You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

499 lines
15 KiB

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<number, { id: number; name: string; color: string }[]> = {};
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<string, any>) {
// 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<string, any> = {};
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<number, number> = {};
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<number[]> {
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<string, any> {
children: CategoryTree[];
itemCount: number;
}
export async function getCategoryTree(): Promise<CategoryTree[]> {
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<number, number> = {};
for (const r of countRows) {
if (r.categoryId) countMap[r.categoryId] = r.count;
}
// Build tree
const map = new Map<number, CategoryTree>();
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(`<meta[^>]+(?:property|name)=["']${name}["'][^>]+content=["']([^"']+)`, "i")
);
return match ? match[1] : null;
};
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/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(
/<link[^>]+rel=["'](?:shortcut )?icon["'][^>]+href=["']([^"']+)["']/i
) || html.match(
/<link[^>]+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<Record<string, any>> {
const rows = await dbGlobal.select().from(settings);
const result: Record<string, any> = {};
for (const row of rows) {
result[row.key] = row.value;
}
return result;
}
export async function updateSettings(data: Record<string, any>) {
for (const [key, value] of Object.entries(data)) {
await dbGlobal
.insert(settings)
.values({ key, value })
.onConflictDoUpdate({ target: settings.key, set: { value } });
}
return getSettings();
}