- Index notes in a separate, rebuildable SQLite database (index.db) using FTS5 trigram, synced from watcher snapshots so external edits are picked up; terms under 3 characters fall back to LIKE - GET /api/v1/search?q= returns matching notes with the first matching line and a snippet; path matches rank first - Sidebar search panel (Ctrl+Shift+F) with highlighted terms and keyboard navigation; opening a result jumps to and selects the match - Restore scroll position on tab switch with scroll snapshots; restoring scrollTop drifted by a few lines after line heights were re-estimated
113 lines
4.3 KiB
TypeScript
113 lines
4.3 KiB
TypeScript
// 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<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', 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: (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<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
|
||
},
|
||
|
||
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}`),
|
||
}
|