# v1.2.4 — wiki-links · navigation pack · Scrivener import · 22 new features

The 1.2.4 cycle is the largest single-release feature batch
since 1.1. Five broad themes:

1. **Knowledge graph** — wiki-links + backlinks as metadata
   (Ctrl+V A / I / L / K), AI-inference integration.
2. **Navigation pack** — fuzzy paragraph picker (Ctrl+V P),
   bookmarks (Ctrl+V B / M), AI prompt history, prefix-ranked
   slash-command picker.
3. **Snapshot diff + safety net** — F6 V diff view, pre-restore
   safety snapshot on Enter.
4. **Goals & tracking deepening** — per-paragraph word-count
   targets + auto-promote, active-time tracking, per-book bar
   chart, `--status=<floor>` export.
5. **Single-binary Scrivener import** — pure-Rust `.scriv`
   ingestion with RTF→Typst conversion.

Plus a long tail of polish: F-key migration into the bindings
table, save-as picker, theme persistence to HJSON, five new
Bund hooks, `ink.fs.*` words, tree multi-select with bulk T/O,
`inkhaven stats`, startup splash, Windows CI re-enabled.

## Headline changes

### Wiki-links and backlinks (metadata-only)

Four chords add typed references between paragraphs as
metadata — never embedded in the Typst source, never leaking
into PDF / EPUB:

| Chord     | Direction | What |
|-----------|-----------|------|
| `Ctrl+V A` | Outgoing  | Add a link FROM the open paragraph TO a tree-picked one. |
| `Ctrl+V I` | Incoming  | Add a link FROM a tree-picked paragraph TO the open one. |
| `Ctrl+V L` | Outgoing  | Picker listing the open paragraph's outgoing links. Enter opens; D removes. |
| `Ctrl+V K` | Incoming  | Backlinks picker — paragraphs that link TO the open one. Enter opens; D removes the source's outgoing link. |

Three guards fire at add time: self-link rejection, duplicate
detection, and cycle detection (`"You can not create circular
references"` — DFS over the candidate target's outgoing
closure). The cycle check is what makes outgoing-link metadata
safe to walk for AI inference.

AI integration: when scope is Paragraph (F9 cycles), inkhaven
appends each linked paragraph to the prompt context wrapped in
`── Linked paragraph: <title> ──` delimiters.

Status-bar widget appends `links N` when ≥ 1 outgoing link.

Storage: `Node.linked_paragraphs: Vec<Uuid>`, serde-default
empty. Stale targets are filtered from pickers silently.

Tutorial: [`19-wiki-links.md`](../Tutorials/19-wiki-links.md).

### Snapshot diff + pre-restore safety snapshot

In the F6 snapshot picker:

- **`V`** on a row — opens a side-by-side line diff of the
  snapshot vs the live buffer. Powered by the `similar` crate's
  Myers algorithm; consecutive Delete→Insert pairs fuse into a
  single Changed row (yellow `~`) so a one-line rewrite renders
  as one row instead of two. Esc returns to the picker via a
  `return_to: Box<Modal>` stash.
- **`Enter`** — restores the snapshot, but **first** takes a
  fresh snapshot of the live buffer. The pre-restore safety
  snapshot lands in the same history list. Failing to create
  the safety snapshot aborts the load — the whole point is
  data safety.

Recovery flow: hit F6 again, the safety snapshot is at the
top, Enter restores it. The "restore you didn't want" is
itself now a safety snapshot — the chain stays intact
indefinitely.

Tutorial: [`20-snapshot-diff.md`](../Tutorials/20-snapshot-diff.md).

### Navigation pack

- **`Ctrl+V P` fuzzy picker** — type-to-filter modal over
  every paragraph in the project (system books filtered).
  Three-tier scoring: title-starts-with > title-contains >
  slug-contains. Enter opens via the same `open_search_result`
  flow as the search overlay (autosaves previous buffer, tree
  cursor follows).
- **`Ctrl+V B` / `Ctrl+V M`** — toggle bookmark on open
  paragraph; bookmark picker (`★`-prefixed list, title-sorted,
  D removes). Bookmarks persist (`Node.bookmark: bool`).
- **AI prompt Up-arrow history** — capped at 500, dedupes
  consecutive sends, any edit clears the cursor. The
  shell-style behaviour whose absence in 1.2.3 was an
  oversight.
- **Slash-command prefix ranking** — `/sum` now ranks
  `summarize` (name prefix) above any prompt that happens to
  contain `sum` mid-word. Tier scoring: name-starts > desc
  word-starts > anywhere-substring.

Tutorial: [`21-navigation.md`](../Tutorials/21-navigation.md).

### Per-paragraph word-count goals + auto-promote

- **`Ctrl+V T`** opens a goal-target modal for the open
  paragraph (empty input clears). Persisted as
  `Node.target_words: Option<i32>`.
- **Auto-promote** — when `goals.auto_promote_on_target` is
  set (default `false`) and a save crosses the target, the
  paragraph's status promotes one rung up the ladder. Records
  in `Node.target_hit_at_status` to fire exactly once per
  (paragraph, status) pair — re-editing past the target after
  reverting status won't auto-promote again unless you change
  the target.
- **Editor footer** shows `title_breadcrumb · 412/500 · ▏▏▏▏▏░░`
  in the goal row when a target is set. Title path, not
  slug-path.
- **Tree pane** keeps a 1-char status pip; the full gauge
  moved to the editor footer (long titles were clipping in
  the tree).

Five new Bund hooks fire alongside:
`hook.on_goal_hit`, `hook.on_streak_break`,
`hook.on_status_promoted`, `hook.on_assemble`, `hook.on_take`.

### Active-time tracking

`writing_events` adds an `active_seconds_in_range` aggregate
computed with a `LAG` window + `SUM(LEAST(gap_seconds, 300))`.
Translation: each save→save gap is summed but capped at 5
minutes per gap, so a half-hour AFK break doesn't count.

Status-bar surfaces `45m` of today's active time, `goals.active_minutes_daily`
gates an explicit per-day target.

### Per-book bar chart

The Ctrl+V G progress modal grows a per-book bar chart pane
(ratatui's built-in `BarChart`, license-clean). Each user
book's per-day word delta over the trailing 14 days. Stays
within the same modal — `r` refreshes everything.

### `inkhaven export --status=<floor>`

```sh
inkhaven export tex --status=ready -o final.tex
```

Filters to paragraphs whose status sits at or above the rung.
Lowercased ladder names — `napkin`, `first`, `second`,
`third`, `final`, `ready`. Unset = include everything (1.2.3
behaviour). System books are still excluded.

### Save-as picker for Ctrl+V markdown extraction

Ctrl+V 1 / 2 / 3 now open a `Modal::SaveMarkdown` with the
default path pre-filled. Bare Enter writes there (mimics
1.2.3 behaviour); editing the path writes to the picked
location. F2-style filename picker but inlined.

### Ctrl+V is a first-class meta key, F-keys in keybind table

- `view_prefix: "Ctrl+V"` in `inkhaven.hjson`'s `keys` block —
  rebindable like the Bund prefix.
- 12 view-sub actions appear in HJSON `keys.bindings` under
  `view_sub` and accept lambda binds via `ink.key.bind_view_sub`.
- 10 top-level F-keys (F1 through F10 + Shift-F variants)
  moved from hardcoded matches into `Layer::TopLevel` of the
  `KeyBindings` table. HJSON overlays accept single-token
  chords (`"f7": "view.add_link"` rebinds F7 to outgoing-link
  add).
- `Ctrl+B H` quick reference is updated to surface View and
  TopLevel layers; user lambdas appear with their registered
  name.

### Theme persistence to HJSON

Live theme edits via `ink.theme.set` / Ctrl+B T cycling now
persist to `inkhaven.hjson`. New helper `set_key_in_hjson_block`
edits the existing `theme: { ... }` block in place, preserving
comments and order. Persisting falls back to a status-bar
warning if the file is read-only.

### Bund stdlib gaps closed

| Word | What |
|------|------|
| `ink.editor.replace_all ( old new -- count )` | In-buffer find/replace. |
| `ink.search.load ( query -- )` | Run semantic search + load first hit into the editor. |
| `ink.ai.send_blocking ( prompt -- response )` | Synchronous AI send (blocks the script; UI keeps repainting). |
| `ink.ai.poll ( -- response-or-empty )` | Poll the async AI response slot — empty string when none ready. |
| `ink.paragraph.set_target ( words -- )` / `ink.paragraph.target ( -- words )` | Per-paragraph word target on the current paragraph. |
| `ink.fs.read ( path -- string )` | Read a file. Default-allowed (`fs_read`). |
| `ink.fs.write ( path content -- )` | Write a file. Default-**denied** (`fs_write` — `policy.allow.fs_write` must be true). |

`ink.fs.write` is default-denied because a script that can
write anywhere on the user's disk is a different security
class from one that can edit the open buffer. Opt in
explicitly in `bund.policy.allow.fs_write = true`.

### Tree multi-select + bulk T/O chords

- **`Space`** in the tree pane marks/unmarks the cursor row.
  Status bar shows `marked N`. **`Esc`** clears all marks.
- **`T`** cycles node type — `paragraph → json → script`. With
  marks, applies to every marked leaf; without marks, to the
  cursor row only.
- **`O`** cycles status — `napkin → first → second → third →
  final → ready → napkin` (configurable). With marks, bulk
  promotes/demotes every marked paragraph.

`Ctrl+B I` (reindex) and `Del` (delete) now also read the
mark set when present. `Ctrl+B R` (rename) is intentionally
single-row only — renames are per-row by design.

Tutorial: [`22-tree-multiselect.md`](../Tutorials/22-tree-multiselect.md).

### Scrivener importer (single-binary)

```sh
inkhaven import-scrivener ~/Scrivener/Tides.scriv --dry-run
inkhaven import-scrivener ~/Scrivener/Tides.scriv
```

Pure-Rust pipeline:

- **`quick-xml = 0.36`** parses `.scriv/Files/Data/<id>/binder.xml`
  into a `BinderItem { uuid, kind, title, children }` tree.
  Modern Scrivener UUIDs are passed through; legacy integer
  IDs get a deterministic `Uuid::new_v5` hash so re-imports
  produce stable UUIDs.
- **`rtf-parser-tt = 0.5`** parses each Text document's RTF
  body. The walker emits Typst markup: `**bold**`, `_italic_`,
  paragraph breaks. Typst meta chars are escaped. Tables /
  footnotes / images are dropped with a per-paragraph error
  entry; the prose survives.
- **Mapping rules**: Draft → Book, depth-1 Folder → Chapter,
  deeper Folder → Subchapter, Text → Paragraph. Outside-Draft
  top-level folders route by title: Places / Locations /
  Settings → `Places`; Characters / Cast → `Characters`;
  Notes / Research → `Notes` (merged); Artefacts / Artifacts /
  Items → `Artefacts`. Everything else is `SkipSubtree`.
- **Flags**: `--draft-as-book <title>`, `--skip-research`,
  `--dry-run`. Per-item error containment — one bad RTF blob
  doesn't abort the whole import.

Tutorial: [`23-scrivener-import.md`](../Tutorials/23-scrivener-import.md).

### Secondary-editor autosave

When similar-paragraph mode (Ctrl+V S) opens the secondary
editor, both buffers now autosave on idle and on Ctrl+V S
exit. The secondary slot stops being a write-only black hole.
Implementation uses `Option::take()` to avoid aliasing
borrows in `tick_autosave`.

### Per-paragraph file rename follows title rename

`Store::rename_node` for paragraphs now also renames the `.typ`
file on disk. Before 1.2.4, a paragraph renamed in the TUI
stayed `untitled.typ` until manually reindexed; now the file
name follows the title slug immediately.

### `inkhaven stats` CLI

```sh
inkhaven stats --book-name "Tides"
```

Per-paragraph table: title, slug, status, word count, target
percentage, last modified. Same `--book-name` semantics as
`export`. System books excluded.

### Startup splash with project pulse

Default-on (`editor.startup_splash: true`). At launch shows a
7-second floating pane:

```
inkhaven 1.2.4
  Today        1,247 / 1,500 words  (▏▏▏▏▏░░)
  Active       45m / 60m
  Streak       3 days
  Books        2 user · 9 system · 412 paragraphs
```

Auto-closes after 7 seconds; any key dismisses early. Set
`editor.startup_splash: false` to skip.

### Windows CI re-enabled

The release matrix's Windows entry, commented out since the
fontconfig build failure in 1.2.1, is back. New "Install
Windows build dependencies" step uses vcpkg to install
`fontconfig:x64-windows-static-md`, exports `PKG_CONFIG_PATH` +
`VCPKGRS_TRIPLET` to `$GITHUB_ENV`. `continue-on-error: true`
specifically for the Windows target so a regression there
doesn't block Linux/macOS releases.

## Other changes

- `KeyBindings::view_prefix` field + `view_sub: Vec<BindingEntry>`
  + `top_level: Vec<BindingEntry>` + `resolve_view_sub` /
  `resolve_top_level` helpers.
- `Modal` enum grows `ParagraphTarget`, `Progress`,
  `SnapshotDiff`, `SaveMarkdown`, `LinkPicker`, `BacklinkPicker`,
  `BookmarkPicker`, `FuzzyParagraphPicker`.
- `LinkPickDirection { Outgoing, Incoming }` swap-on-commit
  pattern handles symmetric outgoing / incoming flows in one
  code path.
- `compute_line_diff` + `fuzzy_filter_entries` helpers in
  `tui/app.rs`.
- New CLI subcommands: `Stats`, `ImportScrivener`. `Export`
  gains `--status`.

## Migration notes

- **HJSON additions** — `goals.daily_active_minutes`,
  `goals.auto_promote_on_target`, `goals.status_ladder`,
  `keys.view_prefix`, `editor.startup_splash` all `#[serde(default)]`.
  Existing 1.2.3 configs continue working unchanged.
- **`fs.write` security model** — scripts that import data
  via `ink.fs.read` will still work out of the box. Scripts
  that *write* via `ink.fs.write` need the policy opt-in:
  `bund.policy.allow.fs_write: true`. Hard-fails noisily
  otherwise — the script reports the denial.
- **`secondary` autosave** — paragraphs opened into the
  secondary slot in 1.2.3 had to be saved manually before
  exiting similar-paragraph mode. In 1.2.4 they're autosaved
  on idle. If you were relying on the "drop changes by not
  saving" path: don't open similar-paragraph mode for
  exploratory edits, use snapshots instead.
- **Paragraph file rename** — `Store::rename_node` now
  modifies the `.typ` file name on disk for paragraphs.
  External tooling that referenced the old `slug.typ` path
  needs to find the file via the slug returned from the
  store, not by constructing the filename from the slug.
- **F-key rebinding semantics** — F-keys now route through
  `Layer::TopLevel` of the bindings table. An HJSON overlay
  `keys.bindings.f7: "view.add_link"` rebinds it. The old
  hardcoded F-key matches are gone — every F-key is a
  table-lookup, including F1 (RAG over Help) and F9 (cycle AI
  scope).

## Files added

- `src/scrivener/{mod,binder,rtf,mapping,import}.rs` — 5 new
  files for the importer (~1,200 LOC + ~400 LOC of tests).
- `src/cli/{stats,import_scrivener}.rs` — 2 new CLI shims.
- `src/scripting/stdlib/fs.rs` — `ink.fs.read` /
  `ink.fs.write` words.
- `Documentation/Tutorials/{19,20,21,22,23}-*.md` — 5 new
  tutorials.
- `Documentation/INKHAVEN_CHEAT_SHEET.typ` — printable two-
  column A4 cheat sheet (compile with `typst compile`).
- `Documentation/RELEASE_NOTES/1.2.4.md` — this file.

## Stats

- 22 distinct new user-facing features.
- 4 new crate dependencies (`similar`, `quick-xml`,
  `rtf-parser-tt`, plus `tylax` carried from 1.2.3).
- ~180 tests pass (≈ 20 new across snapshot diff, link
  guards, Scrivener importer, fuzzy ranking).
- Approximate diffstat: +5,800 LOC across src + Documentation.
