gecko/browser/components/loop/CardDavImporter.jsm
2014-08-29 13:31:46 -05:00

464 lines
16 KiB
JavaScript

/* 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 = "<d:propfind xmlns:d='DAV:'><d:prop><d:getetag />" +
"</d:prop></d:propfind>";
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 = "<c:addressbook-multiget xmlns:d='DAV:' " +
"xmlns:c='urn:ietf:params:xml:ns:carddav'>" +
"<d:prop><d:getetag /> <c:address-data /></d:prop>\n";
for (let element of contactElements) {
let href = element.textContent;
if (href.substr(-1) == "/") {
abookURL = baseURL + href;
} else {
body += "<d:href>" + href + "</d:href>\n";
}
}
body += "</c:addressbook-multiget>";
// 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);
});
}
};