import * as fs from 'fs-extra'; import Note from '../../models/Note'; import Resource from '../../models/Resource'; import Tag from '../../models/Tag'; import { createTempDir, setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils'; import InteropService from './InteropService'; // cspell:ignore हिन्दी describe('InteropService_Importer_Obsidian', () => { let tempDir: string; beforeEach(async () => { await setupDatabaseAndSynchronizer(1); await switchClient(1); tempDir = await createTempDir(); }); afterEach(async () => { await fs.remove(tempDir); }); it('should import plain note', async () => { const vaultPath = `${tempDir}/My vault`; const sourceBody = [ 'Hello.', '', '## What you should expect', '', 'Note title: plain. Body stays.', ].join('\n'); await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/plain.md`, sourceBody); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const note = await Note.loadByTitle('plain'); expect(note.title).toBe('plain'); expect(note.body).toBe(sourceBody); }); it('should keep code examples literal', async () => { const vaultPath = `${tempDir}/My vault`; const codeExamples = new Map([ ['inline-code', '`example #fake [[Note]]`'], ['double-backtick-inline-code', '``example ` #fake [[Note]]``'], ['backtick-code-block', '```text\n#fake\n[[Note]]\n![[photo.png]]\n```'], ['tilde-code-block', '~~~text\n#fake\n[[Note]]\n![[photo.png]]\n~~~'], ['nested-code-block', '````md\n```text\n#fake\n[[Note]]\n```\n````'], ['indented-code-block', ' #fake [[Note]] ![[photo.png]]'], ['unclosed-code-block', '```text\n#fake\n[[Note]]\n![[photo.png]]'], ]); await fs.mkdirp(vaultPath); for (const [title, sourceBody] of codeExamples) { await fs.writeFile(`${vaultPath}/${title}.md`, sourceBody); } await fs.writeFile(`${vaultPath}/Note.md`, 'Target note.'); await fs.writeFile(`${vaultPath}/photo.png`, 'Photo content'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); for (const [title, sourceBody] of codeExamples) { const note = await Note.loadByTitle(title); const tags = (await Tag.tagsByNoteId(note.id)).map(tag => tag.title); expect(note.body).toBe(sourceBody); expect(tags).toEqual([]); } }); test.each([ ['block quote', '> ```\n> [[Target]]\n> ```'], ['list item', '- ~~~typescript\n [[Target]]\n ~~~'], ])('should keep wikilinks literal inside code blocks in %s', async (_type, codeBlock) => { const vaultPath = `${tempDir}/My vault`; const sourceBody = `${codeBlock}\n\n[[Target]]`; await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/Source.md`, sourceBody); await fs.writeFile(`${vaultPath}/Target.md`, 'Target note.'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const sourceNote = await Note.loadByTitle('Source'); const targetNote = await Note.loadByTitle('Target'); expect(sourceNote.body).toBe(`${codeBlock}\n\n[Target](:/${targetNote.id})`); }); it('should process list text but keep nested code content literal', async () => { const vaultPath = `${tempDir}/My vault`; const sourceBody = [ '- [[Target]] #real', '- ~~~typescript', ' [[Target]] #fake ![[photo.png]]', ' ~~~', ].join('\n'); await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/Source.md`, sourceBody); await fs.writeFile(`${vaultPath}/Target.md`, 'Target note.'); await fs.writeFile(`${vaultPath}/photo.png`, 'Photo content'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const sourceNote = await Note.loadByTitle('Source'); const targetNote = await Note.loadByTitle('Target'); const tags = (await Tag.tagsByNoteId(sourceNote.id)).map(tag => tag.title); expect(sourceNote.body).toBe([ `- [Target](:/${targetNote.id}) #real`, '- ~~~typescript', ' [[Target]] #fake ![[photo.png]]', ' ~~~', ].join('\n')); expect(tags).toEqual(['real']); expect(await Note.linkedResourceIds(sourceNote.body)).toEqual([]); }); test.each([ ['backtick', '```text', '````'], ['tilde', '~~~text', '~~~~'], ])('should close %s code fence longer than opening fence', async (_type, openingFence, closingFence) => { const vaultPath = `${tempDir}/My vault`; const sourceBody = [openingFence, '[[Target]]', closingFence, '[[Target]]'].join('\n'); await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/Source.md`, sourceBody); await fs.writeFile(`${vaultPath}/Target.md`, 'Target note.'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const sourceNote = await Note.loadByTitle('Source'); const targetNote = await Note.loadByTitle('Target'); expect(sourceNote.body).toBe([ openingFence, '[[Target]]', closingFence, `[Target](:/${targetNote.id})`, ].join('\n')); }); it('should skip Obsidian config and trash folders', async () => { const vaultPath = `${tempDir}/My vault`; await fs.mkdirp(`${vaultPath}/.obsidian/plugins/example`); await fs.mkdirp(`${vaultPath}/.trash`); await fs.writeFile(`${vaultPath}/kept.md`, 'Keep this note.'); await fs.writeFile(`${vaultPath}/.obsidian/plugins/example/README.md`, 'Plugin documentation.'); await fs.writeFile(`${vaultPath}/.trash/deleted.md`, 'Deleted note.'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const notes = await Note.all(); expect(notes.map(note => note.title)).toEqual(['kept']); expect(notes[0].body).toBe('Keep this note.'); }); it('should use YAML title and remove YAML', async () => { const vaultPath = `${tempDir}/My vault`; await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/title.md`, [ '---', 'title: New title', '---', '', 'Hello.', ].join('\n')); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const note = await Note.loadByTitle('New title'); expect(note.title).toBe('New title'); expect(note.body).toBe('Hello.'); }); it('should import empty front matter', async () => { const vaultPath = `${tempDir}/My vault`; await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/empty.md`, '---\n\n---\n\nHello.'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const note = await Note.loadByTitle('empty'); expect(note.body).toBe('Hello.'); }); it('should import YAML tags, remove tag YAML, and preserve cssclasses', async () => { const vaultPath = `${tempDir}/My vault`; await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/yaml.md`, [ '---', 'tags:', ' - work', ' - home', 'cssclasses:', ' - red-border', '---', '', 'Hello.', '', '## What you should expect', '', 'Tags: work, home. YAML removed.', ].join('\n')); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const note = await Note.loadByTitle('yaml'); const tags = (await Tag.tagsByNoteId(note.id)).map(tag => tag.title).sort(); expect(note.body).toBe([ '---', 'cssclasses:', ' - red-border', '---', '', 'Hello.', '', '## What you should expect', '', 'Tags: work, home. YAML removed.', ].join('\n')); expect(tags).toEqual(['home', 'work']); }); test.each([ ['longer front matter closing line', '----'], ['front matter closing line with trailing spaces', '--- '], ])('should preserve cssclasses with a %s', async (_case, closingLine) => { const vaultPath = `${tempDir}/My vault`; await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/cssclasses.md`, [ '---', 'cssclasses:', ' - wide', closingLine, '', 'Body', ].join('\n')); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const note = await Note.loadByTitle('cssclasses'); expect(note.body).toBe([ '---', 'cssclasses:', ' - wide', '---', '', 'Body', ].join('\n')); }); it('should import front matter after a byte order mark', async () => { const vaultPath = `${tempDir}/My vault`; await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/bom.md`, '\uFEFF---\ncssclasses: [test]\n---\n\nBody'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const note = await Note.loadByTitle('bom'); expect(note.body).toBe([ '---', 'cssclasses:', ' - test', '---', '', 'Body', ].join('\n')); }); it('should import inline tag and keep text', async () => { const vaultPath = `${tempDir}/My vault`; await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/inline.md`, 'Hello #work.'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const note = await Note.loadByTitle('inline'); const tags = (await Tag.tagsByNoteId(note.id)).map(tag => tag.title); expect(note.body).toBe('Hello #work.'); expect(tags).toEqual(['work']); }); it('should stop inline tags at unsupported symbols', async () => { const vaultPath = `${tempDir}/My vault`; const sourceBody = '#x=1 #tag~~strike~~ #
#100k+ #price$ #normal-tag'; await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/unsupported-symbols.md`, sourceBody); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const note = await Note.loadByTitle('unsupported-symbols'); const tags = (await Tag.tagsByNoteId(note.id)).map(tag => tag.title).sort(); expect(note.body).toBe(sourceBody); expect(tags).toEqual(['100k', 'normal-tag', 'price', 'tag', 'x']); }); it('should merge tags ignoring letter case', async () => { const vaultPath = `${tempDir}/My vault`; const caseSourceBody = [ '#Work #work #WORK', '', '## What you should expect', '', 'One tag: Work. Text stays.', ].join('\n'); const bothSourceBody = [ '---', 'tags:', ' - Work', '---', '', '#work #home', '', '## What you should expect', '', 'Tags: Work, home. No duplicate. Text stays.', ].join('\n'); await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/case.md`, caseSourceBody); await fs.writeFile(`${vaultPath}/both.md`, bothSourceBody); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const caseNote = await Note.loadByTitle('case'); const caseNoteTags = (await Tag.tagsByNoteId(caseNote.id)).map(tag => tag.title); const bothNote = await Note.loadByTitle('both'); const bothNoteTags = (await Tag.tagsByNoteId(bothNote.id)).map(tag => tag.title).sort(); expect(caseNote.body).toBe(caseSourceBody); expect(caseNoteTags).toEqual(['Work']); expect(bothNote.body).toBe([ '#work #home', '', '## What you should expect', '', 'Tags: Work, home. No duplicate. Text stays.', ].join('\n')); expect(bothNoteTags).toEqual(['Work', 'home']); }); it('should handle tags containing numbers', async () => { const vaultPath = `${tempDir}/My vault`; await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/number.md`, '#work2'); await fs.writeFile(`${vaultPath}/number-only.md`, '#1984'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const numberNote = await Note.loadByTitle('number'); const numberNoteTags = (await Tag.tagsByNoteId(numberNote.id)).map(tag => tag.title); const numberOnlyNote = await Note.loadByTitle('number-only'); const numberOnlyNoteTags = (await Tag.tagsByNoteId(numberOnlyNote.id)).map(tag => tag.title); expect(numberNote.body).toBe('#work2'); expect(numberNoteTags).toEqual(['work2']); expect(numberOnlyNote.body).toBe('#1984'); expect(numberOnlyNoteTags).toEqual([]); }); it('should import tag containing underscore', async () => { const vaultPath = `${tempDir}/My vault`; const sourceBody = [ '#to_read', '', '## What you should expect', '', 'One tag: to_read.', ].join('\n'); await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/underscore.md`, sourceBody); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const note = await Note.loadByTitle('underscore'); const tags = (await Tag.tagsByNoteId(note.id)).map(tag => tag.title); expect(note.body).toBe(sourceBody); expect(tags).toEqual(['to_read']); }); it('should import tag containing hyphen', async () => { const vaultPath = `${tempDir}/My vault`; const sourceBody = [ '#to-read', '', '## What you should expect', '', 'One tag: to-read.', ].join('\n'); await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/hyphen.md`, sourceBody); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const note = await Note.loadByTitle('hyphen'); const tags = (await Tag.tagsByNoteId(note.id)).map(tag => tag.title); expect(note.body).toBe(sourceBody); expect(tags).toEqual(['to-read']); }); // Obsidian treats `inbox/to-read` as one nested tag. Joplin stores flat tags: // Its `tags` table has no hierarchy, and Tag.save allows `/` in tag titles. // Keep the full tag text as one Joplin tag. it('should import nested tag as one flat tag', async () => { const vaultPath = `${tempDir}/My vault`; const sourceBody = [ '#inbox/to-read', '', '## What you should expect', '', 'One tag: inbox/to-read.', ].join('\n'); await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/nested.md`, sourceBody); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const note = await Note.loadByTitle('nested'); const tags = (await Tag.tagsByNoteId(note.id)).map(tag => tag.title); expect(note.body).toBe(sourceBody); expect(tags).toEqual(['inbox/to-read']); }); it('should import emoji tag', async () => { const vaultPath = `${tempDir}/My vault`; await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/emoji.md`, '#📚'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const note = await Note.loadByTitle('emoji'); const tags = (await Tag.tagsByNoteId(note.id)).map(tag => tag.title); expect(note.body).toBe('#📚'); expect(tags).toEqual(['📚']); }); it('should import Unicode tag', async () => { const vaultPath = `${tempDir}/My vault`; await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/unicode-tag.md`, '#café'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const note = await Note.loadByTitle('unicode-tag'); const tags = (await Tag.tagsByNoteId(note.id)).map(tag => tag.title); expect(note.body).toBe('#café'); expect(tags).toEqual(['café']); }); it('should import Unicode tags containing combining marks and symbols', async () => { const vaultPath = `${tempDir}/My vault`; const sourceBody = '#हिन्दी #cafe\u0301 #✓'; await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/unicode-tags.md`, sourceBody); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const note = await Note.loadByTitle('unicode-tags'); const tags = (await Tag.tagsByNoteId(note.id)).map(tag => tag.title); const expectedTags = ['हिन्दी', 'café', '✓']; expect(note.body).toBe(sourceBody); expect(tags).toHaveLength(expectedTags.length); expect(tags).toEqual(expect.arrayContaining(expectedTags)); }); it('should keep web link', async () => { const vaultPath = `${tempDir}/My vault`; await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/web.md`, '[Joplin](https://joplinapp.org)'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const note = await Note.loadByTitle('web'); expect(note.body).toBe('[Joplin](https://joplinapp.org)'); }); it('should import Markdown link', async () => { const vaultPath = `${tempDir}/My vault`; await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/md-link.md`, [ '[Note](Note.md)', '', '## What you should expect', '', 'With Note.md: working Joplin link.', ].join('\n')); await fs.writeFile(`${vaultPath}/Note.md`, 'Target note.'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const note = await Note.loadByTitle('md-link'); const targetNote = await Note.loadByTitle('Note'); expect(note.body).toBe([ `[Note](:/${targetNote.id})`, '', '## What you should expect', '', 'With Note.md: working Joplin link.', ].join('\n')); }); it('should resolve a Markdown note link with brackets in its label', async () => { const vaultPath = `${tempDir}/My vault`; const sourceBody = [ 'See [the [draft] version](draft.md)', 'See [the [latest [draft]] version](draft.md)', ].join('\n'); await fs.mkdirp(`${vaultPath}/Folder`); await fs.writeFile(`${vaultPath}/Source.md`, sourceBody); await fs.writeFile(`${vaultPath}/Folder/draft.md`, 'Draft note.'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const sourceNote = await Note.loadByTitle('Source'); const targetNote = await Note.loadByTitle('draft'); expect(sourceNote.body).toBe([ `See [the [draft] version](:/${targetNote.id})`, `See [the [latest [draft]] version](:/${targetNote.id})`, ].join('\n')); }); test.each([ ['without fragment', '[Overview](../Overview.md)', ''], ['with fragment', '[Overview](../Overview.md#Install)', '#install'], ])('should resolve relative Markdown link %s', async (_case, sourceBody, fragment) => { const vaultPath = `${tempDir}/My vault`; await fs.mkdirp(`${vaultPath}/Folder`); await fs.writeFile(`${vaultPath}/Folder/Current.md`, sourceBody); await fs.writeFile(`${vaultPath}/Overview.md`, '# Install'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const sourceNote = await Note.loadByTitle('Current'); const targetNote = await Note.loadByTitle('Overview'); expect(sourceNote.body).toBe(`[Overview](:/${targetNote.id}${fragment})`); }); test.each([ ['without fragment', '[MD target](md-link-target.md)', ''], ['with fragment', '[MD target](md-link-target.md#Notes)', '#notes'], ])('should resolve a unique Markdown link target in another folder %s', async (_case, sourceBody, fragment) => { const vaultPath = `${tempDir}/My vault`; await fs.mkdirp(`${vaultPath}/utils`); await fs.writeFile(`${vaultPath}/md-link.md`, sourceBody); await fs.writeFile(`${vaultPath}/utils/md-link-target.md`, '# Notes'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const sourceNote = await Note.loadByTitle('md-link'); const targetNote = await Note.loadByTitle('md-link-target'); expect(sourceNote.body).toBe(`[MD target](:/${targetNote.id}${fragment})`); }); it('should resolve multiple Markdown note links with an uppercase extension', async () => { const vaultPath = `${tempDir}/My vault`; const sourceBody = '[First](md-link-target.MD) [Second](md-link-target.md#Notes)'; await fs.mkdirp(`${vaultPath}/utils`); await fs.writeFile(`${vaultPath}/Source.md`, sourceBody); await fs.writeFile(`${vaultPath}/utils/md-link-target.md`, '# Notes'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const sourceNote = await Note.loadByTitle('Source'); const targetNote = await Note.loadByTitle('md-link-target'); expect(sourceNote.body).toBe(`[First](:/${targetNote.id}) [Second](:/${targetNote.id}#notes)`); }); it('should keep unsupported Markdown note link forms unchanged', async () => { const vaultPath = `${tempDir}/My vault`; const sourceBody = [ '![Image](md-link-target.md)', '[Empty fragment](md-link-target.md#)', '[Wrong type](md-link-target.txt)', '[Missing close](md-link-target.md', '[Line', 'break](md-link-target.md)', ].join('\n'); await fs.mkdirp(`${vaultPath}/utils`); await fs.writeFile(`${vaultPath}/Source.md`, sourceBody); await fs.writeFile(`${vaultPath}/utils/md-link-target.md`, 'Target note.'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const sourceNote = await Note.loadByTitle('Source'); expect(sourceNote.body).toBe(sourceBody); }); it('should update anchors in valid internal links', async () => { const vaultPath = `${tempDir}/My vault`; const itemId = 'A'.repeat(32); await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/Source.md`, `[First](:/${itemId}#First heading) [Second](:/${itemId}#Second heading)`); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const sourceNote = await Note.loadByTitle('Source'); expect(sourceNote.body).toBe(`[First](:/${itemId}#first-heading) [Second](:/${itemId}#second-heading)`); }); test.each([ ['PDF page anchor', 'document.pdf', '#page=5'], ['MP4 start-time anchor', 'video.mp4', '#t=10'], ['MP4 time-range anchor', 'video.mp4', '#t=10,20'], ])('should keep the %s on a resource link', async (_case, fileName, anchor) => { const vaultPath = `${tempDir}/My vault`; await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/Source.md`, `[Media](${fileName}${anchor})`); await fs.writeFile(`${vaultPath}/${fileName}`, 'Media content'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const sourceNote = await Note.loadByTitle('Source'); const resourceIds = await Note.linkedResourceIds(sourceNote.body); expect(resourceIds).toHaveLength(1); expect(sourceNote.body).toBe(`[Media](:/${resourceIds[0]}${anchor})`); }); it('should keep internal link unchanged when item ID is not hexadecimal', async () => { const vaultPath = `${tempDir}/My vault`; const sourceBody = `[Invalid](:/${'g'.repeat(32)}#MyHeading)`; await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/Source.md`, sourceBody); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const sourceNote = await Note.loadByTitle('Source'); expect(sourceNote.body).toBe(sourceBody); }); it('should import linked attachment', async () => { const vaultPath = `${tempDir}/My vault`; await fs.mkdirp(vaultPath); // Test normal Markdown link, attachment wikilink, and attachment wikilink with shown text. await fs.writeFile(`${vaultPath}/attachment.md`, '[Guide](guide.pdf) [[guide.pdf]] [[guide.pdf|Open guide]]'); await fs.writeFile(`${vaultPath}/guide.pdf`, 'Guide content'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const note = await Note.loadByTitle('attachment'); const resourceIds = await Note.linkedResourceIds(note.body); const resource = await Resource.load(resourceIds[0]); expect(resourceIds).toHaveLength(1); // All three links should point to same imported Joplin resource. expect(note.body).toBe(`[Guide](:/${resource.id}) [guide.pdf](:/${resource.id}) [Open guide](:/${resource.id})`); expect(resource.title).toBe('guide.pdf'); }); it('should keep shown text in wikilink', async () => { const vaultPath = `${tempDir}/My vault`; await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/wiki-label.md`, [ '[[Note|Open]]', '', '## What you should expect', '', 'With Note.md: Joplin link shown as Open.', ].join('\n')); await fs.writeFile(`${vaultPath}/Note.md`, 'Target note.'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const note = await Note.loadByTitle('wiki-label'); const targetNote = await Note.loadByTitle('Note'); expect(note.body).toBe([ `[Open](:/${targetNote.id})`, '', '## What you should expect', '', 'With Note.md: Joplin link shown as Open.', ].join('\n')); }); it('should resolve wikilinks ignoring letter case', async () => { const vaultPath = `${tempDir}/My vault`; await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/Source.md`, '[[note]]'); await fs.writeFile(`${vaultPath}/Note.md`, 'Target note.'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const sourceNote = await Note.loadByTitle('Source'); const targetNote = await Note.loadByTitle('Note'); expect(sourceNote.body).toBe(`[note](:/${targetNote.id})`); }); it('should resolve wikilinks using note title with shown alias', async () => { const vaultPath = `${tempDir}/My vault`; await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/Source.md`, '[[Work|Project plan]]'); await fs.writeFile(`${vaultPath}/Work.md`, [ '---', 'aliases:', ' - Project plan', '---', '', 'Target note.', ].join('\n')); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const sourceNote = await Note.loadByTitle('Source'); const targetNote = await Note.loadByTitle('Work'); expect(sourceNote.body).toBe(`[Project plan](:/${targetNote.id})`); }); it('should prefer canonical note title over front matter alias', async () => { const vaultPath = `${tempDir}/My vault`; await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/Source.md`, '[[Project plan]]'); await fs.writeFile(`${vaultPath}/Project plan.md`, 'Canonical note.'); await fs.writeFile(`${vaultPath}/Work.md`, [ '---', 'aliases:', ' - Project plan', '---', ].join('\n')); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const sourceNote = await Note.loadByTitle('Source'); const canonicalNote = await Note.loadByTitle('Project plan'); expect(sourceNote.body).toBe(`[Project plan](:/${canonicalNote.id})`); }); it('should import folder-path wikilink', async () => { const vaultPath = `${tempDir}/My vault`; await fs.mkdirp(`${vaultPath}/Work`); await fs.writeFile(`${vaultPath}/wiki-path.md`, [ '[[Work/Note]]', '', '## What you should expect', '', 'With Work/Note.md: Joplin link to Note.', ].join('\n')); await fs.writeFile(`${vaultPath}/Work/Note.md`, 'Target note.'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const note = await Note.loadByTitle('wiki-path'); const targetNote = await Note.loadByTitle('Note'); expect(note.body).toBe([ `[Work/Note](:/${targetNote.id})`, '', '## What you should expect', '', 'With Work/Note.md: Joplin link to Note.', ].join('\n')); }); it('should import wikilink embed', async () => { const vaultPath = `${tempDir}/My vault`; const sourceBody = [ '![[photo.png]]', '', '## What you should expect', '', 'With photo.png: working Joplin image.', ].join('\n'); await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/embed.md`, sourceBody); await fs.writeFile(`${vaultPath}/photo.png`, 'Photo content'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const note = await Note.loadByTitle('embed'); const resourceIds = await Note.linkedResourceIds(note.body); expect(resourceIds).toHaveLength(1); const resource = await Resource.load(resourceIds[0]); expect(note.body).toBe([ `![photo.png](:/${resource.id})`, '', '## What you should expect', '', 'With photo.png: working Joplin image.', ].join('\n')); expect(resource.title).toBe('photo.png'); }); it('should preserve image embed dimensions', async () => { const vaultPath = `${tempDir}/My vault`; await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/embed-size.md`, '![[photo.png|100]]\n![[photo.png|100x200]]'); await fs.writeFile(`${vaultPath}/photo.png`, 'Photo content'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const note = await Note.loadByTitle('embed-size'); const resourceIds = await Note.linkedResourceIds(note.body); expect(resourceIds).toHaveLength(1); expect(note.body).toBe([ `photo.png`, `photo.png`, ].join('\n')); }); it('should safely escape sized image attributes without breaking resource paths', async () => { const vaultPath = `${tempDir}/My vault`; const ampersandFilename = 'photo©.png'; const quoteFilename = 'photo.png" onerror="alert(1)" x=".png'; const missingFilename = 'missing&photo.png" onerror="alert(1)" x=".png'; await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/embed-size-safe.md`, [ `![[${ampersandFilename}|100]]`, `![[${quoteFilename}|100x200]]`, `![[${missingFilename}|300]]`, ].join('\n')); await fs.writeFile(`${vaultPath}/${ampersandFilename}`, 'Ampersand photo content'); await fs.writeFile(`${vaultPath}/${quoteFilename}`, 'Quote photo content'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const note = await Note.loadByTitle('embed-size-safe'); const resources = await Promise.all((await Note.linkedResourceIds(note.body)).map(id => Resource.load(id))); const resourceIdsByTitle = new Map(resources.map(resource => [resource.title, resource.id])); expect(resources).toHaveLength(2); expect(note.body).toBe([ `photo&copy.png`, `photo.png" onerror="alert(1)" x=".png`, 'missing&photo.png" onerror="alert(1)" x=".png', ].join('\n')); }); it('should preserve image embed caption', async () => { const vaultPath = `${tempDir}/My vault`; await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/embed-caption.md`, '![[photo.png|A photo]]'); await fs.writeFile(`${vaultPath}/photo.png`, 'Photo content'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const note = await Note.loadByTitle('embed-caption'); const resourceIds = await Note.linkedResourceIds(note.body); expect(resourceIds).toHaveLength(1); expect(note.body).toBe(`![A photo](:/${resourceIds[0]})`); }); it('should import non-image embeds as links', async () => { const vaultPath = `${tempDir}/My vault`; await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/embed.md`, [ '![[Document.pdf]]', '![[Audio.ogg]]', '![[Video.mp4]]', '![[Note]]', ].join('\n')); await fs.writeFile(`${vaultPath}/Document.pdf`, 'PDF content'); await fs.writeFile(`${vaultPath}/Audio.ogg`, 'Audio content'); await fs.writeFile(`${vaultPath}/Video.mp4`, 'Video content'); await fs.writeFile(`${vaultPath}/Note.md`, 'Note content'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const note = await Note.loadByTitle('embed'); const targetNote = await Note.loadByTitle('Note'); const resourceIds = await Note.linkedResourceIds(note.body); const resources = await Promise.all(resourceIds.map(resourceId => Resource.load(resourceId))); const resourceIdByTitle = new Map(resources.map(resource => [resource.title, resource.id])); expect(resources.map(resource => resource.title).sort()).toEqual(['Audio.ogg', 'Document.pdf', 'Video.mp4']); expect(note.body).toBe([ `[Document.pdf](:/${resourceIdByTitle.get('Document.pdf')})`, `[Audio.ogg](:/${resourceIdByTitle.get('Audio.ogg')})`, `[Video.mp4](:/${resourceIdByTitle.get('Video.mp4')})`, `[Note](:/${targetNote.id})`, ].join('\n')); expect(await Note.linkedNoteIds(note.body)).toEqual([targetNote.id]); }); it('should import heading wikilink', async () => { const vaultPath = `${tempDir}/My vault`; await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/heading.md`, '[[Note#Part]] [[Missing#Part]]'); await fs.writeFile(`${vaultPath}/Note.md`, '# Part'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const note = await Note.loadByTitle('heading'); const targetNote = await Note.loadByTitle('Note'); expect(note.body).toBe(`[Note#Part](:/${targetNote.id}#part) [[Missing#Part]]`); }); it('should import same-note heading wikilink', async () => { const vaultPath = `${tempDir}/My vault`; await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/same-heading.md`, '# Part\n\n[[#Part]]'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const note = await Note.loadByTitle('same-heading'); expect(note.body).toBe(`# Part\n\n[#Part](:/${note.id}#part)`); }); it('should import nested heading wikilink', async () => { const vaultPath = `${tempDir}/My vault`; await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/heading.md`, '[[Note#Parent#Child]]'); await fs.writeFile(`${vaultPath}/Note.md`, '# Parent\n\n## Child'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const note = await Note.loadByTitle('heading'); const targetNote = await Note.loadByTitle('Note'); expect(note.body).toBe(`[Note#Parent#Child](:/${targetNote.id}#child)`); }); it('should keep unsupported block wikilinks unchanged', async () => { const vaultPath = `${tempDir}/My vault`; const sourceBody = [ 'Local block. ^local-id', '', '[[Note#^block-id]]', '[[#^local-id]]', '[[Note#^block-id|Shown name]]', '![[Note#^block-id]]', '![[#^local-id]]', ].join('\n'); await fs.mkdirp(vaultPath); await fs.writeFile(`${vaultPath}/Source.md`, sourceBody); await fs.writeFile(`${vaultPath}/Note.md`, 'Target block. ^block-id'); await InteropService.instance().import({ format: 'obsidian', path: vaultPath, }); const sourceNote = await Note.loadByTitle('Source'); const targetNote = await Note.loadByTitle('Note'); expect(sourceNote.body).toBe(sourceBody); expect(targetNote.body).toBe('Target block. ^block-id'); }); });