Bug 1639430 - Create utility functions to use ICAL.js for parsing and encoding vCard. r=mkmelin

--HG--
extra : rebase_source : b5082be98f08bedef81adcb1de45c115c6f7a690
extra : histedit_source : 972c42a1d65b79ac160ca439b19a4e425ba3fba8
This commit is contained in:
Geoff Lankow 2020-05-20 10:42:35 +12:00
Родитель c01fa55fe6
Коммит e20a856614
8 изменённых файлов: 748 добавлений и 12 удалений

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

@ -15,4 +15,5 @@ skip-if = os == "linux" || os = "mac"
[browser_emlSubject.js]
[browser_messageSidebar.js]
[browser_vcardActions.js]
tags = vcard
[browser_viewPlaintext.js]

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

@ -0,0 +1,307 @@
/* 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 = ["VCardUtils"];
const { ICAL } = ChromeUtils.import("resource:///modules/calendar/Ical.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 = {
vCardToAbCard(vCard) {
let [, properties] = ICAL.parse(vCard);
let abCard = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
Ci.nsIAbCard
);
for (let [name, params, , value] of properties) {
if (name == "uid") {
abCard.UID = value;
continue;
}
if (["adr", "tel"].includes(name)) {
if (
params.type &&
["home", "work", "cell"].includes(params.type.toLowerCase())
) {
name = `${name}.${params.type.toLowerCase()}`;
} else {
name = `${name}.work`;
}
}
if (name in propertyMap) {
for (let [abPropName, abPropValue] of Object.entries(
propertyMap[name].toAbCard(value)
)) {
if (abPropValue) {
abCard.setProperty(abPropName, abPropValue);
}
}
}
}
return abCard;
},
modifyVCard(vCard, abCard) {
let card = ICAL.parse(vCard);
let [, vProps] = card;
// Collect all of the AB card properties into a Map.
let abProps = new Map();
for (let abProp of abCard.properties) {
if (abProp.value) {
abProps.set(abProp.name, abProp.value);
}
}
// Collect all of the existing vCard properties into a Map.
let indices = new Map();
for (let i = 0; i < vProps.length; i++) {
let [vPropName, vPropParams] = vProps[i];
if (vPropParams.type) {
vPropName += `.${vPropParams.type}`;
}
indices.set(vPropName, i);
}
// Update the vCard.
for (let vPropName of Object.keys(propertyMap)) {
let vProp = propertyMap[vPropName].fromAbCard(abProps);
let index = indices.get(vPropName);
if (vProp) {
// The vCard might have the property, but with no type specified.
// If it does, use that.
if (index === undefined && vPropName.includes(".")) {
index = indices.get(vPropName.split(".")[0]);
// Default to not specifying a type, where this applies.
delete vProp[1].type;
}
if (index === undefined) {
// New property, add it.
vProps.push(vProp);
} else {
// Existing property, update it.
vProps[index][3] = vProp[3];
}
} else if (index !== undefined) {
// Removed property, remove it.
vProps.splice(index, 1);
}
}
// Always add a UID if there isn't one.
if (vProps.findIndex(prop => prop[0] == "uid") == -1) {
vProps.push(["uid", {}, "text", abCard.UID]);
}
return ICAL.stringify(card);
},
abCardToVCard(abCard) {
let vProps = [["version", {}, "text", "4.0"]];
// Collect all of the AB card properties into a Map.
let abProps = new Map();
for (let abProp of abCard.properties) {
if (abProp.value) {
abProps.set(abProp.name, abProp.value);
}
}
// Add the properties to the vCard.
for (let vPropName of Object.keys(propertyMap)) {
let vProp = propertyMap[vPropName].fromAbCard(abProps, vPropName);
if (vProp) {
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;
}
vProps.push(["uid", {}, "text", abCard.UID]);
return ICAL.stringify(["vcard", vProps]);
},
};
/** Helper functions for propertyMap. */
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.
* @return {?Array} - Values in a jCard array for use with ICAL.js.
*/
fromAbCard(map) {
if (map.has(abPropName)) {
return [vPropName, { ...vPropParams }, vPropType, map.get(abPropName)];
}
return null;
},
/**
* Parses a vCard value into properties usable by nsIAbCard.
*
* @param {string} value - vCard string to map to an address book card property.
* @return {Object} - A dictionary of address book properties.
*/
toAbCard(value) {
if (typeof value != "string") {
console.warn(`Unexpected value for ${vPropName}: ${value}`);
return {};
}
return { [abPropName]: value };
},
};
}
function dateProperty(abCardPrefix, vPropName) {
return {
fromAbCard(map) {
if (
!map.has(`${abCardPrefix}Year`) ||
!map.has(`${abCardPrefix}Month`) ||
!map.has(`${abCardPrefix}Day`)
) {
return null;
}
let dateValue = new ICAL.VCardTime(
{
year: Number(map.get(`${abCardPrefix}Year`)),
month: Number(map.get(`${abCardPrefix}Month`)),
day: Number(map.get(`${abCardPrefix}Day`)),
},
null,
"date"
);
return [vPropName, {}, "date", dateValue.toString()];
},
toAbCard(value) {
let dateValue = new Date(value);
return {
[`${abCardPrefix}Year`]: String(dateValue.getFullYear()),
[`${abCardPrefix}Month`]: String(dateValue.getMonth() + 1),
[`${abCardPrefix}Day`]: String(dateValue.getDate()),
};
},
};
}
function multiTextProperty(abPropNames, vPropName, vPropParams = {}) {
return {
fromAbCard(map) {
if (abPropNames.every(name => !map.has(name))) {
return null;
}
return [
vPropName,
{ ...vPropParams },
"text",
abPropNames.map(name => map.get(name) || ""),
];
},
toAbCard(value) {
let result = {};
if (!Array.isArray(value)) {
console.warn(`Unexpected value for ${vPropName}: ${value}`);
return result;
}
for (let abPropName of abPropNames) {
let valuePart = value.shift();
if (abPropName && valuePart) {
result[abPropName] = valuePart;
}
}
return result;
},
};
}
/**
* 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 propertyMap = {
email: singleTextProperty("PrimaryEmail", "email"),
fn: singleTextProperty("DisplayName", "fn"),
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", null, null, null], "n"),
"adr.home": multiTextProperty(
[
null,
"HomeAddress2",
"HomeAddress",
"HomeCity",
"HomeState",
"HomeZipCode",
"HomeCountry",
],
"adr",
{ type: "home" }
),
"adr.work": multiTextProperty(
[
null,
"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: singleTextProperty("WebPage1", "url", {}, "url"),
"x-mozilla-html": {
fromAbCard(map) {
switch (map.get("PreferMailFormat")) {
case Ci.nsIAbPreferMailFormat.html:
return ["x-mozilla-html", {}, "boolean", true];
case Ci.nsIAbPreferMailFormat.plaintext:
return ["x-mozilla-html", {}, "boolean", false];
}
return null;
},
toAbCard(value) {
if (typeof value != "boolean") {
console.warn(`Unexpected value for x-mozilla-html: ${value}`);
return {};
}
return { PreferMailFormat: value };
},
},
};

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

@ -8,6 +8,7 @@ EXTRA_JS_MODULES += [
'AddrBookMailingList.jsm',
'AddrBookManager.jsm',
'AddrBookUtils.jsm',
'VCardUtils.jsm',
]
XPCOM_MANIFESTS += [

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

@ -0,0 +1,417 @@
/* 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/. */
let { VCardUtils } = ChromeUtils.import("resource:///modules/VCardUtils.jsm");
const ANY_UID = "UID:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx";
add_task(function testVCardToAbCard() {
function check(vCardLine, expectedProps) {
const propWhitelist = [
"LastModifiedDate",
"PopularityIndex",
"PreferMailFormat",
];
let vCard = `BEGIN:VCARD\r\n${vCardLine}\r\nEND:VCARD\r\n`;
info(vCard);
let abCard = VCardUtils.vCardToAbCard(vCard);
// Check that every property in expectedProps is present in `abCard`.
// No other property can be present unless it is in `propWhitelist`.
for (let prop of abCard.properties) {
if (prop.name in expectedProps) {
equal(prop.value, expectedProps[prop.name], `expected ${prop.name}`);
delete expectedProps[prop.name];
} else if (!propWhitelist.includes(prop.name)) {
ok(false, `unexpected ${prop.name}`);
}
}
for (let name of Object.keys(expectedProps)) {
ok(false, `expected ${name} not found`);
}
}
// UID
check("UID:12345678-1234-1234-1234-123456789012", {
UID: "12345678-1234-1234-1234-123456789012",
});
// Name
check("N:Last;First;Middle;Prefix;Suffix", {
FirstName: "First",
LastName: "Last",
});
// Address
check("ADR:;;123 Main Street;Any Town;CA;91921-1234;U.S.A.", {
WorkAddress: "123 Main Street",
WorkCity: "Any Town",
WorkState: "CA",
WorkZipCode: "91921-1234",
WorkCountry: "U.S.A.",
});
check("ADR;TYPE=work:;;123 Main Street;Any Town;CA;91921-1234;U.S.A.", {
WorkAddress: "123 Main Street",
WorkCity: "Any Town",
WorkState: "CA",
WorkZipCode: "91921-1234",
WorkCountry: "U.S.A.",
});
check("ADR;TYPE=home:;;123 Main Street;Any Town;CA;91921-1234;U.S.A.", {
HomeAddress: "123 Main Street",
HomeCity: "Any Town",
HomeState: "CA",
HomeZipCode: "91921-1234",
HomeCountry: "U.S.A.",
});
// Phone
check("TEL:11-2358-13-21", { WorkPhone: "11-2358-13-21" });
check("TEL;TYPE=work;VALUE=TEXT:11-2358-13-21", {
WorkPhone: "11-2358-13-21",
});
check("TEL;TYPE=home;VALUE=TEXT:011-2358-13-21", {
HomePhone: "011-2358-13-21",
});
check(
"TEL;TYPE=work;VALUE=TEXT:11-2358-13-21\r\nTEL;TYPE=home;VALUE=TEXT:011-2358-13-21",
{
WorkPhone: "11-2358-13-21",
HomePhone: "011-2358-13-21",
}
);
// Birthday
check("BDAY;VALUE=DATE:19830403", {
BirthDay: "3",
BirthMonth: "4",
BirthYear: "1983",
});
// Anniversary
check("ANNIVERSARY;VALUE=DATE:20041207", {
AnniversaryDay: "7",
AnniversaryMonth: "12",
AnniversaryYear: "2004",
});
});
add_task(function testModifyVCard() {
function check(
inVCard,
newProps,
expectedLines = [],
unexpectedPrefixes = []
) {
let abCard = VCardUtils.vCardToAbCard(inVCard);
for (let [name, value] of Object.entries(newProps)) {
if (value === null) {
abCard.deleteProperty(name);
} else {
abCard.setProperty(name, value);
}
}
let outVCard = VCardUtils.modifyVCard(inVCard, abCard);
info(outVCard);
let lineCounts = {};
for (let line of expectedLines) {
let [prefix] = line.split(":");
if (prefix in lineCounts) {
lineCounts[prefix]++;
} else {
lineCounts[prefix] = 1;
}
}
// Check if `prefix` is expected. If it is expected, check that `line` is
// exactly as specified. If it is unexpected, complain. Otherwise, ignore.
for (let line of outVCard.split("\r\n")) {
let [prefix] = line.split(":");
if (prefix == "UID" && expectedLines.includes(ANY_UID)) {
line = ANY_UID;
}
if (unexpectedPrefixes.includes(prefix)) {
ok(false, `unexpected ${prefix} line`);
} else if (prefix in lineCounts) {
let index = expectedLines.indexOf(line);
ok(index > -1, `line was expected: ${line}`);
expectedLines.splice(index, 1);
lineCounts[prefix]--;
} else {
ok(true, `line was ignored: ${line}`);
}
}
// Check that all expected lines are in `outVCard`.
for (let [prefix, count] of Object.entries(lineCounts)) {
equal(count, 0, `${count} ${prefix} lines remain`);
}
}
// Empty card, no modifications.
check(
formatVCard`
BEGIN:VCARD
END:VCARD`,
{},
[ANY_UID]
);
// Card with UID, no modifications.
check(
formatVCard`
BEGIN:VCARD
UID:12345678-1234-1234-1234-123456789012
END:VCARD`,
{},
["UID:12345678-1234-1234-1234-123456789012"]
);
// Display name changed, notes removed, UID unchanged.
check(
formatVCard`
BEGIN:VCARD
FN:Original Full Name
NOTE:This property will be removed.
UID:12345678-1234-1234-1234-123456789012
END:VCARD`,
{
DisplayName: "New Full Name",
Notes: null,
},
["FN:New Full Name", "UID:12345678-1234-1234-1234-123456789012"],
["NOTE"]
);
// Last name changed.
check(
formatVCard`
BEGIN:VCARD
N:Last;First;;;
END:VCARD`,
{
LastName: "Changed",
},
["N:Changed;First;;;", ANY_UID]
);
// First and last name changed.
check(
formatVCard`
BEGIN:VCARD
N:Last;First;;;
END:VCARD`,
{
LastName: "Changed",
FirstName: "New",
},
["N:Changed;New;;;", ANY_UID]
);
// Work address changed. Other address types should not appear.
check(
formatVCard`
BEGIN:VCARD
ADR;TYPE=work:;;123 Main Street;Any Town;CA;91921-1234;U.S.A.
END:VCARD`,
{
WorkAddress: "345 Main Street",
},
["ADR;TYPE=work:;;345 Main Street;Any Town;CA;91921-1234;U.S.A.", ANY_UID],
["ADR", "ADR;TYPE=home"]
);
// Home address changed. Other address types should not appear.
check(
formatVCard`
BEGIN:VCARD
ADR;TYPE=home:;;123 Main Street;Any Town;CA;91921-1234;U.S.A.
END:VCARD`,
{
HomeAddress: "345 Main Street",
},
["ADR;TYPE=home:;;345 Main Street;Any Town;CA;91921-1234;U.S.A.", ANY_UID],
["ADR", "ADR;TYPE=work"]
);
// Address changed. Other address types should not appear.
check(
formatVCard`
BEGIN:VCARD
ADR:;;123 Main Street;Any Town;CA;91921-1234;U.S.A.
END:VCARD`,
{
WorkAddress: "345 Main Street",
},
["ADR:;;345 Main Street;Any Town;CA;91921-1234;U.S.A.", ANY_UID],
["ADR;TYPE=work", "ADR;TYPE=home"]
);
// Card with properties we don't support. They should remain unchanged.
check(
formatVCard`
BEGIN:VCARD
X-FOO-BAR:foo bar
X-BAZ;VALUE=URI:https://www.example.com/
QUUX:This property is out of spec but we shouldn't touch it anyway.
FN:My full name
END:VCARD`,
{
DisplayName: "My other full name",
},
[
"FN:My other full name",
"X-FOO-BAR:foo bar",
"X-BAZ;VALUE=URI:https://www.example.com/",
"QUUX:This property is out of spec but we shouldn't touch it anyway.",
ANY_UID,
]
);
});
add_task(function testAbCardToVCard() {
function check(abCardProps, ...expectedLines) {
let abCard = Cc["@mozilla.org/addressbook/cardproperty;1"].createInstance(
Ci.nsIAbCard
);
for (let [name, value] of Object.entries(abCardProps)) {
if (name == "UID") {
abCard.UID = abCardProps.UID;
continue;
}
abCard.setProperty(name, value);
}
let vCard = VCardUtils.abCardToVCard(abCard);
info(vCard);
let vCardLines = vCard.split("\r\n");
if (expectedLines.includes(ANY_UID)) {
for (let i = 0; i < vCardLines.length; i++) {
if (vCardLines[i].startsWith("UID:")) {
vCardLines[i] = ANY_UID;
}
}
}
for (let line of expectedLines) {
Assert.ok(vCardLines.includes(line), line);
}
}
// UID
check(
{
UID: "12345678-1234-1234-1234-123456789012",
},
"UID:12345678-1234-1234-1234-123456789012"
);
// Name
check(
{
FirstName: "First",
LastName: "Last",
},
"N:Last;First;;;",
ANY_UID
);
// Address
check(
{
WorkAddress: "123 Main Street",
WorkCity: "Any Town",
WorkState: "CA",
WorkZipCode: "91921-1234",
WorkCountry: "U.S.A.",
},
"ADR:;;123 Main Street;Any Town;CA;91921-1234;U.S.A.",
ANY_UID
);
check(
{
HomeAddress: "123 Main Street",
HomeCity: "Any Town",
HomeState: "CA",
HomeZipCode: "91921-1234",
HomeCountry: "U.S.A.",
},
"ADR:;;123 Main Street;Any Town;CA;91921-1234;U.S.A.",
ANY_UID
);
// Phone
check(
{
WorkPhone: "11-2358-13-21",
},
"TEL;VALUE=TEXT:11-2358-13-21",
ANY_UID
);
check(
{
HomePhone: "011-2358-13-21",
},
"TEL;VALUE=TEXT:011-2358-13-21",
ANY_UID
);
check(
{
WorkPhone: "11-2358-13-21",
HomePhone: "011-2358-13-21",
},
"TEL;TYPE=work;VALUE=TEXT:11-2358-13-21",
"TEL;TYPE=home;VALUE=TEXT:011-2358-13-21",
ANY_UID
);
// Birthday
check(
{
BirthDay: "3",
BirthMonth: "4",
BirthYear: "1983",
},
"BDAY;VALUE=DATE:19830403",
ANY_UID
);
// Anniversary
check(
{
AnniversaryDay: "7",
AnniversaryMonth: "12",
AnniversaryYear: "2004",
},
"ANNIVERSARY;VALUE=DATE:20041207",
ANY_UID
);
});
add_task(function() {
// Check that test_becky_addressbook.js won't fail, without being on Windows.
let v = formatVCard`
BEGIN:VCARD
VERSION:3.0
UID:4E4D17E8.0043655C
FN:The first man
ORG:Organization;Post;
X-BECKY-IMAGE:0
N:The nick name of the first man
TEL;TYPE=HOME:11-1111-1111
TEL;TYPE=WORK:22-2222-2222
TEL;TYPE=CELL:333-3333-3333
EMAIL;TYPE=INTERNET;PREF:first@host.invalid
NOTE;ENCODING=QUOTED-PRINTABLE:This is a note.
END:VCARD`;
let a = VCardUtils.vCardToAbCard(v);
Assert.equal(a.getProperty("DisplayName", "BAD"), "The first man");
Assert.equal(a.getProperty("PrimaryEmail", "BAD"), "first@host.invalid");
Assert.equal(a.getProperty("HomePhone", "BAD"), "11-1111-1111");
Assert.equal(a.getProperty("WorkPhone", "BAD"), "22-2222-2222");
Assert.equal(a.getProperty("CellularNumber", "BAD"), "333-3333-3333");
Assert.equal(a.getProperty("Company", "BAD"), "Organization");
Assert.equal(a.getProperty("Notes", "BAD"), "This is a note.");
});
function formatVCard([str]) {
let lines = str.split("\n");
let indent = lines[1].length - lines[1].trimLeft().length;
let outLines = [];
for (let line of lines) {
if (line.length > 0) {
outLines.push(line.substring(indent) + "\r\n");
}
}
return outLines.join("");
}

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

@ -6,6 +6,7 @@ tags = addrbook
[test_basic_nsIAbCard.js]
[test_basic_nsIAbDirectory.js]
[test_bug387403.js]
tags = vcard
[test_bug448165.js]
[test_bug534822.js]
[test_bug1522453.js]
@ -15,6 +16,7 @@ tags = addrbook
[test_db_enumerator.js]
[test_delete_book.js]
[test_export.js]
tags = vcard
[test_jsaddrbook.js]
[test_ldap1.js]
[test_ldap2.js]
@ -37,4 +39,7 @@ skip-if = debug # Fails for unknown reasons.
[test_nsAbManager4.js]
[test_nsAbManager5.js]
[test_nsIAbCard.js]
tags = vcard
[test_search.js]
[test_vCard.js]
tags = vcard

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

@ -1,24 +1,26 @@
BEGIN:VCARD
VERSION:3.0
UID:4E4D17E8.0043655C
FN:The first man
ORG:Organization;Post;
X-BECKY-IMAGE:0
N:The nick name of the first man
TEL;HOME:11-1111-1111
TEL;WORK:22-2222-2222
TEL;CELL:333-3333-3333
EMAIL;PREF:first@host.invalid
TEL;TYPE=HOME:11-1111-1111
TEL;TYPE=WORK:22-2222-2222
TEL;TYPE=CELL:333-3333-3333
EMAIL;TYPE=INTERNET;PREF:first@host.invalid
NOTE;ENCODING=QUOTED-PRINTABLE:This is a note.
END:VCARD
BEGIN:VCARD
VERSION:3.0
UID:4EBBF6FE.00AC632B
FN:The second man
ORG:Organization;post;
X-BECKY-IMAGE:0
N:The nick name of the second man
TEL;HOME:44-4444-4444
TEL;WORK:55-5555-5555
TEL;CELL:666-6666-6666
EMAIL;PREF:second@host.invalid
TEL;TYPE=HOME:44-4444-4444
TEL;TYPE=WORK:55-5555-5555
TEL;TYPE=CELL:666-6666-6666
EMAIL;TYPE=INTERNET;PREF:second@host.invalid
NOTE;ENCODING=QUOTED-PRINTABLE:This is a note.
END:VCARD

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

@ -1,12 +1,13 @@
BEGIN:VCARD
VERSION:3.0
UID:4E57AB44.0001D53E
FN:The third man
ORG:Organization;post;
X-BECKY-IMAGE:0
N:The third man
TEL;HOME:77-7777-7777
TEL;WORK:88-8888-8888
TEL;CELL:999-9999-9999
EMAIL;PREF:third@host.invalid
TEL;TYPE=HOME:77-7777-7777
TEL;TYPE=WORK:88-8888-8888
TEL;TYPE=CELL:999-9999-9999
EMAIL;TYPE=INTERNET;PREF:third@host.invalid
NOTE;ENCODING=QUOTED-PRINTABLE:This is a note.
END:VCARD

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

@ -8,6 +8,7 @@ support-files = resources/*
[test_csv_GetSample.js]
[test_becky_addressbook.js]
run-if = os == 'win'
tags = vcard
[test_becky_filters.js]
run-if = os == 'win'
[test_csv_import.js]
@ -18,5 +19,6 @@ run-if = os == 'win'
[test_shiftjis_csv.js]
[test_utf16_csv.js]
[test_vcard_import.js]
tags = vcard
[test_winmail.js]
run-if = os == 'win'