Merge branch 'master' of github.com:laurent22/joplin

pull/2314/head
Laurent Cozic 2020-01-18 14:27:26 +00:00
commit c7a9e5f656
16 changed files with 901 additions and 51 deletions

View File

@ -150,4 +150,31 @@ describe('models_Folder', function() {
expect(foldersById[f4.id].note_count).toBe(0);
}));
it('should not count completed to-dos', asyncTest(async () => {
let f1 = await Folder.save({ title: 'folder1' });
let f2 = await Folder.save({ title: 'folder2', parent_id: f1.id });
let f3 = await Folder.save({ title: 'folder3', parent_id: f2.id });
let f4 = await Folder.save({ title: 'folder4' });
let n1 = await Note.save({ title: 'note1', parent_id: f3.id });
let n2 = await Note.save({ title: 'note2', parent_id: f3.id });
let n3 = await Note.save({ title: 'note3', parent_id: f1.id });
let n4 = await Note.save({ title: 'note4', parent_id: f3.id, is_todo: true, todo_completed: 0 });
let n5 = await Note.save({ title: 'note5', parent_id: f3.id, is_todo: true, todo_completed: 999 });
let n6 = await Note.save({ title: 'note6', parent_id: f3.id, is_todo: true, todo_completed: 999 });
const folders = await Folder.all();
await Folder.addNoteCounts(folders, false);
const foldersById = {};
folders.forEach((f) => { foldersById[f.id] = f; });
expect(folders.length).toBe(4);
expect(foldersById[f1.id].note_count).toBe(4);
expect(foldersById[f2.id].note_count).toBe(3);
expect(foldersById[f3.id].note_count).toBe(3);
expect(foldersById[f4.id].note_count).toBe(0);
}));
});

350
CliClient/tests/reducer.js Normal file
View File

@ -0,0 +1,350 @@
/* eslint-disable no-unused-vars */
require('app-module-path').addPath(__dirname);
const {setupDatabaseAndSynchronizer, switchClient, asyncTest } = require('test-utils.js');
const Folder = require('lib/models/Folder.js');
const Note = require('lib/models/Note.js');
const Tag = require('lib/models/Tag.js');
const { reducer, defaultState, stateUtils} = require('lib/reducer.js');
async function createNTestFolders(n) {
let folders = [];
for (let i = 0; i < n; i++) {
let folder = await Folder.save({ title: 'folder' });
folders.push(folder);
}
return folders;
}
async function createNTestNotes(n, folder) {
let notes = [];
for (let i = 0; i < n; i++) {
let note = await Note.save({ title: 'note', parent_id: folder.id });
notes.push(note);
}
return notes;
}
async function createNTestTags(n) {
let tags = [];
for (let i = 0; i < n; i++) {
let tag = await Tag.save({ title: 'tag' });
tags.push(tag);
}
return tags;
}
function initTestState(folders, selectedFolderIndex, notes, selectedIndexes, tags=null, selectedTagIndex=null) {
let state = defaultState;
if (folders != null) {
state = reducer(state, { type: 'FOLDER_UPDATE_ALL', items: folders });
}
if (selectedFolderIndex != null) {
state = reducer(state, { type: 'FOLDER_SELECT', id: folders[selectedFolderIndex].id });
}
if (notes != null) {
state = reducer(state, { type: 'NOTE_UPDATE_ALL', notes: notes, noteSource: 'test' });
}
if (selectedIndexes != null) {
let selectedIds = [];
for (let i = 0; i < selectedIndexes.length; i++) {
selectedIds.push(notes[selectedIndexes[i]].id);
}
state = reducer(state, { type: 'NOTE_SELECT', ids: selectedIds });
}
if (tags != null) {
state = reducer(state, { type: 'TAG_UPDATE_ALL', items: tags });
}
if (selectedTagIndex != null) {
state = reducer(state, { type: 'TAG_SELECT', id: tags[selectedTagIndex].id });
}
return state;
}
function createExpectedState(items, keepIndexes, selectedIndexes) {
let expected = { items: [], selectedIds: []};
for (let i = 0; i < selectedIndexes.length; i++) {
expected.selectedIds.push(items[selectedIndexes[i]].id);
}
for (let i = 0; i < keepIndexes.length; i++) {
expected.items.push(items[keepIndexes[i]]);
}
return expected;
}
function getIds(items, indexes=null) {
let ids = [];
for (let i = 0; i < items.length; i++) {
if (indexes == null || i in indexes) {
ids.push(items[i].id);
}
}
return ids;
}
let insideBeforeEach = false;
describe('Reducer', function() {
beforeEach(async (done) => {
insideBeforeEach = true;
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
done();
insideBeforeEach = false;
});
// tests for NOTE_DELETE
it('should delete selected note', asyncTest(async () => {
// create 1 folder
let folders = await createNTestFolders(1);
// create 5 notes
let notes = await createNTestNotes(5, folders[0]);
// select the 1st folder and the 3rd note
let state = initTestState(folders, 0, notes, [2]);
// test action
// delete the third note
state = reducer(state, {type: 'NOTE_DELETE', id: notes[2].id});
// expect that the third note is missing, and the 4th note is now selected
let expected = createExpectedState(notes, [0,1,3,4], [3]);
// check the ids of all the remaining notes
expect(getIds(state.notes)).toEqual(getIds(expected.items));
// check the ids of the selected notes
expect(state.selectedNoteIds).toEqual(expected.selectedIds);
}));
it('should delete selected note at top', asyncTest(async () => {
let folders = await createNTestFolders(1);
let notes = await createNTestNotes(5, folders[0]);
let state = initTestState(folders, 0, notes, [1]);
// test action
state = reducer(state, {type: 'NOTE_DELETE', id: notes[0].id});
let expected = createExpectedState(notes, [1,2,3,4], [1]);
expect(getIds(state.notes)).toEqual(getIds(expected.items));
expect(state.selectedNoteIds).toEqual(expected.selectedIds);
}));
it('should delete last remaining note', asyncTest(async () => {
let folders = await createNTestFolders(1);
let notes = await createNTestNotes(1, folders[0]);
let state = initTestState(folders, 0, notes, [0]);
// test action
state = reducer(state, {type: 'NOTE_DELETE', id: notes[0].id});
let expected = createExpectedState(notes, [], []);
expect(getIds(state.notes)).toEqual(getIds(expected.items));
expect(state.selectedNoteIds).toEqual(expected.selectedIds);
}));
it('should delete selected note at bottom', asyncTest(async () => {
let folders = await createNTestFolders(1);
let notes = await createNTestNotes(5, folders[0]);
let state = initTestState(folders, 0, notes, [4]);
// test action
state = reducer(state, {type: 'NOTE_DELETE', id: notes[4].id});
let expected = createExpectedState(notes, [0,1,2,3], [3]);
expect(getIds(state.notes)).toEqual(getIds(expected.items));
expect(state.selectedNoteIds).toEqual(expected.selectedIds);
}));
it('should delete note when a note below is selected', asyncTest(async () => {
let folders = await createNTestFolders(1);
let notes = await createNTestNotes(5, folders[0]);
let state = initTestState(folders, 0, notes, [3]);
// test action
state = reducer(state, {type: 'NOTE_DELETE', id: notes[1].id});
let expected = createExpectedState(notes, [0,2,3,4], [3]);
expect(getIds(state.notes)).toEqual(getIds(expected.items));
expect(state.selectedNoteIds).toEqual(expected.selectedIds);
}));
it('should delete note when a note above is selected', asyncTest(async () => {
let folders = await createNTestFolders(1);
let notes = await createNTestNotes(5, folders[0]);
let state = initTestState(folders, 0, notes, [1]);
// test action
state = reducer(state, {type: 'NOTE_DELETE', id: notes[3].id});
let expected = createExpectedState(notes, [0,1,2,4], [1]);
expect(getIds(state.notes)).toEqual(getIds(expected.items));
expect(state.selectedNoteIds).toEqual(expected.selectedIds);
}));
it('should delete selected notes', asyncTest(async () => {
let folders = await createNTestFolders(1);
let notes = await createNTestNotes(5, folders[0]);
let state = initTestState(folders, 0, notes, [1,2]);
// test action
state = reducer(state, {type: 'NOTE_DELETE', id: notes[1].id});
state = reducer(state, {type: 'NOTE_DELETE', id: notes[2].id});
let expected = createExpectedState(notes, [0,3,4], [3]);
expect(getIds(state.notes)).toEqual(getIds(expected.items));
expect(state.selectedNoteIds).toEqual(expected.selectedIds);
}));
it('should delete note when a notes below it are selected', asyncTest(async () => {
let folders = await createNTestFolders(1);
let notes = await createNTestNotes(5, folders[0]);
let state = initTestState(folders, 0, notes, [3,4]);
// test action
state = reducer(state, {type: 'NOTE_DELETE', id: notes[1].id});
let expected = createExpectedState(notes, [0,2,3,4], [3,4]);
expect(getIds(state.notes)).toEqual(getIds(expected.items));
expect(state.selectedNoteIds).toEqual(expected.selectedIds);
}));
it('should delete note when a notes above it are selected', asyncTest(async () => {
let folders = await createNTestFolders(1);
let notes = await createNTestNotes(5, folders[0]);
let state = initTestState(folders, 0, notes, [1,2]);
// test action
state = reducer(state, {type: 'NOTE_DELETE', id: notes[3].id});
let expected = createExpectedState(notes, [0,1,2,4], [1,2]);
expect(getIds(state.notes)).toEqual(getIds(expected.items));
expect(state.selectedNoteIds).toEqual(expected.selectedIds);
}));
it('should delete notes at end', asyncTest(async () => {
let folders = await createNTestFolders(1);
let notes = await createNTestNotes(5, folders[0]);
let state = initTestState(folders, 0, notes, [3,4]);
// test action
state = reducer(state, {type: 'NOTE_DELETE', id: notes[3].id});
state = reducer(state, {type: 'NOTE_DELETE', id: notes[4].id});
let expected = createExpectedState(notes, [0,1,2], [2]);
expect(getIds(state.notes)).toEqual(getIds(expected.items));
expect(state.selectedNoteIds).toEqual(expected.selectedIds);
}));
it('should delete notes when non-contiguous selection', asyncTest(async () => {
let folders = await createNTestFolders(1);
let notes = await createNTestNotes(5, folders[0]);
let state = initTestState(folders, 0, notes, [0,2,4]);
// test action
state = reducer(state, {type: 'NOTE_DELETE', id: notes[0].id});
state = reducer(state, {type: 'NOTE_DELETE', id: notes[2].id});
state = reducer(state, {type: 'NOTE_DELETE', id: notes[4].id});
let expected = createExpectedState(notes, [1,3], [1]);
expect(getIds(state.notes)).toEqual(getIds(expected.items));
expect(state.selectedNoteIds).toEqual(expected.selectedIds);
}));
// tests for FOLDER_DELETE
it('should delete selected notebook', asyncTest(async () => {
let folders = await createNTestFolders(5);
let notes = await createNTestNotes(5, folders[0]);
let state = initTestState(folders, 2, notes, [2]);
// test action
state = reducer(state, {type: 'FOLDER_DELETE', id: folders[2].id});
let expected = createExpectedState(folders, [0,1,3,4], [3]);
expect(getIds(state.folders)).toEqual(getIds(expected.items));
expect(state.selectedFolderId).toEqual(expected.selectedIds[0]);
}));
it('should delete notebook when a book above is selected', asyncTest(async () => {
let folders = await createNTestFolders(5);
let notes = await createNTestNotes(5, folders[0]);
let state = initTestState(folders, 1, notes, [2]);
// test action
state = reducer(state, {type: 'FOLDER_DELETE', id: folders[2].id});
let expected = createExpectedState(folders, [0,1,3,4], [1]);
expect(getIds(state.folders)).toEqual(getIds(expected.items));
expect(state.selectedFolderId).toEqual(expected.selectedIds[0]);
}));
it('should delete notebook when a book below is selected', asyncTest(async () => {
let folders = await createNTestFolders(5);
let notes = await createNTestNotes(5, folders[0]);
let state = initTestState(folders, 4, notes, [2]);
// test action
state = reducer(state, {type: 'FOLDER_DELETE', id: folders[2].id});
let expected = createExpectedState(folders, [0,1,3,4], [4]);
expect(getIds(state.folders)).toEqual(getIds(expected.items));
expect(state.selectedFolderId).toEqual(expected.selectedIds[0]);
}));
// tests for TAG_DELETE
it('should delete selected tag', asyncTest(async () => {
let tags = await createNTestTags(5);
let state = initTestState(null, null, null, null, tags, [2]);
// test action
state = reducer(state, {type: 'TAG_DELETE', id: tags[2].id});
let expected = createExpectedState(tags, [0,1,3,4], [3]);
expect(getIds(state.tags)).toEqual(getIds(expected.items));
expect(state.selectedTagId).toEqual(expected.selectedIds[0]);
}));
it('should delete tag when a tag above is selected', asyncTest(async () => {
let tags = await createNTestTags(5);
let state = initTestState(null, null, null, null, tags, [2]);
// test action
state = reducer(state, {type: 'TAG_DELETE', id: tags[4].id});
let expected = createExpectedState(tags, [0,1,2,3], [2]);
expect(getIds(state.tags)).toEqual(getIds(expected.items));
expect(state.selectedTagId).toEqual(expected.selectedIds[0]);
}));
it('should delete tag when a tag below is selected', asyncTest(async () => {
let tags = await createNTestTags(5);
let state = initTestState(null, null, null, null, tags, [2]);
// test action
state = reducer(state, {type: 'TAG_DELETE', id: tags[0].id});
let expected = createExpectedState(tags, [1,2,3,4], [2]);
expect(getIds(state.tags)).toEqual(getIds(expected.items));
expect(state.selectedTagId).toEqual(expected.selectedIds[0]);
}));
});

View File

@ -0,0 +1,336 @@
/* eslint-disable no-unused-vars */
require('app-module-path').addPath(__dirname);
const fs = require('fs-extra');
const { asyncTest, setupDatabaseAndSynchronizer, switchClient } = require('test-utils.js');
const InteropService_Exporter_Md = require('lib/services/InteropService_Exporter_Md.js');
const BaseModel = require('lib/BaseModel.js');
const Folder = require('lib/models/Folder.js');
const Resource = require('lib/models/Resource.js');
const Note = require('lib/models/Note.js');
const { shim } = require('lib/shim.js');
jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000;
const exportDir = `${__dirname}/export`;
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});
describe('services_InteropService_Exporter_Md', function() {
beforeEach(async (done) => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
await fs.remove(exportDir);
await fs.mkdirp(exportDir);
done();
});
it('should create resources directory', asyncTest(async () => {
const service = new InteropService_Exporter_Md();
await service.init(exportDir);
expect(await shim.fsDriver().exists(`${exportDir}/_resources/`)).toBe(true);
}));
it('should create note paths and add them to context', asyncTest(async () => {
const exporter = new InteropService_Exporter_Md();
await exporter.init(exportDir);
const itemsToExport = [];
const queueExportItem = (itemType, itemOrId) => {
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
});
};
let folder1 = await Folder.save({ title: 'folder1' });
let note1 = await Note.save({ title: 'note1', parent_id: folder1.id });
let note2 = await Note.save({ title: 'note2', parent_id: folder1.id });
await shim.attachFileToNote(note1, `${__dirname}/../tests/support/photo.jpg`);
note1 = await Note.load(note1.id);
queueExportItem(BaseModel.TYPE_FOLDER, folder1.id);
queueExportItem(BaseModel.TYPE_NOTE, note1);
queueExportItem(BaseModel.TYPE_NOTE, note2);
queueExportItem(BaseModel.TYPE_RESOURCE, (await Note.linkedResourceIds(note1.body))[0]);
let folder2 = await Folder.save({ title: 'folder2' });
let note3 = await Note.save({ title: 'note3', parent_id: folder2.id });
await shim.attachFileToNote(note3, `${__dirname}/../tests/support/photo.jpg`);
note3 = await Note.load(note3.id);
queueExportItem(BaseModel.TYPE_FOLDER, folder2.id);
queueExportItem(BaseModel.TYPE_NOTE, note3);
queueExportItem(BaseModel.TYPE_RESOURCE, (await Note.linkedResourceIds(note3.body))[0]);
expect(!exporter.context() && !(exporter.context().notePaths || Object.keys(exporter.context().notePaths).length)).toBe(false, 'Context should be empty before processing.');
await exporter.processItem(Folder, folder1);
await exporter.processItem(Folder, folder2);
await exporter.prepareForProcessingItemType(BaseModel.TYPE_NOTE, itemsToExport);
expect(Object.keys(exporter.context().notePaths).length).toBe(3, 'There should be 3 note paths in the context.');
expect(exporter.context().notePaths[note1.id]).toBe('folder1/note1.md');
expect(exporter.context().notePaths[note2.id]).toBe('folder1/note2.md');
expect(exporter.context().notePaths[note3.id]).toBe('folder2/note3.md');
}));
it('should handle duplicate note names', asyncTest(async () => {
const exporter = new InteropService_Exporter_Md();
await exporter.init(exportDir);
const itemsToExport = [];
const queueExportItem = (itemType, itemOrId) => {
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
});
};
let folder1 = await Folder.save({ title: 'folder1' });
let note1 = await Note.save({ title: 'note1', parent_id: folder1.id });
let note1_2 = await Note.save({ title: 'note1', parent_id: folder1.id });
queueExportItem(BaseModel.TYPE_FOLDER, folder1.id);
queueExportItem(BaseModel.TYPE_NOTE, note1);
queueExportItem(BaseModel.TYPE_NOTE, note1_2);
await exporter.processItem(Folder, folder1);
await exporter.prepareForProcessingItemType(BaseModel.TYPE_NOTE, itemsToExport);
expect(Object.keys(exporter.context().notePaths).length).toBe(2, 'There should be 2 note paths in the context.');
expect(exporter.context().notePaths[note1.id]).toBe('folder1/note1.md');
expect(exporter.context().notePaths[note1_2.id]).toBe('folder1/note1 (1).md');
}));
it('should not override existing files', asyncTest(async () => {
const exporter = new InteropService_Exporter_Md();
await exporter.init(exportDir);
const itemsToExport = [];
const queueExportItem = (itemType, itemOrId) => {
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
});
};
let folder1 = await Folder.save({ title: 'folder1' });
let note1 = await Note.save({ title: 'note1', parent_id: folder1.id });
queueExportItem(BaseModel.TYPE_FOLDER, folder1.id);
queueExportItem(BaseModel.TYPE_NOTE, note1);
await exporter.processItem(Folder, folder1);
// Create a file with the path of note1 before processing note1
await shim.fsDriver().writeFile(`${exportDir}/folder1/note1.md`, 'Note content', 'utf-8');
await exporter.prepareForProcessingItemType(BaseModel.TYPE_NOTE, itemsToExport);
expect(Object.keys(exporter.context().notePaths).length).toBe(1, 'There should be 1 note paths in the context.');
expect(exporter.context().notePaths[note1.id]).toBe('folder1/note1 (1).md');
}));
it('should save resource files in _resource directory', asyncTest(async () => {
const exporter = new InteropService_Exporter_Md();
await exporter.init(exportDir);
const itemsToExport = [];
const queueExportItem = (itemType, itemOrId) => {
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
});
};
let folder1 = await Folder.save({ title: 'folder1' });
let note1 = await Note.save({ title: 'note1', parent_id: folder1.id });
await shim.attachFileToNote(note1, `${__dirname}/../tests/support/photo.jpg`);
note1 = await Note.load(note1.id);
queueExportItem(BaseModel.TYPE_FOLDER, folder1.id);
queueExportItem(BaseModel.TYPE_NOTE, note1);
queueExportItem(BaseModel.TYPE_RESOURCE, (await Note.linkedResourceIds(note1.body))[0]);
let resource1 = await Resource.load(itemsToExport[2].itemOrId);
let folder2 = await Folder.save({ title: 'folder2', parent_id: folder1.id });
let note2 = await Note.save({ title: 'note2', parent_id: folder2.id });
await shim.attachFileToNote(note2, `${__dirname}/../tests/support/photo.jpg`);
note2 = await Note.load(note2.id);
queueExportItem(BaseModel.TYPE_FOLDER, folder2.id);
queueExportItem(BaseModel.TYPE_NOTE, note2);
queueExportItem(BaseModel.TYPE_RESOURCE, (await Note.linkedResourceIds(note2.body))[0]);
let resource2 = await Resource.load(itemsToExport[5].itemOrId);
await exporter.processResource(resource1, Resource.fullPath(resource1));
await exporter.processResource(resource2, Resource.fullPath(resource2));
expect(await shim.fsDriver().exists(`${exportDir}/_resources/${Resource.filename(resource1)}`)).toBe(true, 'Resource file should be copied to _resources directory.');
expect(await shim.fsDriver().exists(`${exportDir}/_resources/${Resource.filename(resource2)}`)).toBe(true, 'Resource file should be copied to _resources directory.');
}));
it('should save notes in fs', asyncTest(async () => {
const exporter = new InteropService_Exporter_Md();
await exporter.init(exportDir);
const itemsToExport = [];
const queueExportItem = (itemType, itemOrId) => {
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
});
};
let folder1 = await Folder.save({ title: 'folder1' });
let note1 = await Note.save({ title: 'note1', parent_id: folder1.id });
queueExportItem(BaseModel.TYPE_FOLDER, folder1.id);
queueExportItem(BaseModel.TYPE_NOTE, note1);
let folder2 = await Folder.save({ title: 'folder2', parent_id: folder1.id });
let note2 = await Note.save({ title: 'note2', parent_id: folder2.id });
queueExportItem(BaseModel.TYPE_FOLDER, folder2.id);
queueExportItem(BaseModel.TYPE_NOTE, note2);
let folder3 = await Folder.save({ title: 'folder3' });
let note3 = await Note.save({ title: 'note3', parent_id: folder3.id });
queueExportItem(BaseModel.TYPE_FOLDER, folder3.id);
queueExportItem(BaseModel.TYPE_NOTE, note3);
await exporter.processItem(Folder, folder1);
await exporter.processItem(Folder, folder2);
await exporter.processItem(Folder, folder3);
await exporter.prepareForProcessingItemType(BaseModel.TYPE_NOTE, itemsToExport);
await exporter.processItem(Note, note1);
await exporter.processItem(Note, note2);
await exporter.processItem(Note, note3);
expect(await shim.fsDriver().exists(`${exportDir}/${exporter.context().notePaths[note1.id]}`)).toBe(true, 'File should be saved in filesystem.');
expect(await shim.fsDriver().exists(`${exportDir}/${exporter.context().notePaths[note2.id]}`)).toBe(true, 'File should be saved in filesystem.');
expect(await shim.fsDriver().exists(`${exportDir}/${exporter.context().notePaths[note3.id]}`)).toBe(true, 'File should be saved in filesystem.');
}));
it('should replace resource ids with relative paths', asyncTest(async () => {
const exporter = new InteropService_Exporter_Md();
await exporter.init(exportDir);
const itemsToExport = [];
const queueExportItem = (itemType, itemOrId) => {
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
});
};
let folder1 = await Folder.save({ title: 'folder1' });
let note1 = await Note.save({ title: 'note1', parent_id: folder1.id });
await shim.attachFileToNote(note1, `${__dirname}/../tests/support/photo.jpg`);
note1 = await Note.load(note1.id);
queueExportItem(BaseModel.TYPE_NOTE, note1);
let resource1 = await Resource.load((await Note.linkedResourceIds(note1.body))[0]);
let folder2 = await Folder.save({ title: 'folder2', parent_id: folder1.id });
let note2 = await Note.save({ title: 'note2', parent_id: folder2.id });
await shim.attachFileToNote(note2, `${__dirname}/../tests/support/photo.jpg`);
note2 = await Note.load(note2.id);
queueExportItem(BaseModel.TYPE_NOTE, note2);
let resource2 = await Resource.load((await Note.linkedResourceIds(note2.body))[0]);
await exporter.processItem(Folder, folder1);
await exporter.processItem(Folder, folder2);
await exporter.prepareForProcessingItemType(BaseModel.TYPE_NOTE, itemsToExport);
let context = {
resourcePaths: {},
};
context.resourcePaths[resource1.id] = 'resource1.jpg';
context.resourcePaths[resource2.id] = 'resource2.jpg';
exporter.updateContext(context);
await exporter.processItem(Note, note1);
await exporter.processItem(Note, note2);
let note1_body = await shim.fsDriver().readFile(`${exportDir}/${exporter.context().notePaths[note1.id]}`);
let note2_body = await shim.fsDriver().readFile(`${exportDir}/${exporter.context().notePaths[note2.id]}`);
expect(note1_body).toContain('](../_resources/resource1.jpg)', 'Resource id should be replaced with a relative path.');
expect(note2_body).toContain('](../../_resources/resource2.jpg)', 'Resource id should be replaced with a relative path.');
}));
it('should replace note ids with relative paths', asyncTest(async () => {
const exporter = new InteropService_Exporter_Md();
await exporter.init(exportDir);
const itemsToExport = [];
const queueExportItem = (itemType, itemOrId) => {
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
});
};
const changeNoteBodyAndReload = async (note, newBody) => {
note.body = newBody;
await Note.save(note);
return await Note.load(note.id);
};
let folder1 = await Folder.save({ title: 'folder1' });
let note1 = await Note.save({ title: 'note1', parent_id: folder1.id });
let folder2 = await Folder.save({ title: 'folder2', parent_id: folder1.id });
let note2 = await Note.save({ title: 'note2', parent_id: folder2.id });
let folder3 = await Folder.save({ title: 'folder3' });
let note3 = await Note.save({ title: 'note3', parent_id: folder3.id });
note1 = await changeNoteBodyAndReload(note1, `# Some text \n\n [A link to note3](:/${note3.id})`);
note2 = await changeNoteBodyAndReload(note2, `# Some text \n\n [A link to note3](:/${note3.id}) some more text \n ## And some headers \n and [A link to note1](:/${note1.id}) more links`);
note3 = await changeNoteBodyAndReload(note3, `[A link to note3](:/${note2.id})`);
queueExportItem(BaseModel.TYPE_NOTE, note1);
queueExportItem(BaseModel.TYPE_NOTE, note2);
queueExportItem(BaseModel.TYPE_NOTE, note3);
await exporter.processItem(Folder, folder1);
await exporter.processItem(Folder, folder2);
await exporter.processItem(Folder, folder3);
await exporter.prepareForProcessingItemType(BaseModel.TYPE_NOTE, itemsToExport);
await exporter.processItem(Note, note1);
await exporter.processItem(Note, note2);
await exporter.processItem(Note, note3);
let note1_body = await shim.fsDriver().readFile(`${exportDir}/${exporter.context().notePaths[note1.id]}`);
let note2_body = await shim.fsDriver().readFile(`${exportDir}/${exporter.context().notePaths[note2.id]}`);
let note3_body = await shim.fsDriver().readFile(`${exportDir}/${exporter.context().notePaths[note3.id]}`);
expect(note1_body).toContain('](../folder3/note3.md)', 'Note id should be replaced with a relative path.');
expect(note2_body).toContain('](../../folder3/note3.md)', 'Resource id should be replaced with a relative path.');
expect(note2_body).toContain('](../../folder1/note1.md)', 'Resource id should be replaced with a relative path.');
expect(note3_body).toContain('](../folder1/folder2/note2.md)', 'Resource id should be replaced with a relative path.');
}));
it('should url encode relative note links', asyncTest(async () => {
const exporter = new InteropService_Exporter_Md();
await exporter.init(exportDir);
const itemsToExport = [];
const queueExportItem = (itemType, itemOrId) => {
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
});
};
let folder1 = await Folder.save({ title: 'folder with space1' });
let note1 = await Note.save({ title: 'note1 name with space', parent_id: folder1.id });
let note2 = await Note.save({ title: 'note2', parent_id: folder1.id, body: `[link](:/${note1.id})` });
queueExportItem(BaseModel.TYPE_NOTE, note1);
queueExportItem(BaseModel.TYPE_NOTE, note2);
await exporter.processItem(Folder, folder1);
await exporter.prepareForProcessingItemType(BaseModel.TYPE_NOTE, itemsToExport);
await exporter.processItem(Note, note1);
await exporter.processItem(Note, note2);
let note2_body = await shim.fsDriver().readFile(`${exportDir}/${exporter.context().notePaths[note2.id]}`);
expect(note2_body).toContain('[link](../folder%20with%20space1/note1%20name%20with%20space.md)', 'Whitespace in URL should be encoded');
}));
});

View File

@ -462,6 +462,7 @@ class Application extends BaseApplication {
this.dispatch({
type: 'WINDOW_COMMAND',
name: 'exportPdf',
noteId: null,
});
},
});

View File

@ -26,6 +26,7 @@ class NotePropertiesDialog extends React.Component {
id: _('ID'),
user_created_time: _('Created'),
user_updated_time: _('Updated'),
todo_completed: _('Completed'),
location: _('Location'),
source_url: _('URL'),
revisionsLink: _('Note History'),
@ -71,6 +72,11 @@ class NotePropertiesDialog extends React.Component {
formNote.user_updated_time = time.formatMsToLocal(note.user_updated_time);
formNote.user_created_time = time.formatMsToLocal(note.user_created_time);
if (note.todo_completed) {
formNote.todo_completed = time.formatMsToLocal(note.todo_completed);
}
formNote.source_url = note.source_url;
formNote.location = '';
@ -89,6 +95,11 @@ class NotePropertiesDialog extends React.Component {
const note = Object.assign({ id: formNote.id }, this.latLongFromLocation(formNote.location));
note.user_created_time = time.formatLocalToMs(formNote.user_created_time);
note.user_updated_time = time.formatLocalToMs(formNote.user_updated_time);
if (formNote.todo_completed) {
note.todo_completed = time.formatMsToLocal(formNote.todo_completed);
}
note.source_url = formNote.source_url;
return note;
@ -338,7 +349,7 @@ class NotePropertiesDialog extends React.Component {
return dms.format('DDMMss', { decimalPlaces: 0 });
}
if (['user_updated_time', 'user_created_time'].indexOf(key) >= 0) {
if (['user_updated_time', 'user_created_time', 'todo_completed'].indexOf(key) >= 0) {
return time.formatMsToLocal(note[key]);
}

View File

@ -689,13 +689,21 @@ class NoteTextComponent extends React.Component {
if (newTags.length !== oldTags.length) return true;
for (let i = 0; i < newTags.length; ++i) {
let found = false;
let currNewTag = newTags[i];
for (let j = 0; j < oldTags.length; ++j) {
let currOldTag = oldTags[j];
if (currOldTag.id === currNewTag.id && currOldTag.updated_time !== currNewTag.updated_time) {
return true;
if (currOldTag.id === currNewTag.id) {
found = true;
if (currOldTag.updated_time !== currNewTag.updated_time) {
return true;
}
break;
}
}
if (!found) {
return true;
}
}
return false;
@ -1103,9 +1111,11 @@ class NoteTextComponent extends React.Component {
if (!command) return;
let fn = null;
let args = null;
if (command.name === 'exportPdf') {
fn = this.commandSavePdf;
args = {noteId: command.noteId};
} else if (command.name === 'print') {
fn = this.commandPrint;
}
@ -1157,7 +1167,7 @@ class NoteTextComponent extends React.Component {
requestAnimationFrame(() => {
fn = fn.bind(this);
fn();
fn(args);
});
}
@ -1250,7 +1260,7 @@ class NoteTextComponent extends React.Component {
setTimeout(async () => {
if (target === 'pdf') {
try {
const pdfData = await InteropServiceHelper.exportNoteToPdf(this.state.note.id, {
const pdfData = await InteropServiceHelper.exportNoteToPdf(options.noteId, {
printBackground: true,
pageSize: Setting.value('export.pdfPageSize'),
landscape: Setting.value('export.pdfPageOrientation') === 'landscape',
@ -1262,7 +1272,7 @@ class NoteTextComponent extends React.Component {
}
} else if (target === 'printer') {
try {
await InteropServiceHelper.printNote(this.state.note.id, {
await InteropServiceHelper.printNote(options.noteId, {
printBackground: true,
});
} catch (error) {
@ -1276,18 +1286,20 @@ class NoteTextComponent extends React.Component {
}, 100);
}
async commandSavePdf() {
async commandSavePdf(args) {
try {
if (!this.state.note) throw new Error(_('Only one note can be printed or exported to PDF at a time.'));
if (!this.state.note && !args.noteId) throw new Error(_('Only one note can be exported to PDF at a time.'));
const note = (!args.noteId ? this.state.note : await Note.load(args.noteId));
const path = bridge().showSaveDialog({
filters: [{ name: _('PDF File'), extensions: ['pdf'] }],
defaultPath: safeFilename(this.state.note.title),
defaultPath: safeFilename(note.title),
});
if (!path) return;
await this.printTo_('pdf', { path: path });
await this.printTo_('pdf', { path: path, noteId: args.noteId });
} catch (error) {
bridge().showErrorMessageBox(error.message);
}
@ -1295,7 +1307,9 @@ class NoteTextComponent extends React.Component {
async commandPrint() {
try {
await this.printTo_('printer');
if (!this.state.note) throw new Error(_('Only one note can be printed at a time.'));
await this.printTo_('printer', { noteId: this.state.note.id });
} catch (error) {
bridge().showErrorMessageBox(error.message);
}

View File

@ -171,6 +171,7 @@ class NoteListUtils {
props.dispatch({
type: 'WINDOW_COMMAND',
name: 'exportPdf',
noteId: noteIds[0],
});
},
})

View File

@ -450,11 +450,14 @@ class BaseApplication {
refreshFolders = true;
}
if (this.hasGui() && ((action.type == 'SETTING_UPDATE_ONE' && action.key.indexOf('folders.sortOrder') === 0) || action.type == 'SETTING_UPDATE_ALL')) {
if (this.hasGui() && action.type == 'SETTING_UPDATE_ALL') {
refreshFolders = 'now';
}
if (this.hasGui() && ((action.type == 'SETTING_UPDATE_ONE' && action.key == 'showNoteCounts') || action.type == 'SETTING_UPDATE_ALL')) {
if (this.hasGui() && action.type == 'SETTING_UPDATE_ONE' && (
action.key.indexOf('folders.sortOrder') === 0 ||
action.key == 'showNoteCounts' ||
action.key == 'showCompletedTodos')) {
refreshFolders = 'now';
}

View File

@ -26,7 +26,8 @@ class FoldersScreenUtils {
}
if (Setting.value('showNoteCounts')) {
await Folder.addNoteCounts(folders);
await Folder.addNoteCounts(folders,
Setting.value('showCompletedTodos'));
}
return folders;

View File

@ -21,7 +21,10 @@ class FsDriverBase {
return output;
}
async findUniqueFilename(name) {
async findUniqueFilename(name, reservedNames = null) {
if (reservedNames === null) {
reservedNames = [];
}
let counter = 1;
let nameNoExt = filename(name, true);
@ -29,7 +32,8 @@ class FsDriverBase {
if (extension) extension = `.${extension}`;
let nameToTry = nameNoExt + extension;
while (true) {
const exists = await this.exists(nameToTry);
// Check if the filename does not exist in the filesystem and is not reserved
const exists = await this.exists(nameToTry) || reservedNames.includes(nameToTry);
if (!exists) return nameToTry;
nameToTry = `${nameNoExt} (${counter})${extension}`;
counter++;

View File

@ -107,15 +107,19 @@ class Folder extends BaseItem {
// Calculates note counts for all folders and adds the note_count attribute to each folder
// Note: this only calculates the overall number of nodes for this folder and all its descendants
static async addNoteCounts(folders) {
static async addNoteCounts(folders, includeCompletedTodos = true) {
const foldersById = {};
folders.forEach((f) => {
foldersById[f.id] = f;
f.note_count = 0;
});
const where = !includeCompletedTodos ? 'WHERE (notes.is_todo = 0 OR notes.todo_completed = 0)' : '';
const sql = `SELECT folders.id as folder_id, count(notes.parent_id) as note_count
FROM folders LEFT JOIN notes ON notes.parent_id = folders.id
GROUP BY folders.id`;
FROM folders LEFT JOIN notes ON notes.parent_id = folders.id
${where} GROUP BY folders.id`;
const noteCounts = await this.db().selectAll(sql);
noteCounts.forEach((noteCount) => {
let parentId = noteCount.folder_id;

View File

@ -143,6 +143,10 @@ class Note extends BaseItem {
return this.linkedItemIdsByType(BaseModel.TYPE_RESOURCE, body);
}
static async linkedNoteIds(body) {
return this.linkedItemIdsByType(BaseModel.TYPE_NOTE, body);
}
static async replaceResourceInternalToExternalLinks(body) {
const resourceIds = await this.linkedResourceIds(body);
const Resource = this.getClass('Resource');

View File

@ -136,38 +136,69 @@ function folderSetCollapsed(state, action) {
// When deleting a note, tag or folder
function handleItemDelete(state, action) {
const map = {
FOLDER_DELETE: ['folders', 'selectedFolderId'],
NOTE_DELETE: ['notes', 'selectedNoteIds'],
TAG_DELETE: ['tags', 'selectedTagId'],
SEARCH_DELETE: ['searches', 'selectedSearchId'],
FOLDER_DELETE: ['folders', 'selectedFolderId', true],
NOTE_DELETE: ['notes', 'selectedNoteIds', false],
TAG_DELETE: ['tags', 'selectedTagId', true],
SEARCH_DELETE: ['searches', 'selectedSearchId', true],
};
const listKey = map[action.type][0];
const selectedItemKey = map[action.type][1];
const isSingular = map[action.type][2];
const selectedItemKeys = isSingular ? [state[selectedItemKey]] : state[selectedItemKey];
const isSelected = selectedItemKeys.includes(action.id);
let previousIndex = 0;
let newItems = [];
const items = state[listKey];
let newItems = [];
let newSelectedIndexes = [];
for (let i = 0; i < items.length; i++) {
let item = items[i];
if (isSelected) {
// the selected item is deleted so select the following item
// if multiple items are selected then just use the first one
if (selectedItemKeys[0] == item.id) {
newSelectedIndexes.push(newItems.length);
}
} else {
// the selected item/s is not deleted so keep it selected
if (selectedItemKeys.includes(item.id)) {
newSelectedIndexes.push(newItems.length);
}
}
if (item.id == action.id) {
previousIndex = i;
continue;
}
newItems.push(item);
}
if (newItems.length == 0) {
newSelectedIndexes = []; // no remaining items so no selection
} else if (newSelectedIndexes.length == 0) {
newSelectedIndexes.push(0); // no selection exists so select the top
} else {
// when the items at end of list are deleted then select the end
for (let i = 0; i < newSelectedIndexes.length; i++) {
if (newSelectedIndexes[i] >= newItems.length) {
newSelectedIndexes = [newItems.length - 1];
break;
}
}
}
let newState = Object.assign({}, state);
newState[listKey] = newItems;
if (previousIndex >= newItems.length) {
previousIndex = newItems.length - 1;
const newIds = [];
for (let i = 0; i < newSelectedIndexes.length; i++) {
newIds.push(newItems[newSelectedIndexes[i]].id);
}
newState[selectedItemKey] = isSingular ? newIds[0] : newIds;
const newId = previousIndex >= 0 ? newItems[previousIndex].id : null;
newState[selectedItemKey] = action.type === 'NOTE_DELETE' ? [newId] : newId;
if (!newId && newState.notesParentType !== 'Folder') {
if ((newIds.length == 0) && newState.notesParentType !== 'Folder') {
newState.notesParentType = 'Folder';
}

View File

@ -341,6 +341,8 @@ class InteropService {
for (let typeOrderIndex = 0; typeOrderIndex < typeOrder.length; typeOrderIndex++) {
const type = typeOrder[typeOrderIndex];
await exporter.prepareForProcessingItemType(type, itemsToExport);
for (let i = 0; i < itemsToExport.length; i++) {
const itemType = itemsToExport[i].type;

View File

@ -3,7 +3,12 @@
const Setting = require('lib/models/Setting');
class InteropService_Exporter_Base {
constructor() {
this.context_ = {};
}
async init(destDir) {}
async prepareForProcessingItemType(type, itemsToExport) {}
async processItem(ItemClass, item) {}
async processResource(resource, filePath) {}
async close() {}
@ -17,7 +22,7 @@ class InteropService_Exporter_Base {
}
updateContext(context) {
this.context_ = context;
this.context_ = Object.assign(this.context_, context);
}
context() {

View File

@ -1,5 +1,5 @@
const InteropService_Exporter_Base = require('lib/services/InteropService_Exporter_Base');
const { basename, friendlySafeFilename, rtrimSlashes } = require('lib/path-utils.js');
const { basename, friendlySafeFilename } = require('lib/path-utils.js');
const BaseModel = require('lib/BaseModel');
const Folder = require('lib/models/Folder');
const Note = require('lib/models/Note');
@ -31,36 +31,92 @@ class InteropService_Exporter_Md extends InteropService_Exporter_Base {
}
}
async replaceResourceIdsByRelativePaths_(item) {
const linkedResourceIds = await Note.linkedResourceIds(item.body);
const relativePath = rtrimSlashes(await this.makeDirPath_(item, '..'));
async relaceLinkedItemIdsByRelativePaths_(item) {
const relativePathToRoot = await this.makeDirPath_(item, '..');
let newBody = await this.replaceResourceIdsByRelativePaths_(item.body, relativePathToRoot);
return await this.replaceNoteIdsByRelativePaths_(newBody, relativePathToRoot);
}
async replaceResourceIdsByRelativePaths_(noteBody, relativePathToRoot) {
const linkedResourceIds = await Note.linkedResourceIds(noteBody);
const resourcePaths = this.context() && this.context().resourcePaths ? this.context().resourcePaths : {};
let newBody = item.body;
let createRelativePath = function(resourcePath) {
return `${relativePathToRoot}_resources/${basename(resourcePath)}`;
};
return await this.replaceItemIdsByRelativePaths_(noteBody, linkedResourceIds, resourcePaths, createRelativePath);
}
for (let i = 0; i < linkedResourceIds.length; i++) {
const id = linkedResourceIds[i];
const resourcePath = `${relativePath}/_resources/${basename(resourcePaths[id])}`;
newBody = newBody.replace(new RegExp(`:/${id}`, 'g'), resourcePath);
async replaceNoteIdsByRelativePaths_(noteBody, relativePathToRoot) {
const linkedNoteIds = await Note.linkedNoteIds(noteBody);
const notePaths = this.context() && this.context().notePaths ? this.context().notePaths : {};
let createRelativePath = function(notePath) {
return encodeURI(`${relativePathToRoot}${notePath}`.trim());
};
return await this.replaceItemIdsByRelativePaths_(noteBody, linkedNoteIds, notePaths, createRelativePath);
}
async replaceItemIdsByRelativePaths_(noteBody, linkedItemIds, paths, fn_createRelativePath) {
let newBody = noteBody;
for (let i = 0; i < linkedItemIds.length; i++) {
const id = linkedItemIds[i];
let itemPath = fn_createRelativePath(paths[id]);
newBody = newBody.replace(new RegExp(`:/${id}`, 'g'), itemPath);
}
return newBody;
}
async prepareForProcessingItemType(type, itemsToExport) {
if (type === BaseModel.TYPE_NOTE) {
// Create unique file path for the note
const context = {
notePaths: {},
};
for (let i = 0; i < itemsToExport.length; i++) {
const itemType = itemsToExport[i].type;
if (itemType !== type) continue;
const itemOrId = itemsToExport[i].itemOrId;
const note = typeof itemOrId === 'object' ? itemOrId : await Note.load(itemOrId);
if (!note) continue;
let notePath = `${await this.makeDirPath_(note)}${friendlySafeFilename(note.title, null, true)}.md`;
notePath = await shim.fsDriver().findUniqueFilename(`${this.destDir_}/${notePath}`, Object.values(context.notePaths));
context.notePaths[note.id] = notePath;
}
// Strip the absolute path to export dir and keep only the relative paths
const destDir = this.destDir_;
Object.keys(context.notePaths).map(function(id) {
context.notePaths[id] = context.notePaths[id].substr(destDir.length + 1);
});
this.updateContext(context);
}
}
async processItem(ItemClass, item) {
if ([BaseModel.TYPE_NOTE, BaseModel.TYPE_FOLDER].indexOf(item.type_) < 0) return;
const dirPath = `${this.destDir_}/${await this.makeDirPath_(item)}`;
if (item.type_ === BaseModel.TYPE_FOLDER) {
const dirPath = `${this.destDir_}/${await this.makeDirPath_(item)}`;
if (this.createdDirs_.indexOf(dirPath) < 0) {
await shim.fsDriver().mkdir(dirPath);
this.createdDirs_.push(dirPath);
}
if (this.createdDirs_.indexOf(dirPath) < 0) {
await shim.fsDriver().mkdir(dirPath);
this.createdDirs_.push(dirPath);
}
if (item.type_ === BaseModel.TYPE_NOTE) {
let noteFilePath = `${dirPath}/${friendlySafeFilename(item.title, null, true)}.md`;
noteFilePath = await shim.fsDriver().findUniqueFilename(noteFilePath);
const noteBody = await this.replaceResourceIdsByRelativePaths_(item);
} else if (item.type_ === BaseModel.TYPE_NOTE) {
const notePaths = this.context() && this.context().notePaths ? this.context().notePaths : {};
let noteFilePath = `${this.destDir_}/${notePaths[item.id]}`;
let noteBody = await this.relaceLinkedItemIdsByRelativePaths_(item);
const modNote = Object.assign({}, item, { body: noteBody });
const noteContent = await Note.serializeForEdit(modNote);
await shim.fsDriver().writeFile(noteFilePath, noteContent, 'utf-8');