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),