Skip to content

Releases: Sum-su/zotero-js-bridge

v1.14 · orphanStorage 的变异体补齐(不改行为)

Choose a tag to compare

@Sum-su Sum-su released this 13 Sep 12:45

补上 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)+ 三个体检项

Choose a tag to compare

@Sum-su Sum-su released this 13 Sep 11:49

一次普查了 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)

Choose a tag to compare

@Sum-su Sum-su released this 12 Sep 11:41

一个端点,治的是翻译器永远认不出来的那批:没有文本层的扫描件。

新增 /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 全抓。
⚠️ 但那 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 · 库体检 / 批量写 / 备份 / 结构化查询

Choose a tag to compare

@Sum-su Sum-su released this 12 Sep 05:07

四个新端点,都是先在真库上跑过才写进来的。

新增

端点 作用
/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 — 管理面板

Choose a tag to compare

@Sum-su Sum-su released this 12 Sep 03:47

首选项里多了个管理面板:工具 → 首选项 → 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

Choose a tag to compare

@Sum-su Sum-su released this 10 Sep 06:26

修复

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

Choose a tag to compare

@Sum-su Sum-su released this 10 Sep 05:21

Manifest author field set to the GitHub handle (local was a leftover dev placeholder). The add-on ID is unchanged, so existing installs upgrade in place — no reinstall needed.

Also fixes build.py writing the manifest as CRLF on Windows.

v1.0.4

Choose a tag to compare

@Sum-su Sum-su released this 10 Sep 04:56

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.