From 18f8eb9b4a7dea584f4eb220ea9b4aaaab8eedc4 Mon Sep 17 00:00:00 2001 From: CUBELinux Date: Tue, 8 Sep 2026 05:29:06 -0400 Subject: [PATCH] cubesys/cubecli/cube-notes-mcp: note categories + project threads. notes use doc_type 'note:' for categories and associate to a 'project' record (linked_records) for project threads; cube note add|list|search accept --project/--cat/--since/--until; new 'cube project list' + 'cube project show ' (walk a project's thread). MCP server exposes note_write/list/search/show + project_list/project_show. 4 notes tests pass; workspace check green. --- cube-notes-mcp.py | 82 +++++-- cubecli/src/main.rs | 10 +- cubesys/src/commands.rs | 3 + cubesys/src/notes.rs | 474 ++++++++++++++++++++++++++++++++-------- 4 files changed, 457 insertions(+), 112 deletions(-) diff --git a/cube-notes-mcp.py b/cube-notes-mcp.py index 2664a90..7519ac5 100644 --- a/cube-notes-mcp.py +++ b/cube-notes-mcp.py @@ -1,18 +1,17 @@ #!/usr/bin/env python3 """cube-notes-mcp — MCP (stdio) server exposing the CUBELinux-2 notes CLI as -model-facing tools `mcp__cubenotes__note_*`. +model-facing tools `mcp__cubenotes__note_*` / `mcp__cubenotes__project_*`. -Wraps `cube note ...` (the durable WordFlags message-save-path) so the agent -can write / list / search / show session notes from the harness. The harness -spawns this server via the `mcp-client` plugin (see the profile's -cordis.patch.yml); each tool call runs the `cube` binary, which persists to -`$CUBE_NOTES_DIR` (default ~/.cubelinux-notes). +Wraps `cube note ...` / `cube project ...` (the durable WordFlags +message-save-path with categories + project threads). The harness spawns this +server via the `mcp-client` plugin; each tool call runs the `cube` binary, +which persists to `$CUBE_NOTES_DIR` (default ~/.cubelinux-notes). """ import sys, json, subprocess CUBE = "/home/CUBELinux/CUBELinux-2/target/release/cube" SERVER_NAME = "cube-notes-mcp" -SERVER_VERSION = "0.1.0" +SERVER_VERSION = "0.2.0" PROTOCOL_VERSION = "2024-11-05" @@ -20,32 +19,52 @@ def tool_schemas(): return [ { "name": "note_write", - "description": "Add a session note to the durable WordFlags CUBE message-save-path " - "(per-session/per-day, searchable). Returns the note's C.Z.Y.X coord.", + "description": "Add a note to the durable WordFlags message-save-path. " + "Optional: project P associates it to a project thread, " + "category C categorises it (doc_type note:C). Returns the coord.", "inputSchema": {"type": "object", "properties": { - "text": {"type": "string", "description": "Note text (subject + body)."} + "text": {"type": "string"}, + "project": {"type": "string", "description": "Project name (thread)."}, + "category": {"type": "string", "description": "e.g. design, impl, cube."}, }, "required": ["text"]}, }, { "name": "note_list", - "description": "List notes (newest first). Optionally filter by session label " - "(default: all), or by a date.", + "description": "List notes (newest first), optionally filtered by session, project, category, since/until (YYYY-MM-DD).", "inputSchema": {"type": "object", "properties": { - "session": {"type": "string", "description": "Session label to filter by."}, + "session": {"type": "string"}, + "project": {"type": "string"}, + "category": {"type": "string"}, + "since": {"type": "string"}, + "until": {"type": "string"}, }}, }, { "name": "note_search", - "description": "Substring-search notes across sessions (case-insensitive).", + "description": "Substring-search notes, optionally scoped to a project/category.", "inputSchema": {"type": "object", "properties": { - "query": {"type": "string", "description": "Substring to find."}, + "query": {"type": "string"}, + "project": {"type": "string"}, + "category": {"type": "string"}, }, "required": ["query"]}, }, { "name": "note_show", - "description": "Show one note's subject + body by its C.Z.Y.X coordinate.", + "description": "Show one note by C.Z.Y.X.", "inputSchema": {"type": "object", "properties": { - "coord": {"type": "string", "description": "Note coordinate (C.Z.Y.X)."}, + "coord": {"type": "string"}, + }, "required": ["coord"]}, + }, + { + "name": "project_list", + "description": "List all project threads (doc_type=project).", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "project_show", + "description": "Walk a project's associated notes (its thread/timeline) by C.Z.Y.X.", + "inputSchema": {"type": "object", "properties": { + "coord": {"type": "string"}, }, "required": ["coord"]}, }, ] @@ -62,20 +81,41 @@ def run_cube(args): return f"error: {e}" +def opt(flag, val): + return [flag, str(val)] if val else [] + + def dispatch(name, args): args = args or {} if name == "note_write": text = (args.get("text") or "").strip() - return run_cube(["note", "add", text]) if text else "error: missing 'text'" + if not text: + return "error: missing 'text'" + return run_cube(["note", "add"] + opt("--project", args.get("project")) + + opt("--cat", args.get("category")) + [text]) if name == "note_list": - session = args.get("session") - return run_cube(["note", "list", session] if session else ["note", "list"]) + cmd = ["note", "list"] + cmd += opt("--project", args.get("project")) + cmd += opt("--cat", args.get("category")) + cmd += opt("--since", args.get("since")) + cmd += opt("--until", args.get("until")) + if args.get("session"): + cmd.append(args["session"]) + return run_cube(cmd) if name == "note_search": q = (args.get("query") or "").strip() - return run_cube(["note", "search", q]) if q else "error: missing 'query'" + if not q: + return "error: missing 'query'" + return run_cube(["note", "search", q] + opt("--project", args.get("project")) + + opt("--cat", args.get("category"))) if name == "note_show": c = (args.get("coord") or "").strip() return run_cube(["note", "show", c]) if c else "error: missing 'coord'" + if name == "project_list": + return run_cube(["project", "list"]) + if name == "project_show": + c = (args.get("coord") or "").strip() + return run_cube(["project", "show", c]) if c else "error: missing 'coord'" return f"error: unknown tool {name}" diff --git a/cubecli/src/main.rs b/cubecli/src/main.rs index a9f3eef..3c3f26a 100644 --- a/cubecli/src/main.rs +++ b/cubecli/src/main.rs @@ -64,6 +64,8 @@ fn is_command_word(w: &str) -> bool { | "audit" | "note" | "notes" + | "project" + | "projects" ) } @@ -101,9 +103,13 @@ fn main() { let line = args[1..].join(" "); // Session notes live in their own DURABLE store so a note written // in one session is reviewable in the next (the message save path). - if word == "note" || word == "notes" { + if word == "note" || word == "notes" || word == "project" || word == "projects" { let store = open_notes_store(); - let res = cubesys::notes::note_command(&store, &line); + let res = if word == "project" || word == "projects" { + cubesys::notes::project_command(&store, &line) + } else { + cubesys::notes::note_command(&store, &line) + }; // Force a durable flush/checkpoint before exiting so the note is // persisted even though this process (the CLI) is short-lived. store.checkpoint(); diff --git a/cubesys/src/commands.rs b/cubesys/src/commands.rs index 1b7ed91..85f8871 100644 --- a/cubesys/src/commands.rs +++ b/cubesys/src/commands.rs @@ -512,6 +512,9 @@ impl Session { // (the structured replacement for the hand-maintained RESUME-*.md // files and the Hermes session store). "note" | "notes" => crate::notes::note_command(store, line), + // Project threads: list projects / walk a project's associated notes + // (the first-class-association model for tracking work between sessions). + "project" | "projects" => crate::notes::project_command(store, line), "trace-capture" => { let src_s = it.next().ok_or_else(|| "trace-capture needs ".to_string())?; let tgt_s = it.next().ok_or_else(|| "trace-capture needs ".to_string())?; diff --git a/cubesys/src/notes.rs b/cubesys/src/notes.rs index 1f29901..8041241 100644 --- a/cubesys/src/notes.rs +++ b/cubesys/src/notes.rs @@ -1,19 +1,19 @@ -//! CUBELinux session-message / note log. +//! CUBELinux session-message / note log with **categories** and **projects**. //! -//! A small, searchable note area backed by the cube store instead of the -//! growing hand-maintained `RESUME-*.md` files. Each note is a CZYX record -//! with `doc_type = "note"`, a text-type [`WordFlags`] stamp, a `created_at` -//! day, and the session label as the owner, so a new session can review prior -//! sessions' messages by scanning the note type, filtering by session/day, or -//! full-text searching — the "message save path" helper. +//! The "message save path" helper. Each note is a CZYX record with a text-type +//! [`WordFlags`] stamp, a `created_at` day, and the session label as the owner. +//! Two extra, searchable dimensions layer on top: +//! * **category** — encoded in `doc_type` as `note:` (so +//! [`scan_by_type`] / filtering slices notes by kind: design, impl, cube, ...). +//! * **project** — notes are *associated* (via `linked_records`) to a +//! `project` record (`doc_type = "project"`), so `cube project walk` +//! follows the links into a project's timeline/thread. This is the +//! CUBELinux first-class-association model (EDG) applied to project work. //! -//! Convention: -//! * `doc_type = "note"` (all notes). -//! * `created_at` (epoch seconds) groups the note into a day. -//! * `owner_local_user` holds the session label. -//! * `word_flags` = a complete single-record frame + text-type note. -//! * The C axis is a per-session class byte so `scan_prefix` can return a -//! session's notes; Z/Y/X are content-derived (collision-resolved). +//! Cross-session: `created_at` (day) + session label + category + project make +//! any note recoverable as `note search [--project p] [--cat c]`, and a +//! new harness session can `note list --project p` / `project walk` to see where +//! a project left off. use std::time::{SystemTime, UNIX_EPOCH}; @@ -21,8 +21,10 @@ use cubecoords::{CubeHeader, Czyx, WordFlags}; use crate::store::ConcurrentStore; -/// `doc_type` stamped on every note record. +/// Base `doc_type` for a note without a category. pub const NOTE_DOC_TYPE: &str = "note"; +/// `doc_type` for a project record. +pub const PROJECT_DOC_TYPE: &str = "project"; /// The default session label for CLI-created notes (today's calendar date). pub fn default_session() -> String { @@ -51,12 +53,33 @@ pub fn now() -> u64 { pub struct Note { /// The note's coordinate (`C.Z.Y.X`). pub coord: Czyx, - /// The note's record header (title, session label, WordFlags, ...). + /// The note's record header (title, session label, WordFlags, category). pub header: CubeHeader, /// The note's body text as raw bytes. pub body: Vec, } +/// Query dimensions for listing/searching notes. `since`/`until` are +/// day-of-epoch (see [`date_to_day`]); the rest are exact-match labels. +#[derive(Clone, Default, Debug)] +pub struct NoteFilter { + /// Session label (owner_local_user). + pub session: Option, + /// Project name (note must be associated to that project record). + pub project: Option, + /// Category (doc_type `note:`). + pub category: Option, + /// Earliest day-of-epoch, inclusive. + pub since: Option, + /// Latest day-of-epoch, inclusive. + pub until: Option, +} + +/// The category portion of a note's `doc_type`, if any (`note:`). +pub fn category_of(doc_type: &str) -> Option<&str> { + doc_type.strip_prefix(&format!("{NOTE_DOC_TYPE}:")) +} + fn fnv1a(bytes: &[u8]) -> u64 { let mut h: u64 = 0xcbf2_9ce4_8422_2325; for &b in bytes { @@ -81,6 +104,13 @@ fn session_class(session: &str) -> u8 { 1 + ((h >> 24) as u8 & 0x7f) // 1..=128 } +/// Project records live in the upper class range (128..=255) so they never +/// collide with note session classes (1..=128). +fn project_class(name: &str) -> u8 { + let h = fnv1a(name.as_bytes()); + 128 + ((h >> 24) as u8 & 0x7f) +} + /// Derive a deterministic, collision-light coordinate for a note: the C axis /// is the session class, Z/Y/X come from the note content + a salt. fn note_coord(session: &str, created: u64, subject: &str, body: &str, salt: u64) -> Czyx { @@ -97,15 +127,74 @@ fn note_coord(session: &str, created: u64, subject: &str, body: &str, salt: u64) ) } +/// Derive the deterministic coordinate of a project record. +fn project_coord(name: &str, salt: u64) -> Czyx { + let mut mix = fnv1a(name.as_bytes()); + mix ^= salt.wrapping_mul(0x8549_b2a1); + Czyx::new( + project_class(name), + non_zero((mix >> 16) as u8), + non_zero((mix >> 8) as u8), + non_zero(mix as u8 ^ (salt as u8)), + ) +} + +/// Find an existing project record by name, without creating it. +pub fn find_project(store: &ConcurrentStore, name: &str) -> Option { + store + .read_snapshot() + .query_by_type(PROJECT_DOC_TYPE) + .into_iter() + .find(|(_, h, _)| { + h.title.as_deref() == Some(name) + || h.doc_type.as_deref() == Some(PROJECT_DOC_TYPE) && h.path.as_deref() == Some(name) + }) + .map(|(c, _, _)| c) +} + +/// Find-or-create a project record named `name`, returning its coordinate. +/// Notes tagged with a project are associated to it via `linked_records`. +pub fn ensure_project(store: &ConcurrentStore, name: &str, session: &str) -> Result { + if let Some(c) = find_project(store, name) { + return Ok(c); + } + let mut salt = 0u64; + let mut coord = project_coord(name, salt); + while store.get_record(&coord).is_some() && salt < 4096 { + salt += 1; + coord = project_coord(name, salt); + } + if salt >= 4096 { + return Err("project store: could not find a free coordinate".into()); + } + let mut h = CubeHeader::new(); + h.title = Some(name.to_string()); + h.path = Some(name.to_string()); + h.doc_type = Some(PROJECT_DOC_TYPE.to_string()); + h.created_at = Some(now()); + h.owner_local_user = Some(session.to_string()); + h.refresh_flags(); + store.put_record(coord, &h, &[]); + Ok(coord) +} + /// Store a note record under `session`, with a `subject` title and `body` -/// text. Returns the note's coordinate. +/// text. `project` (if given) associates the note to a project record; `category` +/// (if given) sets the note's `doc_type` to `note:`. Returns the note's +/// coordinate. pub fn store_note( store: &ConcurrentStore, session: &str, subject: &str, body: &str, + project: Option<&str>, + category: Option<&str>, ) -> Result { let created = now(); + let proj_coord = match project { + Some(p) => Some(ensure_project(store, p, session)?), + None => None, + }; let mut salt = 0u64; let mut coord = note_coord(session, created, subject, body, salt); while store.get_record(&coord).is_some() && salt < 4096 { @@ -117,61 +206,103 @@ pub fn store_note( } let mut h = CubeHeader::new(); h.title = Some(subject.to_string()); - h.doc_type = Some(NOTE_DOC_TYPE.to_string()); + h.doc_type = Some(match category { + Some(c) => format!("{NOTE_DOC_TYPE}:{c}"), + None => NOTE_DOC_TYPE.to_string(), + }); h.created_at = Some(created); h.owner_local_user = Some(session.to_string()); + if let Some(pc) = proj_coord { + h.linked_records = vec![pc]; + } h.word_flags = note_word_flags(); h.refresh_flags(); store.put_record(coord, &h, body.as_bytes()); Ok(coord) } -/// List notes, newest first. `session` filters to one session label; `day` -/// (a day-of-epoch, see [`date_to_day`]) filters to one calendar day. -pub fn list_notes( - store: &ConcurrentStore, - session: Option<&str>, - day: Option, -) -> Vec { - let snap = store.read_snapshot(); - let mut out: Vec = snap - .query_by_type(NOTE_DOC_TYPE) +/// All note records (`doc_type == "note"` or `"note:"`), regardless of +/// category, so a category filter is applied in Rust (the store's exact +/// `scan_by_type("note")` would miss categorized notes). +fn all_notes(snap: &cubestore::CubeStore) -> Vec { + snap.keys() .into_iter() - .filter(|(_, h, _)| match session { - Some(s) => h.owner_local_user.as_deref() == Some(s), - None => true, - }) - .filter(|(_, h, _)| match day { - Some(d) => h.created_at.map(|c| c / 86_400) == Some(d), - None => true, - }) - .map(|(coord, header, body)| Note { - coord, - header, - body, + .filter_map(|coord| { + let (header, body) = snap.get_record(&coord)?; + let dt = header.doc_type.as_deref().unwrap_or(""); + if dt == NOTE_DOC_TYPE || dt.starts_with(&format!("{NOTE_DOC_TYPE}:")) { + Some(Note { + coord, + header, + body, + }) + } else { + None + } }) + .collect() +} + +/// List notes, newest first, filtered by [`NoteFilter`]. +pub fn list_notes(store: &ConcurrentStore, filter: Option<&NoteFilter>) -> Vec { + let snap = store.read_snapshot(); + let mut out: Vec = all_notes(&snap) + .into_iter() + .filter(|n| note_matches_filtered(store, n, filter)) .collect(); out.sort_by(|a, b| b.header.created_at.cmp(&a.header.created_at)); out } -/// Search all notes (session + body) for `needle`, newest first. -pub fn search_notes(store: &ConcurrentStore, needle: &str) -> Vec { +fn note_matches_filtered(store: &ConcurrentStore, n: &Note, filter: Option<&NoteFilter>) -> bool { + let Some(f) = filter else { + return true; + }; + if let Some(s) = &f.session { + if n.header.owner_local_user.as_deref() != Some(s.as_str()) { + return false; + } + } + if let Some(cat) = &f.category { + let expected = format!("{NOTE_DOC_TYPE}:{cat}"); + if n.header.doc_type.as_deref() != Some(expected.as_str()) { + return false; + } + } + if let Some(sd) = f.since { + if n.header.created_at.map(|c| c / 86_400).unwrap_or(0) < sd { + return false; + } + } + if let Some(ud) = f.until { + if n.header.created_at.map(|c| c / 86_400).unwrap_or(u64::MAX) > ud { + return false; + } + } + if let Some(p) = &f.project { + if let Some(pc) = find_project(store, p) { + if !n.header.linked_records.contains(&pc) && n.header.path.as_deref() != Some(p.as_str()) { + return false; + } + } else { + return false; + } + } + true +} + +/// Search all notes (session + body) for `needle`, filtered, newest first. +pub fn search_notes(store: &ConcurrentStore, needle: &str, filter: Option<&NoteFilter>) -> Vec { let needle = needle.to_lowercase(); let snap = store.read_snapshot(); - let mut out: Vec = snap - .query_by_type(NOTE_DOC_TYPE) + let mut out: Vec = all_notes(&snap) .into_iter() - .filter(|(_, h, body)| { - let subject = h.title.as_deref().unwrap_or("").to_lowercase(); - let b = String::from_utf8_lossy(body).to_lowercase(); + .filter(|n| note_matches_filtered(store, n, filter)) + .filter(|n| { + let subject = n.header.title.as_deref().unwrap_or("").to_lowercase(); + let b = String::from_utf8_lossy(&n.body).to_lowercase(); subject.contains(&needle) || b.contains(&needle) }) - .map(|(coord, header, body)| Note { - coord, - header, - body, - }) .collect(); out.sort_by(|a, b| b.header.created_at.cmp(&a.header.created_at)); out @@ -186,6 +317,25 @@ pub fn show_note(store: &ConcurrentStore, coord: Czyx) -> Option { }) } +/// List all project records, newest first. +pub fn project_list(store: &ConcurrentStore) -> Vec<(Czyx, CubeHeader, Vec)> { + let mut v = store.read_snapshot().query_by_type(PROJECT_DOC_TYPE); + v.sort_by(|a, b| b.1.created_at.cmp(&a.1.created_at)); + v +} + +/// Return the notes associated to a project record (its thread/timeline), +/// newest first. +pub fn project_walk(store: &ConcurrentStore, coord: Czyx) -> Vec { + let snap = store.read_snapshot(); + let mut out: Vec = all_notes(&snap) + .into_iter() + .filter(|n| n.header.linked_records.contains(&coord)) + .collect(); + out.sort_by(|a, b| b.header.created_at.cmp(&a.header.created_at)); + out +} + /// Format a unix timestamp as `YYYY-MM-DD` (UTC). pub fn date_string(ts: u64) -> String { let days = (ts / 86_400) as i64; @@ -213,8 +363,6 @@ fn days_from_civil(y: i64, m: u32, d: u32) -> u64 { let mp = (m as i64 + 9) % 12; let doy = (153 * mp + 2) / 5 + d as i64 - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - // Hinnant's `days_from_civil` counts from 1970-01-01; the era frame starts - // at 0000-03-01, so subtract the 719468-day offset to land on the epoch. (era * 146_097 + doe - 719_468).max(0) as u64 } @@ -256,22 +404,58 @@ fn format_notes(notes: &[Note]) -> String { .iter() .map(|n| { let subj = n.header.title.as_deref().unwrap_or(""); - let when = n.header.created_at.map(date_string).unwrap_or_else(|| "?".into()); - format!(" {} [{}] {}", coord_str(n.coord), when, subj) + let when = n + .header + .created_at + .map(|t| date_string(t)) + .unwrap_or_else(|| "?".into()); + let cat = n + .header + .doc_type + .as_deref() + .and_then(category_of) + .map(|c| format!(" [{c}]")) + .unwrap_or_default(); + format!(" {} [{}]{} {}", coord_str(n.coord), when, cat, subj) }) .collect(); format!("{} note(s):\n{}", notes.len(), lines.join("\n")) } +/// Parse `--key value` flags out of a command string; returns `(flags, +/// remainingPositionalJoined)`. A flag with no following token gets `"true"`. +fn split_flags(s: &str) -> (Vec<(String, String)>, String) { + let tokens: Vec<&str> = s.split_whitespace().collect(); + let mut flags = Vec::new(); + let mut pos: Vec<&str> = Vec::new(); + let mut i = 0; + while i < tokens.len() { + let t = tokens[i]; + if let Some(k) = t.strip_prefix("--") { + let (key, val) = if i + 1 < tokens.len() && !tokens[i + 1].starts_with("--") { + i += 1; + (k.to_string(), tokens[i].to_string()) + } else { + (k.to_string(), "true".to_string()) + }; + flags.push((key, val)); + } else { + pos.push(t); + } + i += 1; + } + (flags, pos.join(" ")) +} + /// Process one `note`/`notes` command line against `store`. /// /// Syntax (REPL/CLI): -/// `note ` quick note (session = today, subject = first 60 chars) -/// `note add ` explicit add (same as above) -/// `note list [session]` list a session's notes (default: current day's session label, all sessions if empty) -/// `note search ` full-text search across all notes -/// `note show ` show one note's subject + body -/// `notes` alias for `note list` +/// `note ` quick note +/// `note add [--project P] [--cat C] ` add a note to a project/category +/// `note list [session] [--project P] [--cat C] [--since D] [--until D]` +/// `note search [--project P] [--cat C]` +/// `note show ` +/// `project list` / `project show ` (see [`project_command`]) pub fn note_command(store: &ConcurrentStore, line: &str) -> Result { let body = line .splitn(2, char::is_whitespace) @@ -280,25 +464,27 @@ pub fn note_command(store: &ConcurrentStore, line: &str) -> Result list the current session's notes let session = default_session(); - let notes = list_notes(store, Some(&session), None); + let notes = list_notes(store, Some(&NoteFilter { session: Some(session), ..Default::default() })); return Ok(format_notes(¬es)); }; match head { "list" => { - let s = rest.trim(); - let session = if s.is_empty() { None } else { Some(s) }; - let notes = list_notes(store, session, None); + let (flags, pos) = split_flags(rest); + let f = filter_from_flags(&flags); + let session = if pos.is_empty() { None } else { Some(pos.clone()) }; + let f = NoteFilter { session, ..f }; + let notes = list_notes(store, Some(&f)); Ok(format_notes(¬es)) } "search" => { - let needle = rest.trim(); - if needle.is_empty() { + let (flags, pos) = split_flags(rest); + let f = filter_from_flags(&flags); + if pos.is_empty() { return Err("note search needs ".to_string()); } - let notes = search_notes(store, needle); - Ok(format!("search '{needle}':\n{}", format_notes(¬es))) + let notes = search_notes(store, &pos, Some(&f)); + Ok(format!("search '{pos}':\n{}", format_notes(¬es))) } "show" => { let cs = rest.trim(); @@ -307,7 +493,11 @@ pub fn note_command(store: &ConcurrentStore, line: &str) -> Result { let subj = n.header.title.as_deref().unwrap_or(""); - let when = n.header.created_at.map(date_string).unwrap_or_else(|| "?".into()); + let when = n + .header + .created_at + .map(|t| date_string(t)) + .unwrap_or_else(|| "?".into()); Ok(format!( "note {cs} [{}] {}\n{}", when, @@ -319,18 +509,92 @@ pub fn note_command(store: &ConcurrentStore, line: &str) -> Result { - let text = rest.trim(); + let (flags, pos) = split_flags(rest); + let mut project = None; + let mut category = None; + for (k, v) in &flags { + match k.as_str() { + "project" | "p" => project = Some(v.clone()), + "cat" | "category" | "c" => category = Some(v.clone()), + _ => {} + } + } + let text = pos.trim(); if text.is_empty() { return Err("note needs ".to_string()); } let session = default_session(); let subject = subject_of(text); - let coord = store_note(store, &session, &subject, text)?; - Ok(format!("note saved at {} (session {})", coord_str(coord), session)) + let coord = store_note(store, &session, &subject, text, project.as_deref(), category.as_deref())?; + let proj = project.map(|p| format!(" (project {p})")).unwrap_or_default(); + Ok(format!("note saved at {} (session {}){proj}", coord_str(coord), session)) } } } +fn filter_from_flags(flags: &[(String, String)]) -> NoteFilter { + let mut f = NoteFilter::default(); + for (k, v) in flags { + match k.as_str() { + "project" | "p" => f.project = Some(v.clone()), + "cat" | "category" | "c" => f.category = Some(v.clone()), + "since" => f.since = date_to_day(v), + "until" => f.until = date_to_day(v), + _ => {} + } + } + f +} + +/// Process a `project` command line: `project list` or `project show `. +pub fn project_command(store: &ConcurrentStore, line: &str) -> Result { + let body = line + .splitn(2, char::is_whitespace) + .nth(1) + .unwrap_or("") + .trim() + .to_string(); + let Some((head, rest)) = split_head(&body) else { + return Ok(format_projects(project_list(store))); + }; + match head { + "list" => Ok(format_projects(project_list(store))), + "show" | "walk" => { + let cs = rest.trim(); + let coord = crate::commands::parse_coord(cs) + .ok_or_else(|| format!("bad coordinate '{cs}' (want C.Z.Y.X)"))?; + match show_note(store, coord) { + Some(p) => { + let name = p.header.title.as_deref().unwrap_or("?"); + let notes = project_walk(store, coord); + Ok(format!( + "project {cs} \"{name}\" — {} note(s):\n{}", + notes.len(), + format_notes(¬es) + )) + } + None => Ok(format!("project {cs} -> (no project record)")), + } + } + _ => Err(format!("project: unknown subcommand '{head}' (want list|show)")), + } +} + +fn format_projects(projects: Vec<(Czyx, CubeHeader, Vec)>) -> String { + if projects.is_empty() { + return "(no projects)".to_string(); + } + let lines: Vec = projects + .iter() + .map(|(c, h, _)| { + let name = h.title.as_deref().unwrap_or(""); + let when = h.created_at.map(|t| date_string(t)).unwrap_or_else(|| "?".into()); + format!(" {} [{}] {}", coord_str(*c), when, name) + }) + .collect(); + format!("{} project(s):\n{}", projects.len(), lines.join("\n")) +} + /// Split the first whitespace-delimited token from the rest of a string. fn split_head(s: &str) -> Option<(&str, &str)> { let idx = s.find(char::is_whitespace)?; @@ -344,14 +608,13 @@ mod tests { #[test] fn store_and_list_a_note() { let store = ConcurrentStore::memory(); - let coord = store_note(&store, "test-session", "hello", "world note").unwrap(); + let coord = store_note(&store, "test-session", "hello", "world note", None, None).unwrap(); assert!(store.get_record(&coord).is_some()); - let all = list_notes(&store, None, None); + let all = list_notes(&store, None); assert_eq!(all.len(), 1); assert_eq!(all[0].header.doc_type.as_deref(), Some(NOTE_DOC_TYPE)); assert_eq!(all[0].header.owner_local_user.as_deref(), Some("test-session")); assert_eq!(all[0].body, b"world note"); - // word_flags: text-type single-frame assert!(all[0].header.word_flags.has(WordFlags::START_RECORD)); assert!(!all[0].header.word_flags.has(WordFlags::ENCRYPTED)); } @@ -359,23 +622,56 @@ mod tests { #[test] fn session_and_day_filters() { let store = ConcurrentStore::memory(); - store_note(&store, "a", "one", "first").unwrap(); - store_note(&store, "b", "two", "second").unwrap(); - assert_eq!(list_notes(&store, Some("a"), None).len(), 1); - assert_eq!(list_notes(&store, Some("b"), None).len(), 1); - assert_eq!(list_notes(&store, None, None).len(), 2); + store_note(&store, "a", "one", "first", None, None).unwrap(); + store_note(&store, "b", "two", "second", None, None).unwrap(); + assert_eq!( + list_notes(&store, Some(&NoteFilter { session: Some("a".into()), ..Default::default() })).len(), + 1 + ); + assert_eq!(list_notes(&store, None).len(), 2); let today = now() / 86_400; - assert_eq!(list_notes(&store, None, Some(today)).len(), 2); + assert_eq!( + list_notes(&store, Some(&NoteFilter { since: Some(today), ..Default::default() })).len(), + 2 + ); + } + + #[test] + fn category_and_project_filters() { + let store = ConcurrentStore::memory(); + let a = store_note(&store, "s", "design", "widget design", Some("cubelinux"), Some("design")).unwrap(); + let b = store_note(&store, "s", "impl", "widget impl", None, Some("impl")).unwrap(); + let c = store_note(&store, "s", "cube", "cube notes", None, None).unwrap(); + // category filter + assert_eq!( + list_notes(&store, Some(&NoteFilter { category: Some("design".into()), ..Default::default() })).len(), + 1 + ); + // all categorized + base notes are still found by an unfiltered list + assert_eq!( + list_notes(&store, Some(&NoteFilter { session: Some("s".into()), ..Default::default() })).len(), + 3 + ); + // project filter (a is linked to project cubelinux) + let pc = ensure_project(&store, "cubelinux", "s").unwrap(); + assert_eq!(store.get_record(&a).unwrap().0.linked_records, vec![pc]); + assert_eq!(store.get_record(&b).unwrap().0.linked_records, vec![]); + assert_eq!(store.get_record(&c).unwrap().0.linked_records, vec![]); + let proj_notes = list_notes(&store, Some(&NoteFilter { project: Some("cubelinux".into()), ..Default::default() })); + assert_eq!(proj_notes.len(), 1); + assert_eq!(proj_notes[0].header.title.as_deref(), Some("design")); + // project walk returns the thread + assert_eq!(project_walk(&store, pc).len(), 1); } #[test] fn full_text_search_and_dates() { let store = ConcurrentStore::memory(); - store_note(&store, "s", "kernel", "the ipu4 camera driver linked").unwrap(); - store_note(&store, "s", "mail", "opendkim signing works").unwrap(); - assert_eq!(search_notes(&store, "ipu4").len(), 1); - assert_eq!(search_notes(&store, "opendkim").len(), 1); - assert_eq!(search_notes(&store, "nomatchxyz").len(), 0); + store_note(&store, "s", "kernel", "the ipu4 camera driver linked", None, None).unwrap(); + store_note(&store, "s", "mail", "opendkim signing works", None, None).unwrap(); + assert_eq!(search_notes(&store, "ipu4", None).len(), 1); + assert_eq!(search_notes(&store, "opendkim", None).len(), 1); + assert_eq!(search_notes(&store, "nomatchxyz", None).len(), 0); assert_eq!(date_to_day("2026-09-08"), Some(20_704)); assert_eq!(date_string(20_704 * 86_400), "2026-09-08"); }