aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/web/components/dashboard/search/QueryExplainerTooltip.tsx11
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json2
-rw-r--r--packages/shared/searchQueryParser.test.ts36
-rw-r--r--packages/shared/searchQueryParser.ts11
-rw-r--r--packages/shared/types/search.ts8
-rw-r--r--packages/trpc/lib/__tests__/search.test.ts48
-rw-r--r--packages/trpc/lib/search.ts29
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 })