aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-06-01 22:43:13 +0000
committerMohamed Bassem <me@mbassem.com>2025-06-01 22:43:13 +0000
commit1bae66f7785289818eba3249f651a320f497c6a4 (patch)
tree9c01aca6f2d8924d912f05d90e3e8e768479740d
parente59be245d5e3005b5b5dadf78ad7115cc800c663 (diff)
downloadkarakeep-1bae66f7785289818eba3249f651a320f497c6a4.tar.zst
feat: Maintain list structure when importing from netscape. Fixes #538
-rw-r--r--apps/web/components/settings/ImportExport.tsx131
-rw-r--r--apps/web/lib/importBookmarkParser.ts22
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) {