diff options
| -rw-r--r-- | apps/web/components/dashboard/search/QueryExplainerTooltip.tsx | 11 | ||||
| -rw-r--r-- | apps/web/lib/i18n/locales/en/translation.json | 2 | ||||
| -rw-r--r-- | apps/web/lib/i18n/locales/en_US/translation.json | 2 | ||||
| -rw-r--r-- | docs/docs/14-guides/02-search-query-language.md | 4 | ||||
| -rw-r--r-- | packages/shared/searchQueryParser.test.ts | 36 | ||||
| -rw-r--r-- | packages/shared/searchQueryParser.ts | 7 | ||||
| -rw-r--r-- | packages/shared/types/search.ts | 8 | ||||
| -rw-r--r-- | packages/trpc/lib/__tests__/search.test.ts | 28 | ||||
| -rw-r--r-- | packages/trpc/lib/search.ts | 46 |
9 files changed, 142 insertions, 2 deletions
diff --git a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx index e6abb9c9..529b7dd9 100644 --- a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx +++ b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx @@ -161,6 +161,17 @@ export default function QueryExplainerTooltip({ <TableCell>{matcher.url}</TableCell> </TableRow> ); + case "title": + return ( + <TableRow> + <TableCell> + {matcher.inverse + ? t("search.title_does_not_contain") + : t("search.title_contains")} + </TableCell> + <TableCell>{matcher.title}</TableCell> + </TableRow> + ); case "rssFeedName": return ( <TableRow> diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index 2d7fa1c0..a5dddd56 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -470,6 +470,8 @@ "year_s_ago": " Year(s) Ago", "url_contains": "URL Contains", "url_does_not_contain": "URL Does Not Contain", + "title_contains": "Title Contains", + "title_does_not_contain": "Title Does Not Contain", "is_in_list": "Is In List", "is_not_in_list": "Is not In List", "has_tag": "Has Tag", diff --git a/apps/web/lib/i18n/locales/en_US/translation.json b/apps/web/lib/i18n/locales/en_US/translation.json index 4e066f94..bec0705e 100644 --- a/apps/web/lib/i18n/locales/en_US/translation.json +++ b/apps/web/lib/i18n/locales/en_US/translation.json @@ -571,6 +571,8 @@ "type_is": "Type is", "type_is_not": "Type is not", "url_does_not_contain": "URL does not contain", + "title_contains": "Title contains", + "title_does_not_contain": "Title does not contain", "is_from_feed": "Is from RSS feed", "is_not_from_feed": "Is not from RSS feed", "and": "And", diff --git a/docs/docs/14-guides/02-search-query-language.md b/docs/docs/14-guides/02-search-query-language.md index 55e32b98..6ca0a401 100644 --- a/docs/docs/14-guides/02-search-query-language.md +++ b/docs/docs/14-guides/02-search-query-language.md @@ -21,6 +21,8 @@ Here's a comprehensive table of all supported qualifiers: | `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` | +| `title:<value>` | Match bookmarks with title substring | `title:example` | +| | Supports quoted strings for titles with spaces | `title:"my title"` | | `#<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` | @@ -41,6 +43,8 @@ is:archived and (list:reading or #work) # Find bookmarks that are not tagged or not in any list -is:tagged or -is:inlist +# Find bookmarks with "React" in the title +title:React ``` ## Combining Conditions diff --git a/packages/shared/searchQueryParser.test.ts b/packages/shared/searchQueryParser.test.ts index 7a86ecb5..3fe3f388 100644 --- a/packages/shared/searchQueryParser.test.ts +++ b/packages/shared/searchQueryParser.test.ts @@ -162,6 +162,42 @@ describe("Search Query Parser", () => { inverse: true, }, }); + expect(parseSearchQuery("title:example")).toEqual({ + result: "full", + text: "", + matcher: { + type: "title", + title: "example", + inverse: false, + }, + }); + expect(parseSearchQuery("-title:example")).toEqual({ + result: "full", + text: "", + matcher: { + type: "title", + title: "example", + inverse: true, + }, + }); + expect(parseSearchQuery('title:"my title"')).toEqual({ + result: "full", + text: "", + matcher: { + type: "title", + title: "my title", + inverse: false, + }, + }); + expect(parseSearchQuery('-title:"my title"')).toEqual({ + result: "full", + text: "", + matcher: { + type: "title", + title: "my title", + inverse: true, + }, + }); expect(parseSearchQuery("#my-tag")).toEqual({ result: "full", text: "", diff --git a/packages/shared/searchQueryParser.ts b/packages/shared/searchQueryParser.ts index 9a29a8b7..f919df96 100644 --- a/packages/shared/searchQueryParser.ts +++ b/packages/shared/searchQueryParser.ts @@ -41,7 +41,7 @@ const lexerRules: [RegExp, TokenType][] = [ [/^\s+or/i, TokenType.Or], [/^#/, TokenType.Hash], - [/^(is|url|list|after|before|age|feed):/, TokenType.Qualifier], + [/^(is|url|list|after|before|age|feed|title):/, TokenType.Qualifier], [/^"([^"]+)"/, TokenType.StringLiteral], @@ -195,6 +195,11 @@ MATCHER.setPattern( text: "", matcher: { type: "url", url: ident, inverse: !!minus }, }; + case "title:": + return { + text: "", + matcher: { type: "title", title: ident, inverse: !!minus }, + }; case "#": return { text: "", diff --git a/packages/shared/types/search.ts b/packages/shared/types/search.ts index 26d5bd42..caedcf94 100644 --- a/packages/shared/types/search.ts +++ b/packages/shared/types/search.ts @@ -31,6 +31,12 @@ const zUrlMatcher = z.object({ inverse: z.boolean(), }); +const zTitleMatcher = z.object({ + type: z.literal("title"), + title: z.string(), + inverse: z.boolean(), +}); + const zFavouritedMatcher = z.object({ type: z.literal("favourited"), favourited: z.boolean(), @@ -82,6 +88,7 @@ const zNonRecursiveMatcher = z.union([ zListNameMatcher, zArchivedMatcher, zUrlMatcher, + zTitleMatcher, zFavouritedMatcher, zDateAfterMatcher, zDateBeforeMatcher, @@ -104,6 +111,7 @@ export const zMatcherSchema: z.ZodType<Matcher> = z.lazy(() => { zListNameMatcher, zArchivedMatcher, zUrlMatcher, + zTitleMatcher, zFavouritedMatcher, zDateAfterMatcher, zDateBeforeMatcher, diff --git a/packages/trpc/lib/__tests__/search.test.ts b/packages/trpc/lib/__tests__/search.test.ts index 9d9b39d7..ee8bfb60 100644 --- a/packages/trpc/lib/__tests__/search.test.ts +++ b/packages/trpc/lib/__tests__/search.test.ts @@ -45,6 +45,7 @@ beforeEach(async () => { archived: false, favourited: false, createdAt: new Date("2024-01-01"), + title: null, }, { id: "b2", @@ -53,6 +54,7 @@ beforeEach(async () => { archived: true, favourited: true, createdAt: new Date("2024-01-02"), + title: "example domain page", }, { id: "b3", @@ -61,6 +63,7 @@ beforeEach(async () => { archived: true, favourited: false, createdAt: new Date("2024-01-03"), + title: "third bookmark", }, { id: "b4", @@ -69,6 +72,7 @@ beforeEach(async () => { archived: false, favourited: true, createdAt: new Date("2024-01-04"), + title: "another example page", }, { id: "b5", @@ -77,6 +81,7 @@ beforeEach(async () => { archived: false, favourited: false, createdAt: new Date("2024-01-05"), + title: "fifth text", }, { id: "b6", @@ -85,11 +90,12 @@ beforeEach(async () => { archived: true, favourited: false, createdAt: new Date("2024-01-06"), + title: "example asset", }, ]); await db.insert(bookmarkLinks).values([ - { id: "b1", url: "https://example.com/page1" }, + { id: "b1", url: "https://example.com/page1", title: "example link" }, { id: "b2", url: "https://test.com/page2" }, { id: "b4", url: "https://example.com/page3" }, ]); @@ -279,6 +285,26 @@ describe("getBookmarkIdsFromMatcher", () => { expect(result.sort()).toEqual(["b2"]); }); + it("should handle title matcher", async () => { + const matcher: Matcher = { + type: "title", + title: "example", + inverse: false, + }; + const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); + expect(result.sort()).toEqual(["b1", "b2", "b4", "b6"]); + }); + + it("should handle title matcher with inverse=true", async () => { + const matcher: Matcher = { + type: "title", + title: "example", + inverse: true, + }; + const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); + expect(result.sort()).toEqual(["b3", "b5"]); + }); + it("should handle dateAfter matcher", async () => { const matcher: Matcher = { type: "dateAfter", diff --git a/packages/trpc/lib/search.ts b/packages/trpc/lib/search.ts index d4130798..67348141 100644 --- a/packages/trpc/lib/search.ts +++ b/packages/trpc/lib/search.ts @@ -5,12 +5,14 @@ import { gt, gte, isNotNull, + isNull, like, lt, lte, ne, notExists, notLike, + or, } from "drizzle-orm"; import { @@ -245,6 +247,50 @@ async function getIds( ), ); } + case "title": { + const comp = matcher.inverse ? notLike : like; + if (matcher.inverse) { + return db + .select({ id: bookmarks.id }) + .from(bookmarks) + .leftJoin(bookmarkLinks, eq(bookmarks.id, bookmarkLinks.id)) + .where( + and( + eq(bookmarks.userId, userId), + or( + isNull(bookmarks.title), + comp(bookmarks.title, `%${matcher.title}%`), + ), + or( + isNull(bookmarkLinks.title), + comp(bookmarkLinks.title, `%${matcher.title}%`), + ), + ), + ); + } + + return db + .select({ id: bookmarks.id }) + .from(bookmarks) + .where( + and( + eq(bookmarks.userId, userId), + comp(bookmarks.title, `%${matcher.title}%`), + ), + ) + .union( + db + .select({ id: bookmarkLinks.id }) + .from(bookmarkLinks) + .leftJoin(bookmarks, eq(bookmarks.id, bookmarkLinks.id)) + .where( + and( + eq(bookmarks.userId, userId), + comp(bookmarkLinks.title, `%${matcher.title}%`), + ), + ), + ); + } case "favourited": { return db .select({ id: bookmarks.id }) |
