Files
peoplelib/src/reader/store.js
T
lofyerandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> 3ccd044527 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>
2026-08-03 12:13:02 +08:00

1368 lines
47 KiB
JavaScript

// 阅读状态持久化:阅读进度、书签、笔记。
// 单独存 reader.json,不写进 library.json —— 书库条目可能被移除重建,
// 而阅读痕迹是用户产出的数据,不该跟着条目生命周期一起消失。
const fs = require('fs');
const path = require('path');
const VERSION = 6;
const STANDALONE_ENTRY_ID = 'system:standalone-notes';
const LIMITS = {
id: 160,
title: 500,
text: 20000,
quote: 10000,
context: 20000,
aiTask: 500,
documentKey: 500,
tag: 100,
tags: 30,
collectionName: 200,
authors: 50,
author: 300,
locatorJson: 50000,
richBlocks: 500,
richOps: 5000,
richJson: 12 * 1024 * 1024,
richImages: 12,
richImageBytes: 2 * 1024 * 1024,
richImageTotalBytes: 8 * 1024 * 1024,
imageAlt: 500,
canvasPages: 50,
canvasObjectsPerPage: 500,
canvasObjects: 2500,
canvasJson: 12 * 1024 * 1024,
canvasObjectJson: 1024 * 1024,
canvasImageBytes: 2 * 1024 * 1024,
canvasImageTotalBytes: 20 * 1024 * 1024,
canvasDimension: 3000
};
const SOURCES = new Set(['manual', 'selection', 'ai']);
const NOTE_TYPES = new Set(['reading', 'canvas']);
const CANVAS_TEMPLATES = new Set(['blank', 'lined', 'grid', 'dots']);
const CANVAS_TYPES = {
pen: 'Path',
highlight: 'Path',
rectangle: 'Rect',
text: 'IText',
image: 'Image'
};
const CANVAS_COMMON_OBJECT_KEYS = new Set([
'type', 'version', 'canvasKind', 'originX', 'originY', 'left', 'top', 'width',
'height', 'fill', 'stroke', 'strokeWidth', 'strokeDashArray', 'strokeLineCap',
'strokeDashOffset', 'strokeLineJoin', 'strokeUniform', 'strokeMiterLimit',
'scaleX', 'scaleY', 'angle', 'flipX', 'flipY', 'opacity', 'visible',
'backgroundColor', 'fillRule', 'paintFirst', 'globalCompositeOperation',
'skewX', 'skewY'
]);
const CANVAS_KIND_OBJECT_KEYS = {
pen: new Set(['path']),
highlight: new Set(['path']),
rectangle: new Set(['rx', 'ry']),
text: new Set([
'fontSize', 'fontWeight', 'fontFamily', 'fontStyle', 'lineHeight', 'text',
'charSpacing', 'textAlign', 'styles', 'pathStartOffset', 'pathSide',
'pathAlign', 'underline', 'overline', 'linethrough', 'textBackgroundColor',
'direction', 'textDecorationThickness', 'textDecorationColor'
]),
image: new Set(['src', 'crossOrigin', 'cropX', 'cropY'])
};
const IMAGE_SIGNATURES = {
'image/jpeg': (bytes) => bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff,
'image/png': (bytes) => bytes.length >= 8
&& bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47
&& bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a,
'image/gif': (bytes) => bytes.length >= 6
&& ['GIF89a', 'GIF87a'].includes(String.fromCharCode(...bytes.subarray(0, 6))),
'image/webp': (bytes) => bytes.length >= 12
&& String.fromCharCode(...bytes.subarray(0, 4)) === 'RIFF'
&& String.fromCharCode(...bytes.subarray(8, 12)) === 'WEBP'
};
let filePath = null;
let cache = null;
function init(userDataDir) {
filePath = path.join(userDataDir, 'reader.json');
cache = null;
}
function getFilePath() {
if (filePath) return filePath;
const home = process.env.APPDATA || process.env.HOME || process.cwd();
return path.join(home, 'PeopleLib', 'reader.json');
}
function emptyStore() {
return { version: VERSION, collections: [], entries: {} };
}
function isObject(value) {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function clone(value) {
return value == null ? value : JSON.parse(JSON.stringify(value));
}
function newId(prefix) {
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
}
function isSafeId(value) {
return typeof value === 'string' &&
value.length > 0 &&
value.length <= LIMITS.id &&
/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value) &&
value !== '.' &&
value !== '..' &&
value !== '__proto__' &&
value !== 'prototype' &&
value !== 'constructor';
}
function safeId(value, label = 'ID') {
const id = String(value == null ? '' : value);
if (!isSafeId(id)) throw new Error(`${label}无效`);
return id;
}
function limitedString(value, max) {
return String(value == null ? '' : value).slice(0, max);
}
function nullableString(value, max, label) {
if (value == null || value === '') return null;
const result = String(value);
if (/[\u0000-\u001f]/.test(result)) throw new Error(`${label}无效`);
return result.slice(0, max);
}
function jsonValue(value, label) {
if (value == null) return null;
let encoded;
try {
encoded = JSON.stringify(value);
} catch (e) {
throw new Error(`${label}必须可序列化`);
}
if (encoded === undefined || encoded.length > LIMITS.locatorJson) {
throw new Error(`${label}无效或过大`);
}
return JSON.parse(encoded);
}
function timestamp(value, fallback) {
const n = Number(value);
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
}
function normalizeSource(source, kind, useDefault = true) {
if (source == null || source === '') {
if (kind === 'ai') return 'ai';
if (kind === 'selection') return 'selection';
if (useDefault) return 'manual';
}
if (!SOURCES.has(source)) throw new Error('笔记来源无效');
return source;
}
function hasMeaningfulCanvasContent(content) {
return !!(content && (
content.pages.length > 1
|| content.flow?.ops?.some((op) => (
typeof op.insert === 'string' && op.insert.trim()
))
|| content.pages.some((page) => (
page.objects.length > 0
|| page.background.type === 'pdf'
|| (page.background.type === 'template' && page.background.template !== 'blank')
))
));
}
function normalizeNoteType(value, canvasContent) {
if (value == null || value === '') {
return hasMeaningfulCanvasContent(canvasContent) ? 'canvas' : 'reading';
}
if (!NOTE_TYPES.has(value)) throw new Error('笔记类型无效');
return value;
}
function normalizeTags(value, migrating = false) {
if (value == null) return [];
const values = Array.isArray(value) ? value : [value];
const result = [];
const seen = new Set();
const max = migrating ? values.length : LIMITS.tags;
for (const raw of values.slice(0, max)) {
let tag = String(raw == null ? '' : raw).trim();
if (!migrating) tag = tag.slice(0, LIMITS.tag);
if (!tag) continue;
const key = tag.toLocaleLowerCase();
if (seen.has(key)) continue;
seen.add(key);
result.push(tag);
}
return result;
}
function normalizedImageDataUrl(value) {
const match = /^data:(image\/(?:jpeg|png|gif|webp));base64,([A-Za-z0-9+/]*={0,2})$/.exec(
String(value || '')
);
if (!match || !IMAGE_SIGNATURES[match[1]]) throw new Error('笔记图片格式无效');
const bytes = Buffer.from(match[2], 'base64');
if (!bytes.length || !IMAGE_SIGNATURES[match[1]](bytes)) {
throw new Error('笔记图片内容无效');
}
return {
dataUrl: `data:${match[1]};base64,${match[2]}`,
bytes: bytes.length
};
}
function legacyRichToDelta(value) {
if (!isObject(value) || value.version !== 1 || !Array.isArray(value.blocks)) {
return value;
}
if (value.blocks.length > LIMITS.richBlocks) throw new Error('富文本笔记内容过多');
const ops = [];
for (const block of value.blocks) {
if (!isObject(block)) throw new Error('富文本笔记块无效');
if (block.type === 'image') {
ops.push({ insert: { image: block.dataUrl } });
continue;
}
if (block.type !== 'text' || !Array.isArray(block.runs)) {
throw new Error('富文本笔记段落无效');
}
for (const run of block.runs) {
if (!isObject(run)) throw new Error('富文本笔记文字无效');
const attributes = {
...(run.bold === true ? { bold: true } : {}),
...(run.italic === true ? { italic: true } : {}),
...(run.underline === true ? { underline: true } : {}),
...(run.strike === true ? { strike: true } : {}),
...(run.code === true ? { code: true } : {})
};
const insert = String(run.text == null ? '' : run.text);
if (insert) ops.push({
insert,
...(Object.keys(attributes).length ? { attributes } : {})
});
}
const lineAttributes = block.style === 'heading1'
? { header: 1 }
: block.style === 'heading2'
? { header: 2 }
: block.style === 'quote'
? { blockquote: true }
: block.style === 'bullet'
? { list: 'bullet' }
: block.style === 'number'
? { list: 'ordered' }
: block.style === 'code'
? { 'code-block': 'plain' }
: null;
ops.push({
insert: '\n',
...(lineAttributes ? { attributes: lineAttributes } : {})
});
}
return { version: 2, ops };
}
function normalizeRichAttributes(value) {
if (value == null) return null;
if (!isObject(value)) throw new Error('富文本笔记格式属性无效');
const result = {};
const allowed = new Set([
'bold', 'italic', 'underline', 'strike', 'code',
'header', 'blockquote', 'code-block', 'list'
]);
for (const key of Object.keys(value)) {
if (!allowed.has(key)) throw new Error('富文本笔记包含不支持的格式');
}
for (const key of ['bold', 'italic', 'underline', 'strike', 'code', 'blockquote']) {
if (value[key] === true) result[key] = true;
else if (value[key] != null && value[key] !== false) throw new Error('富文本笔记格式属性无效');
}
if (value['code-block'] != null && value['code-block'] !== false) {
if (value['code-block'] !== true && value['code-block'] !== 'plain') {
throw new Error('富文本笔记代码块格式无效');
}
result['code-block'] = 'plain';
}
if (value.header != null) {
if (value.header !== 1 && value.header !== 2) throw new Error('富文本笔记标题格式无效');
result.header = value.header;
}
if (value.list != null) {
if (value.list !== 'ordered' && value.list !== 'bullet') {
throw new Error('富文本笔记列表格式无效');
}
result.list = value.list;
}
return Object.keys(result).length ? result : null;
}
function normalizeRichContent(input) {
if (input == null) return null;
const value = legacyRichToDelta(input);
if (!isObject(value) || value.version !== 2 || !Array.isArray(value.ops)) {
throw new Error('富文本笔记格式无效');
}
if (value.ops.length > LIMITS.richOps) throw new Error('富文本笔记内容过多');
const result = { version: 2, ops: [] };
let textLength = 0;
let imageCount = 0;
let imageBytes = 0;
for (const op of value.ops) {
if (!isObject(op) || !Object.prototype.hasOwnProperty.call(op, 'insert')) {
throw new Error('富文本笔记操作无效');
}
const attributes = normalizeRichAttributes(op.attributes);
if (typeof op.insert === 'string') {
textLength += op.insert.length;
if (textLength > LIMITS.text) throw new Error('富文本笔记文字过多');
if (op.insert) result.ops.push({
insert: op.insert,
...(attributes ? { attributes } : {})
});
continue;
}
if (!isObject(op.insert)
|| Object.keys(op.insert).length !== 1
|| typeof op.insert.image !== 'string'
|| attributes) {
throw new Error('富文本笔记嵌入内容无效');
}
const image = normalizedImageDataUrl(op.insert.image);
imageCount++;
imageBytes += image.bytes;
if (imageCount > LIMITS.richImages
|| image.bytes > LIMITS.richImageBytes
|| imageBytes > LIMITS.richImageTotalBytes) {
throw new Error('笔记图片过多或过大');
}
result.ops.push({ insert: { image: image.dataUrl } });
}
if (JSON.stringify(result).length > LIMITS.richJson) throw new Error('富文本笔记过大');
return hasRichContent(result) ? result : null;
}
function richPlainText(content) {
if (!content) return '';
return content.ops
.filter((op) => typeof op.insert === 'string')
.map((op) => op.insert)
.join('')
.replace(/\n$/, '')
.slice(0, LIMITS.text);
}
function hasRichContent(content) {
return !!(content && content.ops.some((op) => (
isObject(op.insert) && typeof op.insert.image === 'string'
|| typeof op.insert === 'string' && op.insert.trim()
)));
}
function validateCanvasJson(value, depth = 0) {
if (depth > 12) throw new Error('画布对象结构过深');
if (value == null || typeof value === 'boolean' || typeof value === 'string') {
if (typeof value === 'string' && value.length > LIMITS.canvasObjectJson) {
throw new Error('画布对象文字过大');
}
return;
}
if (typeof value === 'number') {
if (!Number.isFinite(value) || Math.abs(value) > 10000000) {
throw new Error('画布对象数值无效');
}
return;
}
if (Array.isArray(value)) {
if (value.length > 20000) throw new Error('画布对象数组过大');
value.forEach((item) => validateCanvasJson(item, depth + 1));
return;
}
if (!isObject(value)) throw new Error('画布对象格式无效');
for (const [key, item] of Object.entries(value)) {
if (['clipPath', 'filters', 'shadow', 'backgroundImage', 'overlayImage'].includes(key)) {
throw new Error('画布对象包含不支持的属性');
}
if (key === '__proto__' || key === 'prototype' || key === 'constructor') {
throw new Error('画布对象属性无效');
}
validateCanvasJson(item, depth + 1);
}
}
function normalizeCanvasObject(value, imageTotals) {
if (!isObject(value) || CANVAS_TYPES[value.canvasKind] !== value.type) {
throw new Error('画布对象类型无效');
}
const kindKeys = CANVAS_KIND_OBJECT_KEYS[value.canvasKind];
for (const key of Object.keys(value)) {
if (!CANVAS_COMMON_OBJECT_KEYS.has(key) && !kindKeys.has(key)) {
throw new Error('画布对象包含不支持的属性');
}
}
for (const key of ['fill', 'stroke', 'backgroundColor', 'textBackgroundColor']) {
if (value[key] != null && typeof value[key] !== 'string') {
throw new Error('画布对象颜色无效');
}
}
for (const key of ['scaleX', 'scaleY']) {
if (value[key] != null
&& (!Number.isFinite(value[key]) || Math.abs(value[key]) > 100)) {
throw new Error('画布对象缩放无效');
}
}
if (value.opacity != null
&& (!Number.isFinite(value.opacity) || value.opacity < 0 || value.opacity > 1)) {
throw new Error('画布对象透明度无效');
}
if (value.strokeWidth != null
&& (!Number.isFinite(value.strokeWidth) || value.strokeWidth < 0 || value.strokeWidth > 500)) {
throw new Error('画布对象线宽无效');
}
if (value.canvasKind === 'text'
&& (typeof value.text !== 'string' || value.text.length > LIMITS.text)) {
throw new Error('画布文字无效');
}
if ((value.canvasKind === 'pen' || value.canvasKind === 'highlight')
&& (!Array.isArray(value.path) || value.path.length > 20000)) {
throw new Error('画布路径无效');
}
const encoded = JSON.stringify(value);
if (Buffer.byteLength(encoded, 'utf8') > LIMITS.canvasObjectJson) {
throw new Error('单个画布对象过大');
}
const clean = JSON.parse(encoded);
validateCanvasJson(clean);
if (clean.canvasKind === 'image') {
const image = normalizedImageDataUrl(clean.src);
imageTotals.bytes += image.bytes;
imageTotals.count++;
if (image.bytes > LIMITS.canvasImageBytes
|| imageTotals.bytes > LIMITS.canvasImageTotalBytes
|| imageTotals.count > 50) {
throw new Error('画布图片过多或过大');
}
clean.src = image.dataUrl;
} else if (Object.prototype.hasOwnProperty.call(clean, 'src')) {
throw new Error('画布对象资源无效');
}
return clean;
}
function normalizeCanvasFlow(value, pageIds, firstPageId) {
if (value == null) return null;
if (!isObject(value) || value.version !== 1 || !Array.isArray(value.ops)) {
throw new Error('画布全局文本格式无效');
}
if (value.ops.length > LIMITS.richOps) throw new Error('画布全局文本内容过多');
const result = { version: 1, ops: [] };
const breakIds = new Set();
let textLength = 0;
for (const op of value.ops) {
if (!isObject(op) || !Object.prototype.hasOwnProperty.call(op, 'insert')) {
throw new Error('画布全局文本操作无效');
}
if (typeof op.insert === 'string') {
textLength += op.insert.length;
if (textLength > LIMITS.text) throw new Error('画布全局文本文字过多');
const attributes = normalizeRichAttributes(op.attributes);
if (op.insert) result.ops.push({
insert: op.insert,
...(attributes ? { attributes } : {})
});
continue;
}
if (op.attributes != null
|| !isObject(op.insert)
|| Object.keys(op.insert).length !== 1
|| !Object.prototype.hasOwnProperty.call(op.insert, 'canvasPageBreak')) {
throw new Error('画布全局文本嵌入内容无效');
}
const pageId = String(op.insert.canvasPageBreak || '');
if (!isSafeId(pageId)
|| pageId === firstPageId
|| !pageIds.has(pageId)
|| breakIds.has(pageId)) {
throw new Error('画布全局文本分页符无效');
}
breakIds.add(pageId);
result.ops.push({ insert: { canvasPageBreak: pageId } });
}
if (JSON.stringify(result).length > LIMITS.richJson) throw new Error('画布全局文本过大');
const meaningful = result.ops.some((op) => (
typeof op.insert === 'string' ? op.insert.trim() : !!op.insert.canvasPageBreak
));
return meaningful ? result : null;
}
function normalizeCanvasContent(value) {
if (value == null) return null;
if (!isObject(value)
|| (value.version !== 1 && value.version !== 2)
|| !Array.isArray(value.pages)) {
throw new Error('画布笔记格式无效');
}
if (!value.pages.length || value.pages.length > LIMITS.canvasPages) {
throw new Error('画布笔记页数无效');
}
const result = { version: 2, pages: [] };
const ids = new Set();
const imageTotals = { count: 0, bytes: 0 };
let objectCount = 0;
for (const rawPage of value.pages) {
if (!isObject(rawPage) || !Array.isArray(rawPage.objects)) {
throw new Error('画布笔记页面无效');
}
if (rawPage.flowAuto != null && rawPage.flowAuto !== true && rawPage.flowAuto !== false) {
throw new Error('画布笔记自动分页标记无效');
}
let id = String(rawPage.id || '');
if (!isSafeId(id) || ids.has(id)) throw new Error('画布笔记页面 ID 无效');
ids.add(id);
const width = Number(rawPage.width);
const height = Number(rawPage.height);
if (!Number.isFinite(width) || !Number.isFinite(height)
|| width < 200 || height < 200
|| width > LIMITS.canvasDimension || height > LIMITS.canvasDimension) {
throw new Error('画布笔记页面尺寸无效');
}
if (rawPage.objects.length > LIMITS.canvasObjectsPerPage) {
throw new Error('当前画布页面对象过多');
}
objectCount += rawPage.objects.length;
if (objectCount > LIMITS.canvasObjects) throw new Error('画布笔记对象过多');
const background = rawPage.background;
let cleanBackground;
if (isObject(background) && background.type === 'template') {
if (!CANVAS_TEMPLATES.has(background.template)) throw new Error('画布纸张模板无效');
cleanBackground = { type: 'template', template: background.template };
} else if (isObject(background) && background.type === 'pdf') {
const assetId = String(background.assetId || '');
if (!/^pdf_[a-f0-9]{64}$/.test(assetId)) throw new Error('画布 PDF 底版资源无效');
const page = Number(background.page);
if (!Number.isInteger(page) || page < 1 || page > 100000) {
throw new Error('画布 PDF 底版页码无效');
}
cleanBackground = { type: 'pdf', assetId, page };
} else {
throw new Error('画布笔记底版无效');
}
result.pages.push({
id,
width: Math.round(width),
height: Math.round(height),
background: cleanBackground,
objects: rawPage.objects.map((object) => normalizeCanvasObject(object, imageTotals)),
...(rawPage.flowAuto === true ? { flowAuto: true } : {})
});
}
const flow = value.version === 2
? normalizeCanvasFlow(value.flow, ids, result.pages[0]?.id)
: null;
if (flow) result.flow = flow;
if (Buffer.byteLength(JSON.stringify(result), 'utf8') > LIMITS.canvasJson) {
throw new Error('画布笔记过大');
}
return result;
}
function canvasPlainText(content) {
if (!content) return '';
const flowText = content.flow?.ops
?.filter((op) => typeof op.insert === 'string')
.map((op) => op.insert)
.join('')
.replace(/\n$/, '') || '';
const objectText = content.pages
.flatMap((page) => page.objects)
.filter((object) => object.canvasKind === 'text')
.map((object) => String(object.text || '').trim())
.filter(Boolean)
.join('\n');
return [flowText, objectText]
.filter((text) => text.trim())
.join('\n')
.slice(0, LIMITS.text);
}
function notePlainText(richContent, canvasContent, fallback) {
return [
richContent ? richPlainText(richContent) : String(fallback || ''),
canvasPlainText(canvasContent)
].filter((value) => value.trim()).join('\n').slice(0, LIMITS.text);
}
function hasCanvasContent(content) {
return !!(content && content.pages.length);
}
function normalizeSnapshot(snapshot, migrating = false) {
if (snapshot == null) return null;
if (!isObject(snapshot)) throw new Error('图书快照无效');
const title = migrating
? String(snapshot.title == null ? '' : snapshot.title)
: limitedString(snapshot.title, LIMITS.title);
const rawAuthors = Array.isArray(snapshot.authors)
? snapshot.authors
: (snapshot.authors == null ? [] : [snapshot.authors]);
const authors = rawAuthors
.slice(0, migrating ? rawAuthors.length : LIMITS.authors)
.map((author) => migrating
? String(author == null ? '' : author)
: limitedString(author, LIMITS.author))
.filter(Boolean);
return { title, authors };
}
function compatibilityFields(note) {
note.kind = note.source === 'ai' ? 'ai' : 'user';
note.at = note.updatedAt;
return note;
}
function migratedNote(raw, now, collectionIds, idSet) {
const note = isObject(raw) ? raw : { text: String(raw == null ? '' : raw) };
let id = String(note.id == null ? '' : note.id);
if (!isSafeId(id) || idSet.has(id)) id = newId('nt');
idSet.add(id);
const createdAt = timestamp(note.createdAt, timestamp(note.at, now));
const updatedAt = timestamp(note.updatedAt, timestamp(note.at, createdAt));
let collectionId = note.collectionId == null ? null : String(note.collectionId);
if (collectionId != null && !collectionIds.has(collectionId)) collectionId = null;
const source = SOURCES.has(note.source)
? note.source
: (note.kind === 'ai' ? 'ai' : note.kind === 'selection' ? 'selection' : 'manual');
let richContent = null;
try { richContent = normalizeRichContent(note.richContent); } catch (e) { /* discard invalid rich data */ }
let canvasContent = null;
try { canvasContent = normalizeCanvasContent(note.canvasContent); } catch (e) { /* discard invalid canvas data */ }
let noteType;
try {
noteType = normalizeNoteType(note.noteType, canvasContent);
} catch {
noteType = normalizeNoteType(null, canvasContent);
}
return compatibilityFields({
id,
noteType,
title: String(note.title == null ? '' : note.title),
text: notePlainText(richContent, canvasContent, note.text),
...(richContent ? { richContent } : {}),
...(canvasContent ? { canvasContent } : {}),
quote: String(note.quote == null ? '' : note.quote),
context: String(note.context == null ? '' : note.context),
source,
aiTask: note.aiTask == null ? null : String(note.aiTask),
locator: clone(note.locator == null ? null : note.locator),
documentKey: note.documentKey == null ? null : String(note.documentKey),
fileIndex: Number.isInteger(note.fileIndex) && note.fileIndex >= 0 ? note.fileIndex : null,
collectionId,
tags: normalizeTags(note.tags, true),
pinned: note.pinned === true,
createdAt,
updatedAt
});
}
function migratedCollection(raw, now, ids, names, idMap) {
const collection = isObject(raw) ? raw : {};
const oldId = String(collection.id == null ? '' : collection.id);
let id = oldId;
if (!isSafeId(id) || ids.has(id)) id = newId('col');
ids.add(id);
if (oldId && !idMap.has(oldId)) idMap.set(oldId, id);
const base = String(collection.name == null ? '' : collection.name).trim() || '未命名';
let name = base;
let suffix = 2;
while (names.has(name.toLocaleLowerCase())) name = `${base} (${suffix++})`;
names.add(name.toLocaleLowerCase());
const createdAt = timestamp(collection.createdAt, now);
return {
id,
name,
createdAt,
updatedAt: timestamp(collection.updatedAt, createdAt)
};
}
function migratedProgress(raw, now) {
if (!isObject(raw) || !isObject(raw.locator)) return null;
try {
return {
locator: jsonValue(raw.locator, '阅读位置'),
percent: Math.max(0, Math.min(1, Number(raw.percent) || 0)),
at: timestamp(raw.at, now)
};
} catch (e) {
return null;
}
}
function migratedBookmark(raw, now, ids) {
if (!isObject(raw) || !isObject(raw.locator)) return null;
let locator;
try {
locator = jsonValue(raw.locator, '书签位置');
} catch (e) {
return null;
}
let id = String(raw.id == null ? '' : raw.id);
if (!isSafeId(id) || ids.has(id)) id = newId('bm');
ids.add(id);
const documentKey = raw.documentKey == null
? null
: limitedString(raw.documentKey, LIMITS.documentKey).trim() || null;
return {
id,
...(raw.label == null ? {} : { label: limitedString(raw.label, LIMITS.title) }),
locator,
...(documentKey ? { documentKey } : {}),
at: timestamp(raw.at, now)
};
}
function migrate(raw) {
const source = isObject(raw) ? raw : {};
const now = Date.now();
const result = emptyStore();
const collectionIds = new Set();
const collectionNames = new Set();
const collectionIdMap = new Map();
if (Array.isArray(source.collections)) {
for (const item of source.collections) {
result.collections.push(
migratedCollection(item, now, collectionIds, collectionNames, collectionIdMap)
);
}
}
const rawEntries = isObject(source.entries) ? source.entries : {};
for (const oldEntryId of Object.keys(rawEntries)) {
const entryId = isSafeId(oldEntryId) ? oldEntryId : newId('entry');
const rawEntry = isObject(rawEntries[oldEntryId]) ? rawEntries[oldEntryId] : {};
const bookmarkIds = new Set();
const bookmarks = Array.isArray(rawEntry.bookmarks)
? rawEntry.bookmarks.map((item) => migratedBookmark(item, now, bookmarkIds)).filter(Boolean)
: [];
const progressByDocument = {};
if (isObject(rawEntry.progressByDocument)) {
for (const [key, value] of Object.entries(rawEntry.progressByDocument)) {
const documentKey = limitedString(key, LIMITS.documentKey).trim();
const progress = migratedProgress(value, now);
if (documentKey
&& !Object.prototype.hasOwnProperty.call(Object.prototype, documentKey)
&& progress) progressByDocument[documentKey] = progress;
}
}
const entry = {
progress: migratedProgress(rawEntry.progress, now),
bookmarks,
notes: [],
progressByDocument
};
if (rawEntry.bookSnapshot != null) {
try { entry.bookSnapshot = normalizeSnapshot(rawEntry.bookSnapshot, true); } catch (e) { /* ignore */ }
}
const noteIds = new Set();
if (Array.isArray(rawEntry.notes)) {
for (const rawNote of rawEntry.notes) {
const migrated = isObject(rawNote) ? { ...rawNote } : rawNote;
if (isObject(migrated) && migrated.collectionId != null) {
const oldCollectionId = String(migrated.collectionId);
migrated.collectionId = collectionIdMap.get(oldCollectionId) || oldCollectionId;
}
entry.notes.push(migratedNote(migrated, now, collectionIds, noteIds));
}
}
result.entries[entryId] = entry;
}
return result;
}
function save() {
const dest = getFilePath();
const temp = `${dest}.tmp`;
const backup = `${dest}.bak`;
let backedUp = false;
fs.mkdirSync(path.dirname(dest), { recursive: true });
try {
fs.writeFileSync(temp, JSON.stringify(cache, null, 2), 'utf8');
if (fs.existsSync(backup)) fs.unlinkSync(backup);
if (fs.existsSync(dest)) { fs.renameSync(dest, backup); backedUp = true; }
fs.renameSync(temp, dest);
if (backedUp) { try { fs.unlinkSync(backup); } catch (e) { /* ignore */ } }
} catch (e) {
try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch (cleanup) { /* ignore */ }
try {
if (backedUp && !fs.existsSync(dest) && fs.existsSync(backup)) fs.renameSync(backup, dest);
} catch (rollback) { /* 下次 load 时恢复 */ }
throw e;
}
}
function load() {
if (cache) return cache;
const file = getFilePath();
let parsed;
try {
const backup = `${file}.bak`;
if (!fs.existsSync(file) && fs.existsSync(backup)) fs.renameSync(backup, file);
parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
} catch (e) {
if (e && e.code === 'ENOENT') {
cache = emptyStore();
return cache;
}
if (!(e instanceof SyntaxError)) {
throw new Error(`阅读资料读取失败: ${e.message || e}`);
}
const backup = `${file}.bak`;
try {
parsed = JSON.parse(fs.readFileSync(backup, 'utf8'));
} catch (backupError) {
parsed = null;
}
try {
const corrupt = `${file}.corrupt-${Date.now()}`;
fs.renameSync(file, corrupt);
} catch (renameError) {
throw new Error(`损坏的阅读资料无法隔离: ${renameError.message || renameError}`);
}
if (!parsed) {
cache = emptyStore();
return cache;
}
try {
fs.renameSync(backup, file);
} catch (restoreError) {
throw new Error(`阅读资料备份恢复失败: ${restoreError.message || restoreError}`);
}
}
cache = migrate(parsed);
const needsMigration = !isObject(parsed) ||
parsed.version !== VERSION ||
JSON.stringify(parsed) !== JSON.stringify(cache);
if (needsMigration) {
try { save(); } catch (e) { /* 保留内存迁移结果,磁盘由 save 的回滚保护 */ }
}
return cache;
}
function entryOf(value) {
const id = safeId(value, '条目 ID');
const c = load();
if (!Object.prototype.hasOwnProperty.call(c.entries, id)) {
c.entries[id] = { progress: null, bookmarks: [], notes: [] };
}
return { id, entry: c.entries[id] };
}
function mutateCache(fn) {
const c = load();
const snapshot = clone(c);
let result;
try {
result = fn(c);
} catch (err) {
cache = snapshot;
throw err;
}
try {
save();
} catch (err) {
cache = snapshot;
throw err;
}
return clone(result);
}
function mutateEntry(value, fn) {
const id = safeId(value, '条目 ID');
return mutateCache((c) => {
if (!Object.prototype.hasOwnProperty.call(c.entries, id)) {
c.entries[id] = { progress: null, bookmarks: [], notes: [] };
}
return fn(c.entries[id], c);
});
}
function normalizeDocumentKey(value) {
const key = nullableString(value, LIMITS.documentKey, '文档标识');
if (key && Object.prototype.hasOwnProperty.call(Object.prototype, key)) {
throw new Error('文档标识无效');
}
return key;
}
function getState(id, documentKey = null) {
const { entry } = entryOf(id);
const key = normalizeDocumentKey(documentKey);
const documentProgress = isObject(entry.progressByDocument)
? Object.values(entry.progressByDocument)
.filter((value) => isObject(value))
.sort((a, b) => Number(b.at || 0) - Number(a.at || 0))[0] || null
: null;
return clone({
progress: key
? ((entry.progressByDocument && entry.progressByDocument[key]) || null)
: (entry.progress || documentProgress),
bookmarks: key
? entry.bookmarks.filter((bookmark) => bookmark.documentKey === key)
: entry.bookmarks,
notes: key
? entry.notes.filter((note) => !note.documentKey || note.documentKey === key)
: entry.notes,
bookSnapshot: entry.bookSnapshot || null
});
}
function getLastReadAt(value) {
const id = safeId(value, '条目 ID');
const c = load();
const entry = c.entries[id];
if (!isObject(entry)) return 0;
const progress = [
entry.progress,
...Object.values(isObject(entry.progressByDocument) ? entry.progressByDocument : {})
];
return progress.reduce((latest, item) => (
isObject(item) && Number.isFinite(Number(item.at))
? Math.max(latest, Number(item.at))
: latest
), 0);
}
function bindDocument(id, documentKey) {
const key = normalizeDocumentKey(documentKey);
if (!key) throw new Error('文档标识不能为空');
return mutateEntry(id, (entry) => {
let changed = false;
if (!isObject(entry.progressByDocument)) entry.progressByDocument = {};
if (entry.progress && !entry.progressByDocument[key]) {
entry.progressByDocument[key] = entry.progress;
entry.progress = null;
changed = true;
}
for (const bookmark of entry.bookmarks) {
if (bookmark.documentKey) continue;
bookmark.documentKey = key;
changed = true;
}
return changed;
});
}
// locator 由各格式适配器定义(PDF 用页码,EPUB 用章节+偏移),
// 这里只负责存取,不解释其含义。
function setProgress(id, documentKey, locator, percent) {
if (arguments.length < 4) {
percent = locator;
locator = documentKey;
documentKey = null;
}
const key = normalizeDocumentKey(documentKey);
const safeLocator = jsonValue(locator, '阅读位置');
return mutateEntry(id, (entry) => {
const progress = {
locator: safeLocator,
percent: Math.max(0, Math.min(1, Number(percent) || 0)),
at: Date.now()
};
if (key) {
if (!isObject(entry.progressByDocument)) entry.progressByDocument = {};
entry.progressByDocument[key] = progress;
} else {
entry.progress = progress;
}
return progress;
});
}
function addBookmark(id, mark) {
if (!isObject(mark) || !mark.locator) throw new Error('书签缺少定位信息');
const locator = jsonValue(mark.locator, '书签位置');
return mutateEntry(id, (entry) => {
const bookmark = {
id: newId('bm'),
locator,
label: limitedString(mark.label, 200),
excerpt: limitedString(mark.excerpt, 500),
documentKey: normalizeDocumentKey(mark.documentKey),
at: Date.now()
};
entry.bookmarks.push(bookmark);
return bookmark;
});
}
function removeBookmark(id, markId) {
const safeMarkId = safeId(markId, '书签 ID');
return mutateEntry(id, (entry) => {
const index = entry.bookmarks.findIndex((bookmark) => bookmark.id === safeMarkId);
if (index < 0) return false;
entry.bookmarks.splice(index, 1);
return true;
});
}
function collectionExists(c, id) {
return c.collections.some((collection) => collection.id === id);
}
function normalizedCollectionId(value, c) {
if (value == null || value === '') return null;
const id = safeId(value, '笔记本 ID');
if (!collectionExists(c, id)) throw new Error('笔记本不存在');
return id;
}
function noteInput(note, c) {
if (!isObject(note)) throw new Error('笔记内容为空');
const richContent = normalizeRichContent(note.richContent);
const canvasContent = normalizeCanvasContent(note.canvasContent);
const noteType = normalizeNoteType(note.noteType, canvasContent);
if (noteType === 'reading' && canvasContent) throw new Error('读书笔记不能包含画布内容');
if (noteType === 'canvas' && richContent) throw new Error('画布笔记不能包含富文本内容');
if (noteType === 'canvas' && !hasCanvasContent(canvasContent)) {
throw new Error('画布笔记内容为空');
}
const text = notePlainText(richContent, canvasContent, limitedString(note.text, LIMITS.text));
const quote = limitedString(note.quote, LIMITS.quote);
if (!text.trim() && !quote.trim()
&& !hasRichContent(richContent) && !hasCanvasContent(canvasContent)) {
throw new Error('笔记内容为空');
}
const now = Date.now();
return compatibilityFields({
id: newId('nt'),
noteType,
title: limitedString(note.title, LIMITS.title),
text,
...(richContent ? { richContent } : {}),
...(canvasContent ? { canvasContent } : {}),
quote,
context: limitedString(note.context, LIMITS.context),
source: normalizeSource(note.source, note.kind),
aiTask: nullableString(note.aiTask, LIMITS.aiTask, 'AI 任务'),
locator: jsonValue(note.locator, '笔记位置'),
documentKey: nullableString(note.documentKey, LIMITS.documentKey, '文档标识'),
fileIndex: normalizeFileIndex(note.fileIndex),
collectionId: normalizedCollectionId(note.collectionId, c),
tags: normalizeTags(note.tags),
pinned: note.pinned === true,
createdAt: now,
updatedAt: now
});
}
function normalizeFileIndex(value) {
if (value == null || value === '') return null;
const result = Number(value);
if (!Number.isInteger(result) || result < 0 || result > 1000000) {
throw new Error('文件序号无效');
}
return result;
}
function addNote(id, note) {
return mutateEntry(id, (entry, c) => {
const normalized = noteInput(note, c);
entry.notes.push(normalized);
return normalized;
});
}
function addStandaloneNote(note) {
return addNote(STANDALONE_ENTRY_ID, note);
}
function updateNote(id, noteId, patch) {
const safeNoteId = safeId(noteId, '笔记 ID');
if (!isObject(patch)) throw new Error('笔记更新无效');
return mutateEntry(id, (entry, c) => {
const note = entry.notes.find((item) => item.id === safeNoteId);
if (!note) return null;
if (Object.prototype.hasOwnProperty.call(patch, 'noteType')
&& normalizeNoteType(patch.noteType, note.canvasContent) !== note.noteType) {
throw new Error('笔记类型创建后不能更改');
}
let fallbackText = note.text;
if (Object.prototype.hasOwnProperty.call(patch, 'title')) {
note.title = limitedString(patch.title, LIMITS.title);
}
if (Object.prototype.hasOwnProperty.call(patch, 'richContent')) {
const richContent = normalizeRichContent(patch.richContent);
if (richContent) {
note.richContent = richContent;
} else {
delete note.richContent;
}
}
if (Object.prototype.hasOwnProperty.call(patch, 'canvasContent')) {
const canvasContent = normalizeCanvasContent(patch.canvasContent);
if (note.noteType === 'reading' && canvasContent) {
throw new Error('读书笔记不能包含画布内容');
}
if (canvasContent) note.canvasContent = canvasContent;
else delete note.canvasContent;
}
if (Object.prototype.hasOwnProperty.call(patch, 'text')) {
fallbackText = limitedString(patch.text, LIMITS.text);
if (note.noteType !== 'canvas'
&& !Object.prototype.hasOwnProperty.call(patch, 'richContent')) {
delete note.richContent;
}
}
if (note.noteType === 'canvas'
&& Object.prototype.hasOwnProperty.call(patch, 'richContent')
&& patch.richContent != null) {
throw new Error('画布笔记不能包含富文本内容');
}
if (Object.prototype.hasOwnProperty.call(patch, 'quote')) {
note.quote = limitedString(patch.quote, LIMITS.quote);
}
if (Object.prototype.hasOwnProperty.call(patch, 'context')) {
note.context = limitedString(patch.context, LIMITS.context);
}
if (Object.prototype.hasOwnProperty.call(patch, 'source') ||
Object.prototype.hasOwnProperty.call(patch, 'kind')) {
note.source = normalizeSource(patch.source, patch.kind, false);
}
if (Object.prototype.hasOwnProperty.call(patch, 'aiTask')) {
note.aiTask = nullableString(patch.aiTask, LIMITS.aiTask, 'AI 任务');
}
if (Object.prototype.hasOwnProperty.call(patch, 'locator')) {
note.locator = jsonValue(patch.locator, '笔记位置');
}
if (Object.prototype.hasOwnProperty.call(patch, 'documentKey')) {
note.documentKey = nullableString(
patch.documentKey, LIMITS.documentKey, '文档标识'
);
}
if (Object.prototype.hasOwnProperty.call(patch, 'fileIndex')) {
note.fileIndex = normalizeFileIndex(patch.fileIndex);
}
if (Object.prototype.hasOwnProperty.call(patch, 'collectionId')) {
note.collectionId = normalizedCollectionId(patch.collectionId, c);
}
if (Object.prototype.hasOwnProperty.call(patch, 'tags')) {
note.tags = normalizeTags(patch.tags);
}
if (Object.prototype.hasOwnProperty.call(patch, 'pinned')) {
note.pinned = patch.pinned === true;
}
note.text = notePlainText(note.richContent, note.canvasContent, fallbackText);
if (note.noteType === 'canvas' && !hasCanvasContent(note.canvasContent)) {
throw new Error('画布笔记内容为空');
}
if (!note.text.trim() && !note.quote.trim()
&& !hasRichContent(note.richContent) && !hasCanvasContent(note.canvasContent)) {
throw new Error('笔记内容为空');
}
note.updatedAt = Date.now();
return compatibilityFields(note);
});
}
function removeNote(id, noteId) {
const safeNoteId = safeId(noteId, '笔记 ID');
return mutateEntry(id, (entry) => {
const index = entry.notes.findIndex((note) => note.id === safeNoteId);
if (index < 0) return false;
entry.notes.splice(index, 1);
return true;
});
}
function noteAssetIds() {
const ids = new Set();
const c = load();
for (const entry of Object.values(c.entries)) {
for (const note of Array.isArray(entry.notes) ? entry.notes : []) {
const pages = note.canvasContent && Array.isArray(note.canvasContent.pages)
? note.canvasContent.pages
: [];
for (const page of pages) {
const background = page && page.background;
if (background && background.type === 'pdf' && /^pdf_[a-f0-9]{64}$/.test(background.assetId)) {
ids.add(background.assetId);
}
}
}
}
return [...ids];
}
function setBookSnapshot(id, snapshot) {
const normalized = normalizeSnapshot(snapshot);
return mutateEntry(id, (entry) => {
if (normalized == null) delete entry.bookSnapshot;
else entry.bookSnapshot = normalized;
return normalized;
});
}
function listNotes(filters = {}) {
if (!isObject(filters)) throw new Error('笔记筛选条件无效');
const c = load();
const hasEntry = Object.prototype.hasOwnProperty.call(filters, 'entryId');
const entryId = hasEntry ? safeId(filters.entryId, '条目 ID') : null;
const hasCollection = Object.prototype.hasOwnProperty.call(filters, 'collectionId');
const collectionId = hasCollection && filters.collectionId != null
? safeId(filters.collectionId, '笔记本 ID')
: null;
const source = filters.source == null || filters.source === ''
? null
: normalizeSource(filters.source, null, false);
const noteType = filters.noteType == null || filters.noteType === ''
? null
: normalizeNoteType(filters.noteType, null);
const tag = filters.tag == null ? '' : limitedString(filters.tag, LIMITS.tag).trim();
const query = filters.query == null ? '' : limitedString(filters.query, 500).trim();
const tagKey = tag.toLocaleLowerCase();
const queryKey = query.toLocaleLowerCase();
const result = [];
for (const [currentEntryId, entry] of Object.entries(c.entries)) {
if (hasEntry && currentEntryId !== entryId) continue;
for (const note of entry.notes) {
if (hasCollection && note.collectionId !== collectionId) continue;
if (source && note.source !== source) continue;
if (noteType && note.noteType !== noteType) continue;
if (tagKey && !note.tags.some((item) => item.toLocaleLowerCase() === tagKey)) continue;
if (queryKey) {
const haystack = [
note.title,
note.text,
note.quote,
...note.tags,
entry.bookSnapshot && entry.bookSnapshot.title
].filter(Boolean).join('\n').toLocaleLowerCase();
if (!haystack.includes(queryKey)) continue;
}
result.push({
...clone(note),
entryId: currentEntryId,
bookSnapshot: clone(entry.bookSnapshot || null),
associated: currentEntryId !== STANDALONE_ENTRY_ID
});
}
}
result.sort((a, b) =>
Number(b.pinned) - Number(a.pinned) ||
b.updatedAt - a.updatedAt ||
b.createdAt - a.createdAt ||
a.id.localeCompare(b.id)
);
return clone(result);
}
function getNoteCounts() {
const counts = {};
for (const [entryId, entry] of Object.entries(load().entries)) {
if (entryId === STANDALONE_ENTRY_ID) continue;
counts[entryId] = entry.notes.length;
}
return counts;
}
function collectionName(value) {
const name = limitedString(value, LIMITS.collectionName).trim();
if (!name) throw new Error('笔记本名称不能为空');
return name;
}
function ensureUniqueCollectionName(c, name, exceptId = null) {
const key = name.toLocaleLowerCase();
if (c.collections.some((item) =>
item.id !== exceptId && item.name.toLocaleLowerCase() === key)) {
throw new Error('笔记本名称已存在');
}
}
function listCollections() {
return clone(load().collections);
}
function addCollection(input) {
const value = typeof input === 'string' ? { name: input } : input;
if (!isObject(value)) throw new Error('笔记本信息无效');
const name = collectionName(value.name);
return mutateCache((c) => {
ensureUniqueCollectionName(c, name);
const now = Date.now();
const collection = { id: newId('col'), name, createdAt: now, updatedAt: now };
c.collections.push(collection);
return collection;
});
}
function updateCollection(collectionId, patch) {
const id = safeId(collectionId, '笔记本 ID');
if (!isObject(patch)) throw new Error('笔记本更新无效');
return mutateCache((c) => {
const collection = c.collections.find((item) => item.id === id);
if (!collection) return null;
if (Object.prototype.hasOwnProperty.call(patch, 'name')) {
const name = collectionName(patch.name);
ensureUniqueCollectionName(c, name, id);
collection.name = name;
}
collection.updatedAt = Date.now();
return collection;
});
}
function removeCollection(collectionId) {
const id = safeId(collectionId, '笔记本 ID');
return mutateCache((c) => {
const index = c.collections.findIndex((item) => item.id === id);
if (index < 0) return false;
c.collections.splice(index, 1);
const now = Date.now();
for (const entry of Object.values(c.entries)) {
for (const note of entry.notes) {
if (note.collectionId !== id) continue;
note.collectionId = null;
note.updatedAt = now;
compatibilityFields(note);
}
}
return true;
});
}
// forget 是显式删除:只清掉指定条目的阅读数据,不影响其它条目或笔记本。
function forget(value) {
const id = safeId(value, '条目 ID');
const c = load();
if (!Object.prototype.hasOwnProperty.call(c.entries, id)) return false;
return mutateCache((current) => {
delete current.entries[id];
return true;
});
}
module.exports = {
STANDALONE_ENTRY_ID,
init, getState, getLastReadAt, bindDocument, setProgress, setBookSnapshot,
addBookmark, removeBookmark,
addNote, addStandaloneNote, updateNote, removeNote, listNotes, getNoteCounts,
noteAssetIds,
listCollections, addCollection, updateCollection, removeCollection,
forget
};