| LEFT | RIGHT | 
|---|
| 1 /* | 1 /* | 
| 2  * This file is part of Adblock Plus <http://adblockplus.org/>, | 2  * This file is part of Adblock Plus <https://adblockplus.org/>, | 
| 3  * Copyright (C) 2006-2014 Eyeo GmbH | 3  * Copyright (C) 2006-2016 Eyeo GmbH | 
| 4  * | 4  * | 
| 5  * Adblock Plus is free software: you can redistribute it and/or modify | 5  * Adblock Plus is free software: you can redistribute it and/or modify | 
| 6  * it under the terms of the GNU General Public License version 3 as | 6  * it under the terms of the GNU General Public License version 3 as | 
| 7  * published by the Free Software Foundation. | 7  * published by the Free Software Foundation. | 
| 8  * | 8  * | 
| 9  * Adblock Plus is distributed in the hope that it will be useful, | 9  * Adblock Plus is distributed in the hope that it will be useful, | 
| 10  * but WITHOUT ANY WARRANTY; without even the implied warranty of | 10  * but WITHOUT ANY WARRANTY; without even the implied warranty of | 
| 11  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | 11  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | 
| 12  * GNU General Public License for more details. | 12  * GNU General Public License for more details. | 
| 13  * | 13  * | 
| 14  * You should have received a copy of the GNU General Public License | 14  * You should have received a copy of the GNU General Public License | 
| 15  * along with Adblock Plus.  If not, see <http://www.gnu.org/licenses/>. | 15  * along with Adblock Plus.  If not, see <http://www.gnu.org/licenses/>. | 
| 16  */ | 16  */ | 
| 17 | 17 | 
|  | 18 let {Services} = Cu.import("resource://gre/modules/Services.jsm", null); | 
|  | 19 let {FileUtils} = Cu.import("resource://gre/modules/FileUtils.jsm", null); | 
|  | 20 let {setTimeout, clearTimeout} = Cu.import("resource://gre/modules/Timer.jsm", n
     ull); | 
|  | 21 | 
| 18 let {IO} = require("io"); | 22 let {IO} = require("io"); | 
| 19 let {Utils} = require("utils"); |  | 
| 20 let {Prefs} = require("prefs"); | 23 let {Prefs} = require("prefs"); | 
| 21 let {TimeLine} = require("timeline"); | 24 let {Downloader, Downloadable, MILLIS_IN_SECOND, MILLIS_IN_MINUTE, | 
| 22 let {Filter} = require("filterClasses"); | 25   MILLIS_IN_HOUR, MILLIS_IN_DAY} = require("downloader"); | 
|  | 26 let {FilterNotifier} = require("filterNotifier"); | 
|  | 27 let {DownloadableSubscription} = require("subscriptionClasses"); | 
|  | 28 | 
|  | 29 const CHECK_INTERVAL = MILLIS_IN_HOUR; | 
|  | 30 const INITIAL_DELAY = MILLIS_IN_MINUTE * 2; | 
|  | 31 const PUSH_INTERVAL = MILLIS_IN_DAY * 7; | 
| 23 | 32 | 
| 24 /** | 33 /** | 
| 25  * This class reads filter hits statistics from disk, manages them in memory and
      writes them back.W | 34  * The value of storage statement normal execution constant | 
|  | 35  * @type Number | 
|  | 36  */ | 
|  | 37 const REASON_FINISHED = Components.interfaces.mozIStorageStatementCallback.REASO
     N_FINISHED; | 
|  | 38 | 
|  | 39 /** | 
|  | 40  * This class collects filter hits statistics in a SQLite database | 
|  | 41  * and sends them to the server when user opt-in for that | 
| 26  * @class | 42  * @class | 
| 27  */ | 43  */ | 
| 28 let FilterHits = exports.FilterHits = | 44 let FilterHits = exports.FilterHits = | 
| 29 { | 45 { | 
| 30   statistics: {}, | 46   /** | 
| 31 | 47    * Used to prevent timers running in parallel | 
| 32   _loading: false, | 48    * @type Number | 
| 33   _saving: false, | 49    */ | 
| 34   _needsSave: false, | 50   _prefToggleTimeout: 0, | 
| 35 | 51 | 
| 36   /** | 52   get connection() | 
| 37    * File that the filter hits statistics has been loaded from and should be sav
     ed to | 53   { | 
| 38    * @type nsIFile | 54     if (!this.storageFile) | 
| 39    */ | 55       return null; | 
| 40   get filterHitsFile() | 56 | 
| 41   { | 57     let connection = Services.storage.openDatabase(this.storageFile); | 
| 42     let file = null; | 58     if (!connection.tableExists("filtersubscriptions")) | 
| 43     if (!file) | 59     { | 
| 44     { | 60       connection.executeSimpleSQL("CREATE TABLE filtersubscriptions " + | 
| 45       // Place the file in the data dir | 61         "(id INTEGER, subscriptions TEXT, PRIMARY KEY" + | 
| 46       file = IO.resolveFilePath(Prefs.data_directory); | 62         "(id), UNIQUE(subscriptions))"); | 
| 47       if (file) | 63     } | 
| 48         file.append("filter-hits.ini"); | 64     if (!connection.tableExists("filterhits")) | 
| 49     } | 65     { | 
| 50     if (!file) | 66       connection.executeSimpleSQL("CREATE TABLE filterhits (filter TEXT, " + | 
| 51     { | 67         "host TEXT, thirdParty INTEGER, hitCount INTEGER, lastHit INTEGER, " + | 
| 52       // Data directory pref misconfigured? Try the default value | 68         "subscriptions INTEGER NOT NULL, PRIMARY KEY(filter, host, " + | 
| 53       try | 69         "thirdParty), FOREIGN KEY(subscriptions) REFERENCES "+ | 
| 54       { | 70         "filtersubscriptions(id))"); | 
| 55         file = IO.resolveFilePath(Services.prefs.getDefaultBranch("extensions.ad
     blockplus.").getCharPref("data_directory")); | 71     } | 
| 56         if (file) | 72     Object.defineProperty(this, "connection", | 
| 57           file.append("filter-hits.ini"); | 73     { | 
| 58       } catch(e) {} | 74       value: connection | 
| 59     } | 75     }); | 
| 60 | 76     return connection; | 
| 61     if (!file) | 77   }, | 
| 62       Cu.reportError("Adblock Plus: Failed to resolve filter-hits file"); | 78 | 
| 63 | 79   /** | 
| 64     this.__defineGetter__("filterHitsFile", function() file); | 80    * @return nsIFile SQLite database file with filter hits data | 
| 65     return this.filterHitsFile; | 81    */ | 
| 66   }, | 82   get storageFile() | 
| 67 | 83   { | 
| 68   /** | 84     let file = IO.resolveFilePath(Prefs.data_directory); | 
| 69    * Increases the filter hit count by one | 85     if (file) | 
|  | 86       file.append("adblockplus.sqlite"); | 
|  | 87 | 
|  | 88     Object.defineProperty(this, "storageFile", | 
|  | 89     { | 
|  | 90       value: file | 
|  | 91     }); | 
|  | 92     return file; | 
|  | 93   }, | 
|  | 94 | 
|  | 95   /** | 
|  | 96    * Called on module startup. | 
|  | 97    */ | 
|  | 98   init: function() | 
|  | 99   { | 
|  | 100     if (!Prefs.sendstats_status.lastPush) | 
|  | 101     { | 
|  | 102       Prefs.sendstats_status.lastPush = Date.now(); | 
|  | 103       this._saveFilterHitPrefs(); | 
|  | 104     } | 
|  | 105     Prefs.addListener(name => | 
|  | 106     { | 
|  | 107       if (name == "sendstats") | 
|  | 108       { | 
|  | 109         if (this._prefToggleTimeout) | 
|  | 110           clearTimeout(this._prefToggleTimeout); | 
|  | 111         this._prefToggleTimeout = setTimeout(() => | 
|  | 112         { | 
|  | 113           if (!Prefs.sendstats) | 
|  | 114             this.resetFilterHits(); | 
|  | 115         }, 1000); | 
|  | 116       } | 
|  | 117     }); | 
|  | 118 | 
|  | 119     let downloader = new Downloader(this._getDownloadables.bind(this), | 
|  | 120       INITIAL_DELAY, CHECK_INTERVAL); | 
|  | 121     downloader.onExpirationChange = this._onExpirationChange.bind(this); | 
|  | 122     downloader.generateRequestData = this._generateFilterHitsData.bind(this); | 
|  | 123     downloader.onDownloadSuccess = this._onDownloadSuccess.bind(this); | 
|  | 124     downloader.validResponses.push(204); | 
|  | 125     onShutdown.add(() => downloader.cancel()); | 
|  | 126   }, | 
|  | 127 | 
|  | 128   /** | 
|  | 129    * Yields a Downloadable instance for the filter hits push. | 
|  | 130    */ | 
|  | 131   _getDownloadables: function*() | 
|  | 132   { | 
|  | 133     let downloadable = new Downloadable(Prefs.sendstats_url); | 
|  | 134     if (Prefs.sendstats_status.lastError) | 
|  | 135       downloadable.lastError = Prefs.sendstats_status.lastError; | 
|  | 136     if (Prefs.sendstats_status.lastCheck) | 
|  | 137       downloadable.lastCheck = Prefs.sendstats_status.lastCheck; | 
|  | 138     if (Prefs.sendstats_status.lastPush) | 
|  | 139     { | 
|  | 140       downloadable.softExpiration = Prefs.sendstats_status.lastPush + PUSH_INTER
     VAL; | 
|  | 141       downloadable.hardExpiration = Prefs.sendstats_status.lastPush + PUSH_INTER
     VAL; | 
|  | 142     } | 
|  | 143     yield downloadable; | 
|  | 144   }, | 
|  | 145 | 
|  | 146   _onExpirationChange: function(downloadable) | 
|  | 147   { | 
|  | 148     Prefs.sendstats_status.lastCheck = downloadable.lastCheck; | 
|  | 149     this._saveFilterHitPrefs(); | 
|  | 150   }, | 
|  | 151 | 
|  | 152   _saveFilterHitPrefs: function() | 
|  | 153   { | 
|  | 154     Prefs.sendstats_status = JSON.parse(JSON.stringify(Prefs.sendstats_status)); | 
|  | 155   }, | 
|  | 156 | 
|  | 157   _onDownloadSuccess: function(downloadable, responseText, errorCallback, redire
     ctCallback) | 
|  | 158   { | 
|  | 159     Prefs.sendstats_status.lastError = 0; | 
|  | 160     Prefs.sendstats_status.lastPush = Date.now(); | 
|  | 161     this._saveFilterHitPrefs(); | 
|  | 162   }, | 
|  | 163 | 
|  | 164   /** | 
|  | 165    * Increases the filter hit count | 
| 70    * @param {Filter} filter | 166    * @param {Filter} filter | 
| 71    * @param {Window} window  Window that the match originated in (required to ge
     t host name) | 167    * @param {String} host | 
| 72    */ | 168    */ | 
| 73   increaseFilterHits: function(filter, wnd) | 169   increaseFilterHits: function(filter, host, thirdParty) | 
| 74   { | 170   { | 
| 75     if (!filter.text) | 171     let subscriptions = filter.subscriptions; | 
|  | 172     let downloadableSubscriptions = []; | 
|  | 173     for (let i = 0; i < subscriptions.length; i++) | 
|  | 174     { | 
|  | 175       if (subscriptions[i] instanceof DownloadableSubscription) | 
|  | 176         downloadableSubscriptions.push(subscriptions[i].url); | 
|  | 177     } | 
|  | 178 | 
|  | 179     if (downloadableSubscriptions.length == 0) | 
| 76       return; | 180       return; | 
| 77 | 181 | 
| 78     if (filter.text in this.statistics) | 182     if (!this.connection) | 
| 79       this.statistics[filter.text].statsHitCount++; | 183       return; | 
| 80     else | 184 | 
| 81       this.statistics[filter.text] = {statsHitCount:1, lastHit: 0, domainsCount:
     {}, | 185     let statements = []; | 
| 82                                       thirdParty: false, subscriptions: []}; | 186     let filterSubscriptions = JSON.stringify(downloadableSubscriptions); | 
| 83 | 187     let subscriptionStatement = this.connection.createStatement("INSERT OR " + | 
| 84     let increaseHits = function(filterStats) | 188       "IGNORE INTO filtersubscriptions (subscriptions) VALUES " + | 
| 85     { | 189       "(:subscriptions)"); | 
| 86       if (filter.lastHit) | 190     subscriptionStatement.params.subscriptions = filterSubscriptions; | 
| 87         filterStats.lastHit = filter.lastHit; | 191     statements.push(subscriptionStatement); | 
| 88 | 192     let filterHitStatement = this.connection.createStatement("INSERT OR " + | 
| 89       if (filter.subscriptions) | 193       "REPLACE INTO filterhits (filter, host, thirdParty, hitCount, lastHit, " + | 
| 90       { | 194       "subscriptions) VALUES (:filter, :host, :thirdParty, COALESCE ((SELECT " + | 
| 91         for (let i = 0; i < filter.subscriptions.length; i++) | 195       "hitCount  FROM filterhits WHERE filter=:filter AND host=:host AND " + | 
|  | 196       "thirdParty=:thirdParty), 0) + 1, :lastHit, (SELECT id " + | 
|  | 197       "FROM filtersubscriptions WHERE subscriptions=:subscriptions))"); | 
|  | 198     filterHitStatement.params.filter = filter.text; | 
|  | 199     filterHitStatement.params.host = host; | 
|  | 200     filterHitStatement.params.lastHit = roundToHours(filter.lastHit); | 
|  | 201     filterHitStatement.params.thirdParty = thirdParty; | 
|  | 202     filterHitStatement.params.subscriptions = filterSubscriptions; | 
|  | 203     statements.push(filterHitStatement); | 
|  | 204 | 
|  | 205     this.connection.executeAsync(statements, statements.length, | 
|  | 206     { | 
|  | 207       handleError: (error) => | 
|  | 208       { | 
|  | 209         Cu.reportError("Error updating filter hits: " + error.message); | 
|  | 210       }, | 
|  | 211       handleCompletion: (reason) => | 
|  | 212       { | 
|  | 213         if (reason != REASON_FINISHED) | 
|  | 214           Cu.reportError("Updating filter hits canceled or aborted: " + reason); | 
|  | 215       } | 
|  | 216     }); | 
|  | 217   }, | 
|  | 218 | 
|  | 219   /** | 
|  | 220    * Remove all local collected filter hits data | 
|  | 221    */ | 
|  | 222   resetFilterHits: function() | 
|  | 223   { | 
|  | 224     if (!this.connection) | 
|  | 225       return; | 
|  | 226 | 
|  | 227     this.connection.executeSimpleSQL("DELETE FROM filterhits"); | 
|  | 228     this.connection.executeSimpleSQL("DELETE FROM filtersubscriptions"); | 
|  | 229   }, | 
|  | 230 | 
|  | 231   _generateFilterHitsData: function(downloadable) | 
|  | 232   { | 
|  | 233     return { | 
|  | 234       then: (resolve) => | 
|  | 235       { | 
|  | 236         if (!this.connection) | 
|  | 237           return; | 
|  | 238 | 
|  | 239         let statement = this.connection.createStatement("SELECT filterhits.*, " 
     + | 
|  | 240         "filtersubscriptions.subscriptions FROM filterhits LEFT JOIN " + | 
|  | 241         "filtersubscriptions filtersubscriptions ON " + | 
|  | 242         "filterhits.subscriptions=filtersubscriptions.id"); | 
|  | 243         statement.executeAsync( | 
| 92         { | 244         { | 
| 93           if (filterStats.subscriptions.indexOf(filter.subscriptions[i]._title) 
     == -1) | 245           handleResult: (result) => | 
| 94             filterStats.subscriptions.push(filter.subscriptions[i]._title); | 246           { | 
| 95         } | 247             let filters = Object.create(null); | 
|  | 248             for (let row = result.getNextRow(); row; | 
|  | 249                  row = result.getNextRow()) | 
|  | 250             { | 
|  | 251               let filterText = row.getResultByName("filter"); | 
|  | 252               let host = row.getResultByName("host"); | 
|  | 253               let matchType = (row.getResultByName("thirdParty") ? | 
|  | 254                                 "thirdParty" : "firstParty"); | 
|  | 255               let hitCount = row.getResultByName("hitCount"); | 
|  | 256               let lastHit = row.getResultByName("lastHit"); | 
|  | 257               let subscriptions = row.getResultByName("subscriptions"); | 
|  | 258 | 
|  | 259               if (!(filterText in filters)) | 
|  | 260                filters[filterText] = Object.create(null); | 
|  | 261 | 
|  | 262               let filter = filters[filterText]; | 
|  | 263               filter.subscriptions = subscriptions; | 
|  | 264               filter[matchType] = Object.create(null); | 
|  | 265               filter[matchType][host] = {"hits": hitCount, latest: lastHit}; | 
|  | 266             } | 
|  | 267             resolve({timeSincePush: roundToHours(Date.now() - | 
|  | 268               Prefs.sendstats_status.lastPush), "filters": filters}); | 
|  | 269           }, | 
|  | 270           handleError: (error) => | 
|  | 271           { | 
|  | 272             Prefs.sendstats_status.lastError = Date.now(); | 
|  | 273             this._saveFilterHitPrefs(); | 
|  | 274             Cu.reportError("Error loading filter hits:" + error.message); | 
|  | 275           }, | 
|  | 276           handleCompletion: (reason) => | 
|  | 277           { | 
|  | 278             if (reason != REASON_FINISHED) | 
|  | 279             { | 
|  | 280               Prefs.sendstats_status.lastError = Date.now(); | 
|  | 281               this._saveFilterHitPrefs(); | 
|  | 282               Cu.reportError("Loading of filter hits canceled or aborted: " + | 
|  | 283                 reason); | 
|  | 284             } | 
|  | 285           } | 
|  | 286         }); | 
| 96       } | 287       } | 
| 97 | 288     } | 
| 98       if (filter.thirdParty) | 289   }, | 
| 99         filterStats.thirdParty = true; |  | 
| 100 |  | 
| 101       // Get hostname from window object |  | 
| 102       let wndLocation = Utils.getOriginWindow(wnd).location.href; |  | 
| 103       let host = Utils.unwrapURL(wndLocation).host; |  | 
| 104 |  | 
| 105       if (host in filterStats.domainsCount) |  | 
| 106         filterStats.domainsCount[host]++; |  | 
| 107       else |  | 
| 108         filterStats.domainsCount[host] = 1; |  | 
| 109     }; |  | 
| 110 |  | 
| 111     increaseHits(this.statistics[filter.text]); |  | 
| 112   }, |  | 
| 113 |  | 
| 114   resetFilterHits: function() |  | 
| 115   { |  | 
| 116     this.statistics = {}; |  | 
| 117   }, |  | 
| 118 |  | 
| 119   /** |  | 
| 120    * send filter hits statistics to ABP sever and reset filter hits count |  | 
| 121    */ |  | 
| 122   sendFilterHitsToServer: function() |  | 
| 123   { |  | 
| 124     //TODO implement ajax request to server sending this.statistics |  | 
| 125     //clear statics on 200 response |  | 
| 126     this.resetFilterHits(); |  | 
| 127     this.saveFilterHitsToDisk(); |  | 
| 128   }, |  | 
| 129 |  | 
| 130   /** |  | 
| 131    * Loads Filter hit statistics from the disk |  | 
| 132    */ |  | 
| 133   loadFilterHitsFromDisk: function() |  | 
| 134   { |  | 
| 135     if (this._loading) |  | 
| 136       return; |  | 
| 137 |  | 
| 138     let sourceFile = FilterHits.filterHitsFile; |  | 
| 139     if (!sourceFile) |  | 
| 140       return; |  | 
| 141 |  | 
| 142     TimeLine.enter("Entered FilterHits.loadFilterHitsFromDisk()"); |  | 
| 143     this._loading = true; |  | 
| 144 |  | 
| 145     let readFile = function() |  | 
| 146     { |  | 
| 147       TimeLine.enter("FilterHits.loadFilterHitsFromDisk() -> readFile()"); |  | 
| 148       let parser = new FilterHitsParser(); |  | 
| 149       IO.readFromFile(sourceFile, true, parser, function(e) |  | 
| 150       { |  | 
| 151         TimeLine.enter("FilterHits.loadFilterHitsFromDisk() read callback"); |  | 
| 152         if (e) |  | 
| 153           Cu.reportError(e); |  | 
| 154 |  | 
| 155         doneReading(parser); |  | 
| 156       }.bind(this), "FilterHitsRead"); |  | 
| 157 |  | 
| 158       TimeLine.leave("FilterHits.loadFilterHitsFromDisk() <- readFile()", "Filte
     rHitsRead"); |  | 
| 159     }.bind(this); |  | 
| 160 |  | 
| 161     var doneReading = function(parser) |  | 
| 162     { |  | 
| 163       FilterHits.statistics = parser.statistics; |  | 
| 164       this._loading = false; |  | 
| 165       TimeLine.leave("FilterHits.loadFilterHitsFromDisk() read callback done"); |  | 
| 166     }.bind(this); |  | 
| 167 |  | 
| 168     IO.statFile(sourceFile, function(e, statData) |  | 
| 169     { |  | 
| 170       if (!e && statData.exists) |  | 
| 171         readFile(sourceFile); |  | 
| 172     }); |  | 
| 173     TimeLine.leave("FilterHits.loadFilterHitsFromDisk() done"); |  | 
| 174   }, |  | 
| 175 |  | 
| 176   /** |  | 
| 177    * Stringify filter hits statistics object |  | 
| 178    */ |  | 
| 179   _generateFilterHitData: function() |  | 
| 180   { |  | 
| 181     yield JSON.stringify(FilterHits.statistics); |  | 
| 182   }, |  | 
| 183 |  | 
| 184   /** |  | 
| 185    * Save Filters hit statistics to the disk |  | 
| 186    */ |  | 
| 187   saveFilterHitsToDisk: function() |  | 
| 188   { |  | 
| 189     let targetFile = FilterHits.filterHitsFile; |  | 
| 190     if (!targetFile) |  | 
| 191       return; |  | 
| 192 |  | 
| 193     if (this._saving) |  | 
| 194     { |  | 
| 195       this._needsSave = true; |  | 
| 196       return; |  | 
| 197     } |  | 
| 198     TimeLine.enter("Entered FilterHits.saveFilterHitsToDisk()"); |  | 
| 199     // Make sure the file's parent directory exists |  | 
| 200     try { |  | 
| 201       targetFile.parent.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECT
     ORY); |  | 
| 202     } catch (e) {} |  | 
| 203 |  | 
| 204     let writeStats = function() |  | 
| 205     { |  | 
| 206       TimeLine.enter("FilterHits.saveFilterHitsToDisk() -> writeStats()"); |  | 
| 207       IO.writeToFile(targetFile, true, this._generateFilterHitData(), function(e
     ) |  | 
| 208       { |  | 
| 209         TimeLine.enter("FilterHits.saveFilterHitsToDisk() write callback"); |  | 
| 210         this._saving = false; |  | 
| 211 |  | 
| 212         if (e) |  | 
| 213           Cu.reportError(e); |  | 
| 214 |  | 
| 215         if (this._needsSave) |  | 
| 216         { |  | 
| 217           this._needsSave = false; |  | 
| 218           this.saveFilterHitsToDisk(); |  | 
| 219         } |  | 
| 220         TimeLine.leave("FilterHits.saveFilterHitsToDisk() write callback done"); |  | 
| 221       }.bind(this), "FilterHitsWriteStats"); |  | 
| 222       TimeLine.leave("FilterHits.saveFilterHitsToDisk() -> writeStats()", "Filte
     rStorageWriteStats"); |  | 
| 223     }.bind(this); |  | 
| 224 |  | 
| 225     this._saving = true; |  | 
| 226     writeStats(); |  | 
| 227     TimeLine.leave("FilterHits.saveFilterHitsToDisk() done"); |  | 
| 228   } |  | 
| 229 }; | 290 }; | 
| 230 | 291 | 
| 231 /** | 292 function roundToHours(timestamp) | 
| 232  * IO.readFromFile() listener to parse filter-hits data. |  | 
| 233  * @constructor |  | 
| 234  */ |  | 
| 235 function FilterHitsParser() |  | 
| 236 { | 293 { | 
| 237   this.statistics = {__proto__: null}; | 294   return Math.round(timestamp / MILLIS_IN_HOUR) * | 
|  | 295       (MILLIS_IN_HOUR / MILLIS_IN_SECOND); | 
| 238 } | 296 } | 
| 239 FilterHitsParser.prototype = | 297 | 
| 240 { | 298 FilterHits.init(); | 
| 241   process: function(val) |  | 
| 242   { |  | 
| 243     if (val === null) |  | 
| 244        return; |  | 
| 245     this.statistics = JSON.parse(val); |  | 
| 246   } |  | 
| 247 }; |  | 
| LEFT | RIGHT | 
|---|