aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-09-13 21:37:56 +0100
committerGitHub <noreply@github.com>2025-09-13 21:37:56 +0100
commita92ada7727b2596414aafe204e5001eb066569cb (patch)
tree73e289791f83135dfa2f5cecc6fa1c4019679238 /packages
parent3bae3aad9a62dbf2cc9a0038c90ea992166cc336 (diff)
downloadkarakeep-a92ada7727b2596414aafe204e5001eb066569cb.tar.zst
feat(search): add title search qualifier (#1940)
* fix(search): include link titles in title matcher * docs(search): add title qualifier * docs: remove title qualifier from v0.27 guide
Diffstat (limited to 'packages')
-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
5 files changed, 123 insertions, 2 deletions
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 })