From 4a580d713621f99abb8baabc9b847ce039d44842 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sat, 4 Oct 2025 13:40:24 +0100 Subject: feat: Revamp import experience (#2001) * WIP: import v2 * remove new session button * don't redirect after import * store and lint to root list * models + tests * redesign the progress * simplify the import session for ow * drop status from session schema * split the import session page * i18n * fix test * remove pagination * fix some colors in darkmode * one last fix * add privacy filter * privacy check * fix interactivity of import progress * fix test --- packages/shared/import-export/importer.test.ts | 12 ++++ packages/shared/import-export/importer.ts | 50 +++++++++++------ packages/shared/types/bookmarks.ts | 1 + packages/shared/types/importSessions.ts | 76 ++++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 18 deletions(-) create mode 100644 packages/shared/types/importSessions.ts (limited to 'packages/shared') diff --git a/packages/shared/import-export/importer.test.ts b/packages/shared/import-export/importer.test.ts index 2ea63846..00f892a9 100644 --- a/packages/shared/import-export/importer.test.ts +++ b/packages/shared/import-export/importer.test.ts @@ -85,6 +85,8 @@ describe("importBookmarksFromFile", () => { }, ); + const createImportSession = vi.fn(async () => ({ id: "session-1" })); + const progress: number[] = []; const res = await importBookmarksFromFile( { @@ -96,6 +98,7 @@ describe("importBookmarksFromFile", () => { createBookmark, addBookmarkToLists, updateBookmarkTags, + createImportSession, }, onProgress: (d, t) => progress.push(d / t), }, @@ -167,6 +170,7 @@ describe("importBookmarksFromFile", () => { createBookmark: vi.fn(), addBookmarkToLists: vi.fn(), updateBookmarkTags: vi.fn(), + createImportSession: vi.fn(async () => ({ id: "session-1" })), }, }, { parsers }, @@ -174,6 +178,7 @@ describe("importBookmarksFromFile", () => { expect(res).toEqual({ counts: { successes: 0, failures: 0, alreadyExisted: 0, total: 0 }, rootListId: null, + importSessionId: null, }); }); @@ -244,6 +249,8 @@ describe("importBookmarksFromFile", () => { }, ); + const createImportSession = vi.fn(async () => ({ id: "session-1" })); + const progress: number[] = []; const res = await importBookmarksFromFile( { @@ -255,6 +262,7 @@ describe("importBookmarksFromFile", () => { createBookmark, addBookmarkToLists, updateBookmarkTags, + createImportSession, }, onProgress: (d, t) => progress.push(d / t), }, @@ -353,6 +361,8 @@ describe("importBookmarksFromFile", () => { }, ); + const createImportSession = vi.fn(async () => ({ id: "session-1" })); + const progress: number[] = []; const res = await importBookmarksFromFile( { @@ -364,6 +374,7 @@ describe("importBookmarksFromFile", () => { createBookmark, addBookmarkToLists, updateBookmarkTags, + createImportSession, }, onProgress: (d, t) => progress.push(d / t), }, @@ -371,6 +382,7 @@ describe("importBookmarksFromFile", () => { ); expect(res.rootListId).toBe("Imported"); + expect(res.importSessionId).toBe("session-1"); // All bookmarks are created successfully, but 2 fail in post-processing expect(res.counts).toEqual({ diff --git a/packages/shared/import-export/importer.ts b/packages/shared/import-export/importer.ts index 88c0c3bc..b32c49c1 100644 --- a/packages/shared/import-export/importer.ts +++ b/packages/shared/import-export/importer.ts @@ -17,6 +17,7 @@ export interface ImportDeps { }) => Promise<{ id: string }>; createBookmark: ( bookmark: ParsedBookmark, + sessionId: string, ) => Promise<{ id: string; alreadyExists?: boolean }>; addBookmarkToLists: (input: { bookmarkId: string; @@ -26,6 +27,10 @@ export interface ImportDeps { bookmarkId: string; tags: string[]; }) => Promise; + createImportSession: (input: { + name: string; + rootListId: string; + }) => Promise<{ id: string }>; } export interface ImportOptions { @@ -38,6 +43,7 @@ export interface ImportOptions { export interface ImportResult { counts: ImportCounts; rootListId: string | null; + importSessionId: string | null; } export async function importBookmarksFromFile( @@ -66,10 +72,15 @@ export async function importBookmarksFromFile( return { counts: { successes: 0, failures: 0, alreadyExisted: 0, total: 0 }, rootListId: null, + importSessionId: null, }; } const rootList = await deps.createList({ name: rootListName, icon: "⬆️" }); + const session = await deps.createImportSession({ + name: `${source.charAt(0).toUpperCase() + source.slice(1)} Import - ${new Date().toLocaleDateString()}`, + rootListId: rootList.id, + }); onProgress?.(0, parsedBookmarks.length); @@ -109,22 +120,28 @@ export async function importBookmarksFromFile( pathMap[pathKey] = folderList.id; } + let done = 0; const importPromises = parsedBookmarks.map((bookmark) => async () => { - const listIds = bookmark.paths.map( - (path) => pathMap[path.join(PATH_DELIMITER)] || rootList.id, - ); - if (listIds.length === 0) listIds.push(rootList.id); - - const created = await deps.createBookmark(bookmark); - await deps.addBookmarkToLists({ bookmarkId: created.id, listIds }); - if (bookmark.tags && bookmark.tags.length > 0) { - await deps.updateBookmarkTags({ - bookmarkId: created.id, - tags: bookmark.tags, - }); - } + try { + const listIds = bookmark.paths.map( + (path) => pathMap[path.join(PATH_DELIMITER)] || rootList.id, + ); + if (listIds.length === 0) listIds.push(rootList.id); + + const created = await deps.createBookmark(bookmark, session.id); + await deps.addBookmarkToLists({ bookmarkId: created.id, listIds }); + if (bookmark.tags && bookmark.tags.length > 0) { + await deps.updateBookmarkTags({ + bookmarkId: created.id, + tags: bookmark.tags, + }); + } - return created; + return created; + } finally { + done += 1; + onProgress?.(done, parsedBookmarks.length); + } }); const resultsPromises = limitConcurrency(importPromises, concurrencyLimit); @@ -134,7 +151,6 @@ export async function importBookmarksFromFile( let failures = 0; let alreadyExisted = 0; - let done = 0; for (const r of results) { if (r.status === "fulfilled") { if (r.value.alreadyExists) alreadyExisted++; @@ -142,10 +158,7 @@ export async function importBookmarksFromFile( } else { failures++; } - done += 1; - onProgress?.(done, parsedBookmarks.length); } - return { counts: { successes, @@ -154,5 +167,6 @@ export async function importBookmarksFromFile( total: parsedBookmarks.length, }, rootListId: rootList.id, + importSessionId: session.id, }; } diff --git a/packages/shared/types/bookmarks.ts b/packages/shared/types/bookmarks.ts index a22e7632..71cf1012 100644 --- a/packages/shared/types/bookmarks.ts +++ b/packages/shared/types/bookmarks.ts @@ -142,6 +142,7 @@ export const zNewBookmarkRequestSchema = z // A mechanism to prioritize crawling of bookmarks depending on whether // they were created by a user interaction or by a bulk import. crawlPriority: z.enum(["low", "normal"]).optional(), + importSessionId: z.string().optional(), }) .and( z.discriminatedUnion("type", [ diff --git a/packages/shared/types/importSessions.ts b/packages/shared/types/importSessions.ts new file mode 100644 index 00000000..0c1edd03 --- /dev/null +++ b/packages/shared/types/importSessions.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; + +export const zImportSessionStatusSchema = z.enum([ + "pending", + "in_progress", + "completed", + "failed", +]); +export type ZImportSessionStatus = z.infer; + +export const zImportSessionBookmarkStatusSchema = z.enum([ + "pending", + "processing", + "completed", + "failed", +]); +export type ZImportSessionBookmarkStatus = z.infer< + typeof zImportSessionBookmarkStatusSchema +>; + +export const zImportSessionSchema = z.object({ + id: z.string(), + name: z.string(), + userId: z.string(), + message: z.string().nullable(), + rootListId: z.string().nullable(), + createdAt: z.date(), + modifiedAt: z.date().nullable(), +}); +export type ZImportSession = z.infer; + +export const zImportSessionWithStatsSchema = zImportSessionSchema.extend({ + status: z.enum(["pending", "in_progress", "completed", "failed"]), + totalBookmarks: z.number(), + completedBookmarks: z.number(), + failedBookmarks: z.number(), + pendingBookmarks: z.number(), + processingBookmarks: z.number(), +}); +export type ZImportSessionWithStats = z.infer< + typeof zImportSessionWithStatsSchema +>; + +export const zCreateImportSessionRequestSchema = z.object({ + name: z.string().min(1).max(255), + rootListId: z.string().optional(), +}); +export type ZCreateImportSessionRequest = z.infer< + typeof zCreateImportSessionRequestSchema +>; + +export const zGetImportSessionStatsRequestSchema = z.object({ + importSessionId: z.string(), +}); +export type ZGetImportSessionStatsRequest = z.infer< + typeof zGetImportSessionStatsRequestSchema +>; + +export const zListImportSessionsRequestSchema = z.object({}); +export type ZListImportSessionsRequest = z.infer< + typeof zListImportSessionsRequestSchema +>; + +export const zListImportSessionsResponseSchema = z.object({ + sessions: z.array(zImportSessionWithStatsSchema), +}); +export type ZListImportSessionsResponse = z.infer< + typeof zListImportSessionsResponseSchema +>; + +export const zDeleteImportSessionRequestSchema = z.object({ + importSessionId: z.string(), +}); +export type ZDeleteImportSessionRequest = z.infer< + typeof zDeleteImportSessionRequestSchema +>; -- cgit v1.2.3-70-g09d2