一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
1102 行
34 KiB
TypeScript
1102 行
34 KiB
TypeScript
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~~ #<div> #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 = [
|
|
'',
|
|
'[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([
|
|
``,
|
|
'',
|
|
'## 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([
|
|
`<img src=":/${resourceIds[0]}" width="100" alt="photo.png"/>`,
|
|
`<img src=":/${resourceIds[0]}" width="100" height="200" alt="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([
|
|
`<img src=":/${resourceIdsByTitle.get(ampersandFilename)}" width="100" alt="photo&copy.png"/>`,
|
|
`<img src=":/${resourceIdsByTitle.get(quoteFilename)}" width="100" height="200" alt="photo.png" onerror="alert(1)" x=".png"/>`,
|
|
'<img src="missing&photo.png%22%20onerror=%22alert%281%29%22%20x=%22.png" width="300" alt="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(``);
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|