You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-24 20:19:10 +02:00
Compare commits
2 Commits
v3.4.5
...
file-mirro
Author | SHA1 | Date | |
---|---|---|---|
|
03f9ac47de | ||
|
d1b084b884 |
@@ -822,6 +822,8 @@ packages/lib/services/e2ee/ppkTestUtils.js
|
||||
packages/lib/services/e2ee/types.js
|
||||
packages/lib/services/e2ee/utils.test.js
|
||||
packages/lib/services/e2ee/utils.js
|
||||
packages/lib/services/filesync/FileMirroringService.test.js
|
||||
packages/lib/services/filesync/FileMirroringService.js
|
||||
packages/lib/services/interop/InteropService.test.js
|
||||
packages/lib/services/interop/InteropService.js
|
||||
packages/lib/services/interop/InteropService_Exporter_Base.js
|
||||
@@ -1011,6 +1013,7 @@ packages/lib/themes/type.js
|
||||
packages/lib/time.js
|
||||
packages/lib/types.js
|
||||
packages/lib/utils/credentialFiles.js
|
||||
packages/lib/utils/frontMatter.js
|
||||
packages/lib/utils/joplinCloud.js
|
||||
packages/lib/utils/processStartFlags.js
|
||||
packages/lib/utils/replaceUnsupportedCharacters.test.js
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -802,6 +802,8 @@ packages/lib/services/e2ee/ppkTestUtils.js
|
||||
packages/lib/services/e2ee/types.js
|
||||
packages/lib/services/e2ee/utils.test.js
|
||||
packages/lib/services/e2ee/utils.js
|
||||
packages/lib/services/filesync/FileMirroringService.test.js
|
||||
packages/lib/services/filesync/FileMirroringService.js
|
||||
packages/lib/services/interop/InteropService.test.js
|
||||
packages/lib/services/interop/InteropService.js
|
||||
packages/lib/services/interop/InteropService_Exporter_Base.js
|
||||
@@ -991,6 +993,7 @@ packages/lib/themes/type.js
|
||||
packages/lib/time.js
|
||||
packages/lib/types.js
|
||||
packages/lib/utils/credentialFiles.js
|
||||
packages/lib/utils/frontMatter.js
|
||||
packages/lib/utils/joplinCloud.js
|
||||
packages/lib/utils/processStartFlags.js
|
||||
packages/lib/utils/replaceUnsupportedCharacters.test.js
|
||||
|
29
packages/lib/services/filesync/FileMirroringService.test.ts
Normal file
29
packages/lib/services/filesync/FileMirroringService.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import Folder from "../../models/Folder";
|
||||
import Note from "../../models/Note";
|
||||
import { createTempDir, setupDatabaseAndSynchronizer, switchClient } from "../../testing/test-utils";
|
||||
import FileMirroringService from "./FileMirroringService";
|
||||
|
||||
describe('FileMirroringService', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
});
|
||||
|
||||
it('should sync with a local dir', async()=> {
|
||||
const folder1 = await Folder.save({ title: 'folder1' });
|
||||
const folder2 = await Folder.save({ title: 'folder2' });
|
||||
const folder3 = await Folder.save({ title: 'folder3', parent_id: folder2.id });
|
||||
const note1 = await Note.save({ title: 'note1', parent_id: folder1.id });
|
||||
const note2 = await Note.save({ title: 'note2', parent_id: folder1.id });
|
||||
const note3 = await Note.save({ title: 'note3', parent_id: folder3.id });
|
||||
|
||||
const tempDir = await createTempDir();
|
||||
const service = new FileMirroringService();
|
||||
await service.syncDir(tempDir);
|
||||
});
|
||||
|
||||
// TODO: test notes with duplicate names
|
||||
// TODO: test folder with duplicate names
|
||||
|
||||
});
|
117
packages/lib/services/filesync/FileMirroringService.ts
Normal file
117
packages/lib/services/filesync/FileMirroringService.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { ModelType } from "../../BaseModel";
|
||||
import Folder, { FolderEntityWithChildren } from "../../models/Folder";
|
||||
import Note from "../../models/Note";
|
||||
import { friendlySafeFilename } from "../../path-utils";
|
||||
import shim from "../../shim";
|
||||
import { noteToFrontMatter, serialize } from "../../utils/frontMatter";
|
||||
import { FolderEntity, NoteEntity, NoteTagEntity, TagEntity } from "../database/types";
|
||||
|
||||
type FolderItem = FolderEntity | NoteEntity;
|
||||
|
||||
type ItemTree = Record<string, FolderItem>;
|
||||
|
||||
interface Actions {
|
||||
onCreateFolderItem: (type:ModelType, path:string, item:FolderItem) => Promise<void>;
|
||||
onUpdateFolderItem: (type:ModelType, path:string, item:FolderItem) => Promise<void>;
|
||||
onDeleteFolderItem: (type:ModelType, path:string) => Promise<void>;
|
||||
}
|
||||
|
||||
const makeItemPaths = (basePath:string, items:FolderItem[]) => {
|
||||
const output:Record<string, string> = {};
|
||||
const existingFilenames:string[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const isFolder = item.type_ === ModelType.Folder;
|
||||
const basename = friendlySafeFilename(item.title);
|
||||
const filename = isFolder ? basename : basename + '.md';
|
||||
let counter = 0;
|
||||
while (true) {
|
||||
if (counter) basename + ' (' + counter + ')';
|
||||
if (!existingFilenames.includes(filename)) break;
|
||||
counter++;
|
||||
}
|
||||
output[item.id] = basePath ? basePath + '/' + filename : filename;
|
||||
existingFilenames.push(filename);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
const mergeTrees = async (basePath:string, localTree:ItemTree, remoteTree:ItemTree, actions:Actions) => {
|
||||
for (const [path, item] of Object.entries(localTree)) {
|
||||
const fullPath = basePath + '/' + path;
|
||||
const itemType = item.type_ ? item.type_ : ModelType.Note;
|
||||
|
||||
if (!remoteTree[path]) {
|
||||
await actions.onCreateFolderItem(itemType, fullPath, item);
|
||||
} else {
|
||||
await actions.onUpdateFolderItem(itemType, fullPath, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default class {
|
||||
|
||||
public async syncDir(filePath:string, noteTags:NoteTagEntity[], tags:TagEntity[]) {
|
||||
const allFolders = await Folder.allAsTree(null, { fields: ['id', 'title', 'parent_id'] });
|
||||
const allNotes:NoteEntity[] = await Note.all({ fields: ['id', 'title', 'parent_id'] });
|
||||
|
||||
const localTree:ItemTree = {};
|
||||
|
||||
const processFolders = (basePath:string, folders:FolderEntityWithChildren[], notes:NoteEntity[]) => {
|
||||
const itemPaths = makeItemPaths(basePath, folders.concat(notes));
|
||||
|
||||
for (const folder of folders) {
|
||||
const folderPath = itemPaths[folder.id];
|
||||
localTree[folderPath] = folder;
|
||||
const folderNotes = allNotes.filter(n => n.parent_id === folder.id);
|
||||
processFolders(folderPath, folder.children || [], folderNotes);
|
||||
}
|
||||
|
||||
for (const note of notes) {
|
||||
const notePath = itemPaths[note.id];
|
||||
localTree[notePath] = note;
|
||||
}
|
||||
}
|
||||
|
||||
processFolders('', allFolders, allNotes.filter(n => !n.parent_id));
|
||||
|
||||
const remoteTree:ItemTree = {};
|
||||
|
||||
const getNoteMd = async (note:NoteEntity) => {
|
||||
const tagIds = noteTags.filter(nt => nt.note_id === note.id).map(nt => nt.tag_id);
|
||||
const tagTitles = tags.filter(t => tagIds.includes(t.id)).map(t => t.title);
|
||||
return serialize(note, tagTitles);
|
||||
}
|
||||
|
||||
await mergeTrees(filePath, localTree, remoteTree, {
|
||||
onCreateFolderItem: async (type, path, item) => {
|
||||
if (type === ModelType.Folder) {
|
||||
await shim.fsDriver().mkdir(path);
|
||||
} else {
|
||||
await shim.fsDriver().writeFile(path, await getNoteMd(item), 'utf8');
|
||||
}
|
||||
},
|
||||
onUpdateFolderItem: async(type, path, item) => {
|
||||
if (type === ModelType.Folder) {
|
||||
await shim.fsDriver().mkdir(path);
|
||||
} else {
|
||||
const md = await noteToFrontMatter(item, []);
|
||||
await shim.fsDriver().writeFile(path, md, 'utf8');
|
||||
}
|
||||
},
|
||||
onDeleteFolderItem: async(type, path) => {
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
// filePath = '/Users/laurent/src/joplin-2.13/readme/apps';
|
||||
|
||||
// const stats = await shim.fsDriver().readDirStats(filePath, { recursive: true });
|
||||
|
||||
// for (const stat of stats) {
|
||||
|
||||
// }
|
||||
}
|
||||
|
||||
}
|
@@ -4,9 +4,9 @@ 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';
|
||||
import { ExportModuleOutputFormat } from './types';
|
||||
import { fieldOrder } from '../../utils/frontMatter';
|
||||
|
||||
async function recreateExportDir() {
|
||||
const dir = exportDir();
|
||||
|
@@ -1,13 +1,9 @@
|
||||
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';
|
||||
import { serialize } from '../../utils/frontMatter';
|
||||
|
||||
interface NoteTagContext {
|
||||
noteTags: Record<string, string[]>;
|
||||
@@ -19,33 +15,6 @@ interface TagContext {
|
||||
|
||||
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(': \'-');
|
||||
const indexWithSpace = line.indexOf(': \'- ');
|
||||
|
||||
// We don't apply this processing if the string starts with a dash
|
||||
// followed by a space. Those should actually be in quotes, otherwise
|
||||
// they are detected as invalid list items when we later try to import
|
||||
// the file.
|
||||
if (index === indexWithSpace) return line;
|
||||
|
||||
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[]) {
|
||||
@@ -93,78 +62,16 @@ export default class InteropService_Exporter_Md_frontmatter extends InteropServi
|
||||
}
|
||||
}
|
||||
|
||||
private convertDate(datetime: number): string {
|
||||
return time.unixMsToRfc3339Sec(datetime);
|
||||
}
|
||||
|
||||
private extractMetadata(note: NoteEntity) {
|
||||
const md: MdFrontMatterExport = {};
|
||||
// Every variable needs to be converted separately, 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.
|
||||
|
||||
// eslint-disable-next-line eqeqeq
|
||||
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
|
||||
protected async getNoteExportContent_(modNote: NoteEntity) {
|
||||
let tagTitles:string[] = [];
|
||||
const context: FrontMatterContext = this.context();
|
||||
if (context.noteTags[note.id]) {
|
||||
const tagIds = context.noteTags[note.id];
|
||||
if (context.noteTags[modNote.id]) {
|
||||
const tagIds = context.noteTags[modNote.id];
|
||||
// In some cases a NoteTag can still exist, while the Tag does not. In this case, tagTitles
|
||||
// for that tagId will return undefined, which can't be handled by the yaml library (issue #7782)
|
||||
const tags = tagIds.map((id: string) => context.tagTitles[id]).filter(e => !!e).sort();
|
||||
if (tags.length > 0) {
|
||||
md['tags'] = tags;
|
||||
}
|
||||
tagTitles = tagIds.map((id: string) => context.tagTitles[id]).filter(e => !!e).sort();
|
||||
}
|
||||
|
||||
// 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}`;
|
||||
}
|
||||
return serialize(modNote, tagTitles); }
|
||||
|
||||
}
|
||||
|
@@ -1,170 +1,15 @@
|
||||
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';
|
||||
import shim from '../../shim';
|
||||
|
||||
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 function 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 '';
|
||||
}
|
||||
import { parse } from '../../utils/frontMatter';
|
||||
|
||||
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];
|
||||
const nextLine = i + 1 <= lines.length - 1 ? lines[i + 1] : '';
|
||||
|
||||
if (inHeader && line.startsWith('---')) {
|
||||
inHeader = false;
|
||||
|
||||
// Need to eat the extra newline after the yaml block. Note that
|
||||
// if the next line is not an empty line, we keep it. Fixes
|
||||
// https://github.com/laurent22/joplin/issues/8802
|
||||
if (nextLine.trim() === '') i++;
|
||||
|
||||
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 = 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());
|
||||
} else if ('created_at' in md) {
|
||||
// Add support for Notesnook
|
||||
metadata['user_created_time'] = time.anythingToMs(md['created_at'], 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());
|
||||
} else if ('updated_at' in md) {
|
||||
// Notesnook
|
||||
metadata['user_updated_time'] = time.anythingToMs(md['updated_at'], 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 separately 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)];
|
||||
|
||||
metadata['body'] = body;
|
||||
|
||||
return { metadata, tags };
|
||||
}
|
||||
|
||||
public async importFile(filePath: string, parentFolderId: string) {
|
||||
try {
|
||||
const note = await super.importFile(filePath, parentFolderId);
|
||||
const { metadata, tags } = this.parseYamlNote(note.body);
|
||||
const { metadata, tags } = parse(note.body);
|
||||
|
||||
const updatedNote = { ...note, ...metadata };
|
||||
|
||||
|
248
packages/lib/utils/frontMatter.ts
Normal file
248
packages/lib/utils/frontMatter.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import Note from "../models/Note";
|
||||
import { NoteEntity } from "../services/database/types";
|
||||
import { MdFrontMatterExport } from "../services/interop/types";
|
||||
import time from "../time";
|
||||
import * as yaml from 'js-yaml';
|
||||
|
||||
export interface ParsedMeta {
|
||||
metadata: NoteEntity;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const convertDate = (datetime: number): string => {
|
||||
return time.unixMsToRfc3339Sec(datetime);
|
||||
}
|
||||
|
||||
export const fieldOrder = ['title', 'updated', 'created', 'source', 'author', 'latitude', 'longitude', 'altitude', 'completed?', 'due', 'tags'];
|
||||
|
||||
// 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(': \'-');
|
||||
const indexWithSpace = line.indexOf(': \'- ');
|
||||
|
||||
// We don't apply this processing if the string starts with a dash
|
||||
// followed by a space. Those should actually be in quotes, otherwise
|
||||
// they are detected as invalid list items when we later try to import
|
||||
// the file.
|
||||
if (index === indexWithSpace) return line;
|
||||
|
||||
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 noteToFrontMatter = (note: NoteEntity, tagTitles:string[]) => {
|
||||
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.
|
||||
|
||||
// eslint-disable-next-line eqeqeq
|
||||
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'] = convertDate(note.todo_due); }
|
||||
|
||||
// time
|
||||
if (note.user_updated_time) { md['updated'] = convertDate(note.user_updated_time); }
|
||||
if (note.user_created_time) { md['created'] = convertDate(note.user_created_time); }
|
||||
|
||||
// tags
|
||||
if (tagTitles.length) md['tags'] = tagTitles;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
export const serialize = async (modNote: NoteEntity, tagTitles:string[]) => {
|
||||
const noteContent = await Note.replaceResourceInternalToExternalLinks(await Note.serialize(modNote, ['body']));
|
||||
const metadata = noteToFrontMatter(modNote, tagTitles);
|
||||
return `---\n${metadata}---\n\n${noteContent}`;
|
||||
}
|
||||
|
||||
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 '';
|
||||
}
|
||||
|
||||
const 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];
|
||||
const nextLine = i + 1 <= lines.length - 1 ? lines[i + 1] : '';
|
||||
|
||||
if (inHeader && line.startsWith('---')) {
|
||||
inHeader = false;
|
||||
|
||||
// Need to eat the extra newline after the yaml block. Note that
|
||||
// if the next line is not an empty line, we keep it. Fixes
|
||||
// https://github.com/laurent22/joplin/issues/8802
|
||||
if (nextLine.trim() === '') i++;
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
const 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;
|
||||
}
|
||||
|
||||
export const parse = (note: string): ParsedMeta => {
|
||||
if (!note.startsWith('---')) return { metadata: { body: note }, tags: [] };
|
||||
|
||||
const { header, body } = getNoteHeader(note);
|
||||
|
||||
const md = 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());
|
||||
} else if ('created_at' in md) {
|
||||
// Add support for Notesnook
|
||||
metadata['user_created_time'] = time.anythingToMs(md['created_at'], 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());
|
||||
} else if ('updated_at' in md) {
|
||||
// Notesnook
|
||||
metadata['user_updated_time'] = time.anythingToMs(md['updated_at'], 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)];
|
||||
|
||||
metadata['body'] = body;
|
||||
|
||||
return { metadata, tags };
|
||||
}
|
Reference in New Issue
Block a user