mirror of https://github.com/laurent22/joplin.git
Unversioning build files
parent
dbb5599b0f
commit
c838548831
|
@ -1,3 +1,4 @@
|
|||
build/
|
||||
app/node_modules/
|
||||
dist/
|
||||
dist/
|
||||
app/lib/
|
|
@ -1,352 +0,0 @@
|
|||
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 { sprintf } = require('sprintf-js');
|
||||
const { reg } = require('lib/registry.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');
|
||||
|
||||
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) {
|
||||
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 == '--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 (!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 === 'Note') {
|
||||
parentId = state.selectedNoteId;
|
||||
parentType = BaseModel.TYPE_NOTE;
|
||||
} 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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// const packageJson = require('./package.json');
|
||||
// this.logger_.info(sprintf('Starting %s %s (%s)...', packageJson.name, packageJson.version, Setting.value('env')));
|
||||
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);
|
||||
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 };
|
|
@ -1,390 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
console.warn('Could not load resource ' + id);
|
||||
return;
|
||||
}
|
||||
|
||||
resource.base64 = await shim.readLocalFileBase64(Resource.fullPath(resource));
|
||||
|
||||
let newResources = Object.assign({}, this.loadedResources_);
|
||||
newResources[id] = resource;
|
||||
this.loadedResources_ = newResources;
|
||||
|
||||
console.info('Resource loaded: ', resource.title);
|
||||
|
||||
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);
|
||||
if (!this.loadedResources_[resourceId]) {
|
||||
loadResource(resourceId);
|
||||
return '';
|
||||
}
|
||||
|
||||
const r = this.loadedResources_[resourceId];
|
||||
if (!r.base64) return '';
|
||||
|
||||
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) + ')]';
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
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.textContent = this.textContent == '☐' ? '☑' : '☐'; return false;";
|
||||
return '<a href="#" onclick="' + js + '" class="checkbox">' + (type == 'NOTICK' ? '☐' : '☑') + '</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;
|
||||
}
|
||||
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 {
|
||||
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: 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;
|
|
@ -1,367 +0,0 @@
|
|||
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 {
|
||||
|
||||
static modelType() {
|
||||
throw new Error('Must be overriden');
|
||||
}
|
||||
|
||||
static tableName() {
|
||||
throw new Error('Must be overriden');
|
||||
}
|
||||
|
||||
static addModelMd(model) {
|
||||
if (!model) return model;
|
||||
|
||||
if (Array.isArray(model)) {
|
||||
let output = [];
|
||||
for (let i = 0; i < model.length; i++) {
|
||||
output.push(this.addModelMd(model[i]));
|
||||
}
|
||||
return output;
|
||||
} else {
|
||||
model = Object.assign({}, model);
|
||||
model.type_ = this.modelType();
|
||||
return model;
|
||||
}
|
||||
}
|
||||
|
||||
static logger() {
|
||||
return this.db().logger();
|
||||
}
|
||||
|
||||
static useUuid() {
|
||||
return false;
|
||||
}
|
||||
|
||||
static byId(items, id) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].id == id) return items[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static hasField(name) {
|
||||
let fields = this.fieldNames();
|
||||
return fields.indexOf(name) >= 0;
|
||||
}
|
||||
|
||||
static fieldNames(withPrefix = false) {
|
||||
let output = this.db().tableFieldNames(this.tableName());
|
||||
if (!withPrefix) return output;
|
||||
|
||||
let p = withPrefix === true ? this.tableName() : withPrefix;
|
||||
let temp = [];
|
||||
for (let i = 0; i < output.length; i++) {
|
||||
temp.push(p + '.' + output[i]);
|
||||
}
|
||||
|
||||
return temp;
|
||||
}
|
||||
|
||||
static fieldType(name) {
|
||||
let fields = this.fields();
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
if (fields[i].name == name) return fields[i].type;
|
||||
}
|
||||
throw new Error('Unknown field: ' + name);
|
||||
}
|
||||
|
||||
static fields() {
|
||||
return this.db().tableFields(this.tableName());
|
||||
}
|
||||
|
||||
static new() {
|
||||
let fields = this.fields();
|
||||
let output = {};
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
let f = fields[i];
|
||||
output[f.name] = f.default;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
static modOptions(options) {
|
||||
if (!options) {
|
||||
options = {};
|
||||
} else {
|
||||
options = Object.assign({}, options);
|
||||
}
|
||||
if (!('isNew' in options)) options.isNew = 'auto';
|
||||
if (!('autoTimestamp' in options)) options.autoTimestamp = true;
|
||||
return options;
|
||||
}
|
||||
|
||||
static count() {
|
||||
return this.db().selectOne('SELECT count(*) as total FROM `' + this.tableName() + '`').then((r) => {
|
||||
return r ? r['total'] : 0;
|
||||
});
|
||||
}
|
||||
|
||||
static load(id) {
|
||||
return this.loadByField('id', id);
|
||||
}
|
||||
|
||||
static shortId(id) {
|
||||
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.modelSelectAll('SELECT * FROM `' + this.tableName() + '` WHERE `id` LIKE ?', [partialId + '%']);
|
||||
}
|
||||
|
||||
static applySqlOptions(options, sql, params = null) {
|
||||
if (!options) options = {};
|
||||
|
||||
if (options.order && options.order.length) {
|
||||
let items = [];
|
||||
for (let i = 0; i < options.order.length; i++) {
|
||||
const o = options.order[i];
|
||||
let item = o.by;
|
||||
if (options.caseInsensitive === true) item += ' COLLATE NOCASE';
|
||||
if (o.dir) item += ' ' + o.dir;
|
||||
items.push(item);
|
||||
}
|
||||
sql += ' ORDER BY ' + items.join(', ');
|
||||
}
|
||||
|
||||
if (options.limit) sql += ' LIMIT ' + options.limit;
|
||||
|
||||
return { sql: sql, params: params };
|
||||
}
|
||||
|
||||
static async allIds(options = null) {
|
||||
let q = this.applySqlOptions(options, 'SELECT id FROM `' + this.tableName() + '`');
|
||||
const rows = await this.db().selectAll(q.sql, q.params);
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
static async all(options = null) {
|
||||
let q = this.applySqlOptions(options, 'SELECT * FROM `' + this.tableName() + '`');
|
||||
return this.modelSelectAll(q.sql);
|
||||
}
|
||||
|
||||
static async search(options = null) {
|
||||
if (!options) options = {};
|
||||
if (!options.fields) options.fields = '*';
|
||||
|
||||
let conditions = options.conditions ? options.conditions.slice(0) : [];
|
||||
let params = options.conditionsParams ? options.conditionsParams.slice(0) : [];
|
||||
|
||||
if (options.titlePattern) {
|
||||
let pattern = options.titlePattern.replace(/\*/g, '%');
|
||||
conditions.push('title LIKE ?');
|
||||
params.push(pattern);
|
||||
}
|
||||
|
||||
if ('limit' in options && options.limit <= 0) return [];
|
||||
|
||||
let sql = 'SELECT ' + this.db().escapeFields(options.fields) + ' FROM `' + this.tableName() + '`';
|
||||
if (conditions.length) sql += ' WHERE ' + conditions.join(' AND ');
|
||||
|
||||
let query = this.applySqlOptions(options, sql, params);
|
||||
return this.modelSelectAll(query.sql, query.params);
|
||||
}
|
||||
|
||||
static modelSelectOne(sql, params = null) {
|
||||
if (params === null) params = [];
|
||||
return this.db().selectOne(sql, params).then((model) => {
|
||||
return this.filter(this.addModelMd(model));
|
||||
});
|
||||
}
|
||||
|
||||
static modelSelectAll(sql, params = null) {
|
||||
if (params === null) params = [];
|
||||
return this.db().selectAll(sql, params).then((models) => {
|
||||
return this.filterArray(this.addModelMd(models));
|
||||
});
|
||||
}
|
||||
|
||||
static loadByField(fieldName, fieldValue) {
|
||||
return this.modelSelectOne('SELECT * FROM `' + this.tableName() + '` WHERE `' + fieldName + '` = ?', [fieldValue]);
|
||||
}
|
||||
|
||||
static loadByTitle(fieldValue) {
|
||||
return this.modelSelectOne('SELECT * FROM `' + this.tableName() + '` WHERE `title` = ?', [fieldValue]);
|
||||
}
|
||||
|
||||
static diffObjects(oldModel, newModel) {
|
||||
let output = {};
|
||||
let type = null;
|
||||
for (let n in newModel) {
|
||||
if (n == 'type_') {
|
||||
type = n;
|
||||
continue;
|
||||
}
|
||||
if (!newModel.hasOwnProperty(n)) continue;
|
||||
if (!(n in oldModel) || newModel[n] !== oldModel[n]) {
|
||||
output[n] = newModel[n];
|
||||
}
|
||||
}
|
||||
if (type !== null) output.type_ = type;
|
||||
return output;
|
||||
}
|
||||
|
||||
static saveQuery(o, options) {
|
||||
let temp = {}
|
||||
let fieldNames = this.fieldNames();
|
||||
for (let i = 0; i < fieldNames.length; i++) {
|
||||
let n = fieldNames[i];
|
||||
if (n in o) temp[n] = o[n];
|
||||
}
|
||||
o = temp;
|
||||
|
||||
let query = {};
|
||||
let modelId = o.id;
|
||||
|
||||
const timeNow = time.unixMs();
|
||||
|
||||
if (options.autoTimestamp && this.hasField('updated_time')) {
|
||||
o.updated_time = timeNow;
|
||||
}
|
||||
|
||||
if (options.autoTimestamp && this.hasField('user_updated_time')) {
|
||||
o.user_updated_time = timeNow;
|
||||
}
|
||||
|
||||
if (options.isNew) {
|
||||
if (this.useUuid() && !o.id) {
|
||||
modelId = uuid.create();
|
||||
o.id = modelId;
|
||||
}
|
||||
|
||||
if (!o.created_time && this.hasField('created_time')) {
|
||||
o.created_time = timeNow;
|
||||
}
|
||||
|
||||
if (!o.user_created_time && this.hasField('user_created_time')) {
|
||||
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);
|
||||
} else {
|
||||
let where = { id: o.id };
|
||||
let temp = Object.assign({}, o);
|
||||
delete temp.id;
|
||||
query = Database.updateQuery(this.tableName(), temp, where);
|
||||
}
|
||||
|
||||
query.id = modelId;
|
||||
query.modObject = o;
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
static save(o, options = null) {
|
||||
options = this.modOptions(options);
|
||||
options.isNew = this.isNew(o, options);
|
||||
|
||||
o = this.filter(o);
|
||||
|
||||
let queries = [];
|
||||
let saveQuery = this.saveQuery(o, options);
|
||||
let modelId = saveQuery.id;
|
||||
|
||||
queries.push(saveQuery);
|
||||
|
||||
if (options.nextQueries && options.nextQueries.length) {
|
||||
queries = queries.concat(options.nextQueries);
|
||||
}
|
||||
|
||||
return this.db().transactionExecBatch(queries).then(() => {
|
||||
o = Object.assign({}, o);
|
||||
o.id = modelId;
|
||||
if ('updated_time' in saveQuery.modObject) o.updated_time = saveQuery.modObject.updated_time;
|
||||
if ('created_time' in saveQuery.modObject) o.created_time = saveQuery.modObject.created_time;
|
||||
if ('user_updated_time' in saveQuery.modObject) o.user_updated_time = saveQuery.modObject.user_updated_time;
|
||||
if ('user_created_time' in saveQuery.modObject) o.user_created_time = saveQuery.modObject.user_created_time;
|
||||
o = this.addModelMd(o);
|
||||
return this.filter(o);
|
||||
}).catch((error) => {
|
||||
Log.error('Cannot save model', error);
|
||||
});
|
||||
}
|
||||
|
||||
static isNew(object, options) {
|
||||
if (options && ('isNew' in options)) {
|
||||
// options.isNew can be "auto" too
|
||||
if (options.isNew === true) return true;
|
||||
if (options.isNew === false) return false;
|
||||
}
|
||||
|
||||
return !object.id;
|
||||
}
|
||||
|
||||
static filterArray(models) {
|
||||
let output = [];
|
||||
for (let i = 0; i < models.length; i++) {
|
||||
output.push(this.filter(models[i]));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
static filter(model) {
|
||||
if (!model) return model;
|
||||
|
||||
let output = Object.assign({}, model);
|
||||
for (let n in output) {
|
||||
if (!output.hasOwnProperty(n)) continue;
|
||||
// The SQLite database doesn't have booleans so cast everything to int
|
||||
if (output[n] === true) output[n] = 1;
|
||||
if (output[n] === false) output[n] = 0;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
static delete(id, options = null) {
|
||||
options = this.modOptions(options);
|
||||
if (!id) throw new Error('Cannot delete object without an ID');
|
||||
return this.db().exec('DELETE FROM ' + this.tableName() + ' WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
static batchDelete(ids, options = null) {
|
||||
options = this.modOptions(options);
|
||||
if (!ids.length) throw new Error('Cannot delete object without an ID');
|
||||
return this.db().exec('DELETE FROM ' + this.tableName() + ' WHERE id IN ("' + ids.join('","') + '")');
|
||||
}
|
||||
|
||||
static db() {
|
||||
if (!this.db_) throw new Error('Accessing database before it has been initialised');
|
||||
return this.db_;
|
||||
}
|
||||
|
||||
static isReady() {
|
||||
return !!this.db_;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
BaseModel.TYPE_NOTE = 1;
|
||||
BaseModel.TYPE_FOLDER = 2;
|
||||
BaseModel.TYPE_SETTING = 3;
|
||||
BaseModel.TYPE_RESOURCE = 4;
|
||||
BaseModel.TYPE_TAG = 5;
|
||||
BaseModel.TYPE_NOTE_TAG = 6;
|
||||
BaseModel.TYPE_SEARCH = 7;
|
||||
|
||||
BaseModel.db_ = null;
|
||||
BaseModel.dispatch = function(o) {};
|
||||
|
||||
module.exports = { BaseModel };
|
Binary file not shown.
Before Width: | Height: | Size: 344 B |
|
@ -1,141 +0,0 @@
|
|||
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: {
|
||||
fontSize: 20,
|
||||
height: 22,
|
||||
color: 'white',
|
||||
},
|
||||
itemText: {
|
||||
// fontSize: 14, // Cannot currently set fontsize since the bow surrounding the label has a fixed size
|
||||
}
|
||||
});
|
||||
|
||||
class ActionButtonComponent extends React.Component {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
buttonIndex: 0,
|
||||
};
|
||||
}
|
||||
|
||||
componentWillReceiveProps(newProps) {
|
||||
if ('buttonIndex' in newProps) {
|
||||
this.setState({ buttonIndex: newProps.buttonIndex });
|
||||
}
|
||||
}
|
||||
|
||||
newTodo_press() {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Note',
|
||||
noteId: null,
|
||||
folderId: this.props.parentFolderId,
|
||||
itemType: 'todo',
|
||||
});
|
||||
}
|
||||
|
||||
newNote_press() {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Note',
|
||||
noteId: null,
|
||||
folderId: this.props.parentFolderId,
|
||||
itemType: 'note',
|
||||
});
|
||||
}
|
||||
|
||||
newFolder_press() {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Folder',
|
||||
folderId: null,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
let buttons = this.props.buttons ? this.props.buttons : [];
|
||||
|
||||
if (this.props.addFolderNoteButtons) {
|
||||
if (this.props.folders.length) {
|
||||
buttons.push({
|
||||
title: _('New to-do'),
|
||||
onPress: () => { this.newTodo_press() },
|
||||
color: '#9b59b6',
|
||||
icon: 'md-checkbox-outline',
|
||||
});
|
||||
|
||||
buttons.push({
|
||||
title: _('New note'),
|
||||
onPress: () => { this.newNote_press() },
|
||||
color: '#9b59b6',
|
||||
icon: 'md-document',
|
||||
});
|
||||
}
|
||||
|
||||
buttons.push({
|
||||
title: _('New notebook'),
|
||||
onPress: () => { this.newFolder_press() },
|
||||
color: '#3498db',
|
||||
icon: 'md-folder',
|
||||
});
|
||||
}
|
||||
|
||||
let buttonComps = [];
|
||||
for (let i = 0; i < buttons.length; i++) {
|
||||
let button = buttons[i];
|
||||
let buttonTitle = button.title ? button.title : '';
|
||||
let key = buttonTitle.replace(/\s/g, '_') + '_' + button.icon;
|
||||
buttonComps.push(
|
||||
<ReactNativeActionButton.Item key={key} buttonColor={button.color} title={buttonTitle} onPress={button.onPress}>
|
||||
<Icon name={button.icon} style={styles.actionButtonIcon} />
|
||||
</ReactNativeActionButton.Item>
|
||||
);
|
||||
}
|
||||
|
||||
if (!buttonComps.length && !this.props.mainButton) {
|
||||
return <ReactNativeActionButton style={{ display: 'none' }}/>
|
||||
}
|
||||
|
||||
let mainButton = this.props.mainButton ? this.props.mainButton : {};
|
||||
let mainIcon = mainButton.icon ? <Icon name={mainButton.icon} style={styles.actionButtonIcon} /> : <Icon name="md-add" style={styles.actionButtonIcon} />
|
||||
|
||||
if (this.props.multiStates) {
|
||||
if (!this.props.buttons || !this.props.buttons.length) throw new Error('Multi-state button requires at least one state');
|
||||
if (this.state.buttonIndex < 0 || this.state.buttonIndex >= this.props.buttons.length) throw new Error('Button index out of bounds: ' + this.state.buttonIndex + '/' + this.props.buttons.length);
|
||||
let button = this.props.buttons[this.state.buttonIndex];
|
||||
let mainIcon = <Icon name={button.icon} style={styles.actionButtonIcon} />
|
||||
return (
|
||||
<ReactNativeActionButton
|
||||
icon={mainIcon}
|
||||
buttonColor="rgba(231,76,60,1)"
|
||||
onPress={() => { button.onPress() }}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<ReactNativeActionButton textStyle={styles.itemText} icon={mainIcon} buttonColor="rgba(231,76,60,1)" onPress={ function() { } }>
|
||||
{ buttonComps }
|
||||
</ReactNativeActionButton>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ActionButton = connect(
|
||||
(state) => {
|
||||
return {
|
||||
folders: state.folders,
|
||||
};
|
||||
}
|
||||
)(ActionButtonComponent)
|
||||
|
||||
module.exports = { ActionButton };
|
|
@ -1,66 +0,0 @@
|
|||
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 {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.previousRouteName_ = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.props.route) throw new Error('Route must not be null');
|
||||
|
||||
// Note: certain screens are kept into memory, in particular Notes and Search
|
||||
// so that the scroll position is not lost when the user navigate away from them.
|
||||
|
||||
let route = this.props.route;
|
||||
let Screen = null;
|
||||
let notesScreenVisible = false;
|
||||
let searchScreenVisible = false;
|
||||
|
||||
if (route.routeName == 'Notes') {
|
||||
notesScreenVisible = true;
|
||||
} else if (route.routeName == 'Search') {
|
||||
searchScreenVisible = true;
|
||||
} else {
|
||||
Screen = this.props.screens[route.routeName].screen;
|
||||
}
|
||||
|
||||
// Keep the search screen loaded if the user is viewing a note from that search screen
|
||||
// so that if the back button is pressed, the screen is still loaded. However, unload
|
||||
// it if navigating away.
|
||||
let searchScreenLoaded = searchScreenVisible || (this.previousRouteName_ == 'Search' && route.routeName == 'Note');
|
||||
|
||||
this.previousRouteName_ = route.routeName;
|
||||
|
||||
const theme = themeStyle(this.props.theme);
|
||||
|
||||
const style = { flex: 1, backgroundColor: theme.backgroundColor }
|
||||
|
||||
return (
|
||||
<View style={style}>
|
||||
<NotesScreen visible={notesScreenVisible} navigation={{ state: route }} />
|
||||
{ searchScreenLoaded && <SearchScreen visible={searchScreenVisible} navigation={{ state: route }} /> }
|
||||
{ (!notesScreenVisible && !searchScreenVisible) && <Screen navigation={{ state: route }} /> }
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const AppNav = connect(
|
||||
(state) => {
|
||||
return {
|
||||
route: state.route,
|
||||
theme: state.settings.theme,
|
||||
};
|
||||
}
|
||||
)(AppNavComponent)
|
||||
|
||||
module.exports = { AppNav };
|
|
@ -1,40 +0,0 @@
|
|||
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: {
|
||||
flex: 1,
|
||||
backgroundColor: globalStyle.backgroundColor,
|
||||
},
|
||||
};
|
||||
|
||||
const styles_ = StyleSheet.create(styleObject_);
|
||||
|
||||
let rootStyles_ = {};
|
||||
|
||||
class BaseScreenComponent extends React.Component {
|
||||
|
||||
styles() {
|
||||
return styles_;
|
||||
}
|
||||
|
||||
styleObject() {
|
||||
return styleObject_;
|
||||
}
|
||||
|
||||
rootStyle(themeId) {
|
||||
const theme = themeStyle(themeId);
|
||||
if (rootStyles_[themeId]) return rootStyles_[themeId];
|
||||
rootStyles_[themeId] = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
},
|
||||
});
|
||||
return rootStyles_[themeId];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { BaseScreenComponent };
|
|
@ -1,69 +0,0 @@
|
|||
const React = require('react'); const Component = React.Component;
|
||||
const { StyleSheet, TouchableHighlight } = require('react-native');
|
||||
const Icon = require('react-native-vector-icons/Ionicons').default;
|
||||
|
||||
const styles = {
|
||||
checkboxIcon: {
|
||||
fontSize: 20,
|
||||
height: 22,
|
||||
//marginRight: 10,
|
||||
},
|
||||
};
|
||||
|
||||
class Checkbox extends Component {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
checked: false,
|
||||
}
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.setState({ checked: this.props.checked });
|
||||
}
|
||||
|
||||
componentWillReceiveProps(newProps) {
|
||||
if ('checked' in newProps) {
|
||||
this.setState({ checked: newProps.checked });
|
||||
}
|
||||
}
|
||||
|
||||
onPress() {
|
||||
let newChecked = !this.state.checked;
|
||||
this.setState({ checked: newChecked });
|
||||
if (this.props.onChange) this.props.onChange(newChecked);
|
||||
}
|
||||
|
||||
render() {
|
||||
const iconName = this.state.checked ? 'md-checkbox-outline' : 'md-square-outline';
|
||||
|
||||
let style = this.props.style ? Object.assign({}, this.props.style) : {};
|
||||
style.justifyContent = 'center';
|
||||
style.alignItems = 'center';
|
||||
|
||||
let checkboxIconStyle = Object.assign({}, styles.checkboxIcon);
|
||||
if (style.color) checkboxIconStyle.color = style.color;
|
||||
|
||||
if (style.paddingTop) checkboxIconStyle.marginTop = style.paddingTop;
|
||||
if (style.paddingBottom) checkboxIconStyle.marginBottom = style.paddingBottom;
|
||||
if (style.paddingLeft) checkboxIconStyle.marginLeft = style.paddingLeft;
|
||||
if (style.paddingRight) checkboxIconStyle.marginRight = style.paddingRight;
|
||||
|
||||
const thStyle = {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
};
|
||||
|
||||
if (style.display) thStyle.display = style.display;
|
||||
|
||||
return (
|
||||
<TouchableHighlight onPress={() => this.onPress()} style={thStyle}>
|
||||
<Icon name={iconName} style={checkboxIconStyle}/>
|
||||
</TouchableHighlight>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { Checkbox };
|
|
@ -1,73 +0,0 @@
|
|||
const { Setting } = require('lib/models/setting.js');
|
||||
|
||||
const globalStyle = {
|
||||
fontSize: 16,
|
||||
margin: 15, // No text and no interactive component should be within this margin
|
||||
itemMarginTop: 10,
|
||||
itemMarginBottom: 10,
|
||||
backgroundColor: "#ffffff",
|
||||
color: "#555555", // For regular text
|
||||
colorError: "red",
|
||||
colorWarn: "#9A5B00",
|
||||
colorFaded: "#777777", // For less important text
|
||||
fontSizeSmaller: 14,
|
||||
dividerColor: "#dddddd",
|
||||
selectedColor: '#e5e5e5',
|
||||
disabledOpacity: 0.3,
|
||||
|
||||
raisedBackgroundColor: "#0080EF",
|
||||
raisedColor: "#003363",
|
||||
raisedHighlightedColor: "#ffffff",
|
||||
|
||||
// For WebView - must correspond to the properties above
|
||||
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;
|
||||
globalStyle.marginLeft = globalStyle.margin;
|
||||
globalStyle.marginTop = globalStyle.margin;
|
||||
globalStyle.marginBottom = globalStyle.margin;
|
||||
globalStyle.htmlMarginLeft = ((globalStyle.marginLeft / 10) * 0.6).toFixed(2) + 'em';
|
||||
|
||||
globalStyle.icon = {
|
||||
color: globalStyle.color,
|
||||
fontSize: 30,
|
||||
};
|
||||
|
||||
globalStyle.lineInput = {
|
||||
color: globalStyle.color,
|
||||
backgroundColor: globalStyle.backgroundColor,
|
||||
};
|
||||
|
||||
let themeCache_ = {};
|
||||
|
||||
function themeStyle(theme) {
|
||||
if (themeCache_[theme]) return themeCache_[theme];
|
||||
|
||||
let output = Object.assign({}, globalStyle);
|
||||
if (theme == Setting.THEME_LIGHT) return output;
|
||||
|
||||
output.backgroundColor = '#1D2024';
|
||||
output.color = '#dddddd';
|
||||
output.colorFaded = '#777777';
|
||||
output.dividerColor = '#555555';
|
||||
output.selectedColor = '#333333';
|
||||
|
||||
output.raisedBackgroundColor = "#0F2051";
|
||||
output.raisedColor = "#788BC3";
|
||||
output.raisedHighlightedColor = "#ffffff";
|
||||
|
||||
output.htmlColor = 'rgb(220,220,220)';
|
||||
output.htmlBackgroundColor = 'rgb(29,32,36)';
|
||||
output.htmlLinkColor = 'rgb(166,166,255)';
|
||||
|
||||
themeCache_[theme] = output;
|
||||
return themeCache_[theme];
|
||||
}
|
||||
|
||||
module.exports = { globalStyle, themeStyle };
|
|
@ -1,84 +0,0 @@
|
|||
const React = require('react'); const Component = React.Component;
|
||||
const { WebView, View, Linking } = require('react-native');
|
||||
const { globalStyle } = require('lib/components/global-style.js');
|
||||
const { Resource } = require('lib/models/resource.js');
|
||||
const { reg } = require('lib/registry.js');
|
||||
const MdToHtml = require('lib/MdToHtml.js');
|
||||
|
||||
class NoteBodyViewer extends Component {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
resources: {},
|
||||
webViewLoaded: false,
|
||||
}
|
||||
|
||||
this.isMounted_ = false;
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.mdToHtml_ = new MdToHtml({ supportsResourceLinks: false });
|
||||
this.isMounted_ = true;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.mdToHtml_ = null;
|
||||
this.isMounted_ = false;
|
||||
}
|
||||
|
||||
onLoadEnd() {
|
||||
if (this.state.webViewLoaded) return;
|
||||
|
||||
// Need to display after a delay to avoid a white flash before
|
||||
// the content is displayed.
|
||||
setTimeout(() => {
|
||||
if (!this.isMounted_) return;
|
||||
this.setState({ webViewLoaded: true });
|
||||
}, 100);
|
||||
}
|
||||
|
||||
render() {
|
||||
const note = this.props.note;
|
||||
const style = this.props.style;
|
||||
const onCheckboxChange = this.props.onCheckboxChange;
|
||||
|
||||
const mdOptions = {
|
||||
onResourceLoaded: () => {
|
||||
this.forceUpdate();
|
||||
},
|
||||
};
|
||||
|
||||
const html = this.mdToHtml_.render(note ? note.body : '', this.props.webViewStyle, mdOptions);
|
||||
|
||||
let webViewStyle = {}
|
||||
webViewStyle.opacity = this.state.webViewLoaded ? 1 : 0.01;
|
||||
|
||||
return (
|
||||
<View style={style}>
|
||||
<WebView
|
||||
style={webViewStyle}
|
||||
source={{ html: html }}
|
||||
onLoadEnd={() => this.onLoadEnd()}
|
||||
onError={(e) => reg.logger().error('WebView error', e) }
|
||||
onMessage={(event) => {
|
||||
let msg = event.nativeEvent.data;
|
||||
|
||||
if (msg.indexOf('checkboxclick:') === 0) {
|
||||
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]);
|
||||
} else {
|
||||
Linking.openURL(msg);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { NoteBodyViewer };
|
|
@ -1,132 +0,0 @@
|
|||
const React = require('react'); const Component = React.Component;
|
||||
const { connect } = require('react-redux');
|
||||
const { ListView, Text, TouchableHighlight, 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 {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.styles_ = {};
|
||||
}
|
||||
|
||||
noteItem_press(noteId) {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Note',
|
||||
noteId: noteId,
|
||||
});
|
||||
}
|
||||
|
||||
styles() {
|
||||
const theme = themeStyle(this.props.theme);
|
||||
|
||||
if (this.styles_[this.props.theme]) return this.styles_[this.props.theme];
|
||||
this.styles_ = {};
|
||||
|
||||
let styles = {
|
||||
listItem: {
|
||||
flexDirection: 'row',
|
||||
//height: 40,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.dividerColor,
|
||||
alignItems: 'flex-start',
|
||||
paddingLeft: theme.marginLeft,
|
||||
paddingRight: theme.marginRight,
|
||||
paddingTop: theme.itemMarginTop,
|
||||
paddingBottom: theme.itemMarginBottom,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
},
|
||||
listItemText: {
|
||||
flex: 1,
|
||||
color: theme.color,
|
||||
fontSize: theme.fontSize,
|
||||
},
|
||||
};
|
||||
|
||||
styles.listItemWithCheckbox = Object.assign({}, styles.listItem);
|
||||
delete styles.listItemWithCheckbox.paddingTop;
|
||||
delete styles.listItemWithCheckbox.paddingBottom;
|
||||
delete styles.listItemWithCheckbox.paddingLeft;
|
||||
|
||||
styles.listItemTextWithCheckbox = Object.assign({}, styles.listItemText);
|
||||
styles.listItemTextWithCheckbox.marginTop = styles.listItem.paddingTop - 1;
|
||||
styles.listItemTextWithCheckbox.marginBottom = styles.listItem.paddingBottom;
|
||||
|
||||
this.styles_[this.props.theme] = StyleSheet.create(styles);
|
||||
return this.styles_[this.props.theme];
|
||||
}
|
||||
|
||||
async todoCheckbox_change(checked) {
|
||||
if (!this.props.note) return;
|
||||
|
||||
const newNote = {
|
||||
id: this.props.note.id,
|
||||
todo_completed: checked ? time.unixMs() : 0,
|
||||
}
|
||||
await Note.save(newNote);
|
||||
}
|
||||
|
||||
onPress() {
|
||||
if (!this.props.note) return;
|
||||
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Note',
|
||||
noteId: this.props.note.id,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const note = this.props.note ? this.props.note : {};
|
||||
const isTodo = !!Number(note.is_todo);
|
||||
const onPress = this.props.onPress;
|
||||
const onCheckboxChange = this.props.onCheckboxChange;
|
||||
const theme = themeStyle(this.props.theme);
|
||||
|
||||
let checkboxStyle = !isTodo ? { display: 'none' } : { color: theme.color };
|
||||
|
||||
if (isTodo) {
|
||||
checkboxStyle.paddingRight = 10;
|
||||
checkboxStyle.paddingTop = theme.itemMarginTop;
|
||||
checkboxStyle.paddingBottom = theme.itemMarginBottom;
|
||||
checkboxStyle.paddingLeft = theme.marginLeft;
|
||||
}
|
||||
|
||||
const checkboxChecked = !!Number(note.todo_completed);
|
||||
|
||||
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} : {};
|
||||
|
||||
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>
|
||||
</View>
|
||||
</TouchableHighlight>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const NoteItem = connect(
|
||||
(state) => {
|
||||
return {
|
||||
theme: state.settings.theme,
|
||||
};
|
||||
}
|
||||
)(NoteItemComponent)
|
||||
|
||||
module.exports = { NoteItem };
|
|
@ -1,120 +0,0 @@
|
|||
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 {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const ds = new ListView.DataSource({
|
||||
rowHasChanged: (r1, r2) => { return r1 !== r2; }
|
||||
});
|
||||
this.state = {
|
||||
dataSource: ds,
|
||||
items: [],
|
||||
selectedItemIds: [],
|
||||
};
|
||||
this.rootRef_ = null;
|
||||
this.styles_ = {};
|
||||
}
|
||||
|
||||
styles() {
|
||||
const themeId = this.props.theme;
|
||||
const theme = themeStyle(themeId);
|
||||
|
||||
if (this.styles_[themeId]) return this.styles_[themeId];
|
||||
this.styles_ = {};
|
||||
|
||||
let styles = {
|
||||
noItemMessage: {
|
||||
paddingLeft: theme.marginLeft,
|
||||
paddingRight: theme.marginRight,
|
||||
paddingTop: theme.marginTop,
|
||||
paddingBottom: theme.marginBottom,
|
||||
fontSize: theme.fontSize,
|
||||
color: theme.color,
|
||||
},
|
||||
};
|
||||
|
||||
this.styles_[themeId] = StyleSheet.create(styles);
|
||||
return this.styles_[themeId];
|
||||
}
|
||||
|
||||
filterNotes(notes) {
|
||||
const todoFilter = 'all'; //Setting.value('todoFilter');
|
||||
if (todoFilter == 'all') return notes;
|
||||
|
||||
const now = time.unixMs();
|
||||
const maxInterval = 1000 * 60 * 60 * 24;
|
||||
const notRecentTime = now - maxInterval;
|
||||
|
||||
let output = [];
|
||||
for (let i = 0; i < notes.length; i++) {
|
||||
const note = notes[i];
|
||||
if (note.is_todo) {
|
||||
if (todoFilter == 'recent' && note.user_updated_time < notRecentTime && !!note.todo_completed) continue;
|
||||
if (todoFilter == 'nonCompleted' && !!note.todo_completed) continue;
|
||||
}
|
||||
output.push(note);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
const newDataSource = this.state.dataSource.cloneWithRows(this.filterNotes(this.props.items));
|
||||
this.setState({ dataSource: newDataSource });
|
||||
}
|
||||
|
||||
componentWillReceiveProps(newProps) {
|
||||
// https://stackoverflow.com/questions/38186114/react-native-redux-and-listview
|
||||
this.setState({
|
||||
dataSource: this.state.dataSource.cloneWithRows(this.filterNotes(newProps.items)),
|
||||
});
|
||||
|
||||
// Make sure scroll position is reset when switching from one folder to another or to a tag list.
|
||||
if (this.rootRef_ && newProps.notesSource != this.props.notesSource) {
|
||||
this.rootRef_.scrollTo({ x: 0, y: 0, animated: false });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
// `enableEmptySections` is to fix this warning: https://github.com/FaridSafi/react-native-gifted-listview/issues/39
|
||||
|
||||
if (this.state.dataSource.getRowCount()) {
|
||||
return (
|
||||
<ListView
|
||||
ref={(ref) => this.rootRef_ = ref}
|
||||
dataSource={this.state.dataSource}
|
||||
renderRow={(note) => {
|
||||
return <NoteItem note={note}/>
|
||||
}}
|
||||
enableEmptySections={true}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const noItemMessage = _('There are currently no notes. Create one by clicking on the (+) button.');
|
||||
return <Text style={this.styles().noItemMessage} >{noItemMessage}</Text>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const NoteList = connect(
|
||||
(state) => {
|
||||
return {
|
||||
items: state.notes,
|
||||
notesSource: state.notesSource,
|
||||
theme: state.settings.theme,
|
||||
};
|
||||
}
|
||||
)(NoteListComponent)
|
||||
|
||||
module.exports = { NoteList };
|
|
@ -1,326 +0,0 @@
|
|||
const React = require('react'); const Component = React.Component;
|
||||
const { connect } = require('react-redux');
|
||||
const { View, Text, Button, StyleSheet, TouchableOpacity, Picker, 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 { Menu, MenuOptions, MenuOption, MenuTrigger } = require('react-native-popup-menu');
|
||||
const { _ } = require('lib/locale.js');
|
||||
const { Setting } = require('lib/models/setting.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');
|
||||
|
||||
// Rather than applying a padding to the whole bar, it is applied to each
|
||||
// individual component (button, picker, etc.) so that the touchable areas
|
||||
// are widder and to give more room to the picker component which has a larger
|
||||
// default height.
|
||||
const PADDING_V = 10;
|
||||
|
||||
class ScreenHeaderComponent extends Component {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.styles_ = {};
|
||||
}
|
||||
|
||||
styles() {
|
||||
const themeId = Setting.value('theme');
|
||||
if (this.styles_[themeId]) return this.styles_[themeId];
|
||||
this.styles_ = {};
|
||||
|
||||
const theme = themeStyle(themeId);
|
||||
|
||||
let styleObject = {
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: theme.raisedBackgroundColor,
|
||||
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
|
||||
},
|
||||
divider: {
|
||||
borderBottomWidth: 1,
|
||||
borderColor: theme.dividerColor,
|
||||
backgroundColor: "#0000ff"
|
||||
},
|
||||
sideMenuButton: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.raisedBackgroundColor,
|
||||
paddingLeft: theme.marginLeft,
|
||||
paddingRight: 5,
|
||||
marginRight: 2,
|
||||
paddingTop: PADDING_V,
|
||||
paddingBottom: PADDING_V,
|
||||
},
|
||||
iconButton: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.raisedBackgroundColor,
|
||||
paddingLeft: 15,
|
||||
paddingRight: 15,
|
||||
paddingTop: PADDING_V,
|
||||
paddingBottom: PADDING_V,
|
||||
},
|
||||
saveButton: {
|
||||
flex: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.raisedHighlightedColor,
|
||||
borderRadius: 4,
|
||||
marginRight: 8,
|
||||
},
|
||||
saveButtonText: {
|
||||
textAlignVertical: 'center',
|
||||
color: theme.raisedHighlightedColor,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
savedButtonIcon: {
|
||||
fontSize: 20,
|
||||
color: theme.raisedHighlightedColor,
|
||||
width: 18,
|
||||
height: 18,
|
||||
},
|
||||
saveButtonIcon: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
},
|
||||
contextMenuTrigger: {
|
||||
fontSize: 25,
|
||||
paddingRight: theme.marginRight,
|
||||
color: theme.raisedColor,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
contextMenu: {
|
||||
backgroundColor: theme.raisedBackgroundColor,
|
||||
},
|
||||
contextMenuItem: {
|
||||
backgroundColor: theme.backgroundColor,
|
||||
},
|
||||
contextMenuItemText: {
|
||||
flex: 1,
|
||||
textAlignVertical: 'center',
|
||||
paddingLeft: theme.marginLeft,
|
||||
paddingRight: theme.marginRight,
|
||||
paddingTop: theme.itemMarginTop,
|
||||
paddingBottom: theme.itemMarginBottom,
|
||||
color: theme.color,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
fontSize: theme.fontSize,
|
||||
},
|
||||
titleText: {
|
||||
flex: 1,
|
||||
marginLeft: 0,
|
||||
color: theme.raisedHighlightedColor,
|
||||
fontWeight: 'bold',
|
||||
fontSize: theme.fontSize,
|
||||
}
|
||||
};
|
||||
|
||||
styleObject.topIcon = Object.assign({}, theme.icon);
|
||||
styleObject.topIcon.flex = 1;
|
||||
styleObject.topIcon.textAlignVertical = 'center';
|
||||
styleObject.topIcon.color = theme.raisedColor;
|
||||
|
||||
styleObject.backButton = Object.assign({}, styleObject.iconButton);
|
||||
styleObject.backButton.marginRight = 1;
|
||||
|
||||
styleObject.backButtonDisabled = Object.assign({}, styleObject.backButton, { opacity: theme.disabledOpacity });
|
||||
styleObject.saveButtonDisabled = Object.assign({}, styleObject.saveButton, { opacity: theme.disabledOpacity });
|
||||
|
||||
this.styles_[themeId] = StyleSheet.create(styleObject);
|
||||
return this.styles_[themeId];
|
||||
}
|
||||
|
||||
sideMenuButton_press() {
|
||||
this.props.dispatch({ type: 'SIDE_MENU_TOGGLE' });
|
||||
}
|
||||
|
||||
async backButton_press() {
|
||||
await BackButtonService.back();
|
||||
//this.props.dispatch({ type: 'NAV_BACK' });
|
||||
}
|
||||
|
||||
searchButton_press() {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Search',
|
||||
});
|
||||
}
|
||||
|
||||
menu_select(value) {
|
||||
if (typeof(value) == 'function') {
|
||||
value();
|
||||
}
|
||||
}
|
||||
|
||||
log_press() {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Log',
|
||||
});
|
||||
}
|
||||
|
||||
status_press() {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Status',
|
||||
});
|
||||
}
|
||||
|
||||
config_press() {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Config',
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
function sideMenuButton(styles, onPress) {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress}>
|
||||
<View style={styles.sideMenuButton}>
|
||||
<Icon name='md-menu' style={styles.topIcon} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
function backButton(styles, onPress, disabled) {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} disabled={disabled}>
|
||||
<View style={disabled ? styles.backButtonDisabled : styles.backButton}>
|
||||
<Icon name='md-arrow-back' style={styles.topIcon} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
function saveButton(styles, onPress, disabled, show) {
|
||||
if (!show) return null;
|
||||
|
||||
const icon = disabled ? <Icon name='md-checkmark' style={styles.savedButtonIcon} /> : <Image style={styles.saveButtonIcon} source={require('./SaveIcon.png')} />;
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} disabled={disabled} style={{ padding:0 }}>
|
||||
<View style={disabled ? styles.saveButtonDisabled : styles.saveButton}>
|
||||
{ icon }
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
function searchButton(styles, onPress) {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress}>
|
||||
<View style={styles.iconButton}>
|
||||
<Icon name='md-search' 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];
|
||||
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_' + 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}/>);
|
||||
}
|
||||
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>
|
||||
);
|
||||
} else {
|
||||
let title = 'title' in this.props && this.props.title !== null ? this.props.title : '';
|
||||
return <Text style={this.styles().titleText}>{title}</Text>
|
||||
}
|
||||
}
|
||||
|
||||
const titleComp = createTitleComponent();
|
||||
|
||||
return (
|
||||
<View style={this.styles().container} >
|
||||
{ sideMenuButton(this.styles(), () => this.sideMenuButton_press()) }
|
||||
{ backButton(this.styles(), () => this.backButton_press(), !this.props.historyCanGoBack) }
|
||||
{ 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>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
ScreenHeaderComponent.defaultProps = {
|
||||
menuOptions: [],
|
||||
};
|
||||
|
||||
const ScreenHeader = connect(
|
||||
(state) => {
|
||||
return {
|
||||
historyCanGoBack: state.historyCanGoBack,
|
||||
locale: state.settings.locale,
|
||||
theme: state.settings.theme,
|
||||
showAdvancedOptions: state.settings.showAdvancedOptions,
|
||||
};
|
||||
}
|
||||
)(ScreenHeaderComponent)
|
||||
|
||||
module.exports = { ScreenHeader };
|
|
@ -1,152 +0,0 @@
|
|||
const React = require('react'); const Component = React.Component;
|
||||
const { View, Switch, Slider, StyleSheet, Picker, Text, Button } = 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 { themeStyle } = require('lib/components/global-style.js');
|
||||
const { Setting } = require('lib/models/setting.js');
|
||||
|
||||
class ConfigScreenComponent extends BaseScreenComponent {
|
||||
|
||||
static navigationOptions(options) {
|
||||
return { header: null };
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.styles_ = {};
|
||||
}
|
||||
|
||||
styles() {
|
||||
const themeId = this.props.theme;
|
||||
const theme = themeStyle(themeId);
|
||||
|
||||
if (this.styles_[themeId]) return this.styles_[themeId];
|
||||
this.styles_ = {};
|
||||
|
||||
let styles = {
|
||||
settingContainer: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.dividerColor,
|
||||
paddingTop: theme.marginTop,
|
||||
paddingBottom: theme.marginBottom,
|
||||
paddingLeft: theme.marginLeft,
|
||||
paddingRight: theme.marginRight,
|
||||
},
|
||||
settingText: {
|
||||
fontWeight: 'bold',
|
||||
color: theme.color,
|
||||
fontSize: theme.fontSize,
|
||||
},
|
||||
settingControl: {
|
||||
color: theme.color,
|
||||
},
|
||||
pickerItem: {
|
||||
fontSize: theme.fontSize,
|
||||
}
|
||||
}
|
||||
|
||||
styles.switchSettingText = Object.assign({}, styles.settingText);
|
||||
styles.switchSettingText.width = '80%';
|
||||
|
||||
styles.switchSettingContainer = Object.assign({}, styles.settingContainer);
|
||||
styles.switchSettingContainer.flexDirection = 'row';
|
||||
styles.switchSettingContainer.justifyContent = 'space-between';
|
||||
|
||||
styles.switchSettingControl = Object.assign({}, styles.settingControl);
|
||||
delete styles.switchSettingControl.color;
|
||||
styles.switchSettingControl.width = '20%';
|
||||
|
||||
this.styles_[themeId] = StyleSheet.create(styles);
|
||||
return this.styles_[themeId];
|
||||
}
|
||||
|
||||
settingToComponent(key, value) {
|
||||
let output = null;
|
||||
|
||||
const updateSettingValue = (key, value) => {
|
||||
Setting.setValue(key, value);
|
||||
}
|
||||
|
||||
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}/>);
|
||||
}
|
||||
|
||||
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>
|
||||
</View>
|
||||
);
|
||||
} else if (md.type == Setting.TYPE_BOOL) {
|
||||
return (
|
||||
<View key={key} style={this.styles().switchSettingContainer}>
|
||||
<Text key="label" style={this.styles().switchSettingText}>{md.label()}</Text>
|
||||
<Switch key="control" style={this.styles().switchSettingControl} value={value} onValueChange={(value) => updateSettingValue(key, value)} />
|
||||
</View>
|
||||
);
|
||||
} else if (md.type == Setting.TYPE_INT) {
|
||||
return (
|
||||
<View key={key} style={this.styles().settingContainer}>
|
||||
<Text key="label" style={this.styles().settingText}>{md.label()}</Text>
|
||||
<Slider key="control" style={this.styles().settingControl} value={value} onValueChange={(value) => updateSettingValue(key, value)} />
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
//throw new Error('Unsupported setting type: ' + setting.type);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
render() {
|
||||
const settings = this.props.settings;
|
||||
|
||||
let settingComps = [];
|
||||
for (let key in settings) {
|
||||
if (key == 'sync.target') continue;
|
||||
if (!settings.hasOwnProperty(key)) continue;
|
||||
if (!Setting.isPublic(key)) continue;
|
||||
|
||||
const comp = this.settingToComponent(key, settings[key]);
|
||||
if (!comp) continue;
|
||||
settingComps.push(comp);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={this.rootStyle(this.props.theme).root}>
|
||||
<ScreenHeader title={_('Configuration')}/>
|
||||
<View style={this.styles().body}>
|
||||
{ settingComps }
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const ConfigScreen = connect(
|
||||
(state) => {
|
||||
return {
|
||||
settings: state.settings,
|
||||
theme: state.settings.theme,
|
||||
};
|
||||
}
|
||||
)(ConfigScreenComponent)
|
||||
|
||||
module.exports = { ConfigScreen };
|
|
@ -1,132 +0,0 @@
|
|||
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 {
|
||||
|
||||
static navigationOptions(options) {
|
||||
return { header: null };
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
folder: Folder.new(),
|
||||
lastSavedFolder: null,
|
||||
};
|
||||
this.styles_ = {};
|
||||
}
|
||||
|
||||
styles() {
|
||||
const theme = themeStyle(this.props.theme);
|
||||
|
||||
if (this.styles_[this.props.theme]) return this.styles_[this.props.theme];
|
||||
this.styles_ = {};
|
||||
|
||||
let styles = {
|
||||
textInput: {
|
||||
color: theme.color,
|
||||
},
|
||||
};
|
||||
|
||||
this.styles_[this.props.theme] = StyleSheet.create(styles);
|
||||
return this.styles_[this.props.theme];
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
if (!this.props.folderId) {
|
||||
const folder = Folder.new();
|
||||
this.setState({
|
||||
folder: folder,
|
||||
lastSavedFolder: Object.assign({}, folder),
|
||||
});
|
||||
} else {
|
||||
Folder.load(this.props.folderId).then((folder) => {
|
||||
this.setState({
|
||||
folder: folder,
|
||||
lastSavedFolder: Object.assign({}, folder),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
isModified() {
|
||||
if (!this.state.folder || !this.state.lastSavedFolder) return false;
|
||||
let diff = BaseModel.diffObjects(this.state.folder, this.state.lastSavedFolder);
|
||||
delete diff.type_;
|
||||
return !!Object.getOwnPropertyNames(diff).length;
|
||||
}
|
||||
|
||||
folderComponent_change(propName, propValue) {
|
||||
this.setState((prevState, props) => {
|
||||
let folder = Object.assign({}, prevState.folder);
|
||||
folder[propName] = propValue;
|
||||
return { folder: folder }
|
||||
});
|
||||
}
|
||||
|
||||
title_changeText(text) {
|
||||
this.folderComponent_change('title', text);
|
||||
}
|
||||
|
||||
async saveFolderButton_press() {
|
||||
let folder = Object.assign({}, this.state.folder);
|
||||
|
||||
try {
|
||||
folder = await Folder.save(folder, { userSideValidation: true });
|
||||
} catch (error) {
|
||||
dialogs.error(this, _('The notebook could not be saved: %s', error.message));
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
lastSavedFolder: Object.assign({}, folder),
|
||||
folder: folder,
|
||||
});
|
||||
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Notes',
|
||||
folderId: folder.id,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
let saveButtonDisabled = !this.isModified();
|
||||
|
||||
return (
|
||||
<View style={this.rootStyle(this.props.theme).root}>
|
||||
<ScreenHeader
|
||||
title={_('Edit notebook')}
|
||||
showSaveButton={true}
|
||||
saveButtonDisabled={saveButtonDisabled}
|
||||
onSaveButtonPress={() => this.saveFolderButton_press()}
|
||||
/>
|
||||
<TextInput style={this.styles().textInput} autoFocus={true} value={this.state.folder.title} onChangeText={(text) => this.title_changeText(text)} />
|
||||
<dialogs.DialogBox ref={dialogbox => { this.dialogbox = dialogbox }}/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const FolderScreen = connect(
|
||||
(state) => {
|
||||
return {
|
||||
folderId: state.selectedFolderId,
|
||||
theme: state.settings.theme,
|
||||
};
|
||||
}
|
||||
)(FolderScreenComponent)
|
||||
|
||||
module.exports = { FolderScreen };
|
|
@ -1,109 +0,0 @@
|
|||
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 {
|
||||
|
||||
static navigationOptions(options) {
|
||||
return { header: null };
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const ds = new ListView.DataSource({
|
||||
rowHasChanged: (r1, r2) => { return r1 !== r2; }
|
||||
});
|
||||
this.state = {
|
||||
dataSource: ds,
|
||||
};
|
||||
this.styles_ = {};
|
||||
}
|
||||
|
||||
styles() {
|
||||
const theme = themeStyle(this.props.theme);
|
||||
|
||||
if (this.styles_[this.props.theme]) return this.styles_[this.props.theme];
|
||||
this.styles_ = {};
|
||||
|
||||
let styles = {
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
paddingLeft: 1,
|
||||
paddingRight: 1,
|
||||
paddingTop:0,
|
||||
paddingBottom:0,
|
||||
},
|
||||
rowText: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 10,
|
||||
color: theme.color,
|
||||
},
|
||||
};
|
||||
|
||||
styles.rowTextError = Object.assign({}, styles.rowText);
|
||||
styles.rowTextError.color = theme.colorError;
|
||||
|
||||
styles.rowTextWarn = Object.assign({}, styles.rowText);
|
||||
styles.rowTextWarn.color = theme.colorWarn;
|
||||
|
||||
this.styles_[this.props.theme] = StyleSheet.create(styles);
|
||||
return this.styles_[this.props.theme];
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.resfreshLogEntries();
|
||||
}
|
||||
|
||||
resfreshLogEntries() {
|
||||
reg.logger().lastEntries(1000).then((entries) => {
|
||||
const newDataSource = this.state.dataSource.cloneWithRows(entries);
|
||||
this.setState({ dataSource: newDataSource });
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
let renderRow = (item) => {
|
||||
let textStyle = this.styles().rowText;
|
||||
if (item.level == Logger.LEVEL_WARN) textStyle = this.styles().rowTextWarn;
|
||||
if (item.level == Logger.LEVEL_ERROR) textStyle = this.styles().rowTextError;
|
||||
|
||||
return (
|
||||
<View style={this.styles().row}>
|
||||
<Text style={textStyle}>{time.formatMsToLocal(item.timestamp, 'MM-DDTHH:mm:ss') + ': ' + item.message}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// `enableEmptySections` is to fix this warning: https://github.com/FaridSafi/react-native-gifted-listview/issues/39
|
||||
return (
|
||||
<View style={this.rootStyle(this.props.theme).root}>
|
||||
<ScreenHeader title={_('Log')}/>
|
||||
<ListView
|
||||
dataSource={this.state.dataSource}
|
||||
renderRow={renderRow}
|
||||
enableEmptySections={true}
|
||||
/>
|
||||
<Button title={_("Refresh")} onPress={() => { this.resfreshLogEntries(); }}/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const LogScreen = connect(
|
||||
(state) => {
|
||||
return {
|
||||
theme: state.settings.theme,
|
||||
};
|
||||
}
|
||||
)(LogScreenComponent)
|
||||
|
||||
module.exports = { LogScreen };
|
|
@ -1,485 +0,0 @@
|
|||
const React = require('react'); const Component = React.Component;
|
||||
const { 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 { Note } = require('lib/models/note.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 { 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');
|
||||
|
||||
class NoteScreenComponent extends BaseScreenComponent {
|
||||
|
||||
static navigationOptions(options) {
|
||||
return { header: null };
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
note: Note.new(),
|
||||
mode: 'view',
|
||||
noteMetadata: '',
|
||||
showNoteMetadata: false,
|
||||
folder: null,
|
||||
lastSavedNote: null,
|
||||
isLoading: true,
|
||||
titleTextInputHeight: 20,
|
||||
};
|
||||
|
||||
this.saveButtonHasBeenShown_ = false;
|
||||
|
||||
this.styles_ = {};
|
||||
|
||||
this.backHandler = async () => {
|
||||
if (this.isModified()) {
|
||||
let buttonId = await dialogs.pop(this, _('This note has been modified:'), [
|
||||
{ title: _('Save changes'), id: 'save' },
|
||||
{ title: _('Discard changes'), id: 'discard' },
|
||||
{ title: _('Cancel'), id: 'cancel' },
|
||||
]);
|
||||
|
||||
if (buttonId == 'cancel') return true;
|
||||
if (buttonId == 'save') await this.saveNoteButton_press();
|
||||
}
|
||||
|
||||
if (!this.state.note.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.state.mode == 'edit') {
|
||||
Keyboard.dismiss()
|
||||
|
||||
this.setState({
|
||||
note: Object.assign({}, this.state.lastSavedNote),
|
||||
mode: 'view',
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
styles() {
|
||||
const themeId = this.props.theme;
|
||||
const theme = themeStyle(themeId);
|
||||
|
||||
if (this.styles_[themeId]) return this.styles_[themeId];
|
||||
this.styles_ = {};
|
||||
|
||||
let styles = {
|
||||
bodyTextInput: {
|
||||
flex: 1,
|
||||
paddingLeft: theme.marginLeft,
|
||||
paddingRight: theme.marginRight,
|
||||
textAlignVertical: 'top',
|
||||
color: theme.color,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
fontSize: theme.fontSize,
|
||||
},
|
||||
noteBodyViewer: {
|
||||
flex: 1,
|
||||
paddingLeft: theme.marginLeft,
|
||||
paddingRight: theme.marginRight,
|
||||
paddingTop: theme.marginTop,
|
||||
paddingBottom: theme.marginBottom,
|
||||
},
|
||||
metadata: {
|
||||
paddingLeft: globalStyle.marginLeft,
|
||||
paddingRight: globalStyle.marginRight,
|
||||
color: theme.color,
|
||||
},
|
||||
};
|
||||
|
||||
styles.titleContainer = {
|
||||
flex: 0,
|
||||
flexDirection: 'row',
|
||||
paddingLeft: theme.marginLeft,
|
||||
paddingRight: theme.marginRight,
|
||||
borderBottomColor: theme.dividerColor,
|
||||
borderBottomWidth: 1,
|
||||
};
|
||||
|
||||
styles.titleContainerTodo = Object.assign({}, styles.titleContainer);
|
||||
styles.titleContainerTodo.paddingLeft = 0;
|
||||
|
||||
this.styles_[themeId] = StyleSheet.create(styles);
|
||||
return this.styles_[themeId];
|
||||
}
|
||||
|
||||
isModified() {
|
||||
return shared.isModified(this);
|
||||
}
|
||||
|
||||
async componentWillMount() {
|
||||
BackButtonService.addHandler(this.backHandler);
|
||||
|
||||
await shared.initState(this);
|
||||
|
||||
this.refreshNoteMetadata();
|
||||
}
|
||||
|
||||
refreshNoteMetadata(force = null) {
|
||||
return shared.refreshNoteMetadata(this, force);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
BackButtonService.removeHandler(this.backHandler);
|
||||
}
|
||||
|
||||
title_changeText(text) {
|
||||
shared.noteComponent_change(this, 'title', text);
|
||||
}
|
||||
|
||||
body_changeText(text) {
|
||||
shared.noteComponent_change(this, 'body', text);
|
||||
}
|
||||
|
||||
async saveNoteButton_press() {
|
||||
await shared.saveNoteButton_press(this);
|
||||
|
||||
Keyboard.dismiss();
|
||||
}
|
||||
|
||||
async saveOneProperty(name, value) {
|
||||
await shared.saveOneProperty(this, name, value);
|
||||
}
|
||||
|
||||
async deleteNote_onPress() {
|
||||
let note = this.state.note;
|
||||
if (!note.id) return;
|
||||
|
||||
let ok = await dialogs.confirm(this, _('Delete note?'));
|
||||
if (!ok) return;
|
||||
|
||||
let folderId = note.parent_id;
|
||||
|
||||
await Note.delete(note.id);
|
||||
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Notes',
|
||||
folderId: folderId,
|
||||
});
|
||||
}
|
||||
|
||||
async pickDocument() {
|
||||
return new Promise((resolve, reject) => {
|
||||
DocumentPicker.show({ filetype: [DocumentPickerUtil.images()] }, (error,res) => {
|
||||
if (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;
|
||||
}
|
||||
|
||||
resolve(res);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async imageDimensions(uri) {
|
||||
return new Promise((resolve, reject) => {
|
||||
Image.getSize(uri, (width, height) => {
|
||||
resolve({ width: width, height: height });
|
||||
}, (error) => { reject(error) });
|
||||
});
|
||||
}
|
||||
|
||||
async attachFile_onPress() {
|
||||
const res = await this.pickDocument();
|
||||
if (!res) {
|
||||
reg.logger().info('Did not get any file (user cancel?)');
|
||||
return;
|
||||
}
|
||||
|
||||
const localFilePath = res.uri;
|
||||
|
||||
reg.logger().info('Got file: ' + localFilePath);
|
||||
reg.logger().info('Got type: ' + res.type);
|
||||
|
||||
// res.uri,
|
||||
// res.type, // mime type
|
||||
// res.fileName,
|
||||
// res.fileSize
|
||||
|
||||
let resource = Resource.new();
|
||||
resource.id = uuid.create();
|
||||
resource.mime = res.type;
|
||||
resource.title = res.fileName ? res.fileName : _('Untitled');
|
||||
|
||||
let targetPath = Resource.fullPath(resource);
|
||||
|
||||
if (res.type == 'image/jpeg' || res.type == 'image/jpg' || res.type == 'image/png') {
|
||||
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 = res.type == 'image/png' ? 'PNG' : 'JPEG';
|
||||
reg.logger().info('Resizing image ' + localFilePath);
|
||||
const resizedImage = await ImageResizer.createResizedImage(localFilePath, dimensions.width, dimensions.height, format, 85);
|
||||
const resizedImagePath = resizedImage.uri;
|
||||
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);
|
||||
}
|
||||
|
||||
await Resource.save(resource, { isNew: true });
|
||||
|
||||
const resourceTag = Resource.markdownTag(resource);
|
||||
|
||||
const newNote = Object.assign({}, this.state.note);
|
||||
newNote.body += "\n" + resourceTag;
|
||||
this.setState({ note: newNote });
|
||||
}
|
||||
|
||||
toggleIsTodo_onPress() {
|
||||
shared.toggleIsTodo_onPress(this);
|
||||
}
|
||||
|
||||
showMetadata_onPress() {
|
||||
shared.showMetadata_onPress(this);
|
||||
}
|
||||
|
||||
async showOnMap_onPress() {
|
||||
if (!this.state.note.id) return;
|
||||
|
||||
let note = await Note.load(this.state.note.id);
|
||||
try {
|
||||
const url = Note.geolocationUrl(note);
|
||||
Linking.openURL(url);
|
||||
} catch (error) {
|
||||
await dialogs.error(this, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
menuOptions() {
|
||||
const note = this.state.note;
|
||||
const isTodo = note && !!note.is_todo;
|
||||
|
||||
let output = [];
|
||||
|
||||
output.push({ title: _('Attach file'), onPress: () => { this.attachFile_onPress(); } });
|
||||
output.push({ title: _('Delete note'), onPress: () => { this.deleteNote_onPress(); } });
|
||||
|
||||
// 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) {
|
||||
await this.saveOneProperty('todo_completed', checked ? time.unixMs() : 0);
|
||||
}
|
||||
|
||||
titleTextInput_contentSizeChange(event) {
|
||||
let height = event.nativeEvent.contentSize.height;
|
||||
this.setState({ titleTextInputHeight: height });
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.isLoading) {
|
||||
return (
|
||||
<View style={this.styles().screen}>
|
||||
<ScreenHeader/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const theme = themeStyle(this.props.theme);
|
||||
const note = this.state.note;
|
||||
const isTodo = !!Number(note.is_todo);
|
||||
const folder = this.state.folder;
|
||||
const isNew = !note.id;
|
||||
|
||||
let bodyComponent = null;
|
||||
if (this.state.mode == 'view') {
|
||||
const onCheckboxChange = (newBody) => {
|
||||
this.saveOneProperty('body', newBody);
|
||||
};
|
||||
|
||||
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"
|
||||
autoFocus={focusBody}
|
||||
style={this.styles().bodyTextInput}
|
||||
multiline={true}
|
||||
value={note.body}
|
||||
onChangeText={(text) => this.body_changeText(text)}
|
||||
blurOnSubmit={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const renderActionButton = () => {
|
||||
let buttons = [];
|
||||
|
||||
buttons.push({
|
||||
title: _('Edit'),
|
||||
icon: 'md-create',
|
||||
onPress: () => {
|
||||
this.setState({ mode: 'edit' });
|
||||
},
|
||||
});
|
||||
|
||||
if (this.state.mode == 'edit') return <ActionButton style={{display:'none'}}/>;
|
||||
|
||||
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_;
|
||||
let saveButtonDisabled = !this.isModified();
|
||||
|
||||
if (showSaveButton) this.saveButtonHasBeenShown_ = true;
|
||||
|
||||
const titleContainerStyle = isTodo ? this.styles().titleContainerTodo : this.styles().titleContainer;
|
||||
|
||||
let titleTextInputStyle = {
|
||||
flex: 1,
|
||||
paddingLeft: 0,
|
||||
color: theme.color,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
fontWeight: 'bold',
|
||||
fontSize: theme.fontSize,
|
||||
};
|
||||
|
||||
titleTextInputStyle.height = this.state.titleTextInputHeight;
|
||||
|
||||
let checkboxStyle = {
|
||||
color: theme.color,
|
||||
paddingRight: 10,
|
||||
paddingLeft: theme.marginLeft,
|
||||
}
|
||||
|
||||
const titleComp = (
|
||||
<View style={titleContainerStyle}>
|
||||
{ isTodo && <Checkbox style={checkboxStyle} checked={!!Number(note.todo_completed)} onChange={(checked) => { this.todoCheckbox_change(checked) }} /> }
|
||||
<TextInput
|
||||
onContentSizeChange={(event) => this.titleTextInput_contentSizeChange(event)}
|
||||
autoFocus={isNew}
|
||||
multiline={true}
|
||||
underlineColorAndroid="#ffffff00"
|
||||
autoCapitalize="sentences"
|
||||
style={titleTextInputStyle}
|
||||
value={note.title}
|
||||
onChangeText={(text) => this.title_changeText(text)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={this.rootStyle(this.props.theme).root}>
|
||||
<ScreenHeader
|
||||
titlePicker={{
|
||||
items: titlePickerItems(),
|
||||
selectedValue: 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;
|
||||
|
||||
const folder = await Folder.load(note.parent_id);
|
||||
|
||||
this.setState({
|
||||
lastSavedNote: Object.assign({}, note),
|
||||
note: note,
|
||||
folder: folder,
|
||||
});
|
||||
}
|
||||
}}
|
||||
menuOptions={this.menuOptions()}
|
||||
showSaveButton={showSaveButton}
|
||||
saveButtonDisabled={saveButtonDisabled}
|
||||
onSaveButtonPress={() => this.saveNoteButton_press()}
|
||||
/>
|
||||
{ titleComp }
|
||||
{ bodyComponent }
|
||||
{ actionButtonComp }
|
||||
{ this.state.showNoteMetadata && <Text style={this.styles().metadata}>{this.state.noteMetadata}</Text> }
|
||||
<DialogBox ref={dialogbox => { this.dialogbox = dialogbox }}/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const NoteScreen = connect(
|
||||
(state) => {
|
||||
return {
|
||||
noteId: state.selectedNoteId,
|
||||
folderId: state.selectedFolderId,
|
||||
itemType: state.selectedItemType,
|
||||
folders: state.folders,
|
||||
theme: state.settings.theme,
|
||||
showAdvancedOptions: state.settings.showAdvancedOptions,
|
||||
};
|
||||
}
|
||||
)(NoteScreenComponent)
|
||||
|
||||
module.exports = { NoteScreen };
|
|
@ -1,174 +0,0 @@
|
|||
const React = require('react'); const Component = React.Component;
|
||||
const { View, Button, Picker } = 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 {
|
||||
|
||||
static navigationOptions(options) {
|
||||
return { header: null };
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
await this.refreshNotes();
|
||||
}
|
||||
|
||||
async componentWillReceiveProps(newProps) {
|
||||
if (newProps.notesOrder !== this.props.notesOrder ||
|
||||
newProps.selectedFolderId != this.props.selectedFolderId ||
|
||||
newProps.selectedTagId != this.props.selectedTagId ||
|
||||
newProps.notesParentType != this.props.notesParentType) {
|
||||
await this.refreshNotes(newProps);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshNotes(props = null) {
|
||||
if (props === null) props = this.props;
|
||||
|
||||
let options = {
|
||||
order: props.notesOrder,
|
||||
uncompletedTodosOnTop: props.uncompletedTodosOnTop,
|
||||
};
|
||||
|
||||
const parent = this.parentItem(props);
|
||||
if (!parent) return;
|
||||
|
||||
const source = JSON.stringify({
|
||||
options: options,
|
||||
parentId: parent.id,
|
||||
});
|
||||
|
||||
if (source == props.notesSource) return;
|
||||
|
||||
let notes = [];
|
||||
if (props.notesParentType == 'Folder') {
|
||||
notes = await Note.previews(props.selectedFolderId, options);
|
||||
} else {
|
||||
notes = await Tag.notes(props.selectedTagId); // TODO: should also return previews
|
||||
}
|
||||
|
||||
this.props.dispatch({
|
||||
type: 'NOTE_UPDATE_ALL',
|
||||
notes: notes,
|
||||
notesSource: source,
|
||||
});
|
||||
}
|
||||
|
||||
deleteFolder_onPress(folderId) {
|
||||
dialogs.confirm(this, _('Delete notebook?')).then((ok) => {
|
||||
if (!ok) return;
|
||||
|
||||
Folder.delete(folderId).then(() => {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Welcome',
|
||||
});
|
||||
}).catch((error) => {
|
||||
alert(error.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
editFolder_onPress(folderId) {
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Folder',
|
||||
folderId: folderId,
|
||||
});
|
||||
}
|
||||
|
||||
menuOptions() {
|
||||
if (this.props.notesParentType == 'Folder') {
|
||||
if (this.props.selectedFolderId == Folder.conflictFolderId()) return [];
|
||||
|
||||
return [
|
||||
{ title: _('Delete notebook'), onPress: () => { this.deleteFolder_onPress(this.props.selectedFolderId); } },
|
||||
{ title: _('Edit notebook'), onPress: () => { this.editFolder_onPress(this.props.selectedFolderId); } },
|
||||
];
|
||||
} else {
|
||||
return []; // For tags - TODO
|
||||
}
|
||||
}
|
||||
|
||||
parentItem(props = null) {
|
||||
if (!props) props = this.props;
|
||||
|
||||
let output = null;
|
||||
if (props.notesParentType == 'Folder') {
|
||||
output = Folder.byId(props.folders, props.selectedFolderId);
|
||||
} else if (props.notesParentType == 'Tag') {
|
||||
output = Tag.byId(props.tags, props.selectedTagId);
|
||||
} else {
|
||||
return null;
|
||||
throw new Error('Invalid parent type: ' + props.notesParentType);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
render() {
|
||||
const parent = this.parentItem();
|
||||
const theme = themeStyle(this.props.theme);
|
||||
|
||||
let rootStyle = {
|
||||
flex: 1,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
}
|
||||
|
||||
if (!this.props.visible) {
|
||||
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
|
||||
}
|
||||
|
||||
if (!parent) {
|
||||
return (
|
||||
<View style={rootStyle}>
|
||||
<ScreenHeader title={title} menuOptions={this.menuOptions()} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
let title = parent ? parent.title : null;
|
||||
const addFolderNoteButtons = this.props.selectedFolderId && this.props.selectedFolderId != Folder.conflictFolderId();
|
||||
|
||||
return (
|
||||
<View style={rootStyle}>
|
||||
<ScreenHeader title={title} menuOptions={this.menuOptions()} />
|
||||
<NoteList style={{flex: 1}}/>
|
||||
<ActionButton addFolderNoteButtons={addFolderNoteButtons} parentFolderId={this.props.selectedFolderId}></ActionButton>
|
||||
<DialogBox ref={dialogbox => { this.dialogbox = dialogbox }}/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const NotesScreen = connect(
|
||||
(state) => {
|
||||
return {
|
||||
folders: state.folders,
|
||||
tags: state.tags,
|
||||
selectedFolderId: state.selectedFolderId,
|
||||
selectedTagId: state.selectedTagId,
|
||||
notesParentType: state.notesParentType,
|
||||
notes: state.notes,
|
||||
notesOrder: state.notesOrder,
|
||||
notesSource: state.notesSource,
|
||||
uncompletedTodosOnTop: state.settings.uncompletedTodosOnTop,
|
||||
theme: state.settings.theme,
|
||||
};
|
||||
}
|
||||
)(NotesScreenComponent)
|
||||
|
||||
module.exports = { NotesScreen };
|
|
@ -1,110 +0,0 @@
|
|||
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 {
|
||||
|
||||
static navigationOptions(options) {
|
||||
return { header: null };
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.state = { webviewUrl: '' };
|
||||
this.authCode_ = null;
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.setState({
|
||||
webviewUrl: this.startUrl(),
|
||||
});
|
||||
}
|
||||
|
||||
startUrl() {
|
||||
return reg.oneDriveApi().authCodeUrl(this.redirectUrl());
|
||||
}
|
||||
|
||||
redirectUrl() {
|
||||
return reg.oneDriveApi().nativeClientRedirectUrl();
|
||||
}
|
||||
|
||||
async webview_load(noIdeaWhatThisIs) {
|
||||
// This is deprecated according to the doc but since the non-deprecated property (source)
|
||||
// doesn't exist, use this for now. The whole component is completely undocumented
|
||||
// at the moment so it's likely to change.
|
||||
const url = noIdeaWhatThisIs.url;
|
||||
|
||||
if (!this.authCode_ && url.indexOf(this.redirectUrl() + '?code=') === 0) {
|
||||
Log.info('URL: ' + url);
|
||||
|
||||
let code = url.split('?code=');
|
||||
this.authCode_ = code[1];
|
||||
|
||||
try {
|
||||
await reg.oneDriveApi().execTokenRequest(this.authCode_, this.redirectUrl(), true);
|
||||
this.props.dispatch({ type: 'NAV_BACK' });
|
||||
reg.scheduleSync(0);
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
|
||||
this.authCode_ = null;
|
||||
}
|
||||
}
|
||||
|
||||
async webview_error(error) {
|
||||
Log.error(error);
|
||||
}
|
||||
|
||||
retryButton_click() {
|
||||
// It seems the only way it would reload the page is by loading an unrelated
|
||||
// URL, waiting a bit, and then loading the actual URL. There's probably
|
||||
// a better way to do this.
|
||||
|
||||
this.setState({
|
||||
webviewUrl: 'https://microsoft.com',
|
||||
});
|
||||
this.forceUpdate();
|
||||
|
||||
setTimeout(() => {
|
||||
this.setState({
|
||||
webviewUrl: this.startUrl(),
|
||||
});
|
||||
this.forceUpdate();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
render() {
|
||||
const source = {
|
||||
uri: this.state.webviewUrl,
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={this.styles().screen}>
|
||||
<ScreenHeader title={_('Login with OneDrive')}/>
|
||||
<WebView
|
||||
source={source}
|
||||
onNavigationStateChange={(o) => { this.webview_load(o); }}
|
||||
onError={(error) => { this.webview_error(error); }}
|
||||
/>
|
||||
<Button title={_("Refresh")} onPress={() => { this.retryButton_click(); }}></Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const OneDriveLoginScreen = connect(
|
||||
(state) => {
|
||||
return {};
|
||||
}
|
||||
)(OneDriveLoginScreenComponent)
|
||||
|
||||
module.exports = { OneDriveLoginScreen };
|
|
@ -1,181 +0,0 @@
|
|||
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');
|
||||
|
||||
class SearchScreenComponent extends BaseScreenComponent {
|
||||
|
||||
static navigationOptions(options) {
|
||||
return { header: null };
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
query: '',
|
||||
notes: [],
|
||||
};
|
||||
this.isMounted_ = false;
|
||||
this.styles_ = {};
|
||||
}
|
||||
|
||||
styles() {
|
||||
const theme = themeStyle(this.props.theme);
|
||||
|
||||
if (this.styles_[this.props.theme]) return this.styles_[this.props.theme];
|
||||
this.styles_ = {};
|
||||
|
||||
let styles = {
|
||||
body: {
|
||||
flex: 1,
|
||||
},
|
||||
searchContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: theme.dividerColor,
|
||||
}
|
||||
}
|
||||
|
||||
styles.searchTextInput = Object.assign({}, theme.lineInput);
|
||||
styles.searchTextInput.paddingLeft = theme.marginLeft;
|
||||
styles.searchTextInput.flex = 1;
|
||||
styles.searchTextInput.backgroundColor = theme.backgroundColor;
|
||||
styles.searchTextInput.color = theme.color;
|
||||
|
||||
styles.clearIcon = Object.assign({}, theme.icon);
|
||||
styles.clearIcon.color = theme.colorFaded;
|
||||
styles.clearIcon.paddingRight = theme.marginRight;
|
||||
styles.clearIcon.backgroundColor = theme.backgroundColor;
|
||||
|
||||
this.styles_[this.props.theme] = StyleSheet.create(styles);
|
||||
return this.styles_[this.props.theme];
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setState({ query: this.props.query });
|
||||
this.refreshSearch(this.props.query);
|
||||
this.isMounted_ = true;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.isMounted_ = false;
|
||||
}
|
||||
|
||||
componentWillReceiveProps(newProps) {
|
||||
let newState = {};
|
||||
if ('query' in newProps) newState.query = newProps.query;
|
||||
|
||||
if (Object.getOwnPropertyNames(newState).length) {
|
||||
this.setState(newState);
|
||||
this.refreshSearch(newState.query);
|
||||
}
|
||||
}
|
||||
|
||||
searchTextInput_submit() {
|
||||
const query = this.state.query.trim();
|
||||
if (!query) return;
|
||||
|
||||
this.props.dispatch({
|
||||
type: 'SEARCH_QUERY',
|
||||
query: query,
|
||||
});
|
||||
}
|
||||
|
||||
clearButton_press() {
|
||||
this.props.dispatch({
|
||||
type: 'SEARCH_QUERY',
|
||||
query: '',
|
||||
});
|
||||
}
|
||||
|
||||
async refreshSearch(query = null) {
|
||||
if (!this.props.visible) return;
|
||||
|
||||
query = query === null ? this.state.query.trim : query.trim();
|
||||
|
||||
let notes = []
|
||||
|
||||
if (query) {
|
||||
let p = query.split(' ');
|
||||
let temp = [];
|
||||
for (let i = 0; i < p.length; i++) {
|
||||
let t = p[i].trim();
|
||||
if (!t) continue;
|
||||
temp.push(t);
|
||||
}
|
||||
|
||||
notes = await Note.previews(null, {
|
||||
anywherePattern: '*' + temp.join('*') + '*',
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.isMounted_) return;
|
||||
|
||||
this.setState({ notes: notes });
|
||||
}
|
||||
|
||||
searchTextInput_changeText(text) {
|
||||
this.setState({ query: text });
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.isMounted_) return null;
|
||||
|
||||
const theme = themeStyle(this.props.theme);
|
||||
|
||||
let rootStyle = {
|
||||
flex: 1,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
}
|
||||
|
||||
if (!this.props.visible) {
|
||||
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
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={rootStyle}>
|
||||
<ScreenHeader title={_('Search')}/>
|
||||
<View style={this.styles().body}>
|
||||
<View style={this.styles().searchContainer}>
|
||||
<TextInput
|
||||
style={this.styles().searchTextInput}
|
||||
autoFocus={this.props.visible}
|
||||
underlineColorAndroid="#ffffff00"
|
||||
onSubmitEditing={() => { this.searchTextInput_submit() }}
|
||||
onChangeText={(text) => this.searchTextInput_changeText(text) }
|
||||
value={this.state.query}
|
||||
/>
|
||||
<TouchableHighlight onPress={() => this.clearButton_press() }>
|
||||
<Icon name='md-close-circle' style={this.styles().clearIcon} />
|
||||
</TouchableHighlight>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={this.state.notes}
|
||||
keyExtractor={(item, index) => item.id}
|
||||
renderItem={(event) => <NoteItem note={event.item}/>}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const SearchScreen = connect(
|
||||
(state) => {
|
||||
return {
|
||||
query: state.searchQuery,
|
||||
theme: state.settings.theme,
|
||||
};
|
||||
}
|
||||
)(SearchScreenComponent)
|
||||
|
||||
module.exports = { SearchScreen };
|
|
@ -1,122 +0,0 @@
|
|||
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: {
|
||||
flex: 1,
|
||||
margin: globalStyle.margin,
|
||||
},
|
||||
});
|
||||
|
||||
class StatusScreenComponent extends BaseScreenComponent {
|
||||
|
||||
static navigationOptions(options) {
|
||||
return { header: null };
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
report: [],
|
||||
};
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.resfreshScreen();
|
||||
}
|
||||
|
||||
async resfreshScreen() {
|
||||
let service = new ReportService();
|
||||
let report = await service.status(Setting.value('sync.target'));
|
||||
this.setState({ report: report });
|
||||
}
|
||||
|
||||
render() {
|
||||
const theme = themeStyle(this.props.theme);
|
||||
|
||||
function renderBody(report) {
|
||||
let output = [];
|
||||
let baseStyle = {
|
||||
paddingLeft: 6,
|
||||
paddingRight: 6,
|
||||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
flex: 0,
|
||||
color: theme.color,
|
||||
fontSize: theme.fontSize,
|
||||
};
|
||||
|
||||
let lines = [];
|
||||
|
||||
for (let i = 0; i < report.length; i++) {
|
||||
let section = report[i];
|
||||
|
||||
let style = Object.assign({}, baseStyle);
|
||||
style.fontWeight = 'bold';
|
||||
if (i > 0) style.paddingTop = 20;
|
||||
lines.push({ key: 'section_' + i, isSection: true, text: section.title });
|
||||
|
||||
for (let n in section.body) {
|
||||
if (!section.body.hasOwnProperty(n)) continue;
|
||||
style = Object.assign({}, baseStyle);
|
||||
lines.push({ key: 'item_' + i + '_' + n, text: section.body[n] });
|
||||
}
|
||||
|
||||
lines.push({ key: 'divider2_' + i, isDivider: true });
|
||||
}
|
||||
|
||||
return (<FlatList
|
||||
data={lines}
|
||||
renderItem={({item}) => {
|
||||
let style = Object.assign({}, baseStyle);
|
||||
if (item.isSection === true) {
|
||||
style.fontWeight = 'bold';
|
||||
style.marginBottom = 5;
|
||||
}
|
||||
if (item.isDivider) {
|
||||
return (<View style={{borderBottomWidth: 1, borderBottomColor: 'white', marginTop: 20, marginBottom: 20}}/>);
|
||||
} else {
|
||||
return (<Text style={style}>{item.text}</Text>);
|
||||
}
|
||||
}}
|
||||
/>);
|
||||
}
|
||||
|
||||
let body = renderBody(this.state.report);
|
||||
|
||||
return (
|
||||
<View style={this.rootStyle(this.props.theme).root}>
|
||||
<ScreenHeader title={_('Status')}/>
|
||||
<View style={styles.body}>
|
||||
{ body }
|
||||
</View>
|
||||
<Button title={_("Refresh")} onPress={() => this.resfreshScreen()}/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const StatusScreen = connect(
|
||||
(state) => {
|
||||
return {
|
||||
theme: state.settings.theme,
|
||||
};
|
||||
}
|
||||
)(StatusScreenComponent)
|
||||
|
||||
module.exports = { StatusScreen };
|
|
@ -1,76 +0,0 @@
|
|||
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: {
|
||||
flex: 1,
|
||||
},
|
||||
}
|
||||
|
||||
class TagScreenComponent extends BaseScreenComponent {
|
||||
|
||||
static navigationOptions(options) {
|
||||
return { header: null };
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.refreshNotes();
|
||||
}
|
||||
|
||||
componentWillReceiveProps(newProps) {
|
||||
if (newProps.selectedTagId !== this.props.selectedTagId) {
|
||||
this.refreshNotes(newProps);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshNotes(props = null) {
|
||||
if (props === null) props = this.props;
|
||||
|
||||
const source = JSON.stringify({ selectedTagId: props.selectedTagId });
|
||||
if (source == props.tagNotesSource) return;
|
||||
|
||||
const notes = await Tag.notes(props.selectedTagId);
|
||||
|
||||
this.props.dispatch({
|
||||
type: 'NOTE_UPDATE_ALL',
|
||||
notes: notes,
|
||||
notesSource: source,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
let title = tag ? tag.title : '';
|
||||
|
||||
// <ActionButton addFolderNoteButtons={true} parentFolderId={this.props.selectedFolderId}></ActionButton>
|
||||
|
||||
const { navigate } = this.props.navigation;
|
||||
return (
|
||||
<View style={this.styles().screen}>
|
||||
<ScreenHeader title={title} menuOptions={this.menuOptions()} />
|
||||
<NoteList style={{flex: 1}}/>
|
||||
<DialogBox ref={dialogbox => { this.dialogbox = dialogbox }}/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const TagScreen = connect(
|
||||
(state) => {
|
||||
return {
|
||||
tag: tag,
|
||||
notes: state.notes,
|
||||
notesSource: state.notesSource,
|
||||
};
|
||||
}
|
||||
)(TagScreenComponent)
|
||||
|
||||
module.exports = { TagScreen };
|
|
@ -1,64 +0,0 @@
|
|||
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 {
|
||||
|
||||
static navigationOptions(options) {
|
||||
return { header: null };
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.styles_ = {};
|
||||
}
|
||||
|
||||
styles() {
|
||||
const themeId = this.props.theme;
|
||||
const theme = themeStyle(themeId);
|
||||
|
||||
if (this.styles_[themeId]) return this.styles_[themeId];
|
||||
this.styles_ = {};
|
||||
|
||||
let styles = {
|
||||
message: {
|
||||
margin: theme.margin,
|
||||
fontSize: theme.fontSize,
|
||||
color: theme.color,
|
||||
},
|
||||
};
|
||||
|
||||
this.styles_[themeId] = StyleSheet.create(styles);
|
||||
return this.styles_[themeId];
|
||||
}
|
||||
|
||||
render() {
|
||||
let message = this.props.folders.length ? _('Click on the (+) button to create a new note or notebook. Click on the side menu to access your existing notebooks.') : _('You currently have no notebook. Create one by clicking on (+) button.');
|
||||
|
||||
return (
|
||||
<View style={this.rootStyle(this.props.theme).root} >
|
||||
<ScreenHeader title={_('Welcome')}/>
|
||||
<Text style={this.styles().message}>{message}</Text>
|
||||
<ActionButton addFolderNoteButtons={true}/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const WelcomeScreen = connect(
|
||||
(state) => {
|
||||
return {
|
||||
folders: state.folders,
|
||||
theme: state.settings.theme,
|
||||
};
|
||||
}
|
||||
)(WelcomeScreenComponent)
|
||||
|
||||
module.exports = { WelcomeScreen };
|
|
@ -1,125 +0,0 @@
|
|||
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;
|
|
@ -1,55 +0,0 @@
|
|||
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.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 (Setting.value('sync.target') == Setting.SYNC_TARGET_ONEDRIVE && !reg.oneDriveApi().auth()) {
|
||||
comp.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'OneDriveLogin',
|
||||
});
|
||||
return 'auth';
|
||||
}
|
||||
|
||||
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 'error';
|
||||
}
|
||||
|
||||
if (action == 'cancel') {
|
||||
sync.cancel();
|
||||
return 'cancel';
|
||||
} else {
|
||||
reg.scheduleSync(0);
|
||||
return 'sync';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = shared;
|
|
@ -1,234 +0,0 @@
|
|||
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 {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.state = { syncReportText: '',
|
||||
//width: 0,
|
||||
};
|
||||
this.styles_ = {};
|
||||
}
|
||||
|
||||
styles() {
|
||||
const theme = themeStyle(this.props.theme);
|
||||
|
||||
if (this.styles_[this.props.theme]) return this.styles_[this.props.theme];
|
||||
this.styles_ = {};
|
||||
|
||||
let styles = {
|
||||
menu: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.dividerColor,
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
paddingLeft: theme.marginLeft,
|
||||
paddingRight: theme.marginRight,
|
||||
},
|
||||
buttonText: {
|
||||
flex: 1,
|
||||
color: theme.color,
|
||||
paddingLeft: 10,
|
||||
fontSize: theme.fontSize,
|
||||
},
|
||||
syncStatus: {
|
||||
paddingLeft: theme.marginLeft,
|
||||
paddingRight: theme.marginRight,
|
||||
color: theme.colorFaded,
|
||||
fontSize: theme.fontSizeSmaller,
|
||||
},
|
||||
tagItemList: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap'
|
||||
},
|
||||
};
|
||||
|
||||
styles.folderButton = Object.assign({}, styles.button);
|
||||
styles.folderButtonText = Object.assign({}, styles.buttonText);
|
||||
styles.folderButtonSelected = Object.assign({}, styles.folderButton);
|
||||
styles.folderButtonSelected.backgroundColor = theme.selectedColor;
|
||||
styles.folderIcon = Object.assign({}, theme.icon);
|
||||
styles.folderIcon.color = '#0072d5';
|
||||
|
||||
styles.tagButton = Object.assign({}, styles.button);
|
||||
styles.tagButtonSelected = Object.assign({}, styles.tagButton);
|
||||
styles.tagButtonSelected.backgroundColor = theme.selectedColor;
|
||||
styles.tagButtonSelected.borderRadius = 1000;
|
||||
styles.tagButtonText = Object.assign({}, styles.buttonText);
|
||||
styles.tagButtonText.flex = 0;
|
||||
|
||||
styles.syncButton = Object.assign({}, styles.button);
|
||||
styles.syncButtonText = Object.assign({}, styles.buttonText);
|
||||
|
||||
this.styles_[this.props.theme] = StyleSheet.create(styles);
|
||||
return this.styles_[this.props.theme];
|
||||
}
|
||||
|
||||
folder_press(folder) {
|
||||
this.props.dispatch({ type: 'SIDE_MENU_CLOSE' });
|
||||
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Notes',
|
||||
folderId: folder.id,
|
||||
});
|
||||
}
|
||||
|
||||
tag_press(tag) {
|
||||
this.props.dispatch({ type: 'SIDE_MENU_CLOSE' });
|
||||
|
||||
this.props.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Notes',
|
||||
tagId: tag.id,
|
||||
});
|
||||
}
|
||||
|
||||
async synchronize_press() {
|
||||
const actionDone = await shared.synchronize_press(this);
|
||||
if (actionDone === 'auth') this.props.dispatch({ type: 'SIDE_MENU_CLOSE' });
|
||||
}
|
||||
|
||||
folderItem(folder, selected) {
|
||||
const iconComp = selected ? <Icon name='md-folder-open' style={this.styles().folderIcon} /> : <Icon name='md-folder' style={this.styles().folderIcon} />;
|
||||
const folderButtonStyle = selected ? this.styles().folderButtonSelected : this.styles().folderButton;
|
||||
|
||||
return (
|
||||
<TouchableOpacity key={folder.id} onPress={() => { this.folder_press(folder) }}>
|
||||
<View style={folderButtonStyle}>
|
||||
{ iconComp }
|
||||
<Text numberOfLines={1} style={this.styles().folderButtonText}>{folder.title}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
tagItem(tag, selected) {
|
||||
const iconComp = <Icon name='md-pricetag' style={this.styles().folderIcon} />
|
||||
const tagButtonStyle = selected ? this.styles().tagButtonSelected : this.styles().tagButton;
|
||||
|
||||
return (
|
||||
<TouchableOpacity key={tag.id} onPress={() => { this.tag_press(tag) }}>
|
||||
<View style={tagButtonStyle}>
|
||||
{ iconComp }
|
||||
<Text numberOfLines={1} style={this.styles().tagButtonText}>{tag.title}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
synchronizeButton(state) {
|
||||
const title = state == 'sync' ? _('Synchronise') : _('Cancel synchronisation');
|
||||
const iconComp = state == 'sync' ? <Icon name='md-sync' style={globalStyle.icon} /> : <Icon name='md-close' style={globalStyle.icon} />;
|
||||
|
||||
return (
|
||||
<TouchableOpacity key={'synchronize_button'} onPress={() => { this.synchronize_press() }}>
|
||||
<View style={this.styles().syncButton}>
|
||||
{ iconComp }
|
||||
<Text style={this.styles().syncButtonText}>{title}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
makeDivider(key) {
|
||||
return <View style={{ marginTop: 15, marginBottom: 15, flex: -1, borderBottomWidth: 1, borderBottomColor: globalStyle.dividerColor }} key={key}></View>
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
const tagItems = shared.renderTags(this.props, this.tagItem.bind(this));
|
||||
|
||||
items.push(
|
||||
<View style={this.styles().tagItemList} key="tag_items">
|
||||
{tagItems}
|
||||
</View>
|
||||
);
|
||||
|
||||
if (tagItems.length) items.push(this.makeDivider('divider_2'));
|
||||
}
|
||||
|
||||
let lines = Synchronizer.reportToLines(this.props.syncReport);
|
||||
while (lines.length < 10) lines.push(''); // Add blank lines so that height of report text is fixed and doesn't affect scrolling
|
||||
const syncReportText = lines.join("\n");
|
||||
|
||||
items.push(this.synchronizeButton(this.props.syncStarted ? 'cancel' : 'sync'));
|
||||
|
||||
items.push(<Text key='sync_report' style={this.styles().syncStatus}>{syncReportText}</Text>);
|
||||
|
||||
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={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>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const SideMenuContent = connect(
|
||||
(state) => {
|
||||
return {
|
||||
folders: state.folders,
|
||||
tags: state.tags,
|
||||
syncStarted: state.syncStarted,
|
||||
syncReport: state.syncReport,
|
||||
selectedFolderId: state.selectedFolderId,
|
||||
selectedTagId: state.selectedTagId,
|
||||
notesParentType: state.notesParentType,
|
||||
locale: state.settings.locale,
|
||||
theme: state.settings.theme,
|
||||
opacity: state.sideMenuOpenPercent,
|
||||
};
|
||||
}
|
||||
)(SideMenuContentComponent)
|
||||
|
||||
module.exports = { SideMenuContent };
|
|
@ -1,16 +0,0 @@
|
|||
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 MySideMenu = connect(
|
||||
(state) => {
|
||||
return {
|
||||
isOpen: state.showSideMenu,
|
||||
};
|
||||
}
|
||||
)(SideMenuComponent)
|
||||
|
||||
module.exports = { SideMenu: MySideMenu };
|
|
@ -1,72 +0,0 @@
|
|||
const sqlite3 = require('sqlite3').verbose();
|
||||
const Promise = require('promise');
|
||||
|
||||
class DatabaseDriverNode {
|
||||
|
||||
open(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db_ = new sqlite3.Database(options.name, sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE, (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
sqliteErrorToJsError(error, sql = null, params = null) {
|
||||
let msg = [error.toString()];
|
||||
if (sql) msg.push(sql);
|
||||
if (params) msg.push(params);
|
||||
let output = new Error(msg.join(': '));
|
||||
if (error.code) output.code = error.code;
|
||||
return output;
|
||||
}
|
||||
|
||||
setDebugMode(v) {
|
||||
// ??
|
||||
}
|
||||
|
||||
selectOne(sql, params = null) {
|
||||
if (!params) params = {};
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db_.get(sql, params, (error, row) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(row);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
selectAll(sql, params = null) {
|
||||
if (!params) params = {};
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db_.all(sql, params, (error, row) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(row);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
exec(sql, params = null) {
|
||||
if (!params) params = {};
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db_.run(sql, params, (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { DatabaseDriverNode };
|
|
@ -1,57 +0,0 @@
|
|||
const SQLite = require('react-native-sqlite-storage');
|
||||
|
||||
class DatabaseDriverReactNative {
|
||||
|
||||
open(options) {
|
||||
//SQLite.DEBUG(true);
|
||||
return new Promise((resolve, reject) => {
|
||||
SQLite.openDatabase({ name: options.name }, (db) => {
|
||||
this.db_ = db;
|
||||
resolve();
|
||||
}, (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
sqliteErrorToJsError(error, sql = null, params = null) {
|
||||
return error;
|
||||
}
|
||||
|
||||
setDebugMode(v) {
|
||||
//SQLite.DEBUG(v);
|
||||
}
|
||||
|
||||
selectOne(sql, params = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db_.executeSql(sql, params, (r) => {
|
||||
resolve(r.rows.length ? r.rows.item(0) : null);
|
||||
}, (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
selectAll(sql, params = null) {
|
||||
return this.exec(sql, params).then((r) => {
|
||||
let output = []
|
||||
for (let i = 0; i < r.rows.length; i++) {
|
||||
output.push(r.rows.item(i));
|
||||
}
|
||||
return output;
|
||||
});
|
||||
}
|
||||
|
||||
exec(sql, params = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db_.executeSql(sql, params, (r) => {
|
||||
resolve(r);
|
||||
}, (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { DatabaseDriverReactNative };
|
|
@ -1,313 +0,0 @@
|
|||
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 {
|
||||
|
||||
constructor(driver) {
|
||||
this.debugMode_ = false;
|
||||
this.driver_ = driver;
|
||||
this.inTransaction_ = false;
|
||||
|
||||
this.logger_ = new Logger();
|
||||
this.logExcludedQueryTypes_ = [];
|
||||
}
|
||||
|
||||
setLogExcludedQueryTypes(v) {
|
||||
this.logExcludedQueryTypes_ = v;
|
||||
}
|
||||
|
||||
// Converts the SQLite error to a regular JS error
|
||||
// so that it prints a stacktrace when passed to
|
||||
// console.error()
|
||||
sqliteErrorToJsError(error, sql = null, params = null) {
|
||||
return this.driver().sqliteErrorToJsError(error, sql, params);
|
||||
}
|
||||
|
||||
setLogger(l) {
|
||||
this.logger_ = l;
|
||||
}
|
||||
|
||||
logger() {
|
||||
return this.logger_;
|
||||
}
|
||||
|
||||
driver() {
|
||||
return this.driver_;
|
||||
}
|
||||
|
||||
async open(options) {
|
||||
await this.driver().open(options);
|
||||
this.logger().info('Database was open successfully');
|
||||
}
|
||||
|
||||
escapeField(field) {
|
||||
if (field == '*') return '*';
|
||||
let p = field.split('.');
|
||||
if (p.length == 1) return '`' + field + '`';
|
||||
if (p.length == 2) return p[0] + '.`' + p[1] + '`';
|
||||
|
||||
throw new Error('Invalid field format: ' + field);
|
||||
}
|
||||
|
||||
escapeFields(fields) {
|
||||
if (fields == '*') return '*';
|
||||
|
||||
let output = [];
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
output.push(this.escapeField(fields[i]));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async tryCall(callName, sql, params) {
|
||||
if (typeof sql === 'object') {
|
||||
params = sql.params;
|
||||
sql = sql.sql;
|
||||
}
|
||||
|
||||
let waitTime = 50;
|
||||
let totalWaitTime = 0;
|
||||
while (true) {
|
||||
try {
|
||||
this.logQuery(sql, params);
|
||||
let result = await this.driver()[callName](sql, params);
|
||||
return result; // No exception was thrown
|
||||
} catch (error) {
|
||||
if (error && (error.code == 'SQLITE_IOERR' || error.code == 'SQLITE_BUSY')) {
|
||||
if (totalWaitTime >= 20000) throw this.sqliteErrorToJsError(error, sql, params);
|
||||
this.logger().warn(sprintf('Error %s: will retry in %s milliseconds', error.code, waitTime));
|
||||
this.logger().warn('Error was: ' + error.toString());
|
||||
await time.msleep(waitTime);
|
||||
totalWaitTime += waitTime;
|
||||
waitTime *= 1.5;
|
||||
} else {
|
||||
throw this.sqliteErrorToJsError(error, sql, params);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async selectOne(sql, params = null) {
|
||||
return this.tryCall('selectOne', sql, params);
|
||||
}
|
||||
|
||||
async selectAll(sql, params = null) {
|
||||
return this.tryCall('selectAll', sql, params);
|
||||
}
|
||||
|
||||
async exec(sql, params = null) {
|
||||
return this.tryCall('exec', sql, params);
|
||||
}
|
||||
|
||||
transactionExecBatch(queries) {
|
||||
if (queries.length <= 0) return Promise.resolve();
|
||||
|
||||
if (queries.length == 1) {
|
||||
let q = this.wrapQuery(queries[0]);
|
||||
return this.exec(q.sql, q.params);
|
||||
}
|
||||
|
||||
// There can be only one transaction running at a time so queue
|
||||
// any new transaction here.
|
||||
if (this.inTransaction_) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let iid = setInterval(() => {
|
||||
if (!this.inTransaction_) {
|
||||
clearInterval(iid);
|
||||
this.transactionExecBatch(queries).then(() => {
|
||||
resolve();
|
||||
}).catch((error) => {
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
this.inTransaction_ = true;
|
||||
|
||||
queries.splice(0, 0, 'BEGIN TRANSACTION');
|
||||
queries.push('COMMIT'); // Note: ROLLBACK is currently not supported
|
||||
|
||||
let chain = [];
|
||||
for (let i = 0; i < queries.length; i++) {
|
||||
let query = this.wrapQuery(queries[i]);
|
||||
chain.push(() => {
|
||||
return this.exec(query.sql, query.params);
|
||||
});
|
||||
}
|
||||
|
||||
return promiseChain(chain).then(() => {
|
||||
this.inTransaction_ = false;
|
||||
});
|
||||
}
|
||||
|
||||
static enumId(type, s) {
|
||||
if (type == 'settings') {
|
||||
if (s == 'int') return 1;
|
||||
if (s == 'string') return 2;
|
||||
}
|
||||
if (type == 'fieldType') {
|
||||
if (s == 'INTEGER') s = 'INT';
|
||||
if (!(('TYPE_' + s) in this)) throw new Error('Unkonwn fieldType: ' + s);
|
||||
return this['TYPE_' + s];
|
||||
}
|
||||
if (type == 'syncTarget') {
|
||||
if (s == 'memory') return 1;
|
||||
if (s == 'filesystem') return 2;
|
||||
if (s == 'onedrive') return 3;
|
||||
}
|
||||
throw new Error('Unknown enum type or value: ' + type + ', ' + s);
|
||||
}
|
||||
|
||||
static formatValue(type, value) {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (type == this.TYPE_INT) return Number(value);
|
||||
if (type == this.TYPE_TEXT) return value;
|
||||
if (type == this.TYPE_NUMERIC) return Number(value);
|
||||
throw new Error('Unknown type: ' + type);
|
||||
}
|
||||
|
||||
sqlStringToLines(sql) {
|
||||
let output = [];
|
||||
let lines = sql.split("\n");
|
||||
let statement = '';
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i];
|
||||
if (line == '') continue;
|
||||
if (line.substr(0, 2) == "--") continue;
|
||||
statement += line.trim();
|
||||
if (line[line.length - 1] == ',') statement += ' ';
|
||||
if (line[line.length - 1] == ';') {
|
||||
output.push(statement);
|
||||
statement = '';
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
static insertQuery(tableName, data) {
|
||||
if (!data || !Object.keys(data).length) throw new Error('Data is empty');
|
||||
|
||||
let keySql= '';
|
||||
let valueSql = '';
|
||||
let params = [];
|
||||
for (let key in data) {
|
||||
if (!data.hasOwnProperty(key)) continue;
|
||||
if (key[key.length - 1] == '_') continue;
|
||||
if (keySql != '') keySql += ', ';
|
||||
if (valueSql != '') valueSql += ', ';
|
||||
keySql += '`' + key + '`';
|
||||
valueSql += '?';
|
||||
params.push(data[key]);
|
||||
}
|
||||
return {
|
||||
sql: 'INSERT INTO `' + tableName + '` (' + keySql + ') VALUES (' + valueSql + ')',
|
||||
params: params,
|
||||
};
|
||||
}
|
||||
|
||||
static updateQuery(tableName, data, where) {
|
||||
if (!data || !Object.keys(data).length) throw new Error('Data is empty');
|
||||
|
||||
let sql = '';
|
||||
let params = [];
|
||||
for (let key in data) {
|
||||
if (!data.hasOwnProperty(key)) continue;
|
||||
if (key[key.length - 1] == '_') continue;
|
||||
if (sql != '') sql += ', ';
|
||||
sql += '`' + key + '`=?';
|
||||
params.push(data[key]);
|
||||
}
|
||||
|
||||
if (typeof where != 'string') {
|
||||
let s = [];
|
||||
for (let n in where) {
|
||||
if (!where.hasOwnProperty(n)) continue;
|
||||
params.push(where[n]);
|
||||
s.push('`' + n + '`=?');
|
||||
}
|
||||
where = s.join(' AND ');
|
||||
}
|
||||
|
||||
return {
|
||||
sql: 'UPDATE `' + tableName + '` SET ' + sql + ' WHERE ' + where,
|
||||
params: params,
|
||||
};
|
||||
}
|
||||
|
||||
alterColumnQueries(tableName, fields) {
|
||||
let fieldsNoType = [];
|
||||
for (let n in fields) {
|
||||
if (!fields.hasOwnProperty(n)) continue;
|
||||
fieldsNoType.push(n);
|
||||
}
|
||||
|
||||
let fieldsWithType = [];
|
||||
for (let n in fields) {
|
||||
if (!fields.hasOwnProperty(n)) continue;
|
||||
fieldsWithType.push(this.escapeField(n) + ' ' + fields[n]);
|
||||
}
|
||||
|
||||
let sql = `
|
||||
CREATE TEMPORARY TABLE _BACKUP_TABLE_NAME_(_FIELDS_TYPE_);
|
||||
INSERT INTO _BACKUP_TABLE_NAME_ SELECT _FIELDS_NO_TYPE_ FROM _TABLE_NAME_;
|
||||
DROP TABLE _TABLE_NAME_;
|
||||
CREATE TABLE _TABLE_NAME_(_FIELDS_TYPE_);
|
||||
INSERT INTO _TABLE_NAME_ SELECT _FIELDS_NO_TYPE_ FROM _BACKUP_TABLE_NAME_;
|
||||
DROP TABLE _BACKUP_TABLE_NAME_;
|
||||
`;
|
||||
|
||||
sql = sql.replace(/_BACKUP_TABLE_NAME_/g, this.escapeField(tableName + '_backup'));
|
||||
sql = sql.replace(/_TABLE_NAME_/g, this.escapeField(tableName));
|
||||
sql = sql.replace(/_FIELDS_NO_TYPE_/g, this.escapeFields(fieldsNoType).join(','));
|
||||
sql = sql.replace(/_FIELDS_TYPE_/g, fieldsWithType.join(','));
|
||||
|
||||
return sql.trim().split("\n");
|
||||
}
|
||||
|
||||
wrapQueries(queries) {
|
||||
let output = [];
|
||||
for (let i = 0; i < queries.length; i++) {
|
||||
output.push(this.wrapQuery(queries[i]));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
wrapQuery(sql, params = null) {
|
||||
if (!sql) throw new Error('Cannot wrap empty string: ' + sql);
|
||||
|
||||
if (sql.constructor === Array) {
|
||||
let output = {};
|
||||
output.sql = sql[0];
|
||||
output.params = sql.length >= 2 ? sql[1] : null;
|
||||
return output;
|
||||
} else if (typeof sql === 'string') {
|
||||
return { sql: sql, params: params };
|
||||
} else {
|
||||
return sql; // Already wrapped
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Database.TYPE_INT = 1;
|
||||
Database.TYPE_TEXT = 2;
|
||||
Database.TYPE_NUMERIC = 3;
|
||||
|
||||
module.exports = { Database };
|
|
@ -1,66 +0,0 @@
|
|||
const DialogBox = require('react-native-dialogbox').default;
|
||||
const { Keyboard } = require('react-native');
|
||||
|
||||
// Add this at the bottom of the component:
|
||||
//
|
||||
// <DialogBox ref={dialogbox => { this.dialogbox = dialogbox }}/>
|
||||
|
||||
let dialogs = {};
|
||||
|
||||
dialogs.confirm = (parentComponent, message) => {
|
||||
if (!'dialogbox' in parentComponent) throw new Error('A "dialogbox" component must be defined on the parent component!');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
Keyboard.dismiss();
|
||||
|
||||
parentComponent.dialogbox.confirm({
|
||||
content: message,
|
||||
|
||||
ok: {
|
||||
callback: () => {
|
||||
resolve(true);
|
||||
}
|
||||
},
|
||||
|
||||
cancel: {
|
||||
callback: () => {
|
||||
resolve(false);
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
dialogs.pop = (parentComponent, message, buttons) => {
|
||||
if (!'dialogbox' in parentComponent) throw new Error('A "dialogbox" component must be defined on the parent component!');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
Keyboard.dismiss();
|
||||
|
||||
let btns = [];
|
||||
for (let i = 0; i < buttons.length; i++) {
|
||||
btns.push({
|
||||
text: buttons[i].title,
|
||||
callback: () => {
|
||||
parentComponent.dialogbox.close();
|
||||
resolve(buttons[i].id);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
parentComponent.dialogbox.pop({
|
||||
content: message,
|
||||
btns: btns,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
dialogs.error = (parentComponent, message) => {
|
||||
Keyboard.dismiss();
|
||||
return parentComponent.dialogbox.alert(message);
|
||||
}
|
||||
|
||||
dialogs.DialogBox = DialogBox
|
||||
|
||||
module.exports = { dialogs };
|
|
@ -1,35 +0,0 @@
|
|||
class EventDispatcher {
|
||||
|
||||
constructor() {
|
||||
this.listeners_ = [];
|
||||
}
|
||||
|
||||
dispatch(eventName, event = null) {
|
||||
if (!this.listeners_[eventName]) return;
|
||||
|
||||
let ls = this.listeners_[eventName];
|
||||
for (let i = 0; i < ls.length; i++) {
|
||||
ls[i](event);
|
||||
}
|
||||
}
|
||||
|
||||
on(eventName, callback) {
|
||||
if (!this.listeners_[eventName]) this.listeners_[eventName] = [];
|
||||
this.listeners_[eventName].push(callback);
|
||||
}
|
||||
|
||||
off(eventName, callback) {
|
||||
if (!this.listeners_[eventName]) return;
|
||||
|
||||
let ls = this.listeners_[eventName];
|
||||
for (let i = 0; i < ls.length; i++) {
|
||||
if (ls[i] === callback) {
|
||||
ls.splice(i, 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { EventDispatcher };
|
|
@ -1,238 +0,0 @@
|
|||
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,
|
||||
// both clients will not know about each others updates during the next sync. They will simply both sync their note and whoever
|
||||
// comes last will overwrite (on the remote storage) the note of the other client. Both client will then have a different note at
|
||||
// that point and that will only be resolved if one of them changes the note and sync (if they don't change it, it will never get resolved).
|
||||
//
|
||||
// This is compound with the fact that we can't have a reliable delta API on the file system so we need to check all the timestamps
|
||||
// every time and rely on this exclusively to know about changes.
|
||||
//
|
||||
// This explains occasional failures of the fuzzing program (it finds that the clients end up with two different notes after sync). To
|
||||
// check that it is indeed the problem, check log-database.txt of both clients, search for the note ID, and most likely both notes
|
||||
// will have been modified at the same exact second at some point. If not, it's another bug that needs to be investigated.
|
||||
|
||||
class FileApiDriverLocal {
|
||||
|
||||
fsErrorToJsError_(error) {
|
||||
let msg = error.toString();
|
||||
let output = new Error(msg);
|
||||
if (error.code) output.code = error.code;
|
||||
return output;
|
||||
}
|
||||
|
||||
stat(path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.stat(path, (error, s) => {
|
||||
if (error) {
|
||||
if (error.code == 'ENOENT') {
|
||||
resolve(null);
|
||||
} else {
|
||||
reject(this.fsErrorToJsError_(error));
|
||||
}
|
||||
return;
|
||||
}
|
||||
resolve(this.metadataFromStats_(path, s));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
statTimeToTimestampMs_(time) {
|
||||
let m = moment(time, 'YYYY-MM-DDTHH:mm:ss.SSSZ');
|
||||
if (!m.isValid()) {
|
||||
throw new Error('Invalid date: ' + time);
|
||||
}
|
||||
return m.toDate().getTime();
|
||||
}
|
||||
|
||||
metadataFromStats_(path, stats) {
|
||||
return {
|
||||
path: path,
|
||||
created_time: this.statTimeToTimestampMs_(stats.birthtime),
|
||||
updated_time: this.statTimeToTimestampMs_(stats.mtime),
|
||||
created_time_orig: stats.birthtime,
|
||||
updated_time_orig: stats.mtime,
|
||||
isDir: stats.isDirectory(),
|
||||
};
|
||||
}
|
||||
|
||||
setTimestamp(path, timestampMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let t = Math.floor(timestampMs / 1000);
|
||||
fs.utimes(path, t, t, (error) => {
|
||||
if (error) {
|
||||
reject(this.fsErrorToJsError_(error));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async delta(path, options) {
|
||||
const itemIds = await options.allItemIdsHandler();
|
||||
|
||||
try {
|
||||
let items = await fs.readdir(path);
|
||||
let output = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let stat = await this.stat(path + '/' + items[i]);
|
||||
if (!stat) continue; // Has been deleted between the readdir() call and now
|
||||
stat.path = items[i];
|
||||
output.push(stat);
|
||||
}
|
||||
|
||||
if (!Array.isArray(itemIds)) throw new Error('Delta API not supported - local IDs must be provided');
|
||||
|
||||
let deletedItems = [];
|
||||
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];
|
||||
if (BaseItem.pathToId(item.path) == itemId) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
deletedItems.push({
|
||||
path: BaseItem.systemPath(itemId),
|
||||
isDeleted: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
output = output.concat(deletedItems);
|
||||
|
||||
return {
|
||||
hasMore: false,
|
||||
context: null,
|
||||
items: output,
|
||||
};
|
||||
} catch(error) {
|
||||
throw this.fsErrorToJsError_(error);
|
||||
}
|
||||
}
|
||||
|
||||
async list(path, options) {
|
||||
try {
|
||||
let items = await fs.readdir(path);
|
||||
let output = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
let stat = await this.stat(path + '/' + items[i]);
|
||||
if (!stat) continue; // Has been deleted between the readdir() call and now
|
||||
stat.path = items[i];
|
||||
output.push(stat);
|
||||
}
|
||||
|
||||
return {
|
||||
items: output,
|
||||
hasMore: false,
|
||||
context: null,
|
||||
};
|
||||
} catch(error) {
|
||||
throw this.fsErrorToJsError_(error);
|
||||
}
|
||||
}
|
||||
|
||||
async get(path, options) {
|
||||
let output = null;
|
||||
|
||||
try {
|
||||
if (options.encoding == 'binary') {
|
||||
output = fs.readFile(path);
|
||||
} else {
|
||||
output = fs.readFile(path, options.encoding);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code == 'ENOENT') return null;
|
||||
throw this.fsErrorToJsError_(error);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
mkdir(path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.exists(path, (exists) => {
|
||||
if (exists) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirp(path, (error) => {
|
||||
if (error) {
|
||||
reject(this.fsErrorToJsError_(error));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
put(path, content) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.writeFile(path, content, function(error) {
|
||||
if (error) {
|
||||
reject(this.fsErrorToJsError_(error));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
delete(path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.unlink(path, function(error) {
|
||||
if (error) {
|
||||
if (error && error.code == 'ENOENT') {
|
||||
// File doesn't exist - it's fine
|
||||
resolve();
|
||||
} else {
|
||||
reject(this.fsErrorToJsError_(error));
|
||||
}
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async move(oldPath, newPath) {
|
||||
let lastError = null;
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
try {
|
||||
let output = await fs.move(oldPath, newPath, { overwrite: true });
|
||||
return output;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
// Normally cannot happen with the `overwrite` flag but sometime it still does.
|
||||
// In this case, retry.
|
||||
if (error.code == 'EEXIST') {
|
||||
await time.sleep(1);
|
||||
continue;
|
||||
}
|
||||
throw this.fsErrorToJsError_(error);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
format() {
|
||||
throw new Error('Not supported');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { FileApiDriverLocal };
|
|
@ -1,168 +0,0 @@
|
|||
const { time } = require('lib/time-utils.js');
|
||||
|
||||
class FileApiDriverMemory {
|
||||
|
||||
constructor() {
|
||||
this.items_ = [];
|
||||
this.deletedItems_ = [];
|
||||
}
|
||||
|
||||
itemIndexByPath(path) {
|
||||
for (let i = 0; i < this.items_.length; i++) {
|
||||
if (this.items_[i].path == path) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
itemByPath(path) {
|
||||
let index = this.itemIndexByPath(path);
|
||||
return index < 0 ? null : this.items_[index];
|
||||
}
|
||||
|
||||
newItem(path, isDir = false) {
|
||||
let now = time.unixMs();
|
||||
return {
|
||||
path: path,
|
||||
isDir: isDir,
|
||||
updated_time: now, // In milliseconds!!
|
||||
created_time: now, // In milliseconds!!
|
||||
content: '',
|
||||
};
|
||||
}
|
||||
|
||||
stat(path) {
|
||||
let item = this.itemByPath(path);
|
||||
return Promise.resolve(item ? Object.assign({}, item) : null);
|
||||
}
|
||||
|
||||
setTimestamp(path, timestampMs) {
|
||||
let item = this.itemByPath(path);
|
||||
if (!item) return Promise.reject(new Error('File not found: ' + path));
|
||||
item.updated_time = timestampMs;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
list(path, options) {
|
||||
let output = [];
|
||||
|
||||
for (let i = 0; i < this.items_.length; i++) {
|
||||
let item = this.items_[i];
|
||||
if (item.path == path) continue;
|
||||
if (item.path.indexOf(path + '/') === 0) {
|
||||
let s = item.path.substr(path.length + 1);
|
||||
if (s.split('/').length === 1) {
|
||||
let it = Object.assign({}, item);
|
||||
it.path = it.path.substr(path.length + 1);
|
||||
output.push(it);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
items: output,
|
||||
hasMore: false,
|
||||
context: null,
|
||||
});
|
||||
}
|
||||
|
||||
get(path) {
|
||||
let item = this.itemByPath(path);
|
||||
if (!item) return Promise.resolve(null);
|
||||
if (item.isDir) return Promise.reject(new Error(path + ' is a directory, not a file'));
|
||||
return Promise.resolve(item.content);
|
||||
}
|
||||
|
||||
mkdir(path) {
|
||||
let index = this.itemIndexByPath(path);
|
||||
if (index >= 0) return Promise.resolve();
|
||||
this.items_.push(this.newItem(path, true));
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
put(path, content) {
|
||||
let index = this.itemIndexByPath(path);
|
||||
if (index < 0) {
|
||||
let item = this.newItem(path, false);
|
||||
item.content = content;
|
||||
this.items_.push(item);
|
||||
} else {
|
||||
this.items_[index].content = content;
|
||||
this.items_[index].updated_time = time.unix();
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
delete(path) {
|
||||
let index = this.itemIndexByPath(path);
|
||||
if (index >= 0) {
|
||||
let item = Object.assign({}, this.items_[index]);
|
||||
item.isDeleted = true;
|
||||
item.updated_time = time.unixMs();
|
||||
this.deletedItems_.push(item);
|
||||
this.items_.splice(index, 1);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
move(oldPath, newPath) {
|
||||
let sourceItem = this.itemByPath(oldPath);
|
||||
if (!sourceItem) return Promise.reject(new Error('Path not found: ' + oldPath));
|
||||
this.delete(newPath); // Overwrite if newPath already exists
|
||||
sourceItem.path = newPath;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
format() {
|
||||
this.items_ = [];
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async delta(path, options = null) {
|
||||
let limit = 3;
|
||||
|
||||
let output = {
|
||||
hasMore: false,
|
||||
context: {},
|
||||
items: [],
|
||||
};
|
||||
|
||||
let context = options ? options.context : null;
|
||||
let fromTime = 0;
|
||||
|
||||
if (context) fromTime = context.fromTime;
|
||||
|
||||
let sortedItems = this.items_.slice().concat(this.deletedItems_);
|
||||
sortedItems.sort((a, b) => {
|
||||
if (a.updated_time < b.updated_time) return -1;
|
||||
if (a.updated_time > b.updated_time) return +1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
let hasMore = false;
|
||||
let items = [];
|
||||
let maxTime = 0;
|
||||
for (let i = 0; i < sortedItems.length; i++) {
|
||||
let item = sortedItems[i];
|
||||
if (item.updated_time >= fromTime) {
|
||||
item = Object.assign({}, item);
|
||||
item.path = item.path.substr(path.length + 1);
|
||||
items.push(item);
|
||||
if (item.updated_time > maxTime) maxTime = item.updated_time;
|
||||
}
|
||||
|
||||
if (items.length >= limit) {
|
||||
hasMore = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
output.items = items;
|
||||
output.hasMore = hasMore;
|
||||
output.context = { fromTime: maxTime };
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { FileApiDriverMemory };
|
|
@ -1,277 +0,0 @@
|
|||
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 {
|
||||
|
||||
constructor(api) {
|
||||
this.api_ = api;
|
||||
this.pathCache_ = {};
|
||||
}
|
||||
|
||||
api() {
|
||||
return this.api_;
|
||||
}
|
||||
|
||||
itemFilter_() {
|
||||
return {
|
||||
select: 'name,file,folder,fileSystemInfo,parentReference',
|
||||
}
|
||||
}
|
||||
|
||||
makePath_(path) {
|
||||
return path;
|
||||
}
|
||||
|
||||
makeItems_(odItems) {
|
||||
let output = [];
|
||||
for (let i = 0; i < odItems.length; i++) {
|
||||
output.push(this.makeItem_(odItems[i]));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
makeItem_(odItem) {
|
||||
let output = {
|
||||
path: odItem.name,
|
||||
isDir: ('folder' in odItem),
|
||||
};
|
||||
|
||||
if ('deleted' in odItem) {
|
||||
output.isDeleted = true;
|
||||
} else {
|
||||
output.created_time = moment(odItem.fileSystemInfo.createdDateTime, 'YYYY-MM-DDTHH:mm:ss.SSSZ').format('x');
|
||||
output.updated_time = moment(odItem.fileSystemInfo.lastModifiedDateTime, 'YYYY-MM-DDTHH:mm:ss.SSSZ').format('x');
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
async statRaw_(path) {
|
||||
let item = null;
|
||||
try {
|
||||
item = await this.api_.execJson('GET', this.makePath_(path), this.itemFilter_());
|
||||
} catch (error) {
|
||||
if (error.code == 'itemNotFound') return null;
|
||||
throw error;
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
async stat(path) {
|
||||
let item = await this.statRaw_(path);
|
||||
if (!item) return null;
|
||||
return this.makeItem_(item);
|
||||
}
|
||||
|
||||
async setTimestamp(path, timestamp) {
|
||||
let body = {
|
||||
fileSystemInfo: {
|
||||
lastModifiedDateTime: moment.unix(timestamp / 1000).utc().format('YYYY-MM-DDTHH:mm:ss.SSS') + 'Z',
|
||||
}
|
||||
};
|
||||
let item = await this.api_.execJson('PATCH', this.makePath_(path), null, body);
|
||||
return this.makeItem_(item);
|
||||
}
|
||||
|
||||
async list(path, options = null) {
|
||||
let query = this.itemFilter_();
|
||||
let url = this.makePath_(path) + ':/children';
|
||||
|
||||
if (options.context) {
|
||||
query = null;
|
||||
url = options.context;
|
||||
}
|
||||
|
||||
let r = await this.api_.execJson('GET', url, query);
|
||||
|
||||
return {
|
||||
hasMore: !!r['@odata.nextLink'],
|
||||
items: this.makeItems_(r.value),
|
||||
context: r["@odata.nextLink"],
|
||||
}
|
||||
}
|
||||
|
||||
async get(path, options = null) {
|
||||
if (!options) options = {};
|
||||
|
||||
try {
|
||||
if (options.target == 'file') {
|
||||
let response = await this.api_.exec('GET', this.makePath_(path) + ':/content', null, null, options);
|
||||
return response;
|
||||
} else {
|
||||
let content = await this.api_.execText('GET', this.makePath_(path) + ':/content');
|
||||
return content;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code == 'itemNotFound') return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async mkdir(path) {
|
||||
let item = await this.stat(path);
|
||||
if (item) return item;
|
||||
|
||||
let parentPath = dirname(path);
|
||||
item = await this.api_.execJson('POST', this.makePath_(parentPath) + ':/children', this.itemFilter_(), {
|
||||
name: basename(path),
|
||||
folder: {},
|
||||
});
|
||||
|
||||
return this.makeItem_(item);
|
||||
}
|
||||
|
||||
put(path, content, options = null) {
|
||||
if (!options) options = {};
|
||||
|
||||
if (options.source == 'file') {
|
||||
return this.api_.exec('PUT', this.makePath_(path) + ':/content', null, null, options);
|
||||
} else {
|
||||
options.headers = { 'Content-Type': 'text/plain' };
|
||||
return this.api_.exec('PUT', this.makePath_(path) + ':/content', null, content, options);
|
||||
}
|
||||
}
|
||||
|
||||
delete(path) {
|
||||
return this.api_.exec('DELETE', this.makePath_(path));
|
||||
}
|
||||
|
||||
async move(oldPath, newPath) {
|
||||
// Cannot work in an atomic way because if newPath already exist, the OneDrive API throw an error
|
||||
// "An item with the same name already exists under the parent". Some posts suggest to use
|
||||
// @name.conflictBehavior [0]but that doesn't seem to work. So until Microsoft fixes this
|
||||
// it's not possible to do an atomic move.
|
||||
//
|
||||
// [0] https://stackoverflow.com/questions/29191091/onedrive-api-overwrite-on-move
|
||||
throw new Error('NOT WORKING');
|
||||
|
||||
let previousItem = await this.statRaw_(oldPath);
|
||||
|
||||
let newDir = dirname(newPath);
|
||||
let newName = basename(newPath);
|
||||
|
||||
// We don't want the modification date to change when we move the file so retrieve it
|
||||
// now set it in the PATCH operation.
|
||||
|
||||
let item = await this.api_.execJson('PATCH', this.makePath_(oldPath), this.itemFilter_(), {
|
||||
name: newName,
|
||||
parentReference: { path: newDir },
|
||||
fileSystemInfo: {
|
||||
lastModifiedDateTime: previousItem.fileSystemInfo.lastModifiedDateTime,
|
||||
},
|
||||
});
|
||||
|
||||
return this.makeItem_(item);
|
||||
}
|
||||
|
||||
format() {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
async pathDetails_(path) {
|
||||
if (this.pathCache_[path]) return this.pathCache_[path];
|
||||
let output = await this.api_.execJson('GET', path);
|
||||
this.pathCache_[path] = output;
|
||||
return this.pathCache_[path];
|
||||
}
|
||||
|
||||
async delta(path, options = null) {
|
||||
let output = {
|
||||
hasMore: false,
|
||||
context: {},
|
||||
items: [],
|
||||
};
|
||||
|
||||
const pathDetails = await this.pathDetails_(path);
|
||||
const pathId = pathDetails.id;
|
||||
|
||||
let context = options ? options.context : null;
|
||||
let url = context ? context.nextLink : null;
|
||||
let query = null;
|
||||
|
||||
if (!url) {
|
||||
url = this.makePath_(path) + ':/delta';
|
||||
const query = this.itemFilter_();
|
||||
query.select += ',deleted';
|
||||
}
|
||||
|
||||
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
|
||||
// deal with these since all the files we're interested in are at the root (The .resource dir
|
||||
// is special since it's managed directly by the clients and resources never change - only the
|
||||
// associated .md file at the root is synced). So in the loop below we check that the parent is
|
||||
// indeed the root, otherwise the item is skipped.
|
||||
// (Not sure but it's possible the delta API also returns events for files that are copied outside
|
||||
// of the app directory and later deleted or modified. We also don't want to deal with
|
||||
// these files during sync).
|
||||
|
||||
for (let i = 0; i < response.value.length; i++) {
|
||||
const v = response.value[i];
|
||||
if (v.parentReference.id !== pathId) continue;
|
||||
items.push(this.makeItem_(v));
|
||||
}
|
||||
|
||||
output.items = output.items.concat(items);
|
||||
|
||||
let nextLink = null;
|
||||
|
||||
if (response['@odata.nextLink']) {
|
||||
nextLink = response['@odata.nextLink'];
|
||||
output.hasMore = true;
|
||||
} else {
|
||||
if (!response['@odata.deltaLink']) throw new Error('Delta link missing: ' + JSON.stringify(response));
|
||||
nextLink = response['@odata.deltaLink'];
|
||||
}
|
||||
|
||||
output.context = { nextLink: nextLink };
|
||||
|
||||
// https://dev.onedrive.com/items/view_delta.htm
|
||||
// The same item may appear more than once in a delta feed, for various reasons. You should use the last occurrence you see.
|
||||
// So remove any duplicate item from the array.
|
||||
let temp = [];
|
||||
let seenPaths = [];
|
||||
for (let i = output.items.length - 1; i >= 0; i--) {
|
||||
let item = output.items[i];
|
||||
if (seenPaths.indexOf(item.path) >= 0) continue;
|
||||
temp.splice(0, 0, item);
|
||||
seenPaths.push(item.path);
|
||||
}
|
||||
|
||||
output.items = temp;
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { FileApiDriverOneDrive };
|
|
@ -1,112 +0,0 @@
|
|||
const { isHidden } = require('lib/path-utils.js');
|
||||
const { Logger } = require('lib/logger.js');
|
||||
|
||||
class FileApi {
|
||||
|
||||
constructor(baseDir, driver) {
|
||||
this.baseDir_ = baseDir;
|
||||
this.driver_ = driver;
|
||||
this.logger_ = new Logger();
|
||||
this.syncTargetId_ = null;
|
||||
}
|
||||
|
||||
driver() {
|
||||
return this.driver_;
|
||||
}
|
||||
|
||||
setSyncTargetId(v) {
|
||||
this.syncTargetId_ = v;
|
||||
}
|
||||
|
||||
syncTargetId() {
|
||||
if (this.syncTargetId_ === null) throw new Error('syncTargetId has not been set!!');
|
||||
return this.syncTargetId_;
|
||||
}
|
||||
|
||||
setLogger(l) {
|
||||
this.logger_ = l;
|
||||
}
|
||||
|
||||
logger() {
|
||||
return this.logger_;
|
||||
}
|
||||
|
||||
fullPath_(path) {
|
||||
let output = this.baseDir_;
|
||||
if (path != '') output += '/' + path;
|
||||
return output;
|
||||
}
|
||||
|
||||
// DRIVER MUST RETURN PATHS RELATIVE TO `path`
|
||||
list(path = '', options = null) {
|
||||
if (!options) options = {};
|
||||
if (!('includeHidden' in options)) options.includeHidden = false;
|
||||
if (!('context' in options)) options.context = null;
|
||||
|
||||
this.logger().debug('list ' + this.baseDir_);
|
||||
|
||||
return this.driver_.list(this.baseDir_, options).then((result) => {
|
||||
if (!options.includeHidden) {
|
||||
let temp = [];
|
||||
for (let i = 0; i < result.items.length; i++) {
|
||||
if (!isHidden(result.items[i].path)) temp.push(result.items[i]);
|
||||
}
|
||||
result.items = temp;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
setTimestamp(path, timestampMs) {
|
||||
this.logger().debug('setTimestamp ' + this.fullPath_(path));
|
||||
return this.driver_.setTimestamp(this.fullPath_(path), timestampMs);
|
||||
}
|
||||
|
||||
mkdir(path) {
|
||||
this.logger().debug('mkdir ' + this.fullPath_(path));
|
||||
return this.driver_.mkdir(this.fullPath_(path));
|
||||
}
|
||||
|
||||
stat(path) {
|
||||
this.logger().debug('stat ' + this.fullPath_(path));
|
||||
return this.driver_.stat(this.fullPath_(path)).then((output) => {
|
||||
if (!output) return output;
|
||||
output.path = path;
|
||||
return output;
|
||||
});
|
||||
}
|
||||
|
||||
get(path, options = null) {
|
||||
if (!options) options = {};
|
||||
if (!options.encoding) options.encoding = 'utf8';
|
||||
this.logger().debug('get ' + this.fullPath_(path));
|
||||
return this.driver_.get(this.fullPath_(path), options);
|
||||
}
|
||||
|
||||
put(path, content, options = null) {
|
||||
this.logger().debug('put ' + this.fullPath_(path));
|
||||
return this.driver_.put(this.fullPath_(path), content, options);
|
||||
}
|
||||
|
||||
delete(path) {
|
||||
this.logger().debug('delete ' + this.fullPath_(path));
|
||||
return this.driver_.delete(this.fullPath_(path));
|
||||
}
|
||||
|
||||
move(oldPath, newPath) {
|
||||
this.logger().debug('move ' + this.fullPath_(oldPath) + ' => ' + this.fullPath_(newPath));
|
||||
return this.driver_.move(this.fullPath_(oldPath), this.fullPath_(newPath));
|
||||
}
|
||||
|
||||
format() {
|
||||
return this.driver_.format();
|
||||
}
|
||||
|
||||
delta(path, options = null) {
|
||||
this.logger().debug('delta ' + this.fullPath_(path));
|
||||
return this.driver_.delta(this.fullPath_(path), options);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { FileApi };
|
|
@ -1,16 +0,0 @@
|
|||
const { Folder } = require('lib/models/folder.js');
|
||||
|
||||
class FoldersScreenUtils {
|
||||
|
||||
static async refreshFolders() {
|
||||
let initialFolders = await Folder.all({ includeConflictFolder: true });
|
||||
|
||||
this.dispatch({
|
||||
type: 'FOLDER_UPDATE_ALL',
|
||||
folders: initialFolders,
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { FoldersScreenUtils };
|
|
@ -1,10 +0,0 @@
|
|||
class FsDriverDummy {
|
||||
|
||||
constructor() {}
|
||||
appendFileSync(path, string) {}
|
||||
writeBinaryFile(path, content) {}
|
||||
readFile(path) {}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { FsDriverDummy };
|
|
@ -1,20 +0,0 @@
|
|||
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,28 +0,0 @@
|
|||
const { shim } = require('lib/shim.js');
|
||||
const { netUtils } = require('lib/net-utils.js');
|
||||
|
||||
class GeolocationNode {
|
||||
|
||||
static async currentPosition(options = null) {
|
||||
if (!options) options = {};
|
||||
|
||||
const ip = await netUtils.ip();
|
||||
|
||||
let response = await shim.fetch('https://freegeoip.net/json/' + ip);
|
||||
if (!response.ok) throw new Error('Could not get geolocation: ' + await response.text());
|
||||
|
||||
response = await response.json();
|
||||
|
||||
return {
|
||||
timestamp: (new Date()).getTime(),
|
||||
coords: {
|
||||
longitude: response.longitude,
|
||||
altitude: 0,
|
||||
latitude: response.latitude
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { GeolocationNode };
|
|
@ -1,38 +0,0 @@
|
|||
const { Setting } = require('lib/models/setting.js');
|
||||
|
||||
class GeolocationReact {
|
||||
|
||||
static currentPosition_testResponse() {
|
||||
return {
|
||||
mocked: false,
|
||||
timestamp: (new Date()).getTime(),
|
||||
coords: {
|
||||
speed: 0,
|
||||
heading: 0,
|
||||
accuracy: 20,
|
||||
longitude: -3.4596633911132812,
|
||||
altitude: 0,
|
||||
latitude: 48.73219093634444
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static currentPosition(options = null) {
|
||||
if (Setting.value('env') == 'dev') return this.currentPosition_testResponse();
|
||||
|
||||
if (!options) options = {};
|
||||
if (!('enableHighAccuracy' in options)) options.enableHighAccuracy = true;
|
||||
if (!('timeout' in options)) options.timeout = 10000;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition((data) => {
|
||||
resolve(data);
|
||||
}, (error) => {
|
||||
reject(error);
|
||||
}, options);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { GeolocationReact };
|
Binary file not shown.
Before Width: | Height: | Size: 16 KiB |
|
@ -1,623 +0,0 @@
|
|||
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("![");
|
||||
lines.push(tagAlt);
|
||||
lines.push("](:/" + resource.id + ")");
|
||||
} 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 };
|
|
@ -1,411 +0,0 @@
|
|||
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,307 +0,0 @@
|
|||
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 (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL DEFAULT "",
|
||||
created_time INT NOT NULL,
|
||||
updated_time INT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX folders_title ON folders (title);
|
||||
CREATE INDEX folders_updated_time ON folders (updated_time);
|
||||
|
||||
CREATE TABLE notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_id TEXT NOT NULL DEFAULT "",
|
||||
title TEXT NOT NULL DEFAULT "",
|
||||
body TEXT NOT NULL DEFAULT "",
|
||||
created_time INT NOT NULL,
|
||||
updated_time INT NOT NULL,
|
||||
is_conflict INT NOT NULL DEFAULT 0,
|
||||
latitude NUMERIC NOT NULL DEFAULT 0,
|
||||
longitude NUMERIC NOT NULL DEFAULT 0,
|
||||
altitude NUMERIC NOT NULL DEFAULT 0,
|
||||
author TEXT NOT NULL DEFAULT "",
|
||||
source_url TEXT NOT NULL DEFAULT "",
|
||||
is_todo INT NOT NULL DEFAULT 0,
|
||||
todo_due INT NOT NULL DEFAULT 0,
|
||||
todo_completed INT NOT NULL DEFAULT 0,
|
||||
source TEXT NOT NULL DEFAULT "",
|
||||
source_application TEXT NOT NULL DEFAULT "",
|
||||
application_data TEXT NOT NULL DEFAULT "",
|
||||
\`order\` INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX notes_title ON notes (title);
|
||||
CREATE INDEX notes_updated_time ON notes (updated_time);
|
||||
CREATE INDEX notes_is_conflict ON notes (is_conflict);
|
||||
CREATE INDEX notes_is_todo ON notes (is_todo);
|
||||
CREATE INDEX notes_order ON notes (\`order\`);
|
||||
|
||||
CREATE TABLE tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL DEFAULT "",
|
||||
created_time INT NOT NULL,
|
||||
updated_time INT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX tags_title ON tags (title);
|
||||
CREATE INDEX tags_updated_time ON tags (updated_time);
|
||||
|
||||
CREATE TABLE note_tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
note_id TEXT NOT NULL,
|
||||
tag_id TEXT NOT NULL,
|
||||
created_time INT NOT NULL,
|
||||
updated_time INT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX note_tags_note_id ON note_tags (note_id);
|
||||
CREATE INDEX note_tags_tag_id ON note_tags (tag_id);
|
||||
CREATE INDEX note_tags_updated_time ON note_tags (updated_time);
|
||||
|
||||
CREATE TABLE resources (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL DEFAULT "",
|
||||
mime TEXT NOT NULL,
|
||||
filename TEXT NOT NULL DEFAULT "",
|
||||
created_time INT NOT NULL,
|
||||
updated_time INT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX resources_title ON resources (title);
|
||||
CREATE INDEX resources_updated_time ON resources (updated_time);
|
||||
|
||||
CREATE TABLE settings (
|
||||
\`key\` TEXT PRIMARY KEY,
|
||||
\`value\` TEXT,
|
||||
\`type\` INT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE table_fields (
|
||||
id INTEGER PRIMARY KEY,
|
||||
table_name TEXT NOT NULL,
|
||||
field_name TEXT NOT NULL,
|
||||
field_type INT NOT NULL,
|
||||
field_default TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE sync_items (
|
||||
id INTEGER PRIMARY KEY,
|
||||
sync_target INT NOT NULL,
|
||||
sync_time INT NOT NULL DEFAULT 0,
|
||||
item_type INT NOT NULL,
|
||||
item_id TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX sync_items_sync_time ON sync_items (sync_time);
|
||||
CREATE INDEX sync_items_sync_target ON sync_items (sync_target);
|
||||
CREATE INDEX sync_items_item_type ON sync_items (item_type);
|
||||
CREATE INDEX sync_items_item_id ON sync_items (item_id);
|
||||
|
||||
CREATE TABLE deleted_items (
|
||||
id INTEGER PRIMARY KEY,
|
||||
item_type INT NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
deleted_time INT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE version (
|
||||
version INT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO version (version) VALUES (1);
|
||||
`;
|
||||
|
||||
class JoplinDatabase extends Database {
|
||||
|
||||
constructor(driver) {
|
||||
super(driver);
|
||||
this.initialized_ = false;
|
||||
this.tableFields_ = null;
|
||||
}
|
||||
|
||||
initialized() {
|
||||
return this.initialized_;
|
||||
}
|
||||
|
||||
async open(options) {
|
||||
await super.open(options);
|
||||
return this.initialize();
|
||||
}
|
||||
|
||||
tableFieldNames(tableName) {
|
||||
let tf = this.tableFields(tableName);
|
||||
let output = [];
|
||||
for (let i = 0; i < tf.length; i++) {
|
||||
output.push(tf[i].name);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
tableFields(tableName) {
|
||||
if (!this.tableFields_) throw new Error('Fields have not been loaded yet');
|
||||
if (!this.tableFields_[tableName]) throw new Error('Unknown table: ' + tableName);
|
||||
return this.tableFields_[tableName];
|
||||
}
|
||||
|
||||
refreshTableFields() {
|
||||
this.logger().info('Initializing tables...');
|
||||
let queries = [];
|
||||
queries.push(this.wrapQuery('DELETE FROM table_fields'));
|
||||
|
||||
return this.selectAll('SELECT name FROM sqlite_master WHERE type="table"').then((tableRows) => {
|
||||
let chain = [];
|
||||
for (let i = 0; i < tableRows.length; i++) {
|
||||
let tableName = tableRows[i].name;
|
||||
if (tableName == 'android_metadata') continue;
|
||||
if (tableName == 'table_fields') continue;
|
||||
chain.push(() => {
|
||||
return this.selectAll('PRAGMA table_info("' + tableName + '")').then((pragmas) => {
|
||||
for (let i = 0; i < pragmas.length; i++) {
|
||||
let item = pragmas[i];
|
||||
// In SQLite, if the default value is a string it has double quotes around it, so remove them here
|
||||
let defaultValue = item.dflt_value;
|
||||
if (typeof defaultValue == 'string' && defaultValue.length >= 2 && defaultValue[0] == '"' && defaultValue[defaultValue.length - 1] == '"') {
|
||||
defaultValue = defaultValue.substr(1, defaultValue.length - 2);
|
||||
}
|
||||
let q = Database.insertQuery('table_fields', {
|
||||
table_name: tableName,
|
||||
field_name: item.name,
|
||||
field_type: Database.enumId('fieldType', item.type),
|
||||
field_default: defaultValue,
|
||||
});
|
||||
queries.push(q);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return promiseChain(chain);
|
||||
}).then(() => {
|
||||
return this.transactionExecBatch(queries);
|
||||
});
|
||||
}
|
||||
|
||||
async upgradeDatabase(fromVersion) {
|
||||
// INSTRUCTIONS TO UPGRADE THE 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);
|
||||
if (currentVersionIndex == existingDatabaseVersions.length - 1) return false;
|
||||
|
||||
while (currentVersionIndex < existingDatabaseVersions.length - 1) {
|
||||
const targetVersion = existingDatabaseVersions[currentVersionIndex + 1];
|
||||
this.logger().info("Converting database to version " + targetVersion);
|
||||
|
||||
let queries = [];
|
||||
|
||||
if (targetVersion == 1) {
|
||||
queries = this.wrapQueries(this.sqlStringToLines(structureSql));
|
||||
}
|
||||
|
||||
if (targetVersion == 2) {
|
||||
const newTableSql = `
|
||||
CREATE TABLE deleted_items (
|
||||
id INTEGER PRIMARY KEY,
|
||||
item_type INT NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
deleted_time INT NOT NULL,
|
||||
sync_target INT NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
queries.push({ sql: 'DROP TABLE deleted_items' });
|
||||
queries.push({ sql: this.sqlStringToLines(newTableSql)[0] });
|
||||
queries.push({ sql: "CREATE INDEX deleted_items_sync_target ON deleted_items (sync_target)" });
|
||||
}
|
||||
|
||||
if (targetVersion == 3) {
|
||||
queries = this.alterColumnQueries('settings', { key: 'TEXT PRIMARY KEY', value: 'TEXT' });
|
||||
}
|
||||
|
||||
if (targetVersion == 4) {
|
||||
queries.push("INSERT INTO settings (`key`, `value`) VALUES ('sync.3.context', (SELECT `value` FROM settings WHERE `key` = 'sync.context'))");
|
||||
queries.push('DELETE FROM settings WHERE `key` = "sync.context"');
|
||||
}
|
||||
|
||||
if (targetVersion == 5) {
|
||||
const tableNames = ['notes', 'folders', 'tags', 'note_tags', 'resources'];
|
||||
for (let i = 0; i < tableNames.length; i++) {
|
||||
const n = tableNames[i];
|
||||
queries.push('ALTER TABLE ' + n + ' ADD COLUMN user_created_time INT NOT NULL DEFAULT 0');
|
||||
queries.push('ALTER TABLE ' + n + ' ADD COLUMN user_updated_time INT NOT NULL DEFAULT 0');
|
||||
queries.push('UPDATE ' + n + ' SET user_created_time = created_time');
|
||||
queries.push('UPDATE ' + n + ' SET user_updated_time = updated_time');
|
||||
queries.push('CREATE INDEX ' + n + '_user_updated_time ON ' + n + ' (user_updated_time)');
|
||||
}
|
||||
}
|
||||
|
||||
queries.push({ sql: 'UPDATE version SET version = ?', params: [targetVersion] });
|
||||
await this.transactionExecBatch(queries);
|
||||
|
||||
currentVersionIndex++;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.logger().info('Checking for database schema update...');
|
||||
|
||||
let versionRow = null;
|
||||
try {
|
||||
// Will throw if the database has not been created yet, but this is handled below
|
||||
versionRow = await this.selectOne('SELECT * FROM version LIMIT 1');
|
||||
} catch (error) {
|
||||
if (error.message && (error.message.indexOf('no such table: version') >= 0)) {
|
||||
// Ignore
|
||||
} else {
|
||||
console.info(error);
|
||||
}
|
||||
}
|
||||
|
||||
const version = !versionRow ? 0 : versionRow.version;
|
||||
this.logger().info('Current database version', version);
|
||||
|
||||
const upgraded = await this.upgradeDatabase(version);
|
||||
if (upgraded) await this.refreshTableFields();
|
||||
|
||||
this.tableFields_ = {};
|
||||
|
||||
let rows = await this.selectAll('SELECT * FROM table_fields');
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
let row = rows[i];
|
||||
if (!this.tableFields_[row.table_name]) this.tableFields_[row.table_name] = [];
|
||||
this.tableFields_[row.table_name].push({
|
||||
name: row.field_name,
|
||||
type: row.field_type,
|
||||
default: Database.formatValue(row.field_type, row.field_default),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Database.TYPE_INT = 1;
|
||||
Database.TYPE_TEXT = 2;
|
||||
Database.TYPE_NUMERIC = 3;
|
||||
|
||||
module.exports = { JoplinDatabase };
|
|
@ -1,9 +0,0 @@
|
|||
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,299 +0,0 @@
|
|||
const { sprintf } = require('sprintf-js');
|
||||
|
||||
let codeToLanguageE_ = {};
|
||||
codeToLanguageE_["aa"] = "Afar";
|
||||
codeToLanguageE_["ab"] = "Abkhazian";
|
||||
codeToLanguageE_["af"] = "Afrikaans";
|
||||
codeToLanguageE_["am"] = "Amharic";
|
||||
codeToLanguageE_["an"] = "Aragonese";
|
||||
codeToLanguageE_["ar"] = "Arabic";
|
||||
codeToLanguageE_["as"] = "Assamese";
|
||||
codeToLanguageE_["ay"] = "Aymara";
|
||||
codeToLanguageE_["az"] = "Azerbaijani";
|
||||
codeToLanguageE_["ba"] = "Bashkir";
|
||||
codeToLanguageE_["be"] = "Byelorussian";
|
||||
codeToLanguageE_["bg"] = "Bulgarian";
|
||||
codeToLanguageE_["bh"] = "Bihari";
|
||||
codeToLanguageE_["bi"] = "Bislama";
|
||||
codeToLanguageE_["bn"] = "Bangla";
|
||||
codeToLanguageE_["bo"] = "Tibetan";
|
||||
codeToLanguageE_["br"] = "Breton";
|
||||
codeToLanguageE_["ca"] = "Catalan";
|
||||
codeToLanguageE_["co"] = "Corsican";
|
||||
codeToLanguageE_["cs"] = "Czech";
|
||||
codeToLanguageE_["cy"] = "Welsh";
|
||||
codeToLanguageE_["da"] = "Danish";
|
||||
codeToLanguageE_["de"] = "German";
|
||||
codeToLanguageE_["dz"] = "Bhutani";
|
||||
codeToLanguageE_["el"] = "Greek";
|
||||
codeToLanguageE_["en"] = "English";
|
||||
codeToLanguageE_["eo"] = "Esperanto";
|
||||
codeToLanguageE_["es"] = "Spanish";
|
||||
codeToLanguageE_["et"] = "Estonian";
|
||||
codeToLanguageE_["eu"] = "Basque";
|
||||
codeToLanguageE_["fa"] = "Persian";
|
||||
codeToLanguageE_["fi"] = "Finnish";
|
||||
codeToLanguageE_["fj"] = "Fiji";
|
||||
codeToLanguageE_["fo"] = "Faroese";
|
||||
codeToLanguageE_["fr"] = "French";
|
||||
codeToLanguageE_["fy"] = "Frisian";
|
||||
codeToLanguageE_["ga"] = "Irish";
|
||||
codeToLanguageE_["gd"] = "Gaelic";
|
||||
codeToLanguageE_["gl"] = "Galician";
|
||||
codeToLanguageE_["gn"] = "Guarani";
|
||||
codeToLanguageE_["gu"] = "Gujarati";
|
||||
codeToLanguageE_["ha"] = "Hausa";
|
||||
codeToLanguageE_["he"] = "Hebrew";
|
||||
codeToLanguageE_["hi"] = "Hindi";
|
||||
codeToLanguageE_["hr"] = "Croatian";
|
||||
codeToLanguageE_["hu"] = "Hungarian";
|
||||
codeToLanguageE_["hy"] = "Armenian";
|
||||
codeToLanguageE_["ia"] = "Interlingua";
|
||||
codeToLanguageE_["id"] = "Indonesian";
|
||||
codeToLanguageE_["ie"] = "Interlingue";
|
||||
codeToLanguageE_["ik"] = "Inupiak";
|
||||
codeToLanguageE_["is"] = "Icelandic";
|
||||
codeToLanguageE_["it"] = "Italian";
|
||||
codeToLanguageE_["iu"] = "Inuktitut";
|
||||
codeToLanguageE_["ja"] = "Japanese";
|
||||
codeToLanguageE_["jw"] = "Javanese";
|
||||
codeToLanguageE_["ka"] = "Georgian";
|
||||
codeToLanguageE_["kk"] = "Kazakh";
|
||||
codeToLanguageE_["kl"] = "Greenlandic";
|
||||
codeToLanguageE_["km"] = "Cambodian";
|
||||
codeToLanguageE_["kn"] = "Kannada";
|
||||
codeToLanguageE_["ko"] = "Korean";
|
||||
codeToLanguageE_["ks"] = "Kashmiri";
|
||||
codeToLanguageE_["ku"] = "Kurdish";
|
||||
codeToLanguageE_["ky"] = "Kirghiz";
|
||||
codeToLanguageE_["la"] = "Latin";
|
||||
codeToLanguageE_["ln"] = "Lingala";
|
||||
codeToLanguageE_["lo"] = "Laothian";
|
||||
codeToLanguageE_["lt"] = "Lithuanian";
|
||||
codeToLanguageE_["lv"] = "Lettish";
|
||||
codeToLanguageE_["mg"] = "Malagasy";
|
||||
codeToLanguageE_["mi"] = "Maori";
|
||||
codeToLanguageE_["mk"] = "Macedonian";
|
||||
codeToLanguageE_["ml"] = "Malayalam";
|
||||
codeToLanguageE_["mn"] = "Mongolian";
|
||||
codeToLanguageE_["mo"] = "Moldavian";
|
||||
codeToLanguageE_["mr"] = "Marathi";
|
||||
codeToLanguageE_["ms"] = "Malay";
|
||||
codeToLanguageE_["mt"] = "Maltese";
|
||||
codeToLanguageE_["my"] = "Burmese";
|
||||
codeToLanguageE_["na"] = "Nauru";
|
||||
codeToLanguageE_["ne"] = "Nepali";
|
||||
codeToLanguageE_["nl"] = "Dutch";
|
||||
codeToLanguageE_["no"] = "Norwegian";
|
||||
codeToLanguageE_["oc"] = "Occitan";
|
||||
codeToLanguageE_["om"] = "Oromo";
|
||||
codeToLanguageE_["or"] = "Oriya";
|
||||
codeToLanguageE_["pa"] = "Punjabi";
|
||||
codeToLanguageE_["pl"] = "Polish";
|
||||
codeToLanguageE_["ps"] = "Pushto";
|
||||
codeToLanguageE_["pt"] = "Portuguese";
|
||||
codeToLanguageE_["qu"] = "Quechua";
|
||||
codeToLanguageE_["rm"] = "Rhaeto-Romance";
|
||||
codeToLanguageE_["rn"] = "Kirundi";
|
||||
codeToLanguageE_["ro"] = "Romanian";
|
||||
codeToLanguageE_["ru"] = "Russian";
|
||||
codeToLanguageE_["rw"] = "Kinyarwanda";
|
||||
codeToLanguageE_["sa"] = "Sanskrit";
|
||||
codeToLanguageE_["sd"] = "Sindhi";
|
||||
codeToLanguageE_["sg"] = "Sangho";
|
||||
codeToLanguageE_["sh"] = "Serbo-Croatian";
|
||||
codeToLanguageE_["si"] = "Sinhalese";
|
||||
codeToLanguageE_["sk"] = "Slovak";
|
||||
codeToLanguageE_["sl"] = "Slovenian";
|
||||
codeToLanguageE_["sm"] = "Samoan";
|
||||
codeToLanguageE_["sn"] = "Shona";
|
||||
codeToLanguageE_["so"] = "Somali";
|
||||
codeToLanguageE_["sq"] = "Albanian";
|
||||
codeToLanguageE_["sr"] = "Serbian";
|
||||
codeToLanguageE_["ss"] = "Siswati";
|
||||
codeToLanguageE_["st"] = "Sesotho";
|
||||
codeToLanguageE_["su"] = "Sundanese";
|
||||
codeToLanguageE_["sv"] = "Swedish";
|
||||
codeToLanguageE_["sw"] = "Swahili";
|
||||
codeToLanguageE_["ta"] = "Tamil";
|
||||
codeToLanguageE_["te"] = "Telugu";
|
||||
codeToLanguageE_["tg"] = "Tajik";
|
||||
codeToLanguageE_["th"] = "Thai";
|
||||
codeToLanguageE_["ti"] = "Tigrinya";
|
||||
codeToLanguageE_["tk"] = "Turkmen";
|
||||
codeToLanguageE_["tl"] = "Tagalog";
|
||||
codeToLanguageE_["tn"] = "Setswana";
|
||||
codeToLanguageE_["to"] = "Tonga";
|
||||
codeToLanguageE_["tr"] = "Turkish";
|
||||
codeToLanguageE_["ts"] = "Tsonga";
|
||||
codeToLanguageE_["tt"] = "Tatar";
|
||||
codeToLanguageE_["tw"] = "Twi";
|
||||
codeToLanguageE_["ug"] = "Uighur";
|
||||
codeToLanguageE_["uk"] = "Ukrainian";
|
||||
codeToLanguageE_["ur"] = "Urdu";
|
||||
codeToLanguageE_["uz"] = "Uzbek";
|
||||
codeToLanguageE_["vi"] = "Vietnamese";
|
||||
codeToLanguageE_["vo"] = "Volapuk";
|
||||
codeToLanguageE_["wo"] = "Wolof";
|
||||
codeToLanguageE_["xh"] = "Xhosa";
|
||||
codeToLanguageE_["yi"] = "Yiddish";
|
||||
codeToLanguageE_["yo"] = "Yoruba";
|
||||
codeToLanguageE_["za"] = "Zhuang";
|
||||
codeToLanguageE_["zh"] = "Chinese";
|
||||
codeToLanguageE_["zu"] = "Zulu";
|
||||
|
||||
let codeToLanguage_ = {};
|
||||
codeToLanguage_["an"] = "Aragonés";
|
||||
codeToLanguage_["da"] = "Dansk";
|
||||
codeToLanguage_["de"] = "Deutsch";
|
||||
codeToLanguage_["en"] = "English";
|
||||
codeToLanguage_["es"] = "Español";
|
||||
codeToLanguage_["fr"] = "Français";
|
||||
codeToLanguage_["he"] = "עיברית";
|
||||
codeToLanguage_["it"] = "Italiano";
|
||||
codeToLanguage_["lt"] = "Lietuvių kalba";
|
||||
codeToLanguage_["nl"] = "Nederlands";
|
||||
codeToLanguage_["pl"] = "Polski";
|
||||
codeToLanguage_["pt"] = "Português";
|
||||
codeToLanguage_["ru"] = "Русский";
|
||||
codeToLanguage_["sk"] = "Slovenčina";
|
||||
codeToLanguage_["sq"] = "Shqip";
|
||||
codeToLanguage_["sr"] = "српски језик";
|
||||
codeToLanguage_["tr"] = "Türkçe";
|
||||
codeToLanguage_["ja"] = "日本語";
|
||||
codeToLanguage_["ko"] = "한국말";
|
||||
codeToLanguage_["sv"] = "Svenska";
|
||||
codeToLanguage_["el"] = "ελληνικά";
|
||||
codeToLanguage_["zh"] = "中文";
|
||||
codeToLanguage_["ro"] = "Română";
|
||||
codeToLanguage_["et"] = "Eesti Keel";
|
||||
codeToLanguage_["vi"] = "Tiếng Việt";
|
||||
codeToLanguage_["hu"] = "Magyar";
|
||||
|
||||
let codeToCountry_ = {};
|
||||
codeToCountry_["BR"] = "Brasil";
|
||||
codeToCountry_["CN"] = "中国";
|
||||
|
||||
let supportedLocales_ = null;
|
||||
|
||||
let loadedLocales_ = {};
|
||||
|
||||
const defaultLocale_ = 'en_GB';
|
||||
|
||||
let currentLocale_ = defaultLocale_;
|
||||
|
||||
function defaultLocale() {
|
||||
return defaultLocale_;
|
||||
}
|
||||
|
||||
function supportedLocales() {
|
||||
if (!supportedLocales_) supportedLocales_ = require('../locales/index.js').locales;
|
||||
|
||||
let output = [];
|
||||
for (let n in supportedLocales_) {
|
||||
if (!supportedLocales_.hasOwnProperty(n)) continue;
|
||||
output.push(n);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function supportedLocalesToLanguages() {
|
||||
const locales = supportedLocales();
|
||||
let output = {};
|
||||
for (let i = 0; i < locales.length; i++) {
|
||||
const locale = locales[i];
|
||||
output[locale] = countryDisplayName(locale);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function closestSupportedLocale(canonicalName, defaultToEnglish = true) {
|
||||
const locales = supportedLocales();
|
||||
if (locales.indexOf(canonicalName) >= 0) return canonicalName;
|
||||
|
||||
const requiredLanguage = languageCodeOnly(canonicalName).toLowerCase();
|
||||
|
||||
for (let i = 0; i < locales.length; i++) {
|
||||
const locale = locales[i];
|
||||
const language = locale.split('_')[0];
|
||||
if (requiredLanguage == language) return locale;
|
||||
}
|
||||
|
||||
return defaultToEnglish ? 'en_GB' : null;
|
||||
}
|
||||
|
||||
function countryName(countryCode) {
|
||||
return codeToCountry_[countryCode] ? codeToCountry_[countryCode] : '';
|
||||
}
|
||||
|
||||
function languageNameInEnglish(languageCode) {
|
||||
return codeToLanguageE_[languageCode] ? codeToLanguageE_[languageCode] : '';
|
||||
}
|
||||
|
||||
function languageName(languageCode, defaultToEnglish = true) {
|
||||
if (codeToLanguage_[languageCode]) return codeToLanguage_[languageCode];
|
||||
if (defaultToEnglish) return languageNameInEnglish(languageCode)
|
||||
return '';
|
||||
}
|
||||
|
||||
function languageCodeOnly(canonicalName) {
|
||||
if (canonicalName.length < 2) return canonicalName;
|
||||
return canonicalName.substr(0, 2);
|
||||
}
|
||||
|
||||
|
||||
function countryCodeOnly(canonicalName) {
|
||||
if (canonicalName.length <= 2) return "";
|
||||
return canonicalName.substr(3);
|
||||
}
|
||||
|
||||
function countryDisplayName(canonicalName) {
|
||||
const languageCode = languageCodeOnly(canonicalName);
|
||||
const countryCode = countryCodeOnly(canonicalName);
|
||||
|
||||
let output = languageName(languageCode);
|
||||
|
||||
let extraString;
|
||||
|
||||
if (countryCode != "") {
|
||||
if (languageCode == "zh" && countryCode == "CN") {
|
||||
extraString = "简体"; // "Simplified" in "Simplified Chinese"
|
||||
} else {
|
||||
extraString = countryName(countryCode);
|
||||
}
|
||||
}
|
||||
|
||||
if (languageCode == "zh" && (countryCode == "" || countryCode == "TW")) extraString = "繁體"; // "Traditional" in "Traditional Chinese"
|
||||
|
||||
if (extraString != "") output += " (" + extraString + ")";
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function localeStrings(canonicalName) {
|
||||
const locale = closestSupportedLocale(canonicalName);
|
||||
|
||||
if (loadedLocales_[locale]) return loadedLocales_[locale];
|
||||
|
||||
loadedLocales_[locale] = Object.assign({}, supportedLocales_[locale]);
|
||||
|
||||
return loadedLocales_[locale];
|
||||
}
|
||||
|
||||
function setLocale(canonicalName) {
|
||||
if (currentLocale_ == canonicalName) return;
|
||||
currentLocale_ = closestSupportedLocale(canonicalName);
|
||||
}
|
||||
|
||||
function languageCode() {
|
||||
return languageCodeOnly(currentLocale_);
|
||||
}
|
||||
|
||||
function _(s, ...args) {
|
||||
let strings = localeStrings(currentLocale_);
|
||||
let result = strings[s];
|
||||
if (result === '' || result === undefined) result = s;
|
||||
return sprintf(result, ...args);
|
||||
}
|
||||
|
||||
module.exports = { _, supportedLocales, localeStrings, setLocale, supportedLocalesToLanguages, defaultLocale, closestSupportedLocale, languageCode };
|
|
@ -1,40 +0,0 @@
|
|||
// Custom wrapper for `console` to allow for custom logging (to file, etc.) if needed.
|
||||
|
||||
class Log {
|
||||
|
||||
static setLevel(v) {
|
||||
this.level_ = v;
|
||||
}
|
||||
|
||||
static level() {
|
||||
return this.level_ === undefined ? Log.LEVEL_DEBUG : this.level_;
|
||||
}
|
||||
|
||||
static debug(...o) {
|
||||
if (Log.level() > Log.LEVEL_DEBUG) return;
|
||||
console.info(...o);
|
||||
}
|
||||
|
||||
static info(...o) {
|
||||
if (Log.level() > Log.LEVEL_INFO) return;
|
||||
console.info(...o);
|
||||
}
|
||||
|
||||
static warn(...o) {
|
||||
if (Log.level() > Log.LEVEL_WARN) return;
|
||||
console.info(...o);
|
||||
}
|
||||
|
||||
static error(...o) {
|
||||
if (Log.level() > Log.LEVEL_ERROR) return;
|
||||
console.info(...o);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Log.LEVEL_DEBUG = 0;
|
||||
Log.LEVEL_INFO = 10;
|
||||
Log.LEVEL_WARN = 20;
|
||||
Log.LEVEL_ERROR = 30;
|
||||
|
||||
module.exports = { Log };
|
|
@ -1,181 +0,0 @@
|
|||
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 {
|
||||
|
||||
constructor() {
|
||||
this.targets_ = [];
|
||||
this.level_ = Logger.LEVEL_ERROR;
|
||||
this.fileAppendQueue_ = []
|
||||
this.lastDbCleanup_ = time.unixMs();
|
||||
}
|
||||
|
||||
static fsDriver() {
|
||||
if (!Logger.fsDriver_) Logger.fsDriver_ = new FsDriverDummy();
|
||||
return Logger.fsDriver_;
|
||||
}
|
||||
|
||||
setLevel(level) {
|
||||
this.level_ = level;
|
||||
}
|
||||
|
||||
level() {
|
||||
return this.level_;
|
||||
}
|
||||
|
||||
clearTargets() {
|
||||
this.targets_.clear();
|
||||
}
|
||||
|
||||
addTarget(type, options = null) {
|
||||
let target = { type: type };
|
||||
for (let n in options) {
|
||||
if (!options.hasOwnProperty(n)) continue;
|
||||
target[n] = options[n];
|
||||
}
|
||||
|
||||
this.targets_.push(target);
|
||||
}
|
||||
|
||||
objectToString(object) {
|
||||
let output = '';
|
||||
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
output = object;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
objectsToString(...object) {
|
||||
let output = [];
|
||||
for (let i = 0; i < object.length; i++) {
|
||||
output.push('"' + this.objectToString(object[i]) + '"');
|
||||
}
|
||||
return output.join(', ');
|
||||
}
|
||||
|
||||
static databaseCreateTableSql() {
|
||||
let output = `
|
||||
CREATE TABLE IF NOT EXISTS logs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source TEXT,
|
||||
level INT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
\`timestamp\` INT NOT NULL
|
||||
);
|
||||
`;
|
||||
return output.split("\n").join(' ');
|
||||
}
|
||||
|
||||
// Only for database at the moment
|
||||
async lastEntries(limit = 100) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
log(level, ...object) {
|
||||
if (this.level() < level || !this.targets_.length) return;
|
||||
|
||||
let levelString = '';
|
||||
let line = moment().format('YYYY-MM-DD HH:mm:ss') + ': ';
|
||||
|
||||
if (level == Logger.LEVEL_WARN) levelString += '[warn] ';
|
||||
if (level == Logger.LEVEL_ERROR) levelString += '[error] ';
|
||||
|
||||
for (let i = 0; i < this.targets_.length; i++) {
|
||||
let target = this.targets_[i];
|
||||
if (target.type == 'console') {
|
||||
let fn = 'log';
|
||||
if (level == Logger.LEVEL_ERROR) fn = 'error';
|
||||
if (level == Logger.LEVEL_WARN) fn = 'warn';
|
||||
if (level == Logger.LEVEL_INFO) fn = 'info';
|
||||
console[fn](line + this.objectsToString(...object));
|
||||
} else if (target.type == 'file') {
|
||||
let serializedObject = this.objectsToString(...object);
|
||||
Logger.fsDriver().appendFileSync(target.path, line + serializedObject + "\n");
|
||||
} else if (target.type == 'database') {
|
||||
let msg = this.objectsToString(...object);
|
||||
|
||||
let queries = [{
|
||||
sql: 'INSERT INTO logs (`source`, `level`, `message`, `timestamp`) VALUES (?, ?, ?, ?)',
|
||||
params: [target.source, level, msg, time.unixMs()],
|
||||
}];
|
||||
|
||||
const now = time.unixMs();
|
||||
if (now - this.lastDbCleanup_ > 1000 * 60 * 60) {
|
||||
this.lastDbCleanup_ = now;
|
||||
const dayKeep = 14;
|
||||
queries.push({
|
||||
sql: 'DELETE FROM logs WHERE `timestamp` < ?',
|
||||
params: [now - 1000 * 60 * 60 * 24 * dayKeep],
|
||||
});
|
||||
}
|
||||
|
||||
target.database.transactionExecBatch(queries);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
error(...object) { return this.log(Logger.LEVEL_ERROR, ...object); }
|
||||
warn(...object) { return this.log(Logger.LEVEL_WARN, ...object); }
|
||||
info(...object) { return this.log(Logger.LEVEL_INFO, ...object); }
|
||||
debug(...object) { return this.log(Logger.LEVEL_DEBUG, ...object); }
|
||||
|
||||
static levelStringToId(s) {
|
||||
if (s == 'none') return Logger.LEVEL_NONE;
|
||||
if (s == 'error') return Logger.LEVEL_ERROR;
|
||||
if (s == 'warn') return Logger.LEVEL_WARN;
|
||||
if (s == 'info') return Logger.LEVEL_INFO;
|
||||
if (s == 'debug') return Logger.LEVEL_DEBUG;
|
||||
throw new Error(_('Unknown log level: %s', s));
|
||||
}
|
||||
|
||||
static levelIdToString(id) {
|
||||
if (id == Logger.LEVEL_NONE) return 'none';
|
||||
if (id == Logger.LEVEL_ERROR) return 'error';
|
||||
if (id == Logger.LEVEL_WARN) return 'warn';
|
||||
if (id == Logger.LEVEL_INFO) return 'info';
|
||||
if (id == Logger.LEVEL_DEBUG) return 'debug';
|
||||
throw new Error(_('Unknown level ID: %s', id));
|
||||
}
|
||||
|
||||
static levelIds() {
|
||||
return [Logger.LEVEL_NONE, Logger.LEVEL_ERROR, Logger.LEVEL_WARN, Logger.LEVEL_INFO, Logger.LEVEL_DEBUG];
|
||||
}
|
||||
|
||||
static levelEnum() {
|
||||
let output = {};
|
||||
const ids = this.levelIds();
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
output[ids[i]] = this.levelIdToString(ids[i]);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Logger.LEVEL_NONE = 0;
|
||||
Logger.LEVEL_ERROR = 10;
|
||||
Logger.LEVEL_WARN = 20;
|
||||
Logger.LEVEL_INFO = 30;
|
||||
Logger.LEVEL_DEBUG = 40;
|
||||
|
||||
module.exports = { Logger };
|
|
@ -1,16 +0,0 @@
|
|||
const markdownUtils = {
|
||||
|
||||
// Not really escaping because that's not supported by marked.js
|
||||
escapeLinkText(text) {
|
||||
return text.replace(/(\[|\]|\(|\))/g, '_');
|
||||
},
|
||||
|
||||
escapeLinkUrl(url) {
|
||||
url = url.replace(/\(/g, '%28');
|
||||
url = url.replace(/\)/g, '%29');
|
||||
return url;
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
module.exports = { markdownUtils };
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
@ -1,438 +0,0 @@
|
|||
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 {
|
||||
|
||||
static useUuid() {
|
||||
return true;
|
||||
}
|
||||
|
||||
static loadClass(className, classRef) {
|
||||
for (let i = 0; i < BaseItem.syncItemDefinitions_.length; i++) {
|
||||
if (BaseItem.syncItemDefinitions_[i].className == className) {
|
||||
BaseItem.syncItemDefinitions_[i].classRef = classRef;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Invalid class name: ' + className);
|
||||
}
|
||||
|
||||
// Need to dynamically load the classes like this to avoid circular dependencies
|
||||
static getClass(name) {
|
||||
for (let i = 0; i < BaseItem.syncItemDefinitions_.length; i++) {
|
||||
if (BaseItem.syncItemDefinitions_[i].className == name) {
|
||||
return BaseItem.syncItemDefinitions_[i].classRef;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Invalid class name: ' + name);
|
||||
}
|
||||
|
||||
static getClassByItemType(itemType) {
|
||||
for (let i = 0; i < BaseItem.syncItemDefinitions_.length; i++) {
|
||||
if (BaseItem.syncItemDefinitions_[i].type == itemType) {
|
||||
return BaseItem.syncItemDefinitions_[i].classRef;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Invalid item type: ' + itemType);
|
||||
}
|
||||
|
||||
static async syncedCount(syncTarget) {
|
||||
const ItemClass = this.itemClass(this.modelType());
|
||||
const itemType = ItemClass.modelType();
|
||||
// The fact that we don't check if the item_id still exist in the corresponding item table, means
|
||||
// that the returned number might be innaccurate (for example if a sync operation was cancelled)
|
||||
const sql = 'SELECT count(*) as total FROM sync_items WHERE sync_target = ? AND item_type = ?';
|
||||
const r = await this.db().selectOne(sql, [ syncTarget, itemType ]);
|
||||
return r.total;
|
||||
}
|
||||
|
||||
static systemPath(itemOrId) {
|
||||
if (typeof itemOrId === 'string') return itemOrId + '.md';
|
||||
return itemOrId.id + '.md';
|
||||
}
|
||||
|
||||
static isSystemPath(path) {
|
||||
// 1b175bb38bba47baac22b0b47f778113.md
|
||||
if (!path || !path.length) return false;
|
||||
let p = path.split('/');
|
||||
p = p[p.length - 1];
|
||||
p = p.split('.');
|
||||
if (p.length != 2) return false;
|
||||
return p[0].length == 32 && p[1] == 'md';
|
||||
}
|
||||
|
||||
static itemClass(item) {
|
||||
if (!item) throw new Error('Item cannot be null');
|
||||
|
||||
if (typeof item === 'object') {
|
||||
if (!('type_' in item)) throw new Error('Item does not have a type_ property');
|
||||
return this.itemClass(item.type_);
|
||||
} else {
|
||||
for (let i = 0; i < BaseItem.syncItemDefinitions_.length; i++) {
|
||||
let d = BaseItem.syncItemDefinitions_[i];
|
||||
if (Number(item) == d.type) return this.getClass(d.className);
|
||||
}
|
||||
throw new Error('Unknown type: ' + item);
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the IDs of the items that have been synced at least once
|
||||
static async syncedItemIds(syncTarget) {
|
||||
if (!syncTarget) throw new Error('No syncTarget specified');
|
||||
let temp = await this.db().selectAll('SELECT item_id FROM sync_items WHERE sync_time > 0 AND sync_target = ?', [syncTarget]);
|
||||
let output = [];
|
||||
for (let i = 0; i < temp.length; i++) {
|
||||
output.push(temp[i].item_id);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
static pathToId(path) {
|
||||
let p = path.split('/');
|
||||
let s = p[p.length - 1].split('.');
|
||||
return s[0];
|
||||
}
|
||||
|
||||
static loadItemByPath(path) {
|
||||
return this.loadItemById(this.pathToId(path));
|
||||
}
|
||||
|
||||
static async loadItemById(id) {
|
||||
let classes = this.syncItemClassNames();
|
||||
for (let i = 0; i < classes.length; i++) {
|
||||
let item = await this.getClass(classes[i]).load(id);
|
||||
if (item) return item;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static loadItemByField(itemType, field, value) {
|
||||
let ItemClass = this.itemClass(itemType);
|
||||
return ItemClass.loadByField(field, value);
|
||||
}
|
||||
|
||||
static loadItem(itemType, id) {
|
||||
let ItemClass = this.itemClass(itemType);
|
||||
return ItemClass.load(id);
|
||||
}
|
||||
|
||||
static deleteItem(itemType, id) {
|
||||
let ItemClass = this.itemClass(itemType);
|
||||
return ItemClass.delete(id);
|
||||
}
|
||||
|
||||
static async delete(id, options = null) {
|
||||
return this.batchDelete([id], options);
|
||||
}
|
||||
|
||||
static async batchDelete(ids, options = null) {
|
||||
let trackDeleted = true;
|
||||
if (options && options.trackDeleted !== null && options.trackDeleted !== undefined) trackDeleted = options.trackDeleted;
|
||||
|
||||
// Don't create a deleted_items entry when conflicted notes are deleted
|
||||
// since no other client have (or should have) them.
|
||||
let conflictNoteIds = [];
|
||||
if (this.modelType() == BaseModel.TYPE_NOTE) {
|
||||
const conflictNotes = await this.db().selectAll('SELECT id FROM notes WHERE id IN ("' + ids.join('","') + '") AND is_conflict = 1');
|
||||
conflictNoteIds = conflictNotes.map((n) => { return n.id });
|
||||
}
|
||||
|
||||
await super.batchDelete(ids, options);
|
||||
|
||||
if (trackDeleted) {
|
||||
const syncTargetIds = Setting.enumOptionValues('sync.target');
|
||||
let queries = [];
|
||||
let now = time.unixMs();
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
if (conflictNoteIds.indexOf(ids[i]) >= 0) continue;
|
||||
|
||||
// For each deleted item, for each sync target, we need to add an entry in deleted_items.
|
||||
// That way, each target can later delete the remote item.
|
||||
for (let j = 0; j < syncTargetIds.length; j++) {
|
||||
queries.push({
|
||||
sql: 'INSERT INTO deleted_items (item_type, item_id, deleted_time, sync_target) VALUES (?, ?, ?, ?)',
|
||||
params: [this.modelType(), ids[i], now, syncTargetIds[j]],
|
||||
});
|
||||
}
|
||||
}
|
||||
await this.db().transactionExecBatch(queries);
|
||||
}
|
||||
}
|
||||
|
||||
static deletedItems(syncTarget) {
|
||||
return this.db().selectAll('SELECT * FROM deleted_items WHERE sync_target = ?', [syncTarget]);
|
||||
}
|
||||
|
||||
static async deletedItemCount(syncTarget) {
|
||||
let r = await this.db().selectOne('SELECT count(*) as total FROM deleted_items WHERE sync_target = ?', [syncTarget]);
|
||||
return r['total'];
|
||||
}
|
||||
|
||||
static remoteDeletedItem(syncTarget, itemId) {
|
||||
return this.db().exec('DELETE FROM deleted_items WHERE item_id = ? AND sync_target = ?', [itemId, syncTarget]);
|
||||
}
|
||||
|
||||
static serialize_format(propName, propValue) {
|
||||
if (['created_time', 'updated_time', 'sync_time', 'user_updated_time', 'user_created_time'].indexOf(propName) >= 0) {
|
||||
if (!propValue) return '';
|
||||
propValue = moment.unix(propValue / 1000).utc().format('YYYY-MM-DDTHH:mm:ss.SSS') + 'Z';
|
||||
} else if (propValue === null || propValue === undefined) {
|
||||
propValue = '';
|
||||
}
|
||||
|
||||
return propValue;
|
||||
}
|
||||
|
||||
static unserialize_format(type, propName, propValue) {
|
||||
if (propName[propName.length - 1] == '_') return propValue; // Private property
|
||||
|
||||
let ItemClass = this.itemClass(type);
|
||||
|
||||
if (['created_time', 'updated_time', 'user_created_time', 'user_updated_time'].indexOf(propName) >= 0) {
|
||||
if (!propValue) return 0;
|
||||
propValue = moment(propValue, 'YYYY-MM-DDTHH:mm:ss.SSSZ').format('x');
|
||||
} else {
|
||||
propValue = Database.formatValue(ItemClass.fieldType(propName), propValue);
|
||||
}
|
||||
|
||||
return propValue;
|
||||
}
|
||||
|
||||
static async serialize(item, type = null, shownKeys = null) {
|
||||
item = this.filter(item);
|
||||
|
||||
let output = {};
|
||||
|
||||
if ('title' in item && shownKeys.indexOf('title') >= 0) {
|
||||
output.title = item.title;
|
||||
}
|
||||
|
||||
if ('body' in item && shownKeys.indexOf('body') >= 0) {
|
||||
output.body = item.body;
|
||||
}
|
||||
|
||||
output.props = [];
|
||||
|
||||
for (let i = 0; i < shownKeys.length; i++) {
|
||||
let key = shownKeys[i];
|
||||
if (key == 'title' || key == 'body') continue;
|
||||
|
||||
let value = null;
|
||||
if (typeof key === 'function') {
|
||||
let r = await key();
|
||||
key = r.key;
|
||||
value = r.value;
|
||||
} else {
|
||||
value = this.serialize_format(key, item[key]);
|
||||
}
|
||||
|
||||
output.props.push(key + ': ' + value);
|
||||
}
|
||||
|
||||
let temp = [];
|
||||
|
||||
if (output.title) temp.push(output.title);
|
||||
if (output.body) temp.push(output.body);
|
||||
if (output.props.length) temp.push(output.props.join("\n"));
|
||||
|
||||
return temp.join("\n\n");
|
||||
}
|
||||
|
||||
static async unserialize(content) {
|
||||
let lines = content.split("\n");
|
||||
let output = {};
|
||||
let state = 'readingProps';
|
||||
let body = [];
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
let line = lines[i];
|
||||
|
||||
if (state == 'readingProps') {
|
||||
line = line.trim();
|
||||
|
||||
if (line == '') {
|
||||
state = 'readingBody';
|
||||
continue;
|
||||
}
|
||||
|
||||
let p = line.indexOf(':');
|
||||
if (p < 0) throw new Error('Invalid property format: ' + line + ": " + content);
|
||||
let key = line.substr(0, p).trim();
|
||||
let value = line.substr(p + 1).trim();
|
||||
output[key] = value;
|
||||
} else if (state == 'readingBody') {
|
||||
body.splice(0, 0, line);
|
||||
}
|
||||
}
|
||||
|
||||
if (!output.type_) throw new Error('Missing required property: type_: ' + content);
|
||||
output.type_ = Number(output.type_);
|
||||
|
||||
if (body.length) {
|
||||
let title = body.splice(0, 2);
|
||||
output.title = title[0];
|
||||
}
|
||||
|
||||
if (output.type_ === BaseModel.TYPE_NOTE) output.body = body.join("\n");
|
||||
|
||||
for (let n in output) {
|
||||
if (!output.hasOwnProperty(n)) continue;
|
||||
output[n] = await this.unserialize_format(output.type_, n, output[n]);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
static async itemsThatNeedSync(syncTarget, limit = 100) {
|
||||
const classNames = this.syncItemClassNames();
|
||||
|
||||
for (let i = 0; i < classNames.length; i++) {
|
||||
const className = classNames[i];
|
||||
const ItemClass = this.getClass(className);
|
||||
let fieldNames = ItemClass.fieldNames('items');
|
||||
|
||||
// // NEVER SYNCED:
|
||||
// 'SELECT * FROM [ITEMS] WHERE id NOT INT (SELECT item_id FROM sync_items WHERE sync_target = ?)'
|
||||
|
||||
// // CHANGED:
|
||||
// 'SELECT * FROM [ITEMS] items JOIN sync_items s ON s.item_id = items.id WHERE sync_target = ? AND'
|
||||
|
||||
let extraWhere = className == 'Note' ? 'AND is_conflict = 0' : '';
|
||||
|
||||
// First get all the items that have never been synced under this sync target
|
||||
|
||||
let sql = sprintf(`
|
||||
SELECT %s
|
||||
FROM %s items
|
||||
WHERE id NOT IN (
|
||||
SELECT item_id FROM sync_items WHERE sync_target = %d
|
||||
)
|
||||
%s
|
||||
LIMIT %d
|
||||
`,
|
||||
this.db().escapeFields(fieldNames),
|
||||
this.db().escapeField(ItemClass.tableName()),
|
||||
Number(syncTarget),
|
||||
extraWhere,
|
||||
limit);
|
||||
|
||||
let neverSyncedItem = await ItemClass.modelSelectAll(sql);
|
||||
|
||||
// Secondly get the items that have been synced under this sync target but that have been changed since then
|
||||
|
||||
const newLimit = limit - neverSyncedItem.length;
|
||||
|
||||
let changedItems = [];
|
||||
|
||||
if (newLimit > 0) {
|
||||
fieldNames.push('sync_time');
|
||||
|
||||
let sql = sprintf(`
|
||||
SELECT %s FROM %s items
|
||||
JOIN sync_items s ON s.item_id = items.id
|
||||
WHERE sync_target = %d
|
||||
AND s.sync_time < items.updated_time
|
||||
%s
|
||||
LIMIT %d
|
||||
`,
|
||||
this.db().escapeFields(fieldNames),
|
||||
this.db().escapeField(ItemClass.tableName()),
|
||||
Number(syncTarget),
|
||||
extraWhere,
|
||||
newLimit);
|
||||
|
||||
changedItems = await ItemClass.modelSelectAll(sql);
|
||||
}
|
||||
|
||||
const items = neverSyncedItem.concat(changedItems);
|
||||
|
||||
if (i >= classNames.length - 1) {
|
||||
return { hasMore: items.length >= limit, items: items };
|
||||
} else {
|
||||
if (items.length) return { hasMore: true, items: items };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Unreachable');
|
||||
}
|
||||
|
||||
static syncItemClassNames() {
|
||||
return BaseItem.syncItemDefinitions_.map((def) => {
|
||||
return def.className;
|
||||
});
|
||||
}
|
||||
|
||||
static syncItemTypes() {
|
||||
return BaseItem.syncItemDefinitions_.map((def) => {
|
||||
return def.type;
|
||||
});
|
||||
}
|
||||
|
||||
static modelTypeToClassName(type) {
|
||||
for (let i = 0; i < BaseItem.syncItemDefinitions_.length; i++) {
|
||||
if (BaseItem.syncItemDefinitions_[i].type == type) return BaseItem.syncItemDefinitions_[i].className;
|
||||
}
|
||||
throw new Error('Invalid type: ' + type);
|
||||
}
|
||||
|
||||
static updateSyncTimeQueries(syncTarget, item, syncTime) {
|
||||
const itemType = item.type_;
|
||||
const itemId = item.id;
|
||||
if (!itemType || !itemId || syncTime === undefined) throw new Error('Invalid parameters in updateSyncTimeQueries()');
|
||||
|
||||
return [
|
||||
{
|
||||
sql: 'DELETE FROM sync_items WHERE sync_target = ? AND item_type = ? AND item_id = ?',
|
||||
params: [syncTarget, itemType, itemId],
|
||||
},
|
||||
{
|
||||
sql: 'INSERT INTO sync_items (sync_target, item_type, item_id, sync_time) VALUES (?, ?, ?, ?)',
|
||||
params: [syncTarget, itemType, itemId, syncTime],
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
static async saveSyncTime(syncTarget, item, syncTime) {
|
||||
const queries = this.updateSyncTimeQueries(syncTarget, item, syncTime);
|
||||
return this.db().transactionExecBatch(queries);
|
||||
}
|
||||
|
||||
static async deleteOrphanSyncItems() {
|
||||
const classNames = this.syncItemClassNames();
|
||||
|
||||
let queries = [];
|
||||
for (let i = 0; i < classNames.length; i++) {
|
||||
const className = classNames[i];
|
||||
const ItemClass = this.getClass(className);
|
||||
|
||||
let selectSql = 'SELECT id FROM ' + ItemClass.tableName();
|
||||
if (ItemClass.modelType() == this.TYPE_NOTE) selectSql += ' WHERE is_conflict = 0';
|
||||
|
||||
queries.push('DELETE FROM sync_items WHERE item_type = ' + ItemClass.modelType() + ' AND item_id NOT IN (' + selectSql + ')');
|
||||
}
|
||||
|
||||
await this.db().transactionExecBatch(queries);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Also update:
|
||||
// - itemsThatNeedSync()
|
||||
// - syncedItems()
|
||||
|
||||
BaseItem.syncItemDefinitions_ = [
|
||||
{ type: BaseModel.TYPE_NOTE, className: 'Note' },
|
||||
{ type: BaseModel.TYPE_FOLDER, className: 'Folder' },
|
||||
{ type: BaseModel.TYPE_RESOURCE, className: 'Resource' },
|
||||
{ type: BaseModel.TYPE_TAG, className: 'Tag' },
|
||||
{ type: BaseModel.TYPE_NOTE_TAG, className: 'NoteTag' },
|
||||
];
|
||||
|
||||
module.exports = { BaseItem };
|
|
@ -1,168 +0,0 @@
|
|||
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 {
|
||||
|
||||
static tableName() {
|
||||
return 'folders';
|
||||
}
|
||||
|
||||
static async serialize(folder) {
|
||||
let fieldNames = this.fieldNames();
|
||||
fieldNames.push('type_');
|
||||
lodash.pull(fieldNames, 'parent_id');
|
||||
return super.serialize(folder, 'folder', fieldNames);
|
||||
}
|
||||
|
||||
static modelType() {
|
||||
return BaseModel.TYPE_FOLDER;
|
||||
}
|
||||
|
||||
static newFolder() {
|
||||
return {
|
||||
id: null,
|
||||
title: '',
|
||||
}
|
||||
}
|
||||
|
||||
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 = [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
let row = rows[i];
|
||||
output.push(row.id);
|
||||
}
|
||||
return output;
|
||||
});
|
||||
}
|
||||
|
||||
static async noteCount(parentId) {
|
||||
let r = await this.db().selectOne('SELECT count(*) as total FROM notes WHERE is_conflict = 0 AND parent_id = ?', [parentId]);
|
||||
return r ? r.total : 0;
|
||||
}
|
||||
|
||||
static markNotesAsConflict(parentId) {
|
||||
let query = Database.updateQuery('notes', { is_conflict: 1 }, { parent_id: parentId });
|
||||
return this.db().exec(query);
|
||||
}
|
||||
|
||||
static async delete(folderId, options = null) {
|
||||
if (!options) options = {};
|
||||
if (!('deleteChildren' in options)) options.deleteChildren = true;
|
||||
|
||||
let folder = await Folder.load(folderId);
|
||||
if (!folder) return; // noop
|
||||
|
||||
if (options.deleteChildren) {
|
||||
let noteIds = await Folder.noteIds(folderId);
|
||||
for (let i = 0; i < noteIds.length; i++) {
|
||||
await Note.delete(noteIds[i]);
|
||||
}
|
||||
}
|
||||
|
||||
await super.delete(folderId, options);
|
||||
|
||||
this.dispatch({
|
||||
type: 'FOLDER_DELETE',
|
||||
id: folderId,
|
||||
});
|
||||
}
|
||||
|
||||
static conflictFolderTitle() {
|
||||
return _('Conflicts');
|
||||
}
|
||||
|
||||
static conflictFolderId() {
|
||||
return 'c04f1c7c04f1c7c04f1c7c04f1c7c04f';
|
||||
}
|
||||
|
||||
static conflictFolder() {
|
||||
return {
|
||||
type_: this.TYPE_FOLDER,
|
||||
id: this.conflictFolderId(),
|
||||
title: this.conflictFolderTitle(),
|
||||
updated_time: time.unixMs(),
|
||||
user_updated_time: time.unixMs(),
|
||||
};
|
||||
}
|
||||
|
||||
static async all(options = null) {
|
||||
let output = await super.all(options);
|
||||
if (options && options.includeConflictFolder) {
|
||||
let conflictCount = await Note.conflictedCount();
|
||||
if (conflictCount) output.push(this.conflictFolder());
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
static load(id) {
|
||||
if (id == this.conflictFolderId()) return this.conflictFolder();
|
||||
return super.load(id);
|
||||
}
|
||||
|
||||
static defaultFolder() {
|
||||
return this.modelSelectOne('SELECT * FROM folders ORDER BY created_time DESC LIMIT 1');
|
||||
}
|
||||
|
||||
// These "duplicateCheck" and "reservedTitleCheck" should only be done when a user is
|
||||
// manually creating a folder. They shouldn't be done for example when the folders
|
||||
// are being synced to avoid any strange side-effects. Technically it's possible to
|
||||
// have folders and notes with duplicate titles (or no title), or with reserved words.
|
||||
static async save(o, options = null) {
|
||||
if (!options) options = {};
|
||||
|
||||
if (options.userSideValidation === true) {
|
||||
if (!('duplicateCheck' in options)) options.duplicateCheck = true;
|
||||
if (!('reservedTitleCheck' in options)) options.reservedTitleCheck = true;
|
||||
if (!('stripLeftSlashes' in options)) options.stripLeftSlashes = true;
|
||||
}
|
||||
|
||||
if (options.stripLeftSlashes === true && o.title) {
|
||||
while (o.title.length && (o.title[0] == '/' || o.title[0] == "\\")) {
|
||||
o.title = o.title.substr(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.duplicateCheck === true && o.title) {
|
||||
let existingFolder = await Folder.loadByTitle(o.title);
|
||||
if (existingFolder && existingFolder.id != o.id) throw new Error(_('A notebook with this title already exists: "%s"', o.title));
|
||||
}
|
||||
|
||||
if (options.reservedTitleCheck === true && o.title) {
|
||||
if (o.title == Folder.conflictFolderTitle()) throw new Error(_('Notebooks cannot be named "%s", which is a reserved title.', o.title));
|
||||
}
|
||||
|
||||
return super.save(o, options).then((folder) => {
|
||||
this.dispatch({
|
||||
type: 'FOLDER_UPDATE_ONE',
|
||||
folder: folder,
|
||||
});
|
||||
return folder;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { Folder };
|
|
@ -1,37 +0,0 @@
|
|||
const { BaseItem } = require('lib/models/base-item.js');
|
||||
const { BaseModel } = require('lib/base-model.js');
|
||||
const lodash = require('lodash');
|
||||
|
||||
class NoteTag extends BaseItem {
|
||||
|
||||
static tableName() {
|
||||
return 'note_tags';
|
||||
}
|
||||
|
||||
static modelType() {
|
||||
return BaseModel.TYPE_NOTE_TAG;
|
||||
}
|
||||
|
||||
static async serialize(item, type = null, shownKeys = null) {
|
||||
let fieldNames = this.fieldNames();
|
||||
fieldNames.push('type_');
|
||||
return super.serialize(item, 'note_tag', fieldNames);
|
||||
}
|
||||
|
||||
static async byNoteIds(noteIds) {
|
||||
if (!noteIds.length) return [];
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { NoteTag };
|
|
@ -1,433 +0,0 @@
|
|||
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 {
|
||||
|
||||
static tableName() {
|
||||
return 'notes';
|
||||
}
|
||||
|
||||
static async serialize(note, type = null, shownKeys = null) {
|
||||
let fieldNames = this.fieldNames();
|
||||
fieldNames.push('type_');
|
||||
return super.serialize(note, 'note', fieldNames);
|
||||
}
|
||||
|
||||
static async serializeForEdit(note) {
|
||||
return super.serialize(note, 'note', ['title', 'body']);
|
||||
}
|
||||
|
||||
static async unserializeForEdit(content) {
|
||||
content += "\n\ntype_: " + BaseModel.TYPE_NOTE;
|
||||
let output = await super.unserialize(content);
|
||||
if (!output.title) output.title = '';
|
||||
if (!output.body) output.body = '';
|
||||
return output;
|
||||
}
|
||||
|
||||
static async serializeAllProps(note) {
|
||||
let fieldNames = this.fieldNames();
|
||||
fieldNames.push('type_');
|
||||
lodash.pull(fieldNames, 'title', 'body');
|
||||
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;
|
||||
|
||||
if (note.body && note.body.length) {
|
||||
const lines = note.body.trim().split("\n");
|
||||
return lines[0].trim().substr(0, 80).trim();
|
||||
}
|
||||
|
||||
return _('Untitled');
|
||||
}
|
||||
|
||||
static geolocationUrl(note) {
|
||||
if (!('latitude' in note) || !('longitude' in note)) throw new Error('Latitude or longitude is missing');
|
||||
if (!Number(note.latitude) && !Number(note.longitude)) throw new Error(_('This note does not have geolocation information.'));
|
||||
return sprintf('https://www.openstreetmap.org/?lat=%s&lon=%s&zoom=20', note.latitude, note.longitude)
|
||||
}
|
||||
|
||||
static modelType() {
|
||||
return BaseModel.TYPE_NOTE;
|
||||
}
|
||||
|
||||
static linkedResourceIds(body) {
|
||||
// For example: ![](:/fcca2938a96a22570e8eae2565bc6b0b)
|
||||
if (!body || body.length <= 32) return [];
|
||||
const matches = body.match(/\(:\/.{32}\)/g);
|
||||
if (!matches) return [];
|
||||
return matches.map((m) => m.substr(3, 32));
|
||||
}
|
||||
|
||||
static new(parentId = '') {
|
||||
let output = super.new();
|
||||
output.parent_id = parentId;
|
||||
return output;
|
||||
}
|
||||
|
||||
static newTodo(parentId = '') {
|
||||
let output = this.new(parentId);
|
||||
output.is_todo = true;
|
||||
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;
|
||||
|
||||
let r = 0;
|
||||
|
||||
for (let i = 0; i < orders.length; i++) {
|
||||
const order = orders[i];
|
||||
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 !== 0) return r;
|
||||
}
|
||||
|
||||
return sortIdenticalNotes(a, b);
|
||||
});
|
||||
}
|
||||
|
||||
static previewFields() {
|
||||
return ['id', 'title', 'body', 'is_todo', 'todo_completed', 'parent_id', 'updated_time', 'user_updated_time'];
|
||||
}
|
||||
|
||||
static previewFieldsSql() {
|
||||
return this.db().escapeFields(this.previewFields()).join(',');
|
||||
}
|
||||
|
||||
static async loadFolderNoteByField(folderId, field, value) {
|
||||
if (!folderId) throw new Error('folderId is undefined');
|
||||
|
||||
let options = {
|
||||
conditions: ['`' + field + '` = ?'],
|
||||
conditionsParams: [value],
|
||||
fields: '*',
|
||||
}
|
||||
|
||||
// TODO: add support for limits on .search()
|
||||
|
||||
let results = await this.previews(folderId, options);
|
||||
return results.length ? results[0] : null;
|
||||
}
|
||||
|
||||
static async previews(parentId, options = null) {
|
||||
// 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' },
|
||||
{ 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 == BaseItem.getClass('Folder').conflictFolderId()) {
|
||||
options.conditions.push('is_conflict = 1');
|
||||
} else {
|
||||
options.conditions.push('is_conflict = 0');
|
||||
if (parentId) {
|
||||
options.conditions.push('parent_id = ?');
|
||||
options.conditionsParams.push(parentId);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.anywherePattern) {
|
||||
let pattern = options.anywherePattern.replace(/\*/g, '%');
|
||||
options.conditions.push('(title LIKE ? OR body LIKE ?)');
|
||||
options.conditionsParams.push(pattern);
|
||||
options.conditionsParams.push(pattern);
|
||||
}
|
||||
|
||||
let hasNotes = true;
|
||||
let hasTodos = true;
|
||||
if (options.itemTypes && options.itemTypes.length) {
|
||||
if (options.itemTypes.indexOf('note') < 0) {
|
||||
hasNotes = false;
|
||||
} else if (options.itemTypes.indexOf('todo') < 0) {
|
||||
hasTodos = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.uncompletedTodosOnTop && hasTodos) {
|
||||
let cond = options.conditions.slice();
|
||||
cond.push('is_todo = 1');
|
||||
cond.push('(todo_completed <= 0 OR todo_completed IS NULL)');
|
||||
let tempOptions = Object.assign({}, options);
|
||||
tempOptions.conditions = cond;
|
||||
|
||||
let uncompletedTodos = await this.search(tempOptions);
|
||||
|
||||
cond = options.conditions.slice();
|
||||
if (hasNotes && hasTodos) {
|
||||
cond.push('(is_todo = 0 OR (is_todo = 1 AND todo_completed > 0))');
|
||||
} else {
|
||||
cond.push('(is_todo = 1 AND todo_completed > 0)');
|
||||
}
|
||||
|
||||
tempOptions = Object.assign({}, options);
|
||||
tempOptions.conditions = cond;
|
||||
if ('limit' in tempOptions) tempOptions.limit -= uncompletedTodos.length;
|
||||
let theRest = await this.search(tempOptions);
|
||||
|
||||
return uncompletedTodos.concat(theRest);
|
||||
}
|
||||
|
||||
if (hasNotes && hasTodos) {
|
||||
|
||||
} else if (hasNotes) {
|
||||
options.conditions.push('is_todo = 0');
|
||||
} else if (hasTodos) {
|
||||
options.conditions.push('is_todo = 1');
|
||||
}
|
||||
|
||||
return this.search(options);
|
||||
}
|
||||
|
||||
static preview(noteId) {
|
||||
return this.modelSelectOne('SELECT ' + this.previewFieldsSql() + ' FROM notes WHERE is_conflict = 0 AND id = ?', [noteId]);
|
||||
}
|
||||
|
||||
static conflictedNotes() {
|
||||
return this.modelSelectAll('SELECT * FROM notes WHERE is_conflict = 1');
|
||||
}
|
||||
|
||||
static async conflictedCount() {
|
||||
let r = await this.db().selectOne('SELECT count(*) as total FROM notes WHERE is_conflict = 1');
|
||||
return r && r.total ? r.total : 0;
|
||||
}
|
||||
|
||||
static unconflictedNotes() {
|
||||
return this.modelSelectAll('SELECT * FROM notes WHERE is_conflict = 0');
|
||||
}
|
||||
|
||||
static async updateGeolocation(noteId) {
|
||||
if (!Setting.value('trackLocation')) return;
|
||||
if (!Note.updateGeolocationEnabled_) return;
|
||||
|
||||
let startWait = time.unixMs();
|
||||
while (true) {
|
||||
if (!this.geolocationUpdating_) break;
|
||||
this.logger().info('Waiting for geolocation update...');
|
||||
await time.sleep(1);
|
||||
if (startWait + 1000 * 20 < time.unixMs()) {
|
||||
this.logger().warn('Failed to update geolocation for: timeout: ' + noteId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let geoData = null;
|
||||
if (this.geolocationCache_ && this.geolocationCache_.timestamp + 1000 * 60 * 10 > time.unixMs()) {
|
||||
geoData = Object.assign({}, this.geolocationCache_);
|
||||
} else {
|
||||
this.geolocationUpdating_ = true;
|
||||
|
||||
this.logger().info('Fetching geolocation...');
|
||||
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.logger().info('Updating lat/long of note ' + noteId);
|
||||
|
||||
let note = await Note.load(noteId);
|
||||
if (!note) return; // Race condition - note has been deleted in the meantime
|
||||
|
||||
note.longitude = geoData.coords.longitude;
|
||||
note.latitude = geoData.coords.latitude;
|
||||
note.altitude = geoData.coords.altitude;
|
||||
return Note.save(note);
|
||||
}
|
||||
|
||||
static filter(note) {
|
||||
if (!note) return note;
|
||||
|
||||
let output = super.filter(note);
|
||||
if ('longitude' in output) output.longitude = Number(!output.longitude ? 0 : output.longitude).toFixed(8);
|
||||
if ('latitude' in output) output.latitude = Number(!output.latitude ? 0 : output.latitude).toFixed(8);
|
||||
if ('altitude' in output) output.altitude = Number(!output.altitude ? 0 : output.altitude).toFixed(4);
|
||||
return output;
|
||||
}
|
||||
|
||||
static async copyToFolder(noteId, folderId) {
|
||||
if (folderId == this.getClass('Folder').conflictFolderId()) throw new Error(_('Cannot copy note to "%s" notebook', this.getClass('Folder').conflictFolderIdTitle()));
|
||||
|
||||
return Note.duplicate(noteId, {
|
||||
changes: {
|
||||
parent_id: folderId,
|
||||
is_conflict: 0, // Also reset the conflict flag in case we're moving the note out of the conflict folder
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async moveToFolder(noteId, folderId) {
|
||||
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.
|
||||
|
||||
const modifiedNote = {
|
||||
id: noteId,
|
||||
parent_id: folderId,
|
||||
is_conflict: 0,
|
||||
updated_time: time.unixMs(),
|
||||
};
|
||||
|
||||
return Note.save(modifiedNote, { autoTimestamp: false });
|
||||
}
|
||||
|
||||
static toggleIsTodo(note) {
|
||||
if (!('is_todo' in note)) throw new Error('Missing "is_todo" property');
|
||||
|
||||
let output = Object.assign({}, note);
|
||||
output.is_todo = output.is_todo ? 0 : 1;
|
||||
output.todo_due = 0;
|
||||
output.todo_completed = 0;
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
static async duplicate(noteId, options = null) {
|
||||
const changes = options && options.changes;
|
||||
|
||||
const originalNote = await Note.load(noteId);
|
||||
if (!originalNote) throw new Error('Unknown note: ' + noteId);
|
||||
|
||||
let newNote = Object.assign({}, originalNote);
|
||||
delete newNote.id;
|
||||
|
||||
for (let n in changes) {
|
||||
if (!changes.hasOwnProperty(n)) continue;
|
||||
newNote[n] = changes[n];
|
||||
}
|
||||
|
||||
return this.save(newNote);
|
||||
}
|
||||
|
||||
static save(o, options = null) {
|
||||
let isNew = this.isNew(o, options);
|
||||
if (isNew && !o.source) o.source = Setting.value('appName');
|
||||
if (isNew && !o.source_application) o.source_application = Setting.value('appId');
|
||||
|
||||
return super.save(o, options).then((note) => {
|
||||
this.dispatch({
|
||||
type: 'NOTE_UPDATE_ONE',
|
||||
note: note,
|
||||
});
|
||||
|
||||
return note;
|
||||
});
|
||||
}
|
||||
|
||||
static async delete(id, options = null) {
|
||||
let r = await super.delete(id, options);
|
||||
|
||||
this.dispatch({
|
||||
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;
|
||||
|
||||
module.exports = { Note };
|
|
@ -1,83 +0,0 @@
|
|||
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');
|
||||
const lodash = require('lodash');
|
||||
|
||||
class Resource extends BaseItem {
|
||||
|
||||
static tableName() {
|
||||
return 'resources';
|
||||
}
|
||||
|
||||
static modelType() {
|
||||
return BaseModel.TYPE_RESOURCE;
|
||||
}
|
||||
|
||||
static isSupportedImageMimeType(type) {
|
||||
const imageMimeTypes = ["image/jpg", "image/jpeg", "image/png", "image/gif"];
|
||||
return imageMimeTypes.indexOf(type.toLowerCase()) >= 0;
|
||||
}
|
||||
|
||||
static fsDriver() {
|
||||
if (!Resource.fsDriver_) Resource.fsDriver_ = new FsDriverDummy();
|
||||
return Resource.fsDriver_;
|
||||
}
|
||||
|
||||
static async serialize(item, type = null, shownKeys = null) {
|
||||
let fieldNames = this.fieldNames();
|
||||
fieldNames.push('type_');
|
||||
return super.serialize(item, 'resource', fieldNames);
|
||||
}
|
||||
|
||||
static fullPath(resource) {
|
||||
let extension = mime.toFileExtension(resource.mime);
|
||||
extension = extension ? '.' + extension : '';
|
||||
return Setting.value('resourceDir') + '/' + resource.id + extension;
|
||||
}
|
||||
|
||||
static markdownTag(resource) {
|
||||
let tagAlt = resource.alt ? resource.alt : resource.title;
|
||||
if (!tagAlt) tagAlt = '';
|
||||
let lines = [];
|
||||
if (Resource.isSupportedImageMimeType(resource.mime)) {
|
||||
lines.push("![");
|
||||
lines.push(markdownUtils.escapeLinkText(tagAlt));
|
||||
lines.push("](:/" + resource.id + ")");
|
||||
} else {
|
||||
lines.push("[");
|
||||
lines.push(markdownUtils.escapeLinkText(tagAlt));
|
||||
lines.push("](:/" + resource.id + ")");
|
||||
}
|
||||
return lines.join('');
|
||||
}
|
||||
|
||||
static pathToId(path) {
|
||||
return filename(path);
|
||||
}
|
||||
|
||||
static async content(resource) {
|
||||
return this.fsDriver().readFile(this.fullPath(resource));
|
||||
}
|
||||
|
||||
static setContent(resource, content) {
|
||||
return this.fsDriver().writeBinaryFile(this.fullPath(resource), content);
|
||||
}
|
||||
|
||||
static isResourceUrl(url) {
|
||||
return url && url.length === 34 && url[0] === ':' && url[1] === '/';
|
||||
}
|
||||
|
||||
static urlToId(url) {
|
||||
if (!this.isResourceUrl(url)) throw new Error('Not a valid resource URL: ' + url);
|
||||
return url.substr(2);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Resource.IMAGE_MAX_DIMENSION = 1920;
|
||||
|
||||
module.exports = { Resource };
|
|
@ -1,405 +0,0 @@
|
|||
const { BaseModel } = require('lib/base-model.js');
|
||||
const { Database } = require('lib/database.js');
|
||||
const { Logger } = require('lib/logger.js');
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const { _, supportedLocalesToLanguages, defaultLocale } = require('lib/locale.js');
|
||||
|
||||
class Setting extends BaseModel {
|
||||
|
||||
static tableName() {
|
||||
return 'settings';
|
||||
}
|
||||
|
||||
static modelType() {
|
||||
return BaseModel.TYPE_SETTING;
|
||||
}
|
||||
|
||||
static settingMetadata(key) {
|
||||
if (!(key in this.metadata_)) throw new Error('Unknown key: ' + key);
|
||||
let output = Object.assign({}, this.metadata_[key]);
|
||||
output.key = key;
|
||||
return output;
|
||||
}
|
||||
|
||||
static keyExists(key) {
|
||||
return key in this.metadata_;
|
||||
}
|
||||
|
||||
static keys(publicOnly = false, appType = null) {
|
||||
if (!this.keys_) {
|
||||
this.keys_ = [];
|
||||
for (let n in this.metadata_) {
|
||||
if (!this.metadata_.hasOwnProperty(n)) continue;
|
||||
this.keys_.push(n);
|
||||
}
|
||||
//this.keys_.sort();
|
||||
}
|
||||
|
||||
if (appType || publicOnly) {
|
||||
let output = [];
|
||||
for (let i = 0; i < this.keys_.length; i++) {
|
||||
const md = this.settingMetadata(this.keys_[i]);
|
||||
if (publicOnly && !md.public) continue;
|
||||
if (appType && md.appTypes && md.appTypes.indexOf(appType) < 0) continue;
|
||||
output.push(md.key);
|
||||
}
|
||||
return output;
|
||||
} else {
|
||||
return this.keys_;
|
||||
}
|
||||
}
|
||||
|
||||
static isPublic(key) {
|
||||
return this.keys(true).indexOf(key) >= 0;
|
||||
}
|
||||
|
||||
static load() {
|
||||
this.cancelScheduleSave();
|
||||
this.cache_ = [];
|
||||
return this.modelSelectAll('SELECT * FROM settings').then((rows) => {
|
||||
this.cache_ = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
let c = rows[i];
|
||||
|
||||
if (!this.keyExists(c.key)) continue;
|
||||
c.value = this.formatValue(c.key, c.value);
|
||||
|
||||
this.cache_.push(c);
|
||||
}
|
||||
|
||||
this.dispatchUpdateAll();
|
||||
});
|
||||
}
|
||||
|
||||
static dispatchUpdateAll() {
|
||||
const keys = this.keys();
|
||||
let keyToValues = {};
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
keyToValues[keys[i]] = this.value(keys[i]);
|
||||
}
|
||||
|
||||
this.dispatch({
|
||||
type: 'SETTING_UPDATE_ALL',
|
||||
settings: keyToValues,
|
||||
});
|
||||
}
|
||||
|
||||
static setConstant(key, value) {
|
||||
if (!(key in this.constants_)) throw new Error('Unknown constant key: ' + key);
|
||||
this.constants_[key] = value;
|
||||
}
|
||||
|
||||
static setValue(key, value) {
|
||||
if (!this.cache_) throw new Error('Settings have not been initialized!');
|
||||
|
||||
value = this.formatValue(key, value);
|
||||
|
||||
for (let i = 0; i < this.cache_.length; i++) {
|
||||
let c = this.cache_[i];
|
||||
if (c.key == key) {
|
||||
const md = this.settingMetadata(key);
|
||||
|
||||
if (md.isEnum === true) {
|
||||
if (!this.isAllowedEnumOption(key, value)) {
|
||||
throw new Error(_('Invalid option value: "%s". Possible values are: %s.', value, this.enumOptionsDoc(key)));
|
||||
}
|
||||
}
|
||||
|
||||
if (c.value === value) return;
|
||||
|
||||
this.logger().info('Setting: ' + key + ' = ' + c.value + ' => ' + value);
|
||||
|
||||
c.value = value;
|
||||
|
||||
this.dispatch({
|
||||
type: 'SETTING_UPDATE_ONE',
|
||||
key: key,
|
||||
value: c.value,
|
||||
});
|
||||
|
||||
this.scheduleSave();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.cache_.push({
|
||||
key: key,
|
||||
value: this.formatValue(key, value),
|
||||
});
|
||||
|
||||
this.dispatch({
|
||||
type: 'SETTING_UPDATE_ONE',
|
||||
key: key,
|
||||
value: this.formatValue(key, value),
|
||||
});
|
||||
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
static valueToString(key, value) {
|
||||
const md = this.settingMetadata(key);
|
||||
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();
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
value = Number(value);
|
||||
}
|
||||
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];
|
||||
if (output == 'SET_ME') throw new Error('Setting constant has not been set: ' + key);
|
||||
return output;
|
||||
}
|
||||
|
||||
if (!this.cache_) throw new Error('Settings have not been initialized!');
|
||||
|
||||
for (let i = 0; i < this.cache_.length; i++) {
|
||||
if (this.cache_[i].key == key) {
|
||||
return this.cache_[i].value;
|
||||
}
|
||||
}
|
||||
|
||||
const md = this.settingMetadata(key);
|
||||
return md.value;
|
||||
}
|
||||
|
||||
static isEnum(key) {
|
||||
const md = this.settingMetadata(key);
|
||||
return md.isEnum === true;
|
||||
}
|
||||
|
||||
static enumOptionValues(key) {
|
||||
const options = this.enumOptions(key);
|
||||
let output = [];
|
||||
for (let n in options) {
|
||||
if (!options.hasOwnProperty(n)) continue;
|
||||
output.push(n);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
static enumOptionLabel(key, value) {
|
||||
const options = this.enumOptions(key);
|
||||
for (let n in options) {
|
||||
if (n == value) return options[n];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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(sprintf(templateString, n, options[n]));
|
||||
}
|
||||
return output.join(', ');
|
||||
}
|
||||
|
||||
static isAllowedEnumOption(key, value) {
|
||||
const options = this.enumOptions(key);
|
||||
return !!options[value];
|
||||
}
|
||||
|
||||
// Currently only supports objects with properties one level deep
|
||||
static object(key) {
|
||||
let output = {};
|
||||
let keys = this.keys();
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
let k = keys[i].split('.');
|
||||
if (k[0] == key) {
|
||||
output[k[1]] = this.value(keys[i]);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
// Currently only supports objects with properties one level deep
|
||||
static setObject(key, object) {
|
||||
for (let n in object) {
|
||||
if (!object.hasOwnProperty(n)) continue;
|
||||
this.setValue(key + '.' + n, object[n]);
|
||||
}
|
||||
}
|
||||
|
||||
static saveAll() {
|
||||
if (!this.saveTimeoutId_) return Promise.resolve();
|
||||
|
||||
this.logger().info('Saving settings...');
|
||||
clearTimeout(this.saveTimeoutId_);
|
||||
this.saveTimeoutId_ = null;
|
||||
|
||||
let queries = [];
|
||||
queries.push('DELETE FROM settings');
|
||||
for (let i = 0; i < this.cache_.length; i++) {
|
||||
let s = Object.assign({}, this.cache_[i]);
|
||||
s.value = this.valueToString(s.key, s.value);
|
||||
queries.push(Database.insertQuery(this.tableName(), s));
|
||||
}
|
||||
|
||||
return BaseModel.db().transactionExecBatch(queries).then(() => {
|
||||
this.logger().info('Settings have been saved.');
|
||||
});
|
||||
}
|
||||
|
||||
static scheduleSave() {
|
||||
if (this.saveTimeoutId_) clearTimeout(this.saveTimeoutId_);
|
||||
|
||||
this.saveTimeoutId_ = setTimeout(() => {
|
||||
this.saveAll();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
static cancelScheduleSave() {
|
||||
if (this.saveTimeoutId_) clearTimeout(this.saveTimeoutId_);
|
||||
this.saveTimeoutId_ = null;
|
||||
}
|
||||
|
||||
static publicSettings(appType) {
|
||||
if (!appType) throw new Error('appType is required');
|
||||
|
||||
let output = {};
|
||||
for (let key in Setting.metadata_) {
|
||||
if (!Setting.metadata_.hasOwnProperty(key)) continue;
|
||||
let s = Object.assign({}, Setting.metadata_[key]);
|
||||
if (!s.public) continue;
|
||||
if (s.appTypes && s.appTypes.indexOf(appType) < 0) continue;
|
||||
s.value = this.value(key);
|
||||
output[key] = s;
|
||||
}
|
||||
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'], 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.target': { value: Setting.SYNC_TARGET_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: () => {
|
||||
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'], 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),
|
||||
};
|
||||
}},
|
||||
'showAdvancedOptions': { value: false, type: Setting.TYPE_BOOL, public: true, appTypes: ['mobile' ], label: () => _('Show advanced options') },
|
||||
'noteVisiblePanes': { value: ['editor', 'viewer'], type: Setting.TYPE_ARRAY, public: false, appTypes: ['desktop'] },
|
||||
};
|
||||
|
||||
// Contains constants that are set by the application and
|
||||
// cannot be modified by the user:
|
||||
Setting.constants_ = {
|
||||
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: '',
|
||||
}
|
||||
|
||||
module.exports = { Setting };
|
|
@ -1,144 +0,0 @@
|
|||
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');
|
||||
const lodash = require('lodash');
|
||||
|
||||
class Tag extends BaseItem {
|
||||
|
||||
static tableName() {
|
||||
return 'tags';
|
||||
}
|
||||
|
||||
static modelType() {
|
||||
return BaseModel.TYPE_TAG;
|
||||
}
|
||||
|
||||
static async serialize(item, type = null, shownKeys = null) {
|
||||
let fieldNames = this.fieldNames();
|
||||
fieldNames.push('type_');
|
||||
return super.serialize(item, 'tag', fieldNames);
|
||||
}
|
||||
|
||||
static async noteIds(tagId) {
|
||||
let rows = await this.db().selectAll('SELECT note_id FROM note_tags WHERE tag_id = ?', [tagId]);
|
||||
let output = [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
output.push(rows[i].note_id);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
static async notes(tagId) {
|
||||
let noteIds = await this.noteIds(tagId);
|
||||
if (!noteIds.length) return [];
|
||||
|
||||
return Note.search({
|
||||
conditions: ['id IN ("' + noteIds.join('","') + '")'],
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
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) {
|
||||
let noteTags = await NoteTag.modelSelectAll('SELECT id FROM note_tags WHERE tag_id = ? and note_id = ?', [tagId, noteId]);
|
||||
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) {
|
||||
let r = await this.db().selectOne('SELECT note_id FROM note_tags WHERE tag_id = ? AND note_id = ? LIMIT 1', [tagId, noteId]);
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { Tag };
|
|
@ -1,33 +0,0 @@
|
|||
const { shim } = require('lib/shim.js');
|
||||
|
||||
const netUtils = {};
|
||||
|
||||
netUtils.ip = async () => {
|
||||
let response = await shim.fetch('https://api.ipify.org/?format=json');
|
||||
if (!response.ok) {
|
||||
throw new Error('Could not retrieve IP: ' + await response.text());
|
||||
}
|
||||
|
||||
let ip = await response.json();
|
||||
return ip.ip;
|
||||
}
|
||||
|
||||
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,270 +0,0 @@
|
|||
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 {
|
||||
|
||||
// `isPublic` is to tell OneDrive whether the application is a "public" one (Mobile and desktop
|
||||
// apps are considered "public"), in which case the secret should not be sent to the API.
|
||||
// In practice the React Native app is public, and the Node one is not because we
|
||||
// use a local server for the OAuth dance.
|
||||
constructor(clientId, clientSecret, isPublic) {
|
||||
this.clientId_ = clientId;
|
||||
this.clientSecret_ = clientSecret;
|
||||
this.auth_ = null;
|
||||
this.isPublic_ = isPublic;
|
||||
this.listeners_ = {
|
||||
'authRefreshed': [],
|
||||
};
|
||||
this.logger_ = new Logger();
|
||||
}
|
||||
|
||||
setLogger(l) {
|
||||
this.logger_ = l;
|
||||
}
|
||||
|
||||
logger() {
|
||||
return this.logger_;
|
||||
}
|
||||
|
||||
isPublic() {
|
||||
return this.isPublic_;
|
||||
}
|
||||
|
||||
dispatch(eventName, param) {
|
||||
let ls = this.listeners_[eventName];
|
||||
for (let i = 0; i < ls.length; i++) {
|
||||
ls[i](param);
|
||||
}
|
||||
}
|
||||
|
||||
on(eventName, callback) {
|
||||
this.listeners_[eventName].push(callback);
|
||||
}
|
||||
|
||||
tokenBaseUrl() {
|
||||
return 'https://login.microsoftonline.com/common/oauth2/v2.0/token';
|
||||
}
|
||||
|
||||
nativeClientRedirectUrl() {
|
||||
return 'https://login.microsoftonline.com/common/oauth2/nativeclient';
|
||||
}
|
||||
|
||||
auth() {
|
||||
return this.auth_;
|
||||
}
|
||||
|
||||
setAuth(auth) {
|
||||
this.auth_ = auth;
|
||||
this.dispatch('authRefreshed', this.auth());
|
||||
}
|
||||
|
||||
token() {
|
||||
return this.auth_ ? this.auth_.access_token : null;
|
||||
}
|
||||
|
||||
clientId() {
|
||||
return this.clientId_;
|
||||
}
|
||||
|
||||
clientSecret() {
|
||||
return this.clientSecret_;
|
||||
}
|
||||
|
||||
async appDirectory() {
|
||||
let r = await this.execJson('GET', '/drive/special/approot');
|
||||
return r.parentReference.path + '/' + r.name;
|
||||
}
|
||||
|
||||
authCodeUrl(redirectUri) {
|
||||
let query = {
|
||||
client_id: this.clientId_,
|
||||
scope: 'files.readwrite offline_access',
|
||||
response_type: 'code',
|
||||
redirect_uri: redirectUri,
|
||||
};
|
||||
return 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?' + stringify(query);
|
||||
}
|
||||
|
||||
async execTokenRequest(code, redirectUri) {
|
||||
let body = new shim.FormData();
|
||||
body.append('client_id', this.clientId());
|
||||
if (!this.isPublic()) body.append('client_secret', this.clientSecret());
|
||||
body.append('code', code);
|
||||
body.append('redirect_uri', redirectUri);
|
||||
body.append('grant_type', 'authorization_code');
|
||||
|
||||
const r = await shim.fetch(this.tokenBaseUrl(), {
|
||||
method: 'POST',
|
||||
body: body,
|
||||
})
|
||||
|
||||
if (!r.ok) {
|
||||
const text = await r.text();
|
||||
throw new Error('Could not retrieve auth code: ' + r.status + ': ' + r.statusText + ': ' + text);
|
||||
}
|
||||
|
||||
try {
|
||||
const json = await r.json();
|
||||
this.setAuth(json);
|
||||
} catch (error) {
|
||||
this.setAuth(null);
|
||||
const text = await r.text();
|
||||
error.message += ': ' + text;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
oneDriveErrorResponseToError(errorResponse) {
|
||||
if (!errorResponse) return new Error('Undefined error');
|
||||
|
||||
if (errorResponse.error) {
|
||||
let e = errorResponse.error;
|
||||
let output = new Error(e.message);
|
||||
if (e.code) output.code = e.code;
|
||||
if (e.innerError) output.innerError = e.innerError;
|
||||
return output;
|
||||
} else {
|
||||
return new Error(JSON.stringify(errorResponse));
|
||||
}
|
||||
}
|
||||
|
||||
async exec(method, path, query = null, data = null, options = null) {
|
||||
if (!path) throw new Error('Path is required');
|
||||
|
||||
method = method.toUpperCase();
|
||||
|
||||
if (!options) options = {};
|
||||
if (!options.headers) options.headers = {};
|
||||
if (!options.target) options.target = 'string';
|
||||
|
||||
if (method != 'GET') {
|
||||
options.method = method;
|
||||
}
|
||||
|
||||
if (method == 'PATCH' || method == 'POST') {
|
||||
options.headers['Content-Type'] = 'application/json';
|
||||
if (data) data = JSON.stringify(data);
|
||||
}
|
||||
|
||||
let url = path;
|
||||
|
||||
// In general, `path` contains a path relative to the base URL, but in some
|
||||
// cases the full URL is provided (for example, when it's a URL that was
|
||||
// retrieved from the API).
|
||||
if (url.indexOf('https://') !== 0) url = 'https://graph.microsoft.com/v1.0' + path;
|
||||
|
||||
if (query) {
|
||||
url += url.indexOf('?') < 0 ? '?' : '&';
|
||||
url += stringify(query);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
let response = null;
|
||||
try {
|
||||
if (options.source == 'file' && (method == 'POST' || method == 'PUT')) {
|
||||
response = await shim.uploadBlob(url, options);
|
||||
} else if (options.target == 'string') {
|
||||
response = await shim.fetch(url, options);
|
||||
} else { // file
|
||||
response = await shim.fetchBlob(url, options);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger().error('Got unhandled error:', error ? error.code : '', error ? error.message : '', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let errorResponse = await response.json();
|
||||
let error = this.oneDriveErrorResponseToError(errorResponse);
|
||||
|
||||
if (error.code == 'InvalidAuthenticationToken' || error.code == 'unauthenticated') {
|
||||
this.logger().info('Token expired: refreshing...');
|
||||
await this.refreshAccessToken();
|
||||
continue;
|
||||
} else if (error && ((error.error && error.error.code == 'generalException') || error.code == 'generalException' || error.code == 'EAGAIN')) {
|
||||
// Rare error (one Google hit) - I guess the request can be repeated
|
||||
// { error:
|
||||
// { code: 'generalException',
|
||||
// message: 'An error occurred in the data store.',
|
||||
// innerError:
|
||||
// { 'request-id': 'b4310552-c18a-45b1-bde1-68e2c2345eef',
|
||||
// date: '2017-06-29T00:15:50' } } }
|
||||
|
||||
// { FetchError: request to https://graph.microsoft.com/v1.0/drive/root:/Apps/Joplin/.sync/7ee5dc04afcb414aa7c684bfc1edba8b.md_1499352102856 failed, reason: connect EAGAIN 65.52.64.250:443 - Local (0.0.0.0:54374)
|
||||
// name: 'FetchError',
|
||||
// message: 'request to https://graph.microsoft.com/v1.0/drive/root:/Apps/Joplin/.sync/7ee5dc04afcb414aa7c684bfc1edba8b.md_1499352102856 failed, reason: connect EAGAIN 65.52.64.250:443 - Local (0.0.0.0:54374)',
|
||||
// type: 'system',
|
||||
// errno: 'EAGAIN',
|
||||
// code: 'EAGAIN' }
|
||||
this.logger().info('Got error below - retrying (' + i + ')...');
|
||||
this.logger().info(error);
|
||||
await time.sleep((i + 1) * 3);
|
||||
continue;
|
||||
} else if (error.code == 'itemNotFound' && method == 'DELETE') {
|
||||
// Deleting a non-existing item is ok - noop
|
||||
return;
|
||||
} else {
|
||||
error.request = method + ' ' + url + ' ' + JSON.stringify(query) + ' ' + JSON.stringify(data) + ' ' + JSON.stringify(options);
|
||||
error.headers = await response.headers;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
throw new Error('Could not execute request after multiple attempts: ' + method + ' ' + url);
|
||||
}
|
||||
|
||||
async execJson(method, path, query, data) {
|
||||
let response = await this.exec(method, path, query, data);
|
||||
let output = await response.json();
|
||||
return output;
|
||||
}
|
||||
|
||||
async execText(method, path, query, data) {
|
||||
let response = await this.exec(method, path, query, data);
|
||||
let output = await response.text();
|
||||
return output;
|
||||
}
|
||||
|
||||
async refreshAccessToken() {
|
||||
if (!this.auth_ || !this.auth_.refresh_token) {
|
||||
this.setAuth(null);
|
||||
throw new Error(_('Cannot refresh token: authentication data is missing. Starting the synchronisation again may fix the problem.'));
|
||||
}
|
||||
|
||||
let body = new shim.FormData();
|
||||
body.append('client_id', this.clientId());
|
||||
if (!this.isPublic()) body.append('client_secret', this.clientSecret());
|
||||
body.append('refresh_token', this.auth_.refresh_token);
|
||||
body.append('redirect_uri', 'http://localhost:1917');
|
||||
body.append('grant_type', 'refresh_token');
|
||||
|
||||
let options = {
|
||||
method: 'POST',
|
||||
body: body,
|
||||
};
|
||||
|
||||
let response = await shim.fetch(this.tokenBaseUrl(), options);
|
||||
if (!response.ok) {
|
||||
this.setAuth(null);
|
||||
let msg = await response.text();
|
||||
throw new Error(msg + ': TOKEN: ' + this.auth_);
|
||||
}
|
||||
|
||||
let auth = await response.json();
|
||||
this.setAuth(auth);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { OneDriveApi };
|
|
@ -1 +0,0 @@
|
|||
{ "name": "lib" }
|
|
@ -1,35 +0,0 @@
|
|||
const { Setting } = require('lib/models/setting.js');
|
||||
|
||||
const parameters_ = {};
|
||||
|
||||
parameters_.dev = {
|
||||
oneDrive: {
|
||||
id: 'cbabb902-d276-4ea4-aa88-062a5889d6dc',
|
||||
secret: 'YSvrgQMqw9NzVqgiLfuEky1',
|
||||
},
|
||||
oneDriveDemo: {
|
||||
id: '606fd4d7-4dfb-4310-b8b7-a47d96aa22b6',
|
||||
secret: 'qabchuPYL7931$ePDEQ3~_$',
|
||||
},
|
||||
};
|
||||
|
||||
parameters_.prod = {
|
||||
oneDrive: {
|
||||
id: 'e09fc0de-c958-424f-83a2-e56a721d331b',
|
||||
secret: 'JA3cwsqSGHFtjMwd5XoF5L5',
|
||||
},
|
||||
oneDriveDemo: {
|
||||
id: '606fd4d7-4dfb-4310-b8b7-a47d96aa22b6',
|
||||
secret: 'qabchuPYL7931$ePDEQ3~_$',
|
||||
},
|
||||
};
|
||||
|
||||
function parameters() {
|
||||
let output = parameters_[Setting.value('env')];
|
||||
if (Setting.value('isDemo')) {
|
||||
output.oneDrive = output.oneDriveDemo;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
module.exports = { parameters };
|
|
@ -1,38 +0,0 @@
|
|||
function dirname(path) {
|
||||
if (!path) throw new Error('Path is empty');
|
||||
let s = path.split(/\/|\\/);
|
||||
s.pop();
|
||||
return s.join('/');
|
||||
}
|
||||
|
||||
function basename(path) {
|
||||
if (!path) throw new Error('Path is empty');
|
||||
let s = path.split(/\/|\\/);
|
||||
return s[s.length - 1];
|
||||
}
|
||||
|
||||
function filename(path) {
|
||||
if (!path) throw new Error('Path is empty');
|
||||
let output = basename(path);
|
||||
if (output.indexOf('.') < 0) return output;
|
||||
|
||||
output = output.split('.');
|
||||
output.pop();
|
||||
return output.join('.');
|
||||
}
|
||||
|
||||
function fileExtension(path) {
|
||||
if (!path) throw new Error('Path is empty');
|
||||
|
||||
let output = path.split('.');
|
||||
if (output.length <= 1) return '';
|
||||
return output[output.length - 1];
|
||||
}
|
||||
|
||||
function isHidden(path) {
|
||||
let b = basename(path);
|
||||
if (!b.length) throw new Error('Path empty or not a valid path: ' + path);
|
||||
return b[0] === '.';
|
||||
}
|
||||
|
||||
module.exports = { basename, dirname, filename, isHidden, fileExtension };
|
|
@ -1,56 +0,0 @@
|
|||
const { time } = require('lib/time-utils.js');
|
||||
|
||||
class PoorManIntervals {
|
||||
|
||||
static setInterval(callback, interval) {
|
||||
PoorManIntervals.intervalId_++;
|
||||
|
||||
PoorManIntervals.intervals_.push({
|
||||
id: PoorManIntervals.intervalId_,
|
||||
callback: callback,
|
||||
interval: interval,
|
||||
lastIntervalTime: time.unixMs(),
|
||||
});
|
||||
|
||||
return PoorManIntervals.intervalId_;
|
||||
}
|
||||
|
||||
static intervalById(id) {
|
||||
for (let i = 0; i < PoorManIntervals.intervals_.length; i++) {
|
||||
if (PoorManIntervals.intervals_[i].id == id) return PoorManIntervals.intervals_[id];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static clearInterval(id) {
|
||||
for (let i = 0; i < PoorManIntervals.intervals_.length; i++) {
|
||||
if (PoorManIntervals.intervals_[i].id == id) {
|
||||
PoorManIntervals.intervals_.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static update() {
|
||||
// Don't update more than once a second
|
||||
if (PoorManIntervals.lastUpdateTime_ + 1000 > time.unixMs()) return;
|
||||
|
||||
for (let i = 0; i < PoorManIntervals.intervals_.length; i++) {
|
||||
let interval = PoorManIntervals.intervals_[i];
|
||||
const now = time.unixMs();
|
||||
if (now - interval.lastIntervalTime >= interval.interval) {
|
||||
interval.lastIntervalTime = now;
|
||||
interval.callback();
|
||||
}
|
||||
}
|
||||
|
||||
PoorManIntervals.lastUpdateTime_ = time.unixMs();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
PoorManIntervals.lastUpdateTime_ = 0;
|
||||
PoorManIntervals.intervalId_ = 0;
|
||||
PoorManIntervals.intervals_ = [];
|
||||
|
||||
module.exports = { PoorManIntervals };
|
|
@ -1,37 +0,0 @@
|
|||
function promiseChain(chain, defaultValue = null) {
|
||||
let output = new Promise((resolve, reject) => { resolve(defaultValue); });
|
||||
for (let i = 0; i < chain.length; i++) {
|
||||
let f = chain[i];
|
||||
output = output.then(f);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function promiseWhile(callback) {
|
||||
let isDone = false;
|
||||
|
||||
function done() {
|
||||
isDone = true;
|
||||
}
|
||||
|
||||
let iterationDone = false;
|
||||
let p = callback(done).then(() => {
|
||||
iterationDone = true;
|
||||
});
|
||||
|
||||
let iid = setInterval(() => {
|
||||
if (iterationDone) {
|
||||
if (isDone) {
|
||||
clearInterval(iid);
|
||||
return;
|
||||
}
|
||||
|
||||
iterationDone = false;
|
||||
callback(done).then(() => {
|
||||
iterationDone = true;
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
module.exports = { promiseChain, promiseWhile };
|
|
@ -1,9 +0,0 @@
|
|||
const { Logger } = require('lib/logger.js');
|
||||
|
||||
class ReactLogger extends Logger {
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
module.exports = { ReactLogger };
|
|
@ -1,338 +0,0 @@
|
|||
const { Note } = require('lib/models/note.js');
|
||||
const { Folder } = require('lib/models/folder.js');
|
||||
|
||||
const defaultState = {
|
||||
notes: [],
|
||||
notesSource: '',
|
||||
notesParentType: null,
|
||||
folders: [],
|
||||
tags: [],
|
||||
searches: [],
|
||||
selectedNoteId: null,
|
||||
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', 'selectedNoteId'],
|
||||
'TAG_DELETE': ['tags', 'selectedTagId'],
|
||||
};
|
||||
|
||||
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 newIndex = previousIndex >= 0 ? newItems[previousIndex].id : null;
|
||||
newState[selectedItemKey] = newIndex;
|
||||
|
||||
if (!newIndex && 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;
|
||||
}
|
||||
|
||||
const reducer = (state = defaultState, action) => {
|
||||
let newState = state;
|
||||
|
||||
try {
|
||||
switch (action.type) {
|
||||
|
||||
case 'NOTE_SELECT':
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
newState.selectedNoteId = action.id;
|
||||
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.selectedNoteId = 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_REMOVE':
|
||||
|
||||
let foundIndex = -1;
|
||||
for (let i = 0; i < state.searches.length; i++) {
|
||||
if (state.searches[i].id === action.id) {
|
||||
foundIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundIndex >= 0) {
|
||||
newState = Object.assign({}, state);
|
||||
let newSearches = newState.searches.slice();
|
||||
newSearches.splice(foundIndex, 1);
|
||||
newState.searches = newSearches;
|
||||
}
|
||||
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,231 +0,0 @@
|
|||
const { Logger } = require('lib/logger.js');
|
||||
const { Setting } = require('lib/models/setting.js');
|
||||
const { OneDriveApi } = require('lib/onedrive-api.js');
|
||||
const { parameters } = require('lib/parameters.js');
|
||||
const { FileApi } = require('lib/file-api.js');
|
||||
const { Database } = require('lib/database.js');
|
||||
const { Synchronizer } = require('lib/synchronizer.js');
|
||||
const { FileApiDriverOneDrive } = require('lib/file-api-driver-onedrive.js');
|
||||
const { shim } = require('lib/shim.js');
|
||||
const { time } = require('lib/time-utils.js');
|
||||
const { FileApiDriverMemory } = require('lib/file-api-driver-memory.js');
|
||||
const { _ } = require('lib/locale.js');
|
||||
|
||||
const reg = {};
|
||||
|
||||
reg.initSynchronizerStates_ = {};
|
||||
reg.synchronizers_ = {};
|
||||
|
||||
reg.logger = () => {
|
||||
if (!reg.logger_) {
|
||||
//console.warn('Calling logger before it is initialized');
|
||||
return new Logger();
|
||||
}
|
||||
|
||||
return reg.logger_;
|
||||
}
|
||||
|
||||
reg.setLogger = (l) => {
|
||||
reg.logger_ = l;
|
||||
}
|
||||
|
||||
reg.oneDriveApi = () => {
|
||||
if (reg.oneDriveApi_) return reg.oneDriveApi_;
|
||||
|
||||
const isPublic = Setting.value('appType') != 'cli';
|
||||
|
||||
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_[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;
|
||||
}
|
||||
|
||||
reg.scheduleSync = async (delay = null) => {
|
||||
if (delay === null) delay = 1000 * 3;
|
||||
|
||||
if (reg.scheduleSyncId_) {
|
||||
clearTimeout(reg.scheduleSyncId_);
|
||||
reg.scheduleSyncId_ = 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)) {
|
||||
reg.logger().info('Synchroniser is missing credentials - manual sync required to authenticate.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sync = await reg.synchronizer(syncTargetId);
|
||||
|
||||
const contextKey = 'sync.' + syncTargetId + '.context';
|
||||
let context = Setting.value(contextKey);
|
||||
context = context ? JSON.parse(context) : {};
|
||||
try {
|
||||
let newContext = await sync.start({ context: context });
|
||||
Setting.setValue(contextKey, JSON.stringify(newContext));
|
||||
} catch (error) {
|
||||
if (error.code == 'alreadyStarted') {
|
||||
reg.logger().info(error.message);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
reg.logger().info('Could not run background sync: ');
|
||||
reg.logger().info(error);
|
||||
}
|
||||
|
||||
reg.setupRecurrentSync();
|
||||
};
|
||||
|
||||
if (delay === 0) {
|
||||
timeoutCallback();
|
||||
} else {
|
||||
reg.scheduleSyncId_ = setTimeout(timeoutCallback, delay);
|
||||
}
|
||||
}
|
||||
|
||||
reg.syncStarted = async () => {
|
||||
const syncTarget = Setting.value('sync.target');
|
||||
if (!reg.synchronizers_[syncTarget]) return false;
|
||||
if (!reg.syncHasAuth(syncTarget)) return false;
|
||||
const sync = await reg.synchronizer(syncTarget);
|
||||
return sync.state() != 'idle';
|
||||
}
|
||||
|
||||
reg.setupRecurrentSync = () => {
|
||||
if (reg.recurrentSyncId_) {
|
||||
shim.clearInterval(reg.recurrentSyncId_);
|
||||
reg.recurrentSyncId_ = null;
|
||||
}
|
||||
|
||||
if (!Setting.value('sync.interval')) {
|
||||
reg.logger().debug('Recurrent sync is disabled');
|
||||
} else {
|
||||
reg.logger().debug('Setting up recurrent sync with interval ' + Setting.value('sync.interval'));
|
||||
|
||||
reg.recurrentSyncId_ = shim.setInterval(() => {
|
||||
reg.logger().info('Running background sync on timer...');
|
||||
reg.scheduleSync(0);
|
||||
}, 1000 * Setting.value('sync.interval'));
|
||||
}
|
||||
}
|
||||
|
||||
reg.setDb = (v) => {
|
||||
reg.db_ = v;
|
||||
}
|
||||
|
||||
reg.db = () => {
|
||||
return reg.db_;
|
||||
}
|
||||
|
||||
module.exports = { reg };
|
|
@ -1,43 +0,0 @@
|
|||
const { BackHandler } = require('react-native');
|
||||
|
||||
class BackButtonService {
|
||||
|
||||
static initialize(defaultHandler) {
|
||||
this.defaultHandler_ = defaultHandler;
|
||||
|
||||
BackHandler.addEventListener('hardwareBackPress', async () => {
|
||||
return this.back();
|
||||
});
|
||||
}
|
||||
|
||||
static async back() {
|
||||
if (this.handlers_.length) {
|
||||
let r = await this.handlers_[this.handlers_.length - 1]();
|
||||
if (r) return r;
|
||||
}
|
||||
|
||||
return await this.defaultHandler_();
|
||||
}
|
||||
|
||||
static addHandler(handler) {
|
||||
for (let i = this.handlers_.length - 1; i >= 0; i--) {
|
||||
const h = this.handlers_[i];
|
||||
if (h === handler) return;
|
||||
}
|
||||
|
||||
return this.handlers_.push(handler);
|
||||
}
|
||||
|
||||
static removeHandler(hanlder) {
|
||||
for (let i = this.handlers_.length - 1; i >= 0; i--) {
|
||||
const h = this.handlers_[i];
|
||||
if (h === hanlder) this.handlers_.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
BackButtonService.defaultHandler_ = null;
|
||||
BackButtonService.handlers_ = [];
|
||||
|
||||
module.exports = { BackButtonService };
|
|
@ -1,96 +0,0 @@
|
|||
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 {
|
||||
|
||||
async export(options) {
|
||||
const destDir = options.destDir ? options.destDir : null;
|
||||
const resourceDir = destDir ? destDir + '/resources' : null;
|
||||
const writeFile = options.writeFile ? options.writeFile : null;
|
||||
const copyFile = options.copyFile ? options.copyFile : null;
|
||||
const sourceFolderIds = options.sourceFolderIds ? options.sourceFolderIds : [];
|
||||
const sourceNoteIds = options.sourceNoteIds ? options.sourceNoteIds : [];
|
||||
|
||||
let result = {
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
await fs.mkdirp(destDir);
|
||||
await fs.mkdirp(resourceDir);
|
||||
|
||||
const exportItem = async (itemType, itemOrId) => {
|
||||
const ItemClass = BaseItem.getClassByItemType(itemType);
|
||||
const item = typeof itemOrId === 'object' ? itemOrId : await ItemClass.load(itemOrId);
|
||||
|
||||
if (!item) {
|
||||
result.warnings.push('Cannot find item with type ' + itemType + ' and ID ' + JSON.stringify(itemOrId));
|
||||
return;
|
||||
}
|
||||
|
||||
const serialized = await ItemClass.serialize(item);
|
||||
const filePath = destDir + '/' + ItemClass.systemPath(item);
|
||||
await writeFile(filePath, serialized);
|
||||
|
||||
if (itemType == BaseModel.TYPE_RESOURCE) {
|
||||
const sourceResourcePath = Resource.fullPath(item);
|
||||
const destResourcePath = resourceDir + '/' + basename(sourceResourcePath);
|
||||
await copyFile(sourceResourcePath, destResourcePath);
|
||||
}
|
||||
}
|
||||
|
||||
let exportedNoteIds = [];
|
||||
let resourceIds = [];
|
||||
const folderIds = await Folder.allIds();
|
||||
|
||||
for (let folderIndex = 0; folderIndex < folderIds.length; folderIndex++) {
|
||||
const folderId = folderIds[folderIndex];
|
||||
if (sourceFolderIds.length && sourceFolderIds.indexOf(folderId) < 0) continue;
|
||||
|
||||
if (!sourceNoteIds.length) await exportItem(BaseModel.TYPE_FOLDER, folderId);
|
||||
|
||||
const noteIds = await Folder.noteIds(folderId);
|
||||
|
||||
for (let noteIndex = 0; noteIndex < noteIds.length; noteIndex++) {
|
||||
const noteId = noteIds[noteIndex];
|
||||
if (sourceNoteIds.length && sourceNoteIds.indexOf(noteId) < 0) continue;
|
||||
const note = await Note.load(noteId);
|
||||
await exportItem(BaseModel.TYPE_NOTE, note);
|
||||
exportedNoteIds.push(noteId);
|
||||
|
||||
const rids = Note.linkedResourceIds(note.body);
|
||||
resourceIds = resourceIds.concat(rids);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < resourceIds.length; i++) {
|
||||
await exportItem(BaseModel.TYPE_RESOURCE, resourceIds[i]);
|
||||
}
|
||||
|
||||
const noteTags = await NoteTag.all();
|
||||
|
||||
let exportedTagIds = [];
|
||||
|
||||
for (let i = 0; i < noteTags.length; i++) {
|
||||
const noteTag = noteTags[i];
|
||||
if (exportedNoteIds.indexOf(noteTag.note_id) < 0) continue;
|
||||
await exportItem(BaseModel.TYPE_NOTE_TAG, noteTag.id);
|
||||
exportedTagIds.push(noteTag.tag_id);
|
||||
}
|
||||
|
||||
for (let i = 0; i < exportedTagIds.length; i++) {
|
||||
await exportItem(BaseModel.TYPE_TAG, exportedTagIds[i]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { Exporter };
|
|
@ -1,90 +0,0 @@
|
|||
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 {
|
||||
|
||||
async syncStatus(syncTarget) {
|
||||
let output = {
|
||||
items: {},
|
||||
total: {},
|
||||
};
|
||||
|
||||
let itemCount = 0;
|
||||
let syncedCount = 0;
|
||||
for (let i = 0; i < BaseItem.syncItemDefinitions_.length; i++) {
|
||||
let d = BaseItem.syncItemDefinitions_[i];
|
||||
let ItemClass = BaseItem.getClass(d.className);
|
||||
let o = {
|
||||
total: await ItemClass.count(),
|
||||
synced: await ItemClass.syncedCount(syncTarget),
|
||||
};
|
||||
output.items[d.className] = o;
|
||||
itemCount += o.total;
|
||||
syncedCount += o.synced;
|
||||
}
|
||||
|
||||
let conflictedCount = await Note.conflictedCount();
|
||||
|
||||
output.total = {
|
||||
total: itemCount - conflictedCount,
|
||||
synced: syncedCount,
|
||||
};
|
||||
|
||||
output.toDelete = {
|
||||
total: await BaseItem.deletedItemCount(syncTarget),
|
||||
};
|
||||
|
||||
output.conflicted = {
|
||||
total: await Note.conflictedCount(),
|
||||
};
|
||||
|
||||
output.items['Note'].total -= output.conflicted.total;
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
async status(syncTarget) {
|
||||
let r = await this.syncStatus(syncTarget);
|
||||
let sections = [];
|
||||
let section = {};
|
||||
|
||||
section.title = _('Sync status (synced items / total items)');
|
||||
section.body = [];
|
||||
|
||||
for (let n in r.items) {
|
||||
if (!r.items.hasOwnProperty(n)) continue;
|
||||
section.body.push(_('%s: %d/%d', n, r.items[n].synced, r.items[n].total));
|
||||
}
|
||||
|
||||
section.body.push(_('Total: %d/%d', r.total.synced, r.total.total));
|
||||
section.body.push('');
|
||||
section.body.push(_('Conflicted: %d', r.conflicted.total));
|
||||
section.body.push(_('To delete: %d', r.toDelete.total));
|
||||
|
||||
sections.push(section);
|
||||
|
||||
section = {};
|
||||
section.title = _('Folders');
|
||||
section.body = [];
|
||||
|
||||
let folders = await Folder.all({
|
||||
order: { by: 'title', dir: 'ASC' },
|
||||
caseInsensitive: true,
|
||||
});
|
||||
|
||||
for (let i = 0; i < folders.length; i++) {
|
||||
let folder = folders[i];
|
||||
section.body.push(_('%s: %d notes', folders[i].title, await Folder.noteCount(folders[i].id)));
|
||||
}
|
||||
|
||||
sections.push(section);
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { ReportService };
|
|
@ -1,226 +0,0 @@
|
|||
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.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;
|
||||
|
||||
url = urlParse(url.trim());
|
||||
const http = url.protocol.toLowerCase() == 'http:' ? require('follow-redirects').http : require('follow-redirects').https;
|
||||
const headers = options.headers ? options.headers : {};
|
||||
const method = options.method ? options.method : 'GET';
|
||||
if (method != 'GET') throw new Error('Only GET is supported');
|
||||
const filePath = options.path;
|
||||
|
||||
function makeResponse(response) {
|
||||
return {
|
||||
ok: response.statusCode < 400,
|
||||
path: filePath,
|
||||
text: () => { return response.statusMessage; },
|
||||
json: () => { return { message: response.statusCode + ': ' + response.statusMessage }; },
|
||||
status: response.statusCode,
|
||||
headers: response.headers,
|
||||
};
|
||||
}
|
||||
|
||||
const requestOptions = {
|
||||
protocol: url.protocol,
|
||||
host: url.host,
|
||||
port: url.port,
|
||||
method: method,
|
||||
path: url.path + (url.query ? '?' + url.query : ''),
|
||||
headers: headers,
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
file.on('finish', function() {
|
||||
file.close(() => {
|
||||
resolve(makeResponse(response));
|
||||
});
|
||||
});
|
||||
})
|
||||
|
||||
request.on('error', function(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;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { shimInit };
|
|
@ -1,106 +0,0 @@
|
|||
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');
|
||||
|
||||
let headers = options.headers ? options.headers : {};
|
||||
let method = options.method ? options.method : 'GET';
|
||||
|
||||
let dirs = RNFetchBlob.fs.dirs;
|
||||
let localFilePath = options.path;
|
||||
if (localFilePath.indexOf('/') !== 0) localFilePath = dirs.DocumentDir + '/' + localFilePath;
|
||||
|
||||
delete options.path;
|
||||
|
||||
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 = {
|
||||
ok: response.respInfo.status < 400,
|
||||
path: response.data,
|
||||
text: response.text,
|
||||
json: response.json,
|
||||
status: response.respInfo.status,
|
||||
headers: response.respInfo.headers,
|
||||
};
|
||||
|
||||
return output;
|
||||
} catch (error) {
|
||||
throw new Error('fetchBlob: ' + method + ' ' + url + ': ' + error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
shim.uploadBlob = async function(url, options) {
|
||||
if (!options || !options.path) throw new Error('uploadBlob: source file path is missing');
|
||||
|
||||
const headers = options.headers ? options.headers : {};
|
||||
const method = options.method ? options.method : 'POST';
|
||||
|
||||
try {
|
||||
let response = await RNFetchBlob.fetch(method, url, headers, RNFetchBlob.wrap(options.path));
|
||||
|
||||
// Returns an object that's roughtly compatible with a standard Response object
|
||||
return {
|
||||
ok: response.respInfo.status < 400,
|
||||
data: response.data,
|
||||
text: response.text,
|
||||
json: response.json,
|
||||
status: response.respInfo.status,
|
||||
headers: response.respInfo.headers,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error('uploadBlob: ' + method + ' ' + url + ': ' + error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
shim.readLocalFileBase64 = async function(path) {
|
||||
return RNFetchBlob.fs.readFile(path, 'base64')
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { shimInit };
|
|
@ -1,123 +0,0 @@
|
|||
let shim = {};
|
||||
|
||||
shim.isNode = () => {
|
||||
if (typeof process === 'undefined') return false;
|
||||
if (shim.isElectron()) return true;
|
||||
return process.title == 'node';
|
||||
};
|
||||
|
||||
shim.isReactNative = () => {
|
||||
return !shim.isNode();
|
||||
};
|
||||
|
||||
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 = (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) => {}
|
||||
|
||||
module.exports = { shim };
|
|
@ -1,125 +0,0 @@
|
|||
function removeDiacritics(str) {
|
||||
|
||||
var defaultDiacriticsRemovalMap = [
|
||||
{'base':'A', 'letters':/[\u0041\u24B6\uFF21\u00C0\u00C1\u00C2\u1EA6\u1EA4\u1EAA\u1EA8\u00C3\u0100\u0102\u1EB0\u1EAE\u1EB4\u1EB2\u0226\u01E0\u00C4\u01DE\u1EA2\u00C5\u01FA\u01CD\u0200\u0202\u1EA0\u1EAC\u1EB6\u1E00\u0104\u023A\u2C6F]/g},
|
||||
{'base':'AA','letters':/[\uA732]/g},
|
||||
{'base':'AE','letters':/[\u00C6\u01FC\u01E2]/g},
|
||||
{'base':'AO','letters':/[\uA734]/g},
|
||||
{'base':'AU','letters':/[\uA736]/g},
|
||||
{'base':'AV','letters':/[\uA738\uA73A]/g},
|
||||
{'base':'AY','letters':/[\uA73C]/g},
|
||||
{'base':'B', 'letters':/[\u0042\u24B7\uFF22\u1E02\u1E04\u1E06\u0243\u0182\u0181]/g},
|
||||
{'base':'C', 'letters':/[\u0043\u24B8\uFF23\u0106\u0108\u010A\u010C\u00C7\u1E08\u0187\u023B\uA73E]/g},
|
||||
{'base':'D', 'letters':/[\u0044\u24B9\uFF24\u1E0A\u010E\u1E0C\u1E10\u1E12\u1E0E\u0110\u018B\u018A\u0189\uA779]/g},
|
||||
{'base':'DZ','letters':/[\u01F1\u01C4]/g},
|
||||
{'base':'Dz','letters':/[\u01F2\u01C5]/g},
|
||||
{'base':'E', 'letters':/[\u0045\u24BA\uFF25\u00C8\u00C9\u00CA\u1EC0\u1EBE\u1EC4\u1EC2\u1EBC\u0112\u1E14\u1E16\u0114\u0116\u00CB\u1EBA\u011A\u0204\u0206\u1EB8\u1EC6\u0228\u1E1C\u0118\u1E18\u1E1A\u0190\u018E]/g},
|
||||
{'base':'F', 'letters':/[\u0046\u24BB\uFF26\u1E1E\u0191\uA77B]/g},
|
||||
{'base':'G', 'letters':/[\u0047\u24BC\uFF27\u01F4\u011C\u1E20\u011E\u0120\u01E6\u0122\u01E4\u0193\uA7A0\uA77D\uA77E]/g},
|
||||
{'base':'H', 'letters':/[\u0048\u24BD\uFF28\u0124\u1E22\u1E26\u021E\u1E24\u1E28\u1E2A\u0126\u2C67\u2C75\uA78D]/g},
|
||||
{'base':'I', 'letters':/[\u0049\u24BE\uFF29\u00CC\u00CD\u00CE\u0128\u012A\u012C\u0130\u00CF\u1E2E\u1EC8\u01CF\u0208\u020A\u1ECA\u012E\u1E2C\u0197]/g},
|
||||
{'base':'J', 'letters':/[\u004A\u24BF\uFF2A\u0134\u0248]/g},
|
||||
{'base':'K', 'letters':/[\u004B\u24C0\uFF2B\u1E30\u01E8\u1E32\u0136\u1E34\u0198\u2C69\uA740\uA742\uA744\uA7A2]/g},
|
||||
{'base':'L', 'letters':/[\u004C\u24C1\uFF2C\u013F\u0139\u013D\u1E36\u1E38\u013B\u1E3C\u1E3A\u0141\u023D\u2C62\u2C60\uA748\uA746\uA780]/g},
|
||||
{'base':'LJ','letters':/[\u01C7]/g},
|
||||
{'base':'Lj','letters':/[\u01C8]/g},
|
||||
{'base':'M', 'letters':/[\u004D\u24C2\uFF2D\u1E3E\u1E40\u1E42\u2C6E\u019C]/g},
|
||||
{'base':'N', 'letters':/[\u004E\u24C3\uFF2E\u01F8\u0143\u00D1\u1E44\u0147\u1E46\u0145\u1E4A\u1E48\u0220\u019D\uA790\uA7A4]/g},
|
||||
{'base':'NJ','letters':/[\u01CA]/g},
|
||||
{'base':'Nj','letters':/[\u01CB]/g},
|
||||
{'base':'O', 'letters':/[\u004F\u24C4\uFF2F\u00D2\u00D3\u00D4\u1ED2\u1ED0\u1ED6\u1ED4\u00D5\u1E4C\u022C\u1E4E\u014C\u1E50\u1E52\u014E\u022E\u0230\u00D6\u022A\u1ECE\u0150\u01D1\u020C\u020E\u01A0\u1EDC\u1EDA\u1EE0\u1EDE\u1EE2\u1ECC\u1ED8\u01EA\u01EC\u00D8\u01FE\u0186\u019F\uA74A\uA74C]/g},
|
||||
{'base':'OI','letters':/[\u01A2]/g},
|
||||
{'base':'OO','letters':/[\uA74E]/g},
|
||||
{'base':'OU','letters':/[\u0222]/g},
|
||||
{'base':'P', 'letters':/[\u0050\u24C5\uFF30\u1E54\u1E56\u01A4\u2C63\uA750\uA752\uA754]/g},
|
||||
{'base':'Q', 'letters':/[\u0051\u24C6\uFF31\uA756\uA758\u024A]/g},
|
||||
{'base':'R', 'letters':/[\u0052\u24C7\uFF32\u0154\u1E58\u0158\u0210\u0212\u1E5A\u1E5C\u0156\u1E5E\u024C\u2C64\uA75A\uA7A6\uA782]/g},
|
||||
{'base':'S', 'letters':/[\u0053\u24C8\uFF33\u1E9E\u015A\u1E64\u015C\u1E60\u0160\u1E66\u1E62\u1E68\u0218\u015E\u2C7E\uA7A8\uA784]/g},
|
||||
{'base':'T', 'letters':/[\u0054\u24C9\uFF34\u1E6A\u0164\u1E6C\u021A\u0162\u1E70\u1E6E\u0166\u01AC\u01AE\u023E\uA786]/g},
|
||||
{'base':'TZ','letters':/[\uA728]/g},
|
||||
{'base':'U', 'letters':/[\u0055\u24CA\uFF35\u00D9\u00DA\u00DB\u0168\u1E78\u016A\u1E7A\u016C\u00DC\u01DB\u01D7\u01D5\u01D9\u1EE6\u016E\u0170\u01D3\u0214\u0216\u01AF\u1EEA\u1EE8\u1EEE\u1EEC\u1EF0\u1EE4\u1E72\u0172\u1E76\u1E74\u0244]/g},
|
||||
{'base':'V', 'letters':/[\u0056\u24CB\uFF36\u1E7C\u1E7E\u01B2\uA75E\u0245]/g},
|
||||
{'base':'VY','letters':/[\uA760]/g},
|
||||
{'base':'W', 'letters':/[\u0057\u24CC\uFF37\u1E80\u1E82\u0174\u1E86\u1E84\u1E88\u2C72]/g},
|
||||
{'base':'X', 'letters':/[\u0058\u24CD\uFF38\u1E8A\u1E8C]/g},
|
||||
{'base':'Y', 'letters':/[\u0059\u24CE\uFF39\u1EF2\u00DD\u0176\u1EF8\u0232\u1E8E\u0178\u1EF6\u1EF4\u01B3\u024E\u1EFE]/g},
|
||||
{'base':'Z', 'letters':/[\u005A\u24CF\uFF3A\u0179\u1E90\u017B\u017D\u1E92\u1E94\u01B5\u0224\u2C7F\u2C6B\uA762]/g},
|
||||
{'base':'a', 'letters':/[\u0061\u24D0\uFF41\u1E9A\u00E0\u00E1\u00E2\u1EA7\u1EA5\u1EAB\u1EA9\u00E3\u0101\u0103\u1EB1\u1EAF\u1EB5\u1EB3\u0227\u01E1\u00E4\u01DF\u1EA3\u00E5\u01FB\u01CE\u0201\u0203\u1EA1\u1EAD\u1EB7\u1E01\u0105\u2C65\u0250]/g},
|
||||
{'base':'aa','letters':/[\uA733]/g},
|
||||
{'base':'ae','letters':/[\u00E6\u01FD\u01E3]/g},
|
||||
{'base':'ao','letters':/[\uA735]/g},
|
||||
{'base':'au','letters':/[\uA737]/g},
|
||||
{'base':'av','letters':/[\uA739\uA73B]/g},
|
||||
{'base':'ay','letters':/[\uA73D]/g},
|
||||
{'base':'b', 'letters':/[\u0062\u24D1\uFF42\u1E03\u1E05\u1E07\u0180\u0183\u0253]/g},
|
||||
{'base':'c', 'letters':/[\u0063\u24D2\uFF43\u0107\u0109\u010B\u010D\u00E7\u1E09\u0188\u023C\uA73F\u2184]/g},
|
||||
{'base':'d', 'letters':/[\u0064\u24D3\uFF44\u1E0B\u010F\u1E0D\u1E11\u1E13\u1E0F\u0111\u018C\u0256\u0257\uA77A]/g},
|
||||
{'base':'dz','letters':/[\u01F3\u01C6]/g},
|
||||
{'base':'e', 'letters':/[\u0065\u24D4\uFF45\u00E8\u00E9\u00EA\u1EC1\u1EBF\u1EC5\u1EC3\u1EBD\u0113\u1E15\u1E17\u0115\u0117\u00EB\u1EBB\u011B\u0205\u0207\u1EB9\u1EC7\u0229\u1E1D\u0119\u1E19\u1E1B\u0247\u025B\u01DD]/g},
|
||||
{'base':'f', 'letters':/[\u0066\u24D5\uFF46\u1E1F\u0192\uA77C]/g},
|
||||
{'base':'g', 'letters':/[\u0067\u24D6\uFF47\u01F5\u011D\u1E21\u011F\u0121\u01E7\u0123\u01E5\u0260\uA7A1\u1D79\uA77F]/g},
|
||||
{'base':'h', 'letters':/[\u0068\u24D7\uFF48\u0125\u1E23\u1E27\u021F\u1E25\u1E29\u1E2B\u1E96\u0127\u2C68\u2C76\u0265]/g},
|
||||
{'base':'hv','letters':/[\u0195]/g},
|
||||
{'base':'i', 'letters':/[\u0069\u24D8\uFF49\u00EC\u00ED\u00EE\u0129\u012B\u012D\u00EF\u1E2F\u1EC9\u01D0\u0209\u020B\u1ECB\u012F\u1E2D\u0268\u0131]/g},
|
||||
{'base':'j', 'letters':/[\u006A\u24D9\uFF4A\u0135\u01F0\u0249]/g},
|
||||
{'base':'k', 'letters':/[\u006B\u24DA\uFF4B\u1E31\u01E9\u1E33\u0137\u1E35\u0199\u2C6A\uA741\uA743\uA745\uA7A3]/g},
|
||||
{'base':'l', 'letters':/[\u006C\u24DB\uFF4C\u0140\u013A\u013E\u1E37\u1E39\u013C\u1E3D\u1E3B\u017F\u0142\u019A\u026B\u2C61\uA749\uA781\uA747]/g},
|
||||
{'base':'lj','letters':/[\u01C9]/g},
|
||||
{'base':'m', 'letters':/[\u006D\u24DC\uFF4D\u1E3F\u1E41\u1E43\u0271\u026F]/g},
|
||||
{'base':'n', 'letters':/[\u006E\u24DD\uFF4E\u01F9\u0144\u00F1\u1E45\u0148\u1E47\u0146\u1E4B\u1E49\u019E\u0272\u0149\uA791\uA7A5]/g},
|
||||
{'base':'nj','letters':/[\u01CC]/g},
|
||||
{'base':'o', 'letters':/[\u006F\u24DE\uFF4F\u00F2\u00F3\u00F4\u1ED3\u1ED1\u1ED7\u1ED5\u00F5\u1E4D\u022D\u1E4F\u014D\u1E51\u1E53\u014F\u022F\u0231\u00F6\u022B\u1ECF\u0151\u01D2\u020D\u020F\u01A1\u1EDD\u1EDB\u1EE1\u1EDF\u1EE3\u1ECD\u1ED9\u01EB\u01ED\u00F8\u01FF\u0254\uA74B\uA74D\u0275]/g},
|
||||
{'base':'oi','letters':/[\u01A3]/g},
|
||||
{'base':'ou','letters':/[\u0223]/g},
|
||||
{'base':'oo','letters':/[\uA74F]/g},
|
||||
{'base':'p','letters':/[\u0070\u24DF\uFF50\u1E55\u1E57\u01A5\u1D7D\uA751\uA753\uA755]/g},
|
||||
{'base':'q','letters':/[\u0071\u24E0\uFF51\u024B\uA757\uA759]/g},
|
||||
{'base':'r','letters':/[\u0072\u24E1\uFF52\u0155\u1E59\u0159\u0211\u0213\u1E5B\u1E5D\u0157\u1E5F\u024D\u027D\uA75B\uA7A7\uA783]/g},
|
||||
{'base':'s','letters':/[\u0073\u24E2\uFF53\u00DF\u015B\u1E65\u015D\u1E61\u0161\u1E67\u1E63\u1E69\u0219\u015F\u023F\uA7A9\uA785\u1E9B]/g},
|
||||
{'base':'t','letters':/[\u0074\u24E3\uFF54\u1E6B\u1E97\u0165\u1E6D\u021B\u0163\u1E71\u1E6F\u0167\u01AD\u0288\u2C66\uA787]/g},
|
||||
{'base':'tz','letters':/[\uA729]/g},
|
||||
{'base':'u','letters':/[\u0075\u24E4\uFF55\u00F9\u00FA\u00FB\u0169\u1E79\u016B\u1E7B\u016D\u00FC\u01DC\u01D8\u01D6\u01DA\u1EE7\u016F\u0171\u01D4\u0215\u0217\u01B0\u1EEB\u1EE9\u1EEF\u1EED\u1EF1\u1EE5\u1E73\u0173\u1E77\u1E75\u0289]/g},
|
||||
{'base':'v','letters':/[\u0076\u24E5\uFF56\u1E7D\u1E7F\u028B\uA75F\u028C]/g},
|
||||
{'base':'vy','letters':/[\uA761]/g},
|
||||
{'base':'w','letters':/[\u0077\u24E6\uFF57\u1E81\u1E83\u0175\u1E87\u1E85\u1E98\u1E89\u2C73]/g},
|
||||
{'base':'x','letters':/[\u0078\u24E7\uFF58\u1E8B\u1E8D]/g},
|
||||
{'base':'y','letters':/[\u0079\u24E8\uFF59\u1EF3\u00FD\u0177\u1EF9\u0233\u1E8F\u00FF\u1EF7\u1E99\u1EF5\u01B4\u024F\u1EFF]/g},
|
||||
{'base':'z','letters':/[\u007A\u24E9\uFF5A\u017A\u1E91\u017C\u017E\u1E93\u1E95\u01B6\u0225\u0240\u2C6C\uA763]/g}
|
||||
];
|
||||
|
||||
for(var i=0; i<defaultDiacriticsRemovalMap.length; i++) {
|
||||
str = str.replace(defaultDiacriticsRemovalMap[i].letters, defaultDiacriticsRemovalMap[i].base);
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
function escapeFilename(s, maxLength = 32) {
|
||||
let output = removeDiacritics(s);
|
||||
output = output.replace("\n\r", " ");
|
||||
output = output.replace("\r\n", " ");
|
||||
output = output.replace("\r", " ");
|
||||
output = output.replace("\n", " ");
|
||||
output = output.replace("\t", " ");
|
||||
output = output.replace("\0", "");
|
||||
|
||||
const unsafe = "/\\:*\"'?<>|"; // In Windows
|
||||
for (let i = 0; i < unsafe.length; i++) {
|
||||
output = output.replace(unsafe[i], '_');
|
||||
}
|
||||
|
||||
if (output.toLowerCase() == 'nul') output = 'n_l'; // For Windows...
|
||||
|
||||
return output.substr(0, maxLength);
|
||||
}
|
||||
|
||||
function wrap(text, indent, width) {
|
||||
const wrap_ = require('word-wrap');
|
||||
|
||||
return wrap_(text, {
|
||||
width: width - indent.length,
|
||||
indent: indent,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { removeDiacritics, escapeFilename, wrap };
|
|
@ -1,505 +0,0 @@
|
|||
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 {
|
||||
|
||||
constructor(db, api, appType) {
|
||||
this.state_ = 'idle';
|
||||
this.db_ = db;
|
||||
this.api_ = api;
|
||||
this.syncDirName_ = '.sync';
|
||||
this.resourceDirName_ = '.resource';
|
||||
this.logger_ = new Logger();
|
||||
this.appType_ = appType;
|
||||
this.cancelling_ = false;
|
||||
|
||||
this.onProgress_ = function(s) {};
|
||||
this.progressReport_ = {};
|
||||
|
||||
this.dispatch = function(action) {};
|
||||
}
|
||||
|
||||
state() {
|
||||
return this.state_;
|
||||
}
|
||||
|
||||
db() {
|
||||
return this.db_;
|
||||
}
|
||||
|
||||
api() {
|
||||
return this.api_;
|
||||
}
|
||||
|
||||
setLogger(l) {
|
||||
this.logger_ = l;
|
||||
}
|
||||
|
||||
logger() {
|
||||
return this.logger_;
|
||||
}
|
||||
|
||||
static reportToLines(report) {
|
||||
let lines = [];
|
||||
if (report.createLocal) lines.push(_('Created local items: %d.', report.createLocal));
|
||||
if (report.updateLocal) lines.push(_('Updated local items: %d.', report.updateLocal));
|
||||
if (report.createRemote) lines.push(_('Created remote items: %d.', report.createRemote));
|
||||
if (report.updateRemote) lines.push(_('Updated remote items: %d.', report.updateRemote));
|
||||
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.cancelling && !report.completedTime) lines.push(_('Cancelling...'));
|
||||
if (report.completedTime) lines.push(_('Completed: %s', time.unixMsToLocalDateTime(report.completedTime)));
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
logSyncOperation(action, local = null, remote = null, message = null) {
|
||||
let line = ['Sync'];
|
||||
line.push(action);
|
||||
if (message) line.push(message);
|
||||
|
||||
let type = local && local.type_ ? local.type_ : null;
|
||||
if (!type) type = remote && remote.type_ ? remote.type_ : null;
|
||||
|
||||
if (type) line.push(BaseItem.modelTypeToClassName(type));
|
||||
|
||||
if (local) {
|
||||
let s = [];
|
||||
s.push(local.id);
|
||||
if ('title' in local) s.push('"' + local.title + '"');
|
||||
line.push('(Local ' + s.join(', ') + ')');
|
||||
}
|
||||
|
||||
if (remote) {
|
||||
let s = [];
|
||||
s.push(remote.id ? remote.id : remote.path);
|
||||
if ('title' in remote) s.push('"' + remote.title + '"');
|
||||
line.push('(Remote ' + s.join(', ') + ')');
|
||||
}
|
||||
|
||||
this.logger().debug(line.join(': '));
|
||||
|
||||
if (!this.progressReport_[action]) this.progressReport_[action] = 0;
|
||||
this.progressReport_[action]++;
|
||||
this.progressReport_.state = this.state();
|
||||
this.onProgress_(this.progressReport_);
|
||||
|
||||
this.dispatch({ type: 'SYNC_REPORT_UPDATE', report: Object.assign({}, this.progressReport_) });
|
||||
}
|
||||
|
||||
async logSyncSummary(report) {
|
||||
this.logger().info('Operations completed: ');
|
||||
for (let n in report) {
|
||||
if (!report.hasOwnProperty(n)) continue;
|
||||
if (n == 'errors') continue;
|
||||
if (n == 'starting') continue;
|
||||
if (n == 'finished') continue;
|
||||
if (n == 'state') continue;
|
||||
if (n == 'completedTime') continue;
|
||||
this.logger().info(n + ': ' + (report[n] ? report[n] : '-'));
|
||||
}
|
||||
let folderCount = await Folder.count();
|
||||
let noteCount = await Note.count();
|
||||
let resourceCount = await Resource.count();
|
||||
this.logger().info('Total folders: ' + folderCount);
|
||||
this.logger().info('Total notes: ' + noteCount);
|
||||
this.logger().info('Total resources: ' + resourceCount);
|
||||
|
||||
if (report.errors && report.errors.length) {
|
||||
this.logger().warn('There was some errors:');
|
||||
for (let i = 0; i < report.errors.length; i++) {
|
||||
let e = report.errors[i];
|
||||
this.logger().warn(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
return this.cancelling_;
|
||||
}
|
||||
|
||||
async start(options = null) {
|
||||
if (!options) options = {};
|
||||
|
||||
if (this.state() != 'idle') {
|
||||
let error = new Error(_('Synchronisation is already in progress. State: %s', this.state()));
|
||||
error.code = 'alreadyStarted';
|
||||
throw error;
|
||||
return;
|
||||
}
|
||||
|
||||
this.state_ = 'in_progress';
|
||||
|
||||
this.onProgress_ = options.onProgress ? options.onProgress : function(o) {};
|
||||
this.progressReport_ = { errors: [] };
|
||||
|
||||
const lastContext = options.context ? options.context : {};
|
||||
|
||||
const syncTargetId = this.api().syncTargetId();
|
||||
|
||||
this.cancelling_ = false;
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// First, find all the items that have been changed since the
|
||||
// last sync and apply the changes to remote.
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
let synchronizationId = time.unixMs().toString();
|
||||
|
||||
let outputContext = Object.assign({}, lastContext);
|
||||
|
||||
this.dispatch({ type: 'SYNC_STARTED' });
|
||||
|
||||
this.logSyncOperation('starting', null, null, 'Starting synchronisation to target ' + syncTargetId + '... [' + synchronizationId + ']');
|
||||
|
||||
try {
|
||||
await this.api().mkdir(this.syncDirName_);
|
||||
await this.api().mkdir(this.resourceDirName_);
|
||||
|
||||
let donePaths = [];
|
||||
while (true) {
|
||||
if (this.cancelling()) break;
|
||||
|
||||
let result = await BaseItem.itemsThatNeedSync(syncTargetId);
|
||||
let locals = result.items;
|
||||
|
||||
for (let i = 0; i < locals.length; i++) {
|
||||
if (this.cancelling()) break;
|
||||
|
||||
let local = locals[i];
|
||||
let ItemClass = BaseItem.itemClass(local);
|
||||
let path = BaseItem.systemPath(local);
|
||||
|
||||
// Safety check to avoid infinite loops:
|
||||
if (donePaths.indexOf(path) > 0) throw new Error(sprintf('Processing a path that has already been done: %s. sync_time was not updated?', path));
|
||||
|
||||
let remote = await this.api().stat(path);
|
||||
let content = await ItemClass.serialize(local);
|
||||
let action = null;
|
||||
let updateSyncTimeOnly = true;
|
||||
let reason = '';
|
||||
|
||||
if (!remote) {
|
||||
if (!local.sync_time) {
|
||||
action = 'createRemote';
|
||||
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 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';
|
||||
} else {
|
||||
action = 'updateRemote';
|
||||
reason = 'local has changes';
|
||||
}
|
||||
}
|
||||
|
||||
this.logSyncOperation(action, local, remote, reason);
|
||||
|
||||
if (local.type_ == BaseModel.TYPE_RESOURCE && (action == 'createRemote' || (action == 'itemConflict' && remote))) {
|
||||
let remoteContentPath = this.resourceDirName_ + '/' + local.id;
|
||||
// TODO: handle node and mobile in the same way
|
||||
if (shim.isNode()) {
|
||||
let resourceContent = '';
|
||||
try {
|
||||
resourceContent = await Resource.content(local);
|
||||
} catch (error) {
|
||||
error.message = 'Cannot read resource content: ' + local.id + ': ' + error.message;
|
||||
this.logger().error(error);
|
||||
this.progressReport_.errors.push(error);
|
||||
}
|
||||
await this.api().put(remoteContentPath, resourceContent);
|
||||
} else {
|
||||
const localResourceContentPath = Resource.fullPath(local);
|
||||
await this.api().put(remoteContentPath, null, { path: localResourceContentPath, source: 'file' });
|
||||
}
|
||||
}
|
||||
|
||||
if (action == 'createRemote' || action == 'updateRemote') {
|
||||
|
||||
// Make the operation atomic by doing the work on a copy of the file
|
||||
// and then copying it back to the original location.
|
||||
// let tempPath = this.syncDirName_ + '/' + path + '_' + time.unixMs();
|
||||
//
|
||||
// Atomic operation is disabled for now because it's not possible
|
||||
// to do an atomic move with OneDrive (see file-api-driver-onedrive.js)
|
||||
|
||||
// await this.api().put(tempPath, content);
|
||||
// await this.api().setTimestamp(tempPath, local.updated_time);
|
||||
// await this.api().move(tempPath, path);
|
||||
|
||||
await this.api().put(path, content);
|
||||
await this.api().setTimestamp(path, local.updated_time);
|
||||
await ItemClass.saveSyncTime(syncTargetId, local, time.unixMs());
|
||||
|
||||
} else if (action == 'itemConflict') {
|
||||
|
||||
if (remote) {
|
||||
let remoteContent = await this.api().get(path);
|
||||
local = await BaseItem.unserialize(remoteContent);
|
||||
|
||||
const syncTimeQueries = BaseItem.updateSyncTimeQueries(syncTargetId, local, time.unixMs());
|
||||
await ItemClass.save(local, { autoTimestamp: false, nextQueries: syncTimeQueries });
|
||||
} else {
|
||||
await ItemClass.delete(local.id);
|
||||
}
|
||||
|
||||
} else if (action == 'noteConflict') {
|
||||
|
||||
// ------------------------------------------------------------------------------
|
||||
// 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.
|
||||
// ------------------------------------------------------------------------------
|
||||
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
donePaths.push(path);
|
||||
}
|
||||
|
||||
if (!result.hasMore) break;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Delete the remote items that have been deleted locally.
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
let deletedItems = await BaseItem.deletedItems(syncTargetId);
|
||||
for (let i = 0; i < deletedItems.length; i++) {
|
||||
if (this.cancelling()) break;
|
||||
|
||||
let item = deletedItems[i];
|
||||
let path = BaseItem.systemPath(item.item_id)
|
||||
this.logSyncOperation('deleteRemote', null, { id: item.item_id }, 'local has been deleted');
|
||||
await this.api().delete(path);
|
||||
await BaseItem.remoteDeletedItem(syncTargetId, item.item_id);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Loop through all the remote items, find those that
|
||||
// have been updated, and apply the changes to local.
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
// At this point all the local items that have changed have been pushed to remote
|
||||
// or handled as conflicts, so no conflict is possible after this.
|
||||
|
||||
let context = null;
|
||||
let newDeltaContext = null;
|
||||
let localFoldersToDelete = [];
|
||||
if (lastContext.delta) context = lastContext.delta;
|
||||
|
||||
while (true) {
|
||||
if (this.cancelling()) break;
|
||||
|
||||
let listResult = await this.api().delta('', {
|
||||
context: context,
|
||||
|
||||
// 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;
|
||||
|
||||
let remote = remotes[i];
|
||||
if (!BaseItem.isSystemPath(remote.path)) continue; // The delta API might return things like the .sync, .resource or the root folder
|
||||
|
||||
let path = remote.path;
|
||||
let action = null;
|
||||
let reason = '';
|
||||
let local = await BaseItem.loadItemByPath(path);
|
||||
if (!local) {
|
||||
if (!remote.isDeleted) {
|
||||
action = 'createLocal';
|
||||
reason = 'remote exists but local does not';
|
||||
}
|
||||
} else {
|
||||
if (remote.isDeleted) {
|
||||
action = 'deleteLocal';
|
||||
reason = 'remote has been deleted';
|
||||
} else {
|
||||
if (remote.updated_time > local.updated_time) {
|
||||
action = 'updateLocal';
|
||||
reason = 'remote is more recent than local';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!action) continue;
|
||||
|
||||
this.logSyncOperation(action, local, remote, reason);
|
||||
|
||||
if (action == 'createLocal' || action == 'updateLocal') {
|
||||
|
||||
let content = await this.api().get(path);
|
||||
if (content === null) {
|
||||
this.logger().warn('Remote has been deleted between now and the list() call? In that case it will be handled during the next sync: ' + path);
|
||||
continue;
|
||||
}
|
||||
content = await BaseItem.unserialize(content);
|
||||
let ItemClass = BaseItem.itemClass(content);
|
||||
|
||||
let newContent = Object.assign({}, content);
|
||||
let options = {
|
||||
autoTimestamp: false,
|
||||
nextQueries: BaseItem.updateSyncTimeQueries(syncTargetId, newContent, time.unixMs()),
|
||||
};
|
||||
if (action == 'createLocal') options.isNew = true;
|
||||
|
||||
if (newContent.type_ == BaseModel.TYPE_RESOURCE && action == 'createLocal') {
|
||||
let localResourceContentPath = Resource.fullPath(newContent);
|
||||
let remoteResourceContentPath = this.resourceDirName_ + '/' + newContent.id;
|
||||
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') {
|
||||
|
||||
if (local.type_ == BaseModel.TYPE_FOLDER) {
|
||||
localFoldersToDelete.push(local);
|
||||
continue;
|
||||
}
|
||||
|
||||
let ItemClass = BaseItem.itemClass(local.type_);
|
||||
await ItemClass.delete(local.id, { trackDeleted: false });
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
if (!listResult.hasMore) {
|
||||
newDeltaContext = listResult.context;
|
||||
break;
|
||||
}
|
||||
context = listResult.context;
|
||||
}
|
||||
|
||||
outputContext.delta = newDeltaContext ? newDeltaContext : lastContext.delta;
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Delete the folders that have been collected in the loop above.
|
||||
// Folders are always deleted last, and only if they are empty.
|
||||
// If they are not empty it's considered a conflict since whatever deleted
|
||||
// them should have deleted their content too. In that case, all its notes
|
||||
// are marked as "is_conflict".
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
if (!this.cancelling()) {
|
||||
for (let i = 0; i < localFoldersToDelete.length; i++) {
|
||||
const item = localFoldersToDelete[i];
|
||||
const noteIds = await Folder.noteIds(item.id);
|
||||
if (noteIds.length) { // CONFLICT
|
||||
await Folder.markNotesAsConflict(item.id);
|
||||
}
|
||||
await Folder.delete(item.id, { deleteChildren: false });
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.cancelling()) {
|
||||
await BaseItem.deleteOrphanSyncItems();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger().error(error);
|
||||
this.progressReport_.errors.push(error);
|
||||
}
|
||||
|
||||
if (this.cancelling()) {
|
||||
this.logger().info('Synchronisation was cancelled.');
|
||||
this.cancelling_ = false;
|
||||
}
|
||||
|
||||
this.progressReport_.completedTime = time.unixMs();
|
||||
|
||||
this.logSyncOperation('finished', null, null, 'Synchronisation finished [' + synchronizationId + ']');
|
||||
|
||||
await this.logSyncSummary(this.progressReport_);
|
||||
|
||||
this.onProgress_ = function(s) {};
|
||||
this.progressReport_ = {};
|
||||
|
||||
this.dispatch({ type: 'SYNC_COMPLETED' });
|
||||
|
||||
this.state_ = 'idle';
|
||||
|
||||
return outputContext;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { Synchronizer };
|
|
@ -1,47 +0,0 @@
|
|||
const moment = require('moment');
|
||||
|
||||
let time = {
|
||||
|
||||
unix() {
|
||||
return Math.floor((new Date()).getTime() / 1000);
|
||||
},
|
||||
|
||||
unixMs() {
|
||||
return (new Date()).getTime();
|
||||
},
|
||||
|
||||
unixMsToS(ms) {
|
||||
return Math.floor(ms / 1000);
|
||||
},
|
||||
|
||||
unixMsToIso(ms) {
|
||||
return moment.unix(ms / 1000).utc().format('YYYY-MM-DDTHH:mm:ss.SSS') + 'Z';
|
||||
},
|
||||
|
||||
unixMsToIsoSec(ms) {
|
||||
return moment.unix(ms / 1000).utc().format('YYYY-MM-DDTHH:mm:ss') + 'Z';
|
||||
},
|
||||
|
||||
unixMsToLocalDateTime(ms) {
|
||||
return moment.unix(ms / 1000).format('DD/MM/YYYY HH:mm');
|
||||
},
|
||||
|
||||
formatMsToLocal(ms, format) {
|
||||
return moment(ms).format(format);
|
||||
},
|
||||
|
||||
msleep(ms) {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, ms);
|
||||
});
|
||||
},
|
||||
|
||||
sleep(seconds) {
|
||||
return this.msleep(seconds * 1000);
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
module.exports = { time };
|
|
@ -1,9 +0,0 @@
|
|||
const urlUtils = {};
|
||||
|
||||
urlUtils.hash = function(url) {
|
||||
const s = url.split('#');
|
||||
if (s.length <= 1) return '';
|
||||
return s[s.length - 1];
|
||||
}
|
||||
|
||||
module.exports = urlUtils;
|
|
@ -1,11 +0,0 @@
|
|||
const createUuidV4 = require('uuid/v4');
|
||||
|
||||
const uuid = {
|
||||
|
||||
create: function() {
|
||||
return createUuidV4().replace(/-/g, '');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = { uuid };
|
|
@ -14,6 +14,7 @@ install:
|
|||
|
||||
build_script:
|
||||
- ps: cd ElectronClient\app
|
||||
- ps: xcopy /C /I /H /R /Y /S ..\..\ReactNativeClient\lib lib
|
||||
- npm install
|
||||
- yarn dist
|
||||
|
||||
|
|
Loading…
Reference in New Issue