generated from eblume/project-template
feat(core,hephd): frontmatter projection — render on read, strip on write (§8.3)
The store-side half of the frontmatter edit surface:
- heph-core `frontmatter::strip` runs in `update_node` before the yrs
diff, so frontmatter never enters the body or CRDT. Conservative (only
a leading `---` block whose first line is a YAML key; a prose hrule
survives) and idempotent → read→write round-trip is a no-op.
- hephd `frontmatter::render` (local-tz dates via new `datespec::fmt_iso`)
behind `node.get {frontmatter: true}`: id/kind/title/tags, and for a
task or its canonical-context doc the owning task's scalars + a `task:`
ref. Subject-task + project-name resolution in dispatch.
Safe against any client (inbound frontmatter always stripped). Tests:
strip unit (incl. hrule/idempotency), render unit, socket round-trip +
task-context-doc projection. The heph.nvim diff-into-RPCs layer is next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4cdf0de64c
commit
ef56c5d5f2
10 changed files with 407 additions and 9 deletions
92
crates/heph-core/src/frontmatter.rs
Normal file
92
crates/heph-core/src/frontmatter.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
//! YAML frontmatter is a **projection** (tech-spec §8.3): the daemon renders an
|
||||
//! editable block on read; heph-core **strips** it on write so it never enters
|
||||
//! the stored body or the text CRDT. This module owns only the strip side (the
|
||||
//! render side needs timezone-aware date formatting and lives in `hephd`).
|
||||
//!
|
||||
//! Stripping is **conservative**: it removes only a leading, well-formed
|
||||
//! `---`-delimited block whose first line is a YAML key, so a leading `---`
|
||||
//! thematic break in ordinary prose is left intact. It is idempotent — a body
|
||||
//! that has already been stripped is returned unchanged — which keeps the
|
||||
//! read→write round-trip a no-op even when a client echoes the rendered
|
||||
//! frontmatter straight back.
|
||||
|
||||
/// Return `body` without a leading YAML frontmatter block. If `body` has no
|
||||
/// conforming frontmatter, it is returned unchanged (borrowed).
|
||||
pub fn strip(body: &str) -> &str {
|
||||
// Must open with the fence on its own first line.
|
||||
let Some(rest) = body.strip_prefix("---\n") else {
|
||||
return body;
|
||||
};
|
||||
// The first line inside must look like a YAML key (`key:` / `key: value`).
|
||||
// A markdown thematic break (`---` then a blank line / heading / prose)
|
||||
// never does, so this rejects hrules.
|
||||
if !looks_like_yaml_key(rest.lines().next().unwrap_or("")) {
|
||||
return body;
|
||||
}
|
||||
// Find the closing fence — a line that is exactly `---` — and return what
|
||||
// follows it. No closing fence ⇒ not frontmatter; leave the body untouched.
|
||||
let mut offset = 0;
|
||||
for line in rest.split_inclusive('\n') {
|
||||
if line.strip_suffix('\n').unwrap_or(line) == "---" {
|
||||
return &rest[offset + line.len()..];
|
||||
}
|
||||
offset += line.len();
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
/// Does `line` begin with a bare YAML key followed by a colon
|
||||
/// (`[A-Za-z0-9_-]+:`)? Used to distinguish frontmatter from an hrule.
|
||||
fn looks_like_yaml_key(line: &str) -> bool {
|
||||
let b = line.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_' || b[i] == b'-') {
|
||||
i += 1;
|
||||
}
|
||||
i > 0 && b.get(i) == Some(&b':')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::strip;
|
||||
|
||||
#[test]
|
||||
fn strips_a_leading_frontmatter_block() {
|
||||
let body = "---\nid: x\ntitle: Roof\n---\n# Roof\n\nnotes\n";
|
||||
assert_eq!(strip(body), "# Roof\n\nnotes\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_body_after_frontmatter() {
|
||||
assert_eq!(strip("---\nid: x\n---\n"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idempotent_when_already_stripped() {
|
||||
let body = "# Roof\n\nnotes\n";
|
||||
assert_eq!(strip(body), body);
|
||||
assert_eq!(strip(strip(body)), body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_a_leading_thematic_break_intact() {
|
||||
// A real markdown hrule: `---` then a heading, not `key:` lines.
|
||||
let body = "---\n\n# Heading\n\ntext\n";
|
||||
assert_eq!(strip(body), body);
|
||||
// …and a `---` separating prose later in the doc is never touched.
|
||||
let mid = "para one\n\n---\n\npara two\n";
|
||||
assert_eq!(strip(mid), mid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unterminated_block_is_not_frontmatter() {
|
||||
let body = "---\nid: x\nno closing fence here\n";
|
||||
assert_eq!(strip(body), body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_first_block_is_removed() {
|
||||
let body = "---\nid: x\n---\nbody\n\n---\n\nmore\n";
|
||||
assert_eq!(strip(body), "body\n\n---\n\nmore\n");
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ pub mod error;
|
|||
pub mod export;
|
||||
pub mod extract;
|
||||
pub mod filter;
|
||||
pub mod frontmatter;
|
||||
pub mod hlc;
|
||||
pub mod model;
|
||||
pub mod oplog;
|
||||
|
|
|
|||
|
|
@ -294,6 +294,9 @@ pub(super) fn update(
|
|||
if let Some(t) = title {
|
||||
node.title = t;
|
||||
}
|
||||
// Frontmatter is a read-only projection (§8.3): strip any leading block a
|
||||
// client echoes back so it never enters the stored body or the text CRDT.
|
||||
let body = body.map(|b| crate::frontmatter::strip(&b).to_string());
|
||||
let body_changed = match body {
|
||||
Some(b) => {
|
||||
let changed = node.body.as_deref() != Some(b.as_str());
|
||||
|
|
|
|||
|
|
@ -62,6 +62,15 @@ pub fn to_epoch_ms(date: NaiveDate) -> i64 {
|
|||
}
|
||||
}
|
||||
|
||||
/// Absolute `YYYY-MM-DD` for an epoch-ms date (local). The round-trippable form
|
||||
/// for the frontmatter edit surface (§8.3) — `parse_date` reads it straight back.
|
||||
pub fn fmt_iso(ms: i64) -> String {
|
||||
match Local.timestamp_millis_opt(ms).earliest() {
|
||||
Some(dt) => dt.date_naive().format("%Y-%m-%d").to_string(),
|
||||
None => ms.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact display of an epoch-ms date: `MM-DD` within the current year,
|
||||
/// `YYYY-MM-DD` otherwise.
|
||||
pub fn fmt_date(ms: i64) -> String {
|
||||
|
|
|
|||
138
crates/hephd/src/frontmatter.rs
Normal file
138
crates/hephd/src/frontmatter.rs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
//! Render the editable YAML frontmatter projection for a node (tech-spec §8.3).
|
||||
//!
|
||||
//! This is the **read** side: the daemon prepends this block to a node's body
|
||||
//! when a client requests `node.get {frontmatter: true}`. `heph-core` strips it
|
||||
//! back off on write, so it never persists. Lives in `hephd` (not `heph-core`)
|
||||
//! because formatting `do_date`/`late_on` for humans needs the local timezone
|
||||
//! (via [`crate::datespec`]).
|
||||
//!
|
||||
//! Schema (a curated, *editable* subset — not the full export snapshot):
|
||||
//! `id`/`kind` are read-only; `title` and `tags` edit the node itself; when the
|
||||
//! node **is** a task or backs one (its canonical-context doc), the task's
|
||||
//! scalars (`state`/`attention`/`do_date`/`late_on`/`recurrence`/`project`) are
|
||||
//! rendered and a read-only `task:` id is included so the editor knows where to
|
||||
//! route those edits.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use heph_core::{Node, Task};
|
||||
|
||||
use crate::datespec;
|
||||
|
||||
/// Render the frontmatter block (with the `---` fences and a trailing newline)
|
||||
/// for `node`. `task` is the node's own task or — for a task's
|
||||
/// canonical-context doc — its owning task. `project_name`/`tags` are resolved
|
||||
/// by the caller.
|
||||
pub fn render(
|
||||
node: &Node,
|
||||
task: Option<&Task>,
|
||||
project_name: Option<&str>,
|
||||
tags: &[String],
|
||||
) -> String {
|
||||
let mut fm = String::new();
|
||||
let _ = writeln!(fm, "---");
|
||||
let _ = writeln!(fm, "id: {}", node.id);
|
||||
let _ = writeln!(fm, "kind: {}", node.kind.as_str());
|
||||
let _ = writeln!(fm, "title: {}", scalar(&node.title));
|
||||
let _ = writeln!(fm, "tags: {}", flow_seq(tags));
|
||||
|
||||
if let Some(t) = task {
|
||||
let _ = writeln!(fm, "task: {}", t.node_id);
|
||||
let _ = writeln!(fm, "state: {}", t.state.as_str());
|
||||
if let Some(a) = t.attention {
|
||||
let _ = writeln!(fm, "attention: {}", a.as_str());
|
||||
}
|
||||
if let Some(d) = t.do_date {
|
||||
let _ = writeln!(fm, "do_date: {}", datespec::fmt_iso(d));
|
||||
}
|
||||
if let Some(l) = t.late_on {
|
||||
let _ = writeln!(fm, "late_on: {}", datespec::fmt_iso(l));
|
||||
}
|
||||
if let Some(r) = &t.recurrence {
|
||||
let _ = writeln!(fm, "recurrence: {}", scalar(r));
|
||||
}
|
||||
if let Some(p) = project_name {
|
||||
let _ = writeln!(fm, "project: {}", scalar(p));
|
||||
}
|
||||
}
|
||||
|
||||
let _ = writeln!(fm, "---");
|
||||
fm
|
||||
}
|
||||
|
||||
/// A YAML scalar, quoted only when bare would be ambiguous (empty, surrounding
|
||||
/// whitespace, or a character that would break a bare flow scalar).
|
||||
fn scalar(s: &str) -> String {
|
||||
let needs_quote = s.is_empty()
|
||||
|| s != s.trim()
|
||||
|| s.starts_with([
|
||||
'#', '-', '[', ']', '{', '}', '&', '*', '!', '|', '>', '\'', '"', '%', '@', '`',
|
||||
])
|
||||
|| s.contains([':', ',', '\n']);
|
||||
if needs_quote {
|
||||
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// A YAML flow sequence: `[a, b]`, each element scalar-quoted as needed; `[]`
|
||||
/// when empty.
|
||||
fn flow_seq(items: &[String]) -> String {
|
||||
let inner: Vec<String> = items.iter().map(|s| scalar(s)).collect();
|
||||
format!("[{}]", inner.join(", "))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use heph_core::{Attention, NodeKind, TaskState};
|
||||
|
||||
fn doc(title: &str) -> Node {
|
||||
Node {
|
||||
id: "doc1".into(),
|
||||
owner_id: "u".into(),
|
||||
kind: NodeKind::Doc,
|
||||
title: title.into(),
|
||||
body: Some(String::new()),
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
hlc: "0".into(),
|
||||
tombstoned: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_a_plain_doc_block() {
|
||||
let fm = render(&doc("Roof"), None, None, &["house".into(), "urgent".into()]);
|
||||
assert_eq!(
|
||||
fm,
|
||||
"---\nid: doc1\nkind: doc\ntitle: Roof\ntags: [house, urgent]\n---\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_task_scalars_and_project_when_a_task_is_present() {
|
||||
let task = Task {
|
||||
node_id: "task1".into(),
|
||||
attention: Some(Attention::Red),
|
||||
do_date: None,
|
||||
late_on: None,
|
||||
state: TaskState::Outstanding,
|
||||
recurrence: None,
|
||||
};
|
||||
let fm = render(&doc("Fix roof"), Some(&task), Some("Camano"), &[]);
|
||||
assert!(fm.contains("task: task1"));
|
||||
assert!(fm.contains("state: outstanding"));
|
||||
assert!(fm.contains("attention: red"));
|
||||
assert!(fm.contains("project: Camano"));
|
||||
assert!(fm.contains("tags: []"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quotes_titles_that_need_it() {
|
||||
assert_eq!(scalar("plain"), "plain");
|
||||
assert_eq!(scalar("has: colon"), "\"has: colon\"");
|
||||
assert_eq!(scalar(""), "\"\"");
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ pub mod auth;
|
|||
pub mod client;
|
||||
pub mod clock;
|
||||
pub mod datespec;
|
||||
pub mod frontmatter;
|
||||
pub mod lock;
|
||||
pub mod oauth;
|
||||
pub mod remote;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ use serde::{Deserialize, Serialize};
|
|||
use serde_json::{json, Value};
|
||||
|
||||
use heph_core::{
|
||||
Attention, LinkType, ListFilter, NewNode, NewTask, NodeKind, SchedulePatch, Store, TaskState,
|
||||
Attention, LinkType, ListFilter, NewNode, NewTask, Node, NodeKind, SchedulePatch, Store, Task,
|
||||
TaskState,
|
||||
};
|
||||
|
||||
/// A JSON-RPC request line.
|
||||
|
|
@ -111,6 +112,14 @@ struct IdParam {
|
|||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GetNodeParams {
|
||||
id: String,
|
||||
/// Prepend the editable YAML frontmatter projection to the body (§8.3).
|
||||
#[serde(default)]
|
||||
frontmatter: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ResolveParams {
|
||||
title: String,
|
||||
|
|
@ -237,13 +246,54 @@ const DEFAULT_LIMIT: usize = 5;
|
|||
/// Default `log.tail` size.
|
||||
const DEFAULT_TAIL: usize = 10;
|
||||
|
||||
/// The task whose frontmatter `node` carries: the node itself if it's a task,
|
||||
/// else the task whose canonical-context doc this node is (§8.3). `None` for a
|
||||
/// standalone doc/journal.
|
||||
fn subject_task(store: &dyn Store, node: &Node) -> Result<Option<Task>, RpcError> {
|
||||
if node.kind == NodeKind::Task {
|
||||
return Ok(store.get_task(&node.id)?);
|
||||
}
|
||||
for link in store.backlinks(&node.id)? {
|
||||
if link.link_type == LinkType::CanonicalContext {
|
||||
return Ok(store.get_task(&link.src_id)?);
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// The name of the project a task is filed under (its `in-project` link), if any.
|
||||
fn project_name_of(store: &dyn Store, task_id: &str) -> Result<Option<String>, RpcError> {
|
||||
for link in store.outgoing_links(task_id)? {
|
||||
if link.link_type == LinkType::InProject {
|
||||
return Ok(store.get_node(&link.dst_id)?.map(|n| n.title));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Dispatch one method call against `store`. Synchronous — the transport runs
|
||||
/// this on a blocking pool.
|
||||
pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Value, RpcError> {
|
||||
Ok(match method {
|
||||
"node.get" => {
|
||||
let p: IdParam = parse(params)?;
|
||||
json!(store.get_node(&p.id)?)
|
||||
let p: GetNodeParams = parse(params)?;
|
||||
match store.get_node(&p.id)? {
|
||||
Some(mut node) if p.frontmatter => {
|
||||
// Render the editable frontmatter projection (§8.3) from the
|
||||
// node's own fields + its (owning) task, and prepend it.
|
||||
let task = subject_task(store, &node)?;
|
||||
let tags = store.tags_of(&node.id)?;
|
||||
let project = match &task {
|
||||
Some(t) => project_name_of(store, &t.node_id)?,
|
||||
None => None,
|
||||
};
|
||||
let fm =
|
||||
crate::frontmatter::render(&node, task.as_ref(), project.as_deref(), &tags);
|
||||
node.body = Some(format!("{fm}{}", node.body.as_deref().unwrap_or("")));
|
||||
json!(node)
|
||||
}
|
||||
other => json!(other),
|
||||
}
|
||||
}
|
||||
"node.create" => {
|
||||
let p: NewNode = parse(params)?;
|
||||
|
|
|
|||
|
|
@ -267,6 +267,93 @@ fn tag_add_list_remove_over_socket() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontmatter_renders_on_read_and_is_stripped_on_write() {
|
||||
let (socket, _dir) = spawn_daemon();
|
||||
let mut c = client(&socket);
|
||||
|
||||
let doc = c
|
||||
.call(
|
||||
"node.create",
|
||||
json!({ "kind": "doc", "title": "Roof", "body": "# Roof\n\nnotes\n" }),
|
||||
)
|
||||
.unwrap();
|
||||
let id = doc["id"].as_str().unwrap().to_string();
|
||||
c.call("tag.add", json!({ "node_id": id, "tag": "house" }))
|
||||
.unwrap();
|
||||
|
||||
// Read with frontmatter: a leading `---` block carries id/kind/title/tags,
|
||||
// and the original body follows it.
|
||||
let got = c
|
||||
.call("node.get", json!({ "id": id, "frontmatter": true }))
|
||||
.unwrap();
|
||||
let body = got["body"].as_str().unwrap();
|
||||
assert!(body.starts_with("---\n"), "no frontmatter fence:\n{body}");
|
||||
assert!(body.contains(&format!("id: {id}")), "no id:\n{body}");
|
||||
assert!(body.contains("title: Roof"), "no title:\n{body}");
|
||||
assert!(body.contains("tags: [house]"), "no tags:\n{body}");
|
||||
assert!(
|
||||
body.contains("# Roof\n\nnotes\n"),
|
||||
"original body missing:\n{body}"
|
||||
);
|
||||
|
||||
// Echo that whole buffer back: the frontmatter is stripped, body unchanged.
|
||||
c.call("node.update", json!({ "id": id, "body": body }))
|
||||
.unwrap();
|
||||
let plain = c.call("node.get", json!({ "id": id })).unwrap();
|
||||
assert_eq!(
|
||||
plain["body"], "# Roof\n\nnotes\n",
|
||||
"round-trip altered the body"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_context_doc_frontmatter_carries_the_owning_tasks_scalars() {
|
||||
let (socket, _dir) = spawn_daemon();
|
||||
let mut c = client(&socket);
|
||||
|
||||
let proj = c
|
||||
.call(
|
||||
"node.create",
|
||||
json!({ "kind": "project", "title": "Camano" }),
|
||||
)
|
||||
.unwrap();
|
||||
let pid = proj["id"].as_str().unwrap().to_string();
|
||||
let task = c
|
||||
.call(
|
||||
"task.create",
|
||||
json!({ "title": "Fix roof", "attention": "red", "project_id": pid, "do_date": 1_704_067_200_000_i64 }),
|
||||
)
|
||||
.unwrap();
|
||||
let task_id = task["node_id"].as_str().unwrap().to_string();
|
||||
|
||||
// The task's canonical-context doc.
|
||||
let links = c.call("links.outgoing", json!({ "id": task_id })).unwrap();
|
||||
let ctx = links
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|l| l["link_type"] == "canonical-context")
|
||||
.unwrap()["dst_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
// Its frontmatter surfaces the owning task's scalars + a `task:` ref.
|
||||
let got = c
|
||||
.call("node.get", json!({ "id": ctx, "frontmatter": true }))
|
||||
.unwrap();
|
||||
let body = got["body"].as_str().unwrap();
|
||||
assert!(
|
||||
body.contains(&format!("task: {task_id}")),
|
||||
"no task ref:\n{body}"
|
||||
);
|
||||
assert!(body.contains("state: outstanding"), "no state:\n{body}");
|
||||
assert!(body.contains("attention: red"), "no attention:\n{body}");
|
||||
assert!(body.contains("project: Camano"), "no project:\n{body}");
|
||||
assert!(body.contains("do_date:"), "no do_date:\n{body}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn promote_context_item_over_socket() {
|
||||
let (socket, _dir) = spawn_daemon();
|
||||
|
|
|
|||
|
|
@ -28,4 +28,5 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices:
|
|||
- `heph-tui` task-list visuals (§8.1): each row now leads with an attention **flag** (`⚑`, colored red/orange/blue; blank for white) and a **project-colored bullet** — the bullet's color is derived stably from the project id (so it survives projects being added/removed), letting you scan a mixed list by project at a glance. The list also grows a **scrollbar** and keeps the selected task scrolled into view when there are more tasks than fit.
|
||||
- `heph-tui` sort toggle (§8.1): **`s`** flips the task list between two orders — **default** (attention → most-overdue → project → creation) and **by-project** (grouped under dimmed `──── Project ────` separators, then the same sub-order). The view's filter still applies first. (To free `s`, **skip** moved to **`S`**.)
|
||||
- `heph.nvim` task-view rows (§8): `:Heph next`/`:Heph list` rows now show a compact **do/late date chip** (and a recurrence `↻`), so you can see scheduling at a glance; `<CR>` still jumps to a task's context doc.
|
||||
- Frontmatter projection (§8.3): a node can now be fetched with an editable **YAML frontmatter** block prepended — `node.get {frontmatter: true}` renders `id`/`kind`/`title`/`tags`, and for a task (or its context doc) the owning task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project` plus a `task:` ref. Dates are local `YYYY-MM-DD`. On write, the store **strips and ignores** any leading frontmatter (conservatively — a real `---` hrule in prose survives) before the CRDT diff, so frontmatter never persists and an unchanged read→write is a no-op; a naive editor can't corrupt metadata. This is the read/write groundwork for editing a node's metadata as frontmatter in heph.nvim (the diff-into-RPCs layer is next).
|
||||
- Tags (§4, §8.3): nodes can now be **tagged**. A tag is a `tag`-kind node whose id is deterministic in `(owner, name)`, so the same name is **one canonical tag** shared across everything it's applied to (and replicas converge — no duplicate tags). Tagging is an OR-set link, so adding/removing is idempotent and merge-safe. Surfaced as `tag.add`/`tag.remove`/`tag.list` RPCs and `heph tag add|rm|list` (list a node's tags, or every tag with no node). Tag names are trimmed; a canonical case/spelling normalization is deferred to the future zk import. This is the groundwork for the `tags:` line of the upcoming frontmatter edit surface.
|
||||
|
|
|
|||
|
|
@ -300,14 +300,30 @@ Project-subtree resolution needs the **parent-project links** ([[design]] §6.2.
|
|||
|
||||
> **Future — chores as a first-class feature (noted, not scheduled).** The `Chores` view here is an interim project-scoped filter. The intent is to make **chores a first-class concept** with their own **do-date / recurrence semantics** (distinct from regular tasks), retiring the `#Chores` / `#Camano Chores` *projects* (and the Camano split) entirely — chores would be a task flag/kind, not a project you scope to. When that lands, the `Chores` view becomes "tasks where `is_chore`," and `Schedule` (timed routines) is reconsidered alongside it. See [[design]] §6.2.1.
|
||||
|
||||
## 8.3 Frontmatter as an edit surface (planned)
|
||||
## 8.3 Frontmatter as an edit surface (backend built; nvim diff layer next)
|
||||
|
||||
> **Status: planned** (§14 roadmap, 2026-06-03). When a node is opened in `heph.nvim`, its structured metadata (id, title, project, do-date, tags, …) should be visible and editable as a YAML frontmatter block — without that metadata ever becoming a second, drifting source of truth in the body.
|
||||
> **Status: backend built** (the projection); the `heph.nvim` diff layer is next. When a node is opened in `heph.nvim`, its structured metadata (id, title, project, do-date, tags, …) is visible and editable as a YAML frontmatter block — without that metadata ever becoming a second, drifting source of truth in the body.
|
||||
|
||||
The resolving principle is a **two-layer split** that keeps `heph-core` safe against *any* client while making `heph.nvim` a rich editor:
|
||||
The resolving principle is a **two-layer split** that keeps the store safe against *any* client while making `heph.nvim` a rich editor:
|
||||
|
||||
- **`heph-core` is dumb and safe.** Frontmatter is a **projection generated on read** and **stripped + silently ignored on write**. A pure `frontmatter` module (sibling to `extract.rs`) provides `render(node, task?, project, tags)` (prepended by `get_node` and friends) and `strip(body) → body_without` (applied by `update_node` **before** the `yrs` CRDT diff). Invariants: the at-rest body and the CRDT doc **never** contain frontmatter; an unchanged read→write round-trips to a no-op. Because inbound frontmatter is always discarded, a naive editor (or the future web UI) **cannot corrupt metadata** — at worst it sends stale frontmatter and core drops it; the canonical block regenerates on the next read.
|
||||
- **`heph.nvim` is the smart client.** On `BufWriteCmd` it diffs the buffer's frontmatter against the canonical block it was handed and translates each changed field into the **correct structured RPC**: `title` → rename, `attention` → `set_attention`, `do_date`/`late_on`/`recurrence` → `set_schedule`, `project` → **`set_project`** (§8.1), `tags` → `tag.add`/`tag.remove` (§14 tags). Then it strips the frontmatter and sends the body. The frontmatter is thus a genuine declarative edit surface, but the translation lives in the client, not core.
|
||||
- ✅ **The store is dumb and safe.** Frontmatter is a **projection generated on read** and **stripped + silently ignored on write**. `heph-core::frontmatter::strip(body)` runs inside `update_node` **before** the `yrs` CRDT diff (conservative — it only removes a leading `---` block whose first line is a YAML key, so a leading `---` thematic break in prose survives; idempotent). The **render** side lives in `hephd` (`hephd::frontmatter::render`, since formatting `do_date`/`late_on` for humans needs the local timezone via `datespec::fmt_iso`); `node.get {frontmatter: true}` prepends it. Invariants: the at-rest body and the CRDT doc **never** contain frontmatter; an unchanged read→write round-trips to a no-op. Because inbound frontmatter is always discarded, a naive editor (or the future web UI) **cannot corrupt metadata** — at worst it sends stale frontmatter and the store drops it; the canonical block regenerates on the next read.
|
||||
- ⏳ **`heph.nvim` is the smart client.** On `BufWriteCmd` it diffs the buffer's frontmatter against the canonical block it was handed and translates each changed field into the **correct structured RPC**: `title` → rename, `attention` → `set_attention`, `do_date`/`late_on`/`recurrence` → `set_schedule`, `project` → **`set_project`** (§8.1), `tags` → `tag.add`/`tag.remove` (§14 tags). Then it strips the frontmatter and sends the body. The frontmatter is thus a genuine declarative edit surface, but the translation lives in the client, not core.
|
||||
|
||||
**The schema** (a curated, *editable* subset — not the full export snapshot). `id`/`kind` are read-only; `title` and `tags` edit the opened node; when the node **is** a task or backs one (its canonical-context doc), the owning task's scalars are rendered and a read-only `task:` id says where those edits route:
|
||||
|
||||
```yaml
|
||||
---
|
||||
id: 01J… # read-only
|
||||
kind: doc # read-only
|
||||
title: Fix the roof # editable → rename
|
||||
tags: [house, roof] # editable → tag.add / tag.remove
|
||||
task: 01J… # present iff this node is/backs a task (read-only ref)
|
||||
state: outstanding # editable (a mistyped status is a validation error — no picker)
|
||||
attention: red # editable → task.set_attention
|
||||
do_date: 2026-06-10 # editable → task.set_schedule (YYYY-MM-DD, local)
|
||||
project: Camano # editable → task.set_project (by name)
|
||||
---
|
||||
```
|
||||
|
||||
Field rules: `id`/`kind` are **read-only** (display only); `title`, `attention`, `do_date`, `late_on`, `recurrence`, `project`, `tags`, and `state` are editable. **`state` is editable but has no picker or hint** (to keep the UI simple) — a mistyped status value returns a **validation error** rather than guessing. Frontmatter is rendered for any editable-body node.
|
||||
|
||||
|
|
@ -452,7 +468,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi
|
|||
- ✅ **(b) sort toggle `s` — DONE:** **default**: attention (red→orange→white→blue) → days-overdue (descending; no-date = 0) → project (name) → `created_at` (FIFO). **project mode**: project is primary, with dimmed **`──── Name ────` separators** riding atop each group's first task (the cursor only lands on real tasks). View filtering always runs **before** the sort. (`skip` moved to `S` to free `s`.)
|
||||
- ✅ **nvim task-navigation polish (§8) — DONE:** `:Heph next`/`list` rows now carry a compact **do/late date chip** (and a recurrence `↻`); `<CR>` already jumps to a row's canonical-context doc (read/navigate, not field-edit).
|
||||
3. ✅ **Tags (§4, §8.3) — DONE:** a tag is a `tag`-kind node whose id is **deterministic in `(owner, name)`** (`tag:<owner>:<name>`, like the journal), so a name is one canonical tag and replicas converge — no duplicate tag nodes. Tagging is an **OR-set `tagged` link** (mirroring `in-project`): `Store::add_tag` (get-or-create the tag node, idempotent link), `remove_tag` (tombstone the link), `tags_of` (sorted names); enumerate all tags via `list_nodes(Tag)`. RPCs `tag.add`/`tag.remove`/`tag.list` (+ RemoteStore forward); CLI `heph tag add|rm|list`. Names are trimmed, case preserved (canonical normalization deferred to the zk import). Unblocks the `tags:` line of the frontmatter surface (§8.3) and the eventual zk import; inline `#hashtags` remain a heph.nvim concern (§8.3).
|
||||
4. ⏳ **YAML frontmatter as an edit surface (§8.3) — docs-first C1:** generated-on-read, stripped-and-ignored-on-write in `heph-core`; `heph.nvim` diffs it into structured RPCs. See §8.3.
|
||||
4. ◐ **YAML frontmatter as an edit surface (§8.3) — backend DONE, nvim next:** ✅ the projection — `heph-core::frontmatter::strip` (conservative, runs in `update_node` before the CRDT diff) + `hephd::frontmatter::render` (local-tz dates via `datespec::fmt_iso`) behind `node.get {frontmatter: true}`; a task's context-doc surfaces the owning task's scalars + a `task:` ref. Round-trip is a no-op; inbound frontmatter is always stripped (safe vs any client). ⏳ Remaining: the `heph.nvim` diff-on-`BufWriteCmd` → structured RPCs (`title`→rename, `attention`→set_attention, dates→set_schedule, `project`→set_project, `tags`→tag.add/remove) + inline `#hashtags` on save.
|
||||
5. ⏳ **Wiki-links by node id (§8.4) — docs-first C1 (maybe C2):** canonical `[[NODEID]]` at rest, expanded/concealed for display; a `[[` picker; no name-links in the DB. Includes a one-time body fixup. See §8.4.
|
||||
6. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9).
|
||||
7. ⏳ **Split `heph.nvim` to its own forge repo (§8) — UX polish:** generated from this monorepo (subtree-split in CI) so the lazy spec becomes `{ "eblume/heph.nvim" }` instead of a local-clone `dir` (see [[install-heph]]).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue