Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Session auto-restore #12

Open
wants to merge 7 commits into
base: tokyo-jan-2012
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .simplesauce.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"testPath": "/static-files/test/"
}
1 change: 1 addition & 0 deletions config.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
, "jquery/src/effects.js"
, "jquery/src/offset.js"
, "jquery/src/dimensions.js"
, "src/lscache.js"
, "src/get-bookmarklet-url.js"
, "src/localization.js"
, "src/locale/*.js"
Expand Down
266 changes: 266 additions & 0 deletions src/lscache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
/**
* lscache library
* Copyright (c) 2011, Pamela Fox
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/*jshint undef:true, browser:true */

/**
* Creates a namespace for the lscache functions.
*/
var lscache = (function() {
// Prefix for all lscache keys
var CACHE_PREFIX = 'webxray-lscache-';

// Suffix for the key name on the expiration items in localStorage
var CACHE_SUFFIX = '-cacheexpiration';

// expiration date radix (set to Base-36 for most space savings)
var EXPIRY_RADIX = 10;

// time resolution in minutes
var EXPIRY_UNITS = 60 * 1000;

// ECMAScript max Date (epoch + 1e8 days)
var MAX_DATE = Math.floor(8.64e15/EXPIRY_UNITS);

var cachedStorage;
var cachedJSON;

// Determines if localStorage is supported in the browser;
// result is cached for better performance instead of being run each time.
// Feature detection is based on how Modernizr does it;
// it's not straightforward due to FF4 issues.
// It's not run at parse-time as it takes 200ms in Android.
function supportsStorage() {
var key = '__lscachetest__';
var value = key;

if (cachedStorage !== undefined) {
return cachedStorage;
}

try {
setItem(key, value);
removeItem(key);
cachedStorage = true;
} catch (exc) {
cachedStorage = false;
}
return cachedStorage;
}

// Determines if native JSON (de-)serialization is supported in the browser.
function supportsJSON() {
/*jshint eqnull:true */
if (cachedJSON === undefined) {
cachedJSON = (window.JSON != null);
}
return cachedJSON;
}

/**
* Returns the full string for the localStorage expiration item.
* @param {String} key
* @return {string}
*/
function expirationKey(key) {
return key + CACHE_SUFFIX;
}

/**
* Returns the number of minutes since the epoch.
* @return {number}
*/
function currentTime() {
return Math.floor((new Date().getTime())/EXPIRY_UNITS);
}

/**
* Wrapper functions for localStorage methods
*/

function getItem(key) {
return localStorage.getItem(CACHE_PREFIX + key);
}

function setItem(key, value) {
// Fix for iPad issue - sometimes throws QUOTA_EXCEEDED_ERR on setItem.
localStorage.removeItem(CACHE_PREFIX + key);
localStorage.setItem(CACHE_PREFIX + key, value);
}

function removeItem(key) {
localStorage.removeItem(CACHE_PREFIX + key);
}

return {

/**
* Stores the value in localStorage. Expires after specified number of minutes.
* @param {string} key
* @param {Object|string} value
* @param {number} time
*/
set: function(key, value, time) {
if (!supportsStorage()) return;

// If we don't get a string value, try to stringify
// In future, localStorage may properly support storing non-strings
// and this can be removed.
if (typeof value !== 'string') {
if (!supportsJSON()) return;
try {
value = JSON.stringify(value);
} catch (e) {
// Sometimes we can't stringify due to circular refs
// in complex objects, so we won't bother storing then.
return;
}
}

try {
setItem(key, value);
} catch (e) {
if (e.name === 'QUOTA_EXCEEDED_ERR' || e.name === 'NS_ERROR_DOM_QUOTA_REACHED') {
// If we exceeded the quota, then we will sort
// by the expire time, and then remove the N oldest
var storedKeys = [];
var storedKey;
for (var i = 0; i < localStorage.length; i++) {
storedKey = localStorage.key(i);

if (storedKey.indexOf(CACHE_PREFIX) === 0 && storedKey.indexOf(CACHE_SUFFIX) < 0) {
var mainKey = storedKey.substr(CACHE_PREFIX.length);
var exprKey = expirationKey(mainKey);
var expiration = getItem(exprKey);
if (expiration) {
expiration = parseInt(expiration, EXPIRY_RADIX);
} else {
// TODO: Store date added for non-expiring items for smarter removal
expiration = MAX_DATE;
}
storedKeys.push({
key: mainKey,
size: (getItem(mainKey)||'').length,
expiration: expiration
});
}
}
// Sorts the keys with oldest expiration time last
storedKeys.sort(function(a, b) { return (b.expiration-a.expiration); });

var targetSize = (value||'').length;
while (storedKeys.length && targetSize > 0) {
storedKey = storedKeys.pop();
removeItem(storedKey.key);
removeItem(expirationKey(storedKey.key));
targetSize -= storedKey.size;
}
try {
setItem(key, value);
} catch (e) {
// value may be larger than total quota
return;
}
} else {
// If it was some other error, just give up.
return;
}
}

// If a time is specified, store expiration info in localStorage
if (time) {
setItem(expirationKey(key), (currentTime() + time).toString(EXPIRY_RADIX));
} else {
// In case they previously set a time, remove that info from localStorage.
removeItem(expirationKey(key));
}
},

/**
* Retrieves specified value from localStorage, if not expired.
* @param {string} key
* @return {string|Object}
*/
get: function(key) {
if (!supportsStorage()) return null;

// Return the de-serialized item if not expired
var exprKey = expirationKey(key);
var expr = getItem(exprKey);

if (expr) {
var expirationTime = parseInt(expr, EXPIRY_RADIX);

// Check if we should actually kick item out of storage
if (currentTime() >= expirationTime) {
removeItem(key);
removeItem(exprKey);
return null;
}
}

// Tries to de-serialize stored value if its an object, and returns the normal value otherwise.
var value = getItem(key);
if (!value || !supportsJSON()) {
return value;
}

try {
// We can't tell if its JSON or a string, so we try to parse
return JSON.parse(value);
} catch (e) {
// If we can't parse, it's probably because it isn't an object
return value;
}
},

/**
* Removes a value from localStorage.
* Equivalent to 'delete' in memcache, but that's a keyword in JS.
* @param {string} key
*/
remove: function(key) {
if (!supportsStorage()) return null;
removeItem(key);
removeItem(expirationKey(key));
},

/**
* Returns whether local storage is supported.
* Currently exposed for testing purposes.
* @return {boolean}
*/
supported: function() {
return supportsStorage();
},

/**
* Flushes all lscache items and expiry markers without affecting rest of localStorage
*/
flush: function() {
if (!supportsStorage()) return;

// Loop in reverse as removing items will change indices of tail
for (var i = localStorage.length-1; i >= 0 ; --i) {
var key = localStorage.key(i);
if (key.indexOf(CACHE_PREFIX) === 0) {
localStorage.removeItem(key);
}
}
}
};
})();
45 changes: 40 additions & 5 deletions src/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,17 @@
// If the user has made changes to the page, we don't want them
// to be able to navigate away from it without facing a modal
// dialog.
function ModalUnloadBlocker(commandManager) {
function ModalUnloadBlocker(commandManager, cb) {
function beforeUnload(event) {
if (commandManager.canUndo()) {
event.preventDefault();
return jQuery.locale.get("input:unload-blocked");
cb();

// Since we are saving the user's work before they leave and
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this comment should just replace the comment on line 12. Otherwise they contradict each other.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, good point. Will do that... and actually, we should probably rename this class to something more descriptive than ModalUnloadBlocker, since that's not at all what it's doing anymore. Maybe something like BeforeUnloadManager or something.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, works for me.

// auto-restoring it if they come back, don't bother them
// with a modal dialog.

//event.preventDefault();
//return jQuery.locale.get("input:unload-blocked");
}
}

Expand Down Expand Up @@ -67,8 +73,22 @@
});
var touchToolbar = canBeTouched() ? jQuery.touchToolbar(input) : null;
var indicator = jQuery.blurIndicator(input, window);
var modalUnloadBlocker = ModalUnloadBlocker(commandManager);

var modalUnloadBlocker = ModalUnloadBlocker(commandManager,
saveRecording);
var RECORDING_KEY = "recording-" + window.location.href;

function saveRecording() {
// Store emergency rescue data for 5 minutes.
var RECORDING_PERSIST_TIME = 5 * 60;

if (commandManager.canUndo()) {
var recording = commandManager.getRecording();
lscache.set(RECORDING_KEY, JSON.parse(recording),
RECORDING_PERSIST_TIME);
} else
lscache.remove(RECORDING_KEY);
}

var self = jQuery.eventEmitter({
persistence: persistence,
start: function() {
Expand All @@ -78,10 +98,25 @@
focused.on('change', hud.onFocusChange);
input.activate();
$(window).focus();
if (!commandManager.canUndo()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is commandManager.canUndo() basically idiomatic for detecting a new goggles session? It might be nice to give it a more clearly named helper.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is a bit funky, because there's already a pre-existing behavior in the goggles (around line 95) where we call persistence.loadHistoryFromDOM() in an attempt to find serialized undo history in the DOM and restore it. This is useful for when the user has either turned off the goggles on a page and wants to turn them back on again, or when the user activates the goggles on a published hack (in which case we're letting them go "back in time" through the hack author's edits).

So, given that context, the call to commandManager.canUndo() here is basically checking to see if any history was deserialized. A less obfuscated way of doing this might be to change persistence.loadHistoryFromDOM() to return a boolean indicating whether any history was restored, and then change line 95 to be:

var wasHistoryRestored = persistence.loadHistoryFromDOM();

and then change line 101 to be:

if (!wasHistoryRestored)

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Err... actually, now that I think about it, there are probably some cases where we might want to both load the history from the DOM and load the cached history, e.g. when a user is building on an existing published hack.

Hmm, might have to noodle on this a bit more.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, ok, seems more complicated than I thought. I didn't realize there were two different ways to store history. I wouldn't necessarily hold up the pull for this, but it might merit a TODO or something.

// See if we can emergency-restore the user's previous session.
var recording = lscache.get(RECORDING_KEY);
if (recording)
try {
commandManager.playRecording(JSON.stringify(recording));
} catch (e) {
// Corrupt recording, or page has changed in a way
// that we can't replay the recording, so get rid of it.
lscache.remove(RECORDING_KEY);
if (window.console && window.console.error)
console.error(e);
}
}
},
unload: function() {
if (!isUnloaded) {
isUnloaded = true;
saveRecording();
focused.destroy();
focused = null;
input.deactivate();
Expand Down
6 changes: 4 additions & 2 deletions test/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@

<script src="qunit.js"></script>

<!-- This is for TestSwarm only. -->
<script src="/js/inject.js"></script>
<script>
if (location.search.match(/externalreporter=1/))
document.write('<script src="/externalreporter.js"></' + 'script>');
</script>

<script src="run-tests.js"></script>

Expand Down
10 changes: 9 additions & 1 deletion test/run-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@
});
}

function loadEnLocale(cb) {
$.getJSON("../strings.json", function(strings) {
for (var namespace in strings)
jQuery.localization.extend("en", namespace, strings[namespace]);
cb();
});
}

$(window).ready(function() {
$.getJSON("../config.json", function(obj) {
var scripts = obj.compiledFileParts;
Expand All @@ -45,7 +53,7 @@
window.jQuery.noConflict();
$.loadScripts(unitTests, "unit/", function(log) {
makeTestModuleForLog("unit tests", log);
QUnit.start();
loadEnLocale(function() { QUnit.start(); });
});
});
});
Expand Down
Loading