// 阅读状态持久化:阅读进度、书签、笔记。 // 单独存 reader.json,不写进 library.json —— 书库条目可能被移除重建, // 而阅读痕迹是用户产出的数据,不该跟着条目生命周期一起消失。 const fs = require('fs'); const path = require('path'); const atomic = require('../atomic-file'); const VERSION = 6; const STANDALONE_ENTRY_ID = 'system:standalone-notes'; const LIMITS = { id: 160, title: 500, canvasText: 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, canvasRichJson: 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 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') { 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 } }); } 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$/, ''); } 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.canvasText)) { 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.canvasText) 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.canvasRichJson) 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.canvasText); } function notePlainText(richContent, canvasContent, fallback) { const text = [ richContent ? richPlainText(richContent) : String(fallback == null ? '' : fallback), canvasPlainText(canvasContent) ].filter((value) => value.trim()).join('\n'); return canvasContent ? text.slice(0, LIMITS.canvasText) : 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() { atomic.writeJson(getFilePath(), cache); } 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, note.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 = String(patch.text == null ? '' : patch.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; }); } // 与书库对账:列出书库里已不存在的条目。这些阅读资料在「我的笔记」里仍然可见, // 属于有意保留,因此只报告不自动删除,由用户显式决定。 function orphanReport(knownIds) { const known = new Set((Array.isArray(knownIds) ? knownIds : []).map((id) => String(id))); const c = load(); const orphans = []; for (const [entryId, entry] of Object.entries(c.entries)) { if (entryId === STANDALONE_ENTRY_ID) continue; if (known.has(entryId)) continue; const notes = Array.isArray(entry.notes) ? entry.notes.length : 0; const bookmarks = Array.isArray(entry.bookmarks) ? entry.bookmarks.length : 0; const snapshot = entry.book || {}; orphans.push({ entryId, title: snapshot.title || '', notes, bookmarks, hasProgress: !!entry.progress }); } orphans.sort((a, b) => b.notes - a.notes || a.entryId.localeCompare(b.entryId)); return orphans; } function forgetMany(entryIds) { const ids = (Array.isArray(entryIds) ? entryIds : []).map((id) => safeId(id, '条目 ID')); if (!ids.length) return 0; let removed = 0; mutateCache((current) => { for (const id of ids) { if (id === STANDALONE_ENTRY_ID) continue; if (!Object.prototype.hasOwnProperty.call(current.entries, id)) continue; delete current.entries[id]; removed++; } return removed > 0; }); return removed; } // 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, orphanReport, forgetMany, forget };