mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
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.
76 lines
3.5 KiB
TypeScript
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 };
|
|
}
|