Refactor session history into SFJS
This commit is contained in:
@@ -72,6 +72,7 @@ module.exports = function(grunt) {
|
||||
src: [
|
||||
'node_modules/sn-models/dist/sn-models.js',
|
||||
'app/assets/javascripts/app/*.js',
|
||||
'app/assets/javascripts/app/models/**/*.js',
|
||||
'app/assets/javascripts/app/controllers/**/*.js',
|
||||
'app/assets/javascripts/app/services/**/*.js',
|
||||
'app/assets/javascripts/app/filters/**/*.js',
|
||||
|
||||
@@ -49,7 +49,7 @@ class ActionsMenu {
|
||||
switch (action.verb) {
|
||||
case "render": {
|
||||
var item = response.item;
|
||||
actionsManager.presentRevisionPreviewModal(item);
|
||||
actionsManager.presentRevisionPreviewModal(item.uuid, item.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,8 @@ class RevisionPreviewModal {
|
||||
this.restrict = "E";
|
||||
this.templateUrl = "directives/revision-preview-modal.html";
|
||||
this.scope = {
|
||||
revision: "=",
|
||||
show: "=",
|
||||
callback: "="
|
||||
uuid: "=",
|
||||
content: "="
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,25 +26,23 @@ class RevisionPreviewModal {
|
||||
|
||||
var item;
|
||||
if(asCopy) {
|
||||
var contentCopy = Object.assign({}, $scope.revision.content);
|
||||
var contentCopy = Object.assign({}, $scope.content);
|
||||
if(contentCopy.title) { contentCopy.title += " (copy)"; }
|
||||
item = modelManager.createItem({content_type: "Note", content: contentCopy});
|
||||
modelManager.addItem(item);
|
||||
} else {
|
||||
// revision can be an ItemRevision revision object or just a plain SFItem
|
||||
var uuid = $scope.revision.uuid;
|
||||
var uuid = $scope.uuid;
|
||||
item = modelManager.findItem(uuid);
|
||||
item.content = Object.assign({}, $scope.revision.content);
|
||||
item.content = Object.assign({}, $scope.content);
|
||||
modelManager.mapResponseItemsToLocalModels([item], SFModelManager.MappingSourceRemoteActionRetrieved);
|
||||
}
|
||||
|
||||
item.setDirty(true);
|
||||
syncManager.sync();
|
||||
|
||||
$scope.dismiss();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
angular.module('app').directive('revisionPreviewModal', () => new RevisionPreviewModal);
|
||||
|
||||
@@ -20,7 +20,7 @@ class SessionHistoryMenu {
|
||||
$scope.reloadHistory();
|
||||
|
||||
$scope.openRevision = function(revision) {
|
||||
actionsManager.presentRevisionPreviewModal(revision);
|
||||
actionsManager.presentRevisionPreviewModal(revision.item.uuid, revision.item.content);
|
||||
}
|
||||
|
||||
$scope.classForRevision = function(revision) {
|
||||
|
||||
37
app/assets/javascripts/app/models/noteHistoryEntry.js
Normal file
37
app/assets/javascripts/app/models/noteHistoryEntry.js
Normal file
@@ -0,0 +1,37 @@
|
||||
class NoteHistoryEntry extends SFItemHistoryEntry {
|
||||
|
||||
setPreviousEntry(previousEntry) {
|
||||
super.setPreviousEntry(previousEntry);
|
||||
if(previousEntry) {
|
||||
this.textCharDiffLength = this.item.content.text.length - previousEntry.item.content.text.length;
|
||||
} else {
|
||||
this.textCharDiffLength = this.item.content.text.length;
|
||||
}
|
||||
}
|
||||
|
||||
previewTitle() {
|
||||
return this.item.updated_at.toLocaleString();
|
||||
}
|
||||
|
||||
operationVector() {
|
||||
if(!this.hasPreviousEntry || this.textCharDiffLength == 0) {
|
||||
return 0;
|
||||
} else if(this.textCharDiffLength < 0) {
|
||||
return -1;
|
||||
} else {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
previewSubTitle() {
|
||||
if(!this.hasPreviousEntry) {
|
||||
return `${this.textCharDiffLength} characters loaded`
|
||||
} else if(this.textCharDiffLength < 0) {
|
||||
return `${this.textCharDiffLength * -1} characters removed`
|
||||
} else if(this.textCharDiffLength > 0) {
|
||||
return `${this.textCharDiffLength} characters added`
|
||||
} else {
|
||||
return "Title changed"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,10 +198,11 @@ class ActionsManager {
|
||||
})
|
||||
}
|
||||
|
||||
presentRevisionPreviewModal(revision) {
|
||||
presentRevisionPreviewModal(uuid, content) {
|
||||
var scope = this.$rootScope.$new(true);
|
||||
scope.revision = revision;
|
||||
var el = this.$compile( "<revision-preview-modal revision='revision' callback='callback' class='modal'></revision-preview-modal>" )(scope);
|
||||
scope.uuid = uuid;
|
||||
scope.content = content;
|
||||
var el = this.$compile( "<revision-preview-modal uuid='uuid' content='content' class='modal'></revision-preview-modal>" )(scope);
|
||||
angular.element(document.body).append(el);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,280 +1,24 @@
|
||||
const SessionHistoryPersistKey = "sessionHistory_persist";
|
||||
const SessionHistoryRevisionsKey = "sessionHistory_revisions";
|
||||
const SessionHistoryAutoOptimizeKey = "sessionHistory_autoOptimize";
|
||||
|
||||
class SessionHistory {
|
||||
class SessionHistory extends SFSessionHistoryManager {
|
||||
|
||||
constructor(modelManager, storageManager, authManager, passcodeManager, $timeout) {
|
||||
this.modelManager = modelManager;
|
||||
this.storageManager = storageManager;
|
||||
this.authManager = authManager;
|
||||
this.passcodeManager = passcodeManager;
|
||||
this.$timeout = $timeout;
|
||||
|
||||
this.loadFromDisk().then(() => {
|
||||
this.modelManager.addItemSyncObserver("session-history", "Note", (allItems, validItems, deletedItems, source, sourceKey) => {
|
||||
for(let item of allItems) {
|
||||
this.addRevision(item);
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
async encryptionParams() {
|
||||
let offline = this.authManager.offline();
|
||||
let auth_params = offline ? this.passcodeManager.passcodeAuthParams() : await this.authManager.getAuthParams();
|
||||
let keys = offline ? this.passcodeManager.keys() : await this.authManager.keys();
|
||||
return {keys, auth_params};
|
||||
}
|
||||
|
||||
addRevision(item) {
|
||||
var added = this.historyContainer.addRevision(item);
|
||||
|
||||
if(added) {
|
||||
if(this.diskTimeout) {this.$timeout.cancel(this.diskTimeout)};
|
||||
this.diskTimeout = this.$timeout(() => {
|
||||
this.saveToDisk();
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
historyForItem(item) {
|
||||
return this.historyContainer.historyForItem(item);
|
||||
}
|
||||
|
||||
async clearItemHistory(item) {
|
||||
delete this.historyContainer.clearItemHistory(item);
|
||||
return this.saveToDisk();
|
||||
}
|
||||
|
||||
async clearAllHistory() {
|
||||
this.historyContainer.clearAllHistory();
|
||||
return this.storageManager.removeItem(SessionHistoryRevisionsKey);
|
||||
}
|
||||
|
||||
async toggleDiskSaving() {
|
||||
this.diskEnabled = !this.diskEnabled;
|
||||
|
||||
if(this.diskEnabled) {
|
||||
this.storageManager.setItem(SessionHistoryPersistKey, JSON.stringify(true));
|
||||
this.saveToDisk();
|
||||
} else {
|
||||
this.storageManager.setItem(SessionHistoryPersistKey, JSON.stringify(false));
|
||||
return this.storageManager.removeItem(SessionHistoryRevisionsKey);
|
||||
}
|
||||
}
|
||||
|
||||
get autoOptimize() {
|
||||
return this.historyContainer.autoOptimize;
|
||||
}
|
||||
|
||||
async toggleAutoOptimize() {
|
||||
this.historyContainer.autoOptimize = !this.historyContainer.autoOptimize;
|
||||
|
||||
if(this.historyContainer.autoOptimize) {
|
||||
this.storageManager.setItem(SessionHistoryAutoOptimizeKey, JSON.stringify(true));
|
||||
} else {
|
||||
this.storageManager.setItem(SessionHistoryAutoOptimizeKey, JSON.stringify(false));
|
||||
}
|
||||
}
|
||||
|
||||
async saveToDisk() {
|
||||
if(!this.diskEnabled) {
|
||||
return;
|
||||
}
|
||||
let encryptionParams = await this.encryptionParams();
|
||||
var itemParams = new SFItemParams(this.historyContainer, encryptionParams.keys, encryptionParams.auth_params);
|
||||
itemParams.paramsForSync().then((syncParams) => {
|
||||
// console.log("Saving to disk", syncParams);
|
||||
this.storageManager.setItem(SessionHistoryRevisionsKey, JSON.stringify(syncParams));
|
||||
})
|
||||
}
|
||||
|
||||
async loadFromDisk() {
|
||||
var diskValue = await this.storageManager.getItem(SessionHistoryPersistKey);
|
||||
if(diskValue) {
|
||||
this.diskEnabled = JSON.parse(diskValue);
|
||||
SFItemHistory.HistoryEntryClassMapping = {
|
||||
"Note" : NoteHistoryEntry
|
||||
}
|
||||
|
||||
var historyValue = await this.storageManager.getItem(SessionHistoryRevisionsKey);
|
||||
if(historyValue) {
|
||||
historyValue = JSON.parse(historyValue);
|
||||
let encryptionParams = await this.encryptionParams();
|
||||
await SFJS.itemTransformer.decryptItem(historyValue, encryptionParams.keys);
|
||||
var historyContainer = new HistoryContainer(historyValue);
|
||||
this.historyContainer = historyContainer;
|
||||
} else {
|
||||
this.historyContainer = new HistoryContainer();
|
||||
}
|
||||
|
||||
var autoOptimizeValue = await this.storageManager.getItem(SessionHistoryAutoOptimizeKey);
|
||||
if(autoOptimizeValue) {
|
||||
this.historyContainer.autoOptimize = JSON.parse(autoOptimizeValue);
|
||||
} else {
|
||||
// default value is true
|
||||
this.historyContainer.autoOptimize = true;
|
||||
}
|
||||
}
|
||||
|
||||
async optimize() {
|
||||
return this.historyContainer.optimize();
|
||||
}
|
||||
}
|
||||
|
||||
class HistoryContainer extends SFItem {
|
||||
constructor(json_obj) {
|
||||
super(json_obj);
|
||||
|
||||
if(!this.content.itemsDictionary) {
|
||||
this.content.itemsDictionary = {};
|
||||
}
|
||||
|
||||
var objectKeys = Object.keys(this.content.itemsDictionary);
|
||||
objectKeys.forEach((key) => {
|
||||
var value = this.content.itemsDictionary[key];
|
||||
this.content.itemsDictionary[key] = new ItemHistory(value);
|
||||
});
|
||||
}
|
||||
|
||||
addRevision(item) {
|
||||
if(!this.content.itemsDictionary[item.uuid]) {
|
||||
this.content.itemsDictionary[item.uuid] = new ItemHistory();
|
||||
}
|
||||
var itemHistory = this.content.itemsDictionary[item.uuid];
|
||||
return itemHistory.addRevision(item, this.autoOptimize);
|
||||
}
|
||||
|
||||
historyForItem(item) {
|
||||
return this.content.itemsDictionary[item.uuid];
|
||||
}
|
||||
|
||||
clearItemHistory(item) {
|
||||
delete this.content.itemsDictionary[item.uuid];
|
||||
}
|
||||
|
||||
clearAllHistory() {
|
||||
this.content.itemsDictionary = {};
|
||||
}
|
||||
|
||||
optimize() {
|
||||
var objectKeys = Object.keys(this.content.itemsDictionary);
|
||||
objectKeys.forEach((key) => {
|
||||
var itemHistory = this.content.itemsDictionary[key];
|
||||
itemHistory.optimize();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class ItemHistory {
|
||||
|
||||
constructor(json_obj = {}) {
|
||||
if(!this.revisions) {
|
||||
this.revisions = [];
|
||||
}
|
||||
|
||||
if(json_obj.revisions) {
|
||||
for(var revision of json_obj.revisions) {
|
||||
this.revisions.push(new NoteRevision(revision, this.revisions[this.revisions.length - 1], revision.date));
|
||||
var keyRequestHandler = async () => {
|
||||
let offline = authManager.offline();
|
||||
let auth_params = offline ? passcodeManager.passcodeAuthParams() : await authManager.getAuthParams();
|
||||
let keys = offline ? passcodeManager.keys() : await authManager.keys();
|
||||
return {
|
||||
keys: keys,
|
||||
offline: offline,
|
||||
auth_params: auth_params
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addRevision(item, autoOptimize) {
|
||||
var previousRevision = this.revisions[this.revisions.length - 1];
|
||||
var prospectiveRevision = new NoteRevision(item, previousRevision, item.updated_at);
|
||||
|
||||
// Don't add first revision if text length is 0, as this means it's a new note.
|
||||
// Actually, we'll skip this. If we do this, the first character added to a new note
|
||||
// will be displayed as "1 characters loaded"
|
||||
// if(!previousRevision && prospectiveRevision.textCharDiffLength == 0) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// Don't add if text is the same
|
||||
if(prospectiveRevision.isSameAsRevision(previousRevision)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.revisions.push(prospectiveRevision);
|
||||
|
||||
// Clean up if there are too many revisions
|
||||
const LargeRevisionAmount = 100;
|
||||
if(autoOptimize && this.revisions.length > LargeRevisionAmount) {
|
||||
this.optimize();
|
||||
}
|
||||
|
||||
return prospectiveRevision;
|
||||
}
|
||||
|
||||
optimize() {
|
||||
const SmallRevisionLength = 15;
|
||||
this.revisions = this.revisions.filter((revision, index) => {
|
||||
// Keep only first and last item and items whos diff length is greater than the small revision length.
|
||||
var isFirst = index == 0;
|
||||
var isLast = index == this.revisions.length - 1;
|
||||
var isSmallRevision = Math.abs(revision.textCharDiffLength) < SmallRevisionLength;
|
||||
return isFirst || isLast || !isSmallRevision;
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class ItemRevision {
|
||||
|
||||
constructor(item, previousRevision, date) {
|
||||
if(typeof(date) == "string") {
|
||||
this.date = new Date(date);
|
||||
} else {
|
||||
this.date = date;
|
||||
}
|
||||
this.uuid = item.uuid;
|
||||
this.hasPreviousRevision = previousRevision != null;
|
||||
this.content = Object.assign({}, item.content);
|
||||
}
|
||||
|
||||
isSameAsRevision(revision) {
|
||||
if(!revision) {
|
||||
return false;
|
||||
}
|
||||
return JSON.stringify(this.content) === JSON.stringify(revision.content);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class NoteRevision extends ItemRevision {
|
||||
constructor(item, previousRevision, date) {
|
||||
super(item, previousRevision, date);
|
||||
if(previousRevision) {
|
||||
this.textCharDiffLength = this.content.text.length - previousRevision.content.text.length;
|
||||
} else {
|
||||
this.textCharDiffLength = this.content.text.length;
|
||||
}
|
||||
}
|
||||
|
||||
previewTitle() {
|
||||
return this.date.toLocaleString();
|
||||
}
|
||||
|
||||
operationVector() {
|
||||
if(!this.hasPreviousRevision || this.textCharDiffLength == 0) {
|
||||
return 0;
|
||||
} else if(this.textCharDiffLength < 0) {
|
||||
return -1;
|
||||
} else {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
previewSubTitle() {
|
||||
if(!this.hasPreviousRevision) {
|
||||
return `${this.textCharDiffLength} characters loaded`
|
||||
} else if(this.textCharDiffLength < 0) {
|
||||
return `${this.textCharDiffLength * -1} characters removed`
|
||||
} else if(this.textCharDiffLength > 0) {
|
||||
return `${this.textCharDiffLength} characters added`
|
||||
} else {
|
||||
return "Title changed"
|
||||
}
|
||||
var contentTypes = ["Note"];
|
||||
super(modelManager, storageManager, keyRequestHandler, contentTypes, $timeout);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
%a.close-button.info{"ng-click" => "restore(true)"} Restore as copy
|
||||
%a.close-button.info{"ng-click" => "dismiss(); $event.stopPropagation()"} Close
|
||||
.content.selectable
|
||||
%h2 {{revision.content.title}}
|
||||
%p.normal{"style" => "white-space: pre-wrap; font-size: 16px;"} {{revision.content.text}}
|
||||
%h2 {{content.title}}
|
||||
%p.normal{"style" => "white-space: pre-wrap; font-size: 16px;"} {{content.text}}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
.menu-panel.dropdown-menu
|
||||
.header
|
||||
.column
|
||||
%h4.title {{history.revisions.length || 'No'}} revisions
|
||||
%h4.title {{history.entries.length || 'No'}} revisions
|
||||
%h4{"ng-click" => "showOptions = !showOptions; $event.stopPropagation();"}
|
||||
%a Options
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
.sublabel
|
||||
Saving to disk may increase app loading time and memory footprint.
|
||||
|
||||
%menu-row{"ng-repeat" => "revision in history.revisions",
|
||||
%menu-row{"ng-repeat" => "revision in history.entries",
|
||||
"ng-click" => "openRevision(revision); $event.stopPropagation();",
|
||||
"label" => "revision.previewTitle()"}
|
||||
.sublabel.opaque{"ng-class" => "classForRevision(revision)"}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"karma-jasmine": "^1.1.0",
|
||||
"karma-phantomjs-launcher": "^1.0.2",
|
||||
"sn-stylekit": "1.0.15",
|
||||
"standard-file-js": "0.3.2",
|
||||
"standard-file-js": "file:~/Desktop/sf/sfjs",
|
||||
"sn-models": "0.1.0",
|
||||
"connect": "^3.6.6",
|
||||
"mocha": "^5.2.0",
|
||||
|
||||
Reference in New Issue
Block a user