import { and, eq, exists, gt, gte, inArray, isNotNull, isNull, like, lt, lte, ne, notExists, notInArray, notLike, or, } from "drizzle-orm"; import { bookmarkAssets, bookmarkLinks, bookmarkLists, bookmarks, bookmarksInLists, bookmarkTags, rssFeedImportsTable, rssFeedsTable, tagsOnBookmarks, } from "@karakeep/db/schema"; import { Matcher } from "@karakeep/shared/types/search"; import { toAbsoluteDate } from "@karakeep/shared/utils/relativeDateUtils"; import { AuthedContext } from ".."; interface BookmarkQueryReturnType { id: string; } function intersect( vals: BookmarkQueryReturnType[][], ): BookmarkQueryReturnType[] { if (!vals || vals.length === 0) { return []; } if (vals.length === 1) { return [...vals[0]]; } const countMap = new Map(); const map = new Map(); for (const arr of vals) { for (const item of arr) { countMap.set(item.id, (countMap.get(item.id) ?? 0) + 1); map.set(item.id, item); } } const result: BookmarkQueryReturnType[] = []; for (const [id, count] of countMap) { if (count === vals.length) { result.push(map.get(id)!); } } return result; } function union(vals: BookmarkQueryReturnType[][]): BookmarkQueryReturnType[] { if (!vals || vals.length === 0) { return []; } const uniqueIds = new Set(); const map = new Map(); for (const arr of vals) { for (const item of arr) { uniqueIds.add(item.id); map.set(item.id, item); } } const result: BookmarkQueryReturnType[] = []; for (const id of uniqueIds) { result.push(map.get(id)!); } return result; } async function getIds( ctx: AuthedContext, matcher: Matcher, visitedListIds = new Set(), ): Promise { const { db } = ctx; const userId = ctx.user.id; switch (matcher.type) { case "tagName": { const comp = matcher.inverse ? notExists : exists; return db .selectDistinct({ id: bookmarks.id }) .from(bookmarks) .where( and( eq(bookmarks.userId, userId), comp( db .select() .from(tagsOnBookmarks) .innerJoin( bookmarkTags, eq(tagsOnBookmarks.tagId, bookmarkTags.id), ) .where( and( eq(tagsOnBookmarks.bookmarkId, bookmarks.id), eq(bookmarkTags.userId, userId), eq(bookmarkTags.name, matcher.tagName), ), ), ), ), ); } case "tagged": { const comp = matcher.tagged ? exists : notExists; return db .select({ id: bookmarks.id }) .from(bookmarks) .where( and( eq(bookmarks.userId, userId), comp( db .select() .from(tagsOnBookmarks) .where(and(eq(tagsOnBookmarks.bookmarkId, bookmarks.id))), ), ), ); } case "listName": { // First, look up the list by name const lists = await db.query.bookmarkLists.findMany({ where: and( eq(bookmarkLists.userId, userId), eq(bookmarkLists.name, matcher.listName), ), }); if (lists.length === 0) { // No matching lists return []; } // Use List model to resolve list membership (manual and smart) // Import dynamically to avoid circular dependency const { List } = await import("../models/lists"); const listBookmarkIds = [ ...new Set( ( await Promise.all( lists.map(async (list) => { const listModel = await List.fromId(ctx, list.id); return await listModel.getBookmarkIds(visitedListIds); }), ) ).flat(), ), ]; if (listBookmarkIds.length === 0) { if (matcher.inverse) { return db .selectDistinct({ id: bookmarks.id }) .from(bookmarks) .where(eq(bookmarks.userId, userId)); } return []; } return db .selectDistinct({ id: bookmarks.id }) .from(bookmarks) .where( and( eq(bookmarks.userId, userId), matcher.inverse ? notInArray(bookmarks.id, listBookmarkIds) : inArray(bookmarks.id, listBookmarkIds), ), ); } case "inlist": { const comp = matcher.inList ? exists : notExists; return db .select({ id: bookmarks.id }) .from(bookmarks) .where( and( eq(bookmarks.userId, userId), comp( db .select() .from(bookmarksInLists) .where(and(eq(bookmarksInLists.bookmarkId, bookmarks.id))), ), ), ); } case "rssFeedName": { const comp = matcher.inverse ? notExists : exists; return db .selectDistinct({ id: bookmarks.id }) .from(bookmarks) .where( and( eq(bookmarks.userId, userId), comp( db .select() .from(rssFeedImportsTable) .innerJoin( rssFeedsTable, eq(rssFeedImportsTable.rssFeedId, rssFeedsTable.id), ) .where( and( eq(rssFeedImportsTable.bookmarkId, bookmarks.id), eq(rssFeedsTable.userId, userId), eq(rssFeedsTable.name, matcher.feedName), ), ), ), ), ); } case "archived": { return db .select({ id: bookmarks.id }) .from(bookmarks) .where( and( eq(bookmarks.userId, userId), eq(bookmarks.archived, matcher.archived), ), ); } case "url": { const comp = matcher.inverse ? notLike : like; return db .select({ id: bookmarkLinks.id }) .from(bookmarkLinks) .leftJoin(bookmarks, eq(bookmarks.id, bookmarkLinks.id)) .where( and( eq(bookmarks.userId, userId), comp(bookmarkLinks.url, `%${matcher.url}%`), ), ) .union( db .select({ id: bookmarkAssets.id }) .from(bookmarkAssets) .leftJoin(bookmarks, eq(bookmarks.id, bookmarkAssets.id)) .where( and( eq(bookmarks.userId, userId), // When a user is asking for a link, the inverse matcher should match only assets with URLs. isNotNull(bookmarkAssets.sourceUrl), comp(bookmarkAssets.sourceUrl, `%${matcher.url}%`), ), ), ); } case "title": { const comp = matcher.inverse ? notLike : like; if (matcher.inverse) { return db .select({ id: bookmarks.id }) .from(bookmarks) .leftJoin(bookmarkLinks, eq(bookmarks.id, bookmarkLinks.id)) .where( and( eq(bookmarks.userId, userId), or( isNull(bookmarks.title), comp(bookmarks.title, `%${matcher.title}%`), ), or( isNull(bookmarkLinks.title), comp(bookmarkLinks.title, `%${matcher.title}%`), ), ), ); } return db .select({ id: bookmarks.id }) .from(bookmarks) .where( and( eq(bookmarks.userId, userId), comp(bookmarks.title, `%${matcher.title}%`), ), ) .union( db .select({ id: bookmarkLinks.id }) .from(bookmarkLinks) .leftJoin(bookmarks, eq(bookmarks.id, bookmarkLinks.id)) .where( and( eq(bookmarks.userId, userId), comp(bookmarkLinks.title, `%${matcher.title}%`), ), ), ); } case "favourited": { return db .select({ id: bookmarks.id }) .from(bookmarks) .where( and( eq(bookmarks.userId, userId), eq(bookmarks.favourited, matcher.favourited), ), ); } case "dateAfter": { const comp = matcher.inverse ? lt : gte; return db .select({ id: bookmarks.id }) .from(bookmarks) .where( and( eq(bookmarks.userId, userId), comp(bookmarks.createdAt, matcher.dateAfter), ), ); } case "dateBefore": { const comp = matcher.inverse ? gt : lte; return db .select({ id: bookmarks.id }) .from(bookmarks) .where( and( eq(bookmarks.userId, userId), comp(bookmarks.createdAt, matcher.dateBefore), ), ); } case "age": { const comp = matcher.relativeDate.direction === "newer" ? gte : lt; return db .select({ id: bookmarks.id }) .from(bookmarks) .where( and( eq(bookmarks.userId, userId), comp(bookmarks.createdAt, toAbsoluteDate(matcher.relativeDate)), ), ); } case "type": { const comp = matcher.inverse ? ne : eq; return db .select({ id: bookmarks.id }) .from(bookmarks) .where( and( eq(bookmarks.userId, userId), comp(bookmarks.type, matcher.typeName), ), ); } case "brokenLinks": { // Only applies to bookmarks of type LINK return db .select({ id: bookmarkLinks.id }) .from(bookmarkLinks) .leftJoin(bookmarks, eq(bookmarks.id, bookmarkLinks.id)) .where( and( eq(bookmarks.userId, userId), matcher.brokenLinks ? or( eq(bookmarkLinks.crawlStatus, "failure"), lt(bookmarkLinks.crawlStatusCode, 200), gt(bookmarkLinks.crawlStatusCode, 299), ) : and( eq(bookmarkLinks.crawlStatus, "success"), gte(bookmarkLinks.crawlStatusCode, 200), lte(bookmarkLinks.crawlStatusCode, 299), ), ), ); } case "source": { return db .select({ id: bookmarks.id }) .from(bookmarks) .where( and( eq(bookmarks.userId, userId), matcher.inverse ? or( ne(bookmarks.source, matcher.source), isNull(bookmarks.source), ) : eq(bookmarks.source, matcher.source), ), ); } case "and": { const vals = await Promise.all( matcher.matchers.map((m) => getIds(ctx, m, visitedListIds)), ); return intersect(vals); } case "or": { const vals = await Promise.all( matcher.matchers.map((m) => getIds(ctx, m, visitedListIds)), ); return union(vals); } default: { const _exhaustiveCheck: never = matcher; throw new Error("Unknown matcher type"); } } } export async function getBookmarkIdsFromMatcher( ctx: AuthedContext, matcher: Matcher, visitedListIds = new Set(), ): Promise { const results = await getIds(ctx, matcher, visitedListIds); return results.map((r) => r.id); }