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:
1 parent
b26c099a68
commit
5bc0633cdd
77 files changed
+5833
-2427
No files matched your search
+120
-42
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user