From 9fd26b472b18924ab11afcebace90329b0fe3abf Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sun, 12 Jan 2025 20:03:47 +0000 Subject: feat: Add ability to filter by bookmark type --- .../dashboard/search/QueryExplainerTooltip.tsx | 16 +++++++ apps/web/lib/i18n/locales/en/translation.json | 10 +++- apps/web/lib/utils.ts | 14 ++++++ docs/docs/14-Guides/02-search-query-language.md | 27 ++++++----- packages/shared/searchQueryParser.test.ts | 55 ++++++++++++++++++++++ packages/shared/searchQueryParser.ts | 28 +++++++++++ packages/shared/types/search.ts | 14 ++++++ packages/trpc/lib/__tests__/search.test.ts | 48 +++++++++++++++++++ packages/trpc/lib/search.ts | 10 ++++ 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({ {matcher.url} ); + case "type": + return ( + + + {matcher.inverse ? t("search.type_is_not") : t("search.type_is")} + + + {match(matcher.typeName, { + link: t("common.bookmark_types.link"), + text: t("common.bookmark_types.text"), + asset: t("common.bookmark_types.media"), + })} + + + ); 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( + val: T, + options: Record, +) { + return options[val]; +} + +export function matchFunc( + val: T, + options: Record 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:` | Match bookmarks with URL substring | `url:example.com` | -| `#` | Match bookmarks with specific tag | `#important` | -| | Supports quoted strings for tags with spaces | `#"work in progress"` | -| `list:` | Match bookmarks in specific list | `list:reading` | -| | Supports quoted strings for list names with spaces | `list:"to review"` | -| `after:` | Bookmarks created on or after date (YYYY-MM-DD) | `after:2023-01-01` | -| `before:` | 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:` | Match bookmarks with URL substring | `url:example.com` | +| `#` | Match bookmarks with specific tag | `#important` | +| | Supports quoted strings for tags with spaces | `#"work in progress"` | +| `list:` | Match bookmarks in specific list | `list:reading` | +| | Supports quoted strings for list names with spaces | `list:"to review"` | +| `after:` | Bookmarks created on or after date (YYYY-MM-DD) | `after:2023-01-01` | +| `before:` | 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; @@ -79,6 +92,7 @@ export const zMatcherSchema: z.ZodType = 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)), -- cgit v1.2.3-70-g09d2