diff --git a/browser/components/loop/CardDavImporter.jsm b/browser/components/loop/CardDavImporter.jsm
new file mode 100644
index 000000000000..cb9dc34c0506
--- /dev/null
+++ b/browser/components/loop/CardDavImporter.jsm
@@ -0,0 +1,463 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+
+this.EXPORTED_SYMBOLS = ["CardDavImporter"];
+
+let log = Log.repository.getLogger("Loop.Importer.CardDAV");
+log.level = Log.Level.Debug;
+log.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter()));
+
+const DEPTH_RESOURCE_ONLY = "0";
+const DEPTH_RESOURCE_AND_CHILDREN = "1";
+const DEPTH_RESOURCE_AND_ALL_DESCENDENTS = "infinity";
+
+this.CardDavImporter = function() {
+};
+
+/**
+ * CardDAV Address Book importer for Loop.
+ *
+ * The model for address book importers is to have a single public method,
+ * "startImport." When the import is done (or upon a fatal error), the
+ * caller's callback method is called.
+ *
+ * The current model for this importer is based on the subset of CardDAV
+ * implemented by Google. In theory, it should work with other CardDAV
+ * sources, but it has only been tested against Google at the moment.
+ *
+ * At the moment, this importer assumes that no local changes will be made
+ * to data retreived from a remote source: when performing a re-import,
+ * any records that have been previously imported will be completely
+ * removed and replaced with the data received from the CardDAV server.
+ * Witout this behavior, it would be impossible for users to take any
+ * actions to remove fields that are no longer valid.
+ */
+
+this.CardDavImporter.prototype = {
+ /**
+ * Begin import of an address book from a CardDAV server.
+ *
+ * @param {Object} options Information needed to perform the address
+ * book import. The following fields are currently
+ * defined:
+ * - "host": CardDAV server base address
+ * (e.g., "google.com")
+ * - "auth": Authentication mechanism to use.
+ * Currently, only "basic" is implemented.
+ * - "user": Username to use for basic auth
+ * - "password": Password to use for basic auth
+ * @param {Function} callback Callback function that will be invoked once the
+ * import operation is complete. The first argument
+ * passed to the callback will be an 'Error' object
+ * or 'null'. If the import operation was
+ * successful, then the second parameter will be a
+ * count of the number of contacts that were
+ * successfully imported.
+ * @param {Object} db Database to add imported contacts into.
+ * Nominally, this is the LoopContacts API. In
+ * practice, anything with the same interface
+ * should work here.
+ */
+
+ startImport: function(options, callback, db) {
+ let auth;
+ if (!("auth" in options)) {
+ callback(new Error("No authentication specified"));
+ return;
+ }
+
+ if (options.auth === "basic") {
+ if (!("user" in options) || !("password" in options)) {
+ callback(new Error("Missing user or password for basic authentication"));
+ return;
+ }
+ auth = { method: "basic",
+ user: options.user,
+ password: options.password };
+ } else {
+ callback(new Error("Unknown authentication method"));
+ return;
+ }
+
+ if (!("host" in options)){
+ callback(new Error("Missing host for CardDav import"));
+ return;
+ }
+ let host = options.host;
+
+ Task.spawn(function* () {
+ log.info("Starting CardDAV import from " + host);
+ let baseURL = "https://" + host;
+ let startURL = baseURL + "/.well-known/carddav";
+ let abookURL;
+
+ // Get list of contact URLs
+ let body = "" +
+ "";
+ let abook = yield this._davPromise("PROPFIND", startURL, auth,
+ DEPTH_RESOURCE_AND_CHILDREN, body);
+
+ // Build multiget REPORT body from URLs in PROPFIND result
+ let contactElements = abook.responseXML.
+ getElementsByTagNameNS("DAV:", "href");
+
+ body = "" +
+ " \n";
+
+ for (let element of contactElements) {
+ let href = element.textContent;
+ if (href.substr(-1) == "/") {
+ abookURL = baseURL + href;
+ } else {
+ body += "" + href + "\n";
+ }
+ }
+ body += "";
+
+ // Retreive contact URL contents
+ let allEntries = yield this._davPromise("REPORT", abookURL, auth,
+ DEPTH_RESOURCE_AND_CHILDREN,
+ body);
+
+ // Parse multiget entites and add to DB
+ let addressData = allEntries.responseXML.getElementsByTagNameNS(
+ "urn:ietf:params:xml:ns:carddav", "address-data");
+
+ log.info("Retreived " + addressData.length + " contacts from " +
+ host + "; importing into database");
+
+ let importCount = 0;
+ for (let i = 0; i < addressData.length; i++) {
+ let vcard = addressData.item(i).textContent;
+ let contact = this._convertVcard(vcard);
+ contact.id += "@" + host;
+ contact.category = ["carddav@" + host];
+
+ let existing = yield this._dbPromise(db, "getByServiceId", contact.id);
+ if (existing) {
+ yield this._dbPromise(db, "remove", existing._guid);
+ }
+
+ // If the contact contains neither email nor phone number, then it
+ // is not useful in the Loop address book: do not add.
+ if (!("tel" in contact) && !("email" in contact)) {
+ continue;
+ }
+
+ yield this._dbPromise(db, "add", contact);
+ importCount++;
+ }
+
+ return importCount;
+ }.bind(this)).then(
+ (result) => {
+ log.info("Import complete: " + result + " contacts imported.");
+ callback(null, result);
+ },
+ (error) => {
+ log.error("Aborting import: " + error.fileName + ":" +
+ error.lineNumber + ": " + error.message);
+ callback(error);
+ }).then(null,
+ (error) => {
+ log.error("Error in callback: " + error.fileName +
+ ":" + error.lineNumber + ": " + error.message);
+ callback(error);
+ }).then(null,
+ (error) => {
+ log.error("Error calling failure callback, giving up: " +
+ error.fileName + ":" + error.lineNumber + ": " +
+ error.message);
+ });
+ },
+
+ /**
+ * Wrap a LoopContacts-style operation in a promise. The operation is run
+ * immediately, and a corresponding Promise is returned. Error callbacks
+ * cause the promise to be rejected, and success cause it to be resolved.
+ *
+ * @param {Object} db Object the operation is to be performed on
+ * @param {String} method Name of operation being wrapped
+ * @param {Object} param Parameter to be passed to the operation
+ *
+ * @return {Object} Promise corresponding to the result of the operation.
+ */
+
+ _dbPromise: function(db, method, param) {
+ return new Promise((resolve, reject) => {
+ db[method](param, (error, result) => {
+ if (error) {
+ reject(error);
+ } else {
+ resolve(result);
+ }
+ });
+ });
+ },
+
+ /**
+ * Converts a contact in VCard format (see RFC 6350) to the format used
+ * by the LoopContacts class.
+ *
+ * @param {String} vcard The contact to convert, in vcard format
+ * @return {Object} a LoopContacts-style contact object containing
+ * the relevant fields from the vcard.
+ */
+
+ _convertVcard: function(vcard) {
+ let contact = {};
+ let nickname;
+ vcard.split(/[\r\n]+(?! )/).forEach(
+ function (contentline) {
+ contentline = contentline.replace(/[\r\n]+ /g, "");
+ let match = /^(.*?[^\\]):(.*)$/.exec(contentline);
+ if (match) {
+ let nameparam = match[1];
+ let value = match[2];
+
+ // Poor-man's unescaping
+ value = value.replace(/\\:/g, ":");
+ value = value.replace(/\\,/g, ",");
+ value = value.replace(/\\n/gi, "\n");
+ value = value.replace(/\\\\/g, "\\");
+
+ let param = nameparam.split(/;/);
+ let name = param[0];
+ let pref = false;
+ let type = [];
+
+ for (let i = 1; i < param.length; i++) {
+ if (/^PREF/.exec(param[i]) || /^TYPE=PREF/.exec(param[i])) {
+ pref = true;
+ }
+ let typeMatch = /^TYPE=(.*)/.exec(param[i]);
+ if (typeMatch) {
+ type.push(typeMatch[1].toLowerCase());
+ }
+ }
+
+ if (!type.length) {
+ type.push("other");
+ }
+
+ if (name === "FN") {
+ value = value.replace(/\\;/g, ";");
+ contact.name = [value];
+ }
+
+ if (name === "N") {
+ // Because we don't have lookbehinds, matching unescaped
+ // semicolons is a pain. Luckily, we know that \r and \n
+ // cannot appear in the strings, so we use them to swap
+ // unescaped semicolons for \n.
+ value = value.replace(/\\;/g, "\r");
+ value = value.replace(/;/g, "\n");
+ value = value.replace(/\r/g, ";");
+
+ let family, given, additional, prefix, suffix;
+ let values = value.split(/\n/);
+ if (values.length >= 5) {
+ [family, given, additional, prefix, suffix] = values;
+ if (prefix.length) {
+ contact.honorificPrefix = [prefix];
+ }
+ if (given.length) {
+ contact.givenName = [given];
+ }
+ if (additional.length) {
+ contact.additionalName = [additional];
+ }
+ if (family.length) {
+ contact.familyName = [family];
+ }
+ if (suffix.length) {
+ contact.honorificSuffix = [suffix];
+ }
+ }
+ }
+
+ if (name === "EMAIL") {
+ value = value.replace(/\\;/g, ";");
+ if (!("email" in contact)) {
+ contact.email = [];
+ }
+ contact.email.push({
+ pref: pref,
+ type: type,
+ value: value
+ });
+ }
+
+ if (name === "NICKNAME") {
+ value = value.replace(/\\;/g, ";");
+ // We don't store nickname in contact because it's not
+ // a supported field. We're saving it off here in case we
+ // need to use it if the fullname is blank.
+ nickname = value;
+ };
+
+ if (name === "ADR") {
+ value = value.replace(/\\;/g, "\r");
+ value = value.replace(/;/g, "\n");
+ value = value.replace(/\r/g, ";");
+ let pobox, extra, street, locality, region, code, country;
+ let values = value.split(/\n/);
+ if (values.length >= 7) {
+ [pobox, extra, street, locality, region, code, country] = values;
+ if (!("adr" in contact)) {
+ contact.adr = [];
+ }
+ contact.adr.push({
+ pref: pref,
+ type: type,
+ streetAddress: (street || pobox) + (extra ? (" " + extra) : ""),
+ locality: locality,
+ region: region,
+ postalCode: code,
+ countryName: country
+ });
+ }
+ }
+
+ if (name === "TEL") {
+ value = value.replace(/\\;/g, ";");
+ if (!("tel" in contact)) {
+ contact.tel = [];
+ }
+ contact.tel.push({
+ pref: pref,
+ type: type,
+ value: value
+ });
+ }
+
+ if (name === "ORG") {
+ value = value.replace(/\\;/g, "\r");
+ value = value.replace(/;/g, "\n");
+ value = value.replace(/\r/g, ";");
+ if (!("org" in contact)) {
+ contact.org = [];
+ }
+ contact.org.push(value.replace(/\n.*/, ""));
+ }
+
+ if (name === "TITLE") {
+ value = value.replace(/\\;/g, ";");
+ if (!("jobTitle" in contact)) {
+ contact.jobTitle = [];
+ }
+ contact.jobTitle.push(value);
+ }
+
+ if (name === "BDAY") {
+ value = value.replace(/\\;/g, ";");
+ contact.bday = Date.parse(value);
+ }
+
+ if (name === "UID") {
+ contact.id = value;
+ }
+
+ if (name === "NOTE") {
+ value = value.replace(/\\;/g, ";");
+ if (!("note" in contact)) {
+ contact.note = [];
+ }
+ contact.note.push(value);
+ }
+
+ }
+ }
+ );
+
+ // Basic sanity checking: make sure the name field isn't empty
+ if (!("name" in contact) || contact.name[0].length == 0) {
+ if (("familyName" in contact) && ("givenName" in contact)) {
+ // First, try to synthesize a full name from the name fields.
+ // Ordering is culturally sensitive, but we don't have
+ // cultural origin information available here. The best we
+ // can really do is "family, given additional"
+ contact.name = [contact.familyName[0] + ", " + contact.givenName[0]];
+ if (("additionalName" in contact)) {
+ contact.name[0] += " " + contact.additionalName[0];
+ }
+ } else {
+ if (nickname) {
+ contact.name = [nickname];
+ } else if ("familyName" in contact) {
+ contact.name = [contact.familyName[0]];
+ } else if ("givenName" in contact) {
+ contact.name = [contact.givenName[0]];
+ } else if ("org" in contact) {
+ contact.name = [contact.org[0]];
+ } else if ("email" in contact) {
+ contact.name = [contact.email[0].value];
+ } else if ("tel" in contact) {
+ contact.name = [contact.tel[0].value];
+ }
+ }
+ }
+
+ return contact;
+ },
+
+ /**
+ * Issues a CardDAV request (see RFC 6352) and returns a Promise to represent
+ * the success or failure state of the request.
+ *
+ * @param {String} method WebDAV method to use (e.g., "PROPFIND")
+ * @param {String} url HTTP URL to use for the request
+ * @param {Object} auth Object with authentication-related configuration.
+ * See documentation for startImport for details.
+ * @param {Number} depth Value to use for the WebDAV (HTTP) "Depth" header
+ * @param {String} body Body to include in the WebDAV (HTTP) request
+ *
+ * @return {Object} Promise representing the request operation outcome.
+ * If resolved, the resolution value is the XMLHttpRequest
+ * that was used to perform the request.
+ */
+ _davPromise: function(method, url, auth, depth, body) {
+ return new Promise((resolve, reject) => {
+ let req = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].
+ createInstance(Ci.nsIXMLHttpRequest);
+ let user = "";
+ let password = "";
+
+ if (auth.method == "basic") {
+ user = auth.user;
+ password = auth.password;
+ }
+
+ req.open(method, url, true, user, password);
+
+ req.setRequestHeader("Depth", depth);
+ req.setRequestHeader("Content-Type", "application/xml; charset=utf-8");
+
+ req.onload = function() {
+ if (req.status < 400) {
+ resolve(req);
+ } else {
+ reject(new Error(req.status + " " + req.statusText));
+ }
+ };
+
+ req.onerror = function(error) {
+ reject(error);
+ }
+
+ req.send(body);
+ });
+ }
+};
diff --git a/browser/components/loop/LoopContacts.jsm b/browser/components/loop/LoopContacts.jsm
index 58d0c026dd59..a2624d9722bf 100644
--- a/browser/components/loop/LoopContacts.jsm
+++ b/browser/components/loop/LoopContacts.jsm
@@ -10,6 +10,8 @@ XPCOMUtils.defineLazyModuleGetter(this, "console",
"resource://gre/modules/devtools/Console.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "LoopStorage",
"resource:///modules/loop/LoopStorage.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "CardDavImporter",
+ "resource:///modules/loop/CardDavImporter.jsm");
XPCOMUtils.defineLazyGetter(this, "eventEmitter", function() {
const {EventEmitter} = Cu.import("resource://gre/modules/devtools/event-emitter.js", {});
return new EventEmitter();
@@ -318,6 +320,13 @@ LoopStorage.on("upgrade", function(e, db) {
* violated. You'll notice this as well in the documentation for each method.
*/
let LoopContactsInternal = Object.freeze({
+ /**
+ * Map of contact importer names to instances
+ */
+ _importServices: {
+ "carddav": new CardDavImporter()
+ },
+
/**
* Add a contact to the data store.
*
@@ -757,8 +766,15 @@ let LoopContactsInternal = Object.freeze({
* be the result of the operation, if successfull.
*/
startImport: function(options, callback) {
- //TODO in bug 972000.
- callback(new Error("Not implemented yet!"));
+ if (!("service" in options)) {
+ callback(new Error("No import service specified in options"));
+ return;
+ }
+ if (!(options.service in this._importServices)) {
+ callback(new Error("Unknown import service specified: " + options.service));
+ return;
+ }
+ this._importServices[options.service].startImport(options, callback, this);
},
/**
diff --git a/browser/components/loop/moz.build b/browser/components/loop/moz.build
index d4744d9a90a4..aa5377bde48f 100644
--- a/browser/components/loop/moz.build
+++ b/browser/components/loop/moz.build
@@ -13,6 +13,7 @@ BROWSER_CHROME_MANIFESTS += [
]
EXTRA_JS_MODULES.loop += [
+ 'CardDavImporter.jsm',
'LoopContacts.jsm',
'LoopStorage.jsm',
'MozLoopAPI.jsm',