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-- | packages/shared/searchQueryParser.test.ts | 36 | ||||
| -rw-r--r-- | packages/shared/searchQueryParser.ts | 11 | ||||
| -rw-r--r-- | packages/shared/types/search.ts | 8 | ||||
| -rw-r--r-- | packages/trpc/lib/__tests__/search.test.ts | 48 | ||||
| -rw-r--r-- | packages/trpc/lib/search.ts | 29 |
7 files changed, 144 insertions, 1 deletions
diff --git a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx index 37815b0a..ee99eb8d 100644 --- a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx +++ b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx @@ -135,6 +135,17 @@ export default function QueryExplainerTooltip({ <TableCell>{matcher.url}</TableCell> </TableRow> ); + case "rssFeedName": + return ( + <TableRow> + <TableCell> + {matcher.inverse + ? t("search.is_not_from_feed") + : t("search.is_from_feed")} + </TableCell> + <TableCell>{matcher.feedName}</TableCell> + </TableRow> + ); case "type": return ( <TableRow> diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index 2a181285..d03ddfe7 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -261,6 +261,8 @@ "full_text_search": "Full Text Search", "type_is": "Type is", "type_is_not": "Type is not", + "is_from_feed": "Is from RSS Feed", + "is_not_from_feed": "Is not from RSS Feed", "and": "And", "or": "Or" }, diff --git a/packages/shared/searchQueryParser.test.ts b/packages/shared/searchQueryParser.test.ts index 17accd1e..ff69756c 100644 --- a/packages/shared/searchQueryParser.test.ts +++ b/packages/shared/searchQueryParser.test.ts @@ -244,6 +244,42 @@ describe("Search Query Parser", () => { inverse: true, }, }); + expect(parseSearchQuery("feed:my-feed")).toEqual({ + result: "full", + text: "", + matcher: { + type: "rssFeedName", + feedName: "my-feed", + inverse: false, + }, + }); + expect(parseSearchQuery("-feed:my-feed")).toEqual({ + result: "full", + text: "", + matcher: { + type: "rssFeedName", + feedName: "my-feed", + inverse: true, + }, + }); + expect(parseSearchQuery('feed:"my feed"')).toEqual({ + result: "full", + text: "", + matcher: { + type: "rssFeedName", + feedName: "my feed", + inverse: false, + }, + }); + expect(parseSearchQuery('-feed:"my feed"')).toEqual({ + result: "full", + text: "", + matcher: { + type: "rssFeedName", + feedName: "my feed", + inverse: true, + }, + }); }); test("date queries", () => { expect(parseSearchQuery("after:2023-10-12")).toEqual({ diff --git a/packages/shared/searchQueryParser.ts b/packages/shared/searchQueryParser.ts index 3d8a1519..d4e2bf2b 100644 --- a/packages/shared/searchQueryParser.ts +++ b/packages/shared/searchQueryParser.ts @@ -40,7 +40,7 @@ const lexerRules: [RegExp, TokenType][] = [ [/^\s+or/i, TokenType.Or], [/^#/, TokenType.Hash], - [/^(is|url|list|after|before):/, TokenType.Qualifier], + [/^(is|url|list|after|before|feed):/, TokenType.Qualifier], [/^"([^"]+)"/, TokenType.StringLiteral], @@ -204,6 +204,15 @@ MATCHER.setPattern( text: "", matcher: { type: "listName", listName: ident, inverse: !!minus }, }; + case "feed:": + return { + text: "", + matcher: { + type: "rssFeedName", + feedName: ident, + inverse: !!minus, + }, + }; case "after:": try { return { diff --git a/packages/shared/types/search.ts b/packages/shared/types/search.ts index 19c5d0e2..533eea25 100644 --- a/packages/shared/types/search.ts +++ b/packages/shared/types/search.ts @@ -14,6 +14,12 @@ const zListNameMatcher = z.object({ inverse: z.boolean(), }); +const zRssFeedNameMatcher = z.object({ + type: z.literal("rssFeedName"), + feedName: z.string(), + inverse: z.boolean(), +}); + const zArchivedMatcher = z.object({ type: z.literal("archived"), archived: z.boolean(), @@ -73,6 +79,7 @@ const zNonRecursiveMatcher = z.union([ zIsTaggedMatcher, zIsInListMatcher, zTypeMatcher, + zRssFeedNameMatcher, ]); type NonRecursiveMatcher = z.infer<typeof zNonRecursiveMatcher>; @@ -93,6 +100,7 @@ export const zMatcherSchema: z.ZodType<Matcher> = z.lazy(() => { zIsTaggedMatcher, zIsInListMatcher, zTypeMatcher, + zRssFeedNameMatcher, 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 7f573b4f..9f8aac88 100644 --- a/packages/trpc/lib/__tests__/search.test.ts +++ b/packages/trpc/lib/__tests__/search.test.ts @@ -9,6 +9,8 @@ import { bookmarksInLists, bookmarkTags, bookmarkTexts, + rssFeedImportsTable, + rssFeedsTable, tagsOnBookmarks, users, } from "@hoarder/db/schema"; @@ -151,6 +153,32 @@ beforeEach(async () => { { bookmarkId: "b6", listId: "l1" }, ]); + await db.insert(rssFeedsTable).values([ + { id: "f1", userId: testUserId, name: "feed1", url: "url1" }, + { id: "f2", userId: testUserId, name: "feed2", url: "url2" }, + ]); + + await db.insert(rssFeedImportsTable).values([ + { + id: "imp1", + entryId: "entry1", + rssFeedId: "f1", + bookmarkId: "b1", + }, + { + id: "imp2", + entryId: "entry2", + rssFeedId: "f2", + bookmarkId: "b3", + }, + { + id: "imp3", + entryId: "entry3", + rssFeedId: "f1", + bookmarkId: "b5", + }, + ]); + mockCtx = { db, user: { @@ -423,6 +451,26 @@ describe("getBookmarkIdsFromMatcher", () => { expect(result).toEqual(["b3"]); }); + it("should handle rssFeedName matcher", async () => { + const matcher: Matcher = { + type: "rssFeedName", + feedName: "feed1", + inverse: false, + }; + const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); + expect(result.sort()).toEqual(["b1", "b5"]); + }); + + it("should handle rssFeedName matcher with inverse=true", async () => { + const matcher: Matcher = { + type: "rssFeedName", + feedName: "feed1", + inverse: true, + }; + const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); + expect(result.sort()).toEqual(["b2", "b3", "b4", "b6"]); + }); + it("should throw error for unknown matcher type", async () => { const matcher = { type: "unknown" } as unknown as Matcher; await expect(getBookmarkIdsFromMatcher(mockCtx, matcher)).rejects.toThrow( diff --git a/packages/trpc/lib/search.ts b/packages/trpc/lib/search.ts index de76748b..83dfa674 100644 --- a/packages/trpc/lib/search.ts +++ b/packages/trpc/lib/search.ts @@ -20,6 +20,8 @@ import { bookmarks, bookmarksInLists, bookmarkTags, + rssFeedImportsTable, + rssFeedsTable, tagsOnBookmarks, } from "@hoarder/db/schema"; import { Matcher } from "@hoarder/shared/types/search"; @@ -177,6 +179,33 @@ async function getIds( ), ); } + case "rssFeedName": { + const comp = matcher.inverse ? notExists : exists; + return db + .selectDistinct({ id: bookmarks.id }) + .from(bookmarks) + .where( + and( + eq(bookmarks.userId, userId), + comp( + db + .select() + .from(rssFeedImportsTable) + .innerJoin( + rssFeedsTable, + eq(rssFeedImportsTable.rssFeedId, rssFeedsTable.id), + ) + .where( + and( + eq(rssFeedImportsTable.bookmarkId, bookmarks.id), + eq(rssFeedsTable.userId, userId), + eq(rssFeedsTable.name, matcher.feedName), + ), + ), + ), + ), + ); + } case "archived": { return db .select({ id: bookmarks.id }) |
