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
134 lines
5.0 KiB
Python
134 lines
5.0 KiB
Python
"""Slugs, frontmatter parsing, and the two tree guards."""
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.db.models import Document
|
|
from app.services import doc_tree
|
|
|
|
|
|
def _doc(**kwargs) -> Document:
|
|
base = dict(kind="folder", title="T", slug="t", body="")
|
|
base.update(kwargs)
|
|
return Document(**base)
|
|
|
|
|
|
# ── slugs ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_slugify_lowercases_and_collapses_punctuation():
|
|
assert doc_tree.slugify("NAS-01 / main storage!") == "nas-01-main-storage"
|
|
|
|
|
|
def test_slugify_of_nothing_is_untitled():
|
|
assert doc_tree.slugify("") == "untitled"
|
|
assert doc_tree.slugify(" ") == "untitled"
|
|
assert doc_tree.slugify("!!!") == "untitled"
|
|
|
|
|
|
def test_slugify_truncates_a_long_title():
|
|
assert len(doc_tree.slugify("x" * 200)) == 80
|
|
|
|
|
|
async def test_unique_slug_suffixes_a_taken_sibling(db_session: AsyncSession):
|
|
db_session.add(_doc(title="Network", slug="network"))
|
|
await db_session.flush()
|
|
assert await doc_tree.unique_slug(db_session, "Network", parent_id=None) == "network-2"
|
|
|
|
|
|
async def test_unique_slug_keeps_counting_past_the_second(db_session: AsyncSession):
|
|
db_session.add(_doc(slug="network"))
|
|
db_session.add(_doc(slug="network-2"))
|
|
await db_session.flush()
|
|
assert await doc_tree.unique_slug(db_session, "Network", parent_id=None) == "network-3"
|
|
|
|
|
|
async def test_unique_slug_only_looks_at_siblings(db_session: AsyncSession):
|
|
parent = _doc(title="Folder", slug="folder")
|
|
db_session.add(parent)
|
|
await db_session.flush()
|
|
db_session.add(_doc(slug="network", parent_id=parent.id))
|
|
await db_session.flush()
|
|
# Same slug, different parent — no clash.
|
|
assert await doc_tree.unique_slug(db_session, "Network", parent_id=None) == "network"
|
|
|
|
|
|
async def test_unique_slug_ignores_the_document_being_renamed(db_session: AsyncSession):
|
|
doc = _doc(title="Network", slug="network")
|
|
db_session.add(doc)
|
|
await db_session.flush()
|
|
assert await doc_tree.unique_slug(db_session, "Network", parent_id=None, exclude_id=doc.id) == "network"
|
|
|
|
|
|
# ── frontmatter ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_parse_frontmatter_reads_the_leading_block():
|
|
body = "---\ntitle: NAS\ntags: [a, b]\n---\n\n# NAS\n"
|
|
assert doc_tree.parse_frontmatter(body) == {"title": "NAS", "tags": ["a", "b"]}
|
|
|
|
|
|
def test_parse_frontmatter_ignores_a_block_that_is_not_first():
|
|
assert doc_tree.parse_frontmatter("# Title\n\n---\ntitle: NAS\n---\n") == {}
|
|
|
|
|
|
def test_parse_frontmatter_of_malformed_yaml_is_empty_not_an_error():
|
|
# The user is mid-edit; the cache goes stale, the save does not fail.
|
|
assert doc_tree.parse_frontmatter("---\ntitle: [unclosed\n---\nbody") == {}
|
|
|
|
|
|
def test_parse_frontmatter_of_a_non_mapping_is_empty():
|
|
assert doc_tree.parse_frontmatter("---\n- a\n- b\n---\n") == {}
|
|
|
|
|
|
def test_parse_frontmatter_of_an_empty_body_is_empty():
|
|
assert doc_tree.parse_frontmatter("") == {}
|
|
|
|
|
|
def test_tags_accepts_a_list_or_a_comma_string():
|
|
assert doc_tree.tags_from({"tags": ["a", " b "]}) == ["a", "b"]
|
|
assert doc_tree.tags_from({"tags": "a, b"}) == ["a", "b"]
|
|
assert doc_tree.tags_from({"tags": 7}) == []
|
|
assert doc_tree.tags_from({}) == []
|
|
|
|
|
|
# ── tree guards ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
async def _chain(db_session: AsyncSession, depth: int) -> list[Document]:
|
|
docs: list[Document] = []
|
|
parent_id = None
|
|
for i in range(depth):
|
|
doc = _doc(title=f"L{i}", slug=f"l{i}", parent_id=parent_id)
|
|
db_session.add(doc)
|
|
await db_session.flush()
|
|
docs.append(doc)
|
|
parent_id = doc.id
|
|
return docs
|
|
|
|
|
|
async def test_is_ancestor_walks_the_whole_chain(db_session: AsyncSession):
|
|
a, b, c = await _chain(db_session, 3)
|
|
assert await doc_tree.is_ancestor(db_session, a.id, c.id)
|
|
assert not await doc_tree.is_ancestor(db_session, c.id, a.id)
|
|
|
|
|
|
async def test_a_document_is_its_own_ancestor(db_session: AsyncSession):
|
|
(a,) = await _chain(db_session, 1)
|
|
assert await doc_tree.is_ancestor(db_session, a.id, a.id)
|
|
|
|
|
|
async def test_subtree_ids_collects_every_descendant(db_session: AsyncSession):
|
|
a, b, c = await _chain(db_session, 3)
|
|
sibling = _doc(title="S", slug="s", parent_id=a.id)
|
|
db_session.add(sibling)
|
|
await db_session.flush()
|
|
ids = await doc_tree.subtree_ids(db_session, a.id)
|
|
assert set(ids) == {a.id, b.id, c.id, sibling.id}
|
|
# Root first, so a caller deleting in reverse removes leaves first.
|
|
assert ids[0] == a.id
|
|
|
|
|
|
async def test_subtree_ids_of_a_leaf_is_just_itself(db_session: AsyncSession):
|
|
(a,) = await _chain(db_session, 1)
|
|
assert await doc_tree.subtree_ids(db_session, a.id) == [a.id]
|