P2 slice 6: the viewer

`wanderbooks gui [BOOK]` finds the data root, reads settings from the
journal, the vault of the book, presence through ops/, Save a copy /
Open on every document, Save the W-9, the Export menu, and colour
schemes. One builder for the CPA package and the 1099 worksheet
(ledger-package), shared by the server and the viewer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-20 11:14:21 -06:00
1 parent b26c099a68
commit 5bc0633cdd
77 files changed
+5833 -2427

No files matched your search

+12 -2
View File
@@ -145,6 +145,8 @@ pub struct App {
palette: crate::palette::Palette,
updated_at: f64,
live_label: bool,
/// The export menu (menu.rs).
menu: crate::menu::Menu,
}
impl App {
@@ -188,6 +190,7 @@ impl App {
palette: Default::default(),
updated_at: -1e9,
live_label: false,
menu: Default::default(),
}
}
@@ -368,9 +371,9 @@ impl App {
}
/// Go where the agent asked (`books.show`).
pub fn show(&mut self, target: ledger_store::viewer::ViewerTarget) {
pub fn show(&mut self, target: ledger_store::ops::ViewerTarget) {
use crate::nav::ReportKind;
use ledger_store::viewer::ViewerTarget as T;
use ledger_store::ops::ViewerTarget as T;
let Some(b) = self.books.clone() else {
return;
};
@@ -638,6 +641,10 @@ impl App {
});
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
self.scheme_menu(ui, false);
if let Some(b) = &books {
ui.add_space(2.0);
self.menu.ui(ui, b, false);
}
ui.add_space(6.0);
if let Some(b) = &books {
live_status(
@@ -733,6 +740,9 @@ impl App {
});
ui.with_layout(Layout::bottom_up(Align::Center), |ui| {
self.scheme_menu(ui, true);
if let Some(b) = &books {
self.menu.ui(ui, b, true);
}
ui.add_space(6.0);
if let Some(b) = &books {
let live = self.loader.is_some() || self.live_label;
-1
View File
@@ -676,7 +676,6 @@ mod tests {
ledger_store::Store::create(&data_dir, ledger_core::Currency::USD).unwrap();
crate::data::load_once(&crate::data::Source {
data_dir,
books_toml: None,
today: Some(today),
})
.unwrap()
+54 -15
View File
@@ -540,10 +540,12 @@ impl Books {
let ledger = snap.ledger().clone();
let attachments = snap.attachments(None).map_err(|e| e.to_string())?;
let journal = snap.journal().to_vec();
// The server's own rule (the books' settings when they keep them,
// else config.json, else wanderbooks.toml), so the viewer flags
// exactly what the server would refuse.
let policy = if ledger.settings().is_journaled() {
// The server's own rule, so the viewer flags exactly what the
// server would refuse: the books' settings; for an older books dir
// that keeps none, its config.json, else its settings file.
let policy = if ledger.settings().is_journaled()
|| ledger_store::root::inside_a_root(snap.data_dir())
{
ledger_config::codec::receipt_policy(ledger.settings())
} else {
ledger_config::effective_receipt_policy(
@@ -1521,14 +1523,34 @@ impl Books {
/// INVARIANT 1: read only on demand (a click), never on load or paint.
/// INVARIANT 2: never creates a key file; a missing key is a locked
/// vault ("restore it from your password manager").
/// INVARIANT 3: the key file in force is the server's: the one given
/// on the command line (--vault-key-file), else a settings file's
/// [vault] key_file, else ~/.config/wanderbooks/vault.key; one
/// inside the books dir is refused, as the server refuses it.
/// INVARIANT 3: the vault and key are the server's: for a book in the
/// data root, its own `vault/` and `keys/legacy.key`; for older
/// books, `vault/` beside `books-data/` and the key file the one
/// given on the command line (--vault-key-file), else the settings'
/// [vault] key_file, else ~/.config/wanderbooks/vault.key, one
/// inside the books dir refused, as the server refuses it.
/// INVARIANT 4: an error never carries the digits (the vault's errors
/// are redacted); a party with no record is "not in the vault".
/// ```
pub fn tin_from_vault(&self, party: PartyId) -> Result<String, String> {
let vault = self.open_vault()?;
match vault.tin(party) {
Ok(Some(t)) => Ok(t.formatted()),
Ok(None) => Err("Not in the vault on this computer.".into()),
Err(e) => Err(e.to_string()),
}
}
/// Where the vault and its key are (INVARIANT 3 of [`Self::tin_from_vault`]).
fn vault_place(&self) -> Result<(PathBuf, PathBuf), String> {
if ledger_store::root::inside_a_root(&self.data_dir) {
return Ok((
self.data_dir.join(ledger_store::root::VAULT_DIR),
self.data_dir
.join(ledger_store::root::KEYS_DIR)
.join(ledger_store::root::LEGACY_KEY_FILE),
));
}
let key = crate::vault_key_file()
.or_else(|| self.settings.vault_key_file.clone())
.unwrap_or_else(ledger_vault::default_key_file);
@@ -1539,7 +1561,22 @@ impl Books {
key.display()
));
}
let vault = ledger_vault::Vault::open(&books.join("vault"), &key, false).map_err(
Ok((books.join("vault"), key))
}
/// A party's signed W-9 file from the vault, on demand (a click).
pub fn w9_file(&self, party: PartyId) -> Result<ledger_vault::W9File, String> {
self.open_vault()?
.w9(party)
.map_err(|e| e.to_string())?
.ok_or_else(|| "The vault holds no W-9 file for this party.".to_string())
}
/// The vault, opened read-only, never making a key (a missing one is a
/// locked vault).
pub fn open_vault(&self) -> Result<ledger_vault::Vault, String> {
let (dir, key) = self.vault_place()?;
ledger_vault::Vault::open(&dir, &key, false).map_err(
|e| match e {
ledger_vault::Error::NoKey(k) => format!(
"The vault is locked: its key file {} is missing. Restore it from your password manager.",
@@ -1547,12 +1584,7 @@ impl Books {
),
other => other.to_string(),
},
)?;
match vault.tin(party) {
Ok(Some(t)) => Ok(t.formatted()),
Ok(None) => Err("Not in the vault on this computer.".into()),
Err(e) => Err(e.to_string()),
}
)
}
/// One entry's what-for, for one party (see [`PartyFlow`] INVARIANT
@@ -1835,6 +1867,13 @@ impl Books {
pub fn blob_root(&self) -> PathBuf {
self.data_dir.join("blobs")
}
/// A stored file's bytes, read only, checked against its SHA-256.
pub fn attachment_bytes(&self, a: &Attachment) -> Result<Vec<u8>, String> {
ledger_store::BlobStore::open_read_only(&self.blob_root())
.get(&a.blob_sha)
.map_err(|e| e.to_string())
}
}
/// An entry flow's what_for apportioned to one party's `share` of its net:
+120 -42
View File
@@ -14,38 +14,79 @@ use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
use std::time::{Duration, SystemTime};
use std::time::Duration;
/// Where the books come from.
/// Where the books come from: their folder (a book of the data root, or
/// an older books dir's `books-data`). Their settings are their own, in
/// their journal (plan 3.6); only an older books dir whose journal keeps
/// none is read as its server reads it, from its settings file (see
/// `settings`).
#[derive(Clone, Debug)]
pub struct Source {
/// The `books-data` directory (journal and blobs).
/// The books' folder (journal and blobs).
pub data_dir: PathBuf,
/// The company's `wanderbooks.toml`, if any.
pub books_toml: Option<PathBuf>,
/// Fix "today" (tests, screenshots); otherwise the local date.
pub today: Option<NaiveDate>,
}
/// The settings file an older books dir's server reads when the books
/// keep no settings of their own: `wanderbooks.toml` beside `books-data`,
/// or the one named with `--books-toml`. Never for a book of the data
/// root.
pub fn legacy_toml(data_dir: &std::path::Path) -> Option<PathBuf> {
legacy_toml_path(data_dir).filter(|p| p.is_file())
}
/// Where an older books dir's settings file is, whether or not it is
/// there yet (a file made later is followed, as the server follows it).
fn legacy_toml_path(data_dir: &std::path::Path) -> Option<PathBuf> {
if ledger_store::root::inside_a_root(data_dir) {
return None;
}
if let Some(named) = LEGACY_TOML.get() {
return Some(named.clone());
}
Some(data_dir.parent()?.join("wanderbooks.toml"))
}
/// `--books-toml`, for an older books dir (once, at start).
static LEGACY_TOML: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
/// Name an older books dir's settings file (`--books-toml`).
pub fn set_legacy_toml(path: PathBuf) {
let _ = LEGACY_TOML.set(path);
}
pub enum Update {
Books(Arc<Books>),
Error(String),
/// The agent asked the viewer to show something (`books.show`).
Show(ledger_store::viewer::ViewerTarget),
Show(ledger_store::ops::ViewerTarget),
}
pub struct Loader {
rx: Receiver<Update>,
/// Set when this is dropped: the loader's thread then ends, letting go
/// of the books (and the viewer's presence on them).
gone: Arc<std::sync::atomic::AtomicBool>,
}
impl Drop for Loader {
fn drop(&mut self) {
self.gone.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
impl Loader {
pub fn spawn(source: Source, ctx: egui::Context) -> Loader {
let (tx, rx) = channel();
let gone = Arc::new(std::sync::atomic::AtomicBool::new(false));
let stop = gone.clone();
std::thread::Builder::new()
.name("books-loader".into())
.spawn(move || run(source, ctx, tx))
.spawn(move || run(source, ctx, tx, &stop))
.expect("spawn loader");
Loader { rx }
Loader { rx, gone }
}
/// Every update since the last call, in order (books before the
@@ -98,30 +139,35 @@ fn today(source: &Source, settings: &BooksSettings) -> NaiveDate {
.unwrap_or_else(|| ledger_core::settings::today_in(settings.timezone.as_deref()))
}
/// The settings: the books' own when they keep them (the settings file is
/// then never read, as the server never reads it), else the settings
/// file's.
/// The settings, as the server reads them: the books' own, from their
/// journal; for an older books dir that keeps none, its settings file
/// (else the defaults). A book of the data root reads no file.
fn settings(source: &Source, snap: &Snapshot) -> Result<BooksSettings, String> {
settings_of(&source.data_dir, snap)
}
/// `settings` for the books in `data_dir`, as `snap` holds them.
pub fn settings_of(data_dir: &std::path::Path, snap: &Snapshot) -> Result<BooksSettings, String> {
let books = snap.ledger().settings();
if books.is_journaled() {
return Ok(ledger_config::codec::books_settings(
books,
snap.ledger().base_currency().code(),
));
}
match &source.books_toml {
Some(p) if p.exists() => ledger_config::load(p),
_ => Ok(BooksSettings::default()),
if !books.is_journaled()
&& let Some(toml) = legacy_toml(data_dir)
&& toml.is_file()
{
return ledger_config::load(&toml);
}
Ok(ledger_config::codec::books_settings(
books,
snap.ledger().base_currency().code(),
))
}
fn mtime(p: &Option<PathBuf>) -> Option<SystemTime> {
p.as_ref()
.and_then(|p| std::fs::metadata(p).ok())
.and_then(|m| m.modified().ok())
}
fn run(source: Source, ctx: egui::Context, tx: Sender<Update>) {
fn run(
source: Source,
ctx: egui::Context,
tx: Sender<Update>,
gone: &std::sync::atomic::AtomicBool,
) {
let gone = || gone.load(std::sync::atomic::Ordering::SeqCst);
let send = |u: Update| {
let _ = tx.send(u);
ctx.request_repaint();
@@ -133,6 +179,9 @@ fn run(source: Source, ctx: egui::Context, tx: Sender<Update>) {
send(Update::Error(problem(&e, || {
format!("Can't read the books in {}", source.data_dir.display())
})));
if gone() {
return;
}
std::thread::sleep(Duration::from_secs(2));
}
}
@@ -151,6 +200,21 @@ fn run(source: Source, ctx: egui::Context, tx: Sender<Update>) {
};
(update, disk)
};
// An older books dir's settings file, followed as its server follows
// it (only read while the books keep no settings of their own).
// And its config.json, whose receipt rule the server reads too.
let toml = legacy_toml_path(&source.data_dir);
let config = (!ledger_store::root::inside_a_root(&source.data_dir))
.then(|| source.data_dir.join("config.json"));
let mtime = |t: &Option<PathBuf>| {
t.as_ref()
.and_then(|p| std::fs::metadata(p).ok())
.and_then(|m| m.modified().ok())
};
let toml_mtime = |t: &Option<PathBuf>| (mtime(t), mtime(&config));
// Taken before the first build, so a file written while it is being
// built is a change, never part of the starting state.
let mut toml_seen = toml_mtime(&toml);
let (first, disk) = build(&snap, None);
let mut last_disk = Some(disk);
send(first);
@@ -161,7 +225,7 @@ fn run(source: Source, ctx: egui::Context, tx: Sender<Update>) {
let started = chrono::Utc::now();
let mut last_show = 0u64;
let mut check_show = |send: &dyn Fn(Update)| {
if let Some(cmd) = ledger_store::viewer::read(&source.data_dir)
if let Some(cmd) = ledger_store::ops::read(&source.data_dir)
&& cmd.seq != last_show
{
let first_look = last_show == 0;
@@ -184,25 +248,38 @@ fn run(source: Source, ctx: egui::Context, tx: Sender<Update>) {
.ok();
if let Some(w) = watcher.as_mut() {
let _ = w.watch(&source.data_dir, RecursiveMode::NonRecursive);
let cmd = ledger_store::viewer::command_path(&source.data_dir);
if let Some(dir) = cmd.parent() {
let _ = std::fs::create_dir_all(dir);
let _ = w.watch(dir, RecursiveMode::NonRecursive);
}
if let Some(t) = &source.books_toml
&& let Some(dir) = t.parent()
{
let _ = w.watch(dir, RecursiveMode::NonRecursive);
}
}
let mut toml_mtime = mtime(&source.books_toml);
// The books' `ops/`, once the server has made it (the viewer makes
// nothing): its show requests watched, and this viewer's presence
// held there while it runs (ledger_store::ops INVARIANT 3).
let ops = ledger_store::ops::ops_dir(&source.data_dir);
let mut ops_watched = false;
let mut presence: Option<ledger_store::ops::Presence> = None;
let mut look_at_ops = |watcher: &mut Option<notify::RecommendedWatcher>| {
if !ops_watched
&& ops.is_dir()
&& let Some(w) = watcher.as_mut()
{
ops_watched = w.watch(&ops, RecursiveMode::NonRecursive).is_ok();
}
if presence.is_none() {
presence = ledger_store::ops::Presence::hold(&source.data_dir);
}
};
look_at_ops(&mut watcher);
let day_now = |snap: &Snapshot| {
settings(&source, snap)
.map(|s| today(&source, &s))
.unwrap_or_else(|_| today(&source, &BooksSettings::default()))
};
let mut day = day_now(&snap);
if let (Some(w), Some(dir)) = (watcher.as_mut(), toml.as_ref().and_then(|t| t.parent())) {
let _ = w.watch(dir, RecursiveMode::NonRecursive);
}
loop {
if gone() {
return;
}
match ev_rx.recv_timeout(Duration::from_secs(3)) {
Ok(()) => {
// Let a burst of writes settle, then take them all at once.
@@ -224,7 +301,8 @@ fn run(source: Source, ctx: egui::Context, tx: Sender<Update>) {
continue;
}
};
let toml_now = mtime(&source.books_toml);
look_at_ops(&mut watcher);
let toml_now = toml_mtime(&toml);
let new_day = day_now(&snap);
// The writer journals first and updates its database right after:
// a "behind" database usually catches up a moment later, so look
@@ -233,9 +311,9 @@ fn run(source: Source, ctx: egui::Context, tx: Sender<Update>) {
&& last_disk
.as_ref()
.is_some_and(|d| *d != ledger_store::DiskCheck::Matches && snap.check_disk() != *d);
if grew || disk_changed || toml_now != toml_mtime || new_day != day {
toml_mtime = toml_now;
if grew || disk_changed || new_day != day || toml_now != toml_seen {
day = new_day;
toml_seen = toml_now;
let (update, disk) = build(&snap, grew.then_some(before));
last_disk = Some(disk);
send(update);
+14 -4
View File
@@ -16,7 +16,8 @@ use ledger_store::{Statement, Store, StoreError, Subject};
use rust_decimal::Decimal;
use std::path::Path;
/// The demo's `wanderbooks.toml`.
/// The demo's settings, written as a `wanderbooks.toml` and imported into
/// its journal, as real books keep theirs.
pub const BOOKS_TOML: &str = r#"[company]
name = "Juniper Studio LLC"
entity_type = "LLC, single-member"
@@ -149,12 +150,21 @@ fn receipt(vendor: &str, date: NaiveDate, item: &str, amount: Decimal, paid: &st
}
/// Build the demo books in `dir` (which must not already hold books): the
/// journal and blobs under `dir/books-data`, the settings in
/// `dir/wanderbooks.toml`.
/// journal and blobs under `dir/books-data`, its settings in the journal.
pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
std::fs::create_dir_all(dir)?;
std::fs::write(dir.join("wanderbooks.toml"), BOOKS_TOML)?;
let mut s = Store::create(&dir.join("books-data"), Currency::USD)?;
let import = ledger_config::codec::import(BOOKS_TOML, None, s.ledger().settings(), "USD")
.map_err(|e| StoreError::Io(std::io::Error::other(e)))?;
s.adopt_settings(
&import.changes,
&ledger_core::settings::Source {
decided_by: "the demo".into(),
decided_on: NaiveDate::from_ymd_opt(2025, 6, 2).expect("a date"),
citation: None,
note: None,
},
)?;
let mut rng = Rng(0x5eed_1234);
let start =
NaiveDate::from_ymd_opt(today.year(), today.month(), 1).expect("valid") - Months::new(15);
+37 -8
View File
@@ -3,9 +3,20 @@
use std::path::{Path, PathBuf};
/// Where saved files go: `$XDG_DOWNLOAD_DIR`, else `~/Downloads`, else
/// the home directory.
/// The folder saved files go to, when set (tests, at start).
static DOWNLOADS: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
/// Name the folder saved files go to (once; tests use their own).
pub fn set_downloads_dir(dir: PathBuf) {
let _ = DOWNLOADS.set(dir);
}
/// Where saved files go: the folder set, else `$XDG_DOWNLOAD_DIR`, else
/// `~/Downloads`, else the home directory.
pub fn downloads_dir() -> PathBuf {
if let Some(d) = DOWNLOADS.get() {
return d.clone();
}
if let Some(d) = std::env::var_os("XDG_DOWNLOAD_DIR").map(PathBuf::from)
&& d.is_dir()
{
@@ -96,19 +107,37 @@ pub fn file_name(name: &str, ext: &str) -> String {
format!("{base}.{ext}")
}
/// Write `bytes` to the downloads folder as `name.ext` (replacing a file
/// of that name: the same report renders the same bytes) and open it with
/// the system's viewer.
/// Save `bytes` in the downloads folder as `name.ext`, made safe: a new
/// file beside any of that name (`name (1).ext`), never over one, never a
/// part of it left under the name (ledger_store::saving).
pub fn save(name: &str, ext: &str, bytes: &[u8]) -> Result<PathBuf, String> {
ledger_store::saving::save_new(
&downloads_dir(),
&file_name(name, ext),
&file_name("wanderbooks file", ext),
bytes,
)
}
/// [`save`], then open it with the system's viewer.
pub fn save_and_open(name: &str, ext: &str, bytes: &[u8]) -> Result<PathBuf, String> {
let path = downloads_dir().join(file_name(name, ext));
std::fs::write(&path, bytes).map_err(|e| format!("{}: {e}", path.display()))?;
let path = save(name, ext, bytes)?;
open(&path);
Ok(path)
}
/// A stored file's name split into the name and its extension, for
/// [`save`]: "receipt.pdf" is ("receipt", "pdf"); with none, "bin".
pub fn split_name(file_name: &str) -> (&str, &str) {
match file_name.rsplit_once('.') {
Some((stem, ext)) if !stem.is_empty() && !ext.is_empty() && ext.len() <= 16 => (stem, ext),
_ => (file_name, "bin"),
}
}
/// Open a file with whatever the desktop uses for its type. Best effort:
/// the file is saved either way.
fn open(path: &Path) {
pub fn open(path: &Path) {
#[cfg(target_os = "macos")]
let cmd = std::process::Command::new("open").arg(path).spawn();
#[cfg(target_os = "windows")]
+65 -22
View File
@@ -11,6 +11,7 @@ pub mod data;
pub mod demo;
pub mod export;
pub mod fmt;
pub mod menu;
pub mod nav;
pub mod pages;
pub mod palette;
@@ -62,23 +63,41 @@ impl std::error::Error for Exit {}
/// `wanderbooks gui`). It opens the books read-only and returns when the
/// window closes. An unknown argument returns [`Exit`] (code 2).
///
/// `ledger-gui [--data-dir PATH] [--books-toml PATH] [--vault-key-file PATH] [--today YYYY-MM-DD] [--demo]`:
/// with no flags it looks for `books-data/` and `wanderbooks.toml` in the
/// current directory; `--demo` builds a demo company in a temp directory
/// and opens that.
/// `ledger-gui [--root PATH] [--book ID] [--data-dir PATH [--books-toml PATH] [--vault-key-file PATH]] [--today YYYY-MM-DD] [--demo]`:
/// a book of the data root (plan 3.6): `--root`, else the root `wanderbooks
/// setup` made, never the current directory or a settings file; `--book`
/// names one, else the root's only one (or the first, by id).
/// `--data-dir` opens an older books dir's `books-data` instead (its
/// settings file `--books-toml`, read while its journal keeps none, else
/// the `wanderbooks.toml` beside it; its vault key `--vault-key-file`);
/// `--demo` builds a demo company in a temp directory and opens that.
pub fn run(
prog: &str,
args: impl IntoIterator<Item = String>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut root: Option<PathBuf> = None;
let mut book: Option<String> = None;
let mut data_dir: Option<PathBuf> = None;
let mut books_toml: Option<PathBuf> = None;
let mut today = None;
let mut demo = false;
let mut args = args.into_iter();
let usage = || {
format!(
"usage: {prog} [--root PATH] [--book ID] [--data-dir PATH [--books-toml PATH] [--vault-key-file PATH]] [--today YYYY-MM-DD] [--demo]"
)
};
while let Some(a) = args.next() {
match a.as_str() {
"--root" => root = args.next().map(PathBuf::from),
"--book" => book = args.next(),
"--data-dir" => data_dir = args.next().map(PathBuf::from),
"--books-toml" => books_toml = args.next().map(PathBuf::from),
// An older books dir's settings file, read only while its
// books keep no settings of their own (data::legacy_toml).
"--books-toml" => {
if let Some(t) = args.next() {
crate::data::set_legacy_toml(PathBuf::from(t));
}
}
"--vault-key-file" => {
if let Some(k) = args.next() {
crate::set_vault_key_file(PathBuf::from(k));
@@ -87,13 +106,12 @@ pub fn run(
"--today" => today = args.next().and_then(|d| d.parse().ok()),
"--demo" => demo = true,
"-h" | "--help" => {
println!(
"usage: {prog} [--data-dir PATH] [--books-toml PATH] [--vault-key-file PATH] [--today YYYY-MM-DD] [--demo]"
);
println!("{}", usage());
return Ok(());
}
other => {
eprintln!("unknown argument {other}");
eprintln!("{}", usage());
return Err(Box::new(Exit(2)));
}
}
@@ -108,21 +126,18 @@ pub fn run(
}
eprintln!("demo books: {}", dir.display());
data_dir = Some(dir.join("books-data"));
books_toml = Some(dir.join("wanderbooks.toml"));
}
let data_dir = data_dir.unwrap_or_else(|| PathBuf::from("books-data"));
let books_toml = books_toml.or_else(|| {
let guess = data_dir
.parent()
.unwrap_or(&data_dir)
.join("wanderbooks.toml");
guess.exists().then_some(guess)
});
let source = Source {
data_dir,
books_toml,
today,
let data_dir = match data_dir {
Some(d) => d,
None => match book_in_root(root.as_deref(), book.as_deref()) {
Ok(d) => d,
Err(why) => {
eprintln!("{why}");
return Err(Box::new(Exit(2)));
}
},
};
let source = Source { data_dir, today };
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("wanderbooks")
@@ -143,6 +158,34 @@ pub fn run(
)?)
}
/// The folder of a book in the data root (plan 3.4): `root`, else the
/// one `wanderbooks setup` made; `book`, else its only book (or the first
/// by id). Why not, in words for the human.
pub fn book_in_root(root: Option<&std::path::Path>, book: Option<&str>) -> Result<PathBuf, String> {
use ledger_store::root::{BookId, Root};
let root = Root::find(root).map_err(|e| {
format!("no books to show: {e}. `wanderbooks setup` makes a place for them.")
})?;
let books = root.list_books().map_err(|e| e.to_string())?;
let id = match book {
Some(b) => {
let id = BookId::parse(b).map_err(|e| e.to_string())?;
if !books.contains(&id) {
return Err(format!(
"there are no books called {:?}: `wanderbooks books list` lists them",
id.as_str()
));
}
id
}
None => books.into_iter().next().ok_or_else(|| {
"there are no books yet: your agent makes them (`wanderbooks books create`)."
.to_string()
})?,
};
Ok(root.book_dir(&id))
}
#[cfg(test)]
mod exit_tests {
use super::Exit;
+214
View File
@@ -0,0 +1,214 @@
//! 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, 32.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: an older books dir that keeps none
// in its journal reads its settings file).
let st = &crate::data::settings_of(&books.data_dir, &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 note = (!p.tin_gaps.is_empty()).then(|| {
format!(
"{} TIN(s) aren't in it (the README says which): ask your agent to collect them.",
p.tin_gaps.len()
)
});
Ok((path, note))
}
Job::Worksheet(year) => {
let w = ledger_package::vendor_1099_worksheet(
ledger,
&settings,
year,
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.reportable && l.tin.is_none())
.count();
let note = (gaps > 0).then(|| {
format!("{gaps} reportable payee(s) have no TIN in it: ask your agent to collect them.")
});
Ok((path, note))
}
}
}
}
+11 -6
View File
@@ -147,13 +147,18 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|_| {},
|ui| {
let kept = b.ledger.settings();
// Where the settings shown come from: a settings
// file only for an older books dir that keeps none
// in its journal and has one (data::settings).
let from_file =
!kept.is_journaled() && crate::data::legacy_toml(&b.data_dir).is_some();
// The currency is always known: facts are the rest.
if c.profile.iter().all(|(k, _)| k == "base_currency") {
ui.label(
RichText::new(if kept.is_journaled() {
"No company facts set yet: the agent records them as you give them."
} else {
RichText::new(if from_file {
"No company facts in the settings file yet: the agent records them as you give them."
} else {
"No company facts set yet: the agent records them as you give them."
})
.color(p.muted),
);
@@ -177,10 +182,10 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
kv(ui, "Time zone", z);
}
ui.label(
RichText::new(if kept.is_journaled() {
"Kept in the books: the agent changes settings, with who decided and when."
} else {
RichText::new(if from_file {
"Read from the settings file: the agent can move it into the books."
} else {
"Kept in the books: the agent changes settings, with who decided and when."
})
.font(theme::regular(12.0))
.color(p.muted),
+40
View File
@@ -253,6 +253,8 @@ fn detail(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>) {
RichText::new(&a.file_name).font(theme::bold(20.0)),
&a.file_name,
);
ui.add_space(6.0);
file_actions(ui, b, a);
ui.add_space(8.0);
egui::Grid::new(("docfacts", a.id))
.num_columns(2)
@@ -324,6 +326,44 @@ fn detail(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>) {
});
}
/// "Save a copy" and "Open" (plan 3.6): a copy in the Downloads folder,
/// never over a file there, opened with the system's app on "Open". The
/// books are only read. What came of it shows under the buttons.
fn file_actions(ui: &mut Ui, b: &Books, a: &Attachment) {
let p = pal(ui.ctx());
let said = ui.id().with(("file-said", a.id));
ui.horizontal(|ui| {
let save = ui
.button(format!("{} Save a copy", icon::FILE_ARROW_UP))
.on_hover_text("A copy in your Downloads folder");
let open = ui
.button(format!("{} Open", icon::ARROW_SQUARE_OUT))
.on_hover_text("A copy in your Downloads folder, opened with its app");
if save.clicked() || open.clicked() {
let result = save_copy(b, a, open.clicked());
ui.data_mut(|d| d.insert_temp(said, result));
}
});
if let Some(result) = ui.data(|d| d.get_temp::<Result<String, String>>(said)) {
let (text, color) = match result {
Ok(path) => (format!("Saved {path}"), p.muted),
Err(why) => (format!("Couldn't save a copy: {why}"), p.warning),
};
ui.add(egui::Label::new(RichText::new(text).size(12.0).color(color)).wrap());
}
}
/// A copy of `a` in the Downloads folder (opened if `open`): its path.
fn save_copy(b: &Books, a: &Attachment, open: bool) -> Result<String, String> {
let bytes = b.attachment_bytes(a)?;
let (stem, ext) = crate::export::split_name(&a.file_name);
let path = crate::export::save(stem, ext, &bytes)?;
if open {
crate::export::open(&path);
}
Ok(path.display().to_string())
}
/// The icon for a subject's kind.
pub fn subject_icon(s: Subject) -> &'static str {
match s {
+31
View File
@@ -1053,6 +1053,9 @@ fn w9_card(ui: &mut Ui, b: &Books, party: &Party) {
h,
);
ui.end_row();
k(ui, "The form");
w9_save(ui, b, party.id);
ui.end_row();
}
});
},
@@ -1563,6 +1566,34 @@ fn months_card(ui: &mut Ui, b: &Books, id: PartyId, span: (NaiveDate, NaiveDate)
);
}
/// "Save the W-9": the signed form from the vault, a copy in the Downloads
/// folder (D-29: the viewer hands out what the agent can), never over a
/// file there; what came of it shows beside the button.
fn w9_save(ui: &mut Ui, b: &Books, party: PartyId) {
let p = pal(ui.ctx());
let said = ui.id().with(("w9-said", party));
ui.vertical(|ui| {
if ui
.button("Save the W-9")
.on_hover_text("A copy of the signed form in your Downloads folder")
.clicked()
{
let result = b.w9_file(party).and_then(|f| {
let (stem, ext) = crate::export::split_name(&f.file_name);
crate::export::save(stem, ext, &f.bytes).map(|path| path.display().to_string())
});
ui.data_mut(|d| d.insert_temp(said, result));
}
if let Some(result) = ui.data(|d| d.get_temp::<Result<String, String>>(said)) {
let (text, color) = match result {
Ok(path) => (format!("Saved {path}"), p.muted),
Err(why) => (why, p.warning),
};
ui.add(egui::Label::new(RichText::new(text).size(12.0).color(color)).wrap());
}
});
}
#[cfg(test)]
mod tests {
use super::*;
-2
View File
@@ -2191,7 +2191,6 @@ mod tests {
crate::demo::build(&dir, today).unwrap();
let b = crate::data::load_once(&crate::data::Source {
data_dir: dir.join("books-data"),
books_toml: Some(dir.join("wanderbooks.toml")),
today: Some(today),
})
.unwrap();
@@ -2257,7 +2256,6 @@ mod tests {
crate::demo::build(&dir, today).unwrap();
let b = crate::data::load_once(&crate::data::Source {
data_dir: dir.join("books-data"),
books_toml: Some(dir.join("wanderbooks.toml")),
today: Some(today),
})
.unwrap();
-1
View File
@@ -968,7 +968,6 @@ mod painted {
crate::demo::build(&dir.0, today).unwrap();
let source = crate::data::Source {
data_dir: dir.0.join("books-data"),
books_toml: Some(dir.0.join("wanderbooks.toml")),
today: Some(today),
};
let entry = crate::data::load_once(&source)
-2
View File
@@ -118,7 +118,6 @@ impl Build {
drop(self.s);
let books = data::load_once(&Source {
data_dir,
books_toml: None,
today: Some(today()),
})
.unwrap();
@@ -270,7 +269,6 @@ pub(crate) fn old_books(name: &str) -> (TempDir, Books) {
std::fs::copy(from.join("journal.jsonl"), data_dir.join("journal.jsonl")).unwrap();
let books = data::load_once(&Source {
data_dir,
books_toml: None,
today: Some(today()),
})
.unwrap();