merge: P6, the evidence pipeline (branch P6, checkpoint ce366408)
The last of the paused branches, from its "not for merge" checkpoint:
extraction in ledger-import (text, pages, page images; capped, caught),
journaled as attachment.text_extracted; dedup by hash with
books.find_attachment and books.consolidate_duplicates; annotations and
books.set_entry_settles; the company record categories; the two fuzz
targets; feature attachment_text. Nineteen files conflicted with the
four P4b merges, resolved as plan 8.0.2 says: P6 retakes schema 18 (it
had taken 16, which S4.3 holds; S5 holds 17); the tool table is 129 and
the destructive count 31; both sides' appends kept with their seams
restored; one B-27 paragraph naming every feature; in the old-books
fixture P6's hand-written reimbursement is entry 7 (its ids collided
with S4.3's invoice); the replay fuzzer's list gains S7's
invoice.written_off; S4.3's issued-PDF test expects the extraction
record P6 journals right after a filing.
One integration defect between S7 and P6, found by the arap property
test and fixed here: under P6's dedup, a write-off whose evidence bytes
were filed already (the same collection letter for a second write-off of
the same invoice) was refused only after its entry had been journaled,
at the final link to the invoice ("already linked"). The write-off now
resolves the dedup before anything is journaled and keeps a link the
record already has to the invoice instead of refusing it; an explicit
existing_attachment_id already on an entry is still refused (S7's
one-receipt-once rule). P6's gate never ran before the pause; it runs on
main after this merge, and its ledger-store -> ledger-import dependency
is the open review point (plan 8.0.1).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
commit
2ad192bbcd
70 files changed
+4618
-384
No files matched your search
@@ -138,7 +138,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.file_document",
|
||||
title = "File a document",
|
||||
description = "File a document: category formation, legal, tax, accounting, statement (a bank or card statement: then books.record_statement), contract, photo, warranty, import_source (a bank export), or other. link_to links it to subjects in the same call (an account, an asset, a party, a project, the company: see books.link_attachment), all checked first. The bytes: upload_id, from books.begin_upload and `wanderbooks upload` (a file never passes through you). Never a W-9 (books.set_w9 w9_upload_id puts it in the vault). A file holding a full TIN is refused: one the server read or wrote with TINs, or text with a TIN the vault keeps or an SSN or EIN printed 3-2-4 or 2-7 with hyphens.",
|
||||
description = "File a document: category formation, legal, tax, accounting, statement (then books.record_statement), contract, photo, warranty, import_source (a bank export), notices, correspondence, plans, history, or other. link_to links it to subjects in the same call (see books.link_attachment), all checked first. The bytes: upload_id, from books.begin_upload and `wanderbooks upload` (a file never passes through you). Never a W-9 (books.set_w9). A file holding a full TIN is refused, as is text with one the vault keeps or an SSN or EIN printed 3-2-4 or 2-7.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -152,10 +152,11 @@ impl BooksServer {
|
||||
let p = params.0;
|
||||
match p.category.as_str() {
|
||||
"formation" | "legal" | "tax" | "accounting" | "statement" | "contract" | "photo"
|
||||
| "warranty" | "import_source" | "other" => {}
|
||||
| "warranty" | "import_source" | "notices" | "correspondence" | "plans" | "history"
|
||||
| "other" => {}
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"bad category {:?}: expected formation, legal, tax, accounting, statement, contract, photo, warranty, import_source, or other",
|
||||
"bad category {:?}: expected formation, legal, tax, accounting, statement, contract, photo, warranty, import_source, notices, correspondence, plans, history, or other",
|
||||
p.category
|
||||
));
|
||||
}
|
||||
@@ -454,7 +455,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.get_receipt",
|
||||
title = "Get a receipt",
|
||||
description = "A receipt or document: what it is and everything it is linked to. view bytes adds the file, handed over: run its download (wanderbooks download) to save a copy in a folder you name; its bytes never come back here.",
|
||||
description = "A receipt or document: what it is, what it is linked to, its first 8 KiB of text and its pages. view text gives the whole extracted text (past 64 KiB as a handed-over text file); view page_image with page (1 is the first) gives one page as a PNG, handed over (D-46, D-15: no OCR). view bytes hands over the file itself. Text first; a page picture only when the text is empty or wrong; never read a file's bytes into your context.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
pub(super) async fn get_receipt(
|
||||
@@ -462,29 +463,90 @@ impl BooksServer {
|
||||
params: Parameters<GetReceiptParams>,
|
||||
) -> Result<Json<GetReceiptOut>, String> {
|
||||
let p = params.0;
|
||||
let bytes_wanted = match p.view.as_deref().unwrap_or("meta") {
|
||||
"meta" => false,
|
||||
"bytes" => true,
|
||||
other => {
|
||||
return Err(format!(
|
||||
"view {:?}: meta (the default) or bytes",
|
||||
other.chars().take(20).collect::<String>()
|
||||
));
|
||||
}
|
||||
};
|
||||
let view = p.view.as_deref().unwrap_or("meta");
|
||||
if !matches!(view, "meta" | "text" | "page_image" | "bytes") {
|
||||
return Err(format!(
|
||||
"view {:?}: meta (the default), text, page_image or bytes",
|
||||
view.chars().take(20).collect::<String>()
|
||||
));
|
||||
}
|
||||
let store = self.lock()?;
|
||||
let meta = find_attachment(&store, p.attachment_id)?;
|
||||
let extraction = store.extraction(meta.id).map_err(|e| e.to_string())?;
|
||||
let pages = extraction.as_ref().map_or(0, |x| x.pages);
|
||||
let text_status = extraction
|
||||
.as_ref()
|
||||
.map_or("not_extracted", |x| x.status.key())
|
||||
.to_string();
|
||||
let annotation = store.ledger().annotation(meta.id).cloned();
|
||||
let full_text = store.extraction_text(meta.id).map_err(|e| e.to_string())?;
|
||||
let book = book_name(&store);
|
||||
// Handed over, read hash-verified (D-46): never its bytes here.
|
||||
let file = if bytes_wanted {
|
||||
let bytes = store
|
||||
.attachment_bytes(p.attachment_id)
|
||||
let mut file = None;
|
||||
// Every view carries the meta answer's first 8 KiB of text (view
|
||||
// `text` replaces it with the whole text, or hands a file over).
|
||||
let mut text = None;
|
||||
let mut text_truncated = false;
|
||||
if let Some(t) = &full_text {
|
||||
let cut = floor_char_boundary(t, 8 * 1024);
|
||||
text_truncated = cut < t.len();
|
||||
text = Some(t[..cut].to_string());
|
||||
}
|
||||
match view {
|
||||
"meta" => {}
|
||||
"text" => match full_text {
|
||||
None => {
|
||||
return Err(format!(
|
||||
"attachment {} has no extracted text (text_status {text_status}); view page_image to read its pages",
|
||||
meta.id
|
||||
));
|
||||
}
|
||||
Some(t) if t.len() <= 64 * 1024 => text = Some(t),
|
||||
Some(t) => {
|
||||
// Past 64 KiB the whole text is the handed file, not
|
||||
// the preview.
|
||||
text = None;
|
||||
text_truncated = false;
|
||||
let name = format!("{}-text.txt", stem(&meta.file_name));
|
||||
file = Some(self.hand_over(&book, &name, "text/plain", t.into_bytes())?);
|
||||
}
|
||||
},
|
||||
"page_image" => {
|
||||
let page = p.page.ok_or("view page_image needs page: 1 is the first")?;
|
||||
let max_px = p.max_px.unwrap_or(1600);
|
||||
if !(1..=8192).contains(&max_px) {
|
||||
return Err("max_px must be 1 to 8192".into());
|
||||
}
|
||||
let bytes = store.attachment_bytes(meta.id).map_err(|e| e.to_string())?;
|
||||
if meta.mime_type != "application/pdf" && !bytes.starts_with(b"%PDF") {
|
||||
return Err(format!(
|
||||
"attachment {} isn't a PDF: view bytes for the file itself",
|
||||
meta.id
|
||||
));
|
||||
}
|
||||
let png = ledger_import::extract::page_png(
|
||||
&bytes,
|
||||
usize::try_from(page - 1).unwrap_or(usize::MAX),
|
||||
max_px,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Some(self.hand_over(&book_name(&store), &meta.file_name, &meta.mime_type, bytes)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let name = format!("{}-p{page}.png", stem(&meta.file_name));
|
||||
file = Some(self.hand_over(&book, &name, "image/png", png)?);
|
||||
}
|
||||
"bytes" => {
|
||||
let bytes = store.attachment_bytes(meta.id).map_err(|e| e.to_string())?;
|
||||
file = Some(self.hand_over(&book, &meta.file_name, &meta.mime_type, bytes)?);
|
||||
}
|
||||
_ => unreachable!("view checked"),
|
||||
}
|
||||
Ok(Json(GetReceiptOut {
|
||||
file,
|
||||
text,
|
||||
text_truncated,
|
||||
text_status,
|
||||
pages,
|
||||
receipt_number: annotation.as_ref().and_then(|a| a.fields.receipt_number),
|
||||
annotation: annotation.as_ref().map(annotation_out),
|
||||
links: links_out(&store, meta.id)?,
|
||||
description: meta.description.clone(),
|
||||
attachment_id: meta.id,
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
//! Evidence beyond a file's bytes: finding it, annotating it, and
|
||||
//! consolidating duplicates (plan/served.md P6 steps 4 and 5).
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: a receipt found by what it is or says, the facts a human read off
|
||||
//! it kept on the record, and identical files held once, linked
|
||||
//! wherever they are evidence.
|
||||
//! SOURCE: plan/served.md P6 step 4 (defect 4) and step 5 (defect 10);
|
||||
//! IZ-18 (no FIFO guess: `entry.settles` only); Rev. Proc. 98-25
|
||||
//! (an audit trail); rules.md B-44.
|
||||
//! ```
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A date as the tool takes it.
|
||||
fn date_of(text: &str) -> Result<NaiveDate, String> {
|
||||
NaiveDate::parse_from_str(text.trim(), "%Y-%m-%d")
|
||||
.map_err(|_| format!("{text:?} isn't a date: give YYYY-MM-DD"))
|
||||
}
|
||||
|
||||
#[tool_router(router = evidence_router, vis = "pub(super)")]
|
||||
impl BooksServer {
|
||||
/// ```text
|
||||
/// GOAL: find a filed file by its exact bytes, its receipt number, or
|
||||
/// what its text says.
|
||||
/// SOURCE: plan/served.md P6 step 4; B-44.
|
||||
/// INVARIANTS:
|
||||
/// 1. EXACTLY one of sha256, receipt_number and text is given.
|
||||
/// 2. Withdrawn files are listed too, marked (a duplicate is found
|
||||
/// before it is consolidated).
|
||||
/// 3. A text search is case-insensitive over the extracted text.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF none or more than one is given: SHOULD refuse.
|
||||
/// 2. IF sha256 is not 64 hex digits: SHOULD refuse.
|
||||
/// 3. IF nothing matches: SHOULD answer with an empty list.
|
||||
/// ```
|
||||
#[tool(
|
||||
name = "books.find_attachment",
|
||||
title = "Find a filed file",
|
||||
description = "Find receipts and documents by exactly one of: sha256 (the file's exact hash), receipt_number (what books.annotate_attachment recorded), or text (words its extracted text says, case-insensitively). Withdrawn files are listed too, marked, so a duplicate is found before books.consolidate_duplicates. Each comes with all its links.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn find_attachment(
|
||||
&self,
|
||||
params: Parameters<FindAttachmentParams>,
|
||||
) -> Result<Json<FindAttachmentOut>, String> {
|
||||
let p = params.0;
|
||||
let given = [
|
||||
p.sha256.is_some(),
|
||||
p.receipt_number.is_some(),
|
||||
p.text.is_some(),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|g| *g)
|
||||
.count();
|
||||
if given != 1 {
|
||||
return Err(
|
||||
"give exactly one of sha256, receipt_number or text: they find a file three ways"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let store = self.lock()?;
|
||||
let mut found = if let Some(sha) = p.sha256 {
|
||||
let sha = sha.trim().to_lowercase();
|
||||
if sha.len() != 64 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return Err("sha256 is 64 hexadecimal digits".into());
|
||||
}
|
||||
store
|
||||
.list_attachments(None)
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.filter(|a| a.blob_sha.eq_ignore_ascii_case(&sha))
|
||||
.collect()
|
||||
} else if let Some(n) = p.receipt_number {
|
||||
store
|
||||
.attachments_with_receipt_number(n)
|
||||
.map_err(|e| e.to_string())?
|
||||
} else {
|
||||
let text = p.text.unwrap_or_default();
|
||||
let text = text.trim();
|
||||
if text.chars().count() < 2 {
|
||||
return Err("text is at least two characters".into());
|
||||
}
|
||||
store
|
||||
.attachments_holding_text(text)
|
||||
.map_err(|e| e.to_string())?
|
||||
};
|
||||
found.sort_by_key(|a| a.id);
|
||||
let attachments = found
|
||||
.into_iter()
|
||||
.map(|a| receipt_out(&store, a))
|
||||
.collect::<Result<_, _>>()?;
|
||||
Ok(Json(FindAttachmentOut { attachments }))
|
||||
}
|
||||
|
||||
/// ```text
|
||||
/// GOAL: record what a human read off a file, without touching the file
|
||||
/// or the ledger's amounts.
|
||||
/// SOURCE: plan/served.md P6 step 5 (defect 10); B-44.
|
||||
/// INVARIANTS:
|
||||
/// 1. Journaled as `attachment.annotated`; a refusal journals nothing.
|
||||
/// 2. The fields are the closed list of `AnnotationFieldsParam`; at
|
||||
/// least one field or the note is given.
|
||||
/// 3. A new annotation replaces the file's earlier one; the file
|
||||
/// stays filed and its bytes unchanged.
|
||||
/// 4. The answer names the next receipt number (one past the highest
|
||||
/// annotated).
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the attachment is unknown or withdrawn: SHOULD refuse.
|
||||
/// 2. IF nothing is given, an amount is negative or malformed, or a
|
||||
/// date is outside the calendar: SHOULD refuse.
|
||||
/// ```
|
||||
#[tool(
|
||||
name = "books.annotate_attachment",
|
||||
title = "Annotate a filed file",
|
||||
description = "Record what a human read off a receipt or document: receipt_number, payee, amount (decimal string), date (YYYY-MM-DD), instrument, category, source, reimbursable_to, and a note. It changes no amount and no file; a later annotation replaces this one. The answer gives next_receipt_number (one past the highest annotated), so receipt numbers run without reuse. Amounts are exact decimal strings.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn annotate_attachment(
|
||||
&self,
|
||||
params: Parameters<AnnotateAttachmentParams>,
|
||||
) -> Result<Json<AnnotateAttachmentOut>, String> {
|
||||
let p = params.0;
|
||||
let currency = self.currency();
|
||||
let amount = match p.fields.amount.as_deref() {
|
||||
None => None,
|
||||
Some(a) => Some(a.trim().parse::<Decimal>().map_err(|_| {
|
||||
format!("{a:?} isn't an amount: give a decimal string like \"12.34\"")
|
||||
})?),
|
||||
};
|
||||
let date = match p.fields.date.as_deref() {
|
||||
None => None,
|
||||
Some(d) => Some(date_of(d)?),
|
||||
};
|
||||
let annotation = ledger_core::Annotation {
|
||||
attachment_id: p.attachment_id,
|
||||
fields: ledger_core::AnnotationFields {
|
||||
receipt_number: p.fields.receipt_number,
|
||||
payee: p.fields.payee,
|
||||
amount,
|
||||
date,
|
||||
instrument: p.fields.instrument,
|
||||
category: p.fields.category,
|
||||
source: p.fields.source,
|
||||
reimbursable_to: p.fields.reimbursable_to,
|
||||
},
|
||||
note: p.note,
|
||||
};
|
||||
let _ = currency;
|
||||
let mut store = self.lock()?;
|
||||
let stored = store
|
||||
.annotate_attachment(annotation)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let next = store.ledger().next_receipt_number();
|
||||
Ok(Json(AnnotateAttachmentOut {
|
||||
attachment_id: stored.attachment_id,
|
||||
fields: annotation_out(&stored),
|
||||
next_receipt_number: next,
|
||||
}))
|
||||
}
|
||||
|
||||
/// ```text
|
||||
/// GOAL: record which entries a payment settles, so "the member was
|
||||
/// paid back" is read from the books and never guessed.
|
||||
/// SOURCE: plan/served.md P6 step 5 (IZ-18: no FIFO guess).
|
||||
/// INVARIANTS:
|
||||
/// 1. Journaled as `entry.settles`; a refusal journals nothing.
|
||||
/// 2. Every named entry exists, none is the paying entry itself, none
|
||||
/// is named twice.
|
||||
/// 3. An empty list clears the record.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the paying entry or a settled entry is unknown, or the list
|
||||
/// names the paying entry or one twice: SHOULD refuse.
|
||||
/// ```
|
||||
#[tool(
|
||||
name = "books.set_entry_settles",
|
||||
title = "Record what a payment settles",
|
||||
description = "Record which entries an entry pays back, such as a reimbursement to a member settling the expenses they paid personally. Needs attention stops asking about a member-paid entry once a payment names it here. Empty clears the record. It changes no amount: it names what the payment settles.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn set_entry_settles(
|
||||
&self,
|
||||
params: Parameters<SetEntrySettlesParams>,
|
||||
) -> Result<Json<SetEntrySettlesOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
store
|
||||
.set_entry_settles(p.entry_id, &p.settles)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Json(SetEntrySettlesOut {
|
||||
ok: true,
|
||||
entry_id: p.entry_id,
|
||||
settles: p.settles,
|
||||
}))
|
||||
}
|
||||
|
||||
/// ```text
|
||||
/// GOAL: hold identical files once, linked wherever they were evidence,
|
||||
/// without ever leaving an entry the receipt policy covers with
|
||||
/// none.
|
||||
/// SOURCE: plan/served.md P6 step 4 (defect 4); rules.md B-44.
|
||||
/// INVARIANTS:
|
||||
/// 1. Records sharing one blob SHA are one set; the oldest is kept
|
||||
/// and linked to every subject the others had.
|
||||
/// 2. Every other record is unlinked, then withdrawn as a duplicate
|
||||
/// (`duplicate_of`), journaled; nothing is deleted.
|
||||
/// 3. Every touched entry's evidence is re-checked before the first
|
||||
/// journal append: an entry that would be left with none and
|
||||
/// needs one refuses the whole call, journaling nothing.
|
||||
/// 4. Without `confirmed`, a preview that changes nothing.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the kept record is withdrawn and cannot stand in for a
|
||||
/// duplicate that is a posted entry's only evidence: SHOULD
|
||||
/// refuse (receipt_required).
|
||||
/// 2. IF no two records share bytes: SHOULD answer with no groups.
|
||||
/// ```
|
||||
#[tool(
|
||||
name = "books.consolidate_duplicates",
|
||||
title = "Consolidate duplicate files",
|
||||
description = "Find receipts and documents filed more than once with the same bytes. For each set it keeps the oldest, links it to every subject the others had, unlinks the others, and withdraws them as duplicates of it (nothing is deleted). Refused whole when it would leave a posted entry the receipt policy covers with no evidence. Without confirmed, a preview that changes nothing.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn consolidate_duplicates(
|
||||
&self,
|
||||
params: Parameters<ConsolidateDuplicatesParams>,
|
||||
) -> Result<Json<ConsolidateDuplicatesOut>, String> {
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
let confirmed = params.0.confirmed.unwrap_or(false);
|
||||
let mut store = self.lock()?;
|
||||
let all = store.list_attachments(None).map_err(|e| e.to_string())?;
|
||||
let mut by_sha: BTreeMap<String, Vec<ledger_store::Attachment>> = BTreeMap::new();
|
||||
for a in all {
|
||||
by_sha.entry(a.blob_sha.clone()).or_default().push(a);
|
||||
}
|
||||
// One plan per set of two or more records.
|
||||
struct Group {
|
||||
sha: String,
|
||||
keep: ledger_store::Attachment,
|
||||
dups: Vec<ledger_store::Attachment>,
|
||||
link: BTreeSet<ledger_store::Subject>,
|
||||
unlink: Vec<(u64, ledger_store::Subject)>,
|
||||
}
|
||||
let mut groups: Vec<Group> = Vec::new();
|
||||
for (sha, mut records) in by_sha {
|
||||
if records.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
records.sort_by_key(|a| a.id);
|
||||
let keep = records.remove(0);
|
||||
let mut link: BTreeSet<ledger_store::Subject> = BTreeSet::new();
|
||||
let mut unlink = Vec::new();
|
||||
let mut live_dups = Vec::new();
|
||||
for d in records {
|
||||
if d.withdrawn_at.is_some() {
|
||||
continue;
|
||||
}
|
||||
for sub in subjects_of(&store, &d)? {
|
||||
if !covered(&store, &keep, sub)? {
|
||||
link.insert(sub);
|
||||
}
|
||||
if !matches!(sub, ledger_store::Subject::Entry(e) if Some(e) == d.entry_id) {
|
||||
unlink.push((d.id, sub));
|
||||
}
|
||||
}
|
||||
live_dups.push(d);
|
||||
}
|
||||
if live_dups.is_empty() {
|
||||
continue;
|
||||
}
|
||||
groups.push(Group {
|
||||
sha,
|
||||
keep,
|
||||
dups: live_dups,
|
||||
link,
|
||||
unlink,
|
||||
});
|
||||
}
|
||||
// Consolidating writes `duplicate_of`: the books must read the
|
||||
// feature before anything at all is journaled (plan P6, R-A).
|
||||
if !groups.is_empty() {
|
||||
store
|
||||
.ensure_feature(ledger_store::format::ATTACHMENT_TEXT)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
// The evidence gate, before anything is journaled: an entry that
|
||||
// would be left with none, and needs one, refuses the whole call.
|
||||
for g in &groups {
|
||||
let keep_stands = g.keep.withdrawn_at.is_none();
|
||||
for sub in &g.link {
|
||||
if let ledger_store::Subject::Entry(e) = sub {
|
||||
let mut left = store
|
||||
.list_attachments(Some(*e))
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.filter(|a| a.withdrawn_at.is_none())
|
||||
.filter(|a| !g.dups.iter().any(|d| d.id == a.id))
|
||||
.count();
|
||||
if keep_stands && !covered(&store, &g.keep, *sub)? {
|
||||
left += 1;
|
||||
}
|
||||
if left == 0 {
|
||||
self.ensure_receipt_not_required(&store, *e)
|
||||
.map_err(|why| {
|
||||
format!(
|
||||
"not consolidated: entry {e}'s only evidence is a duplicate, and the kept file can't stand in for it ({why})"
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let out_groups: Vec<DuplicateGroupOut> = groups
|
||||
.iter()
|
||||
.map(|g| DuplicateGroupOut {
|
||||
sha256: g.sha.clone(),
|
||||
keep: g.keep.id,
|
||||
withdraw: g.dups.iter().map(|d| d.id).collect(),
|
||||
links: g.link.iter().map(|s| subject_out(&store, *s)).collect(),
|
||||
})
|
||||
.collect();
|
||||
let withdrawn = out_groups.iter().map(|g| g.withdraw.len()).sum();
|
||||
if !confirmed {
|
||||
return Ok(Json(ConsolidateDuplicatesOut {
|
||||
status: "preview".into(),
|
||||
groups: out_groups,
|
||||
withdrawn,
|
||||
}));
|
||||
}
|
||||
for g in &groups {
|
||||
for sub in &g.link {
|
||||
store
|
||||
.link_attachment(g.keep.id, *sub)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let reason = format!("duplicate of #{}", g.keep.id);
|
||||
for (id, sub) in &g.unlink {
|
||||
// A link a previous step took off (it was on the kept file
|
||||
// already) isn't there to take off again.
|
||||
if store
|
||||
.attachment_links(*id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.contains(sub)
|
||||
{
|
||||
store
|
||||
.unlink_attachment(*id, *sub, &reason)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
for d in &g.dups {
|
||||
if d.entry_id.is_some() {
|
||||
store
|
||||
.unlink_receipt(d.id, &reason)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
store
|
||||
.withdraw_attachment_duplicate(d.id, &reason, g.keep.id)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
Ok(Json(ConsolidateDuplicatesOut {
|
||||
status: "consolidated".into(),
|
||||
groups: out_groups,
|
||||
withdrawn,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Every subject a filing is evidence of: the entry it is filed on, and
|
||||
/// every explicit link.
|
||||
fn subjects_of(
|
||||
store: &Store,
|
||||
a: &ledger_store::Attachment,
|
||||
) -> Result<Vec<ledger_store::Subject>, String> {
|
||||
let mut out: Vec<ledger_store::Subject> = a
|
||||
.entry_id
|
||||
.map(ledger_store::Subject::Entry)
|
||||
.into_iter()
|
||||
.collect();
|
||||
out.extend(store.attachment_links(a.id).map_err(|e| e.to_string())?);
|
||||
out.sort();
|
||||
out.dedup();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Whether `a` is already evidence of `sub`.
|
||||
fn covered(
|
||||
store: &Store,
|
||||
a: &ledger_store::Attachment,
|
||||
sub: ledger_store::Subject,
|
||||
) -> Result<bool, String> {
|
||||
if let ledger_store::Subject::Entry(e) = sub
|
||||
&& a.entry_id == Some(e)
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(store
|
||||
.attachment_links(a.id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.contains(&sub))
|
||||
}
|
||||
@@ -48,13 +48,14 @@ pub(crate) const INLINE_ARGS: [&str; 5] = [
|
||||
|
||||
/// Why `args` can't be taken by `tool` (INVARIANT 5): an argument that
|
||||
/// carries a file's bytes or text. books.begin_upload's own sha256 is the
|
||||
/// file's hash, not its bytes.
|
||||
/// file's hash, not its bytes, and books.find_attachment's is what it
|
||||
/// searches by (plan P6 step 4): neither file passes through the call.
|
||||
pub(crate) fn inline_refusal(
|
||||
tool: &str,
|
||||
args: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||
) -> Option<String> {
|
||||
let given = args?.keys().find(|k| INLINE_ARGS.contains(&k.as_str()))?;
|
||||
if tool == "books.begin_upload" && given == "sha256" {
|
||||
if matches!(tool, "books.begin_upload" | "books.find_attachment") && given == "sha256" {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
@@ -368,6 +369,11 @@ mod tests {
|
||||
inline_refusal("books.begin_upload", Some(&args("sha256"))),
|
||||
None
|
||||
);
|
||||
// A search by a file's hash names no bytes (plan P6).
|
||||
assert_eq!(
|
||||
inline_refusal("books.find_attachment", Some(&args("sha256"))),
|
||||
None
|
||||
);
|
||||
assert!(inline_refusal("books.begin_upload", Some(&args("content_base64"))).is_some());
|
||||
assert_eq!(inline_refusal("books.attach_receipt", None), None);
|
||||
}
|
||||
|
||||
@@ -726,3 +726,41 @@ pub(crate) fn sent_out(s: &ledger_core::arap::Sent) -> SentOut {
|
||||
pub(crate) fn last_sent(sends: &[ledger_core::arap::Sent]) -> Option<String> {
|
||||
sends.iter().map(|s| s.on).max().map(|d| d.to_string())
|
||||
}
|
||||
|
||||
/// An annotation as a tool answers with it (plan P6 step 5).
|
||||
pub(crate) fn annotation_out(a: &ledger_core::Annotation) -> AnnotationOut {
|
||||
AnnotationOut {
|
||||
receipt_number: a.fields.receipt_number,
|
||||
payee: a.fields.payee.clone(),
|
||||
amount: a.fields.amount.map(money),
|
||||
date: a.fields.date.map(|d| d.to_string()),
|
||||
instrument: a.fields.instrument.clone(),
|
||||
category: a.fields.category.clone(),
|
||||
source: a.fields.source.clone(),
|
||||
reimbursable_to: a.fields.reimbursable_to.clone(),
|
||||
note: a.note.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A file name's stem, for a page picture's or a text file's name: what is
|
||||
/// before the last dot, or "attachment" when there is nothing.
|
||||
pub(crate) fn stem(file_name: &str) -> String {
|
||||
let stem = file_name
|
||||
.rsplit_once('.')
|
||||
.map_or(file_name, |(stem, _)| stem)
|
||||
.trim();
|
||||
if stem.is_empty() {
|
||||
"attachment".to_string()
|
||||
} else {
|
||||
stem.chars().take(80).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest character boundary at or below `at` in `text`.
|
||||
pub(crate) fn floor_char_boundary(text: &str, at: usize) -> usize {
|
||||
let mut cut = at.min(text.len());
|
||||
while !text.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
cut
|
||||
}
|
||||
Reference in new issue
Block a user