dsh-usage-badge 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/docs/design-notes.md +22 -5
- package/lib/cache-store.js +8 -4
- package/lib/client.js +101 -51
- package/lib/fold.js +9 -2
- package/lib/index.js +27 -0
- package/lib/sync.js +34 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -85,7 +85,7 @@ dsh plugin --profile web remove dsh-usage-badge
|
|
|
85
85
|
- **历史依赖日志,但日志变短不再吃掉历史**:日志被删掉后靠缓存继续计入;缓存也丢了,那段历史就没了。同一个会话的日志**变短或还原成更早的备份**时,缓存会**把更完整的那份留着**(折叠结果变小就只作为备份副本存下,统计仍用完整的那份),要等日志真的更全、却不再包含某一天,那天才会被丢掉。**例外是改名**:会话是按「日志所在目录」识别的,把某个会话目录改名会让同一份用量各算一份(见下)。DSH 自己从不裁日志:格式升级时它另写一代新文件、旧代保留,而实测换代后统计结果逐项相同。
|
|
86
86
|
- **日粒度区间仍只覆盖最近一年**:`近 7 日` / `近 30 日` / `近 12 个月` 与它们下面的滚动热力图都基于快照的最近 400 天。要看更早的日子,用 **`按年`**:那一档的月度合计、年度总额与**自然年热力图**都是直接查缓存库的,任何一年都在。
|
|
87
87
|
- **只统计 DSH 记进日志的调用**:某个东西直接请求外部 API 且不向 DSH 上报 usage,这部分统计不到。
|
|
88
|
-
- **云同步已经能用了**:把每天的用量与价格表备份到一台 WebDAV 服务器(Digest 认证),多台机器互相合并 —— 弹窗里多了「同步」页签:填地址、用户名、密码,点「测试连接」再「立即同步」。开启之后**三条自动触发**都会工作:**打开 DSH 后 8 秒自动同步一次**、**关闭 DSH 前自动同步一次**(最多等 3.5
|
|
88
|
+
- **云同步已经能用了**:把每天的用量与价格表备份到一台 WebDAV 服务器(Digest 认证),多台机器互相合并 —— 弹窗里多了「同步」页签:填地址、用户名、密码,点「测试连接」再「立即同步」。开启之后**三条自动触发**都会工作:**打开 DSH 后 8 秒自动同步一次**、**关闭 DSH 前自动同步一次**(最多等 3.5 秒,不会拖着不退出)、**改完价格表几秒后自动推送**;前两条在页签里各有一个开关。服务器连不上时本地照常工作,绝不会因为同步失败而丢掉一次编辑。**同步来的用量立刻计入**:拉取结束后徽标、日区间、热力图在同一次运行里就更新,不用重启 DSH(「按年」本来就是直查库)。见 [docs/design-notes.md](docs/design-notes.md) 的「云同步(WebDAV + Digest)—— 设计」一节。
|
|
89
89
|
- 每台机器有自己的**设备名**,它只决定「这台机器的新数据写进哪个分区」,**不是**会话身份;远端所有设备的历史都会合并,所以**改设备名不会让用量累增**。但设备名在服务器上必须**唯一**:两台机器用同一个名字会互相覆盖分片,插件会拒绝发布并在面板里说明。
|
|
90
90
|
- **删除不会跨设备传播**:本机删掉日志后,新机器不会再拉到那段历史,但**已经拉过它的机器**库里那份仍然在(远端从不因为本机的改动而删东西)。
|
|
91
91
|
- **会话是按日志目录识别的**:把某个会话目录改名,同一份用量会被当成两个会话、各算一份(实测 ¥1.5 → ¥3.0)。删日志、日志变短、还原旧备份这三种情况都不会丢历史。**这不包括「设备」字段** —— 那只是这台机器往哪个分区写,改设备名不会让用量累增。
|
package/docs/design-notes.md
CHANGED
|
@@ -267,6 +267,22 @@ dsh-usage-badge
|
|
|
267
267
|
2. 折叠结果交给 `cache-store.js`:默认写进 `$DSH_HOME/storages/usage-badge/cache.sqlite`,**一次只写变化的那几天**。只重读 size / mtime / 路径变化的日志;日志被删除时那条记录再也不会被写,于是缓存里的历史贡献原样留下(与桌面壳一致)。
|
|
268
268
|
3. 浏览器半轮询 `GET /usage-badge/summary`,本地完成区间聚合与筛选;切到「单价配置」时才去抓官方定价;切到「按年」时按年向 `GET /usage-badge/year` 取那一年的 12 个月(快照里没有更早的年份)。
|
|
269
269
|
|
|
270
|
+
### 弹窗不读主题令牌:它自带一套调色板
|
|
271
|
+
|
|
272
|
+
`.dub-pill`(侧边栏那一行)仍然用 DSH 的颜色令牌 —— 它要混在宿主自己的行里,跟着主题走才对。**弹窗是另一回事**:`.dub-box` 与图表 / 热力图的悬停卡片(`.dub-tip`)现在**不引用任何 `--dsw-*` 令牌**,颜色全部写死,挂在插件自己的 `--dub-*` 名下,深色变体跟着宿主的主题开关(`body[data-ds-dark-theme]`)切换;字体、字号、间距仍然继承宿主(面板该看起来像这个界面的一部分)。
|
|
273
|
+
|
|
274
|
+
为什么走到这一步,有两层原因:
|
|
275
|
+
|
|
276
|
+
1. **令牌的语义会变**。DSH 0.1.7 把 `--dsw-specific-menu` 从「不透明层」改成磨砂 + `backdrop-filter`,只填令牌、不加 blur 的规则就读成半透明 —— 当时改成直接填 `--dsw-alias-bg-layer-3`(那个 token 当时是不透明的),并由 `test/client.render.mjs` 钉住。
|
|
277
|
+
2. **令牌是任何人都能重定义的值**。open-sea-skin 把 `bg-base` / `bg-layer-1/2/3` 全部改写成 `rgba(…, 40%–90%)` 并带 `!important`,于是弹窗跟着整个界面一起变半透明:背后的对话与代码直接透到折线图上,数字没法读。先试过「只取令牌的颜色、把 alpha 钉成 1」(`rgb(from … r g b / 1)`),但那种修法仍然是**依赖**:下一个被皮肤改掉的令牌会再次失效。所以改成本插件自己拥有一套颜色。
|
|
278
|
+
|
|
279
|
+
代价与边界,写清楚:
|
|
280
|
+
|
|
281
|
+
- **不再跟随宿主的配色**:自定义主题(暖色、彩色)下弹窗仍是中性的白/深灰,不会跟着变色 —— 这正是「不受别的插件影响」的代价,也是要的。
|
|
282
|
+
- **只挡令牌**:如果某个插件直接对本插件的元素写 `style`/`!important`(例如 `* { background: … !important }`),或者给**祖先**加 `opacity`/`filter`,那仍然会影响到它 —— CSS 层面没有更硬的隔离。实测过这三种:前两种正常,第三种(全局 `!important` 填充、祖先 opacity)能穿透,属于「有插件故意针对」的情形。
|
|
283
|
+
- 断言见 `test/client.render.mjs`:**弹窗那段 CSS 里不许出现任何 `--dsw-`**(先剥掉注释再测,注释里允许点名令牌)、`.dub-box` / `.dub-tip` 必须填 `var(--dub-surface)`、浅色与深色两套调色板都在。
|
|
284
|
+
- 验证方式:把插件真实的样式表抽出来,在 headless Chromium 里把**所有** `--dsw-*` 令牌改成半透明/洋红,再读计算值 —— 浅色 `rgb(255,255,255)` / `rgb(17,20,24)`,深色 `rgb(22,26,33)` / `rgb(232,236,241)`,与令牌无关。
|
|
285
|
+
|
|
270
286
|
### 缓存为什么不是「一个 JSON 文件」
|
|
271
287
|
|
|
272
288
|
历史是不删的,所以「每次保存重写全部历史」这件事迟早会变成主问题。实测(把真实的一天桶形状铺成两年、761 天):
|
|
@@ -355,7 +371,7 @@ npm test
|
|
|
355
371
|
| `test/holidays.verify.mjs` | 节假日日历的行为:工作日节假日全天空闲、**调休周末仍按空闲价**(政策原文说周末全天空闲)、旧配置里的 `holidays.workdays` 被忽略、关闭日历时回到旧的按星期行为、自定义 `extra`、以及同一个月内不同日子的分类互不干扰 |
|
|
356
372
|
| `test/official-pricing.verify.mjs` | 解析器对**中英文两个页面快照**的逐字段断言(含旧模型别名、时区识别与换算);页面结构变化时会抛错而不是静默返回错价;`应用` 的合并语义(未知字段存活、已有的 `default` 行不动、没有 `default` 行时按官方第一条补一个并报告 `defaultFrom`、页面第一条确实是最便宜那档);并用**被 stub 掉的 fetch** 跑通两个路由,最后验证应用后的价格真的参与计费(含未列出模型按生成的 default 行计价),以及兜底行的完整生命周期:删除 → 回到内置模板并把 `defaultSource` 报成 `template` → 再次应用从官方第一条生成回来 → 手改过的兜底行不会被应用覆盖 |
|
|
357
373
|
| `test/cache.verify.mjs` | 缓存存储层:**两个后端跑同一套契约断言**(会话游标与桶的往返、`hourly` 槽位与逐请求记录、重写会话即替换它的日期集合、`years()` 的年份列表与有界区间读),以及只有跨重启才看得见的行为 —— **删掉会话日志后历史仍在**(新挂载的宿主照旧报出同样的金额)、**没有变化的一轮刷新不写文件**、**超出加载窗口的那天留在库里只是不读进来**(换个更宽的窗口还能取到)、旧 `cache.json` **迁移一次**并把旧文件改名为 `.migrated`(且 `.migrated` 之后**不再被读**)、**读不出来的缓存文件被改名保留并新建一个**、**更新版本的库只读使用且绝不改名**、加载窗口必须大于快照窗口这条不变量,以及 `按年` 路由(三年以前那一年照样读得到、12 个月恒定、`daily` 带节假日标记与 provider 拆分、没有数据的年份是 12 个空月而不是报错、非法年份 400)。 |
|
|
358
|
-
| `test/client.render.mjs` | 用替身 Loader 加载浏览器 bundle,检查 slot 注册形状与 `id`,再用 `react-dom/server` 渲染两个面板,断言金额格式、区间页签(含 `按年`:下拉只列出宿主报的那几年、选中的那一年被选中、月份轴恒为 12、点数是 12 × 序列数、四张卡片是那一年的合计、这一档不画热力图、没有年份时不渲染空下拉)、四序列折线的条数与配色、图例开关(单独显示某一条时的折线条数,以及面积填充有没有跟着走)、悬停 tooltip 的装配、provider 页签、热力图的格数(371 − 今天之后的那几天)与分档、官方价格表内容、两个开关(前缀回退与未匹配模型计价:开/关两种状态各自渲染出的说明与动作、default 行的来源标注、以及「哪些模型没价格」的提示)、**覆盖行编辑器**(每行的编辑/删除动作、倍率与峰谷列恒定存在、表单用被编辑行的真实数值预填而不是空模板、自定义规则才展开规则字段、新增行不给删除按钮、**兜底行同样可编辑可删除且不提供那些不生效的字段**、**重名键与「改过的行」会被保存拦下**、**旧宿主不会被说成「兜底行还没有」**、以及「保留未显示字段」与拒绝写 NaN
|
|
374
|
+
| `test/client.render.mjs` | 用替身 Loader 加载浏览器 bundle,检查 slot 注册形状与 `id`,再用 `react-dom/server` 渲染两个面板,断言金额格式、区间页签(含 `按年`:下拉只列出宿主报的那几年、选中的那一年被选中、月份轴恒为 12、点数是 12 × 序列数、四张卡片是那一年的合计、这一档不画热力图、没有年份时不渲染空下拉)、四序列折线的条数与配色、图例开关(单独显示某一条时的折线条数,以及面积填充有没有跟着走)、悬停 tooltip 的装配、provider 页签、热力图的格数(371 − 今天之后的那几天)与分档、官方价格表内容、两个开关(前缀回退与未匹配模型计价:开/关两种状态各自渲染出的说明与动作、default 行的来源标注、以及「哪些模型没价格」的提示)、**覆盖行编辑器**(每行的编辑/删除动作、倍率与峰谷列恒定存在、表单用被编辑行的真实数值预填而不是空模板、自定义规则才展开规则字段、新增行不给删除按钮、**兜底行同样可编辑可删除且不提供那些不生效的字段**、**重名键与「改过的行」会被保存拦下**、**旧宿主不会被说成「兜底行还没有」**、以及「保留未显示字段」与拒绝写 NaN 的源码断言)、节假日徽标与日历区块、展开/收起两种行形态、**弹窗那一段 CSS 里不许出现任何宿主颜色令牌(`--dsw-`),必须自带浅色/深色两套 `--dub-*` 调色板**,以及空数据 / 稀疏 provider 两个边界不崩 |
|
|
359
375
|
| `test/smoke.mjs` | 对着**本机真实**的 `$DSH_HOME` 跑一遍,打印徽标金额、今日 token 构成、当前峰谷档位、节假日日历状态、最近 7 天,以及滚动热力图那 371 天的分档分布(有用量的天数与四档各多少天,都挤在一档说明这张图会显得很平) |
|
|
360
376
|
| `test/official-pricing.live.mjs` | **联网**核对:抓真实页面并与 `test/fixtures/` 的快照逐字段比对,确认官方页面仍能解析、价格未变 |
|
|
361
377
|
|
|
@@ -624,9 +640,10 @@ storages/usage-badge/sync.json # 同步配置与状态
|
|
|
624
640
|
|
|
625
641
|
- **来源维度**:`days` / `sessions` 增加一列来源(本设备 / 某个远端设备)。本地 fold 只能写本设备的行;远端导入的行**只读**,永不被本地 fold 波及 —— 否则「B 下载了 A 的历史,B 本地没有那些日志」会让 B 的下一轮 fold 把它们删光。
|
|
626
642
|
- **导入远端行**时:若已存在同一 `(session, date)` 且那份的 `(maxSeq, fileSize)` 更大,则跳过(**旧机器不能覆盖新数据**);否则写入。
|
|
627
|
-
- **导入之后必须重载聚合器**:`refresh()`
|
|
643
|
+
- **导入之后必须重载聚合器**:`refresh()` 的游标没变,不会自己发现库里的新行,否则同步来的历史要等到重启才可见。实现是 `runSync()` 里 `engine.run()` 返回之后的 `aggregator.loadCache({ quiet: true })` 加 `snapshot = null`,条件是引擎自己报的 **`storeChanged`**(真的是库里写进或删掉了行才为真,取值来自 `intakeRows()`/`dropSourceMonth()` 的返回值 —— 重放一份库里已经全有的分片写 0 行,就不该为此付一次全量重载,实测 16k 行的库一次约 0.9 秒)。宿主还要 `!system.syncAbort`:正在关闭的宿主不能去读一个马上要 close 的库(宽松条件实测会打印 `cache load failed: statement has been finalized`),而**因为别的原因被中止**(未来的「取消同步」)仍会重载,所以 `aborted` 分支也带上 `storeChanged`。**撤回(墓碑删行)走的是同一段**,否则「别人撤回了,你这里还在多算」会一直算到重启为止。`test/host-sync.verify.mjs` 的两个方向、**两个后端**都钉着。
|
|
644
|
+
- **两个后端对「这个会话空了」必须给出同一种形状**:SQLite 的墓碑是 `DELETE FROM days`,`sessions` 行还在,于是 `load()` 返回一条**零行记录**;JSON 后端原先在最后一行被删时把整个 session 条目 `delete` 掉,`load()` 于是**少返回一条**。重载是 merge 语义(不能改成替换:只读库的 `put` 返回 null,内存里那次折叠是唯一的一份),它分不出「空了」和「从来没有过」——所以在 JSON 后端上墓碑撤回一直算到重启。现在 JSON 也保留空条目(`days: []`)。这是**只有两个后端都跑**才看得见的差异:契约套件(`cache.verify.mjs`)两个后端都跑却放过了它,因为它在「宿主重载」这一层,所以 `host-sync.verify.mjs` 也改成两个后端各跑一遍。
|
|
628
645
|
- **「变短」要说出来**:折叠时若发现某会话的天数或总量变小,记一行日志、并在面板标一次「这段历史已脱离日志,缓存里仍保留」,让使用者能分辨「格式升级」和「真丢了东西」。
|
|
629
|
-
- 读路径不变:`load()` 按窗口返回已 resolve 的记录;`按年` 仍是直接查库([index.js:
|
|
646
|
+
- 读路径不变:`load()` 按窗口返回已 resolve 的记录;`按年` 仍是直接查库([index.js:1088-1099](../lib/index.js#L1088-L1099) —— 这条链接此前指向 `syncSystem()`,顺手改正)。
|
|
630
647
|
|
|
631
648
|
### 远端布局
|
|
632
649
|
|
|
@@ -787,9 +804,9 @@ storages/usage-badge/host.lock # { pid, startedAt, instanceId, hostname, at
|
|
|
787
804
|
| `test/webdav.verify.mjs` ✅ **已实现(60 项)** | 纯函数:RFC 2617 向量(`Mufasa` 例子逐字段)、无 `qop` 的 RFC 2069 写法、`stale=true` 重试、UTF-8 与带逗号的 realm、`uri` 必须是请求行里的原始路径(含 `%XX`);以及对着假服务端的端到端:七个方法、`MOVE` 的 `Overwrite: F` 保护、404 语义、错误密码 / 一直挑战 / `SHA-256` / `Basic`-only 的四种失败各自报对原因 |
|
|
788
805
|
| `test/webdav-server.mjs` ✅ **已实现(harness)** | 一个**假 WebDAV 服务端**(`node:http`,真发 Digest 挑战并按 `MD5(HA1:nonce:nc:cnonce:qop:HA2)` 校验,检查 `nc` 不回退、`uri` 与请求行一致),可切换 `qop: null`、`staleOnce`、`challengeEveryRequest`、`algorithm`、`scheme` 等恶意行为 |
|
|
789
806
|
| `test/sync.verify.mjs` ✅ **已实现** | 分片与两个方向:路径/键的卫生(设备 id 与 `YYYY-MM` 不能带斜杠或穿越)、分片往返(stamp 与桶逐项保留)、**`source` 被改写成发布设备**、版本不符的分片与清单被具名拒绝、只发布本机 `current` 行(`fallback` 与别人的行都不发)、内容 sha256 决定「未变的月不重传」、首次拉取的窗口只限制拉取、`rowsToRecords()` 取最全 stamp、以及一次完整 trip(导出 → 空库导入 → 数字逐项相同、且**再导入一次不写任何东西**)。行模型的语义(截断/回滚/跨来源/从不删)由 `cache.verify.mjs` 的 section 8/9 覆盖,不在这里重测 |
|
|
790
|
-
| `test/sync-engine.verify.mjs` ✅ **已实现** | 端到端两设备:发布只带自己的月份、第二次运行不重传、另一台空库拉取后**数字与归属都对**、同一天两个设备相加、缺分片报 `partial` 且不记为已应用、补上后自动重试、owner 锁挡住第二个宿主、`sync.json` 里是 HA1
|
|
807
|
+
| `test/sync-engine.verify.mjs` ✅ **已实现** | 端到端两设备:发布只带自己的月份、第二次运行不重传、另一台空库拉取后**数字与归属都对**、同一天两个设备相加、缺分片报 `partial` 且不记为已应用、补上后自动重试、owner 锁挡住第二个宿主、`sync.json` 里是 HA1 而非明文、**配置写入不会覆盖上一次的状态**,以及三条「run 没走到 `ok`、但库里已经变了」的路径都报 `storeChanged`:**拉到行之后发布失败**、**分片在途中被中止(`aborted`)**、**分片行数比清单少(导入照做、失败照报)**;再加上「重放一份已经全有的分片写 0 行 ⇒ `storeChanged: false`」(宿主不必为此重载) |
|
|
791
808
|
| `test/sync.live.mjs` ✅ **已实现(联网)** | 真实的 `bytemark/webdav` 容器上跑完整流水线:A 发布一天 → B 空库拉取并计数 → 再同步一次零传输 → 清理自己建的两个设备目录(**不删 `usage-badge/` 本身**)。URL / 账号 / 密码从环境变量取,缺失就跳过 |
|
|
792
|
-
| `test/host-sync.verify.mjs` ✅ **已实现** | 宿主半的接线:挂载**不创建任何东西**、读状态不落文件、`POST /sync` 把明文算成 HA1 且**明文永不落盘**、响应只回前缀、patch 保存不丢别的字段、非法 URL / 非法设备 id 被拒、连不上的服务器**失败但不抛**、一次真实 run 走完整条路(发布 + 建目录 +
|
|
809
|
+
| `test/host-sync.verify.mjs` ✅ **已实现** | 宿主半的接线:挂载**不创建任何东西**、读状态不落文件、`POST /sync` 把明文算成 HA1 且**明文永不落盘**、响应只回前缀、patch 保存不丢别的字段、非法 URL / 非法设备 id 被拒、连不上的服务器**失败但不抛**、一次真实 run 走完整条路(发布 + 建目录 + 状态回填)、第二个实例拿到同一份配置但**被拒绝同步**、以及**另一台机器同步来的用量在同一次进程里就计入徽标与日窗口(不重启)**——撤回方向也一样,且**两个后端各跑一遍**(这条差异只在 JSON 回退上出现,见「本地缓存」一节) |
|
|
793
810
|
| `test/sync-triggers.verify.mjs` ✅ **已实现** | 两条自动触发:没配过同步的 home **什么都不做**(不建 `sync.json`、不拿锁、不发请求);配好并开启的 home 启动后**自己同步一次**(服务器上出现设备目录、状态记下那次运行);改一次价格表会**自动发布**(带上那次编辑、带设备副本、且不拖任何分片工作);服务器连不上时本地编辑**照样成功**、本地表照样生效 |
|
|
794
811
|
| ⏳ **未做**:跨机同 `deviceId` 的 `instanceId` 报警 | 设计里写了、代码里还没接;本机并发的 owner 锁已经实现并测过 |
|
|
795
812
|
| `test/webdav.live.mjs` ✅ **已实现(联网)** | 对着真实的 `bytemark/webdav` 容器跑:`OPTIONS`(打 realm 与 `DAV` 能力)→ `MKCOL` → `PUT` → `GET` 逐字节比对 → `PROPFIND` → `MOVE`(含撞名 412)→ `DELETE`,并在结尾清理自己创建的目录。URL / 账号 / 密码从环境变量取,缺失就跳过并打印怎么起容器 |
|
package/lib/cache-store.js
CHANGED
|
@@ -318,10 +318,14 @@ function createJsonStore({ path, log }) {
|
|
|
318
318
|
if (!record) continue
|
|
319
319
|
const keep = record.rows.filter((row) => !(rowSource(row) === source && String(row.date).startsWith(month)))
|
|
320
320
|
removed += record.rows.length - keep.length
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
321
|
+
// A session left with **no rows keeps its entry**, empty. The SQLite backend's `sessions` row
|
|
322
|
+
// survives the same delete (`DELETE FROM days`), and the two backends have to answer this question
|
|
323
|
+
// the same way: `load()` returning a session with no rows means "this session is now empty", while
|
|
324
|
+
// dropping the entry means "there was never such a session" — and a reader that merges what it is
|
|
325
|
+
// given instead of replacing its world with it cannot tell those apart. The host's `loadCache` is
|
|
326
|
+
// exactly that reader (it must not drop a fold the store could not accept — a read-only store's
|
|
327
|
+
// `put` returns null, and the fold in memory is then the only copy), so a dropped entry here left
|
|
328
|
+
// the withdrawn month counted in memory until the host restarted.
|
|
325
329
|
entry.days = keep.map((row) => ({ ...row }))
|
|
326
330
|
}
|
|
327
331
|
if (removed > 0) {
|
package/lib/client.js
CHANGED
|
@@ -77,81 +77,131 @@ window.__ModuleLoader__.load({
|
|
|
77
77
|
.dub-dot-peak{background:var(--dsw-static-amber-500,#f59e0b)}
|
|
78
78
|
.dub-pill-tokens{margin-left:auto;flex:none;color:var(--dsw-alias-label-tertiary,#999);font-size:11px;font-variant-numeric:tabular-nums}
|
|
79
79
|
.dub-pill-amount{flex:none;margin-left:6px;color:var(--dsw-alias-label-primary,#111);font-variant-numeric:tabular-nums;font-weight:500}
|
|
80
|
-
|
|
81
|
-
.dub-
|
|
80
|
+
/* ── 弹窗自带一套样式,不读宿主的颜色令牌 ─────────────────────────────────────────────────────
|
|
81
|
+
.dub-pill(侧边栏那一行)仍然用 DSH 的令牌 —— 它要混在宿主自己的行里,跟着主题走才是对的。
|
|
82
|
+
弹窗是另一回事:它是**读数字**的地方,而令牌是任何人都能重新定义的值。皮肤完全可以把
|
|
83
|
+
--dsw-alias-bg-layer-3 改成玻璃(open-sea-skin 就是,40%–90% 可调),于是背后的对话直接透到
|
|
84
|
+
折线图上。所以弹窗改用自己的一套调色板:写死的颜色、挂在插件自己的 --dub-* 名下,深色变体
|
|
85
|
+
跟着宿主的主题开关走。这样任何插件靠重定义令牌都改不动它的观感 —— 要的就是这个独立性。
|
|
86
|
+
布局、间距和字体仍然继承宿主(字体故意不写死:面板该看起来像这个界面的一部分,只是不能透明)。 */
|
|
87
|
+
.dub-mask{
|
|
88
|
+
/* Light palette. This element also owns the scrim, so it is the natural place for it. */
|
|
89
|
+
color-scheme:light;
|
|
90
|
+
--dub-surface:#fff;
|
|
91
|
+
--dub-surface-soft:#f3f5f8;
|
|
92
|
+
--dub-ink:#111418;
|
|
93
|
+
--dub-ink-2:#5b6472;
|
|
94
|
+
--dub-ink-3:#8a929e;
|
|
95
|
+
--dub-line:rgba(15,23,42,.14);
|
|
96
|
+
--dub-line-soft:rgba(15,23,42,.08);
|
|
97
|
+
--dub-line-strong:rgba(15,23,42,.26);
|
|
98
|
+
--dub-grid:rgba(15,23,42,.10);
|
|
99
|
+
--dub-guide:rgba(15,23,42,.45);
|
|
100
|
+
--dub-fill:rgba(15,23,42,.07);
|
|
101
|
+
--dub-fill-strong:rgba(15,23,42,.18);
|
|
102
|
+
--dub-hover:rgba(15,23,42,.07);
|
|
103
|
+
--dub-link:#2f6fed;
|
|
104
|
+
--dub-warn:#a8590a;
|
|
105
|
+
--dub-mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
|
106
|
+
--dub-shadow:0 14px 44px rgba(15,23,42,.22);
|
|
107
|
+
position:fixed;inset:0;z-index:2147483600;pointer-events:auto;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.34)}
|
|
108
|
+
/* Dark palette. Keyed on the host's theme switch, which is the signal every DSH skin uses too; the
|
|
109
|
+
data-theme spellings are hedges for a future host that renames it (a selector that never matches
|
|
110
|
+
costs nothing, while a panel left light in a dark window would be a real regression). */
|
|
111
|
+
body[data-ds-dark-theme] .dub-mask,
|
|
112
|
+
body[data-theme="dark"] .dub-mask,
|
|
113
|
+
html[data-theme="dark"] .dub-mask{
|
|
114
|
+
color-scheme:dark;
|
|
115
|
+
--dub-surface:#161a21;
|
|
116
|
+
--dub-surface-soft:#1d222b;
|
|
117
|
+
--dub-ink:#e8ecf1;
|
|
118
|
+
--dub-ink-2:#aab3bf;
|
|
119
|
+
--dub-ink-3:#7c8695;
|
|
120
|
+
--dub-line:rgba(255,255,255,.16);
|
|
121
|
+
--dub-line-soft:rgba(255,255,255,.09);
|
|
122
|
+
--dub-line-strong:rgba(255,255,255,.26);
|
|
123
|
+
--dub-grid:rgba(255,255,255,.12);
|
|
124
|
+
--dub-guide:rgba(255,255,255,.45);
|
|
125
|
+
--dub-fill:rgba(255,255,255,.10);
|
|
126
|
+
--dub-fill-strong:rgba(255,255,255,.22);
|
|
127
|
+
--dub-hover:rgba(255,255,255,.10);
|
|
128
|
+
--dub-link:#8ab4ff;
|
|
129
|
+
--dub-warn:#f0b45f;
|
|
130
|
+
--dub-shadow:0 14px 44px rgba(0,0,0,.55)}
|
|
131
|
+
.dub-box{width:min(720px,100vw - 48px);max-height:min(680px,100vh - 80px);display:flex;flex-direction:column;overflow:hidden;border-radius:14px;border:.5px solid var(--dub-line);background:var(--dub-surface);color:var(--dub-ink);box-shadow:var(--dub-shadow)}
|
|
82
132
|
.dub-head{display:flex;align-items:center;gap:10px;padding:14px 18px 10px}
|
|
83
133
|
.dub-title{font-size:14px;font-weight:600;flex:1}
|
|
84
|
-
.dub-chip{font-size:11px;padding:1px 7px;border-radius:9px;background:var(--
|
|
85
|
-
.dub-chip-peak{background
|
|
134
|
+
.dub-chip{font-size:11px;padding:1px 7px;border-radius:9px;background:var(--dub-fill);color:var(--dub-ink-2);white-space:nowrap}
|
|
135
|
+
.dub-chip-peak{background:#f59e0b;color:#111}
|
|
86
136
|
.dub-chip-holiday{background:#16a34a;color:#fff}
|
|
87
137
|
.dub-x{border:0;background:0 0;color:inherit;font-size:18px;line-height:1;cursor:pointer;padding:2px 6px;border-radius:6px;opacity:.7}
|
|
88
|
-
.dub-x:hover{opacity:1;background:var(--
|
|
138
|
+
.dub-x:hover{opacity:1;background:var(--dub-hover)}
|
|
89
139
|
.dub-tabs{display:flex;gap:6px;padding:0 18px 10px;flex-wrap:wrap;align-items:center}
|
|
90
|
-
.dub-tab{font-size:12px;padding:3px 10px;border-radius:8px;border:.5px solid var(--
|
|
91
|
-
.dub-tab-on{background:var(--
|
|
140
|
+
.dub-tab{font-size:12px;padding:3px 10px;border-radius:8px;border:.5px solid var(--dub-line);background:0 0;color:inherit;cursor:pointer}
|
|
141
|
+
.dub-tab-on{background:var(--dub-fill);border-color:transparent;font-weight:600}
|
|
92
142
|
/* The year picker sits in the range row, so it is styled as one more tab. */
|
|
93
|
-
.dub-year{font:inherit;font-size:12px;padding:3px 6px;border-radius:8px;border:.5px solid var(--
|
|
143
|
+
.dub-year{font:inherit;font-size:12px;padding:3px 6px;border-radius:8px;border:.5px solid var(--dub-line);background:var(--dub-surface);color:inherit;cursor:pointer}
|
|
94
144
|
.dub-panetabs{padding:0 18px 8px;gap:0}
|
|
95
145
|
.dub-panetabs .dub-tab{border:0;border-bottom:2px solid transparent;border-radius:0;padding:4px 12px;font-size:13px;opacity:.7}
|
|
96
|
-
.dub-panetabs .dub-tab-on{background:0 0;border-bottom-color:var(--
|
|
97
|
-
.dub-sep{width:1px;height:16px;background:var(--
|
|
146
|
+
.dub-panetabs .dub-tab-on{background:0 0;border-bottom-color:var(--dub-ink);font-weight:600;opacity:1}
|
|
147
|
+
.dub-sep{width:1px;height:16px;background:var(--dub-line);margin:0 4px}
|
|
98
148
|
.dub-body{padding:0 18px 14px;overflow:auto}
|
|
99
|
-
.dub-stats{display:flex;gap:22px;flex-wrap:wrap;padding:10px 0 14px;border-bottom:.5px solid var(--
|
|
149
|
+
.dub-stats{display:flex;gap:22px;flex-wrap:wrap;padding:10px 0 14px;border-bottom:.5px solid var(--dub-line)}
|
|
100
150
|
.dub-stat{display:flex;flex-direction:column;gap:2px}
|
|
101
|
-
.dub-stat-k{font-size:11px;color:var(--
|
|
151
|
+
.dub-stat-k{font-size:11px;color:var(--dub-ink-3)}
|
|
102
152
|
.dub-stat-v{font-size:15px;font-weight:600;font-variant-numeric:tabular-nums}
|
|
103
153
|
.dub-chartwrap{padding-top:6px}
|
|
104
154
|
.dub-plot{position:relative}
|
|
105
|
-
.dub-legend{display:flex;gap:14px;flex-wrap:wrap;padding:2px 0 6px;font-size:11px;color:var(--
|
|
155
|
+
.dub-legend{display:flex;gap:14px;flex-wrap:wrap;padding:2px 0 6px;font-size:11px;color:var(--dub-ink-2)}
|
|
106
156
|
/* Legend entries are toggles, so a series can be read on its own — a lone line at
|
|
107
157
|
its own scale says more than four lines sharing one plot. The swatch of a hidden
|
|
108
158
|
series loses its colour rather than being restyled, so the inline background stays
|
|
109
159
|
the only place a series colour is written. */
|
|
110
160
|
.dub-legend-item{display:inline-flex;align-items:center;gap:5px;font:inherit;font-size:11px;color:inherit;background:0 0;border:0;padding:0;cursor:pointer}
|
|
111
|
-
.dub-legend-item:hover{color:var(--
|
|
161
|
+
.dub-legend-item:hover{color:var(--dub-ink)}
|
|
112
162
|
.dub-legend-off{opacity:.5}
|
|
113
|
-
.dub-legend-off .dub-legend-swatch{background:var(--
|
|
114
|
-
.dub-legend-reset{font:inherit;font-size:11px;background:0 0;border:0;padding:0;cursor:pointer;color:var(--
|
|
163
|
+
.dub-legend-off .dub-legend-swatch{background:var(--dub-fill-strong)}
|
|
164
|
+
.dub-legend-reset{font:inherit;font-size:11px;background:0 0;border:0;padding:0;cursor:pointer;color:var(--dub-link);text-decoration:underline}
|
|
115
165
|
.dub-legend-swatch{flex:none;width:11px;height:11px;border-radius:3px}
|
|
116
166
|
.dub-svg{display:block;width:100%;height:auto;overflow:visible}
|
|
117
|
-
.dub-grid{stroke:var(--
|
|
118
|
-
.dub-guide{stroke:var(--
|
|
167
|
+
.dub-grid{stroke:var(--dub-grid);stroke-width:1}
|
|
168
|
+
.dub-guide{stroke:var(--dub-guide);stroke-width:1;stroke-dasharray:3 3}
|
|
119
169
|
.dub-band-holiday{fill:rgba(22,163,74,.10)}
|
|
120
|
-
.dub-axis{fill:var(--
|
|
121
|
-
.dub-tip{position:absolute;top:18px;transform:translateX(-50%);pointer-events:none;box-sizing:border-box;width:172px;padding:8px 10px;border-radius:8px;border:.5px solid var(--
|
|
122
|
-
.dub-tip-title{font-weight:600;color:var(--
|
|
170
|
+
.dub-axis{fill:var(--dub-ink-3);font-size:10px;text-anchor:middle}
|
|
171
|
+
.dub-tip{position:absolute;top:18px;transform:translateX(-50%);pointer-events:none;box-sizing:border-box;width:172px;padding:8px 10px;border-radius:8px;border:.5px solid var(--dub-line);background:var(--dub-surface);box-shadow:0 6px 20px rgba(0,0,0,.18);font-size:11px;line-height:1.6;color:var(--dub-ink-2);z-index:2}
|
|
172
|
+
.dub-tip-title{font-weight:600;color:var(--dub-ink);margin-bottom:2px}
|
|
123
173
|
.dub-tip-row{display:flex;align-items:center;gap:6px;white-space:nowrap}
|
|
124
174
|
.dub-tip-label{flex:1}
|
|
125
|
-
.dub-tip-value{font-variant-numeric:tabular-nums;color:var(--
|
|
126
|
-
.dub-empty{padding:34px 0;text-align:center;font-size:12px;color:var(--
|
|
127
|
-
.dub-block{border:.5px solid var(--
|
|
175
|
+
.dub-tip-value{font-variant-numeric:tabular-nums;color:var(--dub-ink)}
|
|
176
|
+
.dub-empty{padding:34px 0;text-align:center;font-size:12px;color:var(--dub-ink-3)}
|
|
177
|
+
.dub-block{border:.5px solid var(--dub-line);border-radius:10px;padding:12px 14px;margin:12px 0}
|
|
128
178
|
.dub-block-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:8px}
|
|
129
179
|
.dub-block-title{font-size:13px;font-weight:600;flex:1}
|
|
130
|
-
.dub-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--
|
|
180
|
+
.dub-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--dub-ink-2);padding:3px 0}
|
|
131
181
|
.dub-table{width:100%;border-collapse:collapse;font-size:12px;margin:6px 0}
|
|
132
|
-
.dub-table th{text-align:left;font-weight:600;color:var(--
|
|
133
|
-
.dub-table td{padding:4px 8px 4px 0;border-bottom:.5px solid var(--
|
|
134
|
-
.dub-table td.dub-model{font-family:var(--
|
|
135
|
-
.dub-note{color:var(--
|
|
136
|
-
.dub-warn{color
|
|
182
|
+
.dub-table th{text-align:left;font-weight:600;color:var(--dub-ink-2);padding:4px 8px 4px 0;border-bottom:.5px solid var(--dub-line);white-space:nowrap}
|
|
183
|
+
.dub-table td{padding:4px 8px 4px 0;border-bottom:.5px solid var(--dub-line-soft);font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
184
|
+
.dub-table td.dub-model{font-family:var(--dub-mono);color:var(--dub-ink)}
|
|
185
|
+
.dub-note{color:var(--dub-ink-3);font-size:11px;line-height:1.5}
|
|
186
|
+
.dub-warn{color:var(--dub-warn);font-size:11px}
|
|
137
187
|
/* The version handshake lives at the top of the dialog, above the tabs, so a mismatch is stated
|
|
138
188
|
on whichever tab the reader opened. */
|
|
139
|
-
.dub-version{padding:8px 18px;border-bottom:.5px solid var(--
|
|
189
|
+
.dub-version{padding:8px 18px;border-bottom:.5px solid var(--dub-line);background:var(--dub-surface-soft)}
|
|
140
190
|
/* Explanatory prose lives in these tooltips instead of under every block: the panel is a table of
|
|
141
191
|
numbers, and a wall of notes under each one is what made it hard to scan. */
|
|
142
192
|
.dub-info{flex:none;display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border-radius:50%;border:1px solid currentColor;font-size:9px;font-style:normal;line-height:1;opacity:.5;cursor:help;user-select:none}
|
|
143
193
|
.dub-info:hover{opacity:.9}
|
|
144
|
-
.dub-foot{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:10px 18px;border-top:.5px solid var(--
|
|
145
|
-
.dub-foot input,.dub-foot select,.dub-block input,.dub-block select{font:inherit;font-size:12px;padding:2px 6px;border-radius:6px;border:.5px solid var(--
|
|
146
|
-
.dub-btn{font:inherit;font-size:12px;padding:3px 10px;border-radius:7px;border:.5px solid var(--
|
|
147
|
-
.dub-btn:hover{background:var(--
|
|
194
|
+
.dub-foot{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:10px 18px;border-top:.5px solid var(--dub-line);font-size:12px;color:var(--dub-ink-2)}
|
|
195
|
+
.dub-foot input,.dub-foot select,.dub-block input,.dub-block select{font:inherit;font-size:12px;padding:2px 6px;border-radius:6px;border:.5px solid var(--dub-line-strong);background:var(--dub-surface);color:inherit;width:88px}
|
|
196
|
+
.dub-btn{font:inherit;font-size:12px;padding:3px 10px;border-radius:7px;border:.5px solid var(--dub-line-strong);background:0 0;color:inherit;cursor:pointer;white-space:nowrap}
|
|
197
|
+
.dub-btn:hover{background:var(--dub-hover)}
|
|
148
198
|
.dub-btn:disabled{opacity:.5;cursor:default}
|
|
149
|
-
/* The one filled action in the plugin. --dsw-alias-brand-primary is a
|
|
150
|
-
token in this design system (DSH uses it for text, outlines and the switch track),
|
|
151
|
-
so filling with it left white text on near-white in the dark theme. The DeepSeek
|
|
152
|
-
|
|
153
|
-
.dub-btn-primary{border-color:transparent;background
|
|
154
|
-
.dub-btn-primary:hover{opacity:.9;background
|
|
199
|
+
/* The one filled action in the plugin. Not a theme token: --dsw-alias-brand-primary is a
|
|
200
|
+
*foreground* token in this design system (DSH uses it for text, outlines and the switch track),
|
|
201
|
+
so filling with it left white text on near-white in the dark theme. The DeepSeek blue is written
|
|
202
|
+
out here for the same reason the rest of the panel's palette is. */
|
|
203
|
+
.dub-btn-primary{border-color:transparent;background:#4176e6;color:#fff}
|
|
204
|
+
.dub-btn-primary:hover{opacity:.9;background:#4176e6}
|
|
155
205
|
/* Contributions heatmap: one square per calendar day of the last year. The five
|
|
156
206
|
steps are the same rgba green at rising opacity, so the ramp reads correctly on
|
|
157
207
|
both a light and a dark panel without a theme query. Each step sets fill for the
|
|
@@ -160,30 +210,30 @@ window.__ModuleLoader__.load({
|
|
|
160
210
|
.dub-hm-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:6px}
|
|
161
211
|
.dub-hm-title{flex:1;font-size:12px;font-weight:600}
|
|
162
212
|
.dub-hm-metrics{display:flex;gap:4px}
|
|
163
|
-
.dub-hm-metric{font:inherit;font-size:11px;padding:2px 8px;border-radius:7px;border:.5px solid var(--
|
|
164
|
-
.dub-hm-metric:hover{background:var(--
|
|
165
|
-
.dub-hm-metric-on{background:var(--
|
|
213
|
+
.dub-hm-metric{font:inherit;font-size:11px;padding:2px 8px;border-radius:7px;border:.5px solid var(--dub-line);background:0 0;color:inherit;cursor:pointer}
|
|
214
|
+
.dub-hm-metric:hover{background:var(--dub-hover)}
|
|
215
|
+
.dub-hm-metric-on{background:var(--dub-fill);border-color:transparent;font-weight:600}
|
|
166
216
|
/* The tooltip's percentages are squares of the grid, so it hangs off a wrapper that
|
|
167
217
|
is exactly as tall as the svg rather than off the block (title and legend included). */
|
|
168
218
|
.dub-hm-plot{position:relative}
|
|
169
219
|
.dub-hm-cell{stroke:none}
|
|
170
|
-
.dub-hm-l0{fill:var(--
|
|
220
|
+
.dub-hm-l0{fill:var(--dub-fill);background:var(--dub-fill)}
|
|
171
221
|
.dub-hm-l1{fill:rgba(45,164,78,.25);background:rgba(45,164,78,.25)}
|
|
172
222
|
.dub-hm-l2{fill:rgba(45,164,78,.45);background:rgba(45,164,78,.45)}
|
|
173
223
|
.dub-hm-l3{fill:rgba(45,164,78,.7);background:rgba(45,164,78,.7)}
|
|
174
224
|
.dub-hm-l4{fill:rgba(45,164,78,.95);background:rgba(45,164,78,.95)}
|
|
175
|
-
.dub-hm-month,.dub-hm-wd{fill:var(--
|
|
225
|
+
.dub-hm-month,.dub-hm-wd{fill:var(--dub-ink-3);font-size:9px}
|
|
176
226
|
/* The hover card is the chart's .dub-tip, so this only re-anchors it: the style is
|
|
177
227
|
shared, the position is a fraction of the grid. */
|
|
178
228
|
.dub-hm-tip{margin-top:5px}
|
|
179
229
|
.dub-hm-tip-up{margin-top:-5px;transform:translate(-50%,-100%)}
|
|
180
|
-
.dub-hm-foot{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding-top:6px;font-size:11px;color:var(--
|
|
230
|
+
.dub-hm-foot{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding-top:6px;font-size:11px;color:var(--dub-ink-3)}
|
|
181
231
|
.dub-hm-legend{margin-left:auto;display:inline-flex;align-items:center;gap:4px}
|
|
182
232
|
.dub-hm-swatch{display:inline-block;width:10px;height:10px;border-radius:2px}
|
|
183
233
|
/* The price-row editor. The block already styles its inputs at 88px wide, so the fields
|
|
184
234
|
that need another width are written as a two-class selector to outrank that rule. */
|
|
185
|
-
.dub-form{border-top:.5px solid var(--
|
|
186
|
-
.dub-form-row{display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-size:12px;color:var(--
|
|
235
|
+
.dub-form{border-top:.5px solid var(--dub-line);margin-top:10px;padding-top:10px}
|
|
236
|
+
.dub-form-row{display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-size:12px;color:var(--dub-ink-2);padding:3px 0}
|
|
187
237
|
.dub-form-row label{white-space:nowrap}
|
|
188
238
|
.dub-block .dub-form-key{width:250px}
|
|
189
239
|
.dub-block .dub-form-num{width:74px}
|
|
@@ -191,8 +241,8 @@ window.__ModuleLoader__.load({
|
|
|
191
241
|
.dub-form-check{display:inline-flex;align-items:center;gap:4px}
|
|
192
242
|
.dub-form-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;padding-top:8px}
|
|
193
243
|
.dub-table-ops{display:flex;gap:6px}
|
|
194
|
-
.dub-op{font:inherit;font-size:11px;padding:1px 7px;border-radius:6px;border:.5px solid var(--
|
|
195
|
-
.dub-op:hover{background:var(--
|
|
244
|
+
.dub-op{font:inherit;font-size:11px;padding:1px 7px;border-radius:6px;border:.5px solid var(--dub-line-strong);background:0 0;color:inherit;cursor:pointer;white-space:nowrap}
|
|
245
|
+
.dub-op:hover{background:var(--dub-hover)}
|
|
196
246
|
`
|
|
197
247
|
|
|
198
248
|
/** Inject the stylesheet once per document, guarded the way shipped plugins do. */
|
package/lib/fold.js
CHANGED
|
@@ -302,8 +302,15 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
|
|
|
302
302
|
* The store has already resolved the copies it may hold for a `(session, date)` down to the
|
|
303
303
|
* fuller log version; what arrives here is the merged view, with the raw `rows` alongside for
|
|
304
304
|
* anything that needs to know where a day came from.
|
|
305
|
+
*
|
|
306
|
+
* It is also the only way rows that **arrived from another device** can enter this process: `refresh()`
|
|
307
|
+
* walks the logs, and a synced row has no log here to be discovered by. That is why the host calls it
|
|
308
|
+
* again after a sync run touches the store, and why that second call passes `quiet` — a reload is
|
|
309
|
+
* bookkeeping, not news, and a line per sync would read like the cache was being rebuilt from scratch.
|
|
310
|
+
*
|
|
311
|
+
* @param {{quiet?:boolean}} [options] `quiet` suppresses the summary line (the reload after a sync).
|
|
305
312
|
*/
|
|
306
|
-
function loadCache() {
|
|
313
|
+
function loadCache({ quiet = false } = {}) {
|
|
307
314
|
let loaded
|
|
308
315
|
try {
|
|
309
316
|
loaded = store.load()
|
|
@@ -323,7 +330,7 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
|
|
|
323
330
|
})
|
|
324
331
|
}
|
|
325
332
|
mergeDays()
|
|
326
|
-
log(`cache loaded: ${records.size} session(s) from ${store.kind}`)
|
|
333
|
+
if (!quiet) log(`cache loaded: ${records.size} session(s) from ${store.kind}`)
|
|
327
334
|
return records.size > 0
|
|
328
335
|
}
|
|
329
336
|
|
package/lib/index.js
CHANGED
|
@@ -852,6 +852,33 @@ export function apply(ctx) {
|
|
|
852
852
|
if (!attempt.acquired) return { result: 'locked', message: attempt.reason, at: 0 }
|
|
853
853
|
try {
|
|
854
854
|
const result = await instance.engine.run({ lock: attempt })
|
|
855
|
+
// A run changes the store in two ways that **no log cursor can see**: a pull imports rows another
|
|
856
|
+
// device published, and a withdrawn month deletes the rows that came from it. `refresh()` walks the
|
|
857
|
+
// session logs, so neither one would ever reach the in-memory view — the panel would keep showing
|
|
858
|
+
// what it had until the host process restarted, while the sync page (reading `sync.json`) reported
|
|
859
|
+
// the successful run. Reloading here is what makes "pulled 1 shard" and the numbers agree, and the
|
|
860
|
+
// engine's own `storeChanged` is the signal: it is true only when rows were written or deleted.
|
|
861
|
+
//
|
|
862
|
+
// Not "whenever the run was not locked": a run that never got past a failed request has nothing to
|
|
863
|
+
// reload, `storeChanged` is the engine's own answer to "did rows move", and a host that is on its
|
|
864
|
+
// way out must not touch a cache whose statements are about to be finalized — `syncAbort` is that
|
|
865
|
+
// flag, set by the disposer just before it closes the store. (A future way to *cancel* a run
|
|
866
|
+
// without unloading would leave `syncAbort` alone and would get its reload, which is right: this
|
|
867
|
+
// process keeps living and its view still has to catch up.)
|
|
868
|
+
if (result?.storeChanged && !instance.syncAbort) {
|
|
869
|
+
// Losing this must not turn a sync that worked into a reported failure, which is what an
|
|
870
|
+
// exception here would do on the way out of this `try`: the numbers stay stale (the very thing
|
|
871
|
+
// this reload is for) and the log says why, but the run is still what it was. `loadCache`
|
|
872
|
+
// already swallows a store it cannot read; this covers everything else.
|
|
873
|
+
try {
|
|
874
|
+
aggregator.loadCache({ quiet: true })
|
|
875
|
+
} catch (error) {
|
|
876
|
+
log(`the folded view could not be reloaded after the sync: ${error?.message ?? error}`)
|
|
877
|
+
}
|
|
878
|
+
// Nulled rather than left alone: the next poll would otherwise be allowed to answer from a
|
|
879
|
+
// snapshot built before these rows existed (it is reused for `SNAPSHOT_TTL_MS`).
|
|
880
|
+
snapshot = null
|
|
881
|
+
}
|
|
855
882
|
// A shard may have carried a newer price table; the panel's next poll should see it.
|
|
856
883
|
pricing = readPricing(pricingPaths)
|
|
857
884
|
rules = resolveHolidayRules(pricing.holidays)
|
package/lib/sync.js
CHANGED
|
@@ -827,7 +827,12 @@ export class SyncEngine {
|
|
|
827
827
|
/**
|
|
828
828
|
* Pull: fetch the shards this device does not already hold, and store their rows.
|
|
829
829
|
*
|
|
830
|
-
*
|
|
830
|
+
* `wrote` is the honest answer to "did the store change", which is not the same as "did rows arrive":
|
|
831
|
+
* a shard that decodes into fewer rows than its manifest names is still imported (that part is
|
|
832
|
+
* idempotent) and then reported as a failure, and a tombstone **deletes** rows without any arriving at
|
|
833
|
+
* all. The host uses it to decide whether its in-memory view is now stale.
|
|
834
|
+
*
|
|
835
|
+
* @returns {{fetched:number, rows:number, failed:Array<{path:string, error:string}>, wrote:boolean}}
|
|
831
836
|
*/
|
|
832
837
|
async pull(client, manifests) {
|
|
833
838
|
const wanted = planDownload({
|
|
@@ -851,6 +856,11 @@ export class SyncEngine {
|
|
|
851
856
|
})
|
|
852
857
|
let rows = 0
|
|
853
858
|
let fetched = 0
|
|
859
|
+
// `wrote` comes from the two places that touch the store, and it is the **store's own count** that
|
|
860
|
+
// decides it: a shard replayed against a store that already holds every one of its rows writes nothing,
|
|
861
|
+
// and claiming the view is stale then would cost a full reload for nothing (`rows` cannot answer this —
|
|
862
|
+
// it counts rows the manifest vouched for, which is not the same as rows that changed).
|
|
863
|
+
let wrote = false
|
|
854
864
|
const failed = []
|
|
855
865
|
for (const item of wanted) {
|
|
856
866
|
// One shard at a time, and a shard is the natural boundary: stopping here leaves the store consistent
|
|
@@ -886,7 +896,10 @@ export class SyncEngine {
|
|
|
886
896
|
// and the honest answer is to keep asking. This build never writes such a shard; an older one or a
|
|
887
897
|
// hand-made file can.
|
|
888
898
|
if (decoded.malformed > 0 || (item.entry.rows > 0 && decoded.rows.length !== item.entry.rows)) {
|
|
889
|
-
|
|
899
|
+
// Imported even though the shard is about to be reported as a failure, so the store's own count is
|
|
900
|
+
// what says whether the host's view moved. (`rows` stays at zero here: it counts what the manifest
|
|
901
|
+
// vouched for, and the manifest is exactly what is in question.)
|
|
902
|
+
if (this.store && this.store.intakeRows(decoded.rows) > 0) wrote = true
|
|
890
903
|
failed.push({
|
|
891
904
|
path: item.entry.path,
|
|
892
905
|
error: `the shard holds ${decoded.rows.length} of the ${item.entry.rows || decoded.rows.length} rows its manifest names` + (decoded.malformed ? ` (${decoded.malformed} unreadable line(s))` : ''),
|
|
@@ -904,7 +917,10 @@ export class SyncEngine {
|
|
|
904
917
|
if (this.store) {
|
|
905
918
|
// `replace`: the month is this device's own statement about it, so rows it no longer lists go.
|
|
906
919
|
// Without that, a publisher that dropped a row keeps every reader counting it forever.
|
|
907
|
-
this.store.intakeRows(decoded.rows, { replace: { source: item.device, month: item.key } })
|
|
920
|
+
const imported = this.store.intakeRows(decoded.rows, { replace: { source: item.device, month: item.key } })
|
|
921
|
+
// In replace mode the count includes the rows it *deleted* as well as the ones it wrote, which is
|
|
922
|
+
// exactly the question being asked here.
|
|
923
|
+
if (imported > 0) wrote = true
|
|
908
924
|
// `held` is only advanced once the rows are in the store: if the import throws, the shard
|
|
909
925
|
// must be fetched again rather than remembered as applied.
|
|
910
926
|
this.state.held = { ...(this.state.held ?? {}), [`${item.device}/${item.key}`]: { sha: item.entry.sha256, at: Number(item.entry.at) || 0 } }
|
|
@@ -929,6 +945,7 @@ export class SyncEngine {
|
|
|
929
945
|
// Only what this reader actually imported: a tombstone for a month it never held is nothing.
|
|
930
946
|
if (!this.state.held?.[context]) continue
|
|
931
947
|
const removed = this.store?.dropSourceMonth(device, key) ?? 0
|
|
948
|
+
if (removed > 0) wrote = true
|
|
932
949
|
const next = { ...(this.state.held ?? {}) }
|
|
933
950
|
delete next[context]
|
|
934
951
|
this.state.held = next
|
|
@@ -937,7 +954,7 @@ export class SyncEngine {
|
|
|
937
954
|
this.log(`dropped ${removed} row(s) from ${context}: the device that published it no longer holds that month (withdrawn ${entry.at ? new Date(entry.at).toISOString().slice(0, 10) : 'at some point'})`)
|
|
938
955
|
}
|
|
939
956
|
}
|
|
940
|
-
return { fetched, rows, failed, dropped, droppedKeys }
|
|
957
|
+
return { fetched, rows, failed, dropped, droppedKeys, wrote }
|
|
941
958
|
}
|
|
942
959
|
|
|
943
960
|
/**
|
|
@@ -1318,9 +1335,15 @@ export class SyncEngine {
|
|
|
1318
1335
|
// statements are finalized, so continuing is not harmless.
|
|
1319
1336
|
if (this.aborted) return this.finish('aborted', 'the host unloaded during the sync', { started })
|
|
1320
1337
|
|
|
1338
|
+
// Whether this run changed the local store, which is a different question from whether it succeeded:
|
|
1339
|
+
// a pull that imported rows and then failed to publish them has still made the host's in-memory view
|
|
1340
|
+
// stale. Declared outside the `try` so every path out of it — including a failure and an abort — can
|
|
1341
|
+
// report it. The host decides what to do with it: an abort because *it* is unloading is skipped there
|
|
1342
|
+
// (`syncAbort`), while an abort for any other reason still has a live process to catch up.
|
|
1343
|
+
let storeChanged = false
|
|
1321
1344
|
try {
|
|
1322
1345
|
const { manifests } = await this.readManifests(client)
|
|
1323
|
-
if (this.aborted) return this.finish('aborted', 'the host unloaded during the sync', { started })
|
|
1346
|
+
if (this.aborted) return this.finish('aborted', 'the host unloaded during the sync', { started, extra: { storeChanged } })
|
|
1324
1347
|
// **Pull first, refuse only the push.** An installation whose device name is taken still wants the
|
|
1325
1348
|
// history back, and that is the case the feature exists for (reinstall, another machine): the name
|
|
1326
1349
|
// is what is in the way, not the data. So the clash is noted here — which is what tells `pull` to
|
|
@@ -1334,8 +1357,9 @@ export class SyncEngine {
|
|
|
1334
1357
|
// refusing to publish — the one case where the two readings of `collision` differ.
|
|
1335
1358
|
this.pullOwnDirectory = Boolean(clash)
|
|
1336
1359
|
const pulled = await this.pull(client, manifests)
|
|
1360
|
+
storeChanged = pulled.wrote
|
|
1337
1361
|
this.pullOwnDirectory = false
|
|
1338
|
-
if (this.aborted) return this.finish('aborted', `the host unloaded during the sync (${pulled.fetched} shard(s) had arrived)`, { started })
|
|
1362
|
+
if (this.aborted) return this.finish('aborted', `the host unloaded during the sync (${pulled.fetched} shard(s) had arrived)`, { started, extra: { storeChanged } })
|
|
1339
1363
|
const pushed = await this.push(client)
|
|
1340
1364
|
if (pushed.manifest) {
|
|
1341
1365
|
this.state.published = pushed.manifest.chunks
|
|
@@ -1345,7 +1369,7 @@ export class SyncEngine {
|
|
|
1345
1369
|
}
|
|
1346
1370
|
// The price table last: it is the only hand-written thing here, and it should not be touched by
|
|
1347
1371
|
// a run that failed to move the usage it was about to move.
|
|
1348
|
-
if (this.aborted) return this.finish('aborted', 'the host unloaded during the sync', { started })
|
|
1372
|
+
if (this.aborted) return this.finish('aborted', 'the host unloaded during the sync', { started, extra: { storeChanged } })
|
|
1349
1373
|
const pricing = await this.syncPricing(client)
|
|
1350
1374
|
const parts = [
|
|
1351
1375
|
`pulled ${pulled.fetched} shard(s) / ${pulled.rows} row(s)`,
|
|
@@ -1381,12 +1405,14 @@ export class SyncEngine {
|
|
|
1381
1405
|
refused: pushed.refused ?? null,
|
|
1382
1406
|
withdrawn: pushed.withdrawn ?? 0,
|
|
1383
1407
|
dropped: pulled.dropped ?? 0,
|
|
1408
|
+
// Named for the host, not for the panel: it is what tells the host its aggregate is now stale.
|
|
1409
|
+
storeChanged,
|
|
1384
1410
|
},
|
|
1385
1411
|
})
|
|
1386
1412
|
} catch (error) {
|
|
1387
1413
|
const message = String(error?.message ?? error)
|
|
1388
1414
|
this.log(`sync failed: ${message}`)
|
|
1389
|
-
return this.finish('failed', message, { started })
|
|
1415
|
+
return this.finish('failed', message, { started, extra: { storeChanged } })
|
|
1390
1416
|
}
|
|
1391
1417
|
}
|
|
1392
1418
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-usage-badge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "DeepSeek Harness 的用量徽标插件:侧边栏底部一行显示当天的 token 总量与估算费用,点开可看 24 小时 / 近 7 日 / 近 30 日 / 近 12 个月 / 按年用量,按 provider 筛选,并按 DeepSeek 官方定价与中国法定节假日计价。A usage badge plugin for DeepSeek Harness: today's tokens and estimated spend in the sidebar, with 24h / 7d / 30d / 12-month and per-year charts, official DeepSeek pricing and Chinese public holidays.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|