Merge pull request #180 from Coffey-Labs/feat/ldif-import

Import an address book in LDIF
This commit is contained in:
Coffey Labs
2026-09-01 09:31:18 -07:00
committed by GitHub
9 changed files with 695 additions and 4 deletions
+10
View File
@@ -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
+105
View File
@@ -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"]);
});
});
+147
View File
@@ -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();
}
});
});
+107
View File
@@ -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";
});
}
+158
View File
@@ -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;
}
+121
View File
@@ -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);
});
});
+34
View File
@@ -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);
+2 -2
View File
@@ -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>
+11 -2
View File
@@ -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 }));
}
};