Files
RawMark/web/src/app/api.ts
T

258 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}`),
}