You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-11-29 22:48:10 +02:00
Merge branch 'master' into alarm-support
This commit is contained in:
9
ReactNativeClient/lib/ArrayUtils.js
Normal file
9
ReactNativeClient/lib/ArrayUtils.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const ArrayUtils = {};
|
||||
|
||||
ArrayUtils.unique = function(array) {
|
||||
return array.filter(function(elem, index, self) {
|
||||
return index === self.indexOf(elem);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = ArrayUtils;
|
||||
386
ReactNativeClient/lib/BaseApplication.js
Normal file
386
ReactNativeClient/lib/BaseApplication.js
Normal file
@@ -0,0 +1,386 @@
|
||||
const { createStore, applyMiddleware } = require('redux');
|
||||
const { reducer, defaultState } = 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');
|
||||
const { DatabaseDriverNode } = require('lib/database-driver-node.js');
|
||||
const { BaseModel } = require('lib/base-model.js');
|
||||
const { Folder } = require('lib/models/folder.js');
|
||||
const { BaseItem } = require('lib/models/base-item.js');
|
||||
const { Note } = require('lib/models/note.js');
|
||||
const { Tag } = require('lib/models/tag.js');
|
||||
const { Setting } = require('lib/models/setting.js');
|
||||
const { Logger } = require('lib/logger.js');
|
||||
const { splitCommandString } = require('lib/string-utils.js');
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const { reg } = require('lib/registry.js');
|
||||
const BaseSyncTarget = require('lib/BaseSyncTarget.js');
|
||||
const { fileExtension } = require('lib/path-utils.js');
|
||||
const { shim } = require('lib/shim.js');
|
||||
const { _, setLocale, defaultLocale, closestSupportedLocale } = require('lib/locale.js');
|
||||
const os = require('os');
|
||||
const fs = require('fs-extra');
|
||||
const EventEmitter = require('events');
|
||||
const SyncTargetRegistry = require('lib/SyncTargetRegistry.js');
|
||||
const SyncTargetFilesystem = require('lib/SyncTargetFilesystem.js');
|
||||
const SyncTargetOneDrive = require('lib/SyncTargetOneDrive.js');
|
||||
const SyncTargetOneDriveDev = require('lib/SyncTargetOneDriveDev.js');
|
||||
|
||||
SyncTargetRegistry.addClass(SyncTargetFilesystem);
|
||||
SyncTargetRegistry.addClass(SyncTargetOneDrive);
|
||||
SyncTargetRegistry.addClass(SyncTargetOneDriveDev);
|
||||
|
||||
class BaseApplication {
|
||||
|
||||
constructor() {
|
||||
this.logger_ = new Logger();
|
||||
this.dbLogger_ = new Logger();
|
||||
this.eventEmitter_ = new EventEmitter();
|
||||
|
||||
// Note: this is basically a cache of state.selectedFolderId. It should *only*
|
||||
// be derived from the state and not set directly since that would make the
|
||||
// state and UI out of sync.
|
||||
this.currentFolder_ = null;
|
||||
}
|
||||
|
||||
logger() {
|
||||
return this.logger_;
|
||||
}
|
||||
|
||||
store() {
|
||||
return this.store_;
|
||||
}
|
||||
|
||||
currentFolder() {
|
||||
return this.currentFolder_;
|
||||
}
|
||||
|
||||
async refreshCurrentFolder() {
|
||||
let newFolder = null;
|
||||
|
||||
if (this.currentFolder_) newFolder = await Folder.load(this.currentFolder_.id);
|
||||
if (!newFolder) newFolder = await Folder.defaultFolder();
|
||||
|
||||
this.switchCurrentFolder(newFolder);
|
||||
}
|
||||
|
||||
switchCurrentFolder(folder) {
|
||||
this.dispatch({
|
||||
type: 'FOLDER_SELECT',
|
||||
id: folder ? folder.id : '',
|
||||
});
|
||||
}
|
||||
|
||||
// Handles the initial flags passed to main script and
|
||||
// returns the remaining args.
|
||||
async handleStartFlags_(argv, setDefaults = true) {
|
||||
let matched = {};
|
||||
argv = argv.slice(0);
|
||||
argv.splice(0, 2); // First arguments are the node executable, and the node JS file
|
||||
|
||||
while (argv.length) {
|
||||
let arg = argv[0];
|
||||
let nextArg = argv.length >= 2 ? argv[1] : null;
|
||||
|
||||
if (arg == '--profile') {
|
||||
if (!nextArg) throw new Error(_('Usage: %s', '--profile <dir-path>'));
|
||||
matched.profileDir = nextArg;
|
||||
argv.splice(0, 2);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg == '--env') {
|
||||
if (!nextArg) throw new Error(_('Usage: %s', '--env <dev|prod>'));
|
||||
matched.env = nextArg;
|
||||
argv.splice(0, 2);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg == '--is-demo') {
|
||||
Setting.setConstant('isDemo', true);
|
||||
argv.splice(0, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg == '--open-dev-tools') {
|
||||
Setting.setConstant('openDevTools', true);
|
||||
argv.splice(0, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg == '--update-geolocation-disabled') {
|
||||
Note.updateGeolocationEnabled_ = false;
|
||||
argv.splice(0, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg == '--stack-trace-enabled') {
|
||||
this.showStackTraces_ = true;
|
||||
argv.splice(0, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg == '--log-level') {
|
||||
if (!nextArg) throw new Error(_('Usage: %s', '--log-level <none|error|warn|info|debug>'));
|
||||
matched.logLevel = Logger.levelStringToId(nextArg);
|
||||
argv.splice(0, 2);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.length && arg[0] == '-') {
|
||||
throw new Error(_('Unknown flag: %s', arg));
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (setDefaults) {
|
||||
if (!matched.logLevel) matched.logLevel = Logger.LEVEL_INFO;
|
||||
if (!matched.env) matched.env = 'prod';
|
||||
}
|
||||
|
||||
return {
|
||||
matched: matched,
|
||||
argv: argv,
|
||||
};
|
||||
}
|
||||
|
||||
on(eventName, callback) {
|
||||
return this.eventEmitter_.on(eventName, callback);
|
||||
}
|
||||
|
||||
async exit(code = 0) {
|
||||
await Setting.saveAll();
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
async refreshNotes(state) {
|
||||
let parentType = state.notesParentType;
|
||||
let parentId = null;
|
||||
|
||||
if (parentType === 'Folder') {
|
||||
parentId = state.selectedFolderId;
|
||||
parentType = BaseModel.TYPE_FOLDER;
|
||||
} else if (parentType === 'Tag') {
|
||||
parentId = state.selectedTagId;
|
||||
parentType = BaseModel.TYPE_TAG;
|
||||
} else if (parentType === 'Search') {
|
||||
parentId = state.selectedSearchId;
|
||||
parentType = BaseModel.TYPE_SEARCH;
|
||||
}
|
||||
|
||||
this.logger().debug('Refreshing notes:', parentType, parentId);
|
||||
|
||||
let options = {
|
||||
order: state.notesOrder,
|
||||
uncompletedTodosOnTop: Setting.value('uncompletedTodosOnTop'),
|
||||
};
|
||||
|
||||
const source = JSON.stringify({
|
||||
options: options,
|
||||
parentId: parentId,
|
||||
});
|
||||
|
||||
let notes = [];
|
||||
|
||||
if (parentId) {
|
||||
if (parentType === Folder.modelType()) {
|
||||
notes = await Note.previews(parentId, options);
|
||||
} else if (parentType === Tag.modelType()) {
|
||||
notes = await Tag.notes(parentId);
|
||||
} else if (parentType === BaseModel.TYPE_SEARCH) {
|
||||
let fields = Note.previewFields();
|
||||
let search = BaseModel.byId(state.searches, parentId);
|
||||
notes = await Note.previews(null, {
|
||||
fields: fields,
|
||||
anywherePattern: '*' + search.query_pattern + '*',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.store().dispatch({
|
||||
type: 'NOTE_UPDATE_ALL',
|
||||
notes: notes,
|
||||
notesSource: source,
|
||||
});
|
||||
|
||||
this.store().dispatch({
|
||||
type: 'NOTE_SELECT',
|
||||
id: notes.length ? notes[0].id : null,
|
||||
});
|
||||
}
|
||||
|
||||
reducerActionToString(action) {
|
||||
let o = [action.type];
|
||||
if ('id' in action) o.push(action.id);
|
||||
if ('noteId' in action) o.push(action.noteId);
|
||||
if ('folderId' in action) o.push(action.folderId);
|
||||
if ('tagId' in action) o.push(action.tagId);
|
||||
if ('tag' in action) o.push(action.tag.id);
|
||||
if ('folder' in action) o.push(action.folder.id);
|
||||
if ('notesSource' in action) o.push(JSON.stringify(action.notesSource));
|
||||
return o.join(', ');
|
||||
}
|
||||
|
||||
hasGui() {
|
||||
return false;
|
||||
}
|
||||
|
||||
generalMiddlewareFn() {
|
||||
const middleware = store => next => (action) => {
|
||||
return this.generalMiddleware(store, next, action);
|
||||
}
|
||||
|
||||
return middleware;
|
||||
}
|
||||
|
||||
async generalMiddleware(store, next, action) {
|
||||
this.logger().debug('Reducer action', this.reducerActionToString(action));
|
||||
|
||||
const result = next(action);
|
||||
const newState = store.getState();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
if (this.hasGui() && action.type == 'SETTING_UPDATE_ONE' && action.key == 'uncompletedTodosOnTop' || action.type == 'SETTING_UPDATE_ALL') {
|
||||
await this.refreshNotes(newState);
|
||||
}
|
||||
|
||||
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) {
|
||||
await FoldersScreenUtils.refreshFolders();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.hasGui() && action.type == 'SETTING_UPDATE_ONE' && action.key == 'sync.interval' || action.type == 'SETTING_UPDATE_ALL') {
|
||||
reg.setupRecurrentSync();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
dispatch(action) {
|
||||
if (this.store()) return this.store().dispatch(action);
|
||||
}
|
||||
|
||||
reducer(state = defaultState, action) {
|
||||
return reducer(state, action);
|
||||
}
|
||||
|
||||
initRedux() {
|
||||
this.store_ = createStore(this.reducer, applyMiddleware(this.generalMiddlewareFn()));
|
||||
BaseModel.dispatch = this.store().dispatch;
|
||||
FoldersScreenUtils.dispatch = this.store().dispatch;
|
||||
reg.dispatch = this.store().dispatch;
|
||||
BaseSyncTarget.dispatch = this.store().dispatch;
|
||||
}
|
||||
|
||||
async readFlagsFromFile(flagPath) {
|
||||
if (!fs.existsSync(flagPath)) return {};
|
||||
let flagContent = fs.readFileSync(flagPath, 'utf8');
|
||||
if (!flagContent) return {};
|
||||
|
||||
flagContent = flagContent.trim();
|
||||
|
||||
let flags = splitCommandString(flagContent);
|
||||
flags.splice(0, 0, 'cmd');
|
||||
flags.splice(0, 0, 'node');
|
||||
|
||||
flags = await this.handleStartFlags_(flags, false);
|
||||
|
||||
return flags.matched;
|
||||
}
|
||||
|
||||
async start(argv) {
|
||||
let startFlags = await this.handleStartFlags_(argv);
|
||||
|
||||
argv = startFlags.argv;
|
||||
let initArgs = startFlags.matched;
|
||||
if (argv.length) this.showPromptString_ = false;
|
||||
|
||||
if (process.argv[1].indexOf('joplindev') >= 0) {
|
||||
if (!initArgs.profileDir) initArgs.profileDir = '/mnt/d/Temp/TestNotes2';
|
||||
initArgs.logLevel = Logger.LEVEL_DEBUG;
|
||||
initArgs.env = 'dev';
|
||||
}
|
||||
|
||||
let appName = initArgs.env == 'dev' ? 'joplindev' : 'joplin';
|
||||
if (Setting.value('appId').indexOf('-desktop') >= 0) appName += '-desktop';
|
||||
Setting.setConstant('appName', appName);
|
||||
|
||||
const profileDir = initArgs.profileDir ? initArgs.profileDir : os.homedir() + '/.config/' + Setting.value('appName');
|
||||
const resourceDir = profileDir + '/resources';
|
||||
const tempDir = profileDir + '/tmp';
|
||||
|
||||
Setting.setConstant('env', initArgs.env);
|
||||
Setting.setConstant('profileDir', profileDir);
|
||||
Setting.setConstant('resourceDir', resourceDir);
|
||||
Setting.setConstant('tempDir', tempDir);
|
||||
|
||||
await fs.mkdirp(profileDir, 0o755);
|
||||
await fs.mkdirp(resourceDir, 0o755);
|
||||
await fs.mkdirp(tempDir, 0o755);
|
||||
|
||||
const extraFlags = await this.readFlagsFromFile(profileDir + '/flags.txt');
|
||||
initArgs = Object.assign(initArgs, extraFlags);
|
||||
|
||||
this.logger_.addTarget('file', { path: profileDir + '/log.txt' });
|
||||
//this.logger_.addTarget('console');
|
||||
this.logger_.setLevel(initArgs.logLevel);
|
||||
|
||||
reg.setLogger(this.logger_);
|
||||
reg.dispatch = (o) => {};
|
||||
|
||||
this.dbLogger_.addTarget('file', { path: profileDir + '/log-database.txt' });
|
||||
this.dbLogger_.setLevel(initArgs.logLevel);
|
||||
|
||||
if (Setting.value('env') === 'dev') {
|
||||
this.dbLogger_.setLevel(Logger.LEVEL_DEBUG);
|
||||
}
|
||||
|
||||
this.logger_.info('Profile directory: ' + profileDir);
|
||||
|
||||
this.database_ = new JoplinDatabase(new DatabaseDriverNode());
|
||||
//this.database_.setLogExcludedQueryTypes(['SELECT']);
|
||||
this.database_.setLogger(this.dbLogger_);
|
||||
await this.database_.open({ name: profileDir + '/database.sqlite' });
|
||||
|
||||
reg.setDb(this.database_);
|
||||
BaseModel.db_ = this.database_;
|
||||
|
||||
await Setting.load();
|
||||
|
||||
if (Setting.value('firstStart')) {
|
||||
const locale = shim.detectAndSetLocale(Setting);
|
||||
reg.logger().info('First start: detected locale as ' + locale);
|
||||
if (Setting.value('env') === 'dev') Setting.setValue('sync.target', SyncTargetRegistry.nameToId('onedrive_dev'));
|
||||
Setting.setValue('firstStart', 0)
|
||||
} else {
|
||||
setLocale(Setting.value('locale'));
|
||||
}
|
||||
|
||||
let currentFolderId = Setting.value('activeFolderId');
|
||||
let currentFolder = null;
|
||||
if (currentFolderId) currentFolder = await Folder.load(currentFolderId);
|
||||
if (!currentFolder) currentFolder = await Folder.defaultFolder();
|
||||
Setting.setValue('activeFolderId', currentFolder ? currentFolder.id : '');
|
||||
|
||||
return argv;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { BaseApplication };
|
||||
112
ReactNativeClient/lib/BaseSyncTarget.js
Normal file
112
ReactNativeClient/lib/BaseSyncTarget.js
Normal file
@@ -0,0 +1,112 @@
|
||||
const { reg } = require('lib/registry.js');
|
||||
|
||||
class BaseSyncTarget {
|
||||
|
||||
constructor(db, options = null) {
|
||||
this.db_ = db;
|
||||
this.synchronizer_ = null;
|
||||
this.initState_ = null;
|
||||
this.logger_ = null;
|
||||
this.options_ = options;
|
||||
}
|
||||
|
||||
option(name, defaultValue = null) {
|
||||
return this.options_ && (name in this.options_) ? this.options_[name] : defaultValue;
|
||||
}
|
||||
|
||||
logger() {
|
||||
return this.logger_;
|
||||
}
|
||||
|
||||
setLogger(v) {
|
||||
this.logger_ = v;
|
||||
}
|
||||
|
||||
db() {
|
||||
return this.db_;
|
||||
}
|
||||
|
||||
isAuthenticated() {
|
||||
return false;
|
||||
}
|
||||
|
||||
static id() {
|
||||
throw new Error('id() not implemented');
|
||||
}
|
||||
|
||||
// Note: it cannot be called just "name()" because that's a reserved keyword and
|
||||
// it would throw an obscure error in React Native.
|
||||
static targetName() {
|
||||
throw new Error('targetName() not implemented');
|
||||
}
|
||||
|
||||
static label() {
|
||||
throw new Error('label() not implemented');
|
||||
}
|
||||
|
||||
async initSynchronizer() {
|
||||
throw new Error('initSynchronizer() not implemented');
|
||||
}
|
||||
|
||||
async initFileApi() {
|
||||
throw new Error('initFileApi() not implemented');
|
||||
}
|
||||
|
||||
async fileApi() {
|
||||
if (this.fileApi_) return this.fileApi_;
|
||||
this.fileApi_ = await this.initFileApi();
|
||||
return this.fileApi_;
|
||||
}
|
||||
|
||||
// Usually each sync target should create and setup its own file API via initFileApi()
|
||||
// but for testing purposes it might be convenient to provide it here so that multiple
|
||||
// clients can share and sync to the same file api (see test-utils.js)
|
||||
setFileApi(v) {
|
||||
this.fileApi_ = v;
|
||||
}
|
||||
|
||||
async synchronizer() {
|
||||
if (this.synchronizer_) return this.synchronizer_;
|
||||
|
||||
if (this.initState_ == 'started') {
|
||||
// Synchronizer is already being initialized, so wait here till it's done.
|
||||
return new Promise((resolve, reject) => {
|
||||
const iid = setInterval(() => {
|
||||
if (this.initState_ == 'ready') {
|
||||
clearInterval(iid);
|
||||
resolve(this.synchronizer_);
|
||||
}
|
||||
if (this.initState_ == 'error') {
|
||||
clearInterval(iid);
|
||||
reject(new Error('Could not initialise synchroniser'));
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
} else {
|
||||
this.initState_ = 'started';
|
||||
|
||||
try {
|
||||
this.synchronizer_ = await this.initSynchronizer();
|
||||
this.synchronizer_.setLogger(this.logger());
|
||||
this.synchronizer_.dispatch = BaseSyncTarget.dispatch;
|
||||
this.initState_ = 'ready';
|
||||
return this.synchronizer_;
|
||||
} catch (error) {
|
||||
this.initState_ = 'error';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async syncStarted() {
|
||||
if (!this.synchronizer_) return false;
|
||||
if (!this.isAuthenticated()) return false;
|
||||
const sync = await this.synchronizer();
|
||||
return sync.state() != 'idle';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
BaseSyncTarget.dispatch = (action) => {};
|
||||
|
||||
module.exports = BaseSyncTarget;
|
||||
402
ReactNativeClient/lib/MdToHtml.js
Normal file
402
ReactNativeClient/lib/MdToHtml.js
Normal file
@@ -0,0 +1,402 @@
|
||||
const MarkdownIt = require('markdown-it');
|
||||
const Entities = require('html-entities').AllHtmlEntities;
|
||||
const htmlentities = (new Entities()).encode;
|
||||
const { Resource } = require('lib/models/resource.js');
|
||||
const { shim } = require('lib/shim.js');
|
||||
const md5 = require('md5');
|
||||
|
||||
class MdToHtml {
|
||||
|
||||
constructor(options = null) {
|
||||
if (!options) options = {};
|
||||
|
||||
this.supportsResourceLinks_ = !!options.supportsResourceLinks;
|
||||
this.loadedResources_ = {};
|
||||
this.cachedContent_ = null;
|
||||
this.cachedContentKey_ = null;
|
||||
|
||||
// Must include last "/"
|
||||
this.resourceBaseUrl_ = ('resourceBaseUrl' in options) ? options.resourceBaseUrl : null;
|
||||
}
|
||||
|
||||
makeContentKey(resources, body, style, options) {
|
||||
let k = [];
|
||||
for (let n in resources) {
|
||||
if (!resources.hasOwnProperty(n)) continue;
|
||||
const r = resources[n];
|
||||
k.push(r.id);
|
||||
}
|
||||
k.push(md5(body));
|
||||
k.push(md5(JSON.stringify(style)));
|
||||
k.push(md5(JSON.stringify(options)));
|
||||
return k.join('_');
|
||||
}
|
||||
|
||||
renderAttrs_(attrs) {
|
||||
if (!attrs) return '';
|
||||
|
||||
let output = [];
|
||||
for (let i = 0; i < attrs.length; i++) {
|
||||
const n = attrs[i][0];
|
||||
const v = attrs[i].length >= 2 ? attrs[i][1] : null;
|
||||
|
||||
if (n === 'alt' && !v) {
|
||||
continue;
|
||||
} else if (n === 'src') {
|
||||
output.push('src="' + htmlentities(v) + '"');
|
||||
} else {
|
||||
output.push(n + '="' + (v ? htmlentities(v) : '') + '"');
|
||||
}
|
||||
}
|
||||
return output.join(' ');
|
||||
}
|
||||
|
||||
getAttr_(attrs, name) {
|
||||
for (let i = 0; i < attrs.length; i++) {
|
||||
if (attrs[i][0] === name) return attrs[i].length > 1 ? attrs[i][1] : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
setAttr_(attrs, name, value) {
|
||||
for (let i = 0; i < attrs.length; i++) {
|
||||
if (attrs[i][0] === name) {
|
||||
attrs[i][1] = value;
|
||||
return attrs;
|
||||
}
|
||||
}
|
||||
attrs.push([name, value]);
|
||||
return attrs;
|
||||
}
|
||||
|
||||
renderImage_(attrs, options) {
|
||||
const loadResource = async (id) => {
|
||||
console.info('Loading resource: ' + id);
|
||||
|
||||
// Initially set to to an empty object to make
|
||||
// it clear that it is being loaded. Otherwise
|
||||
// it sometimes results in multiple calls to
|
||||
// loadResource() for the same resource.
|
||||
this.loadedResources_[id] = {};
|
||||
|
||||
const resource = await Resource.load(id);
|
||||
|
||||
if (!resource) {
|
||||
// Can happen for example if an image is attached to a note, but the resource hasn't
|
||||
// been download from the sync target yet.
|
||||
console.warn('Cannot load resource: ' + id);
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadedResources_[id] = resource;
|
||||
|
||||
if (options.onResourceLoaded) options.onResourceLoaded();
|
||||
}
|
||||
|
||||
const title = this.getAttr_(attrs, 'title');
|
||||
const href = this.getAttr_(attrs, 'src');
|
||||
|
||||
if (!Resource.isResourceUrl(href)) {
|
||||
return '<span>' + href + '</span><img title="' + htmlentities(title) + '" src="' + href + '"/>';
|
||||
}
|
||||
|
||||
const resourceId = Resource.urlToId(href);
|
||||
const resource = this.loadedResources_[resourceId];
|
||||
if (!resource) {
|
||||
loadResource(resourceId);
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!resource.id) return ''; // Resource is being loaded
|
||||
|
||||
const mime = resource.mime ? resource.mime.toLowerCase() : '';
|
||||
if (mime == 'image/png' || mime == 'image/jpg' || mime == 'image/jpeg' || mime == 'image/gif') {
|
||||
let src = './' + Resource.filename(resource);
|
||||
if (this.resourceBaseUrl_ !== null) src = this.resourceBaseUrl_ + src;
|
||||
let output = '<img title="' + htmlentities(title) + '" src="' + src + '"/>';
|
||||
return output;
|
||||
}
|
||||
|
||||
return '[Image: ' + htmlentities(resource.title) + ' (' + htmlentities(mime) + ')]';
|
||||
}
|
||||
|
||||
renderOpenLink_(attrs, options) {
|
||||
let href = this.getAttr_(attrs, 'href');
|
||||
const title = this.getAttr_(attrs, 'title');
|
||||
const text = this.getAttr_(attrs, 'text');
|
||||
const isResourceUrl = Resource.isResourceUrl(href);
|
||||
|
||||
if (isResourceUrl && !this.supportsResourceLinks_) {
|
||||
// In mobile, links to local resources, such as PDF, etc. currently aren't supported.
|
||||
// Ideally they should be opened in the user's browser.
|
||||
return '[Resource not yet supported: '; //+ htmlentities(text) + ']';
|
||||
} else {
|
||||
if (isResourceUrl) {
|
||||
const resourceId = Resource.pathToId(href);
|
||||
href = 'joplin://' + resourceId;
|
||||
}
|
||||
|
||||
const js = options.postMessageSyntax + "(" + JSON.stringify(href) + "); return false;";
|
||||
let output = "<a title='" + htmlentities(title) + "' href='#' onclick='" + js + "'>";
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
renderCloseLink_(attrs, options) {
|
||||
const href = this.getAttr_(attrs, 'href');
|
||||
const isResourceUrl = Resource.isResourceUrl(href);
|
||||
|
||||
if (isResourceUrl && !this.supportsResourceLinks_) {
|
||||
return ']';
|
||||
} else {
|
||||
return '</a>';
|
||||
}
|
||||
}
|
||||
|
||||
renderTokens_(tokens, options) {
|
||||
let output = [];
|
||||
let previousToken = null;
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const t = tokens[i];
|
||||
const nextToken = i < tokens.length ? tokens[i+1] : null;
|
||||
|
||||
let tag = t.tag;
|
||||
let openTag = null;
|
||||
let closeTag = null;
|
||||
let attrs = t.attrs ? t.attrs : [];
|
||||
const isCodeBlock = tag === 'code' && t.block;
|
||||
|
||||
// if (t.map) attrs.push(['data-map', t.map.join(':')]);
|
||||
|
||||
if (previousToken && previousToken.tag === 'li' && tag === 'p') {
|
||||
// Markdown-it render list items as <li><p>Text<p></li> which makes it
|
||||
// complicated to style and layout the HTML, so we remove this extra
|
||||
// <p> here and below in closeTag.
|
||||
openTag = null;
|
||||
} else if (tag && t.type.indexOf('_open') >= 0) {
|
||||
openTag = tag;
|
||||
} else if (tag && t.type.indexOf('_close') >= 0) {
|
||||
closeTag = tag;
|
||||
} else if (tag && t.type.indexOf('inline') >= 0) {
|
||||
openTag = tag;
|
||||
} else if (t.type === 'link_open') {
|
||||
openTag = 'a';
|
||||
} else if (isCodeBlock) {
|
||||
openTag = 'pre';
|
||||
}
|
||||
|
||||
if (openTag) {
|
||||
if (openTag === 'a') {
|
||||
output.push(this.renderOpenLink_(attrs, options));
|
||||
} else {
|
||||
const attrsHtml = this.renderAttrs_(attrs);
|
||||
output.push('<' + openTag + (attrsHtml ? ' ' + attrsHtml : '') + '>');
|
||||
}
|
||||
}
|
||||
|
||||
if (isCodeBlock) {
|
||||
const codeAttrs = ['code'];
|
||||
if (t.info) codeAttrs.push(t.info); // t.info contains the language when the token is a codeblock
|
||||
output.push('<code class="' + codeAttrs.join(' ') + '">');
|
||||
}
|
||||
|
||||
if (t.type === 'image') {
|
||||
if (t.content) attrs.push(['title', t.content]);
|
||||
output.push(this.renderImage_(attrs, options));
|
||||
} else if (t.type === 'softbreak') {
|
||||
output.push('<br/>');
|
||||
} else if (t.type === 'hr') {
|
||||
output.push('<hr/>');
|
||||
} else {
|
||||
if (t.children) {
|
||||
const parsedChildren = this.renderTokens_(t.children, options);
|
||||
output = output.concat(parsedChildren);
|
||||
} else {
|
||||
if (t.content) {
|
||||
output.push(htmlentities(t.content));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nextToken && nextToken.tag === 'li' && t.tag === 'p') {
|
||||
closeTag = null;
|
||||
} else if (t.type === 'link_close') {
|
||||
closeTag = 'a';
|
||||
} else if (tag && t.type.indexOf('inline') >= 0) {
|
||||
closeTag = openTag;
|
||||
} else if (isCodeBlock) {
|
||||
closeTag = openTag;
|
||||
}
|
||||
|
||||
if (isCodeBlock) output.push('</code>');
|
||||
|
||||
if (closeTag) {
|
||||
if (closeTag === 'a') {
|
||||
output.push(this.renderCloseLink_(attrs, options));
|
||||
} else {
|
||||
output.push('</' + closeTag + '>');
|
||||
}
|
||||
}
|
||||
|
||||
previousToken = t;
|
||||
}
|
||||
return output.join('');
|
||||
}
|
||||
|
||||
render(body, style, options = null) {
|
||||
if (!options) options = {};
|
||||
if (!options.postMessageSyntax) options.postMessageSyntax = 'postMessage';
|
||||
if (!options.paddingBottom) options.paddingBottom = '0';
|
||||
|
||||
const cacheKey = this.makeContentKey(this.loadedResources_, body, style, options);
|
||||
if (this.cachedContentKey_ === cacheKey) return this.cachedContent_;
|
||||
|
||||
const md = new MarkdownIt({
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
});
|
||||
const env = {};
|
||||
|
||||
// Hack to make checkboxes clickable. Ideally, checkboxes should be parsed properly in
|
||||
// renderTokens_(), but for now this hack works. Marking it with HORRIBLE_HACK so
|
||||
// that it can be removed and replaced later on.
|
||||
const HORRIBLE_HACK = true;
|
||||
|
||||
if (HORRIBLE_HACK) {
|
||||
let counter = -1;
|
||||
while (body.indexOf('- [ ]') >= 0 || body.indexOf('- [X]') >= 0) {
|
||||
body = body.replace(/- \[(X| )\]/, function(v, p1) {
|
||||
let s = p1 == ' ' ? 'NOTICK' : 'TICK';
|
||||
counter++;
|
||||
return '- mJOPmCHECKBOXm' + s + 'm' + counter + 'm';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const tokens = md.parse(body, env);
|
||||
|
||||
// console.info(body);
|
||||
// console.info(tokens);
|
||||
|
||||
let renderedBody = this.renderTokens_(tokens, options);
|
||||
|
||||
if (HORRIBLE_HACK) {
|
||||
let loopCount = 0;
|
||||
while (renderedBody.indexOf('mJOPm') >= 0) {
|
||||
renderedBody = renderedBody.replace(/mJOPmCHECKBOXm([A-Z]+)m(\d+)m/, function(v, type, index) {
|
||||
|
||||
const js = options.postMessageSyntax + "('checkboxclick:" + type + ':' + index + "'); this.classList.contains('tick') ? this.classList.remove('tick') : this.classList.add('tick'); return false;";
|
||||
return '<a href="#" onclick="' + js + '" class="checkbox ' + (type == 'NOTICK' ? '' : 'tick') + '"><span>' + '' + '</span></a>';
|
||||
});
|
||||
if (loopCount++ >= 9999) break;
|
||||
}
|
||||
}
|
||||
|
||||
// https://necolas.github.io/normalize.css/
|
||||
const normalizeCss = `
|
||||
html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}
|
||||
article,aside,footer,header,nav,section{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}
|
||||
pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}
|
||||
b,strong{font-weight:bolder}small{font-size:80%}img{border-style:none}
|
||||
`;
|
||||
|
||||
const css = `
|
||||
body {
|
||||
font-size: ` + style.htmlFontSize + `;
|
||||
color: ` + style.htmlColor + `;
|
||||
line-height: ` + style.htmlLineHeight + `;
|
||||
background-color: ` + style.htmlBackgroundColor + `;
|
||||
font-family: sans-serif;
|
||||
padding-bottom: ` + options.paddingBottom + `;
|
||||
}
|
||||
p, h1, h2, h3, h4, ul, table {
|
||||
margin-top: 0;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
}
|
||||
a {
|
||||
color: ` + style.htmlLinkColor + `
|
||||
}
|
||||
ul {
|
||||
padding-left: 1.3em;
|
||||
}
|
||||
a.checkbox {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: .5em;
|
||||
text-decoration: none;
|
||||
width: 1.65em; /* Need to cut a bit the right border otherwise the SVG will display a black line */
|
||||
height: 1.7em;
|
||||
margin-right: .3em;
|
||||
background-color: ` + style.htmlColor + `;
|
||||
/* Awesome Font square-o */
|
||||
-webkit-mask: url("data:image/svg+xml;utf8,<svg viewBox='0 0 1792 1792' xmlns='http://www.w3.org/2000/svg'><path d='M1312 256h-832q-66 0-113 47t-47 113v832q0 66 47 113t113 47h832q66 0 113-47t47-113v-832q0-66-47-113t-113-47zm288 160v832q0 119-84.5 203.5t-203.5 84.5h-832q-119 0-203.5-84.5t-84.5-203.5v-832q0-119 84.5-203.5t203.5-84.5h832q119 0 203.5 84.5t84.5 203.5z'/></svg>");
|
||||
}
|
||||
a.checkbox.tick {
|
||||
left: .1245em; /* square-o and check-square-o aren't exactly aligned so add this extra gap to align them */
|
||||
/* Awesome Font check-square-o */
|
||||
-webkit-mask: url("data:image/svg+xml;utf8,<svg viewBox='0 0 1792 1792' xmlns='http://www.w3.org/2000/svg'><path d='M1472 930v318q0 119-84.5 203.5t-203.5 84.5h-832q-119 0-203.5-84.5t-84.5-203.5v-832q0-119 84.5-203.5t203.5-84.5h832q63 0 117 25 15 7 18 23 3 17-9 29l-49 49q-10 10-23 10-3 0-9-2-23-6-45-6h-832q-66 0-113 47t-47 113v832q0 66 47 113t113 47h832q66 0 113-47t47-113v-254q0-13 9-22l64-64q10-10 23-10 6 0 12 3 20 8 20 29zm231-489l-814 814q-24 24-57 24t-57-24l-430-430q-24-24-24-57t24-57l110-110q24-24 57-24t57 24l263 263 647-647q24-24 57-24t57 24l110 110q24 24 24 57t-24 57z'/></svg>");
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
td, th {
|
||||
border: 1px solid silver;
|
||||
padding: .5em 1em .5em 1em;
|
||||
}
|
||||
hr {
|
||||
border: none;
|
||||
border-bottom: 1px solid ` + style.htmlDividerColor + `;
|
||||
}
|
||||
img {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
const styleHtml = '<style>' + normalizeCss + "\n" + css + '</style>';
|
||||
|
||||
const output = styleHtml + renderedBody;
|
||||
|
||||
this.cachedContent_ = output;
|
||||
this.cachedContentKey_ = cacheKey;
|
||||
return this.cachedContent_;
|
||||
}
|
||||
|
||||
toggleTickAt(body, index) {
|
||||
let counter = -1;
|
||||
while (body.indexOf('- [ ]') >= 0 || body.indexOf('- [X]') >= 0) {
|
||||
counter++;
|
||||
|
||||
body = body.replace(/- \[(X| )\]/, function(v, p1) {
|
||||
let s = p1 == ' ' ? 'NOTICK' : 'TICK';
|
||||
if (index == counter) {
|
||||
s = s == 'NOTICK' ? 'TICK' : 'NOTICK';
|
||||
}
|
||||
return '°°JOP°CHECKBOX°' + s + '°°';
|
||||
});
|
||||
}
|
||||
|
||||
body = body.replace(/°°JOP°CHECKBOX°NOTICK°°/g, '- [ ]');
|
||||
body = body.replace(/°°JOP°CHECKBOX°TICK°°/g, '- [X]');
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
handleCheckboxClick(msg, noteBody) {
|
||||
msg = msg.split(':');
|
||||
let index = Number(msg[msg.length - 1]);
|
||||
let currentState = msg[msg.length - 2]; // Not really needed but keep it anyway
|
||||
return this.toggleTickAt(noteBody, index);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = MdToHtml;
|
||||
39
ReactNativeClient/lib/SyncTargetFilesystem.js
Normal file
39
ReactNativeClient/lib/SyncTargetFilesystem.js
Normal file
@@ -0,0 +1,39 @@
|
||||
const BaseSyncTarget = require('lib/BaseSyncTarget.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const { Setting } = require('lib/models/setting.js');
|
||||
const { FileApi } = require('lib/file-api.js');
|
||||
const { FileApiDriverLocal } = require('lib/file-api-driver-local.js');
|
||||
const { Synchronizer } = require('lib/synchronizer.js');
|
||||
|
||||
class SyncTargetFilesystem extends BaseSyncTarget {
|
||||
|
||||
static id() {
|
||||
return 2;
|
||||
}
|
||||
|
||||
static targetName() {
|
||||
return 'filesystem';
|
||||
}
|
||||
|
||||
static label() {
|
||||
return _('File system');
|
||||
}
|
||||
|
||||
isAuthenticated() {
|
||||
return true;
|
||||
}
|
||||
|
||||
async initFileApi() {
|
||||
const fileApi = new FileApi(Setting.value('sync.2.path'), new FileApiDriverLocal());
|
||||
fileApi.setLogger(this.logger());
|
||||
fileApi.setSyncTargetId(SyncTargetFilesystem.id());
|
||||
return fileApi;
|
||||
}
|
||||
|
||||
async initSynchronizer() {
|
||||
return new Synchronizer(this.db(), await this.fileApi(), Setting.value('appType'));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = SyncTargetFilesystem;
|
||||
39
ReactNativeClient/lib/SyncTargetMemory.js
Normal file
39
ReactNativeClient/lib/SyncTargetMemory.js
Normal file
@@ -0,0 +1,39 @@
|
||||
const BaseSyncTarget = require('lib/BaseSyncTarget.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const { Setting } = require('lib/models/setting.js');
|
||||
const { FileApi } = require('lib/file-api.js');
|
||||
const { FileApiDriverMemory } = require('lib/file-api-driver-memory.js');
|
||||
const { Synchronizer } = require('lib/synchronizer.js');
|
||||
|
||||
class SyncTargetMemory extends BaseSyncTarget {
|
||||
|
||||
static id() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
static targetName() {
|
||||
return 'memory';
|
||||
}
|
||||
|
||||
static label() {
|
||||
return 'Memory';
|
||||
}
|
||||
|
||||
isAuthenticated() {
|
||||
return true;
|
||||
}
|
||||
|
||||
initFileApi() {
|
||||
const fileApi = new FileApi('/root', new FileApiDriverMemory());
|
||||
fileApi.setLogger(this.logger());
|
||||
fileApi.setSyncTargetId(SyncTargetMemory.id());
|
||||
return fileApi;
|
||||
}
|
||||
|
||||
async initSynchronizer() {
|
||||
return new Synchronizer(this.db(), await this.fileApi(), Setting.value('appType'));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = SyncTargetMemory;
|
||||
85
ReactNativeClient/lib/SyncTargetOneDrive.js
Normal file
85
ReactNativeClient/lib/SyncTargetOneDrive.js
Normal file
@@ -0,0 +1,85 @@
|
||||
const BaseSyncTarget = require('lib/BaseSyncTarget.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const { OneDriveApi } = require('lib/onedrive-api.js');
|
||||
const { Setting } = require('lib/models/setting.js');
|
||||
const { parameters } = require('lib/parameters.js');
|
||||
const { FileApi } = require('lib/file-api.js');
|
||||
const { Synchronizer } = require('lib/synchronizer.js');
|
||||
const { FileApiDriverOneDrive } = require('lib/file-api-driver-onedrive.js');
|
||||
|
||||
class SyncTargetOneDrive extends BaseSyncTarget {
|
||||
|
||||
constructor(db, options = null) {
|
||||
super(db, options);
|
||||
this.api_ = null;
|
||||
}
|
||||
|
||||
static id() {
|
||||
return 3;
|
||||
}
|
||||
|
||||
static targetName() {
|
||||
return 'onedrive';
|
||||
}
|
||||
|
||||
static label() {
|
||||
return _('OneDrive');
|
||||
}
|
||||
|
||||
isAuthenticated() {
|
||||
return this.api().auth();
|
||||
}
|
||||
|
||||
syncTargetId() {
|
||||
return SyncTargetOneDrive.id();
|
||||
}
|
||||
|
||||
oneDriveParameters() {
|
||||
return parameters().oneDrive;
|
||||
}
|
||||
|
||||
api() {
|
||||
if (this.api_) return this.api_;
|
||||
|
||||
const isPublic = Setting.value('appType') != 'cli';
|
||||
|
||||
this.api_ = new OneDriveApi(this.oneDriveParameters().id, this.oneDriveParameters().secret, isPublic);
|
||||
this.api_.setLogger(this.logger());
|
||||
|
||||
this.api_.on('authRefreshed', (a) => {
|
||||
this.logger().info('Saving updated OneDrive auth.');
|
||||
Setting.setValue('sync.' + this.syncTargetId() + '.auth', a ? JSON.stringify(a) : null);
|
||||
});
|
||||
|
||||
let auth = Setting.value('sync.' + this.syncTargetId() + '.auth');
|
||||
if (auth) {
|
||||
try {
|
||||
auth = JSON.parse(auth);
|
||||
} catch (error) {
|
||||
this.logger().warn('Could not parse OneDrive auth token');
|
||||
this.logger().warn(error);
|
||||
auth = null;
|
||||
}
|
||||
|
||||
this.api_.setAuth(auth);
|
||||
}
|
||||
|
||||
return this.api_;
|
||||
}
|
||||
|
||||
async initFileApi() {
|
||||
const appDir = await this.api().appDirectory();
|
||||
const fileApi = new FileApi(appDir, new FileApiDriverOneDrive(this.api()));
|
||||
fileApi.setSyncTargetId(this.syncTargetId());
|
||||
fileApi.setLogger(this.logger());
|
||||
return fileApi;
|
||||
}
|
||||
|
||||
async initSynchronizer() {
|
||||
if (!this.isAuthenticated()) throw new Error('User is not authentified');
|
||||
return new Synchronizer(this.db(), await this.fileApi(), Setting.value('appType'));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = SyncTargetOneDrive;
|
||||
37
ReactNativeClient/lib/SyncTargetOneDriveDev.js
Normal file
37
ReactNativeClient/lib/SyncTargetOneDriveDev.js
Normal file
@@ -0,0 +1,37 @@
|
||||
const BaseSyncTarget = require('lib/BaseSyncTarget.js');
|
||||
const SyncTargetOneDrive = require('lib/SyncTargetOneDrive.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const { OneDriveApi } = require('lib/onedrive-api.js');
|
||||
const { Setting } = require('lib/models/setting.js');
|
||||
const { parameters } = require('lib/parameters.js');
|
||||
const { FileApi } = require('lib/file-api.js');
|
||||
const { Synchronizer } = require('lib/synchronizer.js');
|
||||
const { FileApiDriverOneDrive } = require('lib/file-api-driver-onedrive.js');
|
||||
|
||||
class SyncTargetOneDriveDev extends SyncTargetOneDrive {
|
||||
|
||||
static id() {
|
||||
return 4;
|
||||
}
|
||||
|
||||
static targetName() {
|
||||
return 'onedrive_dev';
|
||||
}
|
||||
|
||||
static label() {
|
||||
return _('OneDrive Dev (For testing only)');
|
||||
}
|
||||
|
||||
syncTargetId() {
|
||||
return SyncTargetOneDriveDev.id();
|
||||
}
|
||||
|
||||
oneDriveParameters() {
|
||||
return parameters('dev').oneDrive;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const staticSelf = SyncTargetOneDriveDev;
|
||||
|
||||
module.exports = SyncTargetOneDriveDev;
|
||||
39
ReactNativeClient/lib/SyncTargetRegistry.js
Normal file
39
ReactNativeClient/lib/SyncTargetRegistry.js
Normal file
@@ -0,0 +1,39 @@
|
||||
class SyncTargetRegistry {
|
||||
|
||||
static classById(syncTargetId) {
|
||||
const info = SyncTargetRegistry.reg_[syncTargetId];
|
||||
if (!info) throw new Error('Invalid id: ' + syncTargetId);
|
||||
return info.classRef;
|
||||
}
|
||||
|
||||
static addClass(SyncTargetClass) {
|
||||
this.reg_[SyncTargetClass.id()] = {
|
||||
id: SyncTargetClass.id(),
|
||||
name: SyncTargetClass.targetName(),
|
||||
label: SyncTargetClass.label(),
|
||||
classRef: SyncTargetClass,
|
||||
};
|
||||
}
|
||||
|
||||
static nameToId(name) {
|
||||
for (let n in this.reg_) {
|
||||
if (!this.reg_.hasOwnProperty(n)) continue;
|
||||
if (this.reg_[n].name === name) return this.reg_[n].id;
|
||||
}
|
||||
throw new Error('Name not found: ' + name);
|
||||
}
|
||||
|
||||
static idAndLabelPlainObject() {
|
||||
let output = {};
|
||||
for (let n in this.reg_) {
|
||||
if (!this.reg_.hasOwnProperty(n)) continue;
|
||||
output[n] = this.reg_[n].label;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
SyncTargetRegistry.reg_ = {};
|
||||
|
||||
module.exports = SyncTargetRegistry;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Log } from 'lib/log.js';
|
||||
import { Database } from 'lib/database.js';
|
||||
import { uuid } from 'lib/uuid.js';
|
||||
import { time } from 'lib/time-utils.js';
|
||||
const { Log } = require('lib/log.js');
|
||||
const { Database } = require('lib/database.js');
|
||||
const { uuid } = require('lib/uuid.js');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
|
||||
class BaseModel {
|
||||
|
||||
@@ -109,8 +109,17 @@ class BaseModel {
|
||||
return id.substr(0, 5);
|
||||
}
|
||||
|
||||
// static minimalPartialId(id) {
|
||||
// let length = 2;
|
||||
// while (true) {
|
||||
// const partialId = id.substr(0, length);
|
||||
// const r = await this.db().selectOne('SELECT count(*) as total FROM `' + this.tableName() + '` WHERE `id` LIKE ?', [partialId + '%']);
|
||||
// if (r['total'] <= 1) return partialId;
|
||||
// }
|
||||
// }
|
||||
|
||||
static loadByPartialId(partialId) {
|
||||
return this.modelSelectOne('SELECT * FROM `' + this.tableName() + '` WHERE `id` LIKE ?', [partialId + '%']);
|
||||
return this.modelSelectAll('SELECT * FROM `' + this.tableName() + '` WHERE `id` LIKE ?', [partialId + '%']);
|
||||
}
|
||||
|
||||
static applySqlOptions(options, sql, params = null) {
|
||||
@@ -238,7 +247,11 @@ class BaseModel {
|
||||
}
|
||||
|
||||
if (!o.user_created_time && this.hasField('user_created_time')) {
|
||||
o.user_created_time = timeNow;
|
||||
o.user_created_time = o.created_time ? o.created_time : timeNow;
|
||||
}
|
||||
|
||||
if (!o.user_updated_time && this.hasField('user_updated_time')) {
|
||||
o.user_updated_time = o.updated_time ? o.updated_time : timeNow;
|
||||
}
|
||||
|
||||
query = Database.insertQuery(this.tableName(), o);
|
||||
@@ -346,8 +359,9 @@ BaseModel.TYPE_SETTING = 3;
|
||||
BaseModel.TYPE_RESOURCE = 4;
|
||||
BaseModel.TYPE_TAG = 5;
|
||||
BaseModel.TYPE_NOTE_TAG = 6;
|
||||
BaseModel.TYPE_SEARCH = 7;
|
||||
|
||||
BaseModel.db_ = null;
|
||||
BaseModel.dispatch = function(o) {};
|
||||
|
||||
export { BaseModel };
|
||||
module.exports = { BaseModel };
|
||||
133
ReactNativeClient/lib/components/Dropdown.js
Normal file
133
ReactNativeClient/lib/components/Dropdown.js
Normal file
@@ -0,0 +1,133 @@
|
||||
const React = require('react');
|
||||
const { TouchableOpacity, TouchableWithoutFeedback , Dimensions, Text, Modal, View } = require('react-native');
|
||||
const { ItemList } = require('lib/components/ItemList.js');
|
||||
|
||||
class Dropdown extends React.Component {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.headerRef_ = null;
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.setState({
|
||||
headerSize: { x: 0, y: 0, width: 0, height: 0 },
|
||||
listVisible: false,
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// https://stackoverflow.com/questions/30096038/react-native-getting-the-position-of-an-element
|
||||
setTimeout(() => {
|
||||
this.headerRef_.measure((fx, fy, width, height, px, py) => {
|
||||
this.setState({
|
||||
headerSize: { x: px, y: py, width: width, height: height }
|
||||
});
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
render() {
|
||||
const items = this.props.items;
|
||||
const itemHeight = 60;
|
||||
const windowHeight = Dimensions.get('window').height - 50;
|
||||
|
||||
// Dimensions doesn't return quite the right dimensions so leave an extra gap to make
|
||||
// sure nothing is off screen.
|
||||
const listMaxHeight = windowHeight;
|
||||
const listHeight = Math.min(items.length * itemHeight, listMaxHeight); //Dimensions.get('window').height - this.state.headerSize.y - this.state.headerSize.height - 50;
|
||||
const maxListTop = windowHeight - listHeight;
|
||||
const listTop = Math.min(maxListTop, this.state.headerSize.y + this.state.headerSize.height);
|
||||
|
||||
const wrapperStyle = {
|
||||
width: this.state.headerSize.width,
|
||||
height: listHeight + 2, // +2 for the border (otherwise it makes the scrollbar appear)
|
||||
marginTop: listTop,
|
||||
marginLeft: this.state.headerSize.x,
|
||||
};
|
||||
|
||||
const itemListStyle = Object.assign({}, this.props.itemListStyle ? this.props.itemListStyle : {}, {
|
||||
borderWidth: 1,
|
||||
borderColor: '#ccc',
|
||||
});
|
||||
|
||||
const itemWrapperStyle = Object.assign({}, this.props.itemWrapperStyle ? this.props.itemWrapperStyle : {}, {
|
||||
flex:1,
|
||||
justifyContent: 'center',
|
||||
height: itemHeight,
|
||||
paddingLeft: 20,
|
||||
paddingRight: 10,
|
||||
});
|
||||
|
||||
const headerWrapperStyle = Object.assign({}, this.props.headerWrapperStyle ? this.props.headerWrapperStyle : {}, {
|
||||
height: 35,
|
||||
// borderWidth: 1,
|
||||
// borderColor: '#ccc',
|
||||
//paddingLeft: 20,
|
||||
//paddingRight: 20,
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
const headerStyle = Object.assign({}, this.props.headerStyle ? this.props.headerStyle : {}, {
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
const headerArrowStyle = Object.assign({}, this.props.headerStyle ? this.props.headerStyle : {}, {
|
||||
flex: 0,
|
||||
marginRight: 10,
|
||||
});
|
||||
|
||||
const itemStyle = Object.assign({}, this.props.itemStyle ? this.props.itemStyle : {}, {
|
||||
|
||||
});
|
||||
|
||||
let headerLabel = '...';
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.value === this.props.selectedValue) {
|
||||
headerLabel = item.label;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const closeList = () => {
|
||||
this.setState({ listVisible: false });
|
||||
}
|
||||
|
||||
const itemRenderer= (item) => {
|
||||
return (
|
||||
<TouchableOpacity style={itemWrapperStyle} key={item.value} onPress={() => { closeList(); if (this.props.onValueChange) this.props.onValueChange(item.value); }}>
|
||||
<Text ellipsizeMode="tail" numberOfLines={1} style={itemStyle} key={item.value}>{item.label}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{flex: 1, flexDirection: 'column' }}>
|
||||
<TouchableOpacity style={headerWrapperStyle} ref={(ref) => this.headerRef_ = ref} onPress={() => { this.setState({ listVisible: true }) }}>
|
||||
<Text ellipsizeMode="tail" numberOfLines={1} style={headerStyle}>{headerLabel}</Text>
|
||||
<Text style={headerArrowStyle}>{'▼'}</Text>
|
||||
</TouchableOpacity>
|
||||
<Modal transparent={true} visible={this.state.listVisible} onRequestClose={() => { closeList(); }} >
|
||||
<TouchableWithoutFeedback onPressOut={() => { closeList() }}>
|
||||
<View style={{flex:1}}>
|
||||
<View style={wrapperStyle}>
|
||||
<ItemList
|
||||
style={itemListStyle}
|
||||
items={this.props.items}
|
||||
itemHeight={itemHeight}
|
||||
itemRenderer={(item) => { return itemRenderer(item) }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { Dropdown };
|
||||
103
ReactNativeClient/lib/components/ItemList.js
Normal file
103
ReactNativeClient/lib/components/ItemList.js
Normal file
@@ -0,0 +1,103 @@
|
||||
const React = require('react');
|
||||
const { Text, TouchableHighlight, View, StyleSheet, ScrollView } = require('react-native');
|
||||
|
||||
class ItemList extends React.Component {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.scrollTop_ = 0;
|
||||
}
|
||||
|
||||
itemCount(props = null) {
|
||||
if (props === null) props = this.props;
|
||||
return this.props.items ? this.props.items.length : this.props.itemComponents.length;
|
||||
}
|
||||
|
||||
updateStateItemIndexes(props = null, height = null) {
|
||||
if (props === null) props = this.props;
|
||||
|
||||
if (height === null) {
|
||||
if (!this.state) return;
|
||||
height = this.state.height;
|
||||
}
|
||||
|
||||
const topItemIndex = Math.max(0, Math.floor(this.scrollTop_ / props.itemHeight));
|
||||
const visibleItemCount = Math.ceil(height / props.itemHeight);
|
||||
|
||||
let bottomItemIndex = topItemIndex + visibleItemCount - 1;
|
||||
if (bottomItemIndex >= this.itemCount(props)) bottomItemIndex = this.itemCount(props) - 1;
|
||||
|
||||
this.setState({
|
||||
topItemIndex: topItemIndex,
|
||||
bottomItemIndex: bottomItemIndex,
|
||||
});
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.setState({
|
||||
topItemIndex: 0,
|
||||
bottomItemIndex: 0,
|
||||
height: 0,
|
||||
itemHeight: this.props.itemHeight ? this.props.itemHeight : 0,
|
||||
});
|
||||
|
||||
this.updateStateItemIndexes();
|
||||
}
|
||||
|
||||
componentWillReceiveProps(newProps) {
|
||||
if (newProps.itemHeight) {
|
||||
this.setState({
|
||||
itemHeight: newProps.itemHeight,
|
||||
});
|
||||
}
|
||||
|
||||
this.updateStateItemIndexes(newProps);
|
||||
}
|
||||
|
||||
onScroll(event) {
|
||||
this.scrollTop_ = Math.floor(event.nativeEvent.contentOffset.y);
|
||||
this.updateStateItemIndexes();
|
||||
}
|
||||
|
||||
onLayout(event) {
|
||||
this.setState({ height: event.nativeEvent.layout.height });
|
||||
this.updateStateItemIndexes(null, event.nativeEvent.layout.height);
|
||||
}
|
||||
|
||||
render() {
|
||||
const style = this.props.style ? this.props.style : {};
|
||||
const itemHeight = this.state.itemHeight;
|
||||
|
||||
//if (!this.props.itemHeight) throw new Error('itemHeight is required');
|
||||
|
||||
let itemComps = [];
|
||||
|
||||
if (this.props.items) {
|
||||
const items = this.props.items;
|
||||
|
||||
const blankItem = function(key, height) {
|
||||
return <View key={key} style={{height:height}}></View>
|
||||
}
|
||||
|
||||
itemComps = [blankItem('top', this.state.topItemIndex * this.props.itemHeight)];
|
||||
|
||||
for (let i = this.state.topItemIndex; i <= this.state.bottomItemIndex; i++) {
|
||||
const itemComp = this.props.itemRenderer(items[i]);
|
||||
itemComps.push(itemComp);
|
||||
}
|
||||
|
||||
itemComps.push(blankItem('bottom', (items.length - this.state.bottomItemIndex - 1) * this.props.itemHeight));
|
||||
} else {
|
||||
itemComps = this.props.itemComponents;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView scrollEventThrottle={500} onLayout={(event) => { this.onLayout(event); }} style={style} onScroll={ (event) => { this.onScroll(event) }}>
|
||||
{ itemComps }
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ItemList };
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { Component } from 'react';
|
||||
import { StyleSheet, Text } from 'react-native';
|
||||
import Icon from 'react-native-vector-icons/Ionicons';
|
||||
import ReactNativeActionButton from 'react-native-action-button';
|
||||
import { connect } from 'react-redux'
|
||||
import { globalStyle } from 'lib/components/global-style.js'
|
||||
import { Log } from 'lib/log.js'
|
||||
import { _ } from 'lib/locale.js'
|
||||
const React = require('react'); const Component = React.Component;
|
||||
const { StyleSheet, Text } = require('react-native');
|
||||
const Icon = require('react-native-vector-icons/Ionicons').default;
|
||||
const ReactNativeActionButton = require('react-native-action-button').default;
|
||||
const { connect } = require('react-redux');
|
||||
const { globalStyle } = require('lib/components/global-style.js');
|
||||
const { Log } = require('lib/log.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
actionButtonIcon: {
|
||||
@@ -67,7 +67,7 @@ class ActionButtonComponent extends React.Component {
|
||||
if (this.props.addFolderNoteButtons) {
|
||||
if (this.props.folders.length) {
|
||||
buttons.push({
|
||||
title: _('New todo'),
|
||||
title: _('New to-do'),
|
||||
onPress: () => { this.newTodo_press() },
|
||||
color: '#9b59b6',
|
||||
icon: 'md-checkbox-outline',
|
||||
@@ -134,8 +134,9 @@ const ActionButton = connect(
|
||||
(state) => {
|
||||
return {
|
||||
folders: state.folders,
|
||||
locale: state.settings.locale,
|
||||
};
|
||||
}
|
||||
)(ActionButtonComponent)
|
||||
|
||||
export { ActionButton };
|
||||
module.exports = { ActionButton };
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux'
|
||||
import { NotesScreen } from 'lib/components/screens/notes.js';
|
||||
import { SearchScreen } from 'lib/components/screens/search.js';
|
||||
import { View } from 'react-native';
|
||||
import { _ } from 'lib/locale.js';
|
||||
const React = require('react'); const Component = React.Component;
|
||||
const { connect } = require('react-redux');
|
||||
const { NotesScreen } = require('lib/components/screens/notes.js');
|
||||
const { SearchScreen } = require('lib/components/screens/search.js');
|
||||
const { View } = require('react-native');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const { themeStyle } = require('lib/components/global-style.js');
|
||||
|
||||
class AppNavComponent extends Component {
|
||||
|
||||
@@ -38,8 +39,12 @@ class AppNavComponent extends Component {
|
||||
|
||||
this.previousRouteName_ = route.routeName;
|
||||
|
||||
const theme = themeStyle(this.props.theme);
|
||||
|
||||
const style = { flex: 1, backgroundColor: theme.backgroundColor }
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<View style={style}>
|
||||
<NotesScreen visible={notesScreenVisible} navigation={{ state: route }} />
|
||||
{ searchScreenLoaded && <SearchScreen visible={searchScreenVisible} navigation={{ state: route }} /> }
|
||||
{ (!notesScreenVisible && !searchScreenVisible) && <Screen navigation={{ state: route }} /> }
|
||||
@@ -53,8 +58,9 @@ const AppNav = connect(
|
||||
(state) => {
|
||||
return {
|
||||
route: state.route,
|
||||
theme: state.settings.theme,
|
||||
};
|
||||
}
|
||||
)(AppNavComponent)
|
||||
|
||||
export { AppNav };
|
||||
module.exports = { AppNav };
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { Component } from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { globalStyle, themeStyle } from 'lib/components/global-style.js';
|
||||
const React = require('react'); const Component = React.Component;
|
||||
const { StyleSheet } = require('react-native');
|
||||
const { globalStyle, themeStyle } = require('lib/components/global-style.js');
|
||||
|
||||
const styleObject_ = {
|
||||
screen: {
|
||||
@@ -37,4 +37,4 @@ class BaseScreenComponent extends React.Component {
|
||||
|
||||
}
|
||||
|
||||
export { BaseScreenComponent };
|
||||
module.exports = { BaseScreenComponent };
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { Component } from 'react';
|
||||
import { StyleSheet, TouchableHighlight } from 'react-native';
|
||||
import Icon from 'react-native-vector-icons/Ionicons';
|
||||
const React = require('react'); const Component = React.Component;
|
||||
const { StyleSheet, View, TouchableHighlight } = require('react-native');
|
||||
const Icon = require('react-native-vector-icons/Ionicons').default;
|
||||
|
||||
const styles = {
|
||||
checkboxIcon: {
|
||||
@@ -20,7 +20,7 @@ class Checkbox extends Component {
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.state = { checked: this.props.checked };
|
||||
this.setState({ checked: this.props.checked });
|
||||
}
|
||||
|
||||
componentWillReceiveProps(newProps) {
|
||||
@@ -55,7 +55,9 @@ class Checkbox extends Component {
|
||||
alignItems: 'center',
|
||||
};
|
||||
|
||||
if (style.display) thStyle.display = style.display;
|
||||
if (style && style.display === 'none') return <View/>
|
||||
|
||||
//if (style.display) thStyle.display = style.display;
|
||||
|
||||
return (
|
||||
<TouchableHighlight onPress={() => this.onPress()} style={thStyle}>
|
||||
@@ -66,4 +68,4 @@ class Checkbox extends Component {
|
||||
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
module.exports = { Checkbox };
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Setting } from 'lib/models/setting.js';
|
||||
const { Setting } = require('lib/models/setting.js');
|
||||
|
||||
const globalStyle = {
|
||||
fontSize: 16,
|
||||
@@ -20,11 +20,12 @@ const globalStyle = {
|
||||
raisedHighlightedColor: "#ffffff",
|
||||
|
||||
// For WebView - must correspond to the properties above
|
||||
htmlFontSize: '20x',
|
||||
htmlFontSize: '16px',
|
||||
htmlColor: 'black', // Note: CSS in WebView component only supports named colors or rgb() notation
|
||||
htmlBackgroundColor: 'white',
|
||||
htmlDividerColor: 'Gainsboro',
|
||||
htmlLinkColor: 'blue',
|
||||
htmlLineHeight: '20px',
|
||||
};
|
||||
|
||||
globalStyle.marginRight = globalStyle.margin;
|
||||
@@ -69,4 +70,4 @@ function themeStyle(theme) {
|
||||
return themeCache_[theme];
|
||||
}
|
||||
|
||||
export { globalStyle, themeStyle }
|
||||
module.exports = { globalStyle, themeStyle };
|
||||
@@ -1,11 +1,10 @@
|
||||
import React, { Component } from 'react';
|
||||
import { WebView, View, Linking } from 'react-native';
|
||||
import { globalStyle } from 'lib/components/global-style.js';
|
||||
import { Resource } from 'lib/models/resource.js';
|
||||
import { shim } from 'lib/shim.js';
|
||||
import marked from 'lib/marked.js';
|
||||
const Entities = require('html-entities').AllHtmlEntities;
|
||||
const htmlentities = (new Entities()).encode;
|
||||
const React = require('react'); const Component = React.Component;
|
||||
const { Platform, WebView, View, Linking } = require('react-native');
|
||||
const { globalStyle } = require('lib/components/global-style.js');
|
||||
const { Resource } = require('lib/models/resource.js');
|
||||
const { Setting } = require('lib/models/setting.js');
|
||||
const { reg } = require('lib/registry.js');
|
||||
const MdToHtml = require('lib/MdToHtml.js');
|
||||
|
||||
class NoteBodyViewer extends Component {
|
||||
|
||||
@@ -19,160 +18,16 @@ class NoteBodyViewer extends Component {
|
||||
this.isMounted_ = false;
|
||||
}
|
||||
|
||||
async loadResource(id) {
|
||||
const resource = await Resource.load(id);
|
||||
resource.base64 = await shim.readLocalFileBase64(Resource.fullPath(resource));
|
||||
|
||||
let newResources = Object.assign({}, this.state.resources);
|
||||
newResources[id] = resource;
|
||||
this.setState({ resources: newResources });
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.mdToHtml_ = new MdToHtml({ supportsResourceLinks: false });
|
||||
this.isMounted_ = true;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.mdToHtml_ = null;
|
||||
this.isMounted_ = false;
|
||||
}
|
||||
|
||||
toggleTickAt(body, index) {
|
||||
let counter = -1;
|
||||
while (body.indexOf('- [ ]') >= 0 || body.indexOf('- [X]') >= 0) {
|
||||
counter++;
|
||||
|
||||
body = body.replace(/- \[(X| )\]/, function(v, p1) {
|
||||
let s = p1 == ' ' ? 'NOTICK' : 'TICK';
|
||||
if (index == counter) {
|
||||
s = s == 'NOTICK' ? 'TICK' : 'NOTICK';
|
||||
}
|
||||
return '°°JOP°CHECKBOX°' + s + '°°';
|
||||
});
|
||||
}
|
||||
|
||||
body = body.replace(/°°JOP°CHECKBOX°NOTICK°°/g, '- [ ]');
|
||||
body = body.replace(/°°JOP°CHECKBOX°TICK°°/g, '- [X]');
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
markdownToHtml(body, style) {
|
||||
// https://necolas.github.io/normalize.css/
|
||||
const normalizeCss = `
|
||||
html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}
|
||||
article,aside,footer,header,nav,section{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}
|
||||
pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}
|
||||
b,strong{font-weight:bolder}small{font-size:80%}img{border-style:none}
|
||||
`;
|
||||
|
||||
const css = `
|
||||
body {
|
||||
font-size: ` + style.htmlFontSize + `;
|
||||
color: ` + style.htmlColor + `;
|
||||
line-height: 1.5em;
|
||||
background-color: ` + style.htmlBackgroundColor + `;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
}
|
||||
a {
|
||||
color: ` + style.htmlLinkColor + `
|
||||
}
|
||||
ul {
|
||||
padding-left: 1em;
|
||||
}
|
||||
a.checkbox {
|
||||
font-size: 1.6em;
|
||||
position: relative;
|
||||
top: 0.1em;
|
||||
text-decoration: none;
|
||||
color: ` + style.htmlColor + `;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
td, th {
|
||||
border: 1px solid silver;
|
||||
padding: .5em 1em .5em 1em;
|
||||
}
|
||||
hr {
|
||||
border: 1px solid ` + style.htmlDividerColor + `;
|
||||
}
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
let counter = -1;
|
||||
while (body.indexOf('- [ ]') >= 0 || body.indexOf('- [X]') >= 0) {
|
||||
body = body.replace(/- \[(X| )\]/, function(v, p1) {
|
||||
let s = p1 == ' ' ? 'NOTICK' : 'TICK';
|
||||
counter++;
|
||||
return '°°JOP°CHECKBOX°' + s + '°' + counter + '°°';
|
||||
});
|
||||
}
|
||||
|
||||
const renderer = new marked.Renderer();
|
||||
|
||||
renderer.link = function (href, title, text) {
|
||||
if (Resource.isResourceUrl(href)) {
|
||||
return '[Resource not yet supported: ' + htmlentities(text) + ']';
|
||||
} else {
|
||||
const js = "postMessage(" + JSON.stringify(href) + "); return false;";
|
||||
let output = "<a title='" + htmlentities(title) + "' href='#' onclick='" + js + "'>" + htmlentities(text) + '</a>';
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
renderer.image = (href, title, text) => {
|
||||
const resourceId = Resource.urlToId(href);
|
||||
if (!this.state.resources[resourceId]) {
|
||||
this.loadResource(resourceId);
|
||||
return '';
|
||||
}
|
||||
|
||||
const r = this.state.resources[resourceId];
|
||||
const mime = r.mime.toLowerCase();
|
||||
if (mime == 'image/png' || mime == 'image/jpg' || mime == 'image/jpeg' || mime == 'image/gif') {
|
||||
const src = 'data:' + r.mime + ';base64,' + r.base64;
|
||||
let output = '<img title="' + htmlentities(title) + '" src="' + src + '"/>';
|
||||
return output;
|
||||
}
|
||||
|
||||
return '[Image: ' + htmlentities(r.title) + ' (' + htmlentities(mime) + ')]';
|
||||
}
|
||||
|
||||
let styleHtml = '<style>' + normalizeCss + "\n" + css + '</style>';
|
||||
|
||||
let html = body ? styleHtml + marked(body, {
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
renderer: renderer,
|
||||
sanitize: true,
|
||||
}) : styleHtml;
|
||||
|
||||
let elementId = 1;
|
||||
while (html.indexOf('°°JOP°') >= 0) {
|
||||
html = html.replace(/°°JOP°CHECKBOX°([A-Z]+)°(\d+)°°/, function(v, type, index) {
|
||||
const js = "postMessage('checkboxclick:" + type + ':' + index + "'); this.textContent = this.textContent == '☐' ? '☑' : '☐'; return false;";
|
||||
return '<a href="#" onclick="' + js + '" class="checkbox">' + (type == 'NOTICK' ? '☐' : '☑') + '</a>';
|
||||
});
|
||||
}
|
||||
|
||||
let scriptHtml = '<script>document.body.scrollTop = ' + this.bodyScrollTop_ + ';</script>';
|
||||
|
||||
html = '<body onscroll="postMessage(\'bodyscroll:\' + document.body.scrollTop);">' + html + scriptHtml + '</body>';
|
||||
|
||||
// console.info(html);
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
onLoadEnd() {
|
||||
if (this.state.webViewLoaded) return;
|
||||
|
||||
@@ -181,36 +36,72 @@ class NoteBodyViewer extends Component {
|
||||
setTimeout(() => {
|
||||
if (!this.isMounted_) return;
|
||||
this.setState({ webViewLoaded: true });
|
||||
}, 200);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
render() {
|
||||
const note = this.props.note;
|
||||
const style = this.props.style;
|
||||
const onCheckboxChange = this.props.onCheckboxChange;
|
||||
const html = this.markdownToHtml(note ? note.body : '', this.props.webViewStyle);
|
||||
|
||||
const mdOptions = {
|
||||
onResourceLoaded: () => {
|
||||
this.forceUpdate();
|
||||
},
|
||||
paddingBottom: '3.8em', // Extra bottom padding to make it possible to scroll past the action button (so that it doesn't overlap the text)
|
||||
};
|
||||
|
||||
const html = this.mdToHtml_.render(note ? note.body : '', this.props.webViewStyle, mdOptions);
|
||||
|
||||
let webViewStyle = {}
|
||||
if (!this.state.webViewLoaded) webViewStyle.display = 'none';
|
||||
// On iOS, the onLoadEnd() event is never fired so always
|
||||
// display the webview (don't do the little trick
|
||||
// to avoid the white flash).
|
||||
if (Platform.OS !== 'ios') {
|
||||
webViewStyle.opacity = this.state.webViewLoaded ? 1 : 0.01;
|
||||
}
|
||||
|
||||
// On iOS scalesPageToFit work like this:
|
||||
//
|
||||
// Find the widest image, resize it *and everything else* by x% so that
|
||||
// the image fits within the viewport. The problem is that it means if there's
|
||||
// a large image, everything is going to be scaled to a very small size, making
|
||||
// the text unreadable.
|
||||
//
|
||||
// On Android:
|
||||
//
|
||||
// Find the widest elements and scale them (and them only) to fit within the viewport
|
||||
// It means it's going to scale large images, but the text will remain at the normal
|
||||
// size.
|
||||
//
|
||||
// That means we can use scalesPageToFix on Android but not on iOS.
|
||||
// The weird thing is that on iOS, scalesPageToFix=false along with a CSS
|
||||
// rule "img { max-width: 100% }", works like scalesPageToFix=true on Android.
|
||||
// So we use scalesPageToFix=false on iOS along with that CSS rule.
|
||||
|
||||
// `baseUrl` is where the images will be loaded from. So images must use a path relative to resourceDir.
|
||||
const source = {
|
||||
html: html,
|
||||
baseUrl: 'file://' + Setting.value('resourceDir') + '/',
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={style}>
|
||||
<WebView
|
||||
scalesPageToFit={Platform.OS !== 'ios'}
|
||||
style={webViewStyle}
|
||||
source={{ html: html }}
|
||||
source={source}
|
||||
onLoadEnd={() => this.onLoadEnd()}
|
||||
onError={(e) => reg.logger().error('WebView error', e) }
|
||||
onMessage={(event) => {
|
||||
let msg = event.nativeEvent.data;
|
||||
|
||||
if (msg.indexOf('checkboxclick:') === 0) {
|
||||
msg = msg.split(':');
|
||||
let index = Number(msg[msg.length - 1]);
|
||||
let currentState = msg[msg.length - 2]; // Not really needed but keep it anyway
|
||||
const newBody = this.toggleTickAt(note.body, index);
|
||||
const newBody = this.mdToHtml_.handleCheckboxClick(msg, note.body);
|
||||
if (onCheckboxChange) onCheckboxChange(newBody);
|
||||
} else if (msg.indexOf('bodyscroll:') === 0) {
|
||||
msg = msg.split(':');
|
||||
this.bodyScrollTop_ = Number(msg[1]);
|
||||
//msg = msg.split(':');
|
||||
//this.bodyScrollTop_ = Number(msg[1]);
|
||||
} else {
|
||||
Linking.openURL(msg);
|
||||
}
|
||||
@@ -222,4 +113,4 @@ class NoteBodyViewer extends Component {
|
||||
|
||||
}
|
||||
|
||||
export { NoteBodyViewer };
|
||||
module.exports = { NoteBodyViewer };
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux'
|
||||
import { ListView, Text, TouchableHighlight, View, StyleSheet } from 'react-native';
|
||||
import { Log } from 'lib/log.js';
|
||||
import { _ } from 'lib/locale.js';
|
||||
import { Checkbox } from 'lib/components/checkbox.js';
|
||||
import { reg } from 'lib/registry.js';
|
||||
import { Note } from 'lib/models/note.js';
|
||||
import { time } from 'lib/time-utils.js';
|
||||
import { globalStyle, themeStyle } from 'lib/components/global-style.js';
|
||||
const React = require('react'); const Component = React.Component;
|
||||
const { connect } = require('react-redux');
|
||||
const { ListView, Text, TouchableOpacity , View, StyleSheet } = require('react-native');
|
||||
const { Log } = require('lib/log.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const { Checkbox } = require('lib/components/checkbox.js');
|
||||
const { reg } = require('lib/registry.js');
|
||||
const { Note } = require('lib/models/note.js');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const { globalStyle, themeStyle } = require('lib/components/global-style.js');
|
||||
|
||||
class NoteItemComponent extends Component {
|
||||
|
||||
@@ -41,13 +41,16 @@ class NoteItemComponent extends Component {
|
||||
paddingRight: theme.marginRight,
|
||||
paddingTop: theme.itemMarginTop,
|
||||
paddingBottom: theme.itemMarginBottom,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
//backgroundColor: theme.backgroundColor,
|
||||
},
|
||||
listItemText: {
|
||||
flex: 1,
|
||||
color: theme.color,
|
||||
fontSize: theme.fontSize,
|
||||
},
|
||||
selectionWrapper: {
|
||||
backgroundColor: theme.backgroundColor,
|
||||
},
|
||||
};
|
||||
|
||||
styles.listItemWithCheckbox = Object.assign({}, styles.listItem);
|
||||
@@ -59,6 +62,9 @@ class NoteItemComponent extends Component {
|
||||
styles.listItemTextWithCheckbox.marginTop = styles.listItem.paddingTop - 1;
|
||||
styles.listItemTextWithCheckbox.marginBottom = styles.listItem.paddingBottom;
|
||||
|
||||
styles.selectionWrapperSelected = Object.assign({}, styles.selectionWrapper);
|
||||
styles.selectionWrapperSelected.backgroundColor = theme.selectedColor;
|
||||
|
||||
this.styles_[this.props.theme] = StyleSheet.create(styles);
|
||||
return this.styles_[this.props.theme];
|
||||
}
|
||||
@@ -76,10 +82,26 @@ class NoteItemComponent extends Component {
|
||||
onPress() {
|
||||
if (!this.props.note) return;
|
||||
|
||||
if (this.props.noteSelectionEnabled) {
|
||||
this.props.dispatch({
|
||||
type: 'NOTE_SELECTION_TOGGLE',
|
||||
id: this.props.note.id,
|
||||
});
|
||||
} else {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Note',
|
||||
noteId: this.props.note.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onLongPress() {
|
||||
if (!this.props.note) return;
|
||||
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Note',
|
||||
noteId: this.props.note.id,
|
||||
type: this.props.noteSelectionEnabled ? 'NOTE_SELECTION_TOGGLE' : 'NOTE_SELECTION_START',
|
||||
id: this.props.note.id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,6 +112,7 @@ class NoteItemComponent extends Component {
|
||||
const onCheckboxChange = this.props.onCheckboxChange;
|
||||
const theme = themeStyle(this.props.theme);
|
||||
|
||||
// IOS: display: none crashes the app
|
||||
let checkboxStyle = !isTodo ? { display: 'none' } : { color: theme.color };
|
||||
|
||||
if (isTodo) {
|
||||
@@ -103,19 +126,26 @@ class NoteItemComponent extends Component {
|
||||
|
||||
const listItemStyle = isTodo ? this.styles().listItemWithCheckbox : this.styles().listItem;
|
||||
const listItemTextStyle = isTodo ? this.styles().listItemTextWithCheckbox : this.styles().listItemText;
|
||||
const rootStyle = isTodo && checkboxChecked ? {opacity: 0.4} : {};
|
||||
const opacityStyle = isTodo && checkboxChecked ? {opacity: 0.4} : {};
|
||||
const isSelected = this.props.noteSelectionEnabled && this.props.selectedNoteIds.indexOf(note.id) >= 0;
|
||||
|
||||
const selectionWrapperStyle = isSelected ? this.styles().selectionWrapperSelected : this.styles().selectionWrapper;
|
||||
|
||||
return (
|
||||
<TouchableHighlight onPress={() => this.onPress()} underlayColor="#0066FF" style={rootStyle}>
|
||||
<View style={ listItemStyle }>
|
||||
<Checkbox
|
||||
style={checkboxStyle}
|
||||
checked={checkboxChecked}
|
||||
onChange={(checked) => this.todoCheckbox_change(checked)}
|
||||
/>
|
||||
<Text style={listItemTextStyle}>{note.title}</Text>
|
||||
<TouchableOpacity onPress={() => this.onPress()} onLongPress={() => this.onLongPress() } activeOpacity={0.5}>
|
||||
<View style={ selectionWrapperStyle }>
|
||||
<View style={ opacityStyle }>
|
||||
<View style={ listItemStyle }>
|
||||
<Checkbox
|
||||
style={checkboxStyle}
|
||||
checked={checkboxChecked}
|
||||
onChange={(checked) => this.todoCheckbox_change(checked)}
|
||||
/>
|
||||
<Text style={listItemTextStyle}>{note.title}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableHighlight>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,8 +155,10 @@ const NoteItem = connect(
|
||||
(state) => {
|
||||
return {
|
||||
theme: state.settings.theme,
|
||||
noteSelectionEnabled: state.noteSelectionEnabled,
|
||||
selectedNoteIds: state.selectedNoteIds,
|
||||
};
|
||||
}
|
||||
)(NoteItemComponent)
|
||||
|
||||
export { NoteItem }
|
||||
module.exports = { NoteItem };
|
||||
@@ -1,15 +1,15 @@
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux'
|
||||
import { ListView, Text, TouchableHighlight, Switch, View, StyleSheet } from 'react-native';
|
||||
import { Log } from 'lib/log.js';
|
||||
import { _ } from 'lib/locale.js';
|
||||
import { Checkbox } from 'lib/components/checkbox.js';
|
||||
import { NoteItem } from 'lib/components/note-item.js';
|
||||
import { reg } from 'lib/registry.js';
|
||||
import { Note } from 'lib/models/note.js';
|
||||
import { Setting } from 'lib/models/setting.js';
|
||||
import { time } from 'lib/time-utils.js';
|
||||
import { themeStyle } from 'lib/components/global-style.js';
|
||||
const React = require('react'); const Component = React.Component;
|
||||
const { connect } = require('react-redux');
|
||||
const { ListView, Text, TouchableHighlight, Switch, View, StyleSheet } = require('react-native');
|
||||
const { Log } = require('lib/log.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const { Checkbox } = require('lib/components/checkbox.js');
|
||||
const { NoteItem } = require('lib/components/note-item.js');
|
||||
const { reg } = require('lib/registry.js');
|
||||
const { Note } = require('lib/models/note.js');
|
||||
const { Setting } = require('lib/models/setting.js');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const { themeStyle } = require('lib/components/global-style.js');
|
||||
|
||||
class NoteListComponent extends Component {
|
||||
|
||||
@@ -50,7 +50,7 @@ class NoteListComponent extends Component {
|
||||
}
|
||||
|
||||
filterNotes(notes) {
|
||||
const todoFilter = Setting.value('todoFilter');
|
||||
const todoFilter = 'all'; //Setting.value('todoFilter');
|
||||
if (todoFilter == 'all') return notes;
|
||||
|
||||
const now = time.unixMs();
|
||||
@@ -71,7 +71,7 @@ class NoteListComponent extends Component {
|
||||
|
||||
componentWillMount() {
|
||||
const newDataSource = this.state.dataSource.cloneWithRows(this.filterNotes(this.props.items));
|
||||
this.state = { dataSource: newDataSource };
|
||||
this.setState({ dataSource: newDataSource });
|
||||
}
|
||||
|
||||
componentWillReceiveProps(newProps) {
|
||||
@@ -113,8 +113,9 @@ const NoteList = connect(
|
||||
items: state.notes,
|
||||
notesSource: state.notesSource,
|
||||
theme: state.settings.theme,
|
||||
noteSelectionEnabled: state.noteSelectionEnabled,
|
||||
};
|
||||
}
|
||||
)(NoteListComponent)
|
||||
|
||||
export { NoteList };
|
||||
module.exports = { NoteList };
|
||||
@@ -1,16 +1,25 @@
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux'
|
||||
import { View, Text, Button, StyleSheet, TouchableOpacity, Picker, Image } from 'react-native';
|
||||
import Icon from 'react-native-vector-icons/Ionicons';
|
||||
import { Log } from 'lib/log.js';
|
||||
import { BackButtonService } from 'lib/services/back-button.js';
|
||||
import { Menu, MenuOptions, MenuOption, MenuTrigger } from 'react-native-popup-menu';
|
||||
import { _ } from 'lib/locale.js';
|
||||
import { Setting } from 'lib/models/setting.js';
|
||||
import { FileApi } from 'lib/file-api.js';
|
||||
import { FileApiDriverOneDrive } from 'lib/file-api-driver-onedrive.js';
|
||||
import { reg } from 'lib/registry.js'
|
||||
import { themeStyle } from 'lib/components/global-style.js';
|
||||
const React = require('react'); const Component = React.Component;
|
||||
const { connect } = require('react-redux');
|
||||
const { Platform, View, Text, Button, StyleSheet, TouchableOpacity, Image } = require('react-native');
|
||||
const Icon = require('react-native-vector-icons/Ionicons').default;
|
||||
const { Log } = require('lib/log.js');
|
||||
const { BackButtonService } = require('lib/services/back-button.js');
|
||||
const { ReportService } = require('lib/services/report.js');
|
||||
const { Menu, MenuOptions, MenuOption, MenuTrigger } = require('react-native-popup-menu');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const { Setting } = require('lib/models/setting.js');
|
||||
const { Note } = require('lib/models/note.js');
|
||||
const { Folder } = require('lib/models/folder.js');
|
||||
const { FileApi } = require('lib/file-api.js');
|
||||
const { FileApiDriverOneDrive } = require('lib/file-api-driver-onedrive.js');
|
||||
const { reg } = require('lib/registry.js');
|
||||
const { themeStyle } = require('lib/components/global-style.js');
|
||||
const { ItemList } = require('lib/components/ItemList.js');
|
||||
const { Dropdown } = require('lib/components/Dropdown.js');
|
||||
const { time } = require('lib/time-utils');
|
||||
const RNFS = require('react-native-fs');
|
||||
const { dialogs } = require('lib/dialogs.js');
|
||||
const DialogBox = require('react-native-dialogbox').default;
|
||||
|
||||
// Rather than applying a padding to the whole bar, it is applied to each
|
||||
// individual component (button, picker, etc.) so that the touchable areas
|
||||
@@ -39,11 +48,7 @@ class ScreenHeaderComponent extends Component {
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000000',
|
||||
elevation: 5,
|
||||
},
|
||||
folderPicker: {
|
||||
flex:1,
|
||||
color: theme.raisedHighlightedColor,
|
||||
// Note: cannot set backgroundStyle as that would remove the arrow in the component
|
||||
paddingTop: Platform.OS === 'ios' ? 15 : 0, // Extra padding for iOS because the top icons are there
|
||||
},
|
||||
divider: {
|
||||
borderBottomWidth: 1,
|
||||
@@ -146,7 +151,6 @@ class ScreenHeaderComponent extends Component {
|
||||
|
||||
async backButton_press() {
|
||||
await BackButtonService.back();
|
||||
//this.props.dispatch({ type: 'NAV_BACK' });
|
||||
}
|
||||
|
||||
searchButton_press() {
|
||||
@@ -156,6 +160,17 @@ class ScreenHeaderComponent extends Component {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteButton_press() {
|
||||
// Dialog needs to be displayed as a child of the parent component, otherwise
|
||||
// it won't be visible within the header component.
|
||||
const ok = await dialogs.confirm(this.props.parentComponent, _('Delete these notes?'));
|
||||
if (!ok) return;
|
||||
|
||||
const noteIds = this.props.selectedNoteIds;
|
||||
this.props.dispatch({ type: 'NOTE_SELECTION_END' });
|
||||
await Note.batchDelete(noteIds);
|
||||
}
|
||||
|
||||
menu_select(value) {
|
||||
if (typeof(value) == 'function') {
|
||||
value();
|
||||
@@ -183,6 +198,32 @@ class ScreenHeaderComponent extends Component {
|
||||
});
|
||||
}
|
||||
|
||||
async debugReport_press() {
|
||||
const service = new ReportService();
|
||||
|
||||
const logItems = await reg.logger().lastEntries(null);
|
||||
const logItemRows = [
|
||||
['Date','Level','Message']
|
||||
];
|
||||
for (let i = 0; i < logItems.length; i++) {
|
||||
const item = logItems[i];
|
||||
logItemRows.push([
|
||||
time.formatMsToLocal(item.timestamp, 'MM-DDTHH:mm:ss'),
|
||||
item.level,
|
||||
item.message
|
||||
]);
|
||||
}
|
||||
const logItemCsv = service.csvCreate(logItemRows);
|
||||
|
||||
const itemListCsv = await service.basicItemList({ format: 'csv' });
|
||||
const filePath = RNFS.ExternalDirectoryPath + '/syncReport-' + (new Date()).getTime() + '.txt';
|
||||
|
||||
const finalText = [logItemCsv, itemListCsv].join("\n--------------------------------------------------------------------------------");
|
||||
|
||||
await RNFS.writeFile(filePath, finalText);
|
||||
alert('Debug report exported to ' + filePath);
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
function sideMenuButton(styles, onPress) {
|
||||
@@ -229,53 +270,129 @@ class ScreenHeaderComponent extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
function deleteButton(styles, onPress) {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress}>
|
||||
<View style={styles.iconButton}>
|
||||
<Icon name='md-trash' style={styles.topIcon} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
let key = 0;
|
||||
let menuOptionComponents = [];
|
||||
for (let i = 0; i < this.props.menuOptions.length; i++) {
|
||||
let o = this.props.menuOptions[i];
|
||||
|
||||
if (!this.props.noteSelectionEnabled) {
|
||||
for (let i = 0; i < this.props.menuOptions.length; i++) {
|
||||
let o = this.props.menuOptions[i];
|
||||
menuOptionComponents.push(
|
||||
<MenuOption value={o.onPress} key={'menuOption_' + key++} style={this.styles().contextMenuItem}>
|
||||
<Text style={this.styles().contextMenuItemText}>{o.title}</Text>
|
||||
</MenuOption>);
|
||||
}
|
||||
|
||||
if (this.props.showAdvancedOptions) {
|
||||
if (menuOptionComponents.length) {
|
||||
menuOptionComponents.push(<View key={'menuOption_showAdvancedOptions'} style={this.styles().divider}/>);
|
||||
}
|
||||
|
||||
menuOptionComponents.push(
|
||||
<MenuOption value={() => this.log_press()} key={'menuOption_log'} style={this.styles().contextMenuItem}>
|
||||
<Text style={this.styles().contextMenuItemText}>{_('Log')}</Text>
|
||||
</MenuOption>);
|
||||
|
||||
menuOptionComponents.push(
|
||||
<MenuOption value={() => this.status_press()} key={'menuOption_status'} style={this.styles().contextMenuItem}>
|
||||
<Text style={this.styles().contextMenuItemText}>{_('Status')}</Text>
|
||||
</MenuOption>);
|
||||
|
||||
if (Platform.OS === 'android') {
|
||||
menuOptionComponents.push(
|
||||
<MenuOption value={() => this.debugReport_press()} key={'menuOption_debugReport'} style={this.styles().contextMenuItem}>
|
||||
<Text style={this.styles().contextMenuItemText}>{_('Export Debug Report')}</Text>
|
||||
</MenuOption>);
|
||||
}
|
||||
}
|
||||
|
||||
if (menuOptionComponents.length) {
|
||||
menuOptionComponents.push(<View key={'menuOption_' + key++} style={this.styles().divider}/>);
|
||||
}
|
||||
|
||||
menuOptionComponents.push(
|
||||
<MenuOption value={o.onPress} key={'menuOption_' + key++} style={this.styles().contextMenuItem}>
|
||||
<Text style={this.styles().contextMenuItemText}>{o.title}</Text>
|
||||
<MenuOption value={() => this.config_press()} key={'menuOption_config'} style={this.styles().contextMenuItem}>
|
||||
<Text style={this.styles().contextMenuItemText}>{_('Configuration')}</Text>
|
||||
</MenuOption>);
|
||||
} else {
|
||||
menuOptionComponents.push(
|
||||
<MenuOption value={() => this.deleteButton_press()} key={'menuOption_delete'} style={this.styles().contextMenuItem}>
|
||||
<Text style={this.styles().contextMenuItemText}>{_('Delete')}</Text>
|
||||
</MenuOption>);
|
||||
}
|
||||
|
||||
if (menuOptionComponents.length) {
|
||||
menuOptionComponents.push(<View key={'menuOption_' + key++} style={this.styles().divider}/>);
|
||||
}
|
||||
|
||||
menuOptionComponents.push(
|
||||
<MenuOption value={() => this.log_press()} key={'menuOption_' + key++} style={this.styles().contextMenuItem}>
|
||||
<Text style={this.styles().contextMenuItemText}>{_('Log')}</Text>
|
||||
</MenuOption>);
|
||||
|
||||
menuOptionComponents.push(
|
||||
<MenuOption value={() => this.status_press()} key={'menuOption_' + key++} style={this.styles().contextMenuItem}>
|
||||
<Text style={this.styles().contextMenuItemText}>{_('Status')}</Text>
|
||||
</MenuOption>);
|
||||
|
||||
if (menuOptionComponents.length) {
|
||||
menuOptionComponents.push(<View key={'menuOption_' + key++} style={this.styles().divider}/>);
|
||||
}
|
||||
|
||||
menuOptionComponents.push(
|
||||
<MenuOption value={() => this.config_press()} key={'menuOption_' + key++} style={this.styles().contextMenuItem}>
|
||||
<Text style={this.styles().contextMenuItemText}>{_('Configuration')}</Text>
|
||||
</MenuOption>);
|
||||
|
||||
const createTitleComponent = () => {
|
||||
const p = this.props.titlePicker;
|
||||
if (p) {
|
||||
let items = [];
|
||||
for (let i = 0; i < p.items.length; i++) {
|
||||
let item = p.items[i];
|
||||
items.push(<Picker.Item label={item.label} value={item.value} key={item.value}/>);
|
||||
const themeId = Setting.value('theme');
|
||||
const theme = themeStyle(themeId);
|
||||
const folderPickerOptions = this.props.folderPickerOptions;
|
||||
|
||||
if (folderPickerOptions && folderPickerOptions.enabled) {
|
||||
|
||||
const titlePickerItems = (mustSelect) => {
|
||||
let output = [];
|
||||
if (mustSelect) output.push({ label: _('Move to notebook...'), value: null });
|
||||
for (let i = 0; i < this.props.folders.length; i++) {
|
||||
let f = this.props.folders[i];
|
||||
output.push({ label: f.title, value: f.id });
|
||||
}
|
||||
output.sort((a, b) => {
|
||||
if (a.value === null) return -1;
|
||||
if (b.value === null) return +1;
|
||||
return a.label.toLowerCase() < b.label.toLowerCase() ? -1 : +1;
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<Picker style={this.styles().folderPicker} selectedValue={p.selectedValue} onValueChange={(itemValue, itemIndex) => { if (p.onValueChange) p.onValueChange(itemValue, itemIndex); }}>
|
||||
{ items }
|
||||
</Picker>
|
||||
</View>
|
||||
<Dropdown
|
||||
items={titlePickerItems(!!folderPickerOptions.mustSelect)}
|
||||
itemHeight={35}
|
||||
selectedValue={('selectedFolderId' in folderPickerOptions) ? folderPickerOptions.selectedFolderId : null}
|
||||
itemListStyle={{
|
||||
backgroundColor: theme.backgroundColor,
|
||||
}}
|
||||
headerStyle={{
|
||||
color: theme.raisedHighlightedColor,
|
||||
fontSize: theme.fontSize,
|
||||
}}
|
||||
itemStyle={{
|
||||
color: theme.color,
|
||||
fontSize: theme.fontSize,
|
||||
}}
|
||||
onValueChange={async (folderId, itemIndex) => {
|
||||
// If onValueChange is specified, use this as a callback, otherwise do the default
|
||||
// which is to take the selectedNoteIds from the state and move them to the
|
||||
// chosen folder.
|
||||
|
||||
if (folderPickerOptions.onValueChange) {
|
||||
folderPickerOptions.onValueChange(folderId, itemIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!folderId) return;
|
||||
const noteIds = this.props.selectedNoteIds;
|
||||
if (!noteIds.length) return;
|
||||
|
||||
const folder = await Folder.load(folderId);
|
||||
|
||||
const ok = noteIds.length > 1 ? await dialogs.confirm(this.props.parentComponent, _('Move %d notes to notebook "%s"?', noteIds.length, folder.title)) : true;
|
||||
if (!ok) return;
|
||||
|
||||
this.props.dispatch({ type: 'NOTE_SELECTION_END' });
|
||||
for (let i = 0; i < noteIds.length; i++) {
|
||||
await Note.moveToFolder(noteIds[i], folderId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
let title = 'title' in this.props && this.props.title !== null ? this.props.title : '';
|
||||
@@ -284,22 +401,32 @@ class ScreenHeaderComponent extends Component {
|
||||
}
|
||||
|
||||
const titleComp = createTitleComponent();
|
||||
const sideMenuComp = this.props.noteSelectionEnabled ? null : sideMenuButton(this.styles(), () => this.sideMenuButton_press());
|
||||
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 menuComp = (
|
||||
<Menu onSelect={(value) => this.menu_select(value)} style={this.styles().contextMenu}>
|
||||
<MenuTrigger style={{ paddingTop: PADDING_V, paddingBottom: PADDING_V }}>
|
||||
<Text style={this.styles().contextMenuTrigger}> ⋮</Text>
|
||||
</MenuTrigger>
|
||||
<MenuOptions>
|
||||
{ menuOptionComponents }
|
||||
</MenuOptions>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={this.styles().container} >
|
||||
{ sideMenuButton(this.styles(), () => this.sideMenuButton_press()) }
|
||||
{ backButton(this.styles(), () => this.backButton_press(), !this.props.historyCanGoBack) }
|
||||
{ sideMenuComp }
|
||||
{ backButtonComp }
|
||||
{ saveButton(this.styles(), () => { if (this.props.onSaveButtonPress) this.props.onSaveButtonPress() }, this.props.saveButtonDisabled === true, this.props.showSaveButton === true) }
|
||||
{ titleComp }
|
||||
{ searchButton(this.styles(), () => this.searchButton_press()) }
|
||||
<Menu onSelect={(value) => this.menu_select(value)} style={this.styles().contextMenu}>
|
||||
<MenuTrigger style={{ paddingTop: PADDING_V, paddingBottom: PADDING_V }}>
|
||||
<Text style={this.styles().contextMenuTrigger}> ⋮</Text>
|
||||
</MenuTrigger>
|
||||
<MenuOptions>
|
||||
{ menuOptionComponents }
|
||||
</MenuOptions>
|
||||
</Menu>
|
||||
{ searchButtonComp }
|
||||
{ deleteButtonComp }
|
||||
{ menuComp }
|
||||
<DialogBox ref={dialogbox => { this.dialogbox = dialogbox }}/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -315,9 +442,13 @@ const ScreenHeader = connect(
|
||||
return {
|
||||
historyCanGoBack: state.historyCanGoBack,
|
||||
locale: state.settings.locale,
|
||||
folders: state.folders,
|
||||
theme: state.settings.theme,
|
||||
showAdvancedOptions: state.settings.showAdvancedOptions,
|
||||
noteSelectionEnabled: state.noteSelectionEnabled,
|
||||
selectedNoteIds: state.selectedNoteIds,
|
||||
};
|
||||
}
|
||||
)(ScreenHeaderComponent)
|
||||
|
||||
export { ScreenHeader };
|
||||
module.exports = { ScreenHeader };
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { Component } from 'react';
|
||||
import { View, Switch, Slider, StyleSheet, Picker, Text, Button } from 'react-native';
|
||||
import { connect } from 'react-redux'
|
||||
import { ScreenHeader } from 'lib/components/screen-header.js';
|
||||
import { _, setLocale } from 'lib/locale.js';
|
||||
import { BaseScreenComponent } from 'lib/components/base-screen.js';
|
||||
import { themeStyle } from 'lib/components/global-style.js';
|
||||
import { Setting } from 'lib/models/setting.js';
|
||||
const React = require('react'); const Component = React.Component;
|
||||
const { TouchableOpacity, Linking, View, Switch, Slider, StyleSheet, Text, Button, ScrollView } = require('react-native');
|
||||
const { connect } = require('react-redux');
|
||||
const { ScreenHeader } = require('lib/components/screen-header.js');
|
||||
const { _, setLocale } = require('lib/locale.js');
|
||||
const { BaseScreenComponent } = require('lib/components/base-screen.js');
|
||||
const { Dropdown } = require('lib/components/Dropdown.js');
|
||||
const { themeStyle } = require('lib/components/global-style.js');
|
||||
const { Setting } = require('lib/models/setting.js');
|
||||
|
||||
class ConfigScreenComponent extends BaseScreenComponent {
|
||||
|
||||
@@ -26,7 +27,15 @@ class ConfigScreenComponent extends BaseScreenComponent {
|
||||
this.styles_ = {};
|
||||
|
||||
let styles = {
|
||||
body: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-start',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
settingContainer: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.dividerColor,
|
||||
paddingTop: theme.marginTop,
|
||||
@@ -38,13 +47,12 @@ class ConfigScreenComponent extends BaseScreenComponent {
|
||||
fontWeight: 'bold',
|
||||
color: theme.color,
|
||||
fontSize: theme.fontSize,
|
||||
flex: 1,
|
||||
},
|
||||
settingControl: {
|
||||
color: theme.color,
|
||||
flex: 1,
|
||||
},
|
||||
pickerItem: {
|
||||
fontSize: theme.fontSize,
|
||||
}
|
||||
}
|
||||
|
||||
styles.switchSettingText = Object.assign({}, styles.settingText);
|
||||
@@ -54,15 +62,24 @@ class ConfigScreenComponent extends BaseScreenComponent {
|
||||
styles.switchSettingContainer.flexDirection = 'row';
|
||||
styles.switchSettingContainer.justifyContent = 'space-between';
|
||||
|
||||
styles.linkText = Object.assign({}, styles.settingText);
|
||||
styles.linkText.borderBottomWidth = 1;
|
||||
styles.linkText.borderBottomColor = theme.color;
|
||||
styles.linkText.flex = 0;
|
||||
styles.linkText.fontWeight = 'normal';
|
||||
|
||||
styles.switchSettingControl = Object.assign({}, styles.settingControl);
|
||||
delete styles.switchSettingControl.color;
|
||||
styles.switchSettingControl.width = '20%';
|
||||
//styles.switchSettingControl.width = '20%';
|
||||
styles.switchSettingControl.flex = 0;
|
||||
|
||||
this.styles_[themeId] = StyleSheet.create(styles);
|
||||
return this.styles_[themeId];
|
||||
}
|
||||
|
||||
settingToComponent(key, value) {
|
||||
const themeId = this.props.theme;
|
||||
const theme = themeStyle(themeId);
|
||||
let output = null;
|
||||
|
||||
const updateSettingValue = (key, value) => {
|
||||
@@ -72,25 +89,36 @@ class ConfigScreenComponent extends BaseScreenComponent {
|
||||
const md = Setting.settingMetadata(key);
|
||||
|
||||
if (md.isEnum) {
|
||||
// The Picker component doesn't work properly with int values, so
|
||||
// convert everything to string (Setting.setValue will convert
|
||||
// back to the correct type.
|
||||
|
||||
value = value.toString();
|
||||
|
||||
let items = [];
|
||||
const settingOptions = md.options();
|
||||
for (let k in settingOptions) {
|
||||
if (!settingOptions.hasOwnProperty(k)) continue;
|
||||
items.push(<Picker.Item label={settingOptions[k]} value={k.toString()} key={k}/>);
|
||||
items.push({ label: settingOptions[k], value: k.toString() });
|
||||
}
|
||||
|
||||
return (
|
||||
<View key={key} style={this.styles().settingContainer}>
|
||||
<Text key="label" style={this.styles().settingText}>{md.label()}</Text>
|
||||
<Picker key="control" style={this.styles().settingControl} selectedValue={value} onValueChange={(itemValue, itemIndex) => updateSettingValue(key, itemValue)} >
|
||||
{ items }
|
||||
</Picker>
|
||||
<Dropdown
|
||||
key="control"
|
||||
style={this.styles().settingControl}
|
||||
items={items}
|
||||
selectedValue={value}
|
||||
itemListStyle={{
|
||||
backgroundColor: theme.backgroundColor,
|
||||
}}
|
||||
headerStyle={{
|
||||
color: theme.color,
|
||||
fontSize: theme.fontSize,
|
||||
}}
|
||||
itemStyle={{
|
||||
color: theme.color,
|
||||
fontSize: theme.fontSize,
|
||||
}}
|
||||
onValueChange={(itemValue, itemIndex) => { updateSettingValue(key, itemValue); }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
} else if (md.type == Setting.TYPE_BOOL) {
|
||||
@@ -117,23 +145,42 @@ class ConfigScreenComponent extends BaseScreenComponent {
|
||||
render() {
|
||||
const settings = this.props.settings;
|
||||
|
||||
const keys = Setting.keys(true, 'mobile');
|
||||
let settingComps = [];
|
||||
for (let key in settings) {
|
||||
if (key == 'sync.target') continue;
|
||||
if (!settings.hasOwnProperty(key)) continue;
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i];
|
||||
if (key == 'sync.target' && !settings.showAdvancedOptions) continue;
|
||||
if (!Setting.isPublic(key)) continue;
|
||||
|
||||
const comp = this.settingToComponent(key, settings[key]);
|
||||
if (!comp) continue;
|
||||
settingComps.push(comp);
|
||||
}
|
||||
|
||||
settingComps.push(
|
||||
<View key="website_link" style={this.styles().settingContainer}>
|
||||
<TouchableOpacity onPress={() => { Linking.openURL('http://joplin.cozic.net/') }}>
|
||||
<Text key="label" style={this.styles().linkText}>Joplin Website</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
|
||||
settingComps.push(
|
||||
<View key="privacy_link" style={this.styles().settingContainer}>
|
||||
<TouchableOpacity onPress={() => { Linking.openURL('http://joplin.cozic.net/privacy/') }}>
|
||||
<Text key="label" style={this.styles().linkText}>Privacy Policy</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
|
||||
//style={this.styles().body}
|
||||
|
||||
return (
|
||||
<View style={this.rootStyle(this.props.theme).root}>
|
||||
<ScreenHeader title={_('Configuration')}/>
|
||||
<View style={this.styles().body}>
|
||||
<ScrollView >
|
||||
{ settingComps }
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -149,4 +196,4 @@ const ConfigScreen = connect(
|
||||
}
|
||||
)(ConfigScreenComponent)
|
||||
|
||||
export { ConfigScreen };
|
||||
module.exports = { ConfigScreen };
|
||||
@@ -1,16 +1,16 @@
|
||||
import React, { Component } from 'react';
|
||||
import { View, Button, TextInput, StyleSheet } from 'react-native';
|
||||
import { connect } from 'react-redux'
|
||||
import { Log } from 'lib/log.js'
|
||||
import { ActionButton } from 'lib/components/action-button.js';
|
||||
import { Folder } from 'lib/models/folder.js'
|
||||
import { BaseModel } from 'lib/base-model.js'
|
||||
import { ScreenHeader } from 'lib/components/screen-header.js';
|
||||
import { reg } from 'lib/registry.js';
|
||||
import { BaseScreenComponent } from 'lib/components/base-screen.js';
|
||||
import { dialogs } from 'lib/dialogs.js';
|
||||
import { themeStyle } from 'lib/components/global-style.js';
|
||||
import { _ } from 'lib/locale.js';
|
||||
const React = require('react'); const Component = React.Component;
|
||||
const { View, Button, TextInput, StyleSheet } = require('react-native');
|
||||
const { connect } = require('react-redux');
|
||||
const { Log } = require('lib/log.js');
|
||||
const { ActionButton } = require('lib/components/action-button.js');
|
||||
const { Folder } = require('lib/models/folder.js');
|
||||
const { BaseModel } = require('lib/base-model.js');
|
||||
const { ScreenHeader } = require('lib/components/screen-header.js');
|
||||
const { reg } = require('lib/registry.js');
|
||||
const { BaseScreenComponent } = require('lib/components/base-screen.js');
|
||||
const { dialogs } = require('lib/dialogs.js');
|
||||
const { themeStyle } = require('lib/components/global-style.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
|
||||
class FolderScreenComponent extends BaseScreenComponent {
|
||||
|
||||
@@ -36,6 +36,7 @@ class FolderScreenComponent extends BaseScreenComponent {
|
||||
let styles = {
|
||||
textInput: {
|
||||
color: theme.color,
|
||||
paddingLeft: 10,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -129,4 +130,4 @@ const FolderScreen = connect(
|
||||
}
|
||||
)(FolderScreenComponent)
|
||||
|
||||
export { FolderScreen };
|
||||
module.exports = { FolderScreen };
|
||||
@@ -1,14 +1,14 @@
|
||||
import React, { Component } from 'react';
|
||||
import { ListView, View, Text, Button, StyleSheet } from 'react-native';
|
||||
import { connect } from 'react-redux'
|
||||
import { Log } from 'lib/log.js'
|
||||
import { reg } from 'lib/registry.js'
|
||||
import { ScreenHeader } from 'lib/components/screen-header.js';
|
||||
import { time } from 'lib/time-utils'
|
||||
import { themeStyle } from 'lib/components/global-style.js';
|
||||
import { Logger } from 'lib/logger.js';
|
||||
import { BaseScreenComponent } from 'lib/components/base-screen.js';
|
||||
import { _ } from 'lib/locale.js';
|
||||
const React = require('react'); const Component = React.Component;
|
||||
const { ListView, View, Text, Button, StyleSheet } = require('react-native');
|
||||
const { connect } = require('react-redux');
|
||||
const { Log } = require('lib/log.js');
|
||||
const { reg } = require('lib/registry.js');
|
||||
const { ScreenHeader } = require('lib/components/screen-header.js');
|
||||
const { time } = require('lib/time-utils');
|
||||
const { themeStyle } = require('lib/components/global-style.js');
|
||||
const { Logger } = require('lib/logger.js');
|
||||
const { BaseScreenComponent } = require('lib/components/base-screen.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
|
||||
class LogScreenComponent extends BaseScreenComponent {
|
||||
|
||||
@@ -106,4 +106,4 @@ const LogScreen = connect(
|
||||
}
|
||||
)(LogScreenComponent)
|
||||
|
||||
export { LogScreen };
|
||||
module.exports = { LogScreen };
|
||||
@@ -1,30 +1,36 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Keyboard, BackHandler, View, Button, TextInput, WebView, Text, StyleSheet, Linking, Image } from 'react-native';
|
||||
import { connect } from 'react-redux'
|
||||
import { uuid } from 'lib/uuid.js';
|
||||
import { Log } from 'lib/log.js'
|
||||
import { Note } from 'lib/models/note.js'
|
||||
import { Resource } from 'lib/models/resource.js'
|
||||
import { Folder } from 'lib/models/folder.js'
|
||||
import { BackButtonService } from 'lib/services/back-button.js';
|
||||
import { BaseModel } from 'lib/base-model.js'
|
||||
import { ActionButton } from 'lib/components/action-button.js';
|
||||
import Icon from 'react-native-vector-icons/Ionicons';
|
||||
import { ScreenHeader } from 'lib/components/screen-header.js';
|
||||
import { time } from 'lib/time-utils.js';
|
||||
import { Checkbox } from 'lib/components/checkbox.js'
|
||||
import { _ } from 'lib/locale.js';
|
||||
import { reg } from 'lib/registry.js';
|
||||
import { shim } from 'lib/shim.js';
|
||||
import { BaseScreenComponent } from 'lib/components/base-screen.js';
|
||||
import { dialogs } from 'lib/dialogs.js';
|
||||
import { globalStyle, themeStyle } from 'lib/components/global-style.js';
|
||||
import DialogBox from 'react-native-dialogbox';
|
||||
import { NoteBodyViewer } from 'lib/components/note-body-viewer.js';
|
||||
import RNFetchBlob from 'react-native-fetch-blob';
|
||||
import { DocumentPicker, DocumentPickerUtil } from 'react-native-document-picker';
|
||||
import ImageResizer from 'react-native-image-resizer';
|
||||
import { SelectDateTimeDialog } from 'lib/components/select-date-time-dialog.js';
|
||||
const React = require('react'); const Component = React.Component;
|
||||
const { Platform, Keyboard, BackHandler, View, Button, TextInput, WebView, Text, StyleSheet, Linking, Image } = require('react-native');
|
||||
const { connect } = require('react-redux');
|
||||
const { uuid } = require('lib/uuid.js');
|
||||
const { Log } = require('lib/log.js');
|
||||
const RNFS = require('react-native-fs');
|
||||
const { Note } = require('lib/models/note.js');
|
||||
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 { BaseModel } = require('lib/base-model.js');
|
||||
const { ActionButton } = require('lib/components/action-button.js');
|
||||
const Icon = require('react-native-vector-icons/Ionicons').default;
|
||||
const { fileExtension, basename } = require('lib/path-utils.js');
|
||||
const mimeUtils = require('lib/mime-utils.js').mime;
|
||||
const { ScreenHeader } = require('lib/components/screen-header.js');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const { Checkbox } = require('lib/components/checkbox.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const { reg } = require('lib/registry.js');
|
||||
const { shim } = require('lib/shim.js');
|
||||
const { BaseScreenComponent } = require('lib/components/base-screen.js');
|
||||
const { dialogs } = require('lib/dialogs.js');
|
||||
const { globalStyle, themeStyle } = require('lib/components/global-style.js');
|
||||
const DialogBox = require('react-native-dialogbox').default;
|
||||
const { NoteBodyViewer } = require('lib/components/note-body-viewer.js');
|
||||
const RNFetchBlob = require('react-native-fetch-blob').default;
|
||||
const { DocumentPicker, DocumentPickerUtil } = require('react-native-document-picker');
|
||||
const ImageResizer = require('react-native-image-resizer').default;
|
||||
const shared = require('lib/components/shared/note-screen-shared.js');
|
||||
const ImagePicker = require('react-native-image-picker');
|
||||
const { SelectDateTimeDialog } = require('lib/components/select-date-time-dialog.js');
|
||||
|
||||
class NoteScreenComponent extends BaseScreenComponent {
|
||||
|
||||
@@ -42,11 +48,13 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
folder: null,
|
||||
lastSavedNote: null,
|
||||
isLoading: true,
|
||||
resources: {},
|
||||
titleTextInputHeight: 20,
|
||||
alarmDialogShown: false,
|
||||
};
|
||||
|
||||
// iOS doesn't support multiline text fields properly so disable it
|
||||
this.enableMultilineTitle_ = Platform.OS !== 'ios';
|
||||
|
||||
this.saveButtonHasBeenShown_ = false;
|
||||
|
||||
this.styles_ = {};
|
||||
@@ -130,123 +138,41 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
}
|
||||
|
||||
isModified() {
|
||||
if (!this.state.note || !this.state.lastSavedNote) return false;
|
||||
let diff = BaseModel.diffObjects(this.state.note, this.state.lastSavedNote);
|
||||
delete diff.type_;
|
||||
return !!Object.getOwnPropertyNames(diff).length;
|
||||
return shared.isModified(this);
|
||||
}
|
||||
|
||||
async componentWillMount() {
|
||||
BackButtonService.addHandler(this.backHandler);
|
||||
|
||||
let note = null;
|
||||
let mode = 'view';
|
||||
if (!this.props.noteId) {
|
||||
note = this.props.itemType == 'todo' ? Note.newTodo(this.props.folderId) : Note.new(this.props.folderId);
|
||||
mode = 'edit';
|
||||
} else {
|
||||
note = await Note.load(this.props.noteId);
|
||||
}
|
||||
|
||||
const folder = Folder.byId(this.props.folders, note.parent_id);
|
||||
|
||||
this.setState({
|
||||
lastSavedNote: Object.assign({}, note),
|
||||
note: note,
|
||||
mode: mode,
|
||||
folder: folder,
|
||||
isLoading: false,
|
||||
});
|
||||
await shared.initState(this);
|
||||
|
||||
this.refreshNoteMetadata();
|
||||
}
|
||||
|
||||
refreshNoteMetadata(force = null) {
|
||||
return shared.refreshNoteMetadata(this, force);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
BackButtonService.removeHandler(this.backHandler);
|
||||
}
|
||||
|
||||
noteComponent_change(propName, propValue) {
|
||||
let note = Object.assign({}, this.state.note);
|
||||
note[propName] = propValue;
|
||||
this.setState({ note: note });
|
||||
}
|
||||
|
||||
async refreshNoteMetadata(force = null) {
|
||||
if (force !== true && !this.state.showNoteMetadata) return;
|
||||
|
||||
let noteMetadata = await Note.serializeAllProps(this.state.note);
|
||||
this.setState({ noteMetadata: noteMetadata });
|
||||
}
|
||||
|
||||
title_changeText(text) {
|
||||
this.noteComponent_change('title', text);
|
||||
shared.noteComponent_change(this, 'title', text);
|
||||
}
|
||||
|
||||
body_changeText(text) {
|
||||
this.noteComponent_change('body', text);
|
||||
}
|
||||
|
||||
async noteExists(noteId) {
|
||||
const existingNote = await Note.load(noteId);
|
||||
return !!existingNote;
|
||||
shared.noteComponent_change(this, 'body', text);
|
||||
}
|
||||
|
||||
async saveNoteButton_press() {
|
||||
let note = Object.assign({}, this.state.note);
|
||||
await shared.saveNoteButton_press(this);
|
||||
|
||||
// Note has been deleted while user was modifying it. In that, we
|
||||
// just save a new note by clearing the note ID.
|
||||
if (note.id && !(await this.noteExists(note.id))) delete note.id;
|
||||
|
||||
reg.logger().info('Saving note: ', note);
|
||||
|
||||
if (!note.parent_id) {
|
||||
let folder = await Folder.defaultFolder();
|
||||
if (!folder) {
|
||||
Log.warn('Cannot save note without a notebook');
|
||||
return;
|
||||
}
|
||||
note.parent_id = folder.id;
|
||||
}
|
||||
|
||||
let isNew = !note.id;
|
||||
|
||||
if (isNew && !note.title) {
|
||||
note.title = Note.defaultTitle(note);
|
||||
}
|
||||
|
||||
note = await Note.save(note);
|
||||
this.setState({
|
||||
lastSavedNote: Object.assign({}, note),
|
||||
note: note,
|
||||
});
|
||||
if (isNew) Note.updateGeolocation(note.id);
|
||||
this.refreshNoteMetadata();
|
||||
Keyboard.dismiss();
|
||||
}
|
||||
|
||||
async saveOneProperty(name, value) {
|
||||
let note = Object.assign({}, this.state.note);
|
||||
|
||||
// Note has been deleted while user was modifying it. In that, we
|
||||
// just save a new note by clearing the note ID.
|
||||
if (note.id && !(await this.noteExists(note.id))) delete note.id;
|
||||
|
||||
reg.logger().info('Saving note property: ', note.id, name, value);
|
||||
|
||||
if (note.id) {
|
||||
let toSave = { id: note.id };
|
||||
toSave[name] = value;
|
||||
toSave = await Note.save(toSave);
|
||||
note[name] = toSave[name];
|
||||
|
||||
this.setState({
|
||||
lastSavedNote: Object.assign({}, note),
|
||||
note: note,
|
||||
});
|
||||
} else {
|
||||
note[name] = value;
|
||||
this.setState({ note: note });
|
||||
}
|
||||
await shared.saveOneProperty(this, name, value);
|
||||
}
|
||||
|
||||
async deleteNote_onPress() {
|
||||
@@ -269,9 +195,12 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
|
||||
async pickDocument() {
|
||||
return new Promise((resolve, reject) => {
|
||||
DocumentPicker.show({ filetype: [DocumentPickerUtil.images()] }, (error,res) => {
|
||||
DocumentPicker.show({ filetype: [DocumentPickerUtil.allFiles()] }, (error,res) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
// Also returns an error if the user doesn't pick a file
|
||||
// so just resolve with null.
|
||||
console.info('pickDocument error:', error);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -288,51 +217,100 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
});
|
||||
}
|
||||
|
||||
async attachFile_onPress() {
|
||||
const res = await this.pickDocument();
|
||||
showImagePicker(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
ImagePicker.showImagePicker(options, (response) => {
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const localFilePath = res.uri;
|
||||
async resizeImage(localFilePath, targetPath, mimeType) {
|
||||
const maxSize = Resource.IMAGE_MAX_DIMENSION;
|
||||
|
||||
let dimensions = await this.imageDimensions(localFilePath);
|
||||
|
||||
reg.logger().info('Original dimensions ', dimensions);
|
||||
if (dimensions.width > maxSize || dimensions.height > maxSize) {
|
||||
dimensions.width = maxSize;
|
||||
dimensions.height = maxSize;
|
||||
}
|
||||
reg.logger().info('New dimensions ', dimensions);
|
||||
|
||||
const format = mimeType == 'image/png' ? 'PNG' : 'JPEG';
|
||||
reg.logger().info('Resizing image ' + localFilePath);
|
||||
const resizedImage = await ImageResizer.createResizedImage(localFilePath, dimensions.width, dimensions.height, format, 85); //, 0, targetPath);
|
||||
|
||||
const resizedImagePath = resizedImage.uri;
|
||||
reg.logger().info('Resized image ', resizedImagePath);
|
||||
reg.logger().info('Moving ' + resizedImagePath + ' => ' + targetPath);
|
||||
|
||||
await RNFS.copyFile(resizedImagePath, targetPath);
|
||||
|
||||
try {
|
||||
await RNFS.unlink(resizedImagePath);
|
||||
} catch (error) {
|
||||
reg.logger().warn('Error when unlinking cached file: ', error);
|
||||
}
|
||||
}
|
||||
|
||||
async attachFile(pickerResponse, fileType) {
|
||||
if (!pickerResponse) {
|
||||
reg.logger().warn('Got no response from picker');
|
||||
return;
|
||||
}
|
||||
|
||||
if (pickerResponse.error) {
|
||||
reg.logger().warn('Got error from picker', pickerResponse.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pickerResponse.didCancel) {
|
||||
reg.logger().info('User cancelled picker');
|
||||
return;
|
||||
}
|
||||
|
||||
const localFilePath = pickerResponse.uri;
|
||||
let mimeType = pickerResponse.type;
|
||||
|
||||
if (!mimeType) {
|
||||
const ext = fileExtension(localFilePath);
|
||||
mimeType = mimeUtils.fromFileExtension(ext);
|
||||
}
|
||||
|
||||
if (!mimeType && fileType === 'image') {
|
||||
// Assume JPEG if we couldn't determine the file type. It seems to happen with the image picker
|
||||
// when the file path is something like content://media/external/images/media/123456
|
||||
// If the image is not a JPEG, something will throw an error below, but there's a good chance
|
||||
// it will work.
|
||||
reg.logger().info('Missing file type and could not detect it - assuming image/jpg');
|
||||
mimeType = 'image/jpg';
|
||||
}
|
||||
|
||||
reg.logger().info('Got file: ' + localFilePath);
|
||||
reg.logger().info('Got type: ' + res.type);
|
||||
|
||||
// res.uri,
|
||||
// res.type, // mime type
|
||||
// res.fileName,
|
||||
// res.fileSize
|
||||
reg.logger().info('Got type: ' + mimeType);
|
||||
|
||||
let resource = Resource.new();
|
||||
resource.id = uuid.create();
|
||||
resource.mime = res.type;
|
||||
resource.title = res.fileName ? res.fileName : _('Untitled');
|
||||
resource.mime = mimeType;
|
||||
resource.title = pickerResponse.fileName ? pickerResponse.fileName : _('Untitled');
|
||||
|
||||
let targetPath = Resource.fullPath(resource);
|
||||
|
||||
if (res.type == 'image/jpeg' || res.type == 'image/jpg' || res.type == 'image/png') {
|
||||
const maxSize = 1920;
|
||||
|
||||
let dimensions = await this.imageDimensions(localFilePath);
|
||||
|
||||
reg.logger().info('Original dimensions ', dimensions);
|
||||
if (dimensions.width > maxSize || dimensions.height > maxSize) {
|
||||
dimensions.width = maxSize;
|
||||
dimensions.height = maxSize;
|
||||
try {
|
||||
if (mimeType == 'image/jpeg' || mimeType == 'image/jpg' || mimeType == 'image/png') {
|
||||
await this.resizeImage(localFilePath, targetPath, pickerResponse.mime);
|
||||
} else {
|
||||
if (fileType === 'image') {
|
||||
dialogs.error(this, _('Unsupported image type: %s', mimeType));
|
||||
return;
|
||||
} else {
|
||||
await RNFetchBlob.fs.cp(localFilePath, targetPath);
|
||||
}
|
||||
}
|
||||
reg.logger().info('New dimensions ', dimensions);
|
||||
|
||||
const format = res.type == 'image/png' ? 'PNG' : 'JPEG';
|
||||
reg.logger().info('Resizing image ' + localFilePath);
|
||||
const resizedImagePath = await ImageResizer.createResizedImage(localFilePath, dimensions.width, dimensions.height, format, 85);
|
||||
reg.logger().info('Resized image ', resizedImagePath);
|
||||
RNFetchBlob.fs.cp(resizedImagePath, targetPath); // mv doesn't work ("source path does not exist") so need to do cp and unlink
|
||||
|
||||
try {
|
||||
RNFetchBlob.fs.unlink(resizedImagePath);
|
||||
} catch (error) {
|
||||
reg.logger().info('Error when unlinking cached file: ', error);
|
||||
}
|
||||
} else {
|
||||
RNFetchBlob.fs.cp(localFilePath, targetPath);
|
||||
} catch (error) {
|
||||
reg.logger().warn('Could not attach file:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
await Resource.save(resource, { isNew: true });
|
||||
@@ -344,10 +322,21 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
this.setState({ note: newNote });
|
||||
}
|
||||
|
||||
async attachImage_onPress() {
|
||||
const options = {
|
||||
mediaType: 'photo',
|
||||
};
|
||||
const response = await this.showImagePicker(options);
|
||||
await this.attachFile(response, 'image');
|
||||
}
|
||||
|
||||
async attachFile_onPress() {
|
||||
const response = await this.pickDocument();
|
||||
await this.attachFile(response, 'all');
|
||||
}
|
||||
|
||||
toggleIsTodo_onPress() {
|
||||
let newNote = Note.toggleIsTodo(this.state.note);
|
||||
let newState = { note: newNote };
|
||||
this.setState(newState);
|
||||
shared.toggleIsTodo_onPress(this);
|
||||
}
|
||||
|
||||
setAlarm_onPress() {
|
||||
@@ -371,8 +360,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
}
|
||||
|
||||
showMetadata_onPress() {
|
||||
this.setState({ showNoteMetadata: !this.state.showNoteMetadata });
|
||||
this.refreshNoteMetadata(true);
|
||||
shared.showMetadata_onPress(this);
|
||||
}
|
||||
|
||||
async showOnMap_onPress() {
|
||||
@@ -389,14 +377,31 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
|
||||
menuOptions() {
|
||||
const note = this.state.note;
|
||||
const isTodo = note && !!note.is_todo;
|
||||
|
||||
return [
|
||||
{ title: _('Attach file'), onPress: () => { this.attachFile_onPress(); } },
|
||||
{ title: _('Delete note'), onPress: () => { this.deleteNote_onPress(); } },
|
||||
{ title: note && !!note.is_todo ? _('Convert to regular note') : _('Convert to todo'), onPress: () => { this.toggleIsTodo_onPress(); } },
|
||||
{ title: this.state.showNoteMetadata ? _('Hide metadata') : _('Show metadata'), onPress: () => { this.showMetadata_onPress(); } },
|
||||
{ title: _('View location on map'), onPress: () => { this.showOnMap_onPress(); } },
|
||||
];
|
||||
let output = [];
|
||||
|
||||
// The file attachement modules only work in Android >= 5 (Version 21)
|
||||
// https://github.com/react-community/react-native-image-picker/issues/606
|
||||
let canAttachPicture = true;
|
||||
if (Platform.OS === 'android' && Platform.Version < 21) canAttachPicture = false;
|
||||
if (canAttachPicture) {
|
||||
output.push({ title: _('Attach image'), onPress: () => { this.attachImage_onPress(); } });
|
||||
output.push({ title: _('Attach any other file'), onPress: () => { this.attachFile_onPress(); } });
|
||||
}
|
||||
output.push({ title: _('Delete note'), onPress: () => { this.deleteNote_onPress(); } });
|
||||
output.push({ title: _('Alarm'), onPress: () => { this.setState({ alarmDialogShown: true }) }});;
|
||||
|
||||
// if (isTodo) {
|
||||
// let text = note.todo_due ? _('Edit/Clear alarm') : _('Set an alarm');
|
||||
// output.push({ title: text, onPress: () => { this.setAlarm_onPress(); } });
|
||||
// }
|
||||
|
||||
output.push({ title: isTodo ? _('Convert to regular note') : _('Convert to todo'), onPress: () => { this.toggleIsTodo_onPress(); } });
|
||||
if (this.props.showAdvancedOptions) output.push({ title: this.state.showNoteMetadata ? _('Hide metadata') : _('Show metadata'), onPress: () => { this.showMetadata_onPress(); } });
|
||||
output.push({ title: _('View location on map'), onPress: () => { this.showOnMap_onPress(); } });
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
async todoCheckbox_change(checked) {
|
||||
@@ -404,6 +409,8 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
}
|
||||
|
||||
titleTextInput_contentSizeChange(event) {
|
||||
if (!this.enableMultilineTitle_) return;
|
||||
|
||||
let height = event.nativeEvent.contentSize.height;
|
||||
this.setState({ titleTextInputHeight: height });
|
||||
}
|
||||
@@ -432,6 +439,9 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
bodyComponent = <NoteBodyViewer style={this.styles().noteBodyViewer} webViewStyle={theme} note={note} onCheckboxChange={(newBody) => { onCheckboxChange(newBody) }}/>
|
||||
} else {
|
||||
const focusBody = !isNew && !!note.title;
|
||||
|
||||
// Note: blurOnSubmit is necessary to get multiline to work.
|
||||
// See https://github.com/facebook/react-native/issues/12717#issuecomment-327001997
|
||||
bodyComponent = (
|
||||
<TextInput
|
||||
autoCapitalize="sentences"
|
||||
@@ -440,6 +450,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
multiline={true}
|
||||
value={note.body}
|
||||
onChangeText={(text) => this.body_changeText(text)}
|
||||
blurOnSubmit={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -460,15 +471,6 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
return <ActionButton multiStates={true} buttons={buttons} buttonIndex={0} />
|
||||
}
|
||||
|
||||
const titlePickerItems = () => {
|
||||
let output = [];
|
||||
for (let i = 0; i < this.props.folders.length; i++) {
|
||||
let f = this.props.folders[i];
|
||||
output.push({ label: f.title, value: f.id });
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
const actionButtonComp = renderActionButton();
|
||||
|
||||
let showSaveButton = this.state.mode == 'edit' || this.isModified() || this.saveButtonHasBeenShown_;
|
||||
@@ -485,14 +487,18 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
backgroundColor: theme.backgroundColor,
|
||||
fontWeight: 'bold',
|
||||
fontSize: theme.fontSize,
|
||||
paddingTop: 10, // Added for iOS (Not needed for Android??)
|
||||
paddingBottom: 10, // Added for iOS (Not needed for Android??)
|
||||
};
|
||||
|
||||
titleTextInputStyle.height = this.state.titleTextInputHeight;
|
||||
if (this.enableMultilineTitle_) titleTextInputStyle.height = this.state.titleTextInputHeight;
|
||||
|
||||
let checkboxStyle = {
|
||||
color: theme.color,
|
||||
paddingRight: 10,
|
||||
paddingLeft: theme.marginLeft,
|
||||
paddingTop: 10, // Added for iOS (Not needed for Android??)
|
||||
paddingBottom: 10, // Added for iOS (Not needed for Android??)
|
||||
}
|
||||
|
||||
const dueDate = isTodo && note.todo_due ? new Date(note.todo_due) : null;
|
||||
@@ -503,7 +509,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
<TextInput
|
||||
onContentSizeChange={(event) => this.titleTextInput_contentSizeChange(event)}
|
||||
autoFocus={isNew}
|
||||
multiline={true}
|
||||
multiline={this.enableMultilineTitle_}
|
||||
underlineColorAndroid="#ffffff00"
|
||||
autoCapitalize="sentences"
|
||||
style={titleTextInputStyle}
|
||||
@@ -516,19 +522,10 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
return (
|
||||
<View style={this.rootStyle(this.props.theme).root}>
|
||||
<ScreenHeader
|
||||
titlePicker={{
|
||||
items: titlePickerItems(),
|
||||
selectedValue: folder ? folder.id : null,
|
||||
folderPickerOptions={{
|
||||
enabled: true,
|
||||
selectedFolderId: folder ? folder.id : null,
|
||||
onValueChange: async (itemValue, itemIndex) => {
|
||||
let note = Object.assign({}, this.state.note);
|
||||
|
||||
// RN bug: https://github.com/facebook/react-native/issues/9220
|
||||
// The Picker fires the onValueChange when the component is initialized
|
||||
// so we need to check that it has actually changed.
|
||||
if (note.parent_id == itemValue) return;
|
||||
|
||||
reg.logger().info('Moving note: ' + note.parent_id + ' => ' + itemValue);
|
||||
|
||||
if (note.id) await Note.moveToFolder(note.id, itemValue);
|
||||
note.parent_id = itemValue;
|
||||
|
||||
@@ -539,7 +536,7 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
note: note,
|
||||
folder: folder,
|
||||
});
|
||||
}
|
||||
},
|
||||
}}
|
||||
menuOptions={this.menuOptions()}
|
||||
showSaveButton={showSaveButton}
|
||||
@@ -568,13 +565,14 @@ class NoteScreenComponent extends BaseScreenComponent {
|
||||
const NoteScreen = connect(
|
||||
(state) => {
|
||||
return {
|
||||
noteId: state.selectedNoteId,
|
||||
noteId: state.selectedNoteIds.length ? state.selectedNoteIds[0] : null,
|
||||
folderId: state.selectedFolderId,
|
||||
itemType: state.selectedItemType,
|
||||
folders: state.folders,
|
||||
theme: state.settings.theme,
|
||||
showAdvancedOptions: state.settings.showAdvancedOptions,
|
||||
};
|
||||
}
|
||||
)(NoteScreenComponent)
|
||||
|
||||
export { NoteScreen };
|
||||
module.exports = { NoteScreen };
|
||||
@@ -1,21 +1,21 @@
|
||||
import React, { Component } from 'react';
|
||||
import { View, Button, Picker } from 'react-native';
|
||||
import { connect } from 'react-redux'
|
||||
import { reg } from 'lib/registry.js';
|
||||
import { Log } from 'lib/log.js'
|
||||
import { NoteList } from 'lib/components/note-list.js'
|
||||
import { Folder } from 'lib/models/folder.js'
|
||||
import { Tag } from 'lib/models/tag.js'
|
||||
import { Note } from 'lib/models/note.js'
|
||||
import { Setting } from 'lib/models/setting.js'
|
||||
import { themeStyle } from 'lib/components/global-style.js';
|
||||
import { ScreenHeader } from 'lib/components/screen-header.js';
|
||||
import { MenuOption, Text } from 'react-native-popup-menu';
|
||||
import { _ } from 'lib/locale.js';
|
||||
import { ActionButton } from 'lib/components/action-button.js';
|
||||
import { dialogs } from 'lib/dialogs.js';
|
||||
import DialogBox from 'react-native-dialogbox';
|
||||
import { BaseScreenComponent } from 'lib/components/base-screen.js';
|
||||
const React = require('react'); const Component = React.Component;
|
||||
const { View, Button } = require('react-native');
|
||||
const { connect } = require('react-redux');
|
||||
const { reg } = require('lib/registry.js');
|
||||
const { Log } = require('lib/log.js');
|
||||
const { NoteList } = require('lib/components/note-list.js');
|
||||
const { Folder } = require('lib/models/folder.js');
|
||||
const { Tag } = require('lib/models/tag.js');
|
||||
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 { _ } = require('lib/locale.js');
|
||||
const { ActionButton } = require('lib/components/action-button.js');
|
||||
const { dialogs } = require('lib/dialogs.js');
|
||||
const DialogBox = require('react-native-dialogbox').default;
|
||||
const { BaseScreenComponent } = require('lib/components/base-screen.js');
|
||||
|
||||
class NotesScreenComponent extends BaseScreenComponent {
|
||||
|
||||
@@ -62,7 +62,7 @@ class NotesScreenComponent extends BaseScreenComponent {
|
||||
}
|
||||
|
||||
this.props.dispatch({
|
||||
type: 'NOTES_UPDATE_ALL',
|
||||
type: 'NOTE_UPDATE_ALL',
|
||||
notes: notes,
|
||||
notesSource: source,
|
||||
});
|
||||
@@ -142,12 +142,22 @@ class NotesScreenComponent extends BaseScreenComponent {
|
||||
|
||||
let title = parent ? parent.title : null;
|
||||
const addFolderNoteButtons = this.props.selectedFolderId && this.props.selectedFolderId != Folder.conflictFolderId();
|
||||
const thisComp = this;
|
||||
const actionButtonComp = this.props.noteSelectionEnabled ? null : <ActionButton addFolderNoteButtons={addFolderNoteButtons} parentFolderId={this.props.selectedFolderId}></ActionButton>
|
||||
|
||||
return (
|
||||
<View style={rootStyle}>
|
||||
<ScreenHeader title={title} menuOptions={this.menuOptions()} />
|
||||
<ScreenHeader
|
||||
title={title}
|
||||
menuOptions={this.menuOptions()}
|
||||
parentComponent={thisComp}
|
||||
folderPickerOptions={{
|
||||
enabled: this.props.noteSelectionEnabled,
|
||||
mustSelect: true,
|
||||
}}
|
||||
/>
|
||||
<NoteList style={{flex: 1}}/>
|
||||
<ActionButton addFolderNoteButtons={addFolderNoteButtons} parentFolderId={this.props.selectedFolderId}></ActionButton>
|
||||
{ actionButtonComp }
|
||||
<DialogBox ref={dialogbox => { this.dialogbox = dialogbox }}/>
|
||||
</View>
|
||||
);
|
||||
@@ -160,6 +170,7 @@ const NotesScreen = connect(
|
||||
folders: state.folders,
|
||||
tags: state.tags,
|
||||
selectedFolderId: state.selectedFolderId,
|
||||
selectedNoteIds: state.selectedNoteIds,
|
||||
selectedTagId: state.selectedTagId,
|
||||
notesParentType: state.notesParentType,
|
||||
notes: state.notes,
|
||||
@@ -167,8 +178,9 @@ const NotesScreen = connect(
|
||||
notesSource: state.notesSource,
|
||||
uncompletedTodosOnTop: state.settings.uncompletedTodosOnTop,
|
||||
theme: state.settings.theme,
|
||||
noteSelectionEnabled: state.noteSelectionEnabled,
|
||||
};
|
||||
}
|
||||
)(NotesScreenComponent)
|
||||
|
||||
export { NotesScreen };
|
||||
module.exports = { NotesScreen };
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { Component } from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { WebView, Button, Text } from 'react-native';
|
||||
import { connect } from 'react-redux'
|
||||
import { Log } from 'lib/log.js'
|
||||
import { Setting } from 'lib/models/setting.js'
|
||||
import { ScreenHeader } from 'lib/components/screen-header.js';
|
||||
import { reg } from 'lib/registry.js';
|
||||
import { _ } from 'lib/locale.js';
|
||||
import { BaseScreenComponent } from 'lib/components/base-screen.js';
|
||||
const React = require('react'); const Component = React.Component;
|
||||
const { View } = require('react-native');
|
||||
const { WebView, Button, Text } = require('react-native');
|
||||
const { connect } = require('react-redux');
|
||||
const { Log } = require('lib/log.js');
|
||||
const { Setting } = require('lib/models/setting.js');
|
||||
const { ScreenHeader } = require('lib/components/screen-header.js');
|
||||
const { reg } = require('lib/registry.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const { BaseScreenComponent } = require('lib/components/base-screen.js');
|
||||
|
||||
class OneDriveLoginScreenComponent extends BaseScreenComponent {
|
||||
|
||||
@@ -28,11 +28,11 @@ class OneDriveLoginScreenComponent extends BaseScreenComponent {
|
||||
}
|
||||
|
||||
startUrl() {
|
||||
return reg.oneDriveApi().authCodeUrl(this.redirectUrl());
|
||||
return reg.syncTarget().api().authCodeUrl(this.redirectUrl());
|
||||
}
|
||||
|
||||
redirectUrl() {
|
||||
return 'https://login.microsoftonline.com/common/oauth2/nativeclient';
|
||||
return reg.syncTarget().api().nativeClientRedirectUrl();
|
||||
}
|
||||
|
||||
async webview_load(noIdeaWhatThisIs) {
|
||||
@@ -48,7 +48,7 @@ class OneDriveLoginScreenComponent extends BaseScreenComponent {
|
||||
this.authCode_ = code[1];
|
||||
|
||||
try {
|
||||
await reg.oneDriveApi().execTokenRequest(this.authCode_, this.redirectUrl(), true);
|
||||
await reg.syncTarget().api().execTokenRequest(this.authCode_, this.redirectUrl(), true);
|
||||
this.props.dispatch({ type: 'NAV_BACK' });
|
||||
reg.scheduleSync(0);
|
||||
} catch (error) {
|
||||
@@ -107,4 +107,4 @@ const OneDriveLoginScreen = connect(
|
||||
}
|
||||
)(OneDriveLoginScreenComponent)
|
||||
|
||||
export { OneDriveLoginScreen };
|
||||
module.exports = { OneDriveLoginScreen };
|
||||
@@ -1,13 +1,15 @@
|
||||
import React, { Component } from 'react';
|
||||
import { ListView, StyleSheet, View, TextInput, FlatList, TouchableHighlight } from 'react-native';
|
||||
import { connect } from 'react-redux'
|
||||
import { ScreenHeader } from 'lib/components/screen-header.js';
|
||||
import Icon from 'react-native-vector-icons/Ionicons';
|
||||
import { _ } from 'lib/locale.js';
|
||||
import { Note } from 'lib/models/note.js';
|
||||
import { NoteItem } from 'lib/components/note-item.js';
|
||||
import { BaseScreenComponent } from 'lib/components/base-screen.js';
|
||||
import { themeStyle } from 'lib/components/global-style.js';
|
||||
const React = require('react'); const Component = React.Component;
|
||||
const { ListView, StyleSheet, View, TextInput, FlatList, TouchableHighlight } = require('react-native');
|
||||
const { connect } = require('react-redux');
|
||||
const { ScreenHeader } = require('lib/components/screen-header.js');
|
||||
const Icon = require('react-native-vector-icons/Ionicons').default;
|
||||
const { _ } = require('lib/locale.js');
|
||||
const { Note } = require('lib/models/note.js');
|
||||
const { NoteItem } = require('lib/components/note-item.js');
|
||||
const { BaseScreenComponent } = require('lib/components/base-screen.js');
|
||||
const { themeStyle } = require('lib/components/global-style.js');
|
||||
const { dialogs } = require('lib/dialogs.js');
|
||||
const DialogBox = require('react-native-dialogbox').default;
|
||||
|
||||
class SearchScreenComponent extends BaseScreenComponent {
|
||||
|
||||
@@ -139,9 +141,18 @@ class SearchScreenComponent extends BaseScreenComponent {
|
||||
rootStyle.flex = 0.001; // This is a bit of a hack but it seems to work fine - it makes the component invisible but without unmounting it
|
||||
}
|
||||
|
||||
const thisComponent = this;
|
||||
|
||||
return (
|
||||
<View style={rootStyle}>
|
||||
<ScreenHeader title={_('Search')}/>
|
||||
<ScreenHeader
|
||||
title={_('Search')}
|
||||
parentComponent={thisComponent}
|
||||
folderPickerOptions={{
|
||||
enabled: this.props.noteSelectionEnabled,
|
||||
mustSelect: true,
|
||||
}}
|
||||
/>
|
||||
<View style={this.styles().body}>
|
||||
<View style={this.styles().searchContainer}>
|
||||
<TextInput
|
||||
@@ -163,6 +174,7 @@ class SearchScreenComponent extends BaseScreenComponent {
|
||||
renderItem={(event) => <NoteItem note={event.item}/>}
|
||||
/>
|
||||
</View>
|
||||
<DialogBox ref={dialogbox => { this.dialogbox = dialogbox }}/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -174,8 +186,9 @@ const SearchScreen = connect(
|
||||
return {
|
||||
query: state.searchQuery,
|
||||
theme: state.settings.theme,
|
||||
noteSelectionEnabled: state.noteSelectionEnabled,
|
||||
};
|
||||
}
|
||||
)(SearchScreenComponent)
|
||||
|
||||
export { SearchScreen };
|
||||
module.exports = { SearchScreen };
|
||||
@@ -1,19 +1,19 @@
|
||||
import React, { Component } from 'react';
|
||||
import { ListView, StyleSheet, View, Text, Button, FlatList } from 'react-native';
|
||||
import { Setting } from 'lib/models/setting.js';
|
||||
import { connect } from 'react-redux'
|
||||
import { Log } from 'lib/log.js'
|
||||
import { reg } from 'lib/registry.js'
|
||||
import { ScreenHeader } from 'lib/components/screen-header.js';
|
||||
import { time } from 'lib/time-utils'
|
||||
import { Logger } from 'lib/logger.js';
|
||||
import { BaseItem } from 'lib/models/base-item.js';
|
||||
import { Database } from 'lib/database.js';
|
||||
import { Folder } from 'lib/models/folder.js';
|
||||
import { ReportService } from 'lib/services/report.js';
|
||||
import { _ } from 'lib/locale.js';
|
||||
import { BaseScreenComponent } from 'lib/components/base-screen.js';
|
||||
import { globalStyle, themeStyle } from 'lib/components/global-style.js';
|
||||
const React = require('react'); const Component = React.Component;
|
||||
const { ListView, StyleSheet, View, Text, Button, FlatList } = require('react-native');
|
||||
const { Setting } = require('lib/models/setting.js');
|
||||
const { connect } = require('react-redux');
|
||||
const { Log } = require('lib/log.js');
|
||||
const { reg } = require('lib/registry.js');
|
||||
const { ScreenHeader } = require('lib/components/screen-header.js');
|
||||
const { time } = require('lib/time-utils');
|
||||
const { Logger } = require('lib/logger.js');
|
||||
const { BaseItem } = require('lib/models/base-item.js');
|
||||
const { Database } = require('lib/database.js');
|
||||
const { Folder } = require('lib/models/folder.js');
|
||||
const { ReportService } = require('lib/services/report.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const { BaseScreenComponent } = require('lib/components/base-screen.js');
|
||||
const { globalStyle, themeStyle } = require('lib/components/global-style.js');
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
body: {
|
||||
@@ -119,4 +119,4 @@ const StatusScreen = connect(
|
||||
}
|
||||
)(StatusScreenComponent)
|
||||
|
||||
export { StatusScreen };
|
||||
module.exports = { StatusScreen };
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { Component } from 'react';
|
||||
import { ListView, StyleSheet, View, TextInput, FlatList, TouchableHighlight } from 'react-native';
|
||||
import { connect } from 'react-redux'
|
||||
import { ScreenHeader } from 'lib/components/screen-header.js';
|
||||
import Icon from 'react-native-vector-icons/Ionicons';
|
||||
import { _ } from 'lib/locale.js';
|
||||
import { Note } from 'lib/models/note.js';
|
||||
import { NoteItem } from 'lib/components/note-item.js';
|
||||
import { BaseScreenComponent } from 'lib/components/base-screen.js';
|
||||
import { globalStyle } from 'lib/components/global-style.js';
|
||||
const React = require('react'); const Component = React.Component;
|
||||
const { ListView, StyleSheet, View, TextInput, FlatList, TouchableHighlight } = require('react-native');
|
||||
const { connect } = require('react-redux');
|
||||
const { ScreenHeader } = require('lib/components/screen-header.js');
|
||||
const Icon = require('react-native-vector-icons/Ionicons').default;
|
||||
const { _ } = require('lib/locale.js');
|
||||
const { Note } = require('lib/models/note.js');
|
||||
const { NoteItem } = require('lib/components/note-item.js');
|
||||
const { BaseScreenComponent } = require('lib/components/base-screen.js');
|
||||
const { globalStyle } = require('lib/components/global-style.js');
|
||||
|
||||
let styles = {
|
||||
body: {
|
||||
@@ -40,7 +40,7 @@ class TagScreenComponent extends BaseScreenComponent {
|
||||
const notes = await Tag.notes(props.selectedTagId);
|
||||
|
||||
this.props.dispatch({
|
||||
type: 'NOTES_UPDATE_ALL',
|
||||
type: 'NOTE_UPDATE_ALL',
|
||||
notes: notes,
|
||||
notesSource: source,
|
||||
});
|
||||
@@ -73,4 +73,4 @@ const TagScreen = connect(
|
||||
}
|
||||
)(TagScreenComponent)
|
||||
|
||||
export { TagScreen };
|
||||
module.exports = { TagScreen };
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { Component } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { connect } from 'react-redux'
|
||||
import { Log } from 'lib/log.js'
|
||||
import { ScreenHeader } from 'lib/components/screen-header.js';
|
||||
import { ActionButton } from 'lib/components/action-button.js';
|
||||
import { BaseScreenComponent } from 'lib/components/base-screen.js';
|
||||
import { _ } from 'lib/locale.js';
|
||||
import { themeStyle } from 'lib/components/global-style.js';
|
||||
const React = require('react'); const Component = React.Component;
|
||||
const { View, Text, StyleSheet } = require('react-native');
|
||||
const { connect } = require('react-redux');
|
||||
const { Log } = require('lib/log.js');
|
||||
const { ScreenHeader } = require('lib/components/screen-header.js');
|
||||
const { ActionButton } = require('lib/components/action-button.js');
|
||||
const { BaseScreenComponent } = require('lib/components/base-screen.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const { themeStyle } = require('lib/components/global-style.js');
|
||||
|
||||
class WelcomeScreenComponent extends BaseScreenComponent {
|
||||
|
||||
@@ -61,4 +61,4 @@ const WelcomeScreen = connect(
|
||||
}
|
||||
)(WelcomeScreenComponent)
|
||||
|
||||
export { WelcomeScreen };
|
||||
module.exports = { WelcomeScreen };
|
||||
125
ReactNativeClient/lib/components/shared/note-screen-shared.js
Normal file
125
ReactNativeClient/lib/components/shared/note-screen-shared.js
Normal file
@@ -0,0 +1,125 @@
|
||||
const { reg } = require('lib/registry.js');
|
||||
const { Folder } = require('lib/models/folder.js');
|
||||
const { BaseModel } = require('lib/base-model.js');
|
||||
const { Note } = require('lib/models/note.js');
|
||||
|
||||
const shared = {};
|
||||
|
||||
shared.noteExists = async function(noteId) {
|
||||
const existingNote = await Note.load(noteId);
|
||||
return !!existingNote;
|
||||
}
|
||||
|
||||
shared.saveNoteButton_press = async function(comp) {
|
||||
let note = Object.assign({}, comp.state.note);
|
||||
|
||||
// Note has been deleted while user was modifying it. In that, we
|
||||
// just save a new note by clearing the note ID.
|
||||
if (note.id && !(await shared.noteExists(note.id))) delete note.id;
|
||||
|
||||
reg.logger().info('Saving note: ', note);
|
||||
|
||||
if (!note.parent_id) {
|
||||
let folder = await Folder.defaultFolder();
|
||||
if (!folder) {
|
||||
//Log.warn('Cannot save note without a notebook');
|
||||
return;
|
||||
}
|
||||
note.parent_id = folder.id;
|
||||
}
|
||||
|
||||
let isNew = !note.id;
|
||||
|
||||
if (isNew && !note.title) {
|
||||
note.title = Note.defaultTitle(note);
|
||||
}
|
||||
|
||||
note = await Note.save(note);
|
||||
comp.setState({
|
||||
lastSavedNote: Object.assign({}, note),
|
||||
note: note,
|
||||
});
|
||||
if (isNew) Note.updateGeolocation(note.id);
|
||||
comp.refreshNoteMetadata();
|
||||
}
|
||||
|
||||
shared.saveOneProperty = async function(comp, name, value) {
|
||||
let note = Object.assign({}, comp.state.note);
|
||||
|
||||
// Note has been deleted while user was modifying it. In that, we
|
||||
// just save a new note by clearing the note ID.
|
||||
if (note.id && !(await shared.noteExists(note.id))) delete note.id;
|
||||
|
||||
reg.logger().info('Saving note property: ', note.id, name, value);
|
||||
|
||||
if (note.id) {
|
||||
let toSave = { id: note.id };
|
||||
toSave[name] = value;
|
||||
toSave = await Note.save(toSave);
|
||||
note[name] = toSave[name];
|
||||
|
||||
comp.setState({
|
||||
lastSavedNote: Object.assign({}, note),
|
||||
note: note,
|
||||
});
|
||||
} else {
|
||||
note[name] = value;
|
||||
comp.setState({ note: note });
|
||||
}
|
||||
}
|
||||
|
||||
shared.noteComponent_change = function(comp, propName, propValue) {
|
||||
let note = Object.assign({}, comp.state.note);
|
||||
note[propName] = propValue;
|
||||
comp.setState({ note: note });
|
||||
}
|
||||
|
||||
shared.refreshNoteMetadata = async function(comp, force = null) {
|
||||
if (force !== true && !comp.state.showNoteMetadata) return;
|
||||
|
||||
let noteMetadata = await Note.serializeAllProps(comp.state.note);
|
||||
comp.setState({ noteMetadata: noteMetadata });
|
||||
}
|
||||
|
||||
shared.isModified = function(comp) {
|
||||
if (!comp.state.note || !comp.state.lastSavedNote) return false;
|
||||
let diff = BaseModel.diffObjects(comp.state.note, comp.state.lastSavedNote);
|
||||
delete diff.type_;
|
||||
return !!Object.getOwnPropertyNames(diff).length;
|
||||
}
|
||||
|
||||
shared.initState = async function(comp) {
|
||||
let note = null;
|
||||
let mode = 'view';
|
||||
if (!comp.props.noteId) {
|
||||
note = comp.props.itemType == 'todo' ? Note.newTodo(comp.props.folderId) : Note.new(comp.props.folderId);
|
||||
mode = 'edit';
|
||||
} else {
|
||||
note = await Note.load(comp.props.noteId);
|
||||
}
|
||||
|
||||
const folder = Folder.byId(comp.props.folders, note.parent_id);
|
||||
|
||||
comp.setState({
|
||||
lastSavedNote: Object.assign({}, note),
|
||||
note: note,
|
||||
mode: mode,
|
||||
folder: folder,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
comp.lastLoadedNoteId_ = note ? note.id : null;
|
||||
}
|
||||
|
||||
shared.showMetadata_onPress = function(comp) {
|
||||
comp.setState({ showNoteMetadata: !comp.state.showNoteMetadata });
|
||||
comp.refreshNoteMetadata(true);
|
||||
}
|
||||
|
||||
shared.toggleIsTodo_onPress = function(comp) {
|
||||
let newNote = Note.toggleIsTodo(comp.state.note);
|
||||
let newState = { note: newNote };
|
||||
comp.setState(newState);
|
||||
}
|
||||
|
||||
module.exports = shared;
|
||||
65
ReactNativeClient/lib/components/shared/side-menu-shared.js
Normal file
65
ReactNativeClient/lib/components/shared/side-menu-shared.js
Normal file
@@ -0,0 +1,65 @@
|
||||
let shared = {};
|
||||
|
||||
shared.renderFolders = function(props, renderItem) {
|
||||
let items = [];
|
||||
for (let i = 0; i < props.folders.length; i++) {
|
||||
let folder = props.folders[i];
|
||||
items.push(renderItem(folder, props.selectedFolderId == folder.id && props.notesParentType == 'Folder'));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
shared.renderTags = function(props, renderItem) {
|
||||
let tags = props.tags.slice();
|
||||
tags.sort((a, b) => { return a.title < b.title ? -1 : +1; });
|
||||
let tagItems = [];
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
const tag = tags[i];
|
||||
tagItems.push(renderItem(tag, props.selectedTagId == tag.id && props.notesParentType == 'Tag'));
|
||||
}
|
||||
return tagItems;
|
||||
}
|
||||
|
||||
shared.renderSearches = function(props, renderItem) {
|
||||
let searches = props.searches.slice();
|
||||
let searchItems = [];
|
||||
for (let i = 0; i < searches.length; i++) {
|
||||
const search = searches[i];
|
||||
searchItems.push(renderItem(search, props.selectedSearchId == search.id && props.notesParentType == 'Search'));
|
||||
}
|
||||
return searchItems;
|
||||
}
|
||||
|
||||
shared.synchronize_press = async function(comp) {
|
||||
const { Setting } = require('lib/models/setting.js');
|
||||
const { reg } = require('lib/registry.js');
|
||||
|
||||
const action = comp.props.syncStarted ? 'cancel' : 'start';
|
||||
|
||||
if (!reg.syncTarget().isAuthenticated()) {
|
||||
comp.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'OneDriveLogin',
|
||||
});
|
||||
return 'auth';
|
||||
}
|
||||
|
||||
let sync = null;
|
||||
try {
|
||||
sync = await reg.syncTarget().synchronizer();
|
||||
} catch (error) {
|
||||
reg.logger().info('Could not acquire synchroniser:');
|
||||
reg.logger().info(error);
|
||||
return 'error';
|
||||
}
|
||||
|
||||
if (action == 'cancel') {
|
||||
sync.cancel();
|
||||
return 'cancel';
|
||||
} else {
|
||||
reg.scheduleSync(0);
|
||||
return 'sync';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = shared;
|
||||
@@ -1,16 +1,17 @@
|
||||
import React, { Component } from 'react';
|
||||
import { TouchableOpacity , Button, Text, Image, StyleSheet, ScrollView, View } from 'react-native';
|
||||
import { connect } from 'react-redux'
|
||||
import Icon from 'react-native-vector-icons/Ionicons';
|
||||
import { Log } from 'lib/log.js';
|
||||
import { Tag } from 'lib/models/tag.js';
|
||||
import { Note } from 'lib/models/note.js';
|
||||
import { Setting } from 'lib/models/setting.js';
|
||||
import { FoldersScreenUtils } from 'lib/components/screens/folders-utils.js'
|
||||
import { Synchronizer } from 'lib/synchronizer.js';
|
||||
import { reg } from 'lib/registry.js';
|
||||
import { _ } from 'lib/locale.js';
|
||||
import { globalStyle, themeStyle } from 'lib/components/global-style.js';
|
||||
const React = require('react'); const Component = React.Component;
|
||||
const { TouchableOpacity , Button, Text, Image, StyleSheet, ScrollView, View } = require('react-native');
|
||||
const { connect } = require('react-redux');
|
||||
const Icon = require('react-native-vector-icons/Ionicons').default;
|
||||
const { Log } = require('lib/log.js');
|
||||
const { Tag } = require('lib/models/tag.js');
|
||||
const { Note } = require('lib/models/note.js');
|
||||
const { Setting } = require('lib/models/setting.js');
|
||||
const { FoldersScreenUtils } = require('lib/folders-screen-utils.js');
|
||||
const { Synchronizer } = require('lib/synchronizer.js');
|
||||
const { reg } = require('lib/registry.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const { globalStyle, themeStyle } = require('lib/components/global-style.js');
|
||||
const shared = require('lib/components/shared/side-menu-shared.js');
|
||||
|
||||
class SideMenuContentComponent extends Component {
|
||||
|
||||
@@ -104,32 +105,8 @@ class SideMenuContentComponent extends Component {
|
||||
}
|
||||
|
||||
async synchronize_press() {
|
||||
const action = this.props.syncStarted ? 'cancel' : 'start';
|
||||
|
||||
if (Setting.value('sync.target') == Setting.SYNC_TARGET_ONEDRIVE && !reg.oneDriveApi().auth()) {
|
||||
this.props.dispatch({ type: 'SIDE_MENU_CLOSE' });
|
||||
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'OneDriveLogin',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let sync = null;
|
||||
try {
|
||||
sync = await reg.synchronizer(Setting.value('sync.target'))
|
||||
} catch (error) {
|
||||
reg.logger().info('Could not acquire synchroniser:');
|
||||
reg.logger().info(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action == 'cancel') {
|
||||
sync.cancel();
|
||||
} else {
|
||||
reg.scheduleSync(0);
|
||||
}
|
||||
const actionDone = await shared.synchronize_press(this);
|
||||
if (actionDone === 'auth') this.props.dispatch({ type: 'SIDE_MENU_CLOSE' });
|
||||
}
|
||||
|
||||
folderItem(folder, selected) {
|
||||
@@ -181,27 +158,20 @@ class SideMenuContentComponent extends Component {
|
||||
render() {
|
||||
let items = [];
|
||||
|
||||
const theme = themeStyle(this.props.theme);
|
||||
|
||||
// HACK: inner height of ScrollView doesn't appear to be calculated correctly when
|
||||
// using padding. So instead creating blank elements for padding bottom and top.
|
||||
items.push(<View style={{ height: globalStyle.marginTop }} key='bottom_top_hack'/>);
|
||||
|
||||
if (this.props.folders.length) {
|
||||
for (let i = 0; i < this.props.folders.length; i++) {
|
||||
let folder = this.props.folders[i];
|
||||
items.push(this.folderItem(folder, this.props.selectedFolderId == folder.id && this.props.notesParentType == 'Folder'));
|
||||
}
|
||||
|
||||
const folderItems = shared.renderFolders(this.props, this.folderItem.bind(this));
|
||||
items = items.concat(folderItems);
|
||||
if (items.length) items.push(this.makeDivider('divider_1'));
|
||||
}
|
||||
|
||||
if (this.props.tags.length) {
|
||||
let tags = this.props.tags.slice();
|
||||
tags.sort((a, b) => { return a.title < b.title ? -1 : +1; });
|
||||
let tagItems = [];
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
const tag = tags[i];
|
||||
tagItems.push(this.tagItem(tag, this.props.selectedTagId == tag.id && this.props.notesParentType == 'Tag'));
|
||||
}
|
||||
const tagItems = shared.renderTags(this.props, this.tagItem.bind(this));
|
||||
|
||||
items.push(
|
||||
<View style={this.styles().tagItemList} key="tag_items">
|
||||
@@ -222,14 +192,23 @@ class SideMenuContentComponent extends Component {
|
||||
|
||||
items.push(<View style={{ height: globalStyle.marginBottom }} key='bottom_padding_hack'/>);
|
||||
|
||||
let style = {
|
||||
flex:1,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: globalStyle.dividerColor,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{flex:1, borderRightWidth: 1, borderRightColor: globalStyle.dividerColor }}>
|
||||
<View style={{flexDirection:'row'}}>
|
||||
<Image style={{flex:1, height: 100}} source={require('../images/SideMenuHeader.png')} />
|
||||
<View style={style}>
|
||||
<View style={{flex:1, opacity: this.props.opacity}}>
|
||||
<View style={{flexDirection:'row'}}>
|
||||
<Image style={{flex:1, height: 100}} source={require('../images/SideMenuHeader.png')} />
|
||||
</View>
|
||||
<ScrollView scrollsToTop={false} style={this.styles().menu}>
|
||||
{ items }
|
||||
</ScrollView>
|
||||
</View>
|
||||
<ScrollView scrollsToTop={false} style={this.styles().menu}>
|
||||
{ items }
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -247,8 +226,9 @@ const SideMenuContent = connect(
|
||||
notesParentType: state.notesParentType,
|
||||
locale: state.settings.locale,
|
||||
theme: state.settings.theme,
|
||||
opacity: state.sideMenuOpenPercent,
|
||||
};
|
||||
}
|
||||
)(SideMenuContentComponent)
|
||||
|
||||
export { SideMenuContent };
|
||||
module.exports = { SideMenuContent };
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux'
|
||||
import { Log } from 'lib/log.js';
|
||||
import SideMenu_ from 'react-native-side-menu';
|
||||
const React = require('react'); const Component = React.Component;
|
||||
const { connect } = require('react-redux');
|
||||
const { Log } = require('lib/log.js');
|
||||
const SideMenu_ = require('react-native-side-menu').default;
|
||||
|
||||
class SideMenuComponent extends SideMenu_ {};
|
||||
|
||||
const SideMenu = connect(
|
||||
const MySideMenu = connect(
|
||||
(state) => {
|
||||
return {
|
||||
isOpen: state.showSideMenu,
|
||||
@@ -13,4 +13,4 @@ const SideMenu = connect(
|
||||
}
|
||||
)(SideMenuComponent)
|
||||
|
||||
export { SideMenu };
|
||||
module.exports = { SideMenu: MySideMenu };
|
||||
@@ -69,4 +69,4 @@ class DatabaseDriverNode {
|
||||
|
||||
}
|
||||
|
||||
export { DatabaseDriverNode };
|
||||
module.exports = { DatabaseDriverNode };
|
||||
@@ -1,4 +1,4 @@
|
||||
import SQLite from 'react-native-sqlite-storage';
|
||||
const SQLite = require('react-native-sqlite-storage');
|
||||
|
||||
class DatabaseDriverReactNative {
|
||||
|
||||
@@ -54,4 +54,4 @@ class DatabaseDriverReactNative {
|
||||
|
||||
}
|
||||
|
||||
export { DatabaseDriverReactNative }
|
||||
module.exports = { DatabaseDriverReactNative };
|
||||
@@ -1,8 +1,8 @@
|
||||
import { uuid } from 'lib/uuid.js';
|
||||
import { promiseChain } from 'lib/promise-utils.js';
|
||||
import { Logger } from 'lib/logger.js'
|
||||
import { time } from 'lib/time-utils.js'
|
||||
import { sprintf } from 'sprintf-js';
|
||||
const { uuid } = require('lib/uuid.js');
|
||||
const { promiseChain } = require('lib/promise-utils.js');
|
||||
const { Logger } = require('lib/logger.js');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const { sprintf } = require('sprintf-js');
|
||||
|
||||
class Database {
|
||||
|
||||
@@ -12,6 +12,11 @@ class Database {
|
||||
this.inTransaction_ = false;
|
||||
|
||||
this.logger_ = new Logger();
|
||||
this.logExcludedQueryTypes_ = [];
|
||||
}
|
||||
|
||||
setLogExcludedQueryTypes(v) {
|
||||
this.logExcludedQueryTypes_ = v;
|
||||
}
|
||||
|
||||
// Converts the SQLite error to a regular JS error
|
||||
@@ -185,6 +190,13 @@ class Database {
|
||||
}
|
||||
|
||||
logQuery(sql, params = null) {
|
||||
if (this.logExcludedQueryTypes_.length) {
|
||||
const temp = sql.toLowerCase();
|
||||
for (let i = 0; i < this.logExcludedQueryTypes_.length; i++) {
|
||||
if (temp.indexOf(this.logExcludedQueryTypes_[i].toLowerCase()) === 0) return;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger().debug(sql);
|
||||
if (params !== null && params.length) this.logger().debug(JSON.stringify(params));
|
||||
}
|
||||
@@ -298,4 +310,4 @@ Database.TYPE_INT = 1;
|
||||
Database.TYPE_TEXT = 2;
|
||||
Database.TYPE_NUMERIC = 3;
|
||||
|
||||
export { Database };
|
||||
module.exports = { Database };
|
||||
@@ -1,5 +1,5 @@
|
||||
import DialogBox from 'react-native-dialogbox';
|
||||
import { Keyboard } from 'react-native';
|
||||
const DialogBox = require('react-native-dialogbox').default;
|
||||
const { Keyboard } = require('react-native');
|
||||
|
||||
// Add this at the bottom of the component:
|
||||
//
|
||||
@@ -8,7 +8,8 @@ import { Keyboard } from 'react-native';
|
||||
let dialogs = {};
|
||||
|
||||
dialogs.confirm = (parentComponent, message) => {
|
||||
if (!'dialogbox' in parentComponent) throw new Error('A "dialogbox" component must be defined on the parent component!');
|
||||
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!');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
Keyboard.dismiss();
|
||||
@@ -33,7 +34,8 @@ dialogs.confirm = (parentComponent, message) => {
|
||||
};
|
||||
|
||||
dialogs.pop = (parentComponent, message, buttons) => {
|
||||
if (!'dialogbox' in parentComponent) throw new Error('A "dialogbox" component must be defined on the parent component!');
|
||||
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!');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
Keyboard.dismiss();
|
||||
@@ -63,4 +65,4 @@ dialogs.error = (parentComponent, message) => {
|
||||
|
||||
dialogs.DialogBox = DialogBox
|
||||
|
||||
export { dialogs };
|
||||
module.exports = { dialogs };
|
||||
@@ -32,4 +32,4 @@ class EventDispatcher {
|
||||
|
||||
}
|
||||
|
||||
export { EventDispatcher };
|
||||
module.exports = { EventDispatcher };
|
||||
@@ -1,8 +1,8 @@
|
||||
import fs from 'fs-extra';
|
||||
import { promiseChain } from 'lib/promise-utils.js';
|
||||
import moment from 'moment';
|
||||
import { BaseItem } from 'lib/models/base-item.js';
|
||||
import { time } from 'lib/time-utils.js';
|
||||
const fs = require('fs-extra');
|
||||
const { promiseChain } = require('lib/promise-utils.js');
|
||||
const moment = require('moment');
|
||||
const { BaseItem } = require('lib/models/base-item.js');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
|
||||
// NOTE: when synchronising with the file system the time resolution is the second (unlike milliseconds for OneDrive for instance).
|
||||
// What it means is that if, for example, client 1 changes a note at time t, and client 2 changes the same note within the same second,
|
||||
@@ -26,10 +26,6 @@ class FileApiDriverLocal {
|
||||
return output;
|
||||
}
|
||||
|
||||
supportsDelta() {
|
||||
return false;
|
||||
}
|
||||
|
||||
stat(path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.stat(path, (error, s) => {
|
||||
@@ -79,6 +75,8 @@ class FileApiDriverLocal {
|
||||
}
|
||||
|
||||
async delta(path, options) {
|
||||
const itemIds = await options.allItemIdsHandler();
|
||||
|
||||
try {
|
||||
let items = await fs.readdir(path);
|
||||
let output = [];
|
||||
@@ -89,11 +87,11 @@ class FileApiDriverLocal {
|
||||
output.push(stat);
|
||||
}
|
||||
|
||||
if (!Array.isArray(options.itemIds)) throw new Error('Delta API not supported - local IDs must be provided');
|
||||
if (!Array.isArray(itemIds)) throw new Error('Delta API not supported - local IDs must be provided');
|
||||
|
||||
let deletedItems = [];
|
||||
for (let i = 0; i < options.itemIds.length; i++) {
|
||||
const itemId = options.itemIds[i];
|
||||
for (let i = 0; i < itemIds.length; i++) {
|
||||
const itemId = itemIds[i];
|
||||
let found = false;
|
||||
for (let j = 0; j < output.length; j++) {
|
||||
const item = output[j];
|
||||
@@ -237,4 +235,4 @@ class FileApiDriverLocal {
|
||||
|
||||
}
|
||||
|
||||
export { FileApiDriverLocal };
|
||||
module.exports = { FileApiDriverLocal };
|
||||
@@ -1,4 +1,4 @@
|
||||
import { time } from 'lib/time-utils.js';
|
||||
const { time } = require('lib/time-utils.js');
|
||||
|
||||
class FileApiDriverMemory {
|
||||
|
||||
@@ -7,10 +7,6 @@ class FileApiDriverMemory {
|
||||
this.deletedItems_ = [];
|
||||
}
|
||||
|
||||
supportsDelta() {
|
||||
return true;
|
||||
}
|
||||
|
||||
itemIndexByPath(path) {
|
||||
for (let i = 0; i < this.items_.length; i++) {
|
||||
if (this.items_[i].path == path) return i;
|
||||
@@ -169,4 +165,4 @@ class FileApiDriverMemory {
|
||||
|
||||
}
|
||||
|
||||
export { FileApiDriverMemory };
|
||||
module.exports = { FileApiDriverMemory };
|
||||
@@ -1,7 +1,7 @@
|
||||
import moment from 'moment';
|
||||
import { time } from 'lib/time-utils.js';
|
||||
import { dirname, basename } from 'lib/path-utils.js';
|
||||
import { OneDriveApi } from 'lib/onedrive-api.js';
|
||||
const moment = require('moment');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const { dirname, basename } = require('lib/path-utils.js');
|
||||
const { OneDriveApi } = require('lib/onedrive-api.js');
|
||||
|
||||
class FileApiDriverOneDrive {
|
||||
|
||||
@@ -14,10 +14,6 @@ class FileApiDriverOneDrive {
|
||||
return this.api_;
|
||||
}
|
||||
|
||||
supportsDelta() {
|
||||
return true;
|
||||
}
|
||||
|
||||
itemFilter_() {
|
||||
return {
|
||||
select: 'name,file,folder,fileSystemInfo,parentReference',
|
||||
@@ -197,11 +193,37 @@ class FileApiDriverOneDrive {
|
||||
|
||||
if (!url) {
|
||||
url = this.makePath_(path) + ':/delta';
|
||||
query = this.itemFilter_();
|
||||
const query = this.itemFilter_();
|
||||
query.select += ',deleted';
|
||||
}
|
||||
|
||||
let response = await this.api_.execJson('GET', url, query);
|
||||
let response = null;
|
||||
try {
|
||||
response = await this.api_.execJson('GET', url, query);
|
||||
} catch (error) {
|
||||
if (error.code === 'resyncRequired') {
|
||||
// Error: Resync required. Replace any local items with the server's version (including deletes) if you're sure that the service was up to date with your local changes when you last sync'd. Upload any local changes that the server doesn't know about.
|
||||
// Code: resyncRequired
|
||||
// Request: GET https://graph.microsoft.com/v1.0/drive/root:/Apps/JoplinDev:/delta?select=...
|
||||
|
||||
// The delta token has expired or is invalid and so a full resync is required.
|
||||
// It is an error that is hard to replicate and it's not entirely clear what
|
||||
// URL is in the Location header. What might happen is that:
|
||||
// - OneDrive will get all the latest changes (since delta is done at the
|
||||
// end of the sync process)
|
||||
// - Client will get all the new files and updates from OneDrive
|
||||
// This is unknown:
|
||||
// - Will the files that have been deleted on OneDrive be part of the this
|
||||
// URL in the Location header?
|
||||
//
|
||||
// More info there: https://stackoverflow.com/q/46941371/561309
|
||||
url = error.headers.get('location');
|
||||
response = await this.api_.execJson('GET', url, query);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let items = [];
|
||||
|
||||
// The delta API might return things that happen in subdirectories of the root and we don't want to
|
||||
@@ -252,4 +274,4 @@ class FileApiDriverOneDrive {
|
||||
|
||||
}
|
||||
|
||||
export { FileApiDriverOneDrive };
|
||||
module.exports = { FileApiDriverOneDrive };
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isHidden } from 'lib/path-utils.js';
|
||||
import { Logger } from 'lib/logger.js';
|
||||
const { isHidden } = require('lib/path-utils.js');
|
||||
const { Logger } = require('lib/logger.js');
|
||||
|
||||
class FileApi {
|
||||
|
||||
@@ -23,10 +23,6 @@ class FileApi {
|
||||
return this.syncTargetId_;
|
||||
}
|
||||
|
||||
supportsDelta() {
|
||||
return this.driver_.supportsDelta();
|
||||
}
|
||||
|
||||
setLogger(l) {
|
||||
this.logger_ = l;
|
||||
}
|
||||
@@ -113,4 +109,4 @@ class FileApi {
|
||||
|
||||
}
|
||||
|
||||
export { FileApi };
|
||||
module.exports = { FileApi };
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Folder } from 'lib/models/folder.js'
|
||||
const { Folder } = require('lib/models/folder.js');
|
||||
|
||||
class FoldersScreenUtils {
|
||||
|
||||
static async refreshFolders() {
|
||||
let initialFolders = await Folder.all({ includeConflictFolder: true });
|
||||
|
||||
this.dispatch({
|
||||
type: 'FOLDERS_UPDATE_ALL',
|
||||
type: 'FOLDER_UPDATE_ALL',
|
||||
folders: initialFolders,
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { FoldersScreenUtils }
|
||||
module.exports = { FoldersScreenUtils };
|
||||
@@ -7,4 +7,4 @@ class FsDriverDummy {
|
||||
|
||||
}
|
||||
|
||||
export { FsDriverDummy }
|
||||
module.exports = { FsDriverDummy };
|
||||
20
ReactNativeClient/lib/fs-driver-node.js
Normal file
20
ReactNativeClient/lib/fs-driver-node.js
Normal file
@@ -0,0 +1,20 @@
|
||||
const fs = require('fs-extra');
|
||||
|
||||
class FsDriverNode {
|
||||
|
||||
appendFileSync(path, string) {
|
||||
return fs.appendFileSync(path, string);
|
||||
}
|
||||
|
||||
writeBinaryFile(path, content) {
|
||||
let buffer = new Buffer(content);
|
||||
return fs.writeFile(path, buffer);
|
||||
}
|
||||
|
||||
readFile(path) {
|
||||
return fs.readFile(path);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports.FsDriverNode = FsDriverNode;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { shim } from 'lib/shim.js'
|
||||
import { netUtils } from 'lib/net-utils.js';
|
||||
const { shim } = require('lib/shim.js');
|
||||
const { netUtils } = require('lib/net-utils.js');
|
||||
|
||||
class GeolocationNode {
|
||||
|
||||
@@ -25,4 +25,4 @@ class GeolocationNode {
|
||||
|
||||
}
|
||||
|
||||
export { GeolocationNode };
|
||||
module.exports = { GeolocationNode };
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Setting } from 'lib/models/setting.js';
|
||||
const { Setting } = require('lib/models/setting.js');
|
||||
|
||||
class GeolocationReact {
|
||||
|
||||
@@ -28,11 +28,11 @@ class GeolocationReact {
|
||||
navigator.geolocation.getCurrentPosition((data) => {
|
||||
resolve(data);
|
||||
}, (error) => {
|
||||
rejec(error);
|
||||
reject(error);
|
||||
}, options);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { GeolocationReact };
|
||||
module.exports = { GeolocationReact };
|
||||
623
ReactNativeClient/lib/import-enex-md-gen.js
Normal file
623
ReactNativeClient/lib/import-enex-md-gen.js
Normal file
@@ -0,0 +1,623 @@
|
||||
const stringPadding = require('string-padding');
|
||||
|
||||
const BLOCK_OPEN = "[[BLOCK_OPEN]]";
|
||||
const BLOCK_CLOSE = "[[BLOCK_CLOSE]]";
|
||||
const NEWLINE = "[[NEWLINE]]";
|
||||
const NEWLINE_MERGED = "[[MERGED]]";
|
||||
const SPACE = "[[SPACE]]";
|
||||
|
||||
function processMdArrayNewLines(md) {
|
||||
while (md.length && md[0] == BLOCK_OPEN) {
|
||||
md.shift();
|
||||
}
|
||||
|
||||
while (md.length && md[md.length - 1] == BLOCK_CLOSE) {
|
||||
md.pop();
|
||||
}
|
||||
|
||||
let temp = [];
|
||||
let last = '';
|
||||
for (let i = 0; i < md.length; i++) { let v = md[i];
|
||||
if (isNewLineBlock(last) && isNewLineBlock(v) && last == v) {
|
||||
// Skip it
|
||||
} else {
|
||||
temp.push(v);
|
||||
}
|
||||
last = v;
|
||||
}
|
||||
md = temp;
|
||||
|
||||
|
||||
|
||||
temp = [];
|
||||
last = "";
|
||||
for (let i = 0; i < md.length; i++) { let v = md[i];
|
||||
if (last == BLOCK_CLOSE && v == BLOCK_OPEN) {
|
||||
temp.pop();
|
||||
temp.push(NEWLINE_MERGED);
|
||||
} else {
|
||||
temp.push(v);
|
||||
}
|
||||
last = v;
|
||||
}
|
||||
md = temp;
|
||||
|
||||
|
||||
|
||||
temp = [];
|
||||
last = "";
|
||||
for (let i = 0; i < md.length; i++) { let v = md[i];
|
||||
if (last == NEWLINE && (v == NEWLINE_MERGED || v == BLOCK_CLOSE)) {
|
||||
// Skip it
|
||||
} else {
|
||||
temp.push(v);
|
||||
}
|
||||
last = v;
|
||||
}
|
||||
md = temp;
|
||||
|
||||
|
||||
|
||||
// NEW!!!
|
||||
temp = [];
|
||||
last = "";
|
||||
for (let i = 0; i < md.length; i++) { let v = md[i];
|
||||
if (last == NEWLINE && (v == NEWLINE_MERGED || v == BLOCK_OPEN)) {
|
||||
// Skip it
|
||||
} else {
|
||||
temp.push(v);
|
||||
}
|
||||
last = v;
|
||||
}
|
||||
md = temp;
|
||||
|
||||
|
||||
|
||||
|
||||
if (md.length > 2) {
|
||||
if (md[md.length - 2] == NEWLINE_MERGED && md[md.length - 1] == NEWLINE) {
|
||||
md.pop();
|
||||
}
|
||||
}
|
||||
|
||||
let output = '';
|
||||
let previous = '';
|
||||
let start = true;
|
||||
for (let i = 0; i < md.length; i++) { let v = md[i];
|
||||
let add = '';
|
||||
if (v == BLOCK_CLOSE || v == BLOCK_OPEN || v == NEWLINE || v == NEWLINE_MERGED) {
|
||||
add = "\n";
|
||||
} else if (v == SPACE) {
|
||||
if (previous == SPACE || previous == "\n" || start) {
|
||||
continue; // skip
|
||||
} else {
|
||||
add = " ";
|
||||
}
|
||||
} else {
|
||||
add = v;
|
||||
}
|
||||
start = false;
|
||||
output += add;
|
||||
previous = add;
|
||||
}
|
||||
|
||||
if (!output.trim().length) return '';
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function isWhiteSpace(c) {
|
||||
return c == '\n' || c == '\r' || c == '\v' || c == '\f' || c == '\t' || c == ' ';
|
||||
}
|
||||
|
||||
// Like QString::simpified(), except that it preserves non-breaking spaces (which
|
||||
// Evernote uses for identation, etc.)
|
||||
function simplifyString(s) {
|
||||
let output = '';
|
||||
let previousWhite = false;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
let c = s[i];
|
||||
let isWhite = isWhiteSpace(c);
|
||||
if (previousWhite && isWhite) {
|
||||
// skip
|
||||
} else {
|
||||
output += c;
|
||||
}
|
||||
previousWhite = isWhite;
|
||||
}
|
||||
|
||||
while (output.length && isWhiteSpace(output[0])) output = output.substr(1);
|
||||
while (output.length && isWhiteSpace(output[output.length - 1])) output = output.substr(0, output.length - 1);
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function collapseWhiteSpaceAndAppend(lines, state, text) {
|
||||
if (state.inCode) {
|
||||
text = "\t" + text;
|
||||
lines.push(text);
|
||||
} else {
|
||||
// Remove all \n and \r from the left and right of the text
|
||||
while (text.length && (text[0] == "\n" || text[0] == "\r")) text = text.substr(1);
|
||||
while (text.length && (text[text.length - 1] == "\n" || text[text.length - 1] == "\r")) text = text.substr(0, text.length - 1);
|
||||
|
||||
// Collapse all white spaces to just one. If there are spaces to the left and right of the string
|
||||
// also collapse them to just one space.
|
||||
let spaceLeft = text.length && text[0] == ' ';
|
||||
let spaceRight = text.length && text[text.length - 1] == ' ';
|
||||
text = simplifyString(text);
|
||||
|
||||
if (!spaceLeft && !spaceRight && text == "") return lines;
|
||||
|
||||
if (spaceLeft) lines.push(SPACE);
|
||||
lines.push(text);
|
||||
if (spaceRight) lines.push(SPACE);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
const imageMimeTypes = ["image/cgm", "image/fits", "image/g3fax", "image/gif", "image/ief", "image/jp2", "image/jpeg", "image/jpm", "image/jpx", "image/naplps", "image/png", "image/prs.btif", "image/prs.pti", "image/t38", "image/tiff", "image/tiff-fx", "image/vnd.adobe.photoshop", "image/vnd.cns.inf2", "image/vnd.djvu", "image/vnd.dwg", "image/vnd.dxf", "image/vnd.fastbidsheet", "image/vnd.fpx", "image/vnd.fst", "image/vnd.fujixerox.edmics-mmr", "image/vnd.fujixerox.edmics-rlc", "image/vnd.globalgraphics.pgb", "image/vnd.microsoft.icon", "image/vnd.mix", "image/vnd.ms-modi", "image/vnd.net-fpx", "image/vnd.sealed.png", "image/vnd.sealedmedia.softseal.gif", "image/vnd.sealedmedia.softseal.jpg", "image/vnd.svf", "image/vnd.wap.wbmp", "image/vnd.xiff"];
|
||||
|
||||
function isImageMimeType(m) {
|
||||
return imageMimeTypes.indexOf(m) >= 0;
|
||||
}
|
||||
|
||||
function addResourceTag(lines, resource, alt = "") {
|
||||
// TODO: refactor to use Resource.markdownTag
|
||||
|
||||
let tagAlt = alt == "" ? resource.alt : alt;
|
||||
if (!tagAlt) tagAlt = '';
|
||||
if (isImageMimeType(resource.mime)) {
|
||||
lines.push("");
|
||||
} else {
|
||||
lines.push("[");
|
||||
lines.push(tagAlt);
|
||||
lines.push("](:/" + resource.id + ")");
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
|
||||
function isBlockTag(n) {
|
||||
return n=="div" || n=="p" || n=="dl" || n=="dd" || n=="center";
|
||||
}
|
||||
|
||||
function isStrongTag(n) {
|
||||
return n == "strong" || n == "b";
|
||||
}
|
||||
|
||||
function isEmTag(n) {
|
||||
return n == "em" || n == "i" || n == "u";
|
||||
}
|
||||
|
||||
function isAnchor(n) {
|
||||
return n == "a";
|
||||
}
|
||||
|
||||
function isIgnoredEndTag(n) {
|
||||
return n=="en-note" || n=="en-todo" || n=="span" || n=="body" || n=="html" || n=="font" || n=="br" || n=='hr' || n=='s' || n == 'tbody';
|
||||
}
|
||||
|
||||
function isListTag(n) {
|
||||
return n == "ol" || n == "ul";
|
||||
}
|
||||
|
||||
// Elements that don't require any special treatment beside adding a newline character
|
||||
function isNewLineOnlyEndTag(n) {
|
||||
return n=="div" || n=="p" || n=="li" || n=="h1" || n=="h2" || n=="h3" || n=="h4" || n=="h5" || n=="dl" || n=="dd" || n=="center";
|
||||
}
|
||||
|
||||
function isCodeTag(n) {
|
||||
return n == "pre" || n == "code";
|
||||
}
|
||||
|
||||
function isNewLineBlock(s) {
|
||||
return s == BLOCK_OPEN || s == BLOCK_CLOSE;
|
||||
}
|
||||
|
||||
function xmlNodeText(xmlNode) {
|
||||
if (!xmlNode || !xmlNode.length) return '';
|
||||
return xmlNode[0];
|
||||
}
|
||||
|
||||
function enexXmlToMdArray(stream, resources) {
|
||||
resources = resources.slice();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let state = {
|
||||
inCode: false,
|
||||
lists: [],
|
||||
anchorAttributes: [],
|
||||
};
|
||||
|
||||
let options = {};
|
||||
let strict = true;
|
||||
var saxStream = require('sax').createStream(strict, options)
|
||||
|
||||
let section = {
|
||||
type: 'text',
|
||||
lines: [],
|
||||
parent: null,
|
||||
};
|
||||
|
||||
saxStream.on('error', function(e) {
|
||||
reject(e);
|
||||
})
|
||||
|
||||
saxStream.on('text', function(text) {
|
||||
section.lines = collapseWhiteSpaceAndAppend(section.lines, state, text);
|
||||
})
|
||||
|
||||
saxStream.on('opentag', function(node) {
|
||||
let n = node.name.toLowerCase();
|
||||
if (n == 'en-note') {
|
||||
// Start of note
|
||||
} else if (isBlockTag(n)) {
|
||||
section.lines.push(BLOCK_OPEN);
|
||||
} else if (n == 'table') {
|
||||
let newSection = {
|
||||
type: 'table',
|
||||
lines: [],
|
||||
parent: section,
|
||||
};
|
||||
section.lines.push(newSection);
|
||||
section = newSection;
|
||||
} else if (n == 'tbody') {
|
||||
// Ignore it
|
||||
} else if (n == 'tr') {
|
||||
if (section.type != 'table') throw new Error('Found a <tr> tag outside of a table');
|
||||
|
||||
let newSection = {
|
||||
type: 'tr',
|
||||
lines: [],
|
||||
parent: section,
|
||||
isHeader: false,
|
||||
}
|
||||
|
||||
section.lines.push(newSection);
|
||||
section = newSection;
|
||||
} else if (n == 'td' || n == 'th') {
|
||||
if (section.type != 'tr') throw new Error('Found a <td> tag outside of a <tr>');
|
||||
|
||||
if (n == 'th') section.isHeader = true;
|
||||
|
||||
let newSection = {
|
||||
type: 'td',
|
||||
lines: [],
|
||||
parent: section,
|
||||
};
|
||||
|
||||
section.lines.push(newSection);
|
||||
section = newSection;
|
||||
} else if (isListTag(n)) {
|
||||
section.lines.push(BLOCK_OPEN);
|
||||
state.lists.push({ tag: n, counter: 1 });
|
||||
} else if (n == 'li') {
|
||||
section.lines.push(BLOCK_OPEN);
|
||||
if (!state.lists.length) {
|
||||
reject("Found <li> tag without being inside a list"); // TODO: could be a warning, but nothing to handle warnings at the moment
|
||||
return;
|
||||
}
|
||||
|
||||
let container = state.lists[state.lists.length - 1];
|
||||
if (container.tag == "ul") {
|
||||
section.lines.push("- ");
|
||||
} else {
|
||||
section.lines.push(container.counter + '. ');
|
||||
container.counter++;
|
||||
}
|
||||
} else if (isStrongTag(n)) {
|
||||
section.lines.push("**");
|
||||
} else if (n == 's') {
|
||||
// Not supported
|
||||
} else if (isAnchor(n)) {
|
||||
state.anchorAttributes.push(node.attributes);
|
||||
section.lines.push('[');
|
||||
} else if (isEmTag(n)) {
|
||||
section.lines.push("*");
|
||||
} else if (n == "en-todo") {
|
||||
let x = node.attributes && node.attributes.checked && node.attributes.checked.toLowerCase() == 'true' ? 'X' : ' ';
|
||||
section.lines.push('- [' + x + '] ');
|
||||
} else if (n == "hr") {
|
||||
// Needs to be surrounded by new lines so that it's properly rendered as a line when converting to HTML
|
||||
section.lines.push(NEWLINE);
|
||||
section.lines.push('----------------------------------------');
|
||||
section.lines.push(NEWLINE);
|
||||
section.lines.push(NEWLINE);
|
||||
} else if (n == "h1") {
|
||||
section.lines.push(BLOCK_OPEN); section.lines.push("# ");
|
||||
} else if (n == "h2") {
|
||||
section.lines.push(BLOCK_OPEN); section.lines.push("## ");
|
||||
} else if (n == "h3") {
|
||||
section.lines.push(BLOCK_OPEN); section.lines.push("### ");
|
||||
} else if (n == "h4") {
|
||||
section.lines.push(BLOCK_OPEN); section.lines.push("#### ");
|
||||
} else if (n == "h5") {
|
||||
section.lines.push(BLOCK_OPEN); section.lines.push("##### ");
|
||||
} else if (n == "h6") {
|
||||
section.lines.push(BLOCK_OPEN); section.lines.push("###### ");
|
||||
} else if (isCodeTag(n)) {
|
||||
section.lines.push(BLOCK_OPEN);
|
||||
state.inCode = true;
|
||||
} else if (n == "br") {
|
||||
section.lines.push(NEWLINE);
|
||||
} else if (n == "en-media") {
|
||||
const hash = node.attributes.hash;
|
||||
|
||||
let resource = null;
|
||||
for (let i = 0; i < resources.length; i++) {
|
||||
let r = resources[i];
|
||||
if (r.id == hash) {
|
||||
resource = r;
|
||||
resources.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!resource) {
|
||||
// This is a bit of a hack. Notes sometime have resources attached to it, but those <resource> tags don't contain
|
||||
// an "objID" tag, making it impossible to reference the resource. However, in this case the content of the note
|
||||
// will contain a corresponding <en-media/> tag, which has the ID in the "hash" attribute. All this information
|
||||
// has been collected above so we now set the resource ID to the hash attribute of the en-media tags. Here's an
|
||||
// example of note that shows this problem:
|
||||
|
||||
// <?xml version="1.0" encoding="UTF-8"?>
|
||||
// <!DOCTYPE en-export SYSTEM "http://xml.evernote.com/pub/evernote-export2.dtd">
|
||||
// <en-export export-date="20161221T203133Z" application="Evernote/Windows" version="6.x">
|
||||
// <note>
|
||||
// <title>Commande</title>
|
||||
// <content>
|
||||
// <![CDATA[
|
||||
// <?xml version="1.0" encoding="UTF-8"?>
|
||||
// <!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd">
|
||||
// <en-note>
|
||||
// <en-media alt="your QR code" hash="216a16a1bbe007fba4ccf60b118b4ccc" type="image/png"></en-media>
|
||||
// </en-note>
|
||||
// ]]>
|
||||
// </content>
|
||||
// <created>20160921T203424Z</created>
|
||||
// <updated>20160921T203438Z</updated>
|
||||
// <note-attributes>
|
||||
// <reminder-order>20160902T140445Z</reminder-order>
|
||||
// <reminder-done-time>20160924T101120Z</reminder-done-time>
|
||||
// </note-attributes>
|
||||
// <resource>
|
||||
// <data encoding="base64">........</data>
|
||||
// <mime>image/png</mime>
|
||||
// <width>150</width>
|
||||
// <height>150</height>
|
||||
// </resource>
|
||||
// </note>
|
||||
// </en-export>
|
||||
|
||||
let found = false;
|
||||
for (let i = 0; i < resources.length; i++) {
|
||||
let r = resources[i];
|
||||
if (!r.id) {
|
||||
r.id = hash;
|
||||
resources[i] = r;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
console.warn('Hash with no associated resource: ' + hash);
|
||||
}
|
||||
} else {
|
||||
// If the resource does not appear among the note's resources, it
|
||||
// means it's an attachement. It will be appended along with the
|
||||
// other remaining resources at the bottom of the markdown text.
|
||||
if (!!resource.id) {
|
||||
section.lines = addResourceTag(section.lines, resource, node.attributes.alt);
|
||||
}
|
||||
}
|
||||
} else if (n == "span" || n == "font") {
|
||||
// Ignore
|
||||
} else {
|
||||
console.warn("Unsupported start tag: " + n);
|
||||
}
|
||||
})
|
||||
|
||||
saxStream.on('closetag', function(n) {
|
||||
if (n == 'en-note') {
|
||||
// End of note
|
||||
} else if (isNewLineOnlyEndTag(n)) {
|
||||
section.lines.push(BLOCK_CLOSE);
|
||||
} else if (n == 'td' || n == 'th') {
|
||||
section = section.parent;
|
||||
} else if (n == 'tr') {
|
||||
section = section.parent;
|
||||
} else if (n == 'table') {
|
||||
section = section.parent;
|
||||
} else if (isIgnoredEndTag(n)) {
|
||||
// Skip
|
||||
} else if (isListTag(n)) {
|
||||
section.lines.push(BLOCK_CLOSE);
|
||||
state.lists.pop();
|
||||
} else if (isStrongTag(n)) {
|
||||
section.lines.push("**");
|
||||
} else if (isEmTag(n)) {
|
||||
section.lines.push("*");
|
||||
} else if (isCodeTag(n)) {
|
||||
state.inCode = false;
|
||||
section.lines.push(BLOCK_CLOSE);
|
||||
} else if (isAnchor(n)) {
|
||||
let attributes = state.anchorAttributes.pop();
|
||||
let url = attributes && attributes.href ? attributes.href : '';
|
||||
|
||||
if (section.lines.length < 1) throw new Error('Invalid anchor tag closing'); // Sanity check, but normally not possible
|
||||
|
||||
// When closing the anchor tag, check if there's is any text content. If not
|
||||
// put the URL as is (don't wrap it in [](url)). The markdown parser, using
|
||||
// GitHub flavour, will turn this URL into a link. This is to generate slightly
|
||||
// cleaner markdown.
|
||||
let previous = section.lines[section.lines.length - 1];
|
||||
if (previous == '[') {
|
||||
section.lines.pop();
|
||||
section.lines.push(url);
|
||||
} else if (!previous || previous == url) {
|
||||
section.lines.pop();
|
||||
section.lines.pop();
|
||||
section.lines.push(url);
|
||||
} else {
|
||||
section.lines.push('](' + url + ')');
|
||||
}
|
||||
} else if (isListTag(n)) {
|
||||
section.lines.push(BLOCK_CLOSE);
|
||||
state.lists.pop();
|
||||
} else if (n == "en-media") {
|
||||
// Skip
|
||||
} else if (isIgnoredEndTag(n)) {
|
||||
// Skip
|
||||
} else {
|
||||
console.warn("Unsupported end tag: " + n);
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
saxStream.on('attribute', function(attr) {
|
||||
|
||||
})
|
||||
|
||||
saxStream.on('end', function() {
|
||||
resolve({
|
||||
content: section,
|
||||
resources: resources,
|
||||
});
|
||||
})
|
||||
|
||||
stream.pipe(saxStream);
|
||||
});
|
||||
}
|
||||
|
||||
function setTableCellContent(table) {
|
||||
if (!table.type == 'table') throw new Error('Only for tables');
|
||||
|
||||
for (let trIndex = 0; trIndex < table.lines.length; trIndex++) {
|
||||
const tr = table.lines[trIndex];
|
||||
for (let tdIndex = 0; tdIndex < tr.lines.length; tdIndex++) {
|
||||
let td = tr.lines[tdIndex];
|
||||
td.content = processMdArrayNewLines(td.lines);
|
||||
td.content = td.content.replace(/\n\n\n\n\n/g, ' ');
|
||||
td.content = td.content.replace(/\n\n\n\n/g, ' ');
|
||||
td.content = td.content.replace(/\n\n\n/g, ' ');
|
||||
td.content = td.content.replace(/\n\n/g, ' ');
|
||||
td.content = td.content.replace(/\n/g, ' ');
|
||||
}
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
function cellWidth(cellText) {
|
||||
const lines = cellText.split("\n");
|
||||
let maxWidth = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.length > maxWidth) maxWidth = line.length;
|
||||
}
|
||||
return maxWidth;
|
||||
}
|
||||
|
||||
function colWidths(table) {
|
||||
let output = [];
|
||||
for (let trIndex = 0; trIndex < table.lines.length; trIndex++) {
|
||||
const tr = table.lines[trIndex];
|
||||
for (let tdIndex = 0; tdIndex < tr.lines.length; tdIndex++) {
|
||||
const td = tr.lines[tdIndex];
|
||||
const w = cellWidth(td.content);
|
||||
if (output.length <= tdIndex) output.push(0);
|
||||
if (w > output[tdIndex]) output[tdIndex] = w;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function drawTable(table, colWidths) {
|
||||
// | First Header | Second Header |
|
||||
// | ------------- | ------------- |
|
||||
// | Content Cell | Content Cell |
|
||||
// | Content Cell | Content Cell |
|
||||
|
||||
// There must be at least 3 dashes separating each header cell.
|
||||
// https://gist.github.com/IanWang/28965e13cdafdef4e11dc91f578d160d#tables
|
||||
const minColWidth = 3;
|
||||
let lines = [];
|
||||
let headerDone = false;
|
||||
for (let trIndex = 0; trIndex < table.lines.length; trIndex++) {
|
||||
const tr = table.lines[trIndex];
|
||||
const isHeader = tr.isHeader;
|
||||
let line = [];
|
||||
let headerLine = [];
|
||||
let emptyHeader = null;
|
||||
for (let tdIndex = 0; tdIndex < colWidths.length; tdIndex++) {
|
||||
const width = Math.max(minColWidth, colWidths[tdIndex]);
|
||||
const cell = tr.lines[tdIndex] ? tr.lines[tdIndex].content : '';
|
||||
line.push(stringPadding(cell, width, ' ', stringPadding.RIGHT));
|
||||
|
||||
if (!headerDone) {
|
||||
if (!isHeader) {
|
||||
if (!emptyHeader) emptyHeader = [];
|
||||
let h = stringPadding(' ', width, ' ', stringPadding.RIGHT);
|
||||
if (!width) h = '';
|
||||
emptyHeader.push(h);
|
||||
}
|
||||
headerLine.push('-'.repeat(width));
|
||||
}
|
||||
}
|
||||
|
||||
if (emptyHeader) {
|
||||
lines.push('| ' + emptyHeader.join(' | ') + ' |');
|
||||
lines.push('| ' + headerLine.join(' | ') + ' |');
|
||||
headerDone = true;
|
||||
}
|
||||
|
||||
lines.push('| ' + line.join(' | ') + ' |');
|
||||
|
||||
if (!headerDone) {
|
||||
lines.push('| ' + headerLine.join(' | ') + ' |');
|
||||
headerDone = true;
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('<<<<:D>>>>' + NEWLINE + '<<<<:D>>>>').split('<<<<:D>>>>');
|
||||
}
|
||||
|
||||
async function enexXmlToMd(stream, resources) {
|
||||
let result = await enexXmlToMdArray(stream, resources);
|
||||
|
||||
let mdLines = [];
|
||||
|
||||
for (let i = 0; i < result.content.lines.length; i++) {
|
||||
let line = result.content.lines[i];
|
||||
if (typeof line === 'object') { // A table
|
||||
let table = setTableCellContent(line);
|
||||
//console.log(require('util').inspect(table, false, null))
|
||||
const cw = colWidths(table);
|
||||
const tableLines = drawTable(table, cw);
|
||||
mdLines.push(BLOCK_OPEN);
|
||||
mdLines = mdLines.concat(tableLines);
|
||||
mdLines.push(BLOCK_CLOSE);
|
||||
} else { // an actual line
|
||||
mdLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
let firstAttachment = true;
|
||||
for (let i = 0; i < result.resources.length; i++) {
|
||||
let r = result.resources[i];
|
||||
if (firstAttachment) mdLines.push(NEWLINE);
|
||||
mdLines.push(NEWLINE);
|
||||
mdLines = addResourceTag(mdLines, r, r.filename);
|
||||
firstAttachment = false;
|
||||
}
|
||||
|
||||
return processMdArrayNewLines(mdLines);
|
||||
}
|
||||
|
||||
module.exports = { enexXmlToMd, processMdArrayNewLines, NEWLINE, addResourceTag };
|
||||
411
ReactNativeClient/lib/import-enex.js
Normal file
411
ReactNativeClient/lib/import-enex.js
Normal file
@@ -0,0 +1,411 @@
|
||||
const { uuid } = require('lib/uuid.js');
|
||||
const moment = require('moment');
|
||||
const { promiseChain } = require('lib/promise-utils.js');
|
||||
const { folderItemFilename } = require('lib/string-utils.js');
|
||||
const { BaseModel } = require('lib/base-model.js');
|
||||
const { Note } = require('lib/models/note.js');
|
||||
const { Tag } = require('lib/models/tag.js');
|
||||
const { Resource } = require('lib/models/resource.js');
|
||||
const { Folder } = require('lib/models/folder.js');
|
||||
const { enexXmlToMd } = require('./import-enex-md-gen.js');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const Levenshtein = require('levenshtein');
|
||||
const jsSHA = require("jssha");
|
||||
|
||||
//const Promise = require('promise');
|
||||
const fs = require('fs-extra');
|
||||
const stringToStream = require('string-to-stream')
|
||||
|
||||
function dateToTimestamp(s, zeroIfInvalid = false) {
|
||||
let m = moment(s, 'YYYYMMDDTHHmmssZ');
|
||||
if (!m.isValid()) {
|
||||
if (zeroIfInvalid) return 0;
|
||||
throw new Error('Invalid date: ' + s);
|
||||
}
|
||||
return m.toDate().getTime();
|
||||
}
|
||||
|
||||
function extractRecognitionObjId(recognitionXml) {
|
||||
const r = recognitionXml.match(/objID="(.*?)"/);
|
||||
return r && r.length >= 2 ? r[1] : null;
|
||||
}
|
||||
|
||||
function filePutContents(filePath, content) {
|
||||
return fs.writeFile(filePath, content);
|
||||
}
|
||||
|
||||
function removeUndefinedProperties(note) {
|
||||
let output = {};
|
||||
for (let n in note) {
|
||||
if (!note.hasOwnProperty(n)) continue;
|
||||
let v = note[n];
|
||||
if (v === undefined || v === null) continue;
|
||||
output[n] = v;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function createNoteId(note) {
|
||||
let shaObj = new jsSHA("SHA-256", "TEXT");
|
||||
shaObj.update(note.title + '_' + note.body + "_" + note.created_time + "_" + note.updated_time + "_");
|
||||
let hash = shaObj.getHash("HEX");
|
||||
return hash.substr(0, 32);
|
||||
}
|
||||
|
||||
function levenshteinPercent(s1, s2) {
|
||||
let l = new Levenshtein(s1, s2);
|
||||
if (!s1.length || !s2.length) return 1;
|
||||
return Math.abs(l.distance / s1.length);
|
||||
}
|
||||
|
||||
async function fuzzyMatch(note) {
|
||||
if (note.created_time < time.unixMs() - 1000 * 60 * 60 * 24 * 360) {
|
||||
let notes = await Note.modelSelectAll('SELECT * FROM notes WHERE is_conflict = 0 AND created_time = ? AND title = ?', [note.created_time, note.title]);
|
||||
return notes.length !== 1 ? null : notes[0];
|
||||
}
|
||||
|
||||
let notes = await Note.modelSelectAll('SELECT * FROM notes WHERE is_conflict = 0 AND created_time = ?', [note.created_time]);
|
||||
if (notes.length === 0) return null;
|
||||
if (notes.length === 1) return notes[0];
|
||||
|
||||
let lowestL = 1;
|
||||
let lowestN = null;
|
||||
for (let i = 0; i < notes.length; i++) {
|
||||
let n = notes[i];
|
||||
let l = levenshteinPercent(note.title, n.title);
|
||||
if (l < lowestL) {
|
||||
lowestL = l;
|
||||
lowestN = n;
|
||||
}
|
||||
}
|
||||
|
||||
if (lowestN && lowestL < 0.2) return lowestN;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function saveNoteResources(note) {
|
||||
let resourcesCreated = 0;
|
||||
for (let i = 0; i < note.resources.length; i++) {
|
||||
let resource = note.resources[i];
|
||||
let toSave = Object.assign({}, resource);
|
||||
delete toSave.data;
|
||||
|
||||
// The same resource sometimes appear twice in the same enex (exact same ID and file).
|
||||
// In that case, just skip it - it means two different notes might be linked to the
|
||||
// same resource.
|
||||
let existingResource = await Resource.load(toSave.id);
|
||||
if (existingResource) continue;
|
||||
|
||||
await filePutContents(Resource.fullPath(toSave), resource.data)
|
||||
await Resource.save(toSave, { isNew: true });
|
||||
resourcesCreated++;
|
||||
}
|
||||
return resourcesCreated;
|
||||
}
|
||||
|
||||
async function saveNoteTags(note) {
|
||||
let notesTagged = 0;
|
||||
for (let i = 0; i < note.tags.length; i++) {
|
||||
let tagTitle = note.tags[i];
|
||||
|
||||
let tag = await Tag.loadByTitle(tagTitle);
|
||||
if (!tag) tag = await Tag.save({ title: tagTitle });
|
||||
|
||||
await Tag.addNote(tag.id, note.id);
|
||||
|
||||
notesTagged++;
|
||||
}
|
||||
return notesTagged;
|
||||
}
|
||||
|
||||
async function saveNoteToStorage(note, fuzzyMatching = false) {
|
||||
note = Note.filter(note);
|
||||
|
||||
let existingNote = fuzzyMatching ? await fuzzyMatch(note) : null;
|
||||
|
||||
let result = {
|
||||
noteCreated: false,
|
||||
noteUpdated: false,
|
||||
noteSkipped: false,
|
||||
resourcesCreated: 0,
|
||||
notesTagged: 0,
|
||||
};
|
||||
|
||||
let resourcesCreated = await saveNoteResources(note);
|
||||
result.resourcesCreated += resourcesCreated;
|
||||
|
||||
let notesTagged = await saveNoteTags(note);
|
||||
result.notesTagged += notesTagged;
|
||||
|
||||
if (existingNote) {
|
||||
let diff = BaseModel.diffObjects(existingNote, note);
|
||||
delete diff.tags;
|
||||
delete diff.resources;
|
||||
delete diff.id;
|
||||
|
||||
if (!Object.getOwnPropertyNames(diff).length) {
|
||||
result.noteSkipped = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
diff.id = existingNote.id;
|
||||
diff.type_ = existingNote.type_;
|
||||
await Note.save(diff, { autoTimestamp: false })
|
||||
result.noteUpdated = true;
|
||||
} else {
|
||||
await Note.save(note, {
|
||||
isNew: true,
|
||||
autoTimestamp: false,
|
||||
});
|
||||
result.noteCreated = true;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function importEnex(parentFolderId, filePath, importOptions = null) {
|
||||
if (!importOptions) importOptions = {};
|
||||
if (!('fuzzyMatching' in importOptions)) importOptions.fuzzyMatching = false;
|
||||
if (!('onProgress' in importOptions)) importOptions.onProgress = function(state) {};
|
||||
if (!('onError' in importOptions)) importOptions.onError = function(error) {};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let progressState = {
|
||||
loaded: 0,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
resourcesCreated: 0,
|
||||
notesTagged: 0,
|
||||
};
|
||||
|
||||
let stream = fs.createReadStream(filePath);
|
||||
|
||||
let options = {};
|
||||
let strict = true;
|
||||
let saxStream = require('sax').createStream(strict, options);
|
||||
|
||||
let nodes = []; // LIFO list of nodes so that we know in which node we are in the onText event
|
||||
let note = null;
|
||||
let noteAttributes = null;
|
||||
let noteResource = null;
|
||||
let noteResourceAttributes = null;
|
||||
let noteResourceRecognition = null;
|
||||
let notes = [];
|
||||
let processingNotes = false;
|
||||
|
||||
stream.on('error', (error) => {
|
||||
reject(new Error(error.toString()));
|
||||
});
|
||||
|
||||
function currentNodeName() {
|
||||
if (!nodes.length) return null;
|
||||
return nodes[nodes.length - 1].name;
|
||||
}
|
||||
|
||||
function currentNodeAttributes() {
|
||||
if (!nodes.length) return {};
|
||||
return nodes[nodes.length - 1].attributes;
|
||||
}
|
||||
|
||||
async function processNotes() {
|
||||
if (processingNotes) return false;
|
||||
|
||||
processingNotes = true;
|
||||
stream.pause();
|
||||
|
||||
let chain = [];
|
||||
while (notes.length) {
|
||||
let note = notes.shift();
|
||||
const contentStream = stringToStream(note.bodyXml);
|
||||
chain.push(() => {
|
||||
return enexXmlToMd(contentStream, note.resources).then((body) => {
|
||||
delete note.bodyXml;
|
||||
|
||||
// console.info('-----------------------------------------------------------');
|
||||
// console.info(body);
|
||||
// console.info('-----------------------------------------------------------');
|
||||
|
||||
note.id = uuid.create();
|
||||
note.parent_id = parentFolderId;
|
||||
note.body = body;
|
||||
|
||||
// Notes in enex files always have a created timestamp but not always an
|
||||
// updated timestamp (it the note has never been modified). For sync
|
||||
// we require an updated_time property, so set it to create_time in that case
|
||||
if (!note.updated_time) note.updated_time = note.created_time;
|
||||
|
||||
return saveNoteToStorage(note, importOptions.fuzzyMatching);
|
||||
}).then((result) => {
|
||||
if (result.noteUpdated) {
|
||||
progressState.updated++;
|
||||
} else if (result.noteCreated) {
|
||||
progressState.created++;
|
||||
} else if (result.noteSkipped) {
|
||||
progressState.skipped++;
|
||||
}
|
||||
progressState.resourcesCreated += result.resourcesCreated;
|
||||
progressState.notesTagged += result.notesTagged;
|
||||
importOptions.onProgress(progressState);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return promiseChain(chain).then(() => {
|
||||
stream.resume();
|
||||
processingNotes = false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
saxStream.on('error', (error) => {
|
||||
importOptions.onError(error);
|
||||
});
|
||||
|
||||
saxStream.on('text', function(text) {
|
||||
let n = currentNodeName();
|
||||
|
||||
if (noteAttributes) {
|
||||
noteAttributes[n] = text;
|
||||
} else if (noteResourceAttributes) {
|
||||
noteResourceAttributes[n] = text;
|
||||
} else if (noteResource) {
|
||||
if (n == 'data') {
|
||||
let attr = currentNodeAttributes();
|
||||
noteResource.dataEncoding = attr.encoding;
|
||||
}
|
||||
noteResource[n] = text;
|
||||
} else if (note) {
|
||||
if (n == 'title') {
|
||||
note.title = text;
|
||||
} else if (n == 'created') {
|
||||
note.created_time = dateToTimestamp(text);
|
||||
} else if (n == 'updated') {
|
||||
note.updated_time = dateToTimestamp(text);
|
||||
} else if (n == 'tag') {
|
||||
note.tags.push(text);
|
||||
} else {
|
||||
console.warn('Unsupported note tag: ' + n);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
saxStream.on('opentag', function(node) {
|
||||
let n = node.name.toLowerCase();
|
||||
nodes.push(node);
|
||||
|
||||
if (n == 'note') {
|
||||
note = {
|
||||
resources: [],
|
||||
tags: [],
|
||||
};
|
||||
} else if (n == 'resource-attributes') {
|
||||
noteResourceAttributes = {};
|
||||
} else if (n == 'recognition') {
|
||||
if (noteResource) noteResourceRecognition = {};
|
||||
} else if (n == 'note-attributes') {
|
||||
noteAttributes = {};
|
||||
} else if (n == 'resource') {
|
||||
noteResource = {};
|
||||
}
|
||||
});
|
||||
|
||||
saxStream.on('cdata', function(data) {
|
||||
let n = currentNodeName();
|
||||
|
||||
if (noteResourceRecognition) {
|
||||
noteResourceRecognition.objID = extractRecognitionObjId(data);
|
||||
} else if (note) {
|
||||
if (n == 'content') {
|
||||
note.bodyXml = data;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
saxStream.on('closetag', function(n) {
|
||||
nodes.pop();
|
||||
|
||||
if (n == 'note') {
|
||||
note = removeUndefinedProperties(note);
|
||||
|
||||
progressState.loaded++;
|
||||
importOptions.onProgress(progressState);
|
||||
|
||||
notes.push(note);
|
||||
|
||||
if (notes.length >= 10) {
|
||||
processNotes().catch((error) => {
|
||||
importOptions.onError(error);
|
||||
});
|
||||
}
|
||||
note = null;
|
||||
} else if (n == 'recognition' && noteResource) {
|
||||
noteResource.id = noteResourceRecognition.objID;
|
||||
noteResourceRecognition = null;
|
||||
} else if (n == 'resource-attributes') {
|
||||
noteResource.filename = noteResourceAttributes['file-name'];
|
||||
noteResourceAttributes = null;
|
||||
} else if (n == 'note-attributes') {
|
||||
note.latitude = noteAttributes.latitude;
|
||||
note.longitude = noteAttributes.longitude;
|
||||
note.altitude = noteAttributes.altitude;
|
||||
note.author = noteAttributes.author;
|
||||
note.is_todo = !!noteAttributes['reminder-order'];
|
||||
note.todo_due = dateToTimestamp(noteAttributes['reminder-time'], true);
|
||||
note.todo_completed = dateToTimestamp(noteAttributes['reminder-done-time'], true);
|
||||
note.order = dateToTimestamp(noteAttributes['reminder-order'], true);
|
||||
note.source = !!noteAttributes.source ? 'evernote.' + noteAttributes.source : 'evernote';
|
||||
|
||||
// if (noteAttributes['reminder-time']) {
|
||||
// console.info('======================================================');
|
||||
// console.info(noteAttributes);
|
||||
// console.info('------------------------------------------------------');
|
||||
// console.info(note);
|
||||
// console.info('======================================================');
|
||||
// }
|
||||
|
||||
noteAttributes = null;
|
||||
} else if (n == 'resource') {
|
||||
let decodedData = null;
|
||||
if (noteResource.dataEncoding == 'base64') {
|
||||
try {
|
||||
decodedData = Buffer.from(noteResource.data, 'base64');
|
||||
} catch (error) {
|
||||
importOptions.onError(error);
|
||||
}
|
||||
} else {
|
||||
importOptions.onError(new Error('Cannot decode resource with encoding: ' + noteResource.dataEncoding));
|
||||
decodedData = noteResource.data; // Just put the encoded data directly in the file so it can, potentially, be manually decoded later
|
||||
}
|
||||
|
||||
let r = {
|
||||
id: noteResource.id,
|
||||
data: decodedData,
|
||||
mime: noteResource.mime,
|
||||
title: noteResource.filename ? noteResource.filename : '',
|
||||
filename: noteResource.filename ? noteResource.filename : '',
|
||||
};
|
||||
|
||||
note.resources.push(r);
|
||||
noteResource = null;
|
||||
}
|
||||
});
|
||||
|
||||
saxStream.on('end', function() {
|
||||
// Wait till there is no more notes to process.
|
||||
let iid = setInterval(() => {
|
||||
processNotes().then((allDone) => {
|
||||
if (allDone) {
|
||||
clearTimeout(iid);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}, 500);
|
||||
});
|
||||
|
||||
stream.pipe(saxStream);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { importEnex };
|
||||
@@ -1,7 +1,7 @@
|
||||
import { uuid } from 'lib/uuid.js';
|
||||
import { promiseChain } from 'lib/promise-utils.js';
|
||||
import { time } from 'lib/time-utils.js'
|
||||
import { Database } from 'lib/database.js'
|
||||
const { uuid } = require('lib/uuid.js');
|
||||
const { promiseChain } = require('lib/promise-utils.js');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const { Database } = require('lib/database.js');
|
||||
|
||||
const structureSql = `
|
||||
CREATE TABLE folders (
|
||||
@@ -193,6 +193,14 @@ class JoplinDatabase extends Database {
|
||||
// 1. Add the new version number to the existingDatabaseVersions array
|
||||
// 2. Add the upgrade logic to the "switch (targetVersion)" statement below
|
||||
|
||||
// IMPORTANT:
|
||||
//
|
||||
// Whenever adding a new database property, some additional logic might be needed
|
||||
// in the synchronizer to handle this property. For example, when adding a property
|
||||
// that should have a default value, existing remote items will not have this
|
||||
// default value and thus might cause problems. In that case, the default value
|
||||
// must be set in the synchronizer too.
|
||||
|
||||
const existingDatabaseVersions = [0, 1, 2, 3, 4, 5];
|
||||
|
||||
let currentVersionIndex = existingDatabaseVersions.indexOf(fromVersion);
|
||||
@@ -296,4 +304,4 @@ Database.TYPE_INT = 1;
|
||||
Database.TYPE_TEXT = 2;
|
||||
Database.TYPE_NUMERIC = 3;
|
||||
|
||||
export { JoplinDatabase };
|
||||
module.exports = { JoplinDatabase };
|
||||
9
ReactNativeClient/lib/layout-utils.js
Normal file
9
ReactNativeClient/lib/layout-utils.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const layoutUtils = {};
|
||||
|
||||
layoutUtils.size = function(prefered, min, max) {
|
||||
if (prefered < min) return min;
|
||||
if (typeof max !== 'undefined' && prefered > max) return max;
|
||||
return prefered;
|
||||
}
|
||||
|
||||
module.exports = layoutUtils;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sprintf } from 'sprintf-js';
|
||||
const { sprintf } = require('sprintf-js');
|
||||
|
||||
let codeToLanguageE_ = {};
|
||||
codeToLanguageE_["aa"] = "Afar";
|
||||
@@ -296,4 +296,4 @@ function _(s, ...args) {
|
||||
return sprintf(result, ...args);
|
||||
}
|
||||
|
||||
export { _, supportedLocales, localeStrings, setLocale, supportedLocalesToLanguages, defaultLocale, closestSupportedLocale, languageCode };
|
||||
module.exports = { _, supportedLocales, localeStrings, setLocale, supportedLocalesToLanguages, defaultLocale, closestSupportedLocale, languageCode };
|
||||
@@ -37,4 +37,4 @@ Log.LEVEL_INFO = 10;
|
||||
Log.LEVEL_WARN = 20;
|
||||
Log.LEVEL_ERROR = 30;
|
||||
|
||||
export { Log };
|
||||
module.exports = { Log };
|
||||
@@ -1,7 +1,7 @@
|
||||
import moment from 'moment';
|
||||
import { _ } from 'lib/locale.js';
|
||||
import { time } from 'lib/time-utils.js';
|
||||
import { FsDriverDummy } from 'lib/fs-driver-dummy.js';
|
||||
const moment = require('moment');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const { FsDriverDummy } = require('lib/fs-driver-dummy.js');
|
||||
|
||||
class Logger {
|
||||
|
||||
@@ -45,6 +45,9 @@ class Logger {
|
||||
if (typeof object === 'object') {
|
||||
if (object instanceof Error) {
|
||||
output = object.toString();
|
||||
if (object.code) output += "\nCode: " + object.code;
|
||||
if (object.headers) output += "\nHeader: " + JSON.stringify(object.headers);
|
||||
if (object.request) output += "\nRequest: " + object.request;
|
||||
if (object.stack) output += "\n" + object.stack;
|
||||
} else {
|
||||
output = JSON.stringify(object);
|
||||
@@ -82,7 +85,9 @@ class Logger {
|
||||
for (let i = 0; i < this.targets_.length; i++) {
|
||||
const target = this.targets_[i];
|
||||
if (target.type == 'database') {
|
||||
return await target.database.selectAll('SELECT * FROM logs ORDER BY timestamp DESC LIMIT ' + limit);
|
||||
let sql = 'SELECT * FROM logs ORDER BY timestamp DESC';
|
||||
if (limit !== null) sql += ' LIMIT ' + limit
|
||||
return await target.database.selectAll(sql);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
@@ -108,8 +113,6 @@ class Logger {
|
||||
} else if (target.type == 'file') {
|
||||
let serializedObject = this.objectsToString(...object);
|
||||
Logger.fsDriver().appendFileSync(target.path, line + serializedObject + "\n");
|
||||
} else if (target.type == 'vorpal') {
|
||||
//target.vorpal.log(...object);
|
||||
} else if (target.type == 'database') {
|
||||
let msg = this.objectsToString(...object);
|
||||
|
||||
@@ -177,4 +180,4 @@ Logger.LEVEL_WARN = 20;
|
||||
Logger.LEVEL_INFO = 30;
|
||||
Logger.LEVEL_DEBUG = 40;
|
||||
|
||||
export { Logger };
|
||||
module.exports = { Logger };
|
||||
@@ -13,4 +13,4 @@ const markdownUtils = {
|
||||
|
||||
};
|
||||
|
||||
export { markdownUtils };
|
||||
module.exports = { markdownUtils };
|
||||
@@ -2,6 +2,17 @@ const mimeTypes = [{t:"application/andrew-inset",e:["ez"]},{t:"application/appli
|
||||
|
||||
const mime = {
|
||||
|
||||
fromFileExtension(ext) {
|
||||
ext = ext.toLowerCase();
|
||||
for (let i = 0; i < mimeTypes.length; i++) {
|
||||
const t = mimeTypes[i];
|
||||
if (t.e.indexOf(ext) >= 0) {
|
||||
return t.t;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
toFileExtension(mimeType) {
|
||||
mimeType = mimeType.toLowerCase();
|
||||
for (let i = 0; i < mimeTypes.length; i++) {
|
||||
@@ -20,4 +31,4 @@ const mime = {
|
||||
|
||||
}
|
||||
|
||||
export { mime };
|
||||
module.exports = { mime };
|
||||
@@ -1,9 +1,9 @@
|
||||
import { BaseModel } from 'lib/base-model.js';
|
||||
import { Database } from 'lib/database.js';
|
||||
import { Setting } from 'lib/models/setting.js';
|
||||
import { time } from 'lib/time-utils.js';
|
||||
import { sprintf } from 'sprintf-js';
|
||||
import moment from 'moment';
|
||||
const { BaseModel } = require('lib/base-model.js');
|
||||
const { Database } = require('lib/database.js');
|
||||
const { Setting } = require('lib/models/setting.js');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const moment = require('moment');
|
||||
|
||||
class BaseItem extends BaseModel {
|
||||
|
||||
@@ -280,7 +280,7 @@ class BaseItem extends BaseModel {
|
||||
output.title = title[0];
|
||||
}
|
||||
|
||||
if (body.length) output.body = body.join("\n");
|
||||
if (output.type_ === BaseModel.TYPE_NOTE) output.body = body.join("\n");
|
||||
|
||||
for (let n in output) {
|
||||
if (!output.hasOwnProperty(n)) continue;
|
||||
@@ -404,6 +404,9 @@ class BaseItem extends BaseModel {
|
||||
return this.db().transactionExecBatch(queries);
|
||||
}
|
||||
|
||||
// When an item is deleted, its associated sync_items data is not immediately deleted for
|
||||
// performance reason. So this function is used to look for these remaining sync_items and
|
||||
// delete them.
|
||||
static async deleteOrphanSyncItems() {
|
||||
const classNames = this.syncItemClassNames();
|
||||
|
||||
@@ -435,4 +438,4 @@ BaseItem.syncItemDefinitions_ = [
|
||||
{ type: BaseModel.TYPE_NOTE_TAG, className: 'NoteTag' },
|
||||
];
|
||||
|
||||
export { BaseItem };
|
||||
module.exports = { BaseItem };
|
||||
@@ -1,14 +1,14 @@
|
||||
import { BaseModel } from 'lib/base-model.js';
|
||||
import { Log } from 'lib/log.js';
|
||||
import { promiseChain } from 'lib/promise-utils.js';
|
||||
import { time } from 'lib/time-utils.js';
|
||||
import { Note } from 'lib/models/note.js';
|
||||
import { Setting } from 'lib/models/setting.js';
|
||||
import { Database } from 'lib/database.js';
|
||||
import { _ } from 'lib/locale.js';
|
||||
import moment from 'moment';
|
||||
import { BaseItem } from 'lib/models/base-item.js';
|
||||
import lodash from 'lodash';
|
||||
const { BaseModel } = require('lib/base-model.js');
|
||||
const { Log } = require('lib/log.js');
|
||||
const { promiseChain } = require('lib/promise-utils.js');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const { Note } = require('lib/models/note.js');
|
||||
const { Setting } = require('lib/models/setting.js');
|
||||
const { Database } = require('lib/database.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const moment = require('moment');
|
||||
const { BaseItem } = require('lib/models/base-item.js');
|
||||
const lodash = require('lodash');
|
||||
|
||||
class Folder extends BaseItem {
|
||||
|
||||
@@ -34,6 +34,19 @@ class Folder extends BaseItem {
|
||||
}
|
||||
}
|
||||
|
||||
static async findUniqueFolderTitle(title) {
|
||||
let counter = 1;
|
||||
let titleToTry = title;
|
||||
while (true) {
|
||||
const folder = await this.loadByField('title', titleToTry);
|
||||
if (!folder) return titleToTry;
|
||||
titleToTry = title + ' (' + counter + ')';
|
||||
counter++;
|
||||
if (counter >= 100) titleToTry = title + ' (' + ((new Date()).getTime()) + ')';
|
||||
if (counter >= 1000) throw new Error('Cannot find unique title');
|
||||
}
|
||||
}
|
||||
|
||||
static noteIds(parentId) {
|
||||
return this.db().selectAll('SELECT id FROM notes WHERE is_conflict = 0 AND parent_id = ?', [parentId]).then((rows) => {
|
||||
let output = [];
|
||||
@@ -73,7 +86,7 @@ class Folder extends BaseItem {
|
||||
|
||||
this.dispatch({
|
||||
type: 'FOLDER_DELETE',
|
||||
folderId: folderId,
|
||||
id: folderId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -143,7 +156,7 @@ class Folder extends BaseItem {
|
||||
|
||||
return super.save(o, options).then((folder) => {
|
||||
this.dispatch({
|
||||
type: 'FOLDERS_UPDATE_ONE',
|
||||
type: 'FOLDER_UPDATE_ONE',
|
||||
folder: folder,
|
||||
});
|
||||
return folder;
|
||||
@@ -152,4 +165,4 @@ class Folder extends BaseItem {
|
||||
|
||||
}
|
||||
|
||||
export { Folder };
|
||||
module.exports = { Folder };
|
||||
@@ -1,6 +1,5 @@
|
||||
import { BaseItem } from 'lib/models/base-item.js';
|
||||
import { BaseModel } from 'lib/base-model.js';
|
||||
import lodash from 'lodash';
|
||||
const { BaseItem } = require('lib/models/base-item.js');
|
||||
const { BaseModel } = require('lib/base-model.js');
|
||||
|
||||
class NoteTag extends BaseItem {
|
||||
|
||||
@@ -23,6 +22,15 @@ class NoteTag extends BaseItem {
|
||||
return this.modelSelectAll('SELECT * FROM note_tags WHERE note_id IN ("' + noteIds.join('","') + '")');
|
||||
}
|
||||
|
||||
static async tagIdsByNoteId(noteId) {
|
||||
let rows = await this.db().selectAll('SELECT tag_id FROM note_tags WHERE note_id = ?', [noteId]);
|
||||
let output = [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
output.push(rows[i].tag_id);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { NoteTag };
|
||||
module.exports = { NoteTag };
|
||||
@@ -1,14 +1,13 @@
|
||||
import { BaseModel } from 'lib/base-model.js';
|
||||
import { Log } from 'lib/log.js';
|
||||
import { sprintf } from 'sprintf-js';
|
||||
import { Folder } from 'lib/models/folder.js';
|
||||
import { BaseItem } from 'lib/models/base-item.js';
|
||||
import { Setting } from 'lib/models/setting.js';
|
||||
import { shim } from 'lib/shim.js';
|
||||
import { time } from 'lib/time-utils.js';
|
||||
import { _ } from 'lib/locale.js';
|
||||
import moment from 'moment';
|
||||
import lodash from 'lodash';
|
||||
const { BaseModel } = require('lib/base-model.js');
|
||||
const { Log } = require('lib/log.js');
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const { BaseItem } = require('lib/models/base-item.js');
|
||||
const { Setting } = require('lib/models/setting.js');
|
||||
const { shim } = require('lib/shim.js');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const moment = require('moment');
|
||||
const lodash = require('lodash');
|
||||
|
||||
class Note extends BaseItem {
|
||||
|
||||
@@ -41,6 +40,34 @@ class Note extends BaseItem {
|
||||
return super.serialize(note, 'note', fieldNames);
|
||||
}
|
||||
|
||||
static minimalSerializeForDisplay(note) {
|
||||
let n = Object.assign({}, note);
|
||||
|
||||
let fieldNames = this.fieldNames();
|
||||
|
||||
if (!n.is_conflict) lodash.pull(fieldNames, 'is_conflict');
|
||||
if (!Number(n.latitude)) lodash.pull(fieldNames, 'latitude');
|
||||
if (!Number(n.longitude)) lodash.pull(fieldNames, 'longitude');
|
||||
if (!Number(n.altitude)) lodash.pull(fieldNames, 'altitude');
|
||||
if (!n.author) lodash.pull(fieldNames, 'author');
|
||||
if (!n.source_url) lodash.pull(fieldNames, 'source_url');
|
||||
if (!n.is_todo) {
|
||||
lodash.pull(fieldNames, 'is_todo');
|
||||
lodash.pull(fieldNames, 'todo_due');
|
||||
lodash.pull(fieldNames, 'todo_completed');
|
||||
}
|
||||
if (!n.application_data) lodash.pull(fieldNames, 'application_data');
|
||||
|
||||
lodash.pull(fieldNames, 'type_');
|
||||
lodash.pull(fieldNames, 'title');
|
||||
lodash.pull(fieldNames, 'body');
|
||||
lodash.pull(fieldNames, 'created_time');
|
||||
lodash.pull(fieldNames, 'updated_time');
|
||||
lodash.pull(fieldNames, 'order');
|
||||
|
||||
return super.serialize(n, 'note', fieldNames);
|
||||
}
|
||||
|
||||
static defaultTitle(note) {
|
||||
if (note.title && note.title.length) return note.title;
|
||||
|
||||
@@ -82,11 +109,27 @@ class Note extends BaseItem {
|
||||
return output;
|
||||
}
|
||||
|
||||
// Note: sort logic must be duplicated in previews();
|
||||
static sortNotes(notes, orders, uncompletedTodosOnTop) {
|
||||
const noteOnTop = (note) => {
|
||||
return uncompletedTodosOnTop && note.is_todo && !note.todo_completed;
|
||||
}
|
||||
|
||||
const noteFieldComp = (f1, f2) => {
|
||||
if (f1 === f2) return 0;
|
||||
return f1 < f2 ? -1 : +1;
|
||||
}
|
||||
|
||||
// Makes the sort deterministic, so that if, for example, a and b have the
|
||||
// same updated_time, they aren't swapped every time a list is refreshed.
|
||||
const sortIdenticalNotes = (a, b) => {
|
||||
let r = null;
|
||||
r = noteFieldComp(a.user_updated_time, b.user_updated_time); if (r) return r;
|
||||
r = noteFieldComp(a.user_created_time, b.user_created_time); if (r) return r;
|
||||
r = noteFieldComp(a.title.toLowerCase(), b.title.toLowerCase()); if (r) return r;
|
||||
return noteFieldComp(a.id, b.id);
|
||||
}
|
||||
|
||||
return notes.sort((a, b) => {
|
||||
if (noteOnTop(a) && !noteOnTop(b)) return -1;
|
||||
if (!noteOnTop(a) && noteOnTop(b)) return +1;
|
||||
@@ -98,10 +141,10 @@ class Note extends BaseItem {
|
||||
if (a[order.by] < b[order.by]) r = +1;
|
||||
if (a[order.by] > b[order.by]) r = -1;
|
||||
if (order.dir == 'ASC') r = -r;
|
||||
if (r) break;
|
||||
if (r !== 0) return r;
|
||||
}
|
||||
|
||||
return r;
|
||||
return sortIdenticalNotes(a, b);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -129,17 +172,22 @@ class Note extends BaseItem {
|
||||
}
|
||||
|
||||
static async previews(parentId, options = null) {
|
||||
// Note: ordering logic must be duplicated in sortNotes, which
|
||||
// Note: ordering logic must be duplicated in sortNotes(), which
|
||||
// is used to sort already loaded notes.
|
||||
|
||||
if (!options) options = {};
|
||||
if (!options.order) options.order = [{ by: 'user_updated_time', dir: 'DESC' }];
|
||||
if (!options.order) options.order = [
|
||||
{ by: 'user_updated_time', dir: 'DESC' },
|
||||
{ by: 'user_created_time', dir: 'DESC' },
|
||||
{ by: 'title', dir: 'DESC' },
|
||||
{ by: 'id', dir: 'DESC' },
|
||||
];
|
||||
if (!options.conditions) options.conditions = [];
|
||||
if (!options.conditionsParams) options.conditionsParams = [];
|
||||
if (!options.fields) options.fields = this.previewFields();
|
||||
if (!options.uncompletedTodosOnTop) options.uncompletedTodosOnTop = false;
|
||||
|
||||
if (parentId == Folder.conflictFolderId()) {
|
||||
if (parentId == BaseItem.getClass('Folder').conflictFolderId()) {
|
||||
options.conditions.push('is_conflict = 1');
|
||||
} else {
|
||||
options.conditions.push('is_conflict = 0');
|
||||
@@ -238,11 +286,21 @@ class Note extends BaseItem {
|
||||
geoData = Object.assign({}, this.geolocationCache_);
|
||||
} else {
|
||||
this.geolocationUpdating_ = true;
|
||||
|
||||
this.logger().info('Fetching geolocation...');
|
||||
geoData = await shim.Geolocation.currentPosition();
|
||||
try {
|
||||
geoData = await shim.Geolocation.currentPosition();
|
||||
} catch (error) {
|
||||
this.logger().error('Could not get lat/long for note ' + noteId + ': ', error);
|
||||
geoData = null;
|
||||
}
|
||||
|
||||
this.geolocationUpdating_ = false;
|
||||
|
||||
if (!geoData) return;
|
||||
|
||||
this.logger().info('Got lat/long');
|
||||
this.geolocationCache_ = geoData;
|
||||
this.geolocationUpdating_ = false;
|
||||
}
|
||||
|
||||
this.logger().info('Updating lat/long of note ' + noteId);
|
||||
@@ -267,7 +325,7 @@ class Note extends BaseItem {
|
||||
}
|
||||
|
||||
static async copyToFolder(noteId, folderId) {
|
||||
if (folderId == Folder.conflictFolderId()) throw new Error(_('Cannot copy note to "%s" notebook', Folder.conflictFolderIdTitle()));
|
||||
if (folderId == this.getClass('Folder').conflictFolderId()) throw new Error(_('Cannot copy note to "%s" notebook', this.getClass('Folder').conflictFolderIdTitle()));
|
||||
|
||||
return Note.duplicate(noteId, {
|
||||
changes: {
|
||||
@@ -278,7 +336,7 @@ class Note extends BaseItem {
|
||||
}
|
||||
|
||||
static async moveToFolder(noteId, folderId) {
|
||||
if (folderId == Folder.conflictFolderId()) throw new Error(_('Cannot move note to "%s" notebook', Folder.conflictFolderIdTitle()));
|
||||
if (folderId == this.getClass('Folder').conflictFolderId()) throw new Error(_('Cannot move note to "%s" notebook', this.getClass('Folder').conflictFolderIdTitle()));
|
||||
|
||||
// When moving a note to a different folder, the user timestamp is not updated.
|
||||
// However updated_time is updated so that the note can be synced later on.
|
||||
@@ -328,7 +386,7 @@ class Note extends BaseItem {
|
||||
|
||||
return super.save(o, options).then((note) => {
|
||||
this.dispatch({
|
||||
type: 'NOTES_UPDATE_ONE',
|
||||
type: 'NOTE_UPDATE_ONE',
|
||||
note: note,
|
||||
});
|
||||
|
||||
@@ -340,14 +398,36 @@ class Note extends BaseItem {
|
||||
let r = await super.delete(id, options);
|
||||
|
||||
this.dispatch({
|
||||
type: 'NOTES_DELETE',
|
||||
noteId: id,
|
||||
type: 'NOTE_DELETE',
|
||||
id: id,
|
||||
});
|
||||
}
|
||||
|
||||
static batchDelete(ids, options = null) {
|
||||
const result = super.batchDelete(ids, options);
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
this.dispatch({
|
||||
type: 'NOTE_DELETE',
|
||||
id: ids[i],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Tells whether the conflict between the local and remote note can be ignored.
|
||||
static mustHandleConflict(localNote, remoteNote) {
|
||||
// That shouldn't happen so throw an exception
|
||||
if (localNote.id !== remoteNote.id) throw new Error('Cannot handle conflict for two different notes');
|
||||
|
||||
if (localNote.title !== remoteNote.title) return true;
|
||||
if (localNote.body !== remoteNote.body) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Note.updateGeolocationEnabled_ = true;
|
||||
Note.geolocationUpdating_ = false;
|
||||
|
||||
export { Note };
|
||||
module.exports = { Note };
|
||||
@@ -1,11 +1,10 @@
|
||||
import { BaseModel } from 'lib/base-model.js';
|
||||
import { BaseItem } from 'lib/models/base-item.js';
|
||||
import { Setting } from 'lib/models/setting.js';
|
||||
import { mime } from 'lib/mime-utils.js';
|
||||
import { filename } from 'lib/path-utils.js';
|
||||
import { FsDriverDummy } from 'lib/fs-driver-dummy.js';
|
||||
import { markdownUtils } from 'lib/markdown-utils.js';
|
||||
import lodash from 'lodash';
|
||||
const { BaseModel } = require('lib/base-model.js');
|
||||
const { BaseItem } = require('lib/models/base-item.js');
|
||||
const { Setting } = require('lib/models/setting.js');
|
||||
const { mime } = require('lib/mime-utils.js');
|
||||
const { filename } = require('lib/path-utils.js');
|
||||
const { FsDriverDummy } = require('lib/fs-driver-dummy.js');
|
||||
const { markdownUtils } = require('lib/markdown-utils.js');
|
||||
|
||||
class Resource extends BaseItem {
|
||||
|
||||
@@ -33,10 +32,14 @@ class Resource extends BaseItem {
|
||||
return super.serialize(item, 'resource', fieldNames);
|
||||
}
|
||||
|
||||
static fullPath(resource) {
|
||||
let extension = mime.toFileExtension(resource.mime);
|
||||
static filename(resource) {
|
||||
let extension = resource.mime ? mime.toFileExtension(resource.mime) : '';
|
||||
extension = extension ? '.' + extension : '';
|
||||
return Setting.value('resourceDir') + '/' + resource.id + extension;
|
||||
return resource.id + extension;
|
||||
}
|
||||
|
||||
static fullPath(resource) {
|
||||
return Setting.value('resourceDir') + '/' + this.filename(resource);
|
||||
}
|
||||
|
||||
static markdownTag(resource) {
|
||||
@@ -59,7 +62,7 @@ class Resource extends BaseItem {
|
||||
return filename(path);
|
||||
}
|
||||
|
||||
static content(resource) {
|
||||
static async content(resource) {
|
||||
return this.fsDriver().readFile(this.fullPath(resource));
|
||||
}
|
||||
|
||||
@@ -68,7 +71,7 @@ class Resource extends BaseItem {
|
||||
}
|
||||
|
||||
static isResourceUrl(url) {
|
||||
return url.length === 34 && url[0] === ':' && url[1] === '/';
|
||||
return url && url.length === 34 && url[0] === ':' && url[1] === '/';
|
||||
}
|
||||
|
||||
static urlToId(url) {
|
||||
@@ -78,4 +81,6 @@ class Resource extends BaseItem {
|
||||
|
||||
}
|
||||
|
||||
export { Resource };
|
||||
Resource.IMAGE_MAX_DIMENSION = 1920;
|
||||
|
||||
module.exports = { Resource };
|
||||
@@ -1,7 +1,9 @@
|
||||
import { BaseModel } from 'lib/base-model.js';
|
||||
import { Database } from 'lib/database.js';
|
||||
import { Logger } from 'lib/logger.js';
|
||||
import { _, supportedLocalesToLanguages, defaultLocale } from 'lib/locale.js';
|
||||
const { BaseModel } = require('lib/base-model.js');
|
||||
const { Database } = require('lib/database.js');
|
||||
const { Logger } = require('lib/logger.js');
|
||||
const SyncTargetRegistry = require('lib/SyncTargetRegistry.js');
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const { _, supportedLocalesToLanguages, defaultLocale } = require('lib/locale.js');
|
||||
|
||||
class Setting extends BaseModel {
|
||||
|
||||
@@ -13,21 +15,84 @@ class Setting extends BaseModel {
|
||||
return BaseModel.TYPE_SETTING;
|
||||
}
|
||||
|
||||
static metadata() {
|
||||
if (this.metadata_) return this.metadata_;
|
||||
|
||||
this.metadata_ = {
|
||||
'activeFolderId': { value: '', type: Setting.TYPE_STRING, public: false },
|
||||
'firstStart': { value: true, type: Setting.TYPE_BOOL, public: false },
|
||||
'sync.2.path': { value: '', type: Setting.TYPE_STRING, public: true, appTypes: ['cli'], label: () => _('File system synchronisation target directory'), description: () => _('The path to synchronise with when file system synchronisation is enabled. See `sync.target`.') },
|
||||
'sync.3.auth': { value: '', type: Setting.TYPE_STRING, public: false },
|
||||
'sync.4.auth': { value: '', type: Setting.TYPE_STRING, public: false },
|
||||
'sync.1.context': { value: '', type: Setting.TYPE_STRING, public: false },
|
||||
'sync.2.context': { value: '', type: Setting.TYPE_STRING, public: false },
|
||||
'sync.3.context': { value: '', type: Setting.TYPE_STRING, public: false },
|
||||
'sync.4.context': { value: '', type: Setting.TYPE_STRING, public: false },
|
||||
'sync.5.context': { value: '', type: Setting.TYPE_STRING, public: false },
|
||||
'sync.6.context': { value: '', type: Setting.TYPE_STRING, 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();
|
||||
}},
|
||||
'theme': { value: Setting.THEME_LIGHT, type: Setting.TYPE_INT, public: true, appTypes: ['mobile'], isEnum: true, label: () => _('Theme'), options: () => {
|
||||
let output = {};
|
||||
output[Setting.THEME_LIGHT] = _('Light');
|
||||
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 todos on top of the lists') },
|
||||
'trackLocation': { value: true, type: Setting.TYPE_BOOL, public: true, label: () => _('Save geo-location with notes') },
|
||||
'sync.interval': { value: 300, type: Setting.TYPE_INT, isEnum: true, public: true, label: () => _('Synchronisation interval'), options: () => {
|
||||
return {
|
||||
0: _('Disabled'),
|
||||
300: _('%d minutes', 5),
|
||||
600: _('%d minutes', 10),
|
||||
1800: _('%d minutes', 30),
|
||||
3600: _('%d hour', 1),
|
||||
43200: _('%d hours', 12),
|
||||
86400: _('%d hours', 24),
|
||||
};
|
||||
}},
|
||||
'noteVisiblePanes': { value: ['editor', 'viewer'], type: Setting.TYPE_ARRAY, public: false, appTypes: ['desktop'] },
|
||||
'autoUpdateEnabled': { value: true, type: Setting.TYPE_BOOL, public: true, appTypes: ['desktop'], label: () => _('Automatically update the application') },
|
||||
'showAdvancedOptions': { value: false, type: Setting.TYPE_BOOL, public: true, appTypes: ['mobile' ], label: () => _('Show advanced options') },
|
||||
'sync.target': { value: SyncTargetRegistry.nameToId('onedrive'), type: Setting.TYPE_INT, isEnum: true, public: true, label: () => _('Synchronisation target'), description: () => _('The target to synchonise to. If synchronising with the file system, set `sync.2.path` to specify the target directory.'), options: () => {
|
||||
return SyncTargetRegistry.idAndLabelPlainObject();
|
||||
}},
|
||||
};
|
||||
|
||||
return this.metadata_;
|
||||
}
|
||||
|
||||
static settingMetadata(key) {
|
||||
if (!(key in this.metadata_)) throw new Error('Unknown key: ' + key);
|
||||
let output = Object.assign({}, this.metadata_[key]);
|
||||
const metadata = this.metadata();
|
||||
if (!(key in metadata)) throw new Error('Unknown key: ' + key);
|
||||
let output = Object.assign({}, metadata[key]);
|
||||
output.key = key;
|
||||
return output;
|
||||
}
|
||||
|
||||
static keyExists(key) {
|
||||
return key in this.metadata();
|
||||
}
|
||||
|
||||
static keys(publicOnly = false, appType = null) {
|
||||
if (!this.keys_) {
|
||||
const metadata = this.metadata();
|
||||
this.keys_ = [];
|
||||
for (let n in this.metadata_) {
|
||||
if (!this.metadata_.hasOwnProperty(n)) continue;
|
||||
for (let n in metadata) {
|
||||
if (!metadata.hasOwnProperty(n)) continue;
|
||||
this.keys_.push(n);
|
||||
}
|
||||
this.keys_.sort();
|
||||
}
|
||||
|
||||
if (appType || publicOnly) {
|
||||
@@ -54,31 +119,32 @@ class Setting extends BaseModel {
|
||||
return this.modelSelectAll('SELECT * FROM settings').then((rows) => {
|
||||
this.cache_ = [];
|
||||
|
||||
// Old keys - can be removed later
|
||||
const ignore = ['clientId', 'sync.onedrive.auth', 'syncInterval', 'todoOnTop', 'todosOnTop'];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
let c = rows[i];
|
||||
|
||||
if (ignore.indexOf(c.key) >= 0) continue;
|
||||
|
||||
// console.info(c.key + ' = ' + c.value);
|
||||
|
||||
if (!this.keyExists(c.key)) continue;
|
||||
c.value = this.formatValue(c.key, c.value);
|
||||
|
||||
this.cache_.push(c);
|
||||
}
|
||||
|
||||
const keys = this.keys();
|
||||
let keyToValues = {};
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
keyToValues[keys[i]] = this.value(keys[i]);
|
||||
}
|
||||
this.dispatchUpdateAll();
|
||||
});
|
||||
}
|
||||
|
||||
this.dispatch({
|
||||
type: 'SETTINGS_UPDATE_ALL',
|
||||
settings: keyToValues,
|
||||
});
|
||||
static toPlainObject() {
|
||||
const keys = this.keys();
|
||||
let keyToValues = {};
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
keyToValues[keys[i]] = this.value(keys[i]);
|
||||
}
|
||||
return keyToValues;
|
||||
}
|
||||
|
||||
static dispatchUpdateAll() {
|
||||
this.dispatch({
|
||||
type: 'SETTING_UPDATE_ALL',
|
||||
settings: this.toPlainObject(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -107,10 +173,10 @@ class Setting extends BaseModel {
|
||||
|
||||
this.logger().info('Setting: ' + key + ' = ' + c.value + ' => ' + value);
|
||||
|
||||
c.value = this.formatValue(key, value);
|
||||
c.value = value;
|
||||
|
||||
this.dispatch({
|
||||
type: 'SETTINGS_UPDATE_ONE',
|
||||
type: 'SETTING_UPDATE_ONE',
|
||||
key: key,
|
||||
value: c.value,
|
||||
});
|
||||
@@ -126,7 +192,7 @@ class Setting extends BaseModel {
|
||||
});
|
||||
|
||||
this.dispatch({
|
||||
type: 'SETTINGS_UPDATE_ONE',
|
||||
type: 'SETTING_UPDATE_ONE',
|
||||
key: key,
|
||||
value: this.formatValue(key, value),
|
||||
});
|
||||
@@ -139,12 +205,16 @@ class Setting extends BaseModel {
|
||||
value = this.formatValue(key, value);
|
||||
if (md.type == Setting.TYPE_INT) return value.toFixed(0);
|
||||
if (md.type == Setting.TYPE_BOOL) return value ? '1' : '0';
|
||||
if (md.type == Setting.TYPE_ARRAY) return value ? JSON.stringify(value) : '[]';
|
||||
if (md.type == Setting.TYPE_OBJECT) return value ? JSON.stringify(value) : '{}';
|
||||
return value;
|
||||
}
|
||||
|
||||
static formatValue(key, value) {
|
||||
const md = this.settingMetadata(key);
|
||||
|
||||
if (md.type == Setting.TYPE_INT) return Math.floor(Number(value));
|
||||
|
||||
if (md.type == Setting.TYPE_BOOL) {
|
||||
if (typeof value === 'string') {
|
||||
value = value.toLowerCase();
|
||||
@@ -154,12 +224,28 @@ class Setting extends BaseModel {
|
||||
}
|
||||
return !!value;
|
||||
}
|
||||
|
||||
if (md.type === Setting.TYPE_ARRAY) {
|
||||
if (!value) return [];
|
||||
if (Array.isArray(value)) return value;
|
||||
if (typeof value === 'string') return JSON.parse(value);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (md.type === Setting.TYPE_OBJECT) {
|
||||
if (!value) return {};
|
||||
if (typeof value === 'object') return value;
|
||||
if (typeof value === 'string') return JSON.parse(value);
|
||||
return {};
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
static value(key) {
|
||||
if (key in this.constants_) {
|
||||
let output = this.constants_[key];
|
||||
const v = this.constants_[key];
|
||||
const output = typeof v === 'function' ? v() : v;
|
||||
if (output == 'SET_ME') throw new Error('Setting constant has not been set: ' + key);
|
||||
return output;
|
||||
}
|
||||
@@ -200,17 +286,19 @@ class Setting extends BaseModel {
|
||||
}
|
||||
|
||||
static enumOptions(key) {
|
||||
if (!this.metadata_[key]) throw new Error('Unknown key: ' + key);
|
||||
if (!this.metadata_[key].options) throw new Error('No options for: ' + key);
|
||||
return this.metadata_[key].options();
|
||||
const metadata = this.metadata();
|
||||
if (!metadata[key]) throw new Error('Unknown key: ' + key);
|
||||
if (!metadata[key].options) throw new Error('No options for: ' + key);
|
||||
return metadata[key].options();
|
||||
}
|
||||
|
||||
static enumOptionsDoc(key) {
|
||||
static enumOptionsDoc(key, templateString = null) {
|
||||
if (templateString === null) templateString = '%s: %s';
|
||||
const options = this.enumOptions(key);
|
||||
let output = [];
|
||||
for (let n in options) {
|
||||
if (!options.hasOwnProperty(n)) continue;
|
||||
output.push(_('%s: %s', n, options[n]));
|
||||
output.push(sprintf(templateString, n, options[n]));
|
||||
}
|
||||
return output.join(', ');
|
||||
}
|
||||
@@ -277,10 +365,12 @@ class Setting extends BaseModel {
|
||||
static publicSettings(appType) {
|
||||
if (!appType) throw new Error('appType is required');
|
||||
|
||||
const metadata = this.metadata();
|
||||
|
||||
let output = {};
|
||||
for (let key in Setting.metadata_) {
|
||||
if (!Setting.metadata_.hasOwnProperty(key)) continue;
|
||||
let s = Object.assign({}, Setting.metadata_[key]);
|
||||
for (let key in metadata) {
|
||||
if (!metadata.hasOwnProperty(key)) continue;
|
||||
let s = Object.assign({}, metadata[key]);
|
||||
if (!s.public) continue;
|
||||
if (s.appTypes && s.appTypes.indexOf(appType) < 0) continue;
|
||||
s.value = this.value(key);
|
||||
@@ -289,81 +379,37 @@ class Setting extends BaseModel {
|
||||
return output;
|
||||
}
|
||||
|
||||
}
|
||||
static typeToString(typeId) {
|
||||
if (typeId === Setting.TYPE_INT) return 'int';
|
||||
if (typeId === Setting.TYPE_STRING) return 'string';
|
||||
if (typeId === Setting.TYPE_BOOL) return 'bool';
|
||||
if (typeId === Setting.TYPE_ARRAY) return 'array';
|
||||
if (typeId === Setting.TYPE_OBJECT) return 'object';
|
||||
}
|
||||
|
||||
Setting.SYNC_TARGET_MEMORY = 1;
|
||||
Setting.SYNC_TARGET_FILESYSTEM = 2;
|
||||
Setting.SYNC_TARGET_ONEDRIVE = 3;
|
||||
}
|
||||
|
||||
Setting.TYPE_INT = 1;
|
||||
Setting.TYPE_STRING = 2;
|
||||
Setting.TYPE_BOOL = 3;
|
||||
Setting.TYPE_ARRAY = 4;
|
||||
Setting.TYPE_OBJECT = 5;
|
||||
|
||||
Setting.THEME_LIGHT = 1;
|
||||
Setting.THEME_DARK = 2;
|
||||
|
||||
Setting.metadata_ = {
|
||||
'activeFolderId': { value: '', type: Setting.TYPE_STRING, public: false },
|
||||
'firstStart': { value: true, type: Setting.TYPE_BOOL, public: false },
|
||||
'sync.2.path': { value: '', type: Setting.TYPE_STRING, public: true, appTypes: ['cli'] },
|
||||
'sync.3.auth': { value: '', type: Setting.TYPE_STRING, public: false },
|
||||
'sync.target': { value: Setting.SYNC_TARGET_ONEDRIVE, type: Setting.TYPE_INT, isEnum: true, public: true, label: () => _('Synchronisation target'), options: () => {
|
||||
let output = {};
|
||||
output[Setting.SYNC_TARGET_MEMORY] = 'Memory';
|
||||
output[Setting.SYNC_TARGET_FILESYSTEM] = _('File system');
|
||||
output[Setting.SYNC_TARGET_ONEDRIVE] = _('OneDrive');
|
||||
return output;
|
||||
}},
|
||||
'sync.1.context': { value: '', type: Setting.TYPE_STRING, public: false },
|
||||
'sync.2.context': { value: '', type: Setting.TYPE_STRING, public: false },
|
||||
'sync.3.context': { value: '', type: Setting.TYPE_STRING, public: false },
|
||||
'sync.4.context': { value: '', type: Setting.TYPE_STRING, public: false },
|
||||
'sync.5.context': { value: '', type: Setting.TYPE_STRING, public: false },
|
||||
'sync.6.context': { value: '', type: Setting.TYPE_STRING, public: false },
|
||||
'editor': { value: '', type: Setting.TYPE_STRING, public: true, appTypes: ['cli'] },
|
||||
'locale': { value: defaultLocale(), type: Setting.TYPE_STRING, isEnum: true, public: true, label: () => _('Language'), options: () => {
|
||||
return supportedLocalesToLanguages();
|
||||
}},
|
||||
// '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 todos on top of the lists') },
|
||||
'trackLocation': { value: true, type: Setting.TYPE_BOOL, public: true, label: () => _('Save location with notes') },
|
||||
'sync.interval': { value: 300, type: Setting.TYPE_INT, isEnum: true, public: true, appTypes: ['mobile'], label: () => _('Synchronisation interval'), options: () => {
|
||||
return {
|
||||
0: _('Disabled'),
|
||||
300: _('%d minutes', 5),
|
||||
600: _('%d minutes', 10),
|
||||
1800: _('%d minutes', 30),
|
||||
3600: _('%d hour', 1),
|
||||
43200: _('%d hours', 12),
|
||||
86400: _('%d hours', 24),
|
||||
};
|
||||
}},
|
||||
'theme': { value: Setting.THEME_LIGHT, type: Setting.TYPE_INT, public: true, appTypes: ['mobile'], isEnum: true, label: () => _('Theme'), options: () => {
|
||||
let output = {};
|
||||
output[Setting.THEME_LIGHT] = _('Light');
|
||||
output[Setting.THEME_DARK] = _('Dark');
|
||||
return output;
|
||||
}},
|
||||
};
|
||||
|
||||
// Contains constants that are set by the application and
|
||||
// cannot be modified by the user:
|
||||
Setting.constants_ = {
|
||||
'env': 'SET_ME',
|
||||
'appName': 'joplin',
|
||||
'appId': 'SET_ME', // Each app should set this identifier
|
||||
'appType': 'SET_ME', // 'cli' or 'mobile'
|
||||
'resourceDir': '',
|
||||
'profileDir': '',
|
||||
'tempDir': '',
|
||||
env: 'SET_ME',
|
||||
isDemo: false,
|
||||
appName: 'joplin',
|
||||
appId: 'SET_ME', // Each app should set this identifier
|
||||
appType: 'SET_ME', // 'cli' or 'mobile'
|
||||
resourceDir: '',
|
||||
profileDir: '',
|
||||
tempDir: '',
|
||||
openDevTools: false,
|
||||
}
|
||||
|
||||
export { Setting };
|
||||
module.exports = { Setting };
|
||||
@@ -1,9 +1,8 @@
|
||||
import { BaseModel } from 'lib/base-model.js';
|
||||
import { BaseItem } from 'lib/models/base-item.js';
|
||||
import { NoteTag } from 'lib/models/note-tag.js';
|
||||
import { Note } from 'lib/models/note.js';
|
||||
import { time } from 'lib/time-utils.js';
|
||||
import lodash from 'lodash';
|
||||
const { BaseModel } = require('lib/base-model.js');
|
||||
const { BaseItem } = require('lib/models/base-item.js');
|
||||
const { NoteTag } = require('lib/models/note-tag.js');
|
||||
const { Note } = require('lib/models/note.js');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
|
||||
class Tag extends BaseItem {
|
||||
|
||||
@@ -39,14 +38,42 @@ class Tag extends BaseItem {
|
||||
});
|
||||
}
|
||||
|
||||
// Untag all the notes and delete tag
|
||||
static async untagAll(tagId) {
|
||||
const noteTags = await NoteTag.modelSelectAll('SELECT id FROM note_tags WHERE tag_id = ?', [tagId]);
|
||||
for (let i = 0; i < noteTags.length; i++) {
|
||||
await NoteTag.delete(noteTags[i].id);
|
||||
}
|
||||
|
||||
await Tag.delete(tagId);
|
||||
}
|
||||
|
||||
static async delete(id, options = null) {
|
||||
if (!options) options = {};
|
||||
|
||||
await super.delete(id, options);
|
||||
|
||||
this.dispatch({
|
||||
type: 'TAG_DELETE',
|
||||
id: id,
|
||||
});
|
||||
}
|
||||
|
||||
static async addNote(tagId, noteId) {
|
||||
let hasIt = await this.hasNote(tagId, noteId);
|
||||
if (hasIt) return;
|
||||
|
||||
return NoteTag.save({
|
||||
const output = await NoteTag.save({
|
||||
tag_id: tagId,
|
||||
note_id: noteId,
|
||||
});
|
||||
|
||||
this.dispatch({
|
||||
type: 'TAG_UPDATE_ONE',
|
||||
tag: await Tag.load(tagId),
|
||||
});
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
static async removeNote(tagId, noteId) {
|
||||
@@ -54,6 +81,11 @@ class Tag extends BaseItem {
|
||||
for (let i = 0; i < noteTags.length; i++) {
|
||||
await NoteTag.delete(noteTags[i].id);
|
||||
}
|
||||
|
||||
this.dispatch({
|
||||
type: 'TAG_UPDATE_ONE',
|
||||
tag: await Tag.load(tagId),
|
||||
});
|
||||
}
|
||||
|
||||
static async hasNote(tagId, noteId) {
|
||||
@@ -61,6 +93,51 @@ class Tag extends BaseItem {
|
||||
return !!r;
|
||||
}
|
||||
|
||||
static async allWithNotes() {
|
||||
return await Tag.modelSelectAll('SELECT * FROM tags WHERE id IN (SELECT DISTINCT tag_id FROM note_tags)');
|
||||
}
|
||||
|
||||
static async tagsByNoteId(noteId) {
|
||||
const tagIds = await NoteTag.tagIdsByNoteId(noteId);
|
||||
return this.modelSelectAll('SELECT * FROM tags WHERE id IN ("' + tagIds.join('","') + '")');
|
||||
}
|
||||
|
||||
static async setNoteTagsByTitles(noteId, tagTitles) {
|
||||
const previousTags = await this.tagsByNoteId(noteId);
|
||||
const addedTitles = [];
|
||||
|
||||
for (let i = 0; i < tagTitles.length; i++) {
|
||||
const title = tagTitles[i].trim().toLowerCase();
|
||||
if (!title) continue;
|
||||
let tag = await this.loadByField('title', title);
|
||||
if (!tag) tag = await Tag.save({ title: title }, { userSideValidation: true });
|
||||
await this.addNote(tag.id, noteId);
|
||||
addedTitles.push(title);
|
||||
}
|
||||
|
||||
for (let i = 0; i < previousTags.length; i++) {
|
||||
if (addedTitles.indexOf(previousTags[i].title) < 0) {
|
||||
await this.removeNote(previousTags[i].id, noteId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async save(o, options = null) {
|
||||
if (options && options.userSideValidation) {
|
||||
if ('title' in o) {
|
||||
o.title = o.title.trim().toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
return super.save(o, options).then((tag) => {
|
||||
this.dispatch({
|
||||
type: 'TAG_UPDATE_ONE',
|
||||
tag: tag,
|
||||
});
|
||||
return tag;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { Tag };
|
||||
module.exports = { Tag };
|
||||
@@ -1,4 +1,4 @@
|
||||
import { shim } from 'lib/shim.js'
|
||||
const { shim } = require('lib/shim.js');
|
||||
|
||||
const netUtils = {};
|
||||
|
||||
@@ -12,4 +12,22 @@ netUtils.ip = async () => {
|
||||
return ip.ip;
|
||||
}
|
||||
|
||||
export { netUtils };
|
||||
netUtils.findAvailablePort = async (possiblePorts, extraRandomPortsToTry = 20) => {
|
||||
const tcpPortUsed = require('tcp-port-used');
|
||||
|
||||
for (let i = 0; i < extraRandomPortsToTry; i++) {
|
||||
possiblePorts.push(Math.floor(8000 + Math.random() * 2000));
|
||||
}
|
||||
|
||||
let port = null;
|
||||
for (let i = 0; i < possiblePorts.length; i++) {
|
||||
let inUse = await tcpPortUsed.check(possiblePorts[i]);
|
||||
if (!inUse) {
|
||||
port = possiblePorts[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
module.exports = { netUtils };
|
||||
@@ -1,7 +1,7 @@
|
||||
import { shim } from 'lib/shim.js';
|
||||
import { stringify } from 'query-string';
|
||||
import { time } from 'lib/time-utils.js';
|
||||
import { Logger } from 'lib/logger.js'
|
||||
const { shim } = require('lib/shim.js');
|
||||
const { stringify } = require('query-string');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const { Logger } = require('lib/logger.js');
|
||||
|
||||
class OneDriveApi {
|
||||
|
||||
@@ -47,6 +47,10 @@ class OneDriveApi {
|
||||
return 'https://login.microsoftonline.com/common/oauth2/v2.0/token';
|
||||
}
|
||||
|
||||
nativeClientRedirectUrl() {
|
||||
return 'https://login.microsoftonline.com/common/oauth2/nativeclient';
|
||||
}
|
||||
|
||||
auth() {
|
||||
return this.auth_;
|
||||
}
|
||||
@@ -158,6 +162,8 @@ class OneDriveApi {
|
||||
|
||||
if (data) options.body = data;
|
||||
|
||||
options.timeout = 1000 * 60 * 5; // in ms
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
options.headers['Authorization'] = 'bearer ' + this.token();
|
||||
|
||||
@@ -171,15 +177,8 @@ class OneDriveApi {
|
||||
response = await shim.fetchBlob(url, options);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message == 'Network request failed') {
|
||||
// Unfortunately the error 'Network request failed' doesn't have a type
|
||||
// or error code, so hopefully that message won't change and is not localized
|
||||
this.logger().info('Got error "Network request failed" - retrying (' + i + ')...');
|
||||
await time.sleep((i + 1) * 3);
|
||||
continue;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
this.logger().error('Got unhandled error:', error ? error.code : '', error ? error.message : '', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -214,6 +213,7 @@ class OneDriveApi {
|
||||
return;
|
||||
} else {
|
||||
error.request = method + ' ' + url + ' ' + JSON.stringify(query) + ' ' + JSON.stringify(data) + ' ' + JSON.stringify(options);
|
||||
error.headers = await response.headers;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -267,4 +267,4 @@ class OneDriveApi {
|
||||
|
||||
}
|
||||
|
||||
export { OneDriveApi };
|
||||
module.exports = { OneDriveApi };
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Setting } from 'lib/models/setting.js';
|
||||
const { Setting } = require('lib/models/setting.js');
|
||||
|
||||
const parameters_ = {};
|
||||
|
||||
@@ -7,6 +7,10 @@ parameters_.dev = {
|
||||
id: 'cbabb902-d276-4ea4-aa88-062a5889d6dc',
|
||||
secret: 'YSvrgQMqw9NzVqgiLfuEky1',
|
||||
},
|
||||
oneDriveDemo: {
|
||||
id: '606fd4d7-4dfb-4310-b8b7-a47d96aa22b6',
|
||||
secret: 'qabchuPYL7931$ePDEQ3~_$',
|
||||
},
|
||||
};
|
||||
|
||||
parameters_.prod = {
|
||||
@@ -14,10 +18,19 @@ parameters_.prod = {
|
||||
id: 'e09fc0de-c958-424f-83a2-e56a721d331b',
|
||||
secret: 'JA3cwsqSGHFtjMwd5XoF5L5',
|
||||
},
|
||||
oneDriveDemo: {
|
||||
id: '606fd4d7-4dfb-4310-b8b7-a47d96aa22b6',
|
||||
secret: 'qabchuPYL7931$ePDEQ3~_$',
|
||||
},
|
||||
};
|
||||
|
||||
function parameters() {
|
||||
return parameters_[Setting.value('env')];
|
||||
function parameters(env = null) {
|
||||
if (env === null) env = Setting.value('env');
|
||||
let output = parameters_[env];
|
||||
if (Setting.value('isDemo')) {
|
||||
output.oneDrive = output.oneDriveDemo;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export { parameters }
|
||||
module.exports = { parameters };
|
||||
@@ -1,13 +1,13 @@
|
||||
function dirname(path) {
|
||||
if (!path) throw new Error('Path is empty');
|
||||
let s = path.split('/');
|
||||
let s = path.split(/\/|\\/);
|
||||
s.pop();
|
||||
return s.join('/');
|
||||
}
|
||||
|
||||
function basename(path) {
|
||||
if (!path) throw new Error('Path is empty');
|
||||
let s = path.split('/');
|
||||
let s = path.split(/\/|\\/);
|
||||
return s[s.length - 1];
|
||||
}
|
||||
|
||||
@@ -35,4 +35,4 @@ function isHidden(path) {
|
||||
return b[0] === '.';
|
||||
}
|
||||
|
||||
export { basename, dirname, filename, isHidden, fileExtension };
|
||||
module.exports = { basename, dirname, filename, isHidden, fileExtension };
|
||||
@@ -1,4 +1,4 @@
|
||||
import { time } from 'lib/time-utils.js';
|
||||
const { time } = require('lib/time-utils.js');
|
||||
|
||||
class PoorManIntervals {
|
||||
|
||||
@@ -53,4 +53,4 @@ PoorManIntervals.lastUpdateTime_ = 0;
|
||||
PoorManIntervals.intervalId_ = 0;
|
||||
PoorManIntervals.intervals_ = [];
|
||||
|
||||
export { PoorManIntervals }
|
||||
module.exports = { PoorManIntervals };
|
||||
@@ -34,4 +34,4 @@ function promiseWhile(callback) {
|
||||
}, 100);
|
||||
}
|
||||
|
||||
export { promiseChain, promiseWhile }
|
||||
module.exports = { promiseChain, promiseWhile };
|
||||
4
ReactNativeClient/lib/react-logger.js
vendored
4
ReactNativeClient/lib/react-logger.js
vendored
@@ -1,4 +1,4 @@
|
||||
import { Logger } from 'lib/logger.js';
|
||||
const { Logger } = require('lib/logger.js');
|
||||
|
||||
class ReactLogger extends Logger {
|
||||
|
||||
@@ -6,4 +6,4 @@ class ReactLogger extends Logger {
|
||||
|
||||
}
|
||||
|
||||
export { ReactLogger }
|
||||
module.exports = { ReactLogger };
|
||||
407
ReactNativeClient/lib/reducer.js
Normal file
407
ReactNativeClient/lib/reducer.js
Normal file
@@ -0,0 +1,407 @@
|
||||
const { Note } = require('lib/models/note.js');
|
||||
const { Folder } = require('lib/models/folder.js');
|
||||
const ArrayUtils = require('lib/ArrayUtils.js');
|
||||
|
||||
const defaultState = {
|
||||
notes: [],
|
||||
notesSource: '',
|
||||
notesParentType: null,
|
||||
folders: [],
|
||||
tags: [],
|
||||
searches: [],
|
||||
selectedNoteIds: [],
|
||||
selectedFolderId: null,
|
||||
selectedTagId: null,
|
||||
selectedSearchId: null,
|
||||
selectedItemType: 'note',
|
||||
showSideMenu: false,
|
||||
screens: {},
|
||||
historyCanGoBack: false,
|
||||
notesOrder: [
|
||||
{ by: 'user_updated_time', dir: 'DESC' },
|
||||
],
|
||||
syncStarted: false,
|
||||
syncReport: {},
|
||||
searchQuery: '',
|
||||
settings: {},
|
||||
appState: 'starting',
|
||||
windowContentSize: { width: 0, height: 0 },
|
||||
};
|
||||
|
||||
// When deleting a note, tag or folder
|
||||
function handleItemDelete(state, action) {
|
||||
let newState = Object.assign({}, state);
|
||||
|
||||
const map = {
|
||||
'FOLDER_DELETE': ['folders', 'selectedFolderId'],
|
||||
'NOTE_DELETE': ['notes', 'selectedNoteIds'],
|
||||
'TAG_DELETE': ['tags', 'selectedTagId'],
|
||||
'SEARCH_DELETE': ['searches', 'selectedSearchId'],
|
||||
};
|
||||
|
||||
const listKey = map[action.type][0];
|
||||
const selectedItemKey = map[action.type][1];
|
||||
|
||||
let previousIndex = 0;
|
||||
let newItems = [];
|
||||
const items = state[listKey];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let item = items[i];
|
||||
if (item.id == action.id) {
|
||||
previousIndex = i;
|
||||
continue;
|
||||
}
|
||||
newItems.push(item);
|
||||
}
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
newState[listKey] = newItems;
|
||||
|
||||
if (previousIndex >= newItems.length) {
|
||||
previousIndex = newItems.length - 1;
|
||||
}
|
||||
|
||||
const newId = previousIndex >= 0 ? newItems[previousIndex].id : null;
|
||||
newState[selectedItemKey] = action.type === 'NOTE_DELETE' ? [newId] : newId;
|
||||
|
||||
if (!newId && newState.notesParentType !== 'Folder') {
|
||||
newState.notesParentType = 'Folder';
|
||||
}
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
function updateOneTagOrFolder(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;
|
||||
|
||||
var found = false;
|
||||
for (let i = 0; i < newItems.length; i++) {
|
||||
let n = newItems[i];
|
||||
if (n.id == item.id) {
|
||||
newItems[i] = Object.assign(newItems[i], item);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) newItems.push(item);
|
||||
|
||||
let newState = Object.assign({}, state);
|
||||
|
||||
if (action.type === 'TAG_UPDATE_ONE') {
|
||||
newState.tags = newItems;
|
||||
} else {
|
||||
newState.folders = newItems;
|
||||
}
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
function defaultNotesParentType(state, exclusion) {
|
||||
let newNotesParentType = null;
|
||||
|
||||
if (exclusion !== 'Folder' && state.selectedFolderId) {
|
||||
newNotesParentType = 'Folder';
|
||||
} else if (exclusion !== 'Tag' && state.selectedTagId) {
|
||||
newNotesParentType = 'Tag';
|
||||
} else if (exclusion !== 'Search' && state.selectedSearchId) {
|
||||
newNotesParentType = 'Search';
|
||||
}
|
||||
|
||||
return newNotesParentType;
|
||||
}
|
||||
|
||||
function changeSelectedNotes(state, action) {
|
||||
const noteIds = 'id' in action ? (action.id ? [action.id] : []) : action.ids;
|
||||
let newState = Object.assign({}, state);
|
||||
|
||||
if (action.type === 'NOTE_SELECT') {
|
||||
newState.selectedNoteIds = noteIds;
|
||||
return newState;
|
||||
}
|
||||
|
||||
if (action.type === 'NOTE_SELECT_ADD') {
|
||||
if (!noteIds.length) return state;
|
||||
newState.selectedNoteIds = ArrayUtils.unique(newState.selectedNoteIds.concat(noteIds));
|
||||
return newState;
|
||||
}
|
||||
|
||||
if (action.type === 'NOTE_SELECT_REMOVE') {
|
||||
if (!noteIds.length) return state; // Nothing to unselect
|
||||
if (state.selectedNoteIds.length <= 1) return state; // Cannot unselect the last note
|
||||
|
||||
let newSelectedNoteIds = [];
|
||||
for (let i = 0; i < newState.selectedNoteIds.length; i++) {
|
||||
const id = newState.selectedNoteIds[i];
|
||||
if (noteIds.indexOf(id) >= 0) continue;
|
||||
newSelectedNoteIds.push(id);
|
||||
}
|
||||
newState.selectedNoteIds = newSelectedNoteIds;
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
if (action.type === 'NOTE_SELECT_TOGGLE') {
|
||||
if (!noteIds.length) return state;
|
||||
|
||||
if (newState.selectedNoteIds.indexOf(noteIds[0]) >= 0) {
|
||||
newState = changeSelectedNotes(state, { type: 'NOTE_SELECT_REMOVE', id: noteIds[0] });
|
||||
} else {
|
||||
newState = changeSelectedNotes(state, { type: 'NOTE_SELECT_ADD', id: noteIds[0] });
|
||||
}
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
throw new Error('Unreachable');
|
||||
}
|
||||
|
||||
const reducer = (state = defaultState, action) => {
|
||||
let newState = state;
|
||||
|
||||
try {
|
||||
switch (action.type) {
|
||||
|
||||
case 'NOTE_SELECT':
|
||||
case 'NOTE_SELECT_ADD':
|
||||
case 'NOTE_SELECT_REMOVE':
|
||||
case 'NOTE_SELECT_TOGGLE':
|
||||
|
||||
newState = changeSelectedNotes(state, action);
|
||||
break;
|
||||
|
||||
case 'NOTE_SELECT_EXTEND':
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
|
||||
if (!newState.selectedNoteIds.length) {
|
||||
newState.selectedNoteIds = [action.id];
|
||||
} else {
|
||||
const selectRangeId1 = state.selectedNoteIds[state.selectedNoteIds.length - 1];
|
||||
const selectRangeId2 = action.id;
|
||||
if (selectRangeId1 === selectRangeId2) return state;
|
||||
|
||||
let newSelectedNoteIds = state.selectedNoteIds.slice();
|
||||
let selectionStarted = false;
|
||||
for (let i = 0; i < state.notes.length; i++) {
|
||||
const id = state.notes[i].id;
|
||||
|
||||
if (!selectionStarted && (id === selectRangeId1 || id === selectRangeId2)) {
|
||||
selectionStarted = true;
|
||||
if (newSelectedNoteIds.indexOf(id) < 0) newSelectedNoteIds.push(id);
|
||||
continue;
|
||||
} else if (selectionStarted && (id === selectRangeId1 || id === selectRangeId2)) {
|
||||
if (newSelectedNoteIds.indexOf(id) < 0) newSelectedNoteIds.push(id);
|
||||
break;
|
||||
}
|
||||
|
||||
if (selectionStarted && newSelectedNoteIds.indexOf(id) < 0) {
|
||||
newSelectedNoteIds.push(id);
|
||||
}
|
||||
}
|
||||
newState.selectedNoteIds = newSelectedNoteIds;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'FOLDER_SELECT':
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
newState.selectedFolderId = action.id;
|
||||
if (!action.id) {
|
||||
newState.notesParentType = defaultNotesParentType(state, 'Folder');
|
||||
} else {
|
||||
newState.notesParentType = 'Folder';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'SETTING_UPDATE_ALL':
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
newState.settings = action.settings;
|
||||
break;
|
||||
|
||||
case 'SETTING_UPDATE_ONE':
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
let newSettings = Object.assign({}, state.settings);
|
||||
newSettings[action.key] = action.value;
|
||||
newState.settings = newSettings;
|
||||
break;
|
||||
|
||||
// Replace all the notes with the provided array
|
||||
case 'NOTE_UPDATE_ALL':
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
newState.notes = action.notes;
|
||||
newState.notesSource = action.notesSource;
|
||||
break;
|
||||
|
||||
// Insert the note into the note list if it's new, or
|
||||
// update it within the note array if it already exists.
|
||||
case 'NOTE_UPDATE_ONE':
|
||||
|
||||
const modNote = action.note;
|
||||
|
||||
const noteIsInFolder = function(note, folderId) {
|
||||
if (note.is_conflict) return folderId === Folder.conflictFolderId();
|
||||
if (!('parent_id' in modNote) || note.parent_id == folderId) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
let noteFolderHasChanged = false;
|
||||
let newNotes = state.notes.slice();
|
||||
var found = false;
|
||||
for (let i = 0; i < newNotes.length; i++) {
|
||||
let n = newNotes[i];
|
||||
if (n.id == modNote.id) {
|
||||
|
||||
// Note is still in the same folder
|
||||
if (noteIsInFolder(modNote, n.parent_id)) {
|
||||
// Merge the properties that have changed (in modNote) into
|
||||
// the object we already have.
|
||||
newNotes[i] = Object.assign({}, newNotes[i]);
|
||||
|
||||
for (let n in modNote) {
|
||||
if (!modNote.hasOwnProperty(n)) continue;
|
||||
newNotes[i][n] = modNote[n];
|
||||
}
|
||||
|
||||
} else { // Note has moved to a different folder
|
||||
newNotes.splice(i, 1);
|
||||
noteFolderHasChanged = true;
|
||||
}
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Note was not found - if the current folder is the same as the note folder,
|
||||
// add it to it.
|
||||
if (!found) {
|
||||
if (noteIsInFolder(modNote, state.selectedFolderId)) {
|
||||
newNotes.push(modNote);
|
||||
}
|
||||
}
|
||||
|
||||
newNotes = Note.sortNotes(newNotes, state.notesOrder, newState.settings.uncompletedTodosOnTop);
|
||||
newState = Object.assign({}, state);
|
||||
newState.notes = newNotes;
|
||||
|
||||
if (noteFolderHasChanged) {
|
||||
newState.selectedNoteIds = newNotes.length ? [newNotes[0].id] : null;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'NOTE_DELETE':
|
||||
|
||||
newState = handleItemDelete(state, action);
|
||||
break;
|
||||
|
||||
case 'TAG_DELETE':
|
||||
|
||||
newState = handleItemDelete(state, action);
|
||||
break;
|
||||
|
||||
case 'FOLDER_UPDATE_ALL':
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
newState.folders = action.folders;
|
||||
break;
|
||||
|
||||
case 'TAG_UPDATE_ALL':
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
newState.tags = action.tags;
|
||||
break;
|
||||
|
||||
case 'TAG_SELECT':
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
newState.selectedTagId = action.id;
|
||||
if (!action.id) {
|
||||
newState.notesParentType = defaultNotesParentType(state, 'Tag');
|
||||
} else {
|
||||
newState.notesParentType = 'Tag';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'TAG_UPDATE_ONE':
|
||||
|
||||
newState = updateOneTagOrFolder(state, action);
|
||||
break;
|
||||
|
||||
case 'FOLDER_UPDATE_ONE':
|
||||
|
||||
newState = updateOneTagOrFolder(state, action);
|
||||
break;
|
||||
|
||||
case 'FOLDER_DELETE':
|
||||
|
||||
newState = handleItemDelete(state, action);
|
||||
break;
|
||||
|
||||
case 'SYNC_STARTED':
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
newState.syncStarted = true;
|
||||
break;
|
||||
|
||||
case 'SYNC_COMPLETED':
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
newState.syncStarted = false;
|
||||
break;
|
||||
|
||||
case 'SYNC_REPORT_UPDATE':
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
newState.syncReport = action.report;
|
||||
break;
|
||||
|
||||
case 'SEARCH_QUERY':
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
newState.searchQuery = action.query.trim();
|
||||
break;
|
||||
|
||||
case 'SEARCH_ADD':
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
let searches = newState.searches.slice();
|
||||
searches.push(action.search);
|
||||
newState.searches = searches;
|
||||
break;
|
||||
|
||||
case 'SEARCH_DELETE':
|
||||
|
||||
newState = handleItemDelete(state, action);
|
||||
break;
|
||||
|
||||
case 'SEARCH_SELECT':
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
newState.selectedSearchId = action.id;
|
||||
if (!action.id) {
|
||||
newState.notesParentType = defaultNotesParentType(state, 'Search');
|
||||
} else {
|
||||
newState.notesParentType = 'Search';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'APP_STATE_SET':
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
newState.appState = action.state;
|
||||
break;
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
error.message = 'In reducer: ' + error.message + ' Action: ' + JSON.stringify(action);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
module.exports = { reducer, defaultState };
|
||||
@@ -1,20 +1,12 @@
|
||||
import { Logger } from 'lib/logger.js';
|
||||
import { Setting } from 'lib/models/setting.js';
|
||||
import { OneDriveApi } from 'lib/onedrive-api.js';
|
||||
import { parameters } from 'lib/parameters.js';
|
||||
import { FileApi } from 'lib/file-api.js';
|
||||
import { Database } from 'lib/database.js';
|
||||
import { Synchronizer } from 'lib/synchronizer.js';
|
||||
import { FileApiDriverOneDrive } from 'lib/file-api-driver-onedrive.js';
|
||||
import { shim } from 'lib/shim.js';
|
||||
import { time } from 'lib/time-utils.js';
|
||||
import { FileApiDriverMemory } from 'lib/file-api-driver-memory.js';
|
||||
import { PoorManIntervals } from 'lib/poor-man-intervals.js';
|
||||
import { _ } from 'lib/locale.js';
|
||||
const { Logger } = require('lib/logger.js');
|
||||
const { Setting } = require('lib/models/setting.js');
|
||||
const { shim } = require('lib/shim.js');
|
||||
const SyncTargetRegistry = require('lib/SyncTargetRegistry.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
|
||||
const reg = {};
|
||||
|
||||
reg.initSynchronizerStates_ = {};
|
||||
reg.syncTargets_ = {};
|
||||
|
||||
reg.logger = () => {
|
||||
if (!reg.logger_) {
|
||||
@@ -29,117 +21,21 @@ reg.setLogger = (l) => {
|
||||
reg.logger_ = l;
|
||||
}
|
||||
|
||||
reg.oneDriveApi = () => {
|
||||
if (reg.oneDriveApi_) return reg.oneDriveApi_;
|
||||
reg.syncTarget = (syncTargetId = null) => {
|
||||
if (syncTargetId === null) syncTargetId = Setting.value('sync.target');
|
||||
if (reg.syncTargets_[syncTargetId]) return reg.syncTargets_[syncTargetId];
|
||||
|
||||
const isPublic = Setting.value('appType') != 'cli';
|
||||
const SyncTargetClass = SyncTargetRegistry.classById(syncTargetId);
|
||||
if (!reg.db()) throw new Error('Cannot initialize sync without a db');
|
||||
|
||||
reg.oneDriveApi_ = new OneDriveApi(parameters().oneDrive.id, parameters().oneDrive.secret, isPublic);
|
||||
reg.oneDriveApi_.setLogger(reg.logger());
|
||||
|
||||
reg.oneDriveApi_.on('authRefreshed', (a) => {
|
||||
reg.logger().info('Saving updated OneDrive auth.');
|
||||
Setting.setValue('sync.3.auth', a ? JSON.stringify(a) : null);
|
||||
});
|
||||
|
||||
let auth = Setting.value('sync.3.auth');
|
||||
if (auth) {
|
||||
try {
|
||||
auth = JSON.parse(auth);
|
||||
} catch (error) {
|
||||
reg.logger().warn('Could not parse OneDrive auth token');
|
||||
reg.logger().warn(error);
|
||||
auth = null;
|
||||
}
|
||||
|
||||
reg.oneDriveApi_.setAuth(auth);
|
||||
}
|
||||
|
||||
return reg.oneDriveApi_;
|
||||
}
|
||||
|
||||
reg.initSynchronizer_ = async (syncTargetId) => {
|
||||
if (!reg.db()) throw new Error('Cannot initialize synchronizer: db not initialized');
|
||||
|
||||
let fileApi = null;
|
||||
|
||||
if (syncTargetId == Setting.SYNC_TARGET_ONEDRIVE) {
|
||||
|
||||
if (!reg.oneDriveApi().auth()) throw new Error('User is not authentified');
|
||||
let appDir = await reg.oneDriveApi().appDirectory();
|
||||
fileApi = new FileApi(appDir, new FileApiDriverOneDrive(reg.oneDriveApi()));
|
||||
|
||||
} else if (syncTargetId == Setting.SYNC_TARGET_MEMORY) {
|
||||
|
||||
fileApi = new FileApi('joplin', new FileApiDriverMemory());
|
||||
|
||||
} else if (syncTargetId == Setting.SYNC_TARGET_FILESYSTEM) {
|
||||
|
||||
let syncDir = Setting.value('sync.2.path');
|
||||
if (!syncDir) throw new Error(_('Please set the "sync.2.path" config value to the desired synchronisation destination.'));
|
||||
await shim.fs.mkdirp(syncDir, 0o755);
|
||||
fileApi = new FileApi(syncDir, new shim.FileApiDriverLocal());
|
||||
|
||||
} else {
|
||||
|
||||
throw new Error('Unknown sync target: ' + syncTargetId);
|
||||
|
||||
}
|
||||
|
||||
fileApi.setSyncTargetId(syncTargetId);
|
||||
fileApi.setLogger(reg.logger());
|
||||
|
||||
let sync = new Synchronizer(reg.db(), fileApi, Setting.value('appType'));
|
||||
sync.setLogger(reg.logger());
|
||||
sync.dispatch = reg.dispatch;
|
||||
|
||||
return sync;
|
||||
}
|
||||
|
||||
reg.synchronizer = async (syncTargetId) => {
|
||||
if (!reg.synchronizers_) reg.synchronizers_ = [];
|
||||
if (reg.synchronizers_[syncTargetId]) return reg.synchronizers_[syncTargetId];
|
||||
if (!reg.db()) throw new Error('Cannot initialize synchronizer: db not initialized');
|
||||
|
||||
if (reg.initSynchronizerStates_[syncTargetId] == 'started') {
|
||||
// Synchronizer is already being initialized, so wait here till it's done.
|
||||
return new Promise((resolve, reject) => {
|
||||
const iid = setInterval(() => {
|
||||
if (reg.initSynchronizerStates_[syncTargetId] == 'ready') {
|
||||
clearInterval(iid);
|
||||
resolve(reg.synchronizers_[syncTargetId]);
|
||||
}
|
||||
if (reg.initSynchronizerStates_[syncTargetId] == 'error') {
|
||||
clearInterval(iid);
|
||||
reject(new Error('Could not initialise synchroniser'));
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
} else {
|
||||
reg.initSynchronizerStates_[syncTargetId] = 'started';
|
||||
|
||||
try {
|
||||
const sync = await reg.initSynchronizer_(syncTargetId);
|
||||
reg.synchronizers_[syncTargetId] = sync;
|
||||
reg.initSynchronizerStates_[syncTargetId] = 'ready';
|
||||
return sync;
|
||||
} catch (error) {
|
||||
reg.initSynchronizerStates_[syncTargetId] = 'error';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reg.syncHasAuth = (syncTargetId) => {
|
||||
if (syncTargetId == Setting.SYNC_TARGET_ONEDRIVE && !reg.oneDriveApi().auth()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
const target = new SyncTargetClass(reg.db());
|
||||
target.setLogger(reg.logger());
|
||||
reg.syncTargets_[syncTargetId] = target;
|
||||
return target;
|
||||
}
|
||||
|
||||
reg.scheduleSync = async (delay = null) => {
|
||||
if (delay === null) delay = 1000 * 10;
|
||||
if (delay === null) delay = 1000 * 3;
|
||||
|
||||
if (reg.scheduleSyncId_) {
|
||||
clearTimeout(reg.scheduleSyncId_);
|
||||
@@ -148,19 +44,24 @@ reg.scheduleSync = async (delay = null) => {
|
||||
|
||||
reg.logger().info('Scheduling sync operation...');
|
||||
|
||||
// if (Setting.value('env') === 'dev') {
|
||||
// reg.logger().info('Scheduling sync operation DISABLED!!!');
|
||||
// return;
|
||||
// }
|
||||
|
||||
const timeoutCallback = async () => {
|
||||
reg.scheduleSyncId_ = null;
|
||||
reg.logger().info('Doing scheduled sync');
|
||||
|
||||
const syncTargetId = Setting.value('sync.target');
|
||||
|
||||
if (!reg.syncHasAuth(syncTargetId)) {
|
||||
if (!reg.syncTarget(syncTargetId).isAuthenticated()) {
|
||||
reg.logger().info('Synchroniser is missing credentials - manual sync required to authenticate.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sync = await reg.synchronizer(syncTargetId);
|
||||
const sync = await reg.syncTarget(syncTargetId).synchronizer();
|
||||
|
||||
const contextKey = 'sync.' + syncTargetId + '.context';
|
||||
let context = Setting.value(contextKey);
|
||||
@@ -190,16 +91,9 @@ reg.scheduleSync = async (delay = null) => {
|
||||
}
|
||||
}
|
||||
|
||||
reg.syncStarted = async () => {
|
||||
const syncTarget = Setting.value('sync.target');
|
||||
if (!reg.syncHasAuth(syncTarget)) return false;
|
||||
const sync = await reg.synchronizer(syncTarget);
|
||||
return sync.state() != 'idle';
|
||||
}
|
||||
|
||||
reg.setupRecurrentSync = () => {
|
||||
if (reg.recurrentSyncId_) {
|
||||
PoorManIntervals.clearInterval(reg.recurrentSyncId_);
|
||||
shim.clearInterval(reg.recurrentSyncId_);
|
||||
reg.recurrentSyncId_ = null;
|
||||
}
|
||||
|
||||
@@ -208,7 +102,7 @@ reg.setupRecurrentSync = () => {
|
||||
} else {
|
||||
reg.logger().debug('Setting up recurrent sync with interval ' + Setting.value('sync.interval'));
|
||||
|
||||
reg.recurrentSyncId_ = PoorManIntervals.setInterval(() => {
|
||||
reg.recurrentSyncId_ = shim.setInterval(() => {
|
||||
reg.logger().info('Running background sync on timer...');
|
||||
reg.scheduleSync(0);
|
||||
}, 1000 * Setting.value('sync.interval'));
|
||||
@@ -223,4 +117,4 @@ reg.db = () => {
|
||||
return reg.db_;
|
||||
}
|
||||
|
||||
export { reg }
|
||||
module.exports = { reg };
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BackHandler } from 'react-native';
|
||||
const { BackHandler } = require('react-native');
|
||||
|
||||
class BackButtonService {
|
||||
|
||||
@@ -40,4 +40,4 @@ class BackButtonService {
|
||||
BackButtonService.defaultHandler_ = null;
|
||||
BackButtonService.handlers_ = [];
|
||||
|
||||
export { BackButtonService };
|
||||
module.exports = { BackButtonService };
|
||||
@@ -1,12 +1,12 @@
|
||||
import { BaseItem } from 'lib/models/base-item.js';
|
||||
import { BaseModel } from 'lib/base-model.js';
|
||||
import { Resource } from 'lib/models/resource.js';
|
||||
import { Folder } from 'lib/models/folder.js';
|
||||
import { NoteTag } from 'lib/models/note-tag.js';
|
||||
import { Note } from 'lib/models/note.js';
|
||||
import { Tag } from 'lib/models/tag.js';
|
||||
import { basename } from 'lib/path-utils.js';
|
||||
import fs from 'fs-extra';
|
||||
const { BaseItem } = require('lib/models/base-item.js');
|
||||
const { BaseModel } = require('lib/base-model.js');
|
||||
const { Resource } = require('lib/models/resource.js');
|
||||
const { Folder } = require('lib/models/folder.js');
|
||||
const { NoteTag } = require('lib/models/note-tag.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 {
|
||||
|
||||
@@ -93,4 +93,4 @@ class Exporter {
|
||||
|
||||
}
|
||||
|
||||
export { Exporter }
|
||||
module.exports = { Exporter };
|
||||
@@ -1,11 +1,70 @@
|
||||
import { time } from 'lib/time-utils'
|
||||
import { BaseItem } from 'lib/models/base-item.js';
|
||||
import { Folder } from 'lib/models/folder.js';
|
||||
import { Note } from 'lib/models/note.js';
|
||||
import { _ } from 'lib/locale.js';
|
||||
const { time } = require('lib/time-utils');
|
||||
const { BaseItem } = require('lib/models/base-item.js');
|
||||
const { Folder } = require('lib/models/folder.js');
|
||||
const { Note } = require('lib/models/note.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
|
||||
class ReportService {
|
||||
|
||||
csvEscapeCell(cell) {
|
||||
cell = this.csvValueToString(cell);
|
||||
let output = cell.replace(/"/, '""');
|
||||
if (this.csvCellRequiresQuotes(cell, ',')) {
|
||||
return '"' + output + '"';
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
csvCellRequiresQuotes(cell, delimiter) {
|
||||
if (cell.indexOf('\n') >= 0) return true;
|
||||
if (cell.indexOf('"') >= 0) return true;
|
||||
if (cell.indexOf(delimiter) >= 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
csvValueToString(v) {
|
||||
if (v === undefined || v === null) return '';
|
||||
return v.toString();
|
||||
}
|
||||
|
||||
csvCreateLine(row) {
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
row[i] = this.csvEscapeCell(row[i]);
|
||||
}
|
||||
return row.join(',');
|
||||
}
|
||||
|
||||
csvCreate(rows) {
|
||||
let output = [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
output.push(this.csvCreateLine(rows[i]));
|
||||
}
|
||||
return output.join('\n');
|
||||
}
|
||||
|
||||
async basicItemList(option = null) {
|
||||
if (!option) option = {};
|
||||
if (!option.format) option.format = 'array';
|
||||
|
||||
const itemTypes = BaseItem.syncItemTypes();
|
||||
let output = [];
|
||||
output.push(['type', 'id', 'updated_time', 'sync_time', 'is_conflict']);
|
||||
for (let i = 0; i < itemTypes.length; i++) {
|
||||
const itemType = itemTypes[i];
|
||||
const ItemClass = BaseItem.getClassByItemType(itemType);
|
||||
const items = await ItemClass.modelSelectAll('SELECT items.id, items.updated_time, sync_items.sync_time FROM ' + ItemClass.tableName() + ' items JOIN sync_items ON sync_items.item_id = items.id');
|
||||
|
||||
for (let j = 0; j < items.length; j++) {
|
||||
const item = items[j];
|
||||
let row = [itemType, item.id, item.updated_time, item.sync_time];
|
||||
row.push(('is_conflict' in item) ? item.is_conflict : '');
|
||||
output.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
return option.format === 'csv' ? this.csvCreate(output) : output;
|
||||
}
|
||||
|
||||
async syncStatus(syncTarget) {
|
||||
let output = {
|
||||
items: {},
|
||||
@@ -87,4 +146,4 @@ class ReportService {
|
||||
|
||||
}
|
||||
|
||||
export { ReportService }
|
||||
module.exports = { ReportService };
|
||||
@@ -1,19 +1,151 @@
|
||||
import fs from 'fs-extra';
|
||||
import { shim } from 'lib/shim.js';
|
||||
import { GeolocationNode } from 'lib/geolocation-node.js';
|
||||
import { FileApiDriverLocal } from 'lib/file-api-driver-local.js';
|
||||
const fs = require('fs-extra');
|
||||
const { shim } = require('lib/shim.js');
|
||||
const { GeolocationNode } = require('lib/geolocation-node.js');
|
||||
const { FileApiDriverLocal } = require('lib/file-api-driver-local.js');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const { setLocale, defaultLocale, closestSupportedLocale } = require('lib/locale.js');
|
||||
|
||||
// // Node requests can go wrong is so many different ways and with so
|
||||
// // many different error messages... This handler inspects the error
|
||||
// // and decides whether the request can safely be repeated or not.
|
||||
// function fetchRequestCanBeRetried(error) {
|
||||
// if (!error) return false;
|
||||
|
||||
// // Unfortunately the error 'Network request failed' doesn't have a type
|
||||
// // or error code, so hopefully that message won't change and is not localized
|
||||
// if (error.message == 'Network request failed') return true;
|
||||
|
||||
// // request to https://public-ch3302....1fab24cb1bd5f.md failed, reason: socket hang up"
|
||||
// if (error.code == 'ECONNRESET') return true;
|
||||
|
||||
// // OneDrive (or Node?) sometimes sends back a "not found" error for resources
|
||||
// // that definitely exist and in this case repeating the request works.
|
||||
// // Error is:
|
||||
// // request to https://graph.microsoft.com/v1.0/drive/special/approot failed, reason: getaddrinfo ENOTFOUND graph.microsoft.com graph.microsoft.com:443
|
||||
// if (error.code == 'ENOTFOUND') return true;
|
||||
|
||||
// // network timeout at: https://public-ch3302...859f9b0e3ab.md
|
||||
// if (error.message && error.message.indexOf('network timeout') === 0) return true;
|
||||
|
||||
// // name: 'FetchError',
|
||||
// // message: 'request to https://api.ipify.org/?format=json failed, reason: getaddrinfo EAI_AGAIN api.ipify.org:443',
|
||||
// // type: 'system',
|
||||
// // errno: 'EAI_AGAIN',
|
||||
// // code: 'EAI_AGAIN' } } reason: { FetchError: request to https://api.ipify.org/?format=json failed, reason: getaddrinfo EAI_AGAIN api.ipify.org:443
|
||||
// //
|
||||
// // It's a Microsoft error: "A temporary failure in name resolution occurred."
|
||||
// if (error.code == 'EAI_AGAIN') return true;
|
||||
|
||||
// // request to https://public-...8fd8bc6bb68e9c4d17a.md failed, reason: connect ETIMEDOUT 204.79.197.213:443
|
||||
// // Code: ETIMEDOUT
|
||||
// if (error.code === 'ETIMEDOUT') return true;
|
||||
|
||||
// return false;
|
||||
// }
|
||||
|
||||
function shimInit() {
|
||||
shim.fs = fs;
|
||||
shim.FileApiDriverLocal = FileApiDriverLocal;
|
||||
shim.Geolocation = GeolocationNode;
|
||||
shim.fetch = require('node-fetch');
|
||||
shim.FormData = require('form-data');
|
||||
|
||||
shim.detectAndSetLocale = function (Setting) {
|
||||
let locale = process.env.LANG;
|
||||
if (!locale) locale = defaultLocale();
|
||||
locale = locale.split('.');
|
||||
locale = locale[0];
|
||||
locale = closestSupportedLocale(locale);
|
||||
Setting.setValue('locale', locale);
|
||||
setLocale(locale);
|
||||
return locale;
|
||||
}
|
||||
|
||||
const resizeImage_ = async function(filePath, targetPath) {
|
||||
const sharp = require('sharp');
|
||||
const { Resource } = require('lib/models/resource.js');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
sharp(filePath)
|
||||
.resize(Resource.IMAGE_MAX_DIMENSION, Resource.IMAGE_MAX_DIMENSION)
|
||||
.max()
|
||||
.withoutEnlargement()
|
||||
.toFile(targetPath, (err, info) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(info);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
shim.attachFileToNote = async function(note, filePath) {
|
||||
const { Resource } = require('lib/models/resource.js');
|
||||
const { uuid } = require('lib/uuid.js');
|
||||
const { filename } = require('lib/path-utils.js');
|
||||
const mime = require('mime/lite');
|
||||
const { Note } = require('lib/models/note.js');
|
||||
|
||||
if (!(await fs.pathExists(filePath))) throw new Error(_('Cannot access %s', filePath));
|
||||
|
||||
let resource = Resource.new();
|
||||
resource.id = uuid.create();
|
||||
resource.mime = mime.getType(filePath);
|
||||
resource.title = filename(filePath);
|
||||
|
||||
let targetPath = Resource.fullPath(resource);
|
||||
|
||||
if (resource.mime == 'image/jpeg' || resource.mime == 'image/jpg' || resource.mime == 'image/png') {
|
||||
const result = await resizeImage_(filePath, targetPath);
|
||||
} else {
|
||||
await fs.copy(filePath, targetPath, { overwrite: true });
|
||||
}
|
||||
|
||||
await Resource.save(resource, { isNew: true });
|
||||
|
||||
const newNote = Object.assign({}, note, {
|
||||
body: note.body + "\n\n" + Resource.markdownTag(resource),
|
||||
});
|
||||
return await Note.save(newNote);
|
||||
}
|
||||
|
||||
const nodeFetch = require('node-fetch');
|
||||
|
||||
shim.readLocalFileBase64 = (path) => {
|
||||
const data = fs.readFileSync(path);
|
||||
return new Buffer(data).toString('base64');
|
||||
}
|
||||
|
||||
shim.fetch = async function(url, options = null) {
|
||||
return shim.fetchWithRetry(() => {
|
||||
return nodeFetch(url, options)
|
||||
}, options);
|
||||
|
||||
// if (!options) options = {};
|
||||
// if (!options.timeout) options.timeout = 1000 * 120; // ms
|
||||
// if (!('maxRetry' in options)) options.maxRetry = 5;
|
||||
|
||||
// let retryCount = 0;
|
||||
// while (true) {
|
||||
// try {
|
||||
// const response = await nodeFetch(url, options);
|
||||
// return response;
|
||||
// } catch (error) {
|
||||
// if (fetchRequestCanBeRetried(error)) {
|
||||
// retryCount++;
|
||||
// if (retryCount > options.maxRetry) throw error;
|
||||
// await time.sleep(retryCount * 3);
|
||||
// } else {
|
||||
// throw error;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
shim.fetchBlob = async function(url, options) {
|
||||
if (!options || !options.path) throw new Error('fetchBlob: target file path is missing');
|
||||
if (!options.method) options.method = 'GET';
|
||||
//if (!('maxRetry' in options)) options.maxRetry = 5;
|
||||
|
||||
const urlParse = require('url').parse;
|
||||
|
||||
@@ -44,31 +176,51 @@ function shimInit() {
|
||||
headers: headers,
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// Note: relative paths aren't supported
|
||||
const file = fs.createWriteStream(filePath);
|
||||
const doFetchOperation = async () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// Note: relative paths aren't supported
|
||||
const file = fs.createWriteStream(filePath);
|
||||
|
||||
const request = http.get(requestOptions, function(response) {
|
||||
response.pipe(file);
|
||||
const request = http.get(requestOptions, function(response) {
|
||||
response.pipe(file);
|
||||
|
||||
file.on('finish', function() {
|
||||
file.close(() => {
|
||||
resolve(makeResponse(response));
|
||||
file.on('finish', function() {
|
||||
file.close(() => {
|
||||
resolve(makeResponse(response));
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
request.on('error', function(error) {
|
||||
request.on('error', function(error) {
|
||||
fs.unlink(filePath);
|
||||
reject(error);
|
||||
});
|
||||
} catch(error) {
|
||||
fs.unlink(filePath);
|
||||
reject(error);
|
||||
});
|
||||
} catch(error) {
|
||||
fs.unlink(filePath);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return shim.fetchWithRetry(doFetchOperation, options);
|
||||
|
||||
// let retryCount = 0;
|
||||
// while (true) {
|
||||
// try {
|
||||
// const response = await doFetchOperation();
|
||||
// return response;
|
||||
// } catch (error) {
|
||||
// if (fetchRequestCanBeRetried(error)) {
|
||||
// retryCount++;
|
||||
// if (retryCount > options.maxRetry) throw error;
|
||||
// await time.sleep(retryCount * 3);
|
||||
// } else {
|
||||
// throw error;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
export { shimInit }
|
||||
module.exports = { shimInit };
|
||||
@@ -1,10 +1,40 @@
|
||||
import { shim } from 'lib/shim.js';
|
||||
import { GeolocationReact } from 'lib/geolocation-react.js';
|
||||
import RNFetchBlob from 'react-native-fetch-blob';
|
||||
const { shim } = require('lib/shim.js');
|
||||
const { GeolocationReact } = require('lib/geolocation-react.js');
|
||||
const { PoorManIntervals } = require('lib/poor-man-intervals.js');
|
||||
const RNFetchBlob = require('react-native-fetch-blob').default;
|
||||
|
||||
function shimInit() {
|
||||
shim.Geolocation = GeolocationReact;
|
||||
|
||||
shim.setInterval = PoorManIntervals.setInterval;
|
||||
shim.clearInterval = PoorManIntervals.clearInterval;
|
||||
|
||||
shim.fetch = async function(url, options = null) {
|
||||
return shim.fetchWithRetry(() => {
|
||||
return shim.nativeFetch_(url, options)
|
||||
}, options);
|
||||
|
||||
// if (!options) options = {};
|
||||
// if (!options.timeout) options.timeout = 1000 * 120; // ms
|
||||
// if (!('maxRetry' in options)) options.maxRetry = 5;
|
||||
|
||||
// let retryCount = 0;
|
||||
// while (true) {
|
||||
// try {
|
||||
// const response = await nodeFetch(url, options);
|
||||
// return response;
|
||||
// } catch (error) {
|
||||
// if (fetchRequestCanBeRetried(error)) {
|
||||
// retryCount++;
|
||||
// if (retryCount > options.maxRetry) throw error;
|
||||
// await time.sleep(retryCount * 3);
|
||||
// } else {
|
||||
// throw error;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
shim.fetchBlob = async function(url, options) {
|
||||
if (!options || !options.path) throw new Error('fetchBlob: target file path is missing');
|
||||
|
||||
@@ -17,10 +47,17 @@ function shimInit() {
|
||||
|
||||
delete options.path;
|
||||
|
||||
try {
|
||||
let response = await RNFetchBlob.config({
|
||||
const doFetchBlob = () => {
|
||||
return RNFetchBlob.config({
|
||||
path: localFilePath
|
||||
}).fetch(method, url, headers);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await shim.fetchWithRetry(doFetchBlob, options);
|
||||
// let response = await RNFetchBlob.config({
|
||||
// path: localFilePath
|
||||
// }).fetch(method, url, headers);
|
||||
|
||||
// Returns an object that's roughtly compatible with a standard Response object
|
||||
let output = {
|
||||
@@ -66,4 +103,4 @@ function shimInit() {
|
||||
}
|
||||
}
|
||||
|
||||
export { shimInit }
|
||||
module.exports = { shimInit };
|
||||
@@ -2,6 +2,7 @@ let shim = {};
|
||||
|
||||
shim.isNode = () => {
|
||||
if (typeof process === 'undefined') return false;
|
||||
if (shim.isElectron()) return true;
|
||||
return process.title == 'node';
|
||||
};
|
||||
|
||||
@@ -9,11 +10,114 @@ shim.isReactNative = () => {
|
||||
return !shim.isNode();
|
||||
};
|
||||
|
||||
shim.fetch = typeof fetch !== 'undefined' ? fetch : null;
|
||||
shim.isLinux = () => {
|
||||
return process && process.platform === 'linux';
|
||||
}
|
||||
|
||||
shim.isWindows = () => {
|
||||
return process && process.platform === 'win32';
|
||||
}
|
||||
|
||||
shim.isMac = () => {
|
||||
return process && process.platform === 'darwin';
|
||||
}
|
||||
|
||||
// https://github.com/cheton/is-electron
|
||||
shim.isElectron = () => {
|
||||
// Renderer process
|
||||
if (typeof window !== 'undefined' && typeof window.process === 'object' && window.process.type === 'renderer') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Main process
|
||||
if (typeof process !== 'undefined' && typeof process.versions === 'object' && !!process.versions.electron) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Detect the user agent when the `nodeIntegration` option is set to true
|
||||
if (typeof navigator === 'object' && typeof navigator.userAgent === 'string' && navigator.userAgent.indexOf('Electron') >= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Node requests can go wrong is so many different ways and with so
|
||||
// many different error messages... This handler inspects the error
|
||||
// and decides whether the request can safely be repeated or not.
|
||||
function fetchRequestCanBeRetried(error) {
|
||||
if (!error) return false;
|
||||
|
||||
// Unfortunately the error 'Network request failed' doesn't have a type
|
||||
// or error code, so hopefully that message won't change and is not localized
|
||||
if (error.message == 'Network request failed') return true;
|
||||
|
||||
// request to https://public-ch3302....1fab24cb1bd5f.md failed, reason: socket hang up"
|
||||
if (error.code == 'ECONNRESET') return true;
|
||||
|
||||
// OneDrive (or Node?) sometimes sends back a "not found" error for resources
|
||||
// that definitely exist and in this case repeating the request works.
|
||||
// Error is:
|
||||
// request to https://graph.microsoft.com/v1.0/drive/special/approot failed, reason: getaddrinfo ENOTFOUND graph.microsoft.com graph.microsoft.com:443
|
||||
if (error.code == 'ENOTFOUND') return true;
|
||||
|
||||
// network timeout at: https://public-ch3302...859f9b0e3ab.md
|
||||
if (error.message && error.message.indexOf('network timeout') === 0) return true;
|
||||
|
||||
// name: 'FetchError',
|
||||
// message: 'request to https://api.ipify.org/?format=json failed, reason: getaddrinfo EAI_AGAIN api.ipify.org:443',
|
||||
// type: 'system',
|
||||
// errno: 'EAI_AGAIN',
|
||||
// code: 'EAI_AGAIN' } } reason: { FetchError: request to https://api.ipify.org/?format=json failed, reason: getaddrinfo EAI_AGAIN api.ipify.org:443
|
||||
//
|
||||
// It's a Microsoft error: "A temporary failure in name resolution occurred."
|
||||
if (error.code == 'EAI_AGAIN') return true;
|
||||
|
||||
// request to https://public-...8fd8bc6bb68e9c4d17a.md failed, reason: connect ETIMEDOUT 204.79.197.213:443
|
||||
// Code: ETIMEDOUT
|
||||
if (error.code === 'ETIMEDOUT') return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
shim.fetchWithRetry = async function(fetchFn, options = null) {
|
||||
const { time } = require('lib/time-utils.js');
|
||||
|
||||
if (!options) options = {};
|
||||
if (!options.timeout) options.timeout = 1000 * 120; // ms
|
||||
if (!('maxRetry' in options)) options.maxRetry = 5;
|
||||
|
||||
let retryCount = 0;
|
||||
while (true) {
|
||||
try {
|
||||
const response = await fetchFn();
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (fetchRequestCanBeRetried(error)) {
|
||||
retryCount++;
|
||||
if (retryCount > options.maxRetry) throw error;
|
||||
await time.sleep(retryCount * 3);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shim.nativeFetch_ = typeof fetch !== 'undefined' ? fetch : null;
|
||||
shim.fetch = () => { throw new Error('Not implemented'); }
|
||||
shim.FormData = typeof FormData !== 'undefined' ? FormData : null;
|
||||
shim.fs = null;
|
||||
shim.FileApiDriverLocal = null;
|
||||
shim.readLocalFileBase64 = () => { throw new Error('Not implemented'); }
|
||||
shim.readLocalFileBase64 = (path) => { throw new Error('Not implemented'); }
|
||||
shim.uploadBlob = () => { throw new Error('Not implemented'); }
|
||||
shim.setInterval = function(fn, interval) {
|
||||
return setInterval(fn, interval);
|
||||
}
|
||||
shim.clearInterval = function(id) {
|
||||
return clearInterval(id);
|
||||
}
|
||||
shim.detectAndSetLocale = null;
|
||||
shim.attachFileToNote = async (note, filePath) => {}
|
||||
|
||||
export { shim };
|
||||
module.exports = { shim };
|
||||
@@ -113,4 +113,82 @@ function escapeFilename(s, maxLength = 32) {
|
||||
return output.substr(0, maxLength);
|
||||
}
|
||||
|
||||
export { removeDiacritics, escapeFilename };
|
||||
function wrap(text, indent, width) {
|
||||
const wrap_ = require('word-wrap');
|
||||
|
||||
return wrap_(text, {
|
||||
width: width - indent.length,
|
||||
indent: indent,
|
||||
});
|
||||
}
|
||||
|
||||
function splitCommandString(command) {
|
||||
let args = [];
|
||||
let state = "start"
|
||||
let current = ""
|
||||
let quote = "\""
|
||||
let escapeNext = false;
|
||||
for (let i = 0; i < command.length; i++) {
|
||||
let c = command[i]
|
||||
|
||||
if (state == "quotes") {
|
||||
if (c != quote) {
|
||||
current += c
|
||||
} else {
|
||||
args.push(current)
|
||||
current = ""
|
||||
state = "start"
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (escapeNext) {
|
||||
current += c;
|
||||
escapeNext = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == "\\") {
|
||||
escapeNext = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '"' || c == '\'') {
|
||||
state = "quotes"
|
||||
quote = c
|
||||
continue
|
||||
}
|
||||
|
||||
if (state == "arg") {
|
||||
if (c == ' ' || c == '\t') {
|
||||
args.push(current)
|
||||
current = ""
|
||||
state = "start"
|
||||
} else {
|
||||
current += c
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (c != ' ' && c != "\t") {
|
||||
state = "arg"
|
||||
current += c
|
||||
}
|
||||
}
|
||||
|
||||
if (state == "quotes") {
|
||||
throw new Error("Unclosed quote in command line: " + command)
|
||||
}
|
||||
|
||||
if (current != "") {
|
||||
args.push(current)
|
||||
}
|
||||
|
||||
if (args.length <= 0) {
|
||||
throw new Error("Empty command line")
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
module.exports = { removeDiacritics, escapeFilename, wrap, splitCommandString };
|
||||
@@ -1,14 +1,14 @@
|
||||
import { BaseItem } from 'lib/models/base-item.js';
|
||||
import { Folder } from 'lib/models/folder.js';
|
||||
import { Note } from 'lib/models/note.js';
|
||||
import { Resource } from 'lib/models/resource.js';
|
||||
import { BaseModel } from 'lib/base-model.js';
|
||||
import { sprintf } from 'sprintf-js';
|
||||
import { time } from 'lib/time-utils.js';
|
||||
import { Logger } from 'lib/logger.js'
|
||||
import { _ } from 'lib/locale.js';
|
||||
import { shim } from 'lib/shim.js';
|
||||
import moment from 'moment';
|
||||
const { BaseItem } = require('lib/models/base-item.js');
|
||||
const { Folder } = require('lib/models/folder.js');
|
||||
const { Note } = require('lib/models/note.js');
|
||||
const { Resource } = require('lib/models/resource.js');
|
||||
const { BaseModel } = require('lib/base-model.js');
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const { Logger } = require('lib/logger.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const { shim } = require('lib/shim.js');
|
||||
const moment = require('moment');
|
||||
|
||||
class Synchronizer {
|
||||
|
||||
@@ -22,6 +22,10 @@ class Synchronizer {
|
||||
this.appType_ = appType;
|
||||
this.cancelling_ = false;
|
||||
|
||||
// Debug flags are used to test certain hard-to-test conditions
|
||||
// such as cancelling in the middle of a loop.
|
||||
this.debugFlags_ = [];
|
||||
|
||||
this.onProgress_ = function(s) {};
|
||||
this.progressReport_ = {};
|
||||
|
||||
@@ -57,7 +61,6 @@ class Synchronizer {
|
||||
if (report.deleteLocal) lines.push(_('Deleted local items: %d.', report.deleteLocal));
|
||||
if (report.deleteRemote) lines.push(_('Deleted remote items: %d.', report.deleteRemote));
|
||||
if (!report.completedTime && report.state) lines.push(_('State: "%s".', report.state));
|
||||
//if (report.errors && report.errors.length) lines.push(_('Last error: %s (stacktrace in log).', report.errors[report.errors.length-1].message));
|
||||
if (report.cancelling && !report.completedTime) lines.push(_('Cancelling...'));
|
||||
if (report.completedTime) lines.push(_('Completed: %s', time.unixMsToLocalDateTime(report.completedTime)));
|
||||
|
||||
@@ -125,22 +128,20 @@ class Synchronizer {
|
||||
}
|
||||
}
|
||||
|
||||
randomFailure(options, name) {
|
||||
if (!options.randomFailures) return false;
|
||||
|
||||
if (this.randomFailureChoice_ == name) {
|
||||
options.onMessage('Random failure: ' + name);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
cancel() {
|
||||
async cancel() {
|
||||
if (this.cancelling_ || this.state() == 'idle') return;
|
||||
|
||||
this.logSyncOperation('cancelling', null, null, '');
|
||||
this.cancelling_ = true;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const iid = setInterval(() => {
|
||||
if (this.state() == 'idle') {
|
||||
clearInterval(iid);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
cancelling() {
|
||||
@@ -166,7 +167,6 @@ class Synchronizer {
|
||||
|
||||
const syncTargetId = this.api().syncTargetId();
|
||||
|
||||
this.randomFailureChoice_ = Math.floor(Math.random() * 5);
|
||||
this.cancelling_ = false;
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
@@ -178,7 +178,6 @@ class Synchronizer {
|
||||
|
||||
let outputContext = Object.assign({}, lastContext);
|
||||
|
||||
|
||||
this.dispatch({ type: 'SYNC_STARTED' });
|
||||
|
||||
this.logSyncOperation('starting', null, null, 'Starting synchronisation to target ' + syncTargetId + '... [' + synchronizationId + ']');
|
||||
@@ -216,13 +215,14 @@ class Synchronizer {
|
||||
reason = 'remote does not exist, and local is new and has never been synced';
|
||||
} else {
|
||||
// Note or item was modified after having been deleted remotely
|
||||
// "itemConflict" if for all the items except the notes, which are dealt with in a special way
|
||||
action = local.type_ == BaseModel.TYPE_NOTE ? 'noteConflict' : 'itemConflict';
|
||||
reason = 'remote has been deleted, but local has changes';
|
||||
}
|
||||
} else {
|
||||
if (remote.updated_time > local.sync_time) {
|
||||
// Since, in this loop, we are only dealing with notes that require sync, if the
|
||||
// remote has been modified after the sync time, it means both notes have been
|
||||
// Since, in this loop, we are only dealing with items that require sync, if the
|
||||
// remote has been modified after the sync time, it means both items have been
|
||||
// modified and so there's a conflict.
|
||||
action = local.type_ == BaseModel.TYPE_NOTE ? 'noteConflict' : 'itemConflict';
|
||||
reason = 'both remote and local have changes';
|
||||
@@ -267,13 +267,7 @@ class Synchronizer {
|
||||
// await this.api().move(tempPath, path);
|
||||
|
||||
await this.api().put(path, content);
|
||||
|
||||
if (this.randomFailure(options, 0)) return;
|
||||
|
||||
await this.api().setTimestamp(path, local.updated_time);
|
||||
|
||||
if (this.randomFailure(options, 1)) return;
|
||||
|
||||
await ItemClass.saveSyncTime(syncTargetId, local, time.unixMs());
|
||||
|
||||
} else if (action == 'itemConflict') {
|
||||
@@ -290,22 +284,43 @@ class Synchronizer {
|
||||
|
||||
} else if (action == 'noteConflict') {
|
||||
|
||||
// - Create a duplicate of local note into Conflicts folder (to preserve the user's changes)
|
||||
// - Overwrite local note with remote note
|
||||
let conflictedNote = Object.assign({}, local);
|
||||
delete conflictedNote.id;
|
||||
conflictedNote.is_conflict = 1;
|
||||
await Note.save(conflictedNote, { autoTimestamp: false });
|
||||
// ------------------------------------------------------------------------------
|
||||
// First find out if the conflict matters. For example, if the conflict is on the title or body
|
||||
// we want to preserve all the changes. If it's on todo_completed it doesn't really matter
|
||||
// so in this case we just take the remote content.
|
||||
// ------------------------------------------------------------------------------
|
||||
|
||||
if (this.randomFailure(options, 2)) return;
|
||||
let loadedRemote = null;
|
||||
let mustHandleConflict = true;
|
||||
if (remote) {
|
||||
const remoteContent = await this.api().get(path);
|
||||
loadedRemote = await BaseItem.unserialize(remoteContent);
|
||||
mustHandleConflict = Note.mustHandleConflict(local, loadedRemote);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------
|
||||
// Create a duplicate of local note into Conflicts folder
|
||||
// (to preserve the user's changes)
|
||||
// ------------------------------------------------------------------------------
|
||||
|
||||
if (mustHandleConflict) {
|
||||
let conflictedNote = Object.assign({}, local);
|
||||
delete conflictedNote.id;
|
||||
conflictedNote.is_conflict = 1;
|
||||
await Note.save(conflictedNote, { autoTimestamp: false });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------
|
||||
// Either copy the remote content to local or, if the remote content has
|
||||
// been deleted, delete the local content.
|
||||
// ------------------------------------------------------------------------------
|
||||
|
||||
if (remote) {
|
||||
let remoteContent = await this.api().get(path);
|
||||
local = await BaseItem.unserialize(remoteContent);
|
||||
|
||||
local = loadedRemote;
|
||||
const syncTimeQueries = BaseItem.updateSyncTimeQueries(syncTargetId, local, time.unixMs());
|
||||
await ItemClass.save(local, { autoTimestamp: false, nextQueries: syncTimeQueries });
|
||||
} else {
|
||||
// Remote no longer exists (note deleted) so delete local one too
|
||||
await ItemClass.delete(local.id);
|
||||
}
|
||||
|
||||
@@ -329,7 +344,6 @@ class Synchronizer {
|
||||
let path = BaseItem.systemPath(item.item_id)
|
||||
this.logSyncOperation('deleteRemote', null, { id: item.item_id }, 'local has been deleted');
|
||||
await this.api().delete(path);
|
||||
if (this.randomFailure(options, 3)) return;
|
||||
await BaseItem.remoteDeletedItem(syncTargetId, item.item_id);
|
||||
}
|
||||
|
||||
@@ -344,24 +358,29 @@ class Synchronizer {
|
||||
let context = null;
|
||||
let newDeltaContext = null;
|
||||
let localFoldersToDelete = [];
|
||||
let hasCancelled = false;
|
||||
if (lastContext.delta) context = lastContext.delta;
|
||||
|
||||
while (true) {
|
||||
if (this.cancelling()) break;
|
||||
|
||||
let allIds = null;
|
||||
if (!this.api().supportsDelta()) {
|
||||
allIds = await BaseItem.syncedItemIds(syncTargetId);
|
||||
}
|
||||
if (this.cancelling() || hasCancelled) break;
|
||||
|
||||
let listResult = await this.api().delta('', {
|
||||
context: context,
|
||||
itemIds: allIds,
|
||||
|
||||
// allItemIdsHandler() provides a way for drivers that don't have a delta API to
|
||||
// still provide delta functionality by comparing the items they have to the items
|
||||
// the client has. Very inefficient but that's the only possible workaround.
|
||||
// It's a function so that it is only called if the driver needs these IDs. For
|
||||
// drivers with a delta functionality it's a noop.
|
||||
allItemIdsHandler: async () => { return BaseItem.syncedItemIds(syncTargetId); }
|
||||
});
|
||||
|
||||
let remotes = listResult.items;
|
||||
for (let i = 0; i < remotes.length; i++) {
|
||||
if (this.cancelling()) break;
|
||||
if (this.cancelling() || this.debugFlags_.indexOf('cancelDeltaLoop2') >= 0) {
|
||||
hasCancelled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
let remote = remotes[i];
|
||||
if (!BaseItem.isSystemPath(remote.path)) continue; // The delta API might return things like the .sync, .resource or the root folder
|
||||
@@ -404,7 +423,6 @@ class Synchronizer {
|
||||
let newContent = Object.assign({}, content);
|
||||
let options = {
|
||||
autoTimestamp: false,
|
||||
applyMetadataChanges: true,
|
||||
nextQueries: BaseItem.updateSyncTimeQueries(syncTargetId, newContent, time.unixMs()),
|
||||
};
|
||||
if (action == 'createLocal') options.isNew = true;
|
||||
@@ -415,6 +433,9 @@ class Synchronizer {
|
||||
await this.api().get(remoteResourceContentPath, { path: localResourceContentPath, target: 'file' });
|
||||
}
|
||||
|
||||
if (!newContent.user_updated_time) newContent.user_updated_time = newContent.updated_time;
|
||||
if (!newContent.user_created_time) newContent.user_created_time = newContent.created_time;
|
||||
|
||||
await ItemClass.save(newContent, options);
|
||||
|
||||
} else if (action == 'deleteLocal') {
|
||||
@@ -430,11 +451,18 @@ class Synchronizer {
|
||||
}
|
||||
}
|
||||
|
||||
if (!listResult.hasMore) {
|
||||
newDeltaContext = listResult.context;
|
||||
break;
|
||||
// If user has cancelled, don't record the new context (2) so that synchronisation
|
||||
// can start again from the previous context (1) next time. It is ok if some items
|
||||
// have been synced between (1) and (2) because the loop above will handle the same
|
||||
// items being synced twice as an update. If the local and remote items are indentical
|
||||
// the update will simply be skipped.
|
||||
if (!hasCancelled) {
|
||||
if (!listResult.hasMore) {
|
||||
newDeltaContext = listResult.context;
|
||||
break;
|
||||
}
|
||||
context = listResult.context;
|
||||
}
|
||||
context = listResult.context;
|
||||
}
|
||||
|
||||
outputContext.delta = newDeltaContext ? newDeltaContext : lastContext.delta;
|
||||
@@ -489,4 +517,4 @@ class Synchronizer {
|
||||
|
||||
}
|
||||
|
||||
export { Synchronizer };
|
||||
module.exports = { Synchronizer };
|
||||
@@ -1,4 +1,4 @@
|
||||
import moment from 'moment';
|
||||
const moment = require('moment');
|
||||
|
||||
let time = {
|
||||
|
||||
@@ -48,4 +48,4 @@ let time = {
|
||||
|
||||
}
|
||||
|
||||
export { time };
|
||||
module.exports = { time };
|
||||
9
ReactNativeClient/lib/urlUtils.js
Normal file
9
ReactNativeClient/lib/urlUtils.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const urlUtils = {};
|
||||
|
||||
urlUtils.hash = function(url) {
|
||||
const s = url.split('#');
|
||||
if (s.length <= 1) return '';
|
||||
return s[s.length - 1];
|
||||
}
|
||||
|
||||
module.exports = urlUtils;
|
||||
@@ -1,4 +1,4 @@
|
||||
import createUuidV4 from 'uuid/v4';
|
||||
const createUuidV4 = require('uuid/v4');
|
||||
|
||||
const uuid = {
|
||||
|
||||
@@ -8,4 +8,4 @@ const uuid = {
|
||||
|
||||
}
|
||||
|
||||
export { uuid };
|
||||
module.exports = { uuid };
|
||||
Reference in New Issue
Block a user