Vault import dialog in the pond settings (#118)
Some checks failed
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Deploy to Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Lint, typecheck, test (push) Failing after 1m0s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Has been cancelled

An admin-only 'Import an Obsidian vault' section on the pond settings
page opens a dialog with everything the #117 endpoint expects: the ZIP,
an indented mount-parent picker over the page tree (the MovePageDialog
pattern), a multi-select over the pond's label tree, and the
frontmatter radio (strip / keep as code block). Submit uploads and
polls the job with a vault-sized budget (600 x 1 s), then invalidates
pages, graph, phantom-links, and labels so the sidebar tree, graph, and
pickers show the import without a reload — and links to the mount page.

apiUploadFile now takes extra multipart fields (the options JSON);
existing callers are unchanged.

e2e import-vault.spec.ts: an admin imports the fixture vault through
the dialog and the app shows the folder tree under the mount page, a
rewritten Obsidian link navigates to the right page, the embedded image
renders, and the nested tag labels exist next to the dialog's extra
label; a plain editor gets no section at all. 3x flake-free locally
(CI wiring lands with #119).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5 2026-07-14 18:26:26 +02:00
parent 8ae010218e
commit 704ebe48a6
7 changed files with 513 additions and 3 deletions

View File

@ -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<string, Uint8Array> = {};
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<T>(context: BrowserContext, url: string, data: unknown): Promise<T> {
const response = await context.request.post(url, { data });
if (!response.ok()) throw new Error(`post ${url}${response.status()}`);
return response.json() as Promise<T>;
}
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();
});

View File

@ -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<void> => 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 (
<div className="vault-import">
<p className="vault-import__hint">{t('vault.hint')}</p>
<button type="button" className="button vault-import__open" onClick={() => setOpen(true)}>
{t('vault.action')}
</button>
{open && (
<VaultImportDialog pondId={pondId} pondSlug={pondSlug} onClose={() => setOpen(false)} />
)}
</div>
);
}
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<HTMLDivElement>(null);
const [phase, setPhase] = useState<VaultPhase>({ kind: 'idle' });
const [error, setError] = useState<unknown>(null);
const [file, setFile] = useState<File | null>(null);
const [parentId, setParentId] = useState<string | null>(null);
const [labelIds, setLabelIds] = useState<Set<string>>(new Set());
const [frontmatter, setFrontmatter] = useState<VaultFrontmatterMode>('strip');
const running = phase.kind === 'running';
useDismissable(dialogRef, !running, onClose);
const pond = useQuery({
queryKey: ['pond', pondSlug],
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
});
const pages = useQuery({
queryKey: ['pages', pondId, pond.data?.settings.sidebarSort],
queryFn: () => apiGet<PageListItemView[]>(`/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<PageListItemView>[], 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<void> {
if (!file) return;
setError(null);
setPhase({ kind: 'running' });
try {
const job = await apiUploadFile<ConversionJobView>(`/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<ConversionJobView>(`/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<PageView>(`/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 (
<div className="modal-overlay">
<div className="modal vault-import-dialog" role="dialog" aria-modal="true" ref={dialogRef}>
<h2 className="modal__title">{t('vault.title')}</h2>
<FormError error={error} />
{phase.kind === 'done' ? (
<>
<p>{t('vault.done')}</p>
<div className="modal__actions">
<Link
className="button"
to={phase.mountSlug ? `/p/${pondSlug}/${phase.mountSlug}` : `/p/${pondSlug}`}
onClick={onClose}
>
{t('vault.doneLink')}
</Link>
<button type="button" className="linklike" onClick={onClose}>
{t('vault.close')}
</button>
</div>
</>
) : phase.kind === 'failed' ? (
<>
<p className="form-banner form-banner--error" role="alert">
{tErrors(phase.errorCode)}
</p>
<div className="modal__actions">
<button type="button" className="button" onClick={() => setPhase({ kind: 'idle' })}>
{t('retry')}
</button>
<button type="button" className="linklike" onClick={onClose}>
{t('vault.close')}
</button>
</div>
</>
) : (
<>
<label className="vault-import-dialog__field">
<span>{t('vault.file')}</span>
<input
type="file"
accept=".zip"
disabled={running}
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
/>
</label>
<fieldset className="vault-import-dialog__field" disabled={running}>
<legend>{t('vault.parent')}</legend>
<ul className="move-dialog__options">
<li>
<label className="move-dialog__option">
<input
type="radio"
name="vault-parent"
checked={parentId === null}
onChange={() => setParentId(null)}
/>
<span>{t('vault.parentRoot')}</span>
</label>
</li>
{options.map(({ page, depth }) => (
<li key={page.id} style={{ paddingInlineStart: `${depth * 1}rem` }}>
<label className="move-dialog__option">
<input
type="radio"
name="vault-parent"
checked={parentId === page.id}
onChange={() => setParentId(page.id)}
/>
<span>{page.title}</span>
</label>
</li>
))}
</ul>
</fieldset>
{flat.length > 0 && (
<fieldset className="vault-import-dialog__field" disabled={running}>
<legend>{t('vault.labels')}</legend>
<ul className="move-dialog__options">
{flat.map((label) => (
<li
key={label.id}
style={{ paddingInlineStart: `${(labelDepth(flat, label.id) - 1) * 1}rem` }}
>
<label className="move-dialog__option">
<input
type="checkbox"
checked={labelIds.has(label.id)}
onChange={(event) => toggleLabel(label.id, event.target.checked)}
/>
<span
className="label-chip__swatch"
style={{ backgroundColor: label.color }}
aria-hidden
/>
<span>{label.name}</span>
</label>
</li>
))}
</ul>
</fieldset>
)}
<fieldset className="vault-import-dialog__field" disabled={running}>
<legend>{t('vault.frontmatter')}</legend>
<label className="move-dialog__option">
<input
type="radio"
name="vault-frontmatter"
checked={frontmatter === 'strip'}
onChange={() => setFrontmatter('strip')}
/>
<span>{t('vault.frontmatterStrip')}</span>
</label>
<label className="move-dialog__option">
<input
type="radio"
name="vault-frontmatter"
checked={frontmatter === 'preserve'}
onChange={() => setFrontmatter('preserve')}
/>
<span>{t('vault.frontmatterPreserve')}</span>
</label>
</fieldset>
<div className="modal__actions">
<button
type="button"
className="button vault-import-dialog__start"
disabled={!file || running}
onClick={() => void start()}
>
{running ? t('vault.running') : t('vault.start')}
</button>
<button type="button" className="linklike" disabled={running} onClick={onClose}>
{t('vault.cancel')}
</button>
</div>
</>
)}
</div>
</div>
);
}

View File

@ -66,9 +66,14 @@ export const apiDelete = <T>(path: string): Promise<T> => requestJson<T>('DELETE
/** Multipart upload (issue #27/#28) a FormData body, unlike every other /** Multipart upload (issue #27/#28) a FormData body, unlike every other
* endpoint here, so it can't share `requestJson`'s JSON serialization; the * endpoint here, so it can't share `requestJson`'s JSON serialization; the
* error handling is kept identical. */ * error handling is kept identical. */
export async function apiUploadFile<T>(path: string, file: File): Promise<T> { export async function apiUploadFile<T>(
path: string,
file: File,
fields: Record<string, string> = {},
): Promise<T> {
const form = new FormData(); const form = new FormData();
form.append('file', file); form.append('file', file);
for (const [name, value] of Object.entries(fields)) form.append(name, value);
let response: Response; let response: Response;
try { try {
response = await fetch(`/api/v1${path}`, { method: 'POST', body: form }); response = await fetch(`/api/v1${path}`, { method: 'POST', body: form });

View File

@ -15,6 +15,7 @@ import { AccessRulesManager } from '../access/AccessRulesManager';
import { EffectivePermissionsInspector } from '../access/EffectivePermissionsInspector'; import { EffectivePermissionsInspector } from '../access/EffectivePermissionsInspector';
import { PondFileManager } from '../files/PondFileManager'; import { PondFileManager } from '../files/PondFileManager';
import { apiGet } from '../lib/api'; import { apiGet } from '../lib/api';
import { VaultImportSection } from '../import/VaultImportSection';
import { SidebarViewSetting } from '../layout/SidebarViewSetting'; import { SidebarViewSetting } from '../layout/SidebarViewSetting';
import { MemberManager } from '../members/MemberManager'; import { MemberManager } from '../members/MemberManager';
import { DeletePondSection } from '../ponds/DeletePondSection'; import { DeletePondSection } from '../ponds/DeletePondSection';
@ -39,6 +40,7 @@ export function PondSettingsPage(): React.JSX.Element {
const { t: tApiTokens } = useTranslation('apiTokens'); const { t: tApiTokens } = useTranslation('apiTokens');
const { t: tFont } = useTranslation('font'); const { t: tFont } = useTranslation('font');
const { t: tCommon } = useTranslation(); const { t: tCommon } = useTranslation();
const { t: tImport } = useTranslation('import');
const { pondSlug = '' } = useParams<{ pondSlug: string }>(); const { pondSlug = '' } = useParams<{ pondSlug: string }>();
const { user } = useAuth(); const { user } = useAuth();
@ -80,6 +82,12 @@ export function PondSettingsPage(): React.JSX.Element {
<PhantomPagesView pondId={pond.data.id} pondSlug={pondSlug} /> <PhantomPagesView pondId={pond.data.id} pondSlug={pondSlug} />
</section> </section>
)} )}
{canModify && (
<section>
<h2>{tImport('vault.title')}</h2>
<VaultImportSection pondId={pond.data.id} pondSlug={pondSlug} />
</section>
)}
{canModify && ( {canModify && (
<section> <section>
<h2>{tFiles('manager.title')}</h2> <h2>{tFiles('manager.title')}</h2>

View File

@ -1828,6 +1828,31 @@ button {
border: 2px dashed var(--color-text-muted); 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 this page" on the not-found screen (issue #115). */
.create-missing-page { .create-missing-page {
margin-top: var(--space-3); margin-top: var(--space-3);

View File

@ -10,5 +10,23 @@
}, },
"retry": "Erneut versuchen", "retry": "Erneut versuchen",
"dismiss": "Schließen", "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"
}
} }

View File

@ -10,5 +10,23 @@
}, },
"retry": "Retry", "retry": "Retry",
"dismiss": "Dismiss", "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"
}
} }