Files
native/src/device-sync/contacts/ops.ts
T
Linus Rath a948738506 feat: map contacts and groups to android's contacts provider #34
This is the planner for contacts (`src/device-sync/contacts`), the last
piece the sync task was waiting for: it turns JSContact cards into raw
contacts and data rows, and device edits back into JMAP writes.

- Names, nicknames, organizations and titles, emails, phones, postal
  addresses, websites, birthdays and anniversaries (also without a
  year), relations, notes and photos map both ways, with their types and
  custom labels. Group cards become groups, and their members become
  group memberships.
- Device edits upload as patches of the fields whose rows changed; a
  whole map goes up only where Stalwart needs one. Whatever a card holds
  that Android can't show (pronouns, online services, keywords, a second
  note, an address's coordinates) is never touched, so an edit on the
  phone leaves it intact on the server.
- Rows are matched to their card's entries by the keys they carry and,
  where an editor re-inserted them without (Fossify saves that way), by
  their values. An entry the editor didn't show is never read as
  deleted, and the keys Stalwart assigns by position to cards from other
  clients are followed when they shift.
- Contacts in read-only address books are put back when the device
  changes them. Text beyond what the provider stores is cut on the device
  and never uploaded.

Checked: 151 contacts tests (mappings both ways, one test per edited
field, Fossify and AOSP editing, re-keyed cards, merges, groups,
read-only books, a full lifecycle), the engine's tests with both real
planners, and `npm run typecheck`, `npm test` and `npm run i18n:check`
on this commit.
2026-09-27 04:23:22 +02:00

76 lines
3.5 KiB
TypeScript

/**
* Provider ops the contacts planner emits. Every write into an existing item
* is guarded: the group asserts the VERSION (and DIRTY) the plan was made
* from, and ops that address one row by id expect exactly one row
* (docs/device-sync.md, "Merge rules").
*/
import { Data, GroupMembership, MimeType, RawContacts } from '../android-columns';
import type { LocalContact } from '../planner';
import type { ProviderOp, WriteRow } from '../types';
import { encodeBaseline } from './cells';
import { predictStored, SPECS, type Unit } from './project';
export function assertContact(local: LocalContact, dirty: boolean | null): ProviderOp {
const values: Record<string, number> = { [RawContacts.VERSION]: local.version };
if (dirty !== null) values[RawContacts.DIRTY] = dirty ? 1 : 0;
return { op: 'assert', table: 'raw_contacts', id: local.rawContactId, values, expectCount: 1 };
}
export function updateRawContact(id: number, values: WriteRow): ProviderOp {
return { op: 'update', table: 'raw_contacts', id, values, expectCount: 1 };
}
/** The cells a unit writes (a membership names its group by row id: GROUP_SOURCE_ID is read-only here). */
function unitValues(u: Unit): WriteRow {
const out: WriteRow = {};
for (const [column, value] of Object.entries(u.cells)) {
if (column !== GroupMembership.GROUP_SOURCE_ID) out[column] = value;
}
return { ...out, ...(u.extra ?? {}) };
}
/** DATA_SYNC1..3 for a row written from a unit: key, photo hash and the predicted stored cells. */
export function syncValues(u: Unit): WriteRow {
const out: WriteRow = {
[Data.DATA_SYNC1]: u.key,
[Data.DATA_SYNC3]: encodeBaseline(predictStored(u), SPECS[u.mimetype].columns),
};
if (u.mimetype === MimeType.PHOTO) out[Data.DATA_SYNC2] = u.photoHash ?? null;
return out;
}
/** Inserts a unit's row; `parent` is the raw contact id, or `{ ref }` to an insert earlier in the group. */
export function insertUnit(parent: number | { ref: number }, u: Unit, keyed: boolean): ProviderOp {
const values: WriteRow = { [Data.MIMETYPE]: u.mimetype, ...unitValues(u), ...(keyed ? syncValues(u) : {}) };
if (typeof parent === 'number') {
values[Data.RAW_CONTACT_ID] = parent;
return { op: 'insert', table: 'data', values };
}
return { op: 'insert', table: 'data', values, refs: { [Data.RAW_CONTACT_ID]: parent.ref } };
}
/** Rewrites a row from a unit; photo bytes only go when the hash differs. */
export function updateUnit(rowId: number, u: Unit, keyed: boolean, withBytes = true): ProviderOp {
const values = unitValues(u);
if (!withBytes) delete values[Data.DATA15];
return { op: 'update', table: 'data', id: rowId, values: { ...values, ...(keyed ? syncValues(u) : {}) }, expectCount: 1 };
}
export function updateData(rowId: number, values: WriteRow): ProviderOp {
return { op: 'update', table: 'data', id: rowId, values, expectCount: 1 };
}
export function deleteData(rowId: number): ProviderOp {
return { op: 'delete', table: 'data', id: rowId, expectCount: 1 };
}
/** A delete that tolerates a row that is already gone (purges, server deletes). */
export function deleteWhereId(table: 'raw_contacts' | 'groups' | 'data', id: number): ProviderOp {
return { op: 'delete', table, where: '_id = ?', args: [id] };
}
/** A write that tolerates a row that is already gone (keep-dirty baselines of rows edited meanwhile). */
export function updateWhereId(table: 'data', id: number, values: WriteRow): ProviderOp {
return { op: 'update', table, where: '_id = ?', args: [id], values };
}