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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user