dsh-knowledge-library 0.1.1 → 0.3.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +133 -14
  3. package/dist/client.js +1654 -304
  4. package/dist/core/extract.d.ts +5 -0
  5. package/dist/core/extract.js +78 -8
  6. package/dist/core/fetch.d.ts +5 -0
  7. package/dist/core/fetch.js +102 -25
  8. package/dist/core/grouping.d.ts +6 -0
  9. package/dist/core/grouping.js +71 -0
  10. package/dist/core/image-capture.d.ts +33 -0
  11. package/dist/core/image-capture.js +196 -0
  12. package/dist/core/incremental.d.ts +14 -0
  13. package/dist/core/incremental.js +52 -0
  14. package/dist/core/jobs.d.ts +21 -1
  15. package/dist/core/jobs.js +101 -12
  16. package/dist/core/knowledge.d.ts +37 -0
  17. package/dist/core/knowledge.js +619 -0
  18. package/dist/core/library.d.ts +36 -2
  19. package/dist/core/library.js +363 -17
  20. package/dist/core/page-access.d.ts +6 -0
  21. package/dist/core/page-access.js +38 -0
  22. package/dist/core/page-images.d.ts +15 -0
  23. package/dist/core/page-images.js +97 -0
  24. package/dist/core/purge.d.ts +20 -0
  25. package/dist/core/purge.js +106 -0
  26. package/dist/directory-settings.d.ts +18 -0
  27. package/dist/directory-settings.js +92 -0
  28. package/dist/http.d.ts +3 -1
  29. package/dist/http.js +152 -4
  30. package/dist/index.js +22 -7
  31. package/dist/knowledge-model.d.ts +30 -0
  32. package/dist/knowledge-model.js +194 -0
  33. package/dist/preview-settings.d.ts +6 -0
  34. package/dist/preview-settings.js +42 -0
  35. package/dist/public/app.css +145 -2
  36. package/dist/public/app.js +10 -6
  37. package/dist/standalone.js +12 -4
  38. package/dist/tools.d.ts +2 -1
  39. package/dist/tools.js +10 -1
  40. package/dist/types.d.ts +199 -1
  41. package/dist/ui/App.js +248 -47
  42. package/dist/ui/Dialog.d.ts +7 -0
  43. package/dist/ui/Dialog.js +32 -0
  44. package/dist/ui/Grouping.d.ts +12 -0
  45. package/dist/ui/Grouping.js +93 -0
  46. package/dist/ui/ImageCapture.d.ts +18 -0
  47. package/dist/ui/ImageCapture.js +76 -0
  48. package/dist/ui/Knowledge.d.ts +26 -0
  49. package/dist/ui/Knowledge.js +106 -0
  50. package/dist/ui/api.d.ts +7 -0
  51. package/dist/ui/api.js +16 -0
  52. package/dist/ui/icons.js +2 -0
  53. package/package.json +5 -1
@@ -1,5 +1,5 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
- import type { CaptureFields, CaptureResult, Citation, MaterialDetail, SearchOptions, SearchResult } from '../types.js';
2
+ import type { CaptureFields, CaptureResult, Citation, EmptyTrashResult, MaterialDetail, PageImageManifest, SearchOptions, SearchResult, TrashSnapshot } from '../types.js';
3
3
  export declare const MAX_FILE_BYTES: number;
4
4
  export declare const MAX_TEXT_CHARS = 2000000;
5
5
  export declare function categoryName(value: unknown): string;
@@ -20,6 +20,23 @@ export declare class Library {
20
20
  };
21
21
  private row;
22
22
  get(id: string): MaterialDetail;
23
+ private readImageDataAt;
24
+ pageImages(id: string): PageImageManifest | undefined;
25
+ savePageImages(id: string, data: PageImageManifest): void;
26
+ savePageImageFile(id: string, imageId: string, bytes: Buffer): {
27
+ fileName: string;
28
+ checksum: string;
29
+ mediaType: "image/png" | "image/jpeg" | "image/webp" | "image/gif";
30
+ width: number;
31
+ height: number;
32
+ bytes: number;
33
+ };
34
+ pageImageFile(id: string, imageId: string): {
35
+ bytes: NonSharedBuffer;
36
+ mediaType: "image/png" | "image/jpeg" | "image/webp" | "image/gif";
37
+ name: string;
38
+ };
39
+ has(id: string): boolean;
23
40
  original(id: string): {
24
41
  bytes: Buffer;
25
42
  name: string;
@@ -32,13 +49,30 @@ export declare class Library {
32
49
  importBytes(name: string, bytes: Buffer, fields?: CaptureFields): CaptureResult;
33
50
  private decode;
34
51
  update(id: string, fields: CaptureFields): MaterialDetail;
35
- saveNote(content: string, citations: Citation[], fields: CaptureFields): CaptureResult;
52
+ validateCategoryMove(id: string, category: string): void;
53
+ trash(id: string): MaterialDetail;
54
+ restore(id: string): MaterialDetail;
55
+ private setDeleted;
56
+ private trashPlan;
57
+ trashSnapshot(): TrashSnapshot;
58
+ private cleanupPendingPurge;
59
+ emptyTrash(expectedToken: string): EmptyTrashResult;
60
+ saveNote(content: string, citations: Citation[], fields: CaptureFields, knowledgeJobId?: string): CaptureResult;
61
+ private writeNoteSnapshot;
62
+ private archiveNoteUpdate;
63
+ private recoverNoteUpdate;
64
+ /** Append only after explicit confirmation. Existing paragraph IDs and text never change. */
65
+ appendNote(id: string, expectedFingerprint: string, addition: string, citations: Citation[], tags: string[], jobId: string): CaptureResult;
66
+ generatedNote(jobId: string): MaterialDetail | undefined;
36
67
  search(options?: SearchOptions): {
37
68
  items: SearchResult[];
38
69
  total: number;
39
70
  };
40
71
  stats(): {
41
72
  total: number;
73
+ trashed: number;
74
+ canEmptyTrash: boolean;
75
+ purgePending: number;
42
76
  ready: number;
43
77
  notes: number;
44
78
  inbox: number;
@@ -2,8 +2,11 @@ import { DatabaseSync } from 'node:sqlite';
2
2
  import { randomUUID, createHash } from 'node:crypto';
3
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, statSync, lstatSync, unlinkSync, openSync, closeSync } from 'node:fs';
4
4
  import { resolve, join, dirname, basename, extname, relative, isAbsolute } from 'node:path';
5
+ import { PurgeStore } from './purge.js';
5
6
  import { extractArticle, formatTime, parseSubtitles, splitSegments } from './extract.js';
6
7
  import { validateUrl } from './fetch.js';
8
+ import { noteFingerprint } from './incremental.js';
9
+ import { imageHash, imageSupplement, inspectImage, validateImageManifest } from './page-images.js';
7
10
  export const MAX_FILE_BYTES = 64 * 1024 * 1024;
8
11
  export const MAX_TEXT_CHARS = 2_000_000;
9
12
  const hash = (data) => createHash('sha256').update(data).digest('hex');
@@ -21,6 +24,11 @@ export function atomicJson(path, value) {
21
24
  writeFileSync(tmp, JSON.stringify(value, null, 2), { encoding: 'utf8', flag: 'wx' });
22
25
  renameSync(tmp, path);
23
26
  }
27
+ function atomicText(path, value) {
28
+ const tmp = `${path}.${randomUUID()}.tmp`;
29
+ writeFileSync(tmp, value, { encoding: 'utf8', flag: 'wx' });
30
+ renameSync(tmp, path);
31
+ }
24
32
  export class Library {
25
33
  root;
26
34
  db;
@@ -59,7 +67,9 @@ export class Library {
59
67
  this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;
60
68
  CREATE TABLE IF NOT EXISTS materials (id TEXT PRIMARY KEY, path TEXT NOT NULL, meta TEXT NOT NULL, content TEXT NOT NULL, content_hash TEXT NOT NULL, updated_at TEXT NOT NULL);
61
69
  CREATE VIRTUAL TABLE IF NOT EXISTS material_fts USING fts5(id UNINDEXED, title, content, tokenize='trigram');`);
70
+ this.recoverNoteUpdate();
62
71
  this.rebuild();
72
+ this.warnings.push(...this.cleanupPendingPurge().map(error => `待清理文件:${error}`));
63
73
  }
64
74
  catch (error) {
65
75
  try {
@@ -92,9 +102,11 @@ export class Library {
92
102
  index(meta, path, content) {
93
103
  this.db.prepare('INSERT OR REPLACE INTO materials VALUES (?,?,?,?,?,?)').run(meta.id, path, JSON.stringify(meta), content, meta.contentHash, meta.updatedAt);
94
104
  this.db.prepare('DELETE FROM material_fts WHERE id=?').run(meta.id);
95
- this.db.prepare('INSERT INTO material_fts (id,title,content) VALUES (?,?,?)').run(meta.id, meta.title, content);
105
+ const supplement = meta.kind === 'web' ? imageSupplement(this.readImageDataAt(path)).text : '';
106
+ this.db.prepare('INSERT INTO material_fts (id,title,content) VALUES (?,?,?)').run(meta.id, meta.title, content + '\n\n' + supplement);
96
107
  }
97
108
  rebuild() {
109
+ this.recoverNoteUpdate();
98
110
  this.warnings.length = 0;
99
111
  const entries = [];
100
112
  const visit = (directory, depth) => {
@@ -125,6 +137,8 @@ export class Library {
125
137
  }
126
138
  // The physical category remains recoverable after an interrupted move.
127
139
  meta.category = relative(join(this.root, 'materials'), dirname(path)).split('\\').join('/');
140
+ if (meta.kind === 'web')
141
+ this.readImageDataAt(path);
128
142
  entries.push({ meta, path, content });
129
143
  }
130
144
  catch (error) {
@@ -154,9 +168,89 @@ export class Library {
154
168
  return row;
155
169
  }
156
170
  get(id) {
171
+ this.recoverNoteUpdate();
172
+ const row = this.row(id);
173
+ const meta = JSON.parse(row.meta);
174
+ const images = meta.kind === 'web' ? this.readImageDataAt(row.path) : undefined;
175
+ const supplement = imageSupplement(images);
176
+ return { ...meta, content: row.content + (supplement.text ? '\n\n' + supplement.text : ''), segments: [...JSON.parse(readFileSync(join(row.path, 'segments.json'), 'utf8')), ...supplement.segments], path: row.path,
177
+ ...(images ? { images: images.records.map(record => { const { text: _text, description: _description, fileName, checksum: _checksum, ...view } = record; return { ...view, model: images.model, originalAvailable: !!fileName && existsSync(join(row.path, 'images', fileName)), ...(record.status === 'completed' ? { segmentId: `v_${record.id.slice(4).replaceAll('-', '')}_1` } : {}) }; }) } : {}) };
178
+ }
179
+ readImageDataAt(path) {
180
+ const file = join(path, '.page-images.json');
181
+ this.assertContained(file);
182
+ if (!existsSync(file))
183
+ return;
184
+ const value = JSON.parse(readFileSync(file, 'utf8'));
185
+ if (!value || imageHash(JSON.stringify(value.data)) !== value.checksum)
186
+ throw new Error('网页图片补充记录校验失败,请恢复 .page-images.json');
187
+ return validateImageManifest(value.data);
188
+ }
189
+ pageImages(id) { return structuredClone(this.readImageDataAt(this.row(id).path)); }
190
+ savePageImages(id, data) {
191
+ validateImageManifest(data);
192
+ const row = this.row(id);
193
+ const meta = JSON.parse(row.meta);
194
+ if (meta.kind !== 'web')
195
+ throw new Error('仅网页资料支持图片补充');
196
+ const previous = this.readImageDataAt(row.path);
197
+ if (previous && (previous.model !== data.model || previous.limit !== data.limit || previous.excluded !== data.excluded || JSON.stringify(previous.records.map(record => [record.id, record.url, record.alt])) !== JSON.stringify(data.records.map(record => [record.id, record.url, record.alt]))))
198
+ throw new Error('图片计划与模型已固定,不能静默更换');
199
+ for (const old of previous?.records.filter(image => image.status === 'completed') || [])
200
+ if (JSON.stringify(data.records.find(image => image.id === old.id)) !== JSON.stringify(old))
201
+ throw new Error('已保存的图片文字和段落不可改写');
202
+ const file = join(row.path, '.page-images.json');
203
+ this.assertContained(file);
204
+ this.db.exec('BEGIN IMMEDIATE');
205
+ try {
206
+ atomicJson(file, { data, checksum: imageHash(JSON.stringify(data)) });
207
+ this.index(meta, row.path, row.content);
208
+ this.db.exec('COMMIT');
209
+ }
210
+ catch (error) {
211
+ this.db.exec('ROLLBACK');
212
+ if (previous)
213
+ atomicJson(file, { data: previous, checksum: imageHash(JSON.stringify(previous)) });
214
+ else if (existsSync(file))
215
+ unlinkSync(file);
216
+ throw error;
217
+ }
218
+ }
219
+ savePageImageFile(id, imageId, bytes) {
220
+ if (!/^img_[a-f0-9-]{36}$/.test(imageId))
221
+ throw new Error('图片ID无效');
222
+ const row = this.row(id);
223
+ const meta = JSON.parse(row.meta);
224
+ if (meta.kind !== 'web' || meta.deletedAt)
225
+ throw new Error('网页已删除,停止保存图片');
226
+ const info = inspectImage(bytes);
227
+ const fileName = `${imageId}.${info.extension}`;
228
+ const directory = join(row.path, 'images');
229
+ const file = join(directory, fileName);
230
+ this.assertContained(file);
231
+ mkdirSync(directory, { recursive: true });
232
+ const checksum = imageHash(bytes);
233
+ if (existsSync(file)) {
234
+ if (imageHash(readFileSync(file)) !== checksum)
235
+ throw new Error('原图缓存校验失败,拒绝覆盖');
236
+ }
237
+ else
238
+ writeFileSync(file, bytes, { flag: 'wx' });
239
+ return { fileName, checksum, mediaType: info.mediaType, width: info.width, height: info.height, bytes: bytes.length };
240
+ }
241
+ pageImageFile(id, imageId) {
157
242
  const row = this.row(id);
158
- return { ...JSON.parse(row.meta), content: row.content, segments: JSON.parse(readFileSync(join(row.path, 'segments.json'), 'utf8')), path: row.path };
243
+ const image = this.readImageDataAt(row.path)?.records.find(record => record.id === imageId);
244
+ if (!image?.fileName)
245
+ throw new Error('图片尚未保存');
246
+ const file = join(row.path, 'images', image.fileName);
247
+ this.assertContained(file);
248
+ const bytes = readFileSync(file);
249
+ if (imageHash(bytes) !== image.checksum)
250
+ throw new Error('原图校验失败,请检查图片文件');
251
+ return { bytes, mediaType: image.mediaType, name: image.fileName };
159
252
  }
253
+ has(id) { return Boolean(this.db.prepare('SELECT id FROM materials WHERE id=?').get(id)); }
160
254
  original(id) {
161
255
  const item = this.get(id);
162
256
  if (!item.originalFile || basename(item.originalFile) !== item.originalFile)
@@ -166,12 +260,13 @@ export class Library {
166
260
  return { bytes: readFileSync(path), name: item.originalName || item.originalFile };
167
261
  }
168
262
  save(content, fields, extra, original, suppliedSegments) {
263
+ this.recoverNoteUpdate();
169
264
  if (content.length > MAX_TEXT_CHARS)
170
265
  throw new Error('正文超过 200 万字符,请拆分后导入');
171
- const digest = hash(`${extra.kind}\0${original ? hash(original.bytes) : content}\0${fields.sourceUrl ?? ''}\0${JSON.stringify(extra.citations ?? [])}`);
266
+ const digest = hash(`${extra.kind}\0${original ? hash(original.bytes) : content}\0${fields.sourceUrl ?? ''}\0${JSON.stringify(extra.citations ?? [])}${extra.knowledgeJobId ? `\0${extra.knowledgeJobId}` : ''}`);
172
267
  const duplicate = this.db.prepare('SELECT id FROM materials WHERE content_hash=? LIMIT 1').get(digest);
173
268
  if (duplicate)
174
- return { material: this.get(duplicate.id), duplicate: true };
269
+ return { material: this.get(duplicate.id).deletedAt ? this.restore(duplicate.id) : this.get(duplicate.id), duplicate: true };
175
270
  const category = categoryName(fields.category);
176
271
  const id = `r_${randomUUID()}`;
177
272
  const timestamp = now();
@@ -230,7 +325,7 @@ export class Library {
230
325
  const source = validateUrl(url).href;
231
326
  const article = extractArticle(html, source);
232
327
  return this.save(article.content, { ...fields, title: fields.title || article.title, sourceUrl: source }, {
233
- kind: 'web', author: article.author, extraction: 'Readability 正文提取;图片保留远程引用,未离线下载',
328
+ kind: 'web', author: article.author, extraction: `${article.extraction};图片保留远程引用,未离线下载`,
234
329
  }, { name: 'page.html', bytes: Buffer.from(html) });
235
330
  }
236
331
  importFile(path, fields = {}) {
@@ -253,7 +348,7 @@ export class Library {
253
348
  }
254
349
  if (['.html', '.htm'].includes(extension)) {
255
350
  const article = extractArticle(this.decode(bytes), fields.sourceUrl || 'https://import.invalid/');
256
- return this.save(article.content, { ...data, title: fields.title || article.title }, { kind: 'file', extraction: '本地 HTML 正文提取', author: article.author }, original);
351
+ return this.save(article.content, { ...data, title: fields.title || article.title }, { kind: 'file', extraction: `本地 HTML:${article.extraction};图片保留远程引用,未离线下载`, author: article.author }, original);
257
352
  }
258
353
  if (['.txt', '.md', '.markdown', '.csv', '.json', '.log'].includes(extension)) {
259
354
  return this.save(this.decode(bytes), data, { kind: 'file', extraction: 'UTF-8 文本导入' }, original);
@@ -274,7 +369,12 @@ export class Library {
274
369
  }
275
370
  update(id, fields) {
276
371
  const item = this.get(id);
277
- const { content, segments: _segments, path: oldPath, ...meta } = item;
372
+ if (item.deletedAt)
373
+ throw new Error('请先从回收站恢复资料后再编辑');
374
+ const row = this.row(id);
375
+ const { content, path: oldPath } = row;
376
+ const meta = JSON.parse(row.meta);
377
+ const previous = structuredClone(meta);
278
378
  if (fields.title !== undefined) {
279
379
  if (!cleanString(fields.title))
280
380
  throw new Error('标题不能为空');
@@ -291,23 +391,150 @@ export class Library {
291
391
  meta.updatedAt = now();
292
392
  const path = join(this.root, 'materials', ...meta.category.split('/'), id);
293
393
  this.assertContained(path);
294
- if (path !== oldPath) {
295
- mkdirSync(dirname(path), { recursive: true });
296
- renameSync(oldPath, path);
394
+ let moved = false;
395
+ let written = false;
396
+ this.db.exec('BEGIN IMMEDIATE');
397
+ try {
398
+ if (path !== oldPath) {
399
+ mkdirSync(dirname(path), { recursive: true });
400
+ renameSync(oldPath, path);
401
+ moved = true;
402
+ }
403
+ atomicJson(join(path, 'metadata.json'), meta);
404
+ written = true;
405
+ this.index(meta, path, content);
406
+ this.db.exec('COMMIT');
407
+ }
408
+ catch (error) {
409
+ this.db.exec('ROLLBACK');
410
+ if (written)
411
+ atomicJson(join(path, 'metadata.json'), previous);
412
+ if (moved)
413
+ renameSync(path, oldPath);
414
+ throw error;
415
+ }
416
+ return this.get(id);
417
+ }
418
+ validateCategoryMove(id, category) {
419
+ const source = this.get(id);
420
+ const target = join(this.root, 'materials', ...categoryName(category).split('/'), id);
421
+ this.assertContained(target);
422
+ if (resolve(target) !== resolve(source.path) && existsSync(target))
423
+ throw new Error('目标分类已有同ID目录,请检查后重试');
424
+ }
425
+ trash(id) { return this.setDeleted(id, true); }
426
+ restore(id) { return this.setDeleted(id, false); }
427
+ setDeleted(id, deleted) {
428
+ const item = this.get(id);
429
+ if (Boolean(item.deletedAt) === deleted)
430
+ return item;
431
+ const row = this.row(id);
432
+ const { content, path } = row;
433
+ const previous = JSON.parse(row.meta);
434
+ const meta = { ...previous, updatedAt: now() };
435
+ if (deleted) {
436
+ meta.deletedAt = now();
437
+ meta.deletionId = randomUUID();
297
438
  }
298
- atomicJson(join(path, 'metadata.json'), meta);
439
+ else {
440
+ delete meta.deletedAt;
441
+ delete meta.deletionId;
442
+ }
443
+ // Keep the canonical files and stable IDs in place so existing citations
444
+ // remain readable. The flag also survives rebuilding a lost SQLite index.
299
445
  this.db.exec('BEGIN IMMEDIATE');
446
+ let written = false;
300
447
  try {
448
+ atomicJson(join(path, 'metadata.json'), meta);
449
+ written = true;
301
450
  this.index(meta, path, content);
302
451
  this.db.exec('COMMIT');
303
452
  }
304
453
  catch (error) {
305
454
  this.db.exec('ROLLBACK');
455
+ if (written)
456
+ atomicJson(join(path, 'metadata.json'), previous);
306
457
  throw error;
307
458
  }
308
459
  return this.get(id);
309
460
  }
310
- saveNote(content, citations, fields) {
461
+ trashPlan() {
462
+ const rows = this.db.prepare('SELECT id,path,meta FROM materials ORDER BY id').all();
463
+ const items = rows.map(row => ({ ...row, metadata: JSON.parse(row.meta) })).filter(row => row.metadata.deletedAt);
464
+ const pending = new PurgeStore(this.root).list();
465
+ const token = hash(JSON.stringify({ items: items.map(row => [row.id, row.metadata.deletedAt, row.metadata.deletionId]), pending: pending.map(row => row.id) }));
466
+ return { items, pending, token };
467
+ }
468
+ trashSnapshot() {
469
+ const plan = this.trashPlan();
470
+ return { count: plan.items.length, pending: plan.pending.length, token: plan.token };
471
+ }
472
+ cleanupPendingPurge() {
473
+ const store = new PurgeStore(this.root);
474
+ const errors = [];
475
+ try {
476
+ for (const ticket of store.list()) {
477
+ try {
478
+ store.finish(ticket);
479
+ }
480
+ catch (error) {
481
+ errors.push(`${ticket.id}:${error.message}`);
482
+ }
483
+ }
484
+ }
485
+ catch (error) {
486
+ errors.push(error.message);
487
+ }
488
+ return errors;
489
+ }
490
+ emptyTrash(expectedToken) {
491
+ this.recoverNoteUpdate();
492
+ const plan = this.trashPlan();
493
+ if (typeof expectedToken !== 'string' || expectedToken !== plan.token)
494
+ throw new Error('回收站内容已改变,请重新打开清空确认窗口');
495
+ const store = new PurgeStore(this.root);
496
+ // Preflight the entire confirmed scope before moving or removing anything.
497
+ for (const item of plan.items) {
498
+ const expected = join(this.root, 'materials', ...categoryName(item.metadata.category).split('/'), item.id);
499
+ if (resolve(item.path) !== resolve(expected))
500
+ throw new Error('清理目标与资料目录不一致');
501
+ store.validate(item.id, item.path);
502
+ }
503
+ const errors = this.cleanupPendingPurge();
504
+ let deleted = 0;
505
+ for (const item of plan.items) {
506
+ try {
507
+ const ticket = store.stage(item.id, item.path);
508
+ let transaction = false;
509
+ try {
510
+ this.db.exec('BEGIN IMMEDIATE');
511
+ transaction = true;
512
+ this.db.prepare('DELETE FROM material_fts WHERE id=?').run(item.id);
513
+ this.db.prepare('DELETE FROM materials WHERE id=?').run(item.id);
514
+ this.db.exec('COMMIT');
515
+ transaction = false;
516
+ }
517
+ catch (error) {
518
+ if (transaction)
519
+ this.db.exec('ROLLBACK');
520
+ store.rollback(ticket);
521
+ throw error;
522
+ }
523
+ deleted++;
524
+ try {
525
+ store.finish(ticket);
526
+ }
527
+ catch (error) {
528
+ errors.push(`${item.id}:文件清理未完成,${error.message}`);
529
+ }
530
+ }
531
+ catch (error) {
532
+ errors.push(`${item.id}:${error.message}`);
533
+ }
534
+ }
535
+ return { deleted, pending: store.list().length, errors };
536
+ }
537
+ saveNote(content, citations, fields, knowledgeJobId) {
311
538
  if (!content?.trim())
312
539
  throw new Error('笔记内容不能为空');
313
540
  if (!Array.isArray(citations) || citations.length > 200)
@@ -317,14 +544,121 @@ export class Library {
317
544
  if (citation.segmentId && !source.segments.some(s => s.id === citation.segmentId))
318
545
  throw new Error(`引用段落不存在:${citation.segmentId}`);
319
546
  }
320
- return this.save(content.trim(), fields, { kind: 'note', citations, extraction: '知识笔记;内容由用户或 Agent 提供,请结合引用核验' });
547
+ return this.save(content.trim(), fields, { kind: 'note', citations, ...(knowledgeJobId ? { knowledgeJobId } : {}), extraction: '知识笔记;内容由用户或 Agent 提供,请结合引用核验' });
548
+ }
549
+ writeNoteSnapshot(snapshot) {
550
+ const path = join(this.root, 'materials', ...categoryName(snapshot.meta.category).split('/'), snapshot.meta.id);
551
+ for (const file of ['content.md', 'segments.json', 'metadata.json'])
552
+ this.assertContained(join(path, file));
553
+ atomicText(join(path, 'content.md'), snapshot.content);
554
+ atomicJson(join(path, 'segments.json'), snapshot.segments);
555
+ atomicJson(join(path, 'metadata.json'), snapshot.meta);
556
+ return path;
557
+ }
558
+ archiveNoteUpdate(update) {
559
+ const history = join(this.root, 'materials', ...categoryName(update.before.meta.category).split('/'), update.before.meta.id, '.history');
560
+ this.assertContained(history);
561
+ mkdirSync(history, { recursive: true });
562
+ const target = join(history, `${update.jobId}.json`);
563
+ this.assertContained(target);
564
+ renameSync(join(this.root, '.system', 'note-update.json'), target);
565
+ }
566
+ recoverNoteUpdate() {
567
+ const file = join(this.root, '.system', 'note-update.json');
568
+ this.assertContained(file);
569
+ if (!existsSync(file))
570
+ return;
571
+ const update = JSON.parse(readFileSync(file, 'utf8'));
572
+ if (!update || !/^knowledge_[a-f0-9-]+$/.test(update.jobId) || !['prepared', 'committed'].includes(update.phase))
573
+ throw new Error('笔记更新日志损坏,请检查 .system/note-update.json');
574
+ for (const snapshot of [update.before, update.after]) {
575
+ if (!snapshot || snapshot.meta?.kind !== 'note' || !/^r_[a-f0-9-]+$/.test(snapshot.meta.id) || typeof snapshot.content !== 'string' || !Array.isArray(snapshot.segments) || hash(snapshot.content) !== snapshot.meta.textChecksum || hash(JSON.stringify(snapshot.segments, null, 2)) !== snapshot.meta.segmentsChecksum)
576
+ throw new Error('笔记更新快照损坏,请检查更新日志');
577
+ }
578
+ if (update.before.meta.id !== update.after.meta.id || update.before.meta.category !== update.after.meta.category || update.after.meta.revision !== update.before.meta.revision + 1)
579
+ throw new Error('笔记更新版本无效');
580
+ const snapshot = update.phase === 'committed' ? update.after : update.before;
581
+ const path = this.writeNoteSnapshot(snapshot);
582
+ this.db.exec('BEGIN IMMEDIATE');
583
+ try {
584
+ this.index(snapshot.meta, path, snapshot.content);
585
+ this.db.exec('COMMIT');
586
+ }
587
+ catch (error) {
588
+ this.db.exec('ROLLBACK');
589
+ throw error;
590
+ }
591
+ if (update.phase === 'committed')
592
+ this.archiveNoteUpdate(update);
593
+ else
594
+ unlinkSync(file);
595
+ }
596
+ /** Append only after explicit confirmation. Existing paragraph IDs and text never change. */
597
+ appendNote(id, expectedFingerprint, addition, citations, tags, jobId) {
598
+ this.recoverNoteUpdate();
599
+ const previous = this.generatedNote(jobId);
600
+ if (previous)
601
+ return { material: previous, duplicate: true };
602
+ const item = this.get(id);
603
+ if (item.kind !== 'note' || item.deletedAt)
604
+ throw new Error('目标知识笔记已删除或不可用,请改为新建或重新整理');
605
+ if (noteFingerprint(item) !== expectedFingerprint)
606
+ throw new Error('已有笔记已改变,请改为新建,或重新整理后核对');
607
+ if (!/^knowledge_[a-f0-9-]+$/.test(jobId) || typeof addition !== 'string' || addition.length > 50_000)
608
+ throw new Error('笔记补充参数无效');
609
+ for (const citation of citations) {
610
+ const source = this.get(citation.materialId);
611
+ if (source.deletedAt || source.kind === 'note' || !citation.segmentId || !source.segments.some(s => s.id === citation.segmentId))
612
+ throw new Error('新增来源已删除或引用无效');
613
+ }
614
+ const merged = [...new Map([...item.citations, ...citations].map(c => [JSON.stringify([c.materialId, c.segmentId]), c])).values()];
615
+ if (merged.length > 200)
616
+ throw new Error('合并后来源超过200条,请改为新建笔记');
617
+ const mergedTags = [...new Set([...item.tags, ...this.tags(tags)])];
618
+ if (mergedTags.length > 30)
619
+ throw new Error('合并后标签超过30个,请减少标签或新建笔记');
620
+ const suffix = addition.trim() ? `## 知识补充 · ${now().slice(0, 10)}\n\n${addition.trim()}` : '';
621
+ const content = item.content + (suffix ? `\n\n${suffix}` : '');
622
+ if (content.length > MAX_TEXT_CHARS)
623
+ throw new Error('笔记超过正文上限,请改为新建');
624
+ const segments = [...item.segments, ...splitSegments(suffix).map(s => ({ ...s, id: `u_${randomUUID().replaceAll('-', '')}` }))];
625
+ const { content: oldContent, segments: oldSegments, path: _path, ...meta } = item;
626
+ const before = { meta, content: oldContent, segments: oldSegments };
627
+ const after = { content, segments, meta: { ...meta, revision: meta.revision + 1, updatedAt: now(), tags: mergedTags, citations: merged, knowledgeUpdateJobIds: [...(meta.knowledgeUpdateJobIds || []), jobId], contentHash: hash(`note-update\0${content}\0${jobId}`), textChecksum: hash(content), segmentsChecksum: hash(JSON.stringify(segments, null, 2)) } };
628
+ const update = { jobId, phase: 'prepared', before, after };
629
+ const journal = join(this.root, '.system', 'note-update.json');
630
+ this.assertContained(journal);
631
+ atomicJson(journal, update);
632
+ this.db.exec('BEGIN IMMEDIATE');
633
+ try {
634
+ const path = this.writeNoteSnapshot(after);
635
+ this.index(after.meta, path, content);
636
+ update.phase = 'committed';
637
+ atomicJson(journal, update);
638
+ this.db.exec('COMMIT');
639
+ }
640
+ catch (error) {
641
+ this.db.exec('ROLLBACK');
642
+ update.phase = 'prepared';
643
+ atomicJson(journal, update);
644
+ this.writeNoteSnapshot(before);
645
+ unlinkSync(journal);
646
+ throw error;
647
+ }
648
+ this.archiveNoteUpdate(update);
649
+ return { material: this.get(id), duplicate: false };
650
+ }
651
+ generatedNote(jobId) {
652
+ const row = this.db.prepare("SELECT id FROM materials WHERE json_extract(meta, '$.knowledgeJobId')=? OR EXISTS (SELECT 1 FROM json_each(materials.meta, '$.knowledgeUpdateJobIds') WHERE value=?) LIMIT 1").get(jobId, jobId);
653
+ return row ? this.get(row.id) : undefined;
321
654
  }
322
655
  search(options = {}) {
656
+ this.recoverNoteUpdate();
323
657
  const query = cleanString(options.query, 500);
324
658
  const limit = Math.max(1, Math.min(100, Number(options.limit) || 40));
325
659
  const offset = Math.max(0, Math.floor(Number(options.offset) || 0));
326
660
  const terms = query.toLocaleLowerCase().split(/\s+/).filter(Boolean);
327
- const rows = this.db.prepare('SELECT id,meta,content FROM materials ORDER BY updated_at DESC').all();
661
+ const rows = this.db.prepare('SELECT id,path,meta,content FROM materials ORDER BY updated_at DESC').all();
328
662
  // Literal substring matching guarantees one- and two-character Chinese searches.
329
663
  // FTS narrows body/title candidates for longer terms; metadata is always searched too.
330
664
  let indexed;
@@ -333,6 +667,8 @@ export class Library {
333
667
  }
334
668
  const matched = rows.filter(row => {
335
669
  const meta = JSON.parse(row.meta);
670
+ if (Boolean(meta.deletedAt) !== (options.trashed === true))
671
+ return false;
336
672
  if (options.category && meta.category !== options.category && !meta.category.startsWith(`${options.category}/`))
337
673
  return false;
338
674
  if (options.kind && meta.kind !== options.kind)
@@ -344,7 +680,8 @@ export class Library {
344
680
  const metadataText = `${meta.title}\n${meta.category}\n${meta.tags.join(' ')}\n${meta.collection}\n${meta.sourceNote}`.toLocaleLowerCase();
345
681
  if (!terms.length)
346
682
  return true;
347
- const haystack = metadataText + '\n' + row.content.toLocaleLowerCase();
683
+ const supplement = meta.kind === 'web' ? imageSupplement(this.readImageDataAt(row.path)).text : '';
684
+ const haystack = metadataText + '\n' + row.content.toLocaleLowerCase() + '\n' + supplement.toLocaleLowerCase();
348
685
  // FTS is an optimization hint only; punctuation and Unicode never change correctness.
349
686
  return indexed?.has(row.id) || terms.every(term => haystack.includes(term));
350
687
  });
@@ -359,8 +696,10 @@ export class Library {
359
696
  };
360
697
  }
361
698
  stats() {
699
+ this.recoverNoteUpdate();
362
700
  const rows = this.db.prepare('SELECT meta FROM materials').all();
363
- const materials = rows.map(row => JSON.parse(row.meta));
701
+ const all = rows.map(row => JSON.parse(row.meta));
702
+ const materials = all.filter(item => !item.deletedAt);
364
703
  const count = (select) => {
365
704
  const map = new Map();
366
705
  for (const m of materials)
@@ -369,7 +708,12 @@ export class Library {
369
708
  map.set(key, (map.get(key) || 0) + 1);
370
709
  return [...map].sort((a, b) => a[0].localeCompare(b[0], 'zh')).map(([name, count]) => ({ name, count }));
371
710
  };
372
- return { total: materials.length, ready: materials.filter(m => m.status === 'ready').length, notes: materials.filter(m => m.kind === 'note').length,
711
+ let purgePending = 0;
712
+ try {
713
+ purgePending = new PurgeStore(this.root).list().length;
714
+ }
715
+ catch { /* Clearing reports invalid cleanup records without breaking normal reading. */ }
716
+ return { total: materials.length, trashed: all.length - materials.length, canEmptyTrash: true, purgePending, ready: materials.filter(m => m.status === 'ready').length, notes: materials.filter(m => m.kind === 'note').length,
373
717
  inbox: materials.filter(m => m.category === '收件箱').length, categories: count(m => [m.category]),
374
718
  tags: count(m => m.tags), collections: count(m => [m.collection]), root: this.root, warnings: [...this.warnings] };
375
719
  }
@@ -377,6 +721,8 @@ export class Library {
377
721
  const item = this.get(id);
378
722
  const header = JSON.stringify({ id: item.id, title: item.title, category: item.category, tags: item.tags, collection: item.collection, sourceUrl: item.sourceUrl, createdAt: item.createdAt, revision: item.revision }, null, 2);
379
723
  const references = item.citations.map(c => {
724
+ if (!this.has(c.materialId))
725
+ return `- 来源已永久删除 — library://${c.materialId}${c.segmentId ? `/${c.segmentId}` : ''}`;
380
726
  const source = this.get(c.materialId);
381
727
  const segment = c.segmentId ? source.segments.find(p => p.id === c.segmentId) : undefined;
382
728
  return `- ${source.title} — library://${source.id}${c.segmentId ? `/${c.segmentId}` : ''}${segment?.startSeconds !== undefined ? ` (${formatTime(segment.startSeconds)})` : ''}${source.sourceUrl ? ` — ${source.sourceUrl}` : ''}`;
@@ -0,0 +1,6 @@
1
+ export declare class PageAccessError extends Error {
2
+ readonly code = "PAGE_REQUIRES_BROWSER";
3
+ constructor(site: string);
4
+ }
5
+ /** Match explicit challenge structure, not keywords occurring in an ordinary article. */
6
+ export declare function assertArticleAccess(document: Document, source: string): void;
@@ -0,0 +1,38 @@
1
+ export class PageAccessError extends Error {
2
+ code = 'PAGE_REQUIRES_BROWSER';
3
+ constructor(site) {
4
+ super(`${site}返回了验证页面,尚未获取文章正文。请在浏览器打开原网页,完成验证后粘贴正文,或导入保存的 HTML。后台采集不共享浏览器的验证状态。`);
5
+ this.name = 'PageAccessError';
6
+ }
7
+ }
8
+ /** Match explicit challenge structure, not keywords occurring in an ordinary article. */
9
+ export function assertArticleAccess(document, source) {
10
+ const url = new URL(source);
11
+ const host = url.hostname.toLowerCase().replace(/\.$/, '');
12
+ const text = (document.body?.textContent || '').replace(/\s+/g, ' ').trim();
13
+ const title = document.title?.trim() || '';
14
+ const heading = document.querySelector('h1,h2,.weui-msg__title')?.textContent?.trim() || '';
15
+ const verificationLink = document.querySelector('a[href*="/mp/wappoc_appmsgcaptcha"]');
16
+ let importedWechatChallenge = false;
17
+ if (host === 'import.invalid' && verificationLink) {
18
+ try {
19
+ importedWechatChallenge = new URL(verificationLink.getAttribute('href') || '', source).hostname === 'mp.weixin.qq.com';
20
+ }
21
+ catch { /* Not a recognizable verification link. */ }
22
+ }
23
+ if (host === 'mp.weixin.qq.com' || importedWechatChallenge) {
24
+ if (/^\/mp\/wappoc_appmsgcaptcha(?:\/|$)/i.test(url.pathname))
25
+ throw new PageAccessError('微信');
26
+ const content = document.querySelector('#js_content')?.textContent?.trim() || '';
27
+ const articleText = document.querySelector('article')?.textContent?.trim() || '';
28
+ if (content.length >= 40 || articleText.length >= 300)
29
+ return;
30
+ const environmentNotice = /当前环境异常/.test(text) && /完成验证|去验证/.test(text);
31
+ if (text.length < 2000 && environmentNotice && (verificationLink || title === '环境异常' || heading === '环境异常'))
32
+ throw new PageAccessError('微信');
33
+ }
34
+ const challengeTitle = /^(?:just a moment[.!…]*|attention required!?\s*\|\s*cloudflare)$/i.test(title);
35
+ const challengeForm = document.querySelector('#challenge-form,#challenge-running,form[action*="/cdn-cgi/challenge-platform/"]');
36
+ if (text.length < 3000 && challengeTitle && challengeForm)
37
+ throw new PageAccessError('网站');
38
+ }
@@ -0,0 +1,15 @@
1
+ import type { PageImageManifest, Segment } from '../types.js';
2
+ export declare const imageHash: (value: string | Uint8Array) => string;
3
+ export declare const imageFilePattern: RegExp;
4
+ export declare function validateImageManifest(value: unknown): PageImageManifest;
5
+ export declare function imageSupplement(data?: PageImageManifest): {
6
+ text: string;
7
+ segments: Segment[];
8
+ };
9
+ /** Sniff raster bytes and dimensions without decoding scripts or unbounded compressed content. */
10
+ export declare function inspectImage(bytes: Buffer): {
11
+ extension: "png" | "jpg" | "gif" | "webp";
12
+ mediaType: "image/png" | "image/jpeg" | "image/webp" | "image/gif";
13
+ width: number;
14
+ height: number;
15
+ };