Commit Graph
11 Commits
Author SHA1 Message Date
Pouzor 761a1a526c feat(docs): reach a document's history, and see what links to it
Two things the documentation section stored and never showed.

**History.** Up to 50 revisions per document have been written since the
section shipped — on every save, restore, regenerate, scaffold and the notes
migration — and nothing reached them. RegenerateDocModal went as far as
telling the user the old body "is in its history", which was true and useless:
there was no way there. The header now carries a history button; the rail lists
each version by what caused it, selecting one replaces the body in place, and
Changes turns it into a line diff against the current body. Restore snapshots
the body it replaces first, so a restore is itself undoable.

A revision's body is fetched only when it is opened — the list carries a size,
not the text, which is what RevisionSummary was shaped for.

The diff is a plain LCS over lines after the common head and tail are trimmed,
with unchanged runs collapsed to gaps: no library, and a pathological pair of
long unrelated bodies degrades to "replaced wholesale" rather than building a
quarter-million-cell matrix.

**Backlinks.** A wiki-link only says where it goes. Every document now lists
what points at it, with the line the link was written on and the label it was
written as; repeated links from one document collapse into a count.

The inversion is answered by the server, because the browser holds no body but
the open one — the list endpoint is metadata-only so the tree can badge without
downloading the space. doc_backlinks.py therefore mirrors the resolution rules
in wikilinks.ts, the way the generator's service_url mirrors serviceUrl.ts, and
its tests pin them. The inventory is loaded only when a body actually carries a
[[device:…]].

The client-side backlinkIndex goes with it. It was written, tested and never
rendered; keeping a second copy of the resolution rules on the side that cannot
see every body is only drift.

ha-relevant: no
2026-09-07 15:22:23 +02:00
Pouzor 94f42cf9c5 feat(docs): edit a document's tags, and say they exist
Group by Tag has always been there, and every document was Untagged: tags come
from the frontmatter `tags:` key, nothing but hand-typed YAML ever set one, and
no generated document mentioned the key. Both templates did pass `tags: []` —
`render_frontmatter` dropped every empty value on the way out, so the one place
a user could have learned tags exist never printed. An empty list prints now. An
empty string still does not: `owner:` teaches nothing the template already says.

The chips under the title become the editor. Each carries a cross, and a `+ Tag`
chip opens a field that takes a whole list — "web, prod" is two tags — and
refuses one the document already carries whatever its case. It writes straight
through like starring: a chip clicked off is not a draft of the document.

The body owns tags, the column caches them, so the store rewrites the block and
saves the body rather than patching a field the next body save would overwrite.
`withFrontmatter` would have done it in one line and round-tripped everything
else through the YAML dumper — `created: 2026-09-07` comes back a full timestamp,
because that is what the date parsed to. So `withTags` replaces that one entry,
inline or block list, and leaves the other lines byte for byte as written.

`test_frontmatter_omits_empty_values` asserted the dropping this changes; it is
renamed and now asserts the empty list survives.

ha-relevant: yes
2026-09-07 10:38:24 +02:00
Pouzor f0b5175155 fix(docs): stop the frontmatter regex going quadratic on an open block
CodeQL, high: a polynomial regular expression on uncontrolled data. Both
frontmatter parsers fenced the block with `\s*`, and `\s` matches the newline
the fence line ends with — so the closing alternative was reachable two ways
and every added line multiplied the backtracking. The pathological input is
a body that opens `---` and never closes it, which is what a document looks
like for as long as the user is typing that block: 20k lines took 2.4s, and
the body is parsed on every save.

Fencing on `[ \t]*` instead leaves one way to reach the newline and the same
input takes no measurable time. A trailing space on the fence still parses;
what is no longer accepted is a fence followed by blank lines before the YAML,
which nothing writes.

Both copies, and a timing regression test each side.

ha-relevant: yes
2026-09-07 10:38:24 +02:00
Pouzor 6ba3d1a38f fix(docs): give the document editor a working undo
Ctrl+Z in the editor undid the canvas. The global handler computed whether the
event came from a field and then used that only to gate `?`, so undo fired
wherever the caret was — the document body, a node label, the filter box — and
took the canvas back a step while swallowing the keystroke the field wanted.
It now yields to whatever is being typed in. Which exposed a branch that had
never run: both undo and redo compared a lowercase `z`, and a shifted `z`
arrives as `Z`, so Ctrl+Shift+Z did nothing at all. It redoes now.

Handing the keystroke back is not enough on its own. The textarea is
controlled and half its edits are programmatic — the toolbar and the slash
menu rewrite the body around the caret — and setting a value that way is
exactly what empties the browser's own undo stack. So the editor keeps its
own: snapshots of body and selection, typing folded into one entry while it
keeps coming inside 500ms, an insertion always a step of its own, the redo
branch dropped once editing carries on past an undo, and the stack bounded.

ha-relevant: yes
2026-09-07 10:38:24 +02:00
Pouzor 56d83668d4 fix(docs): follow the title the body gives, in the row and in the tree
Editing `title:` in the frontmatter left the tree showing the name the
document was created with. `_apply_body` refreshed the frontmatter and tags
caches from the body and stopped there, though that line is the only place a
document is ever named — `rename` exists in the store and nothing in the UI
calls it. So the title column follows the body too, on every save and on a
restore, which brings back the name that body carried. It reslugs with it,
ignores a blank or non-string value, and an explicit title in the same request
still wins.

That alone would not have moved the row the user was looking at:
`buildDeviceTree` labelled a device leaf from the inventory whatever the
document said. Once a device is documented the row is that document, so it
carries the document's title, and falls back to the device's label only while
there is nothing to open.

ha-relevant: yes
2026-09-07 10:38:24 +02:00
Pouzor 7c116571e3 feat(canvas): open a node's document from its detail panel
A selected node linked to a device could reach its inventory row and nothing
else, though the same device now has a document too. The panel gains a second
link beside the first, and the store gains the action behind it: the canvas
knows a device id and nothing about documents, so `openForDevice` fetches the
listing if the section was never opened, opens the document the device has,
and writes one from its facts when it has none — the same thing picking that
device in the tree does, rather than switching to an empty section.

Which exposed what the inventory link had always done: open the modal, scroll
the card into view, and stop. "Open in inventory" means show me that device,
so the highlight now opens its detail modal as well. The id it honoured is
remembered, so a later filter change cannot reopen a detail the user closed,
and picker mode is left alone — it wants a device back, not a dialog stacked
on the one it was opened from. The scroll moved after the open and is called
optionally: a browser without `scrollIntoView` should not cost the caller the
modal it asked for.

Both links are backend-only, like the rest of the scan and document surface.

ha-relevant: yes
2026-09-07 10:38:24 +02:00
Pouzor ed49c53969 feat(docs): put the Library first and let a drag file a document
The Library was the section below a device list hundreds of rows long, and
its two create buttons sat in a footer far from it. It is also the only part
of the tree the user owns — Devices is a pivot recomputed from the inventory,
so nothing there is filed by hand. So the Library moves to the top of the
pane, its heading becomes the collapse toggle, and the New document and New
folder buttons move up beside it. The footer keeps the coverage counter.

Filing was then the missing half: `parent_id` has always been settable and
`move` has always been in the store, with no UI reaching either. A Library row
now drags — by its icon, not the row, because a row-wide drag fights the click
that opens a document and the double-click that opens a folder. Folders take
the drop at any depth; the space around the list is a drop zone of its own,
which is the only way back out of a folder.

Every drop asks first, naming both ends, and the target folder is expanded
afterwards so the document does not vanish into a closed one.

`canMoveInto` decides the four rules the server already enforces — tree kinds
only, folders only as parents, no move to the parent it already has, no folder
into its own subtree — so an invalid drag refuses rather than asking, moving,
and failing on the response. `isDescendant` moves to `tree.ts` with it and is
re-exported from the store.

ha-relevant: yes
2026-09-07 10:38:24 +02:00
Pouzor 185d9bcacb feat(docs): regenerate a document, and only warn about real drift
A document is generated once and then owned by the user, which leaves no way
back when the generated header is what you actually wanted. The viewer gains a
Regenerate button behind a confirmation that says plainly the written body is
erased, names what it will be rebuilt from — the device's current facts, or the
page's template — and points at the one consolation: the replaced body is
snapshotted into the history first, so a regenerate is undoable from there.

`POST /documents/{id}/regenerate` re-runs the scaffolder, clears `edited_at`
so the document counts as "only the generated header" again, and refreshes
`facts_snapshot`. A folder has no generated body and is refused. The store
drops the localStorage draft with the body, so a stale edit cannot be saved
back over the new one.

Which surfaced the drift banner being wrong: it read "The device has changed"
on every device document, including one regenerated a second earlier. The
comparison ran in the UI, field by field, against the inventory wire shape —
and `facts_snapshot` is the server's shape, not that one. `properties` is a
flat `{key: value}` map in the snapshot and a `NodeProperty[]` on the wire, so
`(current ?? null) !== (value ?? null)` compared two objects by reference and
was true even when both were empty; `label` and `type` are stored through the
`device_name` / `device_type` fallbacks, so a device with no curated label
never matched its own snapshot either.

So the rule moves to the server, which is the only side that knows the shape:
`drifted` on the document summary, `doc.facts_snapshot != facts_snapshot(row)`,
the same comparison the coverage count already made. The listing resolves it
for every document in one query, which finally lights the tree's `drifted`
badge — `buildDeviceTree` has always taken the set and nothing ever passed it.

ha-relevant: yes
2026-09-07 10:38:24 +02:00
Pouzor d0f0fe0322 fix(docs): stop the tree clipping the last letter of every name
A device with no document yet is drawn in italic, and italic glyphs lean past
the advance width — the width a shrink-wrapped box gets. `truncate` puts
`overflow: hidden` on that box, so the overhang was cut off and every
undocumented name lost the tail of its last letter: "bazarr" and "glpi" ended
mid-stroke.

Padding on the label gives the overhang somewhere to live. Names that really
are too long still ellipsise, and now carry a `title` so they can be read on
hover instead of just being cut. Group headers get the same treatment.

The padding itself is invisible to a test; the title is not, and it is what
makes an over-long name readable at all, so that is what the new DocTree tests
pin — along with the italic rule that caused the clipping in the first place.

ha-relevant: yes
2026-09-07 10:38:24 +02:00
Pouzor bf0d8077bb fix(docs): open the insert menu at the caret, and insert where the slash is
The menu was `absolute bottom-4 left-4` — pinned to the bottom-left of the
source pane, not to the caret. Typing `/` halfway up a long document opened it
at the bottom of the screen, and as the query narrowed nothing the list still
grew downward off the viewport.

It is measured against the caret now. A textarea exposes no caret geometry, so
`caret.ts` uses the mirror technique: a hidden div carrying the same text
metrics and box, filled to the caret, with a marker span read back for its
position. `placeMenu` then puts the popup under that line, flips it above when
it would run past the bottom, and clamps it into the pane when it fits neither
way — so it can no longer end up off-screen whatever the list length or the
pane height. That maths is a pure function with its own tests; the measuring
itself is not testable, because jsdom lays nothing out and reports every offset
as 0.

The same trace turned up a worse bug behind it. `insertAtCursor` derived its
anchor from `el.selectionEnd` at the moment the command was clicked, then
walked back `slashQuery.length + 1` to swallow the "/query". But the query is
typed into the menu's own field and never enters the document, and opening the
menu moves focus out of the textarea — freezing its selection wherever it sat,
and widening the gap with every keystroke. Inserting `/task` on a line after
"Intro" produced "Intr- [ ] " with no query, and ate the line outright with
one: the user's text was deleted, silently.

The index of the `/` is recorded when the menu opens and passed to the insert,
which consumes exactly that one character. `runCommand` reads it before its
await, so the reset effect cannot clear it out from under the insert.

ha-relevant: yes
2026-09-07 10:38:24 +02:00
Pouzor e6d338be13 feat(docs): a real document space for the homelab
The only writing surface was `device_inventory.notes`, a single TEXT column
rendered as plain preformatted text. No structure, no links, no search, and
nowhere to write anything that is not about one device.

This adds markdown documents, a navigable tree and a Documentation section in
the left rail — the first app-level view that is not a canvas.

Model — three tables. `documents` (kind: device / node / design / page /
folder), `document_revisions` capped at 50 per document, and a `documents_fts`
FTS5 index. A document attaches to the *inventory row*, not to a node, so one
device reads the same on every canvas. Every link is SET NULL and the title is
denormalized: deleting a device or a canvas must never destroy what the user
wrote. Foreign keys are off in this SQLite setup, so `doc_links.unlink_documents`
clears those columns by hand in the nodes, scan and designs delete paths.

FTS5 is optional. The SQLite shipped in the LXC and Docker images may not carry
it — `doc_search` probes once at boot and falls back to LIKE, and the search
response names the engine it used so the UI can drop snippet highlighting.

A device document is scaffolded once, from the scan facts: identity table,
hardware, services (one section per service), network, operations,
troubleshooting, dependencies, changelog. Then it belongs to the user and
nothing rewrites it. The old `notes` are appended verbatim under `## Notes`
behind a provenance comment, and `device_inventory.notes` is left untouched —
HACS, the YAML export, MCP and canvas search still read it.

Drift is surfaced, never corrected: `facts_snapshot` powers a banner, and
`GET /documents/blocks` re-renders one generated block from current facts for
the editor's slash commands. `review_every` plus `reviewed_at` produce a
"needs attention" bucket, because homelab docs die of staleness.

The Devices tree is pivoted client-side from the already-loaded inventory —
zone, group, type, physical/virtual, subnet, rack, vendor, discovery source,
status, tag, A-Z — so re-pivoting costs no request. The Library beside it is
real folders the user makes. The Canvas pivot is scoped to the loaded canvas:
`NodeData` carries no `design_id`, so naming every canvas a device appears on
would be a guess.

Saving is explicit, per the product rule. The dirty body is mirrored to
localStorage and offered back on reopen; a draft whose base no longer matches
the document is discarded rather than replayed.

react-markdown is loaded behind React.lazy, so the canvas path does not pay for
it: the main bundle is unchanged and the section brings its own 199 kB chunk.
Raw HTML is not rendered, which removes the XSS surface and means no sanitizer.

Export/import, a print view, an aggregated open-tasks view and images inside a
document are deliberately not in this lot. MCP tools are lot 2.

ha-relevant: yes
2026-09-07 10:38:24 +02:00