зеркало из https://github.com/mozilla/gecko-dev.git
Bug 972079 - CardDAV Address Importer for Loop r=MattN
This commit is contained in:
Родитель
e7723d3dd4
Коммит
34a90a12e3
|
@ -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 = "<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);
|
||||
});
|
||||
}
|
||||
};
|
|
@ -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);
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
@ -13,6 +13,7 @@ BROWSER_CHROME_MANIFESTS += [
|
|||
]
|
||||
|
||||
EXTRA_JS_MODULES.loop += [
|
||||
'CardDavImporter.jsm',
|
||||
'LoopContacts.jsm',
|
||||
'LoopStorage.jsm',
|
||||
'MozLoopAPI.jsm',
|
||||
|
|
Загрузка…
Ссылка в новой задаче