summaryrefslogtreecommitdiffstats
path: root/comm/mailnews/addrbook/modules/VCardUtils.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mailnews/addrbook/modules/VCardUtils.jsm')
-rw-r--r--comm/mailnews/addrbook/modules/VCardUtils.jsm973
1 files changed, 973 insertions, 0 deletions
diff --git a/comm/mailnews/addrbook/modules/VCardUtils.jsm b/comm/mailnews/addrbook/modules/VCardUtils.jsm
new file mode 100644
index 0000000000..a3ff0f5e14
--- /dev/null
+++ b/comm/mailnews/addrbook/modules/VCardUtils.jsm
@@ -0,0 +1,973 @@
+/* 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/. */
+
+const EXPORTED_SYMBOLS = [
+ "VCardService",
+ "VCardMimeConverter",
+ "VCardProperties",
+ "VCardPropertyEntry",
+ "VCardUtils",
+ "BANISHED_PROPERTIES",
+];
+
+const { ICAL } = ChromeUtils.import("resource:///modules/calendar/Ical.jsm");
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ AddrBookCard: "resource:///modules/AddrBookCard.jsm",
+});
+
+/**
+ * Utilities for working with vCard data. This file uses ICAL.js as parser and
+ * formatter to avoid reinventing the wheel.
+ *
+ * @see RFC 6350.
+ */
+
+var VCardUtils = {
+ _decodeQuotedPrintable(value) {
+ let bytes = [];
+ for (let b = 0; b < value.length; b++) {
+ if (value[b] == "=") {
+ bytes.push(parseInt(value.substr(b + 1, 2), 16));
+ b += 2;
+ } else {
+ bytes.push(value.charCodeAt(b));
+ }
+ }
+ return new TextDecoder().decode(new Uint8Array(bytes));
+ },
+ _parse(vProps) {
+ let vPropMap = new Map();
+ for (let index = 0; index < vProps.length; index++) {
+ let { name, params, value } = vProps[index];
+
+ // Work out which type in typeMap, if any, this property belongs to.
+
+ // To make the next piece easier, the type param must always be an array
+ // of lower-case strings.
+ let type = params.type || [];
+ if (type) {
+ if (Array.isArray(type)) {
+ type = type.map(t => t.toLowerCase());
+ } else {
+ type = [type.toLowerCase()];
+ }
+ }
+
+ // Special cases for address and telephone types.
+ if (name == "adr") {
+ name = type.includes("home") ? "adr.home" : "adr.work";
+ }
+ if (name == "tel") {
+ name = "tel.work";
+ for (let t of type) {
+ if (["home", "work", "cell", "pager", "fax"].includes(t)) {
+ name = `tel.${t}`;
+ break;
+ }
+ }
+ }
+ // Preserve URL if no URL with type work is given take for `url.work` the URL without any type.
+ if (name == "url") {
+ name = type.includes("home") ? "url.home" : name;
+ name = type.includes("work") ? "url.work" : name;
+ }
+
+ // Special treatment for `url`, which is not in the typeMap.
+ if (!(name in typeMap) && name != "url") {
+ continue;
+ }
+
+ // The preference param is 1-100, lower numbers indicate higher
+ // preference. If not specified, the value is least preferred.
+ let pref = parseInt(params.pref, 10) || 101;
+
+ if (!vPropMap.has(name)) {
+ vPropMap.set(name, []);
+ }
+ vPropMap.get(name).push({ index, pref, value });
+ }
+
+ // If no URL with type is specified assume its the Work Web Page (WebPage 1).
+ if (vPropMap.has("url") && !vPropMap.has("url.work")) {
+ vPropMap.set("url.work", vPropMap.get("url"));
+ }
+ // AbCard only supports Work Web Page or Home Web Page. Get rid of the URL without type.
+ vPropMap.delete("url");
+
+ for (let props of vPropMap.values()) {
+ // Sort the properties by preference, or by the order they appeared.
+ props.sort((a, b) => {
+ if (a.pref == b.pref) {
+ return a.index - b.index;
+ }
+ return a.pref - b.pref;
+ });
+ }
+ return vPropMap;
+ },
+ /**
+ * ICAL.js's parser only supports vCard 3.0 and 4.0. To maintain
+ * interoperability with other applications, here we convert vCard 2.1
+ * cards into a "good-enough" mimic of vCard 4.0 so that the parser will
+ * read it without throwing an error.
+ *
+ * @param {string} vCard
+ * @returns {string}
+ */
+ translateVCard21(vCard) {
+ if (!/\bVERSION:2.1\b/i.test(vCard)) {
+ return vCard;
+ }
+
+ // Convert known type parameters to valid vCard 4.0, ignore unknown ones.
+ vCard = vCard.replace(/\n(([A-Z]+)(;[\w-]*)+):/gi, (match, key) => {
+ let parts = key.split(";");
+ let newParts = [parts[0]];
+ for (let i = 1; i < parts.length; i++) {
+ if (parts[i] == "") {
+ continue;
+ }
+ if (
+ ["HOME", "WORK", "FAX", "PAGER", "CELL"].includes(
+ parts[i].toUpperCase()
+ )
+ ) {
+ newParts.push(`TYPE=${parts[i]}`);
+ } else if (parts[i].toUpperCase() == "PREF") {
+ newParts.push("PREF=1");
+ } else if (parts[i].toUpperCase() == "QUOTED-PRINTABLE") {
+ newParts.push("ENCODING=QUOTED-PRINTABLE");
+ }
+ }
+ return "\n" + newParts.join(";") + ":";
+ });
+
+ // Join quoted-printable wrapped lines together. This regular expression
+ // only matches lines that are quoted-printable and end with `=`.
+ let quotedNewLineRegExp = /(;ENCODING=QUOTED-PRINTABLE[;:][^\r\n]*)=\r?\n/i;
+ while (vCard.match(quotedNewLineRegExp)) {
+ vCard = vCard.replace(quotedNewLineRegExp, "$1");
+ }
+
+ // Strip the version.
+ return vCard.replace(/(\r?\n)VERSION:2.1\r?\n/i, "$1");
+ },
+ /**
+ * Return a new AddrBookCard from the provided vCard string.
+ *
+ * @param {string} vCard - The vCard string.
+ * @param {string} [uid] - An optional UID to be used for the new card,
+ * overriding any UID specified in the vCard string.
+ * @returns {AddrBookCard}
+ */
+ vCardToAbCard(vCard, uid) {
+ vCard = this.translateVCard21(vCard);
+
+ let abCard = new lazy.AddrBookCard();
+ abCard.setProperty("_vCard", vCard);
+
+ 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;
+ },
+ abCardToVCard(abCard, version) {
+ if (abCard.supportsVCard && abCard.getProperty("_vCard")) {
+ return abCard.vCardProperties.toVCard();
+ }
+
+ // Collect all of the AB card properties into a Map.
+ let abProps = new Map(
+ Array.from(abCard.properties, p => [p.name, p.value])
+ );
+ abProps.set("UID", abCard.UID);
+
+ return this.propertyMapToVCard(abProps, version);
+ },
+ propertyMapToVCard(abProps, version = "4.0") {
+ let vProps = [["version", {}, "text", version]];
+
+ // Add the properties to the vCard.
+ for (let vPropName of Object.keys(typeMap)) {
+ for (let vProp of typeMap[vPropName].fromAbCard(abProps, vPropName)) {
+ if (vProp[3] !== null && vProp[3] !== undefined && vProp[3] !== "") {
+ vProps.push(vProp);
+ }
+ }
+ }
+
+ // If there's only one address or telephone number, don't specify type.
+ let adrProps = vProps.filter(p => p[0] == "adr");
+ if (adrProps.length == 1) {
+ delete adrProps[0][1].type;
+ }
+ let telProps = vProps.filter(p => p[0] == "tel");
+ if (telProps.length == 1) {
+ delete telProps[0][1].type;
+ }
+
+ if (abProps.has("UID")) {
+ vProps.push(["uid", {}, "text", abProps.get("UID")]);
+ }
+ return ICAL.stringify(["vcard", vProps]);
+ },
+};
+
+function VCardService() {}
+VCardService.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIMsgVCardService"]),
+ classID: Components.ID("{e2e0f615-bc5a-4441-a16b-a26e75949376}"),
+
+ vCardToAbCard(vCard) {
+ return vCard ? VCardUtils.vCardToAbCard(vCard) : null;
+ },
+ escapedVCardToAbCard(vCard) {
+ return vCard ? VCardUtils.vCardToAbCard(decodeURIComponent(vCard)) : null;
+ },
+ abCardToEscapedVCard(abCard) {
+ return abCard ? encodeURIComponent(VCardUtils.abCardToVCard(abCard)) : null;
+ },
+};
+
+function VCardMimeConverter() {}
+VCardMimeConverter.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsISimpleMimeConverter"]),
+ classID: Components.ID("{dafab386-bd4c-4238-bb48-228fbc98ba29}"),
+
+ mailChannel: null,
+ uri: null,
+ convertToHTML(contentType, data) {
+ function escapeHTML(template, ...parts) {
+ let arr = [];
+ for (let i = 0; i < parts.length; i++) {
+ arr.push(template[i]);
+ arr.push(
+ parts[i]
+ .replace(/&/g, "&amp;")
+ .replace(/"/g, "&quot;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;")
+ );
+ }
+ arr.push(template[template.length - 1]);
+ return arr.join("");
+ }
+
+ let abCard;
+ try {
+ abCard = VCardUtils.vCardToAbCard(data);
+ } catch (e) {
+ // We were given invalid vcard data.
+ return "";
+ }
+
+ let escapedVCard = encodeURIComponent(data);
+
+ let propertiesTable = `<table class="moz-vcard-properties-table">`;
+ propertiesTable += escapeHTML`<tr><td class="moz-vcard-title-property">${abCard.displayName}`;
+ if (abCard.primaryEmail) {
+ propertiesTable += escapeHTML`&nbsp;&lt;<a href="mailto:${abCard.primaryEmail}" private>${abCard.primaryEmail}</a>&gt;`;
+ }
+ propertiesTable += `</td></tr>`;
+ for (let propName of ["JobTitle", "Department", "Company"]) {
+ let propValue = abCard.getProperty(propName, "");
+ if (propValue) {
+ propertiesTable += escapeHTML`<tr><td class="moz-vcard-property">${propValue}</td></tr>`;
+ }
+ }
+ propertiesTable += `</table>`;
+
+ // VCardChild.jsm and VCardParent.jsm handle clicking on this link.
+ return `<html>
+ <body>
+ <table class="moz-vcard-table">
+ <tr>
+ <td valign="top"><a class="moz-vcard-badge" href="data:text/vcard,${escapedVCard}"></a></td>
+ <td>
+ ${propertiesTable}
+ </td>
+ </tr>
+ </table>
+ </body>
+ </html>`;
+ },
+};
+
+const BANISHED_PROPERTIES = [
+ "UID",
+ "PrimaryEmail",
+ "SecondEmail",
+ "DisplayName",
+ "NickName",
+ "Notes",
+ "Company",
+ "Department",
+ "JobTitle",
+ "BirthDay",
+ "BirthMonth",
+ "BirthYear",
+ "AnniversaryDay",
+ "AnniversaryMonth",
+ "AnniversaryYear",
+ "LastName",
+ "FirstName",
+ "AdditionalNames",
+ "NamePrefix",
+ "NameSuffix",
+ "HomePOBox",
+ "HomeAddress2",
+ "HomeAddress",
+ "HomeCity",
+ "HomeState",
+ "HomeZipCode",
+ "HomeCountry",
+ "WorkPOBox",
+ "WorkAddress2",
+ "WorkAddress",
+ "WorkCity",
+ "WorkState",
+ "WorkZipCode",
+ "WorkCountry",
+ "HomePhone",
+ "WorkPhone",
+ "FaxNumber",
+ "PagerNumber",
+ "CellularNumber",
+ "WebPage1",
+ "WebPage2",
+ "Custom1",
+ "Custom2",
+ "Custom3",
+ "Custom4",
+];
+
+/** Helper functions for typeMap. */
+
+function singleTextProperty(
+ abPropName,
+ vPropName,
+ vPropParams = {},
+ vPropType = "text"
+) {
+ return {
+ /**
+ * Formats nsIAbCard properties into an array for use by ICAL.js.
+ *
+ * @param {Map} map - A map of address book properties to map.
+ * @yields {Array} - Values in a jCard array for use with ICAL.js.
+ */
+ *fromAbCard(map) {
+ yield [vPropName, { ...vPropParams }, vPropType, map.get(abPropName)];
+ },
+ /**
+ * Parses a vCard value into properties usable by nsIAbCard.
+ *
+ * @param {string} value - vCard string to map to an address book card property.
+ * @yields {string[]} - Any number of key, value pairs to set on the nsIAbCard.
+ */
+ *toAbCard(value) {
+ if (typeof value != "string") {
+ console.warn(`Unexpected value for ${vPropName}: ${value}`);
+ return;
+ }
+ yield [abPropName, value];
+ },
+ };
+}
+function dateProperty(abCardPrefix, vPropName) {
+ return {
+ *fromAbCard(map) {
+ let year = map.get(`${abCardPrefix}Year`);
+ let month = map.get(`${abCardPrefix}Month`);
+ let day = map.get(`${abCardPrefix}Day`);
+
+ if (!year && !month && !day) {
+ return;
+ }
+
+ let dateValue = new ICAL.VCardTime({}, null, "date");
+ // Set the properties directly instead of using the VCardTime
+ // constructor argument, which causes null values to become 0.
+ dateValue.year = year ? Number(year) : null;
+ dateValue.month = month ? Number(month) : null;
+ dateValue.day = day ? Number(day) : null;
+
+ yield [vPropName, {}, "date", dateValue.toString()];
+ },
+ *toAbCard(value) {
+ try {
+ let dateValue = ICAL.VCardTime.fromDateAndOrTimeString(value);
+ yield [`${abCardPrefix}Year`, String(dateValue.year ?? "")];
+ yield [`${abCardPrefix}Month`, String(dateValue.month ?? "")];
+ yield [`${abCardPrefix}Day`, String(dateValue.day ?? "")];
+ } catch (ex) {
+ console.error(ex);
+ }
+ },
+ };
+}
+function multiTextProperty(abPropNames, vPropName, vPropParams = {}) {
+ return {
+ *fromAbCard(map) {
+ if (abPropNames.every(name => !map.has(name))) {
+ return;
+ }
+ let vPropValues = abPropNames.map(name => map.get(name) || "");
+ if (vPropValues.some(Boolean)) {
+ yield [vPropName, { ...vPropParams }, "text", vPropValues];
+ }
+ },
+ *toAbCard(value) {
+ if (Array.isArray(value)) {
+ for (let abPropName of abPropNames) {
+ let valuePart = value.shift();
+ if (abPropName && valuePart) {
+ yield [
+ abPropName,
+ Array.isArray(valuePart) ? valuePart.join(" ") : valuePart,
+ ];
+ }
+ }
+ } else if (typeof value == "string") {
+ // Only one value was given.
+ yield [abPropNames[0], value];
+ } else {
+ console.warn(`Unexpected value for ${vPropName}: ${value}`);
+ }
+ },
+ };
+}
+
+/**
+ * Properties we support for conversion between nsIAbCard and vCard.
+ *
+ * Keys correspond to vCard property keys, with the type appended where more
+ * than one type is supported (e.g. work and home).
+ *
+ * Values are objects with toAbCard and fromAbCard functions which convert
+ * property values in each direction. See the docs on the object returned by
+ * singleTextProperty.
+ */
+var typeMap = {
+ fn: singleTextProperty("DisplayName", "fn"),
+ email: {
+ *fromAbCard(map) {
+ yield ["email", { pref: "1" }, "text", map.get("PrimaryEmail")];
+ yield ["email", {}, "text", map.get("SecondEmail")];
+ },
+ toAbCard: singleTextProperty("PrimaryEmail", "email", { pref: "1" })
+ .toAbCard,
+ },
+ nickname: singleTextProperty("NickName", "nickname"),
+ note: singleTextProperty("Notes", "note"),
+ org: multiTextProperty(["Company", "Department"], "org"),
+ title: singleTextProperty("JobTitle", "title"),
+ bday: dateProperty("Birth", "bday"),
+ anniversary: dateProperty("Anniversary", "anniversary"),
+ n: multiTextProperty(
+ ["LastName", "FirstName", "AdditionalNames", "NamePrefix", "NameSuffix"],
+ "n"
+ ),
+ "adr.home": multiTextProperty(
+ [
+ "HomePOBox",
+ "HomeAddress2",
+ "HomeAddress",
+ "HomeCity",
+ "HomeState",
+ "HomeZipCode",
+ "HomeCountry",
+ ],
+ "adr",
+ { type: "home" }
+ ),
+ "adr.work": multiTextProperty(
+ [
+ "WorkPOBox",
+ "WorkAddress2",
+ "WorkAddress",
+ "WorkCity",
+ "WorkState",
+ "WorkZipCode",
+ "WorkCountry",
+ ],
+ "adr",
+ { type: "work" }
+ ),
+ "tel.home": singleTextProperty("HomePhone", "tel", { type: "home" }),
+ "tel.work": singleTextProperty("WorkPhone", "tel", { type: "work" }),
+ "tel.fax": singleTextProperty("FaxNumber", "tel", { type: "fax" }),
+ "tel.pager": singleTextProperty("PagerNumber", "tel", { type: "pager" }),
+ "tel.cell": singleTextProperty("CellularNumber", "tel", { type: "cell" }),
+ "url.work": singleTextProperty("WebPage1", "url", { type: "work" }, "url"),
+ "url.home": singleTextProperty("WebPage2", "url", { type: "home" }, "url"),
+ "x-custom1": singleTextProperty("Custom1", "x-custom1"),
+ "x-custom2": singleTextProperty("Custom2", "x-custom2"),
+ "x-custom3": singleTextProperty("Custom3", "x-custom3"),
+ "x-custom4": singleTextProperty("Custom4", "x-custom4"),
+};
+
+/**
+ * Any value that can be represented in a vCard. A value can be a boolean,
+ * number, string, or an array, depending on the data. A top-level array might
+ * contain primitives and/or second-level arrays of primitives.
+ *
+ * @see ICAL.design
+ * @see RFC6350
+ *
+ * @typedef {boolean|number|string|vCardValue[]} vCardValue
+ */
+
+/**
+ * Represents a single entry in a vCard ("contentline" in RFC6350 terms).
+ * The name, params, type and value are as returned by ICAL.
+ */
+class VCardPropertyEntry {
+ #name = null;
+ #params = null;
+ #type = null;
+ #value = null;
+ _original = null;
+
+ /**
+ * @param {string} name
+ * @param {object} params
+ * @param {string} type
+ * @param {vCardValue} value
+ */
+ constructor(name, params, type, value) {
+ this.#name = name;
+ this.#params = params;
+ this.#type = type;
+ if (params.encoding?.toUpperCase() == "QUOTED-PRINTABLE") {
+ if (Array.isArray(value)) {
+ value = value.map(VCardUtils._decodeQuotedPrintable);
+ } else {
+ value = VCardUtils._decodeQuotedPrintable(value);
+ }
+ delete params.encoding;
+ delete params.charset;
+ }
+ this.#value = value;
+ this._original = this;
+ }
+
+ /**
+ * @type {string}
+ */
+ get name() {
+ return this.#name;
+ }
+
+ /**
+ * @type {object}
+ */
+ get params() {
+ return this.#params;
+ }
+
+ /**
+ * @type {string}
+ */
+ get type() {
+ return this.#type;
+ }
+ set type(type) {
+ this.#type = type;
+ }
+
+ /**
+ * @type {vCardValue}
+ */
+ get value() {
+ return this.#value;
+ }
+ set value(value) {
+ this.#value = value;
+ }
+
+ /**
+ * Clone this object.
+ *
+ * @returns {VCardPropertyEntry}
+ */
+ clone() {
+ let cloneValue;
+ if (Array.isArray(this.#value)) {
+ cloneValue = this.#value.map(v => (Array.isArray(v) ? v.slice() : v));
+ } else {
+ cloneValue = this.#value;
+ }
+
+ let clone = new VCardPropertyEntry(
+ this.#name,
+ { ...this.#params },
+ this.#type,
+ cloneValue
+ );
+ clone._original = this;
+ return clone;
+ }
+
+ /**
+ * @param {VCardPropertyEntry} other
+ */
+ equals(other) {
+ if (other.constructor.name != "VCardPropertyEntry") {
+ return false;
+ }
+ return this._original == other._original;
+ }
+}
+
+/**
+ * Represents an entire vCard as a collection of `VCardPropertyEntry` objects.
+ */
+class VCardProperties {
+ /**
+ * All of the vCard entries in this object.
+ *
+ * @type {VCardPropertyEntry[]}
+ */
+ entries = [];
+
+ /**
+ * @param {?string} version - The version of vCard to use. Valid values are
+ * "3.0" and "4.0". If unspecified, vCard 3.0 will be used.
+ */
+ constructor(version) {
+ if (version) {
+ if (!["3.0", "4.0"].includes(version)) {
+ throw new Error(`Unsupported vCard version: ${version}`);
+ }
+ this.addEntry(new VCardPropertyEntry("version", {}, "text", version));
+ }
+ }
+
+ /**
+ * Parse a vCard into a VCardProperties object.
+ *
+ * @param {string} vCard
+ * @returns {VCardProperties}
+ */
+ static fromVCard(vCard, { isGoogleCardDAV = false } = {}) {
+ vCard = VCardUtils.translateVCard21(vCard);
+
+ let rv = new VCardProperties();
+ let [, properties] = ICAL.parse(vCard);
+ for (let property of properties) {
+ let [name, params, type, value] = property;
+ if (property.length > 4) {
+ // The jCal format stores multiple values as the 4th...nth items.
+ // VCardPropertyEntry has only one place for a value, so store an
+ // array instead. This applies to CATEGORIES and NICKNAME types in
+ // vCard 4 and also NOTE in vCard 3.
+ value = property.slice(3);
+ }
+ if (isGoogleCardDAV) {
+ // Google escapes the characters \r : , ; and \ unnecessarily, in
+ // violation of RFC6350. Removing the escaping at this point means no
+ // other code requires a special case for it.
+ if (Array.isArray(value)) {
+ value = value.map(v => v.replace(/\\r/g, "\r").replace(/\\:/g, ":"));
+ } else {
+ value = value.replace(/\\r/g, "\r").replace(/\\:/g, ":");
+ if (["phone-number", "uri"].includes(type)) {
+ value = value.replace(/\\([,;\\])/g, "$1");
+ }
+ }
+ }
+ rv.addEntry(new VCardPropertyEntry(name, params, type, value));
+ }
+ return rv;
+ }
+
+ /**
+ * Parse a Map of Address Book properties into a VCardProperties object.
+ *
+ * @param {Map<string, string>} propertyMap
+ * @param {string} [version="4.0"]
+ * @returns {VCardProperties}
+ */
+ static fromPropertyMap(propertyMap, version = "4.0") {
+ let rv = new VCardProperties(version);
+
+ for (let vPropName of Object.keys(typeMap)) {
+ for (let vProp of typeMap[vPropName].fromAbCard(propertyMap, vPropName)) {
+ if (vProp[3] !== null && vProp[3] !== undefined && vProp[3] !== "") {
+ rv.addEntry(new VCardPropertyEntry(...vProp));
+ }
+ }
+ }
+
+ return rv;
+ }
+
+ /**
+ * Used to determine the default value type when adding values.
+ * Either `ICAL.design.vcard` for (vCard 4.0) or `ICAL.design.vcard3` (3.0).
+ *
+ * @type {ICAL.design.designSet}
+ */
+ designSet = ICAL.design.vcard3;
+
+ /**
+ * Add an entry to this object.
+ *
+ * @param {VCardPropertyEntry} entry - The entry to add.
+ * @returns {boolean} - If the entry was added.
+ */
+ addEntry(entry) {
+ if (entry.constructor.name != "VCardPropertyEntry") {
+ throw new Error("Not a VCardPropertyEntry");
+ }
+
+ if (this.entries.find(e => e.equals(entry))) {
+ return false;
+ }
+
+ if (entry.name == "version") {
+ if (entry.value == "3.0") {
+ this.designSet = ICAL.design.vcard3;
+ } else if (entry.value == "4.0") {
+ this.designSet = ICAL.design.vcard;
+ } else {
+ throw new Error(`Unsupported vCard version: ${entry.value}`);
+ }
+ // Version must be the first entry, so clear out any existing values
+ // and add it to the start of the collection.
+ this.clearValues("version");
+ this.entries.unshift(entry);
+ return true;
+ }
+
+ this.entries.push(entry);
+ return true;
+ }
+
+ /**
+ * Add an entry to this object by name and value.
+ *
+ * @param {string} name
+ * @param {string} value
+ * @returns {VCardPropertyEntry}
+ */
+ addValue(name, value) {
+ for (let entry of this.getAllEntries(name)) {
+ if (entry.value == value) {
+ return entry;
+ }
+ }
+
+ let newEntry = new VCardPropertyEntry(
+ name,
+ {},
+ this.designSet.property[name].defaultType,
+ value
+ );
+ this.entries.push(newEntry);
+ return newEntry;
+ }
+
+ /**
+ * Remove an entry from this object.
+ *
+ * @param {VCardPropertyEntry} entry - The entry to remove.
+ * @returns {boolean} - If an entry was found and removed.
+ */
+ removeEntry(entry) {
+ if (entry.constructor.name != "VCardPropertyEntry") {
+ throw new Error("Not a VCardPropertyEntry");
+ }
+
+ let index = this.entries.findIndex(e => e.equals(entry));
+ if (index >= 0) {
+ this.entries.splice(index, 1);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Remove entries from this object by name and value. All entries matching
+ * the name and value will be removed.
+ *
+ * @param {string} name
+ * @param {string} value
+ */
+ removeValue(name, value) {
+ for (let entry of this.getAllEntries(name)) {
+ if (entry.value == value) {
+ this.removeEntry(entry);
+ }
+ }
+ }
+
+ /**
+ * Remove entries from this object by name. All entries matching the name
+ * will be removed.
+ *
+ * @param {string} name
+ */
+ clearValues(name) {
+ for (let entry of this.getAllEntries(name)) {
+ this.removeEntry(entry);
+ }
+ }
+
+ /**
+ * Get the first value matching the given name, or null if no entry matches.
+ *
+ * @param {string} name
+ * @returns {?vCardValue}
+ */
+ getFirstValue(name) {
+ let entry = this.entries.find(e => e.name == name);
+ if (entry) {
+ return entry.value;
+ }
+ return null;
+ }
+
+ /**
+ * Get all values matching the given name.
+ *
+ * @param {string} name
+ * @returns {vCardValue[]}
+ */
+ getAllValues(name) {
+ return this.getAllEntries(name).map(e => e.value);
+ }
+
+ /**
+ * Get all values matching the given name, sorted in order of preference.
+ * Preference is determined by the `pref` parameter if it exists, then by
+ * the position in `entries`.
+ *
+ * @param {string} name
+ * @returns {vCardValue[]}
+ */
+ getAllValuesSorted(name) {
+ return this.getAllEntriesSorted(name).map(e => e.value);
+ }
+
+ /**
+ * Get the first entry matching the given name, or null if no entry matches.
+ *
+ * @param {string} name
+ * @returns {?VCardPropertyEntry}
+ */
+ getFirstEntry(name) {
+ return this.entries.find(e => e.name == name) ?? null;
+ }
+
+ /**
+ * Get all entries matching the given name.
+ *
+ * @param {string} name
+ * @returns {VCardPropertyEntry[]}
+ */
+ getAllEntries(name) {
+ return this.entries.filter(e => e.name == name);
+ }
+
+ /**
+ * Get all entries matching the given name, sorted in order of preference.
+ * Preference is determined by the `pref` parameter if it exists, then by
+ * the position in `entries`.
+ *
+ * @param {string} name
+ * @returns {VCardPropertyEntry[]}
+ */
+ getAllEntriesSorted(name) {
+ let nextPref = 101;
+ let entries = this.getAllEntries(name).map(e => {
+ return { entry: e, pref: e.params.pref || nextPref++ };
+ });
+ entries.sort((a, b) => a.pref - b.pref);
+ return entries.map(e => e.entry);
+ }
+
+ /**
+ * Get all entries matching the given group.
+ *
+ * @param {string} group
+ * @returns {VCardPropertyEntry[]}
+ */
+ getGroupedEntries(group) {
+ return this.entries.filter(e => e.params.group == group);
+ }
+
+ /**
+ * Clone this object.
+ *
+ * @returns {VCardProperties}
+ */
+ clone() {
+ let copy = new VCardProperties();
+ copy.entries = this.entries.map(e => e.clone());
+ return copy;
+ }
+
+ /**
+ * Get a Map of Address Book properties from this object.
+ *
+ * @returns {Map<string, string>} propertyMap
+ */
+ toPropertyMap() {
+ let vPropMap = VCardUtils._parse(this.entries.map(e => e.clone()));
+ let propertyMap = new Map();
+
+ for (let [name, props] of vPropMap) {
+ // Store the value(s) on the abCard.
+ for (let [abPropName, abPropValue] of typeMap[name].toAbCard(
+ props[0].value
+ )) {
+ if (abPropValue) {
+ propertyMap.set(abPropName, abPropValue);
+ }
+ }
+ // Special case for email, which can also have a second preference.
+ if (name == "email" && props.length > 1) {
+ propertyMap.set("SecondEmail", props[1].value);
+ }
+ }
+
+ return propertyMap;
+ }
+
+ /**
+ * Serialize this object into a vCard.
+ *
+ * @returns {string} vCard
+ */
+ toVCard() {
+ let jCal = this.entries.map(e => {
+ if (Array.isArray(e.value)) {
+ let design = this.designSet.property[e.name];
+ if (design.multiValue == "," && !design.structuredValue) {
+ // The jCal format stores multiple values as the 4th...nth items,
+ // but VCardPropertyEntry stores them as an array. This applies to
+ // CATEGORIES and NICKNAME types in vCard 4 and also NOTE in vCard 3.
+ return [e.name, e.params, e.type, ...e.value];
+ }
+ }
+ return [e.name, e.params, e.type, e.value];
+ });
+ return ICAL.stringify(["vcard", jCal]);
+ }
+}