| LEFT | RIGHT | 
|---|
| 1 /* | 1 /* | 
| 2  * This file is part of Adblock Plus <https://adblockplus.org/>, | 2  * This file is part of Adblock Plus <https://adblockplus.org/>, | 
| 3  * Copyright (C) 2006-2015 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); | 18 let {Services} = Cu.import("resource://gre/modules/Services.jsm", null); | 
| 19 let {FileUtils} = Cu.import("resource://gre/modules/FileUtils.jsm", null); | 19 let {FileUtils} = Cu.import("resource://gre/modules/FileUtils.jsm", null); | 
| 20 | 20 let {setTimeout, clearTimeout} = Cu.import("resource://gre/modules/Timer.jsm", n
     ull); | 
|  | 21 | 
|  | 22 let {IO} = require("io"); | 
| 21 let {Prefs} = require("prefs"); | 23 let {Prefs} = require("prefs"); | 
| 22 let {Utils} = require("utils"); | 24 let {Downloader, Downloadable, MILLIS_IN_SECOND, MILLIS_IN_MINUTE, | 
| 23 let {MILLIS_IN_DAY} = require("downloader"); | 25   MILLIS_IN_HOUR, MILLIS_IN_DAY} = require("downloader"); | 
| 24 let {FilterNotifier} = require("filterNotifier"); | 26 let {FilterNotifier} = require("filterNotifier"); | 
| 25 let {DownloadableSubscription} = require("subscriptionClasses"); | 27 let {DownloadableSubscription} = require("subscriptionClasses"); | 
| 26 | 28 | 
|  | 29 const CHECK_INTERVAL = MILLIS_IN_HOUR; | 
|  | 30 const INITIAL_DELAY = MILLIS_IN_MINUTE * 2; | 
|  | 31 const PUSH_INTERVAL = MILLIS_IN_DAY * 7; | 
|  | 32 | 
| 27 /** | 33 /** | 
| 28  * This class reads filter hits statistics from SQLite database, | 34  * The value of storage statement normal execution constant | 
| 29  * manages them in memory and writes them back. | 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 | 
| 30  * @class | 42  * @class | 
| 31  */ | 43  */ | 
| 32 let FilterHits = exports.FilterHits = | 44 let FilterHits = exports.FilterHits = | 
| 33 { | 45 { | 
| 34   /** | 46   /** | 
| 35    * Data that shoud be sent to the server | 47    * Used to prevent timers running in parallel | 
| 36    * @type Object |  | 
| 37    */ |  | 
| 38   filters: Object.create(null), |  | 
| 39 |  | 
| 40   /** |  | 
| 41    * Time since last push |  | 
| 42    * @type Number | 48    * @type Number | 
| 43    */ | 49    */ | 
| 44   _lastPush: 0, | 50   _prefToggleTimeout: 0, | 
| 45 | 51 | 
| 46   /** | 52   get connection() | 
| 47    * Indicates the timeframe between pushes | 53   { | 
| 48    * @type Number | 54     if (!this.storageFile) | 
| 49    */ | 55       return null; | 
| 50   _pushInterval: MILLIS_IN_DAY * 7, | 56 | 
| 51 | 57     let connection = Services.storage.openDatabase(this.storageFile); | 
| 52   /** | 58     if (!connection.tableExists("filtersubscriptions")) | 
| 53    * Indicates whether the data is being loaded from storage | 59     { | 
| 54    * @type Boolean | 60       connection.executeSimpleSQL("CREATE TABLE filtersubscriptions " + | 
| 55    */ | 61         "(id INTEGER, subscriptions TEXT, PRIMARY KEY" + | 
| 56   _loading: false, | 62         "(id), UNIQUE(subscriptions))"); | 
| 57 | 63     } | 
| 58   /** | 64     if (!connection.tableExists("filterhits")) | 
| 59    * Indicates whether the data is being saved to storage | 65     { | 
| 60    * @type Boolean | 66       connection.executeSimpleSQL("CREATE TABLE filterhits (filter TEXT, " + | 
| 61    */ | 67         "host TEXT, thirdParty INTEGER, hitCount INTEGER, lastHit INTEGER, " + | 
| 62   _saving: false, | 68         "subscriptions INTEGER NOT NULL, PRIMARY KEY(filter, host, " + | 
| 63 | 69         "thirdParty), FOREIGN KEY(subscriptions) REFERENCES "+ | 
| 64   /** | 70         "filtersubscriptions(id))"); | 
| 65    * Indicates whether the data is being sent to the server | 71     } | 
| 66    * @type Boolean | 72     Object.defineProperty(this, "connection", | 
| 67    */ | 73     { | 
| 68   _sending: false, | 74       value: connection | 
|  | 75     }); | 
|  | 76     return connection; | 
|  | 77   }, | 
|  | 78 | 
|  | 79   /** | 
|  | 80    * @return nsIFile SQLite database file with filter hits data | 
|  | 81    */ | 
|  | 82   get storageFile() | 
|  | 83   { | 
|  | 84     let file = IO.resolveFilePath(Prefs.data_directory); | 
|  | 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   }, | 
| 69 | 163 | 
| 70   /** | 164   /** | 
| 71    * Increases the filter hit count | 165    * Increases the filter hit count | 
| 72    * @param {Filter} filter | 166    * @param {Filter} filter | 
| 73    * @param {String} host | 167    * @param {String} host | 
| 74    */ | 168    */ | 
| 75   increaseFilterHits: function(filter, host) | 169   increaseFilterHits: function(filter, host, thirdParty) | 
| 76   { | 170   { | 
| 77     let subscriptions = filter.subscriptions; | 171     let subscriptions = filter.subscriptions; | 
| 78     let inDownloadableSubscription = false; | 172     let downloadableSubscriptions = []; | 
| 79     for (let i = 0; i < subscriptions.length; i++) | 173     for (let i = 0; i < subscriptions.length; i++) | 
| 80     { | 174     { | 
| 81       if (subscriptions[i] instanceof DownloadableSubscription) | 175       if (subscriptions[i] instanceof DownloadableSubscription) | 
| 82       { | 176         downloadableSubscriptions.push(subscriptions[i].url); | 
| 83         inDownloadableSubscription = true; | 177     } | 
| 84         break; | 178 | 
|  | 179     if (downloadableSubscriptions.length == 0) | 
|  | 180       return; | 
|  | 181 | 
|  | 182     if (!this.connection) | 
|  | 183       return; | 
|  | 184 | 
|  | 185     let statements = []; | 
|  | 186     let filterSubscriptions = JSON.stringify(downloadableSubscriptions); | 
|  | 187     let subscriptionStatement = this.connection.createStatement("INSERT OR " + | 
|  | 188       "IGNORE INTO filtersubscriptions (subscriptions) VALUES " + | 
|  | 189       "(:subscriptions)"); | 
|  | 190     subscriptionStatement.params.subscriptions = filterSubscriptions; | 
|  | 191     statements.push(subscriptionStatement); | 
|  | 192     let filterHitStatement = this.connection.createStatement("INSERT OR " + | 
|  | 193       "REPLACE INTO filterhits (filter, host, thirdParty, hitCount, lastHit, " + | 
|  | 194       "subscriptions) VALUES (:filter, :host, :thirdParty, COALESCE ((SELECT " + | 
|  | 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); | 
| 85       } | 215       } | 
| 86     } | 216     }); | 
| 87 | 217   }, | 
| 88     if (!inDownloadableSubscription) | 218 | 
|  | 219   /** | 
|  | 220    * Remove all local collected filter hits data | 
|  | 221    */ | 
|  | 222   resetFilterHits: function() | 
|  | 223   { | 
|  | 224     if (!this.connection) | 
| 89       return; | 225       return; | 
| 90 | 226 | 
| 91     if (!(filter.text in this.filters)) | 227     this.connection.executeSimpleSQL("DELETE FROM filterhits"); | 
| 92       this.filters[filter.text] = Object.create(null); | 228     this.connection.executeSimpleSQL("DELETE FROM filtersubscriptions"); | 
| 93 | 229   }, | 
| 94     let statFilter = this.filters[filter.text]; | 230 | 
| 95     let filterType = filter.thirdParty ? "thirdParty" : "firstParty"; | 231   _generateFilterHitsData: function(downloadable) | 
| 96 | 232   { | 
| 97     if (!(filterType in statFilter)) | 233     return { | 
| 98       statFilter[filterType] = Object.create(null); | 234       then: (resolve) => | 
| 99 | 235       { | 
| 100     if (!("subscriptions" in statFilter)) | 236         if (!this.connection) | 
| 101       statFilter.subscriptions = []; | 237           return; | 
| 102 | 238 | 
| 103     for (let i = 0; i < subscriptions.length; i++) | 239         let statement = this.connection.createStatement("SELECT filterhits.*, " 
     + | 
| 104     { | 240         "filtersubscriptions.subscriptions FROM filterhits LEFT JOIN " + | 
| 105       if (subscriptions[i] instanceof DownloadableSubscription | 241         "filtersubscriptions filtersubscriptions ON " + | 
| 106           && statFilter.subscriptions.indexOf(subscriptions[i].url) == -1) | 242         "filterhits.subscriptions=filtersubscriptions.id"); | 
| 107         statFilter.subscriptions.push(subscriptions[i].url); | 243         statement.executeAsync( | 
| 108     } | 244         { | 
| 109 | 245           handleResult: (result) => | 
| 110     if (!(host in statFilter[filterType])) | 246           { | 
| 111       statFilter[filterType][host] = {hits: 1, latest: filter.lastHit}; | 247             let filters = Object.create(null); | 
| 112     else | 248             for (let row = result.getNextRow(); row; | 
| 113     { | 249                  row = result.getNextRow()) | 
| 114       statFilter[filterType][host].hits++; | 250             { | 
| 115       statFilter[filterType][host].latest = filter.lastHit; | 251               let filterText = row.getResultByName("filter"); | 
| 116     } | 252               let host = row.getResultByName("host"); | 
| 117   }, | 253               let matchType = (row.getResultByName("thirdParty") ? | 
| 118 | 254                                 "thirdParty" : "firstParty"); | 
| 119   resetFilterHits: function() | 255               let hitCount = row.getResultByName("hitCount"); | 
| 120   { | 256               let lastHit = row.getResultByName("lastHit"); | 
| 121     this.filters = Object.create(null); | 257               let subscriptions = row.getResultByName("subscriptions"); | 
| 122     this.saveFilterHitsToDatabase(); | 258 | 
| 123   }, | 259               if (!(filterText in filters)) | 
| 124 | 260                filters[filterText] = Object.create(null); | 
| 125   sendFilterHitsToServer: function() | 261 | 
| 126   { | 262               let filter = filters[filterText]; | 
| 127     if (!Prefs.sendstats) | 263               filter.subscriptions = subscriptions; | 
| 128       return; | 264               filter[matchType] = Object.create(null); | 
| 129 | 265               filter[matchType][host] = {"hits": hitCount, latest: lastHit}; | 
| 130     let request = new XMLHttpRequest(); | 266             } | 
| 131     request.open("POST", Prefs.sendstats_url); | 267             resolve({timeSincePush: roundToHours(Date.now() - | 
| 132     request.setRequestHeader("Content-Type", "application/json"); | 268               Prefs.sendstats_status.lastPush), "filters": filters}); | 
| 133     request.addEventListener("load", function(event) | 269           }, | 
| 134     { | 270           handleError: (error) => | 
| 135       FilterHits._sending = false; | 271           { | 
| 136       if (request.status >= 200 && request.status < 300) | 272             Prefs.sendstats_status.lastError = Date.now(); | 
| 137       { | 273             this._saveFilterHitPrefs(); | 
| 138         FilterHits._lastPush = new Date().getTime(); | 274             Cu.reportError("Error loading filter hits:" + error.message); | 
| 139         FilterHits.resetFilterHits(); | 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         }); | 
| 140       } | 287       } | 
| 141       else | 288     } | 
| 142         Cu.reportError("Could not send filter hit statistics to Adblock Plus ser
     ver."); | 289   }, | 
| 143     }, false); |  | 
| 144 |  | 
| 145     let {addonName, addonVersion, application, |  | 
| 146         applicationVersion, platform, platformVersion} = require("info"); |  | 
| 147     let data = { |  | 
| 148       version: 1, |  | 
| 149       timeSincePush: this._lastPush, |  | 
| 150       addonName: addonName, |  | 
| 151       addonVersion: addonVersion, |  | 
| 152       application: application, |  | 
| 153       applicationVersion: applicationVersion, |  | 
| 154       platform: platform, |  | 
| 155       platformVersion: platformVersion, |  | 
| 156       filters: this.filters |  | 
| 157     }; |  | 
| 158 |  | 
| 159     this._sending = true; |  | 
| 160     request.send(JSON.stringify(data)); |  | 
| 161   }, |  | 
| 162 |  | 
| 163   getStorageFile: function() |  | 
| 164   { |  | 
| 165     return FileUtils.getFile("ProfD", ["adblockplus.sqlite"]); |  | 
| 166   }, |  | 
| 167 |  | 
| 168   checkCreateTable: function(connection) |  | 
| 169   { |  | 
| 170     if (!connection.tableExists("filterhits")) |  | 
| 171       connection.executeSimpleSQL("CREATE TABLE filterhits (id INTEGER PRIMARY K
     EY, filters TEXT, date INTEGER)"); |  | 
| 172   }, |  | 
| 173 |  | 
| 174   /** |  | 
| 175    * Load Filter hits from database |  | 
| 176    */ |  | 
| 177   loadFilterHitsFromDatabase: function() |  | 
| 178   { |  | 
| 179     let storageFile = this.getStorageFile(); |  | 
| 180     if (!storageFile) |  | 
| 181       return; |  | 
| 182 |  | 
| 183     let connection = Services.storage.openDatabase(storageFile); |  | 
| 184     this.checkCreateTable(connection); |  | 
| 185 |  | 
| 186     let statement = connection.createStatement("SELECT * FROM filterhits"); |  | 
| 187     if (!this._loading) |  | 
| 188     { |  | 
| 189       this._loading = true; |  | 
| 190       statement.executeAsync( |  | 
| 191       { |  | 
| 192         handleResult: function(results) |  | 
| 193         { |  | 
| 194           let row = results.getNextRow(); |  | 
| 195           if (row) |  | 
| 196           { |  | 
| 197             let filters = row.getResultByName("filters"); |  | 
| 198             let lastDate = row.getResultByName("date"); |  | 
| 199             FilterHits.filters = JSON.parse(filters); |  | 
| 200             FilterHits._lastPush = lastDate; |  | 
| 201           } |  | 
| 202         }, |  | 
| 203 |  | 
| 204         handleError: function(error) |  | 
| 205         { |  | 
| 206           Cu.reportError(error.message); |  | 
| 207         }, |  | 
| 208 |  | 
| 209         handleCompletion: function(reason) |  | 
| 210         { |  | 
| 211           if (reason != Ci.mozIStorageStatementCallback.REASON_FINISHED) |  | 
| 212             Cu.reportError("Loading of filter hits canceled or aborted."); |  | 
| 213           FilterHits._loading = false; |  | 
| 214         } |  | 
| 215       }); |  | 
| 216     } |  | 
| 217 |  | 
| 218     connection.asyncClose(); |  | 
| 219   }, |  | 
| 220 |  | 
| 221   /** |  | 
| 222    * Save Filter hits to database |  | 
| 223    */ |  | 
| 224   saveFilterHitsToDatabase: function() |  | 
| 225   { |  | 
| 226     let now = new Date().getTime(); |  | 
| 227     if (!this._lastPush) |  | 
| 228       this._lastPush = now; |  | 
| 229 |  | 
| 230     if (!this._sending && now - this._lastPush > this._pushInterval) |  | 
| 231     { |  | 
| 232       this.sendFilterHitsToServer(); |  | 
| 233       return; |  | 
| 234     } |  | 
| 235 |  | 
| 236     let storageFile = this.getStorageFile(); |  | 
| 237     if (!storageFile) |  | 
| 238       return; |  | 
| 239 |  | 
| 240     let connection = Services.storage.openDatabase(storageFile); |  | 
| 241     this.checkCreateTable(connection); |  | 
| 242 |  | 
| 243     let statement = connection.createStatement("INSERT OR REPLACE INTO filterhit
     s (id, filters, date) VALUES(0, :filters, :date)"); |  | 
| 244     statement.params.filters = JSON.stringify(this.filters); |  | 
| 245     statement.params.date = this._lastPush; |  | 
| 246     if (!this._saving) |  | 
| 247     { |  | 
| 248       this._saving = true; |  | 
| 249       statement.executeAsync( |  | 
| 250       { |  | 
| 251         handleError: function(aError) |  | 
| 252         { |  | 
| 253           Cu.reportError(aError.message); |  | 
| 254         }, |  | 
| 255 |  | 
| 256         handleCompletion: function(aReason) |  | 
| 257         { |  | 
| 258           if (aReason != Components.interfaces.mozIStorageStatementCallback.REAS
     ON_FINISHED) |  | 
| 259             Cu.reportError("Writing of filter hits canceled or aborted."); |  | 
| 260           FilterHits._saving = false; |  | 
| 261         } |  | 
| 262       }); |  | 
| 263     } |  | 
| 264 |  | 
| 265     connection.asyncClose(); |  | 
| 266   } |  | 
| 267 }; | 290 }; | 
| 268 | 291 | 
| 269 FilterNotifier.addListener(function(action) | 292 function roundToHours(timestamp) | 
| 270 { | 293 { | 
| 271   if (action == "load" && Prefs.sendstats) | 294   return Math.round(timestamp / MILLIS_IN_HOUR) * | 
| 272     FilterHits.loadFilterHitsFromDatabase(); | 295       (MILLIS_IN_HOUR / MILLIS_IN_SECOND); | 
| 273 }); | 296 } | 
|  | 297 | 
|  | 298 FilterHits.init(); | 
| LEFT | RIGHT | 
|---|