www

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | Submodules | README | LICENSE

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 }