Add document import UI in the sidebar (#64)
All checks were successful
CD / Build and push images (push) Successful in 3m43s
CI / Lint, typecheck, test (push) Successful in 2m56s
CI / Auth e2e pack (push) Successful in 3m53s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 10s
CD / Smoke tests against Test (push) Successful in 1m20s
CD / Promote to Int (push) Successful in 11s
All checks were successful
CD / Build and push images (push) Successful in 3m43s
CI / Lint, typecheck, test (push) Successful in 2m56s
CI / Auth e2e pack (push) Successful in 3m53s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 10s
CD / Smoke tests against Test (push) Successful in 1m20s
CD / Promote to Int (push) Successful in 11s
An "Import document" action in the pond sidebar: pick a .docx/.odt/.md file
(or several), upload with per-file progress, and open the new page. A
.docx/.odt polls the conversion job (queued → converting → done); a .md
imports directly and comes back already succeeded. Failures stay listed with
the localized error and a retry; concurrent imports all complete and appear.
- web apps/web/src/import/: useImport hook (upload via apiUploadFile → poll
GET /jobs/:id → resolve the page slug → navigate; first success of a batch
navigates, every success refreshes the sidebar) and ImportControl (hidden
file input, accept from shared IMPORT_EXTENSIONS, per-file status list).
Wired into Sidebar next to "new page"; `import` i18n namespace (de+en).
- api: ImportService accepts .md/.markdown and imports in-process (no job),
returning a succeeded ConversionJobView with the created resultPageId
("Markdown imports directly"); the media+parse+create tail is now shared
between the job path and the sync path (createPageFromMarkdown), and a
conversion error on the sync path maps to an HTTP status. shared
IMPORT_EXTENSIONS gains md/markdown.
- e2e apps/web/e2e/import.spec.ts + CI step: .docx corpus fixture opens the
converted page (self-skips without a reachable pandoc sidecar — CI's e2e
stack has none, same as #63; verified locally + on stage), .md opens
directly, an unsupported .txt shows the localized error with no page
created, and two concurrent .md imports both complete.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
parent
546e8279ac
commit
e2f942c0ff
@ -238,6 +238,18 @@ jobs:
|
|||||||
E2E_BASE_URL=http://localhost:5173 \
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/attachments.spec.ts
|
pnpm --filter @dorfteich/web exec playwright test e2e/attachments.spec.ts
|
||||||
|
|
||||||
|
- name: Reset login rate limit before import pack
|
||||||
|
run: |
|
||||||
|
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||||
|
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
||||||
|
|
||||||
|
# The .docx case self-skips without a reachable pandoc sidecar (no
|
||||||
|
# E2E_PANDOC here); the .md, failure, and concurrent cases run (#64).
|
||||||
|
- name: Run import pack
|
||||||
|
run: |
|
||||||
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
|
pnpm --filter @dorfteich/web exec playwright test e2e/import.spec.ts
|
||||||
|
|
||||||
- name: Reset login rate limit before offline pack
|
- name: Reset login rate limit before offline pack
|
||||||
run: |
|
run: |
|
||||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||||
|
|||||||
@ -251,6 +251,26 @@ describe.skipIf(!hasTestDb)('document import (e2e, issue #63)', () => {
|
|||||||
await prisma.quotaOverride.deleteMany({ where: { subjectId: pondId } });
|
await prisma.quotaOverride.deleteMany({ where: { subjectId: pondId } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('imports a Markdown file directly (no job): succeeded with a page at once', async () => {
|
||||||
|
// A .md upload never touches the fake converter — it is parsed in-process.
|
||||||
|
fake.markdown = 'SHOULD NOT BE USED';
|
||||||
|
const enqueued = await api()
|
||||||
|
.post(`/api/v1/ponds/${pondId}/import`)
|
||||||
|
.set('Cookie', ownerCookie)
|
||||||
|
.attach('file', Buffer.from('# Direct Markdown\n\nA paragraph.'), 'note.md')
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
// No polling needed — the response is already terminal.
|
||||||
|
expect(enqueued.body.status).toBe('succeeded');
|
||||||
|
expect(enqueued.body.sourceFormat).toBe('md');
|
||||||
|
const pageId = enqueued.body.resultPageId as string;
|
||||||
|
expect(pageId).toBeTruthy();
|
||||||
|
|
||||||
|
const page = await prisma.page.findUniqueOrThrow({ where: { id: pageId } });
|
||||||
|
expect(page.title).toBe('Direct Markdown');
|
||||||
|
expect(await markdownOf(page.id)).toContain('A paragraph.');
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects an unsupported upload extension (400) before enqueuing', async () => {
|
it('rejects an unsupported upload extension (400) before enqueuing', async () => {
|
||||||
const res = await api()
|
const res = await api()
|
||||||
.post(`/api/v1/ponds/${pondId}/import`)
|
.post(`/api/v1/ponds/${pondId}/import`)
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
import { ConversionJob, User } from '@prisma/client';
|
import { ConversionJob, Page, User } from '@prisma/client';
|
||||||
import { ConversionJobView, editorSchema, markdownToDoc } from '@dorfteich/shared';
|
import { ConversionJobView, editorSchema, markdownToDoc } from '@dorfteich/shared';
|
||||||
import { Node } from 'prosemirror-model';
|
import { Node } from 'prosemirror-model';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
@ -13,10 +13,13 @@ import { ImportProcessor } from './import.constants';
|
|||||||
import { ConversionError, PandocConverter } from './pandoc.converter';
|
import { ConversionError, PandocConverter } from './pandoc.converter';
|
||||||
import { ConversionJobService } from './conversion-job.service';
|
import { ConversionJobService } from './conversion-job.service';
|
||||||
|
|
||||||
/** pandoc source format per accepted upload extension (ADR 0009). */
|
/** Source format per accepted upload extension (ADR 0009). `md` is our own
|
||||||
|
* in-process format (no pandoc); the rest go through the sidecar. */
|
||||||
const IMPORT_FORMATS: Readonly<Record<string, string>> = {
|
const IMPORT_FORMATS: Readonly<Record<string, string>> = {
|
||||||
docx: 'docx',
|
docx: 'docx',
|
||||||
odt: 'odt',
|
odt: 'odt',
|
||||||
|
md: 'md',
|
||||||
|
markdown: 'md',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Job `kind` per source format, so the worker can route the job to the import
|
/** Job `kind` per source format, so the worker can route the job to the import
|
||||||
@ -110,8 +113,13 @@ export class ImportService implements ImportProcessor {
|
|||||||
this.logger.setContext(ImportService.name);
|
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). */
|
* Validate and start an import. Markdown needs no conversion, so it is
|
||||||
|
* imported in-process and the returned view is already `succeeded` with the
|
||||||
|
* created `resultPageId` (issue #64 — "Markdown imports directly, no job").
|
||||||
|
* `.docx`/`.odt` enqueue a job (the returned id is polled via `GET /jobs/:id`
|
||||||
|
* until it reports `resultPageId`).
|
||||||
|
*/
|
||||||
async enqueue(
|
async enqueue(
|
||||||
user: User,
|
user: User,
|
||||||
pondId: string,
|
pondId: string,
|
||||||
@ -122,6 +130,7 @@ export class ImportService implements ImportProcessor {
|
|||||||
if (!format) {
|
if (!format) {
|
||||||
throw new BadRequestException({ code: 'import_unsupported_format' });
|
throw new BadRequestException({ code: 'import_unsupported_format' });
|
||||||
}
|
}
|
||||||
|
if (format === 'md') return this.importMarkdown(user, pondId, file);
|
||||||
const job = await this.jobs.enqueue({
|
const job = await this.jobs.enqueue({
|
||||||
ownerId: user.id,
|
ownerId: user.id,
|
||||||
pondId,
|
pondId,
|
||||||
@ -135,6 +144,41 @@ export class ImportService implements ImportProcessor {
|
|||||||
return this.jobs.viewOf(job);
|
return this.jobs.viewOf(job);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a Markdown file synchronously (no conversion sidecar, no job): parse,
|
||||||
|
* store any embedded images, create the page, and return a `succeeded` view so
|
||||||
|
* the client can navigate straight to the new page. A conversion-level failure
|
||||||
|
* (a pond out of storage) surfaces as an HTTP error rather than a job status.
|
||||||
|
*/
|
||||||
|
private async importMarkdown(
|
||||||
|
user: User,
|
||||||
|
pondId: string,
|
||||||
|
file: { buffer: Buffer; originalname: string },
|
||||||
|
): Promise<ConversionJobView> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
try {
|
||||||
|
const page = await this.createPageFromMarkdown(
|
||||||
|
user,
|
||||||
|
pondId,
|
||||||
|
file.buffer.toString('utf8'),
|
||||||
|
file.originalname,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: page.id,
|
||||||
|
status: 'succeeded',
|
||||||
|
kind: 'import_md',
|
||||||
|
sourceFormat: 'md',
|
||||||
|
targetFormat: 'page',
|
||||||
|
errorCode: null,
|
||||||
|
resultPageId: page.id,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw conversionErrorToHttp(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run one import job to completion (called by the worker). Throws a
|
* Run one import job to completion (called by the worker). Throws a
|
||||||
* {@link ConversionError} on failure so the worker applies the queue's
|
* {@link ConversionError} on failure so the worker applies the queue's
|
||||||
@ -156,26 +200,41 @@ export class ImportService implements ImportProcessor {
|
|||||||
job.sourceFormat,
|
job.sourceFormat,
|
||||||
Buffer.from(job.input),
|
Buffer.from(job.input),
|
||||||
);
|
);
|
||||||
|
const page = await this.createPageFromMarkdown(user, job.pondId, rawMarkdown, job.sourceName);
|
||||||
// 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({
|
await this.prisma.conversionJob.update({
|
||||||
where: { id: job.id },
|
where: { id: job.id },
|
||||||
data: { status: 'SUCCEEDED', resultPageId: page.id, errorCode: null },
|
data: { status: 'SUCCEEDED', resultPageId: page.id, errorCode: null },
|
||||||
});
|
});
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
{ jobId: job.id, pageId: page.id, pondId: job.pondId, images: storedFileIds.length },
|
{ jobId: job.id, pageId: page.id, pondId: job.pondId },
|
||||||
'audit: document imported',
|
'audit: document imported',
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared tail of every import: store embedded images, parse the Markdown
|
||||||
|
* into an editor document, and create a page from it. Media stored during a
|
||||||
|
* failed attempt is rolled back so a retry (or the caller's error) leaves no
|
||||||
|
* orphaned files or double-counted quota, and no half-created page.
|
||||||
|
*/
|
||||||
|
private async createPageFromMarkdown(
|
||||||
|
user: User,
|
||||||
|
pondId: string,
|
||||||
|
rawMarkdown: string,
|
||||||
|
sourceName: string | null,
|
||||||
|
): Promise<Page> {
|
||||||
|
// 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, 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);
|
||||||
|
await this.files.linkAttachmentsToPage(storedFileIds, page.id);
|
||||||
|
return page;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.rollbackMedia(user, storedFileIds);
|
await this.rollbackMedia(user, storedFileIds);
|
||||||
throw error;
|
throw error;
|
||||||
@ -320,3 +379,19 @@ function errorCode(error: unknown): string | undefined {
|
|||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a conversion-level failure from the shared pipeline into an HTTP error
|
||||||
|
* for the synchronous Markdown path (there is no job to carry the code). An
|
||||||
|
* exhausted pond is a 403 `quota_exceeded`; any other conversion error is a 400
|
||||||
|
* with its code. A Nest HttpException (e.g. the pond 404) passes through.
|
||||||
|
*/
|
||||||
|
function conversionErrorToHttp(error: unknown): unknown {
|
||||||
|
if (error instanceof ConversionError) {
|
||||||
|
if (error.code === 'quota_exceeded') {
|
||||||
|
return new ForbiddenException({ code: 'quota_exceeded' });
|
||||||
|
}
|
||||||
|
return new BadRequestException({ code: error.code });
|
||||||
|
}
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|||||||
@ -80,6 +80,24 @@ Admin file manager reports storage usage and flags an orphan (a pond-level
|
|||||||
upload with no embedding page). The SVG sanitize/reject policy is covered at
|
upload with no embedding page). The SVG sanitize/reject policy is covered at
|
||||||
the api level in `files.e2e.db.test.ts`.
|
the api level in `files.e2e.db.test.ts`.
|
||||||
|
|
||||||
|
## Import (`import.spec.ts`, issue #64)
|
||||||
|
|
||||||
|
The sidebar "import document" action: pick a file, upload, watch progress, open
|
||||||
|
the new page. `.md` imports directly (the response is already `succeeded`);
|
||||||
|
`.docx`/`.odt` poll a conversion job. Cases: a `.docx` corpus fixture (#63)
|
||||||
|
opens the converted page, a `.md` opens directly, an unsupported `.txt` shows
|
||||||
|
the localized error and creates no page, and two concurrent `.md` imports both
|
||||||
|
complete. The **`.docx` case self-skips unless `E2E_PANDOC` is set** — CI's e2e
|
||||||
|
stack has no reachable pandoc sidecar (jobs are container-networked; same reason
|
||||||
|
the api's real-pandoc fixtures test skips in CI, #63), so it runs locally / on a
|
||||||
|
stage. Run it locally with a sidecar reachable at the api's `PANDOC_URL`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run -d -p 3030:3030 pandoc/core:3.6 server # api PANDOC_URL → this
|
||||||
|
E2E_PANDOC=1 E2E_BASE_URL=http://localhost:5990 \
|
||||||
|
pnpm --filter @dorfteich/web exec playwright test e2e/import.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
## Content fixtures
|
## Content fixtures
|
||||||
|
|
||||||
`db:seed` also creates a **shared** pond `content-fixtures` (owned by
|
`db:seed` also creates a **shared** pond `content-fixtures` (owned by
|
||||||
|
|||||||
134
apps/web/e2e/import.spec.ts
Normal file
134
apps/web/e2e/import.spec.ts
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { contextForUser } from './helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Document import pack (issue #64): the sidebar "import document" action.
|
||||||
|
* `.docx` goes through the conversion job (needs a pandoc sidecar — the case
|
||||||
|
* self-skips without `E2E_PANDOC`, see below); `.md` imports directly. Failures
|
||||||
|
* show the localized error and leave no page; concurrent imports both complete.
|
||||||
|
* Selectors are language-neutral (the UI follows the user's locale) — CSS
|
||||||
|
* classes, not button text.
|
||||||
|
*/
|
||||||
|
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
||||||
|
// The corpus fixture from #63; the test runner's cwd is `apps/web`.
|
||||||
|
const DOCX = readFileSync(join(process.cwd(), '../../fixtures/import/article.docx'));
|
||||||
|
const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||||
|
|
||||||
|
async function personalPond(
|
||||||
|
context: Awaited<ReturnType<typeof contextForUser>>,
|
||||||
|
): Promise<{ id: string; slug: string }> {
|
||||||
|
const ponds = await context.request.get('/api/v1/ponds');
|
||||||
|
const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal');
|
||||||
|
return { id: pond.id, slug: pond.slug };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('imports a .docx through the sidebar and opens the converted page', async ({ browser }) => {
|
||||||
|
// `.docx` needs the pandoc sidecar reachable by the api. CI's e2e stack has
|
||||||
|
// none (jobs run container-networked; the same reason #63's real-pandoc test
|
||||||
|
// skips there), so this runs locally / on a stage with E2E_PANDOC set. The
|
||||||
|
// `.md`, failure, and concurrent cases below cover the UI flow without it.
|
||||||
|
test.skip(!process.env.E2E_PANDOC, 'needs a reachable pandoc sidecar');
|
||||||
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
const pond = await personalPond(context);
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(`/p/${pond.slug}`);
|
||||||
|
|
||||||
|
await page.locator('.sidebar__import-input').setInputFiles({
|
||||||
|
name: 'field-notes.docx',
|
||||||
|
mimeType: DOCX_MIME,
|
||||||
|
buffer: DOCX,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The conversion job completes and the client navigates to the new page
|
||||||
|
// (slug from the document's title "Field Notes").
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/field-notes`), { timeout: 30000 });
|
||||||
|
// The body keeps the converted structure (the H1 became the page title).
|
||||||
|
await expect(page.locator('.ProseMirror')).toContainText('Observations', { timeout: 15000 });
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('imports a .md directly (no job) and opens the page', async ({ browser }) => {
|
||||||
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
const pond = await personalPond(context);
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(`/p/${pond.slug}`);
|
||||||
|
|
||||||
|
const suffix = Date.now();
|
||||||
|
await page.locator('.sidebar__import-input').setInputFiles({
|
||||||
|
name: 'note.md',
|
||||||
|
mimeType: 'text/markdown',
|
||||||
|
buffer: Buffer.from(`# Imported Note ${suffix}\n\nHello from markdown.`),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/imported-note-${suffix}`), {
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
await expect(page.locator('.ProseMirror')).toContainText('Hello from markdown');
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows the localized error for an unsupported file and creates no page', async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
const pond = await personalPond(context);
|
||||||
|
const before = (
|
||||||
|
(await (await context.request.get(`/api/v1/ponds/${pond.id}/pages`)).json()) as unknown[]
|
||||||
|
).length;
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(`/p/${pond.slug}`);
|
||||||
|
|
||||||
|
await page.locator('.sidebar__import-input').setInputFiles({
|
||||||
|
name: 'notes.txt',
|
||||||
|
mimeType: 'text/plain',
|
||||||
|
buffer: Buffer.from('plain text, not importable'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// The failed import stays listed with a (localized, non-empty) error, and no
|
||||||
|
// page is created (the pond's page count is unchanged — no half-created page).
|
||||||
|
const failed = page.locator('.sidebar__import-item--failed');
|
||||||
|
await expect(failed).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(failed.locator('.sidebar__import-status')).not.toBeEmpty();
|
||||||
|
|
||||||
|
const after = (
|
||||||
|
(await (await context.request.get(`/api/v1/ponds/${pond.id}/pages`)).json()) as unknown[]
|
||||||
|
).length;
|
||||||
|
expect(after).toBe(before);
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runs concurrent imports and both complete', async ({ browser }) => {
|
||||||
|
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
|
||||||
|
const pond = await personalPond(context);
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(`/p/${pond.slug}`);
|
||||||
|
|
||||||
|
const suffix = Date.now();
|
||||||
|
await page.locator('.sidebar__import-input').setInputFiles([
|
||||||
|
{ name: 'alpha.md', mimeType: 'text/markdown', buffer: Buffer.from(`# Alpha ${suffix}\n\nA`) },
|
||||||
|
{ name: 'beta.md', mimeType: 'text/markdown', buffer: Buffer.from(`# Beta ${suffix}\n\nB`) },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Both pages are created (one of them also navigates the browser).
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const list = (await (
|
||||||
|
await context.request.get(`/api/v1/ponds/${pond.id}/pages`)
|
||||||
|
).json()) as { title: string }[];
|
||||||
|
const titles = new Set(list.map((p) => p.title));
|
||||||
|
return titles.has(`Alpha ${suffix}`) && titles.has(`Beta ${suffix}`);
|
||||||
|
},
|
||||||
|
{ timeout: 15000 },
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
@ -4,6 +4,7 @@ import deCommon from '@dorfteich/shared/i18n/de/common.json';
|
|||||||
import deEditor from '@dorfteich/shared/i18n/de/editor.json';
|
import deEditor from '@dorfteich/shared/i18n/de/editor.json';
|
||||||
import deErrors from '@dorfteich/shared/i18n/de/errors.json';
|
import deErrors from '@dorfteich/shared/i18n/de/errors.json';
|
||||||
import deFiles from '@dorfteich/shared/i18n/de/files.json';
|
import deFiles from '@dorfteich/shared/i18n/de/files.json';
|
||||||
|
import deImport from '@dorfteich/shared/i18n/de/import.json';
|
||||||
import deLabels from '@dorfteich/shared/i18n/de/labels.json';
|
import deLabels from '@dorfteich/shared/i18n/de/labels.json';
|
||||||
import deLinks from '@dorfteich/shared/i18n/de/links.json';
|
import deLinks from '@dorfteich/shared/i18n/de/links.json';
|
||||||
import deMembers from '@dorfteich/shared/i18n/de/members.json';
|
import deMembers from '@dorfteich/shared/i18n/de/members.json';
|
||||||
@ -18,6 +19,7 @@ import enCommon from '@dorfteich/shared/i18n/en/common.json';
|
|||||||
import enEditor from '@dorfteich/shared/i18n/en/editor.json';
|
import enEditor from '@dorfteich/shared/i18n/en/editor.json';
|
||||||
import enErrors from '@dorfteich/shared/i18n/en/errors.json';
|
import enErrors from '@dorfteich/shared/i18n/en/errors.json';
|
||||||
import enFiles from '@dorfteich/shared/i18n/en/files.json';
|
import enFiles from '@dorfteich/shared/i18n/en/files.json';
|
||||||
|
import enImport from '@dorfteich/shared/i18n/en/import.json';
|
||||||
import enLabels from '@dorfteich/shared/i18n/en/labels.json';
|
import enLabels from '@dorfteich/shared/i18n/en/labels.json';
|
||||||
import enLinks from '@dorfteich/shared/i18n/en/links.json';
|
import enLinks from '@dorfteich/shared/i18n/en/links.json';
|
||||||
import enMembers from '@dorfteich/shared/i18n/en/members.json';
|
import enMembers from '@dorfteich/shared/i18n/en/members.json';
|
||||||
@ -49,6 +51,7 @@ void i18n
|
|||||||
settings: enSettings,
|
settings: enSettings,
|
||||||
editor: enEditor,
|
editor: enEditor,
|
||||||
files: enFiles,
|
files: enFiles,
|
||||||
|
import: enImport,
|
||||||
labels: enLabels,
|
labels: enLabels,
|
||||||
links: enLinks,
|
links: enLinks,
|
||||||
members: enMembers,
|
members: enMembers,
|
||||||
@ -65,6 +68,7 @@ void i18n
|
|||||||
settings: deSettings,
|
settings: deSettings,
|
||||||
editor: deEditor,
|
editor: deEditor,
|
||||||
files: deFiles,
|
files: deFiles,
|
||||||
|
import: deImport,
|
||||||
labels: deLabels,
|
labels: deLabels,
|
||||||
links: deLinks,
|
links: deLinks,
|
||||||
members: deMembers,
|
members: deMembers,
|
||||||
|
|||||||
81
apps/web/src/import/ImportControl.tsx
Normal file
81
apps/web/src/import/ImportControl.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { IMPORT_EXTENSIONS } from '@dorfteich/shared';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useImport } from './use-import';
|
||||||
|
|
||||||
|
interface ImportControlProps {
|
||||||
|
pondId: string;
|
||||||
|
pondSlug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** File-picker `accept` for the import extensions (".docx,.odt,.md,.markdown"). */
|
||||||
|
const ACCEPT = IMPORT_EXTENSIONS.map((extension) => `.${extension}`).join(',');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Import document" action in the sidebar (#64): pick a `.docx`/`.odt`/`.md`
|
||||||
|
* file (or several), upload it, show per-file progress while a conversion job
|
||||||
|
* runs, and open the new page on success. Failures stay listed with a retry.
|
||||||
|
* Requires pond-editor access, enforced by the API (permissions.md).
|
||||||
|
*/
|
||||||
|
export function ImportControl({ pondId, pondSlug }: ImportControlProps): React.JSX.Element {
|
||||||
|
const { t } = useTranslation('import');
|
||||||
|
const { t: tErrors } = useTranslation('errors');
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const { tasks, startImport, retry, dismiss } = useImport(pondId, pondSlug);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sidebar__import">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="linklike sidebar__import-action"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{t('action')}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
className="sidebar__import-input"
|
||||||
|
accept={ACCEPT}
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
aria-label={t('action')}
|
||||||
|
onChange={(event) => {
|
||||||
|
startImport(Array.from(event.target.files ?? []));
|
||||||
|
// Clear the value so re-picking the same file fires `change` again.
|
||||||
|
event.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{tasks.length > 0 && (
|
||||||
|
<ul className="sidebar__import-list" aria-label={t('panelLabel')}>
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<li
|
||||||
|
key={task.id}
|
||||||
|
className={`sidebar__import-item sidebar__import-item--${task.phase}`}
|
||||||
|
>
|
||||||
|
<span className="sidebar__import-name" title={task.fileName}>
|
||||||
|
{task.fileName}
|
||||||
|
</span>
|
||||||
|
<span className="sidebar__import-status" role="status">
|
||||||
|
{task.phase === 'failed' && task.errorCode
|
||||||
|
? tErrors(task.errorCode)
|
||||||
|
: t(`status.${task.phase}`)}
|
||||||
|
</span>
|
||||||
|
{task.phase === 'failed' && (
|
||||||
|
<span className="sidebar__import-actions">
|
||||||
|
<button type="button" className="linklike" onClick={() => retry(task.id)}>
|
||||||
|
{t('retry')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="linklike" onClick={() => dismiss(task.id)}>
|
||||||
|
{t('dismiss')}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
apps/web/src/import/use-import.ts
Normal file
144
apps/web/src/import/use-import.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import type { ConversionJobStatus, ConversionJobView, PageView } from '@dorfteich/shared';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { ApiError, apiGet, apiUploadFile } from '../lib/api';
|
||||||
|
|
||||||
|
/** UI phase of one document import. `queued`/`converting` mirror the job while a
|
||||||
|
* `.docx`/`.odt` conversion runs; `.md` jumps straight to `done` (#64). */
|
||||||
|
export type ImportPhase = 'uploading' | 'queued' | 'converting' | 'done' | 'failed';
|
||||||
|
|
||||||
|
export interface ImportTask {
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
phase: ImportPhase;
|
||||||
|
/** errors-namespace code when `phase` is `failed`. */
|
||||||
|
errorCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 1000;
|
||||||
|
// A single conversion is capped at 60 s per pass (two passes) — poll well past
|
||||||
|
// that so a job that ends in `converter_timeout` is still observed settling.
|
||||||
|
const MAX_POLLS = 180;
|
||||||
|
// Keep a finished import visible briefly, then clear it so the panel does not
|
||||||
|
// accumulate (the user has usually navigated to the new page by then).
|
||||||
|
const DONE_LINGER_MS = 4000;
|
||||||
|
|
||||||
|
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
function phaseFor(status: ConversionJobStatus): ImportPhase {
|
||||||
|
if (status === 'succeeded') return 'done';
|
||||||
|
if (status === 'failed') return 'failed';
|
||||||
|
if (status === 'running') return 'converting';
|
||||||
|
return 'queued';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseImport {
|
||||||
|
tasks: ImportTask[];
|
||||||
|
startImport: (files: File[]) => void;
|
||||||
|
retry: (taskId: string) => void;
|
||||||
|
dismiss: (taskId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives document imports from the sidebar (#64): upload each file to
|
||||||
|
* `POST /ponds/:id/import`, then — for a conversion job — poll `GET /jobs/:id`
|
||||||
|
* until it succeeds or fails, showing the phase. On the first success of a batch
|
||||||
|
* it navigates to the new page; every success refreshes the sidebar list, so
|
||||||
|
* concurrent imports all appear. Failures stay in the panel with a retry.
|
||||||
|
*/
|
||||||
|
export function useImport(pondId: string, pondSlug: string): UseImport {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [tasks, setTasks] = useState<ImportTask[]>([]);
|
||||||
|
const files = useRef(new Map<string, File>());
|
||||||
|
// Only the first completed import of a batch pulls the user to its page.
|
||||||
|
const navigated = useRef(false);
|
||||||
|
|
||||||
|
const patch = useCallback((id: string, next: Partial<ImportTask>): void => {
|
||||||
|
setTasks((prev) => prev.map((task) => (task.id === id ? { ...task, ...next } : task)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onSucceeded = useCallback(
|
||||||
|
async (id: string, pageId: string): Promise<void> => {
|
||||||
|
patch(id, { phase: 'done', errorCode: undefined });
|
||||||
|
// Refresh the sidebar page list so every imported page shows up.
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['pages'] });
|
||||||
|
if (!navigated.current) {
|
||||||
|
navigated.current = true;
|
||||||
|
try {
|
||||||
|
const page = await apiGet<PageView>(`/pages/${pageId}`);
|
||||||
|
navigate(`/p/${pondSlug}/${page.slug}`);
|
||||||
|
} catch {
|
||||||
|
// The page exists regardless; leaving the user where they are is fine.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTimeout(() => dismissRef.current(id), DONE_LINGER_MS);
|
||||||
|
},
|
||||||
|
[navigate, patch, pondSlug, queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const runOne = useCallback(
|
||||||
|
async (id: string, file: File): Promise<void> => {
|
||||||
|
patch(id, { phase: 'uploading', errorCode: undefined });
|
||||||
|
try {
|
||||||
|
let job = await apiUploadFile<ConversionJobView>(`/ponds/${pondId}/import`, file);
|
||||||
|
for (
|
||||||
|
let poll = 0;
|
||||||
|
job.status !== 'succeeded' && job.status !== 'failed' && poll < MAX_POLLS;
|
||||||
|
poll += 1
|
||||||
|
) {
|
||||||
|
patch(id, { phase: phaseFor(job.status) });
|
||||||
|
await delay(POLL_INTERVAL_MS);
|
||||||
|
job = await apiGet<ConversionJobView>(`/jobs/${job.id}`);
|
||||||
|
}
|
||||||
|
if (job.status === 'succeeded' && job.resultPageId) {
|
||||||
|
await onSucceeded(id, job.resultPageId);
|
||||||
|
} else {
|
||||||
|
patch(id, { phase: 'failed', errorCode: job.errorCode ?? 'conversion_failed' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
patch(id, {
|
||||||
|
phase: 'failed',
|
||||||
|
errorCode: error instanceof ApiError ? error.body.code : 'network',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSucceeded, patch, pondId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const startImport = useCallback(
|
||||||
|
(picked: File[]): void => {
|
||||||
|
if (picked.length === 0) return;
|
||||||
|
navigated.current = false;
|
||||||
|
const started = picked.map((file) => {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
files.current.set(id, file);
|
||||||
|
return { id, fileName: file.name, phase: 'uploading' as const };
|
||||||
|
});
|
||||||
|
setTasks((prev) => [...prev, ...started]);
|
||||||
|
started.forEach((task) => void runOne(task.id, files.current.get(task.id)!));
|
||||||
|
},
|
||||||
|
[runOne],
|
||||||
|
);
|
||||||
|
|
||||||
|
const retry = useCallback(
|
||||||
|
(id: string): void => {
|
||||||
|
const file = files.current.get(id);
|
||||||
|
if (file) void runOne(id, file);
|
||||||
|
},
|
||||||
|
[runOne],
|
||||||
|
);
|
||||||
|
|
||||||
|
const dismiss = useCallback((id: string): void => {
|
||||||
|
files.current.delete(id);
|
||||||
|
setTasks((prev) => prev.filter((task) => task.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// `onSucceeded` schedules a dismiss; keep a stable reference to the latest one.
|
||||||
|
const dismissRef = useRef(dismiss);
|
||||||
|
dismissRef.current = dismiss;
|
||||||
|
|
||||||
|
return { tasks, startImport, retry, dismiss };
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { useAuth } from '../auth/auth-context';
|
import { useAuth } from '../auth/auth-context';
|
||||||
|
import { ImportControl } from '../import/ImportControl';
|
||||||
import { LabelChips } from '../labels/LabelChips';
|
import { LabelChips } from '../labels/LabelChips';
|
||||||
import { usePondLabels } from '../labels/use-pond-labels';
|
import { usePondLabels } from '../labels/use-pond-labels';
|
||||||
import { apiGet, apiPatch } from '../lib/api';
|
import { apiGet, apiPatch } from '../lib/api';
|
||||||
@ -275,6 +276,8 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ImportControl pondId={pond.data.id} pondSlug={pondSlug!} />
|
||||||
|
|
||||||
{/* Keyboard/drag reordering announcements for screen readers. */}
|
{/* Keyboard/drag reordering announcements for screen readers. */}
|
||||||
<p className="visually-hidden sidebar__announce" role="status" aria-live="polite">
|
<p className="visually-hidden sidebar__announce" role="status" aria-live="polite">
|
||||||
{announcement}
|
{announcement}
|
||||||
|
|||||||
@ -164,6 +164,52 @@ button {
|
|||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar__import {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__import-action {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__import-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: var(--space-2) 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__import-item {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__import-name {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__import-status {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__import-item--failed .sidebar__import-status {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__import-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
.pond-home {
|
.pond-home {
|
||||||
max-width: 36rem;
|
max-width: 36rem;
|
||||||
}
|
}
|
||||||
|
|||||||
14
packages/shared/i18n/de/import.json
Normal file
14
packages/shared/i18n/de/import.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"action": "Dokument importieren",
|
||||||
|
"hint": "Word (.docx), OpenDocument (.odt) oder Markdown (.md)",
|
||||||
|
"status": {
|
||||||
|
"uploading": "Wird hochgeladen…",
|
||||||
|
"queued": "In Warteschlange…",
|
||||||
|
"converting": "Wird konvertiert…",
|
||||||
|
"done": "Importiert",
|
||||||
|
"failed": "Import fehlgeschlagen"
|
||||||
|
},
|
||||||
|
"retry": "Erneut versuchen",
|
||||||
|
"dismiss": "Schließen",
|
||||||
|
"panelLabel": "Dokument-Importe"
|
||||||
|
}
|
||||||
14
packages/shared/i18n/en/import.json
Normal file
14
packages/shared/i18n/en/import.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"action": "Import document",
|
||||||
|
"hint": "Word (.docx), OpenDocument (.odt) or Markdown (.md)",
|
||||||
|
"status": {
|
||||||
|
"uploading": "Uploading…",
|
||||||
|
"queued": "Queued…",
|
||||||
|
"converting": "Converting…",
|
||||||
|
"done": "Imported",
|
||||||
|
"failed": "Import failed"
|
||||||
|
},
|
||||||
|
"retry": "Retry",
|
||||||
|
"dismiss": "Dismiss",
|
||||||
|
"panelLabel": "Document imports"
|
||||||
|
}
|
||||||
@ -23,6 +23,8 @@ export interface ConversionJobView {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extensions the import endpoint accepts (ADR 0009, issue #63). */
|
/** Extensions the import endpoint accepts (ADR 0009, issues #63/#64). `.docx`
|
||||||
export const IMPORT_EXTENSIONS = ['docx', 'odt'] as const;
|
* and `.odt` convert via the sidecar (a job to poll); `.md`/`.markdown` import
|
||||||
|
* in-process and come back already `succeeded`. */
|
||||||
|
export const IMPORT_EXTENSIONS = ['docx', 'odt', 'md', 'markdown'] as const;
|
||||||
export type ImportExtension = (typeof IMPORT_EXTENSIONS)[number];
|
export type ImportExtension = (typeof IMPORT_EXTENSIONS)[number];
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user