check / check (push) Canceled after 0s
The owner (2026-10-23): "We should probably just delete the stdio path altogether. THere's no point in that. And it sitting there unused will just lead to bitrot. Nuke it?" Books are served only by the books server (`wanderbooks server`), reached by agents through the bridge (`wanderbooks mcp --book ID`), which stays. Removed: `wanderbooks dev-stdio`, the `ledger-mcp` binary, and `ledger_mcp::run` (stdio and `--http` over a bare data dir), with all that only that mode had: - files the books make coming back inline: `HandedFile.content_base64` is gone, and `download` and `expires_at` are always there; - books with no access log or backups: `books.access_log`, `books.backup` and `books.back_up_now` are always offered, by scope; - the settings file read and reloaded (`legacy_files`), the vault key named by a file or `--vault-key-file`, and the books dir's key-inside-the-books and git checks (with `paths.rs`); - `wire.rs` (the bridge frames an agent's lines itself); - `books.settings`' file fields and `books.start`'s `settings_source`. `BooksServer` is built one way, from `Served` (a book as the books server serves it), in production and in the unit tests. An unknown tool is now a protocol error (mcp-tools.md). Inline file input is unchanged. Then the owner's three decisions (2026-10-23, the question tool): (a) `books.import_settings` is removed entirely (118 tools): old books come in whole by `wanderbooks books adopt`, which reads their settings file once; ledger-config keeps its settings-file parsing only as far as adoption uses it (`load` and the path-reading `effective_receipt_policy` are gone), and `books.chart_template` gives its settings as `books.set_setting` changes. (b) The viewer's loose-books-folder mode is removed (`--data-dir`, `--books-toml`, `--vault-key-file`, the settings file and config.json followed): it opens only books of the data root, reading settings from the journal and the vault from the book's own `vault/` and `keys/`. (c) `wanderbooks dev-stdio` has no stub: it is an unknown command like any other word. Also: an upload's expiry past the calendar is an error, never an empty string. Tests: every suite that drove the stdio server moved to crates/ledger-cli/tests/ and drives a real `wanderbooks server` on a data root of its own (tests/common), handed files by their download; mcp_stdio.rs is mcp_journeys.rs. Tests of what is gone are deleted and listed in docs/testing.md. Docs: plan D-44 (D-18 superseded), STATUS, testing, rules, mcp-interface, the handbook, README; justfile recipes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
223 lines
8.7 KiB
Rust
223 lines
8.7 KiB
Rust
//! The viewer's menu (plan P2 "Viewer menu", D-29): the files the books
|
|
//! make for the human, made by the viewer itself into the Downloads
|
|
//! folder, with their full TINs from the vault. Nothing here writes the
|
|
//! books.
|
|
//!
|
|
//! ```text
|
|
//! GOAL: the owner gets the CPA package and the 1099 worksheet from the
|
|
//! viewer, the same files the agent would hand over.
|
|
//! SOURCE: plan P2 ("Export for CPA" (done by the viewer, into
|
|
//! Downloads)); D-29 ("The viewer's menu makes the same files into
|
|
//! Downloads"); ledger_package (the one builder).
|
|
//! INVARIANTS:
|
|
//! 1. Each is built from a fresh read-only snapshot of the books (its
|
|
//! chain checked when it loads) by ledger_package, the builder the
|
|
//! server uses: the same files, the same figures.
|
|
//! 2. The TINs come from the vault, read-only; a locked vault or a
|
|
//! missing TIN never stops the file: the package lists the gaps.
|
|
//! 3. The file is saved as a new file in Downloads (export::save), never
|
|
//! over one, off the UI thread; what came of it is shown.
|
|
//! ```
|
|
|
|
use crate::books::Books;
|
|
use chrono::Datelike;
|
|
use egui::{RichText, Ui, vec2};
|
|
use egui_phosphor::regular as icon;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::sync::mpsc::{Receiver, channel};
|
|
|
|
/// What a menu job came to: the file saved, and anything to say about it.
|
|
pub type Done = Result<(PathBuf, Option<String>), String>;
|
|
|
|
/// The menu's state: the job under way, and the last one's outcome.
|
|
#[derive(Default)]
|
|
pub struct Menu {
|
|
job: Option<(String, Receiver<Done>)>,
|
|
done: Option<(String, Done)>,
|
|
}
|
|
|
|
impl Menu {
|
|
/// The menu button, its items, and a note of the last job's outcome.
|
|
pub fn ui(&mut self, ui: &mut Ui, books: &Arc<Books>, compact: bool) {
|
|
let p = crate::theme::pal(ui.ctx());
|
|
if let Some((what, rx)) = &self.job
|
|
&& let Ok(done) = rx.try_recv()
|
|
{
|
|
self.done = Some((what.clone(), done));
|
|
self.job = None;
|
|
}
|
|
let text = if compact {
|
|
RichText::new(icon::ARCHIVE).size(16.0).color(p.muted)
|
|
} else {
|
|
RichText::new(format!("{} Export", icon::ARCHIVE))
|
|
.font(crate::theme::regular(12.5))
|
|
.color(p.muted)
|
|
};
|
|
let mut button = egui::Button::new(text).frame(false);
|
|
if compact {
|
|
button = button.min_size(vec2(32.0, ui.spacing().interact_size.y + 4.0));
|
|
}
|
|
let busy = self.job.is_some();
|
|
let year = books.today.year();
|
|
let mut start: Option<Job> = None;
|
|
let response = ui
|
|
.add_enabled_ui(!busy, |ui| {
|
|
egui::containers::menu::MenuButton::from_button(button).ui(ui, |ui| {
|
|
ui.set_min_width(240.0);
|
|
if ui
|
|
.button(format!("{} Export for CPA", icon::PACKAGE))
|
|
.on_hover_text(
|
|
"The CPA package as of today, full TINs included, into your Downloads folder",
|
|
)
|
|
.clicked()
|
|
{
|
|
start = Some(Job::Package);
|
|
}
|
|
ui.separator();
|
|
for y in [year - 1, year] {
|
|
if ui
|
|
.button(format!("{} 1099 worksheet, {y}", icon::FILE_TEXT))
|
|
.on_hover_text(
|
|
"The year's 1099 worksheet as a CSV, full TINs included, into your Downloads folder",
|
|
)
|
|
.clicked()
|
|
{
|
|
start = Some(Job::Worksheet(y));
|
|
}
|
|
}
|
|
})
|
|
})
|
|
.inner;
|
|
if let Some(job) = start {
|
|
self.start(ui.ctx(), books.clone(), job);
|
|
}
|
|
let said = if self.job.is_some() {
|
|
Some(("Making the file…".to_string(), p.muted))
|
|
} else {
|
|
self.done.as_ref().map(|(what, done)| match done {
|
|
Ok((path, None)) => (format!("{what}: saved {}", path.display()), p.muted),
|
|
Ok((path, Some(note))) => (
|
|
format!("{what}: saved {}. {note}", path.display()),
|
|
p.warning,
|
|
),
|
|
Err(why) => (format!("{what} couldn't be made: {why}"), p.warning),
|
|
})
|
|
};
|
|
// Beside the button; in the narrow rail, on hovering it.
|
|
match said {
|
|
Some((text, _)) if compact => {
|
|
response.0.on_hover_text(text);
|
|
}
|
|
Some((text, color)) => {
|
|
ui.add(egui::Label::new(RichText::new(text).size(12.0).color(color)).wrap());
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
|
|
fn start(&mut self, ctx: &egui::Context, books: Arc<Books>, job: Job) {
|
|
let (tx, rx) = channel();
|
|
let what = job.label();
|
|
let ctx = ctx.clone();
|
|
std::thread::spawn(move || {
|
|
let _ = tx.send(job.run(&books));
|
|
ctx.request_repaint();
|
|
});
|
|
self.job = Some((what, rx));
|
|
self.done = None;
|
|
}
|
|
}
|
|
|
|
/// A file the menu makes.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Job {
|
|
/// The CPA package as of the books' today.
|
|
Package,
|
|
/// The 1099 worksheet for a calendar year.
|
|
Worksheet(i32),
|
|
}
|
|
|
|
impl Job {
|
|
fn label(self) -> String {
|
|
match self {
|
|
Job::Package => "The CPA package".to_string(),
|
|
Job::Worksheet(y) => format!("The {y} 1099 worksheet"),
|
|
}
|
|
}
|
|
|
|
/// Make the file and save it (INVARIANTS 1-3).
|
|
pub fn run(self, books: &Books) -> Done {
|
|
let snap = ledger_store::Snapshot::load(&books.data_dir).map_err(|e| e.to_string())?;
|
|
let ledger = snap.ledger();
|
|
// The settings the rest of the viewer shows, read as the server
|
|
// reads them (data::settings).
|
|
let st = &crate::data::settings(&snap);
|
|
let settings = ledger_package::Settings {
|
|
company: &st.company,
|
|
cash_accounts: &st.cash_accounts,
|
|
financing_accounts: &st.financing_accounts,
|
|
investing_accounts: &st.investing_accounts,
|
|
};
|
|
let vault = books.open_vault();
|
|
let tin = |id| ledger_package::vault_tin(ledger, &vault, id);
|
|
match self {
|
|
Job::Package => {
|
|
let p = ledger_package::build_package(
|
|
&snap,
|
|
&settings,
|
|
&ledger_package::PackageRequest {
|
|
as_of: books.today,
|
|
prepared_on: books.today,
|
|
cash_accounts: &[],
|
|
financing_accounts: &[],
|
|
investing_accounts: &[],
|
|
include_receipts: true,
|
|
tins: Some(&tin),
|
|
},
|
|
)?;
|
|
let (stem, ext) = crate::export::split_name(&p.file_name);
|
|
let path = crate::export::save(stem, ext, &p.zip)?;
|
|
let mut notes = Vec::new();
|
|
if !p.tin_gaps.is_empty() {
|
|
notes.push(format!(
|
|
"{} TIN(s) aren't in it (the README says which): ask your agent to collect them.",
|
|
p.tin_gaps.len()
|
|
));
|
|
}
|
|
if p.iif_changes > 0 {
|
|
notes.push(format!(
|
|
"{} account or class name(s), number(s), place(s) or memo(s) are written differently in its QuickBooks file, to fit QuickBooks Desktop: the README says how.",
|
|
p.iif_changes
|
|
));
|
|
}
|
|
let note = (!notes.is_empty()).then(|| notes.join(" "));
|
|
Ok((path, note))
|
|
}
|
|
Job::Worksheet(year) => {
|
|
let w = ledger_package::vendor_1099_worksheet(
|
|
ledger,
|
|
&settings,
|
|
year,
|
|
None,
|
|
None,
|
|
Some(&tin),
|
|
)?;
|
|
let csv = ledger_package::vendor_1099_csv(&w, true);
|
|
let name = ledger_package::vendor_1099_csv_name(year, true);
|
|
let (stem, ext) = crate::export::split_name(&name);
|
|
let path = crate::export::save(stem, ext, csv.as_bytes())?;
|
|
let gaps = w
|
|
.lines
|
|
.iter()
|
|
.filter(|l| l.may_file() && l.tin.is_none())
|
|
.count();
|
|
let note = (gaps > 0).then(|| {
|
|
format!("{gaps} payee(s) a 1099 is or may be due for have no TIN in it: ask your agent to collect them.")
|
|
});
|
|
Ok((path, note))
|
|
}
|
|
}
|
|
}
|
|
}
|