@walknpm/todo-cli 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +203 -0
- package/README.zh-CN.md +202 -0
- package/bin/todo.js +5 -0
- package/package.json +37 -0
- package/src/cli.js +419 -0
- package/src/store.js +48 -0
- package/src/todo.js +291 -0
package/README.md
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# todo-cli
|
|
2
|
+
|
|
3
|
+
> **English** | [简体中文](./README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
A zero-dependency Node.js CLI todo list manager. Full CRUD, completion toggle,
|
|
6
|
+
categories/tags, priority sorting, due dates with overdue highlighting, search,
|
|
7
|
+
statistics, and export (JSON / CSV / Markdown). Data persists to
|
|
8
|
+
`~/.todo-cli/todos.json`.
|
|
9
|
+
|
|
10
|
+
## Requirements
|
|
11
|
+
|
|
12
|
+
- Node.js >= 18
|
|
13
|
+
|
|
14
|
+
## Quick install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
git clone https://gitcode.com/hw_aishell_projects/todo_cli.git
|
|
18
|
+
cd todo_cli
|
|
19
|
+
npm install -g .
|
|
20
|
+
todo --version # → todo v1.0.0
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Commands
|
|
24
|
+
|
|
25
|
+
| Command | Description |
|
|
26
|
+
| --------------------- | -------------------------------------------------- |
|
|
27
|
+
| `add <title>` | Add a new todo |
|
|
28
|
+
| `list` | List todos (default command) |
|
|
29
|
+
| `done <id>` | Mark a todo as done (`done --all` = complete all) |
|
|
30
|
+
| `toggle <id>` | Toggle done / pending |
|
|
31
|
+
| `edit <id>` | Edit a todo |
|
|
32
|
+
| `delete <id>` | Delete a todo |
|
|
33
|
+
| `search <keyword>` | Search title / tags / category / note |
|
|
34
|
+
| `stats` | Show summary statistics |
|
|
35
|
+
| `export [json\|csv\|md]` | Export all todos to stdout |
|
|
36
|
+
| `archive` | Remove all completed todos |
|
|
37
|
+
| `update` | Update todo-cli to latest (`--check` = check only) |
|
|
38
|
+
| `clear` | Remove ALL todos |
|
|
39
|
+
| `help` | Show help |
|
|
40
|
+
|
|
41
|
+
`-h` / `--help` can be appended to **any** command (or used alone) to show help.
|
|
42
|
+
`-v` / `--version` prints the version.
|
|
43
|
+
|
|
44
|
+
## Flags
|
|
45
|
+
|
|
46
|
+
| Flag | Applies to | Description |
|
|
47
|
+
| ----------------------- | ---------------- | ------------------------------------------ |
|
|
48
|
+
| `-p, --priority <v>` | add, edit | `high` \| `medium` \| `low` (default: medium) |
|
|
49
|
+
| `-t, --tags <v>` | add, edit | Comma-separated tags |
|
|
50
|
+
| `-c, --category <v>` | add, edit | Category name |
|
|
51
|
+
| `-d, --due <v>` | add, edit | Due date `YYYY-MM-DD` |
|
|
52
|
+
| `--note <v>` | add, edit | Longer note / description |
|
|
53
|
+
| `--title <v>` | edit | New title |
|
|
54
|
+
| `--status <v>` | list | `all` \| `done` \| `pending` |
|
|
55
|
+
| `--tag <v>` | list | Filter by tag |
|
|
56
|
+
| `--sort <v>` | list | `priority` \| `created` \| `due` \| `id` |
|
|
57
|
+
| `--all` | done | Complete all pending |
|
|
58
|
+
| `--check` | update | Check for update without installing |
|
|
59
|
+
| `--format <v>` | export | `json` \| `csv` \| `md` (default: json) |
|
|
60
|
+
|
|
61
|
+
## Examples
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# Add with due date, priority, tags, category, and note
|
|
65
|
+
todo add "Submit tax return" -p high -d 2025-01-31 -c finance -t urgent,monthly --note "Include receipts"
|
|
66
|
+
|
|
67
|
+
# List & filter
|
|
68
|
+
todo list
|
|
69
|
+
todo list --status pending --sort due
|
|
70
|
+
todo list --category work --priority high
|
|
71
|
+
|
|
72
|
+
# Search
|
|
73
|
+
todo search tax
|
|
74
|
+
|
|
75
|
+
# Bulk complete & archive
|
|
76
|
+
todo done --all
|
|
77
|
+
todo archive
|
|
78
|
+
|
|
79
|
+
# Statistics
|
|
80
|
+
todo stats
|
|
81
|
+
|
|
82
|
+
# Auto-update
|
|
83
|
+
todo update --check # check for updates
|
|
84
|
+
todo update # install latest version
|
|
85
|
+
|
|
86
|
+
# Export
|
|
87
|
+
todo export csv > todos.csv
|
|
88
|
+
todo export md
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Project structure
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
todo_cli/
|
|
95
|
+
├── bin/
|
|
96
|
+
│ └── todo.js # executable entry point (shebang)
|
|
97
|
+
├── src/
|
|
98
|
+
│ ├── store.js # JSON file persistence layer
|
|
99
|
+
│ ├── todo.js # domain logic (CRUD, search, stats, export, ...)
|
|
100
|
+
│ └── cli.js # command parsing, ANSI colours, table output
|
|
101
|
+
├── package.json
|
|
102
|
+
├── README.md # English documentation
|
|
103
|
+
└── README.zh-CN.md # Chinese documentation
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## 🤖 AI 自动部署提示词
|
|
109
|
+
|
|
110
|
+
> 以下提示词可直接粘贴给 AI 助手(如 Claude、ChatGPT、DeepSeek 等),AI 会自动完成部署。
|
|
111
|
+
> 复制对应场景的代码块即可。
|
|
112
|
+
|
|
113
|
+
### 场景一:从零安装并验证
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
请帮我安装部署 todo-cli 命令行待办工具,步骤如下:
|
|
117
|
+
|
|
118
|
+
1. 克隆仓库:git clone https://gitcode.com/hw_aishell_projects/todo_cli.git
|
|
119
|
+
2. 进入目录:cd todo_cli
|
|
120
|
+
3. 全局安装:npm install -g .
|
|
121
|
+
4. 验证安装:运行 todo --version,确认输出 "todo v1.0.0"
|
|
122
|
+
5. 功能验证:依次执行以下命令并确认每步输出正常:
|
|
123
|
+
- todo add "部署测试任务" -p high -t test -c verify
|
|
124
|
+
- todo list
|
|
125
|
+
- todo done 1
|
|
126
|
+
- todo stats
|
|
127
|
+
- todo clear
|
|
128
|
+
6. 如果任何步骤报错,请展示完整错误信息并尝试修复。
|
|
129
|
+
|
|
130
|
+
完成后告诉我安装路径和 todo --version 的输出。
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### 场景二:安装 + 导入已有数据
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
请帮我部署 todo-cli 并导入已有待办数据:
|
|
137
|
+
|
|
138
|
+
1. 克隆并安装:
|
|
139
|
+
git clone https://gitcode.com/hw_aishell_projects/todo_cli.git
|
|
140
|
+
cd todo_cli && npm install -g .
|
|
141
|
+
|
|
142
|
+
2. 确认 todo --version 正常输出。
|
|
143
|
+
|
|
144
|
+
3. 以下是我的待办事项,请逐条用 todo add 命令创建(根据内容自动判断优先级、分类和标签):
|
|
145
|
+
- [在此粘贴你的待办列表,每行一条]
|
|
146
|
+
|
|
147
|
+
4. 创建完成后运行 todo list 和 todo stats,把结果展示给我确认。
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### 场景三:卸载
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
请帮我卸载 todo-cli 工具:
|
|
154
|
+
|
|
155
|
+
1. 运行 npm uninstall -g todo-cli
|
|
156
|
+
2. 删除数据文件:rm -rf ~/.todo-cli
|
|
157
|
+
3. 运行 todo --version 确认已无法找到该命令
|
|
158
|
+
4. 告诉我卸载是否成功。
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### 场景四:更新到最新版本
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
请帮我更新 todo-cli 到最新版本:
|
|
165
|
+
|
|
166
|
+
1. 运行 todo update --check 检查是否有新版本
|
|
167
|
+
2. 如果有更新,运行 todo update 自动安装最新版
|
|
168
|
+
3. 运行 todo --version 确认版本号已更新
|
|
169
|
+
4. 运行 todo list 确认已有数据未丢失
|
|
170
|
+
5. 告诉我更新结果。
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### 场景五:完整部署(含数据备份与恢复)
|
|
174
|
+
|
|
175
|
+
```
|
|
176
|
+
请帮我完成 todo-cli 的完整部署流程:
|
|
177
|
+
|
|
178
|
+
【安装】
|
|
179
|
+
1. git clone https://gitcode.com/hw_aishell_projects/todo_cli.git
|
|
180
|
+
2. cd todo_cli && npm install -g .
|
|
181
|
+
3. 确认 todo --version 输出版本号
|
|
182
|
+
|
|
183
|
+
【备份】(如果 ~/.todo-cli/todos.json 已存在)
|
|
184
|
+
4. 如果存在 ~/.todo-cli/todos.json,先备份到 ~/todo-backup-$(date +%Y%m%d).json
|
|
185
|
+
5. 确认备份文件内容有效
|
|
186
|
+
|
|
187
|
+
【验证】
|
|
188
|
+
6. 执行 todo add "验证任务" -p medium 并确认输出
|
|
189
|
+
7. 执行 todo list 确认表格正常显示
|
|
190
|
+
8. 执行 todo delete 1 清理验证数据
|
|
191
|
+
|
|
192
|
+
【恢复】(如果有备份)
|
|
193
|
+
9. 如果之前做了备份,将备份文件复制回 ~/.todo-cli/todos.json
|
|
194
|
+
10. 执行 todo list 确认数据已恢复
|
|
195
|
+
|
|
196
|
+
最后汇总报告:安装状态、版本号、备份/恢复状态。
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## License
|
|
202
|
+
|
|
203
|
+
MIT
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# todo-cli
|
|
2
|
+
|
|
3
|
+
> **简体中文** | [English](./README.md)
|
|
4
|
+
|
|
5
|
+
一个零依赖的 Node.js 命令行待办事项管理器。支持完整的增删改查、完成状态切换、
|
|
6
|
+
分类/标签、优先级排序、截止日期(含逾期高亮)、搜索、统计概览,以及导出
|
|
7
|
+
(JSON / CSV / Markdown)。数据持久化到 `~/.todo-cli/todos.json`。
|
|
8
|
+
|
|
9
|
+
## 环境要求
|
|
10
|
+
|
|
11
|
+
- Node.js >= 18
|
|
12
|
+
|
|
13
|
+
## 快速安装
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
git clone https://gitcode.com/hw_aishell_projects/todo_cli.git
|
|
17
|
+
cd todo_cli
|
|
18
|
+
npm install -g .
|
|
19
|
+
todo --version # → todo v1.0.0
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## 命令一览
|
|
23
|
+
|
|
24
|
+
| 命令 | 说明 |
|
|
25
|
+
| --------------------- | -------------------------------------------------- |
|
|
26
|
+
| `add <标题>` | 添加新待办 |
|
|
27
|
+
| `list` | 列出待办(默认命令) |
|
|
28
|
+
| `done <id>` | 标记完成(`done --all` = 全部完成) |
|
|
29
|
+
| `toggle <id>` | 切换完成 / 待办状态 |
|
|
30
|
+
| `edit <id>` | 编辑待办 |
|
|
31
|
+
| `delete <id>` | 删除待办 |
|
|
32
|
+
| `search <关键词>` | 搜索标题 / 标签 / 分类 / 备注 |
|
|
33
|
+
| `stats` | 显示统计概览 |
|
|
34
|
+
| `export [json\|csv\|md]` | 导出全部待办到标准输出 |
|
|
35
|
+
| `archive` | 归档(删除所有已完成项) |
|
|
36
|
+
| `update` | 自动更新到最新版(`--check` = 仅检查不安装) |
|
|
37
|
+
| `clear` | 清空所有待办 |
|
|
38
|
+
| `help` | 显示帮助 |
|
|
39
|
+
|
|
40
|
+
`-h` / `--help` 可附加在**任意命令**后(或单独使用)显示帮助。
|
|
41
|
+
`-v` / `--version` 显示版本号。
|
|
42
|
+
|
|
43
|
+
## 参数标志
|
|
44
|
+
|
|
45
|
+
| 标志 | 适用命令 | 说明 |
|
|
46
|
+
| ---------------------- | ---------------- | ------------------------------------------ |
|
|
47
|
+
| `-p, --priority <v>` | add, edit | `high` \| `medium` \| `low`(默认: medium) |
|
|
48
|
+
| `-t, --tags <v>` | add, edit | 逗号分隔的标签 |
|
|
49
|
+
| `-c, --category <v>` | add, edit | 分类名称 |
|
|
50
|
+
| `-d, --due <v>` | add, edit | 截止日期 `YYYY-MM-DD` |
|
|
51
|
+
| `--note <v>` | add, edit | 较长的备注 / 描述 |
|
|
52
|
+
| `--title <v>` | edit | 新标题 |
|
|
53
|
+
| `--status <v>` | list | `all` \| `done` \| `pending` |
|
|
54
|
+
| `--tag <v>` | list | 按标签过滤 |
|
|
55
|
+
| `--sort <v>` | list | `priority` \| `created` \| `due` \| `id` |
|
|
56
|
+
| `--all` | done | 完成所有待办项 |
|
|
57
|
+
| `--check` | update | 仅检查是否有更新,不安装 |
|
|
58
|
+
| `--format <v>` | export | `json` \| `csv` \| `md`(默认: json) |
|
|
59
|
+
|
|
60
|
+
## 使用示例
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# 添加待办(含截止日期、优先级、标签、分类、备注)
|
|
64
|
+
todo add "提交报税单" -p high -d 2025-01-31 -c finance -t urgent,monthly --note "附上收据"
|
|
65
|
+
|
|
66
|
+
# 列出与过滤
|
|
67
|
+
todo list
|
|
68
|
+
todo list --status pending --sort due
|
|
69
|
+
todo list --category work --priority high
|
|
70
|
+
|
|
71
|
+
# 搜索
|
|
72
|
+
todo search 报税
|
|
73
|
+
|
|
74
|
+
# 批量完成与归档
|
|
75
|
+
todo done --all
|
|
76
|
+
todo archive
|
|
77
|
+
|
|
78
|
+
# 统计概览
|
|
79
|
+
todo stats
|
|
80
|
+
|
|
81
|
+
# 自动更新
|
|
82
|
+
todo update --check # 检查是否有新版本
|
|
83
|
+
todo update # 安装最新版本
|
|
84
|
+
|
|
85
|
+
# 导出
|
|
86
|
+
todo export csv > todos.csv
|
|
87
|
+
todo export md
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## 项目结构
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
todo_cli/
|
|
94
|
+
├── bin/
|
|
95
|
+
│ └── todo.js # 可执行入口(shebang)
|
|
96
|
+
├── src/
|
|
97
|
+
│ ├── store.js # JSON 文件持久化层
|
|
98
|
+
│ ├── todo.js # 领域逻辑(增删改查、搜索、统计、导出等)
|
|
99
|
+
│ └── cli.js # 命令解析、ANSI 彩色、表格输出
|
|
100
|
+
├── package.json
|
|
101
|
+
├── README.md # 英文文档
|
|
102
|
+
└── README.zh-CN.md # 中文文档
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## 🤖 AI 自动部署提示词
|
|
108
|
+
|
|
109
|
+
> 以下提示词可直接粘贴给 AI 助手(如 Claude、ChatGPT、DeepSeek 等),AI 会自动完成部署。
|
|
110
|
+
> 复制对应场景的代码块即可。
|
|
111
|
+
|
|
112
|
+
### 场景一:从零安装并验证
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
请帮我安装部署 todo-cli 命令行待办工具,步骤如下:
|
|
116
|
+
|
|
117
|
+
1. 克隆仓库:git clone https://gitcode.com/hw_aishell_projects/todo_cli.git
|
|
118
|
+
2. 进入目录:cd todo_cli
|
|
119
|
+
3. 全局安装:npm install -g .
|
|
120
|
+
4. 验证安装:运行 todo --version,确认输出 "todo v1.0.0"
|
|
121
|
+
5. 功能验证:依次执行以下命令并确认每步输出正常:
|
|
122
|
+
- todo add "部署测试任务" -p high -t test -c verify
|
|
123
|
+
- todo list
|
|
124
|
+
- todo done 1
|
|
125
|
+
- todo stats
|
|
126
|
+
- todo clear
|
|
127
|
+
6. 如果任何步骤报错,请展示完整错误信息并尝试修复。
|
|
128
|
+
|
|
129
|
+
完成后告诉我安装路径和 todo --version 的输出。
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### 场景二:安装 + 导入已有数据
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
请帮我部署 todo-cli 并导入已有待办数据:
|
|
136
|
+
|
|
137
|
+
1. 克隆并安装:
|
|
138
|
+
git clone https://gitcode.com/hw_aishell_projects/todo_cli.git
|
|
139
|
+
cd todo_cli && npm install -g .
|
|
140
|
+
|
|
141
|
+
2. 确认 todo --version 正常输出。
|
|
142
|
+
|
|
143
|
+
3. 以下是我的待办事项,请逐条用 todo add 命令创建(根据内容自动判断优先级、分类和标签):
|
|
144
|
+
- [在此粘贴你的待办列表,每行一条]
|
|
145
|
+
|
|
146
|
+
4. 创建完成后运行 todo list 和 todo stats,把结果展示给我确认。
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### 场景三:卸载
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
请帮我卸载 todo-cli 工具:
|
|
153
|
+
|
|
154
|
+
1. 运行 npm uninstall -g todo-cli
|
|
155
|
+
2. 删除数据文件:rm -rf ~/.todo-cli
|
|
156
|
+
3. 运行 todo --version 确认已无法找到该命令
|
|
157
|
+
4. 告诉我卸载是否成功。
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### 场景四:更新到最新版本
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
请帮我更新 todo-cli 到最新版本:
|
|
164
|
+
|
|
165
|
+
1. 运行 todo update --check 检查是否有新版本
|
|
166
|
+
2. 如果有更新,运行 todo update 自动安装最新版
|
|
167
|
+
3. 运行 todo --version 确认版本号已更新
|
|
168
|
+
4. 运行 todo list 确认已有数据未丢失
|
|
169
|
+
5. 告诉我更新结果。
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### 场景五:完整部署(含数据备份与恢复)
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
请帮我完成 todo-cli 的完整部署流程:
|
|
176
|
+
|
|
177
|
+
【安装】
|
|
178
|
+
1. git clone https://gitcode.com/hw_aishell_projects/todo_cli.git
|
|
179
|
+
2. cd todo_cli && npm install -g .
|
|
180
|
+
3. 确认 todo --version 输出版本号
|
|
181
|
+
|
|
182
|
+
【备份】(如果 ~/.todo-cli/todos.json 已存在)
|
|
183
|
+
4. 如果存在 ~/.todo-cli/todos.json,先备份到 ~/todo-backup-$(date +%Y%m%d).json
|
|
184
|
+
5. 确认备份文件内容有效
|
|
185
|
+
|
|
186
|
+
【验证】
|
|
187
|
+
6. 执行 todo add "验证任务" -p medium 并确认输出
|
|
188
|
+
7. 执行 todo list 确认表格正常显示
|
|
189
|
+
8. 执行 todo delete 1 清理验证数据
|
|
190
|
+
|
|
191
|
+
【恢复】(如果有备份)
|
|
192
|
+
9. 如果之前做了备份,将备份文件复制回 ~/.todo-cli/todos.json
|
|
193
|
+
10. 执行 todo list 确认数据已恢复
|
|
194
|
+
|
|
195
|
+
最后汇总报告:安装状态、版本号、备份/恢复状态。
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## 开源协议
|
|
201
|
+
|
|
202
|
+
MIT
|
package/bin/todo.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@walknpm/todo-cli",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "A zero-dependency Node.js CLI todo list manager with CRUD, due dates, search, stats, export, and auto-update.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/cli.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"todo": "bin/todo.js"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=18"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"start": "node bin/todo.js"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"todo",
|
|
18
|
+
"cli",
|
|
19
|
+
"task",
|
|
20
|
+
"productivity",
|
|
21
|
+
"todo-list",
|
|
22
|
+
"command-line"
|
|
23
|
+
],
|
|
24
|
+
"author": "walknpm",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "https://gitcode.com/hw_aishell_projects/todo_cli.git"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://gitcode.com/hw_aishell_projects/todo_cli",
|
|
31
|
+
"files": [
|
|
32
|
+
"bin/",
|
|
33
|
+
"src/",
|
|
34
|
+
"README.md",
|
|
35
|
+
"README.zh-CN.md"
|
|
36
|
+
]
|
|
37
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
// cli.js — command-line interface for the todo list.
|
|
2
|
+
// Parses argv into commands, executes them against the domain + store,
|
|
3
|
+
// and prints results with ANSI colours and a table layout.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import { execSync } from "node:child_process";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { createStore } from "./store.js";
|
|
11
|
+
import {
|
|
12
|
+
addTodo,
|
|
13
|
+
editTodo,
|
|
14
|
+
deleteTodo,
|
|
15
|
+
toggleTodo,
|
|
16
|
+
completeTodo,
|
|
17
|
+
completeAll,
|
|
18
|
+
listTodos,
|
|
19
|
+
clearTodos,
|
|
20
|
+
searchTodos,
|
|
21
|
+
getStats,
|
|
22
|
+
archiveCompleted,
|
|
23
|
+
exportTodos,
|
|
24
|
+
dueStatus,
|
|
25
|
+
PRIORITIES,
|
|
26
|
+
} from "./todo.js";
|
|
27
|
+
|
|
28
|
+
// --- version & repo (read from package.json so they stay in sync) -----------
|
|
29
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const __pkg = JSON.parse(
|
|
31
|
+
fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")
|
|
32
|
+
);
|
|
33
|
+
const VERSION = __pkg.version;
|
|
34
|
+
const REPO_URL = "https://gitcode.com/hw_aishell_projects/todo_cli.git";
|
|
35
|
+
|
|
36
|
+
// --- ANSI colours (disabled when not a TTY) -------------------------------
|
|
37
|
+
const useColor = process.stdout.isTTY;
|
|
38
|
+
function c(code, str) {
|
|
39
|
+
return useColor ? `\x1b[${code}m${str}\x1b[0m` : str;
|
|
40
|
+
}
|
|
41
|
+
const green = (s) => c("32", s);
|
|
42
|
+
const red = (s) => c("31", s);
|
|
43
|
+
const yellow = (s) => c("33", s);
|
|
44
|
+
const cyan = (s) => c("36", s);
|
|
45
|
+
const bold = (s) => c("1", s);
|
|
46
|
+
const dim = (s) => c("2", s);
|
|
47
|
+
|
|
48
|
+
const PRIORITY_COLOR = { high: red, medium: yellow, low: green };
|
|
49
|
+
|
|
50
|
+
// --- argument parsing ------------------------------------------------------
|
|
51
|
+
/**
|
|
52
|
+
* Split argv into a positional list and a flags object.
|
|
53
|
+
* Flags: -p/--priority, -t/--tags, -c/--category, -d/--due, --note,
|
|
54
|
+
* --title, --status, --tag, --sort, --all, --format
|
|
55
|
+
*/
|
|
56
|
+
function parseArgs(argv) {
|
|
57
|
+
const positional = [];
|
|
58
|
+
const flags = {};
|
|
59
|
+
for (let i = 0; i < argv.length; i++) {
|
|
60
|
+
const a = argv[i];
|
|
61
|
+
if (a === "-p" || a === "--priority") flags.priority = argv[++i];
|
|
62
|
+
else if (a === "-t" || a === "--tags") flags.tags = argv[++i];
|
|
63
|
+
else if (a === "-c" || a === "--category") flags.category = argv[++i];
|
|
64
|
+
else if (a === "-d" || a === "--due") flags.due = argv[++i];
|
|
65
|
+
else if (a === "--note") flags.note = argv[++i];
|
|
66
|
+
else if (a === "--title") flags.title = argv[++i];
|
|
67
|
+
else if (a === "--status") flags.status = argv[++i];
|
|
68
|
+
else if (a === "--tag") flags.tag = argv[++i];
|
|
69
|
+
else if (a === "--sort") flags.sort = argv[++i];
|
|
70
|
+
else if (a === "--format") flags.format = argv[++i];
|
|
71
|
+
else if (a === "--all") flags.all = true;
|
|
72
|
+
else if (a === "--check") flags.check = true;
|
|
73
|
+
else if (a === "-h" || a === "--help") flags.help = true;
|
|
74
|
+
else if (a.startsWith("-")) {
|
|
75
|
+
// unknown flag — ignore but don't swallow a value
|
|
76
|
+
} else {
|
|
77
|
+
positional.push(a);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { positional, flags };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- table rendering -------------------------------------------------------
|
|
84
|
+
function pad(str, width) {
|
|
85
|
+
str = String(str);
|
|
86
|
+
return str.length >= width ? str : str + " ".repeat(width - str.length);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function renderTable(todos) {
|
|
90
|
+
if (todos.length === 0) {
|
|
91
|
+
console.log(dim(" (no todos)"));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const hasDue = todos.some((t) => t.due);
|
|
95
|
+
const cols = hasDue
|
|
96
|
+
? ["ID", "Status", "Priority", "Due", "Category", "Tags", "Title"]
|
|
97
|
+
: ["ID", "Status", "Priority", "Category", "Tags", "Title"];
|
|
98
|
+
|
|
99
|
+
function dueCell(t) {
|
|
100
|
+
if (!t.due) return "-";
|
|
101
|
+
const ds = dueStatus(t.due);
|
|
102
|
+
if (t.done) return dim(t.due);
|
|
103
|
+
if (ds === "overdue") return red(t.due);
|
|
104
|
+
if (ds === "today") return yellow(t.due);
|
|
105
|
+
if (ds === "soon") return yellow(t.due);
|
|
106
|
+
return t.due;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const rows = todos.map((t) => {
|
|
110
|
+
const base = [
|
|
111
|
+
String(t.id),
|
|
112
|
+
t.done ? green("✓") : dim("○"),
|
|
113
|
+
PRIORITY_COLOR[t.priority](t.priority),
|
|
114
|
+
];
|
|
115
|
+
if (hasDue) base.push(dueCell(t));
|
|
116
|
+
base.push(
|
|
117
|
+
t.category || "-",
|
|
118
|
+
t.tags.length ? cyan(t.tags.join(", ")) : "-",
|
|
119
|
+
t.done ? dim(t.title) : t.title
|
|
120
|
+
);
|
|
121
|
+
return base;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const widths = cols.map((_, ci) =>
|
|
125
|
+
Math.max(cols[ci].length, ...rows.map((r) => r[ci].length))
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const header = " " + cols.map((h, i) => bold(pad(h, widths[i]))).join(" ");
|
|
129
|
+
console.log(header);
|
|
130
|
+
console.log(" " + dim("-".repeat(widths.reduce((a, w) => a + w + 2, -2))));
|
|
131
|
+
for (const row of rows) {
|
|
132
|
+
console.log(" " + row.map((cell, i) => pad(cell, widths[i])).join(" "));
|
|
133
|
+
}
|
|
134
|
+
console.log(dim(`\n ${todos.length} item(s)`));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --- commands --------------------------------------------------------------
|
|
138
|
+
function cmdAdd(todos, { positional, flags }) {
|
|
139
|
+
const title = positional.join(" ");
|
|
140
|
+
const todo = addTodo(todos, {
|
|
141
|
+
title,
|
|
142
|
+
priority: flags.priority,
|
|
143
|
+
tags: flags.tags,
|
|
144
|
+
category: flags.category,
|
|
145
|
+
due: flags.due,
|
|
146
|
+
note: flags.note,
|
|
147
|
+
});
|
|
148
|
+
let msg = green("✓ Added:") + ` #${todo.id} ${todo.title}`;
|
|
149
|
+
if (todo.due) msg += dim(` (due ${todo.due})`);
|
|
150
|
+
console.log(msg);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function cmdList(todos, { flags }) {
|
|
154
|
+
const result = listTodos(todos, {
|
|
155
|
+
status: flags.status,
|
|
156
|
+
category: flags.category,
|
|
157
|
+
tag: flags.tag,
|
|
158
|
+
priority: flags.priority,
|
|
159
|
+
sort: flags.sort,
|
|
160
|
+
});
|
|
161
|
+
renderTable(result);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function cmdDone(todos, { positional, flags }) {
|
|
165
|
+
if (flags.all) {
|
|
166
|
+
const n = completeAll(todos);
|
|
167
|
+
console.log(green(`✓ Marked ${n} pending item(s) as done`));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const id = positional[0];
|
|
171
|
+
if (!id) {
|
|
172
|
+
console.log(red("✗ Usage: todo done <id> (or todo done --all)"));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const todo = completeTodo(todos, id);
|
|
176
|
+
if (!todo) console.log(red(`✗ No todo with id ${id}`));
|
|
177
|
+
else console.log(green("✓ Done:") + ` #${todo.id} ${todo.title}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function cmdToggle(todos, { positional }) {
|
|
181
|
+
const id = positional[0];
|
|
182
|
+
const todo = toggleTodo(todos, id);
|
|
183
|
+
if (!todo) console.log(red(`✗ No todo with id ${id}`));
|
|
184
|
+
else
|
|
185
|
+
console.log(
|
|
186
|
+
(todo.done ? green("✓ Done:") : yellow("◌ Reopened:")) +
|
|
187
|
+
` #${todo.id} ${todo.title}`
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function cmdEdit(todos, { positional, flags }) {
|
|
192
|
+
const id = positional[0];
|
|
193
|
+
const todo = editTodo(todos, id, {
|
|
194
|
+
title: flags.title,
|
|
195
|
+
priority: flags.priority,
|
|
196
|
+
tags: flags.tags,
|
|
197
|
+
category: flags.category,
|
|
198
|
+
due: flags.due,
|
|
199
|
+
note: flags.note,
|
|
200
|
+
});
|
|
201
|
+
if (!todo) console.log(red(`✗ No todo with id ${id}`));
|
|
202
|
+
else console.log(green("✓ Edited:") + ` #${todo.id} ${todo.title}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function cmdDelete(todos, { positional }) {
|
|
206
|
+
const id = positional[0];
|
|
207
|
+
const ok = deleteTodo(todos, id);
|
|
208
|
+
if (!ok) console.log(red(`✗ No todo with id ${id}`));
|
|
209
|
+
else console.log(green("✓ Deleted:") + ` #${id}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function cmdClear(todos) {
|
|
213
|
+
const n = clearTodos(todos);
|
|
214
|
+
console.log(green(`✓ Cleared ${n} item(s)`));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function cmdSearch(todos, { positional }) {
|
|
218
|
+
const keyword = positional.join(" ");
|
|
219
|
+
const results = searchTodos(todos, keyword);
|
|
220
|
+
console.log(bold(`Search:`) + ` "${keyword}" — ${results.length} match(es)`);
|
|
221
|
+
renderTable(results);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function cmdStats(todos) {
|
|
225
|
+
const s = getStats(todos);
|
|
226
|
+
console.log(bold("Todo Statistics"));
|
|
227
|
+
console.log(dim("─────────────────"));
|
|
228
|
+
console.log(` Total: ${s.total}`);
|
|
229
|
+
console.log(` ${green("Done:")} ${s.done}`);
|
|
230
|
+
console.log(` ${yellow("Pending:")} ${s.pending}`);
|
|
231
|
+
if (s.overdue > 0) console.log(` ${red("Overdue:")} ${s.overdue}`);
|
|
232
|
+
console.log(dim("\n By priority:"));
|
|
233
|
+
console.log(` ${red("high")}: ${s.byPriority.high} ${yellow("medium")}: ${s.byPriority.medium} ${green("low")}: ${s.byPriority.low}`);
|
|
234
|
+
const cats = Object.keys(s.byCategory);
|
|
235
|
+
if (cats.length) {
|
|
236
|
+
console.log(dim(" By category:"));
|
|
237
|
+
for (const c of cats.sort()) console.log(` ${c}: ${s.byCategory[c]}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function cmdExport(todos, { positional, flags }) {
|
|
242
|
+
const format = (positional[0] || flags.format || "json").toLowerCase();
|
|
243
|
+
const out = exportTodos(todos, format);
|
|
244
|
+
console.log(out);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function cmdArchive(todos) {
|
|
248
|
+
const n = archiveCompleted(todos);
|
|
249
|
+
console.log(green(`✓ Archived (removed) ${n} completed item(s)`));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function cmdUpdate(_todos, { flags }) {
|
|
253
|
+
const tmpDir = path.join(os.tmpdir(), "todo-cli-update");
|
|
254
|
+
const run = (cmd) => execSync(cmd, { stdio: ["pipe", "pipe", "pipe"] }).toString().trim();
|
|
255
|
+
|
|
256
|
+
console.log(bold("todo update") + dim(" — checking for updates...\n"));
|
|
257
|
+
|
|
258
|
+
let latestVersion;
|
|
259
|
+
try {
|
|
260
|
+
// Clone or pull the repo into a temp dir.
|
|
261
|
+
if (fs.existsSync(path.join(tmpDir, ".git"))) {
|
|
262
|
+
console.log(dim(" pulling latest..."));
|
|
263
|
+
run(`git -C ${tmpDir} pull origin main`);
|
|
264
|
+
} else {
|
|
265
|
+
console.log(dim(" cloning repository..."));
|
|
266
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
267
|
+
run(`git clone --depth 1 ${REPO_URL} ${tmpDir}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
latestVersion = JSON.parse(
|
|
271
|
+
fs.readFileSync(path.join(tmpDir, "package.json"), "utf8")
|
|
272
|
+
).version;
|
|
273
|
+
} catch (err) {
|
|
274
|
+
console.error(red(`✗ Failed to fetch latest version: ${err.message}`));
|
|
275
|
+
process.exitCode = 1;
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
console.log(` ${dim("current:")} v${VERSION}`);
|
|
280
|
+
console.log(` ${dim("latest:")} v${latestVersion}`);
|
|
281
|
+
|
|
282
|
+
if (latestVersion === VERSION) {
|
|
283
|
+
console.log(green("\n✓ Already up to date."));
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (flags.check) {
|
|
288
|
+
console.log(yellow(`\n⬆ Update available: v${VERSION} → v${latestVersion}`));
|
|
289
|
+
console.log(dim(" Run `todo update` to install."));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
console.log(dim("\n installing..."));
|
|
295
|
+
execSync(`npm install -g ${tmpDir}`, { stdio: "inherit" });
|
|
296
|
+
console.log(green(`\n✓ Updated: v${VERSION} → v${latestVersion}`));
|
|
297
|
+
} catch (err) {
|
|
298
|
+
console.error(red(`\n✗ Install failed: ${err.message}`));
|
|
299
|
+
process.exitCode = 1;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function cmdHelp() {
|
|
304
|
+
console.log(
|
|
305
|
+
bold("todo") +
|
|
306
|
+
dim(" — a CLI todo list manager\n") +
|
|
307
|
+
`
|
|
308
|
+
${bold("USAGE")}
|
|
309
|
+
todo <command> [args] [flags]
|
|
310
|
+
|
|
311
|
+
${bold("COMMANDS")}
|
|
312
|
+
add <title> Add a new todo
|
|
313
|
+
list List todos (default command)
|
|
314
|
+
done <id> Mark a todo as done (done --all = complete every pending)
|
|
315
|
+
toggle <id> Toggle done / pending
|
|
316
|
+
edit <id> Edit a todo (use --title, -p, -t, -c, -d, --note)
|
|
317
|
+
delete <id> Delete a todo (alias: rm, remove)
|
|
318
|
+
search <keyword> Search title / tags / category / note
|
|
319
|
+
stats Show summary statistics
|
|
320
|
+
export [json|csv|md] Export all todos to stdout
|
|
321
|
+
archive Remove all completed todos
|
|
322
|
+
update Update todo-cli to the latest version (--check = check only)
|
|
323
|
+
clear Remove ALL todos
|
|
324
|
+
help Show this help
|
|
325
|
+
|
|
326
|
+
${bold("GLOBAL FLAGS")}
|
|
327
|
+
-h, --help Show help (works with any command)
|
|
328
|
+
-v, --version Show version
|
|
329
|
+
|
|
330
|
+
${bold("FLAGS")}
|
|
331
|
+
-p, --priority <v> Priority: high | medium | low (default: medium)
|
|
332
|
+
-t, --tags <v> Comma-separated tags
|
|
333
|
+
-c, --category <v> Category name
|
|
334
|
+
-d, --due <v> Due date YYYY-MM-DD
|
|
335
|
+
--note <v> Longer note / description
|
|
336
|
+
--title <v> New title (for edit)
|
|
337
|
+
--status <v> Filter: all | done | pending (list only)
|
|
338
|
+
--tag <v> Filter by tag (list only)
|
|
339
|
+
--sort <v> Sort: priority | created | due | id (default: priority)
|
|
340
|
+
--all Complete all pending (done only)
|
|
341
|
+
--check Check for update without installing (update only)
|
|
342
|
+
--format <v> Export format json|csv|md (export only)
|
|
343
|
+
|
|
344
|
+
${bold("EXAMPLES")}
|
|
345
|
+
todo add "Buy groceries" -p high -t shopping,weekend -c personal -d 2025-01-15
|
|
346
|
+
todo add "Write report" --note "Focus on Q4 metrics" -p high -c work
|
|
347
|
+
todo list --status pending --sort due
|
|
348
|
+
todo search report
|
|
349
|
+
todo done 3
|
|
350
|
+
todo done --all
|
|
351
|
+
todo edit 2 --title "Updated" -p low -d 2025-02-01
|
|
352
|
+
todo stats
|
|
353
|
+
todo export csv
|
|
354
|
+
todo archive
|
|
355
|
+
todo update --check
|
|
356
|
+
todo update
|
|
357
|
+
todo delete 5
|
|
358
|
+
`
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// --- dispatch --------------------------------------------------------------
|
|
363
|
+
const COMMANDS = {
|
|
364
|
+
add: cmdAdd,
|
|
365
|
+
list: cmdList,
|
|
366
|
+
ls: cmdList,
|
|
367
|
+
done: cmdDone,
|
|
368
|
+
toggle: cmdToggle,
|
|
369
|
+
edit: cmdEdit,
|
|
370
|
+
delete: cmdDelete,
|
|
371
|
+
rm: cmdDelete,
|
|
372
|
+
remove: cmdDelete,
|
|
373
|
+
search: cmdSearch,
|
|
374
|
+
stats: cmdStats,
|
|
375
|
+
export: cmdExport,
|
|
376
|
+
archive: cmdArchive,
|
|
377
|
+
update: cmdUpdate,
|
|
378
|
+
clear: cmdClear,
|
|
379
|
+
help: cmdHelp,
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
/** Main entry point. argv should be process.argv.slice(2). */
|
|
383
|
+
export function run(argv) {
|
|
384
|
+
// -h / --help anywhere (including bare `todo --help`) shows help and exits.
|
|
385
|
+
if (argv.some((a) => a === "-h" || a === "--help")) {
|
|
386
|
+
cmdHelp();
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// -v / --version prints the version (read from package.json) and exits.
|
|
391
|
+
if (argv.some((a) => a === "-v" || a === "--version")) {
|
|
392
|
+
console.log(`todo v${VERSION}`);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const store = createStore();
|
|
397
|
+
const todos = store.load();
|
|
398
|
+
|
|
399
|
+
const command = argv[0] && !argv[0].startsWith("-") ? argv[0] : "list";
|
|
400
|
+
const rest = command === "list" && argv[0] && !argv[0].startsWith("-")
|
|
401
|
+
? argv.slice(1)
|
|
402
|
+
: (command === "list" ? argv : argv.slice(1));
|
|
403
|
+
|
|
404
|
+
const handler = COMMANDS[command] ?? cmdHelp;
|
|
405
|
+
const parsed = parseArgs(rest);
|
|
406
|
+
|
|
407
|
+
if (command === "help") {
|
|
408
|
+
cmdHelp();
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
handler(todos, parsed);
|
|
414
|
+
store.save(todos);
|
|
415
|
+
} catch (err) {
|
|
416
|
+
console.error(red(`✗ ${err.message}`));
|
|
417
|
+
process.exitCode = 1;
|
|
418
|
+
}
|
|
419
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// store.js — JSON file persistence layer for todos.
|
|
2
|
+
// Keeps all data in a single JSON file under the user's home directory.
|
|
3
|
+
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_DATA_DIR = path.join(os.homedir(), ".todo-cli");
|
|
9
|
+
const DEFAULT_DATA_FILE = "todos.json";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create a store backed by a JSON file.
|
|
13
|
+
* @param {object} [opts]
|
|
14
|
+
* @param {string} [opts.dataDir] Directory for the data file.
|
|
15
|
+
* @param {string} [opts.dataFile] Filename inside the data dir.
|
|
16
|
+
*/
|
|
17
|
+
export function createStore(opts = {}) {
|
|
18
|
+
const dataDir = opts.dataDir ?? DEFAULT_DATA_DIR;
|
|
19
|
+
const dataFile = opts.dataFile ?? DEFAULT_DATA_FILE;
|
|
20
|
+
const filePath = path.join(dataDir, dataFile);
|
|
21
|
+
|
|
22
|
+
function ensureDir() {
|
|
23
|
+
if (!fs.existsSync(dataDir)) {
|
|
24
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Load all todos from disk. Returns [] when the file does not exist. */
|
|
29
|
+
function load() {
|
|
30
|
+
if (!fs.existsSync(filePath)) return [];
|
|
31
|
+
try {
|
|
32
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
33
|
+
const parsed = JSON.parse(raw);
|
|
34
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
35
|
+
} catch {
|
|
36
|
+
// Corrupt file — start fresh rather than crashing.
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Persist the todos array to disk. */
|
|
42
|
+
function save(todos) {
|
|
43
|
+
ensureDir();
|
|
44
|
+
fs.writeFileSync(filePath, JSON.stringify(todos, null, 2), "utf8");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { load, save, filePath };
|
|
48
|
+
}
|
package/src/todo.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// todo.js — domain logic for the todo list.
|
|
2
|
+
// Pure functions over a todos array; persistence is delegated to a store.
|
|
3
|
+
|
|
4
|
+
export const PRIORITIES = ["high", "medium", "low"];
|
|
5
|
+
const PRIORITY_RANK = { high: 0, medium: 1, low: 2 };
|
|
6
|
+
|
|
7
|
+
/** Normalise and validate a priority string. Defaults to "medium". */
|
|
8
|
+
export function normalizePriority(p) {
|
|
9
|
+
const v = String(p ?? "").toLowerCase().trim();
|
|
10
|
+
return PRIORITIES.includes(v) ? v : "medium";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Parse a comma-separated tag string into a deduplicated, lowercased array. */
|
|
14
|
+
export function parseTags(tags) {
|
|
15
|
+
if (!tags) return [];
|
|
16
|
+
const list = Array.isArray(tags)
|
|
17
|
+
? tags
|
|
18
|
+
: String(tags).split(",").map((t) => t.trim().toLowerCase());
|
|
19
|
+
return [...new Set(list.filter(Boolean))];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse & validate a due date (YYYY-MM-DD). Returns "" for empty input,
|
|
24
|
+
* or throws on an unparseable value.
|
|
25
|
+
*/
|
|
26
|
+
export function parseDueDate(due) {
|
|
27
|
+
if (!due) return "";
|
|
28
|
+
const s = String(due).trim();
|
|
29
|
+
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
30
|
+
if (!m) throw new Error(`Invalid due date "${s}". Use YYYY-MM-DD.`);
|
|
31
|
+
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
|
32
|
+
if (
|
|
33
|
+
d.getFullYear() !== Number(m[1]) ||
|
|
34
|
+
d.getMonth() !== Number(m[2]) - 1 ||
|
|
35
|
+
d.getDate() !== Number(m[3])
|
|
36
|
+
) {
|
|
37
|
+
throw new Error(`Invalid due date "${s}".`);
|
|
38
|
+
}
|
|
39
|
+
return s;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Compare a due date (YYYY-MM-DD) to today. Returns null | "overdue" | "today" | "soon". */
|
|
43
|
+
export function dueStatus(due) {
|
|
44
|
+
if (!due) return null;
|
|
45
|
+
const today = new Date();
|
|
46
|
+
const t = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
|
47
|
+
const d = new Date(due + "T00:00:00");
|
|
48
|
+
const diff = Math.round((d - t) / 86400000);
|
|
49
|
+
if (diff < 0) return "overdue";
|
|
50
|
+
if (diff === 0) return "today";
|
|
51
|
+
if (diff <= 3) return "soon";
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function nextId(todos) {
|
|
56
|
+
return todos.reduce((max, t) => Math.max(max, t.id), 0) + 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Add a new todo. Returns the created todo object. */
|
|
60
|
+
export function addTodo(todos, { title, priority, tags, category, due, note } = {}) {
|
|
61
|
+
if (!title || !title.trim()) {
|
|
62
|
+
throw new Error("Title is required.");
|
|
63
|
+
}
|
|
64
|
+
const todo = {
|
|
65
|
+
id: nextId(todos),
|
|
66
|
+
title: title.trim(),
|
|
67
|
+
done: false,
|
|
68
|
+
priority: normalizePriority(priority),
|
|
69
|
+
tags: parseTags(tags),
|
|
70
|
+
category: category ? String(category).trim() : "",
|
|
71
|
+
due: parseDueDate(due),
|
|
72
|
+
note: note ? String(note).trim() : "",
|
|
73
|
+
createdAt: new Date().toISOString(),
|
|
74
|
+
};
|
|
75
|
+
todos.push(todo);
|
|
76
|
+
return todo;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Find the index of a todo by id. Returns -1 if not found. */
|
|
80
|
+
function indexOf(todos, id) {
|
|
81
|
+
return todos.findIndex((t) => t.id === Number(id));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Delete a todo by id. Returns true if something was removed. */
|
|
85
|
+
export function deleteTodo(todos, id) {
|
|
86
|
+
const i = indexOf(todos, id);
|
|
87
|
+
if (i === -1) return false;
|
|
88
|
+
todos.splice(i, 1);
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Edit an existing todo by id with partial fields. Returns the updated todo or null. */
|
|
93
|
+
export function editTodo(todos, id, { title, priority, tags, category, due, note } = {}) {
|
|
94
|
+
const i = indexOf(todos, id);
|
|
95
|
+
if (i === -1) return null;
|
|
96
|
+
const todo = todos[i];
|
|
97
|
+
if (title != null) todo.title = title.trim();
|
|
98
|
+
if (priority != null) todo.priority = normalizePriority(priority);
|
|
99
|
+
if (tags != null) todo.tags = parseTags(tags);
|
|
100
|
+
if (category != null) todo.category = String(category).trim();
|
|
101
|
+
if (due !== undefined) todo.due = parseDueDate(due);
|
|
102
|
+
if (note !== undefined) todo.note = String(note).trim();
|
|
103
|
+
todo.updatedAt = new Date().toISOString();
|
|
104
|
+
return todo;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Toggle the done flag of a todo by id. Returns the updated todo or null. */
|
|
108
|
+
export function toggleTodo(todos, id) {
|
|
109
|
+
const i = indexOf(todos, id);
|
|
110
|
+
if (i === -1) return null;
|
|
111
|
+
todos[i].done = !todos[i].done;
|
|
112
|
+
todos[i].updatedAt = new Date().toISOString();
|
|
113
|
+
return todos[i];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Mark a todo done by id. Returns the updated todo or null. */
|
|
117
|
+
export function completeTodo(todos, id) {
|
|
118
|
+
const i = indexOf(todos, id);
|
|
119
|
+
if (i === -1) return null;
|
|
120
|
+
todos[i].done = true;
|
|
121
|
+
todos[i].updatedAt = new Date().toISOString();
|
|
122
|
+
return todos[i];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Filter and sort the todos.
|
|
127
|
+
* @param {Array} todos
|
|
128
|
+
* @param {object} [filter]
|
|
129
|
+
* @param {string} [filter.status] "all" | "done" | "pending"
|
|
130
|
+
* @param {string} [filter.category] Exact category match.
|
|
131
|
+
* @param {string} [filter.tag] Exact tag match.
|
|
132
|
+
* @param {string} [filter.priority] "high" | "medium" | "low"
|
|
133
|
+
* @param {string} [filter.sort] "priority" | "created" | "id"
|
|
134
|
+
*/
|
|
135
|
+
export function listTodos(todos, filter = {}) {
|
|
136
|
+
const status = filter.status ?? "all";
|
|
137
|
+
let result = todos.slice();
|
|
138
|
+
|
|
139
|
+
if (status === "done") result = result.filter((t) => t.done);
|
|
140
|
+
else if (status === "pending") result = result.filter((t) => !t.done);
|
|
141
|
+
|
|
142
|
+
if (filter.category) {
|
|
143
|
+
const c = String(filter.category).toLowerCase().trim();
|
|
144
|
+
result = result.filter((t) => t.category.toLowerCase() === c);
|
|
145
|
+
}
|
|
146
|
+
if (filter.tag) {
|
|
147
|
+
const tg = String(filter.tag).toLowerCase().trim();
|
|
148
|
+
result = result.filter((t) => t.tags.includes(tg));
|
|
149
|
+
}
|
|
150
|
+
if (filter.priority) {
|
|
151
|
+
const p = normalizePriority(filter.priority);
|
|
152
|
+
result = result.filter((t) => t.priority === p);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const sort = filter.sort ?? "priority";
|
|
156
|
+
switch (sort) {
|
|
157
|
+
case "created":
|
|
158
|
+
result.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
159
|
+
break;
|
|
160
|
+
case "id":
|
|
161
|
+
result.sort((a, b) => a.id - b.id);
|
|
162
|
+
break;
|
|
163
|
+
case "due":
|
|
164
|
+
result.sort((a, b) => {
|
|
165
|
+
if (!a.due && !b.due) return a.id - b.id;
|
|
166
|
+
if (!a.due) return 1;
|
|
167
|
+
if (!b.due) return -1;
|
|
168
|
+
return a.due.localeCompare(b.due);
|
|
169
|
+
});
|
|
170
|
+
break;
|
|
171
|
+
case "priority":
|
|
172
|
+
default:
|
|
173
|
+
result.sort((a, b) => {
|
|
174
|
+
const d = PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority];
|
|
175
|
+
return d !== 0 ? d : a.id - b.id;
|
|
176
|
+
});
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Remove all todos. Returns the count removed. */
|
|
183
|
+
export function clearTodos(todos) {
|
|
184
|
+
const n = todos.length;
|
|
185
|
+
todos.length = 0;
|
|
186
|
+
return n;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Search todos by keyword across title, tags, category, and note. */
|
|
190
|
+
export function searchTodos(todos, keyword) {
|
|
191
|
+
if (!keyword) return [];
|
|
192
|
+
const kw = String(keyword).toLowerCase().trim();
|
|
193
|
+
return todos.filter((t) => {
|
|
194
|
+
return (
|
|
195
|
+
t.title.toLowerCase().includes(kw) ||
|
|
196
|
+
t.category.toLowerCase().includes(kw) ||
|
|
197
|
+
t.note.toLowerCase().includes(kw) ||
|
|
198
|
+
t.tags.some((tag) => tag.includes(kw))
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Compute summary statistics over all todos. */
|
|
204
|
+
export function getStats(todos) {
|
|
205
|
+
const stats = {
|
|
206
|
+
total: todos.length,
|
|
207
|
+
done: 0,
|
|
208
|
+
pending: 0,
|
|
209
|
+
overdue: 0,
|
|
210
|
+
byPriority: { high: 0, medium: 0, low: 0 },
|
|
211
|
+
byCategory: {},
|
|
212
|
+
};
|
|
213
|
+
for (const t of todos) {
|
|
214
|
+
if (t.done) stats.done++;
|
|
215
|
+
else stats.pending++;
|
|
216
|
+
stats.byPriority[t.priority] = (stats.byPriority[t.priority] ?? 0) + 1;
|
|
217
|
+
if (t.category) {
|
|
218
|
+
stats.byCategory[t.category] = (stats.byCategory[t.category] ?? 0) + 1;
|
|
219
|
+
}
|
|
220
|
+
if (!t.done && dueStatus(t.due) === "overdue") stats.overdue++;
|
|
221
|
+
}
|
|
222
|
+
return stats;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Mark ALL pending todos as done. Returns the count completed. */
|
|
226
|
+
export function completeAll(todos) {
|
|
227
|
+
let n = 0;
|
|
228
|
+
const now = new Date().toISOString();
|
|
229
|
+
for (const t of todos) {
|
|
230
|
+
if (!t.done) {
|
|
231
|
+
t.done = true;
|
|
232
|
+
t.updatedAt = now;
|
|
233
|
+
n++;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return n;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Remove all completed todos. Returns the count archived (removed). */
|
|
240
|
+
export function archiveCompleted(todos) {
|
|
241
|
+
let n = 0;
|
|
242
|
+
for (let i = todos.length - 1; i >= 0; i--) {
|
|
243
|
+
if (todos[i].done) {
|
|
244
|
+
todos.splice(i, 1);
|
|
245
|
+
n++;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return n;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Export todos to a string in the given format: "json" | "csv" | "md". */
|
|
252
|
+
export function exportTodos(todos, format = "json") {
|
|
253
|
+
switch (format) {
|
|
254
|
+
case "csv":
|
|
255
|
+
return exportCSV(todos);
|
|
256
|
+
case "md":
|
|
257
|
+
return exportMarkdown(todos);
|
|
258
|
+
case "json":
|
|
259
|
+
default:
|
|
260
|
+
return JSON.stringify(todos, null, 2);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function exportCSV(todos) {
|
|
265
|
+
const cols = ["id", "title", "done", "priority", "category", "tags", "due", "note"];
|
|
266
|
+
const esc = (v) => {
|
|
267
|
+
const s = String(v ?? "");
|
|
268
|
+
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
269
|
+
};
|
|
270
|
+
const lines = [cols.join(",")];
|
|
271
|
+
for (const t of todos) {
|
|
272
|
+
lines.push(
|
|
273
|
+
[t.id, t.title, t.done, t.priority, t.category, t.tags.join(";"), t.due, t.note]
|
|
274
|
+
.map(esc)
|
|
275
|
+
.join(",")
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
return lines.join("\n");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function exportMarkdown(todos) {
|
|
282
|
+
if (todos.length === 0) return "_(no todos)_";
|
|
283
|
+
const lines = ["| ID | Status | Priority | Category | Tags | Due | Title |",
|
|
284
|
+
"|----|--------|----------|----------|------|-----|-------|"];
|
|
285
|
+
for (const t of todos) {
|
|
286
|
+
lines.push(
|
|
287
|
+
`| ${t.id} | ${t.done ? "✓" : "○"} | ${t.priority} | ${t.category || "-"} | ${t.tags.join(", ") || "-"} | ${t.due || "-"} | ${t.title} |`
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
return lines.join("\n");
|
|
291
|
+
}
|