diff --git a/cockpit/README.md b/cockpit/README.md index 9d830bb0..b273b9c8 100644 --- a/cockpit/README.md +++ b/cockpit/README.md @@ -47,8 +47,11 @@ downloads an export copy. **Manifest** then folds the whole OPFS corpus into a **dock** — browse and filter the captured chunks by class/tag, rights status, and cohort, with an aggregate dashboard (totals, redistributable / near-duplicate / rights-unset counts, top tags) over the shared `griff_ui_core::dock` (ADR-0027 -Slice 5; the `c` key toggles it). The `i` key opens the capture panel to edit the -curator inputs (id / title / rights / tags…) first. +Slice 5; the `c` key toggles it). Selecting a chunk opens the **curation +inspector** — approve / reject, rename, and retag — each edit applied through the +shared `griff_ui_core::curation` ops and persisted back to its OPFS `chunk.json` +(ADR-0027 Slice 6). The `i` key opens the capture panel to edit the curator +inputs (id / title / rights / tags…) first. ## Web tests diff --git a/cockpit/src/lib.rs b/cockpit/src/lib.rs index 51f2867b..7d443915 100644 --- a/cockpit/src/lib.rs +++ b/cockpit/src/lib.rs @@ -29,10 +29,12 @@ use eframe::egui::{self, Align2, Color32, CornerRadius, FontId, Key, Rect}; use griff_core::classify::BarClass; -use griff_core::corpus::{ChunkMeta, RightsStatus, StyleCohort, SwancoreTag}; +use griff_core::corpus::{ChunkMeta, ReviewerDecision, RightsStatus, StyleCohort, SwancoreTag}; use griff_core::import::import_score_auto; use griff_core::score::Score; use griff_ui_core::scene::{resolve, CellRole, GridSize, SceneCell, GUTTER}; +use griff_ui_core::curation::{decide_record, rename_record, set_tags, tag_palette}; +use griff_ui_core::viewport::CurationDecision; use griff_ui_core::{ analyze, build_chunk, build_view, filter_chunks, Analysis, CaptureInputs, CorpusFilter, CorpusStats, Intent, PianoRollView, Step, ViewContext, Viewport, @@ -221,13 +223,21 @@ fn tag_combo(ui: &mut egui::Ui, value: &mut Option) { }); } -/// One row of the dock's chunk list: id, cohort, rights, and dup/redist marks. -fn chunk_row(ui: &mut egui::Ui, chunk: &ChunkMeta) { +/// One row of the dock's chunk list — a selectable label (a click selects the +/// chunk for curation), prefixed by its reviewer/dup marks, plus cohort and +/// rights. Returns whether the row was clicked this frame. +fn chunk_row(ui: &mut egui::Ui, chunk: &ChunkMeta, selected: bool) -> bool { ui.horizontal(|ui| { - if chunk.duplicate.is_some() { - ui.label("≈").on_hover_text("near-duplicate"); - } - ui.label(&chunk.id.0); + let mark = match chunk.reviewer { + Some(ReviewerDecision::Accepted) => "✓ ", + Some(ReviewerDecision::Rejected) => "✗ ", + // No decision yet reads as "needs a look", like an explicit NeedsReview. + Some(ReviewerDecision::NeedsReview) | None => "? ", + }; + let dup = if chunk.duplicate.is_some() { "≈ " } else { "" }; + let clicked = ui + .selectable_label(selected, format!("{mark}{dup}{}", chunk.id.0)) + .clicked(); if let Some(cohort) = chunk.style_cohort { ui.weak(format!("{cohort:?}")); } @@ -243,6 +253,50 @@ fn chunk_row(ui: &mut egui::Ui, chunk: &ChunkMeta) { ui.weak("rights?"); } } + clicked + }) + .inner +} + +/// The curation inspector for the selected chunk (ADR-0027 Slice 6): rename, +/// approve / reject, and retag. Each control sets the `action` the dock then +/// applies through the shared `griff_ui_core::curation` ops. +fn inspector( + ui: &mut egui::Ui, + chunk: &ChunkMeta, + rename_buf: &mut String, + action: &mut Option, +) { + ui.label(format!("▸ {}", chunk.id.0)); + ui.horizontal(|ui| { + ui.text_edit_singleline(rename_buf); + if ui.button("rename").clicked() { + *action = Some(CurationAction::Rename(rename_buf.clone())); + } + }); + ui.horizontal(|ui| { + if ui.button("✓ approve").clicked() { + *action = Some(CurationAction::Decide(CurationDecision::Approve)); + } + if ui.button("✗ reject").clicked() { + *action = Some(CurationAction::Decide(CurationDecision::Reject)); + } + }); + ui.label("tags"); + let palette = tag_palette(); + let mut names: Vec = chunk.tags.iter().filter_map(|&t| tag_wire(t)).collect(); + ui.horizontal_wrapped(|ui| { + for tag in &palette { + let present = names.contains(tag); + if ui.selectable_label(present, tag.as_str()).clicked() { + if present { + names.retain(|n| n != tag); + } else { + names.push(tag.clone()); + } + *action = Some(CurationAction::Retag(names.clone())); + } + } }); } @@ -379,6 +433,53 @@ fn save_chunk(filename: &str, json: &str) -> Result<(), String> { fs::write(filename, json).map_err(|err| err.to_string()) } +/// The OPFS/disk filename for a chunk `id` — a slug so a stray `/` or `..` can't +/// escape the corpus dir (#98); the chunk's own id keeps its raw value. Capture +/// and curation derive the same name, so an edit overwrites its own file. +fn chunk_filename(id: &str) -> String { + let slug: String = id + .trim() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '_' }) + .collect(); + let stem = slug.trim_matches('_'); + let stem = if stem.is_empty() { "chunk" } else { stem }; + format!("{stem}.chunk.json") +} + +/// Re-persists an edited chunk to the corpus (OPFS on web, the working dir on +/// native) — no download, unlike capture's `save_chunk`. +// The OPFS write is fire-and-forget, so the web arm is infallible; the signature +// matches the fallible native arm so callers treat both the same. +#[cfg(target_arch = "wasm32")] +#[allow(clippy::unnecessary_wraps)] +fn persist_chunk(filename: &str, json: &str) -> Result<(), String> { + web::persist(filename, json); + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn persist_chunk(filename: &str, json: &str) -> Result<(), String> { + use std::fs; + fs::write(filename, json).map_err(|err| err.to_string()) +} + +/// A curation edit on the selected chunk (ADR-0027 Slice 6), applied through the +/// shared `griff_ui_core::curation` ops so the cockpit never reimplements them. +enum CurationAction { + /// Approve / reject — sets the reviewer decision. + Decide(CurationDecision), + /// Rename the chunk's title. + Rename(String), + /// Replace the chunk's tags with this set (wire names). + Retag(Vec), +} + +/// A `SwancoreTag`'s wire name (`snake_case`), for the retag toggles. +fn tag_wire(tag: SwancoreTag) -> Option { + serde_json::to_value(tag).ok()?.as_str().map(ToOwned::to_owned) +} + /// The egui cockpit application: a `Scene` renderer over the shared core. #[derive(Debug)] pub struct CockpitApp { @@ -400,6 +501,12 @@ pub struct CockpitApp { corpus_filter: CorpusFilter, /// Whether the corpus dock window is shown (the `c` key toggles it). show_dock: bool, + /// The selected chunk's id, for the curation inspector (ADR-0027 Slice 6). + selected: Option, + /// The inspector's rename-field buffer (seeded from the selection's title). + rename_buf: String, + /// The last curation action's outcome, shown in the dock. + dock_status: Option, } impl CockpitApp { @@ -421,6 +528,9 @@ impl CockpitApp { corpus: Vec::new(), corpus_filter: CorpusFilter::default(), show_dock: false, + selected: None, + rename_buf: String::new(), + dock_status: None, } } @@ -483,19 +593,7 @@ impl CockpitApp { fn do_capture(&mut self) { let now = now_rfc3339(); let result = self.capture_json(&self.form.inputs(&now, &now)).and_then(|json| { - // Slug the (user-editable) id for the filename so a stray `/` or `..` - // can't escape the corpus dir (#98 review); the chunk's own id keeps - // the raw value. Mirrors `seed_from`'s slug. - let slug: String = self - .form - .id - .trim() - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '_' }) - .collect(); - let stem = slug.trim_matches('_'); - let stem = if stem.is_empty() { "chunk" } else { stem }; - let filename = format!("{stem}.chunk.json"); + let filename = chunk_filename(&self.form.id); save_chunk(&filename, &json).map(|()| filename) }); self.form.status = Some(match result { @@ -541,10 +639,63 @@ impl CockpitApp { self.show_dock = true; } + /// Applies a curation edit to the chunk `id` through the shared + /// `griff_ui_core::curation` ops, updating the in-memory corpus; returns the + /// re-serialized `chunk.json` to persist. + /// + /// # Errors + /// A message if the chunk is absent or the op rejects the edit. + fn curate(&mut self, id: &str, action: &CurationAction) -> Result { + let idx = self.corpus.iter().position(|c| c.id.0 == id).ok_or("no such chunk")?; + let json = serde_json::to_string(self.corpus.get(idx).ok_or("no such chunk")?) + .map_err(|err| err.to_string())?; + let edited = match action { + CurationAction::Decide(decision) => decide_record(&json, *decision), + CurationAction::Rename(title) => rename_record(&json, title), + CurationAction::Retag(names) => set_tags(&json, names), + } + .map_err(|err| format!("{err:?}"))?; + let meta: ChunkMeta = serde_json::from_str(&edited).map_err(|err| err.to_string())?; + *self.corpus.get_mut(idx).ok_or("no such chunk")? = meta; + Ok(edited) + } + + /// Curates the chunk `id` and persists it back to the corpus, recording the + /// outcome in the dock's status line. On a (synchronous, native) persist + /// failure the in-memory edit is rolled back so the dock matches disk. + fn apply_curation(&mut self, id: &str, action: &CurationAction) { + let snapshot = self + .corpus + .iter() + .position(|c| c.id.0 == id) + .and_then(|idx| self.corpus.get(idx).cloned().map(|chunk| (idx, chunk))); + self.dock_status = Some(match self.curate(id, action) { + Ok(json) => { + let filename = chunk_filename(id); + match persist_chunk(&filename, &json) { + Ok(()) => format!("saved {filename}"), + Err(err) => { + // The persist failed — undo the in-memory edit so the dock + // never shows a change that didn't reach disk. + if let Some((idx, before)) = snapshot { + if let Some(slot) = self.corpus.get_mut(idx) { + *slot = before; + } + } + format!("save failed: {err}") + } + } + } + Err(err) => format!("curate failed: {err}"), + }); + } + /// The corpus dock (ADR-0027 Slice 5): an aggregate dashboard, browse filters /// (class/tag · rights · cohort · dedup), and the filtered chunk list over the /// loaded `corpus`. On web the 📚 toolbar button fills it from OPFS; `c` toggles. fn corpus_dock(&mut self, ctx: &egui::Context) { + let mut clicked: Option = None; + let mut action: Option<(String, CurationAction)> = None; egui::Window::new("corpus").default_width(360.0).show(ctx, |ui| { let stats = CorpusStats::aggregate(&self.corpus); if stats.total == 0 { @@ -578,15 +729,47 @@ impl CockpitApp { ui.checkbox(&mut self.corpus_filter.duplicates_only, "dups only"); }); ui.separator(); - // ── filtered list ── + // ── filtered list (selectable) ── let kept = filter_chunks(&self.corpus, &self.corpus_filter); ui.label(format!("{} / {} shown", kept.len(), stats.total)); - egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { + egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| { for chunk in kept { - chunk_row(ui, chunk); + let is_sel = self.selected.as_deref() == Some(chunk.id.0.as_str()); + if chunk_row(ui, chunk, is_sel) { + clicked = Some(chunk.id.0.clone()); + } } }); + // ── curation inspector (ADR-0027 Slice 6) ── + if let Some(id) = self.selected.clone() { + if let Some(chunk) = self.corpus.iter().find(|c| c.id.0 == id) { + ui.separator(); + // Bind any edit to *this* chunk's id, so a same-frame row click + // (which changes the selection below) can't redirect it. + let mut pending = None; + inspector(ui, chunk, &mut self.rename_buf, &mut pending); + if let Some(pending) = pending { + action = Some((chunk.id.0.clone(), pending)); + } + } + } + if let Some(status) = &self.dock_status { + ui.weak(status); + } }); + // Apply the click/curation after the closure releases its borrows. + if let Some(id) = clicked { + if self.selected.as_deref() != Some(id.as_str()) { + if let Some(chunk) = self.corpus.iter().find(|c| c.id.0 == id) { + self.rename_buf = chunk.title.clone(); + } + self.dock_status = None; + } + self.selected = Some(id); + } + if let Some((id, action)) = action { + self.apply_curation(&id, &action); + } } /// Drains the frame's key presses into the reducer; returns whether the @@ -1395,6 +1578,42 @@ mod tests { let _frame = ctx.run(egui::RawInput::default(), |ctx| app.corpus_dock(ctx)); } + #[test] + #[allow(deprecated)] // egui 0.34 flags `Context::run`; it still drives a CPU frame. + fn curate_decides_renames_and_retags_the_selected_chunk() { + use griff_core::import::import_score_auto; + use griff_ui_core::{build_chunk, CaptureInputs}; + let score = import_score_auto(include_bytes!("../assets/demo.mid")).expect("demo imports"); + let chunk_json = |id: &str| { + let inputs = + CaptureInputs { id, created_at: "t", updated_at: "t", ..CaptureInputs::default() }; + serde_json::to_string(&build_chunk(&score, 0, &inputs).expect("builds")).expect("json") + }; + + let mut app = demo_app(); + app.load_corpus(&[chunk_json("riff_a")]); + app.selected = Some("riff_a".to_owned()); + + // approve → reviewer decision; rename → title; retag → exact tag set. + app.curate("riff_a", &CurationAction::Decide(CurationDecision::Approve)).expect("approves"); + assert_eq!( + app.corpus.first().expect("chunk").reviewer, + Some(ReviewerDecision::Accepted), + ); + app.curate("riff_a", &CurationAction::Rename("My Riff".to_owned())).expect("renames"); + assert_eq!(app.corpus.first().expect("chunk").title, "My Riff"); + app.curate("riff_a", &CurationAction::Retag(vec!["clean_riff".to_owned()])).expect("retags"); + assert_eq!(app.corpus.first().expect("chunk").tags, vec![SwancoreTag::CleanRiff]); + + // an unknown id is rejected, not a panic. + app.curate("ghost", &CurationAction::Decide(CurationDecision::Reject)) + .expect_err("no such chunk"); + + // the inspector renders for the selection without panicking. + let ctx = egui::Context::default(); + let _frame = ctx.run(egui::RawInput::default(), |ctx| app.corpus_dock(ctx)); + } + mod fuzz { use super::*; use proptest::prelude::*; diff --git a/docs/decisions.log.md b/docs/decisions.log.md index 3bf85768..e59a0d86 100644 --- a/docs/decisions.log.md +++ b/docs/decisions.log.md @@ -1349,3 +1349,13 @@ Architectural decisions go to [`adr/`](adr/) instead. cross-corpus similarity — that is Slice 7's `find_similar_chunks`. Accepting that the dock reads the OPFS corpus through the page (the `load_corpus` export, mirroring the manifest fold), not a Rust OPFS directory walk. + +- 2026-06-22 — In the context of curation actions (ADR-0027 Slice 6), facing the + six listed ops, we decided for wiring `decide` / `rename` / `retag` — the + per-chunk edits — into a dock inspector that routes through the shared + `griff-ui-core::curation` JSON→JSON ops and re-persists the chunk to its OPFS + file, and against reimplementing the edits in the renderer (ADR-0016). Split / + merge are deferred: they need `source.bar_range`, which capture-built chunks + leave `None`, so they apply to CLI-split corpora, not phone captures — a later + slice. Accepting that `decide` only reaches `Accepted`/`Rejected` (the UI + `CurationDecision` has no `NeedsReview`), and that each retag toggle persists.