All checks were successful
CD / Build and push images (push) Successful in 3m57s
CI / Lint, typecheck, test (push) Successful in 3m7s
CI / Auth e2e pack (push) Successful in 3m58s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
Two export paths, both permission-aware (permissions.md):
- `GET /ponds/:id/export/markdown` streams a ZIP of the pond's readable
pages as Markdown (one `<slug>.md` per page, a `media/` directory,
wikilinks rewritten to relative `[text](slug.md)` links, image sources to
`media/<id>.<ext>`). The `reader` guard is "may see the pond"; the service
filters to the pages the requester may actually read, so a label-restricted
reader gets only their slice. Media is appended as read streams and pages as
small strings, so memory stays bounded for a large pond (500-page test).
- `POST /pages/:id/export {format: docx|odt}` enqueues a `markdown → pandoc →
file` conversion job (the #62 queue): embedded images are inlined as data
URIs so the sidecar embeds them, wikilinks flatten to text. The client polls
`GET /jobs/:id` and downloads `GET /jobs/:id/result`.
Frontend: office-export buttons in the page menu (`.docx`/`.odt` run the job
and download the result; PDF is a disabled placeholder for Gotenberg, #67) and
a "Download pond as ZIP" link in pond settings. New `export` i18n namespace
(de+en). Markdown copy/download stay as-is (#30).
Robustness: the pond ZIP skips an attachment whose bytes are missing on disk
(data drift) rather than letting an unhandled read-stream error crash the api;
`FileStorageService.exists` gates inclusion, with a defensive stream error
handler. The per-page export drops an unreadable image the same way.
- shared: EXPORT_FORMATS + pageExportInputSchema; export-markdown transform
helpers (image/wikilink rewrites, MIME→extension).
- deps: archiver (streaming ZIP; v7 for CommonJS compat), fflate (dev, reads
ZIPs in tests).
- tests: export-markdown unit + export.service.db (ZIP contents & relative
links, label-restricted omission, docx job with inlined images, 500-page
streaming, missing-media skip); e2e export pack (ZIP download; `.docx`
self-skips without a pandoc sidecar, as in the import pack, #64).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
70 lines
2.6 KiB
TypeScript
70 lines
2.6 KiB
TypeScript
/**
|
|
* Markdown rewrites for export (issue #65, ADR 0009). A page's cached Markdown
|
|
* stores images as `` and wikilinks as `[[slug]]` /
|
|
* `[[slug|text]]`; neither is portable as-is, so each export target rewrites
|
|
* them: the pond ZIP to relative files, an office document to inline data.
|
|
*/
|
|
|
|
// A wikilink token — `[[slug]]` or `[[slug|display text]]`. Only real wikilink
|
|
// nodes serialize this way (literal brackets in text are escaped), so matching
|
|
// the raw token is safe.
|
|
const WIKILINK = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
|
|
// A Markdown image — ``. In cached page Markdown `src` is always a
|
|
// bare attachment id (no parentheses), so the group is unambiguous.
|
|
const IMAGE = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
|
|
|
/** The image attachment ids referenced by a page's Markdown, in order. */
|
|
export function imageFileIds(markdown: string): string[] {
|
|
const ids: string[] = [];
|
|
for (const match of markdown.matchAll(IMAGE)) ids.push(match[2]!);
|
|
return ids;
|
|
}
|
|
|
|
/**
|
|
* Rewrite for the pond ZIP: a wikilink becomes a relative link to the target
|
|
* page's `.md` file when that page is in the export (readable), else its plain
|
|
* display text; an image source becomes a relative path into `media/`.
|
|
*/
|
|
export function markdownForZip(
|
|
markdown: string,
|
|
readableSlugs: Set<string>,
|
|
mediaNameById: Map<string, string>,
|
|
): string {
|
|
return markdown
|
|
.replace(WIKILINK, (_whole, slug: string, text?: string) => {
|
|
const label = (text ?? slug).trim();
|
|
return readableSlugs.has(slug) ? `[${label}](${encodeURIComponent(slug)}.md)` : label;
|
|
})
|
|
.replace(IMAGE, (whole, alt: string, src: string) => {
|
|
const name = mediaNameById.get(src);
|
|
return name ? `` : whole;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Rewrite for a standalone office document: a wikilink becomes plain display
|
|
* text (there is no target document to link to), and an image source becomes an
|
|
* inline `data:` URI so pandoc embeds the bytes.
|
|
*/
|
|
export function markdownForDocument(markdown: string, dataUriById: Map<string, string>): string {
|
|
return markdown
|
|
.replace(WIKILINK, (_whole, slug: string, text?: string) => (text ?? slug).trim())
|
|
.replace(IMAGE, (whole, alt: string, src: string) => {
|
|
const uri = dataUriById.get(src);
|
|
return uri ? `` : whole;
|
|
});
|
|
}
|
|
|
|
const EXTENSION_BY_MIME: Readonly<Record<string, string>> = {
|
|
'image/png': 'png',
|
|
'image/jpeg': 'jpg',
|
|
'image/gif': 'gif',
|
|
'image/webp': 'webp',
|
|
'image/svg+xml': 'svg',
|
|
};
|
|
|
|
/** File extension for an attachment's stored bytes, from its MIME type. */
|
|
export function imageExtension(mimeType: string): string {
|
|
return EXTENSION_BY_MIME[mimeType] ?? 'bin';
|
|
}
|