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>
115 lines
4.0 KiB
TypeScript
115 lines
4.0 KiB
TypeScript
import type { ApiErrorBody, HealthResponse } from '@dorfteich/shared';
|
|
|
|
/**
|
|
* Typed fetch helper for the REST api. Non-2xx responses reject with an
|
|
* ApiError carrying the uniform body — callers translate `code` via the
|
|
* errors namespace and map `details` onto form fields.
|
|
*/
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
readonly status: number,
|
|
readonly body: ApiErrorBody,
|
|
) {
|
|
super(body.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fired once any api call answers 503 `maintenance_mode` (an in-app backup
|
|
* restore is running, issue #103) — App.tsx listens and swaps the UI for
|
|
* the maintenance screen, whatever the user was doing.
|
|
*/
|
|
export const MAINTENANCE_EVENT = 'dorfteich:maintenance';
|
|
|
|
function noteMaintenance(status: number, body: ApiErrorBody | null): void {
|
|
if (status === 503 && body?.code === 'maintenance_mode') {
|
|
window.dispatchEvent(new CustomEvent(MAINTENANCE_EVENT));
|
|
}
|
|
}
|
|
|
|
async function requestJson<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(`/api/v1${path}`, {
|
|
method,
|
|
headers: {
|
|
Accept: 'application/json',
|
|
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
},
|
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
});
|
|
} catch {
|
|
throw new ApiError(0, { code: 'network', message: 'network error' });
|
|
}
|
|
if (!response.ok) {
|
|
const parsed = (await response.json().catch(() => null)) as ApiErrorBody | null;
|
|
noteMaintenance(response.status, parsed);
|
|
throw new ApiError(
|
|
response.status,
|
|
parsed ?? { code: `http_${response.status}`, message: response.statusText },
|
|
);
|
|
}
|
|
// 201/204 responses may carry no body at all.
|
|
const text = await response.text();
|
|
return (text ? JSON.parse(text) : undefined) as T;
|
|
}
|
|
|
|
export const apiGet = <T>(path: string): Promise<T> => requestJson<T>('GET', path);
|
|
export const apiPost = <T>(path: string, body?: unknown): Promise<T> =>
|
|
requestJson<T>('POST', path, body);
|
|
export const apiPut = <T>(path: string, body?: unknown): Promise<T> =>
|
|
requestJson<T>('PUT', path, body);
|
|
export const apiPatch = <T>(path: string, body?: unknown): Promise<T> =>
|
|
requestJson<T>('PATCH', path, body);
|
|
export const apiDelete = <T>(path: string): Promise<T> => requestJson<T>('DELETE', path);
|
|
|
|
/** 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<T>(
|
|
path: string,
|
|
file: File,
|
|
fields: Record<string, string> = {},
|
|
): Promise<T> {
|
|
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 });
|
|
} catch {
|
|
throw new ApiError(0, { code: 'network', message: 'network error' });
|
|
}
|
|
if (!response.ok) {
|
|
const parsed = (await response.json().catch(() => null)) as ApiErrorBody | null;
|
|
throw new ApiError(
|
|
response.status,
|
|
parsed ?? { code: `http_${response.status}`, message: response.statusText },
|
|
);
|
|
}
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
export function fetchHealth(): Promise<HealthResponse> {
|
|
return apiGet<HealthResponse>('/healthz');
|
|
}
|
|
|
|
/** Plain-text response (issue #30's Markdown export) — every other endpoint
|
|
* here returns JSON, so this can't share `requestJson`'s `JSON.parse`. */
|
|
export async function apiGetText(path: string): Promise<string> {
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(`/api/v1${path}`);
|
|
} catch {
|
|
throw new ApiError(0, { code: 'network', message: 'network error' });
|
|
}
|
|
if (!response.ok) {
|
|
const parsed = (await response.json().catch(() => null)) as ApiErrorBody | null;
|
|
throw new ApiError(
|
|
response.status,
|
|
parsed ?? { code: `http_${response.status}`, message: response.statusText },
|
|
);
|
|
}
|
|
return response.text();
|
|
}
|