diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-12-08 00:31:46 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-12-08 00:31:46 +0000 |
| commit | 1f43f232f723f6cb38864fe150ab78b1c0c62cd3 (patch) | |
| tree | ea9c6ed0e475f0f0f548290c8192ab1ffbded0f8 | |
| parent | 13a090c4113efddc800b1f87a97e0244097bd4df (diff) | |
| download | karakeep-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.ts | 6 | ||||
| -rw-r--r-- | apps/web/lib/i18n/locales/en/translation.json | 2 | ||||
| -rw-r--r-- | docs/docs/14-guides/02-search-query-language.md | 1 | ||||
| -rw-r--r-- | packages/shared/searchQueryParser.test.ts | 16 | ||||
| -rw-r--r-- | packages/shared/searchQueryParser.ts | 5 | ||||
| -rw-r--r-- | packages/shared/types/search.ts | 7 | ||||
| -rw-r--r-- | packages/trpc/lib/search.ts | 23 |
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)), |
