@walknpm/todo-cli 1.2.0 → 2.0.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 +200 -144
- package/README.zh-CN.md +150 -153
- package/package.json +19 -6
- package/src/cli.js +396 -24
- package/src/server.js +422 -0
- package/src/todo.js +544 -35
- package/web/css/style.css +804 -0
- package/web/index.html +216 -0
- package/web/js/app.js +934 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
// server.js — zero-dependency HTTP server for the todo-cli web UI.
|
|
2
|
+
// Uses only Node.js built-in modules. Serves the REST API and static web files.
|
|
3
|
+
|
|
4
|
+
import http from "node:http";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { createStore } from "./store.js";
|
|
9
|
+
import {
|
|
10
|
+
addTodo,
|
|
11
|
+
editTodo,
|
|
12
|
+
deleteTodo,
|
|
13
|
+
toggleTodo,
|
|
14
|
+
completeTodo,
|
|
15
|
+
completeAll,
|
|
16
|
+
listTodos,
|
|
17
|
+
clearTodos,
|
|
18
|
+
searchTodos,
|
|
19
|
+
getStats,
|
|
20
|
+
archiveCompleted,
|
|
21
|
+
exportTodos,
|
|
22
|
+
migrateAll,
|
|
23
|
+
getTodo,
|
|
24
|
+
addSubtask,
|
|
25
|
+
toggleSubtask,
|
|
26
|
+
deleteSubtask,
|
|
27
|
+
editSubtask,
|
|
28
|
+
setRecurring,
|
|
29
|
+
removeRecurring,
|
|
30
|
+
addDependency,
|
|
31
|
+
removeDependency,
|
|
32
|
+
startTimer,
|
|
33
|
+
stopTimer,
|
|
34
|
+
logTime,
|
|
35
|
+
getActiveTimer,
|
|
36
|
+
togglePin,
|
|
37
|
+
duplicateTodo,
|
|
38
|
+
bulkComplete,
|
|
39
|
+
bulkDelete,
|
|
40
|
+
bulkPriority,
|
|
41
|
+
bulkCategory,
|
|
42
|
+
bulkAssign,
|
|
43
|
+
reorderTodo,
|
|
44
|
+
} from "./todo.js";
|
|
45
|
+
|
|
46
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
47
|
+
const WEB_DIR = path.join(__dirname, "..", "web");
|
|
48
|
+
const store = createStore();
|
|
49
|
+
let todos = migrateAll(store.load());
|
|
50
|
+
|
|
51
|
+
function save() {
|
|
52
|
+
store.save(todos);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── HTTP helpers ──────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
function sendJSON(res, data, status = 200) {
|
|
58
|
+
res.writeHead(status, {
|
|
59
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
60
|
+
"Access-Control-Allow-Origin": "*",
|
|
61
|
+
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
62
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
63
|
+
});
|
|
64
|
+
res.end(JSON.stringify(data));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sendStatic(res, filePath, contentType) {
|
|
68
|
+
try {
|
|
69
|
+
const data = fs.readFileSync(filePath);
|
|
70
|
+
res.writeHead(200, { "Content-Type": contentType });
|
|
71
|
+
res.end(data);
|
|
72
|
+
} catch {
|
|
73
|
+
sendJSON(res, { error: "Not found" }, 404);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readBody(req) {
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
let body = "";
|
|
80
|
+
req.on("data", (chunk) => (body += chunk));
|
|
81
|
+
req.on("end", () => {
|
|
82
|
+
try {
|
|
83
|
+
resolve(body ? JSON.parse(body) : {});
|
|
84
|
+
} catch {
|
|
85
|
+
resolve({});
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const MIME = {
|
|
92
|
+
".html": "text/html; charset=utf-8",
|
|
93
|
+
".css": "text/css; charset=utf-8",
|
|
94
|
+
".js": "application/javascript; charset=utf-8",
|
|
95
|
+
".json": "application/json; charset=utf-8",
|
|
96
|
+
".svg": "image/svg+xml",
|
|
97
|
+
".png": "image/png",
|
|
98
|
+
".ico": "image/x-icon",
|
|
99
|
+
".woff2": "font/woff2",
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// ─── API routes ────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
async function handleAPI(req, res, pathname, method) {
|
|
105
|
+
const parts = pathname.replace(/^\/api\//, "").split("/").filter(Boolean);
|
|
106
|
+
const resource = parts[0];
|
|
107
|
+
const id = parts[1] ? Number(parts[1]) : null;
|
|
108
|
+
const sub = parts[2]; // sub-resource
|
|
109
|
+
const subId = parts[3] ? Number(parts[3]) : null;
|
|
110
|
+
const body = method === "POST" || method === "PUT" || method === "PATCH" ? await readBody(req) : {};
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
// GET /api/todos
|
|
114
|
+
if (resource === "todos" && method === "GET" && id === null) {
|
|
115
|
+
const result = listTodos(todos, {
|
|
116
|
+
status: req.url.searchParams.get("status") || undefined,
|
|
117
|
+
category: req.url.searchParams.get("category") || undefined,
|
|
118
|
+
tag: req.url.searchParams.get("tag") || undefined,
|
|
119
|
+
priority: req.url.searchParams.get("priority") || undefined,
|
|
120
|
+
assignee: req.url.searchParams.get("assignee") || undefined,
|
|
121
|
+
sort: req.url.searchParams.get("sort") || undefined,
|
|
122
|
+
overdue: req.url.searchParams.get("overdue") === "true",
|
|
123
|
+
dueToday: req.url.searchParams.get("dueToday") === "true",
|
|
124
|
+
pinnedFirst: true,
|
|
125
|
+
});
|
|
126
|
+
return sendJSON(res, result);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// POST /api/todos
|
|
130
|
+
if (resource === "todos" && method === "POST" && id === null) {
|
|
131
|
+
const todo = addTodo(todos, body);
|
|
132
|
+
save();
|
|
133
|
+
return sendJSON(res, todo, 201);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// GET /api/todos/:id
|
|
137
|
+
if (resource === "todos" && method === "GET" && id !== null && sub === undefined) {
|
|
138
|
+
const todo = getTodo(todos, id);
|
|
139
|
+
if (!todo) return sendJSON(res, { error: "Not found" }, 404);
|
|
140
|
+
return sendJSON(res, todo);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// PUT/PATCH /api/todos/:id
|
|
144
|
+
if (resource === "todos" && (method === "PUT" || method === "PATCH") && id !== null && sub === undefined) {
|
|
145
|
+
const todo = editTodo(todos, id, body);
|
|
146
|
+
if (!todo) return sendJSON(res, { error: "Not found" }, 404);
|
|
147
|
+
save();
|
|
148
|
+
return sendJSON(res, todo);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// DELETE /api/todos/:id
|
|
152
|
+
if (resource === "todos" && method === "DELETE" && id !== null && sub === undefined) {
|
|
153
|
+
const ok = deleteTodo(todos, id);
|
|
154
|
+
if (!ok) return sendJSON(res, { error: "Not found" }, 404);
|
|
155
|
+
save();
|
|
156
|
+
return sendJSON(res, { ok: true });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// POST /api/todos/:id/toggle
|
|
160
|
+
if (resource === "todos" && method === "POST" && id !== null && sub === "toggle") {
|
|
161
|
+
const todo = toggleTodo(todos, id);
|
|
162
|
+
if (!todo) return sendJSON(res, { error: "Not found" }, 404);
|
|
163
|
+
save();
|
|
164
|
+
return sendJSON(res, todo);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// POST /api/todos/:id/complete
|
|
168
|
+
if (resource === "todos" && method === "POST" && id !== null && sub === "complete") {
|
|
169
|
+
const todo = completeTodo(todos, id);
|
|
170
|
+
if (!todo) return sendJSON(res, { error: "Not found" }, 404);
|
|
171
|
+
save();
|
|
172
|
+
return sendJSON(res, todo);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// POST /api/todos/:id/pin
|
|
176
|
+
if (resource === "todos" && method === "POST" && id !== null && sub === "pin") {
|
|
177
|
+
const todo = togglePin(todos, id);
|
|
178
|
+
if (!todo) return sendJSON(res, { error: "Not found" }, 404);
|
|
179
|
+
save();
|
|
180
|
+
return sendJSON(res, todo);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// POST /api/todos/:id/duplicate
|
|
184
|
+
if (resource === "todos" && method === "POST" && id !== null && sub === "duplicate") {
|
|
185
|
+
const todo = duplicateTodo(todos, id);
|
|
186
|
+
if (!todo) return sendJSON(res, { error: "Not found" }, 404);
|
|
187
|
+
save();
|
|
188
|
+
return sendJSON(res, todo, 201);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// POST /api/todos/:id/reorder
|
|
192
|
+
if (resource === "todos" && method === "POST" && id !== null && sub === "reorder") {
|
|
193
|
+
const todo = reorderTodo(todos, id, body.order);
|
|
194
|
+
if (!todo) return sendJSON(res, { error: "Not found" }, 404);
|
|
195
|
+
save();
|
|
196
|
+
return sendJSON(res, todo);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Subtasks ──
|
|
200
|
+
// POST /api/todos/:id/subtasks
|
|
201
|
+
if (resource === "todos" && method === "POST" && id !== null && sub === "subtasks" && subId === null) {
|
|
202
|
+
const st = addSubtask(todos, id, body.title);
|
|
203
|
+
if (!st) return sendJSON(res, { error: "Not found" }, 404);
|
|
204
|
+
save();
|
|
205
|
+
return sendJSON(res, st, 201);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// POST /api/todos/:id/subtasks/:sid/toggle
|
|
209
|
+
if (resource === "todos" && method === "POST" && id !== null && sub === "subtasks" && subId !== null && parts[4] === "toggle") {
|
|
210
|
+
const st = toggleSubtask(todos, id, subId);
|
|
211
|
+
if (!st) return sendJSON(res, { error: "Not found" }, 404);
|
|
212
|
+
save();
|
|
213
|
+
return sendJSON(res, st);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// PATCH /api/todos/:id/subtasks/:sid
|
|
217
|
+
if (resource === "todos" && method === "PATCH" && id !== null && sub === "subtasks" && subId !== null) {
|
|
218
|
+
const st = editSubtask(todos, id, subId, body.title);
|
|
219
|
+
if (!st) return sendJSON(res, { error: "Not found" }, 404);
|
|
220
|
+
save();
|
|
221
|
+
return sendJSON(res, st);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// DELETE /api/todos/:id/subtasks/:sid
|
|
225
|
+
if (resource === "todos" && method === "DELETE" && id !== null && sub === "subtasks" && subId !== null) {
|
|
226
|
+
const ok = deleteSubtask(todos, id, subId);
|
|
227
|
+
if (!ok) return sendJSON(res, { error: "Not found" }, 404);
|
|
228
|
+
save();
|
|
229
|
+
return sendJSON(res, { ok: true });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── Recurring ──
|
|
233
|
+
// POST /api/todos/:id/recurring
|
|
234
|
+
if (resource === "todos" && method === "POST" && id !== null && sub === "recurring") {
|
|
235
|
+
const todo = body.freq ? setRecurring(todos, id, body.freq, body.interval) : removeRecurring(todos, id);
|
|
236
|
+
if (!todo) return sendJSON(res, { error: "Not found" }, 404);
|
|
237
|
+
save();
|
|
238
|
+
return sendJSON(res, todo);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── Dependencies ──
|
|
242
|
+
// POST /api/todos/:id/dependencies
|
|
243
|
+
if (resource === "todos" && method === "POST" && id !== null && sub === "dependencies") {
|
|
244
|
+
const todo = addDependency(todos, id, body.depId);
|
|
245
|
+
if (!todo) return sendJSON(res, { error: "Not found" }, 404);
|
|
246
|
+
save();
|
|
247
|
+
return sendJSON(res, todo);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// DELETE /api/todos/:id/dependencies/:depId
|
|
251
|
+
if (resource === "todos" && method === "DELETE" && id !== null && sub === "dependencies" && subId !== null) {
|
|
252
|
+
const todo = removeDependency(todos, id, subId);
|
|
253
|
+
if (!todo) return sendJSON(res, { error: "Not found" }, 404);
|
|
254
|
+
save();
|
|
255
|
+
return sendJSON(res, todo);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Timer ──
|
|
259
|
+
// POST /api/todos/:id/timer/start
|
|
260
|
+
if (resource === "todos" && method === "POST" && id !== null && sub === "timer" && parts[3] === "start") {
|
|
261
|
+
const todo = startTimer(todos, id);
|
|
262
|
+
if (!todo) return sendJSON(res, { error: "Not found" }, 404);
|
|
263
|
+
save();
|
|
264
|
+
return sendJSON(res, todo);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// POST /api/todos/:id/timer/stop
|
|
268
|
+
if (resource === "todos" && method === "POST" && id !== null && sub === "timer" && parts[3] === "stop") {
|
|
269
|
+
const todo = stopTimer(todos, id);
|
|
270
|
+
if (!todo) return sendJSON(res, { error: "Not found" }, 404);
|
|
271
|
+
save();
|
|
272
|
+
return sendJSON(res, todo);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// POST /api/todos/:id/timer/log
|
|
276
|
+
if (resource === "todos" && method === "POST" && id !== null && sub === "timer" && parts[3] === "log") {
|
|
277
|
+
const todo = logTime(todos, id, body.minutes);
|
|
278
|
+
if (!todo) return sendJSON(res, { error: "Not found" }, 404);
|
|
279
|
+
save();
|
|
280
|
+
return sendJSON(res, todo);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── Stats ──
|
|
284
|
+
if (resource === "stats" && method === "GET") {
|
|
285
|
+
return sendJSON(res, getStats(todos));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── Search ──
|
|
289
|
+
if (resource === "search" && method === "GET") {
|
|
290
|
+
const q = req.url.searchParams.get("q") || "";
|
|
291
|
+
return sendJSON(res, searchTodos(todos, q));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── Timer active ──
|
|
295
|
+
if (resource === "timer" && method === "GET") {
|
|
296
|
+
return sendJSON(res, getActiveTimer(todos));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── Bulk ──
|
|
300
|
+
if (resource === "bulk" && method === "POST") {
|
|
301
|
+
const { action, ids } = body;
|
|
302
|
+
let result;
|
|
303
|
+
switch (action) {
|
|
304
|
+
case "complete": result = { count: bulkComplete(todos, ids) }; break;
|
|
305
|
+
case "delete": result = { count: bulkDelete(todos, ids) }; break;
|
|
306
|
+
case "priority": result = { count: bulkPriority(todos, ids, body.priority) }; break;
|
|
307
|
+
case "category": result = { count: bulkCategory(todos, ids, body.category) }; break;
|
|
308
|
+
case "assign": result = { count: bulkAssign(todos, ids, body.assignee) }; break;
|
|
309
|
+
default: return sendJSON(res, { error: "Unknown bulk action" }, 400);
|
|
310
|
+
}
|
|
311
|
+
save();
|
|
312
|
+
return sendJSON(res, result);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── Complete all ──
|
|
316
|
+
if (resource === "complete-all" && method === "POST") {
|
|
317
|
+
const n = completeAll(todos);
|
|
318
|
+
save();
|
|
319
|
+
return sendJSON(res, { count: n });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── Archive ──
|
|
323
|
+
if (resource === "archive" && method === "POST") {
|
|
324
|
+
const n = archiveCompleted(todos);
|
|
325
|
+
save();
|
|
326
|
+
return sendJSON(res, { count: n });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ── Clear ──
|
|
330
|
+
if (resource === "clear" && method === "POST") {
|
|
331
|
+
const n = clearTodos(todos);
|
|
332
|
+
save();
|
|
333
|
+
return sendJSON(res, { count: n });
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── Export ──
|
|
337
|
+
if (resource === "export" && method === "GET") {
|
|
338
|
+
const format = req.url.searchParams.get("format") || "json";
|
|
339
|
+
return sendJSON(res, { data: exportTodos(todos, format) });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return sendJSON(res, { error: "Not found" }, 404);
|
|
343
|
+
} catch (err) {
|
|
344
|
+
return sendJSON(res, { error: err.message }, 400);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ─── Main server ───────────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
export function startServer(port = 3456) {
|
|
351
|
+
const server = http.createServer(async (req, res) => {
|
|
352
|
+
// Parse URL
|
|
353
|
+
req.url = new URL(req.url, `http://localhost:${port}`);
|
|
354
|
+
const pathname = req.url.pathname;
|
|
355
|
+
const method = req.method;
|
|
356
|
+
|
|
357
|
+
// CORS preflight
|
|
358
|
+
if (method === "OPTIONS") {
|
|
359
|
+
res.writeHead(204, {
|
|
360
|
+
"Access-Control-Allow-Origin": "*",
|
|
361
|
+
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
362
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
363
|
+
});
|
|
364
|
+
return res.end();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// API routes
|
|
368
|
+
if (pathname.startsWith("/api/")) {
|
|
369
|
+
return handleAPI(req, res, pathname, method);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Static file serving
|
|
373
|
+
let filePath = pathname === "/" ? "/index.html" : pathname;
|
|
374
|
+
const fullPath = path.join(WEB_DIR, filePath);
|
|
375
|
+
|
|
376
|
+
// Prevent path traversal
|
|
377
|
+
if (!fullPath.startsWith(WEB_DIR)) {
|
|
378
|
+
return sendJSON(res, { error: "Forbidden" }, 403);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Serve directory with index.html
|
|
382
|
+
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) {
|
|
383
|
+
const indexPath = path.join(fullPath, "index.html");
|
|
384
|
+
if (fs.existsSync(indexPath)) {
|
|
385
|
+
return sendStatic(res, indexPath, MIME[".html"]);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Serve file
|
|
390
|
+
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
|
|
391
|
+
const ext = path.extname(fullPath);
|
|
392
|
+
const contentType = MIME[ext] || "application/octet-stream";
|
|
393
|
+
return sendStatic(res, fullPath, contentType);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// SPA fallback: serve index.html for unknown routes
|
|
397
|
+
const indexFallback = path.join(WEB_DIR, "index.html");
|
|
398
|
+
if (fs.existsSync(indexFallback)) {
|
|
399
|
+
return sendStatic(res, indexFallback, MIME[".html"]);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return sendJSON(res, { error: "Not found" }, 404);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
server.listen(port, () => {
|
|
406
|
+
console.log(`\n ╔══════════════════════════════════════════════════╗`);
|
|
407
|
+
console.log(` ║ ${"todo-cli Web UI".padEnd(46)} ║`);
|
|
408
|
+
console.log(` ║${" ".repeat(46)} ║`);
|
|
409
|
+
console.log(` ║ ${(`➜ http://localhost:${port}`).padEnd(46)} ║`);
|
|
410
|
+
console.log(` ║${" ".repeat(46)} ║`);
|
|
411
|
+
console.log(` ║ ${"Press Ctrl+C to stop".padEnd(46)} ║`);
|
|
412
|
+
console.log(` ╚══════════════════════════════════════════════════╝\n`);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
return server;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Allow running directly: node src/server.js [port]
|
|
419
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
420
|
+
const port = Number(process.argv[2]) || 3456;
|
|
421
|
+
startServer(port);
|
|
422
|
+
}
|