diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-01-02 13:00:58 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-01-02 13:00:58 +0200 |
| commit | 5ecdc36b7d60aa66b49e01e9fec8ba61ad537376 (patch) | |
| tree | 57577822bb104b95900ba577a265fb4f8cf70b78 | |
| parent | 5df0258b2cd884347eabfa866d7e7fbc7225cdb3 (diff) | |
| download | karakeep-5ecdc36b7d60aa66b49e01e9fec8ba61ad537376.tar.zst | |
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>
26 files changed, 2045 insertions, 100 deletions
diff --git a/.dockerignore b/.dockerignore index 7759d5dd..3b2da447 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 4b157cdf..57b6d948 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 69c99fda..3fd0a32d 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 f8c5e0b6..159730a1 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 ( - <Bookmarks - query={{ listId: list.id }} - showDivider={true} - showEditorCard={true} - header={<ListHeader initialData={list} />} - /> + <BookmarkListContextProvider list={list}> + <Bookmarks + query={{ listId: list.id }} + showDivider={true} + showEditorCard={list.type === "manual"} + header={<ListHeader initialData={list} />} + /> + </BookmarkListContextProvider> ); } diff --git a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx index e9e5834b..c37c6417 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 }) { <span>{t("actions.manage_lists")}</span> </DropdownMenuItem> - {listId && ( - <DropdownMenuItem - disabled={demoMode} - onClick={() => - removeFromListMutator.mutate({ - listId, - bookmarkId: bookmark.id, - }) - } - > - <ListX className="mr-2 size-4" /> - <span>{t("actions.remove_from_list")}</span> - </DropdownMenuItem> - )} + {listId && + withinListContext && + withinListContext.type === "manual" && ( + <DropdownMenuItem + disabled={demoMode} + onClick={() => + removeFromListMutator.mutate({ + listId, + bookmarkId: bookmark.id, + }) + } + > + <ListX className="mr-2 size-4" /> + <span>{t("actions.remove_from_list")}</span> + </DropdownMenuItem> + )} {bookmark.content.type === BookmarkTypes.LINK && ( <DropdownMenuItem diff --git a/apps/web/components/dashboard/lists/EditListModal.tsx b/apps/web/components/dashboard/lists/EditListModal.tsx index d66d7096..98bb19be 100644 --- a/apps/web/components/dashboard/lists/EditListModal.tsx +++ b/apps/web/components/dashboard/lists/EditListModal.tsx @@ -25,6 +25,13 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; import data from "@emoji-mart/data"; @@ -38,7 +45,10 @@ import { useCreateBookmarkList, useEditBookmarkList, } from "@hoarder/shared-react/hooks/lists"; -import { ZBookmarkList } from "@hoarder/shared/types/lists"; +import { + ZBookmarkList, + zNewBookmarkListSchema, +} from "@hoarder/shared/types/lists"; import { BookmarkListSelector } from "./BookmarkListSelector"; @@ -46,13 +56,13 @@ export function EditListModal({ open: userOpen, setOpen: userSetOpen, list, - parent, + prefill, children, }: { open?: boolean; setOpen?: (v: boolean) => void; list?: ZBookmarkList; - parent?: ZBookmarkList; + prefill?: Partial<Omit<ZBookmarkList, "id">>; 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<z.infer<typeof formSchema>>({ - resolver: zodResolver(formSchema), + const form = useForm<z.infer<typeof zNewBookmarkListSchema>>({ + 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<typeof formSchema>) => { - value.parentId = value.parentId === "" ? null : value.parentId; - isEdit ? editList({ ...value, listId: list.id }) : createList(value); - }); + const onSubmit = form.handleSubmit( + (value: z.infer<typeof zNewBookmarkListSchema>) => { + value.parentId = value.parentId === "" ? null : value.parentId; + value.query = value.type === "smart" ? value.query : undefined; + isEdit ? editList({ ...value, listId: list.id }) : createList(value); + }, + ); return ( <Dialog @@ -176,7 +195,9 @@ export function EditListModal({ <Form {...form}> <form onSubmit={onSubmit}> <DialogHeader> - <DialogTitle>{isEdit ? "Edit" : "New"} List</DialogTitle> + <DialogTitle> + {isEdit ? t("lists.edit_list") : t("lists.new_list")} + </DialogTitle> </DialogHeader> <div className="flex w-full gap-2 py-4"> <FormField @@ -232,7 +253,7 @@ export function EditListModal({ render={({ field }) => { return ( <FormItem className="grow pb-4"> - <FormLabel>Parent</FormLabel> + <FormLabel>{t("lists.parent_list")}</FormLabel> <div className="flex items-center gap-1"> <FormControl> <BookmarkListSelector @@ -240,7 +261,7 @@ export function EditListModal({ hideSubtreeOf={list ? list.id : undefined} value={field.value} onChange={field.onChange} - placeholder={"No Parent"} + placeholder={t("lists.no_parent")} /> </FormControl> <Button @@ -258,6 +279,58 @@ export function EditListModal({ ); }} /> + <FormField + control={form.control} + name="type" + render={({ field }) => { + return ( + <FormItem className="grow pb-4"> + <FormLabel>{t("lists.list_type")}</FormLabel> + <FormControl> + <Select + disabled={isEdit} + onValueChange={field.onChange} + value={field.value} + > + <SelectTrigger className="w-full"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="manual"> + {t("lists.manual_list")} + </SelectItem> + <SelectItem value="smart"> + {t("lists.smart_list")} + </SelectItem> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + {listType === "smart" && ( + <FormField + control={form.control} + name="query" + render={({ field }) => { + return ( + <FormItem className="grow pb-4"> + <FormLabel>{t("lists.search_query")}</FormLabel> + <FormControl> + <Input + value={field.value} + onChange={field.onChange} + placeholder={t("lists.search_query")} + /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + )} <DialogFooter className="sm:justify-end"> <DialogClose asChild> <Button type="button" variant="secondary"> diff --git a/apps/web/components/dashboard/lists/ListHeader.tsx b/apps/web/components/dashboard/lists/ListHeader.tsx index a6780e1e..b8bfb4ad 100644 --- a/apps/web/components/dashboard/lists/ListHeader.tsx +++ b/apps/web/components/dashboard/lists/ListHeader.tsx @@ -1,19 +1,24 @@ "use client"; +import { useMemo } from "react"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; -import { MoreHorizontal } from "lucide-react"; +import { useTranslation } from "@/lib/i18n/client"; +import { MoreHorizontal, SearchIcon } from "lucide-react"; import { api } from "@hoarder/shared-react/trpc"; +import { parseSearchQuery } from "@hoarder/shared/searchQueryParser"; import { ZBookmarkList } from "@hoarder/shared/types/lists"; +import QueryExplainerTooltip from "../search/QueryExplainerTooltip"; import { ListOptions } from "./ListOptions"; export default function ListHeader({ initialData, }: { - initialData: ZBookmarkList & { bookmarks: string[] }; + initialData: ZBookmarkList; }) { + const { t } = useTranslation(); const router = useRouter(); const { data: list, error } = api.lists.get.useQuery( { @@ -24,6 +29,13 @@ export default function ListHeader({ }, ); + const parsedQuery = useMemo(() => { + if (!list.query) { + return null; + } + return parseSearchQuery(list.query); + }, [list.query]); + if (error) { // This is usually exercised during list deletions. if (error.data?.code == "NOT_FOUND") { @@ -33,10 +45,24 @@ export default function ListHeader({ return ( <div className="flex items-center justify-between"> - <span className="text-2xl"> - {list.icon} {list.name} - </span> - <div className="flex"> + <div className="flex items-center gap-2"> + <span className="text-2xl"> + {list.icon} {list.name} + </span> + </div> + <div className="flex items-center"> + {parsedQuery && ( + <QueryExplainerTooltip + header={ + <div className="flex items-center justify-center gap-1"> + <SearchIcon className="size-3" /> + <span className="text-sm">{t("lists.smart_list")}</span> + </div> + } + parsedSearchQuery={parsedQuery} + className="size-6 stroke-foreground" + /> + )} <ListOptions list={list}> <Button variant="ghost"> <MoreHorizontal /> diff --git a/apps/web/components/dashboard/lists/ListOptions.tsx b/apps/web/components/dashboard/lists/ListOptions.tsx index e663a2e0..a7217954 100644 --- a/apps/web/components/dashboard/lists/ListOptions.tsx +++ b/apps/web/components/dashboard/lists/ListOptions.tsx @@ -33,7 +33,9 @@ export function ListOptions({ <EditListModal open={newNestedListModalOpen} setOpen={setNewNestedListModalOpen} - parent={list} + prefill={{ + parentId: list.id, + }} /> <EditListModal open={editModalOpen} diff --git a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx index eb7282d0..13174fb2 100644 --- a/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx +++ b/apps/web/components/dashboard/search/QueryExplainerTooltip.tsx @@ -6,8 +6,10 @@ import { Matcher } from "@hoarder/shared/types/search"; export default function QueryExplainerTooltip({ parsedSearchQuery, + header, className, }: { + header?: React.ReactNode; parsedSearchQuery: TextAndMatcher & { result: string }; className?: string; }) { @@ -98,6 +100,7 @@ export default function QueryExplainerTooltip({ return ( <InfoTooltip className={className}> + {header} <Table> <TableBody> {parsedSearchQuery.text && ( diff --git a/apps/web/components/dashboard/search/SearchInput.tsx b/apps/web/components/dashboard/search/SearchInput.tsx index 8ed2ea3c..ace3d785 100644 --- a/apps/web/components/dashboard/search/SearchInput.tsx +++ b/apps/web/components/dashboard/search/SearchInput.tsx @@ -1,11 +1,13 @@ "use client"; -import React, { useEffect, useImperativeHandle, useRef } from "react"; +import React, { useEffect, useImperativeHandle, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; 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 { EditListModal } from "../lists/EditListModal"; import QueryExplainerTooltip from "./QueryExplainerTooltip"; function useFocusSearchOnKeyPress( @@ -63,6 +65,7 @@ const SearchInput = React.forwardRef< useFocusSearchOnKeyPress(inputRef, onChange); useImperativeHandle(ref, () => inputRef.current!); + const [newNestedListModalOpen, setNewNestedListModalOpen] = useState(false); useEffect(() => { if (!isInSearchPage) { @@ -72,10 +75,29 @@ const SearchInput = React.forwardRef< return ( <div className={cn("relative flex-1", className)}> + <EditListModal + open={newNestedListModalOpen} + setOpen={setNewNestedListModalOpen} + prefill={{ + type: "smart", + query: value, + }} + /> <QueryExplainerTooltip className="-translate-1/2 absolute right-1.5 top-2 p-0.5" parsedSearchQuery={parsedSearchQuery} /> + {parsedSearchQuery.result === "full" && + parsedSearchQuery.text.length == 0 && ( + <Button + onClick={() => setNewNestedListModalOpen(true)} + size="none" + variant="secondary" + className="absolute right-9 top-2 z-50 px-2 py-1 text-xs" + > + {t("actions.save")} + </Button> + )} <Input ref={inputRef} value={value} diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index cdd31922..27799121 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -166,7 +166,14 @@ "all_lists": "All Lists", "favourites": "Favourites", "new_list": "New List", - "new_nested_list": "New Nested List" + "edit_list": "Edit List", + "new_nested_list": "New Nested List", + "parent_list": "Parent List", + "no_parent": "No Parent", + "list_type": "List Type", + "manual_list": "Manual List", + "smart_list": "Smart List", + "search_query": "Search Query" }, "tags": { "all_tags": "All Tags", diff --git a/packages/db/drizzle/0037_daily_smiling_tiger.sql b/packages/db/drizzle/0037_daily_smiling_tiger.sql new file mode 100644 index 00000000..6aa7efaa --- /dev/null +++ b/packages/db/drizzle/0037_daily_smiling_tiger.sql @@ -0,0 +1,2 @@ +ALTER TABLE `bookmarkLists` ADD `type` text NOT NULL DEFAULT "manual";--> statement-breakpoint +ALTER TABLE `bookmarkLists` ADD `query` text; diff --git a/packages/db/drizzle/meta/0037_snapshot.json b/packages/db/drizzle/meta/0037_snapshot.json new file mode 100644 index 00000000..9eb89622 --- /dev/null +++ b/packages/db/drizzle/meta/0037_snapshot.json @@ -0,0 +1,1561 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "21860136-dd1f-4d1e-b157-3c99132e2036", + "prevId": "7c51445d-0b54-4a42-aad3-630b85601478", + "tables": { + "account": { + "name": "account", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "columns": [ + "provider", + "providerAccountId" + ], + "name": "account_provider_providerAccountId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apiKey": { + "name": "apiKey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyId": { + "name": "keyId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "apiKey_keyId_unique": { + "name": "apiKey_keyId_unique", + "columns": [ + "keyId" + ], + "isUnique": true + }, + "apiKey_name_userId_unique": { + "name": "apiKey_name_userId_unique", + "columns": [ + "name", + "userId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "apiKey_userId_user_id_fk": { + "name": "apiKey_userId_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "assets": { + "name": "assets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "assetType": { + "name": "assetType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "contentType": { + "name": "contentType", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "assets_bookmarkId_idx": { + "name": "assets_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "assets_assetType_idx": { + "name": "assets_assetType_idx", + "columns": [ + "assetType" + ], + "isUnique": false + }, + "assets_userId_idx": { + "name": "assets_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "assets_bookmarkId_bookmarks_id_fk": { + "name": "assets_bookmarkId_bookmarks_id_fk", + "tableFrom": "assets", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_userId_user_id_fk": { + "name": "assets_userId_user_id_fk", + "tableFrom": "assets", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkAssets": { + "name": "bookmarkAssets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "assetType": { + "name": "assetType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assetId": { + "name": "assetId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarkAssets_id_bookmarks_id_fk": { + "name": "bookmarkAssets_id_bookmarks_id_fk", + "tableFrom": "bookmarkAssets", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkLinks": { + "name": "bookmarkLinks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "imageUrl": { + "name": "imageUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "htmlContent": { + "name": "htmlContent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crawledAt": { + "name": "crawledAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crawlStatus": { + "name": "crawlStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "crawlStatusCode": { + "name": "crawlStatusCode", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 200 + } + }, + "indexes": { + "bookmarkLinks_url_idx": { + "name": "bookmarkLinks_url_idx", + "columns": [ + "url" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarkLinks_id_bookmarks_id_fk": { + "name": "bookmarkLinks_id_bookmarks_id_fk", + "tableFrom": "bookmarkLinks", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkLists": { + "name": "bookmarkLists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parentId": { + "name": "parentId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "bookmarkLists_userId_idx": { + "name": "bookmarkLists_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarkLists_userId_user_id_fk": { + "name": "bookmarkLists_userId_user_id_fk", + "tableFrom": "bookmarkLists", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarkLists_parentId_bookmarkLists_id_fk": { + "name": "bookmarkLists_parentId_bookmarkLists_id_fk", + "tableFrom": "bookmarkLists", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "parentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkTags": { + "name": "bookmarkTags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "bookmarkTags_name_idx": { + "name": "bookmarkTags_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "bookmarkTags_userId_idx": { + "name": "bookmarkTags_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarkTags_userId_name_unique": { + "name": "bookmarkTags_userId_name_unique", + "columns": [ + "userId", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "bookmarkTags_userId_user_id_fk": { + "name": "bookmarkTags_userId_user_id_fk", + "tableFrom": "bookmarkTags", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkTexts": { + "name": "bookmarkTexts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarkTexts_id_bookmarks_id_fk": { + "name": "bookmarkTexts_id_bookmarks_id_fk", + "tableFrom": "bookmarkTexts", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarks": { + "name": "bookmarks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "favourited": { + "name": "favourited", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "taggingStatus": { + "name": "taggingStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "bookmarks_userId_idx": { + "name": "bookmarks_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarks_archived_idx": { + "name": "bookmarks_archived_idx", + "columns": [ + "archived" + ], + "isUnique": false + }, + "bookmarks_favourited_idx": { + "name": "bookmarks_favourited_idx", + "columns": [ + "favourited" + ], + "isUnique": false + }, + "bookmarks_createdAt_idx": { + "name": "bookmarks_createdAt_idx", + "columns": [ + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarks_userId_user_id_fk": { + "name": "bookmarks_userId_user_id_fk", + "tableFrom": "bookmarks", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarksInLists": { + "name": "bookmarksInLists", + "columns": { + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedAt": { + "name": "addedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "bookmarksInLists_bookmarkId_idx": { + "name": "bookmarksInLists_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "bookmarksInLists_listId_idx": { + "name": "bookmarksInLists_listId_idx", + "columns": [ + "listId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarksInLists_bookmarkId_bookmarks_id_fk": { + "name": "bookmarksInLists_bookmarkId_bookmarks_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarksInLists_listId_bookmarkLists_id_fk": { + "name": "bookmarksInLists_listId_bookmarkLists_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "listId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bookmarksInLists_bookmarkId_listId_pk": { + "columns": [ + "bookmarkId", + "listId" + ], + "name": "bookmarksInLists_bookmarkId_listId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "config": { + "name": "config", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "customPrompts": { + "name": "customPrompts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attachedBy": { + "name": "attachedBy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "customPrompts_userId_idx": { + "name": "customPrompts_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "customPrompts_userId_user_id_fk": { + "name": "customPrompts_userId_user_id_fk", + "tableFrom": "customPrompts", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "highlights": { + "name": "highlights", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "startOffset": { + "name": "startOffset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endOffset": { + "name": "endOffset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'yellow'" + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "highlights_bookmarkId_idx": { + "name": "highlights_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "highlights_userId_idx": { + "name": "highlights_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "highlights_bookmarkId_bookmarks_id_fk": { + "name": "highlights_bookmarkId_bookmarks_id_fk", + "tableFrom": "highlights", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "highlights_userId_user_id_fk": { + "name": "highlights_userId_user_id_fk", + "tableFrom": "highlights", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rssFeedImports": { + "name": "rssFeedImports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entryId": { + "name": "entryId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rssFeedId": { + "name": "rssFeedId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "rssFeedImports_feedIdIdx_idx": { + "name": "rssFeedImports_feedIdIdx_idx", + "columns": [ + "rssFeedId" + ], + "isUnique": false + }, + "rssFeedImports_entryIdIdx_idx": { + "name": "rssFeedImports_entryIdIdx_idx", + "columns": [ + "entryId" + ], + "isUnique": false + }, + "rssFeedImports_rssFeedId_entryId_unique": { + "name": "rssFeedImports_rssFeedId_entryId_unique", + "columns": [ + "rssFeedId", + "entryId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "rssFeedImports_rssFeedId_rssFeeds_id_fk": { + "name": "rssFeedImports_rssFeedId_rssFeeds_id_fk", + "tableFrom": "rssFeedImports", + "tableTo": "rssFeeds", + "columnsFrom": [ + "rssFeedId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rssFeedImports_bookmarkId_bookmarks_id_fk": { + "name": "rssFeedImports_bookmarkId_bookmarks_id_fk", + "tableFrom": "rssFeedImports", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rssFeeds": { + "name": "rssFeeds", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastFetchedAt": { + "name": "lastFetchedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastFetchedStatus": { + "name": "lastFetchedStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "rssFeeds_userId_idx": { + "name": "rssFeeds_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "rssFeeds_userId_user_id_fk": { + "name": "rssFeeds_userId_user_id_fk", + "tableFrom": "rssFeeds", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tagsOnBookmarks": { + "name": "tagsOnBookmarks", + "columns": { + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attachedAt": { + "name": "attachedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attachedBy": { + "name": "attachedBy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tagsOnBookmarks_tagId_idx": { + "name": "tagsOnBookmarks_tagId_idx", + "columns": [ + "tagId" + ], + "isUnique": false + }, + "tagsOnBookmarks_bookmarkId_idx": { + "name": "tagsOnBookmarks_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tagsOnBookmarks_bookmarkId_bookmarks_id_fk": { + "name": "tagsOnBookmarks_bookmarkId_bookmarks_id_fk", + "tableFrom": "tagsOnBookmarks", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tagsOnBookmarks_tagId_bookmarkTags_id_fk": { + "name": "tagsOnBookmarks_tagId_bookmarkTags_id_fk", + "tableFrom": "tagsOnBookmarks", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "tagId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tagsOnBookmarks_bookmarkId_tagId_pk": { + "columns": [ + "bookmarkId", + "tagId" + ], + "name": "tagsOnBookmarks_bookmarkId_tagId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'user'" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "columns": [ + "identifier", + "token" + ], + "name": "verificationToken_identifier_token_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +}
\ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 7dfcd863..56e61f75 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -260,6 +260,13 @@ "when": 1735308236125, "tag": "0036_luxuriant_white_queen", "breakpoints": true + }, + { + "idx": 37, + "version": "6", + "when": 1735750275339, + "tag": "0037_daily_smiling_tiger", + "breakpoints": true } ] }
\ No newline at end of file diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 722d57cf..19bf6db5 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -307,6 +307,9 @@ export const bookmarkLists = sqliteTable( userId: text("userId") .notNull() .references(() => users.id, { onDelete: "cascade" }), + type: text("type", { enum: ["manual", "smart"] }).notNull(), + // Only applicable for smart lists + query: text("query"), parentId: text("parentId").references( (): AnySQLiteColumn => bookmarkLists.id, { onDelete: "set null" }, diff --git a/packages/e2e_tests/setup/startContainers.ts b/packages/e2e_tests/setup/startContainers.ts index 8cc30162..10b1b9d8 100644 --- a/packages/e2e_tests/setup/startContainers.ts +++ b/packages/e2e_tests/setup/startContainers.ts @@ -39,7 +39,7 @@ export default async function ({ provide }: GlobalSetupContext) { const port = await getRandomPort(); console.log(`Starting docker compose on port ${port}...`); - execSync(`docker compose up -d`, { + execSync(`docker compose up --build -d`, { cwd: __dirname, stdio: "inherit", env: { diff --git a/packages/e2e_tests/tests/api/lists.test.ts b/packages/e2e_tests/tests/api/lists.test.ts index 2a954b6f..657d8535 100644 --- a/packages/e2e_tests/tests/api/lists.test.ts +++ b/packages/e2e_tests/tests/api/lists.test.ts @@ -182,4 +182,51 @@ describe("Lists API", () => { expect(updatedListBookmarks!.bookmarks.length).toBe(0); }); + + it("should support smart lists", async () => { + // Create a bookmark + const { data: createdBookmark1 } = await client.POST("/bookmarks", { + body: { + type: "text", + title: "Test Bookmark", + text: "This is a test bookmark", + favourited: true, + }, + }); + + const { data: _ } = await client.POST("/bookmarks", { + body: { + type: "text", + title: "Test Bookmark", + text: "This is a test bookmark", + favourited: false, + }, + }); + + // Create a list + const { data: createdList } = await client.POST("/lists", { + body: { + name: "Test List", + icon: "🚀", + type: "smart", + query: "is:fav", + }, + }); + + // Get bookmarks in list + const { data: listBookmarks, response: getResponse } = await client.GET( + "/lists/{listId}/bookmarks", + { + params: { + path: { + listId: createdList!.id, + }, + }, + }, + ); + + expect(getResponse.status).toBe(200); + expect(listBookmarks!.bookmarks.length).toBe(1); + expect(listBookmarks!.bookmarks[0].id).toBe(createdBookmark1!.id); + }); }); diff --git a/packages/open-api/hoarder-openapi-spec.json b/packages/open-api/hoarder-openapi-spec.json index 46e5ea0f..fecea0c2 100644 --- a/packages/open-api/hoarder-openapi-spec.json +++ b/packages/open-api/hoarder-openapi-spec.json @@ -362,6 +362,18 @@ "parentId": { "type": "string", "nullable": true + }, + "type": { + "type": "string", + "enum": [ + "manual", + "smart" + ], + "default": "manual" + }, + "query": { + "type": "string", + "nullable": true } }, "required": [ @@ -1058,6 +1070,18 @@ "icon": { "type": "string" }, + "type": { + "type": "string", + "enum": [ + "manual", + "smart" + ], + "default": "manual" + }, + "query": { + "type": "string", + "minLength": 1 + }, "parentId": { "type": "string", "nullable": true @@ -1171,6 +1195,10 @@ "parentId": { "type": "string", "nullable": true + }, + "query": { + "type": "string", + "minLength": 1 } } } diff --git a/packages/open-api/lib/lists.ts b/packages/open-api/lib/lists.ts index 07c9fde9..4b728a1e 100644 --- a/packages/open-api/lib/lists.ts +++ b/packages/open-api/lib/lists.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import { zBookmarkListSchema, + zEditBookmarkListSchema, zNewBookmarkListSchema, } from "@hoarder/shared/types/lists"; @@ -132,7 +133,7 @@ registry.registerPath({ "The data to update. Only the fields you want to update need to be provided.", content: { "application/json": { - schema: zNewBookmarkListSchema.partial(), + schema: zEditBookmarkListSchema.omit({ listId: true }), }, }, }, diff --git a/packages/sdk/src/hoarder-api.d.ts b/packages/sdk/src/hoarder-api.d.ts index 25907bcb..fbe345d0 100644 --- a/packages/sdk/src/hoarder-api.d.ts +++ b/packages/sdk/src/hoarder-api.d.ts @@ -400,6 +400,12 @@ export interface paths { "application/json": { name: string; icon: string; + /** + * @default manual + * @enum {string} + */ + type?: "manual" | "smart"; + query?: string; parentId?: string | null; }; }; @@ -503,6 +509,7 @@ export interface paths { name?: string; icon?: string; parentId?: string | null; + query?: string; }; }; }; @@ -1089,6 +1096,12 @@ export interface components { name: string; icon: string; parentId: string | null; + /** + * @default manual + * @enum {string} + */ + type: "manual" | "smart"; + query?: string | null; }; Tag: { id: string; diff --git a/packages/shared-react/hooks/bookmark-list-context.tsx b/packages/shared-react/hooks/bookmark-list-context.tsx new file mode 100644 index 00000000..d00e0567 --- /dev/null +++ b/packages/shared-react/hooks/bookmark-list-context.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { createContext, useContext } from "react"; + +import { ZBookmarkList } from "@hoarder/shared/types/lists"; + +export const BookmarkListContext = createContext<ZBookmarkList | undefined>( + undefined, +); + +export function BookmarkListContextProvider({ + list, + children, +}: { + list: ZBookmarkList; + children: React.ReactNode; +}) { + return ( + <BookmarkListContext.Provider value={list}> + {children} + </BookmarkListContext.Provider> + ); +} + +export function useBookmarkListContext() { + return useContext(BookmarkListContext); +} diff --git a/packages/shared-react/hooks/lists.ts b/packages/shared-react/hooks/lists.ts index 10633a08..46477228 100644 --- a/packages/shared-react/hooks/lists.ts +++ b/packages/shared-react/hooks/lists.ts @@ -28,6 +28,9 @@ export function useEditBookmarkList( onSuccess: (res, req, meta) => { apiUtils.lists.list.invalidate(); apiUtils.lists.get.invalidate({ listId: req.listId }); + if (res.type === "smart") { + apiUtils.bookmarks.getBookmarks.invalidate({ listId: req.listId }); + } return opts[0]?.onSuccess?.(res, req, meta); }, }); diff --git a/packages/shared/types/lists.ts b/packages/shared/types/lists.ts index d2041907..bd6786b0 100644 --- a/packages/shared/types/lists.ts +++ b/packages/shared/types/lists.ts @@ -1,28 +1,76 @@ import { z } from "zod"; -export const zNewBookmarkListSchema = z.object({ - name: z - .string() - .min(1, "List name can't be empty") - .max(40, "List name is at most 40 chars"), - icon: z.string(), - parentId: z.string().nullish(), -}); +import { parseSearchQuery } from "../searchQueryParser"; + +export const zNewBookmarkListSchema = z + .object({ + name: z + .string() + .min(1, "List name can't be empty") + .max(40, "List name is at most 40 chars"), + icon: z.string(), + type: z.enum(["manual", "smart"]).optional().default("manual"), + query: z.string().min(1).optional(), + parentId: z.string().nullish(), + }) + .refine((val) => val.type === "smart" || !val.query, { + message: "Manual lists cannot have a query", + path: ["query"], + }) + .refine((val) => val.type === "manual" || val.query, { + message: "Smart lists must have a query", + path: ["query"], + }) + .refine( + (val) => !val.query || parseSearchQuery(val.query).result === "full", + { + message: "Smart search query is not valid", + path: ["query"], + }, + ) + .refine((val) => !val.query || parseSearchQuery(val.query).text.length == 0, { + message: + "Smart lists cannot have unqualified terms (aka full text search terms) in the query", + path: ["query"], + }); export const zBookmarkListSchema = z.object({ id: z.string(), name: z.string(), icon: z.string(), parentId: z.string().nullable(), + type: z.enum(["manual", "smart"]).default("manual"), + query: z.string().nullish(), }); -export const zBookmarkListWithBookmarksSchema = zBookmarkListSchema.merge( - z.object({ - bookmarks: z.array(z.string()), - }), -); - export type ZBookmarkList = z.infer<typeof zBookmarkListSchema>; -export type ZBookmarkListWithBookmarks = z.infer< - typeof zBookmarkListWithBookmarksSchema ->; + +export const zEditBookmarkListSchema = z.object({ + listId: z.string(), + name: z + .string() + .min(1, "List name can't be empty") + .max(40, "List name is at most 40 chars") + .optional(), + icon: z.string().optional(), + parentId: z.string().nullish(), + query: z.string().min(1).optional(), +}); + +export const zEditBookmarkListSchemaWithValidation = zEditBookmarkListSchema + .refine((val) => val.parentId != val.listId, { + message: "List can't be its own parent", + path: ["parentId"], + }) + .refine( + (val) => !val.query || parseSearchQuery(val.query).result === "full", + { + message: "Smart search query is not valid", + path: ["query"], + }, + ) + .refine((val) => !val.query || parseSearchQuery(val.query).text.length == 0, { + message: + "Smart lists cannot have unqualified terms (aka full text search terms) in the query", + path: ["query"], + }); diff --git a/packages/trpc/lib/__tests__/search.test.ts b/packages/trpc/lib/__tests__/search.test.ts index bf32bcb1..468aef83 100644 --- a/packages/trpc/lib/__tests__/search.test.ts +++ b/packages/trpc/lib/__tests__/search.test.ts @@ -130,10 +130,16 @@ beforeEach(async () => { ]); 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: "💼" }, + { id: "l1", userId: testUserId, name: "list1", icon: "🚀", type: "manual" }, + { id: "l2", userId: testUserId, name: "list2", icon: "🚀", type: "manual" }, + { + id: "l3", + userId: testUserId, + name: "favorites", + icon: "⭐", + type: "manual", + }, + { id: "l4", userId: testUserId, name: "work", icon: "💼", type: "manual" }, ]); await db.insert(bookmarksInLists).values([ diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts index 3320b3b9..47ba623b 100644 --- a/packages/trpc/routers/bookmarks.ts +++ b/packages/trpc/routers/bookmarks.ts @@ -14,6 +14,7 @@ import { AssetTypes, bookmarkAssets, bookmarkLinks, + bookmarkLists, bookmarks, bookmarksInLists, bookmarkTags, @@ -33,6 +34,7 @@ import { triggerSearchReindex, } from "@hoarder/shared/queues"; import { getSearchIdxClient } from "@hoarder/shared/search"; +import { parseSearchQuery } from "@hoarder/shared/searchQueryParser"; import { BookmarkTypes, DEFAULT_NUM_BOOKMARKS_PER_PAGE, @@ -625,6 +627,34 @@ export const bookmarksAppRouter = router({ if (!input.limit) { input.limit = DEFAULT_NUM_BOOKMARKS_PER_PAGE; } + if (input.listId) { + const list = await ctx.db.query.bookmarkLists.findFirst({ + where: and( + eq(bookmarkLists.id, input.listId), + eq(bookmarkLists.userId, ctx.user.id), + ), + }); + if (!list) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "List not found", + }); + } + if (list.type === "smart") { + invariant(list.query); + const query = parseSearchQuery(list.query); + if (query.result !== "full") { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Found an invalid smart list query", + }); + } + if (query.matcher) { + input.ids = await getBookmarkIdsFromMatcher(ctx, query.matcher); + delete input.listId; + } + } + } const sq = ctx.db.$with("bookmarksSq").as( ctx.db diff --git a/packages/trpc/routers/lists.ts b/packages/trpc/routers/lists.ts index 0cc937b1..ec7cb10f 100644 --- a/packages/trpc/routers/lists.ts +++ b/packages/trpc/routers/lists.ts @@ -1,12 +1,14 @@ import assert from "node:assert"; import { experimental_trpcMiddleware, TRPCError } from "@trpc/server"; import { and, eq } from "drizzle-orm"; +import invariant from "tiny-invariant"; import { z } from "zod"; import { SqliteError } from "@hoarder/db"; import { bookmarkLists, bookmarksInLists } from "@hoarder/db/schema"; import { zBookmarkListSchema, + zEditBookmarkListSchemaWithValidation, zNewBookmarkListSchema, } from "@hoarder/shared/types/lists"; @@ -58,28 +60,40 @@ export const listsAppRouter = router({ icon: input.icon, userId: ctx.user.id, parentId: input.parentId, + type: input.type, + query: input.query, }) .returning(); return result; }), edit: authedProcedure - .input( - zNewBookmarkListSchema - .partial() - .merge(z.object({ listId: z.string() })) - .refine((val) => val.parentId != val.listId, { - message: "List can't be its own parent", - path: ["parentId"], - }), - ) + .input(zEditBookmarkListSchemaWithValidation) .output(zBookmarkListSchema) + .use(ensureListOwnership) .mutation(async ({ input, ctx }) => { + if (input.query) { + const list = await ctx.db.query.bookmarkLists.findFirst({ + where: and( + eq(bookmarkLists.id, input.listId), + eq(bookmarkLists.userId, ctx.user.id), + ), + }); + // List must exist given that we passed the ownership check + invariant(list); + if (list.type !== "smart") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Manual lists cannot have a query", + }); + } + } const result = await ctx.db .update(bookmarkLists) .set({ name: input.name, icon: input.icon, parentId: input.parentId, + query: input.query, }) .where( and( @@ -123,6 +137,19 @@ export const listsAppRouter = router({ .use(ensureListOwnership) .use(ensureBookmarkOwnership) .mutation(async ({ input, ctx }) => { + const list = await ctx.db.query.bookmarkLists.findFirst({ + where: and( + eq(bookmarkLists.id, input.listId), + eq(bookmarkLists.userId, ctx.user.id), + ), + }); + invariant(list); + if (list.type === "smart") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Smart lists cannot be added to", + }); + } try { await ctx.db.insert(bookmarksInLists).values({ listId: input.listId, @@ -174,13 +201,7 @@ export const listsAppRouter = router({ listId: z.string(), }), ) - .output( - zBookmarkListSchema.merge( - z.object({ - bookmarks: z.array(z.string()), - }), - ), - ) + .output(zBookmarkListSchema) .use(ensureListOwnership) .query(async ({ input, ctx }) => { const res = await ctx.db.query.bookmarkLists.findFirst({ @@ -188,9 +209,6 @@ export const listsAppRouter = router({ eq(bookmarkLists.id, input.listId), eq(bookmarkLists.userId, ctx.user.id), ), - with: { - bookmarksInLists: true, - }, }); if (!res) { throw new TRPCError({ code: "NOT_FOUND" }); @@ -201,7 +219,8 @@ export const listsAppRouter = router({ name: res.name, icon: res.icon, parentId: res.parentId, - bookmarks: res.bookmarksInLists.map((b) => b.bookmarkId), + type: res.type, + query: res.query, }; }), list: authedProcedure |
