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
159 lines
6.3 KiB
TypeScript
159 lines
6.3 KiB
TypeScript
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;
|
|
}
|