399 lines
13 KiB
TypeScript
399 lines
13 KiB
TypeScript
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<string, string | null>;
|
|
};
|
|
|
|
/** 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<number, string[]> {
|
|
const map = new Map<number, string[]>();
|
|
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<string, string | null> = {};
|
|
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;
|
|
}
|