import { dbGlobal } from "drizzle-pkg/lib/db"; import { items, categories, tags, itemTags, settings, } from "drizzle-pkg/lib/schema/collection"; import { eq, and, like, desc, asc, sql, inArray, isNull, isNotNull, count, or } from "drizzle-orm"; import log4js from "logger"; const logger = log4js.getLogger("COLLECTION"); // ─── Types ──────────────────────────────────────────── export interface ItemFilters { userId: number; type?: string; categoryId?: number | "inbox"; tagIds?: number[]; tagMode?: "and" | "or"; starred?: boolean; ratingGte?: number; isArchived?: boolean; q?: string; page?: number; limit?: number; sort?: "created_at_desc" | "updated_at_desc" | "rating_desc"; } export interface CreateItemInput { type: "web" | "text" | "image" | "file" | "video"; title: string; description?: string; content?: string; url?: string; coverUrl?: string; faviconUrl?: string; sourceHost?: string; categoryId?: number; tagNames?: string[]; note?: string; rating?: number; starred?: boolean; } // ─── Items ──────────────────────────────────────────── export async function listItems(filters: ItemFilters) { const { userId, page = 1, limit = 20 } = filters; const offset = (page - 1) * limit; let query = dbGlobal.select().from(items).where(eq(items.userId, userId)); if (filters.type) { query = query.where(eq(items.type, filters.type as any)); } if (filters.categoryId === "inbox") { query = query.where(isNull(items.categoryId)); } else if (filters.categoryId !== undefined) { query = query.where(eq(items.categoryId, filters.categoryId)); } if (filters.starred !== undefined) { query = query.where(eq(items.starred, filters.starred)); } if (filters.ratingGte !== undefined) { query = query.where(sql`${items.rating} >= ${filters.ratingGte}`); } if (filters.q) { const term = `%${filters.q}%`; query = query.where( or(like(items.title, term), like(items.description, term)) ); } // Sort if (filters.sort === "updated_at_desc") { query = query.orderBy(desc(items.updatedAt)); } else if (filters.sort === "rating_desc") { query = query.orderBy(desc(items.rating)); } else { query = query.orderBy(desc(items.createdAt)); } // Count total const countRows = await dbGlobal .select({ count: count() }) .from(items) .where(eq(items.userId, userId)); const total = countRows[0]?.count ?? 0; // Paginated data const rows = await query.limit(limit).offset(offset); // Load tags for each item const itemIds = rows.map((r) => r.id); let tagMap: Record = {}; if (itemIds.length > 0) { const tagRows = await dbGlobal .select({ itemId: itemTags.itemId, id: tags.id, name: tags.name, color: tags.color }) .from(itemTags) .innerJoin(tags, eq(itemTags.tagId, tags.id)) .where(inArray(itemTags.itemId, itemIds)); for (const t of tagRows) { if (!tagMap[t.itemId]) tagMap[t.itemId] = []; tagMap[t.itemId].push({ id: t.id, name: t.name, color: t.color }); } } return { data: rows.map((r) => ({ ...r, tags: tagMap[r.id] || [] })), total, page, limit, hasMore: offset + rows.length < total, }; } export async function getItemById(id: number, userId: number) { const [row] = await dbGlobal .select() .from(items) .where(and(eq(items.id, id), eq(items.userId, userId))); if (!row) return null; const tagRows = await dbGlobal .select({ id: tags.id, name: tags.name, color: tags.color }) .from(itemTags) .innerJoin(tags, eq(itemTags.tagId, tags.id)) .where(eq(itemTags.itemId, id)); return { ...row, tags: tagRows }; } export async function createItem(input: CreateItemInput, userId: number) { const [newItem] = await dbGlobal .insert(items) .values({ userId, type: input.type as any, title: input.title, description: input.description || null, content: input.content || null, url: input.url || null, coverUrl: input.coverUrl || null, faviconUrl: input.faviconUrl || null, sourceHost: input.sourceHost || null, categoryId: input.categoryId || null, note: input.note || null, rating: input.rating || null, starred: input.starred ?? false, }) .returning(); // Handle tags if (input.tagNames && input.tagNames.length > 0) { const tagIds = await getOrCreateTags(input.tagNames); if (tagIds.length > 0) { await dbGlobal.insert(itemTags).values( tagIds.map((tagId) => ({ itemId: newItem.id, tagId })) ); } } return getItemById(newItem.id, userId); } export async function updateItem(id: number, userId: number, data: Record) { // Verify ownership const [existing] = await dbGlobal .select({ id: items.id }) .from(items) .where(and(eq(items.id, id), eq(items.userId, userId))); if (!existing) return null; const updateData: Record = {}; const allowed = ["title", "description", "note", "rating", "starred", "categoryId", "isArchived", "coverUrl", "url", "aiSummary", "content"]; for (const key of allowed) { if (key in data) updateData[key] = data[key]; } if (Object.keys(updateData).length === 0) return getItemById(id, userId); await dbGlobal.update(items).set(updateData).where(eq(items.id, id)); return getItemById(id, userId); } export async function deleteItem(id: number, userId: number) { const [existing] = await dbGlobal .select({ id: items.id }) .from(items) .where(and(eq(items.id, id), eq(items.userId, userId))); if (!existing) return false; await dbGlobal.delete(items).where(eq(items.id, id)); return true; } // ─── Tags ───────────────────────────────────────────── export async function getAllTags() { const tagRows = await dbGlobal.select().from(tags).orderBy(desc(tags.createdAt)); const countRows = await dbGlobal .select({ tagId: itemTags.tagId, count: count() }) .from(itemTags) .groupBy(itemTags.tagId); const countMap: Record = {}; for (const r of countRows) { countMap[r.tagId] = r.count; } return tagRows.map((t) => ({ ...t, itemCount: countMap[t.id] || 0 })); } export async function createTag(name: string, color?: string) { const [existing] = await dbGlobal.select({ id: tags.id }).from(tags).where(eq(tags.name, name)); if (existing) return existing; const [newTag] = await dbGlobal .insert(tags) .values({ name, color: color || "#6B7280" }) .returning(); return newTag; } export async function updateTag(id: number, data: { name?: string; color?: string }) { await dbGlobal.update(tags).set(data).where(eq(tags.id, id)); const [updated] = await dbGlobal.select().from(tags).where(eq(tags.id, id)); return updated || null; } export async function deleteTag(id: number) { await dbGlobal.delete(itemTags).where(eq(itemTags.tagId, id)); await dbGlobal.delete(tags).where(eq(tags.id, id)); } async function getOrCreateTags(names: string[]): Promise { const ids: number[] = []; for (const name of names) { const tag = await createTag(name); if (tag) ids.push(tag.id); } return ids; } export async function addTagToItem(itemId: number, userId: number, tagName: string) { const item = await getItemById(itemId, userId); if (!item) return null; const tag = await createTag(tagName); if (!tag) return null; // Check existing const [existing] = await dbGlobal .select() .from(itemTags) .where(and(eq(itemTags.itemId, itemId), eq(itemTags.tagId, tag.id))); if (!existing) { await dbGlobal.insert(itemTags).values({ itemId, tagId: tag.id }); } return { itemId, tagId: tag.id, tagName: tag.name }; } export async function removeTagFromItem(itemId: number, tagId: number) { await dbGlobal .delete(itemTags) .where(and(eq(itemTags.itemId, itemId), eq(itemTags.tagId, tagId))); } // ─── Categories ─────────────────────────────────────── export interface CategoryTree extends Record { children: CategoryTree[]; itemCount: number; } export async function getCategoryTree(): Promise { const all = await dbGlobal.select().from(categories).orderBy(asc(categories.sortOrder)); // Count items per category const countRows = await dbGlobal .select({ categoryId: items.categoryId, count: count() }) .from(items) .where(isNotNull(items.categoryId)) .groupBy(items.categoryId); const countMap: Record = {}; for (const r of countRows) { if (r.categoryId) countMap[r.categoryId] = r.count; } // Build tree const map = new Map(); const roots: CategoryTree[] = []; for (const cat of all) { map.set(cat.id, { ...cat, children: [], itemCount: countMap[cat.id] || 0 }); } for (const cat of all) { const node = map.get(cat.id)!; if (cat.parentId && map.has(cat.parentId)) { map.get(cat.parentId)!.children.push(node); } else { roots.push(node); } } return roots; } export async function createCategory(data: { name: string; icon?: string; color?: string; parentId?: number; sortOrder?: number; }) { const [newCat] = await dbGlobal .insert(categories) .values({ name: data.name, icon: data.icon || "📁", color: data.color || "#6B7280", parentId: data.parentId || null, sortOrder: data.sortOrder || 0, }) .returning(); return newCat; } export async function updateCategory( id: number, data: { name?: string; icon?: string; color?: string; parentId?: number; sortOrder?: number } ) { await dbGlobal.update(categories).set(data).where(eq(categories.id, id)); const [updated] = await dbGlobal.select().from(categories).where(eq(categories.id, id)); return updated || null; } export async function deleteCategory(id: number) { // Set child categories' parentId to null await dbGlobal .update(categories) .set({ parentId: null }) .where(eq(categories.parentId, id)); // Set items in this category back to inbox await dbGlobal .update(items) .set({ categoryId: null }) .where(eq(items.categoryId, id)); await dbGlobal.delete(categories).where(eq(categories.id, id)); } // ─── URL Fetch ──────────────────────────────────────── export async function fetchUrlMeta(url: string) { try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 8000); const res = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", }, signal: controller.signal, }); clearTimeout(timeout); const html = await res.text(); // Simple regex-based extraction (no cheerio dependency yet) const getMeta = (name: string) => { const match = html.match( new RegExp(`]+(?:property|name)=["']${name}["'][^>]+content=["']([^"']+)`, "i") ); return match ? match[1] : null; }; const titleMatch = html.match(/]*>([^<]+)<\/title>/i); const title = getMeta("og:title") || (titleMatch ? titleMatch[1] : null) || new URL(url).hostname; const description = getMeta("og:description") || getMeta("description") || null; const coverUrl = getMeta("og:image") || null; const faviconUrl = getFavicon(html, url); const sourceHost = new URL(url).hostname; return { title, description, coverUrl, faviconUrl, sourceHost }; } catch { const sourceHost = (() => { try { return new URL(url).hostname; } catch { return url; } })(); return { title: sourceHost, description: null, coverUrl: null, faviconUrl: null, sourceHost, }; } } function getFavicon(html: string, baseUrl: string): string | null { // Check for link rel="icon" const match = html.match( /]+rel=["'](?:shortcut )?icon["'][^>]+href=["']([^"']+)["']/i ) || html.match( /]+href=["']([^"']+)["'][^>]+rel=["'](?:shortcut )?icon["']/i ); if (match) { const href = match[1]; if (href.startsWith("http")) return href; try { return new URL(href, baseUrl).href; } catch { return null; } } // Default favicon try { return `${new URL(baseUrl).origin}/favicon.ico`; } catch { return null; } } // ─── Batch Operations ───────────────────────────────── export async function batchOperation( ids: number[], userId: number, action: "move" | "delete" | "star" | "unstar" | "archive", categoryId?: number ) { if (ids.length === 0) return 0; switch (action) { case "move": if (categoryId === undefined) throw new Error("categoryId is required for move action"); await dbGlobal .update(items) .set({ categoryId }) .where(and(inArray(items.id, ids), eq(items.userId, userId))); break; case "delete": await dbGlobal .delete(items) .where(and(inArray(items.id, ids), eq(items.userId, userId))); break; case "star": await dbGlobal .update(items) .set({ starred: true }) .where(and(inArray(items.id, ids), eq(items.userId, userId))); break; case "unstar": await dbGlobal .update(items) .set({ starred: false }) .where(and(inArray(items.id, ids), eq(items.userId, userId))); break; case "archive": await dbGlobal .update(items) .set({ isArchived: true }) .where(and(inArray(items.id, ids), eq(items.userId, userId))); break; } return ids.length; } // ─── Settings ───────────────────────────────────────── export async function getSettings(): Promise> { const rows = await dbGlobal.select().from(settings); const result: Record = {}; for (const row of rows) { result[row.key] = row.value; } return result; } export async function updateSettings(data: Record) { for (const [key, value] of Object.entries(data)) { await dbGlobal .insert(settings) .values({ key, value }) .onConflictDoUpdate({ target: settings.key, set: { value } }); } return getSettings(); }