diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index 01b455d..577f49c 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -321,6 +321,29 @@ impl Store for LocalStore { tags::of(&self.conn, node_id) } + fn migrate_wikilinks_to_ids(&mut self) -> Result { + let owner = self.owner_id.clone(); + let candidates: Vec<(String, String)> = { + let mut stmt = self.conn.prepare( + "SELECT id, body FROM nodes + WHERE owner_id = ?1 AND tombstoned = 0 AND body IS NOT NULL", + )?; + let rows = stmt.query_map([&owner], |r| Ok((r.get(0)?, r.get(1)?)))?; + rows.collect::>>()? + }; + let mut changed = 0; + for (id, body) in candidates { + let new_body = crate::wikilink::to_ids(&body, |t| { + links::resolve_id(&self.conn, &owner, t).ok().flatten() + }); + if new_body != body { + self.update_node(&id, None, Some(new_body))?; + changed += 1; + } + } + Ok(changed) + } + fn log_append(&mut self, task_id: &str, text: &str) -> Result<()> { let now = self.clock.now_ms(); let tx = self.conn.transaction()?; diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 656b1cb..52d459f 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -157,6 +157,12 @@ pub trait Store { /// [`Store::list_nodes`] with [`NodeKind::Tag`].) fn tags_of(&self, node_id: &str) -> Result>; + /// One-time migration (tech-spec §8.4): rewrite legacy name-addressed body + /// links `[[Name]]` to the canonical `[[NODEID]]`, resolving each name and + /// re-materializing the `wiki` links by id. Idempotent (already-id links are + /// left alone). Returns the number of nodes whose body changed. + fn migrate_wikilinks_to_ids(&mut self) -> Result; + // --- per-task log ([[design]] §6.4) --- /// Append a line to a task's append-only log (creating the log on first diff --git a/crates/heph-core/src/wikilink.rs b/crates/heph-core/src/wikilink.rs index 5a01d07..4173067 100644 --- a/crates/heph-core/src/wikilink.rs +++ b/crates/heph-core/src/wikilink.rs @@ -71,6 +71,22 @@ pub fn collapse(body: &str, title_of: impl Fn(&str) -> Option) -> String }) } +/// Rewrite legacy name-addressed links `[[Name]]`/`[[Name|text]]` to the +/// canonical `[[NODEID]]`/`[[NODEID|text]]` (the one-time migration, §8.4). +/// `resolve(target)` returns the node id a target resolves to (id-first, then +/// name): a target that already *is* an id resolves to itself and is left +/// alone; a name resolves to a different id and is rewritten (its display text, +/// if any, preserved); an unresolvable target is left untouched. +pub fn to_ids(body: &str, resolve: impl Fn(&str) -> Option) -> String { + rewrite_spans(body, |target, display| match resolve(target) { + Some(id) if id != target => Some(match display { + Some(d) => format!("[[{id}|{d}]]"), + None => format!("[[{id}]]"), + }), + _ => None, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -110,6 +126,21 @@ mod tests { assert_eq!(collapse(&shown, &t), stored); } + #[test] + fn to_ids_rewrites_names_keeps_ids_and_preserves_labels() { + // `resolve`: a known name → its id; an id → itself; else None. + let resolve = |t: &str| match t { + "Roof" => Some("01ID".to_string()), + "01ID" => Some("01ID".to_string()), + "02ID" => Some("02ID".to_string()), + _ => None, + }; + assert_eq!(to_ids("see [[Roof]]", resolve), "see [[01ID]]"); + assert_eq!(to_ids("[[Roof|my label]]", resolve), "[[01ID|my label]]"); + assert_eq!(to_ids("[[01ID]]", resolve), "[[01ID]]"); // already an id + assert_eq!(to_ids("[[Unknown]]", resolve), "[[Unknown]]"); // unresolvable + } + #[test] fn preserves_surrounding_text_and_handles_unterminated() { let t = titles(); diff --git a/crates/heph-core/tests/wikilinks.rs b/crates/heph-core/tests/wikilinks.rs index a56fa3e..7ab1915 100644 --- a/crates/heph-core/tests/wikilinks.rs +++ b/crates/heph-core/tests/wikilinks.rs @@ -37,3 +37,28 @@ fn update_collapses_name_matching_labels_and_materializes_by_id() { let u2 = s.update_node(&src.id, None, Some(custom.clone())).unwrap(); assert_eq!(u2.body.as_deref(), Some(custom.as_str())); } + +#[test] +fn migrate_rewrites_legacy_name_links_to_ids() { + let mut s = store(); + let target = s.create_node(NewNode::doc("Roof", "")).unwrap(); + // A legacy body authored with a name-addressed link (pre-§8.4). + let src = s + .create_node(NewNode::doc("Daily", "fix the [[Roof]] soon")) + .unwrap(); + + let changed = s.migrate_wikilinks_to_ids().unwrap(); + assert_eq!(changed, 1); + + // The name was rewritten to the canonical bare id, and the wiki link is by id. + let body = s.get_node(&src.id).unwrap().unwrap().body.unwrap(); + assert_eq!(body, format!("fix the [[{}]] soon", target.id)); + assert!(s + .outgoing_links(&src.id) + .unwrap() + .iter() + .any(|l| l.link_type == LinkType::Wiki && l.dst_id == target.id)); + + // Idempotent: a second run finds nothing to change. + assert_eq!(s.migrate_wikilinks_to_ids().unwrap(), 0); +} diff --git a/crates/heph/src/main.rs b/crates/heph/src/main.rs index fef4a3c..8c2f8b0 100644 --- a/crates/heph/src/main.rs +++ b/crates/heph/src/main.rs @@ -230,6 +230,9 @@ enum Command { /// Destination directory (created if needed). dir: PathBuf, }, + /// One-time: rewrite legacy `[[Name]]` body links to the canonical + /// `[[node-id]]` form (tech-spec §8.4). Idempotent. + MigrateLinks, /// Manage the hephd daemon as an OS service (launchd / systemd). Daemon { #[command(subcommand)] @@ -726,6 +729,11 @@ fn main() -> Result<()> { let count = result.get("count").and_then(Value::as_u64).unwrap_or(0); println!("Exported {count} nodes to {}", dir.display()); } + Command::MigrateLinks => { + let result = client.call("migrate.wikilinks", json!({}))?; + let n = result.as_u64().unwrap_or(0); + println!("Rewrote legacy [[Name]] links to [[id]] in {n} node(s)."); + } Command::Auth { .. } => unreachable!("auth is handled before connecting"), Command::Daemon { .. } => unreachable!("daemon is handled before connecting"), } diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index 7449bc8..2d997cf 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -248,6 +248,10 @@ impl Store for RemoteStore { self.call_as("tag.list", json!({ "node_id": node_id })) } + fn migrate_wikilinks_to_ids(&mut self) -> Result { + self.call_as("migrate.wikilinks", json!({})) + } + fn log_append(&mut self, task_id: &str, text: &str) -> Result<()> { self.call("log.append", json!({ "task_id": task_id, "text": text })) .map(|_| ()) diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index 461d882..8f0a77a 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -422,6 +422,7 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result json!(store.migrate_wikilinks_to_ids()?), "log.append" => { let p: LogAppendParams = parse(params)?; store.log_append(&p.task_id, &p.text)?; diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 46c7ab4..5c55db3 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -28,7 +28,7 @@ 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; `` still jumps to a task's context doc. -- Wiki-links by node id (§8.4): node resolution is now **id-first** (`[[NODEID]]` resolves to its node ahead of any name match, so links can't be shadowed by a like-named node), and heph.nvim grows a **`[[` picker** — type `[[` (or `:Heph link`) to search your nodes and insert a canonical `[[NODEID]]` link, with a "+ Create new doc" entry that mints one on the spot. Following such a link (``) jumps straight by id. Those id links are kept **readable**: on read a bare `[[NODEID]]` is expanded to `[[NODEID|Current Name]]` (so it follows renames, in both the nvim buffer and the TUI preview), and on save it collapses back to the canonical bare id — a custom `|label` you write is preserved as an override. In the editor the id is **concealed** — a link renders as just its name, styled like a hyperlink, with the raw `[[id|Name]]` revealed on the line your cursor is on. (Legacy `[[Name]]` links still resolve until a one-time migration rewrites them.) +- Wiki-links by node id (§8.4): node resolution is now **id-first** (`[[NODEID]]` resolves to its node ahead of any name match, so links can't be shadowed by a like-named node), and heph.nvim grows a **`[[` picker** — type `[[` (or `:Heph link`) to search your nodes and insert a canonical `[[NODEID]]` link, with a "+ Create new doc" entry that mints one on the spot. Following such a link (``) jumps straight by id. Those id links are kept **readable**: on read a bare `[[NODEID]]` is expanded to `[[NODEID|Current Name]]` (so it follows renames, in both the nvim buffer and the TUI preview), and on save it collapses back to the canonical bare id — a custom `|label` you write is preserved as an override. In the editor the id is **concealed** — a link renders as just its name, styled like a hyperlink, with the raw `[[id|Name]]` revealed on the line your cursor is on. Legacy `[[Name]]` links keep working, and **`heph migrate-links`** rewrites them to the canonical id form in one pass when you're ready (idempotent). - Frontmatter editing in heph.nvim (§8.3): opening a node now shows an editable **YAML frontmatter** block on top of the body (`id`/`kind`/`title`/`tags`, and for a task or its context doc the task's `state`/`attention`/`do_date`/`late_on`/`recurrence`/`project`). On save, the plugin diffs the block and issues the right RPC per changed field — rename, set-attention, reschedule (dates as `YYYY-MM-DD`), move-to-project (by name), and tag add/remove — then saves the body; the store strips the block so it never persists. A mistyped `state` surfaces a validation error; a buffer with no block changes no metadata (so deleting the block can't wipe your tags). Inline **`#hashtags`** typed in the body are also added as tags on save (a `# heading` doesn't count) and are rendered in **italics** so they stand out. Link-follow and promotion are unaffected (they're content-relative, not line-absolute). - 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. diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 138c608..31cd673 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -338,7 +338,7 @@ Field rules: `id`/`kind` are **read-only** (display only); `title`, `attention`, - **At rest (target):** `[[NODEID]]`, or `[[NODEID|custom text]]` when the author wrote explicit display text. The id before the `|` is the target. - ✅ **Projection (same philosophy as §8.3):** `heph-core::wikilink` (pure, injected id→title) — `node.get` **expands** a bare `[[NODEID]]` → `[[NODEID|Current Name]]` (every read, so the nvim buffer *and* the TUI preview are readable), and `update_node` **collapses** a `|text` equal to the target's current name back to bare before the CRDT diff (a custom label is preserved as an override). Transform order: read = expand links → prepend frontmatter; write = strip frontmatter → collapse links → store. An unchanged read→write round-trips to the canonical bare id. *(`heph export` still emits raw ids — a later polish.)* - ✅ **`heph.nvim` display:** `conceal.lua` hides the `[[id|` prefix and the `]]` suffix with conceal extmarks (refreshed on edit), leaving the label as a styled `HephLink` hyperlink; `conceallevel=2` + empty `concealcursor` reveal the raw link on the cursor's line so it stays editable. -- ⏳ **Migration:** a **one-time fixup** rewrites existing `[[Title]]` bodies to `[[NODEID]]` (resolve→id; flag the unresolvable), after which name-resolution + the canonical-context hack are removed. No special care is warranted (no critical data yet); a first-class migrations feature stays **deferred**. +- ✅ **Migration:** **`heph migrate-links`** (the `migrate.wikilinks` RPC → `Store::migrate_wikilinks_to_ids` → `wikilink::to_ids`) rewrites legacy `[[Name]]` bodies to `[[NODEID]]` and re-materializes the `wiki` links by id; idempotent (already-id links untouched). It is **not auto-run** — the owner runs it once per store. Name-resolution and the canonical-context hack **stay for now** (legacy links keep working until the migration has been run everywhere); removing them is a later tidy. A first-class migrations feature stays **deferred**. ## 9. Testing strategy (TDD, layered) @@ -470,7 +470,7 @@ See [[design]] §5–§7 for the constraints later phases impose on present choi - ✅ **nvim task-navigation polish (§8) — DONE:** `:Heph next`/`list` rows now carry a compact **do/late date chip** (and a recurrence `↻`); `` 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::`, 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) — DONE:** 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 and inbound frontmatter is always stripped (safe vs any client). And the `heph.nvim` smart client (`frontmatter.lua`): the buffer opens with the editable block, and `BufWriteCmd` diffs it → `title`→rename / `attention`→set_attention / dates→set_schedule / `project`→set_project / `tags`→tag.add·remove (a no-block buffer touches no metadata), and inline `#hashtags` in the body are unioned into the tag set on save. -5. ◐ **Wiki-links by node id (§8.4) — authoring + display DONE, migration next:** ✅ id-first resolution; the `heph.nvim` `[[` picker (`search` → insert `[[NODEID|Name]]`, "+ Create" mints a doc) + id-direct follow; the **expand-on-read / collapse-on-write** projection (`heph-core::wikilink`); and **conceal display** (`conceal.lua` hides the id, shows the label as a `HephLink`, reveals on the cursor line). ⏳ Remaining: the one-time `[[Title]]`→`[[NODEID]]` migration (then retire name-resolution + the canonical-context hack). See §8.4. +5. ✅ **Wiki-links by node id (§8.4) — DONE:** id-first resolution; the `heph.nvim` `[[` picker (`search` → insert `[[NODEID|Name]]`, "+ Create" mints a doc) + id-direct follow; the **expand-on-read / collapse-on-write** projection (`heph-core::wikilink`); **conceal display** (`conceal.lua` hides the id, shows the label as a `HephLink`, reveals on the cursor line); and the one-time **`heph migrate-links`** migration of legacy `[[Name]]`→`[[NODEID]]`. *(Follow-up tidy, once the migration is run on every store: retire name-resolution + the canonical-context hack — kept for now so legacy links work pre-migration.)* 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]]). 8. ⏳ **Adoption refinement + multi-tenant (§13) — before v1 done, low priority:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension.