diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 26c5865..1bf1377 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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%';" | \ diff --git a/apps/api/src/import-export/import.service.db.test.ts b/apps/api/src/import-export/import.service.db.test.ts index 04cb74d..fad7fbb 100644 --- a/apps/api/src/import-export/import.service.db.test.ts +++ b/apps/api/src/import-export/import.service.db.test.ts @@ -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`) diff --git a/apps/api/src/import-export/import.service.ts b/apps/api/src/import-export/import.service.ts index cf9f785..dc383f9 100644 --- a/apps/api/src/import-export/import.service.ts +++ b/apps/api/src/import-export/import.service.ts @@ -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> = { 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 { + 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), ); + 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 }, + '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 { // 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 markdown = await this.storeEmbeddedImages(rawMarkdown, user, pondId, storedFileIds); const json = markdownToDoc(markdown).toJSON() as unknown as PmNode; - const { title, doc } = this.splitTitle(json, job.sourceName); + const { title, doc } = this.splitTitle(json, sourceName); const state = docToState(Node.fromJSON(editorSchema, doc)); - const page = await this.pages.createWithState(user, job.pondId, title, state); + const page = await this.pages.createWithState(user, pondId, title, state); await this.files.linkAttachmentsToPage(storedFileIds, page.id); - await this.prisma.conversionJob.update({ - where: { id: job.id }, - data: { status: 'SUCCEEDED', resultPageId: page.id, errorCode: null }, - }); - this.logger.info( - { jobId: job.id, pageId: page.id, pondId: job.pondId, images: storedFileIds.length }, - 'audit: document imported', - ); + 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; +} diff --git a/apps/web/e2e/README.md b/apps/web/e2e/README.md index 1fe549a..1bb6905 100644 --- a/apps/web/e2e/README.md +++ b/apps/web/e2e/README.md @@ -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 diff --git a/apps/web/e2e/import.spec.ts b/apps/web/e2e/import.spec.ts new file mode 100644 index 0000000..ac26837 --- /dev/null +++ b/apps/web/e2e/import.spec.ts @@ -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>, +): 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(); +}); diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts index 0ba8216..d7229e3 100644 --- a/apps/web/src/i18n/index.ts +++ b/apps/web/src/i18n/index.ts @@ -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, diff --git a/apps/web/src/import/ImportControl.tsx b/apps/web/src/import/ImportControl.tsx new file mode 100644 index 0000000..040458c --- /dev/null +++ b/apps/web/src/import/ImportControl.tsx @@ -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(null); + const { tasks, startImport, retry, dismiss } = useImport(pondId, pondSlug); + + return ( +
+ + { + startImport(Array.from(event.target.files ?? [])); + // Clear the value so re-picking the same file fires `change` again. + event.target.value = ''; + }} + /> + {tasks.length > 0 && ( +
    + {tasks.map((task) => ( +
  • + + {task.fileName} + + + {task.phase === 'failed' && task.errorCode + ? tErrors(task.errorCode) + : t(`status.${task.phase}`)} + + {task.phase === 'failed' && ( + + + + + )} +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/import/use-import.ts b/apps/web/src/import/use-import.ts new file mode 100644 index 0000000..37c401c --- /dev/null +++ b/apps/web/src/import/use-import.ts @@ -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 => 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([]); + const files = useRef(new Map()); + // 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): void => { + setTasks((prev) => prev.map((task) => (task.id === id ? { ...task, ...next } : task))); + }, []); + + const onSucceeded = useCallback( + async (id: string, pageId: string): Promise => { + 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(`/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 => { + patch(id, { phase: 'uploading', errorCode: undefined }); + try { + let job = await apiUploadFile(`/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(`/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 }; +} diff --git a/apps/web/src/layout/Sidebar.tsx b/apps/web/src/layout/Sidebar.tsx index b2da673..92b2132 100644 --- a/apps/web/src/layout/Sidebar.tsx +++ b/apps/web/src/layout/Sidebar.tsx @@ -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 { )} + + {/* Keyboard/drag reordering announcements for screen readers. */}

{announcement} diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 7b78f38..cfd38c9 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -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; } diff --git a/packages/shared/i18n/de/import.json b/packages/shared/i18n/de/import.json new file mode 100644 index 0000000..7a226ae --- /dev/null +++ b/packages/shared/i18n/de/import.json @@ -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" +} diff --git a/packages/shared/i18n/en/import.json b/packages/shared/i18n/en/import.json new file mode 100644 index 0000000..caecb9b --- /dev/null +++ b/packages/shared/i18n/en/import.json @@ -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" +} diff --git a/packages/shared/src/conversion.ts b/packages/shared/src/conversion.ts index c80a8bc..3d2abf2 100644 --- a/packages/shared/src/conversion.ts +++ b/packages/shared/src/conversion.ts @@ -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];