From 0c80f515ec9e20c70b69380031886ccc0e4bc06d Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sat, 15 Nov 2025 16:47:20 +0000 Subject: feat: import from mymind (#2138) * feat: add mymind importer support This commit adds support for importing bookmarks from mymind CSV exports. Changes: - Added mymind to ImportSource type in parsers.ts - Implemented parseMymindBookmarkFile() to parse mymind CSV format - Added mymind case to parseImportFile() switch statement - Added mymind import card to ImportExport UI component - Added English translation for mymind import description - Added comprehensive test for mymind CSV parsing The mymind CSV format includes: - WebPages (URLs with optional notes) - Notes (text content without URLs) - Tags (comma-separated) - Created timestamps (ISO format) Fixes #654 * format * use zod for parsing --------- Co-authored-by: Claude --- packages/shared/import-export/importer.test.ts | 79 ++++++++++++++++++++++++++ packages/shared/import-export/parsers.ts | 67 +++++++++++++++++++++- 2 files changed, 145 insertions(+), 1 deletion(-) (limited to 'packages/shared/import-export') diff --git a/packages/shared/import-export/importer.test.ts b/packages/shared/import-export/importer.test.ts index 00f892a9..48cd1204 100644 --- a/packages/shared/import-export/importer.test.ts +++ b/packages/shared/import-export/importer.test.ts @@ -401,4 +401,83 @@ describe("importBookmarksFromFile", () => { // updateBookmarkTags should be called 2 times (once fails at list assignment, one fails at tag update) expect(updateBookmarkTags).toHaveBeenCalledTimes(2); }); + + it("parses mymind CSV export correctly", async () => { + const mymindCsv = `id,type,title,url,content,note,tags,created +1pYm0O0hY4WnmKN,WebPage,mymind,https://access.mymind.com/everything,,,"Wellness,Self-Improvement,Psychology",2024-12-04T23:02:10Z +1pYm0O0hY5ltduL,WebPage,Movies / TV / Anime,https://fmhy.pages.dev/videopiracyguide,,"Free Media!","Tools,media,Entertainment",2024-12-04T23:02:32Z +1pYm0O0hY8oFq9C,Note,,,"• Critical Thinking +• Empathy",,,2024-12-04T23:05:23Z`; + + const mockFile = { + text: vi.fn().mockResolvedValue(mymindCsv), + } as unknown as File; + + const createdBookmarks: ParsedBookmark[] = []; + const createBookmark = vi.fn(async (bookmark: ParsedBookmark) => { + createdBookmarks.push(bookmark); + return { + id: `bookmark-${createdBookmarks.length}`, + alreadyExists: false, + }; + }); + + const res = await importBookmarksFromFile({ + file: mockFile, + source: "mymind", + rootListName: "mymind Import", + deps: { + createList: vi.fn( + async (input: { name: string; icon: string; parentId?: string }) => ({ + id: `${input.parentId ? input.parentId + "/" : ""}${input.name}`, + }), + ), + createBookmark, + addBookmarkToLists: vi.fn(), + updateBookmarkTags: vi.fn(), + createImportSession: vi.fn(async () => ({ id: "session-1" })), + }, + }); + + expect(res.counts).toEqual({ + successes: 3, + failures: 0, + alreadyExisted: 0, + total: 3, + }); + + // Verify first bookmark (WebPage with URL) + expect(createdBookmarks[0]).toMatchObject({ + title: "mymind", + content: { + type: "link", + url: "https://access.mymind.com/everything", + }, + tags: ["Wellness", "Self-Improvement", "Psychology"], + }); + expect(createdBookmarks[0].addDate).toBeCloseTo( + new Date("2024-12-04T23:02:10Z").getTime() / 1000, + ); + + // Verify second bookmark (WebPage with note) + expect(createdBookmarks[1]).toMatchObject({ + title: "Movies / TV / Anime", + content: { + type: "link", + url: "https://fmhy.pages.dev/videopiracyguide", + }, + tags: ["Tools", "media", "Entertainment"], + notes: "Free Media!", + }); + + // Verify third bookmark (Note with text content) + expect(createdBookmarks[2]).toMatchObject({ + title: "", + content: { + type: "text", + text: "• Critical Thinking\n• Empathy", + }, + tags: [], + }); + }); }); diff --git a/packages/shared/import-export/parsers.ts b/packages/shared/import-export/parsers.ts index c969c615..f4d3f862 100644 --- a/packages/shared/import-export/parsers.ts +++ b/packages/shared/import-export/parsers.ts @@ -13,7 +13,8 @@ export type ImportSource = | "omnivore" | "karakeep" | "linkwarden" - | "tab-session-manager"; + | "tab-session-manager" + | "mymind"; export interface ParsedBookmark { title: string; @@ -230,6 +231,67 @@ function parseTabSessionManagerStateFile( ); } +function parseMymindBookmarkFile(textContent: string): ParsedBookmark[] { + const zMymindRecordSchema = z.object({ + id: z.string(), + type: z.string(), + title: z.string(), + url: z.string(), + content: z.string(), + note: z.string(), + tags: z.string(), + created: z.string(), + }); + + const zMymindExportSchema = z.array(zMymindRecordSchema); + + const records = parse(textContent, { + columns: true, + skip_empty_lines: true, + }); + + const parsed = zMymindExportSchema.safeParse(records); + if (!parsed.success) { + throw new Error( + `The uploaded CSV file contains an invalid mymind bookmark file: ${parsed.error.toString()}`, + ); + } + + return parsed.data.map((record) => { + // Determine content type based on presence of URL and content fields + let content: ParsedBookmark["content"]; + if (record.url && record.url.trim().length > 0) { + content = { type: BookmarkTypes.LINK as const, url: record.url.trim() }; + } else if (record.content && record.content.trim().length > 0) { + content = { + type: BookmarkTypes.TEXT as const, + text: record.content.trim(), + }; + } + + // Parse tags from comma-separated string + const tags = + record.tags && record.tags.trim().length > 0 + ? record.tags.split(",").map((tag) => tag.trim()) + : []; + + // Parse created date to timestamp (in seconds) + const addDate = record.created + ? new Date(record.created).getTime() / 1000 + : undefined; + + return { + title: record.title || "", + content, + tags, + addDate, + notes: + record.note && record.note.trim().length > 0 ? record.note : undefined, + paths: [], // mymind doesn't have folder structure + }; + }); +} + function deduplicateBookmarks(bookmarks: ParsedBookmark[]): ParsedBookmark[] { const deduplicatedBookmarksMap = new Map(); const textBookmarks: ParsedBookmark[] = []; @@ -295,6 +357,9 @@ export function parseImportFile( case "tab-session-manager": result = parseTabSessionManagerStateFile(textContent); break; + case "mymind": + result = parseMymindBookmarkFile(textContent); + break; } return deduplicateBookmarks(result); } -- cgit v1.2.3-70-g09d2