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
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();
|
|
}
|
|
|