From 1bae66f7785289818eba3249f651a320f497c6a4 Mon Sep 17 00:00:00 2001
From: Mohamed Bassem <me@mbassem.com>
Date: Sun, 1 Jun 2025 22:43:13 +0000
Subject: feat: Maintain list structure when importing from netscape. Fixes
 #538

---
 apps/web/components/settings/ImportExport.tsx | 131 +++++++++++++++++---------
 1 file changed, 87 insertions(+), 44 deletions(-)

(limited to 'apps/web/components/settings/ImportExport.tsx')

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
-- 
cgit v1.2.3-70-g09d2

