/* ***** BEGIN LICENSE BLOCK ***** * Version: MPL 1.1/GPL 2.0/LGPL 2.1 * * The contents of this file are subject to the Mozilla Public License Version * 1.1 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * http://www.mozilla.org/MPL/ * * Software distributed under the License is distributed on an "AS IS" basis, * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License * for the specific language governing rights and limitations under the * License. * * The Original Code is Snowl. * * The Initial Developer of the Original Code is Mozilla. * Portions created by the Initial Developer are Copyright (C) 2008 * the Initial Developer. All Rights Reserved. * * Contributor(s): * Myk Melez * * Alternatively, the contents of this file may be used under the terms of * either the GNU General Public License Version 2 or later (the "GPL"), or * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), * in which case the provisions of the GPL or the LGPL are applicable instead * of those above. If you wish to allow use of your version of this file only * under the terms of either the GPL or the LGPL, and not to allow others to * use your version of this file under the terms of the MPL, indicate your * decision by deleting the provisions above and replace them with the notice * and other provisions required by the GPL or the LGPL. If you do not delete * the provisions above, a recipient may use your version of this file under * the terms of any one of the MPL, the GPL or the LGPL. * * ***** END LICENSE BLOCK ***** */ let EXPORTED_SYMBOLS = ["SnowlSource"]; const Cc = Components.classes; const Ci = Components.interfaces; const Cr = Components.results; const Cu = Components.utils; // modules that are generic Cu.import("resource://snowl/modules/log4moz.js"); Cu.import("resource://snowl/modules/Observers.js"); Cu.import("resource://snowl/modules/Sync.js"); Cu.import("resource://snowl/modules/URI.js"); // modules that are Snowl-specific Cu.import("resource://snowl/modules/constants.js"); Cu.import("resource://snowl/modules/datastore.js"); Cu.import("resource://snowl/modules/message.js"); Cu.import("resource://snowl/modules/service.js"); Cu.import("resource://snowl/modules/utils.js"); // FIXME: make strands.js into a module. let loader = Cc["@mozilla.org/moz/jssubscript-loader;1"].getService(Ci.mozIJSSubScriptLoader); loader.loadSubScript("chrome://snowl/content/strands.js"); /** * SnowlSource: a source of messages. * * FIXME: update this documentation now that we're using it via mixins * instead of inheritance. * * This is an abstract class that should not be instantiated. Rather, objects * should inherit it via one of two methods (depending on whether or not they * also inherit other functionality): * * Objects that only inherit SnowlSource may assign it to their prototype * (or to their prototype's prototype) and then declare overridden attributes * as appropriate, with the prototype chain automatically delegating other * attributes to SnowlSource: * * function MySource = { * SnowlSource.init.call(this, ...); * this.overriddenMethod: function(...) {...}, * this.overriddenProperty: "foo", * this.__defineGetter("overriddenGetter", function() {...}), * this.__defineSetter("overriddenSetter", function(newVal) {...}), * } * MySource.prototype = SnowlSource; * * -- or -- * * function MySource = { * SnowlSource.init.call(this, ...); * } * MySource.prototype = { * __proto__: SnowlSource, * overriddenMethod: function(...) {...}, * overriddenProperty: "foo", * get overriddenGetter() {...}, * set overriddenSetter(newVal) {...} * }; * * Objects that inherit other functionality should redeclare every attribute * in SnowlSource, manually delegating to SnowlSource as appropriate: * FIXME: make it possible to import attributes instead of redeclaring them. * * function MyThing = { * SnowlSource.init.call(this, ...); * } * * MyThing.prototype = { * overriddenMethod: function(...) {...}, * overriddenProperty: "foo", * get overriddenGetter() {...}, * set overriddenSetter(newVal) {...} * * delegatedMethod: function(...) { * SnowlSource.call(this, ...); * }, * * get delegatedProperty: function() { * return SnowlSource.delegatedProperty; * }, * * // It's dangerous to set the base class's properties; don't do this!!! * set delegatedProperty: function(newVal) { * SnowlSource.delegatedProperty = newVal; * }, * * get delegatedGetter: function() { * return SnowlSource.__lookupGetter__("delegatedGetter").call(this); * }, * * set delegatedSetter: function(newVal) { * SnowlSource.__lookupSetter__("delegatedSetter").call(this, newVal); * } * }; * * Memoizing unary getters in this object must memoize to another getter * so that subclasses can call the getters directly without causing trouble * for other subclasses that access them via __lookupGetter__. */ function SnowlSource() {} SnowlSource.retrieve = function(id) { let source = null; // FIXME: memoize this. let statement = SnowlDatastore.createStatement( "SELECT type, name, machineURI, humanURI, username, lastRefreshed, " + " importance, placeID, attributes " + "FROM sources " + "WHERE id = :id" ); try { statement.params.id = id; if (statement.step()) { let row = statement.row; let constructor; // Bleh, this function is called within the JS context for this module, // which means it doesn't know anything about other modules it doesn't // import (like SnowlFeed and SnowlTwitter). The current hack to deal // with this is to set the constructor to |this| hoping that |this| is // the right constructor (which it is as long as this function got mixed // into the right constructor), but this isn't going to work when we want // to use this to pull all accounts and make them available in the service, // so we'll have to figure out something better to do then. try { constructor = eval(row.type) } catch(ex) { constructor = this }; source = new constructor(id, row.name, URI.get(row.machineURI), URI.get(row.humanURI), row.username, row.lastRefreshed ? SnowlDateUtils.julianToJSDate(row.lastRefreshed) : null, row.importance, row.placeID, JSON.parse(row.attributes)); } } finally { statement.reset(); } return source; } SnowlSource.prototype = { init: function(aID, aName, aMachineURI, aHumanURI, aUsername, aLastRefreshed, aImportance, aPlaceID, aAttributes) { this.id = aID; this.name = aName; this.machineURI = aMachineURI; this.humanURI = aHumanURI; this.username = aUsername; this.lastRefreshed = aLastRefreshed; // FIXME: make it so I don't have to set importance to null if it isn't // specified in order for its non-set value to remain null. this.importance = aImportance || null; this.placeID = aPlaceID; this.attributes = aAttributes || this.Attributes; }, get _log() { let logger = Log4Moz.repository.getLogger(this._logName); this.__defineGetter__("_log", function() logger); return this._log; }, // For adding isBusy property to collections tree. busy: false, // For adding hasError property to collections tree. error: false, id: null, name: null, /** * The URL at which to find a machine-processable representation of the data * provided by the source. For a feed source, this is the URL of its RSS/Atom * document; for an email source, it's the URL of its POP/IMAP server. */ machineURI: null, /** * The codebase principal for the machine URI. We use this to determine * whether or not the source can link to the links it provides, so we can * prevent sources from linking to javascript: and data: links that would * run with chrome privileges if inserted into our views. */ get principal() { let securityManager = Cc["@mozilla.org/scriptsecuritymanager;1"]. getService(Ci.nsIScriptSecurityManager); let principal = securityManager.getCodebasePrincipal(this.machineURI); this.__defineGetter__("principal", function() principal); return this.principal; }, // The URL at which to find a human-readable representation of the data // provided by the source. For a feed source, this is the website that // publishes the feed; for an email source, it might be the webmail interface. humanURI: null, // The username with which the user gets authorized to access the account. username: null, // A JavaScript Date object representing the last time this source // was checked for updates to its set of messages. If source attributes not // set to use default source type value *and* source type setting does not // override customizations on individual sources, then use the custom value. lastRefreshed: null, get refreshInterval() { return this.attributes.refresh["useDefault"] || SnowlService._accountTypesByType[this.constructor.name]. attributes.refresh["useDefault"] ? SnowlService._accountTypesByType[this.constructor.name]. attributes.refresh["interval"] : this.attributes.refresh["interval"]; }, // An integer representing how important this source is to the user // relative to other sources to which the user is subscribed. importance: null, // The ID of the place representing this source in a list of collections. placeID: null, //**************************************************************************// // The default global attributes for a all sources. Source types may override // and add their own attributes (but need to consider such exceptions in // generic handling). The attributes objects are combined by Mixins.meld(). // If a global or source type attribute is changed, db maintenance must remove // that source type record (SnowlFeed, SnowlTwitter) so it may be rebuilt with // all new values (also will overwrite user changes, if any), or update only // those attributes changing. attributes: { refresh: { // If true for the default Type, overrides individual setting; if // true for individual source, overrides default if default is false. useDefault: true, // 30 minutes interval: 1000 * 60 * 30, // Status determines behavior of auto and user refreshes: // 'active' - auto refresh on interval, immediately on user action // 'paused' - no refresh on auto or user action, set by user // 'disabled' - refresh only on user action, auto set on permanent error status: "active", // Code, usually response code, or internal error code. code: "", // Descriptive error message. text: "" }, retention: { // If true for the default Type, overrides individual setting; if // true for individual source, overrides default if default is false. useDefault: true, // If 0, do not delete any messaged; if 1, delete by number of messages; // if 2, delete by days old. deleteBy: 0, // If radio checked, delete messages older than number (of days). deleteDays: 30, // If radio checked, delete messages greater than number (of messages). deleteNumber: 500, // If true, messages will never be auto deleted. keepFlagged: true } }, // Retrieve the melded global and source default attributes, and customizations, // for seeding newly subscribed source attributes. get Attributes() { delete this._Attributes; return this._Attributes = SnowlService._accountTypesByType[this.constructor.name].attributes; }, // The collection of messages from this source. messages: null, // Favicon Service get faviconSvc() { let faviconSvc = Cc["@mozilla.org/browser/favicon-service;1"]. getService(Ci.nsIFaviconService); this.__defineGetter__("faviconSvc", function() faviconSvc); return this.faviconSvc; }, // If a favicon is not in places, getFaviconForPage throws, but we do not want // to try getFaviconImageForPage as that returns a default moz image. Best // efforts and tricks to get a favicon. get faviconURI() { if (this.humanURI) { try { // Due to private browsing changes, the favicon will only be in places // if the page has been bookmarked. return this.faviconSvc.getFaviconForPage(this.humanURI); } catch(ex) { // Not bookmarked. if (this.constructor.name == "SnowlFeed") // Get the favicon - they're usually here for feeds.. If not, an // atom is set for defaultFeedIcon and css handles it. return URI.get('http://' + this.humanURI.host + '/favicon.ico'); if (this.constructor.name == "SnowlTwitter") // Get the favicon - Twitter. return URI.get("http://static.twitter.com/images/favicon.ico"); } } }, /** * Check for new messages and update the local store of messages to reflect * the latest updates available from the source. This method is a stub that * is expected to be overridden by subclass implementations. * * @param refreshTime {Date} * the time at which a refresh currently in progress began * Note: we use this as the received time when adding messages to * the datastore. We get it from the caller instead of generating it * ourselves to allow the caller to synchronize received times * across refreshes of multiple sources, which makes message views * sorted by received, then published look better for messages * received in the same refresh cycle. */ refresh: function(refreshTime) {}, onRefreshError: function() { this.error = true; if (this.attributes.refresh["code"] == 401 || this.attributes.refresh["code"] == 404) { this.attributes.refresh["status"] = "disabled"; SnowlService.sourcesByID[this.id].attributes.refresh["status"] = "disabled"; } this._log.error("Refresh error: " + this.attributes.refresh["text"]); }, onDbCompleted: function() { // Database source record updated, set notifications and states. if (this.id) { // Only for existing stored sources; notify refreshes collections tree state. SnowlService.sourcesByID[this.id].busy = false; SnowlService.sourcesByID[this.id].error = this.error; SnowlService.refreshingCount = --SnowlService.refreshingCount; } Observers.notify("snowl:messages:completed", this.id); }, onDbError: function() { this.error = true; if (this.id) { // Only for existing stored sources; notify refreshes collections tree state. SnowlService.sourcesByID[this.id].busy = false; SnowlService.sourcesByID[this.id].error = this.error; SnowlService.refreshingCount = --SnowlService.refreshingCount; Observers.notify("snowl:messages:completed", this.id); } this._log.error("Database error: " + this.attributes.refresh["text"]); }, retrieveMessages: function() { // FIXME: memoize this. let messagesStatement = SnowlDatastore.createStatement( "SELECT id FROM messages WHERE sourceID = :id" ); try { messagesStatement.params.id = id; this.messages = []; // FIXME: retrieve all messages at once instead of one at a time. while (messagesStatement.step()) this.messages.push(SnowlMessage.retrieve(messagesStatement.row.id)); } finally { messagesStatement.reset(); } }, /** * Insert a record for this source into the database, or update an existing * record; store placeID back into sources table. * * @param pauseBetweenMessages {Boolean} * whether or not to pause between each message we persist; useful for * mixing up messages received at the same time from different sources * when refreshing multiple sources at once * * FIXME: move this to a SnowlAccount interface. */ persist: function(pauseBetweenMessages) { let statement, placeID; if (this.id) { statement = SnowlDatastore.createStatement( "UPDATE sources " + "SET name = :name, " + " type = :type, " + " machineURI = :machineURI, " + " humanURI = :humanURI, " + " username = :username, " + "lastRefreshed = :lastRefreshed, " + " importance = :importance, " + " attributes = :attributes " + "WHERE id = :id" ); } else { statement = SnowlDatastore.createStatement( "INSERT INTO sources ( name, type, machineURI, humanURI, username, " + " lastRefreshed, importance, attributes) " + "VALUES ( :name, :type, :machineURI, :humanURI, :username, " + " :lastRefreshed, :importance, :attributes)" ); } // Need to get a transaction lock. if (SnowlDatastore.dbConnection.transactionInProgress) { this.attributes.refresh["code"] = "db:transactionInProgress"; this.attributes.refresh["text"] = "Database temporarily busy, could not get transaction lock"; if (this.id) { // Only for existing stored sources; notify refreshes collections tree state. SnowlService.sourcesByID[this.id].busy = false; SnowlService.sourcesByID[this.id].error = this.error; SnowlService.refreshingCount = --SnowlService.refreshingCount; // Observers.notify("snowl:messages:completed", this.id); } else { // New subscriptions need to return feedback. this.error = true; this._log.info("persist: " + this.attributes.refresh["text"]); } return; } SnowlDatastore.dbConnection.beginTransaction(); try { statement.params.name = this.name; statement.params.type = this.constructor.name; statement.params.machineURI = this.machineURI.spec; statement.params.humanURI = this.humanURI ? this.humanURI.spec : null; statement.params.username = this.username; statement.params.lastRefreshed = this.lastRefreshed ? SnowlDateUtils.jsToJulianDate(this.lastRefreshed) : null; statement.params.importance = this.importance; statement.params.attributes = JSON.stringify(this.attributes); if (this.id) statement.params.id = this.id; statement.step(); if (!this.id) { // Extract the ID of the source from the newly-created database record. this.id = SnowlDatastore.dbConnection.lastInsertRowID; // New source, bump refreshing count. SnowlService.refreshingCount = ++SnowlService.refreshingCount; // Update message authors to include the source ID. // FIXME: make SnowlIdentity records have a source property // referencing a source object rather than a sourceID property // referencing a source object's ID. if (this.messages) for each (let message in this.messages) if (message.author) message.author.sourceID = this.id; // Create places record this.placeID = SnowlPlaces.persistPlace("sources", this.id, this.name, this.machineURI, null, // this.username, this.faviconURI, this.id); // aSourceID // Store placeID back into messages for db integrity SnowlDatastore.dbConnection.executeSimpleSQL( "UPDATE sources " + "SET placeID = " + this.placeID + " WHERE id = " + this.id); this._log.debug("persist placeID:sources.id - " + this.placeID + " : " + this.id); // Use 'added' here for collections observer for more specificity Observers.notify("snowl:source:added", this.placeID); } if (this.messages) { // Sort the messages by date, so we insert them from oldest to newest, // which makes them show up in the correct order in views that expect // messages to be inserted in that order and sort messages by their IDs. this.messages.sort(function(a, b) a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : 0); let currentMessageIDs = []; let messagesChanged = false; for each (let message in this.messages) { let added = false; message.id = message._getInternalID(); if (!message.id) { // Persist only new messages, ie without an id. this._log.info("persisting new message " + message.externalID); try { added = message.persist(); } catch(ex) { this._log.error("couldn't persist " + message.externalID + ": " + ex); continue; } } if (messagesChanged == false && added) messagesChanged = true; currentMessageIDs.push(message.id); // Sleep for a bit to give other sources that are being refreshed // at the same time the opportunity to insert messages themselves, // so the messages appear mixed together in views that display them // by the order in which they are received, which is more pleasing // than if the messages were clumped together by source. // As a side effect, this might reduce horkage of the UI thread // during refreshes. if (pauseBetweenMessages) Sync.sleep(50); } // Update the current flag. this.updateCurrentMessages(currentMessageIDs); if (messagesChanged) // Invalidate stats cache on completion of refresh with new messages. SnowlService._collectionStatsByCollectionID = null; } SnowlDatastore.dbConnection.commitTransaction(); // Source successfully stored/updated. this.onDbCompleted(); } catch(ex) { SnowlDatastore.dbConnection.rollbackTransaction(); this.attributes.refresh["code"] = "db:error"; this.attributes.refresh["text"] = ex; this.onDbError(); } finally { statement.reset(); } return this.id; }, get _persistAttributesStmt() { let statement = SnowlDatastore.createStatement( "UPDATE sources SET attributes = :attributes WHERE id = :id"); this.__defineGetter__("_persistAttributesStmt", function() statement); return this._persistAttributesStmt; }, persistAttributes: function() { try { this._persistAttributesStmt.params.id = this.id; this._persistAttributesStmt.params.attributes = JSON.stringify(this.attributes); this._persistAttributesStmt.step() } finally { this._persistAttributesStmt.reset(); } }, unstore: function() { SnowlDatastore.dbConnection.beginTransaction(); try { // FIXME: delegate unstorage of messages and people to their respective // JavaScript representations. SnowlDatastore.dbConnection.executeSimpleSQL("DELETE FROM partsText " + "WHERE docid IN " + "(SELECT id FROM parts WHERE messageID IN " + "(SELECT id FROM messages WHERE sourceID = " + this.id + "))"); SnowlDatastore.dbConnection.executeSimpleSQL("DELETE FROM parts " + "WHERE messageID IN " + "(SELECT id FROM messages WHERE sourceID = " + this.id + ")"); SnowlDatastore.dbConnection.executeSimpleSQL("DELETE FROM messages " + "WHERE sourceID = " + this.id); // FIXME: don't delete people unless the only identities with which // they are associated are identities associated with this source. SnowlDatastore.dbConnection.executeSimpleSQL("DELETE FROM people " + "WHERE id IN " + "(SELECT personID FROM identities WHERE sourceID = " + this.id + ")"); SnowlDatastore.dbConnection.executeSimpleSQL("DELETE FROM identities " + "WHERE sourceID = " + this.id); SnowlDatastore.dbConnection.executeSimpleSQL("DELETE FROM sources " + "WHERE id = " + this.id); // Finally, clean up Places bookmarks with sourceID in its prefixed uri. SnowlPlaces.removePlacesItemsByURI("snowl:sId=" + this.id + "&", true); SnowlDatastore.dbConnection.commitTransaction(); } catch(ex) { SnowlDatastore.dbConnection.rollbackTransaction(); throw ex; } Observers.notify("snowl:source:unstored", this.id); this.id = null; }, /** * Update the current flag for messages in a source, after a refresh. * If message's current flag = 1 set to 0, then set current flag for messages * in the current refresh list to 1. Purge current and marked deleted * placeholder message records if no longer current. * * @param aCurrentMessageIDs {array} messages table ids of the current list */ updateCurrentMessages: function(aCurrentMessageIDs) { SnowlDatastore.dbConnection.executeSimpleSQL( "UPDATE messages SET current = " + MESSAGE_NON_CURRENT + " WHERE sourceID = " + this.id + " AND current = " + MESSAGE_CURRENT ); SnowlDatastore.dbConnection.executeSimpleSQL( "UPDATE messages SET current = " + MESSAGE_CURRENT + " WHERE sourceID = " + this.id + " AND id IN" + " (" + aCurrentMessageIDs.join(", ") + ") AND current = " + MESSAGE_NON_CURRENT ); SnowlDatastore.dbConnection.executeSimpleSQL( "DELETE FROM messages" + " WHERE sourceID = " + this.id + " AND" + " current = " + MESSAGE_CURRENT_PENDING_PURGE + " AND" + " id NOT IN (" + aCurrentMessageIDs.join(", ") + ")" ); } };