Files
native/src/device-sync/contacts/groups.ts
T
Linus Rath d1ae713b1f fix: report changes the phone puts back because the server can't take them #34
A change the server cannot take was put back on the phone without a
word: an edit in a read-only calendar or address book, an answer
where answers are not allowed, an event moved to another account's
calendar, an occurrence the server keeps without its series, a
contact added to a group this device can't change. The change just
vanished.

The planners now say why they put a change back, and the sync report
lists the item with that reason (`readOnly`, `rsvpRefused`,
`instanceOnly`, `crossAccountMove`, `groupNotWritable`). A contact
whose only change still uploads through a group card is not listed.

A refused edit was also reported as "invitations not sent", as if it
had been stored without them; the note now appears only when the
change was stored without its scheduling messages.
2026-09-27 08:44:58 +02:00

266 lines
13 KiB
TypeScript

/**
* Group cards (`kind: "group"`) ↔ Groups rows, and membership edits made on
* the device ↔ the group cards' `members` (docs/device-sync.md, "Contacts
* mapping", "Groups"). Group rows are written before the contacts' plans run,
* so memberships can name them. `members` takes `members/<uid>: null` as a
* clean delete; the first member of a group without any is sent as the whole
* map.
*/
import type { ContactName } from '../../api/types';
import { deriveFullName } from '../../lib/contact-utils';
import { Groups } from '../android-columns';
import { collectionKey, objectRef, parseCollectionKey, parseObjectRef } from '../common/ids';
import { canonicalJson, jsonHash } from '../common/json';
import { ptr, type PatchObject } from '../common/patch';
import type {
AcceptedPlan,
ContactCardWire,
ContactsContext,
DownloadPlan,
LocalContact,
LocalGroup,
OpGroup,
UploadAction,
UploadPlan,
} from '../planner';
import type { ProviderOp, WriteRow } from '../types';
import { sameCell } from './cells';
import { cardUnits } from './download';
import { inPlaceEvidence, matchKind } from './matching';
import { planMemberships, shadowMemberOf } from './members';
import { deleteWhereId } from './ops';
import { shadowOf } from './photo';
import { KIND_ORDER } from './project';
type Ctx = ContactsContext;
/** The title a group card shows. */
function groupTitle(card: ContactCardWire | null): string | null {
const name = card?.name as ContactName | undefined;
if (typeof name?.full === 'string' && name.full.trim()) return name.full;
return deriveFullName(name?.components) || null;
}
const ref = (local: LocalGroup) => local.sourceId ?? `group:${local.groupId}`;
function assertGroup(local: LocalGroup, dirty: boolean | null): ProviderOp {
const values: Record<string, number> = { [Groups.VERSION]: local.version };
if (dirty !== null) values[Groups.DIRTY] = dirty ? 1 : 0;
return { op: 'assert', table: 'groups', id: local.groupId, values, expectCount: 1 };
}
const updateGroup = (local: LocalGroup, values: WriteRow): ProviderOp => ({
op: 'update', table: 'groups', id: local.groupId, values, expectCount: 1,
});
function cardColumns(card: ContactCardWire, ctx: Ctx): WriteRow {
return {
[Groups.SYNC2]: JSON.stringify(shadowOf(card)),
[Groups.GROUP_IS_READ_ONLY]: ctx.isReadOnly(card) ? 1 : 0,
};
}
/**
* Only the values that differ from what the local group holds. The read-only
* flag is not decoded (LocalGroup has no field for it), so it only goes along
* with other writes, which keeps an echo free of writes.
*/
function changed(local: LocalGroup, values: WriteRow): WriteRow {
const out: WriteRow = {};
for (const [column, value] of Object.entries(values)) {
if (column === Groups.SYNC2) {
const before = local.shadow ? canonicalJson(local.shadow) : null;
if (typeof value !== 'string' || before !== canonicalJson(JSON.parse(value))) out[column] = value;
} else if (column === Groups.TITLE) {
if (!sameCell(local.title, value as unknown)) out[column] = value;
} else if (column !== Groups.GROUP_IS_READ_ONLY) {
out[column] = value;
}
}
if (Object.keys(out).length && Groups.GROUP_IS_READ_ONLY in values) out[Groups.GROUP_IS_READ_ONLY] = values[Groups.GROUP_IS_READ_ONLY];
return out;
}
export function planGroupDownload(card: ContactCardWire, local: LocalGroup | null, ctx: Ctx): DownloadPlan {
const identity = objectRef(ctx.jmapAccountId, card.id);
const remoteTitle = groupTitle(card);
const plan = (ops: ProviderOp[], effect: DownloadPlan['effect'], conflicts = 0, stillDirty = false): DownloadPlan => ({
ops: { ref: identity, ops }, conflicts, stillDirty, effect, writes: ops.filter((o) => o.op !== 'assert').length,
});
if (!local) {
return plan([{
op: 'insert',
table: 'groups',
values: { [Groups.SOURCE_ID]: identity, [Groups.TITLE]: remoteTitle ?? '', [Groups.GROUP_VISIBLE]: 1, [Groups.DIRTY]: 0, ...cardColumns(card, ctx) },
}], 'insert');
}
if (local.deleted) {
// Deleted on the device: the delete wins over a server edit. Only the shadow (and an identity
// never written) follows the card, so the deletion uploads against the card as it is now.
const diff = changed(local, { ...(local.sourceId ? {} : { [Groups.SOURCE_ID]: identity, [Groups.SYNC3]: null }), ...cardColumns(card, ctx) });
if (!Object.keys(diff).length) return plan([], 'none', 0, true);
return plan([assertGroup(local, local.dirty), updateGroup(local, diff)], 'update', 0, true);
}
if (!local.sourceId) {
// Our own create whose identity was never written.
return plan([
assertGroup(local, local.dirty),
updateGroup(local, { [Groups.SOURCE_ID]: identity, [Groups.SYNC3]: null, ...cardColumns(card, ctx) }),
], 'update', 0, true);
}
const base = groupTitle(local.shadow);
const localChanged = local.dirty && !sameCell(local.title, base);
const remoteChanged = !sameCell(remoteTitle, base);
let conflicts = 0;
const values: WriteRow = { ...cardColumns(card, ctx) };
if (remoteChanged && (!localChanged || !sameCell(local.title, remoteTitle))) {
if (localChanged) conflicts++;
values[Groups.TITLE] = remoteTitle ?? '';
}
const diff = changed(local, values);
if (!Object.keys(diff).length) return plan([], 'none', 0, localChanged && !remoteChanged);
return plan([assertGroup(local, local.dirty), updateGroup(local, diff)], 'update', conflicts, localChanged && !remoteChanged);
}
export function planGroupLocalDelete(local: LocalGroup): OpGroup {
return { ref: ref(local), ops: [deleteWhereId('groups', local.groupId)] };
}
/** A poison marker's `fp` for a group, as the engine takes it (engine/poison.ts). */
export function groupFingerprint(local: LocalGroup): string {
return jsonHash({ deleted: local.deleted, title: local.title, shadow: local.shadow });
}
export function planGroupUpload(local: LocalGroup, ctx: Ctx): UploadPlan<ContactCardWire> {
const group: OpGroup = { ref: ref(local), ops: [] };
if (!local.deleted && !local.dirty && local.sourceId) return { kind: 'clean', ops: group };
const p = local.poison;
if (p && p.until > ctx.now && p.fp === groupFingerprint(local)) return { kind: 'skip', reason: `poisoned:${p.type}` };
const parsed = parseObjectRef(local.sourceId);
const readOnly = !!local.shadow && ctx.isReadOnly(local.shadow);
if (local.deleted) {
if (parsed) {
if (readOnly) return { kind: 'revert', ops: { ...group, ops: [deleteWhereId('groups', local.groupId)] }, refetch: true, reason: 'readOnly' };
const books = Object.entries(local.shadow?.addressBookIds ?? {}).filter(([, on]) => on).map(([id]) => id);
const selected = books.filter((id) => ctx.isSelected(collectionKey(parsed.accountId, id)));
if (selected.length && selected.length < books.length) {
const patch: PatchObject = {};
for (const id of selected) patch[ptr('addressBookIds', id)] = null;
return { kind: 'upload', actions: [{ kind: 'update', id: parsed.id, patch }] };
}
return { kind: 'upload', actions: [{ kind: 'destroy', id: parsed.id, uid: typeof local.shadow?.uid === 'string' ? local.shadow.uid : null }] };
}
if (local.pending) return { kind: 'upload', actions: [{ kind: 'destroy', id: null, uid: local.pending.uid }] };
return { kind: 'purge', ops: { ...group, ops: [deleteWhereId('groups', local.groupId)] } };
}
if (local.sourceId && !parsed) return { kind: 'skip', reason: 'invalidSourceId' };
const title = local.title?.trim() ? local.title : null;
if (!local.sourceId) {
if (!local.pending) {
const target = ctx.createTarget();
if (!target) return { kind: 'skip', reason: 'noWritableAddressBook' };
const pending = { uid: ctx.mintUid(), target };
return { kind: 'claim', ops: { ...group, ops: [assertGroup(local, null), updateGroup(local, { [Groups.SYNC3]: JSON.stringify(pending) })] } };
}
const target = parseCollectionKey(local.pending.target);
if (!target) return { kind: 'skip', reason: 'invalidPendingTarget' };
const object: Partial<ContactCardWire> = {
'@type': 'Card', version: '1.0', uid: local.pending.uid, kind: 'group', addressBookIds: { [target.id]: true },
...(title ? { name: { full: title } } : {}),
};
return { kind: 'upload', actions: [{ kind: 'create', uid: local.pending.uid, collectionId: target.id, object }] };
}
const shadowTitle = groupTitle(local.shadow);
if (readOnly) {
const ops = { ...group, ops: [assertGroup(local, null), updateGroup(local, { [Groups.TITLE]: shadowTitle ?? '', [Groups.DIRTY]: 0 })] };
// Reported when a rename is put back.
return sameCell(local.title, shadowTitle) ? { kind: 'revert', ops } : { kind: 'revert', ops, reason: 'readOnly' };
}
if (sameCell(title, shadowTitle) || title === null) {
return { kind: 'clean', ops: { ...group, ops: [assertGroup(local, true), updateGroup(local, { [Groups.DIRTY]: 0 })] } };
}
const patch: PatchObject = local.shadow?.name && typeof local.shadow.name === 'object' ? { 'name/full': title } : { name: { full: title } };
return { kind: 'upload', actions: [{ kind: 'update', id: parsed!.id, patch }] };
}
export function planGroupAccepted(local: LocalGroup, server: ContactCardWire, ctx: Ctx): AcceptedPlan {
if (local.deleted) {
const purge = planGroupLocalDelete(local);
return { ops: purge, keepDirtyOps: purge };
}
const account = parseObjectRef(local.sourceId)?.accountId ?? parseCollectionKey(local.pending?.target)?.accountId ?? ctx.jmapAccountId;
const identity = objectRef(account, server.id);
const raw: WriteRow = { [Groups.SOURCE_ID]: identity, [Groups.SYNC3]: null, [Groups.SYNC4]: null, ...cardColumns(server, ctx) };
const title = groupTitle(server);
const keepDirtyOps = { ref: identity, ops: [updateGroup(local, raw)] };
// A rename the server still lacks (the upload it accepted was another one, a membership
// patch) stays the device's: title and DIRTY are kept, and it uploads against the new shadow.
const shadowTitle = groupTitle(local.shadow);
const renamed = local.dirty && !!local.title?.trim() && !sameCell(local.title, shadowTitle);
if (renamed && sameCell(title, shadowTitle)) {
return { ops: { ref: identity, ops: [assertGroup(local, null), updateGroup(local, raw)] }, keepDirtyOps };
}
const ops: WriteRow = { ...raw, [Groups.DIRTY]: 0 };
if (!sameCell(local.title, title)) ops[Groups.TITLE] = title ?? '';
return { ops: { ref: identity, ops: [assertGroup(local, null), updateGroup(local, ops)] }, keepDirtyOps };
}
/**
* Membership edits of the given contacts as patches of the group cards'
* `members`. A contact's edits are its rows against the base kept in its
* shadow; what the group shadows already show is not sent again.
*/
export function planMembershipUploads(contacts: LocalContact[], groups: LocalGroup[], ctx: Ctx): UploadAction<ContactCardWire>[] {
const bySource = new Map(groups.filter((g) => g.sourceId).map((g) => [g.sourceId!, g]));
const members = (g: LocalGroup) => {
const m = g.shadow?.members;
return m && typeof m === 'object' ? (m as Record<string, boolean>) : {};
};
const edits = new Map<LocalGroup, Map<string, boolean>>();
for (const contact of contacts) {
if (contact.deleted) continue;
const uid = typeof contact.shadow?.uid === 'string' ? contact.shadow.uid : contact.pending?.uid;
const account = parseObjectRef(contact.sourceId)?.accountId;
if (!uid || !account) continue;
const remote = groups
.filter((g) => g.sourceId && !g.deleted && members(g)[uid] === true)
.map((g) => g.sourceId!);
const base = contact.shadow ? shadowMemberOf(contact.shadow) : [];
if (base === null) continue;
const units = cardUnits(contact.shadow, ctx);
const evidence = inPlaceEvidence(KIND_ORDER.map((k) => matchKind(k, contact.rows, units)));
const plan = planMemberships({
rows: contact.rows, base, remote, evidence, restore: false, parent: contact.rawContactId,
groupRowIdBySourceId: () => null,
});
const put = (gsid: string, on: boolean) => {
const g = bySource.get(gsid);
const gref = parseObjectRef(gsid);
if (!g || g.deleted || !g.shadow || !gref || gref.accountId !== account) return;
if (ctx.isReadOnly(g.shadow)) return;
if (!edits.has(g)) edits.set(g, new Map());
edits.get(g)!.set(uid, on);
};
for (const g of plan.added) put(g, true);
for (const g of plan.removed) put(g, false);
}
const actions: UploadAction<ContactCardWire>[] = [];
for (const [g, changes] of edits) {
const current = members(g);
const patch: PatchObject = {};
if (!Object.keys(current).length) {
const whole: Record<string, boolean> = {};
for (const [uid, on] of changes) if (on) whole[uid] = true;
if (!Object.keys(whole).length) continue;
patch.members = whole;
} else {
for (const [uid, on] of changes) patch[ptr('members', uid)] = on ? true : null;
}
actions.push({ kind: 'update', id: parseObjectRef(g.sourceId)!.id, patch });
}
return actions;
}