Reports get a Save PDF button beside Copy for a spreadsheet. It typesets the report as it's set up on screen (range, months/quarters/years, comparison) through ledger-import's Typst statement template, the same one the CPA package uses, in the background, writes it to the Downloads folder (never the books: the viewer stays read-only), and opens it. - ledger-import: Statement.percent_from marks percentage columns (comparison %, % of income), typeset as 12.5% with no dollar sign; wide statements turn the page, and past seven columns set smaller so a year of months fits; statement() is public for other callers. - Compared columns that read the same (Jan 1 - Oct 12 vs a year earlier) now say which year, on screen and on paper. - Short windows: the sidebar's and rail's links scroll above the status box instead of under it. The search palette fits the window. - A test typesets every table-backed report from the demo books, plain, by month, and compared (PDF_DIR keeps the files). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
72 lines
2.3 KiB
Rust
72 lines
2.3 KiB
Rust
//! Files the viewer makes for you (report PDFs). They go to the
|
|
//! Downloads folder, never into the books: the viewer stays read-only.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// Where saved files go: `$XDG_DOWNLOAD_DIR`, else `~/Downloads`, else
|
|
/// the home directory.
|
|
pub fn downloads_dir() -> PathBuf {
|
|
if let Some(d) = std::env::var_os("XDG_DOWNLOAD_DIR").map(PathBuf::from)
|
|
&& d.is_dir()
|
|
{
|
|
return d;
|
|
}
|
|
let home = std::env::var_os("HOME")
|
|
.or_else(|| std::env::var_os("USERPROFILE"))
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| PathBuf::from("."));
|
|
let d = home.join("Downloads");
|
|
if d.is_dir() { d } else { home }
|
|
}
|
|
|
|
/// `name` made safe as a file name on every platform.
|
|
pub fn file_name(name: &str, ext: &str) -> String {
|
|
let clean: String = name
|
|
.chars()
|
|
.map(|c| match c {
|
|
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
|
|
c if c.is_control() => ' ',
|
|
c => c,
|
|
})
|
|
.collect();
|
|
format!("{}.{ext}", clean.trim())
|
|
}
|
|
|
|
/// 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.
|
|
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()))?;
|
|
open(&path);
|
|
Ok(path)
|
|
}
|
|
|
|
/// Open a file with whatever the desktop uses for its type. Best effort:
|
|
/// the file is saved either way.
|
|
fn open(path: &Path) {
|
|
#[cfg(target_os = "macos")]
|
|
let cmd = std::process::Command::new("open").arg(path).spawn();
|
|
#[cfg(target_os = "windows")]
|
|
let cmd = std::process::Command::new("cmd")
|
|
.args(["/C", "start", ""])
|
|
.arg(path)
|
|
.spawn();
|
|
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
|
let cmd = std::process::Command::new("xdg-open").arg(path).spawn();
|
|
let _ = cmd;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn file_names_are_safe() {
|
|
assert_eq!(
|
|
file_name("Acme LLC — Profit and loss — Jan 1/2026: draft", "pdf"),
|
|
"Acme LLC — Profit and loss — Jan 1-2026- draft.pdf"
|
|
);
|
|
}
|
|
}
|