import type { BookmarkInstance, BookmarksData, CollectionSettings, } from "./types"; import { DEFAULT_COLLECTION_SETTINGS, defaultCategory } from "./types"; import { MAX_BOOKMARKS_PER_MARK } from "./constants"; export interface CollectionRow { mark: string; write_token_hash: string; created_at: string; updated_at: string; migrated_from_kv: number; /** 0 = one-time plaintext token not yet delivered to a client */ token_delivered: number; redirect_after_save: number; default_category: string; is_public: number; } export function rowToSettings(row: CollectionRow): CollectionSettings { return { redirectAfterSave: row.redirect_after_save !== 0, defaultCategory: row.default_category || DEFAULT_COLLECTION_SETTINGS.defaultCategory, isPublic: row.is_public !== 0, }; } export interface BookmarkRow { uuid: string; mark: string; url: string; title: string; description: string | null; category: string; favicon: string | null; created_at: string; modified_at: string; } export function rowToBookmark(row: BookmarkRow): BookmarkInstance { return { uuid: row.uuid, url: row.url, title: row.title, description: row.description ?? undefined, category: row.category || defaultCategory, favicon: row.favicon ?? undefined, createdAt: row.created_at, modifiedAt: row.modified_at, }; } export async function getCollection( db: D1Database, mark: string, ): Promise { const row = await db .prepare( `SELECT mark, write_token_hash, created_at, updated_at, migrated_from_kv, COALESCE(token_delivered, 1) as token_delivered, COALESCE(redirect_after_save, 1) as redirect_after_save, COALESCE(default_category, 'default') as default_category, COALESCE(is_public, 1) as is_public FROM collections WHERE mark = ?`, ) .bind(mark) .first(); return row; } export async function getBookmarksForMark( db: D1Database, mark: string, ): Promise { const { results } = await db .prepare( `SELECT uuid, mark, url, title, description, category, favicon, created_at, modified_at FROM bookmarks WHERE mark = ? ORDER BY created_at ASC`, ) .bind(mark) .all(); return (results ?? []).map(rowToBookmark); } export async function getBookmarksData( db: D1Database, mark: string, ): Promise { const collection = await getCollection(db, mark); if (!collection) { return null; } const bookmarks = await getBookmarksForMark(db, mark); return { mark, bookmarks }; } export async function countBookmarks( db: D1Database, mark: string, ): Promise { const row = await db .prepare("SELECT COUNT(*) as count FROM bookmarks WHERE mark = ?") .bind(mark) .first<{ count: number }>(); return row?.count ?? 0; } export async function createCollection( db: D1Database, mark: string, writeTokenHash: string, options?: { migratedFromKv?: boolean; tokenDelivered?: boolean; settings?: Partial; }, ): Promise { const now = new Date().toISOString(); // Migrated collections default to token_delivered=0 so the first page open can issue once. const delivered = options?.tokenDelivered !== undefined ? options.tokenDelivered ? 1 : 0 : options?.migratedFromKv ? 0 : 1; const settings = { ...DEFAULT_COLLECTION_SETTINGS, ...options?.settings, }; await db .prepare( `INSERT INTO collections (mark, write_token_hash, created_at, updated_at, migrated_from_kv, token_delivered, redirect_after_save, default_category, is_public) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .bind( mark, writeTokenHash, now, now, options?.migratedFromKv ? 1 : 0, delivered, settings.redirectAfterSave ? 1 : 0, settings.defaultCategory || defaultCategory, settings.isPublic ? 1 : 0, ) .run(); } export async function updateCollectionSettings( db: D1Database, mark: string, settings: CollectionSettings, ): Promise { const now = new Date().toISOString(); await db .prepare( `UPDATE collections SET redirect_after_save = ?, default_category = ?, is_public = ?, updated_at = ? WHERE mark = ?`, ) .bind( settings.redirectAfterSave ? 1 : 0, settings.defaultCategory || defaultCategory, settings.isPublic ? 1 : 0, now, mark, ) .run(); } /** Issue a fresh write token and mark it undelivered (or delivered). */ export async function rotateCollectionToken( db: D1Database, mark: string, writeTokenHash: string, options?: { delivered?: boolean; clearMigratedFlag?: boolean }, ): Promise { const now = new Date().toISOString(); const delivered = options?.delivered === false ? 0 : 1; if (options?.clearMigratedFlag) { await db .prepare( `UPDATE collections SET write_token_hash = ?, updated_at = ?, token_delivered = ?, migrated_from_kv = 0 WHERE mark = ?`, ) .bind(writeTokenHash, now, delivered, mark) .run(); } else { await db .prepare( `UPDATE collections SET write_token_hash = ?, updated_at = ?, token_delivered = ? WHERE mark = ?`, ) .bind(writeTokenHash, now, delivered, mark) .run(); } } export async function markTokenDelivered( db: D1Database, mark: string, ): Promise { await db .prepare("UPDATE collections SET token_delivered = 1 WHERE mark = ?") .bind(mark) .run(); } export async function updateCollectionToken( db: D1Database, mark: string, writeTokenHash: string, ): Promise { const now = new Date().toISOString(); await db .prepare( `UPDATE collections SET write_token_hash = ?, updated_at = ?, migrated_from_kv = 0, token_delivered = 1 WHERE mark = ?`, ) .bind(writeTokenHash, now, mark) .run(); } export async function touchCollection( db: D1Database, mark: string, ): Promise { const now = new Date().toISOString(); await db .prepare("UPDATE collections SET updated_at = ? WHERE mark = ?") .bind(now, mark) .run(); } export async function insertBookmark( db: D1Database, mark: string, bookmark: BookmarkInstance, ): Promise { await db .prepare( `INSERT INTO bookmarks (uuid, mark, url, title, description, category, favicon, created_at, modified_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .bind( bookmark.uuid, mark, bookmark.url, bookmark.title, bookmark.description ?? null, bookmark.category, bookmark.favicon ?? null, bookmark.createdAt, bookmark.modifiedAt, ) .run(); await touchCollection(db, mark); } export async function updateBookmark( db: D1Database, mark: string, bookmark: BookmarkInstance, ): Promise { await db .prepare( `UPDATE bookmarks SET url = ?, title = ?, description = ?, category = ?, favicon = ?, modified_at = ? WHERE uuid = ? AND mark = ?`, ) .bind( bookmark.url, bookmark.title, bookmark.description ?? null, bookmark.category, bookmark.favicon ?? null, bookmark.modifiedAt, bookmark.uuid, mark, ) .run(); await touchCollection(db, mark); } export async function deleteBookmark( db: D1Database, mark: string, uuid: string, ): Promise { const result = await db .prepare("DELETE FROM bookmarks WHERE uuid = ? AND mark = ?") .bind(uuid, mark) .run(); if (result.meta.changes > 0) { await touchCollection(db, mark); return true; } return false; } export async function findBookmarkByUrl( db: D1Database, mark: string, url: string, ): Promise { const row = await db .prepare( `SELECT uuid, mark, url, title, description, category, favicon, created_at, modified_at FROM bookmarks WHERE mark = ? AND url = ?`, ) .bind(mark, url) .first(); return row ? rowToBookmark(row) : null; } export async function findBookmarkByUuid( db: D1Database, mark: string, uuid: string, ): Promise { const row = await db .prepare( `SELECT uuid, mark, url, title, description, category, favicon, created_at, modified_at FROM bookmarks WHERE mark = ? AND uuid = ?`, ) .bind(mark, uuid) .first(); return row ? rowToBookmark(row) : null; } export async function assertUnderBookmarkLimit( db: D1Database, mark: string, ): Promise { const count = await countBookmarks(db, mark); if (count >= MAX_BOOKMARKS_PER_MARK) { throw new Error( `Collection has reached the maximum of ${MAX_BOOKMARKS_PER_MARK} bookmarks`, ); } } /** * Insert many bookmarks in a single batch (import / bulk ops). */ export async function insertBookmarksBatch( db: D1Database, mark: string, bookmarks: BookmarkInstance[], ): Promise { if (bookmarks.length === 0) return; const statements = bookmarks.map((b) => db .prepare( `INSERT OR IGNORE INTO bookmarks (uuid, mark, url, title, description, category, favicon, created_at, modified_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .bind( b.uuid, mark, b.url, b.title, b.description ?? null, b.category || defaultCategory, b.favicon ?? null, b.createdAt, b.modifiedAt, ), ); // D1 batch max is large enough; chunk if needed const CHUNK = 50; for (let i = 0; i < statements.length; i += CHUNK) { await db.batch(statements.slice(i, i + CHUNK)); } await touchCollection(db, mark); }