diff --git a/CliClient/tests/synchronizer.js b/CliClient/tests/synchronizer.js
index 5328879b25..0351e9d337 100644
--- a/CliClient/tests/synchronizer.js
+++ b/CliClient/tests/synchronizer.js
@@ -629,4 +629,32 @@ describe('Synchronizer', function() {
done();
});
+ it('items should skip items that cannot be synced', async (done) => {
+ let folder1 = await Folder.save({ title: "folder1" });
+ let note1 = await Note.save({ title: "un", is_todo: 1, parent_id: folder1.id });
+ const noteId = note1.id;
+ await synchronizer().start();
+ let disabledItems = await BaseItem.syncDisabledItems();
+ expect(disabledItems.length).toBe(0);
+ await Note.save({ id: noteId, title: "un mod", });
+ synchronizer().debugFlags_ = ['cannotSync'];
+ await synchronizer().start();
+ synchronizer().debugFlags_ = [];
+ await synchronizer().start(); // Another sync to check that this item is now excluded from sync
+
+ await switchClient(2);
+
+ await synchronizer().start();
+ let notes = await Note.all();
+ expect(notes.length).toBe(1);
+ expect(notes[0].title).toBe('un');
+
+ await switchClient(1);
+
+ disabledItems = await BaseItem.syncDisabledItems();
+ expect(disabledItems.length).toBe(1);
+
+ done();
+ });
+
});
\ No newline at end of file
diff --git a/CliClient/tests/test-utils.js b/CliClient/tests/test-utils.js
index 0cfcd0f0b5..fa02845f95 100644
--- a/CliClient/tests/test-utils.js
+++ b/CliClient/tests/test-utils.js
@@ -43,8 +43,9 @@ const syncDir = __dirname + '/../tests/sync';
const sleepTime = syncTargetId_ == SyncTargetRegistry.nameToId('filesystem') ? 1001 : 400;
const logger = new Logger();
+logger.addTarget('console');
logger.addTarget('file', { path: logDir + '/log.txt' });
-logger.setLevel(Logger.LEVEL_DEBUG);
+logger.setLevel(Logger.LEVEL_WARN);
BaseItem.loadClass('Note', Note);
BaseItem.loadClass('Folder', Folder);
diff --git a/ElectronClient/app/app.js b/ElectronClient/app/app.js
index f4ab0ebc0f..a951f2c0fa 100644
--- a/ElectronClient/app/app.js
+++ b/ElectronClient/app/app.js
@@ -259,6 +259,14 @@ class Application extends BaseApplication {
}, {
label: _('Tools'),
submenu: [{
+ label: _('Synchronisation status'),
+ click: () => {
+ this.dispatch({
+ type: 'NAV_GO',
+ routeName: 'Status',
+ });
+ }
+ },{
label: _('Options'),
click: () => {
this.dispatch({
diff --git a/ElectronClient/app/gui/MainScreen.jsx b/ElectronClient/app/gui/MainScreen.jsx
index 6f41da59bd..744996f102 100644
--- a/ElectronClient/app/gui/MainScreen.jsx
+++ b/ElectronClient/app/gui/MainScreen.jsx
@@ -229,8 +229,8 @@ class MainScreenComponent extends React.Component {
}
}
- styles(themeId, width, height) {
- const styleKey = themeId + '_' + width + '_' + height;
+ styles(themeId, width, height, messageBoxVisible) {
+ const styleKey = themeId + '_' + width + '_' + height + '_' + messageBoxVisible;
if (styleKey === this.styleKey_) return this.styles_;
const theme = themeStyle(themeId);
@@ -239,12 +239,21 @@ class MainScreenComponent extends React.Component {
this.styles_ = {};
- const rowHeight = height - theme.headerHeight;
-
this.styles_.header = {
width: width,
};
+ this.styles_.messageBox = {
+ width: width,
+ height: 30,
+ display: 'flex',
+ alignItems: 'center',
+ paddingLeft: 10,
+ backgroundColor: theme.warningBackgroundColor,
+ }
+
+ const rowHeight = height - theme.headerHeight - (messageBoxVisible ? this.styles_.messageBox.height : 0);
+
this.styles_.sideBar = {
width: Math.floor(layoutUtils.size(width * .2, 150, 300)),
height: rowHeight,
@@ -280,7 +289,8 @@ class MainScreenComponent extends React.Component {
const folders = this.props.folders;
const notes = this.props.notes;
- const styles = this.styles(this.props.theme, style.width, style.height);
+ const styles = this.styles(this.props.theme, style.width, style.height, true);
+ const theme = themeStyle(this.props.theme);
const headerButtons = [];
@@ -325,6 +335,21 @@ class MainScreenComponent extends React.Component {
}
}
+ const onViewDisabledItemsClick = () => {
+ this.props.dispatch({
+ type: 'NAV_GO',
+ routeName: 'Status',
+ });
+ }
+
+ const messageComp = this.props.hasDisabledSyncItems ? (
+
+ {messageComp}
@@ -355,6 +381,7 @@ const mapStateToProps = (state) => {
noteVisiblePanes: state.noteVisiblePanes,
folders: state.folders,
notes: state.notes,
+ hasDisabledSyncItems: state.hasDisabledSyncItems,
};
};
diff --git a/ElectronClient/app/gui/Root.jsx b/ElectronClient/app/gui/Root.jsx
index b009ab607e..f09c57fc52 100644
--- a/ElectronClient/app/gui/Root.jsx
+++ b/ElectronClient/app/gui/Root.jsx
@@ -8,6 +8,7 @@ const { Setting } = require('lib/models/setting.js');
const { MainScreen } = require('./MainScreen.min.js');
const { OneDriveLoginScreen } = require('./OneDriveLoginScreen.min.js');
+const { StatusScreen } = require('./StatusScreen.min.js');
const { ImportScreen } = require('./ImportScreen.min.js');
const { ConfigScreen } = require('./ConfigScreen.min.js');
const { Navigator } = require('./Navigator.min.js');
@@ -75,6 +76,7 @@ class RootComponent extends React.Component {
OneDriveLogin: { screen: OneDriveLoginScreen, title: () => _('OneDrive Login') },
Import: { screen: ImportScreen, title: () => _('Import') },
Config: { screen: ConfigScreen, title: () => _('Options') },
+ Status: { screen: StatusScreen, title: () => _('Synchronisation Status') },
};
return (
diff --git a/ElectronClient/app/gui/StatusScreen.jsx b/ElectronClient/app/gui/StatusScreen.jsx
new file mode 100644
index 0000000000..148d994da4
--- /dev/null
+++ b/ElectronClient/app/gui/StatusScreen.jsx
@@ -0,0 +1,118 @@
+const React = require('react');
+const { connect } = require('react-redux');
+const { reg } = require('lib/registry.js');
+const { Setting } = require('lib/models/setting.js');
+const { bridge } = require('electron').remote.require('./bridge');
+const { Header } = require('./Header.min.js');
+const { themeStyle } = require('../theme.js');
+const { _ } = require('lib/locale.js');
+const { ReportService } = require('lib/services/report.js');
+
+class StatusScreenComponent extends React.Component {
+
+ constructor() {
+ super();
+ this.state = {
+ report: [],
+ };
+ }
+
+ componentWillMount() {
+ this.resfreshScreen();
+ }
+
+ async resfreshScreen() {
+ const service = new ReportService();
+ const report = await service.status(Setting.value('sync.target'));
+ this.setState({ report: report });
+ }
+
+ render() {
+ const theme = themeStyle(this.props.theme);
+ const style = this.props.style;
+
+ const headerStyle = {
+ width: style.width,
+ };
+
+ const containerPadding = 10;
+
+ const containerStyle = {
+ padding: containerPadding,
+ overflowY: 'auto',
+ height: style.height - theme.headerHeight - containerPadding * 2,
+ };
+
+ function renderSectionTitleHtml(key, title) {
+ return
{title}
+ }
+
+ function renderSectionHtml(key, section) {
+ let itemsHtml = [];
+
+ itemsHtml.push(renderSectionTitleHtml(section.title, section.title));
+
+ for (let n in section.body) {
+ if (!section.body.hasOwnProperty(n)) continue;
+ itemsHtml.push(
{section.body[n]}
);
+ }
+
+ return (
+
+ {itemsHtml}
+
+ );
+ }
+
+ function renderBodyHtml(report) {
+ let output = [];
+ let baseStyle = {
+ paddingLeft: 6,
+ paddingRight: 6,
+ paddingTop: 2,
+ paddingBottom: 2,
+ flex: 0,
+ color: theme.color,
+ fontSize: theme.fontSize,
+ };
+
+ let sectionsHtml = [];
+
+ for (let i = 0; i < report.length; i++) {
+ let section = report[i];
+ if (!section.body.length) continue;
+ sectionsHtml.push(renderSectionHtml(i, section));
+ }
+
+ return (
+
+ {sectionsHtml}
+
+ );
+ }
+
+ let body = renderBodyHtml(this.state.report);
+
+ return (
+
+ );
+ }
+
+}
+
+const mapStateToProps = (state) => {
+ return {
+ theme: state.settings.theme,
+ settings: state.settings,
+ locale: state.settings.locale,
+ };
+};
+
+const StatusScreen = connect(mapStateToProps)(StatusScreenComponent);
+
+module.exports = { StatusScreen };
\ No newline at end of file
diff --git a/ElectronClient/app/style.css b/ElectronClient/app/style.css
index 6c2365285e..a54281d279 100644
--- a/ElectronClient/app/style.css
+++ b/ElectronClient/app/style.css
@@ -9,6 +9,19 @@ body, textarea {
overflow: hidden;
}
+table {
+ border-collapse: collapse;
+}
+
+table th {
+ text-align: left;
+}
+
+table td, table th {
+ padding: .5em;
+ border: 1px solid #ccc;
+}
+
/* By default, the Ice Editor displays invalid characters, such as non-breaking spaces
as red boxes, but since those are actually valid characters and common in imported
Evernote data, we hide them here. */
diff --git a/ElectronClient/app/theme.js b/ElectronClient/app/theme.js
index d8a26abdff..1fe56dc2c6 100644
--- a/ElectronClient/app/theme.js
+++ b/ElectronClient/app/theme.js
@@ -25,6 +25,8 @@ const globalStyle = {
selectedColor2: "#5A4D70",
colorError2: "#ff6c6c",
+ warningBackgroundColor: "#FFD08D",
+
headerHeight: 35,
headerButtonHPadding: 6,
@@ -69,6 +71,9 @@ globalStyle.textStyle2 = Object.assign({}, globalStyle.textStyle, {
color: globalStyle.color2,
});
+globalStyle.h2Style = Object.assign({}, globalStyle.textStyle);
+globalStyle.h2Style.fontSize *= 1.3;
+
let themeCache_ = {};
function themeStyle(theme) {
diff --git a/ReactNativeClient/lib/file-api-driver-onedrive.js b/ReactNativeClient/lib/file-api-driver-onedrive.js
index 2e9827fa49..72ac309307 100644
--- a/ReactNativeClient/lib/file-api-driver-onedrive.js
+++ b/ReactNativeClient/lib/file-api-driver-onedrive.js
@@ -123,15 +123,27 @@ class FileApiDriverOneDrive {
return this.makeItem_(item);
}
- put(path, content, options = null) {
+ async 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);
+ let response = null;
+
+ try {
+ if (options.source == 'file') {
+ response = await this.api_.exec('PUT', this.makePath_(path) + ':/content', null, null, options);
+ } else {
+ options.headers = { 'Content-Type': 'text/plain' };
+ response = await this.api_.exec('PUT', this.makePath_(path) + ':/content', null, content, options);
+ }
+ } catch (error) {
+ if (error && error.code === 'BadRequest' && error.message === 'Maximum request length exceeded.') {
+ error.code = 'cannotSync';
+ error.message = 'Resource exceeds OneDrive max file size (4MB)';
+ }
+ throw error;
}
+
+ return response;
}
delete(path) {
diff --git a/ReactNativeClient/lib/joplin-database.js b/ReactNativeClient/lib/joplin-database.js
index e6407d8f3e..dfe0e2e6a5 100644
--- a/ReactNativeClient/lib/joplin-database.js
+++ b/ReactNativeClient/lib/joplin-database.js
@@ -202,7 +202,7 @@ class JoplinDatabase extends Database {
// 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, 6, 7];
+ const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8];
let currentVersionIndex = existingDatabaseVersions.indexOf(fromVersion);
// currentVersionIndex < 0 if for the case where an old version of Joplin used with a newer
@@ -265,6 +265,11 @@ class JoplinDatabase extends Database {
queries.push('ALTER TABLE resources ADD COLUMN file_extension TEXT NOT NULL DEFAULT ""');
}
+ if (targetVersion == 8) {
+ queries.push('ALTER TABLE sync_items ADD COLUMN sync_disabled INT NOT NULL DEFAULT "0"');
+ queries.push('ALTER TABLE sync_items ADD COLUMN sync_disabled_reason TEXT NOT NULL DEFAULT ""');
+ }
+
queries.push({ sql: 'UPDATE version SET version = ?', params: [targetVersion] });
await this.transactionExecBatch(queries);
diff --git a/ReactNativeClient/lib/models/base-item.js b/ReactNativeClient/lib/models/base-item.js
index e4bf934c27..1066a0eaa5 100644
--- a/ReactNativeClient/lib/models/base-item.js
+++ b/ReactNativeClient/lib/models/base-item.js
@@ -339,6 +339,7 @@ class BaseItem extends BaseModel {
JOIN sync_items s ON s.item_id = items.id
WHERE sync_target = %d
AND s.sync_time < items.updated_time
+ AND s.sync_disabled = 0
%s
LIMIT %d
`,
@@ -382,7 +383,21 @@ class BaseItem extends BaseModel {
throw new Error('Invalid type: ' + type);
}
- static updateSyncTimeQueries(syncTarget, item, syncTime) {
+ static async syncDisabledItems(syncTargetId) {
+ const rows = await this.db().selectAll('SELECT * FROM sync_items WHERE sync_disabled = 1 AND sync_target = ?', [syncTargetId]);
+ let output = [];
+ for (let i = 0; i < rows.length; i++) {
+ const item = await this.loadItem(rows[i].item_type, rows[i].item_id);
+ if (!item) continue; // The referenced item no longer exist
+ output.push({
+ syncInfo: rows[i],
+ item: item,
+ });
+ }
+ return output;
+ }
+
+ static updateSyncTimeQueries(syncTarget, item, syncTime, syncDisabled = false, syncDisabledReason = '') {
const itemType = item.type_;
const itemId = item.id;
if (!itemType || !itemId || syncTime === undefined) throw new Error('Invalid parameters in updateSyncTimeQueries()');
@@ -393,8 +408,8 @@ class BaseItem extends BaseModel {
params: [syncTarget, itemType, itemId],
},
{
- sql: 'INSERT INTO sync_items (sync_target, item_type, item_id, sync_time) VALUES (?, ?, ?, ?)',
- params: [syncTarget, itemType, itemId, syncTime],
+ sql: 'INSERT INTO sync_items (sync_target, item_type, item_id, sync_time, sync_disabled, sync_disabled_reason) VALUES (?, ?, ?, ?, ?, ?)',
+ params: [syncTarget, itemType, itemId, syncTime, syncDisabled ? 1 : 0, syncDisabledReason + ''],
}
];
}
@@ -404,6 +419,12 @@ class BaseItem extends BaseModel {
return this.db().transactionExecBatch(queries);
}
+ static async saveSyncDisabled(syncTargetId, item, syncDisabledReason) {
+ const syncTime = 'sync_time' in item ? item.sync_time : 0;
+ const queries = this.updateSyncTimeQueries(syncTargetId, item, syncTime, true, syncDisabledReason);
+ return this.db().transactionExecBatch(queries);
+ }
+
// When an item is deleted, its associated sync_items data is not immediately deleted for
// performance reason. So this function is used to look for these remaining sync_items and
// delete them.
diff --git a/ReactNativeClient/lib/models/note.js b/ReactNativeClient/lib/models/note.js
index 6df28eed8b..f5359fbdc4 100644
--- a/ReactNativeClient/lib/models/note.js
+++ b/ReactNativeClient/lib/models/note.js
@@ -126,7 +126,11 @@ class Note extends BaseItem {
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;
+
+ const titleA = a.title ? a.title.toLowerCase() : '';
+ const titleB = b.title ? b.title.toLowerCase() : '';
+ r = noteFieldComp(titleA, titleB); if (r) return r;
+
return noteFieldComp(a.id, b.id);
}
diff --git a/ReactNativeClient/lib/reducer.js b/ReactNativeClient/lib/reducer.js
index 2c7c37efbe..bb6bcfe9c7 100644
--- a/ReactNativeClient/lib/reducer.js
+++ b/ReactNativeClient/lib/reducer.js
@@ -25,7 +25,8 @@ const defaultState = {
searchQuery: '',
settings: {},
appState: 'starting',
- windowContentSize: { width: 0, height: 0 },
+ //windowContentSize: { width: 0, height: 0 },
+ hasDisabledSyncItems: false,
};
// When deleting a note, tag or folder
@@ -395,6 +396,12 @@ const reducer = (state = defaultState, action) => {
newState.appState = action.state;
break;
+ case 'SYNC_HAS_DISABLED_SYNC_ITEMS':
+
+ newState = Object.assign({}, state);
+ newState.hasDisabledSyncItems = true;
+ break;
+
}
} catch (error) {
error.message = 'In reducer: ' + error.message + ' Action: ' + JSON.stringify(action);
diff --git a/ReactNativeClient/lib/services/report.js b/ReactNativeClient/lib/services/report.js
index 0b189b651c..815ec2129a 100644
--- a/ReactNativeClient/lib/services/report.js
+++ b/ReactNativeClient/lib/services/report.js
@@ -109,8 +109,21 @@ class ReportService {
async status(syncTarget) {
let r = await this.syncStatus(syncTarget);
let sections = [];
+ let section = null;
- let section = { title: _('Sync status (synced items / total items)'), body: [] };
+ const disabledItems = await BaseItem.syncDisabledItems(syncTarget);
+
+ if (disabledItems.length) {
+ section = { title: _('Items that cannot be synchronised'), body: [] };
+
+ for (let i = 0; i < disabledItems.length; i++) {
+ const row = disabledItems[i];
+ section.body.push(_('"%s": "%s"', row.item.title, row.syncInfo.sync_disabled_reason));
+ }
+ sections.push(section);
+ }
+
+ section = { title: _('Sync status (synced items / total items)'), body: [] };
for (let n in r.items) {
if (!r.items.hasOwnProperty(n)) continue;
@@ -138,16 +151,19 @@ class ReportService {
sections.push(section);
- section = { title: _('Coming alarms'), body: [] };
-
const alarms = await Alarm.allDue();
- for (let i = 0; i < alarms.length; i++) {
- const alarm = alarms[i];
- const note = await Note.load(alarm.note_id);
- section.body.push(_('On %s: %s', time.formatMsToLocal(alarm.trigger_time), note.title));
- }
- sections.push(section);
+ if (alarms.length) {
+ section = { title: _('Coming alarms'), body: [] };
+
+ for (let i = 0; i < alarms.length; i++) {
+ const alarm = alarms[i];
+ const note = await Note.load(alarm.note_id);
+ section.body.push(_('On %s: %s', time.formatMsToLocal(alarm.trigger_time), note.title));
+ }
+
+ sections.push(section);
+ }
return sections;
}
diff --git a/ReactNativeClient/lib/synchronizer.js b/ReactNativeClient/lib/synchronizer.js
index fbf0af6235..2faf767e31 100644
--- a/ReactNativeClient/lib/synchronizer.js
+++ b/ReactNativeClient/lib/synchronizer.js
@@ -253,22 +253,36 @@ class Synchronizer {
this.logSyncOperation(action, local, remote, reason);
+ const handleCannotSyncItem = async (syncTargetId, item, cannotSyncReason) => {
+ await ItemClass.saveSyncDisabled(syncTargetId, item, cannotSyncReason);
+ this.dispatch({ type: 'SYNC_HAS_DISABLED_SYNC_ITEMS' });
+ }
+
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);
+ try {
+ // 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' });
+ }
+ } catch (error) {
+ if (error && error.code === 'cannotSync') {
+ await handleCannotSyncItem(syncTargetId, local, error.message);
+ action = null;
+ } else {
+ throw error;
}
- await this.api().put(remoteContentPath, resourceContent);
- } else {
- const localResourceContentPath = Resource.fullPath(local);
- await this.api().put(remoteContentPath, null, { path: localResourceContentPath, source: 'file' });
}
}
@@ -285,9 +299,27 @@ class Synchronizer {
// 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());
+ let canSync = true;
+ try {
+ if (this.debugFlags_.indexOf('cannotSync') >= 0) {
+ const error = new Error('Testing cannotSync');
+ error.code = 'cannotSync';
+ throw error;
+ }
+ await this.api().put(path, content);
+ } catch (error) {
+ if (error && error.code === 'cannotSync') {
+ await handleCannotSyncItem(syncTargetId, local, error.message);
+ canSync = false;
+ } else {
+ throw error;
+ }
+ }
+
+ if (canSync) {
+ await this.api().setTimestamp(path, local.updated_time);
+ await ItemClass.saveSyncTime(syncTargetId, local, time.unixMs());
+ }
} else if (action == 'itemConflict') {