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--apps/web/lib/i18n/locales/en_US/translation.json2
-rw-r--r--docs/docs/14-guides/02-search-query-language.md4
-rw-r--r--packages/shared/searchQueryParser.test.ts36
-rw-r--r--packages/shared/searchQueryParser.ts7
-rw-r--r--packages/shared/types/search.ts8
-rw-r--r--packages/trpc/lib/__tests__/search.test.ts28
-rw-r--r--packages/trpc/lib/search.ts46
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 })