feat(docs): download one document, export the whole space as a zip

A document's body is already the whole markdown file, frontmatter included,
so the viewer's new Download button writes what it is holding — no round
trip. Export all does need the server: the tree carries summaries only.

The archive mirrors what the sidebar shows. Library folders become
directories with their own body as index.md; device, node and design
documents are placed by their link rather than by a parent, so each lands
in a directory named for its kind. Slugs are unique among siblings only,
so colliding paths are numbered rather than overwritten, and every segment
is sanitised — an exported title should not be able to write outside the
archive.

GET /documents/export is declared above /{document_id} so "export" is not
read as an id.

ha-relevant: no
This commit is contained in:
Pouzor
2026-09-09 02:56:16 +02:00
committed by Pouzor - Rémy Jardient
parent 783ca87acd
commit 8dba259796
10 changed files with 833 additions and 7 deletions
+38 -1
View File
@@ -17,7 +17,7 @@ import re
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -39,6 +39,7 @@ from app.schemas.documents import (
SearchResponse,
)
from app.services import doc_backlinks, doc_search
from app.services.doc_export import ExportDoc, build_zip
from app.services.doc_template import (
BLOCKS,
TEMPLATE_DEVICE,
@@ -349,6 +350,42 @@ async def generated_block(
return {"block": block, "markdown": render_block(block, device, **context)}
@router.get("/export")
async def export_documents(
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> Response:
"""Every document as a zip of `.md` files mirroring the tree.
Declared above `/{document_id}` so "export" is not read as an id.
"""
docs = (await db.execute(select(Document))).scalars().all()
archive = build_zip(
[
ExportDoc(
id=doc.id,
kind=doc.kind,
title=doc.title,
slug=doc.slug,
parent_id=doc.parent_id,
body=doc.body or "",
)
for doc in docs
]
)
filename = f"homelable-documentation-{_now():%Y%m%d}.zip"
return Response(
content=archive,
media_type="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
# The browser reads the name from the header, and a cross-origin
# fetch cannot see it unless it is exposed.
"Access-Control-Expose-Headers": "Content-Disposition",
},
)
@router.get("/{document_id}", response_model=DocumentResponse)
async def get_document(
document_id: str,
+119
View File
@@ -0,0 +1,119 @@
"""Export the documentation space as a zip of `.md` files.
`Document.body` is already the whole markdown file, YAML frontmatter included,
so exporting is a question of *where* each body lands, not what it contains —
nothing here rewrites a byte the user wrote.
The archive mirrors what the sidebar shows. The Library tree becomes real
directories, one per folder document; everything that is filed by a link rather
than by hand (a device, a piece of canvas furniture, a whole canvas) lands in a
flat directory named after its kind, because those are pivoted client-side and
have no single tree to mirror.
"""
from __future__ import annotations
import io
import re
import zipfile
from dataclasses import dataclass
# Kinds that are placed by their link instead of by `parent_id`, and the
# directory each one exports into.
LINKED_DIRS = {"device": "devices", "node": "nodes", "design": "designs"}
# A folder carries an index body of its own, and a directory cannot hold one —
# so it becomes a file inside the directory it opens.
FOLDER_INDEX = "index"
_UNSAFE = re.compile(r"[^a-z0-9._-]+")
@dataclass(frozen=True)
class ExportDoc:
"""The fields the layout needs, so the mapping stays testable without a DB."""
id: str
kind: str
title: str
slug: str
parent_id: str | None
body: str
def safe_segment(raw: str) -> str:
"""One path segment that cannot escape the archive or collide with a device.
Slugs come from `slugify` and are already tame, but a body can be exported
long after a title was renamed by hand, and `..` or a leading dot would be a
zip-slip waiting for whichever tool unpacks this.
"""
cleaned = _UNSAFE.sub("-", (raw or "").strip().lower()).strip("-.")
return cleaned or "untitled"
def _ancestors(doc: ExportDoc, by_id: dict[str, ExportDoc]) -> list[str]:
"""The folder segments above `doc`, outermost first.
A parent chain that loops — which the move guards prevent, but a hand-edited
database does not — stops at the first repeat rather than spinning.
"""
segments: list[str] = []
seen = {doc.id}
current = by_id.get(doc.parent_id or "")
while current is not None and current.id not in seen:
seen.add(current.id)
segments.append(safe_segment(current.slug or current.title))
current = by_id.get(current.parent_id or "")
segments.reverse()
return segments
def _path_for(doc: ExportDoc, by_id: dict[str, ExportDoc]) -> str:
name = safe_segment(doc.slug or doc.title)
directory = LINKED_DIRS.get(doc.kind)
if directory is not None:
return f"{directory}/{name}.md"
parts = _ancestors(doc, by_id)
if doc.kind == "folder":
# The folder's own body sits inside the directory it opens, so its
# children are siblings of it rather than of the folder file.
return "/".join([*parts, name, f"{FOLDER_INDEX}.md"])
return "/".join([*parts, f"{name}.md"])
def export_paths(docs: list[ExportDoc]) -> list[tuple[str, str]]:
"""`(path, body)` for every document, in a stable order and never colliding.
Slugs are only unique among siblings, and a linked document is a sibling of
the Library root while exporting into `devices/`, so two documents really can
want the same path. The second one gets a numbered name rather than
overwriting the first — a silently short export is the worst outcome here.
"""
by_id = {doc.id: doc for doc in docs}
taken: set[str] = set()
out: list[tuple[str, str]] = []
for doc in sorted(docs, key=lambda d: (d.kind, d.title.lower(), d.id)):
path = _path_for(doc, by_id)
if path.lower() in taken:
stem, _, extension = path.rpartition(".")
suffix = 2
while f"{stem}-{suffix}.{extension}".lower() in taken:
suffix += 1
path = f"{stem}-{suffix}.{extension}"
taken.add(path.lower())
out.append((path, doc.body or ""))
return out
def build_zip(docs: list[ExportDoc]) -> bytes:
"""The whole documentation space as one deflated archive, built in memory.
A homelab's worth of markdown is kilobytes; streaming it would buy nothing
and cost the caller a temporary file.
"""
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
for path, body in export_paths(docs):
archive.writestr(path, body)
return buffer.getvalue()
+233
View File
@@ -0,0 +1,233 @@
"""Exporting the documentation space as a zip of `.md` files.
The promise is that unzipping gives back what the sidebar shows, byte for byte:
the Library tree as directories, everything filed by a link in a directory named
after its kind, and every body exactly as it was written.
"""
import io
import zipfile
import pytest
from httpx import AsyncClient
from app.services.doc_export import ExportDoc, build_zip, export_paths, safe_segment
def _doc(id: str, kind: str = "page", *, title: str = "", slug: str = "", parent_id=None, body: str = "") -> ExportDoc:
return ExportDoc(
id=id,
kind=kind,
title=title or id,
slug=slug or id,
parent_id=parent_id,
body=body,
)
async def _device(client: AsyncClient, headers: dict, **body) -> dict:
payload = {"label": "nas-01", "hostname": "nas-01.lan", "ip": "192.168.1.20", "discovery_source": "manual", **body}
res = await client.post("/api/v1/scan/pending", json=payload, headers=headers)
assert res.status_code in (200, 201), res.text
return res.json()
async def _create(client: AsyncClient, headers: dict, **body) -> dict:
res = await client.post("/api/v1/documents", json={"title": "Page", **body}, headers=headers)
assert res.status_code == 201, res.text
return res.json()
def _entries(payload: bytes) -> dict[str, str]:
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
return {name: archive.read(name).decode() for name in archive.namelist()}
# ── path layout ─────────────────────────────────────────────────────────────
def test_a_page_at_the_root_is_a_file_at_the_root():
assert export_paths([_doc("vlan-plan")]) == [("vlan-plan.md", "")]
def test_a_page_in_a_folder_lands_under_its_directory():
folder = _doc("network", kind="folder")
page = _doc("vlans", parent_id="network")
paths = dict(export_paths([folder, page]))
assert paths["network/vlans.md"] == ""
def test_a_folder_body_becomes_the_index_of_its_own_directory():
folder = _doc("network", kind="folder", body="# Network\n")
assert dict(export_paths([folder]))["network/index.md"] == "# Network\n"
def test_nesting_goes_as_deep_as_the_tree():
paths = dict(
export_paths(
[
_doc("a", kind="folder"),
_doc("b", kind="folder", parent_id="a"),
_doc("leaf", parent_id="b"),
]
)
)
assert "a/b/leaf.md" in paths
assert "a/b/index.md" in paths
@pytest.mark.parametrize(
"kind,directory",
[("device", "devices"), ("node", "nodes"), ("design", "designs")],
)
def test_linked_kinds_export_into_a_directory_named_for_the_kind(kind, directory):
assert export_paths([_doc("nas-01", kind=kind)]) == [(f"{directory}/nas-01.md", "")]
def test_a_linked_document_ignores_a_stray_parent():
"""Only page/folder are placed by `parent_id`; a device is placed by its link."""
paths = dict(export_paths([_doc("network", kind="folder"), _doc("nas", kind="device", parent_id="network")]))
assert "devices/nas.md" in paths
def test_the_body_is_written_through_untouched():
body = "---\ntags: [edge]\n---\n\n# OPNsense\n\nWAN on igb0.\n"
assert export_paths([_doc("opnsense", body=body)])[0][1] == body
def test_a_missing_parent_files_the_page_at_the_root():
"""A folder deleted out from under a page must not lose the page."""
assert export_paths([_doc("orphan", parent_id="gone")]) == [("orphan.md", "")]
def test_a_parent_cycle_terminates_instead_of_spinning():
docs = [
_doc("a", kind="folder", parent_id="b"),
_doc("b", kind="folder", parent_id="a"),
]
paths = [path for path, _ in export_paths(docs)]
assert len(paths) == 2
assert all(path.endswith("index.md") for path in paths)
# ── collisions and unsafe names ─────────────────────────────────────────────
def test_two_documents_wanting_one_path_both_survive():
"""Slugs are unique among siblings only, so a collision is reachable."""
paths = [path for path, _ in export_paths([_doc("a", title="Same", slug="same"), _doc("b", title="Same", slug="same")])]
assert sorted(paths) == ["same-2.md", "same.md"]
def test_a_collision_only_differing_by_case_is_still_a_collision():
"""Unzipping on macOS or Windows would otherwise overwrite one of them."""
paths = [path for path, _ in export_paths([_doc("a", slug="Same"), _doc("b", slug="same")])]
assert len(set(path.lower() for path in paths)) == 2
def test_a_collision_keeps_both_bodies():
entries = dict(export_paths([_doc("a", slug="same", body="first"), _doc("b", slug="same", body="second")]))
assert sorted(entries.values()) == ["first", "second"]
@pytest.mark.parametrize(
"raw,expected",
[
("../../etc/passwd", "etc-passwd"),
("..", "untitled"),
("/", "untitled"),
("", "untitled"),
(" ", "untitled"),
(".hidden", "hidden"),
("Salle des machines", "salle-des-machines"),
("a/b", "a-b"),
],
)
def test_a_segment_cannot_escape_the_archive(raw, expected):
assert safe_segment(raw) == expected
def test_an_unsafe_title_cannot_escape_the_archive():
path = export_paths([_doc("x", slug="../../../etc/passwd")])[0][0]
assert ".." not in path
assert not path.startswith("/")
# ── the archive ─────────────────────────────────────────────────────────────
def test_the_archive_reads_back_as_a_zip():
payload = build_zip([_doc("vlan-plan", body="# VLANs\n")])
assert _entries(payload) == {"vlan-plan.md": "# VLANs\n"}
def test_an_empty_documentation_space_is_an_empty_archive():
assert _entries(build_zip([])) == {}
# ── the route ───────────────────────────────────────────────────────────────
async def test_export_requires_auth(client: AsyncClient):
assert (await client.get("/api/v1/documents/export")).status_code == 401
async def test_export_is_not_read_as_a_document_id(client: AsyncClient, headers: dict):
"""`/export` is declared above `/{document_id}` — a 404 would mean it is not."""
res = await client.get("/api/v1/documents/export", headers=headers)
assert res.status_code == 200
assert res.headers["content-type"] == "application/zip"
async def test_export_names_the_download(client: AsyncClient, headers: dict):
res = await client.get("/api/v1/documents/export", headers=headers)
assert res.headers["content-disposition"].startswith('attachment; filename="homelable-documentation-')
assert res.headers["content-disposition"].endswith('.zip"')
assert "Content-Disposition" in res.headers["access-control-expose-headers"]
async def test_export_mirrors_the_library_tree(client: AsyncClient, headers: dict):
folder = await _create(client, headers, title="Network", kind="folder")
await _create(client, headers, title="VLAN plan", parent_id=folder["id"])
res = await client.get("/api/v1/documents/export", headers=headers)
entries = _entries(res.content)
assert "network/vlan-plan.md" in entries
assert "network/index.md" in entries
async def test_export_carries_the_body_the_user_saved(client: AsyncClient, headers: dict):
doc = await _create(client, headers, title="Runbook")
body = "---\ntags: [ops]\n---\n\n# Runbook\n\nPull the plug.\n"
res = await client.patch(f"/api/v1/documents/{doc['id']}", json={"body": body}, headers=headers)
assert res.status_code == 200, res.text
entries = _entries((await client.get("/api/v1/documents/export", headers=headers)).content)
assert entries["runbook.md"] == body
async def test_export_files_a_device_document_under_devices(client: AsyncClient, headers: dict):
device = await _device(client, headers)
await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
entries = _entries((await client.get("/api/v1/documents/export", headers=headers)).content)
assert "devices/nas-01.md" in entries
assert entries["devices/nas-01.md"].strip()
async def test_export_includes_every_document(client: AsyncClient, headers: dict):
device = await _device(client, headers)
await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
folder = await _create(client, headers, title="Ops", kind="folder")
await _create(client, headers, title="Incident 42", parent_id=folder["id"])
await _create(client, headers, title="Loose page")
listed = (await client.get("/api/v1/documents", headers=headers)).json()
entries = _entries((await client.get("/api/v1/documents/export", headers=headers)).content)
assert len(entries) == len(listed)
async def test_export_of_an_empty_space_is_a_valid_empty_zip(client: AsyncClient, headers: dict):
res = await client.get("/api/v1/documents/export", headers=headers)
assert res.status_code == 200
assert _entries(res.content) == {}
+3
View File
@@ -358,6 +358,9 @@ export const documentsApi = {
params: { block, device_id: deviceId },
}),
coverage: () => api.get<import('@/documentation/types').DocCoverage>('/documents/coverage'),
// A zip of `.md` files mirroring the tree. Fetched through axios rather than
// a plain link so the Bearer header rides along.
export: () => api.get<Blob>('/documents/export', { responseType: 'blob' }),
scaffold: (data: { device_ids?: string[]; only_with_notes?: boolean }) =>
api.post<{ created: import('@/documentation/types').DocumentSummary[]; skipped: number }>(
'/documents/scaffold',
@@ -33,6 +33,7 @@ const noop = {
onToggleStar: vi.fn(),
onMarkReviewed: vi.fn(),
onRegenerate: vi.fn(),
onDownload: vi.fn(),
onDelete: vi.fn(),
onOpenDoc: vi.fn(),
onCreateFromLink: vi.fn(),
@@ -92,3 +93,24 @@ describe('DocViewer — tags', () => {
expect(screen.getByText('Tag')).toBeInTheDocument()
})
})
describe('DocViewer — download', () => {
it('hands the open document to the host on click', () => {
const onDownload = vi.fn()
render(<DocViewer {...noop} doc={makeDoc()} onSetTags={vi.fn()} onDownload={onDownload} />)
fireEvent.click(screen.getByLabelText('Download this document'))
expect(onDownload).toHaveBeenCalledTimes(1)
})
// A folder hides Regenerate, and the two buttons sit side by side — the
// download must not be hidden along with it.
it('is offered for a folder too', () => {
const onDownload = vi.fn()
render(
<DocViewer {...noop} doc={makeDoc({ kind: 'folder' })} onSetTags={vi.fn()} onDownload={onDownload} />,
)
expect(screen.queryByLabelText('Regenerate this document')).not.toBeInTheDocument()
fireEvent.click(screen.getByLabelText('Download this document'))
expect(onDownload).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,128 @@
/**
* The Export all button in the Documentation layout.
*
* It is the only control in the view that acts on the whole space rather than
* the open document, so it lives in the tree footer and has to stay usable with
* no document selected — and unusable when there is nothing to export.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { documentsApi } from '@/api/client'
import { DocumentationView } from '../components/DocumentationView'
import { useDocsStore } from '../store'
import type { DocumentSummary } from '../types'
vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
vi.mock('@/api/client', () => ({
documentsApi: {
list: vi.fn().mockResolvedValue({ data: [] }),
coverage: vi.fn().mockResolvedValue({
data: { devices: 0, documented: 0, header_only: 0, notes_unmigrated: 0 },
}),
export: vi.fn(),
},
scanApi: { pending: vi.fn().mockResolvedValue({ data: [] }) },
}))
const exportAll = vi.mocked(documentsApi.export)
const listDocs = vi.mocked(documentsApi.list)
function summary(overrides: Partial<DocumentSummary> = {}): DocumentSummary {
return {
id: 'doc-1',
kind: 'page',
title: 'VLAN plan',
slug: 'vlan-plan',
sort_order: 0,
tags: [],
frontmatter: {},
starred: false,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
...overrides,
} as DocumentSummary
}
let clicks = 0
let savedName: string | null = null
beforeEach(() => {
clicks = 0
savedName = null
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:zip')
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
clicks += 1
savedName = this.download
})
useDocsStore.setState({ docs: [], loaded: false, openDoc: null, filter: '' })
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.clearAllMocks()
})
/** The view loads the tree itself on mount, so the docs come from the server. */
async function renderView(docs: DocumentSummary[]) {
listDocs.mockResolvedValue({ data: docs } as never)
render(<DocumentationView />)
await waitFor(() => expect(useDocsStore.getState().loaded).toBe(true))
return screen.getByText(/Export all|Exporting/).closest('button') as HTMLButtonElement
}
describe('Export all', () => {
it('is offered with no document open', async () => {
const button = await renderView([summary()])
expect(button).toBeEnabled()
expect(useDocsStore.getState().openDoc).toBeNull()
})
it('is disabled while there is nothing written down', async () => {
expect(await renderView([])).toBeDisabled()
})
it('saves the archive the server returns', async () => {
exportAll.mockResolvedValue({
data: new Blob(['PK']),
headers: { 'content-disposition': 'attachment; filename="homelable-documentation-20260909.zip"' },
} as never)
fireEvent.click(await renderView([summary()]))
await waitFor(() => expect(clicks).toBe(1))
expect(savedName).toBe('homelable-documentation-20260909.zip')
})
// The request is a server round trip on every document body — a second click
// mid-flight would download the same archive twice.
it('cannot be fired again while it is running', async () => {
let release: (value: unknown) => void = () => {}
exportAll.mockReturnValue(new Promise((resolve) => {
release = resolve
}) as never)
const button = await renderView([summary()])
fireEvent.click(button)
await waitFor(() => expect(button).toBeDisabled())
expect(screen.getByText('Exporting…')).toBeInTheDocument()
release({ data: new Blob(['PK']), headers: {} })
await waitFor(() => expect(exportAll).toHaveBeenCalledTimes(1))
})
it('says so and stays usable when the export fails', async () => {
exportAll.mockRejectedValue(new Error('500'))
const { toast } = await import('sonner')
const button = await renderView([summary()])
fireEvent.click(button)
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Could not export the documentation'))
expect(clicks).toBe(0)
await waitFor(() => expect(button).toBeEnabled())
})
})
@@ -0,0 +1,175 @@
/**
* Downloading documentation.
*
* A page is written from the body the viewer already holds; the whole space
* comes back from the server as a zip. Both end at the same anchor click, so
* that is what these assert on.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { documentsApi } from '@/api/client'
import {
docFilename,
downloadAllDocs,
downloadBlob,
downloadDoc,
filenameFromDisposition,
} from '../export'
import type { Doc } from '../types'
vi.mock('@/api/client', () => ({ documentsApi: { export: vi.fn() } }))
const exportAll = vi.mocked(documentsApi.export)
function makeDoc(overrides: Partial<Doc> = {}): Doc {
return {
id: 'doc-1',
kind: 'page',
title: 'VLAN plan',
slug: 'vlan-plan',
sort_order: 0,
tags: [],
frontmatter: {},
starred: false,
body: '---\ntags: [net]\n---\n\n# VLAN plan\n',
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
...overrides,
} as Doc
}
/** What the browser was handed: the anchor's name and the blob behind it. */
let saved: { name: string; blob: Blob } | null = null
let revoked: string[] = []
let clicks = 0
beforeEach(() => {
saved = null
revoked = []
clicks = 0
let blob: Blob | null = null
vi.spyOn(URL, 'createObjectURL').mockImplementation((value: Blob | MediaSource) => {
blob = value as Blob
return 'blob:doc'
})
vi.spyOn(URL, 'revokeObjectURL').mockImplementation((url: string) => {
revoked.push(url)
})
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
clicks += 1
saved = { name: this.download, blob: blob as Blob }
})
})
afterEach(() => {
vi.restoreAllMocks()
vi.clearAllMocks()
})
// ── file names ──────────────────────────────────────────────────────────────
describe('docFilename', () => {
it('is the slug with a markdown extension', () => {
expect(docFilename(makeDoc())).toBe('vlan-plan.md')
})
it('falls back on the title when there is no slug', () => {
expect(docFilename({ slug: '', title: 'Salle des machines' })).toBe('salle-des-machines.md')
})
it('cannot be turned into a path by a hand-edited title', () => {
const name = docFilename({ slug: '../../etc/passwd', title: 'x' })
expect(name).not.toContain('/')
expect(name).not.toContain('..')
})
it('names an untitled document rather than producing a bare extension', () => {
expect(docFilename({ slug: '', title: ' ' })).toBe('untitled.md')
})
})
describe('filenameFromDisposition', () => {
it('reads the quoted name the server sent', () => {
expect(
filenameFromDisposition('attachment; filename="homelable-documentation-20260909.zip"', 'x.zip'),
).toBe('homelable-documentation-20260909.zip')
})
it('reads an unquoted name', () => {
expect(filenameFromDisposition('attachment; filename=docs.zip', 'x.zip')).toBe('docs.zip')
})
it('falls back when the header is missing', () => {
expect(filenameFromDisposition(undefined, 'x.zip')).toBe('x.zip')
})
it('falls back when the header carries no filename', () => {
expect(filenameFromDisposition('attachment', 'x.zip')).toBe('x.zip')
})
})
// ── writing the file ────────────────────────────────────────────────────────
describe('downloadBlob', () => {
it('clicks an anchor and releases the object URL', () => {
downloadBlob(new Blob(['x']), 'a.md')
expect(clicks).toBe(1)
expect(saved?.name).toBe('a.md')
expect(revoked).toEqual(['blob:doc'])
})
it('leaves no anchor behind in the document', () => {
downloadBlob(new Blob(['x']), 'a.md')
expect(document.querySelectorAll('a').length).toBe(0)
})
})
describe('downloadDoc', () => {
it('writes the body verbatim, frontmatter included', async () => {
const doc = makeDoc()
downloadDoc(doc)
expect(await saved!.blob.text()).toBe(doc.body)
})
it('writes it as markdown under the document name', () => {
downloadDoc(makeDoc())
expect(saved?.name).toBe('vlan-plan.md')
expect(saved?.blob.type).toContain('text/markdown')
})
it('writes an empty file rather than throwing on a body-less document', async () => {
downloadDoc({ slug: 'empty', title: 'Empty', body: '' })
expect(await saved!.blob.text()).toBe('')
})
it('asks the server for nothing — the body is already here', () => {
downloadDoc(makeDoc())
expect(exportAll).not.toHaveBeenCalled()
})
})
// ── the whole space ─────────────────────────────────────────────────────────
describe('downloadAllDocs', () => {
it('saves the archive under the name the server put on it', async () => {
exportAll.mockResolvedValue({
data: new Blob(['PK']),
headers: { 'content-disposition': 'attachment; filename="homelable-documentation-20260909.zip"' },
} as never)
await downloadAllDocs()
expect(saved?.name).toBe('homelable-documentation-20260909.zip')
expect(saved?.blob.type).toBe('application/zip')
})
it('falls back on a plain name when the server sent no disposition', async () => {
exportAll.mockResolvedValue({ data: new Blob(['PK']), headers: {} } as never)
await downloadAllDocs()
expect(saved?.name).toBe('homelable-documentation.zip')
})
it('rejects rather than saving an empty file when the request fails', async () => {
exportAll.mockRejectedValue(new Error('500'))
await expect(downloadAllDocs()).rejects.toThrow()
expect(clicks).toBe(0)
})
})
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
import { Clock, History, Link2, Pencil, Plus, RefreshCw, Star, Trash2, X } from 'lucide-react'
import { Clock, Download, History, Link2, Pencil, Plus, RefreshCw, Star, Trash2, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
@@ -39,6 +39,8 @@ interface Props {
onToggleStar: () => void
onMarkReviewed: () => void
onRegenerate: () => void
/** Saves this document to disk as the `.md` file its body already is. */
onDownload: () => void
onDelete: () => void
onOpenDoc: (id: string) => void
onCreateFromLink: (label: string) => void
@@ -77,6 +79,7 @@ export function DocViewer({
onToggleStar,
onMarkReviewed,
onRegenerate,
onDownload,
onDelete,
onOpenDoc,
onCreateFromLink,
@@ -178,6 +181,16 @@ export function DocViewer({
<RefreshCw />
</Button>
)}
<Button
size="icon-xs"
variant="ghost"
title="Download this document as Markdown"
aria-label="Download this document"
onClick={onDownload}
className="cursor-pointer"
>
<Download />
</Button>
<Button size="icon-xs" variant="ghost" title="Delete this document" onClick={onDelete} className="cursor-pointer">
<Trash2 />
</Button>
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BookOpen, ChevronDown, ChevronRight, FilePlus, FolderPlus, Search, X } from 'lucide-react'
import { Archive, BookOpen, ChevronDown, ChevronRight, FilePlus, FolderPlus, Search, X } from 'lucide-react'
import { toast } from 'sonner'
import { scanApi } from '@/api/client'
@@ -10,6 +10,7 @@ import { useDesignStore } from '@/stores/designStore'
import type { InventoryEntry } from '@/types'
import { cn } from '@/lib/utils'
import { formatRelative } from '@/utils/timeFormat'
import { downloadAllDocs, downloadDoc } from '../export'
import { isOverdue } from '../frontmatter'
import { driftedIds, useDocsStore } from '../store'
import {
@@ -95,6 +96,7 @@ export function DocumentationView() {
const [regenerateOpen, setRegenerateOpen] = useState(false)
const [regenerating, setRegenerating] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false)
const [exporting, setExporting] = useState(false)
useEffect(() => {
void loadDocs()
@@ -234,6 +236,20 @@ export function DocumentationView() {
toast.success('Document deleted')
}, [openDoc, remove])
// The whole space, zipped server-side: the tree only holds summaries, so the
// bodies to write are not in the browser yet.
const handleExportAll = useCallback(async () => {
setExporting(true)
try {
await downloadAllDocs()
toast.success('Documentation exported')
} catch {
toast.error('Could not export the documentation')
} finally {
setExporting(false)
}
}, [])
const handleRegenerate = useCallback(async () => {
if (!openDoc) return
setRegenerating(true)
@@ -458,16 +474,27 @@ export function DocumentationView() {
/>
</div>
{coverage && (
<div className="flex items-center border-t border-border px-2 py-1.5">
<div className="flex items-center gap-1 border-t border-border px-2 py-1.5">
<Button
size="xs"
variant="ghost"
className="cursor-pointer gap-1 px-1.5"
title="Download every document as a zip of Markdown files"
disabled={exporting || docs.length === 0}
onClick={() => void handleExportAll()}
>
<Archive size={12} />
{exporting ? 'Exporting…' : 'Export all'}
</Button>
{coverage && (
<span
className="ml-auto text-[10px] tabular-nums text-muted-foreground/70"
title={`${coverage.documented} of ${coverage.devices} devices documented · ${coverage.header_only} still only the generated header`}
>
{coverage.documented}/{coverage.devices}
</span>
</div>
)}
)}
</div>
</aside>
<div
@@ -537,6 +564,7 @@ export function DocumentationView() {
onMarkReviewed={() => void markReviewed(openDoc.id)}
onSetTags={(tags) => void setTags(tags)}
onRegenerate={() => setRegenerateOpen(true)}
onDownload={() => downloadDoc(openDoc)}
onDelete={() => void handleDelete()}
onOpenDoc={(id) => void open(id)}
onCreateFromLink={async (label) => {
+68
View File
@@ -0,0 +1,68 @@
import { documentsApi } from '@/api/client'
import type { Doc } from './types'
/**
* Downloading documentation.
*
* A single page needs no server: `doc.body` *is* the file — the whole markdown
* document, YAML frontmatter included — so the viewer writes what it is already
* holding. Exporting everything does need the server, because the tree only
* carries summaries and the bodies were never loaded.
*/
/** Fall back on the title when a document predates its slug being set. */
function segment(raw: string): string {
const cleaned = raw
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^[-.]+|[-.]+$/g, '')
return cleaned || 'untitled'
}
export function docFilename(doc: Pick<Doc, 'slug' | 'title'>): string {
return `${segment(doc.slug || doc.title)}.md`
}
/** Hand a blob to the browser as a download, then release the object URL. */
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
URL.revokeObjectURL(url)
}
/** One document as the `.md` file it already is. */
export function downloadDoc(doc: Pick<Doc, 'slug' | 'title' | 'body'>): void {
downloadBlob(new Blob([doc.body ?? ''], { type: 'text/markdown;charset=utf-8' }), docFilename(doc))
}
/**
* The `filename=` the server put on the archive, so the date in it is the
* server's and not a second one computed here.
*/
export function filenameFromDisposition(header: unknown, fallback: string): string {
if (typeof header !== 'string') return fallback
const match = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(header)
if (!match) return fallback
try {
return decodeURIComponent(match[1].trim()) || fallback
} catch {
// A name that is not valid percent-encoding is still a usable name.
return match[1].trim() || fallback
}
}
/** Every document, as a zip of `.md` files mirroring the tree. */
export async function downloadAllDocs(): Promise<void> {
const res = await documentsApi.export()
const name = filenameFromDisposition(
res.headers?.['content-disposition'],
'homelable-documentation.zip',
)
downloadBlob(new Blob([res.data], { type: 'application/zip' }), name)
}