aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/web/components/dashboard/search/QueryExplainerTooltip.tsx16
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json10
-rw-r--r--apps/web/lib/utils.ts14
-rw-r--r--docs/docs/14-Guides/02-search-query-language.md27
-rw-r--r--packages/shared/searchQueryParser.test.ts55
-rw-r--r--packages/shared/searchQueryParser.ts28
-rw-r--r--packages/shared/types/search.ts14
-rw-r--r--packages/trpc/lib/__tests__/search.test.ts48
-rw-r--r--packages/trpc/lib/search.ts10
9 files changed, 208 insertions, 14 deletions
diff --git a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx
index f5f73be3..37815b0a 100644
--- a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx
+++ b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx
@@ -1,6 +1,7 @@
import InfoTooltip from "@/components/ui/info-tooltip";
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
import { useTranslation } from "@/lib/i18n/client";
+import { match } from "@/lib/utils";
import { TextAndMatcher } from "@hoarder/shared/searchQueryParser";
import { Matcher } from "@hoarder/shared/types/search";
@@ -134,6 +135,21 @@ export default function QueryExplainerTooltip({
<TableCell>{matcher.url}</TableCell>
</TableRow>
);
+ case "type":
+ return (
+ <TableRow>
+ <TableCell>
+ {matcher.inverse ? t("search.type_is_not") : t("search.type_is")}
+ </TableCell>
+ <TableCell>
+ {match(matcher.typeName, {
+ link: t("common.bookmark_types.link"),
+ text: t("common.bookmark_types.text"),
+ asset: t("common.bookmark_types.media"),
+ })}
+ </TableCell>
+ </TableRow>
+ );
default: {
const _exhaustiveCheck: never = matcher;
return null;
diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json
index ac08fa3f..1e9f8e4d 100644
--- a/apps/web/lib/i18n/locales/en/translation.json
+++ b/apps/web/lib/i18n/locales/en/translation.json
@@ -24,7 +24,13 @@
"screenshot": "Screenshot",
"video": "Video",
"archive": "Archive",
- "home": "Home"
+ "home": "Home",
+ "bookmark_types": {
+ "title": "Bookmark Type",
+ "link": "Link",
+ "text": "Text",
+ "media": "Media"
+ }
},
"layouts": {
"masonry": "Masonry",
@@ -218,6 +224,8 @@
"has_tag": "Has Tag",
"does_not_have_tag": "Does Not Have Tag",
"full_text_search": "Full Text Search",
+ "type_is": "Type is",
+ "type_is_not": "Type is not",
"and": "And",
"or": "Or"
},
diff --git a/apps/web/lib/utils.ts b/apps/web/lib/utils.ts
index 12207765..230c9eef 100644
--- a/apps/web/lib/utils.ts
+++ b/apps/web/lib/utils.ts
@@ -29,3 +29,17 @@ export function getOS() {
}
return os;
}
+
+export function match<T extends string | number | symbol, U>(
+ val: T,
+ options: Record<T, U>,
+) {
+ return options[val];
+}
+
+export function matchFunc<T extends string | number | symbol, U>(
+ val: T,
+ options: Record<T, () => U>,
+) {
+ return options[val]();
+}
diff --git a/docs/docs/14-Guides/02-search-query-language.md b/docs/docs/14-Guides/02-search-query-language.md
index fddd896c..b0d8ffd3 100644
--- a/docs/docs/14-Guides/02-search-query-language.md
+++ b/docs/docs/14-Guides/02-search-query-language.md
@@ -13,19 +13,20 @@ Hoarder provides a search query language to filter and find bookmarks. Here are
Here's a comprehensive table of all supported qualifiers:
-| Qualifier | Description | Example Usage |
-| --------------- | -------------------------------------------------- | --------------------- |
-| `is:fav` | Favorited bookmarks | `is:fav` |
-| `is:archived` | Archived bookmarks | `-is:archived` |
-| `is:tagged` | Bookmarks that has one or more tags | `is:tagged` |
-| `is:inlist` | Bookmarks that are in one or more lists | `is:inlist` |
-| `url:<value>` | Match bookmarks with URL substring | `url:example.com` |
-| `#<tag>` | Match bookmarks with specific tag | `#important` |
-| | Supports quoted strings for tags with spaces | `#"work in progress"` |
-| `list:<name>` | Match bookmarks in specific list | `list:reading` |
-| | Supports quoted strings for list names with spaces | `list:"to review"` |
-| `after:<date>` | Bookmarks created on or after date (YYYY-MM-DD) | `after:2023-01-01` |
-| `before:<date>` | Bookmarks created on orbefore date (YYYY-MM-DD) | `before:2023-12-31` |
+| Qualifier | Description | Example Usage |
+| -------------------------------- | -------------------------------------------------- | --------------------- |
+| `is:fav` | Favorited bookmarks | `is:fav` |
+| `is:archived` | Archived bookmarks | `-is:archived` |
+| `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` |
+| `url:<value>` | Match bookmarks with URL substring | `url:example.com` |
+| `#<tag>` | Match bookmarks with specific tag | `#important` |
+| | Supports quoted strings for tags with spaces | `#"work in progress"` |
+| `list:<name>` | Match bookmarks in specific list | `list:reading` |
+| | Supports quoted strings for list names with spaces | `list:"to review"` |
+| `after:<date>` | Bookmarks created on or after date (YYYY-MM-DD) | `after:2023-01-01` |
+| `before:<date>` | Bookmarks created on orbefore date (YYYY-MM-DD) | `before:2023-12-31` |
### Examples
diff --git a/packages/shared/searchQueryParser.test.ts b/packages/shared/searchQueryParser.test.ts
index 5af7ca2f..7430d58f 100644
--- a/packages/shared/searchQueryParser.test.ts
+++ b/packages/shared/searchQueryParser.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, test } from "vitest";
import { parseSearchQuery } from "./searchQueryParser";
+import { BookmarkTypes } from "./types/bookmarks";
describe("Search Query Parser", () => {
test("simple is queries", () => {
@@ -68,6 +69,60 @@ describe("Search Query Parser", () => {
inList: false,
},
});
+ expect(parseSearchQuery("is:link")).toEqual({
+ result: "full",
+ text: "",
+ matcher: {
+ type: "type",
+ typeName: BookmarkTypes.LINK,
+ inverse: false,
+ },
+ });
+ expect(parseSearchQuery("-is:link")).toEqual({
+ result: "full",
+ text: "",
+ matcher: {
+ type: "type",
+ typeName: BookmarkTypes.LINK,
+ inverse: true,
+ },
+ });
+ expect(parseSearchQuery("is:text")).toEqual({
+ result: "full",
+ text: "",
+ matcher: {
+ type: "type",
+ typeName: BookmarkTypes.TEXT,
+ inverse: false,
+ },
+ });
+ expect(parseSearchQuery("-is:text")).toEqual({
+ result: "full",
+ text: "",
+ matcher: {
+ type: "type",
+ typeName: BookmarkTypes.TEXT,
+ inverse: true,
+ },
+ });
+ expect(parseSearchQuery("is:media")).toEqual({
+ result: "full",
+ text: "",
+ matcher: {
+ type: "type",
+ typeName: BookmarkTypes.ASSET,
+ inverse: false,
+ },
+ });
+ expect(parseSearchQuery("-is:media")).toEqual({
+ result: "full",
+ text: "",
+ matcher: {
+ type: "type",
+ typeName: BookmarkTypes.ASSET,
+ inverse: true,
+ },
+ });
});
test("simple string queries", () => {
diff --git a/packages/shared/searchQueryParser.ts b/packages/shared/searchQueryParser.ts
index e52af274..4f68523b 100644
--- a/packages/shared/searchQueryParser.ts
+++ b/packages/shared/searchQueryParser.ts
@@ -15,6 +15,7 @@ import {
} from "typescript-parsec";
import { z } from "zod";
+import { BookmarkTypes } from "./types/bookmarks";
import { Matcher } from "./types/search";
enum TokenType {
@@ -136,6 +137,33 @@ MATCHER.setPattern(
text: "",
matcher: { type: "inlist", inList: !minus },
};
+ case "link":
+ return {
+ text: "",
+ matcher: {
+ type: "type",
+ typeName: BookmarkTypes.LINK,
+ inverse: !!minus,
+ },
+ };
+ case "text":
+ return {
+ text: "",
+ matcher: {
+ type: "type",
+ typeName: BookmarkTypes.TEXT,
+ inverse: !!minus,
+ },
+ };
+ case "media":
+ return {
+ text: "",
+ matcher: {
+ type: "type",
+ typeName: BookmarkTypes.ASSET,
+ inverse: !!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 9d97fdd8..19c5d0e2 100644
--- a/packages/shared/types/search.ts
+++ b/packages/shared/types/search.ts
@@ -1,5 +1,7 @@
import { z } from "zod";
+import { BookmarkTypes } from "./bookmarks";
+
const zTagNameMatcher = z.object({
type: z.literal("tagName"),
tagName: z.string(),
@@ -50,6 +52,16 @@ const zIsInListMatcher = z.object({
inList: z.boolean(),
});
+const zTypeMatcher = z.object({
+ type: z.literal("type"),
+ typeName: z.enum([
+ BookmarkTypes.LINK,
+ BookmarkTypes.TEXT,
+ BookmarkTypes.ASSET,
+ ]),
+ inverse: z.boolean(),
+});
+
const zNonRecursiveMatcher = z.union([
zTagNameMatcher,
zListNameMatcher,
@@ -60,6 +72,7 @@ const zNonRecursiveMatcher = z.union([
zDateBeforeMatcher,
zIsTaggedMatcher,
zIsInListMatcher,
+ zTypeMatcher,
]);
type NonRecursiveMatcher = z.infer<typeof zNonRecursiveMatcher>;
@@ -79,6 +92,7 @@ export const zMatcherSchema: z.ZodType<Matcher> = z.lazy(() => {
zDateBeforeMatcher,
zIsTaggedMatcher,
zIsInListMatcher,
+ zTypeMatcher,
z.object({
type: z.literal("and"),
matchers: z.array(zMatcherSchema),
diff --git a/packages/trpc/lib/__tests__/search.test.ts b/packages/trpc/lib/__tests__/search.test.ts
index 468aef83..8a6f1949 100644
--- a/packages/trpc/lib/__tests__/search.test.ts
+++ b/packages/trpc/lib/__tests__/search.test.ts
@@ -280,6 +280,54 @@ describe("getBookmarkIdsFromMatcher", () => {
expect(result).toEqual(["b1", "b2"]);
});
+ it("should handle type matcher", async () => {
+ expect(
+ await getBookmarkIdsFromMatcher(mockCtx, {
+ type: "type",
+ typeName: BookmarkTypes.LINK,
+ inverse: false,
+ }),
+ ).toEqual(["b1", "b2", "b4"]);
+ expect(
+ await getBookmarkIdsFromMatcher(mockCtx, {
+ type: "type",
+ typeName: BookmarkTypes.TEXT,
+ inverse: false,
+ }),
+ ).toEqual(["b3", "b5"]);
+ expect(
+ await getBookmarkIdsFromMatcher(mockCtx, {
+ type: "type",
+ typeName: BookmarkTypes.ASSET,
+ inverse: false,
+ }),
+ ).toEqual(["b6"]);
+ });
+
+ it("should handle type matcher with inverse=true", async () => {
+ expect(
+ await getBookmarkIdsFromMatcher(mockCtx, {
+ type: "type",
+ typeName: BookmarkTypes.LINK,
+ inverse: true,
+ }),
+ ).toEqual(["b3", "b5", "b6"]);
+ expect(
+ await getBookmarkIdsFromMatcher(mockCtx, {
+ type: "type",
+ typeName: BookmarkTypes.TEXT,
+ inverse: true,
+ }),
+ ).toEqual(["b1", "b2", "b4", "b6"]);
+ expect(
+ await getBookmarkIdsFromMatcher(mockCtx, {
+ type: "type",
+ typeName: BookmarkTypes.ASSET,
+ inverse: true,
+ }),
+ ).toEqual(["b1", "b2", "b3", "b4", "b5"]);
+ });
+
it("should handle dateBefore matcher with inverse=true", async () => {
const matcher: Matcher = {
type: "dateBefore",
diff --git a/packages/trpc/lib/search.ts b/packages/trpc/lib/search.ts
index 74aaf51b..eac323df 100644
--- a/packages/trpc/lib/search.ts
+++ b/packages/trpc/lib/search.ts
@@ -7,6 +7,7 @@ import {
like,
lt,
lte,
+ ne,
notExists,
notLike,
} from "drizzle-orm";
@@ -233,6 +234,15 @@ async function getIds(
),
);
}
+ case "type": {
+ const comp = matcher.inverse
+ ? ne(bookmarks.type, matcher.typeName)
+ : eq(bookmarks.type, matcher.typeName);
+ return db
+ .select({ id: bookmarks.id })
+ .from(bookmarks)
+ .where(and(eq(bookmarks.userId, userId), comp));
+ }
case "and": {
const vals = await Promise.all(
matcher.matchers.map((m) => getIds(db, userId, m)),