aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/web/components/dashboard/search/QueryExplainerTooltip.tsx98
-rw-r--r--apps/web/components/dashboard/search/SearchInput.tsx27
-rw-r--r--apps/web/lib/hooks/bookmark-search.ts20
-rw-r--r--packages/shared/package.json7
-rw-r--r--packages/shared/searchQueryParser.test.ts275
-rw-r--r--packages/shared/searchQueryParser.ts351
-rw-r--r--packages/shared/types/search.ts72
-rw-r--r--packages/shared/vitest.config.ts14
-rw-r--r--packages/trpc/lib/search.ts182
-rw-r--r--packages/trpc/routers/bookmarks.ts16
-rw-r--r--pnpm-lock.yaml12
11 files changed, 1054 insertions, 20 deletions
diff --git a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx
new file mode 100644
index 00000000..191c9ff3
--- /dev/null
+++ b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx
@@ -0,0 +1,98 @@
+import InfoTooltip from "@/components/ui/info-tooltip";
+import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
+
+import { TextAndMatcher } from "@hoarder/shared/searchQueryParser";
+import { Matcher } from "@hoarder/shared/types/search";
+
+export default function QueryExplainerTooltip({
+ parsedSearchQuery,
+ className,
+}: {
+ parsedSearchQuery: TextAndMatcher & { result: string };
+ className?: string;
+}) {
+ if (parsedSearchQuery.result == "invalid") {
+ return null;
+ }
+
+ const MatcherComp = ({ matcher }: { matcher: Matcher }) => {
+ switch (matcher.type) {
+ case "tagName":
+ return (
+ <TableRow>
+ <TableCell>Tag Name</TableCell>
+ <TableCell>{matcher.tagName}</TableCell>
+ </TableRow>
+ );
+ case "listName":
+ return (
+ <TableRow>
+ <TableCell>List Name</TableCell>
+ <TableCell>{matcher.listName}</TableCell>
+ </TableRow>
+ );
+ case "dateAfter":
+ return (
+ <TableRow>
+ <TableCell>Created After</TableCell>
+ <TableCell>{matcher.dateAfter.toDateString()}</TableCell>
+ </TableRow>
+ );
+ case "dateBefore":
+ return (
+ <TableRow>
+ <TableCell>Created Before</TableCell>
+ <TableCell>{matcher.dateBefore.toDateString()}</TableCell>
+ </TableRow>
+ );
+ case "favourited":
+ return (
+ <TableRow>
+ <TableCell>Favourited</TableCell>
+ <TableCell>{matcher.favourited.toString()}</TableCell>
+ </TableRow>
+ );
+ case "archived":
+ return (
+ <TableRow>
+ <TableCell>Archived</TableCell>
+ <TableCell>{matcher.archived.toString()}</TableCell>
+ </TableRow>
+ );
+ case "and":
+ case "or":
+ return (
+ <TableRow>
+ <TableCell className="capitalize">{matcher.type}</TableCell>
+ <TableCell>
+ <Table>
+ <TableBody>
+ {matcher.matchers.map((m, i) => (
+ <MatcherComp key={i} matcher={m} />
+ ))}
+ </TableBody>
+ </Table>
+ </TableCell>
+ </TableRow>
+ );
+ }
+ };
+
+ return (
+ <InfoTooltip className={className}>
+ <Table>
+ <TableBody>
+ {parsedSearchQuery.text && (
+ <TableRow>
+ <TableCell>Text</TableCell>
+ <TableCell>{parsedSearchQuery.text}</TableCell>
+ </TableRow>
+ )}
+ {parsedSearchQuery.matcher && (
+ <MatcherComp matcher={parsedSearchQuery.matcher} />
+ )}
+ </TableBody>
+ </Table>
+ </InfoTooltip>
+ );
+}
diff --git a/apps/web/components/dashboard/search/SearchInput.tsx b/apps/web/components/dashboard/search/SearchInput.tsx
index 55f304e3..8ed2ea3c 100644
--- a/apps/web/components/dashboard/search/SearchInput.tsx
+++ b/apps/web/components/dashboard/search/SearchInput.tsx
@@ -4,6 +4,9 @@ import React, { useEffect, useImperativeHandle, useRef } from "react";
import { Input } from "@/components/ui/input";
import { useDoBookmarkSearch } from "@/lib/hooks/bookmark-search";
import { useTranslation } from "@/lib/i18n/client";
+import { cn } from "@/lib/utils";
+
+import QueryExplainerTooltip from "./QueryExplainerTooltip";
function useFocusSearchOnKeyPress(
inputRef: React.RefObject<HTMLInputElement>,
@@ -47,7 +50,8 @@ const SearchInput = React.forwardRef<
React.HTMLAttributes<HTMLInputElement> & { loading?: boolean }
>(({ className, ...props }, ref) => {
const { t } = useTranslation();
- const { debounceSearch, searchQuery, isInSearchPage } = useDoBookmarkSearch();
+ const { debounceSearch, searchQuery, parsedSearchQuery, isInSearchPage } =
+ useDoBookmarkSearch();
const [value, setValue] = React.useState(searchQuery);
@@ -67,14 +71,19 @@ const SearchInput = React.forwardRef<
}, [isInSearchPage]);
return (
- <Input
- ref={inputRef}
- value={value}
- onChange={onChange}
- placeholder={t("common.search")}
- className={className}
- {...props}
- />
+ <div className={cn("relative flex-1", className)}>
+ <QueryExplainerTooltip
+ className="-translate-1/2 absolute right-1.5 top-2 p-0.5"
+ parsedSearchQuery={parsedSearchQuery}
+ />
+ <Input
+ ref={inputRef}
+ value={value}
+ onChange={onChange}
+ placeholder={t("common.search")}
+ {...props}
+ />
+ </div>
);
});
SearchInput.displayName = "SearchInput";
diff --git a/apps/web/lib/hooks/bookmark-search.ts b/apps/web/lib/hooks/bookmark-search.ts
index 9890ac6f..4662ffb6 100644
--- a/apps/web/lib/hooks/bookmark-search.ts
+++ b/apps/web/lib/hooks/bookmark-search.ts
@@ -1,17 +1,20 @@
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { api } from "@/lib/trpc";
import { keepPreviousData } from "@tanstack/react-query";
+import { parseSearchQuery } from "@hoarder/shared/searchQueryParser";
+
function useSearchQuery() {
const searchParams = useSearchParams();
- const searchQuery = searchParams.get("q") ?? "";
- return { searchQuery };
+ const searchQuery = decodeURIComponent(searchParams.get("q") ?? "");
+ const parsed = useMemo(() => parseSearchQuery(searchQuery), [searchQuery]);
+ return { searchQuery, parsedSearchQuery: parsed };
}
export function useDoBookmarkSearch() {
const router = useRouter();
- const { searchQuery } = useSearchQuery();
+ const { searchQuery, parsedSearchQuery } = useSearchQuery();
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | undefined>();
const pathname = usePathname();
@@ -26,7 +29,7 @@ export function useDoBookmarkSearch() {
const doSearch = (val: string) => {
setTimeoutId(undefined);
- router.replace(`/dashboard/search?q=${val}`);
+ router.replace(`/dashboard/search?q=${encodeURIComponent(val)}`);
};
const debounceSearch = (val: string) => {
@@ -43,12 +46,13 @@ export function useDoBookmarkSearch() {
doSearch,
debounceSearch,
searchQuery,
+ parsedSearchQuery,
isInSearchPage: pathname.startsWith("/dashboard/search"),
};
}
export function useBookmarkSearch() {
- const { searchQuery } = useSearchQuery();
+ const { parsedSearchQuery } = useSearchQuery();
const {
data,
@@ -60,7 +64,8 @@ export function useBookmarkSearch() {
isFetchingNextPage,
} = api.bookmarks.searchBookmarks.useInfiniteQuery(
{
- text: searchQuery,
+ text: parsedSearchQuery.text,
+ matcher: parsedSearchQuery.matcher,
},
{
placeholderData: keepPreviousData,
@@ -75,7 +80,6 @@ export function useBookmarkSearch() {
}
return {
- searchQuery,
error,
data,
isPending,
diff --git a/packages/shared/package.json b/packages/shared/package.json
index d741b70f..d412301a 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -10,18 +10,21 @@
"meilisearch": "^0.37.0",
"ollama": "^0.5.9",
"openai": "^4.67.1",
+ "typescript-parsec": "^0.3.4",
"winston": "^3.11.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@hoarder/eslint-config": "workspace:^0.2.0",
"@hoarder/prettier-config": "workspace:^0.1.0",
- "@hoarder/tsconfig": "workspace:^0.1.0"
+ "@hoarder/tsconfig": "workspace:^0.1.0",
+ "vitest": "^1.3.1"
},
"scripts": {
"typecheck": "tsc --noEmit",
"format": "prettier . --ignore-path ../../.prettierignore",
- "lint": "eslint ."
+ "lint": "eslint .",
+ "test": "vitest"
},
"main": "index.ts",
"eslintConfig": {
diff --git a/packages/shared/searchQueryParser.test.ts b/packages/shared/searchQueryParser.test.ts
new file mode 100644
index 00000000..428d5929
--- /dev/null
+++ b/packages/shared/searchQueryParser.test.ts
@@ -0,0 +1,275 @@
+import { describe, expect, test } from "vitest";
+
+import { parseSearchQuery } from "./searchQueryParser";
+
+describe("Search Query Parser", () => {
+ test("simple is queries", () => {
+ expect(parseSearchQuery("is:archived")).toEqual({
+ result: "full",
+ text: "",
+ matcher: {
+ type: "archived",
+ archived: true,
+ },
+ });
+ expect(parseSearchQuery("is:not_archived")).toEqual({
+ result: "full",
+ text: "",
+ matcher: {
+ type: "archived",
+ archived: false,
+ },
+ });
+ expect(parseSearchQuery("is:fav")).toEqual({
+ result: "full",
+ text: "",
+ matcher: {
+ type: "favourited",
+ favourited: true,
+ },
+ });
+ expect(parseSearchQuery("is:not_fav")).toEqual({
+ result: "full",
+ text: "",
+ matcher: {
+ type: "favourited",
+ favourited: false,
+ },
+ });
+ });
+
+ test("simple string queries", () => {
+ expect(parseSearchQuery("url:https://example.com")).toEqual({
+ result: "full",
+ text: "",
+ matcher: {
+ type: "url",
+ url: "https://example.com",
+ },
+ });
+ expect(parseSearchQuery('url:"https://example.com"')).toEqual({
+ result: "full",
+ text: "",
+ matcher: {
+ type: "url",
+ url: "https://example.com",
+ },
+ });
+ expect(parseSearchQuery("#my-tag")).toEqual({
+ result: "full",
+ text: "",
+ matcher: {
+ type: "tagName",
+ tagName: "my-tag",
+ },
+ });
+ expect(parseSearchQuery('#"my tag"')).toEqual({
+ result: "full",
+ text: "",
+ matcher: {
+ type: "tagName",
+ tagName: "my tag",
+ },
+ });
+ expect(parseSearchQuery("list:my-list")).toEqual({
+ result: "full",
+ text: "",
+ matcher: {
+ type: "listName",
+ listName: "my-list",
+ },
+ });
+ expect(parseSearchQuery('list:"my list"')).toEqual({
+ result: "full",
+ text: "",
+ matcher: {
+ type: "listName",
+ listName: "my list",
+ },
+ });
+ });
+ test("date queries", () => {
+ expect(parseSearchQuery("after:2023-10-12")).toEqual({
+ result: "full",
+ text: "",
+ matcher: {
+ type: "dateAfter",
+ dateAfter: new Date("2023-10-12"),
+ },
+ });
+ expect(parseSearchQuery("before:2023-10-12")).toEqual({
+ result: "full",
+ text: "",
+ matcher: {
+ type: "dateBefore",
+ dateBefore: new Date("2023-10-12"),
+ },
+ });
+ });
+
+ test("complex queries", () => {
+ expect(parseSearchQuery("is:fav is:archived")).toEqual({
+ result: "full",
+ text: "",
+ matcher: {
+ type: "and",
+ matchers: [
+ {
+ type: "favourited",
+ favourited: true,
+ },
+ {
+ type: "archived",
+ archived: true,
+ },
+ ],
+ },
+ });
+
+ expect(parseSearchQuery("(is:fav is:archived) #my-tag")).toEqual({
+ result: "full",
+ text: "",
+ matcher: {
+ type: "and",
+ matchers: [
+ {
+ type: "favourited",
+ favourited: true,
+ },
+ {
+ type: "archived",
+ archived: true,
+ },
+ {
+ type: "tagName",
+ tagName: "my-tag",
+ },
+ ],
+ },
+ });
+
+ expect(parseSearchQuery("(is:fav is:archived) or (#my-tag)")).toEqual({
+ result: "full",
+ text: "",
+ matcher: {
+ type: "or",
+ matchers: [
+ {
+ type: "and",
+ matchers: [
+ {
+ type: "favourited",
+ favourited: true,
+ },
+ {
+ type: "archived",
+ archived: true,
+ },
+ ],
+ },
+ {
+ type: "tagName",
+ tagName: "my-tag",
+ },
+ ],
+ },
+ });
+
+ expect(parseSearchQuery("(is:fav or is:archived) and #my-tag")).toEqual({
+ result: "full",
+ text: "",
+ matcher: {
+ type: "and",
+ matchers: [
+ {
+ type: "or",
+ matchers: [
+ {
+ type: "favourited",
+ favourited: true,
+ },
+ {
+ type: "archived",
+ archived: true,
+ },
+ ],
+ },
+ {
+ type: "tagName",
+ tagName: "my-tag",
+ },
+ ],
+ },
+ });
+ });
+ test("pure text", () => {
+ expect(parseSearchQuery("hello")).toEqual({
+ result: "full",
+ text: "hello",
+ matcher: undefined,
+ });
+ expect(parseSearchQuery("hello world")).toEqual({
+ result: "full",
+ text: "hello world",
+ matcher: undefined,
+ });
+ });
+
+ test("text interlived with matchers", () => {
+ expect(
+ parseSearchQuery(
+ "hello is:fav world is:archived mixed world #my-tag test",
+ ),
+ ).toEqual({
+ result: "full",
+ text: "hello world mixed world test",
+ matcher: {
+ type: "and",
+ matchers: [
+ {
+ type: "favourited",
+ favourited: true,
+ },
+ {
+ type: "archived",
+ archived: true,
+ },
+ {
+ type: "tagName",
+ tagName: "my-tag",
+ },
+ ],
+ },
+ });
+ });
+
+ test("unknown qualifiers are emitted as pure text", () => {
+ expect(parseSearchQuery("is:fav is:helloworld")).toEqual({
+ result: "full",
+ text: "is:helloworld",
+ matcher: {
+ type: "favourited",
+ favourited: true,
+ },
+ });
+ });
+
+ test("partial results", () => {
+ expect(parseSearchQuery("(is:archived) or ")).toEqual({
+ result: "partial",
+ text: "or",
+ matcher: {
+ type: "archived",
+ archived: true,
+ },
+ });
+ expect(parseSearchQuery("is:fav is: ( random")).toEqual({
+ result: "partial",
+ text: "is: ( random",
+ matcher: {
+ type: "favourited",
+ favourited: true,
+ },
+ });
+ });
+});
diff --git a/packages/shared/searchQueryParser.ts b/packages/shared/searchQueryParser.ts
new file mode 100644
index 00000000..faf74d08
--- /dev/null
+++ b/packages/shared/searchQueryParser.ts
@@ -0,0 +1,351 @@
+import {
+ alt,
+ alt_sc,
+ apply,
+ kmid,
+ kright,
+ lrec_sc,
+ rule,
+ seq,
+ str,
+ tok,
+ Token,
+ TokenPosition,
+} from "typescript-parsec";
+import { z } from "zod";
+
+import { Matcher } from "./types/search";
+
+enum TokenType {
+ And = "AND",
+ Or = "OR",
+
+ Qualifier = "QUALIFIER",
+ Ident = "IDENT",
+ StringLiteral = "STRING_LITERAL",
+
+ LParen = "LPAREN",
+ RParen = "RPAREN",
+ Space = "SPACE",
+ Hash = "HASH",
+}
+
+// Rules are in order of priority
+const lexerRules: [RegExp, TokenType][] = [
+ [/^and/i, TokenType.And],
+ [/^or/i, TokenType.Or],
+
+ [/^#/, TokenType.Hash],
+ [/^(is|url|list|after|before):/, TokenType.Qualifier],
+
+ [/^"([^"]+)"/, TokenType.StringLiteral],
+
+ [/^\(/, TokenType.LParen],
+ [/^\)/, TokenType.RParen],
+ [/^\s+/, TokenType.Space],
+
+ // This needs to be last as it matches a lot of stuff
+ [/^[^ )(]+/, TokenType.Ident],
+] as const;
+
+class LexerToken implements Token<TokenType> {
+ private constructor(
+ private readonly input: string,
+ public kind: TokenType,
+ public text: string,
+ public pos: TokenPosition,
+ ) {}
+
+ public static from(input: string): Token<TokenType> | undefined {
+ const tok = new LexerToken(
+ input,
+ /* Doesn't matter */ TokenType.Ident,
+ "",
+ {
+ index: 0,
+ rowBegin: 1,
+ rowEnd: 1,
+ columnBegin: 0,
+ columnEnd: 0,
+ },
+ );
+ return tok.next;
+ }
+
+ public get next(): Token<TokenType> | undefined {
+ if (!this.input.length) {
+ return undefined;
+ }
+
+ for (const [regex, tokenType] of lexerRules) {
+ const matchRes = regex.exec(this.input);
+ if (!matchRes) {
+ continue;
+ }
+ const match = matchRes[0];
+ return new LexerToken(this.input.slice(match.length), tokenType, match, {
+ index: this.pos.index + match.length,
+ columnBegin: this.pos.index + 1,
+ columnEnd: this.pos.index + 1 + match.length,
+ // Our strings are always only one line
+ rowBegin: 1,
+ rowEnd: 1,
+ });
+ }
+ // No match
+ throw new Error(
+ `Failed to tokenize the token at position ${this.pos.index}: ${this.input[0]}`,
+ );
+ }
+}
+
+export interface TextAndMatcher {
+ text: string;
+ matcher?: Matcher;
+}
+
+const MATCHER = rule<TokenType, TextAndMatcher>();
+const EXP = rule<TokenType, TextAndMatcher>();
+
+MATCHER.setPattern(
+ alt_sc(
+ apply(kright(str("is:"), tok(TokenType.Ident)), (toks) => {
+ switch (toks.text) {
+ case "fav":
+ return {
+ text: "",
+ matcher: { type: "favourited", favourited: true },
+ };
+ case "not_fav":
+ return {
+ text: "",
+ matcher: { type: "favourited", favourited: false },
+ };
+ case "archived":
+ return {
+ text: "",
+ matcher: { type: "archived", archived: true },
+ };
+ case "not_archived":
+ return {
+ text: "",
+ matcher: { type: "archived", archived: false },
+ };
+ default:
+ // If the token is not known, emit it as pure text
+ return {
+ text: `is:${toks.text}`,
+ matcher: undefined,
+ };
+ }
+ }),
+ apply(
+ seq(
+ alt(tok(TokenType.Qualifier), tok(TokenType.Hash)),
+ alt(
+ apply(tok(TokenType.Ident), (tok) => {
+ return tok.text;
+ }),
+ apply(tok(TokenType.StringLiteral), (tok) => {
+ return tok.text.slice(1, -1);
+ }),
+ ),
+ ),
+ (toks) => {
+ switch (toks[0].text) {
+ case "url:":
+ return {
+ text: "",
+ matcher: { type: "url", url: toks[1] },
+ };
+ case "#":
+ return {
+ text: "",
+ matcher: { type: "tagName", tagName: toks[1] },
+ };
+ case "list:":
+ return {
+ text: "",
+ matcher: { type: "listName", listName: toks[1] },
+ };
+ case "after:":
+ try {
+ return {
+ text: "",
+ matcher: {
+ type: "dateAfter",
+ dateAfter: z.coerce.date().parse(toks[1]),
+ },
+ };
+ } catch (e) {
+ return {
+ // If parsing the date fails, emit it as pure text
+ text: toks[0].text + toks[1],
+ matcher: undefined,
+ };
+ }
+ case "before:":
+ try {
+ return {
+ text: "",
+ matcher: {
+ type: "dateBefore",
+ dateBefore: z.coerce.date().parse(toks[1]),
+ },
+ };
+ } catch (e) {
+ return {
+ // If parsing the date fails, emit it as pure text
+ text: toks[0].text + toks[1],
+ matcher: undefined,
+ };
+ }
+ default:
+ // If the token is not known, emit it as pure text
+ return {
+ text: toks[0].text + toks[1],
+ matcher: undefined,
+ };
+ }
+ },
+ ),
+ // Ident or an incomlete qualifier
+ apply(alt(tok(TokenType.Ident), tok(TokenType.Qualifier)), (toks) => {
+ return {
+ text: toks.text,
+ matcher: undefined,
+ };
+ }),
+ kmid(tok(TokenType.LParen), EXP, tok(TokenType.RParen)),
+ ),
+);
+
+EXP.setPattern(
+ lrec_sc(
+ MATCHER,
+ seq(
+ alt(
+ tok(TokenType.Space),
+ kmid(tok(TokenType.Space), tok(TokenType.And), tok(TokenType.Space)),
+ kmid(tok(TokenType.Space), tok(TokenType.Or), tok(TokenType.Space)),
+ ),
+ MATCHER,
+ ),
+ (toks, next) => {
+ switch (next[0].kind) {
+ case TokenType.Space:
+ case TokenType.And:
+ return {
+ text: [toks.text, next[1].text].join(" ").trim(),
+ matcher:
+ !!toks.matcher || !!next[1].matcher
+ ? {
+ type: "and",
+ matchers: [toks.matcher, next[1].matcher].filter(
+ (a) => !!a,
+ ) as Matcher[],
+ }
+ : undefined,
+ };
+ case TokenType.Or:
+ return {
+ text: [toks.text, next[1].text].join(" ").trim(),
+ matcher:
+ !!toks.matcher || !!next[1].matcher
+ ? {
+ type: "or",
+ matchers: [toks.matcher, next[1].matcher].filter(
+ (a) => !!a,
+ ) as Matcher[],
+ }
+ : undefined,
+ };
+ }
+ },
+ ),
+);
+
+function flattenAndsAndOrs(matcher: Matcher): Matcher {
+ switch (matcher.type) {
+ case "and":
+ case "or": {
+ if (matcher.matchers.length == 1) {
+ return flattenAndsAndOrs(matcher.matchers[0]);
+ }
+ const flattened: Matcher[] = [];
+ for (let m of matcher.matchers) {
+ // If inside the matcher is another matcher of the same type, flatten it
+ m = flattenAndsAndOrs(m);
+ if (m.type == matcher.type) {
+ flattened.push(...m.matchers);
+ } else {
+ flattened.push(m);
+ }
+ }
+ matcher.matchers = flattened;
+ return matcher;
+ }
+ default:
+ return matcher;
+ }
+}
+
+export function _parseAndPrintTokens(query: string) {
+ console.log(`PARSING: ${query}`);
+ let tok = LexerToken.from(query);
+ do {
+ console.log(tok?.kind, tok?.text);
+ tok = tok?.next;
+ } while (tok);
+ console.log("DONE");
+}
+
+function consumeTokenStream(token: Token<TokenType>) {
+ let str = "";
+ let tok: Token<TokenType> | undefined = token;
+ do {
+ str += tok.text;
+ tok = tok.next;
+ } while (tok);
+ return str;
+}
+
+export function parseSearchQuery(
+ query: string,
+): TextAndMatcher & { result: "full" | "partial" | "invalid" } {
+ // _parseAndPrintTokens(query); // Uncomment to debug tokenization
+ const parsed = EXP.parse(LexerToken.from(query.trim()));
+ if (!parsed.successful || parsed.candidates.length != 1) {
+ // If the query is not valid, return the whole query as pure text
+ return {
+ text: query,
+ result: "invalid",
+ };
+ }
+
+ const parseCandidate = parsed.candidates[0];
+ if (parseCandidate.result.matcher) {
+ parseCandidate.result.matcher = flattenAndsAndOrs(
+ parseCandidate.result.matcher,
+ );
+ }
+ if (parseCandidate.nextToken) {
+ // Parser failed to consume the whole query. This usually happen
+ // when the user is still typing the query. Return the partial
+ // result and the remaining query as pure text
+ return {
+ text: (
+ parseCandidate.result.text +
+ consumeTokenStream(parseCandidate.nextToken)
+ ).trim(),
+ matcher: parseCandidate.result.matcher,
+ result: "partial",
+ };
+ }
+
+ return {
+ text: parseCandidate.result.text,
+ matcher: parseCandidate.result.matcher,
+ result: "full",
+ };
+}
diff --git a/packages/shared/types/search.ts b/packages/shared/types/search.ts
new file mode 100644
index 00000000..d430dad5
--- /dev/null
+++ b/packages/shared/types/search.ts
@@ -0,0 +1,72 @@
+import { z } from "zod";
+
+const zTagNameMatcher = z.object({
+ type: z.literal("tagName"),
+ tagName: z.string(),
+});
+
+const zListNameMatcher = z.object({
+ type: z.literal("listName"),
+ listName: z.string(),
+});
+
+const zArchivedMatcher = z.object({
+ type: z.literal("archived"),
+ archived: z.boolean(),
+});
+
+const urlMatcher = z.object({
+ type: z.literal("url"),
+ url: z.string(),
+});
+
+const zFavouritedMatcher = z.object({
+ type: z.literal("favourited"),
+ favourited: z.boolean(),
+});
+
+const zDateAfterMatcher = z.object({
+ type: z.literal("dateAfter"),
+ dateAfter: z.date(),
+});
+
+const zDateBeforeMatcher = z.object({
+ type: z.literal("dateBefore"),
+ dateBefore: z.date(),
+});
+
+const zNonRecursiveMatcher = z.union([
+ zTagNameMatcher,
+ zListNameMatcher,
+ zArchivedMatcher,
+ urlMatcher,
+ zFavouritedMatcher,
+ zDateAfterMatcher,
+ zDateBeforeMatcher,
+]);
+
+type NonRecursiveMatcher = z.infer<typeof zNonRecursiveMatcher>;
+export type Matcher =
+ | NonRecursiveMatcher
+ | { type: "and"; matchers: Matcher[] }
+ | { type: "or"; matchers: Matcher[] };
+
+export const zMatcherSchema: z.ZodType<Matcher> = z.lazy(() => {
+ return z.discriminatedUnion("type", [
+ zTagNameMatcher,
+ zListNameMatcher,
+ zArchivedMatcher,
+ urlMatcher,
+ zFavouritedMatcher,
+ zDateAfterMatcher,
+ zDateBeforeMatcher,
+ z.object({
+ type: z.literal("and"),
+ matchers: z.array(zMatcherSchema),
+ }),
+ z.object({
+ type: z.literal("or"),
+ matchers: z.array(zMatcherSchema),
+ }),
+ ]);
+});
diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts
new file mode 100644
index 00000000..41fd70c4
--- /dev/null
+++ b/packages/shared/vitest.config.ts
@@ -0,0 +1,14 @@
+/// <reference types="vitest" />
+
+import tsconfigPaths from "vite-tsconfig-paths";
+import { defineConfig } from "vitest/config";
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [tsconfigPaths()],
+ test: {
+ alias: {
+ "@/*": "./*",
+ },
+ },
+});
diff --git a/packages/trpc/lib/search.ts b/packages/trpc/lib/search.ts
new file mode 100644
index 00000000..0ee9c76e
--- /dev/null
+++ b/packages/trpc/lib/search.ts
@@ -0,0 +1,182 @@
+import { and, eq, gte, like, lte, sql } from "drizzle-orm";
+
+import {
+ bookmarkLinks,
+ bookmarkLists,
+ bookmarks,
+ bookmarksInLists,
+ bookmarkTags,
+ tagsOnBookmarks,
+} from "@hoarder/db/schema";
+import { Matcher } from "@hoarder/shared/types/search";
+
+import { AuthedContext } from "..";
+
+interface BookmarkQueryReturnType {
+ id: string;
+}
+
+function intersect(
+ vals: BookmarkQueryReturnType[][],
+): BookmarkQueryReturnType[] {
+ if (!vals || vals.length === 0) {
+ return [];
+ }
+
+ if (vals.length === 1) {
+ return [...vals[0]];
+ }
+
+ const countMap = new Map<string, number>();
+ const map = new Map<string, BookmarkQueryReturnType>();
+
+ for (const arr of vals) {
+ for (const item of arr) {
+ countMap.set(item.id, (countMap.get(item.id) ?? 0) + 1);
+ map.set(item.id, item);
+ }
+ }
+
+ const result: BookmarkQueryReturnType[] = [];
+ for (const [id, count] of countMap) {
+ if (count === vals.length) {
+ result.push(map.get(id)!);
+ }
+ }
+
+ return result;
+}
+
+function union(vals: BookmarkQueryReturnType[][]): BookmarkQueryReturnType[] {
+ if (!vals || vals.length === 0) {
+ return [];
+ }
+
+ const uniqueIds = new Set<string>();
+ const map = new Map<string, BookmarkQueryReturnType>();
+ for (const arr of vals) {
+ for (const item of arr) {
+ uniqueIds.add(item.id);
+ map.set(item.id, item);
+ }
+ }
+
+ const result: BookmarkQueryReturnType[] = [];
+ for (const id of uniqueIds) {
+ result.push(map.get(id)!);
+ }
+
+ return result;
+}
+
+async function getIds(
+ db: AuthedContext["db"],
+ userId: string,
+ matcher: Matcher,
+): Promise<BookmarkQueryReturnType[]> {
+ switch (matcher.type) {
+ case "tagName": {
+ return db
+ .select({ id: sql<string>`${tagsOnBookmarks.bookmarkId}`.as("id") })
+ .from(tagsOnBookmarks)
+ .innerJoin(bookmarkTags, eq(tagsOnBookmarks.tagId, bookmarkTags.id))
+ .where(
+ and(
+ eq(bookmarkTags.userId, userId),
+ eq(bookmarkTags.name, matcher.tagName),
+ ),
+ );
+ }
+ case "listName": {
+ return db
+ .select({ id: sql<string>`${bookmarksInLists.bookmarkId}`.as("id") })
+ .from(bookmarksInLists)
+ .innerJoin(bookmarkLists, eq(bookmarksInLists.listId, bookmarkLists.id))
+ .where(
+ and(
+ eq(bookmarkLists.userId, userId),
+ eq(bookmarkLists.name, matcher.listName),
+ ),
+ );
+ }
+ case "archived": {
+ return db
+ .select({ id: bookmarks.id })
+ .from(bookmarks)
+ .where(
+ and(
+ eq(bookmarks.userId, userId),
+ eq(bookmarks.archived, matcher.archived),
+ ),
+ );
+ }
+ case "url": {
+ return db
+ .select({ id: bookmarkLinks.id })
+ .from(bookmarkLinks)
+ .leftJoin(bookmarks, eq(bookmarks.id, bookmarkLinks.id))
+ .where(
+ and(
+ eq(bookmarks.userId, userId),
+ like(bookmarkLinks.url, `%${matcher.url}%`),
+ ),
+ );
+ }
+ case "favourited": {
+ return db
+ .select({ id: bookmarks.id })
+ .from(bookmarks)
+ .where(
+ and(
+ eq(bookmarks.userId, userId),
+ eq(bookmarks.favourited, matcher.favourited),
+ ),
+ );
+ }
+ case "dateAfter": {
+ return db
+ .select({ id: bookmarks.id })
+ .from(bookmarks)
+ .where(
+ and(
+ eq(bookmarks.userId, userId),
+ gte(bookmarks.createdAt, matcher.dateAfter),
+ ),
+ );
+ }
+ case "dateBefore": {
+ return db
+ .select({ id: bookmarks.id })
+ .from(bookmarks)
+ .where(
+ and(
+ eq(bookmarks.userId, userId),
+ lte(bookmarks.createdAt, matcher.dateBefore),
+ ),
+ );
+ }
+ case "and": {
+ const vals = await Promise.all(
+ matcher.matchers.map((m) => getIds(db, userId, m)),
+ );
+ return intersect(vals);
+ }
+ case "or": {
+ const vals = await Promise.all(
+ matcher.matchers.map((m) => getIds(db, userId, m)),
+ );
+ return union(vals);
+ }
+ default: {
+ throw new Error("Unknown matcher type");
+ }
+ }
+}
+
+export async function getBookmarkIdsFromMatcher(
+ ctx: AuthedContext,
+ matcher: Matcher,
+): Promise<string[]> {
+ const results = await getIds(ctx.db, ctx.user.id, matcher);
+ return results.map((r) => r.id);
+}
diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts
index 254ac6c2..3320b3b9 100644
--- a/packages/trpc/routers/bookmarks.ts
+++ b/packages/trpc/routers/bookmarks.ts
@@ -45,6 +45,7 @@ import {
zNewBookmarkRequestSchema,
zUpdateBookmarksRequestSchema,
} from "@hoarder/shared/types/bookmarks";
+import { zMatcherSchema } from "@hoarder/shared/types/search";
import type { AuthedContext, Context } from "../index";
import { authedProcedure, router } from "../index";
@@ -54,6 +55,7 @@ import {
mapDBAssetTypeToUserType,
mapSchemaAssetTypeToDB,
} from "../lib/attachments";
+import { getBookmarkIdsFromMatcher } from "../lib/search";
export const ensureBookmarkOwnership = experimental_trpcMiddleware<{
ctx: Context;
@@ -521,6 +523,7 @@ export const bookmarksAppRouter = router({
.input(
z.object({
text: z.string(),
+ matcher: zMatcherSchema.optional(),
cursor: z
.object({
offset: z.number(),
@@ -548,8 +551,19 @@ export const bookmarksAppRouter = router({
message: "Search functionality is not configured",
});
}
+
+ let filter: string[];
+ if (input.matcher) {
+ const bookmarkIds = await getBookmarkIdsFromMatcher(ctx, input.matcher);
+ filter = [
+ `userId = '${ctx.user.id}' AND id IN [${bookmarkIds.join(",")}]`,
+ ];
+ } else {
+ filter = [`userId = '${ctx.user.id}'`];
+ }
+
const resp = await client.search(input.text, {
- filter: [`userId = '${ctx.user.id}'`],
+ filter,
showRankingScore: true,
attributesToRetrieve: ["id"],
sort: ["createdAt:desc"],
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f9721ffb..934c93bb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1028,6 +1028,9 @@ importers:
openai:
specifier: ^4.67.1
version: 4.67.1(zod@3.22.4)
+ typescript-parsec:
+ specifier: ^0.3.4
+ version: 0.3.4
winston:
specifier: ^3.11.0
version: 3.11.0
@@ -1044,6 +1047,9 @@ importers:
'@hoarder/tsconfig':
specifier: workspace:^0.1.0
version: link:../../tooling/typescript
+ vitest:
+ specifier: ^1.3.1
+ version: 1.3.1(@types/node@20.11.20)
packages/shared-react:
dependencies:
@@ -12808,6 +12814,9 @@ packages:
typedarray-to-buffer@3.1.5:
resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==}
+ typescript-parsec@0.3.4:
+ resolution: {integrity: sha512-6RD4xOxp26BTZLopNbqT2iErqNhQZZWb5m5F07/UwGhldGvOAKOl41pZ3fxsFp04bNL+PbgMjNfb6IvJAC/uYQ==}
+
typescript@5.3.3:
resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==}
engines: {node: '>=14.17'}
@@ -29980,6 +29989,9 @@ snapshots:
is-typedarray: 1.0.0
dev: false
+ typescript-parsec@0.3.4:
+ dev: false
+
typescript@5.3.3: {}
typescript@5.4.2: {}