aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-12-08 00:31:46 +0000
committerGitHub <noreply@github.com>2025-12-08 00:31:46 +0000
commit1f43f232f723f6cb38864fe150ab78b1c0c62cd3 (patch)
treeea9c6ed0e475f0f0f548290c8192ab1ffbded0f8
parent13a090c4113efddc800b1f87a97e0244097bd4df (diff)
downloadkarakeep-1f43f232f723f6cb38864fe150ab78b1c0c62cd3.tar.zst
feat: add is:broken search qualifier for broken links (#2225)
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>
-rw-r--r--apps/web/components/dashboard/search/useSearchAutocomplete.ts6
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json2
-rw-r--r--docs/docs/14-guides/02-search-query-language.md1
-rw-r--r--packages/shared/searchQueryParser.test.ts16
-rw-r--r--packages/shared/searchQueryParser.ts5
-rw-r--r--packages/shared/types/search.ts7
-rw-r--r--packages/trpc/lib/search.ts23
7 files changed, 60 insertions, 0 deletions
diff --git a/apps/web/components/dashboard/search/useSearchAutocomplete.ts b/apps/web/components/dashboard/search/useSearchAutocomplete.ts
index cce5fc0b..ba55d51f 100644
--- a/apps/web/components/dashboard/search/useSearchAutocomplete.ts
+++ b/apps/web/components/dashboard/search/useSearchAutocomplete.ts
@@ -64,6 +64,12 @@ const QUALIFIER_DEFINITIONS = [
appendSpace: true,
},
{
+ value: "is:broken",
+ descriptionKey: "search.is_broken_link",
+ negatedDescriptionKey: "search.is_not_broken_link",
+ appendSpace: true,
+ },
+ {
value: "url:",
descriptionKey: "search.url_contains",
},
diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json
index ccd80ba2..33c7d6e2 100644
--- a/apps/web/lib/i18n/locales/en/translation.json
+++ b/apps/web/lib/i18n/locales/en/translation.json
@@ -662,6 +662,8 @@
"type_is_not": "Type is not",
"is_from_feed": "Is from RSS Feed",
"is_not_from_feed": "Is not from RSS Feed",
+ "is_broken_link": "Has Broken Link",
+ "is_not_broken_link": "Has Working Link",
"and": "And",
"or": "Or",
"history": "Recent Searches",
diff --git a/docs/docs/14-guides/02-search-query-language.md b/docs/docs/14-guides/02-search-query-language.md
index 6ca0a401..8f55ec2f 100644
--- a/docs/docs/14-guides/02-search-query-language.md
+++ b/docs/docs/14-guides/02-search-query-language.md
@@ -20,6 +20,7 @@ Here's a comprehensive table of all supported qualifiers:
| `is:tagged` | Bookmarks that has one or more tags | `is:tagged` |
| `is:inlist` | Bookmarks that are in one or more lists | `is:inlist` |
| `is:link`, `is:text`, `is:media` | Bookmarks that are of type link, text or media | `is:link` |
+| `is:broken` | Bookmarks with broken/failed links (crawl failures or non-2xx status codes) | `is:broken` |
| `url:<value>` | Match bookmarks with URL substring | `url:example.com` |
| `title:<value>` | Match bookmarks with title substring | `title:example` |
| | Supports quoted strings for titles with spaces | `title:"my title"` |
diff --git a/packages/shared/searchQueryParser.test.ts b/packages/shared/searchQueryParser.test.ts
index 3fe3f388..aa11433f 100644
--- a/packages/shared/searchQueryParser.test.ts
+++ b/packages/shared/searchQueryParser.test.ts
@@ -123,6 +123,22 @@ describe("Search Query Parser", () => {
inverse: true,
},
});
+ expect(parseSearchQuery("is:broken")).toEqual({
+ result: "full",
+ text: "",
+ matcher: {
+ type: "brokenLinks",
+ brokenLinks: true,
+ },
+ });
+ expect(parseSearchQuery("-is:broken")).toEqual({
+ result: "full",
+ text: "",
+ matcher: {
+ type: "brokenLinks",
+ brokenLinks: false,
+ },
+ });
});
test("simple string queries", () => {
diff --git a/packages/shared/searchQueryParser.ts b/packages/shared/searchQueryParser.ts
index f919df96..7447593a 100644
--- a/packages/shared/searchQueryParser.ts
+++ b/packages/shared/searchQueryParser.ts
@@ -166,6 +166,11 @@ MATCHER.setPattern(
inverse: !!minus,
},
};
+ case "broken":
+ return {
+ text: "",
+ matcher: { type: "brokenLinks", brokenLinks: !minus },
+ };
default:
// If the token is not known, emit it as pure text
return {
diff --git a/packages/shared/types/search.ts b/packages/shared/types/search.ts
index caedcf94..c29270b8 100644
--- a/packages/shared/types/search.ts
+++ b/packages/shared/types/search.ts
@@ -83,6 +83,11 @@ const zTypeMatcher = z.object({
inverse: z.boolean(),
});
+const zBrokenLinksMatcher = z.object({
+ type: z.literal("brokenLinks"),
+ brokenLinks: z.boolean(),
+});
+
const zNonRecursiveMatcher = z.union([
zTagNameMatcher,
zListNameMatcher,
@@ -97,6 +102,7 @@ const zNonRecursiveMatcher = z.union([
zIsInListMatcher,
zTypeMatcher,
zRssFeedNameMatcher,
+ zBrokenLinksMatcher,
]);
type NonRecursiveMatcher = z.infer<typeof zNonRecursiveMatcher>;
@@ -120,6 +126,7 @@ export const zMatcherSchema: z.ZodType<Matcher> = z.lazy(() => {
zIsInListMatcher,
zTypeMatcher,
zRssFeedNameMatcher,
+ zBrokenLinksMatcher,
z.object({
type: z.literal("and"),
matchers: z.array(zMatcherSchema),
diff --git a/packages/trpc/lib/search.ts b/packages/trpc/lib/search.ts
index 67348141..88f10f22 100644
--- a/packages/trpc/lib/search.ts
+++ b/packages/trpc/lib/search.ts
@@ -350,6 +350,29 @@ async function getIds(
),
);
}
+ 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)),