From 118ffc6410f269cb04646ef1315409a36df03453 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sat, 26 Jul 2025 13:08:11 +0000 Subject: refactor: Extract the importing logic into its own hook --- apps/web/components/settings/ImportExport.tsx | 260 +------------------------ apps/web/lib/hooks/useBookmarkImport.ts | 266 ++++++++++++++++++++++++++ 2 files changed, 268 insertions(+), 258 deletions(-) create mode 100644 apps/web/lib/hooks/useBookmarkImport.ts (limited to 'apps/web') diff --git a/apps/web/components/settings/ImportExport.tsx b/apps/web/components/settings/ImportExport.tsx index 94703876..ba2e9129 100644 --- a/apps/web/components/settings/ImportExport.tsx +++ b/apps/web/components/settings/ImportExport.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import Link from "next/link"; -import { useRouter } from "next/navigation"; import { buttonVariants } from "@/components/ui/button"; import FilePickerButton from "@/components/ui/file-picker-button"; import { Progress } from "@/components/ui/progress"; @@ -13,33 +12,11 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { toast } from "@/components/ui/use-toast"; +import { useBookmarkImport } from "@/lib/hooks/useBookmarkImport"; import { useTranslation } from "@/lib/i18n/client"; -import { - deduplicateBookmarks, - ParsedBookmark, - parseKarakeepBookmarkFile, - parseLinkwardenBookmarkFile, - parseNetscapeBookmarkFile, - parseOmnivoreBookmarkFile, - parsePocketBookmarkFile, - parseTabSessionManagerStateFile, -} from "@/lib/importBookmarkParser"; import { cn } from "@/lib/utils"; -import { useMutation } from "@tanstack/react-query"; import { Download, Upload } from "lucide-react"; -import { - useCreateBookmarkWithPostHook, - useUpdateBookmarkTags, -} from "@karakeep/shared-react/hooks/bookmarks"; -import { - useAddBookmarkToList, - useCreateBookmarkList, -} from "@karakeep/shared-react/hooks/lists"; -import { limitConcurrency } from "@karakeep/shared/concurrency"; -import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; - import { Card, CardContent } from "../ui/card"; function ImportCard({ @@ -109,240 +86,7 @@ function ExportButton() { export function ImportExportRow() { const { t } = useTranslation(); - const router = useRouter(); - - const [importProgress, setImportProgress] = useState<{ - done: number; - total: number; - } | null>(null); - - const { mutateAsync: createBookmark } = useCreateBookmarkWithPostHook(); - const { mutateAsync: createList } = useCreateBookmarkList(); - const { mutateAsync: addToList } = useAddBookmarkToList(); - const { mutateAsync: updateTags } = useUpdateBookmarkTags(); - - const { mutateAsync: parseAndCreateBookmark } = useMutation({ - mutationFn: async (toImport: { - bookmark: ParsedBookmark; - listIds: string[]; - }) => { - const bookmark = toImport.bookmark; - if (bookmark.content === undefined) { - throw new Error("Content is undefined"); - } - const created = await createBookmark({ - // This is important to avoid blocking the crawling of more important bookmarks - crawlPriority: "low", - title: bookmark.title, - createdAt: bookmark.addDate - ? new Date(bookmark.addDate * 1000) - : undefined, - note: bookmark.notes, - archived: bookmark.archived, - ...(bookmark.content.type === BookmarkTypes.LINK - ? { - type: BookmarkTypes.LINK, - url: bookmark.content.url, - } - : { - type: BookmarkTypes.TEXT, - text: bookmark.content.text, - }), - }); - - await Promise.all([ - // Add to import list - ...toImport.listIds.map((listId) => - addToList({ - bookmarkId: created.id, - listId, - }), - ), - // Update tags - bookmark.tags.length > 0 - ? updateTags({ - bookmarkId: created.id, - attach: bookmark.tags.map((t) => ({ tagName: t })), - detach: [], - }) - : undefined, - ]); - return created; - }, - }); - - const { mutateAsync: runUploadBookmarkFile } = useMutation({ - mutationFn: async ({ - file, - source, - }: { - file: File; - source: - | "html" - | "pocket" - | "omnivore" - | "karakeep" - | "linkwarden" - | "tab-session-manager"; - }) => { - if (source === "html") { - return await parseNetscapeBookmarkFile(file); - } else if (source === "pocket") { - return await parsePocketBookmarkFile(file); - } else if (source === "karakeep") { - return await parseKarakeepBookmarkFile(file); - } else if (source === "omnivore") { - return await parseOmnivoreBookmarkFile(file); - } else if (source === "linkwarden") { - return await parseLinkwardenBookmarkFile(file); - } else if (source === "tab-session-manager") { - return await parseTabSessionManagerStateFile(file); - } else { - throw new Error("Unknown source"); - } - }, - onSuccess: async (parsedBookmarks) => { - if (parsedBookmarks.length === 0) { - toast({ description: "No bookmarks found in the file." }); - return; - } - - const rootList = await createList({ - name: t("settings.import.imported_bookmarks"), - icon: "⬆️", - }); - - const finalBookmarksToImport = deduplicateBookmarks(parsedBookmarks); - - setImportProgress({ done: 0, total: finalBookmarksToImport.length }); - - // Precreate folder lists - const allRequiredPaths = new Set(); - // collect the paths of all bookmarks that have non-empty paths - for (const bookmark of finalBookmarksToImport) { - for (const path of bookmark.paths) { - if (path && path.length > 0) { - // We need every prefix of the path for the hierarchy - for (let i = 1; i <= path.length; i++) { - const subPath = path.slice(0, i); - const pathKey = subPath.join("/"); - allRequiredPaths.add(pathKey); - } - } - } - } - - // Convert to array and sort by depth (so that parent paths come first) - const allRequiredPathsArray = Array.from(allRequiredPaths).sort( - (a, b) => a.split("/").length - b.split("/").length, - ); - - const pathMap: Record = {}; - - // Root list is the parent for top-level folders - // Represent root as empty string - pathMap[""] = rootList.id; - - for (const pathKey of allRequiredPathsArray) { - const parts = pathKey.split("/"); - const parentKey = parts.slice(0, -1).join("/"); - const parentId = pathMap[parentKey] || rootList.id; - - const folderName = parts[parts.length - 1]; - // Create the list - const folderList = await createList({ - name: folderName, - parentId: parentId, - icon: "📁", - }); - pathMap[pathKey] = folderList.id; - } - - const importPromises = finalBookmarksToImport.map( - (bookmark) => async () => { - // Determine the target list ids - const listIds = bookmark.paths.map( - (path) => pathMap[path.join("/")] || rootList.id, - ); - if (listIds.length === 0) { - listIds.push(rootList.id); - } - - try { - const created = await parseAndCreateBookmark({ - bookmark: bookmark, - listIds, - }); - - setImportProgress((prev) => { - const newDone = (prev?.done ?? 0) + 1; - return { - done: newDone, - total: finalBookmarksToImport.length, - }; - }); - return { status: "fulfilled" as const, value: created }; - } catch { - setImportProgress((prev) => { - const newDone = (prev?.done ?? 0) + 1; - return { - done: newDone, - total: finalBookmarksToImport.length, - }; - }); - return { status: "rejected" as const }; - } - }, - ); - - const CONCURRENCY_LIMIT = 20; - const resultsPromises = limitConcurrency( - importPromises, - CONCURRENCY_LIMIT, - ); - - const results = await Promise.all(resultsPromises); - - let successes = 0; - let failures = 0; - let alreadyExisted = 0; - - for (const result of results) { - if (result.status === "fulfilled") { - if (result.value.alreadyExists) { - alreadyExisted++; - } else { - successes++; - } - } else { - failures++; - } - } - - if (successes > 0 || alreadyExisted > 0) { - toast({ - description: `Imported ${successes} bookmarks and skipped ${alreadyExisted} bookmarks that already existed`, - variant: "default", - }); - } - - if (failures > 0) { - toast({ - description: `Failed to import ${failures} bookmarks. Check console for details.`, - variant: "destructive", - }); - } - - router.push(`/dashboard/lists/${rootList.id}`); - }, - onError: (error) => { - setImportProgress(null); // Clear progress on initial parsing error - toast({ - description: error.message, - variant: "destructive", - }); - }, - }); + const { importProgress, runUploadBookmarkFile } = useBookmarkImport(); return (
diff --git a/apps/web/lib/hooks/useBookmarkImport.ts b/apps/web/lib/hooks/useBookmarkImport.ts new file mode 100644 index 00000000..7e5f6111 --- /dev/null +++ b/apps/web/lib/hooks/useBookmarkImport.ts @@ -0,0 +1,266 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "@/components/ui/use-toast"; +import { useTranslation } from "@/lib/i18n/client"; +import { + deduplicateBookmarks, + ParsedBookmark, + parseKarakeepBookmarkFile, + parseLinkwardenBookmarkFile, + parseNetscapeBookmarkFile, + parseOmnivoreBookmarkFile, + parsePocketBookmarkFile, + parseTabSessionManagerStateFile, +} from "@/lib/importBookmarkParser"; +import { useMutation } from "@tanstack/react-query"; + +import { + useCreateBookmarkWithPostHook, + useUpdateBookmarkTags, +} from "@karakeep/shared-react/hooks/bookmarks"; +import { + useAddBookmarkToList, + useCreateBookmarkList, +} from "@karakeep/shared-react/hooks/lists"; +import { limitConcurrency } from "@karakeep/shared/concurrency"; +import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; + +export type ImportSource = + | "html" + | "pocket" + | "omnivore" + | "karakeep" + | "linkwarden" + | "tab-session-manager"; + +export interface ImportProgress { + done: number; + total: number; +} + +export function useBookmarkImport() { + const { t } = useTranslation(); + const router = useRouter(); + + const [importProgress, setImportProgress] = useState( + null, + ); + + const { mutateAsync: createBookmark } = useCreateBookmarkWithPostHook(); + const { mutateAsync: createList } = useCreateBookmarkList(); + const { mutateAsync: addToList } = useAddBookmarkToList(); + const { mutateAsync: updateTags } = useUpdateBookmarkTags(); + + const { mutateAsync: parseAndCreateBookmark } = useMutation({ + mutationFn: async (toImport: { + bookmark: ParsedBookmark; + listIds: string[]; + }) => { + const bookmark = toImport.bookmark; + if (bookmark.content === undefined) { + throw new Error("Content is undefined"); + } + const created = await createBookmark({ + crawlPriority: "low", + title: bookmark.title, + createdAt: bookmark.addDate + ? new Date(bookmark.addDate * 1000) + : undefined, + note: bookmark.notes, + archived: bookmark.archived, + ...(bookmark.content.type === BookmarkTypes.LINK + ? { + type: BookmarkTypes.LINK, + url: bookmark.content.url, + } + : { + type: BookmarkTypes.TEXT, + text: bookmark.content.text, + }), + }); + + await Promise.all([ + ...toImport.listIds.map((listId) => + addToList({ + bookmarkId: created.id, + listId, + }), + ), + bookmark.tags.length > 0 + ? updateTags({ + bookmarkId: created.id, + attach: bookmark.tags.map((t) => ({ tagName: t })), + detach: [], + }) + : undefined, + ]); + return created; + }, + }); + + const uploadBookmarkFileMutation = useMutation({ + mutationFn: async ({ + file, + source, + }: { + file: File; + source: ImportSource; + }) => { + if (source === "html") { + return await parseNetscapeBookmarkFile(file); + } else if (source === "pocket") { + return await parsePocketBookmarkFile(file); + } else if (source === "karakeep") { + return await parseKarakeepBookmarkFile(file); + } else if (source === "omnivore") { + return await parseOmnivoreBookmarkFile(file); + } else if (source === "linkwarden") { + return await parseLinkwardenBookmarkFile(file); + } else if (source === "tab-session-manager") { + return await parseTabSessionManagerStateFile(file); + } else { + throw new Error("Unknown source"); + } + }, + onSuccess: async (parsedBookmarks) => { + if (parsedBookmarks.length === 0) { + toast({ description: "No bookmarks found in the file." }); + return; + } + + const rootList = await createList({ + name: t("settings.import.imported_bookmarks"), + icon: "⬆️", + }); + + const finalBookmarksToImport = deduplicateBookmarks(parsedBookmarks); + + setImportProgress({ done: 0, total: finalBookmarksToImport.length }); + + const allRequiredPaths = new Set(); + for (const bookmark of finalBookmarksToImport) { + for (const path of bookmark.paths) { + if (path && path.length > 0) { + for (let i = 1; i <= path.length; i++) { + const subPath = path.slice(0, i); + const pathKey = subPath.join("/"); + allRequiredPaths.add(pathKey); + } + } + } + } + + const allRequiredPathsArray = Array.from(allRequiredPaths).sort( + (a, b) => a.split("/").length - b.split("/").length, + ); + + const pathMap: Record = {}; + pathMap[""] = rootList.id; + + for (const pathKey of allRequiredPathsArray) { + const parts = pathKey.split("/"); + const parentKey = parts.slice(0, -1).join("/"); + const parentId = pathMap[parentKey] || rootList.id; + + const folderName = parts[parts.length - 1]; + const folderList = await createList({ + name: folderName, + parentId: parentId, + icon: "📁", + }); + pathMap[pathKey] = folderList.id; + } + + const importPromises = finalBookmarksToImport.map( + (bookmark) => async () => { + const listIds = bookmark.paths.map( + (path) => pathMap[path.join("/")] || rootList.id, + ); + if (listIds.length === 0) { + listIds.push(rootList.id); + } + + try { + const created = await parseAndCreateBookmark({ + bookmark: bookmark, + listIds, + }); + + setImportProgress((prev) => { + const newDone = (prev?.done ?? 0) + 1; + return { + done: newDone, + total: finalBookmarksToImport.length, + }; + }); + return { status: "fulfilled" as const, value: created }; + } catch { + setImportProgress((prev) => { + const newDone = (prev?.done ?? 0) + 1; + return { + done: newDone, + total: finalBookmarksToImport.length, + }; + }); + return { status: "rejected" as const }; + } + }, + ); + + const CONCURRENCY_LIMIT = 20; + const resultsPromises = limitConcurrency( + importPromises, + CONCURRENCY_LIMIT, + ); + + const results = await Promise.all(resultsPromises); + + let successes = 0; + let failures = 0; + let alreadyExisted = 0; + + for (const result of results) { + if (result.status === "fulfilled") { + if (result.value.alreadyExists) { + alreadyExisted++; + } else { + successes++; + } + } else { + failures++; + } + } + + if (successes > 0 || alreadyExisted > 0) { + toast({ + description: `Imported ${successes} bookmarks and skipped ${alreadyExisted} bookmarks that already existed`, + variant: "default", + }); + } + + if (failures > 0) { + toast({ + description: `Failed to import ${failures} bookmarks. Check console for details.`, + variant: "destructive", + }); + } + + router.push(`/dashboard/lists/${rootList.id}`); + }, + onError: (error) => { + setImportProgress(null); + toast({ + description: error.message, + variant: "destructive", + }); + }, + }); + + return { + importProgress, + runUploadBookmarkFile: uploadBookmarkFileMutation.mutateAsync, + isImporting: uploadBookmarkFileMutation.isPending, + }; +} -- cgit v1.2.3-70-g09d2