aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2026-02-08 15:53:14 +0000
committerGitHub <noreply@github.com>2026-02-08 15:53:14 +0000
commitb05a7531b76d580fc2378d3fed12f57e5f4b35b1 (patch)
treec80d578507321ebe43f540649daba2bdec47f939
parent960ca9b67915408f26b825886f2b6c6481a658dc (diff)
downloadkarakeep-b05a7531b76d580fc2378d3fed12f57e5f4b35b1.tar.zst
feat: add source filter to query language (#2465)
* feat: add source filter to query language Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * autocomplete source --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
-rw-r--r--apps/web/components/dashboard/search/QueryExplainerTooltip.tsx11
-rw-r--r--apps/web/components/dashboard/search/useSearchAutocomplete.ts58
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json2
-rw-r--r--docs/docs/04-using-karakeep/search-query-language.md1
-rw-r--r--packages/shared/searchQueryParser.test.ts36
-rw-r--r--packages/shared/searchQueryParser.ts24
-rw-r--r--packages/shared/types/search.ts10
-rw-r--r--packages/trpc/lib/search.ts16
8 files changed, 154 insertions, 4 deletions
diff --git a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx
index 15facb2d..4d3a690b 100644
--- a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx
+++ b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx
@@ -208,6 +208,17 @@ export default function QueryExplainerTooltip({
</TableCell>
</TableRow>
);
+ case "source":
+ return (
+ <TableRow>
+ <TableCell>
+ {matcher.inverse
+ ? t("search.is_not_from_source")
+ : t("search.is_from_source")}
+ </TableCell>
+ <TableCell>{matcher.source}</TableCell>
+ </TableRow>
+ );
default: {
const _exhaustiveCheck: never = matcher;
return null;
diff --git a/apps/web/components/dashboard/search/useSearchAutocomplete.ts b/apps/web/components/dashboard/search/useSearchAutocomplete.ts
index 426e0835..f98f23f5 100644
--- a/apps/web/components/dashboard/search/useSearchAutocomplete.ts
+++ b/apps/web/components/dashboard/search/useSearchAutocomplete.ts
@@ -4,6 +4,7 @@ import type { LucideIcon } from "lucide-react";
import { useCallback, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import {
+ Globe,
History,
ListTree,
RssIcon,
@@ -15,6 +16,7 @@ import { useBookmarkLists } from "@karakeep/shared-react/hooks/lists";
import { useTagAutocomplete } from "@karakeep/shared-react/hooks/tags";
import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce";
import { useTRPC } from "@karakeep/shared-react/trpc";
+import { zBookmarkSourceSchema } from "@karakeep/shared/types/bookmarks";
const MAX_DISPLAY_SUGGESTIONS = 5;
@@ -98,10 +100,14 @@ const QUALIFIER_DEFINITIONS = [
value: "age:",
descriptionKey: "search.created_within",
},
+ {
+ value: "source:",
+ descriptionKey: "search.is_from_source",
+ },
] satisfies ReadonlyArray<QualifierDefinition>;
export interface AutocompleteSuggestionItem {
- type: "token" | "tag" | "list" | "feed";
+ type: "token" | "tag" | "list" | "feed" | "source";
id: string;
label: string;
insertText: string;
@@ -398,6 +404,46 @@ const useListSuggestions = (
return listSuggestions;
};
+const SOURCE_VALUES = zBookmarkSourceSchema.options;
+
+const useSourceSuggestions = (
+ parsed: ParsedSearchState,
+): AutocompleteSuggestionItem[] => {
+ const shouldSuggestSources =
+ parsed.normalizedTokenWithoutMinus.startsWith("source:");
+ const sourceSearchTerm = shouldSuggestSources
+ ? parsed.normalizedTokenWithoutMinus.slice("source:".length)
+ : "";
+
+ const sourceSuggestions = useMemo<AutocompleteSuggestionItem[]>(() => {
+ if (!shouldSuggestSources) {
+ return [];
+ }
+
+ return SOURCE_VALUES.filter((source) => {
+ if (sourceSearchTerm.length === 0) {
+ return true;
+ }
+ return source.startsWith(sourceSearchTerm);
+ })
+ .slice(0, MAX_DISPLAY_SUGGESTIONS)
+ .map((source) => {
+ const insertText = `${parsed.isTokenNegative ? "-" : ""}source:${source}`;
+ return {
+ type: "source" as const,
+ id: `source-${source}`,
+ label: insertText,
+ insertText,
+ appendSpace: true,
+ description: undefined,
+ Icon: Globe,
+ } satisfies AutocompleteSuggestionItem;
+ });
+ }, [shouldSuggestSources, sourceSearchTerm, parsed.isTokenNegative]);
+
+ return sourceSuggestions;
+};
+
const useHistorySuggestions = (
value: string,
history: string[],
@@ -440,6 +486,7 @@ export const useSearchAutocomplete = ({
const tagSuggestions = useTagSuggestions(parsedState);
const listSuggestions = useListSuggestions(parsedState);
const feedSuggestions = useFeedSuggestions(parsedState);
+ const sourceSuggestions = useSourceSuggestions(parsedState);
const historyItems = useHistorySuggestions(value, history);
const { activeToken, getActiveToken } = parsedState;
@@ -470,6 +517,14 @@ export const useSearchAutocomplete = ({
});
}
+ if (sourceSuggestions.length > 0) {
+ groups.push({
+ id: "sources",
+ label: t("search.is_from_source"),
+ items: sourceSuggestions,
+ });
+ }
+
// Only suggest qualifiers if no other suggestions are available
if (groups.length === 0 && qualifierSuggestions.length > 0) {
groups.push({
@@ -493,6 +548,7 @@ export const useSearchAutocomplete = ({
tagSuggestions,
listSuggestions,
feedSuggestions,
+ sourceSuggestions,
historyItems,
t,
]);
diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json
index 06cfd7a1..567ffb49 100644
--- a/apps/web/lib/i18n/locales/en/translation.json
+++ b/apps/web/lib/i18n/locales/en/translation.json
@@ -802,6 +802,8 @@
"type_is_not": "Type is not",
"is_from_feed": "Is from RSS Feed",
"is_not_from_feed": "Is not from RSS Feed",
+ "is_from_source": "Source is",
+ "is_not_from_source": "Source is not",
"is_broken_link": "Has Broken Link",
"is_not_broken_link": "Has Working Link",
"and": "And",
diff --git a/docs/docs/04-using-karakeep/search-query-language.md b/docs/docs/04-using-karakeep/search-query-language.md
index de11e533..cdf1f56e 100644
--- a/docs/docs/04-using-karakeep/search-query-language.md
+++ b/docs/docs/04-using-karakeep/search-query-language.md
@@ -36,6 +36,7 @@ Here's a comprehensive table of all supported qualifiers:
| `after:<date>` | Bookmarks created on or after date (YYYY-MM-DD) | `after:2023-01-01` |
| `before:<date>` | Bookmarks created on or before date (YYYY-MM-DD) | `before:2023-12-31` |
| `feed:<name>` | Bookmarks imported from a particular rss feed | `feed:Hackernews` |
+| `source:<value>` | Match bookmarks from a specific source. Valid values: `api`, `web`, `cli`, `mobile`, `extension`, `singlefile`, `rss`, `import` | `source:rss` `-source:web` |
| `age:<time-range>` | Match bookmarks based on how long ago they were created. Use `<` or `>` to indicate the maximum / minimum age of the bookmarks. Supported units: `d` (days), `w` (weeks), `m` (months), `y` (years). | `age:<1d` `age:>2w` `age:<6m` `age:>3y` |
### Examples
diff --git a/packages/shared/searchQueryParser.test.ts b/packages/shared/searchQueryParser.test.ts
index 3954e871..37275284 100644
--- a/packages/shared/searchQueryParser.test.ts
+++ b/packages/shared/searchQueryParser.test.ts
@@ -332,6 +332,42 @@ describe("Search Query Parser", () => {
inverse: true,
},
});
+ expect(parseSearchQuery("source:rss")).toEqual({
+ result: "full",
+ text: "",
+ matcher: {
+ type: "source",
+ source: "rss",
+ inverse: false,
+ },
+ });
+ expect(parseSearchQuery("-source:rss")).toEqual({
+ result: "full",
+ text: "",
+ matcher: {
+ type: "source",
+ source: "rss",
+ inverse: true,
+ },
+ });
+ expect(parseSearchQuery("source:web")).toEqual({
+ result: "full",
+ text: "",
+ matcher: {
+ type: "source",
+ source: "web",
+ inverse: false,
+ },
+ });
+ expect(parseSearchQuery("-source:web")).toEqual({
+ result: "full",
+ text: "",
+ matcher: {
+ type: "source",
+ source: "web",
+ inverse: true,
+ },
+ });
});
test("! negation alias for -", () => {
// ! should work exactly like - for negation
diff --git a/packages/shared/searchQueryParser.ts b/packages/shared/searchQueryParser.ts
index 027a662f..7eb3b185 100644
--- a/packages/shared/searchQueryParser.ts
+++ b/packages/shared/searchQueryParser.ts
@@ -16,7 +16,7 @@ import {
} from "typescript-parsec";
import { z } from "zod";
-import { BookmarkTypes } from "./types/bookmarks";
+import { BookmarkTypes, zBookmarkSourceSchema } from "./types/bookmarks";
import { Matcher } from "./types/search";
import { parseRelativeDate } from "./utils/relativeDateUtils";
@@ -42,7 +42,10 @@ const lexerRules: [RegExp, TokenType][] = [
[/^\s+or/i, TokenType.Or],
[/^#/, TokenType.Hash],
- [/^(is|url|list|after|before|age|feed|title|tag):/, TokenType.Qualifier],
+ [
+ /^(is|url|list|after|before|age|feed|title|tag|source):/,
+ TokenType.Qualifier,
+ ],
[/^"([^"]+)"/, TokenType.StringLiteral],
@@ -230,6 +233,23 @@ MATCHER.setPattern(
inverse: !!minus,
},
};
+ case "source:": {
+ const parsed = zBookmarkSourceSchema.safeParse(ident);
+ if (!parsed.success) {
+ return {
+ text: (minus?.text ?? "") + qualifier.text + ident,
+ matcher: undefined,
+ };
+ }
+ return {
+ text: "",
+ matcher: {
+ type: "source",
+ source: parsed.data,
+ inverse: !!minus,
+ },
+ };
+ }
case "after:":
try {
return {
diff --git a/packages/shared/types/search.ts b/packages/shared/types/search.ts
index c29270b8..b653d883 100644
--- a/packages/shared/types/search.ts
+++ b/packages/shared/types/search.ts
@@ -1,6 +1,6 @@
import { z } from "zod";
-import { BookmarkTypes } from "./bookmarks";
+import { BookmarkTypes, zBookmarkSourceSchema } from "./bookmarks";
const zTagNameMatcher = z.object({
type: z.literal("tagName"),
@@ -88,6 +88,12 @@ const zBrokenLinksMatcher = z.object({
brokenLinks: z.boolean(),
});
+const zSourceMatcher = z.object({
+ type: z.literal("source"),
+ source: zBookmarkSourceSchema,
+ inverse: z.boolean(),
+});
+
const zNonRecursiveMatcher = z.union([
zTagNameMatcher,
zListNameMatcher,
@@ -103,6 +109,7 @@ const zNonRecursiveMatcher = z.union([
zTypeMatcher,
zRssFeedNameMatcher,
zBrokenLinksMatcher,
+ zSourceMatcher,
]);
type NonRecursiveMatcher = z.infer<typeof zNonRecursiveMatcher>;
@@ -127,6 +134,7 @@ export const zMatcherSchema: z.ZodType<Matcher> = z.lazy(() => {
zTypeMatcher,
zRssFeedNameMatcher,
zBrokenLinksMatcher,
+ zSourceMatcher,
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 88f10f22..51e51d1c 100644
--- a/packages/trpc/lib/search.ts
+++ b/packages/trpc/lib/search.ts
@@ -373,6 +373,22 @@ async function getIds(
),
);
}
+ 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(db, userId, m)),