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
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user