diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 00000000..97626ba4 --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.jshintrc b/.jshintrc index 9f6a4cc3..8e739bf2 100644 --- a/.jshintrc +++ b/.jshintrc @@ -88,8 +88,6 @@ "globals" : { "require": true, "module": true, - "console": true, - "performance": true, "$": true, "_": true } diff --git a/Gruntfile.js b/Gruntfile.js index ec088016..26f4f4da 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -16,7 +16,7 @@ module.exports = function(grunt) { var webpack = require('webpack'); var pkg = require('./package.json'); var dt = new Date().toISOString().replace(/T.*/, ''); - var electronVersion = '0.35.1'; + var electronVersion = '0.36.0'; function replaceFont(css) { css.walkAtRules('font-face', function (rule) { @@ -152,12 +152,15 @@ module.exports = function(grunt) { manifest: { options: { replacements: [ - { pattern: '# YYYY-MM-DD:v0.0.0', replacement: '# ' + dt + ':v' + pkg.version }, - { pattern: 'vElectron', replacement: electronVersion } + { pattern: '# YYYY-MM-DD:v0.0.0', replacement: '# ' + dt + ':v' + pkg.version } ] }, files: { 'dist/manifest.appcache': 'app/manifest.appcache' } }, + 'manifest_html': { + options: { replacements: [{ pattern: ' - + KeeWeb diff --git a/app/manifest.appcache b/app/manifest.appcache index 2d741eb0..0f8b8573 100644 --- a/app/manifest.appcache +++ b/app/manifest.appcache @@ -1,6 +1,6 @@ CACHE MANIFEST -# YYYY-MM-DD:v0.0.0 evElectron +# YYYY-MM-DD:v0.0.0 CACHE: index.html diff --git a/app/scripts/app.js b/app/scripts/app.js index cc68841d..87919020 100644 --- a/app/scripts/app.js +++ b/app/scripts/app.js @@ -7,7 +7,6 @@ var AppModel = require('./models/app-model'), Alerts = require('./comp/alerts'), DropboxLink = require('./comp/dropbox-link'), Updater = require('./comp/updater'), - LastOpenFiles = require('./comp/last-open-files'), ThemeChanger = require('./util/theme-changer'); $(function() { @@ -41,15 +40,7 @@ $(function() { } function showApp() { - var appView = new AppView({ model: appModel }).render(); - - var lastOpenFiles = LastOpenFiles.all(); - var lastOpenFile = lastOpenFiles[0]; - if (lastOpenFile && lastOpenFile.storage === 'file' && lastOpenFile.path) { - appView.showOpenFile(lastOpenFile.path); - } else { - appView.showOpenFile(); - } + new AppView({ model: appModel }).render(); Updater.init(); } }); diff --git a/app/scripts/collections/entry-collection.js b/app/scripts/collections/entry-collection.js index 12b2bb91..dc7858f5 100644 --- a/app/scripts/collections/entry-collection.js +++ b/app/scripts/collections/entry-collection.js @@ -25,27 +25,10 @@ var EntryCollection = Backbone.Collection.extend({ defaultComparator: 'title', - activeEntry: null, - initialize: function() { this.comparator = this.comparators[this.defaultComparator]; }, - setActive: function(entry) { - if (!(entry instanceof EntryModel)) { - entry = this.get(entry); - } - this.forEach(function(entry) { entry.active = false; }); - if (entry) { - entry.active = true; - } - this.activeEntry = entry; - }, - - getActive: function() { - return this.activeEntry; - }, - sortEntries: function(comparator) { this.comparator = this.comparators[comparator] || this.comparators[this.defaultComparator]; this.sort(); diff --git a/app/scripts/collections/file-collection.js b/app/scripts/collections/file-collection.js index e12bc4fb..f908c087 100644 --- a/app/scripts/collections/file-collection.js +++ b/app/scripts/collections/file-collection.js @@ -14,8 +14,16 @@ var FileCollection = Backbone.Collection.extend({ return this.some(function(file) { return file.get('modified'); }); }, + hasDirtyFiles: function() { + return this.some(function(file) { return file.get('dirty'); }); + }, + getByName: function(name) { - return this.find(function(file) { return file.get('name') === name; }); + return this.find(function(file) { return file.get('name').toLowerCase() === name.toLowerCase(); }); + }, + + getById: function(id) { + return this.find(function(file) { return file.get('id') === id; }); } }); diff --git a/app/scripts/collections/file-info-collection.js b/app/scripts/collections/file-info-collection.js new file mode 100644 index 00000000..260c1f86 --- /dev/null +++ b/app/scripts/collections/file-info-collection.js @@ -0,0 +1,47 @@ +'use strict'; + +var Backbone = require('backbone'), + FileInfoModel = require('../models/file-info-model'), + SettingsStore = require('../comp/settings-store'); + +var FileInfoCollection = Backbone.Collection.extend({ + model: FileInfoModel, + + initialize: function () { + }, + + load: function () { + var data = SettingsStore.load('file-info'); + if (data) { + this.reset(data, {silent: true}); + } + }, + + save: function () { + SettingsStore.save('file-info', this.toJSON()); + }, + + getLast: function () { + return this.first(); + }, + + getMatch: function (storage, name, path) { + return this.find(function(fi) { + return (fi.get('storage') || '') === (storage || '') && + (fi.get('name') || '') === (name || '') && + (fi.get('path') || '') === (path || ''); + }); + }, + + getByName: function(name) { + return this.find(function(file) { return file.get('name').toLowerCase() === name.toLowerCase(); }); + } +}); + +FileInfoCollection.load = function() { + var coll = new FileInfoCollection(); + coll.load(); + return coll; +}; + +module.exports = FileInfoCollection; diff --git a/app/scripts/comp/dropbox-link.js b/app/scripts/comp/dropbox-link.js index c41cd0ff..0cee18cd 100644 --- a/app/scripts/comp/dropbox-link.js +++ b/app/scripts/comp/dropbox-link.js @@ -3,8 +3,11 @@ var Dropbox = require('dropbox'), Alerts = require('./alerts'), Launcher = require('./launcher'), + Logger = require('../util/logger'), Links = require('../const/links'); +var logger = new Logger('dropbox'); + var DropboxKeys = { AppFolder: 'qp7ctun6qt5n9d6', AppFolderKeyParts: ['qp7ctun6', 'qt5n9d6'] // to allow replace key by sed, compare in this way @@ -121,6 +124,8 @@ DropboxChooser.prototype.readFile = function(url) { }; var DropboxLink = { + ERROR_CONFLICT: Dropbox.ApiError.CONFLICT, + ERROR_NOT_FOUND: Dropbox.ApiError.NOT_FOUND, _getClient: function(complete) { if (this._dropboxClient && this._dropboxClient.isAuthenticated()) { complete(null, this._dropboxClient); @@ -151,22 +156,29 @@ var DropboxLink = { _handleUiError: function(err, alertCallback, callback) { if (!alertCallback) { - alertCallback = Alerts.error.bind(Alerts); + if (!Alerts.alertDisplayed) { + alertCallback = Alerts.error.bind(Alerts); + } } - console.error('Dropbox error', err); + logger.error('Dropbox error', err); switch (err.status) { case Dropbox.ApiError.INVALID_TOKEN: - Alerts.yesno({ - icon: 'dropbox', - header: 'Dropbox Login', - body: 'To continue, you have to sign in to Dropbox.', - buttons: [{result: 'yes', title: 'Sign In'}, {result: '', title: 'Cancel'}], - success: (function() { - this.authenticate(function(err) { callback(!err); }); - }).bind(this), - cancel: function() { callback(false); } - }); - return; + if (!Alerts.alertDisplayed) { + Alerts.yesno({ + icon: 'dropbox', + header: 'Dropbox Login', + body: 'To continue, you have to sign in to Dropbox.', + buttons: [{result: 'yes', title: 'Sign In'}, {result: '', title: 'Cancel'}], + success: (function () { + this.authenticate(function (err) { callback(!err); }); + }).bind(this), + cancel: function () { + callback(false); + } + }); + return; + } + break; case Dropbox.ApiError.NOT_FOUND: alertCallback({ header: 'Dropbox Sync Error', @@ -199,6 +211,8 @@ var DropboxLink = { body: 'Something went wrong during Dropbox sync. Please, try again later. Error code: ' + err.status }); break; + case Dropbox.ApiError.CONFLICT: + break; default: alertCallback({ header: 'Dropbox Sync Error', @@ -215,7 +229,10 @@ var DropboxLink = { if (err) { return callback(err); } + var ts = logger.ts(); + logger.debug('Call', callName); client[callName].apply(client, args.concat(function(err) { + logger.debug('Result', callName, logger.ts(ts), arguments); if (err) { that._handleUiError(err, errorAlertCallback, function(repeat) { if (repeat) { @@ -239,9 +256,10 @@ var DropboxLink = { Dropbox.AuthDriver.Popup.oauthReceiver(); }, - saveFile: function(fileName, data, overwrite, complete) { - if (overwrite) { - this._callAndHandleError('writeFile', [fileName, data], complete); + saveFile: function(fileName, data, rev, complete, alertCallback) { + if (rev) { + var opts = typeof rev === 'string' ? { lastVersionTag: rev, noOverwrite: true, noAutoRename: true } : undefined; + this._callAndHandleError('writeFile', [fileName, data, opts], complete, alertCallback); } else { this.getFileList((function(err, files) { if (err) { return complete(err); } @@ -253,18 +271,26 @@ var DropboxLink = { }, openFile: function(fileName, complete, errorAlertCallback) { - this._callAndHandleError('readFile', [fileName, { blob: true }], complete, errorAlertCallback); + this._callAndHandleError('readFile', [fileName, { arrayBuffer: true }], complete, errorAlertCallback); + }, + + stat: function(fileName, complete, errorAlertCallback) { + this._callAndHandleError('stat', [fileName], complete, errorAlertCallback); }, getFileList: function(complete) { - this._callAndHandleError('readdir', [''], function(err, files, dirStat) { + this._callAndHandleError('readdir', [''], function(err, files, dirStat, filesStat) { if (files) { files = files.filter(function(f) { return /\.kdbx$/i.test(f); }); } - complete(err, files, dirStat); + complete(err, files, dirStat, filesStat); }); }, + deleteFile: function(fileName, complete) { + this._callAndHandleError('remove', [fileName], complete); + }, + chooseFile: function(callback) { new DropboxChooser(callback).choose(); } diff --git a/app/scripts/comp/last-open-files.js b/app/scripts/comp/last-open-files.js deleted file mode 100644 index 2fefac4e..00000000 --- a/app/scripts/comp/last-open-files.js +++ /dev/null @@ -1,55 +0,0 @@ -'use strict'; - -var Storage = require('./storage'); - -var MaxItems = 5; - -var LastOpenFiles = { - all: function() { - try { - return JSON.parse(localStorage.lastOpenFiles).map(function(f) { - f.dt = Date.parse(f.dt); - return f; - }); - } catch (e) { - return []; - } - }, - - byName: function(name) { - return this.all().filter(function(f) { return f.name === name; })[0]; - }, - - save: function(files) { - try { - localStorage.lastOpenFiles = JSON.stringify(files); - } catch (e) { - console.error('Error saving last open files', e); - } - }, - - add: function(name, storage, path, availOffline) { - console.log('Add last open file', name, storage, path); - var files = this.all(); - files = files.filter(function(f) { return f.name !== name; }); - files.unshift({ name: name, storage: storage, path: path, availOffline: availOffline, dt: new Date() }); - while (files.length > MaxItems) { - files.pop(); - } - this.save(files); - }, - - remove: function(name) { - console.log('Remove last open file', name); - var files = this.all(); - files.forEach(function(file) { - if (file.name === name && file.availOffline) { - Storage.cache.remove(file.name); - } - }, this); - files = files.filter(function(file) { return file.name !== name; }); - this.save(files); - } -}; - -module.exports = LastOpenFiles; diff --git a/app/scripts/comp/launcher.js b/app/scripts/comp/launcher.js index b931fe82..093ee972 100644 --- a/app/scripts/comp/launcher.js +++ b/app/scripts/comp/launcher.js @@ -49,6 +49,9 @@ if (window.process && window.process.versions && window.process.versions.electro fileExists: function(path) { return this.req('fs').existsSync(path); }, + deleteFile: function(path) { + this.req('fs').unlinkSync(path); + }, preventExit: function(e) { e.returnValue = false; return false; @@ -88,6 +91,9 @@ if (window.process && window.process.versions && window.process.versions.electro Backbone.on('launcher-exit-request', function() { setTimeout(function() { Launcher.exit(); }, 0); }); + Backbone.on('launcher-minimize', function() { + setTimeout(function() { Backbone.trigger('app-minimized'); }, 0); + }); window.launcherOpen = function(path) { Backbone.trigger('launcher-open-file', path); }; diff --git a/app/scripts/comp/settings-store.js b/app/scripts/comp/settings-store.js new file mode 100644 index 00000000..7bcf6fcf --- /dev/null +++ b/app/scripts/comp/settings-store.js @@ -0,0 +1,44 @@ +'use strict'; + +var Launcher = require('./launcher'), + StringUtil = require('../util/string-util'), + Logger = require('../util/logger'); + +var logger = new Logger('settings'); + +var SettingsStore = { + fileName: function(key) { + return key + '.json'; + }, + + load: function(key) { + try { + if (Launcher) { + var settingsFile = Launcher.getUserDataPath(this.fileName(key)); + if (Launcher.fileExists(settingsFile)) { + return JSON.parse(Launcher.readFile(settingsFile, 'utf8')); + } + } else { + var data = localStorage[StringUtil.camelCase(key)]; + return data ? JSON.parse(data) : undefined; + } + } catch (e) { + logger.error('Error loading ' + key, e); + } + return null; + }, + + save: function(key, data) { + try { + if (Launcher) { + Launcher.writeFile(Launcher.getUserDataPath(this.fileName(key)), JSON.stringify(data)); + } else if (typeof localStorage !== 'undefined') { + localStorage[StringUtil.camelCase(key)] = JSON.stringify(data); + } + } catch (e) { + logger.error('Error saving ' + key, e); + } + } +}; + +module.exports = SettingsStore; diff --git a/app/scripts/comp/storage/index.js b/app/scripts/comp/storage/index.js deleted file mode 100644 index 4dfa0c7c..00000000 --- a/app/scripts/comp/storage/index.js +++ /dev/null @@ -1,13 +0,0 @@ -'use strict'; - -var Storage = {}; - -[ - require('./storage-cache'), - require('./storage-dropbox'), - require('./storage-file') -].forEach(function(storage) { - Storage[storage.name] = storage; -}); - -module.exports = Storage; diff --git a/app/scripts/comp/storage/storage-dropbox.js b/app/scripts/comp/storage/storage-dropbox.js deleted file mode 100644 index 8f9d4950..00000000 --- a/app/scripts/comp/storage/storage-dropbox.js +++ /dev/null @@ -1,11 +0,0 @@ -'use strict'; - -var Launcher = require('../launcher'); - -var StorageDropbox = { - name: 'dropbox', - enabled: !Launcher - // TODO: move Dropbox storage operations here -}; - -module.exports = StorageDropbox; diff --git a/app/scripts/comp/storage/storage-file.js b/app/scripts/comp/storage/storage-file.js deleted file mode 100644 index b14eb847..00000000 --- a/app/scripts/comp/storage/storage-file.js +++ /dev/null @@ -1,11 +0,0 @@ -'use strict'; - -var Launcher = require('../launcher'); - -var StorageFile = { - name: 'file', - enabled: !!Launcher - // TODO: move file storage operations here -}; - -module.exports = StorageFile; diff --git a/app/scripts/comp/transport.js b/app/scripts/comp/transport.js index b8f147da..a54c1a44 100644 --- a/app/scripts/comp/transport.js +++ b/app/scripts/comp/transport.js @@ -1,6 +1,9 @@ 'use strict'; -var Launcher = require('./launcher'); +var Launcher = require('./launcher'), + Logger = require('../util/logger'); + +var logger = new Logger('transport'); var Transport = { httpGet: function(config) { @@ -11,7 +14,7 @@ var Transport = { if (fs.existsSync(tmpFile)) { try { if (config.cache && fs.statSync(tmpFile).size > 0) { - console.log('File already downloaded ' + config.url); + logger.info('File already downloaded ' + config.url); return config.success(tmpFile); } else { fs.unlinkSync(tmpFile); @@ -22,11 +25,11 @@ var Transport = { } } var proto = config.url.split(':')[0]; - console.log('GET ' + config.url); + logger.info('GET ' + config.url); var opts = Launcher.req('url').parse(config.url); opts.headers = { 'User-Agent': navigator.userAgent }; Launcher.req(proto).get(opts, function(res) { - console.log('Response from ' + config.url + ': ' + res.statusCode); + logger.info('Response from ' + config.url + ': ' + res.statusCode); if (res.statusCode === 200) { if (config.file) { var file = fs.createWriteStream(tmpFile); @@ -57,7 +60,7 @@ var Transport = { config.error('HTTP status ' + res.statusCode); } }).on('error', function(e) { - console.error('Cannot GET ' + config.url, e); + logger.error('Cannot GET ' + config.url, e); if (tmpFile) { fs.unlink(tmpFile); } diff --git a/app/scripts/comp/updater.js b/app/scripts/comp/updater.js index 41b53763..54c18201 100644 --- a/app/scripts/comp/updater.js +++ b/app/scripts/comp/updater.js @@ -6,7 +6,10 @@ var Backbone = require('backbone'), Launcher = require('../comp/launcher'), AppSettingsModel = require('../models/app-settings-model'), UpdateModel = require('../models/update-model'), - Transport = require('../comp/transport'); + Transport = require('../comp/transport'), + Logger = require('../util/logger'); + +var logger = new Logger('updater'); var Updater = { UpdateInterval: 1000*60*60*24, @@ -57,7 +60,7 @@ var Updater = { timeDiff = Math.min(Math.max(this.UpdateInterval + (lastCheckDate - new Date()), this.MinUpdateTimeout), this.UpdateInterval); } this.nextCheckTimeout = setTimeout(this.check.bind(this), timeDiff); - console.log('Next update check will happen in ' + Math.round(timeDiff / 1000) + 's'); + logger.info('Next update check will happen in ' + Math.round(timeDiff / 1000) + 's'); return timeDiff === this.MinUpdateTimeout; }, @@ -74,20 +77,20 @@ var Updater = { // additional protection from broken program logic, to ensure that auto-checks are not performed more than once an hour var diffMs = new Date() - this.updateCheckDate; if (isNaN(diffMs) || diffMs < 1000 * 60 * 60) { - console.error('Prevented update check; last check was performed at ' + this.updateCheckDate); + logger.error('Prevented update check; last check was performed at ' + this.updateCheckDate); that.scheduleNextCheck(); return; } this.updateCheckDate = new Date(); } - console.log('Checking for update...'); + logger.info('Checking for update...'); Transport.httpGet({ url: Links.WebApp + 'manifest.appcache', utf8: true, success: function(data) { var dt = new Date(); var match = data.match(/#\s*(\d+\-\d+\-\d+):v([\d+\.\w]+)/); - console.log('Update check: ' + (match ? match[0] : 'unknown')); + logger.info('Update check: ' + (match ? match[0] : 'unknown')); if (!match) { var errMsg = 'No version info found'; UpdateModel.instance.set({ status: 'error', lastCheckDate: dt, lastCheckError: errMsg }); @@ -108,7 +111,7 @@ var Updater = { that.scheduleNextCheck(); if (prevLastVersion === UpdateModel.instance.get('lastVersion') && UpdateModel.instance.get('updateStatus') === 'ready') { - console.log('Waiting for the user to apply downloaded update'); + logger.info('Waiting for the user to apply downloaded update'); return; } if (!startedByUser && that.getAutoUpdateType() === 'install') { @@ -118,7 +121,7 @@ var Updater = { } }, error: function(e) { - console.error('Update check error', e); + logger.error('Update check error', e); UpdateModel.instance.set({ status: 'error', lastCheckDate: new Date(), @@ -140,22 +143,22 @@ var Updater = { update: function(startedByUser, successCallback) { var ver = UpdateModel.instance.get('lastVersion'); if (!Launcher || ver === RuntimeInfo.version) { - console.log('You are using the latest version'); + logger.info('You are using the latest version'); return; } UpdateModel.instance.set({ updateStatus: 'downloading', updateError: null }); var that = this; - console.log('Downloading update', ver); + logger.info('Downloading update', ver); Transport.httpGet({ url: Links.UpdateDesktop.replace('{ver}', ver), file: 'KeeWeb-' + ver + '.zip', cache: !startedByUser, success: function(filePath) { UpdateModel.instance.set('updateStatus', 'extracting'); - console.log('Extracting update file', that.UpdateCheckFiles, filePath); + logger.info('Extracting update file', that.UpdateCheckFiles, filePath); that.extractAppUpdate(filePath, function(err) { if (err) { - console.error('Error extracting update', err); + logger.error('Error extracting update', err); UpdateModel.instance.set({ updateStatus: 'error', updateError: 'Error extracting update' }); } else { UpdateModel.instance.set({ updateStatus: 'ready', updateError: null }); @@ -169,7 +172,7 @@ var Updater = { }); }, error: function(e) { - console.error('Error downloading update', e); + logger.error('Error downloading update', e); UpdateModel.instance.set({ updateStatus: 'error', updateError: 'Error downloading update' }); } }); diff --git a/app/scripts/const/timeouts.js b/app/scripts/const/timeouts.js new file mode 100644 index 00000000..f67a1f1f --- /dev/null +++ b/app/scripts/const/timeouts.js @@ -0,0 +1,7 @@ +'use strict'; + +var Timeouts = { + AutoSync: 30 * 1000 * 60 +}; + +module.exports = Timeouts; diff --git a/app/scripts/models/app-model.js b/app/scripts/models/app-model.js index ebf8144f..7feaf554 100644 --- a/app/scripts/models/app-model.js +++ b/app/scripts/models/app-model.js @@ -6,7 +6,13 @@ var Backbone = require('backbone'), EntryModel = require('./entry-model'), GroupModel = require('./group-model'), FileCollection = require('../collections/file-collection'), - EntryCollection = require('../collections/entry-collection'); + EntryCollection = require('../collections/entry-collection'), + FileInfoCollection = require('../collections/file-info-collection'), + FileModel = require('./file-model'), + FileInfoModel = require('./file-info-model'), + Storage = require('../storage'), + IdGenerator = require('../util/id-generator'), + Logger = require('../util/logger'); var AppModel = Backbone.Model.extend({ defaults: {}, @@ -14,23 +20,31 @@ var AppModel = Backbone.Model.extend({ initialize: function() { this.tags = []; this.files = new FileCollection(); + this.fileInfos = FileInfoCollection.load(); this.menu = new MenuModel(); this.filter = {}; this.sort = 'title'; this.settings = AppSettingsModel.instance; + this.activeEntryId = null; this.listenTo(Backbone, 'refresh', this.refresh); this.listenTo(Backbone, 'set-filter', this.setFilter); this.listenTo(Backbone, 'add-filter', this.addFilter); this.listenTo(Backbone, 'set-sort', this.setSort); - this.listenTo(Backbone, 'close-file', this.closeFile); this.listenTo(Backbone, 'empty-trash', this.emptyTrash); + + this.appLogger = new Logger('app'); }, addFile: function(file) { + if (this.files.getById(file.id)) { + return false; + } this.files.add(file); - file.get('groups').forEach(function(group) { this.menu.groupsSection.addItem(group); }, this); - this._addTags(file.db); + file.get('groups').forEach(function (group) { + this.menu.groupsSection.addItem(group); + }, this); + this._addTags(file); this._tagsChanged(); this.menu.filesSection.addItem({ icon: 'lock', @@ -39,24 +53,32 @@ var AppModel = Backbone.Model.extend({ file: file }); this.refresh(); + this.listenTo(file, 'reload', this.reloadFile); + return true; }, - _addTags: function(group) { + reloadFile: function(file) { + this.menu.groupsSection.removeByFile(file, true); + file.get('groups').forEach(function (group) { + this.menu.groupsSection.addItem(group); + }, this); + this.updateTags(); + }, + + _addTags: function(file) { var tagsHash = {}; this.tags.forEach(function(tag) { tagsHash[tag.toLowerCase()] = true; }); - _.forEach(group.entries, function(entry) { + var that = this; + file.forEachEntry({}, function(entry) { _.forEach(entry.tags, function(tag) { if (!tagsHash[tag.toLowerCase()]) { tagsHash[tag.toLowerCase()] = true; - this.tags.push(tag); + that.tags.push(tag); } - }, this); - }, this); - _.forEach(group.groups, function(subGroup) { - this._addTags(subGroup); - }, this); + }); + }); this.tags.sort(); }, @@ -76,7 +98,7 @@ var AppModel = Backbone.Model.extend({ var oldTags = this.tags.slice(); this.tags.splice(0, this.tags.length); this.files.forEach(function(file) { - this._addTags(file.db); + this._addTags(file); }, this); if (!_.isEqual(oldTags, this.tags)) { this._tagsChanged(); @@ -91,12 +113,12 @@ var AppModel = Backbone.Model.extend({ this.menu.tagsSection.removeAllItems(); this.menu.filesSection.removeAllItems(); this.tags.splice(0, this.tags.length); - this.setFilter({}); + this.filter = {}; }, closeFile: function(file) { this.files.remove(file); - this._tagsChanged(); + this.updateTags(); this.menu.groupsSection.removeByFile(file); this.menu.filesSection.removeByFile(file); this.refresh(); @@ -113,8 +135,12 @@ var AppModel = Backbone.Model.extend({ this.filter = filter; this.filter.subGroups = this.settings.get('expandGroups'); var entries = this.getEntries(); + if (!this.activeEntryId || !entries.get(this.activeEntryId)) { + var firstEntry = entries.first(); + this.activeEntryId = firstEntry ? firstEntry.id : null; + } Backbone.trigger('filter', { filter: this.filter, sort: this.sort, entries: entries }); - Backbone.trigger('select-entry', entries.length ? entries.first() : null); + Backbone.trigger('select-entry', entries.get(this.activeEntryId)); }, refresh: function() { @@ -142,9 +168,6 @@ var AppModel = Backbone.Model.extend({ if (this.filter.trash) { this.addTrashGroups(entries); } - if (entries.length) { - entries.setActive(entries.first()); - } return entries; }, @@ -197,6 +220,374 @@ var AppModel = Backbone.Model.extend({ createNewGroup: function() { var sel = this.getFirstSelectedGroup(); return GroupModel.newGroup(sel.group, sel.file); + }, + + createDemoFile: function() { + var that = this; + if (!this.files.getByName('Demo')) { + var demoFile = new FileModel(); + demoFile.openDemo(function() { + that.addFile(demoFile); + }); + return true; + } else { + return false; + } + }, + + createNewFile: function() { + var name; + for (var i = 0; ; i++) { + name = 'New' + (i || ''); + if (!this.files.getByName(name) && !this.fileInfos.getByName(name)) { + break; + } + } + var newFile = new FileModel(); + newFile.create(name); + this.addFile(newFile); + }, + + openFile: function(params, callback) { + var logger = new Logger('open', params.name); + logger.info('File open request'); + var that = this; + var fileInfo = params.id ? this.fileInfos.get(params.id) : this.fileInfos.getMatch(params.storage, params.name, params.path); + if (fileInfo && fileInfo.get('modified')) { + // modified offline, cannot overwrite: load from cache + logger.info('Open file from cache because it is modified'); + this.openFileFromCache(params, callback, fileInfo); + } else if (params.fileData) { + // has user content: load it + logger.info('Open file from supplied content'); + this.openFileWithData(params, callback, fileInfo, params.fileData, true); + } else if (!params.storage) { + // no storage: load from cache as main storage + logger.info('Open file from cache as main storage'); + this.openFileFromCache(params, callback, fileInfo); + } else if (fileInfo && fileInfo.get('rev') === params.rev && fileInfo.get('storage') !== 'file') { + // already latest in cache: use it + logger.info('Open file from cache because it is latest'); + this.openFileFromCache(params, callback, fileInfo); + } else { + // try to load from storage and update cache + logger.info('Open file from storage', params.storage); + var storage = Storage[params.storage]; + var storageLoad = function() { + logger.info('Load from storage'); + storage.load(params.path, function(err, data, stat) { + if (err) { + // failed to load from storage: fallback to cache if we can + if (fileInfo) { + logger.info('Open file from cache because of storage load error', err); + that.openFileFromCache(params, callback, fileInfo); + } else { + logger.info('Storage load error', err); + callback(err); + } + } else { + logger.info('Open file from content loaded from storage'); + params.fileData = data; + params.rev = stat && stat.rev || null; + that.openFileWithData(params, callback, fileInfo, data, true); + } + }); + }; + var cacheRev = fileInfo && fileInfo.get('rev') || null; + if (cacheRev && storage.stat) { + logger.info('Stat file'); + storage.stat(params.path, function(err, stat) { + if (fileInfo && (err || stat && stat.rev === cacheRev)) { + logger.info('Open file from cache because ' + (err ? 'stat error' : 'it is latest'), err); + that.openFileFromCache(params, callback, fileInfo); + } else if (stat) { + logger.info('Open file from storage'); + storageLoad(); + } else { + logger.info('Stat error', err); + callback(err); + } + }); + } else { + storageLoad(); + } + } + }, + + openFileFromCache: function(params, callback, fileInfo) { + var that = this; + Storage.cache.load(fileInfo.id, function(err, data) { + new Logger('open', params.name).info('Loaded file from cache', err); + if (err) { + callback(err); + } else { + that.openFileWithData(params, callback, fileInfo, data); + } + }); + }, + + openFileWithData: function(params, callback, fileInfo, data, updateCacheOnSuccess) { + var logger = new Logger('open', params.name); + var file = new FileModel({ + name: params.name, + storage: params.storage, + path: params.path, + keyFileName: params.keyFileName + }); + var that = this; + file.open(params.password, data, params.keyFileData, function(err) { + if (err) { + return callback(err); + } + if (that.files.get(file.id)) { + return callback('Duplicate file id'); + } + if (fileInfo && fileInfo.get('modified')) { + if (fileInfo.get('editState')) { + logger.info('Loaded local edit state'); + file.setLocalEditState(fileInfo.get('editState')); + } + logger.info('Mark file as modified and schedule sync'); + file.set('modified', true); + setTimeout(that.syncFile.bind(that, file), 0); + } + if (fileInfo) { + file.set('syncDate', fileInfo.get('syncDate')); + } + var cacheId = fileInfo && fileInfo.id || IdGenerator.uuid(); + file.set('cacheId', cacheId); + if (updateCacheOnSuccess && params.storage !== 'file') { + logger.info('Save loaded file to cache'); + Storage.cache.save(cacheId, params.fileData); + } + var rev = params.rev || fileInfo && fileInfo.get('rev'); + that.addToLastOpenFiles(file, rev); + that.addFile(file); + }); + }, + + addToLastOpenFiles: function(file, rev) { + this.appLogger.debug('Add last open file', file.get('cacheId'), file.get('name'), file.get('storage'), file.get('path'), rev); + var dt = new Date(); + var fileInfo = new FileInfoModel({ + id: file.get('cacheId'), + name: file.get('name'), + storage: file.get('storage'), + path: file.get('path'), + modified: file.get('modified'), + editState: file.getLocalEditState(), + rev: rev, + syncDate: file.get('syncDate') || dt, + openDate: dt + }); + this.fileInfos.remove(file.get('cacheId')); + this.fileInfos.unshift(fileInfo); + this.fileInfos.save(); + }, + + removeFileInfo: function(id) { + Storage.cache.remove(id); + this.fileInfos.remove(id); + this.fileInfos.save(); + }, + + syncFile: function(file, options, callback) { + var that = this; + if (file.get('demo')) { + return callback && callback(); + } + if (file.get('syncing')) { + return callback && callback('Sync in progress'); + } + if (!options) { + options = {}; + } + var logger = new Logger('sync', file.get('name')); + var storage = options.storage || file.get('storage'); + var path = options.path || file.get('path'); + if (storage && Storage[storage].getPathForName && !options.path) { + path = Storage[storage].getPathForName(file.get('name')); + } + logger.info('Sync started', storage, path, options); + var fileInfo = file.get('cacheId') ? this.fileInfos.get(file.get('cacheId')) : + this.fileInfos.getMatch(file.get('storage'), file.get('name'), file.get('path')); + if (!fileInfo) { + logger.info('Create new file info'); + var dt = new Date(); + fileInfo = new FileInfoModel({ + id: IdGenerator.uuid(), + name: file.get('name'), + storage: file.get('storage'), + path: file.get('path'), + modified: file.get('modified'), + editState: null, + rev: null, + syncDate: dt, + openDate: dt + }); + } + file.setSyncProgress(); + var complete = function(err, savedToCache) { + if (!err) { savedToCache = true; } + logger.info('Sync finished', err); + file.setSyncComplete(path, storage, err ? err.toString() : null, savedToCache); + file.set('cacheId', fileInfo.id); + fileInfo.set({ + name: file.get('name'), + storage: storage, + path: path, + modified: file.get('modified'), + editState: file.getLocalEditState(), + syncDate: file.get('syncDate'), + cacheId: fileInfo.id + }); + if (!that.fileInfos.get(fileInfo.id)) { + that.fileInfos.unshift(fileInfo); + } + that.fileInfos.save(); + if (callback) { callback(err); } + }; + if (!storage) { + if (!file.get('modified') && fileInfo.id === file.get('cacheId')) { + logger.info('Local, not modified'); + return complete(); + } + logger.info('Local, save to cache'); + file.getData(function(data, err) { + if (err) { return complete(err); } + Storage.cache.save(fileInfo.id, data, function(err) { + logger.info('Saved to cache', err); + complete(err); + }); + }); + } else { + var maxLoadLoops = 3, loadLoops = 0; + var loadFromStorageAndMerge = function() { + if (++loadLoops === maxLoadLoops) { + return complete('Too many load attempts'); + } + logger.info('Load from storage, attempt ' + loadLoops); + Storage[storage].load(path, function(err, data, stat) { + logger.info('Load from storage', stat, err); + if (err) { return complete(err); } + file.mergeOrUpdate(data, function(err) { + logger.info('Merge complete', err); + that.refresh(); + if (err) { return complete(err); } + if (stat && stat.rev) { + logger.info('Update rev in file info'); + fileInfo.set('rev', stat.rev); + } + file.set('syncDate', new Date()); + if (file.get('modified')) { + logger.info('Updated sync date, saving modified file to cache and storage'); + saveToCacheAndStorage(); + } else if (file.get('dirty') && storage !== 'file') { + logger.info('Saving not modified dirty file to cache'); + Storage.cache.save(fileInfo.id, data, function (err) { + if (err) { return complete(err); } + file.set('dirty', false); + logger.info('Complete, remove dirty flag'); + complete(); + }); + } else { + logger.info('Complete, no changes'); + complete(); + } + }); + }); + }; + var saveToCacheAndStorage = function() { + logger.info('Save to cache and storage'); + file.getData(function(data, err) { + if (err) { return complete(err); } + if (!file.get('dirty') || storage === 'file') { + logger.info('Save to storage, skip cache because not dirty or file storage'); + saveToStorage(data); + } else { + logger.info('Saving to cache'); + Storage.cache.save(fileInfo.id, data, function (err) { + if (err) { return complete(err); } + file.set('dirty', false); + logger.info('Saved to cache, saving to storage'); + saveToStorage(data); + }); + } + }); + }; + var saveToStorage = function(data) { + logger.info('Save data to storage'); + Storage[storage].save(path, data, function(err, stat) { + if (err && err.revConflict) { + logger.info('Save rev conflict, reloading from storage'); + loadFromStorageAndMerge(); + } else if (err) { + logger.info('Error saving data to storage'); + complete(err); + } else { + if (storage === 'file') { + Storage.cache.remove(fileInfo.id); + } + if (stat && stat.rev) { + logger.info('Update rev in file info'); + fileInfo.set('rev', stat.rev); + } + file.set('syncDate', new Date()); + logger.info('Save to storage complete, update sync date'); + complete(); + } + }, fileInfo.get('rev')); + }; + if (options.reload) { + logger.info('Saved to cache'); + loadFromStorageAndMerge(); + } else if (storage === 'file') { + if (file.get('modified') || file.get('path') !== path) { + logger.info('Save modified file to storage'); + saveToCacheAndStorage(); + } else { + logger.info('Skip not modified file'); + complete(); + } + } else { + logger.info('Stat file'); + Storage[storage].stat(path, function (err, stat) { + if (err) { + if (err.notFound) { + logger.info('File does not exist in storage, creating'); + saveToCacheAndStorage(); + } else if (file.get('dirty')) { + logger.info('Stat error, dirty, save to cache', err); + file.getData(function (data) { + if (data) { + Storage.cache.save(fileInfo.id, data, function (e) { + if (!e) { + file.set('dirty', false); + } + logger.info('Saved to cache, exit with error', err); + complete(err); + }); + } + }); + } else { + logger.info('Stat error, not dirty', err); + complete(err); + } + } else if (stat.rev === fileInfo.get('rev')) { + if (file.get('modified')) { + logger.info('Stat found same version, modified, saving to cache and storage'); + saveToCacheAndStorage(); + } else { + logger.info('Stat found same version, not modified'); + complete(); + } + } else { + logger.info('Found new version, loading from storage'); + loadFromStorageAndMerge(); + } + }); + } + } } }); diff --git a/app/scripts/models/app-settings-model.js b/app/scripts/models/app-settings-model.js index 22309946..6e782233 100644 --- a/app/scripts/models/app-settings-model.js +++ b/app/scripts/models/app-settings-model.js @@ -1,13 +1,11 @@ 'use strict'; var Backbone = require('backbone'), - Launcher = require('../comp/launcher'); - -var FileName = 'app-settings.json'; + SettingsStore = require('../comp/settings-store'); var AppSettingsModel = Backbone.Model.extend({ defaults: { - theme: 'd', + theme: 'fb', expandGroups: true, listViewWidth: null, menuViewWidth: null, @@ -17,7 +15,9 @@ var AppSettingsModel = Backbone.Model.extend({ autoSave: true, idleMinutes: 15, minimizeOnClose: false, - tableView: false + tableView: false, + colorfulIcons: false, + lockOnMinimize: true }, initialize: function() { @@ -25,34 +25,15 @@ var AppSettingsModel = Backbone.Model.extend({ }, load: function() { - try { - var data; - if (Launcher) { - var settingsFile = Launcher.getUserDataPath(FileName); - if (Launcher.fileExists(settingsFile)) { - data = JSON.parse(Launcher.readFile(settingsFile, 'utf8')); - } - } else if (typeof localStorage !== 'undefined' && localStorage.appSettings) { - data = JSON.parse(localStorage.appSettings); - } - if (data) { - this.set(data, {silent: true}); - } - } catch (e) { - console.error('Error loading settings', e); + var data = SettingsStore.load('app-settings'); + if (data) { + if (data.theme === 'd') { data.theme = 'db'; } // TODO: remove in v0.6 + this.set(data, {silent: true}); } }, save: function() { - try { - if (Launcher) { - Launcher.writeFile(Launcher.getUserDataPath(FileName), JSON.stringify(this.attributes)); - } else if (typeof localStorage !== 'undefined') { - localStorage.appSettings = JSON.stringify(this.attributes); - } - } catch (e) { - console.error('Error saving settings', e); - } + SettingsStore.save('app-settings', this.attributes); } }); diff --git a/app/scripts/models/entry-model.js b/app/scripts/models/entry-model.js index 81f4ab37..91621e7e 100644 --- a/app/scripts/models/entry-model.js +++ b/app/scripts/models/entry-model.js @@ -16,16 +16,19 @@ var EntryModel = Backbone.Model.extend({ }, setEntry: function(entry, group, file) { - this.set({ id: entry.uuid.id }, {silent: true}); this.entry = entry; this.group = group; this.file = file; + if (this.id === entry.uuid.id) { + this._checkUpdatedEntry(); + } this._fillByEntry(); }, _fillByEntry: function() { var entry = this.entry; - this.fileName = this.file.db.meta.name; + this.set({id: entry.uuid.id}, {silent: true}); + this.fileName = this.file.get('name'); this.title = entry.fields.Title || ''; this.password = entry.fields.Password || kdbxweb.ProtectedValue.fromString(''); this.notes = entry.fields.Notes || ''; @@ -48,6 +51,15 @@ var EntryModel = Backbone.Model.extend({ this._buildSearchColor(); }, + _checkUpdatedEntry: function() { + if (this.isJustCreated) { + this.isJustCreated = false; + } + if (this.unsaved && +this.updated !== +this.entry.times.lastModTime) { + this.unsaved = false; + } + }, + _buildSearchText: function() { var text = ''; _.forEach(this.entry.fields, function(value) { @@ -114,7 +126,8 @@ var EntryModel = Backbone.Model.extend({ }, matches: function(filter) { - return (!filter.tagLower || this.searchTags.indexOf(filter.tagLower) >= 0) && + return !filter || + (!filter.tagLower || this.searchTags.indexOf(filter.tagLower) >= 0) && (!filter.textLower || this.searchText.indexOf(filter.textLower) >= 0) && (!filter.color || filter.color === true && this.searchColor || this.searchColor === filter.color); }, @@ -190,7 +203,7 @@ var EntryModel = Backbone.Model.extend({ deleteHistory: function(historyEntry) { var ix = this.entry.history.indexOf(historyEntry); if (ix >= 0) { - this.entry.history.splice(ix, 1); + this.entry.removeHistory(ix); } this._fillByEntry(); }, @@ -211,9 +224,10 @@ var EntryModel = Backbone.Model.extend({ }, discardUnsaved: function() { - if (this.unsaved) { + if (this.unsaved && this.entry.history.length) { this.unsaved = false; - var historyEntry = this.entry.history.pop(); + var historyEntry = this.entry.history[this.entry.history.length - 1]; + this.entry.removeHistory(this.entry.history.length - 1); this.entry.fields = {}; this.entry.binaries = {}; this.entry.copyFrom(historyEntry); @@ -227,18 +241,13 @@ var EntryModel = Backbone.Model.extend({ this.isJustCreated = false; } this.file.db.remove(this.entry); - this.group.removeEntry(this); - var trashGroup = this.file.getTrashGroup(); - if (trashGroup) { - trashGroup.addEntry(this); - this.group = trashGroup; - } + this.file.reload(); }, deleteFromTrash: function() { this.file.setModified(); this.file.db.move(this.entry, null); - this.group.removeEntry(this); + this.file.reload(); }, removeWithoutHistory: function() { @@ -246,7 +255,7 @@ var EntryModel = Backbone.Model.extend({ if (ix >= 0) { this.group.group.entries.splice(ix, 1); } - this.group.removeEntry(this); + this.file.reload(); } }); diff --git a/app/scripts/models/file-info-model.js b/app/scripts/models/file-info-model.js new file mode 100644 index 00000000..9ef05483 --- /dev/null +++ b/app/scripts/models/file-info-model.js @@ -0,0 +1,27 @@ +'use strict'; + +var Backbone = require('backbone'); + +var FileInfoModel = Backbone.Model.extend({ + defaults: { + id: '', + name: '', + storage: null, + path: null, + modified: false, + editState: null, + rev: null, + syncDate: null, + openDate: null + }, + + initialize: function(data, options) { + _.each(data, function(val, key) { + if (/Date$/.test(key)) { + this.set(key, val ? new Date(val) : null, options); + } + }, this); + } +}); + +module.exports = FileInfoModel; diff --git a/app/scripts/models/file-model.js b/app/scripts/models/file-model.js index 8809af0b..56afd869 100644 --- a/app/scripts/models/file-model.js +++ b/app/scripts/models/file-model.js @@ -3,25 +3,24 @@ var Backbone = require('backbone'), GroupCollection = require('../collections/group-collection'), GroupModel = require('./group-model'), - Launcher = require('../comp/launcher'), - DropboxLink = require('../comp/dropbox-link'), - Storage = require('../comp/storage'), - LastOpenFiles = require('../comp/last-open-files'), IconUrl = require('../util/icon-url'), + Logger = require('../util/logger'), kdbxweb = require('kdbxweb'), demoFileData = require('base64!../../resources/Demo.kdbx'); +var logger = new Logger('file'); + var FileModel = Backbone.Model.extend({ defaults: { + id: '', name: '', keyFileName: '', passwordLength: 0, path: '', storage: null, modified: false, + dirty: false, open: false, - opening: false, - error: false, created: false, demo: false, groups: null, @@ -30,17 +29,21 @@ var FileModel = Backbone.Model.extend({ passwordChanged: false, keyFileChanged: false, syncing: false, - availOffline: false, - offline: false + syncError: null, + syncDate: null, + cacheId: null }, db: null, - data: null, + entryMap: null, + groupMap: null, initialize: function() { + this.entryMap = {}; + this.groupMap = {}; }, - open: function(password, fileData, keyFileData) { + open: function(password, fileData, keyFileData, callback) { var len = password.value.length, byteLength = 0, value = new Uint8Array(len * 4), @@ -57,80 +60,54 @@ var FileModel = Backbone.Model.extend({ password = new kdbxweb.ProtectedValue(value.buffer.slice(0, byteLength), salt.buffer.slice(0, byteLength)); try { var credentials = new kdbxweb.Credentials(password, keyFileData); - var start = performance.now(); + var ts = logger.ts(); kdbxweb.Kdbx.load(fileData, credentials, (function(db, err) { if (err) { - this.set({error: true, opening: false}); - console.error('Error opening file', err.code, err.message, err); + logger.error('Error opening file', err.code, err.message, err); + callback(err); } else { this.db = db; - this.readModel(this.get('name')); + this.readModel(); this.setOpenFile({ passwordLength: len }); if (keyFileData) { kdbxweb.ByteUtils.zeroBuffer(keyFileData); } - console.log('Opened file ' + this.get('name') + ': ' + Math.round(performance.now() - start) + 'ms, ' + + logger.info('Opened file ' + this.get('name') + ': ' + logger.ts(ts) + ', ' + db.header.keyEncryptionRounds + ' rounds, ' + Math.round(fileData.byteLength / 1024) + ' kB'); - this.postOpen(fileData); + callback(); } }).bind(this)); } catch (e) { - console.error('Error opening file', e, e.code, e.message, e); - this.set({ error: true, opening: false }); + logger.error('Error opening file', e, e.code, e.message, e); + callback(e); } }, - postOpen: function(fileData) { - var that = this; - this.data = fileData; - if (!this.get('offline')) { - if (this.get('availOffline')) { - Storage.cache.save(this.get('name'), fileData, function (err) { - if (err) { - that.set('availOffline', false); - if (!that.get('storage')) { - return; - } - } - that.addToLastOpenFiles(!err); - }); - } else { - if (this.get('storage')) { - this.addToLastOpenFiles(false); - } - Storage.cache.remove(this.get('name')); - } - } - }, - - addToLastOpenFiles: function(hasOfflineCache) { - LastOpenFiles.add(this.get('name'), this.get('storage'), this.get('path'), hasOfflineCache); - }, - create: function(name) { var password = kdbxweb.ProtectedValue.fromString(''); var credentials = new kdbxweb.Credentials(password); this.db = kdbxweb.Kdbx.create(credentials, name); + this.set('name', name); this.readModel(); - this.set({ open: true, created: true, opening: false, error: false, name: name, offline: false }); + this.set({ open: true, created: true, name: name }); }, - createDemo: function() { + openDemo: function(callback) { var password = kdbxweb.ProtectedValue.fromString('demo'); var credentials = new kdbxweb.Credentials(password); var demoFile = kdbxweb.ByteUtils.arrayToBuffer(kdbxweb.ByteUtils.base64ToBytes(demoFileData)); kdbxweb.Kdbx.load(demoFile, credentials, (function(db) { this.db = db; + this.set('name', 'Demo'); this.readModel(); - this.setOpenFile({passwordLength: 4, demo: true, name: 'Demo'}); + this.setOpenFile({passwordLength: 4, demo: true}); + callback(); }).bind(this)); }, setOpenFile: function(props) { _.extend(props, { open: true, - opening: false, - error: false, oldKeyFileName: this.get('keyFileName'), oldPasswordLength: props.passwordLength, passwordChanged: false, @@ -142,9 +119,10 @@ var FileModel = Backbone.Model.extend({ this._oldKeyChangeDate = this.db.meta.keyChanged; }, - readModel: function(topGroupTitle) { + readModel: function() { var groups = new GroupCollection(); this.set({ + id: this.db.getDefaultGroup().uuid.toString(), groups: groups, defaultUser: this.db.meta.defaultUser, recycleBinEnabled: this.db.meta.recycleBinEnabled, @@ -152,13 +130,66 @@ var FileModel = Backbone.Model.extend({ historyMaxSize: this.db.meta.historyMaxSize, keyEncryptionRounds: this.db.header.keyEncryptionRounds }, { silent: true }); - this.db.groups.forEach(function(group, index) { - var groupModel = GroupModel.fromGroup(group, this); - if (index === 0 && topGroupTitle) { - groupModel.set({title: topGroupTitle}); + this.db.groups.forEach(function(group) { + var groupModel = this.getGroup(group.uuid.id); + if (groupModel) { + groupModel.setGroup(group, this); + } else { + groupModel = GroupModel.fromGroup(group, this); } + groupModel.set({title: this.get('name')}); groups.add(groupModel); }, this); + this.buildObjectMap(); + }, + + buildObjectMap: function() { + var entryMap = {}; + var groupMap = {}; + this.forEachGroup(function(group) { + groupMap[group.id] = group; + group.forEachOwnEntry(null, function(entry) { + entryMap[entry.id] = entry; + }); + }, true); + this.entryMap = entryMap; + this.groupMap = groupMap; + }, + + reload: function() { + this.buildObjectMap(); + this.readModel(); + this.trigger('reload', this); + }, + + mergeOrUpdate: function(fileData, callback) { + kdbxweb.Kdbx.load(fileData, this.db.credentials, (function(remoteDb, err) { + if (err) { + logger.error('Error opening file to merge', err.code, err.message, err); + } else { + if (this.get('modified')) { + try { + this.db.merge(remoteDb); + } catch (e) { + logger.error('File merge error', e); + return callback(e); + } + } else { + this.db = remoteDb; + } + this.set('dirty', true); + this.reload(); + } + callback(err); + }).bind(this)); + }, + + getLocalEditState: function() { + return this.db.getLocalEditState(); + }, + + setLocalEditState: function(editState) { + this.db.setLocalEditState(editState); }, close: function() { @@ -166,9 +197,8 @@ var FileModel = Backbone.Model.extend({ keyFileName: '', passwordLength: 0, modified: false, + dirty: false, open: false, - opening: false, - error: false, created: false, groups: null, passwordChanged: false, @@ -177,17 +207,12 @@ var FileModel = Backbone.Model.extend({ }); }, + getEntry: function(id) { + return this.entryMap[id]; + }, + getGroup: function(id) { - var found = null; - if (id) { - this.forEachGroup(function (group) { - if (group.get('id') === id) { - found = group; - return false; - } - }, true); - } - return found; + return this.groupMap[id]; }, forEachEntry: function(filter, callback) { @@ -223,35 +248,7 @@ var FileModel = Backbone.Model.extend({ setModified: function() { if (!this.get('demo')) { - this.set('modified', true); - } - }, - - autoSave: function(complete) { - var that = this; - that.set('syncing', true); - switch (that.get('storage')) { - case 'file': - that.getData(function(data) { - Launcher.writeFile(that.get('path'), data); - that.saved(that.get('path'), that.get('storage')); - if (complete) { complete(); } - }); - break; - case 'dropbox': - that.getData(function(data) { - DropboxLink.saveFile(that.get('path'), data, true, function (err) { - if (err) { - that.set('syncing', false); - } else { - that.saved(that.get('path'), that.get('storage')); - } - if (complete) { complete(err); } - }); - }); - break; - default: - throw 'Unknown storage; cannot auto save'; + this.set({ modified: true, dirty: true }); } }, @@ -260,21 +257,42 @@ var FileModel = Backbone.Model.extend({ historyRules: true, customIcons: true }); - this.data = this.db.save(cb); - return this.data; + var that = this; + this.db.save(function(data, err) { + if (err) { + logger.error('Error saving file', that.get('name'), err); + } + cb(data, err); + }); }, getXml: function(cb) { this.db.saveXml(cb); }, - saved: function(path, storage) { - this.set({ path: path || '', storage: storage || null, modified: false, created: false, syncing: false }); + setSyncProgress: function() { + this.set({ syncing: true }); + }, + + setSyncComplete: function(path, storage, error, savedToCache) { + if (!error) { + this.db.removeLocalEditState(); + } + var modified = this.get('modified') && !!error; + var dirty = this.get('dirty') && !savedToCache; + this.set({ + created: false, + path: path || this.get('path'), + storage: storage || this.get('storage'), + modified: modified, + dirty: dirty, + syncing: false, + syncError: error + }); this.setOpenFile({ passwordLength: this.get('passwordLength') }); this.forEachEntry({}, function(entry) { entry.unsaved = false; }); - this.addToLastOpenFiles(); }, setPassword: function(password) { @@ -329,6 +347,7 @@ var FileModel = Backbone.Model.extend({ this.set('name', name); this.get('groups').first().setName(name); this.setModified(); + this.reload(); }, setDefaultUser: function(defaultUser) { diff --git a/app/scripts/models/group-model.js b/app/scripts/models/group-model.js index 936fe1c1..437e124e 100644 --- a/app/scripts/models/group-model.js +++ b/app/scripts/models/group-model.js @@ -16,34 +16,51 @@ var GroupModel = MenuItemModel.extend({ editable: true, top: false, drag: true, - drop: true + drop: true, + enableSearching: true }), initialize: function() { if (!GroupCollection) { GroupCollection = require('../collections/group-collection'); } if (!EntryCollection) { EntryCollection = require('../collections/entry-collection'); } - this.set('entries', new EntryCollection()); }, - setFromGroup: function(group, file) { + setGroup: function(group, file, parentGroup) { var isRecycleBin = file.db.meta.recycleBinUuid && file.db.meta.recycleBinUuid.id === group.uuid.id; this.set({ id: group.uuid.id, - expanded: true, + expanded: group.expanded, visible: !isRecycleBin, items: new GroupCollection(), - filterValue: group.uuid.id + entries: new EntryCollection(), + filterValue: group.uuid.id, + enableSearching: group.enableSearching, + top: !parentGroup, + drag: !!parentGroup }, { silent: true }); this.group = group; this.file = file; + this.parentGroup = parentGroup; this._fillByGroup(true); var items = this.get('items'), entries = this.get('entries'); group.groups.forEach(function(subGroup) { - items.add(GroupModel.fromGroup(subGroup, file, this)); + var existing = file.getGroup(subGroup.uuid); + if (existing) { + existing.setGroup(subGroup, file, this); + items.add(existing); + } else { + items.add(GroupModel.fromGroup(subGroup, file, this)); + } }, this); group.entries.forEach(function(entry) { - entries.add(EntryModel.fromEntry(entry, this, file)); + var existing = file.getEntry(entry.uuid); + if (existing) { + existing.setEntry(entry, this, file); + entries.add(existing); + } else { + entries.add(EntryModel.fromEntry(entry, this, file)); + } }, this); }, @@ -102,22 +119,12 @@ var GroupModel = MenuItemModel.extend({ return this.group.groups; }, - removeEntry: function(entry) { - this.get('entries').remove(entry); - }, - addEntry: function(entry) { this.get('entries').add(entry); }, - removeGroup: function(group) { - this.get('items').remove(group); - this.trigger('remove', group); - }, - addGroup: function(group) { this.get('items').add(group); - this.trigger('insert', group); }, setName: function(name) { @@ -139,21 +146,27 @@ var GroupModel = MenuItemModel.extend({ this._fillByGroup(); }, + setExpanded: function(expanded) { + this._groupModified(); + this.group.expanded = expanded; + this.set('expanded', expanded); + }, + + setEnableSearching: function(enabled) { + this._groupModified(); + this.group.enableSearching = enabled; + this.set('enableSearching', enabled); + }, + moveToTrash: function() { this.file.setModified(); this.file.db.remove(this.group); - this.parentGroup.removeGroup(this); - var trashGroup = this.file.getTrashGroup(); - if (trashGroup) { - trashGroup.addGroup(this); - this.parentGroup = trashGroup; - } - this.trigger('delete'); + this.file.reload(); }, deleteFromTrash: function() { this.file.db.move(this.group, null); - this.parentGroup.removeGroup(this); + this.file.reload(); }, removeWithoutHistory: function() { @@ -161,8 +174,7 @@ var GroupModel = MenuItemModel.extend({ if (ix >= 0) { this.parentGroup.group.groups.splice(ix, 1); } - this.parentGroup.removeGroup(this); - this.trigger('delete'); + this.file.reload(); }, moveHere: function(object) { @@ -175,42 +187,32 @@ var GroupModel = MenuItemModel.extend({ return; } this.file.db.move(object.group, this.group); - object.parentGroup.removeGroup(object); - object.trigger('delete'); - this.addGroup(object); + this.file.reload(); } else if (object instanceof EntryModel) { if (this.group.entries.indexOf(object.entry) >= 0) { return; } this.file.db.move(object.entry, this.group); - object.group.removeEntry(object); - this.addEntry(object); - object.group = this; + this.file.reload(); } } }); GroupModel.fromGroup = function(group, file, parentGroup) { var model = new GroupModel(); - model.setFromGroup(group, file); - if (parentGroup) { - model.parentGroup = parentGroup; - } else { - model.set({ top: true, drag: false }, { silent: true }); - } + model.setGroup(group, file, parentGroup); return model; }; GroupModel.newGroup = function(group, file) { var model = new GroupModel(); var grp = file.db.createGroup(group.group); - model.setFromGroup(grp, file); + model.setGroup(grp, file, group); model.group.times.update(); - model.parentGroup = group; - model.unsaved = true; model.isJustCreated = true; group.addGroup(model); file.setModified(); + file.reload(); return model; }; diff --git a/app/scripts/models/menu/menu-section-model.js b/app/scripts/models/menu/menu-section-model.js index 2006e870..7c599587 100644 --- a/app/scripts/models/menu/menu-section-model.js +++ b/app/scripts/models/menu/menu-section-model.js @@ -27,7 +27,7 @@ var MenuItemModel = Backbone.Model.extend({ this.trigger('change-items'); }, - removeByFile: function(file) { + removeByFile: function(file, skipEvent) { var items = this.get('items'); var toRemove; items.each(function(item) { @@ -38,7 +38,9 @@ var MenuItemModel = Backbone.Model.extend({ if (toRemove) { items.remove(toRemove); } - this.trigger('change-items'); + if (!skipEvent) { + this.trigger('change-items'); + } }, setItems: function(items) { diff --git a/app/scripts/models/update-model.js b/app/scripts/models/update-model.js index 00567524..fc4227e7 100644 --- a/app/scripts/models/update-model.js +++ b/app/scripts/models/update-model.js @@ -1,6 +1,7 @@ 'use strict'; -var Backbone = require('backbone'); +var Backbone = require('backbone'), + SettingsStore = require('../comp/settings-store'); var UpdateModel = Backbone.Model.extend({ defaults: { @@ -19,9 +20,9 @@ var UpdateModel = Backbone.Model.extend({ }, load: function() { - if (localStorage.updateInfo) { + var data = SettingsStore.load('update-info'); + if (data) { try { - var data = JSON.parse(localStorage.updateInfo); _.each(data, function(val, key) { if (/Date$/.test(key)) { data[key] = val ? new Date(val) : null; @@ -39,7 +40,7 @@ var UpdateModel = Backbone.Model.extend({ delete attr[key]; } }); - localStorage.updateInfo = JSON.stringify(attr); + SettingsStore.save('update-info', attr); } }); diff --git a/app/scripts/presenters/entry-presenter.js b/app/scripts/presenters/entry-presenter.js index 1af2bc8e..fa0a6243 100644 --- a/app/scripts/presenters/entry-presenter.js +++ b/app/scripts/presenters/entry-presenter.js @@ -2,9 +2,11 @@ var Format = require('../util/format'); -var EntryPresenter = function(descField) { +var EntryPresenter = function(descField, noColor, activeEntryId) { this.entry = null; this.descField = descField; + this.noColor = noColor || ''; + this.activeEntryId = activeEntryId; }; EntryPresenter.prototype = { @@ -19,12 +21,12 @@ EntryPresenter.prototype = { get id() { return this.entry ? this.entry.id : this.group.get('id'); }, get icon() { return this.entry ? this.entry.icon : (this.group.get('icon') || 'folder'); }, get customIcon() { return this.entry ? this.entry.customIcon : undefined; }, - get color() { return this.entry ? this.entry.color : undefined; }, + get color() { return this.entry ? (this.entry.color || (this.entry.customIcon ? this.noColor : undefined)) : undefined; }, get title() { return this.entry ? this.entry.title : this.group.get('title'); }, get notes() { return this.entry ? this.entry.notes : undefined; }, get url() { return this.entry ? this.entry.url : undefined; }, get user() { return this.entry ? this.entry.user : undefined; }, - get active() { return this.entry ? this.entry.active : this.group.active; }, + get active() { return this.entry ? this.entry.id === this.activeEntryId : this.group.active; }, get created() { return this.entry ? Format.dtStr(this.entry.created) : undefined; }, get updated() { return this.entry ? Format.dtStr(this.entry.updated) : undefined; }, get expired() { return this.entry ? this.entry.expired : false; }, diff --git a/app/scripts/storage/index.js b/app/scripts/storage/index.js new file mode 100644 index 00000000..0b43c9f5 --- /dev/null +++ b/app/scripts/storage/index.js @@ -0,0 +1,11 @@ +'use strict'; + +var Launcher = require('../comp/launcher'); + +var Storage = { + file: require('./storage-file'), + dropbox: require('./storage-dropbox'), + cache: Launcher ? require('./storage-file-cache') : require('./storage-cache') +}; + +module.exports = Storage; diff --git a/app/scripts/comp/storage/storage-cache.js b/app/scripts/storage/storage-cache.js similarity index 53% rename from app/scripts/comp/storage/storage-cache.js rename to app/scripts/storage/storage-cache.js index 42e3405a..99c00e76 100644 --- a/app/scripts/comp/storage/storage-cache.js +++ b/app/scripts/storage/storage-cache.js @@ -1,5 +1,8 @@ 'use strict'; +var Logger = require('../util/logger'); + +var logger = new Logger('storage-cache'); var idb = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB; var StorageCache = { @@ -11,101 +14,98 @@ var StorageCache = { init: function(callback) { if (this.db) { - return callback(); + return callback && callback(); } var that = this; try { var req = idb.open('FilesCache'); req.onerror = function (e) { - console.error('Error opening indexed db', e); + logger.error('Error opening indexed db', e); that.errorOpening = e; - callback(e); + if (callback) { callback(e); } }; req.onsuccess = function (e) { that.db = e.target.result; - callback(); + if (callback) { callback(); } }; req.onupgradeneeded = function (e) { var db = e.target.result; db.createObjectStore('files'); }; } catch (e) { - console.error('Error opening indexed db', e); - callback(e); + logger.error('Error opening indexed db', e); + if (callback) { callback(e); } } }, save: function(id, data, callback) { + logger.debug('Save', id); this.init((function(err) { if (err) { - return callback(err); + return callback && callback(err); } try { + var ts = logger.ts(); var req = this.db.transaction(['files'], 'readwrite').objectStore('files').put(data, id); req.onsuccess = function () { - if (callback) { - callback(); - } + logger.debug('Saved', id, logger.ts(ts)); + if (callback) { callback(); } }; req.onerror = function () { - console.error('Error saving to cache', id, req.error); - if (callback) { - callback(req.error); - } + logger.error('Error saving to cache', id, req.error); + if (callback) { callback(req.error); } }; } catch (e) { - console.error('Error saving to cache', id, e); - callback(e); + logger.error('Error saving to cache', id, e); + if (callback) { callback(e); } } }).bind(this)); }, load: function(id, callback) { + logger.debug('Load', id); this.init((function(err) { if (err) { - return callback(null, err); + return callback && callback(err, null); } try { + var ts = logger.ts(); var req = this.db.transaction(['files'], 'readonly').objectStore('files').get(id); req.onsuccess = function () { - if (callback) { - callback(req.result); - } + logger.debug('Loaded', id, logger.ts(ts)); + if (callback) { callback(null, req.result); } }; req.onerror = function () { - console.error('Error loading from cache', id, req.error); - if (callback) { - callback(null, req.error); - } + logger.error('Error loading from cache', id, req.error); + if (callback) { callback(req.error); } }; } catch (e) { - console.error('Error loading from cache', id, e); - callback(null, e); + logger.error('Error loading from cache', id, e); + if (callback) { callback(e, null); } } }).bind(this)); }, remove: function(id, callback) { + logger.debug('Remove', id); this.init((function(err) { if (err) { - return callback(err); + return callback && callback(err); } try { + var ts = logger.ts(); var req = this.db.transaction(['files'], 'readwrite').objectStore('files').delete(id); req.onsuccess = function () { - if (callback) { - callback(); - } + logger.debug('Removed', id, logger.ts(ts)); + if (callback) { callback(); } }; req.onerror = function () { - console.error('Error removing from cache', id, req.error); - if (callback) { - callback(req.error); - } + logger.error('Error removing from cache', id, req.error); + if (callback) { callback(req.error); } }; } catch(e) { - console.error('Error removing from cache', id, e); - callback(e); + logger.error('Error removing from cache', id, e); + if (callback) { callback(e); } } }).bind(this)); } diff --git a/app/scripts/storage/storage-dropbox.js b/app/scripts/storage/storage-dropbox.js new file mode 100644 index 00000000..98127c10 --- /dev/null +++ b/app/scripts/storage/storage-dropbox.js @@ -0,0 +1,65 @@ +'use strict'; + +var DropboxLink = require('../comp/dropbox-link'), + Logger = require('../util/logger'); + +var logger = new Logger('storage-dropbox'); + +var StorageDropbox = { + name: 'dropbox', + enabled: true, + + _convertError: function(err) { + if (!err) { + return err; + } + if (err.status === DropboxLink.ERROR_NOT_FOUND) { + err.notFound = true; + } + if (err.status === DropboxLink.ERROR_CONFLICT) { + err.revConflict = true; + } + return err; + }, + + getPathForName: function(fileName) { + return '/' + fileName + '.kdbx'; + }, + + load: function(path, callback) { + logger.debug('Load', path); + var ts = logger.ts(); + DropboxLink.openFile(path, function(err, data, stat) { + logger.debug('Loaded', path, stat ? stat.versionTag : null, logger.ts(ts)); + err = StorageDropbox._convertError(err); + if (callback) { callback(err, data, stat ? { rev: stat.versionTag } : null); } + }, _.noop); + }, + + stat: function(path, callback) { + logger.debug('Stat', path); + var ts = logger.ts(); + DropboxLink.stat(path, function(err, stat) { + if (stat && stat.isRemoved) { + err = new Error('File removed'); + err.notFound = true; + } + logger.debug('Stated', path, stat ? stat.versionTag : null, logger.ts(ts)); + err = StorageDropbox._convertError(err); + if (callback) { callback(err, stat ? { rev: stat.versionTag } : null); } + }, _.noop); + }, + + save: function(path, data, callback, rev) { + logger.debug('Save', path, rev); + var ts = logger.ts(); + DropboxLink.saveFile(path, data, rev, function(err, stat) { + logger.debug('Saved', path, logger.ts(ts)); + if (!callback) { return; } + err = StorageDropbox._convertError(err); + callback(err, stat ? { rev: stat.versionTag } : null); + }, _.noop); + } +}; + +module.exports = StorageDropbox; diff --git a/app/scripts/storage/storage-file-cache.js b/app/scripts/storage/storage-file-cache.js new file mode 100644 index 00000000..51f3b0dd --- /dev/null +++ b/app/scripts/storage/storage-file-cache.js @@ -0,0 +1,94 @@ +'use strict'; + +var Launcher = require('../comp/launcher'), + Logger = require('../util/logger'); + +var logger = new Logger('storage-file-cache'); + +var StorageFileCache = { + name: 'cache', + enabled: !!Launcher, + + path: null, + + getPath: function(id) { + return Launcher.req('path').join(this.path, id); + }, + + init: function(callback) { + if (this.path) { + return callback && callback(); + } + try { + var path = Launcher.getUserDataPath('OfflineFiles'); + var fs = Launcher.req('fs'); + if (!fs.existsSync(path)) { + fs.mkdirSync(path); + } + this.path = path; + callback(); + } catch (e) { + logger.error('Error opening local offline storage', e); + if (callback) { callback(e); } + } + }, + + save: function(id, data, callback) { + logger.debug('Save', id); + this.init((function(err) { + if (err) { + return callback && callback(err); + } + var ts = logger.ts(); + try { + Launcher.writeFile(this.getPath(id), data); + logger.debug('Saved', id, logger.ts(ts)); + if (callback) { callback(); } + } catch (e) { + logger.error('Error saving to cache', id, e); + if (callback) { callback(e); } + } + }).bind(this)); + }, + + load: function(id, callback) { + logger.debug('Load', id); + this.init((function(err) { + if (err) { + return callback && callback(null, err); + } + var ts = logger.ts(); + try { + var data = Launcher.readFile(this.getPath(id)); + logger.debug('Loaded', id, logger.ts(ts)); + if (callback) { callback(null, data.buffer); } + } catch (e) { + logger.error('Error loading from cache', id, e); + if (callback) { callback(e, null); } + } + }).bind(this)); + }, + + remove: function(id, callback) { + logger.debug('Remove', id); + this.init((function(err) { + if (err) { + return callback && callback(err); + } + var ts = logger.ts(); + try { + var path = this.getPath(id); + if (Launcher.fileExists(path)) { + Launcher.deleteFile(path); + } + logger.debug('Removed', id, logger.ts(ts)); + if (callback) { callback(); } + } catch(e) { + logger.error('Error removing from cache', id, e); + if (callback) { callback(e); } + } + }).bind(this)); + } +}; + +module.exports = StorageFileCache; diff --git a/app/scripts/storage/storage-file.js b/app/scripts/storage/storage-file.js new file mode 100644 index 00000000..ec4aa1e2 --- /dev/null +++ b/app/scripts/storage/storage-file.js @@ -0,0 +1,39 @@ +'use strict'; + +var Launcher = require('../comp/launcher'), + Logger = require('../util/logger'); + +var logger = new Logger('storage-file'); + +var StorageFile = { + name: 'file', + enabled: !!Launcher, + + load: function(path, callback) { + logger.debug('Load', path); + var ts = logger.ts(); + try { + var data = Launcher.readFile(path); + logger.debug('Loaded', path, logger.ts(ts)); + if (callback) { callback(null, data.buffer); } + } catch (e) { + logger.error('Error reading local file', path, e); + if (callback) { callback(e, null); } + } + }, + + save: function(path, data, callback) { + logger.debug('Save', path); + var ts = logger.ts(); + try { + Launcher.writeFile(path, data); + logger.debug('Saved', path, logger.ts(ts)); + if (callback) { callback(); } + } catch (e) { + logger.error('Error writing local file', path, e); + if (callback) { callback(e); } + } + } +}; + +module.exports = StorageFile; diff --git a/app/scripts/util/id-generator.js b/app/scripts/util/id-generator.js new file mode 100644 index 00000000..7c6303a0 --- /dev/null +++ b/app/scripts/util/id-generator.js @@ -0,0 +1,13 @@ +'use strict'; + +var IdGenerator = { + uuid: function() { + var s4 = IdGenerator.s4; + return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4(); + }, + s4: function() { + return Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1); + } +}; + +module.exports = IdGenerator; diff --git a/app/scripts/util/logger.js b/app/scripts/util/logger.js new file mode 100644 index 00000000..28c0650f --- /dev/null +++ b/app/scripts/util/logger.js @@ -0,0 +1,42 @@ +'use strict'; + +/* globals console */ +/* globals performance */ + +var Logger = function(name, id) { + this.prefix = (name ? name + (id ? ':' + id : '') : 'default'); +}; + +Logger.prototype.ts = function(ts) { + if (ts) { + return Math.round(performance.now() - ts) + 'ms'; + } else { + return performance.now(); + } +}; + +Logger.prototype.getPrefix = function() { + return new Date().toISOString() + ' [' + this.prefix + '] '; +}; + +Logger.prototype.debug = function() { + arguments[0] = this.getPrefix() + arguments[0]; + console.debug.apply(console, arguments); +}; + +Logger.prototype.info = function() { + arguments[0] = this.getPrefix() + arguments[0]; + console.log.apply(console, arguments); +}; + +Logger.prototype.warn = function() { + arguments[0] = this.getPrefix() + arguments[0]; + console.warn.apply(console, arguments); +}; + +Logger.prototype.error = function() { + arguments[0] = this.getPrefix() + arguments[0]; + console.error.apply(console, arguments); +}; + +module.exports = Logger; diff --git a/app/scripts/util/string-util.js b/app/scripts/util/string-util.js new file mode 100644 index 00000000..0f7636e2 --- /dev/null +++ b/app/scripts/util/string-util.js @@ -0,0 +1,11 @@ +'use strict'; + +var StringUtil = { + camelCaseRegex: /\-./g, + + camelCase: function(str) { + return str.replace(this.camelCaseRegex, function(match) { return match[1].toUpperCase(); }); + } +}; + +module.exports = StringUtil; diff --git a/app/scripts/views/app-view.js b/app/scripts/views/app-view.js index 3ef76fdf..fbc23ba5 100644 --- a/app/scripts/views/app-view.js +++ b/app/scripts/views/app-view.js @@ -12,6 +12,7 @@ var Backbone = require('backbone'), SettingsView = require('../views/settings/settings-view'), Alerts = require('../comp/alerts'), Keys = require('../const/keys'), + Timeouts = require('../const/timeouts'), KeyHandler = require('../comp/key-handler'), IdleTracker = require('../comp/idle-tracker'), Launcher = require('../comp/launcher'), @@ -64,6 +65,7 @@ var AppView = Backbone.View.extend({ this.listenTo(Backbone, 'edit-group', this.editGroup); this.listenTo(Backbone, 'launcher-open-file', this.launcherOpenFile); this.listenTo(Backbone, 'user-idle', this.userIdle); + this.listenTo(Backbone, 'app-minimized', this.appMinimized); this.listenTo(UpdateModel.instance, 'change:updateReady', this.updateApp); @@ -72,6 +74,8 @@ var AppView = Backbone.View.extend({ KeyHandler.onKey(Keys.DOM_VK_ESCAPE, this.escPressed, this); KeyHandler.onKey(Keys.DOM_VK_BACK_SPACE, this.backspacePressed, this); + + setInterval(this.syncAllByTimer.bind(this), Timeouts.AutoSync); }, render: function () { @@ -85,10 +89,11 @@ var AppView = Backbone.View.extend({ this.views.listDrag.setElement(this.$el.find('.app__list-drag')).render(); this.views.details.setElement(this.$el.find('.app__details')).render(); this.views.grp.setElement(this.$el.find('.app__grp')).render().hide(); + this.showLastOpenFile(); return this; }, - showOpenFile: function(filePath) { + showOpenFile: function() { this.views.menu.hide(); this.views.menuDrag.hide(); this.views.listWrap.hide(); @@ -101,15 +106,21 @@ var AppView = Backbone.View.extend({ this.hideOpenFile(); this.views.open = new OpenView({ model: this.model }); this.views.open.setElement(this.$el.find('.app__body')).render(); - this.views.open.on('cancel', this.showEntries, this); - if (Launcher && filePath) { - this.views.open.showOpenLocalFile(filePath); + this.views.open.on('close', this.showEntries, this); + }, + + showLastOpenFile: function() { + this.showOpenFile(); + var lastOpenFile = this.model.fileInfos.getLast(); + if (lastOpenFile) { + this.views.open.showOpenFileInfo(lastOpenFile); } }, launcherOpenFile: function(path) { if (path && /\.kdbx$/i.test(path)) { - this.showOpenFile(path); + this.showOpenFile(); + this.views.open.showOpenLocalFile(path); } }, @@ -158,7 +169,7 @@ var AppView = Backbone.View.extend({ this.views.details.hide(); this.views.grp.hide(); this.hideOpenFile(); - this.views.settings = new SettingsView(); + this.views.settings = new SettingsView({ model: this.model }); this.views.settings.setElement(this.$el.find('.app__body')).render(); if (!selectedMenuItem) { selectedMenuItem = this.model.menu.generalSection.get('items').first(); @@ -207,7 +218,7 @@ var AppView = Backbone.View.extend({ }, beforeUnload: function(e) { - if (this.model.files.hasUnsavedFiles()) { + if (this.model.files.hasDirtyFiles()) { if (Launcher && !Launcher.exitRequested) { if (!this.exitAlertShown) { var that = this; @@ -232,7 +243,6 @@ var AppView = Backbone.View.extend({ return 'You have unsaved files, all changes will be lost.'; } else if (Launcher && !Launcher.exitRequested && !Launcher.restartPending && Launcher.canMinimize() && this.model.settings.get('minimizeOnClose')) { - this.lockWorkspace(true); Launcher.minimizeApp(); return Launcher.preventExit(e); } @@ -269,6 +279,12 @@ var AppView = Backbone.View.extend({ this.lockWorkspace(true); }, + appMinimized: function() { + if (this.model.settings.get('lockOnMinimize')) { + this.lockWorkspace(true); + } + }, + lockWorkspace: function(autoInit) { var that = this; if (Alerts.alertDisplayed) { @@ -278,20 +294,18 @@ var AppView = Backbone.View.extend({ if (this.model.settings.get('autoSave')) { this.saveAndLock(autoInit); } else { - if (autoInit) { - this.showVisualLock('Auto-save is disabled. Please, enable it, to allow auto-locking'); - return; - } + var message = autoInit ? 'The app cannot be locked because auto save is disabled.' + : 'You have unsaved changes that will be lost. Continue?'; Alerts.alert({ icon: 'lock', header: 'Lock', - body: 'You have unsaved changes that will be lost. Continue?', + body: message, buttons: [ { result: 'save', title: 'Save changes' }, { result: 'discard', title: 'Discard changes', error: true }, { result: '', title: 'Cancel' } ], - checkbox: 'Auto save changes each time I lock the app', + checkbox: 'Save changes automatically', success: function(result, autoSaveChecked) { if (result === 'save') { if (autoSaveChecked) { @@ -309,32 +323,16 @@ var AppView = Backbone.View.extend({ } }, - showVisualLock: function() { - // TODO: remove cases which lead to this - }, - - saveAndLock: function(autoInit) { - // TODO: move to file manager + saveAndLock: function(/*autoInit*/) { var pendingCallbacks = 0, errorFiles = [], that = this; - if (this.model.files.some(function(file) { return file.get('modified') && !file.get('path'); })) { - this.showVisualLock('You have unsaved files, locking is not possible.'); - return; - } this.model.files.forEach(function(file) { - if (!file.get('modified')) { + if (!file.get('dirty')) { return; } - if (file.get('path')) { - try { - file.autoSave(fileSaved.bind(this, file)); - pendingCallbacks++; - } catch (e) { - console.error('Failed to auto-save file', file.get('path'), e); - errorFiles.push(file); - } - } + this.model.syncFile(file, null, fileSaved.bind(this, file)); + pendingCallbacks++; }, this); if (!pendingCallbacks) { this.closeAllFilesAndShowFirst(); @@ -344,10 +342,8 @@ var AppView = Backbone.View.extend({ errorFiles.push(file.get('name')); } if (--pendingCallbacks === 0) { - if (errorFiles.length) { - if (autoInit) { - that.showVisualLock('Failed to save files: ' + errorFiles.join(', ')); - } else if (!Alerts.alertDisplayed) { + if (errorFiles.length && that.model.files.hasDirtyFiles()) { + if (!Alerts.alertDisplayed) { Alerts.error({ header: 'Save Error', body: 'Failed to auto-save file' + (errorFiles.length > 1 ? 's: ' : '') + ' ' + errorFiles.join(', ') @@ -364,26 +360,22 @@ var AppView = Backbone.View.extend({ var firstFile = this.model.files.find(function(file) { return !file.get('demo') && !file.get('created'); }); this.model.closeAllFiles(); if (firstFile) { - this.views.open.showClosedFile(firstFile); + var fileInfo = this.model.fileInfos.getMatch(firstFile.get('storage'), firstFile.get('name'), firstFile.get('path')); + if (fileInfo) { + this.views.open.showOpenFileInfo(fileInfo); + } } }, saveAll: function() { - var fileId; this.model.files.forEach(function(file) { - if (file.get('path')) { - try { - file.autoSave(); - } catch (e) { - console.error('Failed to auto-save file', file.get('path'), e); - fileId = file.cid; - } - } else if (!fileId) { - fileId = file.cid; - } - }); - if (fileId) { - this.showFileSettings({fileId: fileId}); + this.model.syncFile(file); + }, this); + }, + + syncAllByTimer: function() { + if (this.model.settings.get('autoSave')) { + this.saveAll(); } }, diff --git a/app/scripts/views/details/details-view.js b/app/scripts/views/details/details-view.js index 26708a26..3219b631 100644 --- a/app/scripts/views/details/details-view.js +++ b/app/scripts/views/details/details-view.js @@ -115,7 +115,7 @@ var DetailsView = Backbone.View.extend({ value: function() { return model.notes; } } })); this.fieldViews.push(new FieldViewTags({ model: { name: 'Tags', title: 'Tags', tags: this.appModel.tags, value: function() { return model.tags; } } })); - this.fieldViews.push(new FieldViewDate({ model: { name: 'Expires', title: 'Expires', empty: 'Never', lessThanNow: '(expired)', + this.fieldViews.push(new FieldViewDate({ model: { name: 'Expires', title: 'Expires', lessThanNow: '(expired)', value: function() { return model.expires; } } })); this.fieldViews.push(new FieldViewReadOnly({ model: { name: 'File', title: 'File', value: function() { return model.fileName; } } })); diff --git a/app/scripts/views/fields/field-view-date.js b/app/scripts/views/fields/field-view-date.js index ea5d7849..f1d7292b 100644 --- a/app/scripts/views/fields/field-view-date.js +++ b/app/scripts/views/fields/field-view-date.js @@ -7,7 +7,7 @@ var FieldViewText = require('./field-view-text'), var FieldViewDate = FieldViewText.extend({ renderValue: function(value) { - var result = value ? Format.dStr(value) : this.model.empty || ''; + var result = value ? Format.dStr(value) : ''; if (value && this.model.lessThanNow && value < new Date()) { result += ' ' + this.model.lessThanNow; } @@ -15,7 +15,7 @@ var FieldViewDate = FieldViewText.extend({ }, getEditValue: function(value) { - return value ? Format.dStr(value) : this.model.empty || ''; + return value ? Format.dStr(value) : ''; }, startEdit: function() { diff --git a/app/scripts/views/grp-view.js b/app/scripts/views/grp-view.js index b33cc184..c8eafe31 100644 --- a/app/scripts/views/grp-view.js +++ b/app/scripts/views/grp-view.js @@ -12,7 +12,8 @@ var GrpView = Backbone.View.extend({ 'click .grp__icon': 'showIconsSelect', 'click .grp__buttons-trash': 'moveToTrash', 'click .grp__back-button': 'returnToApp', - 'blur #grp__field-title': 'titleBlur' + 'blur #grp__field-title': 'titleBlur', + 'change #grp__check-search': 'setEnableSearching' }, initialize: function() { @@ -26,6 +27,7 @@ var GrpView = Backbone.View.extend({ title: this.model.get('title'), icon: this.model.get('icon') || 'folder', customIcon: this.model.get('customIcon'), + enableSearching: this.model.get('enableSearching') !== false, readonly: this.model.get('top') })); if (!this.model.get('title')) { @@ -107,6 +109,11 @@ var GrpView = Backbone.View.extend({ Backbone.trigger('select-all'); }, + setEnableSearching: function(e) { + var enabled = e.target.checked; + this.model.setEnableSearching(enabled); + }, + returnToApp: function() { Backbone.trigger('edit-group'); } diff --git a/app/scripts/views/icon-select-view.js b/app/scripts/views/icon-select-view.js index a41e4e03..d18d085c 100644 --- a/app/scripts/views/icon-select-view.js +++ b/app/scripts/views/icon-select-view.js @@ -2,7 +2,10 @@ var Backbone = require('backbone'), IconMap = require('../const/icon-map'), - Launcher = require('../comp/launcher'); + Launcher = require('../comp/launcher'), + Logger = require('../util/logger'); + +var logger = new Logger('icon-select-view'); var IconSelectView = Backbone.View.extend({ template: require('templates/icon-select.html'), @@ -67,7 +70,7 @@ var IconSelectView = Backbone.View.extend({ that.downloadingFavicon = false; }; img.onerror = function (e) { - console.error('Favicon download error: ' + url, e); + logger.error('Favicon download error: ' + url, e); that.$el.find('.icon-select__icon-download>i').removeClass('fa-spinner fa-spin'); that.$el.find('.icon-select__icon-download').removeClass('icon-select__icon--custom-selected'); that.downloadingFavicon = false; diff --git a/app/scripts/views/list-view.js b/app/scripts/views/list-view.js index 6c906380..3557ab0f 100644 --- a/app/scripts/views/list-view.js +++ b/app/scripts/views/list-view.js @@ -66,14 +66,15 @@ var ListView = Backbone.View.extend({ if (this.items.length) { var itemTemplate = this.getItemTemplate(); var itemsTemplate = this.getItemsTemplate(); - var presenter = new EntryPresenter(this.getDescField()); + var noColor = AppSettingsModel.instance.get('colorfulIcons') ? '' : 'grayscale'; + var presenter = new EntryPresenter(this.getDescField(), noColor, this.model.activeEntryId); var itemsHtml = ''; this.items.forEach(function (item) { presenter.present(item); itemsHtml += itemTemplate(presenter); }, this); var html = itemsTemplate({ items: itemsHtml }); - this.itemsEl.html(html).scrollTop(0); + this.itemsEl.html(html); } else { this.itemsEl.html(this.emptyTemplate()); } @@ -115,16 +116,14 @@ var ListView = Backbone.View.extend({ }, selectPrev: function() { - var activeItem = this.items.getActive(), - ix = this.items.indexOf(activeItem); + var ix = this.items.indexOf(this.items.get(this.model.activeEntryId)); if (ix > 0) { this.selectItem(this.items.at(ix - 1)); } }, selectNext: function() { - var activeItem = this.items.getActive(), - ix = this.items.indexOf(activeItem); + var ix = this.items.indexOf(this.items.get(this.model.activeEntryId)); if (ix < this.items.length - 1) { this.selectItem(this.items.at(ix + 1)); } @@ -143,10 +142,10 @@ var ListView = Backbone.View.extend({ }, selectItem: function(item) { - this.items.setActive(item); + this.model.activeEntryId = item.id; Backbone.trigger('select-entry', item); this.itemsEl.find('.list__item--active').removeClass('list__item--active'); - var itemEl = document.getElementById(item.get('id')); + var itemEl = document.getElementById(item.id); itemEl.classList.add('list__item--active'); var listEl = this.itemsEl[0], itemRect = itemEl.getBoundingClientRect(), diff --git a/app/scripts/views/menu/menu-item-view.js b/app/scripts/views/menu/menu-item-view.js index b20a0a90..e24be358 100644 --- a/app/scripts/views/menu/menu-item-view.js +++ b/app/scripts/views/menu/menu-item-view.js @@ -96,6 +96,7 @@ var MenuItemView = Backbone.View.extend({ changeExpanded: function(model, expanded) { this.$el.toggleClass('menu__item--collapsed', !expanded); + this.model.setExpanded(expanded); }, changeCls: function(model, cls) { diff --git a/app/scripts/views/open-view.js b/app/scripts/views/open-view.js index 44e88b2f..8dd1a1a4 100644 --- a/app/scripts/views/open-view.js +++ b/app/scripts/views/open-view.js @@ -4,11 +4,10 @@ var Backbone = require('backbone'), Keys = require('../const/keys'), Alerts = require('../comp/alerts'), SecureInput = require('../comp/secure-input'), - FileModel = require('../models/file-model'), - Launcher = require('../comp/launcher'), - LastOpenFiles = require('../comp/last-open-files'), - Storage = require('../comp/storage'), - DropboxLink = require('../comp/dropbox-link'); + DropboxLink = require('../comp/dropbox-link'), + Logger = require('../util/logger'); + +var logger = new Logger('open-view'); var OpenView = Backbone.View.extend({ template: require('templates/open.html'), @@ -31,25 +30,24 @@ var OpenView = Backbone.View.extend({ 'drop': 'drop' }, - fileData: null, - keyFileData: null, + params: null, passwordInput: null, - dropboxLoading: null, + busy: false, initialize: function () { - this.setFileModel(new FileModel()); - this.fileData = null; - this.keyFileData = null; + this.params = { + id: null, + name: '', + storage: null, + path: null, + keyFileName: null, + keyFileData: null, + fileData: null, + rev: null + }; this.passwordInput = new SecureInput(); }, - setFileModel: function(file) { - this.file = file; - this.listenTo(this.file, 'change:open', this.fileOpenChanged); - this.listenTo(this.file, 'change:opening', this.fileOpeningChanged); - this.listenTo(this.file, 'change:error', this.fileErrorChanged); - }, - render: function () { if (this.dragTimeout) { clearTimeout(this.dragTimeout); @@ -61,16 +59,24 @@ var OpenView = Backbone.View.extend({ }, getLastOpenFiles: function() { - return LastOpenFiles.all().map(function(f) { - switch (f.storage) { + return this.model.fileInfos.map(function(f) { + var icon; + switch (f.get('storage')) { case 'dropbox': - f.icon = 'dropbox'; + icon = 'dropbox'; + break; + case 'file': + icon = 'hdd-o'; break; default: - f.icon = 'file-text'; + icon = 'file-text'; break; } - return f; + return { + id: f.get('id'), + name: f.get('name'), + icon: icon + }; }); }, @@ -79,30 +85,6 @@ var OpenView = Backbone.View.extend({ Backbone.View.prototype.remove.apply(this, arguments); }, - fileOpenChanged: function() { - this.model.addFile(this.file); - }, - - fileOpeningChanged: function() { - var opening = this.file.get('opening'); - this.$el.toggleClass('open--opening', opening); - if (opening) { - this.inputEl.attr('disabled', 'disabled'); - this.$el.find('#open__settings-check-offline').attr('disabled', 'disabled'); - } else { - this.inputEl.removeAttr('disabled'); - this.$el.find('#open__settings-check-offline').removeAttr('disabled'); - } - }, - - fileErrorChanged: function() { - if (this.file.get('error')) { - this.inputEl.addClass('input--error').focus(); - this.inputEl[0].selectionStart = 0; - this.inputEl[0].selectionEnd = this.inputEl.val().length; - } - }, - fileSelected: function(e) { var file = e.target.files[0]; if (file) { @@ -113,15 +95,17 @@ var OpenView = Backbone.View.extend({ processFile: function(file, complete) { var reader = new FileReader(); reader.onload = (function(e) { - this[this.reading] = e.target.result; if (this.reading === 'fileData') { - this.file.set({ name: file.name.replace(/\.\w+$/i, ''), offline: false }); - if (file.path) { - this.file.set({ path: file.path, storage: file.storage || 'file' }); - } + this.params.id = null; + this.params.fileData = e.target.result; + this.params.name = file.name.replace(/\.\w+$/i, ''); + this.params.path = file.path || null; + this.params.storage = file.path ? 'file' : null; + this.params.rev = null; this.displayOpenFile(); } else { - this.file.set('keyFileName', file.name); + this.params.keyFileData = e.target.result; + this.params.keyFileName = file.name; this.displayOpenKeyFile(); } if (complete) { @@ -130,7 +114,6 @@ var OpenView = Backbone.View.extend({ }).bind(this); reader.onerror = (function() { Alerts.error({ header: 'Failed to read file' }); - this.showReadyToOpen(); if (complete) { complete(false); } @@ -141,17 +124,13 @@ var OpenView = Backbone.View.extend({ displayOpenFile: function() { this.$el.addClass('open--file'); this.$el.find('.open__settings-key-file').removeClass('hide'); - this.$el.find('#open__settings-check-offline')[0].removeAttribute('disabled'); - var canSwitchOffline = this.file.get('storage') !== 'file' && !this.file.get('offline'); - this.$el.find('.open__settings-offline').toggleClass('hide', !canSwitchOffline); - this.$el.find('.open__settings-offline-warning').toggleClass('hide', !this.file.get('offline')); this.inputEl[0].removeAttribute('readonly'); - this.inputEl[0].setAttribute('placeholder', 'Password for ' + this.file.get('name')); + this.inputEl[0].setAttribute('placeholder', 'Password for ' + this.params.name); this.inputEl.focus(); }, displayOpenKeyFile: function() { - this.$el.find('.open__settings-key-file-name').text(this.file.get('keyFileName')); + this.$el.find('.open__settings-key-file-name').text(this.params.keyFileName); this.$el.addClass('open--key-file'); this.inputEl.focus(); }, @@ -166,54 +145,8 @@ var OpenView = Backbone.View.extend({ }).bind(this)); }, - createDemo: function() { - if (!this.file.get('opening')) { - if (!this.model.files.getByName('Demo')) { - this.file.createDemo(); - } else { - this.trigger('cancel'); - } - } - }, - - createNew: function() { - if (!this.file.get('opening')) { - var name; - for (var i = 0; ; i++) { - name = 'New' + (i || ''); - if (!this.model.files.getByName(name)) { - break; - } - } - this.file.create(name); - } - }, - - showOpenLocalFile: function(path) { - if (path && Launcher) { - try { - var name = path.match(/[^/\\]*$/)[0]; - var data = Launcher.readFile(path); - var file = new Blob([data]); - Object.defineProperties(file, { - path: { value: path }, - name: { value: name } - }); - this.setFile(file); - } catch (e) { - console.log('Failed to show local file', e); - } - } - }, - - showClosedFile: function(file) { - this.setFileModel(file); - this.fileData = file.data; - this.displayOpenFile(); - }, - openFile: function() { - if (!this.file.get('opening')) { + if (!this.busy) { this.openAny('fileData'); } }, @@ -221,10 +154,10 @@ var OpenView = Backbone.View.extend({ openKeyFile: function(e) { if ($(e.target).hasClass('open__settings-key-file-dropbox')) { this.openKeyFileFromDropbox(); - } else if (!this.file.get('opening') && this.file.get('name')) { - if (this.keyFileData) { - this.keyFileData = null; - this.file.set('keyFileName', ''); + } else if (!this.busy && this.params.name) { + if (this.params.keyFileData) { + this.params.keyFileData = null; + this.params.keyFileName = ''; this.$el.removeClass('open--key-file'); this.$el.find('.open__settings-key-file-name').text('key file'); } else { @@ -234,13 +167,13 @@ var OpenView = Backbone.View.extend({ }, openKeyFileFromDropbox: function() { - if (!this.file.get('opening')) { + if (!this.busy) { DropboxLink.chooseFile((function(err, res) { if (err) { return; } - this.keyFileData = res.data; - this.file.set('keyFileName', res.name); + this.params.keyFileData = res.data; + this.params.keyFileName = res.name; this.displayOpenKeyFile(); }).bind(this)); } @@ -248,32 +181,28 @@ var OpenView = Backbone.View.extend({ openAny: function(reading, ext) { this.reading = reading; - this[reading] = null; + this.params[reading] = null; this.$el.find('.open__file-ctrl').attr('accept', ext || '').val(null).click(); }, - openDb: function() { - if (!this.file.get('opening') && this.file.get('name')) { - var offlineChecked = this.$el.find('#open__settings-check-offline').is(':checked'); - if (this.file.get('offline') || - this.file.get('storage') !== 'file' && offlineChecked) { - this.file.set('availOffline', true); - } - var arg = { - password: this.passwordInput.value, - fileData: this.fileData, - keyFileData: this.keyFileData - }; - this.file.set({opening: true, error: false}); - this.afterPaint(function () { - this.file.open(arg.password, arg.fileData, arg.keyFileData); - }); + openLast: function(e) { + if (this.busy) { + return; } + var id = $(e.target).closest('.open__last-item').data('id').toString(); + if ($(e.target).is('.open__last-item-icon-del')) { + this.model.removeFileInfo(id); + this.$el.find('.open__last-item[data-id="' + id + '"]').remove(); + this.initialize(); + this.render(); + return; + } + this.showOpenFileInfo(this.model.fileInfos.get(id)); }, inputKeydown: function(e) { var code = e.keyCode || e.which; - if (code === Keys.DOM_VK_RETURN && this.passwordInput.length) { + if (code === Keys.DOM_VK_RETURN) { this.openDb(); } else if (code === Keys.DOM_VK_CAPS_LOCK) { this.$el.find('.open__pass-warning').removeClass('invisible'); @@ -296,145 +225,6 @@ var OpenView = Backbone.View.extend({ this.$el.find('.open__file-warning').toggleClass('invisible', on); }, - openFromDropbox: function() { - if (this.dropboxLoading || this.file.get('opening')) { - return; - } - var that = this; - DropboxLink.authenticate(function(err) { - if (err) { - return; - } - that.dropboxLoading = 'file list'; - that.displayDropboxLoading(); - DropboxLink.getFileList(function(err, files, dirStat) { - that.dropboxLoading = null; - that.displayDropboxLoading(); - if (err) { - return; - } - var buttons = []; - var allFileNames = {}; - files.forEach(function(file) { - var fileName = file.replace(/\.kdbx/i, ''); - buttons.push({ result: file, title: fileName }); - allFileNames[fileName] = true; - }); - if (!buttons.length) { - Alerts.error({ - header: 'Nothing found', - body: 'You have no files in your Dropbox which could be opened.' + - (dirStat && dirStat.inAppFolder ? ' Files are searched inside app folder in your Dropbox.' : '') - }); - return; - } - buttons.push({ result: '', title: 'Cancel' }); - Alerts.alert({ - header: 'Select a file', - body: 'Select a file from your Dropbox which you would like to open', - icon: 'dropbox', - buttons: buttons, - esc: '', - click: '', - success: that.openDropboxFile.bind(that), - cancel: function() { - that.dropboxLoading = null; - that.displayDropboxLoading(); - } - }); - LastOpenFiles.all().forEach(function(lastOpenFile) { - if (lastOpenFile.storage === 'dropbox' && !allFileNames[lastOpenFile.name]) { - that.delLast(lastOpenFile.name); - } - }); - }); - }); - }, - - openDropboxFile: function(file) { - var fileName = file.replace(/\.kdbx/i, ''); - this.dropboxLoading = fileName; - this.displayDropboxLoading(); - var lastOpen = LastOpenFiles.byName(fileName); - var errorAlertCallback = lastOpen && lastOpen.storage === 'dropbox' && lastOpen.availOffline ? - this.dropboxErrorCallback.bind(this, fileName) : null; - DropboxLink.openFile(file, (function(err, data) { - this.dropboxLoading = null; - this.displayDropboxLoading(); - if (err || !data || !data.size) { - return; - } - Object.defineProperties(data, { - storage: { value: 'dropbox' }, - path: { value: file }, - name: { value: fileName } - }); - this.setFile(data); - }).bind(this), errorAlertCallback); - }, - - dropboxErrorCallback: function(fileName, alertConfig) { - alertConfig.body += '
You have offline version of this file cached. Would you like to open it?'; - alertConfig.buttons = [ - {result: 'offline', title: 'Open offline file'}, - {result: 'yes', title: 'OK'} - ]; - alertConfig.success = (function(result) { - if (result === 'offline') { - this.openCache(fileName, 'dropbox'); - } - }).bind(this); - Alerts.error(alertConfig); - }, - - displayDropboxLoading: function() { - this.$el.find('.open__icon-dropbox .open__icon-i').toggleClass('flip3d', !!this.dropboxLoading); - }, - - openLast: function(e) { - if (this.dropboxLoading || this.file.get('opening')) { - return; - } - var name = $(e.target).closest('.open__last-item').data('name').toString(); - if ($(e.target).is('.open__last-item-icon-del')) { - this.delLast(name); - return; - } - var lastOpenFile = LastOpenFiles.byName(name); - switch (lastOpenFile.storage) { - case 'dropbox': - return this.openDropboxFile(lastOpenFile.path); - case 'file': - return this.showOpenLocalFile(lastOpenFile.path); - default: - return this.openCache(name); - } - }, - - openCache: function(name, storage) { - Storage.cache.load(name, (function(data, err) { - if (err) { - this.delLast(name); - Alerts.error({ - header: 'Error loading file', - body: 'There was an error loading offline file ' + name + '. Please, open it from file' - }); - } else { - this.fileData = data; - this.file.set({ name: name, offline: true, availOffline: true }); - if (storage) { - this.file.set({ storage: storage }); - } - this.displayOpenFile(); - } - }).bind(this)); - }, - - delLast: function(name) { - LastOpenFiles.remove(name); - this.$el.find('.open__last-item[data-name="' + name + '"]').remove(); - }, - dragover: function(e) { e.preventDefault(); if (this.dragTimeout) { @@ -466,6 +256,143 @@ var OpenView = Backbone.View.extend({ if (dataFile) { this.setFile(dataFile, keyFile); } + }, + + displayDropboxLoading: function(isLoading) { + this.$el.find('.open__icon-dropbox .open__icon-i').toggleClass('flip3d', !!isLoading); + }, + + openFromDropbox: function() { + if (this.busy) { + return; + } + var that = this; + DropboxLink.authenticate(function(err) { + if (err) { + return; + } + that.busy = true; + that.displayDropboxLoading(true); + DropboxLink.getFileList(function(err, files, dirStat, filesStat) { + that.busy = false; + that.displayDropboxLoading(false); + if (err) { + return; + } + var buttons = []; + var allDropboxFiles = {}; + filesStat.forEach(function(file) { + if (!file.isFolder && !file.isRemoved) { + var fileName = file.name.replace(/\.kdbx/i, ''); + buttons.push({ result: file.path, title: fileName }); + allDropboxFiles[file.path] = file; + } + }); + if (!buttons.length) { + Alerts.error({ + header: 'Nothing found', + body: 'You have no files in your Dropbox which could be opened.' + + (dirStat && dirStat.inAppFolder ? ' Files are searched inside app folder in your Dropbox.' : '') + }); + return; + } + buttons.push({ result: '', title: 'Cancel' }); + Alerts.alert({ + header: 'Select a file', + body: 'Select a file from your Dropbox which you would like to open', + icon: 'dropbox', + buttons: buttons, + esc: '', + click: '', + success: function(file) { + that.openDropboxFile(allDropboxFiles[file]); + } + }); + that.model.fileInfos.forEach(function(fi) { + if (fi.get('storage') === 'dropbox' && !fi.get('modified') && !allDropboxFiles[fi.get('path')]) { + that.model.removeFileInfo(fi.id); + } + }); + }); + }); + }, + + openDropboxFile: function(fileStat) { + if (this.busy) { + return; + } + this.params.id = null; + this.params.storage = 'dropbox'; + this.params.path = fileStat.path; + this.params.name = fileStat.name.replace(/\.kdbx/i, ''); + this.params.rev = fileStat.versionTag; + this.params.fileData = null; + this.displayOpenFile(); + }, + + showOpenFileInfo: function(fileInfo) { + if (this.busy || !fileInfo) { + return; + } + this.params.id = fileInfo.id; + this.params.storage = fileInfo.get('storage'); + this.params.path = fileInfo.get('path'); + this.params.name = fileInfo.get('name'); + this.params.fileData = null; + this.params.rev = null; + this.displayOpenFile(); + }, + + showOpenLocalFile: function(path) { + if (this.busy) { + return; + } + this.params.id = null; + this.params.storage = 'file'; + this.params.path = path; + this.params.name = path.match(/[^/\\]*$/)[0]; + this.params.rev = null; + this.params.fileData = null; + this.displayOpenFile(); + }, + + createDemo: function() { + if (!this.busy) { + if (!this.model.createDemoFile()) { + this.trigger('close'); + } + } + }, + + createNew: function() { + if (!this.busy) { + this.model.createNewFile(); + } + }, + + openDb: function() { + if (this.busy || !this.params.name) { + return; + } + this.$el.toggleClass('open--opening', true); + this.inputEl.attr('disabled', 'disabled'); + this.busy = true; + this.params.password = this.passwordInput.value; + this.afterPaint(this.model.openFile.bind(this.model, this.params, this.openDbComplete.bind(this))); + }, + + openDbComplete: function(err) { + this.busy = false; + this.$el.toggleClass('open--opening', false); + this.inputEl.removeAttr('disabled').toggleClass('input--error', !!err); + if (err) { + logger.error('Error opening file', err); + this.inputEl.focus(); + this.inputEl[0].selectionStart = 0; + this.inputEl[0].selectionEnd = this.inputEl.val().length; + } else { + this.trigger('close'); + } } }); diff --git a/app/scripts/views/settings/settings-file-view.js b/app/scripts/views/settings/settings-file-view.js index 1edc6bdb..50cfb231 100644 --- a/app/scripts/views/settings/settings-file-view.js +++ b/app/scripts/views/settings/settings-file-view.js @@ -5,8 +5,10 @@ var Backbone = require('backbone'), PasswordGenerator = require('../../util/password-generator'), Alerts = require('../../comp/alerts'), Launcher = require('../../comp/launcher'), + Storage = require('../../storage'), Links = require('../../const/links'), DropboxLink = require('../../comp/dropbox-link'), + Format = require('../../util/format'), kdbxweb = require('kdbxweb'), FileSaver = require('filesaver'); @@ -14,9 +16,10 @@ var SettingsAboutView = Backbone.View.extend({ template: require('templates/settings/settings-file.html'), events: { + 'click .settings__file-button-save-default': 'saveDefault', 'click .settings__file-button-save-file': 'saveToFile', 'click .settings__file-button-export-xml': 'exportAsXml', - 'click .settings__file-button-save-dropbox': 'saveToDropboxClick', + 'click .settings__file-button-save-dropbox': 'saveToDropbox', 'click .settings__file-button-close': 'closeFile', 'change #settings__file-key-file': 'keyFileChange', 'mousedown #settings__file-file-select-link': 'triggerSelectFile', @@ -31,7 +34,10 @@ var SettingsAboutView = Backbone.View.extend({ 'blur #settings__file-key-rounds': 'blurKeyRounds' }, + appModel: null, + initialize: function() { + this.listenTo(this.model, 'change:syncing change:syncError change:syncDate', this.render); }, render: function() { @@ -44,6 +50,8 @@ var SettingsAboutView = Backbone.View.extend({ path: this.model.get('path'), storage: this.model.get('storage'), syncing: this.model.get('syncing'), + syncError: this.model.get('syncError'), + syncDate: Format.dtStr(this.model.get('syncDate')), password: PasswordGenerator.present(this.model.get('passwordLength')), defaultUser: this.model.get('defaultUser'), recycleBinEnabled: this.model.get('recycleBinEnabled'), @@ -81,122 +89,125 @@ var SettingsAboutView = Backbone.View.extend({ } }, - validate: function() { + validatePassword: function(continueCallback) { if (!this.model.get('passwordLength')) { - Alerts.error({ + var that = this; + Alerts.yesno({ header: 'Empty password', - body: 'Please, enter the password. You will use it the next time you open this file.', - complete: (function() { this.$el.find('#settings__file-master-pass').focus(); }).bind(this) + body: 'Saving database with empty password makes it completely unprotected. Do you really want to do it?', + success: function() { + continueCallback(); + }, + cancel: function() { + that.$el.find('#settings__file-master-pass').focus(); + } }); return false; } return true; }, - saveToFile: function() { - if (!this.validate()) { - return; - } + save: function(arg) { var that = this; - this.model.getData(function(data) { - var fileName = that.model.get('name') + '.kdbx'; - if (Launcher) { - if (that.model.get('path')) { - that.saveToFileWithPath(that.model.get('path'), data); - } else { - Launcher.getSaveFileName(fileName, function (path) { - if (path) { - that.saveToFileWithPath(path, data); - } - }); - } - } else { - var blob = new Blob([data], {type: 'application/octet-stream'}); - FileSaver.saveAs(blob, fileName); - that.passwordChanged = false; - if (that.model.get('storage') !== 'dropbox') { - that.model.saved(); - } + if (!arg) { + arg = {}; + } + arg.startedByUser = true; + if (!arg.skipValidation) { + var isValid = this.validatePassword(function() { + arg.skipValidation = true; + that.save(arg); + }); + if (!isValid) { + return; } - }); + } + this.appModel.syncFile(this.model, arg); }, - saveToFileWithPath: function(path, data) { - try { - Launcher.writeFile(path, data); - this.passwordChanged = false; - this.model.saved(path, 'file'); - } catch (e) { - console.error('Error saving file', path, e); - Alerts.error({ - header: 'Save error', - body: 'Error saving to file ' + path + ': \n' + e + saveDefault: function() { + this.save(); + }, + + saveToFile: function(skipValidation) { + if (skipValidation !== true && !this.validatePassword(this.saveToFile.bind(this, true))) { + return; + } + var fileName = this.model.get('name') + '.kdbx'; + var that = this; + if (Launcher && !this.model.get('storage')) { + Launcher.getSaveFileName(fileName, function (path) { + if (path) { + that.save({storage: 'file', path: path}); + } + }); + } else { + this.model.getData(function (data) { + if (Launcher) { + Launcher.getSaveFileName(fileName, function (path) { + if (path) { + Storage.file.save(path, data, function (err) { + if (err) { + Alerts.error({ + header: 'Save error', + body: 'Error saving to file ' + path + ': \n' + err + }); + } + }); + } + }); + } else { + var blob = new Blob([data], {type: 'application/octet-stream'}); + FileSaver.saveAs(blob, fileName); + } }); } }, exportAsXml: function() { - if (!this.validate()) { - return; - } this.model.getXml((function(xml) { var blob = new Blob([xml], {type: 'text/xml'}); FileSaver.saveAs(blob, this.model.get('name') + '.xml'); }).bind(this)); }, - saveToDropboxClick: function() { - var nameChanged = this.model.get('path') !== this.model.get('name') + '.kdbx', - canOverwrite = !nameChanged; - this.saveToDropbox(canOverwrite); - }, - - saveToDropbox: function(overwrite) { - if (this.model.get('syncing') || !this.validate()) { - return; - } + saveToDropbox: function() { var that = this; - this.model.getData(function(data) { - var fileName = that.model.get('name') + '.kdbx'; - that.model.set('syncing', true); - that.render(); - DropboxLink.authenticate(function(err) { - if (err) { + this.model.set('syncing', true); + DropboxLink.authenticate(function(err) { + that.model.set('syncing', false); + if (err) { + return; + } + if (that.model.get('storage') === 'dropbox') { + that.save(); + } else { + that.model.set('syncing', true); + DropboxLink.getFileList(function(err, files) { that.model.set('syncing', false); - that.render(); - return; - } - DropboxLink.saveFile(fileName, data, overwrite, function (err) { - if (err) { - that.model.set('syncing', false); - that.render(); - if (err.exists) { - Alerts.alert({ - header: 'Already exists', - body: 'File ' + fileName + ' already exists in your Dropbox.', - icon: 'question', - buttons: [{result: 'yes', title: 'Overwrite it'}, {result: '', title: 'I\'ll choose another name'}], - esc: '', - click: '', - enter: 'yes', - success: that.saveToDropbox.bind(that, true), - cancel: function () { - that.$el.find('#settings__file-name').focus(); - } - }); - } else { - Alerts.error({ - header: 'Save error', - body: 'Error saving to Dropbox: \n' + err - }); - } + if (!files) { return; } + var expName = that.model.get('name').toLowerCase(); + var existingPath = files.filter(function(f) { return f.toLowerCase().replace('/', '') === expName; })[0]; + if (existingPath) { + Alerts.yesno({ + icon: 'dropbox', + header: 'Already exists', + body: 'File ' + that.model.escape('name') + ' already exists in your Dropbox. Overwrite it?', + success: function() { + that.model.set('syncing', true); + DropboxLink.deleteFile(existingPath, function(err) { + that.model.set('syncing', false); + if (!err) { + that.save({storage: 'dropbox'}); + } + }); + } + }); } else { - that.passwordChanged = false; - that.model.saved(fileName, 'dropbox'); - that.render(); + that.save({storage: 'dropbox'}); } }); - }); + } }); }, @@ -223,7 +234,7 @@ var SettingsAboutView = Backbone.View.extend({ }, closeFileNoCheck: function() { - Backbone.trigger('close-file', this.model); + this.appModel.closeFile(this.model); }, keyFileChange: function(e) { @@ -273,20 +284,16 @@ var SettingsAboutView = Backbone.View.extend({ }, focusMasterPass: function(e) { - if (!this.passwordChanged) { - e.target.value = ''; - } + e.target.value = ''; e.target.setAttribute('type', 'text'); }, blurMasterPass: function(e) { if (!e.target.value) { - this.passwordChanged = false; this.model.resetPassword(); e.target.value = PasswordGenerator.present(this.model.get('passwordLength')); this.$el.find('.settings__file-master-pass-warning').hide(); } else { - this.passwordChanged = true; this.model.setPassword(kdbxweb.ProtectedValue.fromString(e.target.value)); if (!this.model.get('created')) { this.$el.find('.settings__file-master-pass-warning').show(); diff --git a/app/scripts/views/settings/settings-general-view.js b/app/scripts/views/settings/settings-general-view.js index 60b07fb7..a6e61c35 100644 --- a/app/scripts/views/settings/settings-general-view.js +++ b/app/scripts/views/settings/settings-general-view.js @@ -21,7 +21,9 @@ var SettingsGeneralView = Backbone.View.extend({ 'change .settings__general-clipboard': 'changeClipboard', 'change .settings__general-auto-save': 'changeAutoSave', 'change .settings__general-minimize': 'changeMinimize', + 'change .settings__general-lock-on-minimize': 'changeLockOnMinimize', 'change .settings__general-table-view': 'changeTableView', + 'change .settings__general-colorful-icons': 'changeColorfulIcons', 'click .settings__general-update-btn': 'checkUpdate', 'click .settings__general-restart-btn': 'restartApp', 'click .settings__general-download-update-btn': 'downloadUpdate', @@ -30,9 +32,9 @@ var SettingsGeneralView = Backbone.View.extend({ }, allThemes: { - d: 'default', - fb: 'flat blue', - wh: 'white' + fb: 'Flat blue', + db: 'Dark brown', + wh: 'White' }, initialize: function() { @@ -52,7 +54,8 @@ var SettingsGeneralView = Backbone.View.extend({ minimizeOnClose: AppSettingsModel.instance.get('minimizeOnClose'), devTools: Launcher && Launcher.devTools, canAutoUpdate: !!Launcher, - canMinimizeOnClose: Launcher && Launcher.canMinimize(), + canMinimize: Launcher && Launcher.canMinimize(), + lockOnMinimize: Launcher && AppSettingsModel.instance.get('lockOnMinimize'), tableView: AppSettingsModel.instance.get('tableView'), canSetTableView: FeatureDetector.isDesktop(), autoUpdate: Updater.getAutoUpdateType(), @@ -61,7 +64,8 @@ var SettingsGeneralView = Backbone.View.extend({ updateReady: UpdateModel.instance.get('updateStatus') === 'ready', updateFound: UpdateModel.instance.get('updateStatus') === 'found', updateManual: UpdateModel.instance.get('updateManual'), - releaseNotesLink: Links.ReleaseNotes + releaseNotesLink: Links.ReleaseNotes, + colorfulIcons: AppSettingsModel.instance.get('colorfulIcons') }); }, @@ -138,12 +142,23 @@ var SettingsGeneralView = Backbone.View.extend({ AppSettingsModel.instance.set('minimizeOnClose', minimizeOnClose); }, + changeLockOnMinimize: function(e) { + var lockOnMinimize = e.target.checked || false; + AppSettingsModel.instance.set('lockOnMinimize', lockOnMinimize); + }, + changeTableView: function(e) { var tableView = e.target.checked || false; AppSettingsModel.instance.set('tableView', tableView); Backbone.trigger('refresh'); }, + changeColorfulIcons: function(e) { + var colorfulIcons = e.target.checked || false; + AppSettingsModel.instance.set('colorfulIcons', colorfulIcons); + Backbone.trigger('refresh'); + }, + restartApp: function() { if (Launcher) { Launcher.requestRestart(); diff --git a/app/scripts/views/settings/settings-view.js b/app/scripts/views/settings/settings-view.js index 2c349e30..5ada9004 100644 --- a/app/scripts/views/settings/settings-view.js +++ b/app/scripts/views/settings/settings-view.js @@ -46,6 +46,7 @@ var SettingsView = Backbone.View.extend({ } var SettingsPageView = require('./settings-' + e.page + '-view'); this.views.page = new SettingsPageView({ el: this.pageEl, model: e.file }); + this.views.page.appModel = this.model; this.views.page.render(); this.file = e.file; this.page = e.page; diff --git a/app/styles/areas/_details.scss b/app/styles/areas/_details.scss index 93ae878a..cd97b588 100644 --- a/app/styles/areas/_details.scss +++ b/app/styles/areas/_details.scss @@ -130,6 +130,10 @@ @include flex-wrap(wrap); overflow-x: hidden; padding-top: 3px; + + @include scrollbar-full-width-hack(); + @-moz-document url-prefix() { @include scrollbar-padding-hack(); } + @at-root { _:-ms-lang(x), .details__body>.scroller { @include scrollbar-padding-hack(); } } } &-fields { diff --git a/app/styles/areas/_footer.scss b/app/styles/areas/_footer.scss index 3521075d..7cef9ed7 100644 --- a/app/styles/areas/_footer.scss +++ b/app/styles/areas/_footer.scss @@ -32,6 +32,7 @@ right: 1em; top: 1em; @include th { color: action-color(); } + &--error { @include th { color: error-color(); } } } } diff --git a/app/styles/areas/_list.scss b/app/styles/areas/_list.scss index 1a3b66bc..0d78f588 100644 --- a/app/styles/areas/_list.scss +++ b/app/styles/areas/_list.scss @@ -21,6 +21,11 @@ @include flex(1); @include align-self(stretch); position: relative; + .app__list-wrap--table & { + @include scrollbar-full-width-hack(); + @-moz-document url-prefix() { @include scrollbar-padding-hack(); } + @at-root { _:-ms-lang(x), .app__list-wrap--table .list__items>.scroller { @include scrollbar-padding-hack(); } } + } @include mobile { width: 100% !important; } } } @@ -74,7 +79,7 @@ } &__table { - width: calc(100% - 1px); + width: calc(100% - 2px); td, th { padding: $base-padding; text-align: left; @@ -104,7 +109,6 @@ height: 14px; &--custom { vertical-align: text-bottom; - @include filter(grayscale(1)); &.yellow { @include filter(grayscale(1) sepia(1) hue-rotate(20deg) brightness(1.17) saturate(5.7)); } &.green { @include filter(grayscale(1) sepia(1) hue-rotate(55deg) brightness(1.01) saturate(4.9)); } &.red { @include filter(grayscale(1) sepia(1) hue-rotate(316deg) brightness(1.1) saturate(6)); } diff --git a/app/styles/areas/_open.scss b/app/styles/areas/_open.scss index 1f83f164..1fe407f2 100644 --- a/app/styles/areas/_open.scss +++ b/app/styles/areas/_open.scss @@ -103,8 +103,7 @@ } } - &-key-file, &-label-offline, &-label-offline:before, input[type=checkbox] + label.open__settings-label-offline:before, - &-key-file-dropbox { + &-key-file, &-key-file-dropbox { @include th { color: muted-color(); } @@ -114,11 +113,6 @@ } } } - &-label-offline { font-weight: normal; } - } - - &--file:not(.open--opening) input[type=checkbox] + label.open__settings-label-offline:hover:before { - @include th { color: medium-color(); } } &__last { diff --git a/app/styles/base/_colors.scss b/app/styles/base/_colors.scss index 6f31bd77..de9bd23c 100644 --- a/app/styles/base/_colors.scss +++ b/app/styles/base/_colors.scss @@ -59,3 +59,4 @@ $all-colors: ( } .muted-color { @include th { color: muted-color(); }; } .action-color { @include th { color: action-color(); }; } +.error-color { @include th { color: error-color(); }; } diff --git a/app/styles/base/_forms.scss b/app/styles/base/_forms.scss index 45a40edf..929b3a6f 100644 --- a/app/styles/base/_forms.scss +++ b/app/styles/base/_forms.scss @@ -155,7 +155,7 @@ option { input[type=checkbox] { display: none; - & + label:hover:before { + &:not([disabled]) + label:hover:before { @include th { color: action-color(); } diff --git a/app/styles/common/_scroll.scss b/app/styles/common/_scroll.scss index b1b1145f..2c0efdd4 100644 --- a/app/styles/common/_scroll.scss +++ b/app/styles/common/_scroll.scss @@ -1,12 +1,23 @@ +@mixin scrollbar-padding-hack { + // workaround for bugs in custom scrollbar component (baron) + // https://github.com/Diokuz/baron/issues/91 + // https://github.com/Diokuz/baron/issues/93 + margin-right: -30px !important; + padding-right: 30px !important; +} + +@mixin scrollbar-full-width-hack { + // workaround for bugs in custom scrollbar component (baron) + width: auto !important; + min-width: 0 !important; + max-width: none !important; +} + .scroller { overflow-y: scroll; height: 100%; @-moz-document url-prefix() { - // until Firefox bug is fixed in baron, hide native scroll area manually - // https://github.com/Diokuz/baron/issues/91 - // https://github.com/Diokuz/baron/issues/93 - margin-right: -30px; - padding-right: 30px; + @include scrollbar-padding-hack(); } &::-webkit-scrollbar { width: 0; @@ -31,6 +42,15 @@ } } +@media screen and (-moz-windows-theme) { + .scroller { + @-moz-document url-prefix() { + margin-right: 0 !important; + padding-right: 0 !important; + } + } +} + @mixin scrollbar-on-hover { .scroller__bar-wrapper { >.scroller__bar { diff --git a/app/styles/themes/_all-themes.scss b/app/styles/themes/_all-themes.scss index 5bd55d16..24d1b4a7 100644 --- a/app/styles/themes/_all-themes.scss +++ b/app/styles/themes/_all-themes.scss @@ -1,5 +1,5 @@ $themes: (); -@import "default"; +@import "dark-brown"; @import "flat-blue"; @import "white"; diff --git a/app/styles/themes/_default.scss b/app/styles/themes/_dark-brown.scss similarity index 95% rename from app/styles/themes/_default.scss rename to app/styles/themes/_dark-brown.scss index d2d0d8ea..909511f2 100644 --- a/app/styles/themes/_default.scss +++ b/app/styles/themes/_dark-brown.scss @@ -1,5 +1,5 @@ $themes: map-merge($themes, ( - d: ( + db: ( background-color: #342F2E, medium-color: #FED9D8, text-color: #FFEAE9, diff --git a/app/templates/footer.html b/app/templates/footer.html index 90841037..86222d64 100644 --- a/app/templates/footer.html +++ b/app/templates/footer.html @@ -2,8 +2,10 @@ <% files.forEach(function(file) { %> <% }); %> diff --git a/app/templates/grp.html b/app/templates/grp.html index 5e04f351..bcce46ba 100644 --- a/app/templates/grp.html +++ b/app/templates/grp.html @@ -9,6 +9,12 @@ /> + <% if (!readonly) { %> +
+ /> + +
+ <% } %> <% if (customIcon) { %> diff --git a/app/templates/list-item-table.html b/app/templates/list-item-table.html index 24821f32..60e4520e 100644 --- a/app/templates/list-item-table.html +++ b/app/templates/list-item-table.html @@ -2,7 +2,7 @@ <% if (customIcon) { %><% } else { %><% } %> <%- title || '(no title)' %> - <%- description %> + <%- user %> <%- url %> <%- tags %> <%- notes %> diff --git a/app/templates/open.html b/app/templates/open.html index 9aaef660..220e1761 100644 --- a/app/templates/open.html +++ b/app/templates/open.html @@ -34,15 +34,10 @@ key file (from dropbox) -
- - -
-
saved offline version
<% lastOpenFiles.forEach(function(file) { %> -
+
<%- file.name %> diff --git a/app/templates/settings/settings-file.html b/app/templates/settings/settings-file.html index ad9db3c3..d064846c 100644 --- a/app/templates/settings/settings-file.html +++ b/app/templates/settings/settings-file.html @@ -7,21 +7,27 @@

This file is opened from Dropbox.

<% } %> <% } else { %> -

This database is loaded in memory. To enable auto-save and saving with shortcut <%= cmd %>S, - please, save it to <%= supportFiles ? 'file or ' : '' %>Dropbox.

+

This file is stored in internal app storage.

<% if (!supportFiles) { %>

Want to work seamlessly with local files? Download a desktop app

<% } %> <% } %>
- + <% if (!storage || storage === 'file') { %><% } %> + + <% if (storage !== 'file') { %><% } %> -
+ <% if (storage) { %> +

Sync

+
Last sync: <%= syncDate || 'unknown' %> <%= syncing ? '(sync in progress...)' : '' %>
+ <% if (syncError) { %>
Sync error: <%- syncError %>
<% } %> + <% } %> +

Settings

<% } %> +
+ /> + +

Function

@@ -85,13 +89,18 @@
<% } %> - <% if (canMinimizeOnClose) { %> + <% if (canMinimize) { %>
/> + <%- minimizeOnClose ? 'checked' : '' %> />
<% } %> +
+ /> + +
<% if (devTools) { %>

Advanced

diff --git a/app/templates/settings/settings-help.html b/app/templates/settings/settings-help.html index 8cc5db6a..834022c9 100644 --- a/app/templates/settings/settings-help.html +++ b/app/templates/settings/settings-help.html @@ -29,5 +29,5 @@

Updates

-

App twitter: kee_web

+

App twitter: kee_web

diff --git a/bower.json b/bower.json index 0ff0ad38..f3957cd2 100644 --- a/bower.json +++ b/bower.json @@ -24,12 +24,12 @@ "private": true, "dependencies": { "backbone": "~1.2.3", - "baron": "~0.7.11", + "baron": "~1.0.1", "bourbon": "~4.2.5", - "dropbox": "antelle/dropbox-js#0.10.5", + "dropbox": "antelle/dropbox-js#0.10.6", "font-awesome": "~4.4.0", "install": "~1.0.4", - "kdbxweb": "~0.2.7", + "kdbxweb": "~0.3.2", "normalize.css": "~3.0.3", "pikaday": "~1.3.3", "zepto": "~1.1.6", diff --git a/electron/app.js b/electron/app.js index 9bf72d43..422086b1 100644 --- a/electron/app.js +++ b/electron/app.js @@ -28,15 +28,56 @@ app.on('window-all-closed', function() { app.removeAllListeners('window-all-closed'); app.removeAllListeners('ready'); app.removeAllListeners('open-file'); + app.removeAllListeners('activate'); var userDataAppFile = path.join(app.getPath('userData'), 'app.js'); delete require.cache[require.resolve('./app.js')]; require(userDataAppFile); app.emit('ready'); } else { - app.quit(); + if (process.platform !== 'darwin') { + app.quit(); + } } }); app.on('ready', function() { + createMainWindow(); +}); +app.on('open-file', function(e, path) { + e.preventDefault(); + openFile = path; + notifyOpenFile(); +}); +app.on('activate', function() { + if (process.platform === 'darwin') { + if (!mainWindow) { + createMainWindow(); + } + } +}); +app.restartApp = function() { + restartPending = true; + mainWindow.close(); + setTimeout(function() { restartPending = false; }, 1000); +}; +app.openWindow = function(opts) { + return new BrowserWindow(opts); +}; +app.minimizeApp = function() { + if (process.platform === 'win32') { + mainWindow.minimize(); + mainWindow.setSkipTaskbar(true); + appIcon = new Tray(path.join(__dirname, 'icon.png')); + appIcon.on('click', restoreMainWindow); + var contextMenu = Menu.buildFromTemplate([ + { label: 'Open KeeWeb', click: restoreMainWindow }, + { label: 'Quit KeeWeb', click: closeMainWindow } + ]); + appIcon.setContextMenu(contextMenu); + appIcon.setToolTip('KeeWeb'); + } +}; + +function createMainWindow() { mainWindow = new BrowserWindow({ show: false, width: 1000, height: 700, 'min-width': 600, 'min-height': 300, @@ -54,34 +95,10 @@ app.on('ready', function() { mainWindow.on('closed', function() { mainWindow = null; }); -}); -app.on('open-file', function(e, path) { - e.preventDefault(); - openFile = path; - notifyOpenFile(); -}); -app.restartApp = function() { - restartPending = true; - mainWindow.close(); - setTimeout(function() { restartPending = false; }, 1000); -}; -app.openWindow = function(opts) { - return new BrowserWindow(opts); -}; -app.minimizeApp = function() { - if (process.platform === 'win32') { - mainWindow.minimize(); - mainWindow.setSkipTaskbar(true); - appIcon = new Tray(path.join(__dirname, 'icon.png')); - appIcon.on('clicked', restoreMainWindow); - var contextMenu = Menu.buildFromTemplate([ - { label: 'Open KeeWeb', click: restoreMainWindow }, - { label: 'Quit KeeWeb', click: closeMainWindow } - ]); - appIcon.setContextMenu(contextMenu); - appIcon.setToolTip('KeeWeb'); - } -}; + mainWindow.on('minimize', function() { + emitBackboneEvent('launcher-minimize'); + }); +} function restoreMainWindow() { appIcon.destroy(); @@ -93,7 +110,11 @@ function restoreMainWindow() { function closeMainWindow() { appIcon.destroy(); appIcon = null; - mainWindow.webContents.executeJavaScript('Backbone.trigger("launcher-exit-request");'); + emitBackboneEvent('launcher-exit-request'); +} + +function emitBackboneEvent(e) { + mainWindow.webContents.executeJavaScript('Backbone.trigger("' + e + '");'); } function setMenu() { diff --git a/keeweb.iml b/keeweb.iml index 785b6491..0892b3c4 100644 --- a/keeweb.iml +++ b/keeweb.iml @@ -10,6 +10,5 @@ - \ No newline at end of file diff --git a/release-notes.md b/release-notes.md index d8f445a4..a02af186 100644 --- a/release-notes.md +++ b/release-notes.md @@ -1,5 +1,17 @@ Release notes ------------- +##### v0.5 (not released yet) +2-way merge sync +`*` all files are now opened with offline support +`*` disallow opening same files twice +`*` default theme is now blue +`+` #46: option to show colorful icons +`+` #45: optional auto-lock on minimize +`+` option to disable searching for group +`+` #62: saving files with empty password +`+` #56: preserve selected entry after close +`-` #55: custom scrollbar issues + ##### v0.4.6 (2015-11-25) `-` #32: visual glitches on Windows 10