import "dotenv/config"; import express from "express"; import path from "path"; import { fileURLToPath } from "url"; import { client, all, get, run, initDb } from "./db.js"; import { noteToMarkdown, notesToHtml, htmlToPdf, categoryPath, safeName, makeZip, } from "./export.js"; import { requireApiAuth, isAuthed, loginHandler, logoutHandler } from "./auth.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PUBLIC = path.join(__dirname, "public"); const app = express(); const PORT = process.env.PORT || 3000; app.set("trust proxy", 1); app.use(express.json()); // ---------- Auth (single admin) ---------- app.post("/api/login", loginHandler); app.post("/api/logout", logoutHandler); app.get("/api/me", (req, res) => res.json({ authed: isAuthed(req) })); // Static assets (css/js/login page) are public; index=false so "/" hits our gate app.use(express.static(PUBLIC, { index: false })); // Everything under /api (besides the auth routes above) requires login app.use("/api", requireApiAuth); // ---------- Helpers ---------- const bool = (v) => (v ? 1 : 0); // Wrap async route handlers so rejections become a 500 instead of crashing const h = (fn) => (req, res) => fn(req, res).catch((e) => { console.error(e); if (!res.headersSent) res.status(500).json({ error: e.message }); }); function buildCategoryTree(rows) { const map = new Map(); rows.forEach((r) => map.set(r.id, { ...r, children: [] })); const roots = []; for (const cat of map.values()) { if (cat.parent_id && map.has(cat.parent_id)) { map.get(cat.parent_id).children.push(cat); } else { roots.push(cat); } } return roots; } // =================================================================== // CATEGORIES // =================================================================== app.get("/api/categories", h(async (req, res) => { const { kind } = req.query; const rows = kind ? await all("SELECT * FROM categories WHERE kind = ? ORDER BY id", [kind]) : await all("SELECT * FROM categories ORDER BY id"); res.json({ flat: rows, tree: buildCategoryTree(rows) }); })); app.post("/api/categories", h(async (req, res) => { const { name, parent_id = null, kind = "task", color = null } = req.body; if (!name?.trim()) return res.status(400).json({ error: "İsim gerekli" }); const info = await run( "INSERT INTO categories (name, parent_id, kind, color) VALUES (?, ?, ?, ?)", [name.trim(), parent_id || null, kind, color] ); res.json(await get("SELECT * FROM categories WHERE id = ?", [info.lastInsertRowid])); })); app.put("/api/categories/:id", h(async (req, res) => { const { name, color, parent_id } = req.body; const cur = await get("SELECT * FROM categories WHERE id = ?", [req.params.id]); if (!cur) return res.status(404).json({ error: "Bulunamadı" }); await run("UPDATE categories SET name = ?, color = ?, parent_id = ? WHERE id = ?", [ name ?? cur.name, color ?? cur.color, parent_id === undefined ? cur.parent_id : parent_id, req.params.id, ]); res.json(await get("SELECT * FROM categories WHERE id = ?", [req.params.id])); })); app.delete("/api/categories/:id", h(async (req, res) => { await run("DELETE FROM categories WHERE id = ?", [req.params.id]); res.json({ ok: true }); })); // =================================================================== // TODOS // =================================================================== app.get("/api/todos", h(async (req, res) => { const { category_id, priority, status, due_from, due_to, search } = req.query; const where = []; const params = []; if (category_id) { // include subcategories of a parent const subIds = ( await all("SELECT id FROM categories WHERE id = ? OR parent_id = ?", [category_id, category_id]) ).map((r) => r.id); where.push(`t.category_id IN (${subIds.map(() => "?").join(",")})`); params.push(...subIds); } if (priority) { where.push("t.priority = ?"); params.push(priority); } if (status === "active") where.push("t.completed = 0"); if (status === "done") where.push("t.completed = 1"); if (due_from) { where.push("t.due_date >= ?"); params.push(due_from); } if (due_to) { where.push("t.due_date <= ?"); params.push(due_to); } if (search) { where.push("(t.title LIKE ? OR t.description LIKE ?)"); params.push(`%${search}%`, `%${search}%`); } const sql = ` SELECT t.*, c.name AS category_name, c.color AS category_color FROM todos t LEFT JOIN categories c ON c.id = t.category_id ${where.length ? "WHERE " + where.join(" AND ") : ""} ORDER BY t.completed ASC, t.sort_order ASC, t.created_at DESC`; res.json(await all(sql, params)); })); // Manual reorder (drag & drop): body { ids: [orderedTodoIds] } app.put("/api/todos/reorder", h(async (req, res) => { const { ids } = req.body; if (!Array.isArray(ids)) return res.status(400).json({ error: "ids dizisi gerekli" }); await client.batch( ids.map((id, i) => ({ sql: "UPDATE todos SET sort_order = ? WHERE id = ?", args: [i, id] })), "write" ); res.json({ ok: true }); })); app.post("/api/todos", h(async (req, res) => { const { title, description = "", category_id = null, priority = "P3", due_date = null } = req.body; if (!title?.trim()) return res.status(400).json({ error: "Başlık gerekli" }); // New tasks go to the top of the manual order const minOrder = (await get("SELECT MIN(sort_order) AS m FROM todos")).m; const sortOrder = (minOrder ?? 0) - 1; const info = await run( `INSERT INTO todos (title, description, category_id, priority, due_date, sort_order) VALUES (?, ?, ?, ?, ?, ?)`, [title.trim(), description, category_id || null, priority, due_date || null, sortOrder] ); res.json(await get("SELECT * FROM todos WHERE id = ?", [info.lastInsertRowid])); })); app.put("/api/todos/:id", h(async (req, res) => { const cur = await get("SELECT * FROM todos WHERE id = ?", [req.params.id]); if (!cur) return res.status(404).json({ error: "Bulunamadı" }); const b = req.body; await run( `UPDATE todos SET title = ?, description = ?, category_id = ?, priority = ?, due_date = ?, completed = ?, updated_at = datetime('now') WHERE id = ?`, [ b.title ?? cur.title, b.description ?? cur.description, b.category_id === undefined ? cur.category_id : b.category_id || null, b.priority ?? cur.priority, b.due_date === undefined ? cur.due_date : b.due_date || null, b.completed === undefined ? cur.completed : bool(b.completed), req.params.id, ] ); res.json(await get("SELECT * FROM todos WHERE id = ?", [req.params.id])); })); app.delete("/api/todos/:id", h(async (req, res) => { await run("DELETE FROM todos WHERE id = ?", [req.params.id]); res.json({ ok: true }); })); // =================================================================== // NOTES (Knowledge Center) // =================================================================== // Collect a folder id plus all of its (recursive) subfolder ids async function descendantCategoryIds(rootId) { const cats = await all("SELECT id, parent_id FROM categories"); const ids = [Number(rootId)]; let added = true; while (added) { added = false; for (const c of cats) { if (c.parent_id && ids.includes(c.parent_id) && !ids.includes(c.id)) { ids.push(c.id); added = true; } } } return ids; } async function queryNotes({ category_id, search } = {}) { const where = []; const params = []; if (category_id) { const ids = await descendantCategoryIds(category_id); where.push(`n.category_id IN (${ids.map(() => "?").join(",")})`); params.push(...ids); } if (search) { where.push("(n.title LIKE ? OR n.content LIKE ?)"); params.push(`%${search}%`, `%${search}%`); } return all( `SELECT n.*, c.name AS category_name, c.color AS category_color FROM notes n LEFT JOIN categories c ON c.id = n.category_id ${where.length ? "WHERE " + where.join(" AND ") : ""} ORDER BY n.pinned DESC, n.updated_at DESC`, params ); } app.get("/api/notes", h(async (req, res) => { res.json(await queryNotes(req.query)); })); app.post("/api/notes", h(async (req, res) => { const { title, content = "", category_id = null, pinned = false } = req.body; if (!title?.trim()) return res.status(400).json({ error: "Başlık gerekli" }); const info = await run( "INSERT INTO notes (title, content, category_id, pinned) VALUES (?, ?, ?, ?)", [title.trim(), content, category_id || null, bool(pinned)] ); res.json(await get("SELECT * FROM notes WHERE id = ?", [info.lastInsertRowid])); })); app.put("/api/notes/:id", h(async (req, res) => { const cur = await get("SELECT * FROM notes WHERE id = ?", [req.params.id]); if (!cur) return res.status(404).json({ error: "Bulunamadı" }); const b = req.body; await run( `UPDATE notes SET title = ?, content = ?, category_id = ?, pinned = ?, updated_at = datetime('now') WHERE id = ?`, [ b.title ?? cur.title, b.content ?? cur.content, b.category_id === undefined ? cur.category_id : b.category_id || null, b.pinned === undefined ? cur.pinned : bool(b.pinned), req.params.id, ] ); res.json(await get("SELECT * FROM notes WHERE id = ?", [req.params.id])); })); app.delete("/api/notes/:id", h(async (req, res) => { await run("DELETE FROM notes WHERE id = ?", [req.params.id]); res.json({ ok: true }); })); // =================================================================== // EXPORT (Markdown / PDF) — bulk routes BEFORE :id routes // =================================================================== function noteById(id) { return get( `SELECT n.*, c.name AS category_name FROM notes n LEFT JOIN categories c ON c.id = n.category_id WHERE n.id = ?`, [id] ); } const dl = (res, name) => { // ASCII fallback + RFC 5987 UTF-8 filename for Turkish/non-ASCII names const ascii = name.replace(/[^\x20-\x7E]/g, "_").replace(/"/g, "'"); res.setHeader( "Content-Disposition", `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}` ); }; const stamp = () => new Date().toISOString().slice(0, 10); // --- Bulk: all filtered notes as a ZIP of .md files (folder structure preserved) --- app.get("/api/notes/export/md", h(async (req, res) => { const notes = await queryNotes(req.query); const cats = await all("SELECT * FROM categories"); const files = []; const used = new Set(); for (const n of notes) { const folder = n.category_id ? categoryPath(n.category_id, cats) : "Kategorisiz"; let entry = `${folder}/${safeName(n.title)}.md`; let i = 2; while (used.has(entry)) entry = `${folder}/${safeName(n.title)} (${i++}).md`; used.add(entry); files.push({ name: entry, data: noteToMarkdown(n, folder) }); } if (!files.length) files.push({ name: "BOS.md", data: "Not bulunamadı.\n" }); const zip = makeZip(files); res.setHeader("Content-Type", "application/zip"); dl(res, `notlar-${stamp()}.zip`); res.end(zip); })); // --- Bulk: all filtered notes as a single PDF --- app.get("/api/notes/export/pdf", h(async (req, res) => { const notes = await queryNotes(req.query); const cats = await all("SELECT * FROM categories"); const pathFn = (id) => (id ? categoryPath(id, cats) : ""); const html = notesToHtml(notes, pathFn, "Bilgi Merkezi"); const pdf = await htmlToPdf(html); res.setHeader("Content-Type", "application/pdf"); dl(res, `notlar-${stamp()}.pdf`); res.end(pdf); })); // --- Single note as .md --- app.get("/api/notes/:id/export/md", h(async (req, res) => { const note = await noteById(req.params.id); if (!note) return res.status(404).json({ error: "Bulunamadı" }); const cats = await all("SELECT * FROM categories"); const folder = note.category_id ? categoryPath(note.category_id, cats) : ""; res.setHeader("Content-Type", "text/markdown; charset=utf-8"); dl(res, `${safeName(note.title)}.md`); res.end(noteToMarkdown(note, folder)); })); // --- Single note as PDF --- app.get("/api/notes/:id/export/pdf", h(async (req, res) => { const note = await noteById(req.params.id); if (!note) return res.status(404).json({ error: "Bulunamadı" }); const cats = await all("SELECT * FROM categories"); const pathFn = (id) => (id ? categoryPath(id, cats) : ""); const html = notesToHtml([note], pathFn, note.title); const pdf = await htmlToPdf(html); res.setHeader("Content-Type", "application/pdf"); dl(res, `${safeName(note.title)}.pdf`); res.end(pdf); })); // SPA fallback — gate behind login, otherwise show the login page app.get("*", (req, res) => { if (!isAuthed(req)) return res.sendFile(path.join(PUBLIC, "login.html")); res.sendFile(path.join(PUBLIC, "index.html")); }); // ---------- Boot ---------- await initDb(); app.listen(PORT, () => { console.log(`\n🚀 Uygulama çalışıyor: http://localhost:${PORT}\n`); });