diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index f8dfe32..5631b8c 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -21,6 +21,7 @@ RUN pnpm install --frozen-lockfile --filter @dorfteich/api... \ # needed for migrate-on-start) at /out. && pnpm --filter @dorfteich/api deploy --prod --legacy /out \ && cp -r apps/api/dist /out/dist \ + && cp -r apps/api/assets /out/assets \ && cp -r /repo/fonts /out/fonts FROM node:22.15.1-alpine diff --git a/apps/api/assets/README.md b/apps/api/assets/README.md new file mode 100644 index 0000000..4f99bc0 --- /dev/null +++ b/apps/api/assets/README.md @@ -0,0 +1,45 @@ +# Runtime assets + +## `reference-vs-nfd.docx` / `reference-vs-nfd.odt` (issue #209, ADR 0022) + +Pandoc reference documents for the DOCX/ODT export of a **classified** +page: their page setup defines a header and footer carrying the VS-NfD +marking, which pandoc copies into its output — so the marking repeats on +every page in Word and LibreOffice and is not deletable body text. +Unclassified exports pass no reference document and are unchanged. + +These are **derived binaries — never edit them by hand.** Source of truth +is `../scripts/gen-classified-reference-docs.mjs`: it takes the default +reference documents of the pinned sidecar (`pandoc/core:3.6`, the exact +image the stages run) and injects the header/footer, with the wording from +`classificationMarking()` in `@dorfteich/shared` (single source, ADR +0022). Regenerate — after a pandoc pin bump, a wording change, or a layout +tweak in the script — with Docker running: + +```sh +pnpm --filter @dorfteich/shared build # the script imports the wording +node apps/api/scripts/gen-classified-reference-docs.mjs +``` + +Commit script and binaries together. The fidelity suite +(`export.fidelity.test.ts`) asserts against the real pinned pandoc that a +marked export carries the header/footer parts and an unmarked one does +not. + +### Per-page verification in the office suites + +After regenerating, confirm the marking repeats on **every** page of a +multi-page export (not just structurally in the XML): + +1. Produce a marked multi-page export (any classified page with a few + screens of text, exported to `.docx` and `.odt`). +2. **LibreOffice** (scriptable): + `soffice --headless --convert-to pdf ` and check every PDF page + shows the marking twice (header + footer) — e.g. with `pypdf`. +3. **Word**: open the `.docx`, check header and footer on every page + (print preview). Word's AppleScript/sandbox makes this hard to script — + this step is a quick manual look. + +Last verified 2026-07-31 (pandoc 3.6 output): LibreOffice 25.8, both +formats, 5/5 pages with 2 markings each. Word: manual check pending — +sample files in the workspace under `doku/209-marked-sample.docx/.odt`. diff --git a/apps/api/assets/reference-vs-nfd.docx b/apps/api/assets/reference-vs-nfd.docx new file mode 100644 index 0000000..40f48ca Binary files /dev/null and b/apps/api/assets/reference-vs-nfd.docx differ diff --git a/apps/api/assets/reference-vs-nfd.odt b/apps/api/assets/reference-vs-nfd.odt new file mode 100644 index 0000000..adca54d Binary files /dev/null and b/apps/api/assets/reference-vs-nfd.odt differ diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index e7003a1..da2d37c 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -786,8 +786,8 @@ model ConversionJob { /// other job kind, whose payload the general retention (#233) prunes. expiresAt DateTime? @map("expires_at") /// Kind-specific job options (issue #117): a vault import carries - /// `{parentPageId, labelIds, frontmatterMode}`; a PDF export of a - /// classified page carries `{marking}` (issue #208). Null otherwise. + /// `{parentPageId, labelIds, frontmatterMode}`; a PDF/DOCX/ODT export of a + /// classified page carries `{marking}` (issues #208/#209). Null otherwise. options Json? createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/apps/api/scripts/gen-classified-reference-docs.mjs b/apps/api/scripts/gen-classified-reference-docs.mjs new file mode 100644 index 0000000..fc6296f --- /dev/null +++ b/apps/api/scripts/gen-classified-reference-docs.mjs @@ -0,0 +1,118 @@ +/** + * Regenerate the classified reference documents (issue #209, ADR 0022): + * `apps/api/assets/reference-vs-nfd.docx` / `.odt`. + * + * The DOCX/ODT export of a classified page passes these to pandoc via + * `--reference-doc`; pandoc copies the reference's page setup — including + * headers and footers — into its output, which is how the VS-NfD marking + * repeats on every page in Word and LibreOffice without being deletable + * body text. + * + * The binaries are DERIVED files: base = the default reference documents of + * the PINNED pandoc (`pandoc/core:3.6`, the exact sidecar the stages run), + * plus a header and footer carrying the marking. Never edit the binaries by + * hand — edit this script and re-run it (Docker required): + * + * node apps/api/scripts/gen-classified-reference-docs.mjs + * + * The marking wording comes from @dorfteich/shared (single source, ADR + * 0022); the shared package must be built (`pnpm --filter @dorfteich/shared + * build`). + */ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { classificationMarking } from '@dorfteich/shared'; +import { strToU8, strFromU8, unzipSync, zipSync } from 'fflate'; + +const PANDOC_IMAGE = 'pandoc/core:3.6'; +const MARKING = classificationMarking('vs_nfd'); +const outDir = join(dirname(fileURLToPath(import.meta.url)), '../assets'); + +function defaultReference(name) { + return execFileSync('docker', ['run', '--rm', PANDOC_IMAGE, '--print-default-data-file', name], { + maxBuffer: 64 * 1024 * 1024, + }); +} + +function escapeXml(value) { + return value.replace(/&/g, '&').replace(//g, '>'); +} + +/** DOCX: add word/header1.xml + word/footer1.xml, register them in the + * content types and document relationships, and reference them from the + * document's sectPr — Word repeats them on every page. */ +function patchDocx(bytes) { + const zip = unzipSync(new Uint8Array(bytes)); + const marking = escapeXml(MARKING); + + const partXml = (root) => + `\n` + + `` + + `` + + `${marking}` + + ``; + zip['word/header1.xml'] = strToU8(partXml('hdr')); + zip['word/footer1.xml'] = strToU8(partXml('ftr')); + + const types = strFromU8(zip['[Content_Types].xml']); + zip['[Content_Types].xml'] = strToU8( + types.replace( + '', + '' + + '' + + '', + ), + ); + + const rels = strFromU8(zip['word/_rels/document.xml.rels']); + zip['word/_rels/document.xml.rels'] = strToU8( + rels.replace( + '', + '' + + '' + + '', + ), + ); + + const doc = strFromU8(zip['word/document.xml']); + if (!doc.includes('')) throw new Error('reference.docx has no sectPr'); + zip['word/document.xml'] = strToU8( + doc.replace( + '', + '' + + '' + + '', + ), + ); + + return zipSync(zip); +} + +/** ODT: give the Standard master page a header with the marking and put the + * marking next to the existing page number in its footer — LibreOffice + * repeats master-page headers/footers on every page. */ +function patchOdt(bytes) { + const zip = unzipSync(new Uint8Array(bytes)); + const marking = escapeXml(MARKING); + const styles = strFromU8(zip['styles.xml']); + if (!styles.includes('')) throw new Error('reference.odt has no footer'); + const patched = styles + .replace( + '', + `${marking}`, + ) + .replace( + '\n ', + `\n ${marking} · `, + ); + zip['styles.xml'] = strToU8(patched); + return zipSync(zip); +} + +mkdirSync(outDir, { recursive: true }); +writeFileSync(join(outDir, 'reference-vs-nfd.docx'), patchDocx(defaultReference('reference.docx'))); +writeFileSync(join(outDir, 'reference-vs-nfd.odt'), patchOdt(defaultReference('reference.odt'))); +console.log(`generated reference-vs-nfd.docx/.odt in ${outDir} (marking: ${MARKING})`); diff --git a/apps/api/src/import-export/classified-markdown.test.ts b/apps/api/src/import-export/classified-markdown.test.ts new file mode 100644 index 0000000..6612fbf --- /dev/null +++ b/apps/api/src/import-export/classified-markdown.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { markClassifiedMarkdown, parseClassifiedMarkdown } from './classified-markdown'; + +const MARKING = 'VS – NUR FÜR DEN DIENSTGEBRAUCH'; + +describe('classified markdown marking (issue #210)', () => { + it('wraps a classified page in frontmatter and top+bottom imprint', () => { + const marked = markClassifiedMarkdown('# Title\n\nBody.\n', 'vs_nfd'); + expect(marked).toBe( + `---\nclassification: vs_nfd\n---\n\n${MARKING}\n\n# Title\n\nBody.\n\n${MARKING}\n`, + ); + }); + + it('leaves unclassified markdown untouched', () => { + expect(markClassifiedMarkdown('# Title\n\nBody.\n', 'unclassified')).toBe('# Title\n\nBody.\n'); + }); + + it('parse is the inverse of mark', () => { + const original = '# Title\n\nBody.\n'; + const { markdown, classification } = parseClassifiedMarkdown( + markClassifiedMarkdown(original, 'vs_nfd'), + ); + expect(classification).toBe('vs_nfd'); + expect(markdown).toBe(original); + }); + + it('passes documents without our frontmatter through unchanged', () => { + for (const raw of [ + '# Plain\n\nNo frontmatter.\n', + '---\ntitle: Foreign frontmatter\ntags: [a]\n---\n\n# Doc\n', + `${MARKING}\n\nJust an imprint line without frontmatter.\n`, + ]) { + const { markdown, classification } = parseClassifiedMarkdown(raw); + expect(classification).toBeNull(); + expect(markdown).toBe(raw); + } + }); +}); diff --git a/apps/api/src/import-export/classified-markdown.ts b/apps/api/src/import-export/classified-markdown.ts new file mode 100644 index 0000000..9db6774 --- /dev/null +++ b/apps/api/src/import-export/classified-markdown.ts @@ -0,0 +1,43 @@ +import { PageClassification, classificationMarking } from '@dorfteich/shared'; + +/** + * VS-NfD marking of exported Markdown (issue #210, ADR 0022): a classified + * page's `.md` carries the level machine-readably in YAML frontmatter AND + * human-visibly as the marking line at the top and bottom of the file. + * Unclassified pages pass through untouched — no marking, no frontmatter. + */ +export function markClassifiedMarkdown( + markdown: string, + classification: PageClassification, +): string { + const marking = classificationMarking(classification); + if (!marking) return markdown; + return `---\nclassification: ${classification}\n---\n\n${marking}\n\n${markdown.trimEnd()}\n\n${marking}\n`; +} + +/** + * Inverse of {@link markClassifiedMarkdown} for the import side: recognizes + * exactly the frontmatter block we generate (a lone `classification:` key) + * and the marking lines around the body, so a round-trip re-import yields + * the original content — and the page starts at the imported level (content + * must not escape its marking by traveling through a ZIP). Anything else — + * foreign frontmatter, hand-written documents — passes through unchanged. + */ +export function parseClassifiedMarkdown(raw: string): { + markdown: string; + classification: PageClassification | null; +} { + const match = raw.match(/^---\nclassification: (vs_nfd|unclassified)\n---\n\n/); + if (!match) return { markdown: raw, classification: null }; + const classification = match[1] as PageClassification; + let body = raw.slice(match[0].length); + const marking = classificationMarking(classification); + if (marking) { + if (body.startsWith(`${marking}\n\n`)) body = body.slice(marking.length + 2); + const trimmed = body.trimEnd(); + if (trimmed.endsWith(`\n\n${marking}`)) { + body = `${trimmed.slice(0, -(marking.length + 2)).trimEnd()}\n`; + } + } + return { markdown: body, classification }; +} diff --git a/apps/api/src/import-export/conversion-worker.service.ts b/apps/api/src/import-export/conversion-worker.service.ts index 82e537a..72989a9 100644 --- a/apps/api/src/import-export/conversion-worker.service.ts +++ b/apps/api/src/import-export/conversion-worker.service.ts @@ -1,3 +1,6 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { ConversionJob } from '@prisma/client'; @@ -66,10 +69,34 @@ export class ConversionWorker implements OnModuleInit, OnModuleDestroy { to: job.targetFormat, input: Buffer.from(conversionInputOf(job)), standalone: job.standalone, + referenceDoc: await this.classifiedReferenceDoc(job), }); return { bytes: result.output, mimeType: result.mimeType }; } + /** + * The classified reference document for a marked docx/odt export (issue + * #209, ADR 0022): pandoc copies its header/footer — which carry the + * VS-NfD marking — into the output, so the marking repeats on every page + * in Word/LibreOffice and is not deletable body text. Only present when + * the enqueue put a `marking` into the job options; the binaries ship in + * `apps/api/assets/` (see `scripts/gen-classified-reference-docs.mjs`). + */ + private async classifiedReferenceDoc( + job: ConversionJob, + ): Promise<{ name: string; bytes: Buffer } | undefined> { + const marked = Boolean((job.options as { marking?: string } | null)?.marking); + if (!marked || (job.targetFormat !== 'docx' && job.targetFormat !== 'odt')) return undefined; + const name = `reference-vs-nfd.${job.targetFormat}`; + const cached = this.referenceDocs.get(name); + if (cached) return { name, bytes: cached }; + const bytes = await readFile(join(__dirname, '../../assets', name)); + this.referenceDocs.set(name, bytes); + return { name, bytes }; + } + + private readonly referenceDocs = new Map(); + onModuleInit(): void { if (this.config.env.NODE_ENV === 'test') return; // tests drive drain() directly this.timer = setInterval(() => this.drainSafely(), SWEEP_MS); diff --git a/apps/api/src/import-export/export.fidelity.test.ts b/apps/api/src/import-export/export.fidelity.test.ts index 08858d7..29c8c3d 100644 --- a/apps/api/src/import-export/export.fidelity.test.ts +++ b/apps/api/src/import-export/export.fidelity.test.ts @@ -1,6 +1,8 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; +import { classificationMarking } from '@dorfteich/shared'; +import { strFromU8, unzipSync } from 'fflate'; import { beforeAll, describe, expect, it, TestContext } from 'vitest'; import { AppConfig } from '../config/app-config.service'; @@ -8,6 +10,8 @@ import { AppConfig } from '../config/app-config.service'; import { markdownForDocument } from './export-markdown'; import { PandocServerConverter } from './pandoc.converter'; +const MARKING = classificationMarking('vs_nfd')!; + /** * Export fidelity regression (issue #69, ADR 0009): exports the committed * Markdown corpus to `.docx`/`.odt` through the real pinned pandoc and reads @@ -70,4 +74,68 @@ describe('export fidelity corpus (real pandoc, issue #69)', () => { }); } } + + // The classified reference documents (issue #209, ADR 0022): a marked + // export must carry the VS-NfD marking in the document's own header/footer + // definition (repeats per page in Word/LibreOffice, not deletable body + // text); an unmarked export must not. Asserted structurally against the + // real pinned pandoc; the body round-trip above stays untouched by the + // reference doc (headers are outside the content pandoc reads back). + it('a marked docx export carries the marking in header1.xml/footer1.xml; unmarked does not', async (ctx: TestContext) => { + if (!reachable) ctx.skip(); + const referenceDoc = { + name: 'reference-vs-nfd.docx', + bytes: readFileSync(join(process.cwd(), 'assets/reference-vs-nfd.docx')), + }; + const marked = await converter.convert({ + from: 'gfm', + to: 'docx', + input: Buffer.from('# Marked\n\nbody', 'utf8'), + standalone: true, + referenceDoc, + }); + const parts = unzipSync(new Uint8Array(marked.output)); + const header = strFromU8(parts['word/header1.xml']!); + const footer = strFromU8(parts['word/footer1.xml']!); + expect(header).toContain(MARKING); + expect(footer).toContain(MARKING); + expect(strFromU8(parts['word/document.xml']!)).toContain('headerReference'); + + const unmarked = await converter.convert({ + from: 'gfm', + to: 'docx', + input: Buffer.from('# Open\n\nbody', 'utf8'), + standalone: true, + }); + const openParts = unzipSync(new Uint8Array(unmarked.output)); + expect(openParts['word/header1.xml']).toBeUndefined(); + }); + + it('a marked odt export carries the marking in its master-page header/footer; unmarked does not', async (ctx: TestContext) => { + if (!reachable) ctx.skip(); + const referenceDoc = { + name: 'reference-vs-nfd.odt', + bytes: readFileSync(join(process.cwd(), 'assets/reference-vs-nfd.odt')), + }; + const marked = await converter.convert({ + from: 'gfm', + to: 'odt', + input: Buffer.from('# Marked\n\nbody', 'utf8'), + standalone: true, + referenceDoc, + }); + const styles = strFromU8(unzipSync(new Uint8Array(marked.output))['styles.xml']!); + expect(styles).toContain(''); + const occurrences = styles.split(MARKING).length - 1; + expect(occurrences).toBeGreaterThanOrEqual(2); // header + footer + + const unmarked = await converter.convert({ + from: 'gfm', + to: 'odt', + input: Buffer.from('# Open\n\nbody', 'utf8'), + standalone: true, + }); + const openStyles = strFromU8(unzipSync(new Uint8Array(unmarked.output))['styles.xml']!); + expect(openStyles).not.toContain(MARKING); + }); }); diff --git a/apps/api/src/import-export/export.service.db.test.ts b/apps/api/src/import-export/export.service.db.test.ts index a29e00e..c481bae 100644 --- a/apps/api/src/import-export/export.service.db.test.ts +++ b/apps/api/src/import-export/export.service.db.test.ts @@ -31,8 +31,10 @@ const PNG_BASE64 = class RecordingConverter extends PandocConverter { lastInput = ''; + lastReferenceDoc: string | null = null; convert(request: ConversionRequest): Promise { this.lastInput = request.input.toString('utf8'); + this.lastReferenceDoc = request.referenceDoc?.name ?? null; return Promise.resolve({ output: Buffer.from('OFFICE-BYTES'), mimeType: 'application/x-test' }); } reachable(): Promise { @@ -191,6 +193,76 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => { expect(target).toContain(`![dot](media/${image.id}.png)`); }); + it('marks classified pages in the pond ZIP with frontmatter+imprint, ships a manifest, and round-trips (#210)', async () => { + const marking = 'VS – NUR FÜR DEN DIENSTGEBRAUCH'; + const classifiedSlug = await seedPage( + personalPondId, + 'Zip Classified', + '# Zip Classified\n\nclassified body text', + ); + const openSlug = await seedPage(personalPondId, 'Zip Open', '# Zip Open\n\nopen body text'); + await prisma.page.updateMany({ + where: { pondId: personalPondId, slug: classifiedSlug }, + data: { classification: 'VS_NFD' }, + }); + + const res = await api() + .get(`/api/v1/ponds/${personalPondId}/export/markdown`) + .set('Cookie', ownerCookie) + .buffer(true) + .parse((r, cb) => { + const chunks: Buffer[] = []; + r.on('data', (c: Buffer) => chunks.push(c)); + r.on('end', () => cb(null, Buffer.concat(chunks))); + }) + .expect(200); + const entries = zipEntries(res.body as Buffer); + + // Machine-readable frontmatter AND the visible imprint, top and bottom. + const marked = Buffer.from(entries[`${classifiedSlug}.md`]!).toString('utf8'); + expect(marked.startsWith(`---\nclassification: vs_nfd\n---\n\n${marking}\n\n`)).toBe(true); + expect(marked.trimEnd().endsWith(marking)).toBe(true); + // Unclassified files are unchanged: no frontmatter, no imprint. + const open = Buffer.from(entries[`${openSlug}.md`]!).toString('utf8'); + expect(open).not.toContain('classification:'); + expect(open).not.toContain(marking); + + // The manifest lists every file with its level and states the highest once. + const manifest = JSON.parse(Buffer.from(entries['manifest.json']!).toString('utf8')) as { + classification: string; + files: { path: string; classification: string }[]; + }; + expect(manifest.classification).toBe('vs_nfd'); + expect(manifest.files).toContainEqual({ + path: `${classifiedSlug}.md`, + classification: 'vs_nfd', + }); + expect(manifest.files).toContainEqual({ + path: `${openSlug}.md`, + classification: 'unclassified', + }); + + // Round-trip: re-importing the marked file must not confuse the importer — + // the page starts at the imported level, the body carries neither the + // frontmatter nor the imprint lines. + const imported = await api() + .post(`/api/v1/ponds/${personalPondId}/import`) + .set('Cookie', ownerCookie) + .attach('file', Buffer.from(marked, 'utf8'), 'reimported-classified.md') + .expect(201); + expect(imported.body.status).toBe('succeeded'); + const reimported = await prisma.page.findUniqueOrThrow({ + where: { id: imported.body.resultPageId as string }, + }); + expect(reimported.classification).toBe('VS_NFD'); + const cache = await prisma.pageContentCache.findUniqueOrThrow({ + where: { pageId: reimported.id }, + }); + expect(cache.markdown).toContain('classified body text'); + expect(cache.markdown).not.toContain(marking); + expect(cache.markdown).not.toContain('classification:'); + }); + it('skips an attachment whose bytes are missing on disk instead of crashing', async () => { // An attachment row with no file (data drift): upload then remove the bytes. const image = await files.upload({ id: ownerId } as never, personalPondId, { @@ -329,6 +401,30 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => { .set('Cookie', ownerCookie) .expect(200); expect(result.text).toBe('OFFICE-BYTES'); + // An unclassified page converts without a reference doc (issue #209). + expect(fake.lastReferenceDoc).toBeNull(); + }); + + it('hands pandoc the classified reference doc for a marked page (issue #209)', async () => { + const slug = await seedPage(personalPondId, 'Classified Docx', '# Classified Docx\n\nbody'); + const page = await prisma.page.findFirstOrThrow({ + where: { pondId: personalPondId, slug }, + }); + await prisma.page.update({ where: { id: page.id }, data: { classification: 'VS_NFD' } }); + + const enqueued = await api() + .post(`/api/v1/pages/${page.id}/export`) + .set('Cookie', ownerCookie) + .send({ format: 'docx' }) + .expect(201); + await worker.drain(); + + expect(fake.lastReferenceDoc).toBe('reference-vs-nfd.docx'); + const done = await api() + .get(`/api/v1/jobs/${enqueued.body.id}`) + .set('Cookie', ownerCookie) + .expect(200); + expect(done.body.status).toBe('succeeded'); }); it('exports a page to PDF: content + image inlined, font CSS, via Gotenberg', async () => { diff --git a/apps/api/src/import-export/export.service.ts b/apps/api/src/import-export/export.service.ts index e4b7340..94d4041 100644 --- a/apps/api/src/import-export/export.service.ts +++ b/apps/api/src/import-export/export.service.ts @@ -9,6 +9,8 @@ import { fontSlug, PageClassification, classificationMarking, + classificationRank, + highestClassification, pondSettingsSchema, } from '@dorfteich/shared'; import { User } from '@prisma/client'; @@ -23,6 +25,7 @@ import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer'; import { PluginsService } from '../plugins/plugins.service'; import { PrismaService } from '../prisma/prisma.service'; +import { markClassifiedMarkdown } from './classified-markdown'; import { ConversionJobService } from './conversion-job.service'; import { imageExtension, @@ -133,15 +136,54 @@ export class ExportService { attachments.map((a) => [a.id, `${a.id}.${imageExtension(a.mimeType)}`]), ); + // Media inherits the highest classification among the readable pages that + // reference it (fail-closed, ADR 0022 — a shared image is as classified + // as its most classified use). + const mediaClassification = new Map(); for (const page of readablePages) { - const markdown = markdownForZip( - page.contentCache?.markdown ?? '', - readableSlugs, - mediaNameById, + const level = page.classification.toLowerCase() as PageClassification; + for (const id of imageFileIds(page.contentCache?.markdown ?? '')) { + const current = mediaClassification.get(id) ?? 'unclassified'; + if (classificationRank(level) > classificationRank(current)) { + mediaClassification.set(id, level); + } + } + } + + const manifestFiles: { path: string; classification: PageClassification }[] = []; + for (const page of readablePages) { + const level = page.classification.toLowerCase() as PageClassification; + // A classified page's file carries the level in YAML frontmatter and + // the marking line at top and bottom (#210); unclassified files are + // byte-identical to the pre-#210 export. + const markdown = markClassifiedMarkdown( + markdownForZip(page.contentCache?.markdown ?? '', readableSlugs, mediaNameById), + level, ); // Page slugs are unique within a pond, so `.md` never collides. archive.append(markdown, { name: `${prefix}${page.slug}.md` }); + manifestFiles.push({ path: `${prefix}${page.slug}.md`, classification: level }); } + for (const attachment of attachments) { + manifestFiles.push({ + path: `${prefix}media/${mediaNameById.get(attachment.id)!}`, + classification: mediaClassification.get(attachment.id) ?? 'unclassified', + }); + } + // The archive-level manifest (#210): every file with its level, and the + // highest level contained stated once — the bulk-egress channel stays + // machine-checkable even after the ZIP is unpacked and copied onward. + archive.append( + JSON.stringify( + { + classification: highestClassification(manifestFiles.map((f) => f.classification)), + files: manifestFiles, + }, + null, + 2, + ), + { name: `${prefix}manifest.json` }, + ); for (const attachment of attachments) { const stream = this.storage.createReadStream(pond.id, attachment.id); // Defence in depth: a file removed between the existence check and the @@ -186,6 +228,10 @@ export class ExportService { const dataUriById = await this.inlineImages(page.pondId, imageFileIds(markdown)); const document = markdownForDocument(markdown, dataUriById); + // A classified page's export records its marking as a job option (#209): + // the worker then hands pandoc the classified reference document whose + // header/footer carry the marking on every page in Word/LibreOffice. + const marking = classificationMarking(page.classification.toLowerCase() as PageClassification); const job = await this.jobs.enqueue({ ownerId: user.id, kind: `export_${format}`, @@ -193,6 +239,7 @@ export class ExportService { to: format, input: Buffer.from(document, 'utf8'), standalone: true, + ...(marking ? { options: { marking } } : {}), }); this.logger.info( { jobId: job.id, pageId, format, userId: user.id }, diff --git a/apps/api/src/import-export/import.service.ts b/apps/api/src/import-export/import.service.ts index 48918a2..4b9135a 100644 --- a/apps/api/src/import-export/import.service.ts +++ b/apps/api/src/import-export/import.service.ts @@ -24,6 +24,7 @@ import { PagesService } from '../pages/pages.service'; import { docToState, emptyPageState } from '../pages/yjs-content'; import { PrismaService } from '../prisma/prisma.service'; +import { parseClassifiedMarkdown } from './classified-markdown'; import { ImportProcessor } from './import.constants'; import { ASSET_PLACEHOLDER_PREFIX, @@ -544,12 +545,24 @@ export class ImportService implements ImportProcessor { // file ids); track what we create so a later failure can be rolled back. const storedFileIds: string[] = []; try { - const markdown = await this.storeEmbeddedImages(rawMarkdown, user, pondId, storedFileIds); + // Our own classified export wraps the content in frontmatter + marking + // lines (#210) — strip them and carry the level into the new page, so + // a round-trip neither duplicates the marking nor loses it. + const { markdown: unwrapped, classification } = parseClassifiedMarkdown(rawMarkdown); + const markdown = await this.storeEmbeddedImages(unwrapped, user, pondId, storedFileIds); const json = markdownToDoc(markdown).toJSON() as unknown as PmNode; const { title, doc } = this.splitTitle(json, sourceName); const state = docToState(Node.fromJSON(editorSchema, doc)); - const page = await this.pages.createWithState(user, pondId, title, state); + const page = await this.pages.createWithState( + user, + pondId, + title, + state, + null, + undefined, + classification, + ); await this.files.linkAttachmentsToPage(storedFileIds, page.id); return page; } catch (error) { diff --git a/apps/api/src/import-export/pandoc.converter.ts b/apps/api/src/import-export/pandoc.converter.ts index ab5bc49..4162c71 100644 --- a/apps/api/src/import-export/pandoc.converter.ts +++ b/apps/api/src/import-export/pandoc.converter.ts @@ -17,6 +17,11 @@ export interface ConversionRequest { /** Line-wrapping of the writer's output. Import uses `none` so a paragraph * stays on one line (no soft breaks inside image alt text or links). */ wrap?: 'none' | 'auto' | 'preserve'; + /** Reference document for the docx/odt writers (issue #209, ADR 0022): + * pandoc copies its page setup — including the header/footer that carry + * the VS-NfD marking — into the output. Sent to pandoc-server as an + * in-request file plus the `reference-doc` option. */ + referenceDoc?: { name: string; bytes: Buffer }; } export interface ConversionResult { @@ -141,6 +146,14 @@ export class PandocServerConverter extends PandocConverter { // so these are only present when the import pipeline sets them. ...(request.embedResources ? { 'embed-resources': true } : {}), ...(request.wrap ? { wrap: request.wrap } : {}), + ...(request.referenceDoc + ? { + 'reference-doc': request.referenceDoc.name, + files: { + [request.referenceDoc.name]: request.referenceDoc.bytes.toString('base64'), + }, + } + : {}), }), signal: controller.signal, }); diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts index 6020346..2e77f6e 100644 --- a/apps/api/src/pages/pages.service.ts +++ b/apps/api/src/pages/pages.service.ts @@ -274,8 +274,9 @@ export class PagesService { state: Uint8Array, parentId: string | null = null, presetSlug?: string, + atLeastClassification: PageClassification | null = null, ): Promise { - return this.insertPage(user, pondId, title, state, parentId, presetSlug); + return this.insertPage(user, pondId, title, state, parentId, presetSlug, atLeastClassification); } /** @@ -300,6 +301,7 @@ export class PagesService { state: Uint8Array, parentId: string | null = null, presetSlug?: string, + atLeastClassification: PageClassification | null = null, ): Promise { const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); if (!pond) throw new NotFoundException(); @@ -316,11 +318,19 @@ export class PagesService { const sortKey = generateKeyBetween(last?.sortKey ?? null, null); const content = deriveContent(state); // A new page starts at the instance-wide default level (ADR 0022, #204), - // raised to its parent's level when that is higher (#205): a subpage of - // classified content must never begin unmarked. + // raised to its parent's level when that is higher (#205) — and to an + // imported document's own level (#210): content must not escape its + // marking by traveling through an export/import. A subpage of classified + // content must never begin unmarked. let classification: PageClassification = await this.settings.get( 'classification.newPageDefault', ); + if ( + atLeastClassification && + classificationRank(atLeastClassification) > classificationRank(classification) + ) { + classification = atLeastClassification; + } if (parentId) { const parent = await this.prisma.page.findUniqueOrThrow({ where: { id: parentId }, diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md index 12648d0..9b36183 100644 --- a/docs/vs-nfd/20-massnahmenplan.md +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -55,8 +55,8 @@ _Meilenstein: `M26 — VS-NfD: classification metadata`_ - [x] Web-Ansicht (Kopf/Fuß) · 1 AT · #206 - [x] **Print-CSS** (`@media print`, Kopf/Fuß je Seite) — fehlt komplett · 1 AT · #207 - [x] PDF via gotenberg (`pdf-html.ts` Header/Footer-Template) · 1 AT · #208 - - [ ] DOCX/ODT via pandoc (Reference-Doc mit Kopf-/Fußzeile) · 2–3 AT · #209 - - [ ] Markdown-ZIP (Frontmatter + Aufdruck) · 1 AT · #210 + - [x] DOCX/ODT via pandoc (Reference-Doc mit Kopf-/Fußzeile) · 2–3 AT · #209 + - [x] Markdown-ZIP (Frontmatter + Aufdruck) · 1 AT · #210 - [ ] Atom-Feeds, Public-API, Suchergebnisse, No-JS-Shell · 2–3 AT · #211 - [ ] Attachment-Download (Dateiname-Präfix + Begleitdatei) · 1–2 AT · #212 - [ ] Warnung/Sperre beim Anhängen an eingestufte Seiten · 1 AT · #213 diff --git a/eslint.config.mjs b/eslint.config.mjs index 8968a3f..77c0a0f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -24,7 +24,12 @@ export default tseslint.config( prettier, { // Plain-Node maintenance/build scripts (no TypeScript, no bundler). - files: ['scripts/**/*.mjs', 'deploy/**/*.mjs', 'packages/plugins/*/build.mjs'], + files: [ + 'scripts/**/*.mjs', + 'deploy/**/*.mjs', + 'packages/plugins/*/build.mjs', + 'apps/api/scripts/**/*.mjs', + ], languageOptions: { globals: { console: 'readonly',