diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-06-01 22:43:13 +0000 |
|---|---|---|
| committer | Mohamed Bassem <me@mbassem.com> | 2025-06-01 22:43:13 +0000 |
| commit | 1bae66f7785289818eba3249f651a320f497c6a4 (patch) | |
| tree | 9c01aca6f2d8924d912f05d90e3e8e768479740d | |
| parent | e59be245d5e3005b5b5dadf78ad7115cc800c663 (diff) | |
| download | karakeep-1bae66f7785289818eba3249f651a320f497c6a4.tar.zst | |
feat: Maintain list structure when importing from netscape. Fixes #538
| -rw-r--r-- | apps/web/components/settings/ImportExport.tsx | 131 | ||||
| -rw-r--r-- | apps/web/lib/importBookmarkParser.ts | 22 |
2 files changed, 108 insertions, 45 deletions
diff --git a/apps/web/components/settings/ImportExport.tsx b/apps/web/components/settings/ImportExport.tsx index a20bd554..35c2b88f 100644 --- a/apps/web/components/settings/ImportExport.tsx +++ b/apps/web/components/settings/ImportExport.tsx @@ -27,7 +27,6 @@ import { } from "@/lib/importBookmarkParser"; import { cn } from "@/lib/utils"; import { useMutation } from "@tanstack/react-query"; -import { TRPCClientError } from "@trpc/client"; import { Download, Upload } from "lucide-react"; import { @@ -125,7 +124,7 @@ export function ImportExportRow() { const { mutateAsync: parseAndCreateBookmark } = useMutation({ mutationFn: async (toImport: { bookmark: ParsedBookmark; - listId: string; + listIds: string[]; }) => { const bookmark = toImport.bookmark; if (bookmark.content === undefined) { @@ -151,20 +150,14 @@ export function ImportExportRow() { await Promise.all([ // Add to import list - addToList({ - bookmarkId: created.id, - listId: toImport.listId, - }).catch((e) => { - if ( - e instanceof TRPCClientError && - e.message.includes("already in the list") - ) { - /* empty */ - } else { - throw e; - } - }), - + ...[ + toImport.listIds.map((listId) => + addToList({ + bookmarkId: created.id, + listId, + }), + ), + ], // Update tags bookmark.tags.length > 0 ? updateTags({ @@ -214,7 +207,7 @@ export function ImportExportRow() { return; } - const importList = await createList({ + const rootList = await createList({ name: t("settings.import.imported_bookmarks"), icon: "⬆️", }); @@ -223,33 +216,83 @@ export function ImportExportRow() { setImportProgress({ done: 0, total: finalBookmarksToImport.length }); + // Precreate folder lists + const allRequiredPaths = new Set<string>(); + // 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<string, string> = {}; + + // 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) => () => - parseAndCreateBookmark({ - bookmark: bookmark, - listId: importList.id, - }).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 }; - }, - ), + (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 (e) { + setImportProgress((prev) => { + const newDone = (prev?.done ?? 0) + 1; + return { + done: newDone, + total: finalBookmarksToImport.length, + }; + }); + return { status: "rejected" as const }; + } + }, ); const CONCURRENCY_LIMIT = 20; @@ -290,7 +333,7 @@ export function ImportExportRow() { }); } - router.push(`/dashboard/lists/${importList.id}`); + router.push(`/dashboard/lists/${rootList.id}`); }, onError: (error) => { setImportProgress(null); // Clear progress on initial parsing error diff --git a/apps/web/lib/importBookmarkParser.ts b/apps/web/lib/importBookmarkParser.ts index aba11689..2e354ffe 100644 --- a/apps/web/lib/importBookmarkParser.ts +++ b/apps/web/lib/importBookmarkParser.ts @@ -16,6 +16,7 @@ export interface ParsedBookmark { addDate?: number; notes?: string; archived?: boolean; + paths: string[][]; } export async function parseNetscapeBookmarkFile( @@ -42,11 +43,24 @@ export async function parseNetscapeBookmarkFile( /* empty */ } const url = $a.attr("href"); + + // Build folder path by traversing up the hierarchy + const path: string[] = []; + let current = $a.parent(); + while (current && current.length > 0) { + const h3 = current.find("> h3").first(); + if (h3.length > 0) { + path.unshift(h3.text()); + } + current = current.parent(); + } + return { title: $a.text(), content: url ? { type: BookmarkTypes.LINK as const, url } : undefined, tags, addDate: typeof addDate === "undefined" ? undefined : parseInt(addDate), + paths: [path], }; }) .get(); @@ -75,6 +89,7 @@ export async function parsePocketBookmarkFile( tags: record.tags.length > 0 ? record.tags.split("|") : [], addDate: parseInt(record.time_added), archived: record.status === "archive", + paths: [], // TODO }; }); } @@ -111,6 +126,7 @@ export async function parseKarakeepBookmarkFile( addDate: bookmark.createdAt, notes: bookmark.note ?? undefined, archived: bookmark.archived, + paths: [], // TODO }; }); } @@ -143,6 +159,7 @@ export async function parseOmnivoreBookmarkFile( tags: bookmark.labels, addDate: bookmark.savedAt.getTime() / 1000, archived: bookmark.state === "Archived", + paths: [], }; }); } @@ -179,6 +196,7 @@ export async function parseLinkwardenBookmarkFile( content: { type: BookmarkTypes.LINK as const, url: bookmark.url }, tags: bookmark.tags.map((tag) => tag.name), addDate: bookmark.createdAt.getTime() / 1000, + paths: [], // TODO })); }); } @@ -219,6 +237,7 @@ export async function parseTabSessionManagerStateFile( content: { type: BookmarkTypes.LINK as const, url: tab.url }, tags: [], addDate: tab.lastAccessed, + paths: [], // Tab Session Manager doesn't have folders })), ); } @@ -236,7 +255,8 @@ export function deduplicateBookmarks( const existing = deduplicatedBookmarksMap.get(url)!; // Merge tags existing.tags = [...new Set([...existing.tags, ...bookmark.tags])]; - // Keep earliest date + // Merge paths + existing.paths = [...existing.paths, ...bookmark.paths]; const existingDate = existing.addDate ?? Infinity; const newDate = bookmark.addDate ?? Infinity; if (newDate < existingDate) { |
