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,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);
|
||||
|
||||
Reference in New Issue
Block a user