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

+21 -152
View File
@@ -1486,109 +1486,6 @@ fn arrived_whole(headers: &reqwest::header::HeaderMap, body: &[u8]) -> bool {
sent.as_deref() == Some(&<sha2::Sha256 as sha2::Digest>::digest(body)[..])
}
/// Save `body` in `folder` as `named`, or `named (1)`, `named (2)`... the
/// first free; `fallback` if the folder takes no such name. Written aside
/// (a hidden file) and linked in whole, so the name never holds part of
/// it; never over another file. The path it went to.
fn save_new(
folder: &std::path::Path,
named: &str,
fallback: &str,
body: &[u8],
) -> Result<PathBuf, String> {
use std::io::Write;
let fail = |what: &std::path::Path, e: std::io::Error| {
format!("can't write {}: {}", what.display(), e.kind())
};
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.subsec_nanos());
let aside = folder.join(format!(".wanderbooks-{}-{nanos}.part", std::process::id()));
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&aside)
.map_err(|e| fail(folder, e))?;
if let Err(e) = f.write_all(body).and_then(|()| f.sync_all()) {
drop(f);
let _ = std::fs::remove_file(&aside);
return Err(fail(folder, e));
}
drop(f);
let candidates = |name: &str| {
let (stem, ext) = match name.rsplit_once('.') {
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
_ => (name.to_string(), String::new()),
};
let name = name.to_string();
(0..1000).map(move |n| {
if n == 0 {
name.clone()
} else {
format!("{stem} ({n}){ext}")
}
})
};
let mut outcome = Err(format!("{} has no free name for it", folder.display()));
// Why the last name tried was refused, if not for being taken: what
// the human is told when no name is left (a full disk, not a name).
let mut last: Option<std::io::Error> = None;
'names: for name in [named, fallback] {
for candidate in candidates(name) {
let path = folder.join(&candidate);
match std::fs::hard_link(&aside, &path) {
Ok(()) => {
outcome = Ok(path);
break 'names;
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
// A file system with no links (FAT): written in place, never
// over another file, removed if it can't be finished.
Err(e)
if matches!(
e.kind(),
std::io::ErrorKind::Unsupported | std::io::ErrorKind::PermissionDenied
) =>
{
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut f) => {
if let Err(e) = f.write_all(body).and_then(|()| f.sync_all()) {
drop(f);
let _ = std::fs::remove_file(&path);
outcome = Err(fail(&path, e));
last = None;
} else {
outcome = Ok(path);
}
break 'names;
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => {
last = Some(e);
continue 'names;
}
}
}
// A name this folder won't take (too long, a character it
// refuses): the next name.
Err(e) => {
last = Some(e);
continue 'names;
}
}
}
}
let _ = std::fs::remove_file(&aside);
match (outcome, last) {
(Err(_), Some(e)) => Err(fail(folder, e)),
(outcome, _) => outcome,
}
}
/// `wanderbooks download --book ID ATTACHMENT --to FOLDER`: save a copy of
/// a stored file (one too large for books.get_receipt to return).
pub fn download(
@@ -1596,8 +1493,9 @@ pub fn download(
args: impl IntoIterator<Item = std::ffi::OsString>,
) -> Result<(), Box<dyn std::error::Error>> {
let usage = || {
eprintln!("usage: {prog} --book ID [--token NAME] ATTACHMENT --to FOLDER");
eprintln!(" Save a copy of the stored file ATTACHMENT in FOLDER (a new file).");
eprintln!("usage: {prog} --book ID [--token NAME] ATTACHMENT|EXPORT --to FOLDER");
eprintln!(" Save a copy of the stored file ATTACHMENT (a number), or of a file the");
eprintln!(" books made (an EXPORT: ex_ and 32 letters), in FOLDER (a new file).");
};
let Some(a) = file_args(args, &usage)? else {
return Ok(());
@@ -1606,13 +1504,19 @@ pub fn download(
usage();
return Err(exit(2));
};
let Some(attachment) = attachment
.to_str()
.filter(|a| a.parse::<u64>().is_ok_and(|n| n.to_string() == *a))
else {
eprintln!("error: an attachment's id is a number");
return Err(exit(2));
// An attachment's id (a number as the books write it), or an
// export's: each has its own route.
let route = match attachment.to_str() {
Some(a) if a.parse::<u64>().is_ok_and(|n| n.to_string() == a) => "a",
Some(x) if ledger_store::exports::is_id(x) => "x",
_ => {
eprintln!(
"error: that is neither an attachment's id (a number) nor an export's (ex_ and 32 letters)"
);
return Err(exit(2));
}
};
let attachment = attachment.to_str().expect("checked above");
let fail = |why: String| -> Box<dyn std::error::Error> {
eprintln!("error: {why}");
exit(1)
@@ -1621,7 +1525,7 @@ pub fn download(
return Err(fail(format!("{} isn't a folder", to.display())));
}
let (status, headers, body) = on_the_socket(&a.common, &a.book, &a.token, |http, base| {
http.get(format!("{base}/a/{attachment}"))
http.get(format!("{base}/{route}/{attachment}"))
})
.map_err(fail)?;
let path = keep(status, &headers, &body, attachment, &to).map_err(fail)?;
@@ -1647,9 +1551,12 @@ fn keep(
"the file arrived damaged (its SHA-256 isn't the stored one): nothing was saved".into(),
);
}
let fallback = format!("attachment-{attachment}");
let fallback = match attachment.strip_prefix("ex_") {
Some(_) => format!("export-{attachment}"),
None => format!("attachment-{attachment}"),
};
let named = saved_name(headers).unwrap_or_else(|| fallback.clone());
save_new(to, &named, &fallback, body)
ledger_store::saving::save_new(to, &named, &fallback, body)
}
#[cfg(test)]
@@ -1790,44 +1697,6 @@ mod tests {
);
}
#[test]
fn a_saved_copy_is_new_and_whole_beside_what_is_there() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
std::fs::write(d.join("r.pdf"), b"theirs").unwrap();
let first = save_new(d, "r.pdf", "attachment-7", b"ours").unwrap();
assert_eq!(first, d.join("r (1).pdf"));
let second = save_new(d, "r.pdf", "attachment-7", b"ours").unwrap();
assert_eq!(second, d.join("r (2).pdf"));
assert_eq!(std::fs::read(d.join("r.pdf")).unwrap(), b"theirs");
assert_eq!(std::fs::read(&first).unwrap(), b"ours");
// A name the folder won't take: the fallback.
let long = "x".repeat(300);
assert_eq!(
save_new(d, &long, "attachment-7", b"ours").unwrap(),
d.join("attachment-7")
);
// Nothing aside is left.
let mut names: Vec<String> = std::fs::read_dir(d)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
names.sort();
assert_eq!(names, ["attachment-7", "r (1).pdf", "r (2).pdf", "r.pdf"]);
// A folder that can't be written: refused, nothing made.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let ro = d.join("ro");
std::fs::create_dir(&ro).unwrap();
std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o500)).unwrap();
if std::fs::write(ro.join("probe"), b"").is_err() {
assert!(save_new(&ro, "r.pdf", "attachment-7", b"ours").is_err());
}
std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o700)).unwrap();
}
}
#[test]
fn close_reads_the_books_state_for_what_it_is() {
let leases = Ok(Some(HostMsg::Leases {