Desktop, Cli: Resolves #5224: Add Markdown + Front Matter exporter/importer (#5465)

pull/5540/head^2
Caleb John 2021-10-16 01:59:37 -07:00 committed by GitHub
parent 1fee83d408
commit 2acd55eb81
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1127 additions and 1043 deletions

View File

@ -1308,6 +1308,12 @@ packages/lib/services/interop/InteropService_Exporter_Jex.js.map
packages/lib/services/interop/InteropService_Exporter_Md.d.ts
packages/lib/services/interop/InteropService_Exporter_Md.js
packages/lib/services/interop/InteropService_Exporter_Md.js.map
packages/lib/services/interop/InteropService_Exporter_Md_frontmatter.d.ts
packages/lib/services/interop/InteropService_Exporter_Md_frontmatter.js
packages/lib/services/interop/InteropService_Exporter_Md_frontmatter.js.map
packages/lib/services/interop/InteropService_Exporter_Md_frontmatter.test.d.ts
packages/lib/services/interop/InteropService_Exporter_Md_frontmatter.test.js
packages/lib/services/interop/InteropService_Exporter_Md_frontmatter.test.js.map
packages/lib/services/interop/InteropService_Exporter_Raw.d.ts
packages/lib/services/interop/InteropService_Exporter_Raw.js
packages/lib/services/interop/InteropService_Exporter_Raw.js.map
@ -1332,6 +1338,12 @@ packages/lib/services/interop/InteropService_Importer_Md.js.map
packages/lib/services/interop/InteropService_Importer_Md.test.d.ts
packages/lib/services/interop/InteropService_Importer_Md.test.js
packages/lib/services/interop/InteropService_Importer_Md.test.js.map
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.d.ts
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.js
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.js.map
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.test.d.ts
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.test.js
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.test.js.map
packages/lib/services/interop/InteropService_Importer_Raw.d.ts
packages/lib/services/interop/InteropService_Importer_Raw.js
packages/lib/services/interop/InteropService_Importer_Raw.js.map

12
.gitignore vendored
View File

@ -1291,6 +1291,12 @@ packages/lib/services/interop/InteropService_Exporter_Jex.js.map
packages/lib/services/interop/InteropService_Exporter_Md.d.ts
packages/lib/services/interop/InteropService_Exporter_Md.js
packages/lib/services/interop/InteropService_Exporter_Md.js.map
packages/lib/services/interop/InteropService_Exporter_Md_frontmatter.d.ts
packages/lib/services/interop/InteropService_Exporter_Md_frontmatter.js
packages/lib/services/interop/InteropService_Exporter_Md_frontmatter.js.map
packages/lib/services/interop/InteropService_Exporter_Md_frontmatter.test.d.ts
packages/lib/services/interop/InteropService_Exporter_Md_frontmatter.test.js
packages/lib/services/interop/InteropService_Exporter_Md_frontmatter.test.js.map
packages/lib/services/interop/InteropService_Exporter_Raw.d.ts
packages/lib/services/interop/InteropService_Exporter_Raw.js
packages/lib/services/interop/InteropService_Exporter_Raw.js.map
@ -1315,6 +1321,12 @@ packages/lib/services/interop/InteropService_Importer_Md.js.map
packages/lib/services/interop/InteropService_Importer_Md.test.d.ts
packages/lib/services/interop/InteropService_Importer_Md.test.js
packages/lib/services/interop/InteropService_Importer_Md.test.js.map
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.d.ts
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.js
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.js.map
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.test.d.ts
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.test.js
packages/lib/services/interop/InteropService_Importer_Md_frontmatter.test.js.map
packages/lib/services/interop/InteropService_Importer_Raw.d.ts
packages/lib/services/interop/InteropService_Importer_Raw.js
packages/lib/services/interop/InteropService_Importer_Raw.js.map

View File

@ -0,0 +1,9 @@
---
title: ddd
tags:
- banana
- banana
- banana
---
And a link to note [full](./full.md), and link to the [same note](./full.md).

View File

@ -0,0 +1,18 @@
---
title: Test Note Title
updated: 2019-05-01 16:54
created: 2019-05-01 16:54
Source: https://joplinapp.org
author: Joplin
latitude: 37.084021
longitude: -94.51350100
altitude: 0.0000
Completed?: false
due: 2021-08-22 00:00
tags:
- Joplin
- nOte
- pencil
---
This is the note body

View File

@ -0,0 +1,6 @@
---
Title: Inline Tags
Tags: [inline, tag]
---
Body

View File

@ -0,0 +1,9 @@
---
title: norm
tags:
- tag1
- tag2
- tag3
---
note body

View File

@ -0,0 +1,5 @@
---
title: 001
---
note body

View File

@ -0,0 +1,20 @@
---
title: "YAML metadata for R Markdown with examples"
subtitle: "YAML header"
author: Hao Liang
date: "2021-06-10"
output:
md_document:
toc: yes
toc_depth: 2
abstract: YAML is a human-readable and easy to write language to define data structures.
keywords: ["YAML", "Rmd"]
subject: Medicine
description: Rmd makes it possible to use a YAML header to specify certain parameters right at the beginning of the document.
category:
- Rmd
- Medicine
lang: "en-US"
---
Example taken from https://github.com/hao203/rmarkdown-YAML

View File

@ -0,0 +1,10 @@
---
title: "Distill for R Markdown"
author:
- name: "JJ Allaire"
url: https://github.com/jjallaire
affiliation: RStudio
affiliation_url: https://www.rstudio.com
---
Example taken from https://github.com/hao203/rmarkdown-YAML

View File

@ -0,0 +1,7 @@
---
title: Date
created: 2017-01-01
updated: 2021-01-01
---
I hope the dates are imported correctly

View File

@ -0,0 +1,9 @@
---
title: xxx
---
---
author: xxx
---
note body

View File

@ -0,0 +1,12 @@
---
Title: |-
First
Second
Updated: 28/09/2021 20:57
Created: 28/09/2021 20:57
Latitude: 0.00000000
Longitude: 0.00000000
Altitude: 0.0000
---
This note has a newline in the title

View File

@ -0,0 +1,8 @@
---
Title: Unquoted
Longitude: -94.51350100
Completed?: No
DUE: 2022-04-04 13:00
---
note body

View File

@ -0,0 +1,7 @@
---
title: Test Note Title
updated: 2019-05-01T16:54:00Z
created: 2019-05-01 16:54-07:00
---
This is the note body

File diff suppressed because it is too large Load Diff

View File

@ -18,6 +18,7 @@
"devDependencies": {
"@types/fs-extra": "^9.0.6",
"@types/jest": "^26.0.15",
"@types/js-yaml": "^4.0.2",
"@types/node": "^14.14.6",
"@types/node-rsa": "^1.1.1",
"@types/react": "^17.0.20",
@ -53,6 +54,7 @@
"image-data-uri": "^2.0.0",
"image-type": "^3.0.0",
"immer": "^7.0.14",
"js-yaml": "^4.1.0",
"levenshtein": "^1.0.5",
"lodash": "^4.17.20",
"markdown-it": "^10.0.0",

View File

@ -58,6 +58,14 @@ export default class InteropService {
isNoteArchive: false, // Tells whether the file can contain multiple notes (eg. Enex or Jex format)
description: _('Markdown'),
},
{
...defaultImportExportModule(ModuleType.Importer),
format: 'md_frontmatter',
fileExtensions: ['md', 'markdown', 'txt', 'html'],
sources: [FileSystemItem.File, FileSystemItem.Directory],
isNoteArchive: false, // Tells whether the file can contain multiple notes (eg. Enex or Jex format)
description: _('Markdown + Front Matter'),
},
{
...defaultImportExportModule(ModuleType.Importer),
format: 'raw',
@ -105,6 +113,12 @@ export default class InteropService {
target: FileSystemItem.Directory,
description: _('Markdown'),
},
{
...defaultImportExportModule(ModuleType.Exporter),
format: 'md_frontmatter',
target: FileSystemItem.Directory,
description: _('Markdown + Front Matter'),
},
{
...defaultImportExportModule(ModuleType.Exporter),
format: 'html',

View File

@ -110,7 +110,7 @@ export default class InteropService_Exporter_Md extends InteropService_Exporter_
}
}
private async getNoteExportContent_(modNote: NoteEntity) {
protected async getNoteExportContent_(modNote: NoteEntity) {
return await Note.replaceResourceInternalToExternalLinks(await Note.serialize(modNote, ['body']));
}

View File

@ -0,0 +1,134 @@
import InteropService from '../../services/interop/InteropService';
import { setupDatabaseAndSynchronizer, switchClient, exportDir } from '../../testing/test-utils';
import Folder from '../../models/Folder';
import Note from '../../models/Note';
import Tag from '../../models/Tag';
import time from '../../time';
import { fieldOrder } from './InteropService_Exporter_Md_frontmatter';
import * as fs from 'fs-extra';
async function recreateExportDir() {
const dir = exportDir();
await fs.remove(dir);
await fs.mkdirp(dir);
}
describe('interop/InteropService_Exporter_Md_frontmatter', function() {
async function exportAndLoad(path: string): Promise<string> {
const service = InteropService.instance();
await service.export({
path: exportDir(),
format: 'md_frontmatter',
});
return await fs.readFile(path, 'utf8');
}
beforeEach(async (done) => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
await recreateExportDir();
done();
});
test('should export MD file with YAML header', (async () => {
const folder1 = await Folder.save({ title: 'folder1' });
await Note.save({ title: 'ma', latitude: 58.2222, user_updated_time: 1, user_created_time: 1, body: '**ma note**', parent_id: folder1.id });
const content = await exportAndLoad(`${exportDir()}/folder1/ma.md`);
expect(content.startsWith('---')).toBe(true);
expect(content).toContain('title: ma');
expect(content).toContain('updated:'); // Will be current time of test run
expect(content).toContain(`created: ${time.unixMsToRfc3339Sec(1)}`);
expect(content).toContain('latitude: 58.22220000');
expect(content).toContain('longitude: 0.00000000');
expect(content).toContain('altitude: 0.0000');
expect(content).toContain('**ma note**');
expect(content).not.toContain('completed?');
expect(content).not.toContain('author');
expect(content).not.toContain('source');
expect(content).not.toContain('due');
}));
test('should export without additional quotes', (async () => {
const folder1 = await Folder.save({ title: 'folder1' });
await Note.save({ title: '-60', body: '**ma note**', parent_id: folder1.id });
const content = await exportAndLoad(`${exportDir()}/folder1/-60.md`);
expect(content).toContain('title: -60');
}));
test('should export tags', (async () => {
const folder1 = await Folder.save({ title: 'folder1' });
const note = await Note.save({ title: 'Title', body: '**ma note**', parent_id: folder1.id });
await Tag.addNoteTagByTitle(note.id, 'lamp');
await Tag.addNoteTagByTitle(note.id, 'moth');
await Tag.addNoteTagByTitle(note.id, 'godzilla');
const content = await exportAndLoad(`${exportDir()}/folder1/Title.md`);
expect(content).toContain('tags:\n - godzilla\n - lamp\n - moth');
}));
test('should export todo', (async () => {
const folder1 = await Folder.save({ title: 'folder1' });
await Note.save({ title: 'Todo', is_todo: 1, todo_due: 1, body: '**ma note**', parent_id: folder1.id });
const content = await exportAndLoad(`${exportDir()}/folder1/Todo.md`);
expect(content).toContain(`due: ${time.unixMsToRfc3339Sec(1)}`);
expect(content).toContain('completed?: no');
}));
test('should export author', (async () => {
const folder1 = await Folder.save({ title: 'folder1' });
await Note.save({ title: 'Author', author: 'Scott Joplin', body: '**ma note**', parent_id: folder1.id });
const content = await exportAndLoad(`${exportDir()}/folder1/Author.md`);
expect(content).toContain('author: Scott Joplin');
}));
test('should export source', (async () => {
const folder1 = await Folder.save({ title: 'folder1' });
await Note.save({ title: 'Source', source_url: 'https://joplinapp.org', body: '**ma note**', parent_id: folder1.id });
const content = await exportAndLoad(`${exportDir()}/folder1/Source.md`);
expect(content).toContain('source: https://joplinapp.org');
}));
test('should export fields in the correct order', (async () => {
const folder1 = await Folder.save({ title: 'folder1' });
const note = await Note.save({
title: 'Fields',
is_todo: 1,
todo_due: 1,
author: 'Scott Joplin',
source_url: 'https://joplinapp.org',
body: '**ma note**',
parent_id: folder1.id,
});
await Tag.addNoteTagByTitle(note.id, 'piano');
await Tag.addNoteTagByTitle(note.id, 'greatness');
const content = await exportAndLoad(`${exportDir()}/folder1/Fields.md`);
const fieldIndices = fieldOrder.map(field => content.indexOf(field));
expect(fieldIndices).toBe(fieldIndices.sort());
}));
test('should export title with a newline encoded', (async () => {
const folder1 = await Folder.save({ title: 'folder1' });
await Note.save({ title: 'Source\ntitle', body: '**ma note**', parent_id: folder1.id });
const content = await exportAndLoad(`${exportDir()}/folder1/Source_title.md`);
expect(content).toContain('title: |-\n Source\n title');
}));
test('should not export coordinates if they\'re not available', (async () => {
const folder1 = await Folder.save({ title: 'folder1' });
await Note.save({ title: 'Coordinates', body: '**ma note**', parent_id: folder1.id });
const content = await exportAndLoad(`${exportDir()}/folder1/Coordinates.md`);
expect(content).not.toContain('latitude');
expect(content).not.toContain('longitude');
expect(content).not.toContain('altitude');
}));
});

View File

@ -0,0 +1,155 @@
import InteropService_Exporter_Md from './InteropService_Exporter_Md';
import BaseModel from '../../BaseModel';
import Note from '../../models/Note';
import NoteTag from '../../models/NoteTag';
import Tag from '../../models/Tag';
import time from '../../time';
import { NoteEntity } from '../database/types';
import { MdFrontMatterExport } from './types';
import * as yaml from 'js-yaml';
interface NoteTagContext {
noteTags: Record<string, string[]>;
}
interface TagContext {
tagTitles: Record<string, string>;
}
interface FrontMatterContext extends NoteTagContext, TagContext {}
// There is a special case (negative numbers) where the yaml library will force quotations
// These need to be stripped
function trimQuotes(rawOutput: string): string {
return rawOutput.split('\n').map(line => {
const index = line.indexOf(': \'-');
if (index >= 0) {
// The plus 2 eats the : and space characters
const start = line.substring(0, index + 2);
// The plus 3 eats the quote character
const end = line.substring(index + 3, line.length - 1);
return start + end;
}
return line;
}).join('\n');
}
export const fieldOrder = ['title', 'updated', 'created', 'source', 'author', 'latitude', 'longitude', 'altitude', 'completed?', 'due', 'tags'];
export default class InteropService_Exporter_Md_frontmatter extends InteropService_Exporter_Md {
public async prepareForProcessingItemType(itemType: number, itemsToExport: any[]) {
await super.prepareForProcessingItemType(itemType, itemsToExport);
if (itemType === BaseModel.TYPE_NOTE_TAG) {
// Get tag list for each note
const context: NoteTagContext = {
noteTags: {},
};
for (let i = 0; i < itemsToExport.length; i++) {
const it = itemsToExport[i].type;
if (it !== itemType) continue;
const itemOrId = itemsToExport[i].itemOrId;
const noteTag = typeof itemOrId === 'object' ? itemOrId : await NoteTag.load(itemOrId);
if (!noteTag) continue;
if (!context.noteTags[noteTag.note_id]) context.noteTags[noteTag.note_id] = [];
context.noteTags[noteTag.note_id].push(noteTag.tag_id);
}
this.updateContext(context);
} else if (itemType === BaseModel.TYPE_TAG) {
// Map tag ID to title
const context: TagContext = {
tagTitles: {},
};
for (let i = 0; i < itemsToExport.length; i++) {
const it = itemsToExport[i].type;
if (it !== itemType) continue;
const itemOrId = itemsToExport[i].itemOrId;
const tag = typeof itemOrId === 'object' ? itemOrId : await Tag.load(itemOrId);
if (!tag) continue;
context.tagTitles[tag.id] = tag.title;
}
this.updateContext(context);
}
}
private convertDate(datetime: number): string {
return time.unixMsToRfc3339Sec(datetime);
}
private extractMetadata(note: NoteEntity) {
const md: MdFrontMatterExport = {};
// Every variable needs to be converted seperately, so they will be handles in groups
//
// title
if (note.title) { md['title'] = note.title; }
// source, author
if (note.source_url) { md['source'] = note.source_url; }
if (note.author) { md['author'] = note.author; }
// locations
// non-strict inequality is used here to interpret the location strings
// as numbers i.e 0.000000 is the same as 0.
// This is necessary because these fields are officially numbers, but often
// contain strings.
if (note.latitude != 0 || note.longitude != 0 || note.altitude != 0) {
md['latitude'] = note.latitude;
md['longitude'] = note.longitude;
md['altitude'] = note.altitude;
}
// todo
if (note.is_todo) {
// boolean is not support by the yaml FAILSAFE_SCHEMA
md['completed?'] = note.todo_completed ? 'yes' : 'no';
}
if (note.todo_due) { md['due'] = this.convertDate(note.todo_due); }
// time
if (note.user_updated_time) { md['updated'] = this.convertDate(note.user_updated_time); }
if (note.user_created_time) { md['created'] = this.convertDate(note.user_created_time); }
// tags
const context: FrontMatterContext = this.context();
if (context.noteTags[note.id]) {
const tagIds = context.noteTags[note.id];
const tags = tagIds.map((id: string) => context.tagTitles[id]).sort();
md['tags'] = tags;
}
// This guarentees that fields will always be ordered the same way
// which can be useful if users are using this for generating diffs
const sort = (a: string, b: string) => {
return fieldOrder.indexOf(a) - fieldOrder.indexOf(b);
};
// The FAILSAFE_SCHEMA along with noCompatMode allows this to export strings that look
// like numbers (or yes/no) without the added '' quotes around the text
const rawOutput = yaml.dump(md, { sortKeys: sort, noCompatMode: true, schema: yaml.FAILSAFE_SCHEMA });
// The additional trimming is the unfortunate result of the yaml library insisting on
// quoting negative numbers.
// For now the trimQuotes function only trims quotes associated with a negative number
// but it can be extended to support more special cases in the future if necessary.
return trimQuotes(rawOutput);
}
protected async getNoteExportContent_(modNote: NoteEntity) {
const noteContent = await Note.replaceResourceInternalToExternalLinks(await Note.serialize(modNote, ['body']));
const metadata = this.extractMetadata(modNote);
return `---\n${metadata}---\n\n${noteContent}`;
}
}

View File

@ -0,0 +1,135 @@
import InteropService_Importer_Md_frontmatter from '../../services/interop/InteropService_Importer_Md_frontmatter';
import Note from '../../models/Note';
import Tag from '../../models/Tag';
import time from '../../time';
import { setupDatabaseAndSynchronizer, supportDir, switchClient } from '../../testing/test-utils';
describe('InteropService_Importer_Md_frontmatter: importMetadata', function() {
async function importNote(path: string) {
const importer = new InteropService_Importer_Md_frontmatter();
importer.setMetadata({ fileExtensions: ['md', 'html'] });
return await importer.importFile(path, 'notebook');
}
beforeEach(async (done) => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
done();
});
it('should import file and set all metadata correctly', async function() {
const note = await importNote(`${supportDir}/test_notes/yaml/full.md`);
const format = 'DD/MM/YYYY HH:mm';
expect(note.title).toBe('Test Note Title');
expect(time.formatMsToLocal(note.user_updated_time, format)).toBe('01/05/2019 16:54');
expect(time.formatMsToLocal(note.user_created_time, format)).toBe('01/05/2019 16:54');
expect(note.source_url).toBe('https://joplinapp.org');
expect(note.author).toBe('Joplin');
expect(note.latitude).toBe('37.08402100');
expect(note.longitude).toBe('-94.51350100');
expect(note.altitude).toBe('0.0000');
expect(note.is_todo).toBe(1);
expect(note.todo_completed).toBeUndefined();
expect(time.formatMsToLocal(note.todo_due, format)).toBe('22/08/2021 00:00');
expect(note.body).toBe('This is the note body\n');
const tags = await Tag.tagsByNoteId(note.id);
expect(tags.length).toBe(3);
const tagTitles = tags.map(tag => tag.title);
expect(tagTitles).toContain('joplin');
expect(tagTitles).toContain('note');
expect(tagTitles).toContain('pencil');
});
it('should only import data from the first yaml block', async function() {
const note = await importNote(`${supportDir}/test_notes/yaml/split.md`);
expect(note.title).toBe('xxx');
expect(note.author).not.toBe('xxx');
expect(note.body).toBe('---\nauthor: xxx\n---\n\nnote body\n');
});
it('should only import, duplicate notes and tags are not created', async function() {
const note = await importNote(`${supportDir}/test_notes/yaml/duplicates.md`);
expect(note.title).toBe('ddd');
const itemIds = await Note.linkedItemIds(note.body);
expect(itemIds.length).toBe(1);
const tags = await Tag.tagsByNoteId(note.id);
expect(tags.length).toBe(1);
});
it('should not import items as numbers', async function() {
const note = await importNote(`${supportDir}/test_notes/yaml/numbers.md`);
expect(note.title).toBe('001');
expect(note.body).toBe('note body\n');
});
it('should normalize whitespace and load correctly', async function() {
const note = await importNote(`${supportDir}/test_notes/yaml/normalize.md`);
expect(note.title).toBe('norm');
expect(note.body).toBe('note body\n');
const tags = await Tag.tagsByNoteId(note.id);
expect(tags.length).toBe(3);
});
it('should load unquoted special forms correctly', async function() {
const note = await importNote(`${supportDir}/test_notes/yaml/unquoted.md`);
expect(note.title).toBe('Unquoted');
expect(note.body).toBe('note body\n');
expect(note.longitude).toBe('-94.51350100');
expect(note.is_todo).toBe(1);
expect(note.todo_completed).toBeUndefined();
});
it('should load notes with newline in the title', async function() {
const note = await importNote(`${supportDir}/test_notes/yaml/title_newline.md`);
expect(note.title).toBe('First\nSecond');
});
it('should import dates (without time) correctly', async function() {
const note = await importNote(`${supportDir}/test_notes/yaml/short_date.md`);
const format = 'YYYY-MM-DD HH:mm';
expect(time.formatMsToLocal(note.user_updated_time, format)).toBe('2021-01-01 00:00');
expect(time.formatMsToLocal(note.user_created_time, format)).toBe('2017-01-01 00:00');
});
it('should load tags even with the inline syntax', async function() {
const note = await importNote(`${supportDir}/test_notes/yaml/inline_tags.md`);
expect(note.title).toBe('Inline Tags');
const tags = await Tag.tagsByNoteId(note.id);
expect(tags.length).toBe(2);
});
it('should import r-markdown files correctly and set what metadata it can', async function() {
const note = await importNote(`${supportDir}/test_notes/yaml/r-markdown.md`);
const format = 'YYYY-MM-DD HH:mm';
expect(note.title).toBe('YAML metadata for R Markdown with examples');
expect(time.formatMsToLocal(note.user_updated_time, format)).toBe('2021-06-10 00:00');
expect(time.formatMsToLocal(note.user_created_time, format)).toBe('2021-06-10 00:00');
expect(note.author).toBe('Hao Liang');
const tags = await Tag.tagsByNoteId(note.id);
expect(tags.length).toBe(2);
const tagTitles = tags.map(tag => tag.title);
expect(tagTitles).toContain('yaml');
expect(tagTitles).toContain('rmd');
});
it('should import r-markdown files with alternative author syntax', async function() {
const note = await importNote(`${supportDir}/test_notes/yaml/r-markdown_author.md`);
expect(note.title).toBe('Distill for R Markdown');
expect(note.author).toBe('JJ Allaire');
});
it('should handle date formats with timezone information', async function() {
const note = await importNote(`${supportDir}/test_notes/yaml/utc.md`);
expect(note.user_updated_time).toBe(1556729640000);
expect(note.user_created_time).toBe(1556754840000);
});
});

View File

@ -0,0 +1,162 @@
import InteropService_Importer_Md from './InteropService_Importer_Md';
import Note from '../../models/Note';
import Tag from '../../models/Tag';
import time from '../../time';
import { NoteEntity } from '../database/types';
import * as yaml from 'js-yaml';
interface ParsedMeta {
metadata: NoteEntity;
tags: string[];
}
function isTruthy(str: string): boolean {
return str.toLowerCase() in ['true', 'yes'];
}
// Enforces exactly 2 spaces in front of list items
function normalizeYamlWhitespace(yaml: string[]): string[] {
return yaml.map(line => {
const l = line.trimStart();
if (l.startsWith('-')) {
return ` ${l}`;
}
return line;
});
}
// This is a helper functon to convert an arbitrary author variable into a string
// the use case is for loading from r-markdown/pandoc style notes
// references:
// https://pandoc.org/MANUAL.html#extension-yaml_metadata_block
// https://github.com/hao203/rmarkdown-YAML
function extractAuthor(author: any): string {
if (!author) return '';
if (typeof(author) === 'string') {
return author;
} else if (Array.isArray(author)) {
// Joplin only supports a single author, so we take the first one
return extractAuthor(author[0]);
} else if (typeof(author) === 'object') {
if ('name' in author) {
return author['name'];
}
}
return '';
}
export default class InteropService_Importer_Md_frontmatter extends InteropService_Importer_Md {
private getNoteHeader(note: string) {
// Ignore the leading `---`
const lines = note.split('\n').slice(1);
let inHeader = true;
const headerLines: string[] = [];
const bodyLines: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (inHeader && line.startsWith('---')) {
inHeader = false;
i++; // Need to eat the extra newline after the yaml block
continue;
}
if (inHeader) { headerLines.push(line); } else { bodyLines.push(line); }
}
const normalizedHeaderLines = normalizeYamlWhitespace(headerLines);
const header = normalizedHeaderLines.join('\n');
const body = bodyLines.join('\n');
return { header, body };
}
private toLowerCase(obj: Record<string, any>): Record<string, any> {
const newObj: Record<string, any> = {};
for (const key of Object.keys(obj)) {
newObj[key.toLowerCase()] = obj[key];
}
return newObj;
}
private parseYamlNote(note: string): ParsedMeta {
if (!note.startsWith('---')) return { metadata: { body: note }, tags: [] };
const { header, body } = this.getNoteHeader(note);
const md: Record<string, any> = this.toLowerCase(yaml.load(header, { schema: yaml.FAILSAFE_SCHEMA }));
const metadata: NoteEntity = {
title: md['title'] || '',
source_url: md['source'] || '',
is_todo: ('completed?' in md) ? 1 : 0,
};
if ('author' in md) { metadata['author'] = extractAuthor(md['author']); }
// The date fallback gives support for MultiMarkdown format, r-markdown, and pandoc formats
if ('created' in md) {
metadata['user_created_time'] = time.anythingToMs(md['created'], Date.now());
} else if ('date' in md) {
metadata['user_created_time'] = time.anythingToMs(md['date'], Date.now());
}
if ('updated' in md) {
metadata['user_updated_time'] = time.anythingToMs(md['updated'], Date.now());
} else if ('lastmod' in md) {
// Add support for hugo
metadata['user_updated_time'] = time.anythingToMs(md['lastmod'], Date.now());
} else if ('date' in md) {
metadata['user_updated_time'] = time.anythingToMs(md['date'], Date.now());
}
if ('latitude' in md) { metadata['latitude'] = md['latitude']; }
if ('longitude' in md) { metadata['longitude'] = md['longitude']; }
if ('altitude' in md) { metadata['altitude'] = md['altitude']; }
if (metadata.is_todo) {
if (isTruthy(md['completed?'])) {
// Completed time isn't preserved, so we use a sane choice here
metadata['todo_completed'] = metadata['user_updated_time'];
}
if ('due' in md) {
const due_date = time.anythingToMs(md['due'], null);
if (due_date) { metadata['todo_due'] = due_date; }
}
}
// Tags are handled seperately from typical metadata
let tags: string[] = [];
if ('tags' in md) {
// Only create unique tags
tags = md['tags'];
} else if ('keywords' in md) {
// Adding support for r-markdown/pandoc
tags = tags.concat(md['keywords']);
}
// Only create unique tags
tags = [...new Set(tags)] as string[];
metadata['body'] = body;
return { metadata, tags };
}
public async importFile(filePath: string, parentFolderId: string) {
const note = await super.importFile(filePath, parentFolderId);
const { metadata, tags } = this.parseYamlNote(note.body);
const updatedNote = Object.assign({}, note, metadata);
const noteItem = await Note.save(updatedNote, { isNew: false, autoTimestamp: false });
for (const tag of tags) { await Tag.addNoteTagByTitle(noteItem.id, tag); }
return noteItem;
}
}

View File

@ -103,8 +103,24 @@ export interface ImportExportResult {
warnings: string[];
}
// These are the fields that will be included in an exported Md+Front Matter note
export interface MdFrontMatterExport {
'title'?: string;
'source'?: string;
'author'?: string;
'latitude'?: number;
'longitude'?: number;
'altitude'?: number;
'completed?'?: string;
'due'?: string;
'updated'?: string;
'created'?: string;
'tags'?: string[];
}
function moduleFullLabel(moduleSource: FileSystemItem = null): string {
const label = [`${this.format.toUpperCase()} - ${this.description}`];
const format = this.format.split('_')[0];
const label = [`${format.toUpperCase()} - ${this.description}`];
if (moduleSource && this.sources.length > 1) {
label.push(`(${moduleSource === 'file' ? _('File') : _('Directory')})`);
}

View File

@ -80,6 +80,15 @@ class Time {
);
}
public unixMsToRfc3339Sec(ms: number) {
return (
`${moment
.unix(ms / 1000)
.utc()
.format('YYYY-MM-DD HH:mm:ss')}Z`
);
}
public unixMsToLocalDateTime(ms: number) {
return moment.unix(ms / 1000).format('DD/MM/YYYY HH:mm');
}
@ -110,6 +119,19 @@ class Time {
return m.isValid() ? m.toDate() : defaultValue;
}
public anythingToMs(o: any, defaultValue: number = null) {
if (o && o.toDate) return o.toDate();
if (!o) return defaultValue;
// There are a few date formats supported by Joplin that are not supported by
// moment without an explicit format specifier. The typical case is that a user
// has a preferred data format. This means we should try the currently assigned
// date first, and then attempt to load a generic date string.
const m = moment(o, this.dateTimeFormat());
if (m.isValid()) return m.toDate().getTime();
const d = moment(o);
return d.isValid() ? d.toDate().getTime() : defaultValue;
}
public msleep(ms: number) {
return new Promise((resolve: Function) => {
shim.setTimeout(() => {
@ -144,7 +166,6 @@ class Time {
}, 1000);
});
}
}
const time = new Time();

View File

@ -0,0 +1,110 @@
# Markdown with Front Matter Exporter/Importer
This exporter/importer is built around the MD exporter/importer. It functions identically, but includes a block of YAML front matter that contains note metadata.
YAML front matter is represented simply as a block of YAML between `---` delimiters. An illustrative example can be seen below.
```
---
title: Joplin Interop
created: 1970-01-01 00:00Z
tags:
- export
- import
---
```
## Supported Metadata Fields
All of the below fields are supported by both the exporter and the importer.
- `title`: Title of the note
- `updated`: Time of last not update (corresponds to `user_updated_time`)
- `created`: Creation time of note (corresponds to `user_created_time`)
- `source`: The source URL for a note that comes from the web clipper
- `author`: Author's name
- `latitude`: Latitude where note was created
- `longitude`: Longitude where note was created
- `altitude`: Altitude where note was created
- `completed?`: Exists if the note is a todo, indicates if the todo is completed
- `due`: Exists if the note is a todo, due date (alarm time) of note
- `tags`: List of all associated tag names
### Exporter
The exporter will export all the above fields that hold values in the database. So `due` and `completed?` will only be included for "todo" notesm `tags` will only exist for notes that include tags, etc.
### Importer
The importer will import the metadata corresponding to all of the above fields. Missing data will be filled in as if the note was just created. Extra fields will be ignored.
There are other tools that use similar YAML front matter blocks, notably [pandoc](https://pandoc.org/MANUAL.html#extension-yaml_metadata_block) and [r-markdown](https://github.com/hao203/rmarkdown-YAML). The importer attempts to provide compatibility with these formats where possible.
## Dates
### Exporter
All dates are exported in the ISO 8601 format (substituting the 'T' for a space based on RFC 3339 for readability) in the UTC time zone.
e.g. `1970-01-01 00:00:00Z`
### Importer
The importer is more flexible with dates. It will handle ISO 8601 dates with or without a timezone, if no timezone is specified, local time will be used. If there is a timezone specified (Z notation or +00:00 notation) that timezone will be used. If the format is not ISO 8601, the importer will attempt to read based on the users configured date and time preferences (Tools -> Options -> General or Joplin -> Preferences -> General). The importer will fallback on the Javascript [Date](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date) functionality if the format can not be read.
## Examples
Below are a collection of examples that represent valid notes that may have been exported by the exporter, and can be imported by the importer.
```
---
title: Frogs
source: https://en.wikipedia.org/wiki/Frog
created: 2021-05-01 16:40:00Z
updated: 2021-05-01 16:40:00Z
tags:
- Reference
- Cool
---
This article is about the group of amphibians. For other uses, see [Frog (disambiguation)](https://en.wikipedia.org/wiki/Frog_%28disambiguation%29 "Frog (disambiguation)").
...
```
```
---
title: Take Home Quiz
created: 2021-05-01 16:40:00Z
updated: 2021-06-17 23:59:00Z
tags:
- school
- math
- homework
completed?: no
due: 2021-06-18 08:00:00Z
---
**Prove or give a counter-example of the following statement:**
> In three space dimensions and time, given an initial velocity field, there exists a vector velocity and a scalar pressure field, which are both smooth and globally defined, that solve the NavierStokes equations.
```
```
---
title: All Fields
updated: 2019-05-01 16:54:00Z
created: 2019-05-01 16:54:00Z
source: https://joplinapp.org
author: Joplin
latitude: 37.084021
longitude: -94.51350100
altitude: 0.0000
completed?: no
due: 2021-08-22 00:00:00Z
tags:
- joplin
- note
- pencil
---
All of this metadata is available to be imported/exported.
```