dorfteich/apps/api/src/import-export/pandoc.converter.ts
Claude Fable 5 8ae010218e
Some checks failed
CD / Build and push images (push) Successful in 4m5s
CD / Deploy to Test (push) Successful in 9s
CI / Lint, typecheck, test (push) Failing after 4m21s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m22s
CD / Promote to Int (push) Successful in 11s
Obsidian vault import: endpoint, job orchestration, rollback (#117)
POST /ponds/:pondId/import/vault (pond-admin-gated; a vault import
creates a subtree, uploads files, and creates labels — administration,
not everyday editing) takes the ZIP plus a JSON options field
{parentPageId?, labelIds?, frontmatterMode}. The archive is parsed at
enqueue for fast 400s; the job (new kind import_vault, riding the
existing isImportKind worker routing) re-parses and runs the #116
transform, then: containers top-down → notes (asset placeholders →
uploaded pond files; non-images become page attachments) → tags to
labels (nested tags build a label hierarchy via LabelsService, so
locking and cache invalidation apply) plus the dialog labels.

All-or-nothing: any failure hard-deletes the created pages (children
first) and removes the stored files (quota restored), then surfaces as
import_vault_invalid_zip / import_vault_too_large / quota_exceeded /
conversion_failed — and makes the worker's retry policy safe.

Supporting changes:
- conversion_jobs gains a nullable options jsonb column; enqueue takes
  kind-specific options and a maxInputBytes override (the 25 MiB
  default protects the pandoc sidecar, which a vault never touches —
  vaults use the 64 MiB upload limit).
- insertPage accepts a pre-reserved slug (the batch reserves all slugs
  up front against pond ∪ batch).
- NEW: pages born with content seed their outgoing page_links rows
  (deriveContent now returns wikilinkSlugs) — imported pages would
  otherwise stay invisible to backlinks and the graph until their
  first collab save. Collab still rewrites the rows on every save, and
  the existing phantom resolution heals batch creation order.

import-vault.e2e.db.test.ts (4 tests, real worker drained): gating +
input rejection, the full fixture import (tree under a mount page,
collision suffixes, link rows incl. phantom, nested tag labels, extra
label everywhere, frontmatter stripped, image embedded + PDF attached),
complete quota rollback, and a clean re-import with fresh suffixes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 17:13:11 +02:00

166 lines
6.2 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { AppConfig } from '../config/app-config.service';
/** A single pandoc conversion request (ADR 0009). `input` is the raw source
* bytes; the converter base64-encodes it for pandoc when `from` is a binary
* format (docx/odt/…) and passes it as text otherwise. */
export interface ConversionRequest {
from: string;
to: string;
input: Buffer;
standalone?: boolean;
/** Inline referenced/embedded resources (images) as `data:` URIs in the
* output. Used by the import pipeline (#63): pandoc-server is stateless and
* will not hand back a document's media bytes any other way. */
embedResources?: boolean;
/** 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';
}
export interface ConversionResult {
output: Buffer;
mimeType: string;
}
export type ConversionErrorCode =
| 'converter_unavailable'
| 'converter_timeout'
| 'conversion_failed'
// The pond ran out of storage while an import stored the document's media
// (#63) — final, and surfaced the same way through the worker.
| 'quota_exceeded'
// Vault import (#117): rejected archive / unpacked ceiling exceeded.
| 'import_vault_invalid_zip'
| 'import_vault_too_large';
/** A conversion failure with a stable, localizable code. `retryable` marks
* the transient causes (sidecar down / timed out) the worker retries before
* giving up; a genuine `conversion_failed` (bad/unsupported content) is not
* retried. */
export class ConversionError extends Error {
constructor(
readonly code: ConversionErrorCode,
readonly retryable: boolean,
message?: string,
) {
super(message ?? code);
this.name = 'ConversionError';
}
}
/** Server-side conversion limits (ADR 0009). Input is checked before the
* sidecar call; output is capped while reading the response so a runaway
* conversion can't exhaust memory. */
export const MAX_CONVERSION_INPUT_BYTES = 25 * 1024 * 1024;
export const MAX_CONVERSION_OUTPUT_BYTES = 50 * 1024 * 1024;
/** Per ADR 0009 / issue #62: a single conversion may run for at most 60 s. */
export const CONVERSION_TIMEOUT_MS = 60_000;
/** Formats pandoc reads as binary — their bytes are base64-encoded in the
* request `text` field; text formats are sent verbatim. */
const BINARY_INPUT_FORMATS = new Set(['docx', 'odt', 'epub', 'pptx']);
/** Served content type per pandoc output format. */
const OUTPUT_MIME_TYPES: Readonly<Record<string, string>> = {
html: 'text/html',
markdown: 'text/markdown',
gfm: 'text/markdown',
commonmark: 'text/markdown',
plain: 'text/plain',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
odt: 'application/vnd.oasis.opendocument.text',
};
/**
* Typed client for the pandoc-server sidecar (ADR 0009). Abstract so the job
* worker and its tests depend on the contract, not the HTTP transport — the
* queue-mechanics tests inject a fake, this real implementation is exercised
* against a running container in the integration test.
*/
export abstract class PandocConverter {
abstract convert(request: ConversionRequest): Promise<ConversionResult>;
abstract reachable(): Promise<boolean>;
}
@Injectable()
export class PandocServerConverter extends PandocConverter {
/** Overridable so the timeout path is testable without a 60 s wait. */
protected timeoutMs = CONVERSION_TIMEOUT_MS;
constructor(private readonly config: AppConfig) {
super();
}
private get baseUrl(): string {
return this.config.env.PANDOC_URL;
}
async reachable(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/version`);
return response.ok;
} catch {
return false;
}
}
async convert(request: ConversionRequest): Promise<ConversionResult> {
if (request.input.byteLength > MAX_CONVERSION_INPUT_BYTES) {
throw new ConversionError('conversion_failed', false, 'input exceeds size limit');
}
const text = BINARY_INPUT_FORMATS.has(request.from)
? request.input.toString('base64')
: request.input.toString('utf8');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
let response: Response;
try {
response = await fetch(`${this.baseUrl}/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text,
from: request.from,
to: request.to,
standalone: request.standalone ?? true,
// pandoc-server uses hyphenated option keys; unknown keys are ignored,
// so these are only present when the import pipeline sets them.
...(request.embedResources ? { 'embed-resources': true } : {}),
...(request.wrap ? { wrap: request.wrap } : {}),
}),
signal: controller.signal,
});
} catch (error) {
// AbortController.abort() surfaces as an AbortError → the run timed out;
// anything else means the sidecar could not be reached.
if (error instanceof Error && error.name === 'AbortError') {
throw new ConversionError('converter_timeout', true, 'pandoc timed out');
}
throw new ConversionError('converter_unavailable', true, shortMessage(error));
} finally {
clearTimeout(timer);
}
if (!response.ok) {
// Non-200 = pandoc rejected the content (unknown format, malformed
// document). Not retryable — the same input fails the same way.
const detail = (await response.text().catch(() => '')).slice(0, 300);
throw new ConversionError('conversion_failed', false, detail || `pandoc ${response.status}`);
}
const output = Buffer.from(await response.arrayBuffer());
if (output.byteLength > MAX_CONVERSION_OUTPUT_BYTES) {
throw new ConversionError('conversion_failed', false, 'output exceeds size limit');
}
return { output, mimeType: OUTPUT_MIME_TYPES[request.to] ?? 'application/octet-stream' };
}
}
function shortMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
return message.split('\n')[0]?.slice(0, 200) ?? 'unknown error';
}