// REST API 客户端,与服务端 internal/api 对应 export interface Entry { path: string dir: boolean size: number mtime: number } export interface TrashItem { id: string path: string dir: boolean deletedAt: number } export interface SearchResult { path: string // 第一处匹配所在行(从 1 开始)及其摘录;只有路径匹配时 line 为 0 line: number snippet: string } 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', 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: (code: string, password: string) => request('POST', '/api/v1/auth/setup', { json: { code, password } }), login: (password: string) => request('POST', '/api/v1/auth/login', { json: { password } }), logout: () => request('POST', '/api/v1/auth/logout'), 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 }, 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}`), }