diff options
| -rw-r--r-- | apps/web/components/dashboard/search/QueryExplainerTooltip.tsx | 26 | ||||
| -rw-r--r-- | apps/web/lib/i18n/locales/en/translation.json | 10 | ||||
| -rw-r--r-- | docs/docs/14-Guides/02-search-query-language.md | 29 | ||||
| -rw-r--r-- | packages/shared/searchQueryParser.test.ts | 26 | ||||
| -rw-r--r-- | packages/shared/searchQueryParser.ts | 20 | ||||
| -rw-r--r-- | packages/shared/types/search.ts | 11 | ||||
| -rw-r--r-- | packages/shared/utils/relativeDateUtils.ts | 42 | ||||
| -rw-r--r-- | packages/trpc/lib/search.ts | 13 |
8 files changed, 162 insertions, 15 deletions
diff --git a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx index 89c15ad5..e6abb9c9 100644 --- a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx +++ b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx @@ -66,6 +66,32 @@ export default function QueryExplainerTooltip({ <TableCell>{matcher.dateBefore.toDateString()}</TableCell> </TableRow> ); + case "age": + return ( + <TableRow> + <TableCell> + {matcher.relativeDate.direction === "newer" + ? t("search.created_within") + : t("search.created_earlier_than")} + </TableCell> + <TableCell> + {matcher.relativeDate.amount.toString() + + (matcher.relativeDate.direction === "newer" + ? { + day: t("search.day_s"), + week: t("search.week_s"), + month: t("search.month_s"), + year: t("search.year_s"), + }[matcher.relativeDate.unit] + : { + day: t("search.day_s_ago"), + week: t("search.week_s_ago"), + month: t("search.month_s_ago"), + year: t("search.year_s_ago"), + }[matcher.relativeDate.unit])} + </TableCell> + </TableRow> + ); case "favourited": return ( <TableRow> diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index f76d0f33..4cce6295 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -258,6 +258,16 @@ "not_created_on_or_after": "Not Created on or After", "created_on_or_before": "Created on or Before", "not_created_on_or_before": "Not Created on or Before", + "created_within": "Created Within", + "created_earlier_than": "Created Earlier Than", + "day_s": " Day(s)", + "week_s": " Week(s)", + "month_s": " Month(s)", + "year_s": " Year(s)", + "day_s_ago": " Day(s) Ago", + "week_s_ago": " Week(s) Ago", + "month_s_ago": " Month(s) Ago", + "year_s_ago": " Year(s) Ago", "url_contains": "URL Contains", "url_does_not_contain": "URL Does Not Contain", "is_in_list": "Is In List", diff --git a/docs/docs/14-Guides/02-search-query-language.md b/docs/docs/14-Guides/02-search-query-language.md index b0d8ffd3..f45b7dc2 100644 --- a/docs/docs/14-Guides/02-search-query-language.md +++ b/docs/docs/14-Guides/02-search-query-language.md @@ -13,20 +13,21 @@ Hoarder provides a search query language to filter and find bookmarks. Here are Here's a comprehensive table of all supported qualifiers: -| Qualifier | Description | Example Usage | -| -------------------------------- | -------------------------------------------------- | --------------------- | -| `is:fav` | Favorited bookmarks | `is:fav` | -| `is:archived` | Archived bookmarks | `-is:archived` | -| `is:tagged` | Bookmarks that has one or more tags | `is:tagged` | -| `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` | -| `#<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` | -| | Supports quoted strings for list names with spaces | `list:"to review"` | -| `after:<date>` | Bookmarks created on or after date (YYYY-MM-DD) | `after:2023-01-01` | -| `before:<date>` | Bookmarks created on orbefore date (YYYY-MM-DD) | `before:2023-12-31` | +| Qualifier | Description | Example Usage | +|----------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------| +| `is:fav` | Favorited bookmarks | `is:fav` | +| `is:archived` | Archived bookmarks | `-is:archived` | +| `is:tagged` | Bookmarks that has one or more tags | `is:tagged` | +| `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` | +| `#<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` | +| | Supports quoted strings for list names with spaces | `list:"to review"` | +| `after:<date>` | Bookmarks created on or after date (YYYY-MM-DD) | `after:2023-01-01` | +| `before:<date>` | Bookmarks created on or before date (YYYY-MM-DD) | `before:2023-12-31` | +| `age:<time-range>` | Match bookmarks based on how long ago they were created. Use `<` or `>` to indicate the maximum / minimum age of the bookmarks. <br> Supported units: `d` (days), `w` (weeks), `m` (months), `y` (years). | `age:<1d` `age:>2w` <br> `age:<6m` `age:>3y` | ### Examples diff --git a/packages/shared/searchQueryParser.test.ts b/packages/shared/searchQueryParser.test.ts index ff69756c..7a86ecb5 100644 --- a/packages/shared/searchQueryParser.test.ts +++ b/packages/shared/searchQueryParser.test.ts @@ -319,6 +319,32 @@ describe("Search Query Parser", () => { }, }); }); + test("age queries", () => { + expect(parseSearchQuery("age:<3d")).toEqual({ + result: "full", + text: "", + matcher: { + type: "age", + relativeDate: { + direction: "newer", + amount: 3, + unit: "day", + }, + }, + }); + expect(parseSearchQuery("age:>2y")).toEqual({ + result: "full", + text: "", + matcher: { + type: "age", + relativeDate: { + direction: "older", + amount: 2, + unit: "year", + }, + }, + }); + }); test("complex queries", () => { expect(parseSearchQuery("is:fav -is:archived")).toEqual({ diff --git a/packages/shared/searchQueryParser.ts b/packages/shared/searchQueryParser.ts index d4e2bf2b..80f033b0 100644 --- a/packages/shared/searchQueryParser.ts +++ b/packages/shared/searchQueryParser.ts @@ -18,6 +18,7 @@ import { z } from "zod"; import { BookmarkTypes } from "./types/bookmarks"; import { Matcher } from "./types/search"; +import { parseRelativeDate } from "./utils/relativeDateUtils"; enum TokenType { And = "AND", @@ -40,7 +41,7 @@ const lexerRules: [RegExp, TokenType][] = [ [/^\s+or/i, TokenType.Or], [/^#/, TokenType.Hash], - [/^(is|url|list|after|before|feed):/, TokenType.Qualifier], + [/^(is|url|list|after|before|age|feed):/, TokenType.Qualifier], [/^"([^"]+)"/, TokenType.StringLiteral], @@ -247,6 +248,23 @@ MATCHER.setPattern( matcher: undefined, }; } + case "age:": + try { + const { direction, amount, unit } = parseRelativeDate(ident); + return { + text: "", + matcher: { + type: "age", + relativeDate: { direction, amount, unit }, + }, + }; + } catch (e) { + return { + // If parsing the relative time fails, emit it as pure text + text: (minus?.text ?? "") + qualifier.text + ident, + matcher: undefined, + }; + } default: // If the token is not known, emit it as pure text return { diff --git a/packages/shared/types/search.ts b/packages/shared/types/search.ts index 533eea25..4c64c0f5 100644 --- a/packages/shared/types/search.ts +++ b/packages/shared/types/search.ts @@ -48,6 +48,15 @@ const zDateBeforeMatcher = z.object({ inverse: z.boolean(), }); +const zAgeMatcher = z.object({ + type: z.literal("age"), + relativeDate: z.object({ + direction: z.enum(["newer", "older"]), + amount: z.number(), + unit: z.enum(["day", "week", "month", "year"]), + }), +}); + const zIsTaggedMatcher = z.object({ type: z.literal("tagged"), tagged: z.boolean(), @@ -76,6 +85,7 @@ const zNonRecursiveMatcher = z.union([ zFavouritedMatcher, zDateAfterMatcher, zDateBeforeMatcher, + zAgeMatcher, zIsTaggedMatcher, zIsInListMatcher, zTypeMatcher, @@ -97,6 +107,7 @@ export const zMatcherSchema: z.ZodType<Matcher> = z.lazy(() => { zFavouritedMatcher, zDateAfterMatcher, zDateBeforeMatcher, + zAgeMatcher, zIsTaggedMatcher, zIsInListMatcher, zTypeMatcher, diff --git a/packages/shared/utils/relativeDateUtils.ts b/packages/shared/utils/relativeDateUtils.ts new file mode 100644 index 00000000..437c3ea8 --- /dev/null +++ b/packages/shared/utils/relativeDateUtils.ts @@ -0,0 +1,42 @@ +interface RelativeDate { + direction: "newer" | "older"; + amount: number; + unit: "day" | "week" | "month" | "year"; +} + +const parseRelativeDate = (date: string): RelativeDate => { + const match = date.match(/^([<>])(\d+)([dwmy])$/); + if (!match) { + throw new Error(`Invalid relative date format: ${date}`); + } + const direction = match[1] === "<" ? "newer" : "older"; + const amount = parseInt(match[2], 10); + const unit = { + d: "day", + w: "week", + m: "month", + y: "year", + }[match[3]] as "day" | "week" | "month" | "year"; + return { direction, amount, unit }; +}; + +const toAbsoluteDate = (relativeDate: RelativeDate): Date => { + const date = new Date(); + switch (relativeDate.unit) { + case "day": + date.setDate(date.getDate() - relativeDate.amount); + break; + case "week": + date.setDate(date.getDate() - relativeDate.amount * 7); + break; + case "month": + date.setMonth(date.getMonth() - relativeDate.amount); + break; + case "year": + date.setFullYear(date.getFullYear() - relativeDate.amount); + break; + } + return date; +}; + +export { parseRelativeDate, toAbsoluteDate }; diff --git a/packages/trpc/lib/search.ts b/packages/trpc/lib/search.ts index ec582ef9..d4130798 100644 --- a/packages/trpc/lib/search.ts +++ b/packages/trpc/lib/search.ts @@ -25,6 +25,7 @@ import { tagsOnBookmarks, } from "@karakeep/db/schema"; import { Matcher } from "@karakeep/shared/types/search"; +import { toAbsoluteDate } from "@karakeep/shared/utils/relativeDateUtils"; import { AuthedContext } from ".."; @@ -279,6 +280,18 @@ async function getIds( ), ); } + case "age": { + const comp = matcher.relativeDate.direction === "newer" ? gte : lt; + return db + .select({ id: bookmarks.id }) + .from(bookmarks) + .where( + and( + eq(bookmarks.userId, userId), + comp(bookmarks.createdAt, toAbsoluteDate(matcher.relativeDate)), + ), + ); + } case "type": { const comp = matcher.inverse ? ne : eq; return db |
