bell-reader 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +75 -2
- package/README.zh-TW.md +55 -0
- package/bin/bell-reader.mjs +202 -0
- package/dist/assets/caveat-cyrillic-400-normal-9cDH9rLW.woff2 +0 -0
- package/dist/assets/caveat-cyrillic-400-normal-CebvvJET.woff +0 -0
- package/dist/assets/caveat-cyrillic-500-normal-DLSfp5IU.woff +0 -0
- package/dist/assets/caveat-cyrillic-500-normal-DpMZbbjM.woff2 +0 -0
- package/dist/assets/caveat-cyrillic-ext-400-normal-3iEGd-c5.woff2 +0 -0
- package/dist/assets/caveat-cyrillic-ext-400-normal-Cg0RnRQ5.woff +0 -0
- package/dist/assets/caveat-cyrillic-ext-500-normal-DcrB4vVx.woff2 +0 -0
- package/dist/assets/caveat-cyrillic-ext-500-normal-DqTXtm5N.woff +0 -0
- package/dist/assets/caveat-latin-400-normal-BzhAQZkN.woff +0 -0
- package/dist/assets/caveat-latin-400-normal-D6LQsQ_v.woff2 +0 -0
- package/dist/assets/caveat-latin-500-normal-B9SDL8cy.woff2 +0 -0
- package/dist/assets/caveat-latin-500-normal-Do9veZfZ.woff +0 -0
- package/dist/assets/caveat-latin-ext-400-normal-D7hBUiug.woff2 +0 -0
- package/dist/assets/caveat-latin-ext-400-normal-DtiRFvw0.woff +0 -0
- package/dist/assets/caveat-latin-ext-500-normal-jKqJW7bC.woff2 +0 -0
- package/dist/assets/caveat-latin-ext-500-normal-wUUXxlbS.woff +0 -0
- package/dist/assets/index-D9OudjOw.js +84 -0
- package/dist/assets/index-KEOK2cPN.css +1 -0
- package/dist/assets/newsreader-latin-ext-opsz-normal-BQn1nviT.woff2 +0 -0
- package/dist/assets/newsreader-latin-opsz-normal-s-izfB6B.woff2 +0 -0
- package/dist/assets/newsreader-vietnamese-opsz-normal-BjhtXyW2.woff2 +0 -0
- package/dist/fonts/LICENSE-geist-mono.txt +93 -0
- package/dist/fonts/LICENSE-geist.txt +93 -0
- package/dist/fonts/geist-latin-wght.woff2 +0 -0
- package/dist/fonts/geist-mono-latin-wght.woff2 +0 -0
- package/dist/fonts/iansui-tc.ttf +0 -0
- package/dist/fonts/naikai-tc.woff2 +0 -0
- package/dist/index.html +13 -0
- package/package.json +77 -4
- package/server/ai.ts +1391 -0
- package/server/app.ts +644 -0
- package/server/config.ts +140 -0
- package/server/errors.ts +22 -0
- package/server/explain-bridge.ts +320 -0
- package/server/extractor.ts +548 -0
- package/server/index.ts +28 -0
- package/server/security.ts +78 -0
- package/server/store.ts +595 -0
- package/shared/suite.ts +248 -0
- package/shared/types.ts +132 -0
package/server/store.ts
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdirSync } from "node:fs";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
AI_PROVIDERS,
|
|
7
|
+
ANNOTATION_DENSITIES,
|
|
8
|
+
INK_COLORS,
|
|
9
|
+
type AnnotationDensity,
|
|
10
|
+
type AnnotationSketch,
|
|
11
|
+
type Folder,
|
|
12
|
+
type InkColor,
|
|
13
|
+
type InkStyle,
|
|
14
|
+
type PageAnnotation,
|
|
15
|
+
type ProviderSettings,
|
|
16
|
+
type ReaderBlock,
|
|
17
|
+
type SavedPage,
|
|
18
|
+
type SavedPageSummary,
|
|
19
|
+
} from "../shared/types.js";
|
|
20
|
+
import { AppError } from "./errors.js";
|
|
21
|
+
|
|
22
|
+
const DEFAULT_SETTINGS: ProviderSettings = {
|
|
23
|
+
provider: "codex",
|
|
24
|
+
model: "",
|
|
25
|
+
autoFolder: true,
|
|
26
|
+
annotationDensity: "balanced",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
interface PageRow {
|
|
30
|
+
id: string;
|
|
31
|
+
source_kind: "url" | "paste";
|
|
32
|
+
url: string | null;
|
|
33
|
+
site_name: string | null;
|
|
34
|
+
title: string;
|
|
35
|
+
excerpt: string;
|
|
36
|
+
blocks_json: string;
|
|
37
|
+
translated_title: string | null;
|
|
38
|
+
translated_blocks_json: string | null;
|
|
39
|
+
topic: string;
|
|
40
|
+
tags_json: string;
|
|
41
|
+
summary: string | null;
|
|
42
|
+
folder_id: string | null;
|
|
43
|
+
annotated_at: string | null;
|
|
44
|
+
translated_at: string | null;
|
|
45
|
+
created_at: string;
|
|
46
|
+
updated_at: string;
|
|
47
|
+
annotation_count?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface AnnotationRow {
|
|
51
|
+
id: string;
|
|
52
|
+
page_id: string;
|
|
53
|
+
block_id: string;
|
|
54
|
+
quote: string;
|
|
55
|
+
translated_quote: string | null;
|
|
56
|
+
start_offset: number;
|
|
57
|
+
end_offset: number;
|
|
58
|
+
style: InkStyle;
|
|
59
|
+
color: InkColor;
|
|
60
|
+
label: string | null;
|
|
61
|
+
note: string;
|
|
62
|
+
topic: string | null;
|
|
63
|
+
anchor_x: number | null;
|
|
64
|
+
anchor_y: number | null;
|
|
65
|
+
sketch_json: string | null;
|
|
66
|
+
source: "ai" | "user";
|
|
67
|
+
created_at: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class ReaderStore {
|
|
71
|
+
private readonly db: DatabaseSync;
|
|
72
|
+
|
|
73
|
+
constructor(path: string) {
|
|
74
|
+
if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
|
|
75
|
+
this.db = new DatabaseSync(path);
|
|
76
|
+
this.db.exec(`
|
|
77
|
+
PRAGMA journal_mode = WAL;
|
|
78
|
+
PRAGMA foreign_keys = ON;
|
|
79
|
+
PRAGMA busy_timeout = 5000;
|
|
80
|
+
|
|
81
|
+
CREATE TABLE IF NOT EXISTS folders (
|
|
82
|
+
id TEXT PRIMARY KEY,
|
|
83
|
+
name TEXT NOT NULL UNIQUE,
|
|
84
|
+
color TEXT NOT NULL,
|
|
85
|
+
created_at TEXT NOT NULL
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
CREATE TABLE IF NOT EXISTS pages (
|
|
89
|
+
id TEXT PRIMARY KEY,
|
|
90
|
+
source_kind TEXT NOT NULL,
|
|
91
|
+
url TEXT,
|
|
92
|
+
site_name TEXT,
|
|
93
|
+
title TEXT NOT NULL,
|
|
94
|
+
excerpt TEXT NOT NULL,
|
|
95
|
+
blocks_json TEXT NOT NULL,
|
|
96
|
+
topic TEXT NOT NULL DEFAULT '未分類',
|
|
97
|
+
tags_json TEXT NOT NULL DEFAULT '[]',
|
|
98
|
+
summary TEXT,
|
|
99
|
+
folder_id TEXT REFERENCES folders(id) ON DELETE SET NULL,
|
|
100
|
+
annotated_at TEXT,
|
|
101
|
+
created_at TEXT NOT NULL,
|
|
102
|
+
updated_at TEXT NOT NULL
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
CREATE INDEX IF NOT EXISTS idx_pages_url ON pages(url) WHERE url IS NOT NULL;
|
|
106
|
+
CREATE INDEX IF NOT EXISTS idx_pages_updated ON pages(updated_at DESC);
|
|
107
|
+
|
|
108
|
+
CREATE TABLE IF NOT EXISTS annotations (
|
|
109
|
+
id TEXT PRIMARY KEY,
|
|
110
|
+
page_id TEXT NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
|
111
|
+
block_id TEXT NOT NULL,
|
|
112
|
+
quote TEXT NOT NULL,
|
|
113
|
+
start_offset INTEGER NOT NULL,
|
|
114
|
+
end_offset INTEGER NOT NULL,
|
|
115
|
+
style TEXT NOT NULL,
|
|
116
|
+
color TEXT NOT NULL,
|
|
117
|
+
label TEXT,
|
|
118
|
+
note TEXT NOT NULL,
|
|
119
|
+
topic TEXT,
|
|
120
|
+
anchor_x REAL,
|
|
121
|
+
anchor_y REAL,
|
|
122
|
+
source TEXT NOT NULL,
|
|
123
|
+
created_at TEXT NOT NULL
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
CREATE INDEX IF NOT EXISTS idx_annotations_page ON annotations(page_id);
|
|
127
|
+
|
|
128
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
129
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
130
|
+
json TEXT NOT NULL
|
|
131
|
+
);
|
|
132
|
+
`);
|
|
133
|
+
|
|
134
|
+
const cols = this.db.prepare("PRAGMA table_info(annotations)").all() as Array<{ name: string }>;
|
|
135
|
+
const colNames = new Set(cols.map((c) => c.name));
|
|
136
|
+
if (!colNames.has("anchor_x")) this.db.exec("ALTER TABLE annotations ADD COLUMN anchor_x REAL");
|
|
137
|
+
if (!colNames.has("anchor_y")) this.db.exec("ALTER TABLE annotations ADD COLUMN anchor_y REAL");
|
|
138
|
+
if (!colNames.has("translated_quote")) this.db.exec("ALTER TABLE annotations ADD COLUMN translated_quote TEXT");
|
|
139
|
+
if (!colNames.has("sketch_json")) this.db.exec("ALTER TABLE annotations ADD COLUMN sketch_json TEXT");
|
|
140
|
+
|
|
141
|
+
const pageCols = this.db.prepare("PRAGMA table_info(pages)").all() as Array<{ name: string }>;
|
|
142
|
+
const pageColNames = new Set(pageCols.map((c) => c.name));
|
|
143
|
+
if (!pageColNames.has("translated_title")) this.db.exec("ALTER TABLE pages ADD COLUMN translated_title TEXT");
|
|
144
|
+
if (!pageColNames.has("translated_blocks_json")) {
|
|
145
|
+
this.db.exec("ALTER TABLE pages ADD COLUMN translated_blocks_json TEXT");
|
|
146
|
+
}
|
|
147
|
+
if (!pageColNames.has("translated_at")) this.db.exec("ALTER TABLE pages ADD COLUMN translated_at TEXT");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
close(): void {
|
|
151
|
+
this.db.close();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
getSettings(): ProviderSettings {
|
|
155
|
+
const row = this.db.prepare("SELECT json FROM settings WHERE id = 1").get() as { json: string } | undefined;
|
|
156
|
+
if (!row) return { ...DEFAULT_SETTINGS };
|
|
157
|
+
try {
|
|
158
|
+
const parsed = JSON.parse(row.json) as Partial<ProviderSettings>;
|
|
159
|
+
return {
|
|
160
|
+
provider:
|
|
161
|
+
typeof parsed.provider === "string" && (AI_PROVIDERS as readonly string[]).includes(parsed.provider)
|
|
162
|
+
? (parsed.provider as ProviderSettings["provider"])
|
|
163
|
+
: "codex",
|
|
164
|
+
model: typeof parsed.model === "string" ? parsed.model.slice(0, 160) : "",
|
|
165
|
+
autoFolder: typeof parsed.autoFolder === "boolean" ? parsed.autoFolder : true,
|
|
166
|
+
annotationDensity:
|
|
167
|
+
typeof parsed.annotationDensity === "string" &&
|
|
168
|
+
(ANNOTATION_DENSITIES as readonly string[]).includes(parsed.annotationDensity)
|
|
169
|
+
? (parsed.annotationDensity as AnnotationDensity)
|
|
170
|
+
: "balanced",
|
|
171
|
+
};
|
|
172
|
+
} catch {
|
|
173
|
+
return { ...DEFAULT_SETTINGS };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
saveSettings(patch: Partial<ProviderSettings>): ProviderSettings {
|
|
178
|
+
const next: ProviderSettings = {
|
|
179
|
+
...this.getSettings(),
|
|
180
|
+
...(patch.provider ? { provider: patch.provider } : {}),
|
|
181
|
+
...(patch.model !== undefined ? { model: patch.model.trim().slice(0, 160) } : {}),
|
|
182
|
+
...(patch.autoFolder !== undefined ? { autoFolder: patch.autoFolder } : {}),
|
|
183
|
+
...(patch.annotationDensity ? { annotationDensity: patch.annotationDensity } : {}),
|
|
184
|
+
};
|
|
185
|
+
this.db
|
|
186
|
+
.prepare(
|
|
187
|
+
"INSERT INTO settings (id, json) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET json = excluded.json",
|
|
188
|
+
)
|
|
189
|
+
.run(JSON.stringify(next));
|
|
190
|
+
return next;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
listFolders(): Folder[] {
|
|
194
|
+
const rows = this.db
|
|
195
|
+
.prepare(`
|
|
196
|
+
SELECT f.id, f.name, f.color, f.created_at, COUNT(p.id) AS page_count
|
|
197
|
+
FROM folders f
|
|
198
|
+
LEFT JOIN pages p ON p.folder_id = f.id
|
|
199
|
+
GROUP BY f.id
|
|
200
|
+
ORDER BY f.created_at ASC
|
|
201
|
+
`)
|
|
202
|
+
.all() as Array<{ id: string; name: string; color: InkColor; created_at: string; page_count: number }>;
|
|
203
|
+
|
|
204
|
+
return rows.map((r) => ({
|
|
205
|
+
id: r.id,
|
|
206
|
+
name: r.name,
|
|
207
|
+
color: r.color,
|
|
208
|
+
pageCount: Number(r.page_count),
|
|
209
|
+
createdAt: r.created_at,
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
createFolder(name: string, color?: InkColor): Folder {
|
|
214
|
+
const clean = name.trim().slice(0, 48);
|
|
215
|
+
if (!clean) throw new AppError(400, "請輸入資料夾名稱。");
|
|
216
|
+
const existing = this.db
|
|
217
|
+
.prepare("SELECT id FROM folders WHERE lower(name) = lower(?)")
|
|
218
|
+
.get(clean) as { id: string } | undefined;
|
|
219
|
+
if (existing) {
|
|
220
|
+
const found = this.listFolders().find((f) => f.id === existing.id);
|
|
221
|
+
if (found) return found;
|
|
222
|
+
}
|
|
223
|
+
const id = randomUUID();
|
|
224
|
+
const chosenColor = color ?? INK_COLORS[this.listFolders().length % INK_COLORS.length];
|
|
225
|
+
const now = new Date().toISOString();
|
|
226
|
+
this.db
|
|
227
|
+
.prepare("INSERT INTO folders (id, name, color, created_at) VALUES (?, ?, ?, ?)")
|
|
228
|
+
.run(id, clean, chosenColor, now);
|
|
229
|
+
return { id, name: clean, color: chosenColor, pageCount: 0, createdAt: now };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
renameFolder(id: string, name: string, color?: InkColor): Folder {
|
|
233
|
+
const clean = name.trim().slice(0, 48);
|
|
234
|
+
if (!clean) throw new AppError(400, "請輸入資料夾名稱。");
|
|
235
|
+
const current = this.listFolders().find((f) => f.id === id);
|
|
236
|
+
if (!current) throw new AppError(404, "找不到此資料夾。");
|
|
237
|
+
const conflict = this.db
|
|
238
|
+
.prepare("SELECT id FROM folders WHERE lower(name) = lower(?) AND id != ?")
|
|
239
|
+
.get(clean, id) as { id: string } | undefined;
|
|
240
|
+
if (conflict) throw new AppError(409, "已有同名資料夾。");
|
|
241
|
+
this.db
|
|
242
|
+
.prepare("UPDATE folders SET name = ?, color = ? WHERE id = ?")
|
|
243
|
+
.run(clean, color ?? current.color, id);
|
|
244
|
+
return { ...current, name: clean, color: color ?? current.color };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
deleteFolder(id: string): void {
|
|
248
|
+
const res = this.db.prepare("DELETE FROM folders WHERE id = ?").run(id);
|
|
249
|
+
if (!res.changes) throw new AppError(404, "找不到此資料夾。");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private mapSummary(row: PageRow, annotationCount: number): SavedPageSummary {
|
|
253
|
+
let tags: string[] = [];
|
|
254
|
+
try {
|
|
255
|
+
const parsed = JSON.parse(row.tags_json);
|
|
256
|
+
if (Array.isArray(parsed)) tags = parsed.filter((t): t is string => typeof t === "string");
|
|
257
|
+
} catch {
|
|
258
|
+
tags = [];
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
id: row.id,
|
|
262
|
+
sourceKind: row.source_kind,
|
|
263
|
+
...(row.url ? { url: row.url } : {}),
|
|
264
|
+
...(row.site_name ? { siteName: row.site_name } : {}),
|
|
265
|
+
title: row.title,
|
|
266
|
+
excerpt: row.excerpt,
|
|
267
|
+
topic: row.topic,
|
|
268
|
+
tags,
|
|
269
|
+
...(row.summary ? { summary: row.summary } : {}),
|
|
270
|
+
folderId: row.folder_id,
|
|
271
|
+
annotationCount,
|
|
272
|
+
...(row.annotated_at ? { annotatedAt: row.annotated_at } : {}),
|
|
273
|
+
...(row.translated_at ? { translatedAt: row.translated_at } : {}),
|
|
274
|
+
createdAt: row.created_at,
|
|
275
|
+
updatedAt: row.updated_at,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private mapAnnotation(row: AnnotationRow): PageAnnotation {
|
|
280
|
+
let sketch: AnnotationSketch | undefined;
|
|
281
|
+
if (row.sketch_json) {
|
|
282
|
+
try {
|
|
283
|
+
const parsed = JSON.parse(row.sketch_json) as AnnotationSketch;
|
|
284
|
+
const hasCanvas =
|
|
285
|
+
parsed?.kind === "canvas" && Array.isArray(parsed.elements) && parsed.elements.length >= 2;
|
|
286
|
+
const hasNodes = Array.isArray(parsed?.nodes) && parsed.nodes.length >= 2;
|
|
287
|
+
if (hasCanvas || hasNodes) {
|
|
288
|
+
sketch = parsed;
|
|
289
|
+
}
|
|
290
|
+
} catch {
|
|
291
|
+
sketch = undefined;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
id: row.id,
|
|
296
|
+
pageId: row.page_id,
|
|
297
|
+
blockId: row.block_id,
|
|
298
|
+
quote: row.quote,
|
|
299
|
+
...(row.translated_quote ? { translatedQuote: row.translated_quote } : {}),
|
|
300
|
+
startOffset: row.start_offset,
|
|
301
|
+
endOffset: row.end_offset,
|
|
302
|
+
style: row.style,
|
|
303
|
+
color: row.color,
|
|
304
|
+
...(row.label ? { label: row.label } : {}),
|
|
305
|
+
note: row.note,
|
|
306
|
+
...(row.topic ? { topic: row.topic } : {}),
|
|
307
|
+
...(row.anchor_x !== null && row.anchor_y !== null ? { anchorX: row.anchor_x, anchorY: row.anchor_y } : {}),
|
|
308
|
+
...(sketch ? { sketch } : {}),
|
|
309
|
+
source: row.source,
|
|
310
|
+
createdAt: row.created_at,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
listPages(): SavedPageSummary[] {
|
|
315
|
+
const rows = this.db
|
|
316
|
+
.prepare(`
|
|
317
|
+
SELECT p.*, COUNT(a.id) AS annotation_count
|
|
318
|
+
FROM pages p
|
|
319
|
+
LEFT JOIN annotations a ON a.page_id = p.id
|
|
320
|
+
GROUP BY p.id
|
|
321
|
+
ORDER BY p.updated_at DESC
|
|
322
|
+
`)
|
|
323
|
+
.all() as unknown as PageRow[];
|
|
324
|
+
|
|
325
|
+
return rows.map((row) => this.mapSummary(row, Number(row.annotation_count ?? 0)));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
findByUrl(url: string): SavedPage | undefined {
|
|
329
|
+
const row = this.db
|
|
330
|
+
.prepare("SELECT id FROM pages WHERE url = ? ORDER BY updated_at DESC LIMIT 1")
|
|
331
|
+
.get(url) as { id: string } | undefined;
|
|
332
|
+
return row ? this.getPage(row.id) : undefined;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
getPage(id: string): SavedPage | undefined {
|
|
336
|
+
const row = this.db.prepare("SELECT * FROM pages WHERE id = ?").get(id) as PageRow | undefined;
|
|
337
|
+
if (!row) return undefined;
|
|
338
|
+
const annRows = this.db
|
|
339
|
+
.prepare("SELECT * FROM annotations WHERE page_id = ? ORDER BY created_at ASC")
|
|
340
|
+
.all(id) as unknown as AnnotationRow[];
|
|
341
|
+
const annotations = annRows.map((a) => this.mapAnnotation(a));
|
|
342
|
+
let blocks: ReaderBlock[] = [];
|
|
343
|
+
try {
|
|
344
|
+
blocks = JSON.parse(row.blocks_json!) as ReaderBlock[];
|
|
345
|
+
} catch {
|
|
346
|
+
blocks = [];
|
|
347
|
+
}
|
|
348
|
+
let translatedBlocks: ReaderBlock[] | undefined;
|
|
349
|
+
if (row.translated_blocks_json) {
|
|
350
|
+
try {
|
|
351
|
+
translatedBlocks = JSON.parse(row.translated_blocks_json) as ReaderBlock[];
|
|
352
|
+
} catch {
|
|
353
|
+
translatedBlocks = undefined;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
...this.mapSummary(row, annotations.length),
|
|
358
|
+
blocks,
|
|
359
|
+
...(row.translated_title ? { translatedTitle: row.translated_title } : {}),
|
|
360
|
+
...(translatedBlocks ? { translatedBlocks } : {}),
|
|
361
|
+
annotations,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
createPage(input: {
|
|
366
|
+
sourceKind: "url" | "paste";
|
|
367
|
+
url?: string;
|
|
368
|
+
siteName?: string;
|
|
369
|
+
title: string;
|
|
370
|
+
excerpt: string;
|
|
371
|
+
blocks: ReaderBlock[];
|
|
372
|
+
folderId?: string | null;
|
|
373
|
+
topic?: string;
|
|
374
|
+
}): SavedPage {
|
|
375
|
+
const id = randomUUID();
|
|
376
|
+
const now = new Date().toISOString();
|
|
377
|
+
const folderId = input.folderId ?? null;
|
|
378
|
+
if (folderId && !this.listFolders().some((f) => f.id === folderId)) {
|
|
379
|
+
throw new AppError(400, "指定的資料夾不存在。");
|
|
380
|
+
}
|
|
381
|
+
this.db
|
|
382
|
+
.prepare(`
|
|
383
|
+
INSERT INTO pages (
|
|
384
|
+
id, source_kind, url, site_name, title, excerpt, blocks_json, topic, tags_json, folder_id, created_at, updated_at
|
|
385
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '[]', ?, ?, ?)
|
|
386
|
+
`)
|
|
387
|
+
.run(
|
|
388
|
+
id,
|
|
389
|
+
input.sourceKind,
|
|
390
|
+
input.url ?? null,
|
|
391
|
+
input.siteName ?? null,
|
|
392
|
+
input.title.slice(0, 120),
|
|
393
|
+
input.excerpt.slice(0, 200),
|
|
394
|
+
JSON.stringify(input.blocks),
|
|
395
|
+
(input.topic || "未分類").slice(0, 40),
|
|
396
|
+
folderId,
|
|
397
|
+
now,
|
|
398
|
+
now,
|
|
399
|
+
);
|
|
400
|
+
return this.getPage(id)!;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
updatePageMeta(
|
|
404
|
+
id: string,
|
|
405
|
+
patch: {
|
|
406
|
+
title?: string;
|
|
407
|
+
folderId?: string | null;
|
|
408
|
+
topic?: string;
|
|
409
|
+
tags?: string[];
|
|
410
|
+
summary?: string;
|
|
411
|
+
},
|
|
412
|
+
): SavedPage {
|
|
413
|
+
const current = this.getPage(id);
|
|
414
|
+
if (!current) throw new AppError(404, "找不到此頁面。");
|
|
415
|
+
if (patch.folderId && !this.listFolders().some((f) => f.id === patch.folderId)) {
|
|
416
|
+
throw new AppError(400, "指定的資料夾不存在。");
|
|
417
|
+
}
|
|
418
|
+
const now = new Date().toISOString();
|
|
419
|
+
this.db
|
|
420
|
+
.prepare(`
|
|
421
|
+
UPDATE pages
|
|
422
|
+
SET title = ?, folder_id = ?, topic = ?, tags_json = ?, summary = ?, updated_at = ?
|
|
423
|
+
WHERE id = ?
|
|
424
|
+
`)
|
|
425
|
+
.run(
|
|
426
|
+
(patch.title ?? current.title).trim().slice(0, 120) || current.title,
|
|
427
|
+
patch.folderId === undefined ? current.folderId : patch.folderId,
|
|
428
|
+
(patch.topic ?? current.topic).trim().slice(0, 40) || current.topic,
|
|
429
|
+
JSON.stringify(patch.tags ?? current.tags),
|
|
430
|
+
patch.summary === undefined ? (current.summary ?? null) : patch.summary,
|
|
431
|
+
now,
|
|
432
|
+
id,
|
|
433
|
+
);
|
|
434
|
+
return this.getPage(id)!;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
saveTranslation(
|
|
438
|
+
pageId: string,
|
|
439
|
+
result: { title: string; blocks: ReaderBlock[]; annotationQuotes?: Record<string, string> },
|
|
440
|
+
): SavedPage {
|
|
441
|
+
const current = this.getPage(pageId);
|
|
442
|
+
if (!current) throw new AppError(404, "找不到此頁面。");
|
|
443
|
+
const now = new Date().toISOString();
|
|
444
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
445
|
+
try {
|
|
446
|
+
this.db
|
|
447
|
+
.prepare(`
|
|
448
|
+
UPDATE pages
|
|
449
|
+
SET translated_title = ?, translated_blocks_json = ?, translated_at = ?, updated_at = ?
|
|
450
|
+
WHERE id = ?
|
|
451
|
+
`)
|
|
452
|
+
.run(
|
|
453
|
+
result.title.trim().slice(0, 160) || current.title,
|
|
454
|
+
JSON.stringify(result.blocks),
|
|
455
|
+
now,
|
|
456
|
+
now,
|
|
457
|
+
pageId,
|
|
458
|
+
);
|
|
459
|
+
if (result.annotationQuotes) {
|
|
460
|
+
const updateQuote = this.db.prepare(
|
|
461
|
+
"UPDATE annotations SET translated_quote = ? WHERE id = ? AND page_id = ?",
|
|
462
|
+
);
|
|
463
|
+
for (const [annId, tQuote] of Object.entries(result.annotationQuotes)) {
|
|
464
|
+
if (tQuote.trim()) updateQuote.run(tQuote, annId, pageId);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
this.db.exec("COMMIT");
|
|
468
|
+
} catch (error) {
|
|
469
|
+
this.db.exec("ROLLBACK");
|
|
470
|
+
throw error;
|
|
471
|
+
}
|
|
472
|
+
return this.getPage(pageId)!;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
deletePage(id: string): void {
|
|
476
|
+
const res = this.db.prepare("DELETE FROM pages WHERE id = ?").run(id);
|
|
477
|
+
if (!res.changes) throw new AppError(404, "找不到此頁面。");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
replaceAiAnnotations(
|
|
481
|
+
pageId: string,
|
|
482
|
+
result: {
|
|
483
|
+
topic: string;
|
|
484
|
+
tags: string[];
|
|
485
|
+
summary: string;
|
|
486
|
+
folderId?: string | null;
|
|
487
|
+
annotations: Array<Omit<PageAnnotation, "id" | "pageId" | "source" | "createdAt">>;
|
|
488
|
+
},
|
|
489
|
+
): SavedPage {
|
|
490
|
+
const current = this.getPage(pageId);
|
|
491
|
+
if (!current) throw new AppError(404, "找不到此頁面。");
|
|
492
|
+
const now = new Date().toISOString();
|
|
493
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
494
|
+
try {
|
|
495
|
+
this.db.prepare("DELETE FROM annotations WHERE page_id = ? AND source = 'ai'").run(pageId);
|
|
496
|
+
const insert = this.db.prepare(`
|
|
497
|
+
INSERT INTO annotations (
|
|
498
|
+
id, page_id, block_id, quote, translated_quote, start_offset, end_offset, style, color, label, note, topic, anchor_x, anchor_y, sketch_json, source, created_at
|
|
499
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'ai', ?)
|
|
500
|
+
`);
|
|
501
|
+
for (const item of result.annotations) {
|
|
502
|
+
insert.run(
|
|
503
|
+
randomUUID(),
|
|
504
|
+
pageId,
|
|
505
|
+
item.blockId,
|
|
506
|
+
item.quote,
|
|
507
|
+
item.translatedQuote ?? null,
|
|
508
|
+
item.startOffset,
|
|
509
|
+
item.endOffset,
|
|
510
|
+
item.style,
|
|
511
|
+
item.color,
|
|
512
|
+
item.label ?? null,
|
|
513
|
+
item.note,
|
|
514
|
+
item.topic ?? null,
|
|
515
|
+
item.anchorX ?? null,
|
|
516
|
+
item.anchorY ?? null,
|
|
517
|
+
item.sketch ? JSON.stringify(item.sketch) : null,
|
|
518
|
+
now,
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
this.db
|
|
522
|
+
.prepare(`
|
|
523
|
+
UPDATE pages
|
|
524
|
+
SET topic = ?, tags_json = ?, summary = ?, folder_id = ?, annotated_at = ?, updated_at = ?
|
|
525
|
+
WHERE id = ?
|
|
526
|
+
`)
|
|
527
|
+
.run(
|
|
528
|
+
result.topic.slice(0, 40),
|
|
529
|
+
JSON.stringify(result.tags.slice(0, 5)),
|
|
530
|
+
result.summary.slice(0, 240),
|
|
531
|
+
result.folderId === undefined ? current.folderId : result.folderId,
|
|
532
|
+
now,
|
|
533
|
+
now,
|
|
534
|
+
pageId,
|
|
535
|
+
);
|
|
536
|
+
this.db.exec("COMMIT");
|
|
537
|
+
} catch (error) {
|
|
538
|
+
this.db.exec("ROLLBACK");
|
|
539
|
+
throw error;
|
|
540
|
+
}
|
|
541
|
+
return this.getPage(pageId)!;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
addAnnotation(
|
|
545
|
+
pageId: string,
|
|
546
|
+
item: Omit<PageAnnotation, "id" | "pageId" | "createdAt">,
|
|
547
|
+
): PageAnnotation {
|
|
548
|
+
const current = this.getPage(pageId);
|
|
549
|
+
if (!current) throw new AppError(404, "找不到此頁面。");
|
|
550
|
+
const id = randomUUID();
|
|
551
|
+
const now = new Date().toISOString();
|
|
552
|
+
this.db
|
|
553
|
+
.prepare(`
|
|
554
|
+
INSERT INTO annotations (
|
|
555
|
+
id, page_id, block_id, quote, translated_quote, start_offset, end_offset, style, color, label, note, topic, anchor_x, anchor_y, sketch_json, source, created_at
|
|
556
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
557
|
+
`)
|
|
558
|
+
.run(
|
|
559
|
+
id,
|
|
560
|
+
pageId,
|
|
561
|
+
item.blockId,
|
|
562
|
+
item.quote,
|
|
563
|
+
item.translatedQuote ?? null,
|
|
564
|
+
item.startOffset,
|
|
565
|
+
item.endOffset,
|
|
566
|
+
item.style,
|
|
567
|
+
item.color,
|
|
568
|
+
item.label ?? null,
|
|
569
|
+
item.note.slice(0, 320),
|
|
570
|
+
item.topic ? item.topic.slice(0, 40) : null,
|
|
571
|
+
item.anchorX ?? null,
|
|
572
|
+
item.anchorY ?? null,
|
|
573
|
+
item.sketch ? JSON.stringify(item.sketch) : null,
|
|
574
|
+
item.source,
|
|
575
|
+
now,
|
|
576
|
+
);
|
|
577
|
+
this.db
|
|
578
|
+
.prepare("UPDATE pages SET updated_at = ?, annotated_at = COALESCE(annotated_at, ?) WHERE id = ?")
|
|
579
|
+
.run(now, item.source === "ai" ? now : null, pageId);
|
|
580
|
+
return {
|
|
581
|
+
id,
|
|
582
|
+
pageId,
|
|
583
|
+
...item,
|
|
584
|
+
note: item.note.slice(0, 320),
|
|
585
|
+
createdAt: now,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
deleteAnnotation(pageId: string, annotationId: string): void {
|
|
590
|
+
const res = this.db
|
|
591
|
+
.prepare("DELETE FROM annotations WHERE id = ? AND page_id = ?")
|
|
592
|
+
.run(annotationId, pageId);
|
|
593
|
+
if (!res.changes) throw new AppError(404, "找不到此筆標註。");
|
|
594
|
+
}
|
|
595
|
+
}
|