diff --git a/FEATURES.md b/FEATURES.md index a311a0f..d0df80b 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -724,6 +724,14 @@ JMAP Contacts and JSContact. 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. +- **Re-importing updates rather than duplicates.** A vCard is recognised by its + UID; an LDIF entry, whose schema has none, by its distinguished name. The card + already here is merged with the file's version -- what the file carries wins, + what it does not mention is left alone -- so a corrected export can correct + what the first attempt got wrong. Matching is per address book, which is also + how two directories that each hold a `cn=John Smith` stay two people. An entry + no longer recognisable, because its `dn` moved between exports, is imported + again and counted: *"3 of them look like contacts you already had."* [ldif-schema]: https://wiki.mozilla.org/MailNews:Mozilla_LDAP_Address_Book_Schema - **Directory lookup** through `Principal/query`, so colleagues on the server diff --git a/web/src/lib/__tests__/ldif.test.ts b/web/src/lib/__tests__/ldif.test.ts index 924d74a..6a9b2ec 100644 --- a/web/src/lib/__tests__/ldif.test.ts +++ b/web/src/lib/__tests__/ldif.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { parseLdif } from "@/lib/ldif"; +import { parseLdif, uidFromDn } from "@/lib/ldif"; /** The example from issue #174, as SOGo exports it -- lowercased attribute names and all. */ const SOGO = `dn: cn=Jane Doe @@ -103,3 +103,45 @@ describe("parseLdif", () => { expect(r!.attrs.sn).toEqual(["Y"]); }); }); + +describe("an identity for an entry, from its distinguished name", () => { + it("gives the same dn the same identity, which is the whole point", () => { + expect(uidFromDn("cn=Jane Doe,ou=People")).toBe(uidFromDn("cn=Jane Doe,ou=People")); + }); + + it("gives two entries two identities", () => { + expect(uidFromDn("cn=Jane Doe")).not.toBe(uidFromDn("cn=Alan Turing")); + }); + + it("ignores the case and spacing two exports of one directory differ in", () => { + // LDAP matches attribute types without regard to case, and exporters lay + // a dn out differently. Neither is a different person. + const canonical = uidFromDn("cn=Jane Doe,ou=People"); + expect(uidFromDn("CN=Jane Doe,OU=People")).toBe(canonical); + expect(uidFromDn("cn = Jane Doe , ou = People")).toBe(canonical); + expect(uidFromDn(" cn=Jane Doe,ou=People ")).toBe(canonical); + }); + + it("does not run together words inside a value", () => { + expect(uidFromDn("cn=Jane Doe")).not.toBe(uidFromDn("cn=JaneDoe")); + }); + + it("says so plainly that it came from an LDIF entry", () => { + // It becomes the card's uid, where a vCard's own UID also lives. The + // namespace is what keeps one from being read as the other. + expect(uidFromDn("cn=Jane Doe")).toMatch(/^urn:x-ihasmail:ldif:/); + }); + + it("survives a dn a URI would otherwise choke on", () => { + const uid = uidFromDn("cn=Ünter Straße \\+ Söhne,ou=Übersicht")!; + expect(uid.startsWith("urn:x-ihasmail:ldif:")).toBe(true); + expect(uid).not.toMatch(/[\s?#]/); + }); + + it("has nothing to offer for an entry with no dn", () => { + // Such an entry gets an identity of its own instead, and duplicates on + // re-import as everything did before there was a dn to match on. + expect(uidFromDn("")).toBeNull(); + expect(uidFromDn(" ")).toBeNull(); + }); +}); diff --git a/web/src/lib/ldif.ts b/web/src/lib/ldif.ts index 0b72df9..13df9f7 100644 --- a/web/src/lib/ldif.ts +++ b/web/src/lib/ldif.ts @@ -105,3 +105,36 @@ export function parseLdif(text: string): LdifRecord[] { return !change || change === "add"; }); } + +/** + * An identity for an entry, derived from its distinguished name. + * + * Mozilla's schema has no UID, so a re-import had nothing to be recognised by + * and duplicated everything (#223). The `dn` is what the file actually carries, + * and it does not need to be a durable identity to answer the only question + * being asked of it: have I imported this exact entry before? A migration is + * import, notice something wrong, correct the export, import again -- and the + * `dn` does not change in the ten minutes between two attempts, which is the + * interval that matters. An import is not a sync. + * + * Namespaced rather than stored raw, because it becomes the card's `uid` and + * must not be mistaken for a UID a vCard author meant. The one way this can be + * wrong: two directories that both contain `cn=John Smith`, imported into the + * *same* address book, are one contact afterwards. Matching is per book, so + * filing two directories in two books keeps them apart. + * + * Normalised for case and for the spacing exporters differ in, which costs + * nothing when a file is compared against itself and helps when it is compared + * against a differently-produced export of the same directory. + * + * Null for an entry with no usable `dn`: that entry gets an identity of its own + * and duplicates on re-import, as everything did before. + */ +export function uidFromDn(dn: string): string | null { + const normalised = dn + .trim() + .toLowerCase() + .replace(/\s+/g, " ") + .replace(/\s*([,=])\s*/g, "$1"); + return normalised ? `urn:x-ihasmail:ldif:${encodeURIComponent(normalised)}` : null; +} diff --git a/web/src/store/__tests__/ldif-dedupe.test.ts b/web/src/store/__tests__/ldif-dedupe.test.ts new file mode 100644 index 0000000..577f3d1 --- /dev/null +++ b/web/src/store/__tests__/ldif-dedupe.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CAP, client } from "@/jmap/client"; +import { useContacts } from "@/store/contacts"; +import type { ContactCard, JmapSession } from "@/jmap/types"; + +/* + * Re-importing an LDIF address book you already have. + * + * Mozilla's schema has no UID, so for a long time an LDIF re-import duplicated + * everything -- reported on #174, tracked on #223, and reported again once the + * vCard half shipped without it. The entry's `dn` is the identity the file + * actually carries: not durable enough to be anyone's identity forever, and + * unchanged across the ten minutes between importing a migration, spotting a + * mistake, correcting the export and importing again, which is the only + * interval an import has to survive. + * + * Updated rather than skipped, because the reason to import a file twice is + * that the first attempt was not right. + */ + +interface SetArgs { create?: Record>; update?: Record> } + +/** A card as the server holds it: what `scanBook` asks for, and nothing else. */ +const here = (id: string, uid: string, full: string, bookId = "book1") => ({ + id, uid, addressBookIds: { [bookId]: true }, name: { full }, emails: {}, +}) as unknown as Partial & { id: string }; + +/** What `uidFromDn` makes of a `dn`, spelled out rather than imported, so a + change to the scheme has to be a deliberate one. */ +const uidFor = (dn: string) => `urn:x-ihasmail:ldif:${encodeURIComponent(dn)}`; + +function server(existing: Array & { id: string }> = []) { + 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/query") { + const position = (args.position as number) ?? 0; + return [name, { accountId: "a1", queryState: "1", canCalculateChanges: false, position, ids: position ? [] : existing.map((c) => c.id), total: existing.length }, id]; + } + if (name === "ContactCard/get") { + const want = new Set((args.ids as string[]) ?? []); + return [name, { accountId: "a1", state: "1", list: existing.filter((c) => want.has(c.id)), notFound: [] }, id]; + } + if (name === "ContactCard/set") { + sets.push({ + create: args.create as Record>, + update: args.update as Record>, + }); + return [name, { + accountId: "a1", oldState: "1", newState: "2", + created: Object.fromEntries(Object.keys((args.create ?? {}) as object).map((k) => [k, { id: `new-${k}` }])), + updated: Object.fromEntries(Object.keys((args.update ?? {}) as object).map((k) => [k, null])), + notCreated: {}, notUpdated: {}, + }, 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; +} + +/** The same card, carrying the address likeness is read from. */ +const withEmail = (c: Partial & { id: string }, address = "jane@example.com") => + ({ ...c, emails: { e0: { address } } }) as unknown as Partial & { id: string }; + +const JANE = "dn: cn=Jane Doe,ou=People\ngivenName: Jane\nsn: Doe\ncn: Jane Doe\nmail: jane@example.com\n"; + +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: {} as Record }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("re-importing an LDIF address book", () => { + it("updates the card an entry's dn already names, rather than adding a second", async () => { + const sets = server([here("c1", uidFor("cn=jane doe,ou=people"), "Jane Doe")]); + const r = await useContacts.getState().importLdif(JANE, "book1"); + expect(r).toEqual({ created: 0, updated: 1, alike: 0 }); + expect(sets[0]!.create).toEqual({}); + expect(Object.keys(sets[0]!.update!)).toEqual(["c1"]); + }); + + it("carries the file's version of the entry into the existing card", async () => { + const sets = server([here("c1", uidFor("cn=jane doe,ou=people"), "Jane Doe")]); + await useContacts.getState().importLdif(JANE.replace("sn: Doe", "sn: Doe-Smith").replace("cn: Jane Doe", "cn: Jane Doe-Smith"), "book1"); + expect(sets[0]!.update!.c1!.name).toMatchObject({ full: "Jane Doe-Smith" }); + }); + + it("does not move the card into the book being imported into", async () => { + // A contact filed in two books stays filed in both. Naming the target book + // in an update would quietly refile it. + const sets = server([here("c1", uidFor("cn=jane doe,ou=people"), "Jane Doe")]); + await useContacts.getState().importLdif(JANE, "book1"); + expect(sets[0]!.update!.c1!).not.toHaveProperty("addressBookIds"); + }); + + it("creates an entry whose dn is not here yet", async () => { + const sets = server([here("c1", uidFor("cn=someone else,ou=people"), "Someone Else")]); + const r = await useContacts.getState().importLdif(JANE, "book1"); + expect(r).toEqual({ created: 1, updated: 0, alike: 0 }); + expect(Object.keys(sets[0]!.create!)).toHaveLength(1); + }); + + it("ignores the case and spacing two exports of one directory differ in", async () => { + const sets = server([here("c1", uidFor("cn=jane doe,ou=people"), "Jane Doe")]); + const spaced = JANE.replace("dn: cn=Jane Doe,ou=People", "dn: CN = Jane Doe , OU = People"); + await useContacts.getState().importLdif(spaced, "book1"); + expect(Object.keys(sets[0]!.update!)).toEqual(["c1"]); + }); + + it("matches only inside the book being imported into", async () => { + // Two customers' directories can each hold a cn=John Smith. Filed in two + // books they stay two people; this is the escape hatch for that. + const sets = server([here("c1", uidFor("cn=jane doe,ou=people"), "Jane Doe", "book2")]); + const r = await useContacts.getState().importLdif(JANE, "book1"); + expect(r.created).toBe(1); + expect(sets[0]!.update).toEqual({}); + }); + + it("still counts a look-alike whose dn moved, and imports it anyway", async () => { + // The same person under a different branch of the directory: nothing to + // match on, so it arrives as a new card. Reported, never merged -- name + // plus address is a guess, and a merge made on a guess cannot be undone. + server([withEmail(here("c1", uidFor("cn=jane doe,ou=staff"), "Jane Doe"))]); + const r = await useContacts.getState().importLdif(JANE, "book1"); + expect(r).toEqual({ created: 1, updated: 0, alike: 1 }); + }); + + it("does not also count an entry it matched as a look-alike", async () => { + // It is not a card that looks like this one; it is this one. + server([withEmail(here("c1", uidFor("cn=jane doe,ou=people"), "Jane Doe"))]); + const r = await useContacts.getState().importLdif(JANE, "book1"); + expect(r).toEqual({ created: 0, updated: 1, alike: 0 }); + }); + + it("reports created and updated apart when a file holds both", async () => { + const sets = server([here("c1", uidFor("cn=jane doe,ou=people"), "Jane Doe")]); + const both = `${JANE}\ndn: cn=Alan Turing,ou=People\ncn: Alan Turing\nsn: Turing\nmail: alan@example.org\n`; + const r = await useContacts.getState().importLdif(both, "book1"); + expect(r).toEqual({ created: 1, updated: 1, alike: 0 }); + // One call, not one per kind: creates and updates share Stalwart's budget. + expect(sets).toHaveLength(1); + }); +}); diff --git a/web/src/store/__tests__/ldif-import.test.ts b/web/src/store/__tests__/ldif-import.test.ts index cfddd45..3de58b1 100644 --- a/web/src/store/__tests__/ldif-import.test.ts +++ b/web/src/store/__tests__/ldif-import.test.ts @@ -86,15 +86,22 @@ describe("importing an LDIF address book", () => { expect(first.name).toMatchObject({ full: "Jane Doe" }); }); - it("gives each contact an identity of its own, not the entry's directory name", async () => { + it("gives each contact an identity derived from its entry, and a distinct one", 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); + // Namespaced, so it is never mistaken for a UID a vCard author meant, and + // stable, so importing the same file again recognises these. + expect(uids.every((u) => u.startsWith("urn:x-ihasmail:ldif:"))).toBe(true); + }); + + it("gives an entry with no usable dn an identity of its own", async () => { + const sets = server(); + await useContacts.getState().importLdif("dn:\ncn: Nameless Place\nmail: n@example.com\n", "book1"); + const uid = Object.values(sets[0]!.create!)[0]!.uid as string; + expect(uid).not.toContain("urn:x-ihasmail:ldif:"); + expect(uid.length).toBeGreaterThan(0); }); it("says a file held no contacts rather than reporting none imported", async () => { diff --git a/web/src/store/__tests__/ldif-likeness.test.ts b/web/src/store/__tests__/ldif-likeness.test.ts index 75a5b01..1adde06 100644 --- a/web/src/store/__tests__/ldif-likeness.test.ts +++ b/web/src/store/__tests__/ldif-likeness.test.ts @@ -128,11 +128,23 @@ describe("telling somebody what an LDIF re-import duplicated", () => { }); it("does not count the file against itself", async () => { - // Two of the same person in one file are two new cards, not a duplicate of + // Two people in one file are two new cards, neither a duplicate of // something that was already here. The scan is read before anything lands. server([]); - const twice = entry("Jane Doe", "jane@example.com") + "\n" + entry("Jane Doe", "jane@example.com"); - const r = await useContacts.getState().importLdif(twice, "book1"); + const two = entry("Jane Doe", "jane@example.com") + "\n" + entry("Alan Turing", "alan@example.org"); + const r = await useContacts.getState().importLdif(two, "book1"); expect(r).toEqual({ created: 2, updated: 0, alike: 0 }); }); + + it("makes one card of two entries in a file that share a dn", async () => { + // A directory cannot hold two entries under one name, so a file that does + // is malformed -- and must not produce two cards sharing an identity, + // which is the duplication this all exists to prevent. The later wins. + const sets = server([]); + const twice = entry("Jane Doe", "jane@example.com") + "\n" + entry("Jane Doe", "jane.doe@example.com"); + const r = await useContacts.getState().importLdif(twice, "book1"); + expect(r).toEqual({ created: 1, updated: 0, alike: 0 }); + const only = Object.values(sets[0]!.create!)[0]!; + expect(Object.values(only.emails as Record)[0]!.address).toBe("jane.doe@example.com"); + }); }); diff --git a/web/src/store/__tests__/vcard-dedupe.test.ts b/web/src/store/__tests__/vcard-dedupe.test.ts index e9733b2..4d14fb0 100644 --- a/web/src/store/__tests__/vcard-dedupe.test.ts +++ b/web/src/store/__tests__/vcard-dedupe.test.ts @@ -9,8 +9,8 @@ import type { ContactCard, JmapSession, UploadResponse } from "@/jmap/types"; * A vCard UID is an identity its author meant, so a card whose UID a book * already holds is that card and importing it again used to leave a second * copy. Reported on #174 by the reporter's colleague, decided on #173 for - * events, tracked as #223. The LDIF half is deliberately absent -- Mozilla's - * schema has no UID, so the import invents one and there is nothing to match. + * events, tracked as #223. The LDIF half matches on the entry's `dn` instead, + * since Mozilla's schema has no UID; it is tested in `ldif-dedupe.test.ts`. */ const MAX = 500; diff --git a/web/src/store/contacts.ts b/web/src/store/contacts.ts index c1f9183..45530b9 100644 --- a/web/src/store/contacts.ts +++ b/web/src/store/contacts.ts @@ -3,7 +3,7 @@ import { accountKey, loadRaw, saveJson } from "@/lib/storage"; import { CAP, chunk, client, setErrorMessage } from "@/jmap/client"; import type { AddressBook, ContactCard, EmailAddress, GetResponse, Id, Principal, QueryResponse, SetError, SetResponse } from "@/jmap/types"; import { contactDisplayName, contactEmails, sortKey } from "@/lib/contacts"; -import { parseLdif } from "@/lib/ldif"; +import { parseLdif, uidFromDn } from "@/lib/ldif"; import { cardFromLdif } from "@/lib/mozillaAb"; import { useSettings } from "./settings"; import { useSession } from "./session"; @@ -73,14 +73,14 @@ async function scanBook(accountId: Id, addressBookId: Id): Promise<{ byUid: Map< * wrong in both directions: two colleagues who share a name and a shared alias * collapse into one, and somebody whose address changed since the last export * looks like a stranger. Either mistake is silent and one of them is - * unrecoverable, which is why #223 leaves the decision open. + * unrecoverable, so it counts and never acts. * - * Counting is a different act from acting. An LDIF re-import duplicates - * everything -- Mozilla's schema has no UID, so the import invents one and - * nothing can match -- and the reported harm was confusion rather than data - * loss: somebody imports a file twice and cannot tell what happened. Being told - * "40 of these look like contacts you already had" answers that without - * touching a single card. + * What is left for it to count, now that an LDIF re-import matches on the + * entry's `dn`, is the entries that matching could not catch: one whose `dn` + * moved between exports, and anything imported before there was a `dn` to match + * on. Those arrive as new cards, and saying "40 of these look like contacts you + * already had" is the honest half of the answer -- the reported harm was + * confusion rather than duplication, and being told costs nothing. * * One key per address, so a person whose second address matches is still * recognised. @@ -211,10 +211,12 @@ interface ContactsState { /** * Import an address book in LDIF, read against Mozilla's schema. * - * `updated` is always 0: Mozilla's schema has no UID, so there is nothing to - * recognise a re-import by and everything arrives as new. `alike` says how - * many look like cards already here without acting on it. Answered in the - * same shape as the vCard import so the caller need not know which it called. + * Mozilla's schema has no UID, so a re-import is recognised by the entry's + * `dn` instead -- the same update-rather-than-duplicate rule the vCard import + * follows, on the only identity the file carries. `alike` is what is left + * over: entries that were created and still look like somebody already here, + * which is what a changed `dn` produces. Answered in the same shape as the + * vCard import so the caller need not know which it called. */ importLdif(text: string, addressBookId: Id): Promise<{ created: number; updated: number; alike: number }>; loadPrincipals(): Promise; @@ -568,43 +570,61 @@ export const useContacts = create((set, get) => ({ */ 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"); + /* The record and not just the card: the `dn` is the entry's identity and + `cardFromLdif` deliberately does not carry it into the card. */ + const entries = parseLdif(text) + .map((rec) => ({ uid: uidFromDn(rec.dn), card: cardFromLdif(rec) })) + .filter((e): e is { uid: string | null; card: Partial } => e.card !== null); + if (!entries.length) throw new Error("it has no contacts in it"); /* - * Read before anything is created, so "already had" means before this - * import rather than including it. Every card here is imported either way; - * this only counts. + * Read before anything is written, so "already had" means before this + * import rather than including it. */ const before = await scanBook(accountId, addressBookId); let alike = 0; const create: Record = {}; - cards.forEach((c, i) => { - if (likenessKeys(c).some((k) => before.likeness.has(k))) alike++; - // 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 update: Record = {}; + /* Where in `create` an entry from this same file already landed. A + directory cannot hold two entries under one `dn`, so a file that does is + malformed -- but it must not become two cards sharing a uid, which is a + duplicate of exactly the kind being fixed here. The later one wins, as it + would in the directory. */ + const pending = new Map(); + entries.forEach(({ uid, card }, i) => { + /* + * An entry whose `dn` this book already holds is that entry, and the + * newer version of it wins -- a merge, as the vCard import does it: + * properties the file carries overwrite what is here, properties it does + * not mention are left alone. The reason to import a file twice is + * usually that the first attempt was not right, so skipping would mean a + * corrected export corrects nothing (#174). + */ + const existing = uid ? before.byUid.get(uid) : undefined; + if (existing) { + update[existing] = card; + return; + } + const seen = uid ? pending.get(uid) : undefined; + const key = seen ?? `c${i}`; + if (uid) pending.set(uid, key); + /* + * Only what is actually being created can look like a duplicate: what + * matched above is not a look-alike but the same entry. So this counts + * what `dn` matching could not catch -- an entry whose `dn` moved, or one + * imported before there was anything to match on -- and still only + * counts, because name-plus-email is a guess wrong in both directions and + * a merge made on a guess cannot be undone. + */ + if (!seen && likenessKeys(card).some((k) => before.likeness.has(k))) alike++; + create[key] = { "@type": "Card", version: "1.0", ...card, uid: uid ?? crypto.randomUUID(), addressBookIds: { [addressBookId]: true } }; }); - let created: number; try { - const r = await writeCards(accountId, create); - created = r.created; - if (!created) throw new Error(r.refused ? setErrorMessage(r.refused) : "the server did not accept any of its contacts"); + const { created, updated, refused } = await writeCards(accountId, create, update); + if (!created && !updated) throw new Error(refused ? setErrorMessage(refused) : "the server did not accept any of its contacts"); + return { created, updated, alike }; } finally { await get().loadAll(); } - /* - * Nothing skipped, and nothing that could be. The UID above is invented - * here because Mozilla's schema does not define one, so a re-import has no - * identity to be recognised by -- see #223, where whether to guess at one - * from a name and an address is still an open question. - * - * `alike` is what can be said without answering it: how many of these look - * like contacts that were already here. Reporting is not matching -- every - * card was imported -- and it is the confusion rather than the duplication - * that was reported as the harm. - */ - return { created, updated: 0, alike }; }, async loadPrincipals() { diff --git a/web/src/views/contacts/ContactsView.tsx b/web/src/views/contacts/ContactsView.tsx index d4600e1..260e8a9 100644 --- a/web/src/views/contacts/ContactsView.tsx +++ b/web/src/views/contacts/ContactsView.tsx @@ -153,9 +153,10 @@ export function ContactsView({ id }: { id?: string }) { else toast.success(imported); /* * Said separately, and after, because it is a different kind of fact. - * LDIF has no UID to match on, so nothing was updated and nothing was - * merged -- these are simply here twice now, and saying so is the whole - * of what can honestly be said without guessing (#223). + * These were not matched and are here twice now -- an LDIF entry whose + * `dn` moved between exports, or one imported before there was a `dn` to + * match on. Name-plus-email is enough to notice that and not enough to + * merge on, so it is reported and left alone (#223). */ if (alike) { toast.show(plural(alike, {