#210: Markdown ZIP with frontmatter, imprint and manifest #269
@ -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
|
||||
|
||||
45
apps/api/assets/README.md
Normal file
45
apps/api/assets/README.md
Normal file
@ -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 <file>` 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`.
|
||||
BIN
apps/api/assets/reference-vs-nfd.docx
Normal file
BIN
apps/api/assets/reference-vs-nfd.docx
Normal file
Binary file not shown.
BIN
apps/api/assets/reference-vs-nfd.odt
Normal file
BIN
apps/api/assets/reference-vs-nfd.odt
Normal file
Binary file not shown.
@ -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")
|
||||
|
||||
118
apps/api/scripts/gen-classified-reference-docs.mjs
Normal file
118
apps/api/scripts/gen-classified-reference-docs.mjs
Normal file
@ -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, '<').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) =>
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
|
||||
`<w:${root} xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">` +
|
||||
`<w:p><w:pPr><w:jc w:val="center"/></w:pPr>` +
|
||||
`<w:r><w:rPr><w:b/></w:rPr><w:t xml:space="preserve">${marking}</w:t></w:r>` +
|
||||
`</w:p></w:${root}>`;
|
||||
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(
|
||||
'</Types>',
|
||||
'<Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml" />' +
|
||||
'<Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml" />' +
|
||||
'</Types>',
|
||||
),
|
||||
);
|
||||
|
||||
const rels = strFromU8(zip['word/_rels/document.xml.rels']);
|
||||
zip['word/_rels/document.xml.rels'] = strToU8(
|
||||
rels.replace(
|
||||
'</Relationships>',
|
||||
'<Relationship Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Id="rIdVsNfdHeader" Target="header1.xml" />' +
|
||||
'<Relationship Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Id="rIdVsNfdFooter" Target="footer1.xml" />' +
|
||||
'</Relationships>',
|
||||
),
|
||||
);
|
||||
|
||||
const doc = strFromU8(zip['word/document.xml']);
|
||||
if (!doc.includes('<w:sectPr>')) throw new Error('reference.docx has no sectPr');
|
||||
zip['word/document.xml'] = strToU8(
|
||||
doc.replace(
|
||||
'<w:sectPr>',
|
||||
'<w:sectPr>' +
|
||||
'<w:headerReference xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" w:type="default" r:id="rIdVsNfdHeader" />' +
|
||||
'<w:footerReference xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" w:type="default" r:id="rIdVsNfdFooter" />',
|
||||
),
|
||||
);
|
||||
|
||||
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('<style:footer>')) throw new Error('reference.odt has no footer');
|
||||
const patched = styles
|
||||
.replace(
|
||||
'<style:footer>',
|
||||
`<style:header><text:p text:style-name="MP1">${marking}</text:p></style:header><style:footer>`,
|
||||
)
|
||||
.replace(
|
||||
'<style:footer>\n <text:p text:style-name="MP1">',
|
||||
`<style:footer>\n <text:p text:style-name="MP1">${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})`);
|
||||
39
apps/api/src/import-export/classified-markdown.test.ts
Normal file
39
apps/api/src/import-export/classified-markdown.test.ts
Normal file
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
43
apps/api/src/import-export/classified-markdown.ts
Normal file
43
apps/api/src/import-export/classified-markdown.ts
Normal file
@ -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 };
|
||||
}
|
||||
@ -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<string, Buffer>();
|
||||
|
||||
onModuleInit(): void {
|
||||
if (this.config.env.NODE_ENV === 'test') return; // tests drive drain() directly
|
||||
this.timer = setInterval(() => this.drainSafely(), SWEEP_MS);
|
||||
|
||||
@ -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('<style:header>');
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -31,8 +31,10 @@ const PNG_BASE64 =
|
||||
|
||||
class RecordingConverter extends PandocConverter {
|
||||
lastInput = '';
|
||||
lastReferenceDoc: string | null = null;
|
||||
convert(request: ConversionRequest): Promise<ConversionResult> {
|
||||
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<boolean> {
|
||||
@ -191,6 +193,76 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => {
|
||||
expect(target).toContain(``);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
|
||||
@ -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<string, PageClassification>();
|
||||
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 `<slug>.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 },
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -274,8 +274,9 @@ export class PagesService {
|
||||
state: Uint8Array<ArrayBuffer>,
|
||||
parentId: string | null = null,
|
||||
presetSlug?: string,
|
||||
atLeastClassification: PageClassification | null = null,
|
||||
): Promise<Page> {
|
||||
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<ArrayBuffer>,
|
||||
parentId: string | null = null,
|
||||
presetSlug?: string,
|
||||
atLeastClassification: PageClassification | null = null,
|
||||
): Promise<Page> {
|
||||
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 },
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user