Merge pull request #180 from Coffey-Labs/feat/ldif-import
Import an address book in LDIF
This commit is contained in:
+10
@@ -473,6 +473,16 @@ JMAP Contacts and JSContact.
|
||||
- **Search** across name, address, organisation and notes, in one book or all.
|
||||
- **vCard import** through `ContactCard/parse` (a file of any number of cards),
|
||||
and **export** of one card or the whole book as `.vcf`.
|
||||
- **LDIF import**, for address books coming from SOGo, Thunderbird or an LDAP
|
||||
directory. Nothing on the server reads LDIF, so the file is read here:
|
||||
RFC 2849 for the syntax, [Mozilla's address book schema][ldif-schema] for what
|
||||
the attributes mean, which is the one such exports almost always use. Work and
|
||||
home addresses, every phone kind, second email, organisation and units, job
|
||||
title, nickname, web pages and the custom fields all come across. The import
|
||||
control takes either format and decides by what is in the file, not by what it
|
||||
is called.
|
||||
|
||||
[ldif-schema]: https://wiki.mozilla.org/MailNews:Mozilla_LDAP_Address_Book_Schema
|
||||
- **Directory lookup** through `Principal/query`, so colleagues on the server
|
||||
can be addressed without being in an address book first.
|
||||
- **Recent recipients**, kept on the device — and only on a device you said was
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseLdif } from "@/lib/ldif";
|
||||
|
||||
/** The example from issue #174, as SOGo exports it -- lowercased attribute names and all. */
|
||||
const SOGO = `dn: cn=Jane Doe
|
||||
objectClass: top
|
||||
objectClass: inetOrgPerson
|
||||
objectClass: mozillaAbPersonAlpha
|
||||
givenName: Jane
|
||||
description: Description
|
||||
sn: Doe
|
||||
cn: Jane Doe
|
||||
mail: [email protected]
|
||||
telephoneNumber: +1-555-0199
|
||||
mobile: +1-555-0188
|
||||
mozillahomepostalcode: 10000
|
||||
c: ExampleCountry
|
||||
postalcode: 10000
|
||||
l: Examplecity
|
||||
mozillahomecountryname: ExampleCountry
|
||||
mozillahomelocalityname: Examplecity
|
||||
mozillahomestreet: Street Number
|
||||
street: Street Number
|
||||
`;
|
||||
|
||||
describe("parseLdif", () => {
|
||||
it("reads an entry and keeps repeated attributes in file order", () => {
|
||||
const [r] = parseLdif(SOGO);
|
||||
expect(r!.dn).toBe("cn=Jane Doe");
|
||||
expect(r!.attrs.cn).toEqual(["Jane Doe"]);
|
||||
expect(r!.attrs.objectclass).toEqual(["top", "inetOrgPerson", "mozillaAbPersonAlpha"]);
|
||||
expect(r!.attrs.mail).toEqual(["[email protected]"]);
|
||||
});
|
||||
|
||||
it("folds attribute names to one case, since exporters disagree", () => {
|
||||
const [r] = parseLdif("dn: cn=X\nMozillaHomeStreet: One\ntelephonenumber: 2\n");
|
||||
expect(r!.attrs.mozillahomestreet).toEqual(["One"]);
|
||||
expect(r!.attrs.telephonenumber).toEqual(["2"]);
|
||||
});
|
||||
|
||||
it("drops attribute options, keeping the attribute", () => {
|
||||
const [r] = parseLdif("dn: cn=X\nmail;pref: [email protected]\ncn;lang-de: Herr X\n");
|
||||
expect(r!.attrs.mail).toEqual(["[email protected]"]);
|
||||
expect(r!.attrs.cn).toEqual(["Herr X"]);
|
||||
});
|
||||
|
||||
it("splits entries on blank lines", () => {
|
||||
const two = parseLdif("dn: cn=One\ncn: One\n\ndn: cn=Two\ncn: Two\n");
|
||||
expect(two.map((r) => r.attrs.cn?.[0])).toEqual(["One", "Two"]);
|
||||
});
|
||||
|
||||
it("starts a new entry at a dn even without a blank line between", () => {
|
||||
const two = parseLdif("dn: cn=One\ncn: One\ndn: cn=Two\ncn: Two\n");
|
||||
expect(two).toHaveLength(2);
|
||||
expect(two[1]!.attrs.cn).toEqual(["Two"]);
|
||||
});
|
||||
|
||||
it("unfolds a value continued on the next line", () => {
|
||||
const [r] = parseLdif("dn: cn=X\ndescription: this note runs on\n and on\n");
|
||||
expect(r!.attrs.description).toEqual(["this note runs on and on"]);
|
||||
});
|
||||
|
||||
it("decodes a base64 value, including one that is not ASCII", () => {
|
||||
// "Zoë Müller" in UTF-8, base64.
|
||||
const b64 = Buffer.from("Zoë Müller", "utf8").toString("base64");
|
||||
const [r] = parseLdif(`dn: cn=X\ncn:: ${b64}\n`);
|
||||
expect(r!.attrs.cn).toEqual(["Zoë Müller"]);
|
||||
});
|
||||
|
||||
it("drops a value that will not decode rather than the whole import", () => {
|
||||
const [r] = parseLdif("dn: cn=X\ncn: Real Name\ndescription:: !!!not base64!!!\n");
|
||||
expect(r!.attrs.cn).toEqual(["Real Name"]);
|
||||
expect(r!.attrs.description).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips a URL reference, which a browser reading one file cannot follow", () => {
|
||||
const [r] = parseLdif("dn: cn=X\ncn: X\njpegPhoto:< file:///photos/x.jpg\n");
|
||||
expect(r!.attrs.jpegphoto).toBeUndefined();
|
||||
expect(r!.attrs.cn).toEqual(["X"]);
|
||||
});
|
||||
|
||||
it("ignores comments and the version header", () => {
|
||||
const rs = parseLdif("version: 1\n# exported by something\n# a comment\n that folds\n\ndn: cn=X\ncn: X\n");
|
||||
expect(rs).toHaveLength(1);
|
||||
expect(rs[0]!.attrs.version).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps an add change record and drops the rest", () => {
|
||||
const rs = parseLdif(
|
||||
"dn: cn=Kept\nchangetype: add\ncn: Kept\n\ndn: cn=Gone\nchangetype: modify\ncn: Gone\n\ndn: cn=Also gone\nchangetype: delete\n",
|
||||
);
|
||||
expect(rs.map((r) => r.attrs.cn?.[0])).toEqual(["Kept"]);
|
||||
});
|
||||
|
||||
it("returns nothing for a file that is not LDIF at all", () => {
|
||||
expect(parseLdif("this is a shopping list\nmilk\n")).toEqual([]);
|
||||
expect(parseLdif("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("survives CRLF, which is what a file from Windows arrives as", () => {
|
||||
const [r] = parseLdif("dn: cn=X\r\ncn: X\r\nsn: Y\r\n");
|
||||
expect(r!.attrs.cn).toEqual(["X"]);
|
||||
expect(r!.attrs.sn).toEqual(["Y"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseLdif } from "@/lib/ldif";
|
||||
import { cardFromLdif } from "@/lib/mozillaAb";
|
||||
import type { ContactCard } from "@/jmap/types";
|
||||
|
||||
const card = (ldif: string) => cardFromLdif(parseLdif(ldif)[0]!);
|
||||
const values = <T,>(m: Record<string, T> | undefined) => Object.values(m ?? {});
|
||||
/** Address components as `kind: value`, which is easier to assert than the array. */
|
||||
const parts = (a: NonNullable<ContactCard["addresses"]>[string]) => (a.components ?? []).map((c) => `${c.kind}: ${c.value}`);
|
||||
|
||||
/** The entry from issue #174, exactly as SOGo wrote it. */
|
||||
const JANE = `dn: cn=Jane Doe
|
||||
objectClass: top
|
||||
objectClass: inetOrgPerson
|
||||
objectClass: mozillaAbPersonAlpha
|
||||
givenName: Jane
|
||||
description: Description
|
||||
sn: Doe
|
||||
cn: Jane Doe
|
||||
mail: [email protected]
|
||||
telephoneNumber: +1-555-0199
|
||||
mobile: +1-555-0188
|
||||
mozillahomepostalcode: 10000
|
||||
c: ExampleCountry
|
||||
postalcode: 10000
|
||||
l: Examplecity
|
||||
mozillahomecountryname: ExampleCountry
|
||||
mozillahomelocalityname: Examplecity
|
||||
mozillahomestreet: Street Number
|
||||
street: Street Number
|
||||
`;
|
||||
|
||||
describe("the entry from the issue", () => {
|
||||
const c = card(JANE)!;
|
||||
|
||||
it("becomes a person with a name", () => {
|
||||
expect(c.kind).toBe("individual");
|
||||
expect(c.name?.full).toBe("Jane Doe");
|
||||
expect(c.name?.components).toEqual([
|
||||
{ "@type": "NameComponent", kind: "given", value: "Jane" },
|
||||
{ "@type": "NameComponent", kind: "surname", value: "Doe" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the address, marked as the one to use", () => {
|
||||
const emails = values(c.emails);
|
||||
expect(emails).toHaveLength(1);
|
||||
expect(emails[0]).toMatchObject({ address: "[email protected]", pref: 1 });
|
||||
});
|
||||
|
||||
it("tells the work phone from the mobile", () => {
|
||||
const phones = values(c.phones);
|
||||
expect(phones).toContainEqual(expect.objectContaining({ number: "+1-555-0199", contexts: { work: true } }));
|
||||
expect(phones).toContainEqual(expect.objectContaining({ number: "+1-555-0188", features: { mobile: true } }));
|
||||
});
|
||||
|
||||
it("splits the two addresses the schema keeps apart", () => {
|
||||
const addrs = values(c.addresses);
|
||||
expect(addrs).toHaveLength(2);
|
||||
const work = addrs.find((a) => a.contexts?.work)!;
|
||||
const home = addrs.find((a) => a.contexts?.private)!;
|
||||
expect(parts(work)).toEqual(["name: Street Number", "locality: Examplecity", "postcode: 10000", "country: ExampleCountry"]);
|
||||
expect(parts(home)).toEqual(["name: Street Number", "locality: Examplecity", "postcode: 10000", "country: ExampleCountry"]);
|
||||
});
|
||||
|
||||
it("keeps the description as the note", () => {
|
||||
expect(values(c.notes)[0]?.note).toBe("Description");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the rest of the schema", () => {
|
||||
it("reads the second email, after the first", () => {
|
||||
const c = card("dn: cn=X\nmail: [email protected]\nmozillaSecondEmail: [email protected]\n")!;
|
||||
const emails = values(c.emails);
|
||||
expect(emails.map((e) => e.address)).toEqual(["[email protected]", "[email protected]"]);
|
||||
expect(emails[0]!.pref).toBe(1);
|
||||
expect(emails[1]!.pref).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads every kind of phone the schema has", () => {
|
||||
const c = card("dn: cn=X\ncn: X\nhomePhone: 1\nfacsimileTelephoneNumber: 2\npager: 3\n")!;
|
||||
const phones = values(c.phones);
|
||||
expect(phones).toContainEqual(expect.objectContaining({ number: "1", contexts: { private: true } }));
|
||||
expect(phones).toContainEqual(expect.objectContaining({ number: "2", features: { fax: true } }));
|
||||
expect(phones).toContainEqual(expect.objectContaining({ number: "3", features: { pager: true } }));
|
||||
});
|
||||
|
||||
it("reads the organisation, its units and the job title", () => {
|
||||
const c = card("dn: cn=X\ncn: X\no: Example Corp\nou: Research\nou: Optics\ntitle: Lens Grinder\n")!;
|
||||
expect(values(c.organizations)[0]).toMatchObject({
|
||||
name: "Example Corp",
|
||||
units: [{ "@type": "OrgUnit", name: "Research" }, { "@type": "OrgUnit", name: "Optics" }],
|
||||
});
|
||||
expect(values(c.titles)[0]).toMatchObject({ name: "Lens Grinder", kind: "title" });
|
||||
});
|
||||
|
||||
it("reads the nickname, the web pages and the messaging handle", () => {
|
||||
const c = card("dn: cn=X\ncn: X\nmozillaNickname: Zed\nmozillaWorkUrl: https://work.example\nmozillaHomeUrl: https://home.example\nnsAIMid: zedzed\n")!;
|
||||
expect(values(c.nicknames)[0]?.name).toBe("Zed");
|
||||
expect(values(c.links).map((l) => l.uri)).toEqual(["https://work.example", "https://home.example"]);
|
||||
expect(values(c.onlineServices)[0]).toMatchObject({ service: "AIM", user: "zedzed" });
|
||||
});
|
||||
|
||||
it("keeps both street lines and the post office box", () => {
|
||||
const c = card("dn: cn=X\ncn: X\nstreet: 1 Long Road\nmozillaWorkStreet2: Floor 4\npostOfficeBox: PO 12\n")!;
|
||||
expect(parts(values(c.addresses)[0]!)).toEqual([
|
||||
"name: 1 Long Road",
|
||||
"name: Floor 4",
|
||||
"postOfficeBox: PO 12",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the custom fields in the note rather than dropping them", () => {
|
||||
const c = card("dn: cn=X\ncn: X\ndescription: A note\nmozillaCustom1: Met at a conference\nmozillaCustom3: Renewal in May\n")!;
|
||||
expect(values(c.notes)[0]?.note).toBe("A note\nCustom 1: Met at a conference\nCustom 3: Renewal in May");
|
||||
});
|
||||
|
||||
it("prefers the directory's own rendering of a name when it differs", () => {
|
||||
// "Doe, Jane" is not what the parts put back together, and is what the
|
||||
// export meant to display.
|
||||
const c = card("dn: cn=Doe, Jane\ngivenName: Jane\nsn: Doe\ncn: Doe, Jane\n")!;
|
||||
expect(c.name?.full).toBe("Doe, Jane");
|
||||
expect(c.name?.components).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("takes displayName over cn, which is what Thunderbird shows", () => {
|
||||
const c = card("dn: cn=X\ncn: Robert Smith\ndisplayName: Bob\n")!;
|
||||
expect(c.name?.full).toBe("Bob");
|
||||
});
|
||||
|
||||
it("manages an entry that is only an address", () => {
|
||||
const c = card("dn: cn=X\nmail: [email protected]\n")!;
|
||||
expect(c.name).toBeUndefined();
|
||||
expect(values(c.emails)[0]?.address).toBe("[email protected]");
|
||||
});
|
||||
|
||||
it("refuses an entry with neither a name nor an address", () => {
|
||||
expect(card("dn: cn=X\nobjectClass: top\ntelephoneNumber: 1\n")).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves out every section the entry said nothing about", () => {
|
||||
const c = card("dn: cn=X\ncn: X\n")!;
|
||||
for (const empty of ["emails", "phones", "addresses", "links", "notes", "organizations", "titles", "nicknames", "onlineServices"] as const) {
|
||||
expect(c[empty], empty).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Just enough LDIF to read an address book out of one (RFC 2849).
|
||||
*
|
||||
* Unlike vCard, which the server parses for us, nothing on the JMAP side reads
|
||||
* LDIF -- so this does. It is a reader and not a writer, and it stops at the
|
||||
* syntax: what the attributes *mean* is a schema question, and lives in
|
||||
* `mozillaAb.ts` next door, because LDIF says nothing about either.
|
||||
*/
|
||||
|
||||
/** One entry: its distinguished name, and its attributes in file order. */
|
||||
export interface LdifRecord {
|
||||
dn: string;
|
||||
/**
|
||||
* Attribute name, lowercased and stripped of options, to every value given
|
||||
* for it. Names are case-insensitive in LDAP and exporters disagree in
|
||||
* practice -- SOGo writes `mozillahomepostalcode`, the schema documents
|
||||
* `mozillaHomePostalCode` -- so they are folded here rather than at each of
|
||||
* the fifty-odd places that reads one.
|
||||
*/
|
||||
attrs: Record<string, string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo line folding: a line beginning with a single space continues the one
|
||||
* before it, which is how LDIF fits a long value into 78 columns. Done first
|
||||
* and for every line, so nothing downstream has to think about it -- including
|
||||
* comments, which fold the same way.
|
||||
*/
|
||||
function unfold(text: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const raw of text.replace(/\r\n?/g, "\n").split("\n")) {
|
||||
// A continuation with nothing above it to continue is not a continuation.
|
||||
if (raw.startsWith(" ") && out.length && out[out.length - 1] !== "") {
|
||||
out[out.length - 1] += raw.slice(1);
|
||||
continue;
|
||||
}
|
||||
out.push(raw);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* `::` means the value is base64, which is how a non-ASCII name or one with
|
||||
* awkward whitespace survives the format.
|
||||
*
|
||||
* A value that will not decode is dropped rather than thrown: one mangled line
|
||||
* in a thousand-entry export should cost that line, not the import.
|
||||
*/
|
||||
function decodeBase64(value: string): string | null {
|
||||
try {
|
||||
const binary = atob(value.replace(/\s+/g, ""));
|
||||
return new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** `name:`, `name::` for base64, or `name:<` for a URL we are in no position to follow. */
|
||||
const LINE = /^([A-Za-z0-9;.-]+):([:<]?)[ ]*(.*)$/;
|
||||
|
||||
export function parseLdif(text: string): LdifRecord[] {
|
||||
const records: LdifRecord[] = [];
|
||||
let current: LdifRecord | null = null;
|
||||
|
||||
const finish = () => {
|
||||
// A record is only a record once it has said what it is about. This is also
|
||||
// what makes the `version: 1` header at the top of a file disappear on its
|
||||
// own, rather than needing to be named and skipped.
|
||||
if (current && Object.keys(current.attrs).length) records.push(current);
|
||||
current = null;
|
||||
};
|
||||
|
||||
for (const line of unfold(text)) {
|
||||
if (line.trim() === "") {
|
||||
finish();
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("#")) continue;
|
||||
const m = LINE.exec(line);
|
||||
if (!m) continue;
|
||||
const [, rawName, marker, rawValue] = m;
|
||||
// An external file reference. We are a browser reading one file; there is
|
||||
// nothing to fetch and pretending otherwise would invent data.
|
||||
if (marker === "<") continue;
|
||||
const value = marker === ":" ? decodeBase64(rawValue!) : rawValue!;
|
||||
if (value === null) continue;
|
||||
const name = rawName!.split(";")[0]!.toLowerCase();
|
||||
if (name === "dn") {
|
||||
finish();
|
||||
current = { dn: value, attrs: {} };
|
||||
continue;
|
||||
}
|
||||
// Attributes before any `dn` belong to no entry.
|
||||
if (!current) continue;
|
||||
(current.attrs[name] ??= []).push(value);
|
||||
}
|
||||
finish();
|
||||
|
||||
// A change record describes an edit to a directory, not a person in it.
|
||||
// "add" is the only one that carries a whole entry; the rest are instructions
|
||||
// about an entry that lives somewhere else, and importing them as contacts
|
||||
// would produce cards with a field or two and no name.
|
||||
return records.filter((r) => {
|
||||
const change = r.attrs.changetype?.[0]?.toLowerCase();
|
||||
return !change || change === "add";
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { ContactCard, JSContactAddress, JSContactAddressComponent } from "@/jmap/types";
|
||||
import { buildName, newKey } from "@/lib/contacts";
|
||||
import type { LdifRecord } from "@/lib/ldif";
|
||||
|
||||
/**
|
||||
* Mozilla's LDAP address book schema, turned into a contact card.
|
||||
*
|
||||
* LDIF is only a syntax: it says how to write `name: value` and nothing about
|
||||
* what any name means, so an address book in it is only readable against a
|
||||
* schema. There are as many schemas as there are directories, and this handles
|
||||
* one -- [Mozilla's][1], which Thunderbird, SOGo and most things that export
|
||||
* "an address book as LDIF" write, and which issue #174 asks for by name.
|
||||
* Attributes outside it are left where they are rather than guessed at.
|
||||
*
|
||||
* [1]: https://wiki.mozilla.org/MailNews:Mozilla_LDAP_Address_Book_Schema
|
||||
*/
|
||||
|
||||
/** The work and home address, which the schema keeps in two separate sets of attributes. */
|
||||
const ADDRESSES: { context: "work" | "private"; parts: Array<[kind: string, attr: string]> }[] = [
|
||||
{
|
||||
context: "work",
|
||||
parts: [
|
||||
// Street lines land in one `name` component, which is where the contact
|
||||
// editor puts a street and so where it looks for one.
|
||||
["name", "street"],
|
||||
["name", "mozillaworkstreet2"],
|
||||
["postOfficeBox", "postofficebox"],
|
||||
["locality", "l"],
|
||||
["region", "st"],
|
||||
["postcode", "postalcode"],
|
||||
["country", "c"],
|
||||
],
|
||||
},
|
||||
{
|
||||
context: "private",
|
||||
parts: [
|
||||
["name", "mozillahomestreet"],
|
||||
["name", "mozillahomestreet2"],
|
||||
["locality", "mozillahomelocalityname"],
|
||||
["region", "mozillahomestate"],
|
||||
["postcode", "mozillahomepostalcode"],
|
||||
["country", "mozillahomecountryname"],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Every phone attribute, and what kind of phone it is. */
|
||||
const PHONES: Array<{ attr: string; features?: Record<string, boolean>; contexts?: Record<string, boolean> }> = [
|
||||
{ attr: "telephonenumber", contexts: { work: true } },
|
||||
{ attr: "homephone", contexts: { private: true } },
|
||||
{ attr: "mobile", features: { mobile: true } },
|
||||
{ attr: "facsimiletelephonenumber", features: { fax: true } },
|
||||
{ attr: "pager", features: { pager: true } },
|
||||
];
|
||||
|
||||
function address(rec: LdifRecord, spec: (typeof ADDRESSES)[number]): JSContactAddress | null {
|
||||
const components: JSContactAddressComponent[] = [];
|
||||
for (const [kind, attr] of spec.parts) {
|
||||
for (const value of rec.attrs[attr] ?? []) {
|
||||
if (value.trim()) components.push({ "@type": "AddressComponent", kind, value: value.trim() });
|
||||
}
|
||||
}
|
||||
if (!components.length) return null;
|
||||
return { "@type": "Address", components, contexts: { [spec.context]: true } };
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry as a card, or `null` when there is not enough of it to be a person.
|
||||
*
|
||||
* An entry with neither a name nor an address to reach it by would import as a
|
||||
* blank row: present in the list, impossible to identify, and tedious to find
|
||||
* again to delete. Better not to make it.
|
||||
*/
|
||||
export function cardFromLdif(rec: LdifRecord): Partial<ContactCard> | null {
|
||||
const first = (attr: string) => rec.attrs[attr]?.[0]?.trim() ?? "";
|
||||
const all = (attr: string) => (rec.attrs[attr] ?? []).map((v) => v.trim()).filter(Boolean);
|
||||
|
||||
const given = first("givenname");
|
||||
const surname = first("sn");
|
||||
const full = first("displayname") || first("cn");
|
||||
const emails = [...all("mail"), ...all("mozillasecondemail")];
|
||||
if (!given && !surname && !full && !emails.length) return null;
|
||||
|
||||
const card: Partial<ContactCard> = { kind: "individual" };
|
||||
|
||||
// `cn` is the name as the directory renders it, which is not always the parts
|
||||
// put back together -- "Doe, Jane", or a name with no surname attribute at
|
||||
// all. Keep it as the full name when it disagrees, so the card reads the way
|
||||
// the export did.
|
||||
const name = buildName({ given, surname });
|
||||
if (name) card.name = full && full !== name.full ? { ...name, full } : name;
|
||||
else if (full) card.name = { "@type": "Name", full };
|
||||
|
||||
const nickname = first("mozillanickname");
|
||||
if (nickname) card.nicknames = { [newKey("n")]: { "@type": "Nickname", name: nickname } };
|
||||
|
||||
const org = first("o");
|
||||
const units = all("ou");
|
||||
if (org || units.length) {
|
||||
card.organizations = {
|
||||
[newKey("o")]: {
|
||||
"@type": "Organization",
|
||||
...(org ? { name: org } : {}),
|
||||
...(units.length ? { units: units.map((name) => ({ "@type": "OrgUnit" as const, name })) } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const title = first("title");
|
||||
if (title) card.titles = { [newKey("t")]: { "@type": "Title", name: title, kind: "title" } };
|
||||
|
||||
if (emails.length) {
|
||||
card.emails = {};
|
||||
emails.forEach((address, i) => {
|
||||
// The first is `mail`, which the schema means as the address to use.
|
||||
card.emails![newKey("e")] = { "@type": "EmailAddress", address, ...(i === 0 ? { pref: 1 } : {}) };
|
||||
});
|
||||
}
|
||||
|
||||
const phones: NonNullable<ContactCard["phones"]> = {};
|
||||
for (const spec of PHONES) {
|
||||
for (const number of all(spec.attr)) {
|
||||
phones[newKey("p")] = { "@type": "Phone", number, ...(spec.features ? { features: spec.features } : {}), ...(spec.contexts ? { contexts: spec.contexts } : {}) };
|
||||
}
|
||||
}
|
||||
if (Object.keys(phones).length) card.phones = phones;
|
||||
|
||||
const addresses: NonNullable<ContactCard["addresses"]> = {};
|
||||
for (const spec of ADDRESSES) {
|
||||
const a = address(rec, spec);
|
||||
if (a) addresses[newKey("a")] = a;
|
||||
}
|
||||
if (Object.keys(addresses).length) card.addresses = addresses;
|
||||
|
||||
const links: NonNullable<ContactCard["links"]> = {};
|
||||
for (const uri of [...all("mozillaworkurl"), ...all("mozillahomeurl")]) {
|
||||
links[newKey("l")] = { "@type": "Link", uri };
|
||||
}
|
||||
if (Object.keys(links).length) card.links = links;
|
||||
|
||||
const aim = first("nsaimid");
|
||||
if (aim) card.onlineServices = { [newKey("s")]: { "@type": "OnlineService", service: "AIM", user: aim } };
|
||||
|
||||
/*
|
||||
* The four custom fields have nowhere of their own to go: JSContact has no
|
||||
* equivalent, and the schema does not say what they hold -- they are whatever
|
||||
* their owner decided. Appending them to the note keeps them, labelled the
|
||||
* way Thunderbird labels them, which is worth more than the tidiness of
|
||||
* dropping something somebody chose to write down.
|
||||
*/
|
||||
const notes = all("description");
|
||||
[1, 2, 3, 4].forEach((n) => {
|
||||
for (const value of all(`mozillacustom${n}`)) notes.push(`Custom ${n}: ${value}`);
|
||||
});
|
||||
if (notes.length) card.notes = { [newKey("x")]: { "@type": "Note", note: notes.join("\n") } };
|
||||
|
||||
return card;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CAP, client } from "@/jmap/client";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import type { JmapSession } from "@/jmap/types";
|
||||
|
||||
/**
|
||||
* The store half of LDIF import: everything that happens after the file has
|
||||
* been read. Reading it is `parseLdif` and `cardFromLdif`, tested next door.
|
||||
*/
|
||||
|
||||
const TWO = `dn: cn=Jane Doe
|
||||
givenName: Jane
|
||||
sn: Doe
|
||||
cn: Jane Doe
|
||||
mail: [email protected]
|
||||
|
||||
dn: cn=Alan Turing
|
||||
givenName: Alan
|
||||
sn: Turing
|
||||
cn: Alan Turing
|
||||
mail: [email protected]
|
||||
`;
|
||||
|
||||
interface SetArgs { create?: Record<string, Record<string, unknown>> }
|
||||
|
||||
function server(opts: { notCreated?: Record<string, unknown> } = {}) {
|
||||
const sets: SetArgs[] = [];
|
||||
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
|
||||
const body = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
|
||||
const methodResponses = body.methodCalls.map(([name, args, id]) => {
|
||||
if (name === "ContactCard/set") {
|
||||
sets.push({ create: args.create as Record<string, Record<string, unknown>> });
|
||||
const keys = Object.keys((args.create ?? {}) as object);
|
||||
const notCreated = opts.notCreated ?? {};
|
||||
return [name, {
|
||||
accountId: "a1", oldState: "1", newState: "2",
|
||||
created: Object.fromEntries(keys.filter((k) => !(k in notCreated)).map((k) => [k, { id: `new-${k}` }])),
|
||||
notCreated,
|
||||
}, id];
|
||||
}
|
||||
return [name, { accountId: "a1", state: "1", list: [], notFound: [], ids: [], total: 0, queryState: "q", position: 0, canCalculateChanges: false }, id];
|
||||
});
|
||||
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "1" }) } as Response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return sets;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
client.session = {
|
||||
capabilities: { [CAP.core]: { maxObjectsInGet: 500, maxObjectsInSet: 500 }, [CAP.contacts]: {} },
|
||||
accounts: {}, primaryAccounts: {}, state: "s1",
|
||||
} as unknown as JmapSession;
|
||||
useContacts.setState({ accountId: "a1", available: true, books: {}, cards: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("importing an LDIF address book", () => {
|
||||
it("creates every entry in one call, not one call each", async () => {
|
||||
const sets = server();
|
||||
const n = await useContacts.getState().importLdif(TWO, "book1");
|
||||
expect(n).toBe(2);
|
||||
expect(sets).toHaveLength(1);
|
||||
expect(Object.keys(sets[0]!.create!)).toEqual(["c0", "c1"]);
|
||||
});
|
||||
|
||||
it("files them into the address book that was picked", async () => {
|
||||
const sets = server();
|
||||
await useContacts.getState().importLdif(TWO, "book1");
|
||||
for (const c of Object.values(sets[0]!.create!)) {
|
||||
expect(c.addressBookIds).toEqual({ book1: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("sends finished cards, since no server parses LDIF", async () => {
|
||||
const sets = server();
|
||||
await useContacts.getState().importLdif(TWO, "book1");
|
||||
const first = sets[0]!.create!.c0!;
|
||||
expect(first["@type"]).toBe("Card");
|
||||
expect(first.version).toBe("1.0");
|
||||
expect(first.kind).toBe("individual");
|
||||
expect(first.name).toMatchObject({ full: "Jane Doe" });
|
||||
});
|
||||
|
||||
it("gives each contact an identity of its own, not the entry's directory name", async () => {
|
||||
const sets = server();
|
||||
await useContacts.getState().importLdif(TWO, "book1");
|
||||
const uids = Object.values(sets[0]!.create!).map((c) => c.uid as string);
|
||||
expect(uids.every((u) => typeof u === "string" && u.length > 0)).toBe(true);
|
||||
expect(new Set(uids).size).toBe(2);
|
||||
// A distinguished name says where an entry sat in somebody else's
|
||||
// directory, and must not become the contact's identity here.
|
||||
expect(uids.some((u) => u.includes("cn="))).toBe(false);
|
||||
});
|
||||
|
||||
it("says a file held no contacts rather than reporting none imported", async () => {
|
||||
server();
|
||||
await expect(useContacts.getState().importLdif("not an address book\n", "book1")).rejects.toThrow(/no contacts in it/);
|
||||
});
|
||||
|
||||
it("skips entries too empty to be a person, and imports the rest", async () => {
|
||||
const sets = server();
|
||||
const n = await useContacts.getState().importLdif(`${TWO}\ndn: cn=Nobody\nobjectClass: top\n`, "book1");
|
||||
expect(n).toBe(2);
|
||||
expect(Object.keys(sets[0]!.create!)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("reports the server's refusal when nothing was accepted", async () => {
|
||||
server({ notCreated: { c0: { type: "invalidProperties", description: "name is required" }, c1: { type: "invalidProperties" } } });
|
||||
await expect(useContacts.getState().importLdif(TWO, "book1")).rejects.toThrow(/name is required/);
|
||||
});
|
||||
|
||||
it("counts what got in when only some of it did", async () => {
|
||||
server({ notCreated: { c1: { type: "invalidProperties" } } });
|
||||
await expect(useContacts.getState().importLdif(TWO, "book1")).resolves.toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import { accountKey, loadRaw, saveJson } from "@/lib/storage";
|
||||
import { CAP, client, setErrorMessage } from "@/jmap/client";
|
||||
import type { AddressBook, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetResponse } from "@/jmap/types";
|
||||
import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts";
|
||||
import { parseLdif } from "@/lib/ldif";
|
||||
import { cardFromLdif } from "@/lib/mozillaAb";
|
||||
import { useSettings } from "./settings";
|
||||
import { useSession } from "./session";
|
||||
import { useMail } from "./mail";
|
||||
@@ -79,6 +81,8 @@ interface ContactsState {
|
||||
updateBook(id: Id, patch: Partial<AddressBook>): Promise<void>;
|
||||
destroyBook(id: Id): Promise<void>;
|
||||
importVCard(text: string, addressBookId: Id): Promise<number>;
|
||||
/** Import an address book in LDIF, read against Mozilla's schema. */
|
||||
importLdif(text: string, addressBookId: Id): Promise<number>;
|
||||
loadPrincipals(): Promise<void>;
|
||||
suggest(query: string, limit?: number): Promise<Suggestion[]>;
|
||||
addRecent(addrs: EmailAddress[]): void;
|
||||
@@ -367,6 +371,36 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
||||
return Object.keys(res.created ?? {}).length;
|
||||
},
|
||||
|
||||
/*
|
||||
* LDIF, which nothing on the server reads.
|
||||
*
|
||||
* vCard has `ContactCard/parse` and so never needed a parser here; LDIF has
|
||||
* no equivalent, so the file is read in the browser -- `parseLdif` for the
|
||||
* syntax, `cardFromLdif` for what Mozilla's schema means by it -- and what
|
||||
* goes to the server is finished cards. That is the whole difference between
|
||||
* the two imports; from `ContactCard/set` down they are the same.
|
||||
*/
|
||||
async importLdif(text, addressBookId) {
|
||||
const accountId = get().accountId!;
|
||||
const cards = parseLdif(text).map(cardFromLdif).filter((c): c is Partial<ContactCard> => c !== null);
|
||||
if (!cards.length) throw new Error("it has no contacts in it");
|
||||
const create: Record<string, unknown> = {};
|
||||
cards.forEach((c, i) => {
|
||||
// Built here rather than read from the file: LDIF identifies an entry by
|
||||
// its distinguished name, which says where it sat in somebody's
|
||||
// directory and is no use as a contact's identity anywhere else.
|
||||
create[`c${i}`] = { "@type": "Card", version: "1.0", ...c, uid: crypto.randomUUID(), addressBookIds: { [addressBookId]: true } };
|
||||
});
|
||||
const res = await client.call<SetResponse<ContactCard>>("ContactCard/set", { accountId, create });
|
||||
await get().loadAll();
|
||||
const created = Object.keys(res.created ?? {}).length;
|
||||
if (!created) {
|
||||
const first = Object.values(res.notCreated ?? {})[0];
|
||||
throw new Error(first ? setErrorMessage(first) : "the server did not accept any of its contacts");
|
||||
}
|
||||
return created;
|
||||
},
|
||||
|
||||
async loadPrincipals() {
|
||||
if (get().principalsLoaded) return;
|
||||
const accountId = useSession.getState().accountFor(CAP.principals);
|
||||
|
||||
@@ -170,8 +170,8 @@ export function ContactsSidebar() {
|
||||
{/* Import and export lived in the pane this replaced. */}
|
||||
<div style={{ padding: "12px 8px" }} className="col gap-8">
|
||||
<label className="btn btn-sm btn-block">
|
||||
<Upload size={14} /> {t("Import vCard")}
|
||||
<input type="file" accept=".vcf,text/vcard" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) onImport(f); e.target.value = ""; }} />
|
||||
<Upload size={14} /> {t("Import contacts")}
|
||||
<input type="file" accept=".vcf,.vcard,.ldif,.ldi,text/vcard,text/directory" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) onImport(f); e.target.value = ""; }} />
|
||||
</label>
|
||||
<button className="btn btn-sm btn-block" onClick={onExport}><Download size={14} /> {sel.bookId === "all" ? t("Export all") : t("Export book")}</button>
|
||||
</div>
|
||||
|
||||
@@ -96,10 +96,19 @@ export function ContactsView({ id }: { id?: string }) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const n = await contacts.importVCard(await f.text(), book.id);
|
||||
const text = await f.text();
|
||||
/*
|
||||
* Which format, decided by what is in the file rather than by what it is
|
||||
* called. A vCard says so on its first line; an address book exported as
|
||||
* LDIF may arrive as .ldif, .ldi, .txt or with no extension at all, and
|
||||
* the name is the least reliable thing about it.
|
||||
*/
|
||||
const n = /^\s*BEGIN:VCARD/im.test(text)
|
||||
? await contacts.importVCard(text, book.id)
|
||||
: await contacts.importLdif(text, book.id);
|
||||
toast.success(plural(n, { one: "Imported {n} contact", other: "Imported {n} contacts" }));
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
toast.error(translate("Could not import this file: {error}", { error: (err as Error).message }));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user