diff --git a/abprime/addon/adblockplus.js b/abprime/addon/adblockplus.js new file mode 100644 index 00000000..c3c05bbb --- /dev/null +++ b/abprime/addon/adblockplus.js @@ -0,0 +1,32 @@ +#filter substitution + +pref("extensions.@ADDON_CHROME_NAME@.currentVersion", "0.0"); +pref("extensions.@ADDON_CHROME_NAME@.enabled", true); +pref("extensions.@ADDON_CHROME_NAME@.frameobjects", true); +pref("extensions.@ADDON_CHROME_NAME@.fastcollapse", false); +pref("extensions.@ADDON_CHROME_NAME@.showinstatusbar", false); +pref("extensions.@ADDON_CHROME_NAME@.detachsidebar", false); +pref("extensions.@ADDON_CHROME_NAME@.defaulttoolbaraction", 0); +pref("extensions.@ADDON_CHROME_NAME@.defaultstatusbaraction", 0); +pref("extensions.@ADDON_CHROME_NAME@.sidebar_key", "Accel Shift V, Accel Shift U"); +pref("extensions.@ADDON_CHROME_NAME@.sendReport_key", ""); +pref("extensions.@ADDON_CHROME_NAME@.filters_key", "Accel Shift E, Accel Shift F, Accel Shift O"); +pref("extensions.@ADDON_CHROME_NAME@.enable_key", ""); +pref("extensions.@ADDON_CHROME_NAME@.flash_scrolltoitem", true); +pref("extensions.@ADDON_CHROME_NAME@.previewimages", true); +pref("extensions.@ADDON_CHROME_NAME@.data_directory", "adblockplus"); +pref("extensions.@ADDON_CHROME_NAME@.patternsbackups", 5); +pref("extensions.@ADDON_CHROME_NAME@.patternsbackupinterval", 24); +pref("extensions.@ADDON_CHROME_NAME@.whitelistschemes", "about chrome file irc moz-safe-about news resource snews x-jsd addbook cid imap mailbox nntp pop data javascript moz-icon"); +pref("extensions.@ADDON_CHROME_NAME@.hideimagemanager", true); +pref("extensions.@ADDON_CHROME_NAME@.subscriptions_autoupdate", true); +pref("extensions.@ADDON_CHROME_NAME@.subscriptions_listurl", "https://adblockplus.org/subscriptions2.xml"); +pref("extensions.@ADDON_CHROME_NAME@.subscriptions_fallbackurl", "https://adblockplus.org/getSubscription?version=%VERSION%&url=%SUBSCRIPTION%&downloadURL=%URL%&error=%ERROR%&channelStatus=%CHANNELSTATUS%&responseStatus=%RESPONSESTATUS%"); +pref("extensions.@ADDON_CHROME_NAME@.subscriptions_fallbackerrors", 5); +pref("extensions.@ADDON_CHROME_NAME@.documentation_link", "https://adblockplus.org/redirect?link=%LINK%&lang=%LANG%"); +pref("extensions.@ADDON_CHROME_NAME@.savestats", true); +pref("extensions.@ADDON_CHROME_NAME@.composer_default", 2); +pref("extensions.@ADDON_CHROME_NAME@.clearStatsOnHistoryPurge", true); +pref("extensions.@ADDON_CHROME_NAME@.report_submiturl", ""); +pref("extensions.@ADDON_CHROME_NAME@.recentReports", "[]"); +pref("services.sync.engine.@ADDON_CHROME_NAME@", false); diff --git a/abprime/addon/icon.png b/abprime/addon/icon.png new file mode 100644 index 00000000..59c427df Binary files /dev/null and b/abprime/addon/icon.png differ diff --git a/abprime/addon/icon64.png b/abprime/addon/icon64.png new file mode 100644 index 00000000..f840f408 Binary files /dev/null and b/abprime/addon/icon64.png differ diff --git a/abprime/addon/install.rdf b/abprime/addon/install.rdf new file mode 100644 index 00000000..918630e6 --- /dev/null +++ b/abprime/addon/install.rdf @@ -0,0 +1,58 @@ + + + + +#filter substitution + + + + + @ADDON_ID@ + @ADDON_VERSION@ + @ADDON_NAME@ + @ADDON_SHORT_DESC@ + @ADDON_AUTHOR@ +#ifdef ADDON_URL + @ADDON_URL@ +#endif + 2 + + + + @ADDON_TARGET_APP_ID@ + @ADDON_TARGET_APP_MINVER@ + @ADDON_TARGET_APP_MAXVER@ + + +#ifdef ADDON_TARGET_BASILISK + + + + {ec8030f7-c20a-464f-9b0e-13a3a9e97384} + 52.0 + 55.* + true + + +#endif + + + + {a3210b97-8e8a-4737-9aa0-aa0e607640b9} + 1.0.0a1 + 2.* + + + + + + {3550f703-e582-4d05-9a08-453d09bdfdc6} + 52.9.6884 + 52.9.* + + + + \ No newline at end of file diff --git a/abprime/addon/jar.mn b/abprime/addon/jar.mn new file mode 100644 index 00000000..a0fc8158 --- /dev/null +++ b/abprime/addon/jar.mn @@ -0,0 +1,12 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +#filter substitution + +[.] chrome.jar: +% resource @ADDON_CHROME_NAME@ / + +% component {d32a3c00-4ed3-11de-8a39-0800200c9a66} components/Initializer.js +% contract @adblockplus.org/abp/startup;1 {d32a3c00-4ed3-11de-8a39-0800200c9a66} +% category profile-after-change @adblockplus.org/abp/startup;1 @adblockplus.org/abp/startup;1 \ No newline at end of file diff --git a/abprime/addon/moz.build b/abprime/addon/moz.build new file mode 100644 index 00000000..b8894980 --- /dev/null +++ b/abprime/addon/moz.build @@ -0,0 +1,16 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +FINAL_TARGET_FILES += [ + 'icon.png', + 'icon64.png', +] + +FINAL_TARGET_PP_FILES += ['install.rdf'] + +FINAL_TARGET_PP_FILES.defaults += ['patterns.ini'] +FINAL_TARGET_PP_FILES.defaults.preferences += ['adblockplus.js'] + +JAR_MANIFESTS += ['jar.mn'] diff --git a/abprime/addon/patterns.ini b/abprime/addon/patterns.ini new file mode 100644 index 00000000..afb67743 --- /dev/null +++ b/abprime/addon/patterns.ini @@ -0,0 +1,2 @@ +# Adblock Plus preferences +version=4 diff --git a/abprime/app.mozbuild b/abprime/app.mozbuild new file mode 100644 index 00000000..b921a9f6 --- /dev/null +++ b/abprime/app.mozbuild @@ -0,0 +1,8 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +# Never add tier dirs after the application srcdir because they +# apparently won't get packaged properly on Mac. +DIRS += ['/abprime'] diff --git a/abprime/build.mk b/abprime/build.mk new file mode 100644 index 00000000..92fe1b14 --- /dev/null +++ b/abprime/build.mk @@ -0,0 +1,7 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package: + cd $(DIST)/bin; \ + zip -Dr9X ../${ADDON_XPI_NAME}-${ADDON_VERSION}.xpi * -x \*/.mkdir.done; \ diff --git a/abprime/components/Initializer.js b/abprime/components/Initializer.js new file mode 100644 index 00000000..8cf78457 --- /dev/null +++ b/abprime/components/Initializer.js @@ -0,0 +1,94 @@ +/* + * This Source Code is subject to the terms of the Mozilla Public License + * version 2.0 (the "License"). You can obtain a copy of the License at + * http://mozilla.org/MPL/2.0/. + */ + +#filter substitution + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cr = Components.results; +const Cu = Components.utils; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +// Gecko 1.9.0/1.9.1 compatibility - add XPCOMUtils.defineLazyServiceGetter +if (!("defineLazyServiceGetter" in XPCOMUtils)) +{ + XPCOMUtils.defineLazyServiceGetter = function XPCU_defineLazyServiceGetter(obj, prop, contract, iface) + { + obj.__defineGetter__(prop, function XPCU_serviceGetter() + { + delete obj[prop]; + return obj[prop] = Cc[contract].getService(Ci[iface]); + }); + }; +} + +/** + * Application startup/shutdown observer, triggers init()/shutdown() methods in Bootstrap.jsm module. + * @constructor + */ +function Initializer() {} +Initializer.prototype = +{ + classDescription: "Adblock Plus initializer", + contractID: "@adblockplus.org/abp/startup;1", + classID: Components.ID("{d32a3c00-4ed3-11de-8a39-0800200c9a66}"), + _xpcom_categories: [{ category: "app-startup", service: true }], + + QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver, Ci.nsISupportsWeakReference]), + + observe: function(subject, topic, data) + { + let observerService = Cc["@mozilla.org/observer-service;1"].getService(Ci.nsIObserverService); + switch (topic) + { + case "app-startup": + observerService.addObserver(this, "profile-after-change", true); + break; + case "profile-after-change": + observerService.addObserver(this, "quit-application", true); + + // Don't init in Fennec, initialization will happen when UI is ready + let appInfo = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULAppInfo); + if (appInfo.ID != "{a23983c0-fd0e-11dc-95ff-0800200c9a66}" && appInfo.ID != "{aa3c5121-dab2-40e2-81ca-7ea25febc110}") + { + try + { + // Gecko 2.0 and higher - chrome URLs can be loaded directly + Cu.import("resource://@ADDON_CHROME_NAME@/modules/Bootstrap.jsm"); + } + catch (e) + { + // Gecko 1.9.x - have to convert chrome URLs to file URLs first + let chromeRegistry = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(Ci.nsIChromeRegistry); + let ioService = Cc["@mozilla.org/network/io-service;1"].getService(Ci.nsIIOService); + let bootstrapURL = chromeRegistry.convertChromeURL(ioService.newURI("resource://@ADDON_CHROME_NAME@/modules/Bootstrap.jsm", null, null)); + Cu.import(bootstrapURL.spec); + } + Bootstrap.startup(); + } + break; + case "quit-application": + try { + // This will fail if component was added via chrome.manifest (Gecko 2.0) + observerService.removeObserver(this, "profile-after-change"); + }catch(e) {} + observerService.removeObserver(this, "quit-application"); + if ("@adblockplus.org/abp/private;1" in Cc) + { + let baseURL = Cc["@adblockplus.org/abp/private;1"].getService(Ci.nsIURI); + Cu.import(baseURL.spec + "Bootstrap.jsm"); + Bootstrap.shutdown(false); + } + break; + } + } +}; + +if (XPCOMUtils.generateNSGetFactory) + var NSGetFactory = XPCOMUtils.generateNSGetFactory([Initializer]); +else + var NSGetModule = XPCOMUtils.generateNSGetModule([Initializer]); diff --git a/abprime/components/moz.build b/abprime/components/moz.build new file mode 100644 index 00000000..948abeb3 --- /dev/null +++ b/abprime/components/moz.build @@ -0,0 +1,9 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +# Don't use sub-manifest files +NO_JS_MANIFEST = True + +EXTRA_PP_COMPONENTS += ['Initializer.js'] diff --git a/abprime/content/about.js b/abprime/content/about.js new file mode 100644 index 00000000..5a6b9e03 --- /dev/null +++ b/abprime/content/about.js @@ -0,0 +1,164 @@ +/* + * This Source Code is subject to the terms of the Mozilla Public License + * version 2.0 (the "License"). You can obtain a copy of the License at + * http://mozilla.org/MPL/2.0/. + */ + +#filter substitution + +try +{ + Cu.import("resource://gre/modules/AddonManager.jsm"); +} +catch (e) {} + +function init() +{ + let ioService = Cc["@mozilla.org/network/io-service;1"].getService(Ci.nsIIOService); + if (typeof AddonManager != "undefined") + { + let addon = AddonManager.getAddonByID(Utils.addonID, function(addon) + { + loadInstallManifest(addon.getResourceURI("install.rdf"), addon.name, addon.homepageURL); + }); + } + else if ("@mozilla.org/extensions/manager;1" in Cc) + { + let extensionManager = Cc["@mozilla.org/extensions/manager;1"].getService(Ci.nsIExtensionManager); + let rdf = Cc["@mozilla.org/rdf/rdf-service;1"].getService(Ci.nsIRDFService); + let root = rdf.GetResource("urn:mozilla:item:" + Utils.addonID); + + function emResource(prop) + { + return rdf.GetResource("http://www.mozilla.org/2004/em-rdf#" + prop); + } + + function getTarget(prop) + { + let target = extensionManager.datasource.GetTarget(root, emResource(prop), true); + if (target) + return target.QueryInterface(Ci.nsIRDFLiteral).Value; + else + return null; + } + + let installLocation = extensionManager.getInstallLocation(Utils.addonID); + let installManifestFile = installLocation.getItemFile(Utils.addonID, "install.rdf"); + loadInstallManifest(ioService.newFileURI(installManifestFile), getTarget("name"), getTarget("homepageURL")); + } + else + { + // No add-on manager, no extension manager - we must be running in K-Meleon. + // Load Manifest.jsm as last solution. + Cu.import(baseURL + "Manifest.jsm"); + setExtensionData(manifest.name, manifest.version, manifest.homepage, [manifest.creator], manifest.contributors, manifest.translators); + } +} + +function loadInstallManifest(installManifestURI, name, homepage) +{ + let rdf = Cc["@mozilla.org/rdf/rdf-service;1"].getService(Ci.nsIRDFService); + let ds = rdf.GetDataSource(installManifestURI.spec); + let root = rdf.GetResource("urn:mozilla:install-manifest"); + + function emResource(prop) + { + return rdf.GetResource("http://www.mozilla.org/2004/em-rdf#" + prop); + } + + function getTargets(prop) + { + let targets = ds.GetTargets(root, emResource(prop), true); + let result = []; + while (targets.hasMoreElements()) + result.push(targets.getNext().QueryInterface(Ci.nsIRDFLiteral).Value); + return result; + } + + function dataSourceLoaded() + { + setExtensionData(name, getTargets("version")[0], + homepage, getTargets("creator"), + getTargets("contributor"), getTargets("translator")); + } + + if (ds instanceof Ci.nsIRDFRemoteDataSource && ds.loaded) + dataSourceLoaded(); + else + { + let sink = ds.QueryInterface(Ci.nsIRDFXMLSink); + sink.addXMLSinkObserver({ + onBeginLoad: function() {}, + onInterrupt: function() {}, + onResume: function() {}, + onEndLoad: function() { + sink.removeXMLSinkObserver(this); + dataSourceLoaded(); + }, + onError: function() {}, + }); + } +} + +function cmpNoCase(a, b) +{ + let aLC = a.toLowerCase(); + let bLC = b.toLowerCase(); + if (aLC < bLC) + return -1; + else if (aLC > bLC) + return 1; + else + return 0; +} + +function setExtensionData(name, version, homepage, authors, contributors, translators) +{ + authors.sort(cmpNoCase); + contributors.sort(cmpNoCase); + translators.sort(cmpNoCase); + + E("title").value = name; + E("version").value = version; + E("homepage").value = homepage; + E("authors").textContent = authors.join(", "); + E("contributors").textContent = contributors.join(", "); + E("translators").textContent = translators.join(", "); + + let request = new XMLHttpRequest(); + request.open("GET", "chrome://@ADDON_CHROME_NAME@/content/subscriptions.xml"); + request.addEventListener("load", setSubscriptionAuthors, false); + request.send(null); +} + +function setSubscriptionAuthors() +{ + let doc = this.responseXML; + if (!doc || doc.documentElement.localName != "subscriptions") + return; + + let authors = {__proto__: null}; + for (let node = doc.documentElement.firstChild; node; node = node.nextSibling) + { + if (node.localName != "subscription" || !node.hasAttribute("author")) + continue; + + for each (let author in node.getAttribute("author").split(",")) + { + author = author.replace(/^\s+/, "").replace(/\s+$/, ""); + if (author == "") + continue; + + authors[author] = true; + } + } + + let list = []; + for (let author in authors) + list.push(author); + + list.sort(cmpNoCase) + E("subscriptionAuthors").textContent = list.join(", "); + + E("mainBox").setAttribute("loaded", "true"); +} diff --git a/abprime/content/about.xul b/abprime/content/about.xul new file mode 100644 index 00000000..772d7851 --- /dev/null +++ b/abprime/content/about.xul @@ -0,0 +1,62 @@ + + + + +#filter substitution + + + + + + + + + + + diff --git a/abprime/content/filters-backup.js b/abprime/content/filters-backup.js new file mode 100644 index 00000000..648670b8 --- /dev/null +++ b/abprime/content/filters-backup.js @@ -0,0 +1,331 @@ +/* + * This Source Code is subject to the terms of the Mozilla Public License + * version 2.0 (the "License"). You can obtain a copy of the License at + * http://mozilla.org/MPL/2.0/. + */ + +Cu.import("resource://gre/modules/FileUtils.jsm"); + +/** + * Implementation of backup and restore functionality. + * @class + */ +var Backup = +{ + /** + * Template for menu items to be displayed in the Restore menu (for automated + * backups). + * @type Element + */ + restoreTemplate: null, + + /** + * Element after which restore items should be inserted. + * @type Element + */ + restoreInsertionPoint: null, + + /** + * Regular expression to recognize checksum comments. + */ + CHECKSUM_REGEXP: /^!\s*checksum[\s\-:]+([\w\+\/]+)/i, + + /** + * Regular expression to recognize group title comments. + */ + GROUPTITLE_REGEXP: /^!\s*\[(.*)\]((?:\/\w+)*)\s*$/, + + + /** + * Initializes backup UI. + */ + init: function() + { + this.restoreTemplate = E("restoreBackupTemplate"); + this.restoreInsertionPoint = this.restoreTemplate.previousSibling; + this.restoreTemplate.parentNode.removeChild(this.restoreTemplate); + this.restoreTemplate.removeAttribute("id"); + this.restoreTemplate.removeAttribute("hidden"); + }, + + /** + * Gets the default download dir, as used by the browser itself. + */ + getDefaultDir: function() /**nsIFile*/ + { + try + { + return Utils.prefService.getComplexValue("browser.download.lastDir", Ci.nsILocalFile); + } + catch (e) + { + // No default download location. Default to desktop. + return FileUtils.getDir("Desk", [], false); + } + }, + + /** + * Saves new default download dir after the user chose a different directory to + * save his files to. + */ + saveDefaultDir: function(/**nsIFile*/ dir) + { + try + { + Utils.prefService.setComplexValue("browser.download.lastDir", Ci.nsILocalFile, dir); + } catch(e) {}; + }, + + /** + * Called when the Restore menu is being opened, fills in "Automated backup" + * entries. + */ + fillRestorePopup: function() + { + while (this.restoreInsertionPoint.nextSibling && !this.restoreInsertionPoint.nextSibling.id) + this.restoreInsertionPoint.parentNode.removeChild(this.restoreInsertionPoint.nextSibling); + + let files = FilterStorage.getBackupFiles().reverse(); + for (let i = 0; i < files.length; i++) + { + let file = files[i]; + let item = this.restoreTemplate.cloneNode(true); + let label = item.getAttribute("label"); + label = label.replace(/\?1\?/, Utils.formatTime(file.lastModifiedTime)); + item.setAttribute("label", label); + item.addEventListener("command", function() + { + Backup.restoreAllData(file); + }, false); + this.restoreInsertionPoint.parentNode.insertBefore(item, this.restoreInsertionPoint.nextSibling); + } + }, + + /** + * Lets the user choose a file to restore filters from. + */ + restoreFromFile: function() + { + let picker = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + picker.init(window, E("backupButton").getAttribute("_restoreDialogTitle"), picker.modeOpen); + picker.defaultExtension = ".ini"; + picker.appendFilter(E("backupButton").getAttribute("_fileFilterComplete"), "*.ini"); + picker.appendFilter(E("backupButton").getAttribute("_fileFilterCustom"), "*.txt"); + + if (picker.show() != picker.returnCancel) + { + this.saveDefaultDir(picker.file.parent); + if (picker.filterIndex == 0) + this.restoreAllData(picker.file); + else + this.restoreCustomFilters(picker.file); + } + }, + + /** + * Restores patterns.ini from a file. + */ + restoreAllData: function(/**nsIFile*/ file) + { + let stream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(Ci.nsIFileInputStream); + stream.init(file, FileUtils.MODE_RDONLY, FileUtils.PERMS_FILE, 0); + stream.QueryInterface(Ci.nsILineInputStream); + + let lines = []; + let line = {value: null}; + if (stream.readLine(line)) + lines.push(line.value); + if (stream.readLine(line)) + lines.push(line.value); + stream.close(); + + let match; + if (lines.length < 2 || lines[0] != "# Adblock Plus preferences" || !(match = /version=(\d+)/.exec(lines[1]))) + { + Utils.alert(window, E("backupButton").getAttribute("_restoreError"), E("backupButton").getAttribute("_restoreDialogTitle")); + return; + } + + let warning = E("backupButton").getAttribute("_restoreCompleteWarning"); + let minVersion = parseInt(match[1], 10); + if (minVersion > FilterStorage.formatVersion) + warning += "\n\n" + E("backupButton").getAttribute("_restoreVersionWarning"); + + if (!Utils.confirm(window, warning, E("backupButton").getAttribute("_restoreDialogTitle"))) + return; + + FilterStorage.loadFromDisk(file); + }, + + /** + * Restores custom filters from a file. + */ + restoreCustomFilters: function(/**nsIFile*/ file) + { + IO.readFromFile(file, true, { + seenHeader: false, + subscription: null, + process: function(line) + { + if (!this.seenHeader) + { + // This should be a header + this.seenHeader = true; + let match = /\[Adblock(?:\s*Plus\s*([\d\.]+)?)?\]/i.exec(line); + if (match) + { + let warning = E("backupButton").getAttribute("_restoreCustomWarning"); + let minVersion = match[1]; + if (minVersion && Utils.versionComparator.compare(minVersion, Utils.addonVersion) > 0) + warning += "\n\n" + E("backupButton").getAttribute("_restoreVersionWarning"); + + if (Utils.confirm(window, warning, E("backupButton").getAttribute("_restoreDialogTitle"))) + { + let subscriptions = FilterStorage.subscriptions.filter(function(s) s instanceof SpecialSubscription); + for (let i = 0; i < subscriptions.length; i++) + FilterStorage.removeSubscription(subscriptions[i]); + + return; + } + else + throw Cr.NS_BASE_STREAM_WOULD_BLOCK; + } + else + throw new Error("Invalid file"); + } + else if (line === null) + { + // End of file + if (this.subscription) + FilterStorage.addSubscription(this.subscription); + E("tabs").selectedIndex = 1; + } + else if (Backup.CHECKSUM_REGEXP.test(line)) + { + // Ignore checksums + } + else if (Backup.GROUPTITLE_REGEXP.test(line)) + { + // New group start + if (this.subscription) + FilterStorage.addSubscription(this.subscription); + + let [, title, options] = Backup.GROUPTITLE_REGEXP.exec(line); + this.subscription = SpecialSubscription.create(title); + + let defaults = []; + if (options) + options = options.split("/"); + for (let j = 0; j < options.length; j++) + if (options[j] in SpecialSubscription.defaultsMap) + defaults.push(options[j]); + if (defaults.length) + this.subscription.defaults = defaults; + } + else + { + // Regular filter + let filter = Filter.fromText(Filter.normalize(line)); + if (filter) + { + if (!this.subscription) + this.subscription = SpecialSubscription.create(Utils.getString("newGroup_title")); + this.subscription.filters.push(filter); + } + } + } + }, function(e) + { + if (e && e.result != Cr.NS_BASE_STREAM_WOULD_BLOCK) + { + Cu.reportError(e); + Utils.alert(window, E("backupButton").getAttribute("_restoreError"), E("backupButton").getAttribute("_restoreDialogTitle")); + } + }); + }, + + /** + * Lets the user choose a file to backup filters to. + */ + backupToFile: function() + { + let picker = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + picker.init(window, E("backupButton").getAttribute("_backupDialogTitle"), picker.modeSave); + picker.defaultExtension = ".ini"; + picker.appendFilter(E("backupButton").getAttribute("_fileFilterComplete"), "*.ini"); + picker.appendFilter(E("backupButton").getAttribute("_fileFilterCustom"), "*.txt"); + + if (picker.show() != picker.returnCancel) + { + this.saveDefaultDir(picker.file.parent); + if (picker.filterIndex == 0) + this.backupAllData(picker.file); + else + this.backupCustomFilters(picker.file); + } + }, + + /** + * Writes all patterns.ini data to a file. + */ + backupAllData: function(/**nsIFile*/ file) + { + FilterStorage.saveToDisk(file); + }, + + /** + * Writes user's custom filters to a file. + */ + backupCustomFilters: function(/**nsIFile*/ file) + { + let subscriptions = FilterStorage.subscriptions.filter(function(s) s instanceof SpecialSubscription); + let list = ["[Adblock Plus 2.0]"]; + for (let i = 0; i < subscriptions.length; i++) + { + let subscription = subscriptions[i]; + let typeAddition = ""; + if (subscription.defaults) + typeAddition = "/" + subscription.defaults.join("/"); + list.push("! [" + subscription.title + "]" + typeAddition); + for (let j = 0; j < subscription.filters.length; j++) + { + let filter = subscription.filters[j]; + // Skip checksums + if (filter instanceof CommentFilter && this.CHECKSUM_REGEXP.test(filter.text)) + continue; + // Skip group headers + if (filter instanceof CommentFilter && this.GROUPTITLE_REGEXP.test(filter.text)) + continue; + list.push(filter.text); + } + } + + // Insert checksum. Have to add an empty line to the end of the list to + // account for the trailing newline in the file. + list.push(""); + let checksum = Utils.generateChecksum(list); + list.pop(); + if (checksum) + list.splice(1, 0, "! Checksum: " + checksum); + + function generator() + { + for (let i = 0; i < list.length; i++) + yield list[i]; + } + + IO.writeToFile(file, true, generator(), function(e) + { + if (e) + { + Cu.reportError(e); + Utils.alert(window, E("backupButton").getAttribute("_backupError"), E("backupButton").getAttribute("_backupDialogTitle")); + } + }); + } +}; + +window.addEventListener("load", function() +{ + Backup.init(); +}, false); diff --git a/abprime/content/filters-filteractions.js b/abprime/content/filters-filteractions.js new file mode 100644 index 00000000..7dd5d6b4 --- /dev/null +++ b/abprime/content/filters-filteractions.js @@ -0,0 +1,539 @@ +/* + * This Source Code is subject to the terms of the Mozilla Public License + * version 2.0 (the "License"). You can obtain a copy of the License at + * http://mozilla.org/MPL/2.0/. + */ + +/** + * Implementation of the various actions performed on the filters. + * @class + */ +var FilterActions = +{ + /** + * Initializes filter actions. + */ + init: function() + { + let me = this; + this.treeElement.parentNode.addEventListener("keypress", function(event) + { + me.keyPress(event); + }, true); + this.treeElement.view = FilterView; + + this.treeElement.inputField.addEventListener("keypress", function(event) + { + // Prevent the tree from capturing cursor keys pressed in the input field + if (event.keyCode >= event.DOM_VK_PAGE_UP && event.keyCode <= event.DOM_VK_DOWN) + event.stopPropagation(); + }, false); + + // Create a copy of the view menu + function fixId(node, newId) + { + if (node.nodeType == node.ELEMENT_NODE) + { + if (node.hasAttribute("id")) + node.setAttribute("id", node.getAttribute("id").replace(/\d+$/, newId)); + + for (let i = 0, len = node.childNodes.length; i < len; i++) + fixId(node.childNodes[i], newId); + } + return node; + } + E("viewMenu").appendChild(fixId(E("filters-view-menu1").cloneNode(true), "2")); + }, + + /** + * element containing the filters. + * @type XULElement + */ + get treeElement() E("filtersTree"), + + /** + * Tests whether the tree is currently visible. + */ + get visible() + { + return !this.treeElement.parentNode.collapsed; + }, + + /** + * Tests whether the tree is currently focused. + * @type Boolean + */ + get focused() + { + let focused = document.commandDispatcher.focusedElement; + while (focused) + { + if ("treeBoxObject" in focused && focused.treeBoxObject == FilterView.boxObject) + return true; + focused = focused.parentNode; + } + return false; + }, + + /** + * Updates visible filter commands whenever the selected subscription changes. + */ + updateCommands: function() + { + E("filters-add-command").setAttribute("disabled", !FilterView.editable); + }, + + /** + * Called whenever filter actions menu is opened, initializes menu items. + */ + fillActionsPopup: function() + { + let editable = FilterView.editable; + let items = FilterView.selectedItems.filter(function(i) !i.filter.dummy); + items.sort(function(entry1, entry2) entry1.index - entry2.index); + let activeItems = items.filter(function(i) i.filter instanceof ActiveFilter); + + E("filters-edit-command").setAttribute("disabled", !editable || !items.length); + E("filters-delete-command").setAttribute("disabled", !editable || !items.length); + E("filters-resetHitCounts-command").setAttribute("disabled", !activeItems.length); + E("filters-moveUp-command").setAttribute("disabled", !editable || FilterView.isSorted() || !items.length || items[0].index == 0); + E("filters-moveDown-command").setAttribute("disabled", !editable || FilterView.isSorted() || !items.length || items[items.length - 1].index == FilterView.rowCount - 1); + E("filters-copy-command").setAttribute("disabled", !items.length); + E("filters-cut-command").setAttribute("disabled", !editable || !items.length); + E("filters-paste-command").setAttribute("disabled", !editable || !Utils.clipboard.hasDataMatchingFlavors(["text/unicode"], 1, Utils.clipboard.kGlobalClipboard)); + }, + + /** + * Changes sort current order for the tree. Sorts by filter column if the list is unsorted. + * @param {String} order either "ascending" or "descending" + */ + setSortOrder: function(sortOrder) + { + let col = (FilterView.sortColumn ? FilterView.sortColumn.id : "col-filter"); + FilterView.sortBy(col, sortOrder); + }, + + /** + * Toggles the visibility of a tree column. + */ + toggleColumn: function(/**String*/ id) + { + let col = E(id); + col.setAttribute("hidden", col.hidden ? "false" : "true"); + }, + + /** + * Enables or disables all filters in the current selection. + */ + selectionToggleDisabled: function() + { + if (this.treeElement.editingColumn) + return; + + let items = FilterView.selectedItems.filter(function(i) i.filter instanceof ActiveFilter); + if (items.length) + { + FilterView.boxObject.beginUpdateBatch(); + let newValue = !items[0].filter.disabled; + for (let i = 0; i < items.length; i++) + items[i].filter.disabled = newValue; + FilterView.boxObject.endUpdateBatch(); + } + }, + + /** + * Selects all entries in the list. + */ + selectAll: function() + { + if (this.treeElement.editingColumn) + return; + + FilterView.selection.selectAll(); + this.treeElement.focus(); + }, + + /** + * Starts editing the current filter. + */ + startEditing: function() + { + if (this.treeElement.editingColumn) + return; + + this.treeElement.startEditing(FilterView.selection.currentIndex, FilterView.boxObject.columns.getNamedColumn("col-filter")); + }, + + /** + * Starts editing a new filter at the current position. + */ + insertFilter: function() + { + if (!FilterView.editable || this.treeElement.editingColumn) + return; + + FilterView.insertEditDummy(); + this.startEditing(); + + let tree = this.treeElement; + let listener = function(event) + { + if (event.attrName == "editing" && tree.editingRow < 0) + { + tree.removeEventListener("DOMAttrModified", listener, false); + FilterView.removeEditDummy(); + } + } + tree.addEventListener("DOMAttrModified", listener, false); + }, + + /** + * Deletes items from the list. + */ + deleteItems: function(/**Array*/ items) + { + let oldIndex = FilterView.selection.currentIndex; + items.sort(function(entry1, entry2) entry2.index - entry1.index); + + for (let i = 0; i < items.length; i++) + FilterStorage.removeFilter(items[i].filter, FilterView._subscription, items[i].index); + + FilterView.selectRow(oldIndex); + }, + + /** + * Deletes selected filters. + */ + deleteSelected: function() + { + if (!FilterView.editable || this.treeElement.editingColumn) + return; + + let items = FilterView.selectedItems; + if (items.length == 0 || (items.length >= 2 && !Utils.confirm(window, this.treeElement.getAttribute("_removewarning")))) + return; + + this.deleteItems(items) + }, + + /** + * Resets hit counts of the selected filters. + */ + resetHitCounts: function() + { + if (this.treeElement.editingColumn) + return; + + let items = FilterView.selectedItems.filter(function(i) i.filter instanceof ActiveFilter); + if (items.length) + FilterStorage.resetHitCounts(items.map(function(i) i.filter)); + }, + + /** + * Moves items to a different position in the list. + * @param {Array} items + * @param {Integer} offset negative offsets move the items up, positive down + */ + _moveItems: function(/**Array*/ items, /**Integer*/ offset) + { + if (!items.length) + return; + + if (offset < 0) + { + items.sort(function(entry1, entry2) entry1.index - entry2.index); + let position = items[0].index + offset; + if (position < 0) + return; + + for (let i = 0; i < items.length; i++) + FilterStorage.moveFilter(items[i].filter, FilterView._subscription, items[i].index, position++); + FilterView.selection.rangedSelect(position - items.length, position - 1, false); + } + else if (offset > 0) + { + items.sort(function(entry1, entry2) entry2.index - entry1.index); + let position = items[0].index + offset; + if (position >= FilterView.rowCount) + return; + + for (let i = 0; i < items.length; i++) + FilterStorage.moveFilter(items[i].filter, FilterView._subscription, items[i].index, position--); + FilterView.selection.rangedSelect(position + 1, position + items.length, false); + } + }, + + /** + * Moves selected filters one line up. + */ + moveUp: function() + { + if (!FilterView.editable || FilterView.isEmpty || FilterView.isSorted() || this.treeElement.editingColumn) + return; + + this._moveItems(FilterView.selectedItems, -1); + }, + + /** + * Moves selected filters one line down. + */ + moveDown: function() + { + if (!FilterView.editable || FilterView.isEmpty || FilterView.isSorted() || this.treeElement.editingColumn) + return; + + this._moveItems(FilterView.selectedItems, 1); + }, + + /** + * Fills the context menu of the filters columns. + */ + fillColumnPopup: function(/**Element*/ element) + { + let suffix = element.id.match(/\d+$/)[0] || "1"; + + E("filters-view-filter" + suffix).setAttribute("checked", !E("col-filter").hidden); + E("filters-view-slow" + suffix).setAttribute("checked", !E("col-slow").hidden); + E("filters-view-enabled" + suffix).setAttribute("checked", !E("col-enabled").hidden); + E("filters-view-hitcount" + suffix).setAttribute("checked", !E("col-hitcount").hidden); + E("filters-view-lasthit" + suffix).setAttribute("checked", !E("col-lasthit").hidden); + + let sortColumn = FilterView.sortColumn; + let sortColumnID = (sortColumn ? sortColumn.id : null); + let sortDir = (sortColumn ? sortColumn.getAttribute("sortDirection") : "natural"); + E("filters-sort-none" + suffix).setAttribute("checked", sortColumn == null); + E("filters-sort-filter" + suffix).setAttribute("checked", sortColumnID == "col-filter"); + E("filters-sort-enabled" + suffix).setAttribute("checked", sortColumnID == "col-enabled"); + E("filters-sort-hitcount" + suffix).setAttribute("checked", sortColumnID == "col-hitcount"); + E("filters-sort-lasthit" + suffix).setAttribute("checked", sortColumnID == "col-lasthit"); + E("filters-sort-asc" + suffix).setAttribute("checked", sortDir == "ascending"); + E("filters-sort-desc" + suffix).setAttribute("checked", sortDir == "descending"); + }, + + /** + * Fills tooltip with the item data. + */ + fillTooltip: function(event) + { + let item = FilterView.getItemAt(event.clientX, event.clientY); + if (!item || item.filter.dummy) + { + event.preventDefault(); + return; + } + + function setMultilineContent(box, text) + { + while (box.firstChild) + box.removeChild(box.firstChild); + + for (var i = 0; i < text.length; i += 80) + { + var description = document.createElement("description"); + description.setAttribute("value", text.substr(i, 80)); + box.appendChild(description); + } + } + + setMultilineContent(E("tooltip-filter"), item.filter.text); + + E("tooltip-hitcount-row").hidden = !(item.filter instanceof ActiveFilter); + E("tooltip-lasthit-row").hidden = !(item.filter instanceof ActiveFilter) || !item.filter.lastHit; + if (item.filter instanceof ActiveFilter) + { + E("tooltip-hitcount").setAttribute("value", item.filter.hitCount) + E("tooltip-lasthit").setAttribute("value", Utils.formatTime(item.filter.lastHit)) + } + + E("tooltip-additional").hidden = false; + if (item.filter instanceof InvalidFilter && item.filter.reason) + E("tooltip-additional").textContent = item.filter.reason; + else if (item.filter instanceof RegExpFilter && defaultMatcher.isSlowFilter(item.filter)) + E("tooltip-additional").textContent = Utils.getString("filter_regexp_tooltip"); + else + E("tooltip-additional").hidden = true; + }, + + /** + * Called whenever a key is pressed on the list. + */ + keyPress: function(/**Event*/ event) + { + if (event.target != E("filtersTree")) + return; + + let modifiers = 0; + if (event.altKey) + modifiers |= SubscriptionActions._altMask; + if (event.ctrlKey) + modifiers |= SubscriptionActions._ctrlMask; + if (event.metaKey) + modifiers |= SubscriptionActions._metaMask; + + if (event.charCode == " ".charCodeAt(0) && modifiers == 0 && !E("col-enabled").hidden) + this.selectionToggleDisabled(); + else if (event.keyCode == Ci.nsIDOMKeyEvent.DOM_VK_UP && modifiers == SubscriptionActions._accelMask) + { + E("filters-moveUp-command").doCommand(); + event.preventDefault(); + event.stopPropagation(); + } + else if (event.keyCode == Ci.nsIDOMKeyEvent.DOM_VK_DOWN && modifiers == SubscriptionActions._accelMask) + { + E("filters-moveDown-command").doCommand(); + event.preventDefault(); + event.stopPropagation(); + } + }, + + /** + * Copies selected items to clipboard and optionally removes them from the + * list after that. + */ + copySelected: function(/**Boolean*/ keep) + { + let items = FilterView.selectedItems; + if (!items.length) + return; + + items.sort(function(entry1, entry2) entry1.index - entry2.index); + let text = items.map(function(i) i.filter.text).join(IO.lineBreak); + Utils.clipboardHelper.copyString(text); + + if (!keep && FilterView.editable && !this.treeElement.editingColumn) + this.deleteItems(items); + }, + + /** + * Pastes text from clipboard as filters at the current position. + */ + paste: function() + { + if (!FilterView.editable || this.treeElement.editingColumn) + return; + + let transferable = Cc["@mozilla.org/widget/transferable;1"].createInstance(Ci.nsITransferable); + transferable.addDataFlavor("text/unicode"); + + let data; + try + { + data = {}; + Utils.clipboard.getData(transferable, Utils.clipboard.kGlobalClipboard); + transferable.getTransferData("text/unicode", data, {}); + data = data.value.QueryInterface(Ci.nsISupportsString).data; + } + catch (e) { + return; + } + + let item = FilterView.currentItem; + let position = (item ? item.index : FilterView.data.length); + + let lines = data.replace(/\r/g, "").split("\n"); + for (let i = 0; i < lines.length; i++) + { + let filter = Filter.fromText(lines[i]); + if (filter) + FilterStorage.addFilter(filter, FilterView._subscription, position++); + } + }, + + dragItems: null, + + /** + * Called whenever the user starts a drag operation. + */ + startDrag: function(/**Event*/ event) + { + let items = FilterView.selectedItems; + if (!items.length) + return; + + items.sort(function(entry1, entry2) entry1.index - entry2.index); + event.dataTransfer.setData("text/plain", items.map(function(i) i.filter.text).join(IO.lineBreak)); + this.dragItems = items; + event.stopPropagation(); + }, + + /** + * Called to check whether moving the items to the given position is possible. + */ + canDrop: function(/**Integer*/ newPosition, /**nsIDOMDataTransfer*/ dataTransfer) + { + if (!FilterView.editable || this.treeElement.editingColumn) + return false; + + // If we aren't dragging items then maybe we got filters as plain text + if (!this.dragItems) + return dataTransfer && dataTransfer.getData("text/plain"); + + if (FilterView.isEmpty || FilterView.isSorted()) + return false; + + if (newPosition < this.dragItems[0].index) + return true; + else if (newPosition > this.dragItems[this.dragItems.length - 1].index + 1) + return true; + else + return false; + }, + + /** + * Called when the user decides to drop the items. + */ + drop: function(/**Integer*/ newPosition, /**nsIDOMDataTransfer*/ dataTransfer) + { + if (!FilterView.editable || this.treeElement.editingColumn) + return; + + if (!this.dragItems) + { + // We got filters as plain text, insert them into the list + let data = (dataTransfer ? dataTransfer.getData("text/plain") : null); + if (data) + { + let lines = data.replace(/\r/g, "").split("\n"); + for (let i = 0; i < lines.length; i++) + { + let filter = Filter.fromText(lines[i]); + if (filter) + FilterStorage.addFilter(filter, FilterView._subscription, newPosition++); + } + } + return; + } + + if (FilterView.isEmpty || FilterView.isSorted()) + return; + + if (newPosition < this.dragItems[0].index) + this._moveItems(this.dragItems, newPosition - this.dragItems[0].index); + else if (newPosition > this.dragItems[this.dragItems.length - 1].index + 1) + this._moveItems(this.dragItems, newPosition - this.dragItems[this.dragItems.length - 1].index - 1); + }, + + /** + * Called whenever the a drag operation finishes. + */ + endDrag: function(/**Event*/ event) + { + this.dragItems = null; + }, + + /** + * Called if filters have been dragged into a subscription and need to be removed. + */ + removeDraggedFilters: function() + { + if (!this.dragItems) + return; + + this.deleteItems(this.dragItems); + } +}; + +window.addEventListener("load", function() +{ + FilterActions.init(); +}, false); diff --git a/abprime/content/filters-filterview.js b/abprime/content/filters-filterview.js new file mode 100644 index 00000000..e07e8b41 --- /dev/null +++ b/abprime/content/filters-filterview.js @@ -0,0 +1,820 @@ +/* + * This Source Code is subject to the terms of the Mozilla Public License + * version 2.0 (the "License"). You can obtain a copy of the License at + * http://mozilla.org/MPL/2.0/. + */ + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +/** + * nsITreeView implementation to display filters of a particular filter + * subscription. + * @class + */ +var FilterView = +{ + /** + * Initialization function. + */ + init: function() + { + if (this.sortProcs) + return; + + function compareText(/**Filter*/ filter1, /**Filter*/ filter2) + { + if (filter1.text < filter2.text) + return -1; + else if (filter1.text > filter2.text) + return 1; + else + return 0; + } + function compareSlow(/**Filter*/ filter1, /**Filter*/ filter2) + { + let isSlow1 = filter1 instanceof RegExpFilter && defaultMatcher.isSlowFilter(filter1); + let isSlow2 = filter2 instanceof RegExpFilter && defaultMatcher.isSlowFilter(filter2); + return isSlow1 - isSlow2; + } + function compareEnabled(/**Filter*/ filter1, /**Filter*/ filter2) + { + let hasEnabled1 = (filter1 instanceof ActiveFilter ? 1 : 0); + let hasEnabled2 = (filter2 instanceof ActiveFilter ? 1 : 0); + if (hasEnabled1 != hasEnabled2) + return hasEnabled1 - hasEnabled2; + else if (hasEnabled1) + return (filter2.disabled - filter1.disabled); + else + return 0; + } + function compareHitCount(/**Filter*/ filter1, /**Filter*/ filter2) + { + let hasHitCount1 = (filter1 instanceof ActiveFilter ? 1 : 0); + let hasHitCount2 = (filter2 instanceof ActiveFilter ? 1 : 0); + if (hasHitCount1 != hasHitCount2) + return hasHitCount1 - hasHitCount2; + else if (hasHitCount1) + return filter1.hitCount - filter2.hitCount; + else + return 0; + } + function compareLastHit(/**Filter*/ filter1, /**Filter*/ filter2) + { + let hasLastHit1 = (filter1 instanceof ActiveFilter ? 1 : 0); + let hasLastHit2 = (filter2 instanceof ActiveFilter ? 1 : 0); + if (hasLastHit1 != hasLastHit2) + return hasLastHit1 - hasLastHit2; + else if (hasLastHit1) + return filter1.lastHit - filter2.lastHit; + else + return 0; + } + + /** + * Creates a sort function from a primary and a secondary comparison function. + * @param {Function} cmpFunc comparison function to be called first + * @param {Function} fallbackFunc (optional) comparison function to be called if primary function returns 0 + * @param {Boolean} desc if true, the result of the primary function (not the secondary function) will be reversed - sorting in descending order + * @result {Function} comparison function to be used + */ + function createSortFunction(cmpFunc, fallbackFunc, desc) + { + let factor = (desc ? -1 : 1); + + return function(entry1, entry2) + { + // Comment replacements not bound to a filter always go last + let isLast1 = ("origFilter" in entry1 && entry1.filter == null); + let isLast2 = ("origFilter" in entry2 && entry2.filter == null); + if (isLast1) + return (isLast2 ? 0 : 1) + else if (isLast2) + return -1; + + let ret = cmpFunc(entry1.filter, entry2.filter); + if (ret == 0 && fallbackFunc) + return fallbackFunc(entry1.filter, entry2.filter); + else + return factor * ret; + } + } + + this.sortProcs = { + filter: createSortFunction(compareText, null, false), + filterDesc: createSortFunction(compareText, null, true), + slow: createSortFunction(compareSlow, compareText, true), + slowDesc: createSortFunction(compareSlow, compareText, false), + enabled: createSortFunction(compareEnabled, compareText, false), + enabledDesc: createSortFunction(compareEnabled, compareText, true), + hitcount: createSortFunction(compareHitCount, compareText, false), + hitcountDesc: createSortFunction(compareHitCount, compareText, true), + lasthit: createSortFunction(compareLastHit, compareText, false), + lasthitDesc: createSortFunction(compareLastHit, compareText, true) + }; + + let me = this; + let proxy = function() + { + return me._onChange.apply(me, arguments); + }; + FilterNotifier.addListener(proxy); + window.addEventListener("unload", function() + { + FilterNotifier.removeListener(proxy); + }, false); + }, + + /** + * Filter change processing. + * @see FilterNotifier.addListener() + */ + _onChange: function(action, item, param1, param2, param3) + { + switch (action) + { + case "subscription.updated": + { + if (item == this._subscription) + this.refresh(true); + break; + } + case "filter.disabled": + case "filter.hitCount": + case "filter.lastHit": + { + this.updateFilter(item); + break; + } + case "filter.added": + { + let subscription = param1; + let position = param2; + if (subscription == this._subscription) + this.addFilterAt(position, item); + break; + } + case "filter.removed": + { + let subscription = param1; + let position = param2; + if (subscription == this._subscription) + this.removeFilterAt(position); + break; + } + case "filter.moved": + { + let subscription = param1; + let oldPosition = param2; + let newPosition = param3; + if (subscription == this._subscription) + this.moveFilterAt(oldPosition, newPosition); + break; + } + } + }, + + /** + * Box object of the tree that this view is attached to. + * @type nsITreeBoxObject + */ + boxObject: null, + + /** + * Map of used cell properties to the corresponding nsIAtom representations. + */ + atoms: null, + + /** + * "Filter" to be displayed if no filter group is selected. + */ + noGroupDummy: null, + + /** + * "Filter" to be displayed if the selected group is empty. + */ + noFiltersDummy: null, + + /** + * "Filter" to be displayed for a new filter being edited. + */ + editDummy: null, + + /** + * Displayed list of filters, might be sorted. + * @type Filter[] + */ + data: [], + + /** + * element that the view is attached to. + * @type XULElement + */ + get treeElement() this.boxObject ? this.boxObject.treeBody.parentNode : null, + + /** + * Checks whether the list is currently empty (regardless of dummy entries). + * @type Boolean + */ + get isEmpty() + { + return !this._subscription || !this._subscription.filters.length; + }, + + /** + * Checks whether the filters in the view can be changed. + * @type Boolean + */ + get editable() + { + return (FilterView._subscription instanceof SpecialSubscription); + }, + + /** + * Returns current item of the list. + * @type Object + */ + get currentItem() + { + let index = this.selection.currentIndex; + if (index >= 0 && index < this.data.length) + return this.data[index]; + return null; + }, + + /** + * Returns items that are currently selected in the list. + * @type Object[] + */ + get selectedItems() + { + let items = [] + for (let i = 0; i < this.selection.getRangeCount(); i++) + { + let min = {}; + let max = {}; + this.selection.getRangeAt(i, min, max); + for (let j = min.value; j <= max.value; j++) + if (j >= 0 && j < this.data.length) + items.push(this.data[j]); + } + return items; + }, + + getItemAt: function(x, y) + { + let row = this.boxObject.getRowAt(x, y); + if (row >= 0 && row < this.data.length) + return this.data[row]; + else + return null; + }, + + _subscription: 0, + + /** + * Filter subscription being displayed. + * @type Subscription + */ + get subscription() this._subscription, + set subscription(value) + { + if (value == this._subscription) + return; + + // Make sure the editor is done before we update the list. + if (this.treeElement) + this.treeElement.stopEditing(true); + + this._subscription = value; + this.refresh(true); + }, + + /** + * Will be true if updates are outstanding because the list was hidden. + */ + _dirty: false, + + /** + * Updates internal view data after a change. + * @param {Boolean} force if false, a refresh will only happen if previous + * changes were suppressed because the list was hidden + */ + refresh: function(force) + { + if (FilterActions.visible) + { + if (!force && !this._dirty) + return; + this._dirty = false; + this.updateData(); + this.selectRow(0); + } + else + this._dirty = true; + }, + + /** + * Map of comparison functions by column ID or column ID + "Desc" for + * descending sort order. + * @const + */ + sortProcs: null, + + /** + * Column that the list is currently sorted on. + * @type Element + */ + sortColumn: null, + + /** + * Sorting function currently in use. + * @type Function + */ + sortProc: null, + + /** + * Resorts the list. + * @param {String} col ID of the column to sort on. If null, the natural order is restored. + * @param {String} direction "ascending" or "descending", if null the sort order is toggled. + */ + sortBy: function(col, direction) + { + let newSortColumn = null; + if (col) + { + newSortColumn = this.boxObject.columns.getNamedColumn(col).element; + if (!direction) + { + if (this.sortColumn == newSortColumn) + direction = (newSortColumn.getAttribute("sortDirection") == "ascending" ? "descending" : "ascending"); + else + direction = "ascending"; + } + } + + if (this.sortColumn && this.sortColumn != newSortColumn) + this.sortColumn.removeAttribute("sortDirection"); + + this.sortColumn = newSortColumn; + if (this.sortColumn) + { + this.sortColumn.setAttribute("sortDirection", direction); + this.sortProc = this.sortProcs[col.replace(/^col-/, "") + (direction == "descending" ? "Desc" : "")]; + } + else + this.sortProc = null; + + if (this.data.length > 1) + { + this.updateData(); + this.boxObject.invalidate(); + } + }, + + /** + * Inserts dummy entry into the list if necessary. + */ + addDummyRow: function() + { + if (this.boxObject && this.data.length == 0) + { + if (this._subscription) + this.data.splice(0, 0, this.noFiltersDummy); + else + this.data.splice(0, 0, this.noGroupDummy); + this.boxObject.rowCountChanged(0, 1); + } + }, + + /** + * Removes dummy entry from the list if present. + */ + removeDummyRow: function() + { + if (this.boxObject && this.isEmpty && this.data.length) + { + this.data.splice(0, 1); + this.boxObject.rowCountChanged(0, -1); + } + }, + + /** + * Inserts dummy row when a new filter is being edited. + */ + insertEditDummy: function() + { + FilterView.removeDummyRow(); + let position = this.selection.currentIndex; + if (position >= this.data.length) + position = this.data.length - 1; + if (position < 0) + position = 0; + + this.editDummy.index = (position < this.data.length ? this.data[position].index : this.data.length); + this.editDummy.position = position; + this.data.splice(position, 0, this.editDummy); + this.boxObject.rowCountChanged(position, 1); + this.selectRow(position); + }, + + /** + * Removes dummy row once the edit is finished. + */ + removeEditDummy: function() + { + let position = this.editDummy.position; + if (typeof position != "undefined" && position < this.data.length && this.data[position] == this.editDummy) + { + this.data.splice(position, 1); + this.boxObject.rowCountChanged(position, -1); + FilterView.addDummyRow(); + + this.selectRow(position); + } + }, + + /** + * Selects a row in the tree and makes sure it is visible. + */ + selectRow: function(row) + { + if (this.selection) + { + row = Math.min(Math.max(row, 0), this.data.length - 1); + this.selection.select(row); + this.boxObject.ensureRowIsVisible(row); + } + }, + + /** + * Finds a particular filter in the list and selects it. + */ + selectFilter: function(/**Filter*/ filter) + { + let index = -1; + for (let i = 0; i < this.data.length; i++) + { + if (this.data[i].filter == filter) + { + index = i; + break; + } + } + if (index >= 0) + { + this.selectRow(index); + this.treeElement.focus(); + } + }, + + /** + * Updates value of data property on sorting or filter subscription changes. + */ + updateData: function() + { + let oldCount = this.rowCount; + if (this._subscription && this._subscription.filters.length) + { + this.data = this._subscription.filters.map(function(f, i) ({index: i, filter: f})); + if (this.sortProc) + { + // Hide comments in the list, they should be sorted like the filter following them + let followingFilter = null; + for (let i = this.data.length - 1; i >= 0; i--) + { + if (this.data[i].filter instanceof CommentFilter) + { + this.data[i].origFilter = this.data[i].filter; + this.data[i].filter = followingFilter; + } + else + followingFilter = this.data[i].filter; + } + + this.data.sort(this.sortProc); + + // Restore comments + for (let i = 0; i < this.data.length; i++) + { + if ("origFilter" in this.data[i]) + { + this.data[i].filter = this.data[i].origFilter; + delete this.data[i].origFilter; + } + } + } + } + else + this.data = []; + + if (this.boxObject) + { + this.boxObject.rowCountChanged(0, -oldCount); + this.boxObject.rowCountChanged(0, this.rowCount); + } + + this.addDummyRow(); + }, + + /** + * Called to update the view when a filter property is changed. + */ + updateFilter: function(/**Filter*/ filter) + { + for (let i = 0; i < this.data.length; i++) + if (this.data[i].filter == filter) + this.boxObject.invalidateRow(i); + }, + + /** + * Called if a filter has been inserted at the specified position. + */ + addFilterAt: function(/**Integer*/ position, /**Filter*/ filter) + { + if (this.data.length == 1 && this.data[0].filter.dummy) + { + this.data.splice(0, 1); + this.boxObject.rowCountChanged(0, -1); + } + + if (this.sortProc) + { + this.updateData(); + for (let i = 0; i < this.data.length; i++) + { + if (this.data[i].index == position) + { + position = i; + break; + } + } + } + else + { + for (let i = 0; i < this.data.length; i++) + if (this.data[i].index >= position) + this.data[i].index++; + this.data.splice(position, 0, {index: position, filter: filter}); + } + this.boxObject.rowCountChanged(position, 1); + this.selectRow(position); + }, + + /** + * Called if a filter has been removed at the specified position. + */ + removeFilterAt: function(/**Integer*/ position) + { + for (let i = 0; i < this.data.length; i++) + { + if (this.data[i].index == position) + { + this.data.splice(i, 1); + this.boxObject.rowCountChanged(i, -1); + i--; + } + else if (this.data[i].index > position) + this.data[i].index--; + } + this.addDummyRow(); + }, + + /** + * Called if a filter has been moved within the list. + */ + moveFilterAt: function(/**Integer*/ oldPosition, /**Integer*/ newPosition) + { + let dir = (oldPosition < newPosition ? 1 : -1); + for (let i = 0; i < this.data.length; i++) + { + if (this.data[i].index == oldPosition) + this.data[i].index = newPosition; + else if (dir * this.data[i].index > dir * oldPosition && dir * this.data[i].index <= dir * newPosition) + this.data[i].index -= dir; + } + + if (!this.sortProc) + { + let item = this.data[oldPosition]; + this.data.splice(oldPosition, 1); + this.data.splice(newPosition, 0, item); + this.boxObject.invalidateRange(Math.min(oldPosition, newPosition), Math.max(oldPosition, newPosition)); + } + }, + + QueryInterface: XPCOMUtils.generateQI([Ci.nsITreeView]), + + setTree: function(boxObject) + { + this.init(); + this.boxObject = boxObject; + + if (this.boxObject) + { + this.noGroupDummy = {index: 0, filter: {text: this.boxObject.treeBody.getAttribute("noGroupText"), dummy: true}}; + this.noFiltersDummy = {index: 0, filter: {text: this.boxObject.treeBody.getAttribute("noFiltersText"), dummy: true}}; + this.editDummy = {filter: {text: ""}}; + + let atomService = Cc["@mozilla.org/atom-service;1"].getService(Ci.nsIAtomService); + let stringAtoms = ["col-filter", "col-enabled", "col-hitcount", "col-lasthit", "type-comment", "type-filterlist", "type-whitelist", "type-elemhide", "type-invalid"]; + let boolAtoms = ["selected", "dummy", "slow", "disabled"]; + + this.atoms = {}; + for each (let atom in stringAtoms) + this.atoms[atom] = atomService.getAtom(atom); + for each (let atom in boolAtoms) + { + this.atoms[atom + "-true"] = atomService.getAtom(atom + "-true"); + this.atoms[atom + "-false"] = atomService.getAtom(atom + "-false"); + } + + let columns = this.boxObject.columns; + for (let i = 0; i < columns.length; i++) + if (columns[i].element.hasAttribute("sortDirection")) + this.sortBy(columns[i].id, columns[i].element.getAttribute("sortDirection")); + + this.refresh(true); + } + }, + + selection: null, + + get rowCount() this.data.length, + + getCellText: function(row, col) + { + if (row < 0 || row >= this.data.length) + return null; + + col = col.id; + if (col != "col-filter" && col != "col-slow" && col != "col-hitcount" && col != "col-lasthit") + return null; + + let filter = this.data[row].filter; + if (col == "col-filter") + return filter.text; + else if (col == "col-slow") + return (filter instanceof RegExpFilter && defaultMatcher.isSlowFilter(filter) ? "!" : null); + else if (filter instanceof ActiveFilter) + { + if (col == "col-hitcount") + return filter.hitCount; + else if (col == "col-lasthit") + return (filter.lastHit ? Utils.formatTime(filter.lastHit) : null); + } + + return null; + }, + + generateProperties: function(list, properties) + { + if (properties) + { + // Gecko 21 and below: we have an nsISupportsArray parameter, add atoms + // to that. + for (let i = 0; i < list.length; i++) + if (list[i] in this.atoms) + properties.AppendElement(this.atoms[list[i]]); + return null; + } + else + { + // Gecko 22+: no parameter, just return a string + return list.join(" "); + } + }, + + getColumnProperties: function(col, properties) + { + return this.generateProperties(["col-" + col.id], properties); + }, + + getRowProperties: function(row, properties) + { + if (row < 0 || row >= this.data.length) + return ""; + + let list = []; + let filter = this.data[row].filter; + list.push("selected-" + this.selection.isSelected(row)); + list.push("slow-" + (filter instanceof RegExpFilter && defaultMatcher.isSlowFilter(filter))); + if (filter instanceof ActiveFilter) + list.push("disabled-" + filter.disabled); + list.push("dummy-" + ("dummy" in filter)); + + if (filter instanceof CommentFilter) + list.push("type-comment"); + else if (filter instanceof BlockingFilter) + list.push("type-filterlist"); + else if (filter instanceof WhitelistFilter) + list.push("type-whitelist"); + else if (filter instanceof ElemHideFilter) + list.push("type-elemhide"); + else if (filter instanceof InvalidFilter) + list.push("type-invalid"); + + return this.generateProperties(list, properties); + }, + + getCellProperties: function(row, col, properties) + { + return this.getRowProperties(row, properties) + " " + this.getColumnProperties(col, properties); + }, + + cycleHeader: function(col) + { + let oldDirection = col.element.getAttribute("sortDirection"); + if (oldDirection == "ascending") + this.sortBy(col.id, "descending"); + else if (oldDirection == "descending") + this.sortBy(null, null); + else + this.sortBy(col.id, "ascending"); + }, + + isSorted: function() + { + return (this.sortProc != null); + }, + + canDrop: function(row, orientation, dataTransfer) + { + if (orientation == Ci.nsITreeView.DROP_ON || row < 0 || row >= this.data.length || !this.editable) + return false; + + let item = this.data[row]; + let position = (orientation == Ci.nsITreeView.DROP_BEFORE ? item.index : item.index + 1); + return FilterActions.canDrop(position, dataTransfer); + }, + + drop: function(row, orientation, dataTransfer) + { + if (orientation == Ci.nsITreeView.DROP_ON || row < 0 || row >= this.data.length || !this.editable) + return; + + let item = this.data[row]; + let position = (orientation == Ci.nsITreeView.DROP_BEFORE ? item.index : item.index + 1); + FilterActions.drop(position, dataTransfer); + }, + + isEditable: function(row, col) + { + if (row < 0 || row >= this.data.length || !this.editable) + return false; + + let filter = this.data[row].filter; + if (col.id == "col-filter") + return !("dummy" in filter); + else + return false; + }, + + setCellText: function(row, col, value) + { + if (row < 0 || row >= this.data.length || col.id != "col-filter") + return; + + let oldFilter = this.data[row].filter; + let position = this.data[row].index; + value = Filter.normalize(value); + if (!value || value == oldFilter.text) + return; + + // Make sure we don't get called recursively (see https://adblockplus.org/forum/viewtopic.php?t=9003) + this.treeElement.stopEditing(); + + let newFilter = Filter.fromText(value); + if (this.data[row] == this.editDummy) + this.removeEditDummy(); + else + FilterStorage.removeFilter(oldFilter, this._subscription, position); + FilterStorage.addFilter(newFilter, this._subscription, position); + }, + + cycleCell: function(row, col) + { + if (row < 0 || row >= this.data.length || col.id != "col-enabled") + return; + + let filter = this.data[row].filter; + if (filter instanceof ActiveFilter) + filter.disabled = !filter.disabled; + }, + + isContainer: function(row) false, + isContainerOpen: function(row) false, + isContainerEmpty: function(row) true, + getLevel: function(row) 0, + getParentIndex: function(row) -1, + hasNextSibling: function(row, afterRow) false, + toggleOpenState: function(row) {}, + getProgressMode: function() null, + getImageSrc: function() null, + isSeparator: function() false, + performAction: function() {}, + performActionOnRow: function() {}, + performActionOnCell: function() {}, + getCellValue: function() null, + setCellValue: function() {}, + selectionChanged: function() {}, +}; diff --git a/abprime/content/filters-search.js b/abprime/content/filters-search.js new file mode 100644 index 00000000..dabc3208 --- /dev/null +++ b/abprime/content/filters-search.js @@ -0,0 +1,263 @@ +/* + * This Source Code is subject to the terms of the Mozilla Public License + * version 2.0 (the "License"). You can obtain a copy of the License at + * http://mozilla.org/MPL/2.0/. + */ + +/** + * Implementation of the filter search functionality. + * @class + */ +var FilterSearch = +{ + /** + * Initializes findbar widget. + */ + init: function() + { + let filters = E("filtersTree"); + for (let prop in FilterSearch.fakeBrowser) + filters[prop] = FilterSearch.fakeBrowser[prop]; + Object.defineProperty(filters, "_lastSearchString", { + get: function() + { + return this.finder.searchString; + }, + enumerable: true, + configurable: true + }); + + let findbar = E("findbar"); + findbar.browser = filters; + + findbar.addEventListener("keypress", function(event) + { + // Work-around for bug 490047 + if (event.keyCode == KeyEvent.DOM_VK_RETURN) + event.preventDefault(); + }, false); + + // Hack to prevent "highlight all" from getting enabled + findbar.toggleHighlight = function() {}; + }, + + /** + * Performs a text search. + * @param {String} text text to be searched + * @param {Integer} direction search direction: -1 (backwards), 0 (forwards + * starting with current), 1 (forwards starting with next) + * @param {Boolean} caseSensitive if true, a case-sensitive search is performed + * @result {Integer} one of the nsITypeAheadFind constants + */ + search: function(text, direction, caseSensitive) + { + function normalizeString(string) caseSensitive ? string : string.toLowerCase(); + + function findText(text, direction, startIndex) + { + let list = E("filtersTree"); + let col = list.columns.getNamedColumn("col-filter"); + let count = list.view.rowCount; + for (let i = startIndex + direction; i >= 0 && i < count; i += (direction || 1)) + { + let filter = normalizeString(list.view.getCellText(i, col)); + if (filter.indexOf(text) >= 0) + { + FilterView.selectRow(i); + return true; + } + } + return false; + } + + text = normalizeString(text); + + // First try to find the entry in the current list + if (findText(text, direction, E("filtersTree").currentIndex)) + return Ci.nsITypeAheadFind.FIND_FOUND; + + // Now go through the other subscriptions + let result = Ci.nsITypeAheadFind.FIND_FOUND; + let subscriptions = FilterStorage.subscriptions.slice(); + subscriptions.sort((s1, s2) => (s1 instanceof SpecialSubscription) - (s2 instanceof SpecialSubscription)); + let current = subscriptions.indexOf(FilterView.subscription); + direction = direction || 1; + for (let i = current + direction; ; i+= direction) + { + if (i < 0) + { + i = subscriptions.length - 1; + result = Ci.nsITypeAheadFind.FIND_WRAPPED; + } + else if (i >= subscriptions.length) + { + i = 0; + result = Ci.nsITypeAheadFind.FIND_WRAPPED; + } + if (i == current) + break; + + let subscription = subscriptions[i]; + for (let j = 0; j < subscription.filters.length; j++) + { + let filter = normalizeString(subscription.filters[j].text); + if (filter.indexOf(text) >= 0) + { + let list = E(subscription instanceof SpecialSubscription ? "groups" : "subscriptions"); + let node = Templater.getNodeForData(list, "subscription", subscription); + if (!node) + break; + + // Select subscription in its list and restore focus after that + let oldFocus = document.commandDispatcher.focusedElement; + E("tabs").selectedIndex = (subscription instanceof SpecialSubscription ? 1 : 0); + list.ensureElementIsVisible(node); + list.selectItem(node); + if (oldFocus) + { + oldFocus.focus(); + Utils.runAsync(() => oldFocus.focus()); + } + + Utils.runAsync(() => findText(text, direction, direction == 1 ? -1 : subscription.filters.length)); + return result; + } + } + } + + return Ci.nsITypeAheadFind.FIND_NOTFOUND; + } +}; + +/** + * Fake browser implementation to make findbar widget happy - searches in + * the filter list. + */ +FilterSearch.fakeBrowser = +{ + finder: + { + _resultListeners: [], + searchString: null, + caseSensitive: false, + lastResult: null, + + _notifyResultListeners: function(result, findBackwards) + { + this.lastResult = result; + for (let listener of this._resultListeners) + { + // See https://bugzilla.mozilla.org/show_bug.cgi?id=958101, starting + // with Gecko 29 only one parameter is expected. + try + { + if (listener.onFindResult.length == 1) + { + listener.onFindResult({searchString: this.searchString, + result: result, findBackwards: findBackwards}); + } + else + listener.onFindResult(result, findBackwards); + } + catch (e) + { + Cu.reportError(e); + } + } + }, + + fastFind: function(searchString, linksOnly, drawOutline) + { + this.searchString = searchString; + let result = FilterSearch.search(this.searchString, 0, + this.caseSensitive); + this._notifyResultListeners(result, false); + }, + + findAgain: function(findBackwards, linksOnly, drawOutline) + { + let result = FilterSearch.search(this.searchString, + findBackwards ? -1 : 1, + this.caseSensitive); + this._notifyResultListeners(result, findBackwards); + }, + + addResultListener: function(listener) + { + if (this._resultListeners.indexOf(listener) === -1) + this._resultListeners.push(listener); + }, + + removeResultListener: function(listener) + { + let index = this._resultListeners.indexOf(listener); + if (index !== -1) + this._resultListeners.splice(index, 1); + }, + + // Irrelevant for us + requestMatchesCount: function(searchString, matchLimit, linksOnly) {}, + highlight: function(highlight, word) {}, + enableSelection: function() {}, + removeSelection: function() {}, + focusContent: function() {}, + keyPress: function() {} + }, + + currentURI: Utils.makeURI("http://example.com/"), + contentWindow: + { + focus: function() + { + E("filtersTree").focus(); + }, + scrollByLines: function(num) + { + E("filtersTree").boxObject.scrollByLines(num); + }, + scrollByPages: function(num) + { + E("filtersTree").boxObject.scrollByPages(num); + }, + }, + + messageManager: + { + _messageMap: { + "Findbar:Mouseup": "mouseup", + "Findbar:Keypress": "keypress" + }, + + _messageFromEvent: function(event) + { + for (let message in this._messageMap) + if (this._messageMap[message] == event.type) + return {target: event.currentTarget, name: message, data: event}; + return null; + }, + + addMessageListener: function(message, listener) + { + if (!this._messageMap.hasOwnProperty(message)) + return; + + if (!("_ABPHandler" in listener)) + listener._ABPHandler = (event) => listener.receiveMessage(this._messageFromEvent(event)); + + E("filtersTree").addEventListener(this._messageMap[message], listener._ABPHandler, false); + }, + + removeMessageListener: function(message, listener) + { + if (this._messageMap.hasOwnProperty(message) && listener._ABPHandler) + E("filtersTree").removeEventListener(this._messageMap[message], listener._ABPHandler, false); + }, + + sendAsyncMessage: function() {} + } +}; + +window.addEventListener("load", function() +{ + FilterSearch.init(); +}, false); diff --git a/abprime/content/filters-subscriptionactions.js b/abprime/content/filters-subscriptionactions.js new file mode 100644 index 00000000..84a91736 --- /dev/null +++ b/abprime/content/filters-subscriptionactions.js @@ -0,0 +1,592 @@ +/* + * This Source Code is subject to the terms of the Mozilla Public License + * version 2.0 (the "License"). You can obtain a copy of the License at + * http://mozilla.org/MPL/2.0/. + */ + +/** + * Implemetation of the various actions that can be performed on subscriptions. + * @class + */ +var SubscriptionActions = +{ + /** + * Returns the subscription list currently having focus if any. + * @type Element + */ + get focusedList() + { + return E("tabs").selectedPanel.getElementsByTagName("richlistbox")[0]; + }, + + /** + * Returns the currently selected and focused subscription item if any. + * @type Element + */ + get selectedItem() + { + let list = this.focusedList; + return (list ? list.selectedItem : null); + }, + + /** + * Finds the subscription for a particular filter, selects it and selects the + * filter. + */ + selectFilter: function(/**Filter*/ filter) + { + let node = null; + let tabIndex = -1; + let subscriptions = filter.subscriptions.slice(); + subscriptions.sort(function(s1, s2) s1.disabled - s2.disabled); + for (let i = 0; i < subscriptions.length; i++) + { + let subscription = subscriptions[i]; + let list = E(subscription instanceof SpecialSubscription ? "groups" : "subscriptions"); + tabIndex = (subscription instanceof SpecialSubscription ? 1 : 0); + node = Templater.getNodeForData(list, "subscription", subscription); + if (node) + break; + } + if (node) + { + E("tabs").selectedIndex = tabIndex; + Utils.runAsync(function() + { + node.parentNode.ensureElementIsVisible(node); + node.parentNode.selectItem(node); + if (!FilterActions.visible) + E("subscription-showHideFilters-command").doCommand(); + Utils.runAsync(FilterView.selectFilter, FilterView, filter); + }); + } + }, + + /** + * Updates subscription commands whenever the selected subscription changes. + * Note: this method might be called with a wrong "this" value. + */ + updateCommands: function() + { + let node = SubscriptionActions.selectedItem; + let data = Templater.getDataForNode(node); + let subscription = (data ? data.subscription : null) + E("subscription-editTitle-command").setAttribute("disabled", !subscription || + subscription.fixedTitle); + E("subscription-update-command").setAttribute("disabled", !subscription || + !(subscription instanceof DownloadableSubscription) || + Synchronizer.isExecuting(subscription.url)); + E("subscription-moveUp-command").setAttribute("disabled", !subscription || + !node || !node.previousSibling || !!node.previousSibling.id); + E("subscription-moveDown-command").setAttribute("disabled", !subscription || + !node || !node.nextSibling || !!node.nextSibling.id); + }, + + /** + * Starts title editing for the selected subscription. + */ + editTitle: function() + { + let node = this.selectedItem; + if (node) + TitleEditor.start(node); + }, + + /** + * Triggers re-download of a filter subscription. + */ + updateFilters: function(/**Node*/ node) + { + let data = Templater.getDataForNode(node || this.selectedItem); + if (data && data.subscription instanceof DownloadableSubscription) + Synchronizer.execute(data.subscription, true, true); + }, + + /** + * Triggers re-download of all filter subscriptions. + */ + updateAllFilters: function() + { + for (let i = 0; i < FilterStorage.subscriptions.length; i++) + { + let subscription = FilterStorage.subscriptions[i]; + if (subscription instanceof DownloadableSubscription) + Synchronizer.execute(subscription, true, true); + } + }, + + /** + * Sets Subscription.disabled field to a new value. + */ + setDisabled: function(/**Element*/ node, /**Boolean*/ value) + { + let data = Templater.getDataForNode(node || this.selectedItem); + if (data) + data.subscription.disabled = value; + }, + + /** + * Enables all disabled filters in a subscription. + */ + enableFilters: function(/**Element*/ node) + { + let data = Templater.getDataForNode(node); + if (!data) + return; + + let filters = data.subscription.filters; + for (let i = 0, l = filters.length; i < l; i++) + if (filters[i] instanceof ActiveFilter && filters[i].disabled) + filters[i].disabled = false; + }, + + /** + * Removes a filter subscription from the list (after a warning). + */ + remove: function(/**Node*/ node) + { + let data = Templater.getDataForNode(node || this.selectedItem); + if (data && Utils.confirm(window, Utils.getString(data.subscription instanceof SpecialSubscription ? "remove_group_warning" : "remove_subscription_warning"))) + FilterStorage.removeSubscription(data.subscription); + }, + + /** + * Adds a new filter group and allows the user to change its title. + */ + addGroup: function() + { + let subscription = SpecialSubscription.create(); + FilterStorage.addSubscription(subscription); + + let list = E("groups"); + let node = Templater.getNodeForData(list, "subscription", subscription); + if (node) + { + list.focus(); + list.ensureElementIsVisible(node); + list.selectedItem = node; + this.editTitle(); + } + }, + + /** + * Moves a filter subscription one line up. + */ + moveUp: function(/**Node*/ node) + { + node = Templater.getDataNode(node || this.selectedItem); + let data = Templater.getDataForNode(node); + if (!data) + return; + + let previousData = Templater.getDataForNode(node.previousSibling); + if (!previousData) + return; + + FilterStorage.moveSubscription(data.subscription, previousData.subscription); + }, + + /** + * Moves a filter subscription one line down. + */ + moveDown: function(/**Node*/ node) + { + node = Templater.getDataNode(node || this.selectedItem); + let data = Templater.getDataForNode(node); + if (!data) + return; + + let nextNode = node.nextSibling; + if (!Templater.getDataForNode(nextNode)) + return; + + let nextData = Templater.getDataForNode(nextNode.nextSibling); + FilterStorage.moveSubscription(data.subscription, nextData ? nextData.subscription : null); + }, + + /** + * Opens the context menu for a subscription node. + */ + openMenu: function(/**Event*/ event, /**Node*/ node) + { + node.getElementsByClassName("actionMenu")[0].openPopupAtScreen(event.screenX, event.screenY, true); + }, + + _altMask: 2, + _ctrlMask: 4, + _metaMask: 8, + get _accelMask() + { + let result = this._ctrlMask; + try { + let accelKey = Utils.prefService.getIntPref("ui.key.accelKey"); + if (accelKey == Ci.nsIDOMKeyEvent.DOM_VK_META) + result = this._metaMask; + else if (accelKey == Ci.nsIDOMKeyEvent.DOM_VK_ALT) + result = this._altMask; + } catch(e) {} + this.__defineGetter__("_accelMask", function() result); + return result; + }, + + /** + * Called when a key is pressed on the subscription list. + */ + keyPress: function(/**Event*/ event) + { + let modifiers = 0; + if (event.altKey) + modifiers |= this._altMask; + if (event.ctrlKey) + modifiers |= this._ctrlMask; + if (event.metaKey) + modifiers |= this._metaMask; + + if (event.charCode == " ".charCodeAt(0) && modifiers == 0) + { + // Ignore if Space is pressed on a button + for (let node = event.target; node; node = node.parentNode) + if (node.localName == "button") + return; + + let data = Templater.getDataForNode(this.selectedItem); + if (data) + data.subscription.disabled = !data.subscription.disabled; + } + else if (event.keyCode == Ci.nsIDOMKeyEvent.DOM_VK_UP && modifiers == this._accelMask) + { + E("subscription-moveUp-command").doCommand(); + event.preventDefault(); + event.stopPropagation(); + } + else if (event.keyCode == Ci.nsIDOMKeyEvent.DOM_VK_DOWN && modifiers == this._accelMask) + { + E("subscription-moveDown-command").doCommand(); + event.preventDefault(); + event.stopPropagation(); + } + }, + + /** + * Subscription currently being dragged if any. + * @type Subscription + */ + dragSubscription: null, + + /** + * Called when a subscription entry is dragged. + */ + startDrag: function(/**Event*/ event, /**Node*/ node) + { + let data = Templater.getDataForNode(node); + if (!data) + return; + + event.dataTransfer.addElement(node); + event.dataTransfer.setData("text/x-moz-url", data.subscription.url); + event.dataTransfer.setData("text/plain", data.subscription.title); + this.dragSubscription = data.subscription; + event.stopPropagation(); + }, + + /** + * Called when something is dragged over a subscription entry or subscriptions list. + */ + dragOver: function(/**Event*/ event) + { + // Don't allow dragging onto a scroll bar + for (let node = event.originalTarget; node; node = node.parentNode) + if (node.localName == "scrollbar") + return; + + // Don't allow dragging onto element's borders + let target = event.originalTarget; + while (target && target.localName != "richlistitem") + target = target.parentNode; + if (!target) + target = event.originalTarget; + + let styles = window.getComputedStyle(target, null); + let rect = target.getBoundingClientRect(); + if (event.clientX < rect.left + parseInt(styles.borderLeftWidth, 10) || + event.clientY < rect.top + parseInt(styles.borderTopWidth, 10) || + event.clientX > rect.right - parseInt(styles.borderRightWidth, 10) - 1 || + event.clientY > rect.bottom - parseInt(styles.borderBottomWidth, 10) - 1) + { + return; + } + + // If not dragging a subscription check whether we can accept plain text + if (!this.dragSubscription) + { + let data = Templater.getDataForNode(event.target); + if (!data || !(data.subscription instanceof SpecialSubscription) || !event.dataTransfer.getData("text/plain")) + return; + } + + event.preventDefault(); + event.stopPropagation(); + }, + + /** + * Called when something is dropped on a subscription entry or subscriptions list. + */ + drop: function(/**Event*/ event, /**Node*/ node) + { + if (!this.dragSubscription) + { + // Not dragging a subscription, maybe this is plain text that we can add as filters? + let data = Templater.getDataForNode(node); + if (data && data.subscription instanceof SpecialSubscription) + { + let lines = event.dataTransfer.getData("text/plain").replace(/\r/g, "").split("\n"); + for (let i = 0; i < lines.length; i++) + { + let filter = Filter.fromText(lines[i]); + if (filter) + FilterStorage.addFilter(filter, data.subscription); + } + FilterActions.removeDraggedFilters(); + event.stopPropagation(); + } + return; + } + + // When dragging down we need to insert after the drop node, otherwise before it. + node = Templater.getDataNode(node); + if (node) + { + let dragNode = Templater.getNodeForData(node.parentNode, "subscription", this.dragSubscription); + if (node.compareDocumentPosition(dragNode) & node.DOCUMENT_POSITION_PRECEDING) + node = node.nextSibling; + } + + let data = Templater.getDataForNode(node); + FilterStorage.moveSubscription(this.dragSubscription, data ? data.subscription : null); + event.stopPropagation(); + }, + + /** + * Called when the drag operation for a subscription is finished. + */ + endDrag: function() + { + this.dragSubscription = null; + } +}; + +/** + * Subscription title editing functionality. + * @class + */ +var TitleEditor = +{ + /** + * List item corresponding with the currently edited subscription if any. + * @type Node + */ + subscriptionEdited: null, + + /** + * Starts editing of a subscription title. + * @param {Node} node subscription list entry or a child node + * @param {Boolean} [checkSelection] if true the editor will not start if the + * item was selected in the preceding mousedown event + */ + start: function(node, checkSelection) + { + if (this.subscriptionEdited) + this.end(true); + + let subscriptionNode = Templater.getDataNode(node); + if (!subscriptionNode || (checkSelection && !subscriptionNode._wasSelected)) + return; + + let subscription = Templater.getDataForNode(subscriptionNode).subscription; + if (!subscription || subscription.fixedTitle) + return; + + subscriptionNode.getElementsByClassName("titleBox")[0].selectedIndex = 1; + let editor = subscriptionNode.getElementsByClassName("titleEditor")[0]; + editor.value = subscription.title; + editor.setSelectionRange(0, editor.value.length); + this.subscriptionEdited = subscriptionNode; + editor.focus(); + }, + + /** + * Stops editing of a subscription title. + * @param {Boolean} save if true the entered value will be saved, otherwise dismissed + */ + end: function(save) + { + if (!this.subscriptionEdited) + return; + + let subscriptionNode = this.subscriptionEdited; + this.subscriptionEdited = null; + + let newTitle = null; + if (save) + { + newTitle = subscriptionNode.getElementsByClassName("titleEditor")[0].value; + newTitle = newTitle.replace(/^\s+/, "").replace(/\s+$/, ""); + } + + let subscription = Templater.getDataForNode(subscriptionNode).subscription + if (newTitle && newTitle != subscription.title) + subscription.title = newTitle; + else + { + subscriptionNode.getElementsByClassName("titleBox")[0].selectedIndex = 0; + subscriptionNode.parentNode.focus(); + } + }, + + /** + * Processes keypress events on the subscription title editor field. + */ + keyPress: function(/**Event*/ event) + { + // Prevent any key presses from triggering outside actions + event.stopPropagation(); + + if (event.keyCode == event.DOM_VK_RETURN || event.keyCode == event.DOM_VK_ENTER) + { + event.preventDefault(); + this.end(true); + } + else if (event.keyCode == event.DOM_VK_CANCEL || event.keyCode == event.DOM_VK_ESCAPE) + { + event.preventDefault(); + this.end(false); + } + } +}; + +/** + * Methods called when choosing and adding a new filter subscription. + * @class + */ +var SelectSubscription = +{ + /** + * Starts selection of a filter subscription to add. + */ + start: function(/**Event*/ event) + { + let panel = E("selectSubscriptionPanel"); + let list = E("selectSubscription"); + let template = E("selectSubscriptionTemplate"); + let parent = list.menupopup; + + if (panel.state == "open") + { + list.focus(); + return; + } + + // Remove existing entries if any + while (parent.lastChild) + parent.removeChild(parent.lastChild); + + // Load data + let request = new XMLHttpRequest(); + request.open("GET", "subscriptions.xml"); + request.onload = function() + { + // Avoid race condition if two downloads are started in parallel + if (panel.state == "open") + return; + + // Add subscription entries to the list + let subscriptions = request.responseXML.getElementsByTagName("subscription"); + let listedSubscriptions = []; + for (let i = 0; i < subscriptions.length; i++) + { + let subscription = subscriptions[i]; + let url = subscription.getAttribute("url"); + if (!url || url in FilterStorage.knownSubscriptions) + continue; + + let localePrefix = Utils.checkLocalePrefixMatch(subscription.getAttribute("prefixes")); + let node = Templater.process(template, { + __proto__: null, + node: subscription, + localePrefix: localePrefix + }); + parent.appendChild(node); + listedSubscriptions.push(subscription); + } + let selectedNode = Utils.chooseFilterSubscription(listedSubscriptions); + list.selectedItem = Templater.getNodeForData(parent, "node", selectedNode) || parent.firstChild; + + // Show panel and focus list + let position = "bottomcenter topleft"; + panel.openPopup(E("selectSubscriptionButton"), position, 0, 0, false, false, event); + Utils.runAsync(list.focus, list); + }; + request.send(); + }, + + /** + * Adds filter subscription that is selected. + */ + add: function() + { + E("selectSubscriptionPanel").hidePopup(); + + let data = Templater.getDataForNode(E("selectSubscription").selectedItem); + if (!data) + return; + + let subscription = Subscription.fromURL(data.node.getAttribute("url")); + if (!subscription) + return; + + FilterStorage.addSubscription(subscription); + subscription.disabled = false; + subscription.title = data.node.getAttribute("title"); + subscription.homepage = data.node.getAttribute("homepage"); + + // Make sure the subscription is visible and selected + let list = E("subscriptions"); + let node = Templater.getNodeForData(list, "subscription", subscription); + if (node) + { + list.ensureElementIsVisible(node); + list.selectedItem = node; + list.focus(); + } + + // Trigger download if necessary + if (subscription instanceof DownloadableSubscription && !subscription.lastDownload) + Synchronizer.execute(subscription); + }, + + /** + * Called if the user chooses to view the complete subscriptions list. + */ + chooseOther: function() + { + E("selectSubscriptionPanel").hidePopup(); + window.openDialog("subscriptionSelection.xul", "_blank", "chrome,centerscreen,modal,resizable,dialog=no", null, null); + }, + + /** + * Called for keys pressed on the subscription selection panel. + */ + keyPress: function(/**Event*/ event) + { + // Buttons and text links handle Enter key themselves + if (event.target.localName == "button" || event.target.localName == "label") + return; + + if (event.keyCode == event.DOM_VK_RETURN || event.keyCode == event.DOM_VK_ENTER) + { + // This shouldn't accept our dialog, only the panel + event.preventDefault(); + E("selectSubscriptionAccept").doCommand(); + } + } +}; diff --git a/abprime/content/filters-subscriptionview.js b/abprime/content/filters-subscriptionview.js new file mode 100644 index 00000000..bdcd50c3 --- /dev/null +++ b/abprime/content/filters-subscriptionview.js @@ -0,0 +1,288 @@ +/* + * This Source Code is subject to the terms of the Mozilla Public License + * version 2.0 (the "License"). You can obtain a copy of the License at + * http://mozilla.org/MPL/2.0/. + */ + +/** + * Fills a list of filter groups and keeps it updated. + * @param {Element} list richlistbox element to be filled + * @param {Node} template template to use for the groups + * @param {Function} filter filter to decide which lists should be included + * @param {Function} listener function to be called on changes + * @constructor + */ +function ListManager(list, template, filter, listener) +{ + this._list = list; + this._template = template; + this._filter = filter; + this._listener = listener || function(){}; + + this._deck = this._list.parentNode; + + this._list.listManager = this; + this.reload(); + + let me = this; + let proxy = function() + { + return me._onChange.apply(me, arguments); + }; + FilterNotifier.addListener(proxy); + window.addEventListener("unload", function() + { + FilterNotifier.removeListener(proxy); + }, false); +} +ListManager.prototype = +{ + /** + * List element being managed. + * @type Element + */ + _list: null, + /** + * Template used for the groups. + * @type Node + */ + _template: null, + /** + * Filter function to decide which subscriptions should be included. + * @type Function + */ + _filter: null, + /** + * Function to be called whenever list contents change. + * @type Function + */ + _listener: null, + /** + * Deck switching between list display and "no entries" message. + * @type Element + */ + _deck: null, + + /** + * Completely rebuilds the list. + */ + reload: function() + { + // Remove existing entries if any + while (this._list.firstChild) + this._list.removeChild(this._list.firstChild); + + // Now add all subscriptions + let subscriptions = FilterStorage.subscriptions.filter(this._filter, this); + if (subscriptions.length) + { + for each (let subscription in subscriptions) + this.addSubscription(subscription, null); + + // Make sure first list item is selected after list initialization + Utils.runAsync(function() + { + this._list.selectItem(this._list.getItemAtIndex(this._list.getIndexOfFirstVisibleRow())); + }, this); + } + + this._deck.selectedIndex = (subscriptions.length ? 1 : 0); + this._listener(); + }, + + /** + * Adds a filter subscription to the list. + */ + addSubscription: function(/**Subscription*/ subscription, /**Node*/ insertBefore) /**Node*/ + { + let disabledFilters = 0; + for (let i = 0, l = subscription.filters.length; i < l; i++) + if (subscription.filters[i] instanceof ActiveFilter && subscription.filters[i].disabled) + disabledFilters++; + + let node = Templater.process(this._template, { + __proto__: null, + subscription: subscription, + isExternal: subscription instanceof ExternalSubscription, + downloading: Synchronizer.isExecuting(subscription.url), + disabledFilters: disabledFilters + }); + if (insertBefore) + this._list.insertBefore(node, insertBefore); + else + this._list.appendChild(node); + return node; + }, + + /** + * Map indicating subscriptions that need their "disabledFilters" property to + * be updated by next updateDisabled() call. + * @type Object + */ + _scheduledUpdateDisabled: null, + + /** + * Updates subscriptions that had some of their filters enabled/disabled. + */ + updateDisabled: function() + { + let list = this._scheduledUpdateDisabled; + this._scheduledUpdateDisabled = null; + for (let url in list) + { + let subscription = Subscription.fromURL(url); + let subscriptionNode = Templater.getNodeForData(this._list, "subscription", subscription); + if (subscriptionNode) + { + let data = Templater.getDataForNode(subscriptionNode); + let disabledFilters = 0; + for (let i = 0, l = subscription.filters.length; i < l; i++) + if (subscription.filters[i] instanceof ActiveFilter && subscription.filters[i].disabled) + disabledFilters++; + + if (disabledFilters != data.disabledFilters) + { + data.disabledFilters = disabledFilters; + Templater.update(this._template, subscriptionNode); + + if (!document.commandDispatcher.focusedElement) + this._list.focus(); + } + } + } + }, + + /** + * Subscriptions change processing. + * @see FilterNotifier.addListener() + */ + _onChange: function(action, item, param1, param2) + { + + if (action == "filter.disabled") + { + if (this._scheduledUpdateDisabled == null) + { + this._scheduledUpdateDisabled = {__proto__: null}; + Utils.runAsync(this.updateDisabled, this); + } + for (let i = 0; i < item.subscriptions.length; i++) + this._scheduledUpdateDisabled[item.subscriptions[i].url] = true; + return; + } + + if (action != "load" && !this._filter(item)) + return; + + switch (action) + { + case "load": + { + this.reload(); + break; + } + case "subscription.added": + { + let index = FilterStorage.subscriptions.indexOf(item); + if (index >= 0) + { + let insertBefore = null; + for (index++; index < FilterStorage.subscriptions.length && !insertBefore; index++) + insertBefore = Templater.getNodeForData(this._list, "subscription", FilterStorage.subscriptions[index]); + this.addSubscription(item, insertBefore); + this._deck.selectedIndex = 1; + this._listener(); + } + break; + } + case "subscription.removed": + { + let node = Templater.getNodeForData(this._list, "subscription", item); + if (node) + { + let newSelection = node.nextSibling || node.previousSibling; + node.parentNode.removeChild(node); + if (!this._list.firstChild) + { + this._deck.selectedIndex = 0; + this._list.selectedIndex = -1; + } + else if (newSelection) + { + this._list.ensureElementIsVisible(newSelection); + this._list.selectedItem = newSelection; + } + this._listener(); + } + break + } + case "subscription.moved": + { + let node = Templater.getNodeForData(this._list, "subscription", item); + if (node) + { + node.parentNode.removeChild(node); + let insertBefore = null; + let index = FilterStorage.subscriptions.indexOf(item); + if (index >= 0) + for (index++; index < FilterStorage.subscriptions.length && !insertBefore; index++) + insertBefore = Templater.getNodeForData(this._list, "subscription", FilterStorage.subscriptions[index]); + this._list.insertBefore(node, insertBefore); + this._list.ensureElementIsVisible(node); + this._listener(); + } + break; + } + case "subscription.title": + case "subscription.disabled": + case "subscription.homepage": + case "subscription.lastDownload": + case "subscription.downloadStatus": + { + let subscriptionNode = Templater.getNodeForData(this._list, "subscription", item); + if (subscriptionNode) + { + Templater.getDataForNode(subscriptionNode).downloading = Synchronizer.isExecuting(item.url); + Templater.update(this._template, subscriptionNode); + + if (!document.commandDispatcher.focusedElement) + this._list.focus(); + this._listener(); + } + break; + } + case "subscription.fixedTitle": + { + SubscriptionActions.updateCommands(); + break; + } + case "subscription.updated": + { + if (this._scheduledUpdateDisabled == null) + { + this._scheduledUpdateDisabled = {__proto__: null}; + Utils.runAsync(this.updateDisabled, this); + } + this._scheduledUpdateDisabled[item.url] = true; + break; + } + } + } +}; + +/** + * Attaches list managers to the lists. + */ +ListManager.init = function() +{ + new ListManager(E("subscriptions"), + E("subscriptionTemplate"), + function(s) s instanceof RegularSubscription, + SubscriptionActions.updateCommands); + new ListManager(E("groups"), + E("groupTemplate"), + function(s) s instanceof SpecialSubscription, + SubscriptionActions.updateCommands); +}; + +window.addEventListener("load", ListManager.init, false); diff --git a/abprime/content/filters.js b/abprime/content/filters.js new file mode 100644 index 00000000..c535eba8 --- /dev/null +++ b/abprime/content/filters.js @@ -0,0 +1,210 @@ +/* + * This Source Code is subject to the terms of the Mozilla Public License + * version 2.0 (the "License"). You can obtain a copy of the License at + * http://mozilla.org/MPL/2.0/. + */ + +/** + * Initialization function, called when the window is loaded. + */ +function init() +{ + if (window.arguments && window.arguments.length) + { + let filter = window.arguments[0].wrappedJSObject; + if (filter instanceof Filter) + Utils.runAsync(SubscriptionActions.selectFilter, SubscriptionActions, filter); + } +} + +/** + * Called whenever the currently selected tab changes. + */ +function onTabChange(/**Element*/ tabbox) +{ + updateSelectedSubscription(); + + Utils.runAsync(function() + { + let panel = tabbox.selectedPanel; + if (panel) + panel.getElementsByClassName("initialFocus")[0].focus(); + SubscriptionActions.updateCommands(); + }); +} + +/** + * Called whenever the selected subscription changes. + */ +function onSelectionChange(/**Element*/ list) +{ + SubscriptionActions.updateCommands(); + updateSelectedSubscription(); + list.focus(); + + // Take elements of the previously selected item out of the tab order + if ("previousSelection" in list && list.previousSelection) + { + let elements = list.previousSelection.getElementsByClassName("tabable"); + for (let i = 0; i < elements.length; i++) + elements[i].setAttribute("tabindex", "-1"); + } + // Put elements of the selected item into tab order + if (list.selectedItem) + { + let elements = list.selectedItem.getElementsByClassName("tabable"); + for (let i = 0; i < elements.length; i++) + elements[i].removeAttribute("tabindex"); + } + list.previousSelection = list.selectedItem; +} + +/** + * Called when splitter state changes to make sure it is persisted properly. + */ +function onSplitterStateChange(/**Element*/ splitter) +{ + let state = splitter.getAttribute("state"); + if (!state) + { + splitter.setAttribute("state", "open"); + document.persist(splitter.id, "state"); + } +} + +/** + * Updates filter list when selected subscription changes. + */ +function updateSelectedSubscription() +{ + let panel = E("tabs").selectedPanel; + if (!panel) + return; + + let list = panel.getElementsByTagName("richlistbox")[0]; + if (!list) + return; + + let data = Templater.getDataForNode(list.selectedItem); + FilterView.subscription = (data ? data.subscription : null); + FilterActions.updateCommands(); +} + +/** + * Template processing functions. + * @class + */ +var Templater = +{ + /** + * Processes a template node using given data object. + */ + process: function(/**Node*/ template, /**Object*/ data) /**Node*/ + { + // Use a sandbox to resolve attributes (for convenience, not security) + let sandbox = Cu.Sandbox(window); + for (let key in data) + sandbox[key] = data[key]; + sandbox.formatTime = Utils.formatTime; + + // Clone template but remove id/hidden attributes from it + let result = template.cloneNode(true); + result.removeAttribute("id"); + result.removeAttribute("hidden"); + result._data = data; + + // Resolve any attributes of the for attr="{obj.foo}" + let conditionals = []; + let nodeIterator = document.createNodeIterator(result, NodeFilter.SHOW_ELEMENT, null, false); + for (let node = nodeIterator.nextNode(); node; node = nodeIterator.nextNode()) + { + if (node.localName == "if") + conditionals.push(node); + for (let i = 0; i < node.attributes.length; i++) + { + let attribute = node.attributes[i]; + let len = attribute.value.length; + if (len >= 2 && attribute.value[0] == "{" && attribute.value[len - 1] == "}") + attribute.value = Cu.evalInSandbox(attribute.value.substr(1, len - 2), sandbox); + } + } + + // Process tags - remove if condition is false, replace by their children + // if it is true + for each (let node in conditionals) + { + let fragment = document.createDocumentFragment(); + let condition = node.getAttribute("condition"); + if (condition == "false") + condition = false; + for (let i = 0; i < node.childNodes.length; i++) + { + let child = node.childNodes[i]; + if (child.localName == "elif" || child.localName == "else") + { + if (condition) + break; + condition = (child.localName == "elif" ? child.getAttribute("condition") : true); + if (condition == "false") + condition = false; + } + else if (condition) + fragment.appendChild(node.childNodes[i--]); + } + node.parentNode.replaceChild(fragment, node); + } + + return result; + }, + + /** + * Updates first child of a processed template if the underlying data changed. + */ + update: function(/**Node*/ template, /**Node*/ node) + { + if (!("_data" in node)) + return; + let newChild = Templater.process(template.firstChild, node._data); + delete newChild._data; + node.replaceChild(newChild, node.firstChild); + }, + + /** + * Walks up the parent chain for a node until the node corresponding with a + * template is found. + */ + getDataNode: function(/**Node*/ node) /**Node*/ + { + while (node) + { + if ("_data" in node) + return node; + node = node.parentNode; + } + return null; + }, + + /** + * Returns the data used to generate the node from a template. + */ + getDataForNode: function(/**Node*/ node) /**Object*/ + { + node = Templater.getDataNode(node); + if (node) + return node._data; + else + return null; + }, + + /** + * Returns a node that has been generated from a template using a particular + * data object. + */ + getNodeForData: function(/**Node*/ parent, /**String*/ property, /**Object*/ data) /**Node*/ + { + for (let child = parent.firstChild; child; child = child.nextSibling) + if ("_data" in child && property in child._data && child._data[property] == data) + return child; + return null; + } +}; diff --git a/abprime/content/filters.xul b/abprime/content/filters.xul new file mode 100644 index 00000000..8600284f --- /dev/null +++ b/abprime/content/filters.xul @@ -0,0 +1,395 @@ + + + + +#filter substitution + + + + + + + + + + + + + + + + + + + +