import type { IconName } from './icons' // REST API 客户端,与服务端 internal/api 对应 export type TreeIcon = { kind: 'emoji'; value: string } | { kind: 'builtin'; value: IconName } export interface Entry { path: string dir: boolean size: number mtime: number icon?: TreeIcon } export interface ImportResult { paths: string[] notes: number assets: number warnings: string[] } export interface NoteMetadata { path: string rev: string title: string tags: string[] links: { target: string; line: number }[] assets: { target: string; line: number }[] warnings: string[] } export interface Theme { id: string name: string version: string } export interface CustomFont { id: string name: string format: 'ttf' | 'otf' | 'woff' | 'woff2' } export interface InstalledPlugin { id: string name: string version: string apiVersion: 1 revision: string enabled: boolean } export interface TrashItem { id: string path: string dir: boolean deletedAt: number } export interface StorageUsage { totalSize: number categories: { id: 'vault' | 'databases' | 'fonts' | 'themes' | 'plugins' | 'other'; size: number }[] builtinFontsSize: number customFontsSize: number assetsSize: number trashSize: number calculatedAt: number } export interface MaintenanceStatus { busy: boolean; operation: string; error: string; finishedAt: number; backup?: string } export interface BackupSummary { kind: 'full' | 'settings'; createdAt: number; entries: number; size: number } export type BackupKind = 'full' | 'settings' export interface TrashPurgeResult { deleted: string[] failed: { id: string; error: string }[] } export interface SearchResult { path: string // 第一处匹配所在行(从 1 开始)及其摘录;只有路径匹配时 line 为 0 line: number snippet: string } export interface ApiToken { id: string name: string scope: 'read' | 'write' createdAt: number // 0 为从未使用 lastUsedAt: number } export class ApiError extends Error { constructor( readonly status: number, message: string, readonly data: Record = {}, ) { super(message) } } // 401 时通知应用回到登录页 let onUnauthorized: () => void = () => {} export function setUnauthorizedHandler(fn: () => void) { onUnauthorized = fn } export function encodePath(path: string): string { return path.split('/').map(encodeURIComponent).join('/') } async function request( method: string, url: string, init: { json?: unknown; body?: BodyInit; headers?: Record; keepalive?: boolean } = {}, ) { const headers: Record = { ...init.headers } let body = init.body if (init.json !== undefined) { headers['Content-Type'] = 'application/json' body = JSON.stringify(init.json) } const res = await fetch(url, { method, headers, body, credentials: 'same-origin', cache: 'no-store', keepalive: init.keepalive }) if (!res.ok) { let data: Record = {} try { data = await res.json() } catch { // 非 JSON 错误体(如反向代理返回的页面) } if (res.status === 401 && !url.startsWith('/api/v1/auth/')) onUnauthorized() throw new ApiError(res.status, typeof data.error === 'string' ? data.error : `请求失败(${res.status})`, data) } return res } const json = async (p: Promise): Promise => (await p).json() export const api = { authState: () => json<{ setUp: boolean; authenticated: boolean }>(request('GET', '/api/v1/auth/state')), setup: (password: string) => request('POST', '/api/v1/auth/setup', { json: { password } }), login: (password: string) => request('POST', '/api/v1/auth/login', { json: { password } }), logout: () => request('POST', '/api/v1/auth/logout'), changePassword: (current: string, next: string) => request('POST', '/api/v1/auth/password', { json: { current, next } }), maintenanceStatus: () => json(request('GET', '/api/v1/maintenance/status')), exportBackup: (kind: BackupKind) => request('POST', '/api/v1/maintenance/export/' + kind), exportNotes: () => request('POST', '/api/v1/maintenance/notes/export'), startBackupUpload: (kind: BackupKind, size: number) => json<{ id: string }>(request('POST', '/api/v1/maintenance/uploads', { json: { kind, size } })), uploadBackupChunk: (id: string, offset: number, body: Blob) => json<{ offset: number }>(request('PUT', '/api/v1/maintenance/uploads/' + id, { body, headers: { 'Upload-Offset': String(offset) } })), validateBackup: (id: string) => json(request('POST', '/api/v1/maintenance/uploads/' + id + '/validate')), cancelBackupUpload: (id: string) => request('DELETE', '/api/v1/maintenance/uploads/' + id), importSettings: (id: string) => request('POST', '/api/v1/maintenance/settings/import', { json: { id, confirmed: true } }), restoreBackup: (id: string) => request('POST', '/api/v1/maintenance/restore', { json: { id, confirmed: true } }), resetSettings: () => request('POST', '/api/v1/maintenance/settings/reset', { json: { confirmed: true } }), maintainIndex: (operation: 'optimize' | 'rebuild') => request('POST', '/api/v1/maintenance/index/' + operation), clearTemp: () => json<{ files: number; bytes: number; failed: string[] }>(request('POST', '/api/v1/maintenance/temp/clear')), exportLogs: () => request('POST', '/api/v1/maintenance/logs/export'), settings: () => json>(request('GET', '/api/v1/settings')), fonts: async () => (await json<{ fonts: CustomFont[] }>(request('GET', '/api/v1/fonts'))).fonts, importFont: (file: File) => { const body = new FormData() body.append('file', file) return json(request('POST', '/api/v1/fonts', { body })) }, deleteFont: (id: string) => request('DELETE', '/api/v1/fonts/' + encodeURIComponent(id)), themes: async () => (await json<{ themes: Theme[] }>(request('GET', '/api/v1/themes'))).themes, theme: (id: string) => json(request('GET', '/api/v1/themes/' + encodeURIComponent(id))), plugins: async () => (await json<{ plugins: InstalledPlugin[] }>(request('GET', '/api/v1/plugins'))).plugins, installPlugin: (file: File) => { const body = new FormData() body.append('file', file) return json(request('POST', '/api/v1/plugins', { body })) }, togglePlugin: (id: string, enabled: boolean) => json(request('PATCH', '/api/v1/plugins/' + encodeURIComponent(id), { json: { enabled } })), uninstallPlugin: (id: string) => request('DELETE', '/api/v1/plugins/' + encodeURIComponent(id)), pluginData: (id: string) => json<{ data: unknown }>(request('GET', '/api/v1/plugins/' + encodeURIComponent(id) + '/data')), savePluginData: (id: string, data: unknown) => request('PUT', '/api/v1/plugins/' + encodeURIComponent(id) + '/data', { json: { data } }), // 按顶层键合并,值为 null 的键被删除 patchSettings: (patch: Record) => json>(request('PATCH', '/api/v1/settings', { json: patch })), importNotes: (dir: string, folder: string, files: File[]) => { const body = new FormData() body.append('dir', dir) body.append('folder', folder) for (const file of files) body.append('files', file) return json(request('POST', '/api/v1/import', { body })) }, exportFiles: (paths: string[]) => request('POST', '/api/v1/export', { json: { paths } }), metadata: () => json<{ notes: NoteMetadata[]; ready: boolean }>(request('GET', '/api/v1/metadata')), setTreeIcon: (path: string, icon: TreeIcon | null) => request('PUT', '/api/v1/tree-icon', { json: { path, icon } }), tree: async () => (await json<{ entries: Entry[] }>(request('GET', '/api/v1/tree'))).entries, search: async (q: string) => (await json<{ results: SearchResult[] }>(request('GET', '/api/v1/search?q=' + encodeURIComponent(q)))).results, async readFile(path: string): Promise<{ content: string; rev: string }> { const res = await request('GET', '/api/v1/files/' + encodePath(path)) return { content: await res.text(), rev: (res.headers.get('ETag') ?? '').replace(/"/g, '') } }, // ifMatch 为空字符串表示新建(文件必须不存在),undefined 表示无条件覆盖 // keepalive 让请求在页面关闭后仍能发出(浏览器限制请求体不超过 64 KB,超出时退回普通请求) async writeFile(path: string, content: string, ifMatch?: string, keepalive = false): Promise { const headers: Record = { 'Content-Type': 'text/markdown; charset=utf-8' } if (ifMatch === '') headers['If-None-Match'] = '*' else if (ifMatch !== undefined) headers['If-Match'] = `"${ifMatch}"` const res = request('PUT', '/api/v1/files/' + encodePath(path), { body: content, headers, keepalive: keepalive && new Blob([content]).size < 60_000, }) return (await json<{ rev: string }>(res)).rev }, deleteFile: (path: string) => json(request('DELETE', '/api/v1/files/' + encodePath(path))), move: (from: string, to: string) => request('POST', '/api/v1/move', { json: { from, to } }), mkdir: (path: string) => request('POST', '/api/v1/folders', { json: { path } }), async upload(note: string, file: File): Promise { const form = new FormData() form.append('file', file, file.name || 'image') return (await json<{ link: string }>(request('POST', '/api/v1/assets?note=' + encodeURIComponent(note), { body: form }))).link }, cleanupImages: (paths: string[]) => json<{ deleted: TrashItem[]; skipped: { path: string; reason: string }[] }>(request('POST', '/api/v1/assets/cleanup', { json: { paths } })), uploadFolderImage: async (folder: string, file: File) => { const body = new FormData() body.append('file', file) return json<{ link: string }>(request('POST', '/api/v1/assets?folder=' + encodeURIComponent(folder), { body })) }, replaceImage: (path: string, file: File) => { const body = new FormData() body.append('file', file) return request('PUT', '/api/v1/assets/' + encodePath(path), { body }) }, importImage: async (note: string, url: string): Promise => (await json<{ link: string }>(request('POST', '/api/v1/assets/import?note=' + encodeURIComponent(note), { json: { url } }))).link, tokens: async () => (await json<{ tokens: ApiToken[] }>(request('GET', '/api/v1/tokens'))).tokens, // 返回的 token 明文只有这一次 createToken: (name: string, scope: ApiToken['scope']) => json<{ token: string; info: ApiToken }>(request('POST', '/api/v1/tokens', { json: { name, scope } })), deleteToken: (id: string) => request('DELETE', '/api/v1/tokens/' + encodeURIComponent(id)), storage: () => json(request('GET', '/api/v1/storage')), clearTrash: () => json(request('DELETE', '/api/v1/trash')), trash: async () => (await json<{ items: TrashItem[] }>(request('GET', '/api/v1/trash'))).items, restore: (id: string) => json(request('POST', `/api/v1/trash/${id}/restore`)), purge: (id: string) => request('DELETE', `/api/v1/trash/${id}`), }