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
@@ -24,7 +24,6 @@ egui-phosphor = "0.14"
|
||||
# Markdown documents, drawn as text. No image loading (no `load-images`,
|
||||
# no `fetch`): a document never makes the viewer read a file or the network.
|
||||
egui_commonmark = { version = "0.25", default-features = false, features = ["pulldown_cmark"] }
|
||||
hayro = { version = "0.8", features = ["embed-fonts"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||
notify = "8"
|
||||
jiff = "0.2"
|
||||
|
||||
@@ -100,6 +100,10 @@ impl ledger_package::PackageBooks for View<'_> {
|
||||
Ok(self.0.attachments.clone())
|
||||
}
|
||||
|
||||
fn extractions(&self) -> Result<Vec<ledger_store::Extraction>, StoreError> {
|
||||
Ok(self.0.extractions.clone())
|
||||
}
|
||||
|
||||
fn links_of(&self, attachment_id: u64) -> Result<Vec<Subject>, StoreError> {
|
||||
Ok(self.0.links_of(attachment_id).to_vec())
|
||||
}
|
||||
|
||||
@@ -325,6 +325,9 @@ pub struct Books {
|
||||
pub statements: Vec<Statement>,
|
||||
/// Reconciliations, voided ones included, by date then id.
|
||||
pub reconciliations: Vec<Reconciliation>,
|
||||
/// What reading each file's text found (plan P6): the same records the
|
||||
/// server's Needs attention reads.
|
||||
pub extractions: Vec<ledger_store::Extraction>,
|
||||
/// The format upgrade the books wait for, if they do (plan 3.9, D-39):
|
||||
/// read from their journal, as the server reads it.
|
||||
pub format_upgrade: Option<ledger_store::FormatUpgrade>,
|
||||
@@ -666,6 +669,7 @@ impl Books {
|
||||
}
|
||||
let statements = snap.statements(None).map_err(|e| e.to_string())?;
|
||||
let reconciliations = snap.reconciliations(None).map_err(|e| e.to_string())?;
|
||||
let extractions = snap.extractions().map_err(|e| e.to_string())?;
|
||||
let fresh_from_seq = prev_seq.unwrap_or(journal.len() as u64);
|
||||
let mut fresh = HashSet::new();
|
||||
let closings = ledger.closing_entry_ids();
|
||||
@@ -832,6 +836,7 @@ impl Books {
|
||||
links,
|
||||
statements,
|
||||
reconciliations,
|
||||
extractions,
|
||||
format_upgrade: snap.journal_format().upgrade(),
|
||||
};
|
||||
books.parties = books.party_infos();
|
||||
|
||||
@@ -18,6 +18,9 @@ use ledger_store::{Attachment, Subject};
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
pub category: Option<String>,
|
||||
/// One subject's files only (plan P6 step 6: grouped by category and
|
||||
/// subject).
|
||||
pub subject: Option<Subject>,
|
||||
pub show_withdrawn: bool,
|
||||
pub query: String,
|
||||
}
|
||||
@@ -72,6 +75,38 @@ fn grid(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
|
||||
st.category = Some(c.clone());
|
||||
}
|
||||
}
|
||||
// One subject's files (plan P6 step 6): every subject any
|
||||
// document is evidence of, and the company's own.
|
||||
let mut subjects: Vec<Subject> = Vec::new();
|
||||
for a in &b.attachments {
|
||||
if let Some(e) = a.entry_id {
|
||||
subjects.push(Subject::Entry(e));
|
||||
}
|
||||
subjects.extend(b.links_of(a.id));
|
||||
}
|
||||
subjects.sort();
|
||||
subjects.dedup();
|
||||
egui::ComboBox::from_id_salt("documents-subject")
|
||||
.selected_text(match st.subject {
|
||||
None => "Any subject".to_string(),
|
||||
Some(s) => b.subject_label(s),
|
||||
})
|
||||
.show_ui(ui, |ui| {
|
||||
if ui
|
||||
.selectable_label(st.subject.is_none(), "Any subject")
|
||||
.clicked()
|
||||
{
|
||||
st.subject = None;
|
||||
}
|
||||
for s in subjects {
|
||||
if ui
|
||||
.selectable_label(st.subject == Some(s), b.subject_label(s))
|
||||
.clicked()
|
||||
{
|
||||
st.subject = Some(s);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.checkbox(&mut st.show_withdrawn, "Show withdrawn");
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
@@ -82,6 +117,12 @@ fn grid(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
|
||||
.rev()
|
||||
.filter(|a| st.show_withdrawn || a.withdrawn_at.is_none())
|
||||
.filter(|a| st.category.as_ref().is_none_or(|c| &a.category == c))
|
||||
.filter(|a| match st.subject {
|
||||
None => true,
|
||||
Some(s) => {
|
||||
a.entry_id.map(Subject::Entry) == Some(s) || b.links_of(a.id).contains(&s)
|
||||
}
|
||||
})
|
||||
.filter(|a| {
|
||||
q.is_empty()
|
||||
|| a.file_name.to_lowercase().contains(&q)
|
||||
@@ -101,16 +142,34 @@ fn grid(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
|
||||
let tile_w =
|
||||
((ui.available_width() - 16.0 * (per_row as f32 - 1.0)) / per_row as f32).min(280.0);
|
||||
let mut go = None;
|
||||
for row in docs.chunks(per_row) {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 16.0;
|
||||
for a in row {
|
||||
if tile(ui, b, a, tile_w, selected == Some(a.id), shell).clicked() {
|
||||
go = Some(a.id);
|
||||
// Grouped by category unless one is chosen (plan P6 step 6): a
|
||||
// heading, then its files; the subject filter narrows each group.
|
||||
let by_category = st.category.is_none();
|
||||
let mut groups: std::collections::BTreeMap<String, Vec<&Attachment>> =
|
||||
std::collections::BTreeMap::new();
|
||||
for a in docs {
|
||||
groups.entry(a.category.clone()).or_default().push(a);
|
||||
}
|
||||
for (category, files) in groups {
|
||||
if by_category {
|
||||
ui.label(
|
||||
RichText::new(fmt::label(&category))
|
||||
.font(theme::semibold(12.5))
|
||||
.color(pal(ui.ctx()).muted),
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
for row in files.chunks(per_row) {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 16.0;
|
||||
for a in row {
|
||||
if tile(ui, b, a, tile_w, selected == Some(a.id), shell).clicked() {
|
||||
go = Some(a.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
}
|
||||
}
|
||||
if let Some(id) = go {
|
||||
shell.go(Route::Document(id));
|
||||
@@ -625,6 +684,40 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::test_support::{Build, d, find, render};
|
||||
|
||||
/// The list groups by category and offers one subject's files (plan P6
|
||||
/// step 6).
|
||||
#[test]
|
||||
fn documents_group_by_category_and_subject() {
|
||||
let mut b = Build::new("documents-groups");
|
||||
let (cash, capital) = (b.cash, b.capital);
|
||||
let e = b.post(d(2026, 1, 2), cash, capital, 100_000);
|
||||
let (accounting, _, _) =
|
||||
b.s.file_document(
|
||||
"accounting",
|
||||
"ledger.pdf",
|
||||
"application/pdf",
|
||||
b"%PDF ledger",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
b.s.link_attachment(accounting, ledger_store::Subject::Entry(e))
|
||||
.unwrap();
|
||||
b.s.file_document(
|
||||
"notices",
|
||||
"notice.pdf",
|
||||
"application/pdf",
|
||||
b"%PDF notice",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let (_dir, books) = b.books();
|
||||
let (texts, _) = render(books, Route::Documents, |_| {});
|
||||
for group in ["Receipts", "Accounting", "Notices"] {
|
||||
find(&texts, group);
|
||||
}
|
||||
find(&texts, "Any subject");
|
||||
}
|
||||
|
||||
/// A document's page lists what it is linked to: an entry with its date
|
||||
/// and amount.
|
||||
#[test]
|
||||
|
||||
@@ -391,9 +391,63 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
|
||||
}
|
||||
"attachment.withdrawn" => ev(
|
||||
icon::ARCHIVE,
|
||||
format!("Withdrew {}: {}", attachment_name(b, att), s(p, "reason")),
|
||||
format!(
|
||||
"Withdrew {}{}",
|
||||
attachment_name(b, att),
|
||||
match p.get("duplicate_of").and_then(|v| v.as_u64()) {
|
||||
Some(first) => format!(", a duplicate of #{first}: {}", s(p, "reason")),
|
||||
None => format!(": {}", s(p, "reason")),
|
||||
}
|
||||
),
|
||||
Some(Route::Document(att)),
|
||||
),
|
||||
"attachment.text_extracted" => ev(
|
||||
icon::FILE_TEXT,
|
||||
format!(
|
||||
"Read {}: {}",
|
||||
attachment_name(b, att),
|
||||
match s(p, "status") {
|
||||
"ok" => "its text".to_string(),
|
||||
"none" => "no text to read".to_string(),
|
||||
_ => "the reader failed".to_string(),
|
||||
}
|
||||
),
|
||||
Some(Route::Document(att)),
|
||||
),
|
||||
"attachment.annotated" => ev(
|
||||
icon::PENCIL_SIMPLE,
|
||||
match p
|
||||
.get("fields")
|
||||
.and_then(|f| f.get("receipt_number"))
|
||||
.and_then(|v| v.as_u64())
|
||||
{
|
||||
Some(n) => format!("Annotated {} as receipt {n}", attachment_name(b, att)),
|
||||
None => format!("Annotated {}", attachment_name(b, att)),
|
||||
},
|
||||
Some(Route::Document(att)),
|
||||
),
|
||||
"entry.settles" => {
|
||||
let settles: Vec<String> = p
|
||||
.get("settles")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| v.as_u64())
|
||||
.map(|e| format!("#{e}"))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let text = if settles.is_empty() {
|
||||
format!("Cleared what #{} settles", entry_id.unwrap_or(0))
|
||||
} else {
|
||||
format!(
|
||||
"Entry #{} settles {}",
|
||||
entry_id.unwrap_or(0),
|
||||
settles.join(", ")
|
||||
)
|
||||
};
|
||||
ev(icon::CHECK, text, entry_id.map(Route::Entry))
|
||||
}
|
||||
"account.added" => ev(
|
||||
icon::PLUS_CIRCLE,
|
||||
format!("Added account {} {}", s(p, "code"), s(p, "name")),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Rendering receipts and documents to textures, off the UI thread.
|
||||
//!
|
||||
//! PDFs are rasterized with `hayro` (pure Rust), images decoded with
|
||||
//! `image`; Markdown and plain text are read as UTF-8 and drawn as text
|
||||
//! PDFs are rasterized by the one extractor in `ledger-import` (pure Rust,
|
||||
//! plan P6), images decoded with `image`; Markdown and plain text are read as UTF-8 and drawn as text
|
||||
//! (Markdown by `egui_commonmark`), so they follow the theme and select
|
||||
//! like any text. Results are cached by blob hash: the same bytes never render
|
||||
//! twice, and a hash is the content, so the cache never goes stale. Bytes
|
||||
@@ -61,6 +61,10 @@ pub const MAX_SIDE: f32 = 8192.0;
|
||||
/// lines.
|
||||
const THUMB_TEXT_BYTES: usize = 2048;
|
||||
|
||||
/// A page shown in full is rendered at most this many pixels on its
|
||||
/// longest side: a letter page at twice its points (612 x 792).
|
||||
const PAGE_PX: u32 = 1584;
|
||||
|
||||
/// Where the live viewer keeps thumbnails: the user's cache directory
|
||||
/// (`$XDG_CACHE_HOME`, else `~/.cache`), `wanderbooks/thumbs/v1-560`;
|
||||
/// the version and width name how they were drawn, so a change in either
|
||||
@@ -322,23 +326,27 @@ fn render_thumb(job: &Job, dir: Option<&std::path::Path>) -> Result<Rendered, St
|
||||
return Ok(Rendered::Pages(vec![to_color_image(img.to_rgba8())]));
|
||||
}
|
||||
let img = if job.mime == "application/pdf" || bytes.starts_with(b"%PDF") {
|
||||
let png = pdf_first_page_png(bytes)?;
|
||||
// A page rendered twice the tile's width, then cut down here: the
|
||||
// same pixels a picture is, and one extractor for every reader
|
||||
// (plan P6).
|
||||
let png = ledger_import::extract::page_png(&bytes, 0, (THUMB_PX * 2.0) as u32)
|
||||
.map_err(|e| e.to_string())?;
|
||||
image::load_from_memory(&png).map_err(|e| e.to_string())?
|
||||
} else if job.mime.starts_with("image/") {
|
||||
let img = image::load_from_memory(&bytes).map_err(|e| e.to_string())?;
|
||||
if img.width() as f32 > THUMB_PX {
|
||||
let h = (img.height() as f32 * THUMB_PX / img.width() as f32).max(1.0);
|
||||
img.resize_exact(
|
||||
THUMB_PX as u32,
|
||||
h as u32,
|
||||
image::imageops::FilterType::Triangle,
|
||||
)
|
||||
} else {
|
||||
img
|
||||
}
|
||||
image::load_from_memory(&bytes).map_err(|e| e.to_string())?
|
||||
} else {
|
||||
return Err(format!("{} can't be previewed", job.mime));
|
||||
};
|
||||
let img = if img.width() as f32 > THUMB_PX {
|
||||
let h = (img.height() as f32 * THUMB_PX / img.width() as f32).max(1.0);
|
||||
img.resize_exact(
|
||||
THUMB_PX as u32,
|
||||
h as u32,
|
||||
image::imageops::FilterType::Triangle,
|
||||
)
|
||||
} else {
|
||||
img
|
||||
};
|
||||
let img = img.crop_imm(0, 0, img.width(), img.height().min(THUMB_MAX_H));
|
||||
if let Some(path) = &kept {
|
||||
let mut png = Vec::new();
|
||||
@@ -385,43 +393,13 @@ fn keep(path: &std::path::Path, png: &[u8]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A PDF's first page as a PNG, [`THUMB_PX`] wide.
|
||||
fn pdf_first_page_png(bytes: Vec<u8>) -> Result<Vec<u8>, String> {
|
||||
use hayro::hayro_interpret::InterpreterSettings;
|
||||
use hayro::hayro_syntax::Pdf;
|
||||
use hayro::vello_cpu::color::palette::css::WHITE;
|
||||
use hayro::{PixmapSettings, RenderCache, RenderSettings, render};
|
||||
let pdf = Pdf::new(bytes).map_err(|e| format!("not a readable PDF: {e:?}"))?;
|
||||
let pages = pdf.pages();
|
||||
let page = pages.first().ok_or("the PDF has no pages")?;
|
||||
let (w, h) = page.render_dimensions();
|
||||
// THUMB_PX wide, but never past a texture's side on a long page (the
|
||||
// picture is cut to THUMB_MAX_H after), and never nothing.
|
||||
let scale = (THUMB_PX / w.max(1.0))
|
||||
.min(MAX_SIDE / h.max(1.0))
|
||||
.max(1.0 / w.max(h).max(1.0));
|
||||
let px = PixmapSettings {
|
||||
x_scale: scale,
|
||||
y_scale: scale,
|
||||
bg_color: WHITE,
|
||||
};
|
||||
let pixmap = render(
|
||||
page,
|
||||
&RenderCache::new(),
|
||||
&InterpreterSettings::default(),
|
||||
&RenderSettings::default(),
|
||||
&px,
|
||||
);
|
||||
pixmap.into_png().map_err(|e| format!("{e:?}"))
|
||||
}
|
||||
|
||||
fn render(job: &Job) -> Result<Rendered, String> {
|
||||
let bytes = BlobStore::open_read_only(&job.root)
|
||||
.get(&job.sha)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let is_pdf = job.mime == "application/pdf" || bytes.starts_with(b"%PDF");
|
||||
if is_pdf {
|
||||
render_pdf(bytes).map(Rendered::Pages)
|
||||
render_pdf(&bytes).map(Rendered::Pages)
|
||||
} else if job.mime.starts_with("image/") {
|
||||
let img = image::load_from_memory(&bytes).map_err(|e| e.to_string())?;
|
||||
// Past a texture's side, scaled down to fit (aspect kept).
|
||||
@@ -478,27 +456,15 @@ pub fn text_of(bytes: Vec<u8>) -> Result<String, String> {
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
fn render_pdf(bytes: Vec<u8>) -> Result<Vec<ColorImage>, String> {
|
||||
use hayro::hayro_interpret::InterpreterSettings;
|
||||
use hayro::hayro_syntax::Pdf;
|
||||
use hayro::vello_cpu::color::palette::css::WHITE;
|
||||
use hayro::{PixmapSettings, RenderCache, RenderSettings, render};
|
||||
let pdf = Pdf::new(bytes).map_err(|e| format!("not a readable PDF: {e:?}"))?;
|
||||
let cache = RenderCache::new();
|
||||
let interp = InterpreterSettings::default();
|
||||
let settings = RenderSettings::default();
|
||||
/// A PDF's pages, in order, through the one extractor (`ledger-import`,
|
||||
/// plan P6): each at most [`PAGE_PX`] pixels on its longest side, at most
|
||||
/// [`ledger_import::extract::MAX_PAGES`] pages.
|
||||
fn render_pdf(bytes: &[u8]) -> Result<Vec<ColorImage>, String> {
|
||||
let pages = ledger_import::extract::page_count(bytes).map_err(|e| e.to_string())?;
|
||||
let mut out = Vec::new();
|
||||
for page in pdf.pages().iter().take(30) {
|
||||
// Twice the page's size, but never past a texture's side.
|
||||
let (w, h) = page.render_dimensions();
|
||||
let scale = 2.0f32.min(MAX_SIDE / w.max(h).max(1.0));
|
||||
let px = PixmapSettings {
|
||||
x_scale: scale,
|
||||
y_scale: scale,
|
||||
bg_color: WHITE,
|
||||
};
|
||||
let pixmap = render(page, &cache, &interp, &settings, &px);
|
||||
let png = pixmap.into_png().map_err(|e| format!("{e:?}"))?;
|
||||
for page in 0..pages.min(ledger_import::extract::MAX_PAGES) {
|
||||
let png =
|
||||
ledger_import::extract::page_png(bytes, page, PAGE_PX).map_err(|e| e.to_string())?;
|
||||
let img = image::load_from_memory(&png).map_err(|e| e.to_string())?;
|
||||
out.push(to_color_image(img.to_rgba8()));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user