cubesys/cubecli/cube-notes-mcp: note categories + project threads. notes use doc_type 'note:<category>' 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 <coord>' (walk a project's thread). MCP server exposes note_write/list/search/show + project_list/project_show. 4 notes tests pass; workspace check green.
This commit is contained in:
+61
-21
@@ -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}"
|
||||
|
||||
|
||||
|
||||
+8
-2
@@ -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();
|
||||
|
||||
@@ -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 <source C.Z.Y.X> <target C.Z.Y.X>".to_string())?;
|
||||
let tgt_s = it.next().ok_or_else(|| "trace-capture needs <target C.Z.Y.X>".to_string())?;
|
||||
|
||||
+385
-89
@@ -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:<category>` (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 <text> [--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<u8>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Project name (note must be associated to that project record).
|
||||
pub project: Option<String>,
|
||||
/// Category (doc_type `note:<category>`).
|
||||
pub category: Option<String>,
|
||||
/// Earliest day-of-epoch, inclusive.
|
||||
pub since: Option<u64>,
|
||||
/// Latest day-of-epoch, inclusive.
|
||||
pub until: Option<u64>,
|
||||
}
|
||||
|
||||
/// The category portion of a note's `doc_type`, if any (`note:<category>`).
|
||||
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<Czyx> {
|
||||
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<Czyx, String> {
|
||||
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:<category>`. Returns the note's
|
||||
/// coordinate.
|
||||
pub fn store_note(
|
||||
store: &ConcurrentStore,
|
||||
session: &str,
|
||||
subject: &str,
|
||||
body: &str,
|
||||
project: Option<&str>,
|
||||
category: Option<&str>,
|
||||
) -> Result<Czyx, String> {
|
||||
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<u64>,
|
||||
) -> Vec<Note> {
|
||||
let snap = store.read_snapshot();
|
||||
let mut out: Vec<Note> = snap
|
||||
.query_by_type(NOTE_DOC_TYPE)
|
||||
/// All note records (`doc_type == "note"` or `"note:<cat>"`), 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<cubestore::HashBackend>) -> Vec<Note> {
|
||||
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<Note> {
|
||||
let snap = store.read_snapshot();
|
||||
let mut out: Vec<Note> = 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<Note> {
|
||||
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<Note> {
|
||||
let needle = needle.to_lowercase();
|
||||
let snap = store.read_snapshot();
|
||||
let mut out: Vec<Note> = snap
|
||||
.query_by_type(NOTE_DOC_TYPE)
|
||||
let mut out: Vec<Note> = 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<Note> {
|
||||
})
|
||||
}
|
||||
|
||||
/// List all project records, newest first.
|
||||
pub fn project_list(store: &ConcurrentStore) -> Vec<(Czyx, CubeHeader, Vec<u8>)> {
|
||||
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<Note> {
|
||||
let snap = store.read_snapshot();
|
||||
let mut out: Vec<Note> = 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 <text>` quick note (session = today, subject = first 60 chars)
|
||||
/// `note add <text>` 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 <text>` full-text search across all notes
|
||||
/// `note show <C.Z.Y.X>` show one note's subject + body
|
||||
/// `notes` alias for `note list`
|
||||
/// `note <text>` quick note
|
||||
/// `note add [--project P] [--cat C] <text>` add a note to a project/category
|
||||
/// `note list [session] [--project P] [--cat C] [--since D] [--until D]`
|
||||
/// `note search <text> [--project P] [--cat C]`
|
||||
/// `note show <C.Z.Y.X>`
|
||||
/// `project list` / `project show <C.Z.Y.X>` (see [`project_command`])
|
||||
pub fn note_command(store: &ConcurrentStore, line: &str) -> Result<String, String> {
|
||||
let body = line
|
||||
.splitn(2, char::is_whitespace)
|
||||
@@ -280,25 +464,27 @@ pub fn note_command(store: &ConcurrentStore, line: &str) -> Result<String, Strin
|
||||
.trim()
|
||||
.to_string();
|
||||
let Some((head, rest)) = split_head(&body) else {
|
||||
// bare `note` / `notes` -> 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 <text>".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<String, Strin
|
||||
match show_note(store, coord) {
|
||||
Some(n) => {
|
||||
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<String, Strin
|
||||
}
|
||||
}
|
||||
"add" | _ => {
|
||||
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 <text>".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 <C.Z.Y.X>`.
|
||||
pub fn project_command(store: &ConcurrentStore, line: &str) -> Result<String, String> {
|
||||
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<u8>)>) -> String {
|
||||
if projects.is_empty() {
|
||||
return "(no projects)".to_string();
|
||||
}
|
||||
let lines: Vec<String> = 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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user