新增 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>
1368 lines
47 KiB
JavaScript
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
|
|
};
|