diff --git a/plugins/todo-neo/.gitignore b/plugins/todo-neo/.gitignore
new file mode 100644
index 00000000..a547bf36
--- /dev/null
+++ b/plugins/todo-neo/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/plugins/todo-neo/AGENTS.md b/plugins/todo-neo/AGENTS.md
new file mode 100644
index 00000000..97aa0634
--- /dev/null
+++ b/plugins/todo-neo/AGENTS.md
@@ -0,0 +1,81 @@
+# AGENTS.md
+
+这个项目是 ztools 待办插件,框架是 Vue 3 + Vite + TypeScript。
+
+## 基本约定
+
+- 新增 UI 逻辑时优先沿用现有组件和 composable 的拆分方式。
+- README 主要写使用说明,不写偏内部实现的开发细节。
+- 组件私有样式尽量写在对应 `.vue` 文件的 scoped style 里。
+- 交互改动后尽量用浏览器确认一次。
+- 修改数据排序时,要区分“展示过滤后的任务”和“完整分组任务”;持久化重排应基于完整任务列表。
+- 日期输入按本地年月日解析,不要用 `new Date('YYYY-MM-DD')` 或 `toISOString().slice(0, 10)` 做回显。
+
+## 代码结构
+
+```text
+.
+├── public/
+│ ├── plugin.json # ztools 插件配置
+│ ├── logo.png # 插件图标
+│ └── preload/
+│ ├── services.js # ztools tool 注册和 preload 服务
+│ └── package.json # preload 依赖声明
+├── src/
+│ ├── App.vue # 根组件,根据 route 渲染主界面或子窗口
+│ ├── main.ts # Vue 入口
+│ ├── main.css # 全局样式和主题变量
+│ ├── featureFlags.ts # 功能开关
+│ ├── types.ts # 业务类型
+│ ├── utils/
+│ │ └── inputAttrs.ts # 输入框通用属性
+│ ├── assets/
+│ │ └── icons/ # 本地图标资源
+│ ├── components/
+│ │ ├── MainTodo.vue # 主界面组合
+│ │ ├── TodoSidebar.vue # 左侧分组栏
+│ │ ├── WorkspaceHeader.vue # 当前分组标题和工具按钮
+│ │ ├── TaskList.vue # 待办列表、编辑和新建输入
+│ │ ├── TaskSearchPicker.vue # `/` 搜索面板
+│ │ ├── SettingsDrawer.vue # 设置抽屉
+│ │ ├── TaskDetailDrawer.vue # 任务详情抽屉
+│ │ ├── GlobalContextMenu.vue # 统一右键菜单
+│ │ ├── ConfirmDialog.vue # 删除确认弹窗
+│ │ ├── BaseDrawer.vue # 抽屉基础组件
+│ │ └── SvgIcon.vue # SVG 图标组件
+│ └── composables/
+│ ├── useTodoStore.ts # 组合入口,向组件暴露统一 store
+│ ├── useTodoTasks.ts # 任务创建、编辑、删除、完成
+│ ├── useTodoGroups.ts # 分组创建、编辑、删除
+│ ├── useTodoDrag.ts # 拖拽排序和跨分组移动
+│ ├── useTodoKeyboard.ts # Vim/方向键快捷键
+│ ├── useTaskSearch.ts # 搜索状态和跳转逻辑
+│ ├── useTodoContextMenu.ts # 右键菜单状态
+│ ├── useTodoWindows.ts # 子窗口相关入口
+│ ├── useTomatoTimer.ts # 计时相关状态
+│ ├── todoPersistence.ts # ztools/localStorage 读写适配
+│ ├── todoFormatters.ts # 日期和计时格式化
+│ └── todoConstants.ts # 默认配置、存储 key、常量
+├── package.json # npm 脚本和依赖
+├── vite.config.js # Vite 配置
+└── tsconfig.json # TypeScript 配置
+```
+
+## 验证
+
+常规检查:
+
+```bash
+npm run build
+```
+
+如果改了 preload:
+
+```bash
+node --check public/preload/services.js
+```
+
+## Git
+
+- 提交信息保持简短明确,参考现有提交风格。
+- 不要回退用户或其他 agent 的未提交改动。
diff --git a/plugins/todo-neo/README.md b/plugins/todo-neo/README.md
new file mode 100644
index 00000000..b4e4537c
--- /dev/null
+++ b/plugins/todo-neo/README.md
@@ -0,0 +1,77 @@
+# Todo 待办
+
+一个 ztools 待办插件,用来记录和整理日常任务。
+
+它的主要操作方式偏键盘:不用频繁切鼠标,`j/k` 选任务,`h/l` 切分组,`Enter` 编辑,`Tab` 新建,`/` 搜索。熟悉 Vim 的话基本不用重新记一套操作。
+
+## 使用
+
+打开插件后,左侧是待办分组,右侧是当前分组下的任务列表。
+
+常用操作:
+
+- 点击左侧分组切换列表
+- 点击任务选中
+- 双击任务进入编辑
+- 勾选任务表示完成
+- 右键任务可以打开详情或删除
+- 右键分组可以编辑或删除
+- 拖拽任务可以调整顺序
+- 拖拽任务到左侧分组可以移动分组
+- 点击左下角“添加分组”创建新分组
+- 点击右上角加号在当前任务下方新建任务
+
+## 快捷键
+
+快捷键默认启用,核心键位和 Vim 保持一致。列表操作时可以把它当成一个轻量的 Vim buffer:上下移动、左右切组、搜索、跳顶/跳底都可以直接用键盘完成。
+
+| 按键 | 作用 |
+| --- | --- |
+| `j` / `↓` | 下一个任务 |
+| `k` / `↑` | 上一个任务 |
+| `h` / `←` | 上一个分组 |
+| `l` / `→` | 下一个分组 |
+| `Space` | 完成/取消完成 |
+| `Enter` / `i` | 编辑任务 |
+| `Tab` / `Ctrl + N` | 新建任务 |
+| `/` | 搜索待办 |
+| `dd` / `Delete` | 删除任务 |
+| `gg` | 跳到顶部 |
+| `G` | 跳到底部 |
+| `?` | 打开设置 |
+| `Esc` | 关闭面板或取消编辑 |
+
+## 搜索
+
+按 `/` 打开搜索框。输入内容后会在所有分组的待办中搜索,可以用上下键选择结果,按 `Enter` 跳转到对应分组和任务。这个入口适合待办很多时快速定位,比先切分组再找要快很多。
+
+## Markdown
+
+任务内容可以按 Markdown 写。打开 Markdown 渲染后,非编辑状态会显示渲染结果,适合记录链接、列表、代码片段或稍长一点的任务说明。
+
+## 设置
+
+设置面板里可以调整:
+
+- 是否隐藏已完成任务
+- 已完成任务是否置于底部
+- 是否启用 Markdown 渲染
+
+## 开发
+
+```bash
+npm install
+npm run dev
+```
+
+开发地址默认是:
+
+```text
+http://localhost:5173
+```
+
+构建:
+
+```bash
+npm run build
+```
diff --git a/plugins/todo-neo/index.html b/plugins/todo-neo/index.html
new file mode 100644
index 00000000..eaa17316
--- /dev/null
+++ b/plugins/todo-neo/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/todo-neo/package-lock.json b/plugins/todo-neo/package-lock.json
new file mode 100644
index 00000000..8be39e15
--- /dev/null
+++ b/plugins/todo-neo/package-lock.json
@@ -0,0 +1,1211 @@
+{
+ "name": "todo",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "todo",
+ "version": "1.0.0",
+ "dependencies": {
+ "@types/markdown-it": "^14.1.2",
+ "markdown-it": "^14.1.1",
+ "vue": "^3.5.34"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^6.0.7",
+ "@ztools-center/ztools-api-types": "^1.0.3",
+ "typescript": "^6.0.3",
+ "vite": "^8.0.13",
+ "vue-tsc": "^3.2.9"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.3",
+ "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.3.tgz",
+ "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.10.0.tgz",
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.10.0.tgz",
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
+ "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.130.0",
+ "resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.130.0.tgz",
+ "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
+ "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
+ "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
+ "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
+ "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
+ "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
+ "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
+ "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
+ "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
+ "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
+ "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
+ "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
+ "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
+ "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.10.0",
+ "@emnapi/runtime": "1.10.0",
+ "@napi-rs/wasm-runtime": "^1.1.4"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
+ "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
+ "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.2",
+ "resolved": "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
+ "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/linkify-it": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmmirror.com/@types/linkify-it/-/linkify-it-5.0.0.tgz",
+ "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="
+ },
+ "node_modules/@types/markdown-it": {
+ "version": "14.1.2",
+ "resolved": "https://registry.npmmirror.com/@types/markdown-it/-/markdown-it-14.1.2.tgz",
+ "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
+ "dependencies": {
+ "@types/linkify-it": "^5",
+ "@types/mdurl": "^2"
+ }
+ },
+ "node_modules/@types/mdurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/@types/mdurl/-/mdurl-2.0.0.tgz",
+ "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="
+ },
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "6.0.7",
+ "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
+ "integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==",
+ "dev": true,
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
+ "vue": "^3.2.25"
+ }
+ },
+ "node_modules/@volar/language-core": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.28.tgz",
+ "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==",
+ "dev": true,
+ "dependencies": {
+ "@volar/source-map": "2.4.28"
+ }
+ },
+ "node_modules/@volar/source-map": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.28.tgz",
+ "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==",
+ "dev": true
+ },
+ "node_modules/@volar/typescript": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.28.tgz",
+ "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==",
+ "dev": true,
+ "dependencies": {
+ "@volar/language-core": "2.4.28",
+ "path-browserify": "^1.0.1",
+ "vscode-uri": "^3.0.8"
+ }
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.34.tgz",
+ "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==",
+ "dependencies": {
+ "@babel/parser": "^7.29.3",
+ "@vue/shared": "3.5.34",
+ "entities": "^7.0.1",
+ "estree-walker": "^2.0.2",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz",
+ "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==",
+ "dependencies": {
+ "@vue/compiler-core": "3.5.34",
+ "@vue/shared": "3.5.34"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz",
+ "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==",
+ "dependencies": {
+ "@babel/parser": "^7.29.3",
+ "@vue/compiler-core": "3.5.34",
+ "@vue/compiler-dom": "3.5.34",
+ "@vue/compiler-ssr": "3.5.34",
+ "@vue/shared": "3.5.34",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.30.21",
+ "postcss": "^8.5.14",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz",
+ "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.34",
+ "@vue/shared": "3.5.34"
+ }
+ },
+ "node_modules/@vue/language-core": {
+ "version": "3.2.9",
+ "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.2.9.tgz",
+ "integrity": "sha512-ie0ojt/0fU/GfIogh+zgHbaYRPlt9S+cLOxcWwF7nTSFh897BVgnFKL2byT4kpp1mlqYWZ2psGwSniyE2xsxYw==",
+ "dev": true,
+ "dependencies": {
+ "@volar/language-core": "2.4.28",
+ "@vue/compiler-dom": "^3.5.0",
+ "@vue/shared": "^3.5.0",
+ "alien-signals": "^3.2.0",
+ "muggle-string": "^0.4.1",
+ "path-browserify": "^1.0.1",
+ "picomatch": "^4.0.4"
+ }
+ },
+ "node_modules/@vue/reactivity": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.34.tgz",
+ "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==",
+ "dependencies": {
+ "@vue/shared": "3.5.34"
+ }
+ },
+ "node_modules/@vue/runtime-core": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.34.tgz",
+ "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==",
+ "dependencies": {
+ "@vue/reactivity": "3.5.34",
+ "@vue/shared": "3.5.34"
+ }
+ },
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz",
+ "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==",
+ "dependencies": {
+ "@vue/reactivity": "3.5.34",
+ "@vue/runtime-core": "3.5.34",
+ "@vue/shared": "3.5.34",
+ "csstype": "^3.2.3"
+ }
+ },
+ "node_modules/@vue/server-renderer": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.34.tgz",
+ "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==",
+ "dependencies": {
+ "@vue/compiler-ssr": "3.5.34",
+ "@vue/shared": "3.5.34"
+ },
+ "peerDependencies": {
+ "vue": "3.5.34"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.34.tgz",
+ "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA=="
+ },
+ "node_modules/@ztools-center/ztools-api-types": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/@ztools-center/ztools-api-types/-/ztools-api-types-1.0.3.tgz",
+ "integrity": "sha512-dv1eOAIasAupqKaQL/gESk1i2+RebdM/1gvZhrvH2D/bo3enCUsAGJ8nrHnlCLBSOGB81eC/SU0IH8BNsUlmvA==",
+ "dev": true
+ },
+ "node_modules/alien-signals": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-3.2.1.tgz",
+ "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==",
+ "dev": true
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/linkify-it": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.0.tgz",
+ "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
+ "dependencies": {
+ "uc.micro": "^2.0.0"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/markdown-it": {
+ "version": "14.1.1",
+ "resolved": "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.1.1.tgz",
+ "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
+ "dependencies": {
+ "argparse": "^2.0.1",
+ "entities": "^4.4.0",
+ "linkify-it": "^5.0.0",
+ "mdurl": "^2.0.0",
+ "punycode.js": "^2.3.1",
+ "uc.micro": "^2.1.0"
+ },
+ "bin": {
+ "markdown-it": "bin/markdown-it.mjs"
+ }
+ },
+ "node_modules/markdown-it/node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/mdurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmmirror.com/mdurl/-/mdurl-2.0.0.tgz",
+ "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="
+ },
+ "node_modules/muggle-string": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz",
+ "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
+ "dev": true
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/path-browserify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
+ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+ "dev": true
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.14",
+ "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.14.tgz",
+ "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/punycode.js": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz",
+ "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.1.tgz",
+ "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
+ "dev": true,
+ "dependencies": {
+ "@oxc-project/types": "=0.130.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.0.1",
+ "@rolldown/binding-darwin-arm64": "1.0.1",
+ "@rolldown/binding-darwin-x64": "1.0.1",
+ "@rolldown/binding-freebsd-x64": "1.0.1",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.1",
+ "@rolldown/binding-linux-arm64-musl": "1.0.1",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.1",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.1",
+ "@rolldown/binding-linux-x64-gnu": "1.0.1",
+ "@rolldown/binding-linux-x64-musl": "1.0.1",
+ "@rolldown/binding-openharmony-arm64": "1.0.1",
+ "@rolldown/binding-wasm32-wasi": "1.0.1",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.1",
+ "@rolldown/binding-win32-x64-msvc": "1.0.1"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "dev": true,
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "optional": true
+ },
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmmirror.com/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "devOptional": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/uc.micro": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz",
+ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="
+ },
+ "node_modules/vite": {
+ "version": "8.0.13",
+ "resolved": "https://registry.npmmirror.com/vite/-/vite-8.0.13.tgz",
+ "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
+ "dev": true,
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.4",
+ "postcss": "^8.5.14",
+ "rolldown": "1.0.1",
+ "tinyglobby": "^0.2.16"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.1.18",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vscode-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz",
+ "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
+ "dev": true
+ },
+ "node_modules/vue": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.34.tgz",
+ "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.34",
+ "@vue/compiler-sfc": "3.5.34",
+ "@vue/runtime-dom": "3.5.34",
+ "@vue/server-renderer": "3.5.34",
+ "@vue/shared": "3.5.34"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue-tsc": {
+ "version": "3.2.9",
+ "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.2.9.tgz",
+ "integrity": "sha512-qm8/nbo+9eZc1SCndm9wT+gq23pM+wRIdHY0wjm83B3lIginHTwcdrLUyTrKjDWXbMVNjKegNrnymhpdqnCL3A==",
+ "dev": true,
+ "dependencies": {
+ "@volar/typescript": "2.4.28",
+ "@vue/language-core": "3.2.9"
+ },
+ "bin": {
+ "vue-tsc": "bin/vue-tsc.js"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.0.0"
+ }
+ }
+ }
+}
diff --git a/plugins/todo-neo/package.json b/plugins/todo-neo/package.json
new file mode 100644
index 00000000..7ffcb37d
--- /dev/null
+++ b/plugins/todo-neo/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "todo",
+ "version": "1.0.0",
+ "description": "快速记录和查看待办事项,支持 vim 键位操作",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vue-tsc && vite build"
+ },
+ "dependencies": {
+ "@types/markdown-it": "^14.1.2",
+ "markdown-it": "^14.1.1",
+ "vue": "^3.5.34"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^6.0.7",
+ "@ztools-center/ztools-api-types": "^1.0.3",
+ "typescript": "^6.0.3",
+ "vite": "^8.0.13",
+ "vue-tsc": "^3.2.9"
+ }
+}
diff --git a/plugins/todo-neo/public/logo.png b/plugins/todo-neo/public/logo.png
new file mode 100644
index 00000000..0af87703
Binary files /dev/null and b/plugins/todo-neo/public/logo.png differ
diff --git a/plugins/todo-neo/public/plugin.json b/plugins/todo-neo/public/plugin.json
new file mode 100644
index 00000000..247424cd
--- /dev/null
+++ b/plugins/todo-neo/public/plugin.json
@@ -0,0 +1,244 @@
+{
+ "$schema": "../node_modules/@ztools-center/ztools-api-types/resource/ztools.schema.json",
+ "name": "todo-neo",
+ "title": "Todo",
+ "pluginName": "Todo",
+ "description": "Vim-like 的 todo 工具",
+ "author": "thetree",
+ "version": "0.0.1",
+ "ai": true,
+ "main": "../index.html",
+ "preload": "preload/services.js",
+ "logo": "logo.png",
+ "development": {
+ "main": "http://localhost:5173"
+ },
+ "features": [
+ {
+ "code": "todo",
+ "explain": "创建/查看/调整所有待办事项",
+ "icon": "logo.png",
+ "cmds": [
+ "ToDo 待办"
+ ]
+ },
+ {
+ "code": "add",
+ "explain": "添加到待办",
+ "icon": "logo.png",
+ "cmds": [
+ {
+ "type": "over",
+ "label": "添加到待办"
+ }
+ ]
+ }
+ ],
+ "tools": {
+ "todo_group_list": {
+ "title": "列出分组",
+ "description": "列出所有待办分组",
+ "inputSchema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {}
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "groups": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "分组名称"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "todo_search": {
+ "title": "搜索待办",
+ "description": "搜索待办事项,支持关键词、分组、状态、结束日期筛选",
+ "inputSchema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "关键词搜索(可选)"
+ },
+ "group": {
+ "type": "string",
+ "description": "分组名称筛选(可选)"
+ },
+ "status": {
+ "type": "string",
+ "description": "按状态筛选:done=已完成,pending=未完成(可选)"
+ },
+ "dueAt": {
+ "type": "string",
+ "description": "结束日期筛选,格式:2025-12-31(可选)"
+ }
+ }
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "tasks": {
+ "type": "array",
+ "description": "搜索到的待办事项列表",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "text": {
+ "type": "string"
+ },
+ "group": {
+ "type": "string"
+ },
+ "completed": {
+ "type": "boolean"
+ },
+ "dueAt": {
+ "type": "string"
+ },
+ "completed_at": {
+ "type": "string"
+ },
+ "created_at": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "todo_create": {
+ "title": "创建待办",
+ "description": "创建一个新的待办事项",
+ "inputSchema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "content": {
+ "type": "string",
+ "description": "待办事项的内容(必填)"
+ },
+ "group": {
+ "type": "string",
+ "description": "分组名称,不存在则自动创建(可选)"
+ },
+ "dueAt": {
+ "type": "string",
+ "description": "结束日期,格式:2025-12-31(可选)"
+ }
+ },
+ "required": [
+ "content"
+ ]
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "创建的待办事项ID"
+ },
+ "text": {
+ "type": "string",
+ "description": "待办事项的内容"
+ },
+ "group": {
+ "type": "string",
+ "description": "分组名称"
+ },
+ "dueAt": {
+ "type": "string",
+ "description": "结束日期"
+ },
+ "created_at": {
+ "type": "string",
+ "description": "创建时间"
+ }
+ }
+ }
+ },
+ "todo_update": {
+ "title": "更新待办",
+ "description": "根据 patch 字段更新待办事项",
+ "inputSchema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "待办事项的ID(必填)"
+ },
+ "patch": {
+ "type": "object",
+ "description": "要更新的字段",
+ "properties": {
+ "content": {
+ "type": "string",
+ "description": "更新待办内容"
+ },
+ "status": {
+ "type": "string",
+ "description": "更新状态:done=已完成,pending=未完成"
+ },
+ "dueAt": {
+ "type": "string",
+ "description": "更新结束日期,格式:2024-12-31"
+ },
+ "group": {
+ "type": "string",
+ "description": "更新分组名称,不存在则自动创建"
+ }
+ }
+ }
+ },
+ "required": [
+ "id",
+ "patch"
+ ]
+ },
+ "outputSchema": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "text": {
+ "type": "string"
+ },
+ "group": {
+ "type": "string"
+ },
+ "completed": {
+ "type": "boolean"
+ },
+ "dueAt": {
+ "type": "string"
+ },
+ "updated": {
+ "type": "boolean"
+ }
+ }
+ }
+ }
+ },
+ "platform": [
+ "darwin",
+ "win32",
+ "linux"
+ ]
+}
diff --git a/plugins/todo-neo/public/preload/package.json b/plugins/todo-neo/public/preload/package.json
new file mode 100644
index 00000000..5bbefffb
--- /dev/null
+++ b/plugins/todo-neo/public/preload/package.json
@@ -0,0 +1,3 @@
+{
+ "type": "commonjs"
+}
diff --git a/plugins/todo-neo/public/preload/services.js b/plugins/todo-neo/public/preload/services.js
new file mode 100644
index 00000000..ea124c3e
--- /dev/null
+++ b/plugins/todo-neo/public/preload/services.js
@@ -0,0 +1,299 @@
+const TASKS_PREFIX = 'todo-tasks/'
+const GROUPS_PREFIX = 'todo-group/'
+const NEXT_TASK_SORT_KEY = 'mcp-next-task-sort'
+const NEXT_GROUP_SORT_KEY = 'mcp-next-group-sort'
+
+const FEATURE_FLAGS = {
+ noteWindow: false,
+ tomatoWindow: false
+}
+
+const ztools = window.ztools
+const windows = new Map()
+let ipcRenderer
+
+try {
+ ;({ ipcRenderer } = require('electron'))
+} catch {
+ ipcRenderer = null
+}
+
+function getDbDocValue(doc) {
+ return doc && (doc.value || doc)
+}
+
+function getAllTasks() {
+ const docs = ztools?.db?.allDocs?.(TASKS_PREFIX) || []
+ return docs
+ .filter((doc) => doc._id?.startsWith(TASKS_PREFIX) && !doc.$deprecated)
+ .map((doc) => ({ _id: doc._id, ...getDbDocValue(doc) }))
+}
+
+function getAllGroups() {
+ const docs = ztools?.db?.allDocs?.(GROUPS_PREFIX) || []
+ return docs
+ .filter((doc) => doc._id?.startsWith(GROUPS_PREFIX) && !doc.$deprecated)
+ .map((doc) => ({ _id: doc._id, ...getDbDocValue(doc) }))
+ .sort((a, b) => (a.sort || 0) - (b.sort || 0))
+}
+
+function getTaskById(id) {
+ const fullId = id.startsWith(TASKS_PREFIX) ? id : `${TASKS_PREFIX}${id}`
+ const doc = ztools?.db?.get?.(fullId)
+ if (!doc || doc.$deprecated) return null
+ return { _id: doc._id, ...getDbDocValue(doc) }
+}
+
+function putDoc(id, value) {
+ ztools?.dbStorage?.setItem?.(id, value)
+ return ztools?.db?.put?.({ _id: id, value })
+}
+
+function nextSort(key) {
+ const current = ztools?.dbStorage?.getItem?.(key) || 0
+ const next = Number(current) + 1
+ ztools?.dbStorage?.setItem?.(key, next)
+ return next
+}
+
+function getGroupNameById(id) {
+ return getAllGroups().find((group) => group._id === id)?.title || ''
+}
+
+function getGroupIdByName(name) {
+ return getAllGroups().find((group) => group.title === name)?._id || null
+}
+
+function getOrCreateGroup(name) {
+ const existing = getGroupIdByName(name)
+ if (existing) return existing
+ const now = Date.now()
+ const id = `${GROUPS_PREFIX}${now}`
+ putDoc(id, {
+ title: name,
+ sort: nextSort(NEXT_GROUP_SORT_KEY),
+ created_at: now
+ })
+ return id
+}
+
+function formatDateTime(timestamp) {
+ if (!timestamp) return undefined
+ const date = new Date(timestamp)
+ const pad = (value) => String(value).padStart(2, '0')
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
+}
+
+function parseDueAt(value) {
+ if (!value) return undefined
+ const [year, month, day] = String(value).split('-').map(Number)
+ if (!year || !month || !day) return undefined
+ const date = new Date(year, month - 1, day, 23, 59, 59, 999)
+ if (Number.isNaN(date.getTime())) return undefined
+ return date.getTime()
+}
+
+async function todoGroupList() {
+ return {
+ groups: getAllGroups().map((group) => ({ name: group.title }))
+ }
+}
+
+async function todoSearch({ query, group, status, dueAt } = {}) {
+ let tasks = getAllTasks()
+ if (query) {
+ const keyword = query.toLowerCase()
+ tasks = tasks.filter((task) => task.text?.toLowerCase().includes(keyword))
+ }
+ if (group) {
+ const groupId = getGroupIdByName(group)
+ tasks = groupId ? tasks.filter((task) => task.groupId === groupId) : []
+ }
+ if (status === 'done') tasks = tasks.filter((task) => task.completed)
+ if (status === 'pending') tasks = tasks.filter((task) => !task.completed)
+ if (dueAt) {
+ const due = parseDueAt(dueAt)
+ tasks = tasks.filter((task) => task.dueAt && task.dueAt <= due)
+ }
+
+ return {
+ tasks: tasks
+ .sort((a, b) => (a.sort || 0) - (b.sort || 0))
+ .map((task) => ({
+ id: task._id,
+ text: task.text,
+ group: getGroupNameById(task.groupId),
+ completed: Boolean(task.completed),
+ dueAt: formatDateTime(task.dueAt),
+ completed_at: formatDateTime(task.completed_at),
+ created_at: formatDateTime(task.created_at)
+ }))
+ }
+}
+
+async function todoCreate({ content, dueAt, group }) {
+ const now = Date.now()
+ const id = `${TASKS_PREFIX}${now}`
+ const groupId = group ? getOrCreateGroup(group) : `${GROUPS_PREFIX}pending`
+ const task = {
+ text: content,
+ groupId,
+ completed: false,
+ created_at: now,
+ sort: nextSort(NEXT_TASK_SORT_KEY),
+ dueAt: parseDueAt(dueAt)
+ }
+ putDoc(id, task)
+ return {
+ id,
+ text: task.text,
+ group: group || '待处理',
+ dueAt: formatDateTime(task.dueAt),
+ created_at: formatDateTime(task.created_at)
+ }
+}
+
+async function todoUpdate({ id, patch }) {
+ const task = getTaskById(id)
+ if (!task) throw new Error(`待办事项不存在: ${id}`)
+ const updated = { ...task }
+ delete updated._id
+
+ if (patch.content !== undefined) updated.text = patch.content
+ if (patch.status === 'done') {
+ updated.completed = true
+ updated.completed_at = Date.now()
+ if (!updated.first_completed_at) updated.first_completed_at = updated.completed_at
+ }
+ if (patch.status === 'pending') {
+ updated.completed = false
+ delete updated.completed_at
+ }
+ if (patch.dueAt !== undefined) {
+ const due = parseDueAt(patch.dueAt)
+ if (due) updated.dueAt = due
+ else delete updated.dueAt
+ }
+ if (patch.group !== undefined) updated.groupId = getOrCreateGroup(patch.group)
+
+ putDoc(task._id, updated)
+ return {
+ id: task._id,
+ text: updated.text,
+ group: getGroupNameById(updated.groupId),
+ completed: Boolean(updated.completed),
+ dueAt: formatDateTime(updated.dueAt),
+ updated: true
+ }
+}
+
+function appUrl(hash) {
+ if (ztools?.isDev?.()) return `http://localhost:5173/#/${hash}`
+ return `index.html#/${hash}`
+}
+
+function isBrowserWindow() {
+ return ztools?.getWindowType?.() === 'browser'
+}
+
+function sendWindowRequest(channel, payload) {
+ if (!isBrowserWindow()) return false
+ ztools?.sendToParent?.(channel, payload)
+ return true
+}
+
+function openWindow(key, hash, options) {
+ const existing = windows.get(key)
+ if (existing && !existing.isDestroyed?.()) {
+ existing.show?.()
+ existing.moveTop?.()
+ return existing
+ }
+ const win = ztools?.createBrowserWindow?.(appUrl(hash), options)
+ if (win) {
+ windows.set(key, win)
+ win.setAlwaysOnTop?.(true)
+ }
+ return win
+}
+
+function openNote(params = {}) {
+ if (!FEATURE_FLAGS.noteWindow) return null
+ if (sendWindowRequest('todo-open-note', params)) return
+ const search = new URLSearchParams()
+ if (params.group) search.set('group', params.group)
+ if (params.status) search.set('status', params.status)
+ const groupKey = params.group || '待处理'
+ return openWindow(`note:${groupKey}:${params.status || ''}`, `note?${search.toString()}`, {
+ width: 330,
+ height: 460,
+ minWidth: 260,
+ minHeight: 260,
+ frame: false,
+ transparent: true,
+ backgroundColor: '#00000000',
+ resizable: true,
+ webPreferences: { preload: 'preload/services.js' }
+ })
+}
+
+function openTomato(taskId) {
+ if (!FEATURE_FLAGS.tomatoWindow) return null
+ if (sendWindowRequest('todo-open-tomato', taskId)) return
+ const search = new URLSearchParams()
+ if (taskId) search.set('taskId', taskId)
+ return openWindow(`tomato:${taskId || 'empty'}`, `tomato?${search.toString()}`, {
+ width: 360,
+ height: 440,
+ minWidth: 280,
+ minHeight: 340,
+ frame: false,
+ transparent: true,
+ backgroundColor: '#00000000',
+ resizable: true,
+ webPreferences: { preload: 'preload/services.js' }
+ })
+}
+
+async function todoPinToScreen(args = {}) {
+ if (!FEATURE_FLAGS.noteWindow) return { pinned: false }
+ const filter = args.filter || {}
+ const names = Array.isArray(filter.group) && filter.group.length ? filter.group : ['待处理']
+ names.forEach((group) => openNote({ group, status: filter.status }))
+ return { pinned: true }
+}
+
+const handlers = {
+ todo_group_list: todoGroupList,
+ todo_search: todoSearch,
+ todo_create: todoCreate,
+ todo_update: todoUpdate,
+ ...(FEATURE_FLAGS.noteWindow ? { todo_pin_to_screen: todoPinToScreen } : {})
+}
+
+function registerTools() {
+ const registerTool = ztools?.registerTool
+ if (!registerTool || window.__todoToolsRegistered) return
+ Object.entries(handlers).forEach(([name, handler]) => registerTool.call(ztools, name, handler))
+ window.__todoToolsRegistered = true
+}
+
+function registerWindowRequestForwarding() {
+ if (!ipcRenderer || window.__todoWindowRequestsRegistered) return
+ if (FEATURE_FLAGS.noteWindow) ipcRenderer.on('todo-open-note', (_event, params) => openNote(params || {}))
+ if (FEATURE_FLAGS.tomatoWindow) ipcRenderer.on('todo-open-tomato', (_event, taskId) => openTomato(taskId))
+ window.__todoWindowRequestsRegistered = true
+}
+
+registerTools()
+registerWindowRequestForwarding()
+
+window.services = {
+ featureFlags: FEATURE_FLAGS,
+ openNote,
+ openTomato,
+ pinToScreen: todoPinToScreen,
+ closeWindow() {
+ window.close()
+ }
+}
diff --git a/plugins/todo-neo/src/App.vue b/plugins/todo-neo/src/App.vue
new file mode 100644
index 00000000..e5ba4f48
--- /dev/null
+++ b/plugins/todo-neo/src/App.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/plugins/todo-neo/src/assets/icons/alarm-clock.svg b/plugins/todo-neo/src/assets/icons/alarm-clock.svg
new file mode 100644
index 00000000..3d78b790
--- /dev/null
+++ b/plugins/todo-neo/src/assets/icons/alarm-clock.svg
@@ -0,0 +1,20 @@
+
+
diff --git a/plugins/todo-neo/src/assets/icons/circle-plus.svg b/plugins/todo-neo/src/assets/icons/circle-plus.svg
new file mode 100644
index 00000000..1ef02185
--- /dev/null
+++ b/plugins/todo-neo/src/assets/icons/circle-plus.svg
@@ -0,0 +1,17 @@
+
+
diff --git a/plugins/todo-neo/src/assets/icons/info.svg b/plugins/todo-neo/src/assets/icons/info.svg
new file mode 100644
index 00000000..51c226e8
--- /dev/null
+++ b/plugins/todo-neo/src/assets/icons/info.svg
@@ -0,0 +1,17 @@
+
+
diff --git a/plugins/todo-neo/src/assets/icons/list-plus.svg b/plugins/todo-neo/src/assets/icons/list-plus.svg
new file mode 100644
index 00000000..77eb7c98
--- /dev/null
+++ b/plugins/todo-neo/src/assets/icons/list-plus.svg
@@ -0,0 +1,19 @@
+
+
diff --git a/plugins/todo-neo/src/assets/icons/list-todo.svg b/plugins/todo-neo/src/assets/icons/list-todo.svg
new file mode 100644
index 00000000..bb0517de
--- /dev/null
+++ b/plugins/todo-neo/src/assets/icons/list-todo.svg
@@ -0,0 +1,19 @@
+
+
diff --git a/plugins/todo-neo/src/assets/icons/minus.svg b/plugins/todo-neo/src/assets/icons/minus.svg
new file mode 100644
index 00000000..0e6d810b
--- /dev/null
+++ b/plugins/todo-neo/src/assets/icons/minus.svg
@@ -0,0 +1,15 @@
+
+
diff --git a/plugins/todo-neo/src/assets/icons/pause.svg b/plugins/todo-neo/src/assets/icons/pause.svg
new file mode 100644
index 00000000..8a4aa65e
--- /dev/null
+++ b/plugins/todo-neo/src/assets/icons/pause.svg
@@ -0,0 +1,16 @@
+
+
diff --git a/plugins/todo-neo/src/assets/icons/pencil.svg b/plugins/todo-neo/src/assets/icons/pencil.svg
new file mode 100644
index 00000000..76184dd4
--- /dev/null
+++ b/plugins/todo-neo/src/assets/icons/pencil.svg
@@ -0,0 +1,16 @@
+
+
diff --git a/plugins/todo-neo/src/assets/icons/pin.svg b/plugins/todo-neo/src/assets/icons/pin.svg
new file mode 100644
index 00000000..d44cd0f8
--- /dev/null
+++ b/plugins/todo-neo/src/assets/icons/pin.svg
@@ -0,0 +1,16 @@
+
+
diff --git a/plugins/todo-neo/src/assets/icons/play.svg b/plugins/todo-neo/src/assets/icons/play.svg
new file mode 100644
index 00000000..bff9e7d4
--- /dev/null
+++ b/plugins/todo-neo/src/assets/icons/play.svg
@@ -0,0 +1,15 @@
+
+
diff --git a/plugins/todo-neo/src/assets/icons/plus.svg b/plugins/todo-neo/src/assets/icons/plus.svg
new file mode 100644
index 00000000..a982c5a5
--- /dev/null
+++ b/plugins/todo-neo/src/assets/icons/plus.svg
@@ -0,0 +1,16 @@
+
+
diff --git a/plugins/todo-neo/src/assets/icons/rotate-ccw.svg b/plugins/todo-neo/src/assets/icons/rotate-ccw.svg
new file mode 100644
index 00000000..712715e9
--- /dev/null
+++ b/plugins/todo-neo/src/assets/icons/rotate-ccw.svg
@@ -0,0 +1,16 @@
+
+
diff --git a/plugins/todo-neo/src/assets/icons/search.svg b/plugins/todo-neo/src/assets/icons/search.svg
new file mode 100644
index 00000000..21e65650
--- /dev/null
+++ b/plugins/todo-neo/src/assets/icons/search.svg
@@ -0,0 +1,16 @@
+
+
diff --git a/plugins/todo-neo/src/assets/icons/settings.svg b/plugins/todo-neo/src/assets/icons/settings.svg
new file mode 100644
index 00000000..b2b5444b
--- /dev/null
+++ b/plugins/todo-neo/src/assets/icons/settings.svg
@@ -0,0 +1,16 @@
+
+
diff --git a/plugins/todo-neo/src/assets/icons/timer.svg b/plugins/todo-neo/src/assets/icons/timer.svg
new file mode 100644
index 00000000..6bf8d60f
--- /dev/null
+++ b/plugins/todo-neo/src/assets/icons/timer.svg
@@ -0,0 +1,17 @@
+
+
diff --git a/plugins/todo-neo/src/assets/icons/trash-2.svg b/plugins/todo-neo/src/assets/icons/trash-2.svg
new file mode 100644
index 00000000..a61407e6
--- /dev/null
+++ b/plugins/todo-neo/src/assets/icons/trash-2.svg
@@ -0,0 +1,19 @@
+
+
diff --git a/plugins/todo-neo/src/assets/icons/watch.svg b/plugins/todo-neo/src/assets/icons/watch.svg
new file mode 100644
index 00000000..e3562386
--- /dev/null
+++ b/plugins/todo-neo/src/assets/icons/watch.svg
@@ -0,0 +1,18 @@
+
+
diff --git a/plugins/todo-neo/src/assets/icons/x.svg b/plugins/todo-neo/src/assets/icons/x.svg
new file mode 100644
index 00000000..83fb83be
--- /dev/null
+++ b/plugins/todo-neo/src/assets/icons/x.svg
@@ -0,0 +1,16 @@
+
+
diff --git a/plugins/todo-neo/src/components/BaseDrawer.vue b/plugins/todo-neo/src/components/BaseDrawer.vue
new file mode 100644
index 00000000..12ac1d10
--- /dev/null
+++ b/plugins/todo-neo/src/components/BaseDrawer.vue
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
diff --git a/plugins/todo-neo/src/components/ConfirmDialog.vue b/plugins/todo-neo/src/components/ConfirmDialog.vue
new file mode 100644
index 00000000..367f8df1
--- /dev/null
+++ b/plugins/todo-neo/src/components/ConfirmDialog.vue
@@ -0,0 +1,129 @@
+
+
+
+
+
+
+
diff --git a/plugins/todo-neo/src/components/GlobalContextMenu.vue b/plugins/todo-neo/src/components/GlobalContextMenu.vue
new file mode 100644
index 00000000..5b741a95
--- /dev/null
+++ b/plugins/todo-neo/src/components/GlobalContextMenu.vue
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
diff --git a/plugins/todo-neo/src/components/MainTodo.vue b/plugins/todo-neo/src/components/MainTodo.vue
new file mode 100644
index 00000000..5f83114e
--- /dev/null
+++ b/plugins/todo-neo/src/components/MainTodo.vue
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/todo-neo/src/components/NoteWindow.vue b/plugins/todo-neo/src/components/NoteWindow.vue
new file mode 100644
index 00000000..1565aff5
--- /dev/null
+++ b/plugins/todo-neo/src/components/NoteWindow.vue
@@ -0,0 +1,190 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/todo-neo/src/components/SettingsDrawer.vue b/plugins/todo-neo/src/components/SettingsDrawer.vue
new file mode 100644
index 00000000..77f5338e
--- /dev/null
+++ b/plugins/todo-neo/src/components/SettingsDrawer.vue
@@ -0,0 +1,240 @@
+
+
+
+
+
+
+
+
+ Vim 快捷键
+
+
+ - {{ label }}
+ - {{ key }}
+
+
+
+
+
+ 快速入口
+
+ 添加到待办
+ 选中文本 / 输入内容
+
+
+ 新建便签
+ 新建便签
+
+
+
+
+
+
+
+
+
diff --git a/plugins/todo-neo/src/components/SvgIcon.vue b/plugins/todo-neo/src/components/SvgIcon.vue
new file mode 100644
index 00000000..dd4da401
--- /dev/null
+++ b/plugins/todo-neo/src/components/SvgIcon.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
diff --git a/plugins/todo-neo/src/components/TaskDetailDrawer.vue b/plugins/todo-neo/src/components/TaskDetailDrawer.vue
new file mode 100644
index 00000000..0834a337
--- /dev/null
+++ b/plugins/todo-neo/src/components/TaskDetailDrawer.vue
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+ 内容
+ {{ store.detailTask.value.text }}
+
+
+
+ 记录
+
+ - 创建时间
- {{ store.formatDate(store.detailTask.value.created_at) }}
+ - 首次完成
- {{ store.formatDate(store.detailTask.value.first_completed_at) }}
+ - 本次完成
- {{ store.formatDate(store.detailTask.value.completed_at) }}
+
+
+
+
+
+
+
diff --git a/plugins/todo-neo/src/components/TaskList.vue b/plugins/todo-neo/src/components/TaskList.vue
new file mode 100644
index 00000000..fc836a99
--- /dev/null
+++ b/plugins/todo-neo/src/components/TaskList.vue
@@ -0,0 +1,388 @@
+
+
+
+
+
+
+
diff --git a/plugins/todo-neo/src/components/TaskSearchPicker.vue b/plugins/todo-neo/src/components/TaskSearchPicker.vue
new file mode 100644
index 00000000..f580e37d
--- /dev/null
+++ b/plugins/todo-neo/src/components/TaskSearchPicker.vue
@@ -0,0 +1,196 @@
+
+
+
+
+
+
+
+
+
+
+
无匹配
+
+
+
+
+
+
diff --git a/plugins/todo-neo/src/components/TodoSidebar.vue b/plugins/todo-neo/src/components/TodoSidebar.vue
new file mode 100644
index 00000000..2c883819
--- /dev/null
+++ b/plugins/todo-neo/src/components/TodoSidebar.vue
@@ -0,0 +1,277 @@
+
+
+
+
+
+
+
diff --git a/plugins/todo-neo/src/components/TomatoWindow.vue b/plugins/todo-neo/src/components/TomatoWindow.vue
new file mode 100644
index 00000000..2579c327
--- /dev/null
+++ b/plugins/todo-neo/src/components/TomatoWindow.vue
@@ -0,0 +1,275 @@
+
+
+
+
+
+
+
+
+ {{ store.formatTimer(store.tomatoRemaining.value) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/todo-neo/src/components/WorkspaceHeader.vue b/plugins/todo-neo/src/components/WorkspaceHeader.vue
new file mode 100644
index 00000000..a33330ae
--- /dev/null
+++ b/plugins/todo-neo/src/components/WorkspaceHeader.vue
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
diff --git a/plugins/todo-neo/src/composables/todoConstants.ts b/plugins/todo-neo/src/composables/todoConstants.ts
new file mode 100644
index 00000000..ec1e726d
--- /dev/null
+++ b/plugins/todo-neo/src/composables/todoConstants.ts
@@ -0,0 +1,36 @@
+import type { Settings } from '../types'
+
+export const TASKS_PREFIX = 'todo-tasks/'
+export const GROUPS_PREFIX = 'todo-group/'
+export const SETTINGS_KEY = 'todo-settings'
+export const ACTIVE_GROUP_KEY = 'todo-active-group'
+export const ACTIVE_TASK_KEY = 'todo-active-task'
+export const LOCAL_DOCS_KEY = 'todo-dev-docs'
+
+export const noteColors = [
+ { name: '柔和浅黄', background: '#FFFECF', text: '#000000' },
+ { name: '米白黄', background: '#F5F5DC', text: '#000000' },
+ { name: '浅灰白', background: '#F0F0F0', text: '#000000' },
+ { name: '珍珠白', background: '#FFFAF0', text: '#000000' },
+ { name: '浅薄荷绿', background: '#E8F5E9', text: '#000000' },
+ { name: '浅天蓝', background: '#E0F7FA', text: '#000000' }
+]
+
+export const defaultGroups = [
+ { _id: `${GROUPS_PREFIX}pending`, title: '待处理', sort: 1 },
+ { _id: `${GROUPS_PREFIX}doing`, title: '进行中', sort: 2 },
+ { _id: `${GROUPS_PREFIX}unnamed`, title: '未命名', sort: 3 }
+]
+
+export const defaultSettings: Settings = {
+ hideCompleted: false,
+ bottomCompleted: false,
+ renderMarkdown: false,
+ noteBlurTransparent: true,
+ noteOpacity: 0.6,
+ noteBackground: '#FFFECF',
+ tomatoSkin: 'tomato',
+ tomatoMinutes: 25,
+ tomatoScale: 1
+}
+
diff --git a/plugins/todo-neo/src/composables/todoFormatters.ts b/plugins/todo-neo/src/composables/todoFormatters.ts
new file mode 100644
index 00000000..24bd8739
--- /dev/null
+++ b/plugins/todo-neo/src/composables/todoFormatters.ts
@@ -0,0 +1,32 @@
+export function formatDateInput(timestamp?: number) {
+ if (!timestamp) return ''
+ const date = new Date(timestamp)
+ const year = date.getFullYear()
+ const month = String(date.getMonth() + 1).padStart(2, '0')
+ const day = String(date.getDate()).padStart(2, '0')
+ return `${year}-${month}-${day}`
+}
+
+export function parseDateInputEndOfDay(value: string) {
+ const [year, month, day] = value.split('-').map(Number)
+ if (!year || !month || !day) return undefined
+ return new Date(year, month - 1, day, 23, 59, 59, 999).getTime()
+}
+
+export function formatDate(timestamp?: number) {
+ if (!timestamp) return '-'
+ return new Date(timestamp).toLocaleString()
+}
+
+export function compactDate(timestamp?: number) {
+ if (!timestamp) return ''
+ const date = new Date(timestamp)
+ return `${date.getMonth() + 1}/${date.getDate()}`
+}
+
+export function formatTimer(seconds: number) {
+ const minute = Math.floor(seconds / 60)
+ const second = seconds % 60
+ return `${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}`
+}
+
diff --git a/plugins/todo-neo/src/composables/todoPersistence.ts b/plugins/todo-neo/src/composables/todoPersistence.ts
new file mode 100644
index 00000000..92f8e5c7
--- /dev/null
+++ b/plugins/todo-neo/src/composables/todoPersistence.ts
@@ -0,0 +1,89 @@
+import type { GroupDoc, TaskDoc } from '../types'
+import { LOCAL_DOCS_KEY } from './todoConstants'
+
+export function hasZtools() {
+ return typeof window !== 'undefined' && Boolean((window as any).ztools)
+}
+
+export function docValue(doc: any): T {
+ return (doc?.value || doc) as T
+}
+
+function localDocs(): Record {
+ try {
+ return JSON.parse(localStorage.getItem(LOCAL_DOCS_KEY) || '{}')
+ } catch {
+ return {}
+ }
+}
+
+function setLocalDocs(docs: Record) {
+ localStorage.setItem(LOCAL_DOCS_KEY, JSON.stringify(docs))
+}
+
+export function getStorage(key: string, fallback: T): T {
+ if (hasZtools()) return window.ztools.dbStorage.getItem(key) ?? fallback
+ try {
+ const value = localStorage.getItem(key)
+ return value ? (JSON.parse(value) as T) : fallback
+ } catch {
+ return fallback
+ }
+}
+
+export function setStorage(key: string, value: unknown) {
+ if (hasZtools()) window.ztools.dbStorage.setItem(key, value)
+ else localStorage.setItem(key, JSON.stringify(value))
+}
+
+export function allDocs(prefix: string): Array<{ _id: string; value: T }> {
+ if (hasZtools()) return window.ztools.db.allDocs<{ value: T }>(prefix) as Array<{ _id: string; value: T }>
+ return Object.entries(localDocs())
+ .filter(([id]) => id.startsWith(prefix))
+ .map(([id, value]) => ({ _id: id, value: value as T }))
+}
+
+export function putDoc(id: string, value: T) {
+ if (hasZtools()) {
+ window.ztools.dbStorage.setItem(id, value)
+ window.ztools.db.put({ _id: id, value })
+ return
+ }
+ const docs = localDocs()
+ docs[id] = value
+ setLocalDocs(docs)
+}
+
+export function removeDoc(id: string) {
+ if (hasZtools()) {
+ const doc = window.ztools.db.get(id)
+ if (doc) window.ztools.db.remove(doc)
+ window.ztools.dbStorage.removeItem(id)
+ return
+ }
+ const docs = localDocs()
+ delete docs[id]
+ setLocalDocs(docs)
+}
+
+export function taskPayload(task: TaskDoc): Omit {
+ return {
+ text: task.text,
+ groupId: task.groupId,
+ completed: task.completed,
+ completed_at: task.completed_at,
+ first_completed_at: task.first_completed_at,
+ created_at: task.created_at,
+ sort: task.sort,
+ dueAt: task.dueAt
+ }
+}
+
+export function groupPayload(group: GroupDoc): Omit {
+ return {
+ title: group.title,
+ sort: group.sort,
+ created_at: group.created_at
+ }
+}
+
diff --git a/plugins/todo-neo/src/composables/useTaskSearch.ts b/plugins/todo-neo/src/composables/useTaskSearch.ts
new file mode 100644
index 00000000..84e23208
--- /dev/null
+++ b/plugins/todo-neo/src/composables/useTaskSearch.ts
@@ -0,0 +1,104 @@
+import { computed, nextTick, ref, type Ref } from 'vue'
+import type { GroupDoc, TaskDoc } from '../types'
+
+export interface TaskSearchResult {
+ task: TaskDoc
+ group: GroupDoc
+ score: number
+}
+
+interface TaskSearchOptions {
+ tasks: Ref
+ groupById: (id: string) => GroupDoc | undefined
+ selectGroup: (id: string) => void
+ selectTask: (id: string) => void
+ closeContextMenu: () => void
+}
+
+export function useTaskSearch(options: TaskSearchOptions) {
+ const taskSearchOpen = ref(false)
+ const taskSearchQuery = ref('')
+ const taskSearchIndex = ref(0)
+
+ const taskSearchResults = computed(() => {
+ const query = taskSearchQuery.value.trim().toLowerCase()
+ const tokens = query.split(/\s+/).filter(Boolean)
+
+ return options.tasks.value
+ .map((task) => {
+ const group = options.groupById(task.groupId)
+ if (!group) return null
+
+ const taskText = task.text.toLowerCase()
+ const groupTitle = group.title.toLowerCase()
+ const haystack = `${taskText}\n${groupTitle}`
+ if (tokens.length && !tokens.every((token) => haystack.includes(token))) return null
+
+ let score = 0
+ if (!tokens.length) score += 1
+ if (query && taskText === query) score += 240
+ if (query && taskText.startsWith(query)) score += 180
+ if (query && taskText.includes(query)) score += 120
+ if (query && groupTitle.includes(query)) score += 40
+ if (task.completed) score -= 8
+
+ return { task, group, score }
+ })
+ .filter((result): result is TaskSearchResult => Boolean(result))
+ .sort((a, b) => {
+ if (b.score !== a.score) return b.score - a.score
+ if (a.group.sort !== b.group.sort) return a.group.sort - b.group.sort
+ return a.task.sort - b.task.sort
+ })
+ .slice(0, 12)
+ })
+
+ function openTaskSearch() {
+ options.closeContextMenu()
+ taskSearchOpen.value = true
+ taskSearchQuery.value = ''
+ taskSearchIndex.value = 0
+ nextTick(() => document.querySelector('.task-search-input')?.focus())
+ }
+
+ function closeTaskSearch() {
+ taskSearchOpen.value = false
+ taskSearchQuery.value = ''
+ taskSearchIndex.value = 0
+ }
+
+ function updateTaskSearchQuery(value: string) {
+ taskSearchQuery.value = value
+ taskSearchIndex.value = 0
+ }
+
+ function moveTaskSearchSelection(delta: number) {
+ const count = taskSearchResults.value.length
+ if (!count) {
+ taskSearchIndex.value = 0
+ return
+ }
+ taskSearchIndex.value = (taskSearchIndex.value + delta + count) % count
+ }
+
+ function confirmTaskSearchSelection(result = taskSearchResults.value[taskSearchIndex.value]) {
+ if (!result) return
+ const taskId = result.task._id
+ closeTaskSearch()
+ options.selectGroup(result.group._id)
+ options.selectTask(taskId)
+ nextTick(() => document.querySelector('.task-card.active')?.scrollIntoView({ block: 'nearest' }))
+ }
+
+ return {
+ taskSearchOpen,
+ taskSearchQuery,
+ taskSearchIndex,
+ taskSearchResults,
+ openTaskSearch,
+ closeTaskSearch,
+ updateTaskSearchQuery,
+ moveTaskSearchSelection,
+ confirmTaskSearchSelection
+ }
+}
diff --git a/plugins/todo-neo/src/composables/useTodoContextMenu.ts b/plugins/todo-neo/src/composables/useTodoContextMenu.ts
new file mode 100644
index 00000000..1bdf6968
--- /dev/null
+++ b/plugins/todo-neo/src/composables/useTodoContextMenu.ts
@@ -0,0 +1,56 @@
+import { computed, ref } from 'vue'
+import type { GroupDoc, TaskDoc } from '../types'
+
+interface TodoContextMenuOptions {
+ taskById: (id: string) => TaskDoc | undefined
+ groupById: (id: string) => GroupDoc | undefined
+ selectTask: (id: string) => void
+}
+
+export function useTodoContextMenu(options: TodoContextMenuOptions) {
+ const contextMenu = ref<{ open: boolean; x: number; y: number; kind: 'task' | 'group' | ''; id: string }>({
+ open: false,
+ x: 0,
+ y: 0,
+ kind: '',
+ id: ''
+ })
+
+ const contextTask = computed(() => (contextMenu.value.kind === 'task' ? options.taskById(contextMenu.value.id) : undefined))
+ const contextGroup = computed(() => (contextMenu.value.kind === 'group' ? options.groupById(contextMenu.value.id) : undefined))
+
+ function openTaskContextMenu(event: MouseEvent, task: TaskDoc) {
+ options.selectTask(task._id)
+ contextMenu.value = {
+ open: true,
+ x: event.clientX,
+ y: event.clientY,
+ kind: 'task',
+ id: task._id
+ }
+ }
+
+ function openGroupContextMenu(event: MouseEvent, group: GroupDoc) {
+ contextMenu.value = {
+ open: true,
+ x: event.clientX,
+ y: event.clientY,
+ kind: 'group',
+ id: group._id
+ }
+ }
+
+ function closeContextMenu() {
+ contextMenu.value.open = false
+ }
+
+ return {
+ contextMenu,
+ contextTask,
+ contextGroup,
+ openTaskContextMenu,
+ openGroupContextMenu,
+ closeContextMenu
+ }
+}
+
diff --git a/plugins/todo-neo/src/composables/useTodoDrag.ts b/plugins/todo-neo/src/composables/useTodoDrag.ts
new file mode 100644
index 00000000..d6f454fd
--- /dev/null
+++ b/plugins/todo-neo/src/composables/useTodoDrag.ts
@@ -0,0 +1,165 @@
+import { ref, type ComputedRef, type Ref } from 'vue'
+import type { GroupDoc, TaskDoc } from '../types'
+
+type ReadableRef = Ref | ComputedRef
+
+interface TodoDragOptions {
+ groups: ReadableRef
+ tasks: ReadableRef
+ activeGroupId: Ref
+ activeTaskId: Ref
+ editingGroupId: Ref
+ editingTaskId: Ref
+ visibleTasks: ReadableRef
+ taskById: (id: string) => TaskDoc | undefined
+ allTasksForGroup: (groupId: string) => TaskDoc[]
+ saveTask: (task: TaskDoc, shouldRefresh?: boolean) => void
+ saveGroup: (group: GroupDoc, shouldRefresh?: boolean) => void
+ refreshData: () => void
+ selectTask: (id: string) => void
+}
+
+export function useTodoDrag(options: TodoDragOptions) {
+ const dragTaskId = ref('')
+ const dragOverTaskId = ref('')
+ const dragOverTaskGroupId = ref('')
+ const dragInsertPosition = ref<'before' | 'after' | ''>('')
+ const dragGroupId = ref('')
+ const dragOverGroupId = ref('')
+ const groupInsertPosition = ref<'before' | 'after' | ''>('')
+
+ function startGroupDrag(group: GroupDoc) {
+ if (options.editingGroupId.value === group._id) {
+ clearGroupDropTarget()
+ return
+ }
+ dragGroupId.value = group._id
+ }
+
+ function updateGroupDropTarget(event: DragEvent, group: GroupDoc) {
+ if (dragTaskId.value) {
+ const task = options.taskById(dragTaskId.value)
+ if (!task || task.groupId === group._id) {
+ clearGroupDropTarget()
+ return
+ }
+ event.dataTransfer!.dropEffect = 'move'
+ clearGroupDropTarget()
+ clearTaskDropTarget()
+ dragOverTaskGroupId.value = group._id
+ return
+ }
+ if (!dragGroupId.value || dragGroupId.value === group._id) return
+ const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
+ dragOverGroupId.value = group._id
+ groupInsertPosition.value = event.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
+ }
+
+ function clearGroupDropTarget() {
+ dragOverGroupId.value = ''
+ groupInsertPosition.value = ''
+ dragOverTaskGroupId.value = ''
+ }
+
+ function onGroupDragDrop(target: GroupDoc, position = groupInsertPosition.value) {
+ if (dragTaskId.value) {
+ onTaskGroupDrop(target)
+ return
+ }
+ const sourceId = dragGroupId.value
+ dragGroupId.value = ''
+ const resolvedPosition = position || 'before'
+ clearGroupDropTarget()
+ if (!sourceId || sourceId === target._id) return
+ const ordered = [...options.groups.value]
+ const sourceIndex = ordered.findIndex((group) => group._id === sourceId)
+ if (sourceIndex < 0) return
+ const [source] = ordered.splice(sourceIndex, 1)
+ const targetIndex = ordered.findIndex((group) => group._id === target._id)
+ if (targetIndex < 0) return
+ const insertIndex = resolvedPosition === 'after' ? targetIndex + 1 : targetIndex
+ ordered.splice(insertIndex, 0, source)
+ ordered.forEach((group, index) => options.saveGroup({ ...group, sort: index + 1 }, false))
+ options.refreshData()
+ }
+
+ function startTaskDrag(task: TaskDoc) {
+ if (options.editingTaskId.value === task._id) {
+ clearTaskDropTarget()
+ return
+ }
+ dragTaskId.value = task._id
+ }
+
+ function updateTaskDropTarget(event: DragEvent, task: TaskDoc) {
+ if (!dragTaskId.value || dragTaskId.value === task._id) return
+ const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
+ dragOverTaskGroupId.value = ''
+ dragOverTaskId.value = task._id
+ dragInsertPosition.value = event.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
+ }
+
+ function clearTaskDropTarget() {
+ dragOverTaskId.value = ''
+ dragOverTaskGroupId.value = ''
+ dragInsertPosition.value = ''
+ }
+
+ function finishTaskDrag() {
+ dragTaskId.value = ''
+ clearTaskDropTarget()
+ }
+
+ function onTaskDragDrop(targetTask?: TaskDoc, position = dragInsertPosition.value) {
+ const sourceId = dragTaskId.value
+ dragTaskId.value = ''
+ const resolvedPosition = position || 'before'
+ clearTaskDropTarget()
+ const source = options.taskById(sourceId)
+ if (!source) return
+ const ordered = options.allTasksForGroup(options.activeGroupId.value).filter((task) => task._id !== source._id)
+ const targetIndex = targetTask ? ordered.findIndex((task) => task._id === targetTask._id) : ordered.length
+ const insertIndex = targetTask && targetIndex >= 0 && resolvedPosition === 'after' ? targetIndex + 1 : targetIndex
+ ordered.splice(insertIndex < 0 ? ordered.length : insertIndex, 0, { ...source, groupId: options.activeGroupId.value })
+ ordered.forEach((task, index) => options.saveTask({ ...task, sort: index + 1, groupId: options.activeGroupId.value }, false))
+ options.refreshData()
+ options.selectTask(sourceId)
+ }
+
+ function onTaskGroupDrop(targetGroup: GroupDoc) {
+ const sourceId = dragTaskId.value
+ dragTaskId.value = ''
+ clearTaskDropTarget()
+ clearGroupDropTarget()
+ const source = options.taskById(sourceId)
+ if (!source || source.groupId === targetGroup._id) return
+
+ const ordered = options.tasks.value
+ .filter((task) => task.groupId === targetGroup._id && task._id !== source._id)
+ .sort((a, b) => a.sort - b.sort)
+ ordered.push({ ...source, groupId: targetGroup._id })
+ ordered.forEach((task, index) => options.saveTask({ ...task, groupId: targetGroup._id, sort: index + 1 }, false))
+ options.refreshData()
+ if (options.activeTaskId.value === sourceId) options.activeTaskId.value = options.visibleTasks.value[0]?._id || ''
+ }
+
+ return {
+ dragTaskId,
+ dragOverTaskId,
+ dragOverTaskGroupId,
+ dragInsertPosition,
+ dragGroupId,
+ dragOverGroupId,
+ groupInsertPosition,
+ startGroupDrag,
+ updateGroupDropTarget,
+ clearGroupDropTarget,
+ startTaskDrag,
+ updateTaskDropTarget,
+ clearTaskDropTarget,
+ finishTaskDrag,
+ onGroupDragDrop,
+ onTaskDragDrop
+ }
+}
+
diff --git a/plugins/todo-neo/src/composables/useTodoGroups.ts b/plugins/todo-neo/src/composables/useTodoGroups.ts
new file mode 100644
index 00000000..61299ef5
--- /dev/null
+++ b/plugins/todo-neo/src/composables/useTodoGroups.ts
@@ -0,0 +1,78 @@
+import { nextTick, ref, type Ref } from 'vue'
+import type { GroupDoc, TaskDoc } from '../types'
+import { GROUPS_PREFIX } from './todoConstants'
+import { removeDoc } from './todoPersistence'
+
+interface TodoGroupsOptions {
+ groups: Ref
+ tasks: Ref
+ saveGroup: (group: GroupDoc, shouldRefresh?: boolean) => void
+ refreshData: () => void
+ selectGroup: (id: string) => void
+}
+
+export function useTodoGroups(options: TodoGroupsOptions) {
+ const groupComposerOpen = ref(false)
+ const newGroupTitle = ref('')
+ const editingGroupId = ref('')
+ const editingGroupTitle = ref('')
+ const deleteGroupId = ref('')
+
+ function showGroupComposer() {
+ groupComposerOpen.value = true
+ nextTick(() => document.querySelector('.group-create-input')?.focus())
+ }
+
+ function createGroup(title = newGroupTitle.value) {
+ const name = title.trim()
+ if (!name) {
+ groupComposerOpen.value = false
+ return
+ }
+ const now = Date.now()
+ const group: GroupDoc = {
+ _id: `${GROUPS_PREFIX}${now}`,
+ title: name,
+ sort: options.groups.value.reduce((max, item) => Math.max(max, item.sort), 0) + 1,
+ created_at: now
+ }
+ options.saveGroup(group)
+ newGroupTitle.value = ''
+ groupComposerOpen.value = false
+ options.selectGroup(group._id)
+ }
+
+ function startGroupEdit(group: GroupDoc) {
+ editingGroupId.value = group._id
+ editingGroupTitle.value = group.title
+ }
+
+ function renameGroup(group: GroupDoc) {
+ const title = editingGroupTitle.value.trim()
+ if (!title) return
+ options.saveGroup({ ...group, title })
+ editingGroupId.value = ''
+ }
+
+ function deleteGroup(group: GroupDoc) {
+ options.tasks.value.filter((task) => task.groupId === group._id).forEach((task) => removeDoc(task._id))
+ removeDoc(group._id)
+ deleteGroupId.value = ''
+ options.refreshData()
+ options.selectGroup(options.groups.value[0]?._id || `${GROUPS_PREFIX}pending`)
+ }
+
+ return {
+ groupComposerOpen,
+ newGroupTitle,
+ editingGroupId,
+ editingGroupTitle,
+ deleteGroupId,
+ showGroupComposer,
+ createGroup,
+ startGroupEdit,
+ renameGroup,
+ deleteGroup
+ }
+}
+
diff --git a/plugins/todo-neo/src/composables/useTodoKeyboard.ts b/plugins/todo-neo/src/composables/useTodoKeyboard.ts
new file mode 100644
index 00000000..f36b8bfa
--- /dev/null
+++ b/plugins/todo-neo/src/composables/useTodoKeyboard.ts
@@ -0,0 +1,136 @@
+import { ref, type ComputedRef, type Ref } from 'vue'
+import type { GroupDoc, TaskDoc, ViewName } from '../types'
+
+type ReadableRef = Ref | ComputedRef
+
+interface TodoKeyboardOptions {
+ route: Ref
+ settingsOpen: Ref
+ groups: ReadableRef
+ activeGroupId: Ref
+ activeTaskId: Ref
+ visibleTasks: ReadableRef
+ taskById: (id: string) => TaskDoc | undefined
+ selectTaskAndReveal: (id: string) => void
+ selectGroup: (id: string) => void
+ toggleTask: (task: TaskDoc) => void
+ startEditTask: (task: TaskDoc) => void
+ requestDeleteTask: (task: TaskDoc) => void
+ beginCreateTask: (groupId?: string, afterTaskId?: string | null) => void
+ openTaskSearch: () => void
+}
+
+export function useTodoKeyboard(options: TodoKeyboardOptions) {
+ const keyBuffer = ref('')
+
+ function handleKeyboard(event: KeyboardEvent) {
+ const target = event.target as HTMLElement
+ if (options.route.value !== 'main') return
+
+ if (options.settingsOpen.value) {
+ if (event.key === 'j' || event.key === 'k') {
+ event.preventDefault()
+ const content = document.querySelector('.settings-content')
+ content?.scrollBy({ top: event.key === 'j' ? 72 : -72, behavior: 'smooth' })
+ return
+ }
+ }
+
+ if (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return
+
+ const taskList = options.visibleTasks.value
+ const taskIndex = taskList.findIndex((task) => task._id === options.activeTaskId.value)
+ const groupIndex = options.groups.value.findIndex((group) => group._id === options.activeGroupId.value)
+ const activeTask = options.taskById(options.activeTaskId.value)
+
+ if (event.key === '?') {
+ event.preventDefault()
+ options.settingsOpen.value = true
+ return
+ }
+
+ if (event.key === '/' && !event.ctrlKey && !event.metaKey && !event.altKey) {
+ event.preventDefault()
+ options.openTaskSearch()
+ return
+ }
+
+ if (event.ctrlKey && event.key.toLowerCase() === 'n') {
+ event.preventDefault()
+ options.beginCreateTask(options.activeGroupId.value, options.activeTaskId.value || null)
+ return
+ }
+ if (event.key === 'Tab') {
+ event.preventDefault()
+ options.beginCreateTask(options.activeGroupId.value, options.activeTaskId.value || null)
+ return
+ }
+ if (event.key === 'j' || event.key === 'ArrowDown') {
+ event.preventDefault()
+ const nextIndex = taskIndex >= 0 ? (taskIndex + 1) % taskList.length : 0
+ const next = taskList[nextIndex]
+ if (next) options.selectTaskAndReveal(next._id)
+ } else if (event.key === 'k' || event.key === 'ArrowUp') {
+ event.preventDefault()
+ const prevIndex = taskIndex >= 0 ? (taskIndex - 1 + taskList.length) % taskList.length : taskList.length - 1
+ const prev = taskList[prevIndex]
+ if (prev) options.selectTaskAndReveal(prev._id)
+ } else if (event.key === 'h' || event.key === 'ArrowLeft') {
+ event.preventDefault()
+ const groupCount = options.groups.value.length
+ const prevIndex = groupIndex >= 0 ? (groupIndex - 1 + groupCount) % groupCount : groupCount - 1
+ const group = options.groups.value[prevIndex]
+ if (group) options.selectGroup(group._id)
+ } else if (event.key === 'l' || event.key === 'ArrowRight') {
+ event.preventDefault()
+ const groupCount = options.groups.value.length
+ const nextIndex = groupIndex >= 0 ? (groupIndex + 1) % groupCount : 0
+ const group = options.groups.value[nextIndex]
+ if (group) options.selectGroup(group._id)
+ } else if ((event.key === ' ' || event.key === 'Spacebar') && activeTask) {
+ event.preventDefault()
+ options.toggleTask(activeTask)
+ } else if ((event.key === 'Enter' || event.key === 'i') && activeTask) {
+ event.preventDefault()
+ options.startEditTask(activeTask)
+ } else if ((event.key === 'Backspace' || event.key === 'Delete') && activeTask) {
+ event.preventDefault()
+ options.requestDeleteTask(activeTask)
+ } else if (event.key === 'G') {
+ event.preventDefault()
+ const lastTask = taskList[taskList.length - 1]
+ if (lastTask) options.selectTaskAndReveal(lastTask._id)
+ } else if (event.key === 'g') {
+ keyBuffer.value += 'g'
+ if (keyBuffer.value.endsWith('gg')) {
+ event.preventDefault()
+ const firstTask = taskList[0]
+ if (firstTask) options.selectTaskAndReveal(firstTask._id)
+ keyBuffer.value = ''
+ }
+ } else if (event.key === 'd') {
+ keyBuffer.value += 'd'
+ if (keyBuffer.value.endsWith('dd') && activeTask) {
+ event.preventDefault()
+ options.requestDeleteTask(activeTask)
+ keyBuffer.value = ''
+ }
+ } else {
+ keyBuffer.value = ''
+ }
+ }
+
+ function handleSettingsEscape(event: KeyboardEvent) {
+ if (options.route.value !== 'main' || !options.settingsOpen.value || event.key !== 'Escape') return
+ event.preventDefault()
+ event.stopPropagation()
+ event.stopImmediatePropagation()
+ options.settingsOpen.value = false
+ }
+
+ return {
+ handleKeyboard,
+ handleSettingsEscape
+ }
+}
+
diff --git a/plugins/todo-neo/src/composables/useTodoStore.ts b/plugins/todo-neo/src/composables/useTodoStore.ts
new file mode 100644
index 00000000..4298141f
--- /dev/null
+++ b/plugins/todo-neo/src/composables/useTodoStore.ts
@@ -0,0 +1,484 @@
+import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
+import type { GroupDoc, Settings, StatusFilter, TaskDoc, ViewName } from '../types'
+import { featureFlags } from '../featureFlags'
+import {
+ ACTIVE_GROUP_KEY,
+ ACTIVE_TASK_KEY,
+ GROUPS_PREFIX,
+ SETTINGS_KEY,
+ TASKS_PREFIX,
+ defaultGroups,
+ defaultSettings
+} from './todoConstants'
+import { allDocs, docValue, getStorage, groupPayload, putDoc, setStorage, taskPayload } from './todoPersistence'
+import { compactDate, formatDate, formatDateInput, formatTimer, parseDateInputEndOfDay } from './todoFormatters'
+import { useTodoContextMenu } from './useTodoContextMenu'
+import { useTodoDrag } from './useTodoDrag'
+import { useTodoGroups } from './useTodoGroups'
+import { useTodoKeyboard } from './useTodoKeyboard'
+import { useTodoTasks } from './useTodoTasks'
+import { useTomatoTimer } from './useTomatoTimer'
+import { useTodoWindows } from './useTodoWindows'
+import { useTaskSearch } from './useTaskSearch'
+
+const route = ref('main')
+const routeQuery = ref(new URLSearchParams())
+const groups = ref([])
+const tasks = ref([])
+const settings = reactive({ ...defaultSettings })
+const activeGroupId = ref(`${GROUPS_PREFIX}pending`)
+const activeTaskId = ref('')
+const detailTaskId = ref('')
+const settingsOpen = ref(false)
+const noteEditingTaskId = ref('')
+const noteDraft = ref('')
+const noteFocused = ref(true)
+const tomatoTaskId = ref('')
+let mounted = false
+let mountConsumers = 0
+let tomatoInterval: number | undefined
+
+function saveTask(task: TaskDoc, shouldRefresh = true) {
+ putDoc(task._id, taskPayload(task))
+ if (shouldRefresh) refreshData()
+}
+
+function saveGroup(group: GroupDoc, shouldRefresh = true) {
+ putDoc(group._id, groupPayload(group))
+ if (shouldRefresh) refreshData()
+}
+
+function refreshData() {
+ const loadedGroups = allDocs>(GROUPS_PREFIX)
+ .map((doc) => ({ _id: doc._id, ...docValue>(doc) }))
+ .filter((group) => group.title)
+ .sort((a, b) => a.sort - b.sort)
+
+ if (!loadedGroups.length) {
+ const now = Date.now()
+ defaultGroups.forEach((group, index) => {
+ putDoc(group._id, { title: group.title, sort: group.sort, created_at: now + index })
+ })
+ }
+
+ groups.value = allDocs>(GROUPS_PREFIX)
+ .map((doc) => ({ _id: doc._id, ...docValue>(doc) }))
+ .filter((group) => group.title)
+ .sort((a, b) => a.sort - b.sort)
+
+ tasks.value = allDocs>(TASKS_PREFIX)
+ .map((doc) => ({ _id: doc._id, ...docValue>(doc) }))
+ .filter((task) => task.text && task.groupId)
+ .sort((a, b) => a.sort - b.sort)
+
+ if (!groups.value.some((group) => group._id === activeGroupId.value)) {
+ activeGroupId.value = groups.value[0]?._id || `${GROUPS_PREFIX}pending`
+ }
+}
+
+function saveSettings() {
+ setStorage(SETTINGS_KEY, { ...settings })
+}
+
+function loadSettings() {
+ const saved = getStorage>(SETTINGS_KEY, {})
+ const savedSettings: Partial = {}
+ ;(Object.keys(defaultSettings) as Array).forEach((key) => {
+ if (saved[key] !== undefined) {
+ savedSettings[key] = saved[key] as never
+ }
+ })
+ Object.assign(settings, defaultSettings, savedSettings)
+ syncTomatoSettings()
+}
+
+function groupById(id: string) {
+ return groups.value.find((group) => group._id === id)
+}
+
+function taskById(id: string) {
+ return tasks.value.find((task) => task._id === id)
+}
+
+function allTasksForGroup(groupId: string) {
+ return tasks.value
+ .filter((task) => task.groupId === groupId)
+ .sort((a, b) => a.sort - b.sort)
+}
+
+function tasksForGroup(groupId: string, status?: StatusFilter) {
+ let result = allTasksForGroup(groupId)
+ if (status === 'done') result = result.filter((task) => task.completed)
+ if (status === 'pending') result = result.filter((task) => !task.completed)
+ if (settings.hideCompleted && !status) result = result.filter((task) => !task.completed)
+ return [...result].sort((a, b) => {
+ if (settings.bottomCompleted && a.completed !== b.completed) return a.completed ? 1 : -1
+ return a.sort - b.sort
+ })
+}
+
+const activeGroup = computed(() => groupById(activeGroupId.value) || groups.value[0])
+const visibleTasks = computed(() => tasksForGroup(activeGroupId.value))
+const detailTask = computed(() => taskById(detailTaskId.value))
+const currentTomatoTask = computed(() => taskById(tomatoTaskId.value))
+const noteGroupName = computed(() => routeQuery.value.get('group') || '待处理')
+const noteStatus = computed(() => {
+ const status = routeQuery.value.get('status')
+ return status === 'done' || status === 'pending' ? status : undefined
+})
+const noteGroup = computed(() => groups.value.find((group) => group.title === noteGroupName.value) || groups.value[0])
+const noteTasks = computed(() => (noteGroup.value ? tasksForGroup(noteGroup.value._id, noteStatus.value) : []))
+const pendingCount = computed(() => tasks.value.filter((task) => !task.completed).length)
+const {
+ tomatoRemaining,
+ tomatoRunning,
+ tomatoProgress,
+ tomatoSegments,
+ syncTomatoSettings,
+ resetTomato,
+ toggleTomato,
+ updateTomatoMinutes,
+ tickTomato
+} = useTomatoTimer({
+ settings,
+ currentTomatoTask,
+ saveSettings
+})
+
+function selectGroup(id: string) {
+ activeGroupId.value = id
+ setStorage(ACTIVE_GROUP_KEY, id)
+ const firstTask = tasksForGroup(id)[0]
+ selectTask(firstTask?._id || '')
+}
+
+function selectTask(id: string) {
+ activeTaskId.value = id
+ setStorage(ACTIVE_TASK_KEY, id)
+}
+
+function selectTaskAndReveal(id: string) {
+ selectTask(id)
+ nextTick(() => document.querySelector('.task-card.active')?.scrollIntoView({ block: 'nearest' }))
+}
+
+const {
+ groupComposerOpen,
+ newGroupTitle,
+ editingGroupId,
+ editingGroupTitle,
+ deleteGroupId,
+ showGroupComposer,
+ createGroup,
+ startGroupEdit,
+ renameGroup,
+ deleteGroup
+} = useTodoGroups({
+ groups,
+ tasks,
+ saveGroup,
+ refreshData,
+ selectGroup
+})
+
+const {
+ composingTaskGroupId,
+ composingTaskAfterId,
+ composingTaskText,
+ editingTaskId,
+ editingText,
+ deleteTaskId,
+ deletingTask,
+ createTask,
+ beginCreateTask,
+ saveComposedTask,
+ cancelComposedTask,
+ toggleTask,
+ startEditTask,
+ saveEditTask,
+ requestDeleteTask,
+ confirmDeleteTask,
+ moveTask
+} = useTodoTasks({
+ activeGroupId,
+ activeTaskId,
+ visibleTasks,
+ taskById,
+ allTasksForGroup,
+ saveTask,
+ refreshData,
+ selectGroup,
+ selectTask
+})
+
+const {
+ contextMenu,
+ contextTask,
+ contextGroup,
+ openTaskContextMenu,
+ openGroupContextMenu,
+ closeContextMenu
+} = useTodoContextMenu({
+ taskById,
+ groupById,
+ selectTask
+})
+
+const {
+ taskSearchOpen,
+ taskSearchQuery,
+ taskSearchIndex,
+ taskSearchResults,
+ openTaskSearch,
+ closeTaskSearch,
+ updateTaskSearchQuery,
+ moveTaskSearchSelection,
+ confirmTaskSearchSelection
+} = useTaskSearch({
+ tasks,
+ groupById,
+ selectGroup,
+ selectTask,
+ closeContextMenu
+})
+
+const {
+ dragTaskId,
+ dragOverTaskId,
+ dragOverTaskGroupId,
+ dragInsertPosition,
+ dragGroupId,
+ dragOverGroupId,
+ groupInsertPosition,
+ startGroupDrag,
+ updateGroupDropTarget,
+ clearGroupDropTarget,
+ startTaskDrag,
+ updateTaskDropTarget,
+ clearTaskDropTarget,
+ finishTaskDrag,
+ onGroupDragDrop,
+ onTaskDragDrop
+} = useTodoDrag({
+ groups,
+ tasks,
+ activeGroupId,
+ activeTaskId,
+ editingGroupId,
+ editingTaskId,
+ visibleTasks,
+ taskById,
+ allTasksForGroup,
+ saveTask,
+ saveGroup,
+ refreshData,
+ selectTask
+})
+
+const { openNote, openTomato, createNoteTask, closeCurrentWindow } = useTodoWindows({
+ activeGroup,
+ activeTaskId,
+ noteGroup,
+ noteDraft,
+ taskById,
+ createTask
+})
+
+function setDueAt(task: TaskDoc, value: string) {
+ const updated = { ...task }
+ if (value) {
+ const dueAt = parseDateInputEndOfDay(value)
+ if (!dueAt) return
+ updated.dueAt = dueAt
+ } else {
+ delete updated.dueAt
+ }
+ saveTask(updated)
+}
+
+function parseRoute() {
+ const raw = location.hash.replace(/^#\/?/, '')
+ const [name, query = ''] = raw.split('?')
+ if (name === 'note' && featureFlags.noteWindow) route.value = name
+ else if (name === 'tomato' && featureFlags.tomatoWindow) route.value = name
+ else route.value = 'main'
+ routeQuery.value = new URLSearchParams(query)
+ tomatoTaskId.value = routeQuery.value.get('taskId') || activeTaskId.value
+}
+
+function handlePluginEnter(action: { code: string; type: string; payload: any }) {
+ if (action.code === 'new-note') {
+ if (featureFlags.noteWindow) window.services?.openNote()
+ window.ztools?.outPlugin?.()
+ return
+ }
+ route.value = 'main'
+ if (action.code === 'add') {
+ const text = Array.isArray(action.payload) ? action.payload.join('\n') : String(action.payload || '')
+ createTask(text, activeGroupId.value, activeTaskId.value || null)
+ window.ztools?.showNotification?.('已添加到待办')
+ return
+ }
+ refreshData()
+}
+
+const { handleKeyboard, handleSettingsEscape } = useTodoKeyboard({
+ route,
+ settingsOpen,
+ groups,
+ activeGroupId,
+ activeTaskId,
+ visibleTasks,
+ taskById,
+ selectTaskAndReveal,
+ selectGroup,
+ toggleTask,
+ startEditTask,
+ requestDeleteTask,
+ beginCreateTask,
+ openTaskSearch
+})
+
+function mountStore() {
+ if (mounted) return
+ mounted = true
+ loadSettings()
+ activeGroupId.value = getStorage(ACTIVE_GROUP_KEY, activeGroupId.value)
+ activeTaskId.value = getStorage(ACTIVE_TASK_KEY, '')
+ refreshData()
+ parseRoute()
+ window.addEventListener('hashchange', parseRoute)
+ window.addEventListener('keydown', handleSettingsEscape, { capture: true })
+ window.addEventListener('keydown', handleKeyboard)
+ window.ztools?.onPluginEnter?.(handlePluginEnter)
+ window.ztools?.onDbPull?.(() => refreshData())
+ tomatoInterval = window.setInterval(() => {
+ tickTomato()
+ }, 1000)
+}
+
+function unmountStore() {
+ window.removeEventListener('hashchange', parseRoute)
+ window.removeEventListener('keydown', handleSettingsEscape, { capture: true })
+ window.removeEventListener('keydown', handleKeyboard)
+ if (tomatoInterval) {
+ window.clearInterval(tomatoInterval)
+ tomatoInterval = undefined
+ }
+ mounted = false
+}
+
+export function useTodoStore() {
+ onMounted(() => {
+ mountConsumers += 1
+ mountStore()
+ })
+ onBeforeUnmount(() => {
+ mountConsumers = Math.max(0, mountConsumers - 1)
+ if (mountConsumers === 0) {
+ unmountStore()
+ }
+ })
+
+ return {
+ route,
+ groups,
+ tasks,
+ settings,
+ activeGroupId,
+ activeTaskId,
+ groupComposerOpen,
+ newGroupTitle,
+ composingTaskGroupId,
+ composingTaskAfterId,
+ composingTaskText,
+ editingTaskId,
+ editingText,
+ editingGroupId,
+ editingGroupTitle,
+ detailTaskId,
+ deletingTask,
+ settingsOpen,
+ taskSearchOpen,
+ taskSearchQuery,
+ taskSearchIndex,
+ deleteGroupId,
+ deleteTaskId,
+ contextMenu,
+ dragTaskId,
+ dragOverTaskId,
+ dragOverTaskGroupId,
+ dragInsertPosition,
+ dragGroupId,
+ dragOverGroupId,
+ groupInsertPosition,
+ noteEditingTaskId,
+ noteDraft,
+ noteFocused,
+ tomatoTaskId,
+ tomatoRemaining,
+ tomatoRunning,
+ activeGroup,
+ visibleTasks,
+ detailTask,
+ currentTomatoTask,
+ contextTask,
+ contextGroup,
+ noteGroupName,
+ noteTasks,
+ pendingCount,
+ taskSearchResults,
+ tomatoProgress,
+ tomatoSegments,
+ saveSettings,
+ refreshData,
+ groupById,
+ taskById,
+ tasksForGroup,
+ selectGroup,
+ selectTask,
+ openTaskContextMenu,
+ openGroupContextMenu,
+ closeContextMenu,
+ openTaskSearch,
+ closeTaskSearch,
+ updateTaskSearchQuery,
+ moveTaskSearchSelection,
+ confirmTaskSearchSelection,
+ beginCreateTask,
+ saveComposedTask,
+ cancelComposedTask,
+ showGroupComposer,
+ createGroup,
+ startGroupEdit,
+ renameGroup,
+ deleteGroup,
+ toggleTask,
+ startEditTask,
+ saveEditTask,
+ requestDeleteTask,
+ confirmDeleteTask,
+ moveTask,
+ startGroupDrag,
+ updateGroupDropTarget,
+ clearGroupDropTarget,
+ startTaskDrag,
+ updateTaskDropTarget,
+ clearTaskDropTarget,
+ finishTaskDrag,
+ onGroupDragDrop,
+ onTaskDragDrop,
+ openNote,
+ openTomato,
+ createNoteTask,
+ formatDateInput,
+ setDueAt,
+ formatDate,
+ compactDate,
+ resetTomato,
+ toggleTomato,
+ updateTomatoMinutes,
+ formatTimer,
+ closeCurrentWindow
+ }
+}
diff --git a/plugins/todo-neo/src/composables/useTodoTasks.ts b/plugins/todo-neo/src/composables/useTodoTasks.ts
new file mode 100644
index 00000000..b9a6e13f
--- /dev/null
+++ b/plugins/todo-neo/src/composables/useTodoTasks.ts
@@ -0,0 +1,143 @@
+import { computed, nextTick, ref, type ComputedRef, type Ref } from 'vue'
+import type { TaskDoc } from '../types'
+import { TASKS_PREFIX } from './todoConstants'
+import { removeDoc } from './todoPersistence'
+
+type ReadableRef = Ref | ComputedRef
+
+interface TodoTasksOptions {
+ activeGroupId: Ref
+ activeTaskId: Ref
+ visibleTasks: ReadableRef
+ taskById: (id: string) => TaskDoc | undefined
+ allTasksForGroup: (groupId: string) => TaskDoc[]
+ saveTask: (task: TaskDoc, shouldRefresh?: boolean) => void
+ refreshData: () => void
+ selectGroup: (id: string) => void
+ selectTask: (id: string) => void
+}
+
+export function useTodoTasks(options: TodoTasksOptions) {
+ const composingTaskGroupId = ref('')
+ const composingTaskAfterId = ref(null)
+ const composingTaskText = ref('')
+ const editingTaskId = ref('')
+ const editingText = ref('')
+ const deleteTaskId = ref('')
+ const deletingTask = computed(() => options.taskById(deleteTaskId.value))
+
+ function createTask(text: string, groupId = options.activeGroupId.value, afterTaskId: string | null = options.activeTaskId.value || null) {
+ const content = text.trim()
+ if (!content) return
+ const now = Date.now()
+ const task: TaskDoc = {
+ _id: `${TASKS_PREFIX}${now}`,
+ text: content,
+ groupId,
+ completed: false,
+ created_at: now,
+ sort: now
+ }
+
+ const ordered = options.allTasksForGroup(groupId).filter((item) => item._id !== task._id)
+ const afterIndex = afterTaskId ? ordered.findIndex((item) => item._id === afterTaskId) : -1
+ ordered.splice(afterIndex >= 0 ? afterIndex + 1 : 0, 0, task)
+ ordered.forEach((item, index) => options.saveTask({ ...item, groupId, sort: index + 1 }, false))
+ options.refreshData()
+ options.selectGroup(groupId)
+ options.selectTask(task._id)
+ }
+
+ function beginCreateTask(groupId = options.activeGroupId.value, afterTaskId: string | null = options.activeTaskId.value || null) {
+ composingTaskGroupId.value = groupId
+ composingTaskAfterId.value = afterTaskId && options.taskById(afterTaskId)?.groupId === groupId ? afterTaskId : null
+ composingTaskText.value = ''
+ if (options.activeGroupId.value !== groupId) options.selectGroup(groupId)
+ nextTick(() => document.querySelector('.task-create-input')?.focus())
+ }
+
+ function saveComposedTask(groupId = composingTaskGroupId.value) {
+ if (!composingTaskText.value.trim()) {
+ cancelComposedTask()
+ return
+ }
+ createTask(composingTaskText.value, groupId, composingTaskAfterId.value)
+ cancelComposedTask()
+ }
+
+ function cancelComposedTask() {
+ composingTaskGroupId.value = ''
+ composingTaskAfterId.value = null
+ composingTaskText.value = ''
+ }
+
+ function toggleTask(task: TaskDoc) {
+ const now = Date.now()
+ const updated = { ...task, completed: !task.completed }
+ if (updated.completed) {
+ updated.completed_at = now
+ if (!updated.first_completed_at) updated.first_completed_at = now
+ } else {
+ delete updated.completed_at
+ }
+ options.saveTask(updated)
+ }
+
+ function startEditTask(task: TaskDoc) {
+ editingTaskId.value = task._id
+ editingText.value = task.text
+ nextTick(() => document.querySelector('.task-edit-input')?.focus())
+ }
+
+ function saveEditTask(task: TaskDoc) {
+ const text = editingText.value.trim()
+ if (!text) return
+ options.saveTask({ ...task, text })
+ editingTaskId.value = ''
+ }
+
+ function requestDeleteTask(task: TaskDoc) {
+ deleteTaskId.value = task._id
+ }
+
+ function confirmDeleteTask() {
+ const task = deletingTask.value
+ if (!task) {
+ deleteTaskId.value = ''
+ return
+ }
+ removeDoc(task._id)
+ deleteTaskId.value = ''
+ options.refreshData()
+ if (options.activeTaskId.value === task._id) options.activeTaskId.value = options.visibleTasks.value[0]?._id || ''
+ }
+
+ function moveTask(task: TaskDoc, position: 'top' | 'bottom') {
+ const sorted = options.allTasksForGroup(task.groupId).filter((item) => item._id !== task._id)
+ if (position === 'top') sorted.unshift(task)
+ else sorted.push(task)
+ sorted.forEach((item, index) => options.saveTask({ ...item, sort: index + 1 }, false))
+ options.refreshData()
+ }
+
+ return {
+ composingTaskGroupId,
+ composingTaskAfterId,
+ composingTaskText,
+ editingTaskId,
+ editingText,
+ deleteTaskId,
+ deletingTask,
+ createTask,
+ beginCreateTask,
+ saveComposedTask,
+ cancelComposedTask,
+ toggleTask,
+ startEditTask,
+ saveEditTask,
+ requestDeleteTask,
+ confirmDeleteTask,
+ moveTask
+ }
+}
+
diff --git a/plugins/todo-neo/src/composables/useTodoWindows.ts b/plugins/todo-neo/src/composables/useTodoWindows.ts
new file mode 100644
index 00000000..75171ab7
--- /dev/null
+++ b/plugins/todo-neo/src/composables/useTodoWindows.ts
@@ -0,0 +1,43 @@
+import type { ComputedRef, Ref } from 'vue'
+import type { GroupDoc, TaskDoc } from '../types'
+import { featureFlags } from '../featureFlags'
+
+interface TodoWindowsOptions {
+ activeGroup: ComputedRef
+ activeTaskId: Ref
+ noteGroup: ComputedRef
+ noteDraft: Ref
+ taskById: (id: string) => TaskDoc | undefined
+ createTask: (text: string, groupId?: string, afterTaskId?: string | null) => void
+}
+
+export function useTodoWindows(options: TodoWindowsOptions) {
+ function openNote(group = options.activeGroup.value) {
+ if (!featureFlags.noteWindow) return
+ if (!group) return
+ window.services?.openNote({ group: group.title })
+ }
+
+ function openTomato(task = options.taskById(options.activeTaskId.value)) {
+ if (!featureFlags.tomatoWindow) return
+ window.services?.openTomato(task?._id)
+ }
+
+ function createNoteTask() {
+ if (!options.noteGroup.value) return
+ options.createTask(options.noteDraft.value, options.noteGroup.value._id, null)
+ options.noteDraft.value = ''
+ }
+
+ function closeCurrentWindow() {
+ window.services?.closeWindow?.()
+ }
+
+ return {
+ openNote,
+ openTomato,
+ createNoteTask,
+ closeCurrentWindow
+ }
+}
+
diff --git a/plugins/todo-neo/src/composables/useTomatoTimer.ts b/plugins/todo-neo/src/composables/useTomatoTimer.ts
new file mode 100644
index 00000000..80ffa22a
--- /dev/null
+++ b/plugins/todo-neo/src/composables/useTomatoTimer.ts
@@ -0,0 +1,77 @@
+import { computed, ref, type ComputedRef } from 'vue'
+import type { Settings, TaskDoc } from '../types'
+import { defaultSettings } from './todoConstants'
+import { getStorage, setStorage } from './todoPersistence'
+
+interface TomatoTimerOptions {
+ settings: Settings
+ currentTomatoTask: ComputedRef
+ saveSettings: () => void
+}
+
+export function useTomatoTimer(options: TomatoTimerOptions) {
+ const tomatoRemaining = ref(defaultSettings.tomatoMinutes * 60)
+ const tomatoRunning = ref(false)
+
+ const tomatoProgress = computed(() => {
+ const total = options.settings.tomatoMinutes * 60
+ return total ? 1 - tomatoRemaining.value / total : 0
+ })
+ const tomatoSegments = computed(() => Array.from({ length: 25 }, (_, index) => index < Math.ceil(tomatoProgress.value * 25)))
+
+ function syncTomatoSettings() {
+ tomatoRemaining.value = options.settings.tomatoMinutes * 60
+ }
+
+ function resetTomato() {
+ tomatoRunning.value = false
+ tomatoRemaining.value = options.settings.tomatoMinutes * 60
+ }
+
+ function toggleTomato() {
+ tomatoRunning.value = !tomatoRunning.value
+ }
+
+ function completeTomato() {
+ tomatoRunning.value = false
+ tomatoRemaining.value = 0
+ window.ztools?.showNotification?.('番茄钟已完成')
+ if (options.currentTomatoTask.value) {
+ const history = getStorage('todo-tomato-history', [])
+ history.push({
+ taskId: options.currentTomatoTask.value._id,
+ text: options.currentTomatoTask.value.text,
+ minutes: options.settings.tomatoMinutes,
+ completed_at: Date.now()
+ })
+ setStorage('todo-tomato-history', history)
+ }
+ }
+
+ function updateTomatoMinutes(minutes: number) {
+ if (tomatoRunning.value) return
+ options.settings.tomatoMinutes = Math.min(60, Math.max(5, minutes))
+ tomatoRemaining.value = options.settings.tomatoMinutes * 60
+ options.saveSettings()
+ }
+
+ function tickTomato() {
+ if (!tomatoRunning.value) return
+ if (tomatoRemaining.value <= 1) completeTomato()
+ else tomatoRemaining.value -= 1
+ }
+
+ return {
+ tomatoRemaining,
+ tomatoRunning,
+ tomatoProgress,
+ tomatoSegments,
+ syncTomatoSettings,
+ resetTomato,
+ toggleTomato,
+ completeTomato,
+ updateTomatoMinutes,
+ tickTomato
+ }
+}
+
diff --git a/plugins/todo-neo/src/env.d.ts b/plugins/todo-neo/src/env.d.ts
new file mode 100644
index 00000000..01f68363
--- /dev/null
+++ b/plugins/todo-neo/src/env.d.ts
@@ -0,0 +1,27 @@
+///
+///
+
+declare module '*.vue' {
+ import type { DefineComponent } from 'vue'
+ const component: DefineComponent, Record, unknown>
+ export default component
+}
+
+interface TodoWindowServices {
+ featureFlags?: {
+ noteWindow: boolean
+ tomatoWindow: boolean
+ }
+ openNote: (params?: { group?: string; status?: 'done' | 'pending' }) => void
+ openTomato: (taskId?: string) => void
+ pinToScreen: (args?: { filter?: { group?: string[]; status?: 'done' | 'pending' } }) => Promise<{ pinned: boolean }>
+ closeWindow: () => void
+}
+
+declare global {
+ interface Window {
+ services?: TodoWindowServices
+ }
+}
+
+export {}
diff --git a/plugins/todo-neo/src/featureFlags.ts b/plugins/todo-neo/src/featureFlags.ts
new file mode 100644
index 00000000..e8375579
--- /dev/null
+++ b/plugins/todo-neo/src/featureFlags.ts
@@ -0,0 +1,4 @@
+export const featureFlags: Record<'noteWindow' | 'tomatoWindow', boolean> = {
+ noteWindow: false,
+ tomatoWindow: false
+}
diff --git a/plugins/todo-neo/src/main.css b/plugins/todo-neo/src/main.css
new file mode 100644
index 00000000..33900cd6
--- /dev/null
+++ b/plugins/todo-neo/src/main.css
@@ -0,0 +1,91 @@
+:root {
+ color-scheme: light dark;
+ font-family:
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
+ "Microsoft YaHei", sans-serif;
+ --bg: #eef1f5;
+ --surface: #ffffff;
+ --surface-soft: #f8fafc;
+ --line: #e5e7eb;
+ --text: #111827;
+ --muted: #6b7280;
+ --primary: #5976f5;
+ --primary-weak: #e8ecff;
+ --danger: #dc2626;
+ --shadow: 0 18px 45px rgba(15, 23, 42, 0.12);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#app {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+}
+
+body {
+ min-width: 320px;
+ background: var(--bg);
+ color: var(--text);
+ overflow: hidden;
+}
+
+button,
+input,
+textarea {
+ font: inherit;
+}
+
+button {
+ border: 0;
+ cursor: pointer;
+ user-select: none;
+ -webkit-user-select: none;
+}
+
+button:disabled {
+ cursor: not-allowed;
+ opacity: 0.45;
+}
+
+input,
+textarea {
+ min-width: 0;
+}
+
+.danger,
+.danger:hover {
+ color: var(--danger);
+}
+
+@media (max-width: 520px) {
+ body {
+ overflow: auto;
+ }
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #141414;
+ --surface: rgba(42, 42, 42, 0.72);
+ --surface-soft: rgba(54, 54, 54, 0.62);
+ --line: rgba(255, 255, 255, 0.12);
+ --text: #f4f4f5;
+ --muted: #a1a1aa;
+ --primary: #7c8cff;
+ --primary-weak: rgba(255, 255, 255, 0.08);
+ --danger: #fb7185;
+ --shadow: 0 22px 70px rgba(0, 0, 0, 0.46);
+ }
+
+ body {
+ background:
+ radial-gradient(circle at 74% 0%, rgba(255, 255, 255, 0.1), transparent 34%),
+ radial-gradient(circle at 0% 100%, rgba(255, 255, 255, 0.06), transparent 30%),
+ linear-gradient(135deg, #181818, #090909);
+ }
+}
diff --git a/plugins/todo-neo/src/main.ts b/plugins/todo-neo/src/main.ts
new file mode 100644
index 00000000..8a3c77bc
--- /dev/null
+++ b/plugins/todo-neo/src/main.ts
@@ -0,0 +1,5 @@
+import { createApp } from 'vue'
+import './main.css'
+import App from './App.vue'
+
+createApp(App).mount('#app')
diff --git a/plugins/todo-neo/src/types.ts b/plugins/todo-neo/src/types.ts
new file mode 100644
index 00000000..74004970
--- /dev/null
+++ b/plugins/todo-neo/src/types.ts
@@ -0,0 +1,33 @@
+export type StatusFilter = 'done' | 'pending'
+export type ViewName = 'main' | 'note' | 'tomato'
+
+export interface TaskDoc {
+ _id: string
+ text: string
+ groupId: string
+ completed: boolean
+ completed_at?: number
+ first_completed_at?: number
+ created_at: number
+ sort: number
+ dueAt?: number
+}
+
+export interface GroupDoc {
+ _id: string
+ title: string
+ sort: number
+ created_at: number
+}
+
+export interface Settings {
+ hideCompleted: boolean
+ bottomCompleted: boolean
+ renderMarkdown: boolean
+ noteBlurTransparent: boolean
+ noteOpacity: number
+ noteBackground: string
+ tomatoSkin: 'tomato' | 'EWatch'
+ tomatoMinutes: number
+ tomatoScale: number
+}
diff --git a/plugins/todo-neo/src/utils/inputAttrs.ts b/plugins/todo-neo/src/utils/inputAttrs.ts
new file mode 100644
index 00000000..4280d6be
--- /dev/null
+++ b/plugins/todo-neo/src/utils/inputAttrs.ts
@@ -0,0 +1,9 @@
+export const plainTextInputAttrs = {
+ spellcheck: false,
+ autocapitalize: 'off',
+ autocomplete: 'off',
+ autocorrect: 'off',
+ 'data-gramm': 'false',
+ 'data-gramm_editor': 'false',
+ 'data-enable-grammarly': 'false'
+}
diff --git a/plugins/todo-neo/src/utils/markdown.ts b/plugins/todo-neo/src/utils/markdown.ts
new file mode 100644
index 00000000..af4353a9
--- /dev/null
+++ b/plugins/todo-neo/src/utils/markdown.ts
@@ -0,0 +1,19 @@
+import MarkdownIt from 'markdown-it'
+
+const markdown = new MarkdownIt({
+ html: false,
+ linkify: true,
+ breaks: true
+})
+
+const defaultLinkOpen = markdown.renderer.rules.link_open
+
+markdown.renderer.rules.link_open = (tokens, index, options, env, self) => {
+ tokens[index].attrSet('target', '_blank')
+ tokens[index].attrSet('rel', 'noreferrer')
+ return defaultLinkOpen ? defaultLinkOpen(tokens, index, options, env, self) : self.renderToken(tokens, index, options)
+}
+
+export function renderMarkdown(text: string) {
+ return markdown.render(text)
+}
diff --git a/plugins/todo-neo/tsconfig.json b/plugins/todo-neo/tsconfig.json
new file mode 100644
index 00000000..39b73bc6
--- /dev/null
+++ b/plugins/todo-neo/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "preserve",
+ "strict": false,
+ "noImplicitAny": false,
+ "types": ["@ztools-center/ztools-api-types"]
+ },
+ "include": ["src"]
+}
diff --git a/plugins/todo-neo/vite.config.js b/plugins/todo-neo/vite.config.js
new file mode 100644
index 00000000..2fa70efc
--- /dev/null
+++ b/plugins/todo-neo/vite.config.js
@@ -0,0 +1,30 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+
+const finalizeDist = () => ({
+ name: 'finalize-dist',
+ closeBundle() {
+ const readmePath = resolve(__dirname, 'README.md')
+ const distPath = resolve(__dirname, 'dist')
+
+ if (existsSync(readmePath)) {
+ mkdirSync(distPath, { recursive: true })
+ copyFileSync(readmePath, resolve(distPath, 'README.md'))
+ }
+
+ const pluginPath = resolve(distPath, 'plugin.json')
+ if (existsSync(pluginPath)) {
+ const pluginConfig = JSON.parse(readFileSync(pluginPath, 'utf-8'))
+ pluginConfig.main = 'index.html'
+ writeFileSync(pluginPath, `${JSON.stringify(pluginConfig, null, 2)}\n`)
+ }
+ }
+})
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [vue(), finalizeDist()],
+ base: './'
+})