From 64f3bc4e04c05c8644dc41c13688187764bb418d Mon Sep 17 00:00:00 2001 From: soldforaloss Date: Fri, 17 Jul 2026 19:38:47 -0700 Subject: [PATCH 1/6] feat(f40): annotations engine Row bookmarks, tags and notes (F40) backend: mark and annotate records without touching source data. New annotations.rs owns the model (row marks, row/cell notes, per-document tag namespace with usage counts, author label + created/updated timestamps), a doc_id-keyed store with its own revision, the rematch engine (matched/ambiguous/orphaned) anchored by row_identity (composite KeySpec key or source record + content fingerprint), annotation-state filter predicates, tag-to-column preview+apply as one undoable document op, JSON/CSV export, and the versioned sidecar / project-section persistence envelope. - project.rs: annotations section activated (was reserved) as a typed per-source SourceAnnotations{AnnotationsExport}; no-cell-data scan still passes; round-trip + registration tests. - commands.rs: 19 commands (view/rematch/edit/notes/tags/filter/ tag-to-column/export/sidecar) with revision guards; annotations live in the AnnotationRegistry outside the Document so they survive the whole-document swap on reparse (front end calls annotations_rematch after a reload). - error.rs: StaleAnnotationsRevision guard, independent of data/schema/ dictionary revisions (annotating never dirties the document). Rust gates: fmt + clippy clean; cargo test --lib 643 passed / 0 failed (18 annotation tests). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 17 + src-tauri/src/annotations.rs | 1933 ++++++++++++++++++++++++++++++++++ src-tauri/src/commands.rs | 397 +++++++ src-tauri/src/error.rs | 6 + src-tauri/src/lib.rs | 25 + src-tauri/src/project.rs | 122 ++- 6 files changed, 2482 insertions(+), 18 deletions(-) create mode 100644 src-tauri/src/annotations.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aee0f0..e300f52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,23 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- **Row bookmarks, tags & notes** (F40): mark and annotate records without + touching the source data. Star or flag a row, apply multiple named tags + (a per-document tag namespace with usage counts), and attach a row note or + per-column cell notes with an optional author label and created/updated + timestamps. Annotations are pinned by row identity — a user-selected + composite key (survives reordering; duplicate keys are reported ambiguous) + or, otherwise, the source record number plus a content fingerprint — and are + re-matched on reparse or external change into a matched / ambiguous / + orphaned review list, so a note is never silently attached to an uncertain + row (a deleted row keeps its annotation as an orphan until the delete is + committed or reverted). Filter the grid by annotation state (starred, + flagged, tagged, has-note) through the existing filter view; copy a tag into + a real column as one previewed, undoable operation; and export the + annotations to JSON or CSV on an explicit action. Annotations are stored in + the active project's workspace file or, with no project open, a versioned + `.ceesvee-notes.json` sidecar written atomically — never inside the + CSV, and never in an ordinary data export. - **JSON & JSON Lines interoperability** (palette → "Open JSON…" and "Export as JSON…"): open structured JSON without pre-converting to CSV — an array of objects, an array of arrays, JSON Lines / NDJSON, or an diff --git a/src-tauri/src/annotations.rs b/src-tauri/src/annotations.rs new file mode 100644 index 0000000..d5977d8 --- /dev/null +++ b/src-tauri/src/annotations.rs @@ -0,0 +1,1933 @@ +//! Row bookmarks, tags and notes (F40): mark and annotate records WITHOUT +//! touching source data. +//! +//! Annotations are pure metadata. They live in an [`AnnotationStore`] keyed by +//! document id (managed by Tauri, [`AnnotationRegistry`]) — deliberately OUTSIDE +//! the [`crate::document::Document`], so they survive the whole-`Document` +//! replacement a reparse performs (the id stays the same) and never make the +//! document dirty or enter its undo stack. Persistence is either the active +//! project's `annotations` section (F37) or a document-specific sidecar file +//! (`.ceesvee-notes.json`); they are NEVER written into the CSV unless +//! explicitly exported. +//! +//! ## Row identity (built on [`crate::row_identity`]) +//! +//! Every annotated row is pinned by a [`RowAnchor`]: a [`RowIdentity`] plus the +//! row's content fingerprint captured at annotation time. Two anchoring +//! mechanisms are produced, in order of strength: +//! +//! 1. **Composite key** — when the store carries a [`KeySpec`] (user-selected +//! key columns), a new anchor is a normalized [`CompositeKey`]. Survives row +//! reordering; a duplicated key is reported ambiguous, never silently +//! first-wins. +//! 2. **Source record + content fingerprint** — otherwise the anchor is the +//! 0-based record number plus a SHA-256 of the row. On reparse or edit the +//! [`rematch`](AnnotationStore::rematch) engine verifies the record still +//! holds the same content, and otherwise searches for the content elsewhere +//! (a unique hit re-attaches; multiple hits are ambiguous; none is orphaned). +//! +//! A brand-new editable document's rows have no distinguishing content, so a +//! blank row that moves cannot be re-found — it is reported orphaned rather than +//! silently mis-attached. (The [`crate::row_identity::RowIds`] editor-id +//! mechanism that would pin such rows exactly is intentionally NOT wired into +//! the document's mutation paths in this stage — see the module notes in the +//! handoff.) The engine's contract is the same either way: **never silently +//! attach a note to an uncertain row.** +//! +//! ## What this module owns +//! +//! the annotation model (row marks, notes, cell notes, the tag namespace with +//! usage counts, author label and created/updated timestamps), the store with +//! its own revision, the rematch engine (matched / ambiguous / orphaned), the +//! annotation-state filter predicates, tag-to-column preview + application (via +//! the document's existing batched ops, one undo group), JSON/CSV export, and +//! the versioned sidecar / project-section persistence envelope. + +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::error::{AppError, AppResult}; +use crate::job::JobCtx; +use crate::row_identity::{ + build_key_index, composite_key, row_content_hash_hex, CompositeKey, KeySpec, RowIdentity, +}; +use crate::tabular::{TabularColumn, TabularSource}; + +/// Import/export + sidecar/project-section envelope version. Bumped only on an +/// incompatible format change; unknown fields within a version are tolerated. +pub const ANNOTATIONS_VERSION: u32 = 1; + +/// Canonical suffix of a document-specific sidecar file, appended to the full +/// source file name (`orders.csv` → `orders.csv.ceesvee-notes.json`). +pub const SIDECAR_SUFFIX: &str = ".ceesvee-notes.json"; + +/// Wall-clock milliseconds since the Unix epoch (0 on a clock error). +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +// --------------------------------------------------------------------------- +// Model +// --------------------------------------------------------------------------- + +/// A dated free-text note with an optional author label. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Note { + pub text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub author: Option, + pub created_ms: u64, + pub updated_ms: u64, +} + +impl Note { + fn new(text: String, author: Option) -> Note { + let now = now_ms(); + Note { + text, + author, + created_ms: now, + updated_ms: now, + } + } + + /// Update text (and author), preserving the original creation time. + fn edit(&mut self, text: String, author: Option) { + self.text = text; + self.author = author; + self.updated_ms = now_ms(); + } +} + +/// How one annotated row is pinned to a record, plus the content fingerprint +/// (hex SHA-256) captured at annotation time for the record-anchor rematch. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RowAnchor { + pub identity: RowIdentity, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_hash: Option, +} + +impl RowAnchor { + /// Short wire tag for the anchoring mechanism ("key" / "record" / "editor"). + pub fn kind(&self) -> &'static str { + match self.identity { + RowIdentity::Key { .. } => "key", + RowIdentity::SourceRecord { .. } => "record", + RowIdentity::EditorRow { .. } => "editor", + } + } +} + +/// One annotated row: its anchor, marks (star / flag / tags / row note), any +/// per-column cell notes, and timestamps. Kept only while it carries at least +/// one live annotation ([`RowEntry::is_empty`] prunes it otherwise). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RowEntry { + /// Stable in-session handle, preserved across rematches and round-trips. + pub handle: u64, + pub anchor: RowAnchor, + #[serde(default)] + pub star: bool, + #[serde(default)] + pub flag: bool, + /// Tag names applied to this row (deduped, insertion order). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, + /// Per-column notes keyed by stable column id. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub cell_notes: BTreeMap, + pub created_ms: u64, + pub updated_ms: u64, +} + +impl RowEntry { + fn new(handle: u64, anchor: RowAnchor) -> RowEntry { + let now = now_ms(); + RowEntry { + handle, + anchor, + star: false, + flag: false, + tags: Vec::new(), + note: None, + cell_notes: BTreeMap::new(), + created_ms: now, + updated_ms: now, + } + } + + /// Whether the entry carries no annotation at all (safe to drop). + pub fn is_empty(&self) -> bool { + !self.star + && !self.flag + && self.tags.is_empty() + && self.note.is_none() + && self.cell_notes.is_empty() + } + + fn touch(&mut self) { + self.updated_ms = now_ms(); + } +} + +/// A tag definition in the per-document namespace: a name plus optional +/// presentation. Usage counts are computed from the row entries, never stored. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TagDef { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +/// The star/flag/tag mark edit applied to a row in one call. Absent fields are +/// left unchanged; `add_tags` / `remove_tags` mutate the tag set. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct RowMarkPatch { + pub star: Option, + pub flag: Option, + pub add_tags: Vec, + pub remove_tags: Vec, +} + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +/// One document's annotations: the row entries, the tag namespace, an optional +/// key spec (turns new anchors into composite keys), a default author label and +/// its own revision. The revision moves on every annotation edit and is the +/// guard for deferred annotation operations — independent of the document's +/// data/schema/dictionary revisions, so annotating never dirties the document. +#[derive(Debug, Clone, Default)] +pub struct AnnotationStore { + revision: u64, + author: Option, + key_spec: Option, + tags: BTreeMap, + /// Row entries keyed by their stable handle. + rows: BTreeMap, + next_handle: u64, +} + +impl AnnotationStore { + pub fn revision(&self) -> u64 { + self.revision + } + + pub fn author(&self) -> Option<&str> { + self.author.as_deref() + } + + pub fn key_spec(&self) -> Option<&KeySpec> { + self.key_spec.as_ref() + } + + pub fn is_empty(&self) -> bool { + self.rows.is_empty() && self.tags.is_empty() + } + + /// Guard an annotation-dependent deferred edit: fail with + /// [`AppError::StaleAnnotationsRevision`] when the store moved since + /// `expected` was captured. + pub fn check_revision(&self, expected: u64) -> AppResult<()> { + if self.revision == expected { + Ok(()) + } else { + Err(AppError::StaleAnnotationsRevision { + expected, + actual: self.revision, + }) + } + } + + fn bump(&mut self) { + self.revision += 1; + } + + /// Set (or clear, with `None`) the default author label carried on new + /// notes. Existing notes are untouched. + pub fn set_author(&mut self, author: Option) { + let author = author.and_then(|a| { + let t = a.trim(); + (!t.is_empty()).then(|| t.to_string()) + }); + if self.author != author { + self.author = author; + self.bump(); + } + } + + /// Set (or clear, with `None`) the key columns used to anchor NEW + /// annotations. Existing anchors keep their captured form until re-anchored + /// by a rematch that upgrades them — see [`AnnotationStore::reanchor`]. + pub fn set_key_spec(&mut self, key_spec: Option) { + let key_spec = key_spec.filter(|k| !k.columns.is_empty()); + if self.key_spec != key_spec { + self.key_spec = key_spec; + self.bump(); + } + } + + // ----- tag namespace --------------------------------------------------- + + /// Define or update a tag in the namespace. + pub fn define_tag(&mut self, def: TagDef) -> AppResult<()> { + let name = def.name.trim().to_string(); + if name.is_empty() { + return Err(AppError::invalid("a tag needs a name")); + } + self.tags.insert( + name.clone(), + TagDef { + name, + color: def.color, + description: def.description, + }, + ); + self.bump(); + Ok(()) + } + + /// Remove a tag from the namespace AND from every row that carries it. + pub fn remove_tag(&mut self, name: &str) { + let existed = self.tags.remove(name).is_some(); + let mut changed = existed; + let mut emptied = Vec::new(); + for (handle, entry) in self.rows.iter_mut() { + let before = entry.tags.len(); + entry.tags.retain(|t| t != name); + if entry.tags.len() != before { + entry.touch(); + changed = true; + if entry.is_empty() { + emptied.push(*handle); + } + } + } + for handle in emptied { + self.rows.remove(&handle); + } + if changed { + self.bump(); + } + } + + /// Ensure a tag exists in the namespace (auto-created when first applied). + fn ensure_tag(&mut self, name: &str) { + self.tags.entry(name.to_string()).or_insert_with(|| TagDef { + name: name.to_string(), + color: None, + description: None, + }); + } + + // ----- row lifecycle --------------------------------------------------- + + fn fresh_handle(&mut self) -> u64 { + let h = self.next_handle; + self.next_handle += 1; + h + } + + /// Resolve a key column spec to positions in `columns` (by stable id). + fn key_positions(columns: &[TabularColumn], spec: &KeySpec) -> AppResult> { + let mut positions = Vec::with_capacity(spec.columns.len()); + for id in &spec.columns { + let pos = columns + .iter() + .position(|c| c.id.as_deref() == Some(id.as_str())) + .ok_or_else(|| { + AppError::invalid(format!( + "annotation key column '{id}' does not exist in the source" + )) + })?; + positions.push(pos); + } + Ok(positions) + } + + /// Capture an anchor for the row at absolute `record`: a composite key when + /// a key spec is set, else the record number; content fingerprint always. + pub fn capture_anchor( + &self, + source: &dyn TabularSource, + record: u64, + ctx: Option<&JobCtx>, + ) -> AppResult { + let row = source + .read_rows(record, 1, ctx)? + .into_iter() + .next() + .ok_or_else(|| AppError::invalid("row is out of range"))?; + let content_hash = Some(row_content_hash_hex(&row)); + let identity = match &self.key_spec { + Some(spec) => { + let positions = Self::key_positions(&source.columns(), spec)?; + RowIdentity::Key { + key: composite_key(&row, &positions, &spec.normalization), + } + } + None => RowIdentity::SourceRecord { record }, + }; + Ok(RowAnchor { + identity, + content_hash, + }) + } + + /// The handle of the entry currently resolved to absolute `record`, if any. + fn handle_at(resolution: &Resolution, record: u64) -> Option { + resolution.by_handle.iter().find_map(|(handle, r)| { + (r.status == MatchStatus::Matched && r.record == Some(record)).then_some(*handle) + }) + } + + /// The entry attached to absolute `record`, creating a fresh one (with a + /// captured anchor) when none exists there. Rematches first so the lookup + /// reflects the current document state. + fn entry_for_record( + &mut self, + source: &dyn TabularSource, + record: u64, + ctx: Option<&JobCtx>, + ) -> AppResult { + let resolution = self.rematch(source, ctx)?; + if let Some(handle) = Self::handle_at(&resolution, record) { + return Ok(handle); + } + let anchor = self.capture_anchor(source, record, ctx)?; + let handle = self.fresh_handle(); + self.rows.insert(handle, RowEntry::new(handle, anchor)); + Ok(handle) + } + + fn prune_if_empty(&mut self, handle: u64) { + if self.rows.get(&handle).is_some_and(RowEntry::is_empty) { + self.rows.remove(&handle); + } + } + + // ----- row edits ------------------------------------------------------- + + /// Apply a star/flag/tag mark patch to the row at absolute `record`. + pub fn edit_row_marks( + &mut self, + source: &dyn TabularSource, + record: u64, + patch: &RowMarkPatch, + ctx: Option<&JobCtx>, + ) -> AppResult<()> { + let handle = self.entry_for_record(source, record, ctx)?; + for tag in &patch.add_tags { + let tag = tag.trim(); + if !tag.is_empty() { + self.ensure_tag(tag); + } + } + let entry = self.rows.get_mut(&handle).expect("just created/found"); + if let Some(star) = patch.star { + entry.star = star; + } + if let Some(flag) = patch.flag { + entry.flag = flag; + } + for tag in &patch.add_tags { + let tag = tag.trim().to_string(); + if !tag.is_empty() && !entry.tags.contains(&tag) { + entry.tags.push(tag); + } + } + if !patch.remove_tags.is_empty() { + entry.tags.retain(|t| !patch.remove_tags.contains(t)); + } + entry.touch(); + self.prune_if_empty(handle); + self.bump(); + Ok(()) + } + + /// Set (`Some`) or clear (`None`) the ROW note on the row at `record`. + pub fn set_row_note( + &mut self, + source: &dyn TabularSource, + record: u64, + text: Option, + author: Option, + ctx: Option<&JobCtx>, + ) -> AppResult<()> { + let author = author.or_else(|| self.author.clone()); + let handle = self.entry_for_record(source, record, ctx)?; + let entry = self.rows.get_mut(&handle).expect("just created/found"); + apply_note(&mut entry.note, text, author); + entry.touch(); + self.prune_if_empty(handle); + self.bump(); + Ok(()) + } + + /// Set (`Some`) or clear (`None`) a CELL note on `column_id` of the row at + /// `record`. + pub fn set_cell_note( + &mut self, + source: &dyn TabularSource, + record: u64, + column_id: &str, + text: Option, + author: Option, + ctx: Option<&JobCtx>, + ) -> AppResult<()> { + if column_id.trim().is_empty() { + return Err(AppError::invalid("a cell note needs a column id")); + } + let author = author.or_else(|| self.author.clone()); + let handle = self.entry_for_record(source, record, ctx)?; + let entry = self.rows.get_mut(&handle).expect("just created/found"); + match text { + Some(text) if !text.trim().is_empty() => match entry.cell_notes.get_mut(column_id) { + Some(note) => note.edit(text, author), + None => { + entry + .cell_notes + .insert(column_id.to_string(), Note::new(text, author)); + } + }, + _ => { + entry.cell_notes.remove(column_id); + } + } + entry.touch(); + self.prune_if_empty(handle); + self.bump(); + Ok(()) + } + + /// Delete one whole row entry (by handle). Returns whether it existed. + pub fn remove_row(&mut self, handle: u64) -> bool { + let removed = self.rows.remove(&handle).is_some(); + if removed { + self.bump(); + } + removed + } + + /// Discard every orphaned entry (identified against the current source). + pub fn discard_orphans( + &mut self, + source: &dyn TabularSource, + ctx: Option<&JobCtx>, + ) -> AppResult { + let resolution = self.rematch(source, ctx)?; + let orphans: Vec = resolution + .by_handle + .iter() + .filter(|(_, r)| r.status == MatchStatus::Orphaned) + .map(|(h, _)| *h) + .collect(); + for handle in &orphans { + self.rows.remove(handle); + } + if !orphans.is_empty() { + self.bump(); + } + Ok(orphans.len()) + } + + /// Re-capture every MATCHED entry's anchor against the current source under + /// the current key spec — the way a newly-set key spec is adopted by + /// existing annotations. Ambiguous / orphaned entries keep their anchor so + /// the review list still explains them. + pub fn reanchor(&mut self, source: &dyn TabularSource, ctx: Option<&JobCtx>) -> AppResult<()> { + let resolution = self.rematch(source, ctx)?; + let mut updates: Vec<(u64, RowAnchor)> = Vec::new(); + for (handle, res) in &resolution.by_handle { + if res.status == MatchStatus::Matched { + if let Some(record) = res.record { + updates.push((*handle, self.capture_anchor(source, record, ctx)?)); + } + } + } + let mut changed = false; + for (handle, anchor) in updates { + if let Some(entry) = self.rows.get_mut(&handle) { + if entry.anchor != anchor { + entry.anchor = anchor; + changed = true; + } + } + } + if changed { + self.bump(); + } + Ok(()) + } + + // ----- rematch engine -------------------------------------------------- + + /// Resolve every entry against the current `source`: matched (unique row), + /// ambiguous (duplicate key / duplicate content) or orphaned (no row). + /// A pure read of `self` + `source`; callers persist nothing from it beyond + /// the returned map. + pub fn rematch( + &self, + source: &dyn TabularSource, + ctx: Option<&JobCtx>, + ) -> AppResult { + let mut by_handle: BTreeMap = BTreeMap::new(); + + // A key index is needed only when some entry is key-anchored AND a key + // spec is configured. Without a spec, key anchors cannot be resolved. + let needs_key = self + .rows + .values() + .any(|e| matches!(e.anchor.identity, RowIdentity::Key { .. })); + let key_index = match (needs_key, &self.key_spec) { + (true, Some(spec)) => Some(build_key_index(source, spec, ctx)?), + _ => None, + }; + + // Record anchors: verify the original record still holds the captured + // content before any scan; collect the ones that drifted. + let mut drifted: Vec<(u64, String)> = Vec::new(); + for (handle, entry) in &self.rows { + match &entry.anchor.identity { + RowIdentity::Key { key } => { + by_handle.insert(*handle, resolve_key(key, key_index.as_ref())); + } + RowIdentity::SourceRecord { record } => { + let resolved = self.resolve_record_direct(source, *record, entry, ctx)?; + match resolved { + Some(res) => { + by_handle.insert(*handle, res); + } + None => match &entry.anchor.content_hash { + // Original record drifted; defer to a content search. + Some(hash) => drifted.push((*handle, hash.clone())), + None => { + by_handle.insert(*handle, ResolvedRow::orphaned()); + } + }, + } + } + // Editor-row anchors are not produced in this stage (the RowIds + // mechanism is not wired into the document); treat as orphaned. + RowIdentity::EditorRow { .. } => { + by_handle.insert(*handle, ResolvedRow::orphaned()); + } + } + } + + // One content-hash scan resolves everything that drifted. + if !drifted.is_empty() { + let content = self.content_index(source, ctx)?; + for (handle, hash) in drifted { + let res = match content.get(&hash).map(Vec::as_slice) { + None | Some([]) => ResolvedRow::orphaned(), + Some([one]) => ResolvedRow::matched(*one), + Some(many) => ResolvedRow::ambiguous(many.to_vec()), + }; + by_handle.insert(handle, res); + } + } + + Ok(Resolution { by_handle }) + } + + /// Resolve a record anchor by re-reading its original record: `Some(row)` + /// when the content still matches (or no hash was stored), `None` when it + /// drifted (caller falls back to a content search). + fn resolve_record_direct( + &self, + source: &dyn TabularSource, + record: u64, + entry: &RowEntry, + ctx: Option<&JobCtx>, + ) -> AppResult> { + let row = source.read_rows(record, 1, ctx)?.into_iter().next(); + let Some(row) = row else { + return Ok(None); // record past the end now + }; + match &entry.anchor.content_hash { + Some(hash) if &row_content_hash_hex(&row) != hash => Ok(None), + _ => Ok(Some(ResolvedRow::matched(record))), + } + } + + /// Content-hash → record numbers over the whole source (one streamed pass). + fn content_index( + &self, + source: &dyn TabularSource, + ctx: Option<&JobCtx>, + ) -> AppResult>> { + use crate::tabular::DEFAULT_WINDOW; + let mut map: HashMap> = HashMap::new(); + let mut offset = 0u64; + loop { + let rows = source.read_rows(offset, DEFAULT_WINDOW, ctx)?; + if rows.is_empty() { + break; + } + for (i, row) in rows.iter().enumerate() { + map.entry(row_content_hash_hex(row)) + .or_default() + .push(offset + i as u64); + } + let n = rows.len(); + offset += n as u64; + if n < DEFAULT_WINDOW { + break; + } + } + Ok(map) + } + + // ----- read views (over a resolution) ---------------------------------- + + /// Tag namespace with per-tag usage counts across all row entries. + fn tag_usage(&self) -> Vec { + let mut counts: HashMap<&str, usize> = HashMap::new(); + for entry in self.rows.values() { + for tag in &entry.tags { + *counts.entry(tag.as_str()).or_default() += 1; + } + } + self.tags + .values() + .map(|def| TagUsage { + name: def.name.clone(), + color: def.color.clone(), + description: def.description.clone(), + count: counts.get(def.name.as_str()).copied().unwrap_or(0), + }) + .collect() + } + + /// The panel surface: every annotation with its current resolution status + /// and resolved record (for jump-to-row), plus the tag namespace and + /// match tallies. `doc_revision` is echoed for guarding downstream + /// document ops (filter, tag-to-column). + pub fn view( + &self, + source: &dyn TabularSource, + doc_revision: u64, + ctx: Option<&JobCtx>, + ) -> AppResult { + let resolution = self.rematch(source, ctx)?; + let (mut matched, mut ambiguous, mut orphaned) = (0usize, 0usize, 0usize); + let mut entries: Vec = Vec::with_capacity(self.rows.len()); + for (handle, entry) in &self.rows { + let res = resolution + .by_handle + .get(handle) + .cloned() + .unwrap_or_else(ResolvedRow::orphaned); + match res.status { + MatchStatus::Matched => matched += 1, + MatchStatus::Ambiguous => ambiguous += 1, + MatchStatus::Orphaned => orphaned += 1, + } + entries.push(RowAnnotationView { + handle: *handle, + status: res.status, + record: res.record, + candidates: res.candidates, + anchor_kind: entry.anchor.kind(), + star: entry.star, + flag: entry.flag, + tags: entry.tags.clone(), + note: entry.note.clone(), + cell_notes: entry + .cell_notes + .iter() + .map(|(column_id, note)| CellNoteView { + column_id: column_id.clone(), + note: note.clone(), + }) + .collect(), + created_ms: entry.created_ms, + updated_ms: entry.updated_ms, + }); + } + // Stable order for the panel: matched (by record) first, then the + // review items (ambiguous, orphaned) by handle. + entries.sort_by(|a, b| match (a.record, b.record) { + (Some(x), Some(y)) => x.cmp(&y), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.handle.cmp(&b.handle), + }); + Ok(AnnotationsView { + annotations_revision: self.revision, + revision: doc_revision, + author: self.author.clone(), + key_columns: self + .key_spec + .as_ref() + .map(|k| k.columns.clone()) + .unwrap_or_default(), + tags: self.tag_usage(), + matched, + ambiguous, + orphaned, + entries, + }) + } + + /// The review list only: ambiguous + orphaned annotations after a rematch. + pub fn rematch_report( + &self, + source: &dyn TabularSource, + ctx: Option<&JobCtx>, + ) -> AppResult { + let resolution = self.rematch(source, ctx)?; + let mut matched = 0usize; + let mut ambiguous = Vec::new(); + let mut orphaned = Vec::new(); + for (handle, entry) in &self.rows { + let res = resolution + .by_handle + .get(handle) + .cloned() + .unwrap_or_else(ResolvedRow::orphaned); + match res.status { + MatchStatus::Matched => matched += 1, + MatchStatus::Ambiguous => ambiguous.push(ReviewItem { + handle: *handle, + label: entry_label(entry), + candidates: res.candidates, + }), + MatchStatus::Orphaned => orphaned.push(ReviewItem { + handle: *handle, + label: entry_label(entry), + candidates: Vec::new(), + }), + } + } + Ok(RematchReport { + annotations_revision: self.revision, + matched, + ambiguous, + orphaned, + }) + } + + // ----- filter predicates ---------------------------------------------- + + /// Absolute record numbers of the MATCHED rows satisfying `predicate`, in + /// ascending order. Ambiguous / orphaned annotations never contribute (an + /// uncertain row is never filtered onto). + pub fn matching_records( + &self, + source: &dyn TabularSource, + predicate: &AnnotationPredicate, + ctx: Option<&JobCtx>, + ) -> AppResult> { + let resolution = self.rematch(source, ctx)?; + let mut records: Vec = Vec::new(); + for (handle, entry) in &self.rows { + let Some(res) = resolution.by_handle.get(handle) else { + continue; + }; + if res.status != MatchStatus::Matched { + continue; + } + if predicate.matches(entry) { + if let Some(record) = res.record { + records.push(record); + } + } + } + records.sort_unstable(); + records.dedup(); + Ok(records) + } + + // ----- tag → column ---------------------------------------------------- + + /// Preview copying a tag into a column: how many matched rows carry the tag, + /// how many annotations are skipped as ambiguous / orphaned, and a bounded + /// sample of the record → value writes. Read-only. + pub fn preview_tag_to_column( + &self, + source: &dyn TabularSource, + tag: &str, + doc_revision: u64, + ctx: Option<&JobCtx>, + ) -> AppResult { + if !self.tags.contains_key(tag) { + return Err(AppError::invalid(format!("no such tag: '{tag}'"))); + } + let resolution = self.rematch(source, ctx)?; + let mut writes: Vec<(u64, String)> = Vec::new(); + let mut ambiguous_skipped = 0usize; + let mut orphaned_skipped = 0usize; + for (handle, entry) in &self.rows { + if !entry.tags.iter().any(|t| t == tag) { + continue; + } + match resolution.by_handle.get(handle).map(|r| r.status) { + Some(MatchStatus::Matched) => { + if let Some(record) = resolution.by_handle[handle].record { + writes.push((record, tag.to_string())); + } + } + Some(MatchStatus::Ambiguous) => ambiguous_skipped += 1, + _ => orphaned_skipped += 1, + } + } + writes.sort_unstable_by_key(|(r, _)| *r); + let sample = writes + .iter() + .take(TAG_SAMPLE) + .map(|(record, value)| TagCellSample { + record: *record, + value: value.clone(), + }) + .collect(); + Ok(TagToColumnPreview { + revision: doc_revision, + tag: tag.to_string(), + rows_affected: writes.len(), + ambiguous_skipped, + orphaned_skipped, + sample, + }) + } + + /// The record → value writes for a tag over the MATCHED rows (ascending by + /// record). Feeds the document's batched apply. + pub fn tag_to_column_writes( + &self, + source: &dyn TabularSource, + tag: &str, + ctx: Option<&JobCtx>, + ) -> AppResult> { + if !self.tags.contains_key(tag) { + return Err(AppError::invalid(format!("no such tag: '{tag}'"))); + } + let resolution = self.rematch(source, ctx)?; + let mut writes: Vec<(u64, String)> = Vec::new(); + for (handle, entry) in &self.rows { + if !entry.tags.iter().any(|t| t == tag) { + continue; + } + if let Some(res) = resolution.by_handle.get(handle) { + if res.status == MatchStatus::Matched { + if let Some(record) = res.record { + writes.push((record, tag.to_string())); + } + } + } + } + writes.sort_unstable_by_key(|(r, _)| *r); + Ok(writes) + } + + // ----- persistence envelope -------------------------------------------- + + /// Serialize the store into its versioned export envelope. + pub fn to_export(&self) -> AnnotationsExport { + AnnotationsExport { + version: ANNOTATIONS_VERSION, + author: self.author.clone(), + key_spec: self.key_spec.clone(), + tags: self.tags.values().cloned().collect(), + entries: self.rows.values().cloned().collect(), + } + } + + /// Rebuild a store from an export envelope (adopting its handles; the next + /// handle continues past the maximum so new entries never collide). + pub fn from_export(export: AnnotationsExport) -> AnnotationStore { + let mut tags: BTreeMap = BTreeMap::new(); + for def in export.tags { + tags.insert(def.name.clone(), def); + } + let mut rows: BTreeMap = BTreeMap::new(); + let mut max_handle = 0u64; + for entry in export.entries { + max_handle = max_handle.max(entry.handle); + // A row's tags must exist in the namespace even if the file omitted + // the definition (forward tolerance). + for tag in &entry.tags { + tags.entry(tag.clone()).or_insert_with(|| TagDef { + name: tag.clone(), + color: None, + description: None, + }); + } + rows.insert(entry.handle, entry); + } + AnnotationStore { + revision: 0, + author: export.author, + key_spec: export.key_spec.filter(|k| !k.columns.is_empty()), + tags, + next_handle: if rows.is_empty() { 0 } else { max_handle + 1 }, + rows, + } + } + + // ----- export (JSON / CSV) -------------------------------------------- + + /// Render the annotations for explicit export in the requested format, + /// resolving records against the current source so exported rows carry a + /// stable identity and status. + pub fn export_as( + &self, + source: &dyn TabularSource, + format: AnnotationExportFormat, + ctx: Option<&JobCtx>, + ) -> AppResult { + match format { + AnnotationExportFormat::Json => self.export_json(), + AnnotationExportFormat::Csv => self.export_csv(source, ctx), + } + } + + fn export_json(&self) -> AppResult { + serde_json::to_string_pretty(&self.to_export()) + .map_err(|e| AppError::invalid(format!("could not serialize annotations: {e}"))) + } + + /// Flat CSV: one row per annotation, plus one row per cell note. Records are + /// resolved against the current source; unresolved annotations still export + /// with their status so nothing is silently dropped. + fn export_csv(&self, source: &dyn TabularSource, ctx: Option<&JobCtx>) -> AppResult { + let resolution = self.rematch(source, ctx)?; + let mut wtr = csv::Writer::from_writer(Vec::new()); + wtr.write_record([ + "handle", + "status", + "record", + "anchorKind", + "scope", + "columnId", + "star", + "flag", + "tags", + "note", + "author", + "createdMs", + "updatedMs", + ])?; + for (handle, entry) in &self.rows { + let res = resolution + .by_handle + .get(handle) + .cloned() + .unwrap_or_else(ResolvedRow::orphaned); + let record = res.record.map(|r| r.to_string()).unwrap_or_default(); + let status = res.status.label(); + // Row-level annotation line. + wtr.write_record([ + handle.to_string(), + status.to_string(), + record.clone(), + entry.anchor.kind().to_string(), + "row".to_string(), + String::new(), + entry.star.to_string(), + entry.flag.to_string(), + entry.tags.join("; "), + entry + .note + .as_ref() + .map(|n| n.text.clone()) + .unwrap_or_default(), + entry + .note + .as_ref() + .and_then(|n| n.author.clone()) + .unwrap_or_default(), + entry.created_ms.to_string(), + entry.updated_ms.to_string(), + ])?; + // One line per cell note. + for (column_id, note) in &entry.cell_notes { + wtr.write_record([ + handle.to_string(), + status.to_string(), + record.clone(), + entry.anchor.kind().to_string(), + "cell".to_string(), + column_id.clone(), + String::new(), + String::new(), + String::new(), + note.text.clone(), + note.author.clone().unwrap_or_default(), + note.created_ms.to_string(), + note.updated_ms.to_string(), + ])?; + } + } + let bytes = wtr + .into_inner() + .map_err(|e| AppError::invalid(format!("could not serialize annotations CSV: {e}")))?; + String::from_utf8(bytes) + .map_err(|e| AppError::invalid(format!("annotations CSV was not valid UTF-8: {e}"))) + } +} + +/// Set or clear a note in place, preserving the creation time on an edit. +fn apply_note(slot: &mut Option, text: Option, author: Option) { + match text { + Some(text) if !text.trim().is_empty() => match slot { + Some(note) => note.edit(text, author), + None => *slot = Some(Note::new(text, author)), + }, + _ => *slot = None, + } +} + +/// A short human label for a review-list entry (note preview, else tags/marks). +fn entry_label(entry: &RowEntry) -> String { + if let Some(note) = &entry.note { + let preview: String = note.text.chars().take(60).collect(); + return preview.replace('\n', " "); + } + if !entry.tags.is_empty() { + return entry.tags.join(", "); + } + if let Some((col, note)) = entry.cell_notes.iter().next() { + let preview: String = note.text.chars().take(48).collect(); + return format!("{col}: {}", preview.replace('\n', " ")); + } + let mut marks = Vec::new(); + if entry.star { + marks.push("starred"); + } + if entry.flag { + marks.push("flagged"); + } + if marks.is_empty() { + "annotation".to_string() + } else { + marks.join(", ") + } +} + +/// How many record→value writes a tag-to-column preview samples. +const TAG_SAMPLE: usize = 20; + +// --------------------------------------------------------------------------- +// Rematch result types +// --------------------------------------------------------------------------- + +/// The status of one annotation against the current source. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum MatchStatus { + Matched, + Ambiguous, + Orphaned, +} + +impl MatchStatus { + fn label(self) -> &'static str { + match self { + MatchStatus::Matched => "matched", + MatchStatus::Ambiguous => "ambiguous", + MatchStatus::Orphaned => "orphaned", + } + } +} + +/// One entry's resolution: status, the resolved record (when matched) and the +/// candidate records (when ambiguous). +#[derive(Debug, Clone)] +pub struct ResolvedRow { + pub status: MatchStatus, + pub record: Option, + pub candidates: Vec, +} + +impl ResolvedRow { + fn matched(record: u64) -> ResolvedRow { + ResolvedRow { + status: MatchStatus::Matched, + record: Some(record), + candidates: Vec::new(), + } + } + + fn ambiguous(candidates: Vec) -> ResolvedRow { + ResolvedRow { + status: MatchStatus::Ambiguous, + record: None, + candidates, + } + } + + fn orphaned() -> ResolvedRow { + ResolvedRow { + status: MatchStatus::Orphaned, + record: None, + candidates: Vec::new(), + } + } +} + +/// Resolve a key anchor against a (possibly absent) key index. +fn resolve_key( + key: &CompositeKey, + key_index: Option<&crate::row_identity::KeyIndex>, +) -> ResolvedRow { + match key_index { + None => ResolvedRow::orphaned(), + Some(index) => match index.unique_row(key) { + Ok(Some(row)) => ResolvedRow::matched(row), + Ok(None) => ResolvedRow::orphaned(), + Err(rows) => ResolvedRow::ambiguous(rows.to_vec()), + }, + } +} + +/// The full handle → resolution map produced by [`AnnotationStore::rematch`]. +#[derive(Debug, Clone, Default)] +pub struct Resolution { + pub by_handle: BTreeMap, +} + +// --------------------------------------------------------------------------- +// Wire DTOs (camelCase) +// --------------------------------------------------------------------------- + +/// A tag with its usage count across annotated rows. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TagUsage { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub count: usize, +} + +/// One cell note in a row view. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CellNoteView { + pub column_id: String, + pub note: Note, +} + +/// One annotation, resolved against the current document, for the panel. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RowAnnotationView { + pub handle: u64, + pub status: MatchStatus, + /// Absolute record number when matched. + #[serde(skip_serializing_if = "Option::is_none")] + pub record: Option, + /// Candidate records when ambiguous. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub candidates: Vec, + pub anchor_kind: &'static str, + pub star: bool, + pub flag: bool, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub note: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub cell_notes: Vec, + pub created_ms: u64, + pub updated_ms: u64, +} + +/// The full annotations surface for the front end. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AnnotationsView { + pub annotations_revision: u64, + /// The document revision, echoed for guarding downstream document ops. + pub revision: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub author: Option, + /// The active key columns (stable ids), empty when record-anchored. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub key_columns: Vec, + pub tags: Vec, + pub matched: usize, + pub ambiguous: usize, + pub orphaned: usize, + pub entries: Vec, +} + +/// One item in the rematch review list. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewItem { + pub handle: u64, + pub label: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub candidates: Vec, +} + +/// The outcome of a rematch: tallies plus the ambiguous / orphaned review list. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RematchReport { + pub annotations_revision: u64, + pub matched: usize, + pub ambiguous: Vec, + pub orphaned: Vec, +} + +/// The annotation-state filter predicate (integrates with the row-filter view). +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum AnnotationPredicate { + Starred, + Flagged, + /// Any tag when `tag` is absent, else a specific tag. + Tagged { + tag: Option, + }, + /// Has a row note. + HasNote, + /// Has at least one cell note. + HasCellNote, + /// Carries any annotation at all. + AnyAnnotation, +} + +impl AnnotationPredicate { + fn matches(&self, entry: &RowEntry) -> bool { + match self { + AnnotationPredicate::Starred => entry.star, + AnnotationPredicate::Flagged => entry.flag, + AnnotationPredicate::Tagged { tag: Some(tag) } => entry.tags.iter().any(|t| t == tag), + AnnotationPredicate::Tagged { tag: None } => !entry.tags.is_empty(), + AnnotationPredicate::HasNote => entry.note.is_some(), + AnnotationPredicate::HasCellNote => !entry.cell_notes.is_empty(), + AnnotationPredicate::AnyAnnotation => !entry.is_empty(), + } + } +} + +/// Where a tag-to-column apply writes. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum TagToColumnTarget { + /// Create a fresh column with this header, filled for tagged rows and blank + /// elsewhere (one undo op). + NewColumn { name: String }, + /// Write the tag into an existing column (only the tagged rows are set). + ExistingColumn { column: usize }, +} + +/// One record → value write in a tag-to-column preview sample. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TagCellSample { + pub record: u64, + pub value: String, +} + +/// Preview of copying a tag into a column (revision-guarded on apply). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TagToColumnPreview { + pub revision: u64, + pub tag: String, + pub rows_affected: usize, + pub ambiguous_skipped: usize, + pub orphaned_skipped: usize, + pub sample: Vec, +} + +/// Export formats for the explicit annotation export action. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AnnotationExportFormat { + Json, + Csv, +} + +/// The versioned persistence envelope, used both for the sidecar file and the +/// project `annotations` section. Carries no source cell values — rows are +/// referenced by identity (composite key or record number) and content hash, +/// so it passes the project's no-cell-data scan. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AnnotationsExport { + pub version: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub author: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key_spec: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub entries: Vec, +} + +/// Parse a versioned annotations JSON envelope (version probed first). +pub fn parse_export(json: &str) -> AppResult { + #[derive(Deserialize)] + struct VersionProbe { + version: u32, + } + let probe: VersionProbe = serde_json::from_str(json) + .map_err(|e| AppError::invalid(format!("invalid annotations JSON: {e}")))?; + if probe.version != ANNOTATIONS_VERSION { + return Err(AppError::invalid(format!( + "unsupported annotations version {} (this build reads version {ANNOTATIONS_VERSION})", + probe.version + ))); + } + serde_json::from_str(json) + .map_err(|e| AppError::invalid(format!("invalid annotations JSON: {e}"))) +} + +/// The sidecar path for a source file: its full name plus [`SIDECAR_SUFFIX`] +/// (`.../orders.csv` → `.../orders.csv.ceesvee-notes.json`). +pub fn sidecar_path(source: &Path) -> AppResult { + let name = source + .file_name() + .ok_or_else(|| AppError::invalid("the source path has no file name"))? + .to_string_lossy() + .to_string(); + let parent = source.parent().unwrap_or_else(|| Path::new("")); + Ok(parent.join(format!("{name}{SIDECAR_SUFFIX}"))) +} + +/// Load a store from a sidecar file (empty store when the file is absent). +pub fn load_sidecar(source: &Path) -> AppResult { + let path = sidecar_path(source)?; + match std::fs::read_to_string(&path) { + Ok(json) => Ok(AnnotationStore::from_export(parse_export(&json)?)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(AnnotationStore::default()), + Err(e) => Err(AppError::invalid(format!( + "could not read annotations sidecar {}: {e}", + path.display() + ))), + } +} + +/// Write a store to its source's sidecar file (atomic). An empty store DELETES +/// the sidecar rather than leaving a stale empty file. +pub fn save_sidecar(source: &Path, store: &AnnotationStore) -> AppResult<()> { + let path = sidecar_path(source)?; + if store.is_empty() { + match std::fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(AppError::Io(e)), + } + } else { + let json = serde_json::to_vec_pretty(&store.to_export()) + .map_err(|e| AppError::Other(format!("annotations serialization failed: {e}")))?; + crate::save::atomic_write(&path, crate::dto::BackupPolicy::None, |f| { + use std::io::Write; + f.write_all(&json)?; + Ok(json.len() as u64) + })?; + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Registry (managed by Tauri) +// --------------------------------------------------------------------------- + +/// Process-wide annotation stores, keyed by document id. Separate from the +/// document registry so annotations survive the whole-`Document` replacement a +/// reparse performs (the id is stable) and never touch the document's dirty +/// state or undo stack. +#[derive(Default)] +pub struct AnnotationRegistry(pub std::sync::Mutex>); + +impl AnnotationRegistry { + /// Run `f` with mutable access to the store for `doc_id` (created on first + /// use), returning its result. + pub fn with(&self, doc_id: u64, f: impl FnOnce(&mut AnnotationStore) -> T) -> AppResult { + let mut guard = self + .0 + .lock() + .map_err(|_| AppError::Other("internal annotation lock error".into()))?; + Ok(f(guard.entry(doc_id).or_default())) + } + + /// Like [`AnnotationRegistry::with`] but for a fallible closure, flattening + /// the result. + pub fn try_with( + &self, + doc_id: u64, + f: impl FnOnce(&mut AnnotationStore) -> AppResult, + ) -> AppResult { + let mut guard = self + .0 + .lock() + .map_err(|_| AppError::Other("internal annotation lock error".into()))?; + f(guard.entry(doc_id).or_default()) + } + + /// Replace the store for `doc_id`. + pub fn set(&self, doc_id: u64, store: AnnotationStore) -> AppResult<()> { + let mut guard = self + .0 + .lock() + .map_err(|_| AppError::Other("internal annotation lock error".into()))?; + guard.insert(doc_id, store); + Ok(()) + } + + /// Forget a document's annotations (on close). + pub fn remove(&self, doc_id: u64) { + if let Ok(mut guard) = self.0.lock() { + guard.remove(&doc_id); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::row_identity::KeyNormalization; + use crate::tabular::MemSource; + + fn col(id: &str) -> TabularColumn { + TabularColumn { + name: id.to_uppercase(), + id: Some(id.to_string()), + schema: None, + } + } + + fn row(cells: &[&str]) -> Vec> { + cells.iter().map(|c| Some(c.to_string())).collect() + } + + /// A two-column source (id, name) from `(id, name)` pairs. + fn source(rows: &[(&str, &str)]) -> MemSource { + MemSource::new( + vec![col("c0"), col("c1")], + rows.iter().map(|(a, b)| row(&[a, b])).collect(), + ) + } + + fn key_spec(cols: &[&str]) -> KeySpec { + KeySpec { + columns: cols.iter().map(|s| s.to_string()).collect(), + normalization: KeyNormalization::default(), + } + } + + fn marks_star() -> RowMarkPatch { + RowMarkPatch { + star: Some(true), + ..Default::default() + } + } + + // ----- record anchoring + rematch matrix ------------------------------- + + #[test] + fn record_anchor_survives_view_and_reports_status() { + let s = source(&[("1", "Ada"), ("2", "Bob"), ("3", "Cy")]); + let mut store = AnnotationStore::default(); + store.edit_row_marks(&s, 1, &marks_star(), None).unwrap(); + + let view = store.view(&s, 1, None).unwrap(); + assert_eq!(view.matched, 1); + assert_eq!(view.orphaned, 0); + assert_eq!(view.entries[0].record, Some(1)); + assert!(view.entries[0].star); + assert_eq!(view.entries[0].anchor_kind, "record"); + } + + #[test] + fn record_anchor_follows_content_when_row_moves() { + // Star record 1 ("2,Bob"), then reorder so Bob is at record 0. + let s = source(&[("1", "Ada"), ("2", "Bob")]); + let mut store = AnnotationStore::default(); + store.edit_row_marks(&s, 1, &marks_star(), None).unwrap(); + + let reordered = source(&[("2", "Bob"), ("1", "Ada")]); + let report = store.rematch_report(&reordered, None).unwrap(); + assert_eq!(report.matched, 1, "content found at its new record"); + let view = store.view(&reordered, 2, None).unwrap(); + assert_eq!(view.entries[0].record, Some(0)); + } + + #[test] + fn record_anchor_orphans_when_row_deleted() { + let s = source(&[("1", "Ada"), ("2", "Bob"), ("3", "Cy")]); + let mut store = AnnotationStore::default(); + store.edit_row_marks(&s, 1, &marks_star(), None).unwrap(); // Bob + + // Bob removed entirely. + let deleted = source(&[("1", "Ada"), ("3", "Cy")]); + let report = store.rematch_report(&deleted, None).unwrap(); + assert_eq!(report.matched, 0); + assert_eq!(report.orphaned.len(), 1); + + // Bob restored (undo): the orphan re-attaches. + let report = store.rematch_report(&s, None).unwrap(); + assert_eq!(report.matched, 1); + assert!(report.orphaned.is_empty()); + } + + #[test] + fn record_anchor_ambiguous_when_content_duplicated() { + let s = source(&[("1", "Ada"), ("2", "Bob")]); + let mut store = AnnotationStore::default(); + store.edit_row_marks(&s, 1, &marks_star(), None).unwrap(); // "2,Bob" + + // Edit record 1 away, and duplicate its old content at two rows. + let dup = source(&[("2", "Bob"), ("x", "y"), ("2", "Bob")]); + let report = store.rematch_report(&dup, None).unwrap(); + assert_eq!(report.ambiguous.len(), 1, "two rows carry the old content"); + assert_eq!(report.ambiguous[0].candidates, vec![0, 2]); + } + + // ----- keyed anchoring ------------------------------------------------- + + #[test] + fn keyed_anchor_survives_reorder_and_reports_duplicates() { + let mut store = AnnotationStore::default(); + store.set_key_spec(Some(key_spec(&["c0"]))); + let s = source(&[("1", "Ada"), ("2", "Bob"), ("3", "Cy")]); + store.edit_row_marks(&s, 1, &marks_star(), None).unwrap(); // key "2" + assert_eq!(store.rows.values().next().unwrap().anchor.kind(), "key"); + + // Reorder: key "2" is now at record 2 — still matched. + let reordered = source(&[("1", "Ada"), ("3", "Cy"), ("2", "Bob")]); + let view = store.view(&reordered, 2, None).unwrap(); + assert_eq!(view.matched, 1); + assert_eq!(view.entries[0].record, Some(2)); + + // Duplicate the key: ambiguous, every involved row flagged. + let dup = source(&[("2", "Bob"), ("2", "Bob2"), ("9", "z")]); + let report = store.rematch_report(&dup, None).unwrap(); + assert_eq!(report.matched, 0); + assert_eq!(report.ambiguous.len(), 1); + assert_eq!(report.ambiguous[0].candidates, vec![0, 1]); + } + + #[test] + fn keyed_anchor_orphans_when_key_absent() { + let mut store = AnnotationStore::default(); + store.set_key_spec(Some(key_spec(&["c0"]))); + let s = source(&[("1", "Ada"), ("2", "Bob")]); + store.edit_row_marks(&s, 1, &marks_star(), None).unwrap(); // key "2" + + let gone = source(&[("1", "Ada"), ("3", "Cy")]); + let report = store.rematch_report(&gone, None).unwrap(); + assert_eq!(report.orphaned.len(), 1); + } + + // ----- entry merging + pruning + notes --------------------------------- + + #[test] + fn multiple_edits_on_one_row_share_an_entry_and_prune_when_empty() { + let s = source(&[("1", "Ada"), ("2", "Bob")]); + let mut store = AnnotationStore::default(); + store.edit_row_marks(&s, 0, &marks_star(), None).unwrap(); + store + .set_row_note(&s, 0, Some("check".into()), Some("me".into()), None) + .unwrap(); + store + .set_cell_note(&s, 0, "c1", Some("verify name".into()), None, None) + .unwrap(); + assert_eq!(store.rows.len(), 1, "one entry for record 0"); + let entry = store.rows.values().next().unwrap(); + assert!(entry.star); + assert_eq!(entry.note.as_ref().unwrap().text, "check"); + assert_eq!(entry.note.as_ref().unwrap().author.as_deref(), Some("me")); + assert_eq!(entry.cell_notes["c1"].text, "verify name"); + + // Clearing everything prunes the entry. + store + .edit_row_marks( + &s, + 0, + &RowMarkPatch { + star: Some(false), + ..Default::default() + }, + None, + ) + .unwrap(); + store.set_row_note(&s, 0, None, None, None).unwrap(); + store.set_cell_note(&s, 0, "c1", None, None, None).unwrap(); + assert!(store.rows.is_empty(), "empty entry pruned"); + } + + #[test] + fn default_author_applies_to_new_notes() { + let s = source(&[("1", "Ada")]); + let mut store = AnnotationStore::default(); + store.set_author(Some(" Dana ".into())); + assert_eq!(store.author(), Some("Dana")); + store + .set_row_note(&s, 0, Some("hi".into()), None, None) + .unwrap(); + let entry = store.rows.values().next().unwrap(); + assert_eq!(entry.note.as_ref().unwrap().author.as_deref(), Some("Dana")); + } + + // ----- tags + namespace ------------------------------------------------ + + #[test] + fn tags_track_usage_and_removal_cleans_rows() { + let s = source(&[("1", "Ada"), ("2", "Bob")]); + let mut store = AnnotationStore::default(); + let patch = RowMarkPatch { + add_tags: vec!["urgent".into(), "review".into()], + ..Default::default() + }; + store.edit_row_marks(&s, 0, &patch, None).unwrap(); + store + .edit_row_marks( + &s, + 1, + &RowMarkPatch { + add_tags: vec!["urgent".into()], + ..Default::default() + }, + None, + ) + .unwrap(); + let view = store.view(&s, 1, None).unwrap(); + let urgent = view.tags.iter().find(|t| t.name == "urgent").unwrap(); + assert_eq!(urgent.count, 2); + let review = view.tags.iter().find(|t| t.name == "review").unwrap(); + assert_eq!(review.count, 1); + + // Removing the tag drops it from every row and empties Bob's entry. + store.remove_tag("urgent"); + let view = store.view(&s, 1, None).unwrap(); + assert!(view.tags.iter().all(|t| t.name != "urgent")); + // Bob had only "urgent" → pruned; Ada keeps "review". + assert_eq!(view.entries.len(), 1); + assert_eq!(view.entries[0].tags, vec!["review"]); + } + + // ----- filter predicates ---------------------------------------------- + + #[test] + fn filter_predicates_return_matched_records_only() { + let s = source(&[("1", "Ada"), ("2", "Bob"), ("3", "Cy")]); + let mut store = AnnotationStore::default(); + store.edit_row_marks(&s, 0, &marks_star(), None).unwrap(); + store + .edit_row_marks( + &s, + 2, + &RowMarkPatch { + flag: Some(true), + add_tags: vec!["t".into()], + ..Default::default() + }, + None, + ) + .unwrap(); + + assert_eq!( + store + .matching_records(&s, &AnnotationPredicate::Starred, None) + .unwrap(), + vec![0] + ); + assert_eq!( + store + .matching_records(&s, &AnnotationPredicate::Flagged, None) + .unwrap(), + vec![2] + ); + assert_eq!( + store + .matching_records(&s, &AnnotationPredicate::AnyAnnotation, None) + .unwrap(), + vec![0, 2] + ); + assert_eq!( + store + .matching_records( + &s, + &AnnotationPredicate::Tagged { + tag: Some("t".into()) + }, + None + ) + .unwrap(), + vec![2] + ); + } + + #[test] + fn ambiguous_rows_are_never_filtered_onto() { + let mut store = AnnotationStore::default(); + store.set_key_spec(Some(key_spec(&["c0"]))); + let s = source(&[("1", "Ada"), ("2", "Bob")]); + store.edit_row_marks(&s, 1, &marks_star(), None).unwrap(); + let dup = source(&[("2", "x"), ("2", "y")]); + assert!( + store + .matching_records(&dup, &AnnotationPredicate::Starred, None) + .unwrap() + .is_empty(), + "an ambiguous row is never selected by a filter" + ); + } + + // ----- tag → column ---------------------------------------------------- + + #[test] + fn tag_to_column_writes_matched_rows_only() { + let s = source(&[("1", "Ada"), ("2", "Bob"), ("3", "Cy")]); + let mut store = AnnotationStore::default(); + let tag = RowMarkPatch { + add_tags: vec!["keep".into()], + ..Default::default() + }; + store.edit_row_marks(&s, 0, &tag, None).unwrap(); + store.edit_row_marks(&s, 2, &tag, None).unwrap(); + + let preview = store.preview_tag_to_column(&s, "keep", 5, None).unwrap(); + assert_eq!(preview.rows_affected, 2); + assert_eq!(preview.revision, 5); + assert_eq!(preview.sample.len(), 2); + + let writes = store.tag_to_column_writes(&s, "keep", None).unwrap(); + assert_eq!( + writes, + vec![(0, "keep".to_string()), (2, "keep".to_string())] + ); + } + + // ----- persistence round-trip ------------------------------------------ + + #[test] + fn export_round_trip_preserves_handles_and_content() { + let s = source(&[("1", "Ada"), ("2", "Bob")]); + let mut store = AnnotationStore::default(); + store.set_author(Some("Dana".into())); + store.edit_row_marks(&s, 0, &marks_star(), None).unwrap(); + store + .set_row_note(&s, 1, Some("later".into()), None, None) + .unwrap(); + store + .define_tag(TagDef { + name: "keep".into(), + color: Some("#0f0".into()), + description: None, + }) + .unwrap(); + + let export = store.to_export(); + let json = serde_json::to_string(&export).unwrap(); + let parsed = parse_export(&json).unwrap(); + let restored = AnnotationStore::from_export(parsed); + assert_eq!(restored.to_export(), export); + assert_eq!(restored.author(), Some("Dana")); + // New entries after a load never collide with restored handles. + assert!(restored.next_handle > restored.rows.keys().copied().max().unwrap()); + } + + #[test] + fn sidecar_round_trips_and_deletes_when_empty() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("orders.csv"); + std::fs::write(&src, "id,name\n1,Ada\n").unwrap(); + let s = source(&[("1", "Ada")]); + + let mut store = AnnotationStore::default(); + store.edit_row_marks(&s, 0, &marks_star(), None).unwrap(); + save_sidecar(&src, &store).unwrap(); + + let path = sidecar_path(&src).unwrap(); + assert!(path.exists()); + assert!(path + .to_string_lossy() + .ends_with("orders.csv.ceesvee-notes.json")); + + let loaded = load_sidecar(&src).unwrap(); + assert_eq!(loaded.to_export(), store.to_export()); + + // Saving an empty store removes the sidecar. + save_sidecar(&src, &AnnotationStore::default()).unwrap(); + assert!(!path.exists()); + // Loading an absent sidecar is an empty store, not an error. + assert!(load_sidecar(&src).unwrap().is_empty()); + } + + #[test] + fn parse_export_rejects_unknown_version() { + let json = format!( + r#"{{"version": {}, "entries": []}}"#, + ANNOTATIONS_VERSION + 1 + ); + assert!(parse_export(&json).is_err()); + } + + // ----- CSV export ------------------------------------------------------ + + #[test] + fn csv_export_lists_rows_and_cell_notes_with_status() { + let s = source(&[("1", "Ada"), ("2", "Bob")]); + let mut store = AnnotationStore::default(); + store.edit_row_marks(&s, 0, &marks_star(), None).unwrap(); + store + .set_cell_note(&s, 0, "c1", Some("verify".into()), Some("q".into()), None) + .unwrap(); + let csv = store + .export_as(&s, AnnotationExportFormat::Csv, None) + .unwrap(); + let mut reader = csv::Reader::from_reader(csv.as_bytes()); + let records: Vec = reader.records().map(Result::unwrap).collect(); + assert_eq!(records.len(), 2, "one row line + one cell-note line"); + assert!(records.iter().any(|r| &r[4] == "row" && &r[6] == "true")); + let cell = records.iter().find(|r| &r[4] == "cell").unwrap(); + assert_eq!(&cell[5], "c1"); + assert_eq!(&cell[9], "verify"); + assert_eq!(&cell[10], "q"); + } + + // ----- revision independence ------------------------------------------- + + #[test] + fn annotation_revision_moves_only_on_real_changes() { + let s = source(&[("1", "Ada")]); + let mut store = AnnotationStore::default(); + let r0 = store.revision(); + store.edit_row_marks(&s, 0, &marks_star(), None).unwrap(); + assert!(store.revision() > r0); + let r1 = store.revision(); + // A no-op author set (same value) does not bump. + store.set_author(None); + assert_eq!(store.revision(), r1); + // A pure rematch/view never bumps the revision. + store.view(&s, 1, None).unwrap(); + assert_eq!(store.revision(), r1); + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 58884b6..3cade76 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -12,6 +12,10 @@ use std::sync::{Mutex, MutexGuard}; use tauri::State; +use crate::annotations::{ + self, AnnotationExportFormat, AnnotationPredicate, AnnotationRegistry, AnnotationsExport, + AnnotationsView, RematchReport, RowMarkPatch, TagDef, TagToColumnPreview, TagToColumnTarget, +}; use crate::append::{self, AppendCache, AppendInput, AppendOptions, AppendPreview, AppendReport}; use crate::archive::{self, ArchiveCache, ZipEntryInfo}; use crate::clipboard::{self, CopyFormat}; @@ -51,6 +55,7 @@ use crate::recipe::{self, BatchOptions, BatchReport, RecipeCache}; use crate::reopen::{self, CurrentInterpretation}; use crate::repair::{self, RepairPreview, RepairSpec}; use crate::reshape::{self, ReshapePreview, ReshapeSpec}; +use crate::row_identity::KeySpec; use crate::sampling::{ self, SampleDestination, SamplePlan, SamplePreview, SampleRequest, SampleStart, }; @@ -680,6 +685,11 @@ pub async fn apply_reparse( // dictionary (F38) carries across on the same principle. fresh.inherit_schema(doc); fresh.inherit_dictionary(doc); + // F40 annotations are NOT held on the document: they live in the + // doc_id-keyed AnnotationRegistry, so this whole-document swap preserves + // them automatically. They re-resolve against the fresh content on the + // next read; the front end calls `annotations_rematch` after a reparse + // to surface any newly-ambiguous / orphaned annotations for review. // Journaling continues against the NEW interpretation. attach_journal_if_enabled(&app, &mut fresh); let meta = fresh.meta(); @@ -926,6 +936,7 @@ pub fn close_document( outlier_cache: State<'_, OutlierCache>, append_cache: State<'_, AppendCache>, pii_cache: State<'_, PiiCache>, + annotations: State<'_, AnnotationRegistry>, follows: State<'_, FollowRegistry>, ) -> AppResult<()> { // Closing a followed tab stops its watcher and releases the handle. @@ -951,6 +962,7 @@ pub fn close_document( outlier_cache.remove(doc_id); append_cache.remove(doc_id); pii_cache.remove(doc_id); + annotations.remove(doc_id); Ok(()) } @@ -2058,6 +2070,391 @@ pub async fn apply_dictionary_import( .map_err(|e| AppError::Other(format!("background task failed: {e}")))? } +// ----- row bookmarks, tags and notes (F40) --------------------------------------- +// +// Annotations live in the doc_id-keyed [`AnnotationRegistry`], deliberately +// OUTSIDE the document: they survive the whole-`Document` replacement a reparse +// / reindex / convert-to-editable performs (the id is stable) and are re-resolved +// lazily against the current content on every read, so those flows need no change +// — the front end calls `annotations_rematch` after a reload to surface the +// ambiguous / orphaned review list. All edits are metadata: never undoable, never +// dirtying the document, guarded by the store's own revision. Reads run a bounded +// scan of the document (like `apply_diagnostic_filter`); a huge indexed source is +// only ever fully scanned when a record anchor's content drifted (which an +// immutable indexed backing never does) or a key spec is in use. + +/// Run `f` with read access to the document and mutable access to its +/// annotation store (both created/looked-up here). Lock order is always +/// document-then-registry. +fn edit_annotations( + state: &Db<'_>, + annotations: &State<'_, AnnotationRegistry>, + doc_id: u64, + f: impl FnOnce(&Document, &mut annotations::AnnotationStore) -> AppResult, +) -> AppResult { + let handle = doc_handle(state, doc_id)?; + let doc = handle.read().map_err(poisoned)?; + annotations.try_with(doc_id, |store| f(&doc, store)) +} + +/// Snapshot the annotations panel surface (rematched against the current doc). +#[tauri::command] +pub fn annotations_view( + doc_id: u64, + state: Db<'_>, + annotations: State<'_, AnnotationRegistry>, +) -> AppResult { + edit_annotations(&state, &annotations, doc_id, |doc, store| { + store.view(&DocumentSource::new(doc), doc.revision(), None) + }) +} + +/// Re-resolve every annotation against the current document and return the +/// matched tally plus the ambiguous / orphaned review list. The front end calls +/// this after any reparse / reindex / external-change reload. +#[tauri::command] +pub fn annotations_rematch( + doc_id: u64, + state: Db<'_>, + annotations: State<'_, AnnotationRegistry>, +) -> AppResult { + edit_annotations(&state, &annotations, doc_id, |doc, store| { + store.rematch_report(&DocumentSource::new(doc), None) + }) +} + +/// Set (or clear, with `None`) the key columns used to anchor NEW annotations, +/// then re-anchor the matched existing ones to the new mechanism. +#[tauri::command] +pub fn annotations_set_key_spec( + doc_id: u64, + key_spec: Option, + expected_annotations_revision: u64, + state: Db<'_>, + annotations: State<'_, AnnotationRegistry>, +) -> AppResult { + edit_annotations(&state, &annotations, doc_id, |doc, store| { + store.check_revision(expected_annotations_revision)?; + let source = DocumentSource::new(doc); + store.set_key_spec(key_spec); + store.reanchor(&source, None)?; + store.view(&source, doc.revision(), None) + }) +} + +/// Set (or clear, with `None`) the default author label carried on new notes. +#[tauri::command] +pub fn annotations_set_author( + doc_id: u64, + author: Option, + expected_annotations_revision: u64, + state: Db<'_>, + annotations: State<'_, AnnotationRegistry>, +) -> AppResult { + edit_annotations(&state, &annotations, doc_id, |doc, store| { + store.check_revision(expected_annotations_revision)?; + store.set_author(author); + store.view(&DocumentSource::new(doc), doc.revision(), None) + }) +} + +/// Star / flag / add or remove tags on the row at `display_row` (translated to +/// its absolute record). Creates the annotation if absent; prunes it if empty. +#[tauri::command] +pub fn annotations_edit_row( + doc_id: u64, + display_row: usize, + patch: RowMarkPatch, + expected_annotations_revision: u64, + state: Db<'_>, + annotations: State<'_, AnnotationRegistry>, +) -> AppResult { + edit_annotations(&state, &annotations, doc_id, |doc, store| { + store.check_revision(expected_annotations_revision)?; + let record = abs_row(doc, display_row)? as u64; + let source = DocumentSource::new(doc); + store.edit_row_marks(&source, record, &patch, None)?; + store.view(&source, doc.revision(), None) + }) +} + +/// Set (or clear, with `text = None`) the ROW note on `display_row`. +#[tauri::command] +pub fn annotations_set_row_note( + doc_id: u64, + display_row: usize, + text: Option, + author: Option, + expected_annotations_revision: u64, + state: Db<'_>, + annotations: State<'_, AnnotationRegistry>, +) -> AppResult { + edit_annotations(&state, &annotations, doc_id, |doc, store| { + store.check_revision(expected_annotations_revision)?; + let record = abs_row(doc, display_row)? as u64; + let source = DocumentSource::new(doc); + store.set_row_note(&source, record, text, author, None)?; + store.view(&source, doc.revision(), None) + }) +} + +/// Set (or clear, with `text = None`) a CELL note on `column_id` of +/// `display_row`. +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub fn annotations_set_cell_note( + doc_id: u64, + display_row: usize, + column_id: String, + text: Option, + author: Option, + expected_annotations_revision: u64, + state: Db<'_>, + annotations: State<'_, AnnotationRegistry>, +) -> AppResult { + edit_annotations(&state, &annotations, doc_id, |doc, store| { + store.check_revision(expected_annotations_revision)?; + let record = abs_row(doc, display_row)? as u64; + let source = DocumentSource::new(doc); + store.set_cell_note(&source, record, &column_id, text, author, None)?; + store.view(&source, doc.revision(), None) + }) +} + +/// Delete one whole annotation entry by its stable handle (e.g. discarding a +/// single orphan from the review list). +#[tauri::command] +pub fn annotations_remove_row( + doc_id: u64, + handle: u64, + expected_annotations_revision: u64, + state: Db<'_>, + annotations: State<'_, AnnotationRegistry>, +) -> AppResult { + edit_annotations(&state, &annotations, doc_id, |doc, store| { + store.check_revision(expected_annotations_revision)?; + store.remove_row(handle); + store.view(&DocumentSource::new(doc), doc.revision(), None) + }) +} + +/// Discard every orphaned annotation (no matching row in the current document). +#[tauri::command] +pub fn annotations_discard_orphans( + doc_id: u64, + expected_annotations_revision: u64, + state: Db<'_>, + annotations: State<'_, AnnotationRegistry>, +) -> AppResult { + edit_annotations(&state, &annotations, doc_id, |doc, store| { + store.check_revision(expected_annotations_revision)?; + let source = DocumentSource::new(doc); + store.discard_orphans(&source, None)?; + store.view(&source, doc.revision(), None) + }) +} + +/// Define or update a tag in the namespace. +#[tauri::command] +pub fn annotations_define_tag( + doc_id: u64, + tag: TagDef, + expected_annotations_revision: u64, + state: Db<'_>, + annotations: State<'_, AnnotationRegistry>, +) -> AppResult { + edit_annotations(&state, &annotations, doc_id, |doc, store| { + store.check_revision(expected_annotations_revision)?; + store.define_tag(tag)?; + store.view(&DocumentSource::new(doc), doc.revision(), None) + }) +} + +/// Remove a tag from the namespace and from every row that carries it. +#[tauri::command] +pub fn annotations_remove_tag( + doc_id: u64, + name: String, + expected_annotations_revision: u64, + state: Db<'_>, + annotations: State<'_, AnnotationRegistry>, +) -> AppResult { + edit_annotations(&state, &annotations, doc_id, |doc, store| { + store.check_revision(expected_annotations_revision)?; + store.remove_tag(&name); + store.view(&DocumentSource::new(doc), doc.revision(), None) + }) +} + +/// Filter the grid to the rows matching an annotation-state predicate +/// (starred / flagged / tagged / has-note …), via the existing row-filter view. +/// Only MATCHED rows contribute — an ambiguous or orphaned annotation is never +/// filtered onto. Guarded by the document revision. +#[tauri::command] +pub fn apply_annotation_filter( + doc_id: u64, + predicate: AnnotationPredicate, + expected_revision: u64, + state: Db<'_>, + annotations: State<'_, AnnotationRegistry>, +) -> AppResult { + let handle = doc_handle(&state, doc_id)?; + let mut doc = handle.write().map_err(poisoned)?; + doc.check_revision(expected_revision)?; + let records = { + let source = DocumentSource::new(&doc); + annotations.try_with(doc_id, |store| { + store.matching_records(&source, &predicate, None) + })? + }; + let rows: Vec = records.into_iter().map(|r| r as usize).collect(); + doc.set_filter(rows)?; + Ok(doc.meta()) +} + +/// Preview copying a tag into a column (how many rows are affected, what is +/// skipped as ambiguous / orphaned, a bounded sample). Read-only; carries the +/// document revision the apply is guarded by. +#[tauri::command] +pub fn preview_tag_to_column( + doc_id: u64, + tag: String, + state: Db<'_>, + annotations: State<'_, AnnotationRegistry>, +) -> AppResult { + edit_annotations(&state, &annotations, doc_id, |doc, store| { + store.preview_tag_to_column(&DocumentSource::new(doc), &tag, doc.revision(), None) + }) +} + +/// Copy a tag into a real column as ONE undoable document operation: a fresh +/// column (filled for tagged rows, blank elsewhere) or writes into an existing +/// column (only the tagged rows). Guarded by the document revision. The notes +/// themselves are untouched — this materialises a copy, on request. +#[tauri::command] +pub fn apply_tag_to_column( + doc_id: u64, + tag: String, + target: TagToColumnTarget, + expected_revision: u64, + state: Db<'_>, + annotations: State<'_, AnnotationRegistry>, +) -> AppResult { + let handle = doc_handle(&state, doc_id)?; + let mut doc = handle.write().map_err(poisoned)?; + doc.check_revision(expected_revision)?; + doc.ensure_editable()?; + // record → tag value, computed against a read snapshot of the same doc. + let writes = { + let source = DocumentSource::new(&doc); + annotations.try_with(doc_id, |store| { + store.tag_to_column_writes(&source, &tag, None) + })? + }; + match target { + TagToColumnTarget::NewColumn { name } => { + let n_rows = doc.n_rows(); + let mut values = vec![String::new(); n_rows]; + for (record, value) in writes { + if let Some(slot) = values.get_mut(record as usize) { + *slot = value; + } + } + let insert_at = doc.n_cols(); + doc.replace_columns(Vec::new(), insert_at, vec![(name, values)])?; + } + TagToColumnTarget::ExistingColumn { column } => { + if column >= doc.n_cols() { + return Err(AppError::invalid("column index out of range")); + } + let changes: Vec<(usize, usize, String)> = writes + .into_iter() + .map(|(record, value)| (record as usize, column, value)) + .collect(); + doc.set_cells(changes)?; + } + } + Ok(doc.meta()) +} + +/// Export the annotations as versioned JSON or flat CSV (atomic write via the +/// F03 pipeline). An EXPLICIT action — notes never leave through an ordinary +/// data export. +#[tauri::command] +pub fn export_annotations( + doc_id: u64, + path: String, + format: AnnotationExportFormat, + state: Db<'_>, + annotations: State<'_, AnnotationRegistry>, +) -> AppResult<()> { + let rendered = edit_annotations(&state, &annotations, doc_id, |doc, store| { + store.export_as(&DocumentSource::new(doc), format, None) + })?; + save_mod::atomic_write(Path::new(&path), BackupPolicy::None, |file| { + use std::io::Write; + file.write_all(rendered.as_bytes())?; + Ok(rendered.len() as u64) + })?; + Ok(()) +} + +/// The full annotations export envelope for `doc_id` — what the front end writes +/// into the project's `annotations` section, or a sidecar. +#[tauri::command] +pub fn annotations_get_export( + doc_id: u64, + annotations: State<'_, AnnotationRegistry>, +) -> AppResult { + annotations.try_with(doc_id, |store| Ok(store.to_export())) +} + +/// Hydrate a document's annotation store from an export envelope (from the +/// project section on project open, say). Replaces any current store. +#[tauri::command] +pub fn annotations_load_export( + doc_id: u64, + export: AnnotationsExport, + state: Db<'_>, + annotations: State<'_, AnnotationRegistry>, +) -> AppResult { + annotations.set(doc_id, annotations::AnnotationStore::from_export(export))?; + edit_annotations(&state, &annotations, doc_id, |doc, store| { + store.view(&DocumentSource::new(doc), doc.revision(), None) + }) +} + +/// Load a document's annotations from its sidecar file (`` → +/// `.ceesvee-notes.json`), replacing any current store. An absent +/// sidecar yields an empty store. Used when no project is open. +#[tauri::command] +pub fn annotations_load_sidecar( + doc_id: u64, + source_path: String, + state: Db<'_>, + annotations: State<'_, AnnotationRegistry>, +) -> AppResult { + let store = annotations::load_sidecar(Path::new(&source_path))?; + annotations.set(doc_id, store)?; + edit_annotations(&state, &annotations, doc_id, |doc, store| { + store.view(&DocumentSource::new(doc), doc.revision(), None) + }) +} + +/// Save a document's annotations to its sidecar file (atomic). An empty store +/// deletes the sidecar. When a project is open the front end persists into the +/// project's `annotations` section instead (the project absorbs the sidecar on +/// save — the simple migration rule). +#[tauri::command] +pub fn annotations_save_sidecar( + doc_id: u64, + source_path: String, + annotations: State<'_, AnnotationRegistry>, +) -> AppResult<()> { + let store = annotations.try_with(doc_id, |store| Ok(store.clone()))?; + annotations::save_sidecar(Path::new(&source_path), &store) +} + // ----- batch recipes (F25) ------------------------------------------------------ /// Validate a batch (recipe version, steps, templates, distinct output diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 7788cec..52e5311 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -49,6 +49,12 @@ pub enum AppError { #[error("stale dictionary: the data dictionary changed since this operation was prepared (expected dictionary revision {expected}, document is at {actual})")] StaleDictionaryRevision { expected: u64, actual: u64 }, + /// An annotation-dependent deferred operation (F40 mark/note/tag edit) was + /// prepared against an older annotations revision. Independent of the data, + /// schema and dictionary revisions: annotating never dirties the document. + #[error("stale annotations: the annotations changed since this operation was prepared (expected annotations revision {expected}, document is at {actual})")] + StaleAnnotationsRevision { expected: u64, actual: u64 }, + /// A mutation was attempted on a document opened in indexed read-only /// mode (F10). Convert it to editable first. #[error( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7255af4..5f19511 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,11 @@ //! exposed to the web front end through a small Tauri command surface. mod analyze; +/// Public like [`job`]: the F40 annotations engine (row bookmarks, tags and +/// notes anchored by `row_identity` handles, the rematch engine, filter +/// predicates, tag-to-column and versioned sidecar/project-section persistence) +/// is a stable internal API consumed by the command surface and test harness. +pub mod annotations; mod append; mod archive; mod clipboard; @@ -149,6 +154,7 @@ pub fn run() { .manage(crate::follow::FollowRegistry::default()) .manage(crate::json_import::JsonImportPreviewCache::default()) .manage(crate::project::ProjectStore::default()) + .manage(crate::annotations::AnnotationRegistry::default()) .setup(|app| { // Delete index caches orphaned by an abnormal termination. Live // instances hold their cache's lock file, so they are skipped. @@ -315,6 +321,25 @@ pub fn run() { project::project_open_apply, commands::preview_sample, commands::start_sample, + commands::annotations_view, + commands::annotations_rematch, + commands::annotations_set_key_spec, + commands::annotations_set_author, + commands::annotations_edit_row, + commands::annotations_set_row_note, + commands::annotations_set_cell_note, + commands::annotations_remove_row, + commands::annotations_discard_orphans, + commands::annotations_define_tag, + commands::annotations_remove_tag, + commands::apply_annotation_filter, + commands::preview_tag_to_column, + commands::apply_tag_to_column, + commands::export_annotations, + commands::annotations_get_export, + commands::annotations_load_export, + commands::annotations_load_sidecar, + commands::annotations_save_sidecar, ]) .build(tauri::generate_context!()) .expect("error while running tauri application") diff --git a/src-tauri/src/project.rs b/src-tauri/src/project.rs index ba1d8c9..1a28964 100644 --- a/src-tauri/src/project.rs +++ b/src-tauri/src/project.rs @@ -6,9 +6,9 @@ //! sections are typed and registered in [`SECTION_REGISTRY`]. Future features //! extend the registry by adding a typed field to [`ProjectSections`], a row //! to the registry, and a match arm in [`set_section_typed`] — as the F38 -//! `dictionary` section does. The still-reserved `annotations` (F40) and -//! `queries` (F36) sections are already named, default-empty, and rejected for -//! writes until their owning feature lands. +//! `dictionary` and F40 `annotations` sections do. The still-reserved `queries` +//! (F36) section is already named, default-empty, and rejected for writes until +//! its owning feature lands. //! //! Hard rules enforced here: //! @@ -45,6 +45,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use tauri::State; +use crate::annotations::AnnotationsExport; use crate::compare::CompareSpec; use crate::dictionary::DictionaryExport; use crate::dto::{BackupPolicy, FileFingerprint}; @@ -168,10 +169,12 @@ pub struct ProjectSections { /// source. #[serde(default)] pub row_keys: Vec, - /// RESERVED for F40 row annotations: entries will reference rows by - /// `row_identity::RowIdentity` (keys/record numbers), never by content. + /// F40 row annotations, per source: bookmarks, tags and notes referencing + /// rows by `row_identity::RowIdentity` (composite keys / record numbers) and + /// a content fingerprint — never by cell value — in the versioned + /// annotations export envelope. #[serde(default)] - pub annotations: Vec, + pub annotations: Vec, /// F38 data dictionaries, per source: per-column documentation and /// constraints in the versioned dictionary export envelope (configuration /// only — column IDs and metadata, never cell values). @@ -250,7 +253,7 @@ pub const SECTION_REGISTRY: &[SectionSpec] = &[ }, SectionSpec { name: "annotations", - reserved: true, + reserved: false, owner: "F40", }, SectionSpec { @@ -362,6 +365,18 @@ pub struct SourceRowKey { pub key: KeySpec, } +/// F40 row annotations for one source, in the versioned export envelope. The +/// envelope references rows by identity (composite key / record number) and a +/// content hash and carries only annotation metadata (marks, tags, notes, +/// author, timestamps) — never a cell value — so it passes the no-cell-data +/// scan like every other section. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SourceAnnotations { + pub source_id: String, + pub annotations: AnnotationsExport, +} + /// A named opaque configuration payload (used for recipes, whose engine /// types are deserialize-only and validate on explicit use). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -939,6 +954,7 @@ fn remove_source(sections: &mut ProjectSections, id: &str) { sections.views.retain(|v| v.source_id != id); sections.schemas.retain(|s| s.source_id != id); sections.row_keys.retain(|k| k.source_id != id); + sections.annotations.retain(|a| a.source_id != id); sections .join_mappings .retain(|j| j.left_source_id != id && j.right_source_id != id); @@ -1249,6 +1265,7 @@ fn set_section_typed(sections: &mut ProjectSections, name: &str, value: Value) - "joinMappings" => sections.join_mappings = serde_json::from_value(value).map_err(shape)?, "comparisons" => sections.comparisons = serde_json::from_value(value).map_err(shape)?, "rowKeys" => sections.row_keys = serde_json::from_value(value).map_err(shape)?, + "annotations" => sections.annotations = serde_json::from_value(value).map_err(shape)?, "dictionary" => sections.dictionary = serde_json::from_value(value).map_err(shape)?, _ => unreachable!("registry check above covers every arm"), } @@ -1311,6 +1328,7 @@ fn strip_sources(sections: &mut ProjectSections) { sections.views.clear(); sections.schemas.clear(); sections.row_keys.clear(); + sections.annotations.clear(); sections.join_mappings.clear(); sections.comparisons.clear(); for profile in &mut sections.profiles { @@ -1492,11 +1510,12 @@ pub fn project_open_apply( #[cfg(test)] mod tests { use super::*; + use crate::annotations::{AnnotationsExport, Note, RowAnchor, RowEntry, ANNOTATIONS_VERSION}; use crate::compare::CompareMode; use crate::dictionary::{ DictionaryExportEntry, DictionaryField, FieldRole, Sensitivity, DICTIONARY_VERSION, }; - use crate::row_identity::KeyNormalization; + use crate::row_identity::{KeyNormalization, RowIdentity}; use crate::schema::{ColumnSchema, LogicalType, SCHEMA_VERSION}; use crate::settings::ProfileMatch; @@ -1578,6 +1597,36 @@ mod tests { } } + /// A one-entry annotations export: a starred, tagged, record-anchored row + /// with a note (identity + content hash only — no cell values). + fn an_annotations() -> AnnotationsExport { + AnnotationsExport { + version: ANNOTATIONS_VERSION, + author: Some("Dana".into()), + key_spec: None, + tags: Vec::new(), + entries: vec![RowEntry { + handle: 0, + anchor: RowAnchor { + identity: RowIdentity::SourceRecord { record: 3 }, + content_hash: Some("abc123".into()), + }, + star: true, + flag: false, + tags: vec!["keep".into()], + note: Some(Note { + text: "check this row".into(), + author: Some("Dana".into()), + created_ms: 1, + updated_ms: 2, + }), + cell_notes: BTreeMap::new(), + created_ms: 1, + updated_ms: 2, + }], + } + } + fn a_profile() -> FileProfile { FileProfile { id: "p1".into(), @@ -1675,6 +1724,10 @@ mod tests { source_id: "srcA".into(), dictionary: a_dictionary(), }]; + s.annotations = vec![SourceAnnotations { + source_id: "srcA".into(), + annotations: an_annotations(), + }]; (file, a, b) } @@ -1774,6 +1827,38 @@ mod tests { scan_for_data_keys("whole file", &value).expect("dictionary carries no cell data"); } + #[test] + fn annotations_section_registers_and_round_trips_per_source() { + // The F40 annotations section is a real registered section now, not + // reserved: a well-formed per-source payload is accepted, not rejected. + let mut sections = ProjectSections::default(); + let payload = serde_json::to_value(vec![SourceAnnotations { + source_id: "srcA".into(), + annotations: an_annotations(), + }]) + .unwrap(); + set_section_typed(&mut sections, "annotations", payload).unwrap(); + assert_eq!(sections.annotations.len(), 1); + assert_eq!(sections.annotations[0].source_id, "srcA"); + + // It survives an atomic save + reload verbatim. + let dir = tempfile::tempdir().unwrap(); + let (file, _a, _b) = full_project(dir.path()); + let path = dir.path().join(format!("notes.{PROJECT_EXTENSION}")); + write_project_file(&path, &file).unwrap(); + let loaded = load_project_file(&path).unwrap(); + assert_eq!(loaded.sections.annotations, file.sections.annotations); + let entry = &loaded.sections.annotations[0].annotations.entries[0]; + assert!(entry.star); + assert_eq!(entry.tags, vec!["keep"]); + assert_eq!(entry.note.as_ref().unwrap().text, "check this row"); + + // Annotations reference rows by identity + content hash, never by cell + // value: the whole serialized file still passes the no-cell-data scan. + let value: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + scan_for_data_keys("whole file", &value).expect("annotations carry no cell data"); + } + #[test] fn unknown_fields_and_sections_survive_a_round_trip() { let dir = tempfile::tempdir().unwrap(); @@ -1785,7 +1870,7 @@ mod tests { "appVersion": "9.1.0", "futureTopLevel": {"keep": true}, "sections": { - "annotations": [{"rowKey": ["1"], "note": "check this"}], + "queries": [{"rowKey": ["1"], "note": "check this"}], "futureSection": {"nested": [1, 2, 3]}, "sources": [{"id": "s1", "path": "x.csv", "futureSourceField": "keep-me"}] } @@ -1943,12 +2028,12 @@ mod tests { #[test] fn reserved_and_unknown_section_writes_are_rejected() { let mut sections = ProjectSections::default(); - for reserved in ["annotations", "queries"] { - let err = set_section_typed(&mut sections, reserved, serde_json::json!([])) - .unwrap_err() - .to_string(); - assert!(err.contains("reserved"), "{reserved}: {err}"); - } + // `annotations` is no longer reserved (F40 owns it now); `queries` + // (F36) is still reserved and rejects writes. + let err = set_section_typed(&mut sections, "queries", serde_json::json!([])) + .unwrap_err() + .to_string(); + assert!(err.contains("reserved"), "queries: {err}"); let err = set_section_typed(&mut sections, "nope", serde_json::json!([])) .unwrap_err() .to_string(); @@ -2363,9 +2448,10 @@ mod tests { ); } // layout serializes as null; everything else defaults to empty lists - // or objects. Reserved sections are present-but-empty by design. - for reserved in ["annotations", "queries"] { - assert_eq!(map[reserved], serde_json::json!([]), "{reserved}"); + // or objects. The still-reserved `queries` section and the now-active + // but empty-by-default `annotations` section are both present-but-empty. + for empty in ["annotations", "queries"] { + assert_eq!(map[empty], serde_json::json!([]), "{empty}"); } assert_eq!( map.len(), From 4b92359abb300f70fc1137876885396b8a929397 Mon Sep 17 00:00:00 2001 From: soldforaloss Date: Fri, 17 Jul 2026 20:18:53 -0700 Subject: [PATCH 2/6] feat(f40): annotations UI Front end for row bookmarks, tags & notes: grid gutter glyphs (star / flag / note) plus a per-cell note corner and subtle row tint, all placed through a new display-row -> record bridge command so indicators track any sort/filter; an inline cell context menu (star, flag, tags, row note, cell note); the AnnotationsPanel (type filters, search, jump-to-row, tag namespace with create/remove/to-column, ambiguous/orphaned review with resolve actions, and author/key-column anchoring settings); note, tag picker, tag-to-column (preview + one undo) and export dialogs; annotation state filters + row ops in the command palette; a per-tab store slice that loads the doc_id-keyed registry, re-resolves on reparse and persists to the `.ceesvee-notes.json` sidecar. Types, tauri wrappers and vitest for the pure logic. Co-Authored-By: Claude Fable 5 --- src-tauri/src/commands.rs | 18 + src-tauri/src/lib.rs | 1 + src/App.tsx | 13 + src/components/AnnotationExportDialog.tsx | 57 +++ src/components/AnnotationsPanel.tsx | 475 ++++++++++++++++++++++ src/components/Grid.tsx | 327 ++++++++++++++- src/components/NoteEditorDialog.tsx | 88 ++++ src/components/TagPickerDialog.tsx | 106 +++++ src/components/TagToColumnDialog.tsx | 150 +++++++ src/components/Toolbar.tsx | 10 + src/lib/annotations.test.ts | 202 +++++++++ src/lib/annotations.ts | 215 ++++++++++ src/lib/commandDefs.ts | 92 +++++ src/lib/commands.ts | 1 + src/lib/tauri.ts | 188 +++++++++ src/store/useStore.ts | 377 +++++++++++++++++ src/types.ts | 153 +++++++ 17 files changed, 2458 insertions(+), 15 deletions(-) create mode 100644 src/components/AnnotationExportDialog.tsx create mode 100644 src/components/AnnotationsPanel.tsx create mode 100644 src/components/NoteEditorDialog.tsx create mode 100644 src/components/TagPickerDialog.tsx create mode 100644 src/components/TagToColumnDialog.tsx create mode 100644 src/lib/annotations.test.ts create mode 100644 src/lib/annotations.ts diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 3cade76..e708e50 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3371,6 +3371,24 @@ pub fn get_rows(doc_id: u64, start: usize, count: usize, state: Db<'_>) -> AppRe read_doc(&state, doc_id, |doc| doc.get_rows(start, count)) } +/// Absolute source record numbers for a DISPLAY-row window (F40). Under a sort +/// or filter the display row is not the record, so the front end needs this map +/// to place per-row annotation indicators on the correct rows. `None` for a +/// display index past the current view's end. +#[tauri::command] +pub fn display_records( + doc_id: u64, + start: usize, + count: usize, + state: Db<'_>, +) -> AppResult>> { + read_doc(&state, doc_id, |doc| { + Ok((start..start.saturating_add(count)) + .map(|display| doc.display_to_abs(display).map(|abs| abs as u64)) + .collect()) + }) +} + /// The COMPLETE content of one cell (display coordinates), read through the /// backing-aware path so the F13 cell editor never operates on truncated /// grid text. Works for indexed documents (inspection is read-only there). diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5f19511..38a4e37 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -321,6 +321,7 @@ pub fn run() { project::project_open_apply, commands::preview_sample, commands::start_sample, + commands::display_records, commands::annotations_view, commands::annotations_rematch, commands::annotations_set_key_spec, diff --git a/src/App.tsx b/src/App.tsx index d5b0a89..d1e4a5b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,8 @@ import { getCurrentWebview } from "@tauri-apps/api/webview"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { useCallback, useEffect, useState } from "react"; +import { AnnotationExportDialog } from "./components/AnnotationExportDialog"; +import { AnnotationsPanel } from "./components/AnnotationsPanel"; import { AppendDialog } from "./components/AppendDialog"; import { ArchiveEntryDialog } from "./components/ArchiveEntryDialog"; import { CellEditorDialog } from "./components/CellEditorDialog"; @@ -29,6 +31,9 @@ import { ViewsDialog } from "./components/ViewsDialog"; import { ViewWarningBar } from "./components/ViewWarningBar"; import { Grid } from "./components/Grid"; import { GroupByDialog } from "./components/GroupByDialog"; +import { NoteEditorDialog } from "./components/NoteEditorDialog"; +import { TagPickerDialog } from "./components/TagPickerDialog"; +import { TagToColumnDialog } from "./components/TagToColumnDialog"; import { Close } from "./components/Icons"; import { JoinDialog } from "./components/JoinDialog"; import { JsonExportDialog } from "./components/JsonExportDialog"; @@ -81,6 +86,7 @@ export default function App() { const jsonImportPath = useStore((s) => s.jsonImport?.path ?? null); const diagnosticsOpen = useStore((s) => s.diagnosticsOpen); const changesOpen = useStore((s) => s.changesOpen); + const annotationsPanelOpen = useStore((s) => s.annotationsPanelOpen); const [dark, setDark] = useState(() => document.documentElement.classList.contains("dark")); const [dragOver, setDragOver] = useState(false); @@ -286,6 +292,7 @@ export default function App() { {diagnosticsOpen && meta && } {changesOpen && meta && } + {annotationsPanelOpen && meta && } {meta && } @@ -320,6 +327,10 @@ export default function App() { {activeModal === "join" && setModal(null)} />} {activeModal === "groupBy" && setModal(null)} />} {activeModal === "sampling" && setModal(null)} />} + {activeModal === "tagToColumn" && } + {activeModal === "annotationExport" && ( + setModal(null)} /> + )} {activeModal === "reshape" && setModal(null)} />} {activeModal === "recipes" && setModal(null)} />} {activeModal === "pii" && setModal(null)} />} @@ -331,6 +342,8 @@ export default function App() { {jsonImportPath && } + + diff --git a/src/components/AnnotationExportDialog.tsx b/src/components/AnnotationExportDialog.tsx new file mode 100644 index 0000000..ac5f0bd --- /dev/null +++ b/src/components/AnnotationExportDialog.tsx @@ -0,0 +1,57 @@ +import { useStore } from "../store/useStore"; +import { Modal } from "./Modal"; + +/** + * Explicit annotation export (F40). Notes, tags and marks never leave through + * an ordinary data export, so exporting them is a deliberate action offered + * here in two formats: versioned JSON (round-trippable) or flat CSV (one row + * per marked record / cell note, for a spreadsheet). + */ +export function AnnotationExportDialog({ onClose }: { onClose: () => void }) { + const exportToFile = useStore((s) => s.exportAnnotationsToFile); + const view = useStore((s) => s.annotationsView); + const total = view?.entries.length ?? 0; + + const run = (format: "json" | "csv") => { + void exportToFile(format); + onClose(); + }; + + return ( + + Cancel + + } + > +

+ Save this document’s {total} annotation{total === 1 ? "" : "s"} to a file. This is the only + way annotations leave CEESVEE — they are never written into an ordinary data export. +

+
+ + +
+
+ ); +} diff --git a/src/components/AnnotationsPanel.tsx b/src/components/AnnotationsPanel.tsx new file mode 100644 index 0000000..b33779f --- /dev/null +++ b/src/components/AnnotationsPanel.tsx @@ -0,0 +1,475 @@ +import { useMemo, useState } from "react"; + +import { + entryMatchesQuery, + entryPassesKind, + matchStatusLabel, + noteTimeLabel, + normalizeTagName, + predicateForKind, + sortEntries, + tagColor, + type AnnotationFilterKind, +} from "../lib/annotations"; +import { useActiveMeta, useStore } from "../store/useStore"; +import type { RowAnnotationView } from "../types"; +import { Close } from "./Icons"; + +/** Tiny inline glyphs so the panel echoes the grid gutter indicators. */ +const StarGlyph = ({ on }: { on: boolean }) => ( + + + +); + +const FlagGlyph = ({ on }: { on: boolean }) => ( + + + +); + +const FILTERS: { kind: AnnotationFilterKind; label: string }[] = [ + { kind: "all", label: "All" }, + { kind: "starred", label: "Starred" }, + { kind: "flagged", label: "Flagged" }, + { kind: "tagged", label: "Tagged" }, + { kind: "hasNote", label: "Notes" }, + { kind: "hasCellNote", label: "Cell notes" }, + { kind: "review", label: "Review" }, +]; + +/** + * The annotations panel (F40): every bookmark / tag / note for the active + * document, with type filters, search, jump-to-row, a review section for + * ambiguous / orphaned annotations, the tag namespace (with tag-to-column and + * remove), and the anchoring / author settings. Filtering the panel list is + * client-side; "Filter grid" pushes the state predicate into the row filter. + */ +export function AnnotationsPanel() { + const meta = useActiveMeta(); + const view = useStore((s) => s.annotationsView); + const setOpen = useStore((s) => s.setAnnotationsPanelOpen); + const jumpToCell = useStore((s) => s.jumpToCell); + const removeAnnotation = useStore((s) => s.removeAnnotation); + const discardOrphans = useStore((s) => s.discardAnnotationOrphans); + const applyFilter = useStore((s) => s.applyAnnotationFilter); + const exportToFile = useStore((s) => s.exportAnnotationsToFile); + const defineTag = useStore((s) => s.defineAnnotationTag); + const removeTag = useStore((s) => s.removeAnnotationTag); + const openTagToColumn = useStore((s) => s.openTagToColumn); + const openRowNote = useStore((s) => s.openRowNoteEditor); + const setAuthor = useStore((s) => s.setAnnotationAuthor); + const setKeySpec = useStore((s) => s.setAnnotationKeySpec); + + const [kind, setKind] = useState("all"); + const [query, setQuery] = useState(""); + const [newTag, setNewTag] = useState(""); + const [showTags, setShowTags] = useState(true); + const [showSettings, setShowSettings] = useState(false); + + const filtered = useMemo(() => { + if (!view) return [] as RowAnnotationView[]; + return sortEntries( + view.entries.filter((e) => entryPassesKind(e, kind) && entryMatchesQuery(e, query)), + ); + }, [view, kind, query]); + + if (!meta) return null; + + const reviewCount = (view?.ambiguous ?? 0) + (view?.orphaned ?? 0); + const headerFor = (columnId: string): string => { + const phys = meta.columnIds.indexOf(columnId); + return phys >= 0 ? meta.headers[phys] || `Column ${phys + 1}` : columnId; + }; + + const jumpTo = (record: number) => void jumpToCell(record, 0); + + return ( + + ); +} + +function EntryCard({ + entry, + headerFor, + onJump, + onRemove, + onEditNote, +}: { + entry: RowAnnotationView; + headerFor: (columnId: string) => string; + onJump: (record: number) => void; + onRemove: () => void; + onEditNote: () => void; +}) { + const statusClass = + entry.status === "matched" + ? "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300" + : entry.status === "ambiguous" + ? "bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-300" + : "bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300"; + + return ( +
+
+ + {matchStatusLabel(entry.status)} + + {entry.record != null ? ( + + ) : ( + + {entry.candidates && entry.candidates.length > 0 + ? `candidates: ${entry.candidates + .slice(0, 4) + .map((c) => c + 1) + .join(", ")}${entry.candidates.length > 4 ? "…" : ""}` + : "no matching row"} + + )} + {entry.star && } + {entry.flag && } + + +
+ + {entry.tags && entry.tags.length > 0 && ( +
+ {entry.tags.map((t) => ( + + {t} + + ))} +
+ )} + + {entry.note && ( + + )} + + {entry.cellNotes && entry.cellNotes.length > 0 && ( +
+ {entry.cellNotes.map((cn) => ( +
+ + {headerFor(cn.columnId)} + + {cn.note.text} +
+ ))} +
+ )} +
+ ); +} + +/** A compact multi-select of key columns for annotation anchoring. Applying + * writes a KeySpec of stable column ids (survives reorder/rename). */ +function KeyColumnsEditor({ + headers, + columnIds, + active, + onApply, +}: { + headers: string[]; + columnIds: string[]; + active: string[]; + onApply: (columns: string[]) => void; +}) { + const [sel, setSel] = useState(active); + const dirty = sel.join("") !== active.join(""); + + const toggle = (id: string) => + setSel((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id])); + + return ( +
+
+ Key columns + + +
+

+ Anchor annotations to these columns so they survive row reordering. +

+
+ {columnIds.map((id, i) => ( + + ))} +
+
+ ); +} diff --git a/src/components/Grid.tsx b/src/components/Grid.tsx index 02356aa..ea0730a 100644 --- a/src/components/Grid.tsx +++ b/src/components/Grid.tsx @@ -11,9 +11,11 @@ import { type GridSelection, type Item, type Rectangle, + type Theme, } from "@glideapps/glide-data-grid"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { buildRecordIndex, cellNoteColumns } from "../lib/annotations"; import { SENSITIVITY_LABELS } from "../lib/dictionary"; import { indicesToRanges } from "../lib/gridSelection"; import { darkGridTheme, dirtyCellOverride, lightGridTheme } from "../lib/gridTheme"; @@ -27,6 +29,7 @@ import type { DictionaryEntryView, DocumentMeta, LogicalType, + RowAnnotationView, Sensitivity, } from "../types"; import { ColumnMenu, type ColumnMenuState } from "./ColumnMenu"; @@ -122,6 +125,9 @@ export function Grid({ meta, dataVersion, dark }: GridProps) { const gridRef = useRef(null); const rowCache = useRef>(new Map()); const dirtyCache = useRef>(new Map()); + // F40: DISPLAY row -> absolute record number, fetched alongside each page so + // annotation gutter indicators land on the correct rows under sort/filter. + const recordCache = useRef>(new Map()); const inFlight = useRef>(new Set()); const visibleRegion = useRef(null); // Bumped on every cache invalidation; an in-flight fetch from a previous @@ -142,11 +148,17 @@ export function Grid({ meta, dataVersion, dark }: GridProps) { }); const [menu, setMenu] = useState(null); // F13: right-click cell menu (edit in the multiline editor / copy full value). + // F40 extends it with the row's annotation state so star/flag/note/tag/cell + // note actions can be offered inline. const [cellMenu, setCellMenu] = useState<{ row: number; col: number; x: number; y: number; + record: number | null; + columnId: string | null; + columnLabel: string; + entry: RowAnnotationView | undefined; } | null>(null); const findMatches = useStore((s) => s.find.matches); @@ -199,6 +211,21 @@ export function Grid({ meta, dataVersion, dark }: GridProps) { return map; }, [dictionaryEntries]); + // F40: the active document's annotations, indexed by resolved record number + // (matched entries only). Drives the row-gutter glyphs, row tint and per-cell + // note corners. `cellNoteIndex` is the record -> {column ids with a note} map + // consulted per cell in the custom draw. + const annotationsView = useStore((s) => s.annotationsView); + const recordIndex = useMemo(() => buildRecordIndex(annotationsView), [annotationsView]); + const cellNoteIndex = useMemo(() => { + const map = new Map>(); + for (const [record, entry] of recordIndex) { + const cols = cellNoteColumns(entry); + if (cols.size > 0) map.set(record, cols); + } + return map; + }, [recordIndex]); + const tooltipForPhys = useCallback( (phys: number): DictionaryTooltip | null => { const id = meta.columnIds[phys]; @@ -259,13 +286,21 @@ export function Grid({ meta, dataVersion, dark }: GridProps) { if (inFlight.current.has(page) || rowCache.current.has(startRow)) return; inFlight.current.add(page); try { - const resp = await api.getRows(id, startRow, PAGE); + // Fetch the row data and the display->record map together (F40): the + // latter lets annotation indicators track the current sort/filter. + const [resp, records] = await Promise.all([ + api.getRows(id, startRow, PAGE), + api.displayRecords(id, startRow, PAGE), + ]); // The document was invalidated while this fetch was in flight — drop it. if (gen !== generation.current) return; for (let i = 0; i < resp.rows.length; i++) { rowCache.current.set(resp.start + i, resp.rows[i]); dirtyCache.current.set(resp.start + i, resp.dirty[i]); } + for (let i = 0; i < records.length; i++) { + recordCache.current.set(startRow + i, records[i]); + } const updates: { cell: Item }[] = []; // Repaints happen in DISPLAY coordinates (the projection's width). const cols = projectionRef.current.physical.length; @@ -297,6 +332,7 @@ export function Grid({ meta, dataVersion, dark }: GridProps) { generation.current += 1; rowCache.current.clear(); dirtyCache.current.clear(); + recordCache.current.clear(); inFlight.current.clear(); const region = visibleRegion.current; const start = region ? Math.max(0, region.y - PAGE) : 0; @@ -314,6 +350,9 @@ export function Grid({ meta, dataVersion, dark }: GridProps) { // F38: refetch the dictionary alongside so header tooltips (display name / // description / unit / sensitivity) track renames and structural edits. void useStore.getState().loadDictionary(); + // F40: re-resolve annotations against the (possibly reparsed) document so + // the gutter indicators and the panel's review list stay in sync. + void useStore.getState().loadAnnotations(); }, [docId, dataVersion]); const onVisibleRegionChanged = useCallback( @@ -516,17 +555,26 @@ export function Grid({ meta, dataVersion, dark }: GridProps) { setMenu({ col: phys, x: bounds.x, y: bounds.y + bounds.height }); }, []); - const onCellContextMenu = useCallback((cell: Item, event: CellClickedEventArgs) => { - const [col, row] = cell; - if (row < 0 || col < 0) return; - event.preventDefault(); - setCellMenu({ - row, - col: projectionRef.current.physical[col] ?? col, - x: event.bounds.x + event.localEventX, - y: event.bounds.y + event.localEventY, - }); - }, []); + const onCellContextMenu = useCallback( + (cell: Item, event: CellClickedEventArgs) => { + const [col, row] = cell; + if (row < 0 || col < 0) return; + event.preventDefault(); + const phys = projectionRef.current.physical[col] ?? col; + const record = recordCache.current.get(row) ?? null; + setCellMenu({ + row, + col: phys, + x: event.bounds.x + event.localEventX, + y: event.bounds.y + event.localEventY, + record, + columnId: meta.columnIds[phys] ?? null, + columnLabel: meta.headers[phys] || `Column ${phys + 1}`, + entry: record == null ? undefined : recordIndex.get(record), + }); + }, + [recordIndex, meta.columnIds, meta.headers], + ); // ----- scroll to the active find match --------------------------------- @@ -647,6 +695,59 @@ export function Grid({ meta, dataVersion, dark }: GridProps) { const activeTip = headerTip ? tooltipForPhys(headerTip.phys) : null; + // ----- F40 annotation indicators ---------------------------------------- + + // The resolved annotation for a display row, via the display->record map. + // Uncertain (ambiguous/orphaned) entries are absent from `recordIndex`, so + // they never tint a row or draw a glyph. + const entryForDisplayRow = useCallback( + (row: number): RowAnnotationView | undefined => { + const record = recordCache.current.get(row); + return record == null ? undefined : recordIndex.get(record); + }, + [recordIndex], + ); + + // Subtly tint annotated rows so they read at a glance even off-glyph. Star + // wins (amber), then flag (rose), then any other annotation (violet). + const getRowThemeOverride = useCallback( + (row: number): Partial | undefined => { + const entry = entryForDisplayRow(row); + if (!entry) return undefined; + if (entry.star) return dark ? STAR_ROW_DARK : STAR_ROW_LIGHT; + if (entry.flag) return dark ? FLAG_ROW_DARK : FLAG_ROW_LIGHT; + return dark ? NOTE_ROW_DARK : NOTE_ROW_LIGHT; + }, + [entryForDisplayRow, dark], + ); + + // Overlay the gutter glyphs (first display column) and the per-cell note + // corner (any column) on top of the normal cell content. + const drawCell = useCallback( + ( + args: { + ctx: CanvasRenderingContext2D; + rect: Rectangle; + col: number; + row: number; + }, + draw: () => void, + ) => { + draw(); + const { ctx, rect, col, row } = args; + const entry = entryForDisplayRow(row); + if (!entry) return; + const record = recordCache.current.get(row); + const phys = projectionRef.current.physical[col] ?? col; + const colId = meta.columnIds[phys]; + if (record != null && colId && cellNoteIndex.get(record)?.has(colId)) { + drawCellNoteCorner(ctx, rect); + } + if (col === 0) drawGutterGlyphs(ctx, rect, entry); + }, + [entryForDisplayRow, cellNoteIndex, meta.columnIds], + ); + // Effective leading freeze: pinned view columns when any, else the manual // "freeze up to here" count (both clamped below the display width). const displayCount = projection.physical.length; @@ -674,6 +775,8 @@ export function Grid({ meta, dataVersion, dark }: GridProps) { onItemHovered={onItemHovered} onHeaderMenuClick={onHeaderMenuClick} onCellContextMenu={onCellContextMenu} + getRowThemeOverride={getRowThemeOverride} + drawCell={drawCell} gridSelection={selection} onGridSelectionChange={onGridSelectionChange} getCellsForSelection={getCellsForSelection} @@ -714,6 +817,133 @@ function rectOf(range: Rectangle) { return { x: range.x, y: range.y, width: range.width, height: range.height }; } +// ----- F40 annotation drawing ------------------------------------------------- + +const STAR_COLOR = "#f59e0b"; // amber-500 +const FLAG_COLOR = "#f43f5e"; // rose-500 +const NOTE_COLOR = "#8b5cf6"; // violet-500 + +// Subtle full-row tints (bgCell + bgCellMedium so alternating stripes match). +const STAR_ROW_LIGHT: Partial = { bgCell: "#fef9ec", bgCellMedium: "#fdf4dd" }; +const STAR_ROW_DARK: Partial = { bgCell: "#221d10", bgCellMedium: "#282111" }; +const FLAG_ROW_LIGHT: Partial = { bgCell: "#fef2f3", bgCellMedium: "#fde8ea" }; +const FLAG_ROW_DARK: Partial = { bgCell: "#241417", bgCellMedium: "#2a161a" }; +const NOTE_ROW_LIGHT: Partial = { bgCell: "#f7f5fe", bgCellMedium: "#efeafd" }; +const NOTE_ROW_DARK: Partial = { bgCell: "#1c1a2b", bgCellMedium: "#211d33" }; + +/** A small filled five-point star centred at (cx, cy). */ +function drawStar(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) { + ctx.beginPath(); + for (let i = 0; i < 10; i += 1) { + const radius = i % 2 === 0 ? r : r * 0.45; + const angle = (Math.PI / 5) * i - Math.PI / 2; + const x = cx + radius * Math.cos(angle); + const y = cy + radius * Math.sin(angle); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.closePath(); + ctx.fillStyle = STAR_COLOR; + ctx.fill(); +} + +/** A small pennant flag with its centre near (cx, cy). */ +function drawFlag(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) { + const top = cy - r; + const bottom = cy + r; + const poleX = cx - r * 0.7; + ctx.strokeStyle = FLAG_COLOR; + ctx.lineWidth = 1.4; + ctx.beginPath(); + ctx.moveTo(poleX, top); + ctx.lineTo(poleX, bottom); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(poleX, top); + ctx.lineTo(poleX + r * 1.5, top + r * 0.55); + ctx.lineTo(poleX, top + r * 1.1); + ctx.closePath(); + ctx.fillStyle = FLAG_COLOR; + ctx.fill(); +} + +/** A small "note" chit (rounded square) for a row note, or a dot for tags. */ +function drawNoteMark( + ctx: CanvasRenderingContext2D, + cx: number, + cy: number, + r: number, + kind: "note" | "tag", +) { + ctx.fillStyle = NOTE_COLOR; + if (kind === "tag") { + ctx.beginPath(); + ctx.arc(cx, cy, r * 0.7, 0, Math.PI * 2); + ctx.fill(); + return; + } + const s = r * 1.5; + const x = cx - s / 2; + const y = cy - s / 2; + const rad = 2; + ctx.beginPath(); + ctx.moveTo(x + rad, y); + ctx.arcTo(x + s, y, x + s, y + s, rad); + ctx.arcTo(x + s, y + s, x, y + s, rad); + ctx.arcTo(x, y + s, x, y, rad); + ctx.arcTo(x, y, x + s, y, rad); + ctx.closePath(); + ctx.fill(); +} + +/** Draw the row's annotation glyphs (star / flag / note or tag) stacked at the + * left edge of the first display column — the grid's "gutter" next to the row + * marker. */ +function drawGutterGlyphs( + ctx: CanvasRenderingContext2D, + rect: Rectangle, + entry: RowAnnotationView, +) { + const r = 5; + const cy = rect.y + rect.height / 2; + let cx = rect.x + r + 2; + const step = r * 2 + 2; + ctx.save(); + if (entry.star) { + drawStar(ctx, cx, cy, r); + cx += step; + } + if (entry.flag) { + drawFlag(ctx, cx, cy, r); + cx += step; + } + if (entry.note != null) { + drawNoteMark(ctx, cx, cy, r, "note"); + cx += step; + } else if ((entry.tags?.length ?? 0) > 0) { + drawNoteMark(ctx, cx, cy, r, "tag"); + cx += step; + } + ctx.restore(); +} + +/** A small filled corner triangle marking a cell note (top-right, like a + * spreadsheet comment marker). */ +function drawCellNoteCorner(ctx: CanvasRenderingContext2D, rect: Rectangle) { + const size = 7; + const x = rect.x + rect.width; + const y = rect.y; + ctx.save(); + ctx.beginPath(); + ctx.moveTo(x - size, y); + ctx.lineTo(x, y); + ctx.lineTo(x, y + size); + ctx.closePath(); + ctx.fillStyle = NOTE_COLOR; + ctx.fill(); + ctx.restore(); +} + /** The documentation shown in a column header tooltip (F38). */ interface DictionaryTooltip { displayName?: string; @@ -765,19 +995,34 @@ function HeaderTooltip({ x, y, tip }: { x: number; y: number; tip: DictionaryToo } /** Right-click cell menu (F13): open the multiline editor or copy the FULL - * value (fetched from Rust — the grid cache may hold only visible rows). */ + * value (fetched from Rust — the grid cache may hold only visible rows). F40 + * adds inline annotation actions (star / flag / tag / row note / cell note) + * for the row under the cursor; these never touch the source data. */ function CellContextMenu({ state, docId, readOnly, onClose, }: { - state: { row: number; col: number; x: number; y: number }; + state: { + row: number; + col: number; + x: number; + y: number; + record: number | null; + columnId: string | null; + columnLabel: string; + entry: RowAnnotationView | undefined; + }; docId: number; readOnly: boolean; onClose: () => void; }) { const openCellEditor = useStore((s) => s.openCellEditor); + const applyRowMarks = useStore((s) => s.applyRowMarks); + const openRowNoteEditor = useStore((s) => s.openRowNoteEditor); + const openCellNoteEditor = useStore((s) => s.openCellNoteEditor); + const openTagPicker = useStore((s) => s.openTagPicker); const ref = useRef(null); useEffect(() => { @@ -808,12 +1053,64 @@ function CellContextMenu({ const item = "block w-full px-3 py-1.5 text-left text-sm text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-700"; + const entry = state.entry; + const starred = entry?.star ?? false; + const flagged = entry?.flag ?? false; + // A record number labels the row for note dialogs (1-based, human). + const rowLabel = state.record != null ? `Row ${state.record + 1}` : `Row ${state.row + 1}`; + const hasCellNote = !!( + state.columnId && entry?.cellNotes?.some((n) => n.columnId === state.columnId) + ); + const act = (fn: () => void) => { + fn(); + onClose(); + }; + return (
+ + + + + {state.columnId && ( + + )} +
+ )} + + + + } + > +