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.
- package/CHANGELOG.md +40 -0
- package/README.md +133 -14
- package/dist/client.js +1654 -304
- package/dist/core/extract.d.ts +5 -0
- package/dist/core/extract.js +78 -8
- package/dist/core/fetch.d.ts +5 -0
- package/dist/core/fetch.js +102 -25
- package/dist/core/grouping.d.ts +6 -0
- package/dist/core/grouping.js +71 -0
- package/dist/core/image-capture.d.ts +33 -0
- package/dist/core/image-capture.js +196 -0
- package/dist/core/incremental.d.ts +14 -0
- package/dist/core/incremental.js +52 -0
- package/dist/core/jobs.d.ts +21 -1
- package/dist/core/jobs.js +101 -12
- package/dist/core/knowledge.d.ts +37 -0
- package/dist/core/knowledge.js +619 -0
- package/dist/core/library.d.ts +36 -2
- package/dist/core/library.js +363 -17
- package/dist/core/page-access.d.ts +6 -0
- package/dist/core/page-access.js +38 -0
- package/dist/core/page-images.d.ts +15 -0
- package/dist/core/page-images.js +97 -0
- package/dist/core/purge.d.ts +20 -0
- package/dist/core/purge.js +106 -0
- package/dist/directory-settings.d.ts +18 -0
- package/dist/directory-settings.js +92 -0
- package/dist/http.d.ts +3 -1
- package/dist/http.js +152 -4
- package/dist/index.js +22 -7
- package/dist/knowledge-model.d.ts +30 -0
- package/dist/knowledge-model.js +194 -0
- package/dist/preview-settings.d.ts +6 -0
- package/dist/preview-settings.js +42 -0
- package/dist/public/app.css +145 -2
- package/dist/public/app.js +10 -6
- package/dist/standalone.js +12 -4
- package/dist/tools.d.ts +2 -1
- package/dist/tools.js +10 -1
- package/dist/types.d.ts +199 -1
- package/dist/ui/App.js +248 -47
- package/dist/ui/Dialog.d.ts +7 -0
- package/dist/ui/Dialog.js +32 -0
- package/dist/ui/Grouping.d.ts +12 -0
- package/dist/ui/Grouping.js +93 -0
- package/dist/ui/ImageCapture.d.ts +18 -0
- package/dist/ui/ImageCapture.js +76 -0
- package/dist/ui/Knowledge.d.ts +26 -0
- package/dist/ui/Knowledge.js +106 -0
- package/dist/ui/api.d.ts +7 -0
- package/dist/ui/api.js +16 -0
- package/dist/ui/icons.js +2 -0
- package/package.json +5 -1
package/dist/core/library.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/core/library.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
};
|