258 lines
11 KiB
TypeScript
258 lines
11 KiB
TypeScript
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<string, unknown> = {},
|
||
) {
|
||
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<string, string>; keepalive?: boolean } = {},
|
||
) {
|
||
const headers: Record<string, string> = { ...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<string, unknown> = {}
|
||
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 <T>(p: Promise<Response>): Promise<T> => (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<MaintenanceStatus>(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<BackupSummary>(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<Record<string, unknown>>(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<CustomFont>(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<Theme & { css: string }>(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<InstalledPlugin>(request('POST', '/api/v1/plugins', { body }))
|
||
},
|
||
togglePlugin: (id: string, enabled: boolean) => json<InstalledPlugin>(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<string, unknown>) => json<Record<string, unknown>>(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<ImportResult>(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<string> {
|
||
const headers: Record<string, string> = { '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<TrashItem>(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<string> {
|
||
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<string> =>
|
||
(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<StorageUsage>(request('GET', '/api/v1/storage')),
|
||
clearTrash: () => json<TrashPurgeResult>(request('DELETE', '/api/v1/trash')),
|
||
trash: async () => (await json<{ items: TrashItem[] }>(request('GET', '/api/v1/trash'))).items,
|
||
restore: (id: string) => json<TrashItem>(request('POST', `/api/v1/trash/${id}/restore`)),
|
||
purge: (id: string) => request('DELETE', `/api/v1/trash/${id}`),
|
||
}
|