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

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:
Claude Opus 4.8 2026-07-10 09:38:31 +02:00
parent 546e8279ac
commit e2f942c0ff
13 changed files with 584 additions and 17 deletions

View File

@ -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%';" | \

View File

@ -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`)

View File

@ -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;
}

View File

@ -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
View 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();
});

View File

@ -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,

View 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>
);
}

View 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 };
}

View File

@ -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}

View File

@ -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;
} }

View 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"
}

View 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"
}

View File

@ -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];