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

+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);