Repository navigation
feat(reader,library): PDF 深色模式 + 文献库按论文分文件夹(含迁移)+ 嵌套子专题 + 按范围提问 - #8
Veryyoung957 wants to merge 16 commits into
Conversation
- 阶段 0 只读预检:6 类清单(moves/already-done/orphan/hidden/conflict/quarantine)+ 人类可读报告 + JSON - 阶段 1 只复制(逐条 size+sha256 校验后才写日志);任一条失败即停,绝不删任何源 - 阶段 2 全部校验通过后才删源,再自底向上 rmdir 空目录(非递归) - 论文目录不递归(结构不变量)=> 复跑 moves.length === 0,绝不产生 X/X/X.pdf - 回滚:按日志倒序把 to 搬回 from(日志条目必须是安全相对路径,防篡改逃逸) - 日志/报告落在 dataDir 之外($DSH_HOME/.dsh-paper-reader/migration-logs/) - 路由:GET migrate-plan(只读)/ POST migrate(confirm=migrate-library)/ POST migrate-rollback - CLI:node dist/migrate.js [--dry-run|--apply|--rollback](默认 dry-run) - package.json 加 migrate 脚本
布局(方案 §B1/§B2): - 新布局 <dataDir>/<专题路径>/<论文名>/<论文名>.<后缀>;topic 升级为相对路径(切段逐段复用既有校验) - listPapers 递归 + 论文目录不递归(结构不变量);listTopicTree;上传落点改为论文文件夹 - 删除/改名/清空:论文文件夹整体收口(非递归 rmdir);clearTopic 后序遍历整棵子树 - 旧布局兼容读(对方案 §B1.4「不双读」的有据偏离,理由见提交说明):平铺 <名>.pdf 仍被列出并带 legacy 标记;论文文件夹存在时文件夹赢(确定性优先级);新写入一律落到论文文件夹 - 路由:/api/library 增 tree;topic 参数统一走路径门(含 .trash 保留名 / basename 加严) - 回归红线未动:穿越三层防护、非递归 rmdir、回收站与 409 语义、withInflight、零 innerHTML 范围提问(方案 §B3): - 三段式上下文(目录清单 <=2000 / 正文 <=8000 / 检索命中,总 <=40000)、显式 full/truncated/omitted - 默认范围=当前论文;不传 scope 时提示词与改造前逐字一致(回归项 C2) 深色模式(方案 §A2): - canvas 上 invert(1) hue-rotate(180deg) + 亮度/对比档(不挂 .page,避免反转选区/热区) - <head> 同步四级降级链判定(宿主属性 -> prefers-color-scheme -> 亮色),任何异常朝亮色回退 - 配置服务端唯一一份(/api/theme/config),设置面板卡片 + 阅读器 A 按钮/⚙ 共用;跨文档只有一个 storage 监听 - 图片仍会被反成负片(与 Sioyek #171 同源),文案如实写明;下载/导出不受影响 测试:既有 477 例全部保留强度(布局相关的夹具/路径断言随改造更新,VC1 登记表同步)
- test/darkmode-subtopic-features.test.mjs(34 例):深色 A1-A10(配置规范化/持久化/路由/ 四级降级链/开关与强制/首帧镜像/CSS 只挂 canvas/唯一 storage 监听/图片代价如实写明)、 按论文分文件夹 B1-B4(含队长裁定的旧布局兼容读与「文件夹赢」)、迁移 C1-C6(干跑=执行/幂等/ 复跑 moves=0&copied=0/无 X-X-X 套娃/回滚还原/中断不丢源/冲突先报告)、嵌套子专题 D1-D6 (多层/改名/移动/.trash 全层保护/非递归 rmdir/子树清空保留相对路径)、按范围提问 E1-E4 (单篇/子树/只给该范围/五个上限硬生效/显式截断/不传 scope 时提示词逐字不变)、 t2 自查修掉的 4 个真实缺陷 F1-F4 各一条靶子 - test/darkmode-real-pdf.test.mjs(RD1/RD2):**真实 Chromium + 真实论文 PDF + 真实阅读器页面**, 亮/暗两态屏幕取色(实测 角[255,255,255]→[6,6,6]、中[202,207,211]→[45,50,53]),并断言文字层 span 文本与定位、编程式选区、公式热区盒、elementFromPoint 命中在开暗后逐字节不变; 无 playwright/sharp 时**显式 skip 并写明理由**(不静默跳过) - test/mutation-probe.mjs:26 条变异(每条=方案里写明的缺陷),注入→对应用例必须变红→还原→回绿, 并复核文件内容与注入前逐字节一致:实测 26/26
- clearTopic 失败即回滚漏了目录:清空时每篇论文文件夹被非递归 rmdir 收口,回滚只 rename 文件 ⇒
目标父目录不在 ⇒ ENOENT ⇒ 文件留在回收站、库里看起来「白了」。修:回滚前 mkdir 父目录。
(新用例 D6「认不出的条目 ⇒ 非递归 rmdir 必失败 ⇒ 必须回滚」抓到;变异 D-recursive-rmdir 守它)
- scope:清单阶段只把「被省略」的篇推进 papers[](coverage/统计不覆盖全量);且 {kind:'paper'}
不带 topic/name 时抛「需要 path 或 name」而不是回落当前论文。修:范围内每篇都进 papers[];
{kind:'paper'} 缺省 = 当前论文。(E1/E2/E3 抓到)
- migrate.rollbackMigration:只把「源」的父目录列为 rmdir 候选 ⇒ 迁移时新建的论文文件夹在搬回后
留成空目录(回滚后形状与迁移前不一致)。修:源与副本的父目录都进候选。(C4 抓到)
- package.json 1.5.0 → 1.6.0;两份 README 的版本标签同步(merge-readme-parity 与 library-clear-and-busy 的 C3 版本守卫保持绿),并各加 4 条 v1.6.0 特性 bullet(深色模式写明 「图片会变负片」的已知代价、按论文分文件夹/迁移、嵌套子专题、按范围提问) - docs/darkmode-subtopic-plan.md 收录进库并追加「附录 Z」:Z.1 记录 §B1.4「不双读」被有据推翻 与**队长裁定保留**的规则表(平铺可读可删 / 文件夹赢 / 新写入落文件夹 / 迁移收口)与**集中回退点**; Z.2 记录补证据轮修掉的 3 处实现缺陷;Z.3 列出每条验收项对应的用例与真机证据落点
F1/F1b【high】迁移阶段 2 中断后不可复跑收敛: - 预检的论文 stem 现在同时来自「已存在的论文文件夹 <topic>/<stem>/」——阶段 2 若在「某篇的 平铺 <名>.pdf 已删、同篇其余产物还在」处中断,那些产物不再被判 orphan(永远留在旧布局=对插件消失) - MigrationAbort 新增 unlinked(阶段 2 已删的源),复跑/排障不再只有阶段 1 视角的 remaining F2【medium】同名并存且内容不同时被静默覆盖(与裁定的「文件夹赢」相反): - 「已完成」判定从「size 相同」升级为「size + sha256 都相同」;同 size 不同内容 ⇒ 判 conflict, 先报告不执行(runMigration 见 conflicts 即中止,零改动),绝不覆盖、绝不删源 F3【low】单篇删除的回收站未保留论文名层级: - 产物落到 <bundle>/<论文名>/<文件>(与 clearTopic 的 <bundle>/<标签>/<文件> 口径一致); manifest 同时写 bundle 根与论文层级,新增 trashSubdir 线索;响应里的 trashRel 仍是 bundle 根; 失败回滚时收掉空 bundle(不留空的回收站记录) F4【high】路由层挡死「专题移动到另一父」(UI 必然 400): - rename-topic 的 to 从单段 validateEntryName 改成路径类门 badTopicPath(与 topic 创建/清空同一条门), 并把规范化后的值传给 renameTopic - 新增「编码变形」负向防护(encodedEscapeError):%2E%2E%2F 这类解码后才是分隔符/上跳的输入一律拒; 名字里合法含 % (如 50% off)不受影响
- 新增 C7(阶段 2 中断的确定性崩溃现场:同篇残留必须收进论文夹、不许留 orphan) - 新增 C7b(failBeforeUnlink 逐点扫 1..4:源零丢失、unlinked 如实上报、复跑收敛成同一形状) - 新增 C8(F2:同名并存且内容不同 ⇒ 报冲突、零改动、文件夹版与平铺源都不许动;反向对照同内容=已完成; 并补「等长但内容不同」子例 —— 只比 size 的实现会在这里静默丢数据) - 新增 C9(F3:回收站保留论文名层级 + manifest 双写 + 失败不留空 bundle) - 新增 D7(F4:路由级「移动到另一父」必须 200;.. / 绝对路径 / A/%2E%2E%2Fx / 17 段超深 / NUL / .hidden 一律 4xx 且盘上零变化;移入自身子树 409) - 变异验证 33/33(新增 7 条:F1 stem 来源、F1b unlinked、F2 只比 size、F3 平铺 bundle、F4 单段 to、 F4 编码变形、F5 文案回退;并把因 F2 改写而失锚的 C-already-done 同步到新代码形状) - F5 文案:README×2 / lib/client.js i18n / 阅读器 ⚙ 提示 从「图片变负片」改为准确的 「明显褪色偏色(实测纯红 [255,0,0] → 浅粉 [229,134,134],不是真负片)」;A10 断言随之加强 - 既有用例随 F3 的新口径更新回收站路径(存在性/内容强度不降:G1/G4/Q2/V3/T9/VB1/VB2/VB5/C5/E5)
裁定 1(quarantine/冲突 => 跳过该篇、继续迁移;绝不假成功): - runMigration 不再因 conflicts/quarantine 整库中止;跳过项进 skippedPapers(code+reason) - 有跳过项时只写 .migration-v1.partial.json(含 partial/skipped 清单),绝不写 .done; 干跑报告与 CLI 输出都显著列出跳过项并明说「不会写全部完成标记」;rollback 同时清两种标记 裁定 2(隐藏论文纳入迁移、保留原名): - planMigration 的 stem 来源含前导点的平铺 <名>.pdf 与隐藏论文目录;匹配到的隐藏产物随篇迁移 - paperDirNameError 不再拒前导点 / .trash(迁移是搬运已有数据,不是重新命名), 只拒过长 / 分隔符 / NUL / "." / ".." - listTopicTree 与 listPapers 同口径:.hidden/ 这类论文目录与隐藏平铺论文都要在树里可见 F-A(符号链接源绕过冲突判定 => 阶段 1 把论文夹里的常规文件覆盖成链接): - 目标已存在(常规文件或链接)一律走冲突判定;链接到链接只在指向同一目标时算「已完成」, 否则判 conflict(不覆盖、不删源) F-B(同名平铺论文被写进已有子专题目录 => 该目录同时是专题与论文): - 目标已存在且是非论文目录 => 判 conflict(干跑即报警);子专题内论文不再从列表/树里消失, clearTopic 也不会永久 409 F-C(/api/library 的 pdfPath 写死新布局 => 未迁移旧库指向不存在的路径): - 改用 paperPdfPath()(按该篇实际布局推导),未迁移库与新库都给出真实存在的路径 顺带:把「论文目录不递归」的结构不变量收敛到唯一判定点(原先 head 处是可达不到的冗余守卫, 让不变量没有单一变异点),并在子级分支补 paperDirs,报告字段不空。
…trash / clear-plan 入参门
1) 目标占位判定(F-A 的彻底修法):只要目标**已存在**就绝不进 moves --
- 目标是常规文件:size+sha256 相同 => alreadyDone;不同 => conflict(先报告、零改动、源不动)
- 目标是符号链接:与源是同一份链接 => alreadyDone;否则 => conflict
- 目标既不是常规文件也不是链接(目录/设备)=> conflict
只有目标不存在才 moves;**禁止** rmSync(force) 覆盖:符号链接分支删掉了「先删后建」,
常规文件复制改用 COPYFILE_EXCL(内核层兜底,判定层回归也不会覆盖)
2) unreadable:读不进去的子树单列 {dir,cause},报告里显著提示「本次未处理」,并计入
partial/skippedPapers(code=migrate-unreadable),绝不假装全迁完;成功响应带 unreadable 字段
3) 回滚收尾 best-effort rmdirSync 空的 .trash 根(非空一律不动 -- 那是用户可恢复的数据)
4) clear-plan 的 topic 也过 badTopicPath(topic 类路由统一入参门:逐段词法 + 编码变形 + 深度/长度上限)
…rash、C18 结构护栏、V12b 路由门分工 - C2 增结构不变量:moves 的目标必须不存在(已存在的目标只能走 alreadyDone/conflict) - C10 重写:符号链接平铺源 x 论文夹同名常规文件,等长/不等长**两种**都判 conflict, 断言零改动、源链接不动、库外目标不动、不写完成标记(等长那种专打「只比 size 的实现」) - C11 增「冲突篇零改动」递归快照断言 - C14 扩到三种布局(平铺 / 论文夹 / 同名并存文件夹赢),断言 pdfPath 全部真实存在 - C16 unreadable:chmod 000 造读不进去的子树,断言单列、报告提示、partial + skippedPapers 记账、 不写完成标记、其它子树照常迁移(无法构造时显式 skip 并写明理由,不是静默通过) - C17 回滚收尾:空的 .trash 根被清掉;非空的一个字都不动 - C18 结构护栏(只看代码、剥掉注释):默认复制必须 COPYFILE_EXCL;绝不 rmSync(toAbs); 符号链接分支不得先删后建 - V12b:把 F4 之后被放宽的路由门断言补回并**分工** -- topic 类必须走路径门 (badTopicPath/validateTopicPath),论文名类必须走 badEntryBoundary,且路由层不得直接用单段原语 - 变异 45/45(新增 6 条:占位判定降级 / 链接源旁路 / 去 EXCL / rmSync 目标回归 / unreadable 静默 / 回滚不清 .trash / clear-plan 与论文名门分工)
R2-1【必修】全量成功后**清掉旧 .partial**(与写 .done 成对):此前 .partial 只写不清, 跳过项修好后重跑会写 .done 但旧 .partial 仍在 ⇒ 之后每次干跑报告永久谎报「上次有跳过项」, 而那份报告是用户点头迁移前唯一要读的东西(谎报=欺骗)。顺序刻意是「先清 .partial 再写 .done」: 中途死掉时两个标记都不在 ⇒ 报告只会保守地说「没有完成标记」,绝不出现 .done 与 .partial 同时在场的谎报。 R2-2【必修】`unreadable` 收进**数据根**:chmod 300(可写可进不可读)时 readdir 失败, 此前只记 `rel`(根为 '')会漏掉 ⇒ moves=0 / unreadable=[] / partial=false ⇒ 写 .done = **假成功** (论文其实全在旧布局)。根用 '.' 表示;有 unreadable 就绝不写 .done(与裁定 1 同一条规矩)。 R2-3【顺手,第一轮 F-E】clearTopic 失败收尾时 best-effort 清掉**空的** .trash 根 (rmdir 非递归天然保证:非空一字不动)。 CLI 退出码(队长裁定,脚本化调用者据此分支;同时写进 --help 与 migrateMain 的文档注释): 0 = 全部迁移完成 2 = 部分完成(有跳过项/未处理子树)或运行被中断 1 = 硬失败 64 = 用法错误(缺 --yes / 回滚缺日志路径) --dry-run(默认)永远返回 0(只打印报告,不改任何东西);新增 --help 打印用法与四个码。
- C19 R2-1:先造一次有跳过的运行(留 .partial)→ 处理掉那篇后重跑 ⇒ 断言 .done 已写、
旧 .partial 已消失、plan.partialMarker=null、报告不再谎报「部分完成/会被跳过/跳过该篇」
- C20 R2-2:chmod 300 数据根(无法构造时显式 skip 并写明理由)⇒ plan.unreadable=[{dir:'.',cause:'EACCES'}]、
报告列出根、run.partial=true、绝不写 .done、写 .partial
- C21 R2-3:clearTopic 失败后空的 .trash 根被收掉;非空(keepme/payload.pdf)一字不动
- C22 CLI:真跑 dist/migrate.js 断言 0/2/1/64 四个退出码(全迁完 / 有跳过项 / 悬空链接硬失败 / 缺 --yes),
并断言 --help 写明四个码、硬失败后源文件仍在(数据零丢失)
- 变异 50/50(新增 6 条:不清 .partial / 根漏进 unreadable / 空 .trash 残留 / partial 退 0 /
硬失败退 0 / 用法错误退 0;删掉被完全覆盖的重复项 I-unreadable-silent)
…位 / 新布局递归收集) Windows CI(run 38533904376 / job 115752032053)三个失败,根因各不相同;ubuntu/macOS 全绿是因为 前两个只在 win32 暴露、第三个只在「产物在论文夹里」的布局下暴露。 ① C22 拿到空 stdout(`match /退出码 0/` vs '')——**CLI 入口判定在 win32 下永不成立**: `new URL(import.meta.url).pathname` 在 win32 形如 `/D:/a/b/dist/migrate.js`,`path.resolve()` 会拼成 `D:\D:\a\...`,与 `process.argv[1]` 永不相等 ⇒ `node dist/migrate.js` **静默什么都不做**。 改用平台感知的 `fileURLToPath`;同一缺陷在 POSIX 上也能用「带空格的路径」复现(pathname 里是 %20), 所以新增 C23 在本机做判别(同时留一条无空格路径的反向对照)。 ② A2 `配置文件必须 0600` 438 !== 384:384=0o600、438=0o666 —— 不是行尾问题,是 **NTFS 不表达 POSIX mode 位**(chmod 只映射只读位)。改成**实测能力分支**:先探测本机 chmod 是否真能读回 0o600; 能 → 沿用强断言,并**加**一条「已存在的 0o666 文件必须被重写收紧回 0o600」(这条正是 writeFile 的 mode 只在新建时生效、故必须显式 chmod 的理由);不能(win32)→ 断言平台无关的等价性质: 文件确在预期路径 + 写入路径**确实请求** 0600(源码级断言,防止有人以「反正 Windows 不生效」为由 删掉 chmod,那样 Unix 上就真的不收紧)+ 实测记录本平台 mode 恰为 0o666。 禁用 t.skip / 放宽换绿:跳过的部分被**换成更严的断言**,不是删掉。 ③ VB3[B5] `['p']` vs `['p-zh.pdf','p.pdf','p.txt']`:win32 上 chmod 挡不住 rename ⇒ 注入不生效、 删除其实**成功了**(200),而用例的兜底分支用**非递归** readdir 收集(新布局产物在 `<topic>/<paper>/` 里)⇒ 只看到论文目录名 `p`,把「文件都在论文夹里」误判成「文件全丢了」。 改成:注入能力用**实测探针**(复用 test/fs-probe.mjs 的 chmodBlocksRename)而不是硬编码 `process.platform === 'win32'`;收集改成**递归**;并新增 ②b 场景——平台无关地覆盖「移动成功」那一路 的零销毁不变量(三个产物必须全在回收站、专题里零残留),因此本机(POSIX)也能判别递归收集这一修复。
macOS CI(run 38536203961 / job 115759573564)唯一失败是 **C23**("入口判定必须在带空格路径下成立",
actual `''` = CLI 零输出)。根因与我 t13 的怀疑**同形但换了个平台面具**,且这次连 fileURLToPath 也救不了:
· macOS 的 `os.tmpdir()` 是 `/var/folders/...`,它是 `/private/var/folders/...` 的**符号链接**;
· Node 默认对**主模块**做 realpath ⇒ `import.meta.url` 派生出 `/private/...`,
而 `process.argv[1]` 仍是「经由符号链接」的 `/var/...` ⇒ 两边字符串永不相等 ⇒ **CLI 静默零输出**。
判据改成按**实测能力分层**(不按平台名分支):
① **同一实体**:`statSync` 的 (dev, ino) 相同 ⇒ 就是同一个文件 —— 符号链接、大小写、分隔符、
8.3 短名都影响不到(`ino === 0` 的文件系统上自动跳过);
② 退化:两边各自 `realpathSync` 后再比(解开符号链接拿规范路径);
③ 再退化(目标不可达的最保守判断):`resolve` 后直接比。
顺带把这段逻辑抽成导出的 `isDirectRun(argv1, metaUrl)`,便于直接对照测试。
C23 扩成三种平台形态的本机等价判别:带空格路径(POSIX `%20` 面具)、**符号链接路径**
(macOS `/var`→`/private` 面具,用 fs.symlinkSync 在本机造同形现场)、无空格真实路径对照,
并直接对 `isDirectRun` 做四组对照(符号↔真实同一入口、真实=同一入口、不同文件不得算同一入口、
argv[1] 缺失不执行)。
变异 54/54:新增 **L1-guard-plain-resolve**(把分层判据退回朴素的 `resolve(a)===resolve(b)`)⇒ C23 红
(含空格那条仍绿 ⇒ 证明符号链接那条才是判别器);K1 重新锚到 `isDirectRun` 的 `fileURLToPath` 处
(win32 `%20` 面具)⇒ 同样红。
评审结论:功能与验证工作都很扎实,但新布局语义与既有面的交互处有一批已实证复现的缺陷,需要修一轮再合并先说结论之外的话:这批的四条能力设计合理(迁移的先复制后删/幂等/回滚机制我本地完整闭环跑通过:干跑→执行→复跑 moves=0→回滚还原,内容零丢失;变异探针 54/54;CI 三平台绿)。但按「每条新行为要可区分用例 + 变异验证」的口径过了一遍新布局与既有界面的交互面(上传/改名/删除/清空/会话绑定/迁移工具自身),发现以下问题——每条都在本机复现过,不是读代码猜的。 必修(数据不可见/不可删/静默丢失一类)A. 会话 id 与嵌套专题不兼容
B. 上传无碰撞检查(会吞数据)
C. 目录/文件互踩一类(造成 API 不可见且不可删的孤儿)
D. 清空/删除的占用检查只看一层
E. 迁移工具自身的两个契约缺口
F. 会话/主题路径的一个回归
应修(行为正确性/如实提示)G. 范围提问的预算与诚实性
H. 客户端嵌套结构追踪
我这边已经修好的(合并时会带上,供参考)
其它(不阻塞,记录在案)重复 CSS 块(reader/index.html:143-147)、 结论:request changes。 上面 A–F 属必修(都有数据不可见/不可删/静默丢失风险),G/H 应修。修完 push 后我再跑一轮集成验证(main 上已有 issue #9 修复,会一起合入)。感谢这批工作——迁移工具的核心机制经得起拆,问题集中在「新语义没铺到既有面的每个角」,这也是为什么这轮评审值得。 |
GGboya
left a comment
There was a problem hiding this comment.
详见上一条评审评论:A–F 必修(数据不可见/不可删/静默丢失),G/H 应修。每条均已本机复现。修完 push 后我再跑集成验证。
上游 PR GGboya#8(feat/darkmode-subtopic)维护者 @GGboya 的 CHANGES_REQUESTED 逐条修复: A 会话 id 嵌套专题:paperFromSessionId / parseDprSession 按最后一个 '/' 切 B 上传碰撞:同名子专题 / 论文目录 / 旧平铺产物 / 大小写变体一律 409,预检先于消费 body C 目录-文件互踩:create-topic 祖先与平铺、renameTopic 父级、renameLegacyPaper 撞目录、 renamePaper 逐文件预检(sameFileForRename;ino/dev=0 平台保守判冲突) D 清空占用与确认清单改为整棵子树(flattenClearPapers),在跑会话 409 且零改动 E 迁移契约:坏 JSONL 逐行报错(CLI 非 0 / 路由 409);阶段 1 半成品标记与复跑识别; /migrate 异步执行 + 在飞转录/译文 busy 门;回滚 logDir 用 relative 判定 F boundaryOnly 长度回归:删除 / 改名-from / 清空不再卡创建侧段长上限 G 范围预算与诚实性:命中计费、omitted 单次、专题不存在 404、同名如实 omitted、 coverage 不再误报 truncated、5 处码点安全截断(本树等价回退 cutToCodePoint) H 客户端前缀感知重写(cur / 折叠键 / dpr.last)、子树计数、clearEndpoint 透传到 Error 验证口径(基线 fdb45f2 对照 → 修复后): - 基线全量 533 tests / 506 pass / 0 fail / 27 skip;修复后 675 tests / 648 pass / 0 fail / 27 skip - 同一批缺陷用例在基线必红、修复后全绿;独立评审 R1/R2/R3 探针基线 4/15、1/16、1/10 红 - 变异 R1–R5 共 29 条:注入红 / 还原绿 / diff clean - 逐条事实与复跑命令见 docs/evidence/;给维护者的回复见 docs/pr8-review-response.md 未 amend 既有提交、未 rebase、未 push。
PR:PDF 深色模式 + 文献库按论文分文件夹(含迁移)+ 嵌套子专题 + 按范围提问
1. 这一批做了什么
四个能力,外加一条对现有库的一次性迁移(唯一会动用户数据的部分):
src/theme-config.ts(新增)、reader/index.html、lib/client.js<专题>/<论文>/src/library.ts、src/library-manage.ts、src/host.tssrc/migrate.ts(新增,含 CLI)src/library-manage.ts、src/host.ts、lib/client.jssrc/scope.ts(新增)、src/host.ts、reader/index.html分支上的提交粒度刻意做到「迁移与功能分开」(
git log --oneline origin/main..feat/darkmode-subtopic):9b8120f只做迁移工具,5380df1才改阅读器/库布局语义。cad7f12把版本对到 1.6.0;c9c07ea记录队长裁定。最终交付面(
git diff --stat origin/main..feat/darkmode-subtopic→24 files changed, 5676 insertions(+), 286 deletions(-)):未被改动(用
git diff --name-only origin/main..feat/darkmode-subtopic -- <path>逐条核对,计数 0):.gitignore、src/translate.ts、refs/。2. 迁移:风险、备份与回滚(用户能自救)
2.1 会不会碰我的库?—— 本批不执行迁移
迁移不在本批执行(队长裁定,方案附录 Z.4)。合并/部署后库里仍是旧布局,一切照旧;
只有你显式跑
--apply --yes才会动数据。而且执行前必须先看干跑报告 + 先备份。2.2 迁移做了什么(不变量)
size+sha256),全部校验通过之后才删源。任何时刻至少有一份完整可读的产物 ⇒ 中断不会丢数据。
moves=0(实测),不会产生X/X/X.pdf套娃;日志每条落盘可续。size+sha256⇒ 认作已完成;不同 ⇒ 判conflict并中止(零改动)。复制用
COPYFILE_EXCL;符号链接目标不去rmSync。--rollback <日志>按日志倒序搬回并清理空目录。mtime/sha/tree逐条 IDENTICAL。2.3 升级三步(升级后想用新布局)
2.4 回滚三步(出问题怎么退回去)
2.5 迁移期要认识的三类条目
<专题>/<论文>/,校验通过后删源某某笔记.md)--apply见冲突即中止3. 深色模式:调研结论与选型理由
(完整调研见
docs/darkmode-subtopic-plan.md§A1;下表是结论。)其他开源阅读器怎么做:
pageColors: {background, foreground},用页面主色统计推前景/背景色;render({background, intent})另有背景/意图项。官方至今没有"整页反转"开关。来源:commit d7fd510、commit cfac6fa、commit 472a1f9pdfjs.viewerCssTheme(只影响 UI chrome)与pdfjs.forcePageColors(才影响页面内容)常被用户混淆;forcePageColors+ 深色主题有滚动白闪已知 issue。来源:searchfox PdfJsDefaultPreferences.jsm、issue #18680InvertColors/-invert-colors,另有FixDocumentColors;主题与文档反转是两件事。来源:官方 Customize theme colors、官方 settings、#4073我们选了什么、为什么(贴合本插件的三条约束):
.textLayer(选中即问).eqLayer公式热区(pointer-events:none,命中按坐标算)。滤镜是 paint-time 操作:实测
getBoundingClientRect/Range选区 /.eq-box/elementFromPoint/ canvas 后备像素全部逐字节不变,所以"只改画出来的颜色、不改布局与坐标"这条最硬的不变量被满足。
paintPage(),它同时负责 TextLayer 与公式热区的唯一铺层点(被
test/merge-verify-t3.test.mjs的checkSolePaintPoint钉死)。pdf.js 的pageColors路线要求"先统计页面主色 → 再渲染",等于把
page.render()改成两步,直接踩这条断言。pageColors只有前景/背景两个色值;滤镜方案的brightness/contrast能给出四档实测曲线(白底
0/6/13/19),并把「关/跟随/强制亮/强制暗」四态一起表达。真机证据:真实 Chromium + 真实 19 页论文(3.02 MiB,真实库只读 copy)+ 真实
reader/index.html/api/theme/config切档后截图取色:亮[255,255,255]→ 暗[6,6,6];文字层 span 文本摘要、Range 选区、
.eq-box、elementFromPoint命中全部不变。下载/导出永远是原文(走服务端字节流,与客户端滤镜无关)。
dsh 主题怎么判定(实测,不是猜):权威接口是
body[data-ds-dark-theme]与html[data-ds-theme-source](来源:dsh 的
dsh-client-ui-layout的theme-presenter.d.ts与dsh-client-ui-theme的两套 token 表)。阅读器在 iframe 里不继承 CSS 变量与属性,但可读
parent.document,且storage事件可跨文档同步。读取优先级:① body 属性 → ② html 属性 → ③ iframe 自身
prefers-color-scheme→ ④ 全失败 ⇒ 判亮色(宁可"该黑没黑",绝不"该白被弄黑")。
4. 测试数与验收口径(只写实测数)
npm run buildgit worktree于分支 tip047a15bnode --test "test/*.test.mjs"ec21969同口径core.autocrlf=true重新检出)test/mutation-probe.mjs[255,255,255]→ 暗[6,6,6]test/darkmode-real-pdf.test.mjs--dry-run永远 0)dist/migrate.js/tmp副本:幂等、复跑moves=0、无套娃、回滚还原、真 SIGKILL 10 次零丢失且收敛mtime/sha/tree逐条 IDENTICALgit diff --quiet无内容差异,test/mutation-probe.mjs报50/50 通过。注意:git status --porcelain仍可能显示 1 个条目(跑过测试后 stat 缓存过期所致,git update-index --refresh即消)——判定要看git diff,不要看 status 计数skip21t.skip验收口径:每条新行为都要有"可区分用例 + 变异验证"——即把实现改坏,对应用例必须变红。
本轮过程中这个口径真的抓出并修掉了多处缺陷(
clearTopic失败回滚漏建父目录、scope.papers[]只装被省略的篇、rollbackMigration漏清空目录、planMigration的 stem 推导只来自平铺 PDF、--apply目标占位判定会静默覆盖等),每处都有对应用例与变异条目守着。
5. 已知限制与未验证项
A. 已声明、本批不修(队长裁定:如实声明,后续批次修)
src/migrate.ts:317-318与:522未兜错)⇒ 只读
--dry-run也会 exit=1 且不出报告。触发条件现实几乎不可达(数据根下有同名目录、或标记文件不可读);失败响亮且零字节改动;修法一行。
--aply(拼错)会被当成默认 dry-run 跑完并 exit=0;内部注释提到 64 但
--help未承诺这一条。以--help为准(已写进本文档的 CLI 一节)。B. 用户可见的代价(文案已如实写明,不再宣称"不改变图片")
[255,0,0]→ 浅粉[229,134,134]。这是所选路线的已知代价(与 Sioyek #171/#620 同源限制);可一键关闭或调强度规避。
README 与
lib/client.js、reader/index.html的 UI 文案都已改成准确的"去饱和"描述。C. 未验证 / 需用户实测
只列一次。方案附录 Z.1/Z.4 记录了 7 处集中回退点,便于后续批次一次性摘除。
git config core.autocrlf在 linked worktree 里写的是共享 repo 配置;且守卫 VC1 用git diff --name-only HEAD -- test/判"只允许登记过的上游用例被改",自造的 CRLF 检出会因行尾差异让它变红。⇒ Windows 上跑需要
core.autocrlf=true(或由上游决定加.gitattributes的* text=auto eol=lf)。本批不替上游改仓库策略,仅作为 PR 建议提出。
6. 本机重建与部署(部署需用户显式确认)
回滚部署:把
~/.dsh/local/dsh-paper-reader换回上一版构建即可(本批未改动数据结构 ⇒ 回滚插件版本不需要回滚数据)。最简单的做法:部署前先
cp -a ~/.dsh/local/dsh-paper-reader /tmp/dpr-prev-build留一份,出事直接换回来。数据回滚只与迁移有关,见 §2.4。
7. 给队长与用户的一段话(大白话)
这批改动会让你的文献库目录"变样子",但不会自己动它。
代价说清楚:图片和图表会褪色偏色(不是变成负片,但也不再是原色)——这是所有"整页反色"路线的通病,
一键就能关。
升级后不会自动变,你的库还是原样;要变,得由你跑一次迁移工具,而且先看报告、先备份。
插件只把这个范围里的内容给模型,范围外的不会偷偷混进去。
升级三步:① 更新插件 → ② 打开设置确认深色模式与库都在(旧布局照常可读,不迁移也能正常用)
→ ③ 想用新布局时:先
node dist/migrate.js看干跑报告,cp -a备份,再--apply --yes。回滚三步:① 插件换回上一版(数据不用动)→ ② 若已迁移过,用
--rollback <日志>按日志原路搬回→ ③ 万一日志也丢了,直接用备份
cp -a还原。一句话:新能力立刻可用;唯一会动数据的迁移,默认不跑、要跑必须你点头,而且随时能退回来。
三平台 CI 修复记录(推送后)
本 PR 在真机 CI 上暴露并修掉了三个只在特定平台出现的问题 —— 其中两个是真实的用户可见缺陷,不只是测试问题:
node dist/migrate.js静默无反应(零输出、exit 0、库未动)new URL(import.meta.url).pathname在 win32 得到/D:/a/...,拼成D:\D:\a\...≠argv[1]⇒ 入口守卫永不成立fileURLToPathpathname里是%20os.tmpdir()是/var/folders/...→/private/var/folders/...的符号链接:Node 对主模块做 realpath ⇒import.meta.url与argv[1]前缀不同(dev, ino)相同 →realpathSync比较 →resolve兜底(无平台名分支)第三个修法是把这类 bug 一次关掉(比较"同一实体"而不是"路径字符串"),而不是按平台各打一个补丁。
另有两条测试健壮性修复(NTFS 不表达 POSIX mode 位;win32 上 chmod 挡不住 rename 导致兜底分支误判"文件全丢"),均已改为实测能力判别而非平台名分支。
测试数变化说明:
533 tests / 506 pass / 0 fail / 27 skip。其中skip由 21 变为 27 是环境性的(本机真实文献库样本的布局变化导致依赖平铺产物的用例跳过),与代码无关:同一提交047a15b在本轮环境重跑即为532/505/0/27,skip 名集合与本分支 HEAD 逐条一致。