feeds.js (10639B)
1 /* 2 ***** BEGIN LICENSE BLOCK ***** 3 4 Copyright © 2015 Center for History and New Media 5 George Mason University, Fairfax, Virginia, USA 6 http://zotero.org 7 8 This file is part of Zotero. 9 10 Zotero is free software: you can redistribute it and/or modify 11 it under the terms of the GNU Affero General Public License as published by 12 the Free Software Foundation, either version 3 of the License, or 13 (at your option) any later version. 14 15 Zotero is distributed in the hope that it will be useful, 16 but WITHOUT ANY WARRANTY; without even the implied warranty of 17 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 GNU Affero General Public License for more details. 19 20 You should have received a copy of the GNU Affero General Public License 21 along with Zotero. If not, see <http://www.gnu.org/licenses/>. 22 23 ***** END LICENSE BLOCK ***** 24 */ 25 26 "use strict"; 27 28 // Mimics Zotero.Libraries 29 Zotero.Feeds = new function() { 30 var _initPromise; 31 var _updating; 32 33 this.init = function () { 34 // Delay initialization for tests 35 _initPromise = Zotero.Schema.schemaUpdatePromise.delay(5000) 36 .then(() => { 37 // Don't run feed checks randomly during tests 38 if (Zotero.test) return; 39 40 return this.scheduleNextFeedCheck(); 41 }) 42 .then(() => _initPromise = null); 43 44 Zotero.SyncedSettings.onSyncDownload.addListener(Zotero.Libraries.userLibraryID, 'feeds', 45 (oldValue, newValue, conflict) => { 46 Zotero.Feeds.restoreFromJSON(newValue, conflict); 47 } 48 ); 49 50 Zotero.Notifier.registerObserver( 51 { 52 notify: async function (event) { 53 if (event == 'finish') { 54 // Don't update during tests, since the database will have been closed 55 if (Zotero.test) return; 56 57 if (_initPromise) { 58 await _initPromise; 59 } 60 await Zotero.Feeds.updateFeeds(); 61 } 62 }, 63 }, 64 ['sync'], 65 'feedsUpdate' 66 ); 67 }; 68 69 this.uninit = function () { 70 // Cancel initialization if in progress 71 if (_initPromise) { 72 _initPromise.cancel(); 73 } 74 }; 75 76 this._cache = null; 77 78 this._makeCache = function() { 79 return { 80 libraryIDByURL: {}, 81 urlByLibraryID: {} 82 }; 83 } 84 85 this.register = function (feed) { 86 if (!this._cache) throw new Error("Zotero.Feeds cache is not initialized"); 87 Zotero.debug("Zotero.Feeds: Registering feed " + feed.libraryID, 5); 88 this._addToCache(this._cache, feed); 89 } 90 91 this._addToCache = function (cache, feed) { 92 if (!feed.libraryID) throw new Error('Cannot register an unsaved feed'); 93 94 if (cache.libraryIDByURL[feed.url]) { 95 Zotero.debug('Feed with url ' + feed.url + ' is already registered', 2, true); 96 } 97 if (cache.urlByLibraryID[feed.libraryID]) { 98 Zotero.debug('Feed with libraryID ' + feed.libraryID + ' is already registered', 2, true); 99 } 100 101 cache.libraryIDByURL[feed.url] = feed.libraryID; 102 cache.urlByLibraryID[feed.libraryID] = feed.url; 103 } 104 105 this.unregister = function (libraryID) { 106 if (!this._cache) throw new Error("Zotero.Feeds cache is not initialized"); 107 108 Zotero.debug("Zotero.Feeds: Unregistering feed " + libraryID, 5); 109 110 let url = this._cache.urlByLibraryID[libraryID]; 111 if (url === undefined) { 112 Zotero.debug('Attempting to unregister a feed that is not registered (' + libraryID + ')', 2, true); 113 return; 114 } 115 116 delete this._cache.urlByLibraryID[libraryID]; 117 delete this._cache.libraryIDByURL[url]; 118 } 119 120 this.importFromOPML = Zotero.Promise.coroutine(function* (opmlString) { 121 var parser = Components.classes["@mozilla.org/xmlextras/domparser;1"] 122 .createInstance(Components.interfaces.nsIDOMParser); 123 var doc = parser.parseFromString(opmlString, "application/xml"); 124 // Per some random spec (https://developer.mozilla.org/en-US/docs/Web/API/DOMParser), 125 // DOMParser returns a special type of xml document on error, so we do some magic checking here. 126 if (doc.documentElement.tagName == 'parseerror') { 127 return false; 128 } 129 var body = doc.getElementsByTagName('body')[0]; 130 var feedElems = doc.querySelectorAll('[type=rss][url], [xmlUrl]'); 131 var newFeeds = []; 132 var registeredUrls = new Set(); 133 for (let feedElem of feedElems) { 134 let url = feedElem.getAttribute('xmlUrl'); 135 if (!url) url = feedElem.getAttribute('url'); 136 let name = feedElem.getAttribute('title'); 137 if (!name) name = feedElem.getAttribute('text'); 138 if (Zotero.Feeds.existsByURL(url) || registeredUrls.has(url)) { 139 Zotero.debug("Feed Import from OPML: Feed " + name + " : " + url + " already exists. Skipping"); 140 continue; 141 } 142 // Prevent duplicates from the same OPML file 143 registeredUrls.add(url); 144 let feed = new Zotero.Feed({url, name}); 145 newFeeds.push(feed); 146 } 147 // This could potentially be a massive list, so we save in a transaction. 148 yield Zotero.DB.executeTransaction(function* () { 149 for (let feed of newFeeds) { 150 yield feed.save({ 151 skipSelect: true 152 }); 153 } 154 }); 155 // Finally, update 156 yield Zotero.Feeds.updateFeeds(); 157 return true; 158 }); 159 160 this.restoreFromJSON = Zotero.Promise.coroutine(function* (json, merge=false) { 161 Zotero.debug("Restoring feeds from remote JSON"); 162 Zotero.debug(json); 163 if (merge) { 164 let syncedFeeds = Zotero.SyncedSettings.get(Zotero.Libraries.userLibraryID, 'feeds'); 165 // Overwrite with remote values for names, etc. 166 for (let url in json) { 167 syncedFeeds[url] = json[url]; 168 } 169 // But keep all local feeds 170 json = syncedFeeds; 171 } 172 json = this._compactifyFeedJSON(json); 173 yield Zotero.SyncedSettings.set(Zotero.Libraries.userLibraryID, 'feeds', json); 174 let feeds = Zotero.Feeds.getAll(); 175 for (let feed of feeds) { 176 if (json[feed.url]) { 177 Zotero.debug("Feed " + feed.url + " exists remotely and locally"); 178 feed.name = json[feed.url][0]; 179 feed.cleanupReadAfter = json[feed.url][1]; 180 // TEMP after adding cleanupUnreadAfter for unread items 181 if (json[feed.url].length == 4) { 182 feed.cleanupUnreadAfter = json[feed.url][2]; 183 } 184 feed.refreshInterval = json[feed.url][json[feed.url].length-1]; 185 delete json[feed.url]; 186 } else { 187 Zotero.debug("Feed " + feed.url + " does not exist in remote JSON. Deleting"); 188 yield feed.erase(); 189 } 190 } 191 // Because existing json[feed.url] got deleted, `json` now only contains new feeds 192 for (let url in json) { 193 Zotero.debug("Feed " + url + " exists remotely but not locally. Creating"); 194 let obj = { 195 url, 196 name: json[url][0], 197 cleanupReadAfter: json[url][1], 198 refreshInterval: json[url][json[url].length-1] 199 }; 200 // TEMP after adding cleanupUnreadAfter for unread items 201 if (json[url].length == 4) { 202 obj.cleanupUnreadAfter = json[url][2]; 203 } 204 let feed = new Zotero.Feed(obj); 205 yield feed.saveTx({ 206 skipSelect: true 207 }); 208 } 209 }); 210 211 this.getByURL = function(urls) { 212 if (!this._cache) throw new Error("Zotero.Feeds cache is not initialized"); 213 214 let asArray = true; 215 if (!Array.isArray(urls)) { 216 urls = [urls]; 217 asArray = false; 218 } 219 220 let feeds = new Array(urls.length); 221 for (let i=0; i<urls.length; i++) { 222 let libraryID = this._cache.libraryIDByURL[urls[i]]; 223 if (!libraryID) { 224 return 225 } 226 227 feeds[i] = Zotero.Libraries.get(libraryID); 228 } 229 230 return asArray ? feeds : feeds[0]; 231 } 232 233 this.existsByURL = function(url) { 234 if (!this._cache) throw new Error("Zotero.Feeds cache is not initialized"); 235 236 return this._cache.libraryIDByURL[url] !== undefined; 237 } 238 239 this.getAll = function() { 240 if (!this._cache) throw new Error("Zotero.Feeds cache is not initialized"); 241 242 return Object.keys(this._cache.urlByLibraryID) 243 .map(id => Zotero.Libraries.get(id)); 244 } 245 246 this.get = function(libraryID) { 247 let library = Zotero.Libraries.get(libraryID); 248 return library.isFeed ? library : undefined; 249 } 250 251 this.haveFeeds = function() { 252 if (!this._cache) throw new Error("Zotero.Feeds cache is not initialized"); 253 254 return !!Object.keys(this._cache.urlByLibraryID).length 255 } 256 257 let globalFeedCheckDelay = Zotero.Promise.resolve(); 258 this.scheduleNextFeedCheck = Zotero.Promise.coroutine(function* () { 259 // Don't schedule if already updating, since another check is scheduled at the end 260 if (_updating) { 261 return; 262 } 263 264 Zotero.debug("Scheduling next feed update"); 265 let sql = "SELECT ( CASE " 266 + "WHEN lastCheck IS NULL THEN 0 " 267 + "ELSE strftime('%s', lastCheck) + refreshInterval * 60 - strftime('%s', 'now') " 268 + "END ) AS nextCheck " 269 + "FROM feeds WHERE refreshInterval IS NOT NULL " 270 + "ORDER BY nextCheck ASC LIMIT 1"; 271 var nextCheck = yield Zotero.DB.valueQueryAsync(sql); 272 273 if (this._nextFeedCheck) { 274 this._nextFeedCheck.cancel(); 275 this._nextFeedCheck = null; 276 } 277 278 if (nextCheck !== false) { 279 nextCheck = nextCheck > 0 ? nextCheck * 1000 : 0; 280 Zotero.debug("Next feed check in " + (nextCheck / 1000) + " seconds"); 281 this._nextFeedCheck = Zotero.Promise.delay(nextCheck); 282 Zotero.Promise.all([this._nextFeedCheck, globalFeedCheckDelay]) 283 .then(() => { 284 this._nextFeedCheck = null; 285 globalFeedCheckDelay = Zotero.Promise.delay(60000); // Don't perform auto-updates more than once per minute 286 return this.updateFeeds() 287 }) 288 .catch(e => { 289 if (e instanceof Zotero.Promise.CancellationError) { 290 Zotero.debug('Next update check cancelled'); 291 return; 292 } 293 throw e; 294 }); 295 } else { 296 Zotero.debug("No feeds with auto-update"); 297 } 298 }); 299 300 this.updateFeeds = Zotero.Promise.coroutine(function* () { 301 if (_updating) { 302 Zotero.debug("Feed update already in progress"); 303 return; 304 } 305 if (this._nextFeedCheck) { 306 this._nextFeedCheck.cancel(); 307 this._nextFeedCheck = null; 308 } 309 _updating = true; 310 try { 311 let sql = "SELECT libraryID AS id FROM feeds " 312 + "WHERE refreshInterval IS NOT NULL " 313 + "AND ( lastCheck IS NULL " 314 + "OR (julianday(lastCheck, 'utc') + (refreshInterval/1440.0) - julianday('now', 'utc')) <= 0 )"; 315 let needUpdate = (yield Zotero.DB.queryAsync(sql)).map(row => row.id); 316 Zotero.debug("Running update for feeds: " + needUpdate.join(', ')); 317 for (let i=0; i<needUpdate.length; i++) { 318 let feed = Zotero.Feeds.get(needUpdate[i]); 319 yield feed.waitForDataLoad('item'); 320 yield feed._updateFeed(); 321 } 322 } 323 finally { 324 _updating = false; 325 } 326 Zotero.debug("All feed updates done"); 327 this.scheduleNextFeedCheck(); 328 }); 329 330 // Conversion from expansive to compact format sync json 331 // TODO: Remove after beta 332 this._compactifyFeedJSON = function(json) { 333 for (let url in json) { 334 if(Array.isArray(json[url])) { 335 continue; 336 } 337 json[url] = [json[url].name, json[url].cleanupReadAfter, json[url].cleanupUnreadAfter, json[url].refreshInterval]; 338 } 339 return json; 340 }; 341 }