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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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: './' +})