You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-27 20:29:45 +02:00
Compare commits
24 Commits
ios-v1.0.1
...
android-v1
Author | SHA1 | Date | |
---|---|---|---|
|
0ec5518a62 | ||
|
76931370d7 | ||
|
8cf0e4517a | ||
|
e75c62bf0f | ||
|
058285e0b9 | ||
|
795568d8c2 | ||
|
df4933fddd | ||
|
4046a51472 | ||
|
45845f645d | ||
|
d7fd8944f7 | ||
|
3cee671f25 | ||
|
8f2e5faff3 | ||
|
39ddd934f6 | ||
|
9f8a46b9d9 | ||
|
c6698eaea6 | ||
|
8a96cf3434 | ||
|
74d255c056 | ||
|
71aa841265 | ||
|
14a93a9f26 | ||
|
e1fd9c6922 | ||
|
b9db747b5c | ||
|
4a56c76901 | ||
|
6bb3184a72 | ||
|
7fb8fbd450 |
@@ -287,6 +287,8 @@ class AppGui {
|
||||
|
||||
addCommandToConsole(cmd) {
|
||||
if (!cmd) return;
|
||||
const isConfigPassword = cmd.indexOf('config ') >= 0 && cmd.indexOf('password') >= 0;
|
||||
if (isConfigPassword) return;
|
||||
this.stdout(chalk.cyan.bold('> ' + cmd));
|
||||
}
|
||||
|
||||
|
@@ -283,7 +283,7 @@ class Application extends BaseApplication {
|
||||
exit: () => {},
|
||||
showModalOverlay: (text) => {},
|
||||
hideModalOverlay: () => {},
|
||||
stdoutMaxWidth: () => { return 78; },
|
||||
stdoutMaxWidth: () => { return 100; },
|
||||
forceRender: () => {},
|
||||
termSaveState: () => {},
|
||||
termRestoreState: (state) => {},
|
||||
@@ -387,10 +387,11 @@ class Application extends BaseApplication {
|
||||
await this.execCommand(argv);
|
||||
} catch (error) {
|
||||
if (this.showStackTraces_) {
|
||||
console.info(error);
|
||||
console.error(error);
|
||||
} else {
|
||||
console.info(error.message);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
} else { // Otherwise open the GUI
|
||||
this.initRedux();
|
||||
|
@@ -1,5 +1,5 @@
|
||||
const { BaseCommand } = require('./base-command.js');
|
||||
const { Exporter } = require('lib/services/exporter.js');
|
||||
const InteropService = require('lib/services/InteropService.js');
|
||||
const BaseModel = require('lib/BaseModel.js');
|
||||
const Note = require('lib/models/Note.js');
|
||||
const { reg } = require('lib/registry.js');
|
||||
@@ -10,15 +10,21 @@ const fs = require('fs-extra');
|
||||
class Command extends BaseCommand {
|
||||
|
||||
usage() {
|
||||
return 'export <directory>';
|
||||
return 'export <path>';
|
||||
}
|
||||
|
||||
description() {
|
||||
return _('Exports Joplin data to the given directory. By default, it will export the complete database including notebooks, notes, tags and resources.');
|
||||
return _('Exports Joplin data to the given path. By default, it will export the complete database including notebooks, notes, tags and resources.');
|
||||
}
|
||||
|
||||
options() {
|
||||
const service = new InteropService();
|
||||
const formats = service.modules()
|
||||
.filter(m => m.type === 'exporter')
|
||||
.map(m => m.format + (m.description ? ' (' + m.description + ')' : ''));
|
||||
|
||||
return [
|
||||
['--format <format>', _('Destination format: %s', formats.join(', '))],
|
||||
['--note <note>', _('Exports only the given note.')],
|
||||
['--notebook <notebook>', _('Exports only the given notebook.')],
|
||||
];
|
||||
@@ -26,13 +32,9 @@ class Command extends BaseCommand {
|
||||
|
||||
async action(args) {
|
||||
let exportOptions = {};
|
||||
exportOptions.destDir = args.directory;
|
||||
exportOptions.writeFile = (filePath, data) => {
|
||||
return fs.writeFile(filePath, data);
|
||||
};
|
||||
exportOptions.copyFile = (source, dest) => {
|
||||
return fs.copy(source, dest, { overwrite: true });
|
||||
};
|
||||
exportOptions.path = args.path;
|
||||
|
||||
exportOptions.format = args.options.format ? args.options.format : 'jex';
|
||||
|
||||
if (args.options.note) {
|
||||
|
||||
@@ -48,10 +50,10 @@ class Command extends BaseCommand {
|
||||
|
||||
}
|
||||
|
||||
const exporter = new Exporter();
|
||||
const result = await exporter.export(exportOptions);
|
||||
const service = new InteropService();
|
||||
const result = await service.export(exportOptions);
|
||||
|
||||
reg.logger().info('Export result: ', result);
|
||||
result.warnings.map((w) => this.stdout(w));
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -1,68 +0,0 @@
|
||||
const { BaseCommand } = require('./base-command.js');
|
||||
const { app } = require('./app.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const Folder = require('lib/models/Folder.js');
|
||||
const { importEnex } = require('lib/import-enex');
|
||||
const { filename, basename } = require('lib/path-utils.js');
|
||||
const { cliUtils } = require('./cli-utils.js');
|
||||
|
||||
class Command extends BaseCommand {
|
||||
|
||||
usage() {
|
||||
return 'import-enex <file> [notebook]';
|
||||
}
|
||||
|
||||
description() {
|
||||
return _('Imports an Evernote notebook file (.enex file).');
|
||||
}
|
||||
|
||||
options() {
|
||||
return [
|
||||
['-f, --force', _('Do not ask for confirmation.')],
|
||||
];
|
||||
}
|
||||
|
||||
async action(args) {
|
||||
let filePath = args.file;
|
||||
let folder = null;
|
||||
let folderTitle = args['notebook'];
|
||||
let force = args.options.force === true;
|
||||
|
||||
if (!folderTitle) folderTitle = filename(filePath);
|
||||
folder = await Folder.loadByField('title', folderTitle);
|
||||
const msg = folder ? _('File "%s" will be imported into existing notebook "%s". Continue?', basename(filePath), folderTitle) : _('New notebook "%s" will be created and file "%s" will be imported into it. Continue?', folderTitle, basename(filePath));
|
||||
const ok = force ? true : await this.prompt(msg);
|
||||
if (!ok) return;
|
||||
|
||||
let lastProgress = '';
|
||||
|
||||
let options = {
|
||||
onProgress: (progressState) => {
|
||||
let line = [];
|
||||
line.push(_('Found: %d.', progressState.loaded));
|
||||
line.push(_('Created: %d.', progressState.created));
|
||||
if (progressState.updated) line.push(_('Updated: %d.', progressState.updated));
|
||||
if (progressState.skipped) line.push(_('Skipped: %d.', progressState.skipped));
|
||||
if (progressState.resourcesCreated) line.push(_('Resources: %d.', progressState.resourcesCreated));
|
||||
if (progressState.notesTagged) line.push(_('Tagged: %d.', progressState.notesTagged));
|
||||
lastProgress = line.join(' ');
|
||||
cliUtils.redraw(lastProgress);
|
||||
},
|
||||
onError: (error) => {
|
||||
let s = error.trace ? error.trace : error.toString();
|
||||
this.stdout(s);
|
||||
},
|
||||
}
|
||||
|
||||
folder = !folder ? await Folder.save({ title: folderTitle }) : folder;
|
||||
|
||||
app().gui().showConsole();
|
||||
this.stdout(_('Importing notes...'));
|
||||
await importEnex(folder.id, filePath, options);
|
||||
cliUtils.redrawDone();
|
||||
this.stdout(_('The notes have been imported: %s', lastProgress));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = Command;
|
75
CliClient/app/command-import.js
Normal file
75
CliClient/app/command-import.js
Normal file
@@ -0,0 +1,75 @@
|
||||
const { BaseCommand } = require('./base-command.js');
|
||||
const InteropService = require('lib/services/InteropService.js');
|
||||
const BaseModel = require('lib/BaseModel.js');
|
||||
const Note = require('lib/models/Note.js');
|
||||
const { filename, basename, fileExtension } = require('lib/path-utils.js');
|
||||
const { importEnex } = require('lib/import-enex');
|
||||
const { cliUtils } = require('./cli-utils.js');
|
||||
const { reg } = require('lib/registry.js');
|
||||
const { app } = require('./app.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const fs = require('fs-extra');
|
||||
|
||||
class Command extends BaseCommand {
|
||||
|
||||
usage() {
|
||||
return 'import <path> [notebook]';
|
||||
}
|
||||
|
||||
description() {
|
||||
return _('Imports data into Joplin.');
|
||||
}
|
||||
|
||||
options() {
|
||||
const service = new InteropService();
|
||||
const formats = service.modules().filter(m => m.type === 'importer').map(m => m.format);
|
||||
|
||||
return [
|
||||
['--format <format>', _('Source format: %s', (['auto'].concat(formats)).join(', '))],
|
||||
['-f, --force', _('Do not ask for confirmation.')],
|
||||
];
|
||||
}
|
||||
|
||||
async action(args) {
|
||||
let folder = await app().loadItem(BaseModel.TYPE_FOLDER, args.notebook);
|
||||
|
||||
if (args.notebook && !folder) throw new Error(_('Cannot find "%s".', args.notebook));
|
||||
|
||||
const importOptions = {};
|
||||
importOptions.path = args.path;
|
||||
importOptions.format = args.options.format ? args.options.format : 'auto';
|
||||
importOptions.destinationFolderId = folder ? folder.id : null;
|
||||
|
||||
let lastProgress = '';
|
||||
|
||||
// onProgress/onError supported by Enex import only
|
||||
|
||||
importOptions.onProgress = (progressState) => {
|
||||
let line = [];
|
||||
line.push(_('Found: %d.', progressState.loaded));
|
||||
line.push(_('Created: %d.', progressState.created));
|
||||
if (progressState.updated) line.push(_('Updated: %d.', progressState.updated));
|
||||
if (progressState.skipped) line.push(_('Skipped: %d.', progressState.skipped));
|
||||
if (progressState.resourcesCreated) line.push(_('Resources: %d.', progressState.resourcesCreated));
|
||||
if (progressState.notesTagged) line.push(_('Tagged: %d.', progressState.notesTagged));
|
||||
lastProgress = line.join(' ');
|
||||
cliUtils.redraw(lastProgress);
|
||||
};
|
||||
|
||||
importOptions.onError = (error) => {
|
||||
let s = error.trace ? error.trace : error.toString();
|
||||
this.stdout(s);
|
||||
};
|
||||
|
||||
app().gui().showConsole();
|
||||
this.stdout(_('Importing notes...'));
|
||||
const service = new InteropService();
|
||||
const result = await service.import(importOptions);
|
||||
result.warnings.map((w) => this.stdout(w));
|
||||
cliUtils.redrawDone();
|
||||
if (lastProgress) this.stdout(_('The notes have been imported: %s', lastProgress));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = Command;
|
@@ -10,7 +10,6 @@ const { cliUtils } = require('./cli-utils.js');
|
||||
const md5 = require('md5');
|
||||
const locker = require('proper-lockfile');
|
||||
const fs = require('fs-extra');
|
||||
const osTmpdir = require('os-tmpdir');
|
||||
const SyncTargetRegistry = require('lib/SyncTargetRegistry');
|
||||
|
||||
class Command extends BaseCommand {
|
||||
@@ -101,7 +100,7 @@ class Command extends BaseCommand {
|
||||
this.releaseLockFn_ = null;
|
||||
|
||||
// Lock is unique per profile/database
|
||||
const lockFilePath = osTmpdir() + '/synclock_' + md5(escape(Setting.value('profileDir'))); // https://github.com/pvorb/node-md5/issues/41
|
||||
const lockFilePath = require('os').tmpdir() + '/synclock_' + md5(escape(Setting.value('profileDir'))); // https://github.com/pvorb/node-md5/issues/41
|
||||
if (!await fs.pathExists(lockFilePath)) await fs.writeFile(lockFilePath, 'synclock');
|
||||
|
||||
try {
|
||||
|
@@ -133,7 +133,8 @@ class StatusBarWidget extends BaseWidget {
|
||||
resolveResult = input ? input.trim() : input;
|
||||
// Add the command to history but only if it's longer than one character.
|
||||
// Below that it's usually an answer like "y"/"n", etc.
|
||||
if (!isSecurePrompt && input && input.length > 1) this.history_.push(input);
|
||||
const isConfigPassword = input.indexOf('config ') >= 0 && input.indexOf('password') >= 0;
|
||||
if (!isSecurePrompt && input && input.length > 1 && !isConfigPassword) this.history_.push(input);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -71,37 +71,10 @@ process.stdout.on('error', function( err ) {
|
||||
|
||||
|
||||
// async function main() {
|
||||
// const WebDavApi = require('lib/WebDavApi');
|
||||
// const api = new WebDavApi('http://nextcloud.local/remote.php/dav/files/admin/Joplin', { username: 'admin', password: '1234567' });
|
||||
// const { FileApiDriverWebDav } = new require('lib/file-api-driver-webdav');
|
||||
// const driver = new FileApiDriverWebDav(api);
|
||||
|
||||
// const stat = await driver.stat('');
|
||||
// console.info(stat);
|
||||
|
||||
// // const stat = await driver.stat('testing.txt');
|
||||
// // console.info(stat);
|
||||
|
||||
|
||||
// // const content = await driver.get('testing.txta');
|
||||
// // console.info(content);
|
||||
|
||||
// // const content = await driver.get('testing.txta', { target: 'file', path: '/var/www/joplin/CliClient/testing-file.txt' });
|
||||
// // console.info(content);
|
||||
|
||||
// // const content = await driver.mkdir('newdir5');
|
||||
// // console.info(content);
|
||||
|
||||
// //await driver.put('myfile4.md', 'this is my content');
|
||||
|
||||
// // await driver.put('testimg.jpg', null, { source: 'file', path: '/mnt/d/test.jpg' });
|
||||
|
||||
// // await driver.delete('myfile4.md');
|
||||
|
||||
// // const deltaResult = await driver.delta('', {
|
||||
// // allItemIdsHandler: () => { return []; }
|
||||
// // });
|
||||
// // console.info(deltaResult);
|
||||
// const InteropService = require('lib/services/InteropService');
|
||||
// const service = new InteropService();
|
||||
// console.info(service.moduleByFormat('importer', 'enex'));
|
||||
// //await service.modules();
|
||||
// }
|
||||
|
||||
// main().catch((error) => { console.error(error); });
|
||||
|
37
CliClient/package-lock.json
generated
37
CliClient/package-lock.json
generated
@@ -435,6 +435,14 @@
|
||||
"universalify": "0.1.1"
|
||||
}
|
||||
},
|
||||
"fs-minipass": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.5.tgz",
|
||||
"integrity": "sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ==",
|
||||
"requires": {
|
||||
"minipass": "2.2.1"
|
||||
}
|
||||
},
|
||||
"fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
@@ -858,9 +866,9 @@
|
||||
}
|
||||
},
|
||||
"minizlib": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.0.4.tgz",
|
||||
"integrity": "sha512-sN4U9tIJtBRwKbwgFh9qJfrPIQ/GGTRr1MGqkgOeMTLy8/lM0FcWU//FqlnZ3Vb7gJ+Mxh3FOg1EklibdajbaQ==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.1.0.tgz",
|
||||
"integrity": "sha512-4T6Ur/GctZ27nHfpt9THOdRZNgyJ9FZchYO1ceg5S8Q3DNLCKYy44nCZzgCJgcvx2UM8czmqak5BCxJMrq37lA==",
|
||||
"requires": {
|
||||
"minipass": "2.2.1"
|
||||
}
|
||||
@@ -1161,6 +1169,20 @@
|
||||
"semver": "5.4.1",
|
||||
"simple-get": "2.7.0",
|
||||
"tar": "3.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"tar": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-3.2.1.tgz",
|
||||
"integrity": "sha512-ZSzds1E0IqutvMU8HxjMaU8eB7urw2fGwTq88ukDOVuUIh0656l7/P7LiVPxhO5kS4flcRJQk8USG+cghQbTUQ==",
|
||||
"requires": {
|
||||
"chownr": "1.0.1",
|
||||
"minipass": "2.2.1",
|
||||
"minizlib": "1.1.0",
|
||||
"mkdirp": "0.5.1",
|
||||
"yallist": "3.0.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"simple-concat": {
|
||||
@@ -2005,13 +2027,14 @@
|
||||
"integrity": "sha1-Kb9hXUqnEhvdiYsi1LP5vE4qoD0="
|
||||
},
|
||||
"tar": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-3.2.1.tgz",
|
||||
"integrity": "sha512-ZSzds1E0IqutvMU8HxjMaU8eB7urw2fGwTq88ukDOVuUIh0656l7/P7LiVPxhO5kS4flcRJQk8USG+cghQbTUQ==",
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-4.4.0.tgz",
|
||||
"integrity": "sha512-gJlTiiErwo96K904FnoYWl+5+FBgS+FimU6GMh66XLdLa55al8+d4jeDfPoGwSNHdtWI5FJP6xurmVqhBuGJpQ==",
|
||||
"requires": {
|
||||
"chownr": "1.0.1",
|
||||
"fs-minipass": "1.2.5",
|
||||
"minipass": "2.2.1",
|
||||
"minizlib": "1.0.4",
|
||||
"minizlib": "1.1.0",
|
||||
"mkdirp": "0.5.1",
|
||||
"yallist": "3.0.2"
|
||||
}
|
||||
|
@@ -44,7 +44,6 @@
|
||||
"node-emoji": "^1.8.1",
|
||||
"node-fetch": "^1.7.1",
|
||||
"node-persist": "^2.1.0",
|
||||
"os-tmpdir": "^1.0.2",
|
||||
"promise": "^7.1.1",
|
||||
"proper-lockfile": "^2.0.1",
|
||||
"query-string": "4.3.4",
|
||||
@@ -57,6 +56,7 @@
|
||||
"string-padding": "^1.0.2",
|
||||
"string-to-stream": "^1.1.0",
|
||||
"strip-ansi": "^4.0.0",
|
||||
"tar": "^4.4.0",
|
||||
"tcp-port-used": "^0.1.2",
|
||||
"tkwidgets": "^0.5.25",
|
||||
"url-parse": "^1.2.0",
|
||||
|
@@ -9,7 +9,7 @@ rsync -a "$ROOT_DIR/build/locales/" "$BUILD_DIR/locales/"
|
||||
mkdir -p "$BUILD_DIR/data"
|
||||
|
||||
if [[ $TEST_FILE == "" ]]; then
|
||||
(cd "$ROOT_DIR" && npm test tests-build/synchronizer.js tests-build/encryption.js tests-build/ArrayUtils.js tests-build/models_Setting.js)
|
||||
(cd "$ROOT_DIR" && npm test tests-build/synchronizer.js tests-build/encryption.js tests-build/ArrayUtils.js tests-build/models_Setting.js tests-build/services_InteropService.js)
|
||||
else
|
||||
(cd "$ROOT_DIR" && npm test tests-build/$TEST_FILE.js)
|
||||
fi
|
212
CliClient/tests/services_InteropService.js
Normal file
212
CliClient/tests/services_InteropService.js
Normal file
@@ -0,0 +1,212 @@
|
||||
require('app-module-path').addPath(__dirname);
|
||||
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const { asyncTest, fileContentEqual, setupDatabase, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync } = require('test-utils.js');
|
||||
const InteropService = require('lib/services/InteropService.js');
|
||||
const Folder = require('lib/models/Folder.js');
|
||||
const Note = require('lib/models/Note.js');
|
||||
const Tag = require('lib/models/Tag.js');
|
||||
const NoteTag = require('lib/models/NoteTag.js');
|
||||
const Resource = require('lib/models/Resource.js');
|
||||
const fs = require('fs-extra');
|
||||
const ArrayUtils = require('lib/ArrayUtils');
|
||||
const ObjectUtils = require('lib/ObjectUtils');
|
||||
const { shim } = require('lib/shim.js');
|
||||
|
||||
process.on('unhandledRejection', (reason, p) => {
|
||||
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
||||
});
|
||||
|
||||
function exportDir() {
|
||||
return __dirname + '/export';
|
||||
}
|
||||
|
||||
function fieldsEqual(model1, model2, fieldNames) {
|
||||
for (let i = 0; i < fieldNames.length; i++) {
|
||||
const f = fieldNames[i];
|
||||
expect(model1[f]).toBe(model2[f], 'For key ' + f);
|
||||
}
|
||||
}
|
||||
|
||||
describe('services_InteropService', function() {
|
||||
|
||||
beforeEach(async (done) => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
|
||||
const dir = exportDir();
|
||||
await fs.remove(dir);
|
||||
await fs.mkdirp(dir);
|
||||
done();
|
||||
});
|
||||
|
||||
it('should export and import folders', asyncTest(async () => {
|
||||
const service = new InteropService();
|
||||
let folder1 = await Folder.save({ title: "folder1" });
|
||||
folder1 = await Folder.load(folder1.id);
|
||||
const filePath = exportDir() + '/test.jex';
|
||||
|
||||
await service.export({ path: filePath });
|
||||
|
||||
await Folder.delete(folder1.id);
|
||||
|
||||
await service.import({ path: filePath });
|
||||
|
||||
// Check that a new folder, with a new ID, has been created
|
||||
|
||||
expect(await Folder.count()).toBe(1);
|
||||
let folder2 = (await Folder.all())[0];
|
||||
expect(folder2.id).not.toBe(folder1.id);
|
||||
expect(folder2.title).toBe(folder1.title);
|
||||
|
||||
await service.import({ path: filePath });
|
||||
|
||||
// As there was already a folder with the same title, check that the new one has been renamed
|
||||
|
||||
await Folder.delete(folder2.id);
|
||||
let folder3 = (await Folder.all())[0];
|
||||
expect(await Folder.count()).toBe(1);
|
||||
expect(folder3.title).not.toBe(folder2.title);
|
||||
|
||||
let fieldNames = Folder.fieldNames();
|
||||
fieldNames = ArrayUtils.removeElement(fieldNames, 'id');
|
||||
fieldNames = ArrayUtils.removeElement(fieldNames, 'title');
|
||||
|
||||
fieldsEqual(folder3, folder1, fieldNames);
|
||||
}));
|
||||
|
||||
it('should export and import folders and notes', asyncTest(async () => {
|
||||
const service = new InteropService();
|
||||
let folder1 = await Folder.save({ title: "folder1" });
|
||||
let note1 = await Note.save({ title: 'ma note', parent_id: folder1.id });
|
||||
note1 = await Note.load(note1.id);
|
||||
const filePath = exportDir() + '/test.jex';
|
||||
|
||||
await service.export({ path: filePath });
|
||||
|
||||
await Folder.delete(folder1.id);
|
||||
await Note.delete(note1.id);
|
||||
|
||||
await service.import({ path: filePath });
|
||||
|
||||
expect(await Note.count()).toBe(1);
|
||||
let note2 = (await Note.all())[0];
|
||||
let folder2 = (await Folder.all())[0];
|
||||
|
||||
expect(note1.parent_id).not.toBe(note2.parent_id);
|
||||
expect(note1.id).not.toBe(note2.id);
|
||||
expect(note2.parent_id).toBe(folder2.id);
|
||||
|
||||
let fieldNames = Note.fieldNames();
|
||||
fieldNames = ArrayUtils.removeElement(fieldNames, 'id');
|
||||
fieldNames = ArrayUtils.removeElement(fieldNames, 'parent_id');
|
||||
|
||||
fieldsEqual(note1, note2, fieldNames);
|
||||
|
||||
await service.import({ path: filePath });
|
||||
|
||||
note2 = (await Note.all())[0];
|
||||
let note3 = (await Note.all())[1];
|
||||
|
||||
expect(note2.id).not.toBe(note3.id);
|
||||
expect(note2.parent_id).not.toBe(note3.parent_id);
|
||||
|
||||
fieldsEqual(note2, note3, fieldNames);
|
||||
}));
|
||||
|
||||
it('should export and import notes to specific folder', asyncTest(async () => {
|
||||
const service = new InteropService();
|
||||
let folder1 = await Folder.save({ title: "folder1" });
|
||||
let note1 = await Note.save({ title: 'ma note', parent_id: folder1.id });
|
||||
note1 = await Note.load(note1.id);
|
||||
const filePath = exportDir() + '/test.jex';
|
||||
|
||||
await service.export({ path: filePath });
|
||||
|
||||
await Note.delete(note1.id);
|
||||
|
||||
await service.import({ path: filePath, destinationFolderId: folder1.id });
|
||||
|
||||
expect(await Note.count()).toBe(1);
|
||||
expect(await Folder.count()).toBe(1);
|
||||
|
||||
expect(await checkThrowAsync(async () => await service.import({ path: filePath, destinationFolderId: 'oops' }))).toBe(true);
|
||||
}));
|
||||
|
||||
it('should export and import tags', asyncTest(async () => {
|
||||
const service = new InteropService();
|
||||
const filePath = exportDir() + '/test.jex';
|
||||
let folder1 = await Folder.save({ title: "folder1" });
|
||||
let note1 = await Note.save({ title: 'ma note', parent_id: folder1.id });
|
||||
let tag1 = await Tag.save({ title: 'mon tag' });
|
||||
tag1 = await Tag.load(tag1.id);
|
||||
await Tag.addNote(tag1.id, note1.id);
|
||||
|
||||
await service.export({ path: filePath });
|
||||
|
||||
await Folder.delete(folder1.id);
|
||||
await Note.delete(note1.id);
|
||||
await Tag.delete(tag1.id);
|
||||
|
||||
await service.import({ path: filePath });
|
||||
|
||||
expect(await Tag.count()).toBe(1);
|
||||
let tag2 = (await Tag.all())[0];
|
||||
let note2 = (await Note.all())[0];
|
||||
expect(tag1.id).not.toBe(tag2.id);
|
||||
|
||||
let fieldNames = Note.fieldNames();
|
||||
fieldNames = ArrayUtils.removeElement(fieldNames, 'id');
|
||||
fieldsEqual(tag1, tag2, fieldNames);
|
||||
|
||||
let noteIds = await Tag.noteIds(tag2.id);
|
||||
expect(noteIds.length).toBe(1);
|
||||
expect(noteIds[0]).toBe(note2.id);
|
||||
|
||||
await service.import({ path: filePath });
|
||||
|
||||
// If importing again, no new tag should be created as one with
|
||||
// the same name already existed. The newly imported note should
|
||||
// however go under that already existing tag.
|
||||
expect(await Tag.count()).toBe(1);
|
||||
noteIds = await Tag.noteIds(tag2.id);
|
||||
expect(noteIds.length).toBe(2);
|
||||
}));
|
||||
|
||||
it('should export and import resources', asyncTest(async () => {
|
||||
const service = new InteropService();
|
||||
const filePath = exportDir() + '/test.jex';
|
||||
let folder1 = await Folder.save({ title: "folder1" });
|
||||
let note1 = await Note.save({ title: 'ma note', parent_id: folder1.id });
|
||||
await shim.attachFileToNote(note1, __dirname + '/../tests/support/photo.jpg');
|
||||
note1 = await Note.load(note1.id);
|
||||
let resourceIds = Note.linkedResourceIds(note1.body);
|
||||
let resource1 = await Resource.load(resourceIds[0]);
|
||||
|
||||
await service.export({ path: filePath });
|
||||
|
||||
await Note.delete(note1.id);
|
||||
|
||||
await service.import({ path: filePath });
|
||||
|
||||
expect(await Resource.count()).toBe(2);
|
||||
|
||||
let note2 = (await Note.all())[0];
|
||||
expect(note2.body).not.toBe(note1.body);
|
||||
resourceIds = Note.linkedResourceIds(note2.body);
|
||||
expect(resourceIds.length).toBe(1);
|
||||
let resource2 = await Resource.load(resourceIds[0]);
|
||||
expect(resource2.id).not.toBe(resource1.id);
|
||||
|
||||
let fieldNames = Note.fieldNames();
|
||||
fieldNames = ArrayUtils.removeElement(fieldNames, 'id');
|
||||
fieldsEqual(resource1, resource2, fieldNames);
|
||||
|
||||
const resourcePath1 = Resource.fullPath(resource1);
|
||||
const resourcePath2 = Resource.fullPath(resource2);
|
||||
|
||||
expect(resourcePath1).not.toBe(resourcePath2);
|
||||
expect(fileContentEqual(resourcePath1, resourcePath2)).toBe(true);
|
||||
}));
|
||||
|
||||
});
|
@@ -41,13 +41,14 @@ class ElectronAppWrapper {
|
||||
const windowState = windowStateKeeper({
|
||||
defaultWidth: 800,
|
||||
defaultHeight: 600,
|
||||
file: 'window-state-' + this.env_ + '.json',
|
||||
});
|
||||
|
||||
const windowOptions = {
|
||||
'x': windowState.x,
|
||||
'y': windowState.y,
|
||||
'width': windowState.width,
|
||||
'height': windowState.height,
|
||||
x: windowState.x,
|
||||
y: windowState.y,
|
||||
width: windowState.width,
|
||||
height: windowState.height,
|
||||
};
|
||||
|
||||
// Linux icon workaround for bug https://github.com/electron-userland/electron-builder/issues/2098
|
||||
@@ -78,7 +79,7 @@ class ElectronAppWrapper {
|
||||
this.win_ = null;
|
||||
} else {
|
||||
event.preventDefault();
|
||||
this.win_.hide();
|
||||
this.hide();
|
||||
}
|
||||
} else {
|
||||
if (this.trayShown() && !this.willQuitApp_) {
|
||||
@@ -117,6 +118,12 @@ class ElectronAppWrapper {
|
||||
return !!this.tray_;
|
||||
}
|
||||
|
||||
// This method is used in macOS only to hide the whole app (and not just the main window)
|
||||
// including the menu bar. This follows the macOS way of hidding an app.
|
||||
hide() {
|
||||
this.electronApp_.hide();
|
||||
}
|
||||
|
||||
buildDir() {
|
||||
if (this.buildDir_) return this.buildDir_;
|
||||
let dir = __dirname + '/build';
|
||||
@@ -129,11 +136,24 @@ class ElectronAppWrapper {
|
||||
return dir;
|
||||
}
|
||||
|
||||
trayIconFilename_() {
|
||||
let output = '';
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
output = 'macos-16x16Template.png'; // Electron Template Image format
|
||||
} else {
|
||||
output = '16x16.png';
|
||||
}
|
||||
|
||||
if (this.env_ === 'dev') output = '16x16-dev.png'
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// Note: this must be called only after the "ready" event of the app has been dispatched
|
||||
createTray(contextMenu) {
|
||||
try {
|
||||
const iconFilename = this.env_ === 'dev' ? '16x16-dev.png' : '16x16.png';
|
||||
this.tray_ = new Tray(this.buildDir() + '/icons/' + iconFilename)
|
||||
this.tray_ = new Tray(this.buildDir() + '/icons/' + this.trayIconFilename_())
|
||||
this.tray_.setToolTip(this.electronApp_.getName())
|
||||
this.tray_.setContextMenu(contextMenu)
|
||||
|
||||
@@ -151,11 +171,30 @@ class ElectronAppWrapper {
|
||||
this.tray_ = null;
|
||||
}
|
||||
|
||||
ensureSingleInstance() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const alreadyRunning = this.electronApp_.makeSingleInstance((commandLine, workingDirectory) => {
|
||||
const win = this.window();
|
||||
if (!win) return;
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
});
|
||||
|
||||
if (alreadyRunning) this.electronApp_.quit();
|
||||
|
||||
resolve(alreadyRunning);
|
||||
});
|
||||
}
|
||||
|
||||
async start() {
|
||||
// Since we are doing other async things before creating the window, we might miss
|
||||
// the "ready" event. So we use the function below to make sure that the app is ready.
|
||||
await this.waitForElectronAppReady();
|
||||
|
||||
const alreadyRunning = await this.ensureSingleInstance();
|
||||
if (alreadyRunning) return;
|
||||
|
||||
this.createWindow();
|
||||
|
||||
this.electronApp_.on('before-quit', () => {
|
||||
|
@@ -20,6 +20,7 @@ const packageInfo = require('./packageInfo.js');
|
||||
const AlarmService = require('lib/services/AlarmService.js');
|
||||
const AlarmServiceDriverNode = require('lib/services/AlarmServiceDriverNode');
|
||||
const DecryptionWorker = require('lib/services/DecryptionWorker');
|
||||
const InteropService = require('lib/services/InteropService');
|
||||
|
||||
const { bridge } = require('electron').remote.require('./bridge');
|
||||
const Menu = bridge().Menu;
|
||||
@@ -175,6 +176,128 @@ class Application extends BaseApplication {
|
||||
updateMenu(screen) {
|
||||
if (this.lastMenuScreen_ === screen) return;
|
||||
|
||||
const sortNoteItems = [];
|
||||
const sortNoteOptions = Setting.enumOptions('notes.sortOrder.field');
|
||||
for (let field in sortNoteOptions) {
|
||||
if (!sortNoteOptions.hasOwnProperty(field)) continue;
|
||||
sortNoteItems.push({
|
||||
label: sortNoteOptions[field],
|
||||
screens: ['Main'],
|
||||
type: 'checkbox',
|
||||
checked: Setting.value('notes.sortOrder.field') === field,
|
||||
click: () => {
|
||||
Setting.setValue('notes.sortOrder.field', field);
|
||||
this.refreshMenu();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const importItems = [];
|
||||
const exportItems = [];
|
||||
const ioService = new InteropService();
|
||||
const ioModules = ioService.modules();
|
||||
for (let i = 0; i < ioModules.length; i++) {
|
||||
const module = ioModules[i];
|
||||
if (module.type === 'exporter') {
|
||||
exportItems.push({
|
||||
label: module.format + ' - ' + module.description,
|
||||
screens: ['Main'],
|
||||
click: async () => {
|
||||
let path = null;
|
||||
|
||||
if (module.target === 'file') {
|
||||
path = bridge().showSaveDialog({
|
||||
filters: [{ name: module.description, extensions: [module.fileExtension]}]
|
||||
});
|
||||
} else {
|
||||
path = bridge().showOpenDialog({
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
});
|
||||
}
|
||||
|
||||
if (!path || (Array.isArray(path) && !path.length)) return;
|
||||
|
||||
if (Array.isArray(path)) path = path[0];
|
||||
|
||||
this.dispatch({
|
||||
type: 'WINDOW_COMMAND',
|
||||
name: 'showModalMessage',
|
||||
message: _('Exporting to "%s" as "%s" format. Please wait...', path, module.format),
|
||||
});
|
||||
|
||||
const exportOptions = {};
|
||||
exportOptions.path = path;
|
||||
exportOptions.format = module.format;
|
||||
|
||||
const service = new InteropService();
|
||||
const result = await service.export(exportOptions);
|
||||
|
||||
console.info('Export result: ', result);
|
||||
|
||||
this.dispatch({
|
||||
type: 'WINDOW_COMMAND',
|
||||
name: 'hideModalMessage',
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
for (let j = 0; j < module.sources.length; j++) {
|
||||
const moduleSource = module.sources[j];
|
||||
let label = [module.format + ' - ' + module.description];
|
||||
if (module.sources.length > 1) {
|
||||
label.push('(' + (moduleSource === 'file' ? _('File') : _('Directory')) + ')');
|
||||
}
|
||||
importItems.push({
|
||||
label: label.join(' '),
|
||||
screens: ['Main'],
|
||||
click: async () => {
|
||||
let path = null;
|
||||
|
||||
const selectedFolderId = this.store().getState().selectedFolderId;
|
||||
|
||||
if (moduleSource === 'file') {
|
||||
path = bridge().showOpenDialog({
|
||||
filters: [{ name: module.description, extensions: [module.fileExtension]}]
|
||||
});
|
||||
} else {
|
||||
path = bridge().showOpenDialog({
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
});
|
||||
}
|
||||
|
||||
if (!path || (Array.isArray(path) && !path.length)) return;
|
||||
|
||||
if (Array.isArray(path)) path = path[0];
|
||||
|
||||
this.dispatch({
|
||||
type: 'WINDOW_COMMAND',
|
||||
name: 'showModalMessage',
|
||||
message: _('Importing from "%s" as "%s" format. Please wait...', path, module.format),
|
||||
});
|
||||
|
||||
const importOptions = {};
|
||||
importOptions.path = path;
|
||||
importOptions.format = module.format;
|
||||
importOptions.destinationFolderId = !module.isNoteArchive && moduleSource === 'file' ? selectedFolderId : null;
|
||||
|
||||
const service = new InteropService();
|
||||
try {
|
||||
const result = await service.import(importOptions);
|
||||
console.info('Import result: ', result);
|
||||
} catch (error) {
|
||||
bridge().showErrorMessageBox(error.message);
|
||||
}
|
||||
|
||||
this.dispatch({
|
||||
type: 'WINDOW_COMMAND',
|
||||
name: 'hideModalMessage',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const template = [
|
||||
{
|
||||
label: _('File'),
|
||||
@@ -210,25 +333,31 @@ class Application extends BaseApplication {
|
||||
}
|
||||
}, {
|
||||
type: 'separator',
|
||||
}, {
|
||||
label: _('Import Evernote notes'),
|
||||
click: () => {
|
||||
const filePaths = bridge().showOpenDialog({
|
||||
properties: ['openFile', 'createDirectory'],
|
||||
filters: [
|
||||
{ name: _('Evernote Export Files'), extensions: ['enex'] },
|
||||
]
|
||||
});
|
||||
if (!filePaths || !filePaths.length) return;
|
||||
// }, {
|
||||
// label: _('Import Evernote notes'),
|
||||
// click: () => {
|
||||
// const filePaths = bridge().showOpenDialog({
|
||||
// properties: ['openFile', 'createDirectory'],
|
||||
// filters: [
|
||||
// { name: _('Evernote Export Files'), extensions: ['enex'] },
|
||||
// ]
|
||||
// });
|
||||
// if (!filePaths || !filePaths.length) return;
|
||||
|
||||
this.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Import',
|
||||
props: {
|
||||
filePath: filePaths[0],
|
||||
},
|
||||
});
|
||||
}
|
||||
// this.dispatch({
|
||||
// type: 'NAV_GO',
|
||||
// routeName: 'Import',
|
||||
// props: {
|
||||
// filePath: filePaths[0],
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
}, {
|
||||
label: _('Import'),
|
||||
submenu: importItems,
|
||||
}, {
|
||||
label: _('Export'),
|
||||
submenu: exportItems,
|
||||
}, {
|
||||
type: 'separator',
|
||||
platforms: ['darwin'],
|
||||
@@ -236,7 +365,7 @@ class Application extends BaseApplication {
|
||||
label: _('Hide %s', 'Joplin'),
|
||||
platforms: ['darwin'],
|
||||
accelerator: 'CommandOrControl+H',
|
||||
click: () => { bridge().window().hide() }
|
||||
click: () => { bridge().electronApp().hide() }
|
||||
}, {
|
||||
type: 'separator',
|
||||
}, {
|
||||
@@ -287,6 +416,29 @@ class Application extends BaseApplication {
|
||||
name: 'toggleVisiblePanes',
|
||||
});
|
||||
}
|
||||
}, {
|
||||
type: 'separator',
|
||||
screens: ['Main'],
|
||||
}, {
|
||||
label: Setting.settingMetadata('notes.sortOrder.field').label(),
|
||||
screens: ['Main'],
|
||||
submenu: sortNoteItems,
|
||||
}, {
|
||||
label: Setting.settingMetadata('notes.sortOrder.reverse').label(),
|
||||
type: 'checkbox',
|
||||
checked: Setting.value('notes.sortOrder.reverse'),
|
||||
screens: ['Main'],
|
||||
click: () => {
|
||||
Setting.setValue('notes.sortOrder.reverse', !Setting.value('notes.sortOrder.reverse'));
|
||||
},
|
||||
}, {
|
||||
label: Setting.settingMetadata('uncompletedTodosOnTop').label(),
|
||||
type: 'checkbox',
|
||||
checked: Setting.value('uncompletedTodosOnTop'),
|
||||
screens: ['Main'],
|
||||
click: () => {
|
||||
Setting.setValue('uncompletedTodosOnTop', !Setting.value('uncompletedTodosOnTop'));
|
||||
},
|
||||
}],
|
||||
}, {
|
||||
label: _('Tools'),
|
||||
|
@@ -26,7 +26,7 @@ class Bridge {
|
||||
if (!this.window()) return { width: 0, height: 0 };
|
||||
const s = this.window().getContentSize();
|
||||
return { width: s[0], height: s[1] };
|
||||
}
|
||||
}
|
||||
|
||||
windowSize() {
|
||||
if (!this.window()) return { width: 0, height: 0 };
|
||||
@@ -108,23 +108,6 @@ class Bridge {
|
||||
return require('electron').shell.openItem(fullPath)
|
||||
}
|
||||
|
||||
// async checkForUpdatesAndNotify(logFilePath) {
|
||||
// if (!this.autoUpdater_) {
|
||||
// this.autoUpdateLogger_ = new Logger();
|
||||
// this.autoUpdateLogger_.addTarget('file', { path: logFilePath });
|
||||
// this.autoUpdateLogger_.setLevel(Logger.LEVEL_DEBUG);
|
||||
// this.autoUpdateLogger_.info('checkForUpdatesAndNotify: Initializing...');
|
||||
// this.autoUpdater_ = require("electron-updater").autoUpdater;
|
||||
// this.autoUpdater_.logger = this.autoUpdateLogger_;
|
||||
// }
|
||||
|
||||
// try {
|
||||
// await this.autoUpdater_.checkForUpdatesAndNotify();
|
||||
// } catch (error) {
|
||||
// this.autoUpdateLogger_.error(error);
|
||||
// }
|
||||
// }
|
||||
|
||||
checkForUpdates(inBackground, window, logFilePath) {
|
||||
const { checkForUpdates } = require('./checkForUpdates.js');
|
||||
checkForUpdates(inBackground, window, logFilePath);
|
||||
|
BIN
ElectronClient/app/build/icons/macos-16x16Template.png
Normal file
BIN
ElectronClient/app/build/icons/macos-16x16Template.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 348 B |
BIN
ElectronClient/app/build/icons/macos-16x16Template@2x.png
Normal file
BIN
ElectronClient/app/build/icons/macos-16x16Template@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 504 B |
@@ -46,7 +46,13 @@ function onCheckEnded() {
|
||||
autoUpdater.on('error', (error) => {
|
||||
autoUpdateLogger_.error(error);
|
||||
if (checkInBackground_) return onCheckEnded();
|
||||
showErrorMessageBox(error == null ? "unknown" : (error.stack || error).toString())
|
||||
|
||||
let msg = error == null ? "unknown" : (error.stack || error).toString();
|
||||
// Error messages can be very long even without stack trace so shorten
|
||||
// then so that the dialog box doesn't take the whole screen.
|
||||
msg = msg.substr(0,512).replace(/\\n/g, '\n');
|
||||
showErrorMessageBox(msg)
|
||||
|
||||
onCheckEnded();
|
||||
})
|
||||
|
||||
|
@@ -40,10 +40,6 @@ class ConfigScreenComponent extends React.Component {
|
||||
});
|
||||
}
|
||||
|
||||
output.sort((a, b) => {
|
||||
return a.label.toLowerCase() < b.label.toLowerCase() ? -1 : +1;
|
||||
});
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
|
@@ -22,6 +22,10 @@ class MainScreenComponent extends React.Component {
|
||||
componentWillMount() {
|
||||
this.setState({
|
||||
promptOptions: null,
|
||||
modalLayer: {
|
||||
visible: false,
|
||||
message: '',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -165,6 +169,10 @@ class MainScreenComponent extends React.Component {
|
||||
});
|
||||
} else if (command.name === 'toggleVisiblePanes') {
|
||||
this.toggleVisiblePanes();
|
||||
} else if (command.name === 'showModalMessage') {
|
||||
this.setState({ modalLayer: { visible: true, message: command.message } });
|
||||
} else if (command.name === 'hideModalMessage') {
|
||||
this.setState({ modalLayer: { visible: false, message: '' } });
|
||||
} else if (command.name === 'editAlarm') {
|
||||
const note = await Note.load(command.noteId);
|
||||
|
||||
@@ -265,6 +273,17 @@ class MainScreenComponent extends React.Component {
|
||||
height: height,
|
||||
};
|
||||
|
||||
this.styles_.modalLayer = Object.assign({}, theme.textStyle, {
|
||||
zIndex: 10000,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
backgroundColor: theme.backgroundColorTransparent,
|
||||
width: width - 20,
|
||||
height: height - 20,
|
||||
padding: 10,
|
||||
});
|
||||
|
||||
return this.styles_;
|
||||
}
|
||||
|
||||
@@ -353,8 +372,12 @@ class MainScreenComponent extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
const modalLayerStyle = Object.assign({}, styles.modalLayer, { display: this.state.modalLayer.visible ? 'block' : 'none' });
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
<div style={modalLayerStyle}>{this.state.modalLayer.message}</div>
|
||||
|
||||
<PromptDialog
|
||||
autocomplete={promptOptions && ('autocomplete' in promptOptions) ? promptOptions.autocomplete : null}
|
||||
defaultValue={promptOptions && promptOptions.value ? promptOptions.value : ''}
|
||||
|
@@ -604,7 +604,7 @@ class NoteTextComponent extends React.Component {
|
||||
let bodyToRender = body;
|
||||
if (!bodyToRender.trim() && visiblePanes.indexOf('viewer') >= 0 && visiblePanes.indexOf('editor') < 0) {
|
||||
// Fixes https://github.com/laurent22/joplin/issues/217
|
||||
bodyToRender = '*' + _('This not has no content. Click on "%s" to toggle the editor and edit the note.', _('Layout')) + '*';
|
||||
bodyToRender = '*' + _('This note has no content. Click on "%s" to toggle the editor and edit the note.', _('Layout')) + '*';
|
||||
}
|
||||
|
||||
const html = this.mdToHtml().render(bodyToRender, theme, mdOptions);
|
||||
|
67
ElectronClient/app/package-lock.json
generated
67
ElectronClient/app/package-lock.json
generated
@@ -10,9 +10,17 @@
|
||||
"integrity": "sha512-+rr4OgeTNrLuJAf09o3USdttEYiXvZshWMkhD6wR9v1ieXH0JM1Q2yT41/cJuJcqiPpSXlM/g3aR+Y5MWQdr0Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"7zip-bin-linux": "1.3.1",
|
||||
"7zip-bin-win": "2.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"7zip-bin-linux": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/7zip-bin-linux/-/7zip-bin-linux-1.3.1.tgz",
|
||||
"integrity": "sha512-Wv1uEEeHbTiS1+ycpwUxYNuIcyohU6Y6vEqY3NquBkeqy0YhVdsNUGsj0XKSRciHR6LoJSEUuqYUexmws3zH7Q==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"7zip-bin-win": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/7zip-bin-win/-/7zip-bin-win-2.1.1.tgz",
|
||||
@@ -939,8 +947,7 @@
|
||||
"chownr": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.0.1.tgz",
|
||||
"integrity": "sha1-4qdQQqlVGQi+vSW4Uj1fl2nXkYE=",
|
||||
"dev": true
|
||||
"integrity": "sha1-4qdQQqlVGQi+vSW4Uj1fl2nXkYE="
|
||||
},
|
||||
"chromium-pickle-js": {
|
||||
"version": "0.2.0",
|
||||
@@ -2002,6 +2009,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"fs-minipass": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.5.tgz",
|
||||
"integrity": "sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ==",
|
||||
"requires": {
|
||||
"minipass": "2.2.1"
|
||||
}
|
||||
},
|
||||
"fs-readdir-recursive": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-readdir-recursive/-/fs-readdir-recursive-1.1.0.tgz",
|
||||
@@ -2996,6 +3011,22 @@
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz",
|
||||
"integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0="
|
||||
},
|
||||
"minipass": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-2.2.1.tgz",
|
||||
"integrity": "sha512-u1aUllxPJUI07cOqzR7reGmQxmCqlH88uIIsf6XZFEWgw7gXKpJdR+5R9Y3KEDmWYkdIz9wXZs3C0jOPxejk/Q==",
|
||||
"requires": {
|
||||
"yallist": "3.0.2"
|
||||
}
|
||||
},
|
||||
"minizlib": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.1.0.tgz",
|
||||
"integrity": "sha512-4T6Ur/GctZ27nHfpt9THOdRZNgyJ9FZchYO1ceg5S8Q3DNLCKYy44nCZzgCJgcvx2UM8czmqak5BCxJMrq37lA==",
|
||||
"requires": {
|
||||
"minipass": "2.2.1"
|
||||
}
|
||||
},
|
||||
"mkdirp": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||
@@ -3951,6 +3982,18 @@
|
||||
"nan": "2.7.0",
|
||||
"semver": "5.4.1",
|
||||
"tar": "2.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"tar": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-2.2.1.tgz",
|
||||
"integrity": "sha1-jk0qJWwOIYXGsYrWlK7JaLg8sdE=",
|
||||
"requires": {
|
||||
"block-stream": "0.0.9",
|
||||
"fstream": "1.0.11",
|
||||
"inherits": "2.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"shebang-command": {
|
||||
@@ -4923,13 +4966,16 @@
|
||||
"integrity": "sha1-Kb9hXUqnEhvdiYsi1LP5vE4qoD0="
|
||||
},
|
||||
"tar": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-2.2.1.tgz",
|
||||
"integrity": "sha1-jk0qJWwOIYXGsYrWlK7JaLg8sdE=",
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-4.4.0.tgz",
|
||||
"integrity": "sha512-gJlTiiErwo96K904FnoYWl+5+FBgS+FimU6GMh66XLdLa55al8+d4jeDfPoGwSNHdtWI5FJP6xurmVqhBuGJpQ==",
|
||||
"requires": {
|
||||
"block-stream": "0.0.9",
|
||||
"fstream": "1.0.11",
|
||||
"inherits": "2.0.3"
|
||||
"chownr": "1.0.1",
|
||||
"fs-minipass": "1.2.5",
|
||||
"minipass": "2.2.1",
|
||||
"minizlib": "1.1.0",
|
||||
"mkdirp": "0.5.1",
|
||||
"yallist": "3.0.2"
|
||||
}
|
||||
},
|
||||
"tar-fs": {
|
||||
@@ -5384,6 +5430,11 @@
|
||||
"integrity": "sha1-bRX7qITAhnnA136I53WegR4H+kE=",
|
||||
"dev": true
|
||||
},
|
||||
"yallist": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.2.tgz",
|
||||
"integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k="
|
||||
},
|
||||
"yargs": {
|
||||
"version": "10.0.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-10.0.3.tgz",
|
||||
|
@@ -91,6 +91,7 @@
|
||||
"sqlite3": "^3.1.13",
|
||||
"string-padding": "^1.0.2",
|
||||
"string-to-stream": "^1.1.0",
|
||||
"tar": "^4.4.0",
|
||||
"tcp-port-used": "^0.1.2",
|
||||
"url-parse": "^1.2.0",
|
||||
"uuid": "^3.1.0",
|
||||
|
@@ -7,6 +7,7 @@ const globalStyle = {
|
||||
itemMarginTop: 10,
|
||||
itemMarginBottom: 10,
|
||||
backgroundColor: "#ffffff",
|
||||
backgroundColorTransparent: 'rgba(255,255,255,0.9)',
|
||||
oddBackgroundColor: "#dddddd",
|
||||
color: "#222222", // For regular text
|
||||
colorError: "red",
|
||||
|
63
README.md
63
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
Joplin is a free, open source note taking and to-do application, which can handle a large number of notes organised into notebooks. The notes are searchable, can be copied, tagged and modified either from the applications directly or from your own text editor. The notes are in [Markdown format](https://daringfireball.net/projects/markdown/basics).
|
||||
|
||||
Notes exported from Evernote via .enex files [can be imported](#importing-notes-from-evernote) into Joplin, including the formatted content (which is converted to Markdown), resources (images, attachments, etc.) and complete metadata (geolocation, updated time, created time, etc.).
|
||||
Notes exported from Evernote via .enex files [can be imported](#importing) into Joplin, including the formatted content (which is converted to Markdown), resources (images, attachments, etc.) and complete metadata (geolocation, updated time, created time, etc.). Plain Markdown files can also be imported.
|
||||
|
||||
The notes can be [synchronised](#synchronisation) with various targets including [Nextcloud](https://nextcloud.com/), the file system (for example with a network directory) or with Microsoft OneDrive. When synchronising the notes, notebooks, tags and other metadata are saved to plain text files which can be easily inspected, backed up and moved around.
|
||||
|
||||
@@ -26,7 +26,7 @@ Linux | <a href='https://github.com/laurent22/joplin/releases/download/
|
||||
|
||||
Operating System | Download | Alt. Download
|
||||
-----------------|----------|----------------
|
||||
Android | <a href='https://play.google.com/store/apps/details?id=net.cozic.joplin&utm_source=GitHub&utm_campaign=README&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeAndroid.png'/></a> | or [Download APK File](https://github.com/laurent22/joplin-android/releases/download/android-v1.0.101/joplin-v1.0.101.apk)
|
||||
Android | <a href='https://play.google.com/store/apps/details?id=net.cozic.joplin&utm_source=GitHub&utm_campaign=README&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeAndroid.png'/></a> | or [Download APK File](https://github.com/laurent22/joplin-android/releases/download/android-v1.0.102/joplin-v1.0.102.apk)
|
||||
iOS | <a href='https://itunes.apple.com/us/app/joplin/id1315599797'><img alt='Get it on the App Store' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeIOS.png'/></a> | -
|
||||
|
||||
## Terminal application
|
||||
@@ -51,20 +51,23 @@ For usage information, please refer to the full [Joplin Terminal Application Doc
|
||||
# Features
|
||||
|
||||
- Desktop, mobile and terminal applications.
|
||||
- Import Enex files (Evernote export format)
|
||||
- End To End Encryption (E2EE)
|
||||
- Synchronisation with various services, including NextCloud, WebDAV and OneDrive. Dropbox is planned.
|
||||
- Import Enex files (Evernote export format) and Markdown files.
|
||||
- Export JEX files (Joplin Export format) and raw files.
|
||||
- Support notes, to-dos, tags and notebooks.
|
||||
- Sort notes by multiple criteria - title, updated time, etc.
|
||||
- Support for alarms (notifications) in mobile and desktop applications.
|
||||
- Offline first, so the entire data is always available on the device even without an internet connection.
|
||||
- Synchronisation with various services, including NextCloud, WebDAV and OneDrive. Dropbox is planned.
|
||||
- End To End Encryption (E2EE)
|
||||
- Synchronises to a plain text format, which can be easily manipulated, backed up, or exported to a different format.
|
||||
- Markdown notes, which are rendered with images and formatting in the desktop and mobile applications. Support for extra features such as math notation and checkboxes.
|
||||
- File attachment support - images are displayed, and other files are linked and can be opened in the relevant application.
|
||||
- Search functionality.
|
||||
- Geo-location support.
|
||||
- Supports multiple languages
|
||||
|
||||
# Importing notes from Evernote
|
||||
# Importing
|
||||
|
||||
## Importing from Evernote
|
||||
|
||||
Joplin was designed as a replacement for Evernote and so can import complete Evernote notebooks, as well as notes, tags, resources (attached files) and note metadata (such as author, geo-location, etc.) via ENEX files. In terms of data, the only two things that might slightly differ are:
|
||||
|
||||
@@ -74,17 +77,29 @@ Joplin was designed as a replacement for Evernote and so can import complete Eve
|
||||
|
||||
To import Evernote data, first export your Evernote notebooks to ENEX files as described [here](https://help.evernote.com/hc/en-us/articles/209005557-How-to-back-up-export-and-restore-import-notes-and-notebooks). Then follow these steps:
|
||||
|
||||
On the **desktop application**, open the "File" menu, click "Import Evernote notes" and select your ENEX file. This will open a new screen which will display the import progress. The notes will be imported into a new separate notebook (so that, in case of a mistake, the notes are not mixed up with any existing notes). If needed then can then be moved to a different notebook, or the notebook can be renamed, etc.
|
||||
On the **desktop application**, open File > Import > ENEX and select your file. The notes will be imported into a new separate notebook. If needed they can then be moved to a different notebook, or the notebook can be renamed, etc.
|
||||
|
||||
On the **terminal application**, in [command-line mode](/terminal#command-line-mode), type `import-enex /path/to/file.enex`. This will import the notes into a new notebook named after the filename.
|
||||
On the **terminal application**, in [command-line mode](/terminal#command-line-mode), type `import /path/to/file.enex`. This will import the notes into a new notebook named after the filename.
|
||||
|
||||
# Importing notes from other applications
|
||||
## Importing from Markdown files
|
||||
|
||||
Joplin can import notes from plain Markdown file. You can either import a complete directory of Markdown files or individual files.
|
||||
|
||||
On the **desktop application**, open File > Import > MD and select your Markdown file or directory.
|
||||
|
||||
On the **terminal application**, in [command-line mode](/terminal#command-line-mode), type `import --format md /path/to/file.md` or `import --format md /path/to/directory/`.
|
||||
|
||||
## Importing from other applications
|
||||
|
||||
In general the way to import notes from any application into Joplin is to convert the notes to ENEX files (Evernote format) and to import these ENEX files into Joplin using the method above. Most note-taking applications support ENEX files so it should be relatively straightforward. For help about specific applications, see below:
|
||||
|
||||
* Standard Notes: Please see [this tutorial](https://programadorwebvalencia.com/migrate-notes-from-standard-notes-to-joplin/)
|
||||
* Tomboy Notes: Export the notes to ENEX files [as described here](https://askubuntu.com/questions/243691/how-can-i-export-my-tomboy-notes-into-evernote/608551) for example, and import these ENEX files into Joplin.
|
||||
|
||||
# Exporting
|
||||
|
||||
Joplin can export to the JEX format (Joplin Export file), which is a tar file that can contain multiple notes, notebooks, etc. This is a lossless format in that all the notes, but also metadata such as geo-location, updated time, tags, etc. are preserved. This format is convenient for backup purposes and can be re-imported into Joplin. A "raw" format is also available. This is the same as the JEX format except that the data is saved to a directory and each item represented by a single file.
|
||||
|
||||
# Synchronisation
|
||||
|
||||
One of the goals of Joplin was to avoid being tied to any particular company or service, whether it is Evernote, Google or Microsoft. As such the synchronisation is designed without any hard dependency to any particular service. Most of the synchronisation process is done at an abstract level and access to external services, such as Nextcloud or OneDrive, is done via lightweight drivers. It is easy to support new services by creating simple drivers that provide a filesystem-like interface, i.e. the ability to read, write, delete and list items. It is also simple to switch from one service to another or to even sync to multiple services at once. Each note, notebook, tags, as well as the relation between items is transmitted as plain text files during synchronisation, which means the data can also be moved to a different application, can be easily backed up, inspected, etc.
|
||||
@@ -108,12 +123,14 @@ If synchronisation does not work, please consult the logs in the app profile dir
|
||||
|
||||
Select the "WebDAV" synchronisation target and follow the same instructions as for Nextcloud above.
|
||||
|
||||
Known compatible services that use WebDAV:
|
||||
WebDAV-compatible services that are known to work with Joplin:
|
||||
|
||||
- [Box.com](https://www.box.com/)
|
||||
- [DriveHQ](https://www.drivehq.com)
|
||||
- [Zimbra](https://www.zimbra.com/)
|
||||
- [OwnCloud](https://owncloud.org/)
|
||||
- [Seafile](https://www.seafile.com/)
|
||||
- [Stack](https://www.transip.nl/stack/)
|
||||
- [Zimbra](https://www.zimbra.com/)
|
||||
|
||||
## OneDrive synchronisation
|
||||
|
||||
@@ -205,18 +222,18 @@ Current translations:
|
||||
<!-- LOCALE-TABLE-AUTO-GENERATED -->
|
||||
| Language | Po File | Last translator | Percent done
|
||||
---|---|---|---|---
|
||||
 | Basque | [eu](https://github.com/laurent22/joplin/blob/master/CliClient/locales/eu.po) | juan.abasolo@ehu.eus | 87%
|
||||
 | Croatian | [hr_HR](https://github.com/laurent22/joplin/blob/master/CliClient/locales/hr_HR.po) | Hrvoje Mandić <trbuhom@net.hr> | 71%
|
||||
 | Deutsch | [de_DE](https://github.com/laurent22/joplin/blob/master/CliClient/locales/de_DE.po) | Tobias Strobel <git@strobeltobias.de> | 89%
|
||||
 | Basque | [eu](https://github.com/laurent22/joplin/blob/master/CliClient/locales/eu.po) | juan.abasolo@ehu.eus | 82%
|
||||
 | Croatian | [hr_HR](https://github.com/laurent22/joplin/blob/master/CliClient/locales/hr_HR.po) | Hrvoje Mandić <trbuhom@net.hr> | 66%
|
||||
 | Deutsch | [de_DE](https://github.com/laurent22/joplin/blob/master/CliClient/locales/de_DE.po) | Tobias Strobel <git@strobeltobias.de> | 84%
|
||||
 | English | [en_GB](https://github.com/laurent22/joplin/blob/master/CliClient/locales/en_GB.po) | | 100%
|
||||
 | Español | [es_ES](https://github.com/laurent22/joplin/blob/master/CliClient/locales/es_ES.po) | Fernando Martín <f@mrtn.es> | 100%
|
||||
 | Français | [fr_FR](https://github.com/laurent22/joplin/blob/master/CliClient/locales/fr_FR.po) | Laurent Cozic | 100%
|
||||
 | Italiano | [it_IT](https://github.com/laurent22/joplin/blob/master/CliClient/locales/it_IT.po) | | 73%
|
||||
 | Nederlands | [nl_BE](https://github.com/laurent22/joplin/blob/master/CliClient/locales/nl_BE.po) | | 87%
|
||||
 | Português (Brasil) | [pt_BR](https://github.com/laurent22/joplin/blob/master/CliClient/locales/pt_BR.po) | | 71%
|
||||
 | Русский | [ru_RU](https://github.com/laurent22/joplin/blob/master/CliClient/locales/ru_RU.po) | Artyom Karlov <artyom.karlov@gmail.com> | 91%
|
||||
 | 中文 (简体) | [zh_CN](https://github.com/laurent22/joplin/blob/master/CliClient/locales/zh_CN.po) | RCJacH <RCJacH@outlook.com> | 73%
|
||||
 | 日本語 | [ja_JP](https://github.com/laurent22/joplin/blob/master/CliClient/locales/ja_JP.po) | | 71%
|
||||
 | Español | [es_ES](https://github.com/laurent22/joplin/blob/master/CliClient/locales/es_ES.po) | Fernando Martín <f@mrtn.es> | 94%
|
||||
 | Français | [fr_FR](https://github.com/laurent22/joplin/blob/master/CliClient/locales/fr_FR.po) | Laurent Cozic | 94%
|
||||
 | Italiano | [it_IT](https://github.com/laurent22/joplin/blob/master/CliClient/locales/it_IT.po) | | 68%
|
||||
 | Nederlands | [nl_BE](https://github.com/laurent22/joplin/blob/master/CliClient/locales/nl_BE.po) | | 82%
|
||||
 | Português (Brasil) | [pt_BR](https://github.com/laurent22/joplin/blob/master/CliClient/locales/pt_BR.po) | | 67%
|
||||
 | Русский | [ru_RU](https://github.com/laurent22/joplin/blob/master/CliClient/locales/ru_RU.po) | Artyom Karlov <artyom.karlov@gmail.com> | 86%
|
||||
 | 中文 (简体) | [zh_CN](https://github.com/laurent22/joplin/blob/master/CliClient/locales/zh_CN.po) | RCJacH <RCJacH@outlook.com> | 68%
|
||||
 | 日本語 | [ja_JP](https://github.com/laurent22/joplin/blob/master/CliClient/locales/ja_JP.po) | | 66%
|
||||
<!-- LOCALE-TABLE-AUTO-GENERATED -->
|
||||
|
||||
# Known bugs
|
||||
|
@@ -2,7 +2,7 @@
|
||||
|
||||
Joplin is a free, open source note taking and to-do application, which can handle a large number of notes organised into notebooks. The notes are searchable, can be copied, tagged and modified with your own text editor.
|
||||
|
||||
Notes exported from Evernote via .enex files [can be imported](#importing-notes-from-evernote) into Joplin, including the formatted content (which is converted to markdown), resources (images, attachments, etc.) and complete metadata (geolocation, updated time, created time, etc.).
|
||||
Notes exported from Evernote via .enex files [can be imported](http://joplin.cozic.net/#importing) into Joplin, including the formatted content (which is converted to Markdown), resources (images, attachments, etc.) and complete metadata (geolocation, updated time, created time, etc.). Plain Markdown files can also be imported.
|
||||
|
||||
The notes can be [synchronised](#synchronisation) with various targets including the file system (for example with a network directory) or with Microsoft OneDrive. When synchronising the notes, notebooks, tags and other metadata are saved to plain text files which can be easily inspected, backed up and moved around.
|
||||
|
||||
@@ -106,7 +106,7 @@ If the help is not fully visible, press `Tab` multiple times till the console is
|
||||
To import Evernote data, follow these steps:
|
||||
|
||||
* First, export your Evernote notebooks to ENEX files as described [here](https://help.evernote.com/hc/en-us/articles/209005557-How-to-back-up-export-and-restore-import-notes-and-notebooks).
|
||||
* In Joplin, in [command-line mode](#command-line-mode), type `import-enex /path/to/file.enex`. This will import the notes into a new notebook named after the filename.
|
||||
* In Joplin, in [command-line mode](#command-line-mode), type `import /path/to/file.enex`. This will import the notes into a new notebook named after the filename.
|
||||
* Then repeat the process for each notebook that needs to be imported.
|
||||
|
||||
# Synchronisation
|
||||
@@ -274,81 +274,91 @@ The following commands are available in [command-line mode](#command-line-mode):
|
||||
|
||||
Possible keys/values:
|
||||
|
||||
editor Text editor.
|
||||
The editor that will be used to open a note. If
|
||||
none is provided it will try to auto-detect the
|
||||
default editor.
|
||||
Type: string.
|
||||
|
||||
locale Language.
|
||||
Type: Enum.
|
||||
Possible values: en_GB (English), de_DE (Deutsch),
|
||||
es_CR (Español (Costa Rica)), es_ES (Español), eu
|
||||
(Basque), fr_FR (Français), hr_HR (Croatian), it_IT
|
||||
(Italiano), ja_JP (日本語), nl_BE (Nederlands), pt_BR
|
||||
(Português (Brasil)), ru_RU (Русский), zh_CN (中文
|
||||
(简体)).
|
||||
Default: "en_GB"
|
||||
|
||||
dateFormat Date format.
|
||||
Type: Enum.
|
||||
Possible values: DD/MM/YYYY (30/01/2017), DD/MM/YY
|
||||
(30/01/17), MM/DD/YYYY (01/30/2017), MM/DD/YY
|
||||
(01/30/17), YYYY-MM-DD (2017-01-30).
|
||||
Default: "DD/MM/YYYY"
|
||||
|
||||
timeFormat Time format.
|
||||
Type: Enum.
|
||||
Possible values: HH:mm (20:30), h:mm A (8:30 PM).
|
||||
Default: "HH:mm"
|
||||
|
||||
uncompletedTodosOnTop Show uncompleted to-dos on top of the lists.
|
||||
Type: bool.
|
||||
Default: true
|
||||
|
||||
trackLocation Save geo-location with notes.
|
||||
Type: bool.
|
||||
Default: true
|
||||
|
||||
sync.interval Synchronisation interval.
|
||||
Type: Enum.
|
||||
Possible values: 0 (Disabled), 300 (5 minutes), 600
|
||||
(10 minutes), 1800 (30 minutes), 3600 (1 hour),
|
||||
43200 (12 hours), 86400 (24 hours).
|
||||
Default: 300
|
||||
|
||||
sync.target Synchronisation target.
|
||||
The target to synchonise to. Each sync target may
|
||||
have additional parameters which are named as
|
||||
`sync.NUM.NAME` (all documented below).
|
||||
Type: Enum.
|
||||
Possible values: 2 (File system), 3 (OneDrive), 4
|
||||
(OneDrive Dev (For testing only)), 5 (Nextcloud), 6
|
||||
(WebDAV).
|
||||
Default: 3
|
||||
|
||||
sync.2.path Directory to synchronise with (absolute path).
|
||||
The path to synchronise with when file system
|
||||
synchronisation is enabled. See `sync.target`.
|
||||
Type: string.
|
||||
|
||||
sync.5.path Nextcloud WebDAV URL.
|
||||
Type: string.
|
||||
|
||||
sync.5.username Nextcloud username.
|
||||
Type: string.
|
||||
|
||||
sync.5.password Nextcloud password.
|
||||
Type: string.
|
||||
|
||||
sync.6.path WebDAV URL.
|
||||
Type: string.
|
||||
|
||||
sync.6.username WebDAV username.
|
||||
Type: string.
|
||||
|
||||
sync.6.password WebDAV password.
|
||||
Type: string.
|
||||
editor Text editor.
|
||||
The editor that will be used to open a note. If
|
||||
none is provided it will try to auto-detect the
|
||||
default editor.
|
||||
Type: string.
|
||||
|
||||
locale Language.
|
||||
Type: Enum.
|
||||
Possible values: eu (Basque), hr_HR (Croatian),
|
||||
de_DE (Deutsch), en_GB (English), es_ES
|
||||
(Español), fr_FR (Français), it_IT (Italiano),
|
||||
nl_BE (Nederlands), pt_BR (Português (Brasil)),
|
||||
ru_RU (Русский), zh_CN (中文 (简体)), ja_JP (日本語).
|
||||
Default: "en_GB"
|
||||
|
||||
dateFormat Date format.
|
||||
Type: Enum.
|
||||
Possible values: DD/MM/YYYY (30/01/2017),
|
||||
DD/MM/YY (30/01/17), MM/DD/YYYY (01/30/2017),
|
||||
MM/DD/YY (01/30/17), YYYY-MM-DD (2017-01-30).
|
||||
Default: "DD/MM/YYYY"
|
||||
|
||||
timeFormat Time format.
|
||||
Type: Enum.
|
||||
Possible values: HH:mm (20:30), h:mm A (8:30 PM).
|
||||
Default: "HH:mm"
|
||||
|
||||
uncompletedTodosOnTop Uncompleted to-dos on top.
|
||||
Type: bool.
|
||||
Default: true
|
||||
|
||||
notes.sortOrder.field Sort notes by.
|
||||
Type: Enum.
|
||||
Possible values: user_updated_time (Updated
|
||||
date), user_created_time (Created date), title
|
||||
(Title).
|
||||
Default: "user_updated_time"
|
||||
|
||||
notes.sortOrder.reverse Reverse sort order.
|
||||
Type: bool.
|
||||
Default: true
|
||||
|
||||
trackLocation Save geo-location with notes.
|
||||
Type: bool.
|
||||
Default: true
|
||||
|
||||
sync.interval Synchronisation interval.
|
||||
Type: Enum.
|
||||
Possible values: 0 (Disabled), 300 (5 minutes),
|
||||
600 (10 minutes), 1800 (30 minutes), 3600 (1
|
||||
hour), 43200 (12 hours), 86400 (24 hours).
|
||||
Default: 300
|
||||
|
||||
sync.target Synchronisation target.
|
||||
The target to synchonise to. Each sync target may
|
||||
have additional parameters which are named as
|
||||
`sync.NUM.NAME` (all documented below).
|
||||
Type: Enum.
|
||||
Possible values: 2 (File system), 3 (OneDrive), 4
|
||||
(OneDrive Dev (For testing only)), 5 (Nextcloud),
|
||||
6 (WebDAV).
|
||||
Default: 3
|
||||
|
||||
sync.2.path Directory to synchronise with (absolute path).
|
||||
The path to synchronise with when file system
|
||||
synchronisation is enabled. See `sync.target`.
|
||||
Type: string.
|
||||
|
||||
sync.5.path Nextcloud WebDAV URL.
|
||||
Type: string.
|
||||
|
||||
sync.5.username Nextcloud username.
|
||||
Type: string.
|
||||
|
||||
sync.5.password Nextcloud password.
|
||||
Type: string.
|
||||
|
||||
sync.6.path WebDAV URL.
|
||||
Type: string.
|
||||
|
||||
sync.6.username WebDAV username.
|
||||
Type: string.
|
||||
|
||||
sync.6.password WebDAV password.
|
||||
Type: string.
|
||||
|
||||
cp <note> [notebook]
|
||||
|
||||
@@ -374,11 +384,13 @@ The following commands are available in [command-line mode](#command-line-mode):
|
||||
|
||||
Edit note.
|
||||
|
||||
export <directory>
|
||||
export <path>
|
||||
|
||||
Exports Joplin data to the given directory. By default, it will export the
|
||||
Exports Joplin data to the given path. By default, it will export the
|
||||
complete database including notebooks, notes, tags and resources.
|
||||
|
||||
--format <format> Destination format: jex (Joplin Export File), raw
|
||||
(Joplin Export Directory)
|
||||
--note <note> Exports only the given note.
|
||||
--notebook <notebook> Exports only the given notebook.
|
||||
|
||||
@@ -390,11 +402,12 @@ The following commands are available in [command-line mode](#command-line-mode):
|
||||
|
||||
Displays usage information.
|
||||
|
||||
import-enex <file> [notebook]
|
||||
import <path> [notebook]
|
||||
|
||||
Imports an Evernote notebook file (.enex file).
|
||||
Imports data into Joplin.
|
||||
|
||||
-f, --force Do not ask for confirmation.
|
||||
--format <format> Source format: auto, jex, md, raw, enex
|
||||
-f, --force Do not ask for confirmation.
|
||||
|
||||
mkbook <new-notebook>
|
||||
|
||||
|
@@ -90,8 +90,8 @@ android {
|
||||
applicationId "net.cozic.joplin"
|
||||
minSdkVersion 16
|
||||
targetSdkVersion 22
|
||||
versionCode 2097279
|
||||
versionName "1.0.101"
|
||||
versionCode 2097280
|
||||
versionName "1.0.102"
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "x86"
|
||||
}
|
||||
|
@@ -1302,7 +1302,7 @@
|
||||
PRODUCT_NAME = Joplin;
|
||||
PROVISIONING_PROFILE = "";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Debug;
|
||||
@@ -1342,7 +1342,7 @@
|
||||
PRODUCT_NAME = Joplin;
|
||||
PROVISIONING_PROFILE = "";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Release;
|
||||
|
@@ -15,7 +15,6 @@ ArrayUtils.removeElement = function(array, element) {
|
||||
|
||||
// https://stackoverflow.com/a/10264318/561309
|
||||
ArrayUtils.binarySearch = function(items, value) {
|
||||
|
||||
var startIndex = 0,
|
||||
stopIndex = items.length - 1,
|
||||
middle = Math.floor((stopIndex + startIndex)/2);
|
||||
@@ -37,4 +36,13 @@ ArrayUtils.binarySearch = function(items, value) {
|
||||
return (items[middle] != value) ? -1 : middle;
|
||||
}
|
||||
|
||||
ArrayUtils.findByKey = function(array, key, value) {
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
const o = array[i];
|
||||
if (typeof o !== 'object') continue;
|
||||
if (o[key] === value) return o;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = ArrayUtils;
|
@@ -1,5 +1,5 @@
|
||||
const { createStore, applyMiddleware } = require('redux');
|
||||
const { reducer, defaultState } = require('lib/reducer.js');
|
||||
const { reducer, defaultState, stateUtils } = require('lib/reducer.js');
|
||||
const { JoplinDatabase } = require('lib/joplin-database.js');
|
||||
const { Database } = require('lib/database.js');
|
||||
const { FoldersScreenUtils } = require('lib/folders-screen-utils.js');
|
||||
@@ -184,8 +184,9 @@ class BaseApplication {
|
||||
this.logger().debug('Refreshing notes:', parentType, parentId);
|
||||
|
||||
let options = {
|
||||
order: state.notesOrder,
|
||||
order: stateUtils.notesOrder(state.settings),
|
||||
uncompletedTodosOnTop: Setting.value('uncompletedTodosOnTop'),
|
||||
caseInsensitive: true,
|
||||
};
|
||||
|
||||
const source = JSON.stringify({
|
||||
@@ -255,14 +256,31 @@ class BaseApplication {
|
||||
|
||||
const result = next(action);
|
||||
const newState = store.getState();
|
||||
let refreshNotes = false;
|
||||
|
||||
if (action.type == 'FOLDER_SELECT' || action.type === 'FOLDER_DELETE') {
|
||||
Setting.setValue('activeFolderId', newState.selectedFolderId);
|
||||
this.currentFolder_ = newState.selectedFolderId ? await Folder.load(newState.selectedFolderId) : null;
|
||||
await this.refreshNotes(newState);
|
||||
refreshNotes = true;
|
||||
}
|
||||
|
||||
if (this.hasGui() && action.type == 'SETTING_UPDATE_ONE' && action.key == 'uncompletedTodosOnTop' || action.type == 'SETTING_UPDATE_ALL') {
|
||||
if (this.hasGui() && ((action.type == 'SETTING_UPDATE_ONE' && action.key == 'uncompletedTodosOnTop') || action.type == 'SETTING_UPDATE_ALL')) {
|
||||
refreshNotes = true;
|
||||
}
|
||||
|
||||
if (this.hasGui() && ((action.type == 'SETTING_UPDATE_ONE' && action.key.indexOf('notes.sortOrder') === 0) || action.type == 'SETTING_UPDATE_ALL')) {
|
||||
refreshNotes = true;
|
||||
}
|
||||
|
||||
if (action.type == 'TAG_SELECT' || action.type === 'TAG_DELETE') {
|
||||
refreshNotes = true;
|
||||
}
|
||||
|
||||
if (action.type == 'SEARCH_SELECT' || action.type === 'SEARCH_DELETE') {
|
||||
refreshNotes = true;
|
||||
}
|
||||
|
||||
if (refreshNotes) {
|
||||
await this.refreshNotes(newState);
|
||||
}
|
||||
|
||||
@@ -288,14 +306,6 @@ class BaseApplication {
|
||||
}
|
||||
}
|
||||
|
||||
if (action.type == 'TAG_SELECT' || action.type === 'TAG_DELETE') {
|
||||
await this.refreshNotes(newState);
|
||||
}
|
||||
|
||||
if (action.type == 'SEARCH_SELECT' || action.type === 'SEARCH_DELETE') {
|
||||
await this.refreshNotes(newState);
|
||||
}
|
||||
|
||||
if (action.type === 'NOTE_UPDATE_ONE') {
|
||||
// If there is a conflict, we refresh the folders so as to display "Conflicts" folder
|
||||
if (action.note && action.note.is_conflict) {
|
||||
@@ -303,11 +313,6 @@ class BaseApplication {
|
||||
}
|
||||
}
|
||||
|
||||
// if (action.type === 'NOTE_DELETE') {
|
||||
// // Update folders if a note is deleted in case the deleted note was a conflict
|
||||
// await FoldersScreenUtils.refreshFolders();
|
||||
// }
|
||||
|
||||
if (this.hasGui() && action.type == 'SETTING_UPDATE_ONE' && action.key == 'sync.interval' || action.type == 'SETTING_UPDATE_ALL') {
|
||||
reg.setupRecurrentSync();
|
||||
}
|
||||
|
@@ -45,6 +45,14 @@ class BaseModel {
|
||||
return null;
|
||||
}
|
||||
|
||||
static modelTypeToName(type) {
|
||||
for (let i = 0; i < BaseModel.typeEnum_.length; i++) {
|
||||
const e = BaseModel.typeEnum_[i];
|
||||
if (e[1] === type) return e[0].substr(5).toLowerCase();
|
||||
}
|
||||
throw new Error('Unknown model type: ' + type);
|
||||
}
|
||||
|
||||
static hasField(name) {
|
||||
let fields = this.fieldNames();
|
||||
return fields.indexOf(name) >= 0;
|
||||
@@ -487,15 +495,32 @@ class BaseModel {
|
||||
|
||||
}
|
||||
|
||||
BaseModel.TYPE_NOTE = 1;
|
||||
BaseModel.TYPE_FOLDER = 2;
|
||||
BaseModel.TYPE_SETTING = 3;
|
||||
BaseModel.TYPE_RESOURCE = 4;
|
||||
BaseModel.TYPE_TAG = 5;
|
||||
BaseModel.TYPE_NOTE_TAG = 6;
|
||||
BaseModel.TYPE_SEARCH = 7;
|
||||
BaseModel.TYPE_ALARM = 8;
|
||||
BaseModel.TYPE_MASTER_KEY = 9;
|
||||
BaseModel.typeEnum_ = [
|
||||
['TYPE_NOTE', 1],
|
||||
['TYPE_FOLDER', 2],
|
||||
['TYPE_SETTING', 3],
|
||||
['TYPE_RESOURCE', 4],
|
||||
['TYPE_TAG', 5],
|
||||
['TYPE_NOTE_TAG', 6],
|
||||
['TYPE_SEARCH', 7],
|
||||
['TYPE_ALARM', 8],
|
||||
['TYPE_MASTER_KEY', 9],
|
||||
];
|
||||
|
||||
for (let i = 0; i < BaseModel.typeEnum_.length; i++) {
|
||||
const e = BaseModel.typeEnum_[i];
|
||||
BaseModel[e[0]] = e[1];
|
||||
}
|
||||
|
||||
// BaseModel.TYPE_NOTE = 1;
|
||||
// BaseModel.TYPE_FOLDER = 2;
|
||||
// BaseModel.TYPE_SETTING = 3;
|
||||
// BaseModel.TYPE_RESOURCE = 4;
|
||||
// BaseModel.TYPE_TAG = 5;
|
||||
// BaseModel.TYPE_NOTE_TAG = 6;
|
||||
// BaseModel.TYPE_SEARCH = 7;
|
||||
// BaseModel.TYPE_ALARM = 8;
|
||||
// BaseModel.TYPE_MASTER_KEY = 9;
|
||||
|
||||
BaseModel.db_ = null;
|
||||
BaseModel.dispatch = function(o) {};
|
||||
|
@@ -28,8 +28,7 @@ class Cache {
|
||||
Cache.storage = async function() {
|
||||
if (Cache.storage_) return Cache.storage_;
|
||||
Cache.storage_ = require('node-persist');
|
||||
const osTmpdir = require('os-tmpdir');
|
||||
await Cache.storage_.init({ dir: osTmpdir() + '/joplin-cache', ttl: 1000 * 60 });
|
||||
await Cache.storage_.init({ dir: require('os').tmpdir() + '/joplin-cache', ttl: 1000 * 60 });
|
||||
return Cache.storage_;
|
||||
}
|
||||
|
||||
|
47
ReactNativeClient/lib/ObjectUtils.js
Normal file
47
ReactNativeClient/lib/ObjectUtils.js
Normal file
@@ -0,0 +1,47 @@
|
||||
const ObjectUtils = {};
|
||||
|
||||
ObjectUtils.sortByValue = function(object) {
|
||||
const temp = [];
|
||||
for (let k in object) {
|
||||
if (!object.hasOwnProperty(k)) continue;
|
||||
temp.push({
|
||||
key: k,
|
||||
value: object[k],
|
||||
});
|
||||
}
|
||||
|
||||
temp.sort(function(a, b) {
|
||||
let v1 = a.value;
|
||||
let v2 = b.value;
|
||||
if (typeof v1 === 'string') v1 = v1.toLowerCase();
|
||||
if (typeof v2 === 'string') v2 = v2.toLowerCase();
|
||||
if (v1 === v2) return 0;
|
||||
return v1 < v2 ? -1 : +1;
|
||||
});
|
||||
|
||||
const output = {};
|
||||
for (let i = 0; i < temp.length; i++) {
|
||||
const item = temp[i];
|
||||
output[item.key] = item.value;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
ObjectUtils.fieldsEqual = function(o1, o2) {
|
||||
if ((!o1 || !o2) && (o1 !== o2)) return false;
|
||||
|
||||
for (let k in o1) {
|
||||
if (!o1.hasOwnProperty(k)) continue;
|
||||
if (o1[k] !== o2[k]) return false;
|
||||
}
|
||||
|
||||
const c1 = Object.getOwnPropertyNames(o1);
|
||||
const c2 = Object.getOwnPropertyNames(o2);
|
||||
|
||||
if (c1.length !== c2.length) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = ObjectUtils;
|
@@ -37,7 +37,7 @@ class SyncTargetNextcloud extends BaseSyncTarget {
|
||||
}
|
||||
|
||||
async initFileApi() {
|
||||
const fileApi = await SyncTargetWebDAV.initFileApi_(SyncTargetNextcloud.id(), {
|
||||
const fileApi = await SyncTargetWebDAV.newFileApi_(SyncTargetNextcloud.id(), {
|
||||
path: Setting.value('sync.5.path'),
|
||||
username: Setting.value('sync.5.username'),
|
||||
password: Setting.value('sync.5.password'),
|
||||
|
@@ -28,7 +28,7 @@ class SyncTargetWebDAV extends BaseSyncTarget {
|
||||
return true;
|
||||
}
|
||||
|
||||
static async initFileApi_(syncTargetId, options) {
|
||||
static async newFileApi_(syncTargetId, options) {
|
||||
const apiOptions = {
|
||||
baseUrl: () => options.path,
|
||||
username: () => options.username,
|
||||
@@ -43,7 +43,8 @@ class SyncTargetWebDAV extends BaseSyncTarget {
|
||||
}
|
||||
|
||||
static async checkConfig(options) {
|
||||
const fileApi = await SyncTargetWebDAV.initFileApi_(SyncTargetWebDAV.id(), options);
|
||||
const fileApi = await SyncTargetWebDAV.newFileApi_(SyncTargetWebDAV.id(), options);
|
||||
fileApi.requestRepeatCount_ = 0;
|
||||
|
||||
const output = {
|
||||
ok: false,
|
||||
@@ -52,7 +53,7 @@ class SyncTargetWebDAV extends BaseSyncTarget {
|
||||
|
||||
try {
|
||||
const result = await fileApi.stat('');
|
||||
if (!result) throw new Error('Could not access WebDAV directory');
|
||||
if (!result) throw new Error('WebDAV directory not found: ' + options.path);
|
||||
output.ok = true;
|
||||
} catch (error) {
|
||||
output.errorMessage = error.message;
|
||||
@@ -63,7 +64,7 @@ class SyncTargetWebDAV extends BaseSyncTarget {
|
||||
}
|
||||
|
||||
async initFileApi() {
|
||||
const fileApi = await SyncTargetWebDAV.initFileApi_(SyncTargetWebDAV.id(), {
|
||||
const fileApi = await SyncTargetWebDAV.newFileApi_(SyncTargetWebDAV.id(), {
|
||||
path: Setting.value('sync.6.path'),
|
||||
username: Setting.value('sync.6.username'),
|
||||
password: Setting.value('sync.6.password'),
|
||||
|
@@ -29,7 +29,15 @@ class WebDavApi {
|
||||
|
||||
authToken() {
|
||||
if (!this.options_.username() || !this.options_.password()) return null;
|
||||
return base64.encode(this.options_.username() + ':' + this.options_.password());
|
||||
try {
|
||||
// Note: Non-ASCII passwords will throw an error about Latin1 characters - https://github.com/laurent22/joplin/issues/246
|
||||
// Tried various things like the below, but it didn't work on React Native:
|
||||
//return base64.encode(utf8.encode(this.options_.username() + ':' + this.options_.password()));
|
||||
return base64.encode(this.options_.username() + ':' + this.options_.password());
|
||||
} catch (error) {
|
||||
error.message = 'Cannot encode username/password: ' + error.message;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
baseUrl() {
|
||||
@@ -299,7 +307,7 @@ class WebDavApi {
|
||||
if (json && json['d:error']) {
|
||||
const code = json['d:error']['s:exception'] ? json['d:error']['s:exception'].join(' ') : response.status;
|
||||
const message = json['d:error']['s:message'] ? json['d:error']['s:message'].join("\n") : 'Unknown error 1';
|
||||
throw newError(message + '(Exception ' + code + ')', response.status);
|
||||
throw newError(message + ' (Exception ' + code + ')', response.status);
|
||||
}
|
||||
|
||||
throw newError('Unknown error 2', response.status);
|
||||
|
81
ReactNativeClient/lib/components/ModalDialog.js
Normal file
81
ReactNativeClient/lib/components/ModalDialog.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const React = require('react');
|
||||
const { Text, Modal, View, StyleSheet, Button } = require('react-native');
|
||||
const { themeStyle } = require('lib/components/global-style.js');
|
||||
const { _ } = require('lib/locale');
|
||||
|
||||
class ModalDialog extends React.Component {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.styles_ = {};
|
||||
}
|
||||
|
||||
styles() {
|
||||
const themeId = this.props.theme;
|
||||
const theme = themeStyle(themeId);
|
||||
|
||||
if (this.styles_[themeId]) return this.styles_[themeId];
|
||||
this.styles_ = {};
|
||||
|
||||
let styles = {
|
||||
modalWrapper: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
modalContentWrapper: {
|
||||
flex:1,
|
||||
flexDirection: 'column',
|
||||
backgroundColor: theme.backgroundColor,
|
||||
borderWidth: 1,
|
||||
borderColor:theme.dividerColor,
|
||||
margin: 20,
|
||||
padding: 10,
|
||||
},
|
||||
modalContentWrapper2: {
|
||||
paddingTop: 10,
|
||||
flex:1,
|
||||
},
|
||||
title: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.dividerColor,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.dividerColor,
|
||||
paddingTop: 10,
|
||||
},
|
||||
};
|
||||
|
||||
this.styles_[themeId] = StyleSheet.create(styles);
|
||||
return this.styles_[themeId];
|
||||
}
|
||||
|
||||
render() {
|
||||
const ContentComponent = this.props.ContentComponent;
|
||||
|
||||
return (
|
||||
<View style={this.styles().modalWrapper}>
|
||||
<Modal transparent={true} visible={true} onRequestClose={() => { }} >
|
||||
<View style={this.styles().modalContentWrapper}>
|
||||
<Text style={this.styles().title}>Title</Text>
|
||||
<View style={this.styles().modalContentWrapper2}>
|
||||
{ContentComponent}
|
||||
</View>
|
||||
<View style={this.styles().buttonRow}>
|
||||
<View style={{flex:1}}>
|
||||
<Button title={_('OK')} onPress={() => {}}></Button>
|
||||
</View>
|
||||
<View style={{flex:1, marginLeft: 5}}>
|
||||
<Button title={_('Cancel')} onPress={() => {}}></Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ModalDialog;
|
@@ -4,6 +4,7 @@ const { Platform, View, Text, Button, StyleSheet, TouchableOpacity, Image, Scrol
|
||||
const Icon = require('react-native-vector-icons/Ionicons').default;
|
||||
const { Log } = require('lib/log.js');
|
||||
const { BackButtonService } = require('lib/services/back-button.js');
|
||||
const NavService = require('lib/services/NavService.js');
|
||||
const { ReportService } = require('lib/services/report.js');
|
||||
const { Menu, MenuOptions, MenuOption, MenuTrigger } = require('react-native-popup-menu');
|
||||
const { _ } = require('lib/locale.js');
|
||||
@@ -160,10 +161,7 @@ class ScreenHeaderComponent extends Component {
|
||||
}
|
||||
|
||||
searchButton_press() {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Search',
|
||||
});
|
||||
NavService.go('Search');
|
||||
}
|
||||
|
||||
async deleteButton_press() {
|
||||
@@ -184,38 +182,23 @@ class ScreenHeaderComponent extends Component {
|
||||
}
|
||||
|
||||
log_press() {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Log',
|
||||
});
|
||||
NavService.go('Log');
|
||||
}
|
||||
|
||||
status_press() {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Status',
|
||||
});
|
||||
NavService.go('Status');
|
||||
}
|
||||
|
||||
config_press() {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Config',
|
||||
});
|
||||
NavService.go('Config');
|
||||
}
|
||||
|
||||
encryptionConfig_press() {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'EncryptionConfig',
|
||||
});
|
||||
NavService.go('EncryptionConfig');
|
||||
}
|
||||
|
||||
warningBox_press() {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'EncryptionConfig',
|
||||
});
|
||||
NavService.go('EncryptionConfig');
|
||||
}
|
||||
|
||||
async debugReport_press() {
|
||||
@@ -300,6 +283,16 @@ class ScreenHeaderComponent extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
function sortButton(styles, onPress) {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress}>
|
||||
<View style={styles.iconButton}>
|
||||
<Icon name='md-funnel' style={styles.topIcon} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
let key = 0;
|
||||
let menuOptionComponents = [];
|
||||
|
||||
@@ -441,6 +434,7 @@ class ScreenHeaderComponent extends Component {
|
||||
const backButtonComp = backButton(this.styles(), () => this.backButton_press(), !this.props.historyCanGoBack);
|
||||
const searchButtonComp = this.props.noteSelectionEnabled ? null : searchButton(this.styles(), () => this.searchButton_press());
|
||||
const deleteButtonComp = this.props.noteSelectionEnabled ? deleteButton(this.styles(), () => this.deleteButton_press()) : null;
|
||||
const sortButtonComp = this.props.sortButton_press ? sortButton(this.styles(), () => this.props.sortButton_press()) : null;
|
||||
const windowHeight = Dimensions.get('window').height - 50;
|
||||
|
||||
const menuComp = (
|
||||
@@ -465,6 +459,7 @@ class ScreenHeaderComponent extends Component {
|
||||
{ titleComp }
|
||||
{ searchButtonComp }
|
||||
{ deleteButtonComp }
|
||||
{ sortButtonComp }
|
||||
{ menuComp }
|
||||
</View>
|
||||
{ warningComp }
|
||||
|
@@ -9,6 +9,7 @@ const Setting = require('lib/models/Setting.js');
|
||||
const Resource = require('lib/models/Resource.js');
|
||||
const Folder = require('lib/models/Folder.js');
|
||||
const { BackButtonService } = require('lib/services/back-button.js');
|
||||
const NavService = require('lib/services/NavService.js');
|
||||
const BaseModel = require('lib/BaseModel.js');
|
||||
const { ActionButton } = require('lib/components/action-button.js');
|
||||
const Icon = require('react-native-vector-icons/Ionicons').default;
|
||||
@@ -61,18 +62,29 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
|
||||
this.styles_ = {};
|
||||
|
||||
this.backHandler = async () => {
|
||||
const saveDialog = async () => {
|
||||
if (this.isModified()) {
|
||||
let buttonId = await dialogs.pop(this, _('This note has been modified:'), [
|
||||
{ title: _('Save changes'), id: 'save' },
|
||||
{ title: _('Discard changes'), id: 'discard' },
|
||||
{ title: _('Cancel'), id: 'cancel' },
|
||||
{ text: _('Save changes'), id: 'save' },
|
||||
{ text: _('Discard changes'), id: 'discard' },
|
||||
{ text: _('Cancel'), id: 'cancel' },
|
||||
]);
|
||||
|
||||
if (buttonId == 'cancel') return true;
|
||||
if (buttonId == 'save') await this.saveNoteButton_press();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
this.navHandler = async () => {
|
||||
return await saveDialog();
|
||||
}
|
||||
|
||||
this.backHandler = async () => {
|
||||
const r = await saveDialog();
|
||||
if (r) return r;
|
||||
|
||||
if (!this.state.note.id) {
|
||||
return false;
|
||||
}
|
||||
@@ -145,6 +157,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
|
||||
async componentWillMount() {
|
||||
BackButtonService.addHandler(this.backHandler);
|
||||
NavService.addHandler(this.navHandler);
|
||||
|
||||
await shared.initState(this);
|
||||
|
||||
@@ -157,6 +170,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
|
||||
componentWillUnmount() {
|
||||
BackButtonService.removeHandler(this.backHandler);
|
||||
NavService.removeHandler(this.navHandler);
|
||||
}
|
||||
|
||||
title_changeText(text) {
|
||||
|
@@ -1,5 +1,6 @@
|
||||
const React = require('react'); const Component = React.Component;
|
||||
const { View, Button } = require('react-native');
|
||||
const { View, Button, Text } = require('react-native');
|
||||
const { stateUtils } = require('lib/reducer.js');
|
||||
const { connect } = require('react-redux');
|
||||
const { reg } = require('lib/registry.js');
|
||||
const { Log } = require('lib/log.js');
|
||||
@@ -10,7 +11,7 @@ const Note = require('lib/models/Note.js');
|
||||
const Setting = require('lib/models/Setting.js');
|
||||
const { themeStyle } = require('lib/components/global-style.js');
|
||||
const { ScreenHeader } = require('lib/components/screen-header.js');
|
||||
const { MenuOption, Text } = require('react-native-popup-menu');
|
||||
const { MenuOption } = require('react-native-popup-menu');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const { ActionButton } = require('lib/components/action-button.js');
|
||||
const { dialogs } = require('lib/dialogs.js');
|
||||
@@ -23,6 +24,43 @@ class NotesScreenComponent extends BaseScreenComponent {
|
||||
return { header: null };
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.sortButton_press = async () => {
|
||||
const buttons = [];
|
||||
const sortNoteOptions = Setting.enumOptions('notes.sortOrder.field');
|
||||
|
||||
const makeCheckboxText = function(selected, sign, label) {
|
||||
const s = sign === 'tick' ? '✓' : '⬤'
|
||||
return (selected ? (s + ' ') : '') + label;
|
||||
}
|
||||
|
||||
for (let field in sortNoteOptions) {
|
||||
if (!sortNoteOptions.hasOwnProperty(field)) continue;
|
||||
buttons.push({
|
||||
text: makeCheckboxText(Setting.value('notes.sortOrder.field') === field, 'bullet', sortNoteOptions[field]),
|
||||
id: { name: 'notes.sortOrder.field', value: field },
|
||||
});
|
||||
}
|
||||
|
||||
buttons.push({
|
||||
text: makeCheckboxText(Setting.value('notes.sortOrder.reverse'), 'tick', '[ ' + Setting.settingMetadata('notes.sortOrder.reverse').label() + ' ]'),
|
||||
id: { name: 'notes.sortOrder.reverse', value: !Setting.value('notes.sortOrder.reverse') },
|
||||
});
|
||||
|
||||
buttons.push({
|
||||
text: makeCheckboxText(Setting.value('uncompletedTodosOnTop'), 'tick', '[ ' + Setting.settingMetadata('uncompletedTodosOnTop').label() + ' ]'),
|
||||
id: { name: 'uncompletedTodosOnTop', value: !Setting.value('uncompletedTodosOnTop') },
|
||||
});
|
||||
|
||||
const r = await dialogs.pop(this, Setting.settingMetadata('notes.sortOrder.field').label(), buttons);
|
||||
if (!r) return;
|
||||
|
||||
Setting.setValue(r.name, r.value);
|
||||
}
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
await this.refreshNotes();
|
||||
}
|
||||
@@ -42,6 +80,7 @@ class NotesScreenComponent extends BaseScreenComponent {
|
||||
let options = {
|
||||
order: props.notesOrder,
|
||||
uncompletedTodosOnTop: props.uncompletedTodosOnTop,
|
||||
caseInsensitive: true,
|
||||
};
|
||||
|
||||
const parent = this.parentItem(props);
|
||||
@@ -155,6 +194,7 @@ class NotesScreenComponent extends BaseScreenComponent {
|
||||
title={title}
|
||||
menuOptions={this.menuOptions()}
|
||||
parentComponent={thisComp}
|
||||
sortButton_press={this.sortButton_press}
|
||||
folderPickerOptions={{
|
||||
enabled: this.props.noteSelectionEnabled,
|
||||
mustSelect: true,
|
||||
@@ -178,11 +218,11 @@ const NotesScreen = connect(
|
||||
selectedTagId: state.selectedTagId,
|
||||
notesParentType: state.notesParentType,
|
||||
notes: state.notes,
|
||||
notesOrder: state.notesOrder,
|
||||
notesSource: state.notesSource,
|
||||
uncompletedTodosOnTop: state.settings.uncompletedTodosOnTop,
|
||||
theme: state.settings.theme,
|
||||
noteSelectionEnabled: state.noteSelectionEnabled,
|
||||
notesOrder: stateUtils.notesOrder(state.settings),
|
||||
};
|
||||
}
|
||||
)(NotesScreenComponent)
|
||||
|
@@ -33,17 +33,20 @@ dialogs.confirm = (parentComponent, message) => {
|
||||
});
|
||||
};
|
||||
|
||||
dialogs.pop = (parentComponent, message, buttons) => {
|
||||
dialogs.pop = (parentComponent, message, buttons, options = null) => {
|
||||
if (!parentComponent) throw new Error('parentComponent is required');
|
||||
if (!('dialogbox' in parentComponent)) throw new Error('A "dialogbox" component must be defined on the parent component!');
|
||||
|
||||
if (!options) options = {};
|
||||
if (!('buttonFlow' in options)) options.buttonFlow = 'auto';
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
Keyboard.dismiss();
|
||||
|
||||
let btns = [];
|
||||
for (let i = 0; i < buttons.length; i++) {
|
||||
btns.push({
|
||||
text: buttons[i].title,
|
||||
text: buttons[i].text,
|
||||
callback: () => {
|
||||
parentComponent.dialogbox.close();
|
||||
resolve(buttons[i].id);
|
||||
@@ -54,6 +57,7 @@ dialogs.pop = (parentComponent, message, buttons) => {
|
||||
parentComponent.dialogbox.pop({
|
||||
content: message,
|
||||
btns: btns,
|
||||
buttonFlow: options.buttonFlow,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@@ -27,7 +27,6 @@ class FileApiDriverWebDav {
|
||||
const result = await this.api().execPropFind(path, 0, [
|
||||
'd:getlastmodified',
|
||||
'd:resourcetype',
|
||||
// 'd:getcontentlength', // Remove this once PUT call issue is sorted out
|
||||
]);
|
||||
|
||||
const resource = this.api().objectFromJson(result, ['d:multistatus', 'd:response', 0]);
|
||||
@@ -56,11 +55,7 @@ class FileApiDriverWebDav {
|
||||
}
|
||||
}
|
||||
|
||||
const lastModifiedString = this.api().resourcePropByName(resource, 'string', 'd:getlastmodified');
|
||||
|
||||
// const sizeDONOTUSE = Number(this.api().stringFromJson(resource, ['d:propstat', 0, 'd:prop', 0, 'd:getcontentlength', 0]));
|
||||
// if (isNaN(sizeDONOTUSE)) throw new Error('Cannot get content size: ' + JSON.stringify(resource));
|
||||
|
||||
const lastModifiedString = this.api().resourcePropByName(resource, 'string', 'd:getlastmodified');
|
||||
|
||||
// Note: Not all WebDAV servers return a getlastmodified date (eg. Seafile, which doesn't return the
|
||||
// property for folders) so we can only throw an error if it's a file.
|
||||
@@ -70,10 +65,8 @@ class FileApiDriverWebDav {
|
||||
|
||||
return {
|
||||
path: path,
|
||||
// created_time: lastModifiedDate.getTime(),
|
||||
updated_time: lastModifiedDate.getTime(),
|
||||
isDir: isDir,
|
||||
// sizeDONOTUSE: sizeDONOTUSE, // This property is used only for the WebDAV PUT hack (see below) so mark it as such so that it can be removed with the hack later on.
|
||||
};
|
||||
}
|
||||
|
||||
|
24
ReactNativeClient/lib/fs-driver-base.js
Normal file
24
ReactNativeClient/lib/fs-driver-base.js
Normal file
@@ -0,0 +1,24 @@
|
||||
class FsDriverBase {
|
||||
|
||||
async isDirectory(path) {
|
||||
const stat = await this.stat(path);
|
||||
return !stat ? false : stat.isDirectory();
|
||||
}
|
||||
|
||||
async readDirStatsHandleRecursion_(basePath, stat, output, options) {
|
||||
if (options.recursive && stat.isDirectory()) {
|
||||
const subPath = basePath + '/' + stat.path;
|
||||
const subStats = await this.readDirStats(subPath, options);
|
||||
for (let j = 0; j < subStats.length; j++) {
|
||||
const subStat = subStats[j];
|
||||
subStat.path = stat.path + '/' + subStat.path;
|
||||
output.push(subStat);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = FsDriverBase;
|
@@ -1,7 +1,8 @@
|
||||
const fs = require('fs-extra');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const FsDriverBase = require('lib/fs-driver-base');
|
||||
|
||||
class FsDriverNode {
|
||||
class FsDriverNode extends FsDriverBase {
|
||||
|
||||
fsErrorToJsError_(error, path = null) {
|
||||
let msg = error.toString();
|
||||
@@ -81,9 +82,14 @@ class FsDriverNode {
|
||||
|
||||
async stat(path) {
|
||||
try {
|
||||
const s = await fs.stat(path);
|
||||
s.path = path;
|
||||
return s;
|
||||
const stat = await fs.stat(path);
|
||||
return {
|
||||
birthtime: stat.birthtime,
|
||||
mtime: stat.mtime,
|
||||
isDirectory: () => stat.isDirectory(),
|
||||
path: path,
|
||||
size: stat.size,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.code == 'ENOENT') return null;
|
||||
throw error;
|
||||
@@ -94,14 +100,26 @@ class FsDriverNode {
|
||||
return fs.utimes(path, timestampDate, timestampDate);
|
||||
}
|
||||
|
||||
async readDirStats(path) {
|
||||
let items = await fs.readdir(path);
|
||||
async readDirStats(path, options = null) {
|
||||
if (!options) options = {};
|
||||
if (!('recursive' in options)) options.recursive = false;
|
||||
|
||||
let items = [];
|
||||
try {
|
||||
items = await fs.readdir(path);
|
||||
} catch (error) {
|
||||
throw this.fsErrorToJsError_(error);
|
||||
}
|
||||
|
||||
let output = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let stat = await this.stat(path + '/' + items[i]);
|
||||
const item = items[i];
|
||||
let stat = await this.stat(path + '/' + item);
|
||||
if (!stat) continue; // Has been deleted between the readdir() call and now
|
||||
stat.path = stat.path.substr(path.length + 1);
|
||||
output.push(stat);
|
||||
|
||||
output = await this.readDirStatsHandleRecursion_(path, stat, output, options);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
@@ -122,14 +140,22 @@ class FsDriverNode {
|
||||
}
|
||||
}
|
||||
|
||||
readFile(path, encoding = 'utf8') {
|
||||
if (encoding === 'Buffer') return fs.readFile(path); // Returns the raw buffer
|
||||
return fs.readFile(path, encoding);
|
||||
async readFile(path, encoding = 'utf8') {
|
||||
try {
|
||||
if (encoding === 'Buffer') return await fs.readFile(path); // Returns the raw buffer
|
||||
return await fs.readFile(path, encoding);
|
||||
} catch (error) {
|
||||
throw this.fsErrorToJsError_(error, path);
|
||||
}
|
||||
}
|
||||
|
||||
// Always overwrite destination
|
||||
async copy(source, dest) {
|
||||
return fs.copy(source, dest, { overwrite: true });
|
||||
try {
|
||||
return await fs.copy(source, dest, { overwrite: true });
|
||||
} catch (error) {
|
||||
throw this.fsErrorToJsError_(error, source);
|
||||
}
|
||||
}
|
||||
|
||||
async unlink(path) {
|
||||
|
@@ -1,6 +1,7 @@
|
||||
const RNFS = require('react-native-fs');
|
||||
const FsDriverBase = require('lib/fs-driver-base');
|
||||
|
||||
class FsDriverRN {
|
||||
class FsDriverRN extends FsDriverBase {
|
||||
|
||||
appendFileSync(path, string) {
|
||||
throw new Error('Not implemented');
|
||||
@@ -34,13 +35,18 @@ class FsDriverRN {
|
||||
};
|
||||
}
|
||||
|
||||
async readDirStats(path) {
|
||||
async readDirStats(path, options = null) {
|
||||
if (!options) options = {};
|
||||
if (!('recursive' in options)) options.recursive = false;
|
||||
|
||||
let items = await RNFS.readDir(path);
|
||||
let output = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
const relativePath = item.path.substr(path.length + 1);
|
||||
output.push(this.rnfsStatToStd_(item, relativePath));
|
||||
|
||||
output = await this.readDirStatsHandleRecursion_(path, item, output, options);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
@@ -15,6 +15,16 @@ class Note extends BaseItem {
|
||||
return 'notes';
|
||||
}
|
||||
|
||||
static fieldToLabel(field) {
|
||||
const fieldsToLabels = {
|
||||
title: 'title',
|
||||
user_updated_time: 'updated date',
|
||||
user_created_time: 'created date',
|
||||
};
|
||||
|
||||
return field in fieldsToLabels ? fieldsToLabels[field] : field;
|
||||
}
|
||||
|
||||
static async serialize(note, type = null, shownKeys = null) {
|
||||
let fieldNames = this.fieldNames();
|
||||
fieldNames.push('type_');
|
||||
|
@@ -4,6 +4,8 @@ const { Logger } = require('lib/logger.js');
|
||||
const SyncTargetRegistry = require('lib/SyncTargetRegistry.js');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const ObjectUtils = require('lib/ObjectUtils');
|
||||
const { toTitleCase } = require('lib/string-utils.js');
|
||||
const { _, supportedLocalesToLanguages, defaultLocale } = require('lib/locale.js');
|
||||
|
||||
class Setting extends BaseModel {
|
||||
@@ -19,12 +21,16 @@ class Setting extends BaseModel {
|
||||
static metadata() {
|
||||
if (this.metadata_) return this.metadata_;
|
||||
|
||||
// A "public" setting means that it will show up in the various config screens (or config command for the CLI tool), however
|
||||
// if if private a setting might still be handled and modified by the app. For instance, the settings related to sorting notes are not
|
||||
// public for the mobile and desktop apps because they are handled separately in menus.
|
||||
|
||||
this.metadata_ = {
|
||||
'activeFolderId': { value: '', type: Setting.TYPE_STRING, public: false },
|
||||
'firstStart': { value: true, type: Setting.TYPE_BOOL, public: false },
|
||||
'editor': { value: '', type: Setting.TYPE_STRING, public: true, appTypes: ['cli'], label: () => _('Text editor'), description: () => _('The editor that will be used to open a note. If none is provided it will try to auto-detect the default editor.') },
|
||||
'locale': { value: defaultLocale(), type: Setting.TYPE_STRING, isEnum: true, public: true, label: () => _('Language'), options: () => {
|
||||
return supportedLocalesToLanguages();
|
||||
return ObjectUtils.sortByValue(supportedLocalesToLanguages());
|
||||
}},
|
||||
'dateFormat': { value: Setting.DATE_FORMAT_1, type: Setting.TYPE_STRING, isEnum: true, public: true, label: () => _('Date format'), options: () => {
|
||||
let options = {}
|
||||
@@ -49,16 +55,17 @@ class Setting extends BaseModel {
|
||||
output[Setting.THEME_DARK] = _('Dark');
|
||||
return output;
|
||||
}},
|
||||
// 'logLevel': { value: Logger.LEVEL_INFO, type: Setting.TYPE_STRING, isEnum: true, public: true, label: () => _('Log level'), options: () => {
|
||||
// return Logger.levelEnum();
|
||||
// }},
|
||||
// Not used for now:
|
||||
// 'todoFilter': { value: 'all', type: Setting.TYPE_STRING, isEnum: true, public: false, appTypes: ['mobile'], label: () => _('Todo filter'), options: () => ({
|
||||
// all: _('Show all'),
|
||||
// recent: _('Non-completed and recently completed ones'),
|
||||
// nonCompleted: _('Non-completed ones only'),
|
||||
// })},
|
||||
'uncompletedTodosOnTop': { value: true, type: Setting.TYPE_BOOL, public: true, label: () => _('Show uncompleted to-dos on top of the lists') },
|
||||
'uncompletedTodosOnTop': { value: true, type: Setting.TYPE_BOOL, public: true, appTypes: ['cli'], label: () => _('Uncompleted to-dos on top') },
|
||||
'notes.sortOrder.field': { value: 'user_updated_time', type: Setting.TYPE_STRING, isEnum: true, public: true, appTypes: ['cli'], label: () => _('Sort notes by'), options: () => {
|
||||
const Note = require('lib/models/Note');
|
||||
const noteSortFields = ['user_updated_time', 'user_created_time', 'title'];
|
||||
const options = {};
|
||||
for (let i = 0; i < noteSortFields.length; i++) {
|
||||
options[noteSortFields[i]] = toTitleCase(Note.fieldToLabel(noteSortFields[i]));
|
||||
}
|
||||
return options;
|
||||
}},
|
||||
'notes.sortOrder.reverse': { value: true, type: Setting.TYPE_BOOL, public: true, label: () => _('Reverse sort order'), appTypes: ['cli'] },
|
||||
'trackLocation': { value: true, type: Setting.TYPE_BOOL, public: true, label: () => _('Save geo-location with notes') },
|
||||
'newTodoFocus': { value: 'title', type: Setting.TYPE_STRING, isEnum: true, public: true, appTypes: ['desktop'], label: () => _('When creating a new to-do:'), options: () => {
|
||||
return {
|
||||
|
@@ -102,6 +102,10 @@ class Tag extends BaseItem {
|
||||
return this.modelSelectAll('SELECT * FROM tags WHERE id IN ("' + tagIds.join('","') + '")');
|
||||
}
|
||||
|
||||
static async loadByTitle(title) {
|
||||
return this.loadByField('title', title, { caseInsensitive: true });
|
||||
}
|
||||
|
||||
static async setNoteTagsByTitles(noteId, tagTitles) {
|
||||
const previousTags = await this.tagsByNoteId(noteId);
|
||||
const addedTitles = [];
|
||||
@@ -109,7 +113,7 @@ class Tag extends BaseItem {
|
||||
for (let i = 0; i < tagTitles.length; i++) {
|
||||
const title = tagTitles[i].trim().toLowerCase();
|
||||
if (!title) continue;
|
||||
let tag = await this.loadByField('title', title, { caseInsensitive: true });
|
||||
let tag = await this.loadByTitle(title);
|
||||
if (!tag) tag = await Tag.save({ title: title }, { userSideValidation: true });
|
||||
await this.addNote(tag.id, noteId);
|
||||
addedTitles.push(title);
|
||||
|
@@ -19,19 +19,24 @@ const defaultState = {
|
||||
showSideMenu: false,
|
||||
screens: {},
|
||||
historyCanGoBack: false,
|
||||
notesOrder: [
|
||||
{ by: 'user_updated_time', dir: 'DESC' },
|
||||
],
|
||||
syncStarted: false,
|
||||
syncReport: {},
|
||||
searchQuery: '',
|
||||
settings: {},
|
||||
appState: 'starting',
|
||||
//windowContentSize: { width: 0, height: 0 },
|
||||
hasDisabledSyncItems: false,
|
||||
newNote: null,
|
||||
};
|
||||
|
||||
const stateUtils = {};
|
||||
|
||||
stateUtils.notesOrder = function(stateSettings) {
|
||||
return [{
|
||||
by: stateSettings['notes.sortOrder.field'],
|
||||
dir: stateSettings['notes.sortOrder.reverse'] ? 'DESC' : 'ASC',
|
||||
}];
|
||||
}
|
||||
|
||||
function arrayHasEncryptedItems(array) {
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
if (!!array[i].encryption_applied) return true;
|
||||
@@ -90,8 +95,6 @@ function handleItemDelete(state, action) {
|
||||
}
|
||||
|
||||
function updateOneItem(state, action) {
|
||||
// let newItems = action.type === 'TAG_UPDATE_ONE' ? state.tags.splice(0) : state.folders.splice(0);
|
||||
// let item = action.type === 'TAG_UPDATE_ONE' ? action.tag : action.folder;
|
||||
let itemsKey = null;
|
||||
if (action.type === 'TAG_UPDATE_ONE') itemsKey = 'tags';
|
||||
if (action.type === 'FOLDER_UPDATE_ONE') itemsKey = 'folders';
|
||||
@@ -116,12 +119,6 @@ function updateOneItem(state, action) {
|
||||
|
||||
newState[itemsKey] = newItems;
|
||||
|
||||
// if (action.type === 'TAG_UPDATE_ONE') {
|
||||
// newState.tags = newItems;
|
||||
// } else {
|
||||
// newState.folders = newItems;
|
||||
// }
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
@@ -316,7 +313,8 @@ const reducer = (state = defaultState, action) => {
|
||||
}
|
||||
}
|
||||
|
||||
newNotes = Note.sortNotes(newNotes, state.notesOrder, newState.settings.uncompletedTodosOnTop);
|
||||
//newNotes = Note.sortNotes(newNotes, state.notesOrder, newState.settings.uncompletedTodosOnTop);
|
||||
newNotes = Note.sortNotes(newNotes, stateUtils.notesOrder(state.settings), newState.settings.uncompletedTodosOnTop);
|
||||
newState = Object.assign({}, state);
|
||||
newState.notes = newNotes;
|
||||
|
||||
@@ -481,4 +479,4 @@ const reducer = (state = defaultState, action) => {
|
||||
return newState;
|
||||
}
|
||||
|
||||
module.exports = { reducer, defaultState };
|
||||
module.exports = { reducer, defaultState, stateUtils };
|
@@ -44,7 +44,7 @@ reg.syncTarget = (syncTargetId = null) => {
|
||||
}
|
||||
|
||||
reg.scheduleSync = async (delay = null) => {
|
||||
if (delay === null) delay = 1000 * 30;
|
||||
if (delay === null) delay = 1000 * 10;
|
||||
|
||||
let promiseResolve = null;
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
@@ -58,10 +58,10 @@ reg.scheduleSync = async (delay = null) => {
|
||||
|
||||
reg.logger().info('Scheduling sync operation...');
|
||||
|
||||
// if (Setting.value('env') === 'dev') {
|
||||
// reg.logger().info('Scheduling sync operation DISABLED!!!');
|
||||
// return;
|
||||
// }
|
||||
if (Setting.value('env') === 'dev' && delay !== 0) {
|
||||
reg.logger().info('Schedule sync DISABLED!!!');
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutCallback = async () => {
|
||||
reg.scheduleSyncId_ = null;
|
||||
@@ -147,6 +147,11 @@ reg.setupRecurrentSync = () => {
|
||||
} else {
|
||||
reg.logger().debug('Setting up recurrent sync with interval ' + Setting.value('sync.interval'));
|
||||
|
||||
if (Setting.value('env') === 'dev') {
|
||||
reg.logger().info('Recurrent sync operation DISABLED!!!');
|
||||
return;
|
||||
}
|
||||
|
||||
reg.recurrentSyncId_ = shim.setInterval(() => {
|
||||
reg.logger().info('Running background sync on timer...');
|
||||
reg.scheduleSync(0);
|
||||
|
@@ -96,6 +96,11 @@ class DecryptionWorker {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (error.code === 'masterKeyNotLoaded' && options.materKeyNotLoadedHandler === 'throw') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger().warn('DecryptionWorker: error for: ' + item.id + ' (' + ItemClass.tableName() + ')', error);
|
||||
}
|
||||
}
|
||||
|
246
ReactNativeClient/lib/services/InteropService.js
Normal file
246
ReactNativeClient/lib/services/InteropService.js
Normal file
@@ -0,0 +1,246 @@
|
||||
const BaseItem = require('lib/models/BaseItem.js');
|
||||
const BaseModel = require('lib/BaseModel.js');
|
||||
const Resource = require('lib/models/Resource.js');
|
||||
const Folder = require('lib/models/Folder.js');
|
||||
const NoteTag = require('lib/models/NoteTag.js');
|
||||
const Note = require('lib/models/Note.js');
|
||||
const Tag = require('lib/models/Tag.js');
|
||||
const { basename, filename } = require('lib/path-utils.js');
|
||||
const fs = require('fs-extra');
|
||||
const ArrayUtils = require('lib/ArrayUtils');
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const { shim } = require('lib/shim');
|
||||
const { _ } = require('lib/locale');
|
||||
const { fileExtension } = require('lib/path-utils');
|
||||
const { uuid } = require('lib/uuid.js');
|
||||
const { toTitleCase } = require('lib/string-utils');
|
||||
|
||||
class InteropService {
|
||||
|
||||
constructor() {
|
||||
this.modules_ = null;
|
||||
}
|
||||
|
||||
modules() {
|
||||
if (this.modules_) return this.modules_;
|
||||
|
||||
let importModules = [
|
||||
{
|
||||
format: 'jex',
|
||||
fileExtension: 'jex',
|
||||
sources: ['file'],
|
||||
description: _('Joplin Export File'),
|
||||
}, {
|
||||
format: 'md',
|
||||
fileExtension: 'md',
|
||||
sources: ['file', 'directory'],
|
||||
isNoteArchive: false, // Tells whether the file can contain multiple notes (eg. Enex or Jex format)
|
||||
description: _('Markdown'),
|
||||
}, {
|
||||
format: 'raw',
|
||||
sources: ['directory'],
|
||||
description: _('Joplin Export Directory'),
|
||||
}, {
|
||||
format: 'enex',
|
||||
fileExtension: 'enex',
|
||||
sources: ['file'],
|
||||
description: _('Evernote Export File'),
|
||||
},
|
||||
];
|
||||
|
||||
let exportModules = [
|
||||
{
|
||||
format: 'jex',
|
||||
fileExtension: 'jex',
|
||||
target: 'file',
|
||||
description: _('Joplin Export File'),
|
||||
}, {
|
||||
format: 'raw',
|
||||
target: 'directory',
|
||||
description: _('Joplin Export Directory'),
|
||||
},
|
||||
];
|
||||
|
||||
importModules = importModules.map((a) => {
|
||||
const className = 'InteropService_Importer_' + toTitleCase(a.format);
|
||||
const output = Object.assign({}, {
|
||||
type: 'importer',
|
||||
path: 'lib/services/' + className,
|
||||
}, a);
|
||||
if (!('isNoteArchive' in output)) output.isNoteArchive = true;
|
||||
return output;
|
||||
});
|
||||
|
||||
exportModules = exportModules.map((a) => {
|
||||
const className = 'InteropService_Exporter_' + toTitleCase(a.format);
|
||||
return Object.assign({}, {
|
||||
type: 'exporter',
|
||||
path: 'lib/services/' + className,
|
||||
}, a);
|
||||
});
|
||||
|
||||
this.modules_ = importModules.concat(exportModules);
|
||||
|
||||
return this.modules_;
|
||||
}
|
||||
|
||||
moduleByFormat_(type, format) {
|
||||
const modules = this.modules();
|
||||
for (let i = 0; i < modules.length; i++) {
|
||||
const m = modules[i];
|
||||
if (m.format === format && m.type === type) return modules[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
newModule_(type, format) {
|
||||
const module = this.moduleByFormat_(type, format);
|
||||
if (!module) throw new Error(_('Cannot load "%s" module for format "%s"', type, format));
|
||||
const ModuleClass = require(module.path);
|
||||
return new ModuleClass();
|
||||
}
|
||||
|
||||
moduleByFileExtension_(type, ext) {
|
||||
ext = ext.toLowerCase();
|
||||
|
||||
const modules = this.modules();
|
||||
|
||||
for (let i = 0; i < modules.length; i++) {
|
||||
const m = modules[i];
|
||||
if (type !== m.type) continue;
|
||||
if (m.fileExtension === ext) return m;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async import(options) {
|
||||
if (!await shim.fsDriver().exists(options.path)) throw new Error(_('Cannot find "%s".', options.path));
|
||||
|
||||
options = Object.assign({}, {
|
||||
format: 'auto',
|
||||
destinationFolderId: null,
|
||||
destinationFolder: null,
|
||||
}, options);
|
||||
|
||||
if (options.format === 'auto') {
|
||||
const module = this.moduleByFileExtension_('importer', fileExtension(options.path));
|
||||
if (!module) throw new Error(_('Please specify import format for %s', options.path));
|
||||
options.format = module.format;
|
||||
}
|
||||
|
||||
if (options.destinationFolderId) {
|
||||
const folder = await Folder.load(options.destinationFolderId);
|
||||
if (!folder) throw new Error(_('Cannot find "%s".', options.destinationFolderId));
|
||||
options.destinationFolder = folder;
|
||||
}
|
||||
|
||||
let result = { warnings: [] }
|
||||
|
||||
const importer = this.newModule_('importer', options.format);
|
||||
await importer.init(options.path, options);
|
||||
result = await importer.exec(result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async export(options) {
|
||||
const exportPath = options.path ? options.path : null;
|
||||
const sourceFolderIds = options.sourceFolderIds ? options.sourceFolderIds : [];
|
||||
const sourceNoteIds = options.sourceNoteIds ? options.sourceNoteIds : [];
|
||||
const exportFormat = options.format ? options.format : 'jex';
|
||||
const result = { warnings: [] }
|
||||
const itemsToExport = [];
|
||||
|
||||
const queueExportItem = (itemType, itemOrId) => {
|
||||
itemsToExport.push({
|
||||
type: itemType,
|
||||
itemOrId: itemOrId
|
||||
});
|
||||
}
|
||||
|
||||
let exportedNoteIds = [];
|
||||
let resourceIds = [];
|
||||
const folderIds = await Folder.allIds();
|
||||
|
||||
for (let folderIndex = 0; folderIndex < folderIds.length; folderIndex++) {
|
||||
const folderId = folderIds[folderIndex];
|
||||
if (sourceFolderIds.length && sourceFolderIds.indexOf(folderId) < 0) continue;
|
||||
|
||||
if (!sourceNoteIds.length) await queueExportItem(BaseModel.TYPE_FOLDER, folderId);
|
||||
|
||||
const noteIds = await Folder.noteIds(folderId);
|
||||
|
||||
for (let noteIndex = 0; noteIndex < noteIds.length; noteIndex++) {
|
||||
const noteId = noteIds[noteIndex];
|
||||
if (sourceNoteIds.length && sourceNoteIds.indexOf(noteId) < 0) continue;
|
||||
const note = await Note.load(noteId);
|
||||
await queueExportItem(BaseModel.TYPE_NOTE, note);
|
||||
exportedNoteIds.push(noteId);
|
||||
|
||||
const rids = Note.linkedResourceIds(note.body);
|
||||
resourceIds = resourceIds.concat(rids);
|
||||
}
|
||||
}
|
||||
|
||||
resourceIds = ArrayUtils.unique(resourceIds);
|
||||
|
||||
for (let i = 0; i < resourceIds.length; i++) {
|
||||
await queueExportItem(BaseModel.TYPE_RESOURCE, resourceIds[i]);
|
||||
}
|
||||
|
||||
const noteTags = await NoteTag.all();
|
||||
|
||||
let exportedTagIds = [];
|
||||
|
||||
for (let i = 0; i < noteTags.length; i++) {
|
||||
const noteTag = noteTags[i];
|
||||
if (exportedNoteIds.indexOf(noteTag.note_id) < 0) continue;
|
||||
await queueExportItem(BaseModel.TYPE_NOTE_TAG, noteTag.id);
|
||||
exportedTagIds.push(noteTag.tag_id);
|
||||
}
|
||||
|
||||
for (let i = 0; i < exportedTagIds.length; i++) {
|
||||
await queueExportItem(BaseModel.TYPE_TAG, exportedTagIds[i]);
|
||||
}
|
||||
|
||||
const exporter = this.newModule_('exporter', exportFormat);
|
||||
await exporter.init(exportPath);
|
||||
|
||||
for (let i = 0; i < itemsToExport.length; i++) {
|
||||
const itemType = itemsToExport[i].type;
|
||||
const ItemClass = BaseItem.getClassByItemType(itemType);
|
||||
const itemOrId = itemsToExport[i].itemOrId;
|
||||
const item = typeof itemOrId === 'object' ? itemOrId : await ItemClass.load(itemOrId);
|
||||
|
||||
if (!item) {
|
||||
if (itemType === BaseModel.TYPE_RESOURCE) {
|
||||
result.warnings.push(sprintf('A resource that does not exist is referenced in a note. The resource was skipped. Resource ID: %s', itemOrId));
|
||||
} else {
|
||||
result.warnings.push(sprintf('Cannot find item with type "%s" and ID %s. Item was skipped.', ItemClass.tableName(), JSON.stringify(itemOrId)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.encryption_applied || item.encryption_blob_encrypted) throw new Error(_('This item is currently encrypted: %s "%s". Please wait for all items to be decrypted and try again.', BaseModel.modelTypeToName(itemType), item.title ? item.title : item.id));
|
||||
|
||||
try {
|
||||
if (itemType == BaseModel.TYPE_RESOURCE) {
|
||||
const resourcePath = Resource.fullPath(item);
|
||||
await exporter.processResource(item, resourcePath);
|
||||
}
|
||||
|
||||
await exporter.processItem(ItemClass, item);
|
||||
} catch (error) {
|
||||
result.warnings.push(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
await exporter.close();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = InteropService;
|
@@ -0,0 +1,17 @@
|
||||
class InteropService_Exporter_Base {
|
||||
|
||||
async init(destDir) {}
|
||||
async processItem(ItemClass, item) {}
|
||||
async processResource(resource, filePath) {}
|
||||
async close() {}
|
||||
|
||||
async temporaryDirectory_(createIt) {
|
||||
const md5 = require('md5');
|
||||
const tempDir = require('os').tmpdir() + '/' + md5(Math.random() + Date.now());
|
||||
if (createIt) await require('fs-extra').mkdirp(tempDir);
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = InteropService_Exporter_Base;
|
@@ -0,0 +1,57 @@
|
||||
const InteropService_Exporter_Base = require('lib/services/InteropService_Exporter_Base');
|
||||
const InteropService_Exporter_Raw = require('lib/services/InteropService_Exporter_Raw');
|
||||
const BaseItem = require('lib/models/BaseItem.js');
|
||||
const BaseModel = require('lib/BaseModel.js');
|
||||
const Resource = require('lib/models/Resource.js');
|
||||
const Folder = require('lib/models/Folder.js');
|
||||
const NoteTag = require('lib/models/NoteTag.js');
|
||||
const Note = require('lib/models/Note.js');
|
||||
const Tag = require('lib/models/Tag.js');
|
||||
const { basename, filename } = require('lib/path-utils.js');
|
||||
const fs = require('fs-extra');
|
||||
const md5 = require('md5');
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const { shim } = require('lib/shim');
|
||||
const { _ } = require('lib/locale');
|
||||
const { fileExtension } = require('lib/path-utils');
|
||||
const { uuid } = require('lib/uuid.js');
|
||||
const { importEnex } = require('lib/import-enex');
|
||||
|
||||
class InteropService_Exporter_Jex extends InteropService_Exporter_Base {
|
||||
|
||||
async init(destPath) {
|
||||
if (await shim.fsDriver().isDirectory(destPath)) throw new Error('Path is a directory: ' + destPath);
|
||||
|
||||
this.tempDir_ = await this.temporaryDirectory_(false);
|
||||
this.destPath_ = destPath;
|
||||
this.rawExporter_ = new InteropService_Exporter_Raw();
|
||||
await this.rawExporter_.init(this.tempDir_);
|
||||
}
|
||||
|
||||
async processItem(ItemClass, item) {
|
||||
return this.rawExporter_.processItem(ItemClass, item);
|
||||
}
|
||||
|
||||
async processResource(resource, filePath) {
|
||||
return this.rawExporter_.processResource(resource, filePath);
|
||||
}
|
||||
|
||||
async close() {
|
||||
const stats = await shim.fsDriver().readDirStats(this.tempDir_, { recursive: true });
|
||||
const filePaths = stats.filter((a) => !a.isDirectory()).map((a) => a.path);
|
||||
|
||||
if (!filePaths.length) throw new Error(_('There is no data to export.'));
|
||||
|
||||
await require('tar').create({
|
||||
strict: true,
|
||||
portable: true,
|
||||
file: this.destPath_,
|
||||
cwd: this.tempDir_,
|
||||
}, filePaths);
|
||||
|
||||
await fs.remove(this.tempDir_);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = InteropService_Exporter_Jex;
|
@@ -0,0 +1,30 @@
|
||||
const InteropService_Exporter_Base = require('lib/services/InteropService_Exporter_Base');
|
||||
const { basename, filename } = require('lib/path-utils.js');
|
||||
const { shim } = require('lib/shim');
|
||||
|
||||
class InteropService_Exporter_Raw extends InteropService_Exporter_Base {
|
||||
|
||||
async init(destDir) {
|
||||
this.destDir_ = destDir;
|
||||
this.resourceDir_ = destDir ? destDir + '/resources' : null;
|
||||
|
||||
await shim.fsDriver().mkdir(this.destDir_);
|
||||
await shim.fsDriver().mkdir(this.resourceDir_);
|
||||
}
|
||||
|
||||
async processItem(ItemClass, item) {
|
||||
const serialized = await ItemClass.serialize(item);
|
||||
const filePath = this.destDir_ + '/' + ItemClass.systemPath(item);
|
||||
await shim.fsDriver().writeFile(filePath, serialized, 'utf-8');
|
||||
}
|
||||
|
||||
async processResource(resource, filePath) {
|
||||
const destResourcePath = this.resourceDir_ + '/' + basename(filePath);
|
||||
await shim.fsDriver().copy(filePath, destResourcePath);
|
||||
}
|
||||
|
||||
async close() {}
|
||||
|
||||
}
|
||||
|
||||
module.exports = InteropService_Exporter_Raw;
|
@@ -0,0 +1,19 @@
|
||||
class InteropService_Importer_Base {
|
||||
|
||||
async init(sourcePath, options) {
|
||||
this.sourcePath_ = sourcePath;
|
||||
this.options_ = options;
|
||||
}
|
||||
|
||||
async exec(result) {}
|
||||
|
||||
async temporaryDirectory_(createIt) {
|
||||
const md5 = require('md5');
|
||||
const tempDir = require('os').tmpdir() + '/' + md5(Math.random() + Date.now());
|
||||
if (createIt) await require('fs-extra').mkdirp(tempDir);
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = InteropService_Importer_Base;
|
@@ -0,0 +1,37 @@
|
||||
const InteropService_Importer_Base = require('lib/services/InteropService_Importer_Base');
|
||||
const BaseItem = require('lib/models/BaseItem.js');
|
||||
const BaseModel = require('lib/BaseModel.js');
|
||||
const Resource = require('lib/models/Resource.js');
|
||||
const Folder = require('lib/models/Folder.js');
|
||||
const NoteTag = require('lib/models/NoteTag.js');
|
||||
const Note = require('lib/models/Note.js');
|
||||
const Tag = require('lib/models/Tag.js');
|
||||
const { basename, filename } = require('lib/path-utils.js');
|
||||
const fs = require('fs-extra');
|
||||
const md5 = require('md5');
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const { shim } = require('lib/shim');
|
||||
const { _ } = require('lib/locale');
|
||||
const { fileExtension } = require('lib/path-utils');
|
||||
const { uuid } = require('lib/uuid.js');
|
||||
|
||||
class InteropService_Importer_Enex extends InteropService_Importer_Base {
|
||||
|
||||
async exec(result) {
|
||||
const { importEnex } = require('lib/import-enex');
|
||||
|
||||
let folder = this.options_.destinationFolder;
|
||||
|
||||
if (!folder) {
|
||||
const folderTitle = await Folder.findUniqueFolderTitle(filename(this.sourcePath_));
|
||||
folder = await Folder.save({ title: folderTitle });
|
||||
}
|
||||
|
||||
await importEnex(folder.id, this.sourcePath_, this.options_);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = InteropService_Importer_Enex;
|
@@ -0,0 +1,50 @@
|
||||
const InteropService_Importer_Base = require('lib/services/InteropService_Importer_Base');
|
||||
const InteropService_Importer_Raw = require('lib/services/InteropService_Importer_Raw');
|
||||
const BaseItem = require('lib/models/BaseItem.js');
|
||||
const BaseModel = require('lib/BaseModel.js');
|
||||
const Resource = require('lib/models/Resource.js');
|
||||
const Folder = require('lib/models/Folder.js');
|
||||
const NoteTag = require('lib/models/NoteTag.js');
|
||||
const Note = require('lib/models/Note.js');
|
||||
const Tag = require('lib/models/Tag.js');
|
||||
const { basename, filename } = require('lib/path-utils.js');
|
||||
const fs = require('fs-extra');
|
||||
const md5 = require('md5');
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const { shim } = require('lib/shim');
|
||||
const { _ } = require('lib/locale');
|
||||
const { fileExtension } = require('lib/path-utils');
|
||||
const { uuid } = require('lib/uuid.js');
|
||||
const { importEnex } = require('lib/import-enex');
|
||||
|
||||
class InteropService_Importer_Jex extends InteropService_Importer_Base {
|
||||
|
||||
async exec(result) {
|
||||
const tempDir = await this.temporaryDirectory_(true);
|
||||
|
||||
try {
|
||||
await require('tar').extract({
|
||||
strict: true,
|
||||
portable: true,
|
||||
file: this.sourcePath_,
|
||||
cwd: tempDir,
|
||||
});
|
||||
} catch (error) {
|
||||
let msg = ['Cannot untar file ' + this.sourcePath_, error.message];
|
||||
if (error.data) msg.push(JSON.stringify(error.data));
|
||||
let e = new Error(msg.join(': '));
|
||||
throw e;
|
||||
}
|
||||
|
||||
const importer = new InteropService_Importer_Raw();
|
||||
await importer.init(tempDir, this.options_);
|
||||
result = await importer.exec(result);
|
||||
|
||||
await fs.remove(tempDir);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = InteropService_Importer_Jex;
|
70
ReactNativeClient/lib/services/InteropService_Importer_Md.js
Normal file
70
ReactNativeClient/lib/services/InteropService_Importer_Md.js
Normal file
@@ -0,0 +1,70 @@
|
||||
const InteropService_Importer_Base = require('lib/services/InteropService_Importer_Base');
|
||||
const BaseItem = require('lib/models/BaseItem.js');
|
||||
const BaseModel = require('lib/BaseModel.js');
|
||||
const Resource = require('lib/models/Resource.js');
|
||||
const Folder = require('lib/models/Folder.js');
|
||||
const NoteTag = require('lib/models/NoteTag.js');
|
||||
const Note = require('lib/models/Note.js');
|
||||
const Tag = require('lib/models/Tag.js');
|
||||
const { basename, filename, rtrimSlashes } = require('lib/path-utils.js');
|
||||
const fs = require('fs-extra');
|
||||
const md5 = require('md5');
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const { shim } = require('lib/shim');
|
||||
const { _ } = require('lib/locale');
|
||||
const { fileExtension } = require('lib/path-utils');
|
||||
const { uuid } = require('lib/uuid.js');
|
||||
const { importEnex } = require('lib/import-enex');
|
||||
|
||||
class InteropService_Importer_Md extends InteropService_Importer_Base {
|
||||
|
||||
async exec(result) {
|
||||
let parentFolderId = null;
|
||||
|
||||
const filePaths = [];
|
||||
if (await shim.fsDriver().isDirectory(this.sourcePath_)) {
|
||||
const stats = await shim.fsDriver().readDirStats(this.sourcePath_);
|
||||
for (let i = 0; i < stats.length; i++) {
|
||||
const stat = stats[i];
|
||||
if (fileExtension(stat.path).toLowerCase() === 'md') {
|
||||
filePaths.push(this.sourcePath_ + '/' + stat.path);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.options_.destinationFolder) {
|
||||
const folderTitle = await Folder.findUniqueFolderTitle(basename(rtrimSlashes(this.sourcePath_)));
|
||||
const folder = await Folder.save({ title: folderTitle });
|
||||
parentFolderId = folder.id;
|
||||
} else {
|
||||
parentFolderId = this.options_.destinationFolder.id;
|
||||
}
|
||||
} else {
|
||||
if (!this.options_.destinationFolder) throw new Error(_('Please specify the notebook where the notes should be imported to.'));
|
||||
parentFolderId = this.options_.destinationFolder.id
|
||||
filePaths.push(this.sourcePath_);
|
||||
}
|
||||
|
||||
for (let i = 0; i < filePaths.length; i++) {
|
||||
const path = filePaths[i];
|
||||
const stat = await shim.fsDriver().stat(path);
|
||||
if (!stat) throw new Error('Cannot read ' + path);
|
||||
const title = filename(path);
|
||||
const body = await shim.fsDriver().readFile(path);
|
||||
const note = {
|
||||
parent_id: parentFolderId,
|
||||
title: title,
|
||||
body: body,
|
||||
updated_time: stat.mtime.getTime(),
|
||||
created_time: stat.birthtime.getTime(),
|
||||
user_updated_time: stat.mtime.getTime(),
|
||||
user_created_time: stat.birthtime.getTime(),
|
||||
};
|
||||
await Note.save(note, { autoTimestamp: false });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = InteropService_Importer_Md;
|
136
ReactNativeClient/lib/services/InteropService_Importer_Raw.js
Normal file
136
ReactNativeClient/lib/services/InteropService_Importer_Raw.js
Normal file
@@ -0,0 +1,136 @@
|
||||
const InteropService_Importer_Base = require('lib/services/InteropService_Importer_Base');
|
||||
const BaseItem = require('lib/models/BaseItem.js');
|
||||
const BaseModel = require('lib/BaseModel.js');
|
||||
const Resource = require('lib/models/Resource.js');
|
||||
const Folder = require('lib/models/Folder.js');
|
||||
const NoteTag = require('lib/models/NoteTag.js');
|
||||
const Note = require('lib/models/Note.js');
|
||||
const Tag = require('lib/models/Tag.js');
|
||||
const { basename, filename } = require('lib/path-utils.js');
|
||||
const fs = require('fs-extra');
|
||||
const md5 = require('md5');
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const { shim } = require('lib/shim');
|
||||
const { _ } = require('lib/locale');
|
||||
const { fileExtension } = require('lib/path-utils');
|
||||
const { uuid } = require('lib/uuid.js');
|
||||
const { importEnex } = require('lib/import-enex');
|
||||
|
||||
class InteropService_Importer_Raw extends InteropService_Importer_Base {
|
||||
|
||||
async exec(result) {
|
||||
const noteIdMap = {};
|
||||
const folderIdMap = {};
|
||||
const resourceIdMap = {};
|
||||
const tagIdMap = {};
|
||||
const createdResources = {};
|
||||
const noteTagsToCreate = [];
|
||||
const destinationFolderId = this.options_.destinationFolderId;
|
||||
|
||||
const replaceResourceNoteIds = (noteBody) => {
|
||||
let output = noteBody;
|
||||
const resourceIds = Note.linkedResourceIds(noteBody);
|
||||
|
||||
for (let i = 0; i < resourceIds.length; i++) {
|
||||
const id = resourceIds[i];
|
||||
if (!resourceIdMap[id]) resourceIdMap[id] = uuid.create();
|
||||
output = output.replace(new RegExp(id, 'gi'), resourceIdMap[id]);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
const stats = await shim.fsDriver().readDirStats(this.sourcePath_);
|
||||
for (let i = 0; i < stats.length; i++) {
|
||||
const stat = stats[i];
|
||||
if (stat.isDirectory()) continue;
|
||||
if (fileExtension(stat.path).toLowerCase() !== 'md') continue;
|
||||
|
||||
const content = await shim.fsDriver().readFile(this.sourcePath_ + '/' + stat.path);
|
||||
let item = await BaseItem.unserialize(content);
|
||||
const itemType = item.type_;
|
||||
const ItemClass = BaseItem.itemClass(item);
|
||||
|
||||
delete item.type_;
|
||||
|
||||
if (itemType === BaseModel.TYPE_NOTE) {
|
||||
if (!folderIdMap[item.parent_id]) folderIdMap[item.parent_id] = destinationFolderId ? destinationFolderId : uuid.create();
|
||||
const noteId = uuid.create();
|
||||
noteIdMap[item.id] = noteId;
|
||||
item.id = noteId;
|
||||
item.parent_id = folderIdMap[item.parent_id];
|
||||
item.body = replaceResourceNoteIds(item.body);
|
||||
} else if (itemType === BaseModel.TYPE_FOLDER) {
|
||||
if (destinationFolderId) continue;
|
||||
|
||||
if (!folderIdMap[item.id]) folderIdMap[item.id] = uuid.create();
|
||||
item.id = folderIdMap[item.id];
|
||||
item.title = await Folder.findUniqueFolderTitle(item.title);
|
||||
} else if (itemType === BaseModel.TYPE_RESOURCE) {
|
||||
if (!resourceIdMap[item.id]) resourceIdMap[item.id] = uuid.create();
|
||||
item.id = resourceIdMap[item.id];
|
||||
createdResources[item.id] = item;
|
||||
} else if (itemType === BaseModel.TYPE_TAG) {
|
||||
const tag = await Tag.loadByTitle(item.title);
|
||||
if (tag) {
|
||||
tagIdMap[item.id] = tag.id;
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagId = uuid.create();
|
||||
tagIdMap[item.id] = tagId;
|
||||
item.id = tagId;
|
||||
} else if (itemType === BaseModel.TYPE_NOTE_TAG) {
|
||||
noteTagsToCreate.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
await ItemClass.save(item, { isNew: true, autoTimestamp: false });
|
||||
}
|
||||
|
||||
for (let i = 0; i < noteTagsToCreate.length; i++) {
|
||||
const noteTag = noteTagsToCreate[i];
|
||||
const newNoteId = noteIdMap[noteTag.note_id];
|
||||
const newTagId = tagIdMap[noteTag.tag_id];
|
||||
|
||||
if (!newNoteId) {
|
||||
result.warnings.push(sprintf('Non-existent note %s referenced in tag %s', noteTag.note_id, noteTag.tag_id));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!newTagId) {
|
||||
result.warnings.push(sprintf('Non-existent tag %s for note %s', noteTag.tag_id, noteTag.note_id));
|
||||
continue;
|
||||
}
|
||||
|
||||
noteTag.id = uuid.create();
|
||||
noteTag.note_id = newNoteId;
|
||||
noteTag.tag_id = newTagId;
|
||||
|
||||
await NoteTag.save(noteTag, { isNew: true });
|
||||
}
|
||||
|
||||
if (await shim.fsDriver().isDirectory(this.sourcePath_ + '/resources')) {
|
||||
const resourceStats = await shim.fsDriver().readDirStats(this.sourcePath_ + '/resources');
|
||||
|
||||
for (let i = 0; i < resourceStats.length; i++) {
|
||||
const resourceFilePath = this.sourcePath_ + '/resources/' + resourceStats[i].path;
|
||||
const oldId = Resource.pathToId(resourceFilePath);
|
||||
const newId = resourceIdMap[oldId];
|
||||
if (!newId) {
|
||||
result.warnings.push(sprintf('Resource file is not referenced in any note and so was not imported: %s', oldId));
|
||||
continue;
|
||||
}
|
||||
|
||||
const resource = createdResources[newId];
|
||||
const destPath = Resource.fullPath(resource);
|
||||
await shim.fsDriver().copy(resourceFilePath, destPath);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = InteropService_Importer_Raw;
|
35
ReactNativeClient/lib/services/NavService.js
Normal file
35
ReactNativeClient/lib/services/NavService.js
Normal file
@@ -0,0 +1,35 @@
|
||||
class NavService {
|
||||
|
||||
static async go(routeName) {
|
||||
if (this.handlers_.length) {
|
||||
let r = await this.handlers_[this.handlers_.length - 1]();
|
||||
if (r) return r;
|
||||
}
|
||||
|
||||
this.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: routeName,
|
||||
});
|
||||
}
|
||||
|
||||
static addHandler(handler) {
|
||||
for (let i = this.handlers_.length - 1; i >= 0; i--) {
|
||||
const h = this.handlers_[i];
|
||||
if (h === handler) return;
|
||||
}
|
||||
|
||||
return this.handlers_.push(handler);
|
||||
}
|
||||
|
||||
static removeHandler(hanlder) {
|
||||
for (let i = this.handlers_.length - 1; i >= 0; i--) {
|
||||
const h = this.handlers_[i];
|
||||
if (h === hanlder) this.handlers_.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
NavService.handlers_ = [];
|
||||
|
||||
module.exports = NavService;
|
@@ -1,96 +0,0 @@
|
||||
const BaseItem = require('lib/models/BaseItem.js');
|
||||
const BaseModel = require('lib/BaseModel.js');
|
||||
const Resource = require('lib/models/Resource.js');
|
||||
const Folder = require('lib/models/Folder.js');
|
||||
const NoteTag = require('lib/models/NoteTag.js');
|
||||
const Note = require('lib/models/Note.js');
|
||||
const Tag = require('lib/models/Tag.js');
|
||||
const { basename } = require('lib/path-utils.js');
|
||||
const fs = require('fs-extra');
|
||||
|
||||
class Exporter {
|
||||
|
||||
async export(options) {
|
||||
const destDir = options.destDir ? options.destDir : null;
|
||||
const resourceDir = destDir ? destDir + '/resources' : null;
|
||||
const writeFile = options.writeFile ? options.writeFile : null;
|
||||
const copyFile = options.copyFile ? options.copyFile : null;
|
||||
const sourceFolderIds = options.sourceFolderIds ? options.sourceFolderIds : [];
|
||||
const sourceNoteIds = options.sourceNoteIds ? options.sourceNoteIds : [];
|
||||
|
||||
let result = {
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
await fs.mkdirp(destDir);
|
||||
await fs.mkdirp(resourceDir);
|
||||
|
||||
const exportItem = async (itemType, itemOrId) => {
|
||||
const ItemClass = BaseItem.getClassByItemType(itemType);
|
||||
const item = typeof itemOrId === 'object' ? itemOrId : await ItemClass.load(itemOrId);
|
||||
|
||||
if (!item) {
|
||||
result.warnings.push('Cannot find item with type ' + itemType + ' and ID ' + JSON.stringify(itemOrId));
|
||||
return;
|
||||
}
|
||||
|
||||
const serialized = await ItemClass.serialize(item);
|
||||
const filePath = destDir + '/' + ItemClass.systemPath(item);
|
||||
await writeFile(filePath, serialized);
|
||||
|
||||
if (itemType == BaseModel.TYPE_RESOURCE) {
|
||||
const sourceResourcePath = Resource.fullPath(item);
|
||||
const destResourcePath = resourceDir + '/' + basename(sourceResourcePath);
|
||||
await copyFile(sourceResourcePath, destResourcePath);
|
||||
}
|
||||
}
|
||||
|
||||
let exportedNoteIds = [];
|
||||
let resourceIds = [];
|
||||
const folderIds = await Folder.allIds();
|
||||
|
||||
for (let folderIndex = 0; folderIndex < folderIds.length; folderIndex++) {
|
||||
const folderId = folderIds[folderIndex];
|
||||
if (sourceFolderIds.length && sourceFolderIds.indexOf(folderId) < 0) continue;
|
||||
|
||||
if (!sourceNoteIds.length) await exportItem(BaseModel.TYPE_FOLDER, folderId);
|
||||
|
||||
const noteIds = await Folder.noteIds(folderId);
|
||||
|
||||
for (let noteIndex = 0; noteIndex < noteIds.length; noteIndex++) {
|
||||
const noteId = noteIds[noteIndex];
|
||||
if (sourceNoteIds.length && sourceNoteIds.indexOf(noteId) < 0) continue;
|
||||
const note = await Note.load(noteId);
|
||||
await exportItem(BaseModel.TYPE_NOTE, note);
|
||||
exportedNoteIds.push(noteId);
|
||||
|
||||
const rids = Note.linkedResourceIds(note.body);
|
||||
resourceIds = resourceIds.concat(rids);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < resourceIds.length; i++) {
|
||||
await exportItem(BaseModel.TYPE_RESOURCE, resourceIds[i]);
|
||||
}
|
||||
|
||||
const noteTags = await NoteTag.all();
|
||||
|
||||
let exportedTagIds = [];
|
||||
|
||||
for (let i = 0; i < noteTags.length; i++) {
|
||||
const noteTag = noteTags[i];
|
||||
if (exportedNoteIds.indexOf(noteTag.note_id) < 0) continue;
|
||||
await exportItem(BaseModel.TYPE_NOTE_TAG, noteTag.id);
|
||||
exportedTagIds.push(noteTag.tag_id);
|
||||
}
|
||||
|
||||
for (let i = 0; i < exportedTagIds.length; i++) {
|
||||
await exportItem(BaseModel.TYPE_TAG, exportedTagIds[i]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { Exporter };
|
@@ -80,8 +80,12 @@ function shimInit() {
|
||||
|
||||
await Resource.save(resource, { isNew: true });
|
||||
|
||||
const newBody = [];
|
||||
if (note.body) newBody.push(note.body);
|
||||
newBody.push(Resource.markdownTag(resource));
|
||||
|
||||
const newNote = Object.assign({}, note, {
|
||||
body: note.body + "\n\n" + Resource.markdownTag(resource),
|
||||
body: newBody.join('\n\n'),
|
||||
});
|
||||
return await Note.save(newNote);
|
||||
}
|
||||
|
@@ -201,4 +201,9 @@ function padLeft(string, length, padString) {
|
||||
return string;
|
||||
}
|
||||
|
||||
module.exports = { removeDiacritics, escapeFilename, wrap, splitCommandString, padLeft };
|
||||
function toTitleCase(string) {
|
||||
if (!string) return string;
|
||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||
}
|
||||
|
||||
module.exports = { removeDiacritics, escapeFilename, wrap, splitCommandString, padLeft, toTitleCase };
|
@@ -3,6 +3,7 @@ const { AppState, Keyboard, NativeModules, BackHandler } = require('react-native
|
||||
const { SafeAreaView } = require('react-navigation');
|
||||
const { connect, Provider } = require('react-redux');
|
||||
const { BackButtonService } = require('lib/services/back-button.js');
|
||||
const NavService = require('lib/services/NavService.js');
|
||||
const AlarmService = require('lib/services/AlarmService.js');
|
||||
const AlarmServiceDriver = require('lib/services/AlarmServiceDriver');
|
||||
const Alarm = require('lib/models/Alarm');
|
||||
@@ -338,6 +339,7 @@ async function initialize(dispatch) {
|
||||
BaseModel.dispatch = dispatch;
|
||||
FoldersScreenUtils.dispatch = dispatch;
|
||||
BaseSyncTarget.dispatch = dispatch;
|
||||
NavService.dispatch = dispatch;
|
||||
BaseModel.db_ = db;
|
||||
|
||||
BaseItem.loadClass('Note', Note);
|
||||
|
11
Tools/package-lock.json
generated
11
Tools/package-lock.json
generated
@@ -18,9 +18,9 @@
|
||||
}
|
||||
},
|
||||
"fs-extra": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-4.0.2.tgz",
|
||||
"integrity": "sha1-+RcExT0bRh+JNFKwwwfZmXZHq2s=",
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-4.0.3.tgz",
|
||||
"integrity": "sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg==",
|
||||
"requires": {
|
||||
"graceful-fs": "4.1.11",
|
||||
"jsonfile": "4.0.0",
|
||||
@@ -88,6 +88,11 @@
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz",
|
||||
"integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg=="
|
||||
},
|
||||
"string-padding": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/string-padding/-/string-padding-1.0.2.tgz",
|
||||
"integrity": "sha1-OqrYVbPpc1xeQS3+chmMz5nH9I4="
|
||||
},
|
||||
"universalify": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.1.tgz",
|
||||
|
@@ -10,11 +10,12 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"app-module-path": "^2.2.0",
|
||||
"fs-extra": "^4.0.2",
|
||||
"fs-extra": "^4.0.3",
|
||||
"gettext-parser": "^1.3.0",
|
||||
"marked": "^0.3.7",
|
||||
"mustache": "^2.3.0",
|
||||
"node-fetch": "^1.7.3",
|
||||
"string-padding": "^1.0.2",
|
||||
"uri-template": "^1.0.1"
|
||||
}
|
||||
}
|
||||
|
@@ -204,7 +204,7 @@
|
||||
|
||||
<div class="content">
|
||||
<p>Joplin is a free, open source note taking and to-do application, which can handle a large number of notes organised into notebooks. The notes are searchable, can be copied, tagged and modified either from the applications directly or from your own text editor. The notes are in <a href="https://daringfireball.net/projects/markdown/basics">Markdown format</a>.</p>
|
||||
<p>Notes exported from Evernote via .enex files <a href="#importing-notes-from-evernote">can be imported</a> into Joplin, including the formatted content (which is converted to Markdown), resources (images, attachments, etc.) and complete metadata (geolocation, updated time, created time, etc.).</p>
|
||||
<p>Notes exported from Evernote via .enex files <a href="#importing">can be imported</a> into Joplin, including the formatted content (which is converted to Markdown), resources (images, attachments, etc.) and complete metadata (geolocation, updated time, created time, etc.). Plain Markdown files can also be imported.</p>
|
||||
<p>The notes can be <a href="#synchronisation">synchronised</a> with various targets including <a href="https://nextcloud.com/">Nextcloud</a>, the file system (for example with a network directory) or with Microsoft OneDrive. When synchronising the notes, notebooks, tags and other metadata are saved to plain text files which can be easily inspected, backed up and moved around.</p>
|
||||
<p>The UI of the terminal client is built on top of the great <a href="https://github.com/cronvel/terminal-kit">terminal-kit</a> library, the desktop client using <a href="https://electronjs.org/">Electron</a>, and the Android client front end is done using <a href="https://facebook.github.io/react-native/">React Native</a>.</p>
|
||||
<div class="top-screenshot"><img src="https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/AllClients.jpg" style="max-width: 100%; max-height: 35em;"></div>
|
||||
@@ -247,7 +247,7 @@
|
||||
<tr>
|
||||
<td>Android</td>
|
||||
<td><a href='https://play.google.com/store/apps/details?id=net.cozic.joplin&utm_source=GitHub&utm_campaign=README&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' height="40px" src='https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/BadgeAndroid.png'/></a></td>
|
||||
<td>or <a href="https://github.com/laurent22/joplin-android/releases/download/android-v1.0.101/joplin-v1.0.101.apk">Download APK File</a></td>
|
||||
<td>or <a href="https://github.com/laurent22/joplin-android/releases/download/android-v1.0.102/joplin-v1.0.102.apk">Download APK File</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>iOS</td>
|
||||
@@ -269,20 +269,22 @@ sudo ln -s ~/.joplin-bin/bin/joplin /usr/bin/joplin
|
||||
<h1 id="features">Features</h1>
|
||||
<ul>
|
||||
<li>Desktop, mobile and terminal applications.</li>
|
||||
<li>Import Enex files (Evernote export format)</li>
|
||||
<li>End To End Encryption (E2EE)</li>
|
||||
<li>Synchronisation with various services, including NextCloud, WebDAV and OneDrive. Dropbox is planned.</li>
|
||||
<li>Import Enex files (Evernote export format) and Markdown files.</li>
|
||||
<li>Export JEX files (Joplin Export format) and raw files.</li>
|
||||
<li>Support notes, to-dos, tags and notebooks.</li>
|
||||
<li>Sort notes by multiple criteria - title, updated time, etc.</li>
|
||||
<li>Support for alarms (notifications) in mobile and desktop applications.</li>
|
||||
<li>Offline first, so the entire data is always available on the device even without an internet connection.</li>
|
||||
<li>Synchronisation with various services, including NextCloud, WebDAV and OneDrive. Dropbox is planned.</li>
|
||||
<li>End To End Encryption (E2EE)</li>
|
||||
<li>Synchronises to a plain text format, which can be easily manipulated, backed up, or exported to a different format.</li>
|
||||
<li>Markdown notes, which are rendered with images and formatting in the desktop and mobile applications. Support for extra features such as math notation and checkboxes.</li>
|
||||
<li>File attachment support - images are displayed, and other files are linked and can be opened in the relevant application.</li>
|
||||
<li>Search functionality.</li>
|
||||
<li>Geo-location support.</li>
|
||||
<li>Supports multiple languages</li>
|
||||
</ul>
|
||||
<h1 id="importing-notes-from-evernote">Importing notes from Evernote</h1>
|
||||
<h1 id="importing">Importing</h1>
|
||||
<h2 id="importing-from-evernote">Importing from Evernote</h2>
|
||||
<p>Joplin was designed as a replacement for Evernote and so can import complete Evernote notebooks, as well as notes, tags, resources (attached files) and note metadata (such as author, geo-location, etc.) via ENEX files. In terms of data, the only two things that might slightly differ are:</p>
|
||||
<ul>
|
||||
<li><p>Recognition data - Evernote images, in particular scanned (or photographed) documents have <a href="https://en.wikipedia.org/wiki/Optical_character_recognition">recognition data</a> associated with them. It is the text that Evernote has been able to recognise in the document. This data is not preserved when the note are imported into Joplin. However, should it become supported in the search tool or other parts of Joplin, it should be possible to regenerate this recognition data since the actual image would still be available.</p>
|
||||
@@ -291,14 +293,20 @@ sudo ln -s ~/.joplin-bin/bin/joplin /usr/bin/joplin
|
||||
</li>
|
||||
</ul>
|
||||
<p>To import Evernote data, first export your Evernote notebooks to ENEX files as described <a href="https://help.evernote.com/hc/en-us/articles/209005557-How-to-back-up-export-and-restore-import-notes-and-notebooks">here</a>. Then follow these steps:</p>
|
||||
<p>On the <strong>desktop application</strong>, open the "File" menu, click "Import Evernote notes" and select your ENEX file. This will open a new screen which will display the import progress. The notes will be imported into a new separate notebook (so that, in case of a mistake, the notes are not mixed up with any existing notes). If needed then can then be moved to a different notebook, or the notebook can be renamed, etc.</p>
|
||||
<p>On the <strong>terminal application</strong>, in <a href="/terminal#command-line-mode">command-line mode</a>, type <code>import-enex /path/to/file.enex</code>. This will import the notes into a new notebook named after the filename.</p>
|
||||
<h1 id="importing-notes-from-other-applications">Importing notes from other applications</h1>
|
||||
<p>On the <strong>desktop application</strong>, open File > Import > ENEX and select your file. The notes will be imported into a new separate notebook. If needed they can then be moved to a different notebook, or the notebook can be renamed, etc.</p>
|
||||
<p>On the <strong>terminal application</strong>, in <a href="/terminal#command-line-mode">command-line mode</a>, type <code>import /path/to/file.enex</code>. This will import the notes into a new notebook named after the filename.</p>
|
||||
<h2 id="importing-from-markdown-files">Importing from Markdown files</h2>
|
||||
<p>Joplin can import notes from plain Markdown file. You can either import a complete directory of Markdown files or individual files.</p>
|
||||
<p>On the <strong>desktop application</strong>, open File > Import > MD and select your Markdown file or directory.</p>
|
||||
<p>On the <strong>terminal application</strong>, in <a href="/terminal#command-line-mode">command-line mode</a>, type <code>import --format md /path/to/file.md</code> or <code>import --format md /path/to/directory/</code>.</p>
|
||||
<h2 id="importing-from-other-applications">Importing from other applications</h2>
|
||||
<p>In general the way to import notes from any application into Joplin is to convert the notes to ENEX files (Evernote format) and to import these ENEX files into Joplin using the method above. Most note-taking applications support ENEX files so it should be relatively straightforward. For help about specific applications, see below:</p>
|
||||
<ul>
|
||||
<li>Standard Notes: Please see <a href="https://programadorwebvalencia.com/migrate-notes-from-standard-notes-to-joplin/">this tutorial</a></li>
|
||||
<li>Tomboy Notes: Export the notes to ENEX files <a href="https://askubuntu.com/questions/243691/how-can-i-export-my-tomboy-notes-into-evernote/608551">as described here</a> for example, and import these ENEX files into Joplin.</li>
|
||||
</ul>
|
||||
<h1 id="exporting">Exporting</h1>
|
||||
<p>Joplin can export to the JEX format (Joplin Export file), which is a tar file that can contain multiple notes, notebooks, etc. This is a lossless format in that all the notes, but also metadata such as geo-location, updated time, tags, etc. are preserved. This format is convenient for backup purposes and can be re-imported into Joplin. A "raw" format is also available. This is the same as the JEX format except that the data is saved to a directory and each item represented by a single file.</p>
|
||||
<h1 id="synchronisation">Synchronisation</h1>
|
||||
<p>One of the goals of Joplin was to avoid being tied to any particular company or service, whether it is Evernote, Google or Microsoft. As such the synchronisation is designed without any hard dependency to any particular service. Most of the synchronisation process is done at an abstract level and access to external services, such as Nextcloud or OneDrive, is done via lightweight drivers. It is easy to support new services by creating simple drivers that provide a filesystem-like interface, i.e. the ability to read, write, delete and list items. It is also simple to switch from one service to another or to even sync to multiple services at once. Each note, notebook, tags, as well as the relation between items is transmitted as plain text files during synchronisation, which means the data can also be moved to a different application, can be easily backed up, inspected, etc.</p>
|
||||
<p>Currently, synchronisation is possible with Nextcloud and OneDrive (by default) or the local filesystem. A Dropbox one will also be available once <a href="https://github.com/facebook/react-native/issues/14445">this React Native bug</a> is fixed. To setup synchronisation please follow the instructions below. After that, the application will synchronise in the background whenever it is running, or you can click on "Synchronise" to start a synchronisation manually.</p>
|
||||
@@ -312,12 +320,14 @@ sudo ln -s ~/.joplin-bin/bin/joplin /usr/bin/joplin
|
||||
</code></pre><p>If synchronisation does not work, please consult the logs in the app profile directory - it is often due to a misconfigured URL or password. The log should indicate what the exact issue is.</p>
|
||||
<h2 id="webdav-synchronisation">WebDAV synchronisation</h2>
|
||||
<p>Select the "WebDAV" synchronisation target and follow the same instructions as for Nextcloud above.</p>
|
||||
<p>Known compatible services that use WebDAV:</p>
|
||||
<p>WebDAV-compatible services that are known to work with Joplin:</p>
|
||||
<ul>
|
||||
<li><a href="https://www.box.com/">Box.com</a></li>
|
||||
<li><a href="https://www.drivehq.com">DriveHQ</a></li>
|
||||
<li><a href="https://www.zimbra.com/">Zimbra</a></li>
|
||||
<li><a href="https://owncloud.org/">OwnCloud</a></li>
|
||||
<li><a href="https://www.seafile.com/">Seafile</a></li>
|
||||
<li><a href="https://www.transip.nl/stack/">Stack</a></li>
|
||||
<li><a href="https://www.zimbra.com/">Zimbra</a></li>
|
||||
</ul>
|
||||
<h2 id="onedrive-synchronisation">OneDrive synchronisation</h2>
|
||||
<p>When syncing with OneDrive, Joplin creates a sub-directory in OneDrive, in /Apps/Joplin and read/write the notes and notebooks from it. The application does not have access to anything outside this directory.</p>
|
||||
@@ -390,21 +400,21 @@ $$
|
||||
<td>Basque</td>
|
||||
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/eu.po">eu</a></td>
|
||||
<td>juan.abasolo@ehu.eus</td>
|
||||
<td>87%</td>
|
||||
<td>82%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://raw.githubusercontent.com/stevenrskelton/flag-icon/master/png/16/country-4x3/hr.png" alt=""></td>
|
||||
<td>Croatian</td>
|
||||
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/hr_HR.po">hr_HR</a></td>
|
||||
<td>Hrvoje Mandić <a href="mailto:trbuhom@net.hr">trbuhom@net.hr</a></td>
|
||||
<td>71%</td>
|
||||
<td>Hrvoje Mandić <a href="mailto:trbuhom@net.hr">trbuhom@net.hr</a></td>
|
||||
<td>66%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://raw.githubusercontent.com/stevenrskelton/flag-icon/master/png/16/country-4x3/de.png" alt=""></td>
|
||||
<td>Deutsch</td>
|
||||
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/de_DE.po">de_DE</a></td>
|
||||
<td>Tobias Strobel <a href="mailto:git@strobeltobias.de">git@strobeltobias.de</a></td>
|
||||
<td>89%</td>
|
||||
<td>Tobias Strobel <a href="mailto:git@strobeltobias.de">git@strobeltobias.de</a></td>
|
||||
<td>84%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://raw.githubusercontent.com/stevenrskelton/flag-icon/master/png/16/country-4x3/gb.png" alt=""></td>
|
||||
@@ -417,57 +427,57 @@ $$
|
||||
<td><img src="https://raw.githubusercontent.com/stevenrskelton/flag-icon/master/png/16/country-4x3/es.png" alt=""></td>
|
||||
<td>Español</td>
|
||||
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/es_ES.po">es_ES</a></td>
|
||||
<td>Fernando Martín <a href="mailto:f@mrtn.es">f@mrtn.es</a></td>
|
||||
<td>100%</td>
|
||||
<td>Fernando Martín <a href="mailto:f@mrtn.es">f@mrtn.es</a></td>
|
||||
<td>94%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://raw.githubusercontent.com/stevenrskelton/flag-icon/master/png/16/country-4x3/fr.png" alt=""></td>
|
||||
<td>Français</td>
|
||||
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/fr_FR.po">fr_FR</a></td>
|
||||
<td>Laurent Cozic</td>
|
||||
<td>100%</td>
|
||||
<td>94%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://raw.githubusercontent.com/stevenrskelton/flag-icon/master/png/16/country-4x3/it.png" alt=""></td>
|
||||
<td>Italiano</td>
|
||||
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/it_IT.po">it_IT</a></td>
|
||||
<td></td>
|
||||
<td>73%</td>
|
||||
<td>68%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://raw.githubusercontent.com/stevenrskelton/flag-icon/master/png/16/country-4x3/be.png" alt=""></td>
|
||||
<td>Nederlands</td>
|
||||
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/nl_BE.po">nl_BE</a></td>
|
||||
<td></td>
|
||||
<td>87%</td>
|
||||
<td>82%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://raw.githubusercontent.com/stevenrskelton/flag-icon/master/png/16/country-4x3/br.png" alt=""></td>
|
||||
<td>Português (Brasil)</td>
|
||||
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/pt_BR.po">pt_BR</a></td>
|
||||
<td></td>
|
||||
<td>71%</td>
|
||||
<td>67%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://raw.githubusercontent.com/stevenrskelton/flag-icon/master/png/16/country-4x3/ru.png" alt=""></td>
|
||||
<td>Русский</td>
|
||||
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/ru_RU.po">ru_RU</a></td>
|
||||
<td>Artyom Karlov <a href="mailto:artyom.karlov@gmail.com">artyom.karlov@gmail.com</a></td>
|
||||
<td>91%</td>
|
||||
<td>Artyom Karlov <a href="mailto:artyom.karlov@gmail.com">artyom.karlov@gmail.com</a></td>
|
||||
<td>86%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://raw.githubusercontent.com/stevenrskelton/flag-icon/master/png/16/country-4x3/cn.png" alt=""></td>
|
||||
<td>中文 (简体)</td>
|
||||
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/zh_CN.po">zh_CN</a></td>
|
||||
<td>RCJacH <a href="mailto:RCJacH@outlook.com">RCJacH@outlook.com</a></td>
|
||||
<td>73%</td>
|
||||
<td>RCJacH <a href="mailto:RCJacH@outlook.com">RCJacH@outlook.com</a></td>
|
||||
<td>68%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://raw.githubusercontent.com/stevenrskelton/flag-icon/master/png/16/country-4x3/jp.png" alt=""></td>
|
||||
<td>日本語</td>
|
||||
<td><a href="https://github.com/laurent22/joplin/blob/master/CliClient/locales/ja_JP.po">ja_JP</a></td>
|
||||
<td></td>
|
||||
<td>71%</td>
|
||||
<td>66%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
@@ -204,7 +204,7 @@
|
||||
|
||||
<div class="content">
|
||||
<p>Joplin is a free, open source note taking and to-do application, which can handle a large number of notes organised into notebooks. The notes are searchable, can be copied, tagged and modified with your own text editor.</p>
|
||||
<p>Notes exported from Evernote via .enex files <a href="#importing-notes-from-evernote">can be imported</a> into Joplin, including the formatted content (which is converted to markdown), resources (images, attachments, etc.) and complete metadata (geolocation, updated time, created time, etc.).</p>
|
||||
<p>Notes exported from Evernote via .enex files <a href="http://joplin.cozic.net/#importing">can be imported</a> into Joplin, including the formatted content (which is converted to Markdown), resources (images, attachments, etc.) and complete metadata (geolocation, updated time, created time, etc.). Plain Markdown files can also be imported.</p>
|
||||
<p>The notes can be <a href="#synchronisation">synchronised</a> with various targets including the file system (for example with a network directory) or with Microsoft OneDrive. When synchronising the notes, notebooks, tags and other metadata are saved to plain text files which can be easily inspected, backed up and moved around.</p>
|
||||
<p><img src="https://raw.githubusercontent.com/laurent22/joplin/master/docs/images/ScreenshotTerminal.png" style="max-width: 60%"></p>
|
||||
<h1 id="installation">Installation</h1>
|
||||
@@ -296,7 +296,7 @@ sudo ln -s ~/.joplin-bin/bin/joplin /usr/bin/joplin
|
||||
<p>To import Evernote data, follow these steps:</p>
|
||||
<ul>
|
||||
<li>First, export your Evernote notebooks to ENEX files as described <a href="https://help.evernote.com/hc/en-us/articles/209005557-How-to-back-up-export-and-restore-import-notes-and-notebooks">here</a>.</li>
|
||||
<li>In Joplin, in <a href="#command-line-mode">command-line mode</a>, type <code>import-enex /path/to/file.enex</code>. This will import the notes into a new notebook named after the filename.</li>
|
||||
<li>In Joplin, in <a href="#command-line-mode">command-line mode</a>, type <code>import /path/to/file.enex</code>. This will import the notes into a new notebook named after the filename.</li>
|
||||
<li>Then repeat the process for each notebook that needs to be imported.</li>
|
||||
</ul>
|
||||
<h1 id="synchronisation">Synchronisation</h1>
|
||||
@@ -482,81 +482,91 @@ config [name] [value]
|
||||
|
||||
Possible keys/values:
|
||||
|
||||
editor Text editor.
|
||||
The editor that will be used to open a note. If
|
||||
none is provided it will try to auto-detect the
|
||||
default editor.
|
||||
Type: string.
|
||||
editor Text editor.
|
||||
The editor that will be used to open a note. If
|
||||
none is provided it will try to auto-detect the
|
||||
default editor.
|
||||
Type: string.
|
||||
|
||||
locale Language.
|
||||
Type: Enum.
|
||||
Possible values: en_GB (English), de_DE (Deutsch),
|
||||
es_CR (Español (Costa Rica)), es_ES (Español), eu
|
||||
(Basque), fr_FR (Français), hr_HR (Croatian), it_IT
|
||||
(Italiano), ja_JP (日本語), nl_BE (Nederlands), pt_BR
|
||||
(Português (Brasil)), ru_RU (Русский), zh_CN (中文
|
||||
(简体)).
|
||||
Default: "en_GB"
|
||||
locale Language.
|
||||
Type: Enum.
|
||||
Possible values: eu (Basque), hr_HR (Croatian),
|
||||
de_DE (Deutsch), en_GB (English), es_ES
|
||||
(Español), fr_FR (Français), it_IT (Italiano),
|
||||
nl_BE (Nederlands), pt_BR (Português (Brasil)),
|
||||
ru_RU (Русский), zh_CN (中文 (简体)), ja_JP (日本語).
|
||||
Default: "en_GB"
|
||||
|
||||
dateFormat Date format.
|
||||
Type: Enum.
|
||||
Possible values: DD/MM/YYYY (30/01/2017), DD/MM/YY
|
||||
(30/01/17), MM/DD/YYYY (01/30/2017), MM/DD/YY
|
||||
(01/30/17), YYYY-MM-DD (2017-01-30).
|
||||
Default: "DD/MM/YYYY"
|
||||
dateFormat Date format.
|
||||
Type: Enum.
|
||||
Possible values: DD/MM/YYYY (30/01/2017),
|
||||
DD/MM/YY (30/01/17), MM/DD/YYYY (01/30/2017),
|
||||
MM/DD/YY (01/30/17), YYYY-MM-DD (2017-01-30).
|
||||
Default: "DD/MM/YYYY"
|
||||
|
||||
timeFormat Time format.
|
||||
Type: Enum.
|
||||
Possible values: HH:mm (20:30), h:mm A (8:30 PM).
|
||||
Default: "HH:mm"
|
||||
timeFormat Time format.
|
||||
Type: Enum.
|
||||
Possible values: HH:mm (20:30), h:mm A (8:30 PM).
|
||||
Default: "HH:mm"
|
||||
|
||||
uncompletedTodosOnTop Show uncompleted to-dos on top of the lists.
|
||||
Type: bool.
|
||||
Default: true
|
||||
uncompletedTodosOnTop Uncompleted to-dos on top.
|
||||
Type: bool.
|
||||
Default: true
|
||||
|
||||
trackLocation Save geo-location with notes.
|
||||
Type: bool.
|
||||
Default: true
|
||||
notes.sortOrder.field Sort notes by.
|
||||
Type: Enum.
|
||||
Possible values: user_updated_time (Updated
|
||||
date), user_created_time (Created date), title
|
||||
(Title).
|
||||
Default: "user_updated_time"
|
||||
|
||||
sync.interval Synchronisation interval.
|
||||
Type: Enum.
|
||||
Possible values: 0 (Disabled), 300 (5 minutes), 600
|
||||
(10 minutes), 1800 (30 minutes), 3600 (1 hour),
|
||||
43200 (12 hours), 86400 (24 hours).
|
||||
Default: 300
|
||||
notes.sortOrder.reverse Reverse sort order.
|
||||
Type: bool.
|
||||
Default: true
|
||||
|
||||
sync.target Synchronisation target.
|
||||
The target to synchonise to. Each sync target may
|
||||
have additional parameters which are named as
|
||||
`sync.NUM.NAME` (all documented below).
|
||||
Type: Enum.
|
||||
Possible values: 2 (File system), 3 (OneDrive), 4
|
||||
(OneDrive Dev (For testing only)), 5 (Nextcloud), 6
|
||||
(WebDAV).
|
||||
Default: 3
|
||||
trackLocation Save geo-location with notes.
|
||||
Type: bool.
|
||||
Default: true
|
||||
|
||||
sync.2.path Directory to synchronise with (absolute path).
|
||||
The path to synchronise with when file system
|
||||
synchronisation is enabled. See `sync.target`.
|
||||
Type: string.
|
||||
sync.interval Synchronisation interval.
|
||||
Type: Enum.
|
||||
Possible values: 0 (Disabled), 300 (5 minutes),
|
||||
600 (10 minutes), 1800 (30 minutes), 3600 (1
|
||||
hour), 43200 (12 hours), 86400 (24 hours).
|
||||
Default: 300
|
||||
|
||||
sync.5.path Nextcloud WebDAV URL.
|
||||
Type: string.
|
||||
sync.target Synchronisation target.
|
||||
The target to synchonise to. Each sync target may
|
||||
have additional parameters which are named as
|
||||
`sync.NUM.NAME` (all documented below).
|
||||
Type: Enum.
|
||||
Possible values: 2 (File system), 3 (OneDrive), 4
|
||||
(OneDrive Dev (For testing only)), 5 (Nextcloud),
|
||||
6 (WebDAV).
|
||||
Default: 3
|
||||
|
||||
sync.5.username Nextcloud username.
|
||||
Type: string.
|
||||
sync.2.path Directory to synchronise with (absolute path).
|
||||
The path to synchronise with when file system
|
||||
synchronisation is enabled. See `sync.target`.
|
||||
Type: string.
|
||||
|
||||
sync.5.password Nextcloud password.
|
||||
Type: string.
|
||||
sync.5.path Nextcloud WebDAV URL.
|
||||
Type: string.
|
||||
|
||||
sync.6.path WebDAV URL.
|
||||
Type: string.
|
||||
sync.5.username Nextcloud username.
|
||||
Type: string.
|
||||
|
||||
sync.6.username WebDAV username.
|
||||
Type: string.
|
||||
sync.5.password Nextcloud password.
|
||||
Type: string.
|
||||
|
||||
sync.6.password WebDAV password.
|
||||
Type: string.
|
||||
sync.6.path WebDAV URL.
|
||||
Type: string.
|
||||
|
||||
sync.6.username WebDAV username.
|
||||
Type: string.
|
||||
|
||||
sync.6.password WebDAV password.
|
||||
Type: string.
|
||||
|
||||
cp <note> [notebook]
|
||||
|
||||
@@ -582,11 +592,13 @@ edit <note>
|
||||
|
||||
Edit note.
|
||||
|
||||
export <directory>
|
||||
export <path>
|
||||
|
||||
Exports Joplin data to the given directory. By default, it will export the
|
||||
Exports Joplin data to the given path. By default, it will export the
|
||||
complete database including notebooks, notes, tags and resources.
|
||||
|
||||
--format <format> Destination format: jex (Joplin Export File), raw
|
||||
(Joplin Export Directory)
|
||||
--note <note> Exports only the given note.
|
||||
--notebook <notebook> Exports only the given notebook.
|
||||
|
||||
@@ -598,11 +610,12 @@ help [command]
|
||||
|
||||
Displays usage information.
|
||||
|
||||
import-enex <file> [notebook]
|
||||
import <path> [notebook]
|
||||
|
||||
Imports an Evernote notebook file (.enex file).
|
||||
Imports data into Joplin.
|
||||
|
||||
-f, --force Do not ask for confirmation.
|
||||
--format <format> Source format: auto, jex, md, raw, enex
|
||||
-f, --force Do not ask for confirmation.
|
||||
|
||||
mkbook <new-notebook>
|
||||
|
||||
|
@@ -21,6 +21,7 @@
|
||||
"docs/*.html",
|
||||
"docs/*.svg",
|
||||
"ReactNativeClient/lib/mime-utils.js",
|
||||
"_mydocs/EnexSamples/*.enex",
|
||||
],
|
||||
"folder_exclude_patterns":
|
||||
[
|
||||
|
Reference in New Issue
Block a user