to-do / server.js
rodopsin
Bulut dağıtımı: libSQL/Turso + Docker (HF Spaces)
483e583
Raw
History Blame Contribute Delete
13.1 kB
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`);
});