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.
- package/README.md +24 -6
- package/client.js +204 -106
- package/index.js +168 -9
- 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
|

|
|
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
|
-
| 全局搜索 | 顶栏搜索同时搜书名、作者、分类、划线正文、想法正文;结果按**书籍 / 划线 /
|
|
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/ 导入备份前自动留存的旧数据(<时间戳>/
|
|
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 运行时把
|
|
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
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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(--
|
|
459
|
-
.wr-head{flex:none;display:flex;align-items:center;gap:
|
|
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:
|
|
462
|
-
.wr-brand-sub{font-size:11px;color:var(--
|
|
463
|
-
.wr-tabs{display:flex;gap:2px;padding:
|
|
464
|
-
.wr-tab{appearance:none;border:0;background:transparent;color:var(--
|
|
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(--
|
|
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:
|
|
470
|
-
.wr-search input:focus{border-color:var(--
|
|
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
|
|
475
|
-
.wr-btn:hover{background:var(--
|
|
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:
|
|
480
|
-
.wr-
|
|
481
|
-
.wr-body
|
|
482
|
-
.wr-
|
|
483
|
-
.wr-
|
|
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
|
|
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(--
|
|
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(--
|
|
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(--
|
|
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:
|
|
508
|
-
.wr-input:focus{border-color:var(--
|
|
509
|
-
.wr-select{box-sizing:border-box;height:
|
|
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:
|
|
516
|
-
.wr-book-cover-wrap{position:relative;border-radius:8px;overflow:hidden;box-shadow:0
|
|
517
|
-
.wr-book:hover .wr-book-cover-wrap{transform:translateY(-
|
|
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,.
|
|
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
|
|
523
|
-
.wr-badge[data-tone=brand]{background:rgba(
|
|
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(--
|
|
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(--
|
|
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:
|
|
554
|
-
.wr-bar-fill{height:100%;border-radius:999px;background:var(--
|
|
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(--
|
|
575
|
-
.wr-quote:before{content:'“';position:absolute;left:
|
|
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(--
|
|
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(--
|
|
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:
|
|
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:
|
|
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-
|
|
605
|
-
.wr-
|
|
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(--
|
|
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(--
|
|
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 '
|
|
851
|
-
if (value <= mid) return '
|
|
852
|
-
if (value <= high) return '
|
|
853
|
-
return 'var(--
|
|
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
|
-
|
|
870
|
-
|
|
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.
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
'
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
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
|
-
|
|
1150
|
-
|
|
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 =
|
|
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 = {
|
|
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.
|
|
4
|
-
"description": "数字书房 —
|
|
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
|
+
}
|