Releases: Sum-su/zotero-js-bridge
Release list
v1.14 · orphanStorage 的变异体补齐(不改行为)
补上 v1.13 留下的一个测试缺口,外加一处文档笔误。这一版不改变插件行为。
为什么要发一版「不改行为」的版本
v1.13 重写了 orphanStorage,但它自己的逻辑一条变异体都没有 —— 内容/缓存拆分和 deep 只被功能测试盖着,改坏了不会有任何东西变红。而就在前一天,正是这个端点因为在 stub 里把 IOUtils.stat 的 type 字面量写错,在一台真机上自信地报了一个 0。
现在补了 8 条,逐条盯着它报出去的字段:缓存文件的正则、contentBytes / contentFiles(当初就是这两个字段把「按内容/缓存分开算」这件事逼出来的)、scanned、孤儿判定本身、按扩展名分类的字节数,以及 deep 的两件事 —— 不给就不跑,和大小比对的方向。
结果:39/39 全被抓住,0 条锚点跳过。 也就是说没抓到任何真实缺陷:addon/bootstrap.js 与 v1.13 逐字节相同。已下载 v1.13 的发布件逐项比对过 —— 6 个文件里 5 个 sha256 一致,只有 manifest.json 不同,差别就是版本号那一个字符串。
那为什么还要发一版?因为这个仓库的发布坐标是提交:标签和 tools/Zotero_JS_Bridge/ 的文档快照都锚在某个 commit 上。测试和文档是交付物的一部分,让它们留在所有版本之外,快照就只能记一个「v1.13 之后还有东西」的模糊状态。
本机装不装都行:行为与 v1.13 完全相同,装与不装的唯一区别是版本号。
故意没盖的一条
丢掉 8 位长度过滤没有写变异体。夹具里三个目录名恰好都是 8 位,把那句 filter 删掉 scanned 照样是 3 —— 变异体能活下来。活得下来的变异体比没有更糟,因为它会被算成覆盖。 要盖它得往夹具里塞一个非 8 位的杂物,那会牵动 scanned / count 的一串断言,留给下次真需要的时候一起做。
文档
docs/enrich.md 里 "The three gates" 一节的开头写着 "Both gates trace to two items",而同一节的表格列的是三道闸、举的两个例子分别用到闸一、闸二、闸三。改成 "The gates"。这句话同是 llms-full.txt 的源,已重新生成。
验证:196 测试通过 / 0 失败,39/39 变异被抓住、0 条锚点跳过,llms-full.txt 重新生成后 --check 干净通过。
完整源码:12e1f2a
v1.13 · 存储卫生(storage)+ 三个体检项
一次普查了 25 个已装插件,找出没有任何插件覆盖、而这个 bridge 能直接修的三件事。三件都量过了,前两件现在能修。
新增第九个端点 /zoterojs/storage
它是唯一动文件系统的端点 —— 所以数据库备份救不了它。正因如此它比别的写端点多一道闸:
| 请求 | 答复 |
|---|---|
不给 dryRun |
200,dryRun: true,完整列出会动什么 |
dryRun:false 但不给 confirm |
400,ok:false,错误里点名 confirm 和 VACUUM |
dryRun:false + confirm:true |
真写 |
两道闸而不是一道,是因为「没说要写」和「说要写但没确认」要回不同的东西:前者是正常预演,后者是拒绝。合成一个判断,拒绝就会被报成一次平淡的预演。
四个 op:list / quarantine / restore / relocate。
隔离,不是删除。 288 个孤儿 PDF 里,173 个在库内还能找到同尺寸的文件,115 个找不到 —— 含一本 87 MB 的教科书。同尺寸只是线索不是证据,所以不可逆的那一步不做:quarantine 搬进 jsbridge-quarantine/<时间戳>/,restore 整体搬回,撤销是一等操作而不是指望。
manifest 只记真搬成的那批。 写失败的目录仍在原处、不进 manifest,两边对「它在哪」永远一致;体积也按搬成的子集重算 —— 抄预演的数字会让 restore 报出一个从未存在过的「已还原 N MB」。
relocate 按 basename 全等匹配(两边都折叠大小写,Windows 不区分)。故意不做模糊匹配:模糊"修复"会把条目指向另一篇,条目看着好了、点开是错的;断链是看得见的,错链看不见。
apply 新增 tags:标签变体归并
48 组只差大小写或标点的标签。这是库级写入,用不了 doApply(那个是条目中心的),所以自己带同等纪律:先备份,默认预演。预演会连「这一并把几个自动标签转成手动」一起报出来。
真机 SQL 是 UPDATE OR REPLACE itemTags,而 itemTags 的主键是 (itemID, tagID) —— 一个条目同时挂两种拼法时,并完只剩一行,不会留两行。stub 照抄了这个语义。
体检项:新增两个,重写一个(只读、不联网)
体检项从 7 个扩到 9 个。orphanStorage 不是新的 —— v1.12 就有,当时只回 count / scanned / sample / note,一个字节数都不报;本次是重写。
orphanStorage(重写)—— 加了体积,且内容与缓存分开算。.zotero-ft-cache/.zotero-reader-state是 Zotero 自己可再生的,真占地方的是contentMB;只报总数的话,「差在哪」就查不出来,而那恰恰是这项唯一的用处。另有byExt,deep可选,按字节数比对活文件。linkedFiles—— 外链附件(linkMode 2/3)本来就没有 storage 目录,算进孤儿是误报。LINKED_URL 不测可达性:那要连网,体检默认不连网。tagVariants—— 用现成的strip()归一后分组。
写测试时逼出来的三个真缺陷(原本都不在计划里)
checkOrphanStorage算了缓存体积却没放进输出 —— 内容/缓存那一组读不出来。- quarantine 的 manifest 按预演的体积写,而不是按真搬成的那批 —— 部分失败时虚报。
walkFiles把读不了根目录的异常吞了,于是relocate dir=<打错的路径>回 200 +scanned:0。一个自信的 0:看着像"没事",而不是"你给我的路径是错的"。现在根目录严格、子树照旧跳过。
夹具缺陷:所有附件的 parentItemID 是写死的数字,从来没指向过任何条目。一直没人发现,是因为在 linkedFiles 之前没有任何检查真去 resolve 父条目。改成按 key 引用。
改掉一处错误的注释。 checkOrphanStorage 原本用「全量遍历 storage/ 会超时」来解释为什么只走孤儿子树。不会超时 —— 2026-09-13 实测:全树 3820 文件 / 13.67 GB / 约 2.0 秒(跑了三遍:2023 / 2013 / 2054 ms),孤儿那几棵是 447 文件 / 3.53 GB / 220 ms。那条注释里的 3.6 GB 是孤儿子集的体积,被安到了整棵树上。真正的理由:活目录不可能出现在这项检查的输出里,而 deep 本来就走全量 —— 等于说一个每次调用都在做的动作会超时。
体积对比:孤儿 3.53 GB,整树 13.67 GB。
验证:196 测试通过 / 0 失败,31/31 变异被抓住、0 条锚点跳过,llms-full.txt 重新生成后无差异。
文档:docs/storage.md 新增,docs/endpoints.md 覆盖第九个端点和三个体检项,两份 README、llms.txt、AGENTS.md 同步(九个端点,五个可写)。
v1.12 · 扫描件补全(enrich)
一个端点,治的是翻译器永远认不出来的那批:没有文本层的扫描件。
新增 /zoterojs/enrich
| 模式 | 作用 |
|---|---|
GET items=KEY / GET scan=1 |
找出没有文本层的 PDF(只读) |
POST findings=[...] |
把视觉读到的写回去,默认演练 |
判据不是 Zotero.Fulltext.getIndexedState,而是 PDFWorker.getFullText 抽出来的字符数 ——
实测有 29.7 万字符的正常书仍然是 PARTIAL(个别页没字),而两个 0 字符的纯扫描件
一个判 UNINDEXED 一个判 PARTIAL。同一个毛病,状态不同,所以状态不能当判据。
只填空字段,永不覆盖。 两边都有值且不同 → 报 conflict 留给人判;
覆盖是 apply 的活。series 只列不写。写回复用 doApply,所以自动带备份与集合差分。
渲染和调视觉模型不在这里。 插件只做两件本地事:找谁需要、按规矩写回。
外部脚本负责把 PDF 渲成图、调模型。端点不引新依赖,也不把会超时、会花钱的步骤
塞进一个 HTTP 请求。
三道闸,每道都是拿一次真实的错误换的
① 见过封面/书名页/版权页 ② 书名对得上 ③ 字段配得上这个条目类型。
- 一条期刊论文的条目,元数据被读成它自己参考文献里那一本书的出版社和 ISBN,
还标着"版权页 CIP"。② 会放行 —— 文章标题里确实含那个书名,只有 ③ 拦得住。 - 另一条,出版社是从致谢句"本书作者感谢清华大学出版社…"里抠出来的。
那一页只有正文和目录,只有 ① 拦得住。
拿试点那 51 条真实视觉输出在本机重放过:过闸 36 / 挂起 15,与原型逐条一致;
有差异的 5 行全是原型更不准(它的快照里根本没有 place 字段、date 也不对,
于是把"库里已有值"当成了"空" —— 而那恰恰是可写的方向)。
Zotero.Items.getAll(lib, true) 会把批注算成顶层条目
这是同一批真机验证里撞出来的,本库实测:
(await Zotero.Items.getAll(1, true)).length |
11 231 |
| 其中批注 | 10 030 |
| 真正的书目条目 | 1 197 |
它只 join 了 itemNotes 和 itemAttachments,没 join itemAnnotations。
往端点里放,两个后果:遍历时每条批注白跑一次 getAttachments()(实测那个循环
再也没返回),以及报告里写 itemsProbed: 11231 —— 数字骗人比慢更糟,
一个说自己扫了全库、其实只看了十分之一的工具,会让人得出完全错误的结论。
已加过滤。别自己写 itemTypeID 比较来滤:annotation 的 itemTypeID 是 1,
看着特别像"普通条目"。
三处「测试绿着也照样漏」的地方
- stub 的
Item上没有.id(真机上.id与.itemID同值),于是插件里写成
.id的那处拿到undefined→getFullText抛错 → 被记成chars = null——
"抽不出来"和"没有文本层"在候选表里长得一模一样,而这一处正是那个判据。 - stub 的
getByLibraryAndKeyAsync拿夹具变量名当 key 查,真机按item.key查。
多数夹具两样凑巧一致,于是这个坏查找器长期没被发现。 - 响应把「只有
same」的条目滤掉了 —— 调用方分不清"比对过了、两边一致"和
"根本没处理"。这条不是测试没写,是测试照着错的写。
测试与验证
160 项测试(上一版 136,enrich 占 24 条)、mutate.py 22/22 全抓。
enrich 的 —— 全绿 + 22/22 不等于新端点
也被变异验证盖住了,这条缺口如实留在这里。
版本号从 1.0.11 跳到 1.12 是改约定**,不是笔误**:改成十进制两段,
1.12 → 1.13 → … → 1.19 → 1.20(1.0.9 → 1.0.10 那种中段死重的写法不再用)。
装上之后
本机验证过:8 个端点全部注册、只读模式下 enrich 回 403、ping 列出 8 条路径。
真库上的调用全是演练,没有写入过一个字节。
v1.0.11 · 库体检 / 批量写 / 备份 / 结构化查询
四个新端点,都是先在真库上跑过才写进来的。
新增
| 端点 | 作用 |
|---|---|
/zoterojs/doctor |
一键库体检:重复标题、孤立附件、空集合、回收站、附件标题与文件名对不上。联网那项默认不跑 —— 点一下体检就往外发请求是个惊喜,想查同步得点名 |
/zoterojs/apply |
批量写 + 集合差分,每条 op 可带 expect 前置断言,dryRun 一条都不落盘 |
/zoterojs/backup |
VACUUM INTO + 轮转(保留 N 份);另有「写前自动备份」开关,备份失败就中止整批,不照写不误 |
/zoterojs/query |
结构化只读查询,条件名对着 Zotero.SearchConditions 校验,不认识的直接 400 并列出可选项 |
外加 check_backup.py(每份备份只读打开 + 完整性检查)和 mutate.py(变异验证)。
集合差分现在盯两个条目,不是一个
把一个裸条目挂成子条目,它原有的集合归属会整个转给父条目(item.js:1944-1967,
注释原文 "remove from any collections where it existed previously and add parent
instead")。更麻烦的是父条目那次 save() 带 skipDateModifiedUpdate: true ——
它的 dateModified 都不变,事后想靠"最近改过哪些条目"倒查都查不出来。
这一切都是拿真库演练时踩到的:差分老老实实报出附件丢了 [307],
而父条目被凭空塞进 [307],报告里一个字都没有。 一个专门用来抓"静默改集合"的端点,
自己漏掉了一半静默改动。现在演练会预告(wouldGiveParent),真跑会报
(parentCollectionsGained)并给出顶层 warning。
两条"断言过、测试过、但是错的"
Item.setType不碰集合。 stub 里写着"改类型会摘集合",测试断言的正是这个编出来的
行为,于是全绿 —— 直到在真库上来回改一遍,collectionItems纹丝不动。源码站真机这边:
item.js里删collectionItems的地方只有一处,门开在_changed.collections上,
而那个标志只有setCollections()会置位。Collection对象的主键叫.id,不叫.collectionID。 读后者拿到的是undefined,
于是addToCollection/removeFromCollection在真机上从来没成功过。
单测没抓住,是因为 stub 里的集合对象是我自己捏的、形状和真机不一样 ——
stub 形状错了,测试就只是在自我印证。
两条都补了变异:谁把错版写回去,测试立刻红。
变异验证:22 条,全抓
17 条改 addon/bootstrap.js,5 条改 test_bridge.js 自己。锚点对不上算失败(退出码非 0),
不是"跳过" —— 守卫悄悄失效比守卫报错危险得多。CI 里也在跑。
136 项测试 / 0 失败,Zotero 10.0.2 实测。
升级:已装的用户走 updates.json 自动更新,或直接下下面的 xpi。
v1.0.7 — 管理面板
首选项里多了个管理面板:工具 → 首选项 → JS Bridge。改完立刻生效,不用重启 Zotero。
面板上的七个开关
| 开关 | 效果 |
|---|---|
| 总开关 | 关掉后四个端点全返 503 |
| 只读模式 | exec/merge/logs --clear 返 403,读的口子留着 |
| 四个端点各自的开关 | 关掉的返 404,并在 ping 的 disabled 字段里列出 |
| 响应上限 | 默认 1.5 MB,超了截断但保住 logs 尾部 |
外加 token 的复制 / 重新生成 / 重写文件,以及一个不发 HTTP 请求的自检按钮
——从 Zotero 内部发请求会被它自己的 CSRF 防护掐断,测出来的失败说明不了任何问题。
两个容易误解的地方
- 只读模式是拒绝服务,不是沙箱。 它不解析你的代码——
exec能写出多少种副作用
静态判不全——而是把写入口整个关掉。 - 总开关是在请求路径上拦,不是不注册端点。 停用后面板还在,
不然关掉之后就再没有地方打开了。
鉴权排在闸门之前,所以配置错误不会把 pref 名泄漏给没带 token 的调用方。
错误响应里带 pref 字段,zoterojs.py 会把它拼成一句人话,而不是甩一串 traceback。
其他改动
build.py校验面板里每个preference=都在prefs.js有默认值
(抠之前先剥 XML 注释,否则会报一个根本不存在的缺漏);
不再声称需要重启——引导式插件是就地热重载的。- 面板用固定 id 注册、注册前先
unregister自己:不给 id 的话每次
register()都会生成一个新的随机 id,热重载不会报错,只会静默多堆一块面板。 - 77 项测试(README 里那句 "56 tests" 已经过期很久了)。
v1.0.6
修复
merge 的破折号归一化(真重复被误杀)
自检里 strip() 只把 U+002D / U+2014 / U+FF0D 当连字符。Zotero 抓回来的条目里
- 常常是 EN DASH(U+2013)——CNKI、JSTOR、Springer 都这么排——于是 1–10
和 1-10 逐字符比不相等,页号被判成冲突,真的重复条目会被拒绝合并。
现在用 \p{Pd} 收全破折号类,另补三个不在该类里的:U+2212 减号(Sm 类)、
U+00AD 软连字符(Cf 类)、U+2043(Po 类),外加 U+200B 零宽空格。
README 说的"常数时间比较"是假的
从 v1.0.0 起 README 就宣称对 token 做 constant comparison,代码里一直是普通的
!==。这次真的实现了。如果你审计过旧版本并对照 README,是 README 错了,不是你审错了。
热重载后端点还原错
startup() 无条件把 Zotero.Server.Endpoints[path] 存成"原值",于是热重载第二次
startup() 会把我们自己装进去的端点当成原件,shutdown() 之后还原成我们的旧版本
而不是真正被顶掉的那个。
响应超限时把 logs 丢了
1.5 MB 截断路径只保 result,偏偏响应之所以超限往往就是 log() 吐太多,
而 logs 正是唯一能说明问题的线索。
新增
GET|POST /zoterojs/logs —— 读 Zotero 的错误控制台 / 调试输出,不用再去点
工具 → 开发者 → 错误控制台。
python zoterojs.py logs --min-level warning
python zoterojs.py logs --grep "JS Bridge"
python zoterojs.py logs --source debug参数:source / minLevel / category / grep / since / limit / clear。
两个源可用性完全不同:console 永远有货;debug 默认是空的,这不是坏了——
它只在 extensions.zotero.debug.store 打开时记录,而那个 pref 是一次性的,
Zotero 启动读完就自己设回 false。
另外
build.py现在从 manifest 现推updates.json(两边可能脱节:市场显示新版本、
老用户却收不到更新),CI 加了同步校验。- 测试 33 → 59 项,每处修复都做过变异验证(把修复改回去,确认测试真的会红)。
v1.0.5
v1.0.4
First public release.
Install: download zotero-js-bridge.xpi below, then in Zotero open
Tools → Add-ons → ⚙ → Install Add-on From File… and pick the file.
Three local endpoints on Zotero's own server (127.0.0.1:23119):
| Method | Path | Auth |
|---|---|---|
| GET | /zoterojs/ping |
none |
| POST | /zoterojs/exec |
X-ZoteroJS-Token |
| POST | /zoterojs/merge |
X-ZoteroJS-Token |
Verified against Zotero 10.0.2. Bundled Python client, 33 stub tests, no Zotero
install needed to run them.
exec runs arbitrary JavaScript inside Zotero. Read the Security model
section of the README before installing.