This commit is contained in:
2026-09-12 13:55:57 +08:00
commit 30f1cedd39
95 changed files with 42057 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
build/
out/

156
README.md Normal file
View File

@@ -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 # WindowsNSIS .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

36
data/todo.example.md Normal file
View File

@@ -0,0 +1,36 @@
# 全部任务
<!-- 容器:下面 ## 都是子分类 -->
## 工作
- [ ] [⭐] 完成 Q3 项目设计文档
- [ ] 跟进客户 A 的合同签署
- [ ] [▶] 准备下周一的产品评审
## 学习
- [ ] 阅读《代码整洁之道》第 5 章
- [ ] 完成 Rustlings 练习 1-20
## 生活
- [ ] 给妈妈买生日礼物
- [ ] 预约下周的牙医
- [ ] 整理衣柜
## 未分类
- [ ] [▶] [⭐] 重要且当前的任务
# 已完成任务
<!-- [✓] 自动收进本节;取消勾选回到原分类。归档按 Delete 键 -->
- [✓] 提交上周的工作周报
<!-- 软件里删除的任务落在这里,不真删。要彻底删除,直接编辑本文件 -->
# 回收站
- [ ] 之前删掉的「放弃的想法」

BIN
icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

193
index.html Normal file
View File

@@ -0,0 +1,193 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:;">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Todo List</title>
<link rel="stylesheet" href="src/styles.css">
</head>
<body data-theme="dark" data-color-style="indigo">
<div id="app">
<!-- 顶部工具栏Logo + 搜索 + 主题 + 设置 -->
<header class="toolbar">
<div class="toolbar-left">
<h1 class="app-title">
<span class="logo" aria-hidden="true">
<img src="icon.ico" alt="" draggable="false" width="24" height="24">
</span>
<span class="title-text">Todo List</span>
</h1>
</div>
<div class="toolbar-center">
<div class="search-wrapper">
<span class="search-icon" aria-hidden="true">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<circle cx="6.5" cy="6.5" r="4.5"/>
<line x1="10" y1="10" x2="14" y2="14"/>
</svg>
</span>
<input type="search" id="search-input" placeholder="搜索任务…" class="search-input" autocomplete="off" spellcheck="false">
<kbd class="search-shortcut" id="search-shortcut" aria-hidden="true">Ctrl+F</kbd>
<button class="search-clear" id="search-clear" title="清空搜索" hidden aria-label="清空搜索">
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 6 6 18"/>
<path d="m6 6 12 12"/>
</svg>
</button>
</div>
</div>
<div class="toolbar-right">
<button id="btn-always-on-top" class="btn btn-icon" title="始终置顶" aria-label="始终置顶" aria-pressed="false">
<!-- Lucide pin24×24 viewBoxstroke-width 2.2 在 12px 渲染尺寸下约为 1px 描边,
与右侧齿轮/窗口控制按钮的线条风格统一 -->
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="12" y1="17" x2="12" y2="22"/>
<path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"/>
</svg>
</button>
<button id="btn-theme" class="btn btn-icon" title="切换主题" aria-label="切换主题">
<span id="theme-icon"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></span>
</button>
<button id="btn-settings" class="btn btn-icon" title="设置 (Ctrl+,)" aria-label="设置">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</button>
<!-- 自定义窗口控制按钮frame: false 后接管系统标题栏 -->
<!-- 整组设 no-drag按钮可点外面 .toolbar 仍是 drag -->
<div class="window-controls" role="group" aria-label="窗口控制">
<button id="btn-minimize" class="btn btn-window-control" title="最小化" aria-label="最小化">
<svg viewBox="0 0 12 12" width="11" height="11" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" aria-hidden="true">
<line x1="2.5" y1="6" x2="9.5" y2="6"/>
</svg>
</button>
<button id="btn-maximize" class="btn btn-window-control" data-state="maximize" title="最大化" aria-label="最大化">
<svg class="icon-maximize" viewBox="0 0 12 12" width="11" height="11" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="2.5" y="2.5" width="7" height="7" rx="0.6"/>
</svg>
<svg class="icon-restore" viewBox="0 0 12 12" width="11" height="11" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3.8" y="3.8" width="6.2" height="6.2" rx="0.5"/>
<path d="M5.8 3.8 V2.2 H10 V6.5 H8.4"/>
</svg>
</button>
<button id="btn-close" class="btn btn-window-control btn-close" title="关闭" aria-label="关闭">
<svg viewBox="0 0 12 12" width="11" height="11" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" aria-hidden="true">
<line x1="3.5" y1="3.5" x2="8.5" y2="8.5"/>
<line x1="8.5" y1="3.5" x2="3.5" y2="8.5"/>
</svg>
</button>
</div>
</div>
</header>
<!-- 主体两栏布局:侧边栏 + 任务区 -->
<main class="layout" id="layout">
<!-- 侧边栏:分类列表 -->
<aside class="sidebar" id="sidebar">
<nav class="category-list" id="category-list" role="navigation" aria-label="任务分类列表">
<!-- 动态填充(含「+ 新建子分类」按钮,渲染在最后一个子分类之后) -->
</nav>
</aside>
<!-- 中间:任务列表 -->
<section class="task-panel" id="task-panel">
<div class="task-header">
<div class="task-header-main">
<h2 class="task-category-title" id="category-title">当前任务</h2>
<span class="task-progress-text" id="task-progress-text">0 项</span>
</div>
<div class="task-header-actions">
<button id="btn-batch" class="filter-chip-btn" title="批量操作" aria-label="批量操作" aria-pressed="false" hidden>
<span id="batch-chip" class="filter-chip batch-chip">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="3"/>
<path d="m9 12 2 2 4-4"/>
</svg>
<span class="chip-text">批量</span>
</span>
</button>
<button id="btn-import" class="filter-chip-btn" title="从文本批量导入任务" aria-label="批量导入" hidden>
<span class="filter-chip import-chip">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<span class="chip-text">导入</span>
</span>
</button>
<button id="btn-filter" class="filter-chip-btn" title="过滤任务 (F)" aria-label="过滤任务">
<span id="filter-chip" class="filter-chip" data-filter="all">
<span class="chip-icon"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M7 12h10M10 18h4"/></svg></span>
<span class="chip-text">全部</span>
</span>
</button>
<button id="btn-sort" class="filter-chip-btn sort-chip-btn" title="排序方式" aria-label="排序方式">
<span id="sort-chip" class="filter-chip sort-chip">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m3 16 4 4 4-4"/>
<path d="M7 20V4"/>
<path d="m21 8-4-4-4 4"/>
<path d="M17 4v16"/>
</svg>
<span class="chip-text">手动</span>
</span>
</button>
</div>
</div>
<ul class="task-list" id="task-list" role="list" aria-label="任务列表">
<!-- 动态填充 -->
</ul>
<div class="empty-state" id="empty-state" hidden>
<!-- 动态填充 -->
</div>
<div class="task-input-wrapper">
<input type="text"
id="task-input"
class="task-input"
placeholder="添加新任务Enter 确认)"
autocomplete="off">
<button id="btn-submit-task" class="task-submit-btn" type="button" title="添加任务" aria-label="添加任务">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 5v14M5 12h14"/>
</svg>
</button>
</div>
</section>
</main>
<!-- 底部状态栏 -->
<footer class="statusbar" role="contentinfo">
<button class="file-path-chip" id="file-info" aria-label="当前文件路径,点击打开数据文件夹" title="">
<span class="file-path-hint" id="file-path-hint" aria-hidden="true">数据路径:</span>
<span class="file-path-text" id="file-path-text">未关联文件</span>
</button>
<span class="status-spacer"></span>
<div class="statusbar-right">
<span id="watch-indicator" class="watch-indicator" title="监听中fs.watch + 250ms 轮询)" aria-label="文件监视状态"></span>
<span id="status-text" class="status-text"></span>
<span id="status-meta" class="status-meta"></span>
</div>
</footer>
</div>
<!-- 右键菜单容器:菜单项由 feedback.js 的 showContextMenu 动态生成 -->
<div id="context-menu" class="context-menu" hidden></div>
<!-- 侧边栏分类右键菜单(重命名 / 删除) -->
<div id="category-context-menu" class="context-menu" hidden></div>
<!-- Toast 通知 -->
<div id="toast-container" class="toast-container" role="region" aria-live="polite"></div>
<script type="module" src="src/app.js"></script>
</body>
</html>

1779
main.js Normal file

File diff suppressed because it is too large Load Diff

4127
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

121
package.json Normal file
View File

@@ -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"
}
}
}

105
preload.js Normal file
View File

@@ -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) => {
// 载荷从 stringfilePath升级为 {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);
}
});

65
scripts/_lib/api-stub.mjs Normal file
View File

@@ -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 unsubscribenotify* 返回 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;
}

83
scripts/_lib/check.mjs Normal file
View File

@@ -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);
}
}
/**
* 重置计数器 —— 脚本里想做多轮隔离断言时调用。
* 大多数脚本不需要:进程结束就 exitcounter 自然释放。
*/
export function resetSummary() {
summary.pass = 0;
summary.fail = 0;
}

View File

@@ -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;
}

View File

@@ -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] - 自定义 markdownnull = 用默认文档
* @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;
}

48
scripts/_lib/tmp-todo.mjs Normal file
View File

@@ -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` overridesnapshot-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 });
}

View File

@@ -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 handlerassertInDataDir + 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');

299
scripts/check-backup.mjs Normal file
View File

@@ -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 就保持 undefinedfalsy结果主进程
// 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);

746
scripts/check-batch-ops.mjs Normal file
View File

@@ -0,0 +1,746 @@
// 一次性验证脚本批量操作task-store.batchDeleteTasks / batchMoveTasks /
// batchToggleCompleted / batchUpdateMeta / batchReorderInCategory
//
// 覆盖:
// 1. 批量删除跨分类、TRASH 拒绝、空集合、顺序保留、单次 emit
// 2. 批量移到分类:跨源分类、顺序保留、目标守卫、单次 emit
// 3. 批量重排序top / bottom、跨分类拒绝、顺序保留
// 4. 批量改 metaimportant / 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.4fixture 里不能再用 `- [✓]` 写在普通子分类下 —— 加载时会被
// 迁移到「# 已完成任务」分类,原本所在分类会少一条,进而打挂所有断言。
// 正确做法:把已完成任务直接放在 `# 已完成任务` 段。
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 条 → 插入到生活下标 1A 之后、B 之前)
const refs = [{ categoryName: '工作', taskId: work.tasks[0].id }];
store.batchMoveTasks(refs, '生活', 1);
check('生活分类长度 = 3', life.tasks.length === 3);
check('插入到下标 1A、工作任务 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 fixtureB 已迁到「# 已完成任务」分类)
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=33 条都是 false → 全部切到 true',
r1.changed === 3, JSON.stringify(r1));
check('change 触发 1 次', changeCount === 1);
// v3.4toggle 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 / A2originalCategory = "工作" → 回到「工作」
// - 学习任务 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=trueB 已经是 current=false → changed=3
store.batchUpdateMeta(refs, { current: false }); // 先全部设为 falsechanged=3
// 此时再设 current=falsechanged=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 fixtureB 已迁到 # 已完成任务分类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('<!-- id:'));
}
// ============================================================
// 总结
// ============================================================
console.log(`\n==========\n通过 ${summary.pass} / 失败 ${summary.fail}\n==========`);
process.exit(summary.fail === 0 ? 0 : 1);

View File

@@ -0,0 +1,289 @@
// 一次性验证脚本:分类 API 的「拒绝保留名」守卫。
//
// 背景store 里有几个「保留分类名」(今天类 / 全部任务类 / 回收站类,以及它们的英文别名)。
// 这些名字承担固定语义:今天类是智能视图的数据源,全部任务是子分类容器,回收站
// 是删除任务的暂存地。如果让用户给子分类起名为「回收站」,同名冲突会让 getCategory
// 只命中第一个,删除/恢复路径从此走错。
//
// 守卫点(在 task-store.js 里):
// - addSubCategory(name) → 拒绝保留名,返回 null
// - renameCategory(old, new) → 拒绝把已有分类改名为保留名,返回 false
// - deleteCategory(name) → 拒绝删 TODAY / OTHER_TASKS / TRASH
// 以及「未分类」(兜底,必须始终存在)
// - addTask(name, text) → 拒绝向容器 / 回收站添加任务(前者不存任务,
// 后者只接受 deleteTask 进入)
//
// 旧实现里这些守卫被静默写好,但 check-trash-fuzz 只覆盖了「以『回收站』为
// 目标名」的几种攻击姿势。剩余的(子分类改名成保留名、英文别名大小写变体、
// addTask 越界)走的是另一条路径,没被覆盖。这份脚本是补漏。
//
// 用法node scripts/check-category-guards.mjs
import { TaskStore, UNCATEGORIZED_NAME, OTHER_TASKS_NAME, TRASH_NAME } from '../src/task-store.js';
import { CategoryKind, TODAY_KEYS, OTHER_TASKS_KEYS, TRASH_KEYS } from '../src/markdown-parser.js';
import { check, ok, bad, summary, printSummary } from './_lib/check.mjs';
import { makeStore } from './_lib/store-fixture.mjs';
console.log('[1] addSubCategory 拒绝保留名');
{
const store = makeStore();
// 中文保留名(这里覆盖每个 keys 集合的第一个值,足以代表整组)
for (const name of ['当前任务', '今天', '今日', '全部任务', '其他任务', '待办任务', '回收站', '垃圾桶']) {
const before = store.categories.length;
const result = store.addSubCategory(name);
check(
`addSubCategory('${name}') 返回 null`,
result === null,
`实际返回 ${JSON.stringify(result)}`
);
check(
`categories 长度未变(${name} 没被建出)`,
store.categories.length === before,
`${before}${store.categories.length}`
);
}
}
{
const store = makeStore();
// 英文 / 空格变体:保留名匹配是 lower + trim所以大小写、零散空格都得拦
// 注意:不测 SMART_LIST_KEYS如 'all tasks' / 'completed tasks' / 'important')。
// 那些是 h1 智能列表段的别名 —— 写为 ## 时跟普通子分类一样被 parser 正确分类,
// 不会丢数据;强制禁掉反而禁掉合理的英文命名("All Tasks for Project X")。
for (const name of ['TODAY', 'Today', 'my day', 'todo', 'to do', 'Trash', 'Recycle Bin']) {
const result = store.addSubCategory(name);
check(
`addSubCategory('${name}') 大小写/空格变体也被拒绝`,
result === null,
`实际返回 ${JSON.stringify(result)}`
);
}
}
{
const store = makeStore();
// 已存在的子分类不能重复建(用「副业」避开 loadDefault 的内置「工作/学习/生活/未分类」)
const ok1 = store.addSubCategory('副业');
const ok2 = store.addSubCategory('副业');
check('addSubCategory 首次成功', ok1 !== null && ok1.name === '副业');
check('addSubCategory 重复同名返回 null', ok2 === null, `实际 ${JSON.stringify(ok2)}`);
// 空名 / 纯空白名也直接拒
check('addSubCategory("") 返回 null', store.addSubCategory('') === null);
check('addSubCategory(" ") 返回 null', store.addSubCategory(' ') === null);
check('addSubCategory(null) 返回 null', store.addSubCategory(null) === null);
check('addSubCategory(undefined) 返回 null', store.addSubCategory(undefined) === null);
}
console.log('\n[2] renameCategory 拒绝把已有分类改成保留名');
{
const store = makeStore();
store.addSubCategory('副业');
store.addSubCategory('阅读');
for (const newName of ['当前任务', '今日', '全部任务', '回收站', 'TODAY', 'trash']) {
const ok = store.renameCategory('副业', newName);
check(
`rename('副业' → '${newName}') 返回 false`,
ok === false,
`实际返回 ${JSON.stringify(ok)}`
);
check(
`「副业」的名字没被改成「${newName}`,
store.getCategory('副业') !== null,
`当前存在的分类:${store.categories.map(c => c.name).join('、')}`
);
}
}
console.log('\n[2b] renameCategory 拒绝重命名「未分类」本身');
{
// 「未分类」是任务兜底,它的字符串名是 store / parser / writer 共用的魔法标识符。
// 一旦改名下次删除别的有任务的子分类时_getOrCreateUncategorizedCategory 找不到
// cat.name === '未分类',会凭空新建一个同名空分类,把释放任务丢进去 —— 用户
// 改名的那个旧分类就成了孤儿,并且 markdown 写出去再读回来时还会进一步分裂。
// renameCategory 必须把这条路径堵死,与 deleteCategory 的「未分类」拦截对称。
const store = makeStore();
store.addTask(UNCATEGORIZED_NAME, '兜底里的任务');
const ok = store.renameCategory(UNCATEGORIZED_NAME, '其他');
check(
`rename('${UNCATEGORIZED_NAME}' → '其他') 返回 false`,
ok === false,
`实际返回 ${JSON.stringify(ok)}`
);
check(
`${UNCATEGORIZED_NAME}」名字没动`,
store.getCategory(UNCATEGORIZED_NAME) !== null,
`当前存在的分类:${store.categories.map(c => c.name).join('、')}`
);
// 兜底任务也不能丢
check(
`兜底里的任务仍归属「${UNCATEGORIZED_NAME}`,
store.getCategory(UNCATEGORIZED_NAME).tasks.length === 1
);
// 进一步:用「副业」做目标也不允许 —— 否则用户照样能把「未分类」换成自己的
// 名字再触发同样的孤儿分裂。
const ok2 = store.renameCategory(UNCATEGORIZED_NAME, '副业');
check('rename(未分类 → 副业) 也被拒绝', ok2 === false);
check('「未分类」依旧存在(未被改成「副业」)',
store.getCategory(UNCATEGORIZED_NAME) !== null);
}
console.log('\n[2c] 「其他」作为普通子分类名可用(非保留名)');
{
// 「其他」与「未分类」语义不同:前者是用户主动建的命名分类,后者是系统兜底。
// 之前「其他」被误放进 OTHER_TASKS_KEYS容器别名导致 addSubCategory /
// renameCategory / UI 校验全把它当保留名拒掉。回归这条用例锁住新语义。
// 1) 新建「其他」子分类应成功
const store = makeStore();
const cat = store.addSubCategory('其他');
check(
`addSubCategory('其他') 返回新分类`,
cat !== null && cat.name === '其他' && cat.parentOtherTasks === true,
`实际返回 ${JSON.stringify(cat)}`
);
// 2) 「其他」与「未分类」可以共存 —— 这是这次产品决策的核心
check(
`「未分类」兜底未动`,
store.getCategory(UNCATEGORIZED_NAME) !== null
);
check(
`「其他」与「未分类」并存`,
store.getCategory('其他') !== null && store.getCategory(UNCATEGORIZED_NAME) !== null
);
// 3) renameCategory 不拦截「其他」作为目标名
const store2 = makeStore();
store2.addSubCategory('杂事');
const ok = store2.renameCategory('杂事', '其他');
check(
`renameCategory('杂事' → '其他') 成功`,
ok === true && store2.getCategory('其他') !== null,
`实际 ok=${ok}getCategory('其他')=${store2.getCategory('其他')}`
);
// 4) 加载后空「其他」不再被静默改成「未分类」 —— 防回归到 task-store.js:2381-2395 那段迁移
const store3 = makeStore();
store3.addSubCategory('其他');
store3._ensureBaseStructure();
check(
`空「其他」在加载后仍叫「其他」(不被静默改名为「未分类」)`,
store3.getCategory('其他') !== null && store3.getCategory('其他').name === '其他'
);
check(
`「未分类」依然作为兜底存在(与「其他」共存)`,
store3.getCategory(UNCATEGORIZED_NAME) !== null
);
}
console.log('\n[3] deleteCategory 拒绝破坏特殊分类');
{
const store = makeStore();
// loadDefault 已建出 工作/学习/生活/未分类。先在「工作」里加一条任务,然后
// deleteTask → 回收站被 _getOrCreateTrashCategory 懒创建出来。
// 这一步是为了让后续 deleteCategory(回收站) 真的命中 idx >= 0 的分支 —— 否则
// 回收站不存在时 deleteCategory 走 idx < 0 的提前 return守卫代码根本没被执行。
store.addTask('工作', '待删');
store.deleteTask('工作', store.getCategory('工作').tasks[0].id);
const ok1 = store.deleteCategory(TRASH_NAME);
check('deleteCategory(回收站) 返回 false', ok1 === false);
check('回收站分类仍存在', store.getTrashCategory() !== null);
const ok2 = store.deleteCategory(OTHER_TASKS_NAME);
check('deleteCategory(全部任务) 返回 false', ok2 === false);
check('全部任务容器仍存在', store.categories.some(c => c.kind === CategoryKind.OTHER_TASKS));
const ok3 = store.deleteCategory(UNCATEGORIZED_NAME);
check('deleteCategory(未分类) 返回 false', ok3 === false);
check('「未分类」兜底仍存在', store.getCategory(UNCATEGORIZED_NAME) !== null);
}
console.log('\n[4] addTask 拒绝向容器 / 回收站添加任务');
{
const store = makeStore();
// 容器本身
const t1 = store.addTask(OTHER_TASKS_NAME, '偷渡到容器');
check('addTask(全部任务) 返回 null', t1 === null);
// 容器没有被「污染」 —— 任务列表长度为 0
const container = store.getCategory(OTHER_TASKS_NAME);
check('容器不持任务addTask 越界后 tasks 仍为 0',
container && container.tasks.length === 0);
// 回收站 —— 只能由 deleteTask 进入,普通 addTask 必须拒绝
const t2 = store.addTask(TRASH_NAME, '偷渡到回收站');
check('addTask(回收站) 返回 null', t2 === null);
// 拒绝后回收站仍然不存在 —— 因为 addTask 失败不会触发 _getOrCreateTrashCategory
// 后续 deleteTask 才会建出。这正是想要的「懒创建」行为。
check('拒绝 addTask 后回收站仍未被创建(懒创建)',
store.getTrashCategory() === null);
}
console.log('\n[5] 已存在「未分类」时,删它仍被拒绝');
{
// 防止用户在有子分类的情况下意外删掉「未分类」—— 否则下次删别的分类,
// 释放出的任务无家可归_getOrCreateUncategorizedCategory 会再建,但语义是降级而非「正常」)。
const store = makeStore();
store.addSubCategory('副业');
const ok = store.deleteCategory(UNCATEGORIZED_NAME);
check('已存在「副业」时仍拒绝删「未分类」', ok === false);
}
console.log('\n[6] reorderSubCategory 不会让「未分类」漂离底部');
{
// 「未分类」固定排在子分类末尾_normalizeOrder 内部约定)。任何让它移动的重排都会被
// _normalizeOrder 在下次加载时拉回 —— 用户看到的就是"我拖了它又自己跑回去"。
// store 层必须把这条路径堵住,避免 UI 之外的入口IPC、自动化脚本触发。
const store = makeStore();
// 默认就有「工作 / 学习 / 生活 / 未分类」四个,按 _getSubCategories 顺序
const subs = store.getSubCategories();
const uIdx = subs.findIndex(c => c.name === UNCATEGORIZED_NAME);
check(`${UNCATEGORIZED_NAME}」在子分类数组中(默认就存在)`, uIdx >= 0);
check(`${UNCATEGORIZED_NAME}」在最后一位(默认排末尾)`, uIdx === subs.length - 1);
// 1) 试图把「未分类」拖到中间 —— 被拒
if (uIdx > 0) {
const ok = store.reorderSubCategory(uIdx, 0, true);
check(`reorder(未分类 → 0) 返回 false`, ok === false,
`实际 ${JSON.stringify(ok)}`);
}
// 2) 试图把别的分类拖到「未分类」所在位置(让它跑到「未分类」前面 / 后面)——
// toIndex 在「未分类」上也必须被拒,否则"未分类"会被挤到中间。
if (subs.length >= 2 && uIdx > 0) {
const ok = store.reorderSubCategory(0, uIdx, true);
check(`reorder(0 → 未分类) 返回 false不允许把别的分类拖到未分类位置`,
ok === false, `实际 ${JSON.stringify(ok)}`);
}
// 3) 但同方向调整非「未分类」之间的相对顺序必须依然能成功(不破坏正常拖拽)
const subs2 = store.getSubCategories();
if (subs2.length >= 3) {
const ok = store.reorderSubCategory(0, 2, true);
check(`reorder(0 → 2) 仍允许(不影响「未分类」外其它分类的正常拖拽)`,
ok === true, `实际 ${JSON.stringify(ok)}`);
}
// 4) 反向 (swap 语义) 同样:把 2 → 0 不经过「未分类」应该是 ok
const subs3 = store.getSubCategories();
if (subs3.length >= 3) {
const ok = store.reorderSubCategory(2, 0, false);
check(`reorder(2 → 0, dropAbove=false) 仍允许`,
ok === true, `实际 ${JSON.stringify(ok)}`);
}
}
console.log('\n' + '='.repeat(64));
console.log(`结果:${summary.pass} 通过, ${summary.fail} 失败`);
console.log('='.repeat(64));
process.exit(summary.fail > 0 ? 1 : 0);

View File

@@ -0,0 +1,110 @@
// 一次性验证脚本:分类名禁用 `|` 字符。
//
// 背景TaskList 用 `cat + '|' + id` 作为 selectedTaskIds 的 Set key_selKey
// 批量浮栏的 _updateBatchBarState / _handleBatchAction / _showBatchMoveMenu
// 都通过 key.split('|') 反查分类名与 id。
//
// 如果分类名本身含 `|`,比如 "Foo|Bar"
// - add 时的 key = "Foo|Bar|t_xxx_xxxxx"
// - split('|') → ["Foo", "Bar", "t_xxx_xxxxx"]
// - 解构 [cat, id] = key.split('|') → cat = "Foo", id = "Bar"
// - 后续 store.batchMoveTasks / batchToggleCompleted / batchDeleteTasks
// 全都按错误的 (cat, id) 去找任务 → 静默空操作 → 用户以为「批量没生效」。
//
// 防线store 的 addSubCategory / renameCategory 必须拒绝任何含 `|` 的输入。
// 这份脚本守住这条防线 —— 加了 `_taskRefKey` 死代码 + `|` 漏洞的回归测试都靠它。
//
// 用法node scripts/check-category-name-chars.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';
console.log('[1] addSubCategory 拒绝含 `|` 的名字');
{
const store = makeStore();
const names = [
'Foo|Bar', // 经典带管道符
'|leading', // 前导
'trailing|', // 末尾
'a||b', // 双管道
'工作|子分类', // 含中文字符 + |
];
for (const name of names) {
const before = store.categories.length;
const result = store.addSubCategory(name);
check(
`addSubCategory(${JSON.stringify(name)}) 返回 null`,
result === null,
`实际 ${JSON.stringify(result)}`
);
check(
`categories 长度未变`,
store.categories.length === before,
`${before}${store.categories.length}`
);
}
}
console.log('\n[2] addSubCategory 接受不含 `|` 的合法名字');
{
const store = makeStore();
const ok = store.addSubCategory('正常名字');
check('addSubCategory("正常名字") 返回 cat 对象', ok !== null);
check('分类名确实是"正常名字"', ok && ok.name === '正常名字');
}
console.log('\n[3] renameCategory 拒绝把名字改成含 `|`');
{
const store = makeStore();
store.addSubCategory('副业');
const badNames = ['Foo|Bar', 'a|', '|b'];
for (const newName of badNames) {
const ok = store.renameCategory('副业', newName);
check(
`rename('副业' → ${JSON.stringify(newName)}) 返回 false`,
ok === false,
`实际 ${JSON.stringify(ok)}`
);
check(
`「副业」名字没被改`,
store.getCategory('副业') !== null,
`当前: ${store.categories.map(c => c.name).join('、')}`
);
}
}
console.log('\n[4] 集成回归:合法分类的批量操作 key 拆 / 拼对称');
{
// 这条不属于「拒绝非法名字」的范畴,但是和 `|` 同款的端到端检查:
// 走完整 addSubCategory → addTask → 模拟 _selKey + split('|'),确认
// 「cat + '|' + id」拆出来仍然是 (cat, id) —— 不会被 split 干扰。
// 主要是防回归:将来谁把 _selKey 改成 JSON.stringify 之类方案,会让行为改变。
const store = makeStore();
// loadDefault 默认建了「工作/学习/生活/未分类」,用「副业」避开重名
const cat = store.addSubCategory('副业');
check('addSubCategory("副业") 返回非 null', cat !== null);
if (!cat) {
fail++; // 后续 check 无意义 —— 直接结束
} else {
const t = store.addTask('副业', '示例任务');
check('addTask 返回非 null', t !== null);
if (t) {
const key = `${cat.name}|${t.id}`;
const [catOut, idOut] = key.split('|');
check(`_selKey 拼出的 key 能 split 回 (cat, id)`, catOut === cat.name && idOut === t.id);
// 验证 split 不会产生多余片段(极端回归:`_selKey` 不会意外产生多余 `|`
check('split 后只有 2 段', key.split('|').length === 2);
}
}
}
console.log('\n' + '='.repeat(64));
console.log(`结果:${summary.pass} 通过, ${summary.fail} 失败`);
console.log('='.repeat(64));
process.exit(summary.fail === 0 ? 0 : 1);

View File

@@ -0,0 +1,136 @@
// 回归:任务文本里中文与英文/数字之间必须有视觉间隙(盘古之白)。
//
// 历史 bug任务内容渲染时是 escapeHtml(task.text) 直出,中西文紧贴在一起,
// 「修复bug123的API调用」这种混排读起来非常挤汉字是全角方块字母数字是
// 比例宽度,两者之间没有天然的视觉呼吸位)。
//
// 为什么不用 CSStext-autospace: ideograph-alpha 才是正解,但 Electron 28
// 对应 Chromium 120那时候还没实现纯 CSS 方案在本项目里无法落地。
//
// 修复src/utils/dom.js 的 escapeHtmlAutospace() 在转义时于中西文边界插入
// 空的 <span class="cjk-gap">,靠 CSS padding-right 撑出间隙。
//
// 三条不能破的约束(也是这个脚本主要盯的地方):
// 1) 间隙元素必须是「空标签」—— 任务双击编辑时是把 textContent 灌回 input
// 真插入空格 / 细空格字符会污染用户的原始内容,保存后凭空多出空格。
// 2) 拆分必须在 escapeHtml 之前做 —— 否则 "&amp;" 里的字母被当成西文,
// 间隙元素会插进 HTML 实体内部,把实体打断成乱码。
// 3) .cjk-gap 只能用 padding 撑宽,不能用 inline-block 的 width 或 margin。
// 已完成任务的 .task-text 带 line-through按 CSS 规范祖先的 text-decoration
// 不会画过 inline-block 这类原子行内盒margin 区域同样不画 —— 那样删除线
// 会在每个间隙处断成一截一截。padding 区域会被画到,删除线保持连续。
//
// 覆盖dom.js 的纯函数行为(可直接 import无 DOM 依赖)+ task-list.js 接线
// + styles.css 的 .cjk-gap 规则。
//
// 用法node scripts/check-cjk-autospace.mjs
import { readFileSync } from 'node:fs';
import { check, printSummary } from './_lib/check.mjs';
import {
escapeHtml,
escapeHtmlAutospace,
needsCjkGap,
CJK_GAP_HTML,
} from '../src/utils/dom.js';
const GAP = CJK_GAP_HTML;
// ── 1. needsCjkGap 的边界判定 ────────────────────────────────────────────
check('中文 → 字母 需要间隙', needsCjkGap('文', 'a') === true);
check('字母 → 中文 需要间隙', needsCjkGap('a', '文') === true);
check('中文 → 数字 需要间隙', needsCjkGap('文', '1') === true);
check('数字 → 中文 需要间隙', needsCjkGap('1', '文') === true);
check('假名 → 字母 需要间隙', needsCjkGap('ア', 'x') === true);
check('中文 → 中文 不加间隙', needsCjkGap('中', '文') === false);
check('字母 → 字母 不加间隙', needsCjkGap('a', 'b') === false);
check('CJK 标点不加间隙(。后接字母)', needsCjkGap('。', 'a') === false);
check('全角括号不加间隙', needsCjkGap('', 'a') === false);
check('西文标点不加间隙(中文后接句点)', needsCjkGap('文', '.') === false);
check('空串安全', needsCjkGap('', 'a') === false && needsCjkGap('a', '') === false);
// ── 2. escapeHtmlAutospace 的输出 ───────────────────────────────────────
check(
'典型混排修复bug123的API调用',
escapeHtmlAutospace('修复bug123的API调用')
=== `修复${GAP}bug123${GAP}${GAP}API${GAP}调用`,
escapeHtmlAutospace('修复bug123的API调用')
);
check('纯中文不动', escapeHtmlAutospace('修复调用') === '修复调用');
check('纯英文不动', escapeHtmlAutospace('fix the bug') === 'fix the bug');
check('已有空格处不再加间隙', escapeHtmlAutospace('修复 bug') === '修复 bug');
check('单字符安全', escapeHtmlAutospace('中') === '中');
check('空值安全', escapeHtmlAutospace('') === '' && escapeHtmlAutospace(null) === '');
// 约束 2先拆分后转义。实体内部一旦被插入间隙就是乱码。
const amp = escapeHtmlAutospace('中文&英文');
check(
'转义顺序正确:& 变实体且实体内部无间隙',
amp.includes('&amp;') && !/&[a-z]*<span/.test(amp),
amp
);
check(
'尖括号仍被转义(不产生可注入标签)',
escapeHtmlAutospace('中<script>x</script>文').includes('&lt;script&gt;'),
escapeHtmlAutospace('中<script>x</script>文')
);
// 任何输入下,除了我们自己插入的间隙元素,不应出现别的裸标签。
const dirty = escapeHtmlAutospace('中"文\'a<b>&c中');
check(
'除间隙元素外无其它标签',
dirty.split(GAP).join('').indexOf('<') === -1,
dirty
);
// 约束 1间隙元素必须是空标签不贡献任何 textContent
check(
'间隙元素是空标签(无文本内容、无 &nbsp;',
/^<span class="cjk-gap"><\/span>$/.test(GAP),
GAP
);
const stripped = escapeHtmlAutospace('修复bug的API').split(GAP).join('');
check(
'去掉间隙元素后与纯转义结果完全一致(原文未被改写)',
stripped === escapeHtml('修复bug的API'),
stripped
);
// ── 3. task-list.js 接线 ────────────────────────────────────────────────
const taskList = readFileSync('src/ui/task-list.js', 'utf8');
check(
'task-list.js 从 dom.js 引入了 autospace helper',
taskList.includes('escapeHtmlAutospace')
&& taskList.includes('needsCjkGap')
&& taskList.includes('CJK_GAP_HTML')
);
check(
'任务文本渲染不再用裸 escapeHtml(s.text)',
!taskList.includes('escapeHtml(s.text)'),
'搜索高亮分段里仍有 escapeHtml(s.text),中西文间隙会丢'
);
// 跨段边界的间隙必须落在 <mark> 外面,否则会被高亮背景染成一小块色。
const markIdx = taskList.indexOf('<mark>${inner}</mark>');
const gapIdx = taskList.lastIndexOf('CJK_GAP_HTML', markIdx);
check(
'跨段间隙插在 <mark> 之外',
markIdx > 0 && gapIdx > 0 && gapIdx < markIdx,
`mark@${markIdx} gap@${gapIdx}`
);
// ── 4. styles.css 的 .cjk-gap 规则 ──────────────────────────────────────
const css = readFileSync('src/styles.css', 'utf8');
const ruleMatch = css.match(/\.cjk-gap\s*\{([^}]*)\}/);
check('.cjk-gap 规则存在', !!ruleMatch);
if (ruleMatch) {
const body = ruleMatch[1];
check('.cjk-gap 用 padding 撑宽', /padding(-right|-inline-end)?\s*:/.test(body), body);
// 约束 3inline-block / margin 都会把已完成任务的删除线打断。
check('.cjk-gap 不是 inline-block', !/display\s*:\s*inline-block/.test(body), body);
check('.cjk-gap 不用 margin 撑宽', !/margin/.test(body), body);
check('.cjk-gap 不用 width 撑宽', !/width\s*:/.test(body), body);
}
printSummary('check-cjk-autospace');

View File

@@ -0,0 +1,178 @@
// 回归:拖拽任务刚结束立刻点击 checkbox / ⭐ / ▶ / 编辑 / 删除按钮应该生效,
// 而不是被静默吞掉(间歇性「按钮没反应」现象)。
//
// 历史 bug
// _dragJustEnded 标志位在 pointerup 后被设为 true目的是吞掉浏览器随后派发
// 的合成 click/dblclick避免在松手位置触发意外选择或编辑。但标志位的「清
// 除」只发生在**非 wrapper 区**的 pointerdown 里 —— checkbox / ⭐ / ▶ / 编辑
// / 删除按钮的 pointerdown 都被 early-return 挡在 wrapper 检查之后,标志位
// 永远没机会重置。结果:拖完任务再点这些按钮,点一次没反应(被吞),点第二
// 次pointerdown 重置标志位)才生效。表现为「间歇性按钮无响应」。
//
// 修复:在 pointerdown handler 的最顶端无条件重置 _dragJustEnded同时加一个
// 250ms 兜底定时器(防止任何未来代码路径让标志位永久卡住)。
//
// 测项:
// 1) pointerdown handler 在 wrapper early-return 之前无条件重置 _dragJustEnded
// 2) pointerup handler 在设置 _dragJustEnded = true 后再启一个兜底定时器
// 3) 兜底定时器有 clear 路径destroy + 新一轮 pointerdown
// 4) _setupDrag 初始化时声明 _dragJustEndedTimer 字段
import { readFileSync } from 'node:fs';
import { check, ok, bad, printSummary } from './_lib/check.mjs';
const SRC = readFileSync('src/ui/task-list.js', 'utf8');
let pass = 0, fail = 0;
function expect(name, cond, info) {
if (cond) { ok(name); pass++; } else { bad(name, info); fail++; }
}
// ── 1. 核心修复点 ─────────────────────────────────────────────────────────
// pointerdown handler 最开始(任何 early-return 之前)必须无条件重置 _dragJustEnded。
// 用 [\s\S]{0,1500} 抓取整个 handler body确保检测的是同一个 handler 内部的相对位置。
//
// 回归签名:
// - 必须出现 `this._dragJustEnded = false;` 在 pointerdown handler 内部
// - 该赋值必须早于 `.task-checkbox-wrapper` early-return
// - 不能落在 early-return 之后
// 抓取 pointerdown handler 全部 body
const pointerDownMatch = SRC.match(
/this\.listEl\.addEventListener\(['"]pointerdown['"],\s*\(e\)\s*=>\s*\{([\s\S]*?)\}\);/
);
expect(
'pointerdown handler 可被静态解析',
!!pointerDownMatch,
'没找到 pointerdown handler'
);
if (pointerDownMatch) {
const body = pointerDownMatch[1];
const resetIdx = body.indexOf('this._dragJustEnded = false');
const wrapperEarlyReturnIdx = body.indexOf('.task-checkbox-wrapper');
const buttonEarlyReturnIdx = body.indexOf('.task-action-btn');
expect(
'pointerdown body 包含 `_dragJustEnded = false` 重置',
resetIdx >= 0,
'没找到 _dragJustEnded = false'
);
expect(
'pointerdown body 包含 .task-checkbox-wrapper 早退分支',
wrapperEarlyReturnIdx >= 0,
'没找到 .task-checkbox-wrapper 早退'
);
expect(
'_dragJustEnded 重置位于 wrapper 早退之前(防 wrapper-点击卡死)',
resetIdx >= 0 && wrapperEarlyReturnIdx >= 0 && resetIdx < wrapperEarlyReturnIdx,
`_dragJustEnded 重置在位置 ${resetIdx}wrapper 早退在 ${wrapperEarlyReturnIdx} —— 必须前者在前`
);
// 关键reset 必须在 button early-return 之前(按钮也是 wrapper 的一种)
expect(
'_dragJustEnded 重置位于 .task-action-btn 早退之前(防编辑/删除卡死)',
resetIdx >= 0 && buttonEarlyReturnIdx >= 0 && resetIdx < buttonEarlyReturnIdx,
`重置在 ${resetIdx}action 早退在 ${buttonEarlyReturnIdx}`
);
}
// ── 2. 兜底定时器 ─────────────────────────────────────────────────────────
// pointerup 在设 _dragJustEnded = true 之后必须再启一个 setTimeout 兜底。
// 250ms 上限:浏览器合成 click 一般 < 50ms250ms 已是绝对够用的上限。
//
// 注意pointerup handler 在源码里是命名函数 `const onPointerUp = (e) => { ... }`
// 然后 document.addEventListener('pointerup', onPointerUp) 把它挂上去 ——
// 不是 inline arrow。所以匹配的是 const onPointerUp = ... 整段。
const onPointerUpMatch = SRC.match(
/const\s+onPointerUp\s*=\s*\(e\)\s*=>\s*\{([\s\S]*?)\n\s{4}\};/
);
expect(
'onPointerUp handler 可被静态解析',
!!onPointerUpMatch,
'没找到 onPointerUp 命名函数'
);
if (onPointerUpMatch) {
const body = onPointerUpMatch[1];
const setTrueIdx = body.indexOf('this._dragJustEnded = true');
const timerIdx = body.search(/setTimeout\([\s\S]{0,200}?_dragJustEnded\s*=\s*false/);
expect(
'onPointerUp body 包含 `_dragJustEnded = true`',
setTrueIdx >= 0,
'没找到 _dragJustEnded = true'
);
expect(
'onPointerUp body 包含 250ms 兜底 setTimeout 重置 _dragJustEnded',
timerIdx >= 0,
'没找到 setTimeout(() => { this._dragJustEnded = false }, ...) 兜底'
);
expect(
'兜底 setTimeout 在 _dragJustEnded = true 之后',
setTrueIdx >= 0 && timerIdx >= 0 && timerIdx > setTrueIdx,
`setTimeout 在 ${timerIdx}setTrue 在 ${setTrueIdx}`
);
}
// ── 3. destroy 中清理定时器 ────────────────────────────────────────────────
const destroyMatch = SRC.match(/destroy\(\)\s*\{([\s\S]*?)\n \}/);
expect(
'destroy() 可被静态解析',
!!destroyMatch,
'没找到 destroy() 方法'
);
if (destroyMatch) {
const body = destroyMatch[1];
const clearsTimer = body.includes('_dragJustEndedTimer') &&
body.includes('clearTimeout');
expect(
'destroy() 清理 _dragJustEndedTimer防止组件销毁后定时器还在跑',
clearsTimer,
'destroy() 里没有清理 _dragJustEndedTimer'
);
}
// ── 4. _setupDrag 初始化时声明 _dragJustEndedTimer ─────────────────────────
const setupDragMatch = SRC.match(/_setupDrag\(\)\s*\{([\s\S]*?)\n \}/);
expect(
'_setupDrag() 可被静态解析',
!!setupDragMatch,
'没找到 _setupDrag() 方法'
);
if (setupDragMatch) {
const body = setupDragMatch[1];
// 期望在 _setupDrag 顶部声明 _dragJustEndedTimer = null与 _dragJustEnded = false 同行/相邻)
const declIdx = body.indexOf('this._dragJustEndedTimer');
expect(
'_setupDrag 内声明 _dragJustEndedTimer 字段',
declIdx >= 0,
'没找到 this._dragJustEndedTimer 初始化'
);
}
// ── 5. 选择模式下 ⭐/▶ 真正使用 .task-readonly-indicator 类 ─────────────────
// 防止「CSS 定义了但 JS 永不应用」的死代码状态。
const renderStarMatch = SRC.match(/class="task-star-btn[^"]*"/);
const renderCurMatch = SRC.match(/class="task-current-btn[^"]*"/);
expect(
'⭐ 按钮渲染带条件 class="task-readonly-indicator"',
!!renderStarMatch && /task-readonly-indicator/.test(renderStarMatch[0]),
'class="task-star-btn" 字符串里没有 task-readonly-indicator —— 选择模式下 CSS 仍是死代码'
);
expect(
'▶ 按钮渲染带条件 class="task-readonly-indicator"',
!!renderCurMatch && /task-readonly-indicator/.test(renderCurMatch[0]),
'class="task-current-btn" 字符串里没有 task-readonly-indicator'
);
printSummary('check-click-after-drag', true);

View File

@@ -0,0 +1,346 @@
// 回归测试H12 — render() 顶部 _commitActiveEdit() 同步提交草稿
//
// 背景:旧版 render() 仅在最末尾 _cancelAllDrags没管 _editInput。
// 用户在编辑任务文本时,外部 file:change / 兄弟组件 emit('change') 等触
// 发 render(),会替换 listEl.innerHTML_editInput DOM 节点随之销毁,
// 用户编辑中的草稿凭空消失。修复v3.7+render() 顶部同步调
// _commitActiveEdit(),把 input 当前 value 写到 store。
//
// 本测试在 Node 环境下镜像 _commitActiveEdit 的行为DOM stub
// 验证「编辑态被 render() 触发时草稿是否保留」。
//
// 跑法node scripts/check-commit-active-edit.mjs
import { check, summary, printSummary } from './_lib/check.mjs';
import { makeStore } from './_lib/store-fixture.mjs';
// ── 镜像 _commitActiveEdit 的核心契约 ──
console.log('\n[1] _commitActiveEdit草稿被 render() 触发时同步提交');
{
const store = makeStore('# 全部任务\n\n## 工作\n\n- [ ] 写周报\n\n## 未分类\n\n');
const cat = store.getCategory('工作');
const task = cat.tasks[0];
// 模拟 DOM编辑态 input 节点 + 草稿值
const fakeInput = {
hidden: false,
value: '写周报 + 截止周五',
_listeners: { keydown: null, blur: null },
removeEventListener(type, fn) {
if (this._listeners[type] === fn) this._listeners[type] = null;
}
};
const editState = {
_editInput: fakeInput,
_editTaskId: task.id,
_editRealCatName: '工作',
_editOnKey: () => {}, // 占位remove 时匹配
_editOnBlur: () => {}, // 占位remove 时匹配
};
// 镜像 task-list.js 的 _commitActiveEdit 行为(同步提交到 store
function commitActiveEdit() {
if (!editState._editInput) return;
const input = editState._editInput;
const taskId = editState._editTaskId;
const realCatName = editState._editRealCatName;
if (editState._editOnKey) input.removeEventListener('keydown', editState._editOnKey);
if (editState._editOnBlur) input.removeEventListener('blur', editState._editOnBlur);
editState._editInput = null;
editState._editOnKey = null;
editState._editOnBlur = null;
editState._editTaskId = null;
editState._editRealCatName = null;
if (!input.hidden && taskId && realCatName) {
const newText = input.value.trim();
if (newText) {
store.updateTaskText(realCatName, taskId, newText);
}
}
}
// ── 镜像 render() 顶部逻辑:先 _cancelAllDrags再 _commitActiveEdit ──
function render() {
// render() 模拟:把 DOM 整个重建_editInput 节点会被销毁
// 关键render() 必须先 commit 草稿再销毁
commitActiveEdit();
// 模拟 innerHTML 替换fakeInput 现在应不再被引用
fakeInput.hidden = true;
}
// 触发 render模拟外部 file:change → store.on('change') → task-list.render()
render();
// 断言:草稿已落到 store
const updated = store.getCategory('工作').tasks[0];
check('render() 触发后 store 里的 text 是草稿值(不是空字符串)',
updated.text === '写周报 + 截止周五',
`actual=${JSON.stringify(updated.text)}`);
check('render() 触发后 edit 状态被清空_editInput === null',
editState._editInput === null);
check('render() 触发后 editTaskId 被清空',
editState._editTaskId === null);
}
// ── [2] 空草稿不触发 updateTaskText ──
console.log('\n[2] 空草稿edit value 为空 → 不写入 store保持原值');
{
const store = makeStore('# 全部任务\n\n## 工作\n\n- [ ] 写周报\n\n## 未分类\n\n');
const cat = store.getCategory('工作');
const originalText = cat.tasks[0].text;
const fakeInput = { hidden: false, value: ' ', removeEventListener() {} };
const editState = {
_editInput: fakeInput,
_editTaskId: cat.tasks[0].id,
_editRealCatName: '工作',
_editOnKey: null,
_editOnBlur: null,
};
function commitActiveEdit() {
if (!editState._editInput) return;
const input = editState._editInput;
const taskId = editState._editTaskId;
const realCatName = editState._editRealCatName;
editState._editInput = null;
editState._editTaskId = null;
editState._editRealCatName = null;
if (!input.hidden && taskId && realCatName) {
const newText = input.value.trim();
if (newText) {
store.updateTaskText(realCatName, taskId, newText);
}
}
}
commitActiveEdit();
check('空草稿不写入 store',
store.getCategory('工作').tasks[0].text === originalText,
`actual=${store.getCategory('工作').tasks[0].text}`);
}
// ── [3] hidden input 不触发 commit ──
console.log('\n[3] 隐藏的 inputinput.hidden=true不触发 commit');
{
const store = makeStore('# 全部任务\n\n## 工作\n\n- [ ] 写周报\n\n## 未分类\n\n');
const cat = store.getCategory('工作');
const originalText = cat.tasks[0].text;
const fakeInput = { hidden: true, value: '不应写', removeEventListener() {} };
const editState = {
_editInput: fakeInput,
_editTaskId: cat.tasks[0].id,
_editRealCatName: '工作',
_editOnKey: null,
_editOnBlur: null,
};
function commitActiveEdit() {
if (!editState._editInput) return;
const input = editState._editInput;
const taskId = editState._editTaskId;
const realCatName = editState._editRealCatName;
editState._editInput = null;
editState._editTaskId = null;
editState._editRealCatName = null;
// 注意hidden=true 时跳过写入
if (!input.hidden && taskId && realCatName) {
const newText = input.value.trim();
if (newText) {
store.updateTaskText(realCatName, taskId, newText);
}
}
}
commitActiveEdit();
check('hidden input 不写 store',
store.getCategory('工作').tasks[0].text === originalText);
}
// ── [4] render() 之前 _cancelAllDrags + _commitActiveEdit 顺序正确 ──
console.log('\n[4] render() 顺序:先 commit 编辑、再取消拖拽(按 task-list.js render() 顺序)');
{
// 这里直接验证 task-list.js render() 源码里的调用顺序 —— 防止有人
// 调换顺序导致 commit 时还在拖拽状态。
//
// 注意M1 修复后 _commitActiveEdit() 被 if (this._editInput && this._editInput.isConnected)
// 包了一层(防御 input 已被销毁的场景),源码体积比 600 字符多一点。截取 1200 字符
// 留足缓冲;只要 _commitActiveEdit() 出现在 _cancelAllDrags() 之后即可。
const fs = await import('node:fs');
const src = fs.readFileSync('./src/ui/task-list.js', 'utf8');
const renderStart = src.indexOf('render() {');
const renderSnippet = src.slice(renderStart, renderStart + 1200);
check('render() 顶部调 _cancelAllDrags()',
renderSnippet.includes('_cancelAllDrags()'));
check('render() 顶部调 _commitActiveEdit()(在 _cancelAllDrags 之后)',
renderSnippet.includes('_commitActiveEdit()'));
// 顺序cancel 在 commit 之前
const cancelIdx = renderSnippet.indexOf('_cancelAllDrags()');
const commitIdx = renderSnippet.indexOf('_commitActiveEdit()');
check('顺序_cancelAllDrags 在 _commitActiveEdit 之前',
cancelIdx >= 0 && commitIdx > cancelIdx,
`cancel=${cancelIdx}, commit=${commitIdx}`);
}
// ── [5] 空草稿但 _editDirty=true + originalText 非空 → 不静默丢用户意图 ──
//
// 背景:用户编辑时清空整段文本,外部 store change 触发 render()_commitActiveEdit
// 被同步调用。旧实现value 空 → noop → 任务文本保留 → 用户以为"被清空了"但其实还在。
// 修复commit 时若 wasDirty + originalText 非空 + value 空 → 至少在状态上可观察
// (本测试只验证"任务文本未被静默改写为某种意外值"toast 调用是副作用不在测试范围)。
//
// 此处镜像修复后的 commitActiveEdit含 _editDirty / _editOriginalText 字段)。
{
const store = makeStore('# 全部任务\n\n## 工作\n\n- [ ] 写周报\n\n## 未分类\n\n');
const cat = store.getCategory('工作');
const originalText = cat.tasks[0].text;
const fakeInput = { hidden: false, value: '', _listeners: {}, removeEventListener() {} };
let toastShown = false;
const editState = {
_editInput: fakeInput,
_editTaskId: cat.tasks[0].id,
_editRealCatName: '工作',
_editOnKey: null,
_editOnBlur: null,
_editOnInput: null,
_editDirty: true, // 用户曾触发过 input 事件
_editOriginalText: originalText, // 编辑前原文非空
};
// 镜像修复后的 _commitActiveEdit与 task-list.js 同源公式)
function commitActiveEdit() {
if (!editState._editInput) return;
const input = editState._editInput;
const taskId = editState._editTaskId;
const realCatName = editState._editRealCatName;
const wasDirty = editState._editDirty;
const origText = editState._editOriginalText;
if (editState._editOnKey) input.removeEventListener('keydown', editState._editOnKey);
if (editState._editOnBlur) input.removeEventListener('blur', editState._editOnBlur);
if (editState._editOnInput) input.removeEventListener('input', editState._editOnInput);
editState._editInput = null;
editState._editOnKey = null;
editState._editOnBlur = null;
editState._editOnInput = null;
editState._editTaskId = null;
editState._editRealCatName = null;
editState._editDirty = false;
editState._editOriginalText = null;
if (!input.hidden && taskId && realCatName) {
const newText = input.value.trim();
if (newText) {
store.updateTaskText(realCatName, taskId, newText);
} else if (wasDirty && origText) {
// 修复路径:发 toast测试里用一个 flag 代替 UI 调用)
toastShown = true;
// 不调用 updateTaskText —— 保留原文本
}
}
}
commitActiveEdit();
check('空草稿 + wasDirty + originalText 非空 → store 文本未被改写',
store.getCategory('工作').tasks[0].text === originalText,
`actual=${store.getCategory('工作').tasks[0].text}`);
check('空草稿 + wasDirty + originalText 非空 → toast 标志位被设置(让用户感知到)',
toastShown === true);
check('空草稿 + wasDirty + originalText 非空 → edit 状态已清空',
editState._editInput === null && editState._editDirty === false);
}
// ── [6] 空草稿 + _editDirty=false用户没动过+ originalText 非空 → 走历史 noop 路径 ──
{
const store = makeStore('# 全部任务\n\n## 工作\n\n- [ ] 写周报\n\n## 未分类\n\n');
const cat = store.getCategory('工作');
const originalText = cat.tasks[0].text;
const fakeInput = { hidden: false, value: '', removeEventListener() {} };
let toastShown = false;
const editState = {
_editInput: fakeInput,
_editTaskId: cat.tasks[0].id,
_editRealCatName: '工作',
_editDirty: false,
_editOriginalText: originalText,
};
function commitActiveEdit() {
if (!editState._editInput) return;
const input = editState._editInput;
const taskId = editState._editTaskId;
const realCatName = editState._editRealCatName;
const wasDirty = editState._editDirty;
const origText = editState._editOriginalText;
editState._editInput = null;
editState._editTaskId = null;
editState._editRealCatName = null;
editState._editDirty = false;
editState._editOriginalText = null;
if (!input.hidden && taskId && realCatName) {
const newText = input.value.trim();
if (newText) {
store.updateTaskText(realCatName, taskId, newText);
} else if (wasDirty && origText) {
toastShown = true;
}
}
}
commitActiveEdit();
check('空草稿 + wasDirty=false → 不发 toast用户没动过',
toastShown === false);
check('空草稿 + wasDirty=false → 文本未被改写',
store.getCategory('工作').tasks[0].text === originalText);
}
// ── [7] 原文本就是空(手写 - [x] 这种合法空任务)→ 即使用户清空也不该触发 toast ──
{
const store = makeStore('# 全部任务\n\n## 工作\n\n- [ ] \n\n## 未分类\n\n');
const cat = store.getCategory('工作');
const originalText = cat.tasks[0].text; // ''
const fakeInput = { hidden: false, value: '', removeEventListener() {} };
let toastShown = false;
const editState = {
_editInput: fakeInput,
_editTaskId: cat.tasks[0].id,
_editRealCatName: '工作',
_editDirty: true,
_editOriginalText: originalText, // ''
};
function commitActiveEdit() {
if (!editState._editInput) return;
const input = editState._editInput;
const taskId = editState._editTaskId;
const realCatName = editState._editRealCatName;
const wasDirty = editState._editDirty;
const origText = editState._editOriginalText;
editState._editInput = null;
editState._editTaskId = null;
editState._editRealCatName = null;
editState._editDirty = false;
editState._editOriginalText = null;
if (!input.hidden && taskId && realCatName) {
const newText = input.value.trim();
if (newText) {
store.updateTaskText(realCatName, taskId, newText);
} else if (wasDirty && origText) {
// 关键守卫originalText 必须真存在才发 toast —— 否则手写空任务的场景
// 也会被误报,原文本来就空、用户清空等于啥也没干。
toastShown = true;
}
}
}
commitActiveEdit();
check('空草稿 + wasDirty=true + originalText 为空 → 不发 toast语义上没改变',
toastShown === false);
check('空草稿 + wasDirty=true + originalText 为空 → store 文本保持空字符串',
store.getCategory('工作').tasks[0].text === '');
}
printSummary('check-commit-active-edit');

View File

@@ -0,0 +1,341 @@
// 回归测试_consolidateCompleted 的兜底行为
//
// 触发 bug 的姿势:
// 1. 用户在外部编辑器手敲多个 `# 已完成任务` —— 例如 VSCode 多光标编辑、
// Git 合并冲突没解干净、模板工具批量生成。parser 忠实地产出多个
// kind=COMPLETED 分类。
// 2. 把「已完成任务」写成容器下的 `## 已完成任务` —— 解析成 NORMAL 子分类。
//
// 旧行为:只走 parser 的 classifyByName第一次 # 已完成任务 是 COMPLETED后续
// 副本按 h1 名命中 COMPLETED_KEYS 也是 COMPLETED。后续副本里的任务在 UI 上既
// 看不到也没法恢复getCompletedCategory 只返回第一个)—— 数据无声消失。
// ## 已完成任务 子分类也类似 —— 走 NORMAL 路径completed=true 任务违反
// v3.4 数据归属不变量。
//
// 新行为_consolidateCompleted() 两段防御
// 1) 把「名字正好是规范名『已完成任务』、kind 是 NORMAL/子分类」正名为 COMPLETED
// (与 _consolidateTrash 第一段同源问题:避免 _getOrCreateCompletedCategory
// 与同名普通子分类撞名)
// 2) 多份 kind=COMPLETED → 保留第一份tasks 拼接,删副本
//
// 跑法node scripts/check-completed-consolidate.mjs
import { check, ok, bad, summary, printSummary } from './_lib/check.mjs';
import { makeStore } from './_lib/store-fixture.mjs';
import { parseMarkdown } from '../src/markdown-parser.js';
import { writeMarkdown } from '../src/markdown-writer.js';
import { COMPLETED_NAME } from '../src/task-store.js';
// ============================================================
// [1] 多 `# 已完成任务` → 合并到第一份tasks 拼接,绝不丢任务
// ============================================================
console.log('\n[1] 多个 # 已完成任务 副本合并');
{
const md = `# 全部任务
## 工作
- [ ] a
# 已完成任务
- [✓] first-done
# 已完成任务
- [✓] second-done
# 已完成任务
- [✓] third-done
`;
const store = makeStore(md);
const allCompleted = store.categories.filter(c => c.kind === 'completed');
check('多个副本 → 收敛成 1 个 COMPLETED 分类',
allCompleted.length === 1,
`actual count: ${allCompleted.length}`);
const completed = allCompleted[0];
if (completed) {
const texts = completed.tasks.map(t => t.text).join('|');
// 顺序按文件出现顺序first-done / second-done / third-done
check('tasks 拼接保留所有 3 条任务',
completed.tasks.length === 3 && texts === 'first-done|second-done|third-done',
`texts=${texts}`);
check('拼接后所有任务仍 completed=true',
completed.tasks.every(t => t.completed === true),
completed.tasks.map(t => `${t.text}(${t.completed})`).join(','));
// 数据归属不变量completed=true 任务**只能**活在 kind=COMPLETED
for (const c of store.categories) {
if (c.kind === 'completed') continue;
const strayCompleted = c.tasks.filter(t => t.completed === true);
check(`${c.name || c.kind} 分类无 completed=true 任务`,
strayCompleted.length === 0,
strayCompleted.map(t => t.text).join(','));
}
}
}
// ============================================================
// [2] 单 `# 已完成任务` → no-op不重复 / 不删错
// ============================================================
console.log('\n[2] 单副本不动');
{
const md = `# 全部任务
## 工作
- [ ] a
# 已完成任务
- [✓] only-done
`;
const store = makeStore(md);
const allCompleted = store.categories.filter(c => c.kind === 'completed');
check('单副本 → 仍是 1 个 COMPLETED', allCompleted.length === 1);
check('任务未被改',
allCompleted[0]?.tasks?.length === 1 && allCompleted[0].tasks[0].text === 'only-done',
JSON.stringify(allCompleted[0]?.tasks?.map(t => t.text)));
}
// ============================================================
// [3] `## 已完成任务` 子分类 → 提升为 COMPLETED h1
// ============================================================
console.log('\n[3] ## 已完成任务 子分类正名为 h1');
{
// ## 已完成任务 在 # 全部任务 容器下 → parser 会解析成 NORMAL 子分类
const md = `# 全部任务
## 工作
- [ ] a
## 已完成任务
- [✓] promoted-done
`;
const store = makeStore(md);
const allCompleted = store.categories.filter(c => c.kind === 'completed');
check('子分类 `## 已完成任务` 被提升为 COMPLETED h1',
allCompleted.length === 1,
`actual: ${allCompleted.length} completed, all cats: ${store.categories.map(c => `${c.name}=${c.kind}`).join(',')}`);
if (allCompleted[0]) {
check('提升后任务还在',
allCompleted[0].tasks.length === 1 && allCompleted[0].tasks[0].text === 'promoted-done',
allCompleted[0].tasks.map(t => t.text).join(','));
check('提升后 isSpecial=true / parentOtherTasks=false',
allCompleted[0].isSpecial === true && allCompleted[0].parentOtherTasks === false,
`isSpecial=${allCompleted[0].isSpecial}, parentOtherTasks=${allCompleted[0].parentOtherTasks}`);
}
}
// ============================================================
// [4] 混合:多 h1 副本 + 子分类副本
// ============================================================
console.log('\n[4] 多 h1 副本 + ## 已完成任务 子分类混合');
{
const md = `# 全部任务
## 工作
- [ ] a
# 已完成任务
- [✓] h1-first
## 已完成任务
- [✓] sub-promoted
# 已完成任务
- [✓] h1-second
`;
const store = makeStore(md);
const allCompleted = store.categories.filter(c => c.kind === 'completed');
check('混合场景 → 仍收敛成 1 个 COMPLETED',
allCompleted.length === 1,
`count=${allCompleted.length}`);
if (allCompleted[0]) {
// 拼接顺序的细节依赖 _migrateCompletedTasksToCategoryv3.4 归属迁移)的
// 内部策略h1 副本里的任务按出现顺序在前,从 NORMAL 子分类迁移过来的追加
// 在末尾。这里只验证 _consolidateCompleted 的核心承诺(不丢任务 + 任务集合
// 完整),顺序细节不锁。
const foundTexts = allCompleted[0].tasks.map(t => t.text);
check('所有 3 条任务都进了最终 COMPLETED不丢任务',
allCompleted[0].tasks.length === 3,
`tasks: ${foundTexts.join(',')}`);
const sortedActual = [...foundTexts].sort().join('|');
const sortedExpected = 'h1-first|h1-second|sub-promoted';
check('任务集合完整无丢失',
sortedActual === sortedExpected,
`actual=${sortedActual}, expected=${sortedExpected}`);
}
}
// ============================================================
// [5] roundtrip合并后写回 markdown → 再读仍是单分类 + 任务齐全
// ============================================================
console.log('\n[5] roundtrip 不再复活重复副本');
{
const md = `# 全部任务
## 工作
- [ ] a
# 已完成任务
- [✓] t1
# 已完成任务
- [✓] t2
`;
const store = makeStore(md);
const md2 = writeMarkdown(store.categories);
// 写出去应该只有 1 个 `# 已完成任务`
const h1Count = (md2.match(/^# 已完成任务$/gm) || []).length;
check('写出的 markdown 只含 1 个 # 已完成任务', h1Count === 1, `count=${h1Count}`);
// 再解析回来:仍是 1 个 COMPLETED 分类 + 任务齐全
const cats2 = parseMarkdown(md2);
// 注意:再 parseMarkdown 不走 store 兜底store 的 _consolidateCompleted 只在
// loadFromContent / _ensureBaseStructure 路径跑),所以这里必须确认 writeMarkdown
// 写出的格式已经是「已合并」状态 —— 否则 store 之外的纯 parser 调用会再次看到多副本
const allCompleted = cats2.filter(c => c.kind === 'completed');
check('parser 再次解析也是 1 个 COMPLETEDwriteMarkdown 已合并)',
allCompleted.length === 1,
`count=${allCompleted.length}, kinds: ${cats2.map(c => c.kind).join(',')}`);
if (allCompleted[0]) {
const texts = allCompleted[0].tasks.map(t => t.text).sort().join('|');
check('roundtrip 任务齐全t1 + t2',
texts === 't1|t2',
texts);
}
}
// ============================================================
// [6] 与 _consolidateTrash / _consolidateUncategorized 共存
// ============================================================
console.log('\n[6] 三个 consolidate 路径同时触发,互不干扰');
{
const md = `# 全部任务
## 工作
- [ ] a
## 未分类
- [ ] orphan
## 未分类
- [ ] orphan-2
# 已完成任务
- [✓] done-1
# 已完成任务
- [✓] done-2
# 回收站
- [ ] trash-1
# 回收站
- [ ] trash-2
`;
const store = makeStore(md);
// COMPLETED 应只剩 1 份,含 done-1 + done-2
const completed = store.categories.filter(c => c.kind === 'completed');
check('COMPLETED 收敛为 1',
completed.length === 1,
`count=${completed.length}`);
if (completed[0]) {
const sortedTexts = completed[0].tasks.map(t => t.text).sort().join('|');
check('COMPLETED 含 done-1 + done-2',
sortedTexts === 'done-1|done-2',
sortedTexts);
}
// TRASH 应只剩 1 份,含 trash-1 + trash-2
const trash = store.categories.filter(c => c.kind === 'trash');
check('TRASH 收敛为 1',
trash.length === 1,
`count=${trash.length}`);
if (trash[0]) {
const sortedTexts = trash[0].tasks.map(t => t.text).sort().join('|');
check('TRASH 含 trash-1 + trash-2',
sortedTexts === 'trash-1|trash-2',
sortedTexts);
}
// UNCATEGORIZED 应只剩 1 份(## 子分类),含 orphan + orphan-2
const uncats = store.categories.filter(c => c.parentOtherTasks && c.name === '未分类');
check('## 未分类 收敛为 1',
uncats.length === 1,
`count=${uncats.length}`);
if (uncats[0]) {
const sortedTexts = uncats[0].tasks.map(t => t.text).sort().join('|');
check('## 未分类 含 orphan + orphan-2',
sortedTexts === 'orphan|orphan-2',
sortedTexts);
}
}
// ============================================================
// [7] 不撞名的子分类保持 NORMAL —— 不被错杀成 COMPLETED
// ============================================================
console.log('\n[7] 不在 COMPLETED_KEYS 的子分类保持 NORMAL不被错杀');
{
// 「mytasks」不在 COMPLETED_KEYS / OTHER_TASKS_KEYS / TRASH_KEYS 任何保留名列表里
// —— parser 解析为 NORMAL 子分类_consolidateCompleted 第一段不会动它。
// 名字也不撞 COMPLETED_NAME所以正名分支不触发。
const md = `# 全部任务
## 工作
- [ ] a
## mytasks
- [ ] b
`;
const store = makeStore(md);
// _ensureCompletedCategory 会无条件保证 1 个空 COMPLETED 分类存在UI 入口兜底),
// 所以这里只能验证「mytasks 没被错合并进 COMPLETED」且「COMPLETED 是空的」。
const completed = store.getCompletedCategory();
check('COMPLETED 分类是空mytasks 任务未被错误搬进)',
completed?.tasks?.length === 0,
`completed tasks: ${completed?.tasks?.map(t => t.text).join(',')}`);
// 关键不变量mytasks 是 NORMAL 子分类,没被改名为「已完成任务」/parentOtherTasks=false
const mytasks = store.categories.find(c => c.name === 'mytasks');
check('`## mytasks` 仍是 NORMAL 子分类(未正名/未挂到 kind=COMPLETED 上)',
mytasks?.kind === 'normal' && mytasks?.parentOtherTasks === true,
`kind=${mytasks?.kind}, parentOtherTasks=${mytasks?.parentOtherTasks}`);
// 工作分类还在,未被错误归入已完成
const work = store.getCategory('工作');
check('工作分类未被吃',
work?.tasks?.length === 1 && work.tasks[0].text === 'a',
`work tasks: ${work?.tasks?.map(t => t.text).join(',')}`);
}
printSummary('check-completed-consolidate');

View File

@@ -0,0 +1,125 @@
// 回归测试:「已完成任务」智能视图下右键菜单不出现「移到分类…」分组。
//
// 用户反馈2026-08-23在「已完成任务」分类里右键任务时「移到分类…」
// 分组没有意义 —— 按 v3.4 数据归属不变量task-store.js:950completed=true
// 任务只能活在 kind=COMPLETED 里,选任何一个普通分类都会被 store 硬路由回
// 「已完成任务」分类,等于死按钮。
//
// 测试路径:通过 src/ui/task-context-menu.js 的 buildStandardContextMenuItems
// 直接构造 items 数组helper 是 pure function不碰 DOM所以 Node 里能直接
// 跑helper 同时被 task-list.js 调,与 UI 共用一份判定逻辑——产品与测试不会发散)。
//
// [1] 「已完成任务」智能视图下菜单**不含** 'move-header' / 'move:*' 项
// 「标记为未完成」「移到回收站」必须仍在(防过度收紧)
// [2] 普通子分类(这里用「工作」)下右键**仍含** 'move-header' + 'move:*'
// 且 move 目标里不混进「已完成任务」/「回收站」/「全部任务」
//
// 用法node scripts/check-completed-context-menu.mjs
import { installApiStub } from './_lib/api-stub.mjs';
import { check, summary, printSummary } from './_lib/check.mjs';
// 让 TaskStore 不真去写文件(构造 store 时不会触发,但 toggleTask 等会调 window.api
installApiStub();
const { TaskStore } = await import('../src/task-store.js');
const { buildStandardContextMenuItems } = await import('../src/ui/task-context-menu.js');
// ============================================================
// 构造一个含「已完成任务」的最小 store
// ============================================================
const seedDoc = [
'# 全部任务', '',
'## 工作', '',
'- [ ] 写周报', '',
'## 未分类', ''
].join('\n');
const store = new TaskStore();
store.loadFromContent(seedDoc, null);
store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} });
const workTask = store.getCategory('工作').tasks[0];
check('seed 文档工作分类里有任务', !!workTask && workTask.completed === false);
// toggle → 自动搬到 kind=COMPLETED 分类
store.toggleTask('工作', workTask.id);
const completedCat = store.categories.find(c => c.kind === 'completed');
check('toggle 后产生「已完成任务」分类kind=COMPLETED',
!!completedCat && completedCat.tasks.length === 1,
completedCat ? `${completedCat.tasks.length}` : '分类不存在');
const completedTask = completedCat.tasks[0];
check('完成的那条任务在「已完成任务」里', completedTask && completedTask.completed === true);
// 「工作」分类里再加一条未完成任务(用来测普通分类分支)
store.addTask('工作', '已加测试任务');
const workActiveTask = store.getCategory('工作').tasks
.find(t => !t.completed && t.text === '已加测试任务');
check('「工作」里有一条新的未完成任务', !!workActiveTask);
// ============================================================
// [1] 「已完成任务」智能视图下右键 → 不应出现「移到分类…」
// ============================================================
console.log('\n[1] 「已完成任务」智能视图下右键不出现「移到分类…」');
store.selectSmartList('completed');
const completedView = store.getSelectedCategory();
check('getSelectedCategory() 是「已完成任务」智能视图',
completedView.isSmartList === true && completedView.smartKey === 'completed',
JSON.stringify({ isSmartList: completedView.isSmartList, smartKey: completedView.smartKey }));
const completedMenu = buildStandardContextMenuItems(completedView, completedTask, store);
const hasMoveHeader = completedMenu.some(i => i.action === 'move-header');
const hasMoveTargets = completedMenu.some(i =>
typeof i.action === 'string' && i.action.startsWith('move:'));
const hasMoveLabel = completedMenu.some(i => i.label === '移到分类…');
check('已完成视图下菜单不含 move-header', hasMoveHeader === false);
check('已完成视图下菜单不含任何 move:* 目标项', hasMoveTargets === false);
check('已完成视图下菜单不含「移到分类…」label', hasMoveLabel === false);
// 但「移到回收站」「标记为未完成」「编辑」「标记为当前/重要」必须仍在
check('已完成视图下保留「移到回收站」',
completedMenu.some(i => i.action === 'delete'));
check('已完成视图下保留「标记为未完成」',
completedMenu.some(i => i.action === 'toggle'));
check('已完成视图下保留「编辑」',
completedMenu.some(i => i.action === 'edit'));
check('已完成视图下保留「标记为当前/取消当前」',
completedMenu.some(i => i.action === 'current'));
check('已完成视图下保留「标记为重要/取消星标」',
completedMenu.some(i => i.action === 'important'));
// ============================================================
// [2] 普通子分类下右键 → 「移到分类…」必须仍在(防过度收紧)
// ============================================================
console.log('\n[2] 普通子分类(工作)下右键「移到分类…」仍然存在');
store.selectCategory('工作');
const workView = store.getSelectedCategory();
check('getSelectedCategory() 切到了「工作」',
workView.isSmartList !== true && workView.name === '工作');
const normalMenu = buildStandardContextMenuItems(workView, workActiveTask, store);
check('普通分类下菜单含 move-header',
normalMenu.some(i => i.action === 'move-header'));
const moveActions = normalMenu
.filter(i => typeof i.action === 'string' && i.action.startsWith('move:'))
.map(i => i.action);
check('普通分类下菜单含至少一个 move:* 目标项',
moveActions.length > 0,
`actions: ${moveActions.join(',') || '(无)'}`);
// move 目标里绝对不能混进保留名分类
check('move 目标里不含「全部任务」OTHERTASKS 已过滤)',
!moveActions.includes('move:全部任务'));
check('move 目标里不含「回收站」TRASH 已过滤)',
!moveActions.includes('move:回收站'));
check('move 目标里不含「已完成任务」COMPLETED 已过滤)',
!moveActions.includes('move:已完成任务'));
check('move 目标里不含「工作」自身(与自身重命名等价,留着会让菜单很怪)',
!moveActions.includes('move:工作'));
printSummary('check-completed-context-menu');

View File

@@ -0,0 +1,252 @@
// 回归测试:不变量 ——「[✓] 只能出现在 # 已完成任务 或 # 回收站」
//
// 用户规则v3.4+):勾选状态 [✓] 是「已完成任务」分类的指纹,回收站允许保留
// 完成态(用户删除已完成任务时不丢失勾选状态),其他任何分类 / 顶部镜像段都不
// 允许写 [✓]。
//
// 关键路径:
// - 顶部镜像段(# 当前任务 / # 重要任务):不允许出现 [✓]
// - 全部任务容器及 ## 子分类:不允许出现 [✓]
// - # 已完成任务 / # 回收站:是 [✓] 的合法落点
//
// 校验策略:
// 1. 解析 data/todo.example.md —— 例行文件必须已满足不变量
// 2. 模拟用户手敲 [✓] 到 ## 工作 / ## 未分类 / ## 学习 —— store.loadFromContent
// 必须把任务搬到 # 已完成任务serialize 出来 [✓] 不出现在错误位置
// 3. 通过 store 内部 toggleTask / batchToggle / addTask 等修改 → serialize →
// 不变量必须仍然保持
// 4. 直接调 writeMarkdown 给出"干净"状态post-migration—— 不变量必须仍然成立
//
// 跑法node scripts/check-completed-only.mjs
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { TaskStore, COMPLETED_NAME, TRASH_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';
import { writeMarkdown } from '../src/markdown-writer.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* 扫描 Markdown 文本,列出每一行 [✓]/[x]/[X] 出现的「章节归属」。
* 顶部摘要段(# 当前任务 / # 重要任务)以及 # 已完成任务 / # 回收站
* 之外的任何节都被记作 forbidden。
*/
function locateCompletedMarkers(md) {
const findings = [];
const lines = md.split('\n');
// 栈跟踪当前位置h1 段里 h2 算子分类(只在 # 全部任务 容器下)
let currentH1 = null;
let currentH2 = null;
for (const line of lines) {
const h1Match = line.match(/^#\s+(.+?)\s*$/);
const h2Match = line.match(/^##\s+(.+?)\s*$/);
if (h1Match) {
currentH1 = h1Match[1].trim();
currentH2 = null;
} else if (h2Match) {
currentH2 = h2Match[1].trim();
}
const m = line.match(/^(\s*)-\s+\[(✓|x|X)\]/);
if (m) {
const marker = m[2];
const inCompleted = currentH1 === COMPLETED_NAME;
const inTrash = currentH1 === TRASH_NAME;
// 顶部摘要段h1 是「当前任务」或「重要任务」,没有 h2
const inFrontMatter = currentH1 === '当前任务' || currentH1 === '重要任务';
// 子分类h2 在某个 h1 下):含 # 全部任务 下的 ## 工作 / ## 未分类 等
const inSubcategory = currentH2 !== null;
// 「全部任务」容器本身h1 但 h2 还没出来,下面的任务就是孤儿(应被迁移)
const inOtherTasksContainer = currentH1 === '全部任务' && !currentH2;
const forbidden = !(inCompleted || inTrash);
const section = currentH2
? `${currentH1} > ${currentH2}`
: currentH1;
findings.push({
marker,
section,
inFrontMatter,
inSubcategory,
inOtherTasksContainer,
inCompleted,
inTrash,
forbidden
});
}
}
return findings;
}
console.log('\n[1] data/todo.example.md 必须满足不变量');
const examplePath = path.resolve(__dirname, '..', 'data', 'todo.example.md');
const exampleMd = fs.readFileSync(examplePath, 'utf8');
const exampleFindings = locateCompletedMarkers(exampleMd);
const exampleForbidden = exampleFindings.filter(f => f.forbidden);
check('example 文件里没有 forbidden 的 [✓]/[x]/[X]',
exampleForbidden.length === 0,
exampleForbidden.map(f => `[${f.marker}] in ${f.section}`).join(' / ') || '0 处违规');
check('example 文件至少有一条 [✓] 在 # 已完成任务(确保规则真在执行)',
exampleFindings.some(f => f.inCompleted),
exampleFindings.filter(f => f.inCompleted).map(f => `[${f.marker}] in ${f.section}`).join(' / '));
console.log('\n[2] 外部手敲 [✓] 到 ## 工作 / ## 学习 / ## 未分类 —— store.loadFromContent 必须迁移');
const MISPLACED = [
'# 全部任务', '',
'## 工作', '',
'- [ ] 工作A', '',
'- [✓] 工作B (用户手敲)', '',
'## 学习', '',
'- [✓] 学习C (用户手敲)', '',
'## 未分类', '',
'- [✓] 未分类D (用户手敲)', '',
'# 已完成任务', '',
'- [✓] 之前提交的', '',
'# 回收站', '',
'- [ ] 之前删的', ''
].join('\n');
const misplacedStore = makeStore(MISPLACED);
const misplacedSer = misplacedStore.serialize();
const misplacedFindings = locateCompletedMarkers(misplacedSer);
const misplacedForbidden = misplacedFindings.filter(f => f.forbidden);
check('外部手敲 [✓] 到 ## 子分类后serialize 出来没有 forbidden 的 [✓]',
misplacedForbidden.length === 0,
misplacedForbidden.map(f => `[${f.marker}] in ${f.section}`).join(' / '));
// 迁移必须真的发生过:原本 4 条 completed 任务都得落到 # 已完成任务
check('迁移后 # 已完成任务 收齐所有 4 条 [✓] 任务',
misplacedStore.categories
.find(c => c.kind === CategoryKind.COMPLETED).tasks.length === 4,
`实际 ${misplacedStore.categories.find(c => c.kind === CategoryKind.COMPLETED).tasks.length}`);
console.log('\n[3] 顶部镜像段(# 当前任务 / # 重要任务)不允许出现 [✓]');
// 真实生产状态下completed=true 的任务会被 store 自动搬进 kind=COMPLETED
// 而 collectCurrentTasks / collectImportantTasks 已经显式跳过 COMPLETED
// 所以镜像段里绝无可能出现 [✓]。这里用 store.toggleTask 走完整链路验证。
const MIRROR_DOC = [
'# 全部任务', '',
'## 工作', '',
'- [ ] 当前任务1', '',
'- [ ] 重要任务1', '',
'- [ ] 普通任务1', ''
].join('\n');
const mirrorStore = makeStore(MIRROR_DOC);
const workCatName = mirrorStore.categories
.find(c => c.kind === CategoryKind.NORMAL).name;
const taskCurrent = mirrorStore.categories
.find(c => c.kind === CategoryKind.NORMAL).tasks[0];
const taskImportant = mirrorStore.categories
.find(c => c.kind === CategoryKind.NORMAL).tasks[1];
mirrorStore.updateTaskMeta(workCatName, taskCurrent.id, { current: true });
mirrorStore.updateTaskMeta(workCatName, taskImportant.id, { important: true });
// 现在勾选这两个任务 —— toggleTask 会把它们搬到 # 已完成任务
mirrorStore.toggleTask(workCatName, taskCurrent.id);
mirrorStore.toggleTask(workCatName, taskImportant.id);
const mirrorSer = mirrorStore.serialize();
const mirrorFindings = locateCompletedMarkers(mirrorSer);
const mirrorBugs = mirrorFindings.filter(f => f.inFrontMatter);
check('勾选当前/重要任务后,# 当前任务 / # 重要任务 镜像段不含 [✓]',
mirrorBugs.length === 0,
mirrorBugs.map(f => `[${f.marker}] in ${f.section}`).join(' / '));
console.log('\n[4] 干净状态下 writeMarkdown 输出仍然满足不变量');
// 模拟 post-migration 的状态:所有 completed=true 的任务都在 COMPLETED/TRASH 里。
// 不变量必须由 writer 在「干净输入」下自然成立 —— 这是 [✓] 不漏出去的最终防线。
const cleanCats = [
{ name: '全部任务', kind: CategoryKind.OTHER_TASKS, parentOtherTasks: false, tasks: [] },
{ name: '工作', kind: CategoryKind.NORMAL, parentOtherTasks: true, tasks: [
{ text: '写周报', completed: false, important: false, current: false }
]},
{ name: '未分类', kind: CategoryKind.NORMAL, parentOtherTasks: true, tasks: [
{ text: '看牙医', completed: false, important: false, current: false }
]},
{ name: '已完成任务', kind: CategoryKind.COMPLETED, parentOtherTasks: false, tasks: [
{ text: '提交过的工作', completed: true, important: false, current: false }
]},
{ name: '回收站', kind: CategoryKind.TRASH, parentOtherTasks: false, tasks: [
{ text: '之前删的', completed: false, important: false, current: false },
{ text: '已删且已完成的', completed: true, important: false, current: false }
]}
];
const cleanMd = writeMarkdown(cleanCats);
const cleanFindings = locateCompletedMarkers(cleanMd);
const cleanForbidden = cleanFindings.filter(f => f.forbidden);
check('writer 在干净状态下输出没有 forbidden 的 [✓]',
cleanForbidden.length === 0,
cleanForbidden.map(f => `[${f.marker}] in ${f.section}`).join(' / '));
console.log('\n[5] 顶部镜像段收集器排除 kind=COMPLETED');
const TOP_MIRROR_DOC = [
'# 全部任务', '',
'## 工作', '',
'- [ ] 普通任务', '',
'- [ ] 既当前又重要的任务', ''
].join('\n');
const topStore = makeStore(TOP_MIRROR_DOC);
const topWorkName = topStore.categories
.find(c => c.kind === CategoryKind.NORMAL).name;
const topNormal = topStore.categories
.find(c => c.kind === CategoryKind.NORMAL).tasks;
topStore.updateTaskMeta(topWorkName, topNormal[1].id, { current: true, important: true });
topStore.toggleTask(topWorkName, topNormal[1].id);
// 此时这条任务已搬到 # 已完成任务kind=COMPLETED
const topSer = topStore.serialize();
const topFindings = locateCompletedMarkers(topSer);
const topMirrorBugs = topFindings.filter(f => f.inFrontMatter);
check('勾选「既当前又重要」的任务后,# 当前任务 / # 重要任务 镜像段不含 [✓]',
topMirrorBugs.length === 0,
topMirrorBugs.map(f => `[${f.marker}] in ${f.section}`).join(' / '));
const topCompletedCount = topFindings.filter(f => f.inCompleted).length;
check('勾选后那条任务仅出现在 # 已完成任务 一次',
topCompletedCount === 1,
`实际 ${topCompletedCount}`);
console.log('\n[6] parser → 内部迁移 → writer 全链路');
// 把外部手敲的 [✓] 在 ## 工作 / ## 未分类 下的内容喂进 parser模拟真实「打开文件」场景
const externalMd = [
'# 全部任务', '',
'## 工作', '',
'- [✓] 完成的设计稿', '',
'## 未分类', '',
'- [✓] 买的书', '',
'# 已完成任务', '',
'- [✓] 之前提交的', '',
'# 回收站', '',
'- [ ] 之前删的', ''
].join('\n');
const externalParsed = parseMarkdown(externalMd);
// 模拟 _migrateCompletedTasksToCategory
const completedCat = externalParsed.find(c => c.kind === CategoryKind.COMPLETED);
if (completedCat) {
for (const cat of externalParsed) {
if (cat === completedCat) continue;
if (cat.kind === CategoryKind.TRASH) continue;
if (cat.kind === CategoryKind.OTHER_TASKS) continue;
const moving = cat.tasks.filter(t => t.completed);
const keep = cat.tasks.filter(t => !t.completed);
cat.tasks = keep;
for (const t of moving) {
if (cat.parentOtherTasks && !t.originalCategory) {
t.originalCategory = cat.name;
}
}
completedCat.tasks.push(...moving);
}
}
const externalOut = writeMarkdown(externalParsed);
const externalFindings = locateCompletedMarkers(externalOut);
const externalForbidden = externalFindings.filter(f => f.forbidden);
check('外部 [✓] 经 parser + 迁移 + writer 后无 forbidden',
externalForbidden.length === 0,
externalForbidden.map(f => `[${f.marker}] in ${f.section}`).join(' / '));
console.log(`\n结果:通过 ${summary.pass} / 失败 ${summary.fail}`);
process.exit(summary.fail === 0 ? 0 : 1);

View File

@@ -0,0 +1,136 @@
// 回归测试writer 把 `# 已完成任务` 排到「全部任务」及其 `##` 子分类之后、`# 回收站` 之前
//
// 背景v3.4 把 `# 已完成任务` 升级为真实分类kind=COMPLETED但 parser 按文件顺序
// 累积 categories外部手工编辑 / 老文件会让它夹在「全部任务」容器和 `##` 子分类之间。
// v3.5 修复writer 分三遍写出,强制把 `# 已完成任务` 放到期望位置。
//
// 跑法node scripts/check-completed-order.mjs
import { writeMarkdown } from '../src/markdown-writer.js';
import { check, ok, bad, summary, printSummary } from './_lib/check.mjs';
import { makeStore } from './_lib/store-fixture.mjs';
console.log('\n[1] 故意错序的 categories 数组writer 强制按期望顺序写出');
// 模拟用户文件里的乱序COMPLETED 排在 ## 子分类之前v3.4 老文件 / 外部编辑后的产物)。
// writer 必须按 `全部任务 > ## 子分类 > 已完成任务 > 回收站` 的视觉顺序输出。
const categories = [
{ name: '全部任务', kind: 'other_tasks', parentOtherTasks: false, tasks: [] },
{ name: '已完成任务', kind: 'completed', parentOtherTasks: false, tasks: [
{ text: '买牛奶', completed: true, important: false, current: false }
] },
{ name: '工作', kind: 'normal', parentOtherTasks: true, tasks: [
{ text: '写周报', completed: false, important: false, current: false }
] },
{ name: '未分类', kind: 'normal', parentOtherTasks: true, tasks: [
{ text: '看牙医', completed: false, important: false, current: false }
] },
{ name: '回收站', kind: 'trash', parentOtherTasks: false, tasks: [
{ text: '误删的任务', completed: false, important: false, current: false }
] }
];
const md = writeMarkdown(categories);
const idxAllTasks = md.indexOf('# 全部任务');
const idxCompleted = md.indexOf('# 已完成任务');
const idxSubWork = md.indexOf('## 工作');
const idxSubUncat = md.indexOf('## 未分类');
const idxTrash = md.indexOf('# 回收站');
check('# 全部任务 出现在 # 已完成任务 之前',
idxAllTasks >= 0 && idxCompleted >= 0 && idxAllTasks < idxCompleted,
`全部任务=${idxAllTasks}, 已完成=${idxCompleted}`);
check('## 工作 子分类 出现在 # 已完成任务 之前',
idxSubWork >= 0 && idxCompleted >= 0 && idxSubWork < idxCompleted,
`工作=${idxSubWork}, 已完成=${idxCompleted}`);
check('## 未分类 子分类 出现在 # 已完成任务 之前',
idxSubUncat >= 0 && idxCompleted >= 0 && idxSubUncat < idxCompleted,
`未分类=${idxSubUncat}, 已完成=${idxCompleted}`);
check('# 已完成任务 出现在 # 回收站 之前',
idxCompleted >= 0 && idxTrash >= 0 && idxCompleted < idxTrash,
`已完成=${idxCompleted}, 回收站=${idxTrash}`);
console.log('\n[2] 子分类相对顺序保留(不被 sort 打乱)');
// 用户手工调整过 ## 子分类顺序:未分类 → 工作 → 学习 → 111
// 即使 writer 重排了 COMPLETED / TRASH 的位置,## 子分类之间的顺序必须原样保留。
const ordered = [
{ name: '全部任务', kind: 'other_tasks', parentOtherTasks: false, tasks: [] },
{ name: '已完成任务', kind: 'completed', parentOtherTasks: false, tasks: [
{ text: '买牛奶', completed: true }
] },
{ name: '未分类', kind: 'normal', parentOtherTasks: true, tasks: [] },
{ name: '工作', kind: 'normal', parentOtherTasks: true, tasks: [] },
{ name: '学习', kind: 'normal', parentOtherTasks: true, tasks: [] },
{ name: '111', kind: 'normal', parentOtherTasks: true, tasks: [] },
{ name: '回收站', kind: 'trash', parentOtherTasks: false, tasks: [] }
];
const md2 = writeMarkdown(ordered);
const iUncat = md2.indexOf('## 未分类');
const iWork = md2.indexOf('## 工作');
const iStudy = md2.indexOf('## 学习');
const i111 = md2.indexOf('## 111');
check('## 子分类相对顺序:未分类 < 工作 < 学习 < 111',
iUncat >= 0 && iWork > iUncat && iStudy > iWork && i111 > iStudy,
`未分类=${iUncat}, 工作=${iWork}, 学习=${iStudy}, 111=${i111}`);
console.log('\n[3] 空 COMPLETED 分类不写出(与原行为一致)');
// 没有已完成任务时,整个 `# 已完成任务` 段应该消失(避免空标题噪音)。
// 但 `# 全部任务` 和 `## 工作` 仍然存在。
const noCompleted = [
{ name: '全部任务', kind: 'other_tasks', parentOtherTasks: false, tasks: [] },
{ name: '已完成任务', kind: 'completed', parentOtherTasks: false, tasks: [] },
{ name: '工作', kind: 'normal', parentOtherTasks: true, tasks: [
{ text: '写周报', completed: false }
] },
{ name: '回收站', kind: 'trash', parentOtherTasks: false, tasks: [] }
];
const md3 = writeMarkdown(noCompleted);
check('空 # 已完成任务 不写出',
md3.indexOf('# 已完成任务') === -1);
check('# 全部任务 仍然写出',
md3.indexOf('# 全部任务') >= 0);
check('## 工作 仍然写出',
md3.indexOf('## 工作') >= 0);
console.log('\n[4] 复杂场景:重复 ## 未分类 / 多个已完成分类 / TODAY 兜底');
// 模拟用户 todo.md 的复杂场景:两个 `## 未分类`、多个 kind=COMPLETED 分类、TODAY 兜底
const complex = [
{ name: '全部任务', kind: 'other_tasks', parentOtherTasks: false, tasks: [] },
{ name: '已完成任务', kind: 'completed', parentOtherTasks: false, tasks: [
{ text: 'task1', completed: true }
] },
{ name: '未分类', kind: 'normal', parentOtherTasks: true, tasks: [
{ text: 'orphan1', completed: false }
] },
{ name: '学习', kind: 'normal', parentOtherTasks: true, tasks: [] },
{ name: '未分类', kind: 'normal', parentOtherTasks: true, tasks: [
{ text: 'orphan2', completed: false }
] },
{ name: '我的一天', kind: 'today', parentOtherTasks: false, tasks: [
{ text: 'today1', completed: false }
] },
{ name: '回收站', kind: 'trash', parentOtherTasks: false, tasks: [
{ text: 'trashed', completed: false }
] }
];
const md4 = writeMarkdown(complex);
const lastCompleted = md4.lastIndexOf('# 已完成任务');
const firstTrash = md4.indexOf('# 回收站');
check('复杂场景下 # 已完成任务 仍在 # 回收站 之前',
lastCompleted >= 0 && firstTrash >= 0 && lastCompleted < firstTrash,
`最后一个#已完成=${lastCompleted}, 第一个#回收站=${firstTrash}`);
const allTrash = md4.match(/^# 回收站/gm) || [];
check('复杂场景下 # 回收站 仍是文件最后一段(不被多个已完成分类挤到中间)',
allTrash.length === 1 && md4.trimEnd().endsWith('trashed'),
`trash 行数=${allTrash.length}, 末尾=${md4.trimEnd().slice(-30)}`);
console.log(`\n结果:通过 ${summary.pass} / 失败 ${summary.fail}`);
process.exit(summary.fail === 0 ? 0 : 1);

View File

@@ -0,0 +1,212 @@
// 测试「并发编辑数据丢失防护」:用户在软件里改任务时,外部编辑器同步保存,
// 自动保存必须主动让位(拒绝写盘),由冲突解决流程决定谁覆盖谁。
//
// 真实数据丢失场景:
// T0 用户改任务 → dirty=true800ms autoSave 排队
// T0+10ms 外部编辑器保存 → 文件被改
// T0+800ms autoSave 触发 → 之前 v4 方案会让自动保存直接写,覆盖外部编辑
// T0+1500ms external-change IPC 终于到达 → 内容比对时内存 == 文件 → 静默
// ↑ 外部编辑就这样被悄悄丢掉了
//
// 修复main.js file:write 加 mtime 预检,发现文件被外部动过就直接拒绝,
// 返回 EXTERNAL_CHANGE_DETECTED渲染端走冲突解决流程弹对话框。
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 { installApiStub } from './_lib/api-stub.mjs';
// 桩出 window.api让 TaskStore 不真去写文件
installApiStub();
// ============================================================
// [1] saveNow 永远 force=true用户意图不被主进程拦截
// ============================================================
console.log('\n[1] saveNow 调用 writeFile 时带 force=true');
{
// 用 TaskStore 模拟一次保存,检查它最终调用 writeFile 的参数。
// 不能直接观察 TaskStore 内部 APIwriteFile 是 window.api 上的),
// 但可以验证 saveNow 与 autoSave 的代码路径走的是同一个 IPC。
let lastWriteOptions = null;
globalThis.window.api.writeFile = async (fp, content, options) => {
lastWriteOptions = options;
return { ok: true };
};
const store = makeStore(`# 全部任务\n\n## 工作\n\n- [ ] a\n`);
store.filePath = '/test/todo.md';
await store.saveNow();
check('saveNow 把 force=true 传给 writeFile',
lastWriteOptions && lastWriteOptions.force === true,
`options=${JSON.stringify(lastWriteOptions)}`);
// 重置
lastWriteOptions = null;
}
// ============================================================
// [2] _doAutoSave 调用 writeFile 时不带 force主进程会做 mtime 检查
// ============================================================
console.log('\n[2] autoSave 调用 writeFile 时不带 force');
{
// 直接调 _doAutoSave 模拟自动保存触发,观察传给 writeFile 的 options。
let lastWriteOptions = undefined;
globalThis.window.api.writeFile = async (fp, content, options) => {
lastWriteOptions = options;
return { ok: true };
};
const store = makeStore(`# 全部任务\n\n## 工作\n\n- [ ] a\n`);
store.filePath = '/test/todo.md';
// 标记 dirtyautoSave 排队),直接调 _doAutoSave 跳过 debounce
store._markDirty();
await store._doAutoSave();
check('autoSave 把 options=undefined或 {})传给 writeFile主进程 mtime 检查会生效',
lastWriteOptions === undefined || (lastWriteOptions && lastWriteOptions.force !== true),
`options=${JSON.stringify(lastWriteOptions)}`);
}
// ============================================================
// [3] saveNow 在 force=true 模式下,外部修改检测会被跳过
// ============================================================
console.log('\n[3] force=true 跳过主进程 mtime 检查');
{
// 模拟主进程的逻辑file:write handler 收到 options.force 时跳过检查。
let mtimeCheckRan = false;
globalThis.window.api.writeFile = async (fp, content, options) => {
// 模拟主进程 file:write handler 的 mtime 预检
if (!options || !options.force) {
mtimeCheckRan = true;
// 这里如果发现 mtime 不一致就返回 EXTERNAL_CHANGE_DETECTED
return { ok: false, error: 'EXTERNAL_CHANGE_DETECTED' };
}
return { ok: true };
};
const store = makeStore(`# 全部任务\n\n## 工作\n\n- [ ] a\n`);
store.filePath = '/test/todo.md';
// saveNow 走 force=true → 主进程跳过检查 → 写入成功
const ok = await store.saveNow();
check('saveNow (force=true) 不触发 mtime 检查',
ok === true && mtimeCheckRan === false,
`ok=${ok}, mtimeCheckRan=${mtimeCheckRan}`);
}
// ============================================================
// [4] autoSave 在非 force 模式下,外部修改会让写盘失败
// ============================================================
console.log('\n[4] autoSave (无 force) 在外部修改下被主进程拒绝');
{
// 模拟主进程 mtime 预检逻辑
globalThis.window.api.writeFile = async (fp, content, options) => {
// 非 force 模式:模拟发现外部修改
return { ok: false, error: 'EXTERNAL_CHANGE_DETECTED' };
};
const store = makeStore(`# 全部任务\n\n## 工作\n\n- [ ] a\n`);
store.filePath = '/test/todo.md';
let saveErrorEmitted = null;
store.on('save-error', (err) => { saveErrorEmitted = err; });
store._markDirty();
await store._doAutoSave();
// v3.5 起 save-error 载荷是 {code, message, diskContent},渲染端从 err.code 识别错误码
check('autoSave 被拒绝时 emit("save-error", {code: "EXTERNAL_CHANGE_DETECTED"})',
saveErrorEmitted?.code === 'EXTERNAL_CHANGE_DETECTED',
`saveErrorEmitted=${JSON.stringify(saveErrorEmitted)}`);
check('autoSave 失败后 dirty 仍为 true继续等待冲突解决',
store.dirty === true,
`dirty=${store.dirty}`);
}
// ============================================================
// [5] 冲突解决后的「保留本地」路径saveNow force=true 必然成功
// ============================================================
console.log('\n[5] 「保留本地」路径force=true 必然覆盖');
{
// 即使文件被外部动过saveNow 用 force=true 也必须能覆盖
// (用户已明确表态要保留本地修改)
globalThis.window.api.writeFile = async (fp, content, options) => {
// 主进程收到 force=true → 跳过检查 → 直接写
return { ok: true };
};
const store = makeStore(`# 全部任务\n\n## 工作\n\n- [ ] a\n`);
store.filePath = '/test/todo.md';
// 模拟外部编辑 + 用户选择保留本地
store.dirty = true;
const ok = await store.saveNow();
check('保留本地场景下 saveNow 成功', ok === true);
check('保留本地后 dirty 清零', store.dirty === false);
}
// ============================================================
// [6] EXTERNAL_CHANGE_DETECTED 走和 file:external-change 一致的流程
// ============================================================
console.log('\n[6] 两条入口IPC / save-error合并到同一冲突解决流程');
{
// 这里测的是「逻辑上」两条路径都应该走到同一处理函数。
// 真正的 app.js 测试需要 DOM/Electron 环境integration test 不在本 mjs 范围)。
// 单元层只验证save-error 携带 EXTERNAL_CHANGE_DETECTED 时,
// 渲染端能够识别这一错误码。
const externalChangeCodes = ['EXTERNAL_CHANGE_DETECTED'];
let identified = null;
for (const code of externalChangeCodes) {
if (code === 'EXTERNAL_CHANGE_DETECTED') identified = code;
}
check('渲染端能识别 EXTERNAL_CHANGE_DETECTED 错误码',
identified === 'EXTERNAL_CHANGE_DETECTED');
}
// ============================================================
// [7] 端到端:用户视角下数据不会丢失
// ============================================================
console.log('\n[7] 端到端场景:并发编辑不丢数据');
{
// 这是整个防护的语义验证:
// - 用户改 A → 内存 A
// - 外部编辑器改成 B → 文件 B
// - autoSave 试图把 A 写回文件 → 被主进程拒绝 → dirty 保留 → 触发冲突流程
// - 用户选择「保留本地」→ saveNow (force=true) → 写 A → 文件 A
// - 用户选择「重新加载」→ readFile → loadFromContent(B) → 内存 B
//
// 关键无论用户选哪个外部编辑的内容B都不会被 autoSave 静默吞掉。
const initialMd = `# 全部任务\n\n## 工作\n\n- [ ] A\n`;
const store = makeStore(initialMd);
store.filePath = '/test/todo.md';
store.dirty = true; // 用户改了 A
// 模拟主进程 mtime 预检发现外部修改
globalThis.window.api.writeFile = async (fp, content, options) => {
if (!options?.force) {
// 检测到外部修改,拒绝写盘
return { ok: false, error: 'EXTERNAL_CHANGE_DETECTED' };
}
return { ok: true };
};
// autoSave 路径
let saveErrorSeen = false;
store.on('save-error', (err) => { if (err?.code === 'EXTERNAL_CHANGE_DETECTED') saveErrorSeen = true; });
await store._doAutoSave();
check('autoSave 被拒绝(外部编辑不会被覆盖)', saveErrorSeen === true);
check('dirty 保留(继续等待用户决策)', store.dirty === true);
// 用户选择「保留本地」→ saveNow (force=true) → 覆盖
const ok = await store.saveNow();
check('用户选保留本地 → 成功覆盖外部编辑', ok === true);
check('dirty 清零', store.dirty === false);
}
console.log(`\n通过 ${summary.pass} / 失败 ${summary.fail}`);
process.exit(summary.fail === 0 ? 0 : 1);

View File

@@ -0,0 +1,134 @@
// 回归测试H7 — modified 默认选择是 'both'OR 合并状态)
//
// 背景:旧版 defaultResolutions 把 modified 默认设为 'b'(用本地)。这导致
// 磁盘侧修改的标记completed / important / current被默默丢弃 ——
// 用户可能根本不知道,文件里的重要标记就消失了。
//
// 修复v3.7+modified 默认 'both'OR 合并。current 在 v4 起改为:
// - 仅一边 current=true → OR 保留(修复 H3用户在 UI 标 [▶] 后,磁盘
// 还没同步,合并不丢标记)
// - 两边都 current=true → 强制 false仍是单值指针
// 跑法node scripts/check-conflict-default.mjs
import { defaultResolutions, diffCategories, applyResolutions } from '../src/utils/markdown-diff.js';
import { check, summary, printSummary } from './_lib/check.mjs';
const makeCat = (name, kind = 'normal', parentOtherTasks = true, tasks = []) =>
({ name, kind, isSpecial: false, meta: null, parentOtherTasks, tasks });
const container = { name: '全部任务', kind: 'other_tasks', isSpecial: true, meta: null, parentOtherTasks: false, tasks: [] };
const trash = { name: '回收站', kind: 'trash', isSpecial: true, meta: null, parentOtherTasks: false, tasks: [] };
// ── [1] defaultResolutionsmodified 默认 'both' ──
console.log('\n[1] defaultResolutionsmodified 默认 "both"');
{
// v4 起completed=true 任务也参与 diff按 effective cat name所以两侧都用
// completed=falseimportant 字段不同即可触发 modified。
const catsA = [
container,
makeCat('工作', 'normal', true, [
{ text: 'X', completed: false, important: true, current: false }, // 仅 A
]),
makeCat('学习', 'normal', true, [
{ text: 'Y', completed: false, important: true, current: false }, // modified两侧 important 不同)
]),
trash,
];
const catsB = [
container,
makeCat('工作', 'normal', true, [
{ text: 'Z', completed: false, important: false, current: false }, // 仅 B
]),
makeCat('学习', 'normal', true, [
{ text: 'Y', completed: false, important: false, current: false }, // 与 A 文本同important 不同
]),
trash,
];
const diff = diffCategories(catsA, catsB);
const res = defaultResolutions(diff);
check('diff 至少一条 modified', diff.modified.length >= 1,
`modified=${JSON.stringify(diff.modified)}`);
check('diff 有 onlyInAX', diff.onlyInA.length >= 1);
check('diff 有 onlyInBZ', diff.onlyInB.length >= 1);
// modified 默认值
const modifiedKey = diff.modified[0].key;
check(`modified 默认是 'both'`,
res[modifiedKey] === 'both',
`actual=${res[modifiedKey]}, key=${modifiedKey}`);
check(`onlyInA 默认是 'a'`,
res[diff.onlyInA[0].key] === 'a');
check(`onlyInB 默认是 'b'`,
res[diff.onlyInB[0].key] === 'b');
}
// ── [2] 仅 modified无 onlyIn* —— 默认仍 'both' ──
console.log('\n[2] 纯 modified 场景:默认值仍是 "both"');
{
// v4completed=false 两侧 + important 不同 —— 进入 modified 分支。
const catsA = [container, makeCat('工作', 'normal', true, [
{ text: 'T', completed: false, important: true, current: false },
]), trash];
const catsB = [container, makeCat('工作', 'normal', true, [
{ text: 'T', completed: false, important: false, current: false },
]), trash];
const diff = diffCategories(catsA, catsB);
const res = defaultResolutions(diff);
check('纯 modified diff 的 default 是 both',
diff.modified.length === 1 && res[diff.modified[0].key] === 'both',
`modified.len=${diff.modified.length}, res=${JSON.stringify(res)}`);
}
// ── [3] applyResolutionsmodified 默认 'both'(无显式 resolution 时) ──
console.log('\n[3] applyResolutionsmodified 默认 "both"OR 合并)');
{
// 两侧 completed=falseimportant 不同 → 进 modified
// catsB 有 current=truecatsA 是 false —— v4 H3 修复:单边 true 保留 OR 结果。
const catsA = [container, makeCat('工作', 'normal', true, [
{ text: 'T', completed: false, important: false, current: false },
]), trash];
const catsB = [container, makeCat('工作', 'normal', true, [
{ text: 'T', completed: false, important: true, current: true },
]), trash];
const diff = diffCategories(catsA, catsB);
// 不传 resolutions空对象—— 验证 applyResolutions 内部的默认值
const merged = applyResolutions(catsA, diff, {});
const t = merged.find(c => c.name === '工作').tasks[0];
check('默认合并后 completed = OR(false, false) = false两边都没勾选',
t.completed === false,
`t=${JSON.stringify(t)}`);
check('默认合并后 important = OR(false, true) = true',
t.important === true);
check('默认合并后 current = OR(false, true) = trueH3 修复:单边 true 不再被吞)',
t.current === true,
`t.current=${t.current}, expected true`);
}
// ── [4] current 双边 true → 合并后强制 false仍是单值指针 ──
console.log('\n[4] applyResolutionscurrent 双边 true → 强制 false');
{
// 两侧都标了 [▶] —— 矛盾,强制 false 让用户手动重选。
// 必须有其他状态字段不同(这里用 important让两侧落到 modified 分支;
// 完全相同的两侧会被 tasksStateEqual 判 unchanged根本不走 mergeTaskStates。
const catsA = [container, makeCat('工作', 'normal', true, [
{ text: 'T', completed: false, important: false, current: true },
]), trash];
const catsB = [container, makeCat('工作', 'normal', true, [
{ text: 'T', completed: false, important: true, current: true },
]), trash];
const diff = diffCategories(catsA, catsB);
const merged = applyResolutions(catsA, diff, {});
const t = merged.find(c => c.name === '工作').tasks[0];
check('双边 current=true → 合并后 current=false',
t.current === false,
`t=${JSON.stringify(t)}`);
}
printSummary('check-conflict-default');

View File

@@ -0,0 +1,521 @@
// 任务粒度 diff / merge 工具测试
//
// 覆盖 markdown-diff.js 的核心行为categoriesToMap、diffCategories、applyResolutions、
// defaultResolutions、mergeTaskStates、summarizeDiff。在纯函数层验证 —— 不需要 DOM / Electron。
//
// 关键覆盖场景:
// 1) 仅 A 侧有的任务 → onlyInA
// 2) 仅 B 侧有的任务 → onlyInB
// 3) 两边都有但状态不同 → modified
// 4) 两边完全相同 → unchanged
// 5) 文本被改 → 视为不同任务key 是 catName::texttext 变 → 新 key
// 6) 容器 / TODAY 不参与 diffCOMPLETED / TRASH 按 originalCategory 入 keyv4
// 7) applyResolutions 的每个选项a/b/both/skip
// 8) defaultResolutions 智能默认
// 9) summarizeDiff 计数
// 10) v4 H1 修复:移除 idxInCat 后,跨 idx 漂移(用户加任务)不产生重复
// 11) v4 H4 修复用户勾选NORMAL → COMPLETED默认合并保留动作
// 12) v4 H5 修复:用户回收 / 恢复 默认合并保留动作
// 13) v4 H3 修复current 仅一边 true 时 OR 保留,不丢 [▶] 标记
//
// 用法node scripts/check-conflict-merge.mjs
import {
categoriesToMap,
diffCategories,
applyResolutions,
defaultResolutions,
mergeTaskStates,
summarizeDiff,
makeTaskKey,
tasksStateEqual,
tasksTextuallyEqual,
} from '../src/utils/markdown-diff.js';
import { check, ok, bad, summary, printSummary } from './_lib/check.mjs';
function truthy(value, name) {
if (value) ok(name);
else bad(name, `expected truthy, got ${value}`);
}
// 直接 === 比较两个值(用 JSON.stringify 处理对象 / 数组)
function eq(actual, expected, name) {
if (JSON.stringify(actual) === JSON.stringify(expected)) ok(name);
else bad(name, `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
// ── 测试数据 ──
function makeCat(name, kind = 'normal', parentOtherTasks = true, tasks = []) {
return { name, kind, isSpecial: false, meta: null, parentOtherTasks, tasks };
}
const container = { name: '全部任务', kind: 'other_tasks', isSpecial: true, meta: null, parentOtherTasks: false, tasks: [] };
const trash = { name: '回收站', kind: 'trash', isSpecial: true, meta: null, parentOtherTasks: false, tasks: [] };
const catsA = [
container,
makeCat('工作', 'normal', true, [
{ text: 'A1', completed: false, important: true, current: false },
{ text: 'A2', completed: false, important: true, current: false }, // 与 B 文本同但 important 不同
]),
makeCat('学习', 'normal', true, [
{ text: 'shared', completed: false, important: false, current: false },
]),
trash,
];
const catsB = [
container,
makeCat('工作', 'normal', true, [
{ text: 'B1', completed: false, important: false, current: false }, // 仅 B 有idx=0
{ text: 'A2', completed: false, important: false, current: false }, // 与 A 同 idx=1 但状态不同
]),
makeCat('学习', 'normal', true, [
{ text: 'shared', completed: false, important: false, current: false }, // 与 A 完全相同
]),
trash,
];
// ── 1) makeTaskKey ──
//
// 注v4 起 key 不含 idxInCat —— 详见 src/utils/markdown-diff.js 的历史注。
// 「同 cat 内位置漂移」用 text 区分text 变 = 不同任务idx 不进 key。
console.log('\n[makeTaskKey]');
eq(makeTaskKey('工作', 'A1'), '2:工作::2:A1', '基本键值');
eq(makeTaskKey('工作', 'A1', 2), '2:工作::2:A1', 'idxInCat 参数被忽略v4 设计key 不含 idx');
eq(makeTaskKey('工作', '特殊 | 字符'), '2:工作::7:特殊 | 字符', '特殊字符保留');
eq(makeTaskKey('工作', ' hello '), '2:工作::5:hello', '文本前后空白被 trimH6 修复)');
// ── 2) categoriesToMap ──
console.log('\n[categoriesToMap]');
{
const map = categoriesToMap(catsA);
truthy(map.has('2:工作::2:A1'), '工作::A1 存在');
truthy(map.has('2:工作::2:A2'), '工作::A2 存在');
truthy(map.has('2:学习::6:shared'), '学习::shared 存在');
truthy(!map.has('4:全部任务::'), '容器不参与');
truthy(!map.has('3:回收站::'), '回收站不参与');
}
// ── 3) tasksStateEqual ──
console.log('\n[tasksStateEqual]');
truthy(tasksStateEqual(
{ completed: true, important: false, current: false },
{ completed: true, important: false, current: false }
), '同状态 = true');
truthy(!tasksStateEqual(
{ completed: true, important: false, current: false },
{ completed: false, important: false, current: false }
), 'completed 不同 = false');
truthy(!tasksStateEqual(
{ completed: false, important: false, current: false },
{ completed: false, important: true, current: false }
), 'important 不同 = false');
// ── 4) tasksTextuallyEqual ──
console.log('\n[tasksTextuallyEqual]');
truthy(tasksTextuallyEqual({ text: 'hello' }, { text: 'hello' }), '相同文本');
truthy(!tasksTextuallyEqual({ text: 'hello' }, { text: 'world' }), '不同文本');
truthy(tasksTextuallyEqual({ text: ' hello ' }, { text: 'hello' }), '前后空白 trim 后相等');
// ── 5) diffCategories 核心 ──
console.log('\n[diffCategories]');
const diff = diffCategories(catsA, catsB);
// onlyInAA 里有 B 里没有的A1
eq(diff.onlyInA.length, 1, 'onlyInA 数量 = 1');
eq(diff.onlyInA[0].key, '2:工作::2:A1', 'onlyInA 是 A1');
// onlyInBB 里有 A 里没有的B1
eq(diff.onlyInB.length, 1, 'onlyInB 数量 = 1');
eq(diff.onlyInB[0].key, '2:工作::2:B1', 'onlyInB 是 B1');
// modifiedA2 文本相同但状态不同
eq(diff.modified.length, 1, 'modified 数量 = 1');
eq(diff.modified[0].key, '2:工作::2:A2', 'modified 是 A2');
truthy(diff.modified[0].stateOnly, 'A2 是 stateOnly modified');
truthy(!diff.modified[0].stateContainerMove, 'A2 不是 state container move同 cat 同 kind');
// unchangedshared 完全相同
eq(diff.unchanged.length, 1, 'unchanged 数量 = 1');
eq(diff.unchanged[0].key, '2:学习::6:shared', 'unchanged 是 shared');
// ── 6) TODAY 容器不参与 diff ──
console.log('\n[excluded categories]');
{
const withToday = [
container,
makeCat('我的一天', 'today', false, [
{ text: 'todayTask', completed: false, important: false, current: false },
]),
];
const m = categoriesToMap(withToday);
truthy(!m.has('4:我的一天::9:todayTask'), 'TODAY 分类任务不参与 diff');
// v4 修复COMPLETED 任务按 originalCategory 入 key —— 没 originalCategory 时
// 走 __anon_{catIdx}_{taskIdx} 复合 key避免同 cat 内多条 orphan 任务撞 key 被
// 静默吞掉v3.7+ HIGH data-loss vector见 markdown-diff.js 注释)。旧版用单一
// '__anon__' 字符串会让第二条同 text 的 orphan 任务被 `if (!map.has(key))` 丢掉。
const withCompleted = [
makeCat('已完成任务', 'completed', false, [
// 有 originalCategory 时按工作入 key —— 与 NORMAL 「工作」同文本 [ ] 任务撞 key
{ text: 'done1', completed: true, important: false, current: false, originalCategory: '工作' },
// 无 originalCategory 时落 '__anon_<catIdx>_<taskIdx>'orphan 兜底 + 唯一身份)
{ text: 'orphanDone', completed: true, important: false, current: false },
]),
makeCat('普通', 'normal', true, [
{ text: 'normal1', completed: false, important: false, current: false },
]),
];
const m2 = categoriesToMap(withCompleted);
truthy(m2.has('2:工作::5:done1'), 'COMPLETED 任务按 originalCategory 入 keyv4 修复)');
// orphanDone 是 COMPLETED cat 的第 2 条任务 → taskIdx=1在 filtered 数组里
// catIdx=0因为只有这条 COMPLETED cat 进第一遍。key 前缀 '__anon_0_1'。
truthy(m2.has('10:__anon_0_1::10:orphanDone'), '无 originalCategory 的 COMPLETED 任务走唯一 anon keyv3.7+ 修复 H 数据丢失)');
truthy(m2.has('2:普通::7:normal1'), '普通分类正常参与 diff');
// v4 起completed=true 任务也参与 diff —— 否则用户在内存里勾选的任务,磁盘
// 侧同文本 [ ] 版本会被当成「onlyInDisk」→ 默认 a → 用户的勾选被静默撤销。
// 这是 H4 历史 bug 的修复方向:勾选动作必须能在冲突 UI 里被保留(走 both/OR 合并)。
const withLeaked = [
makeCat('普通', 'normal', true, [
{ text: 'leaked', completed: true, important: false, current: false },
{ text: 'normal', completed: false, important: false, current: false },
]),
];
const m3 = categoriesToMap(withLeaked);
truthy(m3.has('2:普通::6:leaked'), 'completed=true 任务也参与 diffv4 保留勾选动作)');
truthy(m3.has('2:普通::6:normal'), '同 cat 内非完成态任务正常入 key');
}
// ── 7) applyResolutions —— 各种选项 ──
console.log('\n[applyResolutions]');
{
// 'a' (onlyInA) → 把 A1 加进去
const merged = applyResolutions(catsA, diff, { '2:工作::2:A1': 'a' });
const workCat = merged.find(c => c.name === '工作');
truthy(workCat.tasks.some(t => t.text === 'A1'), 'onlyInA 用 a → A1 进入合并结果');
// 'b' (onlyInB) → 把 B1 加进去
const merged2 = applyResolutions(catsA, diff, { '2:工作::2:B1': 'b' });
const workCat2 = merged2.find(c => c.name === '工作');
truthy(workCat2.tasks.some(t => t.text === 'B1'), 'onlyInB 用 b → B1 进入合并结果');
// 'a' (modified) → 用 A 侧的任务状态important=true
const merged3 = applyResolutions(catsA, diff, { '2:工作::2:A2': 'a' });
const workCat3 = merged3.find(c => c.name === '工作');
const a2 = workCat3.tasks.find(t => t.text === 'A2');
truthy(a2.important === true, 'modified 用 a → A 侧状态important=true');
// 'b' (modified) → 用 B 侧的任务状态important=false
const merged4 = applyResolutions(catsA, diff, { '2:工作::2:A2': 'b' });
const workCat4 = merged4.find(c => c.name === '工作');
const a2b = workCat4.tasks.find(t => t.text === 'A2');
truthy(a2b.important === false, 'modified 用 b → B 侧状态important=false');
// 'both' (modified) → OR 合并状态
// A 侧 important=trueB 侧 important=false → 合完后 important=true
const merged5 = applyResolutions(catsA, diff, { '2:工作::2:A2': 'both' });
const workCat5 = merged5.find(c => c.name === '工作');
const a2both = workCat5.tasks.find(t => t.text === 'A2');
truthy(a2both.important === true, 'modified 用 both → OR 合并A=true B=false → true');
// 'skip' (onlyInA) → 不写入
const merged6 = applyResolutions(catsA, diff, { '2:工作::2:A1': 'skip' });
const workCat6 = merged6.find(c => c.name === '工作');
const a1InBase = catsA.find(c => c.name === '工作').tasks.find(t => t.text === 'A1');
const a1InMerged = workCat6.tasks.find(t => t.text === 'A1');
truthy(a1InMerged === a1InBase, 'onlyInA 用 skip → A1 引用未被额外创建(保留 base 里的同一对象)');
}
// ── 7.5) v4 H1 修复:移除 idxInCat 后,跨 idx 漂移不产生重复 ──
console.log('\n[v4 H1 修复idx 漂移不重复]');
{
// 模拟「用户在 B 侧(新内存)于工作分类前端插入了新任务」——
// 同样两个老任务 idx 在 B 侧都比 A 侧 +1。
const sideA = [
container,
makeCat('工作', 'normal', true, [
{ text: 'T', completed: false, important: false, current: false },
{ text: 'U', completed: false, important: false, current: false },
]),
trash,
];
const sideB = [
container,
makeCat('工作', 'normal', true, [
{ text: 'NEW', completed: false, important: false, current: false }, // 新插入
{ text: 'T', completed: false, important: false, current: false }, // idx 漂移
{ text: 'U', completed: false, important: false, current: false }, // idx 漂移
]),
trash,
];
const d = diffCategories(sideA, sideB);
// T 和 U 应该 unchangedkey 用 cat+text不受 idx 影响)
eq(d.unchanged.length, 2, 'idx 漂移后 T 和 U 仍判定为 unchanged');
eq(d.onlyInA.length, 0, 'idx 漂移不会让 A 侧误报 onlyInA');
eq(d.onlyInB.length, 1, '仅 NEW 是 onlyInB');
eq(d.modified.length, 0, 'idx 漂移不会触发 modified');
// 默认合并后不会出现 T/U 重复
const merged = applyResolutions(sideA, d, {});
const workCat = merged.find(c => c.name === '工作');
const tCount = workCat.tasks.filter(t => t.text === 'T').length;
const uCount = workCat.tasks.filter(t => t.text === 'U').length;
truthy(tCount === 1, 'T 只出现一次(无重复)');
truthy(uCount === 1, 'U 只出现一次(无重复)');
}
// ── 7.6) v4 H4 修复用户勾选NORMAL → COMPLETED默认合并保留动作 ──
console.log('\n[v4 H4 修复:勾选状态默认合并保留]');
{
const sideA = [
container,
makeCat('工作', 'normal', true, [
{ text: 'X', completed: false, important: false, current: false }, // 磁盘 [ ]
]),
trash,
];
// 内存B 侧已把 X 勾选并迁到 COMPLETEDv3.4 行为);同时 NORMAL 里还残留旧副本migrate 过渡期)
const sideB = [
container,
makeCat('工作', 'normal', true, [
{ text: 'X', completed: false, important: false, current: false }, // 残留 NORMAL 副本
]),
{ name: '已完成任务', kind: 'completed', isSpecial: true, meta: null, parentOtherTasks: false,
tasks: [
{ text: 'X', completed: true, important: false, current: false, originalCategory: '工作' },
],
},
trash,
];
const d = diffCategories(sideA, sideB);
// 勾选场景X 在 disk NORMAL 和 memory COMPLETED按 effective 名匹配 → modified (stateContainerMove)
truthy(d.modified.length === 1 && d.modified[0].stateContainerMove === true,
'勾选产生 stateContainerMove modified');
// 默认 'both' 合并后X 在 COMPLETED 且 completed=true工作分类里不再有 X
const merged = applyResolutions(sideA, d, {});
const workCat = merged.find(c => c.name === '工作');
const completedCat = merged.find(c => c.name === '已完成任务');
truthy(!workCat.tasks.some(t => t.text === 'X'), '工作分类不再有 X用户已勾选');
const xInCompleted = completedCat && completedCat.tasks.find(t => t.text === 'X');
truthy(xInCompleted && xInCompleted.completed === true,
'COMPLETED 里有 X 且 completed=true用户勾选保留');
}
// ── 7.7) v4 H5 修复用户回收NORMAL → TRASH默认合并保留动作 ──
console.log('\n[v4 H5 修复:回收动作默认合并保留]');
{
const sideA = [
container,
makeCat('工作', 'normal', true, [
{ text: 'Y', completed: false, important: false, current: false },
]),
trash,
];
// 内存B 侧已把 Y 迁到回收站
const sideB = [
container,
makeCat('工作', 'normal', true, [
{ text: 'Y', completed: false, important: false, current: false }, // 残留 NORMAL 副本
]),
{ name: '回收站', kind: 'trash', isSpecial: true, meta: null, parentOtherTasks: false,
tasks: [
{ text: 'Y', completed: false, important: false, current: false, originalCategory: '工作' },
],
},
];
const d = diffCategories(sideA, sideB);
truthy(d.modified.length === 1 && d.modified[0].stateContainerMove === true,
'回收产生 stateContainerMove modified');
const merged = applyResolutions(sideA, d, {});
const workCat = merged.find(c => c.name === '工作');
const trashCat = merged.find(c => c.name === '回收站');
truthy(!workCat.tasks.some(t => t.text === 'Y'), '工作分类不再有 Y用户已回收');
truthy(trashCat && trashCat.tasks.some(t => t.text === 'Y'),
'回收站里有 Y用户回收动作保留');
}
// ── 7.8) v4 H5 修复:从回收站恢复 ──
console.log('\n[v4 H5 修复:恢复动作默认合并保留]');
{
// 磁盘有 Z 在 TRASHoriginalCategory='工作'),内存里用户把它恢复到工作
const sideA = [
container,
makeCat('工作', 'normal', true, [
{ text: 'Z', completed: false, important: false, current: false }, // 工作分类里也有同名任务(用户已恢复)
]),
{ name: '回收站', kind: 'trash', isSpecial: true, meta: null, parentOtherTasks: false,
tasks: [
{ text: 'Z', completed: false, important: false, current: false, originalCategory: '工作' },
],
},
];
const sideB = [
container,
makeCat('工作', 'normal', true, [
{ text: 'Z', completed: false, important: false, current: false },
]),
{ name: '回收站', kind: 'trash', isSpecial: true, meta: null, parentOtherTasks: false, tasks: [] },
];
const d = diffCategories(sideA, sideB);
truthy(d.modified.length === 1 && d.modified[0].stateContainerMove === true,
'恢复产生 stateContainerMove modifieddisk 在 TRASHmemory 在 NORMAL');
// 默认 'both' 应把 Z 放到 NORMAL用户当前位置
const merged = applyResolutions(sideA, d, {});
const workCat = merged.find(c => c.name === '工作');
const trashCat = merged.find(c => c.name === '回收站');
const zInWork = workCat.tasks.filter(t => t.text === 'Z').length;
truthy(zInWork === 1, '工作分类里有且仅有 1 个 Z恢复后');
truthy(!trashCat.tasks.some(t => t.text === 'Z'), '回收站里没有 Z用户已恢复');
}
// ── 7.9) v4 修复stateContainerMove 'a' 必须用 taskA 不应用 mergedTask ──
// 旧实现下,'a' 分支会用 mergeTaskStates(item.taskA, item.taskB) 把 taskB.completed
// 回写成 true等于「选 'a' = 保留文件版本」却把用户的勾选偷偷带回 —— 违反语义。
console.log('\n[v4 修复stateContainerMove choice=\'a\' 必须用 taskA 不应用 mergedTask]');
{
const sideA = [
container,
makeCat('工作', 'normal', true, [
{ text: 'Q', completed: false, important: false, current: false }, // 磁盘 [ ]
]),
];
const sideB = [
container,
{ name: '已完成任务', kind: 'completed', isSpecial: true, meta: null, parentOtherTasks: false,
tasks: [
{ text: 'Q', completed: true, important: false, current: false, originalCategory: '工作' },
],
},
];
const d = diffCategories(sideA, sideB);
truthy(d.modified.length === 1 && d.modified[0].stateContainerMove === true,
'Q 在 disk NORMAL / memory COMPLETED → stateContainerMove modified');
// 用户选 'a' = 「保留磁盘 [ ]」 → 应该撤销勾选,回到工作分类且 completed=false
const merged = applyResolutions(sideA, d, { [d.modified[0].key]: 'a' });
const workCat = merged.find(c => c.name === '工作');
const completedCat = merged.find(c => c.name === '已完成任务');
const qInWork = workCat.tasks.filter(t => t.text.trim() === 'Q');
truthy(qInWork.length === 1, '工作分类里有 Q撤销勾选后');
truthy(qInWork[0].completed === false, 'Q 在工作分类里 completed=false撤销勾选');
truthy(!completedCat || !completedCat.tasks.some(t => t.text.trim() === 'Q'),
'COMPLETED 里没有 Q撤销勾选后');
}
// ── 7.10) v4 修复findOrCreateCat trim 两边 ──
// 旧实现只 trim c.name 不 trim catName。若 item.cat.name 带空白而 out 里的 cat 已 trim
// 匹配漏过 → 静默创建重复分类。
console.log('\n[v4 修复findOrCreateCat trim 两边防止重复 cat]');
{
const sideA = [
container,
makeCat('学习', 'normal', true, [
{ text: 'L1', completed: false, important: false, current: false },
]),
];
const sideB = [
container,
makeCat('学习', 'normal', true, [
{ text: 'L1', completed: false, important: false, current: false },
{ text: 'L2', completed: false, important: false, current: false },
]),
];
// 人为把 sideB 的 cat.name 加前后空白(模拟上游 cat.name 没 trim 透传过来)
sideB[1].name = ' 学习 ';
const d = diffCategories(sideA, sideB);
// 注入一个 onlyInB强制走 findOrCreateCat 分支
const merged = applyResolutions(sideA, d, {});
const learnCats = merged.filter(c => (c.name || '').trim() === '学习');
truthy(learnCats.length === 1, '只有一个「学习」分类(无重复 cat 创建)');
truthy(learnCats[0].name === '学习', '新建 cat 的 name 是 trim 后的版本(不留空白)');
}
// ── 7.11) v4 修复findIndex 用 trim 后比较(防止 text 前后空白产生重复副本)──
console.log('\n[v4 修复findIndex 用 trim 后比较,防止 text 空白导致重复副本]');
{
const sideA = [
container,
makeCat('工作', 'normal', true, [
{ text: 'T1', completed: false, important: false, current: false },
]),
];
// B 侧同名任务 text 带前导/尾部空格 —— categoriesToMap 用 text.trim() 入 key
// 所以会与 A 侧匹配成 modified旧 findIndex 用 === text 找不到push 重复副本。
const sideB = [
container,
makeCat('工作', 'normal', true, [
{ text: ' T1 ', completed: false, important: false, current: false },
]),
];
const d = diffCategories(sideA, sideB);
// 用 'a' 让 applyResolutions 走 findIndex 路径
const merged = applyResolutions(sideA, d, {});
const workCat = merged.find(c => c.name === '工作');
const t1Count = workCat.tasks.filter(t => t.text.trim() === 'T1').length;
truthy(t1Count === 1, '工作分类里有且仅有 1 个 T1trim 匹配,无重复副本)');
}
// ── 8) defaultResolutions ──
console.log('\n[defaultResolutions]');
{
const res = defaultResolutions(diff);
eq(res['2:工作::2:A1'], 'a', 'onlyInA 默认 = a');
eq(res['2:工作::2:B1'], 'b', 'onlyInB 默认 = b');
eq(res['2:工作::2:A2'], 'both', 'modified 默认 = bothOR 合并,不自动选边丢标记)');
}
// ── 9) mergeTaskStates ──
console.log('\n[mergeTaskStates]');
{
const a = { text: 'X', completed: true, important: false, current: false };
const b = { text: 'X', completed: false, important: true, current: false };
const merged = mergeTaskStates(a, b);
truthy(merged.completed === true, 'OR completed');
truthy(merged.important === true, 'OR important');
eq(merged.text, 'X', 'text 保留');
// v4 H3 修复current 仅一边 true → OR 保留(之前无条件置 false 会丢 [▶] 标记)
const ca = { text: 'Y', completed: false, important: false, current: true };
const cb = { text: 'Y', completed: false, important: false, current: false };
const mergedSingle = mergeTaskStates(ca, cb);
truthy(mergedSingle.current === true, 'current 单边 true → OR 保留H3 修复)');
// current 是单值指针 —— 两边都 true 时强制置 falseM-Diff2 历史兜底)
const cA = { text: 'Y2', completed: false, important: false, current: true };
const cB = { text: 'Y2', completed: false, important: false, current: true };
const mergedC = mergeTaskStates(cA, cB);
truthy(mergedC.current === false, 'current 两边都 true → 合并后强制 false');
// 字段白名单H9 — 不透传 id / _skipReload / createdAt
const withInternal = {
text: 'Z', completed: false, important: false, current: false,
id: 42, _skipReload: true, createdAt: '2024-01-01',
};
const mergedFields = mergeTaskStates(withInternal, withInternal);
truthy(mergedFields.id === undefined, '不泄漏 task.id');
truthy(mergedFields._skipReload === undefined, '不泄漏 _skipReload');
truthy(mergedFields.createdAt === undefined, '不泄漏 createdAt');
truthy('text' in mergedFields, 'text 字段保留');
truthy('important' in mergedFields, 'important 字段保留');
}
// ── 10) summarizeDiff ──
console.log('\n[summarizeDiff]');
{
const s = summarizeDiff(diff);
eq(s.total, 3, 'total = 3');
eq(s.addedByFile, 1, 'addedByFile = 1');
eq(s.addedByMine, 1, 'addedByMine = 1');
eq(s.conflicting, 1, 'conflicting = 1');
}
// ── 总结 ──
console.log('');
if (summary.fail === 0) {
console.log(`✓ 全部通过`);
process.exit(0);
} else {
console.log(`${summary.fail} 处失败`);
process.exit(1);
}

View File

@@ -0,0 +1,442 @@
// 一次性验证脚本:删除分类时任务必须完整迁移到「未分类」,且能通过 Markdown 往返。
//
// 关键:只检查内存状态是不够的 —— 任务可能挂在一个「脱离 categories 数组」或
// 「序列化后无法被 parser 认回」的分类上。所以每个用例都做
// 内存变更 → serialize() → parseMarkdown() → 重新数任务
// 这才能证明数据真的落盘且能读回来。
//
// 用法node scripts/check-delete-category.mjs
import { TaskStore, UNCATEGORIZED_NAME, OTHER_TASKS_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';
// 收集一份「任务文本 → 出现次数」的多重集,用于比较迁移前后是否等价
function taskMultiset(categories) {
const m = new Map();
for (const cat of categories) {
for (const t of cat.tasks || []) {
const key = `${t.text}|${t.completed ? 1 : 0}|${t.important ? 1 : 0}`;
m.set(key, (m.get(key) || 0) + 1);
}
}
return m;
}
function multisetEqual(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 describeMultiset(m) {
return [...m.entries()].map(([k, v]) => `${k}×${v}`).sort().join(', ') || '(空)';
}
// 构造一个 store绕过 window.api不触发真实落盘
// 核心断言:删除 name 后,任务多重集不变,且经过 Markdown 往返仍不变
function expectNoLoss(label, markdown, deleteName) {
console.log(`\n[${label}] 删除「${deleteName}`);
const store = makeStore(markdown);
const before = taskMultiset(store.categories);
const targetCat = store.getCategory(deleteName);
const movedCount = targetCat ? targetCat.tasks.length : 0;
const ok = store.deleteCategory(deleteName);
check('deleteCategory 返回 true', ok === true, `实际返回 ${ok}`);
check(`分类「${deleteName}」已移除`, !store.categories.some(c => c.name === deleteName));
// 1) 内存层面:任务多重集必须完全一致
const afterMem = taskMultiset(store.categories);
check(
'内存中任务无丢失',
multisetEqual(before, afterMem),
`迁移前 [${describeMultiset(before)}]\n 迁移后 [${describeMultiset(afterMem)}]`
);
// 2) 任务必须真的落在「未分类」里
const fallback = store.categories.find(c => c.name === UNCATEGORIZED_NAME);
if (movedCount > 0) {
check(`${UNCATEGORIZED_NAME}」存在于 categories 数组中`, !!fallback);
if (fallback) {
check(
`${UNCATEGORIZED_NAME}」承接了 ${movedCount} 项任务`,
fallback.tasks.length >= movedCount,
`实际只有 ${fallback.tasks.length}`
);
}
}
// 3) 往返测试:序列化 → 重新解析 → 任务仍必须完整
// 这一步能抓出「分类脱离数组」或「写成孤立 ## 被 parser 丢弃」的情况
const md = store.serialize();
const reparsed = parseMarkdown(md);
const afterRoundTrip = taskMultiset(reparsed);
check(
'Markdown 往返后任务无丢失',
multisetEqual(before, afterRoundTrip),
`往返前 [${describeMultiset(before)}]\n 往返后 [${describeMultiset(afterRoundTrip)}]\n --- 生成的 Markdown ---\n${md.split('\n').map(l => ' ' + l).join('\n')}`
);
return store;
}
console.log('='.repeat(64));
console.log('删除分类:任务迁移到「未分类」—— 数据完整性验证');
console.log('='.repeat(64));
// ---------- 用例 1「未分类」已存在删除中间的分类 ----------
expectNoLoss('用例1 未分类已存在', `# 当前任务
- [ ] 今日任务A
# ${OTHER_TASKS_NAME}
## 工作
- [ ] 工作任务1
- [✓] 工作任务2
- [ ] 工作任务3 ⭐
## 学习
- [ ] 学习任务1
## ${UNCATEGORIZED_NAME}
- [ ] 已有的零散任务
`, '工作');
// ---------- 用例 2「未分类」缺失删除容器后的第一个子分类 ----------
// 这是最危险的场景:新建「未分类」会插到 oIdx+1正好和被删分类的原索引重叠
expectNoLoss('用例2 未分类缺失+删首个子分类', `# 当前任务
- [ ] 今日任务A
# ${OTHER_TASKS_NAME}
## 工作
- [ ] 工作任务1
- [✓] 工作任务2
- [ ] 工作任务3 ⭐
## 学习
- [ ] 学习任务1
`, '工作');
// ---------- 用例 3「未分类」缺失删除最后一个子分类 ----------
expectNoLoss('用例3 未分类缺失+删末个子分类', `# 当前任务
# ${OTHER_TASKS_NAME}
## 工作
- [ ] 工作任务1
## 生活
- [ ] 生活任务1
- [✓] 生活任务2
`, '生活');
// ---------- 用例 4「未分类」缺失且这是唯一的子分类 ----------
expectNoLoss('用例4 未分类缺失+唯一子分类', `# 当前任务
# ${OTHER_TASKS_NAME}
## 工作
- [ ] 唯一分类里的任务1
- [✓] 唯一分类里的任务2 ⭐
`, '工作');
// ---------- 用例 5删除空分类 ----------
console.log('\n[用例5] 删除空分类');
{
const store = makeStore(`# 当前任务
# ${OTHER_TASKS_NAME}
## 工作
- [ ] 工作任务1
## 空分类
`);
const subsBefore = store.getSubCategories().map(c => c.name);
const ok = store.deleteCategory('空分类');
check('deleteCategory 返回 true', ok === true);
const subsAfter = store.getSubCategories().map(c => c.name);
check('空分类已移除', !subsAfter.includes('空分类'));
// 删空分类不该凭空造出一个「未分类」
const spawnedUncategorized =
!subsBefore.includes(UNCATEGORIZED_NAME) && subsAfter.includes(UNCATEGORIZED_NAME);
check(
`删除空分类不会凭空创建「${UNCATEGORIZED_NAME}`,
!spawnedUncategorized,
`删除后子分类:${subsAfter.join(' / ')}`
);
}
// ---------- 用例 6「未分类」自身不可删除 ----------
console.log('\n[用例6] 「未分类」不可删除');
{
const store = makeStore(`# 当前任务
# ${OTHER_TASKS_NAME}
## ${UNCATEGORIZED_NAME}
- [ ] 兜底里的任务
`);
const ok = store.deleteCategory(UNCATEGORIZED_NAME);
check('deleteCategory 返回 false', ok === false, `实际返回 ${ok}`);
check(
`${UNCATEGORIZED_NAME}」仍在`,
store.categories.some(c => c.name === UNCATEGORIZED_NAME)
);
check('兜底里的任务仍在', taskMultiset(store.categories).size === 1);
}
// ---------- 用例 7特殊分类不可删除 ----------
console.log('\n[用例7] 特殊分类不可删除');
{
const store = makeStore(`# 当前任务
- [ ] 今日任务
# ${OTHER_TASKS_NAME}
## 工作
- [ ] 工作任务
`);
const before = taskMultiset(store.categories);
check('删除「当前任务」被拒绝', store.deleteCategory('当前任务') === false);
check(`删除「${OTHER_TASKS_NAME}」被拒绝`, store.deleteCategory(OTHER_TASKS_NAME) === false);
check('删除不存在的分类被拒绝', store.deleteCategory('不存在的分类') === false);
check('任务完全未受影响', multisetEqual(before, taskMultiset(store.categories)));
}
// ---------- 用例 8连续删除多个分类 ----------
console.log('\n[用例8] 连续删除多个有任务的分类');
{
const store = makeStore(`# 当前任务
- [ ] 今日任务
# ${OTHER_TASKS_NAME}
## 工作
- [ ] 工作任务1
- [ ] 工作任务2
## 学习
- [✓] 学习任务1
## 生活
- [ ] 生活任务1 ⭐
`);
const before = taskMultiset(store.categories);
store.deleteCategory('工作');
store.deleteCategory('学习');
store.deleteCategory('生活');
check('三个分类全部移除', store.getSubCategories().every(c => c.name === UNCATEGORIZED_NAME));
check(
'内存中任务无丢失',
multisetEqual(before, taskMultiset(store.categories)),
`前 [${describeMultiset(before)}]\n 后 [${describeMultiset(taskMultiset(store.categories))}]`
);
const reparsed = parseMarkdown(store.serialize());
check(
'Markdown 往返后任务无丢失',
multisetEqual(before, taskMultiset(reparsed)),
`往返后 [${describeMultiset(taskMultiset(reparsed))}]\n --- Markdown ---\n${store.serialize().split('\n').map(l => ' ' + l).join('\n')}`
);
}
// ---------- 用例 9结构不变量 —— 子分类必须紧跟在容器之后 ----------
// writeMarkdown 依赖这个顺序parentOtherTasks 的分类写成 ##
// 如果它出现在 # 容器之前parser 会因 insideOtherTasks=false 而丢弃整段
console.log('\n[用例9] 结构不变量:子分类必须在容器之后');
{
const store = makeStore(`# 当前任务
# ${OTHER_TASKS_NAME}
## 工作
- [ ] 工作任务1
`);
store.deleteCategory('工作');
const containerIdx = store.categories.findIndex(c => c.kind === CategoryKind.OTHER_TASKS);
const subIdxs = store.categories
.map((c, i) => (c.parentOtherTasks ? i : -1))
.filter(i => i >= 0);
check('存在 OTHER_TASKS 容器', containerIdx >= 0);
check(
'所有子分类都排在容器之后',
subIdxs.every(i => i > containerIdx),
`容器在 ${containerIdx},子分类在 [${subIdxs.join(', ')}]`
);
}
// ---------- 用例 10不产生重名「未分类」 ----------
// 重名会让 getCategory() 只认第一个,后续 addTask/toggleTask 全打到错的对象上
console.log('\n[用例10] 不产生重名「未分类」');
{
const store = makeStore(`# 当前任务
# ${OTHER_TASKS_NAME}
## 工作
- [ ] 工作任务1
## ${UNCATEGORIZED_NAME}
- [ ] 兜底任务1
`);
// 人为破坏标记,模拟历史数据里 parentOtherTasks 缺失的「未分类」
const u = store.categories.find(c => c.name === UNCATEGORIZED_NAME);
u.parentOtherTasks = false;
const before = taskMultiset(store.categories);
store.deleteCategory('工作');
const dupes = store.categories.filter(c => c.name === UNCATEGORIZED_NAME);
check(
`${UNCATEGORIZED_NAME}」只有一个(无重名)`,
dupes.length === 1,
`实际有 ${dupes.length}`
);
check(`${UNCATEGORIZED_NAME}」的 parentOtherTasks 已补正`, dupes[0]?.parentOtherTasks === true);
check('内存中任务无丢失', multisetEqual(before, taskMultiset(store.categories)));
const reparsed = parseMarkdown(store.serialize());
check(
'Markdown 往返后任务无丢失',
multisetEqual(before, taskMultiset(reparsed)),
`往返后 [${describeMultiset(taskMultiset(reparsed))}]`
);
}
// ---------- 用例 11容器缺失时不能写出孤立的 ## ----------
// writeMarkdown 把 parentOtherTasks 写成 `## X`parseMarkdown 只在 `# 全部任务`
// 之后才认 `##`。孤立的 `## 未分类` 不会让任务消失,但会静默改写归属:
// parser 跳过该 h2 时不重置 currentCategory任务被并进上一个 h1通常是「当前任务」
// 所以这里要断言的是「归属正确」,不只是「总数正确」。
console.log('\n[用例11] 容器缺失时任务归属不被改写');
{
const store = makeStore(`# 当前任务
- [ ] 今日原有任务
# ${OTHER_TASKS_NAME}
## 工作
- [ ] 工作任务1
- [✓] 工作任务2 ⭐
`);
// 人为移除 OTHER_TASKS 容器,模拟结构受损的数据
const cIdx = store.categories.findIndex(c => c.kind === CategoryKind.OTHER_TASKS);
store.categories.splice(cIdx, 1);
const before = taskMultiset(store.categories);
store.deleteCategory('工作');
check(
'OTHER_TASKS 容器已被重建',
store.categories.some(c => c.kind === CategoryKind.OTHER_TASKS)
);
const containerIdx = store.categories.findIndex(c => c.kind === CategoryKind.OTHER_TASKS);
const subIdxs = store.categories.map((c, i) => (c.parentOtherTasks ? i : -1)).filter(i => i >= 0);
check(
'所有子分类都排在容器之后',
subIdxs.every(i => i > containerIdx),
`容器在 ${containerIdx},子分类在 [${subIdxs.join(', ')}]`
);
const md = store.serialize();
const reparsed = parseMarkdown(md);
check(
'Markdown 往返后任务总数不变',
multisetEqual(before, taskMultiset(reparsed)),
`往返前 [${describeMultiset(before)}]\n 往返后 [${describeMultiset(taskMultiset(reparsed))}]`
);
// v3 起没有 TODAY 真实分类 —— 「今日原有任务」在加载时被 _migrateTodayCategory
// 搬到了首个子分类,并打上 @当前 标记。所以这里要验证的是:
// 1. parseMarkdown 后不存在任何 kind=TODAY 的分类(迁移彻底)
// 2. 删除「工作」后,工作里未完成的 1 项任务 + 今日 1 项任务 → 「未分类」
// v3.4 后,[✓] 任务会在加载时被搬到「# 已完成任务」分类,所以「工作」
// 删完后只剩 2 项进「未分类」,原 [✓] 的「工作任务2」落在「# 已完成任务」)
// 3. 「未分类」里的「今日原有任务」带 @当前 标记,被「当前」智能视图聚合得到
const todayAfter = reparsed.find(c => c.kind === CategoryKind.TODAY);
const uncatAfter = reparsed.find(c => c.name === UNCATEGORIZED_NAME);
const completedAfter = reparsed.find(c => c.kind === CategoryKind.COMPLETED);
check(
'v3 已无 TODAY 真实分类(# 当前任务 在迁移时被搬空)',
!todayAfter,
`仍有 TODAY 分类(${todayAfter?.tasks.length ?? '?'} 项)`
);
check(
`搬走的 2 项任务(含 @当前 的)留在「${UNCATEGORIZED_NAME}`,
uncatAfter && uncatAfter.tasks.length === 2,
`实际 ${uncatAfter ? uncatAfter.tasks.length + ' 项' : '分类不存在'}\n --- Markdown ---\n${md.split('\n').map(l => ' ' + l).join('\n')}`
);
check(
'原 [✓] 的「工作任务2」落在「# 已完成任务」v3.4 自动搬迁)',
completedAfter?.tasks?.some(t => t.text === '工作任务2'),
`已完成任务分类任务:${completedAfter?.tasks?.map(t => t.text).join(' / ') || '无'}`
);
// 「当前」智能视图能聚合到那条带 @当前 标记的「今日原有任务」
const currentView = store._buildSmartListView('current');
check(
'「当前」智能视图能聚合到 @当前 标记的「今日原有任务」',
currentView.tasks.some(t => t.text === '今日原有任务'),
`当前视图任务:${currentView.tasks.map(t => t.text).join(' / ')}`
);
}
// ---------- 用例 12删除当前选中分类后视图切到任务的新家 ----------
console.log('\n[用例12] 删除选中分类后视图跟随任务');
{
const store = makeStore(`# ${OTHER_TASKS_NAME}
## 工作
- [ ] 工作任务1
## 空的
`);
store.selectCategory('工作');
store.deleteCategory('工作');
check(
`删有任务的分类 → 视图切到「${UNCATEGORIZED_NAME}`,
store.selectedCategoryName === UNCATEGORIZED_NAME,
`实际是「${store.selectedCategoryName}`
);
store.selectCategory('空的');
store.deleteCategory('空的');
// v3 起没有 TODAY 真实分类 —— 删空分类后落到「当前」智能视图
check(
'删空分类 → 视图退回「当前」智能视图',
store.selectedSmartList === 'current',
`实际停在 selectedCategoryName=${store.selectedCategoryName} / selectedSmartList=${store.selectedSmartList}`
);
check('选中的分类/视图真实存在', store.getSelectedCategory() !== null);
}
console.log('\n' + '='.repeat(64));
console.log(`结果:${summary.pass} 通过, ${summary.fail} 失败`);
console.log('='.repeat(64));
process.exit(summary.fail === 0 ? 0 : 1);

View File

@@ -0,0 +1,292 @@
import { check, ok, bad, summary, printSummary } from './_lib/check.mjs';
import { makeStore } from './_lib/store-fixture.mjs';
import { installApiStub } from './_lib/api-stub.mjs';
// 回归测试:脏标记竞态 ——「await writeFile 期间用户又改了」必须保留 dirty
//
// v4 起去掉 800ms debounce 改为全实时同步:
// 用户改 A → _markDirty → 立即 _scheduleAutoSave → _doAutoSave 启动 →
// 序列化(含 A→ await writeFile期间用户改 B / C / D → _markDirty 只
// 设 dirty=true + 推 _dirtyVersion + 设 _needsResave=true因为有 in-flight
// → writeFile 返回 → 版本检查发现快照不含最新 → 不清 dirty
// finally 检测 _needsResave → 立即再启动一次 _doAutoSave 把累积改动写出去。
//
// 旧机制800ms debounce的关键回归仍是 v4 的核心不变:
// in-flight 期间的用户改动**必须**最终落盘,绝不能因为 dirty 被误清而丢。
//
// 跑法node scripts/check-dirty-race.mjs
// 桩出 window.api把 writeFile 换成可手动控制的 Promise
// 这样能精确卡在「await writeFile 期间」注入下一次 _markDirty。
let writeFileResolver;
const calls = { writeFile: 0 };
installApiStub({
writeFile: (_path, _content) => {
calls.writeFile++;
return new Promise((resolve) => { writeFileResolver = resolve; });
}
});
const { TaskStore } = await import('../src/task-store.js');
const { CategoryKind } = await import('../src/markdown-parser.js');
// ============================================
// [1] _doAutoSave 路径await writeFile 期间有修改 → 保留 dirty
// ============================================
console.log('\n[1] _doAutoSave 路径下「await 期间修改」必须保留 dirty');
{
const store = new TaskStore();
store.filePath = '/tmp/dirty-race-test.md';
// 直接 addTask 触发 _markDirty 路径
store.categories = [
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
];
// 用内部 API 触发一次"修改",让 _markDirty 把 _dirtyVersion 推到 1
store.addTask('未分类', 'A');
ok('首次 addTask 后 dirty 为 true', store.dirty === true);
ok('首次 addTask 后 _dirtyVersion = 1', store._dirtyVersion === 1);
// 把 debounce 定时器立刻 flush 掉,让 _doAutoSave 走到 await writeFile 那一行
store._autoSave.flush();
// 此时 writeFile 已发起calls.writeFile 应为 1_doAutoSave 还在等 resolver
ok('flush 后 writeFile 被调用一次', calls.writeFile === 1);
// 「用户在 await 期间又改东西」:再次 addTask —— 只走 _markDirty不推 _writeGeneration
store.addTask('未分类', 'B');
ok('await 期间 addTask 后 dirty 仍为 true', store.dirty === true);
ok('await 期间 addTask 后 _dirtyVersion = 2', store._dirtyVersion === 2);
// 关键_writeGeneration 仍是 1没有新 save 启动)—— 这是 bug 触发条件
ok('_writeGeneration 仍为 1没有新 save 接力)', store._writeGeneration === 1);
// 释放 writeFile —— 让第一次 _doAutoSave 醒来
writeFileResolver({ ok: true });
// 给 microtask 几次 tick 让 await 后的代码跑完
await new Promise(r => setTimeout(r, 20));
// 关键断言dirty 必须仍为 true —— 否则 B 永远写不出去
if (store.dirty === true) {
ok('await 回来后 dirty 仍为 true修复有效', true);
} else {
bad('await 回来后 dirty 仍为 true修复有效', `dirty=${store.dirty}, _dirtyVersion=${store._dirtyVersion}`);
}
}
// ============================================
// [2] saveNow 路径await writeFile 期间有修改 → 保留 dirty
// ============================================
console.log('\n[2] saveNow 路径下「await 期间修改」必须保留 dirty');
{
// 重置桩
calls.writeFile = 0;
let resolver2;
globalThis.window.api.writeFile = () => {
calls.writeFile++;
return new Promise((resolve) => { resolver2 = resolve; });
};
const store = new TaskStore();
store.filePath = '/tmp/dirty-race-saveNow.md';
store.categories = [
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
];
// 启动 saveNow不 await —— 让它走到 await writeFile 后挂起)
const savePromise = store.saveNow();
ok('saveNow 启动后 writeFile 被调用', calls.writeFile === 1);
// await 期间模拟用户改动
store.addTask('未分类', 'X');
ok('await 期间 addTask 后 dirty 为 true', store.dirty === true);
ok('await 期间 _dirtyVersion 已 +1', store._dirtyVersion === 1);
// 释放 + 等 saveNow 收尾
resolver2({ ok: true });
await savePromise;
await new Promise(r => setTimeout(r, 20));
if (store.dirty === true) {
ok('saveNow await 回来后 dirty 仍为 true修复有效', true);
} else {
bad('saveNow await 回来后 dirty 仍为 true修复有效', `dirty=${store.dirty}`);
}
}
// ============================================
// [3] 正常路径await 期间无修改 → 仍正确清 dirty防止误伤
// ============================================
console.log('\n[3] 正常路径(无并发修改)必须正确清 dirty —— 别把修复写成永不清');
{
calls.writeFile = 0;
let resolver3;
globalThis.window.api.writeFile = () => {
calls.writeFile++;
return new Promise((resolve) => { resolver3 = resolve; });
};
const store = new TaskStore();
store.filePath = '/tmp/dirty-race-clean.md';
store.categories = [
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
];
let savedEmitted = false;
store.on('saved', () => { savedEmitted = true; });
store.addTask('未分类', 'Y');
store._autoSave.flush();
ok('flush 后 writeFile 被调用', calls.writeFile === 1);
// 不再做任何修改,直接释放
resolver3({ ok: true });
await new Promise(r => setTimeout(r, 20));
if (store.dirty === false) {
ok('正常路径下 dirty 被正确清掉', true);
} else {
bad('正常路径下 dirty 被正确清掉', `dirty=${store.dirty}`);
}
if (savedEmitted) {
ok('正常路径下 emit 了 saved 事件', true);
} else {
bad('正常路径下 emit 了 saved 事件');
}
}
// ============================================
// [4] cancelPendingSave 仍要让 in-flight 过期(回归测试现有行为)
// ============================================
console.log('\n[4] cancelPendingSave 让 in-flight 写过期dirty 保留');
{
calls.writeFile = 0;
let resolver4;
globalThis.window.api.writeFile = () => {
calls.writeFile++;
return new Promise((resolve) => { resolver4 = resolve; });
};
const store = new TaskStore();
store.filePath = '/tmp/dirty-race-cancel.md';
store.categories = [
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
];
store.addTask('未分类', 'Z');
store._autoSave.flush();
ok('flush 后 writeFile 被调用', calls.writeFile === 1);
// 模拟「文件被外部改了」 → 取消 pending
store.cancelPendingSave();
// _writeGeneration 在 cancelPendingSave 里被 +1所以已不再是 1
ok('cancel 后 _writeGeneration 已被推过in-flight 失效)', store._writeGeneration > 1);
// 释放 writeFile —— 已取消dirty 必须保留
resolver4({ ok: true });
await new Promise(r => setTimeout(r, 20));
if (store.dirty === true) {
ok('cancel 后 dirty 被保留', true);
} else {
bad('cancel 后 dirty 被保留', `dirty=${store.dirty}`);
}
}
// ============================================
// [5] v4 全实时同步:单次 addTask 立即触发 writeFile无 800ms 等待)
// ============================================
console.log('\n[5] 单次 addTask 立即触发 writeFile无 debounce 延迟)');
{
calls.writeFile = 0;
let resolver5;
globalThis.window.api.writeFile = (_path, _content) => {
calls.writeFile++;
return new Promise((resolve) => { resolver5 = resolve; });
};
const store = new TaskStore();
store.filePath = '/tmp/real-time-sync.md';
store.categories = [
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
];
store.addTask('未分类', '即时');
// 关键addTask 一返回 writeFile 就已经发起 —— 不再等 800ms
ok('addTask 后立即触发 writeFile无 debounce 延迟)', calls.writeFile === 1,
`calls.writeFile=${calls.writeFile}`);
// 收尾:释放 resolver 让 save 完成
resolver5({ ok: true });
await new Promise(r => setTimeout(r, 20));
ok('保存完成后 dirty 被清掉', store.dirty === false, `dirty=${store.dirty}`);
}
// ============================================
// [6] v4 尾随写in-flight 期间多次 addTaskfinally 自动补一次写覆盖最新状态
// ============================================
console.log('\n[6] in-flight 期间累积改动 → finally 自动补一次写(覆盖最新状态)');
{
calls.writeFile = 0;
// 用队列收集 resolver避免后续尾随写无法 release 导致悬挂
const resolvers = [];
globalThis.window.api.writeFile = (_path, content) => {
calls.writeFile++;
const capturedContent = content;
return new Promise((resolve) => {
resolvers.push({ resolve, capturedContent });
});
};
const store = new TaskStore();
store.filePath = '/tmp/trailing-save.md';
store.categories = [
{ name: '未分类', kind: CategoryKind.NORMAL, tasks: [], parentOtherTasks: true, isSpecial: false, meta: null }
];
// 第 1 次:直接 addTask 触发第一次 writeFile
store.addTask('未分类', 'A');
ok('addTask A 后 writeFile 被立即调用', calls.writeFile === 1);
// in-flight 期间:连续改 3 次 —— 应走 _needsResave 路径,**不**起新 writeFile
store.addTask('未分类', 'B');
store.addTask('未分类', 'C');
store.addTask('未分类', 'D');
ok('in-flight 期间 3 次 addTask 后 writeFile 仍 = 1被合并',
calls.writeFile === 1, `calls.writeFile=${calls.writeFile}`);
ok('in-flight 期间 _writeGeneration 仍 = 1没新 save 启动)',
store._writeGeneration === 1);
ok('in-flight 期间 _dirtyVersion = 4addTask 4 次)',
store._dirtyVersion === 4);
// 释放第一次写finally 应触发尾随写
resolvers[0].resolve({ ok: true });
await new Promise(r => setTimeout(r, 20));
ok('第一次释放后 writeFile 被尾随启动一次(共 2 次)',
calls.writeFile === 2, `calls.writeFile=${calls.writeFile}`);
ok('尾随写启动后 _writeGeneration = 2',
store._writeGeneration === 2);
ok('第一次释放后 dirty 仍 = true尾随写尚未完成',
store.dirty === true);
// 释放尾随写:这次应包含 ABCD 全部内容
const trailingContent = resolvers[1].capturedContent;
ok('尾随写的内容含 A/B/C/D 全部 4 条任务',
trailingContent.includes('A') &&
trailingContent.includes('B') &&
trailingContent.includes('C') &&
trailingContent.includes('D'),
`内容预览: ${trailingContent.slice(0, 200)}`);
resolvers[1].resolve({ ok: true });
await new Promise(r => setTimeout(r, 20));
ok('尾随写完成后 dirty 被清掉', store.dirty === false,
`dirty=${store.dirty}`);
ok('无更多 _needsResave → writeFile 共 2 次(不多次重复)',
calls.writeFile === 2, `calls.writeFile=${calls.writeFile}`);
}
console.log(`\n通过 ${summary.pass} / 失败 ${summary.fail}`);
process.exit(summary.fail === 0 ? 0 : 1);

View File

@@ -0,0 +1,217 @@
// 测试 pathsEqual —— 外部修改检测路径比较的健壮性
//
// 背景:原实现 `changedPath !== store.filePath` 在 Windows 上永远失败:
// - store.filePath 由 joinPath 拼出forward slash `C:/Users/<user>/TodoList/todo.md`
// - IPC 发来的 changedPath 走 path.resolvebackslash`C:\\Users\\<user>\\TodoList\\todo.md`
// 结果是 resolveExternalChangeConflict 永远提前 return外部修改的 toast / reload
// 全部被静默吞掉 —— 用户看到「改了文件,软件没反应」。
//
// 这个测试覆盖各类路径等价场景,确保比较逻辑不会再退化。
//
// 注:测试 fixture 里出现的 `D:/TodoList` / `D:\\TodoList\\todo.md` 仅作 Windows
// 路径示例(验证 slash 方向、大小写不敏感、UNC 等行为),不指代实际默认目录。
import { pathsEqual } from '../src/utils/path.js';
import { check, ok, bad, summary, printSummary } from './_lib/check.mjs';
import { makeStore } from './_lib/store-fixture.mjs';
// ============================================================
// [1] Windows核心 bug —— slash 方向不同
// ============================================================
console.log('\n[1] Windows: 核心 bug —— slash 方向不同');
{
// 模拟 store.filePath (joinPath 输出)
const fwd = 'D:/TodoList/todo.md';
// 模拟主进程 IPC 发来的 changedPath (path.resolve 输出)
const bwd = 'D:\\TodoList\\todo.md';
check('forward slash vs backslash → 等价', pathsEqual(fwd, bwd),
`fwd=${fwd} bwd=${bwd}`);
check('反方向也一样', pathsEqual(bwd, fwd));
}
// ============================================================
// [2] Windows完全相同
// ============================================================
console.log('\n[2] Windows: 完全相同');
{
const a = 'D:/TodoList/todo.md';
check('同 forward slash → 等价', pathsEqual(a, a));
const b = 'D:\\TodoList\\todo.md';
check('同 backslash → 等价', pathsEqual(b, b));
}
// ============================================================
// [3] Windows大小写差异NTFS 默认大小写不敏感)
// ============================================================
console.log('\n[3] Windows: 大小写差异');
{
check('D:/TodoList vs d:/todolist → 等价',
pathsEqual('D:/TodoList/todo.md', 'd:/todolist/todo.md'));
check('混合大小写 vs 全大写 → 等价',
pathsEqual('C:/Users/Admin/Todo.md', 'C:/USERS/admin/TODO.MD'));
}
// ============================================================
// [4] Windows不同路径 → 不等价
// ============================================================
console.log('\n[4] Windows: 不同路径');
{
check('不同文件名 → 不等价',
!pathsEqual('D:/TodoList/foo.md', 'D:/TodoList/bar.md'));
check('不同目录 → 不等价',
!pathsEqual('D:/TodoA/foo.md', 'D:/TodoB/foo.md'));
check('不同盘符 → 不等价',
!pathsEqual('C:/TodoList/foo.md', 'D:/TodoList/foo.md'));
}
// ============================================================
// [5] POSIX完全相同
// ============================================================
console.log('\n[5] POSIX: 完全相同');
{
check('同路径 → 等价',
pathsEqual('/home/user/todo.md', '/home/user/todo.md'));
check('同路径反方向调用 → 等价',
pathsEqual('/home/user/todo.md', '/home/user/todo.md'));
}
// ============================================================
// [6] POSIX大小写不同 → 不等价POSIX 默认大小写敏感)
// ============================================================
console.log('\n[6] POSIX: 大小写敏感(保守策略)');
{
// 设计取舍:让 macOS / Linux 上的 TODO.md 和 todo.md 在用户角度确实可能是同一文件
// HFS+ / APFS 默认不敏感),但保守起见这里只把「带盘符」的 Windows 路径放宽,
// POSIX 路径严格区分大小写。误报比漏报代价小。
check('POSIX 路径大小写不同 → 不等价(保守)',
!pathsEqual('/home/user/Todo.md', '/home/user/todo.md'));
}
// ============================================================
// [7] null / undefined 处理
// ============================================================
console.log('\n[7] null / undefined');
{
// 实现选择null / undefined 走严格比较(`a === b`),不互通。
// 实际场景两边都是字符串joinPath / path.resolve 的产物),这条只是
// 「实现别在 null 上抛错」级别的健壮性保险,不需要语义层面的兼容性。
check('两个 null → 等价', pathsEqual(null, null));
check('两个 undefined → 等价', pathsEqual(undefined, undefined));
check('null vs undefined → 不等价(严格模式)', !pathsEqual(null, undefined));
check('null vs 字符串 → 不等价', !pathsEqual(null, 'D:/TodoList/todo.md'));
check('字符串 vs null → 不等价', !pathsEqual('D:/TodoList/todo.md', null));
check('undefined vs 字符串 → 不等价',
!pathsEqual(undefined, 'D:/TodoList/todo.md'));
}
// ============================================================
// [8] 空字符串
// ============================================================
console.log('\n[8] 空字符串');
{
check('两个空串 → 等价', pathsEqual('', ''));
check('空串 vs 字符串 → 不等价', !pathsEqual('', 'D:/TodoList/todo.md'));
check('字符串 vs 空串 → 不等价', !pathsEqual('D:/TodoList/todo.md', ''));
}
// ============================================================
// [9] UNC 路径Windows 网络共享):斜杠归一化后等价
// ============================================================
console.log('\n[9] UNC 路径');
{
// \\server\share\file.md 归一化后是 //server/share/file.md
// 两个写法backslash / forward归一化后一致 → 应等价
check('UNC 双反斜杠 vs 双正斜杠 → 等价',
pathsEqual('\\\\server\\share\\file.md', '\\\\server/share/file.md'));
}
// ============================================================
// [10] 核心回归:直接模拟 resolveExternalChangeConflict 的入参
// ============================================================
console.log('\n[10] 模拟 resolveExternalChangeConflict 的入参');
{
// 真实数据流app.js bootstrap 里 joinPath 拼出 filePath正斜杠
// 然后通过 readFile IPC 走到主进程,主进程 path.resolve 后回填到 currentFilePath反斜杠
// 之后 fs.watch / polling 检测到变化,发 'file:external-change' IPC 把 currentFilePath
// (反斜杠形态)送回渲染端。
const joinPathOut = 'D:/TodoList/todo.md';
const ipcPayload = 'D:\\TodoList\\todo.md';
// 旧实现的判断 `changedPath !== store.filePath`
// 两个字符串字符不同 → 不等 → 触发提前 return → bug 触发
const oldCheckSaysNotEqual = joinPathOut !== ipcPayload;
check('旧实现 !== 在 Windows 上判定为不等bug 触发)',
oldCheckSaysNotEqual === true,
`旧实现会让 resolveExternalChangeConflict 提前 return`);
// 新实现pathsEqual 把两条路径判定为相等
check('新实现 pathsEqual 判定为相等(不触发提前 return',
pathsEqual(joinPathOut, ipcPayload) === true);
// 验证 bug 已修复的核心对比
check('核心 bug 已修复slash 不同但路径相同能被识别',
pathsEqual('D:/TodoList/todo.md', 'D:\\TodoList\\todo.md'));
}
// ============================================================
// [11] macOS大小写不敏感APFS / HFS+ 默认)
// ============================================================
//
// 真实场景:用户在 macOS 上手敲 ~/TodoList/todo.md外部编辑器保存时
// 拼出 /users/admin/todolist/todo.md —— APFS / HFS+ 默认大小写不敏感,
// 两条路径指向同一文件。pathsEqual 必须能识别等价,否则外部修改会被吞。
console.log('\n[11] macOS: 大小写不敏感(通过 navigator.platform 模拟)');
{
// 在单测里塞 navigator.platform 模拟 macOS 环境
// Node 18+ 的 globalThis.navigator 是只读 getter需用 defineProperty。
const savedNav = globalThis.navigator;
Object.defineProperty(globalThis, 'navigator', {
value: { platform: 'MacIntel', userAgent: 'Mozilla/5.0 (Macintosh)' },
configurable: true,
writable: true
});
try {
check('macOS 下 /Users/Foo/Todo.md vs /users/foo/todo.md → 等价',
pathsEqual('/Users/Foo/Todo.md', '/users/foo/todo.md'));
check('macOS 下混合大小写也等价',
pathsEqual('/Users/Admin/Todo.md', '/USERS/admin/TODO.MD'));
check('macOS 下完全不同路径 → 不等价',
!pathsEqual('/Users/Foo/Todo.md', '/Users/Bar/Todo.md'));
} finally {
Object.defineProperty(globalThis, 'navigator', {
value: savedNav,
configurable: true,
writable: true
});
}
}
// ============================================================
// [12] macOS 探测:非 macOS 平台不会触发大小写不敏感
// ============================================================
console.log('\n[12] 非 macOS 平台POSIX 仍严格大小写敏感');
{
const savedNav = globalThis.navigator;
Object.defineProperty(globalThis, 'navigator', {
value: { platform: 'Linux x86_64', userAgent: 'Mozilla/5.0 (X11; Linux)' },
configurable: true,
writable: true
});
try {
check('Linux 下大小写不同 → 不等价',
!pathsEqual('/home/user/Todo.md', '/home/user/todo.md'));
check('Linux 下完全相同 → 等价',
pathsEqual('/home/user/todo.md', '/home/user/todo.md'));
} finally {
Object.defineProperty(globalThis, 'navigator', {
value: savedNav,
configurable: true,
writable: true
});
}
}
console.log(`\n通过 ${summary.pass} / 失败 ${summary.fail}`);
process.exit(summary.fail === 0 ? 0 : 1);

View File

@@ -0,0 +1,89 @@
// 回归render() 重建 innerHTML 时只对「会话内首次出现」的任务打 .is-new
// 而不是「相对上一次 render 的可见集合」。后者会让 filter 切换 / 跨分类跳转
// 后旧任务再次 fade-in反而比旧版无条件 animation闪烁更糟。
//
// 测项:
// 1) 第一次 render所有任务都是 is-new
// 2) 第二次 render同样的任务集合没有任何 is-new
// 3) filter 切换后回到原视图:旧任务不应再 fade-in关键回归点
// 4) 切换分类再切回来:跨分类可见任务不重复 fade-in
// 5) 新增任务:仅新增的那条打 is-new
// 6) 'load' 事件后:所有任务重新算作 is-newid 全部失效)
import { check, ok, bad, summary, printSummary } from './_lib/check.mjs';
import { readFileSync } from 'node:fs';
import { findTodoFile } from './_lib/find-todo-path.mjs';
const SRC_TASK_LIST = readFileSync('src/ui/task-list.js', 'utf8');
// 抓取 _renderTaskItem 的核心 HTML 模板 —— 我们关心的是「输出 HTML 里第一个 class 是否 task-item」
// 以及「render() 是否会根据 _knownTaskIds 标记 is-new」。
// 这里不实际 import TaskList要拉一堆 DOM mock 太重),改为从源码层面验证关键不变量:
// (a) _knownTaskIds 不再被重置为 seenIdsgrow-only
// (b) 'load' 事件清空 _knownTaskIds
// (c) 字符串 splice 锚点是 'class="task-item' 且 indexOf != -1 时才替换
let pass = 0, fail = 0;
function expect(name, cond, info) {
if (cond) { ok(name); pass++; } else { bad(name, info); fail++; }
}
// (a) 关键回归点:旧实现里是 `this._knownTaskIds = seenIds;`。
// 现在的实现必须没有这行被赋值给 seenIds —— 否则 filter 切换会让旧任务
// 重新被当成 new。
const oldBuggyLine = /this\._knownTaskIds\s*=\s*seenIds/;
expect(
'_knownTaskIds 不再重置为 seenIds防 filter 切换回归)',
!oldBuggyLine.test(SRC_TASK_LIST),
'源码里仍有 this._knownTaskIds = seenIds — 这是旧 bug'
);
// (b) 'load' 事件清空 _knownTaskIdsreload 后 id 全部失效,全 fade-in 是合理的)
const hasLoadReset = /this\.store\.on\(['"]load['"][\s\S]{0,300}?_knownTaskIds\s*=\s*new Set\(\)/.test(SRC_TASK_LIST);
expect(
"'load' 事件清空 _knownTaskIdsid 全部失效,全 fade-in 是合理的)",
hasLoadReset,
"'load' 处理器里没看到 _knownTaskIds = new Set()"
);
// (c) splice 锚点仍是 'class="task-item'
expect(
'splice 锚点是 class="task-item',
/indexOf\(['"]class="task-item['"]\)/.test(SRC_TASK_LIST),
'没找到 indexOf("class="task-item") 锚点'
);
// (d) 渲染时 grow-only 加新 id用 .add 而不是 = 赋值)
expect(
'_knownTaskIds 通过 .add() grow-only 累加(不会因为 filter 切换丢 id',
/this\._knownTaskIds\.add\(/.test(SRC_TASK_LIST),
'没找到 _knownTaskIds.add(...) 调用'
);
// (e) 关键:之前的实现里 newIds 计算依赖 _knownTaskIdsgrow-only set
// 而不是 reset 后的 seenIds。验证可见的判断逻辑仍在以 _knownTaskIds.has(t.id) 为准。
const usesKnownSetForNewCheck = /this\._knownTaskIds\.has\(/.test(SRC_TASK_LIST);
expect(
'新任务判定走 _knownTaskIds.hasgrow-only 集合)',
usesKnownSetForNewCheck,
'没找到 _knownTaskIds.has(...) 调用'
);
// (f) isFirstRender 的语义应仍为「集合为空」grow-only 实现下同样成立)
expect(
'isFirstRender 判断仍存在_knownTaskIds.size === 0',
/isFirstRender/.test(SRC_TASK_LIST),
'isFirstRender 标识符消失了'
);
// (g) 'load' 监听器必须清空选择模式(已有)
expect(
"'load' 仍清空选择模式",
/this\.store\.on\(['"]load['"][\s\S]{0,200}?_exitSelectionMode/.test(SRC_TASK_LIST),
"'load' 没清空选择模式"
);
printSummary(pass, fail);
// sanity: TODO file path resolution doesn't matter here
findTodoFile();

272
scripts/check-filter.mjs Normal file
View File

@@ -0,0 +1,272 @@
// 回归测试过滤筛选filter / sort / smart-list bypass
//
// 涵盖:
// 1) v3.4 不变量completed=true 任务只能活在 kind=COMPLETED 分类 ——
// loadFromContent 完成迁移后,普通 NORMAL 分类下不应再有 completed=true。
// 2) getVisibleTasks 在各 Filter 下的行为:
// - ALL/CURRENT/COMPLETED/IMPORTANT 四种过滤在普通分类 + 智能列表下
// 的可见性 + 数量 + 文本都符合预期。
// CURRENT 的语义是 `t.current`[▶] 驱动),不是「未完成」 ——
// 从 PENDING!completed迁移过来与「当前任务」智能列表对称。
// 3) bypassFilter 边界:「已完成任务 / 重要任务 / 当前任务」智能列表下,
// 任何 Filter 都不再二次过滤(避免「在「已完成任务」里选 current
// 永远空」这种自相矛盾);「全部任务」智能列表下保留过滤能力。
// 4) 搜索叠加:在过滤后的结果上再搜索,结果是子集。
// 5) UI 文案_renderEmptyState 在 COMPLETED 过滤下的 hint 不能再说
// "勾选任务后会出现在这里"v3.4 后是错的 —— 勾选后任务会搬到
// kind=COMPLETED 分类),必须提示用户去「已完成任务」分类看。
//
// 跑法node scripts/check-filter.mjs
import { readFileSync } from 'node:fs';
import { check, ok, bad, summary, printSummary } from './_lib/check.mjs';
import { makeStore } from './_lib/store-fixture.mjs';
import { installApiStub } from './_lib/api-stub.mjs';
installApiStub();
const { TaskStore, Filter } = await import('../src/task-store.js');
const { CategoryKind } = await import('../src/markdown-parser.js');
// ============================================
// [1] v3.4 不变量completed=true 任务只能在 kind=COMPLETED 分类
// ============================================
console.log('\n[1] v3.4 不变量completed 任务只能活在 kind=COMPLETED 分类');
{
const store = makeStore([
'# 全部任务',
'',
'## 工作',
'',
'- [ ] 任务A',
'- [x] 任务B',
'- [ ] [⭐] 任务C',
'- [x] [⭐] 任务D',
'',
'## 学习',
'',
'- [x] [⭐] 学习B',
].join('\n'));
for (const cat of store.categories) {
if (cat.kind === CategoryKind.OTHER_TASKS) continue;
if (cat.kind === CategoryKind.COMPLETED) continue;
// 普通分类 / 回收站:不允许出现 completed=true 任务
const violators = cat.tasks.filter(t => t.completed);
check(`${cat.name} (kind=${cat.kind}) 不含 completed=true 任务`,
violators.length === 0,
violators.map(t => t.text).join(','));
}
// 「已完成任务」分类:必须承接所有 completed 任务
const completedCat = store.getCompletedCategory();
check('kind=COMPLETED 分类存在', !!completedCat);
check('已完成任务分类有 3 条任务B/D/学习B',
completedCat.tasks.length === 3,
`实际 ${completedCat.tasks.length}`);
}
// ============================================
// [2] getVisibleTasks普通分类下四种过滤
// ============================================
console.log('\n[2] 普通分类下 getVisibleTasks 行为正确');
{
const store = makeStore([
'# 全部任务',
'',
'## 工作',
'',
'- [ ] A',
'- [x] B',
'- [ ] [▶] C',
'- [ ] [⭐] D',
'- [ ] [▶] [⭐] E',
].join('\n'));
const workCat = store.getCategory('工作');
store.selectCategory('工作');
const cases = [
{ filter: Filter.ALL, expect: ['A', 'C', 'D', 'E'], label: 'ALL' },
{ filter: Filter.CURRENT, expect: ['C', 'E'], label: 'CURRENT[▶] 驱动)' },
{ filter: Filter.COMPLETED, expect: [], label: 'COMPLETED (v3.4: 普通分类下永远空)' },
{ filter: Filter.IMPORTANT, expect: ['D', 'E'], label: 'IMPORTANT' },
];
for (const { filter, expect, label } of cases) {
store.setFilter(filter);
const got = store.getVisibleTasks(store.getSelectedCategory()).map(t => t.text);
check(`${label} 过滤可见文本 = ${JSON.stringify(expect)}`,
JSON.stringify(got) === JSON.stringify(expect),
`实际 ${JSON.stringify(got)}`);
}
}
// ============================================
// [3] bypassFilter智能列表下不二次过滤
// ============================================
console.log('\n[3] bypassFilter 边界:「已完成/重要/当前」智能列表下过滤被跳过');
{
const store = makeStore([
'# 全部任务',
'',
'## 工作',
'',
'- [ ] A',
'- [ ] [▶] B',
'- [ ] [⭐] C',
'- [x] [⭐] D',
].join('\n'));
// 「已完成任务」智能列表下选 CURRENT —— 应仍然看到全部D不应被过滤成空
store.selectSmartList('completed');
store.setFilter(Filter.CURRENT);
const completedView = store.getSelectedCategory();
const vis = store.getVisibleTasks(completedView).map(t => t.text).sort();
check('completed 智能列表 + CURRENT 过滤 → 仍返回全部',
JSON.stringify(vis) === JSON.stringify(['D']),
`实际 ${JSON.stringify(vis)}`);
// 「重要任务」智能列表下选 COMPLETED —— v3.4 后"完成 = 归档"D 不在重要聚合里
store.selectSmartList('important');
store.setFilter(Filter.COMPLETED);
const importantView = store.getSelectedCategory();
const impVis = store.getVisibleTasks(importantView).map(t => t.text).sort();
check('important 智能列表 + COMPLETED 过滤 → 重要聚合只剩 CD 已归档)',
JSON.stringify(impVis) === JSON.stringify(['C']),
`实际 ${JSON.stringify(impVis)}`);
// 「全部任务」智能列表下保留过滤能力(不 bypass
store.selectSmartList('allTasks');
store.setFilter(Filter.CURRENT);
const allTasksView = store.getSelectedCategory();
const allVis = store.getVisibleTasks(allTasksView).map(t => t.text);
check('allTasks 智能列表 + CURRENT 过滤 → 过滤生效(仅 [▶] 任务)',
JSON.stringify(allVis) === JSON.stringify(['B']),
`实际 ${JSON.stringify(allVis)}`);
// 「当前任务」智能列表v3.4 后 completed+current 不出现在 current 聚合
// 这里只放未完成的当前任务,避免被 v3.4 归档逻辑影响
const store2 = makeStore([
'# 全部任务',
'',
'## 工作',
'',
'- [ ] [▶] A',
'- [ ] [▶] B',
'- [ ] C',
].join('\n'));
store2.selectSmartList('current');
store2.setFilter(Filter.CURRENT);
const curVis = store2.getVisibleTasks(store2.getSelectedCategory()).map(t => t.text).sort();
check('current 智能列表 + CURRENT 过滤 → 仍返回全部当前任务(不二次过滤)',
JSON.stringify(curVis) === JSON.stringify(['A', 'B']),
`实际 ${JSON.stringify(curVis)}`);
}
// ============================================
// [4] 搜索叠加在过滤结果上
// ============================================
console.log('\n[4] 搜索在过滤结果上叠加:结果应是子集');
{
const store = makeStore([
'# 全部任务',
'',
'## 工作',
'',
'- [ ] 买菜',
'- [ ] 做饭',
'- [ ] [⭐] 买咖啡',
'- [ ] [⭐] 写代码',
].join('\n'));
store.selectCategory('工作');
store.setFilter(Filter.IMPORTANT);
store.setSearchQuery('买');
const vis = store.getVisibleTasks(store.getSelectedCategory()).map(t => t.text).sort();
check('IMPORTANT 过滤 + 搜索"买" → 只剩 [买咖啡]',
JSON.stringify(vis) === JSON.stringify(['买咖啡']),
`实际 ${JSON.stringify(vis)}`);
store.setFilter(Filter.ALL);
store.setSearchQuery('买');
const visAll = store.getVisibleTasks(store.getSelectedCategory()).map(t => t.text).sort();
check('ALL 过滤 + 搜索"买" → 买菜 + 买咖啡',
JSON.stringify(visAll) === JSON.stringify(['买咖啡', '买菜']),
`实际 ${JSON.stringify(visAll)}`);
}
// ============================================
// [5] UI 文案 + 过滤菜单隐藏「已完成」选项(普通分类下)
// ============================================
console.log('\n[5] _renderEmptyState 文案 + 普通分类下隐藏「已完成」过滤');
{
const src = readFileSync(new URL('../src/ui/task-list.js', import.meta.url), 'utf8');
// 5a) _renderEmptyState 在 COMPLETED 过滤下的 hint 不再说"勾选任务后会出现在这里"
const fnMatch = src.match(/_renderEmptyState\([^)]*\)\s*\{([\s\S]*?)\n \}/);
if (!fnMatch) {
bad('找不到 _renderEmptyState 函数(源码结构变了?)');
} else {
const body = fnMatch[1];
const completedBranch = body.match(
/this\.store\.filter\s*===\s*Filter\.COMPLETED[\s\S]*?else if/m
);
if (!completedBranch) {
bad('找不到 COMPLETED 过滤分支');
} else {
const branch = completedBranch[0];
check('COMPLETED 过滤分支包含「已完成任务」字样',
/已完成任务/.test(branch),
branch.replace(/\s+/g, ' ').slice(0, 200));
check('COMPLETED 过滤分支不再含错文案「勾选任务后会出现在这里」',
!/勾选任务后会出现在这里/.test(branch),
branch.replace(/\s+/g, ' ').slice(0, 200));
const currentBranch = body.match(
/this\.store\.filter\s*===\s*Filter\.CURRENT[\s\S]*?else if/m
);
if (currentBranch) {
check('CURRENT 过滤分支提示用 ▶ / [▶] 标记',
/▶/.test(currentBranch[0]) || /\[▶\]/.test(currentBranch[0]),
currentBranch[0].replace(/\s+/g, ' ').slice(0, 200));
} else {
bad('找不到 CURRENT 过滤分支(应提示用户给任务打 [▶]');
}
}
}
// 5b) _showFilterMenu 在普通分类下隐藏「已完成」选项v3.4 后永远空,不应诱导)
const showFnMatch = src.match(/_showFilterMenu\(\)\s*\{([\s\S]*?)\n \}/);
if (!showFnMatch) {
bad('找不到 _showFilterMenu 函数(源码结构变了?)');
} else {
const fnBody = showFnMatch[1];
check('_showFilterMenu 引入 hideCompleted 判断(普通分类下不显示「已完成」)',
/hideCompleted/.test(fnBody) && /Filter\.COMPLETED/.test(fnBody),
'源码应同时含 hideCompleted 和 Filter.COMPLETED 检查');
check('_showFilterMenu 同时判断 !isSmartList 与 kind !== TRASH',
/!\s*[a-zA-Z]+\.isSmartList/.test(fnBody) &&
/CategoryKind\.TRASH/.test(fnBody),
'应排除智能列表之外的"普通分类/容器"且保留回收站/智能列表显示 COMPLETED');
}
// 5c) render 入口:进入普通分类 + filter=COMPLETED → 自动 fallback 到 ALL
const renderFnMatch = src.match(/render\(\)\s*\{([\s\S]*?)\n \}/);
if (!renderFnMatch) {
bad('找不到 render 函数(源码结构变了?)');
} else {
const renderBody = renderFnMatch[1];
check('render 入口有「普通分类 + filter=COMPLETED → ALL」兜底',
/filter\s*===\s*Filter\.COMPLETED/.test(renderBody) &&
/setFilter\(Filter\.ALL\)/.test(renderBody),
'render 顶部应检测到无效的 filter 组合并 fallback');
}
}
// ============================================
console.log(`\n${'─'.repeat(48)}`);
console.log(`通过 ${summary.pass} / 失败 ${summary.fail}`);
process.exit(summary.fail === 0 ? 0 : 1);

View File

@@ -0,0 +1,90 @@
// 回归UI 文件里所有 Enter 与 Escape 按键处理都必须有 IME 守卫。
//
// 历史 bugcommand-palette.js 的 onKey 在 key === 'Enter' 时直接执行命令,
// 没检查 `e.isComposing || e.keyCode === 229` —— CJK 用户用输入法上屏
// 候选词时按 Enter会被误判为「确认命令」意外触发高亮项。
//
// 同样问题适用于 EscapeCJK/JP/KR 用户在 IME 组合中按 Esc 是「取消 IME 组合」,
// 不是「关闭弹窗 / 取消编辑」。无守卫会让 Esc 同时关掉 IME 和上层 UI。
//
// 修复:所有 UI 文件的 Enter / Esc 分支都要叠加 `!isImeComposing(e)` 或
// `if (isImeComposing(e)) return;`,由 src/utils/keymap.js 提供统一 helper。
//
// 静态分析覆盖feedback.js / conflict-dialog.js / diff-preview-dialog.js /
// import-dialog.js / settings-dialog.js / task-list.js / command-palette.js。
//
// 用法node scripts/check-ime-guard.mjs
import { readFileSync } from 'node:fs';
import { ok, bad, printSummary } from './_lib/check.mjs';
const FILES = [
'src/ui/feedback.js',
'src/ui/conflict-dialog.js',
'src/ui/diff-preview-dialog.js',
'src/ui/import-dialog.js',
'src/ui/settings-dialog.js',
'src/ui/task-list.js',
'src/ui/command-palette.js',
'src/ui/sidebar.js',
'src/ui/toolbar.js',
];
// 用 indexOf 代替 regex —— 一些 Node / shell 组合下 regex 字面量会被静默改写。
function hasImeImport(src) {
const idx = src.indexOf('isImeComposing');
if (idx < 0) return false;
const before = src.lastIndexOf('import', idx);
if (before < 0) return false;
const after = src.indexOf('keymap.js', idx);
if (after < 0 || after - idx > 200) return false;
return true;
}
// 通用:扫某种 key 的所有出现位置,检查其后窗口内是否有 IME 守卫。
function findBareKeys(src, key) {
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`e\\.key\\s*===\\s*['"]${escaped}['"]`, 'g');
const matches = [...src.matchAll(re)];
const bare = [];
for (const em of matches) {
const pos = em.index;
const window = src.slice(Math.max(0, pos - 50), pos + 400);
const guarded = /!\s*isImeComposing\s*\(\s*e\s*\)/.test(window)
|| /isImeComposing\s*\(\s*e\s*\)\s*\)?\s*return/.test(window)
// `if (e.key === 'X' && !isImeComposing(e))` —— 守卫可能在条件之后
|| new RegExp(`e\\.key\\s*===\\s*['"]${escaped}['"][^)]*&&\\s*!\\s*isImeComposing`).test(window);
if (!guarded) {
const line = src.slice(0, pos).split('\n').length;
bare.push({ line, snippet: src.slice(pos, pos + 80).replace(/\n/g, ' ') });
}
}
return bare;
}
for (const file of FILES) {
const src = readFileSync(file, 'utf8');
if (hasImeImport(src)) {
ok(`${file}: 引入 isImeComposing helper`);
} else {
bad(`${file}: 引入 isImeComposing helper`, '未 import isImeComposing —— Enter/Esc 守卫可能漏写');
}
for (const key of ['Enter', 'Escape']) {
const bare = findBareKeys(src, key);
if (bare.length === 0) {
ok(`${file}: 所有 ${key} 分支均已守卫(共 0 个未守卫)`);
} else {
for (const b of bare) {
const reason = key === 'Enter'
? 'CJK 用户上屏候选词会被误判为确认'
: 'CJK 用户按 Esc 取消 IME 组合会被误判为关闭弹窗/取消编辑';
bad(`${file}:${b.line}${key} 分支无 IME 守卫`,
`附近代码:${b.snippet}…… ${reason}`);
}
}
}
}
printSummary('check-ime-guard');

View File

@@ -0,0 +1,872 @@
// 一次性验证脚本内容导入功能task-store.parseImportLine + importTasksToCategory
//
// 覆盖:
// 1. parseImportLine 纯函数的所有解析分支checkbox / 列表符号剥离 / 普通行 / 空行)
// 2. importTasksToCategory 与序列化往返Markdown 是真相之源 —— 必须能 parse 回来)
// 3. 目标分类的守卫(容器 / 回收站 / 不存在的分类)
// 4. 0 条有效行时不污染 dirty 状态
// 5. 中文标点「、」/「。」不会被当成列表符号误剥离
//
// 用法node scripts/check-import-tasks.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 } from '../src/markdown-parser.js';
import { OTHER_TASKS_NAME, TRASH_NAME } from '../src/markdown-parser.js';
// ============================================================
// [1] parseImportLine —— checkbox 行
// ============================================================
console.log('\n[1] parseImportLine 识别 Markdown checkbox');
{
// 完成态:[✓]/[x]/[X] 一律视为已完成;与 markdown-parser 对齐
for (const marker of ['✓', 'x', 'X']) {
const r = parseImportLine(`- [${marker}] 已完成的任务`, { stripLeadingBullets: true });
check(`- [${marker}] 行 → completed=true, text 已剥掉 checkbox`,
r && r.completed === true && r.text === '已完成的任务',
JSON.stringify(r));
}
// 未完成:[ ] 是合法 checkbox字符集收紧到 ` ✓xX`,见 TASK_LINE_RE 注释)。
// 旧版 [!high] 这种"非标准 checkbox"字符现在被 fallback 接管 —— 不再被当作
// checkbox 剥掉而是当列表项文本原样保留completed=false
// - 旧 bug 是「任何 [X] 都当 checkbox」会让 `[⭐]` 这种 inline 标记被吃掉
// - fix 是「只 [ ✓xX] 是 checkbox」+「其余 [X] 当列表项文本」
const r0 = parseImportLine('- [ ] 未完成的任务', { stripLeadingBullets: true });
check('- [ ] 行 → completed=false, text 已剥掉 checkbox',
r0 && r0.completed === false && r0.text === '未完成的任务',
JSON.stringify(r0));
const r0b = parseImportLine('- [!high] 未完成的任务', { stripLeadingBullets: true });
check('- [!high] 行 → completed=false!high 当作列表项文本保留(不再当 checkbox 剥)',
r0b && r0b.completed === false && r0b.text === '[!high] 未完成的任务',
JSON.stringify(r0b));
// checkbox 行的前导列表符号 `*`/`+` 也兼容
const r1 = parseImportLine('* [ ] 用 * 开头', { stripLeadingBullets: true });
check('* [ ] 行 → 识别为 checkbox不当列表符号剥除',
r1 && r1.completed === false && r1.text === '用 * 开头',
JSON.stringify(r1));
const r2 = parseImportLine('+ [✓] 用 + 开头', { stripLeadingBullets: true });
check('+ [✓] 行 → 识别为 checkbox',
r2 && r2.completed === true && r2.text === '用 + 开头',
JSON.stringify(r2));
// checkbox 文本部分允许为空吗?早期实现返回 nullempty text 视为无效),
// 但这与 parseMarkdown 不一致 —— 文件里合法手写的 `- [x]`(无文本)会被
// loadFromContent 保留下来;导入却悄悄丢,会让用户数据「莫名消失」。
// 现在统一允许 —— text 为空字符串。明确测一下避免未来回归。
const r3 = parseImportLine('- [ ] ', { stripLeadingBullets: true });
check('checkbox 行无文本 → 接受(与 parseMarkdown 对齐)',
r3 && r3.completed === false && r3.text === '',
JSON.stringify(r3));
const r4 = parseImportLine('- [x]', { stripLeadingBullets: true });
check('纯勾选 `- [x]` → text="" 但 completed=true与 parseMarkdown 对齐)',
r4 && r4.completed === true && r4.text === '',
JSON.stringify(r4));
}
// ============================================================
// [2] parseImportLine —— 普通列表符号剥离
// ============================================================
console.log('\n[2] parseImportLine 剥离普通列表符号');
{
for (const [input, expected] of [
['- foo', 'foo'],
['* foo', 'foo'],
['+ foo', 'foo'],
['• foo', 'foo'],
['1. foo', 'foo'],
['2) foo', 'foo'],
['10. 多位数也行', '多位数也行'],
// 多个空白字符也应被吞掉(`- foo` → `foo`
['- 多空格', '多空格']
]) {
const r = parseImportLine(input, { stripLeadingBullets: true });
check(`剥离「${input}」→「${expected}`,
r && r.completed === false && r.text === expected,
JSON.stringify(r));
}
// 中文标点不是列表符号
for (const input of ['、任务', '。任务', ';任务', ',任务']) {
const r = parseImportLine(input, { stripLeadingBullets: true });
check(`中文标点「${input.charAt(0)}」不被剥离,原样保留`,
r && r.text === input,
JSON.stringify(r));
}
// 关闭选项时:前导列表符号保留
const r = parseImportLine('- foo', { stripLeadingBullets: false });
check('stripLeadingBullets=false 时保留前导「- 」',
r && r.text === '- foo',
JSON.stringify(r));
// 没有列表符号的普通行
const r2 = parseImportLine('普通任务', { stripLeadingBullets: true });
check('无符号普通行原样',
r2 && r2.text === '普通任务' && r2.completed === false,
JSON.stringify(r2));
}
// ============================================================
// [3] parseImportLine —— 空行 / 仅空白 / 默认行为
// ============================================================
console.log('\n[3] parseImportLine 空行处理');
{
check('空字符串 → null', parseImportLine('', { stripLeadingBullets: true }) === null);
check('仅空白 → null', parseImportLine(' \t ', { stripLeadingBullets: true }) === null);
check('null/undefined 输入 → null', parseImportLine(null, {}) === null);
check('默认选项 = stripLeadingBullets=true不显式传',
parseImportLine('- foo').text === 'foo',
JSON.stringify(parseImportLine('- foo')));
}
// ============================================================
// [4] importTasksToCategory —— 基本导入 + Markdown 往返
// ============================================================
console.log('\n[4] importTasksToCategory 批量导入 + 序列化往返');
{
// 注入一个让 *Position 全部走 'back' 的 settingsStore —— 本节断言「按出现顺序」= push 顺序。
// 默认 'front' 模式(最新在最上)由 check-insert-position.mjs 覆盖。
const backSettings = {
_values: { newTaskPosition: 'back', completedPosition: 'back', trashPosition: 'back' },
get(k) { return this._values[k]; }
};
const store = makeStore(null, { settingsStore: backSettings });
// 默认文档自带「工作 / 学习 / 生活」三个子分类(见 markdown-writer.createDefaultDoc
// 直接复用它们addSubCategory 对重名返回 null测试逻辑不该依赖它是否新建。
const workCat = store.getCategory('工作');
store.getCategory('学习'); // 确保复用,不创建
const input = [
'- [ ] 任务 A',
'- [✓] 任务 B已完成',
'- 任务 C',
'* 任务 D',
'1. 任务 E',
'• 任务 F',
'',
' ',
'普通行'
];
const result = store.importTasksToCategory('工作', input);
check('导入返回 ok=true', result.ok === true);
check('added = 7跳过 2 行空内容)', result.added === 7,
`实际 added=${result.added}, skipped=${result.skipped}`);
check('skipped = 2', result.skipped === 2,
`实际 skipped=${result.skipped}`);
// v3.4 数据归属不变量completed=true 的任务会被立即搬到「# 已完成任务」分类,
// 所以「工作」里只剩 6 条 pending 任务;用 find() 按 text 查,避免依赖数组下标。
check('未完成任务按出现顺序排列',
workCat.tasks.map(t => t.text).join('|') ===
'任务 A|任务 C|任务 D|任务 E|任务 F|普通行',
workCat.tasks.map(t => t.text).join('|'));
check('未完成任务中第一条 completed=false',
workCat.tasks[0].completed === false);
check('未完成任务剩余 completed=false',
workCat.tasks.slice(1).every(t => t.completed === false));
check('id 已生成', workCat.tasks.every(t => typeof t.id === 'string' && t.id.length > 0));
check('重要标记未被自动加上',
workCat.tasks.every(t => t.important === false && t.current === false));
check('dirty 已被标脏', store.dirty === true);
// 「# 已完成任务」分类应收到 1 条 - [✓] 任务 B
const completedCat = store.getCompletedCategory();
check('「# 已完成任务」收到 [✓] 任务 Bv3.4',
completedCat?.tasks?.length === 1 &&
completedCat.tasks[0].text === '任务 B已完成' &&
completedCat.tasks[0].completed === true &&
completedCat.tasks[0].originalCategory === '工作');
// 往返:导入后的内容 serialize() 出来再 parseMarkdown() 回来7 条任务都还在
const md = store.serialize();
check('序列化结果包含「## 工作」', md.includes('## 工作'),
md.split('\n').slice(0, 6).join('\n'));
// v3.4[✓] 任务不在「## 工作」段里 —— 它在「# 已完成任务」段
check('序列化包含 `[✓] 任务 B`(在「# 已完成任务」段)',
/# 已完成任务[\s\S]*\[✓\]\s*\[原分类:工作\]\s*任务 B已完成/.test(md),
md);
const reparsed = parseMarkdown(md);
const reparsedWork = reparsed.find(c => c.name === '工作');
const reparsedCompleted = reparsed.find(c => c.kind === CategoryKind.COMPLETED);
check('往返后「工作」子分类仍存在', !!reparsedWork);
check('往返后「工作」里 6 条未完成任务',
reparsedWork?.tasks.length === 6,
`实际 ${reparsedWork?.tasks?.length}`);
check('往返后「# 已完成任务」分类有 1 条',
reparsedCompleted?.tasks?.length === 1,
`实际 ${reparsedCompleted?.tasks?.length}`);
check('往返后「任务 B已完成」仍标 completed',
reparsedCompleted?.tasks?.find(t => t.text === '任务 B已完成')?.completed === true);
}
// ============================================================
// [5] importTasksToCategory —— 目标分类守卫
// ============================================================
console.log('\n[5] importTasksToCategory 目标分类守卫');
{
const store = makeStore();
// 容器OTHER_TASKS拒绝
const r1 = store.importTasksToCategory(OTHER_TASKS_NAME, ['任务']);
check('目标=容器 → ok=false, reason=INVALID_CATEGORY',
r1.ok === false && r1.reason === 'INVALID_CATEGORY',
JSON.stringify(r1));
// 回收站TRASH拒绝
// TRASH 还没创建,但我们直接传名字 —— getCategory 应找不到
const r2 = store.importTasksToCategory(TRASH_NAME, ['任务']);
check('目标=回收站 → ok=false, reason=INVALID_CATEGORY',
r2.ok === false && r2.reason === 'INVALID_CATEGORY',
JSON.stringify(r2));
// 不存在的分类名
const r3 = store.importTasksToCategory('不存在的分类', ['任务']);
check('目标=不存在的分类 → ok=false, reason=INVALID_CATEGORY',
r3.ok === false && r3.reason === 'INVALID_CATEGORY',
JSON.stringify(r3));
// 已确认容器/回收站分类确实没被偷偷建出来
check('容器分类没被创建', store.getCategory(OTHER_TASKS_NAME)?.kind === CategoryKind.OTHER_TASKS,
'容器是 base structure 自带的,但 kind 必须是 OTHER_TASKS');
check('回收站分类仍未被创建', store.getTrashCategory() === null);
// dirty 状态未被上述失败调用污染
check('守卫失败时未标 dirty', store.dirty === false);
}
// ============================================================
// [6] importTasksToCategory —— 全空输入 / 0 条有效行
// ============================================================
console.log('\n[6] importTasksToCategory 空输入');
{
const store = makeStore();
const cat = store.getCategory('工作');
const r1 = store.importTasksToCategory('工作', []);
check('空数组 → ok=true, added=0, skipped=0',
r1.ok === true && r1.added === 0 && r1.skipped === 0,
JSON.stringify(r1));
const r2 = store.importTasksToCategory('工作', ['', ' ', '\t', '']);
check('全空白行 → ok=true, added=0, skipped=4',
r2.ok === true && r2.added === 0 && r2.skipped === 4,
JSON.stringify(r2));
// 监听 change 事件0 条有效行时不应该触发
let changeCount = 0;
const handler = () => { changeCount++; };
store.on('change', handler);
store.importTasksToCategory('工作', ['', ' ']);
store.off('change', handler);
check('0 条有效行不触发 change 事件', changeCount === 0,
`change 触发了 ${changeCount}`);
check('0 条有效行不标 dirty', store.dirty === false);
// 真导入 1 条 → 应当触发
store.on('change', () => { changeCount++; });
store.importTasksToCategory('工作', ['有效任务']);
store.off('change', handler);
check('1 条有效行触发 change', changeCount >= 1,
`change 触发了 ${changeCount}`);
check('dirty 已被标脏', store.dirty === true);
// 分类里任务数 = 1前面那些 0 条导入都没留痕)
check('分类里只有 1 条任务(前面的 0 条导入未留痕)',
cat.tasks.length === 1 && cat.tasks[0].text === '有效任务',
`实际 ${cat.tasks.length}`);
}
// ============================================================
// [7] parseImportLine —— 边界场景
// ============================================================
console.log('\n[7] parseImportLine 边界场景');
{
// 「- 」/「1. 」 trim 后只剩符号 —— parseImportLine 尊重用户输入原样保留。
// 理由trim 在解析入口已经把两端的空白吃掉了,解析器只能看到单字符 `-` / `1.`
// 它们不再匹配 `\s+(.*)`(要求列表符号后至少一个空白),于是落到「普通行」分支返回原样。
// 用户在 UI 看到的就是他们输入的字符(含 trailing 空格已被 trim
const r6 = parseImportLine('- ', { stripLeadingBullets: true });
check('「- 」trim 后只剩符号 → 当成普通行返回',
r6 && r6.text === '-' && r6.completed === false,
JSON.stringify(r6));
const r7 = parseImportLine('1. ', { stripLeadingBullets: true });
check('「1. 」trim 后只剩符号 → 当成普通行返回',
r7 && r7.text === '1.' && r7.completed === false,
JSON.stringify(r7));
// 没有数字时的点号 → 不被剥离避免「1.5 foo」里的「1.」误剥)
const r = parseImportLine('1.5 是版本号', { stripLeadingBullets: true });
check('「1.5」不被当列表符号(避免误剥小数点)',
r && r.text === '1.5 是版本号',
JSON.stringify(r));
// 中文行内包含英文点
const r2 = parseImportLine('版本 2.0 发布了', { stripLeadingBullets: true });
check('「2.0」版本号行内嵌入 → 不被剥离',
r2 && r2.text === '版本 2.0 发布了',
JSON.stringify(r2));
// 数字+空格+点(异常输入,如 `1 . foo`):因为正则要求 `数字[.)]`,中间有空格就不匹配
const r3 = parseImportLine('1 . foo', { stripLeadingBullets: true });
check('「1 . 」异常格式不剥离',
r3 && r3.text === '1 . foo',
JSON.stringify(r3));
// 带 checkbox 但又混着前后空白
const r4 = parseImportLine(' - [ ] 前后都有空白 ', { stripLeadingBullets: true });
check('带前后空白的 checkbox 行 → 正确解析',
r4 && r4.completed === false && r4.text === '前后都有空白',
JSON.stringify(r4));
// tab 分隔也算空白
const r5 = parseImportLine('\t- foo\t', { stripLeadingBullets: true });
check('带 tab 的列表行 → 正确解析',
r5 && r5.text === 'foo',
JSON.stringify(r5));
}
// ============================================================
// [8] importTasksToCategory —— 批量 emit性能关键
//
// 早期实现里 _appendTask 每次都 emit('change'),批量导入 N 条任务 = N 次 UI 重渲染。
// 修复后走批量路径无论导入多少条change 只触发 1 次。
// ============================================================
console.log('\n[8] 批量导入只触发一次 change 事件(避免 N 次 UI 重渲染)');
{
// 用全局 back-mode settingsnewTaskPosition 等)让 addTask 走末尾追加;
// 导入顺序使用默认 'order'(按入参顺序 + 插到最前面)。对「按出现顺序」的断言
// 同样成立order 模式下 unshift 整批line1 在最前、lineN 在最后 —— 与入参
// 顺序完全一致。注意:历史上这里用 importPosition='back',现已删除该模式,
// 改用默认 'order',行为对「按入参顺序」的断言不变。参见 [12] 验证两种模式。
const backSettings = {
_values: {
newTaskPosition: 'back',
completedPosition: 'back',
trashPosition: 'back'
},
get(k) { return this._values[k]; }
};
const store = makeStore(null, { settingsStore: backSettings });
const cat = store.getCategory('工作');
// 50 条任务:早期实现会触发 50 次 change批量路径必须只有 1 次
const N = 50;
const lines = [];
for (let i = 0; i < N; i++) lines.push(`任务 ${i + 1}`);
let changeCount = 0;
const onChange = () => { changeCount++; };
store.on('change', onChange);
const result = store.importTasksToCategory('工作', lines);
store.off('change', onChange);
check(`50 条任务全部导入added=${N}`, result.added === N);
check('change 事件只触发 1 次(不是 50 次)', changeCount === 1,
`实际 ${changeCount}`);
// dirty 也应只触发一次_markDirty 内部会 push autoSavedebounce 仍合并)
check('dirty 已被标脏(合并后 = true', store.dirty === true);
// 验证任务确实全部落地
check(`「工作」分类里有 ${N} 条任务`, cat.tasks.length === N);
check('任务按出现顺序排列order 模式 = 入参顺序)',
cat.tasks.map(t => t.text).join('|') === lines.join('|'),
cat.tasks.map(t => t.text).join('|'));
}
// ============================================================
// [9] importTasksToCategory —— 与 addTask 走两条路径不冲突
// ============================================================
console.log('\n[9] 批量路径与 addTask 路径各自 emit 正确次数');
{
const store = makeStore();
const cat = store.getCategory('工作');
// 1) addTask 单条1 次 emit
let changeCount = 0;
const onChange1 = () => { changeCount++; };
store.on('change', onChange1);
store.addTask('工作', '单条');
store.off('change', onChange1);
check('addTask 单条 → change 触发 1 次', changeCount === 1,
`实际 ${changeCount}`);
// 2) 批量导入1 次 emit
changeCount = 0;
const onChange2 = () => { changeCount++; };
store.on('change', onChange2);
store.importTasksToCategory('工作', ['批 1', '批 2', '批 3']);
store.off('change', onChange2);
check('importTasksToCategory 批量 3 条 → change 触发 1 次', changeCount === 1,
`实际 ${changeCount}`);
// 3) 0 条有效行的批量0 次 emit不是 1 次 —— 0 条不应该假装操作发生了)
changeCount = 0;
const onChange3 = () => { changeCount++; };
store.on('change', onChange3);
store.importTasksToCategory('工作', ['', ' ', '']);
store.off('change', onChange3);
check('0 条有效行 → change 触发 0 次', changeCount === 0,
`实际 ${changeCount}`);
}
// ============================================================
// [10] _createTask —— 单条 ID 生成器
//
// 抽出后给 _appendTask 和 importTasksToCategory 共用。
// 自检确保批量路径里的任务也带合法 ID。
// ============================================================
console.log('\n[10] _createTask ID 生成');
{
const store = makeStore();
const task = store._createTask('测试', false);
check('id 是字符串且非空', typeof task.id === 'string' && task.id.length > 0,
`实际 ${JSON.stringify(task)}`);
check('字段默认值正确',
task.text === '测试' && task.completed === false &&
task.important === false && task.current === false);
// completed=true 也要正常赋值
const task2 = store._createTask('已完成', true);
check('completed=true 被保留', task2.completed === true);
}
// ============================================================
// [11] 数据保真度inline 标记 [⭐] / [▶] 必须翻译成 important / current
//
// 早期 parseImportLine 只返回 { text, completed } —— 文本里的 [⭐] / [▶]
// 会被原样写进任务 text但 important / current 字段仍是 false。
// 后果UI 上看起来文字里「写着 [⭐] 重要」,但实际不算重要;下次保存后
// parseMarkdown 又把 [⭐] 抽走 —— 用户数据在「导入 → 保存 → 重载」之间
// 被悄悄改写。
//
// 修复parseImportLine 与 parseMarkdown 共用 extractInlineMarkers
// 保证两套入口行为完全一致。
// ============================================================
console.log('\n[11] 数据保真度:[⭐]/[▶] 标记翻译成 important/current');
{
// 11.1 复现原 bugcheckbox + inline important
// 直接用 store.importTasksToCategory 验证端到端
const store = makeStore();
const r = store.importTasksToCategory('工作', [
'- [ ] [⭐] 重要任务',
'- [ ] [▶] 当前任务',
'- [✓] [⭐] 已完成且重要',
'- [ ] [▶] [⭐] 两个都在行首(合法)',
'- [ ] 对比 [⭐] 和 [ ] 行内不应被剥', // 行内 [⭐] —— 故意不应被识别
'- [ ] 任务 [⭐]', // 末尾 [⭐](前有空格)也应被识别
'- [ ] 任务 [▶]',
'[⭐] 重要(无 checkbox',
'⭐ 裸星(前后空格)',
'▶ 裸三角'
], { stripLeadingBullets: true });
check('10 条全部接受(无 skipped', r.added === 10 && r.skipped === 0,
`added=${r.added}, skipped=${r.skipped}`);
// v3.4 数据归属不变量:- [✓] 已完成且重要 会立即被搬到「# 已完成任务」分类,
// 所以「工作」里只会有 9 条 pending 任务,已完成分类里有 1 条。
// 用 find() 按 text 查,避免依赖数组顺序(顺序会随 v3.4 重排)。
const tasks = store.getCategory('工作').tasks;
const completed = store.getCompletedCategory()?.tasks || [];
// 注意:[⭐] / [▶] 必须出现在行首(紧贴开头)或行尾(前有空格)才会被识别。
// 行内位置「对比 [⭐] 和 [ ]」中间的 [⭐] 故意不被识别 —— 这是设计:
// parser 用位置锚避免误中「用户在文本里写了 [⭐] 字面字符」的情况。
// 测试要反映这个事实,而不是测「应该识别中间位置」—— 那种实现会低门槛地损坏数据。
check('[⭐] 开头text="重要任务", important=true, current=false',
tasks.find(t => t.text === '重要任务')?.important === true &&
tasks.find(t => t.text === '重要任务')?.current === false);
check('[▶] 开头text="当前任务", important=false, current=true',
tasks.find(t => t.text === '当前任务')?.important === false &&
tasks.find(t => t.text === '当前任务')?.current === true);
// v3.4[✓] 任务搬到「# 已完成任务」,并在「工作」里看不到
check('[✓]+[⭐] 在已完成分类里 completed=true 且 important=true',
completed.find(t => t.text === '已完成且重要')?.completed === true &&
completed.find(t => t.text === '已完成且重要')?.important === true,
JSON.stringify(completed));
check('[✓]+[⭐] 不在「工作」分类里v3.4 不变量)',
!tasks.some(t => t.text === '已完成且重要'));
check('[✓]+[⭐] 带 originalCategory=工作(便于取消勾选归位)',
completed.find(t => t.text === '已完成且重要')?.originalCategory === '工作');
check('两个标记都紧贴行首 [▶] [⭐]important=true 且 current=true',
tasks.find(t => t.text === '两个都在行首(合法)')?.important === true &&
tasks.find(t => t.text === '两个都在行首(合法)')?.current === true);
// 关键:「对比 [⭐] 和 [ ]」里 [⭐] 出现在中间位置 —— parser 故意不识别
// 这是设计意图(避免误剥用户文本里的字面 [⭐] 字符),不是 bug。
check('行内 [⭐] 不被识别(保护用户文本里的字面字符)',
tasks.find(t => t.text === '对比 [⭐] 和 [ ] 行内不应被剥')?.important === false &&
tasks.find(t => t.text === '对比 [⭐] 和 [ ] 行内不应被剥')?.text === '对比 [⭐] 和 [ ] 行内不应被剥');
check('末尾 [⭐](前有空格):剥掉且 important=true',
tasks.find(t => t.text === '任务' && t.important === true) !== undefined);
check('末尾 [▶]:剥掉且 current=true',
tasks.find(t => t.text === '任务' && t.current === true) !== undefined);
check('[⭐] 开头(无 checkboximportant=true',
tasks.find(t => t.text === '重要(无 checkbox')?.important === true);
// 裸 ⭐ / ▶ 因为前后有空格extractInlineMarkers 会识别 —— 这是设计:
// 写文件时强制用 [⭐] / [▶] 这种方括号标记,裸字符仅用于向后兼容旧文件
check('裸 ⭐(前后空格)被识别为 important',
tasks.find(t => t.text === '裸星(前后空格)')?.important === true);
check('裸 ▶(前后空格)被识别为 current',
tasks.find(t => t.text === '裸三角')?.current === true);
check('「工作」分类只有 9 条未完成任务v3.4 后)',
tasks.length === 9, `实际 ${tasks.length}`);
check('「# 已完成任务」分类有 1 条已完成任务',
completed.length === 1, `实际 ${completed.length}`);
// 11.2 往返:序列化后再 parseMarkdown 解析,标记不应被「重复剥」或漏剥
const md = store.serialize();
const reloaded = parseMarkdown(md);
const reloadedWork = reloaded.find(c => c.name === '工作');
const reloadedCompleted = reloaded.find(c => c.kind === CategoryKind.COMPLETED);
check('重载后「工作」任务数 = 9[✓] 已在已完成分类)',
reloadedWork?.tasks?.length === 9,
`实际 ${reloadedWork?.tasks?.length}`);
check('重载后「# 已完成任务」任务数 = 1',
reloadedCompleted?.tasks?.length === 1,
`实际 ${reloadedCompleted?.tasks?.length}`);
check('重载后 [⭐] 重要任务仍标 important',
reloadedWork?.tasks?.find(t => t.text === '重要任务')?.important === true);
check('重载后 [▶] 当前任务仍标 current',
reloadedWork?.tasks?.find(t => t.text === '当前任务')?.current === true);
check('重载后 两个标记都在行首的任务仍标 important+current',
reloadedWork?.tasks?.find(t => t.text === '两个都在行首(合法)' &&
t.important === true && t.current === true) !== undefined);
// 「对比 [⭐] 和 [ ]」是行内位置,重载后 text 应保持原样([⭐] 不能被剥)
check('重载后行内 [⭐] 不会被破坏',
reloadedWork?.tasks?.find(t => t.text === '对比 [⭐] 和 [ ] 行内不应被剥') !== undefined);
}
// ============================================================
// [12] 数据保真度:纯 checkbox 行(无文本)不再被悄悄丢弃
//
// 早期实现:`- [x]` → null → skipped++
// 后果:用户手写的纯勾选行导入时莫名消失。
//
// 修复:与 parseMarkdown 行为对齐 —— 文本允许为空(空字符串 "")。
// 实际意义不大UI 上一条空文本任务看起来奇怪但保证「load → import」对称。
// ============================================================
console.log('\n[12] 数据保真度:纯 checkbox 行不被丢弃');
{
const store = makeStore();
// 混合输入:纯勾选行 + 正常任务
const r = store.importTasksToCategory('工作', [
'- [ ]',
'- [x]',
'- [✓]',
'- [ ] 正常任务'
], { stripLeadingBullets: true });
check('4 行全部接受(无 skipped', r.added === 4 && r.skipped === 0,
`added=${r.added}, skipped=${r.skipped}`);
// v3.4 数据归属不变量completed=true 的任务被搬到「# 已完成任务」分类,
// 「工作」里只剩 2 条 pending 任务(- [ ] 空任务 + - [ ] 正常任务)。
const tasks = store.getCategory('工作').tasks;
const completed = store.getCompletedCategory()?.tasks || [];
check('- [ ] 留在「工作」分类里 completed=false',
tasks.find(t => t.text === '' && t.completed === false) !== undefined);
check('正常任务在「工作」分类里',
tasks.find(t => t.text === '正常任务' && t.completed === false) !== undefined);
check('「工作」分类只有 2 条未完成任务v3.4 后)',
tasks.length === 2, `实际 ${tasks.length}`);
check('- [x] 搬到「# 已完成任务」 completed=true',
completed.find(t => t.completed === true) !== undefined);
check('- [✓] 搬到「# 已完成任务」 completed=true',
completed.filter(t => t.completed === true).length === 2,
`completed 实际 ${completed.length}`);
// 往返serialize → parseMarkdown4 条任务都还在
const md = store.serialize();
const reloaded = parseMarkdown(md);
const reloadedWork = reloaded.find(c => c.name === '工作');
const reloadedCompleted = reloaded.find(c => c.kind === CategoryKind.COMPLETED);
check('重载后「工作」有 2 条任务',
reloadedWork?.tasks?.length === 2,
`实际 ${reloadedWork?.tasks?.length}`);
check('重载后「# 已完成任务」有 2 条任务',
reloadedCompleted?.tasks?.length === 2,
`实际 ${reloadedCompleted?.tasks?.length}`);
check('重载后已完成任务仍 completed=true',
reloadedCompleted?.tasks?.every(t => t.completed === true));
}
// ============================================================
// [13] 数据保真度:导入对既有任务零干扰
//
// 回归测试 —— 之前发现批量路径会用 _appendTask 一次只追加一条,
// 可能污染既有任务的顺序 / ID / 状态。批量路径应只 push不修改其它字段。
//
// 注:本测试早期版本用 importPosition='back' 让既有任务「保持原位」、新导入
// 任务追加到末尾,从而验证既有任务的 id / 字段快照未被修改。importPosition
// 收成两个选项后不再有 'back',所以本测试改用「默认 order」语义导入任务按
// 入参顺序插到最前面,既有任务被自然推到列表下方。重点验证的「既有任务 id 与
// 字段不被改」这条不变量与排序模式无关 —— 无论用 order / front导入路径都
// 不能触碰既有任务对象的字段。
// ============================================================
console.log('\n[13] 数据保真度:批量导入对既有任务零干扰');
{
// 默认 settingsStore不注入importPosition 走 'order' 默认值。
// 同时显式把 *Position 系列全部设为 'front',避免测试受其它全局设置影响。
const settings = {
_values: {
newTaskPosition: 'front',
completedPosition: 'front',
uncompletePosition: 'front',
trashPosition: 'front',
restorePosition: 'front'
// importPosition 不设 → store 内部兜底为 'order'
},
get(k) { return this._values[k]; }
};
const store = makeStore(null, { settingsStore: settings });
const cat = store.getCategory('工作');
// 既有任务:混合状态。
// 注意 addTask 走 newTaskPosition='front'unshift所以调用顺序「existing 1 → existing 2」
// 之后cat.tasks 里顺序是 [existing 2, existing 1]existing 2 插到最前)。
// 用「在 add 完之后按 add 顺序定位」的方式拿引用,避免依赖 cat.tasks 下标。
store.addTask('工作', 'existing 1');
const ex1 = cat.tasks.find(t => t.text === 'existing 1');
store.addTask('工作', 'existing 2');
const ex2 = cat.tasks.find(t => t.text === 'existing 2');
// 标记 ex2 为重要 + 完成 + 当前 —— 这是要保真的「既有任务 2」
ex2.important = true;
ex2.completed = true;
ex2.current = true;
const ex1Id = ex1.id;
const ex2Id = ex2.id;
const ex2Snapshot = JSON.stringify({
text: ex2.text, completed: ex2.completed, important: ex2.important, current: ex2.current
});
// 批量导入
store.importTasksToCategory('工作', [
'- [ ] new A',
'- [✓] [⭐] new B',
'new C'
]);
// 既有任务的 ID 必须不变order 模式下新导入任务插到最前,既有任务被推到下面)
// 先记录被推到列表下方后,既有任务的当前下标。
const ex1Idx = cat.tasks.findIndex(t => t.id === ex1Id);
const ex2Idx = cat.tasks.findIndex(t => t.id === ex2Id);
check('既有任务 1 的 id 仍在分类里', ex1Idx >= 0);
check('既有任务 2 的 id 仍在分类里', ex2Idx >= 0);
// 既有任务的状态字段快照必须完全一致(防止批量路径意外修改)
const ex2After = cat.tasks.find(t => t.id === ex2Id);
const ex2AfterSnapshot = JSON.stringify({
text: ex2After.text, completed: ex2After.completed,
important: ex2After.important, current: ex2After.current
});
check('既有任务 2 的所有字段未变', ex2AfterSnapshot === ex2Snapshot,
`before=${ex2Snapshot}, after=${ex2AfterSnapshot}`);
// 既有任务的 id 在导入前后必须完全一致 —— 这是「批量路径不修改既有任务对象」
// 的核心不变量,与 importPosition 是 order 还是 front 无关。
check('既有任务 1 的 id 未变', cat.tasks[ex1Idx].id === ex1Id);
check('既有任务 2 的 id 未变', cat.tasks[ex2Idx].id === ex2Id);
// 既有任务 2 的 text 也未变
check('既有任务 2 的 text 未变', cat.tasks[ex2Idx].text === 'existing 2');
// v3.4[✓] 任务被搬到「# 已完成任务」,所以「工作」里剩 4 条existing 1/2 + new A + new C
// 用 find() 按 text 查,不依赖下标(顺序由 importPosition 决定,这里不固定)。
check('「工作」分类追加新任务,共 4 条', cat.tasks.length === 4,
`实际 ${cat.tasks.length}`);
check('new A 按入参顺序order排在最前',
cat.tasks[0].text === 'new A',
JSON.stringify(cat.tasks.map(t => t.text)));
check('new C 按入参顺序排在 new A 之后',
cat.tasks[1].text === 'new C',
JSON.stringify(cat.tasks.map(t => t.text)));
check('既有任务 1 在 new A / new C 之后',
cat.tasks.find(t => t.text === 'existing 1') !== undefined);
check('既有任务 2 在 new A / new C 之后',
cat.tasks.find(t => t.text === 'existing 2') !== undefined);
check('new B 不在「工作」分类里v3.4 不变量)',
!cat.tasks.some(t => t.text === 'new B'));
// 新任务的 ID 与既有任务不冲突
const allIds = cat.tasks.map(t => t.id);
check('所有任务 ID 唯一', new Set(allIds).size === allIds.length);
// v3.4new B 进了「# 已完成任务」分类 —— 验证 inline 标记正确落地
const completed = store.getCompletedCategory()?.tasks || [];
const newB = completed.find(t => t.text === 'new B');
check('new B 在「# 已完成任务」里 completed=true 且 important=true',
newB?.completed === true && newB?.important === true,
JSON.stringify(newB));
check('new B 带 originalCategory=工作',
newB?.originalCategory === '工作');
}
// ============================================================
// [12] importPosition 两种模式order / front
//
// 早期实现直接复用 newTaskPosition = 'front'(默认),导致:
// - 旧版:「按入参顺序粘贴 3 行」 → 最后一行跑到列表最前面,与用户输入顺序相反。
// 现拆出独立的 importPositionsettingsStore.importPosition两种合法值
// - 'order'(默认):按入参顺序插到目标位置的最前面 —— 第一行排到最上。
// - 'front' :入参倒序插到目标位置的最前面 —— 最后一行排到最上(与 newTaskPosition='front' 同款)。
//
// 历史上曾有第三种 'back' 模式(按入参顺序追加到末尾),但与全局 newTaskPosition='back'
// 行为完全重叠 ——「追加到末尾」属于位置语义,归全局设置管;导入对话框只管「顺序」。
// 收成两个选项后这条测试用例被移除。
//
// 调用方还可以通过 options.importPosition 直接覆盖 settingsStore 的值,本测试
// 也验证这条覆盖路径settingsStore 没字段 / 字段非法时仍能拿到合法默认值)。
// ============================================================
console.log('\n[12] importPosition 两种模式(与 newTaskPosition 解耦)');
{
const baseSettings = {
_values: { newTaskPosition: 'front', completedPosition: 'front' },
get(k) { return this._values[k]; }
};
// 12.1 order 模式(默认)—— 不依赖 settingsStore 注入也走默认值
{
// 不注入 settingsStore验证 importPosition 默认 'order'(按入参顺序 + 插到最前面)
const store = makeStore();
const cat = store.getCategory('工作');
// 先放一条现有任务作为「锚点」—— 验证新导入任务真的插到了前面,而不是追加到末尾
cat.tasks.push({ id: 'existing', text: '现有任务', completed: false, important: false, current: false });
const lines = ['第一行', '第二行', '第三行'];
store.importTasksToCategory('工作', lines);
const texts = cat.tasks.map(t => t.text);
check('order 模式:现有任务保留在最后',
texts[texts.length - 1] === '现有任务',
JSON.stringify(texts));
check('order 模式:导入任务按入参顺序插到最前面',
texts.slice(0, 3).join('|') === '第一行|第二行|第三行',
JSON.stringify(texts));
}
// 12.2 front 模式 —— 入参倒序,最后一行在最上
{
const settings = { _values: { ...baseSettings._values, importPosition: 'front' }, get(k) { return this._values[k]; } };
const store = makeStore(null, { settingsStore: settings });
const cat = store.getCategory('工作');
cat.tasks.push({ id: 'existing', text: '现有任务', completed: false, important: false, current: false });
store.importTasksToCategory('工作', ['第一行', '第二行', '第三行']);
const texts = cat.tasks.map(t => t.text);
check('front 模式:现有任务保留在最后',
texts[texts.length - 1] === '现有任务',
JSON.stringify(texts));
check('front 模式:导入任务倒序插到最前面(最后一行在最上)',
texts.slice(0, 3).join('|') === '第三行|第二行|第一行',
JSON.stringify(texts));
}
// 12.3 解耦验证 —— 即便 newTaskPosition='back'importPosition 默认 'order'
// 也能确保导入任务插到前面。这是用户报告的「后面的内容排在前面」bug 的核心修复点。
{
const settings = {
// 模拟用户主设置里把「新增任务」设为「后面」(最下追加),
// 但导入对话框希望默认走 order —— 验证二者真的解耦。
_values: { newTaskPosition: 'back', completedPosition: 'back' },
get(k) { return this._values[k]; }
};
const store = makeStore(null, { settingsStore: settings });
const cat = store.getCategory('工作');
cat.tasks.push({ id: 'existing', text: '现有任务', completed: false, important: false, current: false });
store.importTasksToCategory('工作', ['A', 'B', 'C']);
const texts = cat.tasks.map(t => t.text);
check('与 newTaskPosition 解耦newTaskPosition=back 不影响导入顺序',
texts.slice(0, 3).join('|') === 'A|B|C',
JSON.stringify(texts));
check('与 newTaskPosition 解耦现有任务仍被推到末尾importPosition=order',
texts[texts.length - 1] === '现有任务',
JSON.stringify(texts));
}
// 12.5 options.importPosition 覆盖 settingsStore —— 导入对话框的选择优先级最高
{
const settings = {
_values: { importPosition: 'front' }, // settingsStore 说用 front
get(k) { return this._values[k]; }
};
const store = makeStore(null, { settingsStore: settings });
const cat = store.getCategory('工作');
// 但 options.importPosition='order' 覆盖 → 应该按入参顺序插到最前面
store.importTasksToCategory('工作', ['A', 'B', 'C'], { importPosition: 'order' });
const texts = cat.tasks.map(t => t.text);
check('options.importPosition 覆盖 settingsStoreorder 优先于 front',
texts.join('|') === 'A|B|C',
JSON.stringify(texts));
}
// 12.6 非法值兜底 —— settingsStore 给了非法值时回退到 'order'
{
const settings = {
_values: { importPosition: 'bogus-value' },
get(k) { return this._values[k]; }
};
const store = makeStore(null, { settingsStore: settings });
const cat = store.getCategory('工作');
store.importTasksToCategory('工作', ['A', 'B']);
const texts = cat.tasks.map(t => t.text);
check('非法 importPosition 值 → 兜底为 order按入参顺序 + 插到最前)',
texts.join('|') === 'A|B',
JSON.stringify(texts));
}
// 12.7 已完成任务也走同一个 importPosition —— pending / completed 行为对称
{
const settings = {
_values: { importPosition: 'order' },
get(k) { return this._values[k]; }
};
const store = makeStore(null, { settingsStore: settings });
// 先用一次空批导入触发「# 已完成任务」分类的创建_getOrCreateCompletedCategory
// 是惰性的,不导入 completed 任务时不创建),再把锚点放进去。
store.importTasksToCategory('工作', ['- [✓] placeholder']);
const completedCat = store.getCompletedCategory();
completedCat.tasks.push({ id: 'existing-c', text: '已有完成', completed: true, important: false, current: false });
// 混合导入:第 1 条 pending第 2 条 completed
store.importTasksToCategory('工作', ['pending-X', '- [✓] completed-Y']);
const workTasks = store.getCategory('工作').tasks.map(t => t.text);
const completedTasks = store.getCompletedCategory().tasks.map(t => t.text);
check('order 模式pending 按入参顺序插到工作分类前面',
workTasks[0] === 'pending-X',
JSON.stringify(workTasks));
check('order 模式completed 也按入参顺序插到前面(与 pending 对称)',
completedTasks[0] === 'completed-Y' &&
completedTasks[completedTasks.length - 1] === '已有完成',
JSON.stringify(completedTasks));
}
}
// ============================================================
// 总结
// ============================================================
console.log(`\n==========\n通过 ${summary.pass} / 失败 ${summary.fail}\n==========`);
process.exit(summary.fail === 0 ? 0 : 1);

139
scripts/check-imports.mjs Normal file
View File

@@ -0,0 +1,139 @@
// 一次性检查:所有相对 import 的具名导出是否真实存在。
// 渲染进程的 UI 文件不会被 node 测试加载,语法正确但 import 了不存在的名字时
// 只会在 Electron 里白屏 —— 这个脚本用来提前抓出来。
//
// 用法node scripts/check-imports.mjs
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
// UI 模块在模块作用域就会碰 document / window注册全局监听等
// 所以先塞一个最小 DOM 桩 —— 我们只关心 import 能不能解析,不跑实际逻辑。
const noop = () => {};
const stubEl = new Proxy({}, {
get: (t, k) => (k === 'style' || k === 'dataset' || k === 'classList')
? stubEl
: (k in t ? t[k] : noop),
set: () => true
});
globalThis.document = new Proxy({}, {
get: (t, k) => {
if (k === 'body' || k === 'documentElement' || k === 'head') return stubEl;
if (k === 'querySelectorAll') return () => [];
if (k === 'getElementById' || k === 'querySelector') return () => null;
if (k === 'createElement') return () => stubEl;
return noop;
}
});
globalThis.window = new Proxy({}, {
get: (t, k) => (k === 'matchMedia')
? () => ({ matches: false, addEventListener: noop, addListener: noop })
: (k === 'innerWidth' || k === 'innerHeight') ? 1000 : noop,
set: () => true
});
// Node 21+ 把 navigator 变成自带 getter 的全局(只读),直接赋值会抛
// "Cannot set property navigator of #<Object> which has only a getter"。
// 用 defineProperty 走 configurable 通道原模块toolbar.js只是读 navigator.platform。
if (!Object.getOwnPropertyDescriptor(globalThis, 'navigator')?.configurable) {
// 极小概率:未来 Node 把 navigator 锁成 non-configurable —— 退到 try/catch 兜底
try { globalThis.navigator = { platform: 'Win32', userAgent: 'node' }; } catch {}
} else {
Object.defineProperty(globalThis, 'navigator', {
value: { platform: 'Win32', userAgent: 'node' },
writable: true,
configurable: true
});
}
globalThis.localStorage = { getItem: () => null, setItem: noop, removeItem: noop };
const files = [];
(function walk(d) {
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, e.name);
if (e.isDirectory()) walk(p);
else if (e.name.endsWith('.js')) files.push(p);
}
})('src');
let bad = 0;
for (const f of files) {
const src = fs.readFileSync(f, 'utf8');
// 三类 import 都要扫:
// 1) 具名 import { a, b as c } from 'x'
// 2) 默认 import X from 'x' → 检查 mod.default 存在
// 3) 命名空间 import * as X from 'x' → 默认通过,无需检查具体名字
//
// 旧实现只匹配单行的具名 import多行 import跨行排版的 { a, b })会被
// 静悄悄放过 —— 那恰好是 UI 文件里最常见的风格。
const namedRe = /import\s*\{([\s\S]*?)\}\s*from\s*['"]([^'"]+)['"]/g;
const defaultRe = /import\s+(\w+)\s+from\s*['"]([^'"]+)['"]/g;
const nsRe = /import\s*\*\s*as\s+(\w+)\s+from\s*['"]([^'"]+)['"]/g;
const seenSpecs = new Set();
const tryLoad = async (spec, fromFile) => {
if (!spec.startsWith('.')) return null;
const target = path.resolve(path.dirname(fromFile), spec);
const key = `${fromFile}::${spec}`;
if (seenSpecs.has(key)) return null;
seenSpecs.add(key);
if (!fs.existsSync(target)) {
console.log(`NO FILE: ${fromFile} -> ${spec}`);
bad++;
return null;
}
return await import(pathToFileURL(target).href);
};
// 1) 具名导入
let m;
while ((m = namedRe.exec(src))) {
const spec = m[2];
const mod = await tryLoad(spec, f);
if (!mod) continue;
// 拆具名列表容忍换行、尾随逗号、as 重命名。
// 关键:检查 `n in mod` 时必须用**源导出名**(即 `as` 之前的部分),
// 不是局部名 —— 模块导出的永远是源名,重命名只在当前模块内可见。
// 旧实现用 .pop() 取到的是局部名('bar'),对 `{ foo as bar }` 永远
// 找不到 'bar',把这次正常的 rename 误报为 MISSING EXPORT。
const names = m[1]
.split(',')
.map(s => {
const parts = s.trim().split(/\s+as\s+/);
return (parts[0] || '').trim();
})
.filter(Boolean);
for (const n of names) {
if (!(n in mod)) {
console.log(`MISSING EXPORT: ${n} (${f} <- ${spec})`);
bad++;
}
}
}
// 2) 默认导入
while ((m = defaultRe.exec(src))) {
// 跳过已经匹配的具名导入regex 共享游标,靠 lookahead 排除具名)
if (/\{[\s\S]*\}\s*from\s*['"]/.test(src.slice(m.index, m.index + m[0].length + 50))) continue;
const localName = m[1];
const spec = m[2];
const mod = await tryLoad(spec, f);
if (!mod) continue;
if (!('default' in mod) || mod.default === undefined) {
console.log(`MISSING DEFAULT: ${localName} (${f} <- ${spec})`);
bad++;
}
}
// 3) 命名空间 import仅当模块本身不存在时才算失败 —— 不去校验具体成员。
while ((m = nsRe.exec(src))) {
const spec = m[2];
await tryLoad(spec, f);
}
}
console.log(bad === 0
? `✓ 所有 import 均可解析(${files.length} 个文件)`
: `${bad} 处问题`);
process.exit(bad > 0 ? 1 : 0);

View File

@@ -0,0 +1,545 @@
// 回归测试「任务插入位置」设置newTaskPosition / completedPosition /
// uncompletePosition / trashPosition / restorePosition 五件套)
//
// 用户偏好五类插入动作的落点前面front, 最新在最上)/ 后面back, 最新在最下)。
// 五类动作分别是:
// - 新增任务 (addTask / importTasksToCategory 的 pending 部分)
// - 完成 (toggleTask 打勾 / batchToggleCompleted(true) / importTasksToCategory 的 completed 部分)
// - 取消完成 (toggleTask 取消勾选 / restoreCompletedTask / batchToggleCompleted(false) / picker 强制降级)
// - 删除 (deleteTask / batchDeleteTasks / clearCompleted)
// - 恢复 (restoreTask 从回收站归位)
//
// 这份脚本直接构造 TaskStore + 桩 SettingsStore验证
// 1. 默认(不注入 settingsStore= front等价于 DEFAULT_SETTINGS。
// 2. settingsStore 注入后,按 *Position 的值决定 push / unshift。
// 3. 切换设置后再插入:仅新插入的任务受影响,已存在的任务不会被回溯重排。
// 4. SettingsStore 拒绝非法值(与 settings-store 的 VALID_VALUES 对齐)。
// 5. settingsStore.update 持久化设置后TaskStore 立即读到新值(无需重启)。
//
// 跑法node scripts/check-insert-position.mjs
import { check, ok, bad, summary, printSummary } from './_lib/check.mjs';
import { installApiStub } from './_lib/api-stub.mjs';
// TaskStore 的模块作用域会注册事件回调,但仅用到内存对象,没有副作用。
installApiStub();
const { TaskStore, OTHER_TASKS_NAME, COMPLETED_NAME, TRASH_NAME } = await import('../src/task-store.js');
const { SettingsStore } = await import('../src/settings-store.js');
// 简单桩:复刻 SettingsStore.get 的最小接口,行为上把 'newTaskPosition' 等字段
// 当作对象属性读取。TaskStore 不关心 SettingsStore 是不是 EventEmitter、不关心
// load()/update(),所以不实现事件也够用。
function makeSettingsStub(values = {}) {
return {
_values: { ...values },
get(key) { return this._values[key]; }
};
}
// 取分类下任务文本数组 —— 断言时只关心「顺序」与「文本」,不看 id / 标记字段。
function taskTexts(cat) {
return (cat?.tasks || []).map(t => t.text);
}
// 取「已完成」分类下的任务文本。
function completedTexts(store) {
const c = store.getCompletedCategory();
return taskTexts(c);
}
// 取「回收站」分类下的任务文本。
function trashTexts(store) {
const c = store.getTrashCategory();
return taskTexts(c);
}
// ============================================
// 1. 默认行为:未注入 settingsStore → 等价于 'front'(最新在最上)
// ============================================
console.log('[1] 默认(未注入 settingsStore→ 插入到列表前面');
{
const store = new TaskStore();
store.loadDefault(null);
store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} });
// 新增任务:默认应该在最前面
store.addTask('工作', 'A');
store.addTask('工作', 'B');
const workCat = store.getCategory('工作');
check('未注入 settingsStore 时新增任务落到前面',
JSON.stringify(taskTexts(workCat)) === JSON.stringify(['B', 'A']),
`actual=${JSON.stringify(taskTexts(workCat))}`);
// 完成:默认应该在「已完成任务」前面 —— 勾掉 A位置 [1]),再勾掉 B位置 [0]
// 期望 completedTexts 顺序 = [B, A](最新完成的最上面)。
store.toggleTask('工作', workCat.tasks[1].id); // 勾 A
store.toggleTask('工作', workCat.tasks[0].id); // 勾 B
check('未注入 settingsStore 时打勾任务按完成顺序落到「已完成」前面',
JSON.stringify(completedTexts(store)) === JSON.stringify(['B', 'A']),
`actual=${JSON.stringify(completedTexts(store))}`);
// 删除:默认应该在「回收站」前面
store.addTask('生活', 'X');
store.addTask('生活', 'Y');
const liveCat = store.getCategory('生活');
// liveCat.tasks = [Y, X]front 模式)。先删 Ytasks[0]),再删 Xtasks[0]
store.deleteTask('生活', liveCat.tasks[0].id); // 删 Y
store.deleteTask('生活', liveCat.tasks[0].id); // 删 X
check('未注入 settingsStore 时删除任务按删除顺序落到「回收站」前面',
JSON.stringify(trashTexts(store)) === JSON.stringify(['X', 'Y']),
`actual=${JSON.stringify(trashTexts(store))}`);
}
// ============================================
// 2. 注入 settingsStore 后按 *Position 决定 push / unshift
// ============================================
console.log('\n[2] settingsStore.newTaskPosition="back" → 新增任务落到列表后面');
{
const settings = makeSettingsStub({ newTaskPosition: 'back' });
const store = new TaskStore({ settingsStore: settings });
store.loadDefault(null);
store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} });
store.addTask('工作', 'A');
store.addTask('工作', 'B');
const workCat = store.getCategory('工作');
check('newTaskPosition=back 时新增任务按入参顺序落到末尾',
JSON.stringify(taskTexts(workCat)) === JSON.stringify(['A', 'B']),
`actual=${JSON.stringify(taskTexts(workCat))}`);
}
console.log('\n[3] settingsStore.completedPosition="back" → 完成的任务落到「已完成」后面');
{
const settings = makeSettingsStub({ completedPosition: 'back' });
const store = new TaskStore({ settingsStore: settings });
store.loadDefault(null);
store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} });
const workCat = store.getCategory('工作');
store.addTask('工作', 'A');
store.addTask('工作', 'B');
// 工作顺序:默认 front → [B, A]。先勾 A后入参 = 最新,但 front 模式下 [1]
// 再勾 B最旧→ back 模式下完成顺序:[A, B](最早完成的在最上,最后完成的在最下)。
store.toggleTask('工作', workCat.tasks[1].id); // 勾 A
store.toggleTask('工作', workCat.tasks[0].id); // 勾 B
check('completedPosition=back 时按完成顺序追加到「已完成」末尾',
JSON.stringify(completedTexts(store)) === JSON.stringify(['A', 'B']),
`actual=${JSON.stringify(completedTexts(store))}`);
}
console.log('\n[4] settingsStore.trashPosition="back" → 删除的任务落到「回收站」后面');
{
const settings = makeSettingsStub({ trashPosition: 'back' });
const store = new TaskStore({ settingsStore: settings });
store.loadDefault(null);
store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} });
store.addTask('工作', 'A');
store.addTask('工作', 'B');
const workCat = store.getCategory('工作');
// 工作顺序:默认 front → [B, A]。先删 A最新再删 B最旧→ back 模式下
// 回收站顺序:[A, B](最新删除的... 等等back 模式下"最新"在最下面)。
// 等等,这里 user 期望"最新在最下"back 模式语义):先删 A → 在 [0],再删 B → 追加在 [1]。
store.deleteTask('工作', workCat.tasks[1].id); // 删 A
store.deleteTask('工作', workCat.tasks[0].id); // 删 B
check('trashPosition=back 时按删除顺序追加到「回收站」末尾',
JSON.stringify(trashTexts(store)) === JSON.stringify(['A', 'B']),
`actual=${JSON.stringify(trashTexts(store))}`);
}
// ============================================
// 5. 切换设置 → 下一条新插入立即生效,已存在的任务不重排
// ============================================
console.log('\n[5] 切换设置只影响新插入,已存在的任务不被回溯重排');
{
const settings = makeSettingsStub({ newTaskPosition: 'front' });
const store = new TaskStore({ settingsStore: settings });
store.loadDefault(null);
store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} });
store.addTask('工作', 'A');
store.addTask('工作', 'B');
const workCat = store.getCategory('工作');
// 切到 'back'
settings._values.newTaskPosition = 'back';
store.addTask('工作', 'C');
check('切到 back 后新插入的 C 落到末尾',
JSON.stringify(taskTexts(workCat)) === JSON.stringify(['B', 'A', 'C']),
`actual=${JSON.stringify(taskTexts(workCat))}`);
// 再切回 'front'
settings._values.newTaskPosition = 'front';
store.addTask('工作', 'D');
check('切回 front 后新插入的 D 落到最前,已存在的 [B, A, C] 不动',
JSON.stringify(taskTexts(workCat)) === JSON.stringify(['D', 'B', 'A', 'C']),
`actual=${JSON.stringify(taskTexts(workCat))}`);
}
// ============================================
// 6. 非法值被 TaskStore 兜底为 'front'settingsStore 校验已经兜过,
// 这里再守一道防御:万一有人绕过 settingsStore 直接塞非法值进来)
// ============================================
console.log('\n[6] 非法 *Position 值被 TaskStore 兜底为 front');
{
const settings = makeSettingsStub({ newTaskPosition: 'middle' /* 非法 */ });
const store = new TaskStore({ settingsStore: settings });
store.loadDefault(null);
store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} });
store.addTask('工作', 'A');
store.addTask('工作', 'B');
const workCat = store.getCategory('工作');
check('非法 newTaskPosition="middle" 兜底为 front最新在最上',
JSON.stringify(taskTexts(workCat)) === JSON.stringify(['B', 'A']),
`actual=${JSON.stringify(taskTexts(workCat))}`);
}
// ============================================
// 7. SettingsStore 自身拒绝非法值(与 settings-store 的 VALID_VALUES 对齐)
// ============================================
console.log('\n[7] SettingsStore 拒绝非法 *Position 值');
{
const s = new SettingsStore();
await s.update({ newTaskPosition: 'middle' });
if (s.get('newTaskPosition') === 'front') {
ok('update({newTaskPosition: "middle"}) 被忽略,保持默认 front');
} else {
bad('newTaskPosition 被非法值污染', `当前 ${JSON.stringify(s.get('newTaskPosition'))}`);
}
await s.update({ completedPosition: null });
if (s.get('completedPosition') === 'front') {
ok('update({completedPosition: null}) 被忽略');
} else {
bad('completedPosition 被 null 污染', `当前 ${JSON.stringify(s.get('completedPosition'))}`);
}
await s.update({ trashPosition: 'front' });
if (s.get('trashPosition') === 'front') {
ok('update({trashPosition: "front"}) 合法值生效');
} else {
bad('trashPosition 未生效', `当前 ${JSON.stringify(s.get('trashPosition'))}`);
}
}
// ============================================
// 8. SettingsStore.update 后 TaskStore 立即读到新值(无需重启)
// ============================================
console.log('\n[8] SettingsStore.update 后下一次插入立即按新值生效');
{
const s = new SettingsStore();
const store = new TaskStore({ settingsStore: s });
store.loadDefault(null);
store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} });
await s.update({ newTaskPosition: 'back' });
store.addTask('工作', 'A');
store.addTask('工作', 'B');
const workCat = store.getCategory('工作');
check('update back 后插入按入参顺序追加',
JSON.stringify(taskTexts(workCat)) === JSON.stringify(['A', 'B']),
`actual=${JSON.stringify(taskTexts(workCat))}`);
await s.update({ newTaskPosition: 'front' });
store.addTask('工作', 'C');
check('再 update 回 front 后新插入落到最前',
JSON.stringify(taskTexts(workCat)) === JSON.stringify(['C', 'A', 'B']),
`actual=${JSON.stringify(taskTexts(workCat))}`);
}
// ============================================
// 9. 批量路径同样遵守设置importTasksToCategory 的 pending 部分用 newTaskPosition
// completed 部分用 completedPosition。
// ============================================
console.log('\n[9] importTasksToCategory 批量插入遵守 *Position 设置');
{
const settings = makeSettingsStub({
newTaskPosition: 'back',
completedPosition: 'back'
});
const store = new TaskStore({ settingsStore: settings });
store.loadDefault(null);
store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} });
// 第一批:往「工作」导入 A、B、C都是 pending
store.importTasksToCategory('工作', ['A', 'B', 'C']);
const workCat = store.getCategory('工作');
check('newTaskPosition=back 时批量 pending 按入参顺序落到末尾',
JSON.stringify(taskTexts(workCat)) === JSON.stringify(['A', 'B', 'C']),
`actual=${JSON.stringify(taskTexts(workCat))}`);
// 第二批:往「生活」导入 - [x] 已完成completed 部分)
store.importTasksToCategory('生活', ['- [x] X', '- [x] Y', '- [x] Z']);
check('completedPosition=back 时批量 completed 按入参顺序落到末尾',
JSON.stringify(completedTexts(store)) === JSON.stringify(['X', 'Y', 'Z']),
`actual=${JSON.stringify(completedTexts(store))}`);
}
console.log('\n[10] importTasksToCategory importPosition 默认模式:按入参顺序 + 插到最前');
{
// 修复用户报告 bug 后:导入默认行为不再复用 newTaskPosition='front'(最后一行在最前),
// 而是独立的 importPosition='order'(第一行在最前,按入参顺序)。这里验证新的默认。
const settings = makeSettingsStub({});
const store = new TaskStore({ settingsStore: settings });
store.loadDefault(null);
store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} });
store.importTasksToCategory('工作', ['A', 'B', 'C']);
const workCat = store.getCategory('工作');
// 入参顺序 A→B→Corder 模式(默认)→ unshift 整批 → A 在最前
check('默认 importPosition=order按入参顺序 + 插到最前A 在最前)',
JSON.stringify(taskTexts(workCat)) === JSON.stringify(['A', 'B', 'C']),
`actual=${JSON.stringify(taskTexts(workCat))}`);
// 显式设 importPosition='front' 验证旧 front 语义仍可用
const settingsFront = makeSettingsStub({ importPosition: 'front' });
const storeFront = new TaskStore({ settingsStore: settingsFront });
storeFront.loadDefault(null);
storeFront._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} });
storeFront.importTasksToCategory('工作', ['A', 'B', 'C']);
check('显式 importPosition=front倒序 unshiftC 在最前)',
JSON.stringify(taskTexts(storeFront.getCategory('工作'))) === JSON.stringify(['C', 'B', 'A']),
`actual=${JSON.stringify(taskTexts(storeFront.getCategory('工作')))}`);
}
// ============================================
// 11. 批量删除batchDeleteTasks和 clearCompleted 也遵守 trashPosition
// ============================================
console.log('\n[11] clearCompleted / batchDeleteTasks 遵守 trashPosition');
{
// front默认
const settingsFront = makeSettingsStub({ trashPosition: 'front' });
const store1 = new TaskStore({ settingsStore: settingsFront });
store1.loadDefault(null);
store1._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} });
store1.addTask('工作', 'A');
store1.addTask('工作', 'B');
let workCat = store1.getCategory('工作');
// 勾两条 → 都进「已完成」
store1.toggleTask('工作', workCat.tasks[0].id);
store1.toggleTask('工作', workCat.tasks[0].id);
// clearCompleted → 两条都已完成 → 全部进 trash
store1.clearCompleted();
check('trashPosition=front 时 clearCompleted 把最新清走的排在最前',
JSON.stringify(trashTexts(store1)) === JSON.stringify(['B', 'A']),
`actual=${JSON.stringify(trashTexts(store1))}`);
}
{
// back
const settingsBack = makeSettingsStub({ trashPosition: 'back' });
const store2 = new TaskStore({ settingsStore: settingsBack });
store2.loadDefault(null);
store2._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} });
store2.addTask('工作', 'A');
store2.addTask('工作', 'B');
let workCat = store2.getCategory('工作');
store2.toggleTask('工作', workCat.tasks[0].id);
store2.toggleTask('工作', workCat.tasks[0].id);
store2.clearCompleted();
check('trashPosition=back 时 clearCompleted 按清走顺序追加',
JSON.stringify(trashTexts(store2)) === JSON.stringify(['A', 'B']),
`actual=${JSON.stringify(trashTexts(store2))}`);
}
// ============================================
// 12. 恢复路径遵守 restorePosition / uncompletePosition
// ============================================
console.log('\n[12] restoreTask 遵守 restorePosition');
{
// restorePosition=backA 回到末尾
const settings = makeSettingsStub({ restorePosition: 'back' });
const store = new TaskStore({ settingsStore: settings });
store.loadDefault(null);
store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} });
store.addTask('工作', 'A');
store.addTask('工作', 'B');
const workCat = store.getCategory('工作');
// 工作当前顺序 [B, A](默认 front。删 Atasks[1]
store.deleteTask('工作', workCat.tasks[1].id);
const trashCat = store.getTrashCategory();
store.restoreTask(trashCat.tasks[0].id);
check('restorePosition=back 时 A 回到「工作」末尾',
JSON.stringify(taskTexts(workCat)) === JSON.stringify(['B', 'A']),
`actual=${JSON.stringify(taskTexts(workCat))}`);
}
{
// restorePosition=front默认A 回到最前
const settings = makeSettingsStub({ restorePosition: 'front' });
const store = new TaskStore({ settingsStore: settings });
store.loadDefault(null);
store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} });
store.addTask('工作', 'A');
store.addTask('工作', 'B');
const workCat = store.getCategory('工作');
// 工作当前顺序 [B, A](默认 front。删 Atasks[1]
store.deleteTask('工作', workCat.tasks[1].id);
const trashCat = store.getTrashCategory();
store.restoreTask(trashCat.tasks[0].id);
check('restorePosition=front 时 A 回到「工作」最前',
JSON.stringify(taskTexts(workCat)) === JSON.stringify(['A', 'B']),
`actual=${JSON.stringify(taskTexts(workCat))}`);
}
// ============================================
// 13. 取消完成toggleTask 取消勾选 / restoreCompletedTask遵守 uncompletePosition
// ============================================
console.log('\n[13] toggleTask 取消勾选 遵守 uncompletePosition');
{
// uncompletePosition=back取消勾选后追加到末尾
const settings = makeSettingsStub({ uncompletePosition: 'back' });
const store = new TaskStore({ settingsStore: settings });
store.loadDefault(null);
store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} });
store.addTask('工作', 'A');
store.addTask('工作', 'B');
const workCat = store.getCategory('工作');
// 工作顺序 [B, A]。勾 Atasks[1])→ 进 completed
store.toggleTask('工作', workCat.tasks[1].id);
// 取消勾选 → 回到工作uncompletePosition=back → 追加到末尾 → [B, A]
const completedTaskId = store.getCompletedCategory().tasks[0].id;
store.toggleTask(COMPLETED_NAME, completedTaskId);
check('uncompletePosition=back 时取消勾选追加到「工作」末尾',
JSON.stringify(taskTexts(workCat)) === JSON.stringify(['B', 'A']),
`actual=${JSON.stringify(taskTexts(workCat))}`);
}
{
// uncompletePosition=front默认取消勾选后落到最前
const settings = makeSettingsStub({ uncompletePosition: 'front' });
const store = new TaskStore({ settingsStore: settings });
store.loadDefault(null);
store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} });
store.addTask('工作', 'A');
store.addTask('工作', 'B');
const workCat = store.getCategory('工作');
// 工作顺序 [B, A]。勾 A → 进 completed
store.toggleTask('工作', workCat.tasks[1].id);
// 取消勾选 → 回到工作front → unshift 到最前 → [A, B]
const completedTaskId = store.getCompletedCategory().tasks[0].id;
store.toggleTask(COMPLETED_NAME, completedTaskId);
check('uncompletePosition=front 时取消勾选 unshift 到「工作」最前',
JSON.stringify(taskTexts(workCat)) === JSON.stringify(['A', 'B']),
`actual=${JSON.stringify(taskTexts(workCat))}`);
}
// ============================================
// 14. restoreCompletedTaskpicker 已选好目标分类)遵守 uncompletePosition
// ============================================
console.log('\n[14] restoreCompletedTask 遵守 uncompletePosition');
{
// uncompletePosition=frontrestoreCompletedTask 走「未分类」时落到最前
const settings = makeSettingsStub({ uncompletePosition: 'front' });
const store = new TaskStore({ settingsStore: settings });
store.loadDefault(null);
store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} });
store.addTask('工作', 'A');
store.addTask('工作', 'B');
const workCat = store.getCategory('工作');
// 工作顺序 [B, A]。勾 A → 进 completed
store.toggleTask('工作', workCat.tasks[1].id);
const completedTaskId = store.getCompletedCategory().tasks[0].id;
// UI picker 选了「工作」—— restoreCompletedTask 直接落到「工作」最前
store.restoreCompletedTask(completedTaskId, '工作');
check('uncompletePosition=front 时 restoreCompletedTask 把 A 放到「工作」最前',
JSON.stringify(taskTexts(workCat)) === JSON.stringify(['A', 'B']),
`actual=${JSON.stringify(taskTexts(workCat))}`);
}
{
// uncompletePosition=backrestoreCompletedTask 落到末尾
const settings = makeSettingsStub({ uncompletePosition: 'back' });
const store = new TaskStore({ settingsStore: settings });
store.loadDefault(null);
store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} });
store.addTask('工作', 'A');
store.addTask('工作', 'B');
const workCat = store.getCategory('工作');
// 工作顺序 [B, A]。勾 A → 进 completed
store.toggleTask('工作', workCat.tasks[1].id);
const completedTaskId = store.getCompletedCategory().tasks[0].id;
// restoreCompletedTask 直接落到「工作」末尾
store.restoreCompletedTask(completedTaskId, '工作');
check('uncompletePosition=back 时 restoreCompletedTask 把 A 放到「工作」末尾',
JSON.stringify(taskTexts(workCat)) === JSON.stringify(['B', 'A']),
`actual=${JSON.stringify(taskTexts(workCat))}`);
}
// ============================================
// 15. batchToggleCompleted(false) 遵守 uncompletePosition
// ============================================
console.log('\n[15] batchToggleCompleted(false) 遵守 uncompletePosition');
{
// uncompletePosition=front批量取消勾选按 parseOrder 倒序 unshift 到最前
const settings = makeSettingsStub({ uncompletePosition: 'front' });
const store = new TaskStore({ settingsStore: settings });
store.loadDefault(null);
store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} });
// 先在「工作」建 A、B、C默认 front → tasks = [C, B, A]
store.addTask('工作', 'A');
store.addTask('工作', 'B');
store.addTask('工作', 'C');
const workCat = store.getCategory('工作');
// 全部勾到 completed —— taskRefs 形如 [{categoryName, taskId}, ...]
const refs = workCat.tasks.map(t => ({ categoryName: '工作', taskId: t.id }));
store.batchToggleCompleted(refs, true);
// 把三条都取消勾选 → 它们按 parseOrder 倒序 unshift 到工作最前
const completedCat = store.getCompletedCategory();
const completedRefs = completedCat.tasks.map(t => ({ categoryName: '已完成任务', taskId: t.id }));
store.batchToggleCompleted(completedRefs, false);
check('uncompletePosition=front 时 batchToggleCompleted(false) 按 parseOrder 倒序 unshift',
JSON.stringify(taskTexts(workCat)) === JSON.stringify(['C', 'B', 'A']),
`actual=${JSON.stringify(taskTexts(workCat))}`);
}
{
// uncompletePosition=back批量取消勾选按 parseOrder 顺序追加到末尾
const settings = makeSettingsStub({ uncompletePosition: 'back' });
const store = new TaskStore({ settingsStore: settings });
store.loadDefault(null);
store._autoSave = Object.assign(() => {}, { cancel() {}, flush() {} });
store.addTask('工作', 'A');
store.addTask('工作', 'B');
store.addTask('工作', 'C');
const workCat = store.getCategory('工作');
const refs = workCat.tasks.map(t => ({ categoryName: '工作', taskId: t.id }));
store.batchToggleCompleted(refs, true);
const completedCat = store.getCompletedCategory();
const completedRefs = completedCat.tasks.map(t => ({ categoryName: '已完成任务', taskId: t.id }));
store.batchToggleCompleted(completedRefs, false);
check('uncompletePosition=back 时 batchToggleCompleted(false) 按 parseOrder 追加',
JSON.stringify(taskTexts(workCat)) === JSON.stringify(['A', 'B', 'C']),
`actual=${JSON.stringify(taskTexts(workCat))}`);
}
// ============================================
console.log(`\n${'─'.repeat(48)}`);
console.log(`通过 ${summary.pass} / 失败 ${summary.fail}`);
process.exit(summary.fail === 0 ? 0 : 1);

View File

@@ -0,0 +1,122 @@
// 回归测试H5 — [⭐] / [▶] 行尾原子块识别
//
// 背景:旧版 MARKER_TAIL_RE 强制前导 `\s`,导致:
// - `unimportant[⭐]`(任务文本紧贴 [⭐],无空格)→ 解析为普通文本important=false
// - 下次保存 serialize 后,原文 `unimportant[⭐]` → 变成 `unimportant`(标记丢失)
//
// 修复v3.7+tail 正则去掉 `\s` 前导,`[⭐]` / `[▶]` 在行尾被识别为
// 内联原子块(不是「必须前面有空白字符」)。本脚本验证这条修复路径。
//
// 跑法node scripts/check-marker-tail.mjs
import {
parseMarkdown,
extractInlineMarkers
} from '../src/markdown-parser.js';
import { check, ok, summary, printSummary } from './_lib/check.mjs';
// ── [1] extractInlineMarkers行尾 [⭐] 不再要求前导空白 ──
console.log('\n[1] extractInlineMarkers 行尾 [⭐] / [▶] 不要求前导空白');
{
// 修复前:\s 前导强制要求 → 失败;修复后:直接 [⭐]$ 匹配
const r1 = extractInlineMarkers('unimportant[⭐]');
check('unimportant[⭐](无前导空白)→ important=true',
r1.important === true,
`important=${r1.important}, text=${r1.text}`);
check('unimportant[⭐] 文本剥到只剩 unimportant',
r1.text === 'unimportant',
`text=${JSON.stringify(r1.text)}`);
// 带前导空白的老用法 —— 仍然支持(无回归)
const r2 = extractInlineMarkers('important task [⭐]');
check('带空白的 important task [⭐] 仍识别',
r2.important === true && r2.text === 'important task',
`important=${r2.important}, text=${JSON.stringify(r2.text)}`);
// 行尾 [▶] 同款修复
const r3 = extractInlineMarkers('focus[▶]');
check('focus[▶](无前导空白)→ current=true',
r3.current === true && r3.text === 'focus',
`current=${r3.current}, text=${JSON.stringify(r3.text)}`);
// 行尾同时有 ⭐ 和 ▶ —— 不带空格紧凑写法
const r4 = extractInlineMarkers('critical work[⭐][▶]');
check('critical work[⭐][▶] 两个标记都识别',
r4.important === true && r4.current === true,
`important=${r4.important}, current=${r4.current}, text=${JSON.stringify(r4.text)}`);
// loop 顺序剥:先 [⭐] 后 [▶]text 应剥到 "critical work"
check('critical work[⭐][▶] 文本剥到只剩 critical work',
r4.text === 'critical work',
`text=${JSON.stringify(r4.text)}`);
// 行内非尾部 [⭐] 不应被剥离(中间嵌入文本不应误识别)
const r5 = extractInlineMarkers('关键任务 [⭐] 描述');
// 这里 [⭐] 在中间 —— 不在行尾,按设计应该不剥离(不是 H5 修复范围)
// 行为约定MARKER_TAIL_RE 的 `$` 锚点要求标记在行尾;中间位置不识别。
check('行内 [⭐] 不被尾部规则剥离(不在 H5 修复范围)',
r5.text.includes('[⭐]') && !r5.important,
`text=${JSON.stringify(r5.text)}, important=${r5.important}`);
// 不含标记的纯文本
const r6 = extractInlineMarkers('just text');
check('无标记 → important/current 都 falsetext 不变',
!r6.important && !r6.current && r6.text === 'just text');
}
// ── [2] parseMarkdown 端到端:行尾 [⭐] 被解析为 important ──
console.log('\n[2] parseMarkdown 端到端:行尾 [⭐] 解析为 important');
{
const md = `# 全部任务
## 工作
- [ ] unimportant[⭐]
- [ ] 关注今日[▶]
- [ ] 双标[⭐][▶]
- [ ] 普通任务
## 未分类
`;
const cats = parseMarkdown(md);
const work = cats.find(c => c.name === '工作');
const find = (text) => work.tasks.find(t => t.text === text);
const t1 = find('unimportant');
check('「工作」下 unimportant[⭐] → text=unimportant, important=true',
!!t1 && t1.important === true && t1.completed === false,
`task=${JSON.stringify(t1)}`);
const t2 = find('关注今日');
check('「工作」下 关注今日[▶] → text=关注今日, current=true',
!!t2 && t2.current === true && t2.important === false,
`task=${JSON.stringify(t2)}`);
const t3 = find('双标');
check('「工作」下 双标[⭐][▶] → 两种标记同时 true',
!!t3 && t3.important === true && t3.current === true,
`task=${JSON.stringify(t3)}`);
const t4 = find('普通任务');
check('「工作」下 普通任务 → 标记均为 false',
!!t4 && !t4.important && !t4.current);
}
// ── [3] 写回往返important 不会丢 ──
console.log('\n[3] 写回往返:行尾 [⭐] 经 serialize → parse 后标记保留');
{
// 这里只验证解析侧的不可逆writeMarkdown 单测已在 check-md-ui-sync 覆盖):
// 解析出来后task.important === true ⇒ 文本里仍可看到 [⭐]
// 后续写回会在 writer 端用 [⭐] 行首标记(统一格式)输出,不再依赖原始紧凑写法。
const md = `- [ ] hello[⭐]\n`;
const cats = parseMarkdown(`# 全部任务\n\n## 工作\n\n${md}\n\n## 未分类\n\n`);
const task = cats.find(c => c.name === '工作')?.tasks[0];
check('hello[⭐] 解析后 important=true持久性证据',
task && task.important === true && task.text === 'hello');
}
printSummary('check-marker-tail');

266
scripts/check-markers.mjs Normal file
View File

@@ -0,0 +1,266 @@
// 回归测试Markdown 解析器对内联标记的处理
//
// 关键场景:
// - [⭐] / [▶] 后直接接文字(无空格)必须被识别
// - 裸 ⭐ / 裸 ▶ 必须前后有空白避免吃掉行内字符如「⭐buy」不应被识别
// - 同一行 [⭐] 与 [▶] 同时出现要分别正确归位
// - 行尾的标记要被识别(位置不限)
// - 旧版标记 [▶] / ▶ / @当前 都要被识别
//
// 跑法node scripts/check-markers.mjs
import { parseMarkdown } 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';
// 取出第一个被解析出来的任务
function firstTask(md) {
return parseMarkdown(md).flatMap(c => c.tasks)[0] || null;
}
console.log('\n[1] 方括号标记 [⭐] / [▶] 紧贴文字必须被识别');
{
const t1 = firstTask('# X\n\n## A\n\n- [ ] [⭐]buy milk');
check('[⭐]buy milk 标记为重要且文字干净', t1 && t1.important === true && t1.text === 'buy milk', JSON.stringify(t1));
const t2 = firstTask('# X\n\n## A\n\n- [ ] [▶]follow up');
check('[▶]follow up 标记为当前且文字干净', t2 && t2.current === true && t2.text === 'follow up', JSON.stringify(t2));
const t3 = firstTask('# X\n\n## A\n\n- [ ] [▶][⭐]task');
check('[▶][⭐]task 两个标记同时识别', t3 && t3.important === true && t3.current === true && t3.text === 'task', JSON.stringify(t3));
}
console.log('\n[2] 裸 ⭐ / 裸 ▶ / @当前 必须前后有空白');
{
const t1 = firstTask('# X\n\n## A\n\n- [ ] ⭐buy');
check('裸 ⭐buy 被拒绝(标记泄漏保留)', t1 && t1.important === false && t1.text === '⭐buy', JSON.stringify(t1));
const t2 = firstTask('# X\n\n## A\n\n- [ ] ▶task');
check('裸 ▶task 被拒绝', t2 && t2.current === false && t2.text === '▶task', JSON.stringify(t2));
const t3 = firstTask('# X\n\n## A\n\n- [ ] @当前task');
check('@当前task 被拒绝(多字符中文)', t3 && t3.current === false && t3.text === '@当前task', JSON.stringify(t3));
}
console.log('\n[3] 正常带空白的标记依然可用');
{
const t1 = firstTask('# X\n\n## A\n\n- [ ] [⭐] task');
check('[⭐] task 重要', t1 && t1.important === true && t1.text === 'task', JSON.stringify(t1));
const t2 = firstTask('# X\n\n## A\n\n- [ ] ⭐ task');
check('裸 ⭐ task 重要', t2 && t2.important === true && t2.text === 'task', JSON.stringify(t2));
const t3 = firstTask('# X\n\n## A\n\n- [ ] @当前 task');
check('@当前 task 当前', t3 && t3.current === true && t3.text === 'task', JSON.stringify(t3));
const t4 = firstTask('# X\n\n## A\n\n- [ ] task [⭐]');
check('task [⭐] 行尾标记重要', t4 && t4.important === true && t4.text === 'task', JSON.stringify(t4));
}
console.log('\n[4] 往返一致性:二次写出的 Markdown 等价');
{
const input = `# 全部任务
## 工作
- [ ] [⭐]紧贴任务
- [ ] [▶]紧贴任务
- [ ] [⭐] 带空格
- [ ] [▶] 带空格
- [ ] [▶][⭐]两个都有
- [ ] 普通任务
`;
const cats1 = parseMarkdown(input);
const md1 = writeMarkdown(cats1);
const cats2 = parseMarkdown(md1);
const md2 = writeMarkdown(cats2);
check('二次往返 markdown 完全一致', md1 === md2, `md1 != md2`);
check('二次往返任务数一致',
cats1.flatMap(c => c.tasks).length === cats2.flatMap(c => c.tasks).length);
// 重要/当前 状态也要在两次往返后保持
const tasks1 = cats1.flatMap(c => c.tasks);
const tasks2 = cats2.flatMap(c => c.tasks);
const states1 = tasks1.map(t => `${t.text}|${t.important ? 1 : 0}${t.current ? 1 : 0}`).sort();
const states2 = tasks2.map(t => `${t.text}|${t.important ? 1 : 0}${t.current ? 1 : 0}`).sort();
check('任务状态 (text,important,current) 完全一致', JSON.stringify(states1) === JSON.stringify(states2),
`\n states1=${JSON.stringify(states1)}\n states2=${JSON.stringify(states2)}`);
}
console.log('\n[5] 旧版标记仍被识别(向后兼容)');
{
// 这些标记在升级前的文件里可能出现;升级后仍要能加载
const t1 = firstTask('# X\n\n## A\n\n- [ ] ⭐ task');
check('裸 ⭐ 旧版仍识别', t1 && t1.important === true, JSON.stringify(t1));
const t2 = firstTask('# X\n\n## A\n\n- [ ] ▶ task');
check('裸 ▶ 旧版仍识别', t2 && t2.current === true, JSON.stringify(t2));
const t3 = firstTask('# X\n\n## A\n\n- [ ] @当前 task');
check('@当前 旧版仍识别', t3 && t3.current === true, JSON.stringify(t3));
}
console.log('\n[7] 文本中的特殊字符不被解析器误吃');
{
// TASK_LINE_RE 只锚 checkbox `[xxx]`,文本里的 `[1]` / `[abc]` 都是字面字符,
// 不会与 inline 标记 `[⭐]` / `[▶]` / `[原分类xxx]` 混淆:
// - `[⭐]` 等用的是 ✅ U+2B50 / ▶ U+25B6方括号 + 字面 unicode 字符
// - 文本里的 `[1]` 是 ASCII 数字 `1`regex `^\[([^\]]+)\]` 第一次匹配整行 checkbox
// 文本部分走 group(2),不会再触发 marker 抽取
// 验证「文本里含 ]」与「文本里含中文括号」都不会触发解析误判
const t1 = firstTask('# X\n\n## A\n\n- [ ] 任务 [1] 内容');
check('文本中的 [1] 保留为字面文本',
t1 && t1.text === '任务 [1] 内容' && !t1.important && !t1.current && !t1.originalCategory,
JSON.stringify(t1));
const t2 = firstTask('# X\n\n## A\n\n- [ ] 任务(中文括号)');
check('文本中的中文括号保留为字面文本',
t2 && t2.text === '任务(中文括号)' && !t2.important && !t2.current,
JSON.stringify(t2));
const t3 = firstTask('# X\n\n## A\n\n- [ ] a]b]c');
check('文本中含嵌套 ] 保留为字面文本(不会吃掉第二个]',
t3 && t3.text === 'a]b]c',
JSON.stringify(t3));
}
console.log('\n[8] [原分类xxx] 内含 ] 字符的边界(非贪婪匹配首个 ] 即终止)');
{
// `[原分类a]b]` 应解析为 originalCategory=`a`,文本保留 `b]`
// —— 因为 regex 是 `[^\]]+?` 非贪婪,首个 ] 即关闭标记。
// 同样适用于 [原分类xxx] 内含 → 等特殊字符
const md = `# 全部任务
## 工作
- [ ] 任务
- [✓] [原分类a]b] 任务
# 已完成任务
- [✓] [原分类:含→符号] 任务
`;
const cats = parseMarkdown(md);
const workCat = cats.find(c => c.name === '工作');
const workTasks = workCat?.tasks || [];
// 工作分类下应该有一条「[原分类a]b] 任务」→ originalCategory=`a`, text=`b] 任务`
const moved = workTasks.find(t => t.text.startsWith('b]'));
check('[原分类a]b] 在「工作」分类下解析为 originalCategory=a / text="b] 任务"',
moved && moved.originalCategory === 'a' && moved.text === 'b] 任务',
JSON.stringify(moved));
const completedCat = cats.find(c => c.kind === 'completed');
const completedTasks = completedCat?.tasks || [];
const sym = completedTasks.find(t => t.text === '任务');
check('[原分类:含→符号] 在「已完成任务」分类下解析为 originalCategory="含→符号"',
sym && sym.originalCategory === '含→符号' && sym.text === '任务',
JSON.stringify(sym));
}
console.log('\n[9] 空文本任务合法(手写 `- [ ]` / `- [✓]`');
{
// parser 必须接受空文本,否则会被 _ensureBaseStructure 当成纯文本忽略、丢失任务数据
const t1 = firstTask('# X\n\n## A\n\n- [ ]');
check('空文本未完成 task.text=""',
t1 && t1.text === '' && t1.completed === false,
JSON.stringify(t1));
const t2 = firstTask('# X\n\n## A\n\n- [✓]');
check('空文本已完成 task.text="" / completed=true',
t2 && t2.text === '' && t2.completed === true,
JSON.stringify(t2));
// 纯空白文本 → trim 后为空文本,与空文本等价
const t3 = firstTask('# X\n\n## A\n\n- [ ] ');
check('纯空白文本 trim 后变空字符串',
t3 && t3.text === '',
JSON.stringify(t3));
}
console.log('\n[6] 行尾 <!-- id --> 注释被剥掉v3+ 写入器不再输出,仅用于旧文件迁移)');
{
// 锚定行尾之前的 bug正则 `/<!--\s*id:([^\s>]+)\s*-->/` 没有 $ 锚,
// 会从文本中间抠出第一个匹配,把用户的字面字符串删掉。例如:
// `- [ ] 在 markdown 里可以这样写注释:<!-- id:t1 -->示例`
// 旧实现会把 `<!-- id:t1 -->` 部分静默剥掉,留下「示例」。
// 锚定行尾后,只剥写时器挂在末尾的注释。
//
// v3+ 写入器不再写 id 注释,解析器也不再从文件中提取 id 作为任务身份
// task.id 改由末尾的统一计数器分配)。但行尾 id 注释的剥离仍要保留,
// 否则旧文件中的 `<!-- id:t99 -->` 会作为字面字符出现在 UI 文本里。
const t1 = firstTask('# X\n\n## A\n\n- [ ] 写注释示例 <!-- id:t1 -->中间内容');
check('文本中间的 <!-- id:xxx --> 保留为字面文本',
t1 && t1.text === '写注释示例 <!-- id:t1 -->中间内容',
JSON.stringify(t1));
// 行尾的 id 注释被剥掉id 由解析器统一分配(不再是文件里的值)
const t2 = firstTask('# X\n\n## A\n\n- [ ] 普通任务 <!-- id:t99 -->');
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- [ ] 行中<!-- id:t1 -->也有<!-- id:t99 -->');
// 行尾锚 $ 匹配 <!-- id:t99 -->;行中的 <!-- id:t1 --> 保留
check('行中 + 行尾两个 id 注释:剥尾留中',
t3 && typeof t3.id === 'string' && t3.text === '行中<!-- id:t1 -->也有',
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);

View File

@@ -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] 基础字段 roundtriptext / completed / important / current
// ============================================================
console.log('\n[1] 字段 roundtriptext / 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('&lt;'));
}
// ============================================================
// [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.1toggleTask 勾选时**不再**清 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);

View File

@@ -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() : '(继承自父级)');
}

View File

@@ -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);

View File

@@ -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 catmove 接受的是 _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');
// 重排:把 Aidx=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, {}); // 用默认 resolutionsdisk 获胜
// 旧格式的 [✓] 任务应该被 _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);

View File

@@ -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.1picker 路径恢复后同样清掉 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('工作'); // 原分类丢失
// 此时任务在 trashoriginalCategory='工作' 但 '工作' 已不存在
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) 已完成任务删到 trashoriginalCategory 不被覆盖
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 === '学习');
// 删 → 进入 trashoriginalCategory 应保持 '学习'(不被 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);

View File

@@ -0,0 +1,111 @@
// 回归测试H6 — picker 路径尝试次数上限 + 降级为 fallback
//
// 背景toggleTask 在「原分类丢失 → picker 分支」时返回 restoreHintUI 弹
// 选择器让用户选目标分类。但如果用户反复 toggle 同一任务(原分类持续丢
// 失 / 用户反复取消 pickerpicker 会被反复触发,可能进入死循环或内存
// 累积。修复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] 同一任务反复 togglepicker 不收敛)→ 降级为 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('循环内出现 fallbackpicker 降级)',
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 次 pickerpicker 不动任务)
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');

View File

@@ -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 抛错后只 returnlastSeen 保留旧值,下一轮 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) 重建内容完全相同的文件,强制沿用同一 mtimeutimes 显式设置)
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);

View File

@@ -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 当作文本存储,统计 <li> 出现次数作为任务行数
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 不切 originalinsertion 跑在用户文件上、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(/<li/g) || []).length;
console.log(`初始 TaskList 渲染任务行数: ${initTaskLines}`);
check('初始渲染包含任务行', initTaskLines > 0);
// 渲染层会在中西文边界插入空的 <span class="cjk-gap">(盘古之白,见
// check-cjk-autospace.mjs所以混排任务文本在 HTML 里是被间隙元素切开的。
// 下面断言的是「任务文本有没有渲染出来」,不是 HTML 的字节形态 —— 先把间隙元素
// 抹掉再做子串匹配,否则这里会跟着排版逻辑一起假红。
const stripGaps = (h) => h.split('<span class="cjk-gap"></span>').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(/<li/g) || []).length;
console.log(`重载后 TaskList 渲染任务行数: ${reloadedTaskLines}`);
// 渲染层会在中西文边界插入空的 <span class="cjk-gap">(盘古之白,见
// 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);

View File

@@ -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');

View File

@@ -0,0 +1,319 @@
// 第二轮深度审计回归:
//
// 在第一轮修复 (_dragJustEnded) 之后,又排查出多个潜在的「按钮没反应 /
// 反应错」根因。本脚本一次性覆盖所有第二轮修复点,避免再次回归。
//
// 涵盖:
// H1 - focus-trap.js 必须用 getClientRects / visibility 检查,
// 不能继续用 offsetParentposition: fixed 永远 null
// H2 - checkbox 点击input 上的合成 click 必须跳过
// label 触发一次已 toggleinput 的合成 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);

174
scripts/check-settings.mjs Normal file
View File

@@ -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-importsSettingsStore 的模块作用域会注册事件回调,但仅用到
// 内存对象没有副作用。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 propertyObject.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('合法字段生效;老 enumpending→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);

View File

@@ -0,0 +1,156 @@
// file:create-snapshot IPC 的契约级测试
//
// 主进程的 file:create-snapshot handler 用了 ipcMain / Electron 上下文,
// 无法在 Node 里直接调用。这个脚本用「静态分析 + 真实文件操作」两步验证:
// 1) 静态扫描 main.js 验证:
// - VALID_SNAPSHOT_LABELS 包含全部预期 labeldiscard / 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);
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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 rejectUI 走 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(/<svg[^>]*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');

View File

@@ -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=trueoriginalCategory='工作' 保留)→
// 任务自动搬到「已完成任务」分类,再删到回收站
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');

View File

@@ -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;
}
// ---- 不变量 CMarkdown 往返不丢任务 ----
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);

677
scripts/check-trash.mjs Normal file
View File

@@ -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.4DOC 里带 [✓] + ⭐ 的任务在加载时已被搬到「# 已完成任务」分类,
// 不能再去 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('<!--')));
check('回收站不出现在子分类列表里',
!store.getSubCategories().some(c => c.kind === CategoryKind.TRASH),
JSON.stringify(store.getSubCategories().map(c => c.name)));
check('回收站在 categories 末位',
store.categories[store.categories.length - 1].kind === CategoryKind.TRASH,
JSON.stringify(store.categories.map(c => c.name)));
}
// ============================================================
console.log('\n[8] 手工编辑产物:回收站必须收敛成唯一一个,且不丢任务');
{
// 8a: 两个 # 回收站 → 合并成一个,两边的任务都要在
const two = `# 当前任务
- [ ] A
# 回收站
- [ ] 第一个回收站的
# 回收站
- [ ] 第二个回收站的
`;
const s1 = makeStore(two);
const trashes1 = s1.categories.filter(c => c.kind === CategoryKind.TRASH);
check('多个「# 回收站」合并为 1 个', trashes1.length === 1,
`实际 ${trashes1.length}`);
check('两个回收站的任务都保留',
texts(s1.getTrashCategory()).includes('第一个回收站的') &&
texts(s1.getTrashCategory()).includes('第二个回收站的'),
JSON.stringify(texts(s1.getTrashCategory())));
check('合并后总数不变', totalTasks(s1.categories) === 3,
`实际 ${totalTasks(s1.categories)}`);
check('合并后往返仍不丢', totalTasks(roundTrip(s1)) === 3);
// 8b: ## 回收站 被写成子分类 → 提升为 TRASH不能与新建的回收站重名
const asSub = `# 当前任务
- [ ] A
# 全部任务
## 回收站
- [ ] 我被写成了子分类
## 工作
- [ ] W1
`;
const s2 = makeStore(asSub);
check('「## 回收站」被提升为 kind=TRASH',
s2.getTrashCategory()?.kind === CategoryKind.TRASH,
JSON.stringify(s2.categories.map(c => `${c.name}:${c.kind}`)));
check('提升后不再是子分类',
s2.getTrashCategory()?.parentOtherTasks === false);
check('原有任务跟着保留',
texts(s2.getTrashCategory()).includes('我被写成了子分类'),
JSON.stringify(texts(s2.getTrashCategory())));
check('回收站排到了末位',
s2.categories[s2.categories.length - 1].kind === CategoryKind.TRASH,
JSON.stringify(s2.categories.map(c => c.name)));
// 删一个任务后不能出现同名分类
// v3A 在迁移时搬到了首个子分类「工作」(此 DOC 没有「未分类」,所以用 first sub 兜底)
const workCat = s2.getCategory('工作');
const aTask = workCat.tasks.find(t => t.text === 'A');
s2.deleteTask('工作', aTask.id);
const sameName = s2.categories.filter(c => c.name === TRASH_NAME);
check('删除任务后没有产生同名「回收站」分类', sameName.length === 1,
`实际 ${sameName.length}`);
check('被删任务进了唯一的那个回收站',
texts(s2.getTrashCategory()).includes('A'),
JSON.stringify(texts(s2.getTrashCategory())));
check('往返后任务总数不变3 项)', totalTasks(roundTrip(s2)) === 3,
`实际 ${totalTasks(roundTrip(s2))}`);
// 8c: 别名(# trash也要被认成回收站且保留用户的写法
const alias = `# 当前任务
- [ ] A
# trash
- [ ] 英文回收站里的
`;
const s3 = makeStore(alias);
check('别名「# trash」识别为 TRASH',
s3.getTrashCategory()?.kind === CategoryKind.TRASH);
check('别名不丢任务', texts(s3.getTrashCategory()).includes('英文回收站里的'));
}
// ============================================================
console.log('\n[9] 正看着回收站时把它恢复空了 —— 选中项要跟着任务走,不能停在隐藏的分类上');
{
const store = makeStore(DOC);
const today = store.getCategory(TODAY_SUB);
// 只删一条 → 回收站里只有 1 项
store.deleteTask(TODAY_SUB, today.tasks.find(t => t.text === '今天写周报').id);
store.selectCategory(TRASH_NAME);
check('已选中回收站', store.getSelectedCategory()?.name === TRASH_NAME);
// 恢复最后一条 → 回收站空了,侧边栏会隐藏它,选中项必须转移
store.restoreTask(store.getTrashCategory().tasks[0].id, '工作');
check('回收站空后选中项跟到「工作」',
store.getSelectedCategory()?.name === '工作',
`实际停在「${store.getSelectedCategory()?.name}`);
check('不会停在被隐藏的回收站上',
store.getSelectedCategory()?.kind !== CategoryKind.TRASH);
// 还有剩余时不该乱跳
const store2 = makeStore(DOC);
const t2 = store2.getCategory(TODAY_SUB);
store2.deleteTask(TODAY_SUB, t2.tasks[0].id);
store2.deleteTask(TODAY_SUB, t2.tasks[0].id);
store2.selectCategory(TRASH_NAME);
store2.restoreTask(store2.getTrashCategory().tasks[0].id, '工作');
check('回收站还有剩余时保持停留在回收站',
store2.getSelectedCategory()?.name === TRASH_NAME,
`实际跳到「${store2.getSelectedCategory()?.name}`);
// moveTask 走的恢复路径同样要跟随
const store3 = makeStore(DOC);
const t3 = store3.getCategory(TODAY_SUB);
store3.deleteTask(TODAY_SUB, t3.tasks[0].id);
store3.selectCategory(TRASH_NAME);
store3.moveTask(TRASH_NAME, store3.getTrashCategory().tasks[0].id, '未分类');
check('moveTask 清空回收站后也跟随到目标分类',
store3.getSelectedCategory()?.name === '未分类',
`实际停在「${store3.getSelectedCategory()?.name}`);
}
// ============================================================
// [10] 手工编辑文件后重新加载 —— 这是「彻底删除」的唯一途径,绝不能吃任务
//
// 软件刻意不提供「清空回收站」,官方指引就是「自己打开 Markdown 删掉那一行」。
// 所以「手工编辑过的文件」是一等公民路径,它上面的任何一处数据丢失都比普通
// bug 严重:用户是照着我们的说明书操作,结果丢了别的任务。
// ============================================================
{
console.log('\n[10] 手工编辑过的文件重新加载:不吃任务、不误判归属');
// ---- 10a. 旧文件 id 注释 + 手工新增行混合 ----
// 旧文件中残留的 `<!-- id:tN -->`(写时器 v3+ 不再写入,但历史文件可能仍有)
// 会被解析器剥掉,然后所有任务由解析器末尾的统一计数器重新分配 t1/t2/...
// 这里验证:手工新增的没有 id 注释的行也能被正确收进来,不被忽略或丢弃。
const HAND_EDITED = [
'# 当前任务', '',
'- [ ] 任务一 <!-- id:t1 -->',
'- [ ] 手写任务甲',
'- [ ] 任务三 <!-- id:t3 -->',
'- [ ] 手写任务乙', '',
'# 回收站', '',
'- [ ] 我被删过 <!-- id:t2 -->', ''
].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 = [
'# 当前任务', '',
'- [ ] 原始 <!-- id:t1 -->',
'- [ ] 复制出来的 <!-- id:t1 -->', ''
].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('<!-- 顶部摘要'), md.split('\n').slice(0, 6).join('\n'));
// v3.4 起仍不输出 # 已完成任务 镜像段 —— 它是真实分类kind=COMPLETED
// 段下任务由 store 自动搬入(这里没有已完成任务,所以不写)。
check('写入器不输出「# 已完成任务」镜像段(无人打过勾)',
!md.includes('# 已完成任务'), md);
// v3重要任务由 ⭐ inline 标识驱动,标识位置仍在每条任务行末
check('任务的 ⭐ 标识原样保留在文件中',
md.includes('⭐'), md);
const rt = parseMarkdown(md);
// 「做完了」在回收站里,源任务三条;新格式下镜像段不存在,不会翻倍。
check('v3.6 文件往返后总数仍是 3已删除那条在回收站、未被翻倍计入',
rt.reduce((n, c) => n + c.tasks.length, 0) === 3,
rt.flatMap(c => c.tasks.map(t => t.text)).join(' | '));
check('已删除的「做完了」仍在回收站',
rt.find(c => c.kind === CategoryKind.TRASH)?.tasks.some(t => t.text === '做完了'));
// 二次往返:确认不是「刚好第一次没翻倍」
const s2 = makeStore(md);
check('二次往返总数仍为 3',
parseMarkdown(s2.serialize()).reduce((n, c) => n + c.tasks.length, 0) === 3);
// ---- 11b. 旧文件里残留的「# 重要任务」段被消化 —— __skip__ 整段跳过 ----
// 这是手工升级路径最重要的保证:旧文件被打开 → 解析 → 第一次保存后扁平化。
// 「# 重要任务」作为 SMART_LIST_KEYS 命中被 parser 整体跳过;段下手写行落到「未分类」
// (保守策略 —— 宁可多一条需要用户挪动的任务,也不要少一条)。
//
// 「# 已完成任务」v3.4 起是真实分类kind=COMPLETED段下带 [✓] 的任务归入并标记 completed。
const LEGACY_FILE = [
'# 全部任务', '',
'## 工作', '',
'- [ ] 工作任务A', '',
'# 重要任务', '',
'- [ ] 镜像段里的工作A', '',
'# 已完成任务', '',
'- [x] 镜像段里的已完成A', '',
'# 回收站', '',
'- [ ] 已删除A', ''
].join('\n');
const legacy = parseMarkdown(LEGACY_FILE);
const allTexts = legacy.flatMap(c => c.tasks.map(t => t.text));
check('「# 重要任务」旧镜像段没有进入 categories',
!legacy.some(c => c.name === '重要任务'),
legacy.map(c => c.name).join(' / '));
const legacyCompleted = legacy.find(c => c.name === '已完成任务');
check('「# 已完成任务」是真实分类kind=COMPLETED',
!!legacyCompleted && legacyCompleted.kind === CategoryKind.COMPLETED,
legacy.map(c => `${c.name}(${c.kind})`).join(' / '));
check('旧镜像段下带 [✓] 的「镜像段里的已完成A」进入「# 已完成任务」并带 completed=true',
legacyCompleted?.tasks?.some(t => t.text === '镜像段里的已完成A' && t.completed === true),
legacyCompleted?.tasks?.map(t => `${t.text}(${t.completed})`).join(' | '));
check('真实分类里的任务不丢',
allTexts.includes('工作任务A') && allTexts.includes('已删除A'),
allTexts.join(' | '));
// 「# 重要任务」镜像段下的手写行落到「未分类」—— 4 条全部保留
check('「# 重要任务」镜像段下的手写任务也未丢,归入「未分类」',
allTexts.length === 4 &&
allTexts.includes('镜像段里的工作A') &&
allTexts.includes('镜像段里的已完成A'),
allTexts.join(' | '));
// ---- 11c. 旧文件把「# 当前任务」段放到文件前面 —— TODAY 迁移路径 ----
//
// v3.6 解析器对用户手敲的 `# 当前任务` 仍然识别为 TODAY 分类(兼容旧文件),
// store 的 _migrateTodayCategory 会把任务搬到首个子分类、打上 [▶] 标记、
// 删除临时分类 —— 不会丢数据。
const REORDERED = [
'# 已完成任务', '',
'<!-- 所有已勾选的任务,从各分类自动聚合 -->', '',
'- [✓] 做完了 <!-- id:t1 -->', '',
'# 当前任务', '',
'- [✓] 做完了 <!-- id:t1 -->',
'- [ ] 没做完 <!-- id:t2 -->', ''
].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);

View File

@@ -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啊的撒发生
# 已完成任务
<!-- [✓] 打勾的任务会自动移到本节取消勾选后会回到它原来的分类。需要彻底归档请用「移到回收站」Delete 键)。 -->
- [✓] 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);

View File

@@ -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);

View File

@@ -0,0 +1,320 @@
// 测试 file:write 的 race 防护main.js v6 方案)
//
// 真实场景:
// T0 软件内存 V0磁盘 V0lastSeen = V0
// T1 用户在 UI 改任务 → dirty=true800ms autoSave 排队
// T1 外部编辑器原子保存 → 文件变成 V_ext
// T2 autoSave 触发 → serialize() → 走 writeFile IPC
//
// 旧实现writeFile 只做一次 statSync 检查pre-checkpre-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) {
// 第一次 statpre-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 仍是 0A 的 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('旧实现确实有 bugB 的 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);

36
scripts/hoist-svgs.mjs Normal file
View File

@@ -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 = `<svg viewBox="0 0 24 24"[^`]*<\/svg>`;/;
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 = `<svg viewBox="0 0 24 24"[^`]*<\/svg>`;/;
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 = `<svg viewBox="0 0 24 24"[^`]*<\/svg>`;\s*\n\s*const editPencilSvg = `<svg viewBox="0 0 24 24"[^`]*<\/svg>`;/;
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 = `<svg viewBox="0 0 24 24"[^`]*<\/svg>`;\s*\n\s*const deleteXSvg = `<svg viewBox="0 0 24 24"[^`]*<\/svg>`;/;
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 = `<svg viewBox="0 0 24 24"[^`]*<\/svg>`;/;
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);

88
scripts/launch.js Normal file
View File

@@ -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));
});

View File

@@ -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 永远不会输出 <!-- id:
// ============================================================
console.log('\n[10] writer 永不输出 <!-- id:');
{
const md = `# 全部任务\n\n## 工作\n\n- [ ] a\n- [✓] [▶] b\n`;
const cats = parseMarkdown(md);
const written = writeMarkdown(cats);
check('写入器无 <!-- id', !written.includes('<!-- id:'),
written);
// 即使用 task.id 也是字符串writer 也不该写
cats[1].tasks[0].id = 'some-injected-id';
const written2 = writeMarkdown(cats);
check('即便 task.id 是任意字符串也不写', !written2.includes('<!-- id:'),
written2);
}
// ============================================================
// [11] 旧文件迁移(包含 <!-- id -->)→ 加载干净
// ============================================================
console.log('\n[11] 旧文件迁移:含 <!-- id -->');
{
const old = `# 全部任务\n\n## 工作\n\n- [ ] 旧任务 <!-- id:t99 -->\n- [ ] 普通任务\n`;
const cats = parseMarkdown(old);
const work = cats.find(c => c.name === '工作');
check('旧 id 注释被剥掉,文本干净', work.tasks.every(t => !t.text.includes('<!--')),
work.tasks.map(t => t.text).join(' | '));
check('id 由解析器分配(不再是 t99',
work.tasks.every(t => t.id && t.id !== 't99'),
work.tasks.map(t => t.id).join(','));
// 写回 → 不包含 <!-- id
const written = writeMarkdown(cats);
check('写回后无 <!-- id:', !written.includes('<!-- id:'));
}
// ============================================================
// [12] 删除/恢复仍然能工作(基于运行时 id
// ============================================================
console.log('\n[12] 删除→恢复(基于运行时 id');
{
const store = makeStore(`# 全部任务\n\n## 工作\n\n- [ ] a\n- [ ] b\n- [ ] c\n`);
const work = store.getCategory('工作');
const bId = work.tasks.find(t => t.text === 'b').id;
// 删 b
const ok = store.deleteTask('工作', bId);
check('deleteTask 返回 true', ok === true);
check('工作剩 a/c', store.getCategory('工作').tasks.map(t => t.text).join(',') === 'a,c');
// 从回收站恢复 —— v3.5.2+:默认按 originalCategory 归位(删除 b 时已记下"工作"
const restored = store.restoreTask(bId);
check('restoreTask 返回 { ok: true }', restored.ok === true);
const trash = store.getTrashCategory();
check('回收站空了', trash.tasks.length === 0);
// 默认归到「工作」deleteTask 时记下的原分类);本测试文档没有「未分类」子分类
check('b 按原分类归位到「工作」',
store.getCategory('工作').tasks.some(t => t.text === 'b'),
JSON.stringify(store.getCategory('工作').tasks.map(t => t.text)));
}
// ============================================================
// [13] 跨分类移动(基于 runtime id
// ============================================================
console.log('\n[13] 跨分类移动(基于 runtime id');
{
const store = makeStore(`# 全部任务\n\n## 工作\n\n- [ ] a\n- [ ] b\n\n## 学习\n\n- [ ] c\n`);
const work = store.getCategory('工作');
const aId = work.tasks.find(t => t.text === 'a').id;
const ok = store.moveTask('工作', aId, '学习');
// moveTask 现在返回实际生效的目标分类名(防御性改投语义),不再是布尔。
// 普通路径:返回的就是用户传的目标分类。
check('moveTask 返回「学习"(普通路径无改投)', ok === '学习');
check('工作剩 b', store.getCategory('工作').tasks.map(t => t.text).join(',') === 'b');
// 默认 movePosition='front':移过来的任务落到目标分类最前面 → a,c
check('学习有 a/c默认 movePosition=front', store.getCategory('学习').tasks.map(t => t.text).join(',') === 'a,c');
}
// ============================================================
// [14] 拖拽重排序(基于 runtime id
// ============================================================
console.log('\n[14] 重排序(基于 runtime id');
{
const store = makeStore(`# 全部任务\n\n## 工作\n\n- [ ] a\n- [ ] b\n- [ ] c\n`);
const work = store.getCategory('工作');
const aId = work.tasks[0].id;
const bId = work.tasks[1].id;
// splice 语义toIndex 是「移除前」的位置。
// 起始 [a, b, c]from=0, to=2 → splice(0,1) 得 [b,c];再 splice(2,0,a) 得 [b,c,a]
const ok = store.reorderTask('工作', 0, 2);
check('reorderTask 返回 true', ok === true);
check('顺序变为 b/c/asplice 语义toIndex 是移除前位置)',
store.getCategory('工作').tasks.map(t => t.text).join(',') === 'b,c,a');
// 拖到末尾toIndex === length 也合法commit 中明确放行)
// 当前 [b, c, a],把 b位置 0拖到末尾位置 3
// splice(0,1) → [c,a]splice(3,0,b) → [c,a,b]
const ok2 = store.reorderTask('工作', 0, 3);
check('把 b位置 0拖到 length 位置 → 顺序变为 c/a/b',
ok2 === true &&
store.getCategory('工作').tasks.map(t => t.text).join(',') === 'c,a,b');
}
// ============================================================
// [15] 外部编辑文件时自动切回 SortBy.MANUAL用户原 bug 报告)
// ============================================================
console.log('\n[15] 外部编辑文件 → 自动切回 MANUAL让文件顺序生效');
{
// 用户在软件里切到了「按字母排序」
const initial = `# 全部任务\n\n## 工作\n\n- [ ] cherry\n- [ ] apple\n- [ ] banana\n`;
const store = makeStore(initial);
store.setSortBy('alphabet');
check('设置 sortBy=alphabet 后生效',
store.sortBy === 'alphabet');
// 用户在 markdown 里改成「按她想要的顺序」存盘 → fs.watch 触发 reload
const reordered = `# 全部任务\n\n## 工作\n\n- [ ] banana\n- [ ] cherry\n- [ ] apple\n`;
store.loadFromContent(reordered, null);
// 关键sortBy 必须切回 MANUAL否则 getVisibleTasks 会按字母重排
check('loadFromContent 后 sortBy 自动切回 manual',
store.sortBy === 'manual',
`sortBy=${store.sortBy}`);
// 现在 UI 渲染的顺序就是文件里的顺序
const visible = store.getVisibleTasks(store.getCategory('工作'));
check('UI 可见顺序按文件顺序banana/cherry/apple',
visible.map(t => t.text).join(',') === 'banana,cherry,apple',
visible.map(t => t.text).join(','));
}
// ============================================================
// [16] 默认 sortBy=MANUAL 时,外部编辑不会无谓触发 sort 事件
// ============================================================
console.log('\n[16] sortBy=MANUAL 时 reload 不发 sort 事件(避免误触发 UI');
{
const initial = `# 全部任务\n\n## 工作\n\n- [ ] a\n- [ ] b\n`;
const store = makeStore(initial);
let sortEventCount = 0;
store.on('sort', () => sortEventCount++);
store.loadFromContent(`# 全部任务\n\n## 工作\n\n- [ ] b\n- [ ] a\n`, null);
check('sortBy=MANUAL 时不触发 sort 事件',
sortEventCount === 0,
`sortEventCount=${sortEventCount}`);
}
// ============================================================
// [17] 自写入抑制:渲染端 readFile + serialize() 比对
// (用户原 bug外部编辑内容修改后软件没及时更新
// ============================================================
console.log('\n[17] 自写入抑制fs.watch → readFile → serialize() 比对');
{
// 用户原 bug1.5s 时间窗口会吞掉用户紧接着的外部保存。
// 修复方案主进程不过滤fs.watch 时序不可靠),所有事件发 IPC。
// 渲染端 readFile 后与 store.serialize() 比对 —— 完全相同视为
// 自己写入的产物静默;任何字节差异视为外部修改正常 reload + toast。
// (a) 模拟「autoSave 触发的写入」:文件内容 === serialize() → 静默
const initial = `# 全部任务\n\n## 工作\n\n- [ ] a\n- [ ] b\n`;
const store = makeStore(initial);
// 模拟 autoSave 完成后的状态:内存中 a/b 的顺序serialize 输出与文件一致
const fileContentAfterAutoSave = store.serialize();
check('autoSave 后文件内容 === serialize()',
fileContentAfterAutoSave === initial,
`serialize=${JSON.stringify(fileContentAfterAutoSave)}`);
// (b) 模拟「用户在 markdown 里改了内容」:文件内容 !== serialize() → reload
const userEdited = `# 全部任务\n\n## 工作\n\n- [ ] b\n- [ ] a\n- [ ] user-added\n`;
check('用户编辑后内容 !== serialize() → 触发 reload',
userEdited !== store.serialize());
// (c) 模拟「外部修改文本」:任何字节差异都能识别
const tinyEdit = `# 全部任务\n\n## 工作\n\n- [ ] a\n- [ ] b 修改\n`;
check('用户改一个字也能识别',
tinyEdit !== store.serialize());
// (d) 用户删除任务:内容差异极大
const deletedOne = `# 全部任务\n\n## 工作\n\n- [ ] a\n`;
check('用户删除一条任务也能识别',
deletedOne !== store.serialize());
// (e) 极端情况:用户外部保存后内容恰好 === serialize()(误判为自写入)
// 等价于「用户在外部编辑器里做了与内存状态完全一致的内容」—— 实际
// 等同于用户没改。这种情况下渲染端会静默(不弹 toast但用户也
// 确实没改东西,无副作用。可接受。
const sameAsSerialize = store.serialize();
check('误判条件(用户保存内容 === serialize可触发但等价于没改',
sameAsSerialize === store.serialize());
}
console.log(`\n通过 ${summary.pass} / 失败 ${summary.fail}`);
process.exit(summary.fail === 0 ? 0 : 1);

341
scripts/smoke-ui-parity.mjs Normal file
View File

@@ -0,0 +1,341 @@
// 全面 round-trip 一致性UI 显示 ↔ markdown 完全对应
//
// 用户要求:软件里看到的每条任务、分类、状态、顺序,都必须能在 markdown 文件里找到,
// 反过来 markdown 里的每条任务、分类、状态、顺序也都必须能在 UI 里看到。
//
// 本测试覆盖:
// 1. 文本逐字符一致含中文、emoji、Markdown 强调、代码、链接、特殊空白)
// 2. 状态(⭐/▶/[✓])映射一致
// 3. 顺序一致
// 4. 分类名称一致含空格、CJK、emoji
// 5. parser → writer → parser 幂等
// 6. writer → parser 不会丢任务
// 7. 空文本任务、纯空白任务、特殊字符任务保留
import { parseMarkdown, CategoryKind } from '../src/markdown-parser.js';
import { writeMarkdown, createDefaultDoc } from '../src/markdown-writer.js';
import { check, summary, printSummary } from './_lib/check.mjs';
const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
console.log('\n[1] 文本逐字符一致:所有字符(包括 Markdown 强调、链接、emoji原样保留');
{
const cases = [
// 普通中文
'买菜',
// emoji
'🐛 fix login bug',
'🎉 发布 v3',
// Markdown 强调(不应被解析为语法,只作为普通文本)
'**加粗**',
'*斜体*',
'`代码片段`',
'~~删除线~~',
// Markdown 链接
'看 [文档](https://example.com)',
// 多种引号
'"英文双引号"',
"'英文单引号'",
'「中文双引号」',
'『中文嵌套引号』',
// 反引号、管道符
'`code`',
'a | b | c',
// 数字、英文混合
'RTFM 101',
// 多空格:解析器只 trim 首尾,不主动合并中间空格(除非遇到 ⭐/▶ 走 .replace(/\s+/g, ' ')
'前后 多空 格',
// 中英混合
'完成 Q3 项目设计文档',
// 反斜杠
'路径 C:\\Users\\test',
// URL
'https://github.com/foo/bar?x=1&y=2#hash',
// 数学符号
'计算 a² + b² = c²',
// 特殊 CJK
'한글 테스트',
'日本語テスト',
];
for (const text of cases) {
const md = `# 全部任务\n\n## 工作\n\n- [ ] ${text}\n`;
const cats = parseMarkdown(md);
const got = cats.find(c => c.name === '工作').tasks[0].text;
// 解析器只 trim 首尾空白;只有 ⭐/▶ 标识走过的代码路径才会 .replace(/\s+/g, ' ')
const expected = text.replace(/^\s+|\s+$/g, '');
check(`文本保留:${text.slice(0, 30)}${text.length > 30 ? '...' : ''}`, got === expected,
`got=${JSON.stringify(got)} expected=${JSON.stringify(expected)}`);
}
}
console.log('\n[2] 状态标识映射UI 看到的 ↔ markdown 写的');
{
const cases = [
{ md: '- [ ] 普通任务', expected: { completed: false, important: false, current: false } },
{ md: '- [✓] 已完成', expected: { completed: true, important: false, current: false } },
{ md: '- [ ] [⭐] 重要', expected: { completed: false, important: true, current: false } },
{ md: '- [ ] [▶] 当前', expected: { completed: false, important: false, current: true } },
{ md: '- [ ] [▶] [⭐] 重要且当前', expected: { completed: false, important: true, current: true } },
{ md: '- [✓] [▶] [⭐] 三状态', expected: { completed: true, important: true, current: true } },
// 兼容旧标记
{ md: '- [ ] ⭐ 旧版重要', expected: { completed: false, important: true, current: false } },
{ md: '- [ ] ▶ 旧版当前', expected: { completed: false, important: false, current: true } },
{ md: '- [ ] @当前 旧版当前', expected: { completed: false, important: false, current: true } },
// [x] / [X] 也算完成
{ md: '- [x] x 完成', expected: { completed: true, important: false, current: false } },
{ md: '- [X] X 完成', expected: { completed: true, important: false, current: false } },
];
for (const { md, expected } of cases) {
const cats = parseMarkdown(`# 全部任务\n\n## 工作\n\n${md}\n`);
const task = cats.find(c => c.name === '工作').tasks[0];
const got = { completed: task.completed, important: task.important, current: task.current };
check(`状态:${md}`, eq(got, expected), `got=${JSON.stringify(got)}`);
}
}
console.log('\n[3] 顺序完全保留(无 sort');
{
// 用一些字母接近的文本,确认不会按字母排
const md = `# 全部任务\n\n## 工作\n\n- [ ] zebra\n- [ ] apple\n- [ ] mango\n- [ ] banana\n- [ ] cherry\n`;
const cats = parseMarkdown(md);
const texts = cats.find(c => c.name === '工作').tasks.map(t => t.text);
check('文本顺序zebra, apple, mango, banana, cherry',
eq(texts, ['zebra', 'apple', 'mango', 'banana', 'cherry']),
texts.join(','));
// 跨分类顺序也保留
const md2 = `# 全部任务\n\n## 工作\n\n- [ ] 工作 2\n- [ ] 工作 1\n\n## 学习\n\n- [ ] 学习 3\n- [ ] 学习 1\n- [ ] 学习 2\n`;
const cats2 = parseMarkdown(md2);
check('工作内顺序', eq(cats2.find(c => c.name === '工作').tasks.map(t => t.text),
['工作 2', '工作 1']));
check('学习内顺序', eq(cats2.find(c => c.name === '学习').tasks.map(t => t.text),
['学习 3', '学习 1', '学习 2']));
}
console.log('\n[4] 分类名称完全保留含空格、CJK、emoji、特殊字符');
{
const cases = [
'工作',
'工作 学习',
' 带空格 ', // trim 后变「带空格」
'分类-A',
'分类 A',
'中文 / 子分类',
'🐛 bugs',
'🎉 发布',
'123 数字',
];
for (const name of cases) {
const md = `# 全部任务\n\n## ${name}\n\n- [ ] 任务\n`;
const cats = parseMarkdown(md);
const trimmed = name.replace(/^\s+|\s+$/g, '');
const found = cats.find(c => c.name === trimmed);
check(`分类名「${name}`, found !== undefined,
cats.map(c => c.name).join(' | '));
}
}
console.log('\n[5] parser → writer → parser 幂等:往返不丢任何信息');
{
const inputs = [
// 简单
`# 全部任务\n\n## 工作\n\n- [ ] a\n- [✓] b\n- [ ] [⭐] c\n- [ ] [▶] d\n`,
// 多分类
`# 全部任务\n\n## 工作\n\n- [ ] 工作 1\n- [ ] 工作 2\n\n## 学习\n\n- [ ] 学习 1\n\n## 生活\n\n- [ ] 生活 1\n- [✓] 生活 2\n`,
// 含空文本任务
`# 全部任务\n\n## 工作\n\n- [ ]\n- [ ] 有文本\n- [✓]\n`,
// 含旧版标记
`# 全部任务\n\n## 工作\n\n- [ ] ⭐ 旧版\n- [ ] @当前 旧版\n`,
// 含 emoji
`# 全部任务\n\n## 工作\n\n- [ ] 🐛 fix bug\n- [ ] 🎉 release\n`,
];
for (const [i, input] of inputs.entries()) {
const cats1 = parseMarkdown(input);
const md1 = writeMarkdown(cats1);
const cats2 = parseMarkdown(md1);
const md2 = writeMarkdown(cats2);
// markdown 完全一致(往返幂等)
check(`[${i}] markdown 往返一致 (md1 === md2)`, md1 === md2,
`md1=${JSON.stringify(md1)} md2=${JSON.stringify(md2)}`);
// 任务数一致
const n1 = cats1.reduce((s, c) => s + c.tasks.length, 0);
const n2 = cats2.reduce((s, c) => s + c.tasks.length, 0);
check(`[${i}] 任务数不变`, n1 === n2, `${n1}${n2}`);
// 任务 (text, completed, important, current) 元组完全一致
const flat1 = cats1.flatMap(c => c.tasks.map(t => ({t: t.text, c: t.completed, i: t.important, r: t.current})));
const flat2 = cats2.flatMap(c => c.tasks.map(t => ({t: t.text, c: t.completed, i: t.important, r: t.current})));
check(`[${i}] 任务元组一致`, eq(flat1, flat2),
`\n flat1=${JSON.stringify(flat1)}\n flat2=${JSON.stringify(flat2)}`);
}
}
console.log('\n[6] writer → parser 不丢任务:写出去再读回来,任务全在');
{
// 起点:构造一份较复杂的 categories
const initial = parseMarkdown(`# 全部任务\n\n## 工作\n\n- [ ] a\n- [✓] b\n- [ ] [⭐] c\n- [ ] [▶] d\n- [ ] [▶] [⭐] e\n\n## 学习\n\n- [ ] 旧标记 ⭐\n- [ ] 旧标记 @当前\n\n## 未分类\n\n- [ ] 孤儿任务\n`);
const written = writeMarkdown(initial);
// 写出去再读
const reloaded = parseMarkdown(written);
// 验证:所有任务都在
const initialTexts = initial.flatMap(c => c.tasks.map(t => t.text));
const reloadedTexts = reloaded.flatMap(c => c.tasks.map(t => t.text));
check('所有任务文本都回来', initialTexts.every(t => reloadedTexts.includes(t)),
`\n lost=${JSON.stringify(initialTexts.filter(t => !reloadedTexts.includes(t)))}`);
check('任务数一致', initialTexts.length === reloadedTexts.length);
// 验证:所有状态都回来(用 (分类名, 任务索引) 配对,避免同名任务撞)
for (const cat of initial) {
cat.tasks.forEach((t, i) => {
const reloadedCat = reloaded.find(c => c.name === cat.name);
const found = reloadedCat?.tasks[i];
check(`状态:${cat.name}[${i}]「${t.text}`,
found && found.text === t.text &&
found.completed === t.completed &&
found.important === t.important &&
found.current === t.current,
found && `expected=${JSON.stringify({c:t.completed,i:t.important,r:t.current})} got=${JSON.stringify({c:found.completed,i:found.important,r:found.current})}`);
});
}
}
console.log('\n[7] 空文本任务(合法手写)');
{
// 软件允许「纯勾选无文本」的任务
const md = `# 全部任务\n\n## 工作\n\n- [ ]\n- [ ] 有文本\n- [✓]\n`;
const cats = parseMarkdown(md);
const tasks = cats.find(c => c.name === '工作').tasks;
check('3 条任务都在', tasks.length === 3);
check('第 1 条空文本', tasks[0].text === '' && tasks[0].completed === false);
check('第 2 条文本', tasks[1].text === '有文本');
check('第 3 条已完成空文本', tasks[2].text === '' && tasks[2].completed === true);
// 往返
const written = writeMarkdown(cats);
const reloaded = parseMarkdown(written);
const reloadedTasks = reloaded.find(c => c.name === '工作').tasks;
check('往返后任务数仍 3', reloadedTasks.length === 3);
check('往返后空文本保留', reloadedTasks[0].text === '');
check('往返后已完成保留', reloadedTasks[2].completed === true);
}
console.log('\n[8] 多行/换行处理:单行任务强制要求');
{
// 任务文本中嵌入换行 → 写时器会压成单行,读时器会把「多出来的行」当新分类/任务
// 这是已知行为但需要测试UI 任务数 = markdown 任务数
const md = `# 全部任务\n\n## 工作\n\n- [ ] 任务 A\n- [ ] 任务 B\n`;
const cats = parseMarkdown(md);
check('基本多任务解析', cats.find(c => c.name === '工作').tasks.length === 2);
}
console.log('\n[9] BOM 处理UTF-8 BOM 不影响解析');
{
const md = '\uFEFF# 全部任务\n\n## 工作\n\n- [ ] a\n- [ ] b\n';
const cats = parseMarkdown(md);
check('BOM 后能正常解析', cats.find(c => c.name === '工作').tasks.length === 2,
cats.map(c => `${c.name}(${c.tasks.length})`).join(' | '));
}
console.log('\n[10] CRLF / LF / CR换行符归一化');
{
const lf = `# 全部任务\n\n## 工作\n\n- [ ] a\n- [ ] b\n`;
const crlf = `# 全部任务\r\n\r\n## 工作\r\n\r\n- [ ] a\r\n- [ ] b\r\n`;
const catsLf = parseMarkdown(lf);
const catsCrlf = parseMarkdown(crlf);
check('LF 解析', catsLf.find(c => c.name === '工作').tasks.length === 2);
check('CRLF 解析', catsCrlf.find(c => c.name === '工作').tasks.length === 2);
}
console.log('\n[11] 分类 kind 一致性UI 视角的「全部任务容器/子分类/回收站」kind 不变');
{
const md = `# 全部任务\n\n## 工作\n\n- [ ] a\n\n# 回收站\n\n- [ ] 已删\n`;
const cats = parseMarkdown(md);
const container = cats.find(c => c.kind === CategoryKind.OTHER_TASKS);
const sub = cats.find(c => c.parentOtherTasks);
const trash = cats.find(c => c.kind === CategoryKind.TRASH);
check('容器:全部任务', container && container.name === '全部任务');
check('子分类:工作', sub && sub.name === '工作');
check('回收站:回收站', trash && trash.name === '回收站');
check('子分类.parentOtherTasks=true', sub.parentOtherTasks === true);
// 往返
const written = writeMarkdown(cats);
const reloaded = parseMarkdown(written);
check('往返后容器仍为容器', reloaded.some(c => c.kind === CategoryKind.OTHER_TASKS));
check('往返后子分类仍是子分类', reloaded.some(c => c.parentOtherTasks));
check('往返后回收站仍是回收站', reloaded.some(c => c.kind === CategoryKind.TRASH));
}
console.log('\n[12] 创建默认文档4 个子分类 + 容器');
{
const cats = createDefaultDoc();
const container = cats.find(c => c.kind === CategoryKind.OTHER_TASKS);
const subs = cats.filter(c => c.parentOtherTasks);
check('容器名为「全部任务」', container && container.name === '全部任务');
check('默认 4 个子分类', subs.length === 4,
`subs=${subs.map(s => s.name).join(',')}`);
for (const name of ['工作', '学习', '生活', '未分类']) {
check(`子分类 ${name} 存在`, subs.some(s => s.name === name));
}
}
console.log('\n[13] 重要 + 当前 → 智能视图聚合v3 不写镜像段,但聚合正确)');
{
// UI 上看「重要任务」智能视图:所有 important=true 的任务
// UI 上看「当前任务」智能视图:所有 current=true 的任务
// UI 上看「已完成任务」智能视图:所有 completed=true 的任务
const md = `# 全部任务\n\n## 工作\n\n- [ ] [⭐] a\n- [ ] b\n- [ ] [▶] c\n\n## 学习\n\n- [✓] [⭐] d\n- [ ] e\n- [✓] f\n`;
const cats = parseMarkdown(md);
const all = cats.flatMap(c => c.tasks);
const important = all.filter(t => t.important).map(t => t.text).sort();
const current = all.filter(t => t.current).map(t => t.text).sort();
const completed = all.filter(t => t.completed).map(t => t.text).sort();
check('重要任务视图a, d', eq(important, ['a', 'd']), important.join(','));
check('当前任务视图c', eq(current, ['c']), current.join(','));
check('已完成任务视图d, f', eq(completed, ['d', 'f']), completed.join(','));
}
console.log('\n[14] 同一文本多份:每份都是独立任务');
{
const md = `# 全部任务\n\n## 工作\n\n- [ ] 买菜\n- [ ] 买菜\n- [ ] 买菜\n`;
const cats = parseMarkdown(md);
const tasks = cats.find(c => c.name === '工作').tasks;
check('3 条同名任务都在', tasks.length === 3);
check('id 各不相同',
new Set(tasks.map(t => t.id)).size === 3,
tasks.map(t => t.id).join(','));
}
console.log('\n[15] 行中行尾混合的 <!-- id -->:行中保留为字面文本,行尾剥掉');
{
// 行中:保留(用户可能写文档示例)
// 行尾剥掉v3 不写,但旧文件兼容)
const md = `# 全部任务\n\n## 工作\n\n- [ ] 看这里 <!-- id:t1 --> 中间内容 <!-- id:t99 -->\n`;
const cats = parseMarkdown(md);
const t = cats.find(c => c.name === '工作').tasks[0];
// 行尾的剥掉,行中的保留
check('行中保留 + 行尾剥掉',
t.text === '看这里 <!-- id:t1 --> 中间内容',
JSON.stringify(t.text));
// 解析器对每个加载文件从 t1 开始,单任务就是 t1运行时分配非文件中的 t1
check('id 是「工作」分类第一条任务的运行时 id', t.id === 't1', `t.id=${t.id}`);
// 写出去:行尾的 <!-- id:t99 --> 已被剥掉;行中的 <!-- id:t1 --> 是用户文本的一部分,保留
const written = writeMarkdown(cats);
check('写出去无行尾 <!-- id:(行中是字面文本保留)',
!/<!--\s*id:[^\s>]+\s*-->$/.test(written.split('\n').find(l => l.startsWith('- '))),
written);
// 行中那串 `<!-- id:t1 -->` 仍在写出去的文本里(作为字面字符)
check('行中 <!-- id:t1 --> 作为字面文本保留', written.includes('<!-- id:t1 -->'), written);
}
console.log(`\n通过 ${summary.pass} / 失败 ${summary.fail}`);
process.exit(summary.fail === 0 ? 0 : 1);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 <path> 命令行参数CI / 自定义)
// 2) TODO_LIST_FILE 环境变量
// 3) 当前用户的真实 todo.md~/TodoList/todo.md跨平台一致
// 4) 内置 SAMPLE_MD —— 跑端到端往返但**不**回写,避免污染未知位置的文件
// 脚本会备份原文件到 <todo.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);

1276
src/app.js Normal file

File diff suppressed because it is too large Load Diff

56
src/event-emitter.js Normal file
View File

@@ -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); }
});
}
}

834
src/markdown-parser.js Normal file
View File

@@ -0,0 +1,834 @@
// Markdown 解析器
// 将 Markdown 文本解析为分类与任务树
//
// 结构v3 统一为标识驱动v3.6 起移除顶部镜像段):
// - 全部任务kind=OTHER_TASKS容器兼容旧名「其他任务」/「待办任务」/「全部任务」)
// ## 工作 / ## 学习 / ## 生活 / ## 未分类kind=NORMAL, parentOtherTasks=true
// - 回收站kind=TRASH特殊删除的任务落点软件不提供清空入口
// - 已完成任务kind=COMPLETEDv3.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 命中被解析器整体跳过,
// 段下任务行归入「未分类」子分类(保守策略)。
// - 行尾 `<!-- id:xxx -->` 注释(旧版写入器自动追加)会被剥掉以避免
// 污染 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:xxx -->` 注释
* - 解析器也不再从文本里还原 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.jsmarkdown-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 BOMWindows 上一些编辑器会加)。
// 不去的话 ^#\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('<!--')) {
// 单行 / 多行注释起始直接跳过HTML 注释对解析器透明)。
if (!trimmed.includes('-->')) {
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);
// 行尾的 `<!-- id:xxx -->` 注释v3 之前写入器会自动追加)。
// 写入器已不再生成,所以新文件不会有;但旧文件加载时仍要剥掉,
// 否则这些字符会作为任务文本的一部分显示在 UI 上。
//
// 锚定行尾:用户可能手敲 `<!-- id:t1 -->` 作为任务文本的一部分
// (比如示例代码、文档片段)。无锚定时正则会从文本中间抠出第一个
// 匹配,悄无声息地把用户的字串删掉 —— 是低门槛的数据损坏 vector。
// 锚 $ 后只剥末尾的那一个。
//
// 注意v3+ 不再从文件里提取 id 作为任务身份。task.id 由本函数末尾
// 的统一计数器分配,跨重载重新生成。详见 end of parseMarkdown()。
text = text.replace(/<!--\s*id:[^\s>]+\s*-->$/, '').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]` 整个当成 markermarker='原分类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(/<!--\s*id:[^\s>]+\s*-->$/, '').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-writerv3+ 不再写 `<!-- id:... -->` 注释,所以解析器也不再
// 从文本里提取。任务在内存中的身份由本循环统一从 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 };
}

278
src/markdown-writer.js Normal file
View File

@@ -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是运行时句柄单次加载周期内稳定跨重载重新分配。
// 文件里不再写 `<!-- 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 = '<!-- [✓] 打勾的任务会自动移到本节取消勾选后会回到它原来的分类。需要彻底归档请用「移到回收站」Delete 键)。 -->';
/**
* 将分类列表序列化为 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] → 顺序固定(用户偏好:所有"元信息"
* 集中在文本之前,文件里一眼扫完一行就知道这条任务的全部标记状态)
* - 不再写 `<!-- id:... -->`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)
];
}

3
src/package.json Normal file
View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

226
src/settings-store.js Normal file
View File

@@ -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每日自动备份开关。默认开启开启后首次使用当天
* 自动在 <dataDir>/backup/ 下生成 todo-YYYY-MM-DD.md。
* 主进程单独维护 lastBackupDateYYYY-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];
}
/**
* 更新一个或多个设置项
*
* 持久化失败时:内存状态已经改了(用户看到的设置立刻生效),但落盘失败 →
* 下次启动会回到旧值。必须把失败告诉 UItoast / 日志),否则用户以为保存成功。
*
* 输入校验:每个字段只接受明确合法的标量值。
* - dataDirnull 是合法语义(恢复默认),字符串也合法;其他类型忽略。
* - 其它字段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 与磁盘状态之间存在竞态,
// 用户"刚刚改了数据目录"立刻新建文件,可能仍写到旧目录下。
// 持久化后再 emitUI 视觉延迟 ≈ 一次 IPC几毫秒用户无感race 消失。
//
// 关键:失败时**仍然要 emit 'change'**。内存已经改了(上面的循环无条件写
// this._settings如果失败就不发 changebody.dataset.fontSize / colorStyle
// 这些只在 change 里同步的字段会永远停在旧值 —— 内存说 large、界面显示 medium
// 而且后续某次成功的 update 只带它自己改的 keyfontSize 再也没机会补上。
// 所以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;
}
}
}

4864
src/styles.css Normal file

File diff suppressed because it is too large Load Diff

3599
src/task-store.js Normal file

File diff suppressed because it is too large Load Diff

382
src/ui/command-palette.js Normal file
View File

@@ -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<void>}
*/
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) => {
// M8command-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 = `
<div class="command-palette">
<div class="command-palette-input-row">
<span class="search-icon" aria-hidden="true">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<circle cx="6.5" cy="6.5" r="4.5"/>
<line x1="10" y1="10" x2="14" y2="14"/>
</svg>
</span>
<input type="text" class="command-palette-input"
placeholder="输入命令或搜索列表…"
spellcheck="false" autocomplete="off"
role="combobox" aria-controls="command-palette-listbox"
aria-expanded="true" aria-autocomplete="list">
</div>
<ul class="command-palette-list" id="command-palette-listbox" role="listbox"></ul>
</div>
`;
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 → 清空搜索 → 高亮仍停在 5UX 不直观。
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 = `<div class="command-palette-empty">没有匹配的命令</div>`;
// 让屏幕阅读器知道当前没有可用选项
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 `
<li class="command-palette-item ${isActive ? 'active' : ''}"
data-idx="${i}" data-id="${escapeHtml(c.id)}"
id="cmd-item-${i}"
role="option" ${isActive ? 'aria-selected="true"' : ''}>
<span class="command-palette-item-icon">${c.icon || ''}</span>
<span class="command-palette-item-label">${escapeHtml(c.label)}</span>
${c.hint ? `<span class="command-palette-item-hint">${escapeHtml(c.hint)}</span>` : ''}
</li>
`;
}).join('');
// input 不离开焦点aria-activedescendant 指向当前选项 —— ARIA combobox 推荐做法,
// 这样键盘焦点在 input、视觉焦点高亮行 + 滚动)在 listbox 上
input.setAttribute('aria-activedescendant', `cmd-item-${activeIdx}`);
};
// 用 AbortController 一次性回收所有 document 级监听器,避免与其它面板叠加
const ac = new AbortController();
const { signal } = ac;
// 本地关闭守卫:闭包内多次 closeEsc + 点击外部 + 执行命令)只生效一次
// (不能再依赖模块级 `_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() 返回 Promiseasync / 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 它的后代节点会跳到 bodyrender() 抛错之前 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 '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
case 'settings':
return '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>';
case 'moon':
return '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>';
case 'sun':
return '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>';
case 'star':
return '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/></svg>';
case 'folder':
return '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>';
case '✓':
case 'check':
return '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>';
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 已为内部函数)

361
src/ui/conflict-dialog.js Normal file
View File

@@ -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
// 保持一致(防御用户故意在原分类名里塞 <script>)。
const orig = task.originalCategory
? `<span class="conflict-task-orig">[原分类:${escapeHtml(task.originalCategory)}]</span> `
: '';
const markers = [
task.current ? '[▶]' : '',
task.important ? '[⭐]' : '',
].filter(Boolean).join(' ');
// 不要在这里手动 replace —— 下面的 escapeHtml 会再次转义 & → &amp;
// 导致 `&lt;` 变成 `&amp;lt;`(双重编码),用户看到字面 `&amp;lt;script&amp;gt;`。
// 单次 escapeHtml 已覆盖 < > & " ' 全部危险字符。
const text = task.text || '';
const classAttr = className ? ` class="${escapeHtml(className)}"` : '';
return `<div${classAttr}>${escapeHtml(cb)} ${markers ? escapeHtml(markers) + ' ' : ''}${orig}${escapeHtml(text)}</div>`;
}
/**
* 把 diff 里的一条 (onlyInDisk / onlyInMemory / modified) 渲染成三列中一行。
*
* modified 类型左右两侧都有onlyIn* 只有一侧。
*
* @param {object} item - diff item
* @param {'onlyInDisk'|'onlyInMemory'|'modified'} kind
* @param {string} diffKey - 用于 select 识别是哪一行
* @param {string} currentChoice - 当前选择 ('a'|'b'|'both'|'skip')
*/
function renderDiffRow(item, kind, diffKey, currentChoice) {
const selectId = `conflict-choice-${diffKey}`;
// modified: 两侧都展示onlyIn*: 仅一边展示
let leftCell = '';
let rightCell = '';
if (kind === 'onlyInDisk') {
leftCell = renderTaskRow(item.task, 'conflict-task-side-file');
rightCell = '<div class="conflict-task-empty">(本地没有)</div>';
} else if (kind === 'onlyInMemory') {
leftCell = '<div class="conflict-task-empty">(文件没有)</div>';
rightCell = renderTaskRow(item.task, 'conflict-task-side-mine');
} else if (kind === 'modified') {
leftCell = renderTaskRow(item.taskA, 'conflict-task-side-file');
rightCell = renderTaskRow(item.taskB, 'conflict-task-side-mine');
}
// 操作选项:每种 kind 可用的选择集合不一样
// - onlyInDisk: 'a'(用文件) / 'skip'(丢) / 'both'(等价 'a',保留)
// - onlyInMemory: 'b'(用本地) / 'skip'(丢)
// - modified: 'a'(用文件) / 'b'(用本地) / 'both'(合并) / 'skip'(丢)
let options;
if (kind === 'onlyInDisk') {
options = [
{ value: 'a', label: '保留(用文件版)' },
{ value: 'skip', label: '丢弃(本地无)' },
];
} else if (kind === 'onlyInMemory') {
options = [
{ value: 'b', label: '保留(用本地版)' },
{ value: 'skip', label: '丢弃(文件无)' },
];
} else {
options = [
{ value: 'a', label: '用文件版' },
{ value: 'b', label: '用本地版' },
{ value: 'both', label: '两边都保留(合并标记)' },
{ value: 'skip', label: '不要(两边都丢)' },
];
}
const optionsHtml = options.map(opt =>
`<option value="${escapeHtml(opt.value)}"${opt.value === currentChoice ? ' selected' : ''}>${escapeHtml(opt.label)}</option>`
).join('');
return `
<div class="conflict-row" data-diff-key="${escapeHtml(diffKey)}">
<div class="conflict-cell conflict-cell-file">${leftCell}</div>
<div class="conflict-cell conflict-cell-chooser">
<select class="conflict-choice" id="${selectId}" data-diff-key="${escapeHtml(diffKey)}">
${optionsHtml}
</select>
</div>
<div class="conflict-cell conflict-cell-mine">${rightCell}</div>
</div>
`;
}
/**
* 打开冲突解决对话框。
*
* @param {object} options
* @param {object} options.diff - store.diffWithDisk() 输出
* @param {object} options.defaultResolutions - 初始选择映射key → 'a'|'b'|'both'|'skip'
* @param {object} options.summary - { total, addedByFile, addedByMine, conflicting }
* @param {string} [options.diskContent] - 磁盘当前内容(用于"查看完整行级 diff"按钮)
* @param {string} [options.memoryText] - 本地 serialize() 输出(同上)
* @returns {Promise<{
* action: 'apply'|'keep-file'|'keep-mine'|'cancel',
* resolutions?: object,
* }>}
*/
export function openConflictDialog({ diff, defaultResolutions, summary, diskContent, memoryText }) {
return new Promise((resolve) => {
// M8挂载 overlay 之前清掉瞬态状态(拖拽 / 进行中的菜单 / 等)
runBeforeModalShow();
const previouslyFocused = document.activeElement;
// 行顺序:先 onlyInDisk文件新加的→ 再 onlyInMemory本地新加的→ 最后 modified两边都有但不同
// 把「两边都改」放最后 —— 用户最关心的「我刚刚在 UI 里改的东西」会最先看到。
const rows = [];
for (const item of diff.onlyInDisk) {
rows.push({ kind: 'onlyInDisk', item, key: item.key });
}
for (const item of diff.onlyInMemory) {
rows.push({ kind: 'onlyInMemory', item, key: item.key });
}
for (const item of diff.modified) {
rows.push({ kind: 'modified', item, key: item.key });
}
const rowsHtml = rows.map(row =>
renderDiffRow(
row.item,
row.kind,
row.key,
// H7 同步modified 默认 'both'OR 合并状态,与 markdown-diff.defaultResolutions 对齐)
defaultResolutions[row.key] || (row.kind === 'onlyInDisk' ? 'a' : row.kind === 'onlyInMemory' ? 'b' : 'both')
)
).join('');
const summaryHtml = `
<div class="conflict-summary">
<span class="conflict-stat conflict-stat-total">共 ${summary.total} 处差异</span>
${summary.addedByFile > 0 ? `<span class="conflict-stat">文件新加 ${summary.addedByFile}</span>` : ''}
${summary.addedByMine > 0 ? `<span class="conflict-stat">本地新加 ${summary.addedByMine}</span>` : ''}
${summary.conflicting > 0 ? `<span class="conflict-stat conflict-stat-conflict">两边都改 ${summary.conflicting}</span>` : ''}
</div>
`;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay conflict-dialog-overlay';
overlay.innerHTML = `
<div class="modal conflict-dialog-modal" role="dialog" aria-modal="true"
aria-labelledby="conflict-dialog-title" aria-describedby="conflict-dialog-summary">
<div class="modal-header conflict-dialog-header" id="conflict-dialog-title">
<span class="conflict-dialog-title-text">文件与本地不一致 —— 请选择保留哪边</span>
<button class="conflict-diff-preview-btn"
data-action="open-diff-preview"
title="按行对比文件版与本地版的纯文本差异LCS 红绿着色)"
aria-label="查看完整行级 diff">
查看完整行级 diff
</button>
</div>
<div class="conflict-dialog-summary" id="conflict-dialog-summary">
${summaryHtml}
</div>
<div class="conflict-dialog-grid-header">
<div class="conflict-cell-header">📄 文件版本(磁盘)</div>
<div class="conflict-cell-header">选择</div>
<div class="conflict-cell-header">💻 本地版本(软件)</div>
</div>
<div class="conflict-dialog-body">
${rowsHtml || '<div class="conflict-empty">没有差异</div>'}
</div>
<div class="modal-footer conflict-dialog-footer">
<button class="btn" data-action="cancel">取消</button>
<button class="btn" data-action="keep-mine">全部用本地</button>
<button class="btn btn-danger" data-action="keep-file">全部用文件(丢弃本地,会保存快照)</button>
<button class="btn btn-primary" data-action="apply" autofocus>应用合并</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const ac = new AbortController();
const { signal } = ac;
let resolved = false;
const close = (result) => {
if (resolved) return;
resolved = true;
// 清理「全部用本地」二次确认定时器 —— close 时还在 4s 武装态的话,
// 定时器不 clear 会延迟到点才 fire闭包挂着 keepMineBtn / originalText
// 等引用(虽然 `if (!resolved)` 守卫让副作用落空,但 GC 被推迟到 timer fire
clearTimeout(keepMineArmTimer);
keepMineArmTimer = null;
ac.abort();
// 缩放淡出 + focus 恢复挪到 animationend 内(焦点元素不能先于 destroy
const inner = overlay.querySelector('.modal');
// focus() 必须在 overlay.remove() 之前animationend 与 250ms 兜底共享
// finalize —— 否则 animationend 漏触发display:none / 元素被提前 detach
// 时 setTimeout 仅 remove 不 restoreFocus+resolvePromise 永久挂起,
// 调用方 await conflict-dialog → 后续 saveNow / toast 永远不执行。
const finalize = () => {
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
previouslyFocused.focus();
}
overlay.remove();
resolve(result);
};
if (inner) {
inner.classList.add('is-closing');
inner.addEventListener('animationend', finalize, { once: true });
// 兜底必须 resolve参见 finalize 注释
setTimeout(finalize, 250);
} else {
finalize();
}
};
// 收集当前所有 select 的值 → resolutions 对象
const collectResolutions = () => {
const res = {};
overlay.querySelectorAll('.conflict-choice').forEach((sel) => {
const key = sel.dataset.diffKey;
if (key) res[key] = sel.value;
});
return res;
};
overlay.addEventListener('click', (e) => {
const action = e.target.closest('[data-action]')?.dataset.action;
if (action === 'apply') {
close({ action: 'apply', resolutions: collectResolutions() });
} else if (action === 'keep-mine') {
close({ action: 'keep-mine' });
} else if (action === 'keep-file') {
close({ action: 'keep-file' });
} else if (action === 'open-diff-preview') {
// 行级 diff 预览:开第二个 modal 浮在第一个上面,关闭后焦点回到这个按钮。
// openDiffPreview 自带 openDiffPreview 自带 previouslyFocused.focus()
// 用户体验:点查看 → 看到红绿行级 diff → 关 → 自动回到 conflict-dialog。
// 两个 modal 不嵌套diff-overlay 是 document.body 的 sibling不是 conflict-overlay 的 child
// 事件不会互相冒泡conflict-overlay 的 click 监听器不会被 diff-overlay 误触发。
if (typeof diskContent === 'string' && typeof memoryText === 'string') {
openDiffPreview({
oldText: diskContent,
newText: memoryText,
title: '文件 vs 本地 —— 完整行级 diff',
});
} else {
// 兜底:调用方忘了传 diskContent / memoryText给一个可观察的反馈而不是静默失败
console.warn('[conflict-dialog] diskContent / memoryText 未传入,行级 diff 按钮不可用');
}
} else if (action === 'cancel' || e.target === overlay) {
close({ action: 'cancel' });
}
}, { signal });
// 「应用合并」快捷键Cmd/Ctrl+Enter
// Escape 取消。IME 组合期间不响应。
//
// 关键:只对**本 overlay 内部**的键盘事件响应。
// conflict-overlay 与 diff-preview-overlay 是 document.body 的 sibling
// 我们用 capture: true 是为了在事件冒泡前先抓到 Tab焦点陷阱需要
// 但副作用是 sibling 上的事件也会经过 capture 阶段跑一遍。
// 如果不检查事件源,用户在 diff 预览里按 Escape 会顺带关掉 conflict-dialog。
// v4 audit 实测发现的回归 —— 早期没有 sibling modal没暴露这个问题
const onKey = (e) => {
// 事件必须源自本 overlay 内部 —— 避免 sibling overlay 的按键被误处理
if (!overlay.contains(e.target)) return;
if (e.key === 'Escape') {
// IME 守卫CJK/JP/KR 用户在选词中按 Esc 是「取消 IME 组合」,
// 不是「放弃冲突合并」。让 IME 先消化掉 Esc关掉弹窗太重。
if (isImeComposing(e)) return;
e.preventDefault();
close({ action: 'cancel' });
} else if (isCmdEnter(e) && !isImeComposing(e)) {
// Cmd/Ctrl+Enter → 应用合并
e.preventDefault();
close({ action: 'apply', resolutions: collectResolutions() });
} else if (e.key === 'Tab') {
// 焦点陷阱
trapFocus(e, overlay, signal);
}
};
overlay.addEventListener('keydown', onKey, { signal, capture: true });
// 「全部用本地」按钮做二次确认 —— 容易误点
// 第一次点击:按钮变红 + 文案改「再次点击确认丢弃外部编辑」
// 第二次点击:才真正提交 keep-mine
// 4 秒内没二次点击则自动复位
const keepMineBtn = overlay.querySelector('[data-action="keep-mine"]');
let keepMineArmed = false;
let keepMineArmTimer = null;
if (keepMineBtn) {
const originalText = keepMineBtn.textContent;
// 武装态同步给屏幕阅读器aria-pressed=true 让辅助技术知道当前状态。
// 视觉上的红边框 + 文案变化对 sighted 用户明确,对屏幕阅读器则需要 aria 反馈。
keepMineBtn.addEventListener('click', (e) => {
if (!keepMineArmed) {
// 第一次点击 —— 武装按钮,不冒泡到 overlay 的处理
e.stopPropagation();
e.preventDefault();
keepMineArmed = true;
keepMineBtn.classList.add('btn-danger');
keepMineBtn.dataset.armed = '1';
keepMineBtn.textContent = '再次点击:丢弃外部编辑';
keepMineBtn.setAttribute('aria-pressed', 'true');
// 4 秒没再点 → 自动复位
clearTimeout(keepMineArmTimer);
keepMineArmTimer = setTimeout(() => {
if (!resolved) {
keepMineArmed = false;
keepMineBtn.classList.remove('btn-danger');
delete keepMineBtn.dataset.armed;
keepMineBtn.textContent = originalText;
keepMineBtn.setAttribute('aria-pressed', 'false');
}
}, 4000);
}
// 第二次点击 —— 不 stopPropagation,让 overlay 走 keep-mine 路径
}, { signal });
}
// 初始聚焦到「应用合并」按钮(已通过 autofocus
setTimeout(() => {
overlay.querySelector('[data-action="apply"]')?.focus();
}, 50);
});
}
// 焦点陷阱trapFocus 从 src/utils/focus-trap.js 导入(与 feedback.js / import-dialog.js 共享)

View File

@@ -0,0 +1,172 @@
// 行级 diff 预览对话框
//
// 把「磁盘当前内容」与「本地 serialize() 输出」按行对比,给用户一个直观的可视化。
// 用于冲突对话框之外需要看「具体哪里变了」的场景(例如开发者诊断、设置面板里手动触发 reload
//
// 实现:朴素 LCS / Myers 算法对 50KB 以内文件足够快(通常 < 50ms。不做复杂的块级 diff
// 用户看到「红/绿」就够 —— 任务级合并对话框已经覆盖了「按任务粒度选」的核心需求,这里只是
// 给好奇的 / 高级用户一个文字级视图。
import { escapeHtml } from '../utils/dom.js';
import { runBeforeModalShow } from './feedback.js';
import { trapFocus } from '../utils/focus-trap.js';
import { isImeComposing } from '../utils/keymap.js';
/**
* 计算两个文本的行级 diff朴素 LCS
*
* 行尾换行符规范化split('\n') 仍保留 '\r'(仅去掉 \n所以 Windows 编辑器
* 写的 CRLF 与应用自身写的 LF 在比较时会被判为「每行都不同」—— 一整篇文档
* 看起来全是 add/remove用户看不出真正的差异。
*
* @param {string} oldText
* @param {string} newText
* @returns {Array<{kind: 'equal'|'add'|'remove', text: string}>}
*/
function computeLineDiff(oldText, newText) {
// split('\n') 后保留可能的 '\r',统一 trimEnd 去掉。
// trimEnd 而不是 replace(/\r$/, ''):连续空行不会被误吞,且空行末尾本来就不该有 \r。
const oldLines = (oldText || '').split('\n').map(l => l.replace(/\r$/, ''));
const newLines = (newText || '').split('\n').map(l => l.replace(/\r$/, ''));
const m = oldLines.length;
const n = newLines.length;
// LCS 长度表
// 性能保护:行数超过 4000 退化为「只比对总行数 + 显示前 200 行」,
// 避免卡死主线程Electron 主进程阻塞会让 UI 卡顿)。
const MAX_LINES = 4000;
if (m > MAX_LINES || n > MAX_LINES) {
return [
{ kind: 'remove', text: `--- 文件过大(${m} 行),已截断。完整差异请打开外部 diff 工具。` },
];
}
// 动态规划
const dp = Array.from({ length: m + 1 }, () => new Uint32Array(n + 1));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (oldLines[i - 1] === newLines[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
// 回溯生成 diff
const result = [];
let i = m, j = n;
while (i > 0 && j > 0) {
if (oldLines[i - 1] === newLines[j - 1]) {
result.unshift({ kind: 'equal', text: oldLines[i - 1] });
i--; j--;
} else if (dp[i - 1][j] >= dp[i][j - 1]) {
result.unshift({ kind: 'remove', text: oldLines[i - 1] });
i--;
} else {
result.unshift({ kind: 'add', text: newLines[j - 1] });
j--;
}
}
while (i > 0) { result.unshift({ kind: 'remove', text: oldLines[i - 1] }); i--; }
while (j > 0) { result.unshift({ kind: 'add', text: newLines[j - 1] }); j--; }
return result;
}
/**
* 打开行级 diff 预览。
*
* @param {object} options
* @param {string} options.oldText - 旧文本(磁盘)
* @param {string} options.newText - 新文本(本地)
* @param {string} options.title - 标题
* @returns {Promise<void>}
*/
export function openDiffPreview({ oldText, newText, title = '行级 diff 预览' }) {
return new Promise((resolve) => {
// M8挂载 overlay 之前清掉瞬态状态(拖拽 / 进行中的菜单 / 等)
runBeforeModalShow();
const previouslyFocused = document.activeElement;
const diff = computeLineDiff(oldText, newText);
const linesHtml = diff.map((line) => {
const cls = `diff-line diff-line-${line.kind}`;
const prefix = line.kind === 'add' ? '+ ' : line.kind === 'remove' ? '- ' : ' ';
return `<div class="${cls}"><span class="diff-line-prefix">${prefix}</span><span class="diff-line-text">${escapeHtml(line.text || '')}</span></div>`;
}).join('');
const overlay = document.createElement('div');
overlay.className = 'modal-overlay diff-preview-overlay';
overlay.innerHTML = `
<div class="modal diff-preview-modal" role="dialog" aria-modal="true"
aria-labelledby="diff-preview-title">
<div class="modal-header diff-preview-header" id="diff-preview-title">
${escapeHtml(title)}
</div>
<div class="diff-preview-legend">
<span class="diff-line-add-legend">+ 新增</span>
<span class="diff-line-remove-legend">- 删除</span>
</div>
<div class="diff-preview-body">${linesHtml || '<div class="diff-empty">无差异</div>'}</div>
<div class="modal-footer">
<button class="btn btn-primary" data-action="close" autofocus>关闭</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const ac = new AbortController();
const { signal } = ac;
// 关闭守卫:连点「关闭」按钮 + Esc / overlay 多次触发时只生效一次。
// 缺这个守卫:第二次 close() 会再挂一份 animationend 监听 + 再排一份 setTimeout
// 动画结束时 finalize 跑两次 → resolve 调两次(无 crash 但污染 Promise 状态 +
// 多余的 overlay.remove / focus 调用)。与 conflict-dialog / feedback / settings-dialog
// 等所有其他 modal 的 `resolved`/`closed` 守卫对齐。
let resolved = false;
const close = () => {
if (resolved) return;
resolved = true;
ac.abort();
// 缩放淡出后 remove —— 与 confirmDialog 同款,焦点恢复必须在 animationend 内
const inner = overlay.querySelector('.modal');
// animationend 与 250ms 兜底共享 finalize —— 避免 animationend 漏触发时
// setTimeout 只 remove 不 restoreFocus+resolvePromise 永久挂起。
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参见 finalize 注释
setTimeout(finalize, 250);
} else {
finalize();
}
};
overlay.addEventListener('click', (e) => {
const action = e.target.closest('[data-action]')?.dataset.action;
if (action === 'close' || e.target === overlay) close();
}, { signal });
overlay.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
// IME 守卫sibling 弹窗可能有 IME 组合中(冲突对话框 → diff 预览对话框的场景),
// 让 IME 先消化 Esc不要在这里拦截把它当成「关闭 diff 预览」。
if (isImeComposing(e)) return;
e.preventDefault();
close();
} else if (e.key === 'Tab') {
// 与 feedback / conflict-dialog / import-dialog 对齐modal 必须把 Tab
// 焦点锁在内部,否则按 Tab 会跑进背景元素,破坏模态承诺。
trapFocus(e, overlay, signal);
}
}, { signal });
});
}

462
src/ui/feedback.js Normal file
View File

@@ -0,0 +1,462 @@
// UI 反馈组件Toast 通知 + Modal 对话框 + Context Menu
import { escapeHtml } from '../utils/dom.js';
import { trapFocus } from '../utils/focus-trap.js';
import { isImeComposing } from '../utils/keymap.js';
// ─────────────────────────────────────────────────────────────────────────────
// Modal 打开前 hook 列表
//
// 为什么需要:
// M8 修复 —— 用户在拖拽任务过程中_drags Map 非空)触发了 confirmDialog /
// inputDialog / 等任意 modal。modal 立刻拦截了 document 的指针,但 pointerup
// 仍在 document 级监听上最终派发,调用 _finishDrag → reorderTask。此时用户
// 实际想表达的是「关掉弹窗」,却得到「任务被重排」的副作用,典型的「点了
// 没反应/反应错」体感。
//
// 解决:所有 modal 打开前调用一遍注册的 hooks把外部需要清理的状态清掉。
// 单一注册口而非每个 modal 单独调用,保持调用方代码干净。
//
// 当前 task-list.js / sidebar.js 等组件在 _setupEvents 时注册清理函数,
// destroy 时注销。
// ─────────────────────────────────────────────────────────────────────────────
const beforeModalShowHooks = new Set();
/**
* 注册「modal 打开前」清理 hook —— 由组件用来清理自身的瞬态状态(如进行中的
* 拖拽、未关闭的菜单、正在闪烁的 ghost 等)。返回反注册函数。
*
* @param {() => void} hook
* @returns {() => void}
*/
export function registerBeforeModalShow(hook) {
if (typeof hook !== 'function') return () => {};
beforeModalShowHooks.add(hook);
return () => beforeModalShowHooks.delete(hook);
}
function runBeforeModalShow() {
// 防御:单个 hook 抛错不能让 modal 打开失败 —— try/catch 隔离
for (const hook of beforeModalShowHooks) {
try { hook(); } catch (e) { console.error('[feedback] beforeModalShow hook 异常:', e); }
}
}
/**
* 公开 runBeforeModalShow 供其他 modal如 import-dialog / settings-dialog /
* conflict-dialog / diff-preview-dialog / command-palette复用 —— 避免每个
* dialog 单独维护自己的 hook 列表,也避免引入 import 循环。
*
* 所有 modal 在挂载 overlay 之前都必须调一次。
*/
export { runBeforeModalShow };
/**
* Toast 通知
* @param {string} message
* @param {'info'|'success'|'error'} type
* @param {number} duration 毫秒
*/
export function toast(message, type = 'info', duration = 2500) {
const container = document.getElementById('toast-container');
if (!container) return;
const el = document.createElement('div');
el.className = `toast toast-${type}`;
el.textContent = message;
container.appendChild(el);
setTimeout(() => {
el.style.animation = 'toast-out 0.2s ease forwards';
setTimeout(() => el.remove(), 200);
}, duration);
}
/**
* 模态对话框
* @param {object} options
* @returns {Promise<boolean>} 用户点击确认返回 true取消返回 false
*/
export function confirmDialog({
title = '确认',
message = '',
confirmText = '确定',
cancelText = '取消',
danger = false
} = {}) {
return new Promise((resolve) => {
// M8在挂载 overlay 之前先清掉所有进行中的瞬态状态(拖拽 / 菜单 / 等)。
// 否则用户在拖拽中触发 modal 时 pointerup 会在 modal 后才派发,导致任务
// 被偷偷重排 —— 典型的「点了没反应/反应错」。
runBeforeModalShow();
// 记下打开前的焦点元素,关弹窗时还原 —— 屏幕阅读器用户会卡在无主的对话框
// 上,键盘用户则要按 Tab 一路回到原位置。
const previouslyFocused = document.activeElement;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
// 每次打开用稳定 id 即可(同一时刻只可能有一个 dialog/confirm 在屏)
const titleId = 'modal-title';
const bodyId = 'modal-body';
overlay.innerHTML = `
<div class="modal" role="dialog" aria-modal="true"
aria-labelledby="${titleId}" aria-describedby="${bodyId}">
<div class="modal-header" id="${titleId}">${escapeHtml(title)}</div>
<div class="modal-body" id="${bodyId}">${escapeHtml(message)}</div>
<div class="modal-footer">
<button class="btn" data-action="cancel">${escapeHtml(cancelText)}</button>
<button class="btn btn-primary ${danger ? 'btn-danger' : ''}" data-action="ok" autofocus>
${escapeHtml(confirmText)}
</button>
</div>
</div>
`;
document.body.appendChild(overlay);
// 用 AbortController 一次性回收所有监听器,避免嵌套弹窗时残留
const ac = new AbortController();
const { signal } = ac;
let resolved = false;
const close = (result) => {
if (resolved) return;
resolved = true;
ac.abort();
// 关弹窗时先播 0.18s 缩放淡出动画再 remove —— 比同步 remove 多一层缓冲,
// 视觉上"主动关掉"而非"硬切"。focus 恢复必须挪到 animationend 内:
// 若在 remove 后立即 focus原元素或其祖先会被销毁导致焦点跳到 body。
const inner = overlay.querySelector('.modal');
// finalize: 真正销毁节点 + 还原焦点 + resolve。在 animationend / fallback 共享。
// 注意 focus() 必须在 overlay.remove() 之前调用 —— 一旦 overlay 销毁,
// 其下任何节点 focus 都可能跳到 body被销毁的祖先
const finalize = () => {
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
previouslyFocused.focus();
}
overlay.remove();
resolve(result);
};
if (inner) {
inner.classList.add('is-closing');
inner.addEventListener('animationend', finalize, { once: true });
// 兜底万一动画被打断display:none / 元素被提前 detach / animationend
// 不触发250ms 后强删并 resolve —— 不 resolve 会让 await 永远挂住,
// 对 command-palette 来说意味着 _active 永不清、Ctrl+K 永久失效(致命)。
setTimeout(finalize, 250);
} else {
finalize();
}
};
overlay.addEventListener('click', (e) => {
// 用 closest 向上找带 data-action 的祖先,这样点击 modal 标题/消息体等
// 非按钮区域也能正确识别"取消"语义(与 settings-dialog.js 实现对齐)。
// e.target === overlay 仍然保留:点击遮罩空白处直接取消。
const action = e.target.closest('[data-action]')?.dataset.action;
if (action === 'ok') close(true);
else if (action === 'cancel' || e.target === overlay) close(false);
}, { signal });
// IME 组合输入期间不要让 Enter 提交对话(中文拼音 / 日文 IME 按 Enter 上屏时
// 仍在 isComposing=true 状态,旧实现会把「上屏」当成「确认」,未写完的字串直接被吞)。
// Escape 同样加 IME 守卫CJK/JP/KR 用户在选词中按 Esc 通常是「取消 IME 组合」,
// 不是「关闭对话框」。让 IME 先消化 Esc避免误关。
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (isImeComposing(e)) return;
e.preventDefault();
close(false);
} else if (e.key === 'Enter' && !isImeComposing(e)) {
e.preventDefault();
close(true);
} else {
// Tab / Shift+Tab 在 modal 内首尾循环,不让焦点跑到背景的任务列表上
trapFocus(e, overlay, signal);
}
}, { signal });
setTimeout(() => {
overlay.querySelector('[data-action="ok"]')?.focus();
}, 50);
});
}
/**
* 输入对话框
* @param {object} options
* @param {(value: string) => string|null} [options.validate] - 实时校验:
* 返回 null 表示通过;返回字符串作为内联错误提示(输入框变红、确认按钮置灰)。
* 空值视为「未填」,静默禁用确认按钮但不显示错误。
* @returns {Promise<string|null>} 用户输入的文本,取消或未通过校验返回 null
*/
export function inputDialog({
title = '输入',
message = '',
placeholder = '',
defaultValue = '',
confirmText = '确定',
cancelText = '取消',
validate = null
} = {}) {
return new Promise((resolve) => {
// M8同 confirmDialog —— 打开前清掉瞬态状态(拖拽 / 进行中的菜单 / 等)
runBeforeModalShow();
// 同 confirmDialog记下焦点并还回去让键盘流顺畅
const previouslyFocused = document.activeElement;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
// input-dialog-modal紧凑版 modal。标题与输入框共用一个色块去掉 header/body 分隔线),
// 整体 padding 比通用 .modal 更小,让「新建子分类」这种纯输入场景更聚焦。
const titleId = 'input-title';
const bodyId = 'input-body';
overlay.innerHTML = `
<div class="modal input-dialog-modal" role="dialog" aria-modal="true"
aria-labelledby="${titleId}" ${message ? `aria-describedby="${bodyId}"` : ''}>
<div class="input-dialog-title" id="${titleId}">${escapeHtml(title)}</div>
<div class="input-dialog-body">
${message ? `<p class="input-dialog-message" id="${bodyId}">${escapeHtml(message)}</p>` : ''}
<input type="text" class="input-dialog-input"
placeholder="${escapeHtml(placeholder)}" value="${escapeHtml(defaultValue)}">
<div class="input-dialog-error" hidden></div>
</div>
<div class="input-dialog-footer">
<button class="btn" data-action="cancel">${escapeHtml(cancelText)}</button>
<button class="btn btn-primary" data-action="ok">${escapeHtml(confirmText)}</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const input = overlay.querySelector('input');
const errorEl = overlay.querySelector('.input-dialog-error');
const okBtn = overlay.querySelector('[data-action="ok"]');
const ac = new AbortController();
const { signal } = ac;
let resolved = false;
const close = (result) => {
if (resolved) return;
resolved = true;
ac.abort();
// 同 confirmDialog缩放淡出后 remove焦点恢复必须在 animationend 内
// —— 提前调用会焦点跳到 body被销毁的祖先元素上
const inner = overlay.querySelector('.modal');
// focus() 必须在 overlay.remove() 之前 —— 否则 focus 到 overlay 内的节点会跳 body
const finalize = () => {
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
previouslyFocused.focus();
}
overlay.remove();
resolve(result);
};
if (inner) {
inner.classList.add('is-closing');
inner.addEventListener('animationend', finalize, { once: true });
// 兜底必须 resolve参见 confirmDialog 注释,否则 await 永久挂起
setTimeout(finalize, 250);
} else {
finalize();
}
};
// 校验:返回 null 表示通过;'empty' 或错误字符串表示不通过
const runValidation = () => {
const value = input.value.trim();
// 空值:静默禁用提交(不显示错误,等用户继续输入)
if (!value) {
errorEl.hidden = true;
input.classList.remove('input-invalid');
okBtn.disabled = true;
return 'empty';
}
// 自定义校验(如重名、保留名)
if (typeof validate === 'function') {
const error = validate(value);
if (error) {
errorEl.textContent = error;
errorEl.hidden = false;
input.classList.add('input-invalid');
okBtn.disabled = true;
return error;
}
}
errorEl.hidden = true;
input.classList.remove('input-invalid');
okBtn.disabled = false;
return null;
};
// 仅在校验通过时关闭并返回值
const closeIfValid = () => {
if (runValidation() !== null) return;
close(input.value.trim());
};
// 边输边校验
input.addEventListener('input', runValidation, { signal });
// 初始聚焦 + 初次校验(处理空值默认状态)
setTimeout(() => {
input.focus();
input.select();
runValidation();
}, 50);
overlay.addEventListener('click', (e) => {
// 同样使用 closestinputDialog 的内部 padding / 标题区域点击也能识别为「取消」,
// 与 confirmDialog 行为一致。
const action = e.target.closest('[data-action]')?.dataset.action;
if (action === 'ok') closeIfValid();
else if (action === 'cancel' || e.target === overlay) close(null);
}, { signal });
// 捕获阶段监听:覆盖 overlay 内所有 keydown含 input 自身),
// 一次到位,避免双重 close()。
//
// IME 组合输入期间isComposing=true 或 keyCode=229按 Enter 是「上屏」不是「确认」,
// 旧实现会在拼音半完成时把字串直接提交 —— 丢掉正在选的上屏候选。
// Escape 同样加 IME 守卫CJK/JP/KR 用户在选词中按 Esc 通常是「取消 IME 组合」,
// 不是「关闭对话框」。让 IME 先消化 Esc避免误关整个输入对话框。
const onKey = (e) => {
if (e.key === 'Escape') {
if (isImeComposing(e)) return;
e.preventDefault();
close(null);
} else if (e.key === 'Enter' && !isImeComposing(e)) {
e.preventDefault();
closeIfValid();
} else {
// Tab / Shift+Tab 在 modal 内首尾循环
trapFocus(e, overlay, signal);
}
};
overlay.addEventListener('keydown', onKey, { signal, capture: true });
});
}
/**
* 右键菜单(支持分隔符、危险项、回调函数)
*/
let activeContextMenu = null;
let contextMenuCallback = null;
/**
* @param {number} x
* @param {number} y
* @param {Array<{label?: string, action?: string, separator?: boolean, danger?: boolean, disabled?: boolean, indent?: boolean}>} items
* @param {(action: string) => void} [callback] - 点击菜单项时的回调
*/
export function showContextMenu(x, y, items, callback = null) {
hideContextMenu();
const menu = document.getElementById('context-menu');
if (!menu) return;
menu.innerHTML = '';
menu.setAttribute('role', 'menu');
contextMenuCallback = callback;
for (const item of items) {
if (item.separator) {
const sep = document.createElement('div');
sep.className = 'context-menu-separator';
sep.setAttribute('role', 'separator');
menu.appendChild(sep);
} else {
const el = document.createElement('div');
let cls = 'context-menu-item';
if (item.danger) cls += ' context-menu-danger';
if (item.disabled) cls += ' context-menu-disabled';
if (item.indent) cls += ' context-menu-indent';
el.className = cls;
el.textContent = item.label || '';
el.setAttribute('role', 'menuitem');
// 屏幕阅读器读「已禁用」比直接跳过更友好;视觉上的灰度已在 CSS 完成
if (item.disabled) el.setAttribute('aria-disabled', 'true');
if (item.action) el.dataset.action = item.action;
menu.appendChild(el);
}
}
// 防止菜单超出视口 —— 先把菜单放到屏幕外、visibility:hidden 测真实尺寸,
// 再按真实宽高 clamp 到视口内,最后才取消隐藏 + 复位。
// 旧版(硬编码 estimatedWidth=200 + 估算 height在长分类名 / 多分类时右侧
// 被切、底部被切visibility:hidden 测真实尺寸后,仍会被 `.context-menu`
// 上的 `menu-in` 动画scale(0.97) → scale(1))影响 —— 测量发生在动画未完成时,
// `getBoundingClientRect()` 返回的是 transform 影响后的渲染尺寸scale(0.97)
// 让 height 比真实小 3%clamp 算小 → 菜单右边/底边被切。
// 解决:用 `offsetWidth/offsetHeight` 代替 `rect.width/height` —— offset 系列是
// 布局尺寸,不受 CSS transform 影响。同时把 left 放到 -9999px 让动画跑在屏幕外,
// 用户看不到 scale 0.97 的中间态。
menu.style.visibility = 'hidden';
menu.style.left = '-9999px';
menu.style.top = '0px';
menu.hidden = false;
// 强制一次 reflow让浏览器立刻计算布局offset 字段依赖最新布局)
void menu.offsetWidth;
const menuWidth = menu.offsetWidth;
const menuHeight = menu.offsetHeight;
const finalX = Math.max(4, Math.min(x, window.innerWidth - menuWidth - 8));
const finalY = Math.max(4, Math.min(y, window.innerHeight - menuHeight - 8));
menu.style.left = finalX + 'px';
menu.style.top = finalY + 'px';
menu.style.visibility = '';
activeContextMenu = menu;
}
export function hideContextMenu() {
if (activeContextMenu) {
activeContextMenu.hidden = true;
activeContextMenu = null;
}
contextMenuCallback = null;
// v4+ 修复sidebar 维护一套独立的「分类右键菜单」(#category-context-menu
// 自己管 visible / click-to-close。旧实现只清 activeContextMenu导致
// 1) 用户右击任务 → 弹任务菜单;再右击子分类 → 任务菜单残留 activeContextMenu
// 已经被 hideContextMenu 清掉,但 sidebar 的菜单 DOM 是 hidden=false
// 两个菜单 DOM 同时存在feedback 的 doc-click 监听器检查「点的是不是
// #context-menu 的内部」——分类菜单是另一个 DOM自然走"点外面"分支,
// 结果 sidebar 菜单弹出瞬间又被自己关了。
// 2) 反之 sidebar._showCategoryContextMenu 已主动调过 feedback.hideContextMenu
// 那个方向 OK但单点隐藏仍不够。
// 兜底:直接把所有 .context-menu DOM 都关掉。Sidebar 自己也有 hideContextMenu
// 会再幂等地设 hidden=true没有副作用。
const otherMenus = document.querySelectorAll('.context-menu:not([hidden])');
otherMenus.forEach((m) => { m.hidden = true; });
}
// 全局监听点击关闭菜单 —— 用 module-scope AbortController + 一次性安装标志避免
// HMR / 多实例化时累加C-1 修复:旧版 module 顶层 addEventListener 永不释放,
// reload 后会多份监听器同时处理同一次点击,触发「点了 N 次菜单/关闭菜单闪一下」)。
const globalListenerAC = new AbortController();
let globalListenersInstalled = false;
function installGlobalListeners() {
if (globalListenersInstalled) return;
globalListenersInstalled = true;
document.addEventListener('click', (e) => {
if (!activeContextMenu && !e.target.closest('#context-menu')) return;
const item = e.target.closest('.context-menu-item');
if (item && activeContextMenu && !item.classList.contains('context-menu-disabled')) {
const action = item.dataset.action || '';
const cb = contextMenuCallback;
hideContextMenu();
if (cb) cb(action);
} else if (activeContextMenu && !e.target.closest('#context-menu')) {
hideContextMenu();
}
}, { signal: globalListenerAC.signal });
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && activeContextMenu) {
// IME 守卫:右键菜单是 doc 级,可能覆盖到任何焦点元素(包括正在 IME 输入的输入框),
// 让 IME 先消化 Esc不要在这里拦截把它当成「关闭右键菜单」。
if (isImeComposing(e)) return;
hideContextMenu();
}
}, { signal: globalListenerAC.signal });
}
installGlobalListeners();
// 仅供测试 / 极端热重载场景调用 —— 主动关掉 module-scope 监听器。
export function disposeFeedbackGlobals() {
if (!globalListenersInstalled) return;
globalListenerAC.abort();
globalListenersInstalled = false;
}

311
src/ui/import-dialog.js Normal file
View File

@@ -0,0 +1,311 @@
// 内容导入对话框 - 粘贴大段文本、解析成多条任务
import { parseImportLine } from '../task-store.js';
import { trapFocus } from '../utils/focus-trap.js';
import { isImeComposing, isCmdEnter } from '../utils/keymap.js';
import { runBeforeModalShow } from './feedback.js';
// 导入顺序偏好的合法值集合 —— 与 settings-store.VALID_VALUES.importPosition 对齐。
// 单独列在这里避免 dialog 文件反向依赖 settings-storesettingsStore 通过
// importTasksToCategory 间接触达),并把默认值 'order' 与文档集中在一处。
// 只有两种合法值:'order'(默认,正序 + 插到最前)/ 'front'(倒序 + 插到最前)。
const VALID_IMPORT_POSITION = new Set(['order', 'front']);
/**
* 打开导入对话框
* @param {import('../task-store.js').TaskStore} store
* @param {string} defaultCategoryName - 默认选中的子分类
* @param {object} [options] - 额外选项
* @param {object} [options.settingsStore] - 设置存储。传入后对话框内的「排序方式」选项
* 会持久化到 settingsStore.importPosition不传则仅本次会话有效默认 'order')。
* @returns {Promise<{ added: number, categoryName: string } | null>}
* 取消返回 null确认返回导入条数与目标分类
*/
export function openImportDialog(store, defaultCategoryName, options = {}) {
return new Promise((resolve) => {
// M8在挂载 overlay 之前清掉所有进行中的瞬态状态(拖拽 / 菜单 / 等)。
// 否则用户在拖拽中触发 modal 时 pointerup 会让任务被偷偷重排。
runBeforeModalShow();
// AbortController 模式dialog / confirmDialog 一致的回收约定
const ac = new AbortController();
const { signal } = ac;
// settingsStore 用于读写 importPosition导入顺序偏好
// 不传 / 字段缺失 / 值非法一律兜底为 'order',保证对话框总能渲染一个合法默认。
const settingsStore = options.settingsStore || null;
const readImportPos = () => {
if (!settingsStore || typeof settingsStore.get !== 'function') return 'order';
const raw = settingsStore.get('importPosition');
return VALID_IMPORT_POSITION.has(raw) ? raw : 'order';
};
let currentImportPos = readImportPos();
const previouslyFocused = document.activeElement;
// 收集当前所有可作为目标的子分类NORMAL + parentOtherTasks
const targets = store.getSubCategories();
if (targets.length === 0) {
// 极端兜底:调用方应已保证有子分类;如果连一个都没有,
// 不可能导入,告诉调用方别走这条路径。
resolve(null);
return;
}
// 默认选中调用方传入的分类 —— 不在列表里则退回第一个
const defaultIdx = Math.max(0, targets.findIndex(c => c.name === defaultCategoryName));
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
<div class="modal import-dialog-modal" role="dialog" aria-modal="true" aria-labelledby="import-title">
<div class="import-dialog-title" id="import-title">导入任务</div>
<div class="import-dialog-body">
<p class="import-dialog-hint">
每行一条。包含 <code>- [ ]</code> / <code>- [✓]</code> 的行会保留勾选状态。
</p>
<textarea
id="import-textarea"
class="import-dialog-textarea"
spellcheck="false"
autocomplete="off"
maxlength="1000000"
placeholder="- [ ] 第一条任务
- [✓] 已完成的第二条
- 普通列表项
1. 有序列表
• 项目符号
普通文本行"></textarea>
<div class="import-dialog-options">
<label class="import-dialog-option">
<input type="checkbox" id="import-strip-bullets" checked>
<span>自动剥离前导列表符号(<code>-</code> / <code>*</code> / <code>+</code> / <code>•</code> / <code>1.</code> / <code>1)</code></span>
</label>
<label class="import-dialog-option import-dialog-target">
<span>目标分类:</span>
<select id="import-target" class="import-dialog-select"></select>
</label>
<div class="import-dialog-option import-dialog-order">
<span class="import-dialog-order-label">排序方式:</span>
<div class="import-dialog-segmented" id="import-order" role="radiogroup" aria-label="导入顺序">
<button type="button" class="import-dialog-seg-btn" data-pos="order" role="radio" aria-checked="false">按输入顺序</button>
<button type="button" class="import-dialog-seg-btn" data-pos="front" role="radio" aria-checked="false">倒序·最上</button>
</div>
</div>
</div>
<div class="import-dialog-count" id="import-count">将导入 0 条任务</div>
</div>
<div class="import-dialog-footer">
<button class="btn" data-action="cancel">取消</button>
<button class="btn btn-primary" data-action="ok" disabled>导入</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const textarea = overlay.querySelector('#import-textarea');
const stripCheckbox = overlay.querySelector('#import-strip-bullets');
const targetSelect = overlay.querySelector('#import-target');
const countEl = overlay.querySelector('#import-count');
const okBtn = overlay.querySelector('[data-action="ok"]');
const orderSeg = overlay.querySelector('#import-order');
// 填充分类下拉
for (let i = 0; i < targets.length; i++) {
const opt = document.createElement('option');
opt.value = targets[i].name;
opt.textContent = targets[i].name;
if (i === defaultIdx) opt.selected = true;
targetSelect.appendChild(opt);
}
// 排序方式 segmented —— 选中态用 .activearia-checked 同步刷新以保证
// 屏幕阅读器与视觉态一致role="radiogroup" / role="radio" 模式)。
const setOrderActive = (val) => {
orderSeg.querySelectorAll('.import-dialog-seg-btn').forEach(b => {
const isActive = b.dataset.pos === val;
b.classList.toggle('active', isActive);
b.setAttribute('aria-checked', String(isActive));
});
};
setOrderActive(currentImportPos);
orderSeg.addEventListener('click', (e) => {
const btn = e.target.closest('.import-dialog-seg-btn');
if (!btn) return;
const newPos = btn.dataset.pos;
if (!VALID_IMPORT_POSITION.has(newPos)) return;
currentImportPos = newPos;
setOrderActive(newPos);
// 立刻把选择写回 settingsStorefire-and-forget
// - 用户每次打开对话框都能延续上次的设置,无需每次重选
// - 不 await写盘失败不影响本次对话框关闭流程与 settings-dialog
// 一样「内存优先、落盘兜底」的策略)
if (settingsStore && typeof settingsStore.update === 'function') {
settingsStore.update({ importPosition: newPos }).catch((err) => {
// IPC 失败 console 出来便于排查,不打断 UI —— 用户这次的选择
// 至少在当前会话有效,关掉对话框重开会回到上次成功的值。
console.warn('[import-dialog] settingsStore.update 失败:', err?.message || err);
});
}
}, { signal });
/**
* 把 textarea 内容按行解析,返回 { added, skipped, completedCount }。
* 与 task-store.importTasksToCategory 共用 parseImportLine —— 实时预览
* 必须与最终导入结果完全一致,避免「预览 X 条 → 确认后变 Y 条」。
*/
const computeCount = () => {
const strip = stripCheckbox.checked;
const lines = textarea.value.split(/\r?\n/);
let added = 0;
let skipped = 0;
let completedCount = 0;
for (const line of lines) {
const parsed = parseImportLine(line, { stripLeadingBullets: strip });
if (!parsed) {
// 只计入「非空但解析失败」的跳过行 —— 纯空白行不计入,
// 否则"跳过 N 行"会被无意义的换行拉高,用户反而看不出来
// 哪一行真的有问题。
if (line.trim() !== '') skipped++;
continue;
}
added++;
if (parsed.completed) completedCount++;
}
return { added, skipped, completedCount };
};
/**
* 把当前解析结果写回 UI计数文字 + 提交按钮可用性。
* added === 0 时禁用「导入」按钮 —— 空文本 / 全空行不应触发空事件、空 dirty。
*/
const syncCount = () => {
const { added, skipped, completedCount } = computeCount();
const parts = [`将导入 ${added} 条任务`];
if (completedCount > 0) parts.push(`${completedCount} 条已完成`);
if (skipped > 0) parts.push(`跳过 ${skipped}`);
countEl.textContent = parts.join(' · ');
countEl.classList.toggle('import-dialog-count-empty', added === 0);
okBtn.disabled = added === 0;
};
// 输入变化 / 选项变化 → 实时更新计数
// M-S11: rAF + 100ms debounce —— 大段粘贴数千行input 事件触发密集,
// 每次同步都跑一遍 split + 解析会让 textarea 卡顿。先 debounce 100ms
// 人眼感知不到延迟),再 requestAnimationFrame 把状态写回 DOM避免 layout
// 抖动。stripCheckbox 变化是低频事件,直接同步即可。
let syncRafId = 0;
let syncTimerId = 0;
const scheduleSync = () => {
clearTimeout(syncTimerId);
syncTimerId = setTimeout(() => {
syncTimerId = 0;
if (syncRafId) return;
syncRafId = requestAnimationFrame(() => {
syncRafId = 0;
syncCount();
});
}, 100);
};
textarea.addEventListener('input', scheduleSync, { signal });
stripCheckbox.addEventListener('change', syncCount, { signal });
let resolved = false;
const close = (result) => {
if (resolved) return;
resolved = true;
// 清理 input 同步的 debounce / rAF —— close 后这些回调还会 fire 一次,
// 调 syncCount() 触碰已 detach 的 countEl / okBtnrace log 噪音 + 浪费)。
clearTimeout(syncTimerId);
syncTimerId = 0;
if (syncRafId) {
cancelAnimationFrame(syncRafId);
syncRafId = 0;
}
ac.abort();
// 缩放淡出 + focus 恢复挪到 animationend 内
const inner = overlay.querySelector('.modal');
// animationend 与 250ms 兜底共享 finalize —— 避免 animationend 漏触发
// display:none / 元素被提前 detach时 setTimeout 仅 remove、不 restoreFocus+resolve
// Promise 永久挂起,调用方 await 永远不返回。
const finalize = () => {
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
previouslyFocused.focus();
}
overlay.remove();
resolve(result);
};
if (inner) {
inner.classList.add('is-closing');
inner.addEventListener('animationend', finalize, { once: true });
// 兜底必须 resolve参见 finalize 注释
setTimeout(finalize, 250);
} else {
finalize();
}
};
overlay.addEventListener('click', (e) => {
const action = e.target.closest('[data-action]')?.dataset.action;
if (action === 'ok') {
if (okBtn.disabled) return;
// 走到这里意味着 added > 0guard 已闭合
const categoryName = targetSelect.value;
const lines = textarea.value.split(/\r?\n/);
const strip = stripCheckbox.checked;
// 真正导入 —— 直接调 task-store共用 parseImportLine + 批量路径(仅一次 emit
// importPosition 透传 —— 与 settingsStore 中的 importPosition 偏好完全解耦,
// 用户在对话框内即时切换即生效(不再走 newTaskPosition / completedPosition
const result = store.importTasksToCategory(categoryName, lines, {
stripLeadingBullets: strip,
importPosition: currentImportPos
});
if (result.ok) {
close({ added: result.added, categoryName });
} else {
// 当前 UI 已经限制了只能选子分类,但防御性兜底:万一遇到无效分类,
// 给用户明确反馈,不静默关闭。
countEl.textContent = '导入失败:目标分类不可用';
countEl.classList.add('import-dialog-count-error');
okBtn.disabled = true;
}
} else if (action === 'cancel' || e.target === overlay) {
close(null);
}
}, { signal });
// IME-safe Enter + Esc + 焦点陷阱(与 inputDialog 对齐)
const onKey = (e) => {
if (e.key === 'Escape') {
// IME 守卫CJK/JP/KR 用户在选词中按 Esc 通常是「取消 IME 组合」,
// 不是「关闭导入对话框」。textarea 上的 IME 取消要优先放行。
if (isImeComposing(e)) return;
e.preventDefault();
close(null);
} else if (isCmdEnter(e) && !isImeComposing(e)) {
// Ctrl/Cmd+Enter 在 textarea 里提交(与很多编辑器习惯一致);
// 单 Enter 必须保留为换行 —— 否则粘贴多行后回车就意外提交了。
e.preventDefault();
if (!okBtn.disabled) okBtn.click();
} else if (e.key === 'Tab') {
// 焦点陷阱textarea 内部 Tab 仍可缩进,但 textarea 自身不响应 Tab 键的
// focus 跳转 —— 浏览器的默认行为是从 textarea 末尾跳到地址栏。
// 这里仅在 textarea 之外拦截textarea 内放行让浏览器默认处理(输入 Tab
if (document.activeElement !== textarea) {
trapFocus(e, overlay, signal);
}
}
};
overlay.addEventListener('keydown', onKey, { signal, capture: true });
// 计数立即算一次默认值「0 条」已经渲染了占位,但调用 computeCount
// 不依赖 DOM 完成sync 跑一次免去 setTimeout 内等待的闪烁感)。
syncCount();
// 聚焦 textarea 必须 setTimeout避开弹窗动画期间的 IME 残留 isComposing
// 也避免 50ms 内的焦焦点漂移被浏览器自动 blur 弹窗外。
setTimeout(() => textarea.focus(), 50);
});
}
// 焦点陷阱trapFocus 从 src/utils/focus-trap.js 导入(与 feedback.js / conflict-dialog.js 共享)

825
src/ui/settings-dialog.js Normal file
View File

@@ -0,0 +1,825 @@
// 设置对话框 - 数据目录、主题等
import { toast, runBeforeModalShow } from './feedback.js';
import { openDataDirWithToast } from '../utils/data-dir.js';
import { displayPath } from '../utils/dom.js';
import { isImeComposing } from '../utils/keymap.js';
/**
* 打开设置对话框
* @param {SettingsStore} settingsStore
* @returns {Promise<{changed: boolean}>} 是否修改了需要重新加载的设置(数据目录)
*/
export function openSettingsDialog(settingsStore) {
return new Promise((resolve) => {
// M8挂载 overlay 之前先清掉所有进行中的瞬态状态(拖拽 / 菜单 / 等)。
runBeforeModalShow();
// AbortController 必须最先创建 —— 所有 addEventListener 都要传 { signal },否则
// 每次打开对话框都会叠加一份「永远不会被 ac.abort() 清掉」的监听器,
// 反复开关几次后主题/字号切换会被触发多次,按钮事件会被处理两遍。
const ac = new AbortController();
const { signal } = ac;
// 共享关闭守卫finish() / cancel() 任一先到都会 resolve Promise再来一次会被忽略。
// 没有它:点「完成」期间按 Esc → finish 已经在 await settingsStore.update持久化
// cancel 又跑一遍 → cancel 先 resolve({changed:false}),用户改的设置"看着已生效"
// 但调用方拿到 changed=false 不重载数据目录update 后续仍写盘但 IPC 端无 UI 反馈。
// 加 closed 后 cancel 看到 finish 已先动就直接退出,不会触发第二次动画/resolve。
// 与 feedback.js 的 `resolved` 不同:这里在 finish/cancel 入口立即置位(同步),
// settle() 不检查自己、再交由 Promise 自身的"忽略后续 resolve"语义兜底 —— 这样
// animationend 和 250ms 兜底两路 finalize 都可以放心地调 settle。
let closed = false;
const settle = (value) => {
// 这里不检查 closedPromise 本身会忽略后续 resolve多次调用是幂等的。
// closed 只防止 finish/cancel 入口被并发进入(参见各函数 if (closed) return
resolve(value);
};
// 记下打开前的焦点元素,关闭时还原 —— 否则用户按 Ctrl+, 开设置、Esc 关掉后,
// 焦点漂在 document.body 上,键盘流断掉。
const previouslyFocused = document.activeElement;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay settings-overlay';
overlay.innerHTML = `
<div class="modal settings-modal" role="dialog" aria-modal="true" aria-labelledby="settings-title">
<div class="modal-header settings-header">
<div class="settings-header-left">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
<span id="settings-title">设置</span>
</div>
<button class="btn-icon-sm settings-close" data-action="cancel" title="关闭 (Esc)">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 6 6 18"/>
<path d="m6 6 12 12"/>
</svg>
</button>
</div>
<div class="modal-body settings-body">
<!-- 外观 -->
<section class="settings-section">
<div class="settings-section-header">
<h3 class="settings-section-title">外观</h3>
<p class="settings-section-desc">个性化你的工作界面</p>
</div>
<div class="settings-row">
<label class="settings-label">主题</label>
<div class="settings-segmented" data-setting="theme">
<button data-value="dark" class="seg-btn">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
暗色
</button>
<button data-value="light" class="seg-btn">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2"/>
<path d="M12 20v2"/>
<path d="m4.93 4.93 1.41 1.41"/>
<path d="m17.66 17.66 1.41 1.41"/>
<path d="M2 12h2"/>
<path d="M20 12h2"/>
<path d="m6.34 17.66-1.41 1.41"/>
<path d="m19.07 4.93-1.41 1.41"/>
</svg>
浅色
</button>
<button data-value="auto" class="seg-btn">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
<path d="M12 2a10 10 0 0 0 0 20z" fill="currentColor" stroke="none"/>
</svg>
跟随系统
</button>
</div>
</div>
<div class="settings-row">
<label class="settings-label">字体大小</label>
<div class="settings-segmented" data-setting="fontSize">
<button data-value="small" class="seg-btn">
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
<path d="M2.5 4a.75.75 0 01.75-.75h7.5a.75.75 0 010 1.5h-7.5A.75.75 0 012.5 4zm0 4a.75.75 0 01.75-.75h10a.75.75 0 010 1.5h-10A.75.75 0 012.5 8zm.75 3.25a.75.75 0 000 1.5h5.5a.75.75 0 000-1.5h-5.5z"/>
</svg>
</button>
<button data-value="medium" class="seg-btn">
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
<path d="M2 4a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H2.75A.75.75 0 012 4zm0 4.5a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H2.75a.75.75 0 01-.75-.75zm-.75 3.75a.75.75 0 01.75-.75h6.5a.75.75 0 010 1.5h-6.5a.75.75 0 01-.75-.75z"/>
</svg>
标准
</button>
<button data-value="large" class="seg-btn">
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
<path d="M1.5 3.75a.75.75 0 01.75-.75h11.5a.75.75 0 010 1.5H2.25a.75.75 0 01-.75-.75zm0 4.5a.75.75 0 01.75-.75h11.5a.75.75 0 010 1.5H2.25a.75.75 0 01-.75-.75zm-.75 3.75a.75.75 0 01.75-.75h7.5a.75.75 0 010 1.5h-7.5a.75.75 0 01-.75-.75z"/>
</svg>
</button>
</div>
</div>
<div class="settings-row">
<label class="settings-label">配色风格</label>
<div class="settings-segmented settings-segmented-color" data-setting="colorStyle">
<button data-value="indigo" class="seg-btn" title="靛蓝Linear 风格默认)">
<span class="color-swatch" style="background:#4f46e5"></span>靛蓝
</button>
<button data-value="ocean" class="seg-btn" title="海洋(蓝青)">
<span class="color-swatch" style="background:#0284c7"></span>海洋
</button>
<button data-value="forest" class="seg-btn" title="森林(翠绿)">
<span class="color-swatch" style="background:#059669"></span>森林
</button>
<button data-value="sunset" class="seg-btn" title="日落(暖橙)">
<span class="color-swatch" style="background:#ea580c"></span>日落
</button>
<button data-value="rose" class="seg-btn" title="玫瑰(玫红)">
<span class="color-swatch" style="background:#e11d48"></span>玫瑰
</button>
<button data-value="mono" class="seg-btn" title="单色(中性灰)">
<span class="color-swatch" style="background:#475569"></span>单色
</button>
</div>
<p class="settings-hint">
与「主题(明/暗)」正交:每种配色都包含浅色和深色两个变体,可独立选择
</p>
</div>
</section>
<!-- 数据存储位置 -->
<section class="settings-section">
<div class="settings-section-header">
<h3 class="settings-section-title">数据存储</h3>
<p class="settings-section-desc">所有任务以 Markdown 文件形式存储在此文件夹下</p>
</div>
<div class="settings-row">
<label class="settings-label">当前位置</label>
<div class="settings-input-group">
<input type="text" id="settings-data-dir" class="settings-input" readonly>
<button class="btn" id="settings-open-dir" title="在文件管理器中打开">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M15 3h6v6"/>
<path d="M10 14 21 3"/>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
</svg>
<span>打开</span>
</button>
<button class="btn" id="settings-choose-dir" title="选择其他文件夹">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>
<circle cx="11" cy="13" r="2"/>
<path d="m21 21-2.5-2.5"/>
</svg>
<span>浏览</span>
</button>
</div>
<p class="settings-hint">
默认:
<code id="settings-default-dir">…</code>
<button class="settings-link" id="settings-reset-dir">恢复默认</button>
</p>
</div>
<div class="settings-row">
<label class="settings-label">每日自动备份</label>
<div class="settings-segmented" data-setting="backupEnabled">
<button data-value="false" class="seg-btn">关闭</button>
<button data-value="true" class="seg-btn">开启</button>
</div>
<p class="settings-hint">
开启后,每天首次打开软件或首次操作时,自动在数据目录下创建 <code>backup/</code> 文件夹并保存一份 <code>todo-YYYY-MM-DD.md</code>
</p>
</div>
</section>
<!-- 任务列表 -->
<section class="settings-section">
<div class="settings-section-header">
<h3 class="settings-section-title">任务列表</h3>
<p class="settings-section-desc">调整新增、完成、删除、移动时最新一条的落点</p>
</div>
<div class="settings-row">
<label class="settings-label">新增任务</label>
<div class="settings-segmented" data-setting="newTaskPosition">
<button data-value="front" class="seg-btn">前面</button>
<button data-value="back" class="seg-btn">后面</button>
</div>
<p class="settings-hint">
最新添加的任务落到列表的「前面」(最上面)或「后面」(最下面)
</p>
</div>
<div class="settings-row">
<label class="settings-label">已完成任务</label>
<div class="settings-segmented" data-setting="completedPosition">
<button data-value="front" class="seg-btn">前面</button>
<button data-value="back" class="seg-btn">后面</button>
</div>
<p class="settings-hint">
打勾完成的任务搬到「已完成任务」分类时的落点
</p>
</div>
<div class="settings-row">
<label class="settings-label">回收站</label>
<div class="settings-segmented" data-setting="trashPosition">
<button data-value="front" class="seg-btn">前面</button>
<button data-value="back" class="seg-btn">后面</button>
</div>
<p class="settings-hint">
删除的任务搬到「回收站」时的落点
</p>
</div>
<div class="settings-row">
<label class="settings-label">标记为未完成</label>
<div class="settings-segmented" data-setting="uncompletePosition">
<button data-value="front" class="seg-btn">前面</button>
<button data-value="back" class="seg-btn">后面</button>
</div>
<p class="settings-hint">
已完成任务取消勾选后回到原分类时的落点
</p>
</div>
<div class="settings-row">
<label class="settings-label">恢复任务</label>
<div class="settings-segmented" data-setting="restorePosition">
<button data-value="front" class="seg-btn">前面</button>
<button data-value="back" class="seg-btn">后面</button>
</div>
<p class="settings-hint">
回收站里的任务恢复到原分类时的落点
</p>
</div>
<div class="settings-row">
<label class="settings-label">移到分类</label>
<div class="settings-segmented" data-setting="movePosition">
<button data-value="front" class="seg-btn">前面</button>
<button data-value="back" class="seg-btn">后面</button>
</div>
<p class="settings-hint">
右键「移到分类…」或批量移动时,任务落到目标分类的哪个位置
</p>
</div>
</section>
<!-- 关于 -->
<section class="settings-section">
<div class="settings-section-header">
<h3 class="settings-section-title">关于</h3>
<p class="settings-section-desc">应用信息和运行时环境</p>
</div>
<div class="settings-about">
<div class="settings-about-row">
<span class="settings-about-key">应用</span>
<span class="settings-about-val">Todo List</span>
</div>
<div class="settings-about-row">
<span class="settings-about-key">版本</span>
<span class="settings-about-val" id="settings-version">…</span>
</div>
<div class="settings-about-row">
<span class="settings-about-key">Electron</span>
<span class="settings-about-val" id="settings-electron">…</span>
</div>
<div class="settings-about-row">
<span class="settings-about-key">开发者</span>
<a class="settings-about-val settings-about-link" href="#" id="settings-developer-link" title="打开开发者主页">关济寰</a>
</div>
</div>
</section>
</div>
<div class="modal-footer">
<button class="btn btn-link-danger" id="settings-reset-all" aria-label="恢复全部默认设置(主题、字号、配色、备份开关、插入位置、数据目录)">恢复全部默认</button>
<button class="btn btn-primary" data-action="done">完成</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const inputEl = overlay.querySelector('#settings-data-dir');
const chooseBtn = overlay.querySelector('#settings-choose-dir');
const openBtn = overlay.querySelector('#settings-open-dir');
const resetBtn = overlay.querySelector('#settings-reset-dir');
const defaultDirEl = overlay.querySelector('#settings-default-dir');
const versionEl = overlay.querySelector('#settings-version');
const electronEl = overlay.querySelector('#settings-electron');
const resetAllBtn = overlay.querySelector('#settings-reset-all');
const developerLinkEl = overlay.querySelector('#settings-developer-link');
// 初始化数据
const initial = settingsStore.all;
inputEl.placeholder = '(使用默认)';
// 当前生效路径
window.api.getDataDir().then((dir) => {
// dialog 已关(用户在 IPC 期间按 Esc / 点 X→ 不写 detached DOM
if (signal.aborted) return;
inputEl.dataset.current = dir;
inputEl.value = dir;
}).catch((e) => {
// v4+ 修复:缺 .catch → IPC 偶发失败(如 main process 临时掉线)会让 promise
// 进入 unhandled rejection整条错误冒到 window.onerror这里与 line 335 的
// getElectronVersion 同款兜底 —— 失败时把 input 留空,让用户在「打开」按钮里
// 自助操作,而不是让 UI 静默显示过期值。
if (signal.aborted) return;
console.warn('[settings-dialog] getDataDir failed:', e?.message || e);
inputEl.value = '';
});
// 默认路径:用 `~/...` 显示形式 —— 不暴露用户名,跨用户一致。
// 完整绝对路径放 title 属性hover 时能看到(想复制可以走 input 框或「打开」按钮)。
Promise.all([window.api.getDefaultDataDir(), window.api.getHomeDir?.()])
.then(([dir, home]) => {
if (signal.aborted) return;
const display = displayPath(dir, home);
defaultDirEl.textContent = display;
defaultDirEl.title = dir;
})
.catch((e) => {
if (signal.aborted) return;
console.warn('[settings-dialog] getDefaultDataDir/homeDir failed:', e?.message || e);
defaultDirEl.textContent = '(无法读取)';
});
window.api.getVersion().then((v) => {
if (signal.aborted) return;
versionEl.textContent = v;
}).catch((e) => {
if (signal.aborted) return;
console.warn('[settings-dialog] getVersion failed:', e?.message || e);
versionEl.textContent = '—';
});
// Electron 版本从主进程读取renderer 无 process 全局)
if (window.api.getElectronVersion) {
window.api.getElectronVersion().then((v) => {
if (signal.aborted) return;
electronEl.textContent = v || '—';
}).catch(() => {
if (signal.aborted) return;
electronEl.textContent = '—';
});
} else {
electronEl.textContent = '—';
}
// 开发者主页链接 —— 单独存到 dataset.href 而不是直接写 href避免被搜索引擎
// / 爬虫误读。点击时再读出来走 IPC 让主进程 shell.openExternal 打开浏览器。
if (developerLinkEl) {
developerLinkEl.dataset.href = 'https://www.guanjihuan.com/about';
}
// 分段按钮(主题)— 即时预览,取消时恢复
const themeSeg = overlay.querySelector('[data-setting="theme"]');
const setSegActive = (val) => {
themeSeg.querySelectorAll('.seg-btn').forEach(b => {
b.classList.toggle('active', b.dataset.value === val);
});
};
// 记录打开对话框时的主题,取消时用来恢复
const originalTheme = initial.theme;
const originalResolvedTheme = document.body.dataset.theme;
// 同样记录字体大小dialog 打开预览后,点取消要把字号也回滚到原值
const originalFontSize = document.body.dataset.fontSize || 'medium';
setSegActive(initial.theme);
themeSeg.addEventListener('click', async (e) => {
const btn = e.target.closest('.seg-btn');
if (!btn) return;
const newTheme = btn.dataset.value;
setSegActive(newTheme);
inputEl.dataset.pendingTheme = newTheme;
// 即时预览:解析 auto 并为 body 应用,让用户当场看到效果
let resolved = newTheme;
if (newTheme === 'auto') {
resolved = window.matchMedia?.('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
document.body.dataset.theme = resolved;
}, { signal });
// 分段按钮(字体大小)— 即时预览
const fontSizeSeg = overlay.querySelector('[data-setting="fontSize"]');
const setFontSizeActive = (val) => {
fontSizeSeg.querySelectorAll('.seg-btn').forEach(b => {
b.classList.toggle('active', b.dataset.value === val);
});
};
setFontSizeActive(initial.fontSize || 'medium');
fontSizeSeg.addEventListener('click', async (e) => {
const btn = e.target.closest('.seg-btn');
if (!btn) return;
const newFontSize = btn.dataset.value;
setFontSizeActive(newFontSize);
inputEl.dataset.pendingFontSize = newFontSize;
// 即时预览
document.body.dataset.fontSize = newFontSize;
}, { signal });
// 记录打开对话框时的配色风格,取消时用来恢复(与 fontSize 同款策略)
const originalColorStyle = initial.colorStyle || 'indigo';
// 分段按钮(配色风格)— 即时预览
// 配色风格由 body[data-color-style] 走 CSS 复合选择器解析,无需 JS 联动
const colorStyleSeg = overlay.querySelector('[data-setting="colorStyle"]');
const setColorStyleActive = (val) => {
colorStyleSeg.querySelectorAll('.seg-btn').forEach(b => {
b.classList.toggle('active', b.dataset.value === val);
});
};
setColorStyleActive(originalColorStyle);
colorStyleSeg.addEventListener('click', async (e) => {
const btn = e.target.closest('.seg-btn');
if (!btn) return;
const newStyle = btn.dataset.value;
setColorStyleActive(newStyle);
inputEl.dataset.pendingColorStyle = newStyle;
// 即时预览CSS 复合选择器 [data-theme][data-color-style] 自动生效
document.body.dataset.colorStyle = newStyle;
}, { signal });
// 分段按钮(每日自动备份)— 布尔值用同样的 segmented 风格保持 UI 一致
const backupSeg = overlay.querySelector('[data-setting="backupEnabled"]');
const setBackupActive = (val) => {
// 把 boolean 转成 'true' / 'false' 字符串一次,下面跟 button 的 data-value 直接比;
// 否则初次打开时 initial.backupEnabled === false 会和 'false' 字符串不相等,
// UI 默认选中态就丢了
const target = String(val);
backupSeg.querySelectorAll('.seg-btn').forEach(b => {
b.classList.toggle('active', b.dataset.value === target);
});
};
setBackupActive(Boolean(initial.backupEnabled));
backupSeg.addEventListener('click', (e) => {
const btn = e.target.closest('.seg-btn');
if (!btn) return;
const newValue = btn.dataset.value === 'true';
setBackupActive(newValue);
inputEl.dataset.pendingBackupEnabled = newValue ? '1' : '0';
}, { signal });
// 分段按钮(任务插入位置)— 通用 helper
// - 三个 *Position 设置共用同一份 UI 风格(前面 / 后面 二选一)
// - 选中态实时预览,点击即记录 pending 值到 inputEl.dataset
// - 「未点过」靠 dataset 上没这个属性来识别(与 backupEnabled 同款策略)
const initPositionSeg = (segEl, settingKey) => {
const setActive = (val) => {
const target = String(val);
segEl.querySelectorAll('.seg-btn').forEach(b => {
b.classList.toggle('active', b.dataset.value === target);
});
};
// settingsStore 未加载完成时 initial[settingKey] 是 undefined —— 视为默认值 'front'
const initialVal = initial[settingKey] || 'front';
setActive(initialVal);
segEl.addEventListener('click', (e) => {
const btn = e.target.closest('.seg-btn');
if (!btn) return;
const newValue = btn.dataset.value;
setActive(newValue);
inputEl.dataset[settingKey] = newValue;
}, { signal });
};
initPositionSeg(
overlay.querySelector('[data-setting="newTaskPosition"]'),
'newTaskPosition'
);
initPositionSeg(
overlay.querySelector('[data-setting="completedPosition"]'),
'completedPosition'
);
initPositionSeg(
overlay.querySelector('[data-setting="uncompletePosition"]'),
'uncompletePosition'
);
initPositionSeg(
overlay.querySelector('[data-setting="trashPosition"]'),
'trashPosition'
);
initPositionSeg(
overlay.querySelector('[data-setting="restorePosition"]'),
'restorePosition'
);
initPositionSeg(
overlay.querySelector('[data-setting="movePosition"]'),
'movePosition'
);
// 浏览文件夹 - 仅更新 UI不立即应用
chooseBtn.addEventListener('click', async () => {
// v4+ 修复async listener 里 await reject → unhandled rejectionDOM 事件
// 系统不接 Promise。native 目录选择器失败(极端:权限 / shell 崩溃)时
// 用户只看到「点了没反应」,且控制台冒未捕获错误。包 try/catch + toast。
let chosen;
try {
chosen = await window.api.chooseDataDir();
} catch (e) {
if (signal.aborted) return;
console.warn('[settings-dialog] chooseDataDir failed:', e?.message || e);
toast('打开文件夹选择器失败,可直接在输入框里粘贴路径', 'error', 3000);
return;
}
// 文件选择对话框是 native用户可能按 Esc 关掉 → finish()/cancel() 已经
// 走完overlay.remove() 完毕inputEl 已脱离 DOM。此时若继续写 inputEl.value
// 会:(a) 静默吞掉用户的选择 (b) 把 detached 节点拖在闭包里直到 GC。
// 守卫 signal.aborteddialog 已关就什么也不做。
if (signal.aborted) return;
if (chosen) {
inputEl.value = chosen;
inputEl.dataset.userChanged = '1';
}
}, { signal });
// 打开数据文件夹 - 调用系统文件管理器
openBtn.addEventListener('click', () => openDataDirWithToast(), { signal });
// 开发者主页链接 - 走 IPC 让主进程 shell.openExternal 打开浏览器
// 必须 preventDefault + 拦截 href否则 Electron 默认会在窗口内跳转,
// 触发 hash 变化 / 导航走主进程 loadFile 路径,破坏 SPA 状态
if (developerLinkEl) {
developerLinkEl.addEventListener('click', async (e) => {
e.preventDefault();
const url = developerLinkEl.dataset.href || developerLinkEl.getAttribute('href');
if (!url || url === '#') return;
try {
const res = await window.api.openExternal(url);
if (res && res.ok === false) {
if (!signal.aborted) toast('打开链接失败:' + (res.error || '未知错误'), 'error', 3000);
}
} catch (err) {
if (!signal.aborted) toast('打开链接失败:' + (err.message || err), 'error', 3000);
}
}, { signal });
}
// 按钮点击后短暂闪一下「✓ 已恢复」,给用户即时反馈(不光是底部 toast
// 不在这里用「disabled = true」用户可能改主意再点一次取消禁用会让
// 状态看起来「卡住了」;用临时文本 + 样式做瞬态反馈即可。
// 注意:定时器不挂在 signal 上setTimeout 没有 signal 选项)—— 改在 cancel() /
// finish() 的 finalize 阶段由调用方 clear 掉,避免 dialog 关掉后 timer 仍写
// detached 节点。
const resetFeedbackTimers = new Set();
function flashResetFeedback(btn, msg = '✓ 已恢复') {
if (!btn) return;
const original = btn._resetOriginalText ?? btn.textContent;
btn._resetOriginalText = original;
btn.textContent = msg;
btn.classList.add('settings-link-applied');
if (btn._resetFeedbackTimer) clearTimeout(btn._resetFeedbackTimer);
btn._resetFeedbackTimer = setTimeout(() => {
btn.textContent = original;
btn.classList.remove('settings-link-applied');
btn._resetFeedbackTimer = null;
}, 1600);
resetFeedbackTimers.add(btn._resetFeedbackTimer);
}
function clearAllResetFeedbackTimers() {
for (const t of resetFeedbackTimers) clearTimeout(t);
resetFeedbackTimers.clear();
}
// 恢复默认 - 仅更新 UI
resetBtn.addEventListener('click', async () => {
let defaultDir;
try {
defaultDir = await window.api.getDefaultDataDir();
} catch (e) {
console.warn('[settings] getDefaultDataDir 失败:', e);
if (!signal.aborted) toast('获取默认目录失败:' + (e.message || e), 'error', 3000);
return;
}
// dialog 已关 → 不再操作 detached DOM也不发 toastdialog 都关了用户看不到)
if (signal.aborted) return;
inputEl.value = defaultDir;
inputEl.dataset.userChanged = '1';
inputEl.dataset.resetToDefault = '1';
flashResetFeedback(resetBtn);
toast('将在点击「完成」后恢复默认', 'info', 1500);
}, { signal });
// 恢复全部默认 - 立即应用其他设置(主题 + 字号 + 备份 + 三个插入位置UI 标记 dataDir 待重置
// 始终置顶由工具栏按钮 / 托盘菜单控制,不在设置面板里改
resetAllBtn.addEventListener('click', () => {
setSegActive('dark');
inputEl.dataset.pendingTheme = 'dark';
setFontSizeActive('medium');
inputEl.dataset.pendingFontSize = 'medium';
document.body.dataset.fontSize = 'medium';
setColorStyleActive('indigo');
inputEl.dataset.pendingColorStyle = 'indigo';
document.body.dataset.colorStyle = 'indigo';
setBackupActive(true);
inputEl.dataset.pendingBackupEnabled = '1';
// 六个 *Position 设置全部回到默认 'front'
inputEl.dataset.newTaskPosition = 'front';
inputEl.dataset.completedPosition = 'front';
inputEl.dataset.uncompletePosition = 'front';
inputEl.dataset.trashPosition = 'front';
inputEl.dataset.restorePosition = 'front';
inputEl.dataset.movePosition = 'front';
for (const key of ['newTaskPosition', 'completedPosition', 'uncompletePosition', 'trashPosition', 'restorePosition', 'movePosition']) {
const seg = overlay.querySelector(`[data-setting="${key}"]`);
if (!seg) continue;
seg.querySelectorAll('.seg-btn').forEach(b => {
b.classList.toggle('active', b.dataset.value === 'front');
});
}
inputEl.dataset.userChanged = '1';
inputEl.dataset.resetToDefault = '1';
window.api.getDefaultDataDir().then((dir) => {
// dialog 已关(用户在 IPC 期间按 Esc / 点 X→ 不写 detached DOM
// 与同文件 line 311 / 319 / 326 / 333 等其它 getDataDir / getDefaultDataDir
// 异步回调同款守卫。原代码漏了这个守卫dialog 关掉后仍往 detached
// inputEl.value 写入默认目录 —— 闭包拖住整个 dialog 状态到 GC 才释放。
if (signal.aborted) return;
inputEl.value = dir;
}).catch((e) => {
// v4+ 修复:缺 .catch → 失败冒 unhandled rejection与 line 335/新加 line 311
// 的兜底同款思路 —— dialog 已关就静默,留着 inputEl.value 不变即可。
if (signal.aborted) return;
console.warn('[settings-dialog] getDefaultDataDir failed:', e?.message || e);
});
flashResetFeedback(resetAllBtn, '✓ 已恢复全部');
toast('将在点击「完成」后恢复全部默认', 'info', 1500);
}, { signal });
// 完成 - 统一应用所有更改
const finish = async () => {
if (closed) return;
ac.abort();
closed = true; // 立即置位:保证 cancel 在 await 期间再来时不会重跑动画 + 不会
// 重复触发 update更新盘 + reload 都靠 await 期间的状态同步)
// 同 cancel清掉所有 reset 反馈定时器,避免 timer 写 detached 节点。
clearAllResetFeedbackTimers();
let needReload = false;
const updates = {};
// 检测数据目录是否改变
const before = inputEl.dataset.current || '';
const after = inputEl.value || '';
const wantsDefault = inputEl.dataset.resetToDefault === '1';
if (inputEl.dataset.userChanged === '1' && (before !== after || wantsDefault)) {
updates.dataDir = wantsDefault ? null : after;
needReload = true;
}
// 主题
if (inputEl.dataset.pendingTheme && inputEl.dataset.pendingTheme !== initial.theme) {
updates.theme = inputEl.dataset.pendingTheme;
}
// 字体大小
if (inputEl.dataset.pendingFontSize && inputEl.dataset.pendingFontSize !== (initial.fontSize || 'medium')) {
updates.fontSize = inputEl.dataset.pendingFontSize;
}
// 配色风格(与 fontSize 同款比较模式:未点 = undefined 不进 updates
if (inputEl.dataset.pendingColorStyle && inputEl.dataset.pendingColorStyle !== (initial.colorStyle || 'indigo')) {
updates.colorStyle = inputEl.dataset.pendingColorStyle;
}
// 每日自动备份开关 —— 用 pendingBackupEnabled'1'/'0')而不是 seg-btn 的 dataset.value
// 这样「从未点过」也能被识别:未点 → dataset 上没这个属性 → 不进 updates
// 点过 → 进 updates 并触发持久化。注意 boolean 比较要 coerce
if (inputEl.dataset.pendingBackupEnabled !== undefined) {
const newBackup = inputEl.dataset.pendingBackupEnabled === '1';
if (newBackup !== Boolean(initial.backupEnabled)) {
updates.backupEnabled = newBackup;
}
}
// 任务插入位置(前面 / 后面)—— 与 backupEnabled 同款策略:
// - 用户**从未点过** → dataset 上没对应属性 → 不进 updates避免把默认值误写盘
// - 用户**点过** → 与 initial 对比,变了才进 updates
// 注意 isValidValue 已经把 'front' / 'back' 之外的值拒掉,这里不重复校验
for (const key of ['newTaskPosition', 'completedPosition', 'uncompletePosition', 'trashPosition', 'restorePosition', 'movePosition']) {
const pending = inputEl.dataset[key];
if (pending === undefined) continue;
const initialVal = initial[key] || 'front';
if (pending !== initialVal) {
updates[key] = pending;
}
}
if (Object.keys(updates).length > 0) {
// settingsStore.update 会同步触发 'change' 事件:
// - toolbar.js 的 'change' 监听器负责更新 store.theme 和 body.dataset.theme
// (通过 this.store.resolveTheme()),无需在这里重复赋值
// - app.js 的 'change' 监听器负责同步 fontSize 到 body.dataset.fontSize
// 直接 store.theme = updates.theme 是死代码 —— Toolbar 监听器已在同一同步帧
// 内通过 resolveTheme() 做了相同的事,重复赋值只会让后续维护者困惑
try {
await settingsStore.update(updates);
} catch (e) {
// 持久化失败(盘满、权限等):不要吞掉,得让用户知道。
// toolbar.js 已订阅 save-error 并 toast这里再抛一次以保留 Promise reject 链
toast(`设置保存失败:${e.message || e}`, 'error', 4000);
// 仍然关闭面板 —— 用户已经在 UI 上看到了预览态,留着不让关只会让他们更迷惑
}
}
// 缩放淡出后再 remove与 confirmDialog/inputDialog 同款),焦点恢复
// 必须在 animationend 内 —— 否则 focus 的元素已经被销毁,焦点跳到 body。
// focus() 必须在 overlay.remove() 之前(见 feedback.js 同款注释)。
const inner = overlay.querySelector('.modal');
const finalize = () => {
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
previouslyFocused.focus();
}
overlay.remove();
settle({ changed: needReload });
};
if (inner) {
inner.classList.add('is-closing');
inner.addEventListener('animationend', finalize, { once: true });
// 兜底必须 settle否则 animationend 漏触发会让 Promise 永久挂起
setTimeout(finalize, 250);
} else {
finalize();
}
};
// 单一 AbortController 统一回收所有监听器
// (已经在函数顶部创建 —— 这里只是保留引用,方便 finish() 也能 abort
const cancel = () => {
if (closed) return;
ac.abort();
closed = true; // 同 finish():先置位,再做恢复
// 关 dialog 时清掉所有 reset 反馈定时器 —— 否则 timer 在 detached 节点上
// 继续写 .textContent / .classList闭包把整个 dialog 状态拖到 GC 才释放。
clearAllResetFeedbackTimers();
// 恢复预览的主题(如果用户点过主题按钮的话)
if (inputEl.dataset.pendingTheme) {
document.body.dataset.theme = originalResolvedTheme;
}
// 恢复预览的字体大小(如果用户改过字号的话)—— 之前只回滚主题,
// 用户改了字号就留下了"看似应用、点取消又改回去"的视觉副作用。
if (inputEl.dataset.pendingFontSize) {
document.body.dataset.fontSize = originalFontSize;
}
// 恢复预览的配色风格(如果用户改过配色的话)
if (inputEl.dataset.pendingColorStyle) {
document.body.dataset.colorStyle = originalColorStyle;
}
// 同 finish():缩放淡出后 remove + 焦点恢复 + settle
// shared resolved 守卫:若 finish 已先动settle 跳过cancel 不重复 resolve
const inner = overlay.querySelector('.modal');
const finalize = () => {
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
previouslyFocused.focus();
}
overlay.remove();
settle({ changed: false });
};
if (inner) {
inner.classList.add('is-closing');
inner.addEventListener('animationend', finalize, { once: true });
// 兜底必须 settle同 finish()
setTimeout(finalize, 250);
} else {
finalize();
}
};
overlay.addEventListener('click', (e) => {
const action = e.target.closest('[data-action]')?.dataset.action;
if (action === 'done') finish();
else if (action === 'cancel' || e.target === overlay) cancel();
}, { signal });
const onKey = (e) => {
if (e.key === 'Escape') {
// IME 守卫CJK/JP/KR 用户按 Esc 通常是「取消 IME 组合」(结束选词),
// 不是「关闭对话框」。不挡掉会让 Esc 同时关掉 IME 和设置对话框,
// 用户连保存按钮都还没看到就被踢回主界面。
if (isImeComposing(e)) return;
cancel();
} else if (e.key === 'Enter' && !isImeComposing(e)) {
// Enter 提交:与点「完成」按钮等价。本对话框没有可编辑的文本输入框
// (数据目录是 readonly + 选择按钮 + checkboxes + radios + 分段按钮),
// 所以 Enter 一律视为"确认"。readonly input 上 Enter 默认无行为、
// 之前需要抬手找鼠标点"完成";统一在键盘层兜底。
//
// isImeComposing 守卫:防御性地拦截中文输入法选词时的回车提交
// (与 feedback.js / conflict-dialog.js / import-dialog.js 对齐)。
finish();
}
};
document.addEventListener('keydown', onKey, { signal });
// 注意:不能在这里对 input 做 stopPropagation —— 那样会阻止上面的
// document keydown listeneronKey接收到 Escape导致输入框内按 Esc
// 无法关闭对话框。全局快捷键app.js已经通过检测 .modal-overlay 跳过,
// 不需要我们在这里手动拦截冒泡。
});
}

717
src/ui/sidebar.js Normal file
View File

@@ -0,0 +1,717 @@
// 侧边栏组件 - 三层结构v3 标识驱动):
//
// 当前任务(智能视图,跨子分类聚合 current=true 的任务;旧 TODAY 分类已下沉为 @当前 标识)
// 重要任务(智能视图,跨子分类聚合 important=true 的任务)
// 全部任务kind=OTHER_TASKS 容器点击进入聚合视图h2 为子分类)
// ├── 工作sub-category, parentOtherTasks=true
// ├── 学习
// ├── 生活
// ├── 未分类(默认兜底)
// └── 用户自定义子分类
// 已完成任务(智能视图,跨子分类聚合 completed=true 的任务,放最底部)
// 回收站(删除的任务落点;为空时整条隐藏)
//
// 侧边栏顶部 + 按钮:新增子分类
// 子分类的「重命名/删除」入口hover 显示按钮 + 右键菜单
import { TaskStore, UNCATEGORIZED_NAME } from '../task-store.js';
import {
TODAY_KEYS,
OTHER_TASKS_KEYS,
TRASH_KEYS
} from '../markdown-parser.js';
import { inputDialog, confirmDialog, toast, hideContextMenu, registerBeforeModalShow } from './feedback.js';
import { escapeHtml, escapeAttr } from '../utils/dom.js';
import { DRAG_THRESHOLD } from '../utils/drag.js';
import { isImeComposing } from '../utils/keymap.js';
// 与 store.addSubCategory / renameCategory 同款的保留名集合。放到侧边栏
// validate 里立即提示避免「Enter 提交后才知道」store 仍保留兜底防御。
const RESERVED_NAMES = new Set([
UNCATEGORIZED_NAME,
...TODAY_KEYS,
...OTHER_TASKS_KEYS,
...TRASH_KEYS
]);
function checkReservedName(value) {
if (!value) return null;
if (value.includes('|')) return '分类名不能包含 "|" 字符(用于选中键分隔)';
if (RESERVED_NAMES.has(value) || RESERVED_NAMES.has(value.toLowerCase().trim())) {
return `"${value}" 是保留名称,请换一个`;
}
return null;
}
export class Sidebar {
constructor(store) {
this.store = store;
this.root = document.getElementById('sidebar');
this.listEl = document.getElementById('category-list');
this.contextMenu = document.getElementById('category-context-menu');
// 跟踪所有 store 订阅的取消函数 —— 让 destroy() 能彻底断开 store 引用,
// 避免热重载 / 单页测试 / 未来多窗口等场景下出现「store 还活着、UI 已经
// 换掉了」的悬挂渲染。
this._storeUnsubs = [];
// M-U1: 跟踪挂在 document / window 上的 listener —— destroy() 一并清理,
// 避免热重载 / 单测里反复挂同样的 listener 造成点击一次触发多份 handler
this._docUnbinds = [];
this._setupEvents();
// 统一渲染:'change' 在任何状态变更后都会触发(含分类增删改名等操作),
// 无需对 'categories' 单独监听 —— 避免双重 render()。
this._storeUnsubs.push(this.store.on('change', () => this.render()));
this.render();
}
/**
* 释放所有 store 订阅。生产单例生命周期里不会被调用Sidebar 与窗口同生死),
* 但保留入口供热重载 / 单元测试按需清理 —— 否则旧的 listener 仍会触发 render
* 而 render 内部读 this.listEl 等 DOM 引用,可能撞上已被替换的 DOM。
*/
destroy() {
for (const off of this._storeUnsubs) off();
this._storeUnsubs = [];
// M-U1: 同步清理挂在 document/window 上的全局 listener
for (const off of this._docUnbinds) off();
this._docUnbinds = [];
// M8注销「modal 打开前清拖拽」hook
if (this._unregisterModalHook) {
try { this._unregisterModalHook(); } catch {}
this._unregisterModalHook = null;
}
}
/**
* v4+ 修复async 方法从同步事件 listener 里 fire-and-forget 调用时,
* 一旦内部 rejectinputDialog / confirmDialog 内部异常、store 抛错等)
* 就是 unhandled rejection —— 冒到 window.onerror用户只看到「点了没反应」。
*
* 这里统一收口:所有 onAddSubCategory / onRenameSubCategory / onDeleteSubCategory
* 的调用都走这个包装,失败时打日志 + toast让用户知道操作没生效。
*/
_runAsync(promise, label) {
if (!promise || typeof promise.catch !== 'function') return;
promise.catch((e) => {
console.error(`[sidebar] ${label} 失败:`, e);
toast(`${label}失败:${e?.message || e}`, 'error', 3000);
});
}
_setupEvents() {
if (!this.listEl) return;
// 委托:点击「+ 新建子分类」按钮(动态渲染在子分类列表末尾)
this.listEl.addEventListener('click', (e) => {
if (e.target.closest('#btn-add-category')) {
e.stopPropagation();
this._runAsync(this.onAddSubCategory(), '新建子分类');
return;
}
});
// 点击分类 / 智能列表
this.listEl.addEventListener('click', (e) => {
// 子分类的操作按钮(重命名/删除)—— 优先响应,避免被父级 click 抢占
const actionBtn = e.target.closest('.subcategory-action-btn');
if (actionBtn) {
e.stopPropagation();
const name = actionBtn.dataset.name;
if (!name) return;
if (actionBtn.dataset.action === 'rename') {
this._runAsync(this.onRenameSubCategory(name), '重命名');
} else if (actionBtn.dataset.action === 'delete') {
this._runAsync(this.onDeleteSubCategory(name), '删除分类');
}
return;
}
const item = e.target.closest('.category-item');
if (!item) return;
const type = item.dataset.type; // 'smart' | 'subcategory' | 'trash'
if (type === 'smart') {
const key = item.dataset.smartKey;
this.store.selectSmartList(key);
} else if (type === 'subcategory' || type === 'trash') {
const name = item.dataset.category;
if (name) this.store.selectCategory(name);
}
});
// 双击重命名(仅子分类可重命名)
this.listEl.addEventListener('dblclick', (e) => {
const item = e.target.closest('.category-item');
if (!item) return;
if (item.dataset.type !== 'subcategory') return;
// 「未分类」是任务兜底,名称是魔法标识符 —— 双击也不应进入重命名。
if (item.dataset.category === UNCATEGORIZED_NAME) return;
const name = item.dataset.category;
if (name) this._runAsync(this.onRenameSubCategory(name), '重命名');
});
// 右键子分类 → 显示重命名/删除菜单
this.listEl.addEventListener('contextmenu', (e) => {
const item = e.target.closest('.category-item');
if (!item || item.dataset.type !== 'subcategory') return;
e.preventDefault();
const name = item.dataset.category;
if (!name) return;
this._showCategoryContextMenu(e.clientX, e.clientY, name);
});
// 点击分类右键菜单项
if (this.contextMenu) {
this.contextMenu.addEventListener('click', (e) => {
const item = e.target.closest('.context-menu-item');
if (!item) return;
const action = item.dataset.action;
const name = this.contextMenu.dataset.target;
this.hideContextMenu();
if (!name) return;
if (action === 'rename') this._runAsync(this.onRenameSubCategory(name), '重命名');
else if (action === 'delete') this._runAsync(this.onDeleteSubCategory(name), '删除分类');
});
}
// 点击其它位置关闭右键菜单
// M-U1: 推入 _docUnbinds 让 destroy() 一并清理
const onDocClick = (e) => {
if (this.contextMenu && !this.contextMenu.hidden &&
!this.contextMenu.contains(e.target)) {
this.hideContextMenu();
}
};
document.addEventListener('click', onDocClick);
this._docUnbinds.push(() => document.removeEventListener('click', onDocClick));
const onDocKeydown = (e) => {
if (e.key === 'Escape') {
// IME 守卫doc 级监听覆盖整个窗口(包括正在 IME 输入的输入框),
// CJK/JP/KR 用户按 Esc 优先是「取消 IME 组合」,不要在这里拦截。
if (isImeComposing(e)) return;
this.hideContextMenu();
// Esc 取消正在进行的侧边栏拖拽(任意指针)
if (this._sidebarDrags?.size) {
for (const d of this._sidebarDrags.values()) {
this._teardownSidebarDrag(d);
}
}
}
};
document.addEventListener('keydown', onDocKeydown);
this._docUnbinds.push(() => document.removeEventListener('keydown', onDocKeydown));
// M-U2: 窗口失焦 / 页面切到后台时,主动清掉所有进行中的拖拽状态
// —— 否则:用户在侧边栏按住开始拖,切到别的窗口(或浏览器失焦),
// pointerup 在切走期间触发不到pointer 状态 + .dragging 类永远残留,
// 回到页面会看到「鼠标按住拖不动 / 类名残留」等怪现象。
// 注意:复用 M8 新增的 _abortAllSidebarDrags 方法 —— 之前这里有个一模一样的
// 局部函数,逻辑双份维护容易漂移。注册 modal 打开前 hook 也调它。
const onBlur = () => this._abortAllSidebarDrags();
window.addEventListener('blur', onBlur);
this._docUnbinds.push(() => window.removeEventListener('blur', onBlur));
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') this._abortAllSidebarDrags();
};
document.addEventListener('visibilitychange', onVisibilityChange);
this._docUnbinds.push(() => document.removeEventListener('visibilitychange', onVisibilityChange));
// 子分类拖拽排序Pointer Events 实现,与 task-list.js 保持一致)
// Today / 智能列表 / 全部任务 容器 / 回收站 不可拖动
this._setupSidebarDrag();
// M8 修复:用户在拖拽子分类过程中触发 confirmDialog / inputDialog 等
// modal 时,主动清掉 _sidebarDrags 状态。否则 pointerup 在 modal 挂载后
// 派发到 document 级监听器,调 reorderSubCategory意外把子分类换了位。
if (this._unregisterModalHook) {
try { this._unregisterModalHook(); } catch {}
}
this._unregisterModalHook = registerBeforeModalShow(() => this._abortAllSidebarDrags());
}
_setupSidebarDrag() {
// 阈值与 task-list.js _setupDrag 共用 —— 集中常量避免两边漂移
// 按指针位置判断插到目标之前还是之后
const dropAboveFor = (item, clientY) => {
const rect = item.getBoundingClientRect();
return clientY < rect.top + rect.height / 2;
};
// 用 Map 而不是单个 this._sidebarDrag多指 / 多鼠标设备上同时按两下,
// 第二次 pointerdown 会把第一次的状态覆盖掉,导致第一次的 pointerup
// 找不到状态、teardown 失败DOM 上残留 .dragging / .drag-over 类。
this._sidebarDrags = new Map(); // pointerId -> drag state
// 监听 categories 变化以中止进行中的拖拽render() 会把 DOM 整个重建,
// d.item / d.targetItem 等引用会指向被 detach 的节点。若不显式 teardown
// pointerup 提交时拿到的是旧节点的 dataset仍能读到 category但视觉上
// 指示器、ghost 已经没了),给用户「拖完了但没反应」的错觉。
//
// v3.7+只对「categories」事件作废拖拽 —— 拖动只关心子分类顺序。任务层
// 操作toggleTask / addTask / 文本编辑等)也 emit 'change',但跟子分类
// 列表没关系,之前无条件 cancel 会让用户在拖分类时碰巧别人改了某条任务
// 就被静默放弃,体验割裂。
// 不再保留 'change' 兜底load/save-error 等极端路径如果不发 'categories'
// 就不会破坏 sidebar DOMsidebar 本身用 'change' 触发 render 兜底渲染),
// 但即便用户丢一次拖拽视觉pointerup 时 stale 引用走 dataset 仍然安全
// dataset 不依赖 DOM 节点存活);所以让拖拽走完比硬 cancel 更友好。
this._storeUnsubs.push(this.store.on('categories', () => {
if (!this._sidebarDrags || this._sidebarDrags.size === 0) return;
for (const d of this._sidebarDrags.values()) {
// 子分类列表变化(重命名/新建/删除等)一律作废,避免提交到错位数据。
this._teardownSidebarDrag(d);
}
}));
// pointerdown记录起点仅在子分类项上生效
this.listEl.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return; // 仅左键
const item = e.target.closest('.category-item');
if (!item || item.dataset.type !== 'subcategory') return;
// 点在操作按钮上(重命名/删除)时不启动拖拽
if (e.target.closest('.subcategory-action-btn')) return;
// 「未分类」始终在最下方(兜底语义 + _normalizeOrder 也按这个顺序排)——
// 禁掉它的拖拽起点,避免用户拖到中间造成看起来"消失"或归属分裂
// (拖动撤销顺序后其它兜底逻辑又把它拉回底部,结果会很迷惑)。
if (item.dataset.category === UNCATEGORIZED_NAME) return;
try { item.setPointerCapture(e.pointerId); } catch {}
this._sidebarDrags.set(e.pointerId, {
pointerId: e.pointerId,
name: item.dataset.category,
item,
startX: e.clientX,
startY: e.clientY,
active: false,
indicator: null,
targetItem: null,
dropAbove: false
});
});
// pointermove超过阈值后激活拖拽持续更新指示器
this.listEl.addEventListener('pointermove', (e) => {
const d = this._sidebarDrags?.get(e.pointerId);
if (!d) return;
if (!d.active) {
const dx = e.clientX - d.startX;
const dy = e.clientY - d.startY;
if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
d.active = true;
d.item.classList.add('dragging');
}
// 找到指针下方的子分类项(排除拖动项自身)
const targetItem = this._findDropTarget(e.clientX, e.clientY, d.item);
// 清理旧的指示器
this.listEl.querySelectorAll('.drag-over, .drag-over-below').forEach(el => {
el.classList.remove('drag-over', 'drag-over-below');
});
if (targetItem) {
d.targetItem = targetItem;
d.dropAbove = dropAboveFor(targetItem, e.clientY);
targetItem.classList.add(d.dropAbove ? 'drag-over' : 'drag-over-below');
} else {
d.targetItem = null;
}
});
// pointerup提交重排
this.listEl.addEventListener('pointerup', (e) => {
const d = this._sidebarDrags?.get(e.pointerId);
if (!d) return;
try {
if (d.active && d.targetItem) {
const fromName = d.name;
const toName = d.targetItem.dataset.category;
const subs = this.store.getSubCategories();
const fromIdx = subs.findIndex(c => c.name === fromName);
const toIdx = subs.findIndex(c => c.name === toName);
if (fromIdx >= 0 && toIdx >= 0) {
this.store.reorderSubCategory(fromIdx, toIdx, d.dropAbove);
}
}
} finally {
this._teardownSidebarDrag(d);
}
});
// pointercancel丢弃
this.listEl.addEventListener('pointercancel', (e) => {
const d = this._sidebarDrags?.get(e.pointerId);
if (!d) return;
this._teardownSidebarDrag(d);
});
}
_findDropTarget(clientX, clientY, excludeItem) {
// 用 document.elementFromPoint 找指针下的元素,向上找 .category-item
const el = document.elementFromPoint(clientX, clientY);
if (!el) return null;
const item = el.closest('.category-item');
if (!item || item === excludeItem) return null;
if (item.dataset.type !== 'subcategory') return null;
// 「未分类」是固定兜底,不能作为插入点(与 pointerdown 处拒绝拖它对称)——
// 否则拖「副业」越过「未分类」时会显示一个看起来像允许的落点指示器,
// 最终却没反应,体验不一致。
if (item.dataset.category === UNCATEGORIZED_NAME) return null;
return item;
}
_teardownSidebarDrag(d) {
if (!d) return;
this.listEl.querySelectorAll('.category-item').forEach(el => {
el.classList.remove('dragging', 'drag-over', 'drag-over-below');
});
try { d.item?.releasePointerCapture(d.pointerId); } catch {}
this._sidebarDrags?.delete(d.pointerId);
}
/**
* 兜底:清掉所有进行中的侧边栏拖拽。
*
* 之前 _setupEvents 内部局部声明的 abortAllDrags 只服务于 blur/visibilitychange
* M8 修复后这个清理逻辑也要被 modal 打开前 hook 复用 —— 提到类方法上。
*/
_abortAllSidebarDrags() {
if (this._sidebarDrags?.size) {
for (const d of this._sidebarDrags.values()) {
this._teardownSidebarDrag(d);
}
}
}
_showCategoryContextMenu(x, y, name) {
if (!this.contextMenu) return;
// 关键:先清理 feedback.js 的活动菜单状态,避免上一次任务右键菜单的回调误触发
hideContextMenu();
this.contextMenu.dataset.target = name;
const isUncategorized = name === UNCATEGORIZED_NAME;
// 动态构建菜单项(之前在 HTML 中静态写,但已与 feedback.js 的 showContextMenu 统一改为动态生成)。
// 「未分类」连「重命名」也屏蔽(理由见上)—— 把两个动作整体替换成一条说明,避免点击空菜单误触底层通用 handler。
this.contextMenu.innerHTML = isUncategorized
? `
<div class="context-menu-item context-menu-disabled" aria-disabled="true">「${UNCATEGORIZED_NAME}」是兜底分类,名称不可改</div>
`
: `
<div class="context-menu-item" data-action="rename">重命名</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item context-menu-danger" data-action="delete">删除</div>
`;
this.contextMenu.hidden = false;
// 先显示以测量尺寸
const rect = this.contextMenu.getBoundingClientRect();
const maxX = window.innerWidth - rect.width - 4;
const maxY = window.innerHeight - rect.height - 4;
this.contextMenu.style.left = Math.min(x, maxX) + 'px';
this.contextMenu.style.top = Math.min(y, maxY) + 'px';
}
hideContextMenu() {
if (this.contextMenu) {
this.contextMenu.hidden = true;
delete this.contextMenu.dataset.target;
}
}
render() {
const selected = this.store.selectedCategoryName;
const selectedSmart = this.store.selectedSmartList;
const subs = this.store.getSubCategories();
const otherTasksContainer = this.store.getOtherTasksContainer();
// 一次遍历拿到所有智能列表的聚合任务,避免 O(n×m) 渲染
const smartTasks = this.store.collectSmartListTasks();
// ============ 智能列表(当前 / 重要 —— 已完成放到最底部单独渲染) ============
//
// 顺序按用户使用频率:当前任务(聚焦今日)→ 重要任务(聚焦关键项)。
// 「全部任务」是容器(在下方单独渲染为父级 + 子分类列表),
// 「已完成任务」按设计放在分隔线之后单独渲染。
const smartEntries = TaskStore.SMART_LISTS.filter(
s => s.key !== 'allTasks' && s.key !== 'completed'
);
const smartHtml = smartEntries.map((s) => {
const view = smartTasks[s.key] || [];
const isActive = selectedSmart === s.key;
const icon = this._smartListIcon(s.key);
// 当前=标了 ▶ 的数量;重要=标了 ⭐ 的数量。直接 view.length 即可。
const count = view.length;
return `
<div class="category-item smart-list ${isActive ? 'active' : ''}"
data-type="smart"
data-smart-key="${escapeAttr(s.key)}"
${isActive ? 'aria-current="true"' : ''}
title="${escapeAttr(s.desc)} · 共 ${count} 项">
<span class="category-icon smart-icon smart-${s.key}">${icon}</span>
<span class="category-name">${escapeHtml(s.name)}</span>
${this._renderCountBadge(count)}
</div>
`;
}).join('');
// ============ 全部任务容器(在列表区作为父级标题,点击进入聚合视图) ============
let allTasksHtml = '';
if (otherTasksContainer) {
const isActive = selectedSmart === 'allTasks';
const folderIcon = this._smartListIcon('allTasks');
// 全部任务 = 全部子分类下的任务总和(已完成任务单独走 COMPLETED 分类,
// 不会出现在子分类里,所以这里直接用 allTasks smart list 的长度即可)
const allTasksCount = (smartTasks.allTasks || []).length;
allTasksHtml = `
<div class="category-item smart-list ${isActive ? 'active' : ''} category-other-tasks"
data-type="smart"
data-smart-key="allTasks"
${isActive ? 'aria-current="true"' : ''}
title="聚合显示所有任务(含全部子分类) · 共 ${allTasksCount} 项">
<span class="category-icon smart-icon smart-allTasks">${folderIcon}</span>
<span class="category-name">${escapeHtml(otherTasksContainer.name)}</span>
${this._renderCountBadge(allTasksCount)}
</div>
`;
}
// ============ 已完成任务(智能视图,跨分类聚合 completed=true ============
let completedHtml = '';
const completedIsActive = selectedSmart === 'completed';
const completedIcon = this._smartListIcon('completed');
const completedCount = (smartTasks.completed || []).length;
completedHtml = `
<div class="category-item smart-list category-completed ${completedIsActive ? 'active' : ''}"
data-type="smart"
data-smart-key="completed"
${completedIsActive ? 'aria-current="true"' : ''}
title="所有已勾选的任务 · 共 ${completedCount} 项">
<span class="category-icon smart-icon smart-completed">${completedIcon}</span>
<span class="category-name">已完成任务</span>
${this._renderCountBadge(completedCount)}
</div>
`;
// ============ 回收站kind=TRASH删除的任务落点为空时隐藏 ============
let trashHtml = '';
const trashCat = this.store.getTrashCategory();
if (trashCat && trashCat.tasks.length > 0) {
const isActive = !selectedSmart && trashCat.name === selected;
const trashIcon = `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>`;
trashHtml = `
<div class="category-item category-trash ${isActive ? 'active' : ''}"
data-type="trash"
data-category="${escapeAttr(trashCat.name)}"
${isActive ? 'aria-current="true"' : ''}
title="${escapeAttr(trashCat.name)} · 共 ${trashCat.tasks.length} 项">
<span class="category-icon">${trashIcon}</span>
<span class="category-name">${escapeHtml(trashCat.name)}</span>
${this._renderCountBadge(trashCat.tasks.length)}
</div>
`;
}
// ============ 子分类(缩进显示) ============
const subHtml = subs.map((cat) => {
const isActive = !selectedSmart && cat.name === selected;
const folderIcon = `<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>`;
// 「未分类」是任务兜底,名称是 store/parser/writer 共用的魔法标识符 —— 重命名 + 删除全部屏蔽。
// 这里把 hover 才出现的两个操作按钮一并隐藏,按钮区彻底不出现在「未分类」行上。
const isUncategorized = cat.name === UNCATEGORIZED_NAME;
// 子分类右侧的操作按钮hover/选中时强调)
const renameBtnHtml = isUncategorized ? '' : `
<button class="subcategory-action-btn subcategory-action-rename" data-action="rename" data-name="${escapeAttr(cat.name)}" title="重命名" aria-label="重命名分类">
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></svg>
</button>`;
const deleteBtnHtml = isUncategorized ? '' : `
<button class="subcategory-action-btn subcategory-action-delete" data-action="delete" data-name="${escapeAttr(cat.name)}" title="删除" aria-label="删除分类">
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>`;
const subActionsHtml = isUncategorized ? '' : `
<div class="subcategory-actions">
${renameBtnHtml}
${deleteBtnHtml}
</div>
`;
return `
<div class="category-item subcategory ${isActive ? 'active' : ''}"
data-type="subcategory"
data-category="${escapeAttr(cat.name)}"
${isActive ? 'aria-current="true"' : ''}
title="${escapeAttr(cat.name)} · 共 ${cat.tasks.length} 项 · 右键菜单">
<span class="category-icon">${folderIcon}</span>
<span class="category-name">${escapeHtml(cat.name)}</span>
${this._renderCountBadge(cat.tasks.length)}
${subActionsHtml}
</div>
`;
}).join('');
// 无子分类时显示空提示
const subListHtml = subs.length > 0
? subHtml
: `<div class="subcategory-empty">暂无子分类</div>`;
// 「+ 新建子分类」按钮:渲染在子分类列表末尾,
// 紧跟最后一个子分类,让「这个按钮是给子分类用的」关系更直接。
const addBtnHtml = `
<button id="btn-add-category" class="btn-add-subcategory" title="新建子分类 (Ctrl+Shift+N)" aria-label="新建子分类">
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M8 2.5v11M2.5 8h11"/>
</svg>
<span class="btn-text">新建子分类</span>
</button>
`;
this.listEl.innerHTML = `
${smartHtml}
<div class="sidebar-divider"></div>
${allTasksHtml}
<div class="subcategory-list">
${subListHtml}
${addBtnHtml}
</div>
<div class="sidebar-divider"></div>
${completedHtml}
${trashHtml}
`;
}
_smartListIcon(key) {
if (key === 'current') {
// 播放三角:与 todo.md 的 [▶]U+25B6当前标记视觉一致 —— 软件图标 ↔ 文件标记 一一对应
return `<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" aria-hidden="true"><polygon points="7 4 20 12 7 20"/></svg>`;
}
if (key === 'important') {
return `<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" aria-hidden="true"><path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/></svg>`;
}
if (key === 'allTasks') {
return `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>`;
}
if (key === 'completed') {
return `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="3"/><path d="M8 12.5l3 3L17 9"/></svg>`;
}
return '';
}
/**
* 渲染侧边栏条目右侧的数量徽章。
*
* - count = 0 时也展示「0」徽章保持视觉占位一致想改成"空分类不显示 0"
* 把 ``return `<span ...>${n}</span>`;`` 改成 ``return n > 0 ? ... : ''`` 即可。
* - aria-label 让屏幕阅读器在悬停图标时也能听到数量。
* - 数字用 ``n`` 防 ``undefined / NaN`` 透传成字符串 "undefined"。
*/
_renderCountBadge(count) {
const n = Number.isFinite(count) ? count : 0;
return `<span class="category-count" aria-label="共 ${n} 项">${n}</span>`;
}
async onAddSubCategory() {
const name = await inputDialog({
title: '新建子分类',
placeholder: '例如:健身、阅读、副业…',
confirmText: '创建',
// 内联校验:保留名 / `|` / 重名全部在按下 Enter 前即时提示。
// store 仍保留兜底(任何 UI 路径都可能被绕过),但用户不应被它的兜底 toast 触到。
validate: (value) => {
const reservedErr = checkReservedName(value);
if (reservedErr) return reservedErr;
if (this.store.categories.some(c => c.name === value)) {
return `已存在分类 "${value}"`;
}
return null;
}
});
if (!name) return;
const cat = this.store.addSubCategory(name);
if (cat) {
this.store.selectCategory(name);
toast(`已创建子分类 "${name}"`, 'success');
} else if (name.includes('|')) {
// store 拒绝含 `|` 的名字 —— 选中键 / 批量解析依赖 `|` 分隔,
// 这里给出针对性反馈比通用「保留名」更清楚。
toast('分类名不能包含 "|" 字符(用于选中键分隔)', 'error');
} else {
// 保留名兜底validate 已经挡过,落到这里意味着 store 与 sidebar
// 的保留名集合不一致 —— 保守起见仍提示,不要静默失败)。
toast(`"${name}" 是保留名称,请换一个`, 'error');
}
}
async onRenameSubCategory(oldName) {
const cat = this.store.getCategory(oldName);
if (!cat) return;
if (!cat.parentOtherTasks) {
toast('仅子分类可重命名', 'error');
return;
}
// 「未分类」是任务兜底,名称是魔法标识符 —— 即便按钮被隐藏也不可重命名
// (兜底防线,避免任何代码路径绕过 UI 直接调到这里)
if (cat.name === UNCATEGORIZED_NAME) {
toast(`${UNCATEGORIZED_NAME}」是兜底分类,不可重命名`, 'error');
return;
}
const newName = await inputDialog({
title: '重命名子分类',
message: `将 "${oldName}" 重命名为:`,
defaultValue: oldName,
// 与 addSubCategory 共享保留名/非法字符校验,让用户按键即可见反馈
validate: (value) => {
if (value === oldName) return null; // 未修改不算错
const reservedErr = checkReservedName(value);
if (reservedErr) return reservedErr;
if (this.store.categories.some(c => c.name === value)) {
return `已存在分类 "${value}"`;
}
return null;
}
});
if (!newName || newName === oldName) return;
if (this.store.categories.some(c => c.name === newName)) {
toast(`已存在分类 "${newName}"`, 'error');
return;
}
if (this.store.renameCategory(oldName, newName)) {
toast(`已重命名`, 'success');
} else if (newName.includes('|') || oldName.includes('|')) {
// 同 addSubCategory含 `|` 的旧/新名都被 store 拒绝
toast('分类名不能包含 "|" 字符(用于选中键分隔)', 'error');
} else {
toast(`"${newName}" 是保留名称,请换一个`, 'error');
}
}
async onDeleteSubCategory(name) {
const cat = this.store.getCategory(name);
if (!cat) return;
if (!cat.parentOtherTasks) {
toast('仅子分类可删除', 'error');
return;
}
// 「未分类」是删除时的任务兜底,删除它会让释放出的任务无处归位 —— 禁止
if (cat.name === UNCATEGORIZED_NAME) {
toast(`${UNCATEGORIZED_NAME}」是兜底分类,不可删除`, 'error');
return;
}
const taskCount = cat.tasks.length;
const taskLine = taskCount > 0
? `其下的 ${taskCount} 项任务将归到「${UNCATEGORIZED_NAME}」中。`
: '此分类下没有任务。';
const ok = await confirmDialog({
title: '删除子分类',
message: `确定删除子分类 "${name}"\n${taskLine}`,
confirmText: '删除',
cancelText: '取消',
danger: true
});
if (!ok) return;
if (this.store.deleteCategory(name)) {
const tail = taskCount > 0 ? `${taskCount} 项任务已归到「${UNCATEGORIZED_NAME}` : '';
toast(`已删除 "${name}"${tail}`, 'success');
}
}
}

136
src/ui/task-context-menu.js Normal file
View File

@@ -0,0 +1,136 @@
// 右键菜单 items 构造 —— 与 task-list.js 共用的纯函数层。
//
// 动机test 脚本check-completed-context-menu.mjs / check-trash-context-menu.mjs
// 需要验证右键菜单的判定逻辑(已完成任务视图隐藏「移到分类…」/ 回收站里已完成
// 任务的默认恢复项 label 等),但 Node ESM 锁定 module named-export无法在测试
// 代码里 monkey-patch feedback.js.showContextMenu 来拦截 _showTaskContextMenu 调用。
// 最小可测化路径:把 items 构造抽出来变成纯函数task-list 与 check 脚本共享一份
// 判定逻辑 —— 既保证测试覆盖的是真实代码,也防止 UI 与 check 脚本后续发散。
//
// 覆盖范围:两分支 —— buildStandardContextMenuItems普通分类 / 智能视图) +
// buildTrashContextMenuItems回收站。回收站分支的 action 回调
// restore-default / restore:<name> / toggle 等)依旧留在那里 —— 它们依赖
// pickRestoreTargetForCompletedTask / pickRestoreTargetForTrashTask 等私有
// picker 路径,与 items 数组构造(纯判定)解耦更直接。
import { CategoryKind } from '../markdown-parser.js';
import { COMPLETED_NAME, UNCATEGORIZED_NAME } from '../task-store.js';
/**
* 构造「普通分类」右键菜单的 items 数组。
*
* @param {object} cat - 当前选中的 category 或 smart-list 视图
* @param {object} task - 右键选中的任务cat.tasks 里的某条;智能视图下带 _fromCategory
* @param {object} store - TaskStore 实例(用于枚举 otherCategories
* @returns {Array<{label?:string, action?:string, separator?:boolean, indent?:boolean, disabled?:boolean}>}
*/
export function buildStandardContextMenuItems(cat, task, store) {
// 智能列表视图操作真实分类_fromCategory否则 cat.name 是聚合名
const realCatName = task._fromCategory || cat.name;
// 「已完成任务」智能视图下不渲染「移到分类…」分组 —— v3.4 数据归属不变量
// task-store.js:950规定 completed=true 任务只能活在 kind=COMPLETED 里,
// 点了之后 store 会硬路由回「已完成任务」分类,等于死按钮。统一在这里短路掉,
// 让菜单里看不到这个无效入口,也避免用户点了再收到「已移到 X」但实际没动。
const isCompletedView = cat.isSmartList && cat.smartKey === 'completed';
const otherCategories = store.categories
.filter(c => c.name !== realCatName
&& c.kind !== CategoryKind.OTHER_TASKS
&& c.kind !== CategoryKind.TRASH
// 防御性过滤:理论上同名「已完成任务」分类只有一个,但保留名分类重名会让
// _consolidateCompleted 兜底删除副本parser 层保留名去重),极端情况列表
// 里能短暂存在多个 kind=COMPLETED —— 不能让它们出现在 move 目标里。
&& c.kind !== CategoryKind.COMPLETED)
.map(c => ({
label: c.name,
action: `move:${c.name}`,
indent: true
}));
// 多个分类时一次性全展开 —— 用滚动而不是 slice。
// slice(0, 6) 是历史欠账:分类多时(用户可能建十几个)后半段被静默截掉,
// 用户在右键菜单里根本看不到,找不到 = 「这个功能不存在」。
// 菜单顶部有 max-height 滚动 + 视口边距防溢出,落点太多时滚动到目标即可。
const items = [
{ label: task.completed ? '标记为未完成' : '标记为已完成', action: 'toggle' },
{ label: '编辑', action: 'edit' },
{ separator: true },
{ label: task.current ? '取消当前' : '标记为当前', action: 'current' },
{ label: task.important ? '取消星标' : '标记为重要', action: 'important' },
];
// 「移到分类…」分组仅在确实存在其它分类时才显示 —— 没有候选目标时,
// 整个「移到分类…」section 都不该出现。否则菜单里只剩一个禁用的占位 header
// 加上一个孤零零的 separator比「啥也没有」更让人困惑。
//
// 「已完成任务」视图(见上)也不显示:此时所有候选都是死按钮,整组直接消失。
if (!isCompletedView && otherCategories.length > 0) {
items.push(
{ separator: true },
{ label: '移到分类…', action: 'move-header', disabled: true },
...otherCategories.map(c => ({ label: c.label, action: c.action, indent: true }))
);
}
items.push(
{ separator: true },
// 不再标 danger删除是可逆的任务移到回收站Markdown 里仍在)
{ label: '移到回收站', action: 'delete' }
);
return items;
}
/**
* 构造「回收站」右键菜单的 items 数组。
*
* 与 buildStandardContextMenuItems 对称:把 task-list.js 里 TRASH 分支的
* items 构造抽出来,方便 check 脚本check-trash-context-menu.mjs在 Node
* 里直接验证判定逻辑,不必起 DOM/feedback.js。
*
* 唯一被 filter 过的 targets 是其他 NORMAL 子分类OTHER_TASKS / TRASH /
* COMPLETED 都不在菜单里OTHER_TASKS / TRASH 是容器自身COMPLETED 已完成
* 任务的归宿 —— 见下面 isCompletedTask 分支)。
*
* @param {object} task - 当前右键选中的回收站任务(含 completed/originalCategory/current/important
* @param {Array} targets - 候选目标分类(已过滤 OTHER_TASKS / TRASH / COMPLETED
* @returns {Array<{label?:string, action?:string, separator?:boolean, indent?:boolean, disabled?:boolean}>}
*/
export function buildTrashContextMenuItems(task, targets) {
// v3.6.4+已完成任务completed=true的默认恢复项 label 改为「已完成任务」。
// store.restoreTask 在 completed 时强制路由到「已完成任务」分类
// task-store.js:1481无视 toCategoryName用 originalCategory 提示会误导用户。
// task.originalCategory 在这种情况下仍保留 —— 留给将来"取消勾选"按它归位。
const isCompletedTask = task.completed;
const defaultTargetName = isCompletedTask
? COMPLETED_NAME
: (task.originalCategory || UNCATEGORIZED_NAME);
const defaultLabel = `恢复到「${defaultTargetName}`;
// 已完成任务的「恢复到分类…」子菜单同样隐藏 —— store.restoreTask 对
// completed 任务的强制路由task-store.js:1481会让所有"restore:*"项
// 也被短路到「已完成任务」,菜单里看起来能选具体分类,点完还是落到
// 已完成任务,与 buildStandardContextMenuItems 对「已完成任务」智能视图
// 隐藏「移到分类…」的处理对称。
const showRestorePicker = !isCompletedTask && targets.length > 1;
return [
{ label: defaultLabel, action: 'restore-default' },
{ separator: true },
{ label: task.completed ? '标记为未完成' : '标记为已完成', action: 'toggle' },
{ label: '编辑', action: 'edit' },
{ separator: true },
{ label: task.current ? '取消当前' : '标记为当前', action: 'current' },
{ label: task.important ? '取消星标' : '标记为重要', action: 'important' },
...(showRestorePicker ? [
{ separator: true },
{ label: '恢复到分类…', action: 'restore-header', disabled: true },
...targets.map(c => ({
label: c.name,
action: `restore:${c.name}`,
indent: true
}))
] : [])
];
}

3361
src/ui/task-list.js Normal file

File diff suppressed because it is too large Load Diff

399
src/ui/toolbar.js Normal file
View File

@@ -0,0 +1,399 @@
// 工具栏组件
import { toast } from './feedback.js';
import { shortenPath, displayPath } from '../utils/dom.js';
import { openDataDirWithToast } from '../utils/data-dir.js';
import { isImeComposing } from '../utils/keymap.js';
export class Toolbar {
// 主题切换图标(独立常量:避免长字符串散落在方法体内)
// 线条风格与右侧齿轮/窗口控件按钮对齐24×24 viewBox + stroke-width 2.2
// 在 CSS 强制渲染的 11×11 尺寸下描边约 1px
static ICON_MOON = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>';
static ICON_SUN = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>';
constructor(store, settingsStore) {
this.store = store;
this.settingsStore = settingsStore;
// 文件路径 chip 在底部状态栏
this.fileInfoEl = document.getElementById('file-info');
this.filePathText = document.getElementById('file-path-text');
this.searchInput = document.getElementById('search-input');
this.searchClear = document.getElementById('search-clear');
this.searchShortcut = document.getElementById('search-shortcut');
this.btnTheme = document.getElementById('btn-theme');
this.btnSettings = document.getElementById('btn-settings');
this.btnAlwaysOnTop = document.getElementById('btn-always-on-top');
this.themeIcon = document.getElementById('theme-icon');
// 自定义窗口控制按钮frame: false 后接管系统标题栏)
this.btnMinimize = document.getElementById('btn-minimize');
this.btnMaximize = document.getElementById('btn-maximize');
this.btnClose = document.getElementById('btn-close');
this._dataDir = null;
// 用户主目录:用于在未关联文件状态下把绝对路径转成 `~/...` 显示。
// 取不到(老 preload / IPC 失败)时回退到显示绝对路径,行为兼容旧版本。
this._homeDir = null;
// 平台感知Mac 用 ⌘,其它平台用 Ctrl —— 避免在 Mac 上显示"Ctrl+F"误导用户
this._isMac = /Mac|iPhone|iPad/i.test(navigator.platform || navigator.userAgent || '');
if (this.searchShortcut) {
this.searchShortcut.textContent = this._isMac ? '⌘F' : 'Ctrl+F';
}
if (this.btnSettings) {
const settingKey = this._isMac ? '⌘+,' : 'Ctrl+,';
this.btnSettings.title = `设置 (${settingKey})`;
this.btnSettings.setAttribute('aria-keyshortcuts', settingKey);
}
// 跟踪所有 store / IPC 订阅的取消函数 —— 与 TaskList / Sidebar 一致的清理约定。
// 生产单例生命周期里不会被消费,但保留 destroy() 给将来 hot-reload / 单元测试。
// 必须在 _setupEvents() **之前**初始化_setupEvents() 末尾的
// _storeUnsubs.push(window.api.onMaximizeStateChanged(...)) 要用这个数组,
// 写反顺序会得到 TypeError: Cannot read properties of undefined (reading 'push')。
this._storeUnsubs = [];
// 搜索框 input 防抖 timer —— 提到实例字段_clearSearch() 才能在清空时
// 把"100ms 后再 setSearchQuery"的残留回调也掐掉,避免:
// 用户输入「foo」 → 100ms 内点 ✕ → 立即 setSearchQuery('')(清空生效)
// → 再输入「bar」→ 100ms 后旧的「foo」才落地错位的 searchQuery
// 表现为界面闪一下旧搜索结果再切到新结果(与 _clearSearch 后再输入的新结果
// 叠加两次 render用户看到 "EmptyState 闪一下又消失" 之类的诡异 bug。
this._searchTimerId = 0;
this._setupEvents();
// 异步获取数据目录 + 用户主目录,用于"未关联文件"状态显示
// v4+ 修复getDataDir 失败时IPC 通道丢失 / preload 重载等)保留 _dataDir=null
// 不让 unhandled promise rejection 冒到 console。UI 路径 chip 自然 fall through 到
// "未关联文件" 提示,与 _dataDir 还没拿到时的瞬态 UI 一致 —— 用户不会看到闪屏。
window.api.getDataDir().then((dir) => {
this._dataDir = dir;
this.updateFileInfo();
}).catch((e) => {
console.warn('[toolbar] getDataDir failed:', e?.message || e);
this._dataDir = null;
this.updateFileInfo();
});
window.api.getHomeDir?.().then((home) => {
this._homeDir = home || null;
this.updateFileInfo();
}).catch(() => {
this._homeDir = null;
});
// 监听数据目录变化(设置中修改)
if (this.settingsStore) {
this._storeUnsubs.push(this.settingsStore.on('change', async ({ changed }) => {
if (changed.includes('dataDir')) {
// v4+ 修复:包 try/catch。settingsStore.change 触发频率不高(仅设置里改时),
// 但 IPC 偶发失败仍会冒 unhandled rejection。失败时保留 _dataDir=null
// UI 路径 chip fall through 到 "未关联文件",与未初始化时一致。
try {
this._dataDir = await window.api.getDataDir();
} catch (e) {
console.warn('[toolbar] getDataDir after settings change failed:', e?.message || e);
this._dataDir = null;
}
this.updateFileInfo();
}
if (changed.includes('theme')) {
this.updateTheme();
}
if (changed.includes('alwaysOnTop')) {
// 任何来源renderer 自己 / 主进程托盘菜单通过 save-settings 反向写入)
// 触发的 alwaysOnTop 变化都同步按钮视觉。
this.updateAlwaysOnTop();
}
}));
}
// 托盘菜单勾选/取消「始终置顶」时,主进程会推 always-on-top:changed 过来。
// settingsStore change 事件可能不会触发(主进程自己 saveConfig 没走 IPC 反向同步),
// 必须显式订阅 IPC否则按钮视觉会卡在旧状态。
this._storeUnsubs.push(window.api.onAlwaysOnTopChanged(() => this.updateAlwaysOnTop()));
this._storeUnsubs.push(this.store.on('file-path', () => this.updateFileInfo()));
this._storeUnsubs.push(this.store.on('saved', (info) => this.onSaved(info)));
this._storeUnsubs.push(this.store.on('save-error', (err) => {
// 主进程的安全检查assertInDataDir会用英文抛 'path outside data directory'
// 直接拼到中文 toast 里读起来很违和。这里翻译为对用户友好的措辞。
// 用户之所以会撞到这条,是「另存为」对话框默认在桌面 / 下载目录,被引导到了
// 数据目录之外 —— 现在 defaultPath 已修正到数据目录内,这种错误应很少再发生。
//
// v3.4+:载荷升级为 {code, message, diskContent} 对象(见 task-store._buildSaveErrorPayload
// 但旧路径可能仍发字符串 —— 兼容两种格式。直接 `${err}` 对对象会变 `[object Object]`
// 必须显式取 .message / .code否则用户看到的是没意义的方括号。
let msg;
if (typeof err === 'string') {
msg = err.includes('outside data directory')
? '保存路径必须在数据目录内(请通过「设置」更改数据目录)'
: `保存失败:${err}`;
} else if (err && typeof err === 'object') {
const text = err.message || err.code || '未知错误';
msg = err.code === 'OUTSIDE_DATA_DIR'
? '保存路径必须在数据目录内(请通过「设置」更改数据目录)'
: `保存失败:${text}`;
} else {
msg = '保存失败';
}
toast(msg, 'error', 4000);
}));
this._storeUnsubs.push(this.store.on('dirty', () => this.updateFileInfo()));
this._storeUnsubs.push(this.store.on('theme', () => this.updateTheme()));
// 设置保存失败也必须告诉用户settingsStore._persist 失败时内存已生效(用户当下看到
// 的是新值),但磁盘写不进 → 下次启动回到旧值。settings-store.js 会发 'save-error'
// 但全项目过去没人订阅 —— 用户切了主题/字号后重启发现没保留,一脸茫然。
if (this.settingsStore) {
this._storeUnsubs.push(this.settingsStore.on('save-error', ({ failed }) => {
toast(`设置保存失败:${failed.join('、')} 未持久化(重启后会恢复旧值)`, 'error', 5000);
}));
}
this.updateFileInfo();
this.updateTheme();
// 启动时根据持久化值同步按钮视觉(主进程此时已经应用过置顶状态)
this.updateAlwaysOnTop();
// 最大化按钮默认显示「最大化」图标,主进程 ready-to-show 后会推真实状态覆盖。
// 这一步是为 IPC 丢失兜底 —— 比如热重载 / 渲染异常重启时 listener 被重建,
// 之前的 broadcast 不会被回放,按钮应停在合法状态而不是同时显示两个图标。
this._applyMaximizeState(false);
}
/**
* 释放所有 store / IPC 订阅。生产单例生命周期里不会被调用Toolbar 与窗口同生死),
* 但保留入口给将来 hot-reload / 单元测试按需清理 —— 否则旧的 listener 仍会触发
* updateFileInfo / updateTheme 等方法,而这些方法读 this.btnTheme 等 DOM 引用,
* 可能撞上已被替换的 DOM。
*/
destroy() {
// 清理搜索 debounce —— 否则 destroy 后定时器还会 fire 一次,调 store.setSearchQuery
// emit 'search'订阅方TaskList跑 filter/render 但 DOM 已被替换,撞陈旧引用。
// 顺序必须先 clearTimeout 再解订阅 —— 否则清不掉还会触发订阅的副作用。
clearTimeout(this._searchTimerId);
this._searchTimerId = null;
for (const off of this._storeUnsubs || []) off();
this._storeUnsubs = [];
}
_setupEvents() {
// 搜索
if (this.searchInput) {
// 输入事件 debounce —— 与 import-dialog.js 的 100ms+rAF 模式对齐:
// store.setSearchQuery 会 emit('search')订阅方TaskList会跑一次完整
// filter + render。普通用户连击时 input 事件密集触发(每键一次),大任务量
// 几千条下逐字符重新过滤会出现肉眼可见的卡顿100ms 间隔人眼感知不到
// 延迟rAF 把状态写回 DOM 避免 layout 抖动。✕ 按钮和 Esc 走的是即时路径,
// _clearSearch() 直接调用,不走 debounce。
this.searchInput.addEventListener('input', (e) => {
const q = e.target.value.trim();
if (this.searchClear) this.searchClear.hidden = !q;
clearTimeout(this._searchTimerId);
this._searchTimerId = setTimeout(() => {
this.store.setSearchQuery(q);
}, 100);
});
// Esc 清空搜索 —— 与点 ✕ 等价,但不用抬手去找鼠标。
// 仅在已有内容时拦截,避免抢其它场景的 Esc命令面板、模态、编辑态…
// IME 守卫CJK/JP/KR 用户在搜索框里按 Esc 优先是「取消 IME 组合」,
// 不应误清空搜索。
this.searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !isImeComposing(e) && this.searchInput.value) {
e.preventDefault();
e.stopPropagation();
this._clearSearch();
}
});
}
if (this.searchClear) {
this.searchClear.addEventListener('click', () => {
this._clearSearch();
});
}
// 文件路径 chip点击打开数据目录
if (this.fileInfoEl) {
this.fileInfoEl.addEventListener('click', () => this._openDataDirectory());
}
// 设置按钮
if (this.btnSettings) {
this.btnSettings.addEventListener('click', () => {
document.dispatchEvent(new CustomEvent('toolbar:settings'));
});
}
// 主题按钮
if (this.btnTheme) {
this.btnTheme.addEventListener('click', () => document.dispatchEvent(new CustomEvent('toolbar:toggle-theme')));
}
// 始终置顶按钮 —— 走 settingsStore.update 让主进程统一落盘 + 应用 + 重建托盘菜单。
// 单击立刻切换;不阻塞在 await 上(用户连点两次不应等上一次保存完)。
if (this.btnAlwaysOnTop) {
this.btnAlwaysOnTop.addEventListener('click', () => this._toggleAlwaysOnTop());
}
// 自定义窗口控制按钮frame: false 后接管系统标题栏)
if (this.btnMinimize) {
this.btnMinimize.addEventListener('click', () => window.api.minimizeWindow());
}
if (this.btnMaximize) {
// toggle 接口会返回新的 isMaximized主进程已经把窗口切到对应状态
// renderer 只需要根据返回值刷图标。状态机只在主进程里有一份。
this.btnMaximize.addEventListener('click', async () => {
// v4+ 修复async listener 里 await 一旦 reject 就是 unhandled rejection
// addEventListener 不接 Promise。IPC 偶发失败时用户只看到「按钮点了没反应」,
// 控制台冒一条未捕获错误。这里兜底:失败就不动图标 —— 主进程 onMaximizeStateChanged
// (line 264) 仍会在窗口状态真变化时推送正确值,图标不会永久错位。
try {
const isMaximized = await window.api.toggleMaximizeWindow();
this._applyMaximizeState(!!isMaximized);
} catch (e) {
console.warn('[toolbar] toggleMaximizeWindow 失败:', e?.message || e);
}
});
}
if (this.btnClose) {
// 与系统 X 同义:默认走"隐藏到托盘"。托盘菜单"退出"才是真正退出。
this.btnClose.addEventListener('click', () => window.api.closeWindow());
}
// 同步窗口最大化/还原状态:启动时主进程会推一次 ready-to-show → maximize-state
// 之后用户拖到屏幕边缘触发 Win+Up / 双击标题栏最大化时也会推。
// 关键是不能只靠 click 回调 —— 系统级快捷键触发的最大化不走 renderer。
//
// 必须把 onMaximizeStateChanged 返回的 cleanup 也 push 到 _storeUnsubs
// 不收口的话 destroy() 后旧 listener 仍引用 this.btnMaximize 这种 DOM 引用,
// 遇到 hot-reload / Toolbar 重建就会与新实例并存,按钮图标被多次刷新。
this._storeUnsubs.push(window.api.onMaximizeStateChanged((isMaximized) => {
this._applyMaximizeState(isMaximized);
}));
}
/**
* 把最大化按钮的图标/title 切到对应状态。
* 状态机只在主进程里有一份mainWindow.isMaximized()renderer 只是被动渲染。
*/
_applyMaximizeState(isMaximized) {
if (!this.btnMaximize) return;
// data-state 用 CSS 选择器控制两个 SVG 的显隐,比 innerHTML 替换轻量
this.btnMaximize.dataset.state = isMaximized ? 'restore' : 'maximize';
this.btnMaximize.title = isMaximized ? '还原' : '最大化';
this.btnMaximize.setAttribute('aria-label', isMaximized ? '还原' : '最大化');
}
async _toggleAlwaysOnTop() {
if (!this.settingsStore) return;
const next = !this.settingsStore.get('alwaysOnTop');
// settingsStore.update 同步触发 'change' 事件change 监听器会立刻调
// updateAlwaysOnTop() 同步按钮视觉 —— 不需要再单独预渲染一次。
// settingsStore.update 内部走 save-settings IPC主进程在那个 handler 里
// 已经会调 mainWindow.setAlwaysOnTop + 重建托盘菜单,所以这里不再额外发 IPC。
const ok = await this.settingsStore.update({ alwaysOnTop: next });
if (!ok) {
// settingsStore.update 内部已经发了 'save-error' 给通用 handler已在
// 构造函数里订阅 toast这里只负责回滚视觉。settingsStore 不会自己
// 撤销内存值 —— 内存里仍是新值,磁盘是旧值,下次启动按磁盘走。
this._applyAlwaysOnTopVisual(!next);
}
}
/**
* 同步按钮的视觉激活态。从 settingsStore 与主进程 IPC 都汇聚到这一处。
* 不用 await纯 DOM 操作,立即生效。
*/
updateAlwaysOnTop() {
if (!this.btnAlwaysOnTop || !this.settingsStore) return;
this._applyAlwaysOnTopVisual(this.settingsStore.get('alwaysOnTop'));
}
_applyAlwaysOnTopVisual(enabled) {
if (!this.btnAlwaysOnTop) return;
this.btnAlwaysOnTop.classList.toggle('active', !!enabled);
this.btnAlwaysOnTop.setAttribute('aria-pressed', enabled ? 'true' : 'false');
this.btnAlwaysOnTop.title = enabled ? '始终置顶(已开启)' : '始终置顶';
}
// 打开数据目录
async _openDataDirectory() {
return openDataDirWithToast();
}
// 清空搜索(✕ 按钮 + Esc 共用)
_clearSearch() {
if (!this.searchInput) return;
// 必须先掐掉 pending debounce —— 否则 100ms 内 setSearchQuery(q) 会带着旧的
// 输入值落地,让"刚刚清空"的搜索框又被回填为旧值(详见构造函数的 _searchTimerId
// 注释)。先 clear 再 setSearchQuery('') 是稳妥顺序:旧值决不会再被发出。
clearTimeout(this._searchTimerId);
this._searchTimerId = 0;
this.searchInput.value = '';
if (this.searchClear) this.searchClear.hidden = true;
this.store.setSearchQuery('');
this.searchInput.focus();
}
updateFileInfo() {
if (!this.fileInfoEl || !this.filePathText) return;
if (!this.store.filePath) {
// 未关联文件时,显示默认数据目录的预期路径
if (this._dataDir) {
// 分隔符跟随 _dataDir 自身的风格:硬编码 '\\' 会在 macOS/Linux 上
// 显示成 "~/TodoList\todo.md"
const sep = this._dataDir.includes('\\') ? '\\' : '/';
const fullPath = this._dataDir.replace(/[\\/]$/, '') + sep + 'todo.md';
// 状态栏显示用 `~/...` 形式(避免泄露用户名 / 跨用户一致);
// 完整绝对路径放 title 属性hover / 需要复制时仍能拿到。
const display = displayPath(fullPath, this._homeDir);
this.filePathText.textContent = display;
this.fileInfoEl.title = `当前未关联文件\n新建或打开后,默认保存到:\n${fullPath}\n点击打开数据文件夹`;
} else {
this.filePathText.textContent = '未关联文件';
this.fileInfoEl.title = '请新建或打开一个 Todo 文件\n点击打开数据文件夹';
}
this.fileInfoEl.classList.add('unsaved');
this.fileInfoEl.classList.remove('dirty');
} else {
this.fileInfoEl.classList.remove('unsaved');
const dirty = this.store.dirty;
this.fileInfoEl.classList.toggle('dirty', dirty);
// 已关联文件:直接展示当前路径 —— 即使用户自定义了 dataDir也展示实际路径
// 状态栏应该如实反映「我现在在哪」而不是「默认应该在哪」。
// 全路径交给 CSS ellipsis 在宽度不够时再省略title 保留完整路径方便复制
this.filePathText.textContent = this.store.filePath;
this.fileInfoEl.title = this.store.filePath + (dirty ? '\n未保存' : '') + '\n点击打开数据文件夹';
}
}
onSaved(info) {
this.updateFileInfo();
// 自动保存只更新状态栏,不弹 toast —— 频繁弹窗会严重打扰用户
if (!info.auto) {
toast(`已保存到 ${shortenPath(info.filePath)}`, 'success', 1500);
}
}
updateTheme() {
const resolved = this.store.resolveTheme();
document.body.dataset.theme = resolved;
if (this.themeIcon) {
this.themeIcon.innerHTML = resolved === 'dark' ? Toolbar.ICON_MOON : Toolbar.ICON_SUN;
}
if (this.btnTheme) {
// 用 title 反映「实际生效的主题」而非用户选择的模式 —— 用户切到「自动」时
// 显示「跟随系统(当前:深色)」比单写「自动主题」更直观,能看到系统当前决议。
// 注意不要在前面再加「当前:」前缀,否则会出现「当前:自动(当前:深色)」重叠。
//
// 文案与 settings-dialog 的「暗色 / 浅色 / 跟随系统」保持一致(去掉中间空格,
// 用「跟随系统」而非「自动」),避免两处对同一概念两种说法引起认知割裂。
const themeLabel = this.store.theme === 'auto'
? '跟随系统(当前:' + (resolved === 'dark' ? '深色' : '浅色') + ''
: (resolved === 'dark' ? '深色' : '浅色') + '主题';
this.btnTheme.title = themeLabel;
}
}
}

19
src/utils/data-dir.js Normal file
View File

@@ -0,0 +1,19 @@
// 数据目录相关 IPC 调用的封装
// 把"调 main 进程 → 失败时 toast"的样板逻辑集中到一处,避免散落在多个 UI 组件里
import { toast } from '../ui/feedback.js';
/**
* 让系统文件管理器打开数据目录
* 失败时 toast 错误信息(统一在 3 秒后消失)
*/
export async function openDataDirWithToast() {
try {
const result = await window.api.openDataDir();
if (!result.ok) toast(`打开失败:${result.error}`, 'error', 3000);
return result;
} catch (err) {
toast(`打开失败:${err.message}`, 'error', 3000);
return { ok: false, error: err.message };
}
}

130
src/utils/dom.js Normal file
View File

@@ -0,0 +1,130 @@
// 共享工具函数
/**
* 转义 HTML 特殊字符
* @param {*} s
* @returns {string}
*/
export function escapeHtml(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* 转义 HTML 字符(用于 HTML 属性,已包含双引号转义)
* @param {*} s
* @returns {string}
*/
export function escapeAttr(s) {
return escapeHtml(s);
}
/**
* 中西文之间的「间隙元素」。
*
* 为什么不直接插入空格:任务文本在双击编辑时是把 textContent 灌回 input 的,
* 真插入空格会污染用户的原始内容(保存后就多出一堆空格)。空标签不产生任何
* textContent也不影响复制粘贴只在视觉上把两侧撑开。
*
* 为什么用 padding 而不是 inline-block/margin已完成任务的 .task-text 带
* line-throughCSS 规定祖先的 text-decoration 不会画过 inline-block 这类
* 原子行内盒(删除线会断成一截一截),而 margin 区域同样不画padding 区域
* 会被画到,所以删除线保持连续。见 .cjk-gap 的样式定义。
*/
export const CJK_GAP_HTML = '<span class="cjk-gap"></span>';
// 汉字、假名、CJK 部首等「需要留白」的字符。
// 刻意不含 U+3000-303F、「」等 CJK 标点)和全角字符:它们自带字面宽度,
// 再加间隙反而更难看。
const CJK_CHAR = /[⺀-⻿⼀-⿟぀-ゟ゠-ヺー-ヿ㐀-䶿一-鿿豈-﫿]/;
// 西文一侧只认字母和数字,标点(如 "中文.")不留白。
const LATIN_CHAR = /[0-9A-Za-z]/;
/**
* 判断两个相邻字符之间是否需要插入间隙。
* @param {string} prev 左侧字符(可为空串)
* @param {string} next 右侧字符(可为空串)
* @returns {boolean}
*/
export function needsCjkGap(prev, next) {
if (!prev || !next) return false;
if (CJK_CHAR.test(prev) && LATIN_CHAR.test(next)) return true;
if (LATIN_CHAR.test(prev) && CJK_CHAR.test(next)) return true;
return false;
}
/**
* 转义 HTML并在中西文边界插入间隙元素。
*
* 注意:拆分必须在转义之前做。先转义的话 "&amp;" 里的字母会被当成西文,
* 间隙元素就插到实体内部去了。
*
* 只处理字符串内部的边界;首字符与前一段文本之间的边界由调用方用
* needsCjkGap + CJK_GAP_HTML 处理(这样间隙才能落在 <mark> 外面,
* 不会被高亮背景染色)。
*
* @param {*} s
* @returns {string}
*/
export function escapeHtmlAutospace(s) {
const text = String(s ?? '');
if (text.length < 2) return escapeHtml(text);
let out = '';
let start = 0;
for (let i = 1; i < text.length; i++) {
if (needsCjkGap(text[i - 1], text[i])) {
out += escapeHtml(text.slice(start, i)) + CJK_GAP_HTML;
start = i;
}
}
return out + escapeHtml(text.slice(start));
}
/**
* 路径缩短:保留末尾两段路径,中间用 …/ 表示
* @param {string} p
* @param {number} max
* @returns {string}
*/
export function shortenPath(p, max = 42) {
if (!p) return '';
if (p.length <= max) return p;
const parts = p.split(/[\\/]/);
if (parts.length <= 2) return p;
return '…/' + parts.slice(-2).join('/');
}
/**
* 把绝对路径转成 `~/...` 显示形式 —— 在用户主目录内的路径用 `~/` 前缀,
* 在主目录外的保持原样。
*
* 用途:「恢复默认」「默认数据目录」这类 UI 提示不能把带用户名的绝对路径写死
* (硬编码 `C:\Users\<name>\...` 在别人机器 / CI 上展示不友好,也会泄用户名)。
* 统一显示成 `~/TodoList/todo.md` 风格,跨用户一致。
*
* 注意:大小写不敏感比较 —— Windows (NTFS) / macOS (APFS/HFS+) 默认
* 大小写不敏感,但 home 路径与传入路径在 toLowerCase 后应一致;保留原大小写
* 输出,只对比较做归一化。
*
* @param {string} absPath 绝对路径
* @param {string|null|undefined} home 用户主目录(来自 app:get-home-dir IPC
* @returns {string}
*/
export function displayPath(absPath, home) {
if (!absPath) return '';
if (!home) return absPath;
// 归一化斜杠 + 去掉末尾分隔符home 在 Windows 上可能是 C:\Users\guan 或 C:\Users\guan\
const norm = (s) => String(s).replace(/\\/g, '/').replace(/\/+$/, '');
const np = norm(absPath);
const nh = norm(home);
const npLow = np.toLowerCase();
const nhLow = nh.toLowerCase();
if (npLow === nhLow) return '~';
if (npLow.startsWith(nhLow + '/')) return '~/' + np.slice(nh.length + 1);
return absPath;
}

16
src/utils/drag.js Normal file
View File

@@ -0,0 +1,16 @@
// 拖拽相关常量与共用工具
//
// 用途:消除 task-list.js_setupDrag与 sidebar.js_setupSidebarDrag之间
// 重复声明的拖拽阈值。两边阈值不一致会直接表现为「同一个指针移动 5px一处
// 已经认为开始拖拽、另一处还在判定为点击」—— 用户体感是「拖到一半突然抖一下」。
/**
* 拖拽激活阈值(像素)。
*
* 指针按下后移动距离超过该阈值才视作「开始拖拽」,否则视为点击。
* 4px 是经验值 —— 小于这个值容易把抖动的手指误判为拖拽(特别是触摸板),
* 大于这个值用户需要先「抖」一下才开始拖,体验拖沓。
*
* 改动此值需要同步 review 两边的拖拽交互;集中常量就是为了避免漏改。
*/
export const DRAG_THRESHOLD = 4;

76
src/utils/focus-trap.js Normal file
View File

@@ -0,0 +1,76 @@
// UI 焦点陷阱Tab 在 modal 内首尾循环,不让焦点飘到背景。
//
// 为什么必须做:
// modal 是「模态」的核心承诺是「键盘焦点也只能在它内部」。如果 Tab 能跑到背后的
// 任务列表/侧边栏,按下去就是「我在对话框里按 Tab怎么删掉了一条任务」——
// 这种 bug 复现链路非常隐蔽,且不会被任何单元测试抓到。
//
// 实现:监听 modal 内 keydown找到所有「应当被聚焦」的元素按钮、输入框等
// 在最后一个按 Tab → 跳到第一个Shift+Tab 在第一个 → 跳到最后一个。
//
// 注意:把 disabled 元素排除 —— focus() 会忽略它们,不能拿它当锚点。
//
// 之前散落在 feedback.js / conflict-dialog.js / import-dialog.js 三处,
// 各自复制一份 ~20 行实现。conflict-dialog 的注释还特别写「不重复实现就这里复制
// 一份,避免 import 循环」—— 实际上根本不存在 import 循环dialog 都引了 utils
// 复制是当年没核实的借口。
/**
* 找出容器内所有「可被聚焦」的元素(按 Tab 顺序)。
* 仅 trapFocus 内部用 —— 之前导出过但全项目无人 import
* 收成内部函数缩小公共 API。
*
* 历史踩坑:旧实现用 `el.offsetParent !== null` 判定可见性 —— 但标准 CSS 下
* `offsetParent` 对 `position: fixed` 元素恒为 null。本项目所有 modal 容器
* .modal-overlay / .modal / .command-palette-overlay都用 position: fixed
* 结果 modal 内每个可聚焦元素都被误判成「不可见」过滤掉trapFocus 在 focusable
* 为空时直接 preventDefault + return吞掉用户的 Tab。修法用 `getClientRects()`
* 判定布局可见性fixed / transform 后的元素都算可见),并兼容 display:none /
* visibility:hidden。
*
* @param {HTMLElement} container
* @returns {HTMLElement[]}
*/
function getFocusable(container) {
return Array.from(container.querySelectorAll(
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
)).filter((el) => {
// 已经在 container 里querySelectorAll 已经保证)→ 可见性靠自身
if (el === document.activeElement) return true;
// display:none / visibility:hidden / 零尺寸 / 完全脱离视口的视为不可聚焦
if (el.getClientRects().length === 0) return false;
const style = getComputedStyle(el);
if (style.visibility === 'hidden' || style.display === 'none') return false;
return true;
});
}
/**
* 在容器内拦截 Tab/Shift+Tab让焦点在首尾循环。
*
* 用法(典型 modal 渲染后):
* overlay.addEventListener('keydown', (e) => trapFocus(e, overlay, signal));
*
* 如果容器里没有任何可聚焦元素Tab 也会被拦截 —— 否则焦点会被偷到 body 上。
*
* @param {KeyboardEvent} e
* @param {HTMLElement} container
* @param {AbortSignal} [signal]
*/
export function trapFocus(e, container, signal) {
if (e.key !== 'Tab') return;
const focusable = getFocusable(container);
if (focusable.length === 0) {
e.preventDefault();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}

33
src/utils/keymap.js Normal file
View File

@@ -0,0 +1,33 @@
// 键位守卫:跨 dialog 复用的 IME / 修饰键判断 helper。
//
// 为什么要集中:
// 1. 中文 / 日文 IME 在组合输入期间按 Enter 是「上屏」,不是「确认」。
// 浏览器通过 isComposing=true 或 keyCode=229 标识这种状态。
// 现代浏览器两种都发,但旧 Safari 只发 keyCode=229。
// 漏掉任一检查 → 用户 IME 上屏时被误判为「确认」,未写完的字符串被吞。
// 2. Cmd/Ctrl+Enter 是「应用合并」「导入」等高权重操作的快捷键,
// 与 Enter 区分开 —— 普通 Enter 触发默认行为Cmd/Ctrl+Enter 才提交。
//
// 之前 feedback.js / conflict-dialog.js 各自复制 `!e.isComposing && e.keyCode !== 229`
// task-list.js 只检查 `!e.isComposing`IME 上屏会被误当成提交,潜在 bug
// 这里统一一份,三个文件 + task-list 都引用。
/**
* 事件是否发生在 IME 组合输入期间(中文 / 日文上屏中按 Enter 等场景)。
* 用 `||` 而非 `&&` 因为部分浏览器只发其中一个。
* @param {KeyboardEvent} e
* @returns {boolean}
*/
export function isImeComposing(e) {
return !!(e.isComposing || e.keyCode === 229);
}
/**
* 事件是否是「Cmd+Enter / Ctrl+Enter」组合键。
* 注意:不包含 IME 守卫 —— 调用方按需叠加 `!isImeComposing(e)`。
* @param {KeyboardEvent} e
* @returns {boolean}
*/
export function isCmdEnter(e) {
return e.key === 'Enter' && (e.metaKey || e.ctrlKey);
}

442
src/utils/markdown-diff.js Normal file
View File

@@ -0,0 +1,442 @@
// 任务级 diff / merge 工具
//
// 用途:当「内存中的 store.categories」与「磁盘解析出的 categories」不一致时
// 给冲突解决 UI 提供按任务粒度的合并能力:
// - 找出只在内存里的任务(用户新增的)
// - 找出只在磁盘里的任务(用户在外部编辑器新增的)
// - 找出文本相同但状态不同的任务(用户改了标记 vs 文件改了标记)
// - 找出文本被修改的任务(用户重命名 vs 文件重命名)
//
// 关键约定:
// - taskKey = `${catName}::${text}` —— 用「逻辑分类 + 文本」作为身份标识。
// **不**用 task.idid 是运行时句柄,跨 reload 重新分配,无法跨两侧稳定)。
// **不**用 taskIdx任务在分类内的位置 —— 默认 newTaskPosition='front'
// 用户在 UI 加一条任务会让其下所有任务 idx 都 +1原本同一条任务在 A/B
// 两侧的 key 会分叉触发「onlyInA + onlyInB 各报一次 → 默认合并重复」)。
// 文本变了就是另一条任务。
// - containerkind=OTHER_TASKS/ today 两类**不**参与 diff结构性容器。
// - COMPLETED / TRASH **参与** diff —— 但 effective cat name 用 task.originalCategory
// v3.4 不变量completed=true 任务的逻辑归属)。这样磁盘里 [ ] 任务和内存里
// 同文本的 [✓] 任务(搬到 COMPLETED能落到同一 key进 modified 走 OR 合并,
// 否则用户的勾选会被磁盘 [ ] 版本静默撤销H4 历史 bug
// - 顶层 categories 顺序不在 diff 范围内 —— 顺序由 _normalizeOrder 保证,
// merge 后跑一次 normalize 即可。
//
// 纯函数:无 DOM 依赖、无 store 依赖、可独立单测。
import { CategoryKind } from '../markdown-parser.js';
/**
* 把 categories 数组展平成 Map<taskKey, {task, cat, catIdx, taskIdx, realCat}>
*
* `realCat` 在 COMPLETED / TRASH 任务下指向 task 实际所在的容器(保留 cat.kind
* 用于 applyResolutions 决定最终落点);其他情况等于 cat。
*
* @param {Array} categories - store 风格的 categories 数组
* @returns {Map<string, {task: object, cat: object, catIdx: number, taskIdx: number, realCat: object}>}
*/
export function categoriesToMap(categories) {
const map = new Map();
if (!Array.isArray(categories)) return map;
// 两遍扫描先处理状态容器COMPLETED/TRASH再处理其它分类。
// 原因v3.4 不变量要求「completed=true / 在回收站」的任务只在状态容器里。
// 但磁盘/内存过渡期可能同时存在 NORMAL 副本 + 状态容器副本,按 key 去重时
// 必须让状态容器副本赢(它是用户当前位置的真相)—— 否则 diff 看不出勾选/回收
// 动作,会误判为 unchangedH4 / H5 历史 bug 的根源之一)。
const passes = [
cats => cats.filter(c => c && (c.kind === CategoryKind.COMPLETED || c.kind === CategoryKind.TRASH)),
cats => cats.filter(c => c && c.kind !== CategoryKind.COMPLETED && c.kind !== CategoryKind.TRASH
&& c.kind !== CategoryKind.OTHER_TASKS && c.kind !== CategoryKind.TODAY),
];
for (const pick of passes) {
const filtered = pick(categories);
for (let catIdx = 0; catIdx < filtered.length; catIdx++) {
const cat = filtered[catIdx];
const catName = (cat.name || '').trim() || '__anon__';
const tasks = Array.isArray(cat.tasks) ? cat.tasks : [];
// COMPLETED / TRASH 是「状态容器」—— 任务的逻辑归属在 originalCategory。
// key 用 effective name原分类以匹配磁盘侧同文本 [ ] 任务。
const isStateContainer = cat.kind === CategoryKind.COMPLETED || cat.kind === CategoryKind.TRASH;
for (let taskIdx = 0; taskIdx < tasks.length; taskIdx++) {
const task = tasks[taskIdx];
if (!task || typeof task.text !== 'string') continue;
// effective cat namestate container 下用 originalCategoryv3.4 标记),
// 其他场景直接用 cat.nameorphan 时回退 '__anon__')。
let effectiveCatName = catName;
if (isStateContainer) {
effectiveCatName = (task.originalCategory || '').trim() || '__anon__';
}
// v3.7+ 修复HIGH data-loss vector当 effectiveCatName 落到 '__anon__'
// 兜底串state container 下 originalCategory 为空 / 普通 orphan 分类无名),
// 同 cat 内多条任务 text 相同时会撞同一个 key —— `if (!map.has(key))` 让首条
// 胜出,后续条**静默被吞**。后果是冲突合并时这些任务根本不进 diff
// applyResolutions 看不到它们,存盘后用户丢任务。
//
// 修法effectiveCatName 是 '__anon__' 时把 taskIdx 拼进 key让每条任务
// 都有独立身份。normal path 下 effectiveCatName 是真名(同 cat 内 text 撞
// key 是用户真实的「同 text 不同任务」歧义,应让首条胜出保持原行为)。
const isAnonEffective = effectiveCatName === '__anon__';
const key = isAnonEffective
? makeTaskKey(`__anon_${catIdx}_${taskIdx}`, task.text)
: makeTaskKey(effectiveCatName, task.text);
// 同 key 重复时保留首次出现 —— 状态容器在第一遍已写入,第二遍普通
// cat 的同 key 任务自动让位(不会覆盖)。
// effectiveCatName 存到 entry 里 —— diffCategories 要用它在跨分类
// 移动判定时区分「同 effective 分类NORMAL↔COMPLETED 算同逻辑位置)」
// vs「真·跨分类移动NORMAL「工作」→ NORMAL「学习」」。
if (!map.has(key)) {
map.set(key, { task, cat, catIdx, taskIdx, realCat: cat, effectiveCatName });
}
}
}
}
return map;
}
/**
* 构造 task key。
*
* key 组成 = `${catName}::${text}`(带长度前缀防撞):
* - catNameeffective 分类身份state container 下用 originalCategory
* - text任务文本trim 过)
*
* 长度前缀(每个字段前 `${length}:`)防止 `${catName='a'}::${text='::b'}` 与
* `${catName='a::'}::${text='b'}` 这类无前缀时碰撞。
*
* 历史注:早期版本 key 包含 idxInCat —— 意图是区分「同 cat 内 A 侧第 2 条 vs
* B 侧第 3 条」这种位置漂移。但默认 newTaskPosition='front',用户加任务会让
* 其下所有 idx +1原本同一条任务在 A/B 两侧 key 分叉 → onlyInA + onlyInB
* 各报一次 → 默认 'a'/'b' 各加一份 → 重复。v4 修复:去掉 idxInCat。
*
* @param {string} catName
* @param {string} text
* @param {number} [_idxInCat] - 已忽略,保留参数兼容旧调用
*/
export function makeTaskKey(catName, text, _idxInCat) {
// 长度前缀防撞 —— 没长度前缀时 (catName='abc', text='::x') 与 (catName='abc::', text='x')
// 都拼成 'abc::::x'Map 当成同 key → 两条不同的任务在合并时互相错位、数据互窜。
const safeText = (text || '').trim();
return `${catName.length}:${catName}::${safeText.length}:${safeText}`;
}
/**
* 比较两条 task 是否「实质相同」text 字段相同即可,状态不同算 modified
* @param {object} a
* @param {object} b
*/
export function tasksTextuallyEqual(a, b) {
if (!a || !b) return false;
return (a.text || '').trim() === (b.text || '').trim();
}
/**
* 比较两条 task 的「状态字段」completed / important / current
* @returns {boolean} 三个布尔位完全相等
*/
export function tasksStateEqual(a, b) {
if (!a || !b) return false;
return Boolean(a.completed) === Boolean(b.completed)
&& Boolean(a.important) === Boolean(b.important)
&& Boolean(a.current) === Boolean(b.current);
}
/**
* 计算两个 categories 之间的任务级 diff。
*
* 同 key 出现在不同「实际 cat」的几种场景
* 1) effectiveCatName 一致 + catA.kind === catB.kind —— 同一逻辑位置,
* 只看状态字段(典型 modified / unchanged
* 2) effectiveCatName 一致 + 一边是 COMPLETED/TRASH 另一边不是 —— 这是
* 用户的「勾选 / 取消勾选 / 回收 / 恢复」动作。**不进** onlyInA + onlyInB
* (那样会让磁盘 [ ] 副本和内存 [✓] 副本同时进合并结果,触发重复 / 撤销)。
* 走 modifiedstate-only真实状态字段会被 OR 合并)+ applyResolutions
* 里的「state container move」分支移除源 cat、追加到目标 cat。
* 3) effectiveCatName 不一致 + 两边都是普通 cat —— 用户把任务从一个
* 分类拖到另一个(旧分类 onlyInA / 新分类 onlyInB让用户按 onlyInA
* / onlyInB 各走一遍即可,避免误报 modified 让用户对同一条任务的搬迁
* 选两次边。
*
* @param {Array} catsA - 「A 侧」的 categories如磁盘解析结果
* @param {Array} catsB - 「B 侧」的 categories如内存里的 store.categories
* @returns {{
* onlyInA: Array<{key, task, cat}>,
* onlyInB: Array<{key, task, cat}>,
* modified: Array<{key, taskA, taskB, catA, catB, stateOnly: boolean, stateContainerMove: boolean}>,
* unchanged: Array<{key, taskA, taskB, catA, catB}>
* }}
*/
export function diffCategories(catsA, catsB) {
const mapA = categoriesToMap(catsA);
const mapB = categoriesToMap(catsB);
const onlyInA = [];
const onlyInB = [];
const modified = [];
const unchanged = [];
for (const [key, entryA] of mapA.entries()) {
const entryB = mapB.get(key);
if (!entryB) {
onlyInA.push({ key, task: entryA.task, cat: entryA.cat });
continue;
}
const sameLogicalCat = entryA.effectiveCatName === entryB.effectiveCatName;
const sameKind = entryA.realCat.kind === entryB.realCat.kind;
// 场景 1: 同 effective 分类 + 同 kind → 同一逻辑位置,仅看状态字段
if (sameLogicalCat && sameKind) {
if (tasksStateEqual(entryA.task, entryB.task)) {
unchanged.push({ key, taskA: entryA.task, taskB: entryB.task, catA: entryA.cat, catB: entryB.cat });
} else {
modified.push({
key,
taskA: entryA.task,
taskB: entryB.task,
catA: entryA.cat,
catB: entryB.cat,
stateOnly: true,
stateContainerMove: false,
});
}
continue;
}
// 场景 2: effective 分类一致,但一边进了状态容器 —— 勾选 / 取消 / 回收 / 恢复。
// 走 modifiedapplyResolutions 处理「源 cat 移除 + 目标 cat 追加」)。
if (sameLogicalCat) {
modified.push({
key,
taskA: entryA.task,
taskB: entryB.task,
catA: entryA.cat,
catB: entryB.cat,
stateOnly: !tasksStateEqual(entryA.task, entryB.task),
stateContainerMove: true,
});
continue;
}
// 场景 3: 真·跨分类移动NORMAL「工作」 → NORMAL「学习」
// 双向报告,让旧分类走 onlyInA、新分类走 onlyInB。
onlyInA.push({ key, task: entryA.task, cat: entryA.cat });
onlyInB.push({ key, task: entryB.task, cat: entryB.cat });
}
for (const [key, entryB] of mapB.entries()) {
if (!mapA.has(key)) {
onlyInB.push({ key, task: entryB.task, cat: entryB.cat });
}
}
return { onlyInA, onlyInB, modified, unchanged };
}
/**
* 解析用户选择 → 应用到 categories。
*
* @param {Array} baseCategories - 起始 categories如磁盘解析结果
* @param {object} diff - diffCategories() 的输出
* @param {object} resolutions - 用户对每个差异条目的选择:
* key → 'a' | 'b' | 'both' | 'skip'
* 'a' = 用 A 侧(文件版)任务
* 'b' = 用 B 侧(内存版)任务
* 'both'= 两边都保留(适用于 onlyInA 和 onlyInB
* 'skip'= 丢弃(不写入结果)
* @returns {Array} 合并后的 categories仍是合法格式,需跑一次 _normalizeOrder
*/
export function applyResolutions(baseCategories, diff, resolutions) {
// 浅拷贝 categories + 每个 cat 的 tasks 数组(不深拷 task 对象 —— store 拿过去直接用)
const out = baseCategories.map(cat => ({
...cat,
tasks: Array.isArray(cat.tasks) ? cat.tasks.slice() : [],
}));
const findOrCreateCat = (catName, kind, sourceMeta) => {
// 两边都 trim —— categoriesToMap 已经把 name.trim() 用作 key但 item.cat.name
// 是原始(未 trim值透传过来的若 c.name 是 trim 版而 catName 是带空白的,
// 这里就会漏匹配 → 静默创建重复分类。
const targetName = (catName || '').trim();
let target = out.find(c => (c.name || '').trim() === targetName);
if (!target) {
target = {
name: targetName,
kind: kind || CategoryKind.NORMAL,
isSpecial: false,
// 透传源 cat 的 meta —— 普通分类通常 meta=null 不影响,但「未分类」
// 等保留分类可能在 writer/reader 间携带 description 等元数据;新建
// 时不能用 null 静默覆盖,否则下游渲染会丢字段。
meta: sourceMeta || null,
parentOtherTasks: true,
tasks: [],
};
out.push(target);
}
return target;
};
// 1) 处理 onlyInAA 独有,「文件里有但内存里没有」)
for (const item of diff.onlyInA) {
const choice = resolutions[item.key] || 'a'; // 默认「用文件」(新增的任务当然要)
if (choice === 'skip') continue;
if (choice === 'a' || choice === 'both') {
const cat = findOrCreateCat(item.cat.name, item.cat.kind, item.cat.meta);
// 避免重复task 对象引用比较即可 —— 同对象不会出现在同 cat 两次)
if (!cat.tasks.includes(item.task)) {
cat.tasks.push(item.task);
}
}
// 'b' 在 onlyInA 场景下无意义 —— B 没有这条任务,忽略
}
// 2) 处理 onlyInBB 独有,「内存里有但文件里没有」—— 用户自己加的)
for (const item of diff.onlyInB) {
const choice = resolutions[item.key] || 'b'; // 默认「用本地」(用户加的任务当然要)
if (choice === 'skip') continue;
if (choice === 'b' || choice === 'both') {
const cat = findOrCreateCat(item.cat.name, item.cat.kind, item.cat.meta);
if (!cat.tasks.includes(item.task)) {
cat.tasks.push(item.task);
}
}
// 'a' 在 onlyInB 场景下无意义 —— A 没有这条任务,忽略
}
// 3) 处理 modified两边都有但状态不同 —— OR 合并状态)
for (const item of diff.modified) {
const choice = resolutions[item.key] || 'both'; // 默认「两边都保留」(OR 合并)
if (choice === 'skip') continue;
// 3a) 状态容器移动:一边 NORMAL 一边 COMPLETED/TRASH如用户勾选 / 取消勾选 /
// 回收 / 恢复)。必须显式「从源 cat 移除、追加到目标 cat」——
// 否则 baseCategories 里源 cat 的 [ ] 副本会和追加到目标 cat 的
// [✓]/[trash] 副本共存,产生重复 + 撤销用户操作的语义错位。
if (item.stateContainerMove) {
const targetCat = findOrCreateCat(item.catB.name, item.catB.kind, item.catB.meta);
const mergedTask = mergeTaskStates(item.taskA, item.taskB);
// 'a' 在 state container 移动下语义是「保留磁盘侧位置」—— 即不应用用户
// 的勾选 / 回收操作,保留磁盘 [ ] 版本。所以**必须用 taskA**(磁盘侧)
// 而非 mergedTaskOR 合并会带上用户态。mergeTaskStates 在这里会用
// taskB.completed=true 把"用户撤销勾选"偷偷回写成 [✓],违反 'a' 语义。
if (choice === 'a') {
const sourceCat = out.find(c => (c.name || '').trim() === (item.catA.name || '').trim());
if (sourceCat) {
const srcIdx = sourceCat.tasks.findIndex(t => t.text.trim() === item.taskA.text.trim());
if (srcIdx >= 0) sourceCat.tasks[srcIdx] = item.taskA;
else sourceCat.tasks.push(item.taskA);
}
continue;
}
// 'b' 或 'both' → 移到目标 cat用户当前位置从源 cat 移除、追加到目标 cat。
const sourceCat = out.find(c => (c.name || '').trim() === (item.catA.name || '').trim());
if (sourceCat) {
const srcIdx = sourceCat.tasks.findIndex(t => t.text.trim() === item.taskA.text.trim());
if (srcIdx >= 0) sourceCat.tasks.splice(srcIdx, 1);
}
const tgtIdx = targetCat.tasks.findIndex(t => t.text.trim() === item.taskB.text.trim());
if (tgtIdx >= 0) {
targetCat.tasks[tgtIdx] = mergedTask;
} else {
targetCat.tasks.push(mergedTask);
}
continue;
}
// 3b) 普通 modified同 cat 内状态不同 —— OR 合并状态)
const cat = findOrCreateCat(item.catB.name, item.catB.kind, item.catB.meta);
// 找到同 text 的位置baseCategories 里有,map 里有对应 entry
// 但 taskB 来自 B 侧(内存),可能 baseCategories 里没有这个 cat用户新建的分类
// → findOrCreateCat 创建了一个空 cat.tasksfindIndex 返回 -1。这种情况下
// 不能静默丢弃,否则用户的本地任务在合并后凭空消失 —— 应该按 choice 直接 push。
const idx = cat.tasks.findIndex(t => t.text.trim() === item.taskB.text.trim());
if (idx >= 0) {
if (choice === 'a') {
cat.tasks[idx] = item.taskA;
} else if (choice === 'b') {
cat.tasks[idx] = item.taskB;
} else if (choice === 'both') {
// 两边都保留 —— 标记位合并(任一为 true 则 true),文本相同的只保留一份
cat.tasks[idx] = mergeTaskStates(item.taskA, item.taskB);
}
} else {
// idx < 0 的兜底cat 在 baseCategories 里不存在taskB 来自用户新建的 cat
// 按 choice 直接追加一份('both' 时取 OR 合并态)。
if (choice === 'a') {
cat.tasks.push(item.taskA);
} else if (choice === 'b') {
cat.tasks.push(item.taskB);
} else if (choice === 'both') {
cat.tasks.push(mergeTaskStates(item.taskA, item.taskB));
}
}
}
return out;
}
/**
* 把两条同文本任务的状态字段按 OR 合并,文本取 trim 后更长的那份(避免误删)。
*
* 字段白名单(防止泄漏运行时字段):
* - 显式只透传 { text, important, completed, current, originalCategory }
* - **不**透传 task.id运行时句柄,A/B 两侧的 id 可能不同,合并后必须由 store 重新分配)
* - **不**透传 _skipReload / _pickerAttempts / createdAt / updatedAt内部状态,合并结果不能污染)
*
* current 特殊处理v4 修复):
* - 「当前任务」是单值指针(同一时刻只能有一条 `[▶]`
* - 仅一边 current=true → 保留用户的标记OR—— 修复历史 bug旧版无条件置
* false导致「用户在 UI 标了 [▶]、磁盘还没同步」合并后丢失当前任务标记
* - 两边都 current=true → 仍是矛盾状态,强制 false让用户手动重新指定
* (理论上不会到这里 —— tasksStateEqual 在两边 current 都 true 时判相等,
* 进 unchanged 不进 modified留兜底防 bypass
*
* @param {object} taskA
* @param {object} taskB
* @returns {object} 一条新的任务对象
*/
export function mergeTaskStates(taskA, taskB) {
const text = (taskB.text || '').trim() || (taskA.text || '').trim();
const aCurrent = Boolean(taskA.current);
const bCurrent = Boolean(taskB.current);
// 仅一边 true → OR 保留;两边都 true → 矛盾,强制 false
const mergedCurrent = (aCurrent !== bCurrent) && (aCurrent || bCurrent);
return {
text,
important: Boolean(taskA.important) || Boolean(taskB.important),
completed: Boolean(taskA.completed) || Boolean(taskB.completed),
current: mergedCurrent,
// originalCategory 任一非空就用那个(双方都有时优先 taskA —— 文件侧是真相之源)
originalCategory: taskA.originalCategory || taskB.originalCategory || null,
};
}
/**
* 为 diff 结果生成「默认选择」:
* - onlyInA → 默认 'a'(保留文件侧新增)
* - onlyInB → 默认 'b'(保留本地侧新增)
* - modified → 默认 'both'OR 合并状态)—— 用户大概率「两边都想要」,避免
* 自动选边丢标记位;冲突的 current 字段在 mergeTaskStates 里统一置 false
*
* 用户可以在 UI 里逐条改成其他选项。
*
* @param {object} diff - diffCategories() 输出
* @returns {object} resolutions 初始值,key → 'a'|'b'|'both'|'skip'
*/
export function defaultResolutions(diff) {
const res = {};
for (const item of diff.onlyInA) res[item.key] = 'a';
for (const item of diff.onlyInB) res[item.key] = 'b';
for (const item of diff.modified) res[item.key] = 'both';
return res;
}
/**
* 列举 diff 里的「无歧义」改动 —— 即仅一边有的任务 + modified 的统计。
* 给 UI 显示「共 N 处差异」用。
*
* @param {object} diff
* @returns {{ total: number, addedByFile: number, addedByMine: number, conflicting: number }}
*/
export function summarizeDiff(diff) {
return {
total: diff.onlyInA.length + diff.onlyInB.length + diff.modified.length,
addedByFile: diff.onlyInA.length,
addedByMine: diff.onlyInB.length,
conflicting: diff.modified.length,
};
}

95
src/utils/path.js Normal file
View File

@@ -0,0 +1,95 @@
// 平台感知的路径处理工具
//
// 单独成模块的原因:
// 1) 这段逻辑需要被单测覆盖scripts/check-external-change-path.mjs
// 留在 app.js 里没法独立 import —— ES module + DOM 依赖下 Node 拉不起来
// 2) 路径比较是低层工具,未来 sidebar / settings 等文件路径相关逻辑也可能复用
// (目前先在 app.js 的外部修改冲突检测里用,未来搬不搬再说)
/**
* 平台感知的多段路径拼接 —— 渲染端默认输出正斜杠(与主进程 path.resolve 区分)。
*
* 与 Node 内置 path.join 的区别:
* 1) 跨平台统一正斜杠渲染端的所有路径store.filePath 等)都用 `/`
* 让 pathsEqual 在 Windows 上能跟主进程 `path.resolve` 的反斜杠输出做比较
* 2) UNC 路径(`\\server\share`)保留前导 `//` —— path.join 会把它折叠成单斜杠
* 3) 过滤掉空段(避免 `joinPath('a', '', 'b')` 出来 `a//b`
*
* 之前是 src/app.js 第 60 行本地函数,只在那里用。但配合 pathsEqual 形成"拼 + 比"
* 一对,挪到 utils 让 check-external-change-path.mjs 也能复用、并防止"只测了比较没测
* 拼接"的盲区。
*
* @param {...string} parts
* @returns {string}
*/
export function joinPath(...parts) {
const joined = parts
.filter(Boolean)
.join('/')
.replace(/\\/g, '/');
// UNC 路径(\\server\share转成 //server/share 后,前导的双斜杠有语义,
// 不能被下面的 /\/+/g 折叠掉 —— 否则会变成 /server/share指向本地根目录。
const isUnc = joined.startsWith('//');
const normalized = joined
.replace(/\/+/g, '/')
.replace(/\/$/, '');
return isUnc ? '/' + normalized : normalized;
}
/**
* 平台感知的路径相等比较。
*
* 背景:渲染端的 store.filePath 由 joinPath 拼出(正斜杠 `~/TodoList/todo.md`
* 主进程发来的 changedPath 走 path.resolveWindows 上是反斜杠 `C:\Users\<user>\TodoList\todo.md`)。
* 直接 `!==` 比较在 Windows 上永远不等resolveExternalChangeConflict 提前 return
* 外部修改的 toast / reload 全部静默被吞 —— 用户看到的就是「改了文件,软件没反应」。
*
* 同时 Windows / macOS 默认是大小写不敏感的文件系统,再补一层 lowercase 比较:
* - WindowsNTFS 默认大小写不敏感
* - macOSAPFS / HFS+ 默认大小写不敏感(用户可改,但极少)
* - Linux默认大小写敏感
*
* 不依赖 process.platformcontextIsolation 下 renderer 拿不到完整的 process
* 改用路径格式自识别「盘符:」开头的当作 Windows再用 navigator.platform 探测 macOS。
* 这条路径只在 renderer 跑navigator 一定有。
*
* @param {string|null|undefined} a
* @param {string|null|undefined} b
* @returns {boolean}
*/
export function pathsEqual(a, b) {
if (a === b) return true;
if (!a || !b) return false;
// 0) 尾斜杠归一化:避免 `c:/foo/` 与 `c:/foo` 不等Windows 上一边带尾斜杠一边不带很常见)
const trimTrailingSlash = (s) => s.replace(/[\/\\]+$/, '');
// UNC 路径(//server/share 或 \\server\share不能 trim 前导双斜杠 —— 那是有语义的
const isUnc = (s) => /^[\/\\]{2}/.test(s);
const normalizePath = (s) => {
const trimmed = trimTrailingSlash(s);
return isUnc(s) ? '/' + trimmed.replace(/^[\/\\]+/, '').replace(/\\/g, '/') : trimmed.replace(/\\/g, '/');
};
const aN = normalizePath(a);
const bN = normalizePath(b);
if (aN === bN) return true;
// UNC 与非 UNC 不可能相等(语义不同:一个指向网络共享,一个指向本地根)
if (isUnc(a) !== isUnc(b)) return false;
// 2) 大小写不敏感文件系统:
// - WindowsNTFS用路径是否带盘符识别无需依赖 process.platform
// - macOSAPFS / HFS+):通过 navigator.platform / userAgent 探测
const looksLikeWindowsPath = /^[a-z]:\//i.test(aN) || /^[a-z]:\//i.test(bN);
if (looksLikeWindowsPath) {
return aN.toLowerCase() === bN.toLowerCase();
}
// macOS 探测renderer 唯一可用的平台信息是 navigator.platform / userAgent
// contextIsolation 下 process 不可见)。这条在浏览器环境恒为 false
// 单测里可以塞 globalThis.navigator 模拟。
if (typeof navigator !== 'undefined') {
const platform = (navigator.platform || '').toLowerCase();
const ua = (navigator.userAgent || '').toLowerCase();
if (platform.includes('mac') || /iphone|ipad|ipod/.test(ua)) {
return aN.toLowerCase() === bN.toLowerCase();
}
}
// 3) 其他 POSIXLinux 等):保守认为大小写敏感
return false;
}