From dde0da571e1435599898508317b755ae91982665 Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Sat, 15 Jul 2017 16:35:40 +0100 Subject: [PATCH] Better UI to handle conflicts --- CliClient/app/app.js | 6 ++- CliClient/app/autocomplete.js | 4 +- CliClient/app/command-cat.js | 2 +- CliClient/app/command-cp.js | 6 +-- CliClient/app/command-ls.js | 1 + CliClient/app/command-mkbook.js | 8 +++- CliClient/app/command-mv.js | 2 +- CliClient/app/command-use.js | 2 +- ReactNativeClient/android/app/build.gradle | 4 +- ReactNativeClient/lib/base-model.js | 1 - ReactNativeClient/lib/models/folder.js | 44 +++++++++++++---- ReactNativeClient/lib/models/note.js | 55 +++++++++++++++++++--- ReactNativeClient/lib/services/report.js | 13 ++++- 13 files changed, 113 insertions(+), 35 deletions(-) diff --git a/CliClient/app/app.js b/CliClient/app/app.js index c10819c14..74eb824a0 100644 --- a/CliClient/app/app.js +++ b/CliClient/app/app.js @@ -57,12 +57,14 @@ class Application { this.updatePrompt(); } - async loadItem(type, pattern) { - let output = await this.loadItems(type, pattern); + async loadItem(type, pattern, options = null) { + let output = await this.loadItems(type, pattern, options); return output.length ? output[0] : null; } async loadItems(type, pattern, options = null) { + if (type == BaseModel.TYPE_FOLDER && (pattern == Folder.conflictFolderTitle() || pattern == Folder.conflictFolderId())) return [Folder.conflictFolder()]; + if (!options) options = {}; const parent = options.parent ? options.parent : app().currentFolder(); diff --git a/CliClient/app/autocomplete.js b/CliClient/app/autocomplete.js index 1c98e4f17..97a85103f 100644 --- a/CliClient/app/autocomplete.js +++ b/CliClient/app/autocomplete.js @@ -11,13 +11,11 @@ function quotePromptArg(s) { } function autocompleteFolders() { - return Folder.all().then((folders) => { + return Folder.all({ includeConflictFolder: true }).then((folders) => { let output = []; for (let i = 0; i < folders.length; i++) { output.push(quotePromptArg(folders[i].title)); } - output.push('..'); - output.push('.'); return output; }); } diff --git a/CliClient/app/command-cat.js b/CliClient/app/command-cat.js index 0cccaeb66..9d2dbc150 100644 --- a/CliClient/app/command-cat.js +++ b/CliClient/app/command-cat.js @@ -29,7 +29,7 @@ class Command extends BaseCommand { async action(args) { let title = args['title']; - let item = await app().loadItem(BaseModel.TYPE_NOTE, title); + let item = await app().loadItem(BaseModel.TYPE_NOTE, title, { parent: app().currentFolder() }); if (!item) throw new Error(_('No item "%s" found.', title)); const content = args.options.verbose ? await Note.serialize(item) : await Note.serializeForEdit(item); diff --git a/CliClient/app/command-cp.js b/CliClient/app/command-cp.js index 28b18811f..e553a641c 100644 --- a/CliClient/app/command-cp.js +++ b/CliClient/app/command-cp.js @@ -34,11 +34,7 @@ class Command extends BaseCommand { if (!notes.length) throw new Error(_('No note matches this pattern: "%s"', args['pattern'])); for (let i = 0; i < notes.length; i++) { - const newNote = await Note.duplicate(notes[i].id, { - changes: { - parent_id: folder.id - }, - }); + const newNote = await Note.copyToFolder(notes[i].id, folder.id); Note.updateGeolocation(newNote.id); } } diff --git a/CliClient/app/command-ls.js b/CliClient/app/command-ls.js index 43c9d1bc5..06eff99e8 100644 --- a/CliClient/app/command-ls.js +++ b/CliClient/app/command-ls.js @@ -56,6 +56,7 @@ class Command extends BaseCommand { let modelType = null; if (pattern == '/' || !app().currentFolder()) { + queryOptions.includeConflictFolder = true; items = await Folder.all(queryOptions); suffix = '/'; modelType = Folder.modelType(); diff --git a/CliClient/app/command-mkbook.js b/CliClient/app/command-mkbook.js index 43fd01998..ae04534c4 100644 --- a/CliClient/app/command-mkbook.js +++ b/CliClient/app/command-mkbook.js @@ -17,8 +17,12 @@ class Command extends BaseCommand { return ['mkdir']; } - async action(args, end) { - let folder = await Folder.save({ title: args['notebook'] }, { duplicateCheck: true }); + async action(args) { + let folder = await Folder.save({ title: args['notebook'] }, { + duplicateCheck: true, + reservedTitleCheck: true, + }); + app().switchCurrentFolder(folder); } diff --git a/CliClient/app/command-mv.js b/CliClient/app/command-mv.js index bfc3bf57b..de94e2339 100644 --- a/CliClient/app/command-mv.js +++ b/CliClient/app/command-mv.js @@ -30,7 +30,7 @@ class Command extends BaseCommand { if (!notes.length) throw new Error(_('No note matches this pattern: "%s"', pattern)); for (let i = 0; i < notes.length; i++) { - await Note.save({ id: notes[i].id, parent_id: folder.id }); + await Note.moveToFolder(notes[i].id, folder.id); } } diff --git a/CliClient/app/command-use.js b/CliClient/app/command-use.js index 38addf0fa..54188b86a 100644 --- a/CliClient/app/command-use.js +++ b/CliClient/app/command-use.js @@ -25,7 +25,7 @@ class Command extends BaseCommand { async action(args) { let folder = await app().loadItem(BaseModel.TYPE_FOLDER, args['notebook']); - if (!folder) throw new Error(_('No folder "%s"', title)); + if (!folder) throw new Error(_('No folder "%s"', args['notebook'])); app().switchCurrentFolder(folder); } diff --git a/ReactNativeClient/android/app/build.gradle b/ReactNativeClient/android/app/build.gradle index f9733c7c0..ab95bfc0a 100644 --- a/ReactNativeClient/android/app/build.gradle +++ b/ReactNativeClient/android/app/build.gradle @@ -90,8 +90,8 @@ android { applicationId "net.cozic.joplin" minSdkVersion 16 targetSdkVersion 22 - versionCode 12 - versionName "0.8.10" + versionCode 13 + versionName "0.9.0" ndk { abiFilters "armeabi-v7a", "x86" } diff --git a/ReactNativeClient/lib/base-model.js b/ReactNativeClient/lib/base-model.js index f59c79644..f24300474 100644 --- a/ReactNativeClient/lib/base-model.js +++ b/ReactNativeClient/lib/base-model.js @@ -96,7 +96,6 @@ class BaseModel { return this.loadByField('id', id); } - static loadByPartialId(partialId) { return this.modelSelectOne('SELECT * FROM `' + this.tableName() + '` WHERE `id` LIKE ?', [partialId + '%']); } diff --git a/ReactNativeClient/lib/models/folder.js b/ReactNativeClient/lib/models/folder.js index 5a253878b..529c98807 100644 --- a/ReactNativeClient/lib/models/folder.js +++ b/ReactNativeClient/lib/models/folder.js @@ -1,6 +1,7 @@ import { BaseModel } from 'lib/base-model.js'; import { Log } from 'lib/log.js'; import { promiseChain } from 'lib/promise-utils.js'; +import { time } from 'lib/time-utils.js'; import { Note } from 'lib/models/note.js'; import { Setting } from 'lib/models/setting.js'; import { Database } from 'lib/database.js'; @@ -76,28 +77,55 @@ class Folder extends BaseItem { }); } + 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(), + }; + } + static async all(options = null) { - if (!options) options = {}; + let output = await super.all(options); + if (options && options.includeConflictFolder) { + let conflictCount = await Note.conflictedCount(); + if (conflictCount) output.push(this.conflictFolder()); + } + return output; + } - let folders = await super.all(options); - if (!options.includeNotes) return folders; - - if (options.limit) options.limit -= folders.length; - - let notes = await Note.all(options); - return folders.concat(notes); + 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-effect. 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.duplicateCheck === true && o.title) { let existingFolder = await Folder.loadByTitle(o.title); if (existingFolder) throw new Error(_('A notebook with this title already exists: "%s"', o.title)); } + if (options && 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: 'FOLDERS_UPDATE_ONE', diff --git a/ReactNativeClient/lib/models/note.js b/ReactNativeClient/lib/models/note.js index 3d3727c2c..a2459763c 100644 --- a/ReactNativeClient/lib/models/note.js +++ b/ReactNativeClient/lib/models/note.js @@ -5,6 +5,7 @@ import { BaseItem } from 'lib/models/base-item.js'; import { Setting } from 'lib/models/setting.js'; import { shim } from 'lib/shim.js'; import { time } from 'lib/time-utils.js'; +import { _ } from 'lib/locale.js'; import moment from 'moment'; import lodash from 'lodash'; @@ -17,7 +18,8 @@ class Note extends BaseItem { static async serialize(note, type = null, shownKeys = null) { let fieldNames = this.fieldNames(); fieldNames.push('type_'); - lodash.pull(fieldNames, 'is_conflict', 'sync_time'); + //lodash.pull(fieldNames, 'is_conflict', 'sync_time'); + lodash.pull(fieldNames, 'sync_time'); return super.serialize(note, 'note', fieldNames); } @@ -64,9 +66,19 @@ class Note extends BaseItem { return this.db().escapeFields(this.previewFields()).join(','); } - static loadFolderNoteByField(folderId, field, value) { + static async loadFolderNoteByField(folderId, field, value) { if (!folderId) throw new Error('folderId is undefined'); - return this.modelSelectOne('SELECT * FROM notes WHERE is_conflict = 0 AND `parent_id` = ? AND `' + field + '` = ?', [folderId, value]); + + 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 previews(parentId, options = null) { @@ -77,10 +89,13 @@ class Note extends BaseItem { if (!options.conditionsParams) options.conditionsParams = []; if (!options.fields) options.fields = this.previewFields(); - options.conditions.push('is_conflict = 0'); - - options.conditions.push('parent_id = ?'); - options.conditionsParams.push(parentId); + if (parentId == Folder.conflictFolderId()) { + options.conditions.push('is_conflict = 1'); + } else { + options.conditions.push('is_conflict = 0'); + options.conditions.push('parent_id = ?'); + options.conditionsParams.push(parentId); + } if (options.itemTypes && options.itemTypes.length) { if (options.itemTypes.indexOf('note') >= 0 && options.itemTypes.indexOf('todo') >= 0) { @@ -103,6 +118,11 @@ class Note extends BaseItem { 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'); } @@ -154,6 +174,27 @@ class Note extends BaseItem { return output; } + static async copyToFolder(noteId, folderId) { + if (folderId == Folder.conflictFolderId()) throw new Error(_('Cannot copy note to "%s" notebook', 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 == Folder.conflictFolderId()) throw new Error(_('Cannot move note to "%s" notebook', Folder.conflictFolderIdTitle())); + + return Note.save({ + id: noteId, + parent_id: folderId, + is_conflict: 0, + }); + } + static async duplicate(noteId, options = null) { const changes = options && options.changes; diff --git a/ReactNativeClient/lib/services/report.js b/ReactNativeClient/lib/services/report.js index bb40faed8..1e101c3f1 100644 --- a/ReactNativeClient/lib/services/report.js +++ b/ReactNativeClient/lib/services/report.js @@ -1,6 +1,7 @@ import { time } from 'lib/time-utils' import { BaseItem } from 'lib/models/base-item.js'; import { Folder } from 'lib/models/folder.js'; +import { Note } from 'lib/models/note.js'; import { _ } from 'lib/locale.js'; class ReportService { @@ -34,6 +35,12 @@ class ReportService { total: await BaseItem.deletedItemCount(), }; + output.conflicted = { + total: await Note.conflictedCount(), + }; + + output.items['Note'].total -= output.conflicted.total; + return output; } @@ -50,8 +57,10 @@ class ReportService { section.body.push(_('%s: %d/%d', n, r.items[n].synced, r.items[n].total)); } - if (r.total) section.body.push(_('Total: %d/%d', r.total.synced, r.total.total)); - if (r.toDelete) section.body.push(_('To delete: %d', r.toDelete.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);