Bug 1773144 - Allow contacts API to handle properties again. r=darktrojan

Differential Revision: https://phabricator.services.mozilla.com/D148564
This commit is contained in:
John Bieling 2022-06-14 14:44:53 +00:00
Родитель b1b12d0fd7
Коммит a234f307a8
4 изменённых файлов: 464 добавлений и 59 удалений

Просмотреть файл

@ -6,10 +6,21 @@ var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
var { MailServices } = ChromeUtils.import(
"resource:///modules/MailServices.jsm"
);
var { AddrBookDirectory } = ChromeUtils.import(
"resource:///modules/AddrBookDirectory.jsm"
);
var { newUID } = ChromeUtils.import("resource:///modules/AddrBookUtils.jsm");
var { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
XPCOMUtils.defineLazyModuleGetters(this, {
newUID: "resource:///modules/AddrBookUtils.jsm",
AddrBookCard: "resource:///modules/AddrBookCard.jsm",
BANISHED_PROPERTIES: "resource:///modules/VCardUtils.jsm",
VCardProperties: "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
@ -21,8 +32,90 @@ const hiddenProperties = [
"PopularityIndex",
"RecordKey",
"UID",
"_vCard",
"vCard",
];
/**
* 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}
@ -92,16 +185,23 @@ class ExtSearchBook extends AddrBookDirectory {
aSearchString,
aQuery
);
for (let properties of results) {
let card = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
Ci.nsIAbCard
);
card.directoryUID = this.UID;
for (let [name, value] of Object.entries(properties)) {
if (!hiddenProperties.includes(name)) {
card.setProperty(name, value);
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, "");
@ -284,23 +384,24 @@ var addressBookCache = new (class extends EventEmitter {
copy.remote = node.item.isRemote;
break;
case "contact": {
let vCardProperties = vCardPropertiesFromCard(node.item);
copy.properties = {};
for (let property of node.item.properties) {
if (!hiddenProperties.includes(property.name)) {
switch (property.value) {
case undefined:
case null:
case "":
// If someone sets a property to one of these values,
// the property will be deleted from the database.
// However, the value still appears in the notification,
// so we ignore it here.
continue;
}
// WebExtensions complains if we use numbers.
copy.properties[property.name] = "" + property.value;
}
// 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;
}
// Add the vCard.
copy.properties.vCard = vCardProperties.toVCard();
let parentNode;
try {
parentNode = this.findAddressBookById(node.parentId);
@ -740,39 +841,49 @@ this.addressBook = class extends ExtensionAPI {
false
);
},
create(parentId, id, properties) {
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 = Cc[
"@mozilla.org/addressbook/cardproperty;1"
].createInstance(Ci.nsIAbCard);
for (let [name, value] of Object.entries(properties)) {
if (!hiddenProperties.includes(name)) {
card.setProperty(name, value);
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);
}
if (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(id, parentId);
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: ${id}`);
throw new ExtensionError(`Duplicate contact id: ${card.UID}`);
}
card.UID = id;
}
let newCard = parentNode.item.addCard(card);
return newCard.UID;
},
update(id, properties) {
update(id, updateData) {
let node = addressBookCache.findContactById(id);
let parentNode = addressBookCache.findAddressBookById(node.parentId);
if (parentNode.item.readOnly) {
@ -781,12 +892,104 @@ this.addressBook = class extends ExtensionAPI {
);
}
for (let [name, value] of Object.entries(properties)) {
if (!hiddenProperties.includes(name)) {
node.item.setProperty(name, value);
// 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
);
}
parentNode.item.modifyCard(node.item);
// Clone original properties and update custom properties.
addProperties(card, updateData, node.item.properties);
parentNode.item.modifyCard(card);
},
delete(id) {
let node = addressBookCache.findContactById(id);
@ -820,9 +1023,40 @@ this.addressBook = class extends ExtensionAPI {
register: fire => {
let listener = (event, node, changes) => {
let filteredChanges = {};
for (let [key, value] of Object.entries(changes)) {
if (!hiddenProperties.includes(key) && key.match(/^\w+$/)) {
filteredChanges[key] = value;
// 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(addressBookCache.convert(node), filteredChanges);

Просмотреть файл

@ -348,7 +348,7 @@
{
"id": "ContactProperties",
"type": "object",
"description": "A set of properties for a particular contact. For a complete list of properties that Thunderbird uses, see https://hg.mozilla.org/comm-central/file/tip/mailnews/addrbook/public/nsIAbCard.idl\nIt is also possible to store custom properties. The custom property name however may only use a to z, A to Z, 1 to 9 and underscores.",
"description": "A set of individual properties for a particular contact and/or its vCard string in the <code>vCard</code> member. The individual properties may either be `legacy properties <https://searchfox.org/comm-central/rev/8a1ae67088acf237dab2fd704db18589e7bf119e/mailnews/addrbook/modules/VCardUtils.jsm#295-334>`__ or custom properties (name may use a to z, A to Z, 1 to 9 and underscores).\n**Note:** Using individual properties is deprecated, get/set the <code>vCard</code> member instead.",
"patternProperties": {
"^\\w+$": {
"choices": [
@ -525,12 +525,13 @@
{
"name": "id",
"type": "string",
"description": "Assigns the contact an id. If an existing contact has this id, an exception is thrown.",
"description": "Assigns the contact an id. If an existing contact has this id, an exception is thrown. **Note:** Deprecated, the card's id should be specified in the vCard string instead.",
"optional": true
},
{
"name": "properties",
"$ref": "ContactProperties"
"$ref": "ContactProperties",
"description": "The properties object for the new contact. If it includes a <code>vCard</code> member, all specified `legacy properties <https://searchfox.org/comm-central/rev/8a1ae67088acf237dab2fd704db18589e7bf119e/mailnews/addrbook/modules/VCardUtils.jsm#295-334>`__ are ignored and the new contact will be based on the provided vCard string. If a UID is specified in the vCard string, which is already used by another contact, an exception is thrown. **Note:** Using individual properties is deprecated, use the <code>vCard</code> member instead."
},
{
"type": "function",
@ -557,10 +558,11 @@
},
{
"name": "properties",
"$ref": "ContactProperties"
"$ref": "ContactProperties",
"description": "An object with properties to update the specified contact. Individual properties are removed, if they are set to <code>null</code>. If the provided object includes a <code>vCard</code> member, all specified `legacy properties <https://searchfox.org/comm-central/rev/8a1ae67088acf237dab2fd704db18589e7bf119e/mailnews/addrbook/modules/VCardUtils.jsm#295-334>`__ are ignored and the details of the contact will be replaced by the provided vCard. Changes to the UID will be ignored. **Note:** Using individual properties is deprecated, use the <code>vCard</code> member instead. "
}
],
"description": "Edits the properties of a contact. To remove a property, specify it as <code>null</code>."
"description": "Updates a contact."
},
{
"name": "delete",

Просмотреть файл

@ -185,6 +185,8 @@ add_task(async function test_addressBooks() {
newContactId = await browser.contacts.create(firstBookId, {
FirstName: "first",
LastName: "last",
Notes: "Notes",
SomethingCustom: "Custom property",
});
browser.test.assertEq(36, newContactId.length);
checkEvents([
@ -211,16 +213,43 @@ add_task(async function test_addressBooks() {
browser.test.assertEq("contact", newContact.type);
browser.test.assertEq(false, newContact.readOnly);
browser.test.assertEq(false, newContact.remote);
browser.test.assertEq(3, Object.keys(newContact.properties).length);
browser.test.assertEq(5, Object.keys(newContact.properties).length);
browser.test.assertEq("first", newContact.properties.FirstName);
browser.test.assertEq("last", newContact.properties.LastName);
browser.test.assertEq("Notes", newContact.properties.Notes);
browser.test.assertEq(
`BEGIN:VCARD\r\nVERSION:4.0\r\nN:last;first;;;\r\nUID:${newContactId}\r\nEND:VCARD\r\n`,
newContact.properties._vCard
"Custom property",
newContact.properties.SomethingCustom
);
browser.test.assertEq(
`BEGIN:VCARD\r\nVERSION:4.0\r\nNOTE:Notes\r\nN:last;first;;;\r\nUID:${newContactId}\r\nEND:VCARD\r\n`,
newContact.properties.vCard
);
// Changing the UID should throw.
try {
await browser.contacts.update(newContactId, {
vCard: `BEGIN:VCARD\r\nVERSION:4.0\r\nN:;first;;;\r\nEMAIL;PREF=1:first@last\r\nUID:SomethingNew\r\nEND:VCARD\r\n`,
});
browser.test.fail(
`Updating a contact with a vCard with a differnt UID should throw`
);
} catch (ex) {
browser.test.assertEq(
`The card's UID ${newContactId} may not be changed: BEGIN:VCARD\r\nVERSION:4.0\r\nN:;first;;;\r\nEMAIL;PREF=1:first@last\r\nUID:SomethingNew\r\nEND:VCARD\r\n.`,
ex.message,
`browser.contacts.update threw exception`
);
}
// If a vCard and legacy properties are given, vCard must win.
await browser.contacts.update(newContactId, {
_vCard: `BEGIN:VCARD\r\nVERSION:4.0\r\nN:;first;;;\r\nEMAIL;PREF=1:first@last\r\nUID:${newContactId}\r\nEND:VCARD\r\n`,
vCard: `BEGIN:VCARD\r\nVERSION:4.0\r\nN:;first;;;\r\nEMAIL;PREF=1:first@last\r\nUID:${newContactId}\r\nEND:VCARD\r\n`,
FirstName: "Superman",
PrimaryEmail: "c.kent@dailyplanet.com",
PreferDisplayName: "0",
OtherCustom: "Yet another custom property",
Notes: "Ignored Notes",
});
checkEvents([
"contacts",
@ -229,23 +258,150 @@ add_task(async function test_addressBooks() {
{
PrimaryEmail: { oldValue: null, newValue: "first@last" },
LastName: { oldValue: "last", newValue: null },
OtherCustom: {
oldValue: null,
newValue: "Yet another custom property",
},
PreferDisplayName: { oldValue: null, newValue: "0" },
},
]);
let updatedContact = await browser.contacts.get(newContactId);
browser.test.assertEq(3, Object.keys(updatedContact.properties).length);
browser.test.assertEq(6, Object.keys(updatedContact.properties).length);
browser.test.assertEq("first", updatedContact.properties.FirstName);
browser.test.assertEq(
"first@last",
updatedContact.properties.PrimaryEmail
);
browser.test.assertTrue(!("LastName" in updatedContact.properties));
browser.test.assertTrue(!("Notes" in updatedContact.properties));
browser.test.assertTrue(
!("Notes" in updatedContact.properties),
"The vCard is not specifying Notes and the specified Notes property should be ignored."
);
browser.test.assertEq(
"Custom property",
updatedContact.properties.SomethingCustom,
"Untouched custom properties should not be changed by updating the vCard"
);
browser.test.assertEq(
"Yet another custom property",
updatedContact.properties.OtherCustom,
"Custom properties should be added even while updating a vCard"
);
browser.test.assertEq(
"0",
updatedContact.properties.PreferDisplayName,
"Setting non-banished properties parallel to a vCard should update"
);
browser.test.assertEq(
`BEGIN:VCARD\r\nVERSION:4.0\r\nN:;first;;;\r\nEMAIL;PREF=1:first@last\r\nUID:${newContactId}\r\nEND:VCARD\r\n`,
updatedContact.properties._vCard
updatedContact.properties.vCard
);
// Manually Remove properties.
await browser.contacts.update(newContactId, {
LastName: "lastname",
PrimaryEmail: null,
SecondEmail: "test@invalid.de",
SomethingCustom: null,
OtherCustom: null,
});
checkEvents([
"contacts",
"onUpdated",
{ type: "contact", parentId: firstBookId, id: newContactId },
{
LastName: { oldValue: null, newValue: "lastname" },
// It is how it is. Defining a 2nd email with no 1st, will make it the first.
PrimaryEmail: { oldValue: "first@last", newValue: "test@invalid.de" },
SomethingCustom: { oldValue: "Custom property", newValue: null },
OtherCustom: {
oldValue: "Yet another custom property",
newValue: null,
},
},
]);
updatedContact = await browser.contacts.get(newContactId);
browser.test.assertEq(5, Object.keys(updatedContact.properties).length);
// LastName and FirstName are stored in the same multi field property and changing LastName should not change FirstName.
browser.test.assertEq("first", updatedContact.properties.FirstName);
browser.test.assertEq("lastname", updatedContact.properties.LastName);
browser.test.assertEq(
"test@invalid.de",
updatedContact.properties.PrimaryEmail
);
browser.test.assertTrue(
!("SomethingCustom" in updatedContact.properties)
);
browser.test.assertTrue(!("OtherCustom" in updatedContact.properties));
browser.test.assertEq(
`BEGIN:VCARD\r\nVERSION:4.0\r\nN:lastname;first;;;\r\nEMAIL:test@invalid.de\r\nUID:${newContactId}\r\nEND:VCARD\r\n`,
updatedContact.properties.vCard
);
// Add an email address, going from 1 to 2.Also remove FirstName, LastName should stay.
await browser.contacts.update(newContactId, {
FirstName: null,
PrimaryEmail: "new1@invalid.de",
SecondEmail: "new2@invalid.de",
});
checkEvents([
"contacts",
"onUpdated",
{ type: "contact", parentId: firstBookId, id: newContactId },
{
PrimaryEmail: {
oldValue: "test@invalid.de",
newValue: "new1@invalid.de",
},
SecondEmail: { oldValue: null, newValue: "new2@invalid.de" },
FirstName: { oldValue: "first", newValue: null },
},
]);
updatedContact = await browser.contacts.get(newContactId);
browser.test.assertEq(5, Object.keys(updatedContact.properties).length);
browser.test.assertEq("lastname", updatedContact.properties.LastName);
browser.test.assertEq(
"new1@invalid.de",
updatedContact.properties.PrimaryEmail
);
browser.test.assertEq(
"new2@invalid.de",
updatedContact.properties.SecondEmail
);
browser.test.assertEq(
`BEGIN:VCARD\r\nVERSION:4.0\r\nN:lastname;;;;\r\nEMAIL;PREF=1:new1@invalid.de\r\nUID:${newContactId}\r\nEMAIL:new2@invalid.de\r\nEND:VCARD\r\n`,
updatedContact.properties.vCard
);
// Remove and email address, going from 2 to 1.
await browser.contacts.update(newContactId, {
SecondEmail: null,
});
checkEvents([
"contacts",
"onUpdated",
{ type: "contact", parentId: firstBookId, id: newContactId },
{
SecondEmail: { oldValue: "new2@invalid.de", newValue: null },
},
]);
updatedContact = await browser.contacts.get(newContactId);
browser.test.assertEq(4, Object.keys(updatedContact.properties).length);
browser.test.assertEq("lastname", updatedContact.properties.LastName);
browser.test.assertEq(
"new1@invalid.de",
updatedContact.properties.PrimaryEmail
);
browser.test.assertEq(
`BEGIN:VCARD\r\nVERSION:4.0\r\nN:lastname;;;;\r\nEMAIL;PREF=1:new1@invalid.de\r\nUID:${newContactId}\r\nEND:VCARD\r\n`,
updatedContact.properties.vCard
);
// Set a fixed UID.
let fixedContactId = await browser.contacts.create(
firstBookId,
"this is a test",

Просмотреть файл

@ -157,14 +157,27 @@ var VCardUtils = {
// Strip the version.
return vCard.replace(/(\r?\n)VERSION:2.1\r?\n/i, "$1");
},
vCardToAbCard(vCard) {
/**
* Return a new AddrBookCard from the provided vCard string.
*
* @param vCard - the vCard string
* @param [uid] - an optional UID to be used for the new card, overriding any
* UID specified in the vCard string.
* @returns
*/
vCardToAbCard(vCard, uid) {
vCard = this.translateVCard21(vCard);
let abCard = new AddrBookCard();
abCard.setProperty("_vCard", vCard);
let uid = abCard.vCardProperties.getFirstValue("uid");
if (uid) {
abCard.UID = uid;
let vCardUID = abCard.vCardProperties.getFirstValue("uid");
if (uid || vCardUID) {
abCard.UID = uid || vCardUID;
if (abCard.UID != vCardUID) {
abCard.vCardProperties.clearValues("uid");
abCard.vCardProperties.addValue("uid", abCard.UID);
}
}
return abCard;