diff --git a/.simplesauce.json b/.simplesauce.json
new file mode 100644
index 0000000..940720f
--- /dev/null
+++ b/.simplesauce.json
@@ -0,0 +1,3 @@
+{
+ "testPath": "/static-files/test/"
+}
diff --git a/config.json b/config.json
index fb96a0a..c45fc86 100755
--- a/config.json
+++ b/config.json
@@ -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"
diff --git a/src/lscache.js b/src/lscache.js
new file mode 100644
index 0000000..a55b4e8
--- /dev/null
+++ b/src/lscache.js
@@ -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);
+ }
+ }
+ }
+ };
+})();
\ No newline at end of file
diff --git a/src/ui.js b/src/ui.js
index 902ebd7..eccc438 100644
--- a/src/ui.js
+++ b/src/ui.js
@@ -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
+ // auto-restoring it if they come back, don't bother them
+ // with a modal dialog.
+
+ //event.preventDefault();
+ //return jQuery.locale.get("input:unload-blocked");
}
}
@@ -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() {
@@ -78,10 +98,25 @@
focused.on('change', hud.onFocusChange);
input.activate();
$(window).focus();
+ if (!commandManager.canUndo()) {
+ // 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();
diff --git a/test/index.html b/test/index.html
index a1bddb5..fd8c7ff 100644
--- a/test/index.html
+++ b/test/index.html
@@ -34,8 +34,10 @@
-
-
+
diff --git a/test/run-tests.js b/test/run-tests.js
index 47a2bb8..8aba04f 100644
--- a/test/run-tests.js
+++ b/test/run-tests.js
@@ -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;
@@ -45,7 +53,7 @@
window.jQuery.noConflict();
$.loadScripts(unitTests, "unit/", function(log) {
makeTestModuleForLog("unit tests", log);
- QUnit.start();
+ loadEnLocale(function() { QUnit.start(); });
});
});
});
diff --git a/test/unit/localization.js b/test/unit/localization.js
index 428bfc3..bcf9c74 100644
--- a/test/unit/localization.js
+++ b/test/unit/localization.js
@@ -17,20 +17,23 @@ test("jQuery.fn.localize() works", function() {
equal(div.find("p").text(), "baz");
});
-test("loadLocale() always triggers completion", function() {
- jQuery.localization.loadLocale({
- path: "../src/locale/",
- languages: ["en", "zz"],
- complete: function(locale, loadResults) {
- ok(locale && locale.languages, "locale object is passed through");
- equal(loadResults.length, 2);
- deepEqual(loadResults[0], ["en", "success"]);
- deepEqual(loadResults[1], ["zz", "error"]);
- start();
- }
+// Only run this test if we're not being served from a simple
+// clone of the repository (i.e. simplesauce).
+if (!location.search.match(/externalreporter=1/))
+ test("loadLocale() always triggers completion", function() {
+ jQuery.localization.loadLocale({
+ path: "../src/locale/",
+ languages: ["en", "zz"],
+ complete: function(locale, loadResults) {
+ ok(locale && locale.languages, "locale object is passed through");
+ equal(loadResults.length, 2);
+ deepEqual(loadResults[0], ["en", "success"]);
+ deepEqual(loadResults[1], ["zz", "error"]);
+ start();
+ }
+ });
+ stop();
});
- stop();
-});
test("createLocale() inherits from non-region locales", function() {
jQuery.localization.extend("en", "l10nTests", {
diff --git a/test/unit/uproot/uproot.js b/test/unit/uproot/uproot.js
index 6357f36..819e57d 100644
--- a/test/unit/uproot/uproot.js
+++ b/test/unit/uproot/uproot.js
@@ -11,29 +11,32 @@ module("uproot", {
}
});
-asyncTest("uprootIgnoringWebxray() works", function() {
- var iframe = jQuery("");
- iframe.attr("src", "unit/uproot/source-pages/basic-page/");
- iframe.load(function() {
- var window = iframe[0].contentWindow;
- Webxray.whenLoaded(function(ui) {
- ok(ui.jQuery.webxrayBuildMetadata.date,
- "build date is " + ui.jQuery.webxrayBuildMetadata.date);
- ok(ui.jQuery.webxrayBuildMetadata.commit &&
- ui.jQuery.webxrayBuildMetadata.commit != "unknown",
- "build commit is " + ui.jQuery.webxrayBuildMetadata.commit);
- ok(ui.jQuery(".webxray-base").length,
- ".webxray-base in goggles-injected document");
- ui.jQuery(window.document).uprootIgnoringWebxray(function(html) {
- ok(html.indexOf('webxray-base') == -1,
- ".webxray-base not in goggles-injected uproot");
- start();
- });
- }, window);
- window.location = Webxray.getBookmarkletURL("../../../../../");
+// Only run this test if we're not being served from a simple
+// clone of the repository (i.e. simplesauce).
+if (!location.search.match(/externalreporter=1/))
+ asyncTest("uprootIgnoringWebxray() works", function() {
+ var iframe = jQuery("");
+ iframe.attr("src", "unit/uproot/source-pages/basic-page/");
+ iframe.load(function() {
+ var window = iframe[0].contentWindow;
+ Webxray.whenLoaded(function(ui) {
+ ok(ui.jQuery.webxrayBuildMetadata.date,
+ "build date is " + ui.jQuery.webxrayBuildMetadata.date);
+ ok(ui.jQuery.webxrayBuildMetadata.commit &&
+ ui.jQuery.webxrayBuildMetadata.commit != "unknown",
+ "build commit is " + ui.jQuery.webxrayBuildMetadata.commit);
+ ok(ui.jQuery(".webxray-base").length,
+ ".webxray-base in goggles-injected document");
+ ui.jQuery(window.document).uprootIgnoringWebxray(function(html) {
+ ok(html.indexOf('webxray-base') == -1,
+ ".webxray-base not in goggles-injected uproot");
+ start();
+ });
+ }, window);
+ window.location = Webxray.getBookmarkletURL("../../../../../");
+ });
+ jQuery("#iframes").append(iframe).show();
});
- jQuery("#iframes").append(iframe).show();
-});
[
'basic-page'
@@ -41,6 +44,7 @@ asyncTest("uprootIgnoringWebxray() works", function() {
, 'complex-doctype'
, 'no-doctype'
].forEach(function(name) {
+ var $ = jQuery;
asyncTest(name, function() {
var prefix = 'unit/uproot/';
var iframe = jQuery("");
@@ -50,7 +54,7 @@ asyncTest("uprootIgnoringWebxray() works", function() {
function(expected) {
var docElem = iframe.get(0).contentDocument.documentElement;
var startHTML = docElem.innerHTML;
- var baseURI = document.location.href + iframe.attr('src');
+ var baseURI = $('').attr('href', iframe.attr('src'))[0].href;
expected = expected.replace("{{ BASE_HREF }}", baseURI);
iframe.uproot(function(actual) {
equal(jQuery.trim(actual), jQuery.trim(expected),