From aed0d55398a3bc86f1b3dc7af94dc13e58f92dac Mon Sep 17 00:00:00 2001 From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:44:38 -0700 Subject: [PATCH] Desktop: Resolves #16148: Evernote import: Support importing cross-notebook links in some cases (#16151) Co-authored-by: Laurent Cozic --- .gitignore | 1 + .ignore.eslint | 1 + .../tests/enex_to_md/links/notebook1.enex | 28 +++++++++ .../tests/enex_to_md/links/notebook2.enex | 40 +++++++++++++ packages/lib/import-enex.ts | 48 ++++++++++++---- packages/lib/models/Note.test.ts | 18 ++++++ packages/lib/models/Note.ts | 20 +++++++ .../InteropService_Importer_EnexToMd.test.ts | 57 +++++++++++++++++++ .../InteropService_Importer_EnexToMd.ts | 35 +++++++++++- 9 files changed, 235 insertions(+), 13 deletions(-) create mode 100644 packages/app-cli/tests/enex_to_md/links/notebook1.enex create mode 100644 packages/app-cli/tests/enex_to_md/links/notebook2.enex create mode 100644 packages/lib/services/interop/InteropService_Importer_EnexToMd.test.ts diff --git a/.gitignore b/.gitignore index 93d614398d..625386c4f5 100644 --- a/.gitignore +++ b/.gitignore @@ -1697,6 +1697,7 @@ packages/lib/services/interop/InteropService_Exporter_Raw.js packages/lib/services/interop/InteropService_Importer_Base.js packages/lib/services/interop/InteropService_Importer_Custom.js packages/lib/services/interop/InteropService_Importer_EnexToHtml.js +packages/lib/services/interop/InteropService_Importer_EnexToMd.test.js packages/lib/services/interop/InteropService_Importer_EnexToMd.js packages/lib/services/interop/InteropService_Importer_Jex.js packages/lib/services/interop/InteropService_Importer_Md.test.js diff --git a/.ignore.eslint b/.ignore.eslint index d75a138376..ac6ccff8bb 100644 --- a/.ignore.eslint +++ b/.ignore.eslint @@ -1723,6 +1723,7 @@ packages/lib/services/interop/InteropService_Exporter_Raw.js packages/lib/services/interop/InteropService_Importer_Base.js packages/lib/services/interop/InteropService_Importer_Custom.js packages/lib/services/interop/InteropService_Importer_EnexToHtml.js +packages/lib/services/interop/InteropService_Importer_EnexToMd.test.js packages/lib/services/interop/InteropService_Importer_EnexToMd.js packages/lib/services/interop/InteropService_Importer_Jex.js packages/lib/services/interop/InteropService_Importer_Md.test.js diff --git a/packages/app-cli/tests/enex_to_md/links/notebook1.enex b/packages/app-cli/tests/enex_to_md/links/notebook1.enex new file mode 100644 index 0000000000..a6949f0525 --- /dev/null +++ b/packages/app-cli/tests/enex_to_md/links/notebook1.enex @@ -0,0 +1,28 @@ + + + + + Note 2 + 20260805T185318Z + 20260805T200333Z + + author + + + +
 
This is a test.

Link to a note in another notebook: Test
Link to a note in the same notebook: Example note
]]> +
+
+ + Example note + 20260805T200313Z + 20260805T200320Z + + author + + + +
 
Testing!
]]> +
+
+
diff --git a/packages/app-cli/tests/enex_to_md/links/notebook2.enex b/packages/app-cli/tests/enex_to_md/links/notebook2.enex new file mode 100644 index 0000000000..a825834ded --- /dev/null +++ b/packages/app-cli/tests/enex_to_md/links/notebook2.enex @@ -0,0 +1,40 @@ + + + + + Test + 20260805T184526Z + 20260805T200439Z + + author + + + +
 

]]> +
+
+ + Testing + 20260805T185202Z + 20260805T200413Z + + author + + + +
 
Test...


]]> +
+
+ + Test note + 20260805T184449Z + 20260805T184526Z + + author + + + +
 
This is a test note!
  • Test


]]> +
+
+
diff --git a/packages/lib/import-enex.ts b/packages/lib/import-enex.ts index 846c14bf3b..6c850cab7e 100644 --- a/packages/lib/import-enex.ts +++ b/packages/lib/import-enex.ts @@ -121,6 +121,7 @@ interface ExtractedNote extends NoteEntity { tags?: string[]; title?: string; bodyXml?: string; + evernoteId?: string; } // Those are the notes that have been parsed and saved to Joplin. We don't keep @@ -308,30 +309,38 @@ const preProcessFile = async (filePath: string): Promise => { }; const isEvernoteUrl = (url: string) => { - return url.toLowerCase().startsWith('evernote://'); + url = url.toLowerCase(); + return url.startsWith('evernote://') || url.startsWith('https://share.evernote.com/note/'); }; -const restoreNoteLinks = async (notes: SavedNote[], noteTitlesToIds: Record, importOptions: ImportOptions) => { +type NoteId = string; +type OnLoadNoteIdsByTitle = (title: string)=> NoteId[]|Promise; + +export const restoreEnexNoteLinks = async (notes: AsyncIterable, noteTitlesToIds: OnLoadNoteIdsByTitle, importOptions: ImportOptions) => { // -------------------------------------------------------- // Convert the Evernote note links to Joplin note links. If // we don't find a matching note, or if there are multiple // matching notes, we leave the Evernote links as is. // -------------------------------------------------------- - for (const note of notes) { + const noteIdsWithUnresolvedLinks = []; + for await (const note of notes) { const links = importOptions.outputFormat === 'html' ? extractUrlsFromHtml(note.body) : extractUrlsFromMarkdown(note.body); let noteChanged = false; + let hasUnresolvedLink = false; for (const link of links) { if (!isEvernoteUrl(link.url)) continue; - const matchingNoteIds = noteTitlesToIds[link.title]; - if (matchingNoteIds && matchingNoteIds.length === 1) { + const matchingNoteIds = await noteTitlesToIds(link.title); + if (matchingNoteIds.length === 1) { note.body = note.body.replace(link.url, `:/${matchingNoteIds[0]}`); noteChanged = true; + } else { + hasUnresolvedLink = true; } } @@ -344,12 +353,18 @@ const restoreNoteLinks = async (notes: SavedNote[], noteTitlesToIds: Record; + noteTitlesToIds: Map; } const parseNotes = async (parentFolderId: string, filePath: string, importOptions: ImportOptions = null): Promise => { @@ -402,7 +417,7 @@ const parseNotes = async (parentFolderId: string, filePath: string, importOption let processingNotes = false; const savedNotes: SavedNote[] = []; const createdNoteIds: string[] = []; - const noteTitlesToIds: Record = {}; + const noteTitlesToIds = new Map(); const createErrorWithNoteTitle = (fnThis: { _parser?: { line: number; column: number } } | null, error: Error) => { const line = []; @@ -495,8 +510,8 @@ const parseNotes = async (parentFolderId: string, filePath: string, importOption const result = await saveNoteToStorage(note); createdNoteIds.push(note.id); - if (!noteTitlesToIds[note.title]) noteTitlesToIds[note.title] = []; - noteTitlesToIds[note.title].push(note.id); + if (!noteTitlesToIds.has(note.title)) noteTitlesToIds.set(note.title, []); + noteTitlesToIds.get(note.title).push(note.id); savedNotes.push({ id: note.id, body: note.body, @@ -722,5 +737,18 @@ export default async function importEnex(parentFolderId: string, filePath: strin if (!('batchSize' in importOptions)) importOptions.batchSize = 10; const result = await parseNotes(parentFolderId, filePath, importOptions); - await restoreNoteLinks(result.savedNotes, result.noteTitlesToIds, importOptions); + + const noteIterator = (async function*() { + for (const note of result.savedNotes) { + yield note; + } + })(); + const titleToIds = (title: string) => result.noteTitlesToIds.get(title) ?? []; + + const { noteIdsWithUnresolvedLinks } = await restoreEnexNoteLinks( + noteIterator, + titleToIds, + importOptions, + ); + return { noteIdsWithUnresolvedLinks, parentFolderId }; } diff --git a/packages/lib/models/Note.test.ts b/packages/lib/models/Note.test.ts index 54f2ac765a..e46495a9b7 100644 --- a/packages/lib/models/Note.test.ts +++ b/packages/lib/models/Note.test.ts @@ -976,4 +976,22 @@ describe('models/Note', () => { const revisionsCountAfter = await Revision.countRevisions(Note.modelType(), note.id); expect(revisionsCountAfter).toBe(1); })); + + test('allByTitleAndParent should query by title and parent ID', async () => { + const folder1 = await Folder.save({ title: 'folder1' }); + const folder2 = await Folder.save({ title: 'folder2' }); + const note1 = await Note.save({ title: 'note', parent_id: folder1.id }); + await Note.save({ title: 'note', parent_id: folder2.id }); + + // Filtering on no parents should return an empty array + expect( + await Note.allByTitleAndParent({ title: 'note', whereParentIn: [], fields: ['id'] }), + ).toEqual([]); + + const allInFolder1 = (await Note.allByTitleAndParent({ + title: 'note', whereParentIn: [folder1.id], fields: ['id'], + })).map(n => n.id); + + expect(allInFolder1).toEqual([note1.id]); + }); }); diff --git a/packages/lib/models/Note.ts b/packages/lib/models/Note.ts index 64a88a987a..3fada4368b 100644 --- a/packages/lib/models/Note.ts +++ b/packages/lib/models/Note.ts @@ -55,6 +55,12 @@ export interface PreviewsOptions { titlePattern?: string; } +interface ByTitleAndParentOptions { + title: string; + whereParentIn: string[]; + fields: string[]; +} + export default class Note extends BaseItem { public static defaultIntevalBetweenNotes = 60 * 60 * 1000; @@ -1289,4 +1295,18 @@ export default class Note extends BaseItem { return Number(folder.order ?? 0) - this.defaultIntevalBetweenNotes; } } + + public static async allByTitleAndParent({ title, whereParentIn: parentIds, fields }: ByTitleAndParentOptions) { + // Avoids invalid SQL when parentIds is empty: + if (parentIds.length === 0) return []; + + const sql = ` + SELECT ${this.db().escapeFieldsToString(fields)} + FROM \`${this.tableName()}\` + WHERE + \`title\` = ? + AND \`parent_id\` IN (${this.escapeIdsForSql(parentIds)}) + `; + return this.modelSelectAll(sql, [title]); + } } diff --git a/packages/lib/services/interop/InteropService_Importer_EnexToMd.test.ts b/packages/lib/services/interop/InteropService_Importer_EnexToMd.test.ts new file mode 100644 index 0000000000..178483a411 --- /dev/null +++ b/packages/lib/services/interop/InteropService_Importer_EnexToMd.test.ts @@ -0,0 +1,57 @@ +import Note from '../../models/Note'; +import { setupDatabaseAndSynchronizer, supportDir, switchClient } from '../../testing/test-utils'; +import { ImportModuleOutputFormat, ImportOptions } from './types'; +import InteropService from './InteropService'; +import Folder from '../../models/Folder'; + +const importFolder = async (path: string) => { + const importOptions: ImportOptions = { + path: path, + format: 'enex', + outputFormat: ImportModuleOutputFormat.Markdown, + }; + + await InteropService.instance().import(importOptions); +}; + +const importTestFile = async (name: string) => { + const enexSampleBaseDir = `${supportDir}/../enex_to_md`; + await importFolder(`${enexSampleBaseDir}/${name}`); +}; + +describe('InteropService_Importer_EnexToMd', () => { + beforeEach(async () => { + await setupDatabaseAndSynchronizer(1); + await switchClient(1); + }); + + it('should resolve cross-notebook links', async () => { + await importTestFile('links/'); + + const folders = await Folder.all({ order: [{ by: 'title', dir: 'ASC' }] }); + expect(folders).toMatchObject([ + { title: 'notebook1', parent_id: '' }, + { title: 'notebook2', parent_id: '' }, + ]); + + const notes = await Note.all({ order: [{ by: 'title', dir: 'ASC' }] }); + expect(notes).toMatchObject([ + { title: 'Example note', parent_id: folders[0].id }, + { title: 'Note 2', parent_id: folders[0].id }, + { title: 'Test', parent_id: folders[1].id }, + { title: 'Test note', parent_id: folders[1].id }, + { title: 'Testing', parent_id: folders[1].id }, + ]); + + // Same-folder link (https:// link) + expect(notes[1].body).toContain(`[Example note](:/${notes[0].id})`); + // Cross-folder link (https:// link) + expect(notes[1].body).toContain(`[Test](:/${notes[2].id})`); + + // Cross-folder link (https:// link) + expect(notes[2].body).toContain(`[Example note](:/${notes[0].id})`); + // Same-folder link (evernote:// link) + expect(notes[2].body).toContain(`[Test note](:/${notes[3].id})`); + expect(notes[4].body).toContain(`[Test](:/${notes[2].id})`); + }); +}); diff --git a/packages/lib/services/interop/InteropService_Importer_EnexToMd.ts b/packages/lib/services/interop/InteropService_Importer_EnexToMd.ts index c0676ee393..c3af2cf086 100644 --- a/packages/lib/services/interop/InteropService_Importer_EnexToMd.ts +++ b/packages/lib/services/interop/InteropService_Importer_EnexToMd.ts @@ -1,11 +1,12 @@ import { ImportExportResult, ImportOptions } from './types'; -import importEnex from '../../import-enex'; +import importEnex, { restoreEnexNoteLinks } from '../../import-enex'; import InteropService_Importer_Base from './InteropService_Importer_Base'; import Folder from '../../models/Folder'; import { FolderEntity } from '../database/types'; import { fileExtension, rtrimSlashes } from '../../path-utils'; import shim from '../../shim'; import { filename } from '../../path-utils'; +import Note from '../../models/Note'; const doImportEnex = async (destFolder: FolderEntity, sourcePath: string, options: ImportOptions) => { if (!destFolder) { @@ -13,28 +14,56 @@ const doImportEnex = async (destFolder: FolderEntity, sourcePath: string, option destFolder = await Folder.save({ title: folderTitle }); } - await importEnex(destFolder.id, sourcePath, options); + return await importEnex(destFolder.id, sourcePath, options); +}; + +const restoreCrossFolderLinks = async (noteIds: string[], importedFolderIds: string[], importOptions: ImportOptions) => { + const readNotes = async function*() { + for (const id of noteIds) { + const note = await Note.load(id, { fields: ['id', 'body'] }); + yield { id: note.id, body: note.body }; + } + }; + const titleToIds = async (title: string) => { + const notes = await Note.allByTitleAndParent({ title, whereParentIn: importedFolderIds, fields: ['id'] }); + return notes.map(n => n.id); + }; + + await restoreEnexNoteLinks( + readNotes(), + titleToIds, + importOptions, + ); }; export const enexImporterExec = async (result: ImportExportResult, destinationFolder: FolderEntity, sourcePath: string, fileExtensions: string[], options: ImportOptions) => { sourcePath = rtrimSlashes(sourcePath); + if (await shim.fsDriver().isDirectory(sourcePath)) { + const notesWithUnresolvedLinks = []; + const importedFolderIds = new Set(); + const stats = await shim.fsDriver().readDirStats(sourcePath); for (const stat of stats) { const fullPath = `${sourcePath}/${stat.path}`; if (!fileExtensions.includes(fileExtension(fullPath).toLowerCase())) continue; try { - await doImportEnex(null, fullPath, options); + const importResult = await doImportEnex(null, fullPath, options); + notesWithUnresolvedLinks.push(...importResult.noteIdsWithUnresolvedLinks); + importedFolderIds.add(importResult.parentFolderId); } catch (error) { result.warnings.push(`When importing "${fullPath}": ${error.message}`); } } + + await restoreCrossFolderLinks(notesWithUnresolvedLinks, [...importedFolderIds], options); } else { await doImportEnex(destinationFolder, sourcePath, options); } + return result; };