From aff4e60952321d06dc4cf517ff3b15206aaaebba Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sun, 29 Dec 2024 21:29:58 +0000 Subject: [PATCH 01/40] deps: Upgrade drizzle-orm to 0.38.3 --- apps/web/package.json | 2 +- apps/workers/package.json | 2 +- packages/db/package.json | 2 +- packages/trpc/package.json | 2 +- pnpm-lock.yaml | 121 +++++++++++++++++++++++++++++++++---- 5 files changed, 114 insertions(+), 15 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 98b3a4343..96058ece2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -55,7 +55,7 @@ "clsx": "^2.1.0", "csv-parse": "^5.5.6", "dayjs": "^1.11.10", - "drizzle-orm": "^0.33.0", + "drizzle-orm": "^0.38.3", "fastest-levenshtein": "^1.0.16", "i18next": "^23.16.5", "i18next-resources-to-backend": "^1.2.1", diff --git a/apps/workers/package.json b/apps/workers/package.json index 1ab2a9345..61d429a6b 100644 --- a/apps/workers/package.json +++ b/apps/workers/package.json @@ -14,7 +14,7 @@ "async-mutex": "^0.4.1", "dompurify": "^3.0.9", "dotenv": "^16.4.1", - "drizzle-orm": "^0.33.0", + "drizzle-orm": "^0.38.3", "execa": "9.3.1", "jsdom": "^24.0.0", "liteque": "^0.3.0", diff --git a/packages/db/package.json b/packages/db/package.json index 38537c5f6..e1229dc57 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -15,7 +15,7 @@ "@paralleldrive/cuid2": "^2.2.2", "better-sqlite3": "^11.3.0", "dotenv": "^16.4.1", - "drizzle-orm": "^0.33.0", + "drizzle-orm": "^0.38.3", "tsx": "^4.7.1" }, "devDependencies": { diff --git a/packages/trpc/package.json b/packages/trpc/package.json index 9174a98dc..87b7d8926 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -15,7 +15,7 @@ "@hoarder/shared": "workspace:*", "@trpc/server": "11.0.0-next-beta.308", "bcryptjs": "^2.4.3", - "drizzle-orm": "^0.33.0", + "drizzle-orm": "^0.38.3", "superjson": "^2.2.1", "tiny-invariant": "^1.3.3", "zod": "^3.22.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0df7d25d1..f7bd727f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -575,8 +575,8 @@ importers: specifier: ^1.11.10 version: 1.11.10 drizzle-orm: - specifier: ^0.33.0 - version: 0.33.0(@types/react@18.2.58)(better-sqlite3@11.3.0)(react@18.3.1) + specifier: ^0.38.3 + version: 0.38.3(@types/react@18.2.58)(better-sqlite3@11.3.0)(react@18.3.1) fastest-levenshtein: specifier: ^1.0.16 version: 1.0.16 @@ -747,8 +747,8 @@ importers: specifier: ^16.4.1 version: 16.4.5 drizzle-orm: - specifier: ^0.33.0 - version: 0.33.0(@types/react@18.2.58)(better-sqlite3@11.3.0)(react@18.3.1) + specifier: ^0.38.3 + version: 0.38.3(@types/react@18.2.58)(better-sqlite3@11.3.0)(react@18.3.1) execa: specifier: 9.3.1 version: 9.3.1 @@ -905,8 +905,8 @@ importers: specifier: ^16.4.1 version: 16.4.5 drizzle-orm: - specifier: ^0.33.0 - version: 0.33.0(@types/better-sqlite3@7.6.11)(better-sqlite3@11.3.0) + specifier: ^0.38.3 + version: 0.38.3(@types/better-sqlite3@7.6.11)(better-sqlite3@11.3.0) tsx: specifier: ^4.7.1 version: 4.7.1 @@ -1038,8 +1038,8 @@ importers: specifier: ^2.4.3 version: 2.4.3 drizzle-orm: - specifier: ^0.33.0 - version: 0.33.0(@types/react@18.2.58)(better-sqlite3@11.3.0)(react@18.3.1) + specifier: ^0.38.3 + version: 0.38.3(@types/react@18.2.58)(better-sqlite3@11.3.0)(react@18.3.1) superjson: specifier: ^2.2.1 version: 2.2.1 @@ -6710,6 +6710,98 @@ packages: sqlite3: optional: true + drizzle-orm@0.38.3: + resolution: {integrity: sha512-w41Y+PquMpSff/QDRGdItG0/aWca+/J3Sda9PPGkTxBtjWQvgU1jxlFBXdjog5tYvTu58uvi3PwR1NuCx0KeZg==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/react': '>=18' + '@types/sql.js': '*' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + react: '>=18' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/react': + optional: true + '@types/sql.js': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + react: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} @@ -12112,9 +12204,11 @@ packages: sudo-prompt@8.2.5: resolution: {integrity: sha512-rlBo3HU/1zAJUrkY6jNxDOC9eVYliG6nS4JA8u8KAshITd07tafMc/Br7xQwCSseXwJ2iCcHCE8SNWX3q8Z+kw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. sudo-prompt@9.1.1: resolution: {integrity: sha512-es33J1g2HjMpyAhz8lOR+ICmXXAqTuKbuXuUWLhOLew20oN9oUCgCJx615U/v7aioZg7IX5lIh9x34vwneu4pA==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. superagent@7.1.6: resolution: {integrity: sha512-gZkVCQR1gy/oUXr+kxJMLDjla434KmSOKbx5iGD30Ql+AkJQ/YlPKECJy2nhqOsHLjGHzoDTXNSjhnvWhzKk7g==} @@ -21134,13 +21228,18 @@ snapshots: - supports-color dev: true - drizzle-orm@0.33.0(@types/better-sqlite3@7.6.11)(better-sqlite3@11.3.0): + drizzle-orm@0.33.0(better-sqlite3@11.3.0): + dependencies: + better-sqlite3: 11.3.0 + dev: false + + drizzle-orm@0.38.3(@types/better-sqlite3@7.6.11)(better-sqlite3@11.3.0): dependencies: '@types/better-sqlite3': 7.6.11 better-sqlite3: 11.3.0 dev: false - drizzle-orm@0.33.0(@types/react@18.2.58)(better-sqlite3@11.3.0)(react@18.3.1): + drizzle-orm@0.38.3(@types/react@18.2.58)(better-sqlite3@11.3.0)(react@18.3.1): dependencies: '@types/react': 18.2.58 better-sqlite3: 11.3.0 @@ -24188,7 +24287,7 @@ snapshots: dependencies: async-mutex: 0.4.1 better-sqlite3: 11.3.0 - drizzle-orm: 0.33.0(@types/react@18.2.58)(better-sqlite3@11.3.0)(react@18.3.1) + drizzle-orm: 0.33.0(better-sqlite3@11.3.0) zod: 3.22.4 transitivePeerDependencies: - '@aws-sdk/client-rds-data' From 179f00b15525b024b6823088ef8fb94b7106b4f0 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Mon, 30 Dec 2024 11:27:32 +0000 Subject: [PATCH 02/40] feat: Change the admin page to be tabbed similar to that of the settings page --- apps/web/app/admin/actions/page.tsx | 5 +++ apps/web/app/admin/layout.tsx | 40 +++++++++++++++++++ apps/web/app/admin/overview/page.tsx | 5 +++ apps/web/app/admin/page.tsx | 6 +++ apps/web/app/admin/users/page.tsx | 5 +++ apps/web/app/dashboard/admin/page.tsx | 26 ------------ .../{dashboard => }/admin/AddUserDialog.tsx | 0 .../{dashboard => }/admin/AdminActions.tsx | 2 +- .../{dashboard => }/admin/AdminCard.tsx | 0 .../{dashboard => }/admin/AdminNotices.tsx | 0 .../admin/ChangeRoleDialog.tsx | 0 .../admin/ResetPasswordDialog.tsx | 0 .../{dashboard => }/admin/ServerStats.tsx | 4 +- .../{dashboard => }/admin/UserList.tsx | 4 +- .../admin/sidebar/MobileSidebar.tsx | 21 ++++++++++ apps/web/components/admin/sidebar/Sidebar.tsx | 36 +++++++++++++++++ apps/web/components/admin/sidebar/items.tsx | 31 ++++++++++++++ .../dashboard/header/ProfileOptions.tsx | 4 +- 18 files changed, 156 insertions(+), 33 deletions(-) create mode 100644 apps/web/app/admin/actions/page.tsx create mode 100644 apps/web/app/admin/layout.tsx create mode 100644 apps/web/app/admin/overview/page.tsx create mode 100644 apps/web/app/admin/page.tsx create mode 100644 apps/web/app/admin/users/page.tsx delete mode 100644 apps/web/app/dashboard/admin/page.tsx rename apps/web/components/{dashboard => }/admin/AddUserDialog.tsx (100%) rename apps/web/components/{dashboard => }/admin/AdminActions.tsx (97%) rename apps/web/components/{dashboard => }/admin/AdminCard.tsx (100%) rename apps/web/components/{dashboard => }/admin/AdminNotices.tsx (100%) rename apps/web/components/{dashboard => }/admin/ChangeRoleDialog.tsx (100%) rename apps/web/components/{dashboard => }/admin/ResetPasswordDialog.tsx (100%) rename apps/web/components/{dashboard => }/admin/ServerStats.tsx (98%) rename apps/web/components/{dashboard => }/admin/UserList.tsx (98%) create mode 100644 apps/web/components/admin/sidebar/MobileSidebar.tsx create mode 100644 apps/web/components/admin/sidebar/Sidebar.tsx create mode 100644 apps/web/components/admin/sidebar/items.tsx diff --git a/apps/web/app/admin/actions/page.tsx b/apps/web/app/admin/actions/page.tsx new file mode 100644 index 000000000..51f7e5d43 --- /dev/null +++ b/apps/web/app/admin/actions/page.tsx @@ -0,0 +1,5 @@ +import AdminActions from "@/components/admin/AdminActions"; + +export default function AdminActionsPage() { + return ; +} diff --git a/apps/web/app/admin/layout.tsx b/apps/web/app/admin/layout.tsx new file mode 100644 index 000000000..0d876736c --- /dev/null +++ b/apps/web/app/admin/layout.tsx @@ -0,0 +1,40 @@ +import { redirect } from "next/navigation"; +import { AdminCard } from "@/components/admin/AdminCard"; +import { AdminNotices } from "@/components/admin/AdminNotices"; +import MobileAdminSidebar from "@/components/admin/sidebar/MobileSidebar"; +import AdminSidebar from "@/components/admin/sidebar/Sidebar"; +import Header from "@/components/dashboard/header/Header"; +import { Separator } from "@/components/ui/separator"; +import { getServerAuthSession } from "@/server/auth"; + +export default async function AdminLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const session = await getServerAuthSession(); + if (!session || session.user.role !== "admin") { + redirect("/"); + } + + return ( +
+
+
+
+ +
+
+
+ + +
+
+ + {children} +
+
+
+
+ ); +} diff --git a/apps/web/app/admin/overview/page.tsx b/apps/web/app/admin/overview/page.tsx new file mode 100644 index 000000000..226fb9d59 --- /dev/null +++ b/apps/web/app/admin/overview/page.tsx @@ -0,0 +1,5 @@ +import ServerStats from "@/components/admin/ServerStats"; + +export default function AdminOverviewPage() { + return ; +} diff --git a/apps/web/app/admin/page.tsx b/apps/web/app/admin/page.tsx new file mode 100644 index 000000000..7fed81854 --- /dev/null +++ b/apps/web/app/admin/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation"; + +export default function AdminHomepage() { + redirect("/admin/overview"); + return null; +} diff --git a/apps/web/app/admin/users/page.tsx b/apps/web/app/admin/users/page.tsx new file mode 100644 index 000000000..be5cfe819 --- /dev/null +++ b/apps/web/app/admin/users/page.tsx @@ -0,0 +1,5 @@ +import UserList from "@/components/admin/UserList"; + +export default function AdminUsersPage() { + return ; +} diff --git a/apps/web/app/dashboard/admin/page.tsx b/apps/web/app/dashboard/admin/page.tsx deleted file mode 100644 index cf97698b5..000000000 --- a/apps/web/app/dashboard/admin/page.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { redirect } from "next/navigation"; -import AdminActions from "@/components/dashboard/admin/AdminActions"; -import { AdminCard } from "@/components/dashboard/admin/AdminCard"; -import { AdminNotices } from "@/components/dashboard/admin/AdminNotices"; -import ServerStats from "@/components/dashboard/admin/ServerStats"; -import UserList from "@/components/dashboard/admin/UserList"; -import { getServerAuthSession } from "@/server/auth"; - -export default async function AdminPage() { - const session = await getServerAuthSession(); - if (!session || session.user.role !== "admin") { - redirect("/"); - } - return ( -
- - - - - - - - -
- ); -} diff --git a/apps/web/components/dashboard/admin/AddUserDialog.tsx b/apps/web/components/admin/AddUserDialog.tsx similarity index 100% rename from apps/web/components/dashboard/admin/AddUserDialog.tsx rename to apps/web/components/admin/AddUserDialog.tsx diff --git a/apps/web/components/dashboard/admin/AdminActions.tsx b/apps/web/components/admin/AdminActions.tsx similarity index 97% rename from apps/web/components/dashboard/admin/AdminActions.tsx rename to apps/web/components/admin/AdminActions.tsx index 3b95045c7..34b3d63aa 100644 --- a/apps/web/components/dashboard/admin/AdminActions.tsx +++ b/apps/web/components/admin/AdminActions.tsx @@ -71,7 +71,7 @@ export default function AdminActions() { return (
-
{t("common.actions")}
+
{t("common.actions")}
+
{t("admin.server_stats.server_stats")}
@@ -143,6 +143,6 @@ export default function ServerStats() {
- +
); } diff --git a/apps/web/components/dashboard/admin/UserList.tsx b/apps/web/components/admin/UserList.tsx similarity index 98% rename from apps/web/components/dashboard/admin/UserList.tsx rename to apps/web/components/admin/UserList.tsx index 8c788ef44..3dfcaad19 100644 --- a/apps/web/components/dashboard/admin/UserList.tsx +++ b/apps/web/components/admin/UserList.tsx @@ -55,7 +55,7 @@ export default function UsersSection() { } return ( - <> +
{t("admin.users_list.users_list")} @@ -125,6 +125,6 @@ export default function UsersSection() { ))} - +
); } diff --git a/apps/web/components/admin/sidebar/MobileSidebar.tsx b/apps/web/components/admin/sidebar/MobileSidebar.tsx new file mode 100644 index 000000000..416b944cc --- /dev/null +++ b/apps/web/components/admin/sidebar/MobileSidebar.tsx @@ -0,0 +1,21 @@ +import MobileSidebarItem from "@/components/shared/sidebar/ModileSidebarItem"; +import { useTranslation } from "@/lib/i18n/server"; + +import { adminSidebarItems } from "./items"; + +export default async function MobileSidebar() { + const { t } = await useTranslation(); + return ( + + ); +} diff --git a/apps/web/components/admin/sidebar/Sidebar.tsx b/apps/web/components/admin/sidebar/Sidebar.tsx new file mode 100644 index 000000000..8a5d615a1 --- /dev/null +++ b/apps/web/components/admin/sidebar/Sidebar.tsx @@ -0,0 +1,36 @@ +import { redirect } from "next/navigation"; +import SidebarItem from "@/components/shared/sidebar/SidebarItem"; +import { useTranslation } from "@/lib/i18n/server"; +import { getServerAuthSession } from "@/server/auth"; + +import serverConfig from "@hoarder/shared/config"; + +import { adminSidebarItems } from "./items"; + +export default async function Sidebar() { + const { t } = await useTranslation(); + const session = await getServerAuthSession(); + if (!session) { + redirect("/"); + } + + return ( + + ); +} diff --git a/apps/web/components/admin/sidebar/items.tsx b/apps/web/components/admin/sidebar/items.tsx new file mode 100644 index 000000000..78dfee349 --- /dev/null +++ b/apps/web/components/admin/sidebar/items.tsx @@ -0,0 +1,31 @@ +import { TFunction } from "i18next"; +import { Activity, ArrowLeft, Settings, Users } from "lucide-react"; + +export const adminSidebarItems = ( + t: TFunction, +): { + name: string; + icon: JSX.Element; + path: string; +}[] => [ + { + name: t("settings.back_to_app"), + icon: , + path: "/dashboard/bookmarks", + }, + { + name: t("admin.server_stats.server_stats"), + icon: , + path: "/admin/overview", + }, + { + name: t("admin.users_list.users_list"), + icon: , + path: "/admin/users", + }, + { + name: t("common.actions"), + icon: , + path: "/admin/actions", + }, +]; diff --git a/apps/web/components/dashboard/header/ProfileOptions.tsx b/apps/web/components/dashboard/header/ProfileOptions.tsx index fc18e9d2f..3d125606f 100644 --- a/apps/web/components/dashboard/header/ProfileOptions.tsx +++ b/apps/web/components/dashboard/header/ProfileOptions.tsx @@ -16,7 +16,7 @@ import { LogOut, Moon, Paintbrush, Settings, Shield, Sun } from "lucide-react"; import { signOut, useSession } from "next-auth/react"; import { useTheme } from "next-themes"; -import { AdminNoticeBadge } from "../admin/AdminNotices"; +import { AdminNoticeBadge } from "../../admin/AdminNotices"; function DarkModeToggle() { const { t } = useTranslation(); @@ -74,7 +74,7 @@ export default function SidebarProfileOptions() { {session.user.role == "admin" && ( - +
{t("admin.admin_settings")} From 5902664658a36e4afc81327eea9f8eef05561bcb Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Mon, 30 Dec 2024 12:15:35 +0000 Subject: [PATCH 03/40] refactor: Refactor sidebar into a shared component --- apps/web/app/admin/layout.tsx | 63 ++++++++----- apps/web/app/dashboard/layout.tsx | 89 ++++++++++++++----- apps/web/app/settings/layout.tsx | 86 +++++++++++++----- apps/web/components/admin/sidebar/items.tsx | 31 ------- .../dashboard/sidebar/ModileSidebar.tsx | 23 ----- .../components/dashboard/sidebar/Sidebar.tsx | 81 ----------------- .../settings/sidebar/ModileSidebar.tsx | 21 ----- .../components/settings/sidebar/Sidebar.tsx | 36 -------- .../web/components/settings/sidebar/items.tsx | 55 ------------ .../sidebar/MobileSidebar.tsx | 13 ++- .../{admin => shared}/sidebar/Sidebar.tsx | 22 ++--- .../shared/sidebar/SidebarLayout.tsx | 37 ++++++++ .../components/shared/sidebar/TSidebarItem.ts | 5 ++ 13 files changed, 233 insertions(+), 329 deletions(-) delete mode 100644 apps/web/components/admin/sidebar/items.tsx delete mode 100644 apps/web/components/dashboard/sidebar/ModileSidebar.tsx delete mode 100644 apps/web/components/dashboard/sidebar/Sidebar.tsx delete mode 100644 apps/web/components/settings/sidebar/ModileSidebar.tsx delete mode 100644 apps/web/components/settings/sidebar/Sidebar.tsx delete mode 100644 apps/web/components/settings/sidebar/items.tsx rename apps/web/components/{admin => shared}/sidebar/MobileSidebar.tsx (58%) rename apps/web/components/{admin => shared}/sidebar/Sidebar.tsx (63%) create mode 100644 apps/web/components/shared/sidebar/SidebarLayout.tsx create mode 100644 apps/web/components/shared/sidebar/TSidebarItem.ts diff --git a/apps/web/app/admin/layout.tsx b/apps/web/app/admin/layout.tsx index 0d876736c..7b20b7ad5 100644 --- a/apps/web/app/admin/layout.tsx +++ b/apps/web/app/admin/layout.tsx @@ -1,11 +1,41 @@ import { redirect } from "next/navigation"; import { AdminCard } from "@/components/admin/AdminCard"; import { AdminNotices } from "@/components/admin/AdminNotices"; -import MobileAdminSidebar from "@/components/admin/sidebar/MobileSidebar"; -import AdminSidebar from "@/components/admin/sidebar/Sidebar"; -import Header from "@/components/dashboard/header/Header"; -import { Separator } from "@/components/ui/separator"; +import MobileSidebar from "@/components/shared/sidebar/MobileSidebar"; +import Sidebar from "@/components/shared/sidebar/Sidebar"; +import SidebarLayout from "@/components/shared/sidebar/SidebarLayout"; import { getServerAuthSession } from "@/server/auth"; +import { TFunction } from "i18next"; +import { Activity, ArrowLeft, Settings, Users } from "lucide-react"; + +const adminSidebarItems = ( + t: TFunction, +): { + name: string; + icon: JSX.Element; + path: string; +}[] => [ + { + name: t("settings.back_to_app"), + icon: , + path: "/dashboard/bookmarks", + }, + { + name: t("admin.server_stats.server_stats"), + icon: , + path: "/admin/overview", + }, + { + name: t("admin.users_list.users_list"), + icon: , + path: "/admin/users", + }, + { + name: t("common.actions"), + icon: , + path: "/admin/actions", + }, +]; export default async function AdminLayout({ children, @@ -18,23 +48,14 @@ export default async function AdminLayout({ } return ( -
-
-
-
- -
-
-
- - -
-
- - {children} -
-
+ } + mobileSidebar={} + > +
+ + {children}
-
+ ); } diff --git a/apps/web/app/dashboard/layout.tsx b/apps/web/app/dashboard/layout.tsx index cbd512456..17a7c1444 100644 --- a/apps/web/app/dashboard/layout.tsx +++ b/apps/web/app/dashboard/layout.tsx @@ -1,9 +1,13 @@ -import Header from "@/components/dashboard/header/Header"; -import MobileSidebar from "@/components/dashboard/sidebar/ModileSidebar"; -import Sidebar from "@/components/dashboard/sidebar/Sidebar"; -import DemoModeBanner from "@/components/DemoModeBanner"; +import { redirect } from "next/navigation"; +import AllLists from "@/components/dashboard/sidebar/AllLists"; +import MobileSidebar from "@/components/shared/sidebar/MobileSidebar"; +import Sidebar from "@/components/shared/sidebar/Sidebar"; +import SidebarLayout from "@/components/shared/sidebar/SidebarLayout"; import { Separator } from "@/components/ui/separator"; -import ValidAccountCheck from "@/components/utils/ValidAccountCheck"; +import { api } from "@/server/api/client"; +import { getServerAuthSession } from "@/server/auth"; +import { TFunction } from "i18next"; +import { Archive, Highlighter, Home, Search, Tag } from "lucide-react"; import serverConfig from "@hoarder/shared/config"; @@ -14,24 +18,63 @@ export default async function Dashboard({ children: React.ReactNode; modal: React.ReactNode; }>) { + const session = await getServerAuthSession(); + if (!session) { + redirect("/"); + } + + const lists = await api.lists.list(); + + const items = (t: TFunction) => + [ + { + name: t("common.home"), + icon: , + path: "/dashboard/bookmarks", + }, + serverConfig.meilisearch + ? [ + { + name: t("common.search"), + icon: , + path: "/dashboard/search", + }, + ] + : [], + { + name: t("common.tags"), + icon: , + path: "/dashboard/tags", + }, + { + name: t("common.highlights"), + icon: , + path: "/dashboard/highlights", + }, + { + name: t("common.archive"), + icon: , + path: "/dashboard/archive", + }, + ].flat(); + return ( -
-
-
- -
- -
-
- {serverConfig.demoMode && } -
- - -
- {modal} -
{children}
-
-
-
+ + + + + } + /> + } + mobileSidebar={} + modal={modal} + > + {children} + ); } diff --git a/apps/web/app/settings/layout.tsx b/apps/web/app/settings/layout.tsx index 0ab6c6249..bbff68a94 100644 --- a/apps/web/app/settings/layout.tsx +++ b/apps/web/app/settings/layout.tsx @@ -1,11 +1,60 @@ -import Header from "@/components/dashboard/header/Header"; -import DemoModeBanner from "@/components/DemoModeBanner"; -import MobileSidebar from "@/components/settings/sidebar/ModileSidebar"; -import Sidebar from "@/components/settings/sidebar/Sidebar"; -import { Separator } from "@/components/ui/separator"; -import ValidAccountCheck from "@/components/utils/ValidAccountCheck"; +import MobileSidebar from "@/components/shared/sidebar/MobileSidebar"; +import Sidebar from "@/components/shared/sidebar/Sidebar"; +import SidebarLayout from "@/components/shared/sidebar/SidebarLayout"; +import { TFunction } from "i18next"; +import { + ArrowLeft, + Download, + KeyRound, + Link, + Rss, + Sparkles, + User, +} from "lucide-react"; -import serverConfig from "@hoarder/shared/config"; +const settingsSidebarItems = ( + t: TFunction, +): { + name: string; + icon: JSX.Element; + path: string; +}[] => [ + { + name: t("settings.back_to_app"), + icon: , + path: "/dashboard/bookmarks", + }, + { + name: t("settings.info.user_info"), + icon: , + path: "/settings/info", + }, + { + name: t("settings.ai.ai_settings"), + icon: , + path: "/settings/ai", + }, + { + name: t("settings.feeds.rss_subscriptions"), + icon: , + path: "/settings/feeds", + }, + { + name: t("settings.import.import_export"), + icon: , + path: "/settings/import", + }, + { + name: t("settings.api_keys.api_keys"), + icon: , + path: "/settings/api-keys", + }, + { + name: t("settings.broken_links.broken_links"), + icon: , + path: "/settings/broken-links", + }, +]; export default async function SettingsLayout({ children, @@ -13,22 +62,11 @@ export default async function SettingsLayout({ children: React.ReactNode; }>) { return ( -
-
-
- -
- -
-
- {serverConfig.demoMode && } -
- - -
-
{children}
-
-
-
+ } + mobileSidebar={} + > + {children} + ); } diff --git a/apps/web/components/admin/sidebar/items.tsx b/apps/web/components/admin/sidebar/items.tsx deleted file mode 100644 index 78dfee349..000000000 --- a/apps/web/components/admin/sidebar/items.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { TFunction } from "i18next"; -import { Activity, ArrowLeft, Settings, Users } from "lucide-react"; - -export const adminSidebarItems = ( - t: TFunction, -): { - name: string; - icon: JSX.Element; - path: string; -}[] => [ - { - name: t("settings.back_to_app"), - icon: , - path: "/dashboard/bookmarks", - }, - { - name: t("admin.server_stats.server_stats"), - icon: , - path: "/admin/overview", - }, - { - name: t("admin.users_list.users_list"), - icon: , - path: "/admin/users", - }, - { - name: t("common.actions"), - icon: , - path: "/admin/actions", - }, -]; diff --git a/apps/web/components/dashboard/sidebar/ModileSidebar.tsx b/apps/web/components/dashboard/sidebar/ModileSidebar.tsx deleted file mode 100644 index 777877bf6..000000000 --- a/apps/web/components/dashboard/sidebar/ModileSidebar.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import MobileSidebarItem from "@/components/shared/sidebar/ModileSidebarItem"; -import HoarderLogoIcon from "@/public/icons/logo-icon.svg"; -import { ClipboardList, Highlighter, Search, Tag } from "lucide-react"; - -export default async function MobileSidebar() { - return ( - - ); -} diff --git a/apps/web/components/dashboard/sidebar/Sidebar.tsx b/apps/web/components/dashboard/sidebar/Sidebar.tsx deleted file mode 100644 index 0f805a094..000000000 --- a/apps/web/components/dashboard/sidebar/Sidebar.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { redirect } from "next/navigation"; -import SidebarItem from "@/components/shared/sidebar/SidebarItem"; -import { Separator } from "@/components/ui/separator"; -import { useTranslation } from "@/lib/i18n/server"; -import { api } from "@/server/api/client"; -import { getServerAuthSession } from "@/server/auth"; -import { Archive, Highlighter, Home, Search, Tag } from "lucide-react"; - -import serverConfig from "@hoarder/shared/config"; - -import AllLists from "./AllLists"; - -export default async function Sidebar() { - const { t } = await useTranslation(); - const session = await getServerAuthSession(); - if (!session) { - redirect("/"); - } - - const lists = await api.lists.list(); - - const searchItem = serverConfig.meilisearch - ? [ - { - name: t("common.search"), - icon: , - path: "/dashboard/search", - }, - ] - : []; - - const menu: { - name: string; - icon: JSX.Element; - path: string; - }[] = [ - { - name: t("common.home"), - icon: , - path: "/dashboard/bookmarks", - }, - ...searchItem, - { - name: t("common.tags"), - icon: , - path: "/dashboard/tags", - }, - { - name: t("common.highlights"), - icon: , - path: "/dashboard/highlights", - }, - { - name: t("common.archive"), - icon: , - path: "/dashboard/archive", - }, - ]; - - return ( - - ); -} diff --git a/apps/web/components/settings/sidebar/ModileSidebar.tsx b/apps/web/components/settings/sidebar/ModileSidebar.tsx deleted file mode 100644 index cbed9ef99..000000000 --- a/apps/web/components/settings/sidebar/ModileSidebar.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import MobileSidebarItem from "@/components/shared/sidebar/ModileSidebarItem"; -import { useTranslation } from "@/lib/i18n/server"; - -import { settingsSidebarItems } from "./items"; - -export default async function MobileSidebar() { - const { t } = await useTranslation(); - return ( - - ); -} diff --git a/apps/web/components/settings/sidebar/Sidebar.tsx b/apps/web/components/settings/sidebar/Sidebar.tsx deleted file mode 100644 index a1b61e985..000000000 --- a/apps/web/components/settings/sidebar/Sidebar.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { redirect } from "next/navigation"; -import SidebarItem from "@/components/shared/sidebar/SidebarItem"; -import { useTranslation } from "@/lib/i18n/server"; -import { getServerAuthSession } from "@/server/auth"; - -import serverConfig from "@hoarder/shared/config"; - -import { settingsSidebarItems } from "./items"; - -export default async function Sidebar() { - const { t } = await useTranslation(); - const session = await getServerAuthSession(); - if (!session) { - redirect("/"); - } - - return ( - - ); -} diff --git a/apps/web/components/settings/sidebar/items.tsx b/apps/web/components/settings/sidebar/items.tsx deleted file mode 100644 index f76d494a5..000000000 --- a/apps/web/components/settings/sidebar/items.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from "react"; -import { TFunction } from "i18next"; -import { - ArrowLeft, - Download, - KeyRound, - Link, - Rss, - Sparkles, - User, -} from "lucide-react"; - -export const settingsSidebarItems = ( - t: TFunction, -): { - name: string; - icon: JSX.Element; - path: string; -}[] => [ - { - name: t("settings.back_to_app"), - icon: , - path: "/dashboard/bookmarks", - }, - { - name: t("settings.info.user_info"), - icon: , - path: "/settings/info", - }, - { - name: t("settings.ai.ai_settings"), - icon: , - path: "/settings/ai", - }, - { - name: t("settings.feeds.rss_subscriptions"), - icon: , - path: "/settings/feeds", - }, - { - name: t("settings.import.import_export"), - icon: , - path: "/settings/import", - }, - { - name: t("settings.api_keys.api_keys"), - icon: , - path: "/settings/api-keys", - }, - { - name: t("settings.broken_links.broken_links"), - icon: , - path: "/settings/broken-links", - }, -]; diff --git a/apps/web/components/admin/sidebar/MobileSidebar.tsx b/apps/web/components/shared/sidebar/MobileSidebar.tsx similarity index 58% rename from apps/web/components/admin/sidebar/MobileSidebar.tsx rename to apps/web/components/shared/sidebar/MobileSidebar.tsx index 416b944cc..d3edc7df4 100644 --- a/apps/web/components/admin/sidebar/MobileSidebar.tsx +++ b/apps/web/components/shared/sidebar/MobileSidebar.tsx @@ -1,14 +1,19 @@ -import MobileSidebarItem from "@/components/shared/sidebar/ModileSidebarItem"; import { useTranslation } from "@/lib/i18n/server"; +import { TFunction } from "i18next"; -import { adminSidebarItems } from "./items"; +import MobileSidebarItem from "./ModileSidebarItem"; +import { TSidebarItem } from "./TSidebarItem"; -export default async function MobileSidebar() { +export default async function MobileSidebar({ + items, +}: { + items: (t: TFunction) => TSidebarItem[]; +}) { const { t } = await useTranslation(); return (
@@ -234,7 +234,7 @@ function Footer() { href={GITHUB_LINK} className="flex justify-center gap-2 text-center" > - Github + GitHub
From b6d5556561698579361db9158d1ad70c5a8d48a4 Mon Sep 17 00:00:00 2001 From: JD Hartley Date: Tue, 31 Dec 2024 04:22:50 -0600 Subject: [PATCH 10/40] feat: show createdAt year in card footer when created over a year ago (#790) --- .../bookmarks/BookmarkLayoutAdaptingCard.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx index 1df0c1975..a2323987e 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx @@ -30,6 +30,13 @@ interface Props { wrapTags: boolean; } +function BookmarkFormattedCreatedAt({ bookmark }: { bookmark: ZBookmark }) { + const createdAt = dayjs(bookmark.createdAt); + const oneYearAgo = dayjs().subtract(1, "year"); + const formatString = createdAt.isAfter(oneYearAgo) ? "MMM D" : "MMM D, YYYY"; + return createdAt.format(formatString); +} + function BottomRow({ footer, bookmark, @@ -45,7 +52,7 @@ function BottomRow({ href={`/dashboard/preview/${bookmark.id}`} suppressHydrationWarning > - {dayjs(bookmark.createdAt).format("MMM DD")} + @@ -232,7 +239,7 @@ function CompactView({ bookmark, title, footer, className }: Props) { suppressHydrationWarning className="shrink-0 gap-2 text-gray-500" > - {dayjs(bookmark.createdAt).format("MMM DD")} + From f476fca758bb039f9605488b61ba35fc097d6cfc Mon Sep 17 00:00:00 2001 From: Nicole Li <40200356+lexafaxine@users.noreply.github.com> Date: Tue, 31 Dec 2024 19:33:41 +0900 Subject: [PATCH 11/40] feat: Add delete bookmark confirmation dialog. Fixes #776 (#787) --- .../components/bookmarks/BookmarkCard.tsx | 19 +++++- .../dashboard/bookmarks/BookmarkOptions.tsx | 21 +++---- .../DeleteBookmarkConfirmationDialog.tsx | 63 +++++++++++++++++++ .../dashboard/preview/ActionBar.tsx | 37 +++++------ apps/web/lib/i18n/locales/en/translation.json | 6 ++ pnpm-lock.yaml | 2 +- 6 files changed, 110 insertions(+), 38 deletions(-) create mode 100644 apps/web/components/dashboard/bookmarks/DeleteBookmarkConfirmationDialog.tsx diff --git a/apps/mobile/components/bookmarks/BookmarkCard.tsx b/apps/mobile/components/bookmarks/BookmarkCard.tsx index 13d639c9c..ce294a6f2 100644 --- a/apps/mobile/components/bookmarks/BookmarkCard.tsx +++ b/apps/mobile/components/bookmarks/BookmarkCard.tsx @@ -1,5 +1,6 @@ import { ActivityIndicator, + Alert, Image, Platform, Pressable, @@ -70,6 +71,20 @@ function ActionBar({ bookmark }: { bookmark: ZBookmark }) { onError, }); + const deleteBookmarkAlert = () => + Alert.alert( + "Delete bookmark?", + "Are you sure you want to delete this bookmark?", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + onPress: () => deleteBookmark({ bookmarkId: bookmark.id }), + style: "destructive", + }, + ], + ); + return ( {(isArchivePending || isDeletionPending) && } @@ -93,9 +108,7 @@ function ActionBar({ bookmark }: { bookmark: ZBookmark }) { onPressAction={({ nativeEvent }) => { Haptics.selectionAsync(); if (nativeEvent.event === "delete") { - deleteBookmark({ - bookmarkId: bookmark.id, - }); + deleteBookmarkAlert(); } else if (nativeEvent.event === "archive") { archiveBookmark({ bookmarkId: bookmark.id, diff --git a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx index 8dfb96fd7..e9e5834bc 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx @@ -28,7 +28,6 @@ import type { ZBookmarkedLink, } from "@hoarder/shared/types/bookmarks"; import { - useDeleteBookmark, useRecrawlBookmark, useUpdateBookmark, } from "@hoarder/shared-react/hooks//bookmarks"; @@ -37,6 +36,7 @@ import { useBookmarkGridContext } from "@hoarder/shared-react/hooks/bookmark-gri import { BookmarkTypes } from "@hoarder/shared/types/bookmarks"; import { BookmarkedTextEditor } from "./BookmarkedTextEditor"; +import DeleteBookmarkConfirmationDialog from "./DeleteBookmarkConfirmationDialog"; import { ArchivedActionIcon, FavouritedActionIcon } from "./icons"; import { useManageListsModal } from "./ManageListsModal"; import { useTagModel } from "./TagModal"; @@ -53,6 +53,8 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { const { setOpen: setManageListsModalOpen, content: manageListsModal } = useManageListsModal(bookmark.id); + const [deleteBookmarkDialogOpen, setDeleteBookmarkDialogOpen] = + useState(false); const [isTextEditorOpen, setTextEditorOpen] = useState(false); const { listId } = useBookmarkGridContext() ?? {}; @@ -63,14 +65,6 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { title: t("common.something_went_wrong"), }); }; - const deleteBookmarkMutator = useDeleteBookmark({ - onSuccess: () => { - toast({ - description: t("toasts.bookmarks.deleted"), - }); - }, - onError, - }); const updateBookmarkMutator = useUpdateBookmark({ onSuccess: () => { @@ -112,6 +106,11 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { <> {tagModal} {manageListsModal} + - deleteBookmarkMutator.mutate({ bookmarkId: bookmark.id }) - } + onClick={() => setDeleteBookmarkDialogOpen(true)} > {t("actions.delete")} diff --git a/apps/web/components/dashboard/bookmarks/DeleteBookmarkConfirmationDialog.tsx b/apps/web/components/dashboard/bookmarks/DeleteBookmarkConfirmationDialog.tsx new file mode 100644 index 000000000..4a69e3d01 --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/DeleteBookmarkConfirmationDialog.tsx @@ -0,0 +1,63 @@ +import { usePathname, useRouter } from "next/navigation"; +import { ActionButton } from "@/components/ui/action-button"; +import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; +import { toast } from "@/components/ui/use-toast"; +import { useTranslation } from "@/lib/i18n/client"; + +import { useDeleteBookmark } from "@hoarder/shared-react/hooks//bookmarks"; +import { ZBookmark } from "@hoarder/shared/types/bookmarks"; + +export default function DeleteBookmarkConfirmationDialog({ + bookmark, + children, + open, + setOpen, +}: { + bookmark: ZBookmark; + children?: React.ReactNode; + open: boolean; + setOpen: (v: boolean) => void; +}) { + const { t } = useTranslation(); + const currentPath = usePathname(); + const router = useRouter(); + + const { mutate: deleteBoomark, isPending } = useDeleteBookmark({ + onSuccess: () => { + toast({ + description: t("toasts.bookmarks.deleted"), + }); + setOpen(false); + if (currentPath.includes(bookmark.id)) { + router.push("/dashboard/bookmarks"); + } + }, + onError: () => { + toast({ + variant: "destructive", + description: `Something went wrong`, + }); + }, + }); + + return ( + ( + deleteBoomark({ bookmarkId: bookmark.id })} + > + Delete + + )} + > + {children} + + ); +} diff --git a/apps/web/components/dashboard/preview/ActionBar.tsx b/apps/web/components/dashboard/preview/ActionBar.tsx index 38ad8fa26..86c86d5a0 100644 --- a/apps/web/components/dashboard/preview/ActionBar.tsx +++ b/apps/web/components/dashboard/preview/ActionBar.tsx @@ -1,5 +1,6 @@ -import { useRouter } from "next/navigation"; +import { useState } from "react"; import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, @@ -10,16 +11,16 @@ import { useTranslation } from "@/lib/i18n/client"; import { Trash2 } from "lucide-react"; import type { ZBookmark } from "@hoarder/shared/types/bookmarks"; -import { - useDeleteBookmark, - useUpdateBookmark, -} from "@hoarder/shared-react/hooks/bookmarks"; +import { useUpdateBookmark } from "@hoarder/shared-react/hooks/bookmarks"; +import DeleteBookmarkConfirmationDialog from "../bookmarks/DeleteBookmarkConfirmationDialog"; import { ArchivedActionIcon, FavouritedActionIcon } from "../bookmarks/icons"; export default function ActionBar({ bookmark }: { bookmark: ZBookmark }) { const { t } = useTranslation(); - const router = useRouter(); + const [deleteBookmarkDialogOpen, setDeleteBookmarkDialogOpen] = + useState(false); + const onError = () => { toast({ variant: "destructive", @@ -44,16 +45,6 @@ export default function ActionBar({ bookmark }: { bookmark: ZBookmark }) { }, onError, }); - const { mutate: deleteBookmark, isPending: pendingDeletion } = - useDeleteBookmark({ - onSuccess: () => { - toast({ - description: "The bookmark has been deleted!", - }); - router.back(); - }, - onError, - }); return (
@@ -100,17 +91,19 @@ export default function ActionBar({ bookmark }: { bookmark: ZBookmark }) { + - { - deleteBookmark({ bookmarkId: bookmark.id }); - }} + onClick={() => setDeleteBookmarkDialogOpen(true)} > - + {t("actions.delete")} diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index 92f6e956e..cdd319224 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -243,6 +243,12 @@ } } }, + "dialogs": { + "bookmarks": { + "delete_confirmation_title": "Delete Bookmark?", + "delete_confirmation_description": "Are you sure you want to delete this bookmark?" + } + }, "toasts": { "bookmarks": { "updated": "The bookmark has been updated!", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29df9366c..f9721ffbe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31059,4 +31059,4 @@ snapshots: use-sync-external-store: 1.2.0(react@18.3.1) dev: false - zwitch@2.0.4: {} + zwitch@2.0.4: {} \ No newline at end of file From cbaf9e6034aa09911fca967b7af6cad11f154b3e Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Tue, 31 Dec 2024 13:17:56 +0200 Subject: [PATCH 12/40] feat: Introduce advanced search capabilities (#753) * feat: Implement search filtering in the backend * feat: Implement search language parser * rename matcher name * Add ability to interleve text * More fixes * be more tolerable to parsing errors * Add a search query explainer widget * Handle date parsing gracefully * Fix the lockfile * Encode query search param * Fix table body error * Fix error when writing quotes --- .../search/QueryExplainerTooltip.tsx | 98 +++++ .../dashboard/search/SearchInput.tsx | 27 +- apps/web/lib/hooks/bookmark-search.ts | 20 +- packages/shared/package.json | 7 +- packages/shared/searchQueryParser.test.ts | 275 ++++++++++++++ packages/shared/searchQueryParser.ts | 351 ++++++++++++++++++ packages/shared/types/search.ts | 72 ++++ packages/shared/vitest.config.ts | 14 + packages/trpc/lib/search.ts | 182 +++++++++ packages/trpc/routers/bookmarks.ts | 16 +- pnpm-lock.yaml | 12 + 11 files changed, 1054 insertions(+), 20 deletions(-) create mode 100644 apps/web/components/dashboard/search/QueryExplainerTooltip.tsx create mode 100644 packages/shared/searchQueryParser.test.ts create mode 100644 packages/shared/searchQueryParser.ts create mode 100644 packages/shared/types/search.ts create mode 100644 packages/shared/vitest.config.ts create mode 100644 packages/trpc/lib/search.ts diff --git a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx new file mode 100644 index 000000000..191c9ff36 --- /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 ( + + Tag Name + {matcher.tagName} + + ); + case "listName": + return ( + + List Name + {matcher.listName} + + ); + case "dateAfter": + return ( + + Created After + {matcher.dateAfter.toDateString()} + + ); + case "dateBefore": + return ( + + Created Before + {matcher.dateBefore.toDateString()} + + ); + case "favourited": + return ( + + Favourited + {matcher.favourited.toString()} + + ); + case "archived": + return ( + + Archived + {matcher.archived.toString()} + + ); + case "and": + case "or": + return ( + + {matcher.type} + + + + {matcher.matchers.map((m, i) => ( + + ))} + +
+
+
+ ); + } + }; + + return ( + + + + {parsedSearchQuery.text && ( + + Text + {parsedSearchQuery.text} + + )} + {parsedSearchQuery.matcher && ( + + )} + +
+
+ ); +} diff --git a/apps/web/components/dashboard/search/SearchInput.tsx b/apps/web/components/dashboard/search/SearchInput.tsx index 55f304e34..8ed2ea3cc 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, @@ -47,7 +50,8 @@ const SearchInput = React.forwardRef< React.HTMLAttributes & { 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 ( - +
+ + +
); }); SearchInput.displayName = "SearchInput"; diff --git a/apps/web/lib/hooks/bookmark-search.ts b/apps/web/lib/hooks/bookmark-search.ts index 9890ac6fa..4662ffb6e 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(); 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 d741b70fb..d412301a9 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 000000000..428d59299 --- /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 000000000..faf74d089 --- /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 { + private constructor( + private readonly input: string, + public kind: TokenType, + public text: string, + public pos: TokenPosition, + ) {} + + public static from(input: string): Token | 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 | 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(); +const EXP = rule(); + +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) { + let str = ""; + let tok: Token | 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 000000000..d430dad5d --- /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; +export type Matcher = + | NonRecursiveMatcher + | { type: "and"; matchers: Matcher[] } + | { type: "or"; matchers: Matcher[] }; + +export const zMatcherSchema: z.ZodType = 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 000000000..41fd70c4d --- /dev/null +++ b/packages/shared/vitest.config.ts @@ -0,0 +1,14 @@ +/// + +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 000000000..0ee9c76e5 --- /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(); + const map = new Map(); + + 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(); + const map = new Map(); + 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 { + switch (matcher.type) { + case "tagName": { + return db + .select({ id: sql`${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`${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 { + 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 254ac6c26..3320b3b99 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 f9721ffbe..934c93bb0 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: {} From 4edea5656a13a1d884f7815034628e54a96f74d8 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Tue, 31 Dec 2024 12:18:13 +0000 Subject: [PATCH 13/40] test: Add tests for the search id queries --- packages/trpc/lib/__tests__/search.test.ts | 276 +++++++++++++++++++++ packages/trpc/vitest.config.ts | 4 + 2 files changed, 280 insertions(+) create mode 100644 packages/trpc/lib/__tests__/search.test.ts diff --git a/packages/trpc/lib/__tests__/search.test.ts b/packages/trpc/lib/__tests__/search.test.ts new file mode 100644 index 000000000..aa57527b1 --- /dev/null +++ b/packages/trpc/lib/__tests__/search.test.ts @@ -0,0 +1,276 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { getInMemoryDB } from "@hoarder/db/drizzle"; +import { + bookmarkAssets, + bookmarkLinks, + bookmarkLists, + bookmarks, + bookmarksInLists, + bookmarkTags, + bookmarkTexts, + tagsOnBookmarks, + users, +} from "@hoarder/db/schema"; +import { BookmarkTypes } from "@hoarder/shared/types/bookmarks"; +import { Matcher } from "@hoarder/shared/types/search"; + +import { AuthedContext } from "../.."; +import { getBookmarkIdsFromMatcher } from "../search"; + +let mockCtx: AuthedContext; +let testUserId: string; + +beforeEach(async () => { + const db = getInMemoryDB(true); + testUserId = "test-user"; + + await db.insert(users).values([ + { + id: testUserId, + name: "Test User", + email: "test@example.com", + role: "user", + }, + ]); + + // Setup test data + await db.insert(bookmarks).values([ + { + id: "b1", + type: BookmarkTypes.LINK, + userId: testUserId, + archived: false, + favourited: false, + createdAt: new Date("2024-01-01"), + }, + { + id: "b2", + type: BookmarkTypes.LINK, + userId: testUserId, + archived: true, + favourited: true, + createdAt: new Date("2024-01-02"), + }, + { + id: "b3", + type: BookmarkTypes.TEXT, + userId: testUserId, + archived: true, + favourited: false, + createdAt: new Date("2024-01-03"), + }, + { + id: "b4", + type: BookmarkTypes.LINK, + userId: testUserId, + archived: false, + favourited: true, + createdAt: new Date("2024-01-04"), + }, + { + id: "b5", + type: BookmarkTypes.TEXT, + userId: testUserId, + archived: false, + favourited: false, + createdAt: new Date("2024-01-05"), + }, + { + id: "b6", + type: BookmarkTypes.ASSET, + userId: testUserId, + archived: true, + favourited: false, + createdAt: new Date("2024-01-06"), + }, + ]); + + await db.insert(bookmarkLinks).values([ + { id: "b1", url: "https://example.com/page1" }, + { id: "b2", url: "https://test.com/page2" }, + { id: "b4", url: "https://example.com/page3" }, + ]); + + await db.insert(bookmarkTexts).values([ + { + id: "b3", + text: "This is a test bookmark", + sourceUrl: "https://example.com/page1", + }, + { + id: "b5", + text: "Another text bookmark", + sourceUrl: null, + }, + ]); + + await db.insert(bookmarkAssets).values([ + { + id: "b6", + assetType: "image", + fileName: "test.png", + assetId: "asset-id", + }, + ]); + + await db.insert(bookmarkTags).values([ + { id: "t1", userId: testUserId, name: "tag1" }, + { id: "t2", userId: testUserId, name: "tag2" }, + { id: "t3", userId: testUserId, name: "important" }, + { id: "t4", userId: testUserId, name: "work" }, + ]); + + await db.insert(tagsOnBookmarks).values([ + { bookmarkId: "b1", tagId: "t1", attachedBy: "ai" }, + { bookmarkId: "b2", tagId: "t2", attachedBy: "ai" }, + { bookmarkId: "b4", tagId: "t3", attachedBy: "human" }, + { bookmarkId: "b5", tagId: "t4", attachedBy: "human" }, + { bookmarkId: "b6", tagId: "t3", attachedBy: "ai" }, + ]); + + await db.insert(bookmarkLists).values([ + { id: "l1", userId: testUserId, name: "list1", icon: "🚀" }, + { id: "l2", userId: testUserId, name: "list2", icon: "🚀" }, + { id: "l3", userId: testUserId, name: "favorites", icon: "⭐" }, + { id: "l4", userId: testUserId, name: "work", icon: "💼" }, + ]); + + await db.insert(bookmarksInLists).values([ + { bookmarkId: "b1", listId: "l1" }, + { bookmarkId: "b2", listId: "l2" }, + { bookmarkId: "b4", listId: "l3" }, + { bookmarkId: "b5", listId: "l4" }, + { bookmarkId: "b6", listId: "l1" }, + ]); + + mockCtx = { + db, + user: { + id: testUserId, + name: "Test User", + email: "test@example.com", + role: "user", + }, + req: { + ip: "127.0.0.1", + }, + }; +}); + +describe("getBookmarkIdsFromMatcher", () => { + it("should handle tagName matcher", async () => { + const matcher: Matcher = { type: "tagName", tagName: "tag1" }; + const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); + expect(result).toEqual(["b1"]); + }); + + it("should handle listName matcher", async () => { + const matcher: Matcher = { type: "listName", listName: "list1" }; + const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); + expect(result).toEqual(["b1", "b6"]); + }); + + it("should handle archived matcher", async () => { + const matcher: Matcher = { type: "archived", archived: true }; + const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); + expect(result).toEqual(["b2", "b3", "b6"]); + }); + + it("should handle favourited matcher", async () => { + const matcher: Matcher = { type: "favourited", favourited: true }; + const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); + expect(result).toEqual(["b2", "b4"]); + }); + + it("should handle url matcher", async () => { + const matcher: Matcher = { type: "url", url: "example.com" }; + const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); + expect(result).toEqual(["b1", "b4"]); + }); + + it("should handle dateAfter matcher", async () => { + const matcher: Matcher = { + type: "dateAfter", + dateAfter: new Date("2024-01-02"), + }; + const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); + expect(result).toEqual(["b2", "b3", "b4", "b5", "b6"]); + }); + + it("should handle dateBefore matcher", async () => { + const matcher: Matcher = { + type: "dateBefore", + dateBefore: new Date("2024-01-02"), + }; + const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); + expect(result).toEqual(["b1", "b2"]); + }); + + it("should handle AND matcher", async () => { + const matcher: Matcher = { + type: "and", + matchers: [ + { type: "archived", archived: true }, + { type: "favourited", favourited: true }, + ], + }; + const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); + expect(result).toEqual(["b2"]); + }); + + it("should handle OR matcher #1", async () => { + const matcher: Matcher = { + type: "or", + matchers: [ + { type: "archived", archived: true }, + { type: "favourited", favourited: true }, + ], + }; + const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); + expect(result.sort()).toEqual(["b2", "b3", "b4", "b6"]); + }); + + it("should handle OR matcher #2", async () => { + const matcher: Matcher = { + type: "or", + matchers: [ + { type: "listName", listName: "favorites" }, + { type: "tagName", tagName: "work" }, + ], + }; + const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); + expect(result).toEqual(["b4", "b5"]); + }); + + it("should handle nested complex matchers", async () => { + const matcher: Matcher = { + type: "and", + matchers: [ + { + type: "or", + matchers: [ + { type: "listName", listName: "favorites" }, + { type: "tagName", tagName: "work" }, + ], + }, + { + type: "or", + matchers: [ + { type: "archived", archived: true }, + { type: "favourited", favourited: true }, + ], + }, + ], + }; + const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); + expect(result).toEqual(["b4"]); + }); + + it("should throw error for unknown matcher type", async () => { + const matcher = { type: "unknown" } as unknown as Matcher; + await expect(getBookmarkIdsFromMatcher(mockCtx, matcher)).rejects.toThrow( + "Unknown matcher type", + ); + }); +}); diff --git a/packages/trpc/vitest.config.ts b/packages/trpc/vitest.config.ts index 41fd70c4d..5af4ad164 100644 --- a/packages/trpc/vitest.config.ts +++ b/packages/trpc/vitest.config.ts @@ -10,5 +10,9 @@ export default defineConfig({ alias: { "@/*": "./*", }, + deps: { + // TODO: this need to be fixed + inline: ["liteque"], + }, }, }); From 17af22bb6df42e1f42809261db3eda45fb2ffe3f Mon Sep 17 00:00:00 2001 From: "Mohamed Bassem (aider)" Date: Tue, 31 Dec 2024 12:27:09 +0000 Subject: [PATCH 14/40] chore: add format:fix and lint:fix scripts to all packages --- apps/browser-extension/package.json | 2 ++ apps/cli/package.json | 2 ++ apps/landing/package.json | 4 +++- apps/mobile/package.json | 2 ++ apps/web/package.json | 4 +++- apps/workers/package.json | 2 ++ packages/e2e_tests/package.json | 2 ++ packages/sdk/package.json | 2 ++ packages/shared/package.json | 2 ++ packages/trpc/package.json | 2 ++ 10 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/browser-extension/package.json b/apps/browser-extension/package.json index b6ac25499..c696f95c8 100644 --- a/apps/browser-extension/package.json +++ b/apps/browser-extension/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc && vite build", "format": "prettier .", + "format:fix": "prettier . --write", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "lint:fix": "eslint . --ext ts,tsx --fix", "preview": "vite preview", "typecheck": "tsc --noEmit" }, diff --git a/apps/cli/package.json b/apps/cli/package.json index 383b8f40b..2a4fb987a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -33,7 +33,9 @@ "build": "vite build", "run": "tsx src/index.ts", "lint": "eslint .", + "lint:fix": "eslint . --fix", "format": "prettier . --ignore-path ../../.prettierignore", + "format:fix": "prettier . --write --ignore-path ../../.prettierignore", "typecheck": "tsc --noEmit" }, "repository": { diff --git a/apps/landing/package.json b/apps/landing/package.json index ca5c7a986..c010f9660 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -11,7 +11,9 @@ "start": "next start", "lint": "next lint", "typecheck": "tsc --noEmit", - "format": "prettier --check . --ignore-path ../../.gitignore" + "format": "prettier --check . --ignore-path ../../.gitignore", + "format:fix": "prettier --write . --ignore-path ../../.gitignore", + "lint:fix": "next lint --fix" }, "dependencies": { "@radix-ui/react-slot": "^1.0.2", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 6f21820cd..2634bdd72 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -9,7 +9,9 @@ "ios": "expo run:ios", "web": "expo start --web", "format": "prettier .", + "format:fix": "prettier . --write", "lint": "eslint .", + "lint:fix": "eslint . --fix", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/apps/web/package.json b/apps/web/package.json index 96058ece2..ec0a3bd20 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,7 +12,9 @@ "lint": "next lint", "test": "vitest", "typecheck": "tsc --noEmit", - "format": "prettier --check . --ignore-path ../../.gitignore" + "format": "prettier --check . --ignore-path ../../.gitignore", + "format:fix": "prettier --write . --ignore-path ../../.gitignore", + "lint:fix": "next lint --fix" }, "dependencies": { "@auth/drizzle-adapter": "^1.4.2", diff --git a/apps/workers/package.json b/apps/workers/package.json index 61d429a6b..1e5220241 100644 --- a/apps/workers/package.json +++ b/apps/workers/package.json @@ -53,7 +53,9 @@ "start": "tsx watch index.ts", "start:prod": "tsx index.ts", "lint": "eslint .", + "lint:fix": "eslint . --fix", "format": "prettier . --ignore-path ../../.prettierignore", + "format:fix": "prettier . --ignore-path ../../.prettierignore --write", "typecheck": "tsc --noEmit" }, "eslintConfig": { diff --git a/packages/e2e_tests/package.json b/packages/e2e_tests/package.json index 0918edf0a..cce151f56 100644 --- a/packages/e2e_tests/package.json +++ b/packages/e2e_tests/package.json @@ -7,7 +7,9 @@ "scripts": { "typecheck": "tsc --noEmit", "format": "prettier . --ignore-path ../../.prettierignore", + "format:fix": "prettier . --write --ignore-path ../../.prettierignore", "lint": "eslint .", + "lint:fix": "eslint . --fix", "test": "vitest run", "test:watch": "vitest" }, diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 326f55b3d..84ea3d5f6 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -23,7 +23,9 @@ "build": "vite build", "run": "tsx src/index.ts", "lint": "eslint .", + "lint:fix": "eslint . --fix", "format": "prettier . --ignore-path ../../.prettierignore", + "format:fix": "prettier . --write --ignore-path ../../.prettierignore", "typecheck": "tsc --noEmit" }, "repository": { diff --git a/packages/shared/package.json b/packages/shared/package.json index d412301a9..93d5495a5 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -23,7 +23,9 @@ "scripts": { "typecheck": "tsc --noEmit", "format": "prettier . --ignore-path ../../.prettierignore", + "format:fix": "prettier . --write --ignore-path ../../.prettierignore", "lint": "eslint .", + "lint:fix": "eslint . --fix", "test": "vitest" }, "main": "index.ts", diff --git a/packages/trpc/package.json b/packages/trpc/package.json index 87b7d8926..0e1721f18 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -7,7 +7,9 @@ "scripts": { "typecheck": "tsc --noEmit", "format": "prettier . --ignore-path ../../.prettierignore", + "format:fix": "prettier . --write --ignore-path ../../.prettierignore", "lint": "eslint .", + "lint:fix": "eslint . --fix", "test": "vitest" }, "dependencies": { From 4deda9d477141e864f472ba95003e3974346f10d Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Tue, 31 Dec 2024 13:58:23 +0000 Subject: [PATCH 15/40] feat: Add support for negative search terms --- .../search/QueryExplainerTooltip.tsx | 12 ++- packages/shared/searchQueryParser.test.ts | 92 ++++++++++++++++++- packages/shared/searchQueryParser.ts | 79 ++++++++-------- packages/shared/types/search.ts | 5 + packages/trpc/lib/__tests__/search.test.ts | 91 ++++++++++++++++-- packages/trpc/lib/search.ts | 72 ++++++++++++--- 6 files changed, 282 insertions(+), 69 deletions(-) diff --git a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx index 191c9ff36..0a325031e 100644 --- a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx +++ b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx @@ -20,28 +20,32 @@ export default function QueryExplainerTooltip({ case "tagName": return ( - Tag Name + + {matcher.inverse ? "Doesn't have" : "Has"} Tag + {matcher.tagName} ); case "listName": return ( - List Name + + {matcher.inverse ? "Is not in" : "Is in "} List + {matcher.listName} ); case "dateAfter": return ( - Created After + {matcher.inverse ? "Not" : ""} Created After {matcher.dateAfter.toDateString()} ); case "dateBefore": return ( - Created Before + {matcher.inverse ? "Not" : ""} Created Before {matcher.dateBefore.toDateString()} ); diff --git a/packages/shared/searchQueryParser.test.ts b/packages/shared/searchQueryParser.test.ts index 428d59299..5bbb3f772 100644 --- a/packages/shared/searchQueryParser.test.ts +++ b/packages/shared/searchQueryParser.test.ts @@ -12,7 +12,7 @@ describe("Search Query Parser", () => { archived: true, }, }); - expect(parseSearchQuery("is:not_archived")).toEqual({ + expect(parseSearchQuery("-is:archived")).toEqual({ result: "full", text: "", matcher: { @@ -28,7 +28,7 @@ describe("Search Query Parser", () => { favourited: true, }, }); - expect(parseSearchQuery("is:not_fav")).toEqual({ + expect(parseSearchQuery("-is:fav")).toEqual({ result: "full", text: "", matcher: { @@ -45,6 +45,16 @@ describe("Search Query Parser", () => { matcher: { type: "url", url: "https://example.com", + inverse: false, + }, + }); + expect(parseSearchQuery("-url:https://example.com")).toEqual({ + result: "full", + text: "", + matcher: { + type: "url", + url: "https://example.com", + inverse: true, }, }); expect(parseSearchQuery('url:"https://example.com"')).toEqual({ @@ -53,6 +63,16 @@ describe("Search Query Parser", () => { matcher: { type: "url", url: "https://example.com", + inverse: false, + }, + }); + expect(parseSearchQuery('-url:"https://example.com"')).toEqual({ + result: "full", + text: "", + matcher: { + type: "url", + url: "https://example.com", + inverse: true, }, }); expect(parseSearchQuery("#my-tag")).toEqual({ @@ -61,6 +81,16 @@ describe("Search Query Parser", () => { matcher: { type: "tagName", tagName: "my-tag", + inverse: false, + }, + }); + expect(parseSearchQuery("-#my-tag")).toEqual({ + result: "full", + text: "", + matcher: { + type: "tagName", + tagName: "my-tag", + inverse: true, }, }); expect(parseSearchQuery('#"my tag"')).toEqual({ @@ -69,6 +99,16 @@ describe("Search Query Parser", () => { matcher: { type: "tagName", tagName: "my tag", + inverse: false, + }, + }); + expect(parseSearchQuery('-#"my tag"')).toEqual({ + result: "full", + text: "", + matcher: { + type: "tagName", + tagName: "my tag", + inverse: true, }, }); expect(parseSearchQuery("list:my-list")).toEqual({ @@ -77,6 +117,16 @@ describe("Search Query Parser", () => { matcher: { type: "listName", listName: "my-list", + inverse: false, + }, + }); + expect(parseSearchQuery("-list:my-list")).toEqual({ + result: "full", + text: "", + matcher: { + type: "listName", + listName: "my-list", + inverse: true, }, }); expect(parseSearchQuery('list:"my list"')).toEqual({ @@ -85,6 +135,16 @@ describe("Search Query Parser", () => { matcher: { type: "listName", listName: "my list", + inverse: false, + }, + }); + expect(parseSearchQuery('-list:"my list"')).toEqual({ + result: "full", + text: "", + matcher: { + type: "listName", + listName: "my list", + inverse: true, }, }); }); @@ -95,6 +155,16 @@ describe("Search Query Parser", () => { matcher: { type: "dateAfter", dateAfter: new Date("2023-10-12"), + inverse: false, + }, + }); + expect(parseSearchQuery("-after:2023-10-12")).toEqual({ + result: "full", + text: "", + matcher: { + type: "dateAfter", + dateAfter: new Date("2023-10-12"), + inverse: true, }, }); expect(parseSearchQuery("before:2023-10-12")).toEqual({ @@ -103,12 +173,22 @@ describe("Search Query Parser", () => { matcher: { type: "dateBefore", dateBefore: new Date("2023-10-12"), + inverse: false, + }, + }); + expect(parseSearchQuery("-before:2023-10-12")).toEqual({ + result: "full", + text: "", + matcher: { + type: "dateBefore", + dateBefore: new Date("2023-10-12"), + inverse: true, }, }); }); test("complex queries", () => { - expect(parseSearchQuery("is:fav is:archived")).toEqual({ + expect(parseSearchQuery("is:fav -is:archived")).toEqual({ result: "full", text: "", matcher: { @@ -120,7 +200,7 @@ describe("Search Query Parser", () => { }, { type: "archived", - archived: true, + archived: false, }, ], }, @@ -143,6 +223,7 @@ describe("Search Query Parser", () => { { type: "tagName", tagName: "my-tag", + inverse: false, }, ], }, @@ -170,6 +251,7 @@ describe("Search Query Parser", () => { { type: "tagName", tagName: "my-tag", + inverse: false, }, ], }, @@ -197,6 +279,7 @@ describe("Search Query Parser", () => { { type: "tagName", tagName: "my-tag", + inverse: false, }, ], }, @@ -237,6 +320,7 @@ describe("Search Query Parser", () => { { type: "tagName", tagName: "my-tag", + inverse: false, }, ], }, diff --git a/packages/shared/searchQueryParser.ts b/packages/shared/searchQueryParser.ts index faf74d089..02129c14f 100644 --- a/packages/shared/searchQueryParser.ts +++ b/packages/shared/searchQueryParser.ts @@ -5,6 +5,7 @@ import { kmid, kright, lrec_sc, + opt, rule, seq, str, @@ -28,6 +29,7 @@ enum TokenType { RParen = "RPAREN", Space = "SPACE", Hash = "HASH", + Minus = "MINUS", } // Rules are in order of priority @@ -43,6 +45,7 @@ const lexerRules: [RegExp, TokenType][] = [ [/^\(/, TokenType.LParen], [/^\)/, TokenType.RParen], [/^\s+/, TokenType.Space], + [/^-/, TokenType.Minus], // This needs to be last as it matches a lot of stuff [/^[^ )(]+/, TokenType.Ident], @@ -109,38 +112,32 @@ const EXP = rule(); 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(opt(str("-")), kright(str("is:"), tok(TokenType.Ident))), + ([minus, ident]) => { + switch (ident.text) { + case "fav": + return { + text: "", + matcher: { type: "favourited", favourited: !minus }, + }; + case "archived": + return { + text: "", + matcher: { type: "archived", archived: !minus }, + }; + default: + // If the token is not known, emit it as pure text + return { + text: `${minus?.text ?? ""}is:${ident.text}`, + matcher: undefined, + }; + } + }, + ), apply( seq( + opt(str("-")), alt(tok(TokenType.Qualifier), tok(TokenType.Hash)), alt( apply(tok(TokenType.Ident), (tok) => { @@ -151,22 +148,22 @@ MATCHER.setPattern( }), ), ), - (toks) => { - switch (toks[0].text) { + ([minus, qualifier, ident]) => { + switch (qualifier.text) { case "url:": return { text: "", - matcher: { type: "url", url: toks[1] }, + matcher: { type: "url", url: ident, inverse: !!minus }, }; case "#": return { text: "", - matcher: { type: "tagName", tagName: toks[1] }, + matcher: { type: "tagName", tagName: ident, inverse: !!minus }, }; case "list:": return { text: "", - matcher: { type: "listName", listName: toks[1] }, + matcher: { type: "listName", listName: ident, inverse: !!minus }, }; case "after:": try { @@ -174,13 +171,14 @@ MATCHER.setPattern( text: "", matcher: { type: "dateAfter", - dateAfter: z.coerce.date().parse(toks[1]), + dateAfter: z.coerce.date().parse(ident), + inverse: !!minus, }, }; } catch (e) { return { // If parsing the date fails, emit it as pure text - text: toks[0].text + toks[1], + text: (minus?.text ?? "") + qualifier.text + ident, matcher: undefined, }; } @@ -190,20 +188,21 @@ MATCHER.setPattern( text: "", matcher: { type: "dateBefore", - dateBefore: z.coerce.date().parse(toks[1]), + dateBefore: z.coerce.date().parse(ident), + inverse: !!minus, }, }; } catch (e) { return { // If parsing the date fails, emit it as pure text - text: toks[0].text + toks[1], + text: (minus?.text ?? "") + qualifier.text + ident, matcher: undefined, }; } default: // If the token is not known, emit it as pure text return { - text: toks[0].text + toks[1], + text: (minus?.text ?? "") + qualifier.text + ident, matcher: undefined, }; } diff --git a/packages/shared/types/search.ts b/packages/shared/types/search.ts index d430dad5d..4d947a05a 100644 --- a/packages/shared/types/search.ts +++ b/packages/shared/types/search.ts @@ -3,11 +3,13 @@ import { z } from "zod"; const zTagNameMatcher = z.object({ type: z.literal("tagName"), tagName: z.string(), + inverse: z.boolean(), }); const zListNameMatcher = z.object({ type: z.literal("listName"), listName: z.string(), + inverse: z.boolean(), }); const zArchivedMatcher = z.object({ @@ -18,6 +20,7 @@ const zArchivedMatcher = z.object({ const urlMatcher = z.object({ type: z.literal("url"), url: z.string(), + inverse: z.boolean(), }); const zFavouritedMatcher = z.object({ @@ -28,11 +31,13 @@ const zFavouritedMatcher = z.object({ const zDateAfterMatcher = z.object({ type: z.literal("dateAfter"), dateAfter: z.date(), + inverse: z.boolean(), }); const zDateBeforeMatcher = z.object({ type: z.literal("dateBefore"), dateBefore: z.date(), + inverse: z.boolean(), }); const zNonRecursiveMatcher = z.union([ diff --git a/packages/trpc/lib/__tests__/search.test.ts b/packages/trpc/lib/__tests__/search.test.ts index aa57527b1..31f87dfdf 100644 --- a/packages/trpc/lib/__tests__/search.test.ts +++ b/packages/trpc/lib/__tests__/search.test.ts @@ -160,53 +160,130 @@ beforeEach(async () => { describe("getBookmarkIdsFromMatcher", () => { it("should handle tagName matcher", async () => { - const matcher: Matcher = { type: "tagName", tagName: "tag1" }; + const matcher: Matcher = { + type: "tagName", + tagName: "tag1", + inverse: false, + }; const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); expect(result).toEqual(["b1"]); }); + it("should handle tagName matcher with inverse=true", async () => { + const matcher: Matcher = { + type: "tagName", + tagName: "tag1", + inverse: true, + }; + const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); + expect(result.sort()).toEqual(["b2", "b3", "b4", "b5", "b6"]); + }); + it("should handle listName matcher", async () => { - const matcher: Matcher = { type: "listName", listName: "list1" }; + const matcher: Matcher = { + type: "listName", + listName: "list1", + inverse: false, + }; const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); expect(result).toEqual(["b1", "b6"]); }); + it("should handle listName matcher with inverse=true", async () => { + const matcher: Matcher = { + type: "listName", + listName: "list1", + inverse: true, + }; + const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); + expect(result.sort()).toEqual(["b2", "b3", "b4", "b5"]); + }); + it("should handle archived matcher", async () => { const matcher: Matcher = { type: "archived", archived: true }; const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); expect(result).toEqual(["b2", "b3", "b6"]); }); + it("should handle archived matcher archived=false", async () => { + const matcher: Matcher = { type: "archived", archived: false }; + const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); + expect(result).toEqual(["b1", "b4", "b5"]); + }); + it("should handle favourited matcher", async () => { const matcher: Matcher = { type: "favourited", favourited: true }; const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); expect(result).toEqual(["b2", "b4"]); }); + it("should handle favourited matcher favourited=false", async () => { + const matcher: Matcher = { type: "favourited", favourited: false }; + const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); + expect(result).toEqual(["b1", "b3", "b5", "b6"]); + }); + it("should handle url matcher", async () => { - const matcher: Matcher = { type: "url", url: "example.com" }; + const matcher: Matcher = { + type: "url", + url: "example.com", + inverse: false, + }; const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); expect(result).toEqual(["b1", "b4"]); }); + it("should handle url matcher with inverse=true", async () => { + const matcher: Matcher = { + type: "url", + url: "example.com", + inverse: true, + }; + const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); + // Not that only bookmarks of type link are returned + expect(result.sort()).toEqual(["b2"]); + }); + it("should handle dateAfter matcher", async () => { const matcher: Matcher = { type: "dateAfter", dateAfter: new Date("2024-01-02"), + inverse: false, }; const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); expect(result).toEqual(["b2", "b3", "b4", "b5", "b6"]); }); + it("should handle dateAfter matcher with inverse=true", async () => { + const matcher: Matcher = { + type: "dateAfter", + dateAfter: new Date("2024-01-02"), + inverse: true, + }; + const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); + expect(result).toEqual(["b1"]); + }); + it("should handle dateBefore matcher", async () => { const matcher: Matcher = { type: "dateBefore", dateBefore: new Date("2024-01-02"), + inverse: false, }; const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); expect(result).toEqual(["b1", "b2"]); }); + it("should handle dateBefore matcher with inverse=true", async () => { + const matcher: Matcher = { + type: "dateBefore", + dateBefore: new Date("2024-01-02"), + inverse: true, + }; + const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); + expect(result.sort()).toEqual(["b3", "b4", "b5", "b6"]); + }); + it("should handle AND matcher", async () => { const matcher: Matcher = { type: "and", @@ -235,8 +312,8 @@ describe("getBookmarkIdsFromMatcher", () => { const matcher: Matcher = { type: "or", matchers: [ - { type: "listName", listName: "favorites" }, - { type: "tagName", tagName: "work" }, + { type: "listName", listName: "favorites", inverse: false }, + { type: "tagName", tagName: "work", inverse: false }, ], }; const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); @@ -250,8 +327,8 @@ describe("getBookmarkIdsFromMatcher", () => { { type: "or", matchers: [ - { type: "listName", listName: "favorites" }, - { type: "tagName", tagName: "work" }, + { type: "listName", listName: "favorites", inverse: false }, + { type: "tagName", tagName: "work", inverse: false }, ], }, { diff --git a/packages/trpc/lib/search.ts b/packages/trpc/lib/search.ts index 0ee9c76e5..fcc5abda6 100644 --- a/packages/trpc/lib/search.ts +++ b/packages/trpc/lib/search.ts @@ -1,4 +1,15 @@ -import { and, eq, gte, like, lte, sql } from "drizzle-orm"; +import { + and, + eq, + exists, + gt, + gte, + like, + lt, + lte, + notExists, + notLike, +} from "drizzle-orm"; import { bookmarkLinks, @@ -76,26 +87,56 @@ async function getIds( ): Promise { switch (matcher.type) { case "tagName": { + const comp = matcher.inverse ? notExists : exists; return db - .select({ id: sql`${tagsOnBookmarks.bookmarkId}`.as("id") }) - .from(tagsOnBookmarks) - .innerJoin(bookmarkTags, eq(tagsOnBookmarks.tagId, bookmarkTags.id)) + .selectDistinct({ id: bookmarks.id }) + .from(bookmarks) .where( and( - eq(bookmarkTags.userId, userId), - eq(bookmarkTags.name, matcher.tagName), + eq(bookmarks.userId, userId), + comp( + db + .select() + .from(tagsOnBookmarks) + .innerJoin( + bookmarkTags, + eq(tagsOnBookmarks.tagId, bookmarkTags.id), + ) + .where( + and( + eq(tagsOnBookmarks.bookmarkId, bookmarks.id), + eq(bookmarkTags.userId, userId), + eq(bookmarkTags.name, matcher.tagName), + ), + ), + ), ), ); } case "listName": { + const comp = matcher.inverse ? notExists : exists; return db - .select({ id: sql`${bookmarksInLists.bookmarkId}`.as("id") }) - .from(bookmarksInLists) - .innerJoin(bookmarkLists, eq(bookmarksInLists.listId, bookmarkLists.id)) + .selectDistinct({ id: bookmarks.id }) + .from(bookmarks) .where( and( - eq(bookmarkLists.userId, userId), - eq(bookmarkLists.name, matcher.listName), + eq(bookmarks.userId, userId), + comp( + db + .select() + .from(bookmarksInLists) + .innerJoin( + bookmarkLists, + eq(bookmarksInLists.listId, bookmarkLists.id), + ) + .where( + and( + eq(bookmarksInLists.bookmarkId, bookmarks.id), + eq(bookmarkLists.userId, userId), + eq(bookmarkLists.name, matcher.listName), + ), + ), + ), ), ); } @@ -111,6 +152,7 @@ async function getIds( ); } case "url": { + const comp = matcher.inverse ? notLike : like; return db .select({ id: bookmarkLinks.id }) .from(bookmarkLinks) @@ -118,7 +160,7 @@ async function getIds( .where( and( eq(bookmarks.userId, userId), - like(bookmarkLinks.url, `%${matcher.url}%`), + comp(bookmarkLinks.url, `%${matcher.url}%`), ), ); } @@ -134,24 +176,26 @@ async function getIds( ); } case "dateAfter": { + const comp = matcher.inverse ? lt : gte; return db .select({ id: bookmarks.id }) .from(bookmarks) .where( and( eq(bookmarks.userId, userId), - gte(bookmarks.createdAt, matcher.dateAfter), + comp(bookmarks.createdAt, matcher.dateAfter), ), ); } case "dateBefore": { + const comp = matcher.inverse ? gt : lte; return db .select({ id: bookmarks.id }) .from(bookmarks) .where( and( eq(bookmarks.userId, userId), - lte(bookmarks.createdAt, matcher.dateBefore), + comp(bookmarks.createdAt, matcher.dateBefore), ), ); } From 96cc11ef321b4580430c05f9344c6eb7dbddcf23 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Tue, 31 Dec 2024 14:15:02 +0000 Subject: [PATCH 16/40] feat: Add support for searching for tagged and listed items --- .../search/QueryExplainerTooltip.tsx | 14 ++++++++ packages/shared/searchQueryParser.test.ts | 32 +++++++++++++++++ packages/shared/searchQueryParser.ts | 10 ++++++ packages/shared/types/search.ts | 14 ++++++++ packages/trpc/lib/__tests__/search.test.ts | 24 +++++++++++++ packages/trpc/lib/search.ts | 34 +++++++++++++++++++ 6 files changed, 128 insertions(+) diff --git a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx index 0a325031e..eb7282d0c 100644 --- a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx +++ b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx @@ -63,6 +63,20 @@ export default function QueryExplainerTooltip({ {matcher.archived.toString()} ); + case "tagged": + return ( + + Has Tags + {matcher.tagged.toString()} + + ); + case "inlist": + return ( + + In Any List + {matcher.inList.toString()} + + ); case "and": case "or": return ( diff --git a/packages/shared/searchQueryParser.test.ts b/packages/shared/searchQueryParser.test.ts index 5bbb3f772..5af7ca2f8 100644 --- a/packages/shared/searchQueryParser.test.ts +++ b/packages/shared/searchQueryParser.test.ts @@ -36,6 +36,38 @@ describe("Search Query Parser", () => { favourited: false, }, }); + expect(parseSearchQuery("is:tagged")).toEqual({ + result: "full", + text: "", + matcher: { + type: "tagged", + tagged: true, + }, + }); + expect(parseSearchQuery("-is:tagged")).toEqual({ + result: "full", + text: "", + matcher: { + type: "tagged", + tagged: false, + }, + }); + expect(parseSearchQuery("is:inlist")).toEqual({ + result: "full", + text: "", + matcher: { + type: "inlist", + inList: true, + }, + }); + expect(parseSearchQuery("-is:inlist")).toEqual({ + result: "full", + text: "", + matcher: { + type: "inlist", + inList: false, + }, + }); }); test("simple string queries", () => { diff --git a/packages/shared/searchQueryParser.ts b/packages/shared/searchQueryParser.ts index 02129c14f..e52af2742 100644 --- a/packages/shared/searchQueryParser.ts +++ b/packages/shared/searchQueryParser.ts @@ -126,6 +126,16 @@ MATCHER.setPattern( text: "", matcher: { type: "archived", archived: !minus }, }; + case "tagged": + return { + text: "", + matcher: { type: "tagged", tagged: !minus }, + }; + case "inlist": + return { + text: "", + matcher: { type: "inlist", inList: !minus }, + }; 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 4d947a05a..9d97fdd84 100644 --- a/packages/shared/types/search.ts +++ b/packages/shared/types/search.ts @@ -40,6 +40,16 @@ const zDateBeforeMatcher = z.object({ inverse: z.boolean(), }); +const zIsTaggedMatcher = z.object({ + type: z.literal("tagged"), + tagged: z.boolean(), +}); + +const zIsInListMatcher = z.object({ + type: z.literal("inlist"), + inList: z.boolean(), +}); + const zNonRecursiveMatcher = z.union([ zTagNameMatcher, zListNameMatcher, @@ -48,6 +58,8 @@ const zNonRecursiveMatcher = z.union([ zFavouritedMatcher, zDateAfterMatcher, zDateBeforeMatcher, + zIsTaggedMatcher, + zIsInListMatcher, ]); type NonRecursiveMatcher = z.infer; @@ -65,6 +77,8 @@ export const zMatcherSchema: z.ZodType = z.lazy(() => { zFavouritedMatcher, zDateAfterMatcher, zDateBeforeMatcher, + zIsTaggedMatcher, + zIsInListMatcher, z.object({ type: z.literal("and"), matchers: z.array(zMatcherSchema), diff --git a/packages/trpc/lib/__tests__/search.test.ts b/packages/trpc/lib/__tests__/search.test.ts index 31f87dfdf..bf32bcb14 100644 --- a/packages/trpc/lib/__tests__/search.test.ts +++ b/packages/trpc/lib/__tests__/search.test.ts @@ -344,6 +344,30 @@ describe("getBookmarkIdsFromMatcher", () => { expect(result).toEqual(["b4"]); }); + it("should handle tagged matcher", async () => { + const matcher: Matcher = { type: "tagged", tagged: true }; + const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); + expect(result.sort()).toEqual(["b1", "b2", "b4", "b5", "b6"]); + }); + + it("should handle tagged matcher with tagged=false", async () => { + const matcher: Matcher = { type: "tagged", tagged: false }; + const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); + expect(result).toEqual(["b3"]); + }); + + it("should handle inlist matcher", async () => { + const matcher: Matcher = { type: "inlist", inList: true }; + const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); + expect(result.sort()).toEqual(["b1", "b2", "b4", "b5", "b6"]); + }); + + it("should handle inlist matcher with inList=false", async () => { + const matcher: Matcher = { type: "inlist", inList: false }; + const result = await getBookmarkIdsFromMatcher(mockCtx, matcher); + expect(result).toEqual(["b3"]); + }); + it("should throw error for unknown matcher type", async () => { const matcher = { type: "unknown" } as unknown as Matcher; await expect(getBookmarkIdsFromMatcher(mockCtx, matcher)).rejects.toThrow( diff --git a/packages/trpc/lib/search.ts b/packages/trpc/lib/search.ts index fcc5abda6..e7e6b5f73 100644 --- a/packages/trpc/lib/search.ts +++ b/packages/trpc/lib/search.ts @@ -113,6 +113,23 @@ async function getIds( ), ); } + case "tagged": { + const comp = matcher.tagged ? exists : notExists; + return db + .select({ id: bookmarks.id }) + .from(bookmarks) + .where( + and( + eq(bookmarks.userId, userId), + comp( + db + .select() + .from(tagsOnBookmarks) + .where(and(eq(tagsOnBookmarks.bookmarkId, bookmarks.id))), + ), + ), + ); + } case "listName": { const comp = matcher.inverse ? notExists : exists; return db @@ -140,6 +157,23 @@ async function getIds( ), ); } + case "inlist": { + const comp = matcher.inList ? exists : notExists; + return db + .select({ id: bookmarks.id }) + .from(bookmarks) + .where( + and( + eq(bookmarks.userId, userId), + comp( + db + .select() + .from(bookmarksInLists) + .where(and(eq(bookmarksInLists.bookmarkId, bookmarks.id))), + ), + ), + ); + } case "archived": { return db .select({ id: bookmarks.id }) From c31b7c6437874b26a45be73da9d5214ce6130d20 Mon Sep 17 00:00:00 2001 From: Deepak Kapoor <41769111+orthdron@users.noreply.github.com> Date: Wed, 1 Jan 2025 21:01:54 +0530 Subject: [PATCH 17/40] docs: Add firefox import-export instructions (#799) * Added firefox in instructions * Update 10-import.md * Update 10-import.md --- docs/docs/10-import.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/docs/10-import.md b/docs/docs/10-import.md index 71cc5ae8a..5de36e5ad 100644 --- a/docs/docs/10-import.md +++ b/docs/docs/10-import.md @@ -14,6 +14,12 @@ All the URLs in the bookmarks file will be added automatically, you will not be - This will download an html file with all of your bookmarks. - To import the bookmark file, go to the settings and click "Import Bookmarks from HTML file". +## Import from Firefox +- Open Firefox and click on the menu button (☰) in the top right corner. +- Navigate to Bookmarks > Manage bookmarks (or press Ctrl + Shift + O / Cmd + Shift + O to open the Bookmarks Library). +- In the Bookmarks Library, click the Import and Backup button at the top. Select Export Bookmarks to HTML... to save your bookmarks as an HTML file. +- To import a bookmark file, go back to the Import and Backup menu, then select Import Bookmarks from HTML... and choose your saved HTML file. + ## Import from Pocket - Go to the [Pocket export page](https://getpocket.com/export) and follow the instructions to export your bookmarks. From b09c5f266df494cca64996d317568122d37dcdb0 Mon Sep 17 00:00:00 2001 From: "Mohamed Bassem (aider)" Date: Wed, 1 Jan 2025 14:55:54 +0000 Subject: [PATCH 18/40] docs: Add search query language documentation --- .../14-Guides/02-search-query-language.md | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 docs/docs/14-Guides/02-search-query-language.md diff --git a/docs/docs/14-Guides/02-search-query-language.md b/docs/docs/14-Guides/02-search-query-language.md new file mode 100644 index 000000000..fddd896c1 --- /dev/null +++ b/docs/docs/14-Guides/02-search-query-language.md @@ -0,0 +1,68 @@ +# Search Query Language + +Hoarder provides a search query language to filter and find bookmarks. Here are all the supported qualifiers and how to use them: + +## Basic Syntax + +- Use spaces to separate multiple conditions (implicit AND) +- Use `and`/`or` keywords for explicit boolean logic +- Use parentheses `()` for grouping conditions +- Prefix qualifiers with `-` to negate them + +## Qualifiers + +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` | +| `url:` | Match bookmarks with URL substring | `url:example.com` | +| `#` | Match bookmarks with specific tag | `#important` | +| | Supports quoted strings for tags with spaces | `#"work in progress"` | +| `list:` | Match bookmarks in specific list | `list:reading` | +| | Supports quoted strings for list names with spaces | `list:"to review"` | +| `after:` | Bookmarks created on or after date (YYYY-MM-DD) | `after:2023-01-01` | +| `before:` | Bookmarks created on orbefore date (YYYY-MM-DD) | `before:2023-12-31` | + +### Examples + +```plaintext +# Find favorited bookmarks from 2023 that are tagged "important" +is:fav after:2023-01-01 before:2023-12-31 #important + +# Find archived bookmarks that are either in "reading" list or tagged "work" +is:archived and (list:reading or #work) + +# Find bookmarks that are not tagged or not in any list +-is:tagged or -is:inlist +``` + +## Combining Conditions + +You can combine multiple conditions using boolean logic: + +```plaintext +# Find favorited bookmarks from 2023 that are tagged "important" +is:fav after:2023-01-01 before:2023-12-31 #important + +# Find archived bookmarks that are either in "reading" list or tagged "work" +is:archived and (list:reading or #work) + +# Find bookmarks that are not favorited and not archived +-is:fav -is:archived +``` + +## Text Search + +Any text not part of a qualifier will be treated as a full-text search: + +```plaintext +# Search for "machine learning" in bookmark content +machine learning + +# Combine text search with qualifiers +machine learning is:fav +``` From 5df0258b2cd884347eabfa866d7e7fbc7225cdb3 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Wed, 1 Jan 2025 15:52:48 +0000 Subject: [PATCH 19/40] refactor: Migrate from the deprecated drizzle-orm schema --- packages/db/package.json | 2 +- packages/db/schema.ts | 107 +++++++++++++++++---------------------- pnpm-lock.yaml | 63 ++++++++++++----------- 3 files changed, 79 insertions(+), 93 deletions(-) diff --git a/packages/db/package.json b/packages/db/package.json index e1229dc57..15b353d67 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -24,7 +24,7 @@ "@hoarder/tsconfig": "workspace:^0.1.0", "@tsconfig/node21": "^21.0.1", "@types/better-sqlite3": "^7.6.11", - "drizzle-kit": "^0.24.02" + "drizzle-kit": "^0.30.01" }, "eslintConfig": { "root": true, diff --git a/packages/db/schema.ts b/packages/db/schema.ts index a8fe9eebd..722d57cf9 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -49,11 +49,11 @@ export const accounts = sqliteTable( id_token: text("id_token"), session_state: text("session_state"), }, - (account) => ({ - compoundKey: primaryKey({ + (account) => [ + primaryKey({ columns: [account.provider, account.providerAccountId], }), - }), + ], ); export const sessions = sqliteTable("session", { @@ -74,9 +74,7 @@ export const verificationTokens = sqliteTable( token: text("token").notNull(), expires: integer("expires", { mode: "timestamp_ms" }).notNull(), }, - (vt) => ({ - compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }), - }), + (vt) => [primaryKey({ columns: [vt.identifier, vt.token] })], ); export const apiKeys = sqliteTable( @@ -94,9 +92,7 @@ export const apiKeys = sqliteTable( .notNull() .references(() => users.id, { onDelete: "cascade" }), }, - (ak) => ({ - unq: unique().on(ak.name, ak.userId), - }), + (ak) => [unique().on(ak.name, ak.userId)], ); export const bookmarks = sqliteTable( @@ -124,12 +120,12 @@ export const bookmarks = sqliteTable( enum: [BookmarkTypes.LINK, BookmarkTypes.TEXT, BookmarkTypes.ASSET], }).notNull(), }, - (b) => ({ - userIdIdx: index("bookmarks_userId_idx").on(b.userId), - archivedIdx: index("bookmarks_archived_idx").on(b.archived), - favIdx: index("bookmarks_favourited_idx").on(b.favourited), - createdAtIdx: index("bookmarks_createdAt_idx").on(b.createdAt), - }), + (b) => [ + index("bookmarks_userId_idx").on(b.userId), + index("bookmarks_archived_idx").on(b.archived), + index("bookmarks_favourited_idx").on(b.favourited), + index("bookmarks_createdAt_idx").on(b.createdAt), + ], ); export const bookmarkLinks = sqliteTable( @@ -155,11 +151,7 @@ export const bookmarkLinks = sqliteTable( }).default("pending"), crawlStatusCode: integer("crawlStatusCode").default(200), }, - (bl) => { - return { - urlIdx: index("bookmarkLinks_url_idx").on(bl.url), - }; - }, + (bl) => [index("bookmarkLinks_url_idx").on(bl.url)], ); export const enum AssetTypes { @@ -197,11 +189,11 @@ export const assets = sqliteTable( .references(() => users.id, { onDelete: "cascade" }), }, - (tb) => ({ - bookmarkIdIdx: index("assets_bookmarkId_idx").on(tb.bookmarkId), - assetTypeIdx: index("assets_assetType_idx").on(tb.assetType), - userIdIdx: index("assets_userId_idx").on(tb.userId), - }), + (tb) => [ + index("assets_bookmarkId_idx").on(tb.bookmarkId), + index("assets_assetType_idx").on(tb.assetType), + index("assets_userId_idx").on(tb.userId), + ], ); export const highlights = sqliteTable( @@ -223,16 +215,17 @@ export const highlights = sqliteTable( endOffset: integer("endOffset").notNull(), color: text("color", { enum: ["red", "green", "blue", "yellow"], - }).default("yellow").notNull(), + }) + .default("yellow") + .notNull(), text: text("text"), note: text("note"), createdAt: createdAtField(), }, - - (tb) => ({ - bookmarkIdIdx: index("highlights_bookmarkId_idx").on(tb.bookmarkId), - userIdIdx: index("highlights_userId_idx").on(tb.userId), - }), + (tb) => [ + index("highlights_bookmarkId_idx").on(tb.bookmarkId), + index("highlights_userId_idx").on(tb.userId), + ], ); export const bookmarkTexts = sqliteTable("bookmarkTexts", { @@ -272,11 +265,11 @@ export const bookmarkTags = sqliteTable( .notNull() .references(() => users.id, { onDelete: "cascade" }), }, - (bt) => ({ - uniq: unique().on(bt.userId, bt.name), - nameIdx: index("bookmarkTags_name_idx").on(bt.name), - userIdIdx: index("bookmarkTags_userId_idx").on(bt.userId), - }), + (bt) => [ + unique().on(bt.userId, bt.name), + index("bookmarkTags_name_idx").on(bt.name), + index("bookmarkTags_userId_idx").on(bt.userId), + ], ); export const tagsOnBookmarks = sqliteTable( @@ -294,11 +287,11 @@ export const tagsOnBookmarks = sqliteTable( ), attachedBy: text("attachedBy", { enum: ["ai", "human"] }).notNull(), }, - (tb) => ({ - pk: primaryKey({ columns: [tb.bookmarkId, tb.tagId] }), - tagIdIdx: index("tagsOnBookmarks_tagId_idx").on(tb.tagId), - bookmarkIdIdx: index("tagsOnBookmarks_bookmarkId_idx").on(tb.bookmarkId), - }), + (tb) => [ + primaryKey({ columns: [tb.bookmarkId, tb.tagId] }), + index("tagsOnBookmarks_tagId_idx").on(tb.tagId), + index("tagsOnBookmarks_bookmarkId_idx").on(tb.bookmarkId), + ], ); export const bookmarkLists = sqliteTable( @@ -319,9 +312,7 @@ export const bookmarkLists = sqliteTable( { onDelete: "set null" }, ), }, - (bl) => ({ - userIdIdx: index("bookmarkLists_userId_idx").on(bl.userId), - }), + (bl) => [index("bookmarkLists_userId_idx").on(bl.userId)], ); export const bookmarksInLists = sqliteTable( @@ -337,11 +328,11 @@ export const bookmarksInLists = sqliteTable( () => new Date(), ), }, - (tb) => ({ - pk: primaryKey({ columns: [tb.bookmarkId, tb.listId] }), - bookmarkIdIdx: index("bookmarksInLists_bookmarkId_idx").on(tb.bookmarkId), - listIdIdx: index("bookmarksInLists_listId_idx").on(tb.listId), - }), + (tb) => [ + primaryKey({ columns: [tb.bookmarkId, tb.listId] }), + index("bookmarksInLists_bookmarkId_idx").on(tb.bookmarkId), + index("bookmarksInLists_listId_idx").on(tb.listId), + ], ); export const customPrompts = sqliteTable( @@ -361,9 +352,7 @@ export const customPrompts = sqliteTable( .notNull() .references(() => users.id, { onDelete: "cascade" }), }, - (bl) => ({ - userIdIdx: index("customPrompts_userId_idx").on(bl.userId), - }), + (bl) => [index("customPrompts_userId_idx").on(bl.userId)], ); export const rssFeedsTable = sqliteTable( @@ -384,9 +373,7 @@ export const rssFeedsTable = sqliteTable( .notNull() .references(() => users.id, { onDelete: "cascade" }), }, - (bl) => ({ - userIdIdx: index("rssFeeds_userId_idx").on(bl.userId), - }), + (bl) => [index("rssFeeds_userId_idx").on(bl.userId)], ); export const rssFeedImportsTable = sqliteTable( @@ -405,11 +392,11 @@ export const rssFeedImportsTable = sqliteTable( onDelete: "set null", }), }, - (bl) => ({ - feedIdIdx: index("rssFeedImports_feedIdIdx_idx").on(bl.rssFeedId), - entryIdIdx: index("rssFeedImports_entryIdIdx_idx").on(bl.entryId), - feedIdEntryIdUnique: unique().on(bl.rssFeedId, bl.entryId), - }), + (bl) => [ + index("rssFeedImports_feedIdIdx_idx").on(bl.rssFeedId), + index("rssFeedImports_entryIdIdx_idx").on(bl.entryId), + unique().on(bl.rssFeedId, bl.entryId), + ], ); export const config = sqliteTable("config", { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 934c93bb0..a840bd899 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -927,8 +927,8 @@ importers: specifier: ^7.6.11 version: 7.6.11 drizzle-kit: - specifier: ^0.24.02 - version: 0.24.2 + specifier: ^0.30.01 + version: 0.30.1 packages/e2e_tests: dependencies: @@ -2562,8 +2562,8 @@ packages: '@docusaurus/types': optional: true - '@drizzle-team/brocli@0.10.1': - resolution: {integrity: sha512-AHy0vjc+n/4w/8Mif+w86qpppHuF3AyXbcWW+R/W7GNA3F5/p2nuhlkCJaTXSLZheB4l1rtHzOfr9A7NwoR/Zg==} + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} '@egjs/hammerjs@2.0.17': resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} @@ -6803,8 +6803,8 @@ packages: resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} engines: {node: '>=12'} - drizzle-kit@0.24.2: - resolution: {integrity: sha512-nXOaTSFiuIaTMhS8WJC2d4EBeIcN9OSt2A2cyFbQYBAZbi7lRsVGJNqDpEwPqYfJz38yxbY/UtbvBBahBfnExQ==} + drizzle-kit@0.30.1: + resolution: {integrity: sha512-HmA/NeewvHywhJ2ENXD3KvOuM/+K2dGLJfxVfIHsGwaqKICJnS+Ke2L6UcSrSrtMJLJaT0Im1Qv4TFXfaZShyw==} hasBin: true drizzle-orm@0.33.0: @@ -13943,7 +13943,7 @@ snapshots: '@babel/core': 7.26.0 '@babel/helper-compilation-targets': 7.25.9 '@babel/helper-plugin-utils': 7.25.9 - debug: 4.3.7 + debug: 4.4.0 lodash.debounce: 4.0.8 resolve: 1.22.8 transitivePeerDependencies: @@ -13955,7 +13955,7 @@ snapshots: '@babel/core': 7.26.0 '@babel/helper-compilation-targets': 7.25.9 '@babel/helper-plugin-utils': 7.25.9 - debug: 4.3.7 + debug: 4.4.0 lodash.debounce: 4.0.8 resolve: 1.22.8 transitivePeerDependencies: @@ -15959,7 +15959,7 @@ snapshots: - webpack-cli dev: false - '@drizzle-team/brocli@0.10.1': + '@drizzle-team/brocli@0.10.2': dev: true '@egjs/hammerjs@2.0.17': @@ -16517,7 +16517,7 @@ snapshots: '@expo/image-utils': 0.6.3 '@expo/json-file': 9.0.0 '@react-native/normalize-colors': 0.76.3 - debug: 4.3.7 + debug: 4.4.0 fs-extra: 9.1.0 resolve-from: 5.0.0 semver: 7.6.3 @@ -19243,7 +19243,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) '@typescript-eslint/utils': 6.21.0(eslint@8.57.0)(typescript@5.3.3) - debug: 4.3.7 + debug: 4.4.0 eslint: 8.57.0 ts-api-utils: 1.2.1(typescript@5.3.3) typescript: 5.3.3 @@ -19273,7 +19273,7 @@ snapshots: dependencies: '@typescript-eslint/types': 6.21.0 '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.3.7 + debug: 4.4.0 globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.3 @@ -19288,7 +19288,7 @@ snapshots: dependencies: '@typescript-eslint/types': 6.21.0 '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.3.7 + debug: 4.4.0 globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.3 @@ -19621,7 +19621,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.3.7 + debug: 4.4.0 transitivePeerDependencies: - supports-color dev: false @@ -19629,7 +19629,7 @@ snapshots: agent-base@7.1.0: dependencies: - debug: 4.3.7 + debug: 4.4.0 transitivePeerDependencies: - supports-color dev: false @@ -21322,7 +21322,6 @@ snapshots: debug@4.4.0: dependencies: ms: 2.1.3 - dev: true decamelize@1.2.0: dev: false @@ -21736,9 +21735,9 @@ snapshots: dotenv@16.4.5: dev: false - drizzle-kit@0.24.2: + drizzle-kit@0.30.1: dependencies: - '@drizzle-team/brocli': 0.10.1 + '@drizzle-team/brocli': 0.10.2 '@esbuild-kit/esm-loader': 2.6.5 esbuild: 0.19.12 esbuild-register: 3.5.0(esbuild@0.19.12) @@ -21950,7 +21949,7 @@ snapshots: esbuild-register@3.5.0(esbuild@0.19.12): dependencies: - debug: 4.3.7 + debug: 4.4.0 esbuild: 0.19.12 transitivePeerDependencies: - supports-color @@ -22766,7 +22765,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.3.7 + debug: 4.4.0 get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -23276,7 +23275,7 @@ snapshots: dependencies: basic-ftp: 5.0.4 data-uri-to-buffer: 6.0.2 - debug: 4.3.7 + debug: 4.4.0 fs-extra: 11.2.0 transitivePeerDependencies: - supports-color @@ -23856,7 +23855,7 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.3.7 + debug: 4.4.0 transitivePeerDependencies: - supports-color dev: false @@ -23873,7 +23872,7 @@ snapshots: https-proxy-agent@7.0.5: dependencies: agent-base: 7.1.0 - debug: 4.3.7 + debug: 4.4.0 transitivePeerDependencies: - supports-color dev: false @@ -25934,7 +25933,7 @@ snapshots: micromark@4.0.0: dependencies: '@types/debug': 4.1.12 - debug: 4.3.7 + debug: 4.4.0 decode-named-character-reference: 1.0.2 devlop: 1.1.0 micromark-core-commonmark: 2.0.0 @@ -26779,7 +26778,7 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.0 - debug: 4.3.7 + debug: 4.4.0 get-uri: 6.0.3 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.4 @@ -27574,7 +27573,7 @@ snapshots: proxy-agent@6.4.0: dependencies: agent-base: 7.1.0 - debug: 4.3.7 + debug: 4.4.0 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.4 lru-cache: 7.18.3 @@ -27637,7 +27636,7 @@ snapshots: puppeteer-extra-plugin-user-data-dir@2.4.1(puppeteer-extra@3.3.6(puppeteer@22.3.0(typescript@5.3.3))): dependencies: - debug: 4.3.7 + debug: 4.4.0 fs-extra: 10.1.0 puppeteer-extra: 3.3.6(puppeteer@22.3.0(typescript@5.3.3)) puppeteer-extra-plugin: 3.2.3(puppeteer-extra@3.3.6(puppeteer@22.3.0(typescript@5.3.3))) @@ -29191,7 +29190,7 @@ snapshots: socks-proxy-agent@8.0.2: dependencies: agent-base: 7.1.0 - debug: 4.3.7 + debug: 4.4.0 socks: 2.8.1 transitivePeerDependencies: - supports-color @@ -29240,7 +29239,7 @@ snapshots: spdy-transport@3.0.0: dependencies: - debug: 4.3.7 + debug: 4.4.0 detect-node: 2.1.0 hpack.js: 2.1.6 obuf: 1.1.2 @@ -29252,7 +29251,7 @@ snapshots: spdy@4.0.2: dependencies: - debug: 4.3.7 + debug: 4.4.0 handle-thing: 2.0.1 http-deceiver: 1.2.7 select-hose: 2.0.0 @@ -29499,7 +29498,7 @@ snapshots: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.3.7 + debug: 4.4.0 fast-safe-stringify: 2.1.1 form-data: 4.0.0 formidable: 2.1.2 @@ -31071,4 +31070,4 @@ snapshots: use-sync-external-store: 1.2.0(react@18.3.1) dev: false - zwitch@2.0.4: {} \ No newline at end of file + zwitch@2.0.4: {} From 5ecdc36b7d60aa66b49e01e9fec8ba61ad537376 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Thu, 2 Jan 2025 13:00:58 +0200 Subject: [PATCH 20/40] feat: Add support for smart lists (#802) * feat: Add support for smart lists * i18n * Fix update list endpoint * Add a test for smart lists * Add header to the query explainer * Hide remove from lists in the smart context list * Add proper validation to list form --------- Co-authored-by: Deepak Kapoor <41769111+orthdron@users.noreply.github.com> --- .dockerignore | 1 + apps/cli/src/commands/lists.ts | 12 +- apps/web/app/api/v1/lists/[listId]/route.ts | 6 +- .../web/app/dashboard/lists/[listId]/page.tsx | 16 +- .../dashboard/bookmarks/BookmarkOptions.tsx | 32 +- .../dashboard/lists/EditListModal.tsx | 119 +- .../components/dashboard/lists/ListHeader.tsx | 38 +- .../dashboard/lists/ListOptions.tsx | 4 +- .../search/QueryExplainerTooltip.tsx | 3 + .../dashboard/search/SearchInput.tsx | 24 +- apps/web/lib/i18n/locales/en/translation.json | 9 +- .../db/drizzle/0037_daily_smiling_tiger.sql | 2 + packages/db/drizzle/meta/0037_snapshot.json | 1561 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/schema.ts | 3 + packages/e2e_tests/setup/startContainers.ts | 2 +- packages/e2e_tests/tests/api/lists.test.ts | 47 + packages/open-api/hoarder-openapi-spec.json | 28 + packages/open-api/lib/lists.ts | 3 +- packages/sdk/src/hoarder-api.d.ts | 13 + .../hooks/bookmark-list-context.tsx | 27 + packages/shared-react/hooks/lists.ts | 3 + packages/shared/types/lists.ts | 82 +- packages/trpc/lib/__tests__/search.test.ts | 14 +- packages/trpc/routers/bookmarks.ts | 30 + packages/trpc/routers/lists.ts | 59 +- 26 files changed, 2045 insertions(+), 100 deletions(-) create mode 100644 packages/db/drizzle/0037_daily_smiling_tiger.sql create mode 100644 packages/db/drizzle/meta/0037_snapshot.json create mode 100644 packages/shared-react/hooks/bookmark-list-context.tsx diff --git a/.dockerignore b/.dockerignore index 7759d5dd8..3b2da4475 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,3 +7,4 @@ README.md **/*.db **/.env* .git +./data diff --git a/apps/cli/src/commands/lists.ts b/apps/cli/src/commands/lists.ts index 4b157cdfe..57b6d9484 100644 --- a/apps/cli/src/commands/lists.ts +++ b/apps/cli/src/commands/lists.ts @@ -89,9 +89,17 @@ listsCmd .action(async (opts) => { const api = getAPIClient(); try { - const results = await api.lists.get.query({ listId: opts.list }); + let resp = await api.bookmarks.getBookmarks.query({ listId: opts.list }); + let results: string[] = resp.bookmarks.map((b) => b.id); + while (resp.nextCursor) { + resp = await api.bookmarks.getBookmarks.query({ + listId: opts.list, + cursor: resp.nextCursor, + }); + results = [...results, ...resp.bookmarks.map((b) => b.id)]; + } - printObject(results.bookmarks); + printObject(results); } catch (error) { printErrorMessageWithReason( "Failed to get the ids of the bookmarks in the list", diff --git a/apps/web/app/api/v1/lists/[listId]/route.ts b/apps/web/app/api/v1/lists/[listId]/route.ts index 69c99fda9..3fd0a32d6 100644 --- a/apps/web/app/api/v1/lists/[listId]/route.ts +++ b/apps/web/app/api/v1/lists/[listId]/route.ts @@ -1,7 +1,7 @@ import { NextRequest } from "next/server"; import { buildHandler } from "@/app/api/v1/utils/handler"; -import { zNewBookmarkListSchema } from "@hoarder/shared/types/lists"; +import { zEditBookmarkListSchema } from "@hoarder/shared/types/lists"; export const dynamic = "force-dynamic"; @@ -28,11 +28,11 @@ export const PATCH = ( ) => buildHandler({ req, - bodySchema: zNewBookmarkListSchema.partial(), + bodySchema: zEditBookmarkListSchema.omit({ listId: true }), handler: async ({ api, body }) => { const list = await api.lists.edit({ - listId: params.listId, ...body!, + listId: params.listId, }); return { status: 200, resp: list }; }, diff --git a/apps/web/app/dashboard/lists/[listId]/page.tsx b/apps/web/app/dashboard/lists/[listId]/page.tsx index f8c5e0b63..159730a1d 100644 --- a/apps/web/app/dashboard/lists/[listId]/page.tsx +++ b/apps/web/app/dashboard/lists/[listId]/page.tsx @@ -4,6 +4,8 @@ import ListHeader from "@/components/dashboard/lists/ListHeader"; import { api } from "@/server/api/client"; import { TRPCError } from "@trpc/server"; +import { BookmarkListContextProvider } from "@hoarder/shared-react/hooks/bookmark-list-context"; + export default async function ListPage({ params, }: { @@ -22,11 +24,13 @@ export default async function ListPage({ } return ( - } - /> + + } + /> + ); } diff --git a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx index e9e5834bc..c37c6417e 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx @@ -33,6 +33,7 @@ import { } from "@hoarder/shared-react/hooks//bookmarks"; import { useRemoveBookmarkFromList } from "@hoarder/shared-react/hooks//lists"; import { useBookmarkGridContext } from "@hoarder/shared-react/hooks/bookmark-grid-context"; +import { useBookmarkListContext } from "@hoarder/shared-react/hooks/bookmark-list-context"; import { BookmarkTypes } from "@hoarder/shared/types/bookmarks"; import { BookmarkedTextEditor } from "./BookmarkedTextEditor"; @@ -58,6 +59,7 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { const [isTextEditorOpen, setTextEditorOpen] = useState(false); const { listId } = useBookmarkGridContext() ?? {}; + const withinListContext = useBookmarkListContext(); const onError = () => { toast({ @@ -210,20 +212,22 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { {t("actions.manage_lists")} - {listId && ( - - removeFromListMutator.mutate({ - listId, - bookmarkId: bookmark.id, - }) - } - > - - {t("actions.remove_from_list")} - - )} + {listId && + withinListContext && + withinListContext.type === "manual" && ( + + removeFromListMutator.mutate({ + listId, + bookmarkId: bookmark.id, + }) + } + > + + {t("actions.remove_from_list")} + + )} {bookmark.content.type === BookmarkTypes.LINK && ( void; list?: ZBookmarkList; - parent?: ZBookmarkList; + prefill?: Partial>; children?: React.ReactNode; }) { const { t } = useTranslation(); @@ -64,17 +74,14 @@ export function EditListModal({ throw new Error("You must provide both open and setOpen or neither"); } const [customOpen, customSetOpen] = useState(false); - const formSchema = z.object({ - name: z.string(), - icon: z.string(), - parentId: z.string().nullish(), - }); - const form = useForm>({ - resolver: zodResolver(formSchema), + const form = useForm>({ + resolver: zodResolver(zNewBookmarkListSchema), defaultValues: { - name: list?.name ?? "", - icon: list?.icon ?? "🚀", - parentId: list?.parentId ?? parent?.id, + name: list?.name ?? prefill?.name ?? "", + icon: list?.icon ?? prefill?.icon ?? "🚀", + parentId: list?.parentId ?? prefill?.parentId, + type: list?.type ?? prefill?.type ?? "manual", + query: list?.query ?? prefill?.query ?? undefined, }, }); const [open, setOpen] = [ @@ -84,9 +91,11 @@ export function EditListModal({ useEffect(() => { form.reset({ - name: list?.name ?? "", - icon: list?.icon ?? "🚀", - parentId: list?.parentId ?? parent?.id, + name: list?.name ?? prefill?.name ?? "", + icon: list?.icon ?? prefill?.icon ?? "🚀", + parentId: list?.parentId ?? prefill?.parentId, + type: list?.type ?? prefill?.type ?? "manual", + query: list?.query ?? prefill?.query ?? undefined, }); }, [open]); @@ -154,14 +163,24 @@ export function EditListModal({ } }, }); + const listType = form.watch("type"); + + useEffect(() => { + if (listType !== "smart") { + form.resetField("query"); + } + }, [listType]); const isEdit = !!list; const isPending = isCreating || isEditing; - const onSubmit = form.handleSubmit((value: z.infer) => { - value.parentId = value.parentId === "" ? null : value.parentId; - isEdit ? editList({ ...value, listId: list.id }) : createList(value); - }); + const onSubmit = form.handleSubmit( + (value: z.infer) => { + value.parentId = value.parentId === "" ? null : value.parentId; + value.query = value.type === "smart" ? value.query : undefined; + isEdit ? editList({ ...value, listId: list.id }) : createList(value); + }, + ); return (
- {isEdit ? "Edit" : "New"} List + + {isEdit ? t("lists.edit_list") : t("lists.new_list")} +
{ return ( - Parent + {t("lists.parent_list")}