Bug 1624207 - Implement address book export in javascript. r=mkmelin
authorGeoff Lankow <geoff@darktrojan.net>
Mon, 23 Mar 2020 22:29:02 +1300
changeset 38647 d42df03dfe10ed6b1574ff7946874bb5099a0824
parent 38646 f38c4f986fa7dc0a930c2b1a60fca96471d29171
child 38648 7b54036ce7d67afa8361606c720287e5710b4775
push id400
push userclokep@gmail.com
push dateMon, 04 May 2020 18:56:09 +0000
reviewersmkmelin
bugs1624207
Bug 1624207 - Implement address book export in javascript. r=mkmelin
mail/components/addrbook/content/addressbook.js
mailnews/addrbook/jsaddrbook/AddrBookManager.jsm
mailnews/addrbook/jsaddrbook/AddrBookUtils.jsm
mailnews/addrbook/public/nsIAbManager.idl
mailnews/addrbook/test/unit/data/export.csv
mailnews/addrbook/test/unit/data/export.ldif
mailnews/addrbook/test/unit/data/export.txt
mailnews/addrbook/test/unit/data/export.vcf
mailnews/addrbook/test/unit/test_export.js
mailnews/addrbook/test/unit/xpcshell.ini
--- a/mail/components/addrbook/content/addressbook.js
+++ b/mail/components/addrbook/content/addressbook.js
@@ -9,16 +9,21 @@
 /* import-globals-from abCardView.js */
 /* import-globals-from abCommon.js */
 
 // Ensure the activity modules are loaded for this window.
 ChromeUtils.import("resource:///modules/activity/activityModules.jsm");
 var { getSearchTokens, getModelQuery, generateQueryURI } = ChromeUtils.import(
   "resource:///modules/ABQueryUtils.jsm"
 );
+var {
+  exportDirectoryToLDIF,
+  exportDirectoryToDelimitedText,
+  exportDirectoryToVCard,
+} = ChromeUtils.import("resource:///modules/AddrBookUtils.jsm");
 var { MailServices } = ChromeUtils.import(
   "resource:///modules/MailServices.jsm"
 );
 var { PluralForm } = ChromeUtils.import(
   "resource://gre/modules/PluralForm.jsm"
 );
 var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
 
@@ -474,43 +479,189 @@ function AbExportAll() {
  *
  * @param aSelectedDirURI  The URI of the addressbook to export.
  */
 function AbExport(aSelectedDirURI) {
   if (!aSelectedDirURI) {
     return;
   }
 
-  try {
-    let directory = GetDirectoryFromURI(aSelectedDirURI);
-    MailServices.ab.exportAddressBook(window, directory);
-  } catch (ex) {
-    let message;
-    switch (ex.result) {
-      case Cr.NS_ERROR_FILE_ACCESS_DENIED:
-        message = gAddressBookBundle.getString(
-          "failedToExportMessageFileAccessDenied"
-        );
+  let systemCharset = "utf-8";
+  if (AppConstants.platform == "win") {
+    // Some Windows applications (notably Outlook) still don't understand
+    // UTF-8 encoding when importing address books and instead use the current
+    // operating system encoding. We can get that encoding from the registry.
+    let registryKey = Cc["@mozilla.org/windows-registry-key;1"].createInstance(
+      Ci.nsIWindowsRegKey
+    );
+    registryKey.open(
+      Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE,
+      "SYSTEM\\CurrentControlSet\\Control\\Nls\\CodePage",
+      Ci.nsIWindowsRegKey.ACCESS_READ
+    );
+    let acpValue = registryKey.readStringValue("ACP");
+
+    // This data converts the registry key value into encodings that
+    // nsIConverterOutputStream understands. It is from
+    // https://github.com/hsivonen/encoding_rs/blob/c3eb642cdf3f17003b8dac95c8fff478568e46da/generate-encoding-data.py#L188
+    systemCharset =
+      {
+        866: "IBM866",
+        874: "windows-874",
+        932: "Shift_JIS",
+        936: "GBK",
+        949: "EUC-KR",
+        950: "Big5",
+        1200: "UTF-16LE",
+        1201: "UTF-16BE",
+        1250: "windows-1250",
+        1251: "windows-1251",
+        1252: "windows-1252",
+        1253: "windows-1253",
+        1254: "windows-1254",
+        1255: "windows-1255",
+        1256: "windows-1256",
+        1257: "windows-1257",
+        1258: "windows-1258",
+        10000: "macintosh",
+        10017: "x-mac-cyrillic",
+        20866: "KOI8-R",
+        20932: "EUC-JP",
+        21866: "KOI8-U",
+        28592: "ISO-8859-2",
+        28593: "ISO-8859-3",
+        28594: "ISO-8859-4",
+        28595: "ISO-8859-5",
+        28596: "ISO-8859-6",
+        28597: "ISO-8859-7",
+        28598: "ISO-8859-8",
+        28600: "ISO-8859-10",
+        28603: "ISO-8859-13",
+        28604: "ISO-8859-14",
+        28605: "ISO-8859-15",
+        28606: "ISO-8859-16",
+        38598: "ISO-8859-8-I",
+        50221: "ISO-2022-JP",
+        54936: "gb18030",
+      }[acpValue] || systemCharset;
+  }
+
+  let directory = GetDirectoryFromURI(aSelectedDirURI);
+  let filePicker = Cc["@mozilla.org/filepicker;1"].createInstance(
+    Ci.nsIFilePicker
+  );
+  let bundle = Services.strings.createBundle(
+    "chrome://messenger/locale/addressbook/addressBook.properties"
+  );
+
+  let title = bundle.formatStringFromName("ExportAddressBookNameTitle", [
+    directory.dirName,
+  ]);
+  filePicker.init(window, title, Ci.nsIFilePicker.modeSave);
+  filePicker.defaultString = directory.dirName;
+
+  let filterString;
+  // Since the list of file picker filters isn't fixed, keep track of which
+  // ones are added, so we can use them in the switch block below.
+  let activeFilters = [];
+
+  // CSV
+  if (systemCharset != "utf-8") {
+    filterString = bundle.GetStringFromName("CSVFilesSysCharset");
+    filePicker.appendFilter(filterString, "*.csv");
+    activeFilters.push("CSVFilesSysCharset");
+  }
+  filterString = bundle.GetStringFromName("CSVFilesUTF8");
+  filePicker.appendFilter(filterString, "*.csv");
+  activeFilters.push("CSVFilesUTF8");
+
+  // Tab separated
+  if (systemCharset != "utf-8") {
+    filterString = bundle.GetStringFromName("TABFilesSysCharset");
+    filePicker.appendFilter(filterString, "*.tab; *.txt");
+    activeFilters.push("TABFilesSysCharset");
+  }
+  filterString = bundle.GetStringFromName("TABFilesUTF8");
+  filePicker.appendFilter(filterString, "*.tab; *.txt");
+  activeFilters.push("TABFilesUTF8");
+
+  // vCard
+  filterString = bundle.GetStringFromName("VCFFiles");
+  filePicker.appendFilter(filterString, "*.vcf");
+  activeFilters.push("VCFFiles");
+
+  // LDIF
+  filterString = bundle.GetStringFromName("LDIFFiles");
+  filePicker.appendFilter(filterString, "*.ldi; *.ldif");
+  activeFilters.push("LDIFFiles");
+
+  filePicker.open(rv => {
+    if (
+      rv == Ci.nsIFilePicker.returnCancel ||
+      !filePicker.file ||
+      !filePicker.file.path
+    ) {
+      return;
+    }
+
+    if (rv == Ci.nsIFilePicker.returnReplace) {
+      if (filePicker.file.isFile()) {
+        filePicker.file.remove(false);
+      }
+    }
+
+    let exportFile = filePicker.file.clone();
+    let leafName = exportFile.leafName;
+    let output = "";
+    let charset = "utf-8";
+
+    switch (activeFilters[filePicker.filterIndex]) {
+      case "CSVFilesSysCharset":
+        charset = systemCharset;
+      // Falls through.
+      case "CSVFilesUTF8":
+        if (!leafName.endsWith(".csv")) {
+          exportFile.leafName += ".csv";
+        }
+        output = exportDirectoryToDelimitedText(directory, ",");
         break;
-      case Cr.NS_ERROR_FILE_NO_DEVICE_SPACE:
-        message = gAddressBookBundle.getString(
-          "failedToExportMessageNoDeviceSpace"
-        );
+      case "TABFilesSysCharset":
+        charset = systemCharset;
+      // Falls through.
+      case "TABFilesUTF8":
+        if (!leafName.endsWith(".txt") && !leafName.endsWith(".tab")) {
+          exportFile.leafName += ".txt";
+        }
+        output = exportDirectoryToDelimitedText(directory, "\t");
         break;
-      default:
-        message = ex.message;
+      case "VCFFiles":
+        if (!leafName.endsWith(".vcf")) {
+          exportFile.leafName += ".vcf";
+        }
+        output = exportDirectoryToVCard(directory);
+        break;
+      case "LDIFFiles":
+        if (!leafName.endsWith(".ldi") && !leafName.endsWith(".ldif")) {
+          exportFile.leafName += ".ldif";
+        }
+        output = exportDirectoryToLDIF(directory);
         break;
     }
 
-    Services.prompt.alert(
-      window,
-      gAddressBookBundle.getString("failedToExportTitle"),
-      message
-    );
-  }
+    let outputFileStream = Cc[
+      "@mozilla.org/network/file-output-stream;1"
+    ].createInstance(Ci.nsIFileOutputStream);
+    outputFileStream.init(exportFile, -1, -1, 0);
+    let outputStream = Cc[
+      "@mozilla.org/intl/converter-output-stream;1"
+    ].createInstance(Ci.nsIConverterOutputStream);
+    outputStream.init(outputFileStream, charset);
+    outputStream.writeString(output);
+    outputStream.close();
+  });
 }
 
 function SetStatusText(total) {
   if (!gStatusText) {
     gStatusText = document.getElementById("statusText");
   }
 
   try {
--- a/mailnews/addrbook/jsaddrbook/AddrBookManager.jsm
+++ b/mailnews/addrbook/jsaddrbook/AddrBookManager.jsm
@@ -359,19 +359,16 @@ AddrBookManager.prototype = {
           file.remove(false);
         }
         this.notifyDirectoryDeleted(null, dir);
       });
     } else {
       this.notifyDirectoryDeleted(null, dir);
     }
   },
-  exportAddressBook(parentWin, directory) {
-    throw Cr.NS_ERROR_NOT_IMPLEMENTED;
-  },
   addAddressBookListener(listener, notifyFlags) {
     listeners.set(listener, notifyFlags);
   },
   removeAddressBookListener(listener) {
     listeners.delete(listener);
   },
   notifyItemPropertyChanged(item, property, oldValue, newValue) {
     for (let [listener, notifyFlags] of listeners.entries()) {
--- a/mailnews/addrbook/jsaddrbook/AddrBookUtils.jsm
+++ b/mailnews/addrbook/jsaddrbook/AddrBookUtils.jsm
@@ -1,26 +1,38 @@
 /* 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/. */
 
-this.EXPORTED_SYMBOLS = ["newUID", "SimpleEnumerator"];
+this.EXPORTED_SYMBOLS = [
+  "exportDirectoryToDelimitedText",
+  "exportDirectoryToLDIF",
+  "exportDirectoryToVCard",
+  "newUID",
+  "SimpleEnumerator",
+];
 
-ChromeUtils.defineModuleGetter(
-  this,
-  "XPCOMUtils",
+const { AppConstants } = ChromeUtils.import(
+  "resource://gre/modules/AppConstants.jsm"
+);
+const { MailServices } = ChromeUtils.import(
+  "resource:///modules/MailServices.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
   "resource://gre/modules/XPCOMUtils.jsm"
 );
 
-XPCOMUtils.defineLazyServiceGetter(
-  this,
-  "uuidGenerator",
-  "@mozilla.org/uuid-generator;1",
-  "nsIUUIDGenerator"
-);
+XPCOMUtils.defineLazyServiceGetters(this, {
+  attrMapService: [
+    "@mozilla.org/addressbook/ldap-attribute-map-service;1",
+    "nsIAbLDAPAttributeMapService",
+  ],
+  uuidGenerator: ["@mozilla.org/uuid-generator;1", "nsIUUIDGenerator"],
+});
 
 function SimpleEnumerator(elements) {
   this._elements = elements;
   this._position = 0;
 }
 SimpleEnumerator.prototype = {
   hasMoreElements() {
     return this._position < this._elements.length;
@@ -40,8 +52,228 @@ SimpleEnumerator.prototype = {
 };
 
 function newUID() {
   return uuidGenerator
     .generateUUID()
     .toString()
     .substring(1, 37);
 }
+
+const exportAttributes = [
+  ["FirstName", 2100],
+  ["LastName", 2101],
+  ["DisplayName", 2102],
+  ["NickName", 2103],
+  ["PrimaryEmail", 2104],
+  ["SecondEmail", 2105],
+  ["_AimScreenName", 2136],
+  ["PreferMailFormat", 0],
+  ["LastModifiedDate", 0],
+  ["WorkPhone", 2106],
+  ["WorkPhoneType", 0],
+  ["HomePhone", 2107],
+  ["HomePhoneType", 0],
+  ["FaxNumber", 2108],
+  ["FaxNumberType", 0],
+  ["PagerNumber", 2109],
+  ["PagerNumberType", 0],
+  ["CellularNumber", 2110],
+  ["CellularNumberType", 0],
+  ["HomeAddress", 2111],
+  ["HomeAddress2", 2112],
+  ["HomeCity", 2113],
+  ["HomeState", 2114],
+  ["HomeZipCode", 2115],
+  ["HomeCountry", 2116],
+  ["WorkAddress", 2117],
+  ["WorkAddress2", 2118],
+  ["WorkCity", 2119],
+  ["WorkState", 2120],
+  ["WorkZipCode", 2121],
+  ["WorkCountry", 2122],
+  ["JobTitle", 2123],
+  ["Department", 2124],
+  ["Company", 2125],
+  ["WebPage1", 2126],
+  ["WebPage2", 2127],
+  ["BirthYear", 2128],
+  ["BirthMonth", 2129],
+  ["BirthDay", 2130],
+  ["Custom1", 2131],
+  ["Custom2", 2132],
+  ["Custom3", 2133],
+  ["Custom4", 2134],
+  ["Notes", 2135],
+  ["AnniversaryYear", 0],
+  ["AnniversaryMonth", 0],
+  ["AnniversaryDay", 0],
+  ["SpouseName", 0],
+  ["FamilyName", 0],
+];
+const LINEBREAK = AppConstants.platform == "win" ? "\r\n" : "\n";
+
+function exportDirectoryToDelimitedText(directory, delimiter) {
+  let bundle = Services.strings.createBundle(
+    "chrome://messenger/locale/importMsgs.properties"
+  );
+  let output = "";
+  for (let i = 0; i < exportAttributes.length; i++) {
+    let [, plainTextStringID] = exportAttributes[i];
+    if (plainTextStringID != 0) {
+      if (i != 0) {
+        output += delimiter;
+      }
+      output += bundle.GetStringFromID(plainTextStringID);
+    }
+  }
+  output += LINEBREAK;
+  for (let card of directory.childCards) {
+    if (card.isMailList) {
+      // .tab, .txt and .csv aren't able to export mailing lists.
+      // Use LDIF for that.
+      continue;
+    }
+    for (let i = 0; i < exportAttributes.length; i++) {
+      let [abPropertyName, plainTextStringID] = exportAttributes[i];
+      if (plainTextStringID == 0) {
+        continue;
+      }
+      if (i != 0) {
+        output += delimiter;
+      }
+      let value = card.getProperty(abPropertyName) || "";
+
+      // If a string contains at least one comma, tab, double quote or line
+      // break then we need to quote the entire string. Also if double quote
+      // is part of the string we need to quote the double quote(s) as well.
+      let needsQuotes = false;
+      if (value.includes('"')) {
+        needsQuotes = true;
+        value = value.replace(/"/g, '""');
+      } else if (/[,\t\r\n]/.test(value)) {
+        needsQuotes = true;
+      }
+      if (needsQuotes) {
+        value = `"${value}"`;
+      }
+
+      output += value;
+    }
+    output += LINEBREAK;
+  }
+
+  return output;
+}
+
+function exportDirectoryToLDIF(directory) {
+  function appendProperty(name, value) {
+    if (!value) {
+      return;
+    }
+    // Follow RFC 2849 to determine if something is safe "as is" for LDIF.
+    // If not, base 64 encode it as UTF-8.
+    if (
+      value[0] == " " ||
+      value[0] == ":" ||
+      value[0] == "<" ||
+      /[\0\r\n\u0080-\uffff]/.test(value)
+    ) {
+      let utf8Bytes = new TextEncoder().encode(value);
+      let byteString = String.fromCharCode(...utf8Bytes);
+      output += name + ":: " + btoa(byteString) + LINEBREAK;
+    } else {
+      output += name + ": " + value + LINEBREAK;
+    }
+  }
+
+  function appendDNForCard(property, card, attrMap) {
+    let value = "";
+    if (card.displayName) {
+      value +=
+        attrMap.getFirstAttribute("DisplayName") + "=" + card.displayName;
+    }
+    if (card.primaryEmail) {
+      if (card.displayName) {
+        value += ",";
+      }
+      value +=
+        attrMap.getFirstAttribute("PrimaryEmail") + "=" + card.primaryEmail;
+    }
+    appendProperty(property, value);
+  }
+
+  let output = "";
+  let attrMap = attrMapService.getMapForPrefBranch(
+    "ldap_2.servers.default.attrmap"
+  );
+
+  for (let card of directory.childCards) {
+    if (card.isMailList) {
+      appendDNForCard("dn", card, attrMap);
+      appendProperty("objectclass", "top");
+      appendProperty("objectclass", "groupOfNames");
+      appendProperty(
+        attrMap.getFirstAttribute("DisplayName"),
+        card.displayName
+      );
+      if (card.getProperty("NickName")) {
+        appendProperty(
+          attrMap.getFirstAttribute("NickName"),
+          card.getProperty("NickName")
+        );
+      }
+      if (card.getProperty("Notes")) {
+        appendProperty(
+          attrMap.getFirstAttribute("Notes"),
+          card.getProperty("Notes")
+        );
+      }
+      let listAsDirectory = MailServices.ab.getDirectory(card.mailListURI);
+      for (let childCard of listAsDirectory.childCards) {
+        appendDNForCard("member", childCard, attrMap);
+      }
+    } else {
+      appendDNForCard("dn", card, attrMap);
+      appendProperty("objectclass", "top");
+      appendProperty("objectclass", "person");
+      appendProperty("objectclass", "organizationalPerson");
+      appendProperty("objectclass", "inetOrgPerson");
+      appendProperty("objectclass", "mozillaAbPersonAlpha");
+
+      for (let i = 0; i < exportAttributes.length; i++) {
+        let [abPropertyName] = exportAttributes[i];
+        let attrName = attrMap.getFirstAttribute(abPropertyName);
+        if (attrName) {
+          let attrValue = card.getProperty(abPropertyName);
+          if (abPropertyName == "PreferMailFormat") {
+            if (attrValue == "html") {
+              attrValue = "true";
+            } else if (attrValue == "plaintext") {
+              attrValue = "false";
+            }
+            // unknown.
+            else {
+              attrValue = "";
+            }
+          }
+
+          appendProperty(attrName, attrValue);
+        }
+      }
+    }
+    output += LINEBREAK;
+  }
+
+  return output;
+}
+
+function exportDirectoryToVCard(directory) {
+  let output = "";
+  for (let card of directory.childCards) {
+    if (!card.isMailList) {
+      // We don't know how to export mailing lists to vcf.
+      // Use LDIF for that.
+      output += decodeURIComponent(card.translateTo("vcard"));
+    }
+  }
+  return output;
+}
--- a/mailnews/addrbook/public/nsIAbManager.idl
+++ b/mailnews/addrbook/public/nsIAbManager.idl
@@ -63,25 +63,16 @@ interface nsIAbManager : nsISupports
    * Deletes an address book.
    *
    * @param  aURI       The URI for the address book. This is specific to each
    *                    type of address book.
    */
   void deleteAddressBook(in ACString aURI);
 
   /**
-   * Exports an address book, it will provide a dialog to the user for the
-   * location to save the file to and will then save the address book to media.
-   *
-   * @param  aParentWin Parent Window for the file save dialog to use.
-   * @param  aDirectory The directory to export.
-   */
-  void exportAddressBook(in mozIDOMWindowProxy aParentWin, in nsIAbDirectory aDirectory);
-
-  /**
    * Adds a nsIAbListener to receive notifications of address book updates
    * according to the specified notifyFlags.
    *
    * @param  aListener      The listener that is to receive updates.
    * @param  aNotifyFlags   A bitwise-or of abListenerNotifyFlagValue items
    *                        specifying which notifications to receive. See
    *                        nsIAbListener for possible values.
    */
new file mode 100644
--- /dev/null
+++ b/mailnews/addrbook/test/unit/data/export.csv
@@ -0,0 +1,4 @@
+First Name,Last Name,Display Name,Nickname,Primary Email,Secondary Email,Screen Name,Work Phone,Home Phone,Fax Number,Pager Number,Mobile Number,Home Address,Home Address 2,Home City,Home State,Home ZipCode,Home Country,Work Address,Work Address 2,Work City,Work State,Work ZipCode,Work Country,Job Title,Department,Organization,Web Page 1,Web Page 2,Birth Year,Birth Month,Birth Day,Custom 1,Custom 2,Custom 3,Custom 4,Notes
+contact,one,contact number one,,contact1@invalid,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
+contact,two,contact number two,,contact2@invalid,,,,,,,,,,,,,,,,,,,,"""worker""",,,,,,,,"custom, 1","custom	2","custom
3","custom
+4",here's some unicode text…
new file mode 100644
--- /dev/null
+++ b/mailnews/addrbook/test/unit/data/export.ldif
@@ -0,0 +1,36 @@
+dn: cn=new list
+objectclass: top
+objectclass: groupOfNames
+cn: new list
+member: cn=contact number one,mail=contact1@invalid
+
+dn: cn=contact number one,mail=contact1@invalid
+objectclass: top
+objectclass: person
+objectclass: organizationalPerson
+objectclass: inetOrgPerson
+objectclass: mozillaAbPersonAlpha
+givenName: contact
+sn: one
+cn: contact number one
+mail: contact1@invalid
+modifytimestamp: 0
+
+dn: cn=contact number two,mail=contact2@invalid
+objectclass: top
+objectclass: person
+objectclass: organizationalPerson
+objectclass: inetOrgPerson
+objectclass: mozillaAbPersonAlpha
+givenName: contact
+sn: two
+cn: contact number two
+mail: contact2@invalid
+modifytimestamp: 0
+title: "worker"
+mozillaCustom1: custom, 1
+mozillaCustom2: custom	2
+mozillaCustom3:: Y3VzdG9tDTM=
+mozillaCustom4:: Y3VzdG9tCjQ=
+description:: aGVyZSdzIHNvbWUgdW5pY29kZSB0ZXh04oCm
+
new file mode 100644
--- /dev/null
+++ b/mailnews/addrbook/test/unit/data/export.txt
@@ -0,0 +1,4 @@
+First Name	Last Name	Display Name	Nickname	Primary Email	Secondary Email	Screen Name	Work Phone	Home Phone	Fax Number	Pager Number	Mobile Number	Home Address	Home Address 2	Home City	Home State	Home ZipCode	Home Country	Work Address	Work Address 2	Work City	Work State	Work ZipCode	Work Country	Job Title	Department	Organization	Web Page 1	Web Page 2	Birth Year	Birth Month	Birth Day	Custom 1	Custom 2	Custom 3	Custom 4	Notes
+contact	one	contact number one		contact1@invalid																																
+contact	two	contact number two		contact2@invalid																				"""worker"""								"custom, 1"	"custom	2"	"custom
3"	"custom
+4"	here's some unicode text…
new file mode 100644
--- /dev/null
+++ b/mailnews/addrbook/test/unit/data/export.vcf
@@ -0,0 +1,16 @@
+begin:vcard
+fn:contact number one
+n:one;contact
+email;internet:contact1@invalid
+version:2.1
+end:vcard
+
+begin:vcard
+fn:contact number two
+n:two;contact
+email;internet:contact2@invalid
+title:"worker"
+note;quoted-printable:here's some unicode text=E2=80=A6
+version:2.1
+end:vcard
+
new file mode 100644
--- /dev/null
+++ b/mailnews/addrbook/test/unit/test_export.js
@@ -0,0 +1,114 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var {
+  exportDirectoryToDelimitedText,
+  exportDirectoryToLDIF,
+  exportDirectoryToVCard,
+} = ChromeUtils.import("resource:///modules/AddrBookUtils.jsm");
+var { AppConstants } = ChromeUtils.import(
+  "resource://gre/modules/AppConstants.jsm"
+);
+var { MailServices } = ChromeUtils.import(
+  "resource:///modules/MailServices.jsm"
+);
+var { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
+var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+add_task(async () => {
+  let dirPrefId = MailServices.ab.newAddressBook("new book", "", 101);
+  let book = MailServices.ab.getDirectoryFromId(dirPrefId);
+
+  let contact1 = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+    Ci.nsIAbCard
+  );
+  contact1.displayName = "contact number one";
+  contact1.firstName = "contact";
+  contact1.lastName = "one";
+  contact1.primaryEmail = "contact1@invalid";
+  contact1 = book.addCard(contact1);
+
+  let contact2 = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
+    Ci.nsIAbCard
+  );
+  contact2.displayName = "contact number two";
+  contact2.firstName = "contact";
+  contact2.lastName = "two";
+  contact2.primaryEmail = "contact2@invalid";
+  contact2.setProperty("JobTitle", `"worker"`);
+  contact2.setProperty("Custom1", "custom, 1");
+  contact2.setProperty("Custom2", "custom\t2");
+  contact2.setProperty("Custom3", "custom\r3");
+  contact2.setProperty("Custom4", "custom\n4");
+  contact2.setProperty("Notes", "here's some unicode text…");
+  contact2 = book.addCard(contact2);
+
+  let list = Cc["@mozilla.org/addressbook/directoryproperty;1"].createInstance(
+    Ci.nsIAbDirectory
+  );
+  list.isMailList = true;
+  list.dirName = "new list";
+  list = book.addMailList(list);
+  list.addCard(contact1);
+
+  await compareAgainstFile(
+    "export.csv",
+    exportDirectoryToDelimitedText(book, ",")
+  );
+  await compareAgainstFile(
+    "export.txt",
+    exportDirectoryToDelimitedText(book, "\t")
+  );
+  await compareAgainstFile("export.vcf", exportDirectoryToVCard(book));
+  await compareAgainstFile("export.ldif", exportDirectoryToLDIF(book));
+});
+
+async function compareAgainstFile(fileName, actual) {
+  info(`checking against ${fileName}`);
+
+  // The test files are UTF-8 encoded and have Windows line endings. The
+  // exportDirectoryTo* functions are platform-dependent, except for VCard
+  // which always uses Windows line endings.
+
+  let file = do_get_file(`data/${fileName}`);
+  let contentBytes = await OS.File.read(file.path);
+  let expected = new TextDecoder("utf-8").decode(contentBytes);
+
+  if (AppConstants.platform != "win" && fileName != "export.vcf") {
+    expected = expected.replace(/\r\n/g, "\n");
+  }
+
+  // From here on, \r is just another character. It will be the last character
+  // on lines where Windows line endings exist.
+  let expectedLines = expected.split("\n");
+  let actualLines = actual.split("\n");
+  equal(actualLines.length, expectedLines.length, "correct number of lines");
+
+  for (let l = 0; l < expectedLines.length; l++) {
+    let expectedLine = expectedLines[l];
+    let actualLine = actualLines[l];
+    if (actualLine == expectedLine) {
+      ok(true, `line ${l + 1} matches`);
+    } else {
+      for (let c = 0; c < expectedLine.length && c < actualLine.length; c++) {
+        if (actualLine[c] != expectedLine[c]) {
+          // This call to equal automatically prints some extra characters of
+          // context. Hopefully that helps with debugging.
+          equal(
+            actualLine.substring(c - 10, c + 10),
+            expectedLine.substring(c - 10, c + 10),
+            `line ${l + 1} does not match at character ${c + 1}`
+          );
+        }
+      }
+      equal(
+        expectedLine.length,
+        actualLine.length,
+        `line ${l + 1} lengths differ`
+      );
+    }
+  }
+}
--- a/mailnews/addrbook/test/unit/xpcshell.ini
+++ b/mailnews/addrbook/test/unit/xpcshell.ini
@@ -8,16 +8,17 @@ tags = addrbook
 [test_bug387403.js]
 [test_bug448165.js]
 [test_bug534822.js]
 [test_bug1522453.js]
 [test_cardForEmail.js]
 [test_collection.js]
 [test_collection_2.js]
 [test_db_enumerator.js]
+[test_export.js]
 [test_jsaddrbook.js]
 [test_jsaddrbook_inner.js]
 [test_ldap1.js]
 [test_ldap2.js]
 [test_ldapOffline.js]
 [test_ldapReplication.js]
 skip-if = debug # Fails for unknown reasons.
 [test_mailList1.js]