feat: books.show — the agent puts things on the viewer's screen
books.show asks the viewer to open an entry, an account register, a report (dates, month/quarter/year columns, prior-period or prior-year comparison), a document, a search, or a page. The request lives outside the books (runtime dir, keyed by data dir, written atomically); the viewer watches it beside the journal and applies books before navigating. 66 tools. Site: the dashboard is the hero again; 'Here's what using it looks like' shows the session beside the viewer, ending in books.show; the cash page replaces the repeated overview lower down. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
17 files changed
+626
-22
No files matched your search
@@ -24,6 +24,7 @@ pub mod journal;
|
||||
pub mod schema;
|
||||
pub mod snapshot;
|
||||
pub mod store;
|
||||
pub mod viewer;
|
||||
|
||||
pub use blob::BlobStore;
|
||||
pub use error::StoreError;
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Telling a running viewer what to show.
|
||||
//!
|
||||
//! The agent can ask the viewer (`ledger-gui`) to open an entry, an
|
||||
//! account, a report, a document, or a search. That's not bookkeeping, so
|
||||
//! it never touches the books: the request is a small JSON file outside the
|
||||
//! data directory, in the user's runtime directory, named for the books it
|
||||
//! belongs to. The viewer watches it and navigates; the server writes it.
|
||||
//! A request carries a sequence number so the same one never applies
|
||||
//! twice, and a time so a viewer started later ignores stale ones.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Where to go.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "show", rename_all = "snake_case")]
|
||||
pub enum ViewerTarget {
|
||||
/// A top-level page: overview, cash, profit, transactions, reports,
|
||||
/// accounts, documents, company, journal.
|
||||
Page {
|
||||
/// Page name.
|
||||
page: String,
|
||||
},
|
||||
/// One entry, with its lines, receipt, and history.
|
||||
Entry {
|
||||
/// Entry id.
|
||||
entry_id: u64,
|
||||
},
|
||||
/// An account's register, optionally for a date range.
|
||||
Account {
|
||||
/// Account code.
|
||||
code: String,
|
||||
/// First date, YYYY-MM-DD.
|
||||
start: Option<String>,
|
||||
/// Last date, YYYY-MM-DD.
|
||||
end: Option<String>,
|
||||
},
|
||||
/// A report, optionally for a date range.
|
||||
Report {
|
||||
/// profit_and_loss, balance_sheet, cash_flow, members_equity,
|
||||
/// trial_balance, general_ledger, tax_summary, vendor_1099,
|
||||
/// ar_aging, ap_aging.
|
||||
report: String,
|
||||
/// First date, YYYY-MM-DD.
|
||||
start: Option<String>,
|
||||
/// Last date, YYYY-MM-DD.
|
||||
end: Option<String>,
|
||||
/// Split into columns: total, month, quarter, or year.
|
||||
#[serde(default)]
|
||||
columns: Option<String>,
|
||||
/// Compare with: prior_period or prior_year.
|
||||
#[serde(default)]
|
||||
compare: Option<String>,
|
||||
},
|
||||
/// A stored receipt or document.
|
||||
Document {
|
||||
/// Attachment id.
|
||||
attachment_id: u64,
|
||||
},
|
||||
/// The transactions list, searched.
|
||||
Search {
|
||||
/// Search text: description, account, vendor, amount.
|
||||
query: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// A request for the viewer.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ViewerCommand {
|
||||
/// Increases with every request.
|
||||
pub seq: u64,
|
||||
/// When it was made, RFC 3339.
|
||||
pub at: String,
|
||||
/// Where to go.
|
||||
pub target: ViewerTarget,
|
||||
}
|
||||
|
||||
/// The command file for the books in `data_dir`: in `$XDG_RUNTIME_DIR`
|
||||
/// (or the temp dir), under `wanderbooks/`, named by a hash of the data
|
||||
/// directory's canonical path.
|
||||
pub fn command_path(data_dir: &Path) -> PathBuf {
|
||||
let canonical = data_dir
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| data_dir.to_path_buf());
|
||||
let key = crate::journal::sha256_hex(canonical.to_string_lossy().as_bytes());
|
||||
let base = std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(std::env::temp_dir);
|
||||
base.join("wanderbooks")
|
||||
.join(format!("viewer-{}.json", &key[..16]))
|
||||
}
|
||||
|
||||
/// Ask the viewer for `data_dir` to show `target`. Atomic (write then
|
||||
/// rename), so the viewer never reads half a request.
|
||||
pub fn request(data_dir: &Path, target: ViewerTarget) -> std::io::Result<ViewerCommand> {
|
||||
let path = command_path(data_dir);
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let seq = read(data_dir).map_or(1, |c| c.seq + 1);
|
||||
let cmd = ViewerCommand {
|
||||
seq,
|
||||
at: chrono::Utc::now().to_rfc3339(),
|
||||
target,
|
||||
};
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(
|
||||
&tmp,
|
||||
serde_json::to_vec(&cmd).map_err(std::io::Error::other)?,
|
||||
)?;
|
||||
std::fs::rename(&tmp, &path)?;
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
/// The latest request for `data_dir`, if any.
|
||||
pub fn read(data_dir: &Path) -> Option<ViewerCommand> {
|
||||
let bytes = std::fs::read(command_path(data_dir)).ok()?;
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn requests_round_trip_and_count_up() {
|
||||
let dir = std::env::temp_dir().join(format!("wb-viewer-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let a = request(&dir, ViewerTarget::Entry { entry_id: 7 }).unwrap();
|
||||
let b = request(
|
||||
&dir,
|
||||
ViewerTarget::Search {
|
||||
query: "adobe".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(b.seq, a.seq + 1);
|
||||
assert_eq!(
|
||||
read(&dir).unwrap().target,
|
||||
ViewerTarget::Search {
|
||||
query: "adobe".into()
|
||||
}
|
||||
);
|
||||
// Same books, same file; other books, another.
|
||||
assert_eq!(command_path(&dir), command_path(&dir.join(".")));
|
||||
assert_ne!(command_path(&dir), command_path(&std::env::temp_dir()));
|
||||
let _ = std::fs::remove_file(command_path(&dir));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user