aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/web/components/dashboard/search/QueryExplainerTooltip.tsx26
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json10
-rw-r--r--docs/docs/14-Guides/02-search-query-language.md29
-rw-r--r--packages/shared/searchQueryParser.test.ts26
-rw-r--r--packages/shared/searchQueryParser.ts20
-rw-r--r--packages/shared/types/search.ts11
-rw-r--r--packages/shared/utils/relativeDateUtils.ts42
-rw-r--r--packages/trpc/lib/search.ts13
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