import http from "node:http";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createStore } from "./store.js";
import {
addTodo,
editTodo,
deleteTodo,
toggleTodo,
completeTodo,
completeAll,
listTodos,
clearTodos,
searchTodos,
getStats,
archiveCompleted,
exportTodos,
migrateAll,
getTodo,
addSubtask,
toggleSubtask,
deleteSubtask,
editSubtask,
setRecurring,
removeRecurring,
addDependency,
removeDependency,
startTimer,
stopTimer,
logTime,
getActiveTimer,
togglePin,
duplicateTodo,
bulkComplete,
bulkDelete,
bulkPriority,
bulkCategory,
bulkAssign,
reorderTodo,
} from "./todo.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const WEB_DIR = path.join(__dirname, "..", "web");
const store = createStore();
let todos = migrateAll(store.load());
function save() {
store.save(todos);
}
function sendJSON(res, data, status = 200) {
res.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
});
res.end(JSON.stringify(data));
}
function sendStatic(res, filePath, contentType) {
try {
const data = fs.readFileSync(filePath);
res.writeHead(200, { "Content-Type": contentType });
res.end(data);
} catch {
sendJSON(res, { error: "Not found" }, 404);
}
}
function readBody(req) {
return new Promise((resolve) => {
let body = "";
req.on("data", (chunk) => (body += chunk));
req.on("end", () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch {
resolve({});
}
});
});
}
const MIME = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".ico": "image/x-icon",
".woff2": "font/woff2",
};
async function handleAPI(req, res, pathname, method) {
const parts = pathname.replace(/^\/api\//, "").split("/").filter(Boolean);
const resource = parts[0];
const id = parts[1] ? Number(parts[1]) : null;
const sub = parts[2];
const subId = parts[3] ? Number(parts[3]) : null;
const body = method === "POST" || method === "PUT" || method === "PATCH" ? await readBody(req) : {};
try {
if (resource === "todos" && method === "GET" && id === null) {
const result = listTodos(todos, {
status: req.url.searchParams.get("status") || undefined,
category: req.url.searchParams.get("category") || undefined,
tag: req.url.searchParams.get("tag") || undefined,
priority: req.url.searchParams.get("priority") || undefined,
assignee: req.url.searchParams.get("assignee") || undefined,
sort: req.url.searchParams.get("sort") || undefined,
overdue: req.url.searchParams.get("overdue") === "true",
dueToday: req.url.searchParams.get("dueToday") === "true",
pinnedFirst: true,
});
return sendJSON(res, result);
}
if (resource === "todos" && method === "POST" && id === null) {
const todo = addTodo(todos, body);
save();
return sendJSON(res, todo, 201);
}
if (resource === "todos" && method === "GET" && id !== null && sub === undefined) {
const todo = getTodo(todos, id);
if (!todo) return sendJSON(res, { error: "Not found" }, 404);
return sendJSON(res, todo);
}
if (resource === "todos" && (method === "PUT" || method === "PATCH") && id !== null && sub === undefined) {
const todo = editTodo(todos, id, body);
if (!todo) return sendJSON(res, { error: "Not found" }, 404);
save();
return sendJSON(res, todo);
}
if (resource === "todos" && method === "DELETE" && id !== null && sub === undefined) {
const ok = deleteTodo(todos, id);
if (!ok) return sendJSON(res, { error: "Not found" }, 404);
save();
return sendJSON(res, { ok: true });
}
if (resource === "todos" && method === "POST" && id !== null && sub === "toggle") {
const todo = toggleTodo(todos, id);
if (!todo) return sendJSON(res, { error: "Not found" }, 404);
save();
return sendJSON(res, todo);
}
if (resource === "todos" && method === "POST" && id !== null && sub === "complete") {
const todo = completeTodo(todos, id);
if (!todo) return sendJSON(res, { error: "Not found" }, 404);
save();
return sendJSON(res, todo);
}
if (resource === "todos" && method === "POST" && id !== null && sub === "pin") {
const todo = togglePin(todos, id);
if (!todo) return sendJSON(res, { error: "Not found" }, 404);
save();
return sendJSON(res, todo);
}
if (resource === "todos" && method === "POST" && id !== null && sub === "duplicate") {
const todo = duplicateTodo(todos, id);
if (!todo) return sendJSON(res, { error: "Not found" }, 404);
save();
return sendJSON(res, todo, 201);
}
if (resource === "todos" && method === "POST" && id !== null && sub === "reorder") {
const todo = reorderTodo(todos, id, body.order);
if (!todo) return sendJSON(res, { error: "Not found" }, 404);
save();
return sendJSON(res, todo);
}
if (resource === "todos" && method === "POST" && id !== null && sub === "subtasks" && subId === null) {
const st = addSubtask(todos, id, body.title);
if (!st) return sendJSON(res, { error: "Not found" }, 404);
save();
return sendJSON(res, st, 201);
}
if (resource === "todos" && method === "POST" && id !== null && sub === "subtasks" && subId !== null && parts[4] === "toggle") {
const st = toggleSubtask(todos, id, subId);
if (!st) return sendJSON(res, { error: "Not found" }, 404);
save();
return sendJSON(res, st);
}
if (resource === "todos" && method === "PATCH" && id !== null && sub === "subtasks" && subId !== null) {
const st = editSubtask(todos, id, subId, body.title);
if (!st) return sendJSON(res, { error: "Not found" }, 404);
save();
return sendJSON(res, st);
}
if (resource === "todos" && method === "DELETE" && id !== null && sub === "subtasks" && subId !== null) {
const ok = deleteSubtask(todos, id, subId);
if (!ok) return sendJSON(res, { error: "Not found" }, 404);
save();
return sendJSON(res, { ok: true });
}
if (resource === "todos" && method === "POST" && id !== null && sub === "recurring") {
const todo = body.freq ? setRecurring(todos, id, body.freq, body.interval) : removeRecurring(todos, id);
if (!todo) return sendJSON(res, { error: "Not found" }, 404);
save();
return sendJSON(res, todo);
}
if (resource === "todos" && method === "POST" && id !== null && sub === "dependencies") {
const todo = addDependency(todos, id, body.depId);
if (!todo) return sendJSON(res, { error: "Not found" }, 404);
save();
return sendJSON(res, todo);
}
if (resource === "todos" && method === "DELETE" && id !== null && sub === "dependencies" && subId !== null) {
const todo = removeDependency(todos, id, subId);
if (!todo) return sendJSON(res, { error: "Not found" }, 404);
save();
return sendJSON(res, todo);
}
if (resource === "todos" && method === "POST" && id !== null && sub === "timer" && parts[3] === "start") {
const todo = startTimer(todos, id);
if (!todo) return sendJSON(res, { error: "Not found" }, 404);
save();
return sendJSON(res, todo);
}
if (resource === "todos" && method === "POST" && id !== null && sub === "timer" && parts[3] === "stop") {
const todo = stopTimer(todos, id);
if (!todo) return sendJSON(res, { error: "Not found" }, 404);
save();
return sendJSON(res, todo);
}
if (resource === "todos" && method === "POST" && id !== null && sub === "timer" && parts[3] === "log") {
const todo = logTime(todos, id, body.minutes);
if (!todo) return sendJSON(res, { error: "Not found" }, 404);
save();
return sendJSON(res, todo);
}
if (resource === "stats" && method === "GET") {
return sendJSON(res, getStats(todos));
}
if (resource === "search" && method === "GET") {
const q = req.url.searchParams.get("q") || "";
return sendJSON(res, searchTodos(todos, q));
}
if (resource === "timer" && method === "GET") {
return sendJSON(res, getActiveTimer(todos));
}
if (resource === "bulk" && method === "POST") {
const { action, ids } = body;
let result;
switch (action) {
case "complete": result = { count: bulkComplete(todos, ids) }; break;
case "delete": result = { count: bulkDelete(todos, ids) }; break;
case "priority": result = { count: bulkPriority(todos, ids, body.priority) }; break;
case "category": result = { count: bulkCategory(todos, ids, body.category) }; break;
case "assign": result = { count: bulkAssign(todos, ids, body.assignee) }; break;
default: return sendJSON(res, { error: "Unknown bulk action" }, 400);
}
save();
return sendJSON(res, result);
}
if (resource === "complete-all" && method === "POST") {
const n = completeAll(todos);
save();
return sendJSON(res, { count: n });
}
if (resource === "archive" && method === "POST") {
const n = archiveCompleted(todos);
save();
return sendJSON(res, { count: n });
}
if (resource === "clear" && method === "POST") {
const n = clearTodos(todos);
save();
return sendJSON(res, { count: n });
}
if (resource === "export" && method === "GET") {
const format = req.url.searchParams.get("format") || "json";
return sendJSON(res, { data: exportTodos(todos, format) });
}
return sendJSON(res, { error: "Not found" }, 404);
} catch (err) {
return sendJSON(res, { error: err.message }, 400);
}
}
export function startServer(port = 3456) {
const server = http.createServer(async (req, res) => {
req.url = new URL(req.url, `http://localhost:${port}`);
const pathname = req.url.pathname;
const method = req.method;
if (method === "OPTIONS") {
res.writeHead(204, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
});
return res.end();
}
if (pathname.startsWith("/api/")) {
return handleAPI(req, res, pathname, method);
}
let filePath = pathname === "/" ? "/index.html" : pathname;
const fullPath = path.join(WEB_DIR, filePath);
if (!fullPath.startsWith(WEB_DIR)) {
return sendJSON(res, { error: "Forbidden" }, 403);
}
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) {
const indexPath = path.join(fullPath, "index.html");
if (fs.existsSync(indexPath)) {
return sendStatic(res, indexPath, MIME[".html"]);
}
}
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
const ext = path.extname(fullPath);
const contentType = MIME[ext] || "application/octet-stream";
return sendStatic(res, fullPath, contentType);
}
const indexFallback = path.join(WEB_DIR, "index.html");
if (fs.existsSync(indexFallback)) {
return sendStatic(res, indexFallback, MIME[".html"]);
}
return sendJSON(res, { error: "Not found" }, 404);
});
server.listen(port, () => {
console.log(`\n ╔══════════════════════════════════════════════════╗`);
console.log(` ║ ${"todo-cli Web UI".padEnd(46)} ║`);
console.log(` ║${" ".repeat(46)} ║`);
console.log(` ║ ${(`➜ http://localhost:${port}`).padEnd(46)} ║`);
console.log(` ║${" ".repeat(46)} ║`);
console.log(` ║ ${"Press Ctrl+C to stop".padEnd(46)} ║`);
console.log(` ╚══════════════════════════════════════════════════╝\n`);
});
return server;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const port = Number(process.argv[2]) || 3456;
startServer(port);
}