diff options
Diffstat (limited to '')
-rw-r--r-- | comm/mailnews/addrbook/modules/CardDAVDirectory.jsm | 925 |
1 files changed, 925 insertions, 0 deletions
diff --git a/comm/mailnews/addrbook/modules/CardDAVDirectory.jsm b/comm/mailnews/addrbook/modules/CardDAVDirectory.jsm new file mode 100644 index 0000000000..e4361d53bb --- /dev/null +++ b/comm/mailnews/addrbook/modules/CardDAVDirectory.jsm @@ -0,0 +1,925 @@ +/* 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 = ["CardDAVDirectory"]; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +const { SQLiteDirectory } = ChromeUtils.import( + "resource:///modules/SQLiteDirectory.jsm" +); + +ChromeUtils.defineESModuleGetters(lazy, { + clearInterval: "resource://gre/modules/Timer.sys.mjs", + setInterval: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + CardDAVUtils: "resource:///modules/CardDAVUtils.jsm", + NotificationCallbacks: "resource:///modules/CardDAVUtils.jsm", + OAuth2Module: "resource:///modules/OAuth2Module.jsm", + OAuth2Providers: "resource:///modules/OAuth2Providers.jsm", + VCardProperties: "resource:///modules/VCardUtils.jsm", + VCardUtils: "resource:///modules/VCardUtils.jsm", +}); + +const PREFIX_BINDINGS = { + card: "urn:ietf:params:xml:ns:carddav", + cs: "http://calendarserver.org/ns/", + d: "DAV:", +}; +const NAMESPACE_STRING = Object.entries(PREFIX_BINDINGS) + .map(([prefix, url]) => `xmlns:${prefix}="${url}"`) + .join(" "); + +const log = console.createInstance({ + prefix: "carddav.sync", + maxLogLevel: "Warn", + maxLogLevelPref: "carddav.sync.loglevel", +}); + +/** + * Adds CardDAV sync to SQLiteDirectory. + */ +class CardDAVDirectory extends SQLiteDirectory { + /** nsIAbDirectory */ + + init(uri) { + super.init(uri); + + let serverURL = this._serverURL; + if (serverURL) { + // Google's server enforces some vCard 3.0-isms (or just fails badly if + // you don't provide exactly what it wants) so we use this property to + // determine when to do things differently. Cards from this directory + // inherit the same property. + if (this.getBoolValue("carddav.vcard3")) { + this._isGoogleCardDAV = true; + } else { + this._isGoogleCardDAV = serverURL.startsWith( + "https://www.googleapis.com/" + ); + if (this._isGoogleCardDAV) { + this.setBoolValue("carddav.vcard3", true); + } + } + + // If this directory is configured, start sync'ing with the server in 30s. + // Don't do this immediately, as this code runs at start-up and could + // impact performance if there are lots of changes to process. + if (this.getIntValue("carddav.syncinterval", 30) > 0) { + this._syncTimer = lazy.setTimeout(() => this.syncWithServer(), 30000); + } + } + + let uidsToSync = this.getStringValue("carddav.uidsToSync", ""); + if (uidsToSync) { + this._uidsToSync = new Set(uidsToSync.split(" ").filter(Boolean)); + this.setStringValue("carddav.uidsToSync", ""); + log.debug(`Retrieved list of cards to sync: ${uidsToSync}`); + } else { + this._uidsToSync = new Set(); + } + + let hrefsToRemove = this.getStringValue("carddav.hrefsToRemove", ""); + if (hrefsToRemove) { + this._hrefsToRemove = new Set(hrefsToRemove.split(" ").filter(Boolean)); + this.setStringValue("carddav.hrefsToRemove", ""); + log.debug(`Retrieved list of cards to remove: ${hrefsToRemove}`); + } else { + this._hrefsToRemove = new Set(); + } + } + async cleanUp() { + await super.cleanUp(); + + if (this._syncTimer) { + lazy.clearInterval(this._syncTimer); + this._syncTimer = null; + } + + if (this._uidsToSync.size) { + let uidsToSync = [...this._uidsToSync].join(" "); + this.setStringValue("carddav.uidsToSync", uidsToSync); + log.debug(`Stored list of cards to sync: ${uidsToSync}`); + } + if (this._hrefsToRemove.size) { + let hrefsToRemove = [...this._hrefsToRemove].join(" "); + this.setStringValue("carddav.hrefsToRemove", hrefsToRemove); + log.debug(`Stored list of cards to remove: ${hrefsToRemove}`); + } + } + + get propertiesChromeURI() { + return "chrome://messenger/content/addressbook/abCardDAVProperties.xhtml"; + } + get dirType() { + return Ci.nsIAbManager.CARDDAV_DIRECTORY_TYPE; + } + get supportsMailingLists() { + return false; + } + + modifyCard(card) { + // Well this is awkward. Because it's defined in nsIAbDirectory, + // modifyCard must not be async, but we need to do async operations. + let newCard = super.modifyCard(card); + this._modifyCard(newCard); + } + async _modifyCard(card) { + try { + await this._sendCardToServer(card); + } catch (ex) { + console.error(ex); + } + } + deleteCards(cards) { + super.deleteCards(cards); + this._deleteCards(cards); + } + async _deleteCards(cards) { + for (let card of cards) { + try { + await this._deleteCardFromServer(card); + } catch (ex) { + console.error(ex); + break; + } + } + + for (let card of cards) { + this._uidsToSync.delete(card.UID); + } + } + dropCard(card, needToCopyCard) { + // Ideally, we'd not add the card until it was on the server, but we have + // to return newCard synchronously. + let newCard = super.dropCard(card, needToCopyCard); + this._sendCardToServer(newCard).catch(console.error); + return newCard; + } + addMailList() { + throw Components.Exception( + "CardDAVDirectory does not implement addMailList", + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + setIntValue(name, value) { + super.setIntValue(name, value); + + // Capture changes to the sync interval from the UI. + if (name == "carddav.syncinterval") { + this._scheduleNextSync(); + } + } + + /** CardDAV specific */ + _syncInProgress = false; + _syncTimer = null; + + get _serverURL() { + return this.getStringValue("carddav.url", ""); + } + get _syncToken() { + return this.getStringValue("carddav.token", ""); + } + set _syncToken(value) { + this.setStringValue("carddav.token", value); + } + + /** + * Wraps CardDAVUtils.makeRequest, resolving path this directory's server + * URL, and providing a mechanism to give a username and password specific + * to this directory. + * + * @param {string} path - A path relative to the server URL. + * @param {object} details - See CardDAVUtils.makeRequest. + * @returns {Promise<object>} - See CardDAVUtils.makeRequest. + */ + async _makeRequest(path, details = {}) { + let serverURI = Services.io.newURI(this._serverURL); + let uri = serverURI.resolve(path); + + if (!("_oAuth" in this)) { + if (lazy.OAuth2Providers.getHostnameDetails(serverURI.host)) { + this._oAuth = new lazy.OAuth2Module(); + this._oAuth.initFromABDirectory(this, serverURI.host); + } else { + this._oAuth = null; + } + } + details.oAuth = this._oAuth; + + let username = this.getStringValue("carddav.username", ""); + let callbacks = new lazy.NotificationCallbacks(username); + details.callbacks = callbacks; + + details.userContextId = + this._userContextId ?? lazy.CardDAVUtils.contextForUsername(username); + + let response; + try { + Services.obs.notifyObservers( + this, + "addrbook-directory-request-start", + this.UID + ); + response = await lazy.CardDAVUtils.makeRequest(uri, details); + } finally { + Services.obs.notifyObservers( + this, + "addrbook-directory-request-end", + this.UID + ); + } + if ( + details.expectedStatuses && + !details.expectedStatuses.includes(response.status) + ) { + throw Components.Exception( + `Incorrect response from server: ${response.status} ${response.statusText}`, + Cr.NS_ERROR_FAILURE + ); + } + + if (callbacks.shouldSaveAuth) { + // The user was prompted for a username and password. Save the response. + this.setStringValue("carddav.username", callbacks.authInfo?.username); + callbacks.saveAuth(); + } + return response; + } + + /** + * Gets or creates the path for storing this card on the server. Cards that + * already exist on the server have this value in the _href property. + * + * @param {nsIAbCard} card + * @returns {string} + */ + _getCardHref(card) { + let href = card.getProperty("_href", ""); + if (href) { + return href; + } + href = Services.io.newURI(this._serverURL).resolve(`${card.UID}.vcf`); + return new URL(href).pathname; + } + + _multigetRequest(hrefsToFetch) { + hrefsToFetch = hrefsToFetch.map( + href => ` <d:href>${xmlEncode(href)}</d:href>` + ); + let data = `<card:addressbook-multiget ${NAMESPACE_STRING}> + <d:prop> + <d:getetag/> + <card:address-data/> + </d:prop> + ${hrefsToFetch.join("\n")} + </card:addressbook-multiget>`; + + return this._makeRequest("", { + method: "REPORT", + body: data, + headers: { + Depth: 1, + }, + expectedStatuses: [207], + }); + } + + /** + * Performs a multiget request for the provided hrefs, and adds each response + * to the directory, adding or modifying as necessary. + * + * @param {string[]} hrefsToFetch - The href of each card to be requested. + */ + async _fetchAndStore(hrefsToFetch) { + if (hrefsToFetch.length == 0) { + return; + } + + let response = await this._multigetRequest(hrefsToFetch); + + // If this directory is set to read-only, the following operations would + // throw NS_ERROR_FAILURE, but sync operations are allowed on a read-only + // directory, so set this._overrideReadOnly to avoid the exception. + // + // Do not use await while it is set, and use a try/finally block to ensure + // it is cleared. + + try { + this._overrideReadOnly = true; + for (let { href, properties } of this._readResponse(response.dom)) { + if (!properties) { + continue; + } + + let etag = properties.querySelector("getetag")?.textContent; + let vCard = normalizeLineEndings( + properties.querySelector("address-data")?.textContent + ); + + let abCard = lazy.VCardUtils.vCardToAbCard(vCard); + abCard.setProperty("_etag", etag); + abCard.setProperty("_href", href); + + if (!this.cards.has(abCard.UID)) { + super.dropCard(abCard, false); + } else if (this.loadCardProperties(abCard.UID).get("_etag") != etag) { + super.modifyCard(abCard); + } + } + } finally { + this._overrideReadOnly = false; + } + } + + /** + * Reads a multistatus response, yielding once for each response element. + * + * @param {Document} dom - as returned by CardDAVUtils.makeRequest. + * @yields {object} - An object representing a single <response> element + * from the document: + * - href, the href of the object represented + * - notFound, if a 404 status applies to this response + * - properties, the <prop> element, if any, containing properties + * of the object represented + */ + _readResponse = function* (dom) { + if (!dom || dom.documentElement.localName != "multistatus") { + throw Components.Exception( + `Expected a multistatus response, but didn't get one`, + Cr.NS_ERROR_FAILURE + ); + } + + for (let r of dom.querySelectorAll("response")) { + let response = { + href: r.querySelector("href")?.textContent, + }; + + let responseStatus = r.querySelector("response > status"); + if (responseStatus?.textContent.startsWith("HTTP/1.1 404")) { + response.notFound = true; + yield response; + continue; + } + + for (let p of r.querySelectorAll("response > propstat")) { + let status = p.querySelector("propstat > status").textContent; + if (status == "HTTP/1.1 200 OK") { + response.properties = p.querySelector("propstat > prop"); + } + } + + yield response; + } + }; + + /** + * Converts the card to a vCard and performs a PUT request to store it on the + * server. Then immediately performs a GET request ensuring the local copy + * matches the server copy. Stores the card in the database on success. + * + * @param {nsIAbCard} card + * @returns {boolean} true if the PUT request succeeded without conflict, + * false if there was a conflict. + * @throws if the server responded with anything other than a success or + * conflict status code. + */ + async _sendCardToServer(card) { + let href = this._getCardHref(card); + let requestDetails = { + method: "PUT", + contentType: "text/vcard", + }; + + let vCard = card.getProperty("_vCard", ""); + if (this._isGoogleCardDAV) { + // There must be an `N` property, even if empty. + let vCardProperties = lazy.VCardProperties.fromVCard(vCard); + if (!vCardProperties.getFirstEntry("n")) { + vCardProperties.addValue("n", ["", "", "", "", ""]); + } + requestDetails.body = vCardProperties.toVCard(); + } else { + requestDetails.body = vCard; + } + + let response; + try { + log.debug(`Sending ${href} to server.`); + response = await this._makeRequest(href, requestDetails); + } catch (ex) { + Services.obs.notifyObservers(this, "addrbook-directory-sync-failed"); + this._uidsToSync.add(card.UID); + throw ex; + } + + if (response.status >= 400) { + throw Components.Exception( + `Sending card to the server failed, response was ${response.status} ${response.statusText}`, + Cr.NS_ERROR_FAILURE + ); + } + + // At this point we *should* be able to make a simple GET request and + // store the response. But Google moves the data (fair enough) without + // telling us where it went (c'mon, really?). Fortunately a multiget + // request at the original location works. + + response = await this._multigetRequest([href]); + + for (let { href, properties } of this._readResponse(response.dom)) { + if (!properties) { + continue; + } + + let etag = properties.querySelector("getetag")?.textContent; + let vCard = normalizeLineEndings( + properties.querySelector("address-data")?.textContent + ); + + let abCard = lazy.VCardUtils.vCardToAbCard(vCard); + abCard.setProperty("_etag", etag); + abCard.setProperty("_href", href); + + if (abCard.UID == card.UID) { + super.modifyCard(abCard); + } else { + // Add a property so the UI can work out if it's still displaying the + // old card and respond appropriately. + abCard.setProperty("_originalUID", card.UID); + super.dropCard(abCard, false); + super.deleteCards([card]); + } + } + } + + /** + * Deletes card from the server. + * + * @param {nsIAbCard|string} cardOrHRef + */ + async _deleteCardFromServer(cardOrHRef) { + let href; + if (typeof cardOrHRef == "string") { + href = cardOrHRef; + } else { + href = cardOrHRef.getProperty("_href", ""); + } + if (!href) { + return; + } + + try { + log.debug(`Removing ${href} from server.`); + await this._makeRequest(href, { method: "DELETE" }); + } catch (ex) { + Services.obs.notifyObservers(this, "addrbook-directory-sync-failed"); + this._hrefsToRemove.add(href); + throw ex; + } + } + + /** + * Set up a repeating timer for synchronisation with the server. The timer's + * interval is defined by pref, set it to 0 to disable sync'ing altogether. + */ + _scheduleNextSync() { + if (this._syncTimer) { + lazy.clearInterval(this._syncTimer); + this._syncTimer = null; + } + + let interval = this.getIntValue("carddav.syncinterval", 30); + if (interval <= 0) { + return; + } + + this._syncTimer = lazy.setInterval( + () => this.syncWithServer(false), + interval * 60000 + ); + } + + /** + * Get all cards on the server and add them to this directory. + * + * This is usually used for the initial population of a directory, but it + * can also be used for a complete re-sync. + */ + async fetchAllFromServer() { + log.log("Fetching all cards from the server."); + this._syncInProgress = true; + + let data = `<propfind xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}> + <prop> + <resourcetype/> + <getetag/> + <cs:getctag/> + </prop> + </propfind>`; + + let response = await this._makeRequest("", { + method: "PROPFIND", + body: data, + headers: { + Depth: 1, + }, + expectedStatuses: [207], + }); + + // A map of all existing hrefs and etags. If the etag for an href matches + // what we already have, we won't fetch it. + let currentHrefs = new Map( + Array.from(this.cards.values(), c => [c.get("_href"), c.get("_etag")]) + ); + + let hrefsToFetch = []; + for (let { href, properties } of this._readResponse(response.dom)) { + if (!properties || properties.querySelector("resourcetype collection")) { + continue; + } + + let currentEtag = currentHrefs.get(href); + currentHrefs.delete(href); + + let etag = properties.querySelector("getetag")?.textContent; + if (etag && currentEtag == etag) { + continue; + } + + hrefsToFetch.push(href); + } + + // Delete any existing cards we didn't see. They're not on the server so + // they shouldn't be on the client. + let cardsToDelete = []; + for (let href of currentHrefs.keys()) { + cardsToDelete.push(this.getCardFromProperty("_href", href, true)); + } + if (cardsToDelete.length > 0) { + super.deleteCards(cardsToDelete); + } + + // Fetch any cards we don't already have, or that have changed. + if (hrefsToFetch.length > 0) { + response = await this._multigetRequest(hrefsToFetch); + + let abCards = []; + + for (let { href, properties } of this._readResponse(response.dom)) { + if (!properties) { + continue; + } + + let etag = properties.querySelector("getetag")?.textContent; + let vCard = normalizeLineEndings( + properties.querySelector("address-data")?.textContent + ); + + try { + let abCard = lazy.VCardUtils.vCardToAbCard(vCard); + abCard.setProperty("_etag", etag); + abCard.setProperty("_href", href); + abCards.push(abCard); + } catch (ex) { + log.error(`Error parsing: ${vCard}`); + console.error(ex); + } + } + + await this.bulkAddCards(abCards); + } + + await this._getSyncToken(); + + log.log("Sync with server completed successfully."); + Services.obs.notifyObservers(this, "addrbook-directory-synced"); + + this._scheduleNextSync(); + this._syncInProgress = false; + } + + /** + * Begin a sync operation. This function will decide which sync protocol to + * use based on the directory's configuration. It will also (re)start the + * timer for the next synchronisation unless told not to. + * + * @param {boolean} shouldResetTimer + */ + async syncWithServer(shouldResetTimer = true) { + if (this._syncInProgress || !this._serverURL) { + return; + } + + log.log("Performing sync with server."); + this._syncInProgress = true; + + try { + // First perform all pending removals. We don't want to have deleted cards + // reappearing when we sync. + for (let href of this._hrefsToRemove) { + await this._deleteCardFromServer(href); + } + this._hrefsToRemove.clear(); + + // Now update any cards that were modified while not connected to the server. + for (let uid of this._uidsToSync) { + let card = this.getCard(uid); + // The card may no longer exist. It shouldn't still be listed to send, + // but it might be. + if (card) { + await this._sendCardToServer(card); + } + } + this._uidsToSync.clear(); + + if (this._syncToken) { + await this.updateAllFromServerV2(); + } else { + await this.updateAllFromServerV1(); + } + } catch (ex) { + log.error("Sync with server failed."); + throw ex; + } finally { + if (shouldResetTimer) { + this._scheduleNextSync(); + } + this._syncInProgress = false; + } + } + + /** + * Compares cards in the directory with cards on the server, and updates the + * directory to match what is on the server. + */ + async updateAllFromServerV1() { + let data = `<propfind xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}> + <prop> + <resourcetype/> + <getetag/> + <cs:getctag/> + </prop> + </propfind>`; + + let response = await this._makeRequest("", { + method: "PROPFIND", + body: data, + headers: { + Depth: 1, + }, + expectedStatuses: [207], + }); + + let hrefMap = new Map(); + for (let { href, properties } of this._readResponse(response.dom)) { + if ( + !properties || + !properties.querySelector("resourcetype") || + properties.querySelector("resourcetype collection") + ) { + continue; + } + + let etag = properties.querySelector("getetag").textContent; + hrefMap.set(href, etag); + } + + let cardMap = new Map(); + let hrefsToFetch = []; + let cardsToDelete = []; + for (let card of this.childCards) { + let href = card.getProperty("_href", ""); + let etag = card.getProperty("_etag", ""); + + if (!href || !etag) { + // Not sure how we got here. Ignore it. + continue; + } + cardMap.set(href, card); + if (hrefMap.has(href)) { + if (hrefMap.get(href) != etag) { + // The card was updated on server. + hrefsToFetch.push(href); + } + } else { + // The card doesn't exist on the server. + cardsToDelete.push(card); + } + } + + for (let href of hrefMap.keys()) { + if (!cardMap.has(href)) { + // The card is new on the server. + hrefsToFetch.push(href); + } + } + + // If this directory is set to read-only, the following operations would + // throw NS_ERROR_FAILURE, but sync operations are allowed on a read-only + // directory, so set this._overrideReadOnly to avoid the exception. + // + // Do not use await while it is set, and use a try/finally block to ensure + // it is cleared. + + if (cardsToDelete.length > 0) { + this._overrideReadOnly = true; + try { + super.deleteCards(cardsToDelete); + } finally { + this._overrideReadOnly = false; + } + } + + await this._fetchAndStore(hrefsToFetch); + + log.log("Sync with server completed successfully."); + Services.obs.notifyObservers(this, "addrbook-directory-synced"); + } + + /** + * Retrieves the current sync token from the server. + * + * @see RFC 6578 + */ + async _getSyncToken() { + log.log("Fetching new sync token"); + + let data = `<propfind xmlns="${PREFIX_BINDINGS.d}" ${NAMESPACE_STRING}> + <prop> + <displayname/> + <cs:getctag/> + <sync-token/> + </prop> + </propfind>`; + + let response = await this._makeRequest("", { + method: "PROPFIND", + body: data, + headers: { + Depth: 0, + }, + }); + + if (response.status == 207) { + for (let { properties } of this._readResponse(response.dom)) { + let token = properties?.querySelector("prop sync-token"); + if (token) { + this._syncToken = token.textContent; + return; + } + } + } + + this._syncToken = ""; + } + + /** + * Gets a list of changes on the server since the last call to getSyncToken + * or updateAllFromServerV2, and updates the directory to match what is on + * the server. + * + * @see RFC 6578 + */ + async updateAllFromServerV2() { + let syncToken = this._syncToken; + if (!syncToken) { + throw new Components.Exception("No sync token", Cr.NS_ERROR_UNEXPECTED); + } + + let data = `<sync-collection xmlns="${ + PREFIX_BINDINGS.d + }" ${NAMESPACE_STRING}> + <sync-token>${xmlEncode(syncToken)}</sync-token> + <sync-level>1</sync-level> + <prop> + <getetag/> + <card:address-data/> + </prop> + </sync-collection>`; + + let response = await this._makeRequest("", { + method: "REPORT", + body: data, + headers: { + Depth: 1, // Only Google seems to need this. + }, + expectedStatuses: [207, 400], + }); + + if (response.status == 400) { + log.warn( + `Server responded with: ${response.status} ${response.statusText}` + ); + await this.fetchAllFromServer(); + return; + } + + let dom = response.dom; + + // If this directory is set to read-only, the following operations would + // throw NS_ERROR_FAILURE, but sync operations are allowed on a read-only + // directory, so set this._overrideReadOnly to avoid the exception. + // + // Do not use await while it is set, and use a try/finally block to ensure + // it is cleared. + + let hrefsToFetch = []; + try { + this._overrideReadOnly = true; + let cardsToDelete = []; + for (let { href, notFound, properties } of this._readResponse(dom)) { + let card = this.getCardFromProperty("_href", href, true); + if (notFound) { + if (card) { + cardsToDelete.push(card); + } + continue; + } + if (!properties) { + continue; + } + + let etag = properties.querySelector("getetag")?.textContent; + if (!etag) { + continue; + } + let vCard = properties.querySelector("address-data")?.textContent; + if (!vCard) { + hrefsToFetch.push(href); + continue; + } + vCard = normalizeLineEndings(vCard); + + let abCard = lazy.VCardUtils.vCardToAbCard(vCard); + abCard.setProperty("_etag", etag); + abCard.setProperty("_href", href); + + if (card) { + if (card.getProperty("_etag", "") != etag) { + super.modifyCard(abCard); + } + } else { + super.dropCard(abCard, false); + } + } + + if (cardsToDelete.length > 0) { + super.deleteCards(cardsToDelete); + } + } finally { + this._overrideReadOnly = false; + } + + await this._fetchAndStore(hrefsToFetch); + + this._syncToken = dom.querySelector("sync-token").textContent; + + log.log("Sync with server completed successfully."); + Services.obs.notifyObservers(this, "addrbook-directory-synced"); + } + + static forFile(fileName) { + let directory = super.forFile(fileName); + if (directory instanceof CardDAVDirectory) { + return directory; + } + return undefined; + } +} +CardDAVDirectory.prototype.classID = Components.ID( + "{1fa9941a-07d5-4a6f-9673-15327fc2b9ab}" +); + +/** + * Ensure that `string` always has Windows line-endings. Some functions, + * notably DOMParser.parseFromString, strip \r, but we want it because \r\n + * is a part of the vCard specification. + */ +function normalizeLineEndings(string) { + if (string.includes("\r\n")) { + return string; + } + return string.replace(/\n/g, "\r\n"); +} + +/** + * Encode special characters safely for XML. + */ +function xmlEncode(string) { + return string + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/</g, "<") + .replace(/>/g, ">"); +} |