- Add CodeMirror 6 with oneDark theme and markdown language support - Bundle via esbuild into public/vendor/ (built on deploy, not committed) - Add codemirror-entry.mjs as bundle entry point - Fix SPA fallback to only serve index.html for /, not all paths - Fix mime type handling by mutating mime-types registry - Add Write/Preview tabs; preview renders on tab switch only - Multi-cursor via Shift+Alt+Up/Down; Alt+Click adds cursor - gitignore package-lock.json and public/vendor/ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
158 lines
6.0 KiB
JavaScript
158 lines
6.0 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,
|
|
list_entry_types, get_entry_type, set_entry_type, delete_entry_type,
|
|
list_entries, get_entry, set_entry, delete_entry, has_child_entries,
|
|
} 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 });
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Entry types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
app.get('/api/entry-types', (req, res) => {
|
|
ok(res, { entry_types: list_entry_types() });
|
|
});
|
|
|
|
app.post('/api/entry-types', (req, res) => {
|
|
const { id, title, description = '' } = req.body;
|
|
if (!id?.trim()) { return fail(res, 'id is required'); }
|
|
if (!/^[a-z][a-z0-9_]*$/.test(id)) { return fail(res, 'id must be lowercase alphanumeric/underscore, starting with a letter'); }
|
|
if (get_entry_type(id)) { return fail(res, 'entry type already exists'); }
|
|
const now = Date.now();
|
|
const type = { id, title: title?.trim() || id, description: description.trim(), created_at: now, updated_at: now };
|
|
set_entry_type(type);
|
|
ok(res, { entry_type: type });
|
|
});
|
|
|
|
app.put('/api/entry-types/:id', (req, res) => {
|
|
const existing = get_entry_type(req.params.id);
|
|
if (!existing) { return fail(res, 'not found', 404); }
|
|
const { title, description } = req.body;
|
|
const updated = { ...existing, updated_at: Date.now() };
|
|
if (title !== undefined) { updated.title = title.trim(); }
|
|
if (description !== undefined) { updated.description = description.trim(); }
|
|
set_entry_type(updated);
|
|
ok(res, { entry_type: updated });
|
|
});
|
|
|
|
app.delete('/api/entry-types/:id', (req, res) => {
|
|
if (!get_entry_type(req.params.id)) { return fail(res, 'not found', 404); }
|
|
if (list_entries(req.params.id).length) { return fail(res, 'entry type still has entries — delete or retype them first'); }
|
|
delete_entry_type(req.params.id);
|
|
ok(res);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Entries
|
|
// ---------------------------------------------------------------------------
|
|
|
|
app.get('/api/entries', (req, res) => {
|
|
const type = req.query.type || null;
|
|
ok(res, { entries: list_entries(type) });
|
|
});
|
|
|
|
app.post('/api/entries', (req, res) => {
|
|
const { type, title, body = '', status = 'open', priority = 'normal', tags = [], parent_id = null } = req.body;
|
|
if (!type) { return fail(res, 'type is required'); }
|
|
if (!get_entry_type(type)) { return fail(res, 'unknown entry type', 400); }
|
|
if (!title?.trim()) { return fail(res, 'title is required'); }
|
|
if (parent_id !== null && !get_entry(Number(parent_id))) { return fail(res, 'parent entry not found', 404); }
|
|
const now = Date.now();
|
|
const entry = {
|
|
id: next_id(),
|
|
type,
|
|
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_entry(entry);
|
|
ok(res, { entry });
|
|
});
|
|
|
|
app.get('/api/entries/:id', (req, res) => {
|
|
const entry = get_entry(Number(req.params.id));
|
|
if (!entry) { return fail(res, 'not found', 404); }
|
|
ok(res, { entry });
|
|
});
|
|
|
|
app.put('/api/entries/:id', (req, res) => {
|
|
const existing = get_entry(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_entry(updated);
|
|
ok(res, { entry: updated });
|
|
});
|
|
|
|
app.delete('/api/entries/:id', (req, res) => {
|
|
const id = Number(req.params.id);
|
|
if (!get_entry(id)) { return fail(res, 'not found', 404); }
|
|
if (has_child_entries(id)) { return fail(res, 'entry has children — delete or reparent them first'); }
|
|
delete_entry(id);
|
|
ok(res);
|
|
});
|
|
|
|
const INDEX_HTML = new URL('./public/index.html', import.meta.url).pathname;
|
|
app.get('/', (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}`);
|
|
});
|