mirror of https://github.com/laurent22/joplin.git
pull/5540/head^2
parent
1fee83d408
commit
2acd55eb81
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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).
|
|
@ -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
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
Title: Inline Tags
|
||||
Tags: [inline, tag]
|
||||
---
|
||||
|
||||
Body
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
title: norm
|
||||
tags:
|
||||
- tag1
|
||||
- tag2
|
||||
- tag3
|
||||
---
|
||||
|
||||
note body
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: 001
|
||||
---
|
||||
|
||||
note body
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
title: Date
|
||||
created: 2017-01-01
|
||||
updated: 2021-01-01
|
||||
---
|
||||
|
||||
I hope the dates are imported correctly
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
title: xxx
|
||||
---
|
||||
|
||||
---
|
||||
author: xxx
|
||||
---
|
||||
|
||||
note body
|
|
@ -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
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
Title: Unquoted
|
||||
Longitude: -94.51350100
|
||||
Completed?: No
|
||||
DUE: 2022-04-04 13:00
|
||||
---
|
||||
|
||||
note body
|
|
@ -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
|
@ -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",
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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']));
|
||||
}
|
||||
|
||||
|
|
|
@ -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');
|
||||
}));
|
||||
});
|
|
@ -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}`;
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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')})`);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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 Navier–Stokes 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.
|
||||
```
|
Loading…
Reference in New Issue