import type { Database } from "bun:sqlite"; import { getDb } from "./db"; import { colorsForTagNames, decodeSnippetTagDisplay, encodeTreeTagStorage, escapeLike, expandExactTreeTagNames, matchingTreeTagNamesForLikePattern, treeStorageRefsForDisplayNames, treeTagDisplayForCanonicalName, } from "./tagTree"; export type SnippetRow = { id: number; title: string | null; body: string; sort_order: number; created_at: string; updated_at: string; deadline_at: string | null; tags: string[]; /** Anzeige-Label pro Tag (Prefix + Name), gleiche Reihenfolge wie `tags`. */ tag_display: string[]; /** Farben aus dem Tag-Baum (Schlüssel = Tag-String wie am Snippet, Wert = #rrggbb oder null). */ tag_colors: Record; }; /** PATCH/POST: `undefined` = nicht ändern, `null` = entfernen. */ export function normalizeDeadlineAtInput(raw: unknown): string | null | undefined { if (raw === undefined) return undefined; if (raw === null) return null; if (typeof raw !== "string") throw new Error("deadline_at muss ein String (YYYY-MM-DD) sein"); const t = raw.trim(); if (t === "") return null; if (!/^\d{4}-\d{2}-\d{2}$/.test(t)) throw new Error("deadline_at muss YYYY-MM-DD sein"); return t; } /** `tag:x`, `title:y`, `content:z` (case-insensitive); remainder = default (title OR tag names, LIKE). All parts AND. */ function parseSearchQuery(raw: string): { defaultText: string | null; tagTermsRaw: string[]; titleTerms: string[]; contentTerms: string[]; } { const tagTermsRaw: string[] = []; const titleTerms: string[] = []; const contentTerms: string[] = []; const re = /(?:^|\s)(tag|title|content):(\S+)/gi; const spans: [number, number][] = []; let m: RegExpExecArray | null; while ((m = re.exec(raw)) !== null) { const kind = m[1].toLowerCase(); const val = m[2]; if (!val) continue; const escaped = escapeLike(val); if (kind === "tag") tagTermsRaw.push(val); else if (kind === "title") titleTerms.push(escaped); else if (kind === "content") contentTerms.push(escaped); spans.push([m.index, m.index + m[0].length]); } let rest = raw; for (const [start, end] of [...spans].sort((a, b) => b[0] - a[0])) { rest = `${rest.slice(0, start)} ${rest.slice(end)}`; } const defaultTrim = rest.replace(/\s+/g, " ").trim(); const defaultText = defaultTrim === "" ? null : escapeLike(defaultTrim); return { defaultText, tagTermsRaw, titleTerms, contentTerms }; } function nowIso(): string { return new Date().toISOString(); } function orderClause(sort: string): string { switch (sort) { case "updated_asc": return "ORDER BY s.updated_at ASC, s.id ASC"; case "title_asc": return "ORDER BY s.title COLLATE NOCASE ASC NULLS LAST, s.updated_at DESC"; case "title_desc": return "ORDER BY s.title COLLATE NOCASE DESC NULLS LAST, s.updated_at DESC"; case "deadline_asc": return "ORDER BY s.deadline_at ASC NULLS LAST, s.updated_at DESC"; case "deadline_desc": return "ORDER BY s.deadline_at DESC NULLS LAST, s.updated_at DESC"; case "manual": return "ORDER BY s.sort_order ASC, s.updated_at DESC"; case "updated_desc": default: return "ORDER BY s.updated_at DESC, s.id DESC"; } } function loadTagsForSnippetIds(database: Database, ids: number[]): Map { const map = new Map(); if (ids.length === 0) return map; const placeholders = ids.map(() => "?").join(", "); const rows = database .query( `SELECT st.snippet_id, t.name FROM snippet_tags st JOIN tags t ON t.id = st.tag_id WHERE st.snippet_id IN (${placeholders}) ORDER BY t.name COLLATE NOCASE ASC`, ) .all(...ids) as { snippet_id: number; name: string }[]; for (const id of ids) map.set(id, []); for (const row of rows) { const list = map.get(row.snippet_id); if (list) list.push(row.name); } return map; } function rowToPublic( database: Database, row: { id: number; title: string | null; body: string; sort_order: number; created_at: string; updated_at: string; deadline_at: string | null; }, tagsRaw: string[], ): SnippetRow { const tags = tagsRaw.map(t => decodeSnippetTagDisplay(database, t)); const tag_display = tags.map(t => treeTagDisplayForCanonicalName(database, t)); const cmap = colorsForTagNames(database, tags); const tag_colors: Record = {}; for (const t of tags) { tag_colors[t] = cmap.get(t) ?? null; } return { id: row.id, title: row.title, body: row.body, sort_order: row.sort_order, created_at: row.created_at, updated_at: row.updated_at, deadline_at: row.deadline_at, tags, tag_display, tag_colors, }; } export function listSnippets(q: string | null, sort: string | null): SnippetRow[] { const database = getDb(); const order = orderClause(sort ?? "updated_desc"); const trimmed = (q ?? "").trim(); let rows: { id: number; title: string | null; body: string; sort_order: number; created_at: string; updated_at: string; deadline_at: string | null; }[]; if (trimmed === "") { rows = database .query( `SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at, s.deadline_at FROM snippets s ${order}`, ) .all() as typeof rows; } else { const parsed = parseSearchQuery(trimmed); const conditions: string[] = []; const params: string[] = []; if (parsed.defaultText !== null) { const treeTagNames = matchingTreeTagNamesForLikePattern(database, parsed.defaultText); const parts: string[] = [ `(s.title IS NOT NULL AND s.title LIKE '%' || ? || '%' ESCAPE '\\')`, `EXISTS ( SELECT 1 FROM snippet_tags st JOIN tags t ON t.id = st.tag_id WHERE st.snippet_id = s.id AND t.name LIKE '%' || ? || '%' ESCAPE '\\' )`, ]; params.push(parsed.defaultText, parsed.defaultText); if (treeTagNames.length > 0) { const lower = treeTagNames.map(n => n.toLowerCase()); const phLower = lower.map(() => "?").join(", "); const refs = treeStorageRefsForDisplayNames(database, treeTagNames); const sub: string[] = [`lower(t.name) IN (${phLower})`]; params.push(...lower); if (refs.length > 0) { const phRefs = refs.map(() => "?").join(", "); sub.push(`t.name IN (${phRefs})`); params.push(...refs); } parts.push(`EXISTS ( SELECT 1 FROM snippet_tags st JOIN tags t ON t.id = st.tag_id WHERE st.snippet_id = s.id AND (${sub.join(" OR ")}) )`); } conditions.push(`(${parts.join(" OR ")})`); } for (const raw of parsed.tagTermsRaw) { const expanded = expandExactTreeTagNames(database, raw); if (expanded !== null && expanded.length > 0) { const lower = expanded.map(n => n.toLowerCase()); const refs = treeStorageRefsForDisplayNames(database, expanded); const sub: string[] = [`lower(t.name) IN (${lower.map(() => "?").join(", ")})`]; params.push(...lower); if (refs.length > 0) { sub.push(`t.name IN (${refs.map(() => "?").join(", ")})`); params.push(...refs); } conditions.push(`EXISTS ( SELECT 1 FROM snippet_tags st JOIN tags t ON t.id = st.tag_id WHERE st.snippet_id = s.id AND (${sub.join(" OR ")}) )`); } else { const term = escapeLike(raw.trim()); conditions.push(`EXISTS ( SELECT 1 FROM snippet_tags st JOIN tags t ON t.id = st.tag_id WHERE st.snippet_id = s.id AND t.name LIKE '%' || ? || '%' ESCAPE '\\' )`); params.push(term); } } for (const term of parsed.titleTerms) { conditions.push(`(s.title IS NOT NULL AND s.title LIKE '%' || ? || '%' ESCAPE '\\')`); params.push(term); } for (const term of parsed.contentTerms) { conditions.push(`(s.body LIKE '%' || ? || '%' ESCAPE '\\')`); params.push(term); } if (conditions.length === 0) { rows = database .query( `SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at, s.deadline_at FROM snippets s ${order}`, ) .all() as typeof rows; } else { const where = conditions.join(" AND "); rows = database .query( `SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at, s.deadline_at FROM snippets s WHERE ${where} ${order}`, ) .all(...params) as typeof rows; } } const ids = rows.map(r => r.id); const tagMap = loadTagsForSnippetIds(database, ids); return rows.map(r => rowToPublic(database, r, tagMap.get(r.id) ?? [])); } export function getSnippetById(id: number): SnippetRow | null { const database = getDb(); const row = database .query( `SELECT id, title, body, sort_order, created_at, updated_at, deadline_at FROM snippets WHERE id = ?`, ) .get(id) as | { id: number; title: string | null; body: string; sort_order: number; created_at: string; updated_at: string; deadline_at: string | null; } | undefined; if (!row) return null; const tagMap = loadTagsForSnippetIds(database, [row.id]); return rowToPublic(database, row, tagMap.get(row.id) ?? []); } function getOrCreateTagId(database: Database, name: string): number | null { const trimmed = name.trim(); if (!trimmed) return null; database.run(`INSERT OR IGNORE INTO tags (name) VALUES (?)`, [trimmed]); const row = database.query(`SELECT id FROM tags WHERE name = ? COLLATE NOCASE`).get(trimmed) as | { id: number } | undefined; return row?.id ?? null; } function replaceSnippetTags(database: Database, snippetId: number, tagNames: string[]) { database.run(`DELETE FROM snippet_tags WHERE snippet_id = ?`, [snippetId]); const unique = [...new Set(tagNames.map(t => t.trim()).filter(Boolean))]; if (unique.length === 0) return; const insertLink = database.prepare(`INSERT OR IGNORE INTO snippet_tags (snippet_id, tag_id) VALUES (?, ?)`); for (const name of unique) { const storage = encodeTreeTagStorage(database, name); const tagId = getOrCreateTagId(database, storage); if (tagId !== null) insertLink.run(snippetId, tagId); } } export function createSnippet(input: { title?: string | null; body?: string; tags?: string[]; sort_order?: number; deadline_at?: string | null; }): SnippetRow { const database = getDb(); const t = nowIso(); const title = input.title === undefined || input.title === null ? null : String(input.title).trim() === "" ? null : String(input.title).trim(); const body = input.body ?? ""; const sortOrder = typeof input.sort_order === "number" ? input.sort_order : Date.now(); const deadlineAt = input.deadline_at === undefined ? null : input.deadline_at === null ? null : String(input.deadline_at).trim() || null; database.run( `INSERT INTO snippets (title, body, sort_order, created_at, updated_at, deadline_at) VALUES (?, ?, ?, ?, ?, ?)`, [title, body, sortOrder, t, t, deadlineAt], ); const idRow = database.query(`SELECT last_insert_rowid() AS id`).get() as { id: number }; const newId = Number(idRow.id); replaceSnippetTags(database, newId, input.tags ?? []); return getSnippetById(newId)!; } export function updateSnippet( id: number, input: { title?: string | null; body?: string; tags?: string[]; sort_order?: number; deadline_at?: string | null; }, ): SnippetRow | null { const database = getDb(); const existing = database.query(`SELECT id FROM snippets WHERE id = ?`).get(id) as { id: number } | undefined; if (!existing) return null; const current = database .query(`SELECT title, body, sort_order, deadline_at FROM snippets WHERE id = ?`) .get(id) as { title: string | null; body: string; sort_order: number; deadline_at: string | null; }; const title = input.title === undefined ? current.title : input.title === null || String(input.title).trim() === "" ? null : String(input.title).trim(); const body = input.body === undefined ? current.body : String(input.body); const sort_order = input.sort_order === undefined ? current.sort_order : input.sort_order; const deadline_at = input.deadline_at === undefined ? current.deadline_at : input.deadline_at; const t = nowIso(); database.run( `UPDATE snippets SET title = ?, body = ?, sort_order = ?, deadline_at = ?, updated_at = ? WHERE id = ?`, [title, body, sort_order, deadline_at, t, id], ); if (input.tags !== undefined) { replaceSnippetTags(database, id, input.tags); } return getSnippetById(id); } export function deleteSnippet(id: number): boolean { const database = getDb(); const res = database.run(`DELETE FROM snippets WHERE id = ?`, [id]); return res.changes > 0; }