Files
task-inventory/server.mjs
mikael-lovqvists-claude-agent b7ee5006d2 Add hierarchical tasks with tree and flat mode
- parent_id on tasks; roots shown by default, others expandable
- Expand/collapse per node with expand/collapse button
- Sub button creates subtask directly from the row
- Make subtask of button in edit dialog with searchable picker
- Remove parent clears the parent link in the dialog
- Flat mode toggle shows all tasks indented by depth
- Tree filter shows nodes whose descendants match, not only direct matches
- Deletion blocked if task has subtasks (client + server)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 18:55:14 +00:00

115 lines
4.3 KiB
JavaScript

process.on('unhandledRejection', (reason) => { console.error('[unhandledRejection]', reason); });
process.on('uncaughtException', (err) => { console.error('[uncaughtException]', err); process.exit(1); });
import express from 'express';
import { next_id } from './lib/ids.mjs';
import { list_tasks, get_task, set_task, delete_task, has_child_tasks } from './lib/storage.mjs';
import { get_config } from './lib/config.mjs';
const app = express();
app.use(express.json());
app.use(express.static(new URL('./public/', import.meta.url).pathname));
const PORT = process.env.PORT ?? 3025;
const BIND_ADDRESS = process.env.BIND_ADDRESS ?? 'localhost';
function ok(res, data = {}) { res.json({ ok: true, ...data }); }
function fail(res, msg, status = 400) { res.status(status).json({ ok: false, error: msg }); }
// ---------------------------------------------------------------------------
// Markdown rendering proxy
// ---------------------------------------------------------------------------
app.post('/api/render-markdown', async (req, res) => {
const { text, mode = 'markdown' } = req.body;
if (typeof text !== 'string') { return fail(res, 'text is required'); }
const config = get_config();
const gitea_url = config.gitea?.url;
const token = config.gitea?.token;
if (!gitea_url) { return fail(res, 'Gitea not configured (missing config.yaml)'); }
const headers = { 'Content-Type': 'application/json' };
if (token) { headers['Authorization'] = `token ${token}`; }
const gitea_res = await fetch(`${gitea_url}/api/v1/markdown`, {
method: 'POST',
headers,
body: JSON.stringify({ Text: text, Mode: mode }),
});
if (!gitea_res.ok) {
return fail(res, `Gitea error: ${gitea_res.status} ${gitea_res.statusText}`);
}
const html = await gitea_res.text();
ok(res, { html });
});
// ---------------------------------------------------------------------------
// Tasks
// ---------------------------------------------------------------------------
app.get('/api/tasks', (req, res) => {
ok(res, { tasks: list_tasks() });
});
app.post('/api/tasks', (req, res) => {
const { title, body = '', status = 'open', priority = 'normal', tags = [], parent_id = null } = req.body;
if (!title?.trim()) { return fail(res, 'title is required'); }
if (parent_id !== null && !get_task(Number(parent_id))) { return fail(res, 'parent task not found', 404); }
const now = Date.now();
const task = {
id: next_id('task'),
title: title.trim(),
body: body.trim(),
status,
priority,
tags: Array.isArray(tags) ? tags : [],
parent_id: parent_id !== null ? Number(parent_id) : null,
created_at: now,
updated_at: now,
};
set_task(task);
ok(res, { task });
});
app.get('/api/tasks/:id', (req, res) => {
const task = get_task(Number(req.params.id));
if (!task) { return fail(res, 'not found', 404); }
ok(res, { task });
});
app.put('/api/tasks/:id', (req, res) => {
const existing = get_task(Number(req.params.id));
if (!existing) { return fail(res, 'not found', 404); }
const { title, body, status, priority, tags, parent_id } = req.body;
const updated = { ...existing, updated_at: Date.now() };
if (title !== undefined) { updated.title = title.trim(); }
if (body !== undefined) { updated.body = body.trim(); }
if (status !== undefined) { updated.status = status; }
if (priority !== undefined) { updated.priority = priority; }
if (tags !== undefined && Array.isArray(tags)) { updated.tags = tags; }
if (parent_id !== undefined) { updated.parent_id = parent_id !== null ? Number(parent_id) : null; }
set_task(updated);
ok(res, { task: updated });
});
app.delete('/api/tasks/:id', (req, res) => {
const id = Number(req.params.id);
if (!get_task(id)) { return fail(res, 'not found', 404); }
if (has_child_tasks(id)) { return fail(res, 'task has subtasks — delete or reparent them first'); }
delete_task(id);
ok(res);
});
// SPA fallback
const INDEX_HTML = new URL('./public/index.html', import.meta.url).pathname;
app.get('/{*path}', (req, res) => res.sendFile(INDEX_HTML));
app.use((err, req, res, next) => {
console.error(`[express error] ${req.method} ${req.path}`, err);
if (!res.headersSent) {
res.status(500).json({ ok: false, error: err.message ?? 'Internal server error' });
}
});
app.listen(PORT, BIND_ADDRESS, () => {
console.log(`Task Inventory running on http://${BIND_ADDRESS}:${PORT}`);
});