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 } 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 = [] } = req.body; if (!title?.trim()) { return fail(res, 'title is required'); } const now = Date.now(); const task = { id: next_id('task'), title: title.trim(), body: body.trim(), status, priority, tags: Array.isArray(tags) ? tags : [], 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 } = 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; } set_task(updated); ok(res, { task: updated }); }); app.delete('/api/tasks/:id', (req, res) => { if (!delete_task(Number(req.params.id))) { return fail(res, 'not found', 404); } 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}`); });