mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 13:31:09 +08:00
原先只认 .epub 和 .pdf,本地书库里占很大比重的 txt 长篇与漫画 (cbz、图片目录)会整片「消失」—— 用户能看到文件在 NAS 上,却在 书架里找不到,这是最让人困惑的一类问题。 格式列表硬编码在三处(扫描器白名单、parseBookFile 分支、content-type 映射表),加一种格式要同时改三处,漏掉任何一处就得到一个「扫到了但读不 出来」的安静 bug。这次把格式收进注册表,并补齐四种格式。 新增格式 - cbz / zip:按包内页序自然排序翻页,扫描时不解压到磁盘 - 漫画目录:一个目录一本书,子目录为卷,支持卷/页两级寻址 - txt:UTF-8 严格解码失败才回退 GB18030(UTF-16 BOM 也识别), 按中文网文章节标题分章,正文可按章节或字节偏移读取 - 单张图片按单页读物入库 - 明确不支持 cbr/rar:需要非自由解压实现,与单容器约束冲突 格式层 - registry.ts 定义 handler 契约(parse/manifest/asset)与扩展名路由, 扫描器与 HTTP 层只与它对话;扩展名白名单、能力声明、遍历过滤都自动派生 - zip-reader.ts 自研只读 ZIP,只用 node:zlib。JsZip 会把整包解压进内存, 而漫画动辄几百 MB,首次扫描真实漫画库会 OOM - natural-sort.ts 自研自然排序:page10 必须排在 page2 之后, 否则整本漫画的阅读顺序是乱的;不用 localeCompare 是因为 ICU 行为 在不同平台上不一致 - epub 章节返回前重写章节内的相对资源引用,否则 WebView 里图片/字体/ 样式全部 404;绝对 URL、data: URI 与文内锚点保持原样,脚注不会坏 修复 - 目录型读物认领后,其内部文件从索引中排除,否则同一部漫画会以 「系列 + 每卷 + 每页」的形式重复出现 - 目录认领顺序改为从外往里:反过来的话一部漫画会变成 N 本独立卷 - 主键新增原地编辑规则:txt/漫画没有内嵌标识,追加一章会算出新 id 并把读者进度留在旧记录上。现在同路径原地编辑算更新,移动/重命名 仍走内容哈希规则,所以进度都保得住 - 损坏的 .cbz 仍然入库并记录失败原因,而不是静默从书架消失 - PDF 不再用正则猜页数(会匹配到无关的 /Count 并报出错误页数, 让客户端分页错乱),改为诚实上报 null - 清理已迁出的 parseBookFile / pdfInfo / SUPPORTED_EXTENSIONS 测试 - 新增 65 个用例(共 92 个):真实 zip 与真实文件,不用 mock; fixtures 由测试自己生成,不需要签入二进制 - 覆盖自然排序、编码探测、ZIP 边界(ZIP64/加密/超大条目)、 目录认领、原地编辑、只读约束、各格式的读取端点
114 lines
4.5 KiB
TypeScript
114 lines
4.5 KiB
TypeScript
/**
|
|
* Book identity regression tests.
|
|
*
|
|
* The identity rule is the single most load-bearing decision in the product
|
|
* (design §6): get it wrong and readers lose their progress, which is the most
|
|
* common complaint about self-hosted libraries. Every branch is pinned here.
|
|
*/
|
|
import { test, describe } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { computeBookId, resolveBookId } from '../src/indexer/identity.ts';
|
|
|
|
const hash = (seed: string): string => seed.padEnd(64, '0');
|
|
|
|
describe('computeBookId', () => {
|
|
test('the same identifier and content give the same id', () => {
|
|
assert.equal(computeBookId('urn:isbn:9787536692930', hash('a')), computeBookId('urn:isbn:9787536692930', hash('a')));
|
|
});
|
|
|
|
test('a different identifier separates otherwise identical content', () => {
|
|
assert.notEqual(computeBookId('urn:isbn:1', hash('a')), computeBookId('urn:isbn:2', hash('a')));
|
|
});
|
|
|
|
test('a re-encoded copy is a distinct book, not an alias', () => {
|
|
// Same book, different bytes: the design wants it kept as a separate copy
|
|
// rather than silently merged into the existing row.
|
|
assert.notEqual(computeBookId('urn:isbn:1', hash('a')), computeBookId('urn:isbn:1', hash('b')));
|
|
});
|
|
|
|
test('the identifier is case and whitespace insensitive', () => {
|
|
assert.equal(computeBookId(' URN:ISBN:ABC ', hash('a')), computeBookId('urn:isbn:abc', hash('a')));
|
|
});
|
|
|
|
test('identity never depends on a path', () => {
|
|
// Two calls with identical inputs cannot diverge, which is what makes a
|
|
// rename/move a no-op for progress.
|
|
const fromOld = computeBookId(null, hash('a'));
|
|
const fromNew = computeBookId(null, hash('a'));
|
|
assert.equal(fromOld, fromNew);
|
|
});
|
|
});
|
|
|
|
describe('resolveBookId', () => {
|
|
test('an in-place edit keeps the existing id', () => {
|
|
// Appending a chapter to a txt file must not mint a second book; doing so
|
|
// would drop the reader's progress on the first one.
|
|
const previousId = computeBookId(null, hash('v1'));
|
|
const next = resolveBookId({
|
|
identifier: null,
|
|
contentHash: hash('v2-different'),
|
|
previousId,
|
|
previousIdentifier: null,
|
|
});
|
|
assert.equal(next, previousId);
|
|
});
|
|
|
|
test('an edit of an epub without a dc:identifier also keeps its id', () => {
|
|
const previousId = computeBookId(null, hash('v1'));
|
|
assert.equal(
|
|
resolveBookId({ identifier: null, contentHash: hash('v2'), previousId, previousIdentifier: null }),
|
|
previousId,
|
|
);
|
|
});
|
|
|
|
test('a genuinely different book at the same path gets a new id', () => {
|
|
// The old file had an identifier and the replacement does not (or vice
|
|
// versa): this is a different book, not an edit, so it must not inherit.
|
|
const previousId = computeBookId('urn:isbn:old', hash('v1'));
|
|
const next = resolveBookId({
|
|
identifier: 'urn:isbn:brand-new',
|
|
contentHash: hash('v2'),
|
|
previousId,
|
|
previousIdentifier: 'urn:isbn:old',
|
|
});
|
|
assert.notEqual(next, previousId);
|
|
assert.equal(next, computeBookId('urn:isbn:brand-new', hash('v2')));
|
|
});
|
|
|
|
test('a replaced epub that keeps the same identifier is treated as an edit', () => {
|
|
const identifier = 'urn:isbn:9787536692930';
|
|
const previousId = computeBookId(identifier, hash('v1'));
|
|
const next = resolveBookId({
|
|
identifier,
|
|
contentHash: hash('re-encoded'),
|
|
previousId,
|
|
previousIdentifier: identifier,
|
|
});
|
|
assert.equal(next, previousId, 'the identifier is the anchor, so the id survives re-encoding');
|
|
});
|
|
|
|
test('replacing a file that had an identifier with one that has none creates a new book', () => {
|
|
const previousId = computeBookId('urn:isbn:old', hash('v1'));
|
|
const next = resolveBookId({
|
|
identifier: null,
|
|
contentHash: hash('v2'),
|
|
previousId,
|
|
previousIdentifier: 'urn:isbn:old',
|
|
});
|
|
assert.notEqual(next, previousId);
|
|
});
|
|
|
|
test('with no previous file the content rule applies', () => {
|
|
const expected = computeBookId(null, hash('fresh'));
|
|
assert.equal(resolveBookId({ identifier: null, contentHash: hash('fresh') }), expected);
|
|
});
|
|
|
|
test('renaming still preserves identity through the content rule', () => {
|
|
// No previousId is available under a brand-new path, so identity falls back
|
|
// to the content hash — which is exactly why a rename keeps progress.
|
|
const original = computeBookId(null, hash('same-bytes'));
|
|
const afterRename = resolveBookId({ identifier: null, contentHash: hash('same-bytes') });
|
|
assert.equal(afterRename, original);
|
|
});
|
|
});
|