Import an address book in LDIF
Somebody arriving from SOGo, Thunderbird or an LDAP directory has their contacts in LDIF, and until now the only way in was vCard. Nothing on the server reads LDIF, so this reads it here, in two pieces that are two different problems. `ldif.ts` is RFC 2849 and nothing else: folded lines, base64 values, case-insensitive attribute names, options, comments, `version:` headers, change records. It knows no attribute by name. `mozillaAb.ts` knows the attributes and no syntax -- Mozilla's address book schema, which is what Thunderbird and SOGo write and what the issue asks for by name. LDIF says nothing about what any attribute means, so a file is only readable against a schema, and keeping the two apart is what would let a second schema be added without touching the reader. Work and home addresses, which the schema keeps in two separate sets of attributes, come across as two addresses. So do every phone kind, the second email, the organisation and its units, job title, nickname, web pages and the AIM handle. The four custom fields have no equivalent in JSContact and are appended to the note, labelled as Thunderbird labels them: keeping something somebody chose to write down is worth more than the tidiness of dropping it. An entry with neither a name nor an address is skipped rather than imported as a blank row that is impossible to identify and tedious to find again to delete. The distinguished name is not used as the contact's uid: it says where an entry sat in somebody else's directory. One import control takes either format and decides by what is in the file rather than by what it is called, because an address book exported as LDIF arrives as .ldif, .ldi, .txt or with no extension at all. Closes #174
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user