diff --git a/FEATURES.md b/FEATURES.md index 01864f9..f4a10f0 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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 diff --git a/web/src/lib/__tests__/ldif.test.ts b/web/src/lib/__tests__/ldif.test.ts new file mode 100644 index 0000000..924d74a --- /dev/null +++ b/web/src/lib/__tests__/ldif.test.ts @@ -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: jane.doe@example.com +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(["jane.doe@example.com"]); + }); + + 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: a@example.com\ncn;lang-de: Herr X\n"); + expect(r!.attrs.mail).toEqual(["a@example.com"]); + 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"]); + }); +}); diff --git a/web/src/lib/__tests__/mozillaAb.test.ts b/web/src/lib/__tests__/mozillaAb.test.ts new file mode 100644 index 0000000..4c2292f --- /dev/null +++ b/web/src/lib/__tests__/mozillaAb.test.ts @@ -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 = (m: Record | undefined) => Object.values(m ?? {}); +/** Address components as `kind: value`, which is easier to assert than the array. */ +const parts = (a: NonNullable[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: jane.doe@example.com +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: "jane.doe@example.com", 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: one@example.com\nmozillaSecondEmail: two@example.com\n")!; + const emails = values(c.emails); + expect(emails.map((e) => e.address)).toEqual(["one@example.com", "two@example.com"]); + 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: only@example.com\n")!; + expect(c.name).toBeUndefined(); + expect(values(c.emails)[0]?.address).toBe("only@example.com"); + }); + + 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(); + } + }); +}); diff --git a/web/src/lib/ldif.ts b/web/src/lib/ldif.ts new file mode 100644 index 0000000..0b72df9 --- /dev/null +++ b/web/src/lib/ldif.ts @@ -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; +} + +/** + * 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"; + }); +} diff --git a/web/src/lib/mozillaAb.ts b/web/src/lib/mozillaAb.ts new file mode 100644 index 0000000..55b337c --- /dev/null +++ b/web/src/lib/mozillaAb.ts @@ -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; contexts?: Record }> = [ + { 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 | 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 = { 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 = {}; + 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 = {}; + 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 = {}; + 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; +} diff --git a/web/src/store/__tests__/ldif-import.test.ts b/web/src/store/__tests__/ldif-import.test.ts new file mode 100644 index 0000000..6889ffb --- /dev/null +++ b/web/src/store/__tests__/ldif-import.test.ts @@ -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: jane.doe@example.com + +dn: cn=Alan Turing +givenName: Alan +sn: Turing +cn: Alan Turing +mail: alan@example.org +`; + +interface SetArgs { create?: Record> } + +function server(opts: { notCreated?: Record } = {}) { + 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][] }; + const methodResponses = body.methodCalls.map(([name, args, id]) => { + if (name === "ContactCard/set") { + sets.push({ create: args.create as Record> }); + 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); + }); +}); diff --git a/web/src/store/contacts.ts b/web/src/store/contacts.ts index f461821..6ebb440 100644 --- a/web/src/store/contacts.ts +++ b/web/src/store/contacts.ts @@ -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): Promise; destroyBook(id: Id): Promise; importVCard(text: string, addressBookId: Id): Promise; + /** Import an address book in LDIF, read against Mozilla's schema. */ + importLdif(text: string, addressBookId: Id): Promise; loadPrincipals(): Promise; suggest(query: string, limit?: number): Promise; addRecent(addrs: EmailAddress[]): void; @@ -367,6 +371,36 @@ export const useContacts = create((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 => c !== null); + if (!cards.length) throw new Error("it has no contacts in it"); + const create: Record = {}; + 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>("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); diff --git a/web/src/views/contacts/ContactsSidebar.tsx b/web/src/views/contacts/ContactsSidebar.tsx index f961a25..7c6ea7b 100644 --- a/web/src/views/contacts/ContactsSidebar.tsx +++ b/web/src/views/contacts/ContactsSidebar.tsx @@ -170,8 +170,8 @@ export function ContactsSidebar() { {/* Import and export lived in the pane this replaced. */}
diff --git a/web/src/views/contacts/ContactsView.tsx b/web/src/views/contacts/ContactsView.tsx index c99d84e..47a5022 100644 --- a/web/src/views/contacts/ContactsView.tsx +++ b/web/src/views/contacts/ContactsView.tsx @@ -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 })); } };