/* 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/XPCOMUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "console", "resource://gre/modules/devtools/Console.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "LoopStorage", "resource:///modules/loop/LoopStorage.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Promise", "resource://gre/modules/Promise.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "CardDavImporter", "resource:///modules/loop/CardDavImporter.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "GoogleImporter", "resource:///modules/loop/GoogleImporter.jsm"); XPCOMUtils.defineLazyGetter(this, "eventEmitter", function() { const {EventEmitter} = Cu.import("resource://gre/modules/devtools/event-emitter.js", {}); return new EventEmitter(); }); this.EXPORTED_SYMBOLS = ["LoopContacts"]; const kObjectStoreName = "contacts"; /* * The table used to store contacts information contains two identifiers, * both of which can be used to look up entries in the table. The table * key path (primary index, which must be unique) is "_guid", and is * automatically generated by IndexedDB when an entry is first inserted. * The other identifier, "id", is the supposedly unique key assigned to this * entry by whatever service generated it (e.g., Google Contacts). While * this key should, in theory, be completely unique, we don't use it * as the key path to avoid generating errors when an external database * violates this constraint. This second ID is referred to as the "serviceId". */ const kKeyPath = "_guid"; const kServiceIdIndex = "id"; /** * Contacts validation. * * To allow for future integration with the Contacts API and/ or potential * integration with contact synchronization across devices (including Firefox OS * devices), we are using objects with properties having the same names and * structure as those used by mozContact. * * See https://developer.mozilla.org/en-US/docs/Web/API/mozContact for more * information. */ const kFieldTypeString = "string"; const kFieldTypeNumber = "number"; const kFieldTypeNumberOrString = "number|string"; const kFieldTypeArray = "array"; const kFieldTypeBool = "boolean"; const kContactFields = { "id": { // Because "id" is externally generated, it might be numeric type: kFieldTypeNumberOrString }, "published": { // mozContact, from which we are derived, defines dates as // "a Date object, which will eventually be converted to a // long long" -- to be forwards compatible, we allow both // formats for now. type: kFieldTypeNumberOrString }, "updated": { // mozContact, from which we are derived, defines dates as // "a Date object, which will eventually be converted to a // long long" -- to be forwards compatible, we allow both // formats for now. type: kFieldTypeNumberOrString }, "bday": { // mozContact, from which we are derived, defines dates as // "a Date object, which will eventually be converted to a // long long" -- to be forwards compatible, we allow both // formats for now. type: kFieldTypeNumberOrString }, "blocked": { type: kFieldTypeBool }, "adr": { type: kFieldTypeArray, contains: { "countryName": { type: kFieldTypeString }, "locality": { type: kFieldTypeString }, "postalCode": { // In some (but not all) locations, postal codes can be strictly numeric type: kFieldTypeNumberOrString }, "pref": { type: kFieldTypeBool }, "region": { type: kFieldTypeString }, "streetAddress": { type: kFieldTypeString }, "type": { type: kFieldTypeArray, contains: kFieldTypeString } } }, "email": { type: kFieldTypeArray, contains: { "pref": { type: kFieldTypeBool }, "type": { type: kFieldTypeArray, contains: kFieldTypeString }, "value": { type: kFieldTypeString } } }, "tel": { type: kFieldTypeArray, contains: { "pref": { type: kFieldTypeBool }, "type": { type: kFieldTypeArray, contains: kFieldTypeString }, "value": { type: kFieldTypeString } } }, "name": { type: kFieldTypeArray, contains: kFieldTypeString }, "honorificPrefix": { type: kFieldTypeArray, contains: kFieldTypeString }, "givenName": { type: kFieldTypeArray, contains: kFieldTypeString }, "additionalName": { type: kFieldTypeArray, contains: kFieldTypeString }, "familyName": { type: kFieldTypeArray, contains: kFieldTypeString }, "honorificSuffix": { type: kFieldTypeArray, contains: kFieldTypeString }, "category": { type: kFieldTypeArray, contains: kFieldTypeString }, "org": { type: kFieldTypeArray, contains: kFieldTypeString }, "jobTitle": { type: kFieldTypeArray, contains: kFieldTypeString }, "note": { type: kFieldTypeArray, contains: kFieldTypeString } }; /** * Compares the properties contained in an object to the definition as defined in * `kContactFields`. * If a property is encountered that is not found in the spec, an Error is thrown. * If a property is encountered with an invalid value, an Error is thrown. * * Please read the spec at https://wiki.mozilla.org/Loop/Architecture/Address_Book * for more information. * * @param {Object} obj The contact object, or part of it when called recursively * @param {Object} def The definition of properties to validate against. Defaults * to `kContactFields` */ const validateContact = function(obj, def = kContactFields) { for (let propName of Object.getOwnPropertyNames(obj)) { // Ignore internal properties. if (propName.startsWith("_")) { continue; } let propDef = def[propName]; if (!propDef) { throw new Error("Field '" + propName + "' is not supported for contacts"); } let val = obj[propName]; switch (propDef.type) { case kFieldTypeString: if (typeof val != kFieldTypeString) { throw new Error("Field '" + propName + "' must be of type String"); } break; case kFieldTypeNumberOrString: let type = typeof val; if (type != kFieldTypeNumber && type != kFieldTypeString) { throw new Error("Field '" + propName + "' must be of type Number or String"); } break; case kFieldTypeBool: if (typeof val != kFieldTypeBool) { throw new Error("Field '" + propName + "' must be of type Boolean"); } break; case kFieldTypeArray: if (!Array.isArray(val)) { throw new Error("Field '" + propName + "' must be an Array"); } let contains = propDef.contains; // If the type of `contains` is a scalar value, it means that the array // consists of items of only that type. let isScalarCheck = (typeof contains == kFieldTypeString); for (let arrayValue of val) { if (isScalarCheck) { if (typeof arrayValue != contains) { throw new Error("Field '" + propName + "' must be of type " + contains); } } else { validateContact(arrayValue, contains); } } break; } } }; /** * Provides a method to perform multiple operations in a single transaction on the * contacts store. * * @param {String} operation Name of an operation supported by `IDBObjectStore` * @param {Array} data List of objects that will be passed to the object * store operation * @param {Function} callback Function that will be invoked once the operations * have finished. The first argument passed will be * an `Error` object or `null`. The second argument * will be the `data` Array, if all operations finished * successfully. */ const batch = function(operation, data, callback) { let processed = []; if (!LoopContactsInternal.hasOwnProperty(operation) || typeof LoopContactsInternal[operation] != 'function') { callback(new Error ("LoopContactsInternal does not contain a '" + operation + "' method")); return; } LoopStorage.asyncForEach(data, (item, next) => { LoopContactsInternal[operation](item, (err, result) => { if (err) { next(err); return; } processed.push(result); next(); }); }, err => { if (err) { callback(err, processed); return; } callback(null, processed); }); } /** * Extend a `target` object with the properties defined in `source`. * * @param {Object} target The target object to receive properties defined in `source` * @param {Object} source The source object to copy properties from */ const extend = function(target, source) { for (let key of Object.getOwnPropertyNames(source)) { target[key] = source[key]; } return target; }; LoopStorage.on("upgrade", function(e, db) { if (db.objectStoreNames.contains(kObjectStoreName)) { return; } // Create the 'contacts' store as it doesn't exist yet. let store = db.createObjectStore(kObjectStoreName, { keyPath: kKeyPath, autoIncrement: true }); store.createIndex(kServiceIdIndex, kServiceIdIndex, {unique: false}); }); /** * The Contacts class. * * Each method that is a member of this class requires the last argument to be a * callback Function. MozLoopAPI will cause things to break if this invariant is * 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(), "google": new GoogleImporter() }, /** * Add a contact to the data store. * * @param {Object} details An object that will be added to the data store * as-is. Please read https://wiki.mozilla.org/Loop/Architecture/Address_Book * for more information of this objects' structure * @param {Function} callback Function that will be invoked once the operation * finished. The first argument passed will be an * `Error` object or `null`. The second argument will * be the contact object, if it was stored successfully. */ add: function(details, callback) { if (!(kServiceIdIndex in details)) { callback(new Error("No '" + kServiceIdIndex + "' field present")); return; } try { validateContact(details); } catch (ex) { callback(ex); return; } LoopStorage.getStore(kObjectStoreName, (err, store) => { if (err) { callback(err); return; } let contact = extend({}, details); let now = Date.now(); // The data source should have included "published" and "updated" values // for any imported records, and we need to keep track of those dated for // sync purposes (i.e., when we add functionality to push local changes to // a remote server from which we originally got a contact). We also need // to track the time at which *we* added and most recently changed the // contact, so as to determine whether the local or the remote store has // fresher data. // // For clarity: the fields "published" and "updated" indicate when the // *remote* data source published and updated the contact. The fields // "_date_add" and "_date_lch" track when the *local* data source // created and updated the contact. contact.published = contact.published ? new Date(contact.published).getTime() : now; contact.updated = contact.updated ? new Date(contact.updated).getTime() : now; contact._date_add = contact._date_lch = now; let request; try { request = store.add(contact); } catch (ex) { callback(ex); return; } request.onsuccess = event => { contact[kKeyPath] = event.target.result; eventEmitter.emit("add", contact); callback(null, contact); }; request.onerror = event => callback(event.target.error); }, "readwrite"); }, /** * Add a batch of contacts to the data store. * * @param {Array} contacts A list of contact objects to be added * @param {Function} callback Function that will be invoked once the operation * finished. The first argument passed will be an * `Error` object or `null`. The second argument will * be the list of added contacts. */ addMany: function(contacts, callback) { batch("add", contacts, callback); }, /** * Remove a contact from the data store. * * @param {String} guid String identifier of the contact to remove * @param {Function} callback Function that will be invoked once the operation * finished. The first argument passed will be an * `Error` object or `null`. The second argument will * be the result of the operation. */ remove: function(guid, callback) { this.get(guid, (err, contact) => { if (err) { callback(err); return; } LoopStorage.getStore(kObjectStoreName, (err, store) => { if (err) { callback(err); return; } let request; try { request = store.delete(guid); } catch (ex) { callback(ex); return; } request.onsuccess = event => { if (contact) { eventEmitter.emit("remove", contact); } callback(null, event.target.result); }; request.onerror = event => callback(event.target.error); }, "readwrite"); }); }, /** * Remove a batch of contacts from the data store. * * @param {Array} guids A list of IDs of the contacts to remove * @param {Function} callback Function that will be invoked once the operation * finished. The first argument passed will be an * `Error` object or `null`. The second argument will * be the list of IDs, if successfull. */ removeMany: function(guids, callback) { batch("remove", guids, callback); }, /** * Remove _all_ contacts from the data store. * CAUTION: this method will clear the whole data store - you won't have any * contacts left! * * @param {Function} callback Function that will be invoked once the operation * finished. The first argument passed will be an * `Error` object or `null`. The second argument will * be the result of the operation, if successfull. */ removeAll: function(callback) { LoopStorage.getStore(kObjectStoreName, (err, store) => { if (err) { callback(err); return; } let request; try { request = store.clear(); } catch (ex) { callback(ex); return; } request.onsuccess = event => { eventEmitter.emit("removeAll", event.target.result); callback(null, event.target.result); }; request.onerror = event => callback(event.target.error); }, "readwrite"); }, /** * Retrieve a specific contact from the data store. * * @param {String} guid String identifier of the contact to retrieve * @param {Function} callback Function that will be invoked once the operation * finished. The first argument passed will be an * `Error` object or `null`. The second argument will * be the contact object, if successful. * If no object matching guid could be found, * then the callback is called with both arguments * set to `null`. */ get: function(guid, callback) { LoopStorage.getStore(kObjectStoreName, (err, store) => { if (err) { callback(err); return; } let request; try { request = store.get(guid); } catch (ex) { callback(ex); return; } request.onsuccess = event => { if (!event.target.result) { callback(null, null); return; } let contact = extend({}, event.target.result); contact[kKeyPath] = guid; callback(null, contact); }; request.onerror = event => callback(event.target.error); }); }, /** * Retrieve a specific contact from the data store using the kServiceIdIndex * property. * * @param {String} serviceId String identifier of the contact to retrieve * @param {Function} callback Function that will be invoked once the operation * finished. The first argument passed will be an * `Error` object or `null`. The second argument will * be the contact object, if successfull. * If no object matching serviceId could be found, * then the callback is called with both arguments * set to `null`. */ getByServiceId: function(serviceId, callback) { LoopStorage.getStore(kObjectStoreName, (err, store) => { if (err) { callback(err); return; } let index = store.index(kServiceIdIndex); let request; try { request = index.get(serviceId); } catch (ex) { callback(ex); return; } request.onsuccess = event => { if (!event.target.result) { callback(null, null); return; } let contact = extend({}, event.target.result); callback(null, contact); }; request.onerror = event => callback(event.target.error); }); }, /** * Retrieve _all_ contacts from the data store. * CAUTION: If the amount of contacts is very large (say > 100000), this method * may slow down your application! * * @param {Function} callback Function that will be invoked once the operation * finished. The first argument passed will be an * `Error` object or `null`. The second argument will * be an `Array` of contact objects, if successfull. */ getAll: function(callback) { LoopStorage.getStore(kObjectStoreName, (err, store) => { if (err) { callback(err); return; } let cursorRequest = store.openCursor(); let contactsList = []; cursorRequest.onsuccess = event => { let cursor = event.target.result; // No more results, return the list. if (!cursor) { callback(null, contactsList); return; } let contact = extend({}, cursor.value); contact[kKeyPath] = cursor.key; contactsList.push(contact); cursor.continue(); }; cursorRequest.onerror = event => callback(event.target.error); }); }, /** * Retrieve an arbitrary amount of contacts from the data store. * CAUTION: If the amount of contacts is very large (say > 1000), this method * may slow down your application! * * @param {Array} guids List of contact IDs to retrieve contact objects of * @param {Function} callback Function that will be invoked once the operation * finished. The first argument passed will be an * `Error` object or `null`. The second argument will * be an `Array` of contact objects, if successfull. */ getMany: function(guids, callback) { let contacts = []; LoopStorage.asyncParallel(guids, (guid, next) => { this.get(guid, (err, contact) => { if (err) { next(err); return; } contacts.push(contact); next(); }); }, err => { callback(err, !err ? contacts : null); }); }, /** * Update a specific contact in the data store. * The contact object is modified by replacing the fields passed in the `details` * param and any fields not passed in are left unchanged. * * @param {Object} details An object that will be updated in the data store * as-is. Please read https://wiki.mozilla.org/Loop/Architecture/Address_Book * for more information of this objects' structure * @param {Function} callback Function that will be invoked once the operation * finished. The first argument passed will be an * `Error` object or `null`. The second argument will * be the contact object, if successfull. */ update: function(details, callback) { if (!(kKeyPath in details)) { callback(new Error("No '" + kKeyPath + "' field present")); return; } try { validateContact(details); } catch (ex) { callback(ex); return; } let guid = details[kKeyPath]; this.get(guid, (err, contact) => { if (err) { callback(err); return; } if (!contact) { callback(new Error("Contact with " + kKeyPath + " '" + guid + "' could not be found")); return; } LoopStorage.getStore(kObjectStoreName, (err, store) => { if (err) { callback(err); return; } let previous = extend({}, contact); // Update the contact with properties provided by `details`. extend(contact, details); details._date_lch = Date.now(); let request; try { request = store.put(contact); } catch (ex) { callback(ex); return; } request.onsuccess = event => { eventEmitter.emit("update", contact, previous); callback(null, event.target.result); }; request.onerror = event => callback(event.target.error); }, "readwrite"); }); }, /** * Block a specific contact in the data store. * * @param {String} guid String identifier of the contact to block * @param {Function} callback Function that will be invoked once the operation * finished. The first argument passed will be an * `Error` object or `null`. The second argument will * be the contact object, if successfull. */ block: function(guid, callback) { this.get(guid, (err, contact) => { if (err) { callback(err); return; } if (!contact) { callback(new Error("Contact with " + kKeyPath + " '" + guid + "' could not be found")); return; } contact.blocked = true; this.update(contact, callback); }); }, /** * Un-block a specific contact in the data store. * * @param {String} guid String identifier of the contact to unblock * @param {Function} callback Function that will be invoked once the operation * finished. The first argument passed will be an * `Error` object or `null`. The second argument will * be the contact object, if successfull. */ unblock: function(guid, callback) { this.get(guid, (err, contact) => { if (err) { callback(err); return; } if (!contact) { callback(new Error("Contact with " + kKeyPath + " '" + guid + "' could not be found")); return; } contact.blocked = false; this.update(contact, callback); }); }, /** * Import a list of (new) contacts from an external data source. * * @param {Object} options Property bag of options for the importer * @param {Function} callback Function that will be invoked once the operation * finished. The first argument passed will be an * `Error` object or `null`. The second argument will * be the result of the operation, if successfull. */ startImport: function(options, windowRef, callback) { 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, LoopContacts, windowRef); }, /** * Search through the data store for contacts that match a certain (sub-)string. * NB: The current implementation is very simple, naive if you will; we fetch * _all_ the contacts via `getAll()` and iterate over all of them to find * the contacts matching the supplied query (brute-force search in * exponential time). * * @param {Object} query Needle to search for in our haystack of contacts * @param {Function} callback Function that will be invoked once the operation * finished. The first argument passed will be an * `Error` object or `null`. The second argument will * be an `Array` of contact objects, if successfull. * * Example: * LoopContacts.search({ * q: "foo@bar.com", * field: "email" // 'email' is the default. * }, function(err, contacts) { * if (err) { * throw err; * } * console.dir(contacts); * }); */ search: function(query, callback) { if (!("q" in query) || !query.q) { callback(new Error("Nothing to search for. 'q' is required.")); return; } if (!("field" in query)) { query.field = "email"; } let queryValue = query.q; if (query.field == "tel") { queryValue = queryValue.replace(/[\D]+/g, ""); } const checkForMatch = function(fieldValue) { if (typeof fieldValue == "string") { if (query.field == "tel") { return fieldValue.replace(/[\D]+/g, "").endsWith(queryValue); } return fieldValue == queryValue; } if (typeof fieldValue == "number" || typeof fieldValue == "boolean") { return fieldValue == queryValue; } if ("value" in fieldValue) { return checkForMatch(fieldValue.value); } return false; }; let foundContacts = []; this.getAll((err, contacts) => { if (err) { callback(err); return; } for (let contact of contacts) { let matchWith = contact[query.field]; if (!matchWith) { continue; } // Many fields are defined as Arrays. if (Array.isArray(matchWith)) { for (let fieldValue of matchWith) { if (checkForMatch(fieldValue)) { foundContacts.push(contact); break; } } } else if (checkForMatch(matchWith)) { foundContacts.push(contact); } } callback(null, foundContacts); }); } }); /** * Public Loop Contacts API. * * LoopContacts implements the EventEmitter interface by exposing three methods - * `on`, `once` and `off` - to subscribe to events. * At this point the following events may be subscribed to: * - 'add': A new contact object was successfully added to the data store. * - 'remove': A contact was successfully removed from the data store. * - 'removeAll': All contacts were successfully removed from the data store. * - 'update': A contact object was successfully updated with changed * properties in the data store. */ this.LoopContacts = Object.freeze({ add: function(details, callback) { return LoopContactsInternal.add(details, callback); }, addMany: function(contacts, callback) { return LoopContactsInternal.addMany(contacts, callback); }, remove: function(guid, callback) { return LoopContactsInternal.remove(guid, callback); }, removeMany: function(guids, callback) { return LoopContactsInternal.removeMany(guids, callback); }, removeAll: function(callback) { return LoopContactsInternal.removeAll(callback); }, get: function(guid, callback) { return LoopContactsInternal.get(guid, callback); }, getByServiceId: function(serviceId, callback) { return LoopContactsInternal.getByServiceId(serviceId, callback); }, getAll: function(callback) { return LoopContactsInternal.getAll(callback); }, getMany: function(guids, callback) { return LoopContactsInternal.getMany(guids, callback); }, update: function(details, callback) { return LoopContactsInternal.update(details, callback); }, block: function(guid, callback) { return LoopContactsInternal.block(guid, callback); }, unblock: function(guid, callback) { return LoopContactsInternal.unblock(guid, callback); }, startImport: function(options, windowRef, callback) { return LoopContactsInternal.startImport(options, windowRef, callback); }, search: function(query, callback) { return LoopContactsInternal.search(query, callback); }, promise: function(method, ...params) { return new Promise((resolve, reject) => { this[method](...params, (error, result) => { if (error) { reject(error); } else { resolve(result); } }); }); }, on: (...params) => eventEmitter.on(...params), once: (...params) => eventEmitter.once(...params), off: (...params) => eventEmitter.off(...params) });