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:
committed by
Pouzor - Rémy Jardient
parent
783ca87acd
commit
8dba259796
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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) == {}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user