dsh-weread-gather 1.1.0 → 1.1.2

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.
Files changed (4) hide show
  1. package/README.md +24 -6
  2. package/client.js +204 -106
  3. package/index.js +168 -9
  4. package/package.json +3 -3
package/README.md CHANGED
@@ -16,6 +16,8 @@
16
16
 
17
17
  总览:置顶的年度阅读热力图,KPI、月度分布与表盘都跟着右上角的年份切。
18
18
 
19
+ 配色是浅绿纸感(页面 `#F6F8F7`、强调绿 `#4F9274`、热力图五档 `#EDF2EF → #438765`),通过重映射 `--dsw-alias-*` 跟随宿主的亮/暗主题。
20
+
19
21
  ![总览](assets/screenshot-2-overview.png)
20
22
 
21
23
  ## 安装
@@ -51,8 +53,9 @@ dsh plugin --profile <profile> add link:D:/path/to/dsh-weread-gather
51
53
  | 笔记 | 全部划线按时间倒流,**每页 24 条翻页**、按书筛选、关键词搜索、**单条复制**、**点卡片跳到该书详情** |
52
54
  | 想法 | 想法卡片墙(瀑布流式多列),**每页 20 条翻页**、按书筛选、关键词搜索、**单条复制**、**点卡片跳到该书详情(自动切到想法页)**、星级与章节 |
53
55
  | 书籍详情 | 封面、作者、大类 + 细分分类/读完/评分/出版社/出版年徽章、简介、该书全部划线 + 想法(每页 24 条翻页、可复制)、一键在微读打开、**「导出这本书」直接生成整本 Markdown** |
54
- | 全局搜索 | 顶栏搜索同时搜书名、作者、分类、划线正文、想法正文;结果按**书籍 / 划线 / 想法**分类切换,命中的关键词在卡片里高亮;`⌘K` / `Ctrl+K` 随时聚焦搜索框 |
56
+ | 全局搜索 | 顶栏搜索同时搜书名、作者、分类、划线正文、想法正文;结果按**书籍 / 划线 / 想法**分类切换,命中的关键词在卡片里高亮 |
55
57
  | 导出 Markdown | 划线/想法卡片左上角圆圈勾选(悬停出现)→「导出所选」;也可「导出全部结果」或只导出某一本。导出面板可关掉原文/想法/书籍信息/章节标题/星级/微读链接,**先预览前 30 行**,再选择复制、下载 `.md` 或直接保存到导出目录(默认 `$DSH_HOME/weread-gather/exports/`,同名自动加 `-2` 序号) |
58
+ | 同步提示 | 同步进行中在页面顶部显示进度条,完成后停留 8 秒自动消失,也可以随时点右侧 `×` 关掉;数据没变化时同步只花 2~3 秒 |
56
59
  | 设置 | 展示 Key 来源(环境变量或手填)、书架上限、每本书同步笔记条数、单次补全书籍信息上限、**Markdown 导出目录**、**下载备份 / 导入备份(两步确认,导入前自动另存当前数据)**、清空数据 |
57
60
 
58
61
  每一项(书架/笔记/想法/搜索/书籍详情内的列表)都有自己的搜索框;顶栏搜索是跨模块的全局搜索。
@@ -121,6 +124,17 @@ tags: ["微信读书"]
121
124
 
122
125
  这两类结果缓存在 `heat.json`:**已过年份永久缓存**,当年缓存 30 分钟后过期重取,所以切年份几乎瞬时。
123
126
 
127
+ ### 同步是增量的
128
+
129
+ 每次同步先读 `sync-state.json` 这本账,只处理「真的变了」的书:
130
+
131
+ - 书架、分类、笔记本清单每次都读(清单约 5 个请求,且它是未在书架前 400 本里的书的主要来源,所以不能提前停)
132
+ - 四套阅读统计 **6 小时**内直接复用,不重新请求
133
+ - 每本书按 `lastNoteTime / noteCount / reviewCount` 三项比对:没变化的书**不重拉划线**,旧划线/想法直接沿用
134
+ - 单次最多细读 `maxBooks` 本(默认 60);账本会记下还有多少本从未细读过(`pending`),需要补齐时在设置里点「强制全量重建」
135
+
136
+ 实测:本机 779 本 / 694 条划线的库,首次全量约 8.4 秒,之后没有变化的同步 **2~3 秒**(`更新 0 本 · 跳过 470 本`)。
137
+
124
138
  ## 数据落点与隐私
125
139
 
126
140
  ```
@@ -129,8 +143,9 @@ $DSH_HOME/weread-gather/
129
143
  data.json 书架 + 分类 + 划线 + 想法 + 阅读统计
130
144
  meta.json 每本书的元信息缓存(bookId -> 分类/简介/出版社/出版年/评分)
131
145
  heat.json 逐年热力图与年度聚合缓存({ "2025": { v, at, days, annual } })
146
+ sync-state.json 同步账本(上次同步时间、每本书的计数与是否细读过、统计缓存时间)
132
147
  exports/ Markdown 导出(默认目录,可在设置里改)
133
- backups/ 导入备份前自动留存的旧数据(<时间戳>/ 四个 JSON)
148
+ backups/ 导入备份前自动留存的旧数据(<时间戳>/ 五个 JSON)
134
149
  ```
135
150
 
136
151
  - 所有数据都在这一个目录里,插件不写别处;`data.json` 只在同步时整体重写,页面读内存副本
@@ -150,9 +165,9 @@ $DSH_HOME/weread-gather/
150
165
  | POST | `/export` | `{ selector, options, mode }`:`selector` 用 `bookIds` / `noteIds` / `thoughtIds`,`mode: 'preview'` 只回 Markdown、`'file'` 落盘;**空 selector 会报错,不会导出全部** |
151
166
  | GET | `/export/dir` | 当前导出目录与是否已存在 |
152
167
  | POST | `/export/dir` | 改导出目录(会先写探针文件确认可写,失败回滚) |
153
- | GET | `/backup` | 一次性备份包 `{ format, files: { config/data/meta/heat } }`,`config.json` 已剔除 apiKey |
168
+ | GET | `/backup` | 一次性备份包 `{ format, files: { config/data/meta/heat/sync-state } }`,`config.json` 已剔除 apiKey |
154
169
  | POST | `/restore` | `{ bundle }`:校验后先复制旧数据到 `backups/<时间戳>/`,再写回;config 合并时保留本机 apiKey |
155
- | POST | `/sync` | 触发一次同步(异步,立刻返回进度;用 `/state` 或 `/sync` 轮询) |
170
+ | POST | `/sync` | 触发一次同步(异步,立刻返回进度;用 `/state` 或 `/sync` 轮询)。`{ "full": true }` = 忽略账本强制全量重建 |
156
171
  | POST | `/config` | 改 apiKey / maxBooks / notesPerBook / metaLimit |
157
172
  | POST | `/reset` | 清掉手填的 Key |
158
173
 
@@ -163,8 +178,9 @@ $DSH_HOME/weread-gather/
163
178
  ```bash
164
179
  node --check index.js && node --check client.js # 语法
165
180
  node scripts/check.cjs # 组件引用 / locale 键对称 / 宿主-客户端契约 / 包身份一致性
166
- node scripts/render-smoke.cjs # 用极简 hooks 运行时把 10 种视图各渲染一遍
181
+ node scripts/render-smoke.cjs # 用极简 hooks 运行时把 11 种视图各渲染一遍
167
182
  node scripts/export-selftest.cjs # 45 项 Markdown 导出断言(front-matter、哈希、批量、隔离)
183
+ node scripts/sync-selftest.cjs # 10 项增量同步断言(重拉判定、账本搬运),不联网
168
184
  ```
169
185
 
170
186
  三个脚本都不依赖浏览器:`render-smoke.cjs` 自建了极简 hooks 运行时,把 `client.js` 放进 `vm` 里逐个视图渲染,断言不会退化成加载态、笔记/想法必须带分页与复制控件;`export-selftest.cjs` 直接 `import` 宿主的 `__internals`,拿真实 `data.json` 跑导出断言。两者默认读 `~/.dsh/weread-gather/data.json`,可用 `WEREAD_DATA` 指到别的数据文件(需要一个已经同步过的数据集)。
@@ -172,7 +188,9 @@ node scripts/export-selftest.cjs # 45 项 Markdown 导出断言
172
188
  ## 已知限制
173
189
 
174
190
  - 「其他」桶里的书是微读自己的数据缺口:多半是 `CB_` 前缀的导入文档 / PDF,`/book/info` 也不返回分类,无法自动归类。
175
- - 笔记同步按「最近有笔记的 N 本书」(默认 60 本)展开,不是全量;把 `maxBooks` 调大可多抓,代价是请求数线性增长。
191
+ - 笔记同步按「最近有笔记的 N 本书」(默认 60 本)展开,不是全量;账本会记下还有多少本从未细读过(同步结果里的 `pending`)。想补齐就在设置里点「强制全量重建」,或把 `maxBooks` 调大(代价是请求数线性增长)。
192
+ - 增量同步依赖 `sync-state.json` 这本账;删掉它不会丢数据,但下一次同步会按全量走一遍。
193
+ - 主题跟随宿主亮/暗主题的方式是重映射 `--dsw-alias-*` token;深色模式下纸感配色会被压暗,观感与浅色模式不同。
176
194
  - `/book/info` 实测不返回字数(文档有写、回包里没有),所以卡片上用的是出版年而不是字数。
177
195
  - 微读的 `/readdata/detail` 只在 `mode: overall` 时给 24 小时时段分布,年/月/周都返回空数组,所以「阅读时段」那张图只能展示累计值,不随年份切换(标题里有标注)。
178
196
  - 切到往年后,本周/本月两张卡会隐藏——它们本来就是「当前」的概念;往年的信息由年度 KPI 与月度分布承载。
package/client.js CHANGED
@@ -18,6 +18,8 @@ window.__ModuleLoader__.load({
18
18
  const API = 'dsh-weread/api'
19
19
  /** Sidebar entry id; also the key of the center-column panel. */
20
20
  const PANEL_ID = 'weread'
21
+ /** A finished sync message clears itself after this long (ms). */
22
+ const AUTO_HIDE_MS = 8000
21
23
 
22
24
  // ------------------------------------------------------------------ copy
23
25
  const zh = {
@@ -35,10 +37,10 @@ window.__ModuleLoader__.load({
35
37
  'action.searchGlobal': '搜索书架、笔记、想法…',
36
38
  'action.save': '保存',
37
39
  'action.cancel': '取消',
40
+ 'action.dismiss': '关闭提示',
38
41
  'action.clearKey': '清除 Key',
39
42
  'action.back': '返回',
40
43
  'action.retry': '重试',
41
- 'action.openWeread': '在微信读书打开',
42
44
  'action.loadMore': '加载更多',
43
45
  'action.copy': '复制',
44
46
  'action.copied': '已复制',
@@ -98,6 +100,8 @@ window.__ModuleLoader__.load({
98
100
  'dash.readRate': '文字 / 听书',
99
101
  'dash.text': '文字阅读',
100
102
  'dash.audio': '有声听书',
103
+ 'dash.heat.none': '无阅读',
104
+ 'dash.heat.future': '还没到',
101
105
  'dash.less': '少',
102
106
  'dash.more': '多',
103
107
  'dash.empty': '还没有阅读统计,先同步一次。',
@@ -189,6 +193,9 @@ window.__ModuleLoader__.load({
189
193
  'settings.dirSaved': '已更新导出目录',
190
194
  'settings.backup': '下载备份',
191
195
  'settings.backupHint': '包含书架、划线、想法、统计缓存与本机设置,不含 API Key。',
196
+ 'settings.fullSync': '强制全量重建',
197
+ 'settings.fullSyncHint': '平时只同步有变化的书;重建会重新拉取全部笔记与想法,比较慢。',
198
+ 'settings.fullSyncStarted': '已开始全量重建,完成后会刷新数据',
192
199
  'settings.restore': '导入备份',
193
200
  'settings.restorePick': '选择备份文件',
194
201
  'settings.restoreConfirm': '将用备份覆盖本机数据,会先把当前数据另存一份。确定恢复吗?',
@@ -215,10 +222,10 @@ window.__ModuleLoader__.load({
215
222
  'action.searchGlobal': 'Search shelf, notes, thoughts…',
216
223
  'action.save': 'Save',
217
224
  'action.cancel': 'Cancel',
225
+ 'action.dismiss': 'Dismiss the message',
218
226
  'action.clearKey': 'Clear key',
219
227
  'action.back': 'Back',
220
228
  'action.retry': 'Retry',
221
- 'action.openWeread': 'Open in WeRead',
222
229
  'action.loadMore': 'Load more',
223
230
  'action.copy': 'Copy',
224
231
  'action.copied': 'Copied',
@@ -278,6 +285,8 @@ window.__ModuleLoader__.load({
278
285
  'dash.readRate': 'Text / audio',
279
286
  'dash.text': 'E-books read',
280
287
  'dash.audio': 'Audiobooks heard',
288
+ 'dash.heat.none': 'No reading',
289
+ 'dash.heat.future': 'Not yet',
281
290
  'dash.less': 'Less',
282
291
  'dash.more': 'More',
283
292
  'dash.empty': 'No reading stats yet — sync once.',
@@ -369,6 +378,9 @@ window.__ModuleLoader__.load({
369
378
  'settings.dirSaved': 'Export folder updated',
370
379
  'settings.backup': 'Download backup',
371
380
  'settings.backupHint': 'Shelf, highlights, thoughts, statistics cache and local settings — API key excluded.',
381
+ 'settings.fullSync': 'Full rebuild',
382
+ 'settings.fullSyncHint': 'Normal syncs only read books that changed; a rebuild re-reads every highlight and is slower.',
383
+ 'settings.fullSyncStarted': 'Full rebuild started — data refreshes when it finishes',
372
384
  'settings.restore': 'Import backup',
373
385
  'settings.restorePick': 'Choose a backup file',
374
386
  'settings.restoreConfirm': 'This overwrites local data (the current copy is saved first). Restore now?',
@@ -408,7 +420,7 @@ window.__ModuleLoader__.load({
408
420
  const apiState = () => request('/state')
409
421
  const apiData = () => request('/data')
410
422
  const apiHeat = (year) => request(`/heat?year=${encodeURIComponent(String(year))}`)
411
- const apiSync = () => request('/sync', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
423
+ const apiSync = (options) => request('/sync', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(options && options.full === true ? { full: true } : {}) })
412
424
  const apiConfig = (patch) => request('/config', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(patch) })
413
425
  const apiReset = () => request('/reset', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
414
426
  const jsonPost = (path, body) => request(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body === undefined ? {} : body) })
@@ -448,79 +460,82 @@ window.__ModuleLoader__.load({
448
460
  function contains(haystack, needle) {
449
461
  return haystack !== undefined && haystack !== null && String(haystack).toLowerCase().includes(needle)
450
462
  }
451
- function wereadBookLink(bookId) {
452
- if (typeof bookId !== 'string' || bookId === '' || bookId.startsWith('album:')) return ''
453
- return `weread://reading?bId=${bookId}`
463
+ /** Hard cut for option labels: a long book title must not widen the popup. */
464
+ function clip(text, max) {
465
+ const value = text === undefined || text === null ? '' : String(text)
466
+ if (value.length <= max) return value
467
+ return `${value.slice(0, Math.max(1, max - 1))}…`
454
468
  }
455
469
 
456
470
  // ----------------------------------------------------------------- styles
457
471
  const CSS = `
458
- .wr-root{display:flex;flex-direction:column;height:100%;min-height:0;width:100%;box-sizing:border-box;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-family:inherit}
459
- .wr-head{flex:none;display:flex;align-items:center;gap:14px;padding:12px 20px;border-bottom:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);flex-wrap:wrap}
472
+ .wr-root{--wr-paper:#F6F8F7;--wr-paper-soft:#FFFFFF;--wr-wash:#F0F4F1;--wr-ink:#263C34;--wr-ink-soft:#6B7F74;--wr-ink-mute:#93A49B;--wr-sage:#A6CCB5;--wr-moss:#4F9274;--wr-clay:#A57F5B;--wr-progress:#73AD8A;--wr-track:#F0F4F1;--wr-line:#E7EEEA;--wr-line-2:#DCE6E0;--wr-heat-0:#EDF2EF;--wr-heat-1:#D5E9DD;--wr-heat-2:#A9D3BA;--wr-heat-3:#74B28E;--wr-heat-4:#438765;--dsw-alias-bg-base:var(--wr-paper);--dsw-alias-bg-layer-1:var(--wr-paper-soft);--dsw-alias-bg-layer-2:var(--wr-wash);--dsw-alias-label-primary:var(--wr-ink);--dsw-alias-label-secondary:var(--wr-ink-soft);--dsw-alias-border-l1:var(--wr-line);--dsw-alias-border-l2:var(--wr-line-2);--dsw-alias-brand-primary:var(--wr-moss);--dsw-alias-state-success-primary:#4F7A5B;--dsw-alias-state-warn-primary:#B98A57;--dsw-alias-state-error-primary:#A8574C;display:flex;flex-direction:column;height:100%;min-height:0;width:100%;box-sizing:border-box;background:radial-gradient(900px 420px at 50% -18%,#FFFFFF 0%,transparent 70%),var(--wr-paper);color:var(--wr-ink);font-family:inherit}
473
+ .wr-head{flex:none;display:flex;align-items:center;gap:16px;padding:14px 26px;border-bottom:1px solid var(--dsw-alias-border-l1);background:rgba(255,255,255,.86);backdrop-filter:blur(10px);flex-wrap:wrap}
460
474
  .wr-brand{display:flex;flex-direction:column;gap:1px;min-width:0}
461
- .wr-brand-title{font-size:15px;font-weight:700;letter-spacing:.02em}
462
- .wr-brand-sub{font-size:11px;color:var(--dsw-alias-label-secondary)}
463
- .wr-tabs{display:flex;gap:2px;padding:3px;border-radius:10px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1)}
464
- .wr-tab{appearance:none;border:0;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12.5px;padding:5px 12px;border-radius:8px;cursor:pointer;white-space:nowrap;transition:background .12s,color .12s}
475
+ .wr-brand-title{font-size:15.5px;font-weight:700;letter-spacing:.07em;color:var(--wr-ink)}
476
+ .wr-brand-sub{font-size:11px;color:var(--wr-ink-mute);letter-spacing:.05em}
477
+ .wr-tabs{display:flex;gap:2px;padding:4px;border-radius:999px;background:var(--wr-wash);border:1px solid transparent}
478
+ .wr-tab{appearance:none;border:0;background:transparent;color:var(--wr-ink-soft);font:inherit;font-size:12.5px;padding:6px 15px;border-radius:999px;cursor:pointer;white-space:nowrap;transition:background .16s ease,color .16s ease}
465
479
  .wr-tab:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1)}
466
- .wr-tab[data-active=true]{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base);font-weight:600;box-shadow:0 1px 2px rgba(0,0,0,.08)}
480
+ .wr-tab[data-active=true]{color:var(--wr-ink);background:var(--wr-paper-soft);font-weight:600;box-shadow:0 1px 2px rgba(38,60,52,.08)}
467
481
  .wr-head-right{margin-left:auto;display:flex;align-items:center;gap:8px;min-width:0;flex-wrap:wrap}
468
482
  .wr-search{position:relative;display:flex;align-items:center}
469
- .wr-search input{box-sizing:border-box;height:30px;width:230px;max-width:44vw;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font:inherit;font-size:12.5px;padding:0 26px 0 10px;outline:none}
470
- .wr-search input:focus{border-color:var(--dsw-alias-brand-primary)}
483
+ .wr-search input{box-sizing:border-box;height:32px;width:232px;max-width:44vw;border-radius:999px;border:1px solid var(--wr-line-2);background:var(--wr-paper-soft);color:var(--wr-ink);font:inherit;font-size:12.5px;padding:0 28px 0 13px;outline:none;transition:border-color .16s ease,box-shadow .16s ease}
484
+ .wr-search input:focus{border-color:var(--wr-moss);box-shadow:0 0 0 3px rgba(79,146,116,.14)}
471
485
  .wr-search input::placeholder{color:var(--dsw-alias-label-secondary)}
472
486
  .wr-search-clear{position:absolute;right:4px;width:20px;height:20px;border:0;border-radius:50%;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:13px;line-height:1;display:flex;align-items:center;justify-content:center}
473
487
  .wr-search-clear:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
474
- .wr-btn{appearance:none;display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 12px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font:inherit;font-size:12.5px;font-weight:500;cursor:pointer;white-space:nowrap;transition:background .12s,border-color .12s}
475
- .wr-btn:hover{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-border-l2)}
488
+ .wr-btn{appearance:none;display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 14px;border-radius:999px;border:1px solid var(--wr-line-2);background:var(--wr-paper-soft);color:var(--wr-ink);font:inherit;font-size:12.5px;font-weight:500;cursor:pointer;white-space:nowrap;transition:background .16s ease,border-color .16s ease,transform .16s ease}
489
+ .wr-btn:hover{background:var(--wr-wash);border-color:var(--wr-sage)}
476
490
  .wr-btn:disabled{opacity:.55;cursor:default}
477
491
  .wr-btn-primary{border-color:transparent;background:var(--dsw-alias-brand-primary);color:#fff}
478
492
  .wr-btn-primary:hover{background:var(--dsw-alias-brand-primary);filter:brightness(1.06)}
479
- .wr-btn-ghost{border-color:transparent;background:transparent}
480
- .wr-body{flex:1;min-height:0;overflow:auto;padding:20px 22px 40px}
481
- .wr-body-inner{max-width:1180px;margin:0 auto}
482
- .wr-card{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:14px;padding:16px 18px}
483
- .wr-section{margin-top:18px}
493
+ .wr-btn-ghost{border-color:var(--wr-line);background:var(--wr-paper-soft)}
494
+ .wr-btn-ghost:hover{background:var(--wr-wash);border-color:var(--wr-sage)}
495
+ .wr-body{flex:1;min-height:0;overflow:auto;padding:26px 30px 64px;scrollbar-width:thin;scrollbar-color:#CFD8C6 transparent}
496
+ .wr-body-inner{max-width:1240px;margin:0 auto}
497
+ .wr-card{background:var(--wr-paper-soft);border:1px solid var(--wr-line);border-radius:16px;padding:20px 22px;box-shadow:0 1px 2px rgba(38,60,52,.04)}
498
+ .wr-section{margin-top:22px}
484
499
  .wr-section:first-child{margin-top:0}
485
- .wr-section-title{display:flex;align-items:baseline;gap:8px;margin:0 0 10px;font-size:13px;font-weight:700}
500
+ .wr-section-title{display:flex;align-items:baseline;gap:8px;margin:0 0 12px;font-size:13.5px;font-weight:700;letter-spacing:.03em}
486
501
  .wr-section-hint{font-size:11.5px;font-weight:400;color:var(--dsw-alias-label-secondary)}
487
502
  .wr-muted{color:var(--dsw-alias-label-secondary);font-size:12px}
488
503
  .wr-empty{padding:36px 10px;text-align:center;color:var(--dsw-alias-label-secondary);font-size:13px}
489
504
  .wr-empty strong{display:block;color:var(--dsw-alias-label-primary);font-size:14px;margin-bottom:6px}
490
505
  .wr-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}
491
- .wr-stat{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:14px;padding:14px 16px}
506
+ .wr-stat{background:var(--wr-paper-soft);border:1px solid var(--wr-line);border-radius:16px;padding:16px 18px;box-shadow:0 1px 2px rgba(38,60,52,.03)}
492
507
  .wr-stat-label{font-size:11.5px;color:var(--dsw-alias-label-secondary);letter-spacing:.04em}
493
508
  .wr-stat-value{font-size:22px;font-weight:700;margin-top:6px;font-variant-numeric:tabular-nums;line-height:1.15}
494
509
  .wr-stat-hint{font-size:11.5px;color:var(--dsw-alias-label-secondary);margin-top:6px;display:flex;gap:10px;flex-wrap:wrap}
495
510
  .wr-kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(112px,1fr));gap:10px;margin-top:14px}
496
- .wr-kpi{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:12px;padding:10px 12px;min-width:0}
511
+ .wr-kpi{background:var(--wr-paper-soft);border:1px solid var(--wr-line);border-radius:14px;padding:12px 14px;min-width:0}
497
512
  .wr-kpi-value{font-size:19px;font-weight:700;font-variant-numeric:tabular-nums;line-height:1.2;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
498
513
  .wr-kpi-label{font-size:11px;color:var(--dsw-alias-label-secondary);margin-top:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
499
514
  .wr-kpi-hint{font-size:10px;color:var(--dsw-alias-label-secondary);opacity:.75;margin-top:2px}
500
515
  .wr-chip-stat{font-size:11.5px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:3px 9px;white-space:nowrap}
501
516
  .wr-chips{display:flex;gap:6px;flex-wrap:wrap}
502
517
  .wr-chip{appearance:none;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:4px 11px;font:inherit;font-size:12px;cursor:pointer;white-space:nowrap;transition:all .12s}
503
- .wr-chip:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l2)}
518
+ .wr-chip:hover{color:var(--wr-ink);border-color:var(--wr-sage)}
504
519
  .wr-chip[data-active=true]{background:var(--dsw-alias-brand-primary);border-color:transparent;color:#fff;font-weight:600}
505
520
  .wr-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px}
506
521
  .wr-toolbar-fill{flex:1;min-width:0}
507
- .wr-input{box-sizing:border-box;height:30px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font:inherit;font-size:12.5px;padding:0 10px;outline:none}
508
- .wr-input:focus{border-color:var(--dsw-alias-brand-primary)}
509
- .wr-select{box-sizing:border-box;height:30px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font:inherit;font-size:12.5px;padding:0 8px;outline:none;max-width:220px}
522
+ .wr-input{box-sizing:border-box;height:32px;border-radius:10px;border:1px solid var(--wr-line-2);background:var(--wr-paper-soft);color:var(--wr-ink);font:inherit;font-size:12.5px;padding:0 11px;outline:none;transition:border-color .16s ease,box-shadow .16s ease}
523
+ .wr-input:focus{border-color:var(--wr-moss);box-shadow:0 0 0 3px rgba(79,146,116,.14)}
524
+ .wr-select{box-sizing:border-box;height:32px;border-radius:10px;border:1px solid var(--wr-line-2);background:var(--wr-paper-soft);color:var(--wr-ink);font:inherit;font-size:12.5px;padding:0 9px;outline:none;max-width:220px;cursor:pointer}
510
525
  .wr-grid{display:grid;grid-template-columns:repeat(10,minmax(0,1fr));gap:12px}
511
526
  @media (max-width:1240px){.wr-grid{grid-template-columns:repeat(8,minmax(0,1fr))}}
512
527
  @media (max-width:920px){.wr-grid{grid-template-columns:repeat(6,minmax(0,1fr))}}
513
528
  @media (max-width:640px){.wr-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}
514
529
  .wr-book{appearance:none;border:0;background:transparent;padding:0;margin:0;text-align:left;cursor:pointer;display:flex;flex-direction:column;gap:6px;font:inherit;color:inherit}
515
- .wr-book-cover-wrap{position:relative;border-radius:6px;overflow:hidden;box-shadow:0 3px 10px rgba(0,0,0,.16);transition:transform .16s,box-shadow .16s}
516
- .wr-book-cover-wrap{position:relative;border-radius:8px;overflow:hidden;box-shadow:0 6px 18px rgba(0,0,0,.18);transition:transform .16s,box-shadow .16s}
517
- .wr-book:hover .wr-book-cover-wrap{transform:translateY(-2px);box-shadow:0 8px 18px rgba(0,0,0,.24)}
530
+ .wr-book-cover-wrap{position:relative;border-radius:8px;overflow:hidden;box-shadow:0 3px 8px rgba(38,60,52,.10),0 14px 26px -18px rgba(38,60,52,.5);transition:transform .2s ease,box-shadow .2s ease}
531
+ .wr-book-cover-wrap{position:relative;border-radius:8px;overflow:hidden;box-shadow:0 3px 8px rgba(38,60,52,.10),0 14px 26px -18px rgba(38,60,52,.5);transition:transform .2s ease,box-shadow .2s ease}
532
+ .wr-book:hover .wr-book-cover-wrap{transform:translateY(-3px);box-shadow:0 8px 16px rgba(38,60,52,.14),0 22px 34px -20px rgba(38,60,52,.55)}
518
533
  .wr-cover{aspect-ratio:3/4;width:100%;background:var(--dsw-alias-bg-layer-2);display:block;overflow:hidden;position:relative}
519
534
  .wr-cover img{width:100%;height:100%;object-fit:cover;display:block}
520
- .wr-cover-fallback{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:30px;font-weight:700;color:rgba(255,255,255,.92);background:linear-gradient(140deg,#5c6b8a,#2f3a52)}
535
+ .wr-cover-fallback{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:30px;font-weight:700;color:rgba(255,255,255,.94);background:linear-gradient(150deg,#A8B9A3,#5F7562)}
521
536
  .wr-cover-badges{position:absolute;left:4px;top:4px;display:flex;gap:3px;flex-wrap:wrap;max-width:calc(100% - 8px)}
522
- .wr-badge{font-size:9px;line-height:1.5;padding:0 4px;border-radius:999px;background:rgba(0,0,0,.55);color:#fff;backdrop-filter:blur(3px)}
523
- .wr-badge[data-tone=brand]{background:rgba(60,110,220,.85)}
537
+ .wr-badge{font-size:9px;line-height:1.5;padding:0 5px;border-radius:999px;background:rgba(38,60,52,.62);color:#FCFAF4;backdrop-filter:blur(3px)}
538
+ .wr-badge[data-tone=brand]{background:rgba(111,138,116,.92)}
524
539
  .wr-book-title{font-size:11px;font-weight:600;line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
525
540
  .wr-book-author{font-size:10px;color:var(--dsw-alias-label-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
526
541
  .wr-book-meta{display:flex;gap:4px;font-size:9.5px;color:var(--dsw-alias-label-secondary);align-items:center;flex-wrap:wrap}
@@ -528,7 +543,9 @@ window.__ModuleLoader__.load({
528
543
  .wr-progress span{display:block;height:100%;background:var(--dsw-alias-brand-primary);border-radius:2px}
529
544
  .wr-hour{display:grid;grid-template-columns:repeat(24,minmax(0,1fr));gap:3px;align-items:end;height:120px}
530
545
  .wr-hour-col{display:flex;flex-direction:column;justify-content:flex-end;align-items:center;gap:4px;height:100%}
531
- .wr-hour-bar{width:100%;border-radius:3px 3px 0 0;background:var(--dsw-alias-brand-primary);min-height:2px;opacity:.85}
546
+ .wr-hour-bar{width:100%;border-radius:3px 3px 0 0;background:var(--wr-sage);min-height:2px}
547
+ .wr-hour-col:hover .wr-hour-bar,.wr-hour-bar[data-active=true]{background:var(--wr-moss)}
548
+ .wr-hour-label[data-active=true]{color:var(--wr-ink);font-weight:600}
532
549
  .wr-hour-label{font-size:9px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
533
550
  .wr-months{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:6px;align-items:end;height:132px}
534
551
  .wr-months .wr-hour-label{font-size:10px}
@@ -545,13 +562,17 @@ window.__ModuleLoader__.load({
545
562
  .wr-heat-months{display:grid;grid-auto-flow:column;grid-auto-columns:13px;gap:3px;font-size:9.5px;color:var(--dsw-alias-label-secondary);overflow:hidden;margin:0 0 4px 0}
546
563
  .wr-heat-months span{white-space:nowrap;overflow:visible}
547
564
  .wr-heat{display:grid;grid-auto-flow:column;grid-template-rows:repeat(7,13px);gap:3px;flex:none}
548
- .wr-heat-cell{width:13px;height:13px;border-radius:3px;background:var(--dsw-alias-bg-layer-2);transition:transform .1s}
565
+ .wr-heat-cell{width:13px;height:13px;border-radius:3px;background:var(--wr-heat-0);transition:transform .12s ease}
549
566
  .wr-heat-cell[data-on=true]:hover{transform:scale(1.3)}
567
+ .wr-heat-cell[data-future=true]{background:transparent;box-shadow:inset 0 0 0 1px var(--wr-line-2);opacity:.7}
568
+ .wr-banner-close{border:none;background:transparent;color:inherit;font-size:14px;line-height:1;cursor:pointer;padding:2px 4px;border-radius:6px;opacity:.55;flex:none;margin-left:auto}
569
+ .wr-banner-close:hover{opacity:1;background:var(--wr-wash)}
570
+ .wr-banner-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
550
571
  .wr-heat-legend{display:flex;align-items:center;gap:5px;margin-top:10px;font-size:11px;color:var(--dsw-alias-label-secondary)}
551
572
  .wr-bars{display:flex;flex-direction:column;gap:8px}
552
573
  .wr-bar-row{display:grid;grid-template-columns:104px 1fr 54px;gap:10px;align-items:center;font-size:12px}
553
- .wr-bar-track{height:8px;border-radius:999px;background:var(--dsw-alias-bg-layer-2);overflow:hidden}
554
- .wr-bar-fill{height:100%;border-radius:999px;background:var(--dsw-alias-brand-primary)}
574
+ .wr-bar-track{height:6px;border-radius:999px;background:var(--wr-track);overflow:hidden}
575
+ .wr-bar-fill{height:100%;border-radius:999px;background:var(--wr-progress)}
555
576
  .wr-bar-value{text-align:right;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums;font-size:11.5px}
556
577
  .wr-list{display:flex;flex-direction:column;gap:2px}
557
578
  .wr-row{display:flex;align-items:center;gap:10px;padding:8px 8px;border-radius:8px;text-align:left;border:0;background:transparent;font:inherit;color:inherit;width:100%;cursor:default}
@@ -571,16 +592,18 @@ window.__ModuleLoader__.load({
571
592
  .wr-cols{column-count:2;column-gap:14px}
572
593
  @media (min-width:900px){.wr-cols{column-count:3}}
573
594
  @media (min-width:1300px){.wr-cols{column-count:4}}
574
- .wr-quote{break-inside:avoid;margin:0 0 14px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:12px;padding:12px 14px;position:relative}
575
- .wr-quote:before{content:'“';position:absolute;left:8px;top:2px;font-size:26px;color:var(--dsw-alias-border-l2);font-family:Georgia,serif;line-height:1}
595
+ .wr-quote{break-inside:avoid;margin:0 0 14px;background:var(--wr-paper-soft);border:1px solid var(--wr-line);border-radius:14px;padding:14px 16px;position:relative;box-shadow:0 1px 2px rgba(38,60,52,.04)}
596
+ .wr-quote:before{content:'“';position:absolute;left:9px;top:3px;font-size:26px;color:#C3D2BC;font-family:Georgia,serif;line-height:1}
576
597
  .wr-quote-text{font-size:12.8px;line-height:1.62;white-space:pre-wrap;word-break:break-word;padding-left:12px;margin:0}
577
598
  .wr-quote-foot{display:flex;align-items:center;gap:6px;margin-top:9px;padding-left:12px;font-size:11px;color:var(--dsw-alias-label-secondary);min-width:0}
578
599
  .wr-quote-book{font-weight:600;color:var(--dsw-alias-label-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:60%}
579
600
  .wr-quote-tail{margin-left:auto;flex:none;display:flex;gap:6px;align-items:center}
580
- .wr-thought{break-inside:avoid;margin:0 0 14px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-left:3px solid var(--dsw-alias-brand-primary);border-radius:12px;padding:12px 14px}
601
+ .wr-thought{break-inside:avoid;margin:0 0 14px;background:var(--wr-paper-soft);border:1px solid var(--wr-line);border-left:3px solid var(--wr-progress);border-radius:14px;padding:14px 16px;box-shadow:0 1px 2px rgba(38,60,52,.04)}
581
602
  .wr-quote[data-clickable=true],.wr-thought[data-clickable=true]{cursor:pointer;transition:border-color .12s ease}
582
603
  .wr-quote[data-clickable=true]:hover,.wr-thought[data-clickable=true]:hover{border-color:var(--dsw-alias-border-l2)}
583
- .wr-copy{appearance:none;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-secondary);font:inherit;font-size:10.5px;line-height:1;padding:3px 7px;border-radius:6px;cursor:pointer;flex:none}
604
+ .wr-copy{appearance:none;border:1px solid var(--wr-line);background:var(--wr-paper);color:var(--wr-ink-soft);font:inherit;font-size:10.5px;line-height:1;padding:4px 9px;border-radius:999px;cursor:pointer;flex:none;opacity:0;transition:opacity .14s ease,color .14s ease,border-color .14s ease}
605
+ .wr-quote:hover .wr-copy,.wr-thought:hover .wr-copy,.wr-copy:focus,.wr-copy[data-done=true]{opacity:1}
606
+ @media (hover:none){.wr-copy{opacity:1}}
584
607
  .wr-copy:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l2)}
585
608
  .wr-copy[data-done=true]{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary)}
586
609
  .wr-pager{display:flex;align-items:center;justify-content:center;gap:12px;margin-top:16px;font-size:12px;flex-wrap:wrap}
@@ -588,7 +611,7 @@ window.__ModuleLoader__.load({
588
611
  .wr-quote:hover .wr-pick,.wr-thought:hover .wr-pick,.wr-pick[data-on=true]{opacity:1}
589
612
  .wr-pick[data-on=true]{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-bg-base)}
590
613
  .wr-quote[data-picked=true],.wr-thought[data-picked=true]{border-color:var(--dsw-alias-brand-primary)}
591
- .wr-mark{background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 30%, transparent);color:inherit;border-radius:3px;padding:0 1px}
614
+ .wr-mark{background:var(--wr-heat-1);color:var(--wr-ink);border-radius:3px;padding:0 2px}
592
615
  .wr-export{width:min(760px,94vw);max-height:88vh;display:flex;flex-direction:column}
593
616
  .wr-export-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
594
617
  .wr-export-opts{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin:10px 0}
@@ -597,14 +620,12 @@ window.__ModuleLoader__.load({
597
620
  .wr-export-preview{margin:0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11.5px;line-height:1.6;white-space:pre-wrap;word-break:break-word}
598
621
  .wr-stars{color:var(--dsw-alias-state-warn-primary);font-size:10.5px;letter-spacing:1px}
599
622
  .wr-detail-head{display:flex;gap:18px;align-items:flex-start;flex-wrap:wrap}
600
- .wr-detail-cover{width:120px;flex:none;border-radius:10px;overflow:hidden;box-shadow:0 10px 26px rgba(0,0,0,.22)}
623
+ .wr-detail-cover{width:120px;flex:none;border-radius:12px;overflow:hidden;box-shadow:0 10px 24px -12px rgba(38,60,52,.55)}
601
624
  .wr-detail-main{flex:1;min-width:220px;display:flex;flex-direction:column;gap:8px}
602
625
  .wr-detail-title{font-size:18px;font-weight:700;line-height:1.35;margin:0}
603
626
  .wr-detail-author{font-size:12.5px;color:var(--dsw-alias-label-secondary)}
604
- .wr-link{appearance:none;border:0;background:transparent;color:var(--dsw-alias-brand-primary);font:inherit;font-size:12px;cursor:pointer;padding:0;text-decoration:none}
605
- .wr-link:hover{text-decoration:underline}
606
- .wr-modal-mask{position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,.42);display:flex;align-items:center;justify-content:center;padding:24px}
607
- .wr-modal{width:min(480px,100%);max-height:80vh;overflow:auto;background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l2);border-radius:16px;box-shadow:0 24px 60px rgba(0,0,0,.35);padding:20px}
627
+ .wr-modal-mask{position:fixed;inset:0;z-index:1200;background:rgba(45,60,48,.34);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;padding:24px}
628
+ .wr-modal{width:min(480px,100%);max-height:80vh;overflow:auto;background:var(--wr-paper-soft);border:1px solid var(--wr-line);border-radius:18px;box-shadow:0 30px 70px -30px rgba(45,60,48,.6);padding:22px}
608
629
  .wr-modal h3{margin:0 0 4px;font-size:15px}
609
630
  .wr-field{display:flex;flex-direction:column;gap:6px;margin-top:14px}
610
631
  .wr-field label{font-size:11.5px;font-weight:600;color:var(--dsw-alias-label-secondary)}
@@ -620,7 +641,14 @@ window.__ModuleLoader__.load({
620
641
  .wr-flex{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
621
642
  .wr-split{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px}
622
643
  .wr-kv{display:flex;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary)}
623
- .wr-notice{font-size:11.5px;color:var(--dsw-alias-label-secondary);margin:8px 0 0}
644
+ .wr-notice{font-size:11.5px;color:var(--wr-ink-mute);margin:8px 0 0}
645
+ .wr-root ::selection{background:rgba(168,185,163,.45);color:var(--wr-ink)}
646
+ .wr-root :focus-visible{outline:2px solid rgba(111,138,116,.45);outline-offset:2px}
647
+ .wr-body::-webkit-scrollbar{width:10px;height:10px}
648
+ .wr-body::-webkit-scrollbar-thumb{background:#CFD8C6;border-radius:999px;border:2px solid transparent;background-clip:content-box}
649
+ .wr-body::-webkit-scrollbar-track{background:transparent}
650
+ .wr-heat-legend i{width:11px;height:11px;border-radius:3px;display:inline-block}
651
+ .wr-setup{background:var(--wr-paper-soft);border:1px solid var(--wr-line);border-radius:18px;padding:26px 28px;box-shadow:0 1px 2px rgba(38,60,52,.04)}
624
652
  `
625
653
 
626
654
  // ------------------------------------------------------------ components
@@ -842,16 +870,20 @@ window.__ModuleLoader__.load({
842
870
  return monthLabel(month - 1)
843
871
  })
844
872
 
873
+ // Four discrete steps above "no reading": a fixed palette keeps the bands
874
+ // distinguishable, where mixing percentages blurred them into the page.
845
875
  const level = (value) => {
846
- if (value <= 0) return 'var(--dsw-alias-bg-layer-2)'
876
+ if (value <= 0) return 'var(--wr-heat-0)'
847
877
  const low = model.thresholds[0]
848
878
  const mid = model.thresholds[1]
849
879
  const high = model.thresholds[2]
850
- if (value <= low) return 'color-mix(in srgb, var(--dsw-alias-brand-primary) 26%, var(--dsw-alias-bg-layer-2))'
851
- if (value <= mid) return 'color-mix(in srgb, var(--dsw-alias-brand-primary) 48%, var(--dsw-alias-bg-layer-2))'
852
- if (value <= high) return 'color-mix(in srgb, var(--dsw-alias-brand-primary) 72%, var(--dsw-alias-bg-layer-2))'
853
- return 'var(--dsw-alias-brand-primary)'
880
+ if (value <= low) return 'var(--wr-heat-1)'
881
+ if (value <= mid) return 'var(--wr-heat-2)'
882
+ if (value <= high) return 'var(--wr-heat-3)'
883
+ return 'var(--wr-heat-4)'
854
884
  }
885
+ const now = new Date()
886
+ const todayIso = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
855
887
 
856
888
  const cells = []
857
889
  model.columns.forEach((column, columnIndex) => {
@@ -861,13 +893,19 @@ window.__ModuleLoader__.load({
861
893
  cells.push(h('div', { key: `e-${columnIndex}-${row}`, className: 'wr-heat-cell', style: { background: 'transparent' } }))
862
894
  continue
863
895
  }
896
+ // Days after today are drawn as empty outlines, so "not yet" never
897
+ // reads as "read nothing".
898
+ const future = cell.iso > todayIso
864
899
  cells.push(
865
900
  h('div', {
866
901
  key: cell.iso,
867
902
  className: 'wr-heat-cell',
868
903
  'data-on': cell.value > 0 ? 'true' : 'false',
869
- title: `${cell.iso} · ${cell.value > 0 ? fmtDuration(cell.value) : '没有阅读'}`,
870
- style: { background: level(cell.value) },
904
+ 'data-future': future ? 'true' : 'false',
905
+ title: future
906
+ ? `${cell.iso} · ${t('dash.heat.future')}`
907
+ : `${cell.iso} · ${cell.value > 0 ? fmtDuration(cell.value) : t('dash.heat.none')}`,
908
+ style: { background: future ? 'transparent' : level(cell.value) },
871
909
  }),
872
910
  )
873
911
  }
@@ -898,14 +936,11 @@ window.__ModuleLoader__.load({
898
936
  h(
899
937
  'div',
900
938
  { className: 'wr-heat-legend' },
901
- h('span', null, t('dash.less')),
902
- [
903
- 'var(--dsw-alias-bg-layer-2)',
904
- 'color-mix(in srgb, var(--dsw-alias-brand-primary) 26%, var(--dsw-alias-bg-layer-2))',
905
- 'color-mix(in srgb, var(--dsw-alias-brand-primary) 48%, var(--dsw-alias-bg-layer-2))',
906
- 'color-mix(in srgb, var(--dsw-alias-brand-primary) 72%, var(--dsw-alias-bg-layer-2))',
907
- 'var(--dsw-alias-brand-primary)',
908
- ].map((color, index) => h('span', { key: index, className: 'wr-heat-cell', style: { background: color } })),
939
+ h('span', null, t('dash.heat.none')),
940
+ h('span', { className: 'wr-heat-cell' }),
941
+ h('span', { style: { marginLeft: '6px' } }, t('dash.less')),
942
+ ['var(--wr-heat-1)', 'var(--wr-heat-2)', 'var(--wr-heat-3)', 'var(--wr-heat-4)'].map((color, index) =>
943
+ h('span', { key: index, className: 'wr-heat-cell', style: { background: color } })),
909
944
  h('span', null, t('dash.more')),
910
945
  ),
911
946
  )
@@ -951,6 +986,7 @@ window.__ModuleLoader__.load({
951
986
  /** Twelve monthly bars for the selected year. */
952
987
  function MonthChart(props) {
953
988
  const values = Array.isArray(props.values) ? props.values : []
989
+ const activeIndex = Number.isInteger(props.activeIndex) ? props.activeIndex : -1
954
990
  const max = Math.max.apply(null, values.concat([1]))
955
991
  const cells = []
956
992
  for (let index = 0; index < 12; index += 1) {
@@ -960,8 +996,8 @@ window.__ModuleLoader__.load({
960
996
  h(
961
997
  'div',
962
998
  { className: 'wr-hour-col', key: index, title: `${index + 1} · ${fmtDuration(value)}` },
963
- h('div', { className: 'wr-hour-bar', style: { height: `${height}%` } }),
964
- h('div', { className: 'wr-hour-label' }, String(index + 1)),
999
+ h('div', { className: 'wr-hour-bar', 'data-active': index === activeIndex ? 'true' : 'false', style: { height: `${height}%` } }),
1000
+ h('div', { className: 'wr-hour-label', 'data-active': index === activeIndex ? 'true' : 'false' }, String(index + 1)),
965
1001
  ),
966
1002
  )
967
1003
  }
@@ -1160,7 +1196,7 @@ window.__ModuleLoader__.load({
1160
1196
  h(
1161
1197
  'div',
1162
1198
  { className: 'wr-split wr-section' },
1163
- h('div', { className: 'wr-card' }, h('h3', { className: 'wr-section-title' }, t('dash.month')), h(MonthChart, { values: monthly })),
1199
+ h('div', { className: 'wr-card' }, h('h3', { className: 'wr-section-title' }, t('dash.month')), h(MonthChart, { values: monthly, activeIndex: isCurrent ? new Date().getMonth() : -1 })),
1164
1200
  h('div', { className: 'wr-card' }, h('h3', { className: 'wr-section-title' }, t('dash.category')), h(BarList, { items: categories })),
1165
1201
  ),
1166
1202
  h(
@@ -1700,9 +1736,17 @@ window.__ModuleLoader__.load({
1700
1736
  }),
1701
1737
  h(
1702
1738
  'select',
1703
- { className: 'wr-select', value: bookFilter, onChange: (event) => setBookFilter(event.target.value) },
1739
+ {
1740
+ className: 'wr-select',
1741
+ style: { maxWidth: '240px' },
1742
+ title: t('filter.allBooks'),
1743
+ value: bookFilter,
1744
+ onChange: (event) => setBookFilter(event.target.value),
1745
+ },
1704
1746
  h('option', { value: 'all' }, t('filter.allBooks')),
1705
- bookOptions.map(([id, title]) => h('option', { key: id, value: id }, title)),
1747
+ // Titles are cut short: a 40-character name would otherwise blow the
1748
+ // native popup out to the width of the whole window.
1749
+ bookOptions.map(([id, title]) => h('option', { key: id, value: id, title }, clip(title, 24))),
1706
1750
  ),
1707
1751
  h('div', { className: 'wr-toolbar-fill' }),
1708
1752
  selected.size > 0 ? h('span', { className: 'wr-chip-stat' }, t('export.selected', { count: String(selected.size) })) : null,
@@ -1799,7 +1843,6 @@ window.__ModuleLoader__.load({
1799
1843
  const needle = query.trim().toLowerCase()
1800
1844
  const visibleNotes = needle === '' ? notes : notes.filter((note) => contains(note.text, needle) || contains(note.chapterTitle, needle))
1801
1845
  const visibleThoughts = needle === '' ? thoughts : thoughts.filter((thought) => contains(thought.content, needle) || contains(thought.chapterName, needle))
1802
- const link = wereadBookLink(book.bookId)
1803
1846
 
1804
1847
  useEffect(() => {
1805
1848
  setPage(0)
@@ -1837,7 +1880,6 @@ window.__ModuleLoader__.load({
1837
1880
  book.rating > 0 ? h('span', { className: 'wr-badge', style: { background: 'var(--dsw-alias-bg-layer-1)', color: 'var(--dsw-alias-state-warn-primary)', border: '1px solid var(--dsw-alias-border-l1)' } }, `${t('book.rating')} ${(book.rating / 10).toFixed(1)}${book.ratingCount > 0 ? ` (${fmtCount(book.ratingCount)})` : ''}`) : null,
1838
1881
  book.publisher ? h('span', { className: 'wr-badge', style: { background: 'var(--dsw-alias-bg-layer-1)', color: 'var(--dsw-alias-label-secondary)', border: '1px solid var(--dsw-alias-border-l1)' } }, book.publisher) : null,
1839
1882
  book.publishYear ? h('span', { className: 'wr-badge', style: { background: 'var(--dsw-alias-bg-layer-1)', color: 'var(--dsw-alias-label-secondary)', border: '1px solid var(--dsw-alias-border-l1)' } }, `${t('book.year')} ${book.publishYear}`) : null,
1840
- link ? h('a', { className: 'wr-link', href: link }, t('action.openWeread')) : null,
1841
1883
  ),
1842
1884
  book.readingProgress > 0
1843
1885
  ? h(
@@ -2009,6 +2051,21 @@ window.__ModuleLoader__.load({
2009
2051
  if (fileRef.current) fileRef.current.click()
2010
2052
  }
2011
2053
 
2054
+ /** Ignore the incremental ledger and re-read every book's highlights. */
2055
+ const fullRebuild = async () => {
2056
+ setBusyData(true)
2057
+ setDataMessage('')
2058
+ try {
2059
+ await apiSync({ full: true })
2060
+ setDataMessage(t('settings.fullSyncStarted'))
2061
+ if (typeof props.onSyncStarted === 'function') props.onSyncStarted()
2062
+ } catch (error) {
2063
+ setDataMessage(String(error.message || error))
2064
+ } finally {
2065
+ setBusyData(false)
2066
+ }
2067
+ }
2068
+
2012
2069
  const onFile = async (event) => {
2013
2070
  const file = event.target.files && event.target.files[0]
2014
2071
  event.target.value = ''
@@ -2106,9 +2163,11 @@ window.__ModuleLoader__.load({
2106
2163
  { className: 'wr-flex', style: { marginTop: '10px' } },
2107
2164
  h('button', { className: 'wr-btn', type: 'button', disabled: busyData, onClick: () => void downloadBackup() }, t('settings.backup')),
2108
2165
  h('button', { className: 'wr-btn', type: 'button', disabled: busyData, onClick: pickFile }, t('settings.restore')),
2166
+ h('button', { className: 'wr-btn', type: 'button', disabled: busyData, onClick: () => void fullRebuild() }, t('settings.fullSync')),
2109
2167
  h('input', { ref: fileRef, type: 'file', accept: 'application/json,.json', style: { display: 'none' }, onChange: (event) => void onFile(event) }),
2110
2168
  ),
2111
2169
  h('p', { className: 'wr-muted', style: { margin: '6px 0 0' } }, t('settings.backupHint')),
2170
+ h('p', { className: 'wr-muted', style: { margin: '4px 0 0' } }, t('settings.fullSyncHint')),
2112
2171
  pending
2113
2172
  ? h(
2114
2173
  'div',
@@ -2197,6 +2256,76 @@ window.__ModuleLoader__.load({
2197
2256
  }
2198
2257
 
2199
2258
  // ------------------------------------------------------------------- page
2259
+ /**
2260
+ * Sync feedback line.
2261
+ *
2262
+ * A running sync stays until it finishes, because it is live progress. A
2263
+ * finished one (done or failed) clears itself after a few seconds so the
2264
+ * reading area is not permanently occupied, and the × dismisses it right
2265
+ * away. Any new sync resets both, so a second run always shows up again.
2266
+ */
2267
+ function SyncBanner(props) {
2268
+ const sync = props && props.sync ? props.sync : null
2269
+ const phase = sync && sync.phase ? String(sync.phase) : ''
2270
+ const running = Boolean(sync && sync.running)
2271
+ const signature = [
2272
+ sync && sync.startedAt ? sync.startedAt : '',
2273
+ phase,
2274
+ sync && sync.message ? sync.message : '',
2275
+ sync && sync.error ? sync.error : '',
2276
+ ].join('|')
2277
+ const [dismissed, setDismissed] = useState(false)
2278
+ const [expired, setExpired] = useState(false)
2279
+
2280
+ useEffect(() => {
2281
+ setDismissed(false)
2282
+ setExpired(false)
2283
+ }, [signature])
2284
+
2285
+ useEffect(() => {
2286
+ if (!sync || running) return undefined
2287
+ if (phase !== 'done' && phase !== 'error') return undefined
2288
+ if (typeof setTimeout !== 'function') return undefined
2289
+ const timer = setTimeout(() => setExpired(true), AUTO_HIDE_MS)
2290
+ return () => clearTimeout(timer)
2291
+ }, [signature, running, phase])
2292
+
2293
+ if (!sync) return null
2294
+ if (dismissed || expired) return null
2295
+
2296
+ const close = h('button', {
2297
+ className: 'wr-banner-close',
2298
+ type: 'button',
2299
+ 'aria-label': t('action.dismiss'),
2300
+ title: t('action.dismiss'),
2301
+ onClick: () => setDismissed(true),
2302
+ }, '×')
2303
+
2304
+ if (running) {
2305
+ const total = sync.total || 0
2306
+ const done = sync.done || 0
2307
+ return h('div', { className: 'wr-banner', 'data-tone': 'run' },
2308
+ h('span', { className: 'wr-spinner' }),
2309
+ h('span', { className: 'wr-banner-text' }, total > 0 ? t('sync.running', { label: sync.label || '', done, total }) : (sync.label || t('action.syncing'))),
2310
+ sync.message ? h('span', { className: 'wr-banner-text wr-muted' }, sync.message) : null,
2311
+ close,
2312
+ )
2313
+ }
2314
+ if (phase === 'done' && sync.message) {
2315
+ return h('div', { className: 'wr-banner' },
2316
+ h('span', { className: 'wr-banner-text' }, t('sync.done', { message: sync.message })),
2317
+ close,
2318
+ )
2319
+ }
2320
+ if (phase === 'error' && sync.error) {
2321
+ return h('div', { className: 'wr-banner', 'data-tone': 'error' },
2322
+ h('span', { className: 'wr-banner-text' }, t('sync.failed', { error: sync.error })),
2323
+ close,
2324
+ )
2325
+ }
2326
+ return null
2327
+ }
2328
+
2200
2329
  function StudyPanel() {
2201
2330
  const [state, setState] = useState(null)
2202
2331
  const [data, setData] = useState(null)
@@ -2208,22 +2337,6 @@ window.__ModuleLoader__.load({
2208
2337
  const [showSettings, setShowSettings] = useState(false)
2209
2338
  const [booting, setBooting] = useState(true)
2210
2339
  const mounted = useRef(true)
2211
- const searchRef = useRef(null)
2212
-
2213
- useEffect(() => {
2214
- const onKey = (event) => {
2215
- if (!(event.metaKey || event.ctrlKey)) return
2216
- if (String(event.key || '').toLowerCase() !== 'k') return
2217
- event.preventDefault()
2218
- const node = searchRef.current
2219
- if (node && typeof node.focus === 'function') {
2220
- node.focus()
2221
- if (typeof node.select === 'function') node.select()
2222
- }
2223
- }
2224
- window.addEventListener('keydown', onKey)
2225
- return () => window.removeEventListener('keydown', onKey)
2226
- }, [])
2227
2340
 
2228
2341
  useEffect(() => {
2229
2342
  mounted.current = true
@@ -2336,7 +2449,6 @@ window.__ModuleLoader__.load({
2336
2449
  { className: 'wr-head-right' },
2337
2450
  h('div', { className: 'wr-search' },
2338
2451
  h('input', {
2339
- ref: searchRef,
2340
2452
  placeholder: t('action.searchGlobal'),
2341
2453
  value: query,
2342
2454
  'aria-label': t('action.search'),
@@ -2352,22 +2464,7 @@ window.__ModuleLoader__.load({
2352
2464
  ),
2353
2465
  )
2354
2466
 
2355
- const syncBanner = (() => {
2356
- if (!state || !state.sync) return null
2357
- const sync = state.sync
2358
- if (sync.running) {
2359
- const total = sync.total || 0
2360
- const done = sync.done || 0
2361
- return h('div', { className: 'wr-banner', 'data-tone': 'run' },
2362
- h('span', { className: 'wr-spinner' }),
2363
- h('span', null, total > 0 ? t('sync.running', { label: sync.label || '', done, total }) : (sync.label || t('action.syncing'))),
2364
- sync.message ? h('span', { className: 'wr-muted' }, sync.message) : null,
2365
- )
2366
- }
2367
- if (sync.phase === 'done' && sync.message) return h('div', { className: 'wr-banner' }, h('span', null, t('sync.done', { message: sync.message })))
2368
- if (sync.phase === 'error' && sync.error) return h('div', { className: 'wr-banner', 'data-tone': 'error' }, h('span', null, t('sync.failed', { error: sync.error })))
2369
- return null
2370
- })()
2467
+ const syncBanner = h(SyncBanner, { sync: state && state.sync })
2371
2468
 
2372
2469
  let body = null
2373
2470
  if (booting) {
@@ -2420,6 +2517,7 @@ window.__ModuleLoader__.load({
2420
2517
  onClose: () => setShowSettings(false),
2421
2518
  onChanged: (next) => { setState(next); void loadData() },
2422
2519
  onDataChanged: () => { void loadData(); void refreshState() },
2520
+ onSyncStarted: () => { void refreshState() },
2423
2521
  })
2424
2522
  : null,
2425
2523
  )
@@ -2497,7 +2595,7 @@ window.__ModuleLoader__.load({
2497
2595
  },
2498
2596
  // Test-only handles: the render smoke test mounts these directly, because
2499
2597
  // the export panel normally only appears after a click. Nothing else reads it.
2500
- __smoke: { ExportPanel, MarkedText, PickButton },
2598
+ __smoke: { ExportPanel, MarkedText, PickButton, SyncBanner, clip },
2501
2599
  }
2502
2600
  },
2503
2601
  })
package/index.js CHANGED
@@ -45,6 +45,16 @@ const HEAT_TTL = 30 * 60
45
45
  const HEAT_CONCURRENCY = 4
46
46
  const HEAT_CACHE_VERSION = 2
47
47
 
48
+ /**
49
+ * Incremental sync. `sync-state.json` remembers, per book, what the notebook
50
+ * list looked like the last time its highlights were fetched. A book is only
51
+ * re-read when one of those numbers moved, so a second sync in a row costs a
52
+ * shelf call plus one notebook page instead of ~130 requests.
53
+ */
54
+ const SYNC_STATE_VERSION = 1
55
+ /** Reading totals (annually / overall) change slowly: reuse them for a while. */
56
+ const DASHBOARD_TTL = 6 * 60 * 60
57
+
48
58
  const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
49
59
 
50
60
  /**
@@ -574,12 +584,60 @@ function buildExportDocument(picked, optionsInput, nowIso) {
574
584
  }
575
585
  }
576
586
 
587
+ // --------------------------------------------------------------- sync planning
588
+
589
+ const notebookSorted = (a, b) => Number(b.lastNoteTime || 0) - Number(a.lastNoteTime || 0)
590
+
591
+ /**
592
+ * Decide which notebooks need their highlights re-read.
593
+ *
594
+ * A book is re-read when we have never recorded it, when its newest note moved,
595
+ * or when either note counter changed. Everything else is skipped and its old
596
+ * entries are carried over from `data.json` instead of being downloaded again.
597
+ */
598
+ function planBookRefresh(previous, notebooks, options = {}) {
599
+ const full = options.full === true
600
+ const known = isObject(previous) ? previous : {}
601
+ const refresh = []
602
+ const skipped = []
603
+ for (const notebook of notebooks) {
604
+ if (!notebook || !notebook.bookId) continue
605
+ const before = isObject(known[notebook.bookId]) ? known[notebook.bookId] : null
606
+ const changed =
607
+ full ||
608
+ before === null ||
609
+ Number(notebook.lastNoteTime || 0) > Number(before.lastNoteTime || 0) ||
610
+ Number(notebook.noteCount || 0) !== Number(before.noteCount || 0) ||
611
+ Number(notebook.reviewCount || 0) !== Number(before.reviewCount || 0)
612
+ if (changed) refresh.push(notebook)
613
+ else skipped.push(notebook.bookId)
614
+ }
615
+ return { refresh, skipped, full }
616
+ }
617
+
618
+ /** Carry over the entries of books we are not re-reading this round. */
619
+ function mergeCarriedEntries(previous, keepBookIds, refreshBookIds) {
620
+ const keep = keepBookIds instanceof Set ? keepBookIds : new Set(keepBookIds || [])
621
+ const refresh = refreshBookIds instanceof Set ? refreshBookIds : new Set(refreshBookIds || [])
622
+ const notes = []
623
+ const thoughts = []
624
+ if (!isObject(previous)) return { notes, thoughts }
625
+ for (const note of Array.isArray(previous.notes) ? previous.notes : []) {
626
+ if (keep.has(note.bookId) && !refresh.has(note.bookId)) notes.push(note)
627
+ }
628
+ for (const thought of Array.isArray(previous.thoughts) ? previous.thoughts : []) {
629
+ if (keep.has(thought.bookId) && !refresh.has(thought.bookId)) thoughts.push(thought)
630
+ }
631
+ return { notes, thoughts }
632
+ }
633
+
577
634
  function createHost(options) {
578
635
  const dir = join(dshHome(), 'weread-gather')
579
636
  const configPath = join(dir, 'config.json')
580
637
  const dataPath = join(dir, 'data.json')
581
638
  const metaPath = join(dir, 'meta.json')
582
639
  const heatPath = join(dir, 'heat.json')
640
+ const syncStatePath = join(dir, 'sync-state.json')
583
641
 
584
642
  let config = readJsonFile(configPath, {})
585
643
  if (!isObject(config)) config = {}
@@ -594,6 +652,13 @@ function createHost(options) {
594
652
  /** "2026" -> { at, days: { "2026-01-02": seconds } } */
595
653
  let heatCache = readJsonFile(heatPath, {})
596
654
  if (!isObject(heatCache)) heatCache = {}
655
+ /** Sync bookkeeping: what the notebook list looked like when we last read it. */
656
+ let syncState = readJsonFile(syncStatePath, null)
657
+ if (!isObject(syncState) || Number(syncState.version) !== SYNC_STATE_VERSION) {
658
+ syncState = { version: SYNC_STATE_VERSION, lastSyncAt: 0, lastFullAt: 0, dashboardAt: {}, books: {} }
659
+ }
660
+ if (!isObject(syncState.books)) syncState.books = {}
661
+ if (!isObject(syncState.dashboardAt)) syncState.dashboardAt = {}
597
662
 
598
663
  let job = null
599
664
 
@@ -648,6 +713,14 @@ function createHost(options) {
648
713
  }
649
714
  }
650
715
 
716
+ function saveSyncState() {
717
+ try {
718
+ writeJsonFile(syncStatePath, syncState)
719
+ } catch {
720
+ // Losing the ledger only costs a slower sync next time; never fail a sync.
721
+ }
722
+ }
723
+
651
724
  // ------------------------------------------------------------------ exports
652
725
 
653
726
  const defaultExportDir = join(dir, 'exports')
@@ -769,6 +842,7 @@ function createHost(options) {
769
842
  'data.json': dataset,
770
843
  'meta.json': meta,
771
844
  'heat.json': heatCache,
845
+ 'sync-state.json': syncState,
772
846
  },
773
847
  }
774
848
  }
@@ -824,6 +898,17 @@ function createHost(options) {
824
898
  saveHeat()
825
899
  restored.push('heat.json')
826
900
  }
901
+ // Optional: older bundles (1.0.0 / 1.1.0) have no ledger. Keep ours instead of
902
+ // resetting it, otherwise the next sync would silently become a full one.
903
+ if (isObject(files['sync-state.json'])) {
904
+ const incomingState = files['sync-state.json']
905
+ if (Number(incomingState.version) === SYNC_STATE_VERSION && isObject(incomingState.books)) {
906
+ syncState = incomingState
907
+ if (!isObject(syncState.dashboardAt)) syncState.dashboardAt = {}
908
+ saveSyncState()
909
+ restored.push('sync-state.json')
910
+ }
911
+ }
827
912
  return { restored, keptBackup: folder, keptFiles, counts: counts() }
828
913
  }
829
914
 
@@ -984,6 +1069,13 @@ function createHost(options) {
984
1069
  finishedAt: 0,
985
1070
  }
986
1071
  const warnings = []
1072
+ // Incremental bookkeeping. `full` (or having no ledger at all) falls back to
1073
+ // the 1.1.0 behaviour of reading everything.
1074
+ const full = options.full === true
1075
+ const previousState = syncState
1076
+ const previousDataset = dataset
1077
+ const lastSyncAt = Number(previousState.lastSyncAt) || 0
1078
+ const effectiveFull = full || lastSyncAt === 0 || !isObject(previousDataset)
987
1079
 
988
1080
  try {
989
1081
  // 1. Shelf ------------------------------------------------------------
@@ -1062,6 +1154,9 @@ function createHost(options) {
1062
1154
  if (!hasMore || nextSort === 0 || nextSort === lastSort) break
1063
1155
  lastSort = nextSort
1064
1156
  job.message = `已读取 ${notebooks.length} 本有笔记的书`
1157
+ // Paging must not stop early: this list is also where books that the
1158
+ // capped shelf slice dropped come from, so a partial list would shrink
1159
+ // the catalogue. It is only ~5 cheap requests anyway.
1065
1160
  }
1066
1161
 
1067
1162
  for (const notebook of notebooks) {
@@ -1084,10 +1179,21 @@ function createHost(options) {
1084
1179
  job.phase = 'dashboard'
1085
1180
  job.label = '同步阅读统计'
1086
1181
  const dashboard = {}
1182
+ const previousDashboard = previousDataset && isObject(previousDataset.dashboard) ? previousDataset.dashboard : {}
1183
+ const now = nowSeconds()
1087
1184
  for (const mode of ['weekly', 'monthly', 'annually', 'overall']) {
1185
+ const cached = previousDashboard[mode]
1186
+ const cachedAt = Number(previousState.dashboardAt[mode]) || 0
1187
+ const reusable = !effectiveFull && isObject(cached) && now - cachedAt < DASHBOARD_TTL
1188
+ if (reusable) {
1189
+ dashboard[mode] = cached
1190
+ continue
1191
+ }
1088
1192
  try {
1089
1193
  dashboard[mode] = trimReadData(await gateway(current.apiKey, '/readdata/detail', { mode }, signal))
1194
+ syncState.dashboardAt[mode] = now
1090
1195
  } catch (error) {
1196
+ if (isObject(cached)) dashboard[mode] = cached
1091
1197
  warnings.push(`阅读统计(${mode}): ${error instanceof Error ? error.message : String(error)}`)
1092
1198
  }
1093
1199
  }
@@ -1139,15 +1245,40 @@ function createHost(options) {
1139
1245
  // 4. Per-book highlights and thoughts ---------------------------------
1140
1246
  job.phase = 'notes'
1141
1247
  job.label = '同步笔记与想法'
1142
- const candidates = notebooks
1143
- .filter((n) => n.noteCount + n.reviewCount > 0)
1144
- .sort((a, b) => b.lastNoteTime - a.lastNoteTime || b.noteCount - a.noteCount)
1145
- .slice(0, maxBooks)
1248
+ const withNotes = notebooks.filter((n) => n.noteCount + n.reviewCount > 0).sort(notebookSorted)
1249
+ const plan = planBookRefresh(previousState.books, withNotes, { full: effectiveFull })
1250
+ const candidates = plan.refresh.slice(0, maxBooks)
1251
+ // Books beyond the per-sync cap keep their old entries; they are simply not
1252
+ // part of the refresh set.
1253
+ const refreshIds = new Set(candidates.map((n) => n.bookId))
1254
+ const keepIds = new Set(books.keys())
1255
+ // A book whose counters dropped to zero has lost its entries: dropping the
1256
+ // old rows is the only way the next view matches WeRead.
1257
+ const emptiedIds = new Set(notebooks.filter((n) => n.noteCount + n.reviewCount === 0).map((n) => n.bookId))
1258
+ const carried = mergeCarriedEntries(previousDataset, keepIds, new Set([...refreshIds, ...emptiedIds]))
1146
1259
  job.total = candidates.length
1147
1260
  job.done = 0
1148
-
1149
- const notes = []
1150
- const thoughts = []
1261
+ job.updated = candidates.length
1262
+ job.skipped = withNotes.length - candidates.length
1263
+ if (job.skipped > 0) job.message = `更新 ${candidates.length} 本 · 跳过 ${job.skipped} 本`
1264
+
1265
+ const notes = carried.notes
1266
+ const thoughts = carried.thoughts
1267
+ // Pre-fill the ledger for every notebook so the next sync can tell
1268
+ // "unchanged" from "never looked at". `read` remembers that a book's
1269
+ // highlights were actually downloaded once: books the per-sync cap has
1270
+ // never reached keep `read:false`, which is what the pending count shows
1271
+ // (1.1.0 only ever had highlights for the newest N books anyway).
1272
+ for (const notebook of withNotes) {
1273
+ const previousRow = isObject(syncState.books[notebook.bookId]) ? syncState.books[notebook.bookId] : null
1274
+ syncState.books[notebook.bookId] = {
1275
+ lastNoteTime: Number(notebook.lastNoteTime) || 0,
1276
+ noteCount: Number(notebook.noteCount) || 0,
1277
+ reviewCount: Number(notebook.reviewCount) || 0,
1278
+ syncedAt: nowSeconds(),
1279
+ read: previousRow !== null && (previousRow.read === true || previousRow.done === true),
1280
+ }
1281
+ }
1151
1282
  const results = await mapLimit(candidates, 4, async (notebook) => {
1152
1283
  const collectedNotes = []
1153
1284
  const collectedThoughts = []
@@ -1191,11 +1322,16 @@ function createHost(options) {
1191
1322
  const notebook = candidates[index]
1192
1323
  if (result && result.error) {
1193
1324
  warnings.push(result.error)
1325
+ // Drop the ledger row so the next sync retries this book instead of
1326
+ // remembering a half-finished state.
1327
+ delete syncState.books[notebook.bookId]
1194
1328
  return
1195
1329
  }
1196
1330
  if (!result) return
1197
1331
  for (const note of result.notes) notes.push({ ...note, bookTitle: notebook.title, author: notebook.author, cover: notebook.cover })
1198
1332
  for (const thought of result.thoughts) thoughts.push({ ...thought, bookTitle: notebook.title, author: notebook.author, cover: notebook.cover })
1333
+ const entry = syncState.books[notebook.bookId]
1334
+ if (isObject(entry)) entry.read = true
1199
1335
  job.done += 1
1200
1336
  })
1201
1337
 
@@ -1219,10 +1355,25 @@ function createHost(options) {
1219
1355
  })
1220
1356
  saveDataset(next)
1221
1357
 
1358
+ // 6. Ledger -----------------------------------------------------------
1359
+ // Forget books that left the shelf so the file cannot grow forever.
1360
+ for (const bookId of Object.keys(syncState.books)) {
1361
+ if (!keepIds.has(bookId)) delete syncState.books[bookId]
1362
+ }
1363
+ const pendingBooks = withNotes.filter((n) => {
1364
+ const row = syncState.books[n.bookId]
1365
+ return isObject(row) && row.read !== true
1366
+ }).length
1367
+ if (pendingBooks > 0) job.pending = pendingBooks
1368
+ syncState.version = SYNC_STATE_VERSION
1369
+ syncState.lastSyncAt = nowSeconds()
1370
+ if (effectiveFull) syncState.lastFullAt = syncState.lastSyncAt
1371
+ saveSyncState()
1372
+
1222
1373
  job.running = false
1223
1374
  job.phase = 'done'
1224
1375
  job.label = '同步完成'
1225
- job.message = `书架 ${next.books.length} 本 · 笔记 ${next.notes.length} 条 · 想法 ${next.thoughts.length} 条`
1376
+ job.message = `更新 ${job.updated || 0} 本 · 跳过 ${job.skipped || 0} 本 · 笔记 ${next.notes.length} 条 · 想法 ${next.thoughts.length} 条`
1226
1377
  job.finishedAt = nowSeconds()
1227
1378
  return status()
1228
1379
  } catch (error) {
@@ -1436,7 +1587,15 @@ export const inject = ['webServer']
1436
1587
  * Pure helpers exposed for `scripts/export-selftest.cjs`. Nothing here touches
1437
1588
  * the filesystem or the gateway, so importing the module stays side-effect free.
1438
1589
  */
1439
- export const __internals = { buildExportDocument, exportOptions, chapterGroups, formatStamp, safeSegment }
1590
+ export const __internals = {
1591
+ buildExportDocument,
1592
+ exportOptions,
1593
+ chapterGroups,
1594
+ formatStamp,
1595
+ safeSegment,
1596
+ planBookRefresh,
1597
+ mergeCarriedEntries,
1598
+ }
1440
1599
 
1441
1600
  export function apply(ctx) {
1442
1601
  const host = createHost({})
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-weread-gather",
3
- "version": "1.1.0",
4
- "description": "数字书房 — a private WeRead study in the DSH sidebar: shelf, categories, highlights, thoughts, reading dashboard and heatmap, Markdown export and backup, with search everywhere.",
3
+ "version": "1.1.2",
4
+ "description": "数字书房 — 微信读书书架、笔记与想法的私人书房:Markdown 导出、备份恢复、增量同步。",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "fountainhead-gq",
@@ -61,4 +61,4 @@
61
61
  "publishConfig": {
62
62
  "registry": "https://registry.npmjs.org/"
63
63
  }
64
- }
64
+ }