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
145 lines
5.2 KiB
TypeScript
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 };
|
|
}
|