generated from eblume/project-template
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>
138 lines
4.6 KiB
Rust
138 lines
4.6 KiB
Rust
//! 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(""), "\"\"");
|
|
}
|
|
}
|