Mercurial > snowl
changeset 190:8e61b480af10
make the subscribe dialog more generic, and add the beginnings of Twitter support (doesn't work yet)
author | Myk Melez <myk@mozilla.org> |
---|---|
date | Mon, 21 Jul 2008 19:13:02 -0700 |
parents | d295062f5655 |
children | 5f9e6509cd30 |
files | extension/content/icons/asterisk_orange.png extension/content/subscribe.css extension/content/subscribe.js extension/content/subscribe.xul extension/modules/twitter.js |
diffstat | 5 files changed, 795 insertions(+), 54 deletions(-) [+] |
line wrap: on
line diff
--- a/extension/content/subscribe.css Mon Jul 21 17:17:19 2008 -0700 +++ b/extension/content/subscribe.css Mon Jul 21 19:13:02 2008 -0700 @@ -22,23 +22,14 @@ -moz-margin-end: 0; } -.statusBox { - /* Give the status boxes the same margin as the Location label above them. */ - margin-left: 6px; -} - -.statusBox > .statusIcon { - list-style-image: url("chrome://snowl/content/icons/asterisk_orange.png"); -} - -.statusBox[status="active"] > .statusIcon { +#statusBox[status="active"] > #statusIcon { list-style-image: url("chrome://global/skin/icons/loading_16.png"); } -.statusBox[status="complete"] > .statusIcon { +#statusBox[status="complete"] > #statusIcon { list-style-image: url("chrome://snowl/content/icons/tick.png"); } -.statusBox[status="error"] > .statusIcon { +#statusBox[status="error"] > #statusIcon { list-style-image: url("chrome://global/skin/icons/error-16.png"); }
--- a/extension/content/subscribe.js Mon Jul 21 17:17:19 2008 -0700 +++ b/extension/content/subscribe.js Mon Jul 21 19:13:02 2008 -0700 @@ -15,38 +15,52 @@ Cu.import("resource://snowl/modules/service.js"); Cu.import("resource://snowl/modules/datastore.js"); Cu.import("resource://snowl/modules/feed.js"); +Cu.import("resource://snowl/modules/twitter.js"); window.addEventListener("load", function() { Subscriber.init() }, false); function SubscriptionListener(subject, topic, data) { - if (subject != Subscriber.feed) + let source = Subscriber.feed; + + // Don't track the status of subscriptions happening in other windows/tabs. + if (subject != source) return; + let statusBox = document.getElementById("statusBox"); + let statusText = document.getElementById("statusText"); + + let identity = source.name || + (source.humanURI ? source.humanURI.spec : null) || + (source.machineURI ? source.machineURI.spec : null) || + "unnamed source"; + switch(topic) { case "snowl:subscribe:connect:start": - document.getElementById("connectingBox").disabled = false; - document.getElementById("connectingBox").setAttribute("status", "active"); - document.getElementById("gettingMessagesBox").disabled = true; - document.getElementById("gettingMessagesBox").removeAttribute("status"); - document.getElementById("doneBox").disabled = true; - document.getElementById("doneBox").removeAttribute("status"); + statusBox.setAttribute("status", "active"); + statusText.value = "Connecting to " + identity; break; case "snowl:subscribe:connect:end": - if (data < 200 || data > 299) - document.getElementById("connectingBox").setAttribute("status", "error"); - else - document.getElementById("connectingBox").setAttribute("status", "complete"); + if (data < 200 || data > 299) { + statusBox.setAttribute("status", "error"); + statusBox.value = "Error connecting to " + identity; + } + else { + // XXX Should we bother setting this when we're going to change it + // to "getting messages" an instant later? + statusBox.setAttribute("status", "complete"); + statusBox.value = "Connected to " + identity; + } break; case "snowl:subscribe:get:start": - document.getElementById("gettingMessagesBox").disabled = false; - document.getElementById("gettingMessagesBox").setAttribute("status", "active"); + statusBox.setAttribute("status", "active"); + statusText.value = "Getting messages for " + identity; break; case "snowl:subscribe:get:progress": break; case "snowl:subscribe:get:end": - document.getElementById("gettingMessagesBox").setAttribute("status", "complete"); - document.getElementById("doneBox").disabled = false; - document.getElementById("doneBox").setAttribute("status", "complete"); + statusBox.setAttribute("status", "complete"); + //statusText.value = "Got messages for " + identity; + statusText.value = "You have subscribed to " + identity; break; } } @@ -130,6 +144,26 @@ window.close(); }, + showTwitterPassword: function() { + if (document.getElementById("showPassword").checked) + document.getElementById("twitterPassword").removeAttribute("type"); + else + document.getElementById("twitterPassword").setAttribute("type", "password"); + }, + + subscribeTwitter: function() { + let machineURI = URI.get("https://twitter.com"); + let humanURI = URI.get("http://twitter.com/home"); + let twitter = new SnowlTwitter(null, "Twitter", machineURI, humanURI); + + let username = document.getElementById("twitterUsername").value; + let password = document.getElementById("twitterPassword").value; + + twitter.verify(username, password, function(response) { alert("twitter verify callback: " + response) }); + + //{"authorized":true} + //Could not authenticate you. + }, //**************************************************************************// // OPML Import
--- a/extension/content/subscribe.xul Mon Jul 21 17:17:19 2008 -0700 +++ b/extension/content/subscribe.xul Mon Jul 21 19:13:02 2008 -0700 @@ -4,7 +4,9 @@ <!-- XXX Don't we need a reference to the browser skin here? --> <?xml-stylesheet href="chrome://snowl/content/subscribe.css" type"text/css"?> -<page id="subscribeToFeedPage" title="Subscribe to Feed" +<!DOCTYPE dialog SYSTEM "chrome://snowl/locale/login.dtd"> + +<page id="subscribeToFeedPage" title="Subscribe to Messages" xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" pack="center" align="center"> @@ -12,45 +14,98 @@ <script type="application/x-javascript" src="chrome://snowl/content/subscribe.js"/> <vbox id="content"> - <label flex="1" value="Subscribe to Feed" class="header"/> + <label flex="1" value="Subscribe to Messages" class="header"/> <separator class="groove-thin"/> - <separator class="thin" orient="horizontal"/> - <hbox align="center"> - <label control="snowlLocationTextbox" value="Location:"/> - <textbox id="snowlLocationTextbox" flex="1"/> - <button label="Subscribe" default="true" oncommand="Subscriber.doSubscribe(event)"/> - </hbox> + <tabbox selectedIndex="1"> + <tabs> + <tab label="Feed"/> + <tab label="Twitter"/> + </tabs> + <tabpanels> + <tabpanel orient="vertical"> + <grid> + <columns> + <column/> + <column flex="1"/> + </columns> + <rows> + <row align="center"> + <hbox flex="1" pack="end"> + <label control="snowlLocationTextbox" value="Location:"/> + </hbox> + <textbox id="snowlLocationTextbox"/> + </row> + <row> + <box/> + <hbox pack="start"> + <button label="Subscribe" default="true" oncommand="Subscriber.doSubscribe(event)"/> + <spacer flex="1"/> + <button id="importOPMLButton" label="Import OPML..." oncommand="Subscriber.doImportOPML(event)"/> + </hbox> + </row> + </rows> + </grid> + </tabpanel> - <separator class="thin" orient="horizontal"/> + <tabpanel orient="vertical"> + <grid> + <columns> + <column/> + <column flex="1"/> + </columns> + <rows> + <row align="center"> + <hbox flex="1" pack="end"> + <label control="twitterUsername" value="Username:"/> + </hbox> + <textbox id="twitterUsername"/> + </row> + <row align="center"> + <hbox flex="1" pack="end"> + <label value="Password:"/> + </hbox> + <textbox type="password" id="twitterPassword"/> + </row> + <row align="center"> + <box/> + <checkbox id="showPassword" label="&showPassword.label;" + oncommand="Subscriber.showTwitterPassword()"/> + </row> + <row align="center"> + <box/> + <checkbox id="rememberPassword" label="&rememberPassword.label;"/> + </row> + <row> + <box/> + <hbox pack="start"> + <button label="Subscribe" default="true" + oncommand="Subscriber.subscribeTwitter(event)"/> + </hbox> + </row> + </rows> + </grid> + </tabpanel> + </tabpanels> + </tabbox> <vbox> - <label id="sourceTitle"/> - - <separator class="thin" orient="horizontal"/> - - <hbox id="connectingBox" class="statusBox" align="center"> - <image id="connectingIcon" class="statusIcon"/> - <label value="Connecting to feed" disabled="true"/> - </hbox> - - <hbox id="gettingMessagesBox" class="statusBox" align="center"> - <image id="gettingMessagesIcon" class="statusIcon"/> - <label value="Getting stories" disabled="true"/> - </hbox> - <hbox id="doneBox" class="statusBox" align="center"> - <image id="doneIcon" class="statusIcon"/> - <label value="Done" disabled="true"/> + <hbox id="statusBox" align="center"> + <image id="statusIcon"/> + <!-- Give the status text an initial value to reserve space + - in the UI for it (so setting it doesn't resize the UI). --> + <label id="statusText" value=" "/> </hbox> </vbox> <separator class="thin" orient="horizontal"/> <hbox> - <button id="importOPMLButton" label="Import OPML..." oncommand="Subscriber.doImportOPML(event)"/> <spacer flex="1"/> <button label="Close" oncommand="Subscriber.doClose(event)"/> </hbox> + </vbox> + </page>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/extension/modules/twitter.js Mon Jul 21 19:13:02 2008 -0700 @@ -0,0 +1,661 @@ +EXPORTED_SYMBOLS = ["SnowlTwitter"]; + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cr = Components.results; +const Cu = Components.utils; + +// modules that come with Firefox +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/ISO8601DateUtils.jsm"); + +// modules that should come with Firefox +Cu.import("resource://snowl/modules/log4moz.js"); +Cu.import("resource://snowl/modules/Observers.js"); +Cu.import("resource://snowl/modules/URI.js"); + +// Snowl-specific modules +Cu.import("resource://snowl/modules/datastore.js"); +Cu.import("resource://snowl/modules/source.js"); +Cu.import("resource://snowl/modules/identity.js"); + +function SnowlTwitter(aID, aName, aMachineURI, aHumanURI, aLastRefreshed, aImportance) { + // Call the superclass's constructor to initialize the new instance. + SnowlSource.call(this, aID, aName, aMachineURI, aHumanURI, aLastRefreshed, aImportance); +} + +SnowlTwitter.prototype = { + __proto__: SnowlSource.prototype, + + _log: Log4Moz.Service.getLogger("Snowl.Twitter"), + + // If we prompt the user to authenticate, and the user asks us to remember + // their password, we store the nsIAuthInformation in this property until + // the request succeeds, at which point we store it with the login manager. + _authInfo: null, + + // Observer Service + get _obsSvc() { + let obsSvc = Cc["@mozilla.org/observer-service;1"]. + getService(Ci.nsIObserverService); + this.__defineGetter__("_obsSvc", function() { return obsSvc }); + return this._obsSvc; + }, + + // nsISupports + + QueryInterface: XPCOMUtils.generateQI([Ci.nsIAuthPrompt2]), + + // nsIInterfaceRequestor + + getInterface: function(iid) { + return this.QueryInterface(iid); + }, + + // nsIAuthPrompt2 + + _logins: null, + _loginIndex: 0, + + promptAuth: function(channel, level, authInfo) { + // Check saved logins before prompting the user. We get them + // from the login manager and try each in turn until one of them works + // or we run out of them. + if (!this._logins) { + let lm = Cc["@mozilla.org/login-manager;1"].getService(Ci.nsILoginManager); + // XXX Should we be using channel.URI.prePath in case the old URI + // redirects us to a new one at a different hostname? + this._logins = lm.findLogins({}, this.machineURI.prePath, null, authInfo.realm); + } + + let login = this._logins[this._loginIndex]; + if (login) { + authInfo.username = login.username; + authInfo.password = login.password; + ++this._loginIndex; + return true; + } + + // If we've made it this far, none of the saved logins worked, so we prompt + // the user to provide one. + let args = Cc["@mozilla.org/supports-array;1"].createInstance(Ci.nsISupportsArray); + args.AppendElement({ wrappedJSObject: this }); + args.AppendElement(authInfo); + + // |result| is how the dialog passes information back to us. It sets two + // properties on the object: |proceed|, which we return from this function, + // and which determines whether or not authentication can proceed using + // the values entered by the user; and |remember|, which determines whether + // or not we save the user's login with the login manager once the request + // succeeds. + let result = {}; + args.AppendElement({ wrappedJSObject: result }); + + let ww = Cc["@mozilla.org/embedcomp/window-watcher;1"].getService(Ci.nsIWindowWatcher); + ww.openWindow(null, + // XXX Should we use commonDialog.xul? + "chrome://snowl/content/login.xul", + null, + "chrome,centerscreen,dialog,modal", + args); + + if (result.remember) + this._authInfo = authInfo; + + return result.proceed; + }, + + asyncPromptAuth: function() { + throw Cr.NS_ERROR_NOT_IMPLEMENTED; + }, + + verify: function(username, password, callback) { + let request = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(); +this._log.info("verify callback: " + callback); + + request.QueryInterface(Ci.nsIDOMEventTarget); + let t = this; + request.addEventListener("load", function(evt) { t.onVerifyLoad(evt, callback) }, false); + request.addEventListener("error", function(evt) { t.onVerifyError(evt, callback) }, false); + + request.QueryInterface(Ci.nsIXMLHttpRequest); + + request.open("GET", this.machineURI.spec + "/account/verify_credentials.json", true); + + request.setRequestHeader("Authorization", "Basic " + btoa(username + ":" + password)); + + // Register a listener for notification callbacks so we handle authentication. + request.channel.notificationCallbacks = this; + + request.send(null); + }, + + onVerifyLoad: function(event, callback) { +this._log.info("onVerifyLoad callback: " + callback); + let request = event.target; + + // If the request failed, let the error handler handle it. + // XXX Do we need this? Don't such failures call the error handler directly? + if (request.status < 200 || request.status > 299) { + this.onVerifyError(event, callback); + return; + } + + // XXX What's the right way to handle this? + if (request.responseText.length == 0) { + this.onVerifyError(event, callback); + return; + } + + callback(request.status + ": " + request.responseText); +return; + // _authInfo only gets set if we prompted the user to authenticate + // and the user checked the "remember password" box. Since we're here, + // it means the request succeeded, so we save the login. + if (this._authInfo) + this._saveLogin(); + + let parser = Cc["@mozilla.org/feed-processor;1"]. + createInstance(Ci.nsIFeedProcessor); + parser.listener = { t: this, handleResult: function(r) { this.t.onRefreshResult(r) } }; + parser.parseFromString(request.responseText, request.channel.URI); + }, + + onVerifyError: function(event, callback) { +this._log.info("onVerifyError callback: " + callback); + + let request = event.target; + + // Sometimes an attempt to retrieve status text throws NS_ERROR_NOT_AVAILABLE. + let statusText = ""; + try { + statusText = request.statusText; + } + catch(ex) {} + + this._log.error("onVerifyError: " + request.status + " (" + statusText + ")"); + + callback(request.responseText); + }, + + + + + refresh: function() { + let request = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(); + + request.QueryInterface(Ci.nsIDOMEventTarget); + let t = this; + request.addEventListener("load", function(e) { t.onRefreshLoad(e) }, false); + request.addEventListener("error", function(e) { t.onRefreshError(e) }, false); + + request.QueryInterface(Ci.nsIXMLHttpRequest); + + // The feed processor is going to parse the XML, so override the MIME type + // in order to turn off parsing by XMLHttpRequest itself. + request.overrideMimeType("text/plain"); + + request.open("GET", this.machineURI.spec, true); + + // Register a listener for notification callbacks so we handle authentication. + request.channel.notificationCallbacks = this; + + request.send(null); + }, + + onRefreshLoad: function(aEvent) { + let request = aEvent.target; + + // If the request failed, let the error handler handle it. + // XXX Do we need this? Don't such failures call the error handler directly? + if (request.status < 200 || request.status > 299) { + this.onRefreshError(aEvent); + return; + } + + // XXX What's the right way to handle this? + if (request.responseText.length == 0) { + this.onRefreshError(aEvent); + return; + } + + // _authInfo only gets set if we prompted the user to authenticate + // and the user checked the "remember password" box. Since we're here, + // it means the request succeeded, so we save the login. + if (this._authInfo) + this._saveLogin(); + + let parser = Cc["@mozilla.org/feed-processor;1"]. + createInstance(Ci.nsIFeedProcessor); + parser.listener = { t: this, handleResult: function(r) { this.t.onRefreshResult(r) } }; + parser.parseFromString(request.responseText, request.channel.URI); + }, + + onRefreshError: function(aEvent) { + let request = aEvent.target; + + // Sometimes an attempt to retrieve status text throws NS_ERROR_NOT_AVAILABLE + let statusText = ""; + try { + statusText = request.statusText; + } + catch(ex) {} + + this._log.error("onRefreshError: " + request.status + " (" + statusText + ")"); + }, + + onRefreshResult: function(aResult) { + Observers.notify(this, "snowl:subscribe:get:start", null); + + // Now that we know we successfully downloaded the feed and obtained + // a result from it, update the "last refreshed" timestamp. + this.lastRefreshed = new Date(); + + let feed = aResult.doc.QueryInterface(Components.interfaces.nsIFeed); + + let currentMessageIDs = []; + let messagesChanged = false; + + SnowlDatastore.dbConnection.beginTransaction(); + try { + for (let i = 0; i < feed.items.length; i++) { + let entry = feed.items.queryElementAt(i, Ci.nsIFeedEntry); + //entry.QueryInterface(Ci.nsIFeedContainer); + + // Figure out the ID for the entry, then check if the entry has already + // been retrieved. If we can't figure out the entry's ID, then we skip + // the entry, since its ID is the only way for us to know whether or not + // it has already been retrieved. + let externalID; + try { + externalID = entry.id || this._generateID(entry); + } + catch(ex) { + this._log.warn("couldn't retrieve a message: " + ex); + continue; + } + + let internalID = this._getInternalIDForExternalID(externalID); + if (internalID) + continue; + + messagesChanged = true; + this._log.info(this.name + " adding message " + externalID); + internalID = this._addMessage(feed, entry, externalID); + currentMessageIDs.push(internalID); + } + + // Update the current flag. + // XXX Should this affect whether or not messages have changed? + SnowlDatastore.dbConnection.executeSimpleSQL("UPDATE messages SET current = 0 WHERE sourceID = " + this.id); + SnowlDatastore.dbConnection.executeSimpleSQL("UPDATE messages SET current = 1 WHERE id IN (" + currentMessageIDs.join(", ") + ")"); + + SnowlDatastore.dbConnection.commitTransaction(); + } + catch(ex) { + SnowlDatastore.dbConnection.rollbackTransaction(); + throw ex; + } + + if (messagesChanged) + this._obsSvc.notifyObservers(null, "messages:changed", null); + + Observers.notify(this, "snowl:subscribe:get:end", null); + }, + + /** + * Add a message to the datastore for the given feed entry. + * + * @param aFeed {nsIFeed} the feed + * @param aEntry {nsIFeedEntry} the entry + * @param aExternalID {string} the external ID of the entry + */ + _addMessage: function(aFeed, aEntry, aExternalID) { + let authorID = null; + let authors = (aEntry.authors.length > 0) ? aEntry.authors + : (aFeed.authors.length > 0) ? aFeed.authors + : null; + if (authors && authors.length > 0) { + let author = authors.queryElementAt(0, Ci.nsIFeedPerson); + // The external ID for an author is her email address, if provided + // (many feeds don't); otherwise it's her name. For the name, on the + // other hand, we use the name, if provided, but fall back to the + // email address if a name is not provided (which it probably was). + let externalID = author.email || author.name; + let name = author.name || author.email; + + // Get an existing identity or create a new one. Creating an identity + // automatically creates a person record with the provided name. + identity = SnowlIdentity.get(this.id, externalID) || + SnowlIdentity.create(this.id, externalID, name); + authorID = identity.personID; + } + + // Pick a timestamp, which is one of (by priority, high to low): + // 1. when the entry was last updated; + // 2. when the entry was published; + // 3. the Dublin Core timestamp associated with the entry; + // XXX Should we separately record when we added the entry so that the user + // can sort in the "order received" and view "when received" separately from + // "when published/updated"? + let timestamp = aEntry.updated ? new Date(aEntry.updated) : + aEntry.published ? new Date(aEntry.published) : + ISO8601DateUtils.parse(aEntry.get("dc:date")); + + // FIXME: handle titles that contain markup or are missing. + let messageID = this.addSimpleMessage(this.id, aExternalID, + aEntry.title.text, authorID, + timestamp, aEntry.link); + + // Add parts + if (aEntry.content) { + this.addPart(messageID, PART_TYPE_CONTENT, aEntry.content.text, + (aEntry.content.base ? aEntry.content.base.spec : null), + aEntry.content.lang, mediaTypes[aEntry.content.type]); + } + + if (aEntry.summary) { + this.addPart(messageID, PART_TYPE_SUMMARY, aEntry.summary.text, + (aEntry.summary.base ? aEntry.summary.base.spec : null), + aEntry.summary.lang, mediaTypes[aEntry.summary.type]); + } + + // Add metadata. + let fields = aEntry.QueryInterface(Ci.nsIFeedContainer). + fields.QueryInterface(Ci.nsIPropertyBag).enumerator; + while (fields.hasMoreElements()) { + let field = fields.getNext().QueryInterface(Ci.nsIProperty); + + // FIXME: create people records for these. + if (field.name == "authors") { + let values = field.value.QueryInterface(Ci.nsIArray).enumerate(); + while (values.hasMoreElements()) { + let value = values.getNext().QueryInterface(Ci.nsIFeedPerson); + // FIXME: store people records in a separate table with individual + // columns for each person attribute (i.e. name, email, url)? + this._addMetadatum(messageID, + "atom:author", + value.name && value.email ? value.name + "<" + value.email + ">" + : value.name ? value.name : value.email); + } + } + + else if (field.name == "links") { + let values = field.value.QueryInterface(Ci.nsIArray).enumerate(); + while (values.hasMoreElements()) { + let value = values.getNext().QueryInterface(Ci.nsIPropertyBag2); + // FIXME: store link records in a separate table with individual + // colums for each link attribute (i.e. href, type, rel, title)? + this._addMetadatum(messageID, + "atom:link_" + value.get("rel"), + value.get("href")); + } + } + + // For some reason, the values of certain simple fields (like RSS2 guid) + // are property bags containing the value instead of the value itself. + // For those, we need to unwrap the extra layer. This strange behavior + // has been filed as bug 427907. + else if (typeof field.value == "object") { + if (field.value instanceof Ci.nsIPropertyBag2) { + let value = field.value.QueryInterface(Ci.nsIPropertyBag2).get(field.name); + this._addMetadatum(messageID, field.name, value); + } + else if (field.value instanceof Ci.nsIArray) { + let values = field.value.QueryInterface(Ci.nsIArray).enumerate(); + while (values.hasMoreElements()) { + // FIXME: values might not always have this interface. + let value = values.getNext().QueryInterface(Ci.nsIPropertyBag2); + this._addMetadatum(messageID, field.name, value.get(field.name)); + } + } + } + + else + this._addMetadatum(messageID, field.name, field.value); + } + + return messageID; + }, + + /** + * Given an entry, generate an ID for it based on a hash of its link, + * published, and title attributes. Useful for uniquely identifying entries + * that don't provide their own IDs. + * + * @param entry {nsIFeedEntry} the entry for which to generate an ID + * @returns {string} an ID for the entry + */ + _generateID: function(entry) { + let hasher = Cc["@mozilla.org/security/hash;1"]. + createInstance(Ci.nsICryptoHash); + hasher.init(Ci.nsICryptoHash.SHA1); + let identity = stringToArray(entry.link.spec + entry.published + entry.title.text); + hasher.update(identity, identity.length); + return "urn:" + hasher.finish(true); + }, + + // FIXME: Make the rest of this stuff be part of a superclass from which + // this class is derived. + + /** + * Get the internal ID of the message with the given external ID. + * + * @param aExternalID {string} + * the external ID of the message + * + * @returns {number} + * the internal ID of the message, or undefined if the message + * doesn't exist + */ + _getInternalIDForExternalID: function(aExternalID) { + return SnowlDatastore.selectInternalIDForExternalID(aExternalID); + }, + + /** + * Add a message with a single part to the datastore. + * + * @param aSourceID {integer} the record ID of the message source + * @param aExternalID {string} the external ID of the message + * @param aSubject {string} the title of the message + * @param aAuthorID {string} the author of the message + * @param aTimestamp {Date} the date/time at which the message was sent + * @param aLink {nsIURI} a link to the content of the message, + * if the content is hosted on a server + * + * @returns {integer} the internal ID of the newly-created message + */ + addSimpleMessage: function(aSourceID, aExternalID, aSubject, aAuthorID, + aTimestamp, aLink) { + // Convert the timestamp to milliseconds-since-epoch, which is how we store + // it in the datastore. + let timestamp = aTimestamp ? aTimestamp.getTime() : null; + + // Convert the link to its string spec, which is how we store it + // in the datastore. + let link = aLink ? aLink.spec : null; + + let messageID = + SnowlDatastore.insertMessage(aSourceID, aExternalID, aSubject, aAuthorID, + timestamp, link); + + return messageID; + }, + + get _addPartStatement() { + let statement = SnowlDatastore.createStatement( + "INSERT INTO parts(messageID, partType, content, baseURI, languageCode, mediaType) \ + VALUES (:messageID, :partType, :content, :baseURI, :languageCode, :mediaType)" + ); + this.__defineGetter__("_addPartStatement", function() { return statement }); + return this._addPartStatement; + }, + + addPart: function(aMessageID, aPartType, aContent, aBaseURI, aLanguageCode, + aMediaType) { + this._addPartStatement.params.messageID = aMessageID; + this._addPartStatement.params.partType = aPartType; + this._addPartStatement.params.content = aContent; + this._addPartStatement.params.baseURI = aBaseURI; + this._addPartStatement.params.languageCode = aLanguageCode; + this._addPartStatement.params.mediaType = aMediaType; + this._addPartStatement.execute(); + + return SnowlDatastore.dbConnection.lastInsertRowID; + }, + + _addMetadatum: function(aMessageID, aAttributeName, aValue) { + // FIXME: speed this up by caching the list of known attributes. + let attributeID = SnowlDatastore.selectAttributeID(aAttributeName) + || SnowlDatastore.insertAttribute(aAttributeName); + SnowlDatastore.insertMetadatum(aMessageID, attributeID, aValue); + }, + + subscribe: function(callback) { + Observers.notify(this, "snowl:subscribe:connect:start", null); + + this._subscribeCallback = callback; + + this._log.info("subscribing to " + this.name + " <" + this.machineURI.spec + ">"); + + let request = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(); + + request = request.QueryInterface(Ci.nsIDOMEventTarget); + + let t = this; + request.addEventListener("load", function(e) { t.onSubscribeLoad(e) }, false); + request.addEventListener("error", function(e) { t.onSubscribeError(e) }, false); + + request = request.QueryInterface(Ci.nsIXMLHttpRequest); + + // The feed processor is going to parse the XML, so override the MIME type + // in order to turn off parsing by XMLHttpRequest itself. + request.overrideMimeType("text/plain"); + + request.open("GET", this.machineURI.spec, true); + + // Register a listener for notification callbacks so we handle authentication. + request.channel.notificationCallbacks = this; + + request.send(null); + }, + + onSubscribeLoad: function(aEvent) { + let request = aEvent.target; + + // If the request failed, let the error handler handle it. + // XXX Do we need this? Don't such failures call the error handler directly? + if (request.status < 200 || request.status > 299) { + this.onSubscribeError(aEvent); + return; + } + + // XXX What's the right way to handle this? + if (request.responseText.length == 0) { + this.onRefreshError(aEvent); + return; + } + + Observers.notify(this, "snowl:subscribe:connect:end", request.status); + + // _authInfo only gets set if we prompted the user to authenticate + // and the user checked the "remember password" box. Since we're here, + // it means the request succeeded, so we save the login. + if (this._authInfo) + this._saveLogin(); + + let parser = Cc["@mozilla.org/feed-processor;1"]. + createInstance(Ci.nsIFeedProcessor); + parser.listener = { t: this, handleResult: function(r) { this.t.onSubscribeResult(r) } }; + parser.parseFromString(request.responseText, request.channel.URI); + }, + + onSubscribeError: function(aEvent) { + let request = aEvent.target; + this._log.error("onSubscribeError: " + request.status + " (" + request.statusText + ")"); + Observers.notify(this, "snowl:subscribe:connect:end", request.status); + if (this._subscribeCallback) + this._subscribeCallback(); + }, + + onSubscribeResult: function(aResult) { + try { + let feed = aResult.doc.QueryInterface(Components.interfaces.nsIFeed); + + // Extract the name (if we don't already have one) and human URI from the feed. + if (!this.name) + this.name = feed.title.plainText(); + this.humanURI = feed.link; + + // Add the source to the database. + let statement = + SnowlDatastore.createStatement("INSERT INTO sources (name, machineURI, humanURI) " + + "VALUES (:name, :machineURI, :humanURI)"); + try { + statement.params.name = this.name; + statement.params.machineURI = this.machineURI.spec; + statement.params.humanURI = this.humanURI.spec; + statement.step(); + } + finally { + statement.reset(); + } + + // Extract the ID of the source from the newly-created database record. + this.id = SnowlDatastore.dbConnection.lastInsertRowID; + + // Let observers know about the new source. + this._obsSvc.notifyObservers(null, "sources:changed", null); + + // Refresh the feed to import all its items. + this.onRefreshResult(aResult); + } + catch(ex) { + dump("error on subscribe result: " + ex + "\n"); + } + finally { + if (this._subscribeCallback) + this._subscribeCallback(); + } + }, + + _saveLogin: function() { + let lm = Cc["@mozilla.org/login-manager;1"].getService(Ci.nsILoginManager); + + // Create a new login with the auth information we obtained from the user. + let LoginInfo = new Components.Constructor("@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init"); + // XXX Should we be using channel.URI.prePath in case the old URI + // redirects us to a new one at a different hostname? + let newLogin = new LoginInfo(this.machineURI.prePath, + null, + this._authInfo.realm, + this._authInfo.username, + this._authInfo.password, + "", + ""); + + // Get existing logins that have the same hostname and realm. + let logins = lm.findLogins({}, this.machineURI.prePath, null, this._authInfo.realm); + + // Try to figure out if we should replace one of the existing logins. + // If there's only one existing login, we replace it. Otherwise, if + // there's a login with the same username, we replace that. Otherwise, + // we add the new login instead of replacing an existing one. + let oldLogin; + if (logins.length == 1) + oldLogin = logins[0]; + else if (logins.length > 1) + oldLogin = logins.filter(function(v) v.username == this._authInfo.username)[0]; + + if (oldLogin) + lm.modifyLogin(oldLogin, newLogin); + else + lm.addLogin(newLogin); + + // Now that we've saved the login, we don't need the auth info anymore. + this._authInfo = null; + } + +};