File size: 13,057 Bytes
40353f2
 
 
 
483e583
40353f2
 
 
 
 
 
 
 
6ed2a0a
40353f2
 
6ed2a0a
40353f2
 
 
6ed2a0a
40353f2
6ed2a0a
 
 
 
 
 
 
 
 
 
 
40353f2
 
 
483e583
 
 
 
 
 
40353f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
483e583
40353f2
 
483e583
 
40353f2
483e583
40353f2
483e583
40353f2
 
483e583
 
 
 
 
 
40353f2
483e583
40353f2
483e583
40353f2
483e583
40353f2
 
 
483e583
 
 
 
40353f2
483e583
 
40353f2
483e583
40353f2
 
 
 
483e583
40353f2
 
 
 
 
 
483e583
 
 
40353f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
483e583
 
40353f2
 
483e583
40353f2
 
483e583
 
 
 
40353f2
483e583
40353f2
483e583
6ed2a0a
40353f2
 
483e583
40353f2
483e583
 
 
 
 
 
 
40353f2
483e583
 
40353f2
 
483e583
40353f2
 
6ed2a0a
483e583
 
 
 
 
 
 
 
 
 
40353f2
483e583
 
40353f2
483e583
 
40353f2
483e583
40353f2
 
 
 
 
483e583
 
40353f2
 
 
 
483e583
40353f2
 
 
 
 
 
 
 
 
483e583
40353f2
 
 
483e583
40353f2
 
 
 
 
 
 
483e583
 
 
 
 
 
 
40353f2
 
483e583
 
 
40353f2
483e583
40353f2
 
483e583
 
 
 
 
 
40353f2
483e583
 
40353f2
 
483e583
 
 
 
 
 
 
 
 
40353f2
483e583
 
40353f2
483e583
 
40353f2
483e583
40353f2
 
 
 
6ed2a0a
483e583
 
 
 
 
40353f2
 
 
 
 
 
 
 
 
 
 
 
 
483e583
 
 
40353f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
483e583
40353f2
 
483e583
 
 
 
 
 
 
 
 
 
40353f2
 
483e583
 
40353f2
483e583
40353f2
 
 
 
483e583
40353f2
 
483e583
 
 
 
 
 
 
 
 
 
 
40353f2
6ed2a0a
40353f2
6ed2a0a
 
40353f2
 
 
483e583
40353f2
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
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`);
});