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
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:
parent
8ae010218e
commit
704ebe48a6
135
apps/web/e2e/import-vault.spec.ts
Normal file
135
apps/web/e2e/import-vault.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
301
apps/web/src/import/VaultImportSection.tsx
Normal file
301
apps/web/src/import/VaultImportSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 });
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user