From af631eb2fd211839ccb681fbb2df23ec69058fbe Mon Sep 17 00:00:00 2001 From: MohamedBassem Date: Mon, 7 Apr 2025 01:21:17 +0100 Subject: fix: Do clientside import dedup and parallelize import calls --- apps/web/components/settings/ImportExport.tsx | 88 +++++++++++++++++++-------- apps/web/lib/importBookmarkParser.ts | 38 ++++++++++++ 2 files changed, 102 insertions(+), 24 deletions(-) (limited to 'apps') diff --git a/apps/web/components/settings/ImportExport.tsx b/apps/web/components/settings/ImportExport.tsx index 48a3758d..3d78a7b4 100644 --- a/apps/web/components/settings/ImportExport.tsx +++ b/apps/web/components/settings/ImportExport.tsx @@ -9,6 +9,7 @@ import { Progress } from "@/components/ui/progress"; import { toast } from "@/components/ui/use-toast"; import { useTranslation } from "@/lib/i18n/client"; import { + deduplicateBookmarks, ParsedBookmark, parseHoarderBookmarkFile, parseLinkwardenBookmarkFile, @@ -29,6 +30,7 @@ import { useAddBookmarkToList, useCreateBookmarkList, } from "@hoarder/shared-react/hooks/lists"; +import { limitConcurrency } from "@hoarder/shared/concurrency"; import { BookmarkTypes } from "@hoarder/shared/types/bookmarks"; import { Card, CardContent } from "../ui/card"; @@ -175,47 +177,84 @@ export function ImportExportRow() { throw new Error("Unknown source"); } }, - onSuccess: async (resp) => { + onSuccess: async (parsedBookmarks) => { + if (parsedBookmarks.length === 0) { + toast({ description: "No bookmarks found in the file." }); + return; + } + const importList = await createList({ name: t("settings.import.imported_bookmarks"), icon: "⬆️", }); - setImportProgress({ done: 0, total: resp.length }); - const successes = []; - const failed = []; - const alreadyExisted = []; - // Do the imports one by one - for (const parsedBookmark of resp) { - try { - const result = await parseAndCreateBookmark({ - bookmark: parsedBookmark, + const finalBookmarksToImport = deduplicateBookmarks(parsedBookmarks); + + setImportProgress({ done: 0, total: finalBookmarksToImport.length }); + + const importPromises = finalBookmarksToImport.map( + (bookmark) => () => + parseAndCreateBookmark({ + bookmark: bookmark, listId: importList.id, - }); - if (result.alreadyExists) { - alreadyExisted.push(parsedBookmark); + }).then( + (value) => { + setImportProgress((prev) => { + const newDone = (prev?.done ?? 0) + 1; + return { + done: newDone, + total: finalBookmarksToImport.length, + }; + }); + return { status: "fulfilled" as const, value }; + }, + () => { + 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.push(parsedBookmark); + successes++; } - } catch (e) { - failed.push(parsedBookmark); + } else { + failures++; } - setImportProgress((prev) => ({ - done: (prev?.done ?? 0) + 1, - total: resp.length, - })); } - if (successes.length > 0 || alreadyExisted.length > 0) { + if (successes > 0 || alreadyExisted > 0) { toast({ - description: `Imported ${successes.length} bookmarks and skipped ${alreadyExisted.length} bookmarks that already existed`, + description: `Imported ${successes} bookmarks and skipped ${alreadyExisted} bookmarks that already existed`, variant: "default", }); } - if (failed.length > 0) { + if (failures > 0) { toast({ - description: `Failed to import ${failed.length} bookmarks`, + description: `Failed to import ${failures} bookmarks. Check console for details.`, variant: "destructive", }); } @@ -223,6 +262,7 @@ export function ImportExportRow() { router.push(`/dashboard/lists/${importList.id}`); }, onError: (error) => { + setImportProgress(null); // Clear progress on initial parsing error toast({ description: error.message, variant: "destructive", diff --git a/apps/web/lib/importBookmarkParser.ts b/apps/web/lib/importBookmarkParser.ts index 69b8a78c..0f0797d2 100644 --- a/apps/web/lib/importBookmarkParser.ts +++ b/apps/web/lib/importBookmarkParser.ts @@ -176,3 +176,41 @@ export async function parseLinkwardenBookmarkFile( })); }); } + +export function deduplicateBookmarks( + bookmarks: ParsedBookmark[], +): ParsedBookmark[] { + const deduplicatedBookmarksMap = new Map(); + const textBookmarks: ParsedBookmark[] = []; + + for (const bookmark of bookmarks) { + if (bookmark.content?.type === BookmarkTypes.LINK) { + const url = bookmark.content.url; + if (deduplicatedBookmarksMap.has(url)) { + const existing = deduplicatedBookmarksMap.get(url)!; + // Merge tags + existing.tags = [...new Set([...existing.tags, ...bookmark.tags])]; + // Keep earliest date + const existingDate = existing.addDate ?? Infinity; + const newDate = bookmark.addDate ?? Infinity; + if (newDate < existingDate) { + existing.addDate = bookmark.addDate; + } + // Append notes if both exist + if (existing.notes && bookmark.notes) { + existing.notes = `${existing.notes}\n---\n${bookmark.notes}`; + } else if (bookmark.notes) { + existing.notes = bookmark.notes; + } + // Title: keep existing one for simplicity + } else { + deduplicatedBookmarksMap.set(url, bookmark); + } + } else { + // Keep text bookmarks as they are (no URL to dedupe on) + textBookmarks.push(bookmark); + } + } + + return [...deduplicatedBookmarksMap.values(), ...textBookmarks]; +} -- cgit v1.2.3-70-g09d2