diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /comm/mail/components/extensions/parent/ext-addressBook.js | |
parent | Initial commit. (diff) | |
download | thunderbird-upstream.tar.xz thunderbird-upstream.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r-- | comm/mail/components/extensions/parent/ext-addressBook.js | 1587 |
1 files changed, 1587 insertions, 0 deletions
diff --git a/comm/mail/components/extensions/parent/ext-addressBook.js b/comm/mail/components/extensions/parent/ext-addressBook.js new file mode 100644 index 0000000000..14b0ce8cd0 --- /dev/null +++ b/comm/mail/components/extensions/parent/ext-addressBook.js @@ -0,0 +1,1587 @@ +/* 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/. */ + +var { MailServices } = ChromeUtils.import( + "resource:///modules/MailServices.jsm" +); + +var { AddrBookDirectory } = ChromeUtils.import( + "resource:///modules/AddrBookDirectory.jsm" +); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +XPCOMUtils.defineLazyGlobalGetters(this, ["fetch", "File", "FileReader"]); + +XPCOMUtils.defineLazyModuleGetters(this, { + newUID: "resource:///modules/AddrBookUtils.jsm", + AddrBookCard: "resource:///modules/AddrBookCard.jsm", + BANISHED_PROPERTIES: "resource:///modules/VCardUtils.jsm", + VCardProperties: "resource:///modules/VCardUtils.jsm", + VCardPropertyEntry: "resource:///modules/VCardUtils.jsm", + VCardUtils: "resource:///modules/VCardUtils.jsm", +}); + +// nsIAbCard.idl contains a list of properties that Thunderbird uses. Extensions are not +// restricted to using only these properties, but the following properties cannot +// be modified by an extension. +const hiddenProperties = [ + "DbRowID", + "LowercasePrimaryEmail", + "LastModifiedDate", + "PopularityIndex", + "RecordKey", + "UID", + "_etag", + "_href", + "_vCard", + "vCard", + "PhotoName", + "PhotoURL", + "PhotoType", +]; + +/** + * Reads a DOM File and returns a Promise for its dataUrl. + * + * @param {File} file + * @returns {string} + */ +function getDataUrl(file) { + return new Promise((resolve, reject) => { + var reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = function () { + resolve(reader.result); + }; + reader.onerror = function (error) { + reject(new ExtensionError(error)); + }; + }); +} + +/** + * Returns the image type of the given contentType string, or throws if the + * contentType is not an image type supported by the address book. + * + * @param {string} contentType - The contentType of a photo. + * @returns {string} - Either "png" or "jpeg". Throws otherwise. + */ +function getImageType(contentType) { + let typeParts = contentType.toLowerCase().split("/"); + if (typeParts[0] != "image" || !["jpeg", "png"].includes(typeParts[1])) { + throw new ExtensionError(`Unsupported image format: ${contentType}`); + } + return typeParts[1]; +} + +/** + * Adds a PHOTO VCardPropertyEntry for the given photo file. + * + * @param {VCardProperties} vCardProperties + * @param {File} photoFile + * @returns {VCardPropertyEntry} + */ +async function addVCardPhotoEntry(vCardProperties, photoFile) { + let dataUrl = await getDataUrl(photoFile); + if (vCardProperties.getFirstValue("version") == "4.0") { + vCardProperties.addEntry( + new VCardPropertyEntry("photo", {}, "url", dataUrl) + ); + } else { + // If vCard version is not 4.0, default to 3.0. + vCardProperties.addEntry( + new VCardPropertyEntry( + "photo", + { encoding: "B", type: getImageType(photoFile.type).toUpperCase() }, + "binary", + dataUrl.substring(dataUrl.indexOf(",") + 1) + ) + ); + } +} + +/** + * Returns a DOM File object for the contact photo of the given contact. + * + * @param {string} id - The id of the contact + * @returns {File} The photo of the contact, or null. + */ +async function getPhotoFile(id) { + let { item } = addressBookCache.findContactById(id); + let photoUrl = item.photoURL; + if (!photoUrl) { + return null; + } + + try { + if (photoUrl.startsWith("file://")) { + let realFile = Services.io + .newURI(photoUrl) + .QueryInterface(Ci.nsIFileURL).file; + let file = await File.createFromNsIFile(realFile); + let type = getImageType(file.type); + // Clone the File object to be able to give it the correct name, matching + // the dataUrl/webUrl code path below. + return new File([file], `${id}.${type}`, { type: `image/${type}` }); + } + + // Retrieve dataUrls or webUrls. + let result = await fetch(photoUrl); + let type = getImageType(result.headers.get("content-type")); + let blob = await result.blob(); + return new File([blob], `${id}.${type}`, { type: `image/${type}` }); + } catch (ex) { + console.error(`Failed to read photo information for ${id}: ` + ex); + } + + return null; +} + +/** + * Sets the provided file as the primary photo of the given contact. + * + * @param {string} id - The id of the contact + * @param {File} file - The new photo + */ +async function setPhotoFile(id, file) { + let node = addressBookCache.findContactById(id); + let vCardProperties = vCardPropertiesFromCard(node.item); + + try { + let type = getImageType(file.type); + + // If the contact already has a photoUrl, replace it with the same url type. + // Otherwise save the photo as a local file, except for CardDAV contacts. + let photoUrl = node.item.photoURL; + let parentNode = addressBookCache.findAddressBookById(node.parentId); + let useFile = photoUrl + ? photoUrl.startsWith("file://") + : parentNode.item.dirType != Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE; + + if (useFile) { + let oldPhotoFile; + if (photoUrl) { + try { + oldPhotoFile = Services.io + .newURI(photoUrl) + .QueryInterface(Ci.nsIFileURL).file; + } catch (ex) { + console.error(`Ignoring invalid photoUrl ${photoUrl}: ` + ex); + } + } + let pathPhotoFile = await IOUtils.createUniqueFile( + PathUtils.join(PathUtils.profileDir, "Photos"), + `${id}.${type}`, + 0o600 + ); + + if (file.mozFullPath) { + // The file object was created by selecting a real file through a file + // picker and is directly linked to a local file. Do a low level copy. + await IOUtils.copy(file.mozFullPath, pathPhotoFile); + } else { + // The file object is a data blob. Dump it into a real file. + let buffer = await file.arrayBuffer(); + await IOUtils.write(pathPhotoFile, new Uint8Array(buffer)); + } + + // Set the PhotoName. + node.item.setProperty("PhotoName", PathUtils.filename(pathPhotoFile)); + + // Delete the old photo file. + if (oldPhotoFile?.exists()) { + try { + await IOUtils.remove(oldPhotoFile.path); + } catch (ex) { + console.error(`Failed to delete old photo file for ${id}: ` + ex); + } + } + } else { + // Follow the UI and replace the entire entry. + vCardProperties.clearValues("photo"); + await addVCardPhotoEntry(vCardProperties, file); + } + parentNode.item.modifyCard(node.item); + } catch (ex) { + throw new ExtensionError( + `Failed to read new photo information for ${id}: ` + ex + ); + } +} + +/** + * Gets the VCardProperties of the given card either directly or by reconstructing + * from a set of flat standard properties. + * + * @param {nsIAbCard/AddrBookCard} card + * @returns {VCardProperties} + */ +function vCardPropertiesFromCard(card) { + if (card.supportsVCard) { + return card.vCardProperties; + } + return VCardProperties.fromPropertyMap( + new Map(Array.from(card.properties, p => [p.name, p.value])) + ); +} + +/** + * Creates a new AddrBookCard from a set of flat standard properties. + * + * @param {ContactProperties} properties - a key/value properties object + * @param {string} uid - optional UID for the card + * @returns {AddrBookCard} + */ +function flatPropertiesToAbCard(properties, uid) { + // Do not use VCardUtils.propertyMapToVCard(). + let vCard = VCardProperties.fromPropertyMap( + new Map(Object.entries(properties)) + ).toVCard(); + return VCardUtils.vCardToAbCard(vCard, uid); +} + +/** + * Checks if the given property is a custom contact property, which can be exposed + * to WebExtensions. + * + * @param {string} name - property name + * @returns {boolean} + */ +function isCustomProperty(name) { + return ( + !hiddenProperties.includes(name) && + !BANISHED_PROPERTIES.includes(name) && + name.match(/^\w+$/) + ); +} + +/** + * Adds the provided originalProperties to the card, adjusted by the changes + * given in updateProperties. All banished properties are skipped and the updated + * properties must be valid according to isCustomProperty(). + * + * @param {AddrBookCard} card - a card to receive the provided properties + * @param {ContactProperties} updateProperties - a key/value object with properties + * to update the provided originalProperties + * @param {nsIProperties} originalProperties - properties to be cloned onto + * the provided card + */ +function addProperties(card, updateProperties, originalProperties) { + let updates = Object.entries(updateProperties).filter(e => + isCustomProperty(e[0]) + ); + let mergedProperties = originalProperties + ? new Map([ + ...Array.from(originalProperties, p => [p.name, p.value]), + ...updates, + ]) + : new Map(updates); + + for (let [name, value] of mergedProperties) { + if ( + !BANISHED_PROPERTIES.includes(name) && + value != "" && + value != null && + value != undefined + ) { + card.setProperty(name, value); + } + } +} + +/** + * Address book that supports finding cards only for a search (like LDAP). + * + * @implements {nsIAbDirectory} + */ +class ExtSearchBook extends AddrBookDirectory { + constructor(fire, context, args = {}) { + super(); + this.fire = fire; + this._readOnly = true; + this._isSecure = Boolean(args.isSecure); + this._dirName = String(args.addressBookName ?? context.extension.name); + this._fileName = ""; + this._uid = String(args.id ?? newUID()); + this._uri = "searchaddr://" + this.UID; + this.lastModifiedDate = 0; + this.isMailList = false; + this.listNickName = ""; + this.description = ""; + this._dirPrefId = ""; + } + /** + * @see {AddrBookDirectory} + */ + get lists() { + return new Map(); + } + /** + * @see {AddrBookDirectory} + */ + get cards() { + return new Map(); + } + // nsIAbDirectory + get isRemote() { + return true; + } + get isSecure() { + return this._isSecure; + } + getCardFromProperty(aProperty, aValue, aCaseSensitive) { + return null; + } + getCardsFromProperty(aProperty, aValue, aCaseSensitive) { + return []; + } + get dirType() { + return Ci.nsIAbManager.ASYNC_DIRECTORY_TYPE; + } + get position() { + return 0; + } + get childCardCount() { + return 0; + } + useForAutocomplete(aIdentityKey) { + // AddrBookDirectory defaults to true + return false; + } + get supportsMailingLists() { + return false; + } + setLocalizedStringValue(aName, aValue) {} + async search(aQuery, aSearchString, aListener) { + try { + if (this.fire.wakeup) { + await this.fire.wakeup(); + } + let { results, isCompleteResult } = await this.fire.async( + await addressBookCache.convert( + addressBookCache.addressBooks.get(this.UID) + ), + aSearchString, + aQuery + ); + for (let resultData of results) { + let card; + // A specified vCard is winning over any individual standard property. + if (resultData.vCard) { + try { + card = VCardUtils.vCardToAbCard(resultData.vCard); + } catch (ex) { + throw new ExtensionError( + `Invalid vCard data: ${resultData.vCard}.` + ); + } + } else { + card = flatPropertiesToAbCard(resultData); + } + // Add custom properties to the property bag. + addProperties(card, resultData); + card.directoryUID = this.UID; + aListener.onSearchFoundCard(card); + } + aListener.onSearchFinished(Cr.NS_OK, isCompleteResult, null, ""); + } catch (ex) { + aListener.onSearchFinished( + ex.result || Cr.NS_ERROR_FAILURE, + true, + null, + "" + ); + } + } +} + +/** + * Cache of items in the address book "tree". + * + * @implements {nsIObserver} + */ +var addressBookCache = new (class extends EventEmitter { + constructor() { + super(); + this.listenerCount = 0; + this.flush(); + } + _makeContactNode(contact, parent) { + contact.QueryInterface(Ci.nsIAbCard); + return { + id: contact.UID, + parentId: parent.UID, + type: "contact", + item: contact, + }; + } + _makeDirectoryNode(directory, parent = null) { + directory.QueryInterface(Ci.nsIAbDirectory); + let node = { + id: directory.UID, + type: directory.isMailList ? "mailingList" : "addressBook", + item: directory, + }; + if (parent) { + node.parentId = parent.UID; + } + return node; + } + _populateListContacts(mailingList) { + mailingList.contacts = new Map(); + for (let contact of mailingList.item.childCards) { + let newNode = this._makeContactNode(contact, mailingList.item); + mailingList.contacts.set(newNode.id, newNode); + } + } + getListContacts(mailingList) { + if (!mailingList.contacts) { + this._populateListContacts(mailingList); + } + return [...mailingList.contacts.values()]; + } + _populateContacts(addressBook) { + addressBook.contacts = new Map(); + for (let contact of addressBook.item.childCards) { + if (!contact.isMailList) { + let newNode = this._makeContactNode(contact, addressBook.item); + this._contacts.set(newNode.id, newNode); + addressBook.contacts.set(newNode.id, newNode); + } + } + } + getContacts(addressBook) { + if (!addressBook.contacts) { + this._populateContacts(addressBook); + } + return [...addressBook.contacts.values()]; + } + _populateMailingLists(parent) { + parent.mailingLists = new Map(); + for (let mailingList of parent.item.childNodes) { + let newNode = this._makeDirectoryNode(mailingList, parent.item); + this._mailingLists.set(newNode.id, newNode); + parent.mailingLists.set(newNode.id, newNode); + } + } + getMailingLists(parent) { + if (!parent.mailingLists) { + this._populateMailingLists(parent); + } + return [...parent.mailingLists.values()]; + } + get addressBooks() { + if (!this._addressBooks) { + this._addressBooks = new Map(); + for (let tld of MailServices.ab.directories) { + this._addressBooks.set(tld.UID, this._makeDirectoryNode(tld)); + } + } + return this._addressBooks; + } + flush() { + this._contacts = new Map(); + this._mailingLists = new Map(); + this._addressBooks = null; + } + findAddressBookById(id) { + let addressBook = this.addressBooks.get(id); + if (addressBook) { + return addressBook; + } + throw new ExtensionUtils.ExtensionError( + `addressBook with id=${id} could not be found.` + ); + } + findMailingListById(id) { + if (this._mailingLists.has(id)) { + return this._mailingLists.get(id); + } + for (let addressBook of this.addressBooks.values()) { + if (!addressBook.mailingLists) { + this._populateMailingLists(addressBook); + if (addressBook.mailingLists.has(id)) { + return addressBook.mailingLists.get(id); + } + } + } + throw new ExtensionUtils.ExtensionError( + `mailingList with id=${id} could not be found.` + ); + } + findContactById(id, bookHint) { + if (this._contacts.has(id)) { + return this._contacts.get(id); + } + if (bookHint && !bookHint.contacts) { + this._populateContacts(bookHint); + if (bookHint.contacts.has(id)) { + return bookHint.contacts.get(id); + } + } + for (let addressBook of this.addressBooks.values()) { + if (!addressBook.contacts) { + this._populateContacts(addressBook); + if (addressBook.contacts.has(id)) { + return addressBook.contacts.get(id); + } + } + } + throw new ExtensionUtils.ExtensionError( + `contact with id=${id} could not be found.` + ); + } + async convert(node, complete) { + if (node === null) { + return node; + } + if (Array.isArray(node)) { + let cards = await Promise.allSettled( + node.map(i => this.convert(i, complete)) + ); + return cards.filter(card => card.value).map(card => card.value); + } + + let copy = {}; + for (let key of ["id", "parentId", "type"]) { + if (key in node) { + copy[key] = node[key]; + } + } + + if (complete) { + if (node.type == "addressBook") { + copy.mailingLists = await this.convert( + this.getMailingLists(node), + true + ); + copy.contacts = await this.convert(this.getContacts(node), true); + } + if (node.type == "mailingList") { + copy.contacts = await this.convert(this.getListContacts(node), true); + } + } + + switch (node.type) { + case "addressBook": + copy.name = node.item.dirName; + copy.readOnly = node.item.readOnly; + copy.remote = node.item.isRemote; + break; + case "contact": { + // Clone the vCardProperties of this contact, so we can manipulate them + // for the WebExtension, but do not actually change the stored data. + let vCardProperties = vCardPropertiesFromCard(node.item).clone(); + copy.properties = {}; + + // Build a flat property list from vCardProperties. + for (let [name, value] of vCardProperties.toPropertyMap()) { + copy.properties[name] = "" + value; + } + + // Return all other exposed properties stored in the nodes property bag. + for (let property of Array.from(node.item.properties).filter(e => + isCustomProperty(e.name) + )) { + copy.properties[property.name] = "" + property.value; + } + + // If this card has no photo vCard entry, but a local photo, add it to its vCard: Thunderbird + // does not store photos of local address books in the internal _vCard property, to reduce + // the amount of data stored in its database. + let photoName = node.item.getProperty("PhotoName", ""); + let vCardPhoto = vCardProperties.getFirstValue("photo"); + if (!vCardPhoto && photoName) { + try { + let realPhotoFile = Services.dirsvc.get("ProfD", Ci.nsIFile); + realPhotoFile.append("Photos"); + realPhotoFile.append(photoName); + let photoFile = await File.createFromNsIFile(realPhotoFile); + await addVCardPhotoEntry(vCardProperties, photoFile); + } catch (ex) { + console.error( + `Failed to read photo information for ${node.id}: ` + ex + ); + } + } + + // Add the vCard. + copy.properties.vCard = vCardProperties.toVCard(); + + let parentNode; + try { + parentNode = this.findAddressBookById(node.parentId); + } catch (ex) { + // Parent might be a mailing list. + parentNode = this.findMailingListById(node.parentId); + } + copy.readOnly = parentNode.item.readOnly; + copy.remote = parentNode.item.isRemote; + break; + } + case "mailingList": + copy.name = node.item.dirName; + copy.nickName = node.item.listNickName; + copy.description = node.item.description; + let parentNode = this.findAddressBookById(node.parentId); + copy.readOnly = parentNode.item.readOnly; + copy.remote = parentNode.item.isRemote; + break; + } + + return copy; + } + + // nsIObserver + _notifications = [ + "addrbook-directory-created", + "addrbook-directory-updated", + "addrbook-directory-deleted", + "addrbook-contact-created", + "addrbook-contact-properties-updated", + "addrbook-contact-deleted", + "addrbook-list-created", + "addrbook-list-updated", + "addrbook-list-deleted", + "addrbook-list-member-added", + "addrbook-list-member-removed", + ]; + + observe(subject, topic, data) { + switch (topic) { + case "addrbook-directory-created": { + subject.QueryInterface(Ci.nsIAbDirectory); + + let newNode = this._makeDirectoryNode(subject); + if (this._addressBooks) { + this._addressBooks.set(newNode.id, newNode); + } + + this.emit("address-book-created", newNode); + break; + } + case "addrbook-directory-updated": { + subject.QueryInterface(Ci.nsIAbDirectory); + + this.emit("address-book-updated", this._makeDirectoryNode(subject)); + break; + } + case "addrbook-directory-deleted": { + subject.QueryInterface(Ci.nsIAbDirectory); + + let uid = subject.UID; + if (this._addressBooks?.has(uid)) { + let parentNode = this._addressBooks.get(uid); + if (parentNode.contacts) { + for (let id of parentNode.contacts.keys()) { + this._contacts.delete(id); + } + } + if (parentNode.mailingLists) { + for (let id of parentNode.mailingLists.keys()) { + this._mailingLists.delete(id); + } + } + this._addressBooks.delete(uid); + } + + this.emit("address-book-deleted", uid); + break; + } + case "addrbook-contact-created": { + subject.QueryInterface(Ci.nsIAbCard); + + let parent = MailServices.ab.getDirectoryFromUID(data); + let newNode = this._makeContactNode(subject, parent); + if (this._addressBooks?.has(data)) { + let parentNode = this._addressBooks.get(data); + if (parentNode.contacts) { + parentNode.contacts.set(newNode.id, newNode); + } + this._contacts.set(newNode.id, newNode); + } + + this.emit("contact-created", newNode); + break; + } + case "addrbook-contact-properties-updated": { + subject.QueryInterface(Ci.nsIAbCard); + + let parentUID = subject.directoryUID; + let parent = MailServices.ab.getDirectoryFromUID(parentUID); + let newNode = this._makeContactNode(subject, parent); + if (this._addressBooks?.has(parentUID)) { + let parentNode = this._addressBooks.get(parentUID); + if (parentNode.contacts) { + parentNode.contacts.set(newNode.id, newNode); + this._contacts.set(newNode.id, newNode); + } + if (parentNode.mailingLists) { + for (let mailingList of parentNode.mailingLists.values()) { + if ( + mailingList.contacts && + mailingList.contacts.has(newNode.id) + ) { + mailingList.contacts.get(newNode.id).item = subject; + } + } + } + } + + this.emit("contact-updated", newNode, JSON.parse(data)); + break; + } + case "addrbook-contact-deleted": { + subject.QueryInterface(Ci.nsIAbCard); + + let uid = subject.UID; + this._contacts.delete(uid); + if (this._addressBooks?.has(data)) { + let parentNode = this._addressBooks.get(data); + if (parentNode.contacts) { + parentNode.contacts.delete(uid); + } + } + + this.emit("contact-deleted", data, uid); + break; + } + case "addrbook-list-created": { + subject.QueryInterface(Ci.nsIAbDirectory); + + let parent = MailServices.ab.getDirectoryFromUID(data); + let newNode = this._makeDirectoryNode(subject, parent); + if (this._addressBooks?.has(data)) { + let parentNode = this._addressBooks.get(data); + if (parentNode.mailingLists) { + parentNode.mailingLists.set(newNode.id, newNode); + } + this._mailingLists.set(newNode.id, newNode); + } + + this.emit("mailing-list-created", newNode); + break; + } + case "addrbook-list-updated": { + subject.QueryInterface(Ci.nsIAbDirectory); + + let listNode = this.findMailingListById(subject.UID); + listNode.item = subject; + + this.emit("mailing-list-updated", listNode); + break; + } + case "addrbook-list-deleted": { + subject.QueryInterface(Ci.nsIAbDirectory); + + let uid = subject.UID; + this._mailingLists.delete(uid); + if (this._addressBooks?.has(data)) { + let parentNode = this._addressBooks.get(data); + if (parentNode.mailingLists) { + parentNode.mailingLists.delete(uid); + } + } + + this.emit("mailing-list-deleted", data, uid); + break; + } + case "addrbook-list-member-added": { + subject.QueryInterface(Ci.nsIAbCard); + + let parentNode = this.findMailingListById(data); + let newNode = this._makeContactNode(subject, parentNode.item); + if ( + this._mailingLists.has(data) && + this._mailingLists.get(data).contacts + ) { + this._mailingLists.get(data).contacts.set(newNode.id, newNode); + } + this.emit("mailing-list-member-added", newNode); + break; + } + case "addrbook-list-member-removed": { + subject.QueryInterface(Ci.nsIAbCard); + + let uid = subject.UID; + if (this._mailingLists.has(data)) { + let parentNode = this._mailingLists.get(data); + if (parentNode.contacts) { + parentNode.contacts.delete(uid); + } + } + + this.emit("mailing-list-member-removed", data, uid); + break; + } + } + } + + incrementListeners() { + this.listenerCount++; + if (this.listenerCount == 1) { + for (let topic of this._notifications) { + Services.obs.addObserver(this, topic); + } + } + } + decrementListeners() { + this.listenerCount--; + if (this.listenerCount == 0) { + for (let topic of this._notifications) { + Services.obs.removeObserver(this, topic); + } + + this.flush(); + } + } +})(); + +this.addressBook = class extends ExtensionAPIPersistent { + PERSISTENT_EVENTS = { + // For primed persistent events (deactivated background), the context is only + // available after fire.wakeup() has fulfilled (ensuring the convert() function + // has been called). + + // addressBooks.* + onAddressBookCreated({ context, fire }) { + let listener = async (event, node) => { + if (fire.wakeup) { + await fire.wakeup(); + } + fire.sync(await addressBookCache.convert(node)); + }; + addressBookCache.on("address-book-created", listener); + return { + unregister: () => { + addressBookCache.off("address-book-created", listener); + }, + convert(newFire, extContext) { + fire = newFire; + context = extContext; + }, + }; + }, + onAddressBookUpdated({ context, fire }) { + let listener = async (event, node) => { + if (fire.wakeup) { + await fire.wakeup(); + } + fire.sync(await addressBookCache.convert(node)); + }; + addressBookCache.on("address-book-updated", listener); + return { + unregister: () => { + addressBookCache.off("address-book-updated", listener); + }, + convert(newFire, extContext) { + fire = newFire; + context = extContext; + }, + }; + }, + onAddressBookDeleted({ context, fire }) { + let listener = async (event, itemUID) => { + if (fire.wakeup) { + await fire.wakeup(); + } + fire.sync(itemUID); + }; + addressBookCache.on("address-book-deleted", listener); + return { + unregister: () => { + addressBookCache.off("address-book-deleted", listener); + }, + convert(newFire, extContext) { + fire = newFire; + context = extContext; + }, + }; + }, + + // contacts.* + onContactCreated({ context, fire }) { + let listener = async (event, node) => { + if (fire.wakeup) { + await fire.wakeup(); + } + fire.sync(await addressBookCache.convert(node)); + }; + addressBookCache.on("contact-created", listener); + return { + unregister: () => { + addressBookCache.off("contact-created", listener); + }, + convert(newFire, extContext) { + fire = newFire; + context = extContext; + }, + }; + }, + onContactUpdated({ context, fire }) { + let listener = async (event, node, changes) => { + if (fire.wakeup) { + await fire.wakeup(); + } + let filteredChanges = {}; + // Find changes in flat properties stored in the vCard. + if (changes.hasOwnProperty("_vCard")) { + let oldVCardProperties = VCardProperties.fromVCard( + changes._vCard.oldValue + ).toPropertyMap(); + let newVCardProperties = VCardProperties.fromVCard( + changes._vCard.newValue + ).toPropertyMap(); + for (let [name, value] of oldVCardProperties) { + if (newVCardProperties.get(name) != value) { + filteredChanges[name] = { + oldValue: value, + newValue: newVCardProperties.get(name) ?? null, + }; + } + } + for (let [name, value] of newVCardProperties) { + if ( + !filteredChanges.hasOwnProperty(name) && + oldVCardProperties.get(name) != value + ) { + filteredChanges[name] = { + oldValue: oldVCardProperties.get(name) ?? null, + newValue: value, + }; + } + } + } + for (let [name, value] of Object.entries(changes)) { + if (!filteredChanges.hasOwnProperty(name) && isCustomProperty(name)) { + filteredChanges[name] = value; + } + } + fire.sync(await addressBookCache.convert(node), filteredChanges); + }; + addressBookCache.on("contact-updated", listener); + return { + unregister: () => { + addressBookCache.off("contact-updated", listener); + }, + convert(newFire, extContext) { + fire = newFire; + context = extContext; + }, + }; + }, + onContactDeleted({ context, fire }) { + let listener = async (event, parentUID, itemUID) => { + if (fire.wakeup) { + await fire.wakeup(); + } + fire.sync(parentUID, itemUID); + }; + addressBookCache.on("contact-deleted", listener); + return { + unregister: () => { + addressBookCache.off("contact-deleted", listener); + }, + convert(newFire, extContext) { + fire = newFire; + context = extContext; + }, + }; + }, + + // mailingLists.* + onMailingListCreated({ context, fire }) { + let listener = async (event, node) => { + fire.sync(await addressBookCache.convert(node)); + }; + addressBookCache.on("mailing-list-created", listener); + return { + unregister: () => { + addressBookCache.off("mailing-list-created", listener); + }, + convert(newFire, extContext) { + fire = newFire; + context = extContext; + }, + }; + }, + onMailingListUpdated({ context, fire }) { + let listener = async (event, node) => { + if (fire.wakeup) { + await fire.wakeup(); + } + fire.sync(await addressBookCache.convert(node)); + }; + addressBookCache.on("mailing-list-updated", listener); + return { + unregister: () => { + addressBookCache.off("mailing-list-updated", listener); + }, + convert(newFire, extContext) { + fire = newFire; + context = extContext; + }, + }; + }, + onMailingListDeleted({ context, fire }) { + let listener = async (event, parentUID, itemUID) => { + if (fire.wakeup) { + await fire.wakeup(); + } + fire.sync(parentUID, itemUID); + }; + addressBookCache.on("mailing-list-deleted", listener); + return { + unregister: () => { + addressBookCache.off("mailing-list-deleted", listener); + }, + convert(newFire, extContext) { + fire = newFire; + context = extContext; + }, + }; + }, + onMemberAdded({ context, fire }) { + let listener = async (event, node) => { + if (fire.wakeup) { + await fire.wakeup(); + } + fire.sync(await addressBookCache.convert(node)); + }; + addressBookCache.on("mailing-list-member-added", listener); + return { + unregister: () => { + addressBookCache.off("mailing-list-member-added", listener); + }, + convert(newFire, extContext) { + fire = newFire; + context = extContext; + }, + }; + }, + onMemberRemoved({ context, fire }) { + let listener = async (event, parentUID, itemUID) => { + if (fire.wakeup) { + await fire.wakeup(); + } + fire.sync(parentUID, itemUID); + }; + addressBookCache.on("mailing-list-member-removed", listener); + return { + unregister: () => { + addressBookCache.off("mailing-list-member-removed", listener); + }, + convert(newFire, extContext) { + fire = newFire; + context = extContext; + }, + }; + }, + }; + + constructor(...args) { + super(...args); + addressBookCache.incrementListeners(); + } + + onShutdown() { + addressBookCache.decrementListeners(); + } + + getAPI(context) { + let { extension } = context; + let { tabManager } = extension; + + return { + addressBooks: { + async openUI() { + let messengerWindow = windowTracker.topNormalWindow; + let abWindow = await messengerWindow.toAddressBook(); + await new Promise(resolve => abWindow.setTimeout(resolve)); + let abTab = messengerWindow.document + .getElementById("tabmail") + .tabInfo.find(t => t.mode.name == "addressBookTab"); + return tabManager.convert(abTab); + }, + async closeUI() { + for (let win of Services.wm.getEnumerator("mail:3pane")) { + let tabmail = win.document.getElementById("tabmail"); + for (let tab of tabmail.tabInfo.slice()) { + if (tab.browser?.currentURI.spec == "about:addressbook") { + tabmail.closeTab(tab); + } + } + } + }, + + list(complete = false) { + return addressBookCache.convert( + [...addressBookCache.addressBooks.values()], + complete + ); + }, + get(id, complete = false) { + return addressBookCache.convert( + addressBookCache.findAddressBookById(id), + complete + ); + }, + create({ name }) { + let dirName = MailServices.ab.newAddressBook( + name, + "", + Ci.nsIAbManager.JS_DIRECTORY_TYPE + ); + let directory = MailServices.ab.getDirectoryFromId(dirName); + return directory.UID; + }, + update(id, { name }) { + let node = addressBookCache.findAddressBookById(id); + node.item.dirName = name; + }, + async delete(id) { + let node = addressBookCache.findAddressBookById(id); + let deletePromise = new Promise(resolve => { + let listener = () => { + addressBookCache.off("address-book-deleted", listener); + resolve(); + }; + addressBookCache.on("address-book-deleted", listener); + }); + MailServices.ab.deleteAddressBook(node.item.URI); + await deletePromise; + }, + + // The module name is addressBook as defined in ext-mail.json. + onCreated: new EventManager({ + context, + module: "addressBook", + event: "onAddressBookCreated", + extensionApi: this, + }).api(), + onUpdated: new EventManager({ + context, + module: "addressBook", + event: "onAddressBookUpdated", + extensionApi: this, + }).api(), + onDeleted: new EventManager({ + context, + module: "addressBook", + event: "onAddressBookDeleted", + extensionApi: this, + }).api(), + + provider: { + onSearchRequest: new EventManager({ + context, + name: "addressBooks.provider.onSearchRequest", + register: (fire, args) => { + if (addressBookCache.addressBooks.has(args.id)) { + throw new ExtensionUtils.ExtensionError( + `addressBook with id=${args.id} already exists.` + ); + } + let dir = new ExtSearchBook(fire, context, args); + dir.init(); + MailServices.ab.addAddressBook(dir); + return () => { + MailServices.ab.deleteAddressBook(dir.URI); + }; + }, + }).api(), + }, + }, + contacts: { + list(parentId) { + let parentNode = addressBookCache.findAddressBookById(parentId); + return addressBookCache.convert( + addressBookCache.getContacts(parentNode), + false + ); + }, + async quickSearch(parentId, queryInfo) { + const { getSearchTokens, getModelQuery, generateQueryURI } = + ChromeUtils.import("resource:///modules/ABQueryUtils.jsm"); + + let searchString; + if (typeof queryInfo == "string") { + searchString = queryInfo; + queryInfo = { + includeRemote: true, + includeLocal: true, + includeReadOnly: true, + includeReadWrite: true, + }; + } else { + searchString = queryInfo.searchString; + } + + let searchWords = getSearchTokens(searchString); + if (searchWords.length == 0) { + return []; + } + let searchFormat = getModelQuery( + "mail.addr_book.quicksearchquery.format" + ); + let searchQuery = generateQueryURI(searchFormat, searchWords); + + let booksToSearch; + if (parentId == null) { + booksToSearch = [...addressBookCache.addressBooks.values()]; + } else { + booksToSearch = [addressBookCache.findAddressBookById(parentId)]; + } + + let results = []; + let promises = []; + for (let book of booksToSearch) { + if ( + (book.item.isRemote && !queryInfo.includeRemote) || + (!book.item.isRemote && !queryInfo.includeLocal) || + (book.item.readOnly && !queryInfo.includeReadOnly) || + (!book.item.readOnly && !queryInfo.includeReadWrite) + ) { + continue; + } + promises.push( + new Promise(resolve => { + book.item.search(searchQuery, searchString, { + onSearchFinished(status, complete, secInfo, location) { + resolve(); + }, + onSearchFoundCard(contact) { + if (contact.isMailList) { + return; + } + results.push( + addressBookCache._makeContactNode(contact, book.item) + ); + }, + }); + }) + ); + } + await Promise.all(promises); + + return addressBookCache.convert(results, false); + }, + get(id) { + return addressBookCache.convert( + addressBookCache.findContactById(id), + false + ); + }, + async getPhoto(id) { + return getPhotoFile(id); + }, + async setPhoto(id, file) { + return setPhotoFile(id, file); + }, + create(parentId, id, createData) { + let parentNode = addressBookCache.findAddressBookById(parentId); + if (parentNode.item.readOnly) { + throw new ExtensionUtils.ExtensionError( + "Cannot create a contact in a read-only address book" + ); + } + + let card; + // A specified vCard is winning over any individual standard property. + if (createData.vCard) { + try { + card = VCardUtils.vCardToAbCard(createData.vCard, id); + } catch (ex) { + throw new ExtensionError( + `Invalid vCard data: ${createData.vCard}.` + ); + } + } else { + card = flatPropertiesToAbCard(createData, id); + } + // Add custom properties to the property bag. + addProperties(card, createData); + + // Check if the new card has an enforced UID. + if (card.vCardProperties.getFirstValue("uid")) { + let duplicateExists = false; + try { + // Second argument is only a hint, all address books are checked. + addressBookCache.findContactById(card.UID, parentId); + duplicateExists = true; + } catch (ex) { + // Do nothing. We want this to throw because no contact was found. + } + if (duplicateExists) { + throw new ExtensionError(`Duplicate contact id: ${card.UID}`); + } + } + + let newCard = parentNode.item.addCard(card); + return newCard.UID; + }, + update(id, updateData) { + let node = addressBookCache.findContactById(id); + let parentNode = addressBookCache.findAddressBookById(node.parentId); + if (parentNode.item.readOnly) { + throw new ExtensionUtils.ExtensionError( + "Cannot modify a contact in a read-only address book" + ); + } + + // A specified vCard is winning over any individual standard property. + // While a vCard is replacing the entire contact, specified standard + // properties only update single entries (setting a value to null + // clears it / promotes the next value of the same kind). + let card; + if (updateData.vCard) { + let vCardUID; + try { + card = new AddrBookCard(); + card.UID = node.item.UID; + card.setProperty( + "_vCard", + VCardUtils.translateVCard21(updateData.vCard) + ); + vCardUID = card.vCardProperties.getFirstValue("uid"); + } catch (ex) { + throw new ExtensionError( + `Invalid vCard data: ${updateData.vCard}.` + ); + } + if (vCardUID && vCardUID != node.item.UID) { + throw new ExtensionError( + `The card's UID ${node.item.UID} may not be changed: ${updateData.vCard}.` + ); + } + } else { + // Get the current vCardProperties, build a propertyMap and create + // vCardParsed which allows to identify all currently exposed entries + // based on the typeName used in VCardUtils.jsm (e.g. adr.work). + let vCardProperties = vCardPropertiesFromCard(node.item); + let vCardParsed = VCardUtils._parse(vCardProperties.entries); + let propertyMap = vCardProperties.toPropertyMap(); + + // Save the old exposed state. + let oldProperties = VCardProperties.fromPropertyMap(propertyMap); + let oldParsed = VCardUtils._parse(oldProperties.entries); + // Update the propertyMap. + for (let [name, value] of Object.entries(updateData)) { + propertyMap.set(name, value); + } + // Save the new exposed state. + let newProperties = VCardProperties.fromPropertyMap(propertyMap); + let newParsed = VCardUtils._parse(newProperties.entries); + + // Evaluate the differences and update the still existing entries, + // mark removed items for deletion. + let deleteLog = []; + for (let typeName of oldParsed.keys()) { + if (typeName == "version") { + continue; + } + for (let idx = 0; idx < oldParsed.get(typeName).length; idx++) { + if ( + newParsed.has(typeName) && + idx < newParsed.get(typeName).length + ) { + let originalIndex = vCardParsed.get(typeName)[idx].index; + let newEntryIndex = newParsed.get(typeName)[idx].index; + vCardProperties.entries[originalIndex] = + newProperties.entries[newEntryIndex]; + // Mark this item as handled. + newParsed.get(typeName)[idx] = null; + } else { + deleteLog.push(vCardParsed.get(typeName)[idx].index); + } + } + } + + // Remove entries which have been marked for deletion. + for (let deleteIndex of deleteLog.sort((a, b) => a < b)) { + vCardProperties.entries.splice(deleteIndex, 1); + } + + // Add new entries. + for (let typeName of newParsed.keys()) { + if (typeName == "version") { + continue; + } + for (let newEntry of newParsed.get(typeName)) { + if (newEntry) { + vCardProperties.addEntry( + newProperties.entries[newEntry.index] + ); + } + } + } + + // Create a new card with the original UID from the updated vCardProperties. + card = VCardUtils.vCardToAbCard( + vCardProperties.toVCard(), + node.item.UID + ); + } + + // Clone original properties and update custom properties. + addProperties(card, updateData, node.item.properties); + + parentNode.item.modifyCard(card); + }, + delete(id) { + let node = addressBookCache.findContactById(id); + let parentNode = addressBookCache.findAddressBookById(node.parentId); + if (parentNode.item.readOnly) { + throw new ExtensionUtils.ExtensionError( + "Cannot delete a contact in a read-only address book" + ); + } + + parentNode.item.deleteCards([node.item]); + }, + + // The module name is addressBook as defined in ext-mail.json. + onCreated: new EventManager({ + context, + module: "addressBook", + event: "onContactCreated", + extensionApi: this, + }).api(), + onUpdated: new EventManager({ + context, + module: "addressBook", + event: "onContactUpdated", + extensionApi: this, + }).api(), + onDeleted: new EventManager({ + context, + module: "addressBook", + event: "onContactDeleted", + extensionApi: this, + }).api(), + }, + mailingLists: { + list(parentId) { + let parentNode = addressBookCache.findAddressBookById(parentId); + return addressBookCache.convert( + addressBookCache.getMailingLists(parentNode), + false + ); + }, + get(id) { + return addressBookCache.convert( + addressBookCache.findMailingListById(id), + false + ); + }, + create(parentId, { name, nickName, description }) { + let parentNode = addressBookCache.findAddressBookById(parentId); + if (parentNode.item.readOnly) { + throw new ExtensionUtils.ExtensionError( + "Cannot create a mailing list in a read-only address book" + ); + } + let mailList = Cc[ + "@mozilla.org/addressbook/directoryproperty;1" + ].createInstance(Ci.nsIAbDirectory); + mailList.isMailList = true; + mailList.dirName = name; + mailList.listNickName = nickName === null ? "" : nickName; + mailList.description = description === null ? "" : description; + + let newMailList = parentNode.item.addMailList(mailList); + return newMailList.UID; + }, + update(id, { name, nickName, description }) { + let node = addressBookCache.findMailingListById(id); + let parentNode = addressBookCache.findAddressBookById(node.parentId); + if (parentNode.item.readOnly) { + throw new ExtensionUtils.ExtensionError( + "Cannot modify a mailing list in a read-only address book" + ); + } + node.item.dirName = name; + node.item.listNickName = nickName === null ? "" : nickName; + node.item.description = description === null ? "" : description; + node.item.editMailListToDatabase(null); + }, + delete(id) { + let node = addressBookCache.findMailingListById(id); + let parentNode = addressBookCache.findAddressBookById(node.parentId); + if (parentNode.item.readOnly) { + throw new ExtensionUtils.ExtensionError( + "Cannot delete a mailing list in a read-only address book" + ); + } + parentNode.item.deleteDirectory(node.item); + }, + + listMembers(id) { + let node = addressBookCache.findMailingListById(id); + return addressBookCache.convert( + addressBookCache.getListContacts(node), + false + ); + }, + addMember(id, contactId) { + let node = addressBookCache.findMailingListById(id); + let parentNode = addressBookCache.findAddressBookById(node.parentId); + if (parentNode.item.readOnly) { + throw new ExtensionUtils.ExtensionError( + "Cannot add to a mailing list in a read-only address book" + ); + } + let contactNode = addressBookCache.findContactById(contactId); + node.item.addCard(contactNode.item); + }, + removeMember(id, contactId) { + let node = addressBookCache.findMailingListById(id); + let parentNode = addressBookCache.findAddressBookById(node.parentId); + if (parentNode.item.readOnly) { + throw new ExtensionUtils.ExtensionError( + "Cannot remove from a mailing list in a read-only address book" + ); + } + let contactNode = addressBookCache.findContactById(contactId); + + node.item.deleteCards([contactNode.item]); + }, + + // The module name is addressBook as defined in ext-mail.json. + onCreated: new EventManager({ + context, + module: "addressBook", + event: "onMailingListCreated", + extensionApi: this, + }).api(), + onUpdated: new EventManager({ + context, + module: "addressBook", + event: "onMailingListUpdated", + extensionApi: this, + }).api(), + onDeleted: new EventManager({ + context, + module: "addressBook", + event: "onMailingListDeleted", + extensionApi: this, + }).api(), + onMemberAdded: new EventManager({ + context, + module: "addressBook", + event: "onMemberAdded", + extensionApi: this, + }).api(), + onMemberRemoved: new EventManager({ + context, + module: "addressBook", + event: "onMemberRemoved", + extensionApi: this, + }).api(), + }, + }; + } +}; |