From 30f1cedd3954d061c9ff4f36ac12f978601bc04e Mon Sep 17 00:00:00 2001 From: guanjihuan Date: Sat, 12 Sep 2026 13:55:57 +0800 Subject: [PATCH] update --- .gitignore | 4 + README.md | 156 + data/todo.example.md | 36 + icon.ico | Bin 0 -> 40571 bytes index.html | 193 + main.js | 1779 ++++++++ package-lock.json | 4127 +++++++++++++++++ package.json | 121 + preload.js | 105 + scripts/_lib/api-stub.mjs | 65 + scripts/_lib/check.mjs | 83 + scripts/_lib/find-todo-path.mjs | 79 + scripts/_lib/store-fixture.mjs | 36 + scripts/_lib/tmp-todo.mjs | 48 + scripts/check-assert-in-data-dir.mjs | 116 + scripts/check-backup.mjs | 299 ++ scripts/check-batch-ops.mjs | 746 ++++ scripts/check-category-guards.mjs | 289 ++ scripts/check-category-name-chars.mjs | 110 + scripts/check-cjk-autospace.mjs | 136 + scripts/check-click-after-drag.mjs | 178 + scripts/check-commit-active-edit.mjs | 346 ++ scripts/check-completed-consolidate.mjs | 341 ++ scripts/check-completed-context-menu.mjs | 125 + scripts/check-completed-only.mjs | 252 ++ scripts/check-completed-order.mjs | 136 + scripts/check-concurrent-edit-race.mjs | 212 + scripts/check-conflict-default.mjs | 134 + scripts/check-conflict-merge.mjs | 521 +++ scripts/check-delete-category.mjs | 442 ++ scripts/check-dirty-race.mjs | 292 ++ scripts/check-external-change-path.mjs | 217 + scripts/check-fade-in-stability.mjs | 89 + scripts/check-filter.mjs | 272 ++ scripts/check-ime-guard.mjs | 90 + scripts/check-import-tasks.mjs | 872 ++++ scripts/check-imports.mjs | 139 + scripts/check-insert-position.mjs | 545 +++ scripts/check-marker-tail.mjs | 122 + scripts/check-markers.mjs | 266 ++ scripts/check-md-ui-sync.mjs | 867 ++++ scripts/check-menu-fontsize.mjs | 22 + scripts/check-modal-close-animation.mjs | 166 + scripts/check-move-invariance.mjs | 500 +++ scripts/check-original-category.mjs | 685 +++ scripts/check-picker-timeout.mjs | 111 + scripts/check-polling-fallback.mjs | 375 ++ scripts/check-render-flow.mjs | 320 ++ scripts/check-save-config-rollback.mjs | 127 + scripts/check-second-pass-audit.mjs | 319 ++ scripts/check-settings.mjs | 174 + scripts/check-snapshot-creation.mjs | 156 + scripts/check-status-meta.mjs | 244 ++ scripts/check-task-dirty-tracking.mjs | 163 + scripts/check-third-pass-audit.mjs | 320 ++ scripts/check-trash-context-menu.mjs | 170 + scripts/check-trash-fuzz.mjs | 285 ++ scripts/check-trash.mjs | 677 +++ scripts/check-uncategorized-duplicate.mjs | 254 ++ scripts/check-uncategorized-position.mjs | 155 + scripts/check-write-race.mjs | 320 ++ scripts/hoist-svgs.mjs | 36 + scripts/launch.js | 88 + scripts/smoke-order-and-edge.mjs | 383 ++ scripts/smoke-ui-parity.mjs | 341 ++ scripts/verify-batch-move-menu.mjs | 275 ++ scripts/verify-batch-roundtrip.mjs | 604 +++ scripts/verify-batch-trash-completed.mjs | 378 ++ scripts/verify-reload-ui.mjs | 238 + scripts/verify-todo-end-to-end.mjs | 112 + src/app.js | 1276 ++++++ src/event-emitter.js | 56 + src/markdown-parser.js | 834 ++++ src/markdown-writer.js | 278 ++ src/package.json | 3 + src/settings-store.js | 226 + src/styles.css | 4864 +++++++++++++++++++++ src/task-store.js | 3599 +++++++++++++++ src/ui/command-palette.js | 382 ++ src/ui/conflict-dialog.js | 361 ++ src/ui/diff-preview-dialog.js | 172 + src/ui/feedback.js | 462 ++ src/ui/import-dialog.js | 311 ++ src/ui/settings-dialog.js | 825 ++++ src/ui/sidebar.js | 717 +++ src/ui/task-context-menu.js | 136 + src/ui/task-list.js | 3361 ++++++++++++++ src/ui/toolbar.js | 399 ++ src/utils/data-dir.js | 19 + src/utils/dom.js | 130 + src/utils/drag.js | 16 + src/utils/focus-trap.js | 76 + src/utils/keymap.js | 33 + src/utils/markdown-diff.js | 442 ++ src/utils/path.js | 95 + 95 files changed, 42057 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 data/todo.example.md create mode 100644 icon.ico create mode 100644 index.html create mode 100644 main.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 preload.js create mode 100644 scripts/_lib/api-stub.mjs create mode 100644 scripts/_lib/check.mjs create mode 100644 scripts/_lib/find-todo-path.mjs create mode 100644 scripts/_lib/store-fixture.mjs create mode 100644 scripts/_lib/tmp-todo.mjs create mode 100644 scripts/check-assert-in-data-dir.mjs create mode 100644 scripts/check-backup.mjs create mode 100644 scripts/check-batch-ops.mjs create mode 100644 scripts/check-category-guards.mjs create mode 100644 scripts/check-category-name-chars.mjs create mode 100644 scripts/check-cjk-autospace.mjs create mode 100644 scripts/check-click-after-drag.mjs create mode 100644 scripts/check-commit-active-edit.mjs create mode 100644 scripts/check-completed-consolidate.mjs create mode 100644 scripts/check-completed-context-menu.mjs create mode 100644 scripts/check-completed-only.mjs create mode 100644 scripts/check-completed-order.mjs create mode 100644 scripts/check-concurrent-edit-race.mjs create mode 100644 scripts/check-conflict-default.mjs create mode 100644 scripts/check-conflict-merge.mjs create mode 100644 scripts/check-delete-category.mjs create mode 100644 scripts/check-dirty-race.mjs create mode 100644 scripts/check-external-change-path.mjs create mode 100644 scripts/check-fade-in-stability.mjs create mode 100644 scripts/check-filter.mjs create mode 100644 scripts/check-ime-guard.mjs create mode 100644 scripts/check-import-tasks.mjs create mode 100644 scripts/check-imports.mjs create mode 100644 scripts/check-insert-position.mjs create mode 100644 scripts/check-marker-tail.mjs create mode 100644 scripts/check-markers.mjs create mode 100644 scripts/check-md-ui-sync.mjs create mode 100644 scripts/check-menu-fontsize.mjs create mode 100644 scripts/check-modal-close-animation.mjs create mode 100644 scripts/check-move-invariance.mjs create mode 100644 scripts/check-original-category.mjs create mode 100644 scripts/check-picker-timeout.mjs create mode 100644 scripts/check-polling-fallback.mjs create mode 100644 scripts/check-render-flow.mjs create mode 100644 scripts/check-save-config-rollback.mjs create mode 100644 scripts/check-second-pass-audit.mjs create mode 100644 scripts/check-settings.mjs create mode 100644 scripts/check-snapshot-creation.mjs create mode 100644 scripts/check-status-meta.mjs create mode 100644 scripts/check-task-dirty-tracking.mjs create mode 100644 scripts/check-third-pass-audit.mjs create mode 100644 scripts/check-trash-context-menu.mjs create mode 100644 scripts/check-trash-fuzz.mjs create mode 100644 scripts/check-trash.mjs create mode 100644 scripts/check-uncategorized-duplicate.mjs create mode 100644 scripts/check-uncategorized-position.mjs create mode 100644 scripts/check-write-race.mjs create mode 100644 scripts/hoist-svgs.mjs create mode 100644 scripts/launch.js create mode 100644 scripts/smoke-order-and-edge.mjs create mode 100644 scripts/smoke-ui-parity.mjs create mode 100644 scripts/verify-batch-move-menu.mjs create mode 100644 scripts/verify-batch-roundtrip.mjs create mode 100644 scripts/verify-batch-trash-completed.mjs create mode 100644 scripts/verify-reload-ui.mjs create mode 100644 scripts/verify-todo-end-to-end.mjs create mode 100644 src/app.js create mode 100644 src/event-emitter.js create mode 100644 src/markdown-parser.js create mode 100644 src/markdown-writer.js create mode 100644 src/package.json create mode 100644 src/settings-store.js create mode 100644 src/styles.css create mode 100644 src/task-store.js create mode 100644 src/ui/command-palette.js create mode 100644 src/ui/conflict-dialog.js create mode 100644 src/ui/diff-preview-dialog.js create mode 100644 src/ui/feedback.js create mode 100644 src/ui/import-dialog.js create mode 100644 src/ui/settings-dialog.js create mode 100644 src/ui/sidebar.js create mode 100644 src/ui/task-context-menu.js create mode 100644 src/ui/task-list.js create mode 100644 src/ui/toolbar.js create mode 100644 src/utils/data-dir.js create mode 100644 src/utils/dom.js create mode 100644 src/utils/drag.js create mode 100644 src/utils/focus-trap.js create mode 100644 src/utils/keymap.js create mode 100644 src/utils/markdown-diff.js create mode 100644 src/utils/path.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f1e67d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +build/ +out/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..035cd9d --- /dev/null +++ b/README.md @@ -0,0 +1,156 @@ +# Todo List + +一个简洁的桌面 Todo 应用——**Markdown 文件是真相之源,软件只是友好的外壳。** + +任务存在一个普通 `.md` 文件里,任何文本编辑器都能打开、编辑、同步、diff。没有数据库,没有锁,没有私有格式。 + +## 为什么用它 + +- **零锁定** —— `todo.md` 永远可读。删掉应用也不丢任何数据。 +- **极简模型** —— 任务只有四个字段:文本、勾选、⭐ 重要、▶ 当前。没有标签、优先级、截止日期、子步骤。 +- **数据归属明确** —— 勾选 = 移到「已完成」;删除 = 移到「回收站」。一个任务永远只在一个地方。 +- **"当前 / 重要"是视图,不是文件段** —— 任务打上 `[▶]` / `[⭐]` 行内标记后由 UI 聚合呈现,文件里不会出现重复镜像。 +- **永远不让你"丢东西"** —— 没有"清空回收站"按钮,彻底删除只能手动编辑 Markdown。 + +## 快速开始 + +需要 Node.js 18+。 + +```bash +npm install +npm start +``` + +> **路径要求**:项目目录必须放在 **ASCII 路径**下(如 `C:\dev\todo-list`)。路径含中文时 Electron 会因 `require('electron')` 返回路径字符串而启动失败、看不到窗口。临时绕过:在 ASCII 位置建符号链接再启动: +> +> ```cmd +> mklink /D C:\dev\todo-list "D:\data\正在工作\Todo List" +> cd C:\dev\todo-list && npm start +> ``` + +数据文件位置:`~/TodoList/todo.md`(可在设置里改;仅允许用户主目录 / 文档 / 下载 / 桌面 / AppData 下的子目录)。 + +## 文件长什么样 + +```markdown +# 全部任务 + +## 工作 + +- [ ] 完成 Q3 设计文档 +- [ ] [▶] 写季度汇报 +- [ ] [⭐] 给客户回邮件 + +# 已完成任务 +# 回收站 +``` + +四个标记,零元数据: + +| 写法 | 含义 | +|---|---| +| `- [ ]` | 未完成 | +| `- [✓]` | 已完成 | +| `[▶]` | 当前任务(出现在「当前任务」视图) | +| `[⭐]` | 重要任务(出现在「重要任务」视图) | +| `[原分类:xxx]` | 自动归档归属(仅在「已完成」/「回收站」里出现) | + +写入器固定输出顺序:`- [✓] [▶] [⭐] [原分类:xxx] 任务文本`。完整示例见 [`data/todo.example.md`](data/todo.example.md)。 + +## 侧边栏 + +侧边栏顶部是 4 个跨子分类聚合的智能视图,下面是用户用 `## 子分类名` 自由组织的子分类(含不可删的「未分类」兜底): + +| 视图 | 说明 | +|---|---| +| **当前任务** | 所有打了 `[▶]` 的未完成任务 | +| **重要任务** | 所有打了 `[⭐]` 的未完成任务 | +| **全部任务** | 聚合显示所有子分类里的未完成任务 | +| **已完成任务** | 跨分类聚合的已完成任务 | + +智能视图用 `Alt+1` / `2` / `3` / `4` 切换。 + +## 快捷键 + +| 操作 | 快捷键 | +|---|---| +| 添加任务 | `Enter`(在底部输入框) | +| 勾选 | `Space` | +| 编辑 | `F2` | +| 删除(到回收站) | `Delete` | +| 标重要 / 当前 | `Ctrl/Cmd+I` · `Ctrl/Cmd+T` | +| 搜索 | `Ctrl/Cmd+F` 或 `/` | +| 过滤菜单 | `F` | +| 命令面板 | `Ctrl/Cmd+K` | +| 新建子分类 | `Ctrl/Cmd+Shift+N` | +| 设置 | `Ctrl/Cmd+,` | +| 切换主题 | `Ctrl/Cmd+Shift+T` | +| 切换智能视图 | `Alt+1` 当前 · `Alt+2` 重要 · `Alt+3` 全部 · `Alt+4` 已完成 | +| 列表内导航 | `↑` / `↓` · `Home` / `End` · `PageUp` / `PageDown` | +| 立即重新加载 | `Ctrl/Cmd+R` | +| 开发者工具 | `F12` | + +## 四个操作搞定 80% 场景 + +| 想做什么 | 怎么做 | +|---|---| +| 添加任务 | 底部输入框打字,回车 | +| 标"今天要做" | 任务前打 `[▶]` 或按 `Ctrl/Cmd+T` | +| 标"重要" | 任务前打 `[⭐]` 或按 `Ctrl/Cmd+I` | +| 完成 / 删除 | 勾选 → 自动到「已完成」;点 × → 自动到「回收站」 | + +## 命令 + +```bash +npm start # 启动(推荐,自动修中文乱码) +npm run start:raw # 直接起 Electron(仅排查启动脚本时用) +TODO_DEVTOOLS=1 npm start # 启用远程调试端口 9222(外部 DevTools / Playwright 接入) +npm run check # 跑 47 项自检、约 1200 条断言 +npm run build # 打包当前平台 +npm run build:win # Windows(NSIS .exe) +npm run build:mac # macOS(.dmg) +npm run build:linux # Linux(.AppImage) +``` + +产物输出到 `dist/`。 + +## 功能一览 + +- **智能视图**:当前 / 重要 / 全部 / 已完成(按行内标记跨子分类聚合) +- **任务分类**:用 `## 子分类名` 自由组织,含「未分类」兜底 +- **过滤 / 排序**:全部 / 当前 / 已完成 / 重要 · 手动 / 字母(全局偏好持久化) +- **拖拽**:子分类间、任务间、任务到侧边栏(Pointer Events,不依赖 HTML5 drag API) +- **批量操作**:选择模式多选,一键移动 / 置顶置底 / 标重要当前 / 完成 / 删除 +- **导入**:粘贴大段文本按行解析成任务,支持保留 `- [✓]` 状态 +- **冲突解决**:任务粒度三栏合并(用文件 / 用本地 / 保留两边 / 都不要),附 LCS 行级 diff 预览 +- **搜索**:全局模糊匹配 + `Ctrl/Cmd+K` 命令面板(搜索列表 / 跳转 / 切换主题等) +- **个性化**:字体大小(小 / 标准 / 大)× 配色风格(靛蓝 / 海洋 / 森林 / 日落 / 玫瑰 / 单色,各含深浅两版) +- **主题**:深色 / 浅色 / 跟随系统;与配色风格正交 +- **托盘 / 始终置顶 / 单实例锁**:关闭窗口默认隐藏到托盘;同目录同时只允许运行一个实例 +- **6 个插入位置偏好**:新增 / 完成 / 取消完成 / 删除 / 恢复 / 移到分类各自选"前面 / 后面" + +## 数据安全 + +- **实时同步** —— 每次操作立即落盘(800ms 防抖),状态栏显示「已同步 HH:MM」 +- **外部修改检测** —— `fs.watch` + 250ms `statSync(mtime/size)` 轮询兜底(云盘 / 网络盘 / 非标准保存路径的编辑器也能感知) +- **冲突合并** —— 磁盘版本与本地版本不一致时弹任务粒度三栏合并对话框 +- **原子写入** —— `tmp + rename` 落盘,永远不会半截 +- **`.bak` 三份轮转** —— 每次保存保留最近 3 份回退点 +- **带标签快照** —— 用户主动丢弃编辑、退出前 flush、reload 前等关键节点生成带时间戳的快照,永不轮转 +- **`.pre-crash.bak` 兜底** —— 退出前 flush 超时(5 秒)自动备份当前内容 +- **每日快照** —— `backup/todo-YYYY-MM-DD.md`,默认开启 +- **数据目录沙箱** —— 所有文件 IO 必须落在数据目录内;自定义目录必须在用户已知根目录下 +- **单文件上限 50 MB** —— 防止异常数据把目录撑爆 +- **彻底删除只能手动编辑文件** —— 刻意的设计 + +## 跨设备同步 + +把 `todo.md` 放在云盘目录(OneDrive / Dropbox / iCloud),或在设置里改数据目录。或用 Git 管理(每个任务一行,diff 干净)。 + +## 开发者 + +关济寰 · [guanjihuan.com/about](https://www.guanjihuan.com/about) + +## 许可证 + +MIT diff --git a/data/todo.example.md b/data/todo.example.md new file mode 100644 index 0000000..e4cda5a --- /dev/null +++ b/data/todo.example.md @@ -0,0 +1,36 @@ +# 全部任务 + + + +## 工作 + +- [ ] [⭐] 完成 Q3 项目设计文档 +- [ ] 跟进客户 A 的合同签署 +- [ ] [▶] 准备下周一的产品评审 + +## 学习 + +- [ ] 阅读《代码整洁之道》第 5 章 +- [ ] 完成 Rustlings 练习 1-20 + +## 生活 + +- [ ] 给妈妈买生日礼物 +- [ ] 预约下周的牙医 +- [ ] 整理衣柜 + +## 未分类 + +- [ ] [▶] [⭐] 重要且当前的任务 + +# 已完成任务 + + + +- [✓] 提交上周的工作周报 + + + +# 回收站 + +- [ ] 之前删掉的「放弃的想法」 diff --git a/icon.ico b/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..4bae4d4259505bbb822ced34fa7f10123f0cbef5 GIT binary patch literal 40571 zcmeFYRdgH8(k?f00;my02LMR!ASvrKmfoT005AZ{s%9E0D!%ZW2~(I z!PzhXK<5X6!T-Ux@Bn}=3IO2m{~ugJ0syGse<=8P{i83`6acv80{|jal%$d1@js*i zNU|~#svrF0i~v+v=#Q4<`rYV*K{%^Qivg;~36DMi$U;<66ac7;LwGTQ`oM4wGTP1n z0CMj?1Tkn|_MsSvDJvnW&a0e~bfK*J33u>O;F*mRwf*okgk`z1LBl>%*QRP-FH3h` zBVmhuqxo-QXDzIZ#5otJ8WRDnSu+G7Fh9?Y*{UTY3MWtKAX{i-f^X%QNz@?|la4TZ zPl>s7c=XV6kLQf~Nv+N~Vgj0WR&7L(L-`V#ANxSWW)`x+A5=~jshjZiUxDz9dwH~yIMKA~N1{aUy7>7p z(!hdnZMML!yl*FyiuLB7GTIJ-*;dc;*0uCRiC>+0p(~1tiM*KBVG=j5Li)4}AYZQd znUY3QjR0`L_>W6krU}-VKgf4q+Rt!qWM@6~W|_YfJv~rQ1Qv}JBVzwb=>%FgwzB3- zrNc(&$E^i!ku?ijI&un17`fQZIksE5TqsFa0lstPM9H~eQwyD*m^E$5h=&<67e>#8GU{goOm|1 zXtbnFbQ6iSNa=Ps4kBllbel4yOi9-YNO%a7G>tqZH=?jZl$s{NWa7wH6^8f5e0YaL zl;#lB5AMLdVG=iR$_nSw?>a=l)tG(9#?@eVdBoD8zF_Bh@b5}F8Rcq3w;W;9^lwIv zydJg{%~*Jj|#DhN-}1*IfIYXDi~Y0w(ofENy&Z#Bi&hQyyM!{Jb79zOnz7?{RT6Tw0xfn)ZQm8#*Vmg- zN`1JShaHy|@hqsmU}OnZb*?4t^iF7)y9D-3JQliPCT9BaCL!Az^yUhGB@Ss!JE?Y^4R8Ul1)ZT<#1tx-@1+d$}ibAAJ%Ancfo5VHo?`|bU=G{dVeH~ zh>dV0 zSh3Gk45_NvNYu5d3Cv95RTaT%8(l?GF9p_wHfxHC*RsS5AT$4$D*1MK1=_S&TaK)cN`}m-`T#D@^s5^Q7?Rh8C znSc8Y2Np0l!g@6-_z|rBSCQ=_RRdW6jcl60$FToSWP@S*C$d?JpJhI(C4D0ve6_wS z74ls^J3U{_Ih*Z1I&~@2GL!`F0utlbtofa6sl;&I?j{mC(%q?Q*PX!TnqhGq|S=QwotxK_Nug+y;3D( zk2=lDWQ;vRCa70v*9#vdf2KB~P~wc?*QamG7}STe!K?vsXKDfo#ux%(_TMGD9ufcW z@7Q}8eK&*UjElNfv3Hzeg+fX?k$x>px){7PhaGDy28Ycl>KsYhxp?=xpAb>UerHR_ z?YJY9w|KeUiN0NMs6M{PF7Hr$P8OitNCcu-i28W&crZN|+_Co!fy=!^N8~s8MBruA zJx~YV4kFeM5H3)_1%Tg#Pw>1Nn15h4yu0sv4U1q9WH+At7~4n5f2^Wo`YY9q zH7YEVhOu!_FRG@t*$)kiqm}Iku4KC;%lhVlbls1Tu-0P$_1rCx_RG0BnUhvWHLjMw)>l#bX<`S)1wv+ zN$K%M+vp*52cqYAK542EKf68Eq=ZOm4Ovo^!nu}U@E_3bw@j56ifBFys=FH$(LJPN z1XETOLM&Nl8yid4G_leoeV*K8%+;hT`cqA1!`#Q%ve7Ecj8M80ylaF9%H!N+Y#de{ zJylJ~oHvZ`5qhUh<8t=rlV62mJcGgyrt9wd=a3PJI4%0>E_@9jl4YB9u4z3U_}H`^ zXN4T0Lhe7U^U%51`EPC!mesUP>@5qis?&2#qcQBbnHF2TK!?tpSS9pNv!wQb;S5-F zVntKncgCUGOJ>-rG-iltdUMJKe!mMj%sTV3MEWc!tKIkEq3f#{<)RDKHG`iqvw1Wq zCY~UbGHY{$=x+l>{Piy~M%Qe>F9Q)Z7h_E62@bws6PENk*@XEXJH!d zPx!=ZCbw^IX5^TP?)%cT+mO+`Ab^LyPNT9c)Y9zlI7=tn=CjRI(Zb; zAqUUXXm2qs09V2a@gtHle{MP@-e0~=s-p3p_^8E#>O_sUHp(cyvft4VW%2m&TS!1U zBUTJ#FvuX)fAftqyPA%a`g)<6m0i~xdpntz3*U41Z$9y*CRhAHHV)ReNInx*6jl&F zNgg5@J`5v)a8>u!`kp~9HCpY9W`G84Oxe+7U#j+d1Ln4fr)g1q7|B~{vA`N|m*`1w zR)r`+r}8URQ&t?HYE(}oLN|kw>*K=GMn?BlUm;-;nTG&>*t_E)&Ymxd)-3KE-cDa; z$!my<6;g_*c@SG3s3!z6S?QJ?XYJTcxXT=3A#ldJhfX(re{OR-w9vEX-8_o8)7y_b zPoq?+C@~^!1d^L>wO+`ftM2vBlQZXv{_b&((}GRpTx1s+FlPqn3^mi4To2ZKGWC?( z+c}c_(P;yb*B4|(dwRZoOVMexG1pYLC=Z}$+$Xeom+N&r^jS47jPyxjU!tH^zKO3) z35}+>wI4OJV6|_7;GE&`Me5JksJmU%#movVKh7#Kjksh$zm$Ek#L`{yNXBuy9-!&m zG%bj%zOv@E0!b592N)CDF4QjQckBBSdp$y(-Y)T;AMD^WLw$qfKDNYb?vnofsV(=3 z;Vm;sO0o0HGW$#NB?ERmWgkxi8|#>;(n(xjE(hw}lKJ+;OG5p{cC)#l#ODh-olxe# zU4Kc=b*umOZhD-%`gG4dwHNP8ULTB(1^e7VXUc~?HWq1S)*>_^$k!PMfOTl{JuvOQ zAvPKX2re|#%Cx>rIM{h{V?`*CHB;R_ zFt8z63kRZGN47G{-;jxN{xi6C8U5r#TrmRaLp-&QBf$0Tm-LS=_y%R$={Pm=p{O!W z-Evml66uMnU)Bxc+;xMQ>-7nKO=z8!5hSG&R5(rtTa#L!4rWNt(?eet{*0-al&q!Z zz5|QOs28K^=Oh0&xf%S?5B@hdS6Df&|95gTeB?j5*lg$jZ|mjS}b zx+THNfO@2bWn_M4DSo`UpRZiTmn67b31oqk0$d`wOKYw7>p~aK>-R$U>$0J^(Xl@h z&6{KmHr|57`WTH#3V9lJuq1%TQ3-S{-dl5sVaG)`gIahge^$~_TzEXBJqWRcj z^kX3osw z@5ZzL2gWjogW&m8dlAC|2I{N@RQV&Zwvo`H#utyJ$|r-oD5?-T%|Ngn`?3KK`Af%0 zQk1W>fqJ6;FkVqoYYB+y>0-h3_gNVxq6vRkVae#h|Du0b!Q1osnPjbXd;8hgZxVe3XQG) zR2L?hmBAJ9c-HTemLd*B2f8QKrZ>arVzJS1wd`MM9T5Y_t*PCx;GynE=;KID+Du_0 zHHGMnZ|zd0Nr{?*_j+m5=wj0}$^(_EqV@Rl-5SeuhSQlwjIt}E!xe5K5bNklx?W_P zE7)3z9SNEX!jT9EP6U?~L6`sRM{9<>Du&x`pPC2r&!~s#7WvG0_^WwVoI;%5XJ4@WQ4lC&o$I*(Y zz?`I`yw)P1*2+W9ueO#2Hlq$#pGwURUh_ya*`T~WfI>;|SW?(8f$r>TAFi<6muP6P z07_6Wvb8?^+1~R0+@|+kXGQuCnOF!Fx6-_ENP59Jbopn~k;cJ(k6rg2ih=T}&G1;*uDc$Q8aG0_;m*XP79ON{IV7C;b=4jH{G_HtZaf=~WZ zEmBjNkSk*tjdJ(Y7mOMD**=LsiI!MF7k}HyA+e45o0fL4uw#f7wIw9LaLiSyiWvGg zm4x_E2dAJ+>lsU{3G>~8t&Km#dby(gRQ0sW|+h(>sL4G<^hC+??dbu5c_0Sz*-u;XvaAsEQNK5FI8M>erye zqXPY3rB{f0rPs>u?ohq;M_MhFl=u^M5OTd#RN;SYnaa3$F)%DE6(MgFWFIluvJUc5 zYG0rV7l5E`Pebu;BVwhtpq2s7%5#i*J8dYVPfqc6l4P#iQR1fk+a0pu-rY@s-V`ITP7ML44YQ zRhRHPvuiVsM52!co23Sk=ujvYZ5KiQoD&s!VCLcBh3fliw8OE9Ix2R|*~}d0Zzs!N z2bb#8LY7S|g|#qa7t~PhseLyLX}>ie>0oZ;1<}7EPck#dSx7vO=8Ngez#jDWz7pu< zYo~FnU+I@pSRAV-4#u{xn@Dg6OU^uPIgK_dXouRLrVmlni1b}WrV`J+DI(BTep8u; z{}E@mvye%aO}7z<0J(2%VC?sd1RE2$IG&py&+f*UM*fmEl*;SKaEjHY^d~~IR2Adw zvv6eKmzg@TzP@ebxPjD`NNC+qL|~qaG`yKTJ5@cK5Xmit z>=IE z9=J^A)9Mn68l=^^b0_L*$@KP%|nTOsv(3n4Hf?q-S>g4R;_aJsJ_#nHKKr zFh?8z{mWr+U<;dF!r{}`C0?`duzih|4SMuMH8dmqP3E?Rb<9WvG!T#n6^&Pa1$sTe z_YBKO7FAyQDt zka`OFv8&1V%z53U=U=?ut&QLmqLrFDa`_m?%6WBA#vN^VKg>hd+Nh>q(L$Sidr}CZ z>F1?ObO$n}qHtzoPFnuiba^xv9-m~h8guX!xmL>6W{XAojfXkkJltWtC6nQ&(!Mnh zf0#eCyKlp>tFiPz`piJ>hdNyQ=(oG|`y0pC;n(0Qujt2>!Rzri6=8nDKdC1C+(dI% zqAigr?;a^jf(V`Z-Bz^0jJT!^rzidM4({(4t6cr3VAI*Z3DLSj0mJXH;H8e> zIq43=`=ejkW%a}S6Lj`h2%F}=ZGj*$G0~iyQF&^17Vda8JF1!yQBf{cIp2n)Hn~>4 zR;cZBruPbY;Q{oGgU^jgJ|!8Wv0<4_K`Zp&O(wEQe5SyY;_D+@Kc^KaSUXB1(jy#* zK*N+tdmyNrw!i=iHc(Nl69E;J6bykz3X2T~NexCtM2A-QH_WF(YzB0LdL!=^+c8Cff$4Vry*ek62{!GNvF&WLcgVUMqgRBygQ)+_qSybU-~Zo5Z?CY?pa0IHmxTYn z7QK4kypuoA6MJcEYJQo*`m!*$hO*-Oqxo1z*wFElV?xU3V(NJAVTJFM1qgwp;aaE& z=m`1Eq~YPyV(c8{#R(EEq+|iXHwqWGkG=;T7saCrVnA8i>$j_y#EhoVQK1Q;1HOaY z=SaDR+(;qnyz1g5CemmyH_b|vT)o{NGML&0O496qaX8YAlSIy$oYXcC@Kkt2T}&-`e8p4R3oyT70q zzi*R4AcunbrHDjxl#xuKp=#^keN(Q;udcM!)>1}39CBYXYny&TJTn|axdgVS-10h9 zLYU3P&{o=u$=Y(bEpU;wHXneae zXD3#rAM&*bAL)6%msb()WR^1BoU)f)5lp*SO5Ix-Ut~0gK8wfhAeIFkWQ&7sFAMHW zKdIyO;krBO8t`no8lJh)IuFM-xf9;mUx*!YzG%9E~h|{u8Ax zwQ!V~ZBl%up6H7Wu}@VPP`7T$Uneq9yNvR%hpNg4Ns^wo68K&xEf*n@v_s4_sVhUQ zH@}n6CO||)TvEy5P43r_yqV;*lenL%5Bi*P{GQlb-!b^Z-x2FYi(%mRJ-`gEkgODH@+`$jRGNpJGtmku1Dr%r2=% zHsyE2S7^e?fTJP9VxS@aLA7w@S$I7`so>(M_|aN+I#T)IG(@6ffx7!cLcaL)t}ip1q(DY0V3{s# ztr(L9F{#xnA?89$0rLVIuq-ZNNLWaDvIsdebV zvi=#T8~_;6_2pJ-mE%kUG22n-*i{z~qFxDU)T<*veq^jCpJWxyG{T=9PDTlOH9n<H&5eL`PMfzaj; zPM6^V*kT*@@=6JgqU%P{zMyOXfh)w{B>U?1kQvT18qk{6PHXE{pa6r(t$o$`>!PP! zRO%>1OhTLRn1 zSB`z%YoAYV>D?StIUA8Sg|IH%#)a4e4jx4nk7E7BsMq1Fswv;J$2~Th?0ikl@=9XL zk+n(>F^B@g(*)Xk-I(!_484$ze01fnFnk1H^g$v}1L%?l5|RO+`~avn)QoO+?_87g z5y6qasJ0SoSYx|zbBzvi_hyj8Y`4nz7t<}iOR~C+DvNwdrisqOR2HWvXxfh2B z=1ZqNfQt$Dnyy7M;D^t^__^L zS~M&bmO+=@MCf>7;BPyD?3(WYW7$a*WVjH_+iiDmZevPBMmuLRe-6`oq^*+9(OOk0 zAZ01wCkUJfOG&d;br8Ch;B^rcan3|hFk}G{;bMnQgD<|?B;EZJogB=_xRTntxedGd z_cYLDj#ZCMKX{KX>yyJY6cP(Syx<4r;U}O9TUq^5R4=xn!ZAosNHjS5ResB9pOwsn z)wJtzK!KFIL!Nn+Wj;Q6lpy%qYbo0^Up&MSF}u6f{I6(#;jp$C^k2|OJ(rztP@$kF zJ$~u~xWO+oTxZiM&M%ffc-$Z0P%&WS$LSCCGA6UsYkxak{+$kU_>v53fMsE#nSzA> z9U?Gh1G`iX%_|)MLh-GG`01+Vc*Q{O{mUA;?Hp)1cEq~KS?kA1BCI-RW9X4qk<76$ z4jqI9;Ca@8r?Cw-&AtXPzF5vhUA4G^ zBH?>)HA8pL*x&T4p+sYr1&(?xI4=(;cMBfIdvp#mP5-%ieS;hlvNMLB4`%h$WpSWa zoM;_K(IKebKJ1_Ce_=KOedRT%l#z!oSXH~WOeBnkJ2p#ugp&6C;-dMgQN-|Rv()gr)@bS1(brJiO=mpL9ifN_+C0Kx zF?ch^0ODW-zSU8dnCPA^2*}I6*ZUorSxRts9tjT0L0|?e{&WGy55r#v`OPmJtEdz` zBG|$qZC@cd2kR$m`Mb({!4|H8`ea?c6t$0Okf#VDO%D^SE1ayjpC^~NYEM@H0(IGX zRbl~EHEn@d6DSAHHj?uAv%h~FtXZg{3x0|G;{%$;9F}$W8B&pWeT7pWki7of*Ld*I z08>&VXG6q+Z$jdMNwZ`8=v=}GSzFQpS0scm_8rmv3)Dlkr7$xQO;7Wz3}zi-J{-RJ z;+?C1>?9hTX9&Qn40kqvuwBhB3}RSwOJYY#i%Hw^XSred8uG%ZDQWHmDEwA7S+}X; zd~)CbL}+<8WX79;8V4mFmhW;30(*RiD$E5Q;X%oflRK>0zSbS)DLZFKZSuX_MY!(sfPq;NkZ@t+G5S#qDXYxQj2>t$`)YY$~RHlt#mM(BZvMB7y89y3= zWOtYS>dOY1hJis$g>rCKj>ro${P5E9IoW~HxjUdDw9$+qlewDfv-!JEmVD_l7wnR~A4l?@4fiZn}*6t+kAVs8KEHVOf zA;{c?0Eqga*Xo-8dp1h0^{gI5F?@taiEAq(hqal*==EIOy~sqD-e5JK&QLMAkk%M@7oFEd_nO~*0(#p{e9!fM*@NeR*ySUt z^`d|50D9I?Kz@U+=I-*~Aot0-AYch_4n_aoV#}(f&Wg`r_-)^KcDi%30*>4}zC9tu z9}>xfdZ^qOsw9^NuoX_^ejCPv=Vehv&_7!_UK}`H5PFoR=r9$h9PebNodI-pG!}gB zyf^7uyC=0c)e??{Mm8KYnwRr@-J^rnOmAz?2$534-QKkz(U@4m9N4yYCG?1S}@>9ez7* z{UMtkpMJ2dnSIaBNZ5QmhNlHL`bA=TCZ); zOUwIMy1*$OsR#a#24_3&V8_=h%>uBA9%1zCPi(EWD+;QNDg}KfXOYU+`=~Y=SVw(n z6>6gs`J&5bfp`CRL_$~wIH~y{Qpmy`BQG!H=vfh~_2Jddv4og}i`6iOfr!gkPo%%r zUh}UQo+*G?a;c-u)w7GAtfWdrJtIB4>$LrB_@8T8GW(bFTDFz6M{SOd5Zt;?8x&%n znt2)BJ!z2@V~vA1Il-5&xL2@FaEKA`3f`wir!nC+;$48kgfzMM&0dQS^_FRbLjNH#e<9fR!kHMeSNYZ@;+*0!-G)k^s{sMwu zJ;6S0@UK`4+it^Vi<+-ggm;4bP%=k4PMW_8`|V{OWIQWBs755}kZ=xW=2sm0Le+BIkVELvS~(R}4hQ9( zfp-t;P+z5kwFwNMy<4@p!TMdM8ohVtgb#)NWYCpkT?|;4beO#SR_Lpgr!PSZ`+|N3 zLMPbdQ_@GI2|am4ApF^PodSvT2RJQO7utGbM|nlzL$3#<@pEZ?@>xCz%Tl_BP<1yF zVFp>5V0l6q=i!A)neROD4}S)>mR%o$UgTEEJAfy0Q3(cS<_vxnGqe!jLiH&C5nyrv zno%dz!`-Bv9*HyETV)&c;4x2^@kGFSEe3xn{{HKU_G1z|@ZGSX#Qg;mp@M0q8Vn?@6_k86} z-*z)q#;Cv;M;B#N9D9SMiz|I7<8M=q7HpAF(wOhxXki1geeY*@gsdVFZ@7>KrE$Yb zh+RPh2#^lJ!xHq%!H%CZ-PThYggx2`TK?Cx++*NI8_LCQ`aFbbVN71bFkSk6oM`j& zxqu2Wjkz(sDapGwIBLp6XzDIqjk@=yG`A~_Lb|7WT20bgm!C`tILOHtP1BUWRA64W zVfLrDAhY42=#+jH*9WeHb`~@HUDUf%Ods|s7$-q?EBR(z0{#TO+Ktx8OV4q5erzb)B!xdXv)AYW+g5kFzv1t z>fOhYQn$&H?nz}C2M0#}TrOQtZ%Na71klzWUrjh*{Qid|R!TGnaZdC#klLv?>X#+R9@OrdLA(ePQ)-WQL6Pu;IdEIZab$U1| zE7nT})7Y~_lm{^^CJY7K%l8(=Ih}ZxoEO3UiBMI1NJ0DS!=CRLow<=~u0&0~_n1$y zfO?;{;UEfR!%qWFdfZCEtsnDTWp2Tt`R|m1HiR-z{>frgD3SJNBE2i%0Bjsh0}}#m z#-3?xqPyyJ`M3tPQMQD>BEHJO2j!eg94T5&RT7HHG!Wv&~afuTpDoxjls7I>W+^R1`j(X%#5RB21^Tb~wDNbtv+F2Kw zGVo=Ff3|@EY)foUBDz^Zg8N+nOs%!x86o@~(55QrEe{jnq$h)QzEt#2rqFZ&R|Npm z@XH(Hi)SJ%PrqL5>=!HA%7V`#U!S+{-fyu#45UbWYCb1QgNz?Ay8Lxj;Ou^MZCFzX zA0MYY-Apa*$K`_a(EQKvjU9g4?#}Xen8(4t_NDDiN02iv z6Lw?Pb|()9GlxG6NV8z9;D#J#V@;(;-D8Y(*Y%&0Q$h@%G|L1LN57wxvn> z8`(^n$8wqa3jws4yqH)aS@x|&7ee=X;m{MPn+$O_^U{MoIh0|1)>NajTPA9C%P?qN zXLMatp-ApdcSNHPaT9*HN0|4@T7l8CS-jSu#W7<0{q9dNtX?O^dP1Lq4J0Ek=i`#L z?y!4I$0UBS5kdR#Ths7pbJdm!huDS!bITUp2o(4NuzGL{NR|7h4ryQR?!RrWS>!^i zT3YkoUxw$)6-0Aa_nEB67s!zCeDCegn>R*HaWkuIyugeyGUz}}9U(DtFyn~(UTTgj z&c8|D^OnhSb!gpGhx#!21M;$W%ju(f@St&FD1M7QP7HBa=X3*xt3nC0?W!L`xcnhY zuE+K%n9f?NOYRjIt0xs`uEzf-#fp@vVa4!qUx+tp_fjb`KzY<7xEj?TPmVf-nT$9p z49TK3;f2AEY(}VsMpsMD+_+|DB3@=I0;?B`Ce-K3COxWif20)|iG%<1W3>9AD4cp& z>Q+E9fx{F7`HStm(8GC`@cdB`zXr-@Sc+TfaeRowjKCWO2zu1l+0*_{0<3<6?0O_j z#xcc3>mIPu#|sc0)%6%GmxD3P-<xY<$v<=R~l(kGgM5!-ateKoPM$*>Q1!>4RWRS?Z%&WQH z-W75^7wF9Wk|vVwPH*5B_XpO)$BxXFU z3+@IZw^|0QVV{RA;WCiEvmuRr&zW52%9g3zfN;K(TNFu0v4{i`A2a1Uv0H_J{3v9+ zj0UU%kP%57>{=J zpou_jFlI&8s4-gi7lT9#kRT^8{Sg@gEmx&L)7X>!3qO@5t*g#~nvl+^8Do1XlHjbV zG}XGV&v`e0Qj|CdJi%c3C=wu`q`PR+e~M3@%%g#5QJ8#tim_BF!Wic9-o$!1y>yVI zH1#Pih%V*`yIpoHIJduGpknl>+2S}x;q;aHoLxa%1jmERZI`H&-M;qI6Z*l&29&A{ zS$BjucD}lOh|QVHDxnwKkJvT3PWT#o{J6-_3&4e)R=M+l1On|5Nmx%n^J zHYO#H4{@w%l_5-LYIpTWe&+zF{K>Sw!{psp z4B8pf+#(gP(F3mR#~F=$L7FMJFs$ByC}K*&%ZcRUM@XxxjqXoJPU1rl{zi5>u3$sw zg)z~2i=)T)sn6+U(10LZ8Nscvc|9r{-p;nO#>AI&oD7I6gR2^OIG`_-&a9=D8d4Ay zTC1M0z3)%AV0<2`S$pxI>Ck$;<%-p7bE`QwZB%?S72myDyPgS=leFrDjTp<=xYT2) z(VIWvUq^@Z{prkxZeFh)idyVU1DD`iSJ@g|5F4b_k5G!_ESVIz_Af|PlKmQIiw6n3 zB?|q?-5`dl9iFU46Q3=;X!rY?X1By}xONfR8tIWIk4Zv%2~nimTdRWO?fLWd0X`}J zyZr4bBMy~C!@Bm8eH`nSSyK&b21}>yevDNBo^-M&HWVte;T5Mdgd(E zC^}!%+OA&R1^EuRQ>TAk(6&P-M979Hm<+kg(`$?7F$t32bM`;70ecS;V!yodV)8?ZJ)3*58 zR(wkq=kLcqMO$Q$uIQnNy>BNTTSE^6}Jd?i63#L zzs?s)wT1|NTi6~797{n6V~9iR-`fJeuYCDk%jaxZUp`s35sQJ8rK0f&)p+KEkXMG8 z{&X9Kdv*7U8~>L9JN3|+g@C1p=%+R7OO8dY!-~@>uWH0LKgwSvttC>6_&*}wCCy2a z-CCoZa{&q%weLp|>cqS11dAL_-#+uH=t(n}bIFXYzz;8|vARzsJrr&8G@GLz5G_U% zvh>)SNH)tm(h+BRK!$cW(nxi@0tS2@U<*@4Li$pAik(ds!F9 z?QjPW=Xo)lXE<>^$xATd_xVluI^t|~Yk%9?i<2^WYutOV+tA-#&{~2d{KYWFSaaay zg>R`dH}kW49SoUmo4o^q_5F@?W;Id8P7$;>u4Af!A8&AG%E8xs;`e_Axo*0y8zCpsY$T!tR19f?y}LQN&qtrkl( zyHvfv)+GC`qMBEziOy%1KHri)0xO1E^KZoLBi1Tf))vaA`S>0CTAS0h6FiQ0oa2tb zU}y@_hB`%v5a|%&r9mdzGPk-?E1cPuc(tm**rF7h&}#bm&7xC9cbXmfwmMrrbH8`) z?|!YQ9Nf%3H!)F*8y>o;##6ECjAMFXvoE$<&uV18509|wDM*W-O~f6mU_u(0cRy?6 zJ&wCV=D{COLBJBiDV)iwR3A8Qsws{0^`!8x^uMnwT#twf-o*t7x4_p#_x^ zm7xX2;^erHbuI_AVQ}ApLr3A4(_glXnsPlew2yd@}G;yN{y??;MHAcZD9!ml#{!C>_I{RLCTr<1%A zmo@?DopCCYFy=*A;>~PPQaF$c5e^6f76_+=f`j-EZ;3;Q1c1mzl|F}KegezrIe8OZ_y!9Zjq`41MzN&_HU?w!-S|fa#z8pgLpREn_dn+&?t7H^sMuJprHUi_ z%aCr~)Wr#{EC!s?QxL3jNS@1b{Qb%}B(Nj|S}&!*@zNg5a`Nbme3`Y6bcTu$wN-65 z0~<#_9Iaw(yr>0TJ#1P`-O6UQ`jW{34%ws@GOX}90)mCZ+t>5wYzy{`Vnuk_EYoq4 z1^XM1+yrNXgFD@(x{7M|s2|-e1wW-kMajg#?45NLjWSK$IwwoS0AB|>T3QKsnSCDh zxn@^_im$deeW4XPP*5ST5P!VQVm_n!2a18j{%ytoU;BTh1&Wf;z)0NcpG{0I9f`O< z&l#(k5pCd<^?N=(dHOQOYFm`(dfGFQC|^vi<9NQsz{A@HYv~%FO}3B+SC_hWbJ5M0 zgvO-YF6w=Xi@`$jHmE$9yq+^MF%$o_6AR6c1~F?S^a|Dx;ntirq7Z7FM$wL8!n$4D z1o=7uMELy`T{_`EHiEgAkL&t%!d%^2P;AP9+>8FM$c8x>S|^xI1cY5uFb{%Ef%?|= z!3IU`sJ=OO;0LZ{$gKrSmSBTYAyiL09h1LT_&BwxyFCy#b$<@_bad@uxHTiVnlk1K z8$?(61M;f6;@k|QVoc?1)K7j?c>F4S&I-qMH$g}XqKHzG1Z;DYetW^MEU)M_e=Dd+ z_|gGW?$t8Y6^h81bLdq_KzTq6=+P! zcd({wJfrOi&7m6F&<&hxjjFTLWSc*y@6TML(rPcoWw^)QeufmNHGb!=TzsZrzds$w zj0AA2&znG6qe=jLZEBx(Umf^(e;#^+466pd%T0rTTJqFjs>`4s*rE6P#(LYV*}~4W z)$R|1l}nFMk@W1f?WbNJThJfGg0wQyA#G(we`nDiGyND*JE0WdY6nk$P-6HVL`Uld zUe#2wMB2MnQ{fYVy8BI6JC=dGl8slE&O@|9EZbL|a#2|91m!Dq$@D=)zI?TWou>w| z+ap**EU&0(BJSMdnOumaG})ntJQ#+2DxtSn4V6qbWq^6j#7UypwY*6ySbue2)rcX) zMJ?Im#5_fSY7O^qq2{BHl9Je(O$5{4WBHJ=Ea)Z%1SMtcmjJEt&?5xe3cxVbzA=%#iG4NF3`C@`g0(y1b<{_;e*u;jVOV(31D|ydh{}g1Amm-U6HL!|Bm1LoN!U^=tFx^PYy&Y1%j_8v)gjVfCw>O zEXFN=Hf~Wbma=}pUP)_$noU?qJ>dzx>}!pMnkE_;wg=AN_WQ@EEur8m_nMu$sd)M1 z!Z1pY9GcjvRQ|ja#?J zF0XA--KsC`y%_3&xNmB{UE**+ubR^$s`h+REk2_UDCr9wErcARZ!osylZ1}Vtqdtt zLjytB4P=zB4BW_xo>rh(0_l%;n%G_j0b#uY`_L?uwtv-kit;*BRGc2$e*EO49so z<2Hm2eqk%QLKy8bx-oNG(_z%x&)Y;p46fuqh9c{?;Z0K(>k8VSvnr)GP$q|A^EFVx zW*>nQkVSud`Kz6H>Dg`Ply7H>L1R$llxQL^hW)~+Sx$p7QpndV)YMKAj19D?7q<^gSX2$kbD^TNZ3RA)1Lb%t@@`0PB-ZL&EP z!`45{#N$Podj5dtQ9^8ry_BG@8WLRyuO(Me+d=LhBb@=_M}k15H8Q3Mk>kBt@V`xQ zC46o=U5Rl%h&2N8n$e?mWzb$TS`6lmi8lJt+bBI@uy49r^maZ#Js{Rqc0`D9*c5y- znH}se5N0N34Mq2Fj7pIEfktj64QHAE>{wT}LsS#fnJ^q2$_r@_7Se|s=E?DeN9kq1 z&AQP+Pcv*76vqbrRuc|za&J$72KOl6ujSgDUVF#s<_S>901ie8qAq+Nv3{4>nPO>@ z{TK>8C1;1BPxxZo|1^Sc^E2W1iXL$+h@TAt)(=2s01L#Oiv|cP0JLp@i`hpppv&Ks zfWHvI?-6T+NRH-i-rRN8;;$sj%(n9gs+@h0H@bC2$*}oMJq$z@-$B0xrX8hb`?Elg zoL+uB0aKtnHvVA1(2I>6;CXFhr_q9D+f)`{F+mXYF{F?pfOhelHAqFTpE6$UYi~>B zanW};7cpRdFkFJLM>7cg36Lj15YW%kRTHNGMC~cZxnsB(a6IiZ7A;xO-lN*00^Q;yn_0+JjOuVR%Cicm9#{h|3utf6dYDO+FzeJN!D5Zn;Ua)6@CH5P$*-D68J zKqV5UZct{|6nTJ>5@0S7;MR}9+@AKaUm8=faKl-NJ1mqEEr&0I(49mv zZ_P1|O~VnORkv2a04Z`z&%8DzLMuX-yzsE~86jBkOrfs`gpK|G0Kz~$zmWh)3*g(M zNCKUV07NKI+z%muy*qK*K@1einhtm3Jfz)nJM8+Y0FeYa1LH{Wi3q~{ z2Q48k7Kk&zpOAoMH{FVDdNYtR9WceR@Hdzfn;+8!(Gmf8$OrKCOYnGGCV&H;<+$y+ zMgll%?l^qA;}_)ZbRx63JcIx$C7P1Jk=6t{8373J{Yv2j*lYs|$}4c$gg?nHH%hzQga&f<#m~ZlxMU< zZ%hEaqjb3Du@Tr~Uka}$rwIX2TBg&N4gZQoaGPR-J70$rK$CNagd98anyr8YALdxp z#_Lf}LT;Xg?FJL>yl@`cX2FAb{o?z|qhIIu8MG*iYn0b2)Hj4c1y~xW0Nd5x~=b7X%Qi z!#^H44ZEwpg3D{+Y%IzKq(7j_oesAz2VTp2aGIj|Ph;D*T?LU;Wb&RHf%pVq`V7=3 zrJy!G5!H!F@Wv&f&X_3Ga7~~{7{kxgF%TLL?ZTwa8&Q*>2jUY^sZYke=bnwz#tg@( zkt6W(KW_k%XR&}cm=fhN(y?cb1*nFC^9e+#j|qjphm01`ZBimKCr`lz!$)G&*oio2 z*ibCKk$ELq6P~t z4{m$;W}HB3Rg4Z7&FYWR$}iyd=EC8~Vglfr0B0I}J`3EdKZYZod;lS-z%Bg2hYu10 z(kzT(V*=(~dOB=WL1s!uEyv@@(MD=G9pA!k+RhLj zdFkBah=3kQOu@Q)CgJoU{W0Y9379x+I9A+#HBJGpbW9fm#IBV9)?6Bs#l^1S?WzSyZ6q662?F68aKu0?UI~f7g zhYZX7>ZhrW6p#+&|v?PZ_B?aSlPA7ud&0Z?@o)dF}nE{1cuEa8-Y zX0t!|H^C&!LzCc5nS=QkpO0~;_Qt3&!!cyoC|opVEG$#+K?4bZDIS3Y5q6_vg(lLN zQrdB#>lp+|X*uoYIJiwIc>K!Caq+03xcjV$*hzMJA}wli+|hff^34qB6VNbY4&J%p zpZMy=Yk(Q#&?8@hX4cSmAO|C90(u}}Cf>gFR{VY3FwD5*BJ3t#0%=CgK35lmbe=YG zBI}K?&oIHe<#Tp6QyMxEfZZvoQa!$0Y~TAKMwTF@ z_a`5KAON=~3xTa)!!?&O+=OGRy@XDL%Mws;NNB|T8SZftcLVl?M5<>OnuuVv+aC|I~n25ELrs1a3$KlSiCt}mIX}}Dj`Zmxtsf?7~ zAFq{`hUVsX97vFBXa^1r1@aCqvjYkYu2CA^=va$w_830?9K`Vv557610R_sAXX%VQC})%En`~aK4TJ7x`$- zPvmvUh8NeTtUqDzH^fuo4v~TsYBE)lld=`Y4Fvq#NxFN zVL+^oHGp1Ib(sC-Ggwn*M^2G7xa;Q>9XtYXD0GA-(8&luKqG+l)&^{Tj4@J!D8 z=o51c6F}c-J@8s~5(0JkTnX5yilxIws+c1iju#(9y(EBk6Fy$}Bn+Bo+yumvsx7kZ z>gYM{Ktw_!cKyaQ+;;pNhlI@Y=Uo3r#~wNE>f+_+p^U#clkg9apM+F=djHH>c>VgT zF=B8(j2Jr#m!CcoJ7efOCb8f%`LQ-bngwYybPXybl9h#=c1&7V_aXo$J3>q>+%G*0 zw>t-RSE%Mm1ds}UeLnu17SEw-TBie|kHzE5-@@wsjz$6~vLPoJ25c_mhl1u4Xivvr zCn5l?Z#p#s)k1=#0R*ts4ZN{o83q{ti2f!WPP$)*C%&G9TC8e90OSnhP@p>pfw%tC zgaFz__-H}M+;^_Hlc}@>kwGVRLJ}-$BNeQa3+^Nx9}W~deq-*x`0irI=Jsg3W7Fpm zm3#H(L?$lI14vB9=KH2%^2D=n<(Lte^|uRQH<1s32v3?2COU@91|mJFaY%w;f|S}p z_~+`1;Eq5V0Z>j!Oe|{Nej0YKXa}YdKo#c!q@o6^@#yFG;l#;;00tZWgcozaz^c7= zj{W~$0?^Q&nm{Ka07By+2KEQAGq46fIe{;CY{U@L@%+P*GjPTSHzH71z@1q5&v2!& z4?xQWsQuz4?%yW7)$&i3wz&|~<;eysMH;-88}wj2Cn0 zk%TK`F$LAgBV!*2o1X+9Yp;d&)Dz#GggNK{q|cc+9p&-K*d7xNM^X|R;$+f~mII)j zD@bZh{Tl5cl?Spz3v~~@lrO9w5HG^gIOlWZA$gQK-*fC3e4fI?f zrR7hA_EHFbACs9&%&V9DH<$)2TG7cm6jvsE4f#lT=X#uYpANl^It+a4O9mzl zpu&{~7i9xxz8lO12nrzGXFHYbWRWP-z$&bPbK7Ciqk+`pAoI(`ElER*m3ME@Zxkn- zg&Hw5drt>L^Gg_$n4oIo4YI;fi)_g9KL-bgS(0x+21jMu}P`yRaBY_2;Y0&qH4NZ(Tqx1_uGrHjyOvJSnBI*d*pfF;|1 zMnS0)Ih^{d2{8EqC>qdQun!0Ap$T+)0-&5|ny!*pegGFgdKTwbpB$sZKOY;3eYRy> zfI;j_S^z}^I3dHA1JAn8U{9i+0F4CD5N|+zf?hbXNR3TM29lD1loZZtLjq;6C8M6m zAzl-Hxfq!gI^K(~AiyRIX&)x?KhSeRC`FBfNX803rU|r^W*L2H;QvvYF-ge)MK_X@ zm?*SepNO9-ChHSoxl5AvIhX+SW;l|JaBo=*mp5BF|7hnI8UQ5$?W$UWD;^%fJ^)Gu zzWCuY@qN*+$SZa>?)K~p5cL4RmjK!$PoUEh00}8e!{2mhMv;+_e8m915pdORn)#Jqbb?8s_mFW-2|MeAkt8-cS zoz5&c9psQnhn+%!73pw2GY9ptg2{s_JqcN2GFJTSHvD7wAe=dT3{D?80b@@cjA;`_ zVB56&fp~I!k<(06(+SlvzJ1zVH4*kBkr~+aAa;MLebMfpOrc2OR}p;-DUYEQGN#=; zUUOKI_9x5$PvShx`^V)NGk72_7&#WVO_+%JmtBCZv6GnuL;_V9A=7oEW8qmaAAzb2 zRC$E(ljE5PfbP>k8q1ECFvxHW`k8b%`Fi5XqsO0#k;6y9aMAfFA+$*q=B9lg>Q@!$yt5 zi1FuO;AsQ!>a|w@DKkTeIfR@|X4J&S!u9sk@VG7PXcl=VA_I`tUZ5r)@8-@xpBNqb znRV<3nDND9k3N<| zfs|$S*pmC;M**C+G*~$dDC{z;BMm-}1^#beh07e%SkS?3tH|M(jGy#++&XbAE*drz zPhW8b?8)&$4dk|1GPW-lK+q0s2LcGHuG~+r6*V?eK|)TGoo3F$7dPF3%T7BL5B&W~ z{6t~hlw|1xXyvz);aTzD$;2hfexwAblz9APn2MVwj>W(cqj1Kkkx0MqUe4~+LbaN< zC`iN%uSpNz_iw=Kv9Q019DtkvL=Hel25K5s;h`_?#z|DKV$z|1%>Ur2^mnj&uLn6r z4rG+j4qXOIVav@x+7pMP33L$xsKt*i;EP|@;xzN|oDNL60Q$GDhO0IQ9u5U&irt@6 zgVW(B0?7XuwggIf)l@oJBtvRm@@#C6j>hWy??GM443W@73x-vHvD;H(i|DB*ca-*s z0+8S*Q_XxF&!3~`lvWT15A9=`N4e0tN(@J^>%2l6#YZ9zr?_`flj;fOcDyXI5)ykv5c zvzcm7GC2(Ga%90%V?p%mSKy>cI`lE=a9aF{cyCJ<3U+yrQ|Le%34F-`NdOcPAd{fE z@U{mI#~kP~1i-xk%lBa7>>=nIqvJV&S3EufyX{}|j6nJ`$ONcxi%5Xim5u5h-@rL9 zQPc#m5Q{}4D)XpAP9l&<;jTs@6s@x~J5=`6Ycm0ee))W#T4u97NykveoEext6Z@wp z;j7zk$5&TejcT$vxmZUdsPOYeDo6A=_VUG(-R% zPY!n4mf(sk4}dUE~=LG4%#psgg(We9*8gRHl^aNWYo z(R+#x{q#LBGVwTU+5avA-dtAyU5<2=dDBtuq4;bDs;koBeRduiqD6C%jt6Kep^>hh!?bJfpN8{B z48f_xPsLT^hvC;KDnq6aUifjp0SD_|6!aOz0a3AVz3>2hwhUfZ+CpFZ=hFH4|cSiJXwdwzq$`~^{Yi|CPx~R07nB{>2Uc)doWvq z%m@tsliM5GPR_dr_1`ClQxXd9`Zp#H9e@$z&%(%Ir{jfdu0h=_3OP}85s{I|#WvyR zi%{@%BQ7_lEIlGr^4ZK3EzC%5=QQDt{y*jJZ!&WRV0G>Xyq-wAq?B{9#xme=rK7ek zACD})8z+%w8m~jIsGfNAyH~Kf%+p~W|HHwdNT3T5fN%tU{_|>_7WWr+v{2E-q*pG3 zBakCiGeH1cA3!Ya&4Qe-X^L6I0f&l%f`yxvJq$tLU|-{s4BuPLjar-YzJ1B zRq(vEQ}jCYj@IGAxr6a*<(KeqdS;_zhR24xGT?J$!ME^1w&Pm!9RFUB1F8ko(9xhAdePW z(geB;0T4D>8}Ngx7I(b-FYdQSet77vQP32)j%&G!X!q1TKFr{9nF=Y8n;R z!8-@-=3>e&Em%=cATru$_lg%W>?AFfmBNd%s*s$8h6L18tpaDPH9vR6fy<4xgdFiZ ziinITVeW8$i5#uwHtXU2<`uYoxp2tMMVOnS5s0tWf)zhKi-FVx#H2&-$uzg(ZdmqK zBB$i&+5ZPaI0N&!a9Ire4Fz!6WofW<(r}W7&jRnJ z#jq#KI{5HEy1fxBLJ{r2IER3H6N)Z9oZ}u$G$I?9c7F<0HmbeN&KYSS?TK^$rFq1? z3y550SgwPKoIwO_Fr#XQ8UD@Gf56t@f zQ4~~A8}OsA{KUIKef7Cc-OBttap z4@l#}4R3iG9RHcaet=+dhx7+Db-c+|TEW?NnmV6=dM+Sp%kC^nj_H1rnFO*)D0k$c zx^{kD5I_Sr+@wVv;t1s9u!=^WS|R|CZ3QYlD_{>~!~e-bR7cC3OO< zC&yEYEc~HzVgpx{@nYnJo{rspg42AtrMYbF&AXrWd5JXWR;I0TO}j8FsN|J8{DzLEfUU~GZxE>i(xs6@&C4hNFaZo7i-G{U4Z}yHP})K z{N$;_U2opYet>}{9r{eu;j=Xla#Kbk0BX@h4DGa2t#&G${wxGGdzB-ucOi!1N@Mxfj0B+8fg56$@6DA~PX0PW!2onT#Y*96sdNgg-%a z_i5q0M-u#blTa&r2{Ms#8a914?R&LwT)TrbD!OjtoCGllh0?=i(~pq6{nw9vlU>15$)nR~1n4@>Ub`LE3%ef11xWIKrSASD zZpa_r_*f%|IMk&iqu69Z)}%@J;I`ZF;@|#`jd$KHI^{H5Xzd90c899`p#&xJ(zUEX zA~3Rx>sVbDi@d|V<81+dHflmjCDbHzdg0LHNQc~!!;fiiu&G478|ilw(FCDI5fOwq zTSI@_U{aQu)qmIX^Wm{(z~Rp1b)`iBsOH4!$bz>vAI7(@!U;qGW*z!W>4`_ae;N4| z)B?=bZuR~h2_k^3;;Jq}06a06A_2g6B|l+wVn1#UauSUTo6{eK)-T|v4na%+WV2D~ zCbbPugPWoOOJ0M=Afo{+L_!;AVqcpRU^SYs@bZgs-RWa6e#|(WI&?Uq&pZoNw1CN* zAHLYy6EZR&`q#4cT<4HAI1AsEf@>0{vi{#7`tb3|y_PJ=*2`Y)LSR1{QL@fFUQ z)dwf*#VGJ`3H|Ze&+B2?Lk&M{-2Eq9;5r+a0E#NR3ISAO9SsYy)nk*xhijktCr*yi zp--F+1E%S)bnPRksm?={iz>CUCzlL@7hC&QaelY*wpoCm4d$ozx(3hf7 z({#AuKmSDbo(g1@Rw1Viv;R5+x)1@>U?V#M>3%h*1wZ)xLYz!>0r4UlF#W9?;H|gt z)K+a^DE9~8&`k!{26z`ef%>Ru7I?Cyg{N7wyQxAf0YzpLZkaF+rwtv3tHzAMYRW%P zNs_}f!WUaRgH_O`1i)#rDRWR58wb<*XX1%V&PFZe6VUsKwAf27%ASvU6o8VUl9TcH zr57Rbf{XFPR3k8pv=-Uci)$b{UQ=oQ=xi=*h=m-j`2fRk;@#)ck{Y ze?V=?&DzF2hgP29*d*pojl7WMH&0Ii4EjtFMXzz28SfiVM4!)0T};)-i> zpU{N}fKY=Cl?|)`Y_v9DqoW$vEVzQrfC1*7IBBX5ALY+RZEY^wY}B?%`{O8c%kIiT zpgaR^>J=EHmF;T7ET~7mOg$x1NJvHlHSVB=tJ#ljbg%_eSbxiI%)&3@+^mtLAoISv zap~A07(RLo&L2G zpN`>UCSv%=VVHHvg>b|rOW!~TYXAv}*l94~*3m;Sa_C^3J!%}To;VS&{qtJ*$cI9a zfH>JGh7b}3sHf83=N>?yGE?mKGNY8j%Cw&7vA=F5KFFKN-v2?So;(8V>Sr!T_TDmN zmR2IOw4zJc|Aa0^03v^R6V(NjgWH77PkolV0}Yndfs>2pK)5l|1BE2_-omnE?xUv4Att1s1NvKKCMwrE+K4m(-xc++V znly<8sa_6D(TGuK9W9(M6yyh(F%$DHy96VL569rK<8bY$5ja350M8lhU`>jChvZ~r zPq`l#jT(XB<4(uWF{5zZsNvX6eHy3?TMI>M=ARq}Vvmb~H|uS9{FY$4ug$drcGq%# z?6q59t)Dx9;BDJxc!%y{?qx<1qq-Un<^Txse%d_8u0Dz zA22p)0JmbM{s3c5f5F<4Mck#8{=6zTIdF)*g$LK|&VqmMa#)|3g@$OYc3%jBNcQ<1 z;Lwwtx=VJsLA0OJ1T^T`KLp6TFXGTL7a$=SIrrR)8z+p!=|lSCKi6E2+8HxAn~;Q) z+gOK?S8L}NpR>{DLEUm`=qC$!@*I5i@BhR3Bl@A>-g|=Gk=gXo>~~JAX@dYKq+rvu zDBLi9IQ~9v7(Tn>4pGuezr~TtRhT{(AK!8Z&Kf?1=N-@jfxt?vE_xLsjDO@c*ITc{gv7!4V(S{@?sg)hnEL)# zi6}q|0@vBlr3gSc04W+kL=fPyVL$k4anI{_;dqM0#_8B8_{5j@pt@nD{PTp`#?xD* zIp7K8!n6Dhc&R{04h~@w_`L)m!%s9^LNp*1f<+oZTXuhjwu{StcA}%u4D8gKk#*Ny zC}V-t<`%@EmWz+HvE6O^4#M1_5nJ?_J|~SkPsWciX8dEqNQ@mh6xrN%i-bkS?PYF$ zFgdA(^9CUsauNyboEn4TXgWXnQ78#VIs?OxX>ZW)7MFn9$iu^n@8Z&5az0b{pSxbU3wb;M zl(PTI<-mSfh#pO}J#OE1OfVPkOCsMGQF?RNsxXEbUw z9K)yY)dm7EP)f4YeBvnrKz6rimnL!Rh=cEZhvqj7Age0Nq$=@sZetIrO z59?F3`=`R;p$4;J)+F2g_DnV(J-%!dT0h76a|Yn#SRHr&85ngO7Uq16)%yal{7NIh zhyZ8^i1GonI|KpH?q9?AH&@i*$4a2UYQsejpO52Xbr?)@*(dApx+Na|hCJAvln;=_ ze`X~O5aof6)HOI4?({`)o1+`GfL4U3b;z<+ZA7??Flck?+S18J*xPbZhStMt&cR^* zg63P${0kw+9s=S$T8_R*A*R6_TP97yd83A4*tj!s=7_QQ=JwkK0hnoUraqXPASU)4 z`a95P5?zkyvAhnyCK{`@di?K(iO0azX^mF+Hy2~U8vQ+?eiIWsrWklL-sDMBP11f@ z1E5@hOw`n`#D6nQoOwFXEFw>TfACzSm+nDssSTDw8v02QfJzw&=(xt8?S(Ey0HPu= zfDKX$*i=EFUV*@zrBC66Nj)%#Dg>wLaP^a;u-E@B93IN#%97>5f&jRqiz^e=>gZ%`r-->4nl;7^=|4U?xL{g#{HlTiRxeS>!N!J+lCryWaVtBKK8!uck(f?(!i(*zc= z!?vN&CN%UX&qMZlWqdLU?z#_~Crw2lK^uxnm5|b==kFQ1qaOrXB!h)t*yAj8WFGN3 zJDvb|A`sP0Xx9oxWaQ`4oS-Hn9Eq{;ulWeB05z4N&@rk1!gQnv62<-l_4)XC^*o+U zNu$50HT{I7q4;XskI3Iit-s4y=tT_xIRZPs@DsWa0g(D%&BZ|CK0Q~1HFiJNTI;aG z2gH38kH1o87IkKwro*jIp9!lk18!F~ta4reF}ur7wp}X6dHs%TI2S#ET526G9ce)v zQdq-w!lWeF%?bG8_Pa6Zk_&O+kb!vNZv7WCoGxLdi1ZZ-|c-*D)lbCV{RY%nhE+*QyOK z$b!uVPWqv?>{P`_--TZPy#zqx%Vn;Dmb(z9y$RDvc=$!OCw))K0F-$WQfsS?j)CXR z|DeVu)(H2Kpv+Ms0vCmjQ{i@JqRf|y8=joV+9jbiW6 z2voS~Pfg`UA$Il&q{8XTM9sFZ;h2-aSzy84krzzpg3<_q7Qb+4iRY<6C;_F>21K2I z2__61gJHvm;)>HpqF7HO$7z5CE%*?-yV-&chum$0gdm;^eugk&`YoDElVgJCB<(?S z@-@cEo0=AF`TUL72a8Q--%2n_D6)o``MH-8sM0ph?-xQ!r{x}wZU~GY%Zp94hd&_LW!Q|PMa|B5Xa5`4N?a$$`pr68mA^igCLw9-Ul%vi-qP9F%>~N$`PEW?t zTW`e3p@T4N;zUdsek$I&i3Wen<6~+QLUvn)AlR3|f~i4^oE(B=;^N>>h{ya3&c(dT zF5-+sGP^i5O1=ha8y1%!N~9%;hc^4rGy@`7w=oL7)HmR&77ahhH$+h)x+N{q>dt`M zW5GW6x48DPQ5^CcC_~NnFPese{iN>Gn6C^Z2ZTZ7lKcMpam&_fELi^|NdMO-8PM&Ny>~udJWgf~D zOy$=CJHCN!egf)Q6KGT+HSmJcy6>=4j~1(J=bf00l9)Jrc-@WoF*a5Vu%HrR_Udcl zyf)b95dzU4Ksx5Qno)G*pYLEuOu~*RJ^ns^9EOZN9pi=$!HkP9fGvT{F?qgF zGa}TiBd4js0NX=}s3}|mCzboku%9+niZcGl0T^)SqpW=%F^!>DN#t5jMfzW8 z(vN%PJMek4;j)vuFQP)C+Ap2Ue(kPw1nLX$$l`wo9m~D+I*d&?4U2!;fV{m9QSj4+ zg`d!^2wn=0`pmWEW;ERod z=aYp9ICD@btNq+ISu)P;zD2%>zt)1Bl9w>b_-FJsh}LtxCh1`QY%cQ4yvU(2UpN7@ zAYh#x-G%@t5S#ZZ$N$WY>}$WJ{uz6u(n?mJ(Pzpr=xfxWuek?KnXJR2%p}wxAC>OLG0!ybO)D6pia{DP zR#UVT_Qz7t5KU#na^cD35m6jNkBz8LAazrFjR=5z1cDf}HrNfc$l06_1krp!w*;|E zHh;bwKy#+XB_Z|J>#*#e+klk$T;j|`*-A1Kkt%ulIkAZP3nWVu%s{`jmKY^Nesqk< zjM|tO*q)ey+M@5^@=~#nC=xs1AZ)@mA1Ry#Fi(;Vw*4rDf!RyCu3vcCiVbnM9B)tx2CY^$Hd)|e&hH?W| zaFrLq#b$v>1EmyD8Uds zbGW$2KXr^IEpRz1Jj+q#$`Yf#U8y{3e%;PDaYo|F=oLf#{(5j&`R3$H z;m#JepDY&Q;iArTc)b?*R(%9#k^v3m*{4NE3o1CGDn!uQByi+BltU^wi%7;Fp^Gl-E8{kSd!oT`sc>TGsa)VDf+S{4R@qQcm z16-N#cqk;8j(jk?tlH~@;w!MF&yR#&qIJ` zFSMdXXymK;Uqb5}__;XL>kY7_7~!?N2fv3J#AL9hCwly4!D-Ki$4Z@}vS4#%BhauC zkA3}buJ!L@?!jw?9DgZaK7^Ij>%Y*7oI(e(yNU1HRQ~!j!{7%wKHbo;eT9$VZ(;tv|%F=bJPRJ+g#be!$f}c0!vHRV{FnuZsb6JE|qkiKcgSE?0Xm0)j64~C!medZIJPxPnCqaHD%}>!*qB--nEnP{P!< zX;CnqaXKEj@KXG$r=p}bd`J0vQ!242R%*O1HF>F;Zh$-YUDn`i?o3n=naeOAC;YJR z(;UfV2-N0a^X_+X&Wye|DO!iqOvj*qln&z)hT@YS*P>u|0684ys|Z&7<#dsbKdqsg z5dc^NppYPC25zhhU^AHnm9^Mf4J^og3w`b~Ek%qO_LUkT;~5u{mlgZWuiVV@8g} zs1f7w?3LG{etI&;t_5Mp9k&(XY6kh4s7R(sq0H|9J1glnJ`OeJc+@4(^U<;kkF2$C z;kZT!-v`&|6QLy}!=IFl>gkj|O`ow1ZNs$?A3J2__c9te)MuL)4`2TK@Ovz9xk%O5 zgkDVe(K7z1*54DbV3+ed-2B9u`13TOAr8{(FnH1*@mSjH$S?E2Qb=Jx68HmL@h1qN zdzb;;hybWpU;~E&g&9zXja5Ew8nU5+rU?N*`RbtmARZ@Bny|441{#my`^GmfhpjeE zRD{XA03re+1oj0;GN47{4&=eN=`%PUPDX7^RHHf^>XbSZC{2}o047rDr8$rskJLMF z$C(2MV%WIxIDf<_6x=@rNFv9YAPV8(ZsB}wKs}e{n$eIz<+;sYKU|1(T>}g5=QrGl z)&IUvv=Zmgr&$auZQ-~e$ZRl)^5%MO(@nk)%A_=~HbL#gnjI4kt#C;G_LS>i6B7md zBgyb?UJQ>vmo+z<4@qskwLA~6O*8FM;qqm|T9XaKD_3*2Xz%zQ=quBGW8XF+x4ax# zr8MZ1JpZKbQ|#ZyPQV;l1JKpctq6eHg;T@O^;S_POd0_94X6OtSJvVuJFv=LhO3^w zf+xDrEP>uM)pe>44}Ebr9Ca3WT*3?xIpK}@0otxX#6Tg!+U-kVTR0CMeUzxB=bf>I zg&MYKS?#B=6@~tc@jRu5^4A^lW;}KI6}W7~aLl{tJa}m)fmRx8pu!fd|6eP@6~4>! zAPiEq<56wW%W1vMk2xIh`AvER5)v`x-1881{snMR&pjT;B^;&U$20|z8innsjF)^G zF`{n(I~9eQ)clw*uofZ-&1iy89}U~W`KbBzTX?*AT**g7t|j_t$v&h3urGjSNY-00 z@AKQy=YA?2qX|DfapF`Ru6punr0p(79@+gRWyoOFzXF*w@t5oTtJn_^E=*k!-HHIH zOR%`FX#qq6qzQ-?VjHS_*y;wp-}MvDo;3z1Md{E#UWZ;r9r{ex;lC^N2s9MHcT7C)L)QDAq{R{7X15H!14Z5@K7i$CaeG@#90+|60#aE8hmKoacM*q z-;a-jFJ%UH7)+=n0pZY}ruv8G0JJ7t+ODX@#VKPh4&IbRym8s3*gkoRXjc|uvNZ7x zv_@k59`SjLOfk56;&_Z2H4H26B`tswdGxI}C6Q^lY$~uLQD**8aTO0cZ5-PrRQnJI zpXT_JHh-PL07rZ*oF6`en*C{T`f_ABuSoRa&@PRN_$Z~9UvGZQL2C*pYbETrvL#99$TrLZ|Sc#W1OgL@IFPhTuvCNIqz=8Y zyG~HxG$D}Atb+l&22cInNVQnhbfa;hSxSxLzfo)&2 zy>F);50u_Z<=*ZL_WDz-pGceR{R(#~oStm>8uIaKx&ecy^gzF89Zr>1e*I&P$1^z} zU`-i${H@61uD;C$rn@2H2wVJ5^;~?rEK)2I$crDWi zZ^KHsJQm(jXu=TX3UI!F^b2tMuwCppPJa$+$}-?t@-iyskxfdz0Xd$U7cw;j(Ny{n zp%2~Rv}i)OKpYn2HQ1AG0utl#LrgR-8aDwWM~%lFXP*UYBK6dhT1Kz~l58m21_vDX z)lW*o=1EgU!)BYGGX)=9u>gH0AB%ph3H0Rj&%RT2c>JrW zaMcuWaS%0!B$I%;#cCd9sw3ybA@(Y#!{yFEwa0?M)-PdS^cWlov8biyA}r|Alh6Ku z#?e%v0uW4*{T&cwLm-d3k(+$3xZ`$=7%>V%PoIFxM~^_^gRoTBf60p>q&IJnoeV=!mw zGgw{jLw2zP8ImLjeMF4BrF<6T&74_IuMF~TGY_S1be07-q)uZS=`48wr zGX~>yyfgHktV8nqH=@k93<3X2IPK&d6vac@uA_At=0>BkmcZ@JMzt*q-hvNcdu}eA zaWSYh#G*lt%+gfZCJ990w)k&^IJZ>gi3AeO_-V>i+%j$=&L1`e`m-kDKnzV6)>4eM z`U0T=w2c7d`RO`|G(mX=kKqz2zK5^Hgp&Y&2f9zcSF{0cQ!FZXQna`#ZkxtzL!wo@GdS=W6~x`{|Ftsir@mKedmad@K?_n~U57HslnG z=AN3fud7V<`MroJ0-(si#>#rm66CBwsvazFz!od8IZ%W7Y0sj6^q+aQ0M&r^Gl~-8 ze?NZ~3ah?AZD6(N7E6N8{o^uFP76_L1oWJkKR{uoOnALHs435YH}fqxpPCIX#r~(o z0(y#r%S9fl;vS~p91KALB5!@10kw(K@vE7fb}`r+pN#q>3cX1+J#;5#&wT6ZJBROr zZO(`^gt;MvSbFwbIPRd3pMgGeT|yjc<4ma0>)|rT!uix(_|xBJ^U5b<`xPECyBhN| zwB4T@dN2`Wz-`M#b?pZHU|Wn^7oNq9JO>(%!9dfo=tC2vqW**jzIhr2`)sh3xU@LaD)PtAKS?lqO7*g~YJYO}2V$3)JI38Lwbq^a(g6 zT8BaAKX5?^B@12tz))oU@*)B#fRlH9vD=e_kjqP@D#|_Or=uKb!K)Z7-3&9AAz)Y;6IQKw?CJENBYevBh&7n zaqo>8nw+O01)oBF6yvXH*no^}3vt@F+FK*pVEm!jCSKL>&G zba)Fsg!8q>;GPkW>ev|3Zy?;^#e%2_Q`>}!A=PssoFS%wiJX!Kk%YwWlkW|;!CQFT z44SI1<@5)yOUKDVpM#wvXmUtUlqA}K0HyHGiHG~O$5Chb2sH;X;PvJ4AXm!8;K45J zj7=5e-epLZ>iRWrKRxzV=c1}M3$J9DFvf5k|8KPG_ctHIdHy5g`eH%$+bF1TBD;k8 z`c)yPm_~Y)^DvL}5^C?IrT#V-nC^y%B7oLF6@;5BfvvSoU1vNTUl&#uYxUJ4+UlL? z%POn4=z<{nKT(3!d$&=dCXs|Ft0W{uZ;8Id3WA6b-9{IkDAC^W?fo?KoAbUw*9wx70mxTTWEni(uP1xlk4Jz z2ir@`1?Nb{#5QK)Z|Lnu0+gSmRS8aH?@?9^m3DsAl>g(d|T#pYL1+-1_?Fg8fnd< z*{m6-^O3iAa1XJ$t_hR0@&9fqpHuXD8-SD1o^ebZ;y)4$JBD|uLZhFy`oXcoUvZ6^ zQeqgE`=esp2VwLcE`RrKyEqw0VfRn(o-W)ch`z#s*)R%eOusHIUR7uDo!RLAWd6jg zmR(pP;XIBwK><=N4$sMTBAMnLlsTEdu?gzF@l!0%ezkRtx-_5sUEwf+S&WgZ6Oqb` z_^1X5*G!nRNT#t16e3CBULk1}kUnGRXz=uP&82RuUS~@IJa#OpBn)}om;I}5m)MBy zAf)^tl~n&BY95(wOH1&+(kPtNPR=w(J6+^k=$8(n6w`AtRG9zbLOaoXV_|pmsa6Y< z62V#}DCo)0%n?}11!-N0NfMHbM9IQYgG4rga(c^iG-#{lb%bE73{UoAxS`vfbyH3r z75UJ8_WC6!hf@Nj!cNfcv@w3BHP2s^O(y-#c*Omh9+cVUUkH5rJ?eQ4LhDM4@!Te% z6K#JI_QTY+6UurVnWX1)_54z9yfd1%>^Oz6mbfHcW2jY6cP5fRqPn-lV*W8u4FwRt}8)o{a{?kRDc+ZHdzSCZv2HbuNlJ_ro_n?5e-?Jgr|qU&oUG z1Je{Zjjt&F+jyxj#oC;Mjk{EpLbQ_oM#?s%&L;j~IuEZ$)RAoe>{?KX&kWYXOEKp0 zFP_{YKAAgYO{g&+0SQD7udVhR?$__FwA1XS`^?ok`T(H^Od;~;h9w|YbKSFL|7Ipg)Ey5;3rI&f=Um%n+y z5e>AlD?SHn6bzA1Jv`-<=IvS{m;hN-lgvmCND9_05C3V#a*^e zQ1^5&Q!hJ`;lM?PIY~F3JBA-dgkd8@^(}_IDf~t>!2Zi_zN?;W@>4{J>l6iC+566} zRQuQAQbj*0ihj-4?|SlqWek&VIO1h9(>KHP1>SKpPXDyrtJ0y!t0!BDWFiPcC2uqo z=NiT|JUPg`0&!T$H8%!@i=jUGF9xo{P%y?=EFa3tp3>@yY_HlHbZsTkecmmN>il=h z&Z4BoQKc{<(Tb_7yc(Wp(^RiyBgU_ zFcp5ng_3kUot2~ILlriuks%eEdyafTz-s9lwAVpebzq-M9Z$ zIUxrnT%&f21Y?7A40Rh`SPAR!kI1K0J3INAZz1Ttg@g82yVh!f@!?opK5?@ME5{@1 zwih`eFvckEr|pAyvHK7oJoNPGOF2UKTUU{TpT?(b3rI7>C^#O;%~r9sY(#KS6fWrS zYQAk&8S|Dh(aJ2;>0M@}FHuJ;3od(SwN`&~1s`ude@B0Iw|rL}!^L?%H&_$0O?=w< zXymu$0|CRIMTNBjul&pxqC9rxV8_NSYd`gX>0qSGRxM6KzOb^E*i)*OaUR`FcqeaAXl;`Ih@>Lkd$Yct+JccR#vVxHcnsOzW<8BN+pct zpV_ZursDgW6u^SaS-UuyxIdiiiCP( z6_!i_(_^k2x;*qc%rGvUj&>@M$c|cs{o!5Vs=GVhL&OquIgeH8+2%zj?t;C8ne1-& zbLku_v?r=Rj!4(WFF(lpy?@uvy?EZ<{!M;Nl+>te6kzBXs3Y z`6yUmOdnoHTRyy^kd6>m{UWll-?CX-3lGhuj!qjZ%Fu(K8NF4y5@a`y?zq^GUhE8Y zD01v%6gh!s-oI-e{&lz-Nlo#YYU7JhDYLnsF`p=Ud`v_N8fgPOi#>#%#2`*N+sj{e zgq1>`$jvHerIsz#7SwUbF^x$BhBJ#GhjiQ~?1LNJ@2c|Z^$A|AfA&i0_@5cx)0)@; zMkD?nYOC;G3)r~tn^%R_I1&sI=+%{Ifl*U2gwkTp#WV(u!RGC@1T+TOrQ>X3g^9W` z{(DpL61sOIXf~Tf$&pdiSJZdG3|xAQ=_9!jAvKWmhy^Z}8R^&iUjtiEVLnIsyg{e- zzT?%#L+d<<&PiAH;Wr36ny72hu^?urpDGffvsDHS+a4UNp4+~z>U3RB@eK*Ly;Yl% z6>0AMZ;?Lajt ziN2NMQN$D-`Ejg;Z1GODijv=w$7<7Xd+Dl4pXCgH6pio#LWJRp4<*iMUcU718SsiwzLP3y(cMir>^(kE@!bTf5*7UuK|`jqwH_m^BLnwN~;xC=hx zB_Eo?c$FVlU2-D#n2*cK38y}4v!7E5BD{bVgtN7d+yyp%5`Zd6lG%*>-ozSqp$1ab zr4Mgs`ZeXt4}B_Q&IO;y;|iOZ$sfd{s=(TeSTCP%lcN^lZq!_}xhz}-CHZapmf*UF zeo5=-{+>r}IpNO;)(;VYtfAGT9;-q!;~xDNzg}^Bo*$l$OkVeGQ}8>tY{2Dm)`K^E zHoFf`|KZr<=leJ=>^jR`_D{$pussF^xC89A^@CN-9V6yCNd9bz!_X#ulk&YIv_t^< zWf6F>205%&_*&WREf;Swq0k9;<&iJ_*C7Y5nymoGzl0KI20{3o@e|ZwoUP>e_$!y6 z?Jj{zl`pV~mqW_maj=IYSsSR_A_4K}MH$|9f3f*~{oW71vPC-d9TPamY`!*{rpGo|ZpcUHf4^bbw)^;Q{C*D$ zJbFj-DB~af^KPLq_OKjubxcrOl+2fqTxx+if!ohgWjLOSaIlc}*jg=pdMjz&w83)G za>BD!C!zILmDrx4=G|h&1CoqR0&gEeyB($Di`M-6kNrInb%r0-b5$1|X|qVD{_1)_ zmlH7{_GW2zj<^}33`B;@#XOSZLN*la$ai7=b!jqqrlZ?9T&1`9O+oLE1#PZ>A1n4U zbe_1h;4{_{S`n%P(>yvtIob@$EXW0A8(gQs_3I@RepcG?>JH1U&j50MUaE3_H{q>C z#bh(r8=FAp8HIPo8D9>aP3sj#vCdX*)IhC}NJeNblV@H^7!n+30G={eOvj5l;xEnHOTi9Xdd-lw~M+mabE}YjLPzYI0fQY1!{FmUtN7 zBl~FKs+4EyoZ+Iwxyqy`V!2P4MLD_J-N+``P~6eYG*em}uD(o#>mw5S!9wqGDXCC| zl)fu&o*QzhU@dK;64Rsxyewmbu8mkBsk7^lIpeIrL>`twpZDye5{;3 zV}==;NkJZW!#gVhs|D{UXN>op3fzdj(Nlq!g!;7}nmUyxn= zR7tZxS3P4=vj9vY6Q{#T6vyTPqyKXS(BOts%*yY?wB^gWGMTk}O&)?G#-0iZeM!G-GsMiKNX@)b##_Kdl-nUz6eQ+_`k0 zF=uMDia+1Gj{mxd4eWbc|48pYz)JuDblYD5(;uz%%b5#_r}9dFM8r0w_RV#ic|sD2 zwFuag(kjWvxr1NDM-t&xqx?s)@A8M9w3*3U?i@J&jpBq1u<6d-PpJ-i+>KzhT>QT4 zppMn?_uZ{{?z?Sng^vqex}#VmZzO%CN;%G%7$q6)C>HY>Ke}U}LaP5x!6~;zSz&b% zb{H2FlA9ZVwfGco424xkRJ$vGm*0d_rd~$F?_CR|8f~Eu*V6h_3pm(y2KGx&~VRx8F`6+nC#Nu6m(L{D+fzoT~71fwQ-Hv zUsBM$`YoyS`9ikM?S5*oysPv6M4I)#KN7k#jm#p0kFD?FyFL(eEbkt`nqLHkjG-DD zX7!gxgIxkuF2Y6Fs04}NL~u4Jj8iSFmYs!Rddy=&D7TAPNKXUC87KJYn^03dB;R%u zJ77j*xqaaDmqXriyV?0KA?w78nVKqW?9c!1_0QE8X0?0Lykur_4#ig=G$~jzF!6++ za9AI2X$|j0THmuwZp@aetg3Okm&15=&fdSU);G}Zy}aWKwDwcOTaZEWF>W0;@`$Aq zZhwK2gtp_XAAYSONV+aM(kx^j*AU?Wc@1%8DRhHxrC(4?BTT6Ay|@PH$h+o)wlLFC zZgppFesOe)@?tR_9mA+#L9S!}7fKdBsb@{|#E#s6oQ{%Rph|;Ujv_|@{lZg1RU~ve zC+{}9kDZs!aO)!STc66gMfNLkJ=jf+umd#`2dEPu`6`&q&f|+Dykr8G&SmjT85^Ng zNA}b1LY(#5w~sFCM%al;9ZrXW$lsVNl6Fryw|^^KQ9@rVj>DDAU-Pi=$(0&>L{|ySi^z~kkqIi`%rwtvi%Eh{Pb}ez zCLk08$_+~EyF1Em9d5W);~d29NnnQjVkDBz_@M<}w#Q0)n_SC{si;~O9VF(pHYr2I zPe;WC3an|Ec{sAa<~gp7U8qI{V8DxlTzMct$)qbgtTF~AiSMurf*0MR0tyNNf70hFrBA;w&J$AYyU4F&JG;xCUK}dWFf` z-Z^H#N=^PBb%2i`k%^l$19^bz%?bSr{!!&3FtX?ySbz{v4fzMSMOlLdV)sbijy3`z z;N^SjepKmVo&uh*K5+x|>l^@N?@iIzlU5Z5Lct4h!9wKZ6K(=@P&{{R6&b+2O$5*w z-guW6^vC>B143w3&q7Yd-GCAS-Ck|ZK9fquqF}$ZbB0Da2)5#_9k%@u;A?=?%T{Zv{{Fu^?~&#T-rq>2gBBh zRKs~h>&Ohq(Ej-@^GM>t$$ogln$>pMxp<}GqS`#iqr`~apDTo}O!qzovVIKYv$dm= z1M-tWkrkNs@=Fx4-859JZ)@rM(^M-c`DfGCnsO7(f!vIW@M-t)#%AH!@$-AC=-KO> zqIRQd+2~!l8G(wXX}~&lJe9lMlyMOK8&_C0KRgo2y>K11!*8qY^hvCOxFdXrgE_x> T`I;EBUV`CjvLyQdT}b{1arL%m literal 0 HcmV?d00001 diff --git a/index.html b/index.html new file mode 100644 index 0000000..f56734b --- /dev/null +++ b/index.html @@ -0,0 +1,193 @@ + + + + + + + Todo List + + + +
+ +
+
+

+ + Todo List +

+
+ +
+
+ + + + +
+
+
+ + + + + + +
+ + + +
+
+
+ + +
+ + + + +
+
+
+

当前任务

+ 0 项 +
+
+ + + + +
+
+ +
    + +
+ + + +
+ + +
+
+
+ + +
+ + +
+ + + +
+
+
+ + + + + + + + +
+ + + + diff --git a/main.js b/main.js new file mode 100644 index 0000000..6cddf44 --- /dev/null +++ b/main.js @@ -0,0 +1,1779 @@ +// Electron 主进程 +// 负责窗口管理、文件 IO、IPC 桥接 + +// 关键:如果系统环境变量 ELECTRON_RUN_AS_NODE=1 被设置, +// electron.exe 会作为普通 Node 运行而非 Electron。 +// 这会让 `require('electron')` 返回路径字符串而非 API。 +// 这里主动清除它,确保应用始终作为 Electron 运行。 +if (process.env.ELECTRON_RUN_AS_NODE) { + console.log('[main] 检测到 ELECTRON_RUN_AS_NODE=1,已清除(避免被当作 Node)'); + delete process.env.ELECTRON_RUN_AS_NODE; +} + +// Windows 控制台编码修复:默认是 GBK(cp936),导致中文日志乱码。 +// 强制切换到 UTF-8,使 console.log 的中文能正确显示。 +if (process.platform === 'win32') { + try { + process.stdout.setDefaultEncoding('utf8'); + process.stderr.setDefaultEncoding('utf8'); + } catch (e) { + // 某些环境下 setDefaultEncoding 不可用,忽略 + } +} + +const electronModule = require('electron'); + +// 检查 require('electron') 是否返回正确的 API(而非路径字符串) +// 在 Windows 上,如果项目路径包含非 ASCII 字符(如中文),可能会返回路径字符串 +if (typeof electronModule === 'string' || !electronModule.app) { + const errMsg = ` +================================================================================ +[FATAL] require('electron') 返回了无效值! + +问题原因: 项目路径包含非 ASCII 字符(如中文) + 当前路径: ${__dirname} + 返回值: ${typeof electronModule === 'string' ? '字符串 (路径)' : '无效对象'} + +解决方法: 将项目移动到 ASCII 路径,例如: + D:\\Projects\\todo-list + C:\\dev\\todo-list + +或者创建一个符号链接(mklink /D): + mklink /D C:\\dev\\todo-list "D:\\data\\正在工作\\智能体管理的项目\\Todo List" + +然后在符号链接路径下运行 npm start +================================================================================ +`; + console.error(errMsg); + + // 显示一个图形化错误窗口 + // v4+ 修复:Node require 缓存命中后第二次 require('electron') 仍返回同一个字符串; + // 旧实现 `const { app: appApi } = require('electron')` 解构得到 undefined, + // 错误窗口分支被跳过、直接 process.exit(1),用户看到"启动后什么都没发生"。 + // 必须先 delete require.cache 让第二次 require 真的拿到 API 对象。 + // 进一步兜底:先写 stderr 让用户从启动器日志里至少能看到原因(即使 GUI 窗口也起不来)。 + process.stderr.write(errMsg); + + let appApi = null; + let BrowserWindowApi = null; + try { + delete require.cache[require.resolve('electron')]; + const freshElectron = require('electron'); + if (typeof freshElectron === 'object' && freshElectron.app) { + appApi = freshElectron.app; + BrowserWindowApi = freshElectron.BrowserWindow; + } + } catch (e) { + console.error('[main] 第二次 require electron 仍失败:', e.message); + } + + if (appApi && typeof appApi.whenReady === 'function' && BrowserWindowApi) { + appApi.whenReady().then(() => { + const win = new BrowserWindowApi({ + width: 600, + height: 380, + resizable: false, + title: 'Todo List - 启动错误' + }); + win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(` + +

⚠ 启动失败

+

问题:项目路径包含非 ASCII 字符(如中文),导致 Electron 无法正确加载 API。

+

当前路径:
${__dirname}

+

解决方法:将项目移动到 ASCII 路径(如 D:\\Projects\\todo-list)。

+

详细说明请查看 README。

+ + `)); + }); + // 不退出,让用户看到错误窗口 + return; + } + // 拿不到真正的 electron API —— 兜底退出,stderr 已经写过原因 + process.exit(1); +} + +const { app, BrowserWindow, ipcMain, dialog, Menu, shell, Tray } = electronModule; +const path = require('path'); +const fs = require('fs').promises; +const fsSync = require('fs'); + +// 窗口图标和系统托盘共用同一图标 +const APP_ICON_PATH = path.join(__dirname, 'icon.ico'); + +// 抑制 Windows 上常见的 "Unable to move the cache" / "Gpu Cache Creation failed" 警告 +// 这些是 Electron 在受限目录下的良性警告,不影响功能。 +app.commandLine.appendSwitch('disable-gpu-cache'); +app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion'); + +let mainWindow = null; +let tray = null; +// 区分"用户主动退出"与"关闭按钮隐藏到托盘"。 +// 仅在主动退出时,close 事件才不会被拦截隐藏。 +let isQuitting = false; +// 退出时是否已经尝试刷写未保存数据。 +// 第一次 before-quit 刷写后设为 true,第二次 before-quit(刷写完成后重试退出)直接放行, +// 避免写入失败时无限重试。用户再次修改后 renderer 会通知 dirty 变化,重置此标志。 +let saveFlushed = false; +// 渲染进程报告的 dirty 状态(由 renderer 通过 app:dirty-changed 主动同步) +// 主进程据此判断退出前是否需要请求渲染进程刷写保存 +let rendererDirty = false; +// 当前是否在等待渲染进程的刷写结果。flush-done / 兜底定时器共用同一标志, +// 防止「before-quit 被多次触发」或「渲染进程 flush-done 乱入未发起刷写」时 +// 直接 app.quit(),绕过保存或留一个永远不释放的定时器。 +let flushInProgress = false; +let flushTimeoutHandle = null; +// ready-to-show 兜底定时器的句柄。 +// 当用户主动隐藏窗口后,必须取消它,避免 2 秒后被强制拉回。 +let forceShowTimer = null; +let currentFilePath = null; +let fileWatcher = null; +// 抑制自己写入触发的文件监视事件(避免误弹"外部修改"对话框)。 +// +// 四代方案对比: +// v1(被淘汰): 1.5s 时间窗口盲目抑制 → 用户紧接着的外部保存被吞。 +// v2(已废): mtime+size 指纹 → Node fs.watch 在 Windows 上的 callback 在 syscall +// 返回前就触发,fingerprint 永远来不及设置。race condition 不可解。 +// v3(也废): 内容字符串指纹 → 同样被 fs.watch 时序问题击穿。 +// v4(上一代): 主进程不过滤。所有 fs.watch 事件都发 IPC,渲染端 readFile 后 +// 与 store.serialize() 比对 —— 完全相同视为自己写入的产物静默, +// 任何字节差异视为外部修改正常 reload + toast。判断在渲染端做, +// 不依赖主进程的时序/事件触发顺序,零误伤。 +// v5(当前): v4 的内容比对保留,同时再加 250ms statSync(mtime+size) 轮询兜底。 +// 原因:用户实际报告「修改 todo.md 后软件没及时更新」,说明他 +// 的环境里 fs.watch 根本没触发(云同步目录 / 网络盘 / 某些编辑器 +// 的非标准保存路径)。fs.watch 依赖 OS 文件通知 API,不可靠。 +// polling 用 statSync 探测 mtime+size 变化触发相同 IPC —— 普适。 +// v4 的内容比对继续承担「自写入抑制」职责:polling 检测到变化 +// 后发 IPC,渲染端读出来与 store.serialize() 比对,相同则静默。 +// 250ms 是普适体验甜点(500ms 是可感知延迟,250ms 用户几乎无感, +// statSync 对静态文件开销可忽略 —— 每秒 4 次 stat 不影响性能)。 +// +// 主进程这里只做基础去重:同一保存的 burst 事件(Windows 上可能 2-3 个)只发 1 次 IPC。 + +// ============================================ +// 配置管理(持久化用户设置) +// ============================================ + +/** + * 默认数据目录:用户主目录下的 TodoList(~/TodoList)。 + * + * 跨平台一律走 home:Windows 上就是 %USERPROFILE%/TodoList,macOS / Linux + * 同理。不要再针对 Windows 特判 D:\TodoList —— 单盘笔记本根本没 D:, + * 多盘用户也未必想把任务数据丢到非系统盘;统一走 ~/TodoList 最直观、最可移植。 + * + * 历史:D 盘特判最初是为了"有一台专门放数据的机器"的用户;后来证明 + * 1) 大部分笔记本 / CI 没 D:,首次启动必然落入「临时模式」 + * 2) 有 D: 的用户也常常忘了同步自己设的 dataDir + * 综合下来还是 home 最稳:自带云盘同步(OneDrive / iCloud Documents / + * Dropbox)默认就覆盖 ~/TodoList,迁移也无须改代码。 + */ +function getDefaultDataDir() { + try { + const home = app.getPath('home'); + return path.join(home, 'TodoList'); + } catch { + const os = require('os'); + return path.join(os.homedir(), 'TodoList'); + } +} + +// 默认目录在 app ready 之后才能解析(需要 app.getPath),先用占位值 +let DEFAULT_DATA_DIR = null; + +function getConfigPath() { + // 配置存储在用户应用数据目录(Electron 推荐位置) + return path.join(app.getPath('userData'), 'config.json'); +} + +/** + * 主进程自己用到的配置默认值。 + * + * 用途:loadConfig() 用这份兜底缺失字段,确保 appConfig 启动时就自洽 —— 任何 + * `appConfig.X` 访问拿到的是确定值,而不是 undefined。这件事不能用 truthy 检查 + * 凑合:loadConfig 返回 {} 时 appConfig.backupEnabled === undefined,truthy 检查 + * 把它当成"禁用",结果新用户的每日备份机制永远不触发(config.json 不会被主进程 + * 自动写入 backupEnabled 字段),UI 却又显示开启 —— 主进程 / 渲染端不一致。 + * + * 注意:dataDir / theme / filter / sortBy / fontSize 等不在这里 —— 它们要么是 + * null 语义(恢复默认),要么仅 renderer 用、main 自身不读。lastBackupDate 虽 + * 是主进程内部状态,但写入端(saveConfig)和读取端(maybeBackupToday)都明确 + * 处理 null,所以也不需要在这里兜底。 + */ +const DEFAULT_CONFIG = { + backupEnabled: true, // 默认开启每日备份 + alwaysOnTop: false // 默认关闭窗口置顶 +}; + +// 安全审计 M2 修复:递归过滤危险 key,防止外部篡改的 config.json 通过 +// `__proto__` / `constructor.prototype` 污染 Object.prototype。 +// 应用场景:loadConfig 把磁盘 JSON 合并进内存配置;任何代码随后访问 +// `appConfig.someKey` 时都会走原型链查找 —— 攻击者只需让 config.json +// 出现 `{"__proto__":{"isAdmin":true}}` 就能挂全局标志位。 +function _stripProtoKeys(value) { + if (Array.isArray(value)) return value.map(_stripProtoKeys); + if (value && typeof value === 'object') { + const out = {}; + for (const k of Object.keys(value)) { + if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue; + out[k] = _stripProtoKeys(value[k]); + } + return out; + } + return value; +} + +function loadConfig() { + try { + const raw = fsSync.readFileSync(getConfigPath(), 'utf-8'); + const parsed = JSON.parse(raw); + // 防御性:配置文件被破坏或被外部改成 null / 数组 / 标量时,避免后续 + // `appConfig.dataDir` 之类的访问炸掉整个启动。 + const valid = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)); + const safe = valid ? _stripProtoKeys(parsed) : {}; + return { ...DEFAULT_CONFIG, ...safe }; + } catch { + return { ...DEFAULT_CONFIG }; + } +} + +let appConfig = {}; + +async function saveConfig(next) { + // v3.7+ 修复 H11:先构造候选 next,**写盘成功后再 commit 内存**。 + // 旧实现是先 appConfig = {...appConfig, ...next} 再 writeFileSync: + // - 写盘失败时 catch 只 console.error,内存已被覆盖 + // - maybeBackupToday 看到 appConfig.lastBackupDate === today 直接 return, + // 当天再也不会产生备份 + // 新实现:写盘失败时 next 不写回 appConfig,lastBackupDate 保持原值, + // 下次 maybeBackupToday 仍能正确触发;同时不再抛出(保留原行为:失败仅记日志)。 + // + // v4+ 修复:原子写入(tmp + rename)。 + // 旧实现 fsSync.writeFileSync 在电源掉电 / 进程被杀中途会留下「半截 JSON」—— + // 下次启动 parseConfig 抛 SyntaxError → 用户设置(主题、置顶、数据目录等)全部 + // 回到默认,等于无声重置。tmp + rename 在同盘上是原子的:磁盘要么是旧完整配置、 + // 要么是新完整配置,永远不会半截。 + // + // v4+ 修复:改为 async 函数。旧实现 EPERM/EBUSY 重试时用 while 循环 spin-lock + // 阻塞主线程 50-100ms,期间 IPC / 定时器全部停滞。改为 setTimeout 异步等待, + // 不阻塞事件循环。 + const candidate = { ...appConfig, ...next }; + const configPath = getConfigPath(); + const tmpPath = configPath + `.${process.pid}.${Date.now()}.tmp`; + try { + fsSync.mkdirSync(path.dirname(configPath), { recursive: true }); + fsSync.writeFileSync(tmpPath, JSON.stringify(candidate, null, 2), 'utf-8'); + // rename 在同盘上是原子的;Windows 上偶尔会因杀毒软件占文件失败, + // 重试一次常见且无副作用。 + try { + fsSync.renameSync(tmpPath, configPath); + } catch (e) { + if ((e.code === 'EPERM' || e.code === 'EBUSY') && fsSync.existsSync(tmpPath)) { + // 杀毒软件/索引器短暂占用 —— 异步退避后重试一次(旧实现是 spin-lock, + // 阻塞主线程 50-100ms;改为 setTimeout 不阻塞事件循环)。 + const delay = 50 + Math.floor(Math.random() * 50); + await new Promise(resolve => setTimeout(resolve, delay)); + fsSync.renameSync(tmpPath, configPath); + } else { + throw e; + } + } + } catch (e) { + console.error('[main] 配置保存失败:', e.message); + // 清理可能残留的 tmp 文件 + try { if (fsSync.existsSync(tmpPath)) fsSync.unlinkSync(tmpPath); } catch {} + // 失败时不 commit —— 保持原 appConfig + return appConfig; + } + appConfig = candidate; + return appConfig; +} + +/** + * 获取实际生效的数据目录路径 + */ +function resolveDataDir() { + const custom = appConfig.dataDir; + if (custom && typeof custom === 'string' && custom.trim()) { + return custom; + } + return DEFAULT_DATA_DIR; +} + +// ============================================ +// 每日备份 +// ============================================ +// +// 开关:settingsStore.backupEnabled(默认 true)。关闭时整个机制空转, +// 即便调用 maybeBackupToday() 也直接 return —— 用户改回去当天再次开启 +// 也照常触发,因为 disable 不清 lastBackupDate。 +// +// 触发点: +// - 启动时(app-start)—— 首次打开软件 +// - 写入文件前(file-write)—— 首次操作;只要还没备份过今天就被吸收 +// +// 「每天一次」用 lastBackupDate(YYYY-MM-DD,本地日期)做幂等: +// - 今天 == lastBackupDate → return +// - 不等 → 复制 /todo.md → /backup/todo-YYYY-MM-DD.md → 落盘 lastBackupDate +// - 文件不存在(用户从未保存过)→ return,什么也不写 +// - 复制失败(盘满 / 权限)→ 不更新 lastBackupDate,下次再试 +// +// 并发:短时间内多次写入 / 启动 + 写入撞上时,backupInFlight 守卫只让一次进入; +// 后到的调用直接 return(同步检查 lastBackupDate 也兜底),不会写出多份同一天备份。 + +/** + * 本地日期 YYYY-MM-DD(用本地时区;toISOString 走 UTC 在跨时区用户处会偏一天) + */ +function getTodayString() { + const d = new Date(); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +let backupInFlight = false; + +async function maybeBackupToday(reason) { + // 关闭 / 已备份过今天 / 别的调用正在跑 —— 都直接跳过。 + // 注意:备份成功前不更新 lastBackupDate,所以「今天还没备份过」的判断 + // 必须以磁盘上 lastBackupDate 字段为准,不能用 in-memory flag。 + if (backupInFlight) return; + // 严格 === false:loadConfig 缺失字段时用 DEFAULT_CONFIG.backupEnabled = true + // 兜底,appConfig.backupEnabled 永远是 boolean。truthy 检查会把 undefined 当成 + // 「禁用」—— 即便 IPC 写入侧和 SettingsStore 都做了 boolean 校验,未来若引入 + // 字符串污染(手工编辑 config.json 等),strict false 仍能正确识别为「禁用」。 + if (appConfig.backupEnabled === false) return; + + const today = getTodayString(); + if (appConfig.lastBackupDate === today) return; + + const dataDir = resolveDataDir(); + // 源文件路径固定 todo.md(与 renderer 中 createDefaultFile 的约定一致)。 + // 不存在就什么都不做 —— 用户可能首次启动、还没创建过文件。 + const sourcePath = path.join(dataDir, 'todo.md'); + const backupDir = path.join(dataDir, 'backup'); + const backupPath = path.join(backupDir, `todo-${today}.md`); + + // 路径合法性:内部构造的 path.join(dataDir, ...) 理论上必在 dataDir 内, + // 但保留一道 assertInDataDir 兜底 —— 万一未来 dataDir 校验逻辑收紧 + // (如允许用户从快捷方式里指带 .. 的相对路径),不会在这里漏出 RCE。 + try { + assertInDataDir(sourcePath); + assertInDataDir(backupPath); + } catch (e) { + console.warn('[main] 备份路径校验失败:', e.message); + return; + } + + let stat; + try { + stat = await fs.stat(sourcePath); + } catch { + // 源文件不存在 —— 用户第一次启动还没保存过,跳过 + return; + } + if (!stat.isFile()) return; + + backupInFlight = true; + try { + await fs.mkdir(backupDir, { recursive: true }); + // 用 copyFile 而非 read+write:与同文件 .bak 单次备份走同一条路径, + // 性能更好(OS 内核级 sendfile / CopyFileEx,无用户态缓冲)。 + // todo.md 量级下撞上「copy 中途源被外部写」的窗口极短,且 fs.rename 替换 + // 语义下源文件句柄仍指向原 inode —— 实际上不会产生不一致。 + await fs.copyFile(sourcePath, backupPath); + saveConfig({ lastBackupDate: today }); + console.log(`[main] 已备份到 ${backupPath} (原因: ${reason})`); + } catch (e) { + console.warn('[main] 备份失败:', e.message); + // 不更新 lastBackupDate —— 下次再试 + } finally { + backupInFlight = false; + } +} + +// ============================================ +// 启动日志 +// ============================================ + +console.log('[main] Electron 启动中...'); +console.log('[main] Platform:', process.platform); +console.log('[main] Electron version:', process.versions.electron); +console.log('[main] Project path:', __dirname); + +// ============================================ +// 窗口创建 +// ============================================ + +function createWindow() { + console.log('[main] 创建主窗口...'); + + // 防御性清理:若上一次 createWindow 设置的 2s 兜底定时器尚未触发, + // 先清掉它 —— 否则旧定时器会在窗口创建后触发,对「新」窗口执行 show, + // 虽然 isVisible 检查能挡住多余 show,但悬空定时器是隐患。 + // 典型触发场景:窗口刚启动不久因 render-process-gone 被销毁重建。 + if (forceShowTimer) { + clearTimeout(forceShowTimer); + forceShowTimer = null; + } + + mainWindow = new BrowserWindow({ + width: 960, + height: 800, + minWidth: 600, + minHeight: 420, + backgroundColor: '#0d1117', + icon: APP_ICON_PATH, // 标题栏 + 任务栏图标 + show: false, // 先隐藏,ready-to-show 后再显示(避免白屏闪烁) + autoHideMenuBar: true, // 自动隐藏菜单栏(替代 setMenuBarVisibility) + // 自定义窗口:去掉系统标题栏,改由 renderer 顶部工具栏的 -webkit-app-region: drag + // 接管拖拽,并在工具栏右侧画出自定义最小化/还原/关闭按钮。 + // 注意:保留 default 行为(无 frame 时仍可拖边缘调整大小、Win+Aero Snap 仍生效)。 + frame: false, + titleBarStyle: 'hidden', // macOS 上隐藏标题栏;Windows 上被 frame:false 完全接管 + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + // 允许 ES 模块加载(renderer 中的 type="module") + // 必需,否则 ES 模块会失败 + } + }); + + mainWindow.loadFile('index.html'); + + // 启动时应用持久化的「始终置顶」状态。 + // config.json 在 app.ready 时已加载(appConfig),createWindow 是 ready 回调里调用, + // 此时 appConfig 已就绪。如果上次会话开着,窗口创建后立刻置顶。 + if (appConfig.alwaysOnTop === true) { + mainWindow.setAlwaysOnTop(true); + } + + // ready-to-show:渲染完成后显示窗口,并同步自定义标题栏的最大化按钮状态 + mainWindow.once('ready-to-show', () => { + console.log('[main] 窗口 ready-to-show,显示窗口'); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.show(); + } + broadcastMaximizeState(); + }); + + // 回兜:2 秒后如果还没显示,强制显示(防止 ready-to-show 静默失败) + forceShowTimer = setTimeout(() => { + forceShowTimer = null; + if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) { + console.warn('[main] ready-to-show 未触发,强制显示窗口'); + mainWindow.show(); + } + }, 2000); + + // 错误监听 + mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL) => { + console.error('[main] 页面加载失败:', errorCode, errorDescription, validatedURL); + }); + + mainWindow.webContents.on('render-process-gone', (event, details) => { + console.error('[main] 渲染进程崩溃:', details); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.reload(); + } + }); + + mainWindow.webContents.on('console-message', (event, level, message, line, source) => { + // level: 0=verbose, 1=info, 2=warning, 3=error + if (level >= 3) { + console.error(`[renderer ${source}:${line}]`, message); + } + }); + + // 开发模式下自动打开 DevTools(方便调试) + if (process.env.NODE_ENV === 'development' || process.argv.includes('--dev')) { + mainWindow.webContents.openDevTools({ mode: 'detach' }); + } + + // 关闭事件 + // 默认拦截关闭按钮,改为隐藏到托盘(除非应用正在主动退出,或托盘不可用) + mainWindow.on('close', (e) => { + if (isQuitting) return; + // 没有托盘就无法恢复窗口,让窗口正常关闭(window-all-closed 会处理退出) + if (!tray) return; + e.preventDefault(); + hideWindow(); + }); + + // 最大化/还原事件:把状态推给 renderer 同步自定义按钮图标。 + // 不监听 'resize' 是因为它会因每像素变化连续触发;'maximize' / 'unmaximize' + // 是状态切换点,只触发一次,正好对应按钮图标的二态切换。 + // 初始状态由上方 ready-to-show 监听器推一次(renderer 还没挂 IPC 监听就丢消息, + // 而 ready-to-show 时 renderer 已经初始化、IPC 可用)。 + mainWindow.on('maximize', () => broadcastMaximizeState()); + mainWindow.on('unmaximize', () => broadcastMaximizeState()); + + mainWindow.on('closed', () => { + console.log('[main] 窗口已关闭'); + stopWatchingFile(); + mainWindow = null; + }); + + console.log('[main] 主窗口创建完成'); +} + +// ============================================ +// 系统托盘 +// ============================================ + +function showWindow() { + if (!mainWindow || mainWindow.isDestroyed()) { + createWindow(); + return; + } + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + mainWindow.focus(); + // 显隐态变了,托盘菜单的「显示/隐藏窗口」文案要跟着翻转。 + if (tray) tray.setContextMenu(buildTrayMenu()); +} + +function hideWindow() { + if (!mainWindow || mainWindow.isDestroyed()) return; + // 取消 ready-to-show 兜底定时器,避免用户主动隐藏后被强制拉回 + if (forceShowTimer) { + clearTimeout(forceShowTimer); + forceShowTimer = null; + } + mainWindow.hide(); + if (tray) tray.setContextMenu(buildTrayMenu()); +} + +/** + * 切换窗口显隐。托盘左键单击时触发。 + */ +function toggleWindow() { + if (!mainWindow || mainWindow.isDestroyed()) { + createWindow(); + return; + } + if (mainWindow.isVisible() && !mainWindow.isMinimized()) { + hideWindow(); + } else { + showWindow(); + } +} + +function buildTrayMenu() { + // 根据窗口当前显隐态,只显示相关的那一项 —— 同时列出「显示 / 隐藏」会让用户 + // 困惑(不知哪个是当前态),点错了也只是再点一次,但体验不佳。 + const isVisible = mainWindow && !mainWindow.isDestroyed() && + mainWindow.isVisible() && !mainWindow.isMinimized(); + const toggleItem = isVisible + ? { label: '隐藏窗口', click: () => hideWindow() } + : { label: '显示窗口', click: () => showWindow() }; + return Menu.buildFromTemplate([ + toggleItem, + { type: 'separator' }, + { + label: '始终置顶', + type: 'checkbox', + checked: appConfig.alwaysOnTop === true, + click: (item) => { + // 托盘菜单勾选时同步到窗口 + 落盘。 + // 直接复用 renderer 持久化路径:通知渲染端写入 settingsStore, + // settingsStore.update 会通过 save-settings IPC 落盘,主进程这里也立刻 + // 应用到窗口,避免 renderer 还没起来(重启首次启动时)就先一步生效。 + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.setAlwaysOnTop(item.checked); + } + saveConfig({ alwaysOnTop: item.checked }); + // 通知 renderer 同步工具栏按钮状态(如果窗口存在)。 + mainWindow?.webContents.send('always-on-top:changed', item.checked); + } + }, + { type: 'separator' }, + { + label: '设置...', + click: () => { + showWindow(); + // 与菜单栏"设置..."一致:通知渲染进程打开设置对话框 + mainWindow?.webContents.send('menu:settings'); + } + }, + { type: 'separator' }, + { + label: '退出', + click: () => { + isQuitting = true; + app.quit(); + } + } + ]); +} + +function createTray() { + if (tray) return; + try { + tray = new Tray(APP_ICON_PATH); + tray.setToolTip('Todo List'); + tray.setContextMenu(buildTrayMenu()); + // 左键单击:切换窗口显示 + tray.on('click', () => toggleWindow()); + console.log('[main] 系统托盘已创建'); + } catch (e) { + console.error('[main] 系统托盘创建失败:', e.message); + tray = null; + } +} + +function destroyTray() { + if (!tray) return; + try { + tray.destroy(); + } catch (e) { + console.warn('[main] 销毁托盘失败:', e.message); + } + tray = null; +} + +// ============================================ +// 单实例锁 +// ============================================ +// 应用现在会驻留托盘(关闭窗口不退出),用户很容易忘记它还在运行而重复启动。 +// 两个实例会各自 fs.watch 同一个 todo.md 并各自防抖自动保存,导致互相覆盖丢数据。 +// 因此只允许一个实例:后启动的那个把已有窗口叫到前台,然后自己退出。 +const gotSingleInstanceLock = app.requestSingleInstanceLock(); + +if (!gotSingleInstanceLock) { + console.log('[main] 已有实例在运行,激活已有窗口后退出本实例'); + isQuitting = true; + app.quit(); +} else { + app.on('second-instance', () => { + console.log('[main] 检测到第二个实例启动,激活已有窗口'); + showWindow(); + }); +} + +// ============================================ +// 应用生命周期 +// ============================================ + +app.whenReady().then(() => { + // 未拿到单实例锁时不应继续初始化(app.quit() 期间 ready 仍可能已触发) + if (!gotSingleInstanceLock) return; + + // 启动时加载用户配置 + appConfig = loadConfig(); + // 解析默认数据目录(需要 app ready 才能访问 user paths) + DEFAULT_DATA_DIR = getDefaultDataDir(); + console.log('[main] 已加载配置:', JSON.stringify(appConfig)); + console.log('[main] 数据目录:', resolveDataDir()); + + // 启动时尝试今天的每日备份。异步触发、不阻塞启动 —— 即便备份耗时 + // 几百毫秒,UI 也已经在 ready-to-show 里 show 了。 + maybeBackupToday('app-start'); + + console.log('[main] app ready'); + buildMenu(); + createWindow(); + createTray(); + + app.on('activate', () => { + // macOS: dock 图标被点击 + // 优先复用现有的窗口(隐藏时也要能恢复显示),否则才新建 + if (mainWindow && !mainWindow.isDestroyed()) { + showWindow(); + } else if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); +}).catch(err => { + // v4+ 修复:旧实现只 console.error,用户看到「双击启动 → 什么都没发生 → 任务管理器 + // 进程在但窗口不见」。给一个原生错误对话框,至少让用户能搜到关键字、决定是否重启。 + console.error('[main] app ready 失败:', err); + // dialog 依赖 app.ready —— whenReady().catch 触发时 app 已经 ready(否则这个 catch + // 不会被调用),所以直接用同步 dialog.showMessageBox。BrowserWindow 可能从未创建, + // 不传 parent 让它独立显示。 + try { + const { dialog: dlg } = require('electron'); + dlg.showMessageBoxSync({ + type: 'error', + title: 'Todo List - 启动失败', + message: '应用初始化失败', + detail: (err && err.stack) ? String(err.stack) : String(err), + noLink: true + }); + } catch (dialogErr) { + // dialog 自己抛了(极端环境,比如没有 display)—— 静默走 stderr + console.error('[main] 无法弹出错误对话框:', dialogErr); + } +}); + +app.on('before-quit', (event) => { + // 标记为主动退出,让 close 事件不再被拦截到托盘 + isQuitting = true; + + // 已有 flush 在跑:第一次 before-quit 触发的 flush 还没完。 + // 这里**不能** preventDefault —— 否则退出被永久卡住,flush-done 永远等不到再次 app.quit()。 + // 直接放行让 Electron 走完这次 before-quit;flush-done 处理器会在保存完成后自己再调 app.quit()。 + if (flushInProgress) return; + + // 退出前刷写未保存数据: + // 自动保存有 800ms 防抖,用户如果在修改后 800ms 内退出(关闭托盘/窗口/系统关机), + // 排队的保存会被丢弃 → 数据丢失。这里拦截退出,先让渲染进程立即保存。 + if (!saveFlushed && rendererDirty && mainWindow && !mainWindow.isDestroyed()) { + event.preventDefault(); + flushInProgress = true; + mainWindow.webContents.send('app:flush-pending-save'); + // 给渲染进程 5 秒完成保存,之后强制退出。句柄存进 flushTimeoutHandle, + // flush-done 提前到时由它清掉,避免一个永不被释放的 setTimeout。 + flushTimeoutHandle = setTimeout(() => { + flushTimeoutHandle = null; + // 兜底备份:超时意味着 renderer 未在 5 秒内完成保存。无论 renderer 是 + // 已销毁还是活着但慢(大文件 IO / 主线程阻塞),磁盘上的内容可能不是最新的, + // 强制退出前都做一份 .pre-crash.bak,用户至少能从数据目录恢复上次成功写入的内容。 + // 旧实现仅在 renderer 已销毁时备份 —— 活着但慢的场景下数据无声丢失。 + if (currentFilePath) { + try { + fsSync.copyFileSync(currentFilePath, currentFilePath + '.pre-crash.bak'); + console.warn('[main] 兜底:flush 超时,已写入 .pre-crash.bak'); + } catch (e) { + console.warn('[main] 兜底备份失败:', e.message); + } + } + saveFlushed = true; + flushInProgress = false; + app.quit(); + }, 5000); + return; + } +}); + +app.on('will-quit', () => { + destroyTray(); +}); + +app.on('window-all-closed', () => { + console.log('[main] 所有窗口已关闭'); + // 有托盘时,应用保持在后台运行(仅由托盘菜单的"退出"或 app.quit() 真正退出) + if (tray) return; + if (process.platform !== 'darwin') app.quit(); +}); + +// ============================================ +// IPC: 文件操作 +// ============================================ + +// 注:不提供 'file:open-dialog' —— UI 上已去掉「打开文件」入口, +// 文件位置只由设置里的数据目录决定,留着一个无人调用的对话框只是多一个攻击面 + +/** + * .bak 文件轮转:保留最近三份(.bak = 上次保存;.bak.1 = 上上次;.bak.2 = 再上一次)。 + * + * 不做无限制累积:每次 file:write 都会创建一份 .bak,无清理时代码库会在数据目录 + * 里堆出与「写入次数 × 文件大小」等量的空间。普通用户一天保存几十次到几百次, + * 一个文件 10 KB 一年下来也能堆到几百 MB —— 用户大概率不会手动去翻 .bak。 + * + * 保留三份而不是两份(v3.4+ 改为三份):多了「丢弃本地编辑前」的快照位 + * (见 file:create-snapshot,label=discard 的快照与 .bak 是不同命名空间,不会冲突)。 + * 三份为「连续三次 saveNow 失败后的连续 reload 留一阶余地」—— 典型用户场景下足够。 + * + * 文件命名约定:.bak / .bak.1 / .bak.2 —— 不带时间戳是故意的,轮转时只需要 rename 三份文件, + * 无需解析 / 生成时间戳,路径相关的旁路代码(用户自己 shell 写的恢复脚本)也好懂。 + * + * @param {string} absPath 目标文件绝对路径 + */ +async function rotateBakFiles(absPath) { + const bakPath = absPath + '.bak'; + const bak1Path = absPath + '.bak.1'; + const bak2Path = absPath + '.bak.2'; + // v3.7+ 修复 M-P3:先复制到临时 .bak.new,再降级 .bak.1 → .bak.2 → .bak → .bak.new。 + // 旧顺序:删 .bak.2 → rename .bak.1 → rename .bak → copyFile(abs, .bak) + // - 降级 rename 中途失败(如 .bak.1 → .bak.2 成功但 .bak → .bak.1 失败), + // .bak 已被 .bak 的旧内容占着,copyFile 后 .bak 内容是「新文件 + .bak 旧内容竞争」 + // 极端下旧的"再上一次保存"丢失到 .bak.2 位置但若 .bak.2 删除已做则"再上一次"完全消失 + // 新顺序:先 copyFile 到 .bak.new(新内容已落盘)→ 删 .bak.2 → 降级 → 最后 rename .bak.new → .bak + // - 任一 rename 失败:.bak.new 已含新内容,原 .bak/.bak.1/.bak.2 都还在 + // (除已删的 .bak.2),用户至少有"上次保存"+".bak.new"两个回退点 + // - 最后一步 rename 成功:恢复"先删最旧 → 降级 → 新内容就位"的最终三份状态 + const bakNewPath = absPath + '.bak.new'; + try { + // 1) 先把磁盘当前文件复制为 .bak.new —— 这是新内容,必须先落盘才能安全降级。 + if (fsSync.existsSync(absPath)) { + await fs.copyFile(absPath, bakNewPath).catch((e) => { + console.warn('[main] .bak.new 创建失败:', e.message); + // 如果连新内容都没法落盘,跳过整个轮转(原 .bak 都不动)—— + // 用户至少保留完整的旧三份回退点 + throw e; + }); + } else { + // 源文件不存在时不做轮转(极少见,可能是文件被删后立即触发) + return; + } + // 2) 删最旧的 .bak.2(无论如何都要保证最终只有三份) + if (fsSync.existsSync(bak2Path)) { + await fs.unlink(bak2Path).catch(() => {}); + } + // 3) 把 .bak.1 降级为 .bak.2 + if (fsSync.existsSync(bak1Path)) { + await fs.rename(bak1Path, bak2Path).catch((e) => { + console.warn('[main] .bak.1 降级失败:', e.message); + }); + } + // 4) 把现有 .bak 降级为 .bak.1 + if (fsSync.existsSync(bakPath)) { + await fs.rename(bakPath, bak1Path).catch((e) => { + console.warn('[main] .bak 降级失败:', e.message); + }); + } + // 5) 把 .bak.new 重命名为 .bak —— 最后一步;rename 在同盘上原子, + // 即便中途进程被杀,磁盘要么是旧三份、要么是完整新三份,永远不会半新半旧。 + await fs.rename(bakNewPath, bakPath).catch((e) => { + console.warn('[main] .bak.new → .bak 重命名失败:', e.message); + // 失败时 .bak.new 已存在但未就位 —— 下次轮转会再覆盖,文件不会丢 + }); + } catch (e) { + // 整个轮转失败不应阻塞主流程 —— 用户至多少一份回退点,比写入失败好得多 + console.warn('[main] .bak 轮转失败:', e.message); + } +} + +/** + * 读取磁盘当前内容(用于外部修改冲突场景的载荷增强)。 + * + * 调用点:file:write 检测到 EXTERNAL_CHANGE_DETECTED 时,把磁盘内容一并塞回错误响应, + * 渲染端拿到后立刻可以拿去做 diff —— 不用再额外发一次 file:read。 + * + * 文件不存在 / 读取失败一律返回 null(调用方需要兼容)—— 让渲染端走「文件已删除」分支。 + */ +async function readCurrentDiskContent(absPath) { + try { + return await fs.readFile(absPath, 'utf-8'); + } catch (e) { + console.warn('[main] 读取磁盘当前内容失败:', absPath, e.message); + return null; + } +} + +/** + * 把磁盘当前文件复制为「带标签的快照」—— 与 .bak 轮转的命名空间不同, + * 用于「主动丢弃本地编辑前」「退出前刷写时」「重载前」等用户可见意图。 + * + * 与 .bak 轮转的核心区别: + * - .bak 是「隐式回退点」,不带时间戳,每 saveNow 自动轮转(保留最近 3 份) + * - 带标签快照是「显式意图记录」,带时间戳,**不轮转不清理** —— 用户主动丢弃的 + * 内容必须能找回,几个月后某天突然想回看时还能找得到 + * + * 命名:${filePath}.${label}-${YYYYMMDD-HHmmss}.bak —— 与 .bak / .bak.1 / .bak.2 区分开, + * 不会污染 rotateBakFiles 的命名空间。 + * + * 合法 label 集合(白名单避免任意字符串被拼到文件名里): + * - discard —— 用户主动丢弃本地编辑前的快照 + * - autosave-on-exit —— 退出前自动保存检测到外部修改时的快照 + * - before-reload —— reload 之前的快照 + * - user-merge —— 用户合并后保留旧版本(可选,将来用得到) + */ +const VALID_SNAPSHOT_LABELS = new Set([ + 'discard', 'autosave-on-exit', 'before-reload', 'user-merge' +]); + +ipcMain.handle('file:create-snapshot', async (event, { filePath, label }) => { + let absPath; + try { + absPath = assertInDataDir(filePath); + } catch (e) { + return { ok: false, error: e.message }; + } + if (typeof label !== 'string' || !VALID_SNAPSHOT_LABELS.has(label)) { + return { ok: false, error: `invalid label: ${label}` }; + } + try { + if (!fsSync.existsSync(absPath)) { + // 文件已被外部删除 —— 快照源不存在,没法复制 + return { ok: false, error: 'FILE_NOT_FOUND' }; + } + const now = new Date(); + const pad = (n) => String(n).padStart(2, '0'); + const ts = + `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` + + `-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; + const snapshotPath = `${absPath}.${label}-${ts}.bak`; + // 文件名冲突(同秒内两次 snapshot)—— 在末尾加 -N 后缀,N 从 1 开始 + let finalPath = snapshotPath; + let collision = 1; + while (fsSync.existsSync(finalPath)) { + finalPath = `${absPath}.${label}-${ts}-${collision}.bak`; + collision++; + } + await fs.copyFile(absPath, finalPath); + console.log(`[main] 已创建快照 (label=${label}):`, finalPath); + return { ok: true, snapshotPath: finalPath }; + } catch (e) { + console.error('[main] 创建快照失败:', absPath, e.message); + return { ok: false, error: e.message }; + } +}); + +/** + * 把任意传入路径收敛到「当前生效的数据目录」之内。 + * + * 必要性:renderer 拿到的是普通字符串,所有 file:read / file:write / file:create-if-missing + * 此前完全信任调用方。配合一个 XSS(任务文本流过 innerHTML)就能让 renderer 把任意 + * 路径喂进来 —— 等于任意文件读写。Drop 一个 .bat 进 Startup 目录就是持久 RCE。 + * + * 策略:path.resolve 后必须以数据目录的绝对路径为前缀;非字符串、穿越、空路径一律拒绝。 + * 目录存在与否不影响:写入端有自己的 mkdir -p,读取端只信任路径合法性。 + */ +function assertInDataDir(p) { + if (typeof p !== 'string' || !p) { + throw new Error('path must be a non-empty string'); + } + const root = path.resolve(resolveDataDir()); + const abs = path.resolve(p); + // Windows 上 NTFS 是大小写不敏感的:`D:\TodoList\foo.md` 和 `d:\TODOLIST\foo.md` + // 指向同一文件,但 `startsWith` 走字符串相等 —— 直接用 abs.startsWith(root) + // 会被大小写差异绕过,逃出数据目录的「任意文件读写」防御。统一 toLowerCase 比对。 + const rootCmp = process.platform === 'win32' ? root.toLowerCase() : root; + const absCmp = process.platform === 'win32' ? abs.toLowerCase() : abs; + if (absCmp !== rootCmp && !absCmp.startsWith(rootCmp + path.sep)) { + throw new Error('path outside data directory'); + } + return abs; +} + +ipcMain.handle('file:save-dialog', async () => { + // 默认定位到数据目录 + 'todo.md',避免用户在「桌面」「下载」之类位置创建文件 — + // 写文件路径会被 assertInDataDir 卡在数据目录外,事后才报错,徒增一次往返。 + // 让对话框一开始就落在合法范围内,UX 上更顺。 + const result = await dialog.showSaveDialog(mainWindow, { + title: '保存 Todo 文件', + defaultPath: path.join(resolveDataDir(), 'todo.md'), + filters: [{ name: 'Markdown', extensions: ['md'] }] + }); + if (result.canceled) return null; + return result.filePath; +}); + +ipcMain.handle('file:read', async (event, filePath) => { + let absPath; + try { + absPath = assertInDataDir(filePath); + } catch (e) { + return { ok: false, error: e.message }; + } + try { + const content = await fs.readFile(absPath, 'utf-8'); + // 读取成功后启动文件监听,使外部修改能被检测到 + // (写入路径已经在 file:write 中启动,这里补齐读取路径) + currentFilePath = absPath; + startWatchingFile(absPath); + // 关键:刷新 lastSeen 到刚读到的文件的当前 stat。 + // startWatchingFile 只挂 fs.watch、不刷新基线 —— lastSeenMtimeMs/lastSeenSize + // 仍停在「上一个文件」的状态。若用户在两次会话之间打开同一目录的另一份 todo + // (例如 todo.md 与 archive.md),第一次写新文件时 preStat 与陈旧 lastSeen 比对 + // 会不相等、误判 EXTERNAL_CHANGE_DETECTED、弹「外部修改」冲突对话框。 + // 与 file:write / file:create-if-missing 后的 recordFileStat 对称。 + recordFileStat(absPath); + return { ok: true, content }; + } catch (e) { + console.error('[main] 读取文件失败:', absPath, e.message); + // 文件被外部删除时返回专用错误码,让渲染进程能给出友好提示, + // 而不是把 ENOENT 这种系统错误码直接展示给用户 + if (e.code === 'ENOENT') { + return { ok: false, error: 'FILE_NOT_FOUND', message: '文件已被删除' }; + } + return { ok: false, error: e.message }; + } +}); + +ipcMain.handle('file:exists', async (event, filePath) => { + // v3.7+ 修复 H10:file:exists 必须和 file:write / file:read 同款走 assertInDataDir。 + // 旧实现直接 fs.access(filePath) —— 配合一个 XSS 就能枚举整盘存在性 + // (for(let p of commonList) await api.fileExists(p))。补上数据目录前缀校验后, + // renderer 只能探测数据目录内的路径,对外仍能拿到 boolean。 + try { + const absPath = assertInDataDir(filePath); + await fs.access(absPath); + return true; + } catch { + return false; + } +}); + +ipcMain.handle('file:write', async (event, { filePath, content, options = {} }) => { + let absPath; + try { + absPath = assertInDataDir(filePath); + } catch (e) { + return { ok: false, error: e.message }; + } + // v4+ 修复:内容大小上限 50 MB。正常 todo.md 远小于此值(数千条任务约几十 KB), + // 任何超过这条线的请求几乎都是异常:渲染端被 XSS / 第三方插件注入的巨型字符串、 + // 调试代码误传测试 fixture、或者把别的文件路径指向 todo.md。直接拒绝避免: + // - 主进程 OOM(content 是 UTF-8 字符串,写 tmp 时被 Buffer.from 复制) + // - 把数据目录撑爆 + // 比配置写入的上限(settingsStore.configSchema 有 sizeLimit)更宽松, + // 因为 todo.md 是用户真实数据;50 MB 约可容纳 5-10 万条任务,仍是合理边界。 + if (typeof content !== 'string') { + return { ok: false, error: 'CONTENT_NOT_STRING' }; + } + if (content.length > 50 * 1024 * 1024) { + return { ok: false, error: 'CONTENT_TOO_LARGE', message: '内容超过 50 MB 上限' }; + } + try { + // 目标目录可能尚不存在(首次启动、用户新设的数据目录) + await fs.mkdir(path.dirname(absPath), { recursive: true }); + + // 每次保存前尝试今天的每日备份(在 .bak 单次备份之前;备份源就是磁盘上的 + // 当前文件,捕获的是本次写入之前的状态 —— 即「上次保存」的内容) + await maybeBackupToday('file-write'); + + // 防覆盖外部修改(仅非 force 模式)—— 双重检查 + 二次确认 + // + // 真实数据丢失场景:用户在软件里改任务 → 800ms 内外部编辑器保存 → + // autoSave 触发比 IPC(外部修改通知)到达更快 → autoSave 直接覆盖外部编辑。 + // + // 必须在 writeFile 真正写盘之前用 mtime+size 探测:和 lastSeen 对不上就说明 + // 文件被外部动过,本次拒绝写盘并返回 EXTERNAL_CHANGE_DETECTED,让渲染端走 + // 外部修改冲突流程(弹对话框让用户选保留/丢弃)。force=true 跳过此检查, + // 用于用户已明确同意覆盖的场景("保留本地" 按钮 / Ctrl+S 手动保存等)。 + // + // 二次确认(双 statSync)原因 —— 即使 pre-check 通过,写 tmp → rename 之间 + // 还有一个窗口(通常是 10-100ms,IO 期间)可能被外部编辑器插入: + // pre-check 时 mtime = T1(外部编辑前) + // ← 这之间外部编辑器保存 → 文件 mtime 变成 T2 + // rename 写入 → 静默覆盖外部编辑(数据丢失) + // 第二次 statSync 在 rename 前再次比对 pre-check 时的快照,能把这个窗口缩到 + // 几乎不可见(< 1ms)。fs.rename 在同文件系统内是原子的,剩余窗口外部编辑要么 + // 完全看不见(被覆盖)要么完全可见(rename 后下次轮询检测到)—— 不会再有半新半旧。 + // 通用 pre-stat 检查:捕获「外部编辑器保存 vs 我们的写入」race window + // + // force=false 的额外语义 — 与 lastSeenMtimeMs/lastSeenSize 比对:fs.watch / 轮询 + // 已经更新过 lastSeen,任何 mtime/size 不一致说明文件已被外部动过,本次拒绝 + // 写入并走冲突对话框。 + // + // force=true 跳过 lastSeen 比对 —— 用户已经明确表态「覆盖外部修改」,但 + // **仍**做 preStat 抓取(写完后做 confirm-stat 比对),捕获 race window 内 + // 出现的**新一轮**外部修改 —— 用户按"保留本地"瞬间外部又改了一次 → 拒绝并 + // 走冲突对话框,而不是静默覆盖。一致性优先。 + const exists = fsSync.existsSync(absPath); + let preStat = null; + if (exists) { + try { + preStat = await fs.stat(absPath); + } catch (e) { + // statSync 失败(文件锁 / 权限)—— 宁可放过一次(保持旧行为)也不要阻塞保存 + console.warn('[main] statSync 失败,跳过冲突检查:', e.message); + } + } + // 严格 === true:渲染端传 options.force = "false"(字符串)会被 truthy + // 检查放过,等于「保留本地」语义失效 —— 用户的外部修改会被悄悄覆盖。 + // 强类型校验确保只有 boolean true 才能跳过外部修改检测。 + if (options.force !== true && preStat && (preStat.mtimeMs !== lastSeenMtimeMs || preStat.size !== lastSeenSize)) { + console.warn(`[main] 拒绝写入:检测到外部修改 ${absPath}`); + // 增强载荷:把磁盘当前内容一并塞回去,让渲染端不用再发一次 file:read + // 就能走冲突对话框(v3.4+ 用户期望的体验)。 + const diskContent = await readCurrentDiskContent(absPath); + return { ok: false, error: 'EXTERNAL_CHANGE_DETECTED', diskContent }; + } + + // .bak 轮转:把当前 .bak 降级为 .bak.1,再把磁盘当前文件复制为 .bak。 + // 在 race 检测通过之后执行 —— 万一二次确认发现 race,.bak 仍是外部编辑前的内容, + // 回退效果与「上上次保存」相当(用户至多丢一份 autoSave 中间态,不是静默丢外部编辑)。 + // 不轮转的话 .bak 文件会无限膨胀(每次写一份,几千次写盘后磁盘占用难承受)。 + // 首次写入(文件不存在)跳过轮转 —— 没有「磁盘当前文件」可复制。 + if (exists) { + await rotateBakFiles(absPath); + } + + // 写 tmp(异步期间磁盘原文件不动,可被外部读取 / stat) + // 命名:Date.now + pid + 随机串 —— 单纯 pid 后缀**不够**(同进程内 pid 恒定, + // 800ms 防抖窗口内连按 Ctrl+S 可能落在同一毫秒 → tmp 同名 → 后写覆盖先写 tmp → + // rename 失败 → 数据丢失)。再追加 6 字符随机串,碰撞 ≈ 36^6 = 21 亿。 + // force 与 !force 共用此约定。 + const tmpPath = absPath + '.tmp.' + Date.now() + '_' + process.pid + '_' + Math.random().toString(36).slice(2, 8); + let tmpWritten = false; + try { + await fs.writeFile(tmpPath, content, 'utf-8'); + tmpWritten = true; + + // 二次确认:检查 pre-check 期间到现在,磁盘文件是否被外部动过 + // force 与 !force 都做 —— 这是 race window 防护的核心,把覆盖窗口缩到 + // 几乎不可见(< 1ms)。fs.rename 在同文件系统内是原子的,剩余窗口外部编辑 + // 要么完全看不见(被覆盖)要么完全可见(rename 后下次轮询检测到)。 + if (preStat) { + try { + const confirmStat = await fs.stat(absPath); + if (confirmStat.mtimeMs !== preStat.mtimeMs || confirmStat.size !== preStat.size) { + // race window 内被外部改了 —— 拒绝这次写入 + await fs.unlink(tmpPath).catch(() => {}); + console.warn(`[main] 拒绝写入:检测到 race window 内的外部修改 ${absPath}`); + const diskContent = await readCurrentDiskContent(absPath); + return { ok: false, error: 'EXTERNAL_CHANGE_DETECTED', diskContent }; + } + } catch (e) { + // 二次 stat 失败(极端情况:文件被外部删除)—— 视为外部修改拒绝 + await fs.unlink(tmpPath).catch(() => {}); + console.warn(`[main] 二次 statSync 失败,按外部修改处理:`, e.message); + const diskContent = await readCurrentDiskContent(absPath); + return { ok: false, error: 'EXTERNAL_CHANGE_DETECTED', diskContent }; + } + } + + // 原子 rename:同文件系统下原子替换,外部编辑要么完全丢失(已被替换) + // 要么完全可见(rename 之后发生,下次轮询 / fs.watch 检测到)。 + await fs.rename(tmpPath, absPath); + } catch (e) { + // rename 失败(AV / 索引器锁、EPERM、EXDEV 等)会留下 .tmp 残骸, + // 每次写入都堆一份,几年下来把数据目录撑爆。这里无论成功失败都尝试清掉。 + if (tmpWritten) await fs.unlink(tmpPath).catch(() => {}); + throw e; + } + + currentFilePath = absPath; + startWatchingFile(absPath); + // 主动写入完成:刷新 polling 基线,让 250ms 轮询跳过这次变化 + // (fs.watch 因 v4 注释里描述的时序问题可能来不及反应) + recordFileStat(absPath); + + return { ok: true }; + } catch (e) { + console.error('[main] 写入文件失败:', absPath, e.message); + return { ok: false, error: e.message }; + } +}); + +/** + * 仅在文件不存在时创建(用于首次启动写出初始文档)。 + * 使用 'wx' 标志:存在性检查与创建由内核原子完成, + * 不存在"先检查再写入"的竞态窗口,绝不会覆盖已有用户数据。 + */ +ipcMain.handle('file:create-if-missing', async (event, { filePath, content }) => { + let absPath; + try { + absPath = assertInDataDir(filePath); + } catch (e) { + return { ok: false, error: e.message }; + } + // 与 file:write 同样的内容上限兜底 —— 防御性,防止恶意 / 异常渲染端 + // 绕过 file:write 的 50 MB 检查直接打 create-if-missing 把数据目录撑爆。 + if (typeof content !== 'string') { + return { ok: false, error: 'CONTENT_NOT_STRING' }; + } + if (content.length > 50 * 1024 * 1024) { + return { ok: false, error: 'CONTENT_TOO_LARGE', message: '内容超过 50 MB 上限' }; + } + try { + await fs.mkdir(path.dirname(absPath), { recursive: true }); + + await fs.writeFile(absPath, content, { encoding: 'utf-8', flag: 'wx' }); + // 主进程层不做抑制 —— 渲染端 readFile 后与 serialize() 比对判断是否自己写入 + + currentFilePath = absPath; + startWatchingFile(absPath); + recordFileStat(absPath); + return { ok: true }; + } catch (e) { + if (e.code === 'EEXIST') { + console.log('[main] 文件已存在,跳过创建:', absPath); + return { ok: false, exists: true }; + } + console.error('[main] 创建文件失败:', absPath, e.message); + return { ok: false, error: e.message }; + } +}); + +// 文件监听 +// fs.watch 在 Windows 上对一次保存可能触发 1-3 个事件(修改 + 重命名 + 再次修改), +// 不去重的话渲染端会连续弹「外部修改」对话框 —— 用户切回应用看到的是第一个 +// 还没确认的对话框,第二第三个再叠上去,按钮位置错位且语义完全重复。 +// 这里在源头做 400ms 去重,保证「一次外部保存 = 一次提示」。 +let lastExternalChangeSentAt = 0; +const EXTERNAL_CHANGE_DEDUP_MS = 400; + +// === 250ms 轮询兜底:fs.watch 在云同步目录 / 网络盘 / 某些编辑器下不可靠 === +// +// 用户的真实反馈:修改 todo.md 后软件没及时更新 —— 说明 fs.watch 没触发。 +// fs.watch 依赖 OS 文件通知 API,Dropbox / OneDrive / 远程盘映射 / 某些 +// 编辑器的非原子保存路径下会丢事件。250ms 一次的 statSync 是普适可靠兜底: +// - 比 500ms 体感响应快一倍(云盘场景下 fs.watch 失效时仍有可接受延迟) +// - 每秒 4 次 statSync 对一个静态文件的开销可忽略(mtime+size 比对,不读内容) +// +// 自写入抑制不归这里管:polling 检测到变化就发 IPC,渲染端 readFile 后与 +// store.serialize() 比对,字节相同视为自己写入的产物静默(v4 方案继续兜底)。 +// 这里只做:(1) 不在主进程层做过滤;(2) 主动写入后刷新基线让 polling 跳过那次变化。 +let lastSeenMtimeMs = 0; +let lastSeenSize = 0; +let pollTimer = null; +const POLL_INTERVAL_MS = 250; + +function recordFileStat(filePath) { + // 主进程刚写完文件后调用:让 polling 下一次 tick 看到 (mtime,size) 与 + // lastSeen 一致,不再误触发「外部修改」。fs.watch 因 v4 注释里的时序 + // 问题可能根本来不及响应,polling 的基线刷新是最可靠的兜底。 + try { + const st = fsSync.statSync(filePath); + lastSeenMtimeMs = st.mtimeMs; + lastSeenSize = st.size; + } catch { + /* 文件尚未落盘 / 已被外部删 —— 忽略,polling 下一轮会自己探测 */ + } +} + +function startPolling(filePath) { + stopPolling(); + // 初始化基线,避免刚启动时把「上次会话之前的 mtime」当成「外部修改」 + recordFileStat(filePath); + pollTimer = setInterval(() => { + if (!currentFilePath) return; + // v4+ 修复:窗口被最小化 / 隐藏时跳过 statSync。 + // 旧实现每 250ms 一次无脑 statSync —— 用户切到别的窗口、托盘里挂着、本应用最小化, + // CPU 仍每秒钟 4 次磁盘 IO。窗口回到前台时本轮 polling 自然会发现 mtime/size + // 变化(基线没刷新),与之前行为一致。 + // 兜底:窗口刚销毁(app 退出流程)也跳过,否则 BrowserWindow 已死、 + // isVisible() 会抛。 + if (mainWindow?.isDestroyed?.()) return; + if (mainWindow && (mainWindow.isMinimized() || !mainWindow.isVisible())) return; + let st; + try { + st = fsSync.statSync(currentFilePath); + } catch { + // 文件被外部删除 / 重命名等场景 —— 把基线清零让下一次 tick 把「文件被重建」 + // 视为外部修改。 + // + // 为什么不能直接 return + 保留旧 lastSeen: + // 若文件随后被外部以**同样的 mtime + size** 重建(delete + write 完全相同 + // 内容、文件系统沿用原 inode),下一轮 statSync 会拿到与 lastSeen 一致的结果 + // → polling 会以「和基线一致」为由漏报。这种 case 在云同步目录偶有发生 + // (客户端做去重优化时整文件删除 + 重建)。 + lastSeenMtimeMs = 0; + lastSeenSize = 0; + return; + } + if (st.mtimeMs === lastSeenMtimeMs && st.size === lastSeenSize) return; + lastSeenMtimeMs = st.mtimeMs; + lastSeenSize = st.size; + // 与 fs.watch 共用 dedup 窗口,避免 fs.watch 已在 400ms 内发过则重复 IPC + const now = Date.now(); + if (now - lastExternalChangeSentAt < EXTERNAL_CHANGE_DEDUP_MS) return; + lastExternalChangeSentAt = now; + // 增强载荷:把磁盘当前内容一并塞回去(v3.4+ 用户期望 —— 渲染端不用再发 file:read)。 + // setInterval 回调是 sync 函数,用 fire-and-forget 异步读取避免阻塞 polling。 + // 修复:在调度异步读取前捕获当前路径,避免 .then() 执行时 currentFilePath + // 已被 startWatchingFile 切换到新路径,导致路径与内容错配。 + const capturedPath = currentFilePath; + readCurrentDiskContent(capturedPath).then((diskContent) => { + mainWindow?.webContents.send('file:external-change', { + filePath: capturedPath, + diskContent, + }); + }); + }, POLL_INTERVAL_MS); +} + +function stopPolling() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } +} + +function startWatchingFile(filePath) { + stopWatchingFile(); + try { + fileWatcher = fsSync.watch(filePath, (eventType) => { + // 同时响应 'change'(内容修改)和 'rename'(重命名 / 原子替换)。 + // 很多外部编辑器(VSCode、Sublime 等)保存时采用「写到临时文件再 rename」, + // 在 Windows 上 fs.watch 通常只会触发 'rename'。旧实现只认 'change', + // 导致这些编辑器的保存永远唤不醒「外部修改」提示 —— 用户切回应用看到的 + // 还是旧内容,必须手动重载。 + if (eventType !== 'change' && eventType !== 'rename') return; + // 关键:触发时立刻 statSync 把 lastSeen 刷新到「当前磁盘状态」。 + // 这一步是 race 修复的核心: + // 旧实现:fs.watch 只发 IPC,不更新 lastSeen。polling 在 250ms 后才 + // 检测到同样的变化,期间 force=false 的 autoSave 拿陈旧的 lastSeen + // 做 mtime 检查,与 statSync 拿到的「外部编辑后」状态对比仍是「一致」—— + // 静默覆盖外部编辑。 + // 现在:fs.watch 触发时立刻 statSync 拿到新状态,更新 lastSeen。 + // 若 autoSave 的 statSync 在此之后跑到,对比「新 lastSeen」与「新磁盘 + // 状态」也一致 —— 但后续 IPC 已发给 renderer 让用户介入,所以即使 + // force=false 的写入跑通,也是「renderer 已知外部修改、用户尚未回复」 + // 的窗口期(< 100ms),数据丢失窗口被压缩到几乎不可见。 + // + // 自写入场景无副作用:自己写入后 recordFileStat 已经把 lastSeen 刷成 + // 「自己刚写入」的状态,fs.watch 在 rename 时再次触发 → 再 statSync + // 仍是同一份(rename 之后的磁盘状态)→ lastSeen 不变 → IPC 走 content 比对静默。 + try { + const st = fsSync.statSync(filePath); + lastSeenMtimeMs = st.mtimeMs; + lastSeenSize = st.size; + } catch { + // 文件被外部删除 / 锁住 —— 立刻清零基线,避免「文件被同 mtime+size 重建」 + // 时漏报(同 startPolling 的处理)。下次 polling tick 会再探测。 + lastSeenMtimeMs = 0; + lastSeenSize = 0; + } + // 不做自写入抑制 —— 见文件顶部三代方案的说明,fs.watch 时序无法支撑。 + // 一次外部保存可能在 fs.watch 里触发多个事件(Windows 尤其明显), + // 在源头做去重避免渲染端堆叠对话框 / IPC 风暴。 + const now = Date.now(); + if (now - lastExternalChangeSentAt < EXTERNAL_CHANGE_DEDUP_MS) return; + lastExternalChangeSentAt = now; + // 增强载荷:把磁盘当前内容一并塞回去(v3.4+ 用户期望 —— 渲染端不用再发 file:read)。 + // fs.watch 的 listener 是 sync 函数,用 fire-and-forget 异步读取避免阻塞 watch 调度。 + readCurrentDiskContent(filePath).then((diskContent) => { + mainWindow?.webContents.send('file:external-change', { + filePath, + diskContent, + }); + }); + }); + // v3.7+ 修复 M-P2:fs.watch 在 Windows 上遇到 EPERM / ENOENT 后会直接 close + // (不是再次触发事件)。整个 fileWatcher 实例变 dead,后续即使文件被 + // 重新创建,watcher 也不会重新监听 → 只剩 250ms polling 兜底,丢失实时性。 + // 修复:监听 'error' 事件,记录并重建 watcher。重建失败时 polling 仍可兜底。 + try { + fileWatcher.on('error', (err) => { + console.warn('[main] fs.watch error, 重建 watcher:', err && err.message); + const cachedPath = filePath; + try { fileWatcher && fileWatcher.close(); } catch {} + fileWatcher = null; + // 异步重建,避免在 'error' 回调里同步调用 fs.watch 引发二次报错 + setImmediate(() => startWatchingFile(cachedPath)); + }); + } catch { + // 极个别 Node 版本 fileWatcher.on 自身抛错(极罕见)—— 静默兜底 + } + } catch (e) { + console.warn('[main] 文件监听启动失败:', e.message); + } + // fs.watch 在云同步目录 / 网络盘 / 某些编辑器下不触发事件 —— 250ms 轮询兜底 + startPolling(filePath); +} + +function stopWatchingFile() { + if (fileWatcher) { + try { fileWatcher.close(); } catch {} + fileWatcher = null; + } + stopPolling(); +} + +// ============================================ +// IPC: 应用信息 +// ============================================ + +ipcMain.handle('app:get-data-dir', () => { + return resolveDataDir(); +}); + +ipcMain.handle('app:get-default-data-dir', () => DEFAULT_DATA_DIR); + +// 用户主目录:renderer 用来把绝对路径转成 `~/...` 显示形式。 +// 不暴露整个 fs,只读 home —— renderer 自己用 process.platform 检测不到 +// contextIsolation 下的用户目录。 +ipcMain.handle('app:get-home-dir', () => { + try { + return app.getPath('home'); + } catch { + return require('os').homedir(); + } +}); + +ipcMain.handle('app:get-version', () => app.getVersion()); + +ipcMain.handle('app:get-electron-version', () => process.versions.electron || null); + +// 打开外部链接 —— 由渲染端通过设置面板的「关于」区触发。 +// 必须经过主进程 + shell.openExternal:renderer 在 sandbox 下无权直接调 shell, +// 而且走 https/http(s) 协议白名单能挡住把 `file:` / `javascript:` 之类的危险协议 +// 塞进 href 后再回传给主进程的钓鱼路径。 +ipcMain.handle('app:open-external', async (_event, url) => { + if (typeof url !== 'string' || !url) return { ok: false, error: '链接无效' }; + let parsed; + try { + parsed = new URL(url); + } catch { + return { ok: false, error: '链接格式不正确' }; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return { ok: false, error: '仅支持 http / https 链接' }; + } + try { + await shell.openExternal(url); + return { ok: true }; + } catch (e) { + return { ok: false, error: e.message }; + } +}); + +ipcMain.handle('app:open-data-dir', async () => { + // 只允许打开真实存在且是目录的路径。防止渲染端把恶意字符串塞进 + // dataDir 设置后让 shell.openPath 跑去执行一个 .exe。 + let dir; + try { + dir = path.resolve(resolveDataDir()); + const st = await fs.stat(dir); + if (!st.isDirectory()) { + return { ok: false, error: '数据路径不是一个目录' }; + } + } catch (e) { + return { ok: false, error: e.message }; + } + try { + // shell.openPath 返回 Promise:成功时 resolve 空字符串,失败时 resolve + // 错误描述字符串(注意不是 reject)。必须 await 并检查返回值,否则错误会被 + // 静默吞掉,IPC 永远返回 { ok: true },用户看到按钮"点了没反应"却无从排查。 + const err = await shell.openPath(dir); + if (err) { + return { ok: false, error: err, path: dir }; + } + return { ok: true, path: dir }; + } catch (e) { + return { ok: false, error: e.message }; + } +}); + +// ============================================ +// IPC: 选择文件夹对话框 +// ============================================ + +ipcMain.handle('app:choose-data-dir', async () => { + if (!mainWindow) return null; + const result = await dialog.showOpenDialog(mainWindow, { + title: '选择数据文件夹', + properties: ['openDirectory', 'createDirectory'], + buttonLabel: '选择此文件夹', + }); + if (result.canceled || !result.filePaths.length) return null; + return result.filePaths[0]; +}); + +// ============================================ +// IPC: 窗口控制 +// ============================================ + +// 设置窗口是否始终置顶。 +// renderer 主动调 —— 启动时根据 settingsStore.alwaysOnTop 应用一次, +// 用户点击工具栏按钮时再 toggle。持久化由 app:save-settings 走。 +ipcMain.handle('window:set-always-on-top', (_event, enabled) => { + if (!mainWindow || mainWindow.isDestroyed()) return false; + // BrowserWindow.setAlwaysOnTop 接受布尔;非法值直接抛 TypeError。 + // 严格校验:仅 boolean 透传,字符串 / 数字 / null 等一律拒掉。 + if (typeof enabled !== 'boolean') { + console.warn('[main] setAlwaysOnTop 收到非布尔值:', enabled); + return false; + } + try { + mainWindow.setAlwaysOnTop(enabled); + return true; + } catch (e) { + console.error('[main] setAlwaysOnTop 失败:', e.message); + return false; + } +}); + +// 自定义窗口控制按钮(frame: false 后接管系统标题栏的最小化/还原/关闭)。 +// 注意:close 必须走与 close 事件一致的"隐藏到托盘"逻辑 —— 直接 mainWindow.close() +// 会被 mainWindow.on('close') 拦下隐藏,所以这里发信号走同一条路: +// 1) 主动退出(isQuitting=true)→ close 不拦截,正常销毁 +// 2) 默认(isQuitting=false)→ close 拦截 + hideWindow 隐藏到托盘 +// 与 X 按钮语义对齐:"最小化"是任务栏可见但前台不可见,"关闭"是托盘后台驻留。 +ipcMain.handle('window:minimize', () => { + if (!mainWindow || mainWindow.isDestroyed()) return false; + mainWindow.minimize(); + return true; +}); + +// 最大化/还原二合一按钮:根据当前状态切换,返回新的 isMaximized 给 renderer 同步图标。 +// 单独传 maximize / restore 会让 renderer 必须跟踪状态,状态机两边各持一份容易漂移。 +// 这里统一在主进程判断、返回最新值,renderer 收到后只负责把图标刷成返回值对应的样子。 +ipcMain.handle('window:toggle-maximize', () => { + if (!mainWindow || mainWindow.isDestroyed()) return false; + if (mainWindow.isMaximized()) { + mainWindow.unmaximize(); + } else { + mainWindow.maximize(); + } + return mainWindow.isMaximized(); +}); + +// 关闭按钮:与窗口右上角 X 同义 —— 默认隐藏到托盘(与 mainWindow.on('close') 一致), +// 仅在用户主动退出(isQuitting=true,托盘菜单/快捷键)时才会真正销毁。 +// 实现上不需要单独设 isQuitting:调用 mainWindow.close() 会被 close handler 拦下走托盘分支。 +ipcMain.handle('window:close', () => { + if (!mainWindow || mainWindow.isDestroyed()) return false; + mainWindow.close(); + return true; +}); + +// 把当前窗口的 isMaximized 状态推给 renderer(用于启动时按钮图标对齐,以及 +// 系统级最大化/还原时同步 UI —— 例如用户双击标题栏或 Win+Up 触发的最大化)。 +// 不走 invoke 是因为这是单向广播,且在 createWindow 之后就要推一次。 +function broadcastMaximizeState() { + if (!mainWindow || mainWindow.isDestroyed()) return; + mainWindow.webContents.send('window:maximize-state', mainWindow.isMaximized()); +} + +// ============================================ +// IPC: 应用设置(持久化) +// ============================================ + +ipcMain.handle('app:get-settings', () => appConfig); + +// 渲染进程 dirty 状态变化时通知主进程,用于退出前判断是否需要刷写保存。 +// 同时在 dirty 重新变为 true 时重置 saveFlushed,避免「退出 → 保存 → 取消 → 再改 → 再退出」 +// 场景下第二次退出跳过保存检查。 +ipcMain.on('app:dirty-changed', (_event, isDirty) => { + // 防御:renderer 发 null / 0 / "" / undefined 进来时,typeof !== 'boolean' 直接拒掉。 + // 否则 rendererDirty 会变成 falsy → before-quit 看到 rendererDirty && ... 判定为 false + // → 跳过刷写 → 直接退出 → 防抖窗口内未落盘的修改永久丢失。 + if (typeof isDirty !== 'boolean') { + console.warn('[main] app:dirty-changed 收到非 boolean 值:', typeof isDirty, isDirty); + return; + } + rendererDirty = isDirty; + // v3.7+ 修复 M-P1:flushInProgress 期间不重置 saveFlushed。 + // 旧行为是 isDirty=true 无条件 saveFlushed=false —— 如果用户在 before-quit + // 触发的 flush 期间(flushInProgress=true)又敲键盘,dirty-changed=true 会把 + // saveFlushed 提前重置;其后 flush-done 回调第一段由于 !rendererDirty 不成立 + // 进入再 flush 分支 → saveFlushed 又被重置 → app.quit() 5s timeout 兜底放过, + // 用户最后敲的内容可能落盘失败。 + // 修复:flushInProgress 期间收到的 dirty=true 不重置 saveFlushed,等 flush-done + // 回调内统一处理(flush-done 自己会重核 rendererDirty 并决定是否再 flush)。 + if (isDirty && !flushInProgress) saveFlushed = false; +}); + +// 渲染进程完成刷写保存后通知主进程,立即放行退出(无需等待 5 秒超时)。 +// 必须守卫 flushInProgress:渲染进程在 before-quit 之外任何时刻发的 flush-done +// 都不应触发 app.quit(),否则一旦这条 IPC 失序到达,整个应用会被无条件退出, +// 且把 saveFlushed 提前设上让下一次真正退出跳过刷写检查(数据丢失)。 +// +// 兜底:渲染端在 await saveNow 期间又改东西(rendererDirty 在我们处理完 flush-done +// 后才上报),单次 saveNow 只写入了旧 snapshot,dirty-changed 会把 saveFlushed 重置 +// 但 quit 已经发生 —— 新编辑凭空消失。处理这条 IPC 时再核一次 rendererDirty, +// 仍脏就再起一轮 flush。两个通道(dirty-changed / flush-done)独立排队、不保证 +// 到达顺序,所以「flush-done 收到时 dirty 仍为 true」才是真相 —— 渲染端在 +// onFlushPendingSave 里的多轮 saveNow 已经把这条窗口压缩到最小。 +ipcMain.on('app:flush-done', () => { + if (!flushInProgress) return; + flushInProgress = false; + if (flushTimeoutHandle) { + clearTimeout(flushTimeoutHandle); + flushTimeoutHandle = null; + } + if (rendererDirty && mainWindow && !mainWindow.isDestroyed()) { + // 渲染端仍脏 —— 再 flush 一次,不 quit + saveFlushed = false; + flushInProgress = true; + mainWindow.webContents.send('app:flush-pending-save'); + flushTimeoutHandle = setTimeout(() => { + flushTimeoutHandle = null; + saveFlushed = true; + flushInProgress = false; + app.quit(); + }, 5000); + return; + } + saveFlushed = true; + app.quit(); +}); + +ipcMain.handle('app:save-settings', async (event, partial) => { + // 防御:渲染端发了 null / undefined / 原始类型进来时,'dataDir' in partial 会抛 + // TypeError 并把主进程栈泄给 renderer;白名单遍历也都会崩。直接当无效请求拒掉。 + if (!partial || typeof partial !== 'object') return appConfig; + // 仅持久化已知的设置项,并对每项做白名单校验,防止 renderer 写入恶意值 + const sanitized = {}; + if ('dataDir' in partial) { + // null = 恢复默认;非空字符串 = 用户自定义目录;其它一律忽略 + if (partial.dataDir === null) { + sanitized.dataDir = null; + // 数据目录改变 → 旧目录的「今天的备份」对新目录毫无意义。 + // 强制下次操作重新触发备份,让新目录也能享受今天的快照。 + // (主进程自己写 lastBackupDate 走 saveConfig,不走 IPC 白名单 —— 这里一并清掉) + sanitized.lastBackupDate = null; + } else if (typeof partial.dataDir === 'string') { + // 防御:dataDir 是 assertInDataDir 的信任根 —— 渲染端把它设为 '/etc', + // 后续 file:read('/etc/passwd') 就能逃出"用户数据目录"沙箱。强制要求: + // - 绝对路径(避免相对路径在不同 cwd 下绕过) + // - path.normalize 后没有 .. 残留(path.resolve 已处理;这里再防御一道) + // - 真实存在的目录(renderer 选了不存在的目录会立即暴露,UX 上也不该静默接受) + // 正常路径:renderer 走 chooseDataDir IPC(native dialog),那里必然存在且绝对。 + // 这层校验只挡被入侵的 renderer(XSS 注入恶意 settings 调用)—— 不挡正常用户流。 + // + // v4+ 修复:旧实现是「校验失败 → console.warn 静默吞掉」,于是 renderer 的 + // settingsStore.update() 看不到失败(IPC 返回 appConfig 旧值、Promise resolve), + // 用户在 settings dialog 里选了目录、看到「✓ 已保存」、磁盘却是旧值,重启后回到旧 + // 目录才会察觉。现在改为:校验失败直接抛错,让 IPC reject → settingsStore._persist + // 返回 false → emit 'save-error' → toolbar.js 的 save-error 监听器 toast 报错。 + // + // 安全审计 H1 修复:增加允许根目录白名单。即便 renderer 被攻陷也最多写到 + // 用户已知位置,不会被重定向到 /etc、/var/lib 之类的系统目录。 + // 白名单:用户主目录、文档、下载、桌面、AppData。允许子目录(含一层新建的)。 + const allowedRoots = []; + try { allowedRoots.push(app.getPath('home')); } catch {} + try { allowedRoots.push(app.getPath('documents')); } catch {} + try { allowedRoots.push(app.getPath('downloads')); } catch {} + try { allowedRoots.push(app.getPath('desktop')); } catch {} + try { allowedRoots.push(app.getPath('userData')); } catch {} + try { allowedRoots.push(app.getPath('appData')); } catch {} + const resolved = path.resolve(partial.dataDir); + if (resolved !== path.normalize(resolved)) { + throw new Error(`数据目录路径不安全:${partial.dataDir}`); + } + // 解析符号链接后再次校验 —— 防止用 symlink 间接绕过白名单。 + // realpathSync 失败(路径不存在)由下面的 existsSync 兜底。 + let realResolved = resolved; + try { realResolved = fsSync.realpathSync(resolved); } catch {} + const inAllowedRoot = allowedRoots.some(root => { + const r = root.toLowerCase(); + const p = realResolved.toLowerCase(); + return p === r || p.startsWith(r + path.sep); + }); + if (!inAllowedRoot) { + throw new Error(`数据目录必须在用户已知目录内:${resolved}`); + } + if (!fsSync.existsSync(resolved) || !fsSync.statSync(resolved).isDirectory()) { + throw new Error(`数据目录不存在或不是文件夹:${resolved}`); + } + sanitized.dataDir = resolved; + sanitized.lastBackupDate = null; + } + } + if ('theme' in partial && ['dark', 'light', 'auto'].includes(partial.theme)) { + sanitized.theme = partial.theme; + } + if ('filter' in partial && ['all', 'current', 'completed', 'important'].includes(partial.filter)) { + // v3.7+ 修复:旧白名单用的是「pending」(v3.x 之前 PENDING 语义)。 + // 现在 settings-store.js 的 VALID_VALUES 是 ['all', 'current', 'completed', 'important'] + // (v3.x 起 PENDING 被替换为 CURRENT,「当前」语义对齐智能列表)。 + // 旧白名单下 renderer 发 'current' 会被这里**静默丢弃**——用户的「当前任务」 + // 全局过滤偏好重启就回到默认。修齐白名单。 + sanitized.filter = partial.filter; + } + if ('sortBy' in partial && ['manual', 'alphabet'].includes(partial.sortBy)) { + sanitized.sortBy = partial.sortBy; + } + if ('fontSize' in partial && ['small', 'medium', 'large'].includes(partial.fontSize)) { + // 之前白名单漏了 fontSize:SettingsStore 内存里写、UI 也立刻应用, + // 但主进程落盘时把它静默丢掉了 —— 重启后字号回到 medium。补上。 + sanitized.fontSize = partial.fontSize; + } + if ('colorStyle' in partial && ['indigo', 'ocean', 'forest', 'sunset', 'rose', 'mono'].includes(partial.colorStyle)) { + // 配色风格:与 theme 正交。同样以前漏过,UI 选了 ocean 重启后回到 indigo。 + sanitized.colorStyle = partial.colorStyle; + } + if ('backupEnabled' in partial && typeof partial.backupEnabled === 'boolean') { + // 每日备份开关:renderer 通过 settingsStore.update 写入,主进程只接收 boolean + sanitized.backupEnabled = partial.backupEnabled; + } + // 六个 *Position 设置(任务插入位置偏好):front | back。 + // 与 settings-store.js 的 VALID_VALUES 对齐 —— 渲染端已校验过,主进程再守一道 + // 防止配置文件被直接编辑成脏值。白名单缺失会导致「UI 选了生效、重启回默认」。 + for (const key of ['newTaskPosition', 'completedPosition', 'uncompletePosition', 'trashPosition', 'restorePosition', 'movePosition']) { + if (key in partial && (partial[key] === 'front' || partial[key] === 'back')) { + sanitized[key] = partial[key]; + } + } + // 导入顺序偏好:order | front(与五个 front/back 不同,不在此循环处理) + if ('importPosition' in partial && (partial.importPosition === 'order' || partial.importPosition === 'front')) { + sanitized.importPosition = partial.importPosition; + } + if ('alwaysOnTop' in partial && typeof partial.alwaysOnTop === 'boolean') { + // 始终置顶开关:与 BrowserWindow.setAlwaysOnTop 同步。 + // 注意此 IPC 仅落盘,窗口状态由 renderer 在 onStartup 时主动调 setAlwaysOnTop 应用。 + const valueChanged = appConfig.alwaysOnTop !== partial.alwaysOnTop; + sanitized.alwaysOnTop = partial.alwaysOnTop; + // v4+ 修复:先 saveConfig 让磁盘先落地,再应用窗口副作用。 + // 旧顺序下 saveConfig 失败时(磁盘满 / 权限不足),托盘菜单 / 窗口 setAlwaysOnTop + // 已经按新值执行 → 用户看到托盘菜单「已勾选」+ 窗口置顶,但磁盘仍是旧值; + // 下次启动 appConfig 加载旧值 → 托盘/窗口都回滚、用户一脸懵。 + // 修正顺序:saveConfig 失败则不进入副作用分支,菜单 / 窗口与磁盘三方一致。 + const committed = await saveConfig(sanitized); + if (valueChanged) { + // 仅在真变化时调 setAlwaysOnTop + 重建托盘菜单。 + // 反复保存相同值会无谓触发窗口属性重设(可能引发平台层闪烁)和 + // Tray.setContextMenu(Windows 上偶尔有可见菜单项重绘抖动)。 + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.setAlwaysOnTop(committed.alwaysOnTop); + } + if (tray) { + tray.setContextMenu(buildTrayMenu()); + } + } + return committed; + } + return await saveConfig(sanitized); +}); + +// ============================================ +// 应用菜单 +// ============================================ + +function buildMenu() { + const isMac = process.platform === 'darwin'; + const template = [ + ...(isMac ? [{ + label: app.name, + submenu: [ + { role: 'about' }, + { type: 'separator' }, + { role: 'quit' } + ] + }] : []), + { + label: '文件', + submenu: [ + { + label: '保存', + accelerator: 'CmdOrCtrl+S', + click: () => mainWindow?.webContents.send('menu:save') + }, + { type: 'separator' }, + isMac ? { role: 'close' } : { role: 'quit' } + ] + }, + { + label: '编辑', + submenu: [ + { role: 'undo', label: '撤销' }, + { role: 'redo', label: '重做' }, + { type: 'separator' }, + { role: 'cut', label: '剪切' }, + { role: 'copy', label: '复制' }, + { role: 'paste', label: '粘贴' }, + { role: 'selectAll', label: '全选' } + ] + }, + { + label: '视图', + submenu: [ + { + label: '切换主题', + accelerator: 'CmdOrCtrl+Shift+T', + click: () => mainWindow?.webContents.send('menu:toggle-theme') + }, + { type: 'separator' }, + { + label: '设置...', + accelerator: 'CmdOrCtrl+,', + click: () => mainWindow?.webContents.send('menu:settings') + }, + { type: 'separator' }, + { + label: '重新加载', + accelerator: 'CmdOrCtrl+R', + click: () => mainWindow?.webContents.send('menu:reload') + }, + { type: 'separator' }, + { + label: '开发者工具', + accelerator: 'F12', + click: () => mainWindow?.webContents.toggleDevTools() + } + ] + } + ]; + + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..15f1cc2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4127 @@ +{ + "name": "todo-list", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "todo-list", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "electron": "^28.2.0", + "electron-builder": "^24.9.1", + "playwright-core": "^1.62.1" + } + }, + "node_modules/@develar/schema-utils": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", + "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.0", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@electron/notarize": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.2.1.tgz", + "integrity": "sha512-aL+bFMIkpR0cmmj5Zgy0LMKEpgy43/hw5zadEArgmAMWWlKc5buwFvFT9G/o/YJkvXAJm5q3iuTuLaiaXW39sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/notarize/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.0.5.tgz", + "integrity": "sha512-k9ZzUQtamSoweGQDV2jILiRIHUu7lYlJ3c6IEmjv1hC17rclE+eb9U+f6UFlOOETo0JzY1HNlXy4YOlCvl+Lww==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/osx-sign/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/osx-sign/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/universal": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-1.5.1.tgz", + "integrity": "sha512-kbgXxyEauPJiQQUNG2VgUeyfQNFk6hBF11ISN2PNI6agUgPl55pv4eQmaqHzTAzchBvqZ2tQuRVaPStGf0mxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.2.1", + "@malept/cross-spawn-promise": "^1.1.0", + "debug": "^4.3.1", + "dir-compare": "^3.0.0", + "fs-extra": "^9.0.1", + "minimatch": "^3.0.4", + "plist": "^3.0.4" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/universal/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/universal/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", + "integrity": "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/plist": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", + "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/verror": { + "version": "1.10.11", + "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", + "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz", + "integrity": "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "node_modules/7zip-bin": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", + "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-bin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-4.0.0.tgz", + "integrity": "sha512-xwdG0FJPQMe0M0UA4Tz0zEB8rBJTRA5a476ZawAqiBkMv16GRK5xpXThOjMaEOFnZ6zabejjG4J3da0SXG63KA==", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-lib": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-24.13.3.tgz", + "integrity": "sha512-FAzX6IBit2POXYGnTCT8YHFO/lr5AapAII6zzhQO3Rw4cEDOgK+t1xhLc5tNcKlicTHlo9zxIwnYCX9X2DLkig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/notarize": "2.2.1", + "@electron/osx-sign": "1.0.5", + "@electron/universal": "1.5.1", + "@malept/flatpak-bundler": "^0.4.0", + "@types/fs-extra": "9.0.13", + "async-exit-hook": "^2.0.1", + "bluebird-lst": "^1.0.9", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "chromium-pickle-js": "^0.2.0", + "debug": "^4.3.4", + "ejs": "^3.1.8", + "electron-publish": "24.13.1", + "form-data": "^4.0.0", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "is-ci": "^3.0.0", + "isbinaryfile": "^5.0.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "minimatch": "^5.1.1", + "read-config-file": "6.3.2", + "sanitize-filename": "^1.6.3", + "semver": "^7.3.8", + "tar": "^6.1.12", + "temp-file": "^3.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "24.13.3", + "electron-builder-squirrel-windows": "24.13.3" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/bluebird-lst": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/bluebird-lst/-/bluebird-lst-1.0.9.tgz", + "integrity": "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "^3.5.5" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-1.0.1.tgz", + "integrity": "sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "24.13.1", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-24.13.1.tgz", + "integrity": "sha512-NhbCSIntruNDTOVI9fdXz0dihaqX2YuE1D6zZMrwiErzH4ELZHE6mdiB40wEgZNprDia+FghRFgKoAqMZRRjSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "4.0.0", + "bluebird-lst": "^1.0.9", + "builder-util-runtime": "9.2.4", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.3", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-ci": "^3.0.0", + "js-yaml": "^4.1.0", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.4.tgz", + "integrity": "sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/builder-util/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/builder-util/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/builder-util/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-file-ts": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.6.tgz", + "integrity": "sha512-6boGVaglwblBgJqGyxm4+xCmEGcWgnWHSWHY5jad58awQhB6gftq0G8HbzU39YqCIYHMLAiL1yjwiZ36m/CL8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.3.10", + "typescript": "^5.3.3" + } + }, + "node_modules/config-file-ts/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/crc": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", + "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.1.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/dir-compare": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-3.3.0.tgz", + "integrity": "sha512-J7/et3WlGUCxjdnD3HAAzQ6nsnc0WL6DD7WcwJb7c39iH1+AWfg+9OqzJNaI6PkBwBvm1mhZNL9iY/nRiZXlPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal": "^1.0.0", + "minimatch": "^3.0.4" + } + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-24.13.3.tgz", + "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "24.13.3", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "fs-extra": "^10.1.0", + "iconv-lite": "^0.6.2", + "js-yaml": "^4.1.0" + }, + "optionalDependencies": { + "dmg-license": "^1.0.11" + } + }, + "node_modules/dmg-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dmg-builder/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/dmg-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/dmg-license": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", + "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "@types/plist": "^3.0.1", + "@types/verror": "^1.10.3", + "ajv": "^6.10.0", + "crc": "^3.8.0", + "iconv-corefoundation": "^1.1.7", + "plist": "^3.0.4", + "smart-buffer": "^4.0.2", + "verror": "^1.10.0" + }, + "bin": { + "dmg-license": "bin/dmg-license.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz", + "integrity": "sha512-I9OvvrHp4pIARv4+x9iuewrWycX6CcZtoAu1XrzPxc5UygMJXJZYmBsynku8IkrJwgypE5DGNjDPmPRhDCptUg==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=10" + } + }, + "node_modules/dotenv-expand": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", + "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "28.3.3", + "resolved": "https://registry.npmjs.org/electron/-/electron-28.3.3.tgz", + "integrity": "sha512-ObKMLSPNhomtCOBAxFS8P2DW/4umkh72ouZUlUKzXGtYuPzgr1SYhskhFWgzAsPtUzhL2CzyV2sfbHcEW4CXqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^18.11.18", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-builder": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-24.13.3.tgz", + "integrity": "sha512-yZSgVHft5dNVlo31qmJAe4BVKQfFdwpRw7sFp1iQglDRCDD6r22zfRJuZlhtB5gp9FHUxCMEoWGq10SkCnMAIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "24.13.3", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "chalk": "^4.1.2", + "dmg-builder": "24.13.3", + "fs-extra": "^10.1.0", + "is-ci": "^3.0.0", + "lazy-val": "^1.0.5", + "read-config-file": "6.3.2", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-24.13.3.tgz", + "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "24.13.3", + "archiver": "^5.3.1", + "builder-util": "24.13.1", + "fs-extra": "^10.1.0" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-publish": { + "version": "24.13.1", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-24.13.1.tgz", + "integrity": "sha512-2ZgdEqJ8e9D17Hwp5LEq5mLQPjqU3lv/IALvgp+4W8VeNhryfGhYEQC/PgDPMrnWUp+l60Ou5SJLsu+k4mhQ8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "builder-util": "24.13.1", + "builder-util-runtime": "9.2.4", + "chalk": "^4.1.2", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-publish/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-publish/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-publish/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-corefoundation": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", + "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "cli-truncate": "^2.1.0", + "node-addon-api": "^1.6.3" + }, + "engines": { + "node": "^8.11.2 || >=10" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-ci": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", + "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ci-info": "^3.2.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/plist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-config-file": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/read-config-file/-/read-config-file-6.3.2.tgz", + "integrity": "sha512-M80lpCjnE6Wt6zb98DoW8WHR09nzMSpu8XHtPkiTHrJ5Az9CybfeQhTJ8D7saeBHpGhLPIVyA8lcL6ZmdKwY6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-file-ts": "^0.2.4", + "dotenv": "^9.0.2", + "dotenv-expand": "^5.1.0", + "js-yaml": "^4.1.0", + "json5": "^2.2.0", + "lazy-val": "^1.0.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/temp-file/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/temp-file/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/temp-file/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7f84a23 --- /dev/null +++ b/package.json @@ -0,0 +1,121 @@ +{ + "name": "todo-list", + "version": "1.0.0", + "description": "简洁的 Markdown Todo 桌面应用 - Markdown 是真相之源,软件是友好外壳", + "main": "main.js", + "scripts": { + "start": "node scripts/launch.js", + "start:raw": "electron .", + "check": "node scripts/check-imports.mjs && node scripts/check-delete-category.mjs && node scripts/check-category-guards.mjs && node scripts/check-category-name-chars.mjs && node scripts/check-trash.mjs && node scripts/check-trash-fuzz.mjs && node scripts/check-markers.mjs && node scripts/check-settings.mjs && node scripts/check-backup.mjs && node scripts/check-dirty-race.mjs && node scripts/check-status-meta.mjs && node scripts/check-polling-fallback.mjs && node scripts/check-external-change-path.mjs && node scripts/check-write-race.mjs && node scripts/check-concurrent-edit-race.mjs && node scripts/check-import-tasks.mjs && node scripts/check-batch-ops.mjs && node scripts/check-render-flow.mjs && node scripts/check-completed-order.mjs && node scripts/check-completed-only.mjs && node scripts/check-completed-context-menu.mjs && node scripts/check-trash-context-menu.mjs && node scripts/check-original-category.mjs && node scripts/check-conflict-merge.mjs && node scripts/check-task-dirty-tracking.mjs && node scripts/check-snapshot-creation.mjs && node scripts/check-uncategorized-duplicate.mjs && node scripts/check-uncategorized-position.mjs && node scripts/check-md-ui-sync.mjs && node scripts/check-move-invariance.mjs && node scripts/check-marker-tail.mjs && node scripts/check-picker-timeout.mjs && node scripts/check-conflict-default.mjs && node scripts/check-assert-in-data-dir.mjs && node scripts/check-save-config-rollback.mjs && node scripts/check-commit-active-edit.mjs && node scripts/check-insert-position.mjs && node scripts/check-filter.mjs && node scripts/check-fade-in-stability.mjs && node scripts/check-modal-close-animation.mjs && node scripts/check-ime-guard.mjs && node scripts/check-cjk-autospace.mjs && node scripts/check-click-after-drag.mjs && node scripts/check-second-pass-audit.mjs && node scripts/check-third-pass-audit.mjs && node scripts/smoke-order-and-edge.mjs && node scripts/smoke-ui-parity.mjs", + "check:imports": "node scripts/check-imports.mjs", + "check:category": "node scripts/check-delete-category.mjs", + "check:guards": "node scripts/check-category-guards.mjs", + "check:name-chars": "node scripts/check-category-name-chars.mjs", + "check:trash": "node scripts/check-trash.mjs", + "check:fuzz": "node scripts/check-trash-fuzz.mjs", + "check:markers": "node scripts/check-markers.mjs", + "check:settings": "node scripts/check-settings.mjs", + "check:backup": "node scripts/check-backup.mjs", + "check:dirty-race": "node scripts/check-dirty-race.mjs", + "check:status-meta": "node scripts/check-status-meta.mjs", + "check:polling": "node scripts/check-polling-fallback.mjs", + "check:external-path": "node scripts/check-external-change-path.mjs", + "check:write-race": "node scripts/check-write-race.mjs", + "check:concurrent": "node scripts/check-concurrent-edit-race.mjs", + "check:import-tasks": "node scripts/check-import-tasks.mjs", + "check:batch": "node scripts/check-batch-ops.mjs", + "check:render-flow": "node scripts/check-render-flow.mjs", + "check:completed-order": "node scripts/check-completed-order.mjs", + "check:completed-only": "node scripts/check-completed-only.mjs", + "check:completed-menu": "node scripts/check-completed-context-menu.mjs", + "check:trash-menu": "node scripts/check-trash-context-menu.mjs", + "check:original-category": "node scripts/check-original-category.mjs", + "check:conflict-merge": "node scripts/check-conflict-merge.mjs", + "check:dirty-tracking": "node scripts/check-task-dirty-tracking.mjs", + "check:snapshot": "node scripts/check-snapshot-creation.mjs", + "check:uncat-dup": "node scripts/check-uncategorized-duplicate.mjs", + "check:uncat-position": "node scripts/check-uncategorized-position.mjs", + "check:md-ui-sync": "node scripts/check-md-ui-sync.mjs", + "check:move-invariance": "node scripts/check-move-invariance.mjs", + "check:marker-tail": "node scripts/check-marker-tail.mjs", + "check:picker-timeout": "node scripts/check-picker-timeout.mjs", + "check:conflict-default": "node scripts/check-conflict-default.mjs", + "check:assert-in-data-dir": "node scripts/check-assert-in-data-dir.mjs", + "check:save-config": "node scripts/check-save-config-rollback.mjs", + "check:commit-active-edit": "node scripts/check-commit-active-edit.mjs", + "check:insert-position": "node scripts/check-insert-position.mjs", + "check:filter": "node scripts/check-filter.mjs", + "check:fade-stability": "node scripts/check-fade-in-stability.mjs", + "check:modal-animation": "node scripts/check-modal-close-animation.mjs", + "check:ime-guard": "node scripts/check-ime-guard.mjs", + "check:cjk-autospace": "node scripts/check-cjk-autospace.mjs", + "check:click-after-drag": "node scripts/check-click-after-drag.mjs", + "check:second-pass-audit": "node scripts/check-second-pass-audit.mjs", + "check:third-pass-audit": "node scripts/check-third-pass-audit.mjs", + "check:extra": "node scripts/check-completed-consolidate.mjs && node scripts/check-menu-fontsize.mjs", + "check:order": "node scripts/smoke-order-and-edge.mjs", + "check:parity": "node scripts/smoke-ui-parity.mjs", + "verify:ui": "node scripts/verify-batch-move-menu.mjs", + "verify:batch-special": "node scripts/verify-batch-trash-completed.mjs", + "verify:reload": "node scripts/verify-reload-ui.mjs", + "verify:e2e": "node scripts/verify-todo-end-to-end.mjs", + "build": "electron-builder", + "build:win": "electron-builder --win", + "build:mac": "electron-builder --mac", + "build:linux": "electron-builder --linux" + }, + "keywords": [ + "todo", + "markdown", + "electron", + "desktop" + ], + "author": "", + "license": "MIT", + "devDependencies": { + "electron": "^28.2.0", + "electron-builder": "^24.9.1", + "playwright-core": "^1.62.1" + }, + "build": { + "appId": "com.todolist.app", + "productName": "Todo List", + "directories": { + "output": "dist" + }, + "files": [ + "main.js", + "preload.js", + "index.html", + "src/**/*", + "icon.ico" + ], + "win": { + "icon": "icon.ico", + "target": [ + { + "target": "nsis", + "arch": [ + "x64" + ] + } + ] + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "createDesktopShortcut": true, + "createStartMenuShortcut": true, + "installerIcon": "icon.ico", + "uninstallerIcon": "icon.ico" + }, + "mac": { + "target": "dmg", + "category": "public.app-category.productivity" + }, + "linux": { + "target": "AppImage", + "category": "Office" + } + } +} diff --git a/preload.js b/preload.js new file mode 100644 index 0000000..fc16e7f --- /dev/null +++ b/preload.js @@ -0,0 +1,105 @@ +// 安全的 IPC 桥 +// 通过 contextBridge 暴露最小化的 API 给渲染进程 + +const { contextBridge, ipcRenderer } = require('electron'); + +// 渲染端的 dev 模式开关 —— preload 跑在 Node 上下文,可以同步读 process.env / process.argv。 +// 与 main.js:416 的 dev 检测同款条件(NODE_ENV=development 或 --dev 参数),保持单一事实来源。 +// renderer 用它开关 [sync] 控制台日志(dev 模式打印、生产静默),不影响功能。 +const IS_DEV = process.env.NODE_ENV === 'development' || process.argv.includes('--dev'); + +contextBridge.exposeInMainWorld('api', { + // 同步字段:渲染端在打 [sync] 日志前用 window.api.isDev 判断,避免生产环境控制台噪音 + isDev: IS_DEV, + // 文件操作 + // 注:不暴露「打开文件对话框」—— UI 上已去掉新建/打开文件的入口, + // 文件位置只通过设置里的数据目录决定 + saveFileDialog: () => ipcRenderer.invoke('file:save-dialog'), + readFile: (filePath) => ipcRenderer.invoke('file:read', filePath), + // options.force = true 跳过主进程的"防覆盖外部修改"mtime 检查, + // 用于用户已明确同意覆盖外部编辑的场景("保留本地" 按钮 / 手动 Ctrl+S)。 + // 自动保存默认 force=false,被主进程拒绝时返回 EXTERNAL_CHANGE_DETECTED, + // 由渲染端走冲突解决流程。 + writeFile: (filePath, content, options = {}) => + ipcRenderer.invoke('file:write', { filePath, content, options }), + fileExists: (filePath) => ipcRenderer.invoke('file:exists', filePath), + createFileIfMissing: (filePath, content) => ipcRenderer.invoke('file:create-if-missing', { filePath, content }), + createSnapshot: (filePath, label) => ipcRenderer.invoke('file:create-snapshot', { filePath, label }), + + // 应用信息 + getDataDir: () => ipcRenderer.invoke('app:get-data-dir'), + getDefaultDataDir: () => ipcRenderer.invoke('app:get-default-data-dir'), + // 用户主目录 —— renderer 用来把绝对路径转成 `~/...` 显示形式。 + // 跟 getDataDir / getDefaultDataDir 配套(都是路径信息,权限边界一致)。 + getHomeDir: () => ipcRenderer.invoke('app:get-home-dir'), + getVersion: () => ipcRenderer.invoke('app:get-version'), + getElectronVersion: () => ipcRenderer.invoke('app:get-electron-version'), + openDataDir: () => ipcRenderer.invoke('app:open-data-dir'), + // 打开外部链接(设置面板「关于」区的开发者主页链接用) + // 主进程端做协议白名单(仅 http / https),防 file: / javascript: 等危险协议 + openExternal: (url) => ipcRenderer.invoke('app:open-external', url), + + // 设置 + getSettings: () => ipcRenderer.invoke('app:get-settings'), + saveSettings: (partial) => ipcRenderer.invoke('app:save-settings', partial), + chooseDataDir: () => ipcRenderer.invoke('app:choose-data-dir'), + + // 退出前保存:通知主进程 dirty 状态变化、接收刷写指令 + notifyDirtyChanged: (isDirty) => ipcRenderer.send('app:dirty-changed', isDirty), + notifyFlushDone: () => ipcRenderer.send('app:flush-done'), + onFlushPendingSave: (callback) => { + const handler = () => callback(); + ipcRenderer.on('app:flush-pending-save', handler); + return () => ipcRenderer.removeListener('app:flush-pending-save', handler); + }, + + // 事件监听(来自主进程) + // 路由表:主进程菜单 / 快捷键 → 渲染端处理器。新增菜单项时**必须**同步加这里, + // 否则 main.js 发了 IPC 渲染端却没人监听,菜单点击会静默无效(v4 调试记录)。 + onMenuCommand: (callback) => { + const events = ['menu:save', 'menu:reload', 'menu:toggle-theme', 'menu:settings']; + const handlers = events.map(name => { + const handler = () => callback(name); + ipcRenderer.on(name, handler); + return { name, handler }; + }); + return () => handlers.forEach(({ name, handler }) => ipcRenderer.removeListener(name, handler)); + }, + + onFileExternalChange: (callback) => { + // 载荷从 string(filePath)升级为 {filePath, diskContent}(v3.4+ 增强) + const handler = (_event, payload) => callback(payload); + ipcRenderer.on('file:external-change', handler); + return () => ipcRenderer.removeListener('file:external-change', handler); + }, + + // 窗口控制 + // setAlwaysOnTop:立即应用置顶状态(不落盘,由调用方走 saveSettings 持久化)。 + // 返回 boolean 表示是否应用成功——主进程在窗口不存在/被销毁时返回 false。 + setAlwaysOnTop: (enabled) => ipcRenderer.invoke('window:set-always-on-top', enabled), + + // 始终置顶状态变更通知(来自主进程,比如托盘菜单点击), + // toolbar.js 订阅这个事件以同步按钮的激活态。 + onAlwaysOnTopChanged: (callback) => { + const handler = (_event, enabled) => callback(enabled); + ipcRenderer.on('always-on-top:changed', handler); + return () => ipcRenderer.removeListener('always-on-top:changed', handler); + }, + + // 自定义窗口控制(frame: false 后接管系统标题栏) + // minimizeWindow:最小化到任务栏 + minimizeWindow: () => ipcRenderer.invoke('window:minimize'), + // toggleMaximizeWindow:在最大化/还原之间切换,返回新的 isMaximized 状态, + // renderer 拿到返回值后只负责把按钮图标刷成对应样式,避免在 renderer 端维护状态副本。 + toggleMaximizeWindow: () => ipcRenderer.invoke('window:toggle-maximize'), + // closeWindow:与窗口右上角 X 同义 —— 默认隐藏到托盘(除非用户主动退出) + closeWindow: () => ipcRenderer.invoke('window:close'), + + // 窗口最大化状态变化通知(来自主进程 maximize / unmaximize / ready-to-show), + // toolbar.js 订阅这个事件以同步按钮图标(最大化态 ↔ 还原态)。 + onMaximizeStateChanged: (callback) => { + const handler = (_event, isMaximized) => callback(isMaximized); + ipcRenderer.on('window:maximize-state', handler); + return () => ipcRenderer.removeListener('window:maximize-state', handler); + } +}); diff --git a/scripts/_lib/api-stub.mjs b/scripts/_lib/api-stub.mjs new file mode 100644 index 0000000..4818abc --- /dev/null +++ b/scripts/_lib/api-stub.mjs @@ -0,0 +1,65 @@ +// 跨脚本共享:window.api 桩。 +// +// 用途:TaskStore / SettingsStore 等模块在模块加载时读 window.api.*,没有 IPC +// preload 环境时直接抛 undefined。之前 4+ 个脚本各自复制一份 6 行 stub, +// 容易漏字段或拼写漂移。 +// +// 用法: +// import { installApiStub } from './_lib/api-stub.mjs'; +// installApiStub(); // 默认桩 +// installApiStub({ // 自定义部分方法 +// writeFile: async () => ({ ok: true }), +// }); +// // 测试中要临时改某个方法: +// globalThis.window.api.readFile = async () => ({ ok: true, content: '...' }); + +/** + * 默认 stub:所有方法返回「无害成功」值,让 TaskStore / SettingsStore 不抛错。 + * + * 默认实现按「最小可运行」原则写:writeFile/readFile 返回 {ok:true},on* 返回 + * noop unsubscribe,notify* 返回 undefined。 + * 需要断言具体调用的脚本可以覆盖。 + */ +export function installApiStub(overrides = {}) { + globalThis.window = globalThis.window || {}; + const api = { + // 文件 IO —— TaskStore._autoSave / file-* IPC 用 + writeFile: async () => ({ ok: true }), + readFile: async () => ({ ok: true, content: '' }), + fileExists: async () => true, + createIfMissing: async () => ({ ok: true }), + createSnapshot: async () => ({ ok: true, snapshotPath: '' }), + + // 事件订阅 —— on* 模式返回 unsubscribe + onFileExternalChange: () => () => {}, + onMenuCommand: () => () => {}, + onFlushPendingSave: () => () => {}, + onAlwaysOnTopChanged: () => () => {}, + onMaximizeStateChanged: () => () => {}, + onDirtyChanged: () => () => {}, + + // 单向通知 —— preload 提供给 renderer 主动调 + notifyDirtyChanged: () => {}, + notifyFlushDone: () => {}, + + // 设置存储 + getSettings: async () => ({}), + saveSettings: async () => ({}), + + // 窗口控制 + setAlwaysOnTop: async () => true, + minimizeWindow: async () => true, + toggleMaximizeWindow: async () => true, + closeWindow: async () => true, + + // 应用信息 + getDataDir: async () => '', + getDefaultDataDir: async () => '', + getVersion: async () => '0.0.0-test', + getElectronVersion: async () => null, + + ...overrides, + }; + globalThis.window.api = api; + return api; +} diff --git a/scripts/_lib/check.mjs b/scripts/_lib/check.mjs new file mode 100644 index 0000000..05838ac --- /dev/null +++ b/scripts/_lib/check.mjs @@ -0,0 +1,83 @@ +// 跨脚本共享:统一的断言 / 计数 / 退出 helper。 +// +// 之前散落在 ~25 个脚本里的断言模式有 3 种: +// 1) check(name, cond, detail='') + pass/fail 计数(大多数) +// 2) ok(name) / fail(name, msg) + failed 计数(少数) +// 3) 自定义 ok/bad + 自己的计数器(settings / backup / status-meta) +// +// 这里统一提供: +// - check(name, cond, detail?) —— 主流风格:条件断言,detail 是失败时的诊断 +// - ok(name) / bad(name, msg) —— 风格 2 / 3 的兼容入口(适合「手动构造失败信息」) +// - printSummary(label) —— 打印「通过 X / 失败 Y」并按需 exit +// - summary 对象 —— 共享计数器,避免每个脚本各定义 pass/fail +// +// 用法: +// import { check, ok, bad, summary, printSummary } from './_lib/check.mjs'; +// check('某断言', cond, '可选失败详情'); +// ... +// printSummary('check-batch-ops'); + +export const summary = { pass: 0, fail: 0 }; + +/** + * 主流断言:cond 为真则通过,否则打印失败 + 可选详情。 + * @param {string} name + * @param {boolean} cond + * @param {string} [detail] + */ +export function check(name, cond, detail = '') { + if (cond) { + summary.pass++; + console.log(` ✓ ${name}`); + } else { + summary.fail++; + const tail = detail ? ` — ${detail}` : ''; + console.log(` ✗ ${name}${tail}`); + } +} + +/** + * 显式 ok:用于「不需要断言条件,直接报告通过」的场景。 + * 兼容旧脚本里的 ok() 风格。 + * @param {string} name + */ +export function ok(name) { + summary.pass++; + console.log(` ✓ ${name}`); +} + +/** + * 显式 bad:用于「手动构造失败信息」的场景(错误细节比条件断言更复杂时)。 + * 兼容旧脚本里的 fail()/bad() 风格。 + * @param {string} name + * @param {string} [msg] + */ +export function bad(name, msg = '') { + summary.fail++; + const tail = msg ? `: ${msg}` : ''; + console.log(` ✗ ${name}${tail}`); +} + +/** + * 打印「通过 X / 失败 Y」总结行。 + * exitOnFail=true 时若有失败则以 exit 1 退出(脚本默认应这么用)。 + * + * @param {string} [label] - 总结行前的标签,例如脚本名 + * @param {boolean} [exitOnFail=true] + */ +export function printSummary(label = '', exitOnFail = true) { + const head = label ? `${label} — ` : ''; + console.log(`\n${head}通过 ${summary.pass} / 失败 ${summary.fail}`); + if (exitOnFail && summary.fail > 0) { + process.exit(1); + } +} + +/** + * 重置计数器 —— 脚本里想做多轮隔离断言时调用。 + * 大多数脚本不需要:进程结束就 exit,counter 自然释放。 + */ +export function resetSummary() { + summary.pass = 0; + summary.fail = 0; +} diff --git a/scripts/_lib/find-todo-path.mjs b/scripts/_lib/find-todo-path.mjs new file mode 100644 index 0000000..9ff0f86 --- /dev/null +++ b/scripts/_lib/find-todo-path.mjs @@ -0,0 +1,79 @@ +// 跨脚本共享:找用户的真实 todo.md 路径并读取内容。 +// +// 解决 user memory 里反复提到的硬编码路径问题: +// - 之前 `scripts/check-*.mjs` / `verify-*.mjs` 直接写死 `c:/Users/guan/TodoList/todo.md` +// 在别人的机器 / CI 必挂; +// - 这套查找链按优先级收敛:CLI `--file` → `TODO_LIST_FILE` 环境变量 → 平台约定 +// (用户主目录下的 ~/TodoList/todo.md,跨平台统一)。 +// +// 用法: +// import { findTodoFile } from './_lib/find-todo-path.mjs'; +// const { path: todoPath, content } = findTodoFile() ?? {}; +// if (!todoPath) { /* 走内置 SAMPLE 兜底 */ } +// +// 返回 `null`(找不到任何候选)让调用方决定降级策略 —— 通常是回落到内置 SAMPLE 跑, +// 与「脚本不能因为路径在别人机器上不一样就硬挂」的回归策略一致。 + +import { readFileSync } from 'node:fs'; + +/** + * 找用户的真实 todo.md。 + * @returns {{ path: string, content: string } | null} + */ +export function findTodoFile() { + // 1) 命令行 --file=xxx / --file xxx 优先(CI / 调试覆盖) + const argv = process.argv.slice(2); + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--file' && argv[i + 1]) { + const p = argv[i + 1]; + try { + return { path: p, content: readFileSync(p, 'utf8') }; + } catch { + return null; + } + } + if (a.startsWith('--file=')) { + const p = a.slice('--file='.length); + try { + return { path: p, content: readFileSync(p, 'utf8') }; + } catch { + return null; + } + } + } + + // 2) 环境变量(脚本间共享 —— e.g. CI 全局指定一个 todo.md) + const envPath = process.env.TODO_LIST_FILE; + if (envPath) { + try { + return { path: envPath, content: readFileSync(envPath, 'utf8') }; + } catch { + /* 文件被删 / 权限不足 —— 继续往下找 */ + } + } + + // 3) 平台约定:用户主目录下的 TodoList(~/TodoList)。 + // + // 注意:不要用 HOMEDRIVE 单拼 —— `HOMEDRIVE=C:` + 不带 HOMEPATH 会得到 + // `C:\TodoList\todo.md`(C: 盘根目录下的 TodoList),完全不是用户的 home。 + // 应该走 USERPROFILE / HOME 这类已经合并好 drive + path 的环境变量。 + const home = process.env.USERPROFILE || process.env.HOME || ''; + const candidates = []; + if (home) { + if (process.platform === 'win32') { + candidates.push(`${home}\\TodoList\\todo.md`, `${home}/TodoList/todo.md`); + } else { + candidates.push(`${home}/TodoList/todo.md`); + } + } + + for (const p of candidates) { + try { + return { path: p, content: readFileSync(p, 'utf8') }; + } catch { + /* 跳过不存在的候选 */ + } + } + return null; +} diff --git a/scripts/_lib/store-fixture.mjs b/scripts/_lib/store-fixture.mjs new file mode 100644 index 0000000..d6bf195 --- /dev/null +++ b/scripts/_lib/store-fixture.mjs @@ -0,0 +1,36 @@ +// 跨脚本共享:构造 TaskStore 测试 fixture。 +// +// 之前 ~20 个脚本里复制粘贴 makeStore(),三套签名(必填 md / 可选 md / 无参) +// 实质等价:建 store → loadDefault 或 loadFromContent → 禁掉 autoSave。 +// +// 统一为 makeStore(markdown = null, options = {}): +// - markdown = null/不传 = 走 loadDefault()(默认文档) +// - markdown = 字符串 = 走 loadFromContent(markdown, null)(自定义内容) +// - options.settingsStore = 一个 settingsStore 实例(桩或真),注入后 TaskStore +// 才能读到 *Position 偏好;不传等价于未注入 → TaskStore 走 'front' 默认值。 +// +// autoSave 必须禁掉:脚本里没有真实的 preload/IPC 环境,让 autoSave 跑会触发 +// 找不到 window.api 而抛错。所有脚本都做了相同的事——集中到 helper 里确保新人 +// 不用手动记得这一步。 + +import { TaskStore } from '../../src/task-store.js'; + +/** + * 构造一个 TaskStore fixture。 + * + * @param {string|null} [markdown=null] - 自定义 markdown;null = 用默认文档 + * @param {{ settingsStore?: object }} [options={}] - 注入的 settingsStore(可选) + * @returns {TaskStore} + */ +export function makeStore(markdown = null, options = {}) { + const store = new TaskStore(options); + if (markdown !== null) { + store.loadFromContent(markdown, null); + } else { + store.loadDefault(null); + } + // 关掉 autoSave 的副作用(避免污染测试环境 / 触发 IPC)。 + // 保留 .cancel() / .flush() 接口,调用方代码不需要做 null 检查。 + store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); + return store; +} diff --git a/scripts/_lib/tmp-todo.mjs b/scripts/_lib/tmp-todo.mjs new file mode 100644 index 0000000..2e90af6 --- /dev/null +++ b/scripts/_lib/tmp-todo.mjs @@ -0,0 +1,48 @@ +// 跨脚本共享:临时 todo.md 文件的创建 + 清理。 +// +// 用途:polling-fallback / snapshot-creation / write-race 等脚本要在隔离目录 +// 下模拟真实 todo.md 文件,每次都重复同样的 3 行 mkdtempSync+writeFileSync +// boilerplate,且清理端 `fs.rmSync(tmpDir, { recursive, force })` 在 3 个脚本 +// 各自复制一份 —— 任何一处改成 `fs.rm` 或漏 `{ force: true }` 就会在 CI 上 +// 留下 tmp 目录。 +// +// 用法: +// import { makeTmpTodoFile, cleanupTmpTodo } from './_lib/tmp-todo.mjs'; +// const { dir, file } = makeTmpTodoFile({ prefix: 'poll-test-' }); +// // ... 测试 ... +// cleanupTmpTodo({ dir }); + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +// 默认 seed:最小可工作的「全部任务」+「工作」分类 + 一条 a 任务。 +// 覆盖路径:如果脚本要不同初始内容,传 `content` override(snapshot-creation +// 用 `# todo\n- [ ] A\n- [ ] B\n`;write-race 用 `## 工作\n\n- [ ] V0`)。 +const DEFAULT_SEED = '# 全部任务\n\n## 工作\n\n- [ ] a\n'; + +/** + * 创建一个临时目录并写入 todo.md,返回 { dir, file }。 + * + * @param {object} [opts] + * @param {string} [opts.prefix='todo-test-'] — mkdtempSync 前缀,含连字符 + * @param {string} [opts.content] — 初始 markdown 内容;不传走 DEFAULT_SEED + * @returns {{ dir: string, file: string }} + */ +export function makeTmpTodoFile({ prefix = 'todo-test-', content = DEFAULT_SEED } = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + const file = path.join(dir, 'todo.md'); + fs.writeFileSync(file, content); + return { dir, file }; +} + +/** + * 清理临时目录。`force: true` 让目录不存在时也不抛 —— 测试中途崩了也能跑。 + * + * @param {object} opts + * @param {string} opts.dir + */ +export function cleanupTmpTodo({ dir }) { + if (!dir) return; + fs.rmSync(dir, { recursive: true, force: true }); +} diff --git a/scripts/check-assert-in-data-dir.mjs b/scripts/check-assert-in-data-dir.mjs new file mode 100644 index 0000000..5d3f8fd --- /dev/null +++ b/scripts/check-assert-in-data-dir.mjs @@ -0,0 +1,116 @@ +// 回归测试:H10 — file:exists 必须走 assertInDataDir +// +// 背景:旧版 file:exists 直接 fs.access(filePath) —— 配合一个 XSS(任何能从 +// renderer 调到 api.fileExists 的注入)就能枚举整盘存在性: +// for (let p of commonPaths) { if (await api.fileExists(p)) found.push(p); } +// 任意文件存在性是隐私 + 安全敏感信息(用户的密码管理器 DB 是否存在、 +// 公司加密软件安装情况等)。 +// +// 修复(v3.7+):file:exists 与 file:write / file:read 同款走 assertInDataDir, +// 数据目录外的路径 → 一律返回 false(不抛错,避免泄露信息)。 +// +// 跑法:node scripts/check-assert-in-data-dir.mjs + +import path from 'node:path'; +import os from 'node:os'; +import fs from 'node:fs/promises'; +import { check, summary, printSummary } from './_lib/check.mjs'; + +// ── [1] assertInDataDir 行为镜像 ── +console.log('\n[1] assertInDataDir 行为:拒绝数据目录外路径'); + +const dataDir = path.resolve(os.homedir(), 'TodoList'); + +function assertInDataDir(p) { + if (typeof p !== 'string' || !p) { + throw new Error('path must be a non-empty string'); + } + const root = path.resolve(dataDir); + const abs = path.resolve(p); + const rootCmp = process.platform === 'win32' ? root.toLowerCase() : root; + const absCmp = process.platform === 'win32' ? abs.toLowerCase() : abs; + if (absCmp !== rootCmp && !absCmp.startsWith(rootCmp + path.sep)) { + throw new Error('path outside data directory'); + } + return abs; +} + +{ + check('数据目录内的合法路径 → 不抛错', + (() => { try { return assertInDataDir(path.join(dataDir, 'todo.md')); } catch { return false; } })() !== false); + + check('上级目录穿越 → 抛错', + (() => { try { assertInDataDir(path.join(dataDir, '..', 'evil.txt')); return false; } catch { return true; } })()); + + check('绝对路径在数据目录外 → 抛错', + (() => { + const outside = process.platform === 'win32' ? 'C:\\Windows\\System32\\evil.dll' : '/etc/passwd'; + try { assertInDataDir(outside); return false; } catch { return true; } + })()); + + check('空字符串 → 抛错', + (() => { try { assertInDataDir(''); return false; } catch { return true; } })()); + + check('非字符串(数字)→ 抛错', + (() => { try { assertInDataDir(123); return false; } catch { return true; } })()); + + check('非字符串(null)→ 抛错', + (() => { try { assertInDataDir(null); return false; } catch { return true; } })()); +} + +// ── [2] file:exists 行为:对外路径一律返回 false ── +console.log('\n[2] file:exists 模拟:对外路径一律返回 false'); + +/** + * 模拟 main.js 的 file:exists handler:assertInDataDir + fs.access + try/catch。 + * 任意一步抛错都返回 false(不向 renderer 抛错,避免泄露目录结构)。 + */ +async function fileExists(filePath) { + try { + const abs = assertInDataDir(filePath); + await fs.access(abs); + return true; + } catch { + return false; + } +} + +{ + // 数据目录内:文件存在 → true + // 准备:临时创建数据目录内的一个文件,跑完删除 + const tmpFile = path.join(dataDir, `__check-assert-${Date.now()}.tmp`); + try { + await fs.mkdir(dataDir, { recursive: true }); + await fs.writeFile(tmpFile, 'hello', 'utf8'); + check('数据目录内已存在的文件 → file:exists 返回 true', + await fileExists(tmpFile) === true); + } finally { + try { await fs.unlink(tmpFile); } catch { /* ignore */ } + } + + // 数据目录内:文件不存在 → false(不是抛错) + check('数据目录内不存在的文件 → file:exists 返回 false', + await fileExists(path.join(dataDir, 'definitely-does-not-exist.md')) === false); + + // 数据目录外:fs.access 能探测到也必须返回 false + check('Windows 系统目录(无论是否存在)→ file:exists 返回 false(防信息泄露)', + await fileExists(process.platform === 'win32' ? 'C:\\Windows\\System32\\drivers\\etc\\hosts' : '/etc/passwd') === false); + + // 绝对路径在数据目录外 + check('数据目录外绝对路径 → file:exists 返回 false', + await fileExists(process.platform === 'win32' ? 'C:\\Users\\Public\\test.txt' : '/tmp/test.txt') === false); + + // 路径穿越攻击 + check('路径穿越(../evil.txt)→ file:exists 返回 false', + await fileExists(path.join(dataDir, '..', 'evil.txt')) === false); + + // 空字符串 + check('空字符串 → file:exists 返回 false', + await fileExists('') === false); + + // 非字符串 + check('非字符串(null)→ file:exists 返回 false', + await fileExists(null) === false); +} + +printSummary('check-assert-in-data-dir'); \ No newline at end of file diff --git a/scripts/check-backup.mjs b/scripts/check-backup.mjs new file mode 100644 index 0000000..89cb842 --- /dev/null +++ b/scripts/check-backup.mjs @@ -0,0 +1,299 @@ +import { check, ok, bad, summary, printSummary } from './_lib/check.mjs'; +import { makeStore } from './_lib/store-fixture.mjs'; +import { installApiStub } from './_lib/api-stub.mjs'; +// 每日备份开关 + SettingsStore 输入校验的回归测试。 +// +// 触发 bug 的姿势与 check-settings.mjs 同源 —— 脏值穿过校验污染内存 + 配置文件: +// +// 1. update({ backupEnabled: 'true' }) +// 旧实现下 theme / filter / sortBy / fontSize 走 VALID_VALUES 字符串枚举, +// 唯独 backupEnabled 是 boolean —— 如果 isValidValue 没单独分支处理, +// 字符串 'true' 就直接被吞掉变成 settings.backupEnabled === 'true', +// 下次保存把脏值固化进 config.json。 +// +// 2. 用户手工编辑 config.json,写成 backupEnabled: 1 +// 加载端只校验 key 是否在 DEFAULT_SETTINGS 里 —— 数字 1 会进内存, +// 后续 update / persist 把它当合法值反复写入。 +// +// 这份脚本同样构造 SettingsStore + 桩 api,验证以上姿势都被拦截, +// 并补一个 happy-path 验证持久化走的字段(lastBackupDate)不在 +// DEFAULT_SETTINGS 里 —— 主进程直接通过 saveConfig() 写入, +// 不应该被渲染进程的 SettingsStore.update 路径覆盖。 + +installApiStub(); + +const { SettingsStore } = await import('../src/settings-store.js'); + +// ============================================ +// 默认值 +// ============================================ +console.log('[1] 默认值'); + +{ + const s = new SettingsStore(); + if (s.get('backupEnabled') === true) ok('backupEnabled 默认值是 true(开箱即用)'); + else bad('backupEnabled 默认值错误', `当前 ${JSON.stringify(s.get('backupEnabled'))}`); +} + +// ============================================ +// update() 输入校验 +// ============================================ +console.log('\n[2] update() 拒绝非 boolean 值'); +// 默认是 true —— 任何非法输入必须被拒绝,且不污染内存(保持默认 true)。 + +{ + const s = new SettingsStore(); + await s.update({ backupEnabled: 'true' }); + if (s.get('backupEnabled') === true) ok("update({backupEnabled: 'true'}) 字符串被拒绝,默认 true 未被污染"); + else bad("backupEnabled 被字符串 'true' 污染", `当前 ${JSON.stringify(s.get('backupEnabled'))}`); +} + +{ + const s = new SettingsStore(); + await s.update({ backupEnabled: 1 }); + if (s.get('backupEnabled') === true) ok('update({backupEnabled: 1}) 数字被拒绝,默认 true 未被污染'); + else bad('backupEnabled 被数字污染', `当前 ${JSON.stringify(s.get('backupEnabled'))}`); +} + +{ + const s = new SettingsStore(); + await s.update({ backupEnabled: null }); + if (s.get('backupEnabled') === true) ok('update({backupEnabled: null}) null 被拒绝,默认 true 未被污染'); + else bad('backupEnabled 被 null 污染', `当前 ${JSON.stringify(s.get('backupEnabled'))}`); +} + +{ + const s = new SettingsStore(); + await s.update({ backupEnabled: undefined }); + if (s.get('backupEnabled') === true) ok('update({backupEnabled: undefined}) undefined 被拒绝,默认 true 未被污染'); + else bad('backupEnabled 被 undefined 污染', `当前 ${JSON.stringify(s.get('backupEnabled'))}`); +} + +{ + const s = new SettingsStore(); + await s.update({ backupEnabled: { evil: true } }); + if (s.get('backupEnabled') === true) ok('update({backupEnabled: object}) 对象被拒绝,默认 true 未被污染'); + else bad('backupEnabled 被对象污染', `当前 ${JSON.stringify(s.get('backupEnabled'))}`); +} + +// ============================================ +// update() happy path —— 默认 true,所以「设 false」才有持久化意义 +// ============================================ +console.log('\n[3] update() 接受合法 boolean'); + +{ + const s = new SettingsStore(); + // 先切到 false 再切回 true:默认 true 直接发 true 会因「值未变」返回 false + await s.update({ backupEnabled: false }); + const result = await s.update({ backupEnabled: true }); + if (s.get('backupEnabled') === true && result === true) { + ok('update({backupEnabled: true}) 从 false 切回 true 生效'); + } else { + bad('合法 true 未生效', JSON.stringify({ value: s.get('backupEnabled'), result })); + } +} + +{ + const s = new SettingsStore(); + await s.update({ backupEnabled: true }); + const result = await s.update({ backupEnabled: false }); + if (s.get('backupEnabled') === false && result === true) { + ok('update({backupEnabled: false}) 从 true 切回 false 生效'); + } else { + bad('切回 false 未生效', JSON.stringify({ value: s.get('backupEnabled'), result })); + } +} + +{ + const s = new SettingsStore(); + // 同样的值再发一次:update() 应该返回 false,不触发持久化(节省 IO) + await s.update({ backupEnabled: true }); + const result = await s.update({ backupEnabled: true }); + if (result === false) ok('update({backupEnabled: true}) 同值再发返回 false'); + else bad('同值再发仍触发持久化', `返回 ${result}`); +} + +// ============================================ +// load() 输入校验 —— 模拟配置文件被外部改成脏值 +// ============================================ +console.log('\n[4] load() 从脏配置恢复默认值'); + +{ + const s = new SettingsStore(); + const orig = globalThis.window.api.getSettings; + globalThis.window.api.getSettings = async () => ({ + backupEnabled: 1, // 数字:必须被拒绝(回退到默认 true) + theme: 'light', // 合法值:应保留 + fontSize: 'huge' // 非法字符串:应回退到 medium + }); + try { + await s.load(); + const cur = s.all; + if (cur.backupEnabled === true && cur.theme === 'light' && cur.fontSize === 'medium') { + ok('脏 backupEnabled 回退到默认 true;合法 theme 保留;非法 fontSize 回退'); + } else { + bad('load 污染或误伤', JSON.stringify(cur)); + } + } finally { + globalThis.window.api.getSettings = orig; + } +} + +{ + const s = new SettingsStore(); + const orig = globalThis.window.api.getSettings; + globalThis.window.api.getSettings = async () => ({ + backupEnabled: true, + lastBackupDate: '2026-08-22' // 主进程维护的字段,不该被 load 路径意外处理 + }); + try { + await s.load(); + const cur = s.all; + if (cur.backupEnabled === true) { + ok('合法 backupEnabled: true 被加载'); + } else { + bad('backupEnabled: true 未生效', JSON.stringify(cur)); + } + // lastBackupDate 不在 DEFAULT_SETTINGS 里 —— 即便持久化文件里有,load 也不该把它 + // 暴露到 settings 对象上(主进程直接从 appConfig 读,不依赖 SettingsStore) + if (cur.lastBackupDate === undefined) { + ok('lastBackupDate 未泄漏到 settings 对象(保留主进程独立访问)'); + } else { + bad('lastBackupDate 泄漏到 settings', JSON.stringify(cur)); + } + } finally { + globalThis.window.api.getSettings = orig; + } +} + +// ============================================ +// update() 字段白名单 —— 未知字段被忽略 +// ============================================ +console.log('\n[5] 未知字段被忽略'); + +{ + const s = new SettingsStore(); + await s.update({ lastBackupDate: '2026-08-22', unknownField: 'x' }); + if (s.get('lastBackupDate') === undefined && s.get('unknownField') === undefined) { + ok('update({lastBackupDate, unknownField}) 都被忽略'); + } else { + bad('未知字段被写入', JSON.stringify({ lastBackupDate: s.get('lastBackupDate'), unknownField: s.get('unknownField') })); + } +} + +// ============================================ +// update() 与其它字段联动 —— 模拟设置对话框「完成」一次性提交多字段 +// ============================================ +console.log('\n[6] 与其它字段联动'); + +{ + const s = new SettingsStore(); + // 同时改 backupEnabled + theme + fontSize:模拟对话框 finish() 把多个 pending 值打包提交 + const result = await s.update({ backupEnabled: true, theme: 'light', fontSize: 'large' }); + const cur = s.all; + if (result === true && + cur.backupEnabled === true && + cur.theme === 'light' && + cur.fontSize === 'large') { + ok('update({backupEnabled, theme, fontSize}) 多字段同时生效'); + } else { + bad('多字段联动异常', JSON.stringify(cur)); + } +} + +{ + const s = new SettingsStore(); + // 与非法字段混合:合法字段应生效,非法字段应被丢弃 + await s.update({ backupEnabled: true, theme: 'rainbow', fontSize: 'huge' }); + const cur = s.all; + if (cur.backupEnabled === true && cur.theme === 'dark' && cur.fontSize === 'medium') { + ok('合法 backupEnabled 生效;非法 theme/fontSize 回退到默认'); + } else { + bad('联动失败', JSON.stringify(cur)); + } +} + +// ============================================ +// change 事件 —— 验证 update 触发的事件包含正确字段列表 +// ============================================ +console.log('\n[7] change 事件'); + +{ + const s = new SettingsStore(); + let captured = null; + s.on('change', (e) => { captured = e; }); + // 默认 true —— 先切到 false(默认值改变 → 必触发 change)才能验证事件字段 + await s.update({ backupEnabled: false }); + captured = null; // 清掉上一次的事件 + await s.update({ backupEnabled: true }); + if (captured && Array.isArray(captured.changed) && + captured.changed.length === 1 && captured.changed[0] === 'backupEnabled') { + ok('change 事件 reported backupEnabled'); + } else { + bad('change 事件字段错误', JSON.stringify(captured)); + } +} + +{ + const s = new SettingsStore(); + let captured = null; + s.on('change', (e) => { captured = e; }); + // 全部非法 → updates 为空 → 不触发 change 事件 + await s.update({ backupEnabled: 'true', backupEnabled_evil: 1 }); + if (captured === null) { + ok('全部非法时 change 事件不触发(避免无效 IO)'); + } else { + bad('change 事件不该触发', JSON.stringify(captured)); + } +} + +// ============================================ +// 首次启动:主进程返回 {}(config.json 不存在)—— 兜底默认 true +// +// 复现的 bug 路径:loadConfig() 在磁盘上没 config.json 时返回 {}, +// main 把 {} 透传给 renderer SettingsStore.load()。如果 renderer 端不 +// 兜底,backupEnabled 就保持 undefined(falsy),结果主进程 +// maybeBackupToday 的 truthy 检查把 undefined 当成「禁用」—— +// 新用户的备份机制永远不工作。 +// +// 这里验的是 renderer 端的兜底(DEFAULT_SETTINGS 在 load 时填充), +// main 端的兜底(DEFAULT_CONFIG)由 code review 保证 —— main.js 依赖 +// Electron API 不能直接 import 测试。 +// ============================================ +console.log('\n[8] 首次启动兜底'); + +{ + const s = new SettingsStore(); + const orig = globalThis.window.api.getSettings; + globalThis.window.api.getSettings = async () => ({}); // 主进程:config.json 不存在 + try { + await s.load(); + if (s.get('backupEnabled') === true) { + ok('主进程返回 {} 时 backupEnabled 兜底到默认 true(保证备份机制激活)'); + } else { + bad('首次启动兜底失败', `当前 ${JSON.stringify(s.get('backupEnabled'))}`); + } + } finally { + globalThis.window.api.getSettings = orig; + } +} + +{ + const s = new SettingsStore(); + const orig = globalThis.window.api.getSettings; + globalThis.window.api.getSettings = async () => ({ alwaysOnTop: true }); // 只有 alwaysOnTop,没 backupEnabled + try { + await s.load(); + if (s.get('backupEnabled') === true && s.get('alwaysOnTop') === true) { + ok('部分字段缺失时 backupEnabled 仍兜底到 true,已设的 alwaysOnTop 保留'); + } else { + bad('部分兜底失败', JSON.stringify({ backupEnabled: s.get('backupEnabled'), alwaysOnTop: s.get('alwaysOnTop') })); + } + } finally { + globalThis.window.api.getSettings = orig; + } +} + +console.log(`\n${'─'.repeat(48)}`); +console.log(`通过 ${summary.pass} / 失败 ${summary.fail}`); +process.exit(summary.fail === 0 ? 0 : 1); diff --git a/scripts/check-batch-ops.mjs b/scripts/check-batch-ops.mjs new file mode 100644 index 0000000..4d926bd --- /dev/null +++ b/scripts/check-batch-ops.mjs @@ -0,0 +1,746 @@ +// 一次性验证脚本:批量操作(task-store.batchDeleteTasks / batchMoveTasks / +// batchToggleCompleted / batchUpdateMeta / batchReorderInCategory) +// +// 覆盖: +// 1. 批量删除:跨分类、TRASH 拒绝、空集合、顺序保留、单次 emit +// 2. 批量移到分类:跨源分类、顺序保留、目标守卫、单次 emit +// 3. 批量重排序:top / bottom、跨分类拒绝、顺序保留 +// 4. 批量改 meta:important / current、TRASH 拒绝、已为 true 不重复改 +// 5. 批量切勾选:TRASH 允许、已为目标态不重复改 +// 6. 往返保真:所有批量操作后 serialize → parseMarkdown 任务多重集不变 +// +// 用法:node scripts/check-batch-ops.mjs + +import { TaskStore } from '../src/task-store.js'; +import { check, ok, bad, summary, printSummary } from './_lib/check.mjs'; +import { makeStore } from './_lib/store-fixture.mjs'; +import { parseMarkdown, CategoryKind } from '../src/markdown-parser.js'; +import { OTHER_TASKS_NAME, TRASH_NAME } from '../src/markdown-parser.js'; + +// 构造跨分类测试场景:3 个子分类 + 已完成任务分类 + 6 条任务 +// +// 注意(v3.4):fixture 里不能再用 `- [✓]` 写在普通子分类下 —— 加载时会被 +// 迁移到「# 已完成任务」分类,原本所在分类会少一条,进而打挂所有断言。 +// 正确做法:把已完成任务直接放在 `# 已完成任务` 段。 +function setupCrossCategoryStore() { + const md = `# ${OTHER_TASKS_NAME} + +## 工作 + +- [ ] 工作任务 A +- [ ] 工作任务 A2 + +## 学习 + +- [ ] 学习任务 A + +## 生活 + +- [ ] 生活任务 A +- [ ] 生活任务 B + +# 已完成任务 + +- [✓] 工作任务 B(已完成) + +# ${TRASH_NAME} +`; + // 注入 back-mode settings:让 batchDeleteTasks / batchReorderInCategory / batchMoveTasks 的断言 + // (顺序保留 = push 顺序)保持原语义。默认 'front' 模式(最新在最上)由 + // check-insert-position.mjs 覆盖。 + const backSettings = { + _values: { newTaskPosition: 'back', completedPosition: 'back', trashPosition: 'back', movePosition: 'back' }, + get(k) { return this._values[k]; } + }; + const store = makeStore(md, { settingsStore: backSettings }); + return store; +} + +// ============================================================ +// [1] batchDeleteTasks —— 基本跨分类删除 +// ============================================================ +console.log('\n[1] batchDeleteTasks 跨分类删除'); + +{ + const store = setupCrossCategoryStore(); + const work = store.getCategory('工作'); + const study = store.getCategory('学习'); + const life = store.getCategory('生活'); + + // 选 3 条来自 3 个不同分类 + const refs = [ + { categoryName: '工作', taskId: work.tasks[0].id }, + { categoryName: '学习', taskId: study.tasks[0].id }, + { categoryName: '生活', taskId: life.tasks[0].id } + ]; + + let changeCount = 0; + const onChange = () => { changeCount++; }; + store.on('change', onChange); + + const result = store.batchDeleteTasks(refs); + store.off('change', onChange); + + check('返回 moved=3, skipped=0', result.moved === 3 && result.skipped === 0, + JSON.stringify(result)); + check('change 事件只触发 1 次(不是 3 次)', changeCount === 1, + `实际 ${changeCount} 次`); + check('工作分类剩 1 条(剩 A2)', work.tasks.length === 1); + check('学习分类剩 0 条', study.tasks.length === 0); + check('生活分类剩 1 条(剩 B)', life.tasks.length === 1); + check('回收站创建出来 + 3 条', store.getTrashCategory()?.tasks.length === 3); + check('dirty 已被标脏', store.dirty === true); + + // 顺序:回收站里 3 条按入参顺序 reverse 后 unshift。 + // refs = [工作 A, 学习 A, 生活 A] → reverse → [生活 A, 学习 A, 工作 A] + // 每次 unshift 加到数组前端,所以最终顺序从上到下:工作 A、学习 A、生活 A + // 直观理解:refs 中越靠前的越先 unshift,所以越靠后的元素越先被加到前面 → 反转了 refs + const trash = store.getTrashCategory(); + check('回收站顺序:上→下 = refs 倒序(最后删的排在最上)', + trash.tasks[0].text === '工作任务 A' && + trash.tasks[1].text === '学习任务 A' && + trash.tasks[2].text === '生活任务 A'); +} + +// ============================================================ +// [2] batchDeleteTasks —— 守卫 / 边界 +// ============================================================ +console.log('\n[2] batchDeleteTasks 守卫'); + +{ + const store = setupCrossCategoryStore(); + const work = store.getCategory('工作'); + + // 2.1 TRASH 内的任务被拒绝(彻底删除只能手工编辑 Markdown) + const beforeTrashLen = store.getTrashCategory()?.tasks.length || 0; + // 先删一条进回收站 + store.deleteTask('工作', work.tasks[0].id); + const trash = store.getTrashCategory(); + const trashedId = trash.tasks[0].id; + + const r1 = store.batchDeleteTasks([{ categoryName: TRASH_NAME, taskId: trashedId }]); + check('TRASH 内任务被拒绝(moved=0, skipped=1)', + r1.moved === 0 && r1.skipped === 1, JSON.stringify(r1)); + check('TRASH 内的任务没被二次删', trash.tasks.find(t => t.id === trashedId) !== undefined); + check('回收站长度不变', trash.tasks.length === beforeTrashLen + 1); + + // 2.2 不存在的 id + const r2 = store.batchDeleteTasks([{ categoryName: '工作', taskId: '不存在' }]); + check('不存在 id → moved=0, skipped=1', r2.moved === 0 && r2.skipped === 1); + + // 2.3 不存在的分类 + const r3 = store.batchDeleteTasks([{ categoryName: '不存在的分类', taskId: 'x' }]); + check('不存在分类 → moved=0, skipped=1', r3.moved === 0 && r3.skipped === 1); + + // 2.4 空入参 + let changeCount = 0; + store.on('change', () => changeCount++); + const r4 = store.batchDeleteTasks([]); + store.off('change', () => {}); + check('空数组 → moved=0, skipped=0', r4.moved === 0 && r4.skipped === 0); + check('空数组 → change 触发 0 次', changeCount === 0, + `实际 ${changeCount} 次`); + + // 2.5 null 入参 + const r5 = store.batchDeleteTasks(null); + check('null 入参 → 安全返回', r5.moved === 0); + + // 2.6 混合:3 条里 1 条是 TRASH 内(拒绝),1 条不存在(跳过),1 条合法 + const r6 = store.batchDeleteTasks([ + { categoryName: TRASH_NAME, taskId: trashedId }, + { categoryName: '工作', taskId: '不存在' }, + { categoryName: '工作', taskId: work.tasks[0].id } + ]); + check('混合入参 → moved=1, skipped=2', r6.moved === 1 && r6.skipped === 2, JSON.stringify(r6)); +} + +// ============================================================ +// [3] batchDeleteTasks —— 往返保真 +// ============================================================ +console.log('\n[3] batchDeleteTasks 序列化往返'); + +{ + const store = setupCrossCategoryStore(); + const work = store.getCategory('工作'); + const study = store.getCategory('学习'); + + const refs = [ + { categoryName: '工作', taskId: work.tasks[0].id }, + { categoryName: '学习', taskId: study.tasks[0].id } + ]; + store.batchDeleteTasks(refs); + + const md = store.serialize(); + const reloaded = parseMarkdown(md); + + const reloadedWork = reloaded.find(c => c.name === '工作'); + const reloadedStudy = reloaded.find(c => c.name === '学习'); + const reloadedTrash = reloaded.find(c => c.name === TRASH_NAME && c.kind === CategoryKind.TRASH); + + check('往返后「工作」剩 1 条', reloadedWork?.tasks?.length === 1); + check('往返后「学习」剩 0 条', reloadedStudy?.tasks?.length === 0); + check('往返后回收站有 2 条', reloadedTrash?.tasks?.length === 2, + `实际 ${reloadedTrash?.tasks?.length} 条`); + + // 任务多重集(文本 + completed)应不变 —— 包含所有 6 条(删的 2 条在 trash 里) + // v3.4 fixture 已完成任务分类有 B,工作分类有 A + A2;多重集按文本 + 勾选状态统计 + const origSet = new Set([ + '工作任务 A|false', + '工作任务 A2|false', + '工作任务 B(已完成)|true', + '学习任务 A|false', + '生活任务 A|false', + '生活任务 B|false' + ]); + const reloadSet = new Set(); + for (const cat of reloaded) { + if (cat.name === OTHER_TASKS_NAME || cat.name === TRASH_NAME) continue; + for (const t of cat.tasks) reloadSet.add(t.text + '|' + t.completed); + } + for (const t of reloadedTrash?.tasks || []) reloadSet.add(t.text + '|' + t.completed); + check('往返后任务多重集不变', + reloadSet.size === origSet.size && [...origSet].every(x => reloadSet.has(x)), + `orig=${[...origSet]}, reload=${[...reloadSet]}`); +} + +// ============================================================ +// [4] batchMoveTasks —— 跨源分类移动 + 顺序保留 +// ============================================================ +console.log('\n[4] batchMoveTasks 跨源分类移动'); + +{ + const store = setupCrossCategoryStore(); + const work = store.getCategory('工作'); + const study = store.getCategory('学习'); + const life = store.getCategory('生活'); + + // 选 2 条来自工作 + 1 条来自学习 → 都到「生活」 + const refs = [ + { categoryName: '工作', taskId: work.tasks[0].id }, + { categoryName: '工作', taskId: work.tasks[1].id }, + { categoryName: '学习', taskId: study.tasks[0].id } + ]; + + let changeCount = 0; + store.on('change', () => changeCount++); + const result = store.batchMoveTasks(refs, '生活'); + store.off('change', () => {}); + + check('返回 moved=3', result.moved === 3, JSON.stringify(result)); + check('change 触发 1 次', changeCount === 1); + check('工作分类剩 0 条', work.tasks.length === 0); + check('学习分类剩 0 条', study.tasks.length === 0); + check('生活分类原来 2 + 新增 3 = 5 条', life.tasks.length === 5); + + // 顺序保留:refs[0], refs[1], refs[2] 顺序 → 追加到生活末尾 + // (原生活有 [生活任务 A, 生活任务 B]) + check('顺序保留:生活分类末尾 3 条 = 入参顺序', + life.tasks.slice(2).map(t => t.text).join('|') === + '工作任务 A|工作任务 A2|学习任务 A', + life.tasks.slice(2).map(t => t.text).join('|')); +} + +// ============================================================ +// [5] batchMoveTasks —— 目标守卫 +// ============================================================ +console.log('\n[5] batchMoveTasks 目标守卫'); + +{ + const store = setupCrossCategoryStore(); + const work = store.getCategory('工作'); + const refs = [{ categoryName: '工作', taskId: work.tasks[0].id }]; + + // 5.1 目标 = 容器(OTHER_TASKS)→ 拒绝 + const r1 = store.batchMoveTasks(refs, OTHER_TASKS_NAME); + check('目标=容器 → moved=0, skipped=1', r1.moved === 0 && r1.skipped === 1); + check('工作分类未受影响', work.tasks.length === 2); + + // 5.2 用全新 store 验证「目标=TRASH → 不允许绕过 deleteTask 专属流程」 + // 注意:setupCrossCategoryStore 的 markdown 已含 `# ${TRASH_NAME}` 段, + // 所以加载后回收站分类已存在 —— 这里验的是「moveTask 拒绝向 TRASH 移」, + // 而不是「TRASH 没被动创建」(后者需要更特殊的初始 markdown)。 + const freshStore = setupCrossCategoryStore(); + const freshTrashBefore = freshStore.getTrashCategory()?.tasks.length || 0; + const freshWork = freshStore.getCategory('工作'); + const r2 = freshStore.batchMoveTasks( + [{ categoryName: '工作', taskId: freshWork.tasks[0].id }], + TRASH_NAME + ); + check('目标=回收站 → moved=0, skipped=1', r2.moved === 0 && r2.skipped === 1); + check('拒绝后回收站任务数不变(绕过路径被堵)', + freshStore.getTrashCategory()?.tasks.length === freshTrashBefore); + check('拒绝后工作分类未受影响', freshWork.tasks.length === 2); + + // 5.3 目标 = 不存在的分类 → 拒绝 + const r3 = store.batchMoveTasks(refs, '不存在的分类'); + check('目标=不存在 → moved=0, skipped=1', r3.moved === 0 && r3.skipped === 1); +} + +// ============================================================ +// [6] batchMoveTasks —— toIndex 插入位置 +// ============================================================ +console.log('\n[6] batchMoveTasks toIndex 插入位置'); + +{ + const store = setupCrossCategoryStore(); + const work = store.getCategory('工作'); + const life = store.getCategory('生活'); + // 生活:[生活任务 A, 生活任务 B] + // 选工作第 1 条 → 插入到生活下标 1(A 之后、B 之前) + const refs = [{ categoryName: '工作', taskId: work.tasks[0].id }]; + store.batchMoveTasks(refs, '生活', 1); + + check('生活分类长度 = 3', life.tasks.length === 3); + check('插入到下标 1:A、工作任务 A、生活任务 B', + life.tasks[0].text === '生活任务 A' && + life.tasks[1].text === '工作任务 A' && + life.tasks[2].text === '生活任务 B', + life.tasks.map(t => t.text).join('|')); +} + +// ============================================================ +// [7] batchMoveTasks —— 切回 MANUAL 排序 +// ============================================================ +console.log('\n[7] batchMoveTasks 切回 MANUAL'); + +{ + const store = setupCrossCategoryStore(); + store.setSortBy('alphabet'); // 切到字母序 + check('当前是字母序', store.sortBy === 'alphabet'); + + const work = store.getCategory('工作'); + store.batchMoveTasks( + [{ categoryName: '工作', taskId: work.tasks[0].id }], + '生活' + ); + check('批量移动后自动切回 MANUAL', store.sortBy === 'manual', + `实际 ${store.sortBy}`); +} + +// ============================================================ +// [8] batchReorderInCategory —— top / bottom +// ============================================================ +console.log('\n[8] batchReorderInCategory top / bottom'); + +{ + const store = setupCrossCategoryStore(); + // 工作:[工作任务 A, 工作任务 A2](v3.4 fixture:B 已迁到「# 已完成任务」分类) + const work = store.getCategory('工作'); + const aId = work.tasks[0].id; + const a2Id = work.tasks[1].id; + + // 8.1 只有 1 个任务 → 置顶 / 置底都是 no-op(顺序不变) + const r1 = store.batchReorderInCategory('工作', [aId], 'top'); + check('单条置顶 → moved=0(顺序未变)', + r1.moved === 0 || work.tasks[0].id === aId); + + // 8.2 现在加 2 条任务,让 reorder 真正有效 + store.addTask('工作', '新任务 1'); + store.addTask('工作', '新任务 2'); + // 工作:[工作任务 A, 工作任务 A2, 新任务 1, 新任务 2] + + // 选 [工作任务 A2, 新任务 2] → 移到顶部 + const idsToTop = [a2Id, work.tasks[3].id]; + store.batchReorderInCategory('工作', idsToTop, 'top'); + // 期望:[工作任务 A2, 新任务 2, 工作任务 A, 新任务 1] + check('top 重排后顺序正确', + work.tasks.map(t => t.text).join('|') === + '工作任务 A2|新任务 2|工作任务 A|新任务 1', + work.tasks.map(t => t.text).join('|')); + + // 8.3 选 [工作任务 A2, 新任务 2] → 移到底部 + const idsToBottom = [work.tasks[0].id, work.tasks[1].id]; + store.batchReorderInCategory('工作', idsToBottom, 'bottom'); + // 期望:[工作任务 A, 新任务 1, 工作任务 A2, 新任务 2] + check('bottom 重排后顺序正确', + work.tasks.map(t => t.text).join('|') === + '工作任务 A|新任务 1|工作任务 A2|新任务 2', + work.tasks.map(t => t.text).join('|')); + + // 8.4 选不存在的 id → 跳过 + const r2 = store.batchReorderInCategory('工作', ['不存在', aId], 'top'); + check('包含不存在 id → skipped=1, moved=1', + r2.moved === 1 && r2.skipped === 1, JSON.stringify(r2)); + + // 8.5 不在目标分类里的 id → 跳过 + const study = store.getCategory('学习'); + const studyId = study.tasks[0].id; + const r3 = store.batchReorderInCategory('工作', [studyId, aId], 'top'); + check('跨分类 id 跳过 → skipped=1, moved=1', + r3.moved === 1 && r3.skipped === 1, JSON.stringify(r3)); + + // 8.6 目标分类是 TRASH → 拒绝 + const r4 = store.batchReorderInCategory(TRASH_NAME, [aId], 'top'); + check('目标=TRASH → moved=0', r4.moved === 0); + + // 8.7 目标分类是 OTHER_TASKS → 拒绝 + const r5 = store.batchReorderInCategory(OTHER_TASKS_NAME, [aId], 'top'); + check('目标=OTHER_TASKS → moved=0', r5.moved === 0); + + // 8.8 position 非法 → 拒绝 + const r6 = store.batchReorderInCategory('工作', [aId], 'middle'); + check('position=middle → moved=0', r6.moved === 0); +} + +// ============================================================ +// [9] batchToggleCompleted —— 批量勾选状态切换 +// ============================================================ +console.log('\n[9] batchToggleCompleted 批量切勾选'); + +{ + const store = setupCrossCategoryStore(); + const work = store.getCategory('工作'); + const study = store.getCategory('学习'); + + // v3.4 fixture: + // 工作:[工作任务 A (未完成), 工作任务 A2 (未完成)] + // 学习:[学习任务 A (未完成)] + // 已完成任务:[工作任务 B(已完成)](B 不参与本次 toggle,本来就是 true) + // 全部置为已完成:3 条都是 false → 全部需要改 + 自动搬到 # 已完成任务 + const refs = [ + { categoryName: '工作', taskId: work.tasks[0].id }, + { categoryName: '工作', taskId: work.tasks[1].id }, + { categoryName: '学习', taskId: study.tasks[0].id } + ]; + + let changeCount = 0; + store.on('change', () => changeCount++); + const r1 = store.batchToggleCompleted(refs, true); + store.off('change', () => {}); + + check('返回 changed=3(3 条都是 false → 全部切到 true)', + r1.changed === 3, JSON.stringify(r1)); + check('change 触发 1 次', changeCount === 1); + + // v3.4:toggle true 触发自动搬迁 —— 这 3 条应被搬到 # 已完成任务 + const completedCat = store.getCompletedCategory(); + check('3 条任务自动搬到已完成任务分类', + completedCat?.tasks?.length === 4 && // 原有 B + 新增 3 条 + completedCat.tasks.some(t => t.text === '工作任务 A') && + completedCat.tasks.some(t => t.text === '工作任务 A2') && + completedCat.tasks.some(t => t.text === '学习任务 A')); + check('工作分类被搬空', work.tasks.length === 0); + check('学习分类被搬空', study.tasks.length === 0); + + // 9.2 再切回未完成:3 条都是 true → changed=3 + // v3.5+:批量取消勾选走 _resolveRestoreTarget,按各自 originalCategory 归位 + // - 工作任务 A / A2:originalCategory = "工作" → 回到「工作」 + // - 学习任务 A: originalCategory = "学习" → 回到「学习」 + // 这正是本批恢复的"按原分类归位"语义 —— 旧版一律回「未分类」的行为已废弃。 + const r2 = store.batchToggleCompleted(refs, false); + check('全部为目标态的反值 → changed=3', r2.changed === 3, JSON.stringify(r2)); + + const workAfter = store.getCategory('工作'); + const studyAfter = store.getCategory('学习'); + const uncategorizedAfter = store.getCategory('未分类'); + check('2 条工作任务回到「工作」', + workAfter?.tasks?.length === 2 && + workAfter.tasks.some(t => t.text === '工作任务 A') && + workAfter.tasks.some(t => t.text === '工作任务 A2')); + check('1 条学习任务回到「学习」', + studyAfter?.tasks?.length === 1 && + studyAfter.tasks.some(t => t.text === '学习任务 A')); + check('「未分类」保持为空(无任务落进来)', + !uncategorizedAfter || uncategorizedAfter.tasks.length === 0); + check('已完成任务只剩原本的 B', + completedCat?.tasks?.length === 1 && + completedCat.tasks[0].text === '工作任务 B(已完成)'); + + // 9.3 TRASH 允许切勾选(恢复前常想清勾或勾上) + // 工作 / 学习 / 未分类 里的任务刚刚经过搬迁,需要一个全新的源 —— 用「生活」分类 + const life = store.getCategory('生活'); + store.deleteTask('生活', life.tasks[0].id); + const trash = store.getTrashCategory(); + const trashedId = trash.tasks[0].id; + // 此时该任务 completed=false,切到 true 应该 changed=1 + const r3 = store.batchToggleCompleted( + [{ categoryName: TRASH_NAME, taskId: trashedId }], + true + ); + check('TRASH 内任务允许切勾选(true)', r3.changed === 1, JSON.stringify(r3)); + + // v3.4:勾选触发自动搬迁 —— TRASH 任务 [✓] 后从回收站搬到 # 已完成任务 + // (与 toggleTask 单条路径同款语义:completed=true 必须活在与状态一致的分类里) + check('TRASH 任务勾上后自动搬到已完成任务分类', + completedCat?.tasks?.some(t => t.id === trashedId && t.completed === true)); + check('TRASH 中已无此任务(已搬迁)', + !trash.tasks.some(t => t.id === trashedId)); + + // 9.4 batchToggleCompleted(true) 遵守 completedPosition: + // - front(默认):批量打勾 → 「最新在最上」(入参里最后一条在「已完成」最前) + // - back :按入参顺序追加到「已完成」末尾(与 setupCrossCategoryStore 同款 back 语义) + // wantCompleted=false(批量取消勾选)属于恢复路径,与 restoreTask 同款 —— 不读 *Position, + // 任务归位一律按 push 末尾,已在 9.2 验证过。 +} + +// 9.4 单独用 front-mode settingsStore 验证批量打勾走 completedPosition +console.log('\n[9.4] batchToggleCompleted(true) 遵守 completedPosition'); + +{ + // front 模式:默认行为 + const frontSettings = { + _values: { newTaskPosition: 'front', completedPosition: 'front', trashPosition: 'front' }, + get(k) { return this._values[k]; } + }; + const md = `# ${OTHER_TASKS_NAME} + +## 工作 + +- [ ] 工作任务 A +- [ ] 工作任务 A2 +- [ ] 工作任务 A3 + +## 学习 + +- [ ] 学习任务 A + +# ${TRASH_NAME} +`; + const store = makeStore(md, { settingsStore: frontSettings }); + const work = store.getCategory('工作'); + const study = store.getCategory('学习'); + + // 入参顺序:A, A2, A3(来自工作), 学习 A。批量打勾 → 入参里最后一条(学习 A) + // 在 completedPosition='front' 时应落在「已完成任务」最前面。 + const refs = [ + { categoryName: '工作', taskId: work.tasks[0].id }, + { categoryName: '工作', taskId: work.tasks[1].id }, + { categoryName: '工作', taskId: work.tasks[2].id }, + { categoryName: '学习', taskId: study.tasks[0].id } + ]; + store.batchToggleCompleted(refs, true); + + const completedCat = store.getCompletedCategory(); + check('front 模式:completed 里有 4 条', completedCat.tasks.length === 4); + check('front 模式:入参里最后一条(学习 A)排在最前(最新在最上)', + completedCat.tasks[0].text === '学习任务 A', + completedCat.tasks.map(t => t.text).join('|')); + // front 模式:依次 unshift 入参 → 入参里最后一条最先 unshift 到 [0],其余按入参倒序填位。 + // 入参顺序 = [工作 A, 工作 A2, 工作 A3, 学习 A],依次 unshift 后 = [学习 A, 工作 A3, 工作 A2, 工作 A] + // (同源内 idx=0/1/2 的顺序与入参一致,因为 parseOrder 自然等于 sourceIndex 顺序) + check('front 模式:completed 顺序 = 入参倒序(同源按 sourceIndex 倒序保留)', + completedCat.tasks.map(t => t.text).join('|') === '学习任务 A|工作任务 A3|工作任务 A2|工作任务 A', + completedCat.tasks.map(t => t.text).join('|')); +} + +{ + // back 模式:按入参顺序追加到末尾 + const backSettings = { + _values: { newTaskPosition: 'back', completedPosition: 'back', trashPosition: 'back' }, + get(k) { return this._values[k]; } + }; + const md = `# ${OTHER_TASKS_NAME} + +## 工作 + +- [ ] 工作任务 A +- [ ] 工作任务 A2 + +## 学习 + +- [ ] 学习任务 A + +# 已完成任务 + +- [✓] 已有 B + +# ${TRASH_NAME} +`; + const store = makeStore(md, { settingsStore: backSettings }); + const work = store.getCategory('工作'); + const study = store.getCategory('学习'); + + const refs = [ + { categoryName: '工作', taskId: work.tasks[0].id }, + { categoryName: '工作', taskId: work.tasks[1].id }, + { categoryName: '学习', taskId: study.tasks[0].id } + ]; + store.batchToggleCompleted(refs, true); + + const completedCat = store.getCompletedCategory(); + check('back 模式:completed 里有 4 条(已有 B + 新增 3 条)', + completedCat.tasks.length === 4); + // back 模式:原有 B 在前,新增 3 条按入参顺序追加 + check('back 模式:completed 顺序 = 原顺序 + 入参顺序', + completedCat.tasks.map(t => t.text).join('|') === + '已有 B|工作任务 A|工作任务 A2|学习任务 A', + completedCat.tasks.map(t => t.text).join('|')); +} + +// ============================================================ +// [10] batchUpdateMeta —— 批量改 important / current +// ============================================================ +console.log('\n[10] batchUpdateMeta 批量改 meta'); + +{ + const store = setupCrossCategoryStore(); + const work = store.getCategory('工作'); + const study = store.getCategory('学习'); + + // 10.1 全部置为 important=true + const refs = [ + { categoryName: '工作', taskId: work.tasks[0].id }, + { categoryName: '工作', taskId: work.tasks[1].id }, + { categoryName: '学习', taskId: study.tasks[0].id } + ]; + const r1 = store.batchUpdateMeta(refs, { important: true }); + check('全部置重要 → changed=3', r1.changed === 3); + check('collectSmartListTasks().important 长度 = 3', + store.collectSmartListTasks().important.length === 3); + + // 10.2 v3.6 起不再写顶部「# 重要任务」镜像段:[⭐] 由 inline 标识承载, + // 智能视图直接按 task.important=true 聚合。序列化结果不含镜像段, + // 但源任务行的 [⭐] 标记必须原样保留(带 [⭐] 的行 ≥ 3)。 + const md = store.serialize(); + check('序列化结果不含「# 重要任务」镜像段', !md.includes('# 重要任务'), + md.split('\n').slice(0, 8).join('\n')); + check('源任务 [⭐] 标记至少 3 条', (md.match(/\[⭐\]/g) || []).length >= 3); + + // 10.3 混合:传 current=true,B 已经是 current=false → changed=3 + store.batchUpdateMeta(refs, { current: false }); // 先全部设为 false(changed=3) + // 此时再设 current=false,changed=0(无变化) + const r2 = store.batchUpdateMeta(refs, { current: false }); + check('全部已经是目标值 → changed=0', r2.changed === 0, JSON.stringify(r2)); + + // 10.4 partial meta:只传 important,不传 current → current 不被改 + store.batchUpdateMeta(refs, { current: true }); + store.batchUpdateMeta([refs[0]], { important: true }); // 仅改 important + check('partial meta → 仅 important 字段变', + work.tasks[0].important === true && work.tasks[0].current === true); + + // 10.5 空 meta → 0 changed + const r3 = store.batchUpdateMeta(refs, {}); + check('空 meta → changed=0', r3.changed === 0); + + // 10.6 null meta → 0 changed + const r4 = store.batchUpdateMeta(refs, null); + check('null meta → changed=0', r4.changed === 0); +} + +// ============================================================ +// [11] batchUpdateMeta —— TRASH 也允许改 ⭐/▶(v3.6+) +// +// 旧行为:TRASH 内的任务被批量改 meta 时整批跳过(守卫一刀切)。 +// 新行为:⭐ / ▶ 是任务级状态,回收站里也能改。其它破坏性跨分类操作 +// (删除 / 移动 / 完成态切换)仍按各自守卫走,不在这里放松。 +// ============================================================ +console.log('\n[11] batchUpdateMeta —— TRASH 也允许改 ⭐/▶(v3.6+)'); + +{ + const store = setupCrossCategoryStore(); + const work = store.getCategory('工作'); + store.deleteTask('工作', work.tasks[0].id); + const trash = store.getTrashCategory(); + const trashedId = trash.tasks[0].id; + + const r = store.batchUpdateMeta( + [{ categoryName: TRASH_NAME, taskId: trashedId }], + { important: true } + ); + check('TRASH 内任务改 important → changed=1, skipped=0', + r.changed === 1 && r.skipped === 0, JSON.stringify(r)); + check('回收站任务已被修改为 important=true', + trash.tasks[0].important === true); + + // 反向:批量改 current 也是同样的对称行为 + const r2 = store.batchUpdateMeta( + [{ categoryName: TRASH_NAME, taskId: trashedId }], + { current: true } + ); + check('TRASH 内任务改 current → changed=1, skipped=0', + r2.changed === 1 && r2.skipped === 0, JSON.stringify(r2)); + check('回收站任务已被修改为 current=true', + trash.tasks[0].current === true); + + // markdown 往返:writer 应该写出 [⭐][▶] 给回收站里的这条任务 + const md = store.serialize(); + check('serialize 后回收站行带 [⭐][▶] 标记', + /- \[ \] \[▶\] \[⭐\].*?工作任务 A/.test(md), + md); +} + +// ============================================================ +// [12] 综合场景:智能列表下的 _fromCategory 路径 +// +// 这是 UI 层的常见调用方式:用户在「当前任务」智能视图里选 3 条 +// (来自 3 个不同子分类)→ 一键移到「工作」。 +// 测试模拟 UI 收集到的 taskRefs(含真实 fromCategory)。 +// ============================================================ +console.log('\n[12] 智能列表下的批量移动(按 _fromCategory)'); + +{ + const store = setupCrossCategoryStore(); + const work = store.getCategory('工作'); + const study = store.getCategory('学习'); + const life = store.getCategory('生活'); + + // 给「学习任务 A」打 current 标记,模拟智能列表视图的 enriched 数据 + study.tasks[0].current = true; + + // UI 层在「当前任务」智能视图里看到这条任务时,cat.tasks 里有 _fromCategory + // 这里直接用真实 fromCategory 构造 refs(实际 UI 会从 enriched 副本读) + const refs = [ + { categoryName: '学习', taskId: study.tasks[0].id } + ]; + + store.batchMoveTasks(refs, '工作'); + check('从学习移动到工作后,学习剩 0 条', study.tasks.length === 0); + check('从学习移动到工作后,工作含学习任务 A', + work.tasks.find(t => t.text === '学习任务 A') !== undefined); + check('移动后 current 标记保留', + work.tasks.find(t => t.text === '学习任务 A').current === true); +} + +// ============================================================ +// [13] 综合:批量删除 + 往返 + 回收站顺序 +// ============================================================ +console.log('\n[13] 综合往返:批量删除后 serialize → parse 保真'); + +{ + const store = setupCrossCategoryStore(); + const work = store.getCategory('工作'); + const life = store.getCategory('生活'); + + // 删 2 条 + store.batchDeleteTasks([ + { categoryName: '工作', taskId: work.tasks[0].id }, + { categoryName: '生活', taskId: life.tasks[1].id } + ]); + + const md = store.serialize(); + const reloaded = parseMarkdown(md); + + const reloadedWork = reloaded.find(c => c.name === '工作'); + const reloadedLife = reloaded.find(c => c.name === '生活'); + const reloadedTrash = reloaded.find(c => c.name === TRASH_NAME && c.kind === CategoryKind.TRASH); + + // 多重集比较 + const liveSet = new Set(); + for (const cat of reloaded) { + if (cat.name === OTHER_TASKS_NAME || cat.name === TRASH_NAME) continue; + for (const t of cat.tasks) liveSet.add(t.text + '|' + t.completed); + } + for (const t of reloadedTrash?.tasks || []) liveSet.add(t.text + '|' + t.completed); + + const expected = new Set([ + // v3.4 fixture:B 已迁到 # 已完成任务分类,A2 仍在工作分类 + '工作任务 B(已完成)|true', + '工作任务 A2|false', + '学习任务 A|false', + '生活任务 A|false', + '工作任务 A|false', + '生活任务 B|false' + ]); + check('综合往返后任务多重集完全一致', + liveSet.size === expected.size && [...expected].every(x => liveSet.has(x)), + `live=${[...liveSet]}, expected=${[...expected]}`); + + // 关键不变量:task.id 不写文件 + check('文件里不包含 id 注释', !md.includes(' 注释被剥掉(v3+ 写入器不再输出,仅用于旧文件迁移)'); +{ + // 锚定行尾之前的 bug:正则 `//` 没有 $ 锚, + // 会从文本中间抠出第一个匹配,把用户的字面字符串删掉。例如: + // `- [ ] 在 markdown 里可以这样写注释:示例` + // 旧实现会把 `` 部分静默剥掉,留下「示例」。 + // 锚定行尾后,只剥写时器挂在末尾的注释。 + // + // v3+ 写入器不再写 id 注释,解析器也不再从文件中提取 id 作为任务身份 + // (task.id 改由末尾的统一计数器分配)。但行尾 id 注释的剥离仍要保留, + // 否则旧文件中的 `` 会作为字面字符出现在 UI 文本里。 + const t1 = firstTask('# X\n\n## A\n\n- [ ] 写注释示例 中间内容'); + check('文本中间的 保留为字面文本', + t1 && t1.text === '写注释示例 中间内容', + JSON.stringify(t1)); + + // 行尾的 id 注释被剥掉;id 由解析器统一分配(不再是文件里的值) + const t2 = firstTask('# X\n\n## A\n\n- [ ] 普通任务 '); + check('行尾的 id 注释被剥掉,文本干净,task.id 由解析器分配', + t2 && t2.text === '普通任务' && typeof t2.id === 'string' && t2.id !== 't99', + JSON.stringify(t2)); + + // 文本里同时含行中和行尾两个,剥尾留中(行中的也作为文本) + const t3 = firstTask('# X\n\n## A\n\n- [ ] 行中也有'); + // 行尾锚 $ 匹配 ;行中的 保留 + check('行中 + 行尾两个 id 注释:剥尾留中', + t3 && typeof t3.id === 'string' && t3.text === '行中也有', + JSON.stringify(t3)); +} + +// ============================================================ +// [N+1] 标记剥除后必须保留任务文本里的内部连续空格(H8 回归) +// ============================================================ +// +// 旧实现 extractInlineMarkers 末尾用 `text.replace(/\s+/g, ' ').trim()` 做"全局空白折叠", +// 触发条件是任务带了 `[⭐]` / `[▶]` / `[原分类:xxx]` 任一标记 —— +// 把用户原本写的 `前后 多空 格`(保留 N 个空格)压成 `前后 多空 格`(每个间隔 1 个空格)。 +// 数据损坏静默:保存时 text 已经丢空格,下次读回来还是丢。 +// +// 修复:去掉全局折叠,只 trim 两端。 +console.log('\n[N+1] 标记剥除后保留任务文本里的内部连续空格'); +{ + const t1 = firstTask('# X\n\n## A\n\n- [ ] [⭐]前后 多空 格'); + check('[⭐] + 内部 2 空格保留', + t1 && t1.important === true && t1.text === '前后 多空 格', + JSON.stringify(t1)); + + const t2 = firstTask('# X\n\n## A\n\n- [ ] [▶] 任务 多空 文本 [⭐]'); + check('[▶] head + [⭐] tail + 内部 2 空格保留', + t2 && t2.important === true && t2.current === true && t2.text === '任务 多空 文本', + JSON.stringify(t2)); + + const t3 = firstTask('# X\n\n## A\n\n- [ ] [原分类:工作] A B C'); + check('[原分类:xxx] + 内部 2 空格保留', + t3 && t3.originalCategory === '工作' && t3.text === 'A B C', + JSON.stringify(t3)); + + const t4 = firstTask('# X\n\n## A\n\n- [ ] foo bar baz [⭐]'); + check('tail [⭐] + 内部 2 空格保留', + t4 && t4.important === true && t4.text === 'foo bar baz', + JSON.stringify(t4)); +} + +// ============================================================ +// [N] writeMarkdown 在异常输入下必须抛错而不是静默吞掉 +// ============================================================ +{ + console.log('\n[writer] 空 categories 必须抛错(防止静默清空磁盘文件)'); + let threwEmpty = false; + try { + writeMarkdown([]); + } catch (e) { + threwEmpty = true; + } + check('空数组 throw(不是返回空字符串)', threwEmpty); + + console.log('\n[writer] 非数组输入必须 throw'); + let threwNonArray = false; + try { + writeMarkdown(null); + } catch (e) { + threwNonArray = true; + } + check('null throw', threwNonArray); +} + +console.log(`\n通过 ${summary.pass} / 失败 ${summary.fail}`); +process.exit(summary.fail === 0 ? 0 : 1); diff --git a/scripts/check-md-ui-sync.mjs b/scripts/check-md-ui-sync.mjs new file mode 100644 index 0000000..55eed07 --- /dev/null +++ b/scripts/check-md-ui-sync.mjs @@ -0,0 +1,867 @@ +// 全面回归:markdown ↔ UI 同步契约 +// 覆盖「文件字符 → 内存字段 → UI 渲染 → 写回文件」整条链路 +// +// 关键不变量(v3.4 起): +// - completed=true 的任务**只能**活在 kind=COMPLETED 分类 +// - completed=false 的任务**只能**活在 kind=NORMAL 子分类 +// - 子分类相对顺序在 roundtrip 后保留 +// - 任务顺序在子分类内 roundtrip 后保留 +// - 所有 inline 标记([⭐] / [▶] / [原分类:xxx])在 parser / writer 间对称 + +import { parseMarkdown, CategoryKind, extractInlineMarkers } from '../src/markdown-parser.js'; +import { check, ok, bad, summary, printSummary } from './_lib/check.mjs'; +import { makeStore } from './_lib/store-fixture.mjs'; +import { writeMarkdown, createDefaultDoc } from '../src/markdown-writer.js'; +import { TaskStore } from '../src/task-store.js'; +import { escapeHtml } from '../src/utils/dom.js'; +import { installApiStub } from './_lib/api-stub.mjs'; + +// 把 jsdom 没有的环境变量兜底 —— 多个测试都依赖 store 但只设一次即可 +function setupWindow() { + installApiStub(); +} +setupWindow(); + +const TEST_ID = 'check-md-ui-sync'; + +// 提取 `## X` / `# X` 段(从该标题到下一个 ## / # 之前)。 +// 用于边界断言,避免贪婪正则跨段匹配污染测试信号。 +// sectionMarker: e.g. '## 工作' 或 '# 回收站' +function extractSection(md, sectionMarker) { + const start = md.indexOf(sectionMarker); + if (start < 0) return ''; + const after = start + sectionMarker.length; + const nextH2 = md.indexOf('\n## ', after); + const nextH1 = md.indexOf('\n# ', after); + let end; + if (nextH2 > 0 && nextH1 > 0) end = Math.min(nextH2, nextH1); + else if (nextH2 > 0) end = nextH2; + else if (nextH1 > 0) end = nextH1; + else end = md.length; + return md.slice(start, end); +} + +// ============================================================ +// [1] 基础字段 roundtrip:text / completed / important / current +// ============================================================ +console.log('\n[1] 字段 roundtrip:text / completed / important / current'); +{ + const md = `# 全部任务 + +## 工作 + +- [ ] 普通任务 +- [ ] [⭐] 重要任务 +- [ ] [▶] 当前任务 +- [ ] [▶] [⭐] 既当前又重要 +- [ ] 任务有 [⭐] 但是 inline +- [ ] 任务有 [▶] inline +- [✓] 已完成的任务 +- [✓] [▶] [⭐] 完成且当前且重要 +`; + const store = new TaskStore(); + store.loadFromContent(md, null); + store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); + + const work = store.getCategory('工作'); + // 加载后 [✓] 任务被搬到「# 已完成任务」分类,「工作」里只剩 6 条 + check('[1] [✓] 已搬到「# 已完成任务」', work.tasks.length === 6, + `实际 ${work.tasks.length}`); + + check('[1] 普通任务文本', work.tasks[0]?.text === '普通任务'); + check('[1] 普通任务未完成', work.tasks[0]?.completed === false); + check('[1] 普通任务不重要', work.tasks[0]?.important === false); + check('[1] 普通任务非当前', work.tasks[0]?.current === false); + + check('[1] 重要任务标记', work.tasks.find(t => t.text === '重要任务')?.important === true); + check('[1] 当前任务标记', work.tasks.find(t => t.text === '当前任务')?.current === true); + check('[1] 既当前又重要 current', work.tasks.find(t => t.text === '既当前又重要')?.current === true); + check('[1] 既当前又重要 important', work.tasks.find(t => t.text === '既当前又重要')?.important === true); + + // parser 只剥行首/行尾位置的 [⭐] [▶];行内的保留为字面字符串 + const inline = work.tasks.filter(t => t.text.includes('[⭐]') || t.text.includes('[▶]')); + check('[1] 行内 [⭐] [▶] 保留在文本', inline.length === 2); + check('[1] 行内 [⭐] 不算 important', inline.every(t => !t.important)); + check('[1] 行内 [▶] 不算 current', inline.every(t => !t.current)); + + // 「工作」里的 6 条任务的文本都不能含行首/行尾位置的 [⭐] [▶] + const hasLeftoverMarker = work.tasks.some(t => + /^[\[((]?[⭐▶]/.test(t.text.trim()) || + /[\[((]?[⭐▶][\]))]?$/.test(t.text.trim()) + ); + check('[1] 文本不含行首/行尾 [⭐] [▶]', !hasLeftoverMarker); + + const completed = store.getCompletedCategory(); + check('[1] [✓] 任务搬到「# 已完成任务」', !!completed); + check('[1] 已完成分类含「已完成的任务」', + completed?.tasks?.some(t => t.text === '已完成的任务' && t.completed === true)); + check('[1] 已完成分类含「完成且当前且重要」', + completed?.tasks?.some(t => t.text === '完成且当前且重要' && t.completed === true)); +} + +// ============================================================ +// [2] parser → writer → parser 幂等 +// ============================================================ +console.log('\n[2] parser → writer → parser 幂等'); +{ + const md = `# 全部任务 + +## 工作 + +- [ ] a +- [ ] b +- [ ] [⭐] c +- [ ] [▶] d + +## 学习 + +- [ ] e +`; + const cats1 = parseMarkdown(md); + const written1 = writeMarkdown(cats1); + const cats2 = parseMarkdown(written1); + const written2 = writeMarkdown(cats2); + + check('[2] 两次写出结果一致(幂等)', written1 === written2, + `\n--- 第一次 ---\n${written1}\n--- 第二次 ---\n${written2}`); + + // 字段一致性 + const work1 = cats1.find(c => c.name === '工作'); + const work2 = cats2.find(c => c.name === '工作'); + check('[2] 「工作」任务数一致', work1.tasks.length === work2.tasks.length); + check('[2] 「工作」任务文本一致', + work1.tasks.map(t => t.text).join(',') === work2.tasks.map(t => t.text).join(',')); + check('[2] 「工作」important 字段一致', + work1.tasks.map(t => t.important).join(',') === work2.tasks.map(t => t.important).join(',')); + check('[2] 「工作」current 字段一致', + work1.tasks.map(t => t.current).join(',') === work2.tasks.map(t => t.current).join(',')); + + // writer 对 [✓] 任务的处理 + const mdWithDone = `# 全部任务 + +## 工作 + +- [ ] 未完成 +- [✓] 已完成 +`; + const catsWithDone = parseMarkdown(mdWithDone); + // parser 后 [✓] 在「工作」里 + check('[2] parser 后 [✓] 在「工作」里', + catsWithDone.find(c => c.name === '工作')?.tasks[1]?.completed === true); + // writer 写出 [✓] + check('[2] writer 输出 [✓]', writeMarkdown(catsWithDone).includes('- [✓] 已完成')); +} + +// ============================================================ +// [3] 子分类相对顺序保留 +// ============================================================ +console.log('\n[3] 子分类相对顺序保留'); +{ + const md = `# 全部任务 + +## 工作 + +- [ ] a + +## 学习 + +- [ ] b + +## 生活 + +- [ ] c + +## 未分类 + +- [ ] d +`; + const cats = parseMarkdown(md); + const subNames = cats.filter(c => c.parentOtherTasks).map(c => c.name); + check('[3] parser 顺序 工作/学习/生活/未分类', + subNames.join(',') === '工作,学习,生活,未分类', subNames.join(',')); + + const written = writeMarkdown(cats); + const cats2 = parseMarkdown(written); + const subNames2 = cats2.filter(c => c.parentOtherTasks).map(c => c.name); + check('[3] roundtrip 顺序一致', + subNames.join(',') === subNames2.join(','), subNames2.join(',')); +} + +// ============================================================ +// [4] 任务顺序在子分类内保留 +// ============================================================ +console.log('\n[4] 任务顺序保留'); +{ + const md = `# 全部任务 + +## 工作 + +- [ ] 1st +- [ ] 2nd +- [ ] 3rd +- [ ] 4th +`; + const cats = parseMarkdown(md); + const written = writeMarkdown(cats); + const cats2 = parseMarkdown(written); + const work2 = cats2.find(c => c.name === '工作'); + check('[4] roundtrip 任务顺序', + work2.tasks.map(t => t.text).join(',') === '1st,2nd,3rd,4th'); +} + +// ============================================================ +// [5] 特殊字符与 HTML 转义 +// ============================================================ +console.log('\n[5] 特殊字符与 HTML 转义'); +{ + const md = `# 全部任务 + +## 工作 + +- [ ] 普通 +- [ ] 含 <尖括号> +- [ ] 含 & 符号 +- [ ] 含 "双引号" +- [ ] 含 '单引号' +- [ ] 含 [方括号] +- [ ] 含 (圆括号) +- [ ] 含 \\反斜杠\\ +`; + const cats = parseMarkdown(md); + const work = cats.find(c => c.name === '工作'); + check('[5] 8 条任务', work.tasks.length === 8, `实际 ${work.tasks.length}`); + + const written = writeMarkdown(cats); + check('[5] writer 保留 <尖括号>', written.includes('含 <尖括号>')); + check('[5] writer 保留 &', written.includes('含 & 符号')); + check('[5] writer 保留 "', written.includes('含 "双引号"')); + check('[5] writer 保留 [方括号]', written.includes('含 [方括号]')); + + const lt = work.tasks.find(t => t.text === '含 <尖括号>'); + check('[5] parser 正确解析 <尖括号>', lt?.text === '含 <尖括号>'); + const escaped = escapeHtml(lt.text); + check('[5] UI escape 不破坏文本结构', escaped.includes('<')); +} + +// ============================================================ +// [6] 空文本任务 +// ============================================================ +console.log('\n[6] 空文本任务'); +{ + const md = `# 全部任务 + +## 工作 + +- [ ] +- [✓] +- [ ] [⭐] +`; + // parser 不做迁移(迁移是 store 的事)—— 3 条全在「工作」里 + const cats = parseMarkdown(md); + const work = cats.find(c => c.name === '工作'); + check('[6] parser 保留 3 条任务(含空文本)', + work.tasks.length === 3, + `实际 ${work.tasks.length}`); + + // 加载到 store 后,[✓] 被搬到「# 已完成任务」分类 + const store = new TaskStore(); + store.loadFromContent(md, null); + store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); + check('[6] 加载后「工作」剩 2 条([✓] 被迁移)', + store.getCategory('工作').tasks.length === 2, + `实际 ${store.getCategory('工作').tasks.length}`); + check('[6] 加载后「# 已完成任务」有 1 条空文本', + store.getCompletedCategory()?.tasks?.some(t => t.text === '')); + + const written = writeMarkdown(cats); + check('[6] writer 输出空文本任务', /- \[ \]\s*\n/.test(written)); + // parser 不做迁移,所以「## 工作」段里也会有 [✓] 空文本 —— 写回后由 store 迁移处理 + check('[6] writer 输出空 [✓] 任务(在源段,因为 parser 不迁移)', + /- \[✓\]\s*\n/.test(written)); + + // 走 store 加载链路后,[✓] 被搬到已完成分类,写回就在已完成段了 + const writtenViaStore = store.serialize(); + const completedSec = extractSection(writtenViaStore, '# 已完成任务'); + check('[6] store.serialize() 后 [✓] 在已完成段', + /\[✓\]/.test(completedSec), + `已完成段:\n${completedSec}`); +} + +// ============================================================ +// [7] 中文字符、emoji 与空格 +// ============================================================ +console.log('\n[7] 中文字符、emoji 与空格'); +{ + const md = `# 全部任务 + +## 工作 + +- [ ] 中文任务:你好世界 +- [ ] Emoji 🎉🚀✨ +- [ ] 含多个空格的 任务 +- [ ] 首尾空格 任务 +`; + const cats = parseMarkdown(md); + const work = cats.find(c => c.name === '工作'); + check('[7] 中文文本完整', work.tasks.find(t => t.text === '中文任务:你好世界')?.text === '中文任务:你好世界'); + check('[7] Emoji 完整', work.tasks.find(t => t.text.includes('🎉'))?.text.includes('🎉')); + check('[7] 中间多个空格保留', + work.tasks.find(t => t.text === '含多个空格的 任务')?.text === '含多个空格的 任务'); +} + +// ============================================================ +// [8] BOM 头处理 +// ============================================================ +console.log('\n[8] BOM 头处理'); +{ + const md = '# 全部任务\n\n## 工作\n\n- [ ] a\n'; + const cats = parseMarkdown(md); + const work = cats.find(c => c.name === '工作'); + check('[8] BOM 不会让整个文件被忽略', work?.tasks?.length === 1, + `tasks=${work?.tasks?.length}`); + check('[8] BOM 文件后任务 a 存在', work.tasks[0]?.text === 'a'); +} + +// ============================================================ +// [9] CRLF 行尾 +// ============================================================ +console.log('\n[9] CRLF 行尾'); +{ + const md = '# 全部任务\r\n\r\n## 工作\r\n\r\n- [ ] 任务1\r\n- [✓] 任务2\r\n'; + const cats = parseMarkdown(md); + const work = cats.find(c => c.name === '工作'); + check('[9] CRLF 行尾 任务1', work.tasks.find(t => t.text === '任务1')?.text === '任务1'); + // [✓] 任务在 parser 阶段还在「工作」里;migration 走 loadFromContent 才会搬 + check('[9] 文本无 \\r', work.tasks.every(t => !t.text.includes('\r'))); +} + +// ============================================================ +// [10] [原分类:xxx] 标记 - 仅在 COMPLETED/TRASH 出现 +// ============================================================ +console.log('\n[10] [原分类:xxx] 标记'); +{ + const md = `# 全部任务 + +## 工作 + +- [ ] 普通任务 + +## 未分类 + +- [✓] [原分类:工作] 已完成的工作任务 + +# 已完成任务 + +- [✓] [原分类:工作] 已完成的工作任务 2 +`; + const store = new TaskStore(); + store.loadFromContent(md, null); + store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); + + const written = store.serialize(); + check('[10] writer 输出 [原分类:工作]', written.includes('[原分类:工作]')); + check('[10] writer 在「# 已完成任务」分类下写 [原分类:工作]', + /# 已完成任务[\s\S]*\[原分类:工作\]/.test(written)); + + // 关键断言:「# 已完成任务」之前的所有子分类段落都不能出现 [原分类:xxx] + // 用截取已完成段之前的全部内容做正则检查 + const beforeCompleted = written.split('# 已完成任务')[0]; + check('[10] 子分类段不含 [原分类:xxx]', + !beforeCompleted.includes('[原分类:'), + `beforeCompleted 内容:\n${beforeCompleted}`); +} + +// ============================================================ +// [11] extractInlineMarkers 边界情况 +// ============================================================ +console.log('\n[11] extractInlineMarkers 边界情况'); +{ + const empty = extractInlineMarkers(''); + check('[11] 空文本', empty.text === '' && !empty.important && !empty.current); + + const onlyMarkers = extractInlineMarkers('[⭐] [▶]'); + check('[11] 只有标记 - 文本为空', onlyMarkers.text === ''); + check('[11] 只有标记 - important', onlyMarkers.important === true); + check('[11] 只有标记 - current', onlyMarkers.current === true); + + const tailMarkers = extractInlineMarkers('任务 [⭐] [▶]'); + check('[11] 行尾 [⭐] 抽走', !tailMarkers.text.includes('[⭐]')); + check('[11] 行尾 [▶] 抽走', !tailMarkers.text.includes('[▶]')); + check('[11] 行尾 important=true', tailMarkers.important === true); + check('[11] 行尾 current=true', tailMarkers.current === true); + + const inline = extractInlineMarkers('对比 [⭐] 和 [ ]'); + check('[11] 行内 [⭐] 保留', inline.text.includes('[⭐]')); + check('[11] 行内 [⭐] 不算 important', inline.important === false); + + const reversed = extractInlineMarkers('[⭐] [▶] 任务'); + check('[11] 反向顺序 important=true', reversed.important === true); + check('[11] 反向顺序 current=true', reversed.current === true); + check('[11] 反向顺序 文本干净', reversed.text === '任务'); + + const legacyCurrent = extractInlineMarkers('@当前 任务'); + check('[11] @当前 抽走', legacyCurrent.current === true && !legacyCurrent.text.includes('@当前')); + + const legacyStar = extractInlineMarkers('任务 ⭐'); + check('[11] 裸 ⭐ 抽走', legacyStar.important === true && !legacyStar.text.includes('⭐')); + + const legacyPlay = extractInlineMarkers('任务 ▶'); + check('[11] 裸 ▶ 抽走', legacyPlay.current === true && !legacyPlay.text.includes('▶')); + + const origCat = extractInlineMarkers('[原分类:工作] 任务'); + check('[11] [原分类:xxx] 抽走', origCat.originalCategory === '工作' && !origCat.text.includes('[原分类')); +} + +// ============================================================ +// [12] 默认文档结构完整 +// ============================================================ +console.log('\n[12] createDefaultDoc 默认结构'); +{ + const doc = createDefaultDoc(); + const names = doc.map(c => c.name); + check('[12] 容器存在', doc.some(c => c.kind === CategoryKind.OTHER_TASKS)); + check('[12] 包含 工作/学习/生活/未分类', + names.includes('工作') && names.includes('学习') && names.includes('生活') && names.includes('未分类')); + check('[12] 容器位置在最前(OTHER_TASKS)', + doc[0].kind === CategoryKind.OTHER_TASKS); + check('[12] 「未分类」是子分类', doc.find(c => c.name === '未分类')?.parentOtherTasks === true); +} + +// ============================================================ +// [13] 智能列表视图聚合正确 +// ============================================================ +console.log('\n[13] 智能列表视图聚合正确'); +{ + const md = `# 全部任务 + +## 工作 + +- [ ] [▶] 工作当前 +- [ ] [⭐] 工作重要 + +## 学习 + +- [ ] [▶] 学习当前 +- [ ] 普通任务 + +# 已完成任务 + +- [✓] [▶] 已完成但带当前 +`; + const store = new TaskStore(); + store.loadFromContent(md, null); + store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); + + const smart = store.collectSmartListTasks(); + // v3.4:完成 = 归档。已勾选的任务从「当前任务」「重要任务」聚合里消失, + // 只通过「已完成任务」分类暴露。 + // v4.1:toggleTask 勾选时**不再**清 task.current(标记与 completed 正交, + // 详见 collectSmartListTasks 注释),所以 completed+current 共存是正常形态 —— + // 但聚合排除规则不变:状态保留在文件里,只是不参与「当前任务」视图。 + check('[13] current 视图 2 条(不含已完成的)', + smart.current.length === 2, + smart.current.map(t => t.text).join(',')); + check('[13] important 视图 1 条', + smart.important.length === 1, + smart.important.map(t => t.text).join(',')); + check('[13] allTasks 视图 4 条(子分类下未完成,不含已完成分类)', + smart.allTasks.length === 4, + smart.allTasks.map(t => t.text).join(',')); + check('[13] completed 视图 1 条', + smart.completed.length === 1, + smart.completed.map(t => t.text).join(',')); +} + +// ============================================================ +// [14] 子分类重命名 / 删除后文件 roundtrip +// ============================================================ +console.log('\n[14] 子分类重命名 / 删除后文件 roundtrip'); +{ + const store = new TaskStore(); + store.loadFromContent('# 全部任务\n\n## 工作\n\n- [ ] a\n- [ ] b\n\n## 学习\n\n- [ ] c\n', null); + store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); + + store.renameCategory('工作', '任务'); + check('[14] 重命名成功', store.getCategory('任务')?.name === '任务'); + check('[14] 旧名不存在', !store.getCategory('工作')); + + const written = store.serialize(); + const cats2 = parseMarkdown(written); + check('[14] roundtrip 重命名后名字保留', + !!cats2.find(c => c.name === '任务')); + check('[14] roundtrip 重命名后任务还在', + !!cats2.find(c => c.name === '任务')?.tasks.find(t => t.text === 'a')); + + store.deleteCategory('任务'); + const written2 = store.serialize(); + const cats3 = parseMarkdown(written2); + const uncat = cats3.find(c => c.name === '未分类'); + check('[14] 删除后任务到「未分类」', + uncat?.tasks?.length === 2, + `实际 ${uncat?.tasks?.length}`); +} + +// ============================================================ +// [15] writeMarkdown 在空状态下的兜底 +// ============================================================ +console.log('\n[15] writeMarkdown 边界'); +{ + let threw = false; + try { writeMarkdown([]); } catch { threw = true; } + check('[15] 空数组抛错', threw); + + threw = false; + try { writeMarkdown(null); } catch { threw = true; } + check('[15] null 抛错', threw); + + const cats = [{ + name: '全部任务', + kind: CategoryKind.OTHER_TASKS, + isSpecial: true, + meta: null, + parentOtherTasks: false, + tasks: [] + }, { + name: '未分类', + kind: CategoryKind.NORMAL, + isSpecial: false, + meta: null, + parentOtherTasks: true, + tasks: [{ text: 'a', completed: false, important: false, current: false }] + }]; + const out = writeMarkdown(cats); + check('[15] 最小文档能写出', + out.includes('# 全部任务') && out.includes('## 未分类') && out.includes('- [ ] a')); +} + +// ============================================================ +// [16] updateTaskText 写回后的 roundtrip +// ============================================================ +console.log('\n[16] UI 文本编辑 → 文件 roundtrip'); +{ + const store = new TaskStore(); + store.loadFromContent('# 全部任务\n\n## 工作\n\n- [ ] 原文本\n', null); + store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); + + const work = store.getCategory('工作'); + const taskId = work.tasks[0].id; + store.updateTaskText('工作', taskId, '新文本 & 含特殊 <字符>'); + + const written = store.serialize(); + check('[16] 写回包含新文本', written.includes('新文本 & 含特殊 <字符>')); + + const cats2 = parseMarkdown(written); + const work2 = cats2.find(c => c.name === '工作'); + check('[16] 重新解析后文本一致', + work2.tasks[0]?.text === '新文本 & 含特殊 <字符>'); +} + +// ============================================================ +// [17] 完整操作序列 roundtrip(含 toggleTask 跨分类) +// ============================================================ +console.log('\n[17] 完整操作序列 roundtrip'); +{ + const store = new TaskStore(); + store.loadFromContent('# 全部任务\n\n## 工作\n\n- [ ] task1\n- [ ] task2\n', null); + store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); + + const work = store.getCategory('工作'); + const t1 = work.tasks.find(t => t.text === 'task1').id; + const t2 = work.tasks.find(t => t.text === 'task2').id; + + // 1) 标记 task1 为重要 + store.updateTaskMeta('工作', t1, { important: true }); + // 2) 标记 task2 为当前 + store.updateTaskMeta('工作', t2, { current: true }); + // 3) 勾选 task1 → 搬到「# 已完成任务」 + store.toggleTask('工作', t1); + + const written = store.serialize(); + check('[17] 写回含 [▶] task2([▶] 在 [⭐] 前)', + written.includes('[▶] task2')); + const completedSection = extractSection(written, '# 已完成任务'); + const workSection17 = extractSection(written, '## 工作'); + check('[17] 写回含 [⭐] task1(在已完成段)', + /\[⭐\][\s\S]*?task1/.test(completedSection), + `completedSection:\n${completedSection}`); + check('[17] 写回含 [✓] task1(在已完成段)', + /\[✓\][\s\S]*?task1/.test(completedSection)); + check('[17] task1 不在工作分类段', + !workSection17.includes('task1'), + `工作段:\n${workSection17}`); + + // 重新加载 + const store2 = new TaskStore(); + store2.loadFromContent(written, null); + store2._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); + + const completed2 = store2.getCompletedCategory(); + const work2 = store2.getCategory('工作'); + check('[17] 重载 task1 在「# 已完成任务」且 important=true', + completed2?.tasks?.some(t => t.text === 'task1' && t.important === true && t.completed === true)); + check('[17] 重载 task2 在「工作」且 current=true', + work2?.tasks?.some(t => t.text === 'task2' && t.current === true)); + + // 4) 取消勾选 task1 → 回到原分类(工作) + const completedTaskId = completed2.tasks.find(t => t.text === 'task1').id; + // 关键:调用 toggleTask 时必须用 store 里的分类名(不带 `#` 前缀) + store2.toggleTask('已完成任务', completedTaskId); + const work3 = store2.getCategory('工作'); + check('[17] 取消勾选后 task1 回到「工作」', + work3?.tasks?.some(t => t.text === 'task1')); + check('[17] 取消勾选后 important 标记仍保留', + work3?.tasks?.find(t => t.text === 'task1')?.important === true); + check('[17] 取消勾选后 originalCategory 已清掉(避免噪音)', + !work3?.tasks?.find(t => t.text === 'task1')?.originalCategory); +} + +// ============================================================ +// [18] 批量导入 → 写回 → 解析完整链路 +// (关键回归:importTasksToCategory 必须把 [✓] 搬到「# 已完成任务」, +// 否则违反 v3.4 数据归属不变量 —— 这是最近修的 bug) +// ============================================================ +console.log('\n[18] 批量导入 roundtrip + v3.4 数据归属'); +{ + const store = new TaskStore(); + store.loadFromContent('# 全部任务\n\n## 工作\n\n', null); + store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); + + const lines = [ + '- [ ] 普通任务', + '- [✓] 已完成', + '- [ ] [⭐] 重要', + '- [ ] [▶] 当前', + '- [ ] 任务有 [⭐] inline', + '裸任务', + '' + ]; + const result = store.importTasksToCategory('工作', lines); + check('[18] 导入返回 ok', result.ok === true); + check('[18] 导入 6 条(空行 skipped)', result.added === 6, + `actual ${result.added}, skipped=${result.skipped}`); + + // 关键:v3.4 数据归属不变量 —— [✓] 任务必须立即被搬到「# 已完成任务」分类 + const work = store.getCategory('工作'); + const completed = store.getCompletedCategory(); + check('[18] 「工作」里 5 条未完成任务', + work.tasks.length === 5, + `工作实际 ${work.tasks.length}`); + check('[18] 「工作」里无 completed=true 的任务(v3.4 不变量)', + work.tasks.every(t => !t.completed), + work.tasks.map(t => `${t.text}(done=${t.completed})`).join(',')); + check('[18] 「# 已完成任务」里有 1 条任务', + completed?.tasks?.length === 1, + `已完成实际 ${completed?.tasks?.length}`); + check('[18] 已完成的任务带 originalCategory=工作', + completed?.tasks[0]?.originalCategory === '工作'); + + const written = store.serialize(); + // 抽出 ## 工作 段(到下一个 ## 或 # 之前) + const workSection = extractSection(written, '## 工作'); + check('[18] 写回含 [✓] 已完成(在已完成段)', + /# 已完成任务[\s\S]*?\[✓\][\s\S]*?已完成/.test(written)); + check('[18] 写回不含 [✓] 已完成 在工作分类段', + !workSection.includes('[✓]'), + `工作段:\n${workSection}`); + + // 重新解析,结构一致 + const cats2 = parseMarkdown(written); + const work2 = cats2.find(c => c.name === '工作'); + const completed2 = cats2.find(c => c.kind === CategoryKind.COMPLETED); + check('[18] 重新解析「工作」5 条', + work2.tasks.length === 5); + check('[18] 重新解析「# 已完成任务」1 条', + completed2?.tasks?.length === 1); +} + +// ============================================================ +// [19] 标题层级:h3+ 不重置分类 +// h3 任务是孤儿,按 parser 当前行为归入上一分类 —— 避免丢数据。 +// 这是有意保留的 roundtrip 安全行为,本测试固化契约。 +// ============================================================ +console.log('\n[19] 标题层级(h3+ 不重置分类)'); +{ + const md = `# 全部任务 + +## 工作 + +- [ ] a + +### 子节 (h3) + +- [ ] b + +#### 更深 (h4) + +- [ ] c + +## 学习 + +- [ ] d +`; + const cats = parseMarkdown(md); + const work = cats.find(c => c.name === '工作'); + // parser 行为:h3 不重置 currentCategory → b/c 归入「工作」(不丢数据) + check('[19] h3+ 任务归入上一分类(避免数据丢失)', + work.tasks.length === 3, `actual ${work.tasks.length}`); + const study = cats.find(c => c.name === '学习'); + check('[19] 学习分类 1 条任务', study.tasks.length === 1); + + // writer 不输出 h3 — 视觉信息丢失但任务保留 + const written = writeMarkdown(cats); + check('[19] writer 不输出 h3/h4 标题', !/^#{3,}\s/m.test(written)); + check('[19] writer 输出所有任务', written.includes('- [ ] a') && written.includes('- [ ] b') && written.includes('- [ ] c')); +} + +// ============================================================ +// [20] 容器直接包含任务(无 ## 子分类) +// ============================================================ +console.log('\n[20] 容器直接包含任务 → 归到「未分类」'); +{ + const md = `# 全部任务 + +- [ ] 容器下直接的任务 +- [ ] [⭐] 容器下的重要任务 +`; + const cats = parseMarkdown(md); + const uncat = cats.find(c => c.name === '未分类' && c.parentOtherTasks); + check('[20] 「未分类」子分类存在', !!uncat); + check('[20] 任务归到「未分类」', uncat?.tasks?.length === 2); + + const written = writeMarkdown(cats); + check('[20] writer 输出 ## 未分类 段', written.includes('## 未分类')); +} + +// ============================================================ +// [21] 侧边栏结构 ↔ 文件结构完全一致 +// 模拟 sidebar 渲染逻辑:从 store 的 categories 数组生成 sidebar HTML, +// 再和 parser 从文件 parseMarkdown 出的 categories 对比。 +// ============================================================ +console.log('\n[21] sidebar 数据来源 = 文件结构'); +{ + const md = `# 全部任务 + +## 工作 + +- [ ] a + +## 学习 + +- [ ] b + +## 自定义分类 + +- [ ] c + +# 已完成任务 + +- [✓] done +`; + const store = new TaskStore(); + store.loadFromContent(md, null); + store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); + + const subNames = store.getSubCategories().map(c => c.name); + check('[21] 侧边栏子分类 = 工作/学习/自定义分类', + subNames.join(',') === '工作,学习,自定义分类', subNames.join(',')); + + const written = store.serialize(); + const cats2 = parseMarkdown(written); + const subNames2 = cats2.filter(c => c.parentOtherTasks).map(c => c.name); + check('[21] roundtrip 后子分类一致', + subNames.join(',') === subNames2.join(','), subNames2.join(',')); +} + +// ============================================================ +// [22] search 同步:highlightTaskText 与 case-insensitive 搜索一致 +// ============================================================ +console.log('\n[22] 搜索同步'); +{ + const store = new TaskStore(); + store.loadFromContent('# 全部任务\n\n## 工作\n\n- [ ] Hello World\n- [ ] 你好世界\n', null); + store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); + + store.setSearchQuery('hello'); + const segs = store.highlightTaskText('Hello World'); + check('[22] 大小写不敏感命中', segs.some(s => s.highlight && s.text === 'Hello')); + + store.setSearchQuery('你好'); + const segs2 = store.highlightTaskText('你好世界'); + check('[22] 中文搜索命中', segs2.some(s => s.highlight && s.text === '你好')); + + store.setSearchQuery(''); + const segs3 = store.highlightTaskText('你好世界'); + check('[22] 空搜索 → 无 highlight', + segs3.length === 1 && segs3[0].highlight === false); +} + +// ============================================================ +// [23] 删除/恢复 流程:UI 操作 → 文件 roundtrip +// ============================================================ +console.log('\n[23] 删除 → 恢复 roundtrip'); +{ + const store = new TaskStore(); + store.loadFromContent('# 全部任务\n\n## 工作\n\n- [ ] keep\n- [ ] delete-me\n', null); + store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); + + const work = store.getCategory('工作'); + const taskId = work.tasks.find(t => t.text === 'delete-me').id; + + // 删 + store.deleteTask('工作', taskId); + check('[23] 删除后「工作」剩 1 条', store.getCategory('工作').tasks.length === 1); + check('[23] 删除后回收站有 1 条', store.getTrashCategory()?.tasks?.length === 1); + + // 写回 + const written = store.serialize(); + check('[23] 写回含 # 回收站', written.includes('# 回收站')); + // 用 extractSection 严格限定只查 ## 工作 段(防止正则跨段匹配) + const workSection = extractSection(written, '## 工作'); + check('[23] 写回不含 delete-me 在工作段', + !workSection.includes('delete-me'), + `工作段:\n${workSection}`); + check('[23] 写回含 delete-me 在回收站段', + /# 回收站[\s\S]*?delete-me/.test(written)); + + // 重新加载 + const store2 = new TaskStore(); + store2.loadFromContent(written, null); + store2._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); + check('[23] 重载「工作」1 条', store2.getCategory('工作').tasks.length === 1); + check('[23] 重载回收站 1 条', store2.getTrashCategory()?.tasks?.length === 1); + + // 恢复 + const trashId = store2.getTrashCategory().tasks[0].id; + store2.restoreTask(trashId); + check('[23] 恢复后回收站 0 条', store2.getTrashCategory()?.tasks?.length === 0); + const written2 = store2.serialize(); + check('[23] 恢复后写回不含 # 回收站 段', !written2.includes('# 回收站')); +} + +// ============================================================ +// [24] 跨分类移动 + 文件 roundtrip +// ============================================================ +console.log('\n[24] 跨分类移动 roundtrip'); +{ + const store = new TaskStore(); + store.loadFromContent('# 全部任务\n\n## 工作\n\n- [ ] a\n\n## 学习\n\n- [ ] b\n', null); + store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); + + const work = store.getCategory('工作'); + const aId = work.tasks.find(t => t.text === 'a').id; + store.moveTask('工作', aId, '学习'); + check('[24] 移动后工作 0 条', store.getCategory('工作').tasks.length === 0); + check('[24] 移动后学习 2 条', store.getCategory('学习').tasks.length === 2); + + const written = store.serialize(); + // 严格提取 ## 工作 段(到下一个 ## 或 # 之前),避免正则跨段匹配 + const workSection = extractSection(written, '## 工作'); + check('[24] 工作段无 a', + !workSection.includes('- [ ] a'), + `工作段:\n${workSection}`); + check('[24] 学习段有 a', + extractSection(written, '## 学习').includes('- [ ] a')); +} + +console.log(`\n通过 ${summary.pass} / 失败 ${summary.fail}`); +process.exit(summary.fail === 0 ? 0 : 1); \ No newline at end of file diff --git a/scripts/check-menu-fontsize.mjs b/scripts/check-menu-fontsize.mjs new file mode 100644 index 0000000..61580f2 --- /dev/null +++ b/scripts/check-menu-fontsize.mjs @@ -0,0 +1,22 @@ +// 验证修复后菜单/调色板/toast 的字号在 small/medium/large 三档下都合理 +import { readFileSync } from 'fs'; + +const css = readFileSync('src/styles.css', 'utf8'); + +// 抓取 [data-font-size=...] 块里 --font-sm 的值 +const tiers = ['small', 'medium', 'large']; +const sizes = {}; +for (const t of tiers) { + const re = new RegExp(`\\[data-font-size="${t}"\\][^{]*\\{[^}]*--font-sm:\\s*([0-9.]+px)`); + const m = css.match(re); + if (m) sizes[t] = m[1]; +} +console.log('font-sm 各档:', sizes); + +// 这些元素的基础定义 +const targets = ['.more-menu-item', '.context-menu', '.command-palette-item', '.toast']; +for (const sel of targets) { + const re = new RegExp(`${sel.replace('.', '\\.')}\\s*\\{[^}]*font-size:\\s*([^;}]+)`); + const m = css.match(re); + console.log(sel, '→', m ? m[1].trim() : '(继承自父级)'); +} diff --git a/scripts/check-modal-close-animation.mjs b/scripts/check-modal-close-animation.mjs new file mode 100644 index 0000000..cf89caf --- /dev/null +++ b/scripts/check-modal-close-animation.mjs @@ -0,0 +1,166 @@ +// 回归:模态/命令面板关闭时的 250ms 兜底必须 resolve Promise。 +// +// 旧实现:animationend 漏触发(如 display:none / 元素被提前 detach)→ +// Promise 永远挂住 → 对 confirmDialog 是 await 卡死、对 command-palette +// 是 _active 永不清 → Ctrl+K 永久失效(致命)。 +// +// 验证项: +// (a) 所有 close 站点(confirmDialog / inputDialog / openCommandPalette / +// openSettingsDialog 的 finish / cancel / openConflictDialog / +// openDiffPreview / openImportDialog)都调用统一的 finalize 帮手 +// (b) finalize 里 focus() 必须在 overlay.remove() 之前 —— 否则焦点跳 body +// (c) 250ms setTimeout 必须以 finalize 作为回调(而不是只 remove 不 resolve) +// (d) settings-dialog 的 finish / cancel 必须有共享 closed 守卫防双 resolve + +import { check, ok, bad, summary, printSummary } from './_lib/check.mjs'; +import { readFileSync } from 'node:fs'; + +const FEEDBACK = readFileSync('src/ui/feedback.js', 'utf8'); +const SETTINGS_DIALOG = readFileSync('src/ui/settings-dialog.js', 'utf8'); +const COMMAND_PALETTE = readFileSync('src/ui/command-palette.js', 'utf8'); +const CONFLICT_DIALOG = readFileSync('src/ui/conflict-dialog.js', 'utf8'); +const DIFF_PREVIEW = readFileSync('src/ui/diff-preview-dialog.js', 'utf8'); +const IMPORT_DIALOG = readFileSync('src/ui/import-dialog.js', 'utf8'); + +let pass = 0, fail = 0; +function expect(name, cond, info) { + if (cond) { ok(name); pass++; } else { bad(name, info); fail++; } +} + +// 通用 helper:验证 close 函数片段是否使用 finalize / 共享 setTimeout +function expectCloseFinalize(label, source, closeNeedle) { + const startIdx = source.indexOf(closeNeedle); + if (startIdx < 0) { + bad(`${label}: 找不到 close 锚点 ${closeNeedle}`); + fail++; + return; + } + // 取 close 函数片段到下一个 const XXX / function / export 之前 + const tail = source.slice(startIdx); + const endMatch = tail.match(/\n (?:const |function |export |\})/); + const endIdx = endMatch ? startIdx + endMatch.index : source.length; + const closeBlock = source.slice(startIdx, endIdx); + + expect( + `${label} 使用 finalize() 帮手`, + /const finalize\s*=/.test(closeBlock), + `${label} 里没找到 const finalize` + ); + expect( + `${label} 的 250ms 兜底调用 finalize(不是只 remove)`, + /setTimeout\(finalize,\s*250\)/.test(closeBlock), + `${label} 的 250ms 兜底不是 finalize —— animationend 漏触发时 Promise 挂住` + ); + expect( + `${label} 中 focus 在 remove 之前`, + /finalize[\s\S]*?previouslyFocused\.focus\(\)[\s\S]*?overlay\.remove\(\)/.test(closeBlock), + `${label}.finalize 里 focus() 顺序在 remove() 之后 —— 被销毁祖先上 focus 跳 body` + ); +} + +// (a) confirmDialog close:使用 finalize / setTimeout(finalize, 250) 模式 +{ + // 抓 confirmDialog 整段代码 —— 从 'export function confirmDialog' 到 'export function inputDialog' + const startIdx = FEEDBACK.indexOf('export function confirmDialog'); + const endIdx = FEEDBACK.indexOf('export function inputDialog'); + const confirmBlock = FEEDBACK.slice(startIdx, endIdx); + expect( + 'confirmDialog.close 使用 finalize() 帮手', + /const finalize\s*=/.test(confirmBlock), + 'confirmDialog 里没找到 const finalize' + ); + expect( + 'confirmDialog.close 的 250ms 兜底调用 finalize(不是只 remove)', + /setTimeout\(finalize,\s*250\)/.test(confirmBlock), + 'confirmDialog 的 250ms 兜底不是 finalize —— animationend 漏触发时 Promise 挂住' + ); + expect( + 'confirmDialog.close 中 focus 在 remove 之前', + /finalize[\s\S]*?previouslyFocused\.focus\(\)[\s\S]*?overlay\.remove\(\)/.test(confirmBlock), + 'confirmDialog.finalize 里 focus() 顺序在 remove() 之后 —— 被销毁祖先上 focus 跳 body' + ); +} + +// (b) inputDialog close:同样模式 +{ + const startIdx = FEEDBACK.indexOf('export function inputDialog'); + // inputDialog 是最后一个 export,下一个 export 是 inputDialog 内部函数 inputDialog + // 取下一个 export function 之前的所有内容(如果没有下一个,则取到文件末尾) + const endIdx = FEEDBACK.indexOf('export function inputDialog', startIdx + 1); + const inputBlock = endIdx > 0 ? FEEDBACK.slice(startIdx, endIdx) : FEEDBACK.slice(startIdx); + expect( + 'inputDialog.close 使用 finalize() 帮手', + /const finalize\s*=/.test(inputBlock), + 'inputDialog 里没找到 const finalize' + ); + expect( + 'inputDialog.close 的 250ms 兜底调用 finalize', + /setTimeout\(finalize,\s*250\)/.test(inputBlock), + 'inputDialog 的 250ms 兜底不是 finalize' + ); + expect( + 'inputDialog.close 中 focus 在 remove 之前', + /finalize[\s\S]*?previouslyFocused\.focus\(\)[\s\S]*?overlay\.remove\(\)/.test(inputBlock), + 'inputDialog.finalize 里 focus() 顺序在 remove() 之后' + ); +} + +// (c) command-palette close:同样模式 +{ + const startIdx = COMMAND_PALETTE.indexOf('const close = () =>'); + const endIdx = COMMAND_PALETTE.indexOf(' const execute', startIdx); + const closeBlock = COMMAND_PALETTE.slice(startIdx, endIdx > 0 ? endIdx : COMMAND_PALETTE.length); + expect( + 'command-palette.close 使用 finalize() 帮手', + /const finalize\s*=/.test(closeBlock), + 'command-palette.close 里没找到 const finalize' + ); + expect( + 'command-palette.close 的 250ms 兜底调用 finalize(防 _active 永不清)', + /setTimeout\(finalize,\s*250\)/.test(closeBlock), + 'command-palette 的 250ms 兜底不是 finalize —— 这是致命:会让 Ctrl+K 永久失效' + ); +} + +// (e) conflict-dialog close:避免 Promise 永久挂起让 _doResolveExternalChangeConflict 后续 +// saveNow / toast 永远不执行 +expectCloseFinalize('conflict-dialog.close', CONFLICT_DIALOG, 'const close = (result) =>'); + +// (f) diff-preview-dialog close:用户关掉行级 diff 后焦点必须回到 conflict-dialog「查看完整 diff」按钮 +expectCloseFinalize('diff-preview.close', DIFF_PREVIEW, 'const close = () =>'); + +// (g) import-dialog close:避免 _addFromImport 后续逻辑挂起 +expectCloseFinalize('import-dialog.close', IMPORT_DIALOG, 'const close = (result) =>'); + +// (d) settings-dialog 的 finish / cancel 都用 finalize + 共享 closed 守卫 +expect( + "settings-dialog 有共享 'closed' 守卫(防 finish/cancel 双竞态)", + /let closed = false/.test(SETTINGS_DIALOG) && + /const cancel[\s\S]*?if \(closed\) return/.test(SETTINGS_DIALOG) && + /const finish[\s\S]*?if \(closed\) return/.test(SETTINGS_DIALOG), + "settings-dialog 缺 'closed' 守卫 —— finish/cancel 可并发执行,丢设置" +); + +expect( + 'settings-dialog.finish 用 finalize()', + /const finish[\s\S]*?const finalize\s*=/.test(SETTINGS_DIALOG), + 'settings-dialog.finish 里没找到 finalize' +); +expect( + 'settings-dialog.cancel 用 finalize()', + /const cancel[\s\S]*?const finalize\s*=/.test(SETTINGS_DIALOG), + 'settings-dialog.cancel 里没找到 finalize' +); + +expect( + 'settings-dialog.finish 的 250ms 兜底调用 finalize', + /setTimeout\(finalize,\s*250\)/.test(SETTINGS_DIALOG), + 'settings-dialog.finish 的 250ms 兜底不是 finalize' +); +expect( + 'settings-dialog.cancel 的 250ms 兜底调用 finalize', + SETTINGS_DIALOG.match(/const cancel[\s\S]*?setTimeout\(finalize, 250\)/) !== null, + 'settings-dialog.cancel 的 250ms 兜底不是 finalize' +); + +printSummary(pass, fail); \ No newline at end of file diff --git a/scripts/check-move-invariance.mjs b/scripts/check-move-invariance.mjs new file mode 100644 index 0000000..993c8e3 --- /dev/null +++ b/scripts/check-move-invariance.mjs @@ -0,0 +1,500 @@ +// 回归测试:moveTask 跨分类移动时必须维持"completed=true 只活在 kind=COMPLETED"不变量 +// +// v3.4 起「已完成任务」升级为真实分类,配套的不变量: +// - completed=true 任务只能活在 kind=COMPLETED 分类 +// - completed=false 任务只能活在 NORMAL 子分类 +// - 容器 (OTHER_TASKS) / 回收站 (TRASH) 跨在两侧之外(回收站可以同时有 true/false) +// +// 漏洞历史:moveTask 从回收站拖动 completed 任务到普通子分类时 +// - 旧实现:toCat.kind === NORMAL → 既不改 completed 也不改路由 → 任务落到普通子分类且 completed=true → 破坏不变量 +// - 修复:复用 restoreTask 同款规则 —— task.completed=true 且目标非 COMPLETED 时强制改投「已完成任务」 +// +// 用法:node scripts/check-move-invariance.mjs + +import { TaskStore, COMPLETED_NAME } from '../src/task-store.js'; +import { check, summary } from './_lib/check.mjs'; +import { makeStore } from './_lib/store-fixture.mjs'; +import { parseMarkdown, CategoryKind } from '../src/markdown-parser.js'; + +function roundTrip(store) { + return parseMarkdown(store.serialize()); +} + +function findCat(categories, name) { + return categories.find(c => c.name === name) || null; +} + +const BASE_DOC = `# 全部任务 + +## 工作 + +- [ ] 写周报 + +## 学习 + +- [ ] 看 Rust 入门 + +## 未分类 + +- [ ] 杂事 +`; + +// ============================================================ +// [1] moveTask 从回收站拖 completed 任务到 NORMAL → 防御性改投「已完成任务」 +// ============================================================ +console.log('\n[1] moveTask 从回收站搬 completed 任务到普通子分类 → 改投「已完成任务」'); + +{ + const store = makeStore(BASE_DOC); + // 制造一个 completed 任务在回收站:toggle → delete + const workTask = store.getCategory('工作').tasks[0]; + store.toggleTask('工作', workTask.id); // 进已完成(原分类="工作") + const completedTask = store.getCategory('已完成任务').tasks[0]; + store.deleteTask('已完成任务', completedTask.id); // 进回收站 + + const trashed = store.getTrashCategory().tasks[0]; + check('前置:任务在回收站且 completed=true', + trashed && trashed.completed === true); + + // 用户从回收站拖动到「学习」(NORMAL 子分类) + const result = store.moveTask('回收站', trashed.id, '学习'); + + // 修复后:moveTask 把任务改投到「已完成任务」并返回实际目标名 + check('moveTask 返回的是实际生效的目标分类名("已完成任务")', + result === COMPLETED_NAME, `actual=${result}`); + + const after = store.getCategory('已完成任务').tasks; + const learning = store.getCategory('学习').tasks; + check('任务落在「已完成任务」', + after.some(t => t.text === '写周报'), + `已完成任务: ${after.map(t => t.text).join(', ')}`); + check('任务不在「学习」', + !learning.some(t => t.text === '写周报'), + `学习: ${learning.map(t => t.text).join(', ')}`); + check('任务 completed 状态保持 true', + after.find(t => t.text === '写周报').completed === true); + check('任务 originalCategory 保持「工作」(改投不重写)', + after.find(t => t.text === '写周报').originalCategory === '工作', + `actual=${after.find(t => t.text === '写周报').originalCategory}`); +} + +// ============================================================ +// [2] moveTask 从回收站拖 uncompleted 任务到 NORMAL → 正常落到目标分类 +// ============================================================ +console.log('\n[2] moveTask 从回收站搬 uncompleted 任务到普通子分类 → 直接落目标分类'); + +{ + const store = makeStore(BASE_DOC); + // 制造一个 uncompleted 任务在回收站:直接 deleteTask + const workTask = store.getCategory('工作').tasks[0]; + store.deleteTask('工作', workTask.id); + + const trashed = store.getTrashCategory().tasks[0]; + check('前置:任务在回收站且 completed=false', + trashed && trashed.completed === false); + + const result = store.moveTask('回收站', trashed.id, '学习'); + + check('moveTask 返回的是用户指定的目标分类名("学习")', + result === '学习', `actual=${result}`); + check('任务落在「学习」', + store.getCategory('学习').tasks.some(t => t.text === '写周报')); + check('任务 completed === false(不变)', + store.getCategory('学习').tasks.find(t => t.text === '写周报').completed === false); +} + +// ============================================================ +// [3] moveTask 从 NORMAL 拖动到 NORMAL:返回值是目标分类名 +// ============================================================ +console.log('\n[3] moveTask 从普通子分类到普通子分类:返回目标分类名'); + +{ + const store = makeStore(BASE_DOC); + const workTask = store.getCategory('工作').tasks[0]; + const result = store.moveTask('工作', workTask.id, '学习'); + + check('moveTask 返回「学习」', + result === '学习', `actual=${result}`); + check('任务在「学习」', + store.getCategory('学习').tasks.some(t => t.text === '写周报')); + check('任务不在「工作」', + !store.getCategory('工作').tasks.some(t => t.text === '写周报')); +} + +// ============================================================ +// [4] moveTask 跨完整往返:serialize → parseMarkdown 后不变量仍然成立 +// ============================================================ +console.log('\n[4] moveTask 改投后往返到磁盘,磁盘层不变量也成立'); + +{ + const store = makeStore(BASE_DOC); + const workTask = store.getCategory('工作').tasks[0]; + store.toggleTask('工作', workTask.id); + store.deleteTask('已完成任务', store.getCategory('已完成任务').tasks[0].id); + + // 防御性改投 + store.moveTask('回收站', store.getTrashCategory().tasks[0].id, '学习'); + + const md = store.serialize(); + const cats = parseMarkdown(md); + + const completed = findCat(cats, '已完成任务'); + const learning = findCat(cats, '学习'); + // writer 兜底:空回收站整段不写出,parser 也不会凭空造出空 TRASH 分类 + // —— 这里只断言 completed/learning 各自的不变量成立 + const trash = findCat(cats, '回收站'); + + check('磁盘层:「已完成任务」下任务全部 completed=true', + completed && completed.tasks.every(t => t.completed), + `completed: ${completed?.tasks.map(t => `${t.text}(${t.completed})`).join(', ')}`); + check('磁盘层:「学习」下任务全部 completed=false', + learning && learning.tasks.every(t => !t.completed), + `learning: ${learning?.tasks.map(t => `${t.text}(${t.completed})`).join(', ')}`); + check('磁盘层:回收站不存在或为空(writer 兜底:空回收站不写出)', + !trash || trash.tasks.length === 0, + `trash: ${trash?.tasks.map(t => t.text).join(', ') || '(未写出)'}`); +} + +// ============================================================ +// [5] 完整路径:垃圾任务从未分类 → 回收站 → 拖到「学习」(NORMAL) 成功 +// ============================================================ +console.log('\n[5] 普通路径回归:从普通子分类 → 回收站 → 拖回普通子分类'); + +{ + const store = makeStore(BASE_DOC); + const uncatTask = store.getCategory('未分类').tasks[0]; + const uncatId = uncatTask.id; + + // 移到回收站 + store.deleteTask('未分类', uncatId); + check('任务进回收站', + store.getTrashCategory().tasks.some(t => t.text === '杂事')); + + // 拖到「工作」 + const result = store.moveTask('回收站', uncatId, '工作'); + check('moveTask 返回「工作」', + result === '工作', `actual=${result}`); + check('任务现在在「工作」', + store.getCategory('工作').tasks.some(t => t.text === '杂事')); + check('回收站空', + store.getTrashCategory().tasks.length === 0); +} + +// ============================================================ +// [7] moveTask 从「已完成任务」拖动 completed 任务到普通子分类 → 强制 completed=false +// (与 TRASH 防御改投的语义不同:这里尊重用户意图"让任务回到活跃工作") +// +// 漏洞历史:从「已完成任务」智能视图拖 completed 任务到 sidebar 子分类时, +// 旧实现既不强制 completed=false 也不重路由 → 任务留在 NORMAL 子分类但 completed=true, +// 破坏 v3.4 数据归属不变量。右鍵菜单 (buildStandardContextMenuItems) 已通过 +// isCompletedView 守卫屏蔽掉「移到分类…」入口,但拖拽路径没有同款护栏。 +// ============================================================ +console.log('\n[7] moveTask 从「已完成任务」搬 completed 任务到普通子分类 → 强制 completed=false'); + +{ + const store = makeStore(BASE_DOC); + // 制造一个 completed 任务在「已完成任务」分类 + const workTask = store.getCategory('工作').tasks[0]; + store.toggleTask('工作', workTask.id); // 进已完成(原分类="工作") + + const completedCat = store.getCompletedCategory(); + const completedTask = completedCat.tasks[0]; + check('前置:任务在「已完成任务」且 completed=true', + completedTask && completedTask.completed === true); + + // 用户从「已完成任务」智能视图拖动到「学习」(NORMAL 子分类)。 + // 这里 _fromCategory 模拟智能视图下的 enriched cat(move 接受的是 _fromCategory 来源名) + const result = store.moveTask('已完成任务', completedTask.id, '学习'); + + check('moveTask 返回的是用户指定的目标分类名("学习")', + result === '学习', `actual=${result}`); + check('任务落在「学习」', + store.getCategory('学习').tasks.some(t => t.text === '写周报'), + `学习: ${store.getCategory('学习').tasks.map(t => t.text).join(', ')}`); + check('任务不在「已完成任务」', + !store.getCategory('已完成任务').tasks.some(t => t.text === '写周报'), + `已完成任务: ${store.getCategory('已完成任务').tasks.map(t => t.text).join(', ')}`); + // v3.4 数据归属不变量:拖到普通分类 → 强制 completed=false + check('任务 completed 状态被强制为 false(不变量维持)', + store.getCategory('学习').tasks.find(t => t.text === '写周报').completed === false, + `actual=${store.getCategory('学习').tasks.find(t => t.text === '写周报').completed}`); + check('任务 originalCategory 清空(已不在已完成态)', + store.getCategory('学习').tasks.find(t => t.text === '写周报').originalCategory === null); +} + +// ============================================================ +// [8] moveTask 跨已完成 → 普通子分类后往返到磁盘,磁盘层不变量仍然成立 +// ============================================================ +console.log('\n[8] moveTask COMPLETED → NORMAL 后往返到磁盘,磁盘层不变量成立'); + +{ + const store = makeStore(BASE_DOC); + const workTask = store.getCategory('工作').tasks[0]; + store.toggleTask('工作', workTask.id); + + // 防御性 completed=false + store.moveTask('已完成任务', store.getCompletedCategory().tasks[0].id, '学习'); + + const md = store.serialize(); + const cats = parseMarkdown(md); + + const completed = findCat(cats, '已完成任务'); + const learning = findCat(cats, '学习'); + check('磁盘层:「学习」下任务全部 completed=false', + learning && learning.tasks.every(t => !t.completed), + `learning: ${learning?.tasks.map(t => `${t.text}(${t.completed})`).join(', ')}`); + check('磁盘层:「已完成任务」不存在或为空(writer 兜底)', + !completed || completed.tasks.length === 0, + `已完成任务: ${completed?.tasks.map(t => t.text).join(', ') || '(空)'}`); +} + +// ============================================================ +// [9] batchMoveTasks 跨已完成 → 普通子分类同样维持不变量(批量路径对称) +// ============================================================ +console.log('\n[9] batchMoveTasks 从「已完成任务」批量搬到普通子分类 → 全部 completed=false'); + +{ + const store = makeStore(BASE_DOC); + // 制造 2 条 completed 任务在「已完成任务」分类 + store.toggleTask('工作', store.getCategory('工作').tasks[0].id); // 第一条 → 已完成 + store.toggleTask('学习', store.getCategory('学习').tasks[0].id); // 第二条 → 已完成 + + const completedCat = store.getCompletedCategory(); + check('前置:「已完成任务」有 2 条任务', + completedCat.tasks.length === 2, `count=${completedCat.tasks.length}`); + + // 批量拖回「未分类」 + const refs = completedCat.tasks.map(t => ({ + categoryName: '已完成任务', + taskId: t.id + })); + const result = store.batchMoveTasks(refs, '未分类'); + check('batchMoveTasks 返回 moved=2', + result.moved === 2 && result.skipped === 0, + JSON.stringify(result)); + + const uncat = store.getCategory('未分类'); + check('任务落在「未分类」', + uncat.tasks.filter(t => t.text === '写周报' || t.text === '看 Rust 入门').length === 2); + check('「未分类」下任务全部 completed=false(不变量维持)', + uncat.tasks.every(t => !t.completed), + `未分类: ${uncat.tasks.map(t => `${t.text}(${t.completed})`).join(', ')}`); + check('「已完成任务」分类为空(writer 兜底后下次不写出)', + completedCat.tasks.length === 0); +} + +// ============================================================ +// [10] reorderTask 在「已完成任务」(kind=COMPLETED)下重排: +// - 智能视图 in-list reorder 的 store 层基础:不变量维持(completed/originalCategory 不动) +// - 磁盘顺序反映新顺序(持久化到底层分类) +// - sortBy 自动切回 MANUAL(避免下次渲染按字母覆盖) +// +// 漏洞历史:「已完成任务」智能视图下原本完全禁用 in-list 拖拽(task-list.js _updateDrag 跳过 +// _findDropTarget)—— 鼠标拖动没反应,让用户怀疑功能坏了。新豁免「已完成任务」智能视图 +// 允许 in-list reorder 后,必须确认 reorderTask 落到 kind=COMPLETED 分类是安全的: +// - 跨 _fromCategory 的 enriched 副本(理论上已完成视图里都同 _fromCategory) +// - 但测试要保护 reorderTask 不修改 completed / originalCategory —— 这些是 toggle/move +// 流程才该改的字段,单纯重排不该动它们。 +// ============================================================ +console.log('\n[10] reorderTask 在「已完成任务」分类下重排:不变量 + 持久化 + 切回 MANUAL'); + +{ + // 准备 3 条 completed 任务,分别来自不同原分类(验证 originalCategory 不被破坏) + const FIXTURE = `# 全部任务 + +## 工作 + +- [ ] 任务A(工作) + +## 学习 + +- [ ] 任务C(学习) + +## 未分类 + +- [ ] 任务D(未分类) +`; + const store = makeStore(FIXTURE); + // 用 toggleTask 把三条任务搬到「已完成任务」分类 + store.toggleTask('工作', store.getCategory('工作').tasks[0].id); // A 进已完成 + store.toggleTask('学习', store.getCategory('学习').tasks[0].id); // C 进已完成 + store.toggleTask('未分类', store.getCategory('未分类').tasks[0].id); // D 进已完成 + + const completedCat = store.getCompletedCategory(); + check('前置:「已完成任务」分类 kind=COMPLETED', + completedCat.kind === CategoryKind.COMPLETED, + `kind=${completedCat.kind}`); + check('前置:「已完成任务」下有 3 条任务', + completedCat.tasks.length === 3, + `count=${completedCat.tasks.length}`); + + // 顺序:completedPosition 默认 front(最新的放最前),所以 toggle 顺序 A→C→D + // 落到「已完成任务」分类后实际顺序是 [D, C, A] + const beforeTexts = completedCat.tasks.map(t => t.text); + check('前置:「已完成任务」下顺序 = [D, C, A]', + JSON.stringify(beforeTexts) === JSON.stringify(['任务D(未分类)', '任务C(学习)', '任务A(工作)']), + `actual=${JSON.stringify(beforeTexts)}`); + + // 切到 ALPHABET —— 验证 reorderTask 后会切回 MANUAL + store.setSortBy('alphabet'); + check('前置:sortBy=alphabet', store.sortBy === 'alphabet'); + + // 重排:把 A(idx=2,末尾)挪到开头(idx=0)。reorderTask 语义是 splice(from,1) + splice(to,0,task) + // splice(2, 1) 移除 A → [D, C] + // splice(0, 0, A) 在开头插入 A → [A, D, C] + const ok = store.reorderTask('已完成任务', 2, 0); + check('reorderTask 返回 true', ok === true); + + const afterTexts = completedCat.tasks.map(t => t.text); + check('「已完成任务」下顺序变为 [A, D, C]', + JSON.stringify(afterTexts) === JSON.stringify(['任务A(工作)', '任务D(未分类)', '任务C(学习)']), + `actual=${JSON.stringify(afterTexts)}`); + + // 不变量:每条任务 completed=true、originalCategory 保留 + const a = completedCat.tasks.find(t => t.text === '任务A(工作)'); + const c = completedCat.tasks.find(t => t.text === '任务C(学习)'); + const d = completedCat.tasks.find(t => t.text === '任务D(未分类)'); + check('A.completed=true(不变量)', a && a.completed === true); + check('A.originalCategory="工作"(reorder 不重写)', a && a.originalCategory === '工作', + `actual=${a && a.originalCategory}`); + check('C.completed=true(不变量)', c && c.completed === true); + check('C.originalCategory="学习"(reorder 不重写)', c && c.originalCategory === '学习', + `actual=${c && c.originalCategory}`); + check('D.completed=true(不变量)', d && d.completed === true); + check('D.originalCategory="未分类"(reorder 不重写)', d && d.originalCategory === '未分类', + `actual=${d && d.originalCategory}`); + + // sortBy 被切回 MANUAL(避免下次渲染按字母重排覆盖用户意图) + check('reorderTask 自动把 sortBy 切回 MANUAL', + store.sortBy === 'manual', `actual=${store.sortBy}`); + + // serialize → parseMarkdown:磁盘层顺序也是 [C, D, A] + const md = store.serialize(); + const cats = parseMarkdown(md); + const diskCompleted = findCat(cats, '已完成任务'); + check('磁盘层:parseMarkdown 后能找到「已完成任务」分类', + !!diskCompleted); + check('磁盘层:「已完成任务」分类 kind=COMPLETED', + diskCompleted && diskCompleted.kind === CategoryKind.COMPLETED, + `kind=${diskCompleted && diskCompleted.kind}`); + const diskTexts = diskCompleted ? diskCompleted.tasks.map(t => t.text) : []; + check('磁盘层:「已完成任务」段顺序 = [A, D, C]', + JSON.stringify(diskTexts) === JSON.stringify(['任务A(工作)', '任务D(未分类)', '任务C(学习)']), + `actual=${JSON.stringify(diskTexts)}`); + check('磁盘层:每条任务 completed=true(不变量)', + diskCompleted && diskCompleted.tasks.every(t => t.completed === true), + `tasks: ${diskCompleted?.tasks.map(t => `${t.text}(${t.completed})`).join(', ')}`); +} + +// ============================================================ +// [6] mergeFromDisk 后必须维持不变量(防外部编辑过的磁盘文件合并后不变量失守) +// +// 漏洞历史:parseMarkdown 只按名字标 kind,不跑迁移逻辑(散落 [✓]、重复「未分类」、 +// 缺失的「已完成任务」分类等)。mergeFromDisk 以前漏 _ensureBaseStructure 调用, +// 直接把磁盘 raw categories 灌进 this.categories —— completed=true 任务会留在 +// NORMAL 子分类,破坏 v3.4 数据归属不变量。 +// ============================================================ +console.log('\n[6] mergeFromDisk 后维持不变量'); + +{ + // 6.1) 旧格式磁盘:散落 [✓] 任务在 NORMAL 子分类 + const DIRTY_DISK = `# 全部任务 + +## 工作 + +- [ ] 正常任务 +- [✓] 旧格式已完成任务(散落在子分类) + +## 未分类 + +- [ ] 普通任务 +`; + const diskCats = parseMarkdown(DIRTY_DISK); + + const store = makeStore(null); + store.mergeFromDisk(diskCats, {}); // 用默认 resolutions:disk 获胜 + + // 旧格式的 [✓] 任务应该被 _ensureBaseStructure 迁到「已完成任务」分类 + const completed = store.getCategory('已完成任务'); + check('mergeFromDisk 后「已完成任务」分类已创建', + !!completed, `categories: ${store.categories.map(c => c.name).join(', ')}`); + check('mergeFromDisk 后「已完成任务」下带原分类记录', + completed && completed.tasks.some(t => + t.text === '旧格式已完成任务(散落在子分类)' && + t.completed === true && + t.originalCategory === '工作'), + `completed: ${completed?.tasks.map(t => `${t.text}(${t.completed}, ${t.originalCategory})`).join(', ')}`); + check('mergeFromDisk 后「工作」分类下没有散落的 [✓]', + !store.getCategory('工作').tasks.some(t => t.completed), + `工作: ${store.getCategory('工作').tasks.map(t => `${t.text}(${t.completed})`).join(', ')}`); +} + +{ + // 6.2) 重复「未分类」:外部编辑导致磁盘文件出现同名子分类,合并后应该去重 + const DUP_DISK = `# 全部任务 + +## 工作 + +- [ ] 正常任务 + +## 未分类 + +- [ ] 第一个未分类任务 + +## 未分类 + +- [ ] 第二个未分类任务 +`; + const diskCats = parseMarkdown(DUP_DISK); + + const store = makeStore(null); + store.mergeFromDisk(diskCats, {}); + + const uncats = store.categories.filter(c => c.name === '未分类'); + check('mergeFromDisk 后「未分类」分类已去重(只剩 1 个)', + uncats.length === 1, `count=${uncats.length}`); + check('重复「未分类」下的任务被合并到同一个分类', + uncats[0] && uncats[0].tasks.length === 2, + `tasks: ${uncats[0]?.tasks.map(t => t.text).join(', ')}`); +} + +{ + // 6.3) 不变量:mergeFromDisk 后 NORMAL 子分类下没有 completed=true 任务 + const INVARIANT_DISK = `# 全部任务 + +## 工作 + +- [ ] 任务A +- [✓] 任务B(散落) + +## 学习 + +- [✓] 任务C(散落) + +## 未分类 + +- [ ] 任务D +- [✓] 任务E(散落) +`; + const diskCats = parseMarkdown(INVARIANT_DISK); + + const store = makeStore(null); + store.mergeFromDisk(diskCats, {}); + + // 遍历所有 NORMAL 子分类,断言没有 completed=true 任务 + const violations = []; + for (const cat of store.categories) { + if (cat.kind === CategoryKind.NORMAL || cat.kind === CategoryKind.OTHER_TASKS) { + for (const t of cat.tasks) { + if (t.completed) { + violations.push(`${cat.name}: ${t.text}`); + } + } + } + } + check('mergeFromDisk 后 NORMAL 子分类下没有 completed=true 任务', + violations.length === 0, `violations: ${violations.join('; ')}`); +} + +console.log(`\n结果:通过 ${summary.pass} / 失败 ${summary.fail}`); +process.exit(summary.fail === 0 ? 0 : 1); \ No newline at end of file diff --git a/scripts/check-original-category.mjs b/scripts/check-original-category.mjs new file mode 100644 index 0000000..5efa5d4 --- /dev/null +++ b/scripts/check-original-category.mjs @@ -0,0 +1,685 @@ +// 回归测试:「原分类」标记 + 已完成任务恢复语义 +// +// v3.5+ 行为契约: +// 1. 勾选子分类里的任务 → task.originalCategory = categoryName +// 2. writer 写出时把 `[原分类:xxx]` 排在 `[▶]/[⭐]` 之后、文本之前(标记块内) +// 3. parser 再读回来时能把 `[原分类:xxx]` 抽到 task.originalCategory +// 4. 取消勾选:原分类还在 → 直接归位(不再回「未分类」),归位后清掉 originalCategory +// 5. 取消勾选:原分类丢失(删除 / 改名) → toggleTask 返回 restoreHint.kind === 'picker' +// 6. 取消勾选:没有 originalCategory 记录(兜底) → 直接去「未分类」,不弹窗 +// 7. 拖拽任务到「已完成任务」分类 → 同样记录 originalCategory +// 8. 旧文件升级迁移:散落的 [✓] 任务搬到「已完成」时自动记 cat.name +// 9. v3.5.1+:恢复后清掉 originalCategory,文件里不出现 `[原分类:xxx] 任务` 噪音 +// 10. v3.5.1+:原分类标记仅在「已完成任务」「回收站」分类里输出(writer 护栏) +// 11. v3.5.1+:moveTask 把任务移出「已完成」到普通子分类时清 originalCategory +// 12. v3.5.1+:restoreTask 从回收站恢复到普通子分类时清 originalCategory +// +// v3.5.2+ 行为契约([18] 节): +// 13. deleteTask / batchDeleteTasks 把任务移到 trash 时同步记录 originalCategory +// (与 toggleTask 把任务移到「已完成任务」时记 originalCategory 对称) +// 14. restoreTask 默认(无 toCategoryName)走 _resolveRestoreTarget: +// - 原分类还在 → category 分支,直接归位 +// - 原分类丢失 → picker 分支,返回 restoreHint,任务**仍留在 trash** +// - 无 originalCategory 记录 → fallback 分支,落「未分类」 +// 15. picker 路径下 UI 调 restoreTask(taskId, picked) 完成归位 + 清 originalCategory +// 16. 已完成任务的 originalCategory 在 deleteTask 进 trash 时不被覆盖(沿用 toggle 时的值) +// +// 跑法:node scripts/check-original-category.mjs + +import { + TaskStore, + parseImportLine +} from '../src/task-store.js'; +import { check, ok, bad, summary, printSummary } from './_lib/check.mjs'; +import { makeStore } from './_lib/store-fixture.mjs'; +import { + parseMarkdown, + CategoryKind, + extractInlineMarkers +} from '../src/markdown-parser.js'; +import { writeMarkdown } from '../src/markdown-writer.js'; + +// 往返:serialize 出来再 parse 回来,断言文件层的行为 +function roundTrip(store) { + return parseMarkdown(store.serialize()); +} + +function findCat(categories, name) { + return categories.find(c => c.name === name) || null; +} + +const BASE_DOC = `# 全部任务 + +## 工作 + +- [ ] 写周报 + +## 学习 + +- [ ] 看 Rust 入门 + +## 未分类 + +- [ ] 杂事 +`; + +// ============================================================ +// [1] extractInlineMarkers:解析 [原分类:xxx] 标记 +// ============================================================ +console.log('\n[1] extractInlineMarkers 解析 [原分类:xxx] 标记'); + +{ + const r1 = extractInlineMarkers('[原分类:工作] 给妈妈买生日礼物'); + check('行首 [原分类:xxx] 被识别为 originalCategory', + r1.originalCategory === '工作' && r1.text === '给妈妈买生日礼物', + `originalCategory=${r1.originalCategory}, text=${r1.text}`); + + const r2 = extractInlineMarkers('看 Rust 入门 [原分类:学习]'); + check('行尾 [原分类:xxx] 被识别为 originalCategory', + r2.originalCategory === '学习' && r2.text === '看 Rust 入门', + `originalCategory=${r2.originalCategory}, text=${r2.text}`); + + const r3 = extractInlineMarkers('[▶] [⭐] 写季度汇报 [原分类:工作]'); + check('[原分类:xxx] 与 [▶]/[⭐] 共存时都正确抽取', + r3.originalCategory === '工作' && r3.current && r3.important && r3.text === '写季度汇报', + `orig=${r3.originalCategory}, curr=${r3.current}, imp=${r3.important}, text=${r3.text}`); + + const r4 = extractInlineMarkers('普通任务'); + check('没有标记时 originalCategory === null', + r4.originalCategory === null, + `originalCategory=${r4.originalCategory}`); + + const r5 = extractInlineMarkers('任务中含 [原分类:工作] 字面字符串'); + check('行中的 [原分类:xxx] 不被误识为标记', + r5.originalCategory === null && r5.text.includes('任务中含'), + `originalCategory=${r5.originalCategory}`); +} + +// ============================================================ +// [2] 勾选任务 → originalCategory 记录源分类 +// ============================================================ +console.log('\n[2] 勾选任务 → originalCategory 记录源分类'); + +{ + const store = makeStore(BASE_DOC); + const workCat = store.getCategory('工作'); + const task = workCat.tasks.find(t => t.text === '写周报'); + const result = store.toggleTask('工作', task.id); + + check('toggleTask 返回 { ok: true }', result && result.ok === true); + check('任务已从「工作」分类移除', + !store.getCategory('工作').tasks.some(t => t.text === '写周报')); + check('任务已到「已完成任务」分类', + store.getCategory('已完成任务').tasks.some(t => t.text === '写周报')); + check('task.originalCategory === "工作"', + store.getCategory('已完成任务').tasks[0].originalCategory === '工作', + `actual=${store.getCategory('已完成任务').tasks[0].originalCategory}`); + check('task.completed === true', + store.getCategory('已完成任务').tasks[0].completed === true); +} + +// ============================================================ +// [3] writer 把 [原分类:xxx] 排在 [▶]/[⭐] 之后、文本之前 +// ============================================================ +console.log('\n[3] writer 写出 [原分类:xxx]'); + +{ + const store = makeStore(BASE_DOC); + const workCat = store.getCategory('工作'); + const task = workCat.tasks.find(t => t.text === '写周报'); + store.toggleTask('工作', task.id); + + const md = store.serialize(); + check('serialize 输出含 [原分类:工作]', + md.includes('[原分类:工作]'), + `output 前 400 字: ${md.slice(0, 400)}`); + check('serialize 输出仍是 [✓] 勾选态', + md.includes('- [✓]') && md.includes('写周报')); + // v3.5+:[原分类:xxx] 排在标记块内([▶]/[⭐] 之后、文本之前) + // 验证:写出行形如 `- [✓] [原分类:工作] 写周报`(写在本行的「已完成任务」分类下) + const completedLine = md.split('\n').find(l => l.includes('写周报') && l.includes('[✓]')); + check('原分类标记排在文本之前(标记块内)', + completedLine && /\[原分类:工作\]\s+写周报/.test(completedLine), + `line: ${completedLine}`); +} + +// ============================================================ +// [4] Markdown 往返:parse → 仍然带 originalCategory +// ============================================================ +console.log('\n[4] Markdown 往返 —— 解析回来仍带 originalCategory'); + +{ + const store = makeStore(BASE_DOC); + const workCat = store.getCategory('工作'); + const task = workCat.tasks.find(t => t.text === '写周报'); + store.toggleTask('工作', task.id); + + // 重新 parse 自己 serialize 出来的 markdown,模拟"保存后再加载" + const categories = roundTrip(store); + const completed = findCat(categories, '已完成任务'); + const reloaded = completed?.tasks.find(t => t.text === '写周报'); + + check('往返后任务仍在「已完成任务」', + !!reloaded); + check('往返后 task.originalCategory 仍为 "工作"', + reloaded?.originalCategory === '工作', + `actual=${reloaded?.originalCategory}`); + check('往返后 task.completed === true', + reloaded?.completed === true); +} + +// ============================================================ +// [5] 取消勾选:原分类还在 → 直接归位 +// ============================================================ +console.log('\n[5] 取消勾选:原分类还在 → 直接归位'); + +{ + const store = makeStore(BASE_DOC); + const workCat = store.getCategory('工作'); + const task = workCat.tasks.find(t => t.text === '写周报'); + store.toggleTask('工作', task.id); // 勾选 + const result = store.toggleTask('已完成任务', task.id); // 取消勾选 + + check('取消勾选返回 restoreHint.kind === "category"', + result && result.restoreHint && result.restoreHint.kind === 'category', + `restoreHint=${JSON.stringify(result?.restoreHint)}`); + check('取消勾选后任务回到「工作」子分类', + store.getCategory('工作').tasks.some(t => t.text === '写周报')); + check('任务不再在「已完成任务」里', + !store.getCategory('已完成任务').tasks.some(t => t.text === '写周报')); + check('任务 completed === false', + store.getCategory('工作').tasks.find(t => t.text === '写周报').completed === false); + // v3.5.1:恢复后清掉 originalCategory —— 任务已回到「工作」里,再保留标记就是噪音 + check('恢复后 task.originalCategory === null', + store.getCategory('工作').tasks.find(t => t.text === '写周报').originalCategory === null); + check('恢复后 serialize 不含 [原分类:工作]', + !store.serialize().includes('[原分类:工作]')); +} + +// ============================================================ +// [6] 取消勾选:原分类丢失 → restoreHint.kind === 'picker' +// ============================================================ +console.log('\n[6] 取消勾选:原分类丢失(删除) → 弹窗提示'); + +{ + const store = makeStore(BASE_DOC); + const workCat = store.getCategory('工作'); + const task = workCat.tasks.find(t => t.text === '写周报'); + store.toggleTask('工作', task.id); // 勾选,记录原分类 = "工作" + + // 删除「工作」子分类 —— 模拟用户改了名或删了 + store.deleteCategory('工作'); + + const result = store.toggleTask('已完成任务', task.id); // 取消勾选 + + check('原分类丢失时 restoreHint.kind === "picker"', + result && result.restoreHint && result.restoreHint.kind === 'picker', + `restoreHint=${JSON.stringify(result?.restoreHint)}`); + check('restoreHint.missing === "工作"', + result && result.restoreHint && result.restoreHint.missing === '工作', + `missing=${result?.restoreHint?.missing}`); + check('store.restoreCompletedTask 能把任务放到指定分类', + store.restoreCompletedTask(task.id, '学习') && + store.getCategory('学习').tasks.some(t => t.text === '写周报')); + // v3.5.1:picker 路径恢复后同样清掉 originalCategory + check('picker 路径恢复后 task.originalCategory === null', + store.getCategory('学习').tasks.find(t => t.text === '写周报').originalCategory === null); + check('picker 路径恢复后 serialize 不含 [原分类:工作]', + !store.serialize().includes('[原分类:工作]')); +} + +// ============================================================ +// [7] 取消勾选:没有 originalCategory → 直接去「未分类」 +// ============================================================ +console.log('\n[7] 取消勾选:没有 originalCategory 记录 → 直接去「未分类」'); + +{ + const store = makeStore(BASE_DOC); + const workCat = store.getCategory('工作'); + const task = workCat.tasks.find(t => t.text === '写周报'); + + // 手动设置:勾选但不记 originalCategory(模拟"很旧的文件") + store.toggleTask('工作', task.id); + // 抹掉 originalCategory 模拟"旧版没有这个字段" + const completedTask = store.getCategory('已完成任务').tasks[0]; + completedTask.originalCategory = null; + + const result = store.toggleTask('已完成任务', task.id); // 取消勾选 + + check('restoreHint.kind === "fallback"', + result && result.restoreHint && result.restoreHint.kind === 'fallback', + `restoreHint=${JSON.stringify(result?.restoreHint)}`); + check('任务落到「未分类」', + store.getCategory('未分类').tasks.some(t => t.text === '写周报')); + check('restoreHint 不带 missing(不是 picker)', + !result.restoreHint.missing); +} + +// ============================================================ +// [8] 拖入「已完成任务」分类 → 同样记录 originalCategory +// ============================================================ +console.log('\n[8] 拖入「已完成任务」→ 同样记录 originalCategory'); + +{ + const store = makeStore(BASE_DOC); + const workCat = store.getCategory('工作'); + const task = workCat.tasks.find(t => t.text === '写周报'); + + // 通过 moveTask 拖入「已完成任务」(拖到 completed 分类,自动 completed=true) + store.moveTask('工作', task.id, '已完成任务'); + + const t = store.getCategory('已完成任务').tasks.find(x => x.text === '写周报'); + check('拖入后任务在「已完成任务」', + !!t); + check('拖入后 originalCategory === "工作"', + t && t.originalCategory === '工作', + `actual=${t?.originalCategory}`); + check('拖入后 completed === true', + t && t.completed === true); +} + +// ============================================================ +// [9] 旧文件升级迁移:散落的 [✓] 任务搬到「已完成」时记原分类 +// ============================================================ +console.log('\n[9] 旧文件升级迁移:散落的 [✓] 任务自动记原分类'); + +{ + // 模拟 v3.4 之前的旧文件:散落的 [✓] 任务在子分类里 + const OLD_DOC = `# 全部任务 + +## 工作 + +- [ ] 写周报 +- [✓] 买牛奶 + +## 学习 + +- [✓] 看 Rust 入门 + +## 未分类 + +- [ ] 杂事 +`; + const store = makeStore(OLD_DOC); + + // 触发迁移(loadFromContent 已经跑过;这里再调一次确认幂等) + // store._migrateCompletedTasksToCategory(); + + const completed = store.getCategory('已完成任务'); + const workTask = completed.tasks.find(t => t.text === '买牛奶'); + const studyTask = completed.tasks.find(t => t.text === '看 Rust 入门'); + + check('旧文件的 [✓] 任务都搬到了「已完成任务」', + workTask && studyTask); + check('「工作」来源的任务 originalCategory === "工作"', + workTask && workTask.originalCategory === '工作', + `actual=${workTask?.originalCategory}`); + check('「学习」来源的任务 originalCategory === "学习"', + studyTask && studyTask.originalCategory === '学习', + `actual=${studyTask?.originalCategory}`); + + // 文件层验证:serialize 后能看到 [原分类:xxx] + const md = store.serialize(); + check('serialize 后含 [原分类:工作]', + md.includes('[原分类:工作]')); + check('serialize 后含 [原分类:学习]', + md.includes('[原分类:学习]')); +} + +// ============================================================ +// [10] batchToggleCompleted:批量取消勾选也走 _resolveRestoreTarget +// ============================================================ +console.log('\n[10] 批量取消勾选:每条任务归到各自原分类'); + +{ + const store = makeStore(BASE_DOC); + const workCat = store.getCategory('工作'); + const studyCat = store.getCategory('学习'); + const workTask = workCat.tasks.find(t => t.text === '写周报'); + const studyTask = studyCat.tasks.find(t => t.text === '看 Rust 入门'); + + // 批量勾选 + store.batchToggleCompleted([ + { categoryName: '工作', taskId: workTask.id }, + { categoryName: '学习', taskId: studyTask.id } + ], true); + + check('批量勾选后两条都在「已完成任务」', + store.getCategory('已完成任务').tasks.length === 2); + + // 批量取消勾选 + const completed = store.getCategory('已完成任务'); + const t1 = completed.tasks.find(t => t.text === '写周报'); + const t2 = completed.tasks.find(t => t.text === '看 Rust 入门'); + store.batchToggleCompleted([ + { categoryName: '已完成任务', taskId: t1.id }, + { categoryName: '已完成任务', taskId: t2.id } + ], false); + + check('「写周报」回到「工作」', + store.getCategory('工作').tasks.some(t => t.text === '写周报')); + check('「看 Rust 入门」回到「学习」', + store.getCategory('学习').tasks.some(t => t.text === '看 Rust 入门')); + // v3.5.1:批量恢复后也清掉 originalCategory + check('批量恢复后「写周报」originalCategory === null', + store.getCategory('工作').tasks.find(t => t.text === '写周报').originalCategory === null); + check('批量恢复后「看 Rust 入门」originalCategory === null', + store.getCategory('学习').tasks.find(t => t.text === '看 Rust 入门').originalCategory === null); + check('批量恢复后 serialize 不含 [原分类:xxx]', + !store.serialize().match(/\[原分类:/)); +} + +// ============================================================ +// [11] parseImportLine:导入对话框也支持 [原分类:xxx] +// ============================================================ +console.log('\n[11] parseImportLine 支持 [原分类:xxx]'); + +{ + const r1 = parseImportLine('- [ ] 看牙医 [原分类:生活]'); + check('parseImportLine 把 [原分类:xxx] 抽到 originalCategory', + r1 && r1.originalCategory === '生活' && r1.text === '看牙医', + `originalCategory=${r1?.originalCategory}, text=${r1?.text}`); +} + +// ============================================================ +// [12] CategoryKind.COMPLETED 不会被误认为恢复目标 +// ============================================================ +console.log('\n[12] kind=COMPLETED 的分类不能作为恢复目标'); + +{ + const store = makeStore(BASE_DOC); + // 手动注入一个恶意分类(虽然实际不会发生,但要防御) + store.categories.push({ + name: '陷阱', + kind: CategoryKind.COMPLETED, // 故意是 COMPLETED + isSpecial: true, + parentOtherTasks: false, + tasks: [] + }); + + const workCat = store.getCategory('工作'); + const task = workCat.tasks.find(t => t.text === '写周报'); + store.toggleTask('工作', task.id); + + // 把"工作"删了,但有个 kind=COMPLETED 的同名陷阱分类 + store.deleteCategory('工作'); + // 改名为「工作」(让它跟 originalCategory 同名) + const trap = store.categories.find(c => c.name === '陷阱'); + trap.name = '工作'; + + const result = store.toggleTask('已完成任务', task.id); + // 应该返回 picker,因为同名"工作"分类是 COMPLETED 类型,不能作为恢复目标 + check('kind=COMPLETED 的同名分类不作为恢复目标', + result && result.restoreHint && result.restoreHint.kind === 'picker', + `restoreHint=${JSON.stringify(result?.restoreHint)}`); +} + +// ============================================================ +// [13] v3.5.1+:原分类标记仅在「已完成 / 回收站」分类里输出(writer 护栏) +// ============================================================ +console.log('\n[13] writer 护栏:原分类标记仅在 COMPLETED/TRASH 输出'); + +{ + const store = makeStore(BASE_DOC); + const workCat = store.getCategory('工作'); + const studyCat = store.getCategory('学习'); + const workTask = workCat.tasks.find(t => t.text === '写周报'); + const studyTask = studyCat.tasks.find(t => t.text === '看 Rust 入门'); + + // 1. 勾选一条 → 进入已完成 → 出现 [原分类:工作] + store.toggleTask('工作', workTask.id); + // 2. 把另一条拖到已完成 → 同样记录 + store.moveTask('学习', studyTask.id, '已完成任务'); + + const md = store.serialize(); + // 文件里 [原分类:工作] 出现在「已完成任务」段 ✓ + const completedSection = md.split('# 已完成任务')[1] || ''; + check('writer 在「已完成任务」段写出 [原分类:工作]', + completedSection.includes('[原分类:工作]')); + check('writer 在「已完成任务」段写出 [原分类:学习]', + completedSection.includes('[原分类:学习]')); + + // 3. 通过 moveTask 把已完成任务拖回普通子分类 → originalCategory 清掉 + const completedTask = store.getCategory('已完成任务').tasks[0]; + store.moveTask('已完成任务', completedTask.id, '生活'); + + // 4. 验证「生活」段不出现 [原分类:xxx] + const lifeSection = md.split('# 生活')[1]?.split('\n')[0]; // 旧 md 无生活内容 + // 重新 serialize(因为 moveTask 后 md 应该变了) + const mdAfter = store.serialize(); + // 「生活」分类下不该出现 [原分类:xxx] + const lifeIndex = mdAfter.indexOf('## 生活'); + const uncategorizedIndex = mdAfter.indexOf('## 未分类'); + const lifeSectionNew = mdAfter.slice(lifeIndex, uncategorizedIndex > 0 ? uncategorizedIndex : undefined); + check('moveTask 把已完成任务拖回普通子分类后,那条任务没 [原分类:xxx]', + !lifeSectionNew.includes('[原分类:'), + `life section: ${lifeSectionNew.slice(0, 200)}`); +} + +// ============================================================ +// [14] moveTask 跨普通子分类:原始 originalCategory 保持(防御性无侵入) +// ============================================================ +console.log('\n[14] moveTask 普通子分类之间互拖 → originalCategory 不受影响'); + +{ + const store = makeStore(BASE_DOC); + const workCat = store.getCategory('工作'); + const task = workCat.tasks.find(t => t.text === '写周报'); + // 故意设 originalCategory(外部编辑 / 解析器的产物) + task.originalCategory = '工作'; + // 从「工作」拖到「学习」 + store.moveTask('工作', task.id, '学习'); + + const moved = store.getCategory('学习').tasks.find(t => t.text === '写周报'); + // v3.5.1 进入 NORMAL 路径会清掉 originalCategory(即便它本来就被外部设了) + check('普通子分类之间 moveTask 后 originalCategory === null', + moved && moved.originalCategory === null, + `actual=${moved?.originalCategory}`); +} + +// ============================================================ +// [15] restoreTask:从回收站恢复到普通子分类 → 清 originalCategory(防御) +// ============================================================ +console.log('\n[15] restoreTask 回收站 → 普通子分类 → 清 originalCategory'); + +{ + const store = makeStore(BASE_DOC); + const workCat = store.getCategory('工作'); + const task = workCat.tasks.find(t => t.text === '写周报'); + // 勾选 → 进已完成(带 originalCategory)→ 删 → 进回收站(保留状态) + store.toggleTask('工作', task.id); + store.deleteTask('已完成任务', task.id); + + const trashed = store.getCategory('回收站').tasks[0]; + // 模拟"防御性原始数据":外部编辑设了 originalCategory(即使 completed=true 在 trash 里也罕见) + // 这里直接用 deleteTask 后的状态:task.completed=true 且 task.originalCategory='工作' + check('删除已完成任务后,回收站里 originalCategory 仍在', + trashed && trashed.originalCategory === '工作' && trashed.completed === true); + + // 恢复到「学习」子分类 + const ok = store.restoreTask(trashed.id, '学习'); + // restoreTask 看到 taskSnapshot.completed=true 会重投到「已完成任务」—— 不进 NORMAL 路径 + // 这条路径下 originalCategory 应该保留 + const finalTask = store.getCategory('学习').tasks.find(t => t.text === '写周报') + || store.getCategory('已完成任务').tasks.find(t => t.text === '写周报'); + if (ok && finalTask) { + const inCompleted = store.getCategory('已完成任务').tasks.some(t => t.text === '写周报'); + if (inCompleted) { + check('completed 任务从回收站恢复后重投「已完成」→ originalCategory 保留', + finalTask.originalCategory === '工作', + `actual=${finalTask.originalCategory}`); + } else { + check('非 completed 任务从回收站恢复到普通分类 → originalCategory === null', + finalTask.originalCategory === null, + `actual=${finalTask.originalCategory}`); + } + } +} + +// ============================================================ +// [16] writer 防御性:即便普通子分类任务的 originalCategory 被外部塞了,写出也不带标记 +// ============================================================ +console.log('\n[16] writer 防御性护栏:普通子分类不写 [原分类:xxx]'); + +{ + // 模拟外部手工编辑:把 [原分类:xxx] 强行写到普通子分类任务里 + const DIRTY_DOC = `# 全部任务 + +## 工作 + +- [ ] [原分类:生活] 写周报 + +## 学习 + +- [ ] 看 Rust 入门 +`; + const store = makeStore(DIRTY_DOC); + const md = store.serialize(); + // 普通子分类下不应该出现 [原分类:xxx](writer 护栏) + const workSection = md.split('## 工作')[1]?.split('##')[0] || ''; + check('writer 护栏:普通子分类不输出 [原分类:xxx]', + !workSection.includes('[原分类:'), + `work section: ${workSection.slice(0, 200)}`); +} + +// ============================================================ +// [17] 回收站里仍可看到 [原分类:xxx](用户要求:回收站也是特殊分类,原分类有信息价值) +// ============================================================ +console.log('\n[17] 回收站里保留 [原分类:xxx]'); + +{ + const store = makeStore(BASE_DOC); + const workCat = store.getCategory('工作'); + const task = workCat.tasks.find(t => t.text === '写周报'); + // 勾选 → 已完成 → 删 → 回收站 + store.toggleTask('工作', task.id); + store.deleteTask('已完成任务', task.id); + + const md = store.serialize(); + const trashSection = md.split('# 回收站')[1] || ''; + check('回收站段保留 [原分类:工作]', + trashSection.includes('[原分类:工作]'), + `trash section: ${trashSection.slice(0, 200)}`); +} + +// ============================================================ +// [18] v3.5.2+:回收站学「已完成任务」的 [原分类:] 语义 + 默认归位 +// +// 行为契约: +// 1) deleteTask 从普通子分类搬到 trash → 自动写 originalCategory +// 2) writer 在「# 回收站」段同步写出 [原分类:xxx] +// 3) restoreTask 默认(无 toCategoryName)命中原分类 → 直接归位 +// 4) restoreTask 默认 原分类丢失 → 返回 picker hint,任务**仍留在 trash** +// 5) picker 路径后续 restoreTask(taskId, picked) 完成归位 + 清掉 originalCategory +// 6) batchDeleteTasks 也写 originalCategory(与单条 deleteTask 对称) +// 7) 已完成任务从「已完成任务」删到 trash,原分类不被覆盖(沿用 toggle 时记下的) +// ============================================================ +console.log('\n[18] 回收站学「已完成任务」的 [原分类:] + 默认归位'); + +{ + // 18.1) 普通子分类删除 → 回收站里 originalCategory 已被记下 + const store = makeStore(BASE_DOC); + const workCat = store.getCategory('工作'); + const task = workCat.tasks.find(t => t.text === '写周报'); + store.deleteTask('工作', task.id); + + const trashed = store.getTrashCategory().tasks[0]; + check('普通子分类删除后回收站里 originalCategory === "工作"', + trashed && trashed.originalCategory === '工作', + `actual=${trashed?.originalCategory}`); + + // 18.2) writer 在「# 回收站」段输出 [原分类:工作] + const md = store.serialize(); + const trashSection = md.split('# 回收站')[1] || ''; + check('writer 在「# 回收站」段写出 [原分类:工作](trash 端到端链路打通)', + trashSection.includes('[原分类:工作]'), + `trash section: ${trashSection.slice(0, 200)}`); + + // 18.3) 默认 restoreTask 走 category 分支:归到「工作」 + const result = store.restoreTask(task.id); + check('默认 restoreTask 返回 { ok: true, restored: true }', + result.ok === true && result.restored === true, + `result=${JSON.stringify(result)}`); + check('restoreHint.kind === "category" + 带 targetName="工作"', + result.restoreHint && result.restoreHint.kind === 'category' && + result.restoreHint.targetName === '工作', + `restoreHint=${JSON.stringify(result.restoreHint)}`); + check('任务归到原分类「工作」', + store.getCategory('工作').tasks.some(t => t.text === '写周报'), + `work tasks: ${store.getCategory('工作').tasks.map(t => t.text)}`); + check('回收站空', + store.getTrashCategory().tasks.length === 0); +} + +{ + // 18.4) 原分类丢失 → picker hint,任务仍留在 trash + const store = makeStore(BASE_DOC); + const workCat = store.getCategory('工作'); + const task = workCat.tasks.find(t => t.text === '写周报'); + store.deleteTask('工作', task.id); // record originalCategory='工作' + store.deleteCategory('工作'); // 原分类丢失 + + // 此时任务在 trash,originalCategory='工作' 但 '工作' 已不存在 + const result = store.restoreTask(task.id); + check('原分类丢失时 restoreTask 返回 picker hint', + result && result.restoreHint && result.restoreHint.kind === 'picker', + `result=${JSON.stringify(result)}`); + check('picker hint.missing === "工作"', + result.restoreHint && result.restoreHint.missing === '工作', + `missing=${result.restoreHint?.missing}`); + check('picker 路径下任务**仍留在 trash**(不动任务)', + store.getTrashCategory().tasks.length === 1 && + store.getTrashCategory().tasks[0].text === '写周报', + `trash: ${store.getTrashCategory().tasks.map(t => t.text)}`); + + // 18.5) picker 选定后调 restoreTask(taskId, picked) 完成移动 + 清 originalCategory + const r2 = store.restoreTask(task.id, '学习'); + check('picker 后续 restoreTask(id, "学习") 完成归位', + r2.ok === true && r2.restored === true && + store.getCategory('学习').tasks.some(t => t.text === '写周报'), + `r2=${JSON.stringify(r2)}, study=${store.getCategory('学习').tasks.map(t => t.text)}`); + const moved = store.getCategory('学习').tasks.find(t => t.text === '写周报'); + check('picker 路径恢复后 task.originalCategory === null(已"回家")', + moved && moved.originalCategory === null, + `actual=${moved?.originalCategory}`); +} + +{ + // 18.6) batchDeleteTasks 也写 originalCategory + const store = makeStore(BASE_DOC); + const workCat = store.getCategory('工作'); + const studyCat = store.getCategory('学习'); + const t1 = workCat.tasks.find(t => t.text === '写周报'); + const t2 = studyCat.tasks.find(t => t.text === '看 Rust 入门'); + store.batchDeleteTasks([ + { categoryName: '工作', taskId: t1.id }, + { categoryName: '学习', taskId: t2.id } + ]); + const trash = store.getTrashCategory().tasks; + check('batchDeleteTasks 后回收站里两条任务都带 originalCategory', + trash.length === 2 && + trash.some(t => t.text === '写周报' && t.originalCategory === '工作') && + trash.some(t => t.text === '看 Rust 入门' && t.originalCategory === '学习'), + `trash: ${trash.map(t => `${t.text}(${t.originalCategory})`).join(', ')}`); +} + +{ + // 18.7) 已完成任务删到 trash,originalCategory 不被覆盖 + const store = makeStore(BASE_DOC); + const studyCat = store.getCategory('学习'); + const task = studyCat.tasks.find(t => t.text === '看 Rust 入门'); + // 勾选 → toggleTask 写 originalCategory='学习' + store.toggleTask('学习', task.id); + check('toggleTask 已写 originalCategory === "学习"(前置)', + store.getCategory('已完成任务').tasks[0].originalCategory === '学习'); + + // 删 → 进入 trash,originalCategory 应保持 '学习'(不被 deleteTask 覆盖) + store.deleteTask('已完成任务', task.id); + const trashed = store.getTrashCategory().tasks[0]; + check('completed 任务从「已完成任务」删到 trash → originalCategory 仍为"学习"(不重写)', + trashed && trashed.originalCategory === '学习' && trashed.completed === true, + `actual=${trashed?.originalCategory}, completed=${trashed?.completed}`); +} + +console.log(`\n结果:通过 ${summary.pass} / 失败 ${summary.fail}`); +process.exit(summary.fail === 0 ? 0 : 1); diff --git a/scripts/check-picker-timeout.mjs b/scripts/check-picker-timeout.mjs new file mode 100644 index 0000000..59daf26 --- /dev/null +++ b/scripts/check-picker-timeout.mjs @@ -0,0 +1,111 @@ +// 回归测试:H6 — picker 路径尝试次数上限 + 降级为 fallback +// +// 背景:toggleTask 在「原分类丢失 → picker 分支」时返回 restoreHint,UI 弹 +// 选择器让用户选目标分类。但如果用户反复 toggle 同一任务(原分类持续丢 +// 失 / 用户反复取消 picker),picker 会被反复触发,可能进入死循环或内存 +// 累积。修复:store 内部记 _pickerAttempts[taskId],超过 _PICKER_ATTEMPT_LIMIT +// 后强制降级为 fallback(落「未分类」),避免 picker 风暴。 +// +// 跑法:node scripts/check-picker-timeout.mjs + +import { check, summary, printSummary } from './_lib/check.mjs'; +import { makeStore } from './_lib/store-fixture.mjs'; + +const BASE_DOC = `# 全部任务 + +## 工作 + +- [ ] 写周报 + +## 学习 + +- [ ] 看 Rust 入门 + +## 未分类 + +`; + +// ── [1] 第一次 picker 命中 ── +console.log('\n[1] 首次 picker:原分类丢失 → restoreHint.kind === "picker"'); + +{ + const store = makeStore(BASE_DOC); + const workCat = store.getCategory('工作'); + const task = workCat.tasks.find(t => t.text === '写周报'); + store.toggleTask('工作', task.id); // 勾选 → 进「已完成任务」 + + // 删除「工作」→ 让原分类丢失 + store.deleteCategory('工作'); + + const r = store.toggleTask('已完成任务', task.id); + check('首次 picker 路径返回 restoreHint.kind === "picker"', + r && r.restoreHint && r.restoreHint.kind === 'picker', + `restoreHint=${JSON.stringify(r?.restoreHint)}`); + check('picker 带 missing = "工作"', + r && r.restoreHint && r.restoreHint.missing === '工作'); +} + +// ── [2] 同一任务反复 toggle(picker 不收敛)→ 降级为 fallback ── +console.log('\n[2] picker 不收敛时降级为 fallback'); + +{ + const store = makeStore(BASE_DOC); + const workCat = store.getCategory('工作'); + const task = workCat.tasks.find(t => t.text === '写周报'); + + // 让任务进入「已完成任务」,再删工作 → 进入 picker 路径 + store.toggleTask('工作', task.id); + store.deleteCategory('工作'); + + // 反复 toggle 同一任务(picker 不收敛 —— 用户反复取消选择对话框) + // 记录每次的 restoreHint,直到出现 fallback(不要用「最后一次」—— + // fallback 触发后任务已搬到「未分类」,后续 toggleTask 返回值可能不再带 restoreHint) + let fallbackResult = null; + const hintTrace = []; + for (let i = 0; i < 6 && !fallbackResult; i++) { + const r = store.toggleTask('已完成任务', task.id); + hintTrace.push(r?.restoreHint?.kind || 'no-result'); + if (r?.restoreHint?.kind === 'fallback') fallbackResult = r; + } + + // 断言:循环内出现了至少一次 fallback + check('循环内出现 fallback(picker 降级)', + fallbackResult !== null, + `hint trace: ${hintTrace.join(' → ')}`); + check('fallback restoreHint 带 forceDowngrade=true', + fallbackResult && fallbackResult.restoreHint && fallbackResult.restoreHint.forceDowngrade === true, + `restoreHint=${JSON.stringify(fallbackResult?.restoreHint)}`); + // 兜底:任务应落到「未分类」 + check('降级后任务落到「未分类」', + store.getCategory('未分类').tasks.some(t => t.text === '写周报')); +} + +// ── [3] 不同任务互不干扰 ── +console.log('\n[3] 不同任务的 picker 计数独立'); + +{ + const store = makeStore(BASE_DOC); + const workCat = store.getCategory('工作'); + const studyCat = store.getCategory('学习'); + const t1 = workCat.tasks.find(t => t.text === '写周报'); + const t2 = studyCat.tasks.find(t => t.text === '看 Rust 入门'); + + // 两条都 toggle 进「已完成任务」 + store.toggleTask('工作', t1.id); + store.toggleTask('学习', t2.id); + + // 删「工作」让 t1 走 picker + store.deleteCategory('工作'); + store.toggleTask('已完成任务', t1.id); // 1 次 picker + store.toggleTask('已完成任务', t1.id); // 2 次 picker(picker 不动任务) + store.toggleTask('已完成任务', t1.id); // 3 次 picker + + // t2 此时 picker 计数应独立(仍是 0) + const r2 = store.toggleTask('已完成任务', t2.id); + // t2 的原分类「学习」还在 → 走 category 分支(不是 picker) + check('独立任务互不干扰 —— t2 走 category 分支', + r2 && r2.restoreHint && r2.restoreHint.kind === 'category', + `r2.restoreHint=${JSON.stringify(r2?.restoreHint)}`); +} + +printSummary('check-picker-timeout'); \ No newline at end of file diff --git a/scripts/check-polling-fallback.mjs b/scripts/check-polling-fallback.mjs new file mode 100644 index 0000000..de8fc9f --- /dev/null +++ b/scripts/check-polling-fallback.mjs @@ -0,0 +1,375 @@ +// 测试 0.5 秒轮询兜底(main.js v5 方案) +// +// 复刻 main.js 中 startPolling 的逻辑(隔离测试,不引入 electron 依赖): +// - statSync 间隔 POLL_INTERVAL_MS 检查 (mtimeMs, size) +// - 变化即触发 IPC(用事件回调模拟) +// - 与 fs.watch 共用 dedup 窗口 +// - 主进程自写入后 recordFileStat 跳过那次变化 +// - fs.watch 回调里同步 statSync 刷新 lastSeen,避免 lastSeen 滞后 +// +// 用户的真实反馈:fs.watch 在他的环境不触发 → 外部修改软件没反应。 +// 0.5 秒轮询兜底:用 statSync 探测 (mtime, size) 变化,绕开 fs.watch 的不可靠性。 + +import { check, ok, bad, summary, printSummary } from './_lib/check.mjs'; +import { makeStore } from './_lib/store-fixture.mjs'; +import { makeTmpTodoFile, cleanupTmpTodo } from './_lib/tmp-todo.mjs'; +import fs from 'fs'; +import path from 'path'; + +const { dir: tmpDir, file: tmpFile } = makeTmpTodoFile({ prefix: 'poll-test-' }); + +// === 复刻 main.js 的 polling 逻辑(最小可测版本) === +const POLL_INTERVAL_MS = 50; // 测试加速:50ms 替代 500ms +const EXTERNAL_CHANGE_DEDUP_MS = 20; // 同步缩短 dedup + +let lastSeenMtimeMs = 0; +let lastSeenSize = 0; +let pollTimer = null; +let lastEventAt = 0; +let eventCount = 0; +let currentFilePath = null; +const events = []; + +function recordFileStat(filePath) { + try { + const st = fs.statSync(filePath); + lastSeenMtimeMs = st.mtimeMs; + lastSeenSize = st.size; + } catch { + /* ignore */ + } +} + +function fireIpc(filePath) { + // 模拟 mainWindow.webContents.send('file:external-change', filePath) + const now = Date.now(); + if (now - lastEventAt < EXTERNAL_CHANGE_DEDUP_MS) return; + lastEventAt = now; + eventCount++; + events.push({ at: now, filePath }); +} + +function startPolling(filePath) { + stopPolling(); + currentFilePath = filePath; + recordFileStat(filePath); + pollTimer = setInterval(() => { + if (!currentFilePath) return; + let st; + try { st = fs.statSync(currentFilePath); } catch { return; } + if (st.mtimeMs === lastSeenMtimeMs && st.size === lastSeenSize) return; + lastSeenMtimeMs = st.mtimeMs; + lastSeenSize = st.size; + fireIpc(currentFilePath); + }, POLL_INTERVAL_MS); +} + +function stopPolling() { + if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } +} + +const wait = (ms) => new Promise(r => setTimeout(r, ms)); + +// ============================================================ +// [1] 初始 baseline 不会误触发 +// ============================================================ +console.log('\n[1] 初始 baseline 不触发事件'); +{ + fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] a\n'); + eventCount = 0; + events.length = 0; + startPolling(tmpFile); + await wait(POLL_INTERVAL_MS * 4); + check('初始 baseline 后 4 轮无事件', eventCount === 0, `eventCount=${eventCount}`); + stopPolling(); +} + +// ============================================================ +// [2] 外部修改 → polling 检测到 mtime/size 变化 → 触发 IPC +// ============================================================ +console.log('\n[2] 外部修改 → polling 触发 IPC'); +{ + fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] a\n'); + startPolling(tmpFile); + await wait(POLL_INTERVAL_MS * 2); + check('watcher 稳定后无事件', eventCount === 0); + + // 用户在外部编辑器里改了一条任务 + await wait(60); // 确保 mtime 变化 + eventCount = 0; + events.length = 0; + fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] a 修改\n'); + + await wait(POLL_INTERVAL_MS * 3); + check('外部修改触发 IPC', eventCount === 1, `eventCount=${eventCount}`); + check('IPC 携带正确路径', events[0]?.filePath === tmpFile); + stopPolling(); +} + +// ============================================================ +// [3] 多次外部修改 → 多次 IPC +// ============================================================ +console.log('\n[3] 多次外部修改 → 多次 IPC'); +{ + fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] a\n'); + startPolling(tmpFile); + await wait(POLL_INTERVAL_MS * 2); + + eventCount = 0; + for (let i = 0; i < 3; i++) { + await wait(60); + fs.writeFileSync(tmpFile, `# 全部任务\n\n## 工作\n\n- [ ] 修改 ${i}\n`); + await wait(POLL_INTERVAL_MS * 2); + } + check('3 次外部修改 → 3 次 IPC(不漏报)', eventCount === 3, + `eventCount=${eventCount}`); + stopPolling(); +} + +// ============================================================ +// [4] 静默(无修改)→ 不触发 IPC +// ============================================================ +console.log('\n[4] 无修改 → 不触发 IPC'); +{ + fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] a\n'); + startPolling(tmpFile); + await wait(POLL_INTERVAL_MS * 2); + + eventCount = 0; + await wait(POLL_INTERVAL_MS * 8); + check('8 轮静默期内 0 事件', eventCount === 0, `eventCount=${eventCount}`); + stopPolling(); +} + +// ============================================================ +// [5] 主进程自写入:recordFileStat 刷新基线 → polling 跳过 +// ============================================================ +console.log('\n[5] 主进程自写入 → recordFileStat 跳过那次变化'); +{ + fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] a\n'); + startPolling(tmpFile); + await wait(POLL_INTERVAL_MS * 2); + + // 模拟主进程 file:write handler:写完调 recordFileStat + eventCount = 0; + await wait(60); // mtime 推进 + fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] 自写入\n'); + recordFileStat(tmpFile); // 关键:刷新基线 + + await wait(POLL_INTERVAL_MS * 4); + check('自写入刷新基线 → polling 不发 IPC', eventCount === 0, + `eventCount=${eventCount}`); + stopPolling(); +} + +// ============================================================ +// [6] dedup 窗口:连续 burst 事件只发一次 IPC +// ============================================================ +console.log('\n[6] dedup 窗口:连续 burst 事件只发一次 IPC'); +{ + fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] a\n'); + startPolling(tmpFile); + await wait(POLL_INTERVAL_MS * 2); + + // 模拟「一次外部保存触发 fs.watch + polling 多次回调」: + // 第一次 fireIpc 应当通过;同窗口内后续 fireIpc 应当被 dedup。 + // 注意:必须先 fireIpc 一次(设置 lastEventAt),后面再连续触发才看得到 dedup。 + eventCount = 0; + lastEventAt = 0; // 重置 dedup 起点 + fireIpc(tmpFile); // 第 1 次:应当通过 + const afterFirst = eventCount; + fireIpc(tmpFile); // 第 2 次:同窗口内,应被 dedup + fireIpc(tmpFile); // 第 3 次:同窗口内,应被 dedup + + check('首次 fireIpc 通过 dedup', afterFirst === 1, `eventCount=${afterFirst}`); + check('dedup 窗口内后续 fireIpc 被吞', eventCount === 1, + `eventCount=${eventCount}`); + stopPolling(); +} + +// ============================================================ +// [7] 文件被外部删除 → polling 不崩 +// ============================================================ +console.log('\n[7] 文件被外部删除 → polling 静默不崩'); +{ + fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] a\n'); + startPolling(tmpFile); + await wait(POLL_INTERVAL_MS * 2); + + eventCount = 0; + fs.unlinkSync(tmpFile); + await wait(POLL_INTERVAL_MS * 4); + check('文件被删后 polling 不触发 IPC(不能误判为外部修改)', + eventCount === 0, `eventCount=${eventCount}`); + + // 文件被恢复 + fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] 恢复\n'); + await wait(POLL_INTERVAL_MS * 4); + check('文件恢复后 polling 检测到 mtime 变化 → 触发 IPC', + eventCount === 1, `eventCount=${eventCount}`); + stopPolling(); +} + +// ============================================================ +// [8] 端到端:polling + 渲染端 v4 内容比对联调 +// ============================================================ +console.log('\n[8] 端到端:polling 触发后,渲染端 v4 内容比对仍能识别自写入'); +{ + // 这是 main.js + app.js 的联调:主进程 polling 触发 IPC 后, + // 渲染端 readFile + store.serialize() 比对,决定 reload or 静默。 + // 这里只测内容比对那一步(渲染端逻辑在 app.js,无法直接测)。 + + const initialMd = '# 全部任务\n\n## 工作\n\n- [ ] a\n'; + + // 模拟 store 内存状态(autoSave 已把内存序列化到磁盘) + const storeSerialize = initialMd; + + // (a) 自写入场景:fileContent === storeSerialize → 静默 + const fileContentAfterAutoSave = initialMd; + check('自写入:fileContent === storeSerialize → 静默', + fileContentAfterAutoSave === storeSerialize); + + // (b) 外部修改:fileContent !== storeSerialize → reload + const userEdited = '# 全部任务\n\n## 工作\n\n- [ ] a 修改\n'; + check('外部修改:fileContent !== storeSerialize → reload', + userEdited !== storeSerialize); + + // (c) 单字节差异(用户改了 1 个字符)也能识别 + const tinyEdit = '# 全部任务\n\n## 工作\n\n- [ ] A\n'; // a → A + check('单字节差异也能识别', + tinyEdit !== storeSerialize); + + // (d) 用户删除任务 + const userDeleted = '# 全部任务\n\n## 工作\n'; + check('用户删除任务也能识别', + userDeleted !== storeSerialize); +} + +// ============================================================ +// [9] fs.watch 回调里 statSync 刷新 lastSeen —— race 修复核心 +// ============================================================ +// +// race 场景:旧实现下 fs.watch 只发 IPC,不更新 lastSeen。polling 在 0.5 秒 +// 之后才检测到变化,期间 force=false 的 autoSave 用陈旧 lastSeen 做检查 —— +// 即使外部修改已经发生,statSync 拿到的「新磁盘状态」与 lastSeen 一致 → +// 静默覆盖外部编辑(数据丢失)。 +// +// 修复后:fs.watch 触发时立刻 statSync 更新 lastSeen。polling tick 内 +// 看到 lastSeen 与磁盘一致就不再触发 IPC,但 force=false autoSave 的 +// 二次 statSync 检查也会发现 lastSeen 与磁盘一致 —— 此时外部修改已被 +// 察觉、IPC 已发给 renderer 让用户介入,窗口期(< 100ms)几乎不可见。 +// +// 测试方式:先停掉 polling,再写文件(模拟 fs.watch 触发的瞬间), +// 立刻刷新 lastSeen,重启 polling —— 后续 tick 应该看不到差异。 +console.log('\n[9] fs.watch 回调里 statSync 刷新 lastSeen'); +{ + // 重置全局状态 + lastSeenMtimeMs = 0; + lastSeenSize = 0; + eventCount = 0; + lastEventAt = 0; + + fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] a\n'); + stopPolling(); // 先停掉 polling + // 把 lastSeen 初始化为磁盘当前状态(相当于 startPolling 内部的 recordFileStat) + recordFileStat(tmpFile); + await wait(POLL_INTERVAL_MS * 2); + + // 现在模拟「fs.watch 触发的瞬间」: + // 1) 外部编辑器写盘 + // 2) fs.watch callback 立刻 statSync → 更新 lastSeen + // 3) IPC 发给 renderer + // 整个动作在 polling tick 之前发生(stopPolling 期间 polling 不会跑)。 + fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] 外部修改\n'); + // 让 mtime 推进(保险,rename 后通常也需要 mtime 推进) + await wait(60); + + // 关键修复点:fs.watch 触发时立刻 statSync → 更新 lastSeen。 + // 模拟 main.js startWatchingFile 回调里 statSync 行为 + const externalStat = fs.statSync(tmpFile); + lastSeenMtimeMs = externalStat.mtimeMs; + lastSeenSize = externalStat.size; + + // 现在重启 polling,记录 fireIpc 次数 + eventCount = 0; + startPolling(tmpFile); // 内部会再次 recordFileStat 刷新基线 + + await wait(POLL_INTERVAL_MS * 4); + check('fs.watch 已刷新 lastSeen → polling 后续 tick 看到一致不发 IPC', + eventCount === 0, `eventCount=${eventCount}`); + + // 此时跑一次 force=false autoSave 的 statSync 检查: + // lastSeen === 磁盘状态 → 检查通过。这不是 bug: + // IPC 已经先发给 renderer 让用户介入,force=false autoSave 在 IPC 之后 + // 跑通是允许的 —— 数据丢失窗口被压缩到 IPC 触发 → autoSave 真正写盘之间。 + const autoSaveCheck = fs.statSync(tmpFile); + const checkPass = autoSaveCheck.mtimeMs === lastSeenMtimeMs && + autoSaveCheck.size === lastSeenSize; + check('force=false autoSave 的 statSync 检查与刷新后的基线一致(预期)', + checkPass === true); + + // 对照:把 lastSeen 重置为陈旧基线,再 statSync: + // 旧实现下这种场景下「lastSeen 陈旧 + statSync 新」会导致漏检(race)。 + // 这里只验证「陈旧 lastSeen 与磁盘新状态确实不等」—— 旧实现没刷新导致 race 正是这种不一致。 + lastSeenMtimeMs = 0; + lastSeenSize = 0; + const staleCheck = fs.statSync(tmpFile); + const wouldBeMissed = staleCheck.mtimeMs === lastSeenMtimeMs && + staleCheck.size === lastSeenSize; + check('对照:陈旧 lastSeen=0 与磁盘不一致(理应被旧 mtime 检查拦下)', + wouldBeMissed === false, + '修复前 race 窗口里这种不一致就是误判漏检的根因'); + + stopPolling(); +} + +// ============================================================ +// [10] 文件被外部删 + 同 mtime/size 重建 → 必须能检测到(不能漏报) +// ============================================================ +console.log('\n[10] 文件被外部删 + 同 mtime/size 重建 → polling 必须检测到'); +{ + // 审计里的边界 case:云同步目录的客户端偶尔会「整文件删除 + 重建完全相同内容」 + // 来做去重优化,文件系统可能沿用原 inode → mtime + size 不变。 + // 旧实现:statSync 抛错后只 return,lastSeen 保留旧值,下一轮 statSync 命中 + // 「mtime + size 与 lastSeen 一致」→ 漏报。 + // 新实现:statSync 抛错时把 lastSeen 清零,下一轮重建后 statSync 拿到新 mtime → + // lastSeen=0 不一致 → 触发 IPC。 + fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] a\n'); + startPolling(tmpFile); + await wait(POLL_INTERVAL_MS * 2); + + // 记录重建前后的 mtime + size,确认确实是「同 mtime/size 重建」场景 + const beforeStat = fs.statSync(tmpFile); + + eventCount = 0; + // 1) 外部删 + fs.unlinkSync(tmpFile); + await wait(POLL_INTERVAL_MS * 2); + check('文件被删期间:polling 不触发 IPC(避免误报)', + eventCount === 0, `eventCount=${eventCount}`); + + // 2) 重建内容完全相同的文件,强制沿用同一 mtime(utimes 显式设置) + fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] a\n'); + fs.utimesSync(tmpFile, beforeStat.atime, beforeStat.mtime); + // mtime/size 一致性是「目标场景」不是「测试断言」:NTFS 写入有 ms 级截断, + // utimesSync 也不能完全消除(实测 ±1ms 漂移)。但 IPC 触发的修复效果由下一个 + // check 覆盖 —— 不论 mtime 是否严格一致,文件被删 + 重建这个动作本身就该 + // 触发 IPC(修复前会漏报,因为 lastSeen 没被清零)。 + const afterStat = fs.statSync(tmpFile); + check('重建前后 size 一致(不依赖 mtime 精度)', + afterStat.size === beforeStat.size, + `before=${beforeStat.size} after=${afterStat.size}`); + + await wait(POLL_INTERVAL_MS * 4); + // 修复后应当触发 IPC;旧实现下 IPC 数为 0(漏报) + check('同 mtime/size 重建后 polling 触发 IPC(修复漏报)', + eventCount >= 1, `eventCount=${eventCount}`); + stopPolling(); +} + +console.log(`\n通过 ${summary.pass} / 失败 ${summary.fail}`); + +// 清理(必须放在所有测试结束之后 —— 之前的版本把 rmSync 放在 [9] 之后, +// 导致 [10] 测试用的临时目录已被清掉、fs.writeFileSync 直接 ENOENT) +cleanupTmpTodo({ dir: tmpDir }); +process.exit(summary.fail === 0 ? 0 : 1); \ No newline at end of file diff --git a/scripts/check-render-flow.mjs b/scripts/check-render-flow.mjs new file mode 100644 index 0000000..4e20ccb --- /dev/null +++ b/scripts/check-render-flow.mjs @@ -0,0 +1,320 @@ +// 用最小 DOM mock 模拟 Sidebar + TaskList 在「外部修改 → loadFromContent」后的渲染行为 +// +// 核心目的:捕获「change 事件触发后,listEl.innerHTML 是否真的反映了新 categories」 + +import { check, ok, bad, summary, printSummary } from './_lib/check.mjs'; +import { makeStore } from './_lib/store-fixture.mjs'; +import { readFileSync } from 'node:fs'; +import { findTodoFile } from './_lib/find-todo-path.mjs'; + +// ====== Minimal DOM ====== +class MiniElement { + constructor(tag) { + this.tagName = (tag || 'div').toUpperCase(); + this.children = []; + this.parent = null; + this._innerHTML = ''; + this._textContent = ''; + this.classList = { + _classes: new Set(), + add(c) { this._classes.add(c); }, + remove(c) { this._classes.delete(c); }, + toggle(c, force) { + if (force === undefined) { + if (this._classes.has(c)) this._classes.delete(c); + else this._classes.add(c); + } else if (force) this._classes.add(c); + else this._classes.delete(c); + }, + contains(c) { return this._classes.has(c); } + }; + this.dataset = {}; + this.style = {}; + this.hidden = false; + this.title = ''; + this.placeholder = ''; + this.disabled = false; + this.value = ''; + this.checked = false; + this.eventListeners = {}; + this._attrs = {}; + } + setAttribute(k, v) { this._attrs[k] = v; } + getAttribute(k) { return this._attrs[k]; } + set innerHTML(html) { + this._innerHTML = html; + // 简单模拟:把 innerHTML 当作文本存储,统计
  • 出现次数作为任务行数 + this._renderedHtml = html; + } + get innerHTML() { return this._innerHTML; } + set textContent(t) { this._textContent = t; this._innerHTML = ''; } + get textContent() { return this._textContent || ''; } + appendChild(c) { this.children.push(c); c.parent = this; return c; } + removeChild(c) { this.children = this.children.filter(x => x !== c); c.parent = null; } + remove() { if (this.parent) this.parent.removeChild(this); } + addEventListener(ev, fn) { + (this.eventListeners[ev] = this.eventListeners[ev] || []).push(fn); + } + removeEventListener(ev, fn) { + if (this.eventListeners[ev]) { + this.eventListeners[ev] = this.eventListeners[ev].filter(f => f !== fn); + } + } + closest(sel) { + // 简化:返回自身(仅用于「找带 dataset.id 的元素」场景的兼容) + return this; + } + querySelector() { return null; } + querySelectorAll() { return []; } + setPointerCapture() {} + releasePointerCapture() {} + contains() { return false; } + getBoundingClientRect() { return { top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0 }; } + focus() {} + click() {} + blur() {} + scrollIntoView() {} +} + +// 注册 DOM 元素 +const elements = {}; +function makeEl(id, tag = 'div') { + const el = new MiniElement(tag); + el.id = id; + elements[id] = el; + return el; +} + +const sidebar = makeEl('sidebar'); +const categoryList = makeEl('category-list'); +sidebar.appendChild(categoryList); +const ctxMenu = makeEl('category-context-menu'); +sidebar.appendChild(ctxMenu); + +const taskListEl = makeEl('task-list'); +const titleEl = makeEl('category-title'); +const titleProgressEl = makeEl('task-progress-text'); +const inputEl = makeEl('task-input', 'input'); +const submitBtn = makeEl('btn-submit-task', 'button'); +const emptyEl = makeEl('empty-state'); +const listEl = makeEl('task-list', 'ul'); // task-list 也是 ul(覆盖 taskListEl) +const filterBtn = makeEl('btn-filter', 'button'); +const filterChipEl = makeEl('filter-chip'); +const sortBtn = makeEl('btn-sort', 'button'); +const sortChipEl = makeEl('sort-chip'); +const importBtn = makeEl('btn-import', 'button'); +const batchBtn = makeEl('btn-batch', 'button'); +const chipText = makeEl('chip-text', 'span'); +batchBtn.appendChild(chipText); +const statusMeta = makeEl('status-meta'); +const statusText = makeEl('status-text'); +const searchInput = makeEl('search-input', 'input'); +const btnAddCategory = makeEl('btn-add-category', 'button'); +const submitTaskBtn = submitBtn; +taskListEl.appendChild(titleEl); +taskListEl.appendChild(titleProgressEl); +taskListEl.appendChild(inputEl); +taskListEl.appendChild(submitBtn); +taskListEl.appendChild(emptyEl); +taskListEl.appendChild(listEl); +taskListEl.appendChild(filterBtn); +taskListEl.appendChild(sortBtn); +taskListEl.appendChild(importBtn); +taskListEl.appendChild(batchBtn); +taskListEl.appendChild(searchInput); +taskListEl.appendChild(btnAddCategory); + +// document mock +globalThis.document = { + getElementById: (id) => elements[id] || null, + querySelector: () => null, + querySelectorAll: () => [], + addEventListener: () => {}, + removeEventListener: () => {}, + body: new MiniElement('body'), + activeElement: null, + createElement: (tag) => new MiniElement(tag), + createDocumentFragment: () => new MiniElement('fragment'), +}; +globalThis.window = { addEventListener: () => {}, removeEventListener: () => {} }; +globalThis.HTMLElement = MiniElement; +globalThis.Node = { ELEMENT_NODE: 1, TEXT_NODE: 3 }; +globalThis.Event = class { constructor(type) { this.type = type; } }; + +// ====== 加载真实模块 ====== +const { TaskStore, Filter, SortBy } = await import('../src/task-store.js'); +const { Sidebar } = await import('../src/ui/sidebar.js'); +const { TaskList } = await import('../src/ui/task-list.js'); + +// 优先用用户的真实 todo.md 做端到端验证,缺文件则用内置样例 +// 路径解析统一走 scripts/_lib/find-todo-path.mjs —— 不能硬编码用户路径 +const found = findTodoFile(); +const userTodoPath = found ? found.path : null; +// v3.6 起 todo.md 不再含顶部「# 当前任务 / # 重要任务」镜像段:[▶]/[⭐] 标识直接 +// 写在子分类里的源任务上即可,智能视图由 store 按标识聚合。 +const SAMPLE_MD = `# 全部任务 + +## 工作打发打发的法大师傅 + +- [✓] [⭐] 通过 17 / 失败 0 (check-trash-fuzz) +- [ ] 通过 14 / 失败 0 (check-settings) +- [✓] 通过 15 / 失败 0 (check-status-met +- [✓] [▶] [⭐] 的28刚刚给烦烦烦 +- [✓] [▶] [⭐] 2222111阿斯顿111 +- [ ] [▶] [⭐] 通过 19 / 失败 0 (check-trash)1112222aaa撒旦发射点发 + +## 学习 + +- [✓] [▶] [⭐] 学习111111 +- [✓] [⭐] 学习55 + +## 未分类 + +- [ ] 生活1 + +# 回收站 + + + +- [✓] [▶] [⭐] 生活2 +`; +let original, fwdPath; +if (found) { + original = found.content; + fwdPath = userTodoPath; + console.log(`[check-render-flow] 使用用户文件 ${fwdPath}(${original.length} 字符)`); +} else { + original = SAMPLE_MD; + // 兜底路径 —— 跟用户路径一样参与 pathsEqual 等字符串比对,仅作为虚拟占位 + fwdPath = `${process.platform === 'win32' ? 'C:\\TodoList' : '/tmp'}/todo.md`; + console.log('[check-render-flow] 找不到用户 todo.md,回退到内置样例(不写回磁盘)'); +} + +console.log('\n=== 完整流程:Sidebar + TaskList 订阅 change → 外部修改 → loadFromContent ===\n'); + +// 创建 store + 选一个有任务的子分类(兼容用户文件 + 内置样例) +// 不能硬取 [0] —— 用户可能把所有任务都从「未分类」清空了,导致初始任务数 = 0, +// 后续「初始渲染包含任务行」的断言无法成立。改取「第一个有任务的子分类」。 +const store = new TaskStore(); +store.loadFromContent(original, fwdPath); +// 优先选第一个有任务的子分类;若整个文件都没有子分类含任务,回退到第一个子分类 +// (不是死路径 —— 子分类存在但空是合法状态,但「初始渲染包含任务行」断言依赖有任务可渲)。 +let firstSub = store.getSubCategories().find(c => c.tasks.length > 0) + || store.getSubCategories()[0]; +if (!firstSub) throw new Error('未找到任何子分类'); +// 用户文件里所有子分类都空(只有「未分类」「学习」空段 + 已完成任务真实分类)时, +// 后续断言「初始渲染包含任务行」永远过不去 —— 这种结构对侧边栏渲染测试不友好, +// 切回内置 SAMPLE_MD 跑 —— SAMPLE 里有「工作」「学习」「未分类」三个有任务的子分类。 +// 注意:必须把 `original` 一起切到 SAMPLE_MD —— 下面的「模拟外部修改」用 original +// 找插入点;如果只切 store 不切 original,insertion 跑在用户文件上、load 进去 +// 又是 SAMPLE 分类,initial 和 reloaded 两边来自不同文件,断言 +2 永远对不上。 +// 也必须重新算 firstSub —— loadFromContent(SAMPLE) 会跑迁移,把「工作」清空、 +// 「学习」变成首个有任务的子分类,旧的 firstSub ref 就废了。 +if (original !== SAMPLE_MD && firstSub.tasks.length === 0) { + console.log('[setup] 用户文件所有子分类都空,切回 SAMPLE_MD 以保留测试信号'); + original = SAMPLE_MD; + store.loadFromContent(SAMPLE_MD, fwdPath); + firstSub = store.getSubCategories().find(c => c.tasks.length > 0) + || store.getSubCategories()[0]; +} +if (!firstSub) throw new Error('未找到任何子分类'); +store.selectCategory(firstSub.name); + +const initial = store.getSelectedCategory(); +if (!initial) throw new Error(`selectCategory(${firstSub.name}) 后 getSelectedCategory() 为 null`); +console.log(`初始: 「${initial.name}」任务数 = ${initial.tasks.length}`); + +// 实例化 UI 组件(订阅 change 事件,触发初始 render) +const sidebarUI = new Sidebar(store); +const taskListUI = new TaskList(store); + +const initHtml = listEl._renderedHtml || ''; +const initTaskLines = (initHtml.match(/
  • 0); + +// 渲染层会在中西文边界插入空的 (盘古之白,见 +// check-cjk-autospace.mjs),所以混排任务文本在 HTML 里是被间隙元素切开的。 +// 下面断言的是「任务文本有没有渲染出来」,不是 HTML 的字节形态 —— 先把间隙元素 +// 抹掉再做子串匹配,否则这里会跟着排版逻辑一起假红。 +const stripGaps = (h) => h.split('').join(''); +// 用一段渲染层不会重排的标识串断言 —— 渲染层会把多空格压成单空格, +// 所以选不含连续空格的固定标识。「check-settings」是 SAMPLE_MD 工作分类里 +// 仅有的两条 [ ] 待办之一,迁移完成后不会被搬到「# 已完成任务」,渲染层可见。 +// 注意:不能用 `[✓]` 任务的标识(如 check-trash-fuzz)—— 迁移逻辑会把已 +// 完成项从子分类搬到「# 已完成任务」,原位置就找不到这段文本了。 +if (original === SAMPLE_MD) { + check('初始渲染包含原始任务文本', stripGaps(initHtml).includes('check-settings')); +} + +// 模拟外部修改:插入新任务 —— 用第一个真实子分类的位置(兼容用户文件 + 内置样例) +// 旧实现硬编码 `## 工作` 在用户机器上没有就直接挂;改用 firstSub.name 动态适配。 +// v3.4:插入 [✓] 任务会触发「# 已完成任务」迁移,搬出当前子分类 —— 渲染流测试 +// 不应耦合迁移逻辑,所以两条都插入 [ ];[✓] 任务的搬迁路径在 check-trash 覆盖。 +const h2Work = original.indexOf(`## ${firstSub.name}`); +// 段终点取「下一个 h2」或「下一个 h1」(哪个先到用哪个)—— +// SAMPLE 里「未分类」是最后一个 h2,紧跟 `# 回收站`;只找 h2 会贴到 EOF, +// 新任务会被误归到回收站。verify-reload-ui.mjs 早就这么做了,这里补齐。 +let insertPoint; +if (h2Work < 0) { + insertPoint = original.length; +} else { + const nextH2 = original.indexOf('\n## ', h2Work); + const nextH1 = original.indexOf('\n# ', h2Work); + if (nextH2 > 0 && nextH1 > 0) insertPoint = Math.min(nextH2, nextH1); + else if (nextH2 > 0) insertPoint = nextH2; + else if (nextH1 > 0) insertPoint = nextH1; + else insertPoint = original.length; +} +const modified = original.slice(0, insertPoint) + + '\n- [ ] 外部添加的新任务A1\n' + + '- [ ] 外部添加的新任务B2 [⭐]\n' + + original.slice(insertPoint); + +// 通过「外部修改检测路径」调用 loadFromContent +store.loadFromContent(modified, fwdPath); + +const reloaded = store.getSelectedCategory(); +console.log(`重载后: 「${reloaded.name}」任务数 = ${reloaded.tasks.length}`); + +const reloadedHtml = listEl._renderedHtml || ''; +const reloadedTaskLines = (reloadedHtml.match(/
  • (盘古之白,见 +// check-cjk-autospace.mjs),所以「新任务A1」在 HTML 里是被间隙元素切开的。 +// 断言的是「任务文本有没有渲染出来」,不是 HTML 的字节形态。 +const reloadedText = stripGaps(reloadedHtml); + +check('重载后 store 任务数增加', + reloaded.tasks.length === initial.tasks.length + 2, + `${initial.tasks.length} → ${reloaded.tasks.length}`); +check('重载后 DOM 包含 A1', + reloadedText.includes('外部添加的新任务A1'), + 'A1 不在 listEl._renderedHtml 中'); +check('重载后 DOM 包含 B2', + reloadedText.includes('外部添加的新任务B2'), + 'B2 不在 listEl._renderedHtml 中'); +check('重载后 DOM 行数增加', + reloadedTaskLines > initTaskLines, + `${initTaskLines} → ${reloadedTaskLines}`); + +// ====== Sidebar 也要更新 ====== +const sidebarHtml = categoryList._renderedHtml || ''; +console.log(`\n Sidebar 渲染 HTML 长度: ${sidebarHtml.length}`); +// 检查 sidebar 是否包含第一个子分类 —— 用 firstSub.name 而不是硬编码「工作」, +// 让测试在用户机器上也能跑(用户的子分类名不一定是「工作」)。 +const hasSubcategory = sidebarHtml.includes('subcategory') && sidebarHtml.includes(firstSub.name); +check(`Sidebar 渲染包含「${firstSub.name}」子分类`, hasSubcategory); +// 「学习」是 SAMPLE_MD 里的固定名 —— 仅在用样例时校验 +if (original === SAMPLE_MD) { + check('Sidebar 渲染包含「学习」子分类', sidebarHtml.includes('学习')); +} +check('Sidebar 渲染包含智能列表', sidebarHtml.includes('当前任务') && sidebarHtml.includes('重要任务')); + +// ====== 关键检查:listEl.innerHTML 的实际值 ====== +console.log('\n实际 listEl.innerHTML:'); +console.log(reloadedHtml.slice(0, 500)); + +console.log(`\n通过 ${summary.pass} / 失败 ${summary.fail}`); +process.exit(summary.fail === 0 ? 0 : 1); \ No newline at end of file diff --git a/scripts/check-save-config-rollback.mjs b/scripts/check-save-config-rollback.mjs new file mode 100644 index 0000000..9cf9494 --- /dev/null +++ b/scripts/check-save-config-rollback.mjs @@ -0,0 +1,127 @@ +// 回归测试:H11 — saveConfig 写盘失败时 appConfig 不被覆盖 +// +// 背景:旧 saveConfig 在 writeFileSync 失败时已经更新了内存里的 appConfig +// (patch 在 writeFileSync 之前就赋给了 appConfig)。结果:磁盘保存失败, +// 但内存里的配置已经是「新值」—— 用户的设置在重启前看似生效,重启后又被 +// 旧值(磁盘上没保存)覆盖,体验「我明明改了,重启就没了」。 +// +// 修复(v3.7+):先 try 块内 writeFileSync 落盘,成功后再 commit 内存; +// 失败时保持原 appConfig 不变。 +// +// 跑法:node scripts/check-save-config-rollback.mjs + +import { check, summary, printSummary } from './_lib/check.mjs'; + +// ── 镜像实现:与 main.js 的 saveConfig 一致 ── +console.log('\n[1] saveConfig:写盘失败 → appConfig 保持原值'); + +{ + // 镜像 main.js 的新 saveConfig 逻辑(先落盘 → 成功再 commit 内存) + function makeSaveConfig(state = { writeFileThrows: false }) { + let appConfig = { theme: 'auto', fontSize: 14 }; + const writeFile = () => { + if (state.writeFileThrows) throw new Error('disk full'); + }; + function saveConfig(next) { + const candidate = { ...appConfig, ...next }; + try { + writeFile(); + } catch (e) { + // 写盘失败:保持 appConfig 不变 + return appConfig; + } + appConfig = candidate; + return appConfig; + } + return { get appConfig() { return appConfig; }, saveConfig }; + } + + // 场景 A:写盘成功 → 内存 + 返回值都更新 + { + const ctx = makeSaveConfig({ writeFileThrows: false }); + const before = ctx.appConfig; + const ret = ctx.saveConfig({ theme: 'dark', fontSize: 18 }); + check('成功:返回新 appConfig', + ret && ret.theme === 'dark' && ret.fontSize === 18, + `ret=${JSON.stringify(ret)}`); + check('成功:appConfig 已是新值', + ctx.appConfig.theme === 'dark' && ctx.appConfig.fontSize === 18); + check('成功:新对象 !== 旧对象(不可变更新)', + ctx.appConfig !== before); + } + + // 场景 B:写盘失败 → appConfig 保持原值 + { + const ctx = makeSaveConfig({ writeFileThrows: true }); + const beforeRef = ctx.appConfig; + const ret = ctx.saveConfig({ theme: 'dark', fontSize: 18 }); + check('失败:返回的仍是旧 appConfig', + ret && ret.theme === 'auto' && ret.fontSize === 14, + `ret=${JSON.stringify(ret)}`); + check('失败:内存 appConfig 未被覆盖', + ctx.appConfig.theme === 'auto' && ctx.appConfig.fontSize === 14, + `actual=${JSON.stringify(ctx.appConfig)}`); + check('失败:appConfig 仍是同一对象引用(无副作用写入)', + ctx.appConfig === beforeRef); + } + + // 场景 C:写盘失败后再试成功 → 之前的旧值仍然在,下次成功才更新 + { + // 用可变状态对象承载 writeFileThrows(闭包 getter 在工厂里捕获了 snapshot) + const state = { writeFileThrows: true }; + const ctx = makeSaveConfig(state); + const beforeRef = ctx.appConfig; + + // 第一次:写盘失败 + const ret1 = ctx.saveConfig({ theme: 'dark' }); + check('C1: 第一次失败 → 返回旧值', + ret1.theme === 'auto'); + check('C1: 第一次失败 → appConfig 未变', + ctx.appConfig === beforeRef); + + // 写盘恢复 + state.writeFileThrows = false; + + // 第二次:成功 + const ret2 = ctx.saveConfig({ theme: 'dark' }); + check('C2: 第二次成功 → 返回新值', + ret2.theme === 'dark'); + check('C2: 第二次成功 → appConfig 已是新值', + ctx.appConfig.theme === 'dark'); + } + + // 场景 D:部分字段更新不影响其他字段 + { + const ctx = makeSaveConfig(); + const ret = ctx.saveConfig({ fontSize: 20 }); + check('D: 部分字段更新 → theme 保留', + ret.theme === 'auto' && ret.fontSize === 20); + } +} + +// ── [2] 旧实现的回归测试:覆盖式 patch 会污染内存 ── +console.log('\n[2] 回归:旧实现(patch 在 write 之前)会污染内存'); + +{ + // 镜像 main.js 的旧 saveConfig 行为:先合并、再 try write + let appConfig = { theme: 'auto', fontSize: 14 }; + const writeFile = () => { throw new Error('disk full'); }; + function oldSaveConfig(next) { + const candidate = { ...appConfig, ...next }; + try { + writeFile(); + } catch (e) { + // 旧版错误地 commit 了 candidate + appConfig = candidate; + return appConfig; + } + return appConfig; + } + oldSaveConfig({ theme: 'dark' }); + // 旧实现下:appConfig.theme === 'dark' ← 这就是 bug + check('回归:旧实现下写盘失败但 appConfig.theme 已被改成 "dark"', + appConfig.theme === 'dark', + `actual=${appConfig.theme}`); +} + +printSummary('check-save-config-rollback'); \ No newline at end of file diff --git a/scripts/check-second-pass-audit.mjs b/scripts/check-second-pass-audit.mjs new file mode 100644 index 0000000..3b627a1 --- /dev/null +++ b/scripts/check-second-pass-audit.mjs @@ -0,0 +1,319 @@ +// 第二轮深度审计回归: +// +// 在第一轮修复 (_dragJustEnded) 之后,又排查出多个潜在的「按钮没反应 / +// 反应错」根因。本脚本一次性覆盖所有第二轮修复点,避免再次回归。 +// +// 涵盖: +// H1 - focus-trap.js 必须用 getClientRects / visibility 检查, +// 不能继续用 offsetParent(position: fixed 永远 null) +// H2 - checkbox 点击:input 上的合成 click 必须跳过 +// (label 触发一次已 toggle,input 的合成 click 二次 toggle 会抵消) +// H3 - Space / F2 / Delete keydown 必须有 isImeComposing 守卫 +// M8 - feedback.js 提供 registerBeforeModalShow, +// confirmDialog / inputDialog 打开前调用 hooks, +// task-list.js 注册 _cancelAllDrags、sidebar.js 注册 _abortAllSidebarDrags +// L4 - _exitSelectionMode 必须立即同步 body.dataset.selectionMode = 'false' +// M1 - render() 内 _commitActiveEdit 加 isConnected 守卫 + +import { readFileSync } from 'node:fs'; +import { ok, bad, printSummary } from './_lib/check.mjs'; + +const TASK_LIST = readFileSync('src/ui/task-list.js', 'utf8'); +const FOCUS_TRAP = readFileSync('src/utils/focus-trap.js', 'utf8'); +const FEEDBACK = readFileSync('src/ui/feedback.js', 'utf8'); +const SIDEBAR = readFileSync('src/ui/sidebar.js', 'utf8'); + +let pass = 0, fail = 0; +function expect(name, cond, info) { + if (cond) { ok(name); pass++; } else { bad(name, info); fail++; } +} + +function section(name) { console.log(`\n[${name}]`); } + +// ───────────────────────────────────────────────────────────────────────────── +// H1: focus-trap 不再依赖 offsetParent +// ───────────────────────────────────────────────────────────────────────────── +section('H1: focus-trap.js 用 getClientRects / visibility,不用 offsetParent'); + +// 注释里允许提到 offsetParent 作为历史说明,但要确保代码里不再使用。 +// 用「去掉所有注释后再检查」的策略:先去掉 /* ... */ 块注释,再剥 // 行注释。 +const stripBlockComments = (s) => s.replace(/\/\*[\s\S]*?\*\//g, ''); +const codeOnly = stripBlockComments(FOCUS_TRAP).split('\n').map(line => { + const i = line.indexOf('//'); + if (i >= 0 && line.slice(0, i).trim() !== '') return line.slice(0, i); + return line; +}).join('\n'); + +expect( + 'focus-trap.js 代码里不再用 offsetParent(注释中允许提到历史 bug)', + !/offsetParent/.test(codeOnly), + 'offsetParent 对 position:fixed 元素恒为 null,会把 modal 内所有 focusable 误判为不可见' +); + +expect( + 'focus-trap.js 使用 getClientRects() 判定可见性', + /getClientRects\(\)/.test(FOCUS_TRAP), + '需要 getClientRects().length === 0 判断零尺寸 / 不可见' +); + +expect( + 'focus-trap.js 检查 computed visibility / display', + /visibility\s*===\s*['"]hidden['"]/.test(FOCUS_TRAP) && + /display\s*===\s*['"]none['"]/.test(FOCUS_TRAP), + '需要兜底 display:none / visibility:hidden' +); + +// ───────────────────────────────────────────────────────────────────────────── +// H2: checkbox 双触发修复 +// ───────────────────────────────────────────────────────────────────────────── +section('H2: checkbox 点击 → 跳过 input 上的合成 click'); + +// 抓取 this.listEl.addEventListener('click', ...) 整段(用括号计数法定位尾部 `});`) +{ + const start = TASK_LIST.indexOf("this.listEl.addEventListener('click'"); + expect( + 'click handler 可被静态解析', + start >= 0, + '没找到 click handler' + ); + + if (start >= 0) { + const openIdx = TASK_LIST.indexOf('{', start); + let depth = 1, i = openIdx + 1; + while (i < TASK_LIST.length && depth > 0) { + const ch = TASK_LIST[i]; + if (ch === '{') depth++; + else if (ch === '}') depth--; + i++; + } + const body = TASK_LIST.slice(openIdx + 1, i - 1); + + expect( + 'checkbox 分支包含 task-checkbox-wrapper / task-checkbox-custom / task-checkbox 三选一匹配', + /task-checkbox-wrapper/.test(body) && /task-checkbox-custom/.test(body) && /task-checkbox/.test(body), + 'checkbox 分支必须仍然能命中三种 className(不破坏现有逻辑)' + ); + + expect( + 'checkbox 分支包含「input 上的合成 click 跳过」守卫', + /tagName\s*===\s*['"]INPUT['"][\s\S]{0,200}task-checkbox/.test(body) || + /tagName\s*===\s*['"]INPUT['"][\s\S]{0,200}return/.test(body), + '必须检测 e.target.tagName === "INPUT" 且 class 含 task-checkbox 时 return' + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// H3: Space / F2 / Delete IME 守卫 +// ───────────────────────────────────────────────────────────────────────────── +section('H3: keydown handler 的 Space / F2 / Delete 加 isImeComposing 守卫'); + +// 抓取 docKeydown 整段:括号计数法定位尾部 +{ + const start = TASK_LIST.indexOf('const docKeydown = '); + expect( + 'docKeydown handler 可被静态解析', + start >= 0, + '没找到 docKeydown 命名函数' + ); + + if (start >= 0) { + const openIdx = TASK_LIST.indexOf('{', start); + let depth = 1, i = openIdx + 1; + while (i < TASK_LIST.length && depth > 0) { + const ch = TASK_LIST[i]; + if (ch === '{') depth++; + else if (ch === '}') depth--; + i++; + } + const body = TASK_LIST.slice(openIdx + 1, i - 1); + + // 验证每个键在分支条件里出现 isImeComposing(e) + const checks = [ + { key: 'Space', pattern: /e\.key\s*===\s*['"] ['"][\s\S]{0,80}!isImeComposing/ }, + { key: 'F2', pattern: /e\.key\s*===\s*['"]F2['"][\s\S]{0,80}!isImeComposing/ }, + { key: 'Delete', pattern: /e\.key\s*===\s*['"]Delete['"][\s\S]{0,80}!isImeComposing/ } + ]; + + for (const c of checks) { + expect( + `${c.key} 分支带 isImeComposing 守卫`, + c.pattern.test(body), + `${c.key} 必须用 !isImeComposing(e) 与 selectedTaskId 一起判断` + ); + } + + // Enter 仍然有守卫(防止被改坏) + expect( + 'Enter 分支带 isImeComposing 守卫(防止重构时弄丢)', + /e\.key\s*===\s*['"]Enter['"][\s\S]{0,80}!isImeComposing/.test(body), + 'Enter 的 IME 守卫必须保留' + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// M8: modal 打开前清掉拖拽 +// ───────────────────────────────────────────────────────────────────────────── +section('M8: feedback.js 注册式 hook + confirm/inputDialog 调用 + 组件注册'); + +expect( + 'feedback.js 导出 registerBeforeModalShow', + /export\s+function\s+registerBeforeModalShow/.test(FEEDBACK), + '必须导出注册入口供组件挂载清理逻辑' +); + +expect( + 'feedback.js 内部维护 beforeModalShowHooks Set', + /beforeModalShowHooks[\s\S]{0,40}=[\s\S]{0,20}new\s+Set\(\)/.test(FEEDBACK), + '需要 Set 而非 Array,避免同一 hook 重复注册后被多次调用' +); + +expect( + 'feedback.js 有 runBeforeModalShow 内部调用函数', + /function\s+runBeforeModalShow\(/.test(FEEDBACK), + '需要统一的调用入口,便于 try/catch 隔离单个 hook 抛错' +); + +// confirmDialog 与 inputDialog 在挂载 overlay 之前必须调用 runBeforeModalShow +const confirmMatch = FEEDBACK.match( + /export\s+function\s+confirmDialog\([\s\S]*?return\s+new\s+Promise\([\s\S]*?runBeforeModalShow\(\);/ +); +const inputMatch = FEEDBACK.match( + /export\s+function\s+inputDialog\([\s\S]*?return\s+new\s+Promise\([\s\S]*?runBeforeModalShow\(\);/ +); + +expect( + 'confirmDialog 在 overlay 创建前调 runBeforeModalShow()', + !!confirmMatch, + 'confirmDialog 必须先调 hook 再 document.body.appendChild(overlay)' +); + +expect( + 'inputDialog 在 overlay 创建前调 runBeforeModalShow()', + !!inputMatch, + 'inputDialog 必须先调 hook 再 document.body.appendChild(overlay)' +); + +// 其他 4 个 modal 也必须调 runBeforeModalShow —— 否则用户在这些 dialog +// 打开时拖拽状态会残留,pointerup 重排任务。 +const otherModals = [ + { name: 'openImportDialog (import-dialog.js)', file: readFileSync('src/ui/import-dialog.js', 'utf8') }, + { name: 'openSettingsDialog (settings-dialog.js)', file: readFileSync('src/ui/settings-dialog.js', 'utf8') }, + { name: 'openConflictDialog (conflict-dialog.js)', file: readFileSync('src/ui/conflict-dialog.js', 'utf8') }, + { name: 'openDiffPreview (diff-preview-dialog.js)', file: readFileSync('src/ui/diff-preview-dialog.js', 'utf8') }, + { name: '_openCommandPaletteInner (command-palette.js)', file: readFileSync('src/ui/command-palette.js', 'utf8') } +]; + +for (const m of otherModals) { + expect( + `${m.name} import runBeforeModalShow`, + /import\s*\{[^}]*runBeforeModalShow[^}]*\}\s*from\s*['"]\.\/feedback\.js['"]/.test(m.file), + `${m.name} 必须从 feedback.js 导入 runBeforeModalShow` + ); + + expect( + `${m.name} 在 Promise 体内调 runBeforeModalShow()`, + /return\s+new\s+Promise\([\s\S]*?runBeforeModalShow\(\);/.test(m.file) || + /return\s+new\s+Promise\([\s\S]*?runBeforeModalShow\(\)/.test(m.file), + `${m.name} 必须先调 hook 再 document.body.appendChild(overlay)` + ); +} + +// task-list.js 注册 _cancelAllDrags 到 hook +expect( + 'task-list.js 注册 _cancelAllDrags 作为 modal 打开前 hook', + /registerBeforeModalShow\(\(\)\s*=>\s*this\._cancelAllDrags\(\)\)/.test(TASK_LIST), + 'task-list.js 必须注册 hook 调 _cancelAllDrags' +); + +expect( + 'task-list.js 在 destroy() 里注销 hook', + /destroy\(\)\s*\{[\s\S]*?_unregisterModalHook[\s\S]*?delete\(|this\._unregisterModalHook\s*=\s*null/.test(TASK_LIST), + 'destroy 必须调反注册函数并清引用,防止热重载 / 多实例化累加 hook' +); + +// sidebar.js 注册 _abortAllSidebarDrags +expect( + 'sidebar.js 注册 _abortAllSidebarDrags 作为 modal 打开前 hook', + /registerBeforeModalShow\(\(\)\s*=>\s*this\._abortAllSidebarDrags\(\)\)/.test(SIDEBAR), + 'sidebar.js 必须注册 hook 调 _abortAllSidebarDrags' +); + +expect( + 'sidebar.js 在 destroy() 里注销 hook', + /destroy\(\)\s*\{[\s\S]*?_unregisterModalHook[\s\S]*?delete\(|this\._unregisterModalHook\s*=\s*null/.test(SIDEBAR), + 'sidebar.destroy 必须注销 hook' +); + +// ───────────────────────────────────────────────────────────────────────────── +// L4: _exitSelectionMode 立即同步 body data 属性 +// ───────────────────────────────────────────────────────────────────────────── +section('L4: _exitSelectionMode 立即同步 body[data-selection-mode] = false'); + +const exitMatch = TASK_LIST.match( + /_exitSelectionMode\(\{[\s\S]*?\}\s*=\s*\{\}\)\s*\{([\s\S]*?)\n \}/ +); + +expect( + '_exitSelectionMode 可被静态解析', + !!exitMatch, + '没找到 _exitSelectionMode 函数' +); + +if (exitMatch) { + const body = exitMatch[1]; + const setIdx = body.indexOf("document.body.dataset.selectionMode = 'false'"); + const renderIdx = body.indexOf('this.render();'); + + expect( + 'body 里包含 body.dataset.selectionMode = "false" 同步赋值', + setIdx >= 0, + '必须在 render() 之前同步设 data 属性,杜绝中间态' + ); + + expect( + 'data 属性赋值在 render() 之前', + setIdx >= 0 && renderIdx >= 0 && setIdx < renderIdx, + `set 在 ${setIdx},render 在 ${renderIdx} —— 必须前者在前` + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// M1: render() 中 _commitActiveEdit 加 isConnected 守卫 +// ───────────────────────────────────────────────────────────────────────────── +section('M1: render() 内 _commitActiveEdit 加 isConnected 守卫'); + +// 用括号计数法精确抓取 render() 函数体(不用 regex,避免复杂的非贪婪匹配) +{ + // 找第一个 `render() {` + const start = TASK_LIST.search(/^\s*render\(\)\s*\{/m); + expect( + 'render() 可被静态解析', + start >= 0, + '没找到 render() 方法' + ); + + if (start >= 0) { + const openIdx = TASK_LIST.indexOf('{', start); + let depth = 1, i = openIdx + 1; + while (i < TASK_LIST.length && depth > 0) { + const ch = TASK_LIST[i]; + if (ch === '{') depth++; + else if (ch === '}') depth--; + i++; + } + const body = TASK_LIST.slice(openIdx + 1, i - 1); + + expect( + 'render() 用 isConnected 守卫 _commitActiveEdit', + /_editInput[\s\S]{0,40}isConnected/.test(body) && /_commitActiveEdit\(\)/.test(body), + '必须有 if (this._editInput && this._editInput.isConnected) this._commitActiveEdit();' + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 收口 +// ───────────────────────────────────────────────────────────────────────────── +printSummary('check-second-pass-audit', true); \ No newline at end of file diff --git a/scripts/check-settings.mjs b/scripts/check-settings.mjs new file mode 100644 index 0000000..1ee9dc5 --- /dev/null +++ b/scripts/check-settings.mjs @@ -0,0 +1,174 @@ +import { check, ok, bad, summary, printSummary } from './_lib/check.mjs'; +import { makeStore } from './_lib/store-fixture.mjs'; +import { installApiStub } from './_lib/api-stub.mjs'; +// 设置存储的回归测试:覆盖 update() 和 load() 的输入校验路径。 +// +// 触发 bug 的两种姿势,都靠「脏值穿过校验、污染内存 + 配置文件」来静默损坏用户设置: +// +// 1. update({ theme: null }) +// 旧实现只校验「值 !== 当前值」和「key 在 DEFAULT_SETTINGS 中」,于是 theme +// 被设成 null。resolveTheme() 能兜底返回 'dark',但 this._settings.theme +// 仍是 null,下一次 setSortBy 等比较触发意外分支;更糟的是下次保存会把脏 +// theme: null 写入 config.json —— 旧用户的设置从此永久损坏。 +// +// 2. 用户手工编辑 config.json,写成 `theme: null` 或 `fontSize: "huge"` +// 加载端只按 key 字段过滤,不校验值类型 / 是否在枚举内 —— 同样的污染路径, +// 只是触发位置从 update 换到了 load。 +// +// 这份脚本直接构造 SettingsStore 实例 + 桩 api,逐项验证上述姿势都被拦截。 +// 跑法:node scripts/check-settings.mjs + +// 同 check-imports:SettingsStore 的模块作用域会注册事件回调,但仅用到 +// 内存对象,没有副作用。installApiStub() 默认就是空实现。 +installApiStub(); + +const { SettingsStore } = await import('../src/settings-store.js'); + +// ============================================ +// update() 输入校验 +// ============================================ +console.log('[1] update() 拒绝非法值'); + +{ + const s = new SettingsStore(); + await s.update({ theme: null }); + if (s.get('theme') === 'dark') ok('update({theme: null}) 被忽略,theme 保持 dark'); + else bad('theme 被 null 污染', `当前 ${JSON.stringify(s.get('theme'))}`); +} + +{ + const s = new SettingsStore(); + await s.update({ theme: undefined }); + if (s.get('theme') === 'dark') ok('update({theme: undefined}) 被忽略'); + else bad('theme 被 undefined 污染', `当前 ${JSON.stringify(s.get('theme'))}`); +} + +{ + const s = new SettingsStore(); + await s.update({ filter: 42 }); + if (s.get('filter') === 'all') ok('update({filter: 42}) 数字被拒绝'); + else bad('filter 被数字污染', `当前 ${JSON.stringify(s.get('filter'))}`); +} + +{ + const s = new SettingsStore(); + await s.update({ fontSize: 'huge' }); + if (s.get('fontSize') === 'medium') ok('update({fontSize: "huge"}) 枚举外字符串被拒绝'); + else bad('fontSize 被非法字符串污染', `当前 ${JSON.stringify(s.get('fontSize'))}`); +} + +{ + const s = new SettingsStore(); + await s.update({ sortBy: { evil: true } }); + if (s.get('sortBy') === 'manual') ok('update({sortBy: object}) 对象被拒绝'); + else bad('sortBy 被对象污染', `当前 ${JSON.stringify(s.get('sortBy'))}`); +} + +{ + const s = new SettingsStore(); + await s.update({ __proto__: { theme: 'light' } }); + // 原型污染:__proto__ 不是 own property,Object.entries 不该枚举它 + if (s.get('theme') === 'dark') ok('update({__proto__}) 原型污染被拒绝'); + else bad('theme 被原型污染', `当前 ${JSON.stringify(s.get('theme'))}`); +} + +{ + const s = new SettingsStore(); + await s.update({ unknownField: 'value' }); + if (s.get('unknownField') === undefined) ok('update({unknownField}) 未知字段被忽略'); + else bad('未知字段被写入', `当前 ${JSON.stringify(s.get('unknownField'))}`); +} + +// dataDir 单独测:null 是合法语义(恢复默认),字符串也合法 +{ + const s = new SettingsStore(); + await s.update({ dataDir: null }); + if (s.get('dataDir') === null) ok('update({dataDir: null}) 保留 null(恢复默认语义)'); + else bad('dataDir: null 被错误拒绝', `当前 ${JSON.stringify(s.get('dataDir'))}`); +} + +{ + const s = new SettingsStore(); + await s.update({ dataDir: 'D:/Tasks' }); + if (s.get('dataDir') === 'D:/Tasks') ok('update({dataDir: "D:/Tasks"}) 字符串路径生效'); + else bad('dataDir 字符串路径未生效', `当前 ${JSON.stringify(s.get('dataDir'))}`); +} + +{ + const s = new SettingsStore(); + await s.update({ dataDir: 123 }); + if (s.get('dataDir') === null) ok('update({dataDir: 123}) 数字被拒绝(保持 null)'); + else bad('dataDir 数字污染', `当前 ${JSON.stringify(s.get('dataDir'))}`); +} + +// 合法值的 happy path +{ + const s = new SettingsStore(); + const result = await s.update({ theme: 'light', fontSize: 'large' }); + if (s.get('theme') === 'light' && s.get('fontSize') === 'large' && result === true) { + ok('update({theme: "light", fontSize: "large"}) 合法值生效并返回 true'); + } else { + bad('合法值未生效', JSON.stringify({ theme: s.get('theme'), fontSize: s.get('fontSize'), result })); + } +} + +// ============================================ +// load() 输入校验 —— 模拟配置文件被外部改成脏值 +// ============================================ +console.log('\n[2] load() 从脏配置恢复默认值'); + +{ + const s = new SettingsStore(); + s._settings = {}; // 重置,确保 load 是从主进程取数 + // 临时替换 api 返回脏配置 + const orig = globalThis.window.api.getSettings; + globalThis.window.api.getSettings = async () => ({ + theme: null, + filter: 42, + sortBy: 'random', + fontSize: 'huge', + dataDir: 'D:/Real' // 合法值,应保留 + }); + try { + await s.load(); + const cur = s.all; + if (cur.theme === 'dark' && + cur.filter === 'all' && + cur.sortBy === 'manual' && + cur.fontSize === 'medium' && + cur.dataDir === 'D:/Real') { + ok('脏 theme/filter/sortBy/fontSize 全部回退到默认值;合法 dataDir 保留'); + } else { + bad('load 污染了设置', JSON.stringify(cur)); + } + } finally { + globalThis.window.api.getSettings = orig; + } +} + +{ + const s = new SettingsStore(); + const orig = globalThis.window.api.getSettings; + globalThis.window.api.getSettings = async () => ({ + theme: 'light', + filter: 'pending', // 老 enum 值,load 端应迁移到 'current' + extraGarbage: 'ignored', + __proto__: { theme: 'dark' } // 原型污染:load 端同样要抗 + }); + try { + await s.load(); + const cur = s.all; + if (cur.theme === 'light' && cur.filter === 'current' && cur.extraGarbage === undefined) { + ok('合法字段生效;老 enum(pending→current)自动迁移;未知字段忽略;原型污染无效'); + } else { + bad('load 表现异常', JSON.stringify(cur)); + } + } finally { + globalThis.window.api.getSettings = orig; + } +} + +// ============================================ +console.log(`\n${'─'.repeat(48)}`); +console.log(`通过 ${summary.pass} / 失败 ${summary.fail}`); +process.exit(summary.fail === 0 ? 0 : 1); diff --git a/scripts/check-snapshot-creation.mjs b/scripts/check-snapshot-creation.mjs new file mode 100644 index 0000000..0ff10a4 --- /dev/null +++ b/scripts/check-snapshot-creation.mjs @@ -0,0 +1,156 @@ +// file:create-snapshot IPC 的契约级测试 +// +// 主进程的 file:create-snapshot handler 用了 ipcMain / Electron 上下文, +// 无法在 Node 里直接调用。这个脚本用「静态分析 + 真实文件操作」两步验证: +// 1) 静态扫描 main.js 验证: +// - VALID_SNAPSHOT_LABELS 包含全部预期 label(discard / autosave-on-exit / before-reload / user-merge) +// - 命名约定:${filePath}.${label}-${YYYYMMDD-HHmmss}.bak +// - 走 assertInDataDir(防止任意路径写) +// 2) 真实跑一个 Node 子进程:执行我们临时拼出的「镜像实现」(与 main.js 内逻辑等价), +// 验证: +// - 快照文件名带 .${label}-${ts}.bak 后缀 +// - 二次调用同秒 → 文件名带 -1 后缀 +// - FILE_NOT_FOUND 时返回错误 +// - assertInDataDir 拒绝数据目录外路径 + +import { check, ok, bad, summary, printSummary } from './_lib/check.mjs'; +import { makeStore } from './_lib/store-fixture.mjs'; +import { makeTmpTodoFile, cleanupTmpTodo } from './_lib/tmp-todo.mjs'; +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +// 主进程 main.js 源码(静态分析用) +const mainSrc = fs.readFileSync(path.resolve('main.js'), 'utf8'); + +// 命名约定含 label + timestamp + .bak +if (/\.\$\{label\}-\$\{ts\}\.bak/.test(mainSrc) || /\.\$\{label\}-\$\{ts\}/.test(mainSrc)) { + ok('快照命名遵循 ${label}-${ts}.bak 约定'); +} else { + bad('快照命名', '未发现 .${label}-${ts}.bak 命名模式'); +} + +// timestamp 含 YYYYMMDD-HHmmss 模式 +if (/getFullYear\(\)/.test(mainSrc) && /getMonth\(\)/.test(mainSrc) && /getDate\(\)/.test(mainSrc)) { + ok('timestamp 含 getFullYear/Month/Date 字段'); +} else { + bad('timestamp', '未发现完整日期字段'); +} + +// assertInDataDir 调用 +if (/assertInDataDir\(filePath\)/.test(mainSrc)) { + ok('create-snapshot 走 assertInDataDir 路径校验'); +} else { + bad('assertInDataDir', 'create-snapshot 未调用 assertInDataDir'); +} + +// 路径白名单:label 校验 +if (/VALID_SNAPSHOT_LABELS\.has\(label\)/.test(mainSrc)) { + ok('label 通过白名单校验'); +} else { + bad('label 校验', '未发现 VALID_SNAPSHOT_LABELS.has(label)'); +} + +// FILE_NOT_FOUND 错误码 +if (/FILE_NOT_FOUND/.test(mainSrc)) { + ok('create-snapshot 返回 FILE_NOT_FOUND 错误码'); +} else { + bad('错误码', '未发现 FILE_NOT_FOUND 返回'); +} + +// ── 2) 真实执行验证(spawn 一个 Node 进程跑镜像逻辑)── +console.log('\n[真实执行]'); + +// 临时数据目录 + 临时源文件 +const { dir: tmpDir, file: sourcePath } = makeTmpTodoFile({ + prefix: 'snap-test-', + content: '# todo\n- [ ] A\n- [ ] B\n' +}); + +// 写一个独立的 Node 脚本镜像 main.js 的核心逻辑,然后跑它 +const mirrorScript = ` +const fs = require('fs'); +const path = require('path'); +const VALID_LABELS = new Set(['discard','autosave-on-exit','before-reload','user-merge']); + +function createSnapshot(filePath, label) { + if (!VALID_LABELS.has(label)) return { ok: false, error: 'invalid label' }; + if (!fs.existsSync(filePath)) return { ok: false, error: 'FILE_NOT_FOUND' }; + const now = new Date(); + const pad = n => String(n).padStart(2, '0'); + const ts = now.getFullYear() + pad(now.getMonth()+1) + pad(now.getDate()) + + '-' + pad(now.getHours()) + pad(now.getMinutes()) + pad(now.getSeconds()); + let p = filePath + '.' + label + '-' + ts + '.bak'; + let n = 1; + while (fs.existsSync(p)) { + p = filePath + '.' + label + '-' + ts + '-' + n + '.bak'; + n++; + } + fs.copyFileSync(filePath, p); + return { ok: true, snapshotPath: p }; +} + +// 1) 正常调用 +const r1 = createSnapshot(${JSON.stringify(sourcePath)}, 'discard'); +console.log('r1=' + JSON.stringify(r1)); + +// 2) 同秒第二次 → 文件名带 -1 +const r2 = createSnapshot(${JSON.stringify(sourcePath)}, 'discard'); +console.log('r2=' + JSON.stringify(r2)); + +// 3) 文件不存在 → FILE_NOT_FOUND +const r3 = createSnapshot(${JSON.stringify(path.join(tmpDir, 'missing.md'))}, 'discard'); +console.log('r3=' + JSON.stringify(r3)); + +// 4) 非法 label +const r4 = createSnapshot(${JSON.stringify(sourcePath)}, 'evil/../escape'); +console.log('r4=' + JSON.stringify(r4)); + +// 5) 快照文件确实落地 +const exists = fs.existsSync(r1.snapshotPath); +console.log('exists=' + exists); + +// 6) 快照内容与源一致 +const original = fs.readFileSync(${JSON.stringify(sourcePath)}, 'utf8'); +const snap = fs.readFileSync(r1.snapshotPath, 'utf8'); +console.log('contentMatch=' + (original === snap)); +`; + +const scriptPath = path.join(tmpDir, 'run.js'); +fs.writeFileSync(scriptPath, mirrorScript, 'utf8'); +const result = spawnSync(process.execPath, [scriptPath], { encoding: 'utf8' }); +if (result.status !== 0) { + bad('子进程执行', `退出码 ${result.status}, stderr=${result.stderr}`); +} else { + const lines = result.stdout.trim().split('\n').map(l => { + const [k, v] = l.split('='); + return [k, v]; + }); + const map = Object.fromEntries(lines); + if (map.r1?.startsWith('{"ok":true')) ok('正常创建快照'); + else bad('正常创建', `r1=${map.r1}`); + if (map.r2?.includes('-1.bak')) ok('同秒碰撞自动加 -1 后缀'); + else bad('碰撞后缀', `r2=${map.r2}`); + if (map.r3?.includes('FILE_NOT_FOUND')) ok('文件不存在 → FILE_NOT_FOUND'); + else bad('FILE_NOT_FOUND', `r3=${map.r3}`); + if (map.r4?.includes('invalid label')) ok('非法 label → 拒绝'); + else bad('label 校验', `r4=${map.r4}`); + if (map.exists === 'true') ok('快照文件实际落地'); + else bad('落地', `exists=${map.exists}`); + if (map.contentMatch === 'true') ok('快照内容与源一致'); + else bad('内容一致', `contentMatch=${map.contentMatch}`); +} + +// 清理 +try { + cleanupTmpTodo({ dir: tmpDir }); +} catch {} + +console.log(''); +if (summary.fail === 0) { + console.log(`✓ 全部通过`); + process.exit(0); +} else { + console.log(`✗ ${summary.fail} 处失败`); + process.exit(1); +} \ No newline at end of file diff --git a/scripts/check-status-meta.mjs b/scripts/check-status-meta.mjs new file mode 100644 index 0000000..408af1c --- /dev/null +++ b/scripts/check-status-meta.mjs @@ -0,0 +1,244 @@ +import { check, ok, bad, summary, printSummary } from './_lib/check.mjs'; +import { makeStore } from './_lib/store-fixture.mjs'; +import { installApiStub } from './_lib/api-stub.mjs'; +// 回归测试:状态栏 #status-meta 在保存完成后必须显示「已同步 HH:MM」 +// +// 触发 bug 的姿势(app.js 第 178-192 行旧实现): +// _doAutoSave / saveNow 成功 → emit('saved') → 处理器把 #status-meta 写成 +// 「已同步 HH:MM」 +// → 紧接着 emit('dirty', false) → 处理器把 textContent='' +// 用户看到的现象:保存之后状态栏空空的,好像没保存过。 +// +// 修复:dirty(false) 处理器只在 isDirty=true 时覆写文本;saved 处理器独立 +// 维护时间戳;新增 load 处理器在重新加载后清空状态栏。 +// +// 跑法:node scripts/check-status-meta.mjs + +// 桩 DOM:状态栏文本 + classList。最简化的 stub,不必仿真全部 DOM API。 +let savedText = ''; +let savedClasses = new Set(); +const fakeMeta = { + get textContent() { return savedText; }, + set textContent(v) { savedText = v; }, + classList: { + toggle(c, on) { if (on) savedClasses.add(c); else savedClasses.delete(c); }, + add(c) { savedClasses.add(c); }, + remove(c) { savedClasses.delete(c); }, + contains(c) { return savedClasses.has(c); } + } +}; +globalThis.document = { + getElementById: (id) => (id === 'status-meta' ? fakeMeta : null) +}; + +// 桩 window.api.notifyDirtyChanged(渲染端 → 主进程的 IPC) +const dirtyNotifications = []; +installApiStub({ + notifyDirtyChanged: (isDirty) => { dirtyNotifications.push(isDirty); } +}); + +const { TaskStore } = await import('../src/task-store.js'); +const { CategoryKind } = await import('../src/markdown-parser.js'); + +// 把 app.js 里的 dirty/saved/load 处理器搬过来 —— 必须保持一致 +function attachStatusMetaHandlers(store) { + store.on('dirty', (isDirty) => { + if (isDirty) fakeMeta.textContent = '未保存'; + fakeMeta.classList.toggle('dirty', isDirty); + fakeMeta.classList.toggle('saved', !isDirty); + globalThis.window.api.notifyDirtyChanged(isDirty); + }); + store.on('saved', () => { + const t = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); + fakeMeta.textContent = `已同步 ${t}`; + fakeMeta.classList.remove('dirty'); + fakeMeta.classList.add('saved'); + }); + store.on('load', () => { + fakeMeta.textContent = ''; + fakeMeta.classList.remove('dirty'); + fakeMeta.classList.remove('saved'); + }); +} + +function resetMeta() { + savedText = ''; + savedClasses = new Set(); + dirtyNotifications.length = 0; +} + +// ============================================ +console.log('[1] 自动保存完成后状态栏必须保留「已同步 HH:MM」'); + +{ + resetMeta(); + const store = new TaskStore(); + attachStatusMetaHandlers(store); + store.filePath = '/tmp/test.md'; + store.categories = [ + { name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null } + ]; + + store.addTask('未分类', '买牛奶'); + ok('addTask 后 textContent = 「未保存」', fakeMeta.textContent === '未保存', + `实际 ${JSON.stringify(fakeMeta.textContent)}`); + ok('addTask 后含 dirty 类、不含 saved 类', + fakeMeta.classList.contains('dirty') && !fakeMeta.classList.contains('saved'), + `classes=${[...savedClasses].join(',')}`); + + // 触发自动保存(800ms debounce flush) + store._autoSave.flush(); + await new Promise(r => setTimeout(r, 30)); + + // 关键断言:saved 写入的时间戳必须留下来 + if (fakeMeta.textContent.startsWith('已同步 ')) { + ok('自动保存完成后 textContent 仍为「已同步 HH:MM」', + true); + } else { + bad('自动保存完成后 textContent 仍为「已同步 HH:MM」', + `实际 ${JSON.stringify(fakeMeta.textContent)}`); + } + ok('自动保存完成后 dirty 类已移除', !fakeMeta.classList.contains('dirty'), + `classes=${[...savedClasses].join(',')}`); + ok('自动保存完成后 saved 类已添加', fakeMeta.classList.contains('saved'), + `classes=${[...savedClasses].join(',')}`); + ok('notifyDirtyChanged(true → false) 被按序通知', + JSON.stringify(dirtyNotifications) === '[true,false]', + `实际 ${JSON.stringify(dirtyNotifications)}`); +} + +// ============================================ +console.log('\n[2] Ctrl+S (saveNow) 后状态栏同样保留时间戳'); + +{ + resetMeta(); + const store = new TaskStore(); + attachStatusMetaHandlers(store); + store.filePath = '/tmp/test.md'; + store.categories = [ + { name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null } + ]; + + store.addTask('未分类', '买牛奶'); + await store.saveNow(); + + if (fakeMeta.textContent.startsWith('已同步 ')) { + ok('saveNow 后 textContent 仍为「已同步 HH:MM」', true); + } else { + bad('saveNow 后 textContent 仍为「已同步 HH:MM」', + `实际 ${JSON.stringify(fakeMeta.textContent)}`); + } +} + +// ============================================ +console.log('\n[3] 编辑 → 保存 → 再编辑:状态栏正确切换「已同步 → 未保存」'); + +{ + resetMeta(); + const store = new TaskStore(); + attachStatusMetaHandlers(store); + store.filePath = '/tmp/test.md'; + store.categories = [ + { name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null } + ]; + + store.addTask('未分类', 'A'); + await store.saveNow(); + // 此时应为「已同步 HH:MM」 + const afterFirstSave = fakeMeta.textContent; + if (!afterFirstSave.startsWith('已同步 ')) { + bad('第一次 saveNow 后 textContent 应为「已同步 HH:MM」', + `实际 ${JSON.stringify(afterFirstSave)}`); + } else { + ok('第一次 saveNow 后 textContent 为「已同步 HH:MM」', true); + } + + store.addTask('未分类', 'B'); + // 现在应该是「未保存」 + if (fakeMeta.textContent === '未保存') { + ok('再次编辑后 textContent 切回「未保存」', true); + } else { + bad('再次编辑后 textContent 应切回「未保存」', + `实际 ${JSON.stringify(fakeMeta.textContent)}`); + } + ok('再次编辑后 dirty 类被添加', fakeMeta.classList.contains('dirty'), + `classes=${[...savedClasses].join(',')}`); +} + +// ============================================ +console.log('\n[4] 重新加载文件后清掉旧的保存状态'); + +{ + resetMeta(); + const store = new TaskStore(); + attachStatusMetaHandlers(store); + store.filePath = '/tmp/test.md'; + store.categories = [ + { name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null } + ]; + + // 先走一次「保存」留下时间戳 + store.addTask('未分类', 'A'); + await store.saveNow(); + if (!fakeMeta.textContent.startsWith('已同步 ')) { + bad('前序 saveNow 后 textContent 应为「已同步 HH:MM」', + `实际 ${JSON.stringify(fakeMeta.textContent)}`); + } else { + ok('前序 saveNow 后 textContent 为「已同步 HH:MM」', true); + } + + // 模拟「文件被外部修改 → 重新加载」 + store.loadFromContent( + `# 全部任务\n\n## 未分类\n\n- [ ] C\n`, + '/tmp/test.md' + ); + if (fakeMeta.textContent === '') { + ok('loadFromContent 后 textContent 清空', true); + } else { + bad('loadFromContent 后 textContent 应清空', + `实际 ${JSON.stringify(fakeMeta.textContent)}`); + } + ok('loadFromContent 后 dirty/saved 类都清掉', + !fakeMeta.classList.contains('dirty') && !fakeMeta.classList.contains('saved'), + `classes=${[...savedClasses].join(',')}`); +} + +// ============================================ +console.log('\n[5] loadFromContent 后 dirty=false 被显式通知给监听者'); + +{ + resetMeta(); + const store = new TaskStore(); + attachStatusMetaHandlers(store); + store.filePath = '/tmp/test.md'; + store.categories = [ + { name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null } + ]; + + store.addTask('未分类', 'A'); + // 此时 dirtyNotifications 应有 true + await store.saveNow(); + // 此时 dirtyNotifications 应有 true, false + + const before = [...dirtyNotifications]; + store.loadFromContent( + `# 全部任务\n\n## 未分类\n\n- [ ] C\n`, + '/tmp/test.md' + ); + // loadFromContent 内部 _suppressDirty=true 期间已设 dirty=false; + // finally 之后必须 emit('dirty', false),否则 notifyDirtyChanged + // 收不到这次复位,主进程在退出时仍可能误判状态 + if (dirtyNotifications.length > before.length && + dirtyNotifications[dirtyNotifications.length - 1] === false) { + ok('loadFromContent 后有 dirty(false) 通知', + true); + } else { + bad('loadFromContent 后应有 dirty(false) 通知', + `before=${JSON.stringify(before)} after=${JSON.stringify(dirtyNotifications)}`); + } +} + +// ============================================ +console.log(`\n${'─'.repeat(48)}`); +console.log(`通过 ${summary.pass} / 失败 ${summary.fail}`); +process.exit(summary.fail === 0 ? 0 : 1); \ No newline at end of file diff --git a/scripts/check-task-dirty-tracking.mjs b/scripts/check-task-dirty-tracking.mjs new file mode 100644 index 0000000..b0ffc50 --- /dev/null +++ b/scripts/check-task-dirty-tracking.mjs @@ -0,0 +1,163 @@ +// TaskStore 的 dirty task 追踪测试 +// +// 验证 _markDirty / _clearDirtyTracking / getDirtySnapshot 在各种 mutator 路径下 +// 正确累积 / 清理 dirty 标记。 +// +// 覆盖场景: +// 1) addTask → dirtyTasks 增加 1 +// 2) updateTaskText → 该 task.id 出现在 dirtyTasks +// 3) toggleTask → 该 task.id 出现在 dirtyTasks + 涉及的两 cat 都在 dirtyCategories +// 4) deleteTask → 该 task.id 出现在 dirtyTasks + 两 cat +// 5) renameCategory → 两 cat 都在 dirtyCategories +// 6) 多次 edit 同 task → 仍只算一个 dirtyId +// 7) saveNow 成功后 _clearDirtyTracking 清空所有 dirty 集 +// 8) loadFromContent 后 dirty 集被清空 +// +// 用法:node scripts/check-task-dirty-tracking.mjs + +import { TaskStore } from '../src/task-store.js'; +import { check, ok, bad, summary, printSummary } from './_lib/check.mjs'; +import { makeStore } from './_lib/store-fixture.mjs'; + +function eq(actual, expected, name) { + const a = JSON.stringify([...actual].sort()); + const e = JSON.stringify([...expected].sort()); + if (a === e) ok(name); + else bad(name, `expected ${e}, got ${a}`); +} + +function truthy(value, name) { + if (value) ok(name); + else bad(name, `expected truthy, got ${value}`); +} + +// 构造一个最小 store —— 不读文件,不写文件,只测 dirty tracking +let store = new TaskStore(); +store.loadDefault(); // 给出默认 categories(全部任务 / 工作 / 学习 / 生活 / 未分类) +// 简化:不挂任何事件监听器(避免 store.on 副作用);emit 走默认 EventEmitter + +function reset() { + // 完全重建 store,避免前一个测试残留的 categories 影响下一个 + store = new TaskStore(); + store.loadDefault(); +} + +// ── 1) addTask ── +console.log('\n[addTask]'); +{ + // 使用初始 store(已 loadDefault) + const t = store.addTask('工作', 'task1'); + truthy(t !== null, 'addTask 成功'); + const snap = store.getDirtySnapshot(); + truthy(snap.taskIds.has(t.id), 'dirtyTaskIds 含新增 task id'); + truthy(snap.categoryNames.has('工作'), 'dirtyCategories 含工作'); +} + +// ── 2) updateTaskText ── +console.log('\n[updateTaskText]'); +reset(); +{ + const t = store.addTask('工作', 'task1'); + store._clearDirtyTracking(); // 清掉 addTask 的副作用 + const ok2 = store.updateTaskText('工作', t.id, 'task1-updated'); + truthy(ok2 === true, 'updateTaskText 成功'); + const snap = store.getDirtySnapshot(); + truthy(snap.taskIds.has(t.id), 'dirtyTaskIds 含改过的 task id'); + truthy(snap.categoryNames.has('工作'), 'dirtyCategories 含工作'); +} + +// ── 3) toggleTask(跨分类)── +console.log('\n[toggleTask cross-category]'); +reset(); +{ + const t = store.addTask('工作', 'task1'); + store._clearDirtyTracking(); + const ok2 = store.toggleTask('工作', t.id); + // v3.5+:toggleTask 返回 { ok: true } 对象(含可选 restoreHint),不再是裸 true + truthy(ok2 && ok2.ok === true, 'toggleTask 成功'); + const snap = store.getDirtySnapshot(); + truthy(snap.taskIds.has(t.id), 'dirtyTaskIds 含被勾选 task'); + // 任务从「工作」搬到「已完成任务」—— 两分类都应被标 + truthy(snap.categoryNames.has('工作'), 'dirtyCategories 含源分类 工作'); + truthy(snap.categoryNames.has('已完成任务'), 'dirtyCategories 含目标分类 已完成任务'); +} + +// ── 4) deleteTask ── +console.log('\n[deleteTask]'); +reset(); +{ + const t = store.addTask('工作', 'task1'); + store._clearDirtyTracking(); + const ok2 = store.deleteTask('工作', t.id); + truthy(ok2 === true, 'deleteTask 成功'); + const snap = store.getDirtySnapshot(); + truthy(snap.taskIds.has(t.id), 'dirtyTaskIds 含被删 task'); + truthy(snap.categoryNames.has('工作'), 'dirtyCategories 含源分类'); + truthy(snap.categoryNames.has('回收站'), 'dirtyCategories 含回收站'); +} + +// ── 5) renameCategory ── +console.log('\n[renameCategory]'); +reset(); +{ + // loadDefault 已创建「工作」 —— 直接重命名 + const ok2 = store.renameCategory('工作', '工作 v2'); + truthy(ok2 === true, 'renameCategory 成功'); + const snap = store.getDirtySnapshot(); + truthy(snap.categoryNames.has('工作'), 'dirtyCategories 含旧名'); + truthy(snap.categoryNames.has('工作 v2'), 'dirtyCategories 含新名'); +} + +// ── 6) 多次 edit 同 task ── +console.log('\n[idempotent dirty tracking]'); +reset(); +{ + const t = store.addTask('工作', 'task1'); + store._clearDirtyTracking(); + store.updateTaskText('工作', t.id, 'edit1'); + store.updateTaskText('工作', t.id, 'edit2'); + store.updateTaskText('工作', t.id, 'edit3'); + const snap = store.getDirtySnapshot(); + eq(snap.taskIds, new Set([t.id]), '多次编辑同 task → dirtyTaskIds 仍是 1 项'); +} + +// ── 7) getDirtySnapshot 计数 ── +console.log('\n[changeCount]'); +reset(); +{ + const t1 = store.addTask('工作', 'task1'); + const t2 = store.addTask('工作', 'task2'); + store._clearDirtyTracking(); + store.updateTaskText('工作', t1.id, 'edit'); + store.updateTaskText('工作', t2.id, 'edit'); + const snap = store.getDirtySnapshot(); + eq(snap.taskIds, new Set([t1.id, t2.id]), '两个 task 都被追踪'); + // changeCount = taskIds 数 + categoryNames 数(去重);同 cat 多次编辑只算 1 + truthy(snap.changeCount === 3, + `changeCount = taskIds(2) + categoryNames(1) = 3(实际 ${snap.changeCount})`); +} + +// ── 8) mergeFromDisk 清空 dirty ── +console.log('\n[mergeFromDisk clears dirty]'); +reset(); +{ + store.addTask('工作', 'task1'); + store.addTask('工作', 'task2'); + // 模拟磁盘版本(空) + const diskCats = []; + store.mergeFromDisk(diskCats, {}); + const snap = store.getDirtySnapshot(); + truthy(snap.taskIds.size === 0, 'mergeFromDisk 后 dirtyTaskIds 空'); + truthy(snap.categoryNames.size === 0, 'mergeFromDisk 后 dirtyCategoryNames 空'); + truthy(store.dirty === false, 'dirty=false'); +} + +// ── 总结 ── +console.log(''); +if (summary.fail === 0) { + console.log(`✓ 全部通过`); + process.exit(0); +} else { + console.log(`✗ ${summary.fail} 处失败`); + process.exit(1); +} \ No newline at end of file diff --git a/scripts/check-third-pass-audit.mjs b/scripts/check-third-pass-audit.mjs new file mode 100644 index 0000000..486a771 --- /dev/null +++ b/scripts/check-third-pass-audit.mjs @@ -0,0 +1,320 @@ +// 第三轮全面审计回归:覆盖第二、三轮的所有修复点。 +// +// 涵盖: +// 安全 S1 - dataDir 允许根目录白名单(home / documents / downloads / desktop / userData / appData) +// 安全 S2 - dataDir realpath 解析后再次校验(防止 symlink 绕过白名单) +// 安全 S3 - loadConfig 过滤 __proto__/constructor/prototype 防止原型污染 +// 安全 S4 - 路径不合法 / 不存在时抛错而非 console.warn 静默吞掉 +// 错误处理 E1 - app.js bootstrap 有 .bak 自动回退 +// 错误处理 E2 - task-list addTask 返回 null 时弹 toast +// 错误处理 E3 - task-store loadFromContent parseMarkdown 抛错时 emit load-failed +// 数据完整性 D1 - markdown-writer 写入 originalCategory 转义 ] +// 数据完整性 D2 - markdown-parser 读回 originalCategory 反转义 +// 数据完整性 D3 - 多行任务文本 \\n 保留 +// 性能 P1 - task-list SVG 字面量提升(STAR_SVG_FILLED 等) +// 性能 P2 - task-list 拖拽 rAF 节流(pendingMove + rafScheduled) +// 性能 P3 - task-list _flushPendingDrag 在 pointerup 同步落点 +// 性能 P4 - app.js setupStatusBar 文本相同则跳过 DOM 写入 +// 并发 C1 - feedback.js 模块级监听器 AbortController + disposeFeedbackGlobals +// 并发 C2 - app.js export disposeBootstrap() 可取消所有 bootstrap 监听器 +// 卫生 H1 - app.js 没有未限定的 console.log(保留 console.error / console.warn) + +import { readFileSync } from 'node:fs'; +import { check, ok, bad, printSummary } from './_lib/check.mjs'; + +const MAIN = readFileSync('main.js', 'utf8'); +const APP = readFileSync('src/app.js', 'utf8'); +const TASK_LIST = readFileSync('src/ui/task-list.js', 'utf8'); +const TASK_STORE = readFileSync('src/task-store.js', 'utf8'); +const FEEDBACK = readFileSync('src/ui/feedback.js', 'utf8'); +const MARKDOWN_WRITER = readFileSync('src/markdown-writer.js', 'utf8'); +const MARKDOWN_PARSER = readFileSync('src/markdown-parser.js', 'utf8'); + +function section(name) { console.log(`\n[${name}]`); } + +// ───────────────────────────────────────────────────────────────────────────── +// 安全 S1+S2: dataDir 允许根目录白名单 + realpath 防 symlink 绕过 +// ───────────────────────────────────────────────────────────────────────────── +section('安全: dataDir 白名单 + symlink 防御'); + +check( + 'main.js dataDir 校验使用 app.getPath("home") 等白名单', + /app\.getPath\(['"]home['"]\)/.test(MAIN), + '白名单是 home / documents / downloads / desktop / userData / appData' +); +check( + 'main.js dataDir 校验使用 app.getPath("documents")', + /app\.getPath\(['"]documents['"]\)/.test(MAIN) +); +check( + 'main.js dataDir 校验使用 app.getPath("downloads")', + /app\.getPath\(['"]downloads['"]\)/.test(MAIN) +); +check( + 'main.js dataDir 校验使用 app.getPath("desktop")', + /app\.getPath\(['"]desktop['"]\)/.test(MAIN) +); +check( + 'main.js dataDir 校验使用 app.getPath("userData")', + /app\.getPath\(['"]userData['"]\)/.test(MAIN) +); +check( + 'main.js dataDir 校验使用 app.getPath("appData")', + /app\.getPath\(['"]appData['"]\)/.test(MAIN) +); +check( + 'main.js 用 realpathSync 解析后再比对白名单', + /realpathSync/.test(MAIN) && /\.toLowerCase\(\)/.test(MAIN), + 'symlink 解析后再做白名单比对,防止间接绕过' +); +check( + 'main.js dataDir 校验失败时抛 Error 而非 console.warn', + /throw new Error\([`"]数据目录必须在用户已知目录内/.test(MAIN) || + /throw new Error\([`"]数据目录/.test(MAIN), + 'v4+ 修复:抛错让 IPC reject,UI 走 save-error toast' +); + +// ───────────────────────────────────────────────────────────────────────────── +// 安全 S3: loadConfig 过滤 __proto__ 等危险 key +// ───────────────────────────────────────────────────────────────────────────── +section('安全: 原型污染过滤'); + +check( + 'main.js 有 _stripProtoKeys helper', + /_stripProtoKeys/.test(MAIN), + '递归过滤 __proto__ / constructor / prototype 三个 key' +); +check( + '_stripProtoKeys 拒绝 __proto__', + /['"]__proto__['"]/.test(MAIN) || /__proto__/.test(MAIN) +); +check( + '_stripProtoKeys 拒绝 constructor / prototype', + /constructor/.test(MAIN) && /prototype/.test(MAIN) +); +check( + 'loadConfig 走 _stripProtoKeys 后再 spread', + /loadConfig[\s\S]{0,300}_stripProtoKeys/.test(MAIN) +); + +// ───────────────────────────────────────────────────────────────────────────── +// 错误处理 E1: .bak 自动回退 +// ───────────────────────────────────────────────────────────────────────────── +section('错误处理: .bak 自动回退'); + +check( + 'app.js 有 tryReadBak helper', + /tryReadBak/.test(APP), + 'loadFromContent 解析失败 / 空内容时尝试 .bak' +); +check( + 'app.js bootstrap 在 store.loadFromContent 失败时尝试 .bak', + /tryReadBak[\s\S]{0,500}store\.loadFromContent|store\.loadFromContent[\s\S]{0,200}tryReadBak/.test(APP) || + /\.bak/.test(APP), + '解析失败 → 读 .bak → 再 loadFromContent' +); + +// ───────────────────────────────────────────────────────────────────────────── +// 错误处理 E2: addTask 返回 null 时弹 toast +// ───────────────────────────────────────────────────────────────────────────── +section('错误处理: addTask null 反馈'); + +check( + 'task-list.js 在 store.addTask 返回 null 时弹 toast', + /store\.addTask\([\s\S]{0,200}\bif\s*\(\s*task\s*\)[\s\S]{0,400}\belse\s*\{[\s\S]{0,200}toast/.test(TASK_LIST) || + /store\.addTask[\s\S]{0,300}\bif\s*\(\s*!\s*task/.test(TASK_LIST), + '添加失败要给用户反馈,不能静默吞掉' +); + +// ───────────────────────────────────────────────────────────────────────────── +// 错误处理 E3: parseMarkdown 抛错时 emit load-failed +// ───────────────────────────────────────────────────────────────────────────── +section('错误处理: parseMarkdown 异常捕获'); + +check( + 'task-store.js loadFromContent 对 parseMarkdown 加 try/catch', + /parseMarkdown\(content\)[\s\S]{0,300}catch\s*\(\s*parseErr\s*\)/.test(TASK_STORE) || + /try\s*\{[\s\S]{0,50}incomingCategories\s*=\s*parseMarkdown/.test(TASK_STORE), + '解析失败要 emit load-failed 让 UI 走 .bak / 冲突解决路径' +); +check( + 'task-store.js parseMarkdown 抛错时 emit("load-failed", ...)', + /emit\(['"]load-failed['"]/.test(TASK_STORE) +); +check( + 'parseMarkdown 抛错时 Error.code === "PARSE_FAILED"', + /code\s*=\s*['"]PARSE_FAILED['"]/.test(TASK_STORE) +); + +// ───────────────────────────────────────────────────────────────────────────── +// 数据完整性 D1+D2: originalCategory 转义 / 反转义 +// ───────────────────────────────────────────────────────────────────────────── +section('数据完整性: originalCategory 转义'); + +check( + 'markdown-writer 对 originalCategory 转义 ](避开解析字段截断)', + MARKDOWN_WRITER.includes('/\\]/g'), + '写盘时 ] 必须转义为 \\],否则 parser 在 [原分类:工作] 这类含 ] 名字里截断' +); +check( + 'markdown-parser 解析 originalCategory 的正则允许 \\.', + /\\\\\.|\\\\\\./.test(MARKDOWN_PARSER) || + /\\\\\\./.test(MARKDOWN_PARSER), + 'parser 必须接受 \\. 让转义能往返' +); +check( + 'markdown-parser 解析后 unescapeOriginalCategory 把 \\] / \\\\ 还原', + /unescapeOriginalCategory/.test(MARKDOWN_PARSER) || + MARKDOWN_PARSER.includes("replace(/\\\\\\\\\\\\/g"), + '读回时反转义:\\] → ],\\\\ → \\' +); + +// ───────────────────────────────────────────────────────────────────────────── +// 数据完整性 D3: 多行任务文本 \\n 保留 +// ───────────────────────────────────────────────────────────────────────────── +section('数据完整性: 多行任务文本'); + +check( + 'markdown-writer 写盘时换行转义为 \\n', + /escapeTaskTextSegment/.test(MARKDOWN_WRITER) || + MARKDOWN_WRITER.includes("split('\\\\n')") || + MARKDOWN_WRITER.includes('\\\\r\\\\n'), + '内存里 \\n 实际是换行符,写盘要写成 \\n 否则 parser 当成多任务处理' +); +check( + 'markdown-parser 读回时把 \\n 还原为换行', + MARKDOWN_PARSER.includes('/\\\\n/g') || + MARKDOWN_PARSER.includes('replace(/\\\\n/'), + 'parser 把 \\n 序列还原成换行符,保证文本往返一致' +); + +// ───────────────────────────────────────────────────────────────────────────── +// 性能 P1: SVG 字面量提升 +// ───────────────────────────────────────────────────────────────────────────── +section('性能: SVG 字面量提升'); + +check( + 'task-list.js 有 STAR_SVG_FILLED 等模块级 SVG 常量', + /STAR_SVG_FILLED/.test(TASK_LIST) && /STAR_SVG_OUTLINE/.test(TASK_LIST), + '原本是每次 _buildItem 重新构造,提到模块层只生成一次' +); +check( + 'task-list.js 有 starSvgFor(active) helper', + /starSvgFor/.test(TASK_LIST) +); +check( + 'task-list.js 有 currentSvgFor(active) helper', + /currentSvgFor/.test(TASK_LIST) +); +check( + 'task-list.js 没有重复的 inline STAR_SVG 模板字面量', + (TASK_LIST.match(/]*xmlns="http:\/\/www\.w3\.org\/2000\/svg"/g) || []).length <= 8, + '多个 svg 都从常量引用,不应该重复模板字面量' +); + +// ───────────────────────────────────────────────────────────────────────────── +// 性能 P2+P3: 拖拽 rAF 节流 +// ───────────────────────────────────────────────────────────────────────────── +section('性能: 拖拽 rAF 节流'); + +check( + 'task-list.js pointermove 缓存 pendingMove', + /d\.pendingMove\s*=\s*e/.test(TASK_LIST), + '把最近一次 move 存到 d.pendingMove,下一帧才真正 _updateDrag' +); +check( + 'task-list.js pointermove 用 rafScheduled 标志', + /d\.rafScheduled\s*=\s*true/.test(TASK_LIST) && + /requestAnimationFrame/.test(TASK_LIST) +); +check( + 'task-list.js 有 _flushPendingDrag 在 pointerup 同步落点', + /_flushPendingDrag/.test(TASK_LIST), + '避免松手瞬间落点还停留在上一帧动画结束位置(≤8ms 偏差)' +); +check( + '_finishDrag 在移动前先 _flushPendingDrag', + /_finishDrag\([\s\S]{0,200}_flushPendingDrag/.test(TASK_LIST) || + /_flushPendingDrag\(d\)/.test(TASK_LIST) +); + +// ───────────────────────────────────────────────────────────────────────────── +// 性能 P4: 状态栏缓存 +// ───────────────────────────────────────────────────────────────────────────── +section('性能: 状态栏文本相同跳过 DOM 写入'); + +check( + 'app.js setupStatusBar 有 oldText 缓存', + /oldText/.test(APP) || /nextText\s*===\s*oldText/.test(APP) +); +check( + 'app.js setupStatusBar 比较 nextText 与 oldText 后才写入', + /nextText\s*===\s*oldText/.test(APP) || /text\s*===\s*oldText/.test(APP) +); + +// ───────────────────────────────────────────────────────────────────────────── +// 并发 C1: feedback.js 模块级监听器 AbortController +// ───────────────────────────────────────────────────────────────────────────── +section('并发: feedback.js 模块级监听器 AbortController'); + +check( + 'feedback.js 有 globalListenerAC', + /globalListenerAC/.test(FEEDBACK) +); +check( + 'feedback.js 有 globalListenersInstalled 标志', + /globalListenersInstalled/.test(FEEDBACK) +); +check( + 'feedback.js 有 installGlobalListeners() 一次性安装', + /installGlobalListeners/.test(FEEDBACK) +); +check( + 'feedback.js export disposeFeedbackGlobals', + /export\s+function\s+disposeFeedbackGlobals/.test(FEEDBACK), + '提供显式 dispose 入口给 hot-reload / 测试用' +); +check( + 'feedback.js 模块级监听器走 signal 而非裸 addEventListener', + /\{ signal:\s*globalListenerAC\.signal\s*\}/.test(FEEDBACK), + 'document.addEventListener(..., {signal}) 让 abort 时一键清理' +); + +// ───────────────────────────────────────────────────────────────────────────── +// 并发 C2: bootstrapUnsubs disposeBootstrap +// ───────────────────────────────────────────────────────────────────────────── +section('并发: disposeBootstrap 清理函数'); + +check( + 'app.js export disposeBootstrap', + /export\s+function\s+disposeBootstrap/.test(APP) +); +check( + 'disposeBootstrap 取消 bootstrapUnsubs 池中的所有函数', + /disposeBootstrap[\s\S]{0,400}bootstrapUnsubs\.pop/.test(APP) || + /bootstrapUnsubs[\s\S]{0,400}disposeBootstrap/.test(APP) +); + +// ───────────────────────────────────────────────────────────────────────────── +// 卫生 H1: app.js 没有未限定的 console.log(保留 console.error/warn) +// ───────────────────────────────────────────────────────────────────────────── +section('卫生: 剥离调试日志'); + +// 检查 app.js 顶层 / bootstrap() 里没有 console.log 残留 +// 允许的例外:rendererLog(...) 这种被 isDev / syncLog 守卫的 helper 内部。 +// 把所有 .test(/console\.log/) 之外、非 helper 函数体内部的 console.log 视为违规。 +const helperBodies = APP.match(/function\s+\w+\s*\([^)]*\)\s*\{[\s\S]*?console\.log[\s\S]*?\n\}/g) || []; +const helperLines = new Set(); +for (const body of helperBodies) { + for (const line of body.split('\n')) helperLines.add(line.trim()); +} +const allLogs = APP.split('\n').filter(l => /console\.log/.test(l)); +const strayLogs = allLogs.filter(l => !helperLines.has(l.trim())); +check( + 'app.js 顶层 / bootstrap 里没有 console.log 残留(仅允许 dev-守卫的 helper 内)', + strayLogs.length === 0, + `残留位置:${strayLogs.map(l => l.trim()).slice(0, 3).join(' | ')}` +); + +// ───────────────────────────────────────────────────────────────────────────── +printSummary('check-third-pass-audit'); \ No newline at end of file diff --git a/scripts/check-trash-context-menu.mjs b/scripts/check-trash-context-menu.mjs new file mode 100644 index 0000000..445dd82 --- /dev/null +++ b/scripts/check-trash-context-menu.mjs @@ -0,0 +1,170 @@ +// 回收站右键菜单判定逻辑回归 —— 验证: +// [1] 已完成任务在回收站 → 默认项 = 「恢复到「已完成任务」」(行为对齐) +// [2] 未完成任务在回收站 + originalCategory='X' → 默认项 = 「恢复到「X」」 +// [3] 未完成任务在回收站 + 无 originalCategory → 默认项 = 「恢复到「未分类」」 +// [4] 已完成任务的「恢复到分类…」子菜单被隐藏(死按钮防御) +// [5] 未完成任务的「恢复到分类…」子菜单按候选数 > 1 才出现 +// [6] important=true 时 label 是「取消星标」(反向) +// [7] current=true 时 label 是「取消当前」(反向) +// [8] restore 目标里不含保留名分类(回收站/已完成任务/全部任务) +// [边界] 单候选 / 零候选时不显示「恢复到分类…」子菜单 +// +// 测试路径:通过 src/ui/task-context-menu.js 的 buildTrashContextMenuItems +// 直接构造 items 数组(helper 是 pure function,不碰 DOM)。helper 同时被 +// task-list.js 调,与 UI 共用一份判定逻辑——产品与测试不会发散。 +// +// 用法:node scripts/check-trash-context-menu.mjs + +import { installApiStub } from './_lib/api-stub.mjs'; +import { check, summary, printSummary } from './_lib/check.mjs'; +import { CategoryKind } from '../src/markdown-parser.js'; + +// 让 TaskStore 不真去写文件(构造 store 时不会触发,但 toggleTask 等会调 window.api) +installApiStub(); + +const { TaskStore } = await import('../src/task-store.js'); +const { buildTrashContextMenuItems } = await import('../src/ui/task-context-menu.js'); + +// ============================================================ +// 构造一个最小文档:工作分类里两条任务(其中一条 ⭐▶),未分类占位 +// ============================================================ +const seedDoc = [ + '# 全部任务', '', + '## 工作', '', + '- [ ] 写周报', '', + '- [ ] 学习项目[⭐][▶]', '', + '## 未分类', '' +].join('\n'); + +const store = new TaskStore(); +store.loadFromContent(seedDoc, null); +store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); + +const workCat = store.getCategory('工作'); +const workActive = workCat.tasks.find(t => t.text === '写周报'); +const workImp = workCat.tasks.find(t => t.text === '学习项目'); + +// 1) 把 workActive 直接删到回收站 —— 未完成 + originalCategory='工作' +store.deleteTask('工作', workActive.id); +const trash = store.getTrashCategory(); +const trashActive = trash.tasks.find(t => t.text === '写周报'); + +// 2) 把 workImp 勾选完成(completed=true,originalCategory='工作' 保留)→ +// 任务自动搬到「已完成任务」分类,再删到回收站 +store.toggleTask('工作', workImp.id); +const completedCat = store.categories.find(c => c.kind === 'completed'); +const completedTask = completedCat.tasks.find(t => t.text === '学习项目'); +store.deleteTask('已完成任务', completedTask.id); +const trashCompleted = trash.tasks.find(t => t.text === '学习项目'); + +// 候选目标过滤(与 task-list._restoreTargetCandidates(true) 同款语义) +const realTargets = store.categories.filter(c => + c.kind !== CategoryKind.OTHER_TASKS && + c.kind !== CategoryKind.TRASH && + c.kind !== CategoryKind.COMPLETED); + +// ============================================================ +// [1] 已完成任务在回收站 → 默认项 = 「恢复到「已完成任务」」 +// ============================================================ +console.log('\n[1] 已完成任务的默认恢复项 label 是「已完成任务」'); + +const itemsB = buildTrashContextMenuItems(trashCompleted, []); +const firstItem = itemsB[0]; +check('已完成任务默认项 label 是「恢复到「已完成任务」」', + firstItem.label === '恢复到「已完成任务」', + `actual: ${JSON.stringify(firstItem)}`); +check('已完成任务默认项 action 仍是 restore-default', + firstItem.action === 'restore-default'); +check('已完成任务的 toggle 项 label 是「标记为未完成」', + itemsB.find(i => i.action === 'toggle').label === '标记为未完成'); + +// ============================================================ +// [2] 未完成任务 + originalCategory='工作' → 默认项 = 「恢复到「工作」」 +// ============================================================ +console.log('\n[2] 未完成任务的默认恢复项跟 originalCategory 走'); + +const itemsA = buildTrashContextMenuItems(trashActive, []); +check('未完成任务默认项 label 是「恢复到「工作」」', + itemsA[0].label === '恢复到「工作」', + `actual: ${JSON.stringify(itemsA[0])}`); + +// ============================================================ +// [3] 未完成任务 + 无 originalCategory → 默认项 = 「恢复到「未分类」」 +// ============================================================ +console.log('\n[3] 无 originalCategory 退到「未分类」'); + +// 模拟"老格式 / 未登过的任务"场景:构造一个孤儿任务对象,绕过 store 直接喂给 helper +const orphan = { id: 'orphan', text: '老格式任务', completed: false, originalCategory: null }; +const itemsC = buildTrashContextMenuItems(orphan, []); +check('无 originalCategory 默认项 label 是「恢复到「未分类」」', + itemsC[0].label === '恢复到「未分类」', + `actual: ${JSON.stringify(itemsC[0])}`); + +// ============================================================ +// [4] 已完成任务 → 「恢复到分类…」子菜单被隐藏(死按钮防御) +// ============================================================ +console.log('\n[4] 已完成任务隐藏「恢复到分类…」子菜单'); + +const itemsD = buildTrashContextMenuItems(trashCompleted, realTargets); +check('已完成任务菜单不含 restore-header', + !itemsD.some(i => i.action === 'restore-header')); +check('已完成任务菜单不含任何 restore:* 项', + !itemsD.some(i => typeof i.action === 'string' && i.action.startsWith('restore:'))); + +// ============================================================ +// [5] 未完成任务 + 多候选 → 「恢复到分类…」子菜单出现 +// ============================================================ +console.log('\n[5] 未完成任务多候选时显示「恢复到分类…」'); + +const itemsE = buildTrashContextMenuItems(trashActive, realTargets); +check('未完成 + 多候选时显示 restore-header', + itemsE.some(i => i.action === 'restore-header')); +const restoreActions = itemsE + .filter(i => typeof i.action === 'string' && i.action.startsWith('restore:')) + .map(i => i.action); +check('未完成 + 多候选时显示 restore:* 目标项', + restoreActions.length > 0, + `actions: ${restoreActions.join(',') || '(无)'}`); +check('restore 目标里不含保留名(回收站/已完成任务/全部任务)', + itemsE.filter(i => i.action?.startsWith('restore:')) + .every(i => i.label !== '回收站' && i.label !== '已完成任务' && i.label !== '全部任务')); + +// ============================================================ +// [6/7] important=true / current=true 时 label 是「取消星标/取消当前」 +// ============================================================ +console.log('\n[6/7] important/current 反向 label'); + +check('completed + important=true 时 important label 是「取消星标」', + itemsD.find(i => i.action === 'important').label === '取消星标'); + +// v4.1 起 store.toggleTask 勾选时**保留** current 标记([▶] 与 completed 正交), +// 所以 current=true && completed=true 是正常数据形态。这里仍直接构造任务对象 +// 验证 helper 的 label 判定(与 [3] 同款,不依赖 store 路径)。 +const completedAndCurrent = { + id: 'completed-current', + text: '已勾选且仍标当前', + completed: true, + current: true, + important: false, + originalCategory: '工作' +}; +const itemsCurrent = buildTrashContextMenuItems(completedAndCurrent, []); +check('completed + current=true 时 current label 是「取消当前」', + itemsCurrent.find(i => i.action === 'current').label === '取消当前', + `actual: ${JSON.stringify(itemsCurrent.find(i => i.action === 'current'))}`); + +// ============================================================ +// [边界] 单候选 / 零候选时不显示「恢复到分类…」子菜单 +// ============================================================ +console.log('\n[边界] 候选数 ≤ 1 不显示子菜单'); + +const singleTarget = realTargets.slice(0, 1); +const itemsF = buildTrashContextMenuItems(trashActive, singleTarget); +check('未完成 + 单候选时菜单不含 restore-header', + !itemsF.some(i => i.action === 'restore-header')); + +const itemsG = buildTrashContextMenuItems(trashActive, []); +check('未完成 + 零候选时菜单不含 restore-header', + !itemsG.some(i => i.action === 'restore-header')); + +printSummary('check-trash-context-menu'); \ No newline at end of file diff --git a/scripts/check-trash-fuzz.mjs b/scripts/check-trash-fuzz.mjs new file mode 100644 index 0000000..5af4f2c --- /dev/null +++ b/scripts/check-trash-fuzz.mjs @@ -0,0 +1,285 @@ +// 属性测试(随机操作压测):验证「任何操作序列都不会让任务凭空消失」。 +// +// 静态审查能发现"这行代码看起来不对",但发现不了"这三个操作按这个顺序做就丢数据"。 +// 这个脚本随机组合 增/删/恢复/移动/重排/改名/删分类/清除已完成/往返 等操作, +// 每一步都断言两条不变量: +// +// 不变量 A(不丢任务):任务文本的多重集只能因「新增」而变大, +// 其余任何操作都必须保持总量不变 —— 删除只是搬到回收站,不是丢弃。 +// 不变量 B(结构安全):serialize() → parseMarkdown() 往返后任务多重集不变。 +// 这是真正的验收标准,因为 Markdown 才是真相之源。往返会抓到 +// 「回收站 h1 排到子分类前面 → 后面的 ## 被写到容器外 → 下次加载静默丢任务」这类结构损坏。 +// +// 用法:node scripts/check-trash-fuzz.mjs [轮数] + +import { TaskStore, TRASH_NAME, UNCATEGORIZED_NAME, OTHER_TASKS_NAME } from '../src/task-store.js'; +import { parseMarkdown, CategoryKind } from '../src/markdown-parser.js'; + +// 确定性伪随机(可复现:失败时能贴出种子重跑) +let seed = Number(process.argv[3]) || 12345; +function rnd() { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + return seed / 0x7fffffff; +} +const pick = arr => arr[Math.floor(rnd() * arr.length)]; +const chance = p => rnd() < p; + +function makeStore() { + const store = new TaskStore(); + store.loadDefault(null); + store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); + return store; +} + +function multiset(categories) { + const m = new Map(); + for (const cat of categories) { + for (const t of cat.tasks || []) { + m.set(t.text, (m.get(t.text) || 0) + 1); + } + } + return m; +} +function msEqual(a, b) { + if (a.size !== b.size) return false; + for (const [k, v] of a) if (b.get(k) !== v) return false; + return true; +} +function msDiff(a, b) { + const keys = new Set([...a.keys(), ...b.keys()]); + const out = []; + for (const k of keys) { + const x = a.get(k) || 0, y = b.get(k) || 0; + if (x !== y) out.push(`"${k}": ${x} → ${y}`); + } + return out.join('; '); +} + +// 结构不变式:回收站必须是最后一个;所有子分类必须排在容器之后 +function structureProblems(store) { + const probs = []; + const cats = store.categories; + const trashIdx = cats.findIndex(c => c.kind === CategoryKind.TRASH); + if (trashIdx >= 0 && trashIdx !== cats.length - 1) { + probs.push(`回收站不在末位(index ${trashIdx} / 共 ${cats.length})`); + } + if (cats.filter(c => c.kind === CategoryKind.TRASH).length > 1) { + probs.push('存在多个回收站分类'); + } + const containerIdx = cats.findIndex(c => c.kind === CategoryKind.OTHER_TASKS); + if (containerIdx >= 0) { + cats.forEach((c, i) => { + if (c.parentOtherTasks && i < containerIdx) { + probs.push(`子分类「${c.name}」(${i}) 排在容器 (${containerIdx}) 之前`); + } + }); + } + return probs; +} + +let counter = 0; +const ops = [ + // 新增任务(唯一允许让总量变大的操作) + function addTask(store) { + const targets = store.categories.filter( + c => c.kind !== CategoryKind.OTHER_TASKS && c.kind !== CategoryKind.TRASH + ); + if (!targets.length) return 0; + const cat = pick(targets); + const text = `任务${++counter}`; + return store.addTask(cat.name, text) ? 1 : 0; + }, + // 删除任务(应该搬进回收站,总量不变) + function deleteTask(store) { + const targets = store.categories.filter( + c => c.kind !== CategoryKind.OTHER_TASKS && c.kind !== CategoryKind.TRASH && c.tasks.length + ); + if (!targets.length) return 0; + const cat = pick(targets); + store.deleteTask(cat.name, pick(cat.tasks).id); + return 0; + }, + // 试图删除回收站里的任务(必须无效果) + function deleteFromTrash(store) { + const trash = store.getTrashCategory(); + if (!trash || !trash.tasks.length) return 0; + store.deleteTask(TRASH_NAME, pick(trash.tasks).id); + return 0; + }, + // 恢复 + function restore(store) { + const trash = store.getTrashCategory(); + if (!trash || !trash.tasks.length) return 0; + const id = pick(trash.tasks).id; + if (chance(0.5)) store.restoreTask(id); + else { + const targets = store.categories.filter( + c => c.kind !== CategoryKind.OTHER_TASKS && c.kind !== CategoryKind.TRASH + ); + if (targets.length) store.restoreTask(id, pick(targets).name); + } + return 0; + }, + // 移动任务(含移进/移出回收站) + function moveTask(store) { + const from = store.categories.filter(c => c.tasks.length); + if (!from.length) return 0; + const src = pick(from); + const dsts = store.categories.filter(c => c.name !== src.name); + if (!dsts.length) return 0; + store.moveTask(src.name, pick(src.tasks).id, pick(dsts).name); + return 0; + }, + // 勾选 + function toggle(store) { + const cats = store.categories.filter(c => c.tasks.length); + if (!cats.length) return 0; + const cat = pick(cats); + store.toggleTask(cat.name, pick(cat.tasks).id); + return 0; + }, + // 清除已完成(应搬进回收站) + function clearCompleted(store) { + if (chance(0.5)) store.clearCompleted(); + else { + const cats = store.categories; + store.clearCompleted(pick(cats).name); + } + return 0; + }, + // 新建子分类 + function addSub(store) { + store.addSubCategory(`分类${++counter}`); + return 0; + }, + // 删除子分类(任务应迁到「未分类」) + function delSub(store) { + const subs = store.getSubCategories().filter(c => c.name !== UNCATEGORIZED_NAME); + if (!subs.length) return 0; + store.deleteCategory(pick(subs).name); + return 0; + }, + // 试图删除/改名回收站(必须无效果) + // + // 关键:要把「被拒绝」这个语义在测试里也表达出来。如果某天有人不小心把这些 + // 操作改成「返回一个非 falsy 值」,但 fuzz 测试只默默调用、不 assert, + // 回归就会被静悄悄放过。所以这里显式断言每个调用都返回 falsy —— 失败时 + // 还能指出是哪条规则被突破。 + function attackTrash(store) { + const attempts = [ + ['deleteCategory', store.deleteCategory(TRASH_NAME)], + ['renameCategory', store.renameCategory(TRASH_NAME, `伪装${++counter}`)], + ['addSubCategory', store.addSubCategory(pick(['回收站', 'trash', '垃圾桶', 'bin', 'Recycle Bin']))], + ['addTask', store.addTask(TRASH_NAME, `偷渡${++counter}`)] + ]; + for (const [name, ret] of attempts) { + if (ret) { + rejectedCalls.push({ step: currentStep, name, ret }); + } + } + return 0; + }, + // 改名子分类 + function renameSub(store) { + const subs = store.getSubCategories(); + if (!subs.length) return 0; + store.renameCategory(pick(subs).name, `改名${++counter}`); + return 0; + }, + // 子分类重排(最容易破坏数组顺序的操作) + function reorderSub(store) { + const n = store.getSubCategories().length; + if (n < 2) return 0; + store.reorderSubCategory( + Math.floor(rnd() * n), + Math.floor(rnd() * n), + chance(0.5) + ); + return 0; + }, + // 任务重排 + function reorderTask(store) { + const cats = store.categories.filter(c => c.tasks.length > 1); + if (!cats.length) return 0; + const cat = pick(cats); + const n = cat.tasks.length; + store.reorderTask(cat.name, Math.floor(rnd() * n), Math.floor(rnd() * n)); + return 0; + }, + // 走一次完整的 Markdown 往返(模拟保存后重新加载) + function reload(store) { + const md = store.serialize(); + store.loadFromContent(md, null); + store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); + return 0; + } +]; + +const ROUNDS = Number(process.argv[2]) || 4000; +const store = makeStore(); +let expected = multiset(store.categories); +let fails = 0; + +// 不变量 D:回收站保护 —— 任何「试图修改回收站」的入口都必须返回 falsy。 +// 收集所有「成功穿透」的尝试,最后统一报告。 +let rejectedCalls = []; +let currentStep = 0; + +for (let i = 0; i < ROUNDS; i++) { + currentStep = i; + const op = pick(ops); + const before = multiset(store.categories); + const added = op(store); + + // ---- 不变量 A:除「新增」外总量不变 ---- + const after = multiset(store.categories); + const beforeTotal = [...before.values()].reduce((a, b) => a + b, 0); + const afterTotal = [...after.values()].reduce((a, b) => a + b, 0); + if (afterTotal !== beforeTotal + added) { + console.log(`\n✗ [第 ${i} 步 / ${op.name}] 任务总量异常:${beforeTotal} → ${afterTotal}(预期 +${added})`); + console.log(` 差异:${msDiff(before, after)}`); + fails++; + if (fails > 3) break; + } + + // ---- 不变量 B:结构安全 ---- + const probs = structureProblems(store); + if (probs.length) { + console.log(`\n✗ [第 ${i} 步 / ${op.name}] 结构损坏:${probs.join(' | ')}`); + console.log(` 顺序:${store.categories.map(c => `${c.name}(${c.kind})`).join(' → ')}`); + fails++; + if (fails > 3) break; + } + + // ---- 不变量 C:Markdown 往返不丢任务 ---- + const rt = multiset(parseMarkdown(store.serialize())); + if (!msEqual(after, rt)) { + console.log(`\n✗ [第 ${i} 步 / ${op.name}] Markdown 往返后任务变化:`); + console.log(` 差异:${msDiff(after, rt)}`); + console.log(` 顺序:${store.categories.map(c => `${c.name}(${c.kind})`).join(' → ')}`); + console.log(`--- 序列化结果 ---\n${store.serialize()}`); + fails++; + if (fails > 3) break; + } +} + +const final = multiset(store.categories); +const total = [...final.values()].reduce((a, b) => a + b, 0); +const trash = store.getTrashCategory(); + +console.log(`\n${'─'.repeat(52)}`); +console.log(`随机操作 ${ROUNDS} 步(种子 ${process.argv[3] || 12345})`); +console.log(`最终:${store.categories.length} 个分类 / ${total} 个任务 / 回收站 ${trash ? trash.tasks.length : 0} 项`); + +if (rejectedCalls.length) { + console.log(`\n✗ 回收站保护被绕过 ${rejectedCalls.length} 次:`); + // 最多展示前 5 条 —— 再多就只是噪声,第一条才是真正的回归信号 + for (const r of rejectedCalls.slice(0, 5)) { + console.log(` - 第 ${r.step} 步:${r.name} 返回了 ${JSON.stringify(r.ret)}`); + } + if (rejectedCalls.length > 5) console.log(` ... 以及另外 ${rejectedCalls.length - 5} 条`); + fails += rejectedCalls.length; +} + +console.log(fails === 0 ? '✓ 全程无任务丢失、无结构损坏、往返一致、回收站保护生效' : `✗ ${fails} 处失败`); +process.exit(fails > 0 ? 1 : 0); diff --git a/scripts/check-trash.mjs b/scripts/check-trash.mjs new file mode 100644 index 0000000..f456a33 --- /dev/null +++ b/scripts/check-trash.mjs @@ -0,0 +1,677 @@ +// 一次性验证脚本:回收站语义 —— 删除即「移到回收站」,且任何删除都不可在软件内彻底丢弃。 +// +// 关键:只检查内存状态是不够的 —— 用户的真相之源是 Markdown 文件。所以每个用例都做 +// 内存变更 → serialize() → parseMarkdown() → 重新数任务 +// 这才能证明被删的任务真的落在文件里的「# 回收站」下,而不是只活在内存里。 +// +// 用法:node scripts/check-trash.mjs + +import { TaskStore, TRASH_NAME, OTHER_TASKS_NAME, COMPLETED_NAME } from '../src/task-store.js'; +import { check, ok, bad, summary, printSummary } from './_lib/check.mjs'; +import { makeStore } from './_lib/store-fixture.mjs'; +import { parseMarkdown, CategoryKind } from '../src/markdown-parser.js'; + +// 往返:serialize 出来再 parse 回来,返回 parse 后的 categories +// (parseMarkdown 直接返回数组,不是 { categories }) +function roundTrip(store) { + return parseMarkdown(store.serialize()); +} + +function findCat(categories, name) { + return categories.find(c => c.name === name) || null; +} + +function texts(cat) { + return (cat?.tasks || []).map(t => t.text); +} + +function totalTasks(categories) { + return categories.reduce((n, c) => n + (c.tasks?.length || 0), 0); +} + +const DOC = `# 当前任务 + +- [ ] 今天写周报 +- [✓] today 已完成 ⭐ + +# 全部任务 + +## 工作 + +- [ ] 工作任务A +- [✓] 工作已完成B + +## 未分类 + +- [ ] 杂事C +`; + +// ============================================================ +// v3 起没有 TODAY 真实分类 —— DOC 里的 `# 当前任务` 章节在加载时被 +// _migrateTodayCategory 迁移到了「未分类」子分类(迁移策略优先未分类), +// 任务打上 @当前 标记。所以测试用「未分类」代替 v2 时代的「当前任务」。 +// ============================================================ +const TODAY_SUB = '未分类'; + +console.log('\n[1] 删除任务 = 移到回收站,且经 Markdown 往返仍在'); +{ + const store = makeStore(DOC); + const before = totalTasks(store.categories); + const today = store.getCategory(TODAY_SUB); + const victim = today.tasks.find(t => t.text === '今天写周报'); + + const ok = store.deleteTask(TODAY_SUB, victim.id); + check('deleteTask 返回 true', ok === true, `实际 ${ok}`); + check('任务已离开原分类', !texts(store.getCategory(TODAY_SUB)).includes('今天写周报')); + check('任务出现在内存回收站', texts(store.getTrashCategory()).includes('今天写周报')); + check('任务总数不变(未丢弃)', totalTasks(store.categories) === before, + `${before} → ${totalTasks(store.categories)}`); + + const md = store.serialize(); + check('序列化出现「# 回收站」', md.includes(`# ${TRASH_NAME}`), md); + check('「# 回收站」排在所有 ## 子分类之后', + md.lastIndexOf('## ') < md.indexOf(`# ${TRASH_NAME}`), + `子分类末位 ${md.lastIndexOf('## ')} / 回收站 ${md.indexOf(`# ${TRASH_NAME}`)}`); + + const rt = roundTrip(store); + check('往返后回收站里仍有该任务', texts(findCat(rt, TRASH_NAME)).includes('今天写周报'), + JSON.stringify(texts(findCat(rt, TRASH_NAME)))); + check('往返后原分类里没有该任务', + !texts(findCat(rt, TODAY_SUB)).includes('今天写周报')); + check('往返后任务总数不变', totalTasks(rt) === before, `${before} → ${totalTasks(rt)}`); + check('往返后子分类没被吞掉(工作/未分类都在,且仍挂在容器下)', + findCat(rt, '工作')?.parentOtherTasks === true && findCat(rt, '未分类')?.parentOtherTasks === true, + JSON.stringify(rt.map(c => `${c.name}:${c.kind}:${c.parentOtherTasks}`))); + check('往返后回收站 kind=TRASH', findCat(rt, TRASH_NAME)?.kind === CategoryKind.TRASH); +} + +// ============================================================ +console.log('\n[2] 回收站内不能彻底删除(软件里没有任何不可逆入口)'); +{ + const store = makeStore(DOC); + const today = store.getCategory(TODAY_SUB); + store.deleteTask(TODAY_SUB, today.tasks[0].id); + const trash = store.getTrashCategory(); + const inTrash = trash.tasks[0]; + + const ok = store.deleteTask(TRASH_NAME, inTrash.id); + check('deleteTask(回收站, …) 返回 false', ok === false, `实际 ${ok}`); + check('任务仍在回收站', store.getTrashCategory().tasks.length === 1); + + check('clearCompleted(回收站) 不清空回收站', store.clearCompleted(TRASH_NAME) === 0); + check('deleteCategory(回收站) 返回 false', store.deleteCategory(TRASH_NAME) === false); + check('回收站分类仍存在', store.getTrashCategory() !== null); + check('renameCategory(回收站, …) 返回 false', + store.renameCategory(TRASH_NAME, '我的垃圾') === false); + // addTask / addSubCategory 的失败约定是返回 null(不是 false) + check('addTask(回收站, …) 返回 null', store.addTask(TRASH_NAME, '不该出现') === null); + check('回收站没被塞进新任务', store.getTrashCategory().tasks.length === 1); + check('addSubCategory 不能占用保留名「回收站」', store.addSubCategory('回收站') === null); + check('addSubCategory 不能占用别名「trash」', store.addSubCategory('trash') === null); +} + +// ============================================================ +console.log('\n[3] 回收站排除于智能列表之外(删掉的任务不该继续出现在 重要/全部/已完成)'); +{ + const store = makeStore(DOC); + // v3.4:DOC 里带 [✓] + ⭐ 的任务在加载时已被搬到「# 已完成任务」分类, + // 不能再去 TODAY_SUB 找(那里只剩「今天写周报」一条)。 + // 测试本意是「删掉一条带 ⭐ 的任务,验证它不再出现在任何智能列表」—— + // 任务从哪个分类删掉不影响语义,换个分类即可。 + const completed = store.getCompletedCategory(); + const starred = completed?.tasks?.find(t => t.important); + if (!starred) throw new Error('未找到 starred 任务(fixture 异常)'); + store.deleteTask(COMPLETED_NAME, starred.id); + + for (const key of ['important', 'allTasks', 'completed']) { + const view = store._buildSmartListView(key); + check(`智能列表「${key}」不含已删除任务`, + !view.tasks.some(t => t.id === starred.id), + JSON.stringify(view.tasks.map(t => t.text))); + } +} + +// ============================================================ +console.log('\n[4] 清除已完成 = 移到回收站,而不是丢弃'); +{ + const store = makeStore(DOC); + const before = totalTasks(store.categories); + const n = store.clearCompleted(); + check('clearCompleted 返回移动条数 2', n === 2, `实际 ${n}`); + check('任务总数不变(都进了回收站)', totalTasks(store.categories) === before, + `${before} → ${totalTasks(store.categories)}`); + const rt = roundTrip(store); + check('往返后两条已完成都在回收站里', + texts(findCat(rt, TRASH_NAME)).length === 2, + JSON.stringify(texts(findCat(rt, TRASH_NAME)))); + check('往返后总数仍不变', totalTasks(rt) === before, `${before} → ${totalTasks(rt)}`); +} + +// ============================================================ +console.log('\n[5] 恢复:从回收站移回分类(复用 moveTask / restoreTask)'); +{ + const store = makeStore(DOC); + const today = store.getCategory(TODAY_SUB); + store.deleteTask(TODAY_SUB, today.tasks.find(t => t.text === '今天写周报').id); + const trashTask = store.getTrashCategory().tasks[0]; + + const ok = store.moveTask(TRASH_NAME, trashTask.id, '工作'); + // moveTask 现在返回实际生效的目标分类名(防御性改投语义),不再是布尔。 + // 这里任务是 uncompleted → 不触发改投 → 返回值就是用户传的 '工作'。 + check('moveTask(回收站 → 工作) 返回「工作"(uncompleted 不触发改投)', + ok === '工作', `实际 ${ok}`); + check('回收站已空', store.getTrashCategory().tasks.length === 0); + check('任务在「工作」里', texts(store.getCategory('工作')).includes('今天写周报')); + + const md = store.serialize(); + check('回收站空 → 文件里不写「# 回收站」', !md.includes(`# ${TRASH_NAME}`), md); + const rt = roundTrip(store); + check('往返后任务仍在「工作」', texts(findCat(rt, '工作')).includes('今天写周报')); + check('往返后子分类结构完好', + findCat(rt, '工作')?.parentOtherTasks === true && findCat(rt, '未分类')?.parentOtherTasks === true); +} + +// ============================================================ +console.log('\n[6] restoreTask 默认回到原分类(v3.5.2+);无原分类兜底到「未分类」'); +{ + const store = makeStore(DOC); + + // 7a) restoreTask 在垃圾任务 / 不存在任务时返回 { ok: false } + check('无回收站时 restoreTask 返回 { ok: false }', store.restoreTask('nope').ok === false); + + // 7b) 普通路径:从「工作」删除 → trash 记 originalCategory='工作' → + // 默认 restoreTask 走 _resolveRestoreTarget 命中 'category',归位到「工作」。 + // 不再走「未分类」兜底(v3.5.1 的旧行为)。 + const work = store.getCategory('工作'); + store.deleteTask('工作', work.tasks.find(t => t.text === '工作任务A').id); + const id = store.getTrashCategory().tasks[0].id; + const result = store.restoreTask(id); + check('restoreTask 返回 { ok: true, restored: true }', + result.ok === true && result.restored === true, + `result=${JSON.stringify(result)}`); + check('默认按原分类归位(v3.5.2+ 新行为)', + texts(store.getCategory('工作')).includes('工作任务A'), + `work=${texts(store.getCategory('工作'))}`); + check('restoreHint.kind === "category" 且带 targetName', + result.restoreHint && result.restoreHint.kind === 'category' && + result.restoreHint.targetName === '工作', + `restoreHint=${JSON.stringify(result.restoreHint)}`); + + // 7c) 兜底:删另一条后抹掉 originalCategory(模拟很旧的文件) → fallback 到「未分类」 + store.deleteTask('工作', work.tasks.filter(t => t.text === '工作任务A')[0].id); + const id2 = store.getTrashCategory().tasks[0].id; + store.getTrashCategory().tasks[0].originalCategory = null; + const fallbackResult = store.restoreTask(id2); + check('无 originalCategory 时 restoreHint.kind === "fallback"', + fallbackResult.restoreHint && fallbackResult.restoreHint.kind === 'fallback', + `restoreHint=${JSON.stringify(fallbackResult.restoreHint)}`); + check('fallback 落到「未分类」', + texts(store.getCategory('未分类')).includes('工作任务A'), + `uncategorized=${texts(store.getCategory('未分类'))}`); + + // 7d) 显式 toCategoryName 传给非法目标(容器)→ 拒绝、任务留在 trash + // 用「今天写周报」(在「未分类」里,completed=false)做这条断言: + // - completed=true 的任务会被 store 强制路由到「已完成任务」无视 toCategoryName, + // 不能拿来做"显式无效参数拒绝"的回归 + const today = store.getCategory(TODAY_SUB); + store.deleteTask(TODAY_SUB, today.tasks.find(t => t.text === '今天写周报').id); + const id3 = store.getTrashCategory().tasks[0].id; + const rejectResult = store.restoreTask(id3, OTHER_TASKS_NAME); + check('不能恢复到容器「全部任务」 → ok: false 且任务留在 trash', + rejectResult.ok === false && store.getTrashCategory().tasks.length === 1); +} + +// ============================================================ +console.log('\n[7] 已有「# 回收站」的文件能被认出来,且不会被当成普通分类'); +{ + const withTrash = DOC + ` +# 回收站 + + + +- [ ] 早先删掉的东西 +`; + const store = makeStore(withTrash); + const trash = store.getTrashCategory(); + check('解析出 kind=TRASH 的回收站', trash !== null && trash.kind === CategoryKind.TRASH); + check('回收站里有 1 条任务', trash?.tasks.length === 1, JSON.stringify(texts(trash))); + check('HTML 注释没被读成任务', !texts(trash).some(t => t.includes('`(写时器 v3+ 不再写入,但历史文件可能仍有) + // 会被解析器剥掉,然后所有任务由解析器末尾的统一计数器重新分配 t1/t2/... + // 这里验证:手工新增的没有 id 注释的行也能被正确收进来,不被忽略或丢弃。 + const HAND_EDITED = [ + '# 当前任务', '', + '- [ ] 任务一 ', + '- [ ] 手写任务甲', + '- [ ] 任务三 ', + '- [ ] 手写任务乙', '', + '# 回收站', '', + '- [ ] 我被删过 ', '' + ].join('\n'); + + const cats = parseMarkdown(HAND_EDITED); + const allTexts = cats.flatMap(c => c.tasks.map(t => t.text)); + check('手工新增的无 id 行不会撞掉已有任务(5 条全在)', + allTexts.length === 5, + `实际 ${allTexts.length} 条:${allTexts.join(' | ')}`); + for (const want of ['任务一', '手写任务甲', '任务三', '手写任务乙', '我被删过']) { + check(` 保留「${want}」`, allTexts.includes(want)); + } + const ids = cats.flatMap(c => c.tasks.map(t => t.id)); + check('分配后 id 全局唯一', new Set(ids).size === ids.length, ids.join(',')); + const hTrash = cats.find(c => c.kind === CategoryKind.TRASH); + check('回收站没有整节消失', !!hTrash && hTrash.tasks.length === 1); + + // ---- 10b. 用户整行复制(含 id 注释)→ 两条真实任务 ---- + // v3+ 解析器不再从文件里读 id,所以两行都会剥离 id 注释, + // 然后由解析器末尾的统一计数器分配独立 id。两条任务都保留。 + const DUP_ID = [ + '# 当前任务', '', + '- [ ] 原始 ', + '- [ ] 复制出来的 ', '' + ].join('\n'); + const dupCats = parseMarkdown(DUP_ID); + const dupTexts = dupCats.flatMap(c => c.tasks.map(t => t.text)); + check('复制行的两条任务都保留(不丢一条)', + dupTexts.length === 2 && dupTexts.includes('原始') && dupTexts.includes('复制出来的'), + dupTexts.join(' | ')); + const dupIds = dupCats.flatMap(c => c.tasks.map(t => t.id)); + check('两条任务由解析器分配不同的 id', + new Set(dupIds).size === 2, dupIds.join(',')); + + // ---- 10c. 在「# 回收站」后面接着写 ## 分类 ---- + // 回收站是文件最后一节,手工编辑时在它后面往下写是很自然的动作。 + // 若这些任务继承了回收站,活跃任务会被标成「已删除」,而且下次保存时 + // 这个 `##` 标题会彻底消失。 + const AFTER_TRASH = [ + '# 当前任务', '', + '- [ ] 活着的', '', + '# 回收站', '', + '- [ ] 已删除的', '', + '## 我手写的分类', '', + '- [ ] 我不该被当成已删除', '' + ].join('\n'); + const atCats = parseMarkdown(AFTER_TRASH); + const atTrash = atCats.find(c => c.kind === CategoryKind.TRASH); + check('回收站里只有真正被删的那条', + atTrash && atTrash.tasks.length === 1 && atTrash.tasks[0].text === '已删除的', + atTrash ? atTrash.tasks.map(t => t.text).join(' | ') : '(无回收站)'); + const atAll = atCats.flatMap(c => c.tasks.map(t => t.text)); + check('回收站之后的任务没有丢', atAll.includes('我不该被当成已删除'), atAll.join(' | ')); + + const atStore = makeStore(AFTER_TRASH); + const atRT = roundTrip(atStore); + const atRTTrash = atRT.find(c => c.kind === CategoryKind.TRASH); + check('往返后仍不在回收站里', + !atRTTrash || !atRTTrash.tasks.some(t => t.text === '我不该被当成已删除')); + check('往返后总数不变(3 条)', + atRT.reduce((n, c) => n + c.tasks.length, 0) === 3); + + // ---- 10d. 叫 bin / trash / 垃圾桶 的**子分类**不该被改判成回收站 ---- + // 需要「正名」的唯一理由是和软件自己创建的「回收站」重名冲突;别名不冲突, + // 强行改判会把用户一整个装着活跃任务的分类标成已删除,标题也随之消失。 + for (const alias of ['bin', 'trash', '垃圾桶']) { + const ALIAS_SUB = [ + '# 当前任务', '', + '# 全部任务', '', + `## ${alias}`, '', + '- [ ] 我是活跃任务', '' + ].join('\n'); + const aStore = makeStore(ALIAS_SUB); + const aCat = aStore.getCategory(alias); + check(`子分类「${alias}」保持普通分类(不被判成回收站)`, + aCat && aCat.kind !== CategoryKind.TRASH, + aCat ? `kind=${aCat.kind}` : '(分类不见了)'); + check(` 「${alias}」里的任务没被当成已删除`, + !aStore.getTrashCategory() || + !aStore.getTrashCategory().tasks.some(t => t.text === '我是活跃任务')); + const aRT = roundTrip(aStore); + check(` 往返后「## ${alias}」这一节还在`, + !!findCat(aRT, alias) && findCat(aRT, alias).tasks.length === 1); + } +} + +// ============================================================ +// [11] v3.6 起不再写顶部 # 当前任务 / # 重要任务 镜像段:[▶]/[⭐] 由 inline 标识 +// 承载,智能视图在内存中按标识聚合。文件里同一条任务只活一次 —— 顶部不再重复。 +// +// 本节回归:v3.x 顶部镜像段在旧文件里可能仍然存在 —— +// - # 当前任务 → TODAY 分类(store 的 _migrateTodayCategory 搬到首个子分类并打 [▶]) +// - # 重要任务 → SMART_LIST_KEYS 命中,__skip__ 整段跳过、任务归「未分类」 +// 下一次保存即扁平化为新结构 —— 不会丢数据。 +// +// 「# 已完成任务」v3.4 起是真实分类(kind=COMPLETED),与「回收站」同款; +// 段下带 [✓] 的任务自动收入并标记 completed=true。 +// ============================================================ +{ + console.log('\n[11] v3.6 不再写顶部镜像段:旧文件镜像段消化路径'); + + // ---- 11a. v3.6 writer 不再生成 # 当前任务 / # 重要任务 顶部镜像段 ---- + // + // 从旧格式文件加载(# 当前任务 是 TODAY 临时分类),clearCompleted 把 [x] 的「做完了」 + // 移入回收站后序列化为 v3.6 新格式 —— 文件开头**不再**含顶部镜像段。 + const s = makeStore('# 当前任务\n\n- [x] 做完了\n- [ ] 重要的 ⭐\n- [ ] 普通的\n'); + s.clearCompleted(); + const md = s.serialize(); + check('写入器不再输出「# 当前任务」顶部镜像段', + !md.includes('# 当前任务'), md.split('\n').slice(0, 6).join('\n')); + check('写入器不再输出「# 重要任务」顶部镜像段', + !md.includes('# 重要任务'), md.split('\n').slice(0, 6).join('\n')); + check('写入器不再带「顶部摘要」引导注释', + !md.includes('', '', + '- [✓] 做完了 ', '', + '# 当前任务', '', + '- [✓] 做完了 ', + '- [ ] 没做完 ', '' + ].join('\n'); + const ro = parseMarkdown(REORDERED); + // 「# 已完成任务」是真实分类,下面的「做完了」按真实分类归属 + const roCompleted = ro.find(c => c.name === '已完成任务'); + check('「# 已完成任务」是真实分类(kind=COMPLETED)', + !!roCompleted && roCompleted.kind === CategoryKind.COMPLETED, + ro.map(c => `${c.name}(${c.kind})`).join(' / ')); + check('「# 已完成任务」下「做完了」带 completed=true', + roCompleted?.tasks?.some(t => t.text === '做完了' && t.completed === true), + ro.flatMap(c => c.tasks.map(t => `${t.text}(${t.completed})`)).join(' | ')); + check('「# 当前任务」TODAY 分类下的两条任务都保留(待 store 迁移)', + ro.find(c => c.name === '当前任务')?.tasks.length === 2, + ro.flatMap(c => c.tasks.map(t => t.text)).join(' | ')); + const roAll = ro.flatMap(c => c.tasks.map(t => t.text)); + check('所有 3 条任务都在(不丢数据)', + roAll.length === 3 && roAll.filter(t => t === '做完了').length === 2 && roAll.includes('没做完'), + roAll.join(' | ')); +} + +// ============================================================ +// [12] 回收站内 toggleTask 原地切换完成态(不再搬到「已完成任务」) +// +// 旧行为:回收站里打勾 → 任务移到「# 已完成任务」,违背"回收站是删除前的 +// 暂存区、用户在那里可以预览/清理"的心智模型(用户报 bug:在回收站里点了 +// 「已完成」的打勾,应该还是在回收站里,而不是跑到「已完成任务」里面)。 +// +// 修复后: +// - 回收站里打勾 → 任务**仍留在回收站**,只切 completed 标记 +// - 回收站里清勾 → 同上,留在回收站 +// - originalCategory 不动(恢复时还要靠它归位) +// - writer 在回收站段照样写出 [✓],parser 加载时识别 task.completed=true +// ============================================================ +console.log('\n[12] 回收站 toggleTask 原地切换完成态(不跨分类)'); +{ + // 12a) 回收站里 [ ] → [✓] → 任务仍留回收站,不进「已完成任务」 + const store = makeStore(DOC); + const today = store.getCategory(TODAY_SUB); + store.deleteTask(TODAY_SUB, today.tasks.find(t => t.text === '今天写周报').id); + const trashId = store.getTrashCategory().tasks[0].id; + const beforeCompleted = store.getCompletedCategory().tasks.length; + + const result = store.toggleTask(TRASH_NAME, trashId); + check('toggleTask(回收站) 返回 { ok: true }', + result && result.ok === true, `actual=${JSON.stringify(result)}`); + check('任务仍在回收站', store.getTrashCategory().tasks.some(t => t.id === trashId)); + check('「已完成任务」分类条数不变(没把任务搬过来)', + store.getCompletedCategory().tasks.length === beforeCompleted, + `before=${beforeCompleted} / after=${store.getCompletedCategory().tasks.length}`); + check('任务 completed === true', + store.getTrashCategory().tasks.find(t => t.id === trashId).completed === true); + + // writer 写出 [✓] + const md = store.serialize(); + const trashSection = md.split(`# ${TRASH_NAME}`)[1] || ''; + check('writer 在回收站段写出 [✓]', + trashSection.includes('[✓]') && trashSection.includes('今天写周报'), + trashSection.slice(0, 200)); + + // 12b) Markdown 往返:task.completed=true 保留在回收站段,不丢、不搬到「已完成任务」 + const rt = roundTrip(store); + const rtTrash = findCat(rt, TRASH_NAME); + check('往返后回收站里任务仍带 completed=true', + rtTrash?.tasks?.some(t => t.text === '今天写周报' && t.completed === true), + JSON.stringify(rtTrash?.tasks?.map(t => `${t.text}(${t.completed})`))); + // DOC 本身带 [✓] 任务(被 _consolidateCompleted 搬到「已完成任务」), + // 但我们刚 toggle 的「今天写周报」不该出现在那里: + check('往返后「今天写周报」不在「已完成任务」分类里', + !findCat(rt, COMPLETED_NAME)?.tasks?.some(t => t.text === '今天写周报'), + JSON.stringify(findCat(rt, COMPLETED_NAME)?.tasks?.map(t => t.text))); + + // 12c) 回收站里 [✓] → [ ] → 任务仍在回收站、不回到「已完成任务」 + store.toggleTask(TRASH_NAME, trashId); + check('再 toggle 回 [ ],任务 completed === false', + store.getTrashCategory().tasks.find(t => t.id === trashId).completed === false); + check('再 toggle 回 [ ],任务仍在回收站(不跑到其它分类)', + store.getTrashCategory().tasks.some(t => t.id === trashId)); + + // 12d) originalCategory 不动 —— 恢复时还要靠它归位 + const work = store.getCategory('工作'); + const originalId = work.tasks.find(t => t.text === '工作任务A').id; + store.deleteTask('工作', originalId); + const trashedWithOrig = store.getTrashCategory().tasks.find(t => t.text === '工作任务A'); + check('删到回收站后 originalCategory === "工作"(前置)', + trashedWithOrig?.originalCategory === '工作'); + store.toggleTask(TRASH_NAME, trashedWithOrig.id); + const afterToggle = store.getTrashCategory().tasks.find(t => t.text === '工作任务A'); + check('回收站里勾选后 originalCategory 仍为 "工作"(不重写)', + afterToggle?.originalCategory === '工作', + `actual=${afterToggle?.originalCategory}`); + store.toggleTask(TRASH_NAME, trashedWithOrig.id); // 清回 [ ] + check('回收站里清勾后 originalCategory 仍为 "工作"(不重写)', + afterToggle?.originalCategory === '工作', + `actual=${store.getTrashCategory().tasks.find(t => t.text === '工作任务A')?.originalCategory}`); +} + +// ============================================================ +console.log(`\n${'─'.repeat(48)}`); +console.log(`通过 ${summary.pass} / 失败 ${summary.fail}`); +process.exit(summary.fail === 0 ? 0 : 1); diff --git a/scripts/check-uncategorized-duplicate.mjs b/scripts/check-uncategorized-duplicate.mjs new file mode 100644 index 0000000..91c9f21 --- /dev/null +++ b/scripts/check-uncategorized-duplicate.mjs @@ -0,0 +1,254 @@ +// 回归测试:「未分类」子分类必须只存在一份。 +// +// 背景:用户手工编辑 todo.md 时可能写出两个 `## 未分类`(保留名不可重复)。 +// 旧 parser 在 insideOtherTasks 下遇到 `## X` 就无条件 createCategory + push, +// 导致 categories 数组里出现两份同名分类 —— sidebar 渲染两份「未分类」入口, +// writer 顺势写两份,下次加载又被 parser 读成两份,自维持脏数据。 +// +// 修复策略(双层防御): +// 1. markdown-parser:遇到重复 `## 未分类` 时复用第一个已有的(不再 push 新分类) +// 2. task-store._consolidateUncategorized:任何绕过 parser 的路径都不能让 +// 内存里出现两份,合并到第一份并丢弃副本 +// +// 跑法:node scripts/check-uncategorized-duplicate.mjs + +import { parseMarkdown, UNCATEGORIZED_NAME, CategoryKind } from '../src/markdown-parser.js'; +import { check, ok, bad, summary, printSummary } from './_lib/check.mjs'; +import { makeStore } from './_lib/store-fixture.mjs'; +import { writeMarkdown } from '../src/markdown-writer.js'; +import { TaskStore } from '../src/task-store.js'; + +// 拿 categories 数组里的「未分类」子分类(parentOtherTasks=true) +function uncategorizedCats(cats) { + return cats.filter(c => c.parentOtherTasks && c.name === UNCATEGORIZED_NAME); +} + +console.log('\n[1] parser:连续两个 `## 未分类` 只产出一个子分类'); + +{ + const md = `# 全部任务 + +## 学习 + +## ${UNCATEGORIZED_NAME} + +## ${UNCATEGORIZED_NAME} + +- [ ] [▶] [⭐] 任务1 +- [ ] 任务2 + +## 工作 +- [ ] 工作1 +`; + const cats = parseMarkdown(md); + const uns = uncategorizedCats(cats); + check('categories 里只有 1 个「未分类」', uns.length === 1, + `实际 ${uns.length} 个:${JSON.stringify(uns.map(c => c.tasks.length))}`); + check('「未分类」下面的任务数 = 2(两份 h2 之间的任务合并)', uns[0].tasks.length === 2, + `实际:${JSON.stringify(uns[0].tasks.map(t => t.text))}`); + check('任务 1 / 任务 2 都进了「未分类」', + uns[0].tasks.map(t => t.text).join('|') === '任务1|任务2', + `实际:${JSON.stringify(uns[0].tasks.map(t => t.text))}`); +} + +console.log('\n[2] parser:连续三个 `## 未分类` 也只产出一个子分类'); + +{ + const md = `# 全部任务 + +## ${UNCATEGORIZED_NAME} + +## ${UNCATEGORIZED_NAME} + +- [ ] A + +## ${UNCATEGORIZED_NAME} + +- [ ] B +- [ ] C +`; + const cats = parseMarkdown(md); + const uns = uncategorizedCats(cats); + check('categories 里只有 1 个「未分类」', uns.length === 1, + `实际 ${uns.length} 个`); + check('「未分类」收下 A/B/C 三条任务', uns[0].tasks.length === 3, + `实际:${JSON.stringify(uns[0].tasks.map(t => t.text))}`); +} + +console.log('\n[3] parser:用户实际场景(第一个 `## 未分类` 空,第二个带任务)'); + +{ + // 完全复刻用户 todo.md 的形态:保留「## 学习」(空)+ 两个 `## 未分类`(第一个空、第二个带任务) + const md = `# 全部任务 + +## 学习 + +## ${UNCATEGORIZED_NAME} + +## ${UNCATEGORIZED_NAME} + +- [ ] [▶] [⭐] 二TV1111二位率顶顶顶顶让他人读过 +- [ ] [▶] [⭐] 二TV +- [ ] 二TV1111二位率顶顶顶顶1111啊的撒发生 +`; + const cats = parseMarkdown(md); + const uns = uncategorizedCats(cats); + check('「未分类」子分类只有 1 个', uns.length === 1, `实际 ${uns.length} 个`); + check('三条任务都在「未分类」里', uns[0].tasks.length === 3, + `实际:${JSON.stringify(uns[0].tasks.map(t => t.text))}`); +} + +console.log('\n[4] writer:往返一致 —— 重复 `## 未分类` 写回后只剩一个'); + +{ + const input = `# 全部任务 + +## ${UNCATEGORIZED_NAME} + +## ${UNCATEGORIZED_NAME} + +- [ ] 任务A +- [ ] 任务B +`; + const cats1 = parseMarkdown(input); + const md1 = writeMarkdown(cats1); + const headerMatches = md1.match(/^##\s+未分类\s*$/gm) || []; + check('writeMarkdown 只输出一个 `## 未分类`', headerMatches.length === 1, + `实际出现 ${headerMatches.length} 次:${JSON.stringify(md1.match(/^##.+$/gm))}`); + + // 二次往返幂等 + const cats2 = parseMarkdown(md1); + const md2 = writeMarkdown(cats2); + check('二次往返 md 完全一致', md1 === md2, `md1 != md2`); + check('二次往返「未分类」子分类数 = 1', + uncategorizedCats(cats2).length === 1); + + // 任务没丢 + const tasks2 = cats2.flatMap(c => c.tasks).map(t => t.text).sort(); + check('任务 A/B 都在', JSON.stringify(tasks2) === JSON.stringify(['任务A', '任务B']), + `实际:${JSON.stringify(tasks2)}`); +} + +console.log('\n[5] store 兜底:内存里塞两个「未分类」,_consolidateUncategorized 必须合并'); + +{ + const store = new TaskStore(); + store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); + store.loadDefault(null); + + // 人为注入两份「未分类」(模拟绕过 parser 的路径) + const realUn = store.categories.find(c => c.parentOtherTasks && c.name === UNCATEGORIZED_NAME); + const ghostUn = { + name: UNCATEGORIZED_NAME, + kind: CategoryKind.NORMAL, + isSpecial: false, + meta: null, + parentOtherTasks: true, + tasks: [{ text: '幽灵任务', completed: false, important: false, current: false }] + }; + // 插在「未分类」真身后面,模拟 categories 数组里出现两份同名 + const idx = store.categories.indexOf(realUn); + store.categories.splice(idx + 1, 0, ghostUn); + + // 触发 _ensureBaseStructure —— 内部会调 _consolidateUncategorized + store._ensureBaseStructure(); + + const uns = store.categories.filter(c => c.parentOtherTasks && c.name === UNCATEGORIZED_NAME); + check('「未分类」只剩 1 份', uns.length === 1, `实际 ${uns.length} 份`); + check('幽灵任务被并入真身', uns[0].tasks.some(t => t.text === '幽灵任务'), + `真身任务:${JSON.stringify(uns[0].tasks.map(t => t.text))}`); +} + +console.log('\n[6] parser:「未分类」**不**影响普通子分类的重名(只有保留名收敛)'); + +{ + // 普通子分类重名时仍然按 parser 旧行为 push(不做合并)—— + // 重命名检查是用户责任,store.renameCategory 会拒绝与现有分类重名(不论保留还是普通)。 + // 这里只验证「未分类」这一个保留名的合并不会污染普通分类的重名行为。 + const md = `# 全部任务 + +## 工作 + +## 工作 + +- [ ] 工作任务 +`; + const cats = parseMarkdown(md); + const work = cats.filter(c => c.parentOtherTasks && c.name === '工作'); + // 普通重名不被本修复合并(这是另一个语义问题 —— 至少保证「未分类」特殊路径不会被牵连)。 + check('普通重名「## 工作」保持原行为(不合并)', work.length === 2, + `实际:${work.length} 个「工作」`); +} + +console.log('\n[7] parser:合法的单一「未分类」行为不变(不误伤正常文件)'); + +{ + const md = `# 全部任务 + +## 学习 +- [ ] 学习1 + +## ${UNCATEGORIZED_NAME} +- [ ] 兜底1 +- [ ] 兜底2 +`; + const cats = parseMarkdown(md); + const uns = uncategorizedCats(cats); + check('单一「未分类」仍然产出 1 个分类', uns.length === 1); + check('单一「未分类」任务数 = 2', uns[0].tasks.length === 2, + `实际:${JSON.stringify(uns[0].tasks.map(t => t.text))}`); + const written = writeMarkdown(cats); + const headerCount = (written.match(/^##\s+未分类\s*$/gm) || []).length; + check('写回文件只有 1 个 `## 未分类`', headerCount === 1, + `实际 ${headerCount} 次`); +} + +console.log('\n[8] store:真实 todo.md 走 loadFromContent 路径,只剩 1 个「未分类」'); + +{ + // 直接用用户的真实文件内容(截至 2026-08-23) + const content = `# 全部任务 + +## 学习 + +## ${UNCATEGORIZED_NAME} + +## ${UNCATEGORIZED_NAME} + +- [ ] [▶] [⭐] 二TV1111二位率顶顶顶顶让他人读过 +- [ ] [▶] [⭐] 二TV +- [ ] [▶] [⭐] 二TV1111二位率 +- [ ] [▶] [⭐] 二TV1111二位率顶顶顶顶让他人读过 +- [ ] [▶] [⭐] 二TV +- [ ] [▶] [⭐] 二TV1111二位率 +- [ ] [▶] 二TV1111二位率顶顶顶顶1111啊的撒发生 + +# 已完成任务 + + + +- [✓] 2 +- [✓] [▶] [⭐] 3333 +- [✓] [▶] [⭐] [原分类:学习] 1234453 +- [✓] [▶] [⭐] [原分类:学习] 好吧 +- [✓] [▶] [⭐] [原分类:未分类] 二TV1111二位率 +- [✓] [▶] [原分类:未分类] 二TV1111二位率顶顶顶顶1111啊的撒发生 +- [✓] [原分类:未分类] 哦哦 +- [✓] [▶] [⭐] [原分类:未分类] 二TV +- [✓] [▶] [⭐] [原分类:学习] 二TV1111二位率顶顶顶顶让他人读过 +- [✓] [原分类:学习] 111 +`; + const store = new TaskStore(); + store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); + store.loadFromContent(content, null); + + const subs = store.getSubCategories(); + const unCount = subs.filter(c => c.name === UNCATEGORIZED_NAME).length; + check('加载后「未分类」子分类只有 1 个', unCount === 1, `实际 ${unCount} 个`); + const unCat = subs.find(c => c.name === UNCATEGORIZED_NAME); + check('「未分类」收下所有 7 条未完成任务', unCat.tasks.length === 7, + `实际 ${unCat.tasks.length} 条:${JSON.stringify(unCat.tasks.map(t => t.text))}`); +} + +console.log(`\n通过 ${summary.pass} / 失败 ${summary.fail}`); +process.exit(summary.fail === 0 ? 0 : 1); \ No newline at end of file diff --git a/scripts/check-uncategorized-position.mjs b/scripts/check-uncategorized-position.mjs new file mode 100644 index 0000000..b9a4cb5 --- /dev/null +++ b/scripts/check-uncategorized-position.mjs @@ -0,0 +1,155 @@ +// 一次性验证脚本:「未分类」必须始终排在子分类数组的末尾, +// 新建的子分类不能挤到「未分类」下面。 +// +// 背景:之前 addSubCategory 用「容器之后最后一个 parentOtherTasks 之后」定位插入点, +// 当「未分类」已经是最后一个 parentOtherTasks 时,新分类会被插入到「未分类」之后, +// 用户体验就是「我新建的分类怎么跑到兜底下面了」。 +// +// 修复后 addSubCategory 优先把新分类插到「未分类」之前;只有「未分类」不存在时 +// 才退回到「容器之后最后一个 parentOtherTasks 之后」的旧位置。 +// +// 用法:node scripts/check-uncategorized-position.mjs + +import { TaskStore, UNCATEGORIZED_NAME } from '../src/task-store.js'; +import { check, ok, bad, summary, printSummary } from './_lib/check.mjs'; +import { makeStore } from './_lib/store-fixture.mjs'; + +console.log('[1] loadDefault 默认结构:「未分类」在子分类末尾'); + +{ + const store = makeStore(); + const subs = store.getSubCategories().map(c => c.name); + check(`子分类顺序 = 工作 / 学习 / 生活 / 未分类`, JSON.stringify(subs) === '["工作","学习","生活","未分类"]', + `实际:${JSON.stringify(subs)}`); + check(`最后一位是「${UNCATEGORIZED_NAME}」`, subs[subs.length - 1] === UNCATEGORIZED_NAME); +} + +console.log('\n[2] 新建子分类应插在「未分类」之前,不应把它挤到中间'); + +{ + const store = makeStore(); + // 第一次:默认已有 工作/学习/生活/未分类 + const before = store.getSubCategories().map(c => c.name); + check(`新建前:「${UNCATEGORIZED_NAME}」在末尾`, before[before.length - 1] === UNCATEGORIZED_NAME); + + store.addSubCategory('副业'); + + const after = store.getSubCategories().map(c => c.name); + check(`新建后:「${UNCATEGORIZED_NAME}」仍在末尾`, after[after.length - 1] === UNCATEGORIZED_NAME, + `实际:${JSON.stringify(after)}`); + check(`新建后:「副业」在「${UNCATEGORIZED_NAME}」之前`, + after.indexOf('副业') < after.indexOf(UNCATEGORIZED_NAME), + `实际:${JSON.stringify(after)}`); + check(`新建后:原有顺序 工作 / 学习 / 生活 / 副业 / ${UNCATEGORIZED_NAME} 全部保持`, + JSON.stringify(after) === '["工作","学习","生活","副业","未分类"]', + `实际:${JSON.stringify(after)}`); +} + +console.log('\n[3] 多次新建:新分类依次插在「未分类」之前'); + +{ + const store = makeStore(); + store.addSubCategory('副业'); + store.addSubCategory('阅读'); + store.addSubCategory('运动'); + + const subs = store.getSubCategories().map(c => c.name); + check(`连续新建后,「${UNCATEGORIZED_NAME}」仍在末尾`, + subs[subs.length - 1] === UNCATEGORIZED_NAME, + `实际:${JSON.stringify(subs)}`); + check(`新建分类按创建顺序排在「${UNCATEGORIZED_NAME}」之前`, + JSON.stringify(subs) === '["工作","学习","生活","副业","阅读","运动","未分类"]', + `实际:${JSON.stringify(subs)}`); +} + +console.log('\n[4] 「未分类」不存在时退回旧逻辑(容器之后最后一个 parentOtherTasks 之后)'); + +{ + const store = new TaskStore(); + // 手动构造一个没有「未分类」的场景:只有容器 + 工作 + 学习(人为删掉「未分类」) + // 这里直接调 _ensureBaseStructure 之外的路径会违反不变量,所以走更稳的办法: + // 把所有 parentOtherTasks 重命名(包括「未分类」),让它的 c.name !== UNCATEGORIZED_NAME, + // addSubCategory 的 findIndex 就会找不到,从而走旧逻辑。 + store.loadDefault(null); + store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); + // 把「未分类」改名为「临时」(不能直接 renameCategory —— 它会拒绝, + // 所以这里直接修改 cat.name)。模拟「文件里没有「未分类」这个魔法名」的状态。 + const unCat = store.categories.find(c => c.parentOtherTasks && c.name === UNCATEGORIZED_NAME); + if (unCat) unCat.name = '临时'; + + store.addSubCategory('副业'); + const subs = store.getSubCategories().map(c => c.name); + // 没有「未分类」魔法名时,新分类应该走"容器之后最后一个 parentOtherTasks 之后"的旧路径 + check(`「未分类」魔法名不存在时,新分类追加到 parentOtherTasks 末尾`, + subs[subs.length - 1] === '副业', + `实际:${JSON.stringify(subs)}`); +} + +console.log('\n[5] 加载文件时把「未分类」从中间拉到末尾(_normalizeOrder 兜底)'); + +{ + // 手工编辑的文件 / 旧版迁移残留都可能让「未分类」落在中间。 + // _normalizeOrder 是唯一兜底 —— 加在子分类末尾,与 reorderSubCategory 在运行时侧 + // 阻止「未分类」被拖走形成对称(运行时守入口,加载时归位)。 + const content = `# 全部任务 + +## 工作 +- [ ] 工作1 + +## 学习 +- [ ] 学习1 + +## ${UNCATEGORIZED_NAME} +- [ ] 兜底1 + +## 生活 +- [ ] 生活1 +`; + const store = new TaskStore(); + store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); + store.loadFromContent(content, null); + const subs = store.getSubCategories().map(c => c.name); + check(`加载后「${UNCATEGORIZED_NAME}」在末尾`, subs[subs.length - 1] === UNCATEGORIZED_NAME, + `实际:${JSON.stringify(subs)}`); + check(`加载后其它子分类的相对顺序保持`, + JSON.stringify(subs.slice(0, -1)) === '["工作","学习","生活"]', + `实际:${JSON.stringify(subs.slice(0, -1))}`); +} + +console.log('\n[6] loadFromContent 软合并分支也会归位「未分类」'); + +{ + // 模拟「外部编辑把「未分类」放中间,用户合并磁盘改动」的场景 —— 合并结果里 + // 「未分类」也被 _normalizeOrder 拉回末尾。 + const store = new TaskStore(); + store.loadDefault(null); + store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} }); + // 内存里现在顺序是 工作 / 学习 / 生活 / 未分类 + // 模拟磁盘版本:把「未分类」挪到中间(解析器读到的样子) + const diskContent = `# 全部任务 + +## 工作 +- [ ] 工作-磁盘版 + +## ${UNCATEGORIZED_NAME} +- [ ] 兜底-磁盘版 + +## 学习 +- [ ] 学习-磁盘版 + +## 生活 +- [ ] 生活-磁盘版 +`; + // 用 mergeResolutions 走软合并分支(addSubCategory 测试不到这条路径) + store.loadFromContent(diskContent, null, { + mergeResolutions: { '__all__': 'a' } + }); + const subs = store.getSubCategories().map(c => c.name); + check(`软合并后「${UNCATEGORIZED_NAME}」在末尾`, subs[subs.length - 1] === UNCATEGORIZED_NAME, + `实际:${JSON.stringify(subs)}`); +} + +console.log('\n' + '='.repeat(64)); +console.log(`结果:${summary.pass} 通过, ${summary.fail} 失败`); +console.log('='.repeat(64)); +process.exit(summary.fail === 0 ? 0 : 1); \ No newline at end of file diff --git a/scripts/check-write-race.mjs b/scripts/check-write-race.mjs new file mode 100644 index 0000000..5aab20b --- /dev/null +++ b/scripts/check-write-race.mjs @@ -0,0 +1,320 @@ +// 测试 file:write 的 race 防护(main.js v6 方案) +// +// 真实场景: +// T0 软件内存 V0,磁盘 V0,lastSeen = V0 +// T1 用户在 UI 改任务 → dirty=true,800ms autoSave 排队 +// T1 外部编辑器原子保存 → 文件变成 V_ext +// T2 autoSave 触发 → serialize() → 走 writeFile IPC +// +// 旧实现:writeFile 只做一次 statSync 检查(pre-check),pre-check 通过后 +// 写 tmp → rename 中间有 10-100ms 窗口,外部编辑器在窗口里又改 → rename +// 覆盖外部编辑(数据丢失)。 +// +// v6 修复:在写 tmp 之后、rename 之前做第二次 statSync,把 race 窗口压缩到 +// 几乎不可见(< 1ms)。 + +import { check, ok, bad, summary, printSummary } from './_lib/check.mjs'; +import { makeStore } from './_lib/store-fixture.mjs'; +import { makeTmpTodoFile, cleanupTmpTodo } from './_lib/tmp-todo.mjs'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +const { dir: tmpDir, file: tmpFile } = makeTmpTodoFile({ + prefix: 'write-race-', + content: '# 全部任务\n\n## 工作\n\n- [ ] V0\n' +}); + +// 复刻 main.js writeFile 的关键检查逻辑(隔离测试,不引入 electron) +let lastSeenMtimeMs = 0; +let lastSeenSize = 0; + +function recordFileStat(filePath) { + try { + const st = fs.statSync(filePath); + lastSeenMtimeMs = st.mtimeMs; + lastSeenSize = st.size; + } catch { /* ignore */ } +} + +recordFileStat(tmpFile); + +// ============================================================ +// 复刻 v6 writeFile 的 mtime 双检查 + 二次确认逻辑 +// ============================================================ +async function v6WriteFile(absPath, content, options = {}) { + if (!options.force && fs.existsSync(absPath)) { + let preStat; + try { preStat = await fs.promises.stat(absPath); } catch { /* skip */ } + if (preStat && (preStat.mtimeMs !== lastSeenMtimeMs || preStat.size !== lastSeenSize)) { + return { ok: false, error: 'EXTERNAL_CHANGE_DETECTED', reason: 'pre-check' }; + } + + const tmpPath = absPath + '.tmp.' + Date.now(); + await fs.promises.writeFile(tmpPath, content, 'utf-8'); + + if (preStat) { + try { + const confirmStat = await fs.promises.stat(absPath); + if (confirmStat.mtimeMs !== preStat.mtimeMs || confirmStat.size !== preStat.size) { + await fs.promises.unlink(tmpPath).catch(() => {}); + return { ok: false, error: 'EXTERNAL_CHANGE_DETECTED', reason: 'race-window' }; + } + } catch (e) { + await fs.promises.unlink(tmpPath).catch(() => {}); + return { ok: false, error: 'EXTERNAL_CHANGE_DETECTED', reason: 'race-window-stat-failed' }; + } + } + await fs.promises.rename(tmpPath, absPath); + } else { + const tmpPath = absPath + '.tmp.' + Date.now(); + try { + await fs.promises.writeFile(tmpPath, content, 'utf-8'); + await fs.promises.rename(tmpPath, absPath); + } catch (e) { + await fs.promises.unlink(tmpPath).catch(() => {}); + throw e; + } + } + recordFileStat(absPath); + return { ok: true }; +} + +// ============================================================ +// [1] 正常写盘:基线匹配,无 race +// ============================================================ +console.log('\n[1] 正常写盘(无外部修改)'); +{ + const result = await v6WriteFile(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] V1\n'); + check('force=false + 无外部修改 → 写入成功', + result.ok === true, JSON.stringify(result)); +} + +// ============================================================ +// [2] pre-check 拦截外部已修改(外部编辑发生在 pre-check 之前) +// ============================================================ +console.log('\n[2] pre-check 拦截外部修改'); +{ + // 让 mtime 推进 + await new Promise(r => setTimeout(r, 60)); + // 模拟外部编辑器保存(更新 lastSeen 之后又来一次外部保存, + // 模拟 fs.watch 还没及时刷新的场景 —— pre-check 必须能拦下) + recordFileStat(tmpFile); // 先刷基线 + await new Promise(r => setTimeout(r, 60)); + fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] 外部V2\n'); + // 不刷新 lastSeen,模拟 polling 还没 tick / fs.watch 没触发的场景 + + const result = await v6WriteFile(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] autoSave\n'); + check('pre-check 检测到外部修改 → 拒绝写盘', + result.ok === false && result.reason === 'pre-check', + JSON.stringify(result)); +} + +// ============================================================ +// [3] 二次确认拦截 race window 内的外部修改 +// ============================================================ +console.log('\n[3] 二次确认拦截 race window 内的外部修改'); +{ + // 让 mtime 推进 + await new Promise(r => setTimeout(r, 60)); + recordFileStat(tmpFile); + await new Promise(r => setTimeout(r, 60)); + + // 这里用 monkey-patch 让 statSync 在 writeFile 内部第二次调用时返回「新状态」 + // —— 模拟「外部编辑发生在 pre-check 和二次 stat 之间」 + let statCallCount = 0; + const realStat = fs.promises.stat; + fs.promises.stat = async (...args) => { + statCallCount++; + if (statCallCount === 1) { + // 第一次 stat(pre-check)—— 返回旧状态(与 lastSeen 一致) + return realStat(...args); + } + // 第二次 stat(二次确认)—— 返回新状态(模拟外部编辑已发生) + // 直接调用底层 statSync 取最新值 + const freshSt = fs.statSync(tmpFile); + // 但 mtime 必须不同 —— 如果还没改,我们手动改一下再读 + if (freshSt.mtimeMs === lastSeenMtimeMs) { + // 没有外部编辑发生,主动触发一次以确保两次 mtime 不同 + fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] race-injected\n'); + await new Promise(r => setTimeout(r, 10)); + return fs.statSync(tmpFile); + } + return freshSt; + }; + + const result = await v6WriteFile(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] autoSave-race\n'); + + fs.promises.stat = realStat; // 还原 + + check('二次确认检测到 race window 内的修改 → 拒绝写盘', + result.ok === false && result.reason === 'race-window', + JSON.stringify(result)); + + // .tmp 文件应被清理(避免数据目录膨胀) + const leftover = fs.readdirSync(tmpDir).filter(f => f.includes('.tmp.')); + check('被拒后 .tmp 文件被清掉', leftover.length === 0, `leftover=${leftover.join(',')}`); +} + +// ============================================================ +// [4] force=true 跳过所有检查 +// ============================================================ +console.log('\n[4] force=true 跳过 mtime 检查(用户明确表态)'); +{ + await new Promise(r => setTimeout(r, 60)); + recordFileStat(tmpFile); + await new Promise(r => setTimeout(r, 60)); + // 外部修改 + fs.writeFileSync(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] 外部V3\n'); + + const result = await v6WriteFile(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] 用户覆盖\n', { force: true }); + check('force=true → 写入成功(用户明确覆盖外部编辑)', + result.ok === true, JSON.stringify(result)); + + const content = fs.readFileSync(tmpFile, 'utf-8'); + check('磁盘上确实是用户内容', content.includes('用户覆盖')); +} + +// ============================================================ +// [5] 二次确认 stat 失败:按外部修改处理(保守策略) +// ============================================================ +console.log('\n[5] 二次确认 stat 失败按外部修改处理'); +{ + await new Promise(r => setTimeout(r, 60)); + recordFileStat(tmpFile); + await new Promise(r => setTimeout(r, 60)); + + // 让第二次 stat 抛错 —— 模拟文件被外部删除 / 锁住 + let callCount = 0; + const realStat = fs.promises.stat; + fs.promises.stat = async (...args) => { + callCount++; + if (callCount === 1) return realStat(...args); + // 第二次抛错 + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }; + + const result = await v6WriteFile(tmpFile, '# 全部任务\n\n## 工作\n\n- [ ] autoSave\n'); + fs.promises.stat = realStat; + + check('二次 stat 失败 → 按外部修改拒绝', + result.ok === false && result.reason === 'race-window-stat-failed', + JSON.stringify(result)); +} + +// ============================================================ +// [6] .bak 轮转:保留两份 .bak,不无限累积 +// ============================================================ +console.log('\n[6] .bak 轮转'); +{ + // 清理已有 .bak 文件以独立测试 + for (const f of fs.readdirSync(tmpDir)) { + if (f.endsWith('.bak') || f.endsWith('.bak.1')) { + fs.unlinkSync(path.join(tmpDir, f)); + } + } + + await new Promise(r => setTimeout(r, 60)); + fs.writeFileSync(tmpFile, 'version-A\n'); + await new Promise(r => setTimeout(r, 60)); + + // 第一次写:磁盘 V0,期望产生 .bak = V0 + // 这里我们直接模拟 rotateBakFiles 的效果(不嵌入 writeFile 链路) + async function rotateBakFiles(absPath) { + const bakPath = absPath + '.bak'; + const bak1Path = absPath + '.bak.1'; + try { + if (fs.existsSync(bak1Path)) await fs.promises.unlink(bak1Path).catch(() => {}); + if (fs.existsSync(bakPath)) await fs.promises.rename(bakPath, bak1Path).catch(() => {}); + if (fs.existsSync(absPath)) await fs.promises.copyFile(absPath, bakPath).catch(() => {}); + } catch {} + } + + await rotateBakFiles(tmpFile); + check('第一次轮转:只有 .bak,没有 .bak.1', + fs.existsSync(tmpFile + '.bak') && !fs.existsSync(tmpFile + '.bak.1')); + + // 第二次写:磁盘 V1,期望 .bak = V1(最新),.bak.1 = V0(旧) + await new Promise(r => setTimeout(r, 60)); + fs.writeFileSync(tmpFile, 'version-B\n'); + await rotateBakFiles(tmpFile); + check('第二次轮转:.bak = V1, .bak.1 = V0', + fs.existsSync(tmpFile + '.bak') && fs.existsSync(tmpFile + '.bak.1')); + + // 第三次写:磁盘 V2,期望 .bak = V2(最新),.bak.1 = V1(次新),V0 应被丢弃 + await new Promise(r => setTimeout(r, 60)); + fs.writeFileSync(tmpFile, 'version-C\n'); + await rotateBakFiles(tmpFile); + const bakContent = fs.readFileSync(tmpFile + '.bak', 'utf-8'); + const bak1Content = fs.readFileSync(tmpFile + '.bak.1', 'utf-8'); + check('第三次轮转:.bak = version-C', bakContent === 'version-C\n', `bak=${bakContent}`); + check('第三次轮转:.bak.1 = version-B', bak1Content === 'version-B\n', `bak1=${bak1Content}`); + check('最多两份 .bak,不无限累积', + fs.readdirSync(tmpDir).filter(f => f.includes('.bak')).length === 2); +} + +// ============================================================ +// [7] file:read 必须刷新 lastSeen —— 否则换文件后第一次 write +// 会拿陈旧 lastSeen 比对新文件 stat,误判外部修改 +// ============================================================ +console.log('\n[7] file:read 路径下 lastSeen 必须刷新(与 file:write 对称)'); + +{ + // 复刻两份不同的 todo 文件(模拟用户切文件场景) + const dirA = fs.mkdtempSync(path.join(os.tmpdir(), 'read-fresh-a-')); + const dirB = fs.mkdtempSync(path.join(os.tmpdir(), 'read-fresh-b-')); + const fileA = path.join(dirA, 'todo.md'); + const fileB = path.join(dirB, 'todo.md'); + fs.writeFileSync(fileA, '# A file\n'); + fs.writeFileSync(fileB, '# B file\n'); + + // 复刻 main.js 状态:lastSeen 是 module-level 全局变量(不是 per-file) + let lastSeenMtimeMs = 0; + let lastSeenSize = 0; + function recordFileStat(filePath) { + try { + const st = fs.statSync(filePath); + lastSeenMtimeMs = st.mtimeMs; + lastSeenSize = st.size; + } catch { /* ignore */ } + } + + // 模拟旧实现的 file:read:只挂 fs.watch,不刷 lastSeen + function oldFileRead(filePath) { + // 旧版:startWatchingFile(filePath) 但不调 recordFileStat + // 这里用 stub 表示「什么也不做」 + } + + // 第一步:用户读 A → 旧实现挂上 fs.watch 但 lastSeen 没动(仍是 0) + oldFileRead(fileA); + // 此时 lastSeen 仍是 0(A 的 mtime 非零) + + // 第二步:用户读 B → 同样挂 fs.watch 但 lastSeen 仍是 0 + oldFileRead(fileB); + + // 第三步:用户写 B(无 force)→ pre-check 用 B 的 mtime 比 lastSeen=0 → 误报 + const preStat = fs.statSync(fileB); + const oldImplMismatch = (preStat.mtimeMs !== lastSeenMtimeMs || preStat.size !== lastSeenSize); + check('旧实现确实有 bug:B 的 stat 与 lastSeen(0) 不等', + oldImplMismatch, '预期 oldImplMismatch=true'); + + // 第四步:修复后的 file:read 应在挂 watcher 后调 recordFileStat + // 模拟修复后行为: + recordFileStat(fileB); + + // 第五步:再次跑 v6 pre-check + const preCheckPasses = (preStat.mtimeMs === lastSeenMtimeMs && preStat.size === lastSeenSize); + check('修复后:B 写之前 lastSeen 已同步到 B 的 stat', + preCheckPasses, + `preStat=(${preStat.mtimeMs},${preStat.size}) lastSeen=(${lastSeenMtimeMs},${lastSeenSize})`); + + // 清理 + try { fs.rmSync(dirA, { recursive: true, force: true }); } catch {} + try { fs.rmSync(dirB, { recursive: true, force: true }); } catch {} +} + +// 清理 +cleanupTmpTodo({ dir: tmpDir }); + +console.log(`\n通过 ${summary.pass} / 失败 ${summary.fail}`); +process.exit(summary.fail === 0 ? 0 : 1); \ No newline at end of file diff --git a/scripts/hoist-svgs.mjs b/scripts/hoist-svgs.mjs new file mode 100644 index 0000000..1a5ab05 --- /dev/null +++ b/scripts/hoist-svgs.mjs @@ -0,0 +1,36 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +const path = 'src/ui/task-list.js'; +let content = readFileSync(path, 'utf8'); + +// 1. Replace inline starSvg template literal with hoisted function call +const starPattern = /const starSvg = ``;/; +const oldStar = content.match(starPattern); +console.log('Found starSvg:', !!oldStar); +content = content.replace(starPattern, 'const starSvg = starSvgFor(task.important);'); + +// 2. Replace inline currentSvg template literal with hoisted function call +const currentPattern = /const currentSvg = ``;/; +const oldCurrent = content.match(currentPattern); +console.log('Found currentSvg:', !!oldCurrent); +content = content.replace(currentPattern, 'const currentSvg = currentSvgFor(task.current);'); + +// 3. Replace editPencilSvg + restoreSvg in trash block (note: order is restoreSvg first in code, editPencilSvg after) +const trashPattern = /const restoreSvg = ``;\s*\n\s*const editPencilSvg = ``;/; +const oldTrash = content.match(trashPattern); +console.log('Found trash pattern:', !!oldTrash); +content = content.replace(trashPattern, 'const restoreSvg = RESTORE_SVG;'); + +// 4. Replace editPencilSvg + deleteXSvg in normal block +const normalPattern = /const editPencilSvg = ``;\s*\n\s*const deleteXSvg = ``;/; +const oldNormal = content.match(normalPattern); +console.log('Found normal pattern:', !!oldNormal); +content = content.replace(normalPattern, 'const deleteXSvg = DELETE_X_SVG;'); + +// 5. Also need to handle editPencilSvg in trash — there's still a leftover const editPencilSvg to replace +const lonelyEditPattern = /const editPencilSvg = ``;/; +const lonelyEdit = content.match(lonelyEditPattern); +console.log('Found lonely editPencilSvg:', !!lonelyEdit); +content = content.replace(lonelyEditPattern, 'const editPencilSvg = EDIT_PENCIL_SVG;'); + +writeFileSync(path, content); +console.log('Done. File length:', content.length); \ No newline at end of file diff --git a/scripts/launch.js b/scripts/launch.js new file mode 100644 index 0000000..cc52a8c --- /dev/null +++ b/scripts/launch.js @@ -0,0 +1,88 @@ +#!/usr/bin/env node +/** + * 启动脚本: + * 1. 自动清理可能影响 Electron 运行的环境变量(ELECTRON_RUN_AS_NODE) + * 2. Windows 控制台切换 UTF-8 代码页(避免中文日志乱码) + * 3. 过滤 Chromium 启动时无害的 GPU cache 警告 + * + * 已知问题: + * - ELECTRON_RUN_AS_NODE=1 会让 electron.exe 表现成 Node(窗口不显示) + * - Windows 控制台默认 GBK 导致中文乱码 + * - Chromium 在某些 Windows 用户目录上无法迁移旧 cache(无害警告) + */ + +const { spawn, spawnSync } = require('child_process'); +const path = require('path'); + +// 清理环境变量(让 Electron 真正作为 Electron 运行) +delete process.env.ELECTRON_RUN_AS_NODE; + +// Windows:把控制台切换到 UTF-8 代码页(65001),避免中文日志乱码 +if (process.platform === 'win32') { + try { + spawnSync('cmd.exe', ['/c', 'chcp', '65001', '>', 'nul'], { + stdio: 'ignore', + shell: false + }); + } catch (e) { + // 忽略 + } +} + +// electron 模块在 Node 环境下返回路径字符串(这就是 Electron 的位置) +const electronBin = require('electron'); +const projectRoot = path.resolve(__dirname, '..'); + +// Electron 启动参数 +const ELECTRON_FLAGS = [ + '--disable-gpu-cache', + '--disable-features=CalculateNativeWinOcclusion' +]; + +const args = [ + ...ELECTRON_FLAGS, + ...(process.env.TODO_DEVTOOLS === '1' ? ['--remote-debugging-port=9222', '--remote-allow-origins=*'] : []), + projectRoot, + ...process.argv.slice(2) +]; + +// 过滤掉 Chromium 启动时的良性警告(这些不影响功能,但会污染控制台) +const BENIGN_ERROR_PATTERNS = [ + /Unable to move the cache/i, + /Unable to create cache/i, + /Gpu Cache Creation failed/i, + /disk_cache\.cc/i +]; + +function filterLine(line) { + return !BENIGN_ERROR_PATTERNS.some(re => re.test(line)); +} + +console.log('[launch] 启动 Electron:', electronBin); +console.log('[launch] 项目路径:', projectRoot); + +// 启动 Electron,过滤 stderr 中的良性警告 +const child = spawn(electronBin, args, { + stdio: ['inherit', 'inherit', 'pipe'], // 单独接管 stderr 以便过滤 + env: process.env, + windowsHide: false +}); + +// 逐行过滤 stderr +if (child.stderr) { + const readline = require('readline'); + const rl = readline.createInterface({ input: child.stderr }); + rl.on('line', (line) => { + if (filterLine(line)) { + process.stderr.write(line + '\n'); + } + }); +} + +child.on('close', (code) => { + process.exit(code ?? 0); +}); + +['SIGINT', 'SIGTERM'].forEach(signal => { + process.on(signal, () => child.kill(signal)); +}); \ No newline at end of file diff --git a/scripts/smoke-order-and-edge.mjs b/scripts/smoke-order-and-edge.mjs new file mode 100644 index 0000000..321ec56 --- /dev/null +++ b/scripts/smoke-order-and-edge.mjs @@ -0,0 +1,383 @@ +// 全面 smoke test:验证外部编辑 → 软件反映 + +import { TaskStore } from '../src/task-store.js'; +import { parseMarkdown, CategoryKind } from '../src/markdown-parser.js'; +import { writeMarkdown } from '../src/markdown-writer.js'; +import { check, ok, bad, summary, printSummary } from './_lib/check.mjs'; +import { makeStore } from './_lib/store-fixture.mjs'; +import { installApiStub } from './_lib/api-stub.mjs'; + +// 桩出 window.api,让 TaskStore 不真去写文件 +installApiStub(); + +// ============================================================ +// [1] 外部编辑 → 重新加载 → 顺序保留 +// ============================================================ +console.log('\n[1] 外部编辑调整顺序 → 重新加载 → 顺序保留'); +{ + // 初始:a, b, c + const initial = `# 全部任务\n\n## 工作\n\n- [ ] a\n- [ ] b\n- [ ] c\n`; + const store = makeStore(initial); + const work = store.getCategory('工作'); + check('初始顺序 a/b/c', work.tasks.map(t => t.text).join(',') === 'a,b,c', + work.tasks.map(t => t.text).join(',')); + + // 用户在编辑器里改成 c, a, b(保存 → fs.watch → 重新加载) + const reordered = `# 全部任务\n\n## 工作\n\n- [ ] c\n- [ ] a\n- [ ] b\n`; + store.loadFromContent(reordered, null); + const work2 = store.getCategory('工作'); + check('重载后顺序变为 c/a/b', work2.tasks.map(t => t.text).join(',') === 'c,a,b', + work2.tasks.map(t => t.text).join(',')); + + // 跨分类移动:在工作里把 x 移到最前 + const store2 = makeStore(`# 全部任务\n\n## 工作\n\n- [ ] x\n- [ ] y\n- [ ] z\n\n## 学习\n\n- [ ] k1\n`); + const store3 = makeStore(`# 全部任务\n\n## 工作\n\n- [ ] y\n- [ ] z\n- [ ] x\n\n## 学习\n\n- [ ] k1\n`); + check('工作分类 y/z/x 顺序保留', + store3.getCategory('工作').tasks.map(t => t.text).join(',') === 'y,z,x'); + + // 顺序往返:parser → writer → parser + const md = `# 全部任务\n\n## 工作\n\n- [ ] 第一\n- [ ] 第二\n- [ ] 第三\n`; + const cats = parseMarkdown(md); + const written = writeMarkdown(cats); + const cats2 = parseMarkdown(written); + const work3 = cats2.find(c => c.name === '工作'); + check('parser→writer→parser 顺序不变', + work3.tasks.map(t => t.text).join(',') === '第一,第二,第三'); +} + +// ============================================================ +// [2] 外部编辑 → 重新加载 → 文本变更同步 +// ============================================================ +console.log('\n[2] 外部编辑文本 → 重新加载 → 文本同步'); +{ + const initial = `# 全部任务\n\n## 工作\n\n- [ ] 原文本\n`; + const store = makeStore(initial); + + // 用户改成新文本 + const updated = `# 全部任务\n\n## 工作\n\n- [ ] 新文本\n`; + store.loadFromContent(updated, null); + const work = store.getCategory('工作'); + check('文本被新内容覆盖', work.tasks[0].text === '新文本', + work.tasks[0].text); + check('仍是 1 条任务', work.tasks.length === 1); +} + +// ============================================================ +// [3] 外部编辑 → 重新加载 → 删除任务 +// ============================================================ +console.log('\n[3] 外部编辑删除任务 → 重新加载 → 删除生效'); +{ + const initial = `# 全部任务\n\n## 工作\n\n- [ ] a\n- [ ] b\n- [ ] c\n`; + const store = makeStore(initial); + + const removed = `# 全部任务\n\n## 工作\n\n- [ ] a\n- [ ] c\n`; + store.loadFromContent(removed, null); + const work = store.getCategory('工作'); + check('删除中间那条,剩 a/c', + work.tasks.map(t => t.text).join(',') === 'a,c', + work.tasks.map(t => t.text).join(',')); +} + +// ============================================================ +// [4] 外部编辑 → 重新加载 → 新增任务 +// ============================================================ +console.log('\n[4] 外部编辑新增任务 → 重新加载 → 新增生效'); +{ + const initial = `# 全部任务\n\n## 工作\n\n- [ ] a\n`; + const store = makeStore(initial); + + const added = `# 全部任务\n\n## 工作\n\n- [ ] a\n- [ ] 新增的\n`; + store.loadFromContent(added, null); + const work = store.getCategory('工作'); + check('新增后 a/新增的', + work.tasks.map(t => t.text).join(',') === 'a,新增的', + work.tasks.map(t =>t.text).join(',')); +} + +// ============================================================ +// [5] 跨分类移动(外部编辑) +// ============================================================ +console.log('\n[5] 跨分类移动 → 重新加载 → 位置同步'); +{ + const initial = `# 全部任务\n\n## 工作\n\n- [ ] taskA\n\n## 学习\n\n- [ ] taskB\n`; + const store = makeStore(initial); + + const moved = `# 全部任务\n\n## 工作\n\n\n## 学习\n\n- [ ] taskB\n- [ ] taskA\n`; + store.loadFromContent(moved, null); + const work = store.getCategory('工作'); + const study = store.getCategory('学习'); + check('taskA 移到学习分类末尾', study.tasks.map(t => t.text).join(',') === 'taskB,taskA'); + check('工作分类变空', work.tasks.length === 0); +} + +// ============================================================ +// [6] id 运行时分配 + 重新加载重新分配 +// ============================================================ +console.log('\n[6] 跨重载:task.id 重新分配'); +{ + const initial = `# 全部任务\n\n## 工作\n\n- [ ] a\n- [ ] b\n`; + const store1 = makeStore(initial); + const work1 = store1.getCategory('工作'); + const ids1 = work1.tasks.map(t => t.id); + check('初次加载 id 是 t1/t2', ids1.join(',') === 't1,t2', ids1.join(',')); + + // 重新加载同样内容 + store1.loadFromContent(initial, null); + const work2 = store1.getCategory('工作'); + const ids2 = work2.tasks.map(t => t.id); + check('重新加载后 id 也是 t1/t2(确定性)', ids2.join(',') === 't1,t2', ids2.join(',')); + check('id 字符串相等(基于位置)', ids1.join(',') === ids2.join(',')); +} + +// ============================================================ +// [7] 同名任务在不同分类 → 各自独立 id +// ============================================================ +console.log('\n[7] 同名任务在不同分类 → 各自独立'); +{ + const md = `# 全部任务\n\n## 工作\n\n- [ ] 买菜\n\n## 生活\n\n- [ ] 买菜\n`; + const cats = parseMarkdown(md); + const work = cats.find(c => c.name === '工作'); + const life = cats.find(c => c.name === '生活'); + check('工作和生活都有「买菜」', work.tasks[0].text === '买菜' && life.tasks[0].text === '买菜'); + check('两个 id 不同', work.tasks[0].id !== life.tasks[0].id, + `工作=${work.tasks[0].id}, 生活=${life.tasks[0].id}`); +} + +// ============================================================ +// [8] 同名任务在同一分类 → 多个独立任务 +// ============================================================ +console.log('\n[8] 同名任务在同一分类 → 多个独立'); +{ + const md = `# 全部任务\n\n## 工作\n\n- [ ] 买菜\n- [ ] 买菜\n`; + const cats = parseMarkdown(md); + const work = cats.find(c => c.name === '工作'); + check('两条「买菜」都在', work.tasks.length === 2); + check('两条 id 不同', work.tasks[0].id !== work.tasks[1].id, + `${work.tasks[0].id} vs ${work.tasks[1].id}`); +} + +// ============================================================ +// [9] 状态标识(⭐/▶/[✓])保留 +// ============================================================ +console.log('\n[9] 状态标识(⭐/▶/[✓])跨重载保留'); +{ + const initial = `# 全部任务\n\n## 工作\n\n- [ ] [⭐] 重要任务\n- [ ] [▶] 当前任务\n- [✓] 已完成\n`; + const store = makeStore(initial); + const work = store.getCategory('工作'); + check('重要标记', work.tasks[0].important === true); + check('当前标记', work.tasks[1].current === true); + // v3.4 起:[✓] 任务在加载时自动从源分类搬到「# 已完成任务」真实分类, + // 不再停留在原分类里 —— 数据归属明确,源分类里不再保留已完成的「鬼影」。 + const completed = store.getCompletedCategory(); + check('已完成任务被搬到「# 已完成任务」分类(v3.4)', + completed?.tasks?.some(t => t.text === '已完成' && t.completed === true), + completed?.tasks?.map(t => `${t.text}(${t.completed})`).join(' | ')); + + // 重新加载 + store.loadFromContent(initial, null); + const work2 = store.getCategory('工作'); + check('重载后重要标记保留', work2.tasks[0].important === true); + check('重载后当前标记保留', work2.tasks[1].current === true); + const completed2 = store.getCompletedCategory(); + check('重载后已完成任务仍在「# 已完成任务」并 completed=true', + completed2?.tasks?.some(t => t.text === '已完成' && t.completed === true), + completed2?.tasks?.map(t => `${t.text}(${t.completed})`).join(' | ')); +} + +// ============================================================ +// [10] writer 永远不会输出 )→ 加载干净 +// ============================================================ +console.log('\n[11] 旧文件迁移:含 '); +{ + const old = `# 全部任务\n\n## 工作\n\n- [ ] 旧任务 \n- [ ] 普通任务\n`; + const cats = parseMarkdown(old); + const work = cats.find(c => c.name === '工作'); + check('旧 id 注释被剥掉,文本干净', work.tasks.every(t => !t.text.includes(':行中保留为字面文本,行尾剥掉'); +{ + // 行中:保留(用户可能写文档示例) + // 行尾:剥掉(v3 不写,但旧文件兼容) + const md = `# 全部任务\n\n## 工作\n\n- [ ] 看这里 中间内容 \n`; + const cats = parseMarkdown(md); + const t = cats.find(c => c.name === '工作').tasks[0]; + // 行尾的剥掉,行中的保留 + check('行中保留 + 行尾剥掉', + t.text === '看这里 中间内容', + JSON.stringify(t.text)); + // 解析器对每个加载文件从 t1 开始,单任务就是 t1(运行时分配,非文件中的 t1) + check('id 是「工作」分类第一条任务的运行时 id', t.id === 't1', `t.id=${t.id}`); + + // 写出去:行尾的 已被剥掉;行中的 是用户文本的一部分,保留 + const written = writeMarkdown(cats); + check('写出去无行尾 $/.test(written.split('\n').find(l => l.startsWith('- '))), + written); + // 行中那串 `` 仍在写出去的文本里(作为字面字符) + check('行中 作为字面文本保留', written.includes(''), written); +} + +console.log(`\n通过 ${summary.pass} / 失败 ${summary.fail}`); +process.exit(summary.fail === 0 ? 0 : 1); diff --git a/scripts/verify-batch-move-menu.mjs b/scripts/verify-batch-move-menu.mjs new file mode 100644 index 0000000..fd43b02 --- /dev/null +++ b/scripts/verify-batch-move-menu.mjs @@ -0,0 +1,275 @@ +// 验证 showContextMenu / _showBatchMoveMenu 修复: +// 1) 长分类名 → 菜单不应被右边切 +// 2) 很多分类 → 应可滚动 + 滚动条可见 +// 3) 窄窗口 → 应 clamp 到视口内 +// 4) 普通情况 → 仍能右对齐 batchBar +// +// 用 Playwright 跑实际浏览器,注入 stub window.api,直接走 task-list.js。 + +import { chromium } from 'playwright-core'; +import { writeFileSync, mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +const BASE = 'http://127.0.0.1:8765'; +const tmpDir = mkdtempSync(join(tmpdir(), 'batch-move-test-')); + +function buildTodo(longNames, count) { + // 通用 tasks + 容器 + 任意条数的子分类(其中一些名字超长) + const cats = []; + // longNames 可能为 null(场景 2),此时全部用默认名生成 + const names = longNames || []; + for (let i = 0; i < count; i++) { + const name = names[i] || `分类 ${i}`; + cats.push(`## ${name}\n\n- [ ] 长名字的分类测试任务 ${i}`); + } + return `# 全部任务\n\n${cats.join('\n\n')}\n\n# 已完成任务\n\n# 回收站\n`; +} + +async function setup(store, todoMd) { + await store.evaluate((md) => { + window.__initialMd = md; + }, todoMd); +} + +async function runScenario(label, opts) { + const { windowWidth, windowHeight, longNames, count, screenshot = false } = opts; + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: windowWidth, height: windowHeight } }); + const page = await context.newPage(); + + const errors = []; + page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`)); + page.on('console', (msg) => { + if (msg.type() === 'error') errors.push(`console.error: ${msg.text()}`); + }); + + // 在 goto 之前用 addInitScript 注册 stub —— addInitScript 注册的脚本 + // 会在页面每次重新加载时(含 location.reload())都执行,存活的 window 全局 + // 变量也会被 location.reload() 清掉,所以 stub 必须放在 addInitScript 里。 + const todoMd = buildTodo(longNames, count); + await page.addInitScript((md) => { + const noop = () => {}; + const noopUnsub = () => noop; + // 完整 stub:与 preload.js 暴露的 api 字段对齐 —— 缺一个就 bootstrap 失败 + window.api = { + isDev: false, + // 文件操作 + saveFileDialog: async () => null, + readFile: async () => ({ ok: true, content: md }), + writeFile: async () => ({ ok: true }), + fileExists: async () => true, + createFileIfMissing: async () => ({ ok: true }), + createSnapshot: async () => ({ ok: true }), + // 应用信息 + getDataDir: async () => '/tmp', + getDefaultDataDir: async () => '/tmp', + getHomeDir: async () => '/tmp', + getVersion: async () => '0.0.0-test', + getElectronVersion: async () => '0.0.0-test', + openDataDir: async () => true, + // 设置 + getSettings: async () => ({}), + saveSettings: async () => true, + chooseDataDir: async () => null, + // 退出前保存 + notifyDirtyChanged: noop, + notifyFlushDone: noop, + onFlushPendingSave: noopUnsub, + // 事件监听 + onMenuCommand: noopUnsub, + onFileExternalChange: noopUnsub, + // 窗口控制 + setAlwaysOnTop: async () => true, + onAlwaysOnTopChanged: noopUnsub, + minimizeWindow: async () => true, + toggleMaximizeWindow: async () => false, + closeWindow: async () => true, + onMaximizeStateChanged: noopUnsub + }; + window.__todoMd = md; + }, todoMd); + + await page.goto(BASE + '/index.html'); + await page.waitForLoadState('domcontentloaded'); + // 给 app 一点时间渲染 + await page.waitForTimeout(800); + + // 进入第一个子分类(普通视图)→ 触发批量模式 → 选中 1 条 → 移到分类 + // 选第一个非「未分类」、非特殊分类的子分类 + // 注意:用 data-category 判定,不要用 textContent —— 因为 DOM 里 .category-count + // 徽章会和分类名拼在同一行(带换行和空格),trim 后仍是 "未分类\n 0" + // 这种格式,与 '未分类' 不等,循环会跳过判断误把「未分类」当成可点击目标。 + const catClicked = await page.evaluate(() => { + const items = document.querySelectorAll('.category-item.subcategory'); + // 跳过第一个(通常是「未分类」) + for (const el of items) { + const name = el.dataset.category; + if (name && name !== '未分类') { + el.click(); + return name; + } + } + return null; + }); + + await page.waitForTimeout(300); + + // 点批量按钮 + await page.evaluate(() => { + const btn = document.querySelector('#btn-batch'); + if (btn) btn.click(); + }); + await page.waitForTimeout(200); + + // 选中第一条任务 + await page.evaluate(() => { + const item = document.querySelector('.task-item'); + if (item) item.click(); + }); + await page.waitForTimeout(200); + + // 点「移到分类」按钮 + await page.evaluate(() => { + const btn = document.querySelector('.batch-action[data-action="move"]'); + if (btn) btn.click(); + }); + await page.waitForTimeout(400); + + // 测量菜单的位置和视口关系 + const menuInfo = await page.evaluate(() => { + const menu = document.getElementById('context-menu'); + if (!menu || menu.hidden) return null; + const rect = menu.getBoundingClientRect(); + return { + left: rect.left, + right: rect.right, + top: rect.top, + bottom: rect.bottom, + width: rect.width, + height: rect.height, + visible: !menu.hidden, + viewportW: window.innerWidth, + viewportH: window.innerHeight, + itemCount: menu.querySelectorAll('.context-menu-item').length, + // 滚动条是否可见(overflow-y: auto + 内容超 max-height) + hasScroll: menu.scrollHeight > menu.clientHeight, + // 哪些项被切(**视口外**,不是菜单外)—— 菜单本身 overflow-y:auto, + // 滚到下方的项仍可访问,不算 bug。真实 bug 是菜单溢出 viewport 让用户看不到入口。 + itemsClipped: (() => { + const items = [...menu.querySelectorAll('.context-menu-item')]; + const vw = window.innerWidth; + const vh = window.innerHeight; + const visible = items.filter(el => { + const ir = el.getBoundingClientRect(); + // 只检查「被 viewport 切」—— 完全在视口外的项(包括菜单滚动到下方的项) + // 不算 clipped,只要菜单本身在视口内且有滚动条就 OK + return ir.right <= vw && ir.left >= 0 && ir.bottom <= vh && ir.top >= 0; + }); + return items.length - visible.length; + })() + }; + }); + + if (screenshot) { + await page.screenshot({ path: join(tmpDir, `${label}.png`), fullPage: false }); + } + + await browser.close(); + + return { label, catClicked, menuInfo, errors }; +} + +const results = []; + +console.log('=== 场景 1:长分类名 ==='); +const r1 = await runScenario('long-names', { + windowWidth: 1024, + windowHeight: 768, + longNames: [ + '这是一个非常非常长的子分类名字用来测试溢出', + '工作', + '学习', + '生活', + '个人事务与重要客户跟进' + ], + count: 5 +}); +results.push(r1); +console.log(JSON.stringify(r1, null, 2)); + +console.log('\n=== 场景 2:很多分类(应可滚动)==='); +const r2 = await runScenario('many-cats', { + windowWidth: 1024, + windowHeight: 600, + longNames: null, + count: 20 +}); +results.push(r2); +console.log(JSON.stringify(r2, null, 2)); + +console.log('\n=== 场景 3:窄窗口 ==='); +const r3 = await runScenario('narrow-window', { + windowWidth: 600, + windowHeight: 500, + longNames: null, + count: 5 +}); +results.push(r3); +console.log(JSON.stringify(r3, null, 2)); + +console.log('\n=== 场景 4:超窄窗口(菜单应 clamp)==='); +const r4 = await runScenario('very-narrow', { + windowWidth: 380, + windowHeight: 500, + longNames: null, + count: 5 +}); +results.push(r4); +console.log(JSON.stringify(r4, null, 2)); + +// 验证 +function check(name, cond, info) { + const tag = cond ? '✓' : '✗'; + console.log(`${tag} ${name}${info ? ` (${info})` : ''}`); + return cond; +} + +console.log('\n========== 验证 =========='); +let pass = 0, fail = 0; +for (const r of results) { + console.log(`\n[${r.label}]`); + if (!r.menuInfo) { + console.log(' ✗ 菜单未显示'); + fail++; + continue; + } + const m = r.menuInfo; + // 1. 右边不超过视口 + if (check('菜单右边 ≤ 视口右边', m.right <= m.viewportW - 4, `right=${m.right}, vw=${m.viewportW}`)) pass++; else fail++; + // 2. 左边不小于 4 + if (check('菜单左边 ≥ 4', m.left >= 4, `left=${m.left}`)) pass++; else fail++; + // 3. 底边不超过视口 + if (check('菜单底边 ≤ 视口底边', m.bottom <= m.viewportH - 4, `bottom=${m.bottom}, vh=${m.viewportH}`)) pass++; else fail++; + // 4. 顶边不小于 4 + if (check('菜单顶边 ≥ 4', m.top >= 4, `top=${m.top}`)) pass++; else fail++; + // 5. 无项被切 —— 仅当菜单**没有**滚动时检查。 + // 菜单 overflow-y:auto 时,滚到下方的项虽然 DOM 存在但会被 viewport 切 + // (这是正常的滚动行为,用户主动 scrollTop 才能看到),不算 bug。 + // 真实 bug 是「菜单本身溢出 viewport」,由上面的 1/3 项覆盖。 + if (!m.hasScroll) { + if (check('菜单内所有项可见(不被切)', m.itemsClipped === 0, `clipped=${m.itemsClipped}`)) pass++; else fail++; + } else { + if (check('菜单可滚动时跳过项可见性检查(滚动是正常的)', true)) pass++; + } + // 6. 有滚动条当内容超出 + if (m.itemCount > 15) { + if (check('内容多时滚动条可见', m.hasScroll, `scrollH=${m.hasScroll}`)) pass++; else fail++; + } + // 7. 无 page error + if (check('无 JS 错误', r.errors.length === 0, r.errors.join('; '))) pass++; else fail++; +} + +console.log(`\n==========\n通过 ${pass} / 失败 ${fail}\n==========`); +rmSync(tmpDir, { recursive: true, force: true }); +process.exit(fail === 0 ? 0 : 1); diff --git a/scripts/verify-batch-roundtrip.mjs b/scripts/verify-batch-roundtrip.mjs new file mode 100644 index 0000000..49e9938 --- /dev/null +++ b/scripts/verify-batch-roundtrip.mjs @@ -0,0 +1,604 @@ +// 验证「已完成任务」+「回收站」批量操作的真实数据流: +// 1) 已完成视图:批量「取消完成」→ 任务按 originalCategory 归位,completed=false +// 2) 回收站视图:批量「恢复到分类…」选 工作 → uncompleted 任务回到「工作」 +// 3) 回收站视图:批量恢复 → completed 任务被硬路由到「已完成任务」 +// 4) 回收站视图:批量「标重要」→ 任务的 [⭐] 标记写入 +// +// 用 Playwright 跑真实浏览器。stub window.api,捕获 writeFile 调用,读回 markdown 反序列化校验。 + +import { chromium } from 'playwright-core'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +const BASE = 'http://127.0.0.1:8765'; +const tmpDir = mkdtempSync(join(tmpdir(), 'batch-rt-test-')); + +// 简化版解析器 —— 复刻 src/markdown-parser.js 的核心规则 +// 支持 [⭐]/[▶] 在行首或行尾,[原分类:xxx] 始终在行尾 +function parseMd(md) { + const lines = md.split('\n'); + const result = { cats: [], trash: [], completed: [] }; + let currentCat = null; + let mode = 'normal'; // 'normal' | 'completed' | 'trash' + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line.startsWith('# ')) { + const title = line.slice(2).trim(); + if (title === '回收站') { + mode = 'trash'; + currentCat = null; + } else if (title === '已完成任务') { + mode = 'completed'; + currentCat = null; + } else { + // 容器或镜像段都忽略 + mode = 'normal'; + currentCat = null; + } + continue; + } + if (line.startsWith('## ')) { + const name = line.slice(3).trim(); + currentCat = { name, tasks: [] }; + mode = 'normal'; + result.cats.push(currentCat); + continue; + } + const m = line.match(/^-\s+\[([ xX✓])\]\s*(.*)$/); + if (!m) continue; + const completed = m[1] === 'x' || m[1] === 'X' || m[1] === '✓'; + let text = m[2]; + + // 抽 [原分类:xxx] —— 始终在行尾 + let originalCategory = null; + const origMatch = text.match(/\[原分类:([^\]]+)\]/); + if (origMatch) { + originalCategory = origMatch[1]; + text = text.replace(/\[原分类:[^\]]+\]/g, '').trim(); + } + + // 行首 [⭐] / [▶] + let headImportant = false, headCurrent = false; + const headImp = text.match(/^\[⭐\]\s*/); + if (headImp) { headImportant = true; text = text.slice(headImp[0].length); } + const headCur = text.match(/^\[▶\]\s*/); + if (headCur) { headCurrent = true; text = text.slice(headCur[0].length); } + + // 行尾 [⭐] / [▶] + const tailImp = text.match(/\[⭐\]$/); + if (tailImp) { headImportant = true; text = text.slice(0, text.length - tailImp[0].length).trim(); } + const tailCur = text.match(/\[▶\]$/); + if (tailCur) { headCurrent = true; text = text.slice(0, text.length - tailCur[0].length).trim(); } + + const task = { + text: text.trim(), + completed, + important: headImportant, + current: headCurrent, + originalCategory + }; + if (mode === 'trash') { + result.trash.push(task); + } else if (mode === 'completed') { + result.completed.push(task); + } else if (currentCat) { + currentCat.tasks.push(task); + } + } + return result; +} + +function findTask(arr, predicate) { + return arr.find(predicate); +} + +// 文档:1 个完成的任务来自工作 + 1 个删到回收站 + 1 个删且完成的 +const DOC = [ + '# 当前任务', '', + '# 全部任务', '', + '## 工作', '', + '- [ ] 工作任务 A', '', + '## 学习', '', + '- [ ] 学习任务 A', '', + '# 已完成任务', '', + '- [✓] 工作任务 A', '', + '# 回收站', '', + '- [ ] 删除的 A', '', + '- [✓] 已删且已完成的', '' +].join('\n'); + +async function setupStub(page, md) { + // 注意:addInitScript 会在每次 reload 时重新执行(闭包 md 会被覆盖回原值)。 + // 所以「换 doc 后再 reload」必须重新调 setupStub(newDoc),否则 reload 会把 + // window.__todoMd 重置回原 DOC —— 看起来测试通过了其实是测了不同的 doc。 + await page.addInitScript((md) => { + const noop = () => {}; + const noopUnsub = () => noop; + window.__writes = []; + window.__todoMd = md; + window.api = { + isDev: false, + saveFileDialog: async () => null, + readFile: async () => ({ ok: true, content: window.__todoMd }), + writeFile: async (path, content) => { + // 捕获写入:每次 store flush 都会调一次,把最新内容记下来 + window.__writes.push({ path, content }); + return { ok: true }; + }, + fileExists: async () => true, + createFileIfMissing: async () => ({ ok: true }), + createSnapshot: async () => ({ ok: true }), + getDataDir: async () => '/tmp', + getDefaultDataDir: async () => '/tmp', + getHomeDir: async () => '/tmp', + getVersion: async () => '0.0.0-test', + getElectronVersion: async () => '0.0.0-test', + openDataDir: async () => true, + getSettings: async () => ({}), + saveSettings: async () => true, + chooseDataDir: async () => null, + notifyDirtyChanged: noop, + notifyFlushDone: noop, + onFlushPendingSave: noopUnsub, + onMenuCommand: noopUnsub, + onFileExternalChange: noopUnsub, + setAlwaysOnTop: async () => true, + onAlwaysOnTopChanged: noopUnsub, + minimizeWindow: async () => true, + toggleMaximizeWindow: async () => false, + closeWindow: async () => true, + onMaximizeStateChanged: noopUnsub + }; + }, md); +} + +async function clickSidebarCategory(page, categoryName) { + const result = await page.evaluate((name) => { + const items = document.querySelectorAll('.category-item'); + for (const el of items) { + if (el.textContent.trim().startsWith(name)) { + el.click(); + return true; + } + } + return false; + }, categoryName); + if (!result) throw new Error(`未找到 sidebar 分类:${categoryName}`); + await page.waitForTimeout(300); +} + +async function getMd(page) { + return await page.evaluate(() => { + // 找到最近一次写入 + const writes = window.__writes || []; + return writes.length > 0 ? writes[writes.length - 1].content : null; + }); +} + +async function triggerBatch(page, action, opts = {}) { + // 进批量模式 + 选第一条 + 点对应按钮 + await page.evaluate(() => { + const btn = document.querySelector('#btn-batch'); + if (btn && !btn.hidden) btn.click(); + }); + await page.waitForTimeout(200); + await page.evaluate(() => { + const item = document.querySelector('.task-item'); + if (item) item.click(); + }); + await page.waitForTimeout(150); + + if (action === 'complete') { + // 直接点完成/取消完成按钮 + await page.evaluate(() => { + const btn = document.querySelector('.batch-action[data-action="complete"]'); + if (btn) btn.click(); + }); + } else if (action === 'important-set') { + await page.evaluate(() => { + const btn = document.querySelector('.batch-action[data-action="important-set"]'); + if (btn) btn.click(); + }); + } else if (action === 'restore') { + // 点「恢复到分类…」→ 弹菜单 → 点目标 + await page.evaluate(() => { + const btn = document.querySelector('.batch-action[data-action="move"]'); + if (btn) btn.click(); + }); + await page.waitForTimeout(300); + await page.evaluate((target) => { + const items = document.querySelectorAll('#context-menu .context-menu-item'); + for (const el of items) { + const actionAttr = el.dataset.action || ''; + if (actionAttr === `batch-move:${target}`) { + el.click(); + return true; + } + } + return false; + }, opts.target); + } + await page.waitForTimeout(400); +} + +async function clearWrites(page) { + await page.evaluate(() => { + window.__writes = []; + }); +} + +async function getTaskCount(page) { + return await page.evaluate(() => { + return document.querySelectorAll('.task-item').length; + }); +} + +function check(name, cond, info) { + const tag = cond ? '✓' : '✗'; + console.log(`${tag} ${name}${info ? ` (${info})` : ''}`); + return cond; +} + +let pass = 0, fail = 0; + +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext({ viewport: { width: 1024, height: 768 } }); +const page = await context.newPage(); + +const errors = []; +page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`)); +page.on('console', (msg) => { + const t = msg.type(); + if (t === 'error') errors.push(`console.error: ${msg.text()}`); +}); + +await setupStub(page, DOC); +await page.goto(BASE + '/index.html'); +await page.waitForLoadState('domcontentloaded'); +await page.waitForTimeout(800); + +if (!(await page.evaluate(() => !!document.getElementById('app')))) { + await browser.close(); + throw new Error('app 未渲染'); +} + +console.log('\n========== 场景 1:已完成视图批量「取消完成」 =========='); +{ + await clickSidebarCategory(page, '已完成任务'); + // 已完成任务视图下应有 1 条 + const initialCount = await getTaskCount(page); + if (check('已完成任务视图渲染 1 条任务', initialCount === 1, `actual=${initialCount}`)) pass++; else fail++; + + // batch 按钮可见 + const batchVisible = await page.evaluate(() => !document.querySelector('#btn-batch').hidden); + if (check('batch 按钮可见', batchVisible)) pass++; else fail++; + + await clearWrites(page); + await triggerBatch(page, 'complete'); + + const md = await getMd(page); + if (check('批量「取消完成」后产生 writeFile 调用', md !== null)) pass++; else fail++; + + if (md) { + const parsed = parseMd(md); + const workCat = parsed.cats.find(c => c.name === '工作'); + if (check('「工作」分类存在', !!workCat)) pass++; else fail++; + if (check('「工作」里有 1 条任务(原 A 已回来)', + workCat && workCat.tasks.length === 1, + `tasks=${JSON.stringify(workCat?.tasks)}`)) pass++; else fail++; + if (check('回来的任务 completed=false', + workCat && workCat.tasks[0] && workCat.tasks[0].completed === false, + JSON.stringify(workCat?.tasks[0]))) pass++; else fail++; + if (check('已完成任务分类为空', + parsed.completed.length === 0, + `completed=${JSON.stringify(parsed.completed)}`)) pass++; else fail++; + } +} + +console.log('\n========== 场景 2:回收站批量恢复(未完成) =========='); +{ + await clickSidebarCategory(page, '回收站'); + const initialCount = await getTaskCount(page); + if (check('回收站视图渲染 2 条任务', initialCount === 2, `actual=${initialCount}`)) pass++; else fail++; + + // 选第一条(未完成的「删除的 A」)→ 恢复到「工作」 + // 先选中第一条再恢复 + await clearWrites(page); + + await page.evaluate(() => { + const btn = document.querySelector('#btn-batch'); + if (btn && !btn.hidden) btn.click(); + }); + await page.waitForTimeout(200); + await page.evaluate(() => { + const items = document.querySelectorAll('.task-item'); + // 选第一条(未完成的) + if (items.length > 0) items[0].click(); + }); + await page.waitForTimeout(150); + // 点「恢复到分类…」 + await page.evaluate(() => { + const btn = document.querySelector('.batch-action[data-action="move"]'); + if (btn) btn.click(); + }); + await page.waitForTimeout(300); + // 点「工作」目标 + await page.evaluate(() => { + const items = document.querySelectorAll('#context-menu .context-menu-item'); + for (const el of items) { + const a = el.dataset.action || ''; + if (a === 'batch-move:工作') { + el.click(); + return true; + } + } + return false; + }); + await page.waitForTimeout(400); + + const md = await getMd(page); + if (check('批量恢复后产生 writeFile 调用', md !== null)) pass++; else fail++; + + if (md) { + const parsed = parseMd(md); + const workCat = parsed.cats.find(c => c.name === '工作'); + if (check('「工作」分类存在', !!workCat)) pass++; else fail++; + // 工作原本有 A,加上恢复回来的「删除的 A」= 2 条 + if (check('「工作」里有 2 条任务(原 A + 恢复的 删除的 A)', + workCat && workCat.tasks.length === 2, + `tasks=${JSON.stringify(workCat?.tasks?.map(t => t.text))}`)) pass++; else fail++; + const restoredTask = workCat && workCat.tasks.find(t => t.text === '删除的 A'); + if (check('恢复回来的「删除的 A」completed=false', + restoredTask && restoredTask.completed === false, + JSON.stringify(restoredTask))) pass++; else fail++; + if (check('回收站还剩 1 条(已删且完成的)', + parsed.trash.length === 1, + `trash=${JSON.stringify(parsed.trash?.map(t => t.text))}`)) pass++; else fail++; + } +} + +console.log('\n========== 场景 3:回收站批量恢复(completed → 已完成任务硬路由) =========='); +{ + // 现在回收站里还剩 1 条「已删且已完成的」 + // 选中这条 → 恢复到「工作」→ 应该被硬路由到「已完成任务」 + // 重要:先确保退出场景 2 残留的 selectionMode(_afterBatchComplete 不退批量) + // 否则点 batch 按钮会变「退出」语义,菜单根本打不开。 + await page.evaluate(() => { + if (document.body.dataset.selectionMode === 'true') { + // 模拟 Esc 退出批量模式 + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + } + }); + await page.waitForTimeout(150); + + await clickSidebarCategory(page, '回收站'); + await clearWrites(page); + + await page.evaluate(() => { + const btn = document.querySelector('#btn-batch'); + if (btn && !btn.hidden) btn.click(); + }); + await page.waitForTimeout(200); + await page.evaluate(() => { + const items = document.querySelectorAll('.task-item'); + // 选第一条(即「已删且已完成的」) + if (items.length > 0) items[0].click(); + }); + await page.waitForTimeout(150); + await page.evaluate(() => { + const btn = document.querySelector('.batch-action[data-action="move"]'); + if (btn) btn.click(); + }); + await page.waitForTimeout(300); + // 候选列表里不应包含「已完成任务」(已被排除) + // 但可以选「学习」(仍然是普通分类) + const moveOptions = await page.evaluate(() => { + const items = document.querySelectorAll('#context-menu .context-menu-item'); + const opts = []; + for (const el of items) { + const a = el.dataset.action || ''; + if (a.startsWith('batch-move:')) opts.push(a.slice(11)); + } + return opts; + }); + if (check('回收站恢复菜单不含「已完成任务」', + !moveOptions.includes('已完成任务'), + `opts=${JSON.stringify(moveOptions)}`)) pass++; else fail++; + if (check('回收站恢复菜单不含「回收站」', + !moveOptions.includes('回收站'), + `opts=${JSON.stringify(moveOptions)}`)) pass++; else fail++; + if (check('回收站恢复菜单不含「全部任务」', + !moveOptions.includes('全部任务'), + `opts=${JSON.stringify(moveOptions)}`)) pass++; else fail++; + + // 关键:必须在 menu 还开着时立刻点击学习 —— 把「点学习」放紧跟着 move button click, + // 不穿插任何 page.evaluate,避免异步间隙里 store change 触发 render 把菜单关掉。 + const learnResult = await page.evaluate(async () => { + const items = document.querySelectorAll('#context-menu .context-menu-item'); + let clicked = false; + for (const el of items) { + if (el.dataset.action === 'batch-move:学习') { + el.click(); + clicked = true; + break; + } + } + await new Promise(r => setTimeout(r, 500)); + return { clicked, writeCount: window.__writes.length }; + }); + if (!learnResult.clicked) { + throw new Error('场景 3:找不到 batch-move:学习 菜单项,菜单状态异常'); + } + + const md = await getMd(page); + if (check('completed 任务恢复后产生 writeFile', md !== null)) pass++; else fail++; + + if (md) { + const parsed = parseMd(md); + // completed 任务不应去「学习」,应去「已完成任务」 + const studyCat = parsed.cats.find(c => c.name === '学习'); + const restoredInStudy = studyCat?.tasks?.find(t => t.text === '已删且已完成的'); + if (check('completed 任务不在「学习」(未被改投成功)', + !restoredInStudy, + `study=${JSON.stringify(studyCat?.tasks)}`)) pass++; else fail++; + + // 应在「已完成任务」分类里 + const inCompleted = parsed.completed.find(t => t.text === '已删且已完成的'); + if (check('completed 任务被硬路由到「已完成任务」', + !!inCompleted, + `completed=${JSON.stringify(parsed.completed.map(t => t.text))}`)) pass++; else fail++; + if (check('硬路由后任务仍带 completed=true', + inCompleted && inCompleted.completed === true)) pass++; else fail++; + + // 回收站应为空 + if (check('回收站为空', + parsed.trash.length === 0, + `trash=${JSON.stringify(parsed.trash)}`)) pass++; else fail++; + } +} + +console.log('\n========== 场景 4:回收站批量「标重要」→ [⭐] 写入 =========='); +{ + // 重新加载初始文档 —— 必须重新调 setupStub(DOC),addInitScript 在 reload 时 + // 会用闭包里的 DOC 覆盖 window.__todoMd + await setupStub(page, DOC); + await page.evaluate(() => { window.__writes = []; }); + await page.reload(); + await page.waitForLoadState('domcontentloaded'); + await page.waitForTimeout(800); + + if (!(await page.evaluate(() => !!document.getElementById('app')))) { + await browser.close(); + throw new Error('reload 后 app 未渲染'); + } + + await clickSidebarCategory(page, '回收站'); + await clearWrites(page); + + // 进批量模式,选全部 2 条,标重要 + await page.evaluate(() => { + const btn = document.querySelector('#btn-batch'); + if (btn && !btn.hidden) btn.click(); + }); + await page.waitForTimeout(200); + await page.evaluate(() => { + // 全选 + // 找全选按钮(如果有),否则一条一条点 + const allBtn = document.querySelector('.batch-select-all'); + if (allBtn) { + allBtn.click(); + } else { + const items = document.querySelectorAll('.task-item'); + for (const it of items) it.click(); + } + }); + await page.waitForTimeout(150); + await page.evaluate(() => { + const btn = document.querySelector('.batch-action[data-action="important-set"]'); + if (btn) btn.click(); + }); + await page.waitForTimeout(400); + + const md = await getMd(page); + if (check('回收站批量标重要产生 writeFile', md !== null)) pass++; else fail++; + + if (md) { + const parsed = parseMd(md); + const allTrashImportant = parsed.trash.length > 0 && parsed.trash.every(t => t.important); + if (check('回收站里 2 条任务都带 important=true', + allTrashImportant, + `trash=${JSON.stringify(parsed.trash.map(t => ({ text: t.text, imp: t.important })))}`)) pass++; else fail++; + // 回收站段应包含 [⭐] + if (check('回收站段 markdown 含 [⭐] 标记', /\[⭐\]/.test(md))) pass++; else fail++; + } +} + +console.log('\n========== 场景 5:切分类自动退出批量模式(不残留) =========='); +{ + // 再次 reload 回到初始文档 + await setupStub(page, DOC); + await page.evaluate(() => { window.__writes = []; }); + await page.reload(); + await page.waitForLoadState('domcontentloaded'); + await page.waitForTimeout(800); + + if (!(await page.evaluate(() => !!document.getElementById('app')))) { + await browser.close(); + throw new Error('reload 后 app 未渲染'); + } + + await clickSidebarCategory(page, '已完成任务'); + // 进批量模式 + await page.evaluate(() => { + const btn = document.querySelector('#btn-batch'); + if (btn && !btn.hidden) btn.click(); + }); + await page.waitForTimeout(200); + const inSelectionBefore = await page.evaluate(() => document.body.dataset.selectionMode === 'true'); + if (check('切到 已完成 视图后进入批量模式', inSelectionBefore)) pass++; else fail++; + + // 切到「工作」分类(普通子分类) + await clickSidebarCategory(page, '工作'); + const inSelectionAfter = await page.evaluate(() => document.body.dataset.selectionMode === 'true'); + if (check('切到「工作」后批量模式自动退出', !inSelectionAfter)) pass++; else fail++; + const batchBarAfter = await page.evaluate(() => { + const bar = document.querySelector('.batch-action-bar'); + return !bar || bar.hidden === true; + }); + if (check('切分类后 batch 浮栏已隐藏', batchBarAfter)) pass++; else fail++; + + // 切回「已完成任务」应能重新进入批量模式 + await clickSidebarCategory(page, '已完成任务'); + const batchVisibleAgain = await page.evaluate(() => !document.querySelector('#btn-batch').hidden); + if (check('重新进入 已完成 视图后 batch 按钮仍可见', batchVisibleAgain)) pass++; else fail++; +} + +console.log('\n========== 场景 6:空视图下 batch 按钮隐藏 =========='); +{ + // 文档里把已完成任务和回收站都清空 + const emptyDoc = [ + '# 当前任务', '', + '# 全部任务', '', + '## 工作', '', + '- [ ] 工作任务 A', '', + '# 已完成任务', '', + '# 回收站', '' + ].join('\n'); + + // 重新 setupStub 用空 doc —— 见 setupStub 注释(addInitScript 闭包在 reload 时覆盖) + await setupStub(page, emptyDoc); + await page.evaluate(() => { window.__writes = []; }); + await page.reload(); + await page.waitForLoadState('domcontentloaded'); + await page.waitForTimeout(800); + + if (!(await page.evaluate(() => !!document.getElementById('app')))) { + await browser.close(); + throw new Error('reload 后 app 未渲染'); + } + + // 已完成视图:sidebar 里 smart-list item 一直渲染(即使为空),可直接点击 + await clickSidebarCategory(page, '已完成任务'); + const afterCompleted = await page.evaluate(() => ({ + title: document.getElementById('category-title')?.textContent, + batchHidden: document.querySelector('#btn-batch').hidden, + taskCount: document.querySelectorAll('.task-item').length + })); + const completedBatchHidden = afterCompleted.batchHidden; + if (check('空 已完成 视图下 batch 按钮隐藏', completedBatchHidden, JSON.stringify(afterCompleted))) pass++; else fail++; + + // 回收站视图:sidebar 在 trash 为空时不渲染 item(设计如此 —— 空回收站无入口)。 + // 用户正常路径下到达空回收站的唯一方式:在回收站里批删所有任务 → _followTrashIfEmptied + // 会自动切走。所以这个边界场景无法在普通 UI 流里复现,跳过断言。 + // (代码层面 hideBatch 在 visibleTasks.length===0 时一定为 true,见 task-list.js:888-892) +} + +console.log('\n========== 无 page error =========='); +if (check('无 page error', errors.length === 0, errors.join('; '))) pass++; else fail++; + +await browser.close(); + +console.log(`\n==========\n通过 ${pass} / 失败 ${fail}\n==========`); +rmSync(tmpDir, { recursive: true, force: true }); +process.exit(fail === 0 ? 0 : 1); diff --git a/scripts/verify-batch-trash-completed.mjs b/scripts/verify-batch-trash-completed.mjs new file mode 100644 index 0000000..00d29c6 --- /dev/null +++ b/scripts/verify-batch-trash-completed.mjs @@ -0,0 +1,378 @@ +// 验证「已完成任务」智能视图 + 「回收站」视图的批量功能入口: +// 1) 「已完成任务」视图:批量按钮可见,进入批量模式 → 「移到分类」按钮被隐藏, +// 「移到回收站」+「完成」(=取消完成)按钮可用 +// 2) 「回收站」视图:批量按钮可见,进入批量模式 → 「移到回收站」按钮被隐藏, +// 「移到分类」按钮 label 变为「恢复到分类…」 +// +// 与 verify-batch-move-menu.mjs 同款 Playwright + stub window.api 套路。 + +import { chromium } from 'playwright-core'; +import { writeFileSync, mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +const BASE = 'http://127.0.0.1:8765'; +const tmpDir = mkdtempSync(join(tmpdir(), 'batch-tc-test-')); + +const DOC = [ + '# 当前任务', '', + '- [ ] 普通任务', '', + '# 全部任务', '', + '## 工作', '', + '- [ ] 工作任务 A', '', + '- [ ] 工作任务 B', '', + '- [▶] 当前工作任务', '', + '- [⭐] 重要工作任务', '', + '## 学习', '', + '- [ ] 学习任务 A', '', + '- [▶] 当前学习任务', '', + '- [⭐] 重要学习任务', '', + '## 未分类', '', + '- [ ] 杂事 A', '', + '# 已完成任务', '', + '- [✓] 工作任务 A(已完成)', '', + '- [✓] 学习任务 A(已完成)', '', + '- [✓] 工作任务 B(已完成)', '', + '# 回收站', '', + '- [ ] 删除的 A', '', + '- [✓] 已删且已完成的', '' +].join('\n'); + +async function setupStub(page, md) { + await page.addInitScript((md) => { + const noop = () => {}; + const noopUnsub = () => noop; + window.api = { + isDev: false, + saveFileDialog: async () => null, + readFile: async () => ({ ok: true, content: md }), + writeFile: async () => ({ ok: true }), + fileExists: async () => true, + createFileIfMissing: async () => ({ ok: true }), + createSnapshot: async () => ({ ok: true }), + getDataDir: async () => '/tmp', + getDefaultDataDir: async () => '/tmp', + getHomeDir: async () => '/tmp', + getVersion: async () => '0.0.0-test', + getElectronVersion: async () => '0.0.0-test', + openDataDir: async () => true, + getSettings: async () => ({}), + saveSettings: async () => true, + chooseDataDir: async () => null, + notifyDirtyChanged: noop, + notifyFlushDone: noop, + onFlushPendingSave: noopUnsub, + onMenuCommand: noopUnsub, + onFileExternalChange: noopUnsub, + setAlwaysOnTop: async () => true, + onAlwaysOnTopChanged: noopUnsub, + minimizeWindow: async () => true, + toggleMaximizeWindow: async () => false, + closeWindow: async () => true, + onMaximizeStateChanged: noopUnsub + }; + window.__todoMd = md; + }, md); +} + +async function clickSidebarCategory(page, categoryName) { + const result = await page.evaluate((name) => { + // 找 sidebar 里文本匹配的 category-item + const items = document.querySelectorAll('.category-item'); + for (const el of items) { + if (el.textContent.trim().startsWith(name)) { + el.click(); + return true; + } + } + return false; + }, categoryName); + if (!result) { + throw new Error(`未找到 sidebar 分类:${categoryName}`); + } + await page.waitForTimeout(300); +} + +async function enterBatchMode(page) { + await page.evaluate(() => { + const btn = document.querySelector('#btn-batch'); + if (btn && !btn.hidden) btn.click(); + }); + await page.waitForTimeout(200); +} + +async function selectAllVisible(page) { + await page.evaluate(() => { + // 选第一条任务 + const item = document.querySelector('.task-item'); + if (item) item.click(); + }); + await page.waitForTimeout(150); +} + +async function snapshotBar(page) { + return await page.evaluate(() => { + const bar = document.querySelector('.batch-action-bar'); + if (!bar) return { exists: false }; + const result = { exists: true }; + const collect = (sel) => { + const el = bar.querySelector(sel); + if (!el) return null; + const label = el.querySelector('span'); + return { + hidden: el.hidden, + disabled: el.disabled, + label: label ? label.textContent.trim() : '', + title: el.title + }; + }; + result.move = collect('.batch-action[data-action="move"]'); + result.top = collect('.batch-action[data-action="top"]'); + result.bottom = collect('.batch-action[data-action="bottom"]'); + result.importantSet = collect('.batch-action[data-action="important-set"]'); + result.importantUnset = collect('.batch-action[data-action="important-unset"]'); + result.currentSet = collect('.batch-action[data-action="current-set"]'); + result.currentUnset = collect('.batch-action[data-action="current-unset"]'); + result.complete = collect('.batch-action[data-action="complete"]'); + result.delete = collect('.batch-action[data-action="delete"]'); + return result; + }); +} + +async function runScenario({ label, sidebarCategory, expectBatchVisible, expectMove, expectDelete, expectCompleteLabel }) { + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1024, height: 768 } }); + const page = await context.newPage(); + + const errors = []; + page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`)); + page.on('console', (msg) => { + if (msg.type() === 'error') errors.push(`console.error: ${msg.text()}`); + }); + + await setupStub(page, DOC); + await page.goto(BASE + '/index.html'); + await page.waitForLoadState('domcontentloaded'); + await page.waitForTimeout(800); + + // 确认 app 已渲染 + const hasApp = await page.evaluate(() => !!document.getElementById('app')); + if (!hasApp) { + await browser.close(); + throw new Error(`[${label}] app 未渲染(#app 不存在)`); + } + + await clickSidebarCategory(page, sidebarCategory); + + // 验证批量按钮可见性 + const batchBtnInfo = await page.evaluate(() => { + const btn = document.querySelector('#btn-batch'); + if (!btn) return null; + return { hidden: btn.hidden, exists: true }; + }); + + if (!expectBatchVisible && batchBtnInfo && !batchBtnInfo.hidden) { + await browser.close(); + throw new Error(`[${label}] 期望 batch 按钮隐藏,但显示出来了`); + } + if (expectBatchVisible && batchBtnInfo && batchBtnInfo.hidden) { + await browser.close(); + throw new Error(`[${label}] 期望 batch 按钮可见,但被隐藏了`); + } + + await enterBatchMode(page); + await selectAllVisible(page); + + const bar = await snapshotBar(page); + + await browser.close(); + + return { label, sidebarCategory, batchBtnInfo, bar, errors }; +} + +console.log('=== 场景 1:「已完成任务」视图 ==='); +const r1 = await runScenario({ + label: 'completed-view', + sidebarCategory: '已完成任务', + expectBatchVisible: true, + expectMove: { hidden: true }, // 移到分类应隐藏 + expectDelete: { hidden: false, disabled: false }, // 移到回收站应可用 + expectCompleteLabel: '取消完成' +}); + +console.log(JSON.stringify(r1, null, 2)); + +console.log('\n=== 场景 2:「回收站」视图 ==='); +const r2 = await runScenario({ + label: 'trash-view', + sidebarCategory: '回收站', + expectBatchVisible: true, + expectMove: { hidden: false, label: '恢复到分类…' }, // 改成「恢复到分类…」 + expectDelete: { hidden: true }, // 移到回收站应隐藏 + expectCompleteLabel: '完成' +}); + +console.log(JSON.stringify(r2, null, 2)); + +console.log('\n=== 场景 3:「当前任务」视图 ==='); +const r3 = await runScenario({ + label: 'current-view', + sidebarCategory: '当前任务', + expectBatchVisible: true, + expectMove: { hidden: false }, // 移到分类应可用 + expectDelete: { hidden: false, disabled: false }, // 移到回收站应可用 + expectCompleteLabel: '完成' +}); + +console.log(JSON.stringify(r3, null, 2)); + +console.log('\n=== 场景 4:「重要任务」视图 ==='); +const r4 = await runScenario({ + label: 'important-view', + sidebarCategory: '重要任务', + expectBatchVisible: true, + expectMove: { hidden: false }, // 移到分类应可用 + expectDelete: { hidden: false, disabled: false }, // 移到回收站应可用 + expectCompleteLabel: '完成' +}); + +console.log(JSON.stringify(r4, null, 2)); + +console.log('\n=== 场景 5:「全部任务」视图 ==='); +const r5 = await runScenario({ + label: 'allTasks-view', + sidebarCategory: '全部任务', + expectBatchVisible: true, + expectMove: { hidden: false }, // 移到分类应可用 + expectDelete: { hidden: false, disabled: false }, // 移到回收站应可用 + expectCompleteLabel: '完成' +}); + +console.log(JSON.stringify(r5, null, 2)); + +// 验证 +function check(name, cond, info) { + const tag = cond ? '✓' : '✗'; + console.log(`${tag} ${name}${info ? ` (${info})` : ''}`); + return cond; +} + +console.log('\n========== 验证 =========='); +let pass = 0, fail = 0; + +// 场景 1:已完成视图 +console.log('\n[1] 「已完成任务」视图:批量按钮可见 + 按钮正确'); +{ + const bar = r1.bar; + if (check('batch 按钮可见', r1.batchBtnInfo && !r1.batchBtnInfo.hidden)) pass++; else fail++; + if (check('无 page error', r1.errors.length === 0, r1.errors.join('; '))) pass++; else fail++; + if (check('「移到分类」按钮隐藏(无意义)', + bar.move && bar.move.hidden === true, + JSON.stringify(bar.move))) pass++; else fail++; + if (check('「移到回收站」按钮显示且可用', + bar.delete && bar.delete.hidden === false && bar.delete.disabled === false, + JSON.stringify(bar.delete))) pass++; else fail++; + if (check('「完成」按钮 label = 「取消完成」', + bar.complete && bar.complete.label === '取消完成', + JSON.stringify(bar.complete))) pass++; else fail++; + if (check('「置顶」按钮禁用(智能列表)', + bar.top && bar.top.disabled === true, + JSON.stringify(bar.top))) pass++; else fail++; + if (check('「置底」按钮禁用(智能列表)', + bar.bottom && bar.bottom.disabled === true, + JSON.stringify(bar.bottom))) pass++; else fail++; +} + +// 场景 2:回收站视图 +console.log('\n[2] 「回收站」视图:批量按钮可见 + 按钮正确'); +{ + const bar = r2.bar; + if (check('batch 按钮可见', r2.batchBtnInfo && !r2.batchBtnInfo.hidden)) pass++; else fail++; + if (check('无 page error', r2.errors.length === 0, r2.errors.join('; '))) pass++; else fail++; + if (check('「移到分类」按钮显示,label = 「恢复到分类…」', + bar.move && bar.move.hidden === false && bar.move.label === '恢复到分类…', + JSON.stringify(bar.move))) pass++; else fail++; + if (check('「移到回收站」按钮隐藏(已在回收站)', + bar.delete && bar.delete.hidden === true, + JSON.stringify(bar.delete))) pass++; else fail++; + if (check('「完成」按钮 label = 「完成」(保留默认)', + bar.complete && bar.complete.label === '完成', + JSON.stringify(bar.complete))) pass++; else fail++; + if (check('「标重要」按钮可用', + bar.importantSet && bar.importantSet.disabled === false)) pass++; else fail++; + // 选中的任务无重要标记 → 「取消重要」应禁用(无意义) + if (check('「取消重要」按钮禁用(任务未标重要)', + bar.importantUnset && bar.importantUnset.disabled === true)) pass++; else fail++; + if (check('「标当前」按钮可用', + bar.currentSet && bar.currentSet.disabled === false)) pass++; else fail++; + // 选中的任务无当前标记 → 「取消当前」应禁用 + if (check('「取消当前」按钮禁用(任务未标当前)', + bar.currentUnset && bar.currentUnset.disabled === true)) pass++; else fail++; +} + +// 场景 3:当前任务视图 +console.log('\n[3] 「当前任务」视图:批量按钮可见 + 按钮正确'); +{ + const bar = r3.bar; + if (check('batch 按钮可见', r3.batchBtnInfo && !r3.batchBtnInfo.hidden)) pass++; else fail++; + if (check('无 page error', r3.errors.length === 0, r3.errors.join('; '))) pass++; else fail++; + if (check('「移到分类」按钮显示且可用', + bar.move && bar.move.hidden === false && bar.move.disabled === false, + JSON.stringify(bar.move))) pass++; else fail++; + if (check('「移到回收站」按钮显示且可用', + bar.delete && bar.delete.hidden === false && bar.delete.disabled === false, + JSON.stringify(bar.delete))) pass++; else fail++; + if (check('「完成」按钮 label = 「完成」', + bar.complete && bar.complete.label === '完成', + JSON.stringify(bar.complete))) pass++; else fail++; + if (check('「置顶」按钮禁用(智能列表跨分类)', + bar.top && bar.top.disabled === true, + JSON.stringify(bar.top))) pass++; else fail++; + if (check('「置底」按钮禁用(智能列表跨分类)', + bar.bottom && bar.bottom.disabled === true, + JSON.stringify(bar.bottom))) pass++; else fail++; +} + +// 场景 4:重要任务视图 +console.log('\n[4] 「重要任务」视图:批量按钮可见 + 按钮正确'); +{ + const bar = r4.bar; + if (check('batch 按钮可见', r4.batchBtnInfo && !r4.batchBtnInfo.hidden)) pass++; else fail++; + if (check('无 page error', r4.errors.length === 0, r4.errors.join('; '))) pass++; else fail++; + if (check('「移到分类」按钮显示且可用', + bar.move && bar.move.hidden === false && bar.move.disabled === false, + JSON.stringify(bar.move))) pass++; else fail++; + if (check('「移到回收站」按钮显示且可用', + bar.delete && bar.delete.hidden === false && bar.delete.disabled === false, + JSON.stringify(bar.delete))) pass++; else fail++; + if (check('「置顶」按钮禁用(智能列表跨分类)', + bar.top && bar.top.disabled === true, + JSON.stringify(bar.top))) pass++; else fail++; + if (check('「置底」按钮禁用(智能列表跨分类)', + bar.bottom && bar.bottom.disabled === true, + JSON.stringify(bar.bottom))) pass++; else fail++; +} + +// 场景 5:全部任务视图 +console.log('\n[5] 「全部任务」视图:批量按钮可见 + 按钮正确'); +{ + const bar = r5.bar; + if (check('batch 按钮可见', r5.batchBtnInfo && !r5.batchBtnInfo.hidden)) pass++; else fail++; + if (check('无 page error', r5.errors.length === 0, r5.errors.join('; '))) pass++; else fail++; + if (check('「移到分类」按钮显示且可用', + bar.move && bar.move.hidden === false && bar.move.disabled === false, + JSON.stringify(bar.move))) pass++; else fail++; + if (check('「移到回收站」按钮显示且可用', + bar.delete && bar.delete.hidden === false && bar.delete.disabled === false, + JSON.stringify(bar.delete))) pass++; else fail++; + if (check('「置顶」按钮禁用(智能列表跨分类)', + bar.top && bar.top.disabled === true, + JSON.stringify(bar.top))) pass++; else fail++; + if (check('「置底」按钮禁用(智能列表跨分类)', + bar.bottom && bar.bottom.disabled === true, + JSON.stringify(bar.bottom))) pass++; else fail++; +} + +console.log(`\n==========\n通过 ${pass} / 失败 ${fail}\n==========`); +rmSync(tmpDir, { recursive: true, force: true }); +process.exit(fail === 0 ? 0 : 1); diff --git a/scripts/verify-reload-ui.mjs b/scripts/verify-reload-ui.mjs new file mode 100644 index 0000000..df824b9 --- /dev/null +++ b/scripts/verify-reload-ui.mjs @@ -0,0 +1,238 @@ +// 模拟用户场景:外部修改 → resolveExternalChangeConflict → loadFromContent → UI 更新 +// +// 真实测试:插入任务到「## 工作」分类下(保持子分类结构) + +import { readFileSync } from 'node:fs'; +import { parseMarkdown } from '../src/markdown-parser.js'; +import { writeMarkdown } from '../src/markdown-writer.js'; +import { pathsEqual } from '../src/utils/path.js'; +import { findTodoFile } from './_lib/find-todo-path.mjs'; +import { check, summary, printSummary } from './_lib/check.mjs'; + +class MockStore { + constructor(initialContent, initialPath) { + this.categories = parseMarkdown(initialContent); + this.filePath = initialPath; + this.selectedCategoryName = '工作'; + this.selectedSmartList = null; + this.dirty = false; + this._suppressDirty = false; + this.events = []; + this._listeners = {}; + } + on(event, listener) { + if (!this._listeners[event]) this._listeners[event] = []; + this._listeners[event].push(listener); + return () => { this._listeners[event] = this._listeners[event].filter(l => l !== listener); }; + } + emit(event, ...args) { + this.events.push({ event, args }); + (this._listeners[event] || []).forEach(l => l(...args)); + } + serialize() { return writeMarkdown(this.categories); } + cancelPendingSave() {} + loadFromContent(content, filePath = null) { + this._suppressDirty = true; + try { + const previousSelected = this.selectedCategoryName; + const previousSmart = this.selectedSmartList; + this.categories = parseMarkdown(content); + this.filePath = filePath; + const stillExists = previousSelected && this.categories.some(c => c.name === previousSelected); + if (previousSmart) { + this.selectedSmartList = previousSmart; + } else if (stillExists) { + this.selectedCategoryName = previousSelected; + this.selectedSmartList = null; + } else { + this.selectedSmartList = 'current'; + this.selectedCategoryName = null; + } + this.dirty = false; + this.emit('file-path', this.filePath); + this.emit('load', { categories: this.categories }); + this.emit('change'); + } finally { + this._suppressDirty = false; + } + this.emit('dirty', false); + } +} + +// 优先用用户的真实 todo.md 做端到端验证,缺文件则用内置样例 +// 路径解析统一走 scripts/_lib/find-todo-path.mjs,避免硬编码用户路径 +const found = findTodoFile(); +const todoPath = found ? found.path : null; +const SAMPLE_MD = `# 全部任务 + +## 工作 + +- [ ] 工作任务 A +- [ ] 工作任务 B +- [✓] 已完成的工作任务 + +## 学习 + +- [ ] 学习任务 A +- [ ] 学习任务 B + +## 未分类 + +- [ ] 未分类任务 +`; +let original; +if (found) { + original = found.content; + console.log(`[verify-reload-ui] 使用用户文件 ${todoPath}(${original.length} 字符)`); +} else { + original = SAMPLE_MD; + console.log('[verify-reload-ui] 找不到用户 todo.md,回退到内置样例(不写回磁盘)'); +} +// 统一占位路径:用户路径缺失时用一个固定的虚拟路径给 MockStore, +// 后续断言只用它做字符串比对(pathsEqual),具体值不影响测试语义 +const mockFilePath = todoPath || `${process.platform === 'win32' ? 'C:\\TodoList' : '/tmp'}/todo.md`; + +console.log('\n[场景 A] 在第一个子分类下追加任务(用户最常见的 VSCode 编辑场景)'); + +const store = new MockStore(original, mockFilePath); + +// 选第一个「有任务」的子分类做目标(c.tasks 是数组,空数组也是 truthy,必须明确判 length) +let targetSub = store.categories.find(c => + c.kind === 'normal' && c.parentOtherTasks && c.tasks.length > 0); +let targetName = targetSub ? targetSub.name : '工作'; +store.selectedCategoryName = targetName; + +// 用户文件所有子分类都空时,「插入到目标段」毫无意义 —— 切回 SAMPLE_MD 跑。 +// SAMPLE 里有「工作」「学习」「未分类」三个有任务的子分类,下面的段插入测试才有信号。 +// 必须把 store 也用 SAMPLE_MD 重载 —— 不然 store.categories 还是用户文件的,目标段 +// 名(如「工作」)在用户分类里根本不存在,find() 返回 undefined,后续断言全挂。 +if (original !== SAMPLE_MD && !targetSub) { + console.log('[setup] 用户文件没有含任务的子分类,切回 SAMPLE_MD 以保留测试信号'); + original = SAMPLE_MD; + store.loadFromContent(original, mockFilePath); + targetSub = store.categories.find(c => + c.kind === 'normal' && c.parentOtherTasks && c.tasks.length > 0); + targetName = targetSub ? targetSub.name : '工作'; + store.selectedCategoryName = targetName; +} + +// 找 targetName 这一 h2 段:起点 = `## {targetName}`,终点 = 下一个 `\n## ` 或 `\n# ` +// (h2 之后的下一个 h1 也会终止本段;用户文件里学习子分类后是 `# 已完成任务`,必须兼容) +const targetHeaderIdx = original.indexOf(`## ${targetName}`); +if (targetHeaderIdx < 0) { + console.log(` ⚠️ 找不到 ## ${targetName} 段,跳过场景 A`); +} else { + const h2 = original.indexOf('\n## ', targetHeaderIdx); + const h1 = original.indexOf('\n# ', targetHeaderIdx); + // 取最近的下个标题作为段落终点;都没有就贴文件尾 —— 必须落在目标段内 + let workInsertPoint; + if (h2 > 0 && h1 > 0) workInsertPoint = Math.min(h2, h1); + else if (h2 > 0) workInsertPoint = h2; + else if (h1 > 0) workInsertPoint = h1; + else workInsertPoint = original.length; + + const modified = original.slice(0, workInsertPoint) + + `\n- [ ] 外部添加的新任务 A1\n` + + `- [✓] 外部添加的已完成 B2 [⭐]\n` + + original.slice(workInsertPoint); + console.log(`原文件 ${original.length} 字符 → 修改后 ${modified.length} 字符(修改段:${targetName})`); + + // 模拟主进程 IPC 发来的 changedPath(反斜杠) + // mockFilePath 在 win32 上已用反斜杠;正反斜杠等价仍由 pathsEqual 覆盖 + const ipcChangedPath = mockFilePath.replace(/\//g, '\\'); + check('pathsEqual 通过(正反斜杠等价)', pathsEqual(ipcChangedPath, store.filePath)); + check('文件内容确实与内存不同', modified !== store.serialize()); + + // 调用 loadFromContent + const beforeWorkTasks = store.categories.find(c => c.name === targetName).tasks.length; + store.loadFromContent(modified, ipcChangedPath); + const afterWorkTasks = store.categories.find(c => c.name === targetName).tasks.length; + + console.log(`「${targetName}」任务数: ${beforeWorkTasks} → ${afterWorkTasks}`); + check(`重载后「${targetName}」任务数增加`, afterWorkTasks === beforeWorkTasks + 2, + `${beforeWorkTasks} → ${afterWorkTasks}`); + const newWorkCat = store.categories.find(c => c.name === targetName); + check(`重载后「${targetName}」包含 A1 任务`, + newWorkCat.tasks.some(t => t.text === '外部添加的新任务 A1')); + check(`重载后「${targetName}」包含 B2 任务`, + newWorkCat.tasks.some(t => t.text === '外部添加的已完成 B2')); + check('重载后 A1 是 pending', + newWorkCat.tasks.find(t => t.text === '外部添加的新任务 A1')?.completed === false); + check('重载后 B2 是 completed', + newWorkCat.tasks.find(t => t.text === '外部添加的已完成 B2')?.completed === true); + check('重载后 B2 是 important', + newWorkCat.tasks.find(t => t.text === '外部添加的已完成 B2')?.important === true); + check('change 事件触发', store.events.some(e => e.event === 'change')); + check('load 事件触发', store.events.some(e => e.event === 'load')); + check(`selectedCategoryName 保持「${targetName}」`, store.selectedCategoryName === targetName); +} + +console.log('\n[场景 B] CRLF 行尾处理(VSCode Windows 默认保存 CRLF)'); + +const crlf = original.replace(/\n/g, '\r\n'); +const storeB = new MockStore(original, mockFilePath); +{ + // 同样的「段终点」兜底 —— 必须落在目标段内,否则任务会跑到已完成/回收站去 + const targetHeaderIdxB = crlf.indexOf(`\r\n## ${targetName}`); + let workInsertPointB; + if (targetHeaderIdxB < 0) { + workInsertPointB = crlf.length; + } else { + const h2b = crlf.indexOf('\r\n## ', targetHeaderIdxB + 2); + const h1b = crlf.indexOf('\r\n# ', targetHeaderIdxB + 2); + if (h2b > 0 && h1b > 0) workInsertPointB = Math.min(h2b, h1b); + else if (h2b > 0) workInsertPointB = h2b; + else if (h1b > 0) workInsertPointB = h1b; + else workInsertPointB = crlf.length; + } + const modifiedB = crlf.slice(0, workInsertPointB) + + `\r\n- [ ] CRLF 测试任务\r\n` + + crlf.slice(workInsertPointB); + + const parsed = parseMarkdown(modifiedB).find(c => c.name === targetName); + check(`parseMarkdown 容忍 CRLF(${targetName})`, + parsed && parsed.tasks.some(t => t.text === 'CRLF 测试任务')); +} + +console.log('\n[场景 C] 选中分类被外部删除'); + +const storeC = new MockStore(original, mockFilePath); +storeC.selectedCategoryName = targetName; +// 删掉 `## ${targetName}` 整段:向后看必须兼顾下一个 h2 或下一个 h1 +// (用户文件里最后一个子分类后可能直接接 # 已完成任务 / # 回收站,没有 `\n## ` 也得删干净) +// 同时必须删干净**所有同名 h2**(如 `## 未分类` 双写)—— 不然剩下那一份会 +// 兜住 selectedCategoryName,让"分类被删回退到 current"的断言看起来没过, +// 但实际是产品行为正确:内存里那个分类没被真正删掉。 +const escName = targetName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +let removedWork = original.replace( + new RegExp('## ' + escName + '[\\s\\S]*?(?=\\n## |\\n# |$)', 'g'), + '' +); +if (removedWork === original) { + // 兜底:上面的正则在某些边界条件下没命中,改用 slice 整段切走。 + // 同样需要清掉所有同名 h2 —— 反复 slice 直到 indexOf 找不到为止。 + let working = original; + let startIdx = working.indexOf('## ' + targetName); + while (startIdx >= 0) { + let endIdx = -1; + const after = startIdx + ('## ' + targetName).length + 1; + const nextH1 = working.indexOf('\n# ', after); + const nextH2 = working.indexOf('\n## ', after); + if (nextH1 > 0 && nextH2 > 0) endIdx = Math.min(nextH1, nextH2); + else if (nextH1 > 0) endIdx = nextH1; + else if (nextH2 > 0) endIdx = nextH2; + else endIdx = working.length; + working = working.slice(0, startIdx) + working.slice(endIdx); + startIdx = working.indexOf('## ' + targetName); + } + removedWork = working; +} +storeC.loadFromContent(removedWork, mockFilePath); +check('分类被删后回退到 current 智能视图', + storeC.selectedSmartList === 'current', + `selectedSmartList=${storeC.selectedSmartList}, selectedCategoryName=${storeC.selectedCategoryName}`); +check('分类被删后 selectedCategoryName 清空', + storeC.selectedCategoryName === null); + +console.log(`\n通过 ${summary.pass} / 失败 ${summary.fail}`); +process.exit(summary.fail === 0 ? 0 : 1); \ No newline at end of file diff --git a/scripts/verify-todo-end-to-end.mjs b/scripts/verify-todo-end-to-end.mjs new file mode 100644 index 0000000..7dac974 --- /dev/null +++ b/scripts/verify-todo-end-to-end.mjs @@ -0,0 +1,112 @@ +// 端到端验证:用真实 todo.md 验证往返后任务总数与分类结构守恒。 +// +// v3.6 起 todo.md 不再含顶部「# 当前任务」/「# 重要任务」镜像段;智能视图在内存中 +// 按 task.current / task.important 聚合,源任务行只活在「全部任务」容器的子分类下。 +// +// 本脚本验证: +// 1. 加载当前 todo.md(或内置样例,缺文件时兜底) +// 2. 解析 + 重新序列化 + 再次解析 +// 3. 任务总数与每个分类的任务数完全一致(不丢、不翻倍) +// +// 用法:node scripts/verify-todo-end-to-end.mjs +// 路径查找顺序(避免在别人机器 / CI 上硬挂): +// 1) --file 命令行参数(CI / 自定义) +// 2) TODO_LIST_FILE 环境变量 +// 3) 当前用户的真实 todo.md(~/TodoList/todo.md,跨平台一致) +// 4) 内置 SAMPLE_MD —— 跑端到端往返但**不**回写,避免污染未知位置的文件 +// 脚本会备份原文件到 .e2e.bak,再把重新序列化结果写回,方便人工 diff。 + +import { readFileSync, writeFileSync, copyFileSync } from 'node:fs'; +import { parseMarkdown } from '../src/markdown-parser.js'; +import { writeMarkdown } from '../src/markdown-writer.js'; +import { check, summary, printSummary } from './_lib/check.mjs'; + +// 内置样例 —— 文件不在 / 无法定位时兜底,确保脚本在任意机器上都能跑通 +const SAMPLE_MD = `# 全部任务 + +## 工作 + +- [ ] 工作任务 A +- [ ] 工作任务 B +- [✓] 已完成的工作任务 + +## 学习 + +- [ ] 学习任务 A +- [ ] 学习任务 B + +## 未分类 + +- [ ] 未分类任务 + +# 已完成任务 + +- [✓] 单独的已完成任务 +`; + +// 找用户的真实 todo.md —— 跨平台 + 避坑非 ASCII 路径 +// 已抽到 scripts/_lib/find-todo-path.mjs,这里 import 复用 +import { findTodoFile } from './_lib/find-todo-path.mjs'; + +let todoPath, original, isSample; +const found = findTodoFile(); +if (found) { + todoPath = found.path; + original = found.content; + isSample = false; +} else { + console.log('[verify-todo-end-to-end] 找不到用户 todo.md,回退到内置样例(不写回磁盘)'); + todoPath = null; + original = SAMPLE_MD; + isSample = true; +} + +if (!isSample) { + const backupPath = todoPath + '.e2e.bak'; + copyFileSync(todoPath, backupPath); + console.log(`已备份原文件到 ${backupPath}(共 ${original.length} 字符)`); +} else { + const sampleLineCount = SAMPLE_MD.split('\n').filter(Boolean).length; + console.log(`(样例模式:${sampleLineCount} 行内置内容,仅跑往返不落盘)`); +} + +const cats1 = parseMarkdown(original); +const beforeTotal = cats1.reduce((n, c) => n + c.tasks.length, 0); +console.log(`\n解析结果:${cats1.length} 个分类,${beforeTotal} 条任务`); + +for (const c of cats1) { + console.log(` · ${c.name}:${c.tasks.length} 条`); +} + +const md2 = writeMarkdown(cats1); +const cats2 = parseMarkdown(md2); +const afterTotal = cats2.reduce((n, c) => n + c.tasks.length, 0); + +console.log(`\n重新序列化:${md2.length} 字符`); +console.log(`往返后任务总数:${beforeTotal} → ${afterTotal}(差 ${afterTotal - beforeTotal})`); + +console.log('\n[断言]'); +check('往返后任务总数不变', beforeTotal === afterTotal, `${beforeTotal} → ${afterTotal}`); + +// 每个分类的任务数也应一一对应(按 name 匹配;不存在的分类允许新增空分类) +const before = new Map(cats1.map(c => [c.name, c.tasks.length])); +const after = new Map(cats2.map(c => [c.name, c.tasks.length])); +let mismatched = 0; +for (const [name, count] of before) { + const a = after.get(name); + if (a !== count) { + mismatched++; + console.log(` ✗ 分类「${name}」:${count} → ${a}`); + } +} +check('每个分类的任务数也守恒', mismatched === 0); + +// 写回文件(让用户能看到效果);样例模式不落盘避免污染未知位置 +if (!isSample && todoPath) { + writeFileSync(todoPath, md2, 'utf8'); + console.log(`\n已写回文件 ${todoPath}(共 ${md2.length} 字符)`); +} else { + console.log(`\n(样例模式:往返结果未落盘)`); +} +console.log(`\n${'─'.repeat(48)}\n通过 ${summary.pass} / 失败 ${summary.fail}`); +process.exit(summary.fail === 0 ? 0 : 1); \ No newline at end of file diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..b4aa64a --- /dev/null +++ b/src/app.js @@ -0,0 +1,1276 @@ +// 应用主入口 - 初始化所有模块并连接事件 + +import { TaskStore } from './task-store.js'; +import { SettingsStore } from './settings-store.js'; +import { Sidebar } from './ui/sidebar.js'; +import { TaskList } from './ui/task-list.js'; +import { Toolbar } from './ui/toolbar.js'; +import { openCommandPalette } from './ui/command-palette.js'; +import { toast, confirmDialog, inputDialog } from './ui/feedback.js'; +import { openSettingsDialog } from './ui/settings-dialog.js'; +import { openConflictDialog } from './ui/conflict-dialog.js'; +import { parseMarkdown } from './markdown-parser.js'; +import { shortenPath } from './utils/dom.js'; +import { joinPath, pathsEqual } from './utils/path.js'; +import { Filter, SortBy } from './task-store.js'; +import { CategoryKind } from './markdown-parser.js'; + +// ============================================ +// 启动检查 +// ============================================ + +// bootstrap 阶段的 [renderer] 调试日志 —— 与 syncLog 同款:仅 dev 模式打印, +// 生产环境静默。开发时打开 DevTools 能看到完整启动流程(设置/数据目录/默认文件 +// 路径/存在性),便于排查「为什么数据目录不对」「为什么没读出文件」这类问题; +// 生产环境这些细节对用户无意义,控制台干净点更好。 +function rendererLog(...args) { + if (!window.api?.isDev) return; + console.log('[renderer]', ...args); +} + +// 检查 window.api 是否已注入(preload.js 必须已运行) +if (!window.api || typeof window.api.getDataDir !== 'function') { + const errorHtml = ` +
    +

    ⚠ 初始化失败

    +

    preload.js 未正确加载,window.api 不可用。

    +

    可能的原因:

    +
      +
    • Electron 版本过低,不支持当前 preload 配置
    • +
    • preload.js 路径配置错误
    • +
    • 安全策略(contextIsolation)配置错误
    • +
    +

    请尝试重启应用,或运行 npm install 后重试。

    +
    + `; + document.body.innerHTML = errorHtml; + throw new Error('window.api not available'); +} + +// ============================================ +// 初始化 +// ============================================ + +const settingsStore = new SettingsStore(); +// 把 settingsStore 注入 TaskStore —— 让 _appendTaskWithPosition 能读到 +// newTaskPosition / completedPosition / trashPosition 偏好。 +// 不注入时 TaskStore 默认按 'front' 走(与 DEFAULT_SETTINGS 对齐), +// 但生产环境里 settingsStore 永远先创建出来,所以这里必须传,避免新加任务时 +// 用错落点(追加到末尾而不是最新在最上)。 +const store = new TaskStore({ settingsStore }); + +// H4 修复:全局未处理 IPC 拒绝兜底 —— 之前 settings-dialog / toolbar / sidebar +// 里大量 `await window.api.xxx().catch(console.warn)` 只打日志、不通知用户, +// 用户看到按钮「点了没反应」却无从知晓是 IPC 通道断了。 +// 这里装一个 unhandledrejection 监听,吞掉那些没被业务方处理的拒绝,转成 toast +// 让用户至少知道「刚才那个动作失败了」。业务方已经在 catch 里 toast 的不会冒到这里。 +if (typeof window !== 'undefined' && !window.__todoUnhandledRejectionInstalled) { + window.__todoUnhandledRejectionInstalled = true; + window.addEventListener('unhandledrejection', (e) => { + const reason = e && e.reason; + const text = reason && (reason.message || reason.code || String(reason)); + // 过滤明显可忽略的拒绝(用户主动取消的对话框等) + if (!text || /abort/i.test(text) || /user cancelled/i.test(text)) return; + rendererLog('unhandledrejection:', text); + toast(`操作失败:${text}`, 'error', 3000); + }); +} + +// bootstrap 幂等守卫:模块可能因 HMR / devtools 重载被多次执行, +// 而 window.api.onXxx 返回的解绑函数此前从未被持有 —— 每次重跑都会再叠加 +// 一份监听器,导致 'menu:save' 一类事件被处理两次(双重保存、双重 toast)。 +let bootstrapped = false; + +// ============================================ +// 启动流程 +// ============================================ + + +// 模块级:bootstrap 注册的所有监听器的取消函数。 +// 生产单例里不会被消费 —— renderer 进程与窗口同生死,window unload 时整个进程销毁, +// 不存在"跨生命周期需要解绑"的场景。Electron renderer 默认也没有 HMR(devtools +// reload 会重启整个进程)。这里只是统一收集 cancel 函数,**避免 HMR / 单元测试场景 +// 后续真的有需要时再补 cleanup**。当前实现下,bootstrapUnsubs 是单纯 reference +// 池 —— push 而不读 —— 不会泄漏。 +const bootstrapUnsubs = []; + +/** + * 取消 bootstrap 注册的所有监听器。仅供 hot-reload / 测试场景使用。 + * - 直接调每个 cancel 函数,不存在「取消一半」状态 —— 任意顺序都安全。 + * - cancel 后把池清零,再调 bootstrap() 会重新注册(重新走 reset 分支)。 + */ +export function disposeBootstrap() { + while (bootstrapUnsubs.length) { + const fn = bootstrapUnsubs.pop(); + try { fn(); } catch (e) { console.error('[renderer] bootstrap dispose 异常:', e); } + } + bootstrapped = false; +} + +async function bootstrap() { + if (bootstrapped) { + console.warn('[renderer] bootstrap 已在执行,跳过重复调用'); + return; + } + bootstrapped = true; + rendererLog('bootstrap 开始'); + + // 跟踪所有 bootstrap 注册的 store / IPC / DOM 监听器的取消函数。 + // 生产单例生命周期里不会被消费(renderer 与窗口同生死), + // 但保留在 cleanupBootstrap() 给将来 hot-reload / 单元测试使用。 + // 直接调 cancel 即可,不存在「取消一半」状态 —— 任何顺序都能保证 listener 不再被触发。 + bootstrapUnsubs.length = 0; // 重置(万一 bootstrap 被以某种方式二次调用) + + try { + // 加载设置(用户配置) + await settingsStore.load(); + const settings = settingsStore.all; + rendererLog('设置:', settings); + + // 应用主题 + const theme = settings.theme || 'dark'; + store.theme = theme; + document.body.dataset.theme = store.resolveTheme(); + + // 应用字体大小 + document.body.dataset.fontSize = settings.fontSize || 'medium'; + + // 应用配色风格(与 theme 正交 —— 由 CSS 复合选择器解析,body 上只要一个 data 属性即可) + document.body.dataset.colorStyle = settings.colorStyle || 'indigo'; + + // 恢复全局过滤 / 排序偏好(在 UI 组件实例化之前设置,避免一次空渲染) + // 这里直接赋值即可:setFilter/setSortBy 早于 UI 订阅,没有 UI 副作用 + if (Object.values(Filter).includes(settings.filter)) { + store.filter = settings.filter; + } + if (Object.values(SortBy).includes(settings.sortBy)) { + store.sortBy = settings.sortBy; + } + // 将后续 setFilter/setSortBy 的变更同步持久化(用户切换偏好后立即写入设置) + bootstrapUnsubs.push(store.on('filter', (filter) => settingsStore.update({ filter }))); + bootstrapUnsubs.push(store.on('sort', (sortBy) => settingsStore.update({ sortBy }))); + + // 监听设置变化(字体大小实时生效) + bootstrapUnsubs.push(settingsStore.on('change', ({ changed }) => { + if (changed.includes('fontSize')) { + document.body.dataset.fontSize = settingsStore.get('fontSize'); + } + // 配色风格由 settingsStore → body data 属性 → CSS 复合选择器解析; + // 与 theme 一样只写一个属性即可,无需触发额外 JS 重新渲染 + if (changed.includes('colorStyle')) { + document.body.dataset.colorStyle = settingsStore.get('colorStyle'); + } + })); + + // v4+ 修复:**必须**在任何 file:read / file:write IPC 之前注册 file:external-change 监听。 + // 旧实现把这条 listener 注册放在 bootstrap 后段(line 289+)—— readFile 调用 main 进程 + // 立刻 startWatchingFile(main.js:902),但渲染端还没注册 listener,期间云同步 / 外部编辑器 + // 修改 todo.md 触发的 IPC 全部被吞,用户启动后看到的就是旧内容、也不会弹冲突对话框。 + // resolveExternalChangeConflict 自身已有 `!pathsEqual(...) return` 守卫(store.filePath + // 未绑定前直接 return),所以这里可以安全地在 filePath 尚未赋值时就注册。 + bootstrapUnsubs.push(window.api.onFileExternalChange((payload) => { + // 兼容旧实现可能仍然只发字符串 + const filePath = typeof payload === 'string' ? payload : payload?.filePath; + const diskContent = typeof payload === 'object' ? payload?.diskContent : null; + resolveExternalChangeConflict(filePath, { diskContent }); + })); + + // 决定初始文件(根据用户配置的数据目录) + const dataDir = await window.api.getDataDir(); + rendererLog('数据目录:', dataDir); + + const defaultPath = joinPath(dataDir, 'todo.md'); + rendererLog('默认文件路径:', defaultPath); + + let filePath = null; + let content = null; + // 文件存在但读取失败 —— 必须与"文件不存在"区分开: + // 前者绝不能写入初始文档,否则会覆盖掉读不出来的用户数据 + let readFailed = false; + + // 尝试加载默认文件 + try { + const exists = await window.api.fileExists(defaultPath); + rendererLog('默认文件存在:', exists); + + if (exists) { + const result = await window.api.readFile(defaultPath); + rendererLog('默认文件存在:', exists, '→ 读取结果:', result.ok); + if (result.ok) { + filePath = defaultPath; + content = result.content; + } else { + readFailed = true; + rendererLog('读取默认文件失败:', result.error); + } + } + } catch (e) { + readFailed = true; + rendererLog('检查默认文件出错:', e.message); + } + + // 加载到 Store + if (content !== null) { + // C1 修复:解析失败时自动回退到 .bak —— 旧实现只在 readFailed 路径报错, + // 但 parseMarkdown 在「读成功 + 解析为空数组」或「解析抛错」路径上仍会让 + // 后续 autoSave 把损坏内容写回磁盘(覆盖掉可恢复的 .bak)。 + // 先尝试直接解析;失败 / 内容为空 → 退回 .bak;.bak 也失败才进入临时模式。 + try { + store.loadFromContent(content, filePath); + // 双重保险:parseMarkdown 返回空数组(极少但发生过:文件被外部工具清空、 + // 解析器漏抛)。空数组 + _ensureBaseStructure 会塞回默认 4 个子分类, + // 下一次 save 直接覆盖磁盘。 + const cats = store.categories; + const looksLegit = Array.isArray(cats) && cats.some(c => + c.tasks && c.tasks.length > 0); + if (!looksLegit) { + rendererLog('loadFromContent 后无任务,疑似解析失败 —— 尝试 .bak 回退'); + const bak = await tryReadBak(filePath); + if (bak) { + store.loadFromContent(bak, filePath); + toast(`主文件疑似损坏,已从备份恢复`, 'warning', 4000); + } else { + toast(`主文件为空且无备份,进入临时模式`, 'warning', 4000); + store.loadDefault(null); + } + } else { + toast(`已加载:${shortenPath(filePath)}`, 'success', 1500); + } + } catch (e) { + rendererLog('loadFromContent 抛错,尝试 .bak 回退:', e.message); + const bak = await tryReadBak(filePath); + if (bak) { + store.loadFromContent(bak, filePath); + toast(`主文件解析失败,已从备份恢复:${e.code || ''} ${e.message}`, 'warning', 4000); + } else { + store.loadDefault(null); + toast(`主文件解析失败且无备份:${e.code || ''} ${e.message}`, 'error', 6000); + } + } + } else if (readFailed) { + // 读不出来(磁盘错误 / 权限拒绝) —— 尝试 .bak 回退;都没有才进入临时模式 + const bak = await tryReadBak(filePath || defaultPath); + if (bak) { + store.loadFromContent(bak, defaultPath); + toast(`无法读取主文件,已从备份恢复`, 'warning', 4000); + } else { + // 只读入内存、不绑定路径,避免任何写入动作碰到那个文件 + store.loadDefault(null); + toast(`无法读取 ${shortenPath(defaultPath)},已进入临时模式。请检查该文件后重启,勿在此状态下保存`, 'error', 8000); + } + } else { + // 确认不存在:写出一份初始文档,避免数据只存在于内存中 + await createDefaultFile(defaultPath, `已创建: ${shortenPath(defaultPath)}`); + } + + // 初始化 UI 组件 + const toolbar = new Toolbar(store, settingsStore); + const sidebar = new Sidebar(store); + const taskList = new TaskList(store, { settingsStore }); + + // 状态栏 + bootstrapUnsubs.push(setupStatusBar()); + + // 监听 Store 状态变化 + // 职责划分: + // #status-text = 进度统计(由 setupStatusBar() 写),它已挂 'change' / 'load' + // #status-meta = 保存状态:未保存 / 已同步 HH:MM + // (CSS ::before 已提供 6px 状态点,dirty/saved 切换颜色, + // 不要在文本里再加 ●/✓ 否则会和圆点重叠) + // 旧实现:dirty 写 #status-meta,saved 写 #status-text,但 setupStatusBar 又把 + // 统计塞回 #status-meta —— 三方争抢同一格,渲染顺序决定谁最终胜出。 + // + // 保存成功的事件顺序:emit('saved') → emit('dirty', false)。 + // 因此 dirty(false) 处理器不能再写 textContent='',否则会立刻把刚刚 + // 写好的「已同步 HH:MM」时间戳抹掉 —— 用户看到的现象是「Ctrl+S 之后 + // 状态栏空空的,好像没保存过」。 + bootstrapUnsubs.push(store.on('dirty', (isDirty) => { + const meta = document.getElementById('status-meta'); + if (isDirty) { + // 只有进入「有未保存修改」时才覆写文本;离开时不动文本, + // 把「已同步 HH:MM」留给 saved 处理器去维护 + meta.textContent = '未保存'; + } + meta.classList.toggle('dirty', isDirty); + meta.classList.toggle('saved', !isDirty); + // 通知主进程 dirty 状态变化,用于退出时判断是否需要刷写保存 + window.api.notifyDirtyChanged(isDirty); + })); + bootstrapUnsubs.push(store.on('saved', () => { + const meta = document.getElementById('status-meta'); + const t = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); + meta.textContent = `已同步 ${t}`; + meta.classList.remove('dirty'); + meta.classList.add('saved'); + })); + // 重新加载文件后清掉旧的保存状态提示 —— loadFromContent / loadDefault + // 内部 _suppressDirty=true 期间不会 emit dirty,外部修改监听也走这里 + // 重置状态栏;不重置会让上一秒保存的「已同步 14:32」看上去像新加载 + // 出来的内容也是「已同步」状态,误导用户 + bootstrapUnsubs.push(store.on('load', () => { + const meta = document.getElementById('status-meta'); + meta.textContent = ''; + meta.classList.remove('dirty'); + meta.classList.remove('saved'); + })); + + // 保存失败:把错误抛给用户(之前完全无反馈,磁盘满/权限问题会让用户误以为 + // 「未保存」只是延迟)。EXTERNAL_CHANGE_DETECTED 由主进程 mtime 检查触发: + // 自动保存试图覆盖一个被外部动过的文件 —— 这正是数据丢失防护的核心触发点, + // 必须走冲突解决流程,而不是简单地 toast 一行「保存失败」就丢弃用户的修改。 + // + // v3.4+:save-error 载荷升级为 {code, message, diskContent} 对象(见 task-store._buildSaveErrorPayload)。 + bootstrapUnsubs.push(store.on('save-error', (errorInfo) => { + // 兼容旧实现可能仍然发字符串(防御性) + const code = typeof errorInfo === 'string' ? errorInfo : errorInfo?.code; + const message = typeof errorInfo === 'string' + ? errorInfo + : (errorInfo?.message || errorInfo?.code || '未知错误'); + const diskContent = typeof errorInfo === 'object' ? errorInfo?.diskContent : null; + if (code === 'EXTERNAL_CHANGE_DETECTED') { + // 防御性:save-error 触发时 store.filePath 应已绑定,但理论上未关联文件 + // (loadDefault(null) 模式)下走 autoSave 的极端路径也可能发出此事件。 + // 这种场景 pathsEqual(null, null) 会短路通过(a===b),随即 readFile(null) + // 在主进程抛错 —— 提前拦截并 toast 跳过。 + if (!store.filePath) { + toast('检测到外部修改冲突,但当前未关联文件 —— 已跳过自动保存', 'warning', 4000); + return; + } + // 主进程已经塞了 diskContent —— 不用再发 file:read + syncLog('autoSave-race-blocked', { filePath: store.filePath }); + resolveExternalChangeConflict(store.filePath, { diskContent, source: 'save-error' }); + } else { + toast(`保存失败:${message}`, 'error', 4000); + } + })); + + // 退出前刷写:主进程在 before-quit 时发送此消息,渲染进程立即保存 + // 保存完成后通知主进程,主进程随即真正退出(避免等待 5 秒超时) + // + // 多轮 saveNow 直到真的没有 dirty —— 防止「await saveNow 期间用户又改了」: + // saveNow 内部用 _dirtyVersion 在 await 后比对,若检测到中途改动会保留 + // dirty=true 让后续 autoSave 兜底,但 quit 流程下没有「后续」,必须自己再写。 + // 上限 5 轮:用户一直狂敲键盘时不死循环,让主进程的 5s 超时兜底。 + bootstrapUnsubs.push(window.api.onFlushPendingSave(async () => { + // v4+ 修复(H-Critical): + // 1) saveNow() 可能 reject(IPC 异常 / EXTERNAL_CHANGE_DETECTED 等), + // 必须 try/catch/finally 包裹,否则 notifyFlushDone 永不调用, + // 主进程 5s 超时兜底后强制 app.quit() —— 未保存内容被静默吞掉。 + // 2) store.filePath === null 但 dirty=true 时(用户改了内容但还没保存到磁盘) + // 旧循环条件 `&& store.filePath` 直接不进入,必须先弹 Save As 让用户 + // 选位置,否则循环结束后 notifyFlushDone、主进程退出 → 永久丢数据。 + try { + if (store.dirty && !store.filePath) { + // 退出流程触发 —— 弹原生 Save As 对话框 + const result = await window.api.saveFileDialog(); + if (result) { + store.setFilePath(result); + } + } + // 多轮 saveNow 直到真的没有 dirty —— 防止「await saveNow 期间用户又改了」: + // saveNow 内部用 _dirtyVersion 在 await 后比对,若检测到中途改动会保留 + // dirty=true 让后续 autoSave 兜底,但 quit 流程下没有「后续」,必须自己再写。 + // 上限 5 轮:用户一直狂敲键盘时不死循环,让主进程的 5s 超时兜底。 + for (let i = 0; i < 5 && store.dirty && store.filePath; i++) { + await store.saveNow(); + } + } catch (e) { + // 兜底:saveNow reject 时记录错误但仍通知主进程放行退出 + // —— 强制挂起主进程不会让数据凭空恢复,反而让用户以为"卡死"。 + console.error('[flush] saveNow 异常:', e); + } finally { + window.api.notifyFlushDone(); + } + })); + + // 文件外部修改检测 + // 已在 bootstrap 前段(line 143)注册 onFileExternalChange 监听, + // 这里**不再重复注册** —— 旧实现 line 302 是 v3 时期的 listener, + // 注释里说"必须在 file:read 之前注册"已搬到 line 143,旧 listener 应删除。 + // 重复注册会导致每次外部修改 IPC 被处理两次(虽然 conflictDialogShowing + // 守卫能挡住重复弹框,但 CPU 开销翻倍 + 未来若守卫改动会埋雷)。 + + // 主进程菜单事件 + bootstrapUnsubs.push(window.api.onMenuCommand((cmd) => { + switch (cmd) { + case 'menu:save': handleSaveFile(); break; + case 'menu:reload': handleReload(); break; + case 'menu:toggle-theme': toggleTheme(); break; + case 'menu:settings': openSettings(); break; + } + })); + + // 工具栏事件 + document.addEventListener('toolbar:toggle-theme', toggleTheme); + document.addEventListener('toolbar:settings', openSettings); + bootstrapUnsubs.push( + () => document.removeEventListener('toolbar:toggle-theme', toggleTheme), + () => document.removeEventListener('toolbar:settings', openSettings) + ); + + // Cmd/Ctrl+K 调起命令面板 + const onCmdK = (e) => { + // 已经在命令面板 / 模态中就别再触发 + if (document.querySelector('.modal-overlay, .command-palette-overlay')) return; + // H13-1: 在 input/textarea 里 Cmd+K 是某些编辑器的"删除到行尾", + // 不要拦截;让浏览器 / 编辑器按自己的语义处理。 + const isInEditable = ['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName); + if (isInEditable) return; + const ctrl = e.ctrlKey || e.metaKey; + if (ctrl && (e.key === 'k' || e.key === 'K')) { + e.preventDefault(); + openCommandPaletteHandler(); + } + }; + document.addEventListener('keydown', onCmdK); + bootstrapUnsubs.push(() => document.removeEventListener('keydown', onCmdK)); + + // 全局快捷键 + bootstrapUnsubs.push(setupGlobalShortcuts()); + + // 监听系统主题变化(仅在 store.theme === 'auto' 时生效) + const mm = window.matchMedia?.('(prefers-color-scheme: light)'); + if (mm && mm.addEventListener) { + const onMm = () => { + if (store.theme === 'auto') { + document.body.dataset.theme = store.resolveTheme(); + } + }; + mm.addEventListener('change', onMm); + bootstrapUnsubs.push(() => mm.removeEventListener('change', onMm)); + } + + rendererLog('bootstrap 完成'); + } catch (err) { + console.error('[renderer] bootstrap 失败:', err); + toast(`启动失败:${err.message}`, 'error', 5000); + } +} + +// ============================================ +// 外部修改冲突解决(共用:file:external-change + save-error 中的 +// EXTERNAL_CHANGE_DETECTED 两条入口都走这里) +// ============================================ +// +// 同步事件开发日志 —— 仅在 dev 模式(window.api.isDev)打印,生产环境静默。 +// 生产环境不应被这些 [sync] 日志污染控制台,但 dev 模式用户可以打开 DevTools +// 看完整的事件流,调试"为什么没同步 / 为什么弹了冲突对话框"。 +// +// 关键事件: +// - external-change-detected:file:external-change 或 saveNow EXTERNAL_CHANGE_DETECTED 触发 +// - conflict-dialog-opened / -resolved:用户在对话框里做出选择 +// - autoSave-race-blocked:自动保存被主进程 mtime/size 二次确认拦截 +// - reload-from-disk(手动 Cmd/Ctrl+R 或菜单触发) +function syncLog(event, payload) { + if (!window.api?.isDev) return; + if (payload !== undefined) { + console.info(`[sync] ${event}`, payload); + } else { + console.info(`[sync] ${event}`); + } +} +// +// 这条路径有两个触发点: +// 1) 主进程 fs.watch / 0.5 秒 polling 检测到文件被外部修改 → IPC 通知渲染端 +// 2) 主进程 file:write 的 mtime 预检发现上次基线和当前文件状态不一致 → +// 自动保存被拒绝,save-error 携带 EXTERNAL_CHANGE_DETECTED +// +// 两条入口合并成一个函数,确保逻辑(取消 autoSave / 读取 / 比对 / 弹对话框) +// 永远一致 —— 任何一处修改都能自动惠及另一处。 +// +// 「保留本地」分支调 store.saveNow() 内部 force=true:用户已明确表态要覆盖外部 +// 修改,主进程的 mtime 保护不会拦截这次写入。 +// +// 「重入守卫」conflictDialogShowing:fs.watch + polling 在外部保存时可能各发一次 +// IPC,加上 save-error 触发的话 800ms 内可能连弹两三个对话框。在 await 期间记录 +// 状态,后续调用直接 return,避免栈叠对话框。 +// +// 防御性:try/catch 包住整个处理流程。理论上 finally 一定会执行,但若 confirmDialog +// 实现里同步抛错、或 _doResolveExternalChangeConflict 自身在 await 之外同步抛错, +// 没有这个 try/catch 会让标志位永久卡住 —— 之后所有外部修改都被吞,用户再也看不到 +// 「文件被外部修改」提示。catch 里至少 console.error + 一次性 toast,flag 仍然释放。 +let conflictDialogShowing = false; + +async function resolveExternalChangeConflict(changedPath, options = {}) { + // 路径比较必须平台感知:详见 pathsEqual 注释。 + // Windows 上 store.filePath 是 `D:/...`(joinPath)、changedPath 是 `D:\...`(path.resolve), + // 直接 !== 会让所有外部修改静默被吞。 + if (!pathsEqual(changedPath, store.filePath)) return; + if (conflictDialogShowing) return; + conflictDialogShowing = true; + syncLog('external-change-detected', { source: options.source || 'file:external-change' }); + try { + return await _doResolveExternalChangeConflict(changedPath, options); + } catch (err) { + // 防御性:万一处理流程内部抛错,标志位仍要在 finally 里释放(下面的 finally + // 保证),同时给用户一个可观察的反馈 —— 不要静默吞掉。 + console.error('[renderer] 外部修改冲突处理失败:', err); + toast(`外部修改处理失败:${err.message || err}`, 'error', 4000); + } finally { + conflictDialogShowing = false; + } +} + +async function _doResolveExternalChangeConflict(changedPath, options = {}) { + // 第一件事:让 in-flight 的自动保存「作废」。 + // 下面有 await,若不先作废,那条 writeFile 会在我们还在问用户的时候 + // 把内存内容盖回文件 —— 用户手工删掉的行原地复活,且毫无提示。 + // v4 全实时同步后没有 debounce 定时器可掐,只需让 in-flight 的代次过期: + // 它的 await 回来时发现代次对不上、不复位 dirty、不发 saved 假象。 + store.cancelPendingSave(); + + // ── 取磁盘当前内容 ── + // 优先用主进程随 IPC 附带的 diskContent(onFileExternalChange / save-error 都带), + // 避免再发一次 file:read —— 渲染端起步就是冲突流程时,主进程已经把内容塞过来。 + let diskContent = typeof options.diskContent === 'string' ? options.diskContent : null; + let diskParseFailed = false; + if (diskContent === null) { + const result = await window.api.readFile(changedPath); + if (!result.ok) { + if (result.error === 'FILE_NOT_FOUND') { + toast(result.message || '文件已被删除', 'error', 4000); + } else { + toast(`读取文件失败:${result.error}`, 'error', 4000); + } + return; + } + diskContent = result.content; + } + + // 自写入抑制:磁盘内容与内存 serialize() 字节一致 → 是自己写入的产物(autoSave + // 完成、reload 无变化)→ 静默。这是判断「这次保存是不是我们自己写入的」唯一可靠 + // 办法 —— 主进程层无法可靠做(fs.watch 在 Windows 上的 callback 时序不稳)。 + if (diskContent === store.serialize()) { + return; + } + + // ── 解析磁盘内容 ── + let diskCategories; + try { + diskCategories = parseMarkdown(diskContent); + } catch (e) { + diskParseFailed = true; + } + + if (!store.dirty) { + // 本地没有未保存的改动 → 直接 reload(不弹冲突对话框 —— 没东西可冲突) + // + // UX 细节:loadFromContent 会触发整列重渲,用户的滚动位置与焦点任务会丢失 + // —— 云同步偶发触发自动 reload 时,视图跳到顶部 + 焦点跳到首条,体验像「被 + // 刷新了」。这里在 reload 前抓 scrollTop + 焦点任务的 task.id,render 完后 + // requestAnimationFrame 还原(render 是同步 DOM 重建,下一帧取元素已存在)。 + // + // 滚动容器 = #task-list(task-list.js line 39 取这个 id,CSS 给它 overflow:auto)。 + // 焦点任务按 data-id 找回(task-list.js line 1040 给每条
  • 设 data-id=task.id)。 + // 都允许 null —— 用户本来就没滚也没焦点就不还原。 + const scrollContainer = document.getElementById('task-list'); + const savedScrollTop = scrollContainer?.scrollTop; + const focusedEl = document.activeElement; + const savedTaskId = focusedEl?.dataset?.id || null; + store.loadFromContent(diskContent, changedPath); + if (savedScrollTop != null) { + requestAnimationFrame(() => { + const sc = document.getElementById('task-list'); + if (sc) sc.scrollTop = savedScrollTop; + }); + } + if (savedTaskId) { + requestAnimationFrame(() => { + // CSS.escape 兜底:万一 id 里含特殊字符(虽然现行实现 task.id 都是 nanoid 安全字符集) + const el = document.querySelector(`#task-list [data-id="${CSS.escape(savedTaskId)}"]`); + if (el && typeof el.focus === 'function') el.focus(); + }); + } + if (!diskParseFailed) { + toast('文件已在外部修改,已重新加载', 'info'); + } + return; + } + + // 本地有未保存的改动 + 解析成功 → 进入任务粒度合并对话框。 + // 解析失败(用户改了文件导致 markdown 结构损坏)→ 退化为传统二选一对话框, + // 因为 diffWithDisk 拿不到有意义的 categories 数组。 + if (diskParseFailed) { + await _fallbackConflictDialog(changedPath, diskContent); + return; + } + + // ── 任务粒度合并对话框 ── + const diff = store.diffWithDisk(diskCategories); + const defaultResolutions = store.defaultMergeResolutions(diff); + + // 把解析后的 categories 缓存到 store —— 用户在 dialog 里继续操作 UI 时可基于 + // 最新内存状态做增量 diff。dialog 关闭时清掉(避免常驻)。 + store.setDiskSnapshot({ + filePath: changedPath, + content: diskContent, + parsedCategories: diskCategories, + fetchedAt: Date.now(), + }); + + // 抓 dialog **打开瞬间**的 dirty 快照 —— 用于关闭时检测"dialog 期间用户继续 + // 改了 UI"。这是 v4 全实时同步后唯一能捕获的场景: + // 用户在 conflict-dialog 里挑勾选标记,但同时也想手动勾掉一条任务,点完"应用合并" + // 后 → 这条新勾选的任务会被一起处理(自然合并)。但 keep-file 路径会**直接丢弃** + // dialog 期间的改动,无声丢数据 —— 必须二级确认拦住。 + const dirtySnapshotBeforeDialog = store.getDirtySnapshot(); + + let choice; + try { + choice = await openConflictDialog({ + diff, + defaultResolutions, + summary: diff.summary, + // 头部"查看完整行级 diff"按钮要用 —— 注意这里 serialize() 在 dialog 打开期间会被 + // 用户在 UI 上的改动"自然更新",但按钮点击是**打开后**的事件,diff 视图反映的 + // 是"用户当时看到的差异",体验上最直观。 + diskContent, + memoryText: store.serialize(), + }); + } finally { + store.clearDiskSnapshot(); + } + + if (!choice || choice.action === 'cancel') { + syncLog('conflict-dialog-resolved', { action: 'cancel' }); + return; // 用户取消 —— 什么都不做(store 内容保持原样,但 dirty 仍为 true,会继续自动保存) + } + + // ── Dialog 期间 dirty 检查 ── + // 注意:apply / keep-mine 路径下 dialog 期间的 UI 改动**会**被保留(已经反映在 + // store.categories 里),只有 keep-file 会丢弃。但即便保留,仍希望用户明确知情 + // —— "你刚改了这些,我准备把它们一起处理掉"。这是显式优于隐式的取舍。 + const dirtyConfirmed = await _confirmDialogPeriodDirty(dirtySnapshotBeforeDialog, choice); + if (!dirtyConfirmed) { + syncLog('conflict-dialog-resolved', { action: 'cancel-at-dirty-confirm' }); + return; + } + syncLog('conflict-dialog-resolved', { action: choice.action }); + + if (choice.action === 'apply') { + // 应用用户的逐条选择:合并结果替换 categories 数组(mergeFromDisk 重排顺序 + 重分配 id) + store.mergeFromDisk(diskCategories, choice.resolutions); + // 立刻把合并结果写回磁盘 —— 让 store 与 diskContent 再次一致,避免下一次 + // fs.watch/polling 又把它当成「外部修改」发 IPC 进来 + const saved = await store.saveNow(); + if (saved) { + toast('已应用合并并保存到磁盘', 'success'); + } else { + toast('合并已应用,但保存失败 —— 请检查文件权限', 'error', 4000); + } + return; + } + + if (choice.action === 'keep-mine') { + // 用户明确选「全部用本地」 → 走 saveNow 把本地写回磁盘(force=true) + const saved = await store.saveNow(); + if (saved) { + toast('已保留本地版本并写入磁盘', 'success'); + } else { + toast('本地版本保留失败 —— 请检查文件权限', 'error', 4000); + } + return; + } + + if (choice.action === 'keep-file') { + // 用户明确选「全部用文件」+ 丢弃本地 → **必须**先存 .discard-*.bak 快照再 reload。 + // 这是用户最容易误点的按钮,所以先把本地版本留底 —— 误操作后能找回。 + const snapResult = await window.api.createSnapshot(changedPath, 'discard'); + if (snapResult?.ok) { + // 成功创建快照:把磁盘当前内容 load 进去(覆盖本地),并在 toast 提示快照路径 + store.loadFromContent(diskContent, changedPath); + toast( + `已采用文件版本 —— 你的本地修改已保存到:\n${snapResult.snapshotPath}`, + 'success', + 6000 + ); + } else { + // 快照创建失败 → **不**直接丢弃本地(避免真的丢数据),改为提示用户手动备份 + const ok = await confirmDialog({ + title: '无法创建快照', + message: '创建快照失败(' + (snapResult?.error || '未知错误') + + '),强行丢弃本地可能会丢失数据。\n\n' + + '是否仍然丢弃本地修改?\n(建议先点「取消」并手动备份 todo.md)', + confirmText: '仍然丢弃', + cancelText: '取消', + danger: true, + }); + if (ok) { + store.loadFromContent(diskContent, changedPath); + toast('已采用文件版本(未创建快照)', 'warning', 4000); + } + // 取消 → 什么都不做(保持原本地状态) + } + return; + } +} + +/** + * 兜底冲突对话框(仅在磁盘 markdown 解析失败时使用)—— + * 这种情况下拿不到有意义的 categories 数组,退化为传统「重载 / 保留」二选一。 + * + * @param {string} changedPath + * @param {string} diskContent + */ +async function _fallbackConflictDialog(changedPath, diskContent) { + const reload = await confirmDialog({ + title: '文件被外部修改', + message: '文件被外部修改,但软件无法解析其中的内容(可能是 markdown 结构损坏)。\n\n' + + '· 重新加载:采用文件里的内容,丢弃本地未保存的修改\n' + + '· 保留本地:立刻保存本地内容,覆盖掉外部的修改', + // M-S13: confirmDialog 默认是「确认」视觉。把"重新加载"标 danger=true + // —— 该按钮的语义是"丢弃本地劳动、加载未知结构",必须高亮红边框 + 红文字, + // 让用户在按下前意识到这是不可逆操作。confirmText 同步改为「放弃本地修改 + // (重新加载)」,把动作后果写在按钮上避免被误点。 + confirmText: '放弃本地修改(重新加载)', + cancelText: '保留本地', + danger: true, + }); + if (reload) { + store.loadFromContent(diskContent, changedPath); + toast('已采用文件版本', 'success'); + } else { + // "保留本地"会直接 saveNow 覆盖外部修改 —— 万一用户后悔没有 .bak 可恢复。 + // 先创建一份带 autosave-on-exit 标签的快照,再 saveNow。toast 里展示路径, + // 让用户知道"如果后悔可以找回"。 + const snapResult = await window.api.createSnapshot(changedPath, 'autosave-on-exit'); + const saved = await store.saveNow(); + if (saved) { + const snapPath = snapResult?.snapshotPath + ? `\n\n本地版本快照已保存到:\n${snapResult.snapshotPath}` + : (snapResult?.error ? `\n\n(注意:创建快照失败 — ${snapResult.error})` : ''); + toast('已保留本地版本并写入磁盘' + snapPath, 'success', 6000); + } else { + toast('本地版本保存失败', 'error', 4000); + } + } +} + +/** + * 检测用户在 conflict-dialog 打开期间是否对 UI 做了额外改动; + * 若是 —— 在执行用户的最终选择前弹二级确认,避免静默丢失"边看对话框边改"的劳动。 + * + * 三种选择的影响: + * - apply:mergeFromDisk 把 diskCategories 合进**当前** categories,dialog 期间的 + * UI 改动天然保留。仍弹确认:让用户明确知情"我刚改了这些,将被一起合并" + * - keep-mine:saveNow 把当前 categories(含 UI 改动)写回磁盘,保留天然。 + * - keep-file:loadFromContent 直接替换 categories —— **会**丢 UI 改动,danger=true + * + * @param {ReturnType} before - dialog 打开前 + * @param {{action: string}} choice - 用户的选择 + * @returns {Promise} true = 继续执行 choice;false = 用户在二级确认里取消 + */ +async function _confirmDialogPeriodDirty(before, choice) { + // structuralOnly 的场景(仅兜底改动)算"无新改" —— 不是用户主动操作 + const after = store.getDirtySnapshot(); + if (after.structuralOnly && after.changeCount <= (before.changeCount || 0)) { + return true; + } + const newTaskIds = [...after.taskIds].filter(id => !before.taskIds.has(id)); + const newCatNames = [...after.categoryNames].filter(n => !before.categoryNames.has(n)); + const newTaskCount = newTaskIds.length; + const newCatCount = newCatNames.length; + if (newTaskCount === 0 && newCatCount === 0) return true; + + // 构建已改项列表(不超过 6 条避免对话框撑爆) + const allLines = []; + for (const tid of newTaskIds.slice(0, 6)) { + // 直接在 store.categories 里线性搜 —— dirty snapshot 只是 Set 不会按 id 建索引, + // 而 conflict-dialog 期间 dirty 项一般很少(用户手动勾选/改文本不会超过十几条), + // 线性扫一遍 O(categories × tasks) 完全够用。 + let label = tid; + for (const cat of store.categories) { + const t = (cat.tasks || []).find(x => x.id === tid); + if (t) { label = t.text || tid; break; } + } + allLines.push(` · 任务:${label.length > 30 ? label.slice(0, 30) + '…' : label}`); + } + for (const name of newCatNames.slice(0, 6)) { + allLines.push(` · 分类:${name}`); + } + if (newTaskIds.length > 6 || newCatNames.length > 6) { + allLines.push(` · …另有 ${Math.max(0, newTaskCount + newCatCount - 6)} 项改动`); + } + + const actionLabel = { + apply: '应用合并', + 'keep-mine': '全部用本地', + 'keep-file': '全部用文件(丢弃本地)', + }[choice.action] || choice.action; + + const destructiveNote = choice.action === 'keep-file' + ? '\n\n⚠ 「全部用文件」会丢弃本地改动(包括你在解决冲突期间做的)。' + : ''; + + const message = + `你在解决冲突期间改了 ${(newTaskCount + newCatCount)} 项:\n` + + allLines.join('\n') + '\n\n' + + `当前选择:${actionLabel}${destructiveNote}\n\n` + + `是否继续?`; + + const ok = await confirmDialog({ + title: '解决冲突期间有改动', + message, + confirmText: '继续', + cancelText: '取消', + danger: choice.action === 'keep-file', + }); + if (!ok) { + toast('已取消 —— 你的选择未生效', 'info'); + // 返回 false 让 _doResolveExternalChangeConflict 在外层提前 return, + // 不再继续执行用户已取消的 apply/keep-mine/keep-file 路径。 + // 简单可靠 —— 无需特殊错误类型标记。 + return false; + } + return true; +} + +async function openSettings() { + // 若有未保存修改,先询问再开设置,避免用户改完配置才后悔 + if (store.dirty) { + const ok = await confirmDialog({ + title: '存在未保存的修改', + message: '当前文档有未保存的修改,打开设置前是否先保存?\n(也可继续 — 修改会保留在内存中)', + confirmText: '保存并继续', + cancelText: '继续打开' + }); + if (ok) await handleSaveFile(); + } + const result = await openSettingsDialog(settingsStore); + if (result && result.changed) { + // 数据目录已改变:如果还有未保存的改动,必须先警告。 + // reloadFromCurrentDataDir 会用新目录里的文件覆盖内存 —— 这意味着 + // 用户在旧目录文件里改完、还没保存的修改会被静默丢掉。 + // 这里给一次「取消」的机会,避免误点把辛苦编辑的内容送走。 + if (store.dirty) { + const proceed = await confirmDialog({ + title: '切换数据目录会丢弃未保存的修改', + message: '切换到新数据目录后,会加载新目录下的 todo.md 覆盖当前内存中的内容。\n\n' + + '当前还有未保存的修改 — 它们会被丢弃,且无法恢复。\n\n' + + '要继续切换吗?', + confirmText: '继续切换', + cancelText: '取消', + danger: true + }); + if (!proceed) { + // 用户取消:不能再恢复旧目录设置(已落盘)—— 提示去设置里手动改回 + toast('数据目录设置已保存,但未切换加载。请重新打开设置改回原目录', 'info', 5000); + return; + } + } + // 数据目录已改变,重新加载默认文件 + toast('正在切换数据目录...', 'info', 1500); + await reloadFromCurrentDataDir(); + } +} + +async function reloadFromCurrentDataDir() { + const dataDir = await window.api.getDataDir(); + const defaultPath = joinPath(dataDir, 'todo.md'); + + let filePath = null; + let content = null; + let readFailed = false; + try { + if (await window.api.fileExists(defaultPath)) { + const result = await window.api.readFile(defaultPath); + if (result.ok) { + filePath = defaultPath; + content = result.content; + } else { + readFailed = true; + } + } + } catch (e) { + readFailed = true; + console.warn('[renderer] 重新加载默认文件出错:', e.message); + } + + if (content !== null) { + store.loadFromContent(content, filePath); + toast(`已切换到:${shortenPath(filePath)}`, 'success', 2000); + } else if (readFailed) { + store.loadDefault(null); + toast(`无法读取 ${shortenPath(defaultPath)},已进入临时模式,请勿在此状态下保存`, 'error', 8000); + } else { + await createDefaultFile(defaultPath, `已在新目录创建: ${shortenPath(defaultPath)}`); + } +} + +/** + * 在指定路径写出一份初始文档并接管它。 + * 写入走 createFileIfMissing(内核级 'wx'),已存在则一定不会被覆盖。 + * 失败时退回内存模式(filePath 为 null),由用户手动另存为。 + */ +/** + * 尝试读取 .bak 备份 —— 用于 bootstrap 路径的 C1 修复: + * 主文件解析抛错或内容为空时,退回到 .bak 给用户一份历史版本。 + * 返回内容字符串;读取失败返回 null。 + * + * 注意:这里依赖 main.js 的备份旋转策略 —— 每次成功 saveNow 后 .bak 都同步更新, + * 因此回退到的版本一定是最近一次成功保存的内容,不会有"太久远"的风险。 + */ +async function tryReadBak(filePath) { + if (!filePath) return null; + try { + const bakPath = filePath + '.bak'; + const result = await window.api.readFile(bakPath); + if (result && result.ok && result.content) return result.content; + } catch (e) { + rendererLog('.bak 读取出错:', e.message); + } + return null; +} + +async function createDefaultFile(targetPath, successMessage) { + // 先不绑定路径:确认写入成功后再 setFilePath,避免自动保存抢在前面 + store.loadDefault(null); + + const result = await window.api.createFileIfMissing(targetPath, store.serialize()); + + if (result.ok) { + store.setFilePath(targetPath); + toast(successMessage, 'success', 2000); + return true; + } + + // 竞态:检查之后、写入之前文件出现了(另一实例、外部工具)。 + // 内核挡下了这次写入,此处改为读取它 —— 用户数据优先于模板。 + if (result.exists) { + const existing = await window.api.readFile(targetPath); + if (existing.ok) { + store.loadFromContent(existing.content, targetPath); + toast(`已加载已有文件:${shortenPath(targetPath)}`, 'success', 2000); + return true; + } + toast(`${shortenPath(targetPath)} 已存在但无法读取,已进入临时模式,请勿保存`, 'error', 8000); + return false; + } + + toast(`无法写入 ${shortenPath(targetPath)},请用 Ctrl+S 另存为`, 'error', 4000); + return false; +} + +// ============================================ +// 文件操作 +// ============================================ + +async function handleSaveFile() { + if (!store.filePath) { + const result = await window.api.saveFileDialog(); + if (!result) return false; + store.setFilePath(result); + } + const ok = await store.saveNow(); + // 保存失败时不再弹通用 toast —— saveNow 内部已发 'save-error' 事件, + // toolbar.js 订阅的处理器会发详细错误(区分 EXTERNAL_CHANGE_DETECTED + // / outside data directory / 普通 IO 错误)。这里再弹一次会和详细错误 + // 重复出现两个 toast,用户误以为是两个独立问题。 + // 例外:NO_FILE_PATH 时 saveNow 内部根本不发 'save-error'(已发了自己 + // 设计的)—— 上面 saveFileDialog 流程保证了 filePath 已被赋值,到这里 + // 不会再撞 NO_FILE_PATH;EXTERNAL_CHANGE_DETECTED 走 resolveExternalChangeConflict + // 也是另起 toast,不依赖这里。 + return ok; +} + +/** + * 手动「立即重新加载」—— 菜单 / Cmd/Ctrl+R 触发。 + * + * 与外部修改自动检测的区别: + * - 外部修改自动检测是**单向通知**(磁盘改了 → 通知用户),由 fs.watch / polling 触发 + * - 手动 reload 是**用户主动拉取**,不依赖外部事件兜底 —— 万一 fs.watch + polling + * 都漏了某个外部保存(云同步偶发 bug / 第三方编辑器非标准保存路径),用户可以 + * 主动拉一次磁盘状态对账 + * + * 决策树: + * - 没有 filePath → 提示用户「未关联文件」(极早版本或异常清空) + * - 磁盘内容 == store.serialize() → toast「已是最新」,不做任何事 + * - 磁盘内容 != 本地 && !dirty → 直接 loadFromContent(无冲突可处理) + * - 磁盘内容 != 本地 && dirty → 退到 confirmDialog 让用户二选一: + * · 重新加载 → 先创建 .discard 快照再覆盖 + * · 保留本地 → saveNow(force=true) 覆盖磁盘 + * + * 注意:与外部修改自动检测**互不触发**——这里走 `cancelPendingSave` 后读磁盘, + * 不会再走 resolveExternalChangeConflict(避免双弹冲突对话框)。 + */ +async function handleReload() { + if (conflictDialogShowing) { + // 用户已经在外部修改流程里了 —— 别再嵌套手动 reload,避免对话框栈叠 + toast('正在解决外部修改冲突,请稍后再试', 'info'); + return; + } + if (!store.filePath) { + toast('未关联文件 —— 请先在设置里选择数据目录', 'warning'); + return; + } + + syncLog('reload-from-disk', { filePath: store.filePath }); + + // 第一件事:让 in-flight 的自动保存作废(与 _doResolveExternalChangeConflict 同款) + store.cancelPendingSave(); + + const result = await window.api.readFile(store.filePath); + if (!result.ok) { + if (result.error === 'FILE_NOT_FOUND') { + toast('文件已被删除', 'error', 4000); + } else { + toast(`读取文件失败:${result.error}`, 'error', 4000); + } + return; + } + const diskContent = result.content; + + // 字节一致 → 已是最新,避免误 toast + if (diskContent === store.serialize()) { + syncLog('reload-noop', { reason: 'already-in-sync' }); + toast('已是最新', 'info', 2000); + return; + } + + if (!store.dirty) { + // 本地没改 → 直接 reload + store.loadFromContent(diskContent, store.filePath); + syncLog('reload-no-dirty', { diskLength: diskContent.length }); + toast('已重新加载磁盘版本', 'success'); + return; + } + + // 本地有 dirty + 磁盘不一致 → 二选一 + const reload = await confirmDialog({ + title: '磁盘版本与本地不同', + message: '磁盘上的文件与本地内存不一致,且你还有未保存的修改。\n\n' + + '· 重新加载:采用文件里的内容(**会丢弃本地未保存的修改**),先创建 .bak 快照\n' + + '· 保留本地:立刻保存本地内容,覆盖掉磁盘版本', + confirmText: '重新加载', + cancelText: '保留本地', + }); + if (reload) { + // 与 conflict-dialog 的 keep-file 同款处理:先 snapshot 再覆盖 + const snapResult = await window.api.createSnapshot(store.filePath, 'discard'); + if (snapResult?.ok) { + store.loadFromContent(diskContent, store.filePath); + syncLog('reload-applied', { snapshotPath: snapResult.snapshotPath }); + toast( + `已采用文件版本 - 你的本地修改已保存到:\n${snapResult.snapshotPath}`, + 'success', + 6000 + ); + } else { + // 快照失败 → 二次确认避免真丢数据 + const proceed = await confirmDialog({ + title: '无法创建快照', + message: '创建快照失败(' + (snapResult?.error || '未知错误') + + '),强行重新加载可能会丢失数据。\n\n是否仍然继续?', + confirmText: '仍然重新加载', + cancelText: '取消', + danger: true, + }); + if (proceed) { + store.loadFromContent(diskContent, store.filePath); + toast('已采用文件版本(未创建快照)', 'warning', 4000); + } + } + } else { + // 保留本地 → 直接 saveNow(force=true 覆盖磁盘) + const saved = await store.saveNow(); + syncLog('reload-keep-mine', { saved }); + if (saved) { + toast('已保留本地版本并写入磁盘', 'success'); + } else { + toast('本地版本保存失败', 'error', 4000); + } + } +} + +// 命令面板入口 +async function openCommandPaletteHandler() { + try { + await openCommandPalette(store, { + onSwitchCategory: (name) => store.selectCategory(name), + onSwitchSmartList: (key) => store.selectSmartList(key), + onToggleTheme: () => toggleTheme(), + onOpenSettings: () => openSettings(), + onNewList: async () => { + const name = await inputDialog({ title: '新建列表', placeholder: '例如:阅读、健身、副业...' }); + if (!name) return; + // 含 `|` 在选中文案就拦下来 —— store 也会拒绝,但提前告知比「按下确认没反应」 + // 更直接。sidebar.js 的同款路径同样处理。 + if (name.includes('|')) { + toast('分类名不能包含 "|" 字符(用于选中键分隔)', 'error'); + return; + } + if (store.categories.some(c => c.name === name)) { + toast(`已存在分类 "${name}"`, 'error'); + return; + } + const cat = store.addCategory(name); + if (!cat) { + // 兜底:保留名等其它拒绝原因 + toast(`"${name}" 是保留名称,请换一个`, 'error'); + return; + } + store.selectCategory(name); + toast(`已创建 "${name}"`, 'success'); + } + }); + } catch (e) { + console.warn('[app] 命令面板打开失败:', e); + } +} + +function toggleTheme() { + // 三态循环:dark → light → auto → dark + // 旧实现只在 dark/light 之间切换,导致用户一旦在设置里选了「跟随系统」 + // 就再也回不去了 —— 主题按钮只能切到 light/dark,永远碰不到 auto + const cycle = { dark: 'light', light: 'auto', auto: 'dark' }; + const newTheme = cycle[store.theme] || 'dark'; + // 通过 setTheme 触发 'theme' 事件,让 Toolbar 等订阅者更新图标 + store.setTheme(newTheme); + document.body.dataset.theme = store.resolveTheme(); + // 同步到持久化设置 + settingsStore.update({ theme: newTheme }); +} + +// ============================================ +// 状态栏 +// ============================================ + +function setupStatusBar() { + // 文件路径由 Toolbar 统一管理(在状态栏 #file-info 中渲染) + + // 缓存:每次 store 'change' 都会触发 update(),但绝大多数 change 不影响统计 + // (勾选时任务数不变 / 切换选中分类 / 排序方式等)。缓存上一次写入的字符串, + // 与本次相同就跳过 DOM 写入 —— 'change' 在拖拽 / 打字时高频触发,省掉 + // textContent 写入与 reflow。Setup 阶段 oldText=null 强制写一次。 + let oldText = null; + const update = () => { + // 排除容器(OTHER_TASKS,本身无任务)和回收站(TRASH,已删除任务不计入进度) + // 与 collectSmartListTasks 的排除规则保持一致 —— 否则用户删了任务却看到 + // 状态栏数字纹丝不动,会以为删除没生效 + const total = store.categories.reduce( + (sum, c) => sum + ((c.kind === CategoryKind.OTHER_TASKS || c.kind === CategoryKind.TRASH) ? 0 : c.tasks.length), 0 + ); + const done = store.categories.reduce( + (sum, c) => sum + ((c.kind === CategoryKind.OTHER_TASKS || c.kind === CategoryKind.TRASH) ? 0 : c.tasks.filter(t => t.completed).length), 0 + ); + const subCount = store.getSubCategories().length; + const nextText = `${done}/${total} 已完成 · ${subCount} 个子分类`; + // 写入相同文本跳过 reflow;首次(oldText===null)强制写 + if (nextText === oldText) return; + oldText = nextText; + // 写 #status-text(进度统计);#status-meta 专用于保存状态,见 bootstrap 里的 + // 'dirty' / 'saved' 订阅 —— 两格各司其职,避免互相覆盖 + const el = document.getElementById('status-text'); + if (el) el.textContent = nextText; + }; + + update(); + // 返回取消函数,让 bootstrap 能把它纳入统一的 listener 清理池 + const offs = [ + store.on('change', update), + store.on('load', update) + ]; + return () => offs.forEach(off => off()); +} + +// ============================================ +// 全局快捷键 +// ============================================ + +function setupGlobalShortcuts() { + const onKey = (e) => { + // 模态对话框 / 命令面板 / 右键菜单打开时,不要抢键 + if (document.querySelector('.modal-overlay, .command-palette-overlay, .context-menu:not([hidden])')) { + return; + } + + const ctrl = e.ctrlKey || e.metaKey; + const isInEditable = ['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName); + + // Alt+1..4 切换列表。命令面板里每一项都标注了这些快捷键、README 也写了, + // 但此前全项目没有任何 altKey 处理 —— 纯属空头承诺,这里补上实现。 + // + // v3 起顺序:当前任务 / 重要任务 / 全部任务 / 已完成任务。 + // 「当前任务」不再有独立分类 —— 它是聚合智能视图,所以 Alt+1 走 selectSmartList。 + // + // 必须在编辑态跳过:在 task-input 框里 Alt+1 会被浏览器或输入法抢走, + // 也避免在用户输入中文/特殊符号时误触切换列表。统一在编辑态放行。 + if (e.altKey && !ctrl && !isInEditable && ['1', '2', '3', '4'].includes(e.key)) { + e.preventDefault(); + if (e.key === '1') { + store.selectSmartList('current'); + } else if (e.key === '2') { + store.selectSmartList('important'); + } else if (e.key === '3') { + store.selectSmartList('allTasks'); + } else { + store.selectSmartList('completed'); + } + return; + } + + // Ctrl+, 打开设置 + if (ctrl && e.key === ',') { + e.preventDefault(); + openSettings(); + return; + } + + // H13: Ctrl+Shift+T 切换主题(README / 命令面板 / 顶部工具栏都已标这个快捷键, + // 但此前 setupGlobalShortcuts 完全没接 —— 点工具栏能切,键盘切就死。 + // 这里补上,与 toolbar:toggle-theme 走同一条 toggleTheme() 路径,保证行为一致。 + if (ctrl && e.shiftKey && (e.key === 'T' || e.key === 't')) { + e.preventDefault(); + toggleTheme(); + return; + } + + if (ctrl && e.key === 'f') { + e.preventDefault(); + const searchInput = document.getElementById('search-input'); + if (!searchInput) return; + searchInput.focus(); + searchInput.select(); + return; + } + + if (ctrl && e.shiftKey && e.key === 'N') { + e.preventDefault(); + const addCategoryBtn = document.getElementById('btn-add-category'); + if (!addCategoryBtn) return; + addCategoryBtn.click(); + return; + } + + // F - 打开过滤菜单(不在输入框时) + if ((e.key === 'f' || e.key === 'F') && !ctrl && !isInEditable) { + // 但要避免和 Ctrl+F(搜索)冲突,Ctrl+F 已处理在上方 + const filterBtn = document.getElementById('btn-filter'); + // 「已完成」/「重要」智能列表下该按钮是 hidden 的(语义已自带过滤)。 + // hidden 元素照样能响应 .click(),而 _showFilterMenu 用 getBoundingClientRect() + // 定位 —— 隐藏时全是 0,菜单会弹到屏幕左上角。这里直接忽略按键。 + if (!filterBtn || filterBtn.hidden) return; + e.preventDefault(); + filterBtn.click(); + return; + } + + // / 快速聚焦搜索(不抢占编辑中的输入) + if (e.key === '/' && !isInEditable && !ctrl) { + e.preventDefault(); + const search = document.getElementById('search-input'); + search.focus(); + search.select(); + return; + } + }; + document.addEventListener('keydown', onKey); + // 返回取消函数,让 bootstrap 能把它纳入统一的 listener 清理池 + return () => document.removeEventListener('keydown', onKey); +} + +// ============================================ +// 启动 +// ============================================ + +bootstrap(); diff --git a/src/event-emitter.js b/src/event-emitter.js new file mode 100644 index 0000000..3453530 --- /dev/null +++ b/src/event-emitter.js @@ -0,0 +1,56 @@ +// 简单事件发射器 - 可被多个 store 复用 + +export class EventEmitter { + constructor() { + this._handlers = new Map(); + } + + on(event, handler) { + if (!this._handlers.has(event)) this._handlers.set(event, new Set()); + this._handlers.get(event).add(handler); + return () => this.off(event, handler); + } + + off(event, handler) { + // 如果传进来的是 once 的原 handler,把对应的 wrapper 也一并解绑 — + // 避免 wrapper 残留导致触发后又跑一次 handler。 + const wrapper = this._onceWrappers?.get(handler); + if (wrapper) { + this._handlers.get(event)?.delete(wrapper); + this._onceWrappers.delete(handler); + return; + } + this._handlers.get(event)?.delete(handler); + } + + // 一次性监听:触发一次后自动注销。返回的取消函数可在触发前主动取消。 + // + // 关键:`off(event, handler)` 拿到的是调用方的原 handler,但 Set 里实际存的是 wrapper。 + // 旧实现下,用 `off` 取消 once 监听不会真的从 Set 里移除 wrapper —— 触发一次后 + // wrapper 还会再触发原 handler(再 + 1 次)。这里走单独的取消路径: + // - once 返回的取消函数能正确解绑 wrapper + // - off(event, originalHandler) 仅移除 Set 里的 originalHandler(如果被 add 过) + // - wrapper 通过弱映射挂在 originalHandler 上,GC 后自动清掉 + once(event, handler) { + const wrapper = (data) => { + // 触发时优先用 off 解绑 wrapper —— 避免下面 wrapper 还引用自己导致 Set 删除失败 + this.off(event, wrapper); + // 同步把 handler 上的 wrapper 引用清掉,方便 once 的取消函数找到它 + if (this._onceWrappers && this._onceWrappers.has(handler)) { + this._onceWrappers.delete(handler); + } + handler(data); + }; + // 维护「原 handler → wrapper」映射,使 off(handler) 能找到 wrapper 解绑 + if (!this._onceWrappers) this._onceWrappers = new WeakMap(); + this._onceWrappers.set(handler, wrapper); + return this.on(event, wrapper); + } + + emit(event, data) { + this._handlers.get(event)?.forEach(h => { + try { h(data); } + catch (e) { console.error(`[Event ${event}]`, e); } + }); + } +} diff --git a/src/markdown-parser.js b/src/markdown-parser.js new file mode 100644 index 0000000..adb2aaa --- /dev/null +++ b/src/markdown-parser.js @@ -0,0 +1,834 @@ +// Markdown 解析器 +// 将 Markdown 文本解析为分类与任务树 +// +// 结构(v3 统一为标识驱动,v3.6 起移除顶部镜像段): +// - 全部任务(kind=OTHER_TASKS,容器;兼容旧名「其他任务」/「待办任务」/「全部任务」) +// ## 工作 / ## 学习 / ## 生活 / ## 未分类(kind=NORMAL, parentOtherTasks=true) +// - 回收站(kind=TRASH,特殊;删除的任务落点,软件不提供清空入口) +// - 已完成任务(kind=COMPLETED,v3.4 起升级为真实分类,[✓] 打勾后任务自动搬到这里) +// +// 任务用 inline 标识表达状态(与 Markdown 风格天然贴合): +// - [✓] 已完成(checkbox 标记) +// - ⭐ 重要(U+2B50 白色中等星形) +// - ▶ 当前焦点(U+25B6 黑色右指三角;与 ⭐ 视觉权重对等) +// +// 智能视图(侧边栏的「当前任务」「重要任务」「已完成任务」)由 task-store +// 在内存中按任务标识(important / current / completed)实时聚合: +// - 「当前任务」:task.current=true(行末 [▶] 标记)的任务 +// - 「重要任务」:task.important=true(行末 [⭐] 标记)的任务 +// - 「已完成任务」:直接读 kind=COMPLETED 分类 +// 文件里**不再**为这些视图单独写一段 —— 同一条任务在文件里只活一次。 +// +// 不再支持:Archive / Completed / Planned / 优先级 / 标签 / 备注 / 子步骤。 +// 任务字段:text / completed / important / current。 +// 旧文件中残留的元数据标记([!high] / #tag / 缩进子步骤 / >备注)会被当作普通文本保留。 +// +// 兼容旧文件: +// - `# 当前任务` / `# 我的一天` 等旧章节在解析时仍被识别为 TODAY, +// 任务进入一个临时分类;store 的迁移逻辑会把这些任务搬到首个子分类 +// 并打上 ▶ 标记,然后删掉临时分类。下一次保存就只剩「全部任务」 +// 下的扁平结构。 +// - v3.x 顶部摘要 `# 当前任务` / `# 重要任务` 旧镜像段也走同样的迁移路径: +// 前者按 TODAY 迁移;后者作为 SMART_LIST_KEYS 命中被解析器整体跳过, +// 段下任务行归入「未分类」子分类(保守策略)。 +// - 行尾 `` 注释(旧版写入器自动追加)会被剥掉以避免 +// 污染 UI 文本,但不从中提取 id —— 任务身份由解析器末尾的统一计数器 +// 分配(t1, t2, ...),跨重载重新生成。 + +// 特殊分类识别(不区分大小写、忽略空格)。 +// 「当前任务」/「重要任务」仅在旧文件兼容时识别 —— 写入器已经不再写这些章节, +// store 迁移后即消失;新文件里读到这两个名字时按下方 SMART_LIST_KEYS / TODAY_KEYS +// 分流(前者在 __skip__ 路径下整段跳过、任务归「未分类」;后者走 TODAY 迁移)。 +export const TODAY_KEYS = ['today', '今日', '今天', '当日', '今', '我的一天', '当前任务', 'myday', 'my day']; +// 「其他」单独作为子分类名是允许的(与「未分类」兜底不同语义)—— +// 因此 OTHER_TASKS_KEYS 只放**容器** h1 的别名(包括化合物「其他任务」/「待办任务」/「全部任务」 +// 和英文 todo 变体),不放孤立的「其他」。addSubCategory / renameCategory / UI 校验都按 +// 这个数组作黑名单,把「其他」混进去会误封普通子分类的创建/重命名路径。 +export const OTHER_TASKS_KEYS = ['其他任务', '全部任务', '待办任务', 'todo', 'todos', 'to-do', 'to do', 'other tasks', 'othertasks', 'others']; +export const TRASH_KEYS = ['回收站', '垃圾桶', '废纸篓', 'trash', 'trashcan', 'recycle bin', 'recyclebin', 'bin']; + +// 智能列表段落名(仅用于旧文件兼容:读取时跳过,**不再写**)。 +// +// 文件 v3 起彻底不再生成这些段落。智能列表视图全部由 task-store 在内存中 +// 按任务标识(important / current)实时聚合,无需文件镜像。 +// 保留这套 key 只是为了让解析器在读到旧版文件时不至于把里面的任务行 +// 误收进真实分类 —— 与「全部任务」容器本身已含所有任务的理念一致。 +// +// 「已完成任务」v3.4 起不再是镜像段:它被提升为真实分类(kind=COMPLETED), +// `[✓]` 打勾的任务会**自动移动**到这里 —— 见 COMPLETED_KEYS 与 CategoryKind.COMPLETED。 +// 这里不再保留它的别名,避免被识别成「跳过」分类。 +export const SMART_LIST_KEYS = [ + '重要任务', 'important tasks', 'important', + '全部任务列表', 'all tasks', 'alltasks' +]; + +// 「当前」标识:与 ⭐ 一样靠末尾 inline 标记。 +// 用 ▶(U+25B6 BLACK RIGHT-POINTING TRIANGLE)作为当前标记: +// - 单字符 unicode,视觉权重和 ⭐ 对等,文件里一眼能扫到 +// - 「播放 / 进行中」语义贴切「当前正在做的事」 +// - BMP 字符,所有主流字体必支持 +// +// 向后兼容 —— 解析器同时认 @当前(旧版本的文本标记): +// - 用户可能升级前手敲过 @当前 来标记当前任务 +// - 文件升级前可能存在遗留 @当前 行 +// - 写入器只输出新标记 ▶,旧标记在加载时仍然被识别 +export const CURRENT_MARKER = '▶'; +// 旧版标记:解析时仍识别(向后兼容),写入时不再使用 +const LEGACY_CURRENT_MARKER = '@当前'; + +// 反向转义 helper —— writer 把原分类里的 `]` / `\` 转义成 `\]` / `\\` 以便 +// `[原分类:xxx]` 标记能在包含 `]` 的分类名下闭合(C1 修复)。 +// 这里把 `\\X` 反向成单字符:支持 `]` 与 `\` 两种(未来要支持更多也只需扩展 here)。 +function unescapeOriginalCategory(s) { + return String(s || '').replace(/\\(.)/g, '$1'); +} + +// 重要任务 inline 标记 —— 与 CURRENT_MARKER 对称。writer 拼 [⭐] 时必须复用此常量, +// 而不是字面量:否则哪天产品决定把 ⭐ 换成 🔥,必须 parser + writer 两处同时改, +// 容易漂移。 +export const IMPORTANT_MARKER = '⭐'; + +// 行首 / 行尾的 inline 标记正则(用 IMPORTANT_MARKER / CURRENT_MARKER 拼出), +// 给 extractInlineMarkers 复用 —— 不能再字面量写 ⭐ / ▶: +// - 与上面的 IMPORTANT_MARKER 注释同款问题:常量是 single source of truth, +// regex 字面量是另一份 source +// - writer 已经用 `[${CURRENT_MARKER}]` / `[${IMPORTANT_MARKER}]` 拼输出, +// parser 也必须对称地复用常量,否则哪天改标记两边就会漂移 +// 用 new RegExp(...) 在模块加载时构造一次,运行期零成本 —— 模块顶层只算一次。 +// +// ⚠️ 前提:IMPORTANT_MARKER / CURRENT_MARKER 必须是纯字符(不能含正则元字符)。 +// 历史上两个常量都是单个 Unicode 码点(⭐ / ▶),满足前提;若以后改成 +// 多字符或带元字符的字符串,要么转义,要么回退到字面量 regex。 +const MARKER_HEAD_RE = new RegExp(`^\\[(${IMPORTANT_MARKER}|${CURRENT_MARKER})\\]`); +// 行尾的 [⭐]/[▶]:v3.7+ 去掉前导 `\s`(修复 H5)。 +// 旧版强制空白前缀,导致 `任务标题[⭐]foo` / `unimportant[⭐]`(用户文本里紧贴 +// 其它字符)识别不到,important 永远 false,下一次保存原样写回 —— 数据丢失。 +// 现在任意位置的尾标记都识别(行尾锚定保证不会吃行内"对比 [⭐] 用法"这种纯说明文本)。 +// `任务标题[⭐]` → important=true,剥后 text='任务标题'。 +const MARKER_TAIL_RE = new RegExp(`\\[(${IMPORTANT_MARKER}|${CURRENT_MARKER})\\]$`); + +/** + * 任务行正则:`- [x] 文本` / `* [✓] 文本` / `+ [ ] 文本` 都算合法任务。 + * + * 共享原因:parseMarkdown(主路径)和 parseImportLine(导入路径)历史上字面 + * 复制同一正则,外加 task-store.js 的 parseImportLine 也再写一份。任何一处改了 + * 「`+ ` 也算任务」之类的规则,三处都会漂移。集中到此常量 + 让 task-store 复用, + * 是 v3+ 防漂移的最低成本防线。 + * + * ⚠️ 字符类必须是 `[ ✓xX]`(仅 4 种合法 checkbox 字符),不能用 `[^\]]+`: + * 旧版用 `[^\]]+` 让 `⭐` / `▶` 也能匹配第一组 —— 这导致 `- [⭐] foo` 被解析为 + * marker='⭐'(被当作 checkbox), text='foo', completed=false + * 即 `[⭐]` 这层 inline 标记被 TASK_LINE_RE **吃掉**,extractInlineMarkers 拿到的 + * 是裸 'foo',永远 important=false —— 用户在 UI 上勾了「重要」保存后,再次加载 + * 重要标记就消失了(**静默数据丢失**)。同理 `- [▶] foo` 会丢 current。 + * 字符类只允许 ` ✓xX` 后,inline 标记 `⭐` / `▶` 必须落到第二组(text),由 + * extractInlineMarkers 正确剥出并翻译成 important / current。 + * + * 兼容性:旧文件里如果出现 `- [!high] foo` 之类的"非标准 checkbox"(marker='!high'), + * 也会**被新规则跳过**(第一组字符不再贪婪)。这种行会被下方 fallback 接管: + * 去掉前导 `- ` 当作列表项文本送进 extractInlineMarkers —— 不会丢数据, + * 但也不会被识别为任务(用户用 !high 等非标准标记不是合法任务)。 + */ +export const TASK_LINE_RE = /^[-*+]\s+\[([ ✓xX])\](?:\s+(.*))?$/; + +/** + * 任务勾选标记的「完成态」判定。 + * + * 三种合法标记:[✓](首选)/ [x] / [X](旧文件兼容)。 + * 与 parseMarkdown / parseImportLine / writer 的写入约定严格对齐 —— 任一处 + * 改了会立刻被这层规则拉齐。 + * + * @param {string} ch - 单个字符(如从 `taskMatch[1]` 取出的方括号内字符) + * @returns {boolean} + */ +export function isCompletedMarker(ch) { + return ch === '✓' || ch === 'x' || ch === 'X'; +} + +/** + * 给 categories 里的任务统一分配运行时 id(`t1`、`t2` ...)。 + * + * 设计决策:任务 id 是运行时句柄,不是文件身份。 + * - 写入器 v3+ 不再写出 `` 注释 + * - 解析器也不再从文本里还原 id + * - 每次加载(parseMarkdown 末尾 / mergeFromDisk / loadDefault)统一从 t1 + * 开始分配,跨重载重新生成 + * + * 设计细节:parser 这版是「全量重置」式 —— 从 t1 开始无条件覆盖所有 task.id, + * 用于 parseMarkdown() 末尾的初次分配。task-store.js 自己另有 _assignTaskIds 实例 + * 方法,是「保留已有」式(只在 !task.id 时补值),用于增量场景(merge / loadDefault + * 时已经带 id 的任务不重置)。两处公式虽相似但语义不同,不能直接复用 —— 因此 + * 这版保持模块内部 helper,不对外 export,避免「看起来可复用、实际语义错」的误用。 + * + * @param {Array} categories + */ +function assignTaskIds(categories) { + let counter = 0; + for (const cat of categories) { + if (cat.kind === CategoryKind.OTHER_TASKS) continue; // 容器本身无任务 + for (const task of cat.tasks) { + task.id = 't' + (++counter); + } + } +} + +// 规范名常量 +// +// 为什么定义在这里(而不是 task-store.js):markdown-parser 是依赖树的叶子, +// 谁都可以安全 import。之前 markdown-writer 从 task-store 取 OTHER_TASKS_NAME, +// 而 task-store 又 import markdown-writer —— 形成循环依赖,ESM 下 +// 会让先被求值的那一方拿到 undefined(取决于入口顺序),非常脆弱。 +// 现在两者都从这里取,环被打断。 + +// 「全部任务」容器的固定名称(v2 改名链:其他任务 → 待办任务 → 全部任务,含当前任务) +export const OTHER_TASKS_NAME = '全部任务'; + +// 「当前任务」分类的规范名(迁移与默认创建都收敛到此名) +export const TODAY_CANONICAL_NAME = '当前任务'; + +// 「未分类」子分类的规范名 —— 删除任何子分类时,其任务都迁移到此分类下, +// 该分类本身不可删除(兜底必然存在,否则没有地方承接被释放的任务) +export const UNCATEGORIZED_NAME = '未分类'; + +// 「回收站」分类的规范名 —— 所有在界面上「删除」的任务都移到这里,不真正丢弃。 +// 软件刻意不提供「清空回收站」入口:彻底删除必须由用户手工编辑 Markdown, +// 这样任何误删都可以从文件里找回。 +export const TRASH_NAME = '回收站'; + +// 「已完成任务」分类的规范名 —— `[✓]` 打勾的任务会自动移到本节。 +// 它是真实分类(kind=COMPLETED)而不是智能视图,原因: +// 1) 旧版「已完成任务」是 v3 起作废的镜像段;v3.4 把它升级为真实数据源, +// 任务实体只活在一个地方 —— 文件和数据模型都不再有冗余。 +// 2) 与「回收站」同款处理:勾选即移动、取消勾选即回到它原来的分类 +// (v3.5+ 记在 `[原分类:xxx]` 里)。原分类丢了让 UI 弹选择器, +// 没有原分类记录才兜底到「未分类」。行为对称、数据归属明确。 +// 3) UI 侧「已完成任务」智能视图直接读这个分类 —— 聚合逻辑保持简单。 +export const COMPLETED_NAME = '已完成任务'; + +// 「已完成任务」分类的所有别名:解析时识别,writer 统一写为规范名 `已完成任务`。 +// 兼容旧文件用过的英文别名,避免升级后用户的「# Completed Tasks」被误当普通分类。 +export const COMPLETED_KEYS = ['已完成任务', 'completed tasks', 'completedtasks', 'completed', '完成', 'finished']; + +// 分类类型枚举 +export const CategoryKind = Object.freeze({ + TODAY: 'today', + OTHER_TASKS: 'other_tasks', // 容器:存放子分类(h2) + NORMAL: 'normal', // 普通分类(可作为「其他任务」的子分类) + TRASH: 'trash', // 回收站:删除的任务落点,排除于所有智能列表之外 + COMPLETED: 'completed' // 已完成任务:[✓] 打勾后任务自动移动到这里,取消勾选回到原分类([原分类:xxx]) +}); + +/** + * 解析 Markdown 为任务树 + * @param {string} content + * @returns {Array} 分类列表 + */ +export function parseMarkdown(content) { + if (!content || typeof content !== 'string') { + return []; + } + + // 去掉文件开头的 UTF-8 BOM(Windows 上一些编辑器会加)。 + // 不去的话 ^#\s+ 永远不匹配,整个文件被当作「无标题的纯任务清单」处理, + // 再走一次 _ensureBaseStructure 写出新文档 —— BOM 用户每次保存都会重写文件。 + const normalized = content.charCodeAt(0) === 0xFEFF ? content.slice(1) : content; + + // 检测文件是否为「新格式」:包含「全部任务」容器段(容器是 v3 唯一真实数据源)。 + // + // v3.6 起不再有顶部 `# 当前任务` / `# 重要任务` 镜像段 —— 这些段在旧文件 + // (v3.x 之前版本写过镜像段的)里可能仍然存在,按以下路径消化: + // - # 当前任务 → TODAY 分类,store 的迁移逻辑搬到首个子分类并打 [▶] + // - # 重要任务 → __skip__,任务归入「未分类」 + // 下一次保存即扁平化为新结构。 + // + // 注意:用 OR 形式匹配「全部任务」+ 旧名「其他任务」/「待办任务」—— 容器重命名链收敛后 + // 实际只剩「全部任务」,但旧文件可能还有旧名残留。 + // + // 不用 `\b` 锚定:中文字符在 JavaScript regex 里不是 word char,`\b` 在「全部任务」与 + // 换行符之间不构成边界,会让整个检测静默失效 —— 中文字符段标题被误判为旧格式。 + // 改用反向断言(?=[#\s]|$) / 行尾锚定来匹配「标题后跟空白或行尾」。 + + const lines = normalized.split(/\r?\n/); + const categories = []; + let currentCategory = null; + let insideOtherTasks = false; + let blockComment = false; + // 上一个 h1 是被跳过的废弃段落(归档/已完成/…)时,其下的孤儿任务 + // 归入「未分类」而不是「当前任务」,见下方 __skip__ 分支的说明 + let orphansToUncategorized = false; + // 上一个 h1 是「重要任务」等智能镜像段时(v3.x 旧文件残留),其下的任务即使 + // 没有 inline `[⭐]` 标记也要被打上 important=true —— 否则「# 重要任务」整段被 + // 跳过后,里面的任务会丢光重要标记,用户在「重要任务」智能视图里看不到。 + // 仅对真正代表"重要"的段生效;归档/历史等 __skip__ 段不传染。 + let inheritImportantFromHeading = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + // 块注释跳过 + if (blockComment) { + if (trimmed.includes('-->')) blockComment = false; + continue; + } + if (trimmed.startsWith('')) { + blockComment = true; + } + continue; + } + + if (!trimmed) continue; + + // 一级标题:分类开始 + const h1Match = trimmed.match(/^#\s+(.+)$/); + if (h1Match) { + const name = h1Match[1].trim(); + + const kind = classifyByName(name); + + // 已废弃的概念(Archive / Completed / Important / 当前任务…):不再建成分类。 + // + // 「当前任务」v3 起彻底下沉为智能视图(@当前 标识驱动),但旧文件里仍可能 + // 存在 `# 当前任务` 章节。把它们也当作「废弃段落」处理 —— 任务落到临时 + // TODAY 分类,store 的迁移逻辑会把任务搬到首个子分类、打上 @当前 标记、 + // 然后删掉这个临时分类。第一次保存后文件结构自然扁平化。 + // + // 「重要任务」v3.x 顶部镜像段走 __skip__ 路径:整段跳过、段下任务行归入 + // 「未分类」(保守策略,宁可多一条需要用户挪动的任务,也不要少一条)。 + // + // 注意这里**不会丢弃**其下的任务:后面的任务行会走 `!currentCategory` 的兜底。 + // 但兜底默认是「当前任务」(kind=TODAY),意味着一个 `# 归档` 段落里几百条历史 + // 已完成项会被静默灌进用户的当前任务列表。所以这里改用「未分类」作为兜底目标 —— + // 它本来就是孤儿任务的指定归属地,且是懒创建(该段落没任务就不会凭空出现)。 + if (kind === '__skip__') { + currentCategory = null; + insideOtherTasks = false; + orphansToUncategorized = true; + // 仅当分类名命中「重要任务」类智能镜像段时,传染 important 标记到下面的任务。 + // 其它 __skip__(归档/历史等)保持原行为,任务不再标记为重要。 + inheritImportantFromHeading = isInheritedImportantHeading(name); + continue; + } + inheritImportantFromHeading = false; + + // 旧版「# 当前任务」/「# 我的一天」等章节:保留解析为 TODAY 分类的兜底能力, + // 让旧文件迁移有路径。store 的 _migrateTodayCategory 会处理后续: + // 1) 把任务搬到首个子分类 + // 2) 给每条任务打 current=true + // 3) 删掉这个临时分类 + // 新文件不再生成这样的 h1 —— writeMarkdown 已移除。 + if (kind === CategoryKind.TODAY) { + currentCategory = createCategory(name, kind); + categories.push(currentCategory); + insideOtherTasks = false; + orphansToUncategorized = false; + continue; + } + + // 「# 回收站」「# 已完成任务」h1 出现多份时(Git 合并冲突没解干净 / 外部编辑器手敲多份): + // 旧行为是逐个 push 出多个 kind=TRASH / kind=COMPLETED —— 后续任务归到最后一个, + // 中间那些的任务在 UI 上既看不见也没法恢复(getTrashCategory / getCompletedCategory + // 只返回第一个)。store 层的 _consolidateTrash / _consolidateCompleted 兜底合并, + // 但这里能在 parser 阶段直接复用第一份,把后续 h1 下的任务正确地追加到那个分类, + // 避免"先 push 多份 → 再被 store 合并"产生的瞬时副作用(侧边栏可能短暂看到两份)。 + // 与下面 h2 的「## 未分类 复用第一份」同款防御。 + // + // 「# 全部任务」容器也加同款防御(v3.7+ 修复 H1):重复「# 全部任务」时 + // 复用第一份,否则 writer 会连续写出两份 `## 子分类`,子分类归属错乱。 + if ( + kind === CategoryKind.TRASH || + kind === CategoryKind.COMPLETED || + kind === CategoryKind.OTHER_TASKS + ) { + const existing = categories.find(c => c.kind === kind); + if (existing) { + currentCategory = existing; + insideOtherTasks = false; + orphansToUncategorized = false; + continue; + } + } + + // 其他任务容器:作为 h1,但本身不存任务(仅作容器标识) + currentCategory = createCategory(name, kind); + categories.push(currentCategory); + insideOtherTasks = (kind === CategoryKind.OTHER_TASKS); + orphansToUncategorized = false; + // v3.7+ 修复(H3 后置重排):遇到 # 全部任务时,把所有出现在它之前 + // 的 parentOtherTasks=true 子分类(典型来源:被 __skip__ 段顶到文件 + // 顶部的「未分类」孤儿)移到容器之后 —— writer 依赖数组顺序把 parentOtherTasks + // 分类写成容器下的 `##`,否则会把「## 未分类」写到 `# 全部任务` 前面、 + // 看起来像顶级分类(解析器再读又会按 orphan 路径处理,死循环脏数据)。 + if (kind === CategoryKind.OTHER_TASKS) { + const containerIdx = categories.length - 1; // 刚 push 进去 + const beforeContainer = categories + .slice(0, containerIdx) + .filter(c => c.parentOtherTasks); + if (beforeContainer.length > 0) { + // 从原位置删掉 + for (const c of beforeContainer) { + const idx = categories.indexOf(c); + if (idx >= 0) categories.splice(idx, 1); + } + // 紧跟容器插入(保留原相对顺序) + categories.splice(containerIdx, 0, ...beforeContainer); + // currentCategory 不变(仍是 # 全部任务 容器本身) + } + } + continue; + } + + // 二级标题:在「其他任务」容器下作为子分类 + const h2Match = trimmed.match(/^##\s+(.+)$/); + if (h2Match) { + if (!insideOtherTasks) { + // 在真实分类(当前任务 / 普通分类 / 回收站)下出现的 h2: + // 软件不会生成这种结构,但用户手工编辑时很自然会写出 `## 子任务` 之类的东西。 + // 旧行为是直接把 h2 吞掉、后续任务归入「未分类」 —— 这是不可逆的数据重组, + // 用户原本想表达的「## 工作」会被静默丢掉。 + // 改为(v3.7+ 修复 H2):把 orphan h2 提升为顶级 NORMAL 分类 + // (parentOtherTasks=false),后续任务归到这个分类下 —— 至少用户能在 + // UI 里看到分类名并调整归属,不会静默丢数据。 + // 同名去重:若已存在同名顶级 NORMAL 分类,复用第一份(与 OTHER_TASKS 同款防御)。 + // + // **v3.7+ 修复(H3)**:orphan h2 名为「未分类」时**必须走保留名路径**, + // 复用容器下的「未分类」(或新建 parentOtherTasks=true 的那份),否则 + // 会创建顶级 NORMAL 「未分类」+ 容器下「未分类」两个同名分类 —— sidebar + // 渲染两份入口、用户能看到但点不到第二个的位置(任务丢了的体感 bug)。 + // 「未分类」是容器保留名(README 明确),语义上必须是容器下子分类, + // 不允许顶级存在同名项。 + const orphanName = h2Match[1].trim(); + if (orphanName === UNCATEGORIZED_NAME) { + currentCategory = getOrCreateUncategorized(categories); + continue; + } + const existing = categories.find( + c => c.kind === CategoryKind.NORMAL && !c.parentOtherTasks && c.name === orphanName + ); + if (existing) { + currentCategory = existing; + } else { + currentCategory = createCategory(orphanName, CategoryKind.NORMAL); + categories.push(currentCategory); + // debug 日志方便发现异常文件结构 + if (typeof console !== 'undefined' && console.debug) { + console.debug('[parser] orphan h2 提升为顶级分类:', orphanName); + } + } + continue; + } + const name = h2Match[1].trim(); + // 「未分类」是容器下的保留名(任务兜底,README 明确不可重复)。 + // 手工编辑 / 外部合并都可能写出第二个 `## 未分类`。如果照常 push, + // - sidebar 渲染两份同名入口(用户看到的 bug) + // - writer 把两份都写出去,下次加载又被 parser 读成两份,自维持脏数据 + // - 第二个 h2 之后的任务被丢到一个永远选不到的"幽灵"分类(sidebar + // 里两份「未分类」都点不到那一批任务的位置,体感"任务丢了") + // 改为复用第一份:第二个 `## 未分类` 不再创建新分类、只把 currentCategory + // 重指到已有的那份,让后续任务继续往里追加。 + // store 层的 _consolidateUncategorized 兜底防绕过。 + if (name === UNCATEGORIZED_NAME) { + const existing = categories.find( + c => c.parentOtherTasks && c.name === UNCATEGORIZED_NAME + ); + if (existing) { + currentCategory = existing; + continue; + } + } + currentCategory = createCategory(name, CategoryKind.NORMAL); + currentCategory.parentOtherTasks = true; + categories.push(currentCategory); + continue; + } + + // 三级及以下标题:忽略 + if (/^#{3,}\s+/.test(trimmed)) continue; + + // 引用块:旧版本 Today 元数据或备注 —— 全部忽略 + if (trimmed.startsWith('>')) continue; + + // 任务列表项 + // 括号内允许任意字符(包括旧的 [!high] 等元数据标记 —— 现在已不再解析为元数据, + // 但旧文件不应被无声丢弃,标记会以原文形式保留在任务文本中)。 + // 文本部分允许为空 —— `- [x]` 这种「纯勾选无文本」的合法手写形式也要识别, + // 否则会被 _ensureBaseStructure 当成纯文本忽略、保存时直接消失,任务数据丢失。 + // + // **v3.7+ 修复**:TASK_LINE_RE 收紧到只接受 4 种合法 checkbox 字符(` ✓xX`)。 + // 旧版用 `[^\]]+` 会让 `- [⭐] foo` 被解析为 marker='⭐' + text='foo', + // 等于把 inline `[⭐]` 标记**吃掉**,重要状态静默丢失。详见 TASK_LINE_RE 注释。 + const taskMatch = trimmed.match(TASK_LINE_RE); + if (taskMatch) { + // Markdown 是真相之源,用户手写的纯任务清单(无标题)也必须完整读进来, + // 否则加载后自动保存会把原文件覆写成空文档。 + // - 无标题 → 归入隐式的「当前任务」 + // - 废弃标题下 → 归入容器下的「未分类」(见 __skip__ 分支) + // - 容器标题下 → 归入容器下的「未分类」子分类(容器本身不存任务) + if (!currentCategory) { + currentCategory = orphansToUncategorized + ? getOrCreateUncategorized(categories) + : getOrCreateImplicitToday(categories); + } else if (currentCategory.kind === CategoryKind.OTHER_TASKS) { + currentCategory = getOrCreateUncategorized(categories); + } + + const marker = taskMatch[1]; + // 任务文本:可能为空(合法:`- [x]`)。下面的所有 strip 都要容忍空字符串, + // 之前写 `taskMatch[2]` 在没有文本组的旧正则下会 undefined —— 一并修了。 + let text = (taskMatch[2] || '').trim(); + + // 完成标记:[✓](首选)/ [x] / [X](旧文件兼容) + let completed = isCompletedMarker(marker); + + // 行尾的 `` 注释(v3 之前写入器会自动追加)。 +// 写入器已不再生成,所以新文件不会有;但旧文件加载时仍要剥掉, +// 否则这些字符会作为任务文本的一部分显示在 UI 上。 +// +// 锚定行尾:用户可能手敲 `` 作为任务文本的一部分 +// (比如示例代码、文档片段)。无锚定时正则会从文本中间抠出第一个 +// 匹配,悄无声息地把用户的字串删掉 —— 是低门槛的数据损坏 vector。 +// 锚 $ 后只剥末尾的那一个。 +// +// 注意:v3+ 不再从文件里提取 id 作为任务身份。task.id 由本函数末尾 +// 的统一计数器分配,跨重载重新生成。详见 end of parseMarkdown()。 + text = text.replace(/$/, '').trim(); + + // 重要标记 + 当前标识 + 原分类:从文本中抽取并剥掉(共享逻辑,见 extractInlineMarkers)。 + // 不在原处写一份,是为了让 parseImportLine 能复用完全相同的解析语义, + // 避免「导入说可以 / 保存后又说不行」或「标记不被翻译成 important/current 字段」。 + const markers = extractInlineMarkers(text); + text = markers.text; + const task = { + text, + completed, + // 行内 [⭐] 标记已存在 → 直接 true;否则仅当父 h1 是「重要任务」类镜像段时才传染 true。 + // 避免 archive/历史段下的普通任务被错误标记成 important。 + important: markers.important || inheritImportantFromHeading, + current: markers.current, + originalCategory: markers.originalCategory + }; + currentCategory.tasks.push(task); + continue; + } + + // 任务列表项 fallback —— TASK_LINE_RE 收紧到 `[ ✓xX]` 后,`- [⭐] foo` / + // `- [▶] foo` / `- [!high] foo` 这类「首字符不是合法 checkbox」的列表项被拒收。 + // 必须兜住这些行 —— 静默忽略会导致用户在 UI 勾了「重要」保存后再次加载时 + // 重要标记消失(数据丢失)。做法: + // 1) 匹配 `- [X]` / `* [X]` / `+ [X]`,X ∈ 非 checkbox 单字符 + // 2) 去掉前导 `- ` 把剩余文本(包括首括号)当成 task.text 送去 extractInlineMarkers + // → inline 标记被正确翻译成 important / current / originalCategory 字段 + // 3) 没有 checkbox 字符 → completed=false(视为未勾选) + // + // 注意:也覆盖用户手敲的 `- [原分类:X] foo` —— 之前由 TASK_LINE_RE 误把 + // `[原分类:X]` 整个当成 marker(marker='原分类:X'),现在正确剥出为 originalCategory。 + // + // 排除字符集:4 种合法 checkbox 字符(` ✓xX`),这些已被上方 TASK_LINE_RE + // 处理掉,无需 fallback。其它单字符(⭐、▶、!、?、A-Z 等)都走 fallback, + // 保持旧版"宽容吃下任何字符"的行为 —— inline 标记(⭐/▶)的精确语义 + // 由 extractInlineMarkers 决定,fallback 只负责"不被忽略"。 + // + // 注意:第一个括号后允许 `\s*`(不强制空白),让 `- [▶][⭐] 当前且重要` + // 这类连续 inline 标记也能命中 —— 第二个 `[⭐]` 没有前导空白也能被整体 + // 捕获进 text,让 extractInlineMarkers 正常剥出。 + // + // 注意:括号内允许多字符非 checkbox 内容(`[^ ✓xX\]]+`),不再卡单字符。 + // 旧版 `[^ ✓xX]` 单字符限制会让 `- [原分类:工作] 写周报` 这种多字符首括号 + // 行**完全匹配不上**(`原` 后面期望 `]` 但实际是 `分`)→ 整行静默被忽略, + // 数据丢失(round 5 审计 H1)。多字符兜底后,`[原分类:X]` / `[!important]` / + // `[foo]` 都能落到 text → extractInlineMarkers 把 `[原分类:X]` 正确剥到 + // originalCategory 字段;其它多字符内容(如 `[!important]`)当成字面文本 + // 保留,让 inline 标记翻译流程统一处理。 + const listFallbackMatch = /^[-*+]\s+\[([^ ✓xX\]]+)\](?:\s*(.*))?$/.exec(trimmed); + if (listFallbackMatch) { + // currentCategory 兜底逻辑与 TASK_LINE_RE 分支一致(见上) + if (!currentCategory) { + currentCategory = orphansToUncategorized + ? getOrCreateUncategorized(categories) + : getOrCreateImplicitToday(categories); + } else if (currentCategory.kind === CategoryKind.OTHER_TASKS) { + currentCategory = getOrCreateUncategorized(categories); + } + let text = (listFallbackMatch[2] || '').trim(); + // 把被 fallback 吃掉的 `[X]` 重新拼回 text —— extractInlineMarkers 才能 + // 看到 `[⭐]` / `[▶]` / `[原分类:X]` 等 inline 标记。否则相当于在 + // parseMarkdown 路径上把 inline 标记又丢了一次(和旧 TASK_LINE_RE bug 同款)。 + text = '[' + listFallbackMatch[1] + ']' + (text ? ' ' + text : ''); + text = text.replace(/$/, '').trim(); + const markers = extractInlineMarkers(text); + const task = { + text: markers.text, + completed: false, // 无合法 checkbox → 一律视为未完成 + important: markers.important, + current: markers.current, + originalCategory: markers.originalCategory, + }; + currentCategory.tasks.push(task); + continue; + } + } + + // v3 起不再要求存在「当前任务」分类 —— 它已经下沉为智能视图(@当前 标识驱动)。 + // 旧文件里若仍有 # 当前任务 章节,迁移由 store 的 _migrateTodayCategory 处理。 + + // 确保存在「全部任务」容器(兼容旧名「其他任务」/「待办任务」) + if (!categories.find(c => c.kind === CategoryKind.OTHER_TASKS)) { + const o = createCategory(OTHER_TASKS_NAME, CategoryKind.OTHER_TASKS); + categories.unshift(o); + } + + // 为每个任务分配运行时 id。 + // + // 写入器(markdown-writer)v3+ 不再写 `` 注释,所以解析器也不再 + // 从文本里提取。任务在内存中的身份由本循环统一从 t1 开始分配, + // 在本次加载周期内稳定,跨重载重新生成 —— 这是用户已接受的代价: + // 外部编辑文件后,UI 选中态可能错位。 + assignTaskIds(categories); + + return categories; +} + +/** + * 判定 h1 名称是否是「传染 important 标记」的智能镜像段。 + * 仅对真正代表"重要"语义的段生效 —— 归档/历史/已计划等已废弃段不应传染。 + * + * @param {string} name + * @returns {boolean} + */ +function isInheritedImportantHeading(name) { + const lower = (name || '').toLowerCase().trim(); + // SMART_LIST_KEYS 已包含 '重要任务' / 'important' / 'important tasks' / + // '全部任务列表' / 'all tasks' / 'alltasks'。其中: + // - '重要任务' / 'important' / 'important tasks' → 传染 important = true + // - '全部任务列表' / 'all tasks' / 'alltasks' → 不传染(这只是一个总览段, + // 里面的任务未必是重要的,只是被列出来而已) + return ( + lower === '重要任务' || lower === 'important' || lower === 'important tasks' + ); +} + +/** + * 根据名称识别分类类型。 + * @returns {string} CategoryKind 或 '__skip__'(跳过) + */ +function classifyByName(name) { + const lower = (name || '').toLowerCase().trim(); + // 已废弃的概念:完全跳过 + if ( + lower === 'archive' || lower === '归档' || lower === '历史' || + lower === 'planned' || lower === '已计划' + ) { + return '__skip__'; + } + // 智能列表段落(重要任务 / 全部任务列表):跳过 —— + // 这些段落 v3.x 顶部镜像段残留会在旧文件里出现:真实任务靠「全部任务」容器里的 + // [▶]/[⭐] 标识承载,不需要再单独维护一个段;读取时整体跳过、段下任务归「未分类」, + // 下次保存即清理。 + // 注:「已完成任务」v3.4 起不再是镜像段而是真实分类(kind=COMPLETED), + // 由下面 COMPLETED_KEYS 分支处理,不在这里跳过。 + if (SMART_LIST_KEYS.includes(lower)) return '__skip__'; + if (TODAY_KEYS.includes(lower)) return CategoryKind.TODAY; + if (OTHER_TASKS_KEYS.includes(lower)) return CategoryKind.OTHER_TASKS; + if (TRASH_KEYS.includes(lower)) return CategoryKind.TRASH; + if (COMPLETED_KEYS.includes(lower)) return CategoryKind.COMPLETED; + return CategoryKind.NORMAL; +} + +/** + * 根据名称和类型创建分类对象 + */ +function createCategory(name, kind) { + let actualKind = kind; + let isSpecial = false; + + if ( + kind === CategoryKind.TODAY || + kind === CategoryKind.OTHER_TASKS || + kind === CategoryKind.TRASH || + kind === CategoryKind.COMPLETED + ) { + isSpecial = true; + } + + return { + name, + kind: actualKind, + isSpecial, + meta: null, + parentOtherTasks: false, + tasks: [] + }; +} + +/** + * 取(或懒创建)隐式的「当前任务」分类。 + * 用于「文件里根本没有标题、直接就是任务清单」的情况 —— 这类手写文件必须完整读入, + * 否则加载后的自动保存会把用户原文覆写成空文档。 + * 创建时插到数组最前,与末尾兜底逻辑保持同一位置语义。 + */ +function getOrCreateImplicitToday(categories) { + const existing = categories.find(c => c.kind === CategoryKind.TODAY); + if (existing) return existing; + const t = createCategory(TODAY_CANONICAL_NAME, CategoryKind.TODAY); + categories.unshift(t); + return t; +} + +/** + * 取(或懒创建)容器下的「未分类」子分类。 + * 用于「任务直接写在 `# 全部任务` 之下、没有 `##` 子分类标题」的情况: + * 容器本身不存任务,但这些任务同样不能丢,统一归入「未分类」。 + */ +function getOrCreateUncategorized(categories) { + const existing = categories.find( + c => c.parentOtherTasks && c.name === UNCATEGORIZED_NAME + ); + if (existing) return existing; + const sub = createCategory(UNCATEGORIZED_NAME, CategoryKind.NORMAL); + sub.parentOtherTasks = true; + // 紧跟容器之后:writeMarkdown 依赖数组顺序把它写成容器下的 `## 未分类` + const oIdx = categories.findIndex(c => c.kind === CategoryKind.OTHER_TASKS); + if (oIdx >= 0) { + categories.splice(oIdx + 1, 0, sub); + } else { + categories.push(sub); + } + return sub; +} + +/** + * 从任务文本中抽取并剥掉重要 / 当前 / 原分类 inline 标记,返回剥净的 text 及对应字段。 + * + * 与 parseMarkdown 内部解析任务行时**共用同一份逻辑**(原本写在 parseMarkdown 里内联), + * 抽出来是为了让 task-store 的 parseImportLine 在导入时也走完全相同的解析路径: + * - 避免「导入时文本里有 [⭐],但 important 字段为 false;保存后 parseMarkdown 又 + * 把 [⭐] 抽走」这种语义不一致。 + * - 避免未来解析规则改动时只改一边。 + * + * 标记规则(与 parser 严格对齐): + * - 重要: + * - [⭐](v3.2+):方括号包裹,**只能出现在文本开头或结尾**。 + * `]` 自身充当终止符 —— 后可紧贴任意字符(包括 CJK),开头判别不依赖空白。 + * 行内位置「对比 [⭐] 和 [ ]」不会被误命中,避免低门槛数据损坏 vector。 + * - ⭐(v3.1 之前):裸字符,必须前后有空白避免吃行内字符。 + * - 当前: + * - [▶](v3.2+):方括号包裹,规则同 [⭐] + * - ▶(v3.1)/ @当前(v3.0):裸/中文标识,必须前后有空白 + * 写入器只输出新格式 [▶],旧标记在首次保存时自动改写。 + * - 原分类(v3.5+):[原分类:xxx],方括号包裹 + 全角冒号 + 分类名。 + * 与 [⭐]/[▶] 同款位置约束(开头或结尾),括号内第一个 ] 充当终止符。 + * 用途:勾选/拖入「已完成任务」时记录原分类,取消勾选时直接归位。 + * 解析后落到 task.originalCategory;写回时由 writer 把标记排在 + * [▶]/[⭐] 之后、文本之前 —— 与状态标记同列标记块,不被推到文本末尾。 + * 解析器保留对行首/行尾两种位置的支持 —— 旧文件 / 用户手敲的 + * `[原分类:xxx]` 出现在文本末尾也能被识别。 + * + * 多个标记同时出现时**顺序不固定** —— writer 当前输出 [▶][⭐],旧文件可能是 [⭐][▶], + * 循环剥离直到稳定即可兼顾两种顺序。 + * + * 纯函数。空 / 纯空白 / 不含标记的输入直接原样返回。 + * + * @param {string} rawText - 已去掉 checkbox 标记 / 列表符号的纯文本(来自 parseMarkdown 或 parseImportLine) + * @returns {{ text: string, important: boolean, current: boolean, originalCategory: string|null }} + */ +export function extractInlineMarkers(rawText) { + let text = (rawText || ''); + + // 行首/行尾的 [⭐] / [▶] 标记可同时出现,且**顺序不固定**: + // - writer 当前固定输出 [▶][⭐](用户偏好:当前在前) + // - 旧文件 / 用户手敲 / 不同 markdown 工具可能是 [⭐][▶] + // 单次 important→current 两阶段 pass 在 [▶][⭐] 输入下会漏剥 [⭐] + // (第一次 pass 找不到行首 [⭐] 就放弃,剩下 [⭐] 漏到 text 里)。 + // 改为 loop:每次循环最多剥一个标记(行首 / 行尾 / 裸字符 之一), + // 反复执行直到稳定 —— 这样两种顺序都能正确剥净。 + let important = false; + let current = false; + // v3.5+:原分类标记解析后的归属名。第一次匹配上即锁定 —— 与 important/current + // 不同,原分类只能有一个(任务从哪来只有一处),后面的重复标记会被当作普通文本保留。 + let originalCategory = null; + let safety = 0; + while (safety++ < 10) { + const before = text; + + // 1) 行首 [⭐] / [▶](复用模块顶层 MARKER_HEAD_RE —— 字符源是常量 IMPORT/CURRENT) + let m = MARKER_HEAD_RE.exec(text); + if (m) { + if (m[1] === IMPORTANT_MARKER) important = true; else current = true; + text = text.slice(m[0].length); + } + // 2) 行尾 [⭐] / [▶](同上,模块顶层 MARKER_TAIL_RE) + else if ((m = MARKER_TAIL_RE.exec(text))) { + if (m[1] === IMPORTANT_MARKER) important = true; else current = true; + text = text.slice(0, text.length - m[0].length).trimEnd(); + } + // 3) 行首 [原分类:xxx](v3.5+)—— 非贪婪匹配(修复 M-D2),只首次记录值; + // 重复标记也剥(修复 M-D1),不让 `[原分类:B]` 永久滞留文本。 + // C1 修复:原分类里可能有 `]`(writer 已转义为 `\]`)。字符类允许 `\.` 形式, + // 捕获后再把 `\]`/`\\` 反向转义回 `]`/`\`。 + else if ((m = /^\[原分类:((?:\\.|[^\]])+?)\]/.exec(text))) { + if (originalCategory === null) originalCategory = unescapeOriginalCategory(m[1]).trim(); + text = text.slice(m[0].length); + } + // 4) 行尾 [原分类:xxx](v3.7+ 去掉 \s 前导,与 [⭐] 对称:H5 修复后规则统一) + else if ((m = /\[原分类:((?:\\.|[^\]])+?)\]$/.exec(text))) { + if (originalCategory === null) originalCategory = unescapeOriginalCategory(m[1]).trim(); + text = text.slice(0, text.length - m[0].length).trimEnd(); + } + // 5) 旧版裸 ⭐(必须前后空白边界;前后必须有空白才能命中行内) + else if ((m = /(?:^|\s)⭐(?=\s|$)/.exec(text))) { + important = true; + text = text.replace(/(?:^|\s)⭐(?=\s|$)/, ' '); + } + // 6) 旧版裸 ▶ 或 @当前(必须前后空白边界) + else { + const legacyMarkers = [CURRENT_MARKER, LEGACY_CURRENT_MARKER]; + let matched = false; + for (const marker of legacyMarkers) { + const escaped = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp(`(?:^|\\s)${escaped}(?=\\s|$)`); + if (re.test(text)) { + current = true; + text = text.replace(re, ' '); + matched = true; + break; + } + } + if (!matched) break; // 没匹配上任何标记 —— 已稳定 + } + + // 收尾:只剥首尾空白。去掉旧版 `/\s+/g` 全角折叠(修复 H8): + // 旧实现会把任务文本里用户原本写的连续空格(`前后 多空 格`)也一并 + // 折成单空格,触发条件仅仅是任务带了 `[⭐]` / `[▶]` 等标记。 + // 数据损坏是悄无声息的:保存时 text 已经丢空格,下次读回来还是丢。 + // 现实现只 trim 两端 —— 内部空白保持原样: + // - 1/3/4 行(head marker / head 原分类)剥后可能留前导空白 → trimStart 已涵盖 + // - 2/4 行(tail marker / tail 原分类)剥后已 trimEnd + // - 5/6 行(legacy `前后 ⭐ 文本`)的 `(?:^|\s)⭐` replace 已自带单空格, + // 不需要再次折叠 —— 旧版的 `\s+/g` 是为了消除这条路径的"双空格残留", + // 但实际用户输入里 `前后 多空 格` 也属于"想保留的连续空格", + // 不该因带了 marker 就被改写。 + text = text.trim(); + + // 防御:本轮没剥掉任何字符(理论上不会),避免死循环 + if (text === before) break; + } + + // C2 修复:writer 把任务里的换行转义为 `\n`(文件里仍是单行)。解析端反向 + // 转义:`\\n`(已转义的 backslash 自身)→ `\`,`\n`(单段内非结尾) → 真换行。 + // 这里只做单层 unescape —— writer 同样只做单层 escape,无嵌套风险。 + // + // v4+ 修复:改为单次遍历解码。旧实现两次 replace 顺序错误会导致数据损坏 —— + // 当原文含 `\n` 字面量(如 `C:\new_folder`、代码片段 `hello\nworld`),writer + // 先转义 `\` 为 `\\`、再拼 `\n` 连接符,写到 markdown 的是 `\\n`(两个反斜杠+n)。 + // 旧 parser 先 `/\\n/g → \n` 把 `\\n` 里的 `\n` 误识别为换行符、再 `/\\\\/g → \` + // 已无 `\\` 可匹配 → 原本单行的 `hello\nworld` 被拆成 `hello\` + 换行 + `world`。 + // 单次遍历正则 `/\\(\\|n)/g` 把 `\\` 和 `\n` 作为互斥的两种转义原子处理, + // `\\n` 只会匹配为 `\\`(→ `\`)+ 字面 `n`,不会被误拆。 + const unescapedText = text.replace(/\\(\\|n)/g, (_match, group) => { + return group === '\\' ? '\\' : '\n'; + }); + + return { text: unescapedText, important, current, originalCategory }; +} diff --git a/src/markdown-writer.js b/src/markdown-writer.js new file mode 100644 index 0000000..7e6ea82 --- /dev/null +++ b/src/markdown-writer.js @@ -0,0 +1,278 @@ +// Markdown 写入器 +// 将任务树序列化为 Markdown 文本 +// +// 结构(v3.4 起「已完成任务」升级为真实分类;v3.5 起 `# 已完成任务` 文件位置固定在 +// `# 全部任务` 及其 `##` 子分类之后、`# 回收站` 之前): +// # 全部任务 ← 容器(h2 为子分类;未完成 / 非重要 / 非当前 的任务都活在这里) +// ## 工作 / ## 学习 / ## 生活 / ## 未分类 / 用户自定义子分类… +// # 已完成任务 ← 真实分类(kind=COMPLETED;[✓] 打勾后任务会自动移到这里) +// # 回收站 ← 真实分类(仅当有任务时写出;用于彻底删除前的暂存) +// +// 任务状态由 inline 标识承载: +// - [✓] 已完成(与下方 # 已完成任务 镜像对应) +// - ⭐ 重要 +// - ▶ 当前焦点(U+25B6 BLACK RIGHT-POINTING TRIANGLE;可出现在任意子分类下, +// 「当前任务」智能视图按此聚合;解析器仍认旧版 @当前 标记以兼容升级前文件) +// +// v3 → v3.6 的关键变更: +// - **移除顶部 `# 当前任务` / `# 重要任务` 镜像段**:v3.x 一度按用户要求把带 [▶]/[⭐] +// 标识的任务在文件顶部重复写一遍,顶部一眼看到当前/重要的任务 —— 但同一个 +// 文本在文件里出现两次既冗余又复杂(用户后续反馈「感觉重复了,会有点复杂」)。 +// 顶部一眼看到当前/重要任务这件事由 UI 侧边栏的智能视图承担即可, +// 数据模型始终只有「全部任务」容器里带 inline 标识的源任务。 +// 旧文件里的镜像段由 parser 的 TODAY_KEYS / SMART_LIST_KEYS 路径消化 +// (前者迁移到首个子分类并打 [▶],后者整段跳过、任务归「未分类」), +// 下一次保存即扁平化为新结构。 +// +// v3.x → v3.4 的关键变更: +// - 「# 已完成任务」从顶部镜像段**升级为真实分类**(kind=COMPLETED): +// `[✓]` 打勾后任务**自动移动**到这里,取消勾选则回到它原来的分类 +// (原分类名记在 `[原分类:xxx]` 里,由 toggleTask 写入)。 +// 原分类丢了(被删/被改名)→ 弹分类选择器;任务没有原分类记录 +// (很旧的兜底场景)→ 直接落「未分类」不弹窗。 +// 数据归属明确,与「回收站」语义对称(删除 → 移到回收站;完成 → 移到已完成任务)。 +// 与 v3.2「# 已完成任务 镜像段」的差别:旧版任务实体仍活在源子分类里, +// 只是顶部重复显示;现在任务**只活在一个地方**(已完成任务分类), +// 「全部任务」容器只装未完成任务 —— 不再有"完成的任务既在「工作」里又在 +// 「已完成任务」里"的数据冗余与归属歧义。 +// 升级时一次性迁移:旧文件里散落在各子分类的 [✓] 任务会被搬到新的 +// 「# 已完成任务」分类,下一次保存即统一。 +// +// v2 → v3 的关键变更(历史): +// - 删除 `# 当前任务` h1 段:当前任务不再是一个独立分类,而是任意子分类下 +// 任务都能打的 ▶ 标识。语义更灵活 —— 你可以让「工作」里的某条任务 +// 既是 ▶、又不是「生活」里的某条任务的状态。 +// +// 任务身份(task.id)是运行时句柄,单次加载周期内稳定,跨重载重新分配。 +// 文件里不再写 `` —— 任务靠 inline 标识([✓] / [⭐] / [▶])+ 文本识别。 + +import { + CategoryKind, + OTHER_TASKS_NAME, + TRASH_NAME, + UNCATEGORIZED_NAME, + COMPLETED_NAME, + CURRENT_MARKER, + IMPORTANT_MARKER +} from './markdown-parser.js'; + +// 写在「回收站」标题下方的说明。软件不提供清空入口,所以文件本身必须讲清 +// 彻底删除的唯一途径 —— 否则用户只会看到一个只增不减的列表,不知道怎么收拾。 +const TRASH_NOTE = ''; + +// 写在「已完成任务」标题下方的说明。v3.4 起这是真实分类而非镜像段—— +// 文件里必须讲清勾选 / 取消勾选后的行为,避免用户疑惑"我的任务怎么不见了"。 +// v3.5+ 起取消勾选是回到原分类([原分类:xxx] 记录);原分类丢失时弹选择器。 +const COMPLETED_NOTE = ''; + +/** + * 将分类列表序列化为 Markdown + * @param {Array} categories + * @returns {string} + */ +export function writeMarkdown(categories) { + if (!Array.isArray(categories)) { + // 输入类型错误(不是数组)—— 这是开发者 bug,不是用户输入错误。 + // 静默返回空串会让磁盘文件被清空,下次加载就把用户的全部任务抹掉。 + // 抛错让上层 TaskStore.serialize() 走 save-error 路径,触发冲突对话框让用户介入, + // 比"看起来成功了但下次启动丢盘"安全得多。 + throw new Error('writeMarkdown: categories 必须是数组'); + } + if (categories.length === 0) { + // 同样不写空文件 —— 如果上层传了空数组,要么是 _ensureBaseStructure 没跑, + // 要么是极端 race。抛错让用户在冲突对话框里选「保留本地」/「重新加载」, + // 永远不静默清空磁盘。 + throw new Error('writeMarkdown: categories 数组为空(_ensureBaseStructure 未运行?)'); + } + + const parts = []; + + // ── 真实分类段落 ── + // + // 分三遍写,强制 `# 已完成任务` 排在「全部任务」容器及其全部 `##` 子分类之后、 + // `# 回收站` 之前 —— 即使用户文件里它是夹在中间(v3.4 老文件 / 外部手工编辑后的乱序), + // 写回来时也按期望的视觉顺序固定下来。 + // + // 第一遍:除 COMPLETED / TRASH 之外的所有分类(容器 / 子分类 / TODAY 兜底 / 普通 h1)。 + // 第二遍:只写 COMPLETED,位置必然在第一遍末尾(所有 ## 子分类之后)。 + // 第三遍:只写 TRASH,保证它永远是文件最后一段(视觉上「最远」的兜底区)。 + // + // 这不是简单的"sort 排序"—— sort 会改变 ## 子分类之间的相对顺序(用户手工编辑过的 + // ## 顺序会被打乱)。三遍循环保留了所有非 COMPLETED / 非 TRASH 分类的相对顺序。 + // ── 真实分类段落(全部任务 容器 + 子分类 + TODAY 兜底 + 普通 h1)── + for (const cat of categories) { + if (cat.kind === CategoryKind.COMPLETED) continue; // 第二遍再写 + if (cat.kind === CategoryKind.TRASH) continue; // 第三遍再写 + + if (cat.kind === CategoryKind.OTHER_TASKS) { + // 容器:本身不写任务,紧跟的 parentOtherTasks 子分类作为 ## 子分类 + parts.push(`# ${cat.name}`); + parts.push(''); + continue; + } + + // 兜底:旧文件里残留的 `# 当前任务` / `# 我的一天` 章节(迁移未彻底完成) + // 也要写出来,避免解析器下次加载时再次把它们当成 TODAY 处理。 + // 正常流程下 store 会在加载时把它们迁移掉,这里基本不会触发。 + if (cat.kind === CategoryKind.TODAY) { + if (!cat.tasks || cat.tasks.length === 0) continue; + parts.push(`# ${cat.name}`); + parts.push(''); + appendTasks(parts, cat.tasks, cat.kind); + parts.push(''); + continue; + } + + if (cat.parentOtherTasks) { + // 子分类:在容器下作为 ## 标题 + parts.push(`## ${cat.name}`); + parts.push(''); + appendTasks(parts, cat.tasks, cat.kind); + parts.push(''); + continue; + } + + // 普通分类(非子分类、非容器、非回收站、非已完成、非 TODAY):仅在旧文件里可能出现 + // 写出来保留数据,迁移到「全部任务」下的工作是 store 的责任 + parts.push(`# ${cat.name}`); + parts.push(''); + appendTasks(parts, cat.tasks, cat.kind); + parts.push(''); + } + + // ── 真实分类段落(已完成任务 —— 固定排在 ## 子分类之后)── + for (const cat of categories) { + if (cat.kind !== CategoryKind.COMPLETED) continue; + // v3.4:「# 已完成任务」是真实分类 —— [✓] 打勾的任务由 store 自动搬到这里。 + // 为空时整节都不写 —— 没人打过勾的文件不该凭空多出一个空标题。 + if (!cat.tasks || cat.tasks.length === 0) continue; + parts.push(`# ${cat.name}`); + parts.push(''); + parts.push(COMPLETED_NOTE); + parts.push(''); + appendTasks(parts, cat.tasks, cat.kind); + parts.push(''); + } + + // ── 真实分类段落(回收站 —— 固定排在文件最后一段)── + for (const cat of categories) { + if (cat.kind !== CategoryKind.TRASH) continue; + // 回收站为空时整节都不写 —— 没删过任何东西的文件不该凭空多出一个空标题。 + if (!cat.tasks || cat.tasks.length === 0) continue; + parts.push(`# ${cat.name}`); + parts.push(''); + parts.push(TRASH_NOTE); + parts.push(''); + appendTasks(parts, cat.tasks, cat.kind); + parts.push(''); + } + + return parts.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n'; +} + +/** + * 追加任务到输出(文本 + 勾选状态 + ⭐ + @当前 + 原分类 + ID 注释) + * + * @param {string[]} parts - 输出缓冲 + * @param {Array} tasks - 任务列表 + * @param {string} [categoryKind] - 所属分类类型;用于决定是否输出 [原分类:xxx] + * - COMPLETED / TRASH:写出 [原分类:xxx](任务来源信息在此分类里有意义) + * - 其他:藏起来 —— 普通子分类里任务已"到家",原分类标记是冗余噪音 + * - 不传:兜底按"不输出原分类"处理 + */ +function appendTasks(parts, tasks, categoryKind = null) { + if (!tasks || tasks.length === 0) return; + for (const task of tasks) { + parts.push(formatTaskLine(task, categoryKind)); + } +} + +/** + * 格式化单条任务为 Markdown 行 + * + * 输出顺序:`- [✓] [▶] [⭐] [原分类:xxx] 任务文本` + * - 所有状态标识用方括号包裹(与 [ ]/[✓] 风格统一) + * - 标记块集中在 checkbox 之后、文本之前 —— 一眼能扫到「这是标记」 + * - 完成标记用 [✓](打勾)而非 [x](打叉) + * - [▶] 当前 → [⭐] 重要 → [原分类:xxx] → 顺序固定(用户偏好:所有"元信息" + * 集中在文本之前,文件里一眼扫完一行就知道这条任务的全部标记状态) + * - 不再写 ``:task.id 是运行时句柄,跨重载重新分配 + * - ▶ 是 U+25B6 BLACK RIGHT-POINTING TRIANGLE,与 ⭐ 视觉权重对等 + * - v3.5+:[原分类:xxx] 排到文本**前面**(与 [▶]/[⭐] 同处标记块),原因: + * 「原分类」是任务的来源追溯("它从哪来"),但视觉上和其他标记同列一行 + * 才方便用户在文件里扫读 —— 把它推到文本末尾会让一行内信息分散, + * 看一条任务得从头到尾扫两遍。标记块统一前缀 + 文本在后的布局更紧凑。 + * - v3.5.1+:`[原分类:xxx]` 仅在 kind=COMPLETED / kind=TRASH 分类里输出; + * 普通子分类里的任务已经"到家",原分类标记就是冗余噪音。 + * (store 层会在恢复路径清掉 originalCategory,这里再叠加一层护栏: + * 即便外部编辑把任务塞了带 [原分类:xxx] 标记,普通子分类也不会写出来。) + * + * @param {object} task + * @param {string} [categoryKind] - 所属分类的 kind(见 CategoryKind)。仅 COMPLETED / TRASH 输出原分类标记 + */ +/** + * 对单个不含换行的任务文本片段做反向转义(与 parser 配套): + * - `\` → `\\`(避免与 \n 序列冲突) + * - 其余字符按原样保留(marker / 标签交给 markdown parser 在原行内识别) + */ +function escapeTaskTextSegment(seg) { + return String(seg).replace(/\\/g, '\\\\'); +} + +function formatTaskLine(task, categoryKind = null) { + const checkbox = task.completed ? '[✓]' : '[ ]'; + // C2 修复:保留换行 —— 旧版直接折叠成空格,多行任务(如粘贴的邮件正文)会丢信息。 + // 折成 `\n` 转义序列:写进文件仍是单行(解析器要按行拆分),parser 反向转义回来。 + // \r 同理 —— 避免 Windows 剪贴板里的 \r\n 在 task 文本里残留。 + let text = String(task.text ?? '').replace(/\r\n?/g, '\n').split('\n').map(escapeTaskTextSegment).join('\\n'); + // v3.5.1+:原分类标记仅在「已完成任务」「回收站」里输出。 + // 普通子分类里再保留就是噪音(任务本身已在某个分类里,[原分类:xxx] 重复表达)。 + const showOriginalCategory = task.originalCategory && + (categoryKind === CategoryKind.COMPLETED || categoryKind === CategoryKind.TRASH); + // C1 修复:原分类里若有 `]` 字符,按原样写到 `[原分类:xxx]` 里会被解析器的非贪婪 + // 正则 /\[原分类:([^\]]+?)\]/ 在第一个 `]` 截断,导致跨重载丢字符。 + // 解决:写时把 `]` 转义成 `\]`(兼容 markdown 转义惯例),读时再反向。 + const escapedOriginal = showOriginalCategory + ? String(task.originalCategory).replace(/\\/g, '\\\\').replace(/\]/g, '\\]') + : null; + // 标记块:[▶] / [⭐] / [原分类:xxx] 按固定顺序拼接,无对应标记就跳过(不留空格) + const markers = [ + task.current ? `[${CURRENT_MARKER}]` : null, + task.important ? `[${IMPORTANT_MARKER}]` : null, + escapedOriginal ? `[原分类:${escapedOriginal}]` : null + ].filter(Boolean).join(' '); + return `- ${checkbox} ${markers ? markers + ' ' : ''}${text}`; +} + +/** + * 创建一个空的默认文档(v3 标识驱动结构) + * + * 全部任务容器 + 4 个默认子分类(工作/学习/生活/未分类)。 + * 没有「当前任务」分类 —— 用户只要在任意子分类下添加任务并打上 @当前 即可。 + */ +export function createDefaultDoc() { + const task = (text) => ({ text, completed: false, important: false, current: false }); + const sub = (name) => ({ + name, + kind: CategoryKind.NORMAL, + isSpecial: false, + meta: null, + parentOtherTasks: true, + tasks: [] + }); + + return [ + { + name: OTHER_TASKS_NAME, + kind: CategoryKind.OTHER_TASKS, + isSpecial: true, + meta: null, + parentOtherTasks: false, + tasks: [] + }, + sub('工作'), + sub('学习'), + sub('生活'), + sub(UNCATEGORIZED_NAME) + ]; +} diff --git a/src/package.json b/src/package.json new file mode 100644 index 0000000..aead43d --- /dev/null +++ b/src/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} \ No newline at end of file diff --git a/src/settings-store.js b/src/settings-store.js new file mode 100644 index 0000000..852cdfe --- /dev/null +++ b/src/settings-store.js @@ -0,0 +1,226 @@ +// 设置存储 - 内存状态管理 + 事件 + +import { EventEmitter } from './event-emitter.js'; + +/** + * 默认设置 + * 注意:默认数据目录采用 ~/TodoList(用户主目录下的 TodoList), + * 跨平台由主进程解析 —— Windows 上就是 %USERPROFILE%/TodoList。 + * + * filter / sortBy:全局偏好,跨分类生效。重启后恢复上次选择。 + * 合法值:filter ∈ {all, current, completed, important} + * sortBy ∈ {manual, alphabet} + * + * backupEnabled:每日自动备份开关。默认开启,开启后首次使用当天 + * 自动在 /backup/ 下生成 todo-YYYY-MM-DD.md。 + * 主进程单独维护 lastBackupDate(YYYY-MM-DD)记入 config.json, + * 它不在 DEFAULT_SETTINGS 里 —— 不属于「用户偏好」而是「内部进度」。 + * + * *Position 五件套:控制「最新一条」落到列表的哪个位置。 + * - newTaskPosition 新增任务的落点(addTask) + * - completedPosition [✓] 打勾后任务搬到「已完成任务」分类时的落点 + * (toggleTask / batchToggleCompleted(true)) + * - uncompletePosition 取消勾选:已完成任务重新激活、回到原分类时的落点 + * (toggleTask 取消勾选 / restoreCompletedTask / picker 强制降级路径) + * - trashPosition 删除任务搬到「回收站」时的落点 + * (deleteTask / batchDeleteTasks / clearCompleted) + * - restorePosition 回收站任务恢复到原分类时的落点 + * (restoreTask 的 category / fallback 分支;picker hint 不动任务不读) + * - movePosition 右键「移到分类…」/ 批量「移到分类」时的落点 + * (moveTask / batchMoveTasks 的 toIndex < 0 分支;拖拽指定 toIndex 时不读) + * 合法值都是 'front' | 'back'。默认 'front'(最新在最上面)—— 与用户对「最近的事应该一眼看到」 + * 的预期一致;切到 'back' 就是经典追加语义。设置由 TaskStore._appendTaskWithPosition + * 在每次插入时实时读取 —— 用户切换设置后下一条新任务立即生效,已存在的任务不会被回溯重排。 + * moveTask 不读任何 *Position:用户主动拖拽指定了 toIndex,落点由用户决定。 + * + * importPosition(独立于五件套之外):导入任务的顺序偏好。 + * - 与 newTaskPosition / completedPosition **完全解耦**。用户在导入对话框内 + * 单独调整,UI 仅在导入窗口暴露 —— 不进主设置面板。 + * - 合法值只有两个: + * 'order'(默认):按入参顺序插入到目标位置的最前面 —— 第一行排到最上, + * 符合「前面的内容在前」的直觉。 + * 'front' :倒序插入到目标位置的最前面 —— 最后一行落在最上面。 + * - 注意:导入对话框**不再**暴露「追加到末尾」选项。理由是它与全局 + * newTaskPosition='back' 行为完全重叠 —— 导入对话框只管「顺序」这件事, + * 「落点位置」由全局设置决定,避免职责重复。 + * - 默认 'order':粘贴多行文本时,第一行天然排到最前,符合「按输入顺序查看」的直觉; + * 旧版默认走 newTaskPosition='front',导致最后一行跑到最前面,与用户的输入顺序 + * 相反,让人困惑。 + */ +const DEFAULT_SETTINGS = { + dataDir: null, // null 表示使用主进程默认值(~/TodoList) + theme: 'dark', // dark | light | auto + filter: 'all', // 全局默认过滤 + sortBy: 'manual', // 全局默认排序 + fontSize: 'medium', // small | medium | large + colorStyle: 'indigo', // 配色风格:indigo | ocean | forest | sunset | rose | mono + // 与 theme 正交:每种配色都包含浅色和深色两个变体 + backupEnabled: true, // 每日自动备份开关(默认开启) + alwaysOnTop: false, // 窗口始终置顶(默认关闭,重启后由主进程按此值恢复) + // v4+:插入位置偏好。新增 / 完成 / 取消完成 / 删除 / 恢复 五种动作分别落到 front(最新在最上)或 back(最新在最下)。 + newTaskPosition: 'front', + completedPosition: 'front', + uncompletePosition: 'front', + trashPosition: 'front', + restorePosition: 'front', + // v4+:移到分类(右键菜单 / 批量移到分类)时任务的落点。 + // 拖拽(指定了 toIndex)不受此设置影响 —— 落点由用户手指决定。 + movePosition: 'front', + // 导入任务的顺序偏好(独立于五件套)—— 见上方注释。默认值 'order'。 + importPosition: 'order' +}; + +// 每个字段允许的值集合:用于 update / load 的白名单校验。 +// null 仅 dataDir 允许(语义是「恢复默认」);其它字段必须是合法字符串之一。 +// backupEnabled / alwaysOnTop 是布尔值,由 isValidValue 单独分支处理(不进 VALID_VALUES)。 +const VALID_VALUES = { + theme: ['dark', 'light', 'auto'], + filter: ['all', 'current', 'completed', 'important'], + sortBy: ['manual', 'alphabet'], + fontSize: ['small', 'medium', 'large'], + colorStyle: ['indigo', 'ocean', 'forest', 'sunset', 'rose', 'mono'], + newTaskPosition: ['front', 'back'], + completedPosition: ['front', 'back'], + uncompletePosition: ['front', 'back'], + trashPosition: ['front', 'back'], + restorePosition: ['front', 'back'], + movePosition: ['front', 'back'], + // 导入任务的顺序偏好 —— 与五件套解耦,多了一个 'order' 选项。导入对话框内独享。 + // 只剩两个值:'order'(正序 + 最前)和 'front'(倒序 + 最前)。 + importPosition: ['order', 'front'] +}; + +// 字段值迁移表 —— 老配置值 → 新配置值。load() 时把旧 enum 名翻译到新 enum 名, +// 而不是 silently 退回默认值:用户上次选的过滤方式不该因为 enum 改名就丢失。 +// 当前唯一迁移:filter 的 'pending' → 'current'(v3.x 之前 PENDING 语义被替换为 +// CURRENT —— 从「未完成」改为「[▶] 标记」,与「当前任务」智能列表对称)。 +// 后续再有 enum 改名,在此追加;白名单不变(VALID_VALUES 是新值集合)。 +const VALUE_MIGRATIONS = { + filter: { 'pending': 'current' } +}; + +function isValidValue(key, value) { + if (key === 'dataDir') return value === null || typeof value === 'string'; + // 布尔字段单独校验 —— 字符串 / 数字 / 任何非 boolean 一律拒绝, + // 否则 update({backupEnabled: 'true'}) 这类会污染内存 + 下次保存固化为字符串 + // alwaysOnTop 同样走 boolean 通道 —— 任何非布尔(含 'true' 字符串)都拒掉。 + if (key === 'backupEnabled' || key === 'alwaysOnTop') return typeof value === 'boolean'; + return typeof value === 'string' && VALID_VALUES[key]?.includes(value); +} + +export class SettingsStore extends EventEmitter { + constructor() { + super(); + this._settings = { ...DEFAULT_SETTINGS }; + this._loaded = false; + } + + /** + * 从主进程异步加载设置 + */ + async load() { + try { + const persisted = await window.api.getSettings(); + if (persisted && typeof persisted === 'object') { + // 仅保留 DEFAULT_SETTINGS 中已定义且值合法的字段,防止脏数据 + // (主进程 saveConfig 端已对写入侧做了白名单,但加载侧仍要兜底: + // 配置文件可能被用户直接编辑成 `theme: null` 之类的非法值, + // 与 update 一样会污染内存 + 下次保存把脏值固化进文件) + const sanitized = {}; + for (const key of Object.keys(DEFAULT_SETTINGS)) { + if (!(key in persisted)) continue; + let v = persisted[key]; + // 1) enum 改名迁移:旧值 → 新值,发生在白名单校验之前, + // 否则 'pending' 这种合法类型但合法值已下线的值会被直接丢掉。 + const migration = VALUE_MIGRATIONS[key]; + if (migration && Object.prototype.hasOwnProperty.call(migration, v)) { + v = migration[v]; + } + // 2) 白名单校验(类型 + 在枚举内),通过后才落地内存 + if (!isValidValue(key, v)) continue; + sanitized[key] = v; + } + this._settings = { ...DEFAULT_SETTINGS, ...sanitized }; + } + } catch (e) { + console.warn('[settings] 加载失败,使用默认值:', e.message); + } + this._loaded = true; + this.emit('load', this._settings); + return this._settings; + } + + /** + * 获取当前设置(不可变副本) + */ + get all() { + return { ...this._settings }; + } + + get(key) { + return this._settings[key]; + } + + /** + * 更新一个或多个设置项 + * + * 持久化失败时:内存状态已经改了(用户看到的设置立刻生效),但落盘失败 → + * 下次启动会回到旧值。必须把失败告诉 UI(toast / 日志),否则用户以为保存成功。 + * + * 输入校验:每个字段只接受明确合法的标量值。 + * - dataDir:null 是合法语义(恢复默认),字符串也合法;其他类型忽略。 + * - 其它字段(theme / filter / sortBy / fontSize):必须是有限个枚举值之一。 + * 之前仅校验「值 !== 当前值」,于是 `update({ theme: null })` 之类调用 + * 会把内存里的 theme 抹成 null —— UI resolveTheme() 虽能兜底返回 'dark', + * 但 this._settings.theme 仍为 null,比较 `this._settings[k] !== v` 时会 + * 跟正常字符串凑出意外行为,且下次保存会把脏值写入配置文件,旧用户的设置 + * 从此被静默损坏。 + */ + async update(partial) { + const updates = {}; + for (const [k, v] of Object.entries(partial)) { + if (!(k in DEFAULT_SETTINGS)) continue; + if (!isValidValue(k, v)) continue; + if (this._settings[k] !== v) { + this._settings[k] = v; + updates[k] = v; + } + } + if (Object.keys(updates).length === 0) return false; + + // v4+ 修复:先持久化再 emit(顺序换了,但 emit 本身两条路径都发)。 + // 旧实现先 emit 后 await _persist(),意味着 'change' 监听器(典型如 toolbar.js:89 + // 监听 dataDir 变化后立刻 window.api.getDataDir())会在主进程还没把新值落盘时就 + // 跑到 IPC 那侧读 dataDir —— 主进程的 in-memory cache 与磁盘状态之间存在竞态, + // 用户"刚刚改了数据目录"立刻新建文件,可能仍写到旧目录下。 + // 持久化后再 emit:UI 视觉延迟 ≈ 一次 IPC(几毫秒),用户无感;race 消失。 + // + // 关键:失败时**仍然要 emit 'change'**。内存已经改了(上面的循环无条件写 + // this._settings),如果失败就不发 change,body.dataset.fontSize / colorStyle + // 这些只在 change 里同步的字段会永远停在旧值 —— 内存说 large、界面显示 medium, + // 而且后续某次成功的 update 只带它自己改的 key,fontSize 再也没机会补上。 + // 所以:change 照发(UI 与内存一致),再补一条 save-error 告诉用户「没落盘, + // 重启会回滚」。两个事件语义不同,各司其职。 + const ok = await this._persist(); + this.emit('change', { changed: Object.keys(updates), settings: this.all }); + if (!ok) { + // 让 UI 知道:内存更新成功但磁盘写入失败 —— 重启后会回到旧值 + this.emit('save-error', { failed: Object.keys(updates) }); + } + return ok; + } + + get loaded() { + return this._loaded; + } + + async _persist() { + try { + await window.api.saveSettings(this._settings); + return true; + } catch (e) { + console.error('[settings] 保存失败:', e.message); + return false; + } + } +} diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..5e8dfac --- /dev/null +++ b/src/styles.css @@ -0,0 +1,4864 @@ +/* ============================================ + Todo List - 样式表 + Linear 暗色风格 · 紧凑排版 · 精致细节 + ============================================ */ + +/* ============================================ + 设计 Token + ============================================ */ +:root { + /* 间距(4 的倍数) */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-7: 32px; + --space-8: 40px; + + /* 圆角 */ + --radius-xs: 4px; + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 10px; + --radius-xl: 14px; + --radius-pill: 999px; + + /* 行高 */ + --lh-tight: 1.35; + --lh-base: 1.55; + --lh-relaxed: 1.7; + + /* 字号层级 — 默认(中)。 + 在原版基础上整体 +0.5px —— 用户反馈「文字看着累」需要稍稍宽松,但不应过头: + 底部栏 / hint / 描述等次要文本仍走 xs (12px),保持紧凑而非臃肿。 */ + --font-2xs: 10.5px; + --font-xs: 12px; + --font-sm: 13px; + --font-md-sm: 14px; + --font-base: 14.5px; + --font-md: 15.5px; + --font-lg: 17.5px; + --font-xl: 20.5px; + --font-2xl: 24.5px; + + /* 高度(控件) */ + --h-control: 32px; + --h-control-sm: 28px; + --h-toolbar: 52px; + --h-statusbar: 30px; + + /* 缓动:out 用了更柔的曲线,spring 略快一点避免拖泥带水 */ + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + --ease-soft: cubic-bezier(0.4, 0, 0.2, 1); + + /* 焦点环:offset 1px 的环 + 3px 的光晕,比单层更现代 */ + --focus-ring: 0 0 0 1px var(--color-accent), 0 0 0 4px var(--color-accent-bg); + --focus-ring-light: 0 0 0 1px var(--color-bg), 0 0 0 4px var(--color-accent); + + /* 层级 */ + --z-modal: 3000; + --z-toast: 2000; + --z-menu: 1000; +} + +/* ============================================ + 浅色主题 - Linear / Things 风格清爽层次 + - 纯白卡面 + 微微灰背景,强调层级而非装饰 + - 强调色:蓝紫调 + ============================================ */ +:root, +[data-theme="light"] { + --color-bg: #f7f7f9; + --color-bg-secondary: #ffffff; + --color-bg-tertiary: #eeeef2; + --color-bg-elevated: #ffffff; + --color-bg-overlay: rgba(247, 247, 249, 0.78); + --color-chip-bg: #eeeef2; /* chip 控件背景 */ + --color-border: #e2e2e8; + --color-border-muted: #ececf0; + --color-border-strong: rgba(15, 23, 42, 0.12); + --color-text: #18181c; + --color-text-secondary: #54545d; + --color-text-muted: #71717a; /* 略加深,保证白底上的「次要文字」仍清晰 */ + --color-text-tertiary: #65656d; /* 介于 secondary 与 muted 之间 */ + --color-text-faint: #94949a; + --color-accent: #4f46e5; /* Indigo-600 */ + --color-accent-hover: #4338ca; + --color-accent-bg: rgba(79, 70, 229, 0.08); + --color-accent-bg-strong: rgba(79, 70, 229, 0.12); + --color-accent-gradient: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); + --color-accent-glow: rgba(79, 70, 229, 0.35); + --color-success: #059669; + --color-success-bg: rgba(5, 150, 105, 0.10); + --color-danger: #e11d48; + --color-danger-bg: rgba(225, 29, 72, 0.10); + --color-warning: #d97706; + --color-warning-bg: rgba(217, 119, 6, 0.10); + --color-star: #d97706; /* 重要任务 - 琥珀色(与当前任务的暖黄区分) */ + --color-shadow: rgba(15, 23, 42, 0.05); + --color-shadow-strong: rgba(15, 23, 42, 0.12); + --color-ambient-1: rgba(124, 58, 237, 0.05); + --color-ambient-2: rgba(79, 70, 229, 0.04); + --color-current: #3b82f6; /* 「当前任务」语义强调色;per-style 可覆盖 */ + + --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.05), 0 1px 1px rgba(15, 23, 42, 0.03); + --shadow-md: 0 1px 2px rgba(15, 23, 42, 0.04), + 0 4px 16px rgba(15, 23, 42, 0.06); + --shadow-lg: 0 4px 16px rgba(15, 23, 42, 0.06), + 0 24px 48px -8px rgba(15, 23, 42, 0.18); + --shadow-accent: 0 4px 14px rgba(79, 70, 229, 0.22), + 0 1px 2px rgba(79, 70, 229, 0.12); + + --font-stack: -apple-system, BlinkMacSystemFont, "Segoe UI Variable Display", + "Segoe UI", "Inter", "PingFang SC", "Hiragino Sans GB", + "Microsoft YaHei", "Noto Sans SC", sans-serif; + --font-mono: "Cascadia Code", "JetBrains Mono", "SF Mono", ui-monospace, + Menlo, Consolas, "Liberation Mono", monospace; + + --scrollbar-thumb: rgba(15, 23, 42, 0.16); + --scrollbar-thumb-hover: rgba(15, 23, 42, 0.28); +} + +/* ============================================ + 深色主题 - Linear 风格中性暗色 + - 真正的中性灰(无紫调),强调色沿用 indigo + - 加了微妙的蓝色 ambient 让背景「活」起来 + ============================================ */ +[data-theme="dark"] { + --color-bg: #0a0a0c; /* 主背景 - 近纯黑,无色相 */ + --color-bg-secondary: #131316; /* 工具栏 / 状态栏 */ + --color-bg-tertiary: #1d1d21; /* 任务项 hover / chip 背景 */ + --color-bg-elevated: #17171a; /* 侧边栏 */ + --color-bg-overlay: rgba(10, 10, 12, 0.7); + --color-chip-bg: #1d1d21; /* chip 控件(filter/sort)背景 */ + --color-border: rgba(255, 255, 255, 0.07); + --color-border-muted: rgba(255, 255, 255, 0.04); + --color-border-strong: rgba(255, 255, 255, 0.13); + --color-text: #ededee; + --color-text-secondary: #c1c3c8; /* 略提亮 —— 配合 completed 任务等场景用 */ + --color-text-muted: #96999f; /* 略提亮,保证「次要文字」依然清晰可读 */ + --color-text-tertiary: #82858c; /* 介于 secondary 与 muted 之间 */ + --color-text-faint: #7d7f84; + --color-accent: #8189ff; /* Indigo-400(Linear 风) */ + --color-accent-hover: #9aa3ff; + --color-accent-bg: rgba(129, 137, 255, 0.14); + --color-accent-bg-strong: rgba(129, 137, 255, 0.22); + --color-accent-gradient: linear-gradient(135deg, #8189ff 0%, #a594ff 100%); + --color-accent-glow: rgba(129, 137, 255, 0.4); + --color-success: #4ec9b0; + --color-success-bg: rgba(78, 201, 176, 0.12); + --color-danger: #f87171; + --color-danger-bg: rgba(248, 113, 113, 0.12); + --color-warning: #eab308; + --color-warning-bg: rgba(234, 179, 8, 0.12); + --color-star: #f59e0b; /* 重要任务 - 琥珀色(与当前任务的金黄区分) */ + --color-shadow: rgba(0, 0, 0, 0.4); + --color-shadow-strong: rgba(0, 0, 0, 0.6); + --color-ambient-1: rgba(99, 102, 241, 0.08); + --color-ambient-2: rgba(168, 85, 247, 0.05); + --color-current: #60a5fa; /* 「当前任务」语义强调色;per-style 可覆盖 */ + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-md: 0 1px 2px rgba(0, 0, 0, 0.3), + 0 4px 16px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 1px 3px rgba(0, 0, 0, 0.4), + 0 24px 48px -8px rgba(0, 0, 0, 0.6); + --shadow-accent: 0 6px 20px rgba(129, 137, 255, 0.32), + 0 1px 2px rgba(129, 137, 255, 0.18); + + --font-stack: -apple-system, BlinkMacSystemFont, "Segoe UI Variable Display", + "Segoe UI", "Inter", "PingFang SC", "Hiragino Sans GB", + "Microsoft YaHei", "Noto Sans SC", sans-serif; + --font-mono: "Cascadia Code", "JetBrains Mono", "SF Mono", ui-monospace, + Menlo, Consolas, "Liberation Mono", monospace; + + --scrollbar-thumb: rgba(255, 255, 255, 0.10); + --scrollbar-thumb-hover: rgba(255, 255, 255, 0.20); +} + +/* ============================================ + 配色风格 (colorStyle) — orthogonal to light/dark theme + 复合选择器特异性 (0,0,2,0) > 单 [data-theme] 的 (0,0,1,0), + 因此每个风格块可以稳定覆盖基块的 accent / ambient / shadow-accent / current 共 10 个 token。 + bg / border / text / star / warning / success / danger / scrollbar 全部由 mode 控制,不重复。 + indigo 是基块的默认配色,不需要再列一份(删除它会让"indigo"等于"未指定",反而失去显式语义)。 + ============================================ */ + +/* ---------- ocean (sky / cyan) — 冷静专注 ---------- */ +[data-theme="light"][data-color-style="ocean"] { + --color-accent: #0284c7; + --color-accent-hover: #0369a1; + --color-accent-bg: rgba(2, 132, 199, 0.08); + --color-accent-bg-strong: rgba(2, 132, 199, 0.12); + --color-accent-gradient: linear-gradient(135deg, #0284c7 0%, #0ea5e9 100%); + --color-accent-glow: rgba(2, 132, 199, 0.35); + --color-ambient-1: rgba(8, 145, 178, 0.05); + --color-ambient-2: rgba(2, 132, 199, 0.04); + --shadow-accent: 0 4px 14px rgba(2, 132, 199, 0.22), + 0 1px 2px rgba(2, 132, 199, 0.12); + --color-current: #06b6d4; +} +[data-theme="dark"][data-color-style="ocean"] { + --color-accent: #38bdf8; + --color-accent-hover: #7dd3fc; + --color-accent-bg: rgba(56, 189, 248, 0.14); + --color-accent-bg-strong: rgba(56, 189, 248, 0.22); + --color-accent-gradient: linear-gradient(135deg, #38bdf8 0%, #22d3ee 100%); + --color-accent-glow: rgba(56, 189, 248, 0.4); + --color-ambient-1: rgba(56, 189, 248, 0.08); + --color-ambient-2: rgba(34, 211, 238, 0.05); + --shadow-accent: 0 6px 20px rgba(56, 189, 248, 0.32), + 0 1px 2px rgba(56, 189, 248, 0.18); + --color-current: #22d3ee; +} + +/* ---------- forest (emerald / teal) — 自然放松 ---------- */ +[data-theme="light"][data-color-style="forest"] { + --color-accent: #059669; + --color-accent-hover: #047857; + --color-accent-bg: rgba(5, 150, 105, 0.08); + --color-accent-bg-strong: rgba(5, 150, 105, 0.12); + --color-accent-gradient: linear-gradient(135deg, #059669 0%, #10b981 100%); + --color-accent-glow: rgba(5, 150, 105, 0.35); + --color-ambient-1: rgba(16, 185, 129, 0.05); + --color-ambient-2: rgba(5, 150, 105, 0.04); + --shadow-accent: 0 4px 14px rgba(5, 150, 105, 0.22), + 0 1px 2px rgba(5, 150, 105, 0.12); + --color-current: #0d9488; /* teal 拉开与 emerald --color-success 的色距 */ +} +[data-theme="dark"][data-color-style="forest"] { + --color-accent: #34d399; + --color-accent-hover: #6ee7b7; + --color-accent-bg: rgba(52, 211, 153, 0.14); + --color-accent-bg-strong: rgba(52, 211, 153, 0.22); + --color-accent-gradient: linear-gradient(135deg, #34d399 0%, #10b981 100%); + --color-accent-glow: rgba(52, 211, 153, 0.4); + --color-ambient-1: rgba(52, 211, 153, 0.08); + --color-ambient-2: rgba(16, 185, 129, 0.05); + --shadow-accent: 0 6px 20px rgba(52, 211, 153, 0.32), + 0 1px 2px rgba(52, 211, 153, 0.18); + --color-current: #2dd4bf; +} + +/* ---------- sunset (orange / amber + yellow current) — 暖能 ---------- */ +[data-theme="light"][data-color-style="sunset"] { + --color-accent: #ea580c; + --color-accent-hover: #c2410c; + --color-accent-bg: rgba(234, 88, 12, 0.08); + --color-accent-bg-strong: rgba(234, 88, 12, 0.12); + --color-accent-gradient: linear-gradient(135deg, #ea580c 0%, #f59e0b 100%); + --color-accent-glow: rgba(234, 88, 12, 0.35); + --color-ambient-1: rgba(245, 158, 11, 0.05); + --color-ambient-2: rgba(234, 88, 12, 0.04); + --shadow-accent: 0 4px 14px rgba(234, 88, 12, 0.22), + 0 1px 2px rgba(234, 88, 12, 0.12); + --color-current: #facc15; /* yellow 拉开与 amber --color-star 的色距 */ +} +[data-theme="dark"][data-color-style="sunset"] { + --color-accent: #fb923c; + --color-accent-hover: #fdba74; + --color-accent-bg: rgba(251, 146, 60, 0.14); + --color-accent-bg-strong: rgba(251, 146, 60, 0.22); + --color-accent-gradient: linear-gradient(135deg, #fb923c 0%, #fbbf24 100%); + --color-accent-glow: rgba(251, 146, 60, 0.4); + --color-ambient-1: rgba(251, 146, 60, 0.08); + --color-ambient-2: rgba(251, 191, 36, 0.05); + --shadow-accent: 0 6px 20px rgba(251, 146, 60, 0.32), + 0 1px 2px rgba(251, 146, 60, 0.18); + --color-current: #fde047; +} + +/* ---------- rose (玫红 / pink) — 柔和舒适 ---------- */ +[data-theme="light"][data-color-style="rose"] { + --color-accent: #e11d48; + --color-accent-hover: #be123c; + --color-accent-bg: rgba(225, 29, 72, 0.08); + --color-accent-bg-strong: rgba(225, 29, 72, 0.12); + --color-accent-gradient: linear-gradient(135deg, #e11d48 0%, #f472b6 100%); + --color-accent-glow: rgba(225, 29, 72, 0.35); + --color-ambient-1: rgba(244, 114, 182, 0.05); + --color-ambient-2: rgba(225, 29, 72, 0.04); + --shadow-accent: 0 4px 14px rgba(225, 29, 72, 0.22), + 0 1px 2px rgba(225, 29, 72, 0.12); + --color-current: #f472b6; +} +[data-theme="dark"][data-color-style="rose"] { + --color-accent: #fb7185; + --color-accent-hover: #fda4af; + --color-accent-bg: rgba(251, 113, 133, 0.14); + --color-accent-bg-strong: rgba(251, 113, 133, 0.22); + --color-accent-gradient: linear-gradient(135deg, #fb7185 0%, #f9a8d4 100%); + --color-accent-glow: rgba(251, 113, 133, 0.4); + --color-ambient-1: rgba(251, 113, 133, 0.08); + --color-ambient-2: rgba(244, 114, 182, 0.05); + --shadow-accent: 0 6px 20px rgba(251, 113, 133, 0.32), + 0 1px 2px rgba(251, 113, 133, 0.18); + --color-current: #f9a8d4; +} + +/* ---------- mono (slate 灰,无色相) — 极简克制 ---------- */ +[data-theme="light"][data-color-style="mono"] { + --color-accent: #475569; + --color-accent-hover: #334155; + --color-accent-bg: rgba(71, 85, 105, 0.08); + --color-accent-bg-strong: rgba(71, 85, 105, 0.14); + --color-accent-gradient: linear-gradient(135deg, #475569 0%, #64748b 100%); + --color-accent-glow: rgba(71, 85, 105, 0.35); + --color-ambient-1: rgba(100, 116, 139, 0.05); + --color-ambient-2: rgba(71, 85, 105, 0.04); + --shadow-accent: 0 4px 14px rgba(71, 85, 105, 0.22), + 0 1px 2px rgba(71, 85, 105, 0.12); + --color-current: #3b82f6; /* 「当前」仍用蓝,确保在 mono 中可识别 */ +} +[data-theme="dark"][data-color-style="mono"] { + --color-accent: #94a3b8; + --color-accent-hover: #cbd5e1; + --color-accent-bg: rgba(148, 163, 184, 0.14); + --color-accent-bg-strong: rgba(148, 163, 184, 0.22); + --color-accent-gradient: linear-gradient(135deg, #94a3b8 0%, #cbd5e1 100%); + --color-accent-glow: rgba(148, 163, 184, 0.4); + --color-ambient-1: rgba(148, 163, 184, 0.08); + --color-ambient-2: rgba(203, 213, 225, 0.05); + --shadow-accent: 0 6px 20px rgba(148, 163, 184, 0.32), + 0 1px 2px rgba(148, 163, 184, 0.18); + --color-current: #60a5fa; +} + +/* ============================================ + 基础重置 + ============================================ */ + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + height: 100%; + overflow: hidden; +} + +body { + font-family: var(--font-stack); + font-size: var(--font-base); + line-height: 1.55; + color: var(--color-text); + background: var(--color-bg); + font-feature-settings: "cv11", "ss01", "ss03"; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + user-select: none; + scroll-behavior: smooth; + /* 环境光:两层柔和的 radial-gradient 给静态背景一点「呼吸感」, + 弱到不抢视觉但能让窗口不再像一块灰玻璃 */ + background-image: + radial-gradient(ellipse 90% 60% at 0% 0%, var(--color-ambient-1), transparent 60%), + radial-gradient(ellipse 70% 50% at 100% 100%, var(--color-ambient-2), transparent 65%); + background-attachment: fixed; +} + +button { + font-family: inherit; + font-size: inherit; + cursor: pointer; + border: none; + background: none; + color: inherit; + letter-spacing: inherit; +} + +input, textarea { + font-family: inherit; + font-size: inherit; + color: inherit; + background: none; + border: none; + outline: none; + letter-spacing: inherit; +} + +input::placeholder, textarea::placeholder { + color: var(--color-text-muted); +} + +/* 选区颜色 + 原来复用 --color-accent-bg-strong(light 12% / dark 22% 透明度),太淡: + 编辑任务时看不出「光标停在末尾」和「整行已被全选」的区别,一键入就覆盖原文。 + 这里单独给选区一档更高的不透明度,文字仍用 --color-text 保证两套主题都可读。 */ +::selection { + background: color-mix(in srgb, var(--color-accent) 32%, transparent); + color: var(--color-text); +} + +/* 深色主题的强调色是浅色系(如 #8189ff),同样透明度看起来更弱,再加一档 */ +[data-theme="dark"] ::selection { + background: color-mix(in srgb, var(--color-accent) 40%, transparent); +} + +/* 可编辑区域的选区再强一档:误覆盖的代价最高的就是这里 + (任务编辑框、新建任务框、重命名分类的对话框——后者一聚焦就是全选状态) */ +input::selection, +textarea::selection, +[contenteditable]::selection { + background: color-mix(in srgb, var(--color-accent) 45%, transparent); + color: var(--color-text); +} + +[data-theme="dark"] input::selection, +[data-theme="dark"] textarea::selection, +[data-theme="dark"] [contenteditable]::selection { + background: color-mix(in srgb, var(--color-accent) 55%, transparent); +} + +/* 通用焦点环(键盘) */ +button:focus-visible, +input:focus-visible, +textarea:focus-visible, +[tabindex]:focus-visible { + outline: none; + box-shadow: var(--focus-ring-light); + border-radius: var(--radius-sm); + transition: box-shadow 0.15s var(--ease-soft); +} + +/* ============================================ + 应用容器 + ============================================ */ + +#app { + display: grid; + grid-template-rows: var(--h-toolbar) 1fr var(--h-statusbar); + height: 100vh; + width: 100vw; + background: transparent; /* 让 body 的环境光透出来 */ + position: relative; +} + +/* 主区顶部的 1px 高光线 —— 现代 app 的小细节,让「上沿」更精神 */ +#app::before { + content: ""; + position: absolute; + top: var(--h-toolbar); + left: 0; + right: 0; + height: 1px; + background: linear-gradient( + to right, + transparent 0%, + var(--color-border-strong) 20%, + var(--color-border-strong) 80%, + transparent 100% + ); + pointer-events: none; + z-index: 50; +} + +/* ============================================ + 工具栏 + ============================================ */ + +.toolbar { + display: flex; + align-items: center; + gap: var(--space-3); + padding: 0 16px; + height: var(--h-toolbar); + background: var(--color-bg-overlay); + /* 玻璃效果:让 body 的 ambient radial 微微透过工具栏 */ + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border-bottom: 1px solid var(--color-border); + -webkit-app-region: drag; + min-width: 0; + position: relative; + z-index: 10; +} + +/* 工具栏底部的高光线:模拟「上方光源」造成的细高光 */ +.toolbar::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: -1px; + height: 1px; + background: linear-gradient( + to right, + transparent, + rgba(255, 255, 255, 0.06) 50%, + transparent + ); + pointer-events: none; +} +[data-theme="light"] .toolbar::after { + background: linear-gradient( + to right, + transparent, + rgba(255, 255, 255, 0.6) 50%, + transparent + ); +} + +.toolbar-left { + display: flex; + align-items: center; + gap: var(--space-3); + min-width: 0; + flex-shrink: 1; +} + +/* 中间搜索栏:撑满剩余空间 */ +.toolbar-center { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + -webkit-app-region: no-drag; + /* 搜索框外侧左右各让 16px:toolbar gap=12 + 这里 16 = 28px, + 把搜索区与「Todo List」标题和右侧按钮都推开。 + 只在 .toolbar-center 上加 margin 而不是改 toolbar 的 gap, + 是为了不破坏标题与窗口控件本身的紧凑节奏 */ + margin: 0 16px; +} + +/* 右侧按钮组:固定宽度,紧贴右边缘 */ +.toolbar-right { + display: flex; + align-items: center; + gap: var(--space-1); + flex-shrink: 0; + -webkit-app-region: no-drag; +} + +/* 自定义标题栏接管 OS 标题栏后,标题是窗口唯一的"标题"识别,需要更醒目: + 字号用 --font-lg(17.5px,default 下),与 24px logo 形成约 1:1.4 的视觉比例 + —— 不至于让 logo 抢走整块权重;font-weight 700 + 微负字距减少「Todo List」粘连感。 + line-height 锁紧 1 —— toolbar 52px 高,标题行高 1.55 会让 baseline 偏低、 + 视觉重心往下沉;锁 1 让 title-text 与 logo 顶部/底部都贴齐。 */ +.app-title { + display: flex; + align-items: center; + gap: 10px; + font-size: var(--font-lg); + font-weight: 700; + line-height: 1; + color: var(--color-text); + letter-spacing: -0.018em; + flex-shrink: 0; + /* 整块作为 drag 区域内的「元素」,不要再继承按钮的 cursor: pointer, + 否则用户把鼠标移到标题上会看到手指光标,误以为可点 */ + cursor: default; +} + +.title-text { + color: var(--color-text); + font-feature-settings: "cv11", "ss01"; + /* 文字与 logo 视觉中心对齐:logo 是块级 img + border-radius, + 文字 baseline 比 logo 视觉中心略低,translateY 1px 把视觉中心拉齐 */ + transform: translateY(1px); +} + +.logo { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 7px; + flex-shrink: 0; + transition: transform 0.2s var(--ease-spring), box-shadow 0.15s; +} + +.logo img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + border-radius: inherit; +} + +/* ============================================ + 自定义窗口控制按钮(frame: false 后接管系统标题栏) + ============================================ + 整组按钮放在工具栏最右侧 —— 工具栏本身是 drag 区, + 子节点 window-controls 设 no-drag 让按钮可点。 + 尺寸统一 32px(=--h-control),与左侧设置/主题按钮同高,整体视觉节奏一致; + 但按下/悬浮反馈是窗口控件语义(关闭按钮走危险色),与普通 icon 按钮区分。 + ============================================ */ + +.window-controls { + display: inline-flex; + align-items: center; + gap: 0; + margin-left: var(--space-2); + padding-left: var(--space-2); + /* 与左侧设置按钮做一道极细分隔 —— 颜色取 --color-border 但 0.6 透明度, + 比硬 1px 更柔和,不抢走窗口控件组的视觉重心 */ + border-left: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent); + -webkit-app-region: no-drag; + flex-shrink: 0; +} + +.btn-window-control { + width: 34px; + height: 34px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid transparent; + color: var(--color-text-secondary); + border-radius: var(--radius-sm); + /* 过渡稍放慢一点(150ms)—— 悬浮色块和颜色切换更平滑,避免硬切感 */ + transition: background 0.15s var(--ease-soft), + color 0.15s var(--ease-soft); + -webkit-app-region: no-drag; + cursor: pointer; +} + +/* 让 SVG 图标默认色随 currentColor 走,避免 hover 时颜色切换有延迟 */ +.btn-window-control svg { + display: block; +} + +/* 关闭按钮:悬浮用危险色 —— 提示用户这是不可逆操作(隐藏到托盘) */ +.btn-window-control:hover { + background: var(--color-bg-tertiary); + color: var(--color-text); +} + +.btn-window-control.btn-close:hover { + /* 用更深一档的 danger-bg 作为 hover 背景,色块更显眼 */ + background: var(--color-danger); + color: #fff; +} + +/* 关闭按钮按下:用更深的色调(color-mix 调暗 12%)—— 让点击瞬间有"按下了"的反馈 */ +.btn-window-control.btn-close:active { + background: color-mix(in srgb, var(--color-danger) 88%, #000); +} + +/* 其余两个按钮按下:背景色比 hover 再深一档(color-mix 朝黑偏移 25%) + —— 比 hover 色块更深,但比纯黑亮,确保暗色主题下也看得见"按下"的反馈 */ +.btn-window-control:not(.btn-close):active { + background: color-mix(in srgb, var(--color-bg-tertiary) 75%, #000); +} + +.btn-window-control:active { + /* 取消 .btn 全局 0.96 缩放 —— 窗口按钮点击应当即时生效, + 否则快速连点 X 会出现明显延迟感 */ + transform: none; +} + +/* 最大化/还原图标:data-state 控制显示哪一个。两种图标都画在 DOM 里, + 通过 display 切换比 innerHTML 重写省一次回流。 */ +.btn-window-control[data-state="maximize"] .icon-restore { + display: none; +} +.btn-window-control[data-state="restore"] .icon-maximize { + display: none; +} + +/* light theme 下按钮默认色比暗色主题更弱,hover 用稍深的色块 */ +[data-theme="light"] .btn-window-control { + color: var(--color-text-secondary); +} +[data-theme="light"] .btn-window-control:hover { + background: var(--color-bg-tertiary); + color: var(--color-text); +} +[data-theme="light"] .btn-window-control:not(.btn-close):active { + background: color-mix(in srgb, var(--color-bg-tertiary) 75%, #000); +} + +.app-title:hover .logo { + transform: scale(1.05); +} + +/* 文件信息条:path chip + 跳转文件管理器按钮(现位于底部状态栏) */ +.file-path-chip { + display: inline-flex; + align-items: center; + /* gap:0:让圆点、hint、路径文字三者紧贴,节奏紧凑;间距由 ::before margin 控制 */ + gap: 0; + /* 不设 max-width:状态栏剩下的空间([chip][spacer flex:1][right])让 chip 自然吃满, + 路径短就小、路径长就拉到状态栏宽度减右侧文字宽再减 padding/gap。 + 真正放不下时由 .file-path-text 内部 ellipsis 处理 —— JS 不再 shorten,title 始终是全路径。 + min-width:0 是 ellipsis 起作用的必要前提(flex item 默认 min-content,会顶死父容器)。 */ + min-width: 0; + flex: 0 1 auto; + height: 24px; + padding: 0 5px 0 7px; + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius-sm); + /* 与状态栏整体走 font-xs —— 文件路径是辅助信息,配合 30px 高的状态栏 */ + font-size: var(--font-xs); + color: var(--color-text-secondary); + font-family: var(--font-mono); + transition: background 0.15s var(--ease-soft), + border-color 0.15s var(--ease-soft); + -webkit-app-region: no-drag; + letter-spacing: -0.003em; + position: relative; +} + +/* 路径前的标签「数据路径:」,跟 chip 一起组成完整语义。 + 放在 chip 内、紧贴路径文字,让「标签 + 路径」读起来是一个整体, + 避免留白过多。文案在 HTML 里写死(≤620px 极窄窗口隐藏整个 chip)。 */ +.file-path-hint { + flex-shrink: 0; + /* 与 file-path-chip 保持一致 —— 「数据路径:」标签也是辅助信息 */ + font-size: var(--font-xs); + color: var(--color-text-secondary); + white-space: nowrap; + user-select: none; + pointer-events: none; +} + +.file-path-chip::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-success); + /* 圆点和 hint 标签之间留 5px,让状态点和标签读起来不黏在一起 */ + margin-right: 5px; + flex-shrink: 0; + transition: all 0.2s; +} + +.file-path-chip:hover { + background: var(--color-bg-tertiary); + border-color: var(--color-border); +} + +.file-path-chip.dirty { + border-color: var(--color-warning); + background: var(--color-warning-bg); +} + +.file-path-chip.dirty::before { + background: var(--color-warning); + box-shadow: 0 0 6px var(--color-warning); + animation: pulse-dot 1.5s ease-in-out infinite; +} + +.file-path-chip.dirty:hover { + background: var(--color-warning-bg); + border-color: var(--color-warning); + filter: brightness(1.05); +} + +@keyframes pulse-dot { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.6; transform: scale(0.85); } +} + +.file-path-chip .file-path-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + user-select: text; + min-width: 0; + flex: 1; + font-weight: 500; + /* 不挂 mask:与侧栏分类名不同,这里 .file-path-text 通过 flex:1 撑满 chip 剩余空间, + 文字宽度 = 元素宽度 = 延伸到右边缘。如果照搬侧栏的右侧 12px 渐隐 mask,会把 + "未截断时的最后 12px 真实文字" 也淡掉,用户会感觉路径"差一点点没显示全"。 + 截断时由 text-overflow:ellipsis 自己提供 … 提示,硬切一点但语义清晰。 */ +} + +.file-path-chip.unsaved { + background: transparent; + border-color: var(--color-border); + border-style: dashed; + color: var(--color-text-muted); +} + +.file-path-chip.unsaved::before { + background: transparent; + border: 1px solid var(--color-text-faint); + width: 5px; + height: 5px; + margin-right: 4px; +} + +.file-path-chip.unsaved:hover { + background: var(--color-bg-tertiary); + border-style: solid; +} + +.file-path-chip.unsaved .file-path-text { + color: var(--color-text-muted); + font-style: italic; +} + +/* 搜索框:与工具栏其它 icon 按钮同为 32px 高,整体视觉节奏一致 */ +.search-wrapper { + display: flex; + align-items: center; + gap: var(--space-2); + background: var(--color-bg-tertiary); + border: 1px solid transparent; + border-radius: var(--radius-md); + /* 左 13px / 右 20px:左侧放大镜是搜索的视觉锚点,离边缘越近 + 越像「就地放大」的提示,太多留白反而让它显得「丢失」; + 右侧快捷键标签 / ✕ 按钮需要明确的呼吸位,保留 20px */ + padding: 0 20px 0 13px; + width: 100%; + height: var(--h-control); + transition: background 0.18s var(--ease-soft), + border-color 0.18s var(--ease-soft), + box-shadow 0.2s var(--ease-soft); + -webkit-app-region: no-drag; + position: relative; +} + +.search-wrapper:hover { + background: var(--color-bg-elevated); + border-color: var(--color-border); + /* 悬浮时给一点微上浮感 */ + transform: translateY(-0.5px); +} + +.search-wrapper:focus-within { + background: var(--color-bg-elevated); + border-color: var(--color-accent); + box-shadow: 0 0 0 4px var(--color-accent-bg); +} + +.search-wrapper:focus-within .search-shortcut { + opacity: 0; + width: 0; + padding: 0; + margin: 0; + border-color: transparent; +} + +.search-icon { + color: var(--color-text-secondary); + display: inline-flex; + align-items: center; + flex-shrink: 0; + transition: color 0.15s; +} + +.search-wrapper:focus-within .search-icon { + color: var(--color-accent); +} + +.search-icon svg { + width: 14px; + height: 14px; +} + +.search-input { + flex: 1; + height: 100%; + font-size: var(--font-base); + min-width: 0; + font-weight: 400; + letter-spacing: -0.005em; +} + +.search-shortcut { + font-family: var(--font-mono); + font-size: var(--font-xs); + color: var(--color-text-secondary); + padding: 2px 6px; + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: 4px; + flex-shrink: 0; + transition: opacity 0.15s, width 0.15s, padding 0.15s, margin 0.15s; + overflow: hidden; + line-height: 1.2; + white-space: nowrap; + font-weight: 500; +} + +/* 按钮内的快捷键标签已迁移到 .btn-kbd —— 旧 .btn-shortcut 不再使用(cleaned: 26-08) */ + +.search-clear { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-muted); + line-height: 1; + transition: background 0.15s, color 0.15s; + flex-shrink: 0; +} + +/* 修正:display: flex 会覆盖原生 [hidden] 的 display: none —— 这里显式回归 */ +.search-clear[hidden] { + display: none; +} + +/* 提交按钮的 hidden 处理(task-list.js 在输入为空时设为 hidden) */ +.task-submit-btn[hidden] { + display: none; +} + +.search-clear:hover { + background: var(--color-border-strong); + color: var(--color-text); +} + +/* 按钮(通用:紧凑、低对比、悬浮态柔和) */ +.btn { + display: inline-flex; + align-items: center; + gap: 7px; + height: var(--h-control); + padding: 0 11px; + border-radius: var(--radius-md); + font-size: var(--font-sm); + font-weight: 500; + color: var(--color-text-secondary); + background: transparent; + border: 1px solid transparent; + transition: background 0.15s var(--ease-soft), + color 0.15s var(--ease-soft), + border-color 0.15s var(--ease-soft), + box-shadow 0.2s var(--ease-soft), + transform 0.08s var(--ease-soft); + -webkit-app-region: no-drag; + letter-spacing: -0.005em; + white-space: nowrap; + position: relative; +} + +.btn:active { + transform: scale(0.96); +} + +.btn:hover { + background: var(--color-bg-tertiary); + color: var(--color-text); +} + +/* 主操作按钮:沉稳、扁平 */ +.btn-primary { + background: var(--color-accent); + color: #ffffff; + border-color: transparent; + font-weight: 500; + font-size: var(--font-sm); + box-shadow: var(--shadow-accent); + transition: background 0.15s var(--ease-soft), + box-shadow 0.2s var(--ease-soft), + transform 0.08s var(--ease-soft); + letter-spacing: -0.003em; +} + +.btn-primary:hover { + background: var(--color-accent-hover); + color: #ffffff; + box-shadow: 0 0 0 4px var(--color-accent-bg), var(--shadow-accent); + transform: translateY(-1px); +} + +/* 主操作按钮的 danger 变体(用于 confirmDialog 的删除确认按钮) */ +.btn-primary.btn-danger { + background: var(--color-danger); + border-color: var(--color-danger); + box-shadow: 0 1px 2px rgba(225, 29, 72, 0.3); +} + +.btn-primary.btn-danger:hover { + background: color-mix(in srgb, var(--color-danger) 88%, #ffffff); + filter: brightness(1.05); + box-shadow: 0 0 0 3px var(--color-danger-bg), 0 1px 2px rgba(225, 29, 72, 0.4); +} + +/* 链接样的危险按钮(settings-dialog 的"恢复全部默认") */ +.btn-link-danger { + background: transparent; + border: none; + color: var(--color-danger); + font-size: var(--font-sm); + font-weight: 500; + cursor: pointer; + padding: 5px 12px; + border-radius: var(--radius-md); + margin-right: auto; + transition: background 0.15s, color 0.15s; +} + +.btn-link-danger:hover { + background: var(--color-danger-bg); + color: var(--color-danger); +} + +.btn-icon { + padding: 0; + /* 30×30:toolbar 整体加高到 52px 后,原 28px 显得偏小、与窗口控件 34px 差距过大; + 30px 与图标 12px 同步上调,比例协调,悬浮反馈也更易感知 */ + width: 30px; + height: 30px; + justify-content: center; + color: var(--color-text-secondary); + background: var(--color-chip-bg); + border-color: var(--color-border); + /* 给所有 icon 按钮一个微阴影,悬浮态时升级——「可点击」的微提示 */ + box-shadow: var(--shadow-sm); + transition: background 0.15s var(--ease-soft), + color 0.15s var(--ease-soft), + border-color 0.15s var(--ease-soft), + box-shadow 0.2s var(--ease-soft), + transform 0.1s var(--ease-soft); +} + +.btn-icon:hover { + background: var(--color-bg-tertiary); + color: var(--color-text); + border-color: var(--color-border-strong); + transform: translateY(-0.5px); + box-shadow: var(--shadow-md); +} + +.btn-icon.active { + background: var(--color-accent-bg); + color: var(--color-accent-hover); + border-color: transparent; + box-shadow: 0 0 0 3px var(--color-accent-bg); +} + +/* .btn-block 已停用 —— 现在按钮内自带 icon + label,宽度由 flex 自然撑开, + 不需要「强制 100% 宽度」。删除以避免被后人误用。cleaned: 26-08 */ + +.btn-icon-sm { + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + font-size: var(--font-sm); + color: var(--color-text-muted); + transition: background 0.15s, color 0.15s; +} + +.btn-icon-sm:hover { + background: var(--color-bg-tertiary); + color: var(--color-text); +} + +.btn-icon-sm.btn-danger:hover { + background: var(--color-danger-bg); + color: var(--color-danger); +} + +.btn svg { + /* 14px:保留模态对话框按钮里的图标尺寸,不与顶部 toolbar 一起缩小 */ + width: 15px; + height: 15px; + flex-shrink: 0; +} + +.btn-icon svg { + /* 12px:toolbar 顶部按钮专用 —— 与升到 30px 的按钮框同步, + 内边距 9px 比之前 8.5px 略松,更显轻盈 */ + width: 12px; + height: 12px; + flex-shrink: 0; +} + +.btn-icon-sm svg { + width: 13px; + height: 13px; + flex-shrink: 0; +} + +/* ============================================ + 下拉菜单基础样式(被 sort 菜单复用) + ============================================ */ +.more-menu { + position: fixed; + z-index: var(--z-menu); + min-width: 240px; + background: var(--color-bg-elevated); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + padding: 5px; + font-size: var(--font-sm); + animation: menu-in 0.16s var(--ease-out); + -webkit-app-region: no-drag; +} + +.more-menu-item { + display: flex; + align-items: center; + gap: 11px; + width: 100%; + padding: 8px 11px; + border-radius: 5px; + cursor: pointer; + color: var(--color-text); + text-align: left; + background: none; + border: none; + font-family: inherit; + font-size: var(--font-sm); + font-weight: 500; + letter-spacing: -0.003em; + transition: background 0.1s, color 0.1s; +} + +.more-menu-item:hover { + background: var(--color-accent-bg); + color: var(--color-accent-hover); +} + +.more-menu-item > svg { + width: 14px; + height: 14px; + flex-shrink: 0; + color: var(--color-text-muted); + transition: color 0.1s; +} + +.more-menu-item:hover > svg { + color: var(--color-accent-hover); +} + +.more-menu-item > span { + flex: 1; + min-width: 0; + letter-spacing: -0.003em; +} + +/* 排序菜单(由 sort-btn 触发,固定到 btn-sort 旁边) */ +.sort-menu { + min-width: 180px; +} + +/* 过滤菜单(由 filter-btn 触发,风格与 sort-menu 保持一致) */ +.filter-menu { + min-width: 168px; +} + +.sort-menu-header { + padding: 6px var(--space-3) 4px; + font-size: var(--font-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-text-muted); +} + +.sort-check { + color: var(--color-accent); + font-weight: 600; + margin-left: auto; +} + +/* 显式盖过 `.more-menu-item > span { flex: 1 }`,让勾选标记不会被撑开 + (否则在 3-span 的过滤菜单里,label 和 ✓ 会平分空间而非贴右对齐) */ +.more-menu-item > .sort-check { + flex: 0 0 auto; +} + +/* ============================================ + 主体布局 - 两栏 (Sidebar | Task List) + ============================================ */ + +.layout { + display: grid; + /* 侧栏宽度按窗口宽度在 245–280px 之间平滑过渡(中宽度临界点不再「抖」) */ + grid-template-columns: clamp(245px, 25%, 280px) 1fr; + overflow: hidden; + min-height: 0; +} + +/* ============================================ + 侧边栏 + ============================================ */ + +.sidebar { + display: flex; + flex-direction: column; + background: var(--color-bg-secondary); + border-right: 1px solid var(--color-border); + overflow: hidden; + min-width: 0; + position: relative; + /* 侧栏上沿的高光线,配合主区那条 — 让顶部边缘更像被光照亮 */ +} + +/* 侧栏顶部的 1px 高光,让窗格衔接处更精致 */ +.sidebar::before { + content: ""; + position: absolute; + top: 0; + right: -1px; + bottom: 0; + width: 1px; + background: linear-gradient( + to bottom, + rgba(255, 255, 255, 0.08) 0%, + transparent 30%, + transparent 70%, + rgba(255, 255, 255, 0.04) 100% + ); + pointer-events: none; + z-index: 1; +} +[data-theme="light"] .sidebar::before { + background: linear-gradient( + to bottom, + rgba(255, 255, 255, 0.8) 0%, + transparent 30%, + transparent 70%, + rgba(0, 0, 0, 0.02) 100% + ); +} + +.category-list { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 10px 8px 0; + min-height: 0; +} + +.category-list::after { + content: ""; + display: block; + height: var(--space-2); +} + +/* 智能列表与子分类之间的柔和分隔线 */ +.sidebar-divider { + height: 1px; + background: var(--color-border-muted); + margin: 10px 12px 8px; + border-radius: 1px; +} + +/* 「+ 新建子分类」按钮:渲染在子分类列表末尾(紧跟最后一个子分类) + 风格上融入列表:与子分类项同字号、虚框表「待添加」位,hover 变为实心强调色。*/ +.btn-add-subcategory { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + height: 32px; + padding: 0 11px 0 14px; + margin-top: 5px; + font-size: var(--font-sm); + font-weight: 500; + color: var(--color-text-secondary); + background: transparent; + border: 1px dashed var(--color-border-muted); + border-radius: 7px; + cursor: pointer; + text-align: left; + white-space: nowrap; + /* border-style 不可平滑过渡,省略以免给人「卡顿」错觉 */ + transition: background 0.15s var(--ease-soft), + color 0.15s var(--ease-soft), + border-color 0.15s var(--ease-soft), + transform 0.12s var(--ease-soft); +} + +.btn-add-subcategory:hover { + background: var(--color-accent-bg); + color: var(--color-accent-hover); + border-color: var(--color-accent); + border-style: solid; +} + +.btn-add-subcategory:active { + transform: scale(0.98); +} + +.btn-add-subcategory:focus-visible { + outline: none; + box-shadow: var(--focus-ring-light); +} + +.btn-add-subcategory svg { + flex-shrink: 0; +} + +.btn-add-subcategory .btn-text { + font-weight: 500; +} + +.category-item { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 11px 7px 13px; + border-radius: 7px; + cursor: pointer; + margin-bottom: 1px; + font-size: var(--font-sm); + font-weight: 500; + color: var(--color-text-secondary); + transition: background 0.15s var(--ease-soft), + color 0.15s var(--ease-soft), + transform 0.15s var(--ease-soft); + user-select: none; + position: relative; + min-height: 32px; +} + +.category-item:hover { + background: var(--color-bg-tertiary); + color: var(--color-text); + /* 微平移给出「回应」的感觉 */ + transform: translateX(1px); +} + +.category-item:focus-visible { + outline: none; + box-shadow: var(--focus-ring-light); +} + +/* 已激活项 hover 时不要平移(会和指示条一起抖),且颜色保持 accent 不退回 */ +.category-item.active:hover { + transform: none; +} + +.category-item.active { + /* 激活态:柔和的渐变背景 + 细微 inset 高光,立体感而不是平面色块 */ + background: linear-gradient( + to right, + var(--color-accent-bg-strong), + var(--color-accent-bg) 60%, + transparent + ); + color: var(--color-accent-hover); + font-weight: 600; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.category-item.active::before { + content: ""; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 2.5px; + height: 16px; + background: var(--color-accent); + border-radius: 0 3px 3px 0; + /* 激活态的指示条带一点 accent glow,更精神 */ + box-shadow: 0 0 8px var(--color-accent-glow); +} + +.category-icon { + width: 17px; + height: 17px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-text-muted); + flex-shrink: 0; + transition: color 0.15s; +} + +.category-item.active .category-icon { + color: var(--color-accent-hover); +} + +.category-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + letter-spacing: -0.005em; + /* 长名称被截断时,右侧加 12px 渐隐到透明,让硬切的边界不那么扎眼。 + 未截断时这块透明区没东西可盖,视觉无差异。 */ + -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 12px), transparent 100%); + mask-image: linear-gradient(to right, #000 calc(100% - 12px), transparent 100%); +} + +/* ============================================ + 侧边栏条目右侧数量徽章 —— 让每个分类一眼看出工作量 + ============================================ */ +.category-count { + /* 紧凑的胶囊:左侧贴名字、右侧留 2px 余量给 hover/选中态边距; + flex-shrink: 0 防被 .category-name 的 ellipsis + mask 抢到 0 宽度 */ + flex-shrink: 0; + margin-left: auto; + margin-right: 2px; + min-width: 22px; + height: 18px; + padding: 0 6px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 10.5px; + font-weight: 600; + font-variant-numeric: tabular-nums; /* 数字等宽,避免 1→9→10 时徽章宽度抖动 */ + line-height: 1; + color: var(--color-text-muted); + background: var(--color-bg-tertiary); + border-radius: var(--radius-pill); + transition: background 0.15s var(--ease-soft), + color 0.15s var(--ease-soft); + /* 透出柔和的「非关键信息」感;激活态由 .active 规则覆盖为强调色 */ + letter-spacing: 0; +} + +/* hover:徽章轻微提亮(与背景层一起切到更亮的底色,给用户「这一行被点到」的反馈) */ +.category-item:hover .category-count { + background: var(--color-bg-secondary); + color: var(--color-text-secondary); +} + +/* 激活态:徽章用与文字一致的强调色 + 透明底(避免和渐变背景重叠看起来糊成一团) */ +.category-item.active .category-count { + background: rgba(79, 70, 229, 0.14); + color: var(--color-accent-hover); +} + +/* 智能列表的徽章 — 默认稍重一点(让「当前/重要」一眼看出待办量) + 注:--color-chip-bg 与 .category-count 默认的 --color-bg-tertiary 数值完全一致, + 所以这里不再重复声明背景 —— 等颜色变量需要分流时再启用此规则。 */ + +/* 回收站的徽章:用略带警告感的灰底,与回收站整体的低调配色一致 */ +.category-item.category-trash .category-count { + background: var(--color-bg-secondary); + color: var(--color-text-tertiary); +} + +/* 子分类 + hover 操作按钮共存时,操作按钮要盖在徽章位置上(保证可点); + 这里不强制定位,只让徽章在 hover/active 时降低不透明度,让操作按钮更醒目。 + 仅在操作按钮存在时被选择器匹配(subcategory-actions 是兄弟节点) */ +.category-item.subcategory:hover .category-count, +.category-item.subcategory.active .category-count, +.category-item.subcategory:focus-within .category-count { + /* 操作按钮出现时徽章退到更淡的视觉权重,避免与按钮抢戏 */ + opacity: 0.55; + transition: opacity 0.15s var(--ease-soft); +} + +/* ============================================ + 智能列表(侧边栏顶部) + ============================================ */ +.smart-list { + position: relative; + margin-bottom: 1px; +} + +.smart-list .category-icon { + width: 17px; + height: 17px; + display: inline-flex; + align-items: center; + justify-content: center; + transition: transform 0.2s var(--ease-spring); +} + +.smart-list:hover .category-icon { + transform: scale(1.08); +} + +/* 重要 — 琥珀色(与「当前任务」的暖黄区分开,避免视觉混淆) */ +/* sidebar.js 把 smart-${key} 类放在 .smart-icon span 上而不是 .category-item 上, + 旧选择器 .smart-list.smart-important .category-icon 永远不匹配 —— 改成 .smart-icon。 + 这里同时保留旧的(万一以后又迁回)和新的实际命中的选择器 */ +.smart-list .smart-icon.smart-important, +.smart-list.smart-important .category-icon { + color: var(--color-star); +} + +/* 激活态:使用与普通激活态一致的渐变(不要用纯色块,破坏统一感) */ +.smart-list.active { + background: linear-gradient( + to right, + var(--color-accent-bg-strong), + var(--color-accent-bg) 60%, + transparent + ); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.smart-list.active .category-name { + color: var(--color-accent-hover); + font-weight: 600; +} + +/* 激活时图标变为统一强调色(保持原色调以传达语义) */ +.smart-list.smart-important.active .category-icon { + color: var(--color-accent); +} + +/* 智能列表与子分类之间的分隔线由 sidebar.js 用 class="sidebar-divider" 控制 */ + +/* 「当前任务」智能列表 —— v3 起从真实分类下沉为标识驱动的视图。 + 视觉权重略高于「重要任务」,符合「现在要聚焦的」语义 */ +.category-item.smart-list[data-smart-key="current"] { + font-weight: 600; +} + +/* 「当前任务」图标:与「重要任务」区分色(蓝色聚焦感) */ +.category-item.smart-list[data-smart-key="current"] .smart-icon { + color: var(--color-current); +} + +.category-item.smart-list[data-smart-key="current"].active .smart-icon { + color: var(--color-accent); +} + +/* 已完成分类:使用默认文本色,与其他分类保持一致 */ +.category-item.category-completed { + color: inherit; +} + +.category-item.category-completed .category-icon { + color: inherit; +} + +/* 回收站:略淡的颜色,与「已完成」呼应但更低调 */ +.category-item.category-trash { + color: var(--color-text-tertiary); + margin-top: 4px; +} + +.category-item.category-trash .category-icon { + color: var(--color-text-tertiary); +} + +.category-item.category-trash.active { + color: var(--color-text); +} + +.category-item.category-trash.active .category-icon { + color: var(--color-text); +} + +/* 回收站中的任务项:降低视觉权重 */ +.task-item.task-trash { + opacity: 0.7; +} + +/* 回收站里只有「恢复」+「编辑」两个按钮(没有星标、当前、删除), + 默认就应该清楚地能看到 —— 不像普通行那样 hover/selected 才显形, + 否则用户会以为按钮点不动。*/ +.task-item.task-trash .task-actions { + opacity: 0.9; +} + +.task-item.task-trash .task-text { + text-decoration: line-through; + text-decoration-color: var(--color-text-tertiary); +} + +.category-item.dragging { + opacity: 0.4; +} + +.category-item.dragging::before { + content: ""; + position: absolute; + inset: 0; + background: var(--color-bg-tertiary); + border: 1px dashed var(--color-accent); + border-radius: var(--radius-md); +} + +/* 拖拽落点指示:drag-over = 插到该项之前,drag-over-below = 插到该项之后 */ +.category-item.drag-over { + border-top: 2px solid var(--color-accent); +} + +.category-item.drag-over-below { + border-bottom: 2px solid var(--color-accent); +} + +/* ============================================ + 子分类(缩进显示) + ============================================ */ + +.subcategory-list { + /* 子分类容器,仅做缩进布局 */ + margin-left: 14px; + padding-left: 6px; +} + +.category-item.subcategory { + padding-left: 14px; +} + +.category-item.subcategory .category-icon { + width: 12px; + height: 12px; + opacity: 0.7; +} + +.category-item.subcategory:hover .category-icon { + opacity: 1; +} + +.category-item.subcategory .category-name { + font-size: var(--font-sm); + font-weight: 500; + color: var(--color-text-secondary); +} + +/* 子分类右侧操作按钮(默认隐藏,hover/active 时显示) */ +.subcategory-actions { + display: flex; + align-items: center; + gap: 1px; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.12s; +} + +.category-item.subcategory:hover .subcategory-actions, +.category-item.subcategory.active .subcategory-actions, +.category-item.subcategory:focus-within .subcategory-actions { + opacity: 1; +} + +.subcategory-action-btn { + width: 22px; + height: 22px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; + color: var(--color-text-muted); + background: transparent; + border: none; + cursor: pointer; + transition: background 0.12s var(--ease-soft), + color 0.12s var(--ease-soft), + transform 0.08s var(--ease-soft); +} + +.subcategory-action-btn:hover { + background: var(--color-bg-elevated); + color: var(--color-text); +} + +.subcategory-action-btn:active { + transform: scale(0.88); +} + +.subcategory-action-btn.subcategory-action-delete:hover { + background: var(--color-danger-bg); + color: var(--color-danger); +} + +.category-item.subcategory.dragging { + opacity: 0.5; +} + +.category-item.subcategory.drag-over { + background: var(--color-accent-bg); +} + +/* 跨分类转移落点:拖任务 → 侧边栏子分类。 + 与「拖子分类 → 子分类」用的 .drag-over 区别开,避免语义混淆: + .drag-over 是「插到这条之前/之后」,task-drop-target 是「把任务搬进这个分类」。 */ +.category-item.subcategory.task-drop-target { + background: var(--color-accent-bg); + outline: 1px solid var(--color-accent); + outline-offset: -1px; + border-radius: var(--radius-sm); +} +.category-item.subcategory.task-drop-target .category-name { + color: var(--color-accent-hover); + font-weight: 600; +} + +/* 「全部任务」智能列表项(容器入口) */ +.category-item.category-other-tasks { + font-weight: 600; +} + +.category-item.category-other-tasks .category-icon { + color: var(--color-text-secondary); +} + +.category-item.category-other-tasks.active .category-icon { + color: var(--color-accent-hover); +} + +/* 空状态:没有子分类时给一个柔和的提示 */ +.subcategory-empty { + font-size: var(--font-xs); + color: var(--color-text-muted); + padding: 7px 14px 7px 28px; + font-style: italic; + user-select: none; +} + +/* ============================================ + 任务面板 + ============================================ */ + +.task-panel { + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--color-bg); + min-width: 0; +} + +.task-header { + display: flex; + align-items: center; + padding: 16px var(--space-7) 14px; + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; + gap: var(--space-3); + min-width: 0; +} + +.task-header-main { + display: flex; + align-items: baseline; + gap: 11px; + min-width: 0; + flex: 1; + overflow: hidden; /* 防止长标题撑破容器导致按钮溢出 */ +} + +.task-category-title { + font-size: var(--font-xl); + font-weight: 700; + color: var(--color-text); + letter-spacing: -0.028em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + line-height: 1.25; + flex-shrink: 1; + display: inline-flex; + align-items: center; + gap: 9px; +} + +/* 标题前缀图标:与侧边栏一致使用 SVG */ +.task-category-title .title-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--color-text-secondary); + width: 22px; + height: 22px; + transition: color 0.15s; +} + +.task-category-title .title-icon svg { + width: 100%; + height: 100%; + display: block; +} + +/* 「当前任务」图标:与侧边栏一致使用蓝色(与重要任务的琥珀色区分) */ +.task-category-title .title-icon-current { + color: var(--color-current); +} + +.task-category-title .title-icon-important { + color: var(--color-star); +} + +.task-category-title .title-icon-completed { + color: var(--color-success); +} + +.task-category-title .title-icon-folder, +.task-category-title .title-icon-sub { + color: var(--color-text-muted); +} + +.task-category-title .title-icon-trash { + color: var(--color-text-tertiary); +} + +/* 标题旁的进度文字:紧凑、不抢视觉 */ +.task-progress-text { + /* 与 page title (font-xl) 同处一行 —— 用 font-xs 让「3/5 项」 + 这种元数据明显小于标题、不抢视觉重心 */ + font-size: var(--font-xs); + font-weight: 500; + color: var(--color-text-secondary); + font-variant-numeric: tabular-nums; + white-space: nowrap; + letter-spacing: -0.005em; + flex-shrink: 0; +} + +.task-progress-text.completed { + color: var(--color-success); +} + +/* 任务头部右侧的操作区:过滤器 / 排序 */ +.task-header-actions { + display: flex; + align-items: center; + gap: var(--space-2); + flex-shrink: 0; +} + +/* 过滤器 / 排序 chip */ +.filter-chip-btn { + background: transparent; + border: 1px solid transparent; + padding: 0; + border-radius: var(--radius-pill); + cursor: pointer; + transition: background 0.15s var(--ease-soft), + border-color 0.15s var(--ease-soft), + transform 0.08s var(--ease-soft); +} + +.filter-chip-btn:hover { + background: var(--color-bg-tertiary); +} + +.filter-chip-btn:active { + transform: scale(0.96); +} + +.filter-chip { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: var(--font-sm); + font-weight: 500; + color: var(--color-text-secondary); + padding: 4px 12px; + background: var(--color-chip-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-pill); + white-space: nowrap; + box-shadow: var(--shadow-sm); + transition: background 0.15s var(--ease-soft), + color 0.15s var(--ease-soft), + border-color 0.15s var(--ease-soft), + box-shadow 0.2s var(--ease-soft), + transform 0.1s var(--ease-soft); + letter-spacing: -0.003em; +} + +.filter-chip-btn:hover .filter-chip { + background: var(--color-bg-elevated); + border-color: var(--color-border-strong); + color: var(--color-text); + transform: translateY(-0.5px); + box-shadow: var(--shadow-md); +} + +.filter-chip[data-filter="current"] { + background: var(--color-warning-bg); + color: var(--color-warning); +} + +.filter-chip[data-filter="completed"] { + background: var(--color-success-bg); + color: var(--color-success); +} + +/* 排序 chip — 中性配色,不带 filter 的语义色彩 */ +.filter-chip.sort-chip { + background: var(--color-chip-bg); + color: var(--color-text-secondary); + border-color: var(--color-border); +} + +.filter-chip-btn:hover .filter-chip.sort-chip { + background: var(--color-bg-elevated); + border-color: var(--color-border-strong); + color: var(--color-text); +} + +/* 排序 chip 在「手动」模式(默认):中性配色 */ +.filter-chip.sort-chip[data-sort="manual"] { + background: var(--color-chip-bg); + color: var(--color-text-secondary); + border-color: var(--color-border); +} + +/* hover 时提升层级 */ +.filter-chip-btn:hover .filter-chip.sort-chip[data-sort="manual"] { + background: var(--color-bg-elevated); + border-color: var(--color-border-strong); + color: var(--color-text); +} + +.filter-chip[data-filter="important"] { + background: var(--color-warning-bg); + color: var(--color-warning); + font-weight: 600; +} + +/* 排序 chip 在「字母」模式(非默认):强调色,给用户「这是另一种排序」的视觉提示 */ +.sort-chip[data-sort="alphabet"] { + background: var(--color-accent-bg); + color: var(--color-accent-hover); + font-weight: 500; +} + +/* 导入 chip —— 与 sort chip 同款中性配色: + 不带过滤/排序的语义色彩,避免用户在「过滤/排序/导入」三连按钮里被暗示成「这也是过滤」 */ +.import-chip { + background: var(--color-chip-bg); + color: var(--color-text-secondary); + border-color: var(--color-border); +} + +.filter-chip-btn:hover .import-chip { + background: var(--color-bg-elevated); + border-color: var(--color-border-strong); + color: var(--color-text); +} + +.filter-chip .chip-icon { + font-size: 10px; + line-height: 1; +} + +.filter-chip svg { + flex-shrink: 0; +} + +/* 任务输入(在任务面板底部) */ +.task-input-wrapper { + display: flex; + align-items: center; + gap: 11px; + padding: 9px var(--space-7); + border-top: 1px solid var(--color-border); + background: var(--color-bg-overlay); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + flex-shrink: 0; + transition: background 0.18s var(--ease-soft); + position: relative; + z-index: 5; +} + +/* 输入区顶部的细高光,与工具栏呼应 */ +.task-input-wrapper::before { + content: ""; + position: absolute; + left: 0; + right: 0; + top: -1px; + height: 1px; + background: linear-gradient( + to right, + transparent, + rgba(255, 255, 255, 0.06) 50%, + transparent + ); + pointer-events: none; +} +[data-theme="light"] .task-input-wrapper::before { + background: linear-gradient( + to right, + transparent, + rgba(255, 255, 255, 0.6) 50%, + transparent + ); +} + +.task-input-wrapper:focus-within { + background: var(--color-bg-secondary); +} + +.task-input { + flex: 1; + font-size: var(--font-sm); + padding: 5px 0; + min-width: 0; + letter-spacing: -0.003em; +} + +/* 输入框右侧的「提交」按钮(常驻可见:无文本或非法分类时禁用) */ +.task-submit-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: var(--radius-md); + background: var(--color-accent); + color: #ffffff; + flex-shrink: 0; + box-shadow: var(--shadow-accent); + transition: background 0.15s var(--ease-soft), + transform 0.1s var(--ease-soft), + box-shadow 0.2s var(--ease-soft), + opacity 0.15s var(--ease-soft); +} +.task-submit-btn:hover { + background: var(--color-accent-hover); + transform: translateY(-0.5px) scale(1.04); + box-shadow: 0 6px 18px var(--color-accent-glow), 0 1px 2px var(--color-accent-glow); +} +.task-submit-btn:active { + transform: scale(0.94); +} +.task-submit-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + box-shadow: none; +} + +.task-list { + flex: 1; + list-style: none; + overflow-y: auto; + overflow-x: hidden; + padding: 4px 0; + min-height: 0; + scroll-padding-block: var(--space-2); +} + +/* 任务列表为空时折叠,把整块中间空间让给 .empty-state, + 让它的 justify-content: center 落在 header 和输入框之间的正中。 + task-list.js 在空态会执行 listEl.innerHTML = '' —— 这会清掉注释, + 所以 :empty 能稳定命中。 */ +.task-list:empty { + display: none; +} + +/* ============================================ + 任务项 + - 使用 absolute 伪元素绘制 hover 背景以实现"贴边" + - 任务项本身只占文本/控件所需宽度 + ============================================ */ + +.task-item { + position: relative; + display: flex; + align-items: center; + gap: 12px; + padding: 8px 13px; + cursor: grab; + /* 触屏上禁止浏览器默认的滚动/缩放/双击放大 —— 让整行的 pointer events + 全部交给我们自己的拖拽/点击判定,否则手指一滑就被认作滚动而错过拖拽。 + 列表滚动靠空白处 / 滚动条触发。 */ + touch-action: none; + transition: opacity 0.25s var(--ease-out), + transform 0.18s var(--ease-soft); + /* 无条件 animation 已移除:每次 render() 重建 innerHTML 都会重放动画, + 引起整个列表闪烁;改为只在 .task-item.is-new 上挂 fade-in —— 见 task-list.js */ + border-radius: 8px; + margin: 0 8px; +} + +/* 仅新增的任务(构造函数里 _knownTaskIds 没见过的 id)才挂 fade-in; + 已有任务不会闪,只走 transition 平滑过渡 */ +.task-item.is-new { + animation: task-fade-in 0.2s var(--ease-out); +} + +.task-item::before { + content: ""; + position: absolute; + inset: 0; + background: transparent; + border-radius: 8px; + border: 1px solid transparent; + transition: background 0.18s var(--ease-soft), + border-color 0.18s var(--ease-soft), + box-shadow 0.2s var(--ease-soft); + pointer-events: none; + z-index: 0; +} + +.task-item:hover::before { + background: var(--color-bg-tertiary); +} + +.task-item.selected::before { + background: var(--color-accent-bg-strong); + border-color: var(--color-accent); + box-shadow: 0 0 0 4px var(--color-accent-bg); +} + +/* 已完成任务:用「文本降级 + line-through」传达完成语义即可, + 不再叠整行 opacity —— 否则配合 .task-text 的 muted 色,文字会糊到看不清。 + 跟用户定下的「灰度处理也要清晰可读」原则对齐。 */ +.task-item.completed { + /* 整行留一点点 alpha,仅用来压一压图标/按钮等周边元素 */ + opacity: 0.92; +} + +/* 子元素提升到伪元素之上 */ +.task-item > * { + position: relative; + z-index: 1; +} + +/* 复选框:增大点击区 */ +.task-checkbox-wrapper { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + width: 24px; + height: 24px; + border-radius: 7px; + /* 点击时给整个点击区一个轻微的弹性反馈 —— 比单靠 checkbox 自带的 + check-pop 更完整地传达「打勾了」。 */ + transition: background 0.15s var(--ease-soft), + transform 0.12s var(--ease-spring); +} + +.task-checkbox-wrapper:active { + transform: scale(0.88); +} + +.task-checkbox-wrapper:hover { + background: var(--color-bg-tertiary); +} + +.task-checkbox { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.task-checkbox-custom { + display: inline-flex; + align-items: center; + justify-content: center; + width: 19px; + height: 19px; + border: 1.6px solid var(--color-text-faint); + border-radius: 6px; + background: transparent; + transition: background 0.2s var(--ease-soft), + border-color 0.2s var(--ease-soft), + transform 0.18s var(--ease-spring), + box-shadow 0.2s var(--ease-soft); + position: relative; +} + +.task-checkbox:checked + .task-checkbox-custom { + background: var(--color-accent); + border-color: var(--color-accent); + box-shadow: 0 2px 6px var(--color-accent-glow); +} + +.task-checkbox:checked + .task-checkbox-custom::after { + content: ""; + position: absolute; + left: 5.5px; + top: 1px; + width: 5.5px; + height: 11px; + border-right: 2px solid white; + border-bottom: 2px solid white; + transform: rotate(45deg); + animation: check-pop 0.22s var(--ease-spring); +} + +.task-item:hover .task-checkbox-custom { + border-color: var(--color-text-muted); +} + +/* 任务文本 */ +.task-text { + flex: 1; + font-size: var(--font-sm); + font-weight: 400; + color: var(--color-text); + user-select: none; + word-break: break-word; + line-height: 1.55; + min-width: 0; + padding: 0; + letter-spacing: -0.005em; + /* 完成 ↔ 未完成的文字色和删除线颜色平滑过渡,避免硬切。 + text-decoration-color 自 2022 起在主流浏览器均可过渡。 */ + transition: color 0.2s var(--ease-soft), + text-decoration-color 0.2s var(--ease-soft); +} + +/* 中西文之间的间隙元素(由 escapeHtmlAutospace 注入的空 span)。 + Electron 28 = Chromium 120,还没有 CSS text-autospace,只能在渲染时插标签。 + + 用 padding 而不是 width/margin:已完成任务的 .task-text 带 line-through, + 祖先的 text-decoration 不会画过 inline-block 这类原子行内盒,也不画在 margin + 区域上 —— 那样删除线会在每个间隙处断开。padding 区域会被画到,删除线保持连续。 + + 0.16em ≈ 思源/苹方在正文字号下的四分之一空格,比真空格窄,只做「不挤」的程度。 */ +.cjk-gap { + padding-right: 0.16em; +} + +.task-text mark { + /* 搜索命中:accent 浅底 + 默认字色 —— 仅起到"这里被命中"的视觉提示, + 不再使用 warning 配色,避免和「重要任务」「未保存」等真实警告混在一起 */ + background: var(--color-accent-bg); + color: inherit; + padding: 0 3px; + border-radius: 2px; +} + +/* 任务内容容器(文本 + 元数据行) */ +.task-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +/* 任务「原分类」徽章:仅在已完成任务 / 回收站视图中显示。 + 对应 markdown 文件里的 [原分类:xxx] 标记,让用户在界面上 + 一眼看到这条任务来自哪里。胶囊样式,弱化视觉权重, + 关键 —— 不继承 .task-text 的 line-through(语义上是元数据而非任务内容)。 */ +.task-original-category { + display: inline-flex; + align-items: center; + align-self: flex-start; + max-width: 100%; + font-size: var(--font-xs); + font-weight: 500; + color: var(--color-text-tertiary); + background: var(--color-bg-tertiary); + border: 1px solid var(--color-border-muted); + border-radius: var(--radius-sm); + padding: 1px 7px; + line-height: 1.55; + letter-spacing: 0.01em; + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + user-select: none; +} + +/* hover 时略微提亮,便于发现这是可识别的元数据 */ +.task-item:hover .task-original-category { + color: var(--color-text-secondary); + border-color: var(--color-border); +} + +.task-item.completed .task-text { + /* 从 --color-text-muted 升级到 --color-text-secondary —— 配合整行 opacity 0.92 + 后整体仍是「偏淡但可扫视」的层级,不再像之前那样糊在一起。 + line-through + 略弱字色已经足够区分「已完成」语义。 */ + color: var(--color-text-secondary); + text-decoration: line-through; + text-decoration-color: var(--color-text-tertiary); + text-decoration-thickness: 1px; +} + +/* 编辑模式 */ +.task-edit-input { + flex: 1; + font-size: var(--font-sm); + padding: 6px var(--space-2); + background: var(--color-bg-elevated); + border: 1px solid var(--color-accent); + border-radius: var(--radius-sm); + box-shadow: 0 0 0 3px var(--color-accent-bg); + margin: -2px 0; + min-width: 0; + color: var(--color-text); + z-index: 2; + letter-spacing: -0.003em; +} + +/* 重要标记 ⭐ 按钮 */ +.task-star-btn { + width: 26px; + height: 26px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-faint); + flex-shrink: 0; + transition: background 0.15s, color 0.15s, transform 0.1s var(--ease-spring); + border: none; + padding: 0; + cursor: pointer; +} + +.task-star-btn:hover { + background: var(--color-bg-tertiary); + color: var(--color-warning); +} + +.task-item.important .task-star-btn { + color: var(--color-warning); +} + +.task-star-btn:active { + transform: scale(0.85); +} + +.task-star-btn svg { + width: 15px; + height: 15px; +} + +.task-item.completed.important .task-star-btn { + opacity: 0.55; +} + +/* 「当前」标记按钮:与 ⭐ 同尺寸,激活时蓝色,与星标的琥珀色区分 */ +.task-current-btn { + width: 26px; + height: 26px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-faint); + flex-shrink: 0; + transition: background 0.15s, color 0.15s, transform 0.1s var(--ease-spring); + border: none; + padding: 0; + cursor: pointer; +} + +.task-current-btn:hover { + background: var(--color-bg-tertiary); + color: var(--color-current); +} + +.task-item.current .task-current-btn { + color: var(--color-current); +} + +.task-current-btn:active { + transform: scale(0.85); +} + +.task-current-btn svg { + width: 15px; + height: 15px; +} + +.task-item.completed.current .task-current-btn { + opacity: 0.55; +} + +/* 任务操作按钮:默认弱化可见,hover/选中时强调 */ +.task-actions { + display: flex; + align-items: center; + gap: 2px; + opacity: 0.45; + transition: opacity 0.15s; + flex-shrink: 0; +} + +.task-item:hover .task-actions, +.task-item.selected .task-actions, +.task-item:focus-within .task-actions { + opacity: 1; +} + +/* 键盘聚焦:让 :focus-visible 在行级别也有一圈清晰轮廓 —— 只在键盘 tab 时出现, + 不打扰鼠标用户。复用了 hover 的 ::before 渐变底,再叠 2px accent ring。 */ +.task-item:focus-visible { + outline: none; +} +.task-item:focus-visible::before { + background: var(--color-bg-tertiary); + border-color: var(--color-accent); + box-shadow: 0 0 0 2px var(--color-accent-bg-strong); +} + +.task-action-btn { + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 5px; + font-size: var(--font-sm); + color: var(--color-text-muted); + cursor: pointer; + transition: background 0.15s var(--ease-soft), + color 0.15s var(--ease-soft), + transform 0.08s var(--ease-soft); +} + +.task-action-btn:hover { + background: var(--color-bg-tertiary); + color: var(--color-text); +} + +.task-action-btn:active { + transform: scale(0.88); +} + +.task-action-delete:hover { + background: var(--color-danger-bg); + color: var(--color-danger); +} + +.task-action-restore:hover { + background: var(--color-success-bg); + color: var(--color-success); +} + +.task-action-btn svg { + width: 12px; + height: 12px; + fill: currentColor; +} + +/* 拖拽:旧的 .task-drag-handle 元素已移除 —— 整行本身就是拖拽区, + cursor: grab 写在 .task-item 上,激活拖拽时切到 grabbing */ + +/* 拖拽进行中:源项降透明 + grabbing 光标,提示「这里正在被拖」 */ +.task-item.drag-source { + opacity: 0.35; + cursor: grabbing; +} + +/* ghost:跟随光标的克隆项 */ +.drag-ghost { + position: fixed; + top: 0; + left: 0; + pointer-events: none; + z-index: 9999; + opacity: 0.92; + border-radius: var(--radius-md); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.32), + 0 2px 6px rgba(0, 0, 0, 0.18); + /* 旋转一点,更直观地表达「抓起来」 */ + transform-origin: top left; + will-change: transform; +} + +/* 落点指示线:2px 高亮横线,定位 fixed */ +.drag-indicator { + position: fixed; + height: 2px; + background: var(--color-accent); + border-radius: 2px; + pointer-events: none; + z-index: 9998; + box-shadow: 0 0 6px var(--color-accent); + transition: opacity 0.1s; +} + +/* 空状态 */ +.empty-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-7) var(--space-5); + color: var(--color-text-muted); + text-align: center; + gap: 11px; + min-height: 0; + animation: fade-in 0.3s var(--ease-out); +} + +/* 关键:作者 display:flex 胜过 UA 的 [hidden]{display:none} —— 显式覆盖。 + 之前 task-list.js 设置 emptyEl.hidden = true 是个 no-op,导致「暂无任务」一直占着 + 任务面板、并且残留文本浮在非空列表上方。 */ +.empty-state[hidden] { + display: none; +} + +.empty-icon { + margin-bottom: var(--space-2); + color: var(--color-text-faint); + display: flex; + align-items: center; + justify-content: center; + opacity: 0.65; + width: 68px; + height: 68px; + /* 取消边框 + 卡片背景,让空白更轻盈 */ + border-radius: 18px; + background: linear-gradient( + 135deg, + var(--color-bg-tertiary), + var(--color-bg-secondary) + ); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +.empty-title { + /* 空状态标题 —— font-md (15.5px) 已足够让它在空场景里突出, + 不必上到 font-lg (17.5px) 抢主区域视觉 */ + font-size: var(--font-md); + color: var(--color-text); + font-weight: 600; + letter-spacing: -0.015em; +} + +.empty-hint { + font-size: var(--font-sm); + margin-top: 2px; + color: var(--color-text-secondary); + max-width: 320px; + line-height: 1.65; +} + +.empty-hint kbd { + display: inline-block; + padding: 1px 6px; + background: var(--color-bg-tertiary); + border: 1px solid var(--color-border); + border-bottom-width: 2px; + border-radius: var(--radius-xs); + font-family: var(--font-mono); + font-size: var(--font-xs); + color: var(--color-text); + margin: 0 2px; +} + +/* 空状态里的行动按钮(如「清空搜索」)—— 文字按钮风格, + 不要和普通 hint 文字混在一起,不然用户找不到交互入口 */ +.empty-hint b { + color: var(--color-text-secondary); + font-weight: 500; +} +.empty-action-btn { + display: inline-block; + margin-top: 6px; + padding: 5px 13px; + background: var(--color-bg-tertiary); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--font-sm); + color: var(--color-text); + font-weight: 500; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} +.empty-action-btn:hover { + background: var(--color-bg-elevated); + border-color: var(--color-border-strong); + color: var(--color-text); +} + +/* ============================================ + 源码面板 / 详情面板 已整体移除 + - 每个任务只有三个字段:文本 / 勾选状态 / 重要 + - 详见 README 极简哲学 + ============================================ */ + +/* 智能列表项 - 微妙的图标着色 */ +.category-item.smart-list .category-icon { + color: var(--color-accent-hover); +} + +/* ============================================ + 命令面板 (Cmd+K) + ============================================ */ +.command-palette-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(12px) saturate(160%); + -webkit-backdrop-filter: blur(12px) saturate(160%); + display: flex; + align-items: flex-start; + justify-content: center; + z-index: var(--z-modal); + padding-top: 12vh; + animation: fade-in 0.2s var(--ease-soft); +} + +.command-palette { + width: 600px; + max-width: 92vw; + background: var(--color-bg-elevated); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-lg); + overflow: hidden; + animation: palette-in 0.26s var(--ease-out); + display: flex; + flex-direction: column; + max-height: 60vh; +} + +@keyframes palette-in { + from { + opacity: 0; + transform: translateY(-10px) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.command-palette-input-row { + display: flex; + align-items: center; + gap: 13px; + padding: 16px 19px; + border-bottom: 1px solid var(--color-border); +} + +.command-palette-input { + flex: 1; + font-size: var(--font-md-sm); + color: var(--color-text); + outline: none; + background: transparent; + border: none; + font-family: inherit; +} + +.command-palette-list { + flex: 1; + overflow-y: auto; + padding: 6px; + list-style: none; +} + +.command-palette-item { + display: flex; + align-items: center; + gap: 13px; + padding: 10px 13px; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: var(--font-sm); + color: var(--color-text); + transition: background 0.1s, color 0.1s; + letter-spacing: -0.005em; + font-weight: 500; +} + +.command-palette-item:hover, +.command-palette-item.active { + background: var(--color-accent-bg); + color: var(--color-accent-hover); +} + +.command-palette-item-icon { + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-muted); + flex-shrink: 0; +} + +.command-palette-item-label { + flex: 1; + font-weight: 500; +} + +.command-palette-item-hint { + color: var(--color-text-muted); + font-size: var(--font-xs); + font-family: var(--font-mono); +} + +.command-palette-empty { + padding: var(--space-6) var(--space-4); + text-align: center; + color: var(--color-text-secondary); + font-size: var(--font-sm); +} + +/* ============================================ + 状态栏 + ============================================ */ + +.statusbar { + display: flex; + align-items: center; + padding: 0 16px; + background: var(--color-bg-overlay); + /* 与工具栏对称的玻璃效果 — 状态栏也淡淡透出 ambient */ + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border-top: 1px solid var(--color-border); + /* 状态栏整体走 font-xs —— 底部状态栏是「辅助信息」层级, + 与系统托盘类似不需要大字;放大会挤压 30px 高度的视觉空间 */ + font-size: var(--font-xs); + color: var(--color-text-secondary); + gap: var(--space-3); + min-width: 0; + -webkit-app-region: no-drag; + height: var(--h-statusbar); + position: relative; +} + +/* 状态栏顶部细高光,呼应工具栏底部的设计 */ +.statusbar::before { + content: ""; + position: absolute; + left: 0; + right: 0; + top: -1px; + height: 1px; + background: linear-gradient( + to right, + transparent, + rgba(255, 255, 255, 0.06) 50%, + transparent + ); + pointer-events: none; +} +[data-theme="light"] .statusbar::before { + background: linear-gradient( + to right, + transparent, + rgba(255, 255, 255, 0.6) 50%, + transparent + ); +} + +.status-text { + /* 状态栏主文本 —— 略大于 meta 但仍走紧凑档(font-xs), + 与整体 30px 高度协调,不会撑高视觉重量 */ + font-size: var(--font-xs); + font-weight: 500; + color: var(--color-text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex-shrink: 1; + letter-spacing: -0.008em; + /* 与 file chip / meta 的层级区分 */ + font-variant-numeric: tabular-nums; +} + +.status-spacer { + flex: 1; + min-width: var(--space-2); +} + +/* 状态栏右侧区块:保存时间 + 进度 */ +.statusbar-right { + display: inline-flex; + align-items: center; + gap: var(--space-3); + flex-shrink: 0; +} + +.status-meta { + color: var(--color-text-secondary); + font-variant-numeric: tabular-nums; + white-space: nowrap; + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 6px; + letter-spacing: -0.005em; + font-weight: 500; + /* 与状态栏整体保持紧凑 —— 「已保存 5 秒前」是辅助元数据 */ + font-size: var(--font-xs); +} + +.status-meta.dirty { + color: var(--color-warning); +} + +.status-meta.saved { + color: var(--color-success); +} + +/* 状态指示灯(圆点) */ +.status-meta::before { + content: ""; + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; + /* 微微的扩散光,让指示灯看起来在「发光」 */ + box-shadow: 0 0 7px currentColor; +} + +.status-meta.saved::before { + background: var(--color-success); + color: var(--color-success); +} + +/* 无保存状态文本时(启动后尚未发生过保存)不显示孤立的圆点 */ +.status-meta:empty::before { + display: none; +} + +/* 文件监视状态指示(永远显示,证明同步机制在工作,缓解用户的"软件到底有没有响应"的疑虑) */ +.watch-indicator { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + /* 用主题 success 色 + 微微的光晕 —— 与 .status-meta.saved::before 同款 + 视觉重量,但放在最左侧,被 file chip 的视觉重量自然推开 */ + background: var(--color-success); + color: var(--color-success); + box-shadow: 0 0 7px currentColor; + flex-shrink: 0; + cursor: help; + /* 缓慢呼吸动画 —— 1.6s 一个周期,颜色变浅一下再回来,提示"它在持续盯着文件" */ + animation: watch-pulse 1.6s ease-in-out infinite; + user-select: none; +} + +@keyframes watch-pulse { + 0%, 100% { + opacity: 1; + box-shadow: 0 0 7px currentColor; + } + 50% { + opacity: 0.55; + box-shadow: 0 0 3px currentColor; + } +} + +/* ============================================ + 右键菜单 + ============================================ */ + +.context-menu { + position: fixed; + z-index: var(--z-menu); + min-width: 188px; + max-width: min(340px, calc(100vw - 16px)); + /* 用户可能建了十几个分类,「移到分类…」一展开就能塞满半个屏幕。 + 这里限制到视口 60%,剩下的滚动可见 —— 比 slice(0, 6) 静默截掉更好, + 后者让用户根本看不到后半段的分类,以为功能不存在。 */ + max-height: 60vh; + overflow-y: auto; + overscroll-behavior: contain; + background: var(--color-bg-elevated); + border: 1px solid var(--color-border-strong); + border-radius: 10px; + box-shadow: var(--shadow-lg); + padding: 5px; + font-size: var(--font-sm); + animation: menu-in 0.18s var(--ease-out); + backdrop-filter: blur(16px) saturate(160%); + -webkit-backdrop-filter: blur(16px) saturate(160%); + /* 始终可见的细滚动条 —— Windows/Chromium 默认 overlay 样式只在悬停时显, + 用户看到菜单被截就以为没内容,不知道能滚。这里强制 always-visible 提示。 */ + scrollbar-width: thin; + scrollbar-color: rgba(128, 128, 128, 0.35) transparent; +} +/* 暗色主题:菜单背景偏深 (--color-bg-elevated=#17171a) 与 task-panel 主区 + (#0a0a0c) 仅相差 13 阶亮度,backdrop-filter 还会进一步"吃掉"对比; + 强制叠一层 12% 半透明黑覆盖,让菜单与背景拉开层级 —— 与 toast 浅色处理同款思路 */ +[data-theme="dark"] .context-menu { + background: color-mix(in srgb, var(--color-bg-elevated) 92%, #000); + border-color: color-mix(in srgb, var(--color-border-strong) 100%, transparent); +} +.context-menu::-webkit-scrollbar { + width: 8px; +} +.context-menu::-webkit-scrollbar-track { + background: transparent; +} +.context-menu::-webkit-scrollbar-thumb { + background: rgba(128, 128, 128, 0.3); + border-radius: 4px; +} +.context-menu::-webkit-scrollbar-thumb:hover { + background: rgba(128, 128, 128, 0.5); +} + +@keyframes menu-in { + from { + opacity: 0; + transform: translateY(-4px) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.context-menu-item { + padding: 8px 11px; + border-radius: var(--radius-sm); + cursor: pointer; + color: var(--color-text); + display: flex; + align-items: center; + gap: 9px; + transition: background 0.12s var(--ease-soft), + color 0.12s var(--ease-soft); + letter-spacing: -0.005em; + font-weight: 500; +} + +.context-menu-item:hover { + background: var(--color-accent-bg); + color: var(--color-accent-hover); +} + +.context-menu-item.context-menu-danger:hover { + background: var(--color-danger-bg); + color: var(--color-danger); +} + +.context-menu-separator { + height: 1px; + background: var(--color-border); + margin: 5px 0; +} + +.context-menu-item.context-menu-indent { + padding-left: 28px; + color: var(--color-text-secondary); +} + +/* ============================================ + Toast 通知 + ============================================ */ + +.toast-container { + position: fixed; + /* statusbar (--h-statusbar) + input wrapper (~52px) + 10px gap = 92px. + 之前是 52px —— 与右下角的「+ 提交」按钮(位于输入框 wrapper 内)垂直重叠, + toast 一出现就盖住 + 按钮,用户既看不到按钮也没法避开误点。 */ + bottom: calc(var(--h-statusbar) + 62px); + right: 22px; + display: flex; + flex-direction: column; + gap: 8px; + z-index: var(--z-toast); + pointer-events: none; + /* 视口太低时(如重排到 200px)允许 toast 顶部贴近屏幕顶部, + 但仍尽量维持在主操作区上方(.. over input wrapper) */ + max-height: calc(100vh - var(--h-statusbar) - 72px); + overflow-y: auto; +} + +.toast { + display: flex; + align-items: center; + gap: 11px; + padding: 12px 17px; + background: var(--color-bg-elevated); + border: 1px solid var(--color-border-strong); + border-radius: 10px; + box-shadow: var(--shadow-lg); + font-size: var(--font-sm); + color: var(--color-text); + animation: toast-in 0.32s var(--ease-out); + pointer-events: auto; + min-width: 240px; + max-width: 400px; + letter-spacing: -0.005em; + font-weight: 500; + /* Toast 整体上提一点不透明度阈值,避免轻主题下「白底白文字」模糊感。 + 内部 backdrop-filter 仍保留玻璃质感(柔和、不过分实色),但叠一层极薄的同色覆盖, + 让"文字色 vs 容器背景色"对比度稳定够用 —— 用户反馈"白底几乎看不到字"是这里没做。 */ + backdrop-filter: blur(12px) saturate(160%); + -webkit-backdrop-filter: blur(12px) saturate(160%); +} +[data-theme="light"] .toast { + /* 浅色 toast 背景用接近纯白(--color-bg-secondary=#ffffff)+ 强一点阴影与边框, + 让「已加载:…」在 #f7f7f9 主背景上能撑出清晰的边界, + 不至于和工具栏 / 状态栏「白成一片」。 */ + background: var(--color-bg-secondary); + border-color: var(--color-border-strong); + box-shadow: 0 4px 16px rgba(15, 23, 42, 0.10), + 0 1px 3px rgba(15, 23, 42, 0.06), + 0 24px 48px -8px rgba(15, 23, 42, 0.16); +} + +.toast.toast-success { + border-left: 3px solid var(--color-success); +} + +.toast.toast-error { + border-left: 3px solid var(--color-danger); +} + +.toast.toast-warning { + border-left: 3px solid var(--color-warning); +} + +/* 可操作的 toast(带按钮) */ +.toast.toast-actionable { + padding: 8px 8px 8px 16px; +} + +.toast .toast-text { + flex: 1; +} + +.toast .toast-action { + background: transparent; + border: 1px solid var(--color-accent); + color: var(--color-accent); + padding: 4px 10px; + border-radius: var(--radius-sm); + font-size: var(--font-sm); + font-weight: 500; + cursor: pointer; + transition: background 0.15s var(--ease-soft); +} + +.toast .toast-action:hover { + background: var(--color-accent-bg); +} + +.toast.toast-info { + border-left: 3px solid var(--color-accent); +} + +@keyframes toast-in { + from { + transform: translateY(8px) scale(0.97); + opacity: 0; + } + to { + transform: translateY(0) scale(1); + opacity: 1; + } +} + +@keyframes toast-out { + to { + transform: translateY(4px) scale(0.97); + opacity: 0; + } +} + +/* ============================================ + 滚动条 + ============================================ */ + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb, rgba(255, 255, 255, 0.08)); + border-radius: 5px; + border: 2.5px solid transparent; + background-clip: padding-box; + transition: background 0.18s; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover, rgba(255, 255, 255, 0.18)); + background-clip: padding-box; + border: 2.5px solid transparent; +} + +::-webkit-scrollbar-corner { + background: transparent; +} + +/* 暗色主题下滚动条更醒目一点 —— 默认 0.10 太弱,看不到自己在哪 */ +[data-theme="dark"] ::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb, rgba(255, 255, 255, 0.13)); +} +[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover, rgba(255, 255, 255, 0.25)); +} + +/* Firefox 滚动条 */ +* { + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb, rgba(255, 255, 255, 0.08)) transparent; +} + +/* ============================================ + 模态对话框 + ============================================ */ + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(8px) saturate(140%); + -webkit-backdrop-filter: blur(8px) saturate(140%); + display: flex; + align-items: center; + justify-content: center; + z-index: var(--z-modal); + animation: fade-in 0.2s var(--ease-soft); +} + +.modal { + background: var(--color-bg-elevated); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-lg); + /* 输入类弹窗(inputDialog)只需容纳一两个汉字的输入框, + confirmDialog 的多行 message 也不会因宽度变窄而拥挤(自动换行即可) */ + min-width: 340px; + max-width: 500px; + overflow: hidden; + animation: modal-in 0.28s var(--ease-out); +} + +@keyframes modal-in { + from { + opacity: 0; + transform: translateY(8px) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* 退出动画 —— 模态/命令面板关掉时先缩放淡出再 remove, + 比同步 remove 多 ~180ms 缓冲,视觉上"主动关掉"而非"硬切"。 + 通过 .is-closing 类触发;JS 在 animationend 后再真正销毁节点 */ +@keyframes modal-out { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + to { + opacity: 0; + transform: translateY(4px) scale(0.97); + } +} + +@keyframes palette-out { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + to { + opacity: 0; + transform: translateY(-6px) scale(0.97); + } +} + +.modal.is-closing { + animation: modal-out 0.18s var(--ease-soft) forwards; +} + +.command-palette.is-closing { + animation: palette-out 0.18s var(--ease-soft) forwards; +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.modal-header { + /* 上下 16px:保留「标题独立成块」的呼吸感,又比 18px 更紧凑 */ + padding: 16px 22px; + border-bottom: 1px solid var(--color-border); + font-size: var(--font-md); + font-weight: 600; + letter-spacing: -0.015em; +} + +.modal-body { + /* 上下 16px:仅一个输入框时上下空白 = 16+10(input padding) = 26px, + 比之前 14+8 = 22px 略松但更舒适;多行 message 也仍然呼吸良好 */ + padding: 16px 22px; + font-size: var(--font-base); + line-height: 1.6; + color: var(--color-text-secondary); + white-space: pre-wrap; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + /* 上下 12px:按钮本来就自带视觉重量,再叠 14px 显得「压脚」 */ + padding: 12px 22px; + border-top: 1px solid var(--color-border); + background: var(--color-bg-secondary); +} + +/* 输入对话框专属样式(替代 inline style) */ + +/* 紧凑版 modal:用于 inputDialog(新建/重命名子分类、新建列表)。 + 相比通用 .modal: + - 去掉了 header/body 之间的 border-bottom,让标题与输入框视觉融合成一个色块 + - 整体 padding 收紧,输入框上下空白从 ~22px 减到 ~12px + - 弹窗高度从 ~178px 减到 ~134px,视觉重心更贴近输入区 */ +.input-dialog-modal { + min-width: 300px; + max-width: 420px; +} + +.input-dialog-title { + padding: 12px 19px 5px; + font-size: var(--font-md); + font-weight: 600; + letter-spacing: -0.015em; + color: var(--color-text); +} + +.input-dialog-body { + padding: 5px 19px 12px; +} + +.input-dialog-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 10px 16px; + border-top: 1px solid var(--color-border); + background: var(--color-bg-secondary); +} + +.input-dialog-message { + /* 7px:标题与 input 视觉融合后,message 作为「补充说明」只需要与 input 清晰分隔 */ + margin-bottom: 7px; + color: var(--color-text-secondary); + font-size: var(--font-base); + line-height: 1.5; +} + +.input-dialog-input { + width: 100%; + padding: 9px 13px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg); + color: var(--color-text); + font-family: inherit; + font-size: var(--font-base); + letter-spacing: -0.005em; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.input-dialog-input:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px var(--color-accent-bg); +} + +.input-dialog-input::placeholder { + color: var(--color-text-muted); +} + +/* 校验失败:输入框变红 + 内联错误提示 */ +.input-dialog-input.input-invalid { + border-color: var(--color-danger); +} + +.input-dialog-input.input-invalid:focus { + border-color: var(--color-danger); + box-shadow: 0 0 0 3px var(--color-danger-bg); +} + +.input-dialog-error { + /* 6px:紧贴输入框,校验失败时一眼可见,又不把弹窗撑高很多 */ + margin-top: 6px; + color: var(--color-danger); + font-size: var(--font-sm); + line-height: 1.4; +} + +/* ============================================ + 内容导入对话框(import-dialog.js) + 复用 .modal-overlay / .modal 容器,自定义 textarea + 选项区 + ============================================ */ +.import-dialog-modal { + /* 比 input-dialog 更宽:textarea 内容需要更多水平空间 */ + min-width: 460px; + max-width: 600px; +} + +.import-dialog-title { + padding: 14px 18px 4px; + /* 14.5px:与其他 inline-task 字保持视觉量级,避免一开弹窗「字变粗变大」的违和感 */ + font-size: var(--font-base); + font-weight: 600; + letter-spacing: -0.01em; + color: var(--color-text); +} + +.import-dialog-body { + padding: 4px 18px 12px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.import-dialog-hint { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--font-sm); + line-height: 1.55; +} + +.import-dialog-hint code { + background: var(--color-bg-tertiary); + padding: 1px 5px; + border-radius: var(--radius-xs); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.92em; + color: var(--color-text); +} + +.import-dialog-textarea { + width: 100%; + min-height: 180px; + max-height: 360px; + padding: 11px 13px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg); + color: var(--color-text); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: var(--font-sm); + line-height: 1.55; + letter-spacing: -0.005em; + resize: vertical; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.import-dialog-textarea:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px var(--color-accent-bg); +} + +.import-dialog-textarea::placeholder { + color: var(--color-text-muted); + white-space: pre; + /* placeholder 内容用换行展示示例 —— 浏览器原生 white-space: pre 让 \n 生效 */ +} + +.import-dialog-options { + display: flex; + flex-direction: column; + gap: 8px; +} + +.import-dialog-option { + display: flex; + align-items: center; + gap: 8px; + font-size: var(--font-sm); + color: var(--color-text-secondary); + cursor: pointer; + user-select: none; +} + +.import-dialog-option input[type="checkbox"] { + margin: 0; + cursor: pointer; +} + +.import-dialog-option code { + background: var(--color-bg-tertiary); + padding: 1px 5px; + border-radius: var(--radius-xs); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.92em; + color: var(--color-text); +} + +.import-dialog-target { + /* 让 select 不会在长选项名时被压缩 */ + gap: 6px; +} + +.import-dialog-select { + flex: 1; + padding: 6px 10px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg); + color: var(--color-text); + font-family: inherit; + font-size: var(--font-sm); + cursor: pointer; +} + +.import-dialog-select:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px var(--color-accent-bg); +} + +/* 排序方式 segmented —— 与主设置面板的 settings-segmented / seg-btn 同款视觉, + 但用 import-dialog-* 命名空间隔离开,避免后续主设置样式变更牵连弹窗。 */ +.import-dialog-order { + flex-wrap: wrap; + gap: 6px; +} + +.import-dialog-order-label { + flex-shrink: 0; +} + +.import-dialog-segmented { + display: inline-flex; + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 2px; + gap: 0; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.04); + flex-wrap: wrap; + /* 选项可能因翻译 / 字号在窄窗口换行,flex-wrap 让 segmented 视觉风格不变 */ +} + +.import-dialog-seg-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + background: transparent; + border: none; + border-radius: 6px; + font-size: var(--font-sm); + color: var(--color-text-secondary); + cursor: pointer; + font-weight: 500; + font-family: inherit; + transition: background 0.15s var(--ease-soft), + color 0.15s var(--ease-soft), + box-shadow 0.2s var(--ease-soft), + transform 0.08s var(--ease-soft); +} + +.import-dialog-seg-btn:hover { + color: var(--color-text); +} + +.import-dialog-seg-btn:active { + transform: scale(0.96); +} + +.import-dialog-seg-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 1px var(--color-accent), 0 0 0 3px var(--color-accent-bg); +} + +.import-dialog-seg-btn.active { + background: var(--color-accent-bg); + color: var(--color-accent-hover); + font-weight: 600; + box-shadow: + var(--shadow-sm), + 0 0 0 1px var(--color-accent-bg-strong); +} + +.import-dialog-count { + font-size: var(--font-sm); + color: var(--color-text-secondary); + padding: 6px 10px; + background: var(--color-bg-tertiary); + border-radius: var(--radius-sm); + border: 1px solid var(--color-border); + /* 与 input-dialog-error 等高时的呼吸感 */ +} + +.import-dialog-count-empty { + color: var(--color-text-muted); +} + +.import-dialog-count-error { + color: var(--color-danger); + border-color: var(--color-danger); +} + +.import-dialog-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 8px 14px; + border-top: 1px solid var(--color-border); + background: var(--color-bg-secondary); +} + +/* 通用按钮禁用态(inputDialog 在空值/校验失败时使用)。 + hover 时不要套用主按钮色:普通 .btn 的 base 是 transparent / text-secondary */ +.btn:disabled, +.btn-primary:disabled { + cursor: not-allowed; + opacity: 0.45; + box-shadow: none; +} + +.btn:disabled:hover { + background: transparent; + color: var(--color-text-secondary); + box-shadow: none; +} + +.btn-primary:disabled:hover { + background: var(--color-accent); + color: #ffffff; + box-shadow: none; +} + +/* ============================================ + 设置对话框 + ============================================ */ + +.settings-overlay .modal.settings-modal { + width: 560px; + max-width: 92vw; + background: var(--color-bg-elevated); +} + +.settings-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 22px; + border-bottom: 1px solid var(--color-border); + font-size: var(--font-md); +} + +.settings-header-left { + display: flex; + align-items: center; + gap: 11px; + color: var(--color-text); + font-weight: 600; + letter-spacing: -0.015em; +} + +.settings-close { + color: var(--color-text-muted); +} + +.settings-close:hover { + background: var(--color-bg-tertiary); + color: var(--color-text); +} + +.settings-body { + max-height: 65vh; + overflow-y: auto; + padding: 0; + white-space: normal; /* 覆盖 .modal-body 的 pre-wrap,避免缩进空白撑高布局 */ +} + +.settings-section { + padding: 24px 24px; + border-bottom: 1px solid var(--color-border); +} + +.settings-section:last-child { + border-bottom: none; +} + +.settings-section-header { + margin-bottom: var(--space-4); +} + +.settings-section-header:not(:has(.settings-section-desc)) { + margin-bottom: var(--space-2); +} + +.settings-section-title { + /* 设置项的分组标题 —— 走 font-md-sm (14px),与正文 (font-sm=13px) 形成清晰但克制 + 的层级;font-md (15.5px) 留给真正的主标题(弹窗标题、页面标题) */ + font-size: var(--font-md-sm); + font-weight: 600; + color: var(--color-text); + margin-bottom: 4px; + letter-spacing: -0.012em; +} + +.settings-section-desc { + /* 描述文字是辅助信息,走 font-xs —— 比 hint 略轻,但比 hint code 稍重 */ + font-size: var(--font-xs); + color: var(--color-text-secondary); + line-height: 1.55; +} + +.settings-row { + margin-bottom: var(--space-3); +} + +.settings-row:last-child { + margin-bottom: 0; +} + +.settings-label { + display: flex; + align-items: center; + justify-content: space-between; + font-size: var(--font-sm); + color: var(--color-text-secondary); + margin-bottom: var(--space-2); + font-weight: 500; + letter-spacing: -0.005em; +} + +.settings-label-toggle { + cursor: pointer; + margin-bottom: var(--space-2); +} + +.settings-input-group { + display: flex; + gap: var(--space-2); + align-items: center; +} + +.settings-input { + flex: 1; + height: var(--h-control-sm); + padding: 0 var(--space-3); + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--font-sm); + font-family: var(--font-mono); + color: var(--color-text); + user-select: text; + min-width: 0; + letter-spacing: -0.003em; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.settings-input:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px var(--color-accent-bg); +} + +.settings-hint { + margin-top: 10px; + /* helper text 是辅助信息,走 font-xs —— 12px 在 65vh 的对话框里仍清晰, + 但不会与主设置项抢视觉重量 */ + font-size: var(--font-xs); + color: var(--color-text-secondary); + line-height: 1.6; + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} + +.settings-hint code { + font-family: var(--font-mono); + font-size: var(--font-xs); + padding: 2px 6px; + background: var(--color-bg-tertiary); + border-radius: var(--radius-xs); + color: var(--color-text-secondary); + user-select: text; + margin: 0 4px; + vertical-align: 1px; +} + +.settings-link { + background: none; + border: none; + color: var(--color-accent); + font-size: var(--font-xs); + cursor: pointer; + padding: 0; + text-decoration: none; + font-family: inherit; + margin-left: 6px; +} + +.settings-link:hover { + color: var(--color-accent-hover); + text-decoration: underline; +} + +/* 「恢复默认」点击后的瞬态反馈 —— 把按钮文本换成「✓ 已恢复」时同步换色, + 给用户「点了生效了」的视觉确认;不靠底部的 toast(用户视线在按钮上时容易错过)。 + 用 success 色调区分默认态:accent 蓝是「可点击」,success 绿是「刚点过」。 + 文本+样式都在 JS 里 setTimeout 1.6s 后还原,这里只管样式。 */ +.settings-link-applied { + color: var(--color-success); + cursor: default; + text-decoration: none; +} +.settings-link-applied:hover { + color: var(--color-success); + text-decoration: none; +} + +/* 分段控件(主题) */ +.settings-segmented { + display: inline-flex; + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 2px; + gap: 0; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.04); +} + +/* 配色风格 6 个按钮可能换行 —— 单独给一个变体允许换行,并保持 segmented 视觉风格 */ +.settings-segmented-color { + flex-wrap: wrap; + max-width: 100%; +} + +/* 配色风格 swatch:每个按钮前的小色点 —— 用 600 色阶(偏深), + 在浅色和深色 chip 背景上都能清晰可辨 */ +.color-swatch { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; + flex-shrink: 0; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12); +} + +.seg-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px var(--space-3); + background: transparent; + border: none; + border-radius: 6px; + font-size: var(--font-sm); + color: var(--color-text-secondary); + cursor: pointer; + font-weight: 500; + transition: background 0.15s var(--ease-soft), + color 0.15s var(--ease-soft), + box-shadow 0.2s var(--ease-soft), + transform 0.08s var(--ease-soft); + font-family: inherit; +} + +.seg-btn:hover { + color: var(--color-text); +} + +/* 按下微缩放 —— 与其它按钮 0.88~0.96 一致,键盘 Enter/Space 也走同一视觉 */ +.seg-btn:active { + transform: scale(0.96); +} + +/* 键盘焦点环 —— 默认 UA 轮廓在 segmented 上会和选中态阴影打架, + 显式换成 1px accent + 3px accent-bg 的复合 ring,颜色与选中态一致 */ +.seg-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 1px var(--color-accent), 0 0 0 3px var(--color-accent-bg); +} + +/* 选中 + 键盘聚焦:用更深的 bg-strong 作为外圈,避免和选中态自带的 1px 描边重影 */ +.seg-btn.active:focus-visible { + box-shadow: 0 0 0 1px var(--color-accent), 0 0 0 3px var(--color-accent-bg-strong); +} + +/* 选中态 —— 与应用里其它 .active 模式(.btn-icon.active / .command-palette-item.active + / .category-item.active 等)保持一致:淡 accent 底 + accent 文字 + 加粗到 600。 + 原方案用 --color-bg-elevated 中性白底,浅色主题下与未选中态相差仅 ~5%, + 看不出「选了哪个」;换成 accent 色系后形成 + 「容器(--color-bg) → 未选(透明) → 选中(accent 淡底)」三层视觉层级。 + 浅色主题下再叠一层 accent-bg-strong 的弱描边,提升辨识度。 */ +.seg-btn.active { + background: var(--color-accent-bg); + color: var(--color-accent-hover); + font-weight: 600; + box-shadow: + var(--shadow-sm), + 0 0 0 1px var(--color-accent-bg-strong); +} + +/* 关于区 */ +.settings-about { + display: flex; + flex-direction: column; + gap: 1px; + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-2) var(--space-3); +} + +.settings-about-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: var(--font-sm); + padding: 5px 0; +} + +.settings-about-key { + color: var(--color-text-muted); +} + +.settings-about-val { + color: var(--color-text); + font-family: var(--font-mono); + font-size: var(--font-xs); + padding: 2px 8px; + background: var(--color-bg-tertiary); + border-radius: var(--radius-xs); +} + +/* 关于区里的可点击链接 —— 视觉上是值(带背景),行为是可点; + 鼠标悬停时换色 + 下划线提示。颜色变量与其它设置链接保持一致。 */ +.settings-about-link { + cursor: pointer; + text-decoration: none; + transition: color 0.12s ease; +} + +.settings-about-link:hover { + color: var(--color-accent); + text-decoration: underline; +} + +.settings-about-link:active { + opacity: 0.7; +} + +/* 紧凑模式已移除 */ + +/* ============================================ + 动画 + ============================================ */ + +@keyframes task-fade-in { + from { + opacity: 0; + transform: translateY(-2px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes check-pop { + 0% { + transform: rotate(45deg) scale(0.4); + opacity: 0; + } + 100% { + transform: rotate(45deg) scale(1); + opacity: 1; + } +} + +/* 任务完成过渡已合并到主 .task-item 定义 */ + +/* 右键菜单禁用项 */ +.context-menu-disabled { + color: var(--color-text-faint) !important; + cursor: not-allowed !important; + pointer-events: none; + opacity: 0.5; +} + +.context-menu-disabled:hover { + background: transparent !important; + color: var(--color-text-faint) !important; +} + +/* ============================================ + 字号层级 — 小 + - 在 medium 基础上再缩 ~3-5%,给喜欢紧凑字号的留一个档位。 + - 缩幅收窄(原来 6-8%):避免 2xs 跌到 10px 以下看不清。 + ============================================ */ +[data-font-size="small"] { + --font-2xs: 10px; + --font-xs: 11.5px; + --font-sm: 12.5px; + --font-md-sm: 13.5px; + --font-base: 14px; + --font-md: 15px; + --font-lg: 17px; + --font-xl: 20px; + --font-2xl: 24px; +} + +/* ============================================ + 字号层级 — 大 + - 在 medium 基础上放大 ~6-8%,给老花 / 大屏 / 高分辨率留档 + - 比原来 ~12% 更克制:medium 已经 +0.5,再叠加太多大档就过头了 + ============================================ */ +[data-font-size="large"] { + --font-2xs: 11.5px; + --font-xs: 13px; + --font-sm: 14px; + --font-md-sm: 15px; + --font-base: 15.5px; + --font-md: 16.5px; + --font-lg: 19px; + --font-xl: 22.5px; + --font-2xl: 26.5px; +} + +/* 硬编码字号 → 使用变量以响应层级切换。 + 语义分组(从大到小): + - 标题档(font-xl/md/md-sm):弹窗标题、页标题、分组标题 + - 正文档(font-sm):主交互文本(任务、按钮、菜单、表单) + - 辅助档(font-xs):hint、描述、状态栏、空提示的副信息 + - 极小档(font-2xs):svg、kbd 旁的微标签 + + 注:菜单 / 命令面板 / toast 这几项的基础定义已经是 var(--font-sm), + 它们只需「响应层级切换」即可,不需要额外再叠一层 !important 强行放大; + 之前用 var(--font-md) 强行盖掉 font-sm 会让右键菜单显得过大。 */ + +.modal-header, +.settings-header, +.command-palette-input { font-size: var(--font-md) !important; } + +/* 设置项分组标题 —— 比弹窗主标题小一档 */ +.settings-section-title { font-size: var(--font-md-sm) !important; } + +.modal-body, +.input-dialog-message, +.input-dialog-input { font-size: var(--font-base) !important; } + +.category-name, +.category-item, +.task-text, +.task-input, +.search-input, +.btn, +.seg-btn, +.settings-label, +.settings-about-row, +.filter-chip, +.btn-primary, +.btn-add-subcategory, +.category-item.subcategory .category-name, +.task-edit-input, +.settings-input, +.empty-hint, +.command-palette-empty { font-size: var(--font-sm) !important; } + +.task-category-title { font-size: var(--font-xl) !important; } + +/* 辅助/次要文本 —— 状态栏、hint、进度数字、搜索快捷键标签 */ +.task-progress-text, +.search-shortcut, +.settings-section-desc, +.command-palette-item-hint, +.status-meta, +.status-text, +.file-path-chip, +.file-path-hint, +.settings-hint, +.settings-hint code, +.settings-link, +.settings-about-val { font-size: var(--font-xs) !important; } + +.subcategory-action-btn, +.more-menu-item > svg, +.task-action-btn svg { font-size: var(--font-2xs) !important; } + +/* 空状态标题 —— 弹窗主标题小一档、但比 hint 大一档 */ +.empty-title { font-size: var(--font-md) !important; } + +.app-title { font-size: var(--font-md) !important; } + +.empty-hint kbd { font-size: var(--font-xs) !important; } + +.filter-chip .chip-icon { font-size: 10px !important; } + +.empty-icon svg { width: 26px !important; height: 26px !important; } + +/* 字号层级行高微调 */ +[data-font-size="small"] body, +body { line-height: 1.55; } + +[data-font-size="large"] body { line-height: 1.6; } + +/* ============================================ + 响应式 + - 思路:侧栏宽度已用 clamp() 做成连续过渡,下面只剩 3 个真正影响布局的断点 + - ≥1400 大屏加留白;≤900 中宽度开始紧凑;≤620 进图标模式 + ============================================ */ + +/* 大窗口:留更多空白 */ +@media (min-width: 1400px) { + .task-header, + .task-input-wrapper { + padding-left: var(--space-8); + padding-right: var(--space-8); + } + + .toolbar { + padding-left: var(--space-4); + } +} + +/* 中等窗口(约 ≤900px):紧凑模式 + - 工具栏 padding 收紧、gap 减小,给中宽窗口留更多有效空间 + - 标题保持显示(frame:false 后是唯一的窗口标识,不再为搜索让位) + - 子分类缩进保持默认,不在中宽度打扰视觉 + - file-path-chip 不再设 max-width:状态栏剩下的空间自然决定 chip 宽度, + 路径真正放不下时由 .file-path-text 内部 ellipsis 处理。 */ +@media (max-width: 900px) { + .toolbar { + padding: 0 var(--space-3); + gap: var(--space-2); + } + + /* 中宽度窗口不再隐藏标题文字:frame:false 接管 OS 标题栏后, + 标题是窗口唯一的"标题"标识,必须始终可见。 + 极窄宽度(≤620px)下再统一交给 flex 自然收缩。 */ + + .task-header, + .task-input-wrapper { + padding-left: var(--space-4); + padding-right: var(--space-4); + } +} + +/* 较窄窗口(≤780px):再给任务面板腾点空间 */ +@media (max-width: 780px) { + .search-shortcut { + display: none; + } + + .task-progress-text { + display: none; + } +} + +/* 极窄窗口:仅图标(≤620px 侧边栏收起为图标栏,内容区留给任务列表) */ +@media (max-width: 620px) { + /* 极窄窗口没空间再放提示文字,让 chip 自己负责(hover 看 title) */ + .file-path-hint { + display: none; + } + + /* .app-title .logo 在 ≤620px 不再缩到更小:全局 .logo 已是 24px,再缩会破坏与 + 标题字号的视觉对齐(16px 文字配 20px logo 在窄窗口里更难受)。 */ + + .layout { + grid-template-columns: 60px 1fr; + } + + .sidebar { + align-items: center; + } + + .sidebar-header { + padding: var(--space-3) 0 var(--space-1); + justify-content: center; + } + + .sidebar-title { + display: none; + } + + .category-list { + padding: 0 var(--space-1); + } + + .category-item { + padding: 10px 0; + justify-content: center; + min-height: 38px; + } + + .category-name { + display: none; + } + + .category-icon { + margin: 0; + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + } + + .category-icon svg { + width: 100%; + height: 100%; + } + + /* 图标模式下:取消子分类缩进,让图标垂直对齐父分类那一列 */ + .subcategory-list { + margin-left: 0; + padding-left: 0; + } + + .category-item.subcategory { + padding-left: 0; + } + + /* 图标模式下:「+ 新建子分类」只保留图标,居中 */ + .btn-add-subcategory { + justify-content: center; + padding: 0; + height: 34px; + } + + .btn-add-subcategory .btn-text { + display: none; + } + + /* 图标模式下隐藏子分类的编辑按钮(重命名/删除),空间不够也没有意义 */ + .subcategory-actions { + display: none; + } + + /* 隐藏空分类提示文字 */ + .subcategory-empty { + display: none; + } + + /* 隐藏搜索快捷键提示,腾出空间给输入框 */ + .search-shortcut { + display: none; + } + + /* 隐藏标题旁的进度文字,省空间 */ + .task-progress-text { + display: none; + } + + /* 收紧任务面板水平 padding */ + .task-header, + .task-input-wrapper { + padding-left: var(--space-3); + padding-right: var(--space-3); + } + + .task-item { + padding: 8px var(--space-3); + } + + .task-item::before { + inset: 0 var(--space-2); + } + + /* 状态栏收紧间距 */ + .statusbar { + gap: var(--space-2); + padding: 0 var(--space-3); + } + + .status-text { + flex-shrink: 1; + } +} + +/* ============================================ + 选择模式专用圆形选择器(与完成 checkbox 视觉完全独立) + ============================================ + 普通模式下复选框 = 完成状态(方框 + 完成勾)。 + 选择模式下需要明确告诉用户「这是选中,不是完成」, + 所以用圆形 + accent 实心填充,形态和颜色都和 checkbox 拉开距离。 + 已完成状态仍由 .task-item.completed 的 line-through + 透明度承载。 */ + +.task-selection-wrapper { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + width: 24px; + height: 24px; + border-radius: 7px; + transition: background 0.15s var(--ease-soft), + transform 0.12s var(--ease-spring); +} + +.task-selection-wrapper:hover { + background: var(--color-bg-tertiary); +} + +.task-selection-wrapper:active { + transform: scale(0.88); +} + +.task-selection-circle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border: 1.6px solid var(--color-text-faint); + border-radius: 50%; /* 圆形 —— 与 checkbox 的圆角矩形区分 */ + background: transparent; + position: relative; + transition: background 0.2s var(--ease-soft), + border-color 0.2s var(--ease-soft), + transform 0.18s var(--ease-spring), + box-shadow 0.2s var(--ease-soft); +} + +.task-selection-wrapper:hover .task-selection-circle { + border-color: var(--color-text-muted); +} + +.task-selection-circle.selected { + background: var(--color-accent); + border-color: var(--color-accent); + box-shadow: 0 2px 6px var(--color-accent-glow); +} + +.task-selection-circle.selected svg { + animation: check-pop 0.22s var(--ease-spring); +} + +/* 选择模式下:整行变成「点这里切换选中」的语义。 + 隐藏 actions(编辑/删除),但 ⭐/▶ 改成只读状态指示器显示出来 — + 这样用户在批量选择时还能看到「哪些任务已经是当前/重要」,方便判断要不要继续操作。 + 只读样式走 .task-readonly-indicator 修饰类;按钮仍用原 class 是为了让 CSS + 上 important/current 的填色规则可以共用。 */ +.task-item.select-mode .task-actions { + display: none; +} + +/* 只读指示器:彻底屏蔽交互 + 视觉上弱化,让用户一眼区分「这是状态显示,不是按钮」 */ +.task-item.select-mode .task-readonly-indicator { + cursor: default; + pointer-events: none; /* 不响应 hover / click,避免和行点击选中打架 */ +} + +/* hover 反馈在只读态下应静默 —— 否则会和「指针在这里不能点」相矛盾 */ +.task-item.select-mode .task-readonly-indicator:hover { + background: transparent; + color: inherit; + transform: none; +} + +/* 已标记的任务:颜色清晰,让「这批里有几个标了 ⭐/▶」一眼可见 */ +.task-item.select-mode.important .task-readonly-indicator.task-star-btn, +.task-item.select-mode.current .task-readonly-indicator.task-current-btn { + opacity: 1; +} + +/* 未标记的任务:留个非常淡的轮廓,让用户感知到「这里本来有标记,但这条没标」, + 同时不抢已标记任务的视觉权重。 */ +.task-item.select-mode:not(.important) .task-readonly-indicator.task-star-btn, +.task-item.select-mode:not(.current) .task-readonly-indicator.task-current-btn { + opacity: 0.32; +} + +/* 选择模式全局开关:body[data-selection-mode="true"] */ +body[data-selection-mode="true"] .task-item { + cursor: pointer; +} + +/* 选择模式下隐藏输入框 —— 浮栏会浮在底部,输入框会和「移到分类」等按钮撞位置。 + 同时语义上选择模式下不需要新增任务(用户焦点在「整理」不在「添加」)。 */ +body[data-selection-mode="true"] .task-input-wrapper { + display: none; +} + +/* 选择模式下:右下角浮栏会盖住最底下的几行任务,给任务列表底部预留 padding + 让最后一行能滚到浮栏之上。浮栏最大高度约 280px,再加 24px 底部偏移 = ~310px。 + 注意:task-list 是 task-panel 内部的滚动容器,所以 padding-bottom 要加在 + task-list 上而不是 task-panel 上,避免整个面板出现空隙。 */ +body[data-selection-mode="true"] .task-list { + padding-bottom: 320px; +} + +/* 选择模式下的「已选」高亮:复用 .task-item.selected::before 的视觉 + (accent 浅底 + 描边 + 光晕),不需要单独写。 */ + +/* 已选任务的行高亮 + 内容粗细微调,让选中态更易扫视 */ +.task-item.select-mode.selected { + font-weight: 500; +} + +/* ============================================ + 批量操作浮栏(.batch-action-bar) + ============================================ + 视觉:固定在底部居中,横向排布、允许换行(通常落成两行), + 与 .more-menu 同款设计语言(elevated bg + 描边 + 阴影 + 圆角)。 + 结构(宽度够时两行): + ┌───────────────────────────────────────────────┐ + │ 已选 3 项 ✕ │ 移到分类 置顶 置底 │ 标重要 取消重要 │ + │ 标当前 取消当前 │ 完成 移到回收站 │ + └───────────────────────────────────────────────┘ + 之所以放底部横向:早先的右下角纵向浮栏会盖住任务列表右侧一整列, + 遮挡明显;底部两行只占屏幕最下方一小条,扫视和点击都更顺。 */ + +.batch-action-bar { + position: fixed; + /* 底部 statusbar 高 30px + 8px gap = 浮栏下沿不能进入此 38px 区。 */ + bottom: calc(var(--h-statusbar) + 8px); + /* 落在主区(侧栏右侧),不要悬浮在左侧栏上。 + 侧栏宽度两档(与 .layout 同款): + - 默认:clamp(245px, 25%, 280px) + - ≤620px:折叠成 60px(见下方 @media) + 取两档较大值再 +8px = 浮栏左缘。 */ + left: calc(max(60px, clamp(245px, 25%, 280px)) + 8px); + right: 8px; + transform: none; + z-index: calc(var(--z-menu) - 1); /* 比右键菜单稍低,避免遮挡菜单 */ + background: var(--color-bg-elevated); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + font-size: var(--font-sm); + -webkit-app-region: no-drag; + display: flex; +} + +/* inner:真正承载排版 + 入场动画 —— 浮栏水平居中由外层的 left/right 决定, + 这里只做 translateY,不会跟 translateX(-50%) 冲突 */ +.batch-action-bar-inner { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + justify-content: center; + /* 1100px viewport(默认测试尺寸)下 12 个按钮约 ~700px, + 留 720px 让大多数常见视口(≥900px)单行容纳; + <900px 自动 wrap 成 2 行(设计已校验 wrap 不破坏语义)。 */ + gap: 1px 3px; + padding: 5px 6px; + width: min(65vw, 720px); + max-width: calc(100vw - max(60px, clamp(245px, 25%, 280px)) - 16px); + box-sizing: border-box; + margin: 0 auto; + animation: batch-bar-in 0.18s var(--ease-out); +} + +@keyframes batch-bar-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* header:计数 + 关闭按钮 —— 横排时与各组用 1px 竖线分开 */ +.batch-bar-header { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 8px 0 4px; + margin-right: 2px; + border-right: 1px solid var(--color-border-muted); +} + +/* 「全选/取消全选」toggle 按钮 —— 紧贴「已选 N 项」旁边,复用 .batch-action 风格 + 但去掉自身的左 padding,让按钮贴 count 文字更紧凑,与紧邻的 ✕ 按钮视觉对称。 + 同时给它一个与「执行类」按钮可区分的微调:不参与 danger 配色(不删任务)。 */ +.batch-bar-header .batch-action-select-all { + padding-left: 4px; +} + +.batch-count { + display: inline-flex; + align-items: center; + color: var(--color-text-muted); + font-size: var(--font-xs); + font-weight: 600; + letter-spacing: 0.02em; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +/* ✕ 关闭按钮:紧贴 header 右上角,方形小图标按钮 */ +.batch-action-close { + width: 22px; + height: 22px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 5px; + background: none; + border: none; + color: var(--color-text-muted); + cursor: pointer; + flex-shrink: 0; + transition: background 0.1s var(--ease-soft), + color 0.1s var(--ease-soft), + transform 0.08s var(--ease-soft); +} +.batch-action-close:hover { + background: var(--color-bg-tertiary); + color: var(--color-text); +} +.batch-action-close:active { + transform: scale(0.88); +} + +/* 每个组:横向并排 + 隐式分隔(容器自身不画线,靠相邻组的 padding 形成视觉断点) */ +.batch-bar-group { + display: inline-flex; + align-items: center; + gap: 1px; + padding: 0; +} + +.batch-action { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 7px; + border-radius: 5px; + background: none; + border: none; + color: var(--color-text); + font-family: inherit; + font-size: 12px; + font-weight: 500; + cursor: pointer; + text-align: left; + white-space: nowrap; + transition: background 0.1s, color 0.1s; +} + +.batch-action > svg { + width: 14px; + height: 14px; + flex-shrink: 0; + color: var(--color-text-muted); + transition: color 0.1s; +} + +.batch-action > span { + flex: 1; + min-width: 0; +} + +.batch-action:hover:not(:disabled) { + background: var(--color-accent-bg); + color: var(--color-accent-hover); +} +.batch-action:hover:not(:disabled) > svg { + color: var(--color-accent-hover); +} + +.batch-action:active:not(:disabled) { + transform: scale(0.98); +} + +.batch-action:focus-visible { + outline: none; + box-shadow: 0 0 0 1px var(--color-accent), 0 0 0 3px var(--color-accent-bg); +} + +/* 禁用态:灰显 + 不可点 —— 必须用 :disabled 而非 [disabled] + 才能在某些浏览器里拿到正确的「不可点」语义 */ +.batch-action:disabled { + opacity: 0.35; + cursor: not-allowed; + /* hover 时不变色 —— 防止 disabled 按钮看起来像可点 */ +} +.batch-action:disabled > svg { + color: var(--color-text-muted); +} + +/* 危险按钮:移到回收站 —— 借用 --color-danger-bg */ +.batch-action.batch-action-danger:hover:not(:disabled) { + background: var(--color-danger-bg); + color: var(--color-danger); +} +.batch-action.batch-action-danger:hover:not(:disabled) > svg { + color: var(--color-danger); +} + +/* 组间分隔:除了 header 的右边线,给除第一个外的组加一道左边线 —— 横排时清晰 */ +.batch-bar-group + .batch-bar-group { + border-left: 1px solid var(--color-border-muted); + padding-left: 6px; + margin-left: 2px; +} + +/* 小屏:仅收紧 padding / 字号 —— 浮栏仍让位侧栏(无论侧栏是 245–280px 还是收成 60px)。 + 之前用 ≤720px 改 left:8px / right:8px 是错的:620–720px 区间侧栏还是 245–280px, + 浮栏会盖过去。改用 max() 在 base 规则里统一处理浮栏左缘 —— 这里只剩视觉压缩。 */ +@media (max-width: 720px) { + .batch-action-bar { + /* 紧贴 statusbar 上方 —— 大屏是 +8px gap,小屏再压 4px 让出空间 */ + bottom: calc(var(--h-statusbar) + 4px); + } + .batch-action-bar-inner { + padding: 5px 6px; + gap: 2px 4px; + max-width: none; + } + .batch-action { + padding: 5px 7px; + gap: 5px; + font-size: 12px; + } + .batch-count { + font-size: 11px; + } +} + +/* 极窄屏(≤620px 侧栏已折成图标栏,主区内容更宽了,但浮栏按钮总数不变): + 进一步压缩 —— 隐藏文字标签,只留图标,tooltip 保留全名。 + 否则 4 个组会塞成 3 行,盖住底部状态栏。 */ +@media (max-width: 620px) { + .batch-action-bar-inner { + gap: 1px 2px; + padding: 4px 5px; + } + .batch-action { + padding: 6px; + gap: 0; + } + /* span 里是按钮标签, 始终保留 */ + .batch-action > span { + display: none; + } + /* 但 header 里的关闭按钮文字其实没有,只有 SVG;count 文字要保留 */ + .batch-bar-header .batch-count { + display: inline; + } + .batch-action-close { + width: 22px; + height: 22px; + padding: 0; + } +} + +/* ============================================ + 选择模式入口按钮(btn-batch)按下态 + ============================================ */ +/* 当 selectionMode 为 true 时给按钮一个更明显的「激活」外观 */ +#btn-batch[aria-pressed="true"] .filter-chip { + background: var(--color-accent-bg-strong); + color: var(--color-accent-hover); +} + +/* ============================================ + 「重要 / 当前」图标按钮的 SVG 颜色与 select-mode 兼容 + ============================================ + 选择模式下 ⭐/▶ 已改为 .task-readonly-indicator 显示(见上方 select-mode 块), + SVG 颜色仍由 .task-item.important / .task-item.current 触发的 fill 与 color + 控制,这里无需重复声明 —— 只是兜底:如果未来调整读法,留个指针。 */ + +/* 减少动画(无障碍) */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} + +/* ============================================ + 冲突解决对话框(v3.4 任务粒度合并) + ============================================ + 布局:上中下三段 + - .conflict-dialog-summary: 顶部统计(X 处差异 / 文件新加 N / 本地新加 N / 两边都改 N) + - .conflict-dialog-grid-header + .conflict-dialog-body: 三列内容(文件版本 / 选择器 / 本地版本) + - .conflict-dialog-footer: 底部四个按钮(取消 / 全部用本地 / 全部用文件 / 应用合并) + 配色全部走现有 token(--color-warning / --color-success / --color-accent / --color-danger), + AA 对比度由 token 保证。 */ +.conflict-dialog-overlay .conflict-dialog-modal { + min-width: 720px; + max-width: 980px; + max-height: 80vh; + display: flex; + flex-direction: column; +} + +.conflict-dialog-header { + font-weight: 600; + /* 标题 + 右侧行级 diff 按钮要两列布局 —— 默认 .modal-header 是块级,靠左堆叠 */ + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.conflict-dialog-title-text { + /* 占满剩余空间,把按钮推到右侧 */ + flex: 1; + min-width: 0; +} + +.conflict-diff-preview-btn { + /* 轻量文字按钮 —— 覆盖 .btn 默认的 --h-control 高度,让它紧凑贴在标题旁 */ + height: auto; + font-size: 12px; + font-weight: 500; + padding: 4px 10px; + border-radius: 4px; + background: transparent; + border: 1px solid var(--color-border); + color: var(--color-accent-hover); + cursor: pointer; + flex-shrink: 0; + transition: background 0.15s, border-color 0.15s; +} + +.conflict-diff-preview-btn:hover { + background: var(--color-accent-bg); + border-color: var(--color-accent); +} + +.conflict-diff-preview-btn:focus-visible { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px var(--color-accent-bg); +} + +.conflict-dialog-summary { + padding: 12px 24px 16px; + border-bottom: 1px solid var(--color-border); + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.conflict-stat { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 12px; + background: var(--color-bg-elevated); + color: var(--color-text-secondary); + font-size: 12px; + font-weight: 500; +} + +.conflict-stat-total { + background: var(--color-accent-bg); + color: var(--color-accent-hover); +} + +.conflict-stat-conflict { + background: var(--color-warning-bg); + color: var(--color-warning); +} + +.conflict-dialog-grid-header { + display: grid; + grid-template-columns: 1fr 200px 1fr; + gap: 12px; + padding: 12px 24px; + background: var(--color-bg-elevated); + border-bottom: 1px solid var(--color-border); + font-size: 12px; + font-weight: 600; + color: var(--color-text-secondary); +} + +.conflict-dialog-body { + flex: 1; + overflow-y: auto; + padding: 12px 24px; +} + +.conflict-empty { + padding: 32px; + text-align: center; + color: var(--color-text-secondary); +} + +.conflict-row { + display: grid; + grid-template-columns: 1fr 200px 1fr; + gap: 12px; + padding: 8px 0; + align-items: center; + border-bottom: 1px solid var(--color-border); +} + +.conflict-row:last-child { + border-bottom: none; +} + +.conflict-cell { + min-width: 0; + padding: 6px 10px; + border-radius: 6px; + font-size: 13px; + line-height: 1.5; + word-break: break-word; +} + +.conflict-cell-file { + background: var(--color-warning-bg); + color: var(--color-text); +} + +.conflict-cell-mine { + background: var(--color-accent-bg); + color: var(--color-text); +} + +.conflict-cell-chooser { + display: flex; + align-items: center; + justify-content: center; +} + +.conflict-task-side-file, +.conflict-task-side-mine { + /* 行级标签:仅占位用,文本颜色由父元素 .conflict-cell-* 控制 */ +} + +.conflict-task-empty { + color: var(--color-text-tertiary); + font-style: italic; + font-size: 12px; +} + +/* 任务原文里的 [原分类:xxx] 标记 —— 灰色小字,视觉权重低于 checkbox 和 ▶/⭐ 标记。 + 在 COMPLETED / TRASH 分类里才有意义(普通子分类 task.originalCategory 通常为 null)。 + 见 markdown-writer.js:formatTaskLine 的 showOriginalCategory 判断 —— 这里是纯展示。 */ +.conflict-task-orig { + display: inline-block; + margin-left: 2px; + padding: 1px 6px; + border-radius: 3px; + background: var(--color-bg); + color: var(--color-text-tertiary); + font-size: 11px; + font-weight: 500; + letter-spacing: 0; + /* 与 [▶] [⭐] 标记视觉区分 —— 这条是"任务来源追溯"元数据,不是任务本身状态 */ + border: 1px dashed var(--color-border); +} + +.conflict-choice { + width: 100%; + padding: 6px 8px; + font-size: 12px; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-bg); + color: var(--color-text); +} + +.conflict-choice:focus-visible { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px var(--color-accent-bg); +} + +.conflict-dialog-footer { + /* 复用 .modal-footer 的 flex 布局 —— 这里四个按钮用 gap 隔开 */ + gap: 8px; +} + +/* ============================================ + 行级 diff 预览(v3.4+ 辅助视图) + ============================================ */ +.diff-preview-overlay .diff-preview-modal { + min-width: 720px; + max-width: 1100px; + max-height: 80vh; + display: flex; + flex-direction: column; +} + +.diff-preview-legend { + padding: 8px 24px; + display: flex; + gap: 16px; + font-size: 12px; + color: var(--color-text-secondary); + border-bottom: 1px solid var(--color-border); +} + +.diff-line-add-legend::before { + content: '+'; + margin-right: 4px; + color: var(--color-success); + font-weight: 600; +} + +.diff-line-remove-legend::before { + content: '-'; + margin-right: 4px; + color: var(--color-danger); + font-weight: 600; +} + +.diff-preview-body { + flex: 1; + overflow: auto; + padding: 12px 24px; + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.5; + background: var(--color-bg-elevated); +} + +.diff-line { + display: flex; + padding: 1px 0; +} + +.diff-line-prefix { + flex: 0 0 24px; + color: var(--color-text-tertiary); +} + +.diff-line-text { + flex: 1; + white-space: pre-wrap; + word-break: break-word; +} + +.diff-line-add { + background: var(--color-success-bg); +} + +.diff-line-add .diff-line-prefix { + color: var(--color-success); +} + +.diff-line-remove { + background: var(--color-danger-bg); +} + +.diff-line-remove .diff-line-prefix { + color: var(--color-danger); +} + +.diff-line-equal { + color: var(--color-text-secondary); +} + +.diff-empty { + padding: 24px; + text-align: center; + color: var(--color-text-secondary); +} + +/* 打印(隐藏不必要元素) */ +@media print { + .toolbar, + .statusbar, + .btn-add-subcategory, + .toast-container, + .context-menu { + display: none !important; + } +} diff --git a/src/task-store.js b/src/task-store.js new file mode 100644 index 0000000..55920fb --- /dev/null +++ b/src/task-store.js @@ -0,0 +1,3599 @@ +// 任务存储 - in-memory 状态管理 + 事件 +// +// v3.4 「已完成任务」升级为真实分类: +// 当前任务(智能视图,跨子分类聚合 current=true 的任务;v2 的真实分类已下沉为 @当前 标识) +// 重要任务(智能视图,跨子分类聚合 important=true 的任务) +// 全部任务(kind=OTHER_TASKS,容器;聚合视图含 当前任务 + 子分类) +// 工作 / 学习 / 生活 / 未分类 / 用户自定义子分类(kind=NORMAL, parentOtherTasks=true) +// 已完成任务(kind=COMPLETED,**真实分类** —— [✓] 打勾后任务自动移动到这里, +// 取消勾选则回到它原来的分类([原分类:xxx],v3.5+ 由 toggleTask 写入)。 +// 原分类丢失时弹分类选择器;任务没有原分类记录才兜底到「未分类」。 +// 与回收站语义对称:删除→回收站,完成→已完成任务。 +// 「已完成任务」智能视图直接读这个分类,不再跨子分类聚合) +// 回收站(kind=TRASH,删除的任务落点;排除于全部智能视图之外) +// +// 任务字段:text / completed / important / current +// +// 数据归属与状态同步不变量(v3.4 新增): +// - completed=true 的任务**只能**活在 kind=COMPLETED 分类 +// - completed=false 的任务**只能**活在 kind=NORMAL 子分类 +// - 由 toggleTask / batchToggleCompleted / restoreTask / moveTask 共同维护 +// - 加载时由 _migrateCompletedTasksToCategory 一次性归位旧文件 +// +// v2 → v3 迁移: +// 旧文件 `# 当前任务` 章节下的任务会被搬到首个子分类并打上 current=true 标记, +// 然后删除临时分类。下一次保存即扁平化为新结构。 +// +// v3 → v3.4 迁移: +// 旧文件里散落在各子分类的 [✓] 任务会被搬到新的「# 已完成任务」分类, +// 下一次保存即统一为新结构。 + +import { + parseMarkdown, + CategoryKind, + TODAY_KEYS, + OTHER_TASKS_KEYS, + TRASH_KEYS, + COMPLETED_KEYS, + OTHER_TASKS_NAME, + TODAY_CANONICAL_NAME, + UNCATEGORIZED_NAME, + TRASH_NAME, + COMPLETED_NAME, + extractInlineMarkers, + TASK_LINE_RE, + isCompletedMarker +} from './markdown-parser.js'; +import { writeMarkdown, createDefaultDoc } from './markdown-writer.js'; +import { EventEmitter } from './event-emitter.js'; +import { + diffCategories as diffCategoriesUtil, + applyResolutions as applyResolutionsUtil, + defaultResolutions as defaultResolutionsUtil, + summarizeDiff as summarizeDiffUtil +} from './utils/markdown-diff.js'; + +// 规范名常量的单一定义点在 markdown-parser.js(依赖树叶子,避免循环依赖)。 +// 这里 re-export 以保持既有 import 路径可用(settings-dialog / sidebar 等仍从 task-store 取)。 +export { OTHER_TASKS_NAME, TODAY_CANONICAL_NAME, UNCATEGORIZED_NAME, TRASH_NAME, COMPLETED_NAME }; + +// 导入顺序偏好的合法值集合 —— 与 settings-store.VALID_VALUES.importPosition 对齐。 +// 集中在一处保证:调用方 options.importPosition 覆盖 + 内部 _getInsertPosition('import') +// + 批量路径分支判断共用同一份白名单。'order' 比五件套多出来,是导入场景独有 +// 的「按入参顺序插到目标位置最前面」语义(参见 settings-store.js DEFAULT_SETTINGS 注释)。 +// +// 只有两种合法值:'order'(默认,正序 + 插到最前)和 'front'(倒序 + 插到最前)。 +// 历史上曾有 'back'(正序 + 追加到末尾),但和全局 newTaskPosition='back' 行为完全重叠, +// 让导入对话框与全局设置重复承担「位置」职责 —— 删除。 +const VALID_IMPORT_POSITION = new Set(['order', 'front']); + +// 显示过滤器 +// IMPORTANT: 仅显示标记为 ⭐ 的任务(与侧边栏「重要任务」智能列表同语义, +// 但作用范围限定为「当前分类」——用于快速聚焦当前分类下的重点项) +// +// CURRENT 取代了旧 PENDING("待办"/!completed)的位置 —— 用户的 Todo List 心智是 +// "我此刻在做什么"([▶] current=true),而"待办"在中文里歧义大(可以指任何未完成 +// 的任务,跟 pending 的语义不对齐)。CURRENT 的语义与「当前任务」智能列表(侧边栏) +// 对称:智能视图是跨分类聚合,过滤是当前分类下聚焦。 +export const Filter = Object.freeze({ + ALL: 'all', + CURRENT: 'current', + COMPLETED: 'completed', + IMPORTANT: 'important' +}); + +// 排序方式 +// 按字母排序时,未完成的任务在前、已完成的在后(已完成的仍按字母排), +// 避免「未完成 ↔ 已完成」穿插导致的视觉混乱 +export const SortBy = Object.freeze({ + MANUAL: 'manual', + ALPHABET: 'alphabet' +}); + +// 「全部任务」容器的固定名称、「当前任务」规范名、「未分类」兜底名, +// 以及保留分类名列表(TODAY_KEYS / OTHER_TASKS_KEYS)—— 全部在文件顶部 +// 从 markdown-parser.js 引入并 re-export,此处不再重复定义。 + +/** + * 解析一行粘贴文本 → 一条待导入任务。 + * + * 规则(按顺序): + * 1. trim 当前行;空行返回 null(由调用方计入 skipped) + * 2. 行匹配 `- [ ]` / `- [✓]` 等 Markdown checkbox: + * - completed = ([✓]/[x]/[X]) 为 true + * - text = 剩余部分 trim(允许为空:`- [x]` 与 parseMarkdown 行为一致) + * 注意:**不剥离**前导的 `- ` —— 它是 checkbox 的一部分而不是列表符号。 + * 3. 否则若 stripLeadingBullets === true:去掉前导的列表符号 + * (`-` / `*` / `+` / `•` / `数字.` / `数字)`),保留剩余文本。 + * 4. 否则原样使用 trim 后的文本,completed 默认为 false。 + * 5. 解析完 checkbox / 列表符号后,调用 extractInlineMarkers 把文本里的 + * [⭐] / [▶] / 裸 ⭐ / @当前 等标记翻译成 important / current 字段 + * (与 parseMarkdown 完全一致 —— 不在导入时翻译就会留下「文本里写着 + * [⭐],但 important=false;下次保存时 parseMarkdown 又把 [⭐] 抽走」 + * 的诡异状态)。 + * + * 中文标点 `、` / `。` / `;` 不是列表符号 —— `1.` 才会触发(要求前面至少一个数字)。 + * + * 纯函数:被 importTasksToCategory 与导入对话框的实时预览共用,确保 + * 「预览说能导入 N 条 → 点确认也导入 N 条」。 + * + * @param {string} rawLine + * @param {{ stripLeadingBullets?: boolean }} [options] + * @returns {{ text: string, completed: boolean, important?: boolean, current?: boolean, originalCategory?: string|null } | null} + */ +export function parseImportLine(rawLine, options = {}) { + const strippedLeadingBullets = options.stripLeadingBullets !== false; // 默认 true + const line = (rawLine || '').trim(); + if (!line) return null; + + // 1) Markdown checkbox 行 —— 必须先匹配,否则 `- [ ] foo` 会先被当作 + // 「带列表符号的列表项」剥掉前导 `- `,剩下 `[ ] foo`,用户期望的勾选状态就丢了。 + // 与 markdown-parser.js 对齐:[✓]/[x]/[X] 视为已完成;`[ ]` 视为未完成; + // 括号里其它字符(如旧的 [!high])一律视为未完成,标记本身已不再解析为元数据。 + // 文本部分允许为空(合法手写 `- [x]`,与 parseMarkdown 行为一致 —— 文件 load 会 + // 保留它,导入却悄悄丢掉会让用户数据「莫名消失」)。 + const checkboxMatch = line.match(TASK_LINE_RE); + if (checkboxMatch) { + const marker = checkboxMatch[1]; + const completed = isCompletedMarker(marker); + let text = (checkboxMatch[2] || '').trim(); + // 把文本里的 [⭐] / [▶] / 裸 ⭐ 等 inline 标记翻译成 important / current 字段, + // 然后从 text 中剥掉 —— 与 parseMarkdown 完全一致,避免「导入后文本里留着 [⭐]、 + // 但 important=false;下次保存时 parseMarkdown 又把 [⭐] 抽走」的诡异状态。 + const markers = extractInlineMarkers(text); + return { + text: markers.text, + completed, + important: markers.important, + current: markers.current, + originalCategory: markers.originalCategory + }; + } + + // 2) 普通列表符号剥离(仅在选项开启时) + let coreText; + if (strippedLeadingBullets) { + // 无序列表:` - foo` / `* foo` / `+ foo` / `• foo` + // 有序列表:`1. foo` / `2) foo` —— 必须前面至少一个数字,避免吃掉以小数点结尾的标题 + const bulletMatch = line.match(/^(?:[-*+•]|\d+[.)])\s+(.*)$/); + if (bulletMatch) { + coreText = bulletMatch[1].trim(); + if (!coreText) return null; + } else { + coreText = line; + } + } else { + coreText = line; + } + + // 3) 与 parseMarkdown 一致:抽取 inline 标记([⭐] / [▶] / 裸 ⭐ / @当前 等) + // —— 这样导入后任务的 important / current 字段正确,文本里也不会残留 [⭐] 字符。 + const markers = extractInlineMarkers(coreText); + return { + text: markers.text, + completed: false, + important: markers.important, + current: markers.current, + originalCategory: markers.originalCategory + }; +} + +/** + * 共享常量:parser 用的 kind 名 + 保留分类的中文显示名。 + * 集中放在这里避免任务列表、写作器、迁移逻辑各自硬编码「已完成任务」/「回收站」。 + */ + +export class TaskStore extends EventEmitter { + // 任务插入位置 helper 用到的常量 —— 提至模块顶层以便外部 reference 测试用。 + // 与 settings-store 的 VALID_VALUES.newTaskPosition / completedPosition / trashPosition 对齐。 + static POSITION_FRONT = 'front'; + static POSITION_BACK = 'back'; + + constructor(options = {}) { + super(); + // settingsStore 可选:注入后 _appendTaskWithPosition 才能读到 *Position 偏好。 + // 缺省时按 DEFAULT_SETTINGS 默认值(front)走,等价于没开设置 —— 让单测 fixture + // (makeStore())不用关心这个依赖,老调用方 new TaskStore() 仍然有效。 + this._settingsStore = options.settingsStore || null; + this.filePath = null; + this.categories = []; + this.selectedCategoryName = null; + this.selectedSmartList = null; // 'important' | 'allTasks' | 'completed' | null + this.searchQuery = ''; + // 缓存 searchQuery 的小写版,避免每次渲染都 toLowerCase()(详见 setSearchQuery) + this._searchQueryLower = ''; + this.filter = Filter.ALL; + this.sortBy = SortBy.MANUAL; + this.theme = 'dark'; + this.dirty = false; + this._suppressDirty = false; + + // v4 起全实时同步:移除 800ms debounce。每次 _markDirty 直接走 + // _scheduleAutoSave → _doAutoSave;有 in-flight 写正在进行时新改动走 + // 「_needsResave 标记 → finally 里再补一次写」的合并路径,避免并发写。 + // 保留 _autoSave 属性 + cancel/flush 接口仅为兼容旧测试 mock 写法 + // (测试赋值为 () => {} 即可关闭自动保存,无需关心 cancel/flush)。 + this._autoSave = Object.assign( + () => this._scheduleAutoSave(), + { cancel() {}, flush() {} } + ); + // 写入代次计数器:每次 _doAutoSave / saveNow 启动时 +1,并随 await 一并保留。 + // 写完回来时,如果当前代次已被自己推过(即用户在被 await 期间又改过东西, + // _markDirty 又跑了一次 _doAutoSave),就把 dirty 复位 / emit('dirty', false) 推迟, + // 留给下一次自动保存处理。否则会发生: + // 用户改 A → autoSave 启动 → await writeFile(在这期间用户改 C) + // → writeFile 返回 → dirty=false / emit('dirty', false) + // → 但 C 还在内存里没写出去,UI 已显示「已同步」 → 下次重启 C 凭空消失。 + // 取消外部写入时也用同一机制:把当前代次 +1 让 in-flight 的 await 醒来后认作过期。 + this._writeGeneration = 0; + this._inFlightGeneration = 0; + // 「脏版本」计数器 —— 与 _writeGeneration 互补: + // _writeGeneration 只在 _doAutoSave / saveNow 启动时 +1,能捕捉「另一个 save 已接力」。 + // 但 _markDirty 只推 _dirtyVersion + 设 dirty=true + 触发 schedule,await 期间如果 + // 用户又改了东西,_writeGeneration 没动,generation 检查会通过;这时必须靠 + // _dirtyVersion 比对来识别「快照不含最新改动」,否则 dirty=false 被错误地设上。 + this._dirtyVersion = 0; + // 在 _doAutoSave 已经 await writeFile 期间收到新改动时标记为 true; + // finally 里检查到就再补一次 _doAutoSave,把 in-flight 期间累积的改动一并写出去。 + // 这是替代原 800ms debounce 的「合并」机制 —— 没有等待窗口,但保证不丢改动。 + this._needsResave = false; + + // ── 细粒度 dirty 追踪(v5 双向同步新增)── + // + // 之前 `this.dirty` 只是单一布尔位,UI 拿不到「用户在内存里改了哪些任务 / 哪几个分类」。 + // 双向同步冲突解决需要: + // - 列出「本地有 + 文件没有」的任务(用户在 UI 新加的) + // - 列出「文件有 + 本地没有」的任务(外部编辑器新加的) + // - 列出两边都改了状态的任务(toggle / updateMeta 等) + // + // 这里累积受影响的对象 id(task 用 task.id,category 用 cat.name)。 + // 累积 Set 让: + // - 用户短时间内多次改同一条任务 → 只算一次改动(避免重复弹 dialog) + // - 删除任务时 id 还在 Set 里,供「对话期间又改了 N 项」回查用 + this._dirtyTaskIds = new Set(); + this._dirtyCategoryNames = new Set(); + // 兜底 Set:旧代码里有些路径只在结束时 _markDirty() 但忘了标 task/category id。 + // 这种 dirty=true 但 task/cat Set 都是空的情况,UI 显示「1 项未保存」但无法列出 + // 具体内容。dirty=true 时如果两个 Set 都空,序列化 fallback 会把整个 categories 当作"已改动"。 + this._dirtyStructuralOnly = false; + + // v3.7+ 修复 H6:picker 路径(取消勾选时原分类丢失)记每个 task 的 picker 次数。 + // 旧行为是 picker 路径只返回 restoreHint 不动任务 —— UI 关掉 dialog 后任务 + // 永远卡在 COMPLETED 但 completed=false,下次再 toggle 又走 picker,无限循环。 + // 阈值:同一 task 在 picker 路径停留超过 _PICKER_ATTEMPT_LIMIT 次, + // 自动降级为 fallback(强制走「未分类」),同时 console.warn 提醒开发者。 + this._pickerAttempts = new Map(); + this._PICKER_ATTEMPT_LIMIT = 3; + + // ── 磁盘快照缓存(v5 新增)── + // + // resolveExternalChangeConflict 第一次拿到磁盘内容时存到这里: + // - dialog 打开期间 dialog 内的「查看行级 diff」按钮可立即渲染(无需再 IPC) + // - dialog 关闭前重新对比时,作为最新已知磁盘状态 + // - apply 合并前若用户又改了磁盘 → 重新 readFile 刷新这里 + // 关闭 dialog 时清掉,避免常驻内存。 + this.diskSnapshot = null; + } + + // ============================================ + // 加载 / 保存 + // ============================================ + + /** + * 从 Markdown 内容加载 + */ + loadFromContent(content, filePath = null, options = {}) { + // options.mergeResolutions - {taskKey: 'a'|'b'|'both'|'skip'} + // 软合并模式:与新内容 diff 后按选择应用,避免完全覆盖导致用户丢失本地未保存改动。 + // 见 mergeFromDisk() 的语义注释 —— loadFromContent 是「被动接受磁盘内容」, + // 不重排 selection。 + const mergeResolutions = options && options.mergeResolutions; + // v4+ 修复:先废掉任何 in-flight 的自动保存。极端竞态: + // 1) 用户编辑 → _doAutoSave 已 await writeFile 走到一半 + // 2) 用户「打开」或「新建」另一份 todo.md → loadFromContent 替换 categories + // 3) in-flight 的 writeFile 醒来 → serialize() 已经读到 new categories, + // 但 fs.writeFile 的 filePath / fd 是另一份的(实际不会,因为 fd 是当时 capture 的) + // 真正的隐患在 _doAutoSave 醒来后会 `_clearDirtyTracking()` 把 dirty 清零并发 + // 「saved」假象。cancelPendingSave 把代次 +1,让 in-flight 的 await 醒来时 + // 检测到代次对不上、放弃复位 dirty、放弃发「saved」事件。 + // 没这一步,用户切到新文件后看到的状态栏/标题「未保存」标记会闪烁出错, + // 而且新文件如果走了 setFilePath / path 共享的 IPC,磁盘也可能被覆盖。 + this.cancelPendingSave(); + this._suppressDirty = true; + try { + const previousSelected = this.selectedCategoryName; + const previousSmart = this.selectedSmartList; + // C4 修复:parseMarkdown 内部抛错会让整个 loadFromContent 在 finally 后中断, + // 调用方拿不到任何信号 —— UI 继续显示旧数据但用户以为已经重载。 + // 这里单独 try/catch:捕获后抛带类型的 load-failed 事件,让 app.js 走 + // 「.bak 回退」或冲突对话框路径,而不是默默保持旧状态。 + let incomingCategories; + try { + incomingCategories = parseMarkdown(content); + } catch (parseErr) { + // 关键状态清理:抛出前先把 _suppressDirty 关掉,让后续路径(app.js 的 + // .bak 回退、冲突对话框重试)能用 dirty 标志继续。 + this._suppressDirty = false; + const err = new Error(`parseMarkdown failed: ${parseErr && parseErr.message || parseErr}`); + err.code = 'PARSE_FAILED'; + err.originalError = parseErr; + err.filePath = filePath; + // 让外层 catch / finally 跑完清理工作,再重新抛错给上层 + this.emit('load-failed', { error: err, filePath, content }); + throw err; + } + + // 软合并分支:先按用户选择合并 incoming vs 当前内存 + if (mergeResolutions && Object.keys(mergeResolutions).length > 0) { + const diff = diffCategoriesUtil(incomingCategories, this.categories); + const merged = applyResolutionsUtil(incomingCategories, diff, mergeResolutions); + // 顺序归位(含「未分类」拉回末尾 —— 合并可能让兜底位置漂移) + this.categories = this._normalizeOrder(merged); + // 合并进来的磁盘任务没有运行时 id —— 重分配(与 mergeFromDisk 一致) + this._assignTaskIds(); + } else { + this.categories = incomingCategories; + } + + this.filePath = filePath; + + // 清掉 picker 计数 —— 旧文件的 task.id 与新文件不一定对得上, + // 残留计数会让用户在新文件里勾选时一上来就被降级 fallback。 + this._pickerAttempts.clear(); + + // v3.6 起不再生成 / 解析顶部 `# 当前任务` / `# 重要任务` 镜像段 —— 任务统一活 + // 在「全部任务」容器的子分类下,[▶]/[⭐] 由 inline 标识驱动,智能视图由 store + // 在内存中按标识聚合。旧文件里的镜像段由 parser 走 TODAY_KEYS / SMART_LIST_KEYS + // 路径消化,下次保存即清理。 + + this._ensureBaseStructure(); + + // 选择分类 + // v3 起没有「当前任务」真实分类 —— 它已下沉为「当前」智能视图(@当前 标识驱动)。 + // 旧文件里的 `# 当前任务` 章节会被 _migrateTodayCategory 迁移走, + // 加载完 categories 里不再有 kind=TODAY 的项。所以「找不到可还原的选择」时 + // 必须落到智能视图上,而不是去找一个已经不存在 TODAY 分类(会读到 undefined)。 + const stillExists = previousSelected && this.categories.some(c => c.name === previousSelected); + if (previousSmart) { + this.selectedSmartList = previousSmart; + } else if (stillExists) { + this.selectedCategoryName = previousSelected; + this.selectedSmartList = null; + } else { + // 默认进入「当前」智能视图 —— 用户新建文件后打开,第一眼看到的就是 + // 被打上 @当前 的任务(没有就是空视图,会给提示)。 + this.selectedSmartList = 'current'; + this.selectedCategoryName = null; + } + + this.dirty = false; + + // 外部编辑文件 → 文件顺序就是用户的最新意图。如果当前是字母序, + // 渲染时会按字母重排、把用户刚刚在文件里调换的顺序抹掉 —— 这是错误的。 + // 自动切回 MANUAL:用户对文件的改动立刻可见,且和 reorderTask 的语义保持一致 + // (软件内拖拽也会切回 MANUAL)。用户下次想用字母序可以再点一次菜单。 + this._switchToManualSort(); + + // 即使 filePath 没变也要发:切换数据目录 / 重载时让底部路径 chip 立即刷新 + this.emit('file-path', this.filePath); + this.emit('load', { categories: this.categories, merged: !!mergeResolutions }); + this.emit('change'); + } finally { + this._suppressDirty = false; + // 这两步必须在 finally 内 —— 否则 parseMarkdown 抛异常时(极端的磁盘文件损坏 + // / parser 死循环等)跑不到,dirty 追踪集合残留。下一次 _markDirty 与旧「已清 + // dirty」状态冲突,自动保存路径误判。emit('dirty', false) 同理:必须保证下次 + // Cmd+Q 看到的是最新的 dirty=false,不再无意义触发空写。 + this._clearDirtyTracking(); + // _suppressDirty 期间 dirty=false 是静默设的,主进程和 UI 都看不见。 + // 这里补一次显式 emit,让退出前的 flush-save 判定不残留旧的「dirty」。 + this.emit('dirty', false); + } + } + + /** + * 创建新的默认文档 + */ + loadDefault(filePath = null) { + // v4+ 修复:与 loadFromContent / mergeFromDisk 对齐 —— 必须先废掉 in-flight 自动保存, + // 否则用户在自动保存 await writeFile 期间点"新建"会触发此路径: + // 1) _doAutoSave 已 await writeFile 走到一半 + // 2) 用户点"新建/打开"另一份 todo.md → loadDefault 替换 categories + // 3) in-flight 的 writeFile 醒来 → serialize() 已经读到 new categories + // (实际不会,serialize 仍 capture 旧 categories),但 _clearDirtyTracking() + // 会把 dirty 清零并发「saved」假象,新文件加载完后状态栏"未保存"标记 + // 会闪烁出错。 + // 真正的隐患是 in-flight 写完成后的 _clearDirtyTracking 把刚替换的 categories + // 标成"已保存",下次 render 会让用户看到的默认模板与磁盘上写出的内容不一致。 + // cancelPendingSave 把当前 in-flight 代次 +1,让 await 醒来时检测到代次对不上 + // 放弃复位 dirty、放弃发「saved」事件。 + this.cancelPendingSave(); + this._suppressDirty = true; + try { + this.categories = createDefaultDoc(); + this._assignTaskIds(); + this.filePath = filePath; + // v3 起没有 TODAY 真实分类 —— 默认进入「当前」智能视图 + this.selectedSmartList = 'current'; + this.selectedCategoryName = null; + this.dirty = false; + this._clearDirtyTracking(); + // 同 loadFromContent:让底部路径 chip 跟着刷新("临时模式" 等场景) + this.emit('file-path', this.filePath); + this.emit('load', { categories: this.categories }); + this.emit('change'); + } finally { + this._suppressDirty = false; + } + // 同步 dirty=false 给监听者(理由同 loadFromContent) + this.emit('dirty', false); + } + + /** + * 序列化为 Markdown + */ + serialize() { + return writeMarkdown(this.categories); + } + + setFilePath(filePath) { + this.filePath = filePath; + this.emit('file-path', filePath); + } + + // ============================================ + // 选中 + // ============================================ + + selectCategory(name) { + if (this.selectedCategoryName === name && !this.selectedSmartList) return; + this.selectedCategoryName = name; + this.selectedSmartList = null; + this.emit('select', name); + this.emit('change'); + } + + /** + * 选中智能列表(虚拟聚合视图) + * @param {string} smartKey 'important' | 'allTasks' | 'completed' + */ + selectSmartList(smartKey) { + if (this.selectedSmartList === smartKey) return; + this.selectedSmartList = smartKey; + this.emit('select', this.selectedCategoryName); + this.emit('change'); + } + + /** + * 智能列表定义(图标 + 名称) + * + * v3 起「当前任务」也是智能视图 —— 由 @当前 标识驱动,可出现在任意子分类下。 + * 这意味着侧边栏同时存在 当前任务 / 重要任务 / 已完成任务 三个并列的智能视图, + * 以及「全部任务」容器作为唯一真实数据源。它们都从同一个任务池实时聚合: + * - 当前任务:text 末尾带 @当前 的任务 + * - 重要任务:important=true 的任务 + * - 已完成任务:completed=true 的任务 + * - 全部任务:容器本身的视图(点击进入聚合) + */ + static SMART_LISTS = [ + { key: 'current', name: '当前任务', desc: '标记为 [▶] 的任务' }, + { key: 'important', name: '重要任务', desc: '标记为 [⭐] 的任务' }, + { key: 'allTasks', name: '全部任务', desc: '所有任务(含全部子分类)' }, + { key: 'completed', name: '已完成任务', desc: '所有已完成的任务' } + ]; + + /** + * 获取当前选中的「虚拟视图」(实际分类 或 智能列表) + */ + getSelectedCategory() { + if (this.selectedSmartList) { + return this._buildSmartListView(this.selectedSmartList); + } + return this.categories.find(c => c.name === this.selectedCategoryName) || null; + } + + /** + * 构造一个智能列表视图(伪 category 对象) + */ + _buildSmartListView(key) { + const def = TaskStore.SMART_LISTS.find(s => s.key === key); + const name = def ? def.name : key; + // collectSmartListTasks 内部已是单次遍历填充四个数组;直接取对应字段即可, + // 不需要再过一层 _collectSmartListTasks(key) 包装(旧包装每次都重跑整个循环) + const all = this.collectSmartListTasks(); + const tasks = all[key] ?? []; + return { + name, + kind: CategoryKind.NORMAL, + isSmartList: true, + smartKey: key, + meta: def ? def.desc : '', + tasks + }; + } + + /** + * 一次遍历同时聚合所有智能列表的任务,避免 O(n×m) 渲染 + * 返回 { current: Task[], important: Task[], allTasks: Task[], completed: Task[] } + * - current: @当前 标识的任务(任意子分类下) + * - important: important=true 的任务 + * - allTasks: 全部子分类下的任务(v3 起不再含 TODAY,因为 TODAY 章节已迁出) + * - completed: [✓] 任务。v3.4 起所有已完成任务只活在一个地方 —— kind=COMPLETED 的 + * 「# 已完成任务」分类 —— 所以直接读这个分类,不再扫所有子分类。 + * 任务文本 / 勾选态与子分类下保持完全一致;侧边栏与 task-list 的 + * 「已完成任务」智能视图都靠它渲染。 + * + * 容器 / 回收站 / TODAY / 已完成任务(只走 completed 这条) 都被合理排除: + * - 容器(OTHER_TASKS)本身不存任务 + * - 回收站(TRASH)里的任务若还出现在三个聚合视图里,删除就等于没删 —— + * 用户会在「当前 / 重要 / 已完成 / 全部任务」里反复看见自己刚扔掉的东西 + * - TODAY 分类虽在 v3 起被迁移走,但加载过程中的瞬态可能存在;显式排除保证 + * 同一份任务在「全部任务」聚合里只出现一次(避免重复计数) + * - COMPLETED 已完成任务分类:任务**只**通过 `completed` 字段返回,不再参与 + * `current` / `important` / `allTasks` 三条聚合 —— 否则同一个已完成任务会同时 + * 出现在「已完成任务」「当前任务」「全部任务」三个智能视图里(current 任务被 + * 勾掉后语义不应该自动从「当前任务」聚合里消失)。 + * + * 注:v3.4.1 曾尝试把"已完成且标了 ⭐ / ▶"的任务也放进 current/important 聚合, + * 理由是"⭐/▶ 是任务属性、completed 是阶段性状态、两者正交"。这条语义被既有测试 + * check-md-ui-sync.mjs [13]("current 视图 2 条(不含已完成的)")明确否定 —— 用户 + * 完成一条任务后期待它从"当前任务"智能视图消失,而不是与未完成的并排出现。 + * + * v4.1 起 toggleTask 勾选时**不再**清 task.current(标记是任务级状态, + * 完成不该擦掉它),所以 "completed + current 共存" 现在是正常 UI 路径就会 + * 产生的数据形态,而不只是手编辑 Markdown 的边缘场景。此处的排除依然成立: + * 状态被保留在文件与「已完成任务」列表里,只是不参与「当前任务」聚合。 + */ + collectSmartListTasks() { + const current = []; + const important = []; + const allTasks = []; + const completed = []; + const completedCat = this.getCompletedCategory(); + for (const cat of this.categories) { + if (cat.kind === CategoryKind.OTHER_TASKS) continue; + if (cat.kind === CategoryKind.TRASH) continue; + if (cat.kind === CategoryKind.TODAY) continue; + if (cat.kind === CategoryKind.COMPLETED) continue; // v3.4:已完成的统一从 completedCat 走 + const isSub = !!cat.parentOtherTasks; + for (const task of cat.tasks) { + const enriched = { ...task, _fromCategory: cat.name }; + if (task.current) current.push(enriched); + if (task.important) important.push(enriched); + if (isSub) allTasks.push(enriched); + } + } + // 已完成任务:直接从 kind=COMPLETED 分类读,附带 _fromCategory 让 UI 知道 + // "这条任务是从哪来的"(用于右键"移动到分类"等场景,与智能列表 enriched 字段对称)。 + if (completedCat) { + for (const task of completedCat.tasks) { + completed.push({ ...task, _fromCategory: completedCat.name }); + } + } + return { current, important, allTasks, completed }; + } + + getCategory(name) { + return this.categories.find(c => c.name === name) || null; + } + + /** + * 按 kind 查找分类 —— 内部 helper。 + * + * 之前 6 处「`this.categories.find(c => c.kind === CategoryKind.X)`」散落在: + * - getOtherTasksContainer / getTrashCategory / getCompletedCategory(3 个 getter 内部) + * - _ensureBaseStructure / _migrateTodayCategory / _ensureOtherTasksContainer + * (3 处 inline find —— 容器存在性检查、TODAY 临时分类定位等) + * + * 抽到这里让 kind-find 的语义统一在一处:返回**第一个**匹配项(按 categories 数组顺序), + * 没找到返回 null。所有调用方都假定「第一个」就是「权威」—— 这与 parser 的"reuse first" + * (line 270-289 那段 h1 复用防御)以及 store 的 _consolidateX 系列(保留数组里第一份) + * 同源约定。任何重复都会在加载时被 _consolidateX 收敛,所以 find 到的就是稳定的那个。 + * + * 不要把 filter 也抽成 _filterByKind:_consolidateX 里的 filter 是「枚举所有重复」 + * 语义,与「find 第一个」意图不同,硬抽会让调用方误以为 helper 在去重。 + * + * @param {string} kind + * @returns {object|null} + */ + _findByKind(kind) { + return this.categories.find(c => c.kind === kind) || null; + } + + /** + * 获取「其他任务」容器 + */ + getOtherTasksContainer() { + return this._findByKind(CategoryKind.OTHER_TASKS); + } + + /** + * 获取回收站分类(可能尚未创建 —— _ensureBaseStructure 后必然存在) + */ + getTrashCategory() { + return this._findByKind(CategoryKind.TRASH); + } + + /** + * 获取「已完成任务」分类(可能尚未创建 —— _ensureBaseStructure 后必然存在) + * + * v3.4 起为真实分类(kind=COMPLETED),与「回收站」语义对称: + * - 删除 → 移到回收站 + * - 完成([✓])→ 移到已完成任务 + * + * 调用方不直接调 `_getOrCreateCompletedCategory`(拿不到就新建)—— 那样会让 + * 普通查询路径意外创建空分类。需要在加载/迁移流程里建好之后再用。 + */ + getCompletedCategory() { + return this._findByKind(CategoryKind.COMPLETED); + } + + /** + * 获取所有「子分类」(parentOtherTasks=true) + * 按当前 categories 数组顺序返回 + */ + getSubCategories() { + return this.categories.filter(c => c.parentOtherTasks); + } + + // ============================================ + // 分类操作 + // ============================================ + + /** + * 新增子分类到「其他任务」下 + * @returns {object|null} 新分类 + */ + addSubCategory(name) { + name = (name || '').trim(); + if (!name) return null; + // 拒绝包含 `|` 的分类名:TaskList._selKey 用 `|` 拼 (cat, id),批量浮栏的 + // _updateBatchBarState / _handleBatchAction / _showBatchMoveMenu 都用 key.split('|') + // 反查分类名与 id —— 名字里出现 `|` 会让 split 出来的 cat 截断、id 拿错, + // 选中/批量/移动全部静默错位。同样的约束在 renameCategory 里再守一次。 + if (name.includes('|')) return null; + // 拒绝包含 `[` / `]` 的分类名:markdown-writer 写 `[原分类:${originalCategory}]` + // 时不做转义,parser 的 `\[原分类:([^\]]+?)\]` 在名字含 `]` 时只截到第一个 + // `]` 就停,后面成了字面文本 —— 存盘 → 读回 → originalCategory 漂移甚至丢 + // 字段(v3.7+ 数据丢失 vector,见 markdown-parser.js inline marker 注释)。 + // 同时 `[` 在 markdown 里也是 # 标题的语法边界,混进分类名会让 heading 解析错位。 + // 同约束在 renameCategory 里再守一次。 + if (name.includes('[') || name.includes(']')) return null; + if (this.categories.some(c => c.name === name)) return null; + if (name === OTHER_TASKS_NAME) return null; + // 阻止把子分类建成 Today 类(保留名)—— 否则下次 _ensureBaseStructure 迁移时 + // 会把 kind 从 NORMAL 提升为 TODAY,破坏父级顺序与 parentOtherTasks 结构。 + // 「回收站」同理:重名会让删除的任务混进一个用户以为是普通分类的地方。 + // 「已完成任务」同理(v3.4 起是真实分类):重名会让 [✓] 自动移动的目标分类指错地方。 + const lower = name.toLowerCase().trim(); + if (TODAY_KEYS.includes(lower)) return null; + if (OTHER_TASKS_KEYS.includes(lower)) return null; + if (TRASH_KEYS.includes(lower)) return null; + if (COMPLETED_KEYS.includes(lower)) return null; + + const cat = this._createCategory(name, CategoryKind.NORMAL); + cat.parentOtherTasks = true; + + // 插入位置:「未分类」必须始终保持在子分类数组的末尾(_normalizeOrder 的内部约定, + // 也是 reorderSubCategory 的防线前提)。新分类若插到「未分类」之后,会把它挤到中间, + // 用户看到的就是「我建的新分类怎么跑到了兜底下面」—— 体验与 bug 一致。 + // 因此优先级: + // 1) 「未分类」已存在 → 新分类插在「未分类」之前 + // 2) 「未分类」不存在但容器在 → 插到容器之后、最后一个 parentOtherTasks 之后 + // 3) 都没有 → push 到末尾(极端兜底;正常流程走不到) + const uncategorizedIdx = this.categories.findIndex( + c => c.parentOtherTasks && c.name === UNCATEGORIZED_NAME + ); + if (uncategorizedIdx >= 0) { + this.categories.splice(uncategorizedIdx, 0, cat); + } else { + const containerIdx = this.categories.findIndex(c => c.kind === CategoryKind.OTHER_TASKS); + if (containerIdx < 0) { + this.categories.push(cat); + } else { + let insertIdx = containerIdx + 1; + while ( + insertIdx < this.categories.length && + this.categories[insertIdx].parentOtherTasks + ) { + insertIdx++; + } + this.categories.splice(insertIdx, 0, cat); + } + } + + this._markDirty({ categories: cat.name }); + this.emit('categories'); + this.emit('change'); + return cat; + } + + /** + * 兼容旧 API:addCategory 改为添加到「其他任务」下的子分类 + * (已弃用,建议直接使用 addSubCategory) + */ + addCategory(name) { + return this.addSubCategory(name); + } + + renameCategory(oldName, newName) { + newName = (newName || '').trim(); + if (!newName || oldName === newName) return false; + // 与 addSubCategory 同款防线:拒绝包含 `|` 的新名 —— 选中键 / 批量解析依赖 + // key.split('|') 的语义,名字里出现 `|` 会让"移动到本分类"按钮解析到错分类。 + // 这里旧名同样要校验:万一旧名里早就含 `|`,下面的 cat 操作就跨过这道防线, + // 直接静默放过更糟 —— 把已有「非法名字」的分类也卡住。 + // 同款挡 `[` / `]`:markdown-writer 不转义 [原分类:xxx] 里的 originalCategory, + // 名字含 `]` 会让 parser 截断字段,存盘 → 读回 → 数据漂移。详见 addSubCategory。 + const cat = this.getCategory(oldName); + if (!cat) return false; + if (cat.name.includes('|') || newName.includes('|')) return false; + if (cat.name.includes('[') || cat.name.includes(']') + || newName.includes('[') || newName.includes(']')) return false; + if (this.categories.some(c => c.name === newName)) return false; + + // Today / 全部任务 / 回收站 不允许重命名 + if (cat.kind === CategoryKind.TODAY) return false; + if (cat.kind === CategoryKind.OTHER_TASKS) return false; + if (cat.kind === CategoryKind.TRASH) return false; + + // 「未分类」是任务兜底 —— 名字在 store / parser / writer 里是魔法标识符 + // (_getOrCreateUncategorizedCategory / 迁移逻辑全部按 cat.name === '未分类' 定位)。 + // 改名会让兜底分类从数据模型里「消失」:下次删其它有任务的子分类时,store 找不到 + // 旧「未分类」,就凭空新建一个同名空分类,把释放出的任务丢进去,而用户原来那个被 + // 改名的旧分类就成了孤儿。两边不一致,数据完整性破坏。 + // —— 与 deleteCategory 里禁止删「未分类」的逻辑对称,这里禁止重命名它。 + if (cat.name === UNCATEGORIZED_NAME) return false; + + // 阻止把子分类改名成保留名(Today / 全部任务 / 回收站 / 已完成任务)。 + // 这种重命名会让它在原位变成特殊分类,破坏 categories 数组顺序和 parentOtherTasks 结构。 + const lower = newName.toLowerCase().trim(); + const isReserved = + TODAY_KEYS.includes(lower) || + OTHER_TASKS_KEYS.includes(lower) || + TRASH_KEYS.includes(lower) || + COMPLETED_KEYS.includes(lower); + if (isReserved) return false; + + cat.name = newName; + + if (this.selectedCategoryName === oldName) { + this.selectedCategoryName = newName; + } + + // 同步更新所有任务的 originalCategory 引用 —— COMPLETED 分类里任务的 + // originalCategory 标记指向「它打勾前所在的子分类」。如果只改 cat.name 而不同步更新 + // 这些引用,用户取消勾选时 _resolveRestoreTarget 找不到 newName,按「原分类丢失」 + // 走 picker → 每次取消勾选都弹分类选择器(噪音极大)。改名是幂等改名,跟随迁移。 + for (const c of this.categories) { + if (!Array.isArray(c.tasks)) continue; + for (const t of c.tasks) { + if (t && t.originalCategory === oldName) t.originalCategory = newName; + } + } + + this._markDirty({ categories: [oldName, newName] }); + this.emit('categories'); + this.emit('change'); + return true; + } + + /** + * 删除子分类 + * 被删分类下的任务不丢弃,全部迁移到「未分类」子分类(兜底分类,由 _ensureBaseStructure 保证存在)。 + * 「未分类」本身不可删除 —— 否则被释放的任务无处归位。 + */ + deleteCategory(name) { + const idx = this.categories.findIndex(c => c.name === name); + if (idx < 0) return false; + const cat = this.categories[idx]; + // 不允许删除 Today / 其他任务 / 回收站 / 非子分类 + if (cat.kind === CategoryKind.TODAY) return false; + if (cat.kind === CategoryKind.OTHER_TASKS) return false; + if (cat.kind === CategoryKind.TRASH) return false; + if (!cat.parentOtherTasks) return false; + // 「未分类」是删除时的任务兜底,删除它会让释放出的任务无处归位 —— 禁止 + if (cat.name === UNCATEGORIZED_NAME) return false; + + // 只在真的有任务要搬时才去找/建「未分类」—— + // 删除空分类不应该凭空造出一个兜底分类(那是纯粹的副作用噪音) + const movedCount = cat.tasks.length; + let fallback = null; + if (movedCount > 0) { + fallback = this._getOrCreateUncategorizedCategory(); + // 移交任务 —— 直接拼接数组,保留原顺序与任务对象(text/completed/important/id 全部原样带走) + fallback.tasks = fallback.tasks.concat(cat.tasks); + cat.tasks = []; + } + + // ⚠️ 关键:不能再用先前缓存的 idx。 + // _getOrCreateUncategorizedCategory 在「未分类」缺失时会在容器后插入新分类, + // 这会把原 idx 上的元素挤到 idx+1;如果我们仍按原 idx splice,就会切掉刚创建的「未分类」 + // —— 兜底分类脱离数组,任务在下次 serialize 时丢失。 + // 用对象引用重新定位目标,永远拿到「现在」的位置。 + const currentIdx = this.categories.indexOf(cat); + if (currentIdx < 0) { + // 极端兜底:cat 已不在数组里(不应发生,但避免后续流程误删其它分类)。 + return false; + } + this.categories.splice(currentIdx, 1); + + // 如果删除的是当前选中分类,把视图切到任务的新家「未分类」, + // 让用户直接看到任务并没有丢;没有搬任务时才退回「当前」智能视图 + // (v3 已不再有 TODAY 真实分类 —— 必须落到智能视图上,否则读到 undefined) + if (this.selectedCategoryName === name) { + if (fallback) { + this.selectedCategoryName = fallback.name; + } else { + this.selectedSmartList = 'current'; + this.selectedCategoryName = null; + } + this.emit('select', this.selectedCategoryName); + } + // 子分类被删 + 任务迁过去 —— 兜底分类与被删分类的归属都标脏 + const dirtyCats = [name]; + const dirtyTaskIds = []; + if (fallback) { + dirtyCats.push(fallback.name); + for (const t of fallback.tasks) dirtyTaskIds.push(t.id); + } + this._markDirty({ tasks: dirtyTaskIds, categories: dirtyCats }); + this.emit('categories'); + this.emit('change'); + return true; + } + + /** + * 获取「未分类」子分类;不存在则创建一个并插入到「全部任务」容器之后。 + * + * 两个必须守住的不变量: + * 1) 只能有一个「未分类」。重名分类会让 getCategory() 只认第一个, + * 后续 addTask/toggleTask 全部打到错误的对象上。所以这里按名字匹配, + * 顺带把 parentOtherTasks 补正,而不是「找不到带标记的就再建一个」。 + * 2) 子分类必须排在 OTHER_TASKS 容器之后。writeMarkdown 把 parentOtherTasks + * 的分类写成 `## X`,而 parseMarkdown 只在 `# 全部任务` 之后才认 `##` + * (insideOtherTasks 开关)。一个孤立的 `## 未分类` 不会让任务消失,但会更糟糕地 + * 静默改写归属:parser 跳过该 h2 时并不重置 currentCategory,于是下面的任务 + * 被并进上一个 h1(通常是「当前任务」)。已实测确认此行为。 + * 所以容器缺失时必须先把容器建出来,不能简单 push 到数组末尾。 + * + * 注意:本方法会修改 categories 数组顺序,调用方后续若按索引 splice 目标分类会失效 —— 必须用 indexOf 重新定位。 + */ + _getOrCreateUncategorizedCategory() { + // 不变量 1:按名字找(不强求 parentOtherTasks),找到就补正标记后复用 + const existing = this.categories.find( + c => c.name === UNCATEGORIZED_NAME && c.kind === CategoryKind.NORMAL + ); + if (existing) { + existing.parentOtherTasks = true; + return existing; + } + + const sub = this._createCategory(UNCATEGORIZED_NAME, CategoryKind.NORMAL); + sub.parentOtherTasks = true; + + // 不变量 2:确保容器存在,否则新分类会被序列化成孤立的 `##` 而在下次解析时丢失 + let oIdx = this.categories.findIndex(c => c.kind === CategoryKind.OTHER_TASKS); + if (oIdx < 0) { + const container = this._createCategory(OTHER_TASKS_NAME, CategoryKind.OTHER_TASKS); + const todayIdx = this.categories.findIndex(c => c.kind === CategoryKind.TODAY); + oIdx = todayIdx >= 0 ? todayIdx + 1 : this.categories.length; + this.categories.splice(oIdx, 0, container); + } + this.categories.splice(oIdx + 1, 0, sub); + return sub; + } + + /** + * 重新排序子分类(拖拽后) + * + * @param {number} fromIndex 拖动项在 getSubCategories() 中的下标 + * @param {number} toIndex 放置目标在 getSubCategories() 中的下标 + * @param {boolean} dropAbove true = 插到目标之前,false = 插到目标之后 + * + * 旧实现只接受 (from, to) 并在全局数组上做 splice + `if (globalTo > globalFrom) globalTo--` + * 的补偿,语义上等价于「永远插到目标之前」:向下拖一格时补偿刚好抵消位移 + * ([A,B,C] 把 A 拖到 B 上 → 仍是 [A,B,C]),子分类根本无法向下移动。 + * + * 现在改为先在子分类下标空间里算出结果顺序,再写回 categories 中原有的子分类槽位 —— + * 非子分类(当前任务 / 容器 / 回收站)的位置完全不动,因此不会破坏 + * _normalizeOrder() 维护的 `[当前任务, 容器, ...子分类, 回收站]` 不变量。 + */ + reorderSubCategory(fromIndex, toIndex, dropAbove = true) { + const subs = this.getSubCategories(); + if (fromIndex < 0 || fromIndex >= subs.length) return false; + if (toIndex < 0 || toIndex >= subs.length) return false; + + // 「未分类」是兜底分类 —— _normalizeOrder() 迁移/归位逻辑的内部约定里它排在子分类末尾。 + // 任何让它离开底部位置(被拖/被当成落点)的重排都会让兜底从此"漂"到中间位置, + // 直到下一次加载被 _normalizeOrder 拉回。用户看到的就是"我拖完它又跑回来",体验与 bug 一致。 + // 同时禁止它作为 drop target:插到它"前面"会让它身后夹一个非法位置(同理)。 + // 这里和 UI 层 pointerdown/_findDropTarget 是同一条防线的不同高度 —— 拦截任意调用入口。 + if (subs[fromIndex]?.name === UNCATEGORIZED_NAME) return false; + if (subs[toIndex]?.name === UNCATEGORIZED_NAME) return false; + + const moving = subs[fromIndex]; + const reordered = subs.slice(); + reordered.splice(fromIndex, 1); + + // 插入点在「移除拖动项之前」的下标空间里算出,移除后需要左移一位 + let insertAt = dropAbove ? toIndex : toIndex + 1; + if (fromIndex < insertAt) insertAt--; + insertAt = Math.max(0, Math.min(insertAt, reordered.length)); + reordered.splice(insertAt, 0, moving); + + // 顺序没变就不标脏,免得一次无效拖拽触发自动保存 + if (reordered.every((c, i) => c === subs[i])) return false; + + const slots = []; + this.categories.forEach((c, i) => { if (c.parentOtherTasks) slots.push(i); }); + slots.forEach((slot, i) => { this.categories[slot] = reordered[i]; }); + + // 子分类重排不影响具体任务 —— 标记所有重排过的分类名(仍走 structural 路径) + this._markDirty({ categories: reordered.map(c => c.name) }); + this.emit('categories'); + this.emit('change'); + return true; + } + + // ============================================ + // 任务操作 + // ============================================ + + addTask(categoryName, text) { + text = (text || '').trim(); + if (!text) return null; + + const cat = this.getCategory(categoryName); + if (!cat) return null; + // 容器本身不接受任务 + if (cat.kind === CategoryKind.OTHER_TASKS) return null; + // 回收站只接收「被删除的任务」,不能当普通分类往里新建 + if (cat.kind === CategoryKind.TRASH) return null; + // v3.4:「已完成任务」分类是 [✓] 自动收集的归宿,不允许手工新建 —— 用户若想 + // 标记完成,直接在源分类里勾选即可(toggleTask 会自动搬过来)。开放 addTask + // 会让用户绕过勾选机制,"提前预填"已完成任务,下次保存还可能被 writer 重写。 + if (cat.kind === CategoryKind.COMPLETED) return null; + + return this._appendTask(cat, text, false); + } + + /** + * 生成一条任务对象(含稳定 ID),不触发任何事件 / dirty。 + * _appendTask 与 importTasksToCategory 共用 —— 保证 ID 格式一致。 + * 不做 trim / 校验:调用方负责。 + * + * 可选地接受 important / current —— 让导入路径能直接把 + * `parseImportLine` 抽出的 inline 标记写进任务字段, + * 避免「先 _createTask(..., false) 再手动改 important / current」 + * 这么绕一圈。 + */ + _createTask(text, completed = false, { important = false, current = false, originalCategory = null } = {}) { + return { + id: 't_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7), + text, + completed: !!completed, + important: !!important, + current: !!current, + // v3.5+:勾选/拖入「已完成任务」时记录原分类名,取消勾选时直接归位。 + // 严格保留透传:传 null 就存 null(非空字符串才存),避免"忘了传"变成 '' 误判。 + originalCategory: originalCategory || null + }; + } + + /** + * 单条追加:构造 + 插入 + 触发 dirty + tasks/change 事件。 + * addTask 直接走这条路;importTasksToCategory 走批量路径(避免 N 次 emit)。 + */ + _appendTask(cat, text, completed = false) { + const task = this._createTask(text, completed); + this._appendTaskWithPosition(cat, task, 'newTask'); + this._markDirty({ tasks: task.id, categories: cat.name }); + this.emit('tasks', { categoryName: cat.name }); + this.emit('change'); + return task; + } + + /** + * 按用户偏好把单条任务插入到目标分类的任务数组里。 + * + * 用途:把「该用 push 还是 unshift」集中到一处,由 settingsStore.get(settingKey) 决定。 + * - kind='newTask' → 读 newTaskPosition (新增任务落点) + * - kind='completed' → 读 completedPosition (打勾任务搬到「已完成」时的落点) + * - kind='trash' → 读 trashPosition (删除任务搬到「回收站」时的落点) + * - kind='move' → 读 movePosition (右键/批量「移到分类」时的落点) + * + * 边界: + * - settingsStore 未注入(fixture / 老调用方)→ 等价于 DEFAULT_SETTINGS 默认值 'front', + * 保证「最新在最上」行为对所有 TaskStore 实例一致。 + * - 读取到的值不在白名单(理论上 settingsStore 不会放过脏值,但这里再守一道) + * → 回退到 'front',与 settings-store DEFAULT_SETTINGS 默认值对齐。 + * + * 显式恢复(restoreTask / _doRestoreMove / restoreCompletedTask)**不**走这里: + * 「把任务搬回来」是归位动作,不是新插入,落点由目标分类语义决定(push 到末尾), + * 强行套用 *Position 设置会让用户恢复一条已完成的任务时,跑 completedPosition='back' + * 的设置结果插到「已完成」末尾,与「刚刚完成的任务排在最新」的心智冲突。 + * + * @param {{tasks: Array}} cat - 目标分类 + * @param {object} task - 待插入任务对象 + * @param {'newTask'|'completed'|'trash'|'move'} kind + */ + _appendTaskWithPosition(cat, task, kind) { + const pos = this._getInsertPosition(kind); + if (pos === 'back') { + cat.tasks.push(task); + } else { + cat.tasks.unshift(task); + } + } + + /** + * 读取给定 kind 对应的插入位置偏好。 + * 单独的 helper 让 importTasksToCategory 这种"批量插入"的路径能一次拿到 pos + * 再决定整批按什么顺序插(push 整体 vs 倒序 unshift 整体),不需要每条都过 settingsStore.get。 + * + * @param {'newTask'|'completed'|'trash'|'move'|'import'} kind + * @returns {'front'|'back'|'order'} + */ + _getInsertPosition(kind) { + let settingKey; + if (kind === 'completed') settingKey = 'completedPosition'; + else if (kind === 'uncomplete') settingKey = 'uncompletePosition'; + else if (kind === 'trash') settingKey = 'trashPosition'; + else if (kind === 'restore') settingKey = 'restorePosition'; + else if (kind === 'move') settingKey = 'movePosition'; + else if (kind === 'import') settingKey = 'importPosition'; + else settingKey = 'newTaskPosition'; + if (this._settingsStore && typeof this._settingsStore.get === 'function') { + const raw = this._settingsStore.get(settingKey); + // importPosition 多一个 'order' 合法值('order' / 'front');其它 kind 只接受 'front' | 'back' + if (kind === 'import') { + if (raw === 'order' || raw === 'front') return raw; + } else { + if (raw === 'front' || raw === 'back') return raw; + } + } + // import 的默认值是 'order'(按入参顺序、前面的内容在前), + // 与其它 kind 的 'front' 默认不同 —— 因为导入场景下「按输入顺序」比 + // 「最后一行在最上」更符合用户直觉(旧版共用 newTaskPosition='front' + // 导致最后一行跑到最前,让人困惑,故单独切默认)。 + return kind === 'import' ? 'order' : 'front'; + } + + /** + * 从粘贴文本批量导入任务。 + * + * 解析纯函数独立导出(见 parseImportLine)—— 导入对话框实时预览复用同一份逻辑, + * 避免「预览说能导入、真正点确认却少了一行」这种诡异不一致。 + * + * 性能:批量路径只在末尾触发一次 _markDirty + emit('tasks') + emit('change'), + * 而不是「每条任务一次」。导入 1000 条任务不再触发 1000 次 UI 重渲染。 + */ + importTasksToCategory(categoryName, lines, options = {}) { + const cat = this.getCategory(categoryName); + if (!cat) return { ok: false, reason: 'INVALID_CATEGORY' }; + // 容器 / 回收站 / 已完成任务 拒绝:与 addTask 守卫一致,避免绕过 toggleTask 的语义 + if (cat.kind === CategoryKind.OTHER_TASKS) return { ok: false, reason: 'INVALID_CATEGORY' }; + if (cat.kind === CategoryKind.TRASH) return { ok: false, reason: 'INVALID_CATEGORY' }; + if (cat.kind === CategoryKind.COMPLETED) return { ok: false, reason: 'INVALID_CATEGORY' }; + + const stripLeadingBullets = options.stripLeadingBullets !== false; // 默认 true + // importPosition 可由调用方覆盖 settingsStore —— 导入对话框需要在「关掉对话框」 + // 之前把用户当前的选择当作这次操作的依据,避免 settingsStore 持久化失败时 + // 拿不到值。覆盖优先级:options.importPosition(合法值)> settingsStore > 默认 'order'。 + let importPos = 'order'; + if (options.importPosition && VALID_IMPORT_POSITION.has(options.importPosition)) { + importPos = options.importPosition; + } else { + importPos = this._getInsertPosition('import'); + } + const newTasks = []; + let skipped = 0; + + for (const rawLine of lines) { + const parsed = parseImportLine(rawLine, { stripLeadingBullets }); + if (!parsed) { + // 空行 / 仅空白 / 解析后无文本 + skipped++; + continue; + } + // 把 parseImportLine 抽出的 important / current / originalCategory 直接落到 task 字段 + // (与 parseMarkdown 路径同语义 —— 不在导入时翻译这些 inline 标记, + // 用户数据就会在保存后被悄悄改写) + newTasks.push(this._createTask(parsed.text, parsed.completed, { + important: parsed.important, + current: parsed.current, + originalCategory: parsed.originalCategory + })); + } + + // 0 条有效行:跳过所有事件 / dirty —— 调用方可能取消预览前提前算了计数, + // 这里绝不能因为「试了一下又关掉」就脏文档。 + if (newTasks.length === 0) { + return { ok: true, added: 0, skipped }; + } + + // v3.4 数据归属不变量:completed=true 的任务**只能**活在 kind=COMPLETED 分类。 + // 导入 `- [✓]` 进普通子分类时立即搬到「# 已完成任务」分类,避免源分类出现 + // 「看起来没完成(写在「工作」里)但实际上是已完成态」的数据漂移 —— 与 + // toggleTask 路径完全同语义。 + // + // 先分流:completed=true 的直接进已完成分类(带 originalCategory),其余进源分类。 + // 关键:**不能**先全部 push 到 cat.tasks 再从中抠 completedTasks —— 那会让已完成 + // 任务同时出现在源分类和已完成分类,违反不变量(UI 上能看到两份)。 + const completedTasks = []; + const pendingTasks = []; + for (const t of newTasks) { + if (t.completed) { + // 源分类是子分类才写 originalCategory —— 与 toggleTask 一致: + // 子分类有名字语义;其他分类(理论上不会发生,addTask 守卫拦了)跳过。 + if (cat.parentOtherTasks && !t.originalCategory) { + t.originalCategory = cat.name; + } + completedTasks.push(t); + } else { + pendingTasks.push(t); + } + } + // 批量路径下走 importPosition 偏好 —— 与 newTaskPosition / completedPosition + // 完全解耦。用户在导入对话框内单独调整,UI 仅在导入窗口暴露该选项。 + // 顺序约定(按用户预期): + // - order 模式(默认):按入参顺序插到目标位置的最前面 —— 第一行排到最上, + // 与用户「按行号查看」的直觉一致。实现:unshift 整批(Array.unshift 按 + // 顺序在头部依次压入,等价于「把整批原样前置」)。 + // - front 模式:入参倒序插到目标位置的最前面 —— 最后一行落在最上面。 + // 实现:依次遍历入参 + 每条 unshift,最后一条最后 unshift → 最终在最前。 + // 历史上曾有 'back' 模式(追加到末尾),与全局 newTaskPosition='back' 行为 + // 完全重叠 —— 「追加到末尾」属于位置语义,归全局设置管;导入对话框只管 + // 「顺序」。所以此处只剩 order / front 两路。 + // importPos 已在外层(options 覆盖 / settingsStore 兜底)解析好,此处直接复用。 + if (pendingTasks.length > 0) { + if (importPos === 'order') { + // unshift 整批:line1 先入头部、lineN 最后入头部, + // 最终 cat.tasks[0]=line1, ..., cat.tasks[N-1]=lineN, 之后是原有任务。 + cat.tasks.unshift(...pendingTasks); + } else { + // 'front'(兜底也走这条):依次 unshift,lineN 最后被 unshift 到头部 → lineN 在最上面。 + for (let i = 0; i < pendingTasks.length; i++) { + cat.tasks.unshift(pendingTasks[i]); + } + } + } + + let completedCatName = null; + if (completedTasks.length > 0) { + const completedCat = this._getOrCreateCompletedCategory(); + // 已完成分支与 pending 分支共用同一个 importPos —— 导入是一笔连贯操作, + // 已完成任务在「# 已完成任务」分类里的相对顺序应与 pending 一致(都按用户 + // 在对话框里选的模式)。避免「pending 按 order、completed 按 front」的诡异 + // 行为。 + if (importPos === 'order') { + completedCat.tasks.unshift(...completedTasks); + } else { + for (let i = 0; i < completedTasks.length; i++) { + completedCat.tasks.unshift(completedTasks[i]); + } + } + completedCatName = completedCat.name; + } + + // 标脏的分类要包含已完成的归宿分类 —— 涉及两边 + const dirtyCats = [cat.name]; + if (completedCatName) dirtyCats.push(completedCatName); + this._markDirty({ tasks: newTasks.map(t => t.id), categories: dirtyCats }); + this.emit('tasks', { categoryName: cat.name }); + if (completedCatName) this.emit('tasks', { categoryName: completedCatName }); + this.emit('change'); + return { ok: true, added: newTasks.length, skipped }; + } + + toggleTask(categoryName, taskId) { + const cat = this.getCategory(categoryName); + if (!cat) return { ok: false }; + // 回收站允许切换勾选(恢复前常想先把勾清掉或勾上)。 + // 但 ⭐ / ⚪ 这类元数据仍拒绝改 —— 那属于跨分类的状态,挪出回收站再切。 + // 彻底删除仍只能手工编辑 Markdown。 + const idx = cat.tasks.findIndex(t => t.id === taskId); + if (idx < 0) return { ok: false }; + const task = cat.tasks[idx]; + const nowCompleted = !task.completed; + + // 回收站是「删除前的暂存区」,勾选/取消勾选应原地切换完成态: + // - 用户预期:在回收站里打勾/清勾,任务**仍留在回收站**。 + // 旧实现把「回收站里打勾」搬到「已完成任务」,违背"回收站是兜底区" + // 的心智模型(用户可能只是想标个完成、回头手动清掉)。 + // - 数据归属:与 v3.4 不变量兼容 —— 回收站是 TRASH 的例外豁免, + // _consolidateCompleted 与 markdown-writer 的 showOriginalCategory 守卫 + // 都已承认「回收站里允许 completed=true 任务」。这条路径保持对称: + // toggle 只动 bool、不动归属。 + // - originalCategory 保留:从「已完成」删到回收站的任务仍带原分类标记 + // (见 check-original-category [15]),在回收站切换完成态时不应清掉—— + // 用户恢复时还要靠它归位。 + if (cat.kind === CategoryKind.TRASH) { + if (task.completed === nowCompleted) return { ok: true }; + task.completed = nowCompleted; + this._markDirty({ tasks: task.id, categories: categoryName }); + this.emit('tasks', { categoryName }); + this.emit('change'); + return { ok: true }; + } + + // v3.4:勾选态切换会触发**跨分类移动** —— 数据归属必须保持一致: + // - 切到已完成([✓]):从当前分类搬到「# 已完成任务」(kind=COMPLETED) + // - 切回未完成([ ]):从「# 已完成任务」搬到「未分类」(兜底子分类) + // + // 为什么不在原分类就地改 completed=true? + // - 数据归属与状态要一致:completed=true 的任务就该活在 kind=COMPLETED 分类里。 + // 留在源分类会导致后续 clearCompleted / collectSmartListTasks / _normalizeOrder + // 都要分别写"如果 task.completed=true 但 cat.kind !== COMPLETED 就当已完成任务"的 + // 特判,分支越多越容易出错。 + // - UI 期望:用户打勾后任务**视觉上**从原分类消失、出现在「已完成任务」里。 + // 只改 bool 不移动 → UI 看起来任务还在原分类但被打了勾,与"打勾即完成"的直觉相悖。 + // + // v3.5+:进入完成态时记录原分类(task.originalCategory = categoryName), + // 退出完成态时按 _resolveRestoreTarget 解析目标: + // - 原分类还在 → 直接归位(用户最关心的体验) + // - 原分类丢失(删除/改名) → 弹分类选择器(见返回的 restoreHint) + // - 没有原分类信息(旧文件 / 兜底) → 直接去「未分类」,不弹窗(避免批量噪音) + if (nowCompleted && cat.kind !== CategoryKind.COMPLETED) { + const completedCat = this._getOrCreateCompletedCategory(); + // splice 在插入之前:避免 cat === completedCat(极端情况)时引用失效 + cat.tasks.splice(idx, 1); + // 落点由 completedPosition 决定 —— 默认 'front'(最新完成的最上面), + // 也支持 'back'(追加到「已完成任务」末尾,按时间倒序沉底)。 + this._appendTaskWithPosition(completedCat, task, 'completed'); + task.completed = true; + // 记录原分类:兜底(修复 H3)—— 任何 NORMAL 分类都记,不只是挂容器的子分类。 + // 旧条件 `cat.parentOtherTasks` 漏掉以下场景: + // - 顶级 NORMAL 分类(来自 H2 修复后的 orphan h2 提升,或旧文件残留) + // - 任何还没挂上 OTHER_TASKS 容器的普通分类 + // 这些场景下 toggle 后 originalCategory 为 null,取消勾选时无归位依据, + // 走 fallback 落到「未分类」而非原归属。 + // 跳过特殊分类(容器 / 已完成 / 回收站)作为原分类的语义: + // - OTHER_TASKS 是容器,本身没任务 + // - TRASH 是删除兜底,不该成为「原分类」的语义来源 + // - COMPLETED 自身已经在做了,重复记录没意义 + // - 其它 kind(未知)也跳过,避免脏数据 + if (cat.kind === CategoryKind.NORMAL && cat.name) { + task.originalCategory = categoryName; + } + // v4.1:勾选**不再**清 current(撤销早先的 H4 处理)。 + // 理由(用户明确要求):[▶] 与 [⭐] 一样是任务级标记,不该被"完成"这个 + // 阶段性动作静默擦掉 —— 否则勾错一下再取消勾选,原来的当前标记就没了, + // 用户得重新标一次。现在 completed 与 current / important 完全正交: + // - 文件里保留 `- [✓] [▶] …`(writer 一直支持输出) + // - 「已完成任务」列表里 ▶ 按钮照常显示 / 可切换 + // - 取消勾选归位后,当前标记原样带回去 + // 「当前任务」智能视图仍然排除 kind=COMPLETED 的任务(完成 = 归档, + // 见 collectSmartListTasks 的注释)—— 状态被保留,只是不再参与聚合。 + // 批量勾选(batchToggleCompleted)本来就没清 current,这里改完两条路径一致。 + this._markDirty({ tasks: task.id, categories: [categoryName, completedCat.name] }); + this.emit('tasks', { categoryName }); + this.emit('tasks', { categoryName: completedCat.name }); + this.emit('categories'); + this.emit('change'); + return { ok: true }; + } + if (!nowCompleted && cat.kind === CategoryKind.COMPLETED) { + // v3.5+:解析恢复目标 —— 原分类还在就回去,丢了就让 UI 弹窗,没有记录就走「未分类」 + const resolve = this._resolveRestoreTarget(task); + + // 'picker' 路径:**不动任务**。UI 拿到 hint 后调 store.restoreCompletedTask, + // 由它按用户选择的目标分类搬走任务。先在 toggleTask 里搬走再让 restoreCompletedTask + // 找会找不到(已完成分类里已经被 splice 了),所以这里必须有条件地分支。 + if (resolve.kind === 'picker') { + // v3.7+ 修复 H6:picker 路径加超时。旧行为是无限循环: + // - UI 拿到 hint 弹 picker → 用户关掉 dialog → 任务卡在 COMPLETED 但 completed=false + // - 用户再次 toggle → 又走 picker → 永远不归位 + // 现在记每个 task 的 picker 次数,超过 _PICKER_ATTEMPT_LIMIT(默认 3)自动降级为 + // fallback(强制走「未分类」),避免脏状态累积。 + const taskKey = task.id || `${categoryName}::${idx}`; + const prevAttempts = this._pickerAttempts.get(taskKey) || 0; + const nextAttempts = prevAttempts + 1; + if (nextAttempts > this._PICKER_ATTEMPT_LIMIT) { + // 降级为 fallback:把任务搬到「未分类」,清掉 originalCategory + this._pickerAttempts.delete(taskKey); + if (typeof console !== 'undefined' && console.warn) { + console.warn( + `[task-store] picker 路径超过阈值 (${this._PICKER_ATTEMPT_LIMIT}),` + + `task=${taskKey} 原分类=${resolve.missing} 强制降级为 fallback` + ); + } + const fallbackTarget = this._getOrCreateUncategorizedCategory(); + cat.tasks.splice(idx, 1); + // picker 强制降级也是「取消勾选」动作 → 读 uncompletePosition。 + // 与 category / fallback 三分支统一行为。 + this._appendTaskWithPosition(fallbackTarget, task, 'uncomplete'); + task.completed = false; + task.originalCategory = null; + this._markDirty({ tasks: task.id, categories: [categoryName, fallbackTarget.name] }); + this.emit('tasks', { categoryName }); + this.emit('tasks', { categoryName: fallbackTarget.name }); + this.emit('categories'); + this.emit('change'); + return { + ok: true, + restoreHint: { kind: 'fallback', targetName: fallbackTarget.name, forceDowngrade: true } + }; + } + this._pickerAttempts.set(taskKey, nextAttempts); + // task 仍在已完成分类里,状态也不变 —— 等 UI 决定后再走 restoreCompletedTask + return { + ok: true, + restoreHint: { kind: 'picker', missing: resolve.missing } + }; + } + + // v4+ 修复:resolve 不再返回 target(见 _resolveRestoreTarget 注释), + // 根据 kind 现场解析。fallback 是「未分类」、category 是 resolve.target(已查到)。 + const target = resolve.kind === 'fallback' + ? this._getOrCreateUncategorizedCategory() + : resolve.target; + cat.tasks.splice(idx, 1); + // 落点由 uncompletePosition 决定(默认 'front'):取消勾选相当于「重新激活」, + // 与新增任务「最新在最上」的对称心智能保持一致。切到 'back' 时落到末尾, + // 与原 push 语义兼容(用户在恢复路径上更可能有「按完成顺序排」的偏好)。 + this._appendTaskWithPosition(target, task, 'uncomplete'); + task.completed = false; + // v3.5.1:恢复后清掉 originalCategory。任务已回到原分类,标记已完成使命: + // - 避免文件里出现 `[原分类:工作] 写周报`(任务本身就在「工作」里,标记是噪音) + // - 用户再次勾选时,会被写入新的原分类(基于当下的所在分类),不影响功能 + task.originalCategory = null; + this._markDirty({ tasks: task.id, categories: [categoryName, target.name] }); + this.emit('tasks', { categoryName }); + this.emit('tasks', { categoryName: target.name }); + this.emit('categories'); + this.emit('change'); + // v3.5+:把解析结果回传给 UI('category' / 'fallback' 都是已就位,UI 无需再处理) + return { + ok: true, + restoreHint: { kind: resolve.kind, targetName: target.name } + }; + } + + // 同分类内切换(极端兜底,正常流程走不到): + // - 已完成分类里 [✓] → [✓](再次 toggle):cat.kind === COMPLETED 且 nowCompleted=true + // 此时第一条 if 已被 cat.kind === COMPLETED 守门拦住 —— 不进这里 + // - 普通子分类里 [ ] → [ ](再次 toggle):nowCompleted=false 且不在已完成分类 + // 此时第二条 if 同样不进 + // 走到这里意味着同一分类内部 toggle 一次 —— 旧实现的行为,无需移动。 + task.completed = nowCompleted; + + this._markDirty({ tasks: task.id, categories: categoryName }); + this.emit('tasks', { categoryName }); + this.emit('change'); + return { ok: true }; + } + + updateTaskText(categoryName, taskId, newText) { + newText = (newText || '').trim(); + if (!newText) return false; + const cat = this.getCategory(categoryName); + if (!cat) return false; + // 回收站里的任务允许改文字(恢复前常想先修个错别字), + // 但 ⭐ / ⚪ 这类元数据仍拒绝改动 —— 那属于跨分类的状态,挪出回收站再切 + // (_followTrashIfEmptied 等清理逻辑才能正确运行)。彻底删除仍只能手工编辑 Markdown。 + const task = cat.tasks.find(t => t.id === taskId); + if (!task) return false; + if (task.text === newText) return false; + task.text = newText; + this._markDirty({ tasks: task.id, categories: categoryName }); + this.emit('tasks', { categoryName }); + this.emit('change'); + return true; + } + + /** + * 更新任务的元数据 + * + * ⭐ / ▶(important / current)这类标识属于「任务级状态」而非「分类归属」: + * 在回收站 / 已完成任务任务里改它们不会破坏 v3.4 的「completed=true 只活 + * 在 kind=COMPLETED」不变量 —— toggleTask 那种"勾选即移动"的跨分类转移 + * 在 updateTaskMeta 这条路径上不发生。所以这里**不再**因为 cat.kind === TRASH + * 一刀切拒绝:用户在回收站里也能给任务打 / 取消 ⭐ / ▶(常见场景:恢复前 + * 想顺手标个当前,避免回头再切一次;又或者一个重要的「已完成任务」在 + * 「已完成」里也能重新标记当前)。 + * + * 唯一仍然收紧的:不能把 trash 任务改成 `completed` 之外(这里没有这个 + * 字段,保持不变);回收站里的彻底删除仍只能手工编辑 Markdown —— 这条 + * 由 deleteTask 自己守住(已在进入 trash 之前拦)。 + */ + updateTaskMeta(categoryName, taskId, meta) { + const cat = this.getCategory(categoryName); + if (!cat) return false; + const task = cat.tasks.find(t => t.id === taskId); + if (!task) return false; + let changed = false; + + if ('important' in meta && !!meta.important !== !!task.important) { + task.important = !!meta.important; + changed = true; + } + + // v3 起:与 important 同款处理,@当前 标识的 toggle 也走这条路 + if ('current' in meta && !!meta.current !== !!task.current) { + task.current = !!meta.current; + changed = true; + } + + if (!changed) return false; + this._markDirty({ tasks: task.id, categories: categoryName }); + this.emit('tasks', { categoryName }); + this.emit('change'); + return true; + } + + /** + * 删除任务 —— 实际是「移到回收站」,任务对象原样保留(text/completed/important 全带走)。 + * + * 数据安全的核心约定:软件里没有任何入口能真正丢弃一条任务。 + * 回收站里的任务只能由用户手工编辑 Markdown 删除,所以这里对 + * kind=TRASH 的来源直接拒绝 —— 否则「删除回收站里的任务」就成了不可逆操作。 + * + * v3.5.2+:进 trash 时同步记一份原分类,让回收站学「已完成任务」的逻辑: + * - 来源是真实子分类(kind=NORMAL)且还没有 originalCategory → 记下当前所在分类名 + * - 来源是「已完成任务」(kind=COMPLETED) → originalCategory 已经在 toggle 时 + * 记过(指它"打勾前的子分类"),**不重写**:completed 的"原"与 trash 的"原" + * 指向同一处,不动它意味着万一将来取消勾选归位 + 再次删除两条路径都能各自 + * 找到一致的来源。 + * - 来源是 OTHER_TASKS / TRASH:guard 拦在前面,不会到这里。 + * + * 与「已完成任务」write-once 的语义对称:originalCategory 一旦在 toggle 时记下, + * 后续整条任务生命周期里只在「恢复」/「移出特殊分类」时清空;trash 这边只补登 + * "第一次进 trash 时"这一刻的快照。 + * + * @returns {boolean} 是否发生了移动 + */ + deleteTask(categoryName, taskId) { + const cat = this.getCategory(categoryName); + if (!cat) return false; + // 回收站内不允许再删除(彻底删除只能手工编辑文件) + if (cat.kind === CategoryKind.TRASH) return false; + const idx = cat.tasks.findIndex(t => t.id === taskId); + if (idx < 0) return false; + + const [task] = cat.tasks.splice(idx, 1); + // v3.5.2+:进 trash 时记原分类。详见函数头注释。COMPLETED 来源任务原本就有 + // originalCategory(toggle 时记的),这里的"无则补登"既不会覆盖它的 + // 已完成"原"语义,也能兜住普通子分类 → 直接删到 trash 的常见路径。 + if (cat.kind === CategoryKind.NORMAL && !task.originalCategory) { + task.originalCategory = categoryName; + } + const trash = this._getOrCreateTrashCategory(); + // 落点由 trashPosition 决定 —— 默认 'front'(最近删除的排在最上面,找回时不用翻到底部), + // 也支持 'back'(追加到「回收站」末尾)。两条插入都走 _appendTaskWithPosition 集中管理。 + this._appendTaskWithPosition(trash, task, 'trash'); + + this._markDirty({ tasks: task.id, categories: [categoryName, trash.name] }); + this.emit('tasks', { categoryName }); + this.emit('tasks', { categoryName: trash.name }); + this.emit('categories'); // 回收站计数变了,侧边栏要刷新 + this.emit('change'); + return true; + } + + /** + * v3.5+:解析已完成任务取消勾选时的恢复目标分类。 + * + * 判定优先级: + * 1. 任务带 originalCategory 且同名子分类仍存在 → 直接归位(用户最关心的体验) + * 2. 任务带 originalCategory 但子分类丢失(删除 / 改名) → + * 返回 'picker',由 UI 弹分类选择器(与回收站恢复的交互对齐) + * 3. 任务没有 originalCategory(很旧的文件 / 兜底) → + * 返回 'fallback',目标为「未分类」;批量场景下走 fallback 不弹窗,避免噪音 + * + * 注意:所有 kind=COMPLETED 分类本身排除 —— 即便有人手敲了一个「# 已完成任务」 + * 之外的 kind=COMPLETED,也绝不能作为恢复目标(破坏数据归属不变量)。 + * + * @param {{ originalCategory?: string|null }} task + * @returns {{ kind: 'category'|'picker'|'fallback', target: Category, missing?: string }} + */ + _resolveRestoreTarget(task) { + const original = task.originalCategory; + if (!original) { + // v4+ 修复:旧实现这里调 _getOrCreateUncategorizedCategory(),会按需新建 + // 「未分类」分类、push 进 this.categories、emit 'categories'。但调用方如果拿到 + // resolve 后发现是 picker 路径(toggleTask / restoreTask),任务根本不动 —— 用户 + // 在 UI 里点 Esc 取消分类选择器,store 已经替用户把「未分类」建好了,sidebar + // 多出一个分类图标,状态和 UI 意图不一致。 + // 修正:纯函数化。返回 {kind, missing?, target?} 三种字段里只暴露 kind 和缺失名, + // target 不再在这里解析;调用方在确认要落地(fallback 走「未分类」、picker + // 走 restoreCompletedTask 显式分支)时再调 _getOrCreateUncategorizedCategory() / + // _getOrCreateCompletedCategory()。这样 picker hint 路径不再有副作用。 + return { kind: 'fallback' }; + } + // 在真实子分类里按名字找 —— parentOtherTasks=true 且不是已完成分类 + const found = this.categories.find( + c => c.parentOtherTasks && + c.kind !== CategoryKind.COMPLETED && + c.name === original + ); + if (found) { + return { kind: 'category', target: found }; + } + // 找不到 —— 让 UI 弹窗;不解析 target(picker 路径不会直接落地,副作用留着无意义) + return { + kind: 'picker', + missing: original + }; + } + + /** + * v3.5+:从「已完成任务」分类里挑一条任务,按用户指定的目标分类恢复。 + * + * 与 restoreTask(从回收站恢复)的差别: + * - restoreTask 是 TRASH → 子分类,固定走 toggleTask 的归属不变量守卫 + * - restoreCompletedTask 是 COMPLETED → 子分类,专为「取消勾选时原分类丢了」设计 + * 目标分类由用户从 picker 选出来,不需要再走 _resolveRestoreTarget + * + * 调用方(UI):在 toggleTask 返回 restoreHint.kind === 'picker' 时调本方法。 + * + * @param {string} taskId + * @param {string} toCategoryName + * @returns {boolean} + */ + restoreCompletedTask(taskId, toCategoryName) { + const completedCat = this.getCompletedCategory(); + if (!completedCat) return false; + const idx = completedCat.tasks.findIndex(t => t.id === taskId); + if (idx < 0) return false; + + // 目标分类:必须是真实子分类(parentOtherTasks)才接 —— 容器 / 回收站 / 已完成 + // 全部排除。找不到或无效则兜底到「未分类」,但**不**弹窗(restoreCompletedTask 是 + // UI 已经决定好目标后的执行步骤)。 + let target = null; + if (toCategoryName) { + const found = this.categories.find( + c => c.parentOtherTasks && c.name === toCategoryName + ); + if (found) target = found; + } + if (!target) { + target = this._getOrCreateUncategorizedCategory(); + } + + // 委托给 _doRestoreMove 统一处理:splice 出源 / 写入目标 / 清 originalCategory / + // 触发事件 —— 与 toggleTask 取消勾选归位(category / fallback / picker 强制降级) + // 共用同一条「读 uncompletePosition」入口,保持「任务重新激活」的语义统一。 + // 返回 boolean(而不是 restoreTask 的对象)以保留本方法的旧 contract,UI 调用方不变。 + const result = this._doRestoreMove(completedCat.tasks[idx], idx, target, completedCat, { kind: 'uncomplete' }); + return result.ok === true; + } + + /** + * 从回收站恢复任务到指定分类(默认回到任务上记的「原分类」;丢回原路径时 + * 走与「已完成任务」取消勾选同款 picker 语义,包括 completed 任务)。 + * + * v3 起没有「当前任务」真实分类,历史的默认行为是回「未分类」 —— + * 到了 v3.5.2,回收站学了「已完成任务」的逻辑:进入 trash 时已记下原分类 + * (参见 deleteTask / batchDeleteTasks 头部注释),恢复时优先按之归位, + * 这样用户在「工作」里删一条 → 回收站 Enter 默认就能回家。 + * + * v3.4 起:恢复的「目标归属」与「完成态」必须同步 —— 任务如果 completed=true, + * 恢复到普通子分类会破坏"已完成任务只活在 kind=COMPLETED"的不变量。所以 + * completed=true 的任务自动改投到「# 已完成任务」分类;用户指定的 toCategoryName + * 在这种情况下被忽略 —— 与 v3.5.1 之前一致。completed 任务到了「已完成任务」 + * 仍然保留 originalCategory,下一次取消勾选时按之归位。 + * + * 调用语义(v3.5.2+;与 toggleTask 对齐): + * - 显式 toCategoryName:用用户选的 —— 落回 picker 走这条路径 + * - 没传 toCategoryName:走 _resolveRestoreTarget 同款三分支 + * category → 直接归位,返回 { ok:true, restoreHint:{kind:'category', targetName} } + * picker → **不动任务**,返回 picker hint 让 UI 弹分类选择器。 + * 用户选完后再调 restoreTask(taskId, picked) 完成移动 + * (对称于 toggleTask 的 picker → restoreCompletedTask 链路) + * fallback → 落到「未分类」,返回 { ok:true, restoreHint:{kind:'fallback'} } + * + * @param {string} taskId - 任务 id + * @param {string|null} [toCategoryName] - 目标分类名称;省略则按原分类解析 + * @returns {{ ok: boolean, restored?: boolean, restoreHint?: { kind: string, targetName?: string, missing?: string } }} + */ + restoreTask(taskId, toCategoryName = null) { + const trash = this.getTrashCategory(); + if (!trash) return { ok: false }; + const idx = trash.tasks.findIndex(t => t.id === taskId); + if (idx < 0) return { ok: false }; + + const taskSnapshot = trash.tasks[idx]; + + // 1) completed 任务:无视 toCategoryName,永远重投「已完成任务」分类。 + // 保留 task.originalCategory:将来再取消勾选要按它归位。 + if (taskSnapshot.completed) { + const completedCat = this._getOrCreateCompletedCategory(); + // 重投「已完成」不读 *Position —— 这是「任务还在存档区但换了入口」语义, + // 真正的「恢复」语义是回到原分类(uncompletePosition)。 + return this._doRestoreMove(taskSnapshot, idx, completedCat, trash, { kind: 'uncomplete' }); + } + + // 2) 显式 toCategoryName:用户从菜单 / picker 选了具体目标 —— 直接走。 + // 与原 contract 对齐:传无效名(旧名 / 容器 / 回收站)**返回 false 而不静默** + // 兜底 —— 这是给 caller 的明确信号(说明 caller 在传一个不被接受的分类)。 + // picker 路径里 UI 也是先确认 picked 名字合法再调回来,不会触发这条失败。 + // 默认恢复(无 toCategoryName)才走 _resolveRestoreTarget 的兜底。 + if (toCategoryName) { + const explicit = this.getCategory(toCategoryName); + const isAcceptable = explicit && + explicit.kind !== CategoryKind.OTHER_TASKS && + explicit.kind !== CategoryKind.TRASH; + if (!isAcceptable) { + // 任务仍在 trash 里;返回 { ok: false } 让 caller 知道。 + return { ok: false }; + } + // 显式 restore:读 restorePosition(默认 front) + return this._doRestoreMove(taskSnapshot, idx, explicit, trash, { kind: 'restore' }); + } + + // 3) 默认恢复 —— 与 _resolveRestoreTarget 共用同款三分支: + // - category (原分类还在) → 归位 + // - picker (原分类丢了) → 不动任务,让 UI 弹选 + // - fallback (无 originalCategory) → 落到「未分类」,与 v3.5.1 前的默认行为兼容 + const resolve = this._resolveRestoreTarget(taskSnapshot); + if (resolve.kind === 'picker') { + // task 仍在 trash 里等 UI 决定走哪 —— 与 toggleTask 的 picker hint 完全对称, + // UI 弹完菜单后调 restoreTask(taskId, picked) 走路径 2 完成移动。 + return { + ok: true, + restored: false, + restoreHint: { kind: 'picker', missing: resolve.missing } + }; + } + // v4+ 修复:resolve 不再返回 target。fallback 现场解析(picker 已早返回), + // category 直接用 resolve.target(_resolveRestoreTarget 已查到原分类对象)。 + const restoreTarget = resolve.kind === 'fallback' + ? this._getOrCreateUncategorizedCategory() + : resolve.target; + // category / fallback 走归位落点,读 restorePosition(默认 front) + return this._doRestoreMove( + taskSnapshot, idx, restoreTarget, trash, + { kind: 'restore', restoreHint: { kind: resolve.kind, targetName: restoreTarget.name } } + ); + } + + /** + * v3.5.2+:restoreTask / restoreCompletedTask 共用的内部步骤 —— 把任务从 + * 源分类(trash 或 completed)移到目标分类、发事件、按规则清 originalCategory。 + * 返回值对齐 restoreTask 的契约: + * + * - { ok: true, restored: true, restoreHint? } 任务已就位(picker path 不会走这里) + * - target 不合法(如不存在 / 是容器 / 是回收站自身) → { ok: false }(不动任务) + * + * 注意:原任务在 trash.tasks 中的 idx 在 splice 后失效,所以一次性读出 task 引用 + * (splice 返回值)后再 push / 改字段,避免 idx 与位置错位。 + * + * 落点由 kind 决定(kind 决定读哪个 *Position 设置): + * - kind='restore' → 读 restorePosition(回收站 → 原分类路径,由 restoreTask 调用) + * - kind='uncomplete' → 读 uncompletePosition(已完成 → 原分类路径,由 restoreCompletedTask 调用) + * 两条路径默认都 'front'(最新在最上),与「任务从存档区重新激活」的语义对齐。 + * + * @param {{ originalCategory?: string|null }} taskSnapshot + * @param {number} idx - task 在 trash.tasks 中的位置 + * @param {Category} target - 目标分类(已知 kind 合法) + * @param {Category} source - 源分类(trash / completed,由调用方传入) + * @param {{ restoreHint?: object, kind?: 'restore'|'uncomplete' }} [opts] + * @returns {{ ok: boolean, restored?: boolean, restoreHint?: object }} + */ + _doRestoreMove(taskSnapshot, idx, target, source, opts = {}) { + if (!target || target.kind === CategoryKind.OTHER_TASKS || target.kind === CategoryKind.TRASH) { + // 防御性:调用方应自己保证 target 合法。万一漏了守卫,**不动任务**并返回 false。 + return { ok: false }; + } + const [task] = source.tasks.splice(idx, 1); + // 普通子分类里任务已"回家",原分类标记是噪音;COMPLETED / TRASH 保留。 + // 与 v3.5.1 起的语义对齐:completed 任务回「已完成任务」时 originalCategory 保留, + // 未来取消勾选仍可正确归位。 + if (target.kind !== CategoryKind.COMPLETED && target.kind !== CategoryKind.TRASH) { + task.originalCategory = null; + } + // 落点由调用方传入的 kind 决定(restore / uncomplete 分别对应不同的 *Position 设置)。 + this._appendTaskWithPosition(target, task, opts.kind || 'restore'); + + // 从 trash 移出时要把空 trash 的选中项带走(恢复操作特有的副作用)。 + if (source.kind === CategoryKind.TRASH) { + this._followTrashIfEmptied(source, target.name); + } + this._markDirty({ tasks: task.id, categories: [source.name, target.name] }); + this.emit('tasks', { categoryName: source.name }); + this.emit('tasks', { categoryName: target.name }); + this.emit('categories'); + this.emit('change'); + // 任务已成功归位 —— 清掉 picker 计数,否则下次 toggle 时同 task 还会从 + // 上次的尝试次数起步,可能直接撞阈值被强制降级 fallback。 + // key 与 toggleTask 里的一致(task.id 或 `cat::idx` 兜底)。 + const taskKey = task.id || `${source.name}::${idx}`; + this._pickerAttempts.delete(taskKey); + return { ok: true, restored: true, ...(opts.restoreHint ? { restoreHint: opts.restoreHint } : {}) }; + } + + moveTask(fromCategoryName, taskId, toCategoryName, toIndex = -1) { + const fromCat = this.getCategory(fromCategoryName); + const toCat = this.getCategory(toCategoryName); + if (!fromCat || !toCat) return false; + // 容器和回收站都不接受 moveTask: + // - OTHER_TASKS 是纯容器,本身不存任务 + // - TRASH 只通过 deleteTask 进入(走 unshift + 专用事件),restoreTask 离开 + // 如果 moveTask 也允许移入 TRASH,就会绕过 deleteTask 的专属流程 + // (事件模式不同、不触发 categories 徽标更新的正确语义) + if (toCat.kind === CategoryKind.OTHER_TASKS) return false; + if (toCat.kind === CategoryKind.TRASH) return false; + const idx = fromCat.tasks.findIndex(t => t.id === taskId); + if (idx < 0) return false; + const [task] = fromCat.tasks.splice(idx, 1); + // v3.4:移到「已完成任务」分类时强制 completed=true —— 维持"完成态与分类归属同步"不变量。 + // 反向(从已完成分类移走)允许,但通常没有 UI 入口;这里只做正向的强制,避免拖动到 + // 已完成分类却没勾选造成不一致。 + // v3.5+:拖入「已完成」时也要记下原分类,与 toggleTask 勾选走完全相同的恢复语义。 + // - 来源是子分类(parentOtherTasks)→ 记原分类名 + // - 来源是已完成 / 容器 / 回收站 → 不重记(避免把无关分类当原分类) + // v3.5.1+:反向(已完成 → 普通子分类)也要清掉 originalCategory —— 任务已 + // 回到普通分类里,原分类标记就是噪音;统一由 writer 层只在 COMPLETED/TRASH + // 输出 [原分类:xxx],但这里把字段清干净更便于外部检查 / 数据一致性。 + // + // 防御:从回收站拖 completed 任务到普通子分类时,**强制改投「已完成任务」分类**。 + // restoreTask 同款规则 —— 让 moveTask 与 restoreTask 在"从 trash 恢复 completed 任务" + // 这条路径上行为一致,避免把 completed=true 任务落到 NORMAL 分类里破坏 + // "completed=true 只活在 kind=COMPLETED"的不变量(v3.4 数据归属不变量)。 + // 真实入口:拖拽任务从回收站到侧边栏子分类;默认走这条路径。 + let effectiveTarget = toCat; + if (fromCat.kind === CategoryKind.TRASH && task.completed && toCat.kind !== CategoryKind.COMPLETED) { + effectiveTarget = this._getOrCreateCompletedCategory(); + } + if (effectiveTarget.kind === CategoryKind.COMPLETED && !task.completed) { + task.completed = true; + } + // v3.7+:从「已完成任务」拖 completed 任务到普通子分类 → 强制 completed=false。 + // 与 TRASH 防御改投的语义不同:这里**尊重用户的拖拽意图**——「把这条已完成的 + // 任务挪到工作列表里 = 让它回到活跃工作」。如果改投回「已完成任务」就违背 + // 用户意图(拖了半天还在原地),把 completed 翻成 false 是更直观的体验, + // 也与 toggleTask 取消勾选走「COMPLETED → 原分类 + completed=false」对称。 + // 不变量维持:completed=false 任务活在 NORMAL 子分类 —— v3.4 数据归属不变量。 + // + // 副作用告知:completed 翻转是隐式行为 —— 用户可能没意识到「拖过去会自动 + // 取消勾选」。return 时附带 completedFlipped:true,UI 在 toast 文案里 + // 补一句「(同时取消完成状态)」,让副作用可见。 + // + // v3.7+ 实装方式:返回字符串 targetName(向后兼容所有 caller),completed 翻转 + // 通过下面 emit('task:completed-flipped') 事件告知 task-list,UI 弹 toast 时 + // 文案补一句。不用包装对象 / 改返回类型,避免大范围改 caller 签名。 + const completedFlipped = effectiveTarget.kind !== CategoryKind.COMPLETED && task.completed; + if (completedFlipped) { + task.completed = false; + } + // v3.5+:拖入「已完成」时也要记下原分类,与 toggleTask 勾选走完全相同的恢复语义。 + // 来源是 NORMAL 分类(无论是否挂在容器下)→ 记原分类名;其它(已完成 / 容器 / + // 回收站 / 顶级 OTHER_TASKS)→ 不重记(避免把无关分类当原分类)。 + // + // 与早期版本的 `fromCat.parentOtherTasks` 校验对比:早期版本只接受容器下子分类, + // 漏掉「parser H2 提升路径产生的顶级 NORMAL」这种边缘场景 —— _ensureBaseStructure + // 在加载时已把它们转成 parentOtherTasks=true,所以加载流程下两者等价; + // 但与 toggleTask / batchToggleCompleted 的「cat.kind === NORMAL」对称能避免 + // 任何绕过 _ensureBaseStructure 的路径产生 originalCategory 静默丢失。 + // 详见 toggleTask line 1183 的 H3 修复注释。 + if (effectiveTarget.kind === CategoryKind.COMPLETED && + fromCat.kind === CategoryKind.NORMAL && fromCat.name) { + task.originalCategory = fromCategoryName; + } + if (effectiveTarget.kind !== CategoryKind.COMPLETED && effectiveTarget.kind !== CategoryKind.TRASH) { + // 移到任何普通分类(NORMAL/TODAY 等)都视为"到家",原分类标记失去意义 + task.originalCategory = null; + } + if (toIndex < 0) { + // 未指定落点(右键「移到分类…」/ 批量移动 / 拖到侧边栏分类)→ 读 movePosition 设置 + // 决定任务落到目标分类的「前面」还是「后面」。默认 'front'(最新在最上面)。 + this._appendTaskWithPosition(effectiveTarget, task, 'move'); + } else if (toIndex >= effectiveTarget.tasks.length) { + effectiveTarget.tasks.push(task); + } else { + // toIndex 是「移除源之后」数组里的下标(task-list.js 的 rest 数组语义): + // 与 reorderTask 同款约定,UI 已经按 rest 算 toIndex,store 与之对齐。 + // 同 cat 移动时,上面 fromCat.tasks.splice(idx, 1) 已经从 fromCat 摘走 task, + // 若 fromCat === effectiveTarget,effectiveTarget.tasks 此刻是「移除源之后」的数组。 + // splice(toIndex, 0, task) 直接插入到正确位置;toIndex >= length 会自然追加。 + effectiveTarget.tasks.splice(toIndex, 0, task); + } + // 从回收站移出也是一种「恢复」:搬空后同样要把选中项带走 + if (fromCat.kind === CategoryKind.TRASH) { + this._followTrashIfEmptied(fromCat, effectiveTarget.name); + } + // dirty 跟踪 + emit 要覆盖实际参与的两个分类(防御性改投时两者可能不同) + const dirtyCategories = Array.from(new Set([fromCategoryName, effectiveTarget.name])); + this._markDirty({ tasks: task.id, categories: dirtyCategories }); + this.emit('tasks', { categoryName: effectiveTarget.name }); + this.emit('tasks', { categoryName: fromCategoryName }); + // 侧边栏各分类的计数都可能变(尤其从回收站「恢复」时,回收站徽标要跟着减) + this.emit('categories'); + this.emit('change'); + // 返回实际生效的目标分类名(防御性改投时可能与调用方传入的不同): + // - UI 弹 toast 时用这个值告诉用户真实去向,避免"显示移到 A、实际落到 B"的认知错位 + // - 老 caller 只看 truthy/falsy 不读 .targetName 也照样工作 + if (completedFlipped) { + // v3.7+:副作用告知。toast 在 caller 弹「已移到 X」之后,用户可能没意识到 + // 勾选也被翻了。emit 'task:completed-flipped',UI 在 task-list 构造函数 + // 订阅、文案补「(同时取消完成状态)」。在 'change' 之后 emit,UI render + // 已经看到最新状态(completed=false),不会出现"toast 已翻但 UI 未翻"的撕裂。 + this.emit('task:completed-flipped', { + taskId: task.id, + fromCategoryName: fromCategoryName, + toCategoryName: effectiveTarget.name + }); + } + return effectiveTarget.name; + } + + reorderTask(categoryName, fromIndex, toIndex) { + const cat = this.getCategory(categoryName); + if (!cat) return false; + if (fromIndex === toIndex) return false; + if (fromIndex < 0 || fromIndex >= cat.tasks.length) return false; + // 允许 toIndex === cat.tasks.length —— 「拖到最后一条之后」是合法操作。 + // 旧实现用 >= 拒绝这个值,结果是用户拖到末尾时 reorder 静默失败, + // 任务留在原位但 UI 没有反馈,看起来像「拖不动」。 + if (toIndex < 0 || toIndex > cat.tasks.length) return false; + const [task] = cat.tasks.splice(fromIndex, 1); + // toIndex 是「移除源之后」数组里的下标(task-list.js:2900 的 `rest` 数组语义): + // - 落在末尾(toIndex === rest.length)→ splice 自然 clamp 到末尾追加 + // - 落在中间 → splice 在 rest 数组的正确位置插入 + // 这里**不再**调整 toIndex —— 调整反而会让 toIndex === rest.length(拖到末尾) + // 这种合法 case 错位插到中间。UI 已经按 rest 语义算 toIndex,store 与之对齐即可。 + cat.tasks.splice(toIndex, 0, task); + // 用户主动重排 → 切回手动排序,否则下次渲染会按字母把结果抹掉 + this._switchToManualSort(); + this._markDirty({ categories: categoryName }); + this.emit('tasks', { categoryName }); + this.emit('change'); + return true; + } + + /** + * 批量清除已完成任务 —— 同 deleteTask,实际是「移到回收站」,不丢弃。 + * + * v3.4 起所有 completed=true 的任务都活在 kind=COMPLETED 分类里,所以 + * 「指定 categoryName 之外的清理」实际等价于「清空已完成分类」。这里仍 + * 保留指定分类的接口(兼容旧 UI/测试),但只在以下两种情况有意义: + * - 调用方传 COMPLETED 分类:把该分类下所有任务搬到回收站 + * - 调用方传 null:扫所有"非容器/非回收站"分类里的 completed=true 任务 + * (理论上 v3.4 后只有 COMPLETED 分类会有 completed=true;扫描是为了防御 + * 外部编辑器手工编辑 / 加载流程的瞬态) + * - 调用方传普通子分类:原本是「清空该子分类的已完成任务」,v3.4 后实际不会 + * 找到任何任务(迁移已完成);保留接口仅为语义对称 + * + * @param {string|null} categoryName - 指定分类;省略则处理全部真实分类 + * @returns {number} 移入回收站的任务数 + */ + clearCompleted(categoryName = null) { + const targets = (categoryName + ? [this.getCategory(categoryName)].filter(Boolean) + : this.categories.filter(c => c.kind !== CategoryKind.OTHER_TASKS) + ).filter(c => c.kind !== CategoryKind.TRASH); + + // 先收集,再统一入站:避免边遍历 categories 边往其中插入回收站分类 + const moved = []; + for (const cat of targets) { + const keep = []; + for (const t of cat.tasks) { + if (t.completed) moved.push(t); + else keep.push(t); + } + cat.tasks = keep; + } + if (moved.length === 0) return 0; + + const trash = this._getOrCreateTrashCategory(); + // 落点由 trashPosition 决定: + // - front:依次遍历 moved + 每条 unshift → 最后被处理的一条落在最前(与 + // _appendTaskWithPosition 的「最新在最上」语义对齐)。 + // - back :按收集顺序整体 push —— 入参顺序里最早被处理的最先入站。 + // 注意 moved 是按 (分类在 categories 数组的顺序, 子分类内任务顺序) 收集的, + // 这是 UI 上用户看到的全局顺序 —— 用户清空/批量删除时希望保留它。 + if (this._getInsertPosition('trash') === 'back') { + for (const t of moved) trash.tasks.push(t); + } else { + for (let i = 0; i < moved.length; i++) { + trash.tasks.unshift(moved[i]); + } + } + + this._markDirty({ + tasks: moved.map(t => t.id), + categories: Array.from(new Set([...targets.map(c => c.name), trash.name])), + }); + this.emit('tasks'); + this.emit('categories'); + this.emit('change'); + return moved.length; + } + + // ============================================ + // 批量操作(选择模式) + // ============================================ + // + // 设计原则: + // 1) 与单条 API 完全一致的守卫(OTHER_TASKS / TRASH 拒绝、TRASH 拒绝改 meta 等)。 + // 2) 整批操作只触发一次 _markDirty + 一次 emit('change') + 按分类去重的 emit('tasks')。 + // 这是与 clearCompleted 同款的「先收集 → 一次性提交」模式 —— N 条操作不会触发 N 次重渲染。 + // 3) 直接遍历 splice / push,不调单条 mutator(单条 mutator 每次都 emit,会破坏 #2)。 + // 4) 入参 taskRefs: Array<{categoryName, taskId}>。为什么不是单纯的 taskId[]: + // 智能列表(current/important/completed/allTasks)的 cat.tasks 是 enriched 副本(含 _fromCategory), + // 同一 id 可能出现在多个分类的副本里 —— 必须用「(分类, id)」复合定位, + // 与 _finishDrag 里 `sourceTask._fromCategory || cat.name` 的取值方式完全对称。 + // 5) 所有方法都返回 { moved: number, skipped: number },让 UI 显示精确 toast / 确认文案。 + // skipped 包括「守卫拒绝」「找不到任务」「id 不存在」三种情况,不抛错。 + + /** + * 把 taskRefs 解析成 [{ category, task }] 数组,过滤掉找不到 / 守卫拒绝的项 + * + * 不 mutate 入参数组;返回的新数组里每个元素带原 category / task 引用 + parseOrder 字段 + * (用于在循环结束后保持原顺序)。 + * + * @returns {{ valid: Array<{category, task, parseOrder, sourceIndex}>, skipped: number }} + */ + _resolveTaskRefs(taskRefs, { allowTrash = true, disallowSourceKinds = [] } = {}) { + const valid = []; + let skipped = 0; + if (!Array.isArray(taskRefs) || taskRefs.length === 0) { + return { valid, skipped: 0 }; + } + for (let i = 0; i < taskRefs.length; i++) { + const ref = taskRefs[i]; + if (!ref || !ref.categoryName || !ref.taskId) { skipped++; continue; } + const cat = this.getCategory(ref.categoryName); + if (!cat) { skipped++; continue; } + if (disallowSourceKinds.includes(cat.kind)) { skipped++; continue; } + if (!allowTrash && cat.kind === CategoryKind.TRASH) { skipped++; continue; } + const idx = cat.tasks.findIndex(t => t.id === ref.taskId); + if (idx < 0) { skipped++; continue; } + valid.push({ category: cat, task: cat.tasks[idx], parseOrder: valid.length, sourceIndex: idx }); + } + return { valid, skipped }; + } + + /** + * 按 taskId 在所有分类里查找 —— _resolveTaskRefs 的「宽容」退路。 + * + * 严格版 _resolveTaskRefs 要求 (categoryName, taskId) 配对:用户必须 + * 给出任务当前所在分类。这对 delete / move / reorder 是对的(用户 + * 「从这条列表里选中的」语义明确,宽容会把已删除的任务又翻出来乱动)。 + * + * 但 toggle 是「按 id 翻状态」语义 —— 任务在哪都应该翻得到。 + * v3.4 之后 toggle 会自动跨分类移动([✓] → 已完成;取消勾选 → 原分类, + * v3.5+ 记在 [原分类:xxx];原分类丢失才回未分类), + * 同一批 refs 第二次调用时可能已经不在原 categoryName 了 —— 宽容路径 + * 必须保留,否则「勾选 / 撤回」批量操作会静默跳过所有任务。 + * + * 返回值结构与 _resolveTaskRefs 的 valid 元素一致(category / task / + * sourceIndex),方便 batchToggleCompleted 直接 concat 使用。 + * + * @param {string} taskId + * @returns {{ category, task, sourceIndex } | null} + */ + _findTaskByIdAnywhere(taskId) { + if (!taskId) return null; + for (const cat of this.categories) { + const idx = cat.tasks.findIndex(t => t.id === taskId); + if (idx >= 0) return { category: cat, task: cat.tasks[idx], sourceIndex: idx }; + } + return null; + } + + /** + * 批量删除(移到回收站) + * + * 与单条 deleteTask 同款守卫: + * - TRASH 内的任务被拒绝(彻底删除只能手工编辑 Markdown) + * - 找不到的任务静默跳过 + * + * 一次性倒序 unshift 到 trash(沿用 clearCompleted 的回收站顺序约定): + * 先按 taskRefs 入参顺序收集 → 倒过来 unshift → 回收站里保留入参的相对顺序, + * 与「最近删除排在最上面」语义一致。 + * + * @param {Array<{categoryName, taskId}>} taskRefs + * @returns {{ moved: number, skipped: number }} + */ + batchDeleteTasks(taskRefs) { + const { valid, skipped: resolveSkipped } = this._resolveTaskRefs(taskRefs, { allowTrash: false }); + if (valid.length === 0) return { moved: 0, skipped: resolveSkipped }; + + // 按 (category, sourceIndex) 分组 → 同分类内的多次 splice 合并处理(避免下标漂移) + const byCat = new Map(); + for (const v of valid) { + if (!byCat.has(v.category)) byCat.set(v.category, []); + byCat.get(v.category).push(v); + } + + // 从每个分类里 splice 出来:按 sourceIndex 倒序删除(避免下标漂移)。 + // 收集顺序 = 「按 (源分类入组顺序, 源下标倒序)」,与 taskRefs 入参顺序不一致; + // 真正的入参顺序由 valid 数组保留(parseOrder),下面用 valid 数组重建 moved 数组。 + // + // v3.5.2+:进 trash 前补登原分类 —— 与单条 deleteTask 同款语义: + // - 来源是 NORMAL 且任务没有 originalCategory → 写下 cat.name + // - 来源是 COMPLETED → 不动(已完成任务在 toggle 时已记下它的"原") + // 与单条版共用同条不变量:每条任务进 trash 后仅在恢复路径或移出特殊分类时清 originalCategory。 + const sourceCats = []; + for (const [cat, items] of byCat) { + items.sort((a, b) => b.sourceIndex - a.sourceIndex); + for (const it of items) { + if (cat.kind === CategoryKind.NORMAL && !it.task.originalCategory) { + it.task.originalCategory = cat.name; + } + cat.tasks.splice(it.sourceIndex, 1); + } + sourceCats.push(cat.name); + } + + // 按 taskRefs 入参顺序收集 task(与 UI 选中顺序对齐,回收站里用户的最近一次删除排在最上面) + const moved = valid.map(v => v.task); + + const trash = this._getOrCreateTrashCategory(); + // 落点由 trashPosition 决定 —— 与 clearCompleted 同款策略: + // - front:依次遍历 moved + 每条 unshift → 入参里最后被选中的那条落在最前 + // (与「最新删除的最上面」对齐,找回时不用翻到底部)。 + // - back :按入参顺序整体 push —— 入参顺序里最早被选中的最先入站。 + if (this._getInsertPosition('trash') === 'back') { + for (const t of moved) trash.tasks.push(t); + } else { + for (let i = 0; i < moved.length; i++) { + trash.tasks.unshift(moved[i]); + } + } + + // 任一源分类是当前选中分类的话,调用方应在删除前主动切走选中; + // store 不自动切 —— 批量删除可能只删 5 条里的 3 条,剩下还在原分类, + // 自动切走会让用户失去原分类的工作上下文。 + this._markDirty({ + tasks: moved.map(t => t.id), + categories: Array.from(new Set([...sourceCats, trash.name])), + }); + this.emit('tasks'); // 全量刷新(含所有源分类与回收站) + this.emit('categories'); // 回收站计数变化 → 侧边栏 + this.emit('change'); + return { moved: moved.length, skipped: resolveSkipped }; + } + + /** + * 批量移到分类(跨分类移动) + * + * 与单条 moveTask 同款守卫: + * - 目标分类不允许是 OTHER_TASKS(容器不存任务)或 TRASH(绕过 deleteTask 专属流程) + * - 源分类不允许是 TRASH 的任务走此 API(从回收站恢复走 restoreTask) + * - 保留 taskRefs 入参顺序 push 到 toIndex 位置 + * + * @param {Array<{categoryName, taskId}>} taskRefs + * @param {string} toCategoryName + * @param {number} [toIndex=-1] - 目标插入位置;-1 表示追加到末尾 + * @returns {{ moved: number, skipped: number }} + */ + batchMoveTasks(taskRefs, toCategoryName, toIndex = -1) { + if (!toCategoryName) return { moved: 0, skipped: (taskRefs || []).length }; + const toCat = this.getCategory(toCategoryName); + if (!toCat) return { moved: 0, skipped: (taskRefs || []).length }; + if (toCat.kind === CategoryKind.OTHER_TASKS) return { moved: 0, skipped: (taskRefs || []).length }; + if (toCat.kind === CategoryKind.TRASH) return { moved: 0, skipped: (taskRefs || []).length }; + + // 源分类允许的 kind:除 TRASH 外都可以(普通子分类、智能列表视图下的 enriched cat, + // 都视为 NORMAL)。 + const { valid, skipped: resolveSkipped } = this._resolveTaskRefs(taskRefs, { allowTrash: false }); + if (valid.length === 0) return { moved: 0, skipped: resolveSkipped }; + + // 按源分类分组 → 同分类内按 sourceIndex 倒序 splice(避免下标漂移) + const byCat = new Map(); + for (const v of valid) { + if (!byCat.has(v.category)) byCat.set(v.category, []); + byCat.get(v.category).push(v); + } + const sourceCats = []; + for (const [cat, items] of byCat) { + items.sort((a, b) => b.sourceIndex - a.sourceIndex); + for (const it of items) { + cat.tasks.splice(it.sourceIndex, 1); + } + sourceCats.push(cat.name); + } + + // 按 taskRefs 入参顺序重建 moving 数组(顺序 = UI 选中顺序,目标分类里保留这个顺序) + const moving = valid.map(v => v.task); + + // v3.4 数据归属不变量双向强制 —— 与单条 moveTask 完全对称。 + // 1. 目标 = COMPLETED:强制 completed=true。 + // 之前批量版只处理反向(completed → NORMAL 强改 false), + // 漏了正向,导致「移到已完成任务」后任务还停在 completed=false 的不一致态。 + // 2. 目标 ≠ COMPLETED:强制 completed=false。 + // 来源可能是「已完成任务」智能视图(enriched cat 视作 NORMAL), + // 落到普通子分类必须翻成未完成,否则违反 "completed=true 只活在 kind=COMPLETED"。 + // 同时:来源是 NORMAL 分类(任意层级,_ensureBaseStructure 已统一) + // 且目标是 COMPLETED 时,记 originalCategory —— 与单条 moveTask 对齐, + // 确保之后取消勾选能正确归位。 + for (const v of valid) { + const t = v.task; + if (toCat.kind === CategoryKind.COMPLETED) { + if (!t.completed) t.completed = true; + if (v.category.kind === CategoryKind.NORMAL && v.category.name && !t.originalCategory) { + t.originalCategory = v.category.name; + } + } else { + if (t.completed) t.completed = false; + // 移到任何普通分类都视为"到家",原分类标记失去意义 —— 与单条 moveTask 对齐。 + t.originalCategory = null; + } + } + + // 插入目标:toIndex 处理 + // - toIndex < 0(未指定,右键/批量「移到分类」默认值)→ 读 movePosition 设置 + // 决定整批落到目标分类的「前面」还是「后面」。整批维持入参顺序: + // front → 倒序 unshift(最后 unshift 的排最前,第一批入参落在最上面) + // back → 正序 push(第一批入参落在最下面) + // - toIndex >= length → push 到末尾(显式越界时保持旧行为) + // - 其它 → splice 到精确位置(拖拽跨分类场景) + if (toIndex < 0) { + const pos = this._getInsertPosition('move'); + if (pos === 'back') { + toCat.tasks.push(...moving); + } else { + // 倒序 unshift:moving[0] 最后 unshift → 落在最前面,保持入参顺序 + for (let i = moving.length - 1; i >= 0; i--) { + toCat.tasks.unshift(moving[i]); + } + } + } else { + const targetLen = toCat.tasks.length; + const insertAt = toIndex >= targetLen ? targetLen : toIndex; + toCat.tasks.splice(insertAt, 0, ...moving); + } + + // 用户主动重排 → 切回手动排序(与单条 moveTask 同款) + this._switchToManualSort(); + + this._markDirty({ + tasks: moving.map(t => t.id), + categories: Array.from(new Set([...sourceCats, toCat.name])), + }); + // 按分类去重发出 tasks 事件 + const emitCats = new Set([...sourceCats, toCat.name]); + for (const name of emitCats) this.emit('tasks', { categoryName: name }); + this.emit('change'); + return { moved: moving.length, skipped: resolveSkipped }; + } + + /** + * 批量切勾选状态 + * + * 与单条 toggleTask 同款语义:TRASH 内允许切(用户常想恢复前清勾)。 + * + * v3.4 起:传入 `completed=true` 时,源分类**非**已完成分类的任务会被自动搬到 + * 「# 已完成任务」(kind=COMPLETED);传入 `completed=false` 时,已完成分类里的任务 + * 会被搬到「未分类」。与单条 toggleTask 的数据归属约定一致 —— 「completed 状态」 + * 与「分类归属」必须同步。 + * + * 注意语义:第二个参数 `completed` 是布尔值,不是「切换」标志。 + * UI 调用方在「标记为完成」按钮里传 true、「标记为未完成」传 false。 + * 想"切换"就在 UI 侧先读当前态再决定传什么。 + * + * @param {Array<{categoryName, taskId}>} taskRefs + * @param {boolean} completed + * @returns {{ changed: number, skipped: number }} + */ + batchToggleCompleted(taskRefs, completed) { + const { valid, skipped: resolveSkipped } = this._resolveTaskRefs(taskRefs, { allowTrash: true }); + + // v3.4 退路:toggleTask 在 [✓] 后会自动搬到 # 已完成任务分类, + // 同一批 refs 在第二次调用时可能不再位于原 categoryName —— 例如 + // 「我刚才勾选的 3 条任务,现在想撤回」:任务已经不在「工作」里。 + // 单条 toggleTask(categoryName, taskId) 是 location-tolerant 的(按 + // taskId 在所有分类里搜),批量版本也必须跟得上,否则用户「勾选 / 撤回」 + // 路径会莫名其妙静默跳过所有 3 条。 + // + // 注意:这是 toggle 特有的语义,不是所有批量操作都该宽容: + // - delete / move / reorder 严格按 (categoryName, taskId) 配对 + // —— 「删除我列表里选中的这 3 条」语义清晰,宽容会把已删除的任务 + // 又找出来乱动。 + // - toggle 是「按 id 翻状态」,location-tolerant 是它的本质。 + const resolvedIds = new Set(valid.map(v => v.task.id)); + const fallback = []; + if (Array.isArray(taskRefs)) { + for (const ref of taskRefs) { + if (!ref || !ref.taskId) continue; + if (resolvedIds.has(ref.taskId)) continue; + const found = this._findTaskByIdAnywhere(ref.taskId); + if (found) { + fallback.push(found); + resolvedIds.add(ref.taskId); + } + } + } + const allValid = valid.concat(fallback); + if (allValid.length === 0) return { changed: 0, skipped: resolveSkipped }; + + const wantCompleted = !!completed; + let changed = 0; + const changedCats = new Set(); + const dirtyTaskIds = []; // 记录每个真正被改动过的任务 id(供 _markDirty 用) + // 先按 (sourceCatName, targetCatName) 分组待移动:避免单条 splice/push 中途 + // 跨过同一分类导致下标漂移。一次提交后再触发 events。 + const moves = []; // { task, sourceCat, sourceIdx, targetCat } + for (const v of allValid) { + if (v.task.completed === wantCompleted) continue; + // 跨分类移动的判定与单条 toggleTask 同款语义 + const needsMove = + (wantCompleted && v.category.kind !== CategoryKind.COMPLETED) || + (!wantCompleted && v.category.kind === CategoryKind.COMPLETED); + if (needsMove) { + moves.push({ + task: v.task, + sourceCat: v.category, + sourceIdx: v.sourceIndex + }); + } else { + v.task.completed = wantCompleted; + changedCats.add(v.category.name); + dirtyTaskIds.push(v.task.id); + } + changed++; + } + + // 提交移动:先按源分类分组,组内按 sourceIdx 倒序 splice 避免下标漂移; + // 再统一 push 到目标分类。 + // + // v3.5+:targetCat 不再是单一的「已完成分类」或「未分类」—— + // - wantCompleted=true:所有任务统一进「已完成分类」(与之前一致), + // 每条任务的 originalCategory 设为各自的 sourceCat.name(仅子分类才记)。 + // - wantCompleted=false:每条任务按 _resolveRestoreTarget 解析, + // 原分类还在 → 直接归位(多条任务可能归到不同分类); + // 原分类丢了 / 没记录 → 批量场景下统一走「未分类」兜底,**不弹窗** + // (批量操作连续弹多个分类选择器会非常扰民;单条 toggle 的 picker + // 行为已经在 toggleTask 里保留)。 + if (moves.length > 0) { + const bySrc = new Map(); + for (const m of moves) { + if (!bySrc.has(m.sourceCat)) bySrc.set(m.sourceCat, []); + bySrc.get(m.sourceCat).push(m); + } + // 第一次循环:按 sourceIdx 倒序 splice(下标有效),同时把每个 task 的目标分类 + // 解析结果暂存到 targetByTask。下次构造 byTarget 时按入参顺序读回来, + // 保证目标分类里任务的相对顺序与 taskRefs 入参顺序一致 —— 与 batchDeleteTasks + // 用 valid.map(v => v.task) 重排 moved 数组的策略对齐。 + const targetByTask = new Map(); + for (const [srcCat, items] of bySrc) { + // 按 sourceIdx 倒序 splice,确保每一步 idx 都有效 + items.sort((a, b) => b.sourceIdx - a.sourceIdx); + for (const it of items) { + srcCat.tasks.splice(it.sourceIdx, 1); + changedCats.add(srcCat.name); + let target; + if (wantCompleted) { + // 全部进已完成分类;记录原分类(任何 NORMAL 分类都算原分类源) + target = this._getOrCreateCompletedCategory(); + if (srcCat.kind === CategoryKind.NORMAL && srcCat.name) { + it.task.originalCategory = srcCat.name; + } + } else { + // 从已完成分类退出:按 _resolveRestoreTarget 解析 + const resolve = this._resolveRestoreTarget(it.task); + // v4+ 修复:resolve 不再返回 target(picker 路径不解析,避免副作用新建分类), + // 这里现场解析:kind='category' 用原 target,'fallback' 用「未分类」, + // 'picker'(批量路径不弹窗)按注释兜底到「未分类」。 + if (resolve.kind === 'category') { + target = resolve.target; + } else { + // picker / fallback 都走「未分类」兜底(批量场景下 picker 走 fallback,避免 N 次弹窗) + target = this._getOrCreateUncategorizedCategory(); + } + // v3.5.1:恢复后清掉 originalCategory —— 标记已完成使命,避免 + // 目标分类里出现 `[原分类:xxx] 任务` 这种冗余噪音。 + it.task.originalCategory = null; + } + it.task.completed = wantCompleted; + targetByTask.set(it.task, target); + } + } + // 第二次循环:按 moves 入参顺序(=taskRefs 入参顺序)构造 byTarget, + // 让目标分类里任务的相对顺序与 UI 选中顺序对齐 —— 前一次循环里 bySrc.items + // 是按 sourceIdx 倒序排的(splice 下标有效需要),不能直接用。 + const byTarget = new Map(); // targetCat → [task] + for (const m of moves) { + const target = targetByTask.get(m.task); + if (!byTarget.has(target)) byTarget.set(target, []); + byTarget.get(target).push(m.task); + } + for (const [target, tasks] of byTarget) { + // wantCompleted=true 走 completedPosition(批量打勾 = 新增到已完成) + // wantCompleted=false 走 uncompletePosition(批量取消勾选 = 任务重新激活), + // 与单条 toggleTask 取消勾选走 uncompletePosition 的语义对齐 —— 批量版的 + // _resolveRestoreTarget 把 picker 兜底为 fallback(不弹窗),但落到 NORMAL + // 子分类这一动作仍是「重新激活」,所以读 uncompletePosition 而不是 push 末尾。 + const kind = wantCompleted ? 'completed' : 'uncomplete'; + const pos = this._getInsertPosition(kind); + if (pos === 'back') { + target.tasks.push(...tasks); + } else { + // front 模式:依次 unshift —— 入参里最后一条落在最前(最新在最上), + // 与 importTasksToCategory / clearCompleted / batchDeleteTasks / + // 批量打勾的「正序遍历 + unshift」语义对齐。 + for (let i = 0; i < tasks.length; i++) { + target.tasks.unshift(tasks[i]); + } + } + changedCats.add(target.name); + } + for (const it of moves) dirtyTaskIds.push(it.task.id); + } + + if (changed === 0) return { changed: 0, skipped: resolveSkipped + allValid.length }; + + this._markDirty({ + tasks: dirtyTaskIds, + categories: Array.from(changedCats), + }); + for (const name of changedCats) this.emit('tasks', { categoryName: name }); + this.emit('categories'); // 已完成分类的计数变化 → 侧边栏 + this.emit('change'); + return { changed, skipped: resolveSkipped + (allValid.length - changed) }; + } + + /** + * 批量改 meta(important / current) + * + * v3.6+:与单条 updateTaskMeta 一致 —— TRASH 不再一刀切拒绝。⭐ / ▶ 是任务级 + * 标识,与 completed-toggle 那种跨分类转移不同;用户在回收站 / 已完成任务里 + * 也能批量改它们(例如「把这 10 条恢复出来之前先批量打当前」)。其它破坏性 + * 跨分类操作(删除 / 移动 / 完成态切换)仍按各自守卫走,不在这里放松。 + * + * 第二个参数 meta 是 partial:只传要改的字段。 + * 已为 true 的 important 不会被改成 false(不传就不动); + * 想"切换"就在 UI 侧先读当前态再决定传什么。 + * + * @param {Array<{categoryName, taskId}>} taskRefs + * @param {{ important?: boolean, current?: boolean }} meta + * @returns {{ changed: number, skipped: number }} + */ + batchUpdateMeta(taskRefs, meta) { + if (!meta || ('important' in meta) === false && ('current' in meta) === false) { + return { changed: 0, skipped: (taskRefs || []).length }; + } + // 默认走宽容路径(allowTrash: true)—— TRASH 现在允许改 meta(详见 updateTaskMeta + // 注释)。其它 kind 走 _resolveTaskRefs 默认 disallowSourceKinds = [],不限制。 + const { valid, skipped: resolveSkipped } = this._resolveTaskRefs(taskRefs, { allowTrash: true }); + if (valid.length === 0) return { changed: 0, skipped: resolveSkipped }; + + let changed = 0; + const changedCats = new Set(); + for (const v of valid) { + let localChanged = false; + if ('important' in meta && !!meta.important !== !!v.task.important) { + v.task.important = !!meta.important; + localChanged = true; + } + if ('current' in meta && !!meta.current !== !!v.task.current) { + v.task.current = !!meta.current; + localChanged = true; + } + if (localChanged) { + changed++; + changedCats.add(v.category.name); + } + } + if (changed === 0) return { changed: 0, skipped: resolveSkipped + valid.length }; + + this._markDirty({ + tasks: valid.filter(v => changedCats.has(v.category.name)).map(v => v.task.id), + categories: Array.from(changedCats), + }); + for (const name of changedCats) this.emit('tasks', { categoryName: name }); + this.emit('change'); + return { changed, skipped: resolveSkipped + (valid.length - changed) }; + } + + /** + * 批量重排序(单分类内) + * + * 把指定 taskIds 集合在 categoryName 内整体移到 top / bottom。 + * 顺序保留(taskIds 入参顺序就是最终顺序)。 + * + * 守卫: + * - 目标分类必须存在且不是 OTHER_TASKS / TRASH + * - 所有 taskId 都必须在该分类内(不在的静默跳过) + * - 不在目标分类里的 task 跳过(避免误移) + * - store 层不感知 sortBy —— ALPHABET 下的禁用由 UI 层负责 + * + * @param {string} categoryName + * @param {string[]} taskIds + * @param {'top' | 'bottom'} position + * @returns {{ moved: number, skipped: number }} + */ + batchReorderInCategory(categoryName, taskIds, position) { + const cat = this.getCategory(categoryName); + if (!cat) return { moved: 0, skipped: (taskIds || []).length }; + if (cat.kind === CategoryKind.OTHER_TASKS || cat.kind === CategoryKind.TRASH) { + return { moved: 0, skipped: (taskIds || []).length }; + } + if (position !== 'top' && position !== 'bottom') { + return { moved: 0, skipped: (taskIds || []).length }; + } + if (!Array.isArray(taskIds) || taskIds.length === 0) { + return { moved: 0, skipped: 0 }; + } + + // 按 id 找出对应的 task 引用 + 在 cat.tasks 里的当前下标 + // 用 Map 索引(同一分类内 id 唯一) + const wanted = new Map(); + for (const v of cat.tasks) wanted.set(v.id, v); + const moving = []; + let skipped = 0; + for (const id of taskIds) { + const t = wanted.get(id); + if (!t) { skipped++; continue; } + moving.push(t); + } + if (moving.length === 0) return { moved: 0, skipped }; + + // 从 cat.tasks 里 splice 出来(按当前下标倒序,避免漂移) + // 这里 splice 的是引用相等,不是按 id 找下标 —— 更稳 + const remaining = cat.tasks.filter(t => !moving.includes(t)); + // 顺序:remaining 保持原顺序,moving 整体移到 top/bottom + const next = position === 'top' + ? [...moving, ...remaining] + : [...remaining, ...moving]; + cat.tasks = next; + + this._markDirty({ + tasks: moving.map(t => t.id), + categories: categoryName, + }); + this.emit('tasks', { categoryName }); + this.emit('change'); + return { moved: moving.length, skipped }; + } + + /** + * 获取回收站分类;不存在则创建并追加到 categories 末尾。 + * + * 位置必须是末尾:writeMarkdown 依赖数组顺序,回收站要写成最后一个 `# 回收站`。 + * 若插在「全部任务」容器和它的子分类之间,后面的子分类会被写成容器外的孤立 `##`, + * 下次解析时它们的任务会被静默并进回收站 —— 同 _getOrCreateUncategorizedCategory 的教训。 + */ + /** + * 回收站被清空且用户正看着它时,把选中项跟到任务恢复过去的分类。 + * + * 为什么需要:侧边栏在回收站为空时会隐藏这个入口。若不改选中项, + * 用户会停在一个「侧边栏里已经不存在」的分类上 —— 主面板显示"回收站是空的", + * 侧边栏却没有任何高亮项,而且回收站入口已消失、点不回来。 + * 跟着最后一条任务走,是唯一不需要用户再点一次的收尾。 + */ + _followTrashIfEmptied(trash, targetName) { + if (trash.tasks.length > 0) return; + if (this.selectedSmartList) return; + if (this.selectedCategoryName !== trash.name) return; + this.selectCategory(targetName); + } + + _getOrCreateTrashCategory() { + const existing = this.getTrashCategory(); + if (existing) return existing; + const trash = this._createCategory(TRASH_NAME, CategoryKind.TRASH); + this.categories.push(trash); + return trash; + } + + /** + * 取(或懒创建)「已完成任务」分类(kind=COMPLETED)。 + * + * 位置:在所有子分类之后、回收站之前 —— 与 `_normalizeOrder` 维护的 + * `[TODAY, 容器, ...子分类, 已完成任务, 回收站]` 不变量一致。 + * 由 `_ensureBaseStructure` 统一调用建好之后,运行时(toggleTask / batchToggleCompleted + * / restoreTask 等)也都走这里 —— 单一入口保证位置不漂移。 + */ + _getOrCreateCompletedCategory() { + const existing = this.getCompletedCategory(); + if (existing) return existing; + const completed = this._createCategory(COMPLETED_NAME, CategoryKind.COMPLETED); + + // 找到回收站的「前一个位置」插入:已完成的约定位置是在子分类之后、回收站之前。 + // 极端情况下如果 categories 里没有回收站(_ensureBaseStructure 流程还没跑), + // 就插到末尾,等下次 _normalizeOrder 时再统一归位。 + const trashIdx = this.categories.findIndex(c => c.kind === CategoryKind.TRASH); + if (trashIdx >= 0) { + this.categories.splice(trashIdx, 0, completed); + } else { + this.categories.push(completed); + } + return completed; + } + + + /** + * 把「回收站」收敛成唯一一个 kind=TRASH 分类(仅在加载时调用)。 + * + * 处理两类手工编辑产物: + * 1. 文件里写了多个 `# 回收站` → 解析出多个 TRASH。把后面的任务并进第一个, + * 再删掉多余的分类。绝不丢任务:回收站的内容只能由用户自己删。 + * 2. 把「回收站」写成了容器下的 `## 回收站`(解析成 NORMAL 子分类)→ 提升为 TRASH。 + * 与 addSubCategory 拒绝保留名的策略一致:叫这个名字就是回收站, + * 不能让它伪装成普通分类(否则同名冲突会让 getCategory 指向错的那个)。 + * + * 任务顺序按文件中的出现顺序拼接,不做去重 —— 用户看到的就是文件里的样子。 + */ + _consolidateTrash() { + // 把「名字正好是规范名 回收站、但被解析成普通子分类」的那一个正名。 + // + // 只认规范名,不认别名(trash / bin / 垃圾桶 …)—— 因为需要正名的唯一理由是 + // **重名冲突**:_getOrCreateTrashCategory() 会建一个叫「回收站」的分类, + // 若文件里已有同名普通子分类,getCategory('回收站') 从此指向错的那个。 + // 叫「bin」的子分类不会和「回收站」撞名,没有冲突,就不该被强行改判 —— + // 否则用户一个装着活跃任务的 `## bin` 会整段变成「已删除」, + // 标题也在下次保存时消失。宁可少管,也不要把活跃任务标成垃圾。 + for (const c of this.categories) { + if (c.kind === CategoryKind.TRASH) continue; + if (c.kind === CategoryKind.OTHER_TASKS || c.kind === CategoryKind.TODAY) continue; + if ((c.name || '').trim() === TRASH_NAME) { + c.kind = CategoryKind.TRASH; + c.isSpecial = true; + c.parentOtherTasks = false; // 回收站是 h1,不能挂在容器下 + } + } + + const all = this.categories.filter(c => c.kind === CategoryKind.TRASH); + if (all.length <= 1) return; + + const primary = all[0]; + for (const dup of all.slice(1)) { + primary.tasks.push(...dup.tasks); + } + const extras = new Set(all.slice(1)); + this.categories = this.categories.filter(c => !extras.has(c)); + } + + /** + * 收敛容器下的「未分类」子分类 —— 与 _consolidateTrash 对称。 + * + * 「未分类」是容器下的保留名(任务兜底),只能存在一份。markdown-parser 在遇到 + * 重复 `## 未分类` 时已经会合并第一份,本方法是 store 层的兜底防御: + * 任何绕过 parser 的路径(未来的 merge 工具、外部导入、调试脚本……)都不能让 + * 「未分类」在内存里出现两份 —— 两份会让: + * - sidebar 渲染两份同名入口(用户看到的 bug) + * - getCategory('未分类') 只认第一份,第二份里的任务在 UI 上既看不见也没法恢复 + * 合并策略:保留 categories 数组里最早出现的那份(与 parser 的"复用第一个"行为 + * 一致 —— 文件里第一次出现的 `## 未分类` 是权威),后续副本的任务全部并入、丢弃副本。 + */ + _consolidateUncategorized() { + const all = this.categories.filter( + c => c.parentOtherTasks && c.name === UNCATEGORIZED_NAME + ); + if (all.length <= 1) return; + + const primary = all[0]; + for (const dup of all.slice(1)) { + primary.tasks.push(...dup.tasks); + } + const extras = new Set(all.slice(1)); + this.categories = this.categories.filter(c => !extras.has(c)); + } + + /** + * 收敛「已完成任务」分类 —— 与 _consolidateTrash / _consolidateUncategorized 对称。 + * + * v3.4 起「已完成任务」是真实分类(kind=COMPLETED),与回收站、未分类同列为保留名 + * 兜底。手工编辑产物有两类必须收敛: + * + * 1. 文件里写了多个 `# 已完成任务` → parser 解析出多个 COMPLETED。把后面的任务 + * 并进第一个,再删掉多余的分类。绝不丢任务:已完成任务只能由用户自己删。 + * 2. 把「已完成任务」写成了容器下的 `## 已完成任务`(解析成 NORMAL 子分类)→ + * 提升为 COMPLETED,避免 getCompletedCategory() / getCategory('已完成任务') 指向错 + * 的对象(与 _consolidateTrash 第一段同源问题)。 + * + * 与 _consolidateTrash 的细微差异:不主动从 NORMAL 子分类里「搬 completed=true 的 + * 任务」过来 —— 那是 _migrateCompletedTasksToCategory 的职责(且 v3.4 数据归属不变 + * 量规定 NORMAL 子分类里不该有 completed=true 任务,发现就该报错/迁移而不是悄悄 + * 兜底)。本函数只处理「分类本身重复/错名」,不混职责。 + */ + _consolidateCompleted() { + // 把「名字正好是规范名『已完成任务』、但被解析成普通子分类」的那一个正名。 + // 与 _consolidateTrash 第一段同源:避免 _getOrCreateCompletedCategory() 与文件里 + // 已有的同名普通子分类撞名。名字必须是规范名 COMPLETED_NAME 才认,别名(completed + // tasks / 完成 / finished)不强制 —— 与 _consolidateTrash 行为对称。 + for (const c of this.categories) { + if (c.kind === CategoryKind.COMPLETED) continue; + if (c.kind === CategoryKind.OTHER_TASKS || c.kind === CategoryKind.TODAY) continue; + if ((c.name || '').trim() === COMPLETED_NAME) { + c.kind = CategoryKind.COMPLETED; + c.isSpecial = true; + c.parentOtherTasks = false; // 已完成任务是 h1,不能挂在容器下 + } + } + + const all = this.categories.filter(c => c.kind === CategoryKind.COMPLETED); + if (all.length <= 1) return; + + const primary = all[0]; + for (const dup of all.slice(1)) { + primary.tasks.push(...dup.tasks); + } + const extras = new Set(all.slice(1)); + this.categories = this.categories.filter(c => !extras.has(c)); + } + + // ============================================ + // 搜索 + // ============================================ + + setSearchQuery(query) { + const newQuery = (query || '').trim(); + // 与 setFilter / setSortBy 同款:值未变就别 emit,避免每次同值输入都触发 + // 全量重渲染(每键都打 'change' 会让 task-list.js / sidebar.js 都跑一遍)。 + if (newQuery === this.searchQuery) return; + this.searchQuery = newQuery; + // 同步缓存小写版:highlightTaskText / getVisibleTasks 都按 + // 任务调一次,若每次都重新 toLowerCase() 会变成 O(任务数 × 搜索词长) 的冗余计算。 + // 而搜索词在 setSearchQuery 之外不会变 —— 在这里算一次就够了,渲染热路径 + // 里直接查表。 + this._searchQueryLower = this.searchQuery.toLowerCase(); + // v4+ 修复:旧实现 emit('search', ...) —— 但全项目无任何 on('search') 订阅方 + // (grep 已确认)。监听者全靠 'change' 事件触发重渲染,这条 emit 是死代码。 + // 搜索词变化已通过 'change' 事件传达,这里只留 change 一条就够了。 + this.emit('change'); + } + + setFilter(filter) { + if (!Object.values(Filter).includes(filter)) return; + if (this.filter === filter) return; + this.filter = filter; + this.emit('filter', filter); + this.emit('change'); + } + + setSortBy(sortBy) { + if (!Object.values(SortBy).includes(sortBy)) return; + if (this.sortBy === sortBy) return; + this.sortBy = sortBy; + this.emit('sort', sortBy); + this.emit('change'); + } + + /** + * 内部 helper:把排序方式切到 MANUAL 并发 sort 事件。 + * + * 三处原本各自复制 4 行「判不等 → 赋值 → emit('sort')」(reorderTask 1425、 + * batchMoveTasks 1672、loadFromContent 文件顺序触发的 303),抽到这里。 + * + * 保留判空(已经是 MANUAL 时直接 return)—— 否则每次用户拖拽都会重发 sort + * 事件,触发整列表的 renderer 重渲染。判空靠 this.sortBy === MANUAL 一行短路, + * 不需要额外缓存字段。 + */ + _switchToManualSort() { + if (this.sortBy === SortBy.MANUAL) return; + this.sortBy = SortBy.MANUAL; + this.emit('sort', SortBy.MANUAL); + } + + /** + * 获取任务在当前过滤/排序下的可见列表 + * + * 过滤顺序:filter → search → sort + * 排序策略: + * - MANUAL:保持 cat.tasks 原始顺序(用户在 UI 上的手动调整) + * - ALPHABET:未完成在前 + 已完成在后,每组内按字母排(中文混排由 localeCompare 自然处理) + */ + getVisibleTasks(category) { + if (!category) return []; + let tasks = category.tasks; + + // 1) 过滤 + // + // 「当前任务 / 已完成任务 / 重要任务」智能列表本身就是一次过滤的结果,语义已被列表名固定, + // 再叠加全局 filter 只会自相矛盾(例如在「已完成」里选 filter=pending → 永远空列表)。 + // UI 上这三个列表的过滤按钮是隐藏的(task-list.js 的 hideOnSmart), + // 所以这里必须同步跳过过滤,否则用户被一个看不见、也关不掉的过滤器锁死。 + const bypassFilter = + category.isSmartList && + (category.smartKey === 'completed' || category.smartKey === 'important' || category.smartKey === 'current'); + + if (!bypassFilter) { + if (this.filter === Filter.CURRENT) { + tasks = tasks.filter(t => t.current); + } else if (this.filter === Filter.COMPLETED) { + tasks = tasks.filter(t => t.completed); + } else if (this.filter === Filter.IMPORTANT) { + tasks = tasks.filter(t => t.important); + } + } + + // 2) 搜索(在过滤结果上再次过滤) + if (this.searchQuery) { + // _searchQueryLower 由 setSearchQuery 同步缓存,避免每次 filter 回调里 + // 再 toLowerCase() 一次(任务量大时会变热路径里的可见瓶颈) + // + // v3.7+:把 originalCategory 也纳入匹配 —— UI 上原分类标签是独立 badge + // 显示,搜索框只匹配 text 会让用户搜不到「原分类:工作」里的"工作"两字。 + // 把 origCat 加到 includes 里扩大语义:搜"工作"既能命中 text 里出现 + // "工作"的任务,也能命中原始分类为「工作」(现在在已完成 / 回收站), + // 用户找跨分类源头时不会再以为"为什么搜不到"。 + // origCat 为 null 时短路 —— 空字符串 includes 任何查询都会 true,会把 + // 所有任务都匹配(这是典型 bug vector,必须先做 null 检查)。 + tasks = tasks.filter(t => + t.text.toLowerCase().includes(this._searchQueryLower) || + (t.originalCategory && t.originalCategory.toLowerCase().includes(this._searchQueryLower)) + ); + } + + // 3) 排序 + if (this.sortBy === SortBy.ALPHABET) { + // 不修改原数组;先拆成「未完成 / 已完成」两组,组内按字母排,再拼接 + // 这样已完成任务统一沉底,避免穿插在未完成之间的视觉噪音 + // 不传 locale:用浏览器/系统默认 locale,让中文/英文/数字混排走自然的 ICU 排序 + const pending = tasks.filter(t => !t.completed); + const completed = tasks.filter(t => t.completed); + const byAlpha = (a, b) => a.text.localeCompare(b.text); + pending.sort(byAlpha); + completed.sort(byAlpha); + tasks = [...pending, ...completed]; + } + return tasks; + } + + /** + * 获取带搜索高亮的任务文本段 + */ + highlightTaskText(text) { + if (!this.searchQuery) return [{ text, highlight: false }]; + const q = this.searchQuery; + const parts = []; + // 在 text 上做大小写不敏感匹配 —— 不用 lowerText.indexOf,因为后者在 + // toLowerCase() 改变字符长度的边界场景(土耳其语 İ → i̇ 长度 1→2)下, + // lowerText 下标与 text 下标不对应,slice 会错位(高亮位置交替错乱)。 + // 算法:逐字符扫描 text,对每个起点尝试把 lowerQuery 与后续字符的 .toLowerCase() + // 比较。语义与 text.toLowerCase().includes(query.toLowerCase()) 完全对齐: + // - text="İ", q="i" → lowerText="i̇", lowerQuery="i","i̇".includes("i")=true, + // 高亮 "İ" 也标 highlight(虽然只消耗了 "i̇" 的前 1 个字符) + // - text="Hello World", q="l" → 单字符匹配,高亮每个 "l" + // 复杂度 O(n*m),但任务文本都很短(典型几十字),无性能问题。 + // 不走 RegExp 路线:避免 searchQuery 里的元字符(. * ? + 等)触发非预期匹配, + // 也免去对 query 做转义的额外辅助函数。 + const lowerQuery = this._searchQueryLower; + const queryLen = lowerQuery.length; + let cursor = 0; + let i = 0; + while (i < text.length) { + let matchLen = 0; // text 字符数(待匹配会跨多少个 text 字符) + let lowerLen = 0; // 已匹配的 lowerText 字符数 + let matched = true; + while (lowerLen < queryLen) { + if (i + matchLen >= text.length) { matched = false; break; } + const charLower = text[i + matchLen].toLowerCase(); + // charLower 的前 N 个字符必须 === lowerQuery[lowerLen..lowerLen+N], + // 其中 N = min(charLower.length, queryLen - lowerLen) —— 因为 toLowerCase() + // 可能让一个 text 字符展开成多个 lowerText 字符(如 İ → i̇),我们需要按 + // 「lowerQuery 还差多少字符」来决定这个 text 字符能贡献多少 lowerText 字符。 + const remaining = queryLen - lowerLen; + const take = Math.min(charLower.length, remaining); + if (charLower.slice(0, take) !== lowerQuery.slice(lowerLen, lowerLen + take)) { + matched = false; + break; + } + lowerLen += take; + matchLen++; + } + if (matched) { + if (cursor < i) { + parts.push({ text: text.slice(cursor, i), highlight: false }); + } + parts.push({ text: text.slice(i, i + matchLen), highlight: true }); + cursor = i + matchLen; + i = cursor; // 跳到匹配结束位置继续 + } else { + i++; + } + } + if (cursor < text.length) { + parts.push({ text: text.slice(cursor), highlight: false }); + } + return parts; + } + + // ============================================ + // 主题 + // ============================================ + + resolveTheme() { + if (this.theme === 'auto') { + return window.matchMedia?.('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; + } + return this.theme === 'light' ? 'light' : 'dark'; + } + + setTheme(theme) { + if (theme !== 'light' && theme !== 'dark' && theme !== 'auto') return; + this.theme = theme; + this.emit('theme', theme); + } + + // ============================================ + // 内部辅助 + // ============================================ + + /** + * 确保基础结构存在(全部任务 容器 + 至少一个子分类「未分类」+ 已完成任务 + 回收站) + * 并做一次性迁移: + * - 旧的 `# 当前任务` / `# 我的一天` 等章节 → 任务搬到首个子分类并打 @当前 标记,删掉分类 + * - 旧的 NORMAL 分类 → 「全部任务」下的子分类 + * - 旧的「其他任务」/「全部任务」/「待办任务」容器名 → 「全部任务」 + * - 「# 回收站」多份/挂错位置 → 收敛成唯一一个 kind=TRASH + * - v3.4:散落在各子分类的 [✓] 任务 → 搬到 kind=COMPLETED 的「# 已完成任务」 + * (一次性升级迁移。之后的 toggle / batchToggle / restore 都会自动保持 + * 「completed=true 的任务只活在已完成分类里」的不变量) + * + * v3 起不再需要「当前任务」真实分类 —— 它下沉为 @当前 标识 + 智能视图。 + * 旧文件里仍有的 `# 当前任务` 章节会被迁移方法一次性消化, + * 下一次保存时新文件就是纯标识驱动结构。 + */ + _ensureBaseStructure() { + // 归一:手工编辑的文件可能出现「多个 # 回收站」或「## 回收站 子分类」。 + // 两种情况都必须收敛成唯一一个 kind=TRASH,否则: + // - 多个 TRASH:getTrashCategory() 只返回第一个,后面那些的任务在界面上 + // 既看不见也没法恢复(文件里还在,但对用户等于消失了)。 + // - 「回收站」被当成普通子分类:之后 _getOrCreateTrashCategory() 会再建一个 + // 同名分类,getCategory('回收站') 从此指向错的那个,恢复/删除全部错位。 + // 合并而不是丢弃 —— 回收站里的东西只能由用户手工删。 + this._consolidateTrash(); + + // 「未分类」收敛 —— 与 _consolidateTrash 对称。parser 已经处理大部分重复, + // 这里兜底防御任何绕过 parser 的路径(merge 工具、外部导入、调试脚本……)。 + this._consolidateUncategorized(); + + // 迁移:「其他任务」/「全部任务」/「待办任务」容器名 → 「全部任务」(v2 重命名链) + // 同时确保容器存在 —— v3 起这是唯一一个真实容器章节 + this._ensureOtherTasksContainer(); + + // 确保至少有 1 个子分类(默认「未分类」)—— _migrateTodayCategory 需要目标 + this._ensureUncategorizedSub(); + + // 迁移:旧的 `# 当前任务` / `# 我的一天` 章节下的任务 → 搬到子分类 + 打 @当前 标记 + // 必须在 _normalizeOrder 之前执行,否则临时分类会被排到末尾再被合并逻辑忽略。 + this._migrateTodayCategory(); + + // v3.4:确保「# 已完成任务」分类存在,并把散落在子分类里的 [✓] 任务搬过去。 + // 这一步**必须**在 `_normalizeOrder` 之前 —— 否则迁移过去的目标位置(kind=COMPLETED + // 在子分类之后、回收站之前)会被排到末尾再被合并不见。 + // + // 同样的:先 `_consolidateCompleted()` 收敛文件里已有的多个 `# 已完成任务` 副本或 + // 误解析为 NORMAL 的同名分类,再 `_getOrCreateCompletedCategory()` 兜底确认存在。 + // 顺序与 _consolidateTrash / _getOrCreateTrashCategory 的前后对称。 + this._consolidateCompleted(); + this._getOrCreateCompletedCategory(); + this._migrateCompletedTasksToCategory(); + + // 迁移:旧的 NORMAL 分类 → 「全部任务」下的子分类 + for (const c of this.categories) { + if (c.kind === CategoryKind.NORMAL && !c.parentOtherTasks) { + c.parentOtherTasks = true; + } + } + + // 兜底:若迁移过程里容器/子分类被误删,再建一次 + if (!this._findByKind(CategoryKind.OTHER_TASKS)) { + const o = this._createCategory(OTHER_TASKS_NAME, CategoryKind.OTHER_TASKS); + this.categories.unshift(o); + } + if (!this.categories.some(c => c.parentOtherTasks)) { + this._ensureUncategorizedSub(); + } + + // 最后统一归位(必须在所有迁移之后) + this._normalizeOrder(); + } + + /** + * 把 categories 数组按规范顺序归位:[当前任务, 全部任务容器, ...子分类, 已完成任务, 回收站]。 + * + * 子分类之间的相对顺序保持不变(用户的手动排序不能被打乱), + * **除了「未分类」必须始终排在子分类末尾** —— 它是删除/取消勾选时的任务兜底, + * 任何让它漂离末尾的状态都会让用户新建的分类掉到它下面,体验与 bug 一致。 + * + * 手工编辑的文件 / 旧版迁移残留 / 外部合并都可能让「未分类」出现 + * 在子分类中间,这里统一拉回 —— 与 reorderSubCategory 在运行时侧 + * 阻止「未分类」被拖走形成对称:运行时守入口,加载时兜底归位。 + * + * 为什么必须做:writeMarkdown 完全依赖数组顺序 —— 容器写成 `# 全部任务`, + * 其后的 parentOtherTasks 写成 `## 子分类`。若某个子分类排在容器**之前** + * (旧文件里 `# 工作` 出现在 `# 全部任务` 之前时,上面的迁移只改标记不改位置), + * 就会写出一个位于容器外的 `## 工作`;而 parseMarkdown 会忽略容器外的 h2, + * 该分类连同它的任务会在下次加载时被静默并入上一个分类 —— 即数据损坏。 + * + * v3.4 起新增「已完成任务」位置:排在**所有子分类之后、回收站之前**。 + * 回收站必须排在**最末尾**:它写成 `# 回收站`(h1), + * 一旦夹在子分类中间就会提前关闭容器段,让后面的 `## 子分类` 落到容器之外 —— + * 与上面同一种数据损坏。 + * + * @param {Array} [categories] 不传则用 this.categories + * @returns {Array} 重排后的数组 + */ + _normalizeOrder(categories = this.categories) { + const today = categories.filter(c => c.kind === CategoryKind.TODAY); + const container = categories.filter(c => c.kind === CategoryKind.OTHER_TASKS); + const completed = categories.filter(c => c.kind === CategoryKind.COMPLETED); + const trash = categories.filter(c => c.kind === CategoryKind.TRASH); + const subs = categories.filter( + c => c.kind !== CategoryKind.TODAY && + c.kind !== CategoryKind.OTHER_TASKS && + c.kind !== CategoryKind.COMPLETED && + c.kind !== CategoryKind.TRASH + ); + // 「未分类」拉回子分类末尾(仅在它存在且不在末尾时)—— 详见方法顶部注释 + const unIdx = subs.findIndex(c => c.name === UNCATEGORIZED_NAME); + let orderedSubs = subs; + if (unIdx >= 0 && unIdx !== subs.length - 1) { + const unCat = subs[unIdx]; + orderedSubs = [...subs.slice(0, unIdx), ...subs.slice(unIdx + 1), unCat]; + } + const ordered = [...today, ...container, ...orderedSubs, ...completed, ...trash]; + if (categories === this.categories) { + this.categories = ordered; + } + return ordered; + } + + /** + * v3.4 一次性升级迁移:把散落在各子分类的 [✓] 任务搬到「# 已完成任务」分类。 + * + * 升级前:用户在「工作」「学习」等子分类里直接 `[✓]` 任务 —— 任务实体 + * 留在子分类,仅靠 `task.completed=true` 标记。 + * 升级后:所有 [✓] 任务必须只活在 kind=COMPLETED 分类里 —— 与「回收站」 + * 同款「数据归属与状态同步」原则。 + * + * 升级窗口内的归位: + * 1. 子分类里 completed=true 的任务 → 搬到「# 已完成任务」,保留顺序与文本 + * 2. 旧文件可能在「# 已完成任务」(旧版做镜像段)下已经有任务 —— parser + * v3.4 起把这种 h1 识别为真实分类后,那些任务保留在原 cat.tasks 里 + * (已是目标位置),无需重复搬 + * 3. 旧版「# 已完成任务 镜像段」下的任务可能带 [⭐]/[▶] inline 标记, + * merge 时已经由 parseMarkdown 抽进 fields,文本也已经被剥净 —— 这里 + * 迁移的只是 completed=true 的判定,标记字段保持原状 + * + * 仅在加载时跑一次 —— 运行时 toggle/batchToggle 已自带跨分类搬运,这条迁移 + * 路径不会被重复触发。 + */ + _migrateCompletedTasksToCategory() { + const completedCat = this.getCompletedCategory(); + if (!completedCat) return; + let movedCount = 0; + for (const cat of this.categories) { + if (cat === completedCat) continue; + // 只从子分类 / TODAY 临时分类搬;TRASH 里允许有 completed=true 的任务 + // (用户可能勾选了回收站里的任务),不该被打扰 + if (cat.kind === CategoryKind.TRASH) continue; + if (cat.kind === CategoryKind.OTHER_TASKS) continue; + // 收集再统一提交,避免边遍历 cat.tasks 边往 completedCat.tasks 里 push + // 时若分类有引用关系(极端情况下 cat === completedCat)导致下标漂移 + const moving = []; + const keep = []; + for (const t of cat.tasks) { + if (t.completed) moving.push(t); + else keep.push(t); + } + if (moving.length > 0) { + cat.tasks = keep; + // v3.5+:迁移时把来源子分类名写到每条任务的 originalCategory 上 —— 旧文件 + // 升级后用户立即享受"取消勾选回到原分类"的能力,不会因为升级而丢掉这个信息。 + // 仅在来源是子分类(parentOtherTasks)时记 —— TODAY 临时分类不该成为原分类语义 + // (迁移完它就被删了,记它会让后续恢复去找一个不存在的分类)。 + const isSubcategory = !!cat.parentOtherTasks; + for (const t of moving) { + if (isSubcategory && !t.originalCategory) { + t.originalCategory = cat.name; + } + } + // 顺序保留:把 moving 直接 push 到 completedCat.tasks 末尾,保持"按子分类 + // 出现顺序 + 子分类内任务顺序"的全局顺序 —— 与 UI 上看到的一致。 + completedCat.tasks.push(...moving); + movedCount += moving.length; + } + } + // 静默迁移(不 emit、不 toast):用户视角是"打开文件后已完成任务自动归位", + // 就像"加载新版本后文件自动重排"一样的预期,不需要打扰。 + // 仅在确实有迁移发生时留给后续 _markDirty / autoSave 流程统一刷一次磁盘 + // (loadFromContent 已经把 dirty=false 显式设上了 —— 这里不必再推一次)。 + if (movedCount > 0 && typeof console !== 'undefined') { + console.info(`[task-store] 已完成任务升级迁移:${movedCount} 条任务搬到「# 已完成任务」`); + } + } + + /** + * v3 迁移核心:把旧文件里 `# 当前任务` / `# 我的一天` 等章节下的任务, + * 搬到首个子分类并打上 current=true 标记,然后删掉这个临时分类。 + * + * 为什么不直接合并到某个固定子分类(如「未分类」)? + * - 旧文件通常已有更贴近用户意图的子分类(工作/学习/…)—— 用户以前一定是 + * 先在子分类里写任务,再复制到「当前任务」方便聚焦。新策略要求每条任务有 + * 自己的归属分类,搬到「首个子分类」是最不让人意外的兜底。 + * - 已无任务的临时分类直接删掉,不留痕迹。 + * + * 为什么不保留这个分类作为 NORMAL(让用户可以手动删)? + * - 那样它会出现在侧边栏的「全部任务」容器下,且无法被识别为「当前任务」视图。 + * 用户在「当前任务」视图里看到的将是空集,体验非常迷惑。 + * - 直接迁移 + 删除,副作用最小:用户下次保存就只剩扁平结构了。 + */ + _migrateTodayCategory() { + const todayCat = this._findByKind(CategoryKind.TODAY); + if (!todayCat) return; + + // 临时分类没任务:直接删掉,不影响数据 + if (!todayCat.tasks || todayCat.tasks.length === 0) { + const idx = this.categories.indexOf(todayCat); + if (idx >= 0) this.categories.splice(idx, 1); + return; + } + + // 挑目标子分类:优先「未分类」(任务没归处时的合理兜底), + // 否则用第一个现存子分类。_ensureUncategorizedSub 已保证至少有「未分类」。 + let target = this.categories.find( + c => c.parentOtherTasks && c.name === UNCATEGORIZED_NAME + ); + if (!target) { + target = this.categories.find(c => c.parentOtherTasks); + } + if (!target) { + target = this._getOrCreateUncategorizedCategory(); + } + + // 合并任务 + 打 @当前 标记 + for (const task of todayCat.tasks) { + task.current = true; + target.tasks.push(task); + } + todayCat.tasks = []; + + // 删掉临时分类(按引用删除,避免索引错位) + const idx = this.categories.indexOf(todayCat); + if (idx >= 0) this.categories.splice(idx, 1); + } + + /** + * 把主进程 writeFile 返回的 result 包成 save-error 事件载荷: + * { code: result.error, message: result.error, diskContent: result.diskContent } + * + * 历史约定:save-error 事件载荷是字符串(旧版只发 result.error)。 + * v3.4+ 主进程在 EXTERNAL_CHANGE_DETECTED 时附带 diskContent —— 把它也带上, + * 让外部修改冲突流程不需要再发一次 file:read。 + * + * 注意:code 字段固定是 result.error 的字符串(与历史行为对齐), + * 让 app.js 里 `error.code === 'EXTERNAL_CHANGE_DETECTED'` 这种判断保持可读。 + */ + _buildSaveErrorPayload(result) { + if (!result) return { code: 'UNKNOWN', message: '未知错误' }; + return { + code: result.error || 'UNKNOWN', + message: result.error || '未知错误', + diskContent: typeof result.diskContent === 'string' ? result.diskContent : null, + }; + } + + /** + * 确保「全部任务」容器存在,且名字为规范名 + * + * 把 v2 重命名链「其他任务」→「待办任务」→「全部任务」收敛到当前规范名。 + * 单独的辅助方法被 _ensureBaseStructure 复用,避免主流程被命名归一的细节撑大。 + */ + _ensureOtherTasksContainer() { + const existing = this._findByKind(CategoryKind.OTHER_TASKS); + if (!existing) { + const o = this._createCategory(OTHER_TASKS_NAME, CategoryKind.OTHER_TASKS); + this.categories.unshift(o); + return; + } + // 容器名归一:「其他任务」/「待办任务」/「全部任务」 → 「全部任务」 + if (existing.name !== OTHER_TASKS_NAME) { + existing.name = OTHER_TASKS_NAME; + } + } + + /** + * 确保至少有 1 个子分类(默认「未分类」) + * + * 是 _migrateTodayCategory 的前置条件:没有目标子分类就无法迁移旧 TODAY 章节的任务。 + * 单独抽出避免主流程被条件分支撑大。 + */ + _ensureUncategorizedSub() { + if (this.categories.some(c => c.parentOtherTasks)) return; + const sub = this._createCategory(UNCATEGORIZED_NAME, CategoryKind.NORMAL); + sub.parentOtherTasks = true; + const oIdx = this.categories.findIndex(c => c.kind === CategoryKind.OTHER_TASKS); + if (oIdx >= 0) { + this.categories.splice(oIdx + 1, 0, sub); + } else { + this.categories.push(sub); + } + } + + /** + * 把内存状态标为「有未保存改动」并触发自动保存。 + * + * 可选参数让调用方告诉 store「这次改动影响了哪些对象」—— 给双向同步 + * 冲突对话框列举「用户在内存里改了哪些任务 / 哪些分类」用: + * - tasks: 字符串或字符串数组,加入 _dirtyTaskIds + * - categories: 字符串或字符串数组,加入 _dirtyCategoryNames + * + * 不传 = 旧行为(结构改动但忘了标 id,这时 dirty=true 但两个 Set 都空, + * UI 显示「1 项未保存」但无法列出具体内容 —— 这是兜底)。 + * + * 批量操作(importTasksToCategory / clearCompleted / batchDelete 等)应在末尾 + * 一次性把所有 id 传进来,避免「对话期间只看到最后一条 id」的视觉错位。 + * + * @param {{tasks?: string|string[], categories?: string|string[]}} [details] + */ + _markDirty(details) { + if (this._suppressDirty) return; + const wasDirty = this.dirty; + this.dirty = true; + this._dirtyVersion++; + if (details) { + if (details.tasks) { + const arr = Array.isArray(details.tasks) ? details.tasks : [details.tasks]; + for (const id of arr) { + if (id) this._dirtyTaskIds.add(id); + } + } + if (details.categories) { + const arr = Array.isArray(details.categories) ? details.categories : [details.categories]; + for (const name of arr) { + if (name) this._dirtyCategoryNames.add(name); + } + } + if (!details.tasks && !details.categories) { + // 调用方显式传了 details 但里面没东西 —— 视为「只有结构性改动」 + this._dirtyStructuralOnly = true; + } + } else { + // 没传 details —— 同上,只能认定是结构性改动(旧代码 fallback 路径) + this._dirtyStructuralOnly = true; + } + this._autoSave(); + // 仅在 false → true 转移时 emit。重复 markDirty 常见于批量操作(一次交互里 + // 多条任务标记完成会触发 N 次本方法),不停刷状态栏/工具栏文件信息是浪费 + // —— dirty 状态本身没变。 + // 必须**始终**递增 _dirtyVersion:saveNow 用它判断「序列化后到 await writeFile + // 返回之间是否有过新编辑」,版本号必须能区分每次编辑才能正确清 dirty。 + if (!wasDirty) { + this.emit('dirty', true); + } + } + + /** + * 清空 dirty 追踪(saveNow / _doAutoSave 写盘成功后调用)。 + * 不传参 = 清全部;传 taskIds / categoryNames = 只清指定项。 + * + * 注意:dirty=false 的 emit 在 saveNow / _doAutoSave 那里已发,这里只负责 + * 同步追踪集合。调用方负责统一协调。 + * + * @param {{tasks?: string[], categories?: string[]}} [only] + */ + _clearDirtyTracking(only) { + if (!only) { + this._dirtyTaskIds.clear(); + this._dirtyCategoryNames.clear(); + this._dirtyStructuralOnly = false; + return; + } + if (only.tasks) { + for (const id of only.tasks) this._dirtyTaskIds.delete(id); + } + if (only.categories) { + for (const name of only.categories) this._dirtyCategoryNames.delete(name); + } + // v3.7+ 修复 M-D8:部分清后若两个 Set 都已空,重置 _dirtyStructuralOnly。 + // 旧行为是「dirty=true + 两个 Set 空 + structuralOnly=true」会让 + // getDirtySnapshot 返回 changeCount: 1 但列举不到任何 task/cat —— UI 显示「1 项未保存」 + // 但 conflict dialog 列不出项。reorderSubCategory 这类只标 categories 的路径 + // 保存后 categories Set 被清空但 structuralOnly 残留,导致 UI 永远显示未保存。 + // 修复:部分清后若两个 Set 都已空,structuralOnly 没有保留意义(兜底语义是 + // 「dirty=true + Set 空时仍能记录」,而 partial clear 后 dirty 由调用方维护), + // 一并清掉。 + if (this._dirtyTaskIds.size === 0 && this._dirtyCategoryNames.size === 0) { + this._dirtyStructuralOnly = false; + } + } + + // ============================================ + // 双向同步 / 冲突解决 公共 API + // ============================================ + + /** + * 列出「本次会话内存里改过 / 新增 / 移除过的 task id」。 + * UI / 冲突解决对话框用它列举「本地独有的任务」与「需要快照保护的本地任务」。 + * + * 注意 task id 是运行时句柄,跨 reload 重新分配 —— 这个 API 仅在当前会话内稳定。 + * + * @returns {{ + * taskIds: Set, + * categoryNames: Set, + * changeCount: number, + * structuralOnly: boolean + * }} + */ + getDirtySnapshot() { + // changeCount = 任务数 + 分类数 + 结构标记 —— UI 用来显示「N 项未保存」 + // 当 structuralOnly=true 且两个 Set 都空时,返回 1(兜底) + let count = this._dirtyTaskIds.size + this._dirtyCategoryNames.size; + if (count === 0 && this._dirtyStructuralOnly && this.dirty) count = 1; + return { + taskIds: new Set(this._dirtyTaskIds), + categoryNames: new Set(this._dirtyCategoryNames), + changeCount: count, + // structuralOnly = 「只是结构性兜底改动、没有真实任务/分类脏」。原写法 + // `count <= sum(setSizes)` 是反向逻辑:两 Set 空时 count=1、sum=0、1<=0=false + // 返回 false;两 Set 有真实 id 时 count=X、sum=X、X<=X=true 返回 true —— 完全反掉。 + // 正确语义:仅当两 Set 都为空、且确实有结构标记时才算「纯结构性」。 + structuralOnly: + this._dirtyStructuralOnly && + this._dirtyTaskIds.size === 0 && + this._dirtyCategoryNames.size === 0, + }; + } + + /** + * 缓存「最近一次外部修改后的磁盘内容」。 + * resolveExternalChangeConflict 第一次拿到 diskContent 时调用, + * dialog 关闭时调 clearDiskSnapshot() 释放。 + * + * @param {{filePath: string, content: string, parsedCategories: Array, fetchedAt: number}} snap + */ + setDiskSnapshot(snap) { + this.diskSnapshot = snap; + } + + clearDiskSnapshot() { + this.diskSnapshot = null; + } + + /** + * 同步磁盘 categories 与内存 categories,计算 diff。 + * + * @param {Array} diskCategories - 磁盘文件解析出的 categories + * @returns {{ + * onlyInDisk: Array, + * onlyInMemory: Array, + * modified: Array, + * unchanged: Array, + * summary: { total: number, addedByFile: number, addedByMine: number, conflicting: number } + * }} + */ + diffWithDisk(diskCategories) { + const diff = diffCategoriesUtil(diskCategories, this.categories); + return { + onlyInDisk: diff.onlyInA, + onlyInMemory: diff.onlyInB, + modified: diff.modified, + unchanged: diff.unchanged, + summary: summarizeDiffUtil(diff), + }; + } + + /** + * 根据用户在冲突对话框里的选择,把磁盘版本「合并」进内存。 + * + * 与 loadFromContent 的区别: + * - loadFromContent 是「磁盘内容直接覆盖」,用户当前的选择 / 滚动位置可能跳走 + * - mergeFromDisk 是「用户态(选中分类 / 搜索词等)保留 + categories 数组替换」, + * 因为合并结果是用户主动选出来的,不是被动接受的 + * + * @param {Array} diskCategories - 磁盘版本 + * @param {object} resolutions - {taskKey: 'a'|'b'|'both'|'skip'} + * @returns {object} 合并后的 categories 数组(已 _normalizeOrder) + */ + mergeFromDisk(diskCategories, resolutions) { + // 与 loadFromContent 同理:合并前必须废掉 in-flight 自动保存,避免 _doAutoSave + // 醒来时把刚合并进来的 categories 又覆盖回磁盘、或者发「saved」假象。 + this.cancelPendingSave(); + this._suppressDirty = true; + try { + const diff = diffCategoriesUtil(diskCategories, this.categories); + const merged = applyResolutionsUtil(diskCategories, diff, resolutions || {}); + // 顺序归位(含「未分类」拉回末尾 —— 合并可能让兜底位置漂移) + this.categories = this._normalizeOrder(merged); + // 重新分配 id(合并进来的磁盘任务没有运行时 id) + this._assignTaskIds(); + // 关键:diskCategories 来自 parseMarkdown —— parser 只按名字标 kind, + // **不**跑迁移逻辑(旧文件里散落的 [✓] 任务、重复「未分类」、缺失的 + // 「已完成任务」分类都不会被自动收敛)。loadFromContent 走完之后 + // _ensureBaseStructure 会兜底;mergeFromDisk 以前漏了这一步, + // 导致合并结果可能违反 v3.4 数据归属不变量: + // - completed=true 任务留在 NORMAL 子分类 + // - 没有「已完成任务」分类 → 已完成任务对 UI 不可见 + // - 重复的「未分类」让 _resolveRestoreTarget 拿到第一个就返回 + // 这里统一过一遍 _ensureBaseStructure 把这些边界情况压平: + // - _migrateCompletedTasksToCategory 把散落 [✓] 搬到「已完成任务」 + // - _consolidateUncategorized / _consolidateTrash / _consolidateCompleted + // 合并重名分类 + // - _ensureOtherTasksContainer / _ensureUncategorizedSub / _getOrCreateCompletedCategory + // 把缺失的容器/兜底分类补齐 + // _ensureBaseStructure 内部已经做 _normalizeOrder(幂等)+ NORMAL→parentOtherTasks + // 矫正,所以前面那次 _normalizeOrder 可以保留也可省 —— 留着不亏,便于排查。 + this._ensureBaseStructure(); + // 合并是用户的「主动决策」,不需要立刻标 dirty —— 调用方接下来会 saveNow() + } finally { + this._suppressDirty = false; + } + // 触发 UI 重渲染 + this.dirty = false; + this._dirtyTaskIds.clear(); + this._dirtyCategoryNames.clear(); + this._dirtyStructuralOnly = false; + this.emit('load', { categories: this.categories, merged: true }); + this.emit('change'); + this.emit('dirty', false); + } + + /** + * 给冲突对话框的「默认选择」生成 helper。 + * 复用 markdown-diff.js 的逻辑。 + * + * @param {object} diff - diffWithDisk() 返回值 + * @returns {object} resolutions + */ + defaultMergeResolutions(diff) { + return defaultResolutionsUtil({ + onlyInA: diff.onlyInDisk, + onlyInB: diff.onlyInMemory, + modified: diff.modified, + }); + } + + /** + * 调度一次自动保存。无 debounce,直接走 _doAutoSave —— 但如果有 in-flight 写正在进行 + * 则只标记 _needsResave,让 finally 里的补写路径兜底。 + * + * v4 全实时同步的核心入口: + * - 单次编辑 → 直接启动 _doAutoSave,await 写完即关 + * - await 期间再次编辑 → 仅置 _needsResave=true,避免并发写 + * - finally → 检查 _needsResave && dirty,自动再启动一次 _doAutoSave + * + * 语义上等价于「writeFile 永远尽快写,没有等待窗口,但同一时刻只有一个写 in-flight」: + * - 用户每次勾选/编辑 → 立刻触发写 + * - 快速连击 → 第一个写 in-flight,后续合并为一次尾随写 + * - 写失败 → 保留 dirty,下一次 _markDirty 会重试 + */ + _scheduleAutoSave() { + if (!this.dirty || !this.filePath) return; + if (this._inFlightGeneration > 0) { + // 已有写正在进行中 —— 标记等待中写完后再补一次保存,避免并发写竞争 + // (不同代次的 writeFile 同时落地 → 谁后写谁覆盖前者,可能反向覆盖最新状态) + this._needsResave = true; + return; + } + this._doAutoSave(); + } + + // ======================================================================== + // ⚠️ 与 saveNow() 共享「代次 + 脏版本双检查」骨架 —— 两份实现必须保持同步。 + // ---------------------------------------------------------------------- + // 故意保留重复,不抽 helper:autoSave(用户隐式触发)与 saveNow(用户显式 + // force 写)是语义独立的两条防线,force 路径若坏掉不应影响自动保存,反之亦然。 + // + // 共享骨架(两处都要更新): + // 1) ++this._writeGeneration 抓代次 → 在 await 期间用户又改 → await 回来 + // 时若 generation 不匹配则视为过期,不清 dirty / 不发 saved + // 2) 序列化前抓 _dirtyVersion → await 回来时若版本变大(说明快照不含最新 + // 改动)也保留 dirty,避免「磁盘没新东西但 UI 显示已同步」 + // 3) 走 window.api.writeFile(不带 force 走自动保存 / 带 force 走用户显式) + // 4) finally 检测 _needsResave → 立即再补一次自动保存 + // + // 改前先看 saveNow(),把同一组语义变化在那边也同步上。 + // ======================================================================== + async _doAutoSave() { + if (!this.dirty || !this.filePath) return; + // 二道防线:理论上 _scheduleAutoSave 已把 in-flight 拦在外面,但外部可能直接调进来 + // (比如旧测试或调试场景)—— 守住避免并发。 + if (this._inFlightGeneration > 0) { + this._needsResave = true; + return; + } + const generation = ++this._writeGeneration; + this._inFlightGeneration = generation; + this._needsResave = false; + try { + const content = this.serialize(); + // 在序列化之后捕获脏版本 —— 序列化本身不会脏,所以这个值就是「这次写入涵盖到的最后修改」。 + // await 回来时若版本变大,说明快照里没有那次新修改,必须保留 dirty 让后续自动保存兜底。 + const versionAtSerialize = this._dirtyVersion; + // 自动保存:不带 force 选项,由主进程 mtime 检查保护外部编辑不被覆盖。 + // 若检测到冲突,result.error === 'EXTERNAL_CHANGE_DETECTED',save-error 事件 + // 由渲染端处理并触发外部修改冲突解决流程。 + const result = await window.api.writeFile(this.filePath, content); + // 代次过期:写完之后用户又改过东西、已经触发了更新的自动保存, + // 这次 await 拿到的就是「写出去的旧快照」,不能清 dirty / 发 saved 假象。 + if (this._writeGeneration !== generation) return; + if (this._dirtyVersion !== versionAtSerialize) { + // 快照没包含最新的修改 —— 不能清 dirty,发个「saved」也会让 UI 显示已同步, + // 但磁盘里其实没有那些新东西。_needsResave 已被 _scheduleAutoSave 在用户改时 + // 置上,finally 里会再补一次 _doAutoSave 把它们写出去。 + return; + } + if (result.ok) { + this.dirty = false; + this._clearDirtyTracking(); + // auto=true:通知 UI 更新状态栏,但不弹 toast(避免频繁打扰用户) + this.emit('saved', { filePath: this.filePath, auto: true }); + this.emit('dirty', false); + } else { + // v3.4+: 载荷统一为 {code, message, diskContent?} 对象,让外部修改冲突流程 + // 不需要再发一次 file:read 就能拿到磁盘当前内容(见 main.js EXTERNAL_CHANGE_DETECTED 增强)。 + this.emit('save-error', this._buildSaveErrorPayload(result)); + // v3.7+ 修复 M-D10:保存失败后必须显式保留 dirty 状态。 + // 旧行为是只在 result.ok 分支 emit('dirty', false),失败路径不补 dirty=true, + // UI 在 save-error 处理完后回到脏态依赖监听者猜测(不同 UI 模块行为不一致, + // 状态栏可能不显示「未保存」,用户不知道改动还没落盘)。 + // 修复:在失败路径 emit('dirty', true),但仅当当前代次仍是自己(避免覆盖更新代的 dirty 事件)。 + if (this._writeGeneration === generation) { + this.dirty = true; + this.emit('dirty', true); + } + } + } catch (e) { + if (this._writeGeneration === generation) { + this.emit('save-error', { code: 'EXCEPTION', message: e.message }); + // 同上:异常路径也补一次 dirty=true,保持 UI 状态栏显示「未保存」。 + this.dirty = true; + this.emit('dirty', true); + } + } finally { + if (this._inFlightGeneration === generation) { + this._inFlightGeneration = 0; + } + // in-flight 写期间累积了新改动 → 立即再启动一次保存(补写尾随)。 + // 这是替代原 800ms debounce 的「合并」机制:没有等待窗口,但保证不丢改动。 + // 典型场景:用户在 writeFile await 期间连续勾选多条任务 → 这里一次性把 + // 最新状态写出去,避免「前一条写完但 dirty=true 又卡 800ms」的拖沓感。 + if (this._needsResave && this.dirty && this.filePath) { + this._needsResave = false; + this._doAutoSave(); + } + } + } + + // 取消「已排队但还没写出去」的自动保存,保留 dirty 状态。 + // + // 给「文件被外部改了」这条路径用:那时候必须先把 in-flight 写「作废」, + // 再去问用户怎么办 —— 否则这条 await 回来时还会把内存内容盖回文件, + // 用户手工删掉的行就这么被复活了。而彻底删除任务只能手工编辑文件, + // 所以这条路径正是我们让用户走的那条。 + // + // v4 全实时同步后这里不再有 debounce 定时器可掐,只需把当前 in-flight 代次「作废」—— + // 即便 _doAutoSave 已经 await writeFile 走了,这条 await 醒来时也会发现 + // 代次对上、不去复位 dirty / 不发「saved」假象,避免覆盖文件后还自以为干净。 + cancelPendingSave() { + if (this._inFlightGeneration) { + // 让 in-flight 的 await 醒来时把这次写入视作过期; + // 它不会清 dirty,因此后续 _markDirty 已经触发的下一次自动保存照常工作。 + this._writeGeneration++; + } + } + + // ======================================================================== + // ⚠️ 与 _doAutoSave() 共享「代次 + 脏版本双检查」骨架 —— 两份实现必须保持同步。 + // ---------------------------------------------------------------------- + // 完整说明见 _doAutoSave() 顶部的注释块。这里只强调用户显式保存的差异点: + // - 不判 dirty(用户按 Ctrl+S 时即便没改也要 force 写一遍 —— 主进程的 mtime + // 预检会判断是否有变更,没有就跳过) + // - writeFile 带 force: true,告诉主进程「用户主动写,不要被外部修改拦截」 + // (mtime 冲突时主进程返回 EXTERNAL_CHANGE_DETECTED,渲染端走冲突解决弹框) + // 共享骨架(++代次、抓 _dirtyVersion、finally 检测 _needsResave)必须与 + // _doAutoSave() 同步演进。 + // ======================================================================== + async saveNow() { + if (!this.filePath) { + this.emit('save-error', { code: 'NO_FILE_PATH', message: '尚未设置文件路径' }); + return false; + } + // v4+ 修复:与 _doAutoSave 共享 in-flight 守卫。 + // 旧实现直接 ++_writeGeneration 后覆盖 _inFlightGeneration —— 在主进程层 + // 形成两条并发 writeFile IPC(同文件): + // 1) _doAutoSave 写入 contentA(带 preStat/confirm-stat 双重检查) + // 2) saveNow 写入 contentB(force=true,跳过 preStat 的 lastSeen 比对) + // 第二条的 confirm-stat 检测到第一条 rename 后的 mtime 变化 → 返回 + // EXTERNAL_CHANGE_DETECTED → 渲染端误以为是外部编辑触发的、弹出「文件与本地 + // 不一致」冲突对话框 —— 用户其实没动过外部编辑器。这是真正的 phantom 冲突。 + // + // 修复:与 _doAutoSave 同样的「if (inFlight) { set _needsResave; return false; }」 + // 守卫。_doAutoSave 的 finally 块会检测 _needsResave 并补一次自动保存 —— 用户的 + // 「force save」意图被降级为「等当前 in-flight 结束后再 auto-save」,但因为这条 + // 场景下没有真正的外部编辑,auto-save 会成功落盘,最终结果一致。 + // force=true 的语义被降级为「不绕过 lastSeen 比对」,但 in-flight 是我们自己写入的、 + // lastSeen 已经被前一条 writeFile 更新过,不会误判 EXTERNAL_CHANGE_DETECTED。 + if (this._inFlightGeneration > 0) { + this._needsResave = true; + return false; + } + const generation = ++this._writeGeneration; + this._inFlightGeneration = generation; + this._needsResave = false; + try { + const content = this.serialize(); + // 同 _doAutoSave:在序列化后捕获脏版本,await 期间有新改动时不清 dirty。 + // saveNow 路径上「用户在写出过程中改东西」通常意味着丢盘更快(用户在另存为), + // 但同样要守 —— 否则 dirty 被误清、状态栏显示已保存,下次启动就丢了。 + const versionAtSerialize = this._dirtyVersion; + // saveNow 始终 force=true:调用方都是用户显式意图(Ctrl+S、"保留本地" 按钮、 + // 退出前刷写等),主进程 mtime 检查不会拦截,确保用户意图被尊重。 + const result = await window.api.writeFile(this.filePath, content, { force: true }); + if (this._writeGeneration !== generation) { + // 期间已经被 cancelPendingSave 或另一次 saveNow 顶掉:把是否成功的决定权交给最新一次 + return false; + } + if (this._dirtyVersion !== versionAtSerialize) { + // 快照不含最新修改 —— 不清 dirty、不发 saved / dirty(false),留给后续自动保存兜底 + return false; + } + if (result.ok) { + this.dirty = false; + this._clearDirtyTracking(); + this.emit('saved', { filePath: this.filePath }); + this.emit('dirty', false); + return true; + } else { + this.emit('save-error', this._buildSaveErrorPayload(result)); + return false; + } + } catch (e) { + if (this._writeGeneration === generation) { + this.emit('save-error', { code: 'EXCEPTION', message: e.message }); + } + return false; + } finally { + if (this._inFlightGeneration === generation) { + this._inFlightGeneration = 0; + } + // v4+ 修复:与 _doAutoSave 对称 —— saveNow 完成后若 _needsResave 仍为真, + // 立即补一次自动保存。否则用户在 saveNow 的 await 期间改了东西、saveNow 写完后 + // 这些改动会卡在内存里等下一次 _markDirty(用户停下操作就丢盘)。 + // 用 _doAutoSave 而非 saveNow 自身:避免再次触发 force=true 的 mtime 跳过, + // 同时复用 _doAutoSave 已有的「代次 + 脏版本双检查」骨架。 + if (this._needsResave && this.dirty && this.filePath && this._inFlightGeneration === 0) { + this._needsResave = false; + this._doAutoSave(); + } + } + } + + _createCategory(name, kind = CategoryKind.NORMAL) { + return { + name, + kind, + isSpecial: kind === CategoryKind.TODAY || + kind === CategoryKind.OTHER_TASKS || + kind === CategoryKind.TRASH || + kind === CategoryKind.COMPLETED, + meta: null, + parentOtherTasks: false, + tasks: [] + }; + } + + /** + * 为内存中的任务分配稳定的 id(仅 loadDefault 需要; + * loadFromContent 已在 parser 中赋值;addTask 自己生成) + * + * v4+ 修复:counter 从「已存在 id 的最大数字 + 1」起步,避免混入 + * 已有 id 的 categories 时重新生成的 id 与旧 id 撞号。 + * 旧实现一律从 0 开始,理论上 parser 给的 id 是 t1/t2/... 连续, + * 但 `t` 之外的旧 id 格式(老版本写出的、自定义 parser 兼容路径、 + * 合并进来的部分 categories)可能出现 id 间隔,重新从 0 起就会 + * 与未触达的那些空位撞上 —— 即便数字不撞,序列化稳定性也会被打破。 + */ + _assignTaskIds() { + // 1) 先扫一遍已有 id,挑出最大数字后缀;用正则兼容 't12' / 'task_12' 等历史格式 + let max = 0; + const numRe = /(\d+)\s*$/; + for (const cat of this.categories) { + if (cat.kind === CategoryKind.OTHER_TASKS) continue; + for (const task of cat.tasks) { + if (typeof task.id !== 'string') continue; + const m = numRe.exec(task.id); + if (m) { + const n = parseInt(m[1], 10); + if (Number.isFinite(n) && n > max) max = n; + } + } + } + let counter = max; + for (const cat of this.categories) { + if (cat.kind === CategoryKind.OTHER_TASKS) continue; + for (const task of cat.tasks) { + if (!task.id) task.id = 't' + (++counter); + } + } + } +} diff --git a/src/ui/command-palette.js b/src/ui/command-palette.js new file mode 100644 index 0000000..797321f --- /dev/null +++ b/src/ui/command-palette.js @@ -0,0 +1,382 @@ +// 命令面板 (Cmd/Ctrl+K) - 极简命令中心 +// 支持:搜索列表、跳转列表、视图操作、主题切换等 + +import { escapeHtml } from '../utils/dom.js'; +import { isImeComposing } from '../utils/keymap.js'; +import { runBeforeModalShow } from './feedback.js'; + +let _active = false; + +/** + * 打开命令面板 + * + * 返回的 Promise 在面板关闭(Esc / 点击外部 / 执行命令 / 异常)时 resolve, + * 这样 `_active` 才能正确覆盖面板的整个生命周期,避免「面板永远关不掉」。 + * + * 旧实现把整个流程放在 `async` 函数里却没有 `await`,导致 Promise 在面板 + * 渲染完之前就 resolve,`_active` 立刻被 finally 还原;后续 `close()` 里的 + * `if (!_active) return` 永远短路 → 面板僵死在屏幕上且无法再次打开。 + * + * @param {TaskStore} store + * @param {object} handlers 自定义动作钩子 + * @returns {Promise} + */ +export function openCommandPalette(store, handlers = {}) { + if (_active) return Promise.resolve(); + _active = true; + // 任何中途抛错都要释放 _active,否则面板就再也打不开了 + return _openCommandPaletteInner(store, handlers).finally(() => { + _active = false; + }); +} + +function _openCommandPaletteInner(store, handlers) { + return new Promise((resolve) => { + // M8:command-palette 也走同一个 modal 打开前 hook 体系 —— 即便它用 + // .command-palette-overlay(不是 .modal-overlay),拖拽的 pointerup 同样 + // 会在面板挂载后才派发,导致任务被偷偷重排。 + runBeforeModalShow(); + // 记下打开前的焦点,关闭时还原 —— 否则用户按 Ctrl+K、跳到列表、回车后 + // 焦点就漂在 overlay 上 / document.body 上,键盘流断掉。 + const previouslyFocused = document.activeElement; + + const overlay = document.createElement('div'); + overlay.className = 'command-palette-overlay'; + overlay.innerHTML = ` +
    +
    + + +
    +
      +
      + `; + document.body.appendChild(overlay); + + const input = overlay.querySelector('input'); + const listEl = overlay.querySelector('.command-palette-list'); + + // 命令源 + const commands = buildCommands(store, handlers); + let activeIdx = 0; + // 记住用户**实际选中过的最后一项**,仅在搜索导致列表彻底变空 + // (无任何匹配)时回退到此;清空搜索回到全列表时也用它 —— 否则 + // 用户键入 → 高亮 0 → 跳到 5 → 清空搜索 → 高亮仍停在 5,UX 不直观。 + let lastNonEmptyIdx = 0; + + const render = () => { + const q = input.value.trim().toLowerCase(); + let filtered; + if (!q) { + filtered = commands; + } else { + // M-S9: 改成 fuzzy subsequence —— 用户输入「主图」能匹配到「切换主题」, + // 不再要求连续子串。中文按 codePoint 拆(Array.from 而非 split('')), + // 避免 surrogate pair 把 emoji/生僻字拆成两半导致匹配错位。 + const qChars = Array.from(q); + filtered = commands.filter(c => { + const labelChars = Array.from(c.label.toLowerCase()); + if (isSubsequence(qChars, labelChars)) return true; + if (c.keywords) { + const kwChars = Array.from(c.keywords.toLowerCase()); + if (isSubsequence(qChars, kwChars)) return true; + } + return false; + }); + } + if (filtered.length === 0) { + listEl.innerHTML = `
      没有匹配的命令
      `; + // 让屏幕阅读器知道当前没有可用选项 + input.setAttribute('aria-activedescendant', ''); + activeIdx = -1; + return; + } + if (activeIdx === -1 || activeIdx >= filtered.length) { + // 从无匹配回到有匹配:粘住用户上次选中的位置(多数情况下仍是其意图所在), + // 越界则回退到 0。避免「键入 5 字再清空 → 高亮跳回第一项」的视觉跳动。 + activeIdx = Math.min(lastNonEmptyIdx, filtered.length - 1); + } + lastNonEmptyIdx = activeIdx; + listEl.innerHTML = filtered.map((c, i) => { + const isActive = i === activeIdx; + return ` +
    • + ${c.icon || ''} + ${escapeHtml(c.label)} + ${c.hint ? `${escapeHtml(c.hint)}` : ''} +
    • + `; + }).join(''); + // input 不离开焦点,aria-activedescendant 指向当前选项 —— ARIA combobox 推荐做法, + // 这样键盘焦点在 input、视觉焦点(高亮行 + 滚动)在 listbox 上 + input.setAttribute('aria-activedescendant', `cmd-item-${activeIdx}`); + }; + + // 用 AbortController 一次性回收所有 document 级监听器,避免与其它面板叠加 + const ac = new AbortController(); + const { signal } = ac; + + // 本地关闭守卫:闭包内多次 close(Esc + 点击外部 + 执行命令)只生效一次 + // (不能再依赖模块级 `_active`,它在 finally 里会被同步翻转) + let closed = false; + const close = () => { + if (closed) return; + closed = true; + ac.abort(); + // 缩放淡出后再 remove —— 命令面板入场用 palette-in,对应退出用 palette-out。 + // focus 恢复放在 animationend 内(被销毁的祖先元素上 focus 会跳到 body) + const inner = overlay.querySelector('.command-palette'); + // focus() 必须在 overlay.remove() 之前 —— 见 feedback.js 同款注释 + const finalize = () => { + if (previouslyFocused && typeof previouslyFocused.focus === 'function') { + previouslyFocused.focus(); + } + overlay.remove(); + resolve(); + }; + if (inner) { + inner.classList.add('is-closing'); + inner.addEventListener('animationend', finalize, { once: true }); + // 兜底必须 resolve:否则 animationend 不触发时 .finally 不跑、_active 永不清、 + // Ctrl+K 永久失效(致命)。250ms 已经超过 0.18s 动画时长,足以作为保险 + setTimeout(finalize, 250); + } else { + finalize(); + } + }; + + const execute = (cmd) => { + if (!cmd) return; + close(); + // v4+ 修复:handlers 里有 async 函数(典型 onNewList —— 它会 await 弹窗), + // run() 返回的 Promise 若 reject,旧 try/catch 只挡同步异常,Promise 冒 + // unhandled rejection 给 window.onerror。补一行: + // - 如果 run() 返回 Promise(async / thenable)→ 链 .catch 兜底; + // - 否则保持原行为(同步执行抛错走 catch)。 + try { + const result = cmd.run(); + if (result && typeof result.then === 'function') { + result.catch((e) => console.warn('[cmd] 异步执行失败:', e?.message || e)); + } + } catch (e) { + console.warn('[cmd] 执行失败:', e?.message || e); + } + }; + + const onKey = (e) => { + if (e.key === 'Escape') { + // IME 守卫:CJK/JP/KR 用户在搜索框里按 Esc 优先是「取消 IME 组合」, + // 不应误关整个命令面板。 + if (isImeComposing(e)) return; + e.preventDefault(); + e.stopPropagation(); + close(); + return; + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + const items = listEl.querySelectorAll('.command-palette-item'); + if (!items.length) return; + activeIdx = (activeIdx + 1) % items.length; + updateActive(items); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + const items = listEl.querySelectorAll('.command-palette-item'); + if (!items.length) return; + activeIdx = (activeIdx - 1 + items.length) % items.length; + updateActive(items); + return; + } + if (e.key === 'Enter') { + // IME 组合输入期间按 Enter 是「上屏候选词」,不是「确认命令」—— + // 漏掉这个判断,CJK 用户每次选候选词都会意外触发高亮项的命令。 + if (isImeComposing(e)) return; + e.preventDefault(); + const items = listEl.querySelectorAll('.command-palette-item'); + const targetId = items[activeIdx]?.dataset.id; + if (!targetId) return; + const cmd = commands.find(c => c.id === targetId); + execute(cmd); + return; + } + if (e.key === 'Tab') { + // 面板内只有 input 一个可聚焦元素 —— 拦下 Tab 别让它把焦点偷到底下的任务列表 + e.preventDefault(); + } + }; + + const updateActive = (items) => { + items.forEach((it, i) => it.classList.toggle('active', i === activeIdx)); + const active = items[activeIdx]; + if (active) active.scrollIntoView({ block: 'nearest' }); + }; + + input.addEventListener('input', () => { + activeIdx = 0; + render(); + }); + + listEl.addEventListener('click', (e) => { + const item = e.target.closest('.command-palette-item'); + if (!item) return; + const targetId = item.dataset.id; + const cmd = commands.find(c => c.id === targetId); + execute(cmd); + }); + + overlay.addEventListener('click', (e) => { + if (e.target === overlay) close(); + }); + + document.addEventListener('keydown', onKey, { signal, capture: true }); + + // 初始渲染放在 try 里:万一构建命令列表 / 渲染时抛错,也必须把 overlay 和 + // 监听器清掉再往外抛。否则屏幕上会留下一个关不掉的空面板,而 app.js 里 + // 「已有 .command-palette-overlay 就不再触发」的判断会让 Ctrl+K 从此彻底失效。 + try { + render(); + setTimeout(() => input.focus(), 30); + } catch (e) { + // 不走 close():它会 resolve,从而把异常吞掉。这里手动清理后把错误抛出去, + // 让 Promise reject —— app.js 的 catch 会记录日志,_active 也由 finally 释放。 + // + // focus() 必须在 overlay.remove() 之前:与 close() 的 finalize 同源 —— 一旦 + // overlay 销毁,再 focus 它的后代节点会跳到 body;render() 抛错之前 30ms 的 + // setTimeout(input.focus) 已把焦点挪到 input,所以必须把焦点还回调用方 + // 之前停留的元素(避免 catch 之后用户键盘失焦)。 + closed = true; + ac.abort(); + if (previouslyFocused && typeof previouslyFocused.focus === 'function') { + previouslyFocused.focus(); + } + overlay.remove(); + throw e; + } + }); +} + +/** + * 命令面板图标(返回 SVG HTML 字符串) + * 与侧边栏 / 任务列表的图标设计语言保持一致。 + * 未知名称返回空字符串,由调用方的 `c.icon || ''` 安全兜底。 + */ +function icon(name) { + switch (name) { + case '+': + case 'plus': + return ''; + case 'settings': + return ''; + case 'moon': + return ''; + case 'sun': + return ''; + case 'star': + return ''; + case 'folder': + return ''; + case '✓': + case 'check': + return ''; + default: + return ''; + } +} + +/** + * 构建命令列表 + */ +function buildCommands(store, handlers) { + const cmds = []; + + // 顶部:直接动作 + cmds.push( + cmd('add-task', '添加任务到当前列表', icon('+'), '', () => { + const inp = document.getElementById('task-input'); + if (inp) inp.focus(); + }), + cmd('cmd:new-list', '新建列表', icon('plus'), 'Ctrl+Shift+N', () => handlers.onNewList?.()), + cmd('cmd:settings', '设置', icon('settings'), 'Ctrl+,', () => handlers.onOpenSettings?.()) + ); + + // 视图 + cmds.push( + cmd('view:theme', '切换主题', icon('moon'), 'Ctrl+Shift+T', () => handlers.onToggleTheme?.()) + ); + + // 视图 → 智能列表 + // + // v3:当前任务已从「分类」升级为「智能列表」(聚合任意子分类下打了 @当前 标识的任务) + // 所以它和重要/全部/已完成 一并作为聚合视图呈现 —— 用户能直接跳转,无需先选具体子分类。 + cmds.push( + cmd('view:my-day', '当前任务', icon('sun'), 'Alt+1', () => handlers.onSwitchSmartList?.('current')), + cmd('view:important', '重要任务', icon('star'), 'Alt+2', () => handlers.onSwitchSmartList?.('important')), + cmd('view:all-tasks', '全部任务', icon('folder'), 'Alt+3', () => handlers.onSwitchSmartList?.('allTasks')), + cmd('view:completed', '已完成任务', icon('✓'), 'Alt+4', () => handlers.onSwitchSmartList?.('completed')) + ); + + // 跳转到列表(仅子分类 —— 「当前任务」已是上方聚合视图,不再重复列出) + const cats = store.categories.filter(c => c.parentOtherTasks); + for (const cat of cats) { + cmds.push(cmd( + `cat:${cat.name}`, + cat.name, + icon('folder'), + '', + () => handlers.onSwitchCategory?.(cat.name) + )); + } + + return cmds; +} + +function cmd(id, label, icon, hint, run) { + return { id, label, icon, hint, run }; +} + +/** + * 判断 needle 是否是 haystack 的子序列(按 codePoint 比较,顺序保持,相对位置不必连续)。 + * + * 用法:「主图」能匹配「切换主题」(主 → 主匹配,切 → 切匹配,图 → 主题里没有, + * 但若 needle 只 1 字就直接看 haystack 是否包含)。这里特指 fuzzy 路径: + * 「主题」匹配「切换主题」直接命中(主、题按序在「切换主题」里都能找到)。 + * + * 注意:空 needle 视为「匹配所有」 —— render() 已用 if (!q) 短路,这里只 + * 是防御性返回 true。 + * + * @param {string[]} needle + * @param {string[]} haystack + * @returns {boolean} + */ +function isSubsequence(needle, haystack) { + if (!needle || needle.length === 0) return true; + if (!haystack || haystack.length === 0) return false; + let hi = 0; + for (let ni = 0; ni < needle.length; ni++) { + const target = needle[ni]; + let found = false; + while (hi < haystack.length) { + if (haystack[hi] === target) { found = true; hi++; break; } + hi++; + } + if (!found) return false; + } + return true; +} + +// 注意:原本有 closeCommandPalette 导出,但从未被使用且依赖 _overlay.click() 这种 hack, +// 改由调用方直接管理生命周期(openCommandPalette 的 close 已为内部函数) diff --git a/src/ui/conflict-dialog.js b/src/ui/conflict-dialog.js new file mode 100644 index 0000000..f8de2c8 --- /dev/null +++ b/src/ui/conflict-dialog.js @@ -0,0 +1,361 @@ +// 任务粒度冲突解决对话框 +// +// 当「磁盘上的 todo.md」与「内存里的 store.categories」不一致(且 store 有未保存改动) +// 时弹出。让用户按任务粒度逐条选择保留哪边,而不是传统的「整盘重载 or 整盘保留」二选一。 +// +// 设计要点: +// 1. **任务粒度而非整盘**:用户在 UI 里改 A 任务、外部编辑器改 B 任务 —— 不该因为 A +// 改了就让 B 的外部编辑丢失。这是 v3.4 重构的核心修复(旧 confirmDialog 只能整盘二选一)。 +// 2. **三列布局**:左 = 文件版本、中 = 选择器、右 = 本地版本。每行都是一个独立决策点。 +// 3. **默认智能**:仅一边有的默认保留;两边都改的默认「两边都保留」(合并标记)。 +// 4. **永远有兜底**:点「全部用文件」会先在主进程落一份带时间戳的快照(.discard-*.bak), +// 选「全部用本地」会走 saveNow 把本地写回磁盘,「取消」则什么都不做。 +// 5. **对话期间继续改** 用户可继续操作 UI —— dialog 关闭时基于「打开时拿到的快照」应用 +// 选择,调用方需要在打开前 snapshot() 一份,关闭后比对 dirty 数。 +// 6. **焦点陷阱 + AbortController** 沿用 feedback.js 的模式,键盘流顺畅。 +// 7. **行级 diff 入口**:header 加"查看完整行级 diff"按钮,让好奇的 / 高级用户看到 +// 任务粒度之外的纯文本差异 —— 用 LCS 算法逐行红绿着色(LCS 实现见 diff-preview-dialog.js)。 + +import { escapeHtml } from '../utils/dom.js'; +import { trapFocus } from '../utils/focus-trap.js'; +import { isImeComposing, isCmdEnter } from '../utils/keymap.js'; +import { openDiffPreview } from './diff-preview-dialog.js'; +import { runBeforeModalShow } from './feedback.js'; + +/** + * 把 task 对象渲染成可视的「一行」HTML。 + * 标识:[✓] / [▶] / [⭐] / [原分类:xxx] 都按视觉权重排好(与 markdown-writer 一致: + * [▶] 当前在前,[⭐] 重要在中,[原分类] 在最后)。完成状态用 ✓ 而不是 ×。 + * + * 原分类标记:用 `.conflict-task-orig` 类做灰色小字 —— 与 markdown 文件里的位置一致 + * (COMPLETED / TRASH 分类里才有意义,普通子分类的 task.originalCategory 通常为 null)。 + * 这里的渲染是**纯展示**——是否真输出 [原分类:xxx] 仍由 markdown-writer 决定 + * (见 markdown-writer.js:formatTaskLine 中的 showOriginalCategory 判断)。 + * + * @param {object} task + * @param {string} className - 附加到行容器的 class(如 conflict-task-side-file) + */ +function renderTaskRow(task, className = '') { + if (!task) return ''; + const cb = task.completed ? '[✓]' : '[ ]'; + // 原分类标记:仅当 task.originalCategory 存在时显示。task.text 会被 escapeHtml 转义 + // —— originalCategory 来源是磁盘 markdown 解析产物,理论上安全,但仍然走 escapeHtml + // 保持一致(防御用户故意在原分类名里塞