on master help sign in
spec 0.40: checkbox is the only task state, names set once, block files adopt stray column-0 lines, ordinal node keys, per-file checked undo, blank line before text after a node, heading-embed level checks
commit65d0c87e04d0656b4098647505a811c0cbc13ca2 authoriko <ilyakooo0@gmail.com> dateSat 26 Sep 2026, 04:07 (1 month ago) parent7e7417d
15 files changed+933−485
SPEC.md+146−66 crates/fold-core/src/check.rs+80−3 crates/fold-core/src/merge.rs+13−102 crates/fold-core/src/ops.rs+184−188 crates/fold-core/src/parse.rs+82−26 crates/fold-core/src/render.rs+9−9 crates/fold-core/src/tree.rs+14 crates/fold-core/src/vault.rs+55−11 crates/fold-core/tests/edit.rs+2−2 crates/fold-core/tests/merge_check.rs+5−5 crates/fold-core/tests/ops.rs+25−8 crates/fold-core/tests/ordering.rs+141−1 crates/fold-core/tests/roundtrip.rs+1−1 crates/fold-core/tests/undo.rs+84 crates/fold-tui/src/app.rs+92−63
SPEC.md+146−66
@@ -1,9 +1,20 @@
11# SPEC — a tree-shaped plain-text notes and task manager for the terminal
22
3−Status: draft 0.39 · 2026-09-26
3+Status: draft 0.40 · 2026-09-26
44Working name: not chosen yet. This document uses `notes` as the binary name; rename freely.
55Language: Rust · TUI: ratatui · Sync: Syncthing · History: file versioning on one node
66
7+Changes in 0.40: one spelling of task state. A block's state is the checkbox on its own
8+title line, like any node's; `todo:` is gone (read on legacy files, migrated on the next
9+toggle or `check --fix`), so `rg '\[ \]'` is the whole open-task list (§4.5, §8). Block
10+files keep their names when titles change; `check --fix` renames on request (§6.4). A block
11+file's later column-0 lines are adopted by its root instead of making it read-only (§4.9).
12+Verbs track nodes by key — titles plus ordinals among same-titled siblings — never by title
13+path (§3.4). Undo records exactly the files an operation touched and refuses if one changed
14+since (§10.11). Text after a child node is always preceded by a blank line (§4.2), and a
15+heading embed's level is checked against its position (§4.7). §3.1 states what the ordering
16+rule costs.
17+
718Changes in 0.39: a node's children are an ordered list of **text children** and **child
819nodes**, interleaved as they are in the file; the body is just the leading text (§3.1, §3.3).
920Children obey one ordering rule, `(text | item)* section*`, because Markdown cannot close a
@@ -237,7 +248,7 @@ Principles, in priority order:
237248| Section | A node spelled as an ATX heading `#`, `##`, … (no upper bound). |
238249| Item | A node spelled as a `- ` bullet. |
239250| Spelling | Whether a node is written as a heading or a bullet. Presentation only (§3.1). |
240−| Task | A non-block node with a checkbox after its marker, or a block with a `todo` key. |
251+| Task | A node with a checkbox after its marker — block or not. |
241252| Block | A node with its own identity and file. Only blocks have properties; every edit changes exactly one block. |
242253| Id | A random four-word phonemic name (`racfer-hattes-dozzod-binwes`) in a block's `id:` frontmatter; its stable identity. |
243254| Name | The slug of a block's title. With a prefix of the id in front, its filename. Decoration, not identity. |
@@ -292,6 +303,17 @@ Rules:
292303 before its new parent's first section child, and a section no earlier than just after its
293304 last item child. The *boundary* of a node's children is the position between its last
294305 item or text child and its first section child.
306+
307+ **What the rule costs.** A node cannot have items or text after a section child: under
308+ `# Trip` with a `## Budget` subsection, there is no way to put another bullet or paragraph
309+ back under Trip after Budget. Everything written there is Budget's. Plain Markdown cannot
310+ express it either, so the format accepts the limit rather than invent a closing syntax
311+ no other tool would read. The practical consequences are that `>`, `<`, paste, refile and
312+ capture sometimes place an item earlier than asked (before the first section), `J` / `K`
313+ refuse to move an item past a section, and `~` moves the node it respells. The TUI says
314+ so in the status line whenever the rule, not the user, chose the position; none of these
315+ is a bug. The workaround is the obvious one:
316+ put the later items in a section of their own (`## Also`), or keep sections last.
295317- **Text children are content, not outline.** They are not rows in the outline pane, not
296318 targets, never blocks or tasks, and are never moved on their own. A verb that moves,
297319 deletes or refiles a node moves exactly that node's own lines; the text children around it
@@ -307,8 +329,10 @@ Rules:
307329 moves to its parent's boundary, so its siblings keep their parent.
308330- Sibling order is block order and is meaningful.
309331- Any node may be a **block** (the root of its own file). Its file starts with the node
310− as it is spelled — a heading or a bullet — so the file is the only place its spelling
311− lives (§4.9).
332+ as it is spelled — a heading or a bullet — and that is where its spelling is defined
333+ (§4.9). The embed repeats it in its form (§4.7) so the parent file parses into the right
334+ shape on its own; the app keeps the two in step (`~` rewrites both), and where they
335+ disagree the file wins and `notes check` reports the embed.
312336
313337### 3.2 Properties
314338
@@ -332,7 +356,7 @@ Reserved keys (the app assigns semantics; users may still read/write them by han
332356| Key | Value | Meaning |
333357|---|---|---|
334358| `id` | four `@p` words | The block's identity (§3.4). Present on every managed file; never on `root.md`. |
335−| `todo` | `open` \| `done` | Makes the block a task and holds its state (§4.5). Absent means not a task. |
359+| `todo` | `open` \| `done` | **Legacy.** Before 0.40 it held a block's task state. Read only when the block's title line has no checkbox, reported by `notes check`, and removed on the next toggle or `check --fix`, which write the checkbox instead (§4.5). The app never writes it. |
336360| `due` | `YYYY-MM-DD` | Task due date; shown in the outline. |
337361| `done` | `YYYY-MM-DD` | Written when a task block is checked; removed when unchecked. |
338362| `conflict` | `<device> <timestamp>` | Marks a "theirs" copy written by the merge engine (§12.4). |
@@ -361,8 +385,8 @@ A text line belongs to the deepest open node whose region it reaches:
361385
362386So a line at column 0 after `- b` under `# Trip` is Trip's text, not b's: it ends the list.
363387(CommonMark would read an unindented line right after a list item with no blank line between
364−as a lazy continuation of that item; this format does not. Write a blank line before it and
365−both agree.)
388+as a lazy continuation of that item; this format does not. Canonical form therefore always
389+writes a blank line there (§4.2), and then both agree.)
366390
367391A blank line belongs to the text run it is in, or — between nodes — to the deepest open node
368392before it: the blank line after `- a` is `a`'s trailing separator. Blank lines are therefore
@@ -382,7 +406,19 @@ when the block is created and stored as the `id` key of its frontmatter. Its fil
382406directory uses as its prefix — usually one — then the slug of its title
383407(`racfer~order-new-switch.md`). The prefix alone identifies the file; the name is for humans. The filename is decoration that any tool may change and the
384408app will repair (§6.4). The one exception is `root.md`, which has no
385−id. A non-block node's identity is its **path**.
409+id. A non-block node has no stored identity. Two things stand in for one, and they are
410+different on purpose:
411+
412+- **Typed targets** (below) name a node by titles. Titles are what a person can type, and a
413+ title that matches twice is an ambiguity error, never a guess.
414+- **Tracking** — re-attaching the cursor, folds and a verb's result across a re-parse —
415+ uses the node's **key**: the block whose file holds it (none for `root.md`), then one step
416+ per level down from that file's root, each step the title *and the node's ordinal among
417+ same-titled siblings*. Two siblings with one title, or two empty new nodes, have
418+ different keys. Keys are never shown and never typed.
419+
420+Verbs never find a node again by its title path: a verb that re-parses finds its result by
421+key, by id, or by position.
386422
387423Embeds reference the id alone (`![[racfer-hattes-dozzod-binwes]]`), so nothing in the vault
388424depends on a filename and a rename touches only the renamed file.
@@ -399,7 +435,8 @@ Resolution order for a command target (the *refile*, *go to* and *make block* pr
399435Ambiguous or missing targets are an error for the command that named them; broken or
400436duplicate embeds are diagnostics in `notes check` (§15.7).
401437
402−Renaming a block's title renames its file (§6.4). Nothing else references the filename.
438+Renaming a block's title does not rename its file (§6.4); a stale name is harmless, since
439+nothing references the filename.
403440
404441Titles may not contain `/` (the path separator). `[` and `]` are discouraged.
405442
@@ -465,6 +502,8 @@ The app **reads** any CommonMark it can make sense of and **writes** this subset
465502 or bullets alike. `[X]` is accepted on read; `[-]` is read as `[x]`.
466503- Exactly one blank line between a node's body and its first child, and between sibling
467504 sections. Loose/tight lists are preserved as found.
505+- A blank line before any text child that follows a child node (§3.3), so that CommonMark
506+ readers agree the text is not part of that node.
468507
469508Canonicalization is **lazy**: a node is rewritten in canonical form only when it is touched
470509(edited, spliced, moved). `notes check --fix` (or *canonicalize* in the palette) rewrites
@@ -508,21 +547,31 @@ was designed for an external editor and is set aside with it, §17.)
508547
509548### 4.5 Tasks
510549
511−Any node can be a task. Where the state is written depends on whether the node is a file:
550+Any node can be a task, and there is one way to write it: a **checkbox after the marker on
551+its title line**. `- [ ]` and `## [ ]` are both open tasks; `[x]` is done. A block is no
552+different — its checkbox is on its own title line, in its file:
512553
513−- A node that is **not** a block is a task iff it has a checkbox after its marker.
514− `- [ ]` and `## [ ]` are both open tasks; `[x]` is done. That is all the state it has.
515−- A **block** is a task iff its frontmatter has `todo:`, whose value is the state:
516− `open` or `done`. Its title line in the file carries no checkbox. Checking writes
517− `todo: done` and `done: <date>`; reverting sets `todo: open` and removes the date.
554+```markdown
555+---
556+id: racfer-hattes-mislup-nodrys
557+due: 2026-09-20
558+---
559+
560+- [ ] Order new switch
561+```
562+
563+What a block adds is a completion record: checking it writes `done: <date>` to its
564+frontmatter, and unchecking removes the date. A plain task has no frontmatter and so no
565+date; that is the whole difference. Because the state is always on the title line,
566+`rg '\[ \]'` over the vault is the complete open-task list.
518567
519568There is no cancelled state. A task that will never be done is either checked off or
520569deleted.
521570
522−Embeds carry no state. Cutting a checkbox task turns the checkbox into `todo:` (§6.1). A
523−checkbox on a block's own title line is
524−accepted on read as `todo` and rewritten on the next touch. A task section has its own
525−state *and* the derived count of its subtree. See §8.
571+Embeds carry no state: the checkbox travels with the title line into the block's file when
572+it is made (§6.1). A legacy `todo:` key (§3.2) is read as the state when the title line has
573+no checkbox; if both are present the checkbox wins. A task section has its own state *and*
574+the derived count of its subtree. See §8.
526575
527576### 4.6 Links
528577
@@ -554,11 +603,19 @@ there, belong to that section (§3.1). Either form is read anywhere; an embed wh
554603does not match its block's spelling is a diagnostic (§15.7) that `notes check --fix`
555604rewrites, and it is placed where its form puts it.
556605
557−The embed names the block by id and nothing else; title, checkbox or `todo`, properties,
558−text and children all live in the file. The embed's form repeats the block's spelling so
559−that the parent file parses into the right shape without opening the block. An embed line
560−has no children in the parent file; lines indented under a bare embed, or nested under a
561−heading embed, are a diagnostic.
606+The embed names the block by id and nothing else; title, checkbox, properties, text and
607+children all live in the file. The embed's form repeats the block's spelling so that the
608+parent file parses into the right shape without opening the block. An embed line has no
609+children in the parent file; lines indented under a bare embed, or nested under a heading
610+embed, are a diagnostic.
611+
612+A heading embed's **level** is read like any heading's: honoured as written, and it
613+decides the embed's parent the same way. The level its position gives is `1 +` its section
614+ancestors (§3.1), and that is the level the app writes. A written level deeper than that —
615+left by another editor, or by moving text around by hand — still parses under the same
616+parent (a shallower one would parse elsewhere and is simply a different position), so
617+`notes check` reports it and `check --fix` rewrites it to the positional level; the
618+structure does not change.
562619
563620`![[` anywhere else is body text. The app does not implement general transclusion.
564621
@@ -572,7 +629,7 @@ for a later query language (§17); leave such blocks alone and they will start w
572629A block's file is exactly `render(node, 1, resolve_blocks = false)`:
573630
574631```
575−[frontmatter, always beginning with id; todo: if the block is a task]
632+[frontmatter, always beginning with id; done: if the block is a checked task]
576633[the node's title line, as spelled: "# Title" or "- Title"]
577634[its children: text and nodes, in order]
578635```
@@ -580,11 +637,18 @@ A block's file is exactly `render(node, 1, resolve_blocks = false)`:
580637- The root is written as it is spelled in the tree. A block that is a heading is a `#` at column 0; one
581638 that is a bullet is a `- ` at column 0, with its body and children indented under it exactly
582639 as they would be in the parent. The file is the only place the spelling is recorded.
583−- The block's task state is `todo:` in its frontmatter, with a `done:` date beside it
584− when done; there is no checkbox in the file. Toggling the node — from the outline,
585− or the embed — writes to the file.
586−- A file with text before the root other than frontmatter, or with more than one node at
587− column 0, is a diagnostic; the app treats the file as read-only until fixed.
640+- The block's task state is the checkbox on its title line, with a `done:` date in the
641+ frontmatter when it is checked. Toggling the node — from the outline, or the embed —
642+ writes to the file.
643+- The first node at column 0 is the block's root. **Anything after it at column 0 — more
644+ nodes, or text — is adopted as the root's children**, in order, as if it were indented
645+ under it. That is what a phone editor produces by appending `- new thing` to a block
646+ spelled as a bullet, and it must never make the block unusable. Adopted lines are
647+ non-canonical (`notes check` notes them); the next write of the block, or `check --fix`,
648+ writes them in place under the root (indented under a bullet root, re-levelled under a
649+ heading root).
650+- Text before the root other than frontmatter, or an embed before it, is a diagnostic; the
651+ app treats that file as read-only until fixed, since it cannot tell what the block is.
588652- `root.md` is the file of the implicit Root node; its frontmatter holds vault-level
589653 properties (rarely needed) and never an `id`.
590654
@@ -640,19 +704,18 @@ Mirrored pairs, no raidz. Snapshots hourly via sanoid.
640704```markdown
641705---
642706id: racfer-hattes-mislup-nodrys
643−todo: open
644707due: 2026-09-20
645708---
646709
647−- Order new switch
710+- [ ] Order new switch
648711 Two options, noted under Networking.
649712```
650713
651714Note that "Replace the flaky switch" is a one-line task with no file; "ZFS layout" is a
652715section block, so its embed is a heading at the level it has under NAS, while "Order new
653716switch" is an item block and is embedded bare. "Order new switch" became a block the moment
654−it got a due date, its checkbox became `todo: open`, and its
655−file starts with a bullet because that is what it is. "Options" is
717+it got a due date; its checkbox moved into the file with its title line, and its file starts
718+with a bullet because that is what it is. "Options" is
656719a section nested under an item, and "Snapshot policy" is a section that is itself a task.
657720
658721---
@@ -725,7 +788,7 @@ each block is written by its own splice, and the law holds per block.
725788Edge cases follow from the tags, not from rules:
726789
727790- Renaming a nested block is editing its title line; it is that block's line, so its file
728− is rewritten and renamed (§6.4). No second step.
791+ is rewritten (and keeps its name, §6.4). No second step.
729792- Deleting a nested block's title line deletes the block: the file goes to trash, and any
730793 lines it still owned are re-tagged to the enclosing block — they become plain text of the
731794 parent, which is what deleting a heading does to its content in any editor. This is the
@@ -762,7 +825,7 @@ demotes anything. The level-overflow rule of earlier drafts is gone.
7628253. Replace the node's span in its parent file with an embed at the node's position: a
763826 heading embed at its level for a section, a bare `![[id]]` at its indent for an item
764827 (§4.7). The text children around the node stay in the parent. A checkbox on the node
765− becomes `todo: <state>` in the frontmatter (§4.5).
828+ stays on its title line, now in the block's file (§4.5).
7668294. Update the index.
767830
768831There is no inverse. A block stays a file until it is deleted (`d`, *clear done*, or
@@ -777,7 +840,8 @@ or on `s` / *make block*. There is no other way.
777840
778841- Every block is embedded from **exactly one** embed. A second embed for the same id is
779842 a diagnostic and renders as broken. Cycles are a diagnostic.
780−- A block file has exactly one node at column 0 (§4.9) and its frontmatter has a valid
843+- A block file has one root, its first node at column 0; later column-0 lines are adopted
844+ under it (§4.9). Its frontmatter has a valid
781845 `id`.
782846- Blocks may be nested: a block's file may itself contain embeds.
783847- Embed lines have no children in the parent file.
@@ -834,11 +898,14 @@ the same way (§12.2). The filename carries no identity: a file is parsed becaus
834898check` asks for the prefix) while `racfer~order-new-switch.md` without one is ignored.
835899Safe on APFS, ext4, Android storage and Windows; case-insensitive-safe because lowercase.
836900
837−Renaming a block's title renames the file to the new name (temp-and-rename). Nothing
838−else in the vault references the filename, so nothing else is rewritten. A file renamed or
839−titled by another tool is still found by its id; `notes check` reports filenames whose
840−prefix is not a leading run of the id or whose name no longer matches the title, and
841−`notes check --fix` fixes them.
901+**Names are set once.** The name is the slug of the title when the block is made, and a
902+later title change does not rename the file. Under Syncthing a rename arrives on other
903+devices as a delete plus a create; a phone that edits the old name meanwhile turns that
904+into a sync conflict or an orphan, for nothing — the prefix alone identifies the file and
905+nothing references the filename. A file renamed or titled by another tool is still found by
906+its id. `notes check` reports filenames whose prefix is not a leading run of the id (a
907+problem) and names that no longer match the title (stale, harmless); `notes check --fix`
908+renames both, which is the one moment the app renames a file.
842909
843910### 6.5 Refile
844911
@@ -891,7 +958,7 @@ delete it. Nothing in the archive is hidden or dimmed.
891958| Node | open | done |
892959|---|---|---|
893960| not a block | `- [ ] Title` / `## [ ] Title` | `[x]` |
894−| block | `todo: open` | `todo: done` + `done: <date>` |
961+| block | `- [ ] Title` / `## [ ] Title` | `[x]` + `done: <date>` in frontmatter |
895962
896963Any node can be a task. A checkbox task has exactly this much state and nothing else. Its
897964completion date is not recorded; if you need one, make the task a block so toggling
@@ -900,9 +967,9 @@ writes `done:` (§8.2).
900967### 8.2 Task blocks
901968
902969A task that needs a date, a property, a completion record, or a body with structure becomes
903−a block (§6.1). Its checkbox becomes `todo:` in the frontmatter; the embed in the parent
904−is `![[id]]` and the title line in the file is plain. Toggling from anywhere
905−(outline or embed) rewrites `todo:` and stamps or removes `done:`.
970+a block (§6.1). Its checkbox moves into the block's file with its title line; the embed in
971+the parent is `![[id]]`. Toggling from anywhere (outline or embed) rewrites that checkbox and
972+stamps or removes `done:`.
906973The reading pane renders a resolved task embed on one line:
907974
908975```
@@ -1009,8 +1076,8 @@ aside (§17, open decision 16); the ```` ```query ```` fence is reserved for it
10091076| `n` / `N` | new sibling after cursor / new last child, spelled like the cursor node / like the last child node (a section if the parent has section children): inserts an empty node and opens it with `e` |
10101077| `e` | edit the subtree's Markdown in the built-in editor; saves as you go (§10.6) |
10111078| `a` | property editor: a form over the node's properties (§10.6); first property on a plain node makes it a block |
1012−| `x` | toggle task open / done (checkbox, or `todo:` plus `done:` on a block) |
1013−| `t` | toggle task-ness: adds or removes the checkbox, or the `todo` key on a block |
1079+| `x` | toggle task open / done: the checkbox, plus `done:` on a block |
1080+| `t` | toggle task-ness: adds or removes the checkbox (and a block's `done:`) |
10141081| `s` | make the node a block |
10151082| `y` / `d` | yank / delete subtree into the register (delete goes to trash too) |
10161083| `p` / `P` | paste register after / before cursor as sibling, clamped by the ordering rule (§3.1) |
@@ -1114,8 +1181,15 @@ Every mutation is an operation with an inverse (span edits, file create/move/del
11141181including making blocks). The session op log powers `u` / `U`. Outline verbs write
11151182immediately and atomically; the built-in editor writes each dirty block within 750 ms of the
11161183last keystroke, or sooner when the cursor leaves it (§10.6), so the only unsaved text at any
1117−moment is under a second of typing in one block. Undo after an external change re-checks
1118−span hashes and refuses with a message if the target moved.
1184+moment is under a second of typing in one block.
1185+
1186+An op-log entry records **exactly the files the operation touched**: for each, its text
1187+before and after, where *absent* stands for a file the operation created or deleted (making
1188+a block, trashing one). An operation that changed nothing leaves no entry, so it does not
1189+clear the redo stack. Undo checks every touched file against its *after* text, and redo
1190+against its *before* text; if any differs — another editor or a sync changed it since — it
1191+refuses with a message naming the file, writes nothing, and keeps the entry. Files the
1192+operation did not touch are never written by undo, so an external edit to them survives.
11191193
11201194---
11211195
@@ -1138,7 +1212,7 @@ span hashes and refuses with a message if the target moved.
11381212§11.4.
11391213
11401214- A changed file that the user is not editing is re-parsed; the cursor is re-attached by
1141− id, then path, then nearest surviving ancestor.
1215+ id, then key (§3.4), then the deepest step of the key that still exists.
11421216- A changed file with a built-in edit in progress: the editor saves its dirty blocks first;
11431217 a block whose span hash no longer matches is merged two-way (§5.2 step 5), then the file
11441218 is re-parsed and the buffer re-rendered around the cursor.
@@ -1216,7 +1290,7 @@ never deletes, so this is the safe failure; delete the copy you don't want.
12161290
12171291**Per matched node**, for each of title, checkbox state, text (all its text children, with
12181292their positions among its child nodes, as one field), and — for the file's block — each
1219−frontmatter key independently (`todo` included): `O == T → take O`, otherwise **conflict**. A conflict pair is raised for every field that differs, even when
1293+frontmatter key independently: `O == T → take O`, otherwise **conflict**. A conflict pair is raised for every field that differs, even when
12201294only one side touched it; the cost of having no per-device state is paid here, in
12211295resolution clicks, never in lost text.
12221296
@@ -1324,7 +1398,7 @@ pub enum TaskState { Open, Done }
13241398pub struct Node {
13251399 kind: Kind,
13261400 title: String,
1327− task: Option<TaskState>, // any node; from the checkbox, or `todo` for a block
1401+ task: Option<TaskState>, // any node, block or not: the checkbox on its title line
13281402 content: Vec<Content>, // ordered children: text runs and nodes (§3.1)
13291403 parent: Option<NodeId>,
13301404 file: FileId,
@@ -1334,7 +1408,7 @@ pub struct Node {
13341408
13351409pub struct Block {
13361410 id: Option<Id>, // None only for root.md
1337− name: String, // slug of the title at last rename
1411+ name: String, // slug of the title when made (or at the last `check --fix`)
13381412 path: RelPath, // e.g. "racfer~order-new-switch.md"
13391413 props: IndexMap<String, String>, // top-level `key: value` lines, order-preserving; includes `id`
13401414 frontmatter_raw: String, // verbatim, for lossless rewrite
@@ -1347,6 +1421,11 @@ pub struct Span { start: usize, end: usize }
13471421
13481422pub struct Tree { nodes: Vec<Node>, root: NodeId, files: Vec<FileState> }
13491423pub struct Cursor { path: Vec<NodeId> } // zipper-style focus; all outline verbs are cursor ops
1424+pub enum NodeKey { // survives re-parses (§3.4)
1425+ Root,
1426+ Id(Id),
1427+ Path { block: Option<Id>, steps: Vec<(String, usize)> }, // title + ordinal among same-titled siblings
1428+}
13501429```
13511430
13521431Levels and indents are computed from ancestry, never stored.
@@ -1409,7 +1488,9 @@ of real-world Markdown (Obsidian, Logseq, FSNotes exports) that must parse witho
14091488
14101489`notes check` reports, with `ariadne`-rendered source spans: non-canonical syntax, empty
14111490titles, titles containing `/`, broken, duplicate or cyclic embeds, embeds with children in the
1412−parent file, embeds whose form (bare or heading) does not match their block's spelling, malformed block files, invalid or duplicate `id` keys, ignored `.md` files,
1491+parent file, embeds whose form (bare or heading) does not match their block's spelling,
1492+heading embeds whose level is not the level of their position, text directly after a child
1493+node with no blank line, column-0 lines adopted by a block's root, malformed block files, invalid or duplicate `id` keys, ignored `.md` files,
14131494filenames whose prefix or name no longer match their id or title, frontmatter outside
14141495byte 0, `due` or `done` values that are not ISO dates, and unresolved conflict blocks.
14151496The TUI
@@ -1424,8 +1505,8 @@ gets wrong. What other tools see:
14241505
14251506| Tool | What it sees | Notes |
14261507|---|---|---|
1427−| `rg`, `fd`, Helix | plain text | `rg '\[ \]'` and `rg '^todo: open'` together are the open task list; `rg '^done: 2026-09'` the blocks finished this month; `rg '^due:'` the dated ones |
1428−| Any phone editor | plain text | capture by appending a `- ` line to the inbox block's file; the app adds nothing to it |
1508+| `rg`, `fd`, Helix | plain text | `rg '\[ \]'` is the open task list; `rg '^done: 2026-09'` the blocks finished this month; `rg '^due:'` the dated ones |
1509+| Any phone editor | plain text | capture by appending a `- ` line to the inbox block's file, at any indent: a column-0 line is adopted by the block's root (§4.9); the app adds nothing to it |
14291510| Obsidian, Logseq, FSNotes | mostly readable, structurally wrong | frontmatter as properties and `- [ ]` bullets render; `![[id]]` shows as a broken embed (a heading embed as a heading holding one), `## [ ]` as a literal heading, `#######` as a paragraph, indented headings as code. Fine for reading a file; do not edit structure there |
14301511
14311512---
@@ -1451,7 +1532,7 @@ task state; wiki-links between nodes and backlinks
14511532| M0 | `notes-core`: parser incl. frontmatter, tree, render/splice, canonicalize, ids and names | round-trip laws pass under proptest; `notes check` works |
14521533| M1 | TUI: outline + reading panes, zoom, fold, move, promote/demote, refile, filter box, subtree editing (`e`), undo | daily-drivable on a single `root.md` |
14531534| M2 | Blocks and properties: `s`, property-triggered blocks, task blocks | a task can be given a due date in the editor and its file round-trips |
1454−| M3 | Tasks: toggling on any node, `todo:` and `done:` stamping, dates, clear-done, filter box | replaces a TaskPaper file |
1535+| M3 | Tasks: toggling on any node, `done:` stamping, dates, clear-done, filter box | replaces a TaskPaper file |
14551536| M4 | Inbox and capture; watcher and reload | safe to edit in Helix and the TUI at once |
14561537| M5 | Sync: conflict detection, two-way node-level merge, `conflict:` blocks, conflict view, `notes merge` | phone + laptop + homelab over Syncthing for two weeks without data loss |
14571538| M6 | CLI polish, diagnostics, docs | 1.0 |
@@ -1464,10 +1545,9 @@ task state; wiki-links between nodes and backlinks
146415452. **Dated inbox, ISO titles, fixed.** Capture creates `## YYYY-MM-DD` under `Inbox`. There
14651546 is no format choice and no way to turn it off except `--to`. Days append oldest-first; a
14661547 long-lived inbox is a long section, which is what `s` (make block) and *clear done* are for.
1467−3. **Filenames follow titles, always.** A title change is a file rename (Syncthing handles
1468− renames by block reuse, so cheap but visible) and nothing else, since the id lives in the
1469− file. The id prefix stays in front through renames, so it is the one part of a filename
1470− that never moves.
1548+3. **Filenames are named once.** A title change does not rename the file: renames sync as
1549+ delete-plus-create and race with edits on other devices. The id prefix identifies the
1550+ file; the name is a hint that `notes check --fix` refreshes on request.
147115513b. **Id format.** Four-word `@p` names (64 bits). Two words (32 bits) were enough for
14721552 identity but left the one-word filename prefix with only 16 bits of room to grow; four
14731553 words make the id collision-free in practice and give the prefix three words of slack.
@@ -1475,10 +1555,10 @@ task state; wiki-links between nodes and backlinks
14751555 syllable tables; nobody is expected to type all four.
147615564. **Task sections.** Any node can be a task, so a project heading can be checked off. The
14771557 derived count is still shown beside it; there is no roll-up in either direction (§8.3).
1478−4b. **Two spellings of task state.** A checkbox for nodes that have no file, `todo:` for
1479− nodes that do. One mechanism was considered twice: all-frontmatter would make every task
1480− a file, all-checkbox puts structure on a title line the frontmatter already describes.
1481− The split costs one conversion when a block is made and nothing else.
1558+4b. **One spelling of task state.** The checkbox, for every node. Until 0.39 blocks used
1559+ `todo:` in frontmatter instead; that hid block tasks from `rg '\[ \]'` and cost a
1560+ conversion every time a task became a block. All-frontmatter was never an option (it
1561+ would make every task a file). A block keeps one thing a plain task lacks: `done:`.
148215625. **Two task states.** Open and done. Cancelled was removed: it was a third value in every
14831563 table, a second date key, and a key binding, for a distinction between "done" and
14841564 "won't do" that a deleted line or a done line expresses well enough. `[-]` is read as
crates/fold-core/src/check.rs+80−3
@@ -44,8 +44,8 @@ pub fn check(vault: &Vault) -> Vec<Diagnostic> {
4444 }
4545 }
4646 // embeds: broken, duplicate, cyclic, with children (§6.2, §15.7)
47− for f in &t.files {
48− for n in &f.nodes {
47+ for (fi, f) in t.files.iter().enumerate() {
48+ for (ni, n) in f.nodes.iter().enumerate() {
4949 if let Some(id) = &n.embed {
5050 match t.block_by_id(id) {
5151 None => out.push(Diagnostic {
@@ -61,6 +61,17 @@ pub fn check(vault: &Vault) -> Vec<Diagnostic> {
6161 }),
6262 Some(_) => {}
6363 }
64+ if let Some(w) = embed_level_mismatch(t, (fi, ni)) {
65+ out.push(Diagnostic {
66+ file: f.path.clone(),
67+ message: format!(
68+ "heading embed at level {} where its position gives {}: {} (§4.7)",
69+ w,
70+ t.derived_level((fi, ni)),
71+ id
72+ ),
73+ });
74+ }
6475 if !n.children.is_empty() {
6576 out.push(Diagnostic {
6677 file: f.path.clone(),
@@ -118,7 +129,10 @@ pub fn check(vault: &Vault) -> Vec<Diagnostic> {
118129 if name != want {
119130 out.push(Diagnostic {
120131 file: b.path.clone(),
121− message: format!("filename name {} does not match the title ({})", name, want),
132+ message: format!(
133+ "filename name {} does not match the title ({}): stale, harmless; --fix renames",
134+ name, want
135+ ),
122136 });
123137 }
124138 }
@@ -183,6 +197,16 @@ pub fn check(vault: &Vault) -> Vec<Diagnostic> {
183197 out
184198}
185199
200+/// A heading embed's written level, when it is not the level its position
201+/// gives (§4.7). Levels are honoured as written, like any heading's, so a
202+/// stale one is reported rather than trusted to mean something.
203+fn embed_level_mismatch(t: &crate::tree::Tree, e: NRef) -> Option<usize> {
204+ let n = t.node(e);
205+ let written = n.level?;
206+ (n.embed.is_some() && n.kind == Kind::Section && written != t.derived_level(e))
207+ .then_some(written)
208+}
209+
186210/// A heading embed must embed a section, a bare one an item (§4.7).
187211fn form_mismatch(embed: Kind, block: Kind) -> bool {
188212 (embed == Kind::Section) != (block == Kind::Section)
@@ -205,6 +229,25 @@ pub fn is_iso_date(v: &str) -> bool {
205229/// malformed (§4.9: read-only until fixed by hand) is left alone.
206230pub fn fix(vault: &mut Vault) -> std::io::Result<usize> {
207231 let mut count = 0;
232+ // a blank line before every text child that follows a child node (§4.2)
233+ for i in 0..vault.tree.files.len() {
234+ let f = &vault.tree.files[i];
235+ let mut at: Vec<usize> = f
236+ .nodes
237+ .iter()
238+ .flat_map(|n| crate::parse::unseparated_text(n, &f.text))
239+ .collect();
240+ if at.is_empty() {
241+ continue;
242+ }
243+ at.sort_unstable();
244+ let mut text = f.text.clone();
245+ for &p in at.iter().rev() {
246+ text.insert(p, '\n');
247+ }
248+ vault.write_file_text(i, &text)?;
249+ count += 1;
250+ }
208251 for i in 0..vault.tree.files.len() {
209252 let f = &vault.tree.files[i];
210253 let top: Vec<usize> = f.nodes[f.root_node].children.clone();
@@ -267,6 +310,19 @@ pub fn fix(vault: &mut Vault) -> std::io::Result<usize> {
267310 count += 1;
268311 }
269312 }
313+ // legacy `todo:` keys: the canonical render above wrote the state as the
314+ // title line's checkbox, so the key goes (§4.5)
315+ for i in 0..vault.tree.files.len() {
316+ let f = &vault.tree.files[i];
317+ let migrated = f.nodes.iter().any(|n| {
318+ n.block.as_ref().and_then(|b| b.prop("todo")).is_some()
319+ && crate::parse::title_has_checkbox(n.title_span.text(&f.text))
320+ });
321+ if migrated {
322+ crate::ops::set_frontmatter_key(vault, i, "todo", None)?;
323+ count += 1;
324+ }
325+ }
270326 // embeds in the form of their block's spelling (§4.7), one at a time:
271327 // each respell may move an embed and re-parse its file
272328 for _ in 0..vault.tree.blocks.len() {
@@ -280,6 +336,27 @@ pub fn fix(vault: &mut Vault) -> std::io::Result<usize> {
280336 crate::ops::respell_embed(vault, e, to_section)?;
281337 count += 1;
282338 }
339+ // heading embeds at the level of their position (§4.7); only ever
340+ // shallower than written, so no line changes parent
341+ for _ in 0..10_000 {
342+ let t = &vault.tree;
343+ let wrong = t.files.iter().enumerate().find_map(|(fi, f)| {
344+ (0..f.nodes.len())
345+ .find(|&ni| embed_level_mismatch(t, (fi, ni)).is_some())
346+ .map(|ni| (fi, ni))
347+ });
348+ let Some(e) = wrong else { break };
349+ let n = vault.tree.node(e);
350+ let line = format!(
351+ "{}{} ![[{}]]",
352+ " ".repeat(n.indent),
353+ "#".repeat(vault.tree.derived_level(e)),
354+ n.embed.as_ref().unwrap()
355+ );
356+ let span = n.title_span;
357+ vault.write_span(e.0, span, &line)?;
358+ count += 1;
359+ }
283360 // repair filenames (§6.4): an existing prefix that is a leading run of
284361 // the id is kept — prefixes are never lengthened — unless a file decided
285362 // earlier already holds it (a collision: the later file moves)
crates/fold-core/src/merge.rs+13−102
@@ -402,20 +402,11 @@ fn merge_pair(
402402 block_text.push_str(&format!("{}: {}\n", k, v));
403403 }
404404 }
405− None => {
406− if let Some(ts) = ttask {
407− block_text.push_str(&format!(
408− "todo: {}\n",
409− match ts {
410− TaskState::Open => "open",
411− TaskState::Done => "done",
412− }
413− ));
414− }
415− }
405+ // T's task state travels as the checkbox on its title line (§4.5)
406+ None => {}
416407 }
417408 block_text.push_str("---\n\n");
418− block_text.push_str(&emit_subtree_with_state(t, b, 1, 0));
409+ block_text.push_str(&emit_subtree(t, b, 1, 0));
419410 ctx.conflict_blocks.push((fname, block_text));
420411 if is_file_block {
421412 // a block file has one column-0 node: the embed goes after this
@@ -439,41 +430,22 @@ fn merge_pair(
439430
440431/// Render a node at (level, indent) with given already-rendered children.
441432fn emit_node_with(o: &Tree, a: NRef, level: usize, indent: usize, kids: &str) -> String {
442− emit_node_with_impl(o, a, level, indent, kids, false)
443−}
444−
445−fn emit_node_with_impl(
446− o: &Tree,
447− a: NRef,
448− level: usize,
449− indent: usize,
450− kids: &str,
451− show_block_state: bool,
452−) -> String {
453433 let n = o.node(a);
454434 let mut out = String::new();
455435 let ind = " ".repeat(indent);
456− // A block root's task state normally lives only in frontmatter (§4.9);
457− // when emitting for resolution we surface it as a checkbox so the
458− // receiving side parses it back as state.
459− let is_block_root = n.is_block() && !show_block_state;
460436 match n.kind {
461437 Kind::Section => {
462438 out.push_str(&ind);
463439 out.push_str(&"#".repeat(level.max(1)));
464440 out.push(' ');
465− if !is_block_root {
466− push_task(n.task, &mut out);
467− }
441+ push_task(n.task, &mut out);
468442 out.push_str(&n.title);
469443 out.push('\n');
470444 }
471445 Kind::Item => {
472446 out.push_str(&ind);
473447 out.push_str("- ");
474− if !is_block_root {
475− push_task(n.task, &mut out);
476− }
448+ push_task(n.task, &mut out);
477449 out.push_str(&n.title);
478450 out.push('\n');
479451 }
@@ -506,16 +478,6 @@ fn emit_node_with_impl(
506478}
507479
508480fn emit_subtree(t: &Tree, r: NRef, level: usize, indent: usize) -> String {
509− emit_subtree_impl(t, r, level, indent, false)
510−}
511−
512−/// Emit with the block root's `todo:` shown as a checkbox — used for
513−/// conflict blocks so resolution can lift their state back (§12.5).
514−fn emit_subtree_with_state(t: &Tree, r: NRef, level: usize, indent: usize) -> String {
515− emit_subtree_impl(t, r, level, indent, true)
516−}
517−
518−fn emit_subtree_impl(t: &Tree, r: NRef, level: usize, indent: usize, show_block_state: bool) -> String {
519481 let kids = t.resolved_children(r);
520482 let child_level = match t.node(r).kind {
521483 Kind::Section => level + 1,
@@ -532,13 +494,13 @@ fn emit_subtree_impl(t: &Tree, r: NRef, level: usize, indent: usize, show_block_
532494 for (j, &k) in kids.iter().enumerate() {
533495 pieces.push(Piece::Node(
534496 t.node(k).kind,
535− emit_subtree_impl(t, k, child_level, child_indent, show_block_state),
497+ emit_subtree(t, k, child_level, child_indent),
536498 ));
537499 for (_, sp) in trailing.iter().filter(|(pos, _)| *pos == j) {
538500 pieces.push(Piece::Text(emit_text(t, r, *sp, child_indent)));
539501 }
540502 }
541− emit_node_with_impl(t, r, level, indent, &join_pieces(pieces), show_block_state)
503+ emit_node_with(t, r, level, indent, &join_pieces(pieces))
542504}
543505
544506fn push_task(task: Option<TaskState>, out: &mut String) {
@@ -780,48 +742,9 @@ pub fn resolve_keep_theirs(vault: &mut Vault, ours: NRef, theirs: NRef) -> std::
780742 let tb = vault.tree.node(theirs).block.clone();
781743 let Some(tb) = tb else { return Ok(()) };
782744 // 1. replace ours' subtree text with theirs' (re-levelled to ours' spot)
783− let mut theirs_body = crate::render::render(&vault.tree, theirs, 1, true);
784− // The conflict block's state lives in its frontmatter (§4.9); surface it
785− // as a checkbox so the receiving side parses it back as task state.
786− // A block's state is `todo:`, never a checkbox in its file (§4.9).
787− let ours_is_block = vault.tree.node(ours).is_block();
788− let ours_block_id = vault.tree.node(ours).block.as_ref().and_then(|b| b.id.clone());
789− let state = if ours_is_block { None } else { vault.tree.node(theirs).task };
790− if let Some(state) = state {
791− let marker = match state {
792− TaskState::Open => "[ ] ",
793− TaskState::Done => "[x] ",
794− };
795− let mut lines = theirs_body.lines();
796− if let Some(first) = lines.next() {
797− // the title line is `#… ` or `- ` after its indent; the checkbox
798− // goes right after that marker, never inside the title
799− let lead = first.len() - first.trim_start().len();
800− let rest = &first[lead..];
801− let marker_end = if rest.starts_with('#') {
802− let hashes = rest.len() - rest.trim_start_matches('#').len();
803− rest[hashes..].starts_with(' ').then_some(lead + hashes + 1)
804− } else if rest.starts_with("- ") {
805− Some(lead + 2)
806− } else {
807− None
808− };
809− let inserted = match marker_end {
810− Some(m) if !is_checkbox(&first[m..]) => {
811− let mut s = first.to_string();
812− s.insert_str(m, marker);
813− s
814− }
815− _ => first.to_string(),
816− };
817− let rest: Vec<&str> = lines.collect();
818− theirs_body = if rest.is_empty() {
819− format!("{}\n", inserted)
820− } else {
821− format!("{}\n{}\n", inserted, rest.join("\n"))
822− };
823− }
824− }
745+ let theirs_body = crate::render::render(&vault.tree, theirs, 1, true);
746+ // theirs' task state is the checkbox on its title line, already in the
747+ // rendered text (§4.5)
825748 let on = vault.tree.node(ours);
826749 if on.is_block() {
827750 // ours is a block: rewrite its file (after frontmatter) with theirs'
@@ -864,20 +787,13 @@ pub fn resolve_keep_theirs(vault: &mut Vault, ours: NRef, theirs: NRef) -> std::
864787 for (k, v) in tprops {
865788 crate::ops::set_frontmatter_key(vault, file, &k, Some(&v))?;
866789 }
867− // rename the file if the title changed (§6.4)
790+ // a title change does not rename the file (§6.4)
868791 vault.reload()?;
869− let file = ours_block_id
870− .as_ref()
871− .and_then(|id| vault.tree.block_by_id(id))
872− .map(|r| (r.0, vault.tree.node(r).title.clone()));
873− if let Some((file, title)) = file {
874− vault.rename_block_file(file, &title)?;
875− }
876792 } else {
877793 // plain node: replace its span with theirs' re-levelled text
878− let level = vault.tree.level(ours);
794+ let parent_level = on.parent.map(|p| vault.tree.level((ours.0, p))).unwrap_or(0);
879795 let indent = vault.tree.indent(ours);
880− let shifted = crate::ops::shift_document(&theirs_body, level, indent);
796+ let shifted = crate::ops::shift_document(&theirs_body, parent_level, indent);
881797 let file = ours.0;
882798 let span = on.span;
883799 vault.write_span(file, span, &shifted)?;
@@ -890,11 +806,6 @@ pub fn resolve_keep_theirs(vault: &mut Vault, ours: NRef, theirs: NRef) -> std::
890806 Ok(())
891807}
892808
893−fn is_checkbox(s: &str) -> bool {
894− let b = s.as_bytes();
895− b.len() >= 3 && b[0] == b'[' && b[2] == b']' && (b.len() == 3 || b[3] == b' ')
896−}
897−
898809/// keep both: drop the `conflict` key; the block stays as an ordinary
899810/// sibling (§12.5).
900811pub fn resolve_keep_both(vault: &mut Vault, theirs: NRef) -> std::io::Result<()> {
crates/fold-core/src/ops.rs+184−188
@@ -1,5 +1,6 @@
11//! Structural operations on the tree (§5.2, §6, §7, §8). Every op mutates
2−//! files through the vault and returns an inverse for the undo log (§10.11).
2+//! files through the vault; the undo log records what each one changed as
3+//! an `Inverse` (§10.11).
34
45use crate::ident::{slug, Id};
56use crate::parse::{Kind, Span, TaskState};
@@ -7,43 +8,105 @@ use crate::render::render;
78use crate::tree::NRef;
89use crate::vault::Vault;
910
10−/// An inverse snapshot for undo (§10.11): file path → prior text. Files that
11−/// did not exist are recorded as None.
11+/// Every vault file's text at one moment, taken before an operation.
12+#[derive(Debug, Clone)]
13+pub struct Snapshot {
14+ files: Vec<(String, String)>,
15+ description: String,
16+}
17+
18+impl Snapshot {
19+ pub fn take(vault: &Vault, description: &str) -> Snapshot {
20+ Snapshot {
21+ files: vault
22+ .tree
23+ .files
24+ .iter()
25+ .map(|f| (f.path.clone(), f.text.clone()))
26+ .collect(),
27+ description: description.into(),
28+ }
29+ }
30+}
31+
32+/// One file an operation touched: its text before and after (`None`:
33+/// absent — created or deleted by the operation).
34+#[derive(Debug, Clone)]
35+pub struct Change {
36+ pub path: String,
37+ pub before: Option<String>,
38+ pub after: Option<String>,
39+}
40+
41+/// One entry of the session op log (§10.11): exactly the files an operation
42+/// changed, created or deleted, before and after.
1243#[derive(Debug, Clone)]
1344pub struct Inverse {
14− pub files: Vec<(String, Option<String>)>,
45+ pub changes: Vec<Change>,
1546 pub description: String,
1647}
1748
1849impl Inverse {
19− pub fn apply(self, vault: &mut Vault) -> std::io::Result<()> {
20− for (path, text) in self.files {
21− match text {
22− Some(t) => {
23− let full = vault.dir.join(&path);
24− // untouched files keep their bytes and mtime (§11.1)
25− if std::fs::read_to_string(&full).ok().as_deref() != Some(t.as_str()) {
26− crate::vault::atomic_write(&full, &t)?;
27− }
28− }
29− None => {
30− let _ = std::fs::remove_file(vault.dir.join(&path));
31− }
50+ /// What changed since `snap` was taken; `None` if nothing did, so an
51+ /// operation that did nothing leaves no entry.
52+ pub fn since(snap: Snapshot, vault: &Vault) -> Option<Inverse> {
53+ let mut changes = Vec::new();
54+ for (path, before) in &snap.files {
55+ let after = vault.tree.files.iter().find(|f| f.path == *path).map(|f| &f.text);
56+ if after != Some(before) {
57+ changes.push(Change {
58+ path: path.clone(),
59+ before: Some(before.clone()),
60+ after: after.cloned(),
61+ });
3262 }
3363 }
34− vault.reload()
64+ for f in &vault.tree.files {
65+ if !snap.files.iter().any(|(p, _)| *p == f.path) {
66+ changes.push(Change {
67+ path: f.path.clone(),
68+ before: None,
69+ after: Some(f.text.clone()),
70+ });
71+ }
72+ }
73+ (!changes.is_empty()).then(|| Inverse {
74+ changes,
75+ description: snap.description,
76+ })
3577 }
36−}
3778
38−fn snapshot(vault: &Vault, description: &str) -> Inverse {
39− Inverse {
40− files: vault
41− .tree
42− .files
43− .iter()
44− .map(|f| (f.path.clone(), Some(f.text.clone())))
45− .collect(),
46− description: description.into(),
79+ /// Undo: put every touched file back as it was before. Refuses, writing
80+ /// nothing, if any of them is no longer as the operation left it — an
81+ /// external change since then (§10.11).
82+ pub fn undo(&self, vault: &mut Vault) -> std::io::Result<()> {
83+ self.swap(vault, false)
84+ }
85+
86+ /// Redo: the reverse, with the same check against the `before` side.
87+ pub fn redo(&self, vault: &mut Vault) -> std::io::Result<()> {
88+ self.swap(vault, true)
89+ }
90+
91+ fn swap(&self, vault: &mut Vault, forward: bool) -> std::io::Result<()> {
92+ for c in &self.changes {
93+ let expect = if forward { &c.before } else { &c.after };
94+ let now = std::fs::read_to_string(vault.dir.join(&c.path)).ok();
95+ if now != *expect {
96+ return Err(io_err(&format!(
97+ "{} changed since {}; not overwriting",
98+ c.path, self.description
99+ )));
100+ }
101+ }
102+ for c in &self.changes {
103+ let full = vault.dir.join(&c.path);
104+ match if forward { &c.after } else { &c.before } {
105+ Some(t) => crate::vault::atomic_write(&full, t)?,
106+ None => std::fs::remove_file(&full)?,
107+ }
108+ }
109+ vault.reload()
47110 }
48111}
49112
@@ -55,10 +118,7 @@ fn today() -> String {
55118
56119/// Append an item under today's day section of `Inbox` (§7).
57120pub fn capture(vault: &mut Vault, text: &str, task: bool) -> std::io::Result<NRef> {
58− let inv = snapshot(vault, "capture");
59− let r = capture_inner(vault, text, task, None)?;
60− let _ = inv;
61− Ok(r)
121+ capture_inner(vault, text, task, None)
62122}
63123
64124pub fn capture_to(
@@ -137,10 +197,11 @@ fn find_or_create_day(vault: &mut Vault, inbox: NRef) -> std::io::Result<NRef> {
137197 "#".repeat(level),
138198 day
139199 );
200+ let inbox_key = vault.key_of(inbox);
140201 append_structural_line(vault, inbox, &heading)?;
141202 vault.reload()?;
142203 let inbox = vault
143− .find_by_path(&vault_inbox_path(vault))
204+ .find_by_key(&inbox_key)
144205 .ok_or_else(|| io_err("inbox lost after reload"))?;
145206 for c in vault.tree.resolved_children(inbox) {
146207 if vault.tree.node(c).title == day {
@@ -150,15 +211,6 @@ fn find_or_create_day(vault: &mut Vault, inbox: NRef) -> std::io::Result<NRef> {
150211 Err(io_err("day section not created"))
151212}
152213
153−fn vault_inbox_path(vault: &Vault) -> Vec<String> {
154− let root = vault.tree.root;
155− for c in vault.tree.resolved_children(root) {
156− if vault.tree.node(c).title.eq_ignore_ascii_case("inbox") {
157− return vault.tree.path(c);
158− }
159− }
160− vec!["Inbox".into()]
161−}
162214
163215fn append_top_section(vault: &mut Vault, title: &str) -> std::io::Result<NRef> {
164216 let root_file = 0;
@@ -231,7 +283,6 @@ fn append_child_line(
231283 line: &str,
232284 section_ok: bool,
233285) -> std::io::Result<NRef> {
234− let parent_path = vault.tree.path(parent);
235286 let parent_key = vault.key_of(parent);
236287 let node = vault.tree.node(parent);
237288 let file = parent.0;
@@ -297,7 +348,6 @@ fn append_child_line(
297348 vault.reload()?;
298349 let parent = vault
299350 .find_by_key(&parent_key)
300− .or_else(|| vault.find_by_path(&parent_path))
301351 .ok_or_else(|| io_err("parent lost after reload"))?;
302352 vault
303353 .tree
@@ -309,72 +359,69 @@ fn append_child_line(
309359
310360// ---------------------------------------------------------------- tasks
311361
312−/// Toggle task open/done (§8.1, §8.2).
362+/// Toggle task open/done (§8.1, §8.2). The state is the checkbox on the
363+/// title line for every node; a block also gets `done:` stamped or removed.
313364pub fn toggle_task(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
314− let n = vault.tree.node(r);
315− if n.is_block() {
316− let new_state = match n.task {
317− Some(TaskState::Open) | None => TaskState::Done,
318− Some(TaskState::Done) => TaskState::Open,
319− };
320− set_block_todo(vault, r, Some(new_state))
321− } else if n.is_embed() {
322− let target = vault.tree.resolved_child(r);
323− if target != r {
324− return toggle_task(vault, target);
325− }
326− Ok(())
327− } else {
328− // checkbox on the title line
329− let file = r.0;
330− let ts = n.title_span;
331− let line = ts.text(&vault.tree.files[file].text).to_string();
332− let to = match n.task {
333− Some(TaskState::Open) => "[x]",
334− Some(TaskState::Done) => "[ ]", // also rewrites `[X]` / `[-]`
335− None => return Ok(()), // not a task; `t` makes it one
336− };
337− let Some((start, _)) = checkbox_span(&line) else {
338− return Ok(());
339− };
340− let mut new_line = line.clone();
341− new_line.replace_range(start..start + 3, to);
342− vault.write_span(file, ts, &new_line)
343− }
365+ let r = vault.tree.resolved_child(r);
366+ let new_state = match vault.tree.node(r).task {
367+ Some(TaskState::Open) => TaskState::Done,
368+ Some(TaskState::Done) => TaskState::Open,
369+ None => return Ok(()), // not a task; `t` makes it one
370+ };
371+ set_task(vault, r, Some(new_state))
344372}
345373
346374/// Toggle task-ness itself (§10.3 `t`).
347375pub fn toggle_taskness(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
376+ let r = vault.tree.resolved_child(r);
377+ let state = match vault.tree.node(r).task {
378+ Some(_) => None,
379+ None => Some(TaskState::Open),
380+ };
381+ set_task(vault, r, state)
382+}
383+
384+/// Write a node's task state (§4.5, §8.1): the checkbox on its title line
385+/// (`None` removes it). For a block, `done:` is stamped when it is checked
386+/// and removed otherwise, and a legacy `todo:` key is dropped.
387+pub fn set_task(vault: &mut Vault, r: NRef, state: Option<TaskState>) -> std::io::Result<()> {
348388 let n = vault.tree.node(r);
349− if n.is_block() {
350− if n.task.is_some() {
351− set_block_todo(vault, r, None)
352− } else {
353− set_block_todo(vault, r, Some(TaskState::Open))
354− }
355− } else if n.is_embed() {
356− let target = vault.tree.resolved_child(r);
357− if target != r {
358− return toggle_taskness(vault, target);
389+ let is_block = n.is_block();
390+ let file = r.0;
391+ let ts = n.title_span;
392+ let line = ts.text(&vault.tree.files[file].text).to_string();
393+ let mark = |st: TaskState| match st {
394+ TaskState::Open => "[ ]",
395+ TaskState::Done => "[x]", // also rewrites `[X]` / `[-]`
396+ };
397+ let new_line = match (checkbox_span(&line), state) {
398+ (Some((start, _)), Some(st)) => {
399+ let mut l = line.clone();
400+ l.replace_range(start..start + 3, mark(st));
401+ l
359402 }
360− Ok(())
361− } else {
362− let file = r.0;
363− let ts = n.title_span;
364− let line = ts.text(&vault.tree.files[file].text).to_string();
365− let new_line = match n.task {
366− Some(_) => match checkbox_span(&line) {
367− Some((start, end)) => format!("{}{}", &line[..start], &line[end..]),
368− None => line.clone(),
369− },
370− // insert `[ ] ` right after the marker, whatever the indent
371− None => match marker_end(&line) {
372− Some(idx) => format!("{}[ ] {}", &line[..idx], &line[idx..]),
373− None => line.clone(),
374− },
403+ (Some((start, end)), None) => format!("{}{}", &line[..start], &line[end..]),
404+ // insert right after the marker, whatever the indent
405+ (None, Some(st)) => match marker_end(&line) {
406+ Some(idx) => format!("{}{} {}", &line[..idx], mark(st), &line[idx..]),
407+ None => format!("{} {}", line.trim_end(), mark(st)),
408+ },
409+ (None, None) => line.clone(),
410+ };
411+ if new_line != line {
412+ vault.write_span(file, ts, &new_line)?;
413+ }
414+ if is_block {
415+ let done = match state {
416+ Some(TaskState::Done) => Some(today()),
417+ _ => None,
375418 };
376− vault.write_span(file, ts, &new_line)
419+ set_frontmatter_key(vault, file, "done", done.as_deref())?;
420+ if vault.tree.files[file].nodes.iter().any(|n| n.block.as_ref().and_then(|b| b.prop("todo")).is_some()) {
421+ set_frontmatter_key(vault, file, "todo", None)?;
422+ }
377423 }
424+ Ok(())
378425}
379426
380427/// Byte offset just past a title line's marker: `#…` plus its spaces, or
@@ -414,29 +461,6 @@ fn checkbox_span(line: &str) -> Option<(usize, usize)> {
414461 }
415462}
416463
417−/// Write `todo:`/`done:` for a task block (§8.2).
418−pub fn set_block_todo(
419− vault: &mut Vault,
420− r: NRef,
421− state: Option<TaskState>,
422−) -> std::io::Result<()> {
423− let file = r.0;
424− match state {
425− Some(TaskState::Done) => {
426− set_frontmatter_key(vault, file, "todo", Some("done"))?;
427− set_frontmatter_key(vault, file, "done", Some(&today()))
428− }
429− Some(TaskState::Open) => {
430− set_frontmatter_key(vault, file, "todo", Some("open"))?;
431− set_frontmatter_key(vault, file, "done", None)
432− }
433− None => {
434− set_frontmatter_key(vault, file, "todo", None)?;
435− set_frontmatter_key(vault, file, "done", None)
436− }
437− }
438−}
439−
440464// ------------------------------------------------------- frontmatter edits
441465
442466/// Set or remove a top-level frontmatter key, preserving every other line
@@ -656,7 +680,7 @@ pub fn yank(vault: &Vault, r: NRef) -> String {
656680
657681/// Paste rendered text after/before a node as siblings (§10.3 `p`/`P`),
658682/// clamped by the ordering rule (§3.1).
659−pub fn paste(vault: &mut Vault, at: NRef, text: &str, after: bool) -> std::io::Result<()> {
683+pub fn paste(vault: &mut Vault, at: NRef, text: &str, after: bool) -> std::io::Result<bool> {
660684 if vault.tree.node(at).kind == Kind::Root {
661685 return Err(io_err("cannot paste beside the root"));
662686 }
@@ -667,7 +691,7 @@ pub fn paste(vault: &mut Vault, at: NRef, text: &str, after: bool) -> std::io::R
667691 };
668692 let pos = vault.tree.raw_children(parent).iter().position(|&k| k == at).unwrap_or(0);
669693 let shifted = shift_document(text, vault.tree.level(parent), child_indent(&vault.tree, parent));
670− place(vault, None, parent, pos + after as usize, &shifted).map(|_| ())
694+ place(vault, None, parent, pos + after as usize, &shifted)
671695}
672696
673697/// Parse a standalone document and re-emit it as children of a node whose
@@ -782,7 +806,7 @@ fn fence_transition(raw: &str, open: &mut Option<(char, usize)>) -> bool {
782806}
783807
784808/// Refile: move a subtree under a new parent as its last child (§6.5).
785−pub fn refile(vault: &mut Vault, r: NRef, dest: NRef) -> std::io::Result<()> {
809+pub fn refile(vault: &mut Vault, r: NRef, dest: NRef) -> std::io::Result<bool> {
786810 let dest = vault.tree.resolved_child(dest);
787811 let n = vault.tree.node(r);
788812 // guard: cannot refile into own subtree — ancestry followed through
@@ -809,7 +833,7 @@ pub fn refile(vault: &mut Vault, r: NRef, dest: NRef) -> std::io::Result<()> {
809833 }
810834 let rendered = render(&vault.tree, moving, 1, false);
811835 let shifted = shift_document(&rendered, vault.tree.level(dest), child_indent(&vault.tree, dest));
812− place(vault, Some(moving), dest, usize::MAX, &shifted).map(|_| ())
836+ place(vault, Some(moving), dest, usize::MAX, &shifted)
813837}
814838
815839// ---------------------------------------------------------------- placement
@@ -865,15 +889,15 @@ fn clamp_index(tree: &crate::tree::Tree, kids: &[NRef], want: usize, kinds: &[Ki
865889/// `want`-th child node of `parent`, clamped by the ordering rule (§3.1).
866890/// With `moving`, that node's own lines are removed in the same edit: text
867891/// children around it stay where they are. The insertion is written before
868−/// any removal in another file, so a failure never loses text. Returns the
869−/// index the document landed at among `parent`'s child nodes.
892+/// any removal in another file, so a failure never loses text. Returns
893+/// whether the ordering rule moved it from the wanted index.
870894fn place(
871895 vault: &mut Vault,
872896 moving: Option<NRef>,
873897 parent: NRef,
874898 want: usize,
875899 doc: &str,
876−) -> std::io::Result<usize> {
900+) -> std::io::Result<bool> {
877901 let tree = &vault.tree;
878902 let pnode = tree.node(parent);
879903 let kids: Vec<NRef> = tree
@@ -958,11 +982,11 @@ fn place(
958982 }
959983 }
960984 vault.reload()?;
961− Ok(idx)
985+ Ok(idx != want.min(kids.len()))
962986}
963987
964988/// Archive: refile under the top-level `Archive` section (§6.5).
965−pub fn archive(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
989+pub fn archive(vault: &mut Vault, r: NRef) -> std::io::Result<bool> {
966990 let root = vault.tree.root;
967991 let mut dest = None;
968992 for c in vault.tree.resolved_children(root) {
@@ -1066,36 +1090,9 @@ pub fn make_block(vault: &mut Vault, r: NRef) -> std::io::Result<Id> {
10661090 let id = Id::generate();
10671091 // the file: render(node, 1, false) with frontmatter (§6.1.2)
10681092 let body = render(&vault.tree, r, 1, false);
1069− let mut file_text = format!("---\nid: {}\n", id);
1070− // checkbox becomes todo: (§6.1.3)
1071− if let Some(state) = n.task {
1072− file_text.push_str(&format!(
1073− "todo: {}\n",
1074− match state {
1075− TaskState::Open => "open",
1076− TaskState::Done => "done",
1077− }
1078− ));
1079− }
1080− file_text.push_str("---\n\n");
1081− // strip the checkbox from the root title line (§4.5)
1082− let body_lines: Vec<&str> = body.lines().collect();
1083− let mut stripped = String::new();
1084− for (i, l) in body_lines.iter().enumerate() {
1085− if i == 0 && n.task.is_some() {
1086− match checkbox_span(l) {
1087− Some((start, end)) => {
1088− stripped.push_str(&l[..start]);
1089− stripped.push_str(&l[end..]);
1090− }
1091− None => stripped.push_str(l),
1092− }
1093− } else {
1094− stripped.push_str(l);
1095− }
1096− stripped.push('\n');
1097− }
1098− file_text.push_str(&stripped);
1093+ // the checkbox stays on the title line: it is the state (§4.5)
1094+ let mut file_text = format!("---\nid: {}\n---\n\n", id);
1095+ file_text.push_str(&body);
10991096 let prefix = vault.unique_prefix(&id);
11001097 let fname = crate::ident::filename(&prefix, &slug(&n.title));
11011098 crate::vault::atomic_write(&vault.dir.join(&fname), &file_text)?;
@@ -1214,11 +1211,11 @@ pub fn move_sibling(vault: &mut Vault, r: NRef, down: bool) -> std::io::Result<(
12141211/// and the subtree is re-indented and re-levelled for the new spelling. The
12151212/// node moves to its parent's boundary if it has to (§3.1), so no sibling
12161213/// changes parent; a block's embed changes form and moves with it (§4.7).
1217−pub fn toggle_spelling(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
1214+pub fn toggle_spelling(vault: &mut Vault, r: NRef) -> std::io::Result<bool> {
12181215 let r = vault.tree.resolved_child(r);
12191216 let n = vault.tree.node(r);
12201217 if n.kind == Kind::Root {
1221− return Ok(());
1218+ return Ok(false);
12221219 }
12231220 let to_section = n.kind == Kind::Item;
12241221 let stand = stand_in(&vault.tree, r);
@@ -1231,21 +1228,22 @@ pub fn toggle_spelling(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
12311228 let id = n.block.as_ref().and_then(|b| b.id.clone());
12321229 vault.write_span(file, span, &respelled)?;
12331230 let Some(e) = id.and_then(|id| vault.tree.embed_of(&id)) else {
1234− return vault.reload();
1231+ vault.reload()?;
1232+ return Ok(false);
12351233 };
12361234 return respell_embed(vault, e, to_section);
12371235 }
1238− let Some(parent) = n.parent.map(|p| (file, p)) else { return Ok(()) };
1236+ let Some(parent) = n.parent.map(|p| (file, p)) else { return Ok(false) };
12391237 let respelled = respell(span.text(&text), to_section, vault.tree.level(parent) + 1);
12401238 reposition(vault, r, parent, respelled, to_section)
12411239}
12421240
12431241/// Rewrite an embed in the other form (§4.7) — heading for a section-spelled
12441242/// block, bare for an item — moving it to its parent's boundary if needed.
1245−pub fn respell_embed(vault: &mut Vault, e: NRef, to_section: bool) -> std::io::Result<()> {
1243+pub fn respell_embed(vault: &mut Vault, e: NRef, to_section: bool) -> std::io::Result<bool> {
12461244 let en = vault.tree.node(e);
1247− let Some(id) = en.embed.clone() else { return Ok(()) };
1248− let Some(parent) = en.parent.map(|p| (e.0, p)) else { return Ok(()) };
1245+ let Some(id) = en.embed.clone() else { return Ok(false) };
1246+ let Some(parent) = en.parent.map(|p| (e.0, p)) else { return Ok(false) };
12491247 let text = &vault.tree.files[e.0].text;
12501248 let old = Span { start: en.span.start, end: en.span.end.min(text.len()) }.text(text);
12511249 let rest = old.find('\n').map(|i| &old[i..]).unwrap_or("\n");
@@ -1267,7 +1265,7 @@ fn reposition(
12671265 parent: NRef,
12681266 new: String,
12691267 to_section: bool,
1270−) -> std::io::Result<()> {
1268+) -> std::io::Result<bool> {
12711269 let tree = &vault.tree;
12721270 let kids = tree.raw_children(parent);
12731271 let pos = kids.iter().position(|&k| k == r).unwrap_or(0);
@@ -1276,9 +1274,10 @@ fn reposition(
12761274 if clamp_index(tree, &others, pos, &[kind]) == pos {
12771275 let span = tree.node(r).span;
12781276 vault.write_span(r.0, span, &new)?;
1279− return vault.reload();
1277+ vault.reload()?;
1278+ return Ok(false);
12801279 }
1281− place(vault, Some(r), parent, pos, &new).map(|_| ())
1280+ place(vault, Some(r), parent, pos, &new).map(|_| true)
12821281}
12831282
12841283
@@ -1317,13 +1316,13 @@ fn respell(span_text: &str, to_section: bool, level: usize) -> String {
13171316
13181317/// Demote: become the last child of the previous sibling node (§10.3 `>`),
13191318/// clamped by the ordering rule (§3.1).
1320−pub fn demote(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
1319+pub fn demote(vault: &mut Vault, r: NRef) -> std::io::Result<bool> {
13211320 let m = stand_in(&vault.tree, r);
1322− let Some(parent) = vault.tree.node(m).parent.map(|p| (m.0, p)) else { return Ok(()) };
1321+ let Some(parent) = vault.tree.node(m).parent.map(|p| (m.0, p)) else { return Ok(false) };
13231322 let kids = vault.tree.raw_children(parent);
1324− let Some(pos) = kids.iter().position(|&k| k == m) else { return Ok(()) };
1323+ let Some(pos) = kids.iter().position(|&k| k == m) else { return Ok(false) };
13251324 if pos == 0 {
1326− return Ok(());
1325+ return Ok(false);
13271326 }
13281327 let prev = vault.tree.resolved_child(kids[pos - 1]);
13291328 refile(vault, r, prev)
@@ -1333,16 +1332,16 @@ pub fn demote(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
13331332/// ordering rule (§3.1): an item leaving a section lands before the first
13341333/// section among the parent's siblings. Out of a block's root, the node moves
13351334/// to the parent file beside the block's embed.
1336−pub fn promote(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
1335+pub fn promote(vault: &mut Vault, r: NRef) -> std::io::Result<bool> {
13371336 let m = stand_in(&vault.tree, r);
1338− let Some(parent) = vault.tree.node(m).parent.map(|p| (m.0, p)) else { return Ok(()) };
1337+ let Some(parent) = vault.tree.node(m).parent.map(|p| (m.0, p)) else { return Ok(false) };
13391338 if vault.tree.node(parent).kind == Kind::Root {
1340− return Ok(());
1339+ return Ok(false);
13411340 }
13421341 // the parent's own position: its embed if it is a block root
13431342 let pstand = stand_in(&vault.tree, parent);
13441343 let Some(grand) = vault.tree.node(pstand).parent.map(|p| (pstand.0, p)) else {
1345− return Ok(());
1344+ return Ok(false);
13461345 };
13471346 if vault.tree.node(grand).kind == Kind::Root && pstand.0 != vault.tree.root.0 {
13481347 return Err(io_err("block has no embed"));
@@ -1350,7 +1349,7 @@ pub fn promote(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
13501349 let pos = vault.tree.raw_children(grand).iter().position(|&k| k == pstand).unwrap_or(0);
13511350 let rendered = render(&vault.tree, m, 1, false);
13521351 let shifted = shift_document(&rendered, vault.tree.level(grand), child_indent(&vault.tree, grand));
1353− place(vault, Some(m), grand, pos + 1, &shifted).map(|_| ())
1352+ place(vault, Some(m), grand, pos + 1, &shifted)
13541353}
13551354
13561355/// Rename a node's title (used by the editor when it detects a title change,
@@ -1384,13 +1383,10 @@ pub fn rename_title(vault: &mut Vault, r: NRef, new_title: &str) -> std::io::Res
13841383 if !checkbox.is_empty() {
13851384 prefix.push_str(checkbox);
13861385 }
1386+ // the file keeps its name: names are set once, and `check --fix`
1387+ // brings them up to date on request (§6.4)
13871388 let new_line = format!("{}{}", prefix, new_title);
1388− let is_block_root = n.is_block();
1389− vault.write_span(file, ts, &new_line)?;
1390− if is_block_root {
1391− vault.rename_block_file(file, new_title)?;
1392− }
1393− Ok(())
1389+ vault.write_span(file, ts, &new_line)
13941390}
13951391
13961392/// Append a plain item child titled `title` under `parent`; returns the new
crates/fold-core/src/parse.rs+82−26
@@ -456,6 +456,10 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
456456 let mut fence: Option<(char, usize)> = None;
457457 let mut root_title_seen = !is_block_file;
458458 let mut root_level_nodes = 0usize;
459+ // A block file's root node, once seen: later column-0 nodes and text
460+ // are adopted as its children (§4.9) — what a phone appending to the
461+ // file produces.
462+ let mut block_root: Option<usize> = None;
459463
460464 // Pending setext: (title line index, title line indent) — a plain body
461465 // line that may become a section if the next line is an underline.
@@ -470,12 +474,12 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
470474
471475 if fence_transition(&raw, &mut fence) {
472476 pending_setext = None;
473− push_body(&mut nodes, &mut stack, &lines, i);
477+ push_body(&mut nodes, &mut stack, &lines, i, block_root);
474478 i += 1;
475479 continue;
476480 }
477481 if fence.is_some() {
478− push_body(&mut nodes, &mut stack, &lines, i);
482+ push_body(&mut nodes, &mut stack, &lines, i, block_root);
479483 i += 1;
480484 continue;
481485 }
@@ -495,7 +499,7 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
495499 continue;
496500 }
497501 // Not a setext: a `---` after a blank or structure is a break (body).
498− push_body(&mut nodes, &mut stack, &lines, i);
502+ push_body(&mut nodes, &mut stack, &lines, i, block_root);
499503 i += 1;
500504 continue;
501505 }
@@ -557,7 +561,14 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
557561 }
558562 stack.pop();
559563 }
560− let parent = stack.last().unwrap().node;
564+ let mut parent = stack.last().unwrap().node;
565+ let mut adopted = false;
566+ if parent == root_idx {
567+ if let Some(br) = block_root {
568+ parent = br;
569+ adopted = true;
570+ }
571+ }
561572 let mut node = Node {
562573 kind: match info.kind {
563574 TitleKind::Section { .. } | TitleKind::Embed(_, Some(_)) => Kind::Section,
@@ -591,10 +602,16 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
591602 },
592603 noncanonical: info.noncanonical,
593604 };
605+ if adopted {
606+ node.noncanonical
607+ .push("column-0 node in a block file, adopted by its root".into());
608+ }
609+ let first_root = parent == root_idx && is_block_file && !root_title_seen
610+ && node.embed.is_none();
594611 if parent == root_idx {
595612 root_level_nodes += 1;
596613 if is_block_file {
597− if !root_title_seen && node.embed.is_none() {
614+ if first_root {
598615 root_title_seen = true;
599616 } else if root_level_nodes > 1 {
600617 diagnostics.push(Diag {
@@ -618,6 +635,9 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
618635 });
619636 }
620637 let idx = nodes.len();
638+ if first_root {
639+ block_root = Some(idx);
640+ }
621641 nodes[parent].children.push(idx);
622642 nodes[parent].content.push(Content::Node(idx));
623643 nodes.push(node);
@@ -647,12 +667,24 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
647667 message: "text before the block's root node".into(),
648668 });
649669 }
650− push_body(&mut nodes, &mut stack, &lines, i);
670+ push_body(&mut nodes, &mut stack, &lines, i, block_root);
651671 i += 1;
652672 }
653673 }
654674 }
655675
676+ // A text child right after a child node, with no blank line between,
677+ // reads here as the parent's text but in CommonMark as a continuation of
678+ // that node: canonical form separates them (§4.2).
679+ for n in 0..nodes.len() {
680+ let tight = unseparated_text(&nodes[n], text);
681+ if !tight.is_empty() {
682+ nodes[n]
683+ .noncanonical
684+ .push("text right after a child node without a blank line".into());
685+ }
686+ }
687+
656688 let mut pf = ParsedFile {
657689 path: path.to_string(),
658690 text: text.to_string(),
@@ -665,28 +697,21 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
665697 if is_block_file {
666698 // The block root is the first top-level node.
667699 if let Some(&first) = pf.nodes[root_idx].children.first() {
668− // A checkbox on a block's own title line reads as `todo` (§4.5).
669− if let Some(task) = pf.nodes[first].task {
700+ // Task state is the checkbox on the block's own title line,
701+ // as for any node (§4.5). A legacy `todo:` key is read only
702+ // when there is no checkbox, and is non-canonical either way.
703+ if b.prop("todo").is_some() {
670704 pf.nodes[first]
671705 .noncanonical
672− .push("checkbox on a block's own title line".into());
673− // frontmatter todo: wins; else checkbox reads as state
674− if b.prop("todo").is_none() {
675− b.props.insert(
676− "todo".into(),
677− match task {
678− TaskState::Open => "open".into(),
679− TaskState::Done => "done".into(),
680− },
681− );
682− }
706+ .push("todo: key (the state belongs on the title line)".into());
707+ }
708+ if pf.nodes[first].task.is_none() {
709+ pf.nodes[first].task = match b.prop("todo") {
710+ Some("open") => Some(TaskState::Open),
711+ Some("done") => Some(TaskState::Done),
712+ _ => None,
713+ };
683714 }
684− // Task state from `todo:` (§4.5).
685− pf.nodes[first].task = match b.prop("todo") {
686− Some("open") => Some(TaskState::Open),
687− Some("done") => Some(TaskState::Done),
688− _ => None,
689− };
690715 if matches!(b.prop("todo"), Some(v) if v != "open" && v != "done") {
691716 let d = Diag {
692717 span: b.frontmatter_span.unwrap_or_default(),
@@ -706,11 +731,31 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
706731 pf
707732}
708733
734+/// Whether a title line carries a checkbox right after its marker (§4.3).
735+pub fn title_has_checkbox(line: &str) -> bool {
736+ classify_title(line).map(|(_, info)| info.task.is_some()).unwrap_or(false)
737+}
738+
739+/// Starts of the text children that follow a child node with no blank line
740+/// between them (§4.2): where canonical form inserts one.
741+pub fn unseparated_text(n: &Node, text: &str) -> Vec<usize> {
742+ let mut out = Vec::new();
743+ for w in n.content.windows(2) {
744+ if let [Content::Node(_), Content::Text(sp)] = w {
745+ if sp.start > 0 && !text[..sp.start].ends_with("\n\n") {
746+ out.push(sp.start);
747+ }
748+ }
749+ }
750+ out
751+}
752+
709753fn push_body(
710754 nodes: &mut Vec<Node>,
711755 stack: &mut Vec<Frame>,
712756 lines: &[Line],
713757 i: usize,
758+ block_root: Option<usize>,
714759) {
715760 let line = &lines[i];
716761 // A body line belongs to the deepest node whose region it reaches
@@ -751,7 +796,18 @@ fn push_body(
751796 }
752797 stack.pop();
753798 }
754− let top = stack.last().unwrap().node;
799+ let mut top = stack.last().unwrap().node;
800+ // column-0 text after a block file's root belongs to that root (§4.9)
801+ if top == 0 {
802+ if let Some(br) = block_root {
803+ if !line.raw.trim().is_empty() {
804+ nodes[br]
805+ .noncanonical
806+ .push("column-0 text in a block file, adopted by its root".into());
807+ }
808+ top = br;
809+ }
810+ }
755811 // consecutive lines extend the node's last text child; a line after a
756812 // child node starts a new one, so text never spans a child (§3.3)
757813 match nodes[top].content.last_mut() {
crates/fold-core/src/render.rs+9−9
@@ -123,20 +123,14 @@ impl Walk<'_> {
123123 let mut s = " ".repeat(dindent);
124124 s.push_str(&"#".repeat(dlevel.max(1)));
125125 s.push(' ');
126− // A block's task state is frontmatter-only (§4.9): no
127− // checkbox on its own title line.
128− if !n.is_block() {
129− push_checkbox(n.task, &mut s);
130− }
126+ push_checkbox(n.task, &mut s);
131127 s.push_str(&n.title);
132128 Some((s.trim_end().to_string(), LineKind::Title))
133129 }
134130 Kind::Item => {
135131 let mut s = " ".repeat(dindent);
136132 s.push_str("- ");
137− if !n.is_block() {
138− push_checkbox(n.task, &mut s);
139− }
133+ push_checkbox(n.task, &mut s);
140134 s.push_str(&n.title);
141135 Some((s.trim_end().to_string(), LineKind::Title))
142136 }
@@ -205,13 +199,19 @@ impl Walk<'_> {
205199 }
206200 let tree = self.tree;
207201 let cols = tree.node(r).indent;
202+ // an item's text sits two columns in; a line left of that (adopted
203+ // column-0 text in a block file, §4.9) is written there
204+ let min_rel: usize = if tree.node(r).kind == Kind::Item { 2 } else { 0 };
208205 for l in tree.text_of(r)[span.start..span.end].split_inclusive('\n') {
209206 let l = l.strip_suffix('\n').unwrap_or(l);
210207 let l = l.strip_suffix('\r').unwrap_or(l);
211208 if l.trim().is_empty() {
212209 self.push(String::new(), LineKind::Blank, r, owner, outer, dlevel, dindent);
213210 } else {
214− let text = format!("{}{}", " ".repeat(dindent), dedent(l, cols));
211+ let rel = dedent(l, cols);
212+ let lead = rel.len() - rel.trim_start_matches(' ').len();
213+ let pad = min_rel.saturating_sub(lead);
214+ let text = format!("{}{}", " ".repeat(dindent + pad), rel);
215215 self.push(text, LineKind::Body, r, owner, outer, dlevel, dindent);
216216 }
217217 }
crates/fold-core/src/tree.rs+14
@@ -63,6 +63,20 @@ impl Tree {
6363 }
6464 }
6565
66+ /// The level a section has by position alone: 1 + its section ancestors
67+ /// (§3.1), whatever level is written.
68+ pub fn derived_level(&self, r: NRef) -> usize {
69+ let mut level = 1;
70+ let mut cur = self.node(r).parent;
71+ while let Some(p) = cur {
72+ if self.node((r.0, p)).kind == Kind::Section {
73+ level += 1;
74+ }
75+ cur = self.node((r.0, p)).parent;
76+ }
77+ level
78+ }
79+
6680 /// indent(n) = 2 × number of item ancestors (§3.1).
6781 pub fn indent(&self, r: NRef) -> usize {
6882 let mut n = 0usize;
crates/fold-core/src/vault.rs+55−11
@@ -467,13 +467,19 @@ fn read_if_exists(path: &Path) -> std::io::Result<Option<String>> {
467467 }
468468}
469469
470−/// A node reference that survives reloads: block id if the node is a block,
471−/// else its path of titles (§11.2).
470+/// A node reference that survives reloads (§3.4, §11.2): a block by its id;
471+/// any other node by its steps from the root of the file it lives in, each
472+/// step a title and the node's ordinal among same-titled siblings, so two
473+/// siblings with one title (or none) are never confused.
472474#[derive(Debug, Clone, PartialEq, Eq)]
473475pub enum NodeKey {
474476 Root,
475477 Id(Id),
476− Path(Vec<String>),
478+ Path {
479+ /// The block whose file holds the node; `None` for root.md.
480+ block: Option<Id>,
481+ steps: Vec<(String, usize)>,
482+ },
477483}
478484
479485impl Vault {
@@ -487,22 +493,60 @@ impl Vault {
487493 return NodeKey::Id(id.clone());
488494 }
489495 }
490− NodeKey::Path(self.tree.path(r))
496+ let f = &self.tree.files[r.0];
497+ let mut steps = Vec::new();
498+ let mut cur = r.1;
499+ let mut block = None;
500+ while let Some(p) = f.nodes[cur].parent {
501+ let title = &f.nodes[cur].title;
502+ let ordinal = f.nodes[p]
503+ .children
504+ .iter()
505+ .take_while(|&&c| c != cur)
506+ .filter(|&&c| f.nodes[c].title == *title)
507+ .count();
508+ steps.push((title.clone(), ordinal));
509+ if let Some(id) = f.nodes[p].block.as_ref().and_then(|b| b.id.clone()) {
510+ block = Some(id);
511+ break;
512+ }
513+ cur = p;
514+ }
515+ steps.reverse();
516+ NodeKey::Path { block, steps }
491517 }
492518
493− /// Re-attach a cursor after reload: by id, then path, then nearest
494− /// surviving ancestor (§11.2).
519+ /// Re-attach a node after a reload: by id, else by its steps, falling
520+ /// back to the deepest step that still exists (§11.2).
495521 pub fn find_by_key(&self, key: &NodeKey) -> Option<NRef> {
496522 match key {
497523 NodeKey::Root => Some(self.tree.root),
498524 NodeKey::Id(id) => self.tree.block_by_id(id),
499− NodeKey::Path(segs) => {
500− for n in (1..=segs.len()).rev() {
501− if let Some(r) = self.find_by_path(&segs[..n]) {
502− return Some(r);
525+ NodeKey::Path { block, steps } => {
526+ let mut cur = match block {
527+ Some(id) => self.tree.block_by_id(id)?,
528+ None => self.tree.root,
529+ };
530+ let mut found = None;
531+ for (title, ordinal) in steps {
532+ let f = &self.tree.files[cur.0];
533+ let next = f.nodes[cur.1]
534+ .children
535+ .iter()
536+ .filter(|&&c| f.nodes[c].title == *title)
537+ .nth(*ordinal);
538+ match next {
539+ Some(&c) => {
540+ cur = (cur.0, c);
541+ found = Some(cur);
542+ }
543+ None => break,
503544 }
504545 }
505− None
546+ found.or(match block {
547+ Some(id) => self.tree.block_by_id(id),
548+ None => None,
549+ })
506550 }
507551 }
508552 }
crates/fold-core/tests/edit.rs+2−2
@@ -247,14 +247,14 @@ fn noop_splice_of_spec_example_vault() {
247247 let dir = tempfile::tempdir().unwrap();
248248 std::fs::write(dir.path().join("root.md"), "# Homelab\n\nTwo boxes in the closet, one at Hetzner.\n\n## NAS\n\n![[dozzod-binwes-talsun-worbec]]\n\n## Networking\n\n- [ ] Replace the flaky switch\n![[racfer-hattes-mislup-nodrys]]\n\n# Inbox\n\n## 2026-09-10\n\n- Talked to Anya about the venue.\n ### Options\n Warehouse on Ligovsky, or the old bakery. Both need a licence.\n- [x] Send the deposit\n").unwrap();
249249 std::fs::write(dir.path().join("dozzod~zfs-layout.md"), "---\nid: dozzod-binwes-talsun-worbec\nsince: 2024-03\ntags: [storage, homelab] # user-defined\n---\n\n# ZFS layout\n\nMirrored pairs, no raidz. Snapshots hourly via sanoid.\n\n## [ ] Snapshot policy\n\n- hourly, keep 24\n- [x] Move scratch to its own dataset\n").unwrap();
250− std::fs::write(dir.path().join("racfer~order-new-switch.md"), "---\nid: racfer-hattes-mislup-nodrys\ntodo: open\ndue: 2026-09-20\n---\n\n- Order new switch\n Two options, noted under Networking.\n").unwrap();
250+ std::fs::write(dir.path().join("racfer~order-new-switch.md"), "---\nid: racfer-hattes-mislup-nodrys\ndue: 2026-09-20\n---\n\n- [ ] Order new switch\n Two options, noted under Networking.\n").unwrap();
251251 let mut v = Vault::open(dir.path()).unwrap();
252252 let root = v.tree.root;
253253 let buf = open_editor(&v, root);
254254 let texts: Vec<&str> = buf.lines.iter().map(|l| l.text.as_str()).collect();
255255 assert!(texts.contains(&"### ZFS layout"), "{:#?}", texts);
256256 assert!(texts.contains(&"#### [ ] Snapshot policy"), "{:#?}", texts);
257− assert!(texts.contains(&"- Order new switch"), "{:#?}", texts);
257+ assert!(texts.contains(&"- [ ] Order new switch"), "{:#?}", texts);
258258 assert!(!texts.iter().any(|t| t.contains("![[")), "{:#?}", texts);
259259 assert_noop_splice(&mut v, root);
260260 let homelab = v.tree.resolved_children(v.tree.root)[0];
crates/fold-core/tests/merge_check.rs+5−5
@@ -206,12 +206,12 @@ fn sync_conflict_on_block_places_embed_after_its_embed() {
206206 std::fs::write(dir.path().join("root.md"), format!("# A\n\n![[{}]]\n\n# B\n", BID)).unwrap();
207207 std::fs::write(
208208 dir.path().join("racfer~t.md"),
209− format!("---\nid: {}\ntodo: open\n---\n\n- t\n", BID),
209+ format!("---\nid: {}\n---\n\n- [ ] t\n", BID),
210210 )
211211 .unwrap();
212212 std::fs::write(
213213 dir.path().join("racfer~t.sync-conflict-20260912-100000-phone.md"),
214− format!("---\nid: {}\ntodo: done\n---\n\n- t\n", BID),
214+ format!("---\nid: {}\n---\n\n- [x] t\n", BID),
215215 )
216216 .unwrap();
217217 let mut v = Vault::open(dir.path()).unwrap();
@@ -290,7 +290,7 @@ fn keep_ours_handles_multibyte_text_before_embed() {
290290}
291291
292292#[test]
293−fn retitled_block_root_conflicts_and_keep_theirs_renames_file() {
293+fn retitled_block_root_conflicts_and_keep_theirs_keeps_the_filename() {
294294 let dir = tempfile::tempdir().unwrap();
295295 std::fs::write(dir.path().join("root.md"), format!("# A\n\n![[{}]]\n", BID)).unwrap();
296296 std::fs::write(dir.path().join("racfer~old.md"), format!("---\nid: {}\n---\n\n- old\n", BID)).unwrap();
@@ -306,8 +306,8 @@ fn retitled_block_root_conflicts_and_keep_theirs_renames_file() {
306306 let pairs = merge::conflict_pairs(&v);
307307 assert_eq!(pairs.len(), 1);
308308 merge::resolve_keep_theirs(&mut v, pairs[0].0, pairs[0].1).unwrap();
309− assert!(!dir.path().join("racfer~old.md").exists());
310− let text = std::fs::read_to_string(dir.path().join("racfer~new.md")).unwrap();
309+ // the title changed, the filename did not (§6.4)
310+ let text = std::fs::read_to_string(dir.path().join("racfer~old.md")).unwrap();
311311 assert!(text.contains(&format!("id: {}", BID)) && text.contains("- new"), "{}", text);
312312 assert!(merge::conflict_pairs(&v).is_empty());
313313}
crates/fold-core/tests/ops.rs+25−8
@@ -58,10 +58,11 @@ fn make_block_writes_file_and_embed() {
5858 // the block file round-trips
5959 assert_eq!(v.tree.files.len(), 2);
6060 let bf = &v.tree.files[1];
61− assert!(bf.text.starts_with(&format!("---\nid: {}\ntodo: open\n---\n\n", id)));
62− assert!(bf.text.contains("- Order new switch"), "{}", bf.text);
61+ // the checkbox stays on the title line: it is the state (§4.5)
62+ assert!(bf.text.starts_with(&format!("---\nid: {}\n---\n\n", id)), "{}", bf.text);
63+ assert!(bf.text.contains("- [ ] Order new switch"), "{}", bf.text);
6364 assert!(bf.text.contains("Two options."), "{}", bf.text);
64− assert!(!bf.text.contains("[ ]"));
65+ assert!(!bf.text.contains("todo:"));
6566 // filename: <prefix>~<name>.md
6667 assert!(bf.path.ends_with("~order-new-switch.md"), "{}", bf.path);
6768}
@@ -76,8 +77,13 @@ fn task_block_toggle_stamps_done() {
7677 let block = v.tree.resolved_child(embed);
7778 ops::toggle_task(&mut v, block).unwrap();
7879 let bf = &v.tree.files[1];
79− assert!(bf.text.contains("todo: done"), "{}", bf.text);
80+ assert!(bf.text.contains("- [x] Order new switch"), "{}", bf.text);
8081 assert!(bf.text.contains("done: 20"), "{}", bf.text);
82+ assert!(!bf.text.contains("todo:"), "{}", bf.text);
83+ ops::toggle_task(&mut v, block).unwrap();
84+ let bf = &v.tree.files[1];
85+ assert!(bf.text.contains("- [ ] Order new switch"), "{}", bf.text);
86+ assert!(!bf.text.contains("done:"), "{}", bf.text);
8187}
8288
8389#[test]
@@ -218,17 +224,23 @@ fn demote_and_promote() {
218224}
219225
220226#[test]
221−fn rename_block_title_renames_file() {
227+fn rename_block_title_keeps_the_filename_until_fix() {
222228 let (_d, mut v) = vault_with("# A\n\n- task\n");
223229 let a = v.tree.resolved_children(v.tree.root)[0];
224230 let task = v.tree.resolved_children(a)[0];
225231 ops::make_block(&mut v, task).unwrap();
226232 let embed = v.tree.resolved_children(v.tree.resolved_children(v.tree.root)[0])[0];
227233 let block = v.tree.resolved_child(embed);
234+ let old_path = v.tree.files[1].path.clone();
228235 ops::rename_title(&mut v, block, "renamed title").unwrap();
229236 assert_eq!(v.tree.files.len(), 2);
230− assert!(v.tree.files[1].path.ends_with("~renamed-title.md"), "{}", v.tree.files[1].path);
237+ // names are set once (§6.4): the file keeps its name ...
238+ assert_eq!(v.tree.files[1].path, old_path);
231239 assert!(v.tree.files[1].text.contains("- renamed title"));
240+ // ... check calls it stale, and --fix renames it on request
241+ assert!(fold_core::check::check(&v).iter().any(|d| d.message.contains("does not match the title")));
242+ fold_core::check::fix(&mut v).unwrap();
243+ assert!(v.tree.files[1].path.ends_with("~renamed-title.md"), "{}", v.tree.files[1].path);
232244}
233245
234246#[test]
@@ -525,7 +537,9 @@ fn capture_to_a_node_with_sections_stays_an_item() {
525537}
526538
527539#[test]
528−fn check_fix_keeps_root_frontmatter_intro_and_malformed_files() {
540+fn check_fix_keeps_root_frontmatter_intro_and_adopted_nodes() {
541+ // a second column-0 node is adopted by the block's root (§4.9): --fix
542+ // writes it as the root's child and loses nothing
529543 let malformed = format!("---\nid: {}\n---\n\n# One\n\n# Two\n\nprecious\n", ID_A);
530544 let fm_only = format!("---\nid: {}\n---\n", ID_B);
531545 let (d, mut v) = vault_files(
@@ -534,7 +548,10 @@ fn check_fix_keeps_root_frontmatter_intro_and_malformed_files() {
534548 );
535549 fold_core::check::fix(&mut v).unwrap();
536550 assert_eq!(read(&d, "root.md"), "---\nvault: x\n---\n\nIntro text.\n\n# Inbox\n");
537− assert_eq!(read(&d, "racfer~one.md"), malformed);
551+ assert_eq!(
552+ read(&d, "racfer~one.md"),
553+ format!("---\nid: {}\n---\n\n# One\n\n## Two\n\nprecious\n", ID_A)
554+ );
538555}
539556
540557#[test]
crates/fold-core/tests/ordering.rs+141−1
@@ -218,7 +218,8 @@ fn refile_item_keeps_nested_section_levels() {
218218fn paste_item_after_a_section_is_clamped() {
219219 let (_d, mut v) = vault_with("# P\n\n- a\n\n## S\n");
220220 let r = at(&v, "P/S");
221− ops::paste(&mut v, r, "- new\n", true).unwrap();
221+ // clamped, and reported as such
222+ assert!(ops::paste(&mut v, r, "- new\n", true).unwrap());
222223 assert!(v.find_by_path(&["P".into(), "new".into()]).is_some(), "{}", root_text(&v));
223224 assert!(v.find_by_path(&["P".into(), "S".into(), "new".into()]).is_none());
224225 assert_ordered(&v);
@@ -413,3 +414,142 @@ fn promote_out_of_a_block_root_lands_beside_its_embed() {
413414 assert_eq!(kids, ["x", "s", "kid", "y"], "{}", root_text(&v));
414415 assert!(v.tree.resolved_children(at(&v, "A/s")).is_empty());
415416}
417+
418+// ------------------------------------------------------------- adoption
419+
420+#[test]
421+fn phone_appends_to_an_item_block_are_adopted() {
422+ // a phone editor appends `- new` at column 0 to an Inbox block spelled
423+ // as a bullet (§4.9): it becomes the root's child, not a second root
424+ let (_d, mut v) = vault_with_block(
425+ "# A\n\n![[racfer-hattes-mislup-nodrys]]\n",
426+ "- Inbox\n - old\n- new\nloose text\n",
427+ );
428+ let kids: Vec<String> = v
429+ .tree
430+ .resolved_children(at(&v, "A/Inbox"))
431+ .iter()
432+ .map(|&c| v.tree.node(c).title.clone())
433+ .collect();
434+ assert_eq!(kids, ["old", "new"]);
435+ let inbox = at(&v, "A/Inbox");
436+ let text = v.tree.node(inbox).text_lines(&v.tree.files[inbox.0].text);
437+ assert!(text.contains(&"loose text"), "{:?}", text);
438+ let diags = check::check(&v);
439+ assert!(diags.iter().all(|d| !d.message.contains("exactly one node")));
440+ assert!(diags.iter().any(|d| d.message.contains("adopted")));
441+ // --fix writes it canonical: the adopted lines nest under the root
442+ check::fix(&mut v).unwrap();
443+ let inbox = at(&v, "A/Inbox");
444+ assert!(
445+ v.tree.files[inbox.0].text.ends_with("- Inbox\n - old\n - new\n\n loose text\n"),
446+ "{}",
447+ v.tree.files[inbox.0].text
448+ );
449+}
450+
451+#[test]
452+fn a_second_heading_in_a_section_block_is_adopted() {
453+ let (_d, v) = vault_with_block(
454+ "# A\n\n## ![[racfer-hattes-mislup-nodrys]]\n",
455+ "# S\n\nbody\n\n# T\n",
456+ );
457+ assert!(v.find_by_path(&["A".into(), "S".into(), "T".into()]).is_some());
458+}
459+
460+// ------------------------------------------------------------- node keys
461+
462+#[test]
463+fn keys_tell_same_titled_siblings_apart() {
464+ let (_d, mut v) = vault_with("# P\n\n- same\n- same\n- \n- \n");
465+ let kids = v.tree.resolved_children(at(&v, "P"));
466+ let keys: Vec<_> = kids.iter().map(|&k| v.key_of(k)).collect();
467+ for (i, k) in keys.iter().enumerate() {
468+ assert_eq!(v.find_by_key(k), Some(kids[i]));
469+ assert!(keys.iter().filter(|o| *o == k).count() == 1);
470+ }
471+ // the second "same" survives an edit to the first by key
472+ let first = kids[0];
473+ ops::toggle_task(&mut v, first).unwrap();
474+ let second = v.find_by_key(&keys[1]).unwrap();
475+ assert_eq!(v.tree.node(second).task, None);
476+ assert_eq!(v.tree.resolved_children(at(&v, "P"))[1], second);
477+}
478+
479+#[test]
480+fn keys_of_nodes_inside_nested_blocks_resolve() {
481+ let (_d, v) = vault_with_block(
482+ "# A\n\n## B\n\n### ![[racfer-hattes-mislup-nodrys]]\n",
483+ "# S\n\n- deep\n",
484+ );
485+ let deep = at(&v, "A/B/S/deep");
486+ let key = v.key_of(deep);
487+ assert_eq!(v.find_by_key(&key), Some(deep));
488+}
489+
490+#[test]
491+fn check_reports_and_fixes_heading_embed_levels() {
492+ let (_d, mut v) = vault_with_block(
493+ "# A\n\n#### ![[racfer-hattes-mislup-nodrys]]\n\n## B\n",
494+ "# S\n",
495+ );
496+ let before = parents(&v);
497+ assert!(check::check(&v).iter().any(|d| d.message.contains("level 4 where its position gives 2")));
498+ check::fix(&mut v).unwrap();
499+ assert_eq!(root_text(&v), "# A\n\n## ![[racfer-hattes-mislup-nodrys]]\n\n## B\n");
500+ assert_eq!(parents(&v), before);
501+ assert!(check::check(&v).iter().all(|d| !d.message.contains("heading embed at level")));
502+}
503+
504+#[test]
505+fn placement_as_asked_is_not_reported_as_moved() {
506+ let (_d, mut v) = vault_with("# P\n\n- a\n- b\n");
507+ let r = at(&v, "P/a");
508+ assert!(!ops::paste(&mut v, r, "- new\n", true).unwrap());
509+ let kids: Vec<String> = v
510+ .tree
511+ .resolved_children(at(&v, "P"))
512+ .iter()
513+ .map(|&c| v.tree.node(c).title.clone())
514+ .collect();
515+ assert_eq!(kids, ["a", "new", "b"]);
516+}
517+
518+// ------------------------------------------------------------- task state
519+
520+#[test]
521+fn legacy_todo_key_is_read_then_migrated_on_toggle() {
522+ let dir = tempfile::tempdir().unwrap();
523+ std::fs::write(dir.path().join("root.md"), "![[racfer-hattes-mislup-nodrys]]\n").unwrap();
524+ std::fs::write(
525+ dir.path().join("racfer~t.md"),
526+ "---\nid: racfer-hattes-mislup-nodrys\ntodo: open\ndue: 2026-09-20\n---\n\n- t\n",
527+ )
528+ .unwrap();
529+ let mut v = Vault::open(dir.path()).unwrap();
530+ let t = at(&v, "t");
531+ assert_eq!(v.tree.node(t).task, Some(fold_core::TaskState::Open));
532+ assert!(check::check(&v).iter().any(|d| d.message.contains("todo: key")));
533+ ops::toggle_task(&mut v, t).unwrap();
534+ let text = &v.tree.files[t.0].text;
535+ assert!(text.contains("- [x] t") && !text.contains("todo:") && text.contains("due: 2026-09-20"), "{}", text);
536+}
537+
538+#[test]
539+fn check_fix_migrates_legacy_todo_keys() {
540+ let dir = tempfile::tempdir().unwrap();
541+ std::fs::write(dir.path().join("root.md"), "### ![[racfer-hattes-mislup-nodrys]]\n").unwrap();
542+ std::fs::write(
543+ dir.path().join("racfer~t.md"),
544+ "---\nid: racfer-hattes-mislup-nodrys\ntodo: done\ndone: 2026-09-01\n---\n\n# t\n",
545+ )
546+ .unwrap();
547+ let mut v = Vault::open(dir.path()).unwrap();
548+ check::fix(&mut v).unwrap();
549+ let t = at(&v, "t");
550+ assert_eq!(
551+ v.tree.files[t.0].text,
552+ "---\nid: racfer-hattes-mislup-nodrys\ndone: 2026-09-01\n---\n\n# [x] t\n"
553+ );
554+ assert_eq!(v.tree.node(t).task, Some(fold_core::TaskState::Done));
555+}
crates/fold-core/tests/roundtrip.rs+1−1
@@ -237,7 +237,7 @@ fn resolved_render_has_no_boundaries() {
237237#[test]
238238fn bullet_root_block_file() {
239239 // A block whose root is an item: the file starts with a bullet (§4.9).
240− let text = "---\nid: racfer-hattes-mislup-nodrys\ntodo: open\ndue: 2026-09-20\n---\n\n- Order new switch\n Two options, noted under Networking.\n";
240+ let text = "---\nid: racfer-hattes-mislup-nodrys\ndue: 2026-09-20\n---\n\n- [ ] Order new switch\n Two options, noted under Networking.\n";
241241 let fm = fold_core::parse::parse_frontmatter(text).unwrap();
242242 let id = Id::parse("racfer-hattes-mislup-nodrys").unwrap();
243243 let block = Block {
crates/fold-core/tests/undo.rsnew file+84
@@ -0,0 +1,84 @@
1+//! The session op log (§10.11): an entry holds exactly the files an
2+//! operation touched; undo and redo refuse when one changed since.
3+
4+use fold_core::ops::{self, Inverse, Snapshot};
5+use fold_core::vault::Vault;
6+
7+fn vault_with(root: &str) -> (tempfile::TempDir, Vault) {
8+ let dir = tempfile::tempdir().unwrap();
9+ std::fs::write(dir.path().join("root.md"), root).unwrap();
10+ std::fs::write(
11+ dir.path().join("racfer~other.md"),
12+ "---\nid: racfer-hattes-mislup-nodrys\n---\n\n- other\n",
13+ )
14+ .unwrap();
15+ let v = Vault::open(dir.path()).unwrap();
16+ (dir, v)
17+}
18+
19+fn read(d: &tempfile::TempDir, f: &str) -> String {
20+ std::fs::read_to_string(d.path().join(f)).unwrap()
21+}
22+
23+#[test]
24+fn an_entry_holds_only_the_touched_files() {
25+ let (_d, mut v) = vault_with("# A\n\n- [ ] t\n\n![[racfer-hattes-mislup-nodrys]]\n");
26+ let snap = Snapshot::take(&v, "toggle");
27+ let t = v.find_by_path(&["A".into(), "t".into()]).unwrap();
28+ ops::toggle_task(&mut v, t).unwrap();
29+ let inv = Inverse::since(snap, &v).unwrap();
30+ let paths: Vec<&str> = inv.changes.iter().map(|c| c.path.as_str()).collect();
31+ assert_eq!(paths, ["root.md"]);
32+}
33+
34+#[test]
35+fn nothing_changed_means_no_entry() {
36+ let (_d, v) = vault_with("# A\n");
37+ let snap = Snapshot::take(&v, "nothing");
38+ assert!(Inverse::since(snap, &v).is_none());
39+}
40+
41+#[test]
42+fn undo_keeps_external_edits_to_untouched_files() {
43+ let (d, mut v) = vault_with("# A\n\n- [ ] t\n\n![[racfer-hattes-mislup-nodrys]]\n");
44+ let snap = Snapshot::take(&v, "toggle");
45+ let t = v.find_by_path(&["A".into(), "t".into()]).unwrap();
46+ ops::toggle_task(&mut v, t).unwrap();
47+ let inv = Inverse::since(snap, &v).unwrap();
48+ // another editor changes the block file meanwhile
49+ let edited = "---\nid: racfer-hattes-mislup-nodrys\n---\n\n- other, edited\n";
50+ std::fs::write(d.path().join("racfer~other.md"), edited).unwrap();
51+ inv.undo(&mut v).unwrap();
52+ assert_eq!(read(&d, "root.md"), "# A\n\n- [ ] t\n\n![[racfer-hattes-mislup-nodrys]]\n");
53+ assert_eq!(read(&d, "racfer~other.md"), edited);
54+}
55+
56+#[test]
57+fn undo_refuses_when_a_touched_file_changed() {
58+ let (d, mut v) = vault_with("# A\n\n- [ ] t\n");
59+ let snap = Snapshot::take(&v, "toggle");
60+ let t = v.find_by_path(&["A".into(), "t".into()]).unwrap();
61+ ops::toggle_task(&mut v, t).unwrap();
62+ let inv = Inverse::since(snap, &v).unwrap();
63+ std::fs::write(d.path().join("root.md"), "# A\n\n- [x] t\n- added elsewhere\n").unwrap();
64+ let err = inv.undo(&mut v).unwrap_err();
65+ assert!(err.to_string().contains("root.md"), "{}", err);
66+ assert_eq!(read(&d, "root.md"), "# A\n\n- [x] t\n- added elsewhere\n");
67+}
68+
69+#[test]
70+fn undo_and_redo_of_make_block_create_and_remove_the_file() {
71+ let (d, mut v) = vault_with("# A\n\n## B\n\nbody\n");
72+ let snap = Snapshot::take(&v, "make block");
73+ let b = v.find_by_path(&["A".into(), "B".into()]).unwrap();
74+ ops::make_block(&mut v, b).unwrap();
75+ let inv = Inverse::since(snap, &v).unwrap();
76+ let count = || std::fs::read_dir(d.path()).unwrap().count();
77+ let with_block = count();
78+ inv.undo(&mut v).unwrap();
79+ assert_eq!(count(), with_block - 1);
80+ assert_eq!(read(&d, "root.md"), "# A\n\n## B\n\nbody\n");
81+ inv.redo(&mut v).unwrap();
82+ assert_eq!(count(), with_block);
83+ assert!(v.find_by_path(&["A".into(), "B".into()]).is_some());
84+}
crates/fold-tui/src/app.rs+92−63
@@ -61,6 +61,8 @@ pub struct App {
6161 status_time: Instant,
6262 undo: Vec<ops::Inverse>,
6363 redo: Vec<ops::Inverse>,
64+ // snapshot taken when a verb started, settled into `undo` after it
65+ pending_undo: Option<ops::Snapshot>,
6466 filter: String,
6567 filter_rows: Vec<NRef>,
6668 palette: String,
@@ -144,6 +146,7 @@ impl App {
144146 status_time: Instant::now(),
145147 undo: Vec::new(),
146148 redo: Vec::new(),
149+ pending_undo: None,
147150 filter: String::new(),
148151 filter_rows: Vec::new(),
149152 palette: String::new(),
@@ -467,12 +470,11 @@ impl App {
467470 fn act_make_block(&mut self) {
468471 let Some(r) = self.current() else { return };
469472 self.push_undo("act_make_block");
470− let path = self.vault.tree.path(r);
471473 match ops::make_block(&mut self.vault, r) {
472474 Ok(id) => {
473475 self.say(format!("block {}", id));
474− // cursor lands on the embed, which resolves to the block
475− if let Some(nr) = self.vault.find_by_path(&path) {
476+ // the cursor stays on the node, now a block
477+ if let Some(nr) = self.vault.tree.block_by_id(&id) {
476478 self.move_cursor_to(nr);
477479 }
478480 }
@@ -505,7 +507,7 @@ impl App {
505507 self.push_undo("act_paste");
506508 let text = self.register.clone();
507509 match ops::paste(&mut self.vault, r, &text, after) {
508− Ok(()) => self.refresh_after("pasted"),
510+ Ok(moved) => self.refresh_after(&with_rule_note("pasted", moved)),
509511 Err(e) => self.say(format!("error: {}", e)),
510512 }
511513 }
@@ -540,10 +542,13 @@ impl App {
540542 self.push_undo("act_spelling");
541543 let key = self.vault.key_of(r);
542544 match ops::toggle_spelling(&mut self.vault, r) {
543− Ok(()) => {
545+ Ok(moved) => {
544546 if let Some(nr) = self.vault.find_by_key(&key) {
545547 self.move_cursor_to(nr);
546548 }
549+ if moved {
550+ self.say(with_rule_note("respelled", true));
551+ }
547552 }
548553 Err(e) => self.say(format!("error: {}", e)),
549554 }
@@ -554,10 +559,13 @@ impl App {
554559 self.push_undo("act_demote");
555560 let key = self.vault.key_of(r);
556561 match ops::demote(&mut self.vault, r) {
557− Ok(()) => {
562+ Ok(moved) => {
558563 if let Some(nr) = self.vault.find_by_key(&key) {
559564 self.move_cursor_to(nr);
560565 }
566+ if moved {
567+ self.say(with_rule_note("demoted", true));
568+ }
561569 }
562570 Err(e) => self.say(format!("error: {}", e)),
563571 }
@@ -568,10 +576,13 @@ impl App {
568576 self.push_undo("act_promote");
569577 let key = self.vault.key_of(r);
570578 match ops::promote(&mut self.vault, r) {
571− Ok(()) => {
579+ Ok(moved) => {
572580 if let Some(nr) = self.vault.find_by_key(&key) {
573581 self.move_cursor_to(nr);
574582 }
583+ if moved {
584+ self.say(with_rule_note("promoted", true));
585+ }
575586 }
576587 Err(e) => self.say(format!("error: {}", e)),
577588 }
@@ -581,7 +592,7 @@ impl App {
581592 let Some(r) = self.current() else { return };
582593 self.push_undo("act_archive");
583594 match ops::archive(&mut self.vault, r) {
584− Ok(()) => self.refresh_after("archived"),
595+ Ok(moved) => self.refresh_after(&with_rule_note("archived", moved)),
585596 Err(e) => self.say(format!("error: {}", e)),
586597 }
587598 }
@@ -600,7 +611,7 @@ impl App {
600611 self.push_undo("act_refile");
601612 match self.vault.resolve_target(dest_text) {
602613 Ok(dest) => match ops::refile(&mut self.vault, r, dest) {
603− Ok(()) => self.refresh_after("refiled"),
614+ Ok(moved) => self.refresh_after(&with_rule_note("refiled", moved)),
604615 Err(e) => self.say(format!("error: {}", e)),
605616 },
606617 Err(e) => self.say(e),
@@ -632,7 +643,7 @@ impl App {
632643 let key = self.vault.key_of(r);
633644 let depth = self.rows().get(self.cursor).map(|row| row.depth).unwrap_or(0);
634645 match ops::paste(&mut self.vault, r, &format!("{}\n", line), true) {
635− Ok(()) => {
646+ Ok(_) => {
636647 // move to the new sibling: the first row after the
637648 // cursor node's subtree, at its depth, with an empty title
638649 if let Some(nr) = self.vault.find_by_key(&key) {
@@ -677,11 +688,12 @@ impl App {
677688
678689 fn save_editor(&mut self, why: &str) {
679690 let Some(mut buf) = self.edit_buf.take() else { return };
680− let snap = self.snapshot("edit");
691+ self.settle_undo();
692+ let snap = ops::Snapshot::take(&self.vault, "edit");
681693 match buf.save_all(&mut self.vault) {
682694 Ok(n) => {
695+ self.record_undo(snap);
683696 if n > 0 {
684− self.record_undo(snap);
685697 self.say(format!("saved {} block(s) ({})", n, why));
686698 }
687699 self.edit_saved_dot = false;
@@ -804,7 +816,8 @@ impl App {
804816 if new_owner != old_owner {
805817 if let (Some(old), Some(buf2)) = (old_owner, self.edit_buf.as_mut()) {
806818 if buf2.dirty.contains(&old) {
807− let snap = self.snapshot("edit");
819+ self.settle_undo();
820+ let snap = ops::Snapshot::take(&self.vault, "edit");
808821 let mut tmp = self.edit_buf.take().unwrap();
809822 if tmp.splice(&mut self.vault, old).is_ok() {
810823 self.record_undo(snap);
@@ -1106,6 +1119,11 @@ impl App {
11061119 if !self.mouse_enabled {
11071120 return;
11081121 }
1122+ self.handle_mouse_inner(m);
1123+ self.settle_undo();
1124+ }
1125+
1126+ fn handle_mouse_inner(&mut self, m: MouseEvent) {
11091127 let (x, y) = (m.column, m.row);
11101128 match m.kind {
11111129 MouseEventKind::ScrollDown => self.mouse_scroll(x, y, 3),
@@ -1443,6 +1461,11 @@ impl App {
14431461 /// Dispatch a key press by mode, including the two-key sequences
14441462 /// (`zd`/`zr`/`za`, `gg`, `[[`/`]]`).
14451463 pub fn handle_key(&mut self, key: KeyEvent) {
1464+ self.handle_key_inner(key);
1465+ self.settle_undo();
1466+ }
1467+
1468+ fn handle_key_inner(&mut self, key: KeyEvent) {
14461469 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
14471470 if ctrl && key.code == KeyCode::Char('c') && self.mode != Mode::Edit {
14481471 self.quit = true;
@@ -1693,72 +1716,68 @@ impl App {
16931716 }
16941717
16951718 fn act_undo(&mut self) {
1696− if let Some(inv) = self.undo.pop() {
1697− let redo_snapshot = self.snapshot("redo point");
1698− match self.restore(inv) {
1699− Ok(()) => {
1700− self.redo.push(redo_snapshot);
1701− self.clamp_cursor();
1702− self.say("undone");
1703− }
1704− Err(e) => self.say(format!("undo failed: {}", e)),
1705− }
1706− } else {
1719+ self.settle_undo();
1720+ let Some(inv) = self.undo.pop() else {
17071721 self.say("nothing to undo");
1722+ return;
1723+ };
1724+ self.mark_self_write();
1725+ match inv.undo(&mut self.vault) {
1726+ Ok(()) => {
1727+ self.say(format!("undone: {}", inv.description));
1728+ self.redo.push(inv);
1729+ self.clamp_cursor();
1730+ }
1731+ Err(e) => {
1732+ // refused: an external change since; the entry stays
1733+ self.say(format!("undo refused: {}", e));
1734+ self.undo.push(inv);
1735+ }
17081736 }
17091737 }
17101738
17111739 fn act_redo(&mut self) {
1712− if let Some(inv) = self.redo.pop() {
1713− let undo_snapshot = self.snapshot("undo point");
1714− match self.restore(inv) {
1715− Ok(()) => {
1716− self.undo.push(undo_snapshot);
1717− self.clamp_cursor();
1718− self.say("redone");
1719− }
1720− Err(e) => self.say(format!("redo failed: {}", e)),
1721− }
1722− } else {
1740+ self.settle_undo();
1741+ let Some(inv) = self.redo.pop() else {
17231742 self.say("nothing to redo");
1743+ return;
1744+ };
1745+ self.mark_self_write();
1746+ match inv.redo(&mut self.vault) {
1747+ Ok(()) => {
1748+ self.say(format!("redone: {}", inv.description));
1749+ self.undo.push(inv);
1750+ self.clamp_cursor();
1751+ }
1752+ Err(e) => {
1753+ self.say(format!("redo refused: {}", e));
1754+ self.redo.push(inv);
1755+ }
17241756 }
17251757 }
17261758
1759+ /// Start an op-log entry: remember the files as they are before a verb
1760+ /// runs. The entry is settled after the key or click that ran it.
17271761 fn push_undo(&mut self, desc: &str) {
1728− let snap = self.snapshot(desc);
1729− self.record_undo(snap);
1730− }
1731−
1732− /// Record a snapshot taken before a mutation as one op-log entry.
1733− fn record_undo(&mut self, snap: ops::Inverse) {
1734− self.undo.push(snap);
1735− self.redo.clear();
1762+ self.settle_undo();
1763+ self.pending_undo = Some(ops::Snapshot::take(&self.vault, desc));
17361764 self.mark_self_write();
17371765 }
17381766
1739− /// Bring the vault back to a snapshot: rewrite its files and remove
1740− /// files created since it was taken (new blocks), so undo of a
1741− /// file-creating op leaves no orphan (§10.11).
1742− fn restore(&mut self, mut inv: ops::Inverse) -> std::io::Result<()> {
1743− for f in &self.vault.tree.files {
1744− if !inv.files.iter().any(|(p, _)| *p == f.path) {
1745− inv.files.push((f.path.clone(), None));
1746− }
1767+ /// Turn the pending snapshot into an op-log entry holding exactly the
1768+ /// files the verb changed (§10.11); a verb that changed nothing leaves
1769+ /// no entry and keeps the redo stack.
1770+ fn settle_undo(&mut self) {
1771+ if let Some(snap) = self.pending_undo.take() {
1772+ self.record_undo(snap);
17471773 }
1748− self.mark_self_write();
1749− inv.apply(&mut self.vault)
17501774 }
17511775
1752− fn snapshot(&self, desc: &str) -> ops::Inverse {
1753− ops::Inverse {
1754− files: self
1755− .vault
1756− .tree
1757− .files
1758− .iter()
1759− .map(|f| (f.path.clone(), Some(f.text.clone())))
1760− .collect(),
1761− description: desc.into(),
1776+ fn record_undo(&mut self, snap: ops::Snapshot) {
1777+ if let Some(inv) = ops::Inverse::since(snap, &self.vault) {
1778+ self.undo.push(inv);
1779+ self.redo.clear();
1780+ self.mark_self_write();
17621781 }
17631782 }
17641783
@@ -2771,6 +2790,16 @@ impl App {
27712790 }
27722791}
27732792
2793+/// A status message, noting when the ordering rule placed the node other
2794+/// than asked (§3.1: items before sections).
2795+fn with_rule_note(what: &str, moved: bool) -> String {
2796+ if moved {
2797+ format!("{} — placed at the item/section boundary (§3.1)", what)
2798+ } else {
2799+ what.to_string()
2800+ }
2801+}
2802+
27742803fn style_markdown_line(l: &str, vault: &Vault, _ctx: NRef) -> Line<'static> {
27752804 let trimmed = l.trim_start();
27762805 if trimmed.starts_with('#') {