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 \
|
||||
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
|
||||
run: |
|
||||
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 } });
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const res = await api()
|
||||
.post(`/api/v1/ponds/${pondId}/import`)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 { Node } from 'prosemirror-model';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
@ -13,10 +13,13 @@ 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). */
|
||||
/** 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>> = {
|
||||
docx: 'docx',
|
||||
odt: 'odt',
|
||||
md: 'md',
|
||||
markdown: 'md',
|
||||
};
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
/** 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(
|
||||
user: User,
|
||||
pondId: string,
|
||||
@ -122,6 +130,7 @@ export class ImportService implements ImportProcessor {
|
||||
if (!format) {
|
||||
throw new BadRequestException({ code: 'import_unsupported_format' });
|
||||
}
|
||||
if (format === 'md') return this.importMarkdown(user, pondId, file);
|
||||
const job = await this.jobs.enqueue({
|
||||
ownerId: user.id,
|
||||
pondId,
|
||||
@ -135,6 +144,41 @@ export class ImportService implements ImportProcessor {
|
||||
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
|
||||
* {@link ConversionError} on failure so the worker applies the queue's
|
||||
@ -156,26 +200,41 @@ export class ImportService implements ImportProcessor {
|
||||
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);
|
||||
const page = await this.createPageFromMarkdown(user, job.pondId, rawMarkdown, job.sourceName);
|
||||
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 },
|
||||
{ jobId: job.id, pageId: page.id, pondId: job.pondId },
|
||||
'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) {
|
||||
await this.rollbackMedia(user, storedFileIds);
|
||||
throw error;
|
||||
@ -320,3 +379,19 @@ function errorCode(error: unknown): string | 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
|
||||
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
|
||||
|
||||
`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 deErrors from '@dorfteich/shared/i18n/de/errors.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 deLinks from '@dorfteich/shared/i18n/de/links.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 enErrors from '@dorfteich/shared/i18n/en/errors.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 enLinks from '@dorfteich/shared/i18n/en/links.json';
|
||||
import enMembers from '@dorfteich/shared/i18n/en/members.json';
|
||||
@ -49,6 +51,7 @@ void i18n
|
||||
settings: enSettings,
|
||||
editor: enEditor,
|
||||
files: enFiles,
|
||||
import: enImport,
|
||||
labels: enLabels,
|
||||
links: enLinks,
|
||||
members: enMembers,
|
||||
@ -65,6 +68,7 @@ void i18n
|
||||
settings: deSettings,
|
||||
editor: deEditor,
|
||||
files: deFiles,
|
||||
import: deImport,
|
||||
labels: deLabels,
|
||||
links: deLinks,
|
||||
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 { useAuth } from '../auth/auth-context';
|
||||
import { ImportControl } from '../import/ImportControl';
|
||||
import { LabelChips } from '../labels/LabelChips';
|
||||
import { usePondLabels } from '../labels/use-pond-labels';
|
||||
import { apiGet, apiPatch } from '../lib/api';
|
||||
@ -275,6 +276,8 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
|
||||
</button>
|
||||
)}
|
||||
|
||||
<ImportControl pondId={pond.data.id} pondSlug={pondSlug!} />
|
||||
|
||||
{/* Keyboard/drag reordering announcements for screen readers. */}
|
||||
<p className="visually-hidden sidebar__announce" role="status" aria-live="polite">
|
||||
{announcement}
|
||||
|
||||
@ -164,6 +164,52 @@ button {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
/** Extensions the import endpoint accepts (ADR 0009, issue #63). */
|
||||
export const IMPORT_EXTENSIONS = ['docx', 'odt'] as const;
|
||||
/** Extensions the import endpoint accepts (ADR 0009, issues #63/#64). `.docx`
|
||||
* 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];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user