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.
|
- **Search** across name, address, organisation and notes, in one book or all.
|
||||||
- **vCard import** through `ContactCard/parse` (a file of any number of cards),
|
- **vCard import** through `ContactCard/parse` (a file of any number of cards),
|
||||||
and **export** of one card or the whole book as `.vcf`.
|
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
|
- **Directory lookup** through `Principal/query`, so colleagues on the server
|
||||||
can be addressed without being in an address book first.
|
can be addressed without being in an address book first.
|
||||||
- **Recent recipients**, kept on the device — and only on a device you said was
|
- **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 { CAP, client, setErrorMessage } from "@/jmap/client";
|
||||||
import type { AddressBook, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetResponse } from "@/jmap/types";
|
import type { AddressBook, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetResponse } from "@/jmap/types";
|
||||||
import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts";
|
import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts";
|
||||||
|
import { parseLdif } from "@/lib/ldif";
|
||||||
|
import { cardFromLdif } from "@/lib/mozillaAb";
|
||||||
import { useSettings } from "./settings";
|
import { useSettings } from "./settings";
|
||||||
import { useSession } from "./session";
|
import { useSession } from "./session";
|
||||||
import { useMail } from "./mail";
|
import { useMail } from "./mail";
|
||||||
@@ -79,6 +81,8 @@ interface ContactsState {
|
|||||||
updateBook(id: Id, patch: Partial<AddressBook>): Promise<void>;
|
updateBook(id: Id, patch: Partial<AddressBook>): Promise<void>;
|
||||||
destroyBook(id: Id): Promise<void>;
|
destroyBook(id: Id): Promise<void>;
|
||||||
importVCard(text: string, addressBookId: Id): Promise<number>;
|
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>;
|
loadPrincipals(): Promise<void>;
|
||||||
suggest(query: string, limit?: number): Promise<Suggestion[]>;
|
suggest(query: string, limit?: number): Promise<Suggestion[]>;
|
||||||
addRecent(addrs: EmailAddress[]): void;
|
addRecent(addrs: EmailAddress[]): void;
|
||||||
@@ -367,6 +371,36 @@ export const useContacts = create<ContactsState>((set, get) => ({
|
|||||||
return Object.keys(res.created ?? {}).length;
|
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() {
|
async loadPrincipals() {
|
||||||
if (get().principalsLoaded) return;
|
if (get().principalsLoaded) return;
|
||||||
const accountId = useSession.getState().accountFor(CAP.principals);
|
const accountId = useSession.getState().accountFor(CAP.principals);
|
||||||
|
|||||||
@@ -170,8 +170,8 @@ export function ContactsSidebar() {
|
|||||||
{/* Import and export lived in the pane this replaced. */}
|
{/* Import and export lived in the pane this replaced. */}
|
||||||
<div style={{ padding: "12px 8px" }} className="col gap-8">
|
<div style={{ padding: "12px 8px" }} className="col gap-8">
|
||||||
<label className="btn btn-sm btn-block">
|
<label className="btn btn-sm btn-block">
|
||||||
<Upload size={14} /> {t("Import vCard")}
|
<Upload size={14} /> {t("Import contacts")}
|
||||||
<input type="file" accept=".vcf,text/vcard" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) onImport(f); e.target.value = ""; }} />
|
<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>
|
</label>
|
||||||
<button className="btn btn-sm btn-block" onClick={onExport}><Download size={14} /> {sel.bookId === "all" ? t("Export all") : t("Export book")}</button>
|
<button className="btn btn-sm btn-block" onClick={onExport}><Download size={14} /> {sel.bookId === "all" ? t("Export all") : t("Export book")}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -96,10 +96,19 @@ export function ContactsView({ id }: { id?: string }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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" }));
|
toast.success(plural(n, { one: "Imported {n} contact", other: "Imported {n} contacts" }));
|
||||||
} catch (err) {
|
} 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