generated from eblume/project-template
feat(nvim): inline #hashtags become tags on save (§8.3)
Some checks failed
Build / validate (pull_request) Failing after 4m57s
Some checks failed
Build / validate (pull_request) Failing after 4m57s
On save, whitespace-prefixed `#hashtags` in a node's body are unioned into its tag set (via `frontmatter.hashtags` + the existing tag diff), so you can tag a note by writing `#kitchen` inline. A markdown `# heading` has a space after the `#`, so it never matches. e2e covers it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d85ce3362f
commit
8dc98dc9c1
5 changed files with 54 additions and 9 deletions
|
|
@ -28,6 +28,6 @@ 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 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). Link-follow and promotion are unaffected (they're content-relative, not line-absolute).
|
||||
- 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). 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.
|
||||
|
|
|
|||
|
|
@ -302,12 +302,12 @@ Project-subtree resolution needs the **parent-project links** ([[design]] §6.2.
|
|||
|
||||
## 8.3 Frontmatter as an edit surface (built)
|
||||
|
||||
> **Status: built** (inline `#hashtags` on save are a small deferred follow-up). 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 atop the body — without that metadata ever becoming a second, drifting source of truth in the body.
|
||||
> **Status: built.** 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 atop the body — without that metadata ever becoming a second, drifting source of truth in the body.
|
||||
|
||||
The resolving principle is a **two-layer split** that keeps the store safe against *any* client while making `heph.nvim` a rich editor:
|
||||
|
||||
- ✅ **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.** `node.lua` reads with `frontmatter: true` (the buffer opens with the block on top) and caches the rendered block; on `BufWriteCmd`, `frontmatter.lua` parses the buffer's block, **diffs it against the cached one**, and translates each changed field into the **correct structured RPC**: `title` → rename, `attention` → `set_attention`, `do_date`/`late_on`/`recurrence` → `set_schedule` (dates parsed `YYYY-MM-DD` → local-midnight ms; a removed line clears via `null`), `project` → **`set_project`** (resolved by name, §8.1), `tags` → `tag.add`/`tag.remove` (§14 tags); a mistyped `state` surfaces the daemon's validation error. Then it sends the (frontmatter-less) body. A buffer with **no** block touches no metadata — only deleting individual fields edits them — so removing the whole block can't accidentally wipe tags. (Body-position features — `[[link]]` follow, promote — are content-relative, so the prepended block doesn't disturb them.) **Remaining:** inline `#hashtags` detected on save.
|
||||
- ✅ **`heph.nvim` is the smart client.** `node.lua` reads with `frontmatter: true` (the buffer opens with the block on top) and caches the rendered block; on `BufWriteCmd`, `frontmatter.lua` parses the buffer's block, **diffs it against the cached one**, and translates each changed field into the **correct structured RPC**: `title` → rename, `attention` → `set_attention`, `do_date`/`late_on`/`recurrence` → `set_schedule` (dates parsed `YYYY-MM-DD` → local-midnight ms; a removed line clears via `null`), `project` → **`set_project`** (resolved by name, §8.1), `tags` → `tag.add`/`tag.remove` (§14 tags); a mistyped `state` surfaces the daemon's validation error. Then it sends the (frontmatter-less) body. A buffer with **no** block touches no metadata — only deleting individual fields edits them — so removing the whole block can't accidentally wipe tags. Inline **`#hashtags`** in the body are unioned into the tag set on save (a `# heading` has a space after `#`, so it doesn't match). (Body-position features — `[[link]]` follow, promote — are content-relative, so the prepended block doesn't disturb them.)
|
||||
|
||||
**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:
|
||||
|
||||
|
|
@ -468,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) — 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). **Deferred follow-up:** inline `#hashtags` detected on save.
|
||||
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) — 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]]).
|
||||
|
|
|
|||
|
|
@ -79,6 +79,35 @@ local function date_to_ms(s)
|
|||
return os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d), hour = 0, min = 0, sec = 0 }) * 1000
|
||||
end
|
||||
|
||||
--- Whitespace-prefixed inline `#hashtags` in `body`, de-duplicated in order.
|
||||
--- A markdown heading (`# Title`, `## foo`) has a space after the `#`, so it
|
||||
--- never matches. (Scanned across the whole body, code fences included — a
|
||||
--- pragmatic v1 simplification.)
|
||||
function M.hashtags(body)
|
||||
local seen, out = {}, {}
|
||||
for tag in (" " .. (body or "")):gmatch("%s#([%w_%-]+)") do
|
||||
if not seen[tag] then
|
||||
seen[tag] = true
|
||||
out[#out + 1] = tag
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
--- Union of two scalar lists, order-stable, de-duplicated.
|
||||
local function union(a, b)
|
||||
local seen, out = {}, {}
|
||||
for _, list in ipairs({ a or {}, b or {} }) do
|
||||
for _, x in ipairs(list) do
|
||||
if not seen[x] then
|
||||
seen[x] = true
|
||||
out[#out + 1] = x
|
||||
end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
--- Set difference of two scalar lists → (added, removed).
|
||||
local function set_diff(old, new)
|
||||
local oldset, newset = {}, {}
|
||||
|
|
@ -104,12 +133,16 @@ end
|
|||
|
||||
--- Apply the difference between `canonical` (rendered on read) and the buffer's
|
||||
--- `fm` by issuing the matching RPCs. `node_id` is the opened node (title/tags
|
||||
--- target); task scalars route to the owning task (`fm.task`). Raises on any
|
||||
--- RPC error (e.g. a mistyped `state` or an unknown `project`).
|
||||
function M.apply(node_id, canonical, fm)
|
||||
--- target); task scalars route to the owning task (`fm.task`). Inline
|
||||
--- `#hashtags` in `body` are unioned into the desired tag set (so they manage
|
||||
--- tags too); `fm.tags` is updated to that union so the caller caches it as the
|
||||
--- new canonical. Raises on any RPC error (a mistyped `state`, an unknown
|
||||
--- `project`).
|
||||
function M.apply(node_id, canonical, fm, body)
|
||||
canonical = canonical or {}
|
||||
|
||||
-- Tags on the opened node.
|
||||
-- Tags on the opened node = the frontmatter list ∪ inline #hashtags.
|
||||
fm.tags = union(fm.tags, M.hashtags(body))
|
||||
local added, removed = set_diff(canonical.tags, fm.tags)
|
||||
for _, t in ipairs(added) do
|
||||
rpc.call("tag.add", { node_id = node_id, tag = t })
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ function M.write(buf, _uri)
|
|||
-- block ⇒ the user removed it; treat the whole buffer as body, touch no
|
||||
-- metadata.) The backend also strips defensively, so `body` is what stores.
|
||||
local canonical = M._canonical[buf] or {}
|
||||
frontmatter.apply(id, canonical, fm)
|
||||
frontmatter.apply(id, canonical, fm, body)
|
||||
if fm.title and fm.title ~= canonical.title then
|
||||
params.title = fm.title
|
||||
end
|
||||
|
|
|
|||
|
|
@ -82,4 +82,16 @@ describe("frontmatter edit surface", function()
|
|||
assert.are.equal(1, #tags)
|
||||
assert.are.equal("roofing", tags[1])
|
||||
end)
|
||||
|
||||
it("adds inline #hashtags from the body as tags on save", function()
|
||||
local doc = h.create_doc("Notes", "# Notes")
|
||||
local buf = h.open(doc.id)
|
||||
-- Append a body line with an inline hashtag (a `# heading` must not match).
|
||||
vim.api.nvim_buf_set_lines(buf, -1, -1, false, { "", "see #kitchen and # not-a-tag" })
|
||||
h.save(buf)
|
||||
|
||||
local tags = ctx.q:call("tag.list", { node_id = doc.id })
|
||||
assert.are.equal(1, #tags, "exactly the one inline tag")
|
||||
assert.are.equal("kitchen", tags[1])
|
||||
end)
|
||||
end)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue