feat: 内置阅读器、批注笔记与 AI 助手,发布 1.3.0

新增 PDF/EPUB/MOBI/AZW 内置阅读器,PDF 分段读取支持超大文件,
批注、读书与画布笔记、封面生成与本地导入。AI 助手支持三种协议、
图像上下文与安全 Markdown 渲染,上下文范围改为 选中/当前页/全文,
页面与全文无需选中文本即可发送,全文会提示可能超出模型限制。

便携版输出目录固定为 PeopleLib-windows-x64,不再随版本号变化,
避免升级后 data/ 被遗留在旧目录。

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-08-03 12:13:02 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent b8c8d24107
commit 3ccd044527
307 changed files with 98477 additions and 1148 deletions
+979
View File
@@ -0,0 +1,979 @@
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const h = require('./helpers');
h.installFetchStub();
const storePath = require.resolve('../reader/store.js');
const cfgPath = require.resolve('../reader/ai-config.js');
const dirs = [];
function tmp() {
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'peoplelib-reader-'));
dirs.push(d);
return d;
}
test.after(() => {
for (const d of dirs) {
try { fs.rmSync(d, { recursive: true, force: true }); } catch (e) { /* ignore */ }
}
});
function freshStore() {
delete require.cache[storePath];
const s = require(storePath);
s.init(tmp());
return s;
}
function storeAt(dir) {
delete require.cache[storePath];
const s = require(storePath);
s.init(dir);
return s;
}
function fakeStorage(available = true) {
return {
isEncryptionAvailable: () => available,
encryptString: (s) => Buffer.from('ENC:' + Buffer.from(s, 'utf8').toString('base64')),
decryptString: (b) => {
const s = b.toString();
if (!s.startsWith('ENC:')) throw new Error('bad');
return Buffer.from(s.slice(4), 'base64').toString('utf8');
}
};
}
// --- reader/store ---
test('阅读进度可存取,百分比被夹在 0..1', () => {
const s = freshStore();
assert.strictEqual(s.getLastReadAt('e1'), 0);
s.setProgress('e1', { kind: 'pdf', page: 5 }, 2.5);
const st = s.getState('e1');
assert.strictEqual(st.progress.locator.page, 5);
assert.strictEqual(st.progress.percent, 1);
assert.strictEqual(s.getLastReadAt('e1'), st.progress.at);
s.setProgress('e1', { kind: 'pdf', page: 1 }, -3);
assert.strictEqual(s.getState('e1').progress.percent, 0);
});
test('书签与笔记的增删互不干扰', () => {
const s = freshStore();
const b = s.addBookmark('e1', { locator: { kind: 'epub', chapter: 2, offset: 10 }, label: '第 3 章' });
const n = s.addNote('e1', { locator: { kind: 'epub', chapter: 2 }, text: '这是笔记', kind: 'ai' });
let st = s.getState('e1');
assert.strictEqual(st.bookmarks.length, 1);
assert.strictEqual(st.notes.length, 1);
assert.strictEqual(st.notes[0].kind, 'ai');
s.removeBookmark('e1', b.id);
st = s.getState('e1');
assert.strictEqual(st.bookmarks.length, 0);
assert.strictEqual(st.notes.length, 1, '删书签不该动笔记');
assert.strictEqual(s.removeNote('e1', n.id), true);
});
test('缺少定位信息的书签被拒绝', () => {
const s = freshStore();
assert.throws(() => s.addBookmark('e1', { label: 'x' }), /定位/);
assert.throws(() => s.addNote('e1', { text: ' ' }), /内容为空/);
});
test('不同条目的阅读数据互相隔离', () => {
const s = freshStore();
s.addBookmark('a', { locator: { kind: 'pdf', page: 1 } });
s.addBookmark('b', { locator: { kind: 'pdf', page: 2 } });
assert.strictEqual(s.getState('a').bookmarks.length, 1);
assert.strictEqual(s.getState('b').bookmarks[0].locator.page, 2);
s.forget('a');
assert.strictEqual(s.getState('a').bookmarks.length, 0);
assert.strictEqual(s.getState('b').bookmarks.length, 1, 'forget 误删了其它条目');
});
test('getState 返回副本,外部改动不污染存储', () => {
const s = freshStore();
s.addBookmark('e1', { locator: { kind: 'pdf', page: 1 } });
const st = s.getState('e1');
st.bookmarks.push({ id: 'fake' });
assert.strictEqual(s.getState('e1').bookmarks.length, 1);
});
test('损坏的 reader.json 不会导致崩溃', () => {
const d = tmp();
fs.writeFileSync(path.join(d, 'reader.json'), '{ 这不是 json');
delete require.cache[storePath];
const s = require(storePath);
s.init(d);
assert.deepStrictEqual(s.getState('x').bookmarks, []);
s.setProgress('x', { kind: 'pdf', page: 1 }, 0.1);
assert.ok(s.getState('x').progress);
assert.strictEqual(
fs.readdirSync(d).some((name) => name.startsWith('reader.json.corrupt-')),
true,
'损坏原文件应被隔离保留'
);
assert.doesNotThrow(() => JSON.parse(fs.readFileSync(path.join(d, 'reader.json'), 'utf8')));
});
test('同一条目的进度、书签和笔记按文档标识隔离', () => {
const s = freshStore();
s.setProgress('e1', 'doc-a', { kind: 'pdf', page: 2 }, 0.2);
s.setProgress('e1', 'doc-b', { kind: 'epub', chapter: 3, offset: 20 }, 0.7);
s.addBookmark('e1', {
documentKey: 'doc-a',
locator: { kind: 'pdf', page: 2 },
label: 'PDF'
});
s.addBookmark('e1', {
documentKey: 'doc-b',
locator: { kind: 'epub', chapter: 3, offset: 20 },
label: 'EPUB'
});
s.addNote('e1', { documentKey: 'doc-a', text: 'PDF 笔记' });
s.addNote('e1', { documentKey: 'doc-b', text: 'EPUB 笔记' });
assert.strictEqual(s.getState('e1', 'doc-a').progress.locator.page, 2);
assert.strictEqual(s.getState('e1', 'doc-b').progress.locator.chapter, 3);
assert.deepStrictEqual(s.getState('e1', 'doc-a').bookmarks.map((item) => item.label), ['PDF']);
assert.deepStrictEqual(s.getState('e1', 'doc-b').notes.map((item) => item.text), ['EPUB 笔记']);
});
test('主文件损坏时隔离原件并从有效备份恢复阅读资料', () => {
const d = tmp();
const file = path.join(d, 'reader.json');
fs.writeFileSync(file, '{ broken');
fs.writeFileSync(`${file}.bak`, JSON.stringify({
version: 2,
collections: [],
entries: {
restored: {
progress: null,
bookmarks: [],
notes: [{ id: 'note-1', text: '已恢复', kind: 'user', at: 10 }]
}
}
}));
const s = storeAt(d);
assert.strictEqual(s.getState('restored').notes[0].text, '已恢复');
assert.strictEqual(fs.existsSync(file), true);
assert.strictEqual(
fs.readdirSync(d).some((name) => name.startsWith('reader.json.corrupt-')),
true
);
});
test('迁移会丢弃合法 JSON 中结构损坏的进度和书签', () => {
const d = tmp();
fs.writeFileSync(path.join(d, 'reader.json'), JSON.stringify({
version: 2,
collections: [],
entries: {
broken: {
progress: { locator: null, percent: 2 },
progressByDocument: {
bad: { locator: null },
good: { locator: { kind: 'pdf', page: 2 }, percent: 2 }
},
bookmarks: [
null,
{ id: 'bad', locator: null },
{ id: 'good', locator: { kind: 'pdf', page: 3 }, label: '有效书签' }
],
notes: []
}
}
}));
const s = storeAt(d);
assert.deepStrictEqual(s.getState('broken').bookmarks.map((item) => item.label), ['有效书签']);
assert.doesNotThrow(() => s.bindDocument('broken', 'doc-current'));
const state = s.getState('broken', 'good');
assert.strictEqual(state.progress.percent, 1);
});
test('首次绑定文档把旧版进度和书签安全迁移到该文档', () => {
const s = freshStore();
s.setProgress('legacy', { kind: 'pdf', page: 6 }, 0.5);
s.addBookmark('legacy', { locator: { kind: 'pdf', page: 6 }, label: '旧书签' });
assert.strictEqual(s.bindDocument('legacy', 'doc-key'), true);
const state = s.getState('legacy', 'doc-key');
assert.strictEqual(state.progress.locator.page, 6);
assert.strictEqual(state.bookmarks[0].documentKey, 'doc-key');
});
test('结构化笔记支持纯引用、来源、标签和上下文字段', () => {
const s = freshStore();
const note = s.addNote('e1', {
title: '重点',
quote: '只保存引用也可以',
context: '第二章',
source: 'selection',
documentKey: 'doc-1',
fileIndex: 2,
tags: ['方法', '方法', '研究'],
pinned: true,
locator: { kind: 'pdf', page: 3 }
});
assert.strictEqual(note.text, '');
assert.strictEqual(note.source, 'selection');
assert.strictEqual(note.kind, 'user', '保留旧渲染器使用的兼容别名');
assert.deepStrictEqual(note.tags, ['方法', '研究']);
assert.strictEqual(note.createdAt, note.updatedAt);
note.tags.push('外部修改');
note.locator.page = 99;
const stored = s.getState('e1').notes[0];
assert.deepStrictEqual(stored.tags, ['方法', '研究']);
assert.strictEqual(stored.locator.page, 3);
});
test('富文本笔记保存格式、纯文本索引和内嵌图片', () => {
const s = freshStore();
const image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB';
const richContent = {
version: 1,
blocks: [
{
type: 'text',
style: 'heading1',
runs: [{ text: '富文本标题', bold: true }]
},
{
type: 'text',
style: 'paragraph',
runs: [{ text: '正文' }, { text: '强调', italic: true, underline: true }]
},
{ type: 'image', dataUrl: image, alt: '示例图片' }
]
};
const note = s.addNote('e1', { richContent, source: 'manual' });
assert.strictEqual(note.noteType, 'reading');
assert.strictEqual(note.text, '富文本标题\n正文强调');
assert.deepStrictEqual(note.richContent, {
version: 2,
ops: [
{ insert: '富文本标题', attributes: { bold: true } },
{ insert: '\n', attributes: { header: 1 } },
{ insert: '正文' },
{ insert: '强调', attributes: { italic: true, underline: true } },
{ insert: '\n' },
{ insert: { image } }
]
});
assert.strictEqual(s.listNotes({ query: '正文强调' })[0].id, note.id);
const stored = s.getState('e1').notes[0];
assert.strictEqual(stored.richContent.version, 2);
richContent.blocks[0].runs[0].text = '外部污染';
assert.strictEqual(s.getState('e1').notes[0].text, '富文本标题\n正文强调');
const imageOnly = s.addNote('e1', {
richContent: { version: 1, blocks: [{ type: 'image', dataUrl: image, alt: '' }] }
});
assert.strictEqual(imageOnly.text, '');
assert.strictEqual(imageOnly.richContent.ops[0].insert.image, image);
});
test('富文本笔记拒绝主动内容、远程图片和超限结构', () => {
const s = freshStore();
assert.throws(
() => s.addNote('e1', {
richContent: {
version: 1,
blocks: [{ type: 'image', dataUrl: 'https://example.com/x.png', alt: '' }]
}
}),
/图片格式无效/
);
assert.throws(
() => s.addNote('e1', {
richContent: {
version: 1,
blocks: [{ type: 'html', html: '<script>alert(1)</script>' }]
}
}),
/段落无效/
);
assert.throws(
() => s.addNote('e1', {
richContent: {
version: 1,
blocks: Array.from({ length: 501 }, () => ({
type: 'text', style: 'paragraph', runs: [{ text: 'x' }]
}))
}
}),
/内容过多/
);
});
test('Quill Delta 仅保留受支持格式并拒绝主动嵌入', () => {
const s = freshStore();
const note = s.addNote('e1', {
richContent: {
version: 2,
ops: [
{ insert: '一级标题' },
{ insert: '\n', attributes: { header: 1 } },
{ insert: '正文', attributes: { bold: true, italic: true } },
{ insert: '\n', attributes: { list: 'bullet' } },
{ insert: '代码' },
{ insert: '\n', attributes: { 'code-block': 'plain' } }
]
}
});
assert.strictEqual(note.text, '一级标题\n正文\n代码');
assert.strictEqual(note.richContent.version, 2);
assert.deepStrictEqual(
note.richContent.ops.at(-1),
{ insert: '\n', attributes: { 'code-block': 'plain' } }
);
assert.throws(() => s.addNote('e1', {
richContent: {
version: 2,
ops: [{ insert: '外链', attributes: { link: 'https://example.com' } }]
}
}), /不支持的格式/);
assert.throws(() => s.addNote('e1', {
richContent: {
version: 2,
ops: [{ insert: { video: 'https://example.com/video' } }]
}
}), /嵌入内容无效/);
assert.throws(() => s.addNote('e1', {
richContent: {
version: 2,
ops: [{ retain: 1, attributes: { bold: true } }]
}
}), /操作无效/);
});
test('画布笔记保存分页画布、PDF 底版并支持类型筛选和文本搜索', () => {
const s = freshStore();
const assetId = `pdf_${'a'.repeat(64)}`;
const canvasContent = {
version: 2,
flow: {
version: 1,
ops: [
{ insert: '全局文本关键词', attributes: { bold: true } },
{ insert: '\n' },
{ insert: { canvasPageBreak: 'pg_two' } }
]
},
pages: [
{
id: 'pg_one',
width: 794,
height: 1123,
background: { type: 'template', template: 'grid' },
objects: [{
type: 'IText',
canvasKind: 'text',
text: '画布关键词',
left: 10,
top: 20,
fill: '#222222',
fontSize: 18
}]
},
{
id: 'pg_two',
width: 612,
height: 792,
background: { type: 'pdf', assetId, page: 2 },
objects: []
}
]
};
const note = s.addStandaloneNote({
noteType: 'canvas',
canvasContent
});
assert.strictEqual(note.noteType, 'canvas');
assert.strictEqual(note.text, '全局文本关键词\n画布关键词');
assert.deepStrictEqual(note.canvasContent, canvasContent);
assert.deepStrictEqual(s.noteAssetIds(), [assetId]);
assert.strictEqual(s.listNotes({ query: '画布关键词' })[0].id, note.id);
assert.strictEqual(s.listNotes({ query: '全局文本关键词' })[0].id, note.id);
assert.deepStrictEqual(s.listNotes({ noteType: 'canvas' }).map((item) => item.id), [note.id]);
assert.deepStrictEqual(s.listNotes({ noteType: 'reading' }), []);
assert.strictEqual(s.removeNote(s.STANDALONE_ENTRY_ID, note.id), true);
assert.deepStrictEqual(s.noteAssetIds(), []);
});
test('读书笔记和画布笔记创建后保持独立且类型不可更改', () => {
const s = freshStore();
const canvasContent = {
version: 1,
pages: [{
id: 'pg_type',
width: 794,
height: 1123,
background: { type: 'template', template: 'blank' },
objects: []
}]
};
assert.throws(() => s.addNote('e1', {
noteType: 'unknown',
text: '正文'
}), /笔记类型无效/);
assert.throws(() => s.addNote('e1', {
noteType: 'reading',
richContent: { version: 2, ops: [{ insert: '正文\n' }] },
canvasContent
}), /读书笔记不能包含画布内容/);
assert.throws(() => s.addNote('e1', {
noteType: 'canvas',
richContent: { version: 2, ops: [{ insert: '正文\n' }] },
canvasContent
}), /画布笔记不能包含富文本内容/);
const reading = s.addNote('e1', { noteType: 'reading', text: '正文' });
assert.throws(() => s.updateNote('e1', reading.id, { noteType: 'canvas' }), /不能更改/);
const canvas = s.addNote('e1', { noteType: 'canvas', canvasContent });
assert.throws(() => s.updateNote('e1', canvas.id, {
richContent: { version: 2, ops: [{ insert: '正文\n' }] }
}), /不能包含富文本内容/);
});
test('v4 混合笔记迁移为画布笔记并保留两类旧内容', () => {
const root = tmp();
fs.writeFileSync(path.join(root, 'reader.json'), JSON.stringify({
version: 4,
collections: [],
entries: {
e1: {
notes: [{
id: 'nt_legacy_mixed',
title: '旧混合笔记',
richContent: { version: 2, ops: [{ insert: '旧正文\n' }] },
canvasContent: {
version: 1,
pages: [{
id: 'pg_legacy',
width: 794,
height: 1123,
background: { type: 'template', template: 'grid' },
objects: []
}]
},
source: 'manual',
tags: [],
createdAt: 1,
updatedAt: 1
}, {
id: 'nt_legacy_blank_canvas',
richContent: { version: 2, ops: [{ insert: '仅正文\n' }] },
canvasContent: {
version: 1,
pages: [{
id: 'pg_legacy_blank',
width: 794,
height: 1123,
background: { type: 'template', template: 'blank' },
objects: []
}]
},
source: 'manual',
tags: [],
createdAt: 2,
updatedAt: 2
}]
}
}
}));
const s = storeAt(root);
const notes = s.getState('e1').notes;
const note = notes.find((item) => item.id === 'nt_legacy_mixed');
assert.strictEqual(note.noteType, 'canvas');
assert.strictEqual(note.richContent.ops[0].insert, '旧正文\n');
assert.strictEqual(note.canvasContent.version, 2);
assert.strictEqual(note.canvasContent.pages[0].background.template, 'grid');
const updated = s.updateNote('e1', note.id, {
noteType: 'canvas',
canvasContent: note.canvasContent
});
assert.strictEqual(updated.richContent.ops[0].insert, '旧正文\n');
const blankCanvas = notes.find((item) => item.id === 'nt_legacy_blank_canvas');
assert.strictEqual(blankCanvas.noteType, 'reading');
assert.strictEqual(blankCanvas.richContent.ops[0].insert, '仅正文\n');
assert.strictEqual(blankCanvas.canvasContent.pages[0].background.template, 'blank');
});
test('画布笔记拒绝未知对象、主动属性、远程图片和非法 PDF 引用', () => {
const s = freshStore();
const page = (object, background = { type: 'template', template: 'blank' }) => ({
version: 1,
pages: [{
id: 'pg_safe',
width: 794,
height: 1123,
background,
objects: object ? [object] : []
}]
});
assert.throws(() => s.addNote('e1', {
canvasContent: page({ type: 'Circle', canvasKind: 'circle' })
}), /对象类型无效/);
assert.throws(() => s.addNote('e1', {
canvasContent: page({
type: 'Path',
canvasKind: 'pen',
path: [['M', 0, 0], ['L', 1, 1]],
clipPath: {}
})
}), /不支持的属性/);
assert.throws(() => s.addNote('e1', {
canvasContent: page({
type: 'Path',
canvasKind: 'pen',
path: [['M', 0, 0]],
arbitraryPayload: { type: 'Image' }
})
}), /不支持的属性/);
assert.throws(() => s.addNote('e1', {
canvasContent: page({
type: 'Path',
canvasKind: 'pen',
path: [['M', 0, 0]],
scaleX: 1000
})
}), /缩放无效/);
assert.throws(() => s.addNote('e1', {
canvasContent: page({
type: 'Image',
canvasKind: 'image',
src: 'https://example.com/image.png'
})
}), /图片格式无效/);
assert.throws(() => s.addNote('e1', {
canvasContent: page(null, { type: 'pdf', assetId: '../outside', page: 1 })
}), /PDF 底版资源无效/);
const flowPage = {
version: 2,
pages: [{
id: 'pg_flow',
width: 794,
height: 1123,
background: { type: 'template', template: 'blank' },
objects: []
}, {
id: 'pg_flow_two',
width: 794,
height: 1123,
background: { type: 'template', template: 'blank' },
objects: [],
flowAuto: true
}]
};
assert.throws(() => s.addNote('e1', {
noteType: 'canvas',
canvasContent: {
...flowPage,
flow: { version: 1, ops: [{ insert: { canvasPageBreak: '../outside' } }] }
}
}), /分页符无效/);
assert.throws(() => s.addNote('e1', {
noteType: 'canvas',
canvasContent: {
...flowPage,
flow: { version: 1, ops: [{ insert: { image: 'https://example.com/a.png' } }] }
}
}), /嵌入内容无效/);
const flowNote = s.addNote('e1', {
noteType: 'canvas',
canvasContent: {
...flowPage,
flow: {
version: 1,
ops: [
{ insert: '跨页正文\n', attributes: { header: 1 } },
{ insert: { canvasPageBreak: 'pg_flow_two' } },
{ insert: '第二页正文\n' }
]
}
}
});
assert.strictEqual(flowNote.text, '跨页正文\n第二页正文');
assert.strictEqual(flowNote.canvasContent.pages[1].flowAuto, true);
});
test('笔记可更新且必需保留正文或引用', () => {
const s = freshStore();
const note = s.addNote('e1', { text: '原文', source: 'manual' });
const updated = s.updateNote('e1', note.id, {
text: '',
quote: '新引用',
source: 'ai',
aiTask: '总结',
tags: ['AI'],
pinned: true
});
assert.strictEqual(updated.quote, '新引用');
assert.strictEqual(updated.source, 'ai');
assert.strictEqual(updated.kind, 'ai');
assert.strictEqual(updated.aiTask, '总结');
assert.strictEqual(updated.at, updated.updatedAt);
assert.throws(
() => s.updateNote('e1', note.id, { quote: '', text: '' }),
/内容为空/
);
assert.strictEqual(s.getState('e1').notes[0].quote, '新引用', '失败更新必须回滚');
assert.strictEqual(s.updateNote('e1', 'nt_missing', { title: 'x' }), null);
});
test('笔记本名称不区分大小写去重,删除后笔记移入未分类', () => {
const s = freshStore();
const collection = s.addCollection({ name: 'Research' });
assert.throws(() => s.addCollection({ name: ' research ' }), /已存在/);
const note = s.addNote('e1', { text: '归档笔记', collectionId: collection.id });
assert.strictEqual(s.listCollections()[0].name, 'Research');
assert.strictEqual(s.updateCollection(collection.id, { name: 'Inbox' }).name, 'Inbox');
assert.strictEqual(s.removeCollection(collection.id), true);
assert.strictEqual(s.listCollections().length, 0);
assert.strictEqual(s.getState('e1').notes[0].id, note.id);
assert.strictEqual(s.getState('e1').notes[0].collectionId, null);
assert.strictEqual(s.listNotes({ collectionId: null }).length, 1);
});
test('聚合笔记按置顶与更新时间排序并支持全部筛选', () => {
const s = freshStore();
const work = s.addCollection('Work');
s.setBookSnapshot('book-a', { title: 'Alpha Handbook', authors: ['A. One'] });
s.setBookSnapshot('book-b', { title: 'Beta Notes', authors: ['B. Two'] });
const first = s.addNote('book-a', {
title: 'Ordinary',
text: 'needle in text',
source: 'manual',
tags: ['Blue'],
collectionId: work.id
});
const pinned = s.addNote('book-b', {
quote: 'selected passage',
source: 'selection',
tags: ['Green'],
pinned: true
});
s.updateNote('book-a', first.id, { context: 'changed' });
const all = s.listNotes();
assert.deepStrictEqual(all.map((note) => note.id), [pinned.id, first.id]);
assert.strictEqual(all[0].entryId, 'book-b');
assert.strictEqual(all[0].bookSnapshot.title, 'Beta Notes');
assert.deepStrictEqual(s.listNotes({ entryId: 'book-a' }).map((n) => n.id), [first.id]);
assert.deepStrictEqual(s.listNotes({ collectionId: work.id }).map((n) => n.id), [first.id]);
assert.deepStrictEqual(s.listNotes({ source: 'selection' }).map((n) => n.id), [pinned.id]);
assert.deepStrictEqual(s.listNotes({ tag: 'blue' }).map((n) => n.id), [first.id]);
assert.deepStrictEqual(s.listNotes({ query: 'alpha hand' }).map((n) => n.id), [first.id]);
assert.deepStrictEqual(s.listNotes({ query: 'NEEDLE' }).map((n) => n.id), [first.id]);
assert.deepStrictEqual(s.getNoteCounts(), { 'book-a': 1, 'book-b': 1 });
all[0].bookSnapshot.title = '污染';
all[0].tags.push('污染');
assert.strictEqual(s.listNotes()[0].bookSnapshot.title, 'Beta Notes');
assert.deepStrictEqual(s.listNotes()[0].tags, ['Green']);
});
test('无关联笔记独立持久化且不计入书库卡片笔记数', () => {
const s = freshStore();
const note = s.addStandaloneNote({
title: '独立想法',
text: '不关联任何书籍',
source: 'manual',
tags: ['随想']
});
const listed = s.listNotes().find((item) => item.id === note.id);
assert.strictEqual(listed.entryId, s.STANDALONE_ENTRY_ID);
assert.strictEqual(listed.associated, false);
assert.strictEqual(listed.bookSnapshot, null);
assert.deepStrictEqual(s.getNoteCounts(), {});
assert.strictEqual(
s.updateNote(listed.entryId, note.id, { text: '已编辑' }).text,
'已编辑'
);
assert.strictEqual(s.removeNote(listed.entryId, note.id), true);
});
test('旧版 reader.json 安全迁移为 v6 并保留阅读数据', () => {
const d = tmp();
const file = path.join(d, 'reader.json');
const legacy = {
entries: {
e1: {
progress: { locator: { page: 8 }, percent: 0.4, at: 10 },
bookmarks: [{ id: 'bm_old', locator: { page: 8 }, at: 11 }],
notes: [{
id: 'nt_old',
text: '旧笔记',
quote: '旧引用',
kind: 'ai',
at: 123,
locator: { page: 8 }
}]
}
}
};
fs.writeFileSync(file, JSON.stringify(legacy), 'utf8');
let s = storeAt(d);
const state = s.getState('e1');
assert.deepStrictEqual(state.progress, legacy.entries.e1.progress);
assert.deepStrictEqual(state.bookmarks, legacy.entries.e1.bookmarks);
assert.strictEqual(state.notes[0].id, 'nt_old');
assert.strictEqual(state.notes[0].source, 'ai');
assert.strictEqual(state.notes[0].createdAt, 123);
assert.strictEqual(state.notes[0].updatedAt, 123);
assert.strictEqual(state.notes[0].collectionId, null);
const migrated = JSON.parse(fs.readFileSync(file, 'utf8'));
assert.strictEqual(migrated.version, 6);
assert.deepStrictEqual(migrated.collections, []);
const bytes = fs.readFileSync(file, 'utf8');
s = storeAt(d);
assert.strictEqual(s.getState('e1').notes.length, 1);
assert.strictEqual(fs.readFileSync(file, 'utf8'), bytes, 'v6 再加载不应重复迁移');
});
test('字段限制、来源校验和安全 ID 校验生效', () => {
const s = freshStore();
assert.throws(() => s.getState('../reader'), /ID无效/);
assert.throws(
() => s.setProgress('e1', 'toString', { kind: 'pdf', page: 1 }, 0.1),
/文档标识无效/
);
assert.throws(() => s.addNote('e1', { text: 'x', source: 'robot' }), /来源无效/);
assert.throws(
() => s.addNote('e1', { text: 'x', collectionId: 'col_missing' }),
/不存在/
);
const note = s.addNote('e1', {
text: 'x'.repeat(25000),
tags: Array.from({ length: 40 }, (_, i) => `tag-${i}`)
});
assert.strictEqual(note.text.length, 20000);
assert.strictEqual(note.tags.length, 30);
});
test('写盘失败时内存和磁盘状态都回滚', () => {
const d = tmp();
let s = storeAt(d);
s.addNote('e1', { text: '已保存' });
const file = path.join(d, 'reader.json');
const before = fs.readFileSync(file, 'utf8');
const originalRename = fs.renameSync;
fs.renameSync = (from, to) => {
if (from === `${file}.tmp` && to === file) throw new Error('模拟写盘失败');
return originalRename(from, to);
};
try {
assert.throws(() => s.addNote('e1', { text: '不应保存' }), /模拟写盘失败/);
} finally {
fs.renameSync = originalRename;
}
assert.strictEqual(s.getState('e1').notes.length, 1);
assert.strictEqual(fs.readFileSync(file, 'utf8'), before);
s = storeAt(d);
assert.strictEqual(s.getState('e1').notes.length, 1);
});
// --- reader/ai-config ---
function freshCfg(storage = fakeStorage()) {
delete require.cache[cfgPath];
const c = require(cfgPath);
c.init(tmp(), storage);
return c;
}
test('AI Key 加密落盘,磁盘无明文', () => {
const d = tmp();
delete require.cache[cfgPath];
const c = require(cfgPath);
c.init(d, fakeStorage());
c.save({ baseUrl: 'https://api.deepseek.com/v1', model: 'deepseek-chat', apiKey: 'sk-SECRET-123' });
for (const f of fs.readdirSync(d)) {
const content = fs.readFileSync(path.join(d, f)).toString();
assert.ok(!content.includes('sk-SECRET-123'), `${f} 出现明文 Key`);
}
assert.strictEqual(c.get().apiKey, 'sk-SECRET-123');
assert.strictEqual(c.status().hasKey, true);
});
test('只改模型时不传 apiKey,不会清掉已存的 Key', () => {
const c = freshCfg();
c.save({
protocol: 'anthropic',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o-mini',
apiKey: 'sk-keep',
vision: true
});
c.save({ baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o' });
assert.strictEqual(c.get().apiKey, 'sk-keep');
assert.strictEqual(c.get().model, 'gpt-4o');
assert.strictEqual(c.status().protocol, 'anthropic');
assert.strictEqual(c.status().vision, true);
});
test('AI 接口类型显式持久化,旧配置默认使用 Chat Completions', () => {
const d = tmp();
fs.writeFileSync(path.join(d, 'ai-config.json'), JSON.stringify({
baseUrl: 'https://api.openai.com/v1',
model: 'legacy'
}));
delete require.cache[cfgPath];
const c = require(cfgPath);
c.init(d, fakeStorage());
assert.strictEqual(c.status().protocol, 'chat-completions');
c.save({
protocol: 'openai-responses',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4.1'
});
assert.strictEqual(c.status().protocol, 'openai-responses');
assert.strictEqual(JSON.parse(fs.readFileSync(path.join(d, 'ai-config.json'), 'utf8')).protocol, 'openai-responses');
assert.throws(
() => c.save({ protocol: 'unknown', baseUrl: 'https://api.openai.com/v1', model: 'm' }),
/接口类型/
);
});
test('AI 状态区分模型已配置、缺少 Key 与 Key 无法读取', () => {
const d = tmp();
delete require.cache[cfgPath];
let c = require(cfgPath);
c.init(d, fakeStorage());
let status = c.status();
assert.strictEqual(status.modelConfigured, false);
assert.strictEqual(status.ready, false);
assert.strictEqual(status.keyState, 'missing');
c.save({
protocol: 'anthropic',
baseUrl: 'https://api.anthropic.com/v1',
model: 'claude-sonnet',
vision: true
});
status = c.status();
assert.strictEqual(status.modelConfigured, true);
assert.strictEqual(status.ready, false);
assert.strictEqual(status.keyState, 'missing');
c.save({
protocol: 'anthropic',
baseUrl: 'https://api.anthropic.com/v1',
model: 'claude-sonnet',
apiKey: 'sk-anthropic'
});
assert.strictEqual(c.status().ready, true);
delete require.cache[cfgPath];
c = require(cfgPath);
c.init(d, {
isEncryptionAvailable: () => true,
decryptString: () => { throw new Error('cannot decrypt'); }
});
status = c.status();
assert.strictEqual(status.modelConfigured, true);
assert.strictEqual(status.hasKey, false);
assert.strictEqual(status.ready, false);
assert.strictEqual(status.keyState, 'unreadable');
});
test('图像输入能力必须显式配置并持久化', () => {
const d = tmp();
delete require.cache[cfgPath];
const c = require(cfgPath);
c.init(d, fakeStorage());
c.save({ baseUrl: 'https://api.openai.com/v1', model: 'm' });
assert.strictEqual(c.status().vision, false);
c.save({ baseUrl: 'https://api.openai.com/v1', model: 'm', vision: true });
assert.strictEqual(c.status().vision, true);
assert.strictEqual(JSON.parse(fs.readFileSync(path.join(d, 'ai-config.json'), 'utf8')).vision, true);
});
test('图像输入能力拒绝配置文件中的非布尔真值', () => {
const d = tmp();
fs.writeFileSync(path.join(d, 'ai-config.json'), JSON.stringify({
baseUrl: 'https://api.openai.com/v1',
model: 'm',
vision: 'true'
}));
delete require.cache[cfgPath];
const c = require(cfgPath);
c.init(d, fakeStorage());
assert.strictEqual(c.status().vision, false);
assert.strictEqual(c.get().vision, false);
});
test('显式传空字符串才清除 Key', () => {
const c = freshCfg();
c.save({ baseUrl: 'https://api.openai.com/v1', model: 'm', apiKey: 'sk-x' });
c.save({ baseUrl: 'https://api.openai.com/v1', model: 'm', apiKey: '' });
assert.strictEqual(c.status().hasKey, false);
});
test('切换 AI 接口类型或服务来源时不会复用旧 API Key', () => {
const c = freshCfg();
c.save({
protocol: 'chat-completions',
baseUrl: 'https://api.openai.com/v1',
model: 'm',
apiKey: 'sk-openai'
});
c.save({
protocol: 'anthropic',
baseUrl: 'https://api.anthropic.com/v1',
model: 'claude'
});
assert.strictEqual(c.status().hasKey, false);
c.save({
protocol: 'anthropic',
baseUrl: 'https://api.anthropic.com/v1',
model: 'claude',
apiKey: 'sk-anthropic'
});
c.save({
protocol: 'anthropic',
baseUrl: 'https://proxy.example.com/v1',
model: 'claude'
});
assert.strictEqual(c.status().hasKey, false);
});
test('非法接口地址被拒绝', () => {
const c = freshCfg();
assert.throws(() => c.save({ baseUrl: 'ftp://x/v1', model: 'm' }), /http/);
assert.throws(() => c.save({ baseUrl: '', model: 'm' }), /不能为空/);
assert.throws(() => c.save({ baseUrl: 'https://a/v1', model: '' }), /模型/);
assert.throws(() => c.save({ baseUrl: 'https://a/v1#fragment', model: 'm' }), /片段标识/);
});
test('本地端点识别为无需 Key', () => {
const c = freshCfg();
c.save({ baseUrl: 'http://127.0.0.1:11434/v1', model: 'qwen' });
assert.strictEqual(c.status().isLocal, true);
c.save({ baseUrl: 'https://api.openai.com/v1', model: 'gpt' });
assert.strictEqual(c.status().isLocal, false);
});
test('加密不可用时不落盘 Key', () => {
const d = tmp();
delete require.cache[cfgPath];
const c = require(cfgPath);
c.init(d, fakeStorage(false));
c.save({ baseUrl: 'https://a.com/v1', model: 'm', apiKey: 'sk-plain' });
for (const f of fs.readdirSync(d)) {
assert.ok(!fs.readFileSync(path.join(d, f)).toString().includes('sk-plain'), `${f} 落了明文`);
}
assert.strictEqual(c.get().apiKey, 'sk-plain');
assert.strictEqual(c.status().persistent, false);
});
test('baseUrl 末尾斜杠被规范化', () => {
const c = freshCfg();
c.save({ baseUrl: 'https://api.openai.com/v1///', model: 'm' });
assert.strictEqual(c.status().baseUrl, 'https://api.openai.com/v1');
});