From 267db791290f4f539d7bda113992e3d1690b0e8b Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sat, 27 Dec 2025 11:59:39 +0200 Subject: feat: support archiving as pdf (#2309) * feat: support archiving as pdf * add supprot for manually triggering pdf downloads * fix submenu * menu cleanup * fix store pdf --- .../dashboard/bookmarks/BookmarkOptions.tsx | 155 +++++++++++++++++---- .../dashboard/preview/LinkContentSection.tsx | 19 +++ apps/web/lib/attachments.tsx | 2 + apps/web/lib/i18n/locales/en/translation.json | 4 + apps/web/lib/i18n/locales/en_US/translation.json | 4 + apps/workers/workerUtils.ts | 2 + apps/workers/workers/crawlerWorker.ts | 111 ++++++++++++++- .../03-configuration/01-environment-variables.md | 1 + packages/db/schema.ts | 2 + packages/open-api/karakeep-openapi-spec.json | 7 + packages/shared-server/src/queues.ts | 1 + packages/shared/config.ts | 2 + packages/shared/types/bookmarks.ts | 2 + packages/trpc/lib/attachments.ts | 5 + packages/trpc/models/bookmarks.ts | 4 + packages/trpc/routers/bookmarks.ts | 2 + 16 files changed, 290 insertions(+), 33 deletions(-) diff --git a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx index 66de6156..eb746efc 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkOptions.tsx @@ -6,13 +6,18 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useToast } from "@/components/ui/use-toast"; import { useClientConfig } from "@/lib/clientConfig"; import { useTranslation } from "@/lib/i18n/client"; import { + Archive, FileDown, + FileText, Link, List, ListX, @@ -43,6 +48,30 @@ import { EditBookmarkDialog } from "./EditBookmarkDialog"; import { ArchivedActionIcon, FavouritedActionIcon } from "./icons"; import { useManageListsModal } from "./ManageListsModal"; +interface ActionItem { + id: string; + title: string; + icon: React.ReactNode; + visible: boolean; + disabled: boolean; + className?: string; + onClick: () => void; +} + +interface SubsectionItem { + id: string; + title: string; + icon: React.ReactNode; + visible: boolean; + items: ActionItem[]; +} + +type ActionItemType = ActionItem | SubsectionItem; + +function isSubsectionItem(item: ActionItemType): item is SubsectionItem { + return "items" in item; +} + export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { const { t } = useTranslation(); const { toast } = useToast(); @@ -110,6 +139,15 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { onError, }); + const preservePdfMutator = useRecrawlBookmark({ + onSuccess: () => { + toast({ + description: t("toasts.bookmarks.preserve_pdf"), + }); + }, + onError, + }); + const removeFromListMutator = useRemoveBookmarkFromList({ onSuccess: () => { toast({ @@ -120,7 +158,7 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { }); // Define action items array - const actionItems = [ + const actionItems: ActionItemType[] = [ { id: "edit", title: t("actions.edit"), @@ -173,19 +211,6 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { archived: !bookmark.archived, }), }, - { - id: "download-full-page", - title: t("actions.download_full_page_archive"), - icon: , - visible: isOwner && bookmark.content.type === BookmarkTypes.LINK, - disabled: false, - onClick: () => { - fullPageArchiveBookmarkMutator.mutate({ - bookmarkId: bookmark.id, - archiveFullPage: true, - }); - }, - }, { id: "copy-link", title: t("actions.copy_link"), @@ -213,14 +238,15 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { id: "remove-from-list", title: t("actions.remove_from_list"), icon: , - visible: + visible: Boolean( (isOwner || (withinListContext && (withinListContext.userRole === "editor" || withinListContext.userRole === "owner"))) && - !!listId && - !!withinListContext && - withinListContext.type === "manual", + !!listId && + !!withinListContext && + withinListContext.type === "manual", + ), disabled: demoMode, onClick: () => removeFromListMutator.mutate({ @@ -236,6 +262,40 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { disabled: demoMode, onClick: () => crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id }), }, + { + id: "offline-copies", + title: t("actions.offline_copies"), + icon: , + visible: isOwner && bookmark.content.type === BookmarkTypes.LINK, + items: [ + { + id: "download-full-page", + title: t("actions.download_full_page_archive"), + icon: , + visible: true, + disabled: demoMode, + onClick: () => { + fullPageArchiveBookmarkMutator.mutate({ + bookmarkId: bookmark.id, + archiveFullPage: true, + }); + }, + }, + { + id: "preserve-pdf", + title: t("actions.preserve_as_pdf"), + icon: , + visible: true, + disabled: demoMode, + onClick: () => { + preservePdfMutator.mutate({ + bookmarkId: bookmark.id, + storePdf: true, + }); + }, + }, + ], + }, { id: "delete", title: t("actions.delete"), @@ -248,7 +308,12 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { ]; // Filter visible items - const visibleItems = actionItems.filter((item) => item.visible); + const visibleItems: ActionItemType[] = actionItems.filter((item) => { + if (isSubsectionItem(item)) { + return item.visible && item.items.some((subItem) => subItem.visible); + } + return item.visible; + }); // If no items are visible, don't render the dropdown if (visibleItems.length === 0) { @@ -283,17 +348,47 @@ export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) { - {visibleItems.map((item) => ( - - {item.icon} - {item.title} - - ))} + {visibleItems.map((item) => { + if (isSubsectionItem(item)) { + const visibleSubItems = item.items.filter( + (subItem) => subItem.visible, + ); + if (visibleSubItems.length === 0) { + return null; + } + return ( + + + {item.icon} + {item.title} + + + {visibleSubItems.map((subItem) => ( + + {subItem.icon} + {subItem.title} + + ))} + + + ); + } + return ( + + {item.icon} + {item.title} + + ); + })} diff --git a/apps/web/components/dashboard/preview/LinkContentSection.tsx b/apps/web/components/dashboard/preview/LinkContentSection.tsx index bdf5faf1..5fb51784 100644 --- a/apps/web/components/dashboard/preview/LinkContentSection.tsx +++ b/apps/web/components/dashboard/preview/LinkContentSection.tsx @@ -24,6 +24,7 @@ import { BookOpen, Camera, ExpandIcon, + FileText, Info, Video, } from "lucide-react"; @@ -104,6 +105,16 @@ function VideoSection({ link }: { link: ZBookmarkedLink }) { ); } +function PDFSection({ link }: { link: ZBookmarkedLink }) { + return ( +