dorfteich/apps/web/src/import/use-import.ts
Claude Opus 4.8 e2f942c0ff
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
Add document import UI in the sidebar (#64)
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
2026-07-10 09:38:31 +02:00

145 lines
5.2 KiB
TypeScript

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