diff --git a/apps/web/e2e/import-vault.spec.ts b/apps/web/e2e/import-vault.spec.ts new file mode 100644 index 0000000..8ea8c4e --- /dev/null +++ b/apps/web/e2e/import-vault.spec.ts @@ -0,0 +1,135 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { expect, test, type BrowserContext } from '@playwright/test'; +import { zipSync } from 'fflate'; + +import { contextForUser } from './helpers'; + +/** + * Obsidian vault import pack (issues #117/#118): a pond admin imports the + * checked-in fixture vault through the settings dialog and the result shows + * up in the app — folder tree, rewritten wikilinks, tag labels, embedded + * image. A plain editor sees no such section. The ZIP is built from the + * fixture directory here, so the vault stays reviewable as plain files. + */ +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; +const FIXTURE_DIR = join( + dirname(fileURLToPath(import.meta.url)), + '../../../fixtures/import/obsidian-vault', +); + +function fixtureZip(): Buffer { + const entries: Record = {}; + const walk = (dir: string): void => { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) walk(path); + else entries[relative(FIXTURE_DIR, path)] = new Uint8Array(readFileSync(path)); + } + }; + walk(FIXTURE_DIR); + return Buffer.from(zipSync(entries)); +} + +async function json(context: BrowserContext, url: string, data: unknown): Promise { + const response = await context.request.post(url, { data }); + if (!response.ok()) throw new Error(`post ${url} → ${response.status()}`); + return response.json() as Promise; +} + +test('a pond admin imports an Obsidian vault through the settings dialog', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const ts = Date.now(); + const pond = await json<{ id: string; slug: string }>(context, '/api/v1/ponds', { + name: `Vault Pond ${ts}`, + }); + const mount = await json<{ id: string; slug: string; title: string }>( + context, + `/api/v1/ponds/${pond.id}/pages`, + { title: `Vault Mount ${ts}` }, + ); + const label = await json<{ id: string; name: string }>( + context, + `/api/v1/ponds/${pond.id}/labels`, + { name: `import-${ts}` }, + ); + + const page = await context.newPage(); + await page.goto(`/p/${pond.slug}/settings`); + + // The admin-only section opens the dialog. + await page.locator('.vault-import__open').click(); + const dialog = page.locator('.vault-import-dialog'); + await expect(dialog).toBeVisible(); + + await dialog.locator('input[type="file"]').setInputFiles({ + name: 'vault.zip', + mimeType: 'application/zip', + buffer: fixtureZip(), + }); + // Mount under the prepared page and tag everything with the extra label. + await dialog + .locator('li', { hasText: `Vault Mount ${ts}` }) + .locator('input') + .check(); + await dialog + .locator('li', { hasText: `import-${ts}` }) + .locator('input') + .check(); + await dialog.locator('.vault-import-dialog__start').click(); + + // The job runs; the dialog offers the way into the imported pages. + const doneLink = dialog.getByRole('link'); + await expect(doneLink).toBeVisible({ timeout: 30_000 }); + await doneLink.click(); + + // The sidebar shows the vault's folder structure under the mount page. + const mountItem = page + .locator('.sidebar__page-item') + .filter({ has: page.locator(`.sidebar__page:text-is("Vault Mount ${ts}")`) }) + .first(); + await expect( + mountItem.locator('.sidebar__tree-children .sidebar__page', { hasText: 'Projekte' }), + ).toBeVisible(); + await expect(page.locator('.sidebar__page:text-is("Startseite")')).toBeVisible(); + + // A rewritten Obsidian link navigates to the right imported page, and the + // display text still reads like the original note name. + await page.goto(`/p/${pond.slug}/startseite`); + await page.locator('.editor-content a.wikilink', { hasText: 'Projekt A' }).click(); + await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/projekt-a$`)); + + // The note's image came in as a pond file, and its tags became labels + // (nested #status/aktiv included) next to the dialog's extra label. + await expect(page.locator('.editor-content img').first()).toBeVisible(); + const labels = await context.request.get(`/api/v1/ponds/${pond.id}/labels`); + const tree = JSON.stringify(await labels.json()); + expect(tree).toContain('status'); + expect(tree).toContain('aktiv'); + expect(tree).toContain(label.name); + + await context.close(); +}); + +test('a plain editor gets no vault-import section', async ({ browser }) => { + const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); + const editor = await contextForUser(browser, BASE_URL, 'fixture-editor'); + const ts = Date.now(); + const pond = await json<{ id: string; slug: string }>(owner, '/api/v1/ponds', { + name: `Vault Gate ${ts}`, + }); + await json(owner, `/api/v1/ponds/${pond.id}/members`, { + usernameOrEmail: 'fixture-editor', + role: 'editor', + }); + + const page = await editor.newPage(); + await page.goto(`/p/${pond.slug}/settings`); + await expect(page.locator('.pond-settings-page')).toBeVisible(); + await expect(page.locator('.vault-import__open')).toHaveCount(0); + + await owner.close(); + await editor.close(); +}); diff --git a/apps/web/src/import/VaultImportSection.tsx b/apps/web/src/import/VaultImportSection.tsx new file mode 100644 index 0000000..27cfcde --- /dev/null +++ b/apps/web/src/import/VaultImportSection.tsx @@ -0,0 +1,301 @@ +import type { + ConversionJobView, + PageListItemView, + PageView, + PondView, + TreeNode, + VaultFrontmatterMode, +} from '@dorfteich/shared'; +import { buildTree, labelDepth } from '@dorfteich/shared'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; + +import { FormError } from '../components/forms'; +import { labelsKey, usePondLabels } from '../labels/use-pond-labels'; +import { apiGet, apiUploadFile } from '../lib/api'; +import { useDismissable } from '../lib/use-dismissable'; + +const POLL_INTERVAL_MS = 1000; +// Vault jobs process many notes and files — budget well past a single +// document's window (#64 uses 180). +const MAX_POLLS = 600; + +const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +type VaultPhase = + | { kind: 'idle' } + | { kind: 'running' } + | { kind: 'done'; mountSlug: string | null } + | { kind: 'failed'; errorCode: string }; + +/** + * Obsidian vault import (issue #118): an admin-only section in the pond + * settings. The dialog collects the archive plus the #117 options — mount + * parent, import-wide labels, frontmatter mode — starts the job, and polls + * it to the end. + */ +export function VaultImportSection({ + pondId, + pondSlug, +}: { + pondId: string; + pondSlug: string; +}): React.JSX.Element { + const { t } = useTranslation('import'); + const [open, setOpen] = useState(false); + + return ( +
+

{t('vault.hint')}

+ + {open && ( + setOpen(false)} /> + )} +
+ ); +} + +function VaultImportDialog({ + pondId, + pondSlug, + onClose, +}: { + pondId: string; + pondSlug: string; + onClose: () => void; +}): React.JSX.Element { + const { t } = useTranslation('import'); + const { t: tErrors } = useTranslation('errors'); + const queryClient = useQueryClient(); + const dialogRef = useRef(null); + const [phase, setPhase] = useState({ kind: 'idle' }); + const [error, setError] = useState(null); + const [file, setFile] = useState(null); + const [parentId, setParentId] = useState(null); + const [labelIds, setLabelIds] = useState>(new Set()); + const [frontmatter, setFrontmatter] = useState('strip'); + const running = phase.kind === 'running'; + useDismissable(dialogRef, !running, onClose); + + const pond = useQuery({ + queryKey: ['pond', pondSlug], + queryFn: () => apiGet(`/ponds/${pondSlug}`), + }); + const pages = useQuery({ + queryKey: ['pages', pondId, pond.data?.settings.sidebarSort], + queryFn: () => apiGet(`/ponds/${pondId}/pages`), + enabled: Boolean(pond.data), + }); + const { flat } = usePondLabels(pondId); + + /** Depth-first page options for the indented mount picker. */ + const options: { page: PageListItemView; depth: number }[] = []; + const walk = (nodes: TreeNode[], depth: number): void => { + for (const node of nodes) { + options.push({ page: node, depth }); + walk(node.children, depth + 1); + } + }; + walk(buildTree(pages.data ?? []), 0); + + function toggleLabel(id: string, on: boolean): void { + setLabelIds((prev) => { + const next = new Set(prev); + if (on) next.add(id); + else next.delete(id); + return next; + }); + } + + async function start(): Promise { + if (!file) return; + setError(null); + setPhase({ kind: 'running' }); + try { + const job = await apiUploadFile(`/ponds/${pondId}/import/vault`, file, { + options: JSON.stringify({ + parentPageId: parentId, + labelIds: [...labelIds], + frontmatterMode: frontmatter, + }), + }); + for (let poll = 0; poll < MAX_POLLS; poll += 1) { + await delay(POLL_INTERVAL_MS); + const view = await apiGet(`/jobs/${job.id}`); + if (view.status === 'succeeded') { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['pages', pondId] }), + queryClient.invalidateQueries({ queryKey: ['pond-links', pondId] }), + queryClient.invalidateQueries({ queryKey: ['phantom-links', pondId] }), + queryClient.invalidateQueries({ queryKey: labelsKey(pondId) }), + ]); + let mountSlug: string | null = null; + if (view.resultPageId) { + mountSlug = (await apiGet(`/pages/${view.resultPageId}`)).slug; + } + setPhase({ kind: 'done', mountSlug }); + return; + } + if (view.status === 'failed') { + setPhase({ kind: 'failed', errorCode: view.errorCode ?? 'conversion_failed' }); + return; + } + } + setPhase({ kind: 'failed', errorCode: 'converter_timeout' }); + } catch (err) { + setError(err); + setPhase({ kind: 'idle' }); + } + } + + return ( +
+
+

{t('vault.title')}

+ + + {phase.kind === 'done' ? ( + <> +

{t('vault.done')}

+
+ + {t('vault.doneLink')} + + +
+ + ) : phase.kind === 'failed' ? ( + <> +

+ {tErrors(phase.errorCode)} +

+
+ + +
+ + ) : ( + <> + + +
+ {t('vault.parent')} +
    +
  • + +
  • + {options.map(({ page, depth }) => ( +
  • + +
  • + ))} +
+
+ + {flat.length > 0 && ( +
+ {t('vault.labels')} +
    + {flat.map((label) => ( +
  • + +
  • + ))} +
+
+ )} + +
+ {t('vault.frontmatter')} + + +
+ +
+ + +
+ + )} +
+
+ ); +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index f3796ad..512e2a2 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -66,9 +66,14 @@ export const apiDelete = (path: string): Promise => requestJson('DELETE /** Multipart upload (issue #27/#28) — a FormData body, unlike every other * endpoint here, so it can't share `requestJson`'s JSON serialization; the * error handling is kept identical. */ -export async function apiUploadFile(path: string, file: File): Promise { +export async function apiUploadFile( + path: string, + file: File, + fields: Record = {}, +): Promise { const form = new FormData(); form.append('file', file); + for (const [name, value] of Object.entries(fields)) form.append(name, value); let response: Response; try { response = await fetch(`/api/v1${path}`, { method: 'POST', body: form }); diff --git a/apps/web/src/pages/PondSettingsPage.tsx b/apps/web/src/pages/PondSettingsPage.tsx index 5491cdd..721cb54 100644 --- a/apps/web/src/pages/PondSettingsPage.tsx +++ b/apps/web/src/pages/PondSettingsPage.tsx @@ -15,6 +15,7 @@ import { AccessRulesManager } from '../access/AccessRulesManager'; import { EffectivePermissionsInspector } from '../access/EffectivePermissionsInspector'; import { PondFileManager } from '../files/PondFileManager'; import { apiGet } from '../lib/api'; +import { VaultImportSection } from '../import/VaultImportSection'; import { SidebarViewSetting } from '../layout/SidebarViewSetting'; import { MemberManager } from '../members/MemberManager'; import { DeletePondSection } from '../ponds/DeletePondSection'; @@ -39,6 +40,7 @@ export function PondSettingsPage(): React.JSX.Element { const { t: tApiTokens } = useTranslation('apiTokens'); const { t: tFont } = useTranslation('font'); const { t: tCommon } = useTranslation(); + const { t: tImport } = useTranslation('import'); const { pondSlug = '' } = useParams<{ pondSlug: string }>(); const { user } = useAuth(); @@ -80,6 +82,12 @@ export function PondSettingsPage(): React.JSX.Element { )} + {canModify && ( +
+

{tImport('vault.title')}

+ +
+ )} {canModify && (

{tFiles('manager.title')}

diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 78b5f05..62eb6a8 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -1828,6 +1828,31 @@ button { border: 2px dashed var(--color-text-muted); } +/* Obsidian vault import dialog (issue #118). */ +.vault-import__hint { + color: var(--color-text-muted); + font-size: 0.9rem; +} + +.vault-import-dialog__field { + display: block; + border: 0; + padding: 0; + margin: 0 0 var(--space-3); +} + +.vault-import-dialog__field > legend, +.vault-import-dialog__field > span { + display: block; + font-weight: 600; + font-size: 0.9rem; + margin-bottom: var(--space-1); +} + +.vault-import-dialog__field .move-dialog__options { + max-height: 12rem; +} + /* "Create this page" on the not-found screen (issue #115). */ .create-missing-page { margin-top: var(--space-3); diff --git a/packages/shared/i18n/de/import.json b/packages/shared/i18n/de/import.json index 7a226ae..52665d9 100644 --- a/packages/shared/i18n/de/import.json +++ b/packages/shared/i18n/de/import.json @@ -10,5 +10,23 @@ }, "retry": "Erneut versuchen", "dismiss": "Schließen", - "panelLabel": "Dokument-Importe" + "panelLabel": "Dokument-Importe", + "vault": { + "title": "Obsidian-Vault importieren", + "hint": "Lade ein ZIP eines ganzen Vaults hoch: Ordner werden Unterseiten, Obsidian-Tags werden Labels, und [[Wikilinks]] funktionieren weiter.", + "action": "Vault importieren…", + "file": "Vault-Archiv (.zip)", + "parent": "Einhängen unter", + "parentRoot": "Oberste Ebene", + "labels": "Zusätzliche Labels für alle importierten Seiten", + "frontmatter": "YAML-Frontmatter", + "frontmatterStrip": "Entfernen", + "frontmatterPreserve": "Als Code-Block erhalten", + "start": "Import starten", + "running": "Importiere…", + "cancel": "Abbrechen", + "done": "Der Vault wurde importiert.", + "doneLink": "Importierte Seiten öffnen", + "close": "Schließen" + } } diff --git a/packages/shared/i18n/en/import.json b/packages/shared/i18n/en/import.json index caecb9b..90d0b06 100644 --- a/packages/shared/i18n/en/import.json +++ b/packages/shared/i18n/en/import.json @@ -10,5 +10,23 @@ }, "retry": "Retry", "dismiss": "Dismiss", - "panelLabel": "Document imports" + "panelLabel": "Document imports", + "vault": { + "title": "Import an Obsidian vault", + "hint": "Upload a ZIP of a whole vault: folders become subpages, Obsidian tags become labels, and [[wikilinks]] keep working.", + "action": "Import vault…", + "file": "Vault archive (.zip)", + "parent": "Mount under", + "parentRoot": "Top level", + "labels": "Additional labels for every imported page", + "frontmatter": "YAML frontmatter", + "frontmatterStrip": "Remove it", + "frontmatterPreserve": "Keep it as a code block", + "start": "Start import", + "running": "Importing…", + "cancel": "Cancel", + "done": "The vault was imported.", + "doneLink": "Open the imported pages", + "close": "Close" + } }