@walknpm/todo-cli 1.1.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/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
+ }