aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
Diffstat (limited to 'apps')
-rw-r--r--apps/web/components/settings/ImportExport.tsx88
-rw-r--r--apps/web/lib/importBookmarkParser.ts38
2 files changed, 102 insertions, 24 deletions
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<string, ParsedBookmark>();
+ 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];
+}