From 546e8279ac53867b3febc7d4bf2f6b9bb0ad9fe5 Mon Sep 17 00:00:00 2001 From: "Claude Opus 4.8" Date: Fri, 10 Jul 2026 07:34:43 +0200 Subject: [PATCH] Import .docx and .odt documents as new pages (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uploading a Word/OpenOffice document to POST /ponds/:id/import enqueues a conversion job (the #62 queue) that produces a new page in the pond; the client polls GET /jobs/:id for the created resultPageId. Pipeline (ImportService, ADR 0009): pandoc-server is stateless and hands back a document's media no other way, so we convert in two passes — docx/odt → html with embed-resources inlines every image as a data: URI, then html → gfm produces clean structural Markdown with those data URIs still inline. Embedded images are stored as pond files (with quota accounting) and their references rewritten to file ids on the Markdown text before parsing (the editor parser only admits png/jpeg/gif/webp data URIs); an image whose bytes the upload pipeline rejects is dropped, not fatal. The title comes from a leading top-level heading (removed from the body) else the file name. The page is created from the resulting Yjs state. The shared conversion worker routes import-kind jobs to the pipeline via a token (breaking a module cycle), so import inherits the queue's locking, retry, and restart-survival. Media stored during a failed attempt is rolled back; a pond that runs out of storage fails the job with quota_exceeded. - schema: ConversionJob gains pond_id / source_name / result_page_id (migration 20260710041215_import_pages_conversion); ConversionJobView gains resultPageId. - PagesService.createWithState / yjs-content docToState build a page from a prepared document; FilesService.linkAttachmentsToPage links import media. - fixtures/import/: representative .docx/.odt corpus (headings, lists, nested lists, tables, images, links, bold/italic) with expected-Markdown snapshots; scripts/gen-import-fixtures.mjs regenerates them. - tests: import.service.db.test.ts drives the full pipeline with a fake converter (CI); import.fixtures.test.ts runs the real two-pass conversion over the corpus and a 50-page timing check against a reachable sidecar. - i18n: import_unsupported_format (de+en). Limits documented (25 MiB input, 60 s per pass). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1 --- .prettierignore | 5 + .../migration.sql | 10 + apps/api/prisma/schema.prisma | 13 +- apps/api/src/files/files.module.ts | 2 +- apps/api/src/files/files.service.ts | 15 + .../import-export/conversion-job.service.ts | 8 + .../conversion-worker.service.ts | 12 + .../src/import-export/import-export.module.ts | 20 +- .../api/src/import-export/import.constants.ts | 21 ++ .../src/import-export/import.controller.ts | 39 +++ .../src/import-export/import.fixtures.test.ts | 78 +++++ .../import-export/import.service.db.test.ts | 270 +++++++++++++++ apps/api/src/import-export/import.service.ts | 322 ++++++++++++++++++ .../api/src/import-export/pandoc.converter.ts | 18 +- apps/api/src/pages/pages.service.ts | 36 +- apps/api/src/pages/yjs-content.ts | 17 +- eslint.config.mjs | 8 +- fixtures/import/README.md | 42 +++ fixtures/import/article.docx | Bin 0 -> 11602 bytes fixtures/import/article.docx.expected.md | 29 ++ fixtures/import/article.odt | Bin 0 -> 8275 bytes fixtures/import/article.odt.expected.md | 32 ++ fixtures/import/article.src.html | 21 ++ fixtures/import/formatting.docx | Bin 0 -> 10524 bytes fixtures/import/formatting.docx.expected.md | 13 + fixtures/import/formatting.odt | Bin 0 -> 7690 bytes fixtures/import/formatting.odt.expected.md | 15 + fixtures/import/formatting.src.html | 6 + packages/shared/i18n/de/errors.json | 1 + packages/shared/i18n/en/errors.json | 1 + packages/shared/src/conversion.ts | 11 +- scripts/gen-import-fixtures.mjs | 69 ++++ 32 files changed, 1116 insertions(+), 18 deletions(-) create mode 100644 apps/api/prisma/migrations/20260710041215_import_pages_conversion/migration.sql create mode 100644 apps/api/src/import-export/import.constants.ts create mode 100644 apps/api/src/import-export/import.controller.ts create mode 100644 apps/api/src/import-export/import.fixtures.test.ts create mode 100644 apps/api/src/import-export/import.service.db.test.ts create mode 100644 apps/api/src/import-export/import.service.ts create mode 100644 fixtures/import/README.md create mode 100644 fixtures/import/article.docx create mode 100644 fixtures/import/article.docx.expected.md create mode 100644 fixtures/import/article.odt create mode 100644 fixtures/import/article.odt.expected.md create mode 100644 fixtures/import/article.src.html create mode 100644 fixtures/import/formatting.docx create mode 100644 fixtures/import/formatting.docx.expected.md create mode 100644 fixtures/import/formatting.odt create mode 100644 fixtures/import/formatting.odt.expected.md create mode 100644 fixtures/import/formatting.src.html create mode 100644 scripts/gen-import-fixtures.mjs diff --git a/.prettierignore b/.prettierignore index 6ae2ada..35e1aed 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,3 +6,8 @@ pnpm-lock.yaml # Must stay byte-identical to docToMarkdown's own output (issue #32) — # Prettier's Markdown table/list opinions would break that fixed point. apps/api/prisma/fixtures/content-page.md +# Import fixture corpus (issue #63): the expected-Markdown snapshots and the +# HTML sources must stay exactly as pandoc produces/consumes them — Prettier's +# Markdown/HTML opinions would break the regression tests. +fixtures/import/*.expected.md +fixtures/import/*.src.html diff --git a/apps/api/prisma/migrations/20260710041215_import_pages_conversion/migration.sql b/apps/api/prisma/migrations/20260710041215_import_pages_conversion/migration.sql new file mode 100644 index 0000000..1c49877 --- /dev/null +++ b/apps/api/prisma/migrations/20260710041215_import_pages_conversion/migration.sql @@ -0,0 +1,10 @@ +-- AlterTable +ALTER TABLE "conversion_jobs" ADD COLUMN "pond_id" TEXT, +ADD COLUMN "result_page_id" TEXT, +ADD COLUMN "source_name" TEXT; + +-- AddForeignKey +ALTER TABLE "conversion_jobs" ADD CONSTRAINT "conversion_jobs_pond_id_fkey" FOREIGN KEY ("pond_id") REFERENCES "ponds"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "conversion_jobs" ADD CONSTRAINT "conversion_jobs_result_page_id_fkey" FOREIGN KEY ("result_page_id") REFERENCES "pages"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index a54cb94..e77ccf4 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -81,6 +81,7 @@ model Pond { attachments Attachment[] labels Label[] grants RoleGrant[] + conversionJobs ConversionJob[] @@index([ownerId]) @@map("ponds") @@ -164,6 +165,7 @@ model Page { labels PageLabel[] outgoingLinks PageLink[] @relation("outgoingLinks") incomingLinks PageLink[] @relation("incomingLinks") + conversionJobs ConversionJob[] @@unique([pondId, slug]) @@index([pondId]) @@ -551,7 +553,16 @@ model ConversionJob { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + /// Import jobs (#63) carry the pond the document is imported into, the + /// original upload file name (title fallback), and the page they produced. + /// All null for a plain byte→byte conversion (export, #62/#65). + pondId String? @map("pond_id") + sourceName String? @map("source_name") + resultPageId String? @map("result_page_id") + + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + pond Pond? @relation(fields: [pondId], references: [id], onDelete: Cascade) + page Page? @relation(fields: [resultPageId], references: [id], onDelete: SetNull) @@index([status, createdAt]) @@map("conversion_jobs") diff --git a/apps/api/src/files/files.module.ts b/apps/api/src/files/files.module.ts index 26665e0..1fa378a 100644 --- a/apps/api/src/files/files.module.ts +++ b/apps/api/src/files/files.module.ts @@ -11,6 +11,6 @@ import { FilesService } from './files.service'; imports: [PondsModule, QuotasModule], controllers: [FilesController], providers: [FilesService, FileStorageService], - exports: [FileStorageService], + exports: [FileStorageService, FilesService], }) export class FilesModule {} diff --git a/apps/api/src/files/files.service.ts b/apps/api/src/files/files.service.ts index 742a751..da943be 100644 --- a/apps/api/src/files/files.service.ts +++ b/apps/api/src/files/files.service.ts @@ -169,6 +169,21 @@ export class FilesService { } } + /** + * Point a set of just-uploaded attachments at the page that now embeds them + * (issue #63 import). The import worker stores a document's media before the + * page exists (the page's Yjs state references their ids), then calls this so + * they list under the page and are purged with it (#31), the same invariant + * the collab persistence hook maintains for pasted images. + */ + async linkAttachmentsToPage(attachmentIds: string[], pageId: string): Promise { + if (attachmentIds.length === 0) return; + await this.prisma.attachment.updateMany({ + where: { id: { in: attachmentIds } }, + data: { pageId }, + }); + } + /** Upload against the pond of `pageId`, linked to that page (#61). */ async uploadToPage( user: User, diff --git a/apps/api/src/import-export/conversion-job.service.ts b/apps/api/src/import-export/conversion-job.service.ts index 4667f25..5713f3b 100644 --- a/apps/api/src/import-export/conversion-job.service.ts +++ b/apps/api/src/import-export/conversion-job.service.ts @@ -15,6 +15,10 @@ export interface EnqueueConversion { to: string; input: Buffer; standalone?: boolean; + /** Set for import jobs (#63): the pond the document is imported into and the + * original upload file name (a title fallback). */ + pondId?: string; + sourceName?: string; } export interface ConversionResultPayload { @@ -55,6 +59,8 @@ export class ConversionJobService { const job = await this.prisma.conversionJob.create({ data: { ownerId: request.ownerId, + pondId: request.pondId ?? null, + sourceName: request.sourceName ?? null, kind: request.kind, sourceFormat: request.from, targetFormat: request.to, @@ -103,6 +109,8 @@ export class ConversionJobService { sourceFormat: job.sourceFormat, targetFormat: job.targetFormat, errorCode: job.errorCode, + // Set once an import job succeeds (#63) so the client can open the page. + resultPageId: job.resultPageId, createdAt: job.createdAt.toISOString(), updatedAt: job.updatedAt.toISOString(), }; diff --git a/apps/api/src/import-export/conversion-worker.service.ts b/apps/api/src/import-export/conversion-worker.service.ts index aaa9639..97d4964 100644 --- a/apps/api/src/import-export/conversion-worker.service.ts +++ b/apps/api/src/import-export/conversion-worker.service.ts @@ -1,10 +1,12 @@ import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; import { ConversionJob } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; import { AppConfig } from '../config/app-config.service'; import { PrismaService } from '../prisma/prisma.service'; +import { IMPORT_PROCESSOR, ImportProcessor, isImportKind } from './import.constants'; import { ConversionError, PandocConverter } from './pandoc.converter'; /** How often the worker sweeps for pending jobs on its own — the safety net @@ -38,6 +40,9 @@ export class ConversionWorker implements OnModuleInit, OnModuleDestroy { private readonly converter: PandocConverter, private readonly config: AppConfig, private readonly logger: PinoLogger, + // Resolved lazily to break the construction cycle (the import service + // enqueues via the job service, which wakes this worker). + private readonly moduleRef: ModuleRef, ) { this.logger.setContext(ConversionWorker.name); } @@ -103,6 +108,13 @@ export class ConversionWorker implements OnModuleInit, OnModuleDestroy { private async process(job: ConversionJob): Promise { try { + if (isImportKind(job.kind)) { + // Import runs a multi-step pipeline and records its own success (the + // created page id) on the job (#63). Resolved lazily via a token so the + // worker's file never imports the import service's (avoids a cycle). + await this.moduleRef.get(IMPORT_PROCESSOR, { strict: false }).run(job); + return; + } const result = await this.converter.convert({ from: job.sourceFormat, to: job.targetFormat, diff --git a/apps/api/src/import-export/import-export.module.ts b/apps/api/src/import-export/import-export.module.ts index 7bf16f3..28d0557 100644 --- a/apps/api/src/import-export/import-export.module.ts +++ b/apps/api/src/import-export/import-export.module.ts @@ -1,20 +1,32 @@ import { Module } from '@nestjs/common'; +import { FilesModule } from '../files/files.module'; +import { PagesModule } from '../pages/pages.module'; + import { ConversionJobService } from './conversion-job.service'; import { ConversionWorker } from './conversion-worker.service'; +import { IMPORT_PROCESSOR } from './import.constants'; +import { ImportController } from './import.controller'; +import { ImportService } from './import.service'; import { JobsController } from './jobs.controller'; import { PandocConverter, PandocServerConverter } from './pandoc.converter'; /** - * Import/export orchestration (ADR 0009, issue #62): the conversion job queue, - * its worker, and the pandoc-server client. Later stories (#63 import, #65 - * export) add the feature endpoints that enqueue jobs here. + * Import/export orchestration (ADR 0009): the conversion job queue, its worker, + * the pandoc-server client (#62), and the document import pipeline (#63, which + * turns an uploaded `.docx`/`.odt` into a new page). Export (#65) will add its + * feature endpoints here too. */ @Module({ - controllers: [JobsController], + imports: [FilesModule, PagesModule], + controllers: [JobsController, ImportController], providers: [ ConversionJobService, ConversionWorker, + ImportService, + // The worker resolves the import pipeline through this token (never the + // class), so its file does not import the import service's (avoids a cycle). + { provide: IMPORT_PROCESSOR, useExisting: ImportService }, // Bind the abstract converter to the HTTP implementation; tests override // this provider with a fake so the queue mechanics need no live sidecar. { provide: PandocConverter, useClass: PandocServerConverter }, diff --git a/apps/api/src/import-export/import.constants.ts b/apps/api/src/import-export/import.constants.ts new file mode 100644 index 0000000..30d7aa2 --- /dev/null +++ b/apps/api/src/import-export/import.constants.ts @@ -0,0 +1,21 @@ +import { ConversionJob } from '@prisma/client'; + +/** + * Contract between the shared conversion worker and the import pipeline, kept in + * its own module so neither imports the other's file (the worker resolves the + * processor lazily via this token, breaking what would otherwise be a cycle: + * import.service → conversion-job.service → conversion-worker.service). + */ +export const IMPORT_PROCESSOR = Symbol('IMPORT_PROCESSOR'); + +export interface ImportProcessor { + /** Run one import job to completion, recording the created page on the job or + * throwing a ConversionError for the worker's retry/fail policy. */ + run(job: ConversionJob): Promise; +} + +/** True for a job the import pipeline processes rather than the plain pandoc + * byte→byte path (export, #62/#65). */ +export function isImportKind(kind: string): boolean { + return kind.startsWith('import_'); +} diff --git a/apps/api/src/import-export/import.controller.ts b/apps/api/src/import-export/import.controller.ts new file mode 100644 index 0000000..185cb26 --- /dev/null +++ b/apps/api/src/import-export/import.controller.ts @@ -0,0 +1,39 @@ +import { + BadRequestException, + Controller, + Param, + Post, + Req, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ConversionJobView, MAX_UPLOAD_PARSE_BYTES } from '@dorfteich/shared'; + +import { AuthedRequest } from '../auth/auth.guard'; +import { RequiresPondRole } from '../permissions/permission.decorators'; + +import { ImportService } from './import.service'; + +/** + * Document import (ADR 0009, issue #63). Uploading a `.docx`/`.odt` enqueues a + * conversion job (#62 queue) that produces a new page in the pond; the client + * polls `GET /jobs/:id` for `resultPageId`. Creating a page requires pond-wide + * editor access (permissions.md, same rule as `POST /ponds/:id/pages`). + */ +@Controller('ponds/:pondId') +export class ImportController { + constructor(private readonly imports: ImportService) {} + + @Post('import') + @RequiresPondRole('editor', { idParam: 'pondId' }) + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_UPLOAD_PARSE_BYTES } })) + async import( + @Param('pondId') pondId: string, + @UploadedFile() file: Express.Multer.File | undefined, + @Req() request: AuthedRequest, + ): Promise { + if (!file) throw new BadRequestException({ code: 'bad_request' }); + return this.imports.enqueue(request.user!, pondId, file); + } +} diff --git a/apps/api/src/import-export/import.fixtures.test.ts b/apps/api/src/import-export/import.fixtures.test.ts new file mode 100644 index 0000000..735a3d0 --- /dev/null +++ b/apps/api/src/import-export/import.fixtures.test.ts @@ -0,0 +1,78 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { markdownToDoc } from '@dorfteich/shared'; +import { beforeAll, describe, expect, it, TestContext } from 'vitest'; + +import { AppConfig } from '../config/app-config.service'; + +import { convertImportedDocument } from './import.service'; +import { CONVERSION_TIMEOUT_MS, PandocServerConverter } from './pandoc.converter'; + +/** + * Import fidelity regression (issue #63, ADR 0009): runs the real two-pass + * pandoc conversion over the committed `.docx`/`.odt` corpus and asserts each + * produces its expected Markdown. Needs a reachable pandoc sidecar (the pinned + * `pandoc/core:3.6`, so output matches the snapshots) — each test skips itself + * when none is configured, and CI starts one and points `PANDOC_URL` at it. + */ +const PANDOC_URL = process.env.PANDOC_URL ?? 'http://localhost:3030'; +// The test runner's cwd is `apps/api`; the corpus lives at the repo root. +const FIXTURES = join(process.cwd(), '../../fixtures/import'); + +const converter = new PandocServerConverter({ + env: { PANDOC_URL }, +} as unknown as AppConfig); + +let reachable = false; + +/** Normalise embedded image `data:` URIs to the stable token the snapshots use + * (the base64 payload is volatile and not what we are pinning). */ +function normalize(markdown: string): string { + return markdown.replace( + /data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=]+/g, + 'data:embedded-image', + ); +} + +const CORPUS = ['article.docx', 'article.odt', 'formatting.docx', 'formatting.odt']; + +describe('import fixture corpus (real pandoc, issue #63)', () => { + beforeAll(async () => { + reachable = await converter.reachable().catch(() => false); + }); + + for (const fixture of CORPUS) { + it(`converts ${fixture} to its expected Markdown`, async (ctx: TestContext) => { + if (!reachable) ctx.skip(); + const format = fixture.endsWith('.odt') ? 'odt' : 'docx'; + const document = readFileSync(join(FIXTURES, fixture)); + const expected = readFileSync(join(FIXTURES, `${fixture}.expected.md`), 'utf8'); + + const markdown = await convertImportedDocument(converter, format, document); + expect(normalize(markdown)).toBe(expected); + + // The Markdown must also parse into a valid editor document (no schema + // surprises from real-world structure). + expect(() => markdownToDoc(markdown)).not.toThrow(); + }); + } + + it('imports a 50-page document within the conversion timeout', async (ctx: TestContext) => { + if (!reachable) ctx.skip(); + // Build a ~50-page document by repeating a page of structured content, then + // convert it to docx once and time the import conversion of that document. + const onePage = + '# Section\n\n' + 'A paragraph of survey notes about the pond. '.repeat(20) + '\n\n'; + const large = Array.from({ length: 50 }, () => onePage).join('\n---\n\n'); + const docx = await converter.convert({ from: 'gfm', to: 'docx', input: Buffer.from(large) }); + + const started = Date.now(); + const markdown = await convertImportedDocument(converter, 'docx', docx.output); + const elapsed = Date.now() - started; + + expect(markdown.length).toBeGreaterThan(1000); + // Comfortably inside the documented 60 s per-conversion ceiling (ADR 0009). + expect(elapsed).toBeLessThan(CONVERSION_TIMEOUT_MS); + }); +}); diff --git a/apps/api/src/import-export/import.service.db.test.ts b/apps/api/src/import-export/import.service.db.test.ts new file mode 100644 index 0000000..04cb74d --- /dev/null +++ b/apps/api/src/import-export/import.service.db.test.ts @@ -0,0 +1,270 @@ +import { INestApplication } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { AuthTokensService } from '../auth/auth-tokens.service'; +import { createTestApp, sessionCookieOf } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { UsersService } from '../users/users.service'; + +import { ConversionWorker } from './conversion-worker.service'; +import { + ConversionError, + ConversionRequest, + ConversionResult, + PandocConverter, +} from './pandoc.converter'; + +/** + * Document import pipeline (issue #63): upload → conversion job → media stored + * as pond files → new page. Driven by an injected fake converter that returns a + * chosen Markdown for the structural pass, so the orchestration (media + * extraction, quota, title, rollback, failure codes) is tested deterministically + * without a live pandoc sidecar. The real conversion of the fixture corpus is + * covered against a running sidecar by import.fixtures.test.ts. + */ + +/** A 1×1 PNG — passes the raster magic-byte sniff, so it is stored as an image. */ +const PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; + +/** A converter whose `markdown` each test sets. The import pipeline calls it + * twice (source→html, then html→gfm); the html pass is echoed, the gfm pass + * returns the chosen Markdown (its input is irrelevant to these tests). A test + * may instead make a pass throw to exercise a conversion failure. */ +class FakeConverter extends PandocConverter { + markdown = '# Untitled\n\nBody.'; + failHtmlWith: ConversionError | null = null; + + convert(request: ConversionRequest): Promise { + if (request.to === 'html') { + if (this.failHtmlWith) return Promise.reject(this.failHtmlWith); + return Promise.resolve({ output: Buffer.from(''), mimeType: 'text/html' }); + } + return Promise.resolve({ output: Buffer.from(this.markdown), mimeType: 'text/markdown' }); + } + reachable(): Promise { + return Promise.resolve(true); + } +} + +describe.skipIf(!hasTestDb)('document import (e2e, issue #63)', () => { + let app: INestApplication; + let prisma: PrismaClient; + let worker: ConversionWorker; + let fake: FakeConverter; + const suffix = uniqueSuffix(); + const password = 'importiere meine dokumente 1'; + + const owner = { username: `iris-import-${suffix}`, displayName: `Iris Import ${suffix}` }; + const outsider = { username: `otis-out-${suffix}`, displayName: `Otis Out ${suffix}` }; + let ownerId: string; + let pondId: string; + let ownerCookie: string; + let outsiderCookie: string; + + const api = () => request(app.getHttpServer()); + + async function loginOf(username: string): Promise { + const res = await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: username, password }) + .expect(200); + return sessionCookieOf(res); + } + + /** Upload a document and drain the queue; returns the finished job view. */ + async function importDoc(fileName: string, cookie = ownerCookie): Promise { + const enqueued = await api() + .post(`/api/v1/ponds/${pondId}/import`) + .set('Cookie', cookie) + .attach('file', Buffer.from('source-document-bytes'), fileName) + .expect(201); + await worker.drain(); + return api().get(`/api/v1/jobs/${enqueued.body.id}`).set('Cookie', cookie).expect(200); + } + + async function markdownOf(pageId: string): Promise { + const cache = await prisma.pageContentCache.findUniqueOrThrow({ where: { pageId } }); + return cache.markdown; + } + + beforeAll(async () => { + prisma = createTestPrisma(); + await prisma.rateLimit.deleteMany({}); + fake = new FakeConverter(); + app = await createTestApp((builder) => + builder.overrideProvider(PandocConverter).useValue(fake), + ); + worker = app.get(ConversionWorker); + + const users = app.get(UsersService); + const tokens = app.get(AuthTokensService); + const ownerUser = await users.createUser({ + username: owner.username, + email: `${owner.username}@example.org`, + displayName: owner.displayName, + password, + locale: 'en', + }); + ownerId = ownerUser.id; + // Verifying the e-mail creates the owner's personal pond (+ owner-admin + // grant), which the owner may import into. + const verify = await tokens.issue(ownerUser.id, 'EMAIL_VERIFICATION', 600); + await api().post('/api/v1/auth/verify-email').send({ token: verify }).expect(204); + ownerCookie = await loginOf(owner.username); + pondId = (await prisma.pond.findFirstOrThrow({ where: { ownerId, type: 'PERSONAL' } })).id; + + const outsiderUser = await users.createUser({ + username: outsider.username, + email: `${outsider.username}@example.org`, + displayName: outsider.displayName, + password, + locale: 'en', + }); + await users.markEmailVerified(outsiderUser.id); + outsiderCookie = await loginOf(outsider.username); + }); + + afterAll(async () => { + await prisma.conversionJob.deleteMany({ where: { owner: { username: { contains: suffix } } } }); + await prisma.quotaOverride.deleteMany({ where: { subjectId: pondId } }); + const where = { pond: { owner: { username: { contains: suffix } } } }; + await prisma.attachment.deleteMany({ where }); + // Imports created pages; remove them before their pond (pages restrict it). + await prisma.page.deleteMany({ where }); + await prisma.roleGrant.deleteMany({ where }); + await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } }); + await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('imports a document as a new page, titled from the leading heading', async () => { + fake.markdown = '# Imported Report\n\nA paragraph with **bold** text.\n\n## Section\n\nMore.'; + const job = await importDoc('report.docx'); + + expect(job.body.status).toBe('succeeded'); + expect(job.body.errorCode).toBeNull(); + expect(job.body.resultPageId).toBeTruthy(); + + const page = await prisma.page.findUniqueOrThrow({ where: { id: job.body.resultPageId } }); + expect(page.title).toBe('Imported Report'); + expect(page.pondId).toBe(pondId); + // The leading H1 became the title and was removed from the body; the rest + // of the structure survives. + const markdown = await markdownOf(page.id); + expect(markdown).not.toMatch(/^# Imported Report/); + expect(markdown).toContain('**bold**'); + expect(markdown).toContain('## Section'); + }); + + it('falls back to the file name (without extension) when there is no heading', async () => { + fake.markdown = 'Just a paragraph, no heading.'; + const job = await importDoc('meeting-notes.odt'); + + const page = await prisma.page.findUniqueOrThrow({ where: { id: job.body.resultPageId } }); + expect(page.title).toBe('meeting-notes'); + }); + + it('stores an embedded image as a pond file with quota accounting', async () => { + fake.markdown = `# With Image\n\nBefore.\n\n![a dot](data:image/png;base64,${PNG_BASE64})\n\nAfter.`; + const before = await prisma.pondUsage.findUnique({ where: { pondId } }); + const usedBefore = Number(before?.storageBytesUsed ?? 0n); + + const job = await importDoc('illustrated.docx'); + const pageId = job.body.resultPageId as string; + + const files = await prisma.attachment.findMany({ where: { pageId } }); + expect(files).toHaveLength(1); + expect(files[0]!.mimeType).toBe('image/png'); + expect(files[0]!.sizeBytes).toBeGreaterThan(0); + + // Usage grew by exactly the stored file's size (quota accounting). + const after = await prisma.pondUsage.findUniqueOrThrow({ where: { pondId } }); + expect(Number(after.storageBytesUsed) - usedBefore).toBe(files[0]!.sizeBytes); + + // The image node now references the stored file id, not the data URI. + const markdown = await markdownOf(pageId); + expect(markdown).toContain(`(${files[0]!.id})`); + expect(markdown).not.toContain('data:image'); + }); + + it('drops an image whose type the upload pipeline rejects, keeping the rest', async () => { + // A vector image Word can embed — not a raster type, not on the allowlist. + fake.markdown = '# Vector\n\nText before.\n\n![v](data:image/x-emf;base64,AAAA)\n\nText after.'; + const job = await importDoc('vector.docx'); + const pageId = job.body.resultPageId as string; + + expect(await prisma.attachment.count({ where: { pageId } })).toBe(0); + const markdown = await markdownOf(pageId); + expect(markdown).toContain('Text before.'); + expect(markdown).toContain('Text after.'); + expect(markdown).not.toContain('data:image'); + }); + + it('fails the job with a meaningful error for a corrupt/unsupported document', async () => { + fake.failHtmlWith = new ConversionError('conversion_failed', false, 'not a real docx'); + const pagesBefore = await prisma.page.count({ where: { pondId } }); + + const job = await importDoc('broken.docx'); + fake.failHtmlWith = null; + + expect(job.body.status).toBe('failed'); + expect(job.body.errorCode).toBe('conversion_failed'); + expect(job.body.resultPageId).toBeNull(); + // No page is created for a failed import. + expect(await prisma.page.count({ where: { pondId } })).toBe(pagesBefore); + }); + + it('fails and rolls back media when the pond runs out of storage', async () => { + // Cap this pond's storage below one image so the media store fails. + await prisma.quotaOverride.upsert({ + where: { + subjectType_subjectId_quotaKey: { + subjectType: 'POND', + subjectId: pondId, + quotaKey: 'storage_bytes', + }, + }, + create: { subjectType: 'POND', subjectId: pondId, quotaKey: 'storage_bytes', value: 10 }, + update: { value: 10 }, + }); + const usedBefore = Number( + (await prisma.pondUsage.findUnique({ where: { pondId } }))?.storageBytesUsed ?? 0n, + ); + const pagesBefore = await prisma.page.count({ where: { pondId } }); + fake.markdown = `# Too big\n\n![x](data:image/png;base64,${PNG_BASE64})`; + + const job = await importDoc('too-big.docx'); + + expect(job.body.status).toBe('failed'); + expect(job.body.errorCode).toBe('quota_exceeded'); + // Neither a page nor any stored bytes survive the failed attempt. + expect(await prisma.page.count({ where: { pondId } })).toBe(pagesBefore); + const usedAfter = Number( + (await prisma.pondUsage.findUnique({ where: { pondId } }))?.storageBytesUsed ?? 0n, + ); + expect(usedAfter).toBe(usedBefore); + + await prisma.quotaOverride.deleteMany({ where: { subjectId: pondId } }); + }); + + it('rejects an unsupported upload extension (400) before enqueuing', async () => { + const res = await api() + .post(`/api/v1/ponds/${pondId}/import`) + .set('Cookie', ownerCookie) + .attach('file', Buffer.from('hello'), 'notes.txt') + .expect(400); + expect(res.body.code).toBe('import_unsupported_format'); + }); + + it('hides the pond from a non-member (404, not an import)', async () => { + await api() + .post(`/api/v1/ponds/${pondId}/import`) + .set('Cookie', outsiderCookie) + .attach('file', Buffer.from('x'), 'doc.docx') + .expect(404); + }); +}); diff --git a/apps/api/src/import-export/import.service.ts b/apps/api/src/import-export/import.service.ts new file mode 100644 index 0000000..cf9f785 --- /dev/null +++ b/apps/api/src/import-export/import.service.ts @@ -0,0 +1,322 @@ +import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; +import { ConversionJob, User } from '@prisma/client'; +import { ConversionJobView, editorSchema, markdownToDoc } from '@dorfteich/shared'; +import { Node } from 'prosemirror-model'; +import { PinoLogger } from 'nestjs-pino'; + +import { FilesService } from '../files/files.service'; +import { PagesService } from '../pages/pages.service'; +import { docToState } from '../pages/yjs-content'; +import { PrismaService } from '../prisma/prisma.service'; + +import { ImportProcessor } from './import.constants'; +import { ConversionError, PandocConverter } from './pandoc.converter'; +import { ConversionJobService } from './conversion-job.service'; + +/** pandoc source format per accepted upload extension (ADR 0009). */ +const IMPORT_FORMATS: Readonly> = { + docx: 'docx', + odt: 'odt', +}; + +/** Job `kind` per source format, so the worker can route the job to the import + * pipeline (a plain byte→byte conversion has a different kind, #62/#65). */ +function importKind(format: string): string { + return `import_${format}`; +} + +/** + * Writer format for the structural pass. GFM matches our editor schema + * (tables, task lists, strikethrough); `-implicit_figures` keeps images as + * inline `![alt](src)` instead of wrapping them in a figure with a duplicated + * caption; `-raw_html` stops pandoc emitting raw HTML for constructs it can't + * represent (our Markdown parser runs with `html:false` and would drop it + * anyway — per ADR 0009 we preserve structure, not layout). + */ +const IMPORT_MARKDOWN_FORMAT = 'gfm-implicit_figures-raw_html'; + +/** + * The two-pass conversion at the heart of import, extracted so tests can run it + * over the fixture corpus against a real sidecar. pandoc-server is stateless and + * hands back a document's media no other way, so we first inline every image as + * a `data:` URI (`source → html`, embed-resources), then produce clean + * structural Markdown that still carries those data URIs inline at the right + * positions (`html → gfm`, no wrapping). + */ +export async function convertImportedDocument( + converter: PandocConverter, + sourceFormat: string, + input: Buffer, +): Promise { + const html = await converter.convert({ + from: sourceFormat, + to: 'html', + input, + standalone: false, + embedResources: true, + }); + const md = await converter.convert({ + from: 'html', + to: IMPORT_MARKDOWN_FORMAT, + input: html.output, + standalone: false, + wrap: 'none', + }); + return md.output.toString('utf8'); +} + +/** Minimal shape of a ProseMirror document as JSON — enough to walk it for + * image nodes and the leading heading without pulling in prosemirror types. */ +interface PmNode { + type: string; + attrs?: Record; + content?: PmNode[]; + text?: string; + marks?: unknown[]; +} + +interface DecodedImage { + buffer: Buffer; + extension: string; +} + +/** + * Imports `.docx`/`.odt` documents as new pages (ADR 0009, issue #63). Enqueue + * is synchronous and cheap (validate + persist a job on the #62 queue); the + * heavy conversion runs out of band in {@link run}, invoked by the shared + * {@link ConversionWorker} for import-kind jobs so it inherits the queue's + * locking, retry, and restart-survival. + * + * Pipeline: the pandoc sidecar is stateless and will not hand back a + * document's embedded media any other way, so we convert in two passes — + * `docx/odt → html` with `embed-resources` inlines every image as a `data:` + * URI, then `html → gfm` produces clean structural Markdown with those data + * URIs still inline at the right positions. We parse that to an editor + * document, store each embedded image as a pond file (with quota accounting) + * and rewrite its reference to the file id, derive the title from a leading + * top-level heading (else the file name), and create the page from the + * resulting Yjs state. + */ +@Injectable() +export class ImportService implements ImportProcessor { + constructor( + private readonly prisma: PrismaService, + private readonly jobs: ConversionJobService, + private readonly converter: PandocConverter, + private readonly files: FilesService, + private readonly pages: PagesService, + private readonly logger: PinoLogger, + ) { + this.logger.setContext(ImportService.name); + } + + /** Validate and enqueue an import; the returned job id is polled via + * `GET /jobs/:id` until it reports the created `resultPageId` (#62). */ + async enqueue( + user: User, + pondId: string, + file: { buffer: Buffer; originalname: string }, + ): Promise { + const extension = fileExtension(file.originalname); + const format = extension ? IMPORT_FORMATS[extension] : undefined; + if (!format) { + throw new BadRequestException({ code: 'import_unsupported_format' }); + } + const job = await this.jobs.enqueue({ + ownerId: user.id, + pondId, + kind: importKind(format), + from: format, + to: 'page', + input: file.buffer, + sourceName: file.originalname, + standalone: false, + }); + return this.jobs.viewOf(job); + } + + /** + * Run one import job to completion (called by the worker). Throws a + * {@link ConversionError} on failure so the worker applies the queue's + * retry/fail policy; on success it records the created page on the job. + * Media stored during a failed attempt is rolled back so a retry does not + * leak files or double-count quota. + */ + async run(job: ConversionJob): Promise { + if (!job.pondId) { + throw new ConversionError('conversion_failed', false, 'import job has no pond'); + } + const user = await this.prisma.user.findUnique({ where: { id: job.ownerId } }); + if (!user) { + throw new ConversionError('conversion_failed', false, 'import job owner is gone'); + } + + const rawMarkdown = await convertImportedDocument( + this.converter, + job.sourceFormat, + Buffer.from(job.input), + ); + + // Media is stored before the page exists (the page's state references the + // 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, job.pondId, storedFileIds); + const json = markdownToDoc(markdown).toJSON() as unknown as PmNode; + const { title, doc } = this.splitTitle(json, job.sourceName); + const state = docToState(Node.fromJSON(editorSchema, doc)); + + const page = await this.pages.createWithState(user, job.pondId, title, state); + await this.files.linkAttachmentsToPage(storedFileIds, page.id); + await this.prisma.conversionJob.update({ + where: { id: job.id }, + data: { status: 'SUCCEEDED', resultPageId: page.id, errorCode: null }, + }); + this.logger.info( + { jobId: job.id, pageId: page.id, pondId: job.pondId, images: storedFileIds.length }, + 'audit: document imported', + ); + } catch (error) { + await this.rollbackMedia(user, storedFileIds); + throw error; + } + } + + /** + * Store each embedded image (an inline `data:` URI, from the embed-resources + * pass) as a pond file and rewrite the Markdown reference to the stored file + * id. Done on the Markdown text, before parsing, because the editor's parser + * only admits a whitelist of `data:` image types (png/jpeg/gif/webp) and + * would leave any other inline as a huge literal base64 string. base64 has no + * `)`, so the image regex is unambiguous. An image whose bytes the upload + * pipeline rejects (e.g. a vector `image/x-emf` Word can embed) is dropped + * rather than failing the whole import — structure over layout (ADR 0009); a + * pond that runs out of storage fails the import. + */ + private async storeEmbeddedImages( + markdown: string, + user: User, + pondId: string, + storedFileIds: string[], + ): Promise { + const image = /!\[([^\]]*)\]\((data:[^)\s]+)\)/g; + const uris = new Set(); + for (const match of markdown.matchAll(image)) uris.add(match[2]!); + if (uris.size === 0) return markdown; + + // Dedup identical images so a document that repeats one stores it once. + const resolved = new Map(); + let index = 0; + for (const uri of uris) { + resolved.set(uri, await this.storeImage(uri, user, pondId, ++index, storedFileIds)); + } + return markdown.replace(image, (_whole, alt: string, uri: string) => { + const fileId = resolved.get(uri); + return fileId ? `![${alt}](${fileId})` : ''; + }); + } + + private async storeImage( + uri: string, + user: User, + pondId: string, + index: number, + storedFileIds: string[], + ): Promise { + const decoded = decodeDataUri(uri); + if (!decoded) return null; + try { + const view = await this.files.upload(user, pondId, { + buffer: decoded.buffer, + size: decoded.buffer.length, + originalname: `import-${index}.${decoded.extension}`, + }); + storedFileIds.push(view.id); + return view.id; + } catch (error) { + if (error instanceof ForbiddenException && errorCode(error) === 'quota_exceeded') { + throw new ConversionError('quota_exceeded', false, 'pond storage exhausted during import'); + } + // Unsupported image type / rejected bytes — drop this one, keep going. + this.logger.warn({ pondId, code: errorCode(error) }, 'import: dropped an image'); + return null; + } + } + + /** + * Use a leading top-level heading as the page title (and remove it from the + * body so it is not duplicated), else fall back to the upload file name + * without its extension, else a generic title. + */ + private splitTitle(root: PmNode, sourceName: string | null): { title: string; doc: PmNode } { + const content = root.content ?? []; + const first = content[0]; + if (first && first.type === 'heading') { + const headingText = textOf(first).trim(); + if (headingText) { + return { title: headingText, doc: { ...root, content: content.slice(1) } }; + } + } + const fallback = baseName(sourceName) || 'Imported document'; + return { title: fallback, doc: root }; + } + + private async rollbackMedia(user: User, fileIds: string[]): Promise { + for (const id of fileIds) { + try { + await this.files.remove(user, id); + } catch (error) { + this.logger.warn({ fileId: id, code: errorCode(error) }, 'import: media rollback failed'); + } + } + } +} + +/** Concatenated text of a node's inline content (headings have no nesting). */ +function textOf(node: PmNode): string { + if (typeof node.text === 'string') return node.text; + return (node.content ?? []).map(textOf).join(''); +} + +const DATA_URI = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/; +const EXTENSION_BY_MIME: Readonly> = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/svg+xml': 'svg', +}; + +function decodeDataUri(value: string): DecodedImage | null { + const match = DATA_URI.exec(value); + if (!match) return null; + const mime = match[1]!; + const buffer = Buffer.from(match[2]!, 'base64'); + if (buffer.length === 0) return null; + return { buffer, extension: EXTENSION_BY_MIME[mime] ?? 'bin' }; +} + +/** Lowercased extension without the dot, or '' when there is none. */ +function fileExtension(fileName: string): string { + const dot = fileName.lastIndexOf('.'); + return dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : ''; +} + +/** File name without its extension. */ +function baseName(fileName: string | null): string { + if (!fileName) return ''; + const dot = fileName.lastIndexOf('.'); + return (dot > 0 ? fileName.slice(0, dot) : fileName).trim(); +} + +/** The `code` from a Nest HttpException response body, if any. */ +function errorCode(error: unknown): string | undefined { + if (error instanceof BadRequestException || error instanceof ForbiddenException) { + const body = error.getResponse(); + if (body && typeof body === 'object' && 'code' in body) { + const code = (body as { code?: unknown }).code; + return typeof code === 'string' ? code : undefined; + } + } + return undefined; +} diff --git a/apps/api/src/import-export/pandoc.converter.ts b/apps/api/src/import-export/pandoc.converter.ts index 1ee23d9..8993436 100644 --- a/apps/api/src/import-export/pandoc.converter.ts +++ b/apps/api/src/import-export/pandoc.converter.ts @@ -10,6 +10,13 @@ export interface ConversionRequest { 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 { @@ -18,7 +25,12 @@ export interface ConversionResult { } export type ConversionErrorCode = - 'converter_unavailable' | 'converter_timeout' | 'conversion_failed'; + | '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'; /** A conversion failure with a stable, localizable code. `retryable` marks * the transient causes (sidecar down / timed out) the worker retries before @@ -111,6 +123,10 @@ export class PandocServerConverter extends PandocConverter { 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, }); diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts index 7e8d540..4a0832f 100644 --- a/apps/api/src/pages/pages.service.ts +++ b/apps/api/src/pages/pages.service.ts @@ -130,23 +130,49 @@ export class PagesService { } async create(user: User, pondId: string, input: CreatePageInput): Promise { + const page = await this.insertPage(user, pondId, input.title, emptyPageState()); + return this.viewOf(page); + } + + /** + * Create a page from a prepared Yjs state (issue #63 import): the imported + * document is already a full Yjs state whose fragment the editor binds to, so + * an opening client sees the converted content immediately. Same invariants + * as {@link create} — unique slug, appended sort key, derived content cache, + * phantom-link resolution, search indexing. Returns the persisted row so the + * caller (the import worker) can link the document's media to it. + */ + async createWithState( + user: User, + pondId: string, + title: string, + state: Uint8Array, + ): Promise { + return this.insertPage(user, pondId, title, state); + } + + private async insertPage( + user: User, + pondId: string, + title: string, + state: Uint8Array, + ): Promise { const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); if (!pond) throw new NotFoundException(); - const slug = await this.generateUniqueSlugInPond(pond.id, input.title); + const slug = await this.generateUniqueSlugInPond(pond.id, title); const last = await this.prisma.page.findFirst({ where: { pondId: pond.id }, orderBy: { sortKey: 'desc' }, select: { sortKey: true }, }); const sortKey = generateKeyBetween(last?.sortKey ?? null, null); - const state = emptyPageState(); const content = deriveContent(state); const page = await this.prisma.page.create({ data: { pondId: pond.id, - title: input.title, + title, slug, sortKey, ydocState: state, @@ -156,10 +182,10 @@ export class PagesService { }); // A new page may satisfy phantom wikilinks that referenced its slug (#47). await this.resolvePhantomLinks(pond.id, slug, page.id); - // Index the (empty) page so a title-only match is findable immediately (#49). + // Index the page so a title-only match is findable immediately (#49). await this.search.indexPage(page.id); this.logger.info({ pageId: page.id, pondId: pond.id, userId: user.id }, 'audit: page created'); - return this.viewOf(page); + return page; } /** diff --git a/apps/api/src/pages/yjs-content.ts b/apps/api/src/pages/yjs-content.ts index ff82f5b..cc8dd4e 100644 --- a/apps/api/src/pages/yjs-content.ts +++ b/apps/api/src/pages/yjs-content.ts @@ -32,13 +32,17 @@ function docFromState(state: Uint8Array): Node { } } -/** A fresh Yjs state containing a single empty paragraph. */ -export function emptyPageState(): Uint8Array { +/** + * Encode a ProseMirror document as the initial Yjs state a page is created + * with. Used both for a fresh empty page and for importing a converted + * document (#63) as a page's starting content — the editor binds to the same + * {@link FRAGMENT_NAME}, so an opening client sees exactly this document. + */ +export function docToState(doc: Node): Uint8Array { const ydoc = new Y.Doc(); try { const fragment = ydoc.getXmlFragment(FRAGMENT_NAME); - const emptyDoc = editorSchema.node('doc', null, [editorSchema.node('paragraph')]); - prosemirrorJSONToYXmlFragment(editorSchema, emptyDoc.toJSON(), fragment); + prosemirrorJSONToYXmlFragment(editorSchema, doc.toJSON(), fragment); // Copy into a plain ArrayBuffer-backed view — yjs's own return type is // the wider `Uint8Array`, which Prisma's Bytes input // (`Uint8Array`) does not accept directly. @@ -48,6 +52,11 @@ export function emptyPageState(): Uint8Array { } } +/** A fresh Yjs state containing a single empty paragraph. */ +export function emptyPageState(): Uint8Array { + return docToState(editorSchema.node('doc', null, [editorSchema.node('paragraph')])); +} + export interface DerivedPageContent { plainText: string; markdown: string; diff --git a/eslint.config.mjs b/eslint.config.mjs index 821c14e..d4a6bbf 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -22,7 +22,13 @@ export default tseslint.config( // Plain-Node maintenance scripts (no TypeScript, no bundler). files: ['scripts/**/*.mjs'], languageOptions: { - globals: { console: 'readonly', process: 'readonly', URL: 'readonly' }, + globals: { + console: 'readonly', + process: 'readonly', + URL: 'readonly', + fetch: 'readonly', + Buffer: 'readonly', + }, }, }, { diff --git a/fixtures/import/README.md b/fixtures/import/README.md new file mode 100644 index 0000000..99b3e18 --- /dev/null +++ b/fixtures/import/README.md @@ -0,0 +1,42 @@ +# Import fixture corpus (issue #63, ADR 0009) + +Representative `.docx`/`.odt` documents and the Markdown our import pipeline is +expected to produce from them. `import.fixtures.test.ts` runs the real two-pass +pandoc conversion over each and asserts the result, so a change in behaviour +(ours or pandoc's) surfaces as a snapshot diff to review — not a silent +regression. + +## Files + +For each document `` and format `` (`docx`, `odt`): + +- `.` — the source document. +- `..expected.md` — the Markdown the pipeline produces, with every + embedded image's `data:` URI normalised to the literal `data:embedded-image` + (the base64 payload is volatile and not what the snapshot is pinning). + +`article` covers headings, paragraphs, **bold**/_italic_, a link, a bullet list +with nesting, an ordered list, an embedded image, and a table. `formatting` +covers strikethrough, inline code, a blockquote, and three levels of nesting. + +## Fidelity notes (structure, not layout — ADR 0009) + +- **ODT images lose their alt text** and **ODT tables lose their header row**: + pandoc's HTML→ODT _writer_ does not encode either, so the source documents + genuinely lack them. The DOCX variants keep both. This is a pinned pandoc + limitation, not an import bug — extend the corpus rather than chasing it. + +## Limits (ADR 0009) + +An uploaded document may be at most **25 MiB** (`MAX_CONVERSION_INPUT_BYTES`), +and each of the two conversion passes may run for at most **60 s** +(`CONVERSION_TIMEOUT_MS`). A ~50-page document converts well inside that ceiling +(`import.fixtures.test.ts` asserts it); a document that exceeds the timeout fails +the job with `converter_timeout`. + +## Regenerating + +The documents and snapshots are generated with the **pinned** `pandoc/core:3.6` +(the production sidecar) so the snapshots match CI; a different pandoc version +wraps lists and pads tables differently. `scripts/gen-import-fixtures.mjs` +regenerates everything against a reachable pandoc sidecar (`PANDOC_URL`). diff --git a/fixtures/import/article.docx b/fixtures/import/article.docx new file mode 100644 index 0000000000000000000000000000000000000000..e39b190df98b2a6071c57969c6bc23f7e76f9a89 GIT binary patch literal 11602 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 B_0#|W literal 0 HcmV?d00001 diff --git a/fixtures/import/article.docx.expected.md b/fixtures/import/article.docx.expected.md new file mode 100644 index 0000000..32e1945 --- /dev/null +++ b/fixtures/import/article.docx.expected.md @@ -0,0 +1,29 @@ +# Field Notes + +An introduction with **bold**, *italic*, and a [link to the pond](https://example.com/pond). + +## Observations + +The pond hosts several species. Notable ones include an image below. + +![A calm pond](data:embedded-image) + +## Lists + +- Amphibians + - Frogs + - Newts +- Insects + - Dragonflies + - Water striders + +1. Spring survey +2. Summer survey +3. Autumn survey + +## Measurements + +| Month | Depth (cm) | +|-------|------------| +| April | 120 | +| July | 95 | diff --git a/fixtures/import/article.odt b/fixtures/import/article.odt new file mode 100644 index 0000000000000000000000000000000000000000..5c0adbcb8e4a1f10fe76811b0d45065f1cf78915 GIT binary patch literal 8275 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 literal 0 HcmV?d00001 diff --git a/fixtures/import/article.odt.expected.md b/fixtures/import/article.odt.expected.md new file mode 100644 index 0000000..3a4d13f --- /dev/null +++ b/fixtures/import/article.odt.expected.md @@ -0,0 +1,32 @@ +# Field Notes + +An introduction with **bold**, *italic*, and a [link to the pond](https://example.com/pond). + +## Observations + +The pond hosts several species. Notable ones include an image below. + +![](data:embedded-image) + +## Lists + +- Amphibians + + - Frogs + - Newts + +- Insects + + - Dragonflies + - Water striders + +1. Spring survey +2. Summer survey +3. Autumn survey + +## Measurements + +| | | +|-------|-----| +| April | 120 | +| July | 95 | diff --git a/fixtures/import/article.src.html b/fixtures/import/article.src.html new file mode 100644 index 0000000..6822bbf --- /dev/null +++ b/fixtures/import/article.src.html @@ -0,0 +1,21 @@ +

Field Notes

+

An introduction with bold, italic, and a link to the pond.

+

Observations

+

The pond hosts several species. Notable ones include an image below.

+

A calm pond

+

Lists

+
    +
  • Amphibians +
    • Frogs
    • Newts
  • +
  • Insects +
    • Dragonflies
    • Water striders
  • +
+
  1. Spring survey
  2. Summer survey
  3. Autumn survey
+

Measurements

+ + + + + + +
MonthDepth (cm)
April120
July95
diff --git a/fixtures/import/formatting.docx b/fixtures/import/formatting.docx new file mode 100644 index 0000000000000000000000000000000000000000..1859b1b39a2f74ff61c140f4af7bc21d3deac724 GIT binary patch literal 10524 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%0A=mM{ZtSj1X#_uDr?!FMu`N4fv9v?`)Hgb*MVAWe zgzI;smEd^kWJ3-crndx-kpW&Gx?v&v;2rE|y(zCt#PMp{M|=2KQQ2v|-z4x6mvFY1 zO_pH9c6I8NKXpHW?j-ySDNCQr#VK2h8q&Xuvyh|znSu=a9L@mO)mDSigK(x1pa7+{ z-%o%m&3n3$U%Zi$t)w78dl&`IH=bp_XUi{3v0%@|bmR`mCRoeEe(-m=j}5}JkXm8? zNNH$u{xSNLRL44Zll-mWrSF)yHOl9{5PCoZe)Wj2Zsa13K^f)>k%n@a@Ral!(&gr2 z$!;$pAiIT4S#OA+6aql5KFN4% z@j+#6k@#39W=q|maFX%9T zu?n_MDuzbZzZ1dJ_>o`LuAm!t*pUb7py<8a=u%D>)^NoK5X5AIA*`M4fW6ZJE;8YS zL^Y8Lw&_l=%jf?6Z-)bcfSbXNmh8Wr@h01m{{~5Z-}MF6A93P z3W9mBYf0q}(WG1PH2S`t!`;-WA4I2034cB=p7ska$t*|6QDE-$)dy`>ZGj(F91$ zx1Qpnev%?*&?MEKgy+~$MN68J2uz--vTyYS$*~^tAlRV}nlKtQ-Pv4c?OHI)w*l*1 zu5ebd1~-5Jky>hgDnsQ#{cW{SIatet3oG)=W)-mmjdRppHkb4e`o-7?dNvx25Fz@hE-BR?2$gBcTg& z6q|Q+Dk6IEV?^$H8(J;zg4bVM;B_O;A9US|h5f57=QZ$k z$I2gI@5>DTpZ=BC@YhWMf8d{A{QqzG|IrfgTGH!c_a8~9FL~L&B>ly^{VsjK7WKM} z{6`evr55;G)L%vA*Z9{}!#{Wx*njYUYKX7FueriM;Ix;L_U|A6YXz_IuUWD`_|2D$ z`ZxaHoY`ymYjWui{P4wN{>jJxp_*R9UsEc7;4GN`g8xIXyhguH|No$SvHperLk7GS z@H!3uBj5w}KZ}2zl)o17I{x@0Vu0vBoA A quoted remark about still water. + +### Nested detail + +- Top level + - Second level + - Third level + +A closing line with another [external link](https://example.org). diff --git a/fixtures/import/formatting.odt b/fixtures/import/formatting.odt new file mode 100644 index 0000000000000000000000000000000000000000..c3f9824659ed77b52a0a84e214bb714f6062fc82 GIT binary patch literal 7690 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 k`R{tz-^j|B^4Q A quoted remark about still water. + +### Nested detail + +- Top level + + - Second level + + - Third level + +A closing line with another [external link](https://example.org). diff --git a/fixtures/import/formatting.src.html b/fixtures/import/formatting.src.html new file mode 100644 index 0000000..6695e81 --- /dev/null +++ b/fixtures/import/formatting.src.html @@ -0,0 +1,6 @@ +

Formatting Sampler

+

This paragraph mixes bold, italic, strikethrough, and inline code.

+

A quoted remark about still water.

+

Nested detail

+
  • Top level
    • Second level
      • Third level
+

A closing line with another external link.

diff --git a/packages/shared/i18n/de/errors.json b/packages/shared/i18n/de/errors.json index 1e99c26..bb0fd2f 100644 --- a/packages/shared/i18n/de/errors.json +++ b/packages/shared/i18n/de/errors.json @@ -34,6 +34,7 @@ "converter_unavailable": "Der Dokument-Konverter ist derzeit nicht verfügbar. Bitte versuche es später erneut.", "converter_timeout": "Die Konvertierung hat zu lange gedauert und wurde abgebrochen.", "conversion_failed": "Dieses Dokument konnte nicht konvertiert werden.", + "import_unsupported_format": "Nur Word- (.docx) und OpenDocument-Dokumente (.odt) können importiert werden.", "network": "Der Server war nicht erreichbar.", "grant_exists": "Diese Berechtigung existiert bereits.", "grant_pond_admin_scope": "Eine Teich-Admin-Berechtigung muss für den ganzen Teich und eine bestimmte Person gelten.", diff --git a/packages/shared/i18n/en/errors.json b/packages/shared/i18n/en/errors.json index 6e1258b..6f90499 100644 --- a/packages/shared/i18n/en/errors.json +++ b/packages/shared/i18n/en/errors.json @@ -34,6 +34,7 @@ "converter_unavailable": "The document converter is currently unavailable. Please try again later.", "converter_timeout": "The conversion took too long and was cancelled.", "conversion_failed": "This document could not be converted.", + "import_unsupported_format": "Only Word (.docx) and OpenDocument (.odt) documents can be imported.", "network": "The server could not be reached.", "grant_exists": "This grant already exists.", "grant_pond_admin_scope": "A Pond Admin grant must apply to the whole pond and a specific user.", diff --git a/packages/shared/src/conversion.ts b/packages/shared/src/conversion.ts index c42b9ea..c80a8bc 100644 --- a/packages/shared/src/conversion.ts +++ b/packages/shared/src/conversion.ts @@ -12,8 +12,17 @@ export interface ConversionJobView { sourceFormat: string; targetFormat: string; /** Set only when `status` is `failed` — a code from the errors namespace - * (`converter_unavailable` | `converter_timeout` | `conversion_failed`). */ + * (`converter_unavailable` | `converter_timeout` | `conversion_failed` | + * `quota_exceeded`). */ errorCode: string | null; + /** Set once an import job (#63) succeeds: the id of the page it created, so + * the client can navigate to it. `null` for a pending/failed import and for + * plain byte→byte conversions (export). */ + resultPageId: string | null; createdAt: string; updatedAt: string; } + +/** Extensions the import endpoint accepts (ADR 0009, issue #63). */ +export const IMPORT_EXTENSIONS = ['docx', 'odt'] as const; +export type ImportExtension = (typeof IMPORT_EXTENSIONS)[number]; diff --git a/scripts/gen-import-fixtures.mjs b/scripts/gen-import-fixtures.mjs new file mode 100644 index 0000000..13b2397 --- /dev/null +++ b/scripts/gen-import-fixtures.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node +// Regenerate the import fixture corpus (issue #63, ADR 0009) from the committed +// `*.src.html` sources: for each source, write `.docx`, `.odt`, and +// the expected Markdown snapshot for each format. Run against a reachable pandoc +// sidecar (the pinned `pandoc/core:3.6`, so snapshots match CI): +// +// docker run --rm -p 3030:3030 pandoc/core:3.6 server +// PANDOC_URL=http://localhost:3030 node scripts/gen-import-fixtures.mjs +// +// The pipeline mirrors ImportService: `html → ` builds the document, then +// ` → html` (embed-resources) → `html → gfm` reproduces what an import +// would parse, with image data URIs normalised to a stable token. + +import { readFileSync, writeFileSync, readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), '..', 'fixtures', 'import'); +const PANDOC_URL = process.env.PANDOC_URL ?? 'http://localhost:3030'; +const MARKDOWN_FORMAT = 'gfm-implicit_figures-raw_html'; + +/** POST a conversion to pandoc-server. Without an `Accept` header the server + * returns text for text writers and raw bytes for binary ones (docx/odt). */ +async function pandoc(params, binaryOut = false) { + const res = await fetch(`${PANDOC_URL}/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + if (!res.ok) throw new Error(`pandoc ${res.status}: ${await res.text()}`); + return binaryOut ? Buffer.from(await res.arrayBuffer()) : res.text(); +} + +async function buildDocument(html, ext) { + return pandoc({ text: html, from: 'html', to: ext, standalone: true }, true); +} + +async function toExpectedMarkdown(documentBytes, ext) { + const embedded = await pandoc({ + text: documentBytes.toString('base64'), + from: ext, + to: 'html', + standalone: false, + 'embed-resources': true, + }); + const md = await pandoc({ + text: embedded, + from: 'html', + to: MARKDOWN_FORMAT, + standalone: false, + wrap: 'none', + }); + return md.replace(/data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=]+/g, 'data:embedded-image'); +} + +const sources = readdirSync(FIXTURES).filter((f) => f.endsWith('.src.html')); +for (const source of sources) { + const name = source.replace(/\.src\.html$/, ''); + const html = readFileSync(join(FIXTURES, source), 'utf8'); + for (const ext of ['docx', 'odt']) { + const bytes = await buildDocument(html, ext); + writeFileSync(join(FIXTURES, `${name}.${ext}`), bytes); + writeFileSync( + join(FIXTURES, `${name}.${ext}.expected.md`), + await toExpectedMarkdown(bytes, ext), + ); + console.log(`wrote ${name}.${ext} (+ expected.md)`); + } +}