snapshot
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
import type { Database } from "bun:sqlite";
|
||||
import { getDb } from "./db";
|
||||
|
||||
export type SnippetRow = {
|
||||
id: number;
|
||||
title: string | null;
|
||||
body: string;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
function escapeLike(raw: string): string {
|
||||
return raw.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
|
||||
}
|
||||
|
||||
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 "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(
|
||||
row: {
|
||||
id: number;
|
||||
title: string | null;
|
||||
body: string;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
},
|
||||
tags: string[],
|
||||
): SnippetRow {
|
||||
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,
|
||||
tags,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}[];
|
||||
|
||||
if (trimmed === "") {
|
||||
rows = database
|
||||
.query(
|
||||
`SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at
|
||||
FROM snippets s
|
||||
${order}`,
|
||||
)
|
||||
.all() as typeof rows;
|
||||
} else {
|
||||
const term = escapeLike(trimmed);
|
||||
rows = database
|
||||
.query(
|
||||
`SELECT s.id, s.title, s.body, s.sort_order, s.created_at, s.updated_at
|
||||
FROM snippets s
|
||||
WHERE s.title LIKE '%' || ?1 || '%' ESCAPE '\\'
|
||||
OR s.body LIKE '%' || ?1 || '%' ESCAPE '\\'
|
||||
OR 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 '%' || ?1 || '%' ESCAPE '\\'
|
||||
)
|
||||
${order}`,
|
||||
)
|
||||
.all(term) as typeof rows;
|
||||
}
|
||||
|
||||
const ids = rows.map(r => r.id);
|
||||
const tagMap = loadTagsForSnippetIds(database, ids);
|
||||
return rows.map(r => rowToPublic(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 FROM snippets WHERE id = ?`,
|
||||
)
|
||||
.get(id) as
|
||||
| {
|
||||
id: number;
|
||||
title: string | null;
|
||||
body: string;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
const tagMap = loadTagsForSnippetIds(database, [row.id]);
|
||||
return rowToPublic(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 tagId = getOrCreateTagId(database, name);
|
||||
if (tagId !== null) insertLink.run(snippetId, tagId);
|
||||
}
|
||||
}
|
||||
|
||||
export function createSnippet(input: {
|
||||
title?: string | null;
|
||||
body?: string;
|
||||
tags?: string[];
|
||||
sort_order?: number;
|
||||
}): SnippetRow {
|
||||
const database = getDb();
|
||||
const t = nowIso();
|
||||
const title = input.title === undefined || input.title === "" ? null : String(input.title);
|
||||
const body = input.body ?? "";
|
||||
const sortOrder = typeof input.sort_order === "number" ? input.sort_order : Date.now();
|
||||
|
||||
database.run(
|
||||
`INSERT INTO snippets (title, body, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
[title, body, sortOrder, t, t],
|
||||
);
|
||||
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;
|
||||
},
|
||||
): 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 FROM snippets WHERE id = ?`)
|
||||
.get(id) as {
|
||||
title: string | null;
|
||||
body: string;
|
||||
sort_order: number;
|
||||
};
|
||||
|
||||
const title =
|
||||
input.title === undefined ? current.title : input.title === "" || input.title === null ? null : String(input.title);
|
||||
const body = input.body === undefined ? current.body : String(input.body);
|
||||
const sort_order = input.sort_order === undefined ? current.sort_order : input.sort_order;
|
||||
const t = nowIso();
|
||||
|
||||
database.run(
|
||||
`UPDATE snippets SET title = ?, body = ?, sort_order = ?, updated_at = ? WHERE id = ?`,
|
||||
[title, body, sort_order, 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;
|
||||
}
|
||||
Reference in New Issue
Block a user