mirror of
https://github.com/karakeep-app/karakeep.git
synced 2026-01-09 07:44:58 +08:00
Add a new search qualifier `is:broken` that allows users to filter bookmarks with broken or failed links. This matches the functionality on the broken links settings page, where a link is considered broken if: - crawlStatus is "failure" - crawlStatusCode is less than 200 - crawlStatusCode is greater than 299 The qualifier supports negation with `-is:broken` to find working links. Changes: - Add brokenLinks matcher type definition - Update search query parser to handle is:broken qualifier - Implement query execution logic for broken links filtering - Add autocomplete support with translations - Add parser tests - Update search query language documentation Co-authored-by: Claude <noreply@anthropic.com>
402 lines
10 KiB
TypeScript
402 lines
10 KiB
TypeScript
import {
|
|
and,
|
|
eq,
|
|
exists,
|
|
gt,
|
|
gte,
|
|
isNotNull,
|
|
isNull,
|
|
like,
|
|
lt,
|
|
lte,
|
|
ne,
|
|
notExists,
|
|
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<string, number>();
|
|
const map = new Map<string, BookmarkQueryReturnType>();
|
|
|
|
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<string>();
|
|
const map = new Map<string, BookmarkQueryReturnType>();
|
|
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(
|
|
db: AuthedContext["db"],
|
|
userId: string,
|
|
matcher: Matcher,
|
|
): Promise<BookmarkQueryReturnType[]> {
|
|
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": {
|
|
const comp = matcher.inverse ? notExists : exists;
|
|
return db
|
|
.selectDistinct({ id: bookmarks.id })
|
|
.from(bookmarks)
|
|
.where(
|
|
and(
|
|
eq(bookmarks.userId, userId),
|
|
comp(
|
|
db
|
|
.select()
|
|
.from(bookmarksInLists)
|
|
.innerJoin(
|
|
bookmarkLists,
|
|
eq(bookmarksInLists.listId, bookmarkLists.id),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(bookmarksInLists.bookmarkId, bookmarks.id),
|
|
eq(bookmarkLists.userId, userId),
|
|
eq(bookmarkLists.name, matcher.listName),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
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 "and": {
|
|
const vals = await Promise.all(
|
|
matcher.matchers.map((m) => getIds(db, userId, m)),
|
|
);
|
|
return intersect(vals);
|
|
}
|
|
case "or": {
|
|
const vals = await Promise.all(
|
|
matcher.matchers.map((m) => getIds(db, userId, m)),
|
|
);
|
|
return union(vals);
|
|
}
|
|
default: {
|
|
const _exhaustiveCheck: never = matcher;
|
|
throw new Error("Unknown matcher type");
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function getBookmarkIdsFromMatcher(
|
|
ctx: AuthedContext,
|
|
matcher: Matcher,
|
|
): Promise<string[]> {
|
|
const results = await getIds(ctx.db, ctx.user.id, matcher);
|
|
return results.map((r) => r.id);
|
|
}
|