mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
feat: merge contacts and calendar sync with the phone on Android #34
Brings feat/device-sync into main: on Android, each account's address books and calendars can sync both ways with the phone's Contacts and Calendar apps (Settings -> Contacts / Calendar -> Sync to this device), through a "Bulwark" Android account with a contacts and a calendar sync adapter. - native side: the account, both sync adapters, provider access scoped to the account, push routing to the syncs, sync problem notices - the sync engine in `src/device-sync`, run as a headless task - the settings sections, sign-out and removal handling, translations - design, limitations and verification in `docs/device-sync.md`
This commit is contained in:
@@ -40,6 +40,11 @@ const TRANSLATED_PANES: Record<string, string[]> = {
|
||||
'settings/UpdatesSettings.tsx': [],
|
||||
'settings/VacationSettings.tsx': ['YYYY-MM-DD HH:MM'],
|
||||
'settings/settings-section.tsx': [],
|
||||
// Device sync (Android, #34).
|
||||
'settings/device-sync/DeviceSyncSection.tsx': [],
|
||||
'settings/device-sync/DeviceSyncAccountRow.tsx': [],
|
||||
'settings/device-sync/DeviceSyncCollectionsSheet.tsx': [],
|
||||
'settings/device-sync/status.ts': [],
|
||||
'filters/FilterRuleModal.tsx': [],
|
||||
'filters/SieveEditorSheet.tsx': [],
|
||||
};
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
} from '../../stores/settings-store';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { AUTO_TIME_ZONE, getDeviceTimeZone, isValidTimeZone } from '../../lib/calendar-timezone';
|
||||
import { deviceSyncAvailable } from '../../device-sync/app/available';
|
||||
import { CALENDAR_AUTHORITY } from '../../device-sync/types';
|
||||
import { DeviceSyncSection } from './device-sync/DeviceSyncSection';
|
||||
|
||||
// A compact list of IANA zones for the picker (#755). The device zone and
|
||||
// any previously stored value are always offered too.
|
||||
@@ -46,6 +49,9 @@ export function CalendarSettings() {
|
||||
const tasksEnabled = useSettingsStore((s) => s.enableCalendarTasks);
|
||||
const showTasksOnCal = useSettingsStore((s) => s.showTasksOnCalendar);
|
||||
|
||||
// Android with the native module only (#34).
|
||||
const deviceSync = React.useMemo(() => deviceSyncAvailable(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
@@ -64,130 +70,143 @@ export function CalendarSettings() {
|
||||
}, [deviceZone, timeZone, t]);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('calendar.settings.title', 'Calendar')}>
|
||||
<SettingItem label={t('calendar.settings.default_view', 'Default view')}>
|
||||
<Select
|
||||
value={viewMode}
|
||||
onChange={(v) => update('calendarDefaultView', v as CalendarView)}
|
||||
options={[
|
||||
{ value: 'month', label: t('calendar.views.month', 'Month') },
|
||||
{ value: 'week', label: t('calendar.views.week', 'Week') },
|
||||
{ value: 'day', label: t('calendar.views.day', 'Day') },
|
||||
{ value: 'agenda', label: t('calendar.views.agenda', 'Agenda') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
<>
|
||||
<SettingsSection title={t('calendar.settings.title', 'Calendar')}>
|
||||
<SettingItem label={t('calendar.settings.default_view', 'Default view')}>
|
||||
<Select
|
||||
value={viewMode}
|
||||
onChange={(v) => update('calendarDefaultView', v as CalendarView)}
|
||||
options={[
|
||||
{ value: 'month', label: t('calendar.views.month', 'Month') },
|
||||
{ value: 'week', label: t('calendar.views.week', 'Week') },
|
||||
{ value: 'day', label: t('calendar.views.day', 'Day') },
|
||||
{ value: 'agenda', label: t('calendar.views.agenda', 'Agenda') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('calendar.settings.week_starts_on', 'Week starts on')}>
|
||||
<Select
|
||||
value={String(firstDay)}
|
||||
onChange={(v) => update('calendarFirstDayOfWeek', Number(v) as FirstDayOfWeek)}
|
||||
options={[
|
||||
{ value: '1', label: t('calendar.days.monday', 'Monday') },
|
||||
{ value: '6', label: t('calendar.days.saturday', 'Saturday') },
|
||||
{ value: '0', label: t('calendar.days.sunday', 'Sunday') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t('calendar.settings.week_starts_on', 'Week starts on')}>
|
||||
<Select
|
||||
value={String(firstDay)}
|
||||
onChange={(v) => update('calendarFirstDayOfWeek', Number(v) as FirstDayOfWeek)}
|
||||
options={[
|
||||
{ value: '1', label: t('calendar.days.monday', 'Monday') },
|
||||
{ value: '6', label: t('calendar.days.saturday', 'Saturday') },
|
||||
{ value: '0', label: t('calendar.days.sunday', 'Sunday') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('calendar.settings.time_format', 'Time format')}>
|
||||
<RadioGroup
|
||||
value={timeFormat}
|
||||
onChange={(v) => update('calendarTimeFormat', v as TimeFormat)}
|
||||
options={[
|
||||
{ value: '12h', label: t('calendar.settings.time_format_12h', '12-hour') },
|
||||
{ value: '24h', label: t('calendar.settings.time_format_24h', '24-hour') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t('calendar.settings.time_format', 'Time format')}>
|
||||
<RadioGroup
|
||||
value={timeFormat}
|
||||
onChange={(v) => update('calendarTimeFormat', v as TimeFormat)}
|
||||
options={[
|
||||
{ value: '12h', label: t('calendar.settings.time_format_12h', '12-hour') },
|
||||
{ value: '24h', label: t('calendar.settings.time_format_24h', '24-hour') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('calendar.settings.time_zone', 'Time zone')}
|
||||
description={t(
|
||||
'calendar.settings.time_zone_desc',
|
||||
'Zone used for new events and for interpreting the calendar. "Device" follows the phone.',
|
||||
)}
|
||||
>
|
||||
<Select
|
||||
value={timeZone || AUTO_TIME_ZONE}
|
||||
onChange={(v) => update('calendarTimeZone', v)}
|
||||
options={timeZoneOptions}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('calendar.settings.show_time_in_month_view', 'Show time in month view')}
|
||||
description={t(
|
||||
'calendar.settings.show_time_in_month_view_desc',
|
||||
'Display event times in the month calendar view. On small screens this shows full event entries instead of dots.',
|
||||
)}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={showTimeInMonth}
|
||||
onChange={(v) => update('calendarShowTimeInMonth', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('calendar.settings.show_week_numbers', 'Show week numbers')}
|
||||
description={t('calendar.settings.show_week_numbers_mobile_desc', 'Display week numbers in the month view.')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={showWeekNumbers}
|
||||
onChange={(v) => update('calendarShowWeekNumbers', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('calendar.settings.calendar_free_scroll', 'Free scrolling')}
|
||||
description={t(
|
||||
'calendar.settings.calendar_free_scroll_desc',
|
||||
'Scroll continuously through months, weeks and days instead of one period at a time',
|
||||
)}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={freeScroll}
|
||||
onChange={(v) => update('calendarFreeScroll', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('calendar.settings.show_birthday_calendar', 'Contact birthday calendar')}
|
||||
description={t(
|
||||
'calendar.settings.show_birthday_calendar_desc',
|
||||
'Show a virtual calendar with birthdays from your contacts',
|
||||
)}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={birthdayCal}
|
||||
onChange={(v) => update('showBirthdayCalendar', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('calendar.settings.enable_tasks', 'Enable tasks')}
|
||||
description={t('calendar.settings.enable_tasks_desc', 'Show a tasks view in the calendar for managing to-dos')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={tasksEnabled}
|
||||
onChange={(v) => update('enableCalendarTasks', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{tasksEnabled && (
|
||||
<SettingItem
|
||||
label={t('calendar.settings.show_tasks_on_calendar', 'Show tasks on calendar')}
|
||||
label={t('calendar.settings.time_zone', 'Time zone')}
|
||||
description={t(
|
||||
'calendar.settings.show_tasks_on_calendar_desc',
|
||||
'Display task chips on the day and week calendar views',
|
||||
'calendar.settings.time_zone_desc',
|
||||
'Zone used for new events and for interpreting the calendar. "Device" follows the phone.',
|
||||
)}
|
||||
>
|
||||
<Select
|
||||
value={timeZone || AUTO_TIME_ZONE}
|
||||
onChange={(v) => update('calendarTimeZone', v)}
|
||||
options={timeZoneOptions}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('calendar.settings.show_time_in_month_view', 'Show time in month view')}
|
||||
description={t(
|
||||
'calendar.settings.show_time_in_month_view_desc',
|
||||
'Display event times in the month calendar view. On small screens this shows full event entries instead of dots.',
|
||||
)}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={showTasksOnCal}
|
||||
onChange={(v) => update('showTasksOnCalendar', v)}
|
||||
checked={showTimeInMonth}
|
||||
onChange={(v) => update('calendarShowTimeInMonth', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('calendar.settings.show_week_numbers', 'Show week numbers')}
|
||||
description={t('calendar.settings.show_week_numbers_mobile_desc', 'Display week numbers in the month view.')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={showWeekNumbers}
|
||||
onChange={(v) => update('calendarShowWeekNumbers', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('calendar.settings.calendar_free_scroll', 'Free scrolling')}
|
||||
description={t(
|
||||
'calendar.settings.calendar_free_scroll_desc',
|
||||
'Scroll continuously through months, weeks and days instead of one period at a time',
|
||||
)}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={freeScroll}
|
||||
onChange={(v) => update('calendarFreeScroll', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('calendar.settings.show_birthday_calendar', 'Contact birthday calendar')}
|
||||
description={t(
|
||||
'calendar.settings.show_birthday_calendar_desc',
|
||||
'Show a virtual calendar with birthdays from your contacts',
|
||||
)}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={birthdayCal}
|
||||
onChange={(v) => update('showBirthdayCalendar', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('calendar.settings.enable_tasks', 'Enable tasks')}
|
||||
description={t('calendar.settings.enable_tasks_desc', 'Show a tasks view in the calendar for managing to-dos')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={tasksEnabled}
|
||||
onChange={(v) => update('enableCalendarTasks', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{tasksEnabled && (
|
||||
<SettingItem
|
||||
label={t('calendar.settings.show_tasks_on_calendar', 'Show tasks on calendar')}
|
||||
description={t(
|
||||
'calendar.settings.show_tasks_on_calendar_desc',
|
||||
'Display task chips on the day and week calendar views',
|
||||
)}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={showTasksOnCal}
|
||||
onChange={(v) => update('showTasksOnCalendar', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
{deviceSync && (
|
||||
<DeviceSyncSection
|
||||
authority={CALENDAR_AUTHORITY}
|
||||
title={t('calendar.settings.device_sync_title', 'Sync to this device')}
|
||||
description={t(
|
||||
'calendar.settings.device_sync_desc',
|
||||
'Show your calendars in the Calendar app and other apps on this device. Changes sync both ways.',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,11 +9,14 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import Dialog from '../Dialog';
|
||||
import { ContactImportSheet, AddressBookPickerSheet } from '../contacts';
|
||||
import { DeviceSyncSection } from './device-sync/DeviceSyncSection';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useContactsStore, selectAddressBooksWithCount } from '../../stores/contacts-store';
|
||||
import { contactsToVCard } from '../../lib/vcard';
|
||||
import { isGroup } from '../../lib/contact-utils';
|
||||
import { deviceSyncAvailable } from '../../device-sync/app/available';
|
||||
import { CONTACTS_AUTHORITY } from '../../device-sync/types';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
@@ -53,6 +56,8 @@ export function ContactsSettings() {
|
||||
const [newName, setNewName] = React.useState('');
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<{ id: string; name: string; count: number } | null>(null);
|
||||
// Android with the native module only (#34).
|
||||
const deviceSync = React.useMemo(() => deviceSyncAvailable(), []);
|
||||
|
||||
const exportable = React.useMemo(() => contacts.filter((cc) => !isGroup(cc)), [contacts]);
|
||||
const exportLabel =
|
||||
@@ -356,6 +361,17 @@ export function ContactsSettings() {
|
||||
</View>
|
||||
</SettingsSection>
|
||||
|
||||
{deviceSync && (
|
||||
<DeviceSyncSection
|
||||
authority={CONTACTS_AUTHORITY}
|
||||
title={t('settings.contacts.device_sync_title', 'Sync to this device')}
|
||||
description={t(
|
||||
'settings.contacts.device_sync_desc',
|
||||
'Show your address books in the Contacts app and other apps on this device. Changes sync both ways.',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ContactImportSheet
|
||||
visible={importOpen}
|
||||
onClose={() => setImportOpen(false)}
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
// One signed-in account in "Sync to this device" (docs/device-sync.md,
|
||||
// "Settings sections"): the switch (Android's automatic sync for the
|
||||
// authority), the status or the error with what to do about it, and, while
|
||||
// sync is on, which collections sync, where new contacts go or who reminds of
|
||||
// events, the interval, "Sync now" and Android's account settings.
|
||||
import React from 'react';
|
||||
import { Alert, StyleSheet, Text, View, ActivityIndicator } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { AlertTriangle, RefreshCw, Settings as SettingsIcon, XCircle } from 'lucide-react-native';
|
||||
import { SettingItem, Select, RadioGroup, ToggleSwitch } from '../settings-section';
|
||||
import Button from '../../Button';
|
||||
import Dialog from '../../Dialog';
|
||||
import { spacing, typography, type ThemePalette } from '../../../theme/tokens';
|
||||
import { useColors } from '../../../theme/colors';
|
||||
import { useLocaleStore, type TranslateFn } from '../../../stores/locale-store';
|
||||
import { useSettingsStore } from '../../../stores/settings-store';
|
||||
import type { AccountEntry } from '../../../stores/account-store';
|
||||
import {
|
||||
effectiveSelection,
|
||||
emptyAccountDeviceSync,
|
||||
intervalFor,
|
||||
syncOnInApp,
|
||||
useDeviceSyncStore,
|
||||
} from '../../../stores/device-sync-store';
|
||||
import {
|
||||
changeReminderOwner,
|
||||
changeSyncInterval,
|
||||
disableDeviceSync,
|
||||
enableDeviceSync,
|
||||
openAndroidAccountSettings,
|
||||
resolveDeletions,
|
||||
resumeDeviceSync,
|
||||
syncNow,
|
||||
} from '../../../device-sync/app/lifecycle';
|
||||
import { hasSyncPermissions, openAppSystemSettings, requestSyncPermissions } from '../../../device-sync/app/permissions';
|
||||
import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY, type Authority, type ReminderOwner } from '../../../device-sync/types';
|
||||
import type { RootStackParamList } from '../../../navigation/types';
|
||||
import { DeviceSyncCollectionsSheet } from './DeviceSyncCollectionsSheet';
|
||||
import { describeSyncStatus, type StatusAction } from './status';
|
||||
import { useAndroidSync } from './use-android-sync';
|
||||
import { useSyncCollections } from './use-sync-collections';
|
||||
|
||||
interface Props {
|
||||
account: AccountEntry;
|
||||
authority: Authority;
|
||||
}
|
||||
|
||||
type Busy = 'on' | 'off' | 'sync' | null;
|
||||
|
||||
type Prompt =
|
||||
| { kind: 'rationale' }
|
||||
| { kind: 'blocked' }
|
||||
| { kind: 'pending'; pending: number };
|
||||
|
||||
/** Asks who reminds of synced events; null when the user backed out. */
|
||||
function askReminderOwner(t: TranslateFn): Promise<ReminderOwner | null> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const answer = (owner: ReminderOwner | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(owner);
|
||||
};
|
||||
Alert.alert(
|
||||
t('settings.device_sync.reminder_question_title', 'Who should remind you?'),
|
||||
t(
|
||||
'settings.device_sync.reminder_question_message',
|
||||
'Synced events can remind you from the calendar app on this device or from Bulwark. Pick one so you are not reminded twice. You can change it later.',
|
||||
),
|
||||
[
|
||||
{ text: t('common.cancel', 'Cancel'), style: 'cancel', onPress: () => answer(null) },
|
||||
{ text: t('settings.device_sync.reminder_owner_bulwark', 'Bulwark'), onPress: () => answer('bulwark') },
|
||||
{ text: t('settings.device_sync.reminder_owner_device', 'Calendar app'), onPress: () => answer('device') },
|
||||
],
|
||||
{ cancelable: true, onDismiss: () => answer(null) },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** How often the status line's "… ago" is brought up to date. */
|
||||
const RELATIVE_TIME_REFRESH_MS = 30_000;
|
||||
|
||||
export function DeviceSyncAccountRow({ account, authority }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const storedEntry = useDeviceSyncStore((s) => s.accounts[account.id]);
|
||||
const entry = React.useMemo(() => storedEntry ?? emptyAccountDeviceSync(), [storedEntry]);
|
||||
const calendarReminders = useSettingsStore((s) => s.calendarNotificationsEnabled);
|
||||
const isContacts = authority === CONTACTS_AUTHORITY;
|
||||
|
||||
const onInApp = syncOnInApp(entry, authority);
|
||||
const { view, permitted, refresh } = useAndroidSync(entry.androidAccountName, authority);
|
||||
const [busy, setBusy] = React.useState<Busy>(null);
|
||||
// "Synced 2 min ago" keeps counting while the settings stay open.
|
||||
const [now, setNow] = React.useState(() => Date.now());
|
||||
React.useEffect(() => {
|
||||
const timer = setInterval(() => setNow(Date.now()), RELATIVE_TIME_REFRESH_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
const [prompt, setPrompt] = React.useState<Prompt | null>(null);
|
||||
const [chooserOpen, setChooserOpen] = React.useState(false);
|
||||
|
||||
// The switch follows Android; until Android answered, what the app knows.
|
||||
const androidOn = view ? view.exists && view.automatic : onInApp;
|
||||
const checked = busy === 'on' ? true : busy === 'off' ? false : androidOn;
|
||||
const paused = onInApp && !!view?.exists && !view.automatic;
|
||||
const showDetails = (checked || paused) && busy !== 'off';
|
||||
|
||||
const { state: collectionsState } = useSyncCollections(account.id, authority, showDetails);
|
||||
const collections = collectionsState.kind === 'loaded' ? collectionsState.collections : null;
|
||||
const selectedKeys = React.useMemo(
|
||||
() => (collections ? effectiveSelection(entry, authority, collections) : null),
|
||||
[collections, entry, authority],
|
||||
);
|
||||
|
||||
const accountLabel = account.email || account.username;
|
||||
const status = describeSyncStatus(
|
||||
{
|
||||
authority,
|
||||
on: checked,
|
||||
paused,
|
||||
removed: !!entry.removedInAndroidSettings,
|
||||
permitted,
|
||||
syncing: !!view?.active,
|
||||
lastRun: entry.lastRun[authority],
|
||||
now,
|
||||
},
|
||||
t,
|
||||
);
|
||||
|
||||
const alertFailed = (message: string) => {
|
||||
Alert.alert(
|
||||
t('settings.device_sync.failed_title', 'Sync could not be turned on'),
|
||||
message,
|
||||
[{ text: t('settings.device_sync.ok', 'OK') }],
|
||||
);
|
||||
};
|
||||
|
||||
const alertDenied = () => {
|
||||
Alert.alert(
|
||||
t('settings.device_sync.denied_title', 'Sync stays off'),
|
||||
isContacts
|
||||
? t('settings.device_sync.denied_contacts', 'Without access to contacts, Bulwark cannot put your address books on this device.')
|
||||
: t('settings.device_sync.denied_calendar', 'Without access to calendars, Bulwark cannot put your calendars on this device.'),
|
||||
[{ text: t('settings.device_sync.ok', 'OK') }],
|
||||
);
|
||||
};
|
||||
|
||||
const turnOn = async () => {
|
||||
setPrompt(null);
|
||||
setBusy('on');
|
||||
try {
|
||||
const permission = await requestSyncPermissions(authority);
|
||||
if (permission === 'blocked') {
|
||||
setPrompt({ kind: 'blocked' });
|
||||
return;
|
||||
}
|
||||
if (permission === 'denied') {
|
||||
alertDenied();
|
||||
return;
|
||||
}
|
||||
if (authority === CALENDAR_AUTHORITY && entry.reminderOwner == null) {
|
||||
// Bulwark reminds nobody while its event notifications are off: the
|
||||
// calendar app does, without asking.
|
||||
const owner = calendarReminders ? await askReminderOwner(t) : 'device';
|
||||
if (!owner) return;
|
||||
await changeReminderOwner(account.id, owner);
|
||||
}
|
||||
const outcome = await enableDeviceSync(account.id, authority);
|
||||
if (outcome.kind === 'permission') alertDenied();
|
||||
else if (outcome.kind === 'failed') alertFailed(outcome.message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
void refresh();
|
||||
}
|
||||
};
|
||||
|
||||
const startTurnOn = async () => {
|
||||
// Explain first, unless access was granted before (another account).
|
||||
if (await hasSyncPermissions(authority)) void turnOn();
|
||||
else setPrompt({ kind: 'rationale' });
|
||||
};
|
||||
|
||||
const turnOff = async (force: boolean) => {
|
||||
setPrompt(null);
|
||||
setBusy('off');
|
||||
try {
|
||||
const result = await disableDeviceSync(account.id, authority, { force });
|
||||
if (result.done) return;
|
||||
if (!force) {
|
||||
setPrompt({ kind: 'pending', pending: result.pending });
|
||||
return;
|
||||
}
|
||||
Alert.alert(
|
||||
t('settings.device_sync.turn_off_failed_title', 'Sync could not be turned off'),
|
||||
t(
|
||||
'settings.device_sync.turn_off_failed',
|
||||
'The synced data could not be removed from this device. Sync is paused; try again later.',
|
||||
),
|
||||
[{ text: t('settings.device_sync.ok', 'OK') }],
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
void refresh();
|
||||
}
|
||||
};
|
||||
|
||||
const keepSyncing = async () => {
|
||||
setPrompt(null);
|
||||
await resumeDeviceSync(account.id, authority);
|
||||
void refresh();
|
||||
};
|
||||
|
||||
const handleSyncNow = async () => {
|
||||
setBusy('sync');
|
||||
try {
|
||||
await syncNow(account.id, authority);
|
||||
} catch (err) {
|
||||
alertFailed(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
void refresh();
|
||||
}
|
||||
};
|
||||
|
||||
const grantAccess = async () => {
|
||||
const permission = await requestSyncPermissions(authority);
|
||||
if (permission === 'blocked') setPrompt({ kind: 'blocked' });
|
||||
else if (permission === 'denied') alertDenied();
|
||||
else if (checked) await syncNow(account.id, authority).catch(() => false);
|
||||
void refresh();
|
||||
};
|
||||
|
||||
const reviewDeletions = () => {
|
||||
const resolve = (choice: 'delete' | 'restore') => {
|
||||
void resolveDeletions(account.id, authority, choice)
|
||||
.catch((err: unknown) => alertFailed(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => { void refresh(); });
|
||||
};
|
||||
Alert.alert(
|
||||
t('settings.device_sync.deletions_title', 'Delete them on the server too?'),
|
||||
t(
|
||||
'settings.device_sync.deletions_message',
|
||||
'Many items were deleted on this device at once, so they were not deleted on the server. Delete them there as well, or bring them back on this device.',
|
||||
),
|
||||
[
|
||||
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
|
||||
{ text: t('settings.device_sync.deletions_restore', 'Bring them back'), onPress: () => resolve('restore') },
|
||||
{ text: t('settings.device_sync.deletions_delete', 'Delete on server'), style: 'destructive', onPress: () => resolve('delete') },
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const runAction = (action: StatusAction) => {
|
||||
if (action === 'signIn') navigation.navigate('AddAccount');
|
||||
else if (action === 'grantAccess') void grantAccess();
|
||||
else reviewDeletions();
|
||||
};
|
||||
|
||||
const closeChooser = (changed: boolean) => {
|
||||
setChooserOpen(false);
|
||||
// A new selection is loaded (or dropped) by the next sync: run it now.
|
||||
// (A sync Android paused would only report that it is off.)
|
||||
if (changed && checked) void syncNow(account.id, authority).catch(() => false);
|
||||
};
|
||||
|
||||
const intervalOptions = [
|
||||
{ value: '900', label: t('settings.device_sync.interval_15m', 'Every 15 minutes') },
|
||||
{ value: '1800', label: t('settings.device_sync.interval_30m', 'Every 30 minutes') },
|
||||
{ value: '3600', label: t('settings.device_sync.interval_1h', 'Every hour') },
|
||||
{ value: '21600', label: t('settings.device_sync.interval_6h', 'Every 6 hours') },
|
||||
{ value: '0', label: t('settings.device_sync.interval_manual', 'No regular sync') },
|
||||
];
|
||||
|
||||
const writableSelected = (collections ?? []).filter((col) => !col.readOnly && selectedKeys?.includes(col.key));
|
||||
const newContactsOptions = [
|
||||
{ value: '', label: t('settings.device_sync.new_contacts_default', 'Default address book') },
|
||||
...writableSelected.map((col) => ({
|
||||
value: col.key,
|
||||
label: col.isPersonal ? col.name : `${col.name} (${col.accountName})`,
|
||||
})),
|
||||
];
|
||||
const newContactsValue = entry.newContactsAddressBook && newContactsOptions.some((o) => o.value === entry.newContactsAddressBook)
|
||||
? entry.newContactsAddressBook
|
||||
: '';
|
||||
|
||||
const collectionsDescription = collections && selectedKeys
|
||||
? t('settings.device_sync.collections_count', '{selected, plural, one {# of {total} syncs} other {# of {total} sync}}', {
|
||||
selected: selectedKeys.length,
|
||||
total: collections.length,
|
||||
})
|
||||
: isContacts
|
||||
? t('settings.device_sync.books_default', 'Your own address books sync; shared ones only when you pick them.')
|
||||
: t('settings.device_sync.calendars_default', 'Your own calendars sync; shared ones only when you pick them.');
|
||||
|
||||
const statusStyle = status.tone === 'error' ? styles.statusError : status.tone === 'warning' ? styles.statusWarning : styles.statusMuted;
|
||||
const busyText = busy === 'on'
|
||||
? t('settings.device_sync.turning_on', 'Setting up…')
|
||||
: busy === 'off'
|
||||
? t('settings.device_sync.turning_off', 'Uploading changes, then removing the data from this device…')
|
||||
: null;
|
||||
|
||||
return (
|
||||
<View style={styles.block}>
|
||||
<SettingItem label={accountLabel} description={busyText ?? undefined} noBorder>
|
||||
<View style={styles.row}>
|
||||
{(busy === 'on' || busy === 'off') && <ActivityIndicator size="small" color={c.primary} />}
|
||||
<ToggleSwitch
|
||||
checked={checked}
|
||||
onChange={(next) => {
|
||||
if (next) void startTurnOn();
|
||||
else void turnOff(false);
|
||||
}}
|
||||
disabled={busy !== null}
|
||||
accessibilityLabel={t('settings.device_sync.switch_label', 'Sync {account} to this device', { account: accountLabel })}
|
||||
/>
|
||||
</View>
|
||||
</SettingItem>
|
||||
|
||||
{!busyText && (
|
||||
<View style={styles.statusRow} accessibilityRole={status.tone === 'error' ? 'alert' : undefined}>
|
||||
{status.tone === 'error' && <XCircle size={14} color={c.error} />}
|
||||
{status.tone === 'warning' && <AlertTriangle size={14} color={c.warning} />}
|
||||
<Text style={[styles.statusText, statusStyle]}>{status.text}</Text>
|
||||
</View>
|
||||
)}
|
||||
{!busyText && status.action && (
|
||||
<View style={styles.actions}>
|
||||
<Button variant="outline" size="sm" onPress={() => runAction(status.action!)}>
|
||||
{status.action === 'signIn'
|
||||
? t('settings.device_sync.sign_in_again', 'Sign in again')
|
||||
: status.action === 'grantAccess'
|
||||
? t('settings.device_sync.grant_access', 'Grant access')
|
||||
: t('settings.device_sync.review_deletions', 'Review deletions')}
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
{showDetails && view && !view.masterAutomatic && (
|
||||
<Text style={[styles.statusText, styles.statusWarning]}>
|
||||
{t(
|
||||
'settings.device_sync.master_off',
|
||||
'Auto-sync is off on this device, so syncs only run when you tap Sync now.',
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{showDetails && (
|
||||
<View style={styles.details}>
|
||||
<SettingItem
|
||||
label={isContacts
|
||||
? t('settings.device_sync.books_label', 'Address books')
|
||||
: t('settings.device_sync.calendars_label', 'Calendars')}
|
||||
description={collectionsDescription}
|
||||
>
|
||||
<Button variant="outline" size="sm" onPress={() => setChooserOpen(true)}>
|
||||
{t('settings.device_sync.choose', 'Choose')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
|
||||
{isContacts ? (
|
||||
<SettingItem
|
||||
label={t('settings.device_sync.new_contacts_label', 'New contacts go to')}
|
||||
description={t(
|
||||
'settings.device_sync.new_contacts_desc',
|
||||
'The address book for contacts created in other apps on this device.',
|
||||
)}
|
||||
>
|
||||
<Select
|
||||
value={newContactsValue}
|
||||
onChange={(v) => useDeviceSyncStore.getState().setNewContactsAddressBook(account.id, v || null)}
|
||||
options={newContactsOptions}
|
||||
/>
|
||||
</SettingItem>
|
||||
) : (
|
||||
<SettingItem
|
||||
label={t('settings.device_sync.reminders_label', 'Reminders')}
|
||||
description={entry.reminderOwner === 'bulwark' && !calendarReminders
|
||||
? t(
|
||||
'settings.device_sync.reminders_nobody',
|
||||
"Bulwark's event notifications are off, so nothing reminds you of synced events.",
|
||||
)
|
||||
: t(
|
||||
'settings.device_sync.reminders_desc',
|
||||
'Who reminds you of synced events. The other stays quiet, so you are not reminded twice.',
|
||||
)}
|
||||
>
|
||||
<RadioGroup
|
||||
value={entry.reminderOwner ?? 'device'}
|
||||
onChange={(v) => { void changeReminderOwner(account.id, v === 'bulwark' ? 'bulwark' : 'device'); }}
|
||||
options={[
|
||||
{ value: 'device', label: t('settings.device_sync.reminder_owner_device', 'Calendar app') },
|
||||
{ value: 'bulwark', label: t('settings.device_sync.reminder_owner_bulwark', 'Bulwark') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
<SettingItem
|
||||
label={t('settings.device_sync.interval_label', 'Sync interval')}
|
||||
description={t(
|
||||
'settings.device_sync.interval_desc',
|
||||
'How often Android syncs on its own. Edits in Bulwark and pushed changes sync sooner.',
|
||||
)}
|
||||
noBorder
|
||||
>
|
||||
<Select
|
||||
value={String(intervalFor(entry, authority))}
|
||||
onChange={(v) => { void changeSyncInterval(account.id, authority, Number(v)); }}
|
||||
options={intervalOptions}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={() => { void handleSyncNow(); }}
|
||||
disabled={busy !== null || !checked}
|
||||
loading={busy === 'sync'}
|
||||
icon={<RefreshCw size={14} color={c.text} />}
|
||||
>
|
||||
{t('settings.device_sync.sync_now', 'Sync now')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={() => { void openAndroidAccountSettings(account.id); }}
|
||||
disabled={!view?.exists}
|
||||
icon={<SettingsIcon size={14} color={c.text} />}
|
||||
>
|
||||
{t('settings.device_sync.android_settings', 'Android account settings')}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<DeviceSyncCollectionsSheet
|
||||
visible={chooserOpen}
|
||||
onClose={closeChooser}
|
||||
registryId={account.id}
|
||||
authority={authority}
|
||||
accountLabel={accountLabel}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
visible={prompt?.kind === 'rationale'}
|
||||
title={isContacts
|
||||
? t('settings.device_sync.rationale_title_contacts', 'Allow access to contacts')
|
||||
: t('settings.device_sync.rationale_title_calendar', 'Allow access to calendars')}
|
||||
message={isContacts
|
||||
? t(
|
||||
'settings.device_sync.rationale_contacts',
|
||||
'To show your address books in the Contacts app, Bulwark needs access to the contacts on this device. It only changes the contacts of its own account.',
|
||||
)
|
||||
: t(
|
||||
'settings.device_sync.rationale_calendar',
|
||||
'To show your calendars in the Calendar app, Bulwark needs access to the calendars on this device. It only changes the calendars of its own account.',
|
||||
)}
|
||||
confirmText={t('settings.device_sync.continue', 'Continue')}
|
||||
onConfirm={() => { void turnOn(); }}
|
||||
onCancel={() => setPrompt(null)}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
visible={prompt?.kind === 'blocked'}
|
||||
title={t('settings.device_sync.blocked_title', 'Access is turned off')}
|
||||
message={isContacts
|
||||
? t(
|
||||
'settings.device_sync.blocked_contacts',
|
||||
"Android no longer asks for access to contacts. Allow it in the app's settings, then turn sync on again.",
|
||||
)
|
||||
: t(
|
||||
'settings.device_sync.blocked_calendar',
|
||||
"Android no longer asks for access to calendars. Allow it in the app's settings, then turn sync on again.",
|
||||
)}
|
||||
confirmText={t('settings.device_sync.open_app_settings', 'Open app settings')}
|
||||
onConfirm={() => {
|
||||
setPrompt(null);
|
||||
void openAppSystemSettings();
|
||||
}}
|
||||
onCancel={() => setPrompt(null)}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
visible={prompt?.kind === 'pending'}
|
||||
variant="destructive"
|
||||
title={t('settings.device_sync.turn_off_title', 'Turn off anyway?')}
|
||||
message={prompt?.kind === 'pending' && prompt.pending > 0
|
||||
? t(
|
||||
'settings.device_sync.turn_off_pending',
|
||||
"{count, plural, one {# change made on this device hasn't reached the server yet. It is lost if you turn sync off now.} other {# changes made on this device haven't reached the server yet. They are lost if you turn sync off now.}}",
|
||||
{ count: prompt.pending },
|
||||
)
|
||||
: t(
|
||||
'settings.device_sync.turn_off_unknown',
|
||||
'Bulwark could not check whether changes made on this device reached the server. They are lost if you turn sync off now.',
|
||||
)}
|
||||
confirmText={t('settings.device_sync.turn_off_anyway', 'Turn off')}
|
||||
cancelText={t('settings.device_sync.keep_syncing', 'Keep syncing')}
|
||||
onConfirm={() => { void turnOff(true); }}
|
||||
onCancel={() => { void keepSyncing(); }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
block: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
paddingBottom: spacing.md,
|
||||
},
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||
statusRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 6 },
|
||||
statusText: { ...typography.caption, flexShrink: 1 },
|
||||
statusMuted: { color: c.mutedForeground },
|
||||
statusWarning: { color: c.warning },
|
||||
statusError: { color: c.error },
|
||||
details: {
|
||||
marginTop: spacing.sm,
|
||||
marginStart: spacing.sm,
|
||||
paddingStart: spacing.md,
|
||||
borderStartWidth: 2,
|
||||
borderStartColor: c.muted,
|
||||
},
|
||||
actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginTop: spacing.sm },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
// Which address books or calendars of one account sync to this device
|
||||
// (docs/device-sync.md, "Settings sections"). The account's own collections
|
||||
// are on unless turned off, shared ones off unless turned on; read-only ones
|
||||
// are marked, and a collection another signed-in account already syncs gets
|
||||
// a warning (it would show twice on the phone).
|
||||
import React from 'react';
|
||||
import {
|
||||
ActivityIndicator, Animated, Easing, Modal, Pressable, ScrollView, StyleSheet, Text, View,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { AlertTriangle, Check, X } from 'lucide-react-native';
|
||||
import Button from '../../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../../theme/tokens';
|
||||
import { useColors } from '../../../theme/colors';
|
||||
import { useSheetDrag } from '../../../lib/use-sheet-drag';
|
||||
import { useLocaleStore } from '../../../stores/locale-store';
|
||||
import { useAccountStore } from '../../../stores/account-store';
|
||||
import {
|
||||
isCollectionSelected,
|
||||
selectionFor,
|
||||
useDeviceSyncStore,
|
||||
emptyAccountDeviceSync,
|
||||
} from '../../../stores/device-sync-store';
|
||||
import { otherAccountsSyncing, type SyncCollection } from '../../../device-sync/app/collections';
|
||||
import { CONTACTS_AUTHORITY, type Authority } from '../../../device-sync/types';
|
||||
import { useSyncCollections } from './use-sync-collections';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
/** `changed`: the selection was edited while the sheet was open. */
|
||||
onClose: (changed: boolean) => void;
|
||||
registryId: string;
|
||||
authority: Authority;
|
||||
/** The account's address, under the title. */
|
||||
accountLabel: string;
|
||||
}
|
||||
|
||||
export function DeviceSyncCollectionsSheet({ visible, onClose, registryId, authority, accountLabel }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { state, reload } = useSyncCollections(registryId, authority, visible);
|
||||
const entry = useDeviceSyncStore((s) => s.accounts[registryId]);
|
||||
const allEntries = useDeviceSyncStore((s) => s.accounts);
|
||||
const setCollectionSelected = useDeviceSyncStore((s) => s.setCollectionSelected);
|
||||
const registry = useAccountStore((s) => s.accounts);
|
||||
const changed = React.useRef(false);
|
||||
|
||||
const slideY = React.useRef(new Animated.Value(600)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
const close = React.useCallback(() => {
|
||||
const wasChanged = changed.current;
|
||||
changed.current = false;
|
||||
onClose(wasChanged);
|
||||
}, [onClose]);
|
||||
const dragHandlers = useSheetDrag({ slideY, closedY: 600, onClose: close });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
changed.current = false;
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 600, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, slideY, overlayOpacity]);
|
||||
|
||||
const selection = selectionFor(entry ?? emptyAccountDeviceSync(), authority);
|
||||
const collections = state.kind === 'loaded' || state.kind === 'loading' ? state.collections ?? [] : [];
|
||||
const groups = React.useMemo(() => {
|
||||
const out: Array<{ jmapAccountId: string; isPersonal: boolean; accountName: string; items: SyncCollection[] }> = [];
|
||||
for (const collection of collections) {
|
||||
let group = out.find((g) => g.jmapAccountId === collection.jmapAccountId);
|
||||
if (!group) {
|
||||
group = {
|
||||
jmapAccountId: collection.jmapAccountId,
|
||||
isPersonal: collection.isPersonal,
|
||||
accountName: collection.accountName,
|
||||
items: [],
|
||||
};
|
||||
out.push(group);
|
||||
}
|
||||
group.items.push(collection);
|
||||
}
|
||||
return out;
|
||||
}, [collections]);
|
||||
|
||||
const toggle = (collection: SyncCollection) => {
|
||||
const on = isCollectionSelected(selection, authority, collection);
|
||||
setCollectionSelected(registryId, authority, collection.key, !on);
|
||||
changed.current = true;
|
||||
};
|
||||
|
||||
const errorText = state.kind !== 'error'
|
||||
? ''
|
||||
: state.error === 'signedOut'
|
||||
? t('settings.device_sync.list_error_signed_out', 'This account is not signed in on this device.')
|
||||
: state.error === 'auth'
|
||||
? t('settings.device_sync.list_error_auth', 'The server did not accept the sign-in.')
|
||||
: state.error === 'network'
|
||||
? t('settings.device_sync.list_error_network', 'Could not reach the server.')
|
||||
: t('settings.device_sync.list_error_server', 'The server could not list them.');
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={close} statusBarTranslucent>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={{ flex: 1 }} onPress={close} />
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheet,
|
||||
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
||||
]}
|
||||
>
|
||||
<View {...dragHandlers}>
|
||||
<View style={styles.handleHit}>
|
||||
<View style={styles.handle} />
|
||||
</View>
|
||||
<View style={styles.header}>
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={styles.title}>
|
||||
{authority === CONTACTS_AUTHORITY
|
||||
? t('settings.device_sync.choose_books_title', 'Address books to sync')
|
||||
: t('settings.device_sync.choose_calendars_title', 'Calendars to sync')}
|
||||
</Text>
|
||||
<Text style={styles.subtitle} numberOfLines={1}>{accountLabel}</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={close}
|
||||
hitSlop={8}
|
||||
style={styles.close}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('common.close', 'Close')}
|
||||
>
|
||||
<X size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.body}>
|
||||
{state.kind === 'error' && (
|
||||
<View style={styles.message} accessibilityRole="alert">
|
||||
<Text style={styles.errorText}>{errorText}</Text>
|
||||
<Button variant="outline" size="sm" onPress={reload}>
|
||||
{t('common.retry', 'Retry')}
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
{(state.kind === 'loading' || state.kind === 'idle') && collections.length === 0 && (
|
||||
<View style={styles.message}>
|
||||
<ActivityIndicator size="small" color={c.primary} />
|
||||
</View>
|
||||
)}
|
||||
{state.kind === 'loaded' && collections.length === 0 && (
|
||||
<View style={styles.message}>
|
||||
<Text style={styles.muted}>
|
||||
{authority === CONTACTS_AUTHORITY
|
||||
? t('settings.device_sync.no_books', 'This account has no address books.')
|
||||
: t('settings.device_sync.no_calendars', 'This account has no calendars to sync.')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{groups.map((group) => (
|
||||
<View key={group.jmapAccountId} style={styles.group}>
|
||||
<Text style={styles.groupTitle}>
|
||||
{group.isPersonal
|
||||
? t('settings.device_sync.own_collections', 'Your own')
|
||||
: t('settings.device_sync.shared_by', 'Shared by {name}', { name: group.accountName })}
|
||||
</Text>
|
||||
{group.items.map((collection) => {
|
||||
const on = isCollectionSelected(selection, authority, collection);
|
||||
const others = on ? otherAccountsSyncing(registryId, authority, collection, registry, allEntries) : [];
|
||||
const tint = collection.color ?? c.primary;
|
||||
return (
|
||||
<Pressable
|
||||
key={collection.key}
|
||||
onPress={() => toggle(collection)}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityState={{ checked: on }}
|
||||
accessibilityLabel={collection.name}
|
||||
>
|
||||
<View style={[styles.box, { borderColor: tint, backgroundColor: on ? tint : 'transparent' }]}>
|
||||
{on && <Check size={12} color={c.textInverse} />}
|
||||
</View>
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text style={[styles.rowName, !on && styles.rowNameOff]} numberOfLines={1}>
|
||||
{collection.name}
|
||||
</Text>
|
||||
{(collection.readOnly || collection.isDefault) && (
|
||||
<Text style={styles.muted}>
|
||||
{[
|
||||
collection.isDefault ? t('contacts.address_books.default', 'Default') : null,
|
||||
collection.readOnly ? t('settings.device_sync.read_only', 'Read-only') : null,
|
||||
].filter(Boolean).join(' · ')}
|
||||
</Text>
|
||||
)}
|
||||
{others.length > 0 && (
|
||||
<View style={styles.warningRow}>
|
||||
<AlertTriangle size={12} color={c.warning} />
|
||||
<Text style={styles.warningText}>
|
||||
{t(
|
||||
'settings.device_sync.also_synced_by',
|
||||
'Also synced for {accounts}, so it shows twice on this device.',
|
||||
{ accounts: others.join(', ') },
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
|
||||
<Text style={styles.hint}>
|
||||
{authority === CONTACTS_AUTHORITY
|
||||
? t(
|
||||
'settings.device_sync.books_hint',
|
||||
'Your own address books sync unless you turn them off; shared ones only when you turn them on.',
|
||||
)
|
||||
: t(
|
||||
'settings.device_sync.calendars_hint',
|
||||
'Your own calendars sync unless you turn them off; shared ones only when you turn them on. Task lists stay in Bulwark.',
|
||||
)}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
maxHeight: '80%',
|
||||
backgroundColor: c.popover,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderTopWidth: 1,
|
||||
borderColor: c.border,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
handleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm },
|
||||
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.border },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
title: { ...typography.bodySemibold, color: c.text },
|
||||
subtitle: { ...typography.caption, color: c.mutedForeground },
|
||||
close: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.xs },
|
||||
body: { paddingBottom: spacing.md },
|
||||
message: { alignItems: 'center', gap: spacing.sm, padding: spacing.lg },
|
||||
errorText: { ...typography.caption, color: c.error, textAlign: 'center' },
|
||||
muted: { ...typography.caption, color: c.mutedForeground },
|
||||
group: { paddingTop: spacing.sm },
|
||||
groupTitle: {
|
||||
...typography.captionMedium,
|
||||
color: c.mutedForeground,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
minHeight: 44,
|
||||
},
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
box: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: radius.xs,
|
||||
borderWidth: 2,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
rowName: { ...typography.body, color: c.text },
|
||||
rowNameOff: { color: c.textSecondary },
|
||||
warningRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 4, marginTop: 2 },
|
||||
warningText: { ...typography.caption, color: c.warning, flex: 1 },
|
||||
hint: {
|
||||
...typography.caption,
|
||||
color: c.mutedForeground,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// "Sync to this device" in Settings → Contacts and Settings → Calendar
|
||||
// (Android only, #34; docs/device-sync.md "Settings sections"): one block per
|
||||
// signed-in account, then the accounts the app signed out while they synced
|
||||
// (their data stays until the user signs in again or removes it). The panes
|
||||
// pass the title and description, so the settings search indexes them where
|
||||
// they are rendered.
|
||||
import React from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { SettingsSection, SettingItem } from '../settings-section';
|
||||
import Button from '../../Button';
|
||||
import Dialog from '../../Dialog';
|
||||
import { spacing, type ThemePalette } from '../../../theme/tokens';
|
||||
import { useColors } from '../../../theme/colors';
|
||||
import { useAccountStore } from '../../../stores/account-store';
|
||||
import { useDeviceSyncStore } from '../../../stores/device-sync-store';
|
||||
import { useLocaleStore } from '../../../stores/locale-store';
|
||||
import { removeSuspendedAccount } from '../../../device-sync/app/lifecycle';
|
||||
import type { Authority } from '../../../device-sync/types';
|
||||
import type { RootStackParamList } from '../../../navigation/types';
|
||||
import { DeviceSyncAccountRow } from './DeviceSyncAccountRow';
|
||||
|
||||
interface Props {
|
||||
authority: Authority;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function DeviceSyncSection({ authority, title, description }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const syncEntries = useDeviceSyncStore((s) => s.accounts);
|
||||
const suspended = React.useMemo(
|
||||
() => Object.keys(syncEntries)
|
||||
.filter((id) => syncEntries[id].suspended && !accounts.some((a) => a.id === id))
|
||||
.sort(),
|
||||
[syncEntries, accounts],
|
||||
);
|
||||
if (accounts.length === 0 && suspended.length === 0) return null;
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<SettingsSection title={title} description={description}>
|
||||
{accounts.map((account) => (
|
||||
<DeviceSyncAccountRow key={account.id} account={account} authority={authority} />
|
||||
))}
|
||||
{suspended.map((registryId) => (
|
||||
<SuspendedAccountRow key={registryId} registryId={registryId} styles={styles} />
|
||||
))}
|
||||
</SettingsSection>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// An account the app signed out (its sign-in failed) while it synced: sync is
|
||||
// off, the data stays. Signing in again resumes it; removing drops it.
|
||||
function SuspendedAccountRow({ registryId, styles }: { registryId: string; styles: ReturnType<typeof makeStyles> }) {
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const [confirming, setConfirming] = React.useState(false);
|
||||
const [removing, setRemoving] = React.useState(false);
|
||||
|
||||
const remove = async () => {
|
||||
setConfirming(false);
|
||||
setRemoving(true);
|
||||
try {
|
||||
await removeSuspendedAccount(registryId);
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.block}>
|
||||
<SettingItem
|
||||
label={registryId}
|
||||
description={t(
|
||||
'settings.device_sync.suspended_desc',
|
||||
'Signed out. Its contacts and calendars stay on this device, with the changes made here, and sync again once you sign in.',
|
||||
)}
|
||||
noBorder
|
||||
/>
|
||||
<View style={styles.actions}>
|
||||
<Button variant="outline" size="sm" onPress={() => navigation.navigate('AddAccount')}>
|
||||
{t('settings.device_sync.sign_in_again', 'Sign in again')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onPress={() => setConfirming(true)} loading={removing} disabled={removing}>
|
||||
{t('settings.device_sync.remove_from_device', 'Remove from this device')}
|
||||
</Button>
|
||||
</View>
|
||||
<Dialog
|
||||
visible={confirming}
|
||||
variant="destructive"
|
||||
title={t('settings.device_sync.remove_title', 'Remove from this device?')}
|
||||
message={t(
|
||||
'settings.device_sync.remove_message',
|
||||
"The account's contacts and calendars are removed from this device. Changes made here that haven't reached the server are lost.",
|
||||
)}
|
||||
confirmText={t('settings.device_sync.remove', 'Remove')}
|
||||
onConfirm={() => { void remove(); }}
|
||||
onCancel={() => setConfirming(false)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
section: { marginTop: spacing.xxxl },
|
||||
block: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
paddingBottom: spacing.md,
|
||||
},
|
||||
actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { translate, type MessageParams } from '../../../../i18n';
|
||||
import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY, type RunStatus } from '../../../../device-sync/types';
|
||||
import { describeSyncStatus, relativeTime, type StatusInput } from '../status';
|
||||
|
||||
const t = (key: string, fallback?: string, params?: MessageParams) => translate('en', key, fallback, params);
|
||||
|
||||
const NOW = Date.UTC(2026, 8, 27, 12, 0, 0);
|
||||
|
||||
const run = (outcome: RunStatus['outcome'], extra: Partial<RunStatus> = {}): RunStatus => ({
|
||||
at: NOW - 5 * 60_000,
|
||||
outcome,
|
||||
durationMs: 100,
|
||||
conflicts: 0,
|
||||
itemErrors: 0,
|
||||
stats: {
|
||||
downloaded: { created: 0, updated: 0, deleted: 0 },
|
||||
uploaded: { created: 0, updated: 0, deleted: 0 },
|
||||
entries: 0,
|
||||
skipped: 0,
|
||||
},
|
||||
...extra,
|
||||
});
|
||||
|
||||
const base: StatusInput = {
|
||||
authority: CONTACTS_AUTHORITY,
|
||||
on: true,
|
||||
paused: false,
|
||||
removed: false,
|
||||
permitted: true,
|
||||
syncing: false,
|
||||
now: NOW,
|
||||
};
|
||||
|
||||
describe('describeSyncStatus', () => {
|
||||
it('says off, or that the account was removed in Android settings', () => {
|
||||
expect(describeSyncStatus({ ...base, on: false }, t)).toEqual({ text: 'Off', tone: 'muted' });
|
||||
expect(describeSyncStatus({ ...base, on: false, removed: true }, t).tone).toBe('warning');
|
||||
});
|
||||
|
||||
it('puts a revoked permission first, with a way to grant it again', () => {
|
||||
const line = describeSyncStatus({ ...base, permitted: false, lastRun: run('ok') }, t);
|
||||
expect(line).toMatchObject({ tone: 'error', action: 'grantAccess' });
|
||||
expect(line.text).toContain('contacts');
|
||||
expect(describeSyncStatus({ ...base, authority: CALENDAR_AUTHORITY, permitted: false }, t).text).toContain('calendars');
|
||||
});
|
||||
|
||||
it('says when Android paused the account', () => {
|
||||
expect(describeSyncStatus({ ...base, on: false, paused: true }, t))
|
||||
.toEqual({ text: 'Paused in Android settings', tone: 'warning' });
|
||||
});
|
||||
|
||||
it('shows a running sync, then when it last synced', () => {
|
||||
expect(describeSyncStatus({ ...base, syncing: true, lastRun: run('ok') }, t).text).toBe('Syncing…');
|
||||
expect(describeSyncStatus({ ...base, lastRun: run('ok') }, t)).toEqual({ text: 'Synced 5 min ago', tone: 'muted' });
|
||||
expect(describeSyncStatus({ ...base, lastRun: run('ok', { itemErrors: 2 }) }, t)).toEqual({
|
||||
text: 'Synced 5 min ago · 2 items could not be synced.',
|
||||
tone: 'warning',
|
||||
});
|
||||
expect(describeSyncStatus(base, t).text).toBe('Waiting for the first sync');
|
||||
});
|
||||
|
||||
it('offers what fixes a failed run', () => {
|
||||
expect(describeSyncStatus({ ...base, lastRun: run('auth') }, t)).toMatchObject({ tone: 'error', action: 'signIn' });
|
||||
expect(describeSyncStatus({ ...base, lastRun: run('tooManyDeletions') }, t)).toMatchObject({ action: 'reviewDeletions' });
|
||||
expect(describeSyncStatus({ ...base, lastRun: run('permission') }, t)).toMatchObject({ action: 'grantAccess' });
|
||||
expect(describeSyncStatus({ ...base, lastRun: run('io') }, t).text).toBe('Could not reach the server 5 min ago. Android tries again.');
|
||||
expect(describeSyncStatus({ ...base, lastRun: run('internal', { message: 'Boom' }) }, t).text)
|
||||
.toBe('The last sync failed: Boom');
|
||||
// A run that found sync off says nothing new.
|
||||
expect(describeSyncStatus({ ...base, lastRun: run('disabled') }, t).text).toBe('Waiting for the next sync');
|
||||
});
|
||||
});
|
||||
|
||||
describe('relativeTime', () => {
|
||||
it('counts minutes, hours and days', () => {
|
||||
expect(relativeTime(NOW - 10_000, NOW, t)).toBe('just now');
|
||||
expect(relativeTime(NOW - 3 * 3600_000, NOW, t)).toBe('3 h ago');
|
||||
expect(relativeTime(NOW - 2 * 86_400_000, NOW, t)).toBe('2 d ago');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
// The status line of one account's device sync in Settings: off, paused in
|
||||
// Android settings, syncing, when it last synced, or what went wrong and what
|
||||
// the user can do about it (docs/device-sync.md, "Settings sections",
|
||||
// "Failure matrix").
|
||||
import { CONTACTS_AUTHORITY, type Authority, type RunStatus } from '../../../device-sync/types';
|
||||
import type { TranslateFn } from '../../../stores/locale-store';
|
||||
|
||||
/** What the status line offers to do. */
|
||||
export type StatusAction = 'signIn' | 'grantAccess' | 'reviewDeletions';
|
||||
|
||||
export interface StatusLine {
|
||||
text: string;
|
||||
tone: 'muted' | 'warning' | 'error';
|
||||
action?: StatusAction;
|
||||
}
|
||||
|
||||
export interface StatusInput {
|
||||
authority: Authority;
|
||||
/** The switch: Android syncs this authority automatically. */
|
||||
on: boolean;
|
||||
/** On in the app but off in Android's settings for the account. */
|
||||
paused: boolean;
|
||||
/** The Android account was removed in Android Settings. */
|
||||
removed: boolean;
|
||||
/** The runtime permissions; null while unknown. */
|
||||
permitted: boolean | null;
|
||||
syncing: boolean;
|
||||
lastRun?: RunStatus;
|
||||
now: number;
|
||||
}
|
||||
|
||||
/** "just now", "5 min ago", … */
|
||||
export function relativeTime(at: number, now: number, t: TranslateFn): string {
|
||||
const diff = Math.max(0, now - at);
|
||||
if (diff < 60_000) return t('settings.offline.just_now', 'just now');
|
||||
if (diff < 60 * 60_000) return t('settings.offline.minutes_ago', '{count} min ago', { count: Math.round(diff / 60_000) });
|
||||
if (diff < 24 * 60 * 60_000) {
|
||||
return t('settings.offline.hours_ago', '{count} h ago', { count: Math.round(diff / (60 * 60_000)) });
|
||||
}
|
||||
return t('settings.offline.days_ago', '{count} d ago', { count: Math.round(diff / (24 * 60 * 60_000)) });
|
||||
}
|
||||
|
||||
function permissionLine(authority: Authority, t: TranslateFn): StatusLine {
|
||||
return {
|
||||
text: authority === CONTACTS_AUTHORITY
|
||||
? t('settings.device_sync.status_permission_contacts', 'Access to contacts was turned off, so nothing syncs.')
|
||||
: t('settings.device_sync.status_permission_calendar', 'Access to calendars was turned off, so nothing syncs.'),
|
||||
tone: 'error',
|
||||
action: 'grantAccess',
|
||||
};
|
||||
}
|
||||
|
||||
function runLine(run: RunStatus, input: StatusInput, t: TranslateFn): StatusLine | null {
|
||||
const when = relativeTime(run.at, input.now, t);
|
||||
switch (run.outcome) {
|
||||
case 'ok': {
|
||||
const synced = t('settings.device_sync.status_synced', 'Synced {when}', { when });
|
||||
if (run.itemErrors <= 0) return { text: synced, tone: 'muted' };
|
||||
const skipped = t(
|
||||
'settings.device_sync.status_item_errors',
|
||||
'{count, plural, one {# item could not be synced.} other {# items could not be synced.}}',
|
||||
{ count: run.itemErrors },
|
||||
);
|
||||
return { text: `${synced} · ${skipped}`, tone: 'warning' };
|
||||
}
|
||||
case 'auth':
|
||||
return {
|
||||
text: t('settings.device_sync.status_auth', 'The server no longer accepts the sign-in. Sign in again to keep syncing.'),
|
||||
tone: 'error',
|
||||
action: 'signIn',
|
||||
};
|
||||
case 'permission':
|
||||
return permissionLine(input.authority, t);
|
||||
case 'tooManyDeletions':
|
||||
return {
|
||||
text: t(
|
||||
'settings.device_sync.status_too_many_deletions',
|
||||
'Many items were deleted on this device. They are not deleted on the server until you confirm.',
|
||||
),
|
||||
tone: 'warning',
|
||||
action: 'reviewDeletions',
|
||||
};
|
||||
case 'io':
|
||||
return {
|
||||
text: t('settings.device_sync.status_io', 'Could not reach the server {when}. Android tries again.', { when }),
|
||||
tone: 'warning',
|
||||
};
|
||||
case 'unsupported':
|
||||
return {
|
||||
text: input.authority === CONTACTS_AUTHORITY
|
||||
? t('settings.device_sync.status_unsupported_contacts', 'The server offers no address books for this account.')
|
||||
: t('settings.device_sync.status_unsupported_calendar', 'The server offers no calendars for this account.'),
|
||||
tone: 'error',
|
||||
};
|
||||
case 'safetyAbort':
|
||||
return {
|
||||
text: t(
|
||||
'settings.device_sync.status_safety_abort',
|
||||
'Stopped: the server returned nothing while this device still holds items. Check the server, then sync again.',
|
||||
),
|
||||
tone: 'error',
|
||||
};
|
||||
case 'cancelled':
|
||||
return { text: t('settings.device_sync.status_cancelled', 'The last sync stopped early. It continues next time.'), tone: 'muted' };
|
||||
case 'internal':
|
||||
return {
|
||||
text: run.message
|
||||
? t('settings.device_sync.status_failed', 'The last sync failed: {message}', { message: run.message })
|
||||
: t('settings.device_sync.status_failed_plain', 'The last sync failed.'),
|
||||
tone: 'error',
|
||||
};
|
||||
case 'disabled':
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function describeSyncStatus(input: StatusInput, t: TranslateFn): StatusLine {
|
||||
if (!input.on && !input.paused) {
|
||||
return input.removed
|
||||
? {
|
||||
text: t('settings.device_sync.status_removed', 'Removed in Android settings. Turn on to add the account again.'),
|
||||
tone: 'warning',
|
||||
}
|
||||
: { text: t('settings.device_sync.status_off', 'Off'), tone: 'muted' };
|
||||
}
|
||||
if (input.permitted === false) return permissionLine(input.authority, t);
|
||||
if (input.paused) return { text: t('settings.device_sync.status_paused', 'Paused in Android settings'), tone: 'warning' };
|
||||
if (input.syncing) return { text: t('settings.device_sync.status_syncing', 'Syncing…'), tone: 'muted' };
|
||||
if (!input.lastRun) return { text: t('settings.device_sync.status_waiting', 'Waiting for the first sync'), tone: 'muted' };
|
||||
return runLine(input.lastRun, input, t)
|
||||
?? { text: t('settings.device_sync.status_waiting_next', 'Waiting for the next sync'), tone: 'muted' };
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Android's side of one account's device sync for the settings: whether the
|
||||
// Android account exists, its automatic sync for the authority (the switch),
|
||||
// the device's master auto-sync, whether a sync runs or waits, and whether
|
||||
// the runtime permissions are still granted. Re-read on mount, when the app
|
||||
// returns to the foreground, when accounts of our type change, and every few
|
||||
// seconds while a sync runs or waits.
|
||||
import React from 'react';
|
||||
import { AppState } from 'react-native';
|
||||
import { getSyncSettings, listAndroidAccounts, onAccountsChanged } from '../../../device-sync/native';
|
||||
import { hasSyncPermissions } from '../../../device-sync/app/permissions';
|
||||
import type { Authority } from '../../../device-sync/types';
|
||||
|
||||
export interface AndroidSyncView {
|
||||
/** The Android account exists. */
|
||||
exists: boolean;
|
||||
/** Automatic sync for the authority: the toggle in Settings → Accounts. */
|
||||
automatic: boolean;
|
||||
/** The device's "Auto-sync data". */
|
||||
masterAutomatic: boolean;
|
||||
active: boolean;
|
||||
pending: boolean;
|
||||
}
|
||||
|
||||
const POLL_MS = 3000;
|
||||
|
||||
export function useAndroidSync(accountName: string | undefined, authority: Authority): {
|
||||
view: AndroidSyncView | null;
|
||||
permitted: boolean | null;
|
||||
refresh: () => Promise<void>;
|
||||
} {
|
||||
const [view, setView] = React.useState<AndroidSyncView | null>(null);
|
||||
const [permitted, setPermitted] = React.useState<boolean | null>(null);
|
||||
const alive = React.useRef(true);
|
||||
React.useEffect(() => {
|
||||
alive.current = true;
|
||||
return () => { alive.current = false; };
|
||||
}, []);
|
||||
// Read at call time: turning sync on creates the account while the
|
||||
// caller still holds the refresh of the render before.
|
||||
const nameRef = React.useRef(accountName);
|
||||
nameRef.current = accountName;
|
||||
// Only the latest read may land; an older one resolving late is stale.
|
||||
const reads = React.useRef(0);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
const read = ++reads.current;
|
||||
const accountName = nameRef.current;
|
||||
const granted = await hasSyncPermissions(authority);
|
||||
let next: AndroidSyncView = { exists: false, automatic: false, masterAutomatic: true, active: false, pending: false };
|
||||
if (accountName) {
|
||||
try {
|
||||
const accounts = await listAndroidAccounts();
|
||||
if (accounts.some((a) => a.name === accountName)) {
|
||||
const settings = await getSyncSettings(accountName);
|
||||
const own = settings.authorities?.[authority];
|
||||
next = {
|
||||
exists: true,
|
||||
automatic: !!own?.automatic,
|
||||
masterAutomatic: settings.masterAutomatic !== false,
|
||||
active: !!own?.active,
|
||||
pending: !!own?.pending,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Shown as off until the next read.
|
||||
}
|
||||
}
|
||||
if (!alive.current || read !== reads.current) return;
|
||||
setPermitted(granted);
|
||||
setView(next);
|
||||
}, [authority]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh, accountName]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const subscription = AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') void refresh();
|
||||
});
|
||||
let unsubscribe: () => void = () => undefined;
|
||||
try {
|
||||
unsubscribe = onAccountsChanged(() => { void refresh(); });
|
||||
} catch {
|
||||
// Foreground reads still catch up.
|
||||
}
|
||||
return () => {
|
||||
subscription.remove();
|
||||
unsubscribe();
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const busy = !!view?.active || !!view?.pending;
|
||||
React.useEffect(() => {
|
||||
if (!busy) return undefined;
|
||||
const timer = setInterval(() => { void refresh(); }, POLL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [busy, refresh]);
|
||||
|
||||
return { view, permitted, refresh };
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// The address books or calendars of one account for the device sync settings,
|
||||
// listed through the account's own client (device-sync/app/collections) and
|
||||
// cached for a little while, so the chooser, the counts and the "new contacts
|
||||
// go to" picker share one request.
|
||||
import React from 'react';
|
||||
import {
|
||||
cachedSyncCollections,
|
||||
CollectionsError,
|
||||
listSyncCollections,
|
||||
onSyncCollectionsLoaded,
|
||||
type CollectionsErrorKind,
|
||||
type SyncCollection,
|
||||
} from '../../../device-sync/app/collections';
|
||||
import { CALENDAR_AUTHORITY, type Authority } from '../../../device-sync/types';
|
||||
import { useAuthStore } from '../../../stores/auth-store';
|
||||
import { useCalendarSubscriptionsStore } from '../../../stores/calendar-subscriptions-store';
|
||||
|
||||
export type CollectionsState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'loading'; collections: SyncCollection[] | null }
|
||||
| { kind: 'loaded'; collections: SyncCollection[] }
|
||||
| { kind: 'error'; error: CollectionsErrorKind };
|
||||
|
||||
export function useSyncCollections(registryId: string, authority: Authority, active: boolean): {
|
||||
state: CollectionsState;
|
||||
reload: () => void;
|
||||
} {
|
||||
const [state, setState] = React.useState<CollectionsState>(() => {
|
||||
const cached = cachedSyncCollections(registryId, authority);
|
||||
return cached ? { kind: 'loaded', collections: cached } : { kind: 'idle' };
|
||||
});
|
||||
const [attempt, setAttempt] = React.useState(0);
|
||||
// Calendars mirroring subscribed feeds are read-only on the device. Only
|
||||
// the active account's subscriptions are known here.
|
||||
const isActive = useAuthStore((s) => s.activeAccountId === registryId);
|
||||
const subscriptions = useCalendarSubscriptionsStore((s) => s.subscriptions);
|
||||
const feeds = React.useMemo(
|
||||
() => (authority === CALENDAR_AUTHORITY && isActive
|
||||
? subscriptions.map((sub) => ({ calendarId: sub.calendarId, accountId: sub.accountId }))
|
||||
: []),
|
||||
[authority, isActive, subscriptions],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) return undefined;
|
||||
let cancelled = false;
|
||||
const force = attempt > 0;
|
||||
setState((prev) => ({ kind: 'loading', collections: prev.kind === 'loaded' ? prev.collections : null }));
|
||||
listSyncCollections(registryId, authority, { force, feeds })
|
||||
.then((collections) => {
|
||||
if (!cancelled) setState({ kind: 'loaded', collections });
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
setState({ kind: 'error', error: err instanceof CollectionsError ? err.kind : 'server' });
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [registryId, authority, active, attempt, feeds]);
|
||||
|
||||
// The chooser loads its own, fresher list: the row's counts follow it.
|
||||
React.useEffect(
|
||||
() => onSyncCollectionsLoaded((id, auth, collections) => {
|
||||
if (id === registryId && auth === authority) setState({ kind: 'loaded', collections });
|
||||
}),
|
||||
[registryId, authority],
|
||||
);
|
||||
|
||||
const reload = React.useCallback(() => setAttempt((n) => n + 1), []);
|
||||
return { state, reload };
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
CollectionsError,
|
||||
forgetSyncCollections,
|
||||
listSyncCollections,
|
||||
onSyncCollectionsLoaded,
|
||||
otherAccountsSyncing,
|
||||
type CollectionsClient,
|
||||
} from '../../app/collections';
|
||||
import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY, JMAP_CALENDARS, JMAP_CONTACTS } from '../../types';
|
||||
import { useDeviceSyncStore, waitForDeviceSyncHydration } from '../../../stores/device-sync-store';
|
||||
import type { JMAPMethodCall } from '../../../api/types';
|
||||
|
||||
type Handler = (args: Record<string, any>, calls: JMAPMethodCall[]) => any;
|
||||
|
||||
// A client for one account with the personal JMAP account `c` and a shared
|
||||
// account `team`, answering the calls the listing makes.
|
||||
function fakeClient(handlers: Record<string, Handler>, options: { loaded?: boolean | Error; maxCalls?: number } = {}) {
|
||||
const requests: JMAPMethodCall[][] = [];
|
||||
const client: CollectionsClient = {
|
||||
async loadAccount() {
|
||||
if (options.loaded instanceof Error) throw options.loaded;
|
||||
return options.loaded ?? true;
|
||||
},
|
||||
get currentSession() {
|
||||
return {
|
||||
primaryAccounts: { [JMAP_CONTACTS]: 'c', [JMAP_CALENDARS]: 'c' },
|
||||
accounts: {
|
||||
c: { name: 'alice@example.org', isPersonal: true, accountCapabilities: { [JMAP_CONTACTS]: {}, [JMAP_CALENDARS]: {} } },
|
||||
team: { name: 'Team', isPersonal: false, accountCapabilities: { [JMAP_CONTACTS]: {}, [JMAP_CALENDARS]: {} } },
|
||||
mailonly: { name: 'Archive', isPersonal: false, accountCapabilities: {} },
|
||||
},
|
||||
};
|
||||
},
|
||||
async request(calls) {
|
||||
requests.push(calls);
|
||||
const responses: Array<[string, any, string]> = [];
|
||||
for (const [name, args, id] of calls) {
|
||||
const handler = handlers[`${name} ${args.accountId}`] ?? handlers[name];
|
||||
if (!handler) {
|
||||
responses.push(['error', { type: 'unknownMethod' }, id]);
|
||||
continue;
|
||||
}
|
||||
const body = handler(args, calls);
|
||||
responses.push(body === null ? ['error', { type: 'forbidden' }, id] : [name, body, id]);
|
||||
}
|
||||
return { methodResponses: responses };
|
||||
},
|
||||
getMaxCallsInRequest: () => options.maxCalls ?? 16,
|
||||
};
|
||||
return { client, requests };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await waitForDeviceSyncHydration();
|
||||
useDeviceSyncStore.setState({ accounts: {} });
|
||||
forgetSyncCollections();
|
||||
});
|
||||
|
||||
describe('listSyncCollections', () => {
|
||||
it('lists the personal address books first, then the shared ones, and marks read-only ones', async () => {
|
||||
const { client, requests } = fakeClient({
|
||||
'AddressBook/get c': () => ({ list: [
|
||||
{ id: 'b2', name: 'Work', sortOrder: 2, myRights: { mayWrite: true } },
|
||||
{ id: 'b1', name: 'Personal', isDefault: true, sortOrder: 1, myRights: { mayWrite: true } },
|
||||
] }),
|
||||
'AddressBook/get team': () => ({ list: [{ id: 'b1', name: 'Team contacts', myRights: { mayWrite: false } }] }),
|
||||
});
|
||||
const list = await listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client });
|
||||
expect(list.map((c) => [c.key, c.name, c.isPersonal, c.readOnly, c.isDefault])).toEqual([
|
||||
['c/b1', 'Personal', true, false, true],
|
||||
['c/b2', 'Work', true, false, false],
|
||||
['team/b1', 'Team contacts', false, true, false],
|
||||
]);
|
||||
expect(list[2].accountName).toBe('Team');
|
||||
// Explicit properties, the contacts capability, and never the mail-only account.
|
||||
const [calls] = requests;
|
||||
expect(calls.map(([name, args]) => `${name} ${args.accountId}`)).toEqual(['AddressBook/get c', 'AddressBook/get team']);
|
||||
expect(calls[0][1].properties).toEqual(expect.arrayContaining(['id', 'name', 'myRights']));
|
||||
});
|
||||
|
||||
it('tells every view of the account when a fresh list arrived', async () => {
|
||||
const { client } = fakeClient({
|
||||
'AddressBook/get c': () => ({ list: [{ id: 'b1', name: 'Personal' }] }),
|
||||
'AddressBook/get team': () => ({ list: [] }),
|
||||
});
|
||||
const seen: string[][] = [];
|
||||
const stop = onSyncCollectionsLoaded((id, authority, list) => seen.push([id, authority, ...list.map((c) => c.key)]));
|
||||
await listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client });
|
||||
stop();
|
||||
await listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client, force: true });
|
||||
expect(seen).toEqual([['alice@x', CONTACTS_AUTHORITY, 'c/b1']]);
|
||||
});
|
||||
|
||||
it('remembers the personal JMAP account for the push routes', async () => {
|
||||
const { client } = fakeClient({ 'AddressBook/get': () => ({ list: [] }) });
|
||||
await listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client });
|
||||
expect(useDeviceSyncStore.getState().accounts['alice@x']?.primaryJmapAccounts).toEqual({ [CONTACTS_AUTHORITY]: 'c' });
|
||||
});
|
||||
|
||||
it('keeps the list when a shared account refuses', async () => {
|
||||
const { client } = fakeClient({
|
||||
'AddressBook/get c': () => ({ list: [{ id: 'b1', name: 'Personal' }] }),
|
||||
'AddressBook/get team': () => null,
|
||||
});
|
||||
const list = await listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client });
|
||||
expect(list.map((c) => c.key)).toEqual(['c/b1']);
|
||||
});
|
||||
|
||||
it('leaves out calendars it may not read and calendars holding only tasks', async () => {
|
||||
// What each calendar's scan finds: `todo` holds only tasks, `empty` nothing.
|
||||
const scans: Record<string, any[]> = {
|
||||
cal1: [{ id: 'e', '@type': 'Event' }],
|
||||
todo: [{ id: 't', '@type': 'Task' }, { id: 'u', due: '2026-10-01T00:00:00' }],
|
||||
empty: [],
|
||||
};
|
||||
const { client } = fakeClient({
|
||||
'Calendar/get c': () => ({ list: [
|
||||
{ id: 'cal1', name: 'Personal', color: '#ff0000', myRights: { mayReadItems: true, mayWriteAll: true } },
|
||||
{ id: 'todo', name: 'Tasks', myRights: { mayReadItems: true, mayWriteAll: true } },
|
||||
{ id: 'empty', name: 'New', myRights: { mayReadItems: true, mayWriteAll: true } },
|
||||
{ id: 'hidden', name: 'Free/busy only', myRights: { mayReadItems: false } },
|
||||
] }),
|
||||
'Calendar/get team': () => ({ list: [
|
||||
{ id: 'cal1', name: 'Team', myRights: { mayReadItems: true, mayWriteAll: false, mayWriteOwn: false } },
|
||||
] }),
|
||||
'CalendarEvent/query': (args) => ({ ids: (scans[args.filter.inCalendar] ?? []).map((o) => o.id) }),
|
||||
'CalendarEvent/get': (args, calls) => {
|
||||
// The get reads the ids of the query it references.
|
||||
const query = calls.find(([, , id]) => id === args['#ids'].resultOf)!;
|
||||
return { list: scans[(query[1].filter as { inCalendar: string }).inCalendar] ?? [] };
|
||||
},
|
||||
});
|
||||
const list = await listSyncCollections('alice@x', CALENDAR_AUTHORITY, { client });
|
||||
// By name within an account (no sort order given).
|
||||
expect(list.map((c) => [c.key, c.readOnly])).toEqual([
|
||||
['c/empty', false],
|
||||
['c/cal1', false],
|
||||
['team/cal1', true],
|
||||
]);
|
||||
expect(list.find((c) => c.key === 'c/cal1')?.color).toBe('#ff0000');
|
||||
});
|
||||
|
||||
it('marks calendars mirroring a subscribed feed read-only', async () => {
|
||||
const { client } = fakeClient({
|
||||
'Calendar/get c': () => ({ list: [
|
||||
{ id: 'feed', name: 'Holidays', myRights: { mayReadItems: true, mayWriteAll: true } },
|
||||
] }),
|
||||
'Calendar/get team': () => ({ list: [] }),
|
||||
'CalendarEvent/query': () => ({ ids: [] }),
|
||||
'CalendarEvent/get': () => ({ list: [] }),
|
||||
});
|
||||
const list = await listSyncCollections('alice@x', CALENDAR_AUTHORITY, { client, feeds: [{ calendarId: 'feed' }] });
|
||||
expect(list.map((c) => [c.key, c.readOnly])).toEqual([['c/feed', true]]);
|
||||
});
|
||||
|
||||
it('keeps a query and the get that reads it in one request', async () => {
|
||||
const { client, requests } = fakeClient({
|
||||
'Calendar/get': () => ({ list: [
|
||||
{ id: 'a', name: 'A' }, { id: 'b', name: 'B' }, { id: 'c', name: 'C' },
|
||||
] }),
|
||||
'CalendarEvent/query': () => ({ ids: [] }),
|
||||
'CalendarEvent/get': () => ({ list: [] }),
|
||||
}, { maxCalls: 5 });
|
||||
await listSyncCollections('alice@x', CALENDAR_AUTHORITY, { client });
|
||||
const scanRequests = requests.filter((calls) => calls[0][0] === 'CalendarEvent/query');
|
||||
for (const calls of scanRequests) {
|
||||
expect(calls.length % 2).toBe(0);
|
||||
expect(calls.length).toBeLessThanOrEqual(5);
|
||||
}
|
||||
});
|
||||
|
||||
it('says why the list could not be loaded', async () => {
|
||||
const signedOut = fakeClient({}, { loaded: false }).client;
|
||||
await expect(listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client: signedOut }))
|
||||
.rejects.toMatchObject({ kind: 'signedOut' });
|
||||
const auth = Object.assign(new Error('Invalid credentials'), { name: 'AuthenticationError' });
|
||||
await expect(listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client: fakeClient({}, { loaded: auth }).client }))
|
||||
.rejects.toBeInstanceOf(CollectionsError);
|
||||
await expect(listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client: fakeClient({}, { loaded: auth }).client }))
|
||||
.rejects.toMatchObject({ kind: 'auth' });
|
||||
const offline = Object.assign(new Error('Server unreachable'), { name: 'NetworkError' });
|
||||
await expect(listSyncCollections('alice@x', CONTACTS_AUTHORITY, { client: fakeClient({}, { loaded: offline }).client }))
|
||||
.rejects.toMatchObject({ kind: 'network' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('otherAccountsSyncing', () => {
|
||||
const registry = [
|
||||
{ id: 'alice@mail.example.org', serverUrl: 'https://mail.example.org', email: 'alice@example.org', username: 'alice' },
|
||||
{ id: 'bob@mail.example.org', serverUrl: 'https://mail.example.org/', email: 'bob@example.org', username: 'bob' },
|
||||
{ id: 'bob@other.example.net', serverUrl: 'https://other.example.net', email: 'bob@example.net', username: 'bob' },
|
||||
];
|
||||
|
||||
it('names the other accounts on the same server that sync the same collection', () => {
|
||||
const accounts = {
|
||||
'bob@mail.example.org': {
|
||||
contactsSelection: { 'team/b1': true },
|
||||
calendarSelection: {},
|
||||
reminderOwner: null,
|
||||
intervalSeconds: {},
|
||||
lastRun: {},
|
||||
enabled: { [CONTACTS_AUTHORITY]: true },
|
||||
},
|
||||
// Another server: the same JMAP ids mean other data.
|
||||
'bob@other.example.net': {
|
||||
contactsSelection: { 'team/b1': true },
|
||||
calendarSelection: {},
|
||||
reminderOwner: null,
|
||||
intervalSeconds: {},
|
||||
lastRun: {},
|
||||
enabled: { [CONTACTS_AUTHORITY]: true },
|
||||
},
|
||||
};
|
||||
const collection = { key: 'team/b1', jmapAccountId: 'team' };
|
||||
expect(otherAccountsSyncing('alice@mail.example.org', CONTACTS_AUTHORITY, collection, registry, accounts))
|
||||
.toEqual(['bob@example.org']);
|
||||
// Not for another authority, nor once Bob's contacts sync is off.
|
||||
expect(otherAccountsSyncing('alice@mail.example.org', CALENDAR_AUTHORITY, collection, registry, accounts)).toEqual([]);
|
||||
accounts['bob@mail.example.org'].enabled = { [CONTACTS_AUTHORITY]: false };
|
||||
expect(otherAccountsSyncing('alice@mail.example.org', CONTACTS_AUTHORITY, collection, registry, accounts)).toEqual([]);
|
||||
});
|
||||
|
||||
it("counts the other account's own collections, which sync by default", () => {
|
||||
const accounts = {
|
||||
'bob@mail.example.org': {
|
||||
contactsSelection: {},
|
||||
calendarSelection: {},
|
||||
reminderOwner: null,
|
||||
intervalSeconds: {},
|
||||
lastRun: {},
|
||||
enabled: { [CALENDAR_AUTHORITY]: true },
|
||||
primaryJmapAccounts: { [CALENDAR_AUTHORITY]: 'bobacct' },
|
||||
},
|
||||
};
|
||||
expect(otherAccountsSyncing(
|
||||
'alice@mail.example.org',
|
||||
CALENDAR_AUTHORITY,
|
||||
{ key: 'bobacct/cal1', jmapAccountId: 'bobacct' },
|
||||
registry,
|
||||
accounts,
|
||||
)).toEqual(['bob@example.org']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,591 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import {
|
||||
CALENDAR_AUTHORITY,
|
||||
CONTACTS_AUTHORITY,
|
||||
type Authority,
|
||||
} from '../../types';
|
||||
|
||||
// A fake of the native module: Android's accounts of our type and their sync
|
||||
// settings, with every call logged in order.
|
||||
const h = vi.hoisted(() => {
|
||||
type Settings = { automatic: boolean; periodicSeconds: number };
|
||||
const state = {
|
||||
available: true,
|
||||
permitted: true,
|
||||
master: true,
|
||||
accounts: [] as Array<{ name: string; registryId: string | null; sync: Record<string, Settings> }>,
|
||||
log: [] as string[],
|
||||
failSetSyncEnabled: false,
|
||||
};
|
||||
const find = (name: string) => state.accounts.find((a) => a.name === name);
|
||||
const settingsOf = (name: string, authority: string) => {
|
||||
const account = find(name);
|
||||
if (!account) return { automatic: false, periodicSeconds: 0 };
|
||||
account.sync[authority] ??= { automatic: false, periodicSeconds: 0 };
|
||||
return account.sync[authority];
|
||||
};
|
||||
const teardown = async (
|
||||
_registryId: string,
|
||||
name: string,
|
||||
authority: string,
|
||||
options?: { uploadFirst?: boolean; timeoutMs?: number; force?: boolean },
|
||||
) => {
|
||||
state.log.push(`teardown ${name} ${authority} ${JSON.stringify(options ?? {})}`);
|
||||
return { pending: 0 };
|
||||
};
|
||||
return { state, find, settingsOf, teardown };
|
||||
});
|
||||
|
||||
vi.mock('../../native', () => ({
|
||||
isDeviceSyncAvailable: () => h.state.available,
|
||||
listAndroidAccounts: vi.fn(async () => h.state.accounts.map((a) => ({ name: a.name, registryId: a.registryId }))),
|
||||
ensureAndroidAccount: vi.fn(async (name: string, registryId: string) => {
|
||||
h.state.log.push(`ensure ${name}`);
|
||||
const existing = h.find(name);
|
||||
// As the native module rejects (DeviceSyncAccounts.ensure → AccountConflictException).
|
||||
if (existing && existing.registryId !== registryId) throw Object.assign(new Error(`${name} belongs to another app account`), { code: 'conflict' });
|
||||
if (existing) return false;
|
||||
h.state.accounts.push({ name, registryId, sync: {} });
|
||||
return true;
|
||||
}),
|
||||
removeAndroidAccount: vi.fn(async (name: string) => {
|
||||
h.state.log.push(`remove ${name}`);
|
||||
h.state.accounts = h.state.accounts.filter((a) => a.name !== name);
|
||||
return true;
|
||||
}),
|
||||
getSyncSettings: vi.fn(async (name: string) => ({
|
||||
masterAutomatic: h.state.master,
|
||||
authorities: {
|
||||
'com.android.contacts': { syncable: 1, active: false, pending: false, ...h.settingsOf(name, 'com.android.contacts') },
|
||||
'com.android.calendar': { syncable: 1, active: false, pending: false, ...h.settingsOf(name, 'com.android.calendar') },
|
||||
},
|
||||
})),
|
||||
setSyncEnabled: vi.fn(async (name: string, authority: string, enabled: boolean) => {
|
||||
if (h.state.failSetSyncEnabled) throw new Error('boom');
|
||||
h.state.log.push(`auto ${name} ${authority} ${enabled}`);
|
||||
h.settingsOf(name, authority).automatic = enabled;
|
||||
}),
|
||||
setPeriodicSync: vi.fn(async (name: string, authority: string, seconds: number) => {
|
||||
h.state.log.push(`periodic ${name} ${authority} ${seconds}`);
|
||||
h.settingsOf(name, authority).periodicSeconds = seconds;
|
||||
}),
|
||||
requestSync: vi.fn(async (name: string, authority: string, options?: Record<string, unknown>) => {
|
||||
h.state.log.push(`sync ${name} ${authority} ${JSON.stringify(options ?? {})}`);
|
||||
}),
|
||||
openAccountSettings: vi.fn(async () => undefined),
|
||||
setPushRoutes: vi.fn(async () => undefined),
|
||||
showSyncProblem: vi.fn(async (name: string) => { h.state.log.push(`problem ${name}`); }),
|
||||
clearSyncProblem: vi.fn(async (name: string) => { h.state.log.push(`clear ${name}`); }),
|
||||
onAccountsChanged: vi.fn(() => () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../task', () => ({ teardownAuthority: vi.fn(h.teardown) }));
|
||||
|
||||
vi.mock('../../app/permissions', () => ({
|
||||
hasSyncPermissions: vi.fn(async () => h.state.permitted),
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/push-notifications', () => ({
|
||||
refreshPushSubscriptionTypes: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
import * as native from '../../native';
|
||||
import * as task from '../../task';
|
||||
import { refreshPushSubscriptionTypes } from '../../../lib/push-notifications';
|
||||
import { useAccountStore, type AccountEntry } from '../../../stores/account-store';
|
||||
import {
|
||||
accountDeviceSync,
|
||||
useDeviceSyncStore,
|
||||
waitForDeviceSyncHydration,
|
||||
} from '../../../stores/device-sync-store';
|
||||
import {
|
||||
changeReminderOwner,
|
||||
changeSyncInterval,
|
||||
deviceSyncSignedIn,
|
||||
disableDeviceSync,
|
||||
enableDeviceSync,
|
||||
preferredAndroidAccountName,
|
||||
reconcileDeviceAccounts,
|
||||
releaseDeviceSyncBeforeSignOut,
|
||||
removeSuspendedAccount,
|
||||
resolveDeletions,
|
||||
resumeDeviceSync,
|
||||
TEARDOWN_TIMEOUT_MS,
|
||||
} from '../../app/lifecycle';
|
||||
|
||||
const ALICE = 'alice@mail.example.org';
|
||||
const ALICE2 = 'alice@other.example.net';
|
||||
const BOB = 'bob@mail.example.org';
|
||||
|
||||
function entry(id: string, username: string): AccountEntry {
|
||||
return {
|
||||
id,
|
||||
serverUrl: `https://${id.split('@').pop()}`,
|
||||
username,
|
||||
displayName: username,
|
||||
email: `${username}@example.org`,
|
||||
avatarColor: '#000',
|
||||
lastLoginAt: 0,
|
||||
isConnected: true,
|
||||
hasError: false,
|
||||
isDefault: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function ready(registry: AccountEntry[]): Promise<void> {
|
||||
await waitForDeviceSyncHydration();
|
||||
if (!useAccountStore.persist.hasHydrated()) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const off = useAccountStore.persist.onFinishHydration(() => { off(); resolve(); });
|
||||
});
|
||||
}
|
||||
useAccountStore.setState({ accounts: registry, activeAccountId: registry[0]?.id ?? null });
|
||||
}
|
||||
|
||||
const teardown = vi.mocked(task.teardownAuthority);
|
||||
const setSyncEnabled = vi.mocked(native.setSyncEnabled);
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
h.state.available = true;
|
||||
h.state.permitted = true;
|
||||
h.state.master = true;
|
||||
h.state.accounts = [];
|
||||
h.state.log = [];
|
||||
h.state.failSetSyncEnabled = false;
|
||||
teardown.mockImplementation(h.teardown);
|
||||
await AsyncStorage.clear();
|
||||
useDeviceSyncStore.setState({ accounts: {} });
|
||||
await ready([entry(ALICE, 'alice'), entry(BOB, 'bob')]);
|
||||
});
|
||||
|
||||
describe('enable', () => {
|
||||
it('creates the Android account under the login, turns automatic and periodic sync on and syncs', async () => {
|
||||
useDeviceSyncStore.getState().setIntervalSeconds(ALICE, CONTACTS_AUTHORITY, 1800);
|
||||
const outcome = await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
expect(outcome).toEqual({ kind: 'enabled', accountName: 'alice' });
|
||||
expect(h.state.log).toEqual([
|
||||
'ensure alice',
|
||||
`auto alice ${CONTACTS_AUTHORITY} true`,
|
||||
`periodic alice ${CONTACTS_AUTHORITY} 1800`,
|
||||
`sync alice ${CONTACTS_AUTHORITY} {"manual":true}`,
|
||||
]);
|
||||
const stored = accountDeviceSync(ALICE);
|
||||
expect(stored.androidAccountName).toBe('alice');
|
||||
expect(stored.enabled).toEqual({ [CONTACTS_AUTHORITY]: true });
|
||||
await vi.waitFor(() => expect(refreshPushSubscriptionTypes).toHaveBeenCalledWith(ALICE));
|
||||
});
|
||||
|
||||
it('uses the default hourly interval', async () => {
|
||||
await enableDeviceSync(ALICE, CALENDAR_AUTHORITY);
|
||||
expect(h.state.log).toContain(`periodic alice ${CALENDAR_AUTHORITY} 3600`);
|
||||
});
|
||||
|
||||
it('does nothing without the runtime permissions', async () => {
|
||||
h.state.permitted = false;
|
||||
expect(await enableDeviceSync(ALICE, CONTACTS_AUTHORITY)).toEqual({ kind: 'permission' });
|
||||
expect(h.state.log).toEqual([]);
|
||||
expect(accountDeviceSync(ALICE).enabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reuses the Android account of the app account', async () => {
|
||||
h.state.accounts.push({ name: 'alice', registryId: ALICE, sync: {} });
|
||||
await enableDeviceSync(ALICE, CALENDAR_AUTHORITY);
|
||||
expect(h.state.log.filter((l) => l.startsWith('ensure'))).toEqual([]);
|
||||
expect(accountDeviceSync(ALICE).androidAccountName).toBe('alice');
|
||||
});
|
||||
|
||||
it('names the account after its registry id when another app account has the login', async () => {
|
||||
await ready([entry(ALICE, 'alice'), entry(ALICE2, 'alice')]);
|
||||
h.state.accounts.push({ name: 'alice', registryId: ALICE, sync: {} });
|
||||
expect(preferredAndroidAccountName(ALICE2, await native.listAndroidAccounts())).toBe(ALICE2);
|
||||
const outcome = await enableDeviceSync(ALICE2, CONTACTS_AUTHORITY);
|
||||
expect(outcome).toEqual({ kind: 'enabled', accountName: ALICE2 });
|
||||
});
|
||||
|
||||
it('falls back to the registry id when the login turns out to be taken', async () => {
|
||||
const list = vi.mocked(native.listAndroidAccounts);
|
||||
// A stale list: the clash only shows when the account is added.
|
||||
list.mockResolvedValueOnce([]);
|
||||
h.state.accounts.push({ name: 'alice', registryId: ALICE2, sync: {} });
|
||||
const outcome = await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
expect(outcome).toEqual({ kind: 'enabled', accountName: ALICE });
|
||||
});
|
||||
|
||||
it('adds no second account named after the registry id when adding the account fails for another reason', async () => {
|
||||
for (const code of ['failed', 'permission']) {
|
||||
vi.mocked(native.ensureAndroidAccount).mockClear();
|
||||
vi.mocked(native.ensureAndroidAccount).mockRejectedValueOnce(Object.assign(new Error(`AccountManager said no (${code})`), { code }));
|
||||
const outcome = await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
expect(outcome).toEqual({ kind: 'failed', message: `AccountManager said no (${code})` });
|
||||
expect(native.ensureAndroidAccount).toHaveBeenCalledTimes(1);
|
||||
expect(h.state.accounts).toEqual([]);
|
||||
expect(accountDeviceSync(ALICE).androidAccountName).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves no account behind when turning sync on fails', async () => {
|
||||
h.state.failSetSyncEnabled = true;
|
||||
const outcome = await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
expect(outcome.kind).toBe('failed');
|
||||
expect(h.state.accounts).toEqual([]);
|
||||
expect(accountDeviceSync(ALICE).enabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the account of the other authority when turning one on fails', async () => {
|
||||
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
vi.mocked(native.setPeriodicSync).mockRejectedValueOnce(new Error('boom'));
|
||||
expect((await enableDeviceSync(ALICE, CALENDAR_AUTHORITY)).kind).toBe('failed');
|
||||
expect(h.find('alice')?.sync[CALENDAR_AUTHORITY].automatic).toBe(false);
|
||||
expect(h.find('alice')?.sync[CONTACTS_AUTHORITY].automatic).toBe(true);
|
||||
expect(accountDeviceSync(ALICE)).toMatchObject({
|
||||
androidAccountName: 'alice',
|
||||
enabled: { [CONTACTS_AUTHORITY]: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('is a no-op where device sync is not available', async () => {
|
||||
h.state.available = false;
|
||||
expect((await enableDeviceSync(ALICE, CONTACTS_AUTHORITY)).kind).toBe('failed');
|
||||
expect(h.state.log).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disable', () => {
|
||||
beforeEach(async () => {
|
||||
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
await enableDeviceSync(ALICE, CALENDAR_AUTHORITY);
|
||||
await changeReminderOwner(ALICE, 'bulwark');
|
||||
h.state.log = [];
|
||||
});
|
||||
|
||||
it('turns automatic sync off before the teardown uploads and deletes', async () => {
|
||||
const result = await disableDeviceSync(ALICE, CALENDAR_AUTHORITY);
|
||||
expect(result).toEqual({ done: true, pending: 0 });
|
||||
expect(h.state.log).toEqual([
|
||||
`auto alice ${CALENDAR_AUTHORITY} false`,
|
||||
`teardown alice ${CALENDAR_AUTHORITY} {"uploadFirst":true,"timeoutMs":${TEARDOWN_TIMEOUT_MS}}`,
|
||||
]);
|
||||
const stored = accountDeviceSync(ALICE);
|
||||
expect(stored.enabled).toEqual({ [CONTACTS_AUTHORITY]: true, [CALENDAR_AUTHORITY]: false });
|
||||
// Contacts still sync: the account stays; the reminder question comes again next time.
|
||||
expect(h.find('alice')).toBeDefined();
|
||||
expect(stored.reminderOwner).toBeNull();
|
||||
});
|
||||
|
||||
it('lets a turn-on wait for a turn-off that is still tearing down, instead of being undone by it', async () => {
|
||||
await disableDeviceSync(ALICE, CALENDAR_AUTHORITY);
|
||||
let finishTeardown: () => void = () => undefined;
|
||||
teardown.mockImplementationOnce(async (_registryId, name, authority, options) => {
|
||||
h.state.log.push(`teardown ${name} ${authority} ${JSON.stringify(options ?? {})}`);
|
||||
await new Promise<void>((resolve) => { finishTeardown = resolve; });
|
||||
return { pending: 0 };
|
||||
});
|
||||
const turningOff = disableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
await vi.waitFor(() => expect(h.state.log).toContain(`teardown alice ${CONTACTS_AUTHORITY} {"uploadFirst":true,"timeoutMs":${TEARDOWN_TIMEOUT_MS}}`));
|
||||
// The user opens the settings again and turns contacts sync back on.
|
||||
const turningOn = enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
finishTeardown();
|
||||
expect(await turningOff).toEqual({ done: true, pending: 0 });
|
||||
expect(await turningOn).toMatchObject({ kind: 'enabled' });
|
||||
expect(h.find('alice')?.sync[CONTACTS_AUTHORITY]?.automatic).toBe(true);
|
||||
expect(accountDeviceSync(ALICE).enabled?.[CONTACTS_AUTHORITY]).toBe(true);
|
||||
expect(accountDeviceSync(ALICE).androidAccountName).toBe('alice');
|
||||
});
|
||||
|
||||
it('removes the Android account once both authorities are off', async () => {
|
||||
await disableDeviceSync(ALICE, CALENDAR_AUTHORITY);
|
||||
await disableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
expect(h.state.accounts).toEqual([]);
|
||||
expect(h.state.log).toContain('remove alice');
|
||||
expect(accountDeviceSync(ALICE).androidAccountName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deletes nothing and reports the waiting changes when they could not be uploaded', async () => {
|
||||
teardown.mockResolvedValueOnce({ pending: 3 });
|
||||
const result = await disableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
expect(result).toEqual({ done: false, pending: 3 });
|
||||
expect(accountDeviceSync(ALICE).enabled?.[CONTACTS_AUTHORITY]).toBe(true);
|
||||
|
||||
// The user keeps syncing: automatic sync comes back.
|
||||
h.state.log = [];
|
||||
await resumeDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
expect(h.state.log).toEqual([
|
||||
`auto alice ${CONTACTS_AUTHORITY} true`,
|
||||
`sync alice ${CONTACTS_AUTHORITY} {"manual":true}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('deletes on the user\'s word with force', async () => {
|
||||
teardown.mockResolvedValueOnce({ pending: 2 });
|
||||
await disableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
h.state.log = [];
|
||||
const result = await disableDeviceSync(ALICE, CONTACTS_AUTHORITY, { force: true });
|
||||
expect(result.done).toBe(true);
|
||||
expect(h.state.log).toContain(`teardown alice ${CONTACTS_AUTHORITY} {"force":true,"timeoutMs":5000}`);
|
||||
expect(accountDeviceSync(ALICE).enabled?.[CONTACTS_AUTHORITY]).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a failed teardown as unknown changes', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
teardown.mockRejectedValueOnce(new Error('provider gone'));
|
||||
expect(await disableDeviceSync(ALICE, CONTACTS_AUTHORITY)).toEqual({ done: false, pending: 0, failed: true });
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sign-out', () => {
|
||||
it('lets an account without device sync go at once', async () => {
|
||||
const confirm = vi.fn(async () => true);
|
||||
expect(await releaseDeviceSyncBeforeSignOut([BOB], confirm)).toBe(true);
|
||||
expect(teardown).not.toHaveBeenCalled();
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads, deletes and removes the Android account before the credentials go', async () => {
|
||||
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
h.state.log = [];
|
||||
const confirm = vi.fn(async () => true);
|
||||
expect(await releaseDeviceSyncBeforeSignOut([ALICE], confirm)).toBe(true);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(teardown).toHaveBeenCalledWith(ALICE, 'alice', CONTACTS_AUTHORITY, { uploadFirst: true, timeoutMs: TEARDOWN_TIMEOUT_MS });
|
||||
expect(teardown).toHaveBeenCalledWith(ALICE, 'alice', CALENDAR_AUTHORITY, { uploadFirst: true, timeoutMs: TEARDOWN_TIMEOUT_MS });
|
||||
expect(h.state.accounts).toEqual([]);
|
||||
expect(useDeviceSyncStore.getState().accounts[ALICE]).toBeUndefined();
|
||||
// Automatic sync went off before the first teardown.
|
||||
expect(h.state.log.indexOf(`auto alice ${CONTACTS_AUTHORITY} false`))
|
||||
.toBeLessThan(h.state.log.findIndex((l) => l.startsWith('teardown')));
|
||||
});
|
||||
|
||||
it('asks before losing changes, and keeps syncing when the user stays', async () => {
|
||||
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
teardown.mockImplementation(async (_r: string, _n: string, authority: string) => ({ pending: authority === CONTACTS_AUTHORITY ? 4 : 0 }));
|
||||
const confirm = vi.fn(async () => false);
|
||||
expect(await releaseDeviceSyncBeforeSignOut([ALICE], confirm)).toBe(false);
|
||||
expect(confirm).toHaveBeenCalledWith(4);
|
||||
expect(h.find('alice')?.sync[CONTACTS_AUTHORITY].automatic).toBe(true);
|
||||
expect(accountDeviceSync(ALICE).androidAccountName).toBe('alice');
|
||||
});
|
||||
|
||||
it('deletes what is left once the user agrees', async () => {
|
||||
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
teardown.mockImplementation(async (_r: string, _n: string, authority: string, options?: { force?: boolean }) =>
|
||||
({ pending: authority === CONTACTS_AUTHORITY && !options?.force ? 4 : 0 }));
|
||||
const confirm = vi.fn(async () => true);
|
||||
expect(await releaseDeviceSyncBeforeSignOut([ALICE], confirm)).toBe(true);
|
||||
expect(teardown).toHaveBeenCalledWith(ALICE, 'alice', CONTACTS_AUTHORITY, { force: true, timeoutMs: 5_000 });
|
||||
expect(h.state.accounts).toEqual([]);
|
||||
});
|
||||
|
||||
it('asks once for several accounts', async () => {
|
||||
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
await enableDeviceSync(BOB, CALENDAR_AUTHORITY);
|
||||
teardown.mockImplementation(async (_r: string, _n: string, _a: string, options?: { force?: boolean }) => ({ pending: options?.force ? 0 : 1 }));
|
||||
const confirm = vi.fn(async () => true);
|
||||
expect(await releaseDeviceSyncBeforeSignOut([ALICE, BOB], confirm)).toBe(true);
|
||||
expect(confirm).toHaveBeenCalledTimes(1);
|
||||
expect(confirm).toHaveBeenCalledWith(4);
|
||||
expect(h.state.accounts).toEqual([]);
|
||||
});
|
||||
|
||||
it('waits for a turn-on in progress, then releases what it set up', async () => {
|
||||
let finishEnsure: () => void = () => undefined;
|
||||
vi.mocked(native.ensureAndroidAccount).mockImplementationOnce(async (name: string, registryId: string) => {
|
||||
h.state.log.push(`ensure ${name}`);
|
||||
await new Promise<void>((resolve) => { finishEnsure = resolve; });
|
||||
h.state.accounts.push({ name, registryId, sync: {} });
|
||||
return true;
|
||||
});
|
||||
const turningOn = enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
await vi.waitFor(() => expect(h.state.log).toContain('ensure alice'));
|
||||
|
||||
const signingOut = releaseDeviceSyncBeforeSignOut([ALICE], vi.fn(async () => true));
|
||||
finishEnsure();
|
||||
|
||||
expect(await signingOut).toBe(true);
|
||||
await turningOn;
|
||||
expect(teardown).toHaveBeenCalledWith(ALICE, 'alice', CONTACTS_AUTHORITY, { uploadFirst: true, timeoutMs: TEARDOWN_TIMEOUT_MS });
|
||||
expect(h.state.accounts).toEqual([]);
|
||||
expect(useDeviceSyncStore.getState().accounts[ALICE]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lets a turn-on or turn-off asked for during the sign-out set nothing up for the account', async () => {
|
||||
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
let finishTeardown: () => void = () => undefined;
|
||||
teardown.mockImplementationOnce(async () => {
|
||||
await new Promise<void>((resolve) => { finishTeardown = resolve; });
|
||||
return { pending: 0 };
|
||||
});
|
||||
const signingOut = releaseDeviceSyncBeforeSignOut([ALICE], vi.fn(async () => true));
|
||||
await vi.waitFor(() => expect(teardown).toHaveBeenCalled());
|
||||
|
||||
const turningOn = enableDeviceSync(ALICE, CALENDAR_AUTHORITY);
|
||||
const turningOff = disableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
finishTeardown();
|
||||
|
||||
expect(await signingOut).toBe(true);
|
||||
expect((await turningOn).kind).toBe('failed');
|
||||
expect(await turningOff).toEqual({ done: true, pending: 0 });
|
||||
expect(h.state.accounts).toEqual([]);
|
||||
expect(h.state.log.filter((l) => l.startsWith('ensure') || l.startsWith('sync'))).toEqual(['ensure alice', `sync alice ${CONTACTS_AUTHORITY} {"manual":true}`]);
|
||||
expect(useDeviceSyncStore.getState().accounts[ALICE]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lets a turn-on asked for during the sign-out go ahead once the user chose to stay signed in', async () => {
|
||||
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
teardown.mockImplementation(async (_r: string, _n: string, authority: string) => ({ pending: authority === CONTACTS_AUTHORITY ? 1 : 0 }));
|
||||
let answer: (signOut: boolean) => void = () => undefined;
|
||||
const confirm = vi.fn(() => new Promise<boolean>((resolve) => { answer = resolve; }));
|
||||
const signingOut = releaseDeviceSyncBeforeSignOut([ALICE], confirm);
|
||||
await vi.waitFor(() => expect(confirm).toHaveBeenCalled());
|
||||
|
||||
const turningOn = enableDeviceSync(ALICE, CALENDAR_AUTHORITY);
|
||||
answer(false);
|
||||
|
||||
expect(await signingOut).toBe(false);
|
||||
expect(await turningOn).toEqual({ kind: 'enabled', accountName: 'alice' });
|
||||
expect(h.find('alice')?.sync[CALENDAR_AUTHORITY].automatic).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcile', () => {
|
||||
it('suspends an Android account whose app account the registry dropped, once', async () => {
|
||||
h.state.accounts.push({
|
||||
name: 'carol',
|
||||
registryId: 'carol@mail.example.org',
|
||||
sync: { [CONTACTS_AUTHORITY]: { automatic: true, periodicSeconds: 3600 } },
|
||||
});
|
||||
await reconcileDeviceAccounts();
|
||||
expect(h.find('carol')?.sync[CONTACTS_AUTHORITY].automatic).toBe(false);
|
||||
expect(h.find('carol')).toBeDefined();
|
||||
expect(native.showSyncProblem).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(native.showSyncProblem).mock.calls[0][3]).toBe('bulwarkmobile://settings/account');
|
||||
const carol = accountDeviceSync('carol@mail.example.org');
|
||||
expect(carol.suspended).toBe(true);
|
||||
expect(carol.enabled).toEqual({ [CONTACTS_AUTHORITY]: true });
|
||||
|
||||
await reconcileDeviceAccounts();
|
||||
expect(native.showSyncProblem).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('drops a suspended account and its data when the user removes it', async () => {
|
||||
h.state.accounts.push({ name: 'carol', registryId: 'carol@mail.example.org', sync: {} });
|
||||
await reconcileDeviceAccounts();
|
||||
h.state.log = [];
|
||||
await removeSuspendedAccount('carol@mail.example.org');
|
||||
expect(h.state.log).toEqual(['remove carol', 'clear carol']);
|
||||
expect(useDeviceSyncStore.getState().accounts['carol@mail.example.org']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never acts on an empty registry', async () => {
|
||||
await ready([]);
|
||||
h.state.accounts.push({ name: 'carol', registryId: 'carol@mail.example.org', sync: {} });
|
||||
await reconcileDeviceAccounts();
|
||||
expect(native.showSyncProblem).not.toHaveBeenCalled();
|
||||
expect(setSyncEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows sync off for an account removed in Android Settings and never recreates it', async () => {
|
||||
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
h.state.accounts = [];
|
||||
vi.mocked(native.ensureAndroidAccount).mockClear();
|
||||
await reconcileDeviceAccounts();
|
||||
const alice = accountDeviceSync(ALICE);
|
||||
expect(alice.removedInAndroidSettings).toBe(true);
|
||||
expect(alice.androidAccountName).toBeUndefined();
|
||||
expect(alice.enabled).toEqual({});
|
||||
await reconcileDeviceAccounts();
|
||||
expect(native.ensureAndroidAccount).not.toHaveBeenCalled();
|
||||
expect(h.state.accounts).toEqual([]);
|
||||
});
|
||||
|
||||
it('forgets an account that is neither signed in nor on the device', async () => {
|
||||
useDeviceSyncStore.getState().update('gone@mail.example.org', { androidAccountName: 'gone', enabled: { [CONTACTS_AUTHORITY]: true } });
|
||||
await reconcileDeviceAccounts();
|
||||
expect(useDeviceSyncStore.getState().accounts['gone@mail.example.org']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('takes an authority turned on in Android Settings as on', async () => {
|
||||
h.state.accounts.push({
|
||||
name: 'alice',
|
||||
registryId: ALICE,
|
||||
sync: { [CALENDAR_AUTHORITY]: { automatic: true, periodicSeconds: 3600 } },
|
||||
});
|
||||
await reconcileDeviceAccounts();
|
||||
const alice = accountDeviceSync(ALICE);
|
||||
expect(alice.androidAccountName).toBe('alice');
|
||||
expect(alice.enabled).toEqual({ [CALENDAR_AUTHORITY]: true });
|
||||
});
|
||||
|
||||
it('hands the push router the accounts that feed each Android account', async () => {
|
||||
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
useDeviceSyncStore.getState().rememberPrimaryJmapAccount(ALICE, CONTACTS_AUTHORITY, 'c');
|
||||
await reconcileDeviceAccounts();
|
||||
expect(native.setPushRoutes).toHaveBeenLastCalledWith({
|
||||
c: [{ accountName: 'alice', authorities: [CONTACTS_AUTHORITY] }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sign-in', () => {
|
||||
it('clears the sign-in notice and resumes a suspended account where it synced', async () => {
|
||||
h.state.accounts.push({
|
||||
name: 'alice',
|
||||
registryId: ALICE,
|
||||
sync: { [CONTACTS_AUTHORITY]: { automatic: false, periodicSeconds: 3600 } },
|
||||
});
|
||||
useDeviceSyncStore.getState().update(ALICE, {
|
||||
androidAccountName: 'alice',
|
||||
suspended: true,
|
||||
enabled: { [CONTACTS_AUTHORITY]: true },
|
||||
});
|
||||
await deviceSyncSignedIn(ALICE);
|
||||
expect(h.state.log).toEqual([
|
||||
'clear alice',
|
||||
`auto alice ${CONTACTS_AUTHORITY} true`,
|
||||
`sync alice ${CONTACTS_AUTHORITY} {"manual":true}`,
|
||||
]);
|
||||
expect(accountDeviceSync(ALICE).suspended).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does nothing for an account without an Android account', async () => {
|
||||
await deviceSyncSignedIn(BOB);
|
||||
expect(h.state.log).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings actions', () => {
|
||||
it('deletes held-back deletions on the server, or brings the items back, with a sync', async () => {
|
||||
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
h.state.log = [];
|
||||
expect(await resolveDeletions(ALICE, CONTACTS_AUTHORITY, 'delete')).toBe(true);
|
||||
expect(await resolveDeletions(ALICE, CONTACTS_AUTHORITY, 'restore')).toBe(true);
|
||||
expect(h.state.log).toEqual([
|
||||
`sync alice ${CONTACTS_AUTHORITY} {"manual":true,"overrideTooManyDeletions":true}`,
|
||||
`sync alice ${CONTACTS_AUTHORITY} {"manual":true,"discardLocalDeletions":true}`,
|
||||
]);
|
||||
// Without an Android account there is nothing to resolve.
|
||||
expect(await resolveDeletions(BOB, CONTACTS_AUTHORITY, 'delete')).toBe(false);
|
||||
});
|
||||
|
||||
it('applies a new interval to Android while the authority syncs', async () => {
|
||||
await enableDeviceSync(ALICE, CONTACTS_AUTHORITY);
|
||||
h.state.log = [];
|
||||
await changeSyncInterval(ALICE, CONTACTS_AUTHORITY, 0);
|
||||
expect(h.state.log).toEqual([`periodic alice ${CONTACTS_AUTHORITY} 0`]);
|
||||
await changeSyncInterval(ALICE, CALENDAR_AUTHORITY, 900);
|
||||
expect(h.state.log).toEqual([`periodic alice ${CONTACTS_AUTHORITY} 0`]);
|
||||
expect(accountDeviceSync(ALICE).intervalSeconds[CALENDAR_AUTHORITY as Authority]).toBe(900);
|
||||
});
|
||||
|
||||
it('syncs the calendar when the reminder owner changes', async () => {
|
||||
await enableDeviceSync(ALICE, CALENDAR_AUTHORITY);
|
||||
h.state.log = [];
|
||||
await changeReminderOwner(ALICE, 'device');
|
||||
expect(h.state.log).toEqual([`sync alice ${CALENDAR_AUTHORITY} {"manual":true}`]);
|
||||
h.state.log = [];
|
||||
await changeReminderOwner(ALICE, 'device');
|
||||
expect(h.state.log).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Android's side: the device's auto-sync, the account's calendar sync and the calendar permission.
|
||||
const h = vi.hoisted(() => ({ available: true, master: true, automatic: true, permitted: true }));
|
||||
|
||||
vi.mock('../../native', () => ({
|
||||
isDeviceSyncAvailable: () => h.available,
|
||||
getSyncSettings: vi.fn(async () => ({
|
||||
masterAutomatic: h.master,
|
||||
authorities: {
|
||||
'com.android.contacts': { syncable: 1, automatic: false, periodicSeconds: 0, active: false, pending: false },
|
||||
'com.android.calendar': { syncable: 1, automatic: h.automatic, periodicSeconds: 3600, active: false, pending: false },
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../app/permissions', () => ({
|
||||
hasSyncPermissions: vi.fn(async () => h.permitted),
|
||||
}));
|
||||
|
||||
import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY } from '../../types';
|
||||
import type { CalendarEvent } from '../../../api/types';
|
||||
import { useDeviceSyncStore, waitForDeviceSyncHydration } from '../../../stores/device-sync-store';
|
||||
import { onDeviceRemindersChange, remindedOnDevice } from '../../app/reminders';
|
||||
|
||||
const ALICE = 'alice@mail.example.org';
|
||||
|
||||
function event(id: string, calendarIds: Record<string, boolean>): CalendarEvent {
|
||||
return { id, calendarIds, title: id, start: '2026-09-24T12:00:00', duration: 'PT1H' } as CalendarEvent;
|
||||
}
|
||||
|
||||
function calendarSync(extra: Record<string, unknown> = {}): void {
|
||||
useDeviceSyncStore.setState({
|
||||
accounts: {
|
||||
[ALICE]: {
|
||||
contactsSelection: {},
|
||||
calendarSelection: { 'c/work': false, 'team/shared-on': true },
|
||||
reminderOwner: 'device',
|
||||
intervalSeconds: {},
|
||||
lastRun: {},
|
||||
androidAccountName: 'alice',
|
||||
enabled: { [CALENDAR_AUTHORITY]: true },
|
||||
...extra,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Waits until the calendar app reminds of Alice's synced events (Android's side is read when the filter is asked for). */
|
||||
async function remindedByCalendarApp(): Promise<void> {
|
||||
await vi.waitFor(() => expect(remindedOnDevice(ALICE, 'c')).not.toBeNull());
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await waitForDeviceSyncHydration();
|
||||
Object.assign(h, { available: true, master: true, automatic: true, permitted: true });
|
||||
calendarSync();
|
||||
await remindedByCalendarApp();
|
||||
});
|
||||
|
||||
describe('remindedOnDevice', () => {
|
||||
it("picks the events of the account's synced calendars", () => {
|
||||
const onDevice = remindedOnDevice(ALICE, 'c')!;
|
||||
// Own calendars sync unless turned off.
|
||||
expect(onDevice(event('a', { personal: true }))).toBe(true);
|
||||
expect(onDevice(event('b', { work: true }))).toBe(false);
|
||||
expect(onDevice(event('c', { work: true, personal: true }))).toBe(true);
|
||||
// Shared calendars: namespaced ids in calendarIds, raw ones in originalCalendarIds.
|
||||
expect(onDevice({
|
||||
...event('d', { 'team:shared-on': true }),
|
||||
accountId: 'team',
|
||||
originalCalendarIds: { 'shared-on': true },
|
||||
})).toBe(true);
|
||||
expect(onDevice({
|
||||
...event('e', { 'team:other': true }),
|
||||
accountId: 'team',
|
||||
originalCalendarIds: { other: true },
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves everything to Bulwark when Bulwark reminds, or calendars do not sync', () => {
|
||||
calendarSync({ reminderOwner: 'bulwark' });
|
||||
expect(remindedOnDevice(ALICE, 'c')).toBeNull();
|
||||
calendarSync({ enabled: { [CONTACTS_AUTHORITY]: true } });
|
||||
expect(remindedOnDevice(ALICE, 'c')).toBeNull();
|
||||
calendarSync({ removedInAndroidSettings: true });
|
||||
expect(remindedOnDevice(ALICE, 'c')).toBeNull();
|
||||
expect(remindedOnDevice('someone@else', 'c')).toBeNull();
|
||||
expect(remindedOnDevice(null, 'c')).toBeNull();
|
||||
});
|
||||
|
||||
it('counts the calendar app as the owner until the user chose', () => {
|
||||
calendarSync({ reminderOwner: null });
|
||||
expect(remindedOnDevice(ALICE, 'c')?.(event('a', { personal: true }))).toBe(true);
|
||||
});
|
||||
|
||||
it('does nothing where device sync is not available', () => {
|
||||
h.available = false;
|
||||
expect(remindedOnDevice(ALICE, 'c')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("remindedOnDevice while the device calendar can't receive events", () => {
|
||||
it('leaves the reminders to Bulwark while calendar sync is paused in Android settings', async () => {
|
||||
h.automatic = false;
|
||||
await vi.waitFor(() => expect(remindedOnDevice(ALICE, 'c')).toBeNull());
|
||||
});
|
||||
|
||||
it("leaves them to Bulwark while Android's auto-sync is off: Android then runs manual syncs only", async () => {
|
||||
h.master = false;
|
||||
await vi.waitFor(() => expect(remindedOnDevice(ALICE, 'c')).toBeNull());
|
||||
});
|
||||
|
||||
it('leaves them to Bulwark while the calendar permission is revoked', async () => {
|
||||
h.permitted = false;
|
||||
await vi.waitFor(() => expect(remindedOnDevice(ALICE, 'c')).toBeNull());
|
||||
});
|
||||
|
||||
it('hands them back to the calendar app once it syncs again, and tells the scheduler both times', async () => {
|
||||
const listener = vi.fn();
|
||||
const off = onDeviceRemindersChange(listener);
|
||||
h.automatic = false;
|
||||
await vi.waitFor(() => expect(listener).toHaveBeenCalledTimes(1));
|
||||
expect(remindedOnDevice(ALICE, 'c')).toBeNull();
|
||||
|
||||
h.automatic = true;
|
||||
await vi.waitFor(() => expect(listener).toHaveBeenCalledTimes(2));
|
||||
expect(remindedOnDevice(ALICE, 'c')).not.toBeNull();
|
||||
off();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onDeviceRemindersChange', () => {
|
||||
it('fires for the owner and the calendar selection, not for sync bookkeeping', () => {
|
||||
const listener = vi.fn();
|
||||
const off = onDeviceRemindersChange(listener);
|
||||
const store = useDeviceSyncStore.getState();
|
||||
|
||||
store.recordKnownState(ALICE, 'c', 'CalendarEvent', 's1');
|
||||
store.setIntervalSeconds(ALICE, CALENDAR_AUTHORITY, 900);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
|
||||
store.setReminderOwner(ALICE, 'bulwark');
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
store.setReminderOwner(ALICE, 'device');
|
||||
store.setCollectionSelected(ALICE, CALENDAR_AUTHORITY, 'c/work', true);
|
||||
expect(listener).toHaveBeenCalledTimes(3);
|
||||
off();
|
||||
store.setEnabled(ALICE, CALENDAR_AUTHORITY, false);
|
||||
expect(listener).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
available: true,
|
||||
appState: [] as Array<(state: string) => void>,
|
||||
accountsChanged: [] as Array<() => void>,
|
||||
}));
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Platform: { OS: 'android', Version: 34, select: <T,>(s: { android?: T; default?: T }) => s.android ?? s.default },
|
||||
NativeModules: {},
|
||||
NativeEventEmitter: class {
|
||||
addListener() {
|
||||
return { remove: () => undefined };
|
||||
}
|
||||
},
|
||||
AppState: {
|
||||
currentState: 'active',
|
||||
addEventListener: (_type: string, listener: (state: string) => void) => {
|
||||
h.appState.push(listener);
|
||||
return { remove: () => { h.appState = h.appState.filter((l) => l !== listener); } };
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../native', () => ({
|
||||
isDeviceSyncAvailable: () => h.available,
|
||||
requestSync: vi.fn(async () => undefined),
|
||||
onAccountsChanged: vi.fn((listener: () => void) => {
|
||||
h.accountsChanged.push(listener);
|
||||
return () => { h.accountsChanged = h.accountsChanged.filter((l) => l !== listener); };
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../app/lifecycle', () => ({
|
||||
reconcileDeviceAccounts: vi.fn(async () => undefined),
|
||||
refreshPushRoutes: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
import * as native from '../../native';
|
||||
import * as lifecycle from '../../app/lifecycle';
|
||||
import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY } from '../../types';
|
||||
import { dispatchStateChange } from '../../../lib/state-change-bus';
|
||||
import { useAccountStore, type AccountEntry } from '../../../stores/account-store';
|
||||
import { useDeviceSyncStore, waitForDeviceSyncHydration } from '../../../stores/device-sync-store';
|
||||
import { cancelDeviceSyncRequests, requestDeviceSync, TRIGGER_DELAY_MS } from '../../app/request';
|
||||
import {
|
||||
FOREGROUND_MIN_INTERVAL_MS,
|
||||
handleForeground,
|
||||
handleStateChange,
|
||||
startDeviceSyncTriggers,
|
||||
} from '../../app/triggers';
|
||||
|
||||
const ALICE = 'alice@mail.example.org';
|
||||
const BOB = 'bob@mail.example.org';
|
||||
const requestSync = vi.mocked(native.requestSync);
|
||||
|
||||
function account(id: string): AccountEntry {
|
||||
return {
|
||||
id,
|
||||
serverUrl: 'https://mail.example.org',
|
||||
username: id.split('@')[0],
|
||||
displayName: id,
|
||||
email: id,
|
||||
avatarColor: '#000',
|
||||
lastLoginAt: 0,
|
||||
isConnected: true,
|
||||
hasError: false,
|
||||
isDefault: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Alice syncs contacts and calendars; Bob only calendars.
|
||||
function syncing(): void {
|
||||
useDeviceSyncStore.setState({
|
||||
accounts: {
|
||||
[ALICE]: {
|
||||
contactsSelection: {},
|
||||
calendarSelection: { 'team/cal': true },
|
||||
reminderOwner: null,
|
||||
intervalSeconds: {},
|
||||
lastRun: {},
|
||||
androidAccountName: 'alice',
|
||||
enabled: { [CONTACTS_AUTHORITY]: true, [CALENDAR_AUTHORITY]: true },
|
||||
},
|
||||
[BOB]: {
|
||||
contactsSelection: {},
|
||||
calendarSelection: {},
|
||||
reminderOwner: null,
|
||||
intervalSeconds: {},
|
||||
lastRun: {},
|
||||
androidAccountName: 'bob',
|
||||
enabled: { [CALENDAR_AUTHORITY]: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let stop: (() => void) | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
await AsyncStorage.clear();
|
||||
await waitForDeviceSyncHydration();
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
h.available = true;
|
||||
cancelDeviceSyncRequests();
|
||||
useAccountStore.setState({ accounts: [account(ALICE), account(BOB)], activeAccountId: ALICE });
|
||||
syncing();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stop?.();
|
||||
stop = null;
|
||||
cancelDeviceSyncRequests();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('requestDeviceSync', () => {
|
||||
it('makes one sync of the edits and echoes of 5 seconds', async () => {
|
||||
requestDeviceSync(CONTACTS_AUTHORITY);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
requestDeviceSync(CONTACTS_AUTHORITY, ALICE);
|
||||
requestDeviceSync(CONTACTS_AUTHORITY, ALICE, { jmapAccountId: 'c', type: 'ContactCard', state: 's2' });
|
||||
expect(requestSync).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS - 2000);
|
||||
expect(requestSync).toHaveBeenCalledTimes(1);
|
||||
expect(requestSync).toHaveBeenCalledWith('alice', CONTACTS_AUTHORITY, {});
|
||||
|
||||
// The next change arms a new request.
|
||||
requestDeviceSync(CONTACTS_AUTHORITY);
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps accounts and authorities apart', async () => {
|
||||
requestDeviceSync(CONTACTS_AUTHORITY, ALICE);
|
||||
requestDeviceSync(CALENDAR_AUTHORITY, ALICE);
|
||||
requestDeviceSync(CALENDAR_AUTHORITY, BOB);
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync.mock.calls.map(([name, authority]) => `${name} ${authority}`).sort()).toEqual([
|
||||
`alice ${CALENDAR_AUTHORITY}`,
|
||||
`alice ${CONTACTS_AUTHORITY}`,
|
||||
`bob ${CALENDAR_AUTHORITY}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('asks nothing for an authority the account does not sync, or without device sync', async () => {
|
||||
requestDeviceSync(CONTACTS_AUTHORITY, BOB);
|
||||
h.available = false;
|
||||
requestDeviceSync(CALENDAR_AUTHORITY, BOB);
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops a request when sync was turned off meanwhile', async () => {
|
||||
requestDeviceSync(CONTACTS_AUTHORITY, ALICE);
|
||||
useDeviceSyncStore.getState().setEnabled(ALICE, CONTACTS_AUTHORITY, false);
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('can be cancelled', async () => {
|
||||
requestDeviceSync(CONTACTS_AUTHORITY, ALICE);
|
||||
requestDeviceSync(CALENDAR_AUTHORITY, ALICE);
|
||||
cancelDeviceSyncRequests(ALICE, CONTACTS_AUTHORITY);
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync).toHaveBeenCalledTimes(1);
|
||||
expect(requestSync).toHaveBeenCalledWith('alice', CALENDAR_AUTHORITY, {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('StateChanges of the live stream', () => {
|
||||
it('syncs the active account on a contact or calendar change', async () => {
|
||||
handleStateChange(CALENDAR_AUTHORITY, 'CalendarEvent', 'c', 'e7');
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync).toHaveBeenCalledWith('alice', CALENDAR_AUTHORITY, {});
|
||||
});
|
||||
|
||||
it('skips the echo of what the engine synced itself', async () => {
|
||||
useDeviceSyncStore.getState().recordKnownState(ALICE, 'c', 'ContactCard', 's9');
|
||||
handleStateChange(CONTACTS_AUTHORITY, 'ContactCard', 'c', 's9');
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips an echo that arrived before the engine recorded its state', async () => {
|
||||
handleStateChange(CONTACTS_AUTHORITY, 'ContactCard', 'c', 's10');
|
||||
// The engine's upload returns and records the state it moved the account to.
|
||||
useDeviceSyncStore.getState().recordKnownState(ALICE, 'c', 'ContactCard', 's10');
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still syncs when an edit in the app joined the echo', async () => {
|
||||
handleStateChange(CONTACTS_AUTHORITY, 'ContactCard', 'c', 's11');
|
||||
requestDeviceSync(CONTACTS_AUTHORITY);
|
||||
useDeviceSyncStore.getState().recordKnownState(ALICE, 'c', 'ContactCard', 's11');
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ignores shared accounts nothing is synced from, once the personal account is known', async () => {
|
||||
handleStateChange(CALENDAR_AUTHORITY, 'CalendarEvent', 'other', 'x1');
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
// Unknown yet: anything may feed the device.
|
||||
expect(requestSync).toHaveBeenCalledTimes(1);
|
||||
|
||||
useDeviceSyncStore.getState().rememberPrimaryJmapAccount(ALICE, CALENDAR_AUTHORITY, 'c');
|
||||
handleStateChange(CALENDAR_AUTHORITY, 'CalendarEvent', 'other', 'x2');
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The shared account a calendar was picked in does feed it.
|
||||
handleStateChange(CALENDAR_AUTHORITY, 'Calendar', 'team', 'x3');
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does nothing for an authority the active account does not sync', async () => {
|
||||
useAccountStore.setState({ activeAccountId: BOB });
|
||||
handleStateChange(CONTACTS_AUTHORITY, 'AddressBook', 'b', 'x');
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('foreground', () => {
|
||||
it('reconciles the accounts, then syncs everything that syncs, at most once a minute', async () => {
|
||||
await handleForeground(1_000_000);
|
||||
expect(lifecycle.reconcileDeviceAccounts).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync.mock.calls.map(([name, authority]) => `${name} ${authority}`).sort()).toEqual([
|
||||
`alice ${CALENDAR_AUTHORITY}`,
|
||||
`alice ${CONTACTS_AUTHORITY}`,
|
||||
`bob ${CALENDAR_AUTHORITY}`,
|
||||
]);
|
||||
|
||||
await handleForeground(1_000_000 + FOREGROUND_MIN_INTERVAL_MS - 1);
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync).toHaveBeenCalledTimes(3);
|
||||
|
||||
await handleForeground(1_000_000 + FOREGROUND_MIN_INTERVAL_MS);
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startDeviceSyncTriggers', () => {
|
||||
it('listens to the stream, the foreground, account changes and the preferences', async () => {
|
||||
stop = startDeviceSyncTriggers();
|
||||
// Launch counts as coming to the foreground.
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(lifecycle.reconcileDeviceAccounts).toHaveBeenCalledTimes(1);
|
||||
requestSync.mockClear();
|
||||
|
||||
dispatchStateChange({ '@type': 'StateChange', changed: { c: { AddressBook: 'a2', Email: 'm1' } } });
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(requestSync).toHaveBeenCalledWith('alice', CONTACTS_AUTHORITY, {});
|
||||
|
||||
for (const listener of h.appState) listener('background');
|
||||
for (const listener of h.appState) listener('active');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(lifecycle.reconcileDeviceAccounts).toHaveBeenCalledTimes(2);
|
||||
|
||||
for (const listener of h.accountsChanged) listener();
|
||||
expect(lifecycle.reconcileDeviceAccounts).toHaveBeenCalledTimes(3);
|
||||
|
||||
useDeviceSyncStore.getState().rememberPrimaryJmapAccount(ALICE, CONTACTS_AUTHORITY, 'c');
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(lifecycle.refreshPushRoutes).toHaveBeenCalled();
|
||||
|
||||
// Started once only.
|
||||
expect(startDeviceSyncTriggers()).toBe(stop);
|
||||
});
|
||||
|
||||
it('starts nothing without device sync', async () => {
|
||||
h.available = false;
|
||||
startDeviceSyncTriggers()();
|
||||
await vi.advanceTimersByTimeAsync(TRIGGER_DELAY_MS);
|
||||
expect(lifecycle.reconcileDeviceAccounts).not.toHaveBeenCalled();
|
||||
expect(native.onAccountsChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
// Big meetings (many attendees on many overrides): a write into such an event
|
||||
// has to fit one provider transaction (Binder: about 1 MB; a Parcel carries
|
||||
// the JSON as UTF-16, estimated at 2 bytes per character plus ~300 per op).
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Attendees, Events } from '../../android-columns';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import { contactsPlanner } from '../../contacts/planner';
|
||||
import type { OpGroup } from '../../planner';
|
||||
import { CALENDAR_AUTHORITY } from '../../types';
|
||||
import type { CalendarEventWire } from '../../wire';
|
||||
import { createHarness, rowWrites, type Harness as EngineHarness } from '../engine/harness';
|
||||
import { Harness, ME, serverApply } from './harness';
|
||||
|
||||
const TRANSACTION = 1_000_000;
|
||||
const estimate = (group: OpGroup) => 2 * JSON.stringify(group.ops).length + 300 * group.ops.length;
|
||||
/** A plan's groups: the group and the ones applied after it. */
|
||||
const chain = (group: OpGroup): OpGroup[] => [group, ...(group.next ?? [])];
|
||||
|
||||
/** A daily meeting with `attendees` guests whose `overrides` occurrences each carry the whole participants map. */
|
||||
function meeting(attendees: number, overrides: number, me = ME, calendarId = 'b'): CalendarEventWire {
|
||||
const participants: Record<string, unknown> = {
|
||||
me: { '@type': 'Participant', calendarAddress: `mailto:${me}`, roles: { owner: true, attendee: true }, participationStatus: 'accepted' },
|
||||
};
|
||||
for (let i = 0; i < attendees; i++) {
|
||||
participants[`p${i}`] = { '@type': 'Participant', calendarAddress: `mailto:person${i}@example.com`, name: `Person ${i}`, roles: { attendee: true }, participationStatus: 'needs-action', expectReply: true };
|
||||
}
|
||||
const recurrenceOverrides: Record<string, unknown> = {};
|
||||
for (let i = 0; i < overrides; i++) {
|
||||
const day = new Date(Date.UTC(2026, 9, 6) + i * 86_400_000).toISOString().slice(0, 10);
|
||||
recurrenceOverrides[`${day}T09:00:00`] = { title: `Standup #${i}`, participants };
|
||||
}
|
||||
return {
|
||||
id: 'big',
|
||||
'@type': 'Event',
|
||||
uid: 'big@example.org',
|
||||
calendarIds: { [calendarId]: true },
|
||||
title: 'Standup',
|
||||
description: 'Daily standup',
|
||||
start: '2026-10-05T09:00:00',
|
||||
timeZone: 'Europe/Berlin',
|
||||
duration: 'PT15M',
|
||||
recurrenceRule: { frequency: 'daily', count: 300 },
|
||||
organizerCalendarAddress: `mailto:${me}`,
|
||||
participants,
|
||||
recurrenceOverrides,
|
||||
} as unknown as CalendarEventWire;
|
||||
}
|
||||
|
||||
/** The engine with the real planners (the fake provider refuses a batch over one Binder transaction, as Android does). */
|
||||
function engineWithRealPlanners(): EngineHarness {
|
||||
const h = createHarness();
|
||||
h.deps.planners = { contacts: contactsPlanner, calendar: calendarPlanner };
|
||||
return h;
|
||||
}
|
||||
|
||||
/** The big meeting's rows on the engine's device: one master, one exception row per override, every attendee once. */
|
||||
function expectWholeMeeting(h: EngineHarness, id: string, attendees: number, overrides: number, context = ''): void {
|
||||
const rows = h.events();
|
||||
const masters = rows.filter((e) => e[Events._SYNC_ID] === `a/${id}`);
|
||||
expect(masters, context).toHaveLength(1);
|
||||
const exceptions = rows.filter((e) => e[Events.ORIGINAL_SYNC_ID] === `a/${id}`);
|
||||
expect(exceptions, context).toHaveLength(overrides);
|
||||
expect(new Set(exceptions.map((x) => x[Events._SYNC_ID])).size, context).toBe(overrides);
|
||||
expect(exceptions.every((x) => Number(x[Events.ORIGINAL_ID]) === Number(masters[0]._id)), context).toBe(true);
|
||||
const attendeesOf = (eventId: unknown) => h.device.rows('attendees').filter((a) => Number(a.event_id) === Number(eventId)).length;
|
||||
expect([masters[0], ...exceptions].every((row) => attendeesOf(row._id) === attendees + 1), context).toBe(true);
|
||||
expect(Object.keys(JSON.parse(String(masters[0][Events.SYNC_DATA1])).recurrenceOverrides), context).toHaveLength(overrides);
|
||||
expect(rows.filter((e) => e[Events.ORIGINAL_SYNC_ID] === null), context).toHaveLength(1);
|
||||
}
|
||||
|
||||
async function synced(event: CalendarEventWire) {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(event);
|
||||
return { h, id: Number(h.masterRow(event.id)._id) };
|
||||
}
|
||||
|
||||
describe('calendar planner: big meetings', () => {
|
||||
it('fits the writes after an edit of a big meeting into one transaction', async () => {
|
||||
const event = meeting(25, 52);
|
||||
const { h, id } = await synced(event);
|
||||
// Only the series row is edited; its 52 occurrences with 26 attendees each stay clean.
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Daily standup' });
|
||||
const local = (await h.local('big'))!;
|
||||
|
||||
const plan = calendarPlanner.planUpload(local, h.ctx);
|
||||
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'update') throw new Error(plan.kind);
|
||||
const server = serverApply(event, plan.actions[0].patch);
|
||||
const accepted = calendarPlanner.planAccepted(local, server, h.ctx);
|
||||
expect(estimate(accepted.ops)).toBeLessThan(TRANSACTION);
|
||||
expect(estimate(accepted.keepDirtyOps)).toBeLessThan(TRANSACTION);
|
||||
|
||||
// A download into the edited event (the server changed one occurrence meanwhile).
|
||||
const remote = { ...event, recurrenceOverrides: { ...event.recurrenceOverrides, '2026-10-09T09:00:00': { title: 'Moved to Friday' } } } as CalendarEventWire;
|
||||
expect(estimate(calendarPlanner.planDownload(remote, local, h.ctx).ops)).toBeLessThan(TRANSACTION);
|
||||
|
||||
// A save that changed nothing mapped is cleared the same way.
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Standup' });
|
||||
const clean = calendarPlanner.planUpload((await h.local('big'))!, h.ctx);
|
||||
expect(clean.kind).toBe('clean');
|
||||
if (clean.kind === 'clean') expect(estimate(clean.ops)).toBeLessThan(TRANSACTION);
|
||||
}, 60_000);
|
||||
|
||||
it('still fails a write when an app changed a clean occurrence\'s attendees meanwhile', async () => {
|
||||
const event = meeting(3, 4);
|
||||
const { h, id } = await synced(event);
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Daily standup' });
|
||||
const local = (await h.local('big'))!;
|
||||
const x = local.exceptions[0];
|
||||
const plan = calendarPlanner.planUpload(local, h.ctx);
|
||||
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'update') throw new Error(plan.kind);
|
||||
const accepted = calendarPlanner.planAccepted(local, serverApply(event, plan.actions[0].patch), h.ctx);
|
||||
// The occurrence's attendees are not asserted one by one: CalendarProvider marks the event DIRTY on any app write to them.
|
||||
h.fake.beforeNextBatch(() => h.fake.user.setAttendeeStatus(x.eventId, 'person1@example.com', 1));
|
||||
expect(await h.tryApply(accepted.ops)).toMatchObject({ ok: false, reason: 'assert' });
|
||||
expect(h.fake.rows('attendees', `${Attendees.EVENT_ID} = ? AND ${Attendees.ATTENDEE_EMAIL} = ?`, [x.eventId, 'person1@example.com'])[0][Attendees.ATTENDEE_STATUS]).toBe(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[25, 52],
|
||||
[100, 12],
|
||||
])('writes the first download of %i attendees on %i overrides in groups that each fit one transaction', async (attendees, overrides) => {
|
||||
const h = await new Harness().setup();
|
||||
const event = meeting(attendees, overrides);
|
||||
|
||||
const plan = calendarPlanner.planDownload(event, null, h.ctx);
|
||||
|
||||
expect(plan.effect).toBe('insert');
|
||||
expect(chain(plan.ops).length).toBeGreaterThan(1);
|
||||
for (const group of chain(plan.ops)) expect(estimate(group)).toBeLessThan(TRANSACTION);
|
||||
await h.apply(plan.ops);
|
||||
const local = (await h.local('big'))!;
|
||||
expect(local.shadow).toEqual(event);
|
||||
expect(local.exceptions.map((x) => x.recurrenceId).sort()).toEqual(Object.keys(event.recurrenceOverrides!).sort());
|
||||
expect(local.exceptions.every((x) => x.attendees.length === attendees + 1 && !x.dirty)).toBe(true);
|
||||
// Its echo writes nothing.
|
||||
expect(calendarPlanner.planDownload(event, local, h.ctx).effect).toBe('none');
|
||||
}, 60_000);
|
||||
|
||||
it('finishes a first download cut off between its groups, without writing a row twice', async () => {
|
||||
const event = meeting(25, 52);
|
||||
const groups = chain(calendarPlanner.planDownload(event, null, (await new Harness().setup()).ctx).ops).length;
|
||||
for (let cut = 1; cut < groups; cut++) {
|
||||
const h = await new Harness().setup();
|
||||
for (const group of chain(calendarPlanner.planDownload(event, null, h.ctx).ops).slice(0, cut)) await h.apply({ ref: group.ref, ops: group.ops });
|
||||
|
||||
// Planned again from what the first groups wrote.
|
||||
const again = calendarPlanner.planDownload(event, await h.local('big'), h.ctx);
|
||||
for (const group of chain(again.ops)) expect(estimate(group)).toBeLessThan(TRANSACTION);
|
||||
await h.apply(again.ops);
|
||||
|
||||
const local = (await h.local('big'))!;
|
||||
expect(local.shadow, `cut after ${cut}`).toEqual(event);
|
||||
expect(new Set(local.exceptions.map((x) => x.recurrenceId)).size, `cut after ${cut}`).toBe(52);
|
||||
expect(local.exceptions, `cut after ${cut}`).toHaveLength(52);
|
||||
expect(local.exceptions.every((x) => x.attendees.length === 26), `cut after ${cut}`).toBe(true);
|
||||
expect(calendarPlanner.planDownload(event, local, h.ctx).effect, `cut after ${cut}`).toBe('none');
|
||||
}
|
||||
// Every cut point writes a 2 MB meeting again.
|
||||
}, 60_000);
|
||||
|
||||
it('writes a change of every occurrence in groups, the series with its new shadow last', async () => {
|
||||
const h = await new Harness().setup();
|
||||
const event = meeting(25, 52);
|
||||
await h.download(event);
|
||||
// Another client adds a guest to the meeting and to every occurrence.
|
||||
const guest = { '@type': 'Participant', calendarAddress: 'mailto:guest@example.com', name: 'Guest', roles: { attendee: true }, participationStatus: 'needs-action' };
|
||||
const changed = JSON.parse(JSON.stringify(event)) as CalendarEventWire;
|
||||
(changed.participants as Record<string, unknown>).guest = guest;
|
||||
for (const override of Object.values(changed.recurrenceOverrides!)) (override as { participants: Record<string, unknown> }).participants.guest = guest;
|
||||
|
||||
const plan = calendarPlanner.planDownload(changed, await h.local('big'), h.ctx);
|
||||
|
||||
const groups = chain(plan.ops);
|
||||
expect(groups.length).toBeGreaterThan(1);
|
||||
for (const group of groups) expect(estimate(group)).toBeLessThan(TRANSACTION);
|
||||
const writesShadow = (group: OpGroup) => group.ops.some((op) => op.op === 'update' && Events.SYNC_DATA1 in op.values);
|
||||
expect(groups.map(writesShadow)).toEqual(groups.map((_, i) => i === groups.length - 1));
|
||||
// Cut off after the first group: planned again from what it wrote, the rest follows.
|
||||
await h.apply({ ref: groups[0].ref, ops: groups[0].ops });
|
||||
await h.apply(calendarPlanner.planDownload(changed, await h.local('big'), h.ctx).ops);
|
||||
const local = (await h.local('big'))!;
|
||||
expect(local.shadow).toEqual(changed);
|
||||
expect(local.exceptions.every((x) => x.attendees.length === 27)).toBe(true);
|
||||
expect(calendarPlanner.planDownload(changed, local, h.ctx).effect).toBe('none');
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('device sync engine: big meetings', () => {
|
||||
it.each([
|
||||
[25, 52],
|
||||
[100, 12],
|
||||
])('downloads a meeting of %i attendees on %i overrides in several transactions', async (attendees, overrides) => {
|
||||
const h = engineWithRealPlanners();
|
||||
const { id: _drop, ...event } = meeting(attendees, overrides, 'alice@example.com', h.calendar) as unknown as Record<string, unknown>;
|
||||
const id = h.server.addEvent('a', event);
|
||||
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { downloaded: { created: 1 } } });
|
||||
expectWholeMeeting(h, id, attendees, overrides);
|
||||
const before = h.batches.log.length;
|
||||
expect((await h.run(CALENDAR_AUTHORITY)).outcome).toBe('ok');
|
||||
expect(rowWrites(h.batches.log.slice(before))).toEqual([]);
|
||||
}, 60_000);
|
||||
|
||||
it('finishes a big meeting cut off between its groups by a crash, without duplicating rows', async () => {
|
||||
const setup = () => {
|
||||
const h = engineWithRealPlanners();
|
||||
const { id: _drop, ...event } = meeting(25, 52, 'alice@example.com', h.calendar) as unknown as Record<string, unknown>;
|
||||
return { h, id: h.server.addEvent('a', event) };
|
||||
};
|
||||
const probe = setup();
|
||||
await probe.h.run(CALENDAR_AUTHORITY);
|
||||
const batches = probe.h.batches.applied;
|
||||
|
||||
for (let n = 1; n < batches; n++) {
|
||||
const { h, id } = setup();
|
||||
h.batches.crashAfter = n;
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
h.batches.crashAfter = null;
|
||||
expect((await h.run(CALENDAR_AUTHORITY)).outcome, `crash after batch ${n}`).toBe('ok');
|
||||
expectWholeMeeting(h, id, 25, 52, `crash after batch ${n}`);
|
||||
}
|
||||
// Two runs of a 2 MB meeting per crash point.
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CalendarAccess, Calendars } from '../../android-columns';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import { CAN_PARTIALLY_UPDATE } from '../../calendar/columns';
|
||||
import { cssColorToArgb } from '../../calendar/values';
|
||||
import { BIRTHDAY_CALENDAR_ID } from '../../../lib/birthday-calendar';
|
||||
import type { CalendarLike } from '../../wire';
|
||||
import { ACCT, CALENDARS, Harness, ME } from './harness';
|
||||
|
||||
const personal = (calendar: CalendarLike, readOnly = false) => ({ jmapAccountId: ACCT, calendar, readOnly, accountName: ME });
|
||||
|
||||
describe('calendar planner: calendar rows', () => {
|
||||
it('inserts every column the provider and the calendar apps need', async () => {
|
||||
const h = await new Harness().setup(['b']);
|
||||
const [row] = await h.calendars();
|
||||
expect(row.syncId).toBe(`${ACCT}/b`);
|
||||
expect(row.cells).toMatchObject({
|
||||
[Calendars.NAME]: 'Personal',
|
||||
[Calendars.CALENDAR_DISPLAY_NAME]: 'Personal',
|
||||
[Calendars.CALENDAR_COLOR]: cssColorToArgb('#3b82f6'),
|
||||
[Calendars.CALENDAR_ACCESS_LEVEL]: CalendarAccess.OWNER,
|
||||
[Calendars.OWNER_ACCOUNT]: ME,
|
||||
[Calendars.SYNC_EVENTS]: 1,
|
||||
[Calendars.VISIBLE]: 1,
|
||||
[Calendars.CALENDAR_TIME_ZONE]: 'Europe/Berlin',
|
||||
[Calendars.ALLOWED_REMINDERS]: '1,2',
|
||||
[Calendars.ALLOWED_AVAILABILITY]: '0,1',
|
||||
[Calendars.ALLOWED_ATTENDEE_TYPES]: '0,1,2,3',
|
||||
[Calendars.MAX_REMINDERS]: 5,
|
||||
[Calendars.CAN_ORGANIZER_RESPOND]: 0,
|
||||
[CAN_PARTIALLY_UPDATE]: 0,
|
||||
});
|
||||
// CAL_SYNC1 would become the `feed` extra of every sync the provider requests for the calendar.
|
||||
expect(h.fake.row('calendars', row.calendarRowId)).toMatchObject({ [Calendars.CAL_SYNC1]: null, [Calendars.DIRTY]: 0 });
|
||||
expect(row.shadow).toEqual(CALENDARS.b);
|
||||
expect(row.flags).toEqual({});
|
||||
});
|
||||
|
||||
it('writes nothing when nothing changed, and only what changed otherwise', async () => {
|
||||
const h = await new Harness().setup(['b']);
|
||||
const local = await h.calendars();
|
||||
expect(calendarPlanner.planCalendars([personal(CALENDARS.b)], local, h.ctx)).toEqual({ groups: [], deleteCalendarRows: [] });
|
||||
const renamed = { ...CALENDARS.b, name: 'Private', color: 'tomato' } as CalendarLike;
|
||||
const plan = calendarPlanner.planCalendars([personal(renamed)], local, h.ctx);
|
||||
expect(plan.groups).toHaveLength(1);
|
||||
const [op] = plan.groups[0].ops;
|
||||
expect(op).toMatchObject({ op: 'update', table: 'calendars', id: local[0].calendarRowId, expectCount: 1 });
|
||||
expect(Object.keys((op as { values: object }).values).sort()).toEqual(
|
||||
[Calendars.NAME, Calendars.CALENDAR_DISPLAY_NAME, Calendars.CALENDAR_COLOR, Calendars.CAL_SYNC2].sort(),
|
||||
);
|
||||
// VISIBLE is the user's once the row exists.
|
||||
expect((op as { values: object }).values).not.toHaveProperty(Calendars.VISIBLE);
|
||||
});
|
||||
|
||||
it('keeps VISIBLE as the user set it', async () => {
|
||||
const h = await new Harness().setup(['b']);
|
||||
const [row] = await h.calendars();
|
||||
await h.apply({ ref: 'x', ops: [{ op: 'update', table: 'calendars', id: row.calendarRowId, values: { [Calendars.VISIBLE]: 0 } }] });
|
||||
expect(calendarPlanner.planCalendars([personal(CALENDARS.b)], await h.calendars(), h.ctx).groups).toEqual([]);
|
||||
});
|
||||
|
||||
it('maps rights to access levels, and a writable read-only calendar is a subscribed feed', () => {
|
||||
const level = (rights: CalendarLike['myRights'], readOnly = false) => {
|
||||
const plan = calendarPlanner.planCalendars([personal({ id: 'z', name: 'Z', myRights: rights } as CalendarLike, readOnly)], [], new Harness().ctx);
|
||||
const values = (plan.groups[0].ops[0] as { values: Record<string, unknown> }).values;
|
||||
return [values[Calendars.CALENDAR_ACCESS_LEVEL], JSON.parse(String(values[Calendars.CAL_SYNC3]))];
|
||||
};
|
||||
expect(level({ mayWriteAll: true, mayWriteOwn: true })).toEqual([CalendarAccess.OWNER, {}]);
|
||||
expect(level({ mayWriteOwn: true })).toEqual([CalendarAccess.CONTRIBUTOR, {}]);
|
||||
expect(level({ mayRSVP: true }, true)).toEqual([CalendarAccess.RESPOND, { readOnly: 'rights' }]);
|
||||
expect(level({ mayReadItems: true }, true)).toEqual([CalendarAccess.READ, { readOnly: 'rights' }]);
|
||||
expect(level({ mayWriteAll: true }, true)).toEqual([CalendarAccess.READ, { readOnly: 'subscription' }]);
|
||||
});
|
||||
|
||||
it('offers no reminder editor when Bulwark owns reminders', async () => {
|
||||
const h = await new Harness({ reminderOwner: 'bulwark' }).setup(['b']);
|
||||
expect((await h.calendars())[0].cells[Calendars.MAX_REMINDERS]).toBe(0);
|
||||
h.ctx = { ...h.ctx, reminderOwner: 'device' };
|
||||
const plan = calendarPlanner.planCalendars([personal(CALENDARS.b)], await h.calendars(), h.ctx);
|
||||
expect((plan.groups[0].ops[0] as { values: object }).values).toEqual({ [Calendars.MAX_REMINDERS]: 5 });
|
||||
});
|
||||
|
||||
it('names shared accounts\' calendars after their account and colours calendars without a colour', () => {
|
||||
const ctx = new Harness().ctx;
|
||||
const plan = calendarPlanner.planCalendars(
|
||||
[{ jmapAccountId: 'd', calendar: { id: 'x', name: 'Team', myRights: { mayWriteAll: true } } as CalendarLike, readOnly: false, accountName: 'Bob Builder' }],
|
||||
[],
|
||||
ctx,
|
||||
);
|
||||
const values = (plan.groups[0].ops[0] as { values: Record<string, unknown> }).values;
|
||||
expect(values[Calendars._SYNC_ID]).toBe('d/x');
|
||||
expect(values[Calendars.CALENDAR_DISPLAY_NAME]).toBe('Team (Bob Builder)');
|
||||
expect(typeof values[Calendars.CALENDAR_COLOR]).toBe('number');
|
||||
expect(values[Calendars.OWNER_ACCOUNT]).toBe(ME);
|
||||
});
|
||||
|
||||
it('deletes rows of deselected calendars and never syncs the birthday calendar', async () => {
|
||||
const h = await new Harness().setup(['b', 'w']);
|
||||
const local = await h.calendars();
|
||||
const plan = calendarPlanner.planCalendars(
|
||||
[personal(CALENDARS.b), personal({ id: BIRTHDAY_CALENDAR_ID, name: 'Birthdays' } as CalendarLike)],
|
||||
local,
|
||||
h.ctx,
|
||||
);
|
||||
expect(plan.groups).toEqual([]);
|
||||
expect(plan.deleteCalendarRows).toEqual([local.find((c) => c.syncId === `${ACCT}/w`)!.calendarRowId]);
|
||||
});
|
||||
|
||||
it('reads calendar rows defensively', () => {
|
||||
const cal = calendarPlanner.decodeCalendar({ _id: '4', _sync_id: 'c/b', cal_sync2: 'garbage', cal_sync3: '{"readOnly":"subscription","taskOnly":true}' });
|
||||
expect(cal).toMatchObject({ calendarRowId: 4, syncId: 'c/b', shadow: null, flags: { readOnly: 'subscription', taskOnly: true } });
|
||||
expect(calendarPlanner.decodeCalendar({ _id: 5, _sync_id: null, cal_sync3: '[1]' }).flags).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Attendees, Calendars, Events, Reminders } from '../../android-columns';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import { cssColorToArgb } from '../../calendar/values';
|
||||
import { ACCT, CALENDARS, Harness, ME, single, utc, weekly } from './harness';
|
||||
import type { CalendarEventWire } from '../../wire';
|
||||
|
||||
describe('calendar planner: downloads', () => {
|
||||
it('writes a recurring event with its exception, exclusion, attendees and reminders', async () => {
|
||||
const h = await new Harness().setup();
|
||||
const plan = await h.download(weekly());
|
||||
expect(plan.effect).toBe('insert');
|
||||
const master = h.masterRow('bl');
|
||||
expect(master).toMatchObject({
|
||||
[Events.TITLE]: 'Probe weekly',
|
||||
[Events.DESCRIPTION]: 'desc',
|
||||
[Events.EVENT_LOCATION]: 'Room 1',
|
||||
[Events.STATUS]: 0,
|
||||
[Events.AVAILABILITY]: 1,
|
||||
[Events.ACCESS_LEVEL]: 2,
|
||||
[Events.EVENT_COLOR]: cssColorToArgb('steelblue'),
|
||||
[Events.DTSTART]: utc('2026-10-05T13:00:00Z'),
|
||||
[Events.DTEND]: null,
|
||||
[Events.DURATION]: 'P5400S',
|
||||
[Events.EVENT_TIMEZONE]: 'America/New_York',
|
||||
[Events.ALL_DAY]: 0,
|
||||
[Events.RRULE]: 'FREQ=WEEKLY;COUNT=10;BYDAY=MO,WE',
|
||||
[Events.EXDATE]: '20261012T130000Z',
|
||||
[Events.RDATE]: null,
|
||||
[Events.UID_2445]: 'probe-rec@example.org',
|
||||
[Events.ORGANIZER]: ME,
|
||||
[Events.HAS_ATTENDEE_DATA]: 1,
|
||||
[Events.DIRTY]: 0,
|
||||
});
|
||||
expect(JSON.parse(String(master[Events.SYNC_DATA1]))).toEqual(weekly());
|
||||
expect(master[Events.SELF_ATTENDEE_STATUS] ?? null).toBeNull();
|
||||
|
||||
const exceptions = h.fake.rows('events', `${Events.ORIGINAL_SYNC_ID} = ?`, [`${ACCT}/bl`]);
|
||||
expect(exceptions).toHaveLength(1);
|
||||
expect(exceptions[0]).toMatchObject({
|
||||
[Events._SYNC_ID]: `${ACCT}/bl#2026-10-07T09:00:00`,
|
||||
[Events.SYNC_DATA2]: '2026-10-07T09:00:00',
|
||||
[Events.ORIGINAL_ID]: master._id,
|
||||
// Exact to the millisecond, or the provider shows the original instance as well.
|
||||
[Events.ORIGINAL_INSTANCE_TIME]: utc('2026-10-07T13:00:00Z'),
|
||||
[Events.ORIGINAL_ALL_DAY]: 0,
|
||||
[Events.TITLE]: 'Moved Wednesday',
|
||||
[Events.DTSTART]: utc('2026-10-07T15:00:00Z'),
|
||||
[Events.DTEND]: utc('2026-10-07T16:30:00Z'),
|
||||
[Events.DURATION]: null,
|
||||
[Events.RRULE]: null,
|
||||
[Events.STATUS]: 0,
|
||||
});
|
||||
|
||||
const attendees = h.fake.rows('attendees', `${Attendees.EVENT_ID} = ?`, [Number(master._id)]);
|
||||
expect(attendees.map((a) => [a[Attendees.ATTENDEE_EMAIL], a[Attendees.ATTENDEE_RELATIONSHIP], a[Attendees.ATTENDEE_STATUS]])).toEqual([
|
||||
[ME, 2, 1],
|
||||
['guest@example.net', 1, 3],
|
||||
]);
|
||||
const reminders = h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [Number(master._id)]);
|
||||
// Only the start-relative offset alert has a Reminders row; the others stay on the server.
|
||||
expect(reminders.map((r) => [r[Reminders.MINUTES], r[Reminders.METHOD]])).toEqual([[15, 1]]);
|
||||
});
|
||||
|
||||
it('writes nothing for an echo of what the rows already hold', async () => {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(weekly());
|
||||
const batches = h.fake.batches.length;
|
||||
const plan = calendarPlanner.planDownload(weekly(), await h.local('bl'), h.ctx);
|
||||
expect(plan).toMatchObject({ effect: 'none', writes: 0, conflicts: 0, stillDirty: false });
|
||||
expect(plan.ops.ops).toEqual([]);
|
||||
expect(h.fake.batches.length).toBe(batches);
|
||||
});
|
||||
|
||||
it('writes only what the server changed, behind asserts', async () => {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(weekly());
|
||||
const plan = await h.download({ ...weekly(), title: 'Renamed', updated: '2026-09-27T08:00:00Z' });
|
||||
expect(plan.effect).toBe('update');
|
||||
const ops = plan.ops.ops;
|
||||
const firstWrite = ops.findIndex((op) => op.op !== 'assert');
|
||||
expect(ops.slice(0, firstWrite).every((op) => op.op === 'assert')).toBe(true);
|
||||
expect(ops.slice(firstWrite).every((op) => op.op !== 'assert')).toBe(true);
|
||||
const updates = ops.filter((op) => op.op === 'update');
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(Object.keys((updates[0] as { values: object }).values).sort()).toEqual([Events.SYNC_DATA1, Events.SYNC_DATA4, Events.TITLE].sort());
|
||||
expect(h.masterRow('bl')[Events.TITLE]).toBe('Renamed');
|
||||
});
|
||||
|
||||
it('updates only the shadow when the server changed a property the device does not show', async () => {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(weekly());
|
||||
const plan = await h.download({ ...weekly(), priority: 1, keywords: { other: true } });
|
||||
expect(plan).toMatchObject({ effect: 'update', writes: 1 });
|
||||
expect(JSON.parse(String(h.masterRow('bl')[Events.SYNC_DATA1])).priority).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps floating events on the wall clock of the device zone', async () => {
|
||||
const h = await new Harness({ deviceZone: 'America/New_York' }).setup();
|
||||
await h.download(single({ id: 'f1', timeZone: null, start: '2026-10-06T14:00:00', duration: 'PT45M' }));
|
||||
expect(h.masterRow('f1')).toMatchObject({
|
||||
[Events.EVENT_TIMEZONE]: 'America/New_York',
|
||||
[Events.DTSTART]: utc('2026-10-06T18:00:00Z'),
|
||||
[Events.DTEND]: utc('2026-10-06T18:45:00Z'),
|
||||
});
|
||||
});
|
||||
|
||||
it('writes a timed event in its own zone, not the device one', async () => {
|
||||
const h = await new Harness({ deviceZone: 'Europe/Berlin' }).setup();
|
||||
await h.download(single({ id: 'tk', timeZone: 'Asia/Tokyo', start: '2026-10-06T09:00:00' }));
|
||||
expect(h.masterRow('tk')).toMatchObject({ [Events.EVENT_TIMEZONE]: 'Asia/Tokyo', [Events.DTSTART]: utc('2026-10-06T00:00:00Z') });
|
||||
});
|
||||
|
||||
it('writes all-day events as UTC midnights, multi-day and in weeks too', async () => {
|
||||
const h = await new Harness({ deviceZone: 'Pacific/Auckland' }).setup();
|
||||
await h.download(single({ id: 'ad', start: '2026-10-08T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P2D' }));
|
||||
expect(h.masterRow('ad')).toMatchObject({
|
||||
[Events.ALL_DAY]: 1,
|
||||
[Events.EVENT_TIMEZONE]: 'UTC',
|
||||
[Events.DTSTART]: utc('2026-10-08T00:00:00Z'),
|
||||
[Events.DTEND]: utc('2026-10-10T00:00:00Z'),
|
||||
});
|
||||
// CalDAV DTENDs arrive as weeks.
|
||||
await h.download(single({ id: 'wk', start: '2026-10-12T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P1W' }));
|
||||
expect(h.masterRow('wk')[Events.DTEND]).toBe(utc('2026-10-19T00:00:00Z'));
|
||||
// A recurring all-day event gets a DURATION in days (the provider throws on seconds).
|
||||
await h.download(single({ id: 'ar', start: '2026-10-12T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P1D', recurrenceRule: { frequency: 'yearly' } }));
|
||||
expect(h.masterRow('ar')).toMatchObject({ [Events.DURATION]: 'P1D', [Events.DTEND]: null, [Events.RRULE]: 'FREQ=YEARLY' });
|
||||
// A time part on an all-day duration rounds up.
|
||||
await h.download(single({ id: 'ap', start: '2026-10-12T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P1DT2H' }));
|
||||
expect(h.masterRow('ap')[Events.DTEND]).toBe(utc('2026-10-14T00:00:00Z'));
|
||||
});
|
||||
|
||||
it('writes the calendar defaults as reminders when the event uses them', async () => {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(single({ id: 'da', useDefaultAlerts: true, alerts: { own: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT1H' } } } }));
|
||||
const master = h.masterRow('da');
|
||||
const reminders = h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [Number(master._id)]);
|
||||
expect(reminders.map((r) => r[Reminders.MINUTES])).toEqual([10]);
|
||||
});
|
||||
|
||||
it('writes no reminders when Bulwark owns them, and leaves the ones an app adds', async () => {
|
||||
const h = await new Harness({ reminderOwner: 'bulwark' }).setup();
|
||||
const cal = (await h.calendars()).find((c) => c.syncId === `${ACCT}/b`)!;
|
||||
expect(cal.cells[Calendars.MAX_REMINDERS]).toBe(0);
|
||||
await h.download(weekly());
|
||||
const master = h.masterRow('bl');
|
||||
expect(h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [Number(master._id)])).toHaveLength(0);
|
||||
h.fake.user.setReminders(Number(master._id), [{ minutes: 5 }]);
|
||||
// Neither uploaded nor deleted: the edit only clears DIRTY.
|
||||
const upload = calendarPlanner.planUpload((await h.local('bl'))!, h.ctx);
|
||||
expect(upload.kind).toBe('clean');
|
||||
if (upload.kind === 'clean') await h.apply(upload.ops);
|
||||
await h.download({ ...weekly(), title: 'Again' });
|
||||
expect(h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [Number(master._id)])).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('normalises the user\'s own address, under any alias, to OWNER_ACCOUNT', async () => {
|
||||
const h = await new Harness().setup();
|
||||
const event = single({
|
||||
id: 'inv',
|
||||
organizerCalendarAddress: 'mailto:boss@example.net',
|
||||
participants: {
|
||||
boss: { calendarAddress: 'mailto:boss@example.net', roles: { owner: true } },
|
||||
me: { calendarAddress: 'mailto:ALIAS@Example.org', roles: { attendee: true }, participationStatus: 'tentative' },
|
||||
room: { calendarAddress: 'mailto:room@example.net', kind: 'resource', roles: { attendee: true } },
|
||||
opt: { calendarAddress: 'mailto:opt@example.net', roles: { attendee: true, optional: true }, participationStatus: 'declined' },
|
||||
noMail: { name: 'Phone only', roles: { attendee: true } },
|
||||
},
|
||||
} as Partial<CalendarEventWire>);
|
||||
await h.download(event);
|
||||
const master = h.masterRow('inv');
|
||||
expect(master[Events.ORGANIZER]).toBe('boss@example.net');
|
||||
const rows = h.fake.rows('attendees', `${Attendees.EVENT_ID} = ?`, [Number(master._id)]);
|
||||
expect(rows.map((a) => [a[Attendees.ATTENDEE_EMAIL], a[Attendees.ATTENDEE_RELATIONSHIP], a[Attendees.ATTENDEE_TYPE], a[Attendees.ATTENDEE_STATUS]])).toEqual([
|
||||
['boss@example.net', 2, 1, 3],
|
||||
[ME, 1, 1, 4],
|
||||
['room@example.net', 1, 3, 3],
|
||||
['opt@example.net', 1, 2, 2],
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves ORGANIZER to the provider for events without participants', async () => {
|
||||
const h = await new Harness().setup();
|
||||
const plan = await h.download(single());
|
||||
const insert = plan.ops.ops.find((op) => op.op === 'insert' && op.table === 'events') as { values: Record<string, unknown> };
|
||||
expect(Events.ORGANIZER in insert.values).toBe(false);
|
||||
expect(Events.SELF_ATTENDEE_STATUS in insert.values).toBe(false);
|
||||
expect(h.masterRow('e1')[Events.ORGANIZER]).toBe(ME);
|
||||
// The baseline predicted the provider's value, so there is nothing to heal and an echo writes nothing.
|
||||
expect(calendarPlanner.planBaselineHeal((await h.local('e1'))!)).toBeNull();
|
||||
expect(calendarPlanner.planDownload(single(), await h.local('e1'), h.ctx).effect).toBe('none');
|
||||
});
|
||||
|
||||
it('never writes a NULL STATUS', async () => {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(single({ status: undefined }));
|
||||
expect(h.masterRow('e1')[Events.STATUS]).toBe(1);
|
||||
await h.download(single({ status: 'cancelled' }));
|
||||
expect(h.masterRow('e1')[Events.STATUS]).toBe(2);
|
||||
});
|
||||
|
||||
it('removes and adds exception rows as overrides come and go', async () => {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(weekly());
|
||||
const next = weekly();
|
||||
next.recurrenceOverrides = {
|
||||
'2026-10-12T09:00:00': { excluded: true },
|
||||
'2026-10-14T09:00:00': { excluded: true },
|
||||
'2026-10-19T09:00:00': { title: 'Other' },
|
||||
};
|
||||
const plan = await h.download(next);
|
||||
expect(plan.effect).toBe('update');
|
||||
const exceptions = h.fake.rows('events', `${Events.ORIGINAL_SYNC_ID} = ?`, [`${ACCT}/bl`]);
|
||||
expect(exceptions.map((x) => x[Events.SYNC_DATA2])).toEqual(['2026-10-19T09:00:00']);
|
||||
expect(exceptions[0][Events.TITLE]).toBe('Other');
|
||||
expect(h.masterRow('bl')[Events.EXDATE]).toBe('20261012T130000Z,20261014T130000Z');
|
||||
});
|
||||
|
||||
it('replaces the rows of a different object under a reused id', async () => {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(weekly());
|
||||
const other = single({ id: 'bl', uid: 'other-uid', title: 'Someone else' });
|
||||
const plan = await h.download(other);
|
||||
expect(plan.effect).toBe('update');
|
||||
const events = await h.events();
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({ syncId: `${ACCT}/bl`, exceptions: [] });
|
||||
expect(events[0].cells[Events.TITLE]).toBe('Someone else');
|
||||
});
|
||||
|
||||
it('refuses tasks', async () => {
|
||||
const h = await new Harness().setup();
|
||||
expect(() => calendarPlanner.planDownload({ ...single(), '@type': 'Task' } as CalendarEventWire, null, h.ctx)).toThrow(/tasks/);
|
||||
expect(() => calendarPlanner.planDownload({ ...single(), '@type': undefined, due: '2026-10-01T00:00:00' } as unknown as CalendarEventWire, null, h.ctx)).toThrow(/tasks/);
|
||||
});
|
||||
|
||||
it('merges an edited event another client moved out of every synced calendar where its rows are', async () => {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(single());
|
||||
const id = Number(h.masterRow('e1')._id);
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Team lunch' });
|
||||
// Filed in a calendar this device does not sync, with a new description.
|
||||
const moved = single({ calendarIds: { archive: true }, description: 'Bring salad' });
|
||||
|
||||
const plan = calendarPlanner.planDownload(moved, await h.local('e1'), h.ctx);
|
||||
await h.apply(plan.ops);
|
||||
|
||||
expect(plan).toMatchObject({ effect: 'update', stillDirty: true, conflicts: 0 });
|
||||
expect(h.masterRow('e1')).toMatchObject({ [Events.CALENDAR_ID]: h.rowIds.get('b'), [Events.TITLE]: 'Team lunch', [Events.DESCRIPTION]: 'Bring salad', [Events.DIRTY]: 1 });
|
||||
const local = (await h.local('e1'))!;
|
||||
expect(local.shadow).toEqual(moved);
|
||||
// Only the device's edit goes up (no calendar move), and the accepted version takes the rows away.
|
||||
const plan2 = calendarPlanner.planUpload(local, h.ctx);
|
||||
expect(plan2.kind === 'upload' && plan2.actions).toEqual([{ kind: 'update', id: 'e1', patch: { title: 'Team lunch' } }]);
|
||||
await h.apply(calendarPlanner.planAccepted(local, { ...moved, title: 'Team lunch' }, h.ctx).ops);
|
||||
expect(await h.events()).toEqual([]);
|
||||
});
|
||||
|
||||
it('places an event in several calendars in the first selected one, and keeps it where it is', async () => {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(single({ calendarIds: { w: true, b: true } }));
|
||||
expect(h.masterRow('e1')[Events.CALENDAR_ID]).toBe(h.rowIds.get('b'));
|
||||
await h.download(single({ calendarIds: { w: true } }));
|
||||
expect(h.masterRow('e1')[Events.CALENDAR_ID]).toBe(h.rowIds.get('w'));
|
||||
await h.download(single({ calendarIds: { w: true, b: true }, title: 'x' }));
|
||||
expect(h.masterRow('e1')[Events.CALENDAR_ID]).toBe(h.rowIds.get('w'));
|
||||
expect(CALENDARS.w.name).toBe('Work');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
addDuration,
|
||||
durationBetween,
|
||||
durationTextSeconds,
|
||||
formatAospDuration,
|
||||
formatJsDuration,
|
||||
formatJsDurationSeconds,
|
||||
parseDuration,
|
||||
sameDurationLength,
|
||||
} from '../../calendar/duration';
|
||||
import { aospDurationValid } from '../fakes/fake-provider';
|
||||
import { localToUtc } from '../../calendar/zoned-time';
|
||||
|
||||
describe('calendar/duration', () => {
|
||||
it('parses JSCalendar, iCalendar and AOSP durations', () => {
|
||||
expect(parseDuration('PT1H30M')).toEqual({ negative: false, days: 0, seconds: 5400 });
|
||||
expect(parseDuration('P1W')).toEqual({ negative: false, days: 7, seconds: 0 });
|
||||
expect(parseDuration('P1W2D')).toEqual({ negative: false, days: 9, seconds: 0 });
|
||||
expect(parseDuration('P1DT2H')).toEqual({ negative: false, days: 1, seconds: 7200 });
|
||||
expect(parseDuration('-PT15M')).toEqual({ negative: true, days: 0, seconds: 900 });
|
||||
expect(parseDuration('+PT15M')).toEqual({ negative: false, days: 0, seconds: 900 });
|
||||
expect(parseDuration('PT1.5S')).toEqual({ negative: false, days: 0, seconds: 1.5 });
|
||||
expect(parseDuration('pt10m')).toEqual({ negative: false, days: 0, seconds: 600 });
|
||||
// AOSP: no T before seconds, M always minutes, Fossify's padded form.
|
||||
expect(parseDuration('P3600S')).toEqual({ negative: false, days: 0, seconds: 3600 });
|
||||
expect(parseDuration('P0DT1H30M0S')).toEqual({ negative: false, days: 0, seconds: 5400 });
|
||||
for (const bad of ['P', 'PT', 'P1Y', 'P1.5D', '1H', '', 'PTH']) expect(parseDuration(bad)).toBeNull();
|
||||
expect(durationTextSeconds('P1W')).toBe(604_800);
|
||||
});
|
||||
|
||||
it('writes the strict JSCalendar grammar', () => {
|
||||
expect(formatJsDuration(0, 0)).toBe('PT0S');
|
||||
expect(formatJsDuration(2, 0)).toBe('P2D');
|
||||
expect(formatJsDuration(1, 3600)).toBe('P1DT1H');
|
||||
expect(formatJsDuration(0, 5400)).toBe('PT1H30M');
|
||||
// Minutes stay between hours and seconds.
|
||||
expect(formatJsDuration(0, 3605)).toBe('PT1H0M5S');
|
||||
expect(formatJsDuration(0, 45)).toBe('PT45S');
|
||||
expect(formatJsDuration(0, 59.6)).toBe('PT1M');
|
||||
expect(formatJsDurationSeconds(90_000)).toBe('P1DT1H');
|
||||
expect(formatJsDuration(-1, -5)).toBe('PT0S');
|
||||
});
|
||||
|
||||
it('writes only what CalendarProvider accepts', () => {
|
||||
expect(formatAospDuration(5400, false)).toBe('P5400S');
|
||||
expect(formatAospDuration(0, false)).toBe('P0S');
|
||||
expect(formatAospDuration(172_800, true)).toBe('P2D');
|
||||
// A time part on an all-day event is rounded up to whole days; never zero days.
|
||||
expect(formatAospDuration(90_000, true)).toBe('P2D');
|
||||
expect(formatAospDuration(0, true)).toBe('P1D');
|
||||
for (const s of [0, 1, 59, 3600, 86_400, 1_000_000]) {
|
||||
expect(aospDurationValid(formatAospDuration(s, false))).toBe(true);
|
||||
expect(formatAospDuration(s, true)).toMatch(/^P\d+D$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('counts whole days nominally in the zone and the rest exactly', () => {
|
||||
const berlin = 'Europe/Berlin';
|
||||
// 23 h across the spring change are still one day.
|
||||
const start = localToUtc('2026-03-28T12:00:00', berlin)!;
|
||||
const end = localToUtc('2026-03-29T12:00:00', berlin)!;
|
||||
expect(end - start).toBe(23 * 3_600_000);
|
||||
expect(durationBetween(start, end, berlin)).toEqual({ negative: false, days: 1, seconds: 0 });
|
||||
expect(addDuration(start, { negative: false, days: 1, seconds: 0 }, berlin)).toBe(end);
|
||||
expect(durationBetween(start, end + 1_800_000, berlin)).toEqual({ negative: false, days: 1, seconds: 1800 });
|
||||
expect(durationBetween(start, start + 5_400_000, berlin)).toEqual({ negative: false, days: 0, seconds: 5400 });
|
||||
expect(durationBetween(end, start, berlin)).toEqual({ negative: false, days: 0, seconds: 0 });
|
||||
});
|
||||
|
||||
it('keeps the second instant of a repeated hour when adding exact time', () => {
|
||||
const second = Date.parse('2026-10-25T01:30:00Z'); // 02:30 CET, after the change
|
||||
expect(addDuration(second, { negative: false, days: 0, seconds: 3600 }, 'Europe/Berlin')).toBe(second + 3_600_000);
|
||||
});
|
||||
|
||||
it('compares lengths with a day as 24 hours', () => {
|
||||
expect(sameDurationLength('PT24H', 'P1D')).toBe(true);
|
||||
expect(sameDurationLength('P3600S', 'PT1H')).toBe(true);
|
||||
expect(sameDurationLength('PT1H', 'PT2H')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Upload → server → accepted plan → the server's echo: after every kind of
|
||||
* device edit, the rows must already be what the server's next download
|
||||
* describes, so that echo writes nothing (docs/device-sync.md, invariant 4).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Attendees, Events } from '../../android-columns';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import type { CalendarEventWire } from '../../wire';
|
||||
import { ACCT, Harness, ME, serverApply, single, utc, weekly } from './harness';
|
||||
|
||||
async function roundTrip(event: CalendarEventWire, edit: (h: Harness, id: number) => void | Promise<void>) {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(event);
|
||||
const id = Number(h.masterRow(event.id)._id);
|
||||
await edit(h, id);
|
||||
const local = (await h.local(event.id))!;
|
||||
const plan = calendarPlanner.planUpload(local, h.ctx);
|
||||
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'update') throw new Error(`expected an update: ${JSON.stringify(plan)}`);
|
||||
const server = { ...serverApply(event, plan.actions[0].patch), updated: '2026-09-27T13:00:00Z' };
|
||||
await h.apply(calendarPlanner.planAccepted(local, server, h.ctx).ops);
|
||||
const after = (await h.local(event.id))!;
|
||||
return { h, id, server, after, patch: plan.actions[0].patch };
|
||||
}
|
||||
|
||||
const EDITS: Array<[string, () => CalendarEventWire, (h: Harness, id: number) => void | Promise<void>]> = [
|
||||
['a title', weekly, (h, id) => h.fake.user.updateEvent(id, { [Events.TITLE]: 'T' })],
|
||||
['a start of a single event', () => single(), (h, id) => h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-06T11:00:00Z'), [Events.DTEND]: utc('2026-10-06T12:00:00Z') })],
|
||||
['a DST-ambiguous start (fixed-offset zone)', () => single(), (h, id) => h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-25T00:30:00Z'), [Events.DTEND]: utc('2026-10-25T01:30:00Z') })],
|
||||
['an "all events" move', weekly, (h, id) => h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-05T14:00:00Z') })],
|
||||
['a "this event" edit', weekly, (h, id) => void h.fake.user.insertException(id, utc('2026-10-14T13:00:00Z'), { [Events.TITLE]: 'Only this', [Events.DTSTART]: utc('2026-10-14T13:00:00Z'), [Events.DTEND]: utc('2026-10-14T14:30:00Z') })],
|
||||
['a cancelled instance', weekly, (h, id) => void h.fake.user.cancelInstance(id, utc('2026-10-19T13:00:00Z'), utc('2026-10-19T14:30:00Z'))],
|
||||
['a capped rule', weekly, (h, id) => h.fake.user.updateEvent(id, { [Events.RRULE]: 'FREQ=WEEKLY;UNTIL=20261010T000000Z;BYDAY=MO,WE' })],
|
||||
['an RSVP', weekly, (h, id) => h.fake.user.setAttendeeStatus(id, ME, 4)],
|
||||
['an added attendee', weekly, (h, id) => void h.fake.user.addAttendee(id, { [Attendees.ATTENDEE_EMAIL]: 'x@example.net', [Attendees.ATTENDEE_TYPE]: 1, [Attendees.ATTENDEE_STATUS]: 3, [Attendees.ATTENDEE_RELATIONSHIP]: 1 })],
|
||||
['reminders', weekly, (h, id) => h.fake.user.setReminders(id, [{ minutes: 5 }, { minutes: 1440, method: 2 }])],
|
||||
['a location on an event without one', () => single(), (h, id) => h.fake.user.updateEvent(id, { [Events.EVENT_LOCATION]: 'Hall' })],
|
||||
['an all-day event\'s dates', () => single({ id: 'a', start: '2026-10-08T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P1D' }), (h, id) => h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-09T00:00:00Z'), [Events.DTEND]: utc('2026-10-12T00:00:00Z') })],
|
||||
];
|
||||
|
||||
describe('calendar planner: accepted uploads leave nothing for the echo', () => {
|
||||
it.each(EDITS)('%s', async (_name, make, edit) => {
|
||||
const { h, server, after } = await roundTrip(make(), edit);
|
||||
expect(after.dirty).toBe(false);
|
||||
expect(after.exceptions.every((x) => !x.dirty)).toBe(true);
|
||||
const echo = calendarPlanner.planDownload(server, after, h.ctx);
|
||||
expect(echo).toMatchObject({ effect: 'none', writes: 0 });
|
||||
expect(calendarPlanner.planBaselineHeal(after)).toBeNull();
|
||||
});
|
||||
|
||||
it('re-keys the exception rows with the series after an "all events" move', async () => {
|
||||
const { after, patch } = await roundTrip(weekly(), (h, id) => h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-05T14:00:00Z') }));
|
||||
expect(Object.keys(patch.recurrenceOverrides as object).sort()).toEqual(['2026-10-07T10:00:00', '2026-10-12T10:00:00']);
|
||||
expect(after.exceptions.map((x) => [x.syncId, x.cells[Events.ORIGINAL_INSTANCE_TIME]])).toEqual([
|
||||
[`${ACCT}/bl#2026-10-07T10:00:00`, utc('2026-10-07T14:00:00Z')],
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops the exception rows of overrides a "this and following" cut pruned', async () => {
|
||||
const event = weekly();
|
||||
event.recurrenceRule = { frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'we' }] };
|
||||
event.recurrenceOverrides = { '2026-10-07T09:00:00': { title: 'Early' }, '2026-10-21T09:00:00': { title: 'Late' } };
|
||||
const { after } = await roundTrip(event, (h, id) => h.fake.user.updateEvent(id, { [Events.RRULE]: 'FREQ=WEEKLY;UNTIL=20261019T125959Z;BYDAY=MO,WE' }));
|
||||
expect(after.exceptions.map((x) => x.recurrenceId)).toEqual(['2026-10-07T09:00:00']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Events } from '../../android-columns';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import type { CalendarEventWire } from '../../wire';
|
||||
import { ACCT, Harness, single, utc, weekly } from './harness';
|
||||
|
||||
async function synced(event: CalendarEventWire) {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(event);
|
||||
return { h, id: Number(h.masterRow(event.id)._id) };
|
||||
}
|
||||
|
||||
describe('calendar planner: edge cases', () => {
|
||||
it('undoes a deletion in a read-only calendar by purging and refetching', async () => {
|
||||
const { h, id } = await synced(single({ calendarIds: { r: true } }));
|
||||
h.fake.user.deleteEvent(id);
|
||||
const plan = calendarPlanner.planUpload((await h.local('e1'))!, h.ctx);
|
||||
expect(plan).toMatchObject({ kind: 'revert', refetch: true, reason: 'readOnly' });
|
||||
if (plan.kind === 'revert') await h.apply(plan.ops);
|
||||
expect(await h.events()).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats an instance Stalwart holds without its series as read-only', async () => {
|
||||
const { h, id } = await synced(single({ recurrenceId: '2026-10-06T12:00:00', recurrenceIdTimeZone: 'Europe/Berlin' }));
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Mine' });
|
||||
const plan = calendarPlanner.planUpload((await h.local('e1'))!, h.ctx);
|
||||
expect(plan).toMatchObject({ kind: 'revert', reason: 'instanceOnly' });
|
||||
if (plan.kind === 'revert') await h.apply(plan.ops);
|
||||
expect(h.row(id)).toMatchObject({ [Events.TITLE]: 'Lunch', [Events.DIRTY]: 0 });
|
||||
});
|
||||
|
||||
it('pairs Etar\'s move of a series with its copied exceptions and adopts them', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.deleteEvent(id);
|
||||
const fresh = h.fake.user.insertEvent(h.rowIds.get('w')!, {
|
||||
[Events.TITLE]: 'Probe weekly', [Events.DESCRIPTION]: 'desc', [Events.EVENT_LOCATION]: 'Room 1', [Events.DTSTART]: utc('2026-10-05T13:00:00Z'),
|
||||
[Events.DURATION]: 'P5400S', [Events.RRULE]: 'FREQ=WEEKLY;COUNT=10;BYDAY=MO,WE', [Events.EVENT_TIMEZONE]: 'America/New_York',
|
||||
[Events.STATUS]: 0, [Events.AVAILABILITY]: 1, [Events.ACCESS_LEVEL]: 2, [Events.EVENT_COLOR]: h.row(id)[Events.EVENT_COLOR],
|
||||
});
|
||||
for (const a of h.fake.rows('attendees', 'event_id = ?', [id])) {
|
||||
h.fake.table('attendees').set(h.fake.allocId(), { ...a, _id: null, event_id: fresh });
|
||||
}
|
||||
// Etar copies the exception too (API 30+).
|
||||
h.fake.user.insertException(fresh, utc('2026-10-07T13:00:00Z'), { [Events.TITLE]: 'Moved Wednesday', [Events.DTSTART]: utc('2026-10-07T15:00:00Z'), [Events.DTEND]: utc('2026-10-07T16:30:00Z') });
|
||||
const events = await h.events();
|
||||
const pairs = calendarPlanner.planPairs(events.filter((e) => e.deleted), events.filter((e) => !e.syncId && !e.deleted), h.ctx);
|
||||
expect(pairs.map((p) => p.actions)).toEqual([[{ kind: 'update', id: 'bl', patch: { 'calendarIds/b': null, 'calendarIds/w': true } }]]);
|
||||
await h.apply(pairs[0].ops);
|
||||
const after = (await h.local('bl'))!;
|
||||
expect(after.eventId).toBe(fresh);
|
||||
expect(after.exceptions.map((x) => [x.syncId, x.dirty])).toEqual([[`${ACCT}/bl#2026-10-07T09:00:00`, false]]);
|
||||
// The old row and its own exception are gone.
|
||||
expect((await h.events()).map((e) => e.eventId)).toEqual([fresh]);
|
||||
});
|
||||
|
||||
it('compares descriptions over 1 KB by hash and uploads them only when edited', async () => {
|
||||
const long = 'Lorem ipsum '.repeat(200);
|
||||
const { h, id } = await synced(single({ description: long }));
|
||||
const local = (await h.local('e1'))!;
|
||||
expect(String(local.baseline!.cells[Events.DESCRIPTION])).toMatch(/^sha256:[0-9a-f]{64}$/);
|
||||
h.fake.user.updateEvent(id, {});
|
||||
expect(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx).kind).toBe('clean');
|
||||
h.fake.user.updateEvent(id, { [Events.DESCRIPTION]: `${long}!` });
|
||||
const plan = calendarPlanner.planUpload((await h.local('e1'))!, h.ctx);
|
||||
expect(plan.kind === 'upload' && plan.actions[0].kind === 'update' && plan.actions[0].patch).toEqual({ description: `${long}!` });
|
||||
});
|
||||
|
||||
it('writes nothing into a split clone on download: it is a new event', async () => {
|
||||
const event = single({ recurrenceRule: { frequency: 'daily' } });
|
||||
const { h, id } = await synced(event);
|
||||
const clone = h.fake.user.splitViaUri(id, utc('2026-10-10T10:00:00Z'), '20261010T095959Z', {});
|
||||
const cloneEvent = (await h.events()).find((e) => e.eventId === clone)!;
|
||||
expect(calendarPlanner.planDownload({ ...event, title: 'x' }, cloneEvent, h.ctx)).toMatchObject({ effect: 'none', stillDirty: true });
|
||||
});
|
||||
|
||||
it('keeps an exception row an app left without a description or attendees inheriting them', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.insertException(id, utc('2026-10-14T13:00:00Z'), { [Events.DTSTART]: utc('2026-10-14T13:00:00Z'), [Events.DTEND]: utc('2026-10-14T14:30:00Z'), [Events.EVENT_LOCATION]: null });
|
||||
const plan = calendarPlanner.planUpload((await h.local('bl'))!, h.ctx);
|
||||
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'update') throw new Error();
|
||||
const override = plan.actions[0].patch['recurrenceOverrides/2026-10-14T09:00:00'] as Record<string, unknown>;
|
||||
// The series' description, location and attendees stay on the instance.
|
||||
expect(override).toMatchObject({ description: 'desc', locations: weekly().locations, participants: weekly().participants });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,456 @@
|
||||
// The calendar planner through the engine and the fake JMAP server: device
|
||||
// edits reach the server as the user made them and the rows stay as the user
|
||||
// left them, end to end. Mapping details are the planner's own tests.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Events, Reminders } from '../../android-columns';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import { contactsPlanner } from '../../contacts/planner';
|
||||
import { CALENDAR_AUTHORITY, createHarness, type Harness } from '../engine/harness';
|
||||
|
||||
function real(options: Parameters<typeof createHarness>[0] = {}): Harness {
|
||||
const h = createHarness(options);
|
||||
h.deps.planners = { contacts: contactsPlanner, calendar: calendarPlanner };
|
||||
return h;
|
||||
}
|
||||
|
||||
type Overrides = Record<string, Record<string, unknown>>;
|
||||
|
||||
describe('calendar device edits through the engine', () => {
|
||||
it('creates an event and an occurrence edit an app left without a status as confirmed ones', async () => {
|
||||
const h = real();
|
||||
const series = h.server.addEvent('a', {
|
||||
uid: 'weekly-uid',
|
||||
title: 'Weekly',
|
||||
start: '2026-10-05T08:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'Europe/Berlin',
|
||||
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly', count: 8 },
|
||||
calendarIds: { [h.calendar]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const master = h.events().find((e) => e[Events._SYNC_ID] === `a/${series}`)!;
|
||||
const rowId = Number(master[Events.CALENDAR_ID]);
|
||||
|
||||
// Google Calendar leaves STATUS NULL on the events and exceptions it inserts.
|
||||
const created = h.device.user.insertEvent(rowId, {
|
||||
[Events.TITLE]: 'New from Google Calendar',
|
||||
[Events.DTSTART]: Date.UTC(2026, 9, 14, 13),
|
||||
[Events.DTEND]: Date.UTC(2026, 9, 14, 14),
|
||||
[Events.EVENT_TIMEZONE]: 'Europe/Berlin',
|
||||
[Events.STATUS]: null,
|
||||
});
|
||||
h.device.user.insertException(Number(master._id), Date.UTC(2026, 10, 9, 7), {
|
||||
[Events.TITLE]: 'Only Nov 9',
|
||||
[Events.DTSTART]: Date.UTC(2026, 10, 9, 7),
|
||||
[Events.DTEND]: Date.UTC(2026, 10, 9, 8),
|
||||
[Events.STATUS]: null,
|
||||
});
|
||||
expect(await h.run(CALENDAR_AUTHORITY)).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 1, updated: 1 } } });
|
||||
|
||||
const event = h.server.all('CalendarEvent', 'a').find((e) => e.title === 'New from Google Calendar')!;
|
||||
expect(event.status).toBeUndefined();
|
||||
const overrides = h.server.get('CalendarEvent', 'a', series)!.recurrenceOverrides as Overrides;
|
||||
expect(overrides['2026-11-09T08:00:00']).toMatchObject({ title: 'Only Nov 9' });
|
||||
expect(overrides['2026-11-09T08:00:00'].status).toBeUndefined();
|
||||
// The rows hold the default, confirmed, and are clean.
|
||||
expect(h.events().find((e) => Number(e._id) === created)).toMatchObject({ [Events.STATUS]: 1, [Events.DIRTY]: 0 });
|
||||
expect(h.events().filter((e) => e[Events.ORIGINAL_SYNC_ID] === `a/${series}`).map((e) => [e[Events.STATUS], e[Events.DIRTY]])).toEqual([[1, 0]]);
|
||||
});
|
||||
|
||||
it('keeps a reminder changed on one occurrence of a series on the calendar\'s default alerts', async () => {
|
||||
const h = real();
|
||||
h.server.serverUpdate('Calendar', 'a', h.calendar, {
|
||||
defaultAlertsWithTime: { d1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' } },
|
||||
});
|
||||
const id = h.server.addEvent('a', {
|
||||
uid: 'standup-uid',
|
||||
title: 'Standup',
|
||||
start: '2026-09-28T09:00:00',
|
||||
duration: 'PT15M',
|
||||
timeZone: 'Europe/Berlin',
|
||||
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily', count: 5 },
|
||||
recurrenceOverrides: { '2026-09-29T09:00:00': { title: 'Standup (late)', start: '2026-09-29T10:00:00' } },
|
||||
useDefaultAlerts: true,
|
||||
calendarIds: { [h.calendar]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const minutes = (eventId: unknown) => h.device.rows('reminders').filter((r) => r[Reminders.EVENT_ID] === Number(eventId)).map((r) => Number(r[Reminders.MINUTES]));
|
||||
const master = h.events().find((e) => e[Events._SYNC_ID] === `a/${id}`)!;
|
||||
const exception = () => h.events().find((e) => e[Events.ORIGINAL_SYNC_ID] === `a/${id}`)!;
|
||||
expect([minutes(master._id), minutes(exception()._id)]).toEqual([[15], [15]]);
|
||||
|
||||
h.device.user.setReminders(Number(exception()._id), [{ minutes: 60 }]);
|
||||
expect(await h.run(CALENDAR_AUTHORITY)).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { updated: 1 } } });
|
||||
|
||||
expect(exception()[Events.DIRTY]).toBe(0);
|
||||
expect([minutes(master._id), minutes(exception()._id)]).toEqual([[15], [60]]);
|
||||
const event = h.server.get('CalendarEvent', 'a', id)!;
|
||||
expect(event.useDefaultAlerts).toBe(true);
|
||||
expect((event.recurrenceOverrides as Overrides)['2026-09-29T09:00:00']).toMatchObject({ useDefaultAlerts: false });
|
||||
// Its echo writes nothing.
|
||||
const before = h.batches.log.length;
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
expect(h.batches.log.slice(before).filter((ops) => ops.some((op) => op.op !== 'syncState' && op.op !== 'assert'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('sends an answer to one occurrence in a calendar that grants only RSVP, and reports a refusal', async () => {
|
||||
const h = real({ accounts: 'withShared' });
|
||||
const team = h.server.addCalendar('team', {
|
||||
name: 'Team events',
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: true, mayShare: false, mayDelete: false },
|
||||
});
|
||||
h.prefs.calendarSelection[`team/${team}`] = true;
|
||||
const id = h.server.addEvent('team', {
|
||||
uid: 'team-weekly',
|
||||
title: 'Team weekly',
|
||||
start: '2026-10-05T09:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'Europe/Berlin',
|
||||
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly', count: 5 },
|
||||
organizerCalendarAddress: 'mailto:boss@example.net',
|
||||
participants: {
|
||||
boss: { '@type': 'Participant', calendarAddress: 'mailto:boss@example.net', roles: { owner: true }, participationStatus: 'accepted' },
|
||||
me: { '@type': 'Participant', calendarAddress: 'mailto:alice@example.com', roles: { attendee: true }, participationStatus: 'needs-action' },
|
||||
},
|
||||
calendarIds: { [team]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const master = h.events().find((e) => e[Events._SYNC_ID] === `team/${id}`)!;
|
||||
|
||||
// Etar answers the Oct 12 occurrence through CONTENT_EXCEPTION_URI.
|
||||
h.device.user.exceptionViaUri(Number(master._id), Date.UTC(2026, 9, 12, 7), { [Events.SELF_ATTENDEE_STATUS]: 2, [Events.STATUS]: 1 });
|
||||
const sets = () => h.server.requests.filter((r) => r.methods.includes('CalendarEvent/set')).length;
|
||||
const before = sets();
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
// The answer was sent (this server takes no RSVP without write rights), the refusal is reported and the row put back.
|
||||
expect(sets()).toBeGreaterThan(before);
|
||||
expect(report.itemErrors).toEqual([expect.objectContaining({ ref: `team/${id}`, side: 'upload', type: 'forbidden' })]);
|
||||
expect(h.events().filter((e) => e[Events.ORIGINAL_SYNC_ID] === `team/${id}`)).toEqual([]);
|
||||
expect(h.events().every((e) => Number(e[Events.DIRTY] ?? 0) === 0)).toBe(true);
|
||||
// Nothing was stored at all: the report does not claim it was, without invitations.
|
||||
expect(report.message ?? '').not.toContain('invitations not sent');
|
||||
});
|
||||
|
||||
it('notes that invitations were not sent only when the change was stored without them', async () => {
|
||||
const h = real();
|
||||
const id = h.server.addEvent('a', {
|
||||
uid: 'review-uid',
|
||||
title: 'Review',
|
||||
start: '2026-10-06T12:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'Europe/Berlin',
|
||||
organizerCalendarAddress: 'mailto:alice@example.com',
|
||||
participants: {
|
||||
me: { '@type': 'Participant', calendarAddress: 'mailto:alice@example.com', roles: { owner: true, attendee: true }, participationStatus: 'accepted' },
|
||||
bob: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.com', roles: { attendee: true }, participationStatus: 'needs-action' },
|
||||
},
|
||||
calendarIds: { [h.calendar]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const row = Number(h.events().find((e) => e[Events._SYNC_ID] === `a/${id}`)!._id);
|
||||
h.device.user.updateEvent(row, { [Events.TITLE]: 'Design review' });
|
||||
// The server refuses the scheduling messages once (e.g. scheduling turned off): the change goes up without them.
|
||||
h.server.setErrorFor('CalendarEvent', 'a', id, { type: 'forbidden' });
|
||||
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { updated: 1 } } });
|
||||
expect(report.message).toContain('invitations not sent');
|
||||
expect(h.server.get('CalendarEvent', 'a', id)).toMatchObject({ title: 'Design review' });
|
||||
});
|
||||
|
||||
it('puts an event an app moved in place to a calendar of another account back into its calendar', async () => {
|
||||
const h = real({ accounts: 'withShared' });
|
||||
const team = String(h.server.all('Calendar', 'team')[0].id);
|
||||
h.prefs.calendarSelection[`team/${team}`] = true;
|
||||
const id = h.server.addEvent('a', {
|
||||
uid: 'lunch-uid',
|
||||
title: 'Lunch',
|
||||
start: '2026-10-06T12:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'Europe/Berlin',
|
||||
calendarIds: { [h.calendar]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const calendarRow = (key: string) => Number(h.device.rows('calendars').find((c) => c._sync_id === key)!._id);
|
||||
const row = () => h.events().find((e) => e[Events._SYNC_ID] === `a/${id}`)!;
|
||||
expect(row()[Events.CALENDAR_ID]).toBe(calendarRow(`a/${h.calendar}`));
|
||||
|
||||
// An app moves it by changing CALENDAR_ID in place (a move across JMAP accounts can't be a patch).
|
||||
h.device.user.updateEvent(Number(row()._id), { [Events.CALENDAR_ID]: calendarRow(`team/${team}`) });
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(row()).toMatchObject({ [Events.CALENDAR_ID]: calendarRow(`a/${h.calendar}`), [Events.DIRTY]: 0 });
|
||||
expect(h.server.get('CalendarEvent', 'a', id)!.calendarIds).toEqual({ [h.calendar]: true });
|
||||
expect(h.server.all('CalendarEvent', 'team')).toEqual([]);
|
||||
// Nothing was sent; the change counts as skipped, and the report says why.
|
||||
expect(report).toMatchObject({ outcome: 'ok', stats: { uploaded: { created: 0, updated: 0, deleted: 0 }, skipped: 1 } });
|
||||
expect(report.itemErrors).toEqual([expect.objectContaining({ ref: `a/${id}`, side: 'upload', type: 'crossAccountMove' })]);
|
||||
|
||||
// Together with another edit: that one uploads, and the row goes back all the same.
|
||||
h.device.user.updateEvent(Number(row()._id), { [Events.CALENDAR_ID]: calendarRow(`team/${team}`), [Events.TITLE]: 'Team lunch' });
|
||||
expect(await h.run(CALENDAR_AUTHORITY)).toMatchObject({ outcome: 'ok', stats: { uploaded: { updated: 1 } } });
|
||||
expect(h.server.get('CalendarEvent', 'a', id)).toMatchObject({ title: 'Team lunch', calendarIds: { [h.calendar]: true } });
|
||||
expect(row()).toMatchObject({ [Events.CALENDAR_ID]: calendarRow(`a/${h.calendar}`), [Events.TITLE]: 'Team lunch', [Events.DIRTY]: 0 });
|
||||
expect(h.server.all('CalendarEvent', 'team')).toEqual([]);
|
||||
});
|
||||
|
||||
describe("Etar's move (delete + insert) with other edits", () => {
|
||||
/** The event columns Etar copies into the row it inserts when it moves an event. */
|
||||
const ETAR_COPY = [
|
||||
Events.TITLE, Events.DESCRIPTION, Events.EVENT_LOCATION, Events.STATUS, Events.AVAILABILITY, Events.ACCESS_LEVEL,
|
||||
Events.EVENT_COLOR, Events.DTSTART, Events.DTEND, Events.DURATION, Events.EVENT_TIMEZONE, Events.ALL_DAY, Events.RRULE, Events.EXDATE,
|
||||
];
|
||||
function etarMove(h: Harness, masterId: number, calendarRowId: number, reminders: Array<{ minutes: number }>): number {
|
||||
const old = h.events().find((e) => Number(e._id) === masterId)!;
|
||||
const values = Object.fromEntries(ETAR_COPY.filter((c) => old[c] !== undefined).map((c) => [c, old[c]]));
|
||||
h.device.user.deleteEvent(masterId);
|
||||
return h.device.user.insertEvent(calendarRowId, values, {
|
||||
reminders: reminders.map((r) => ({ [Reminders.MINUTES]: r.minutes, [Reminders.METHOD]: 1 })),
|
||||
});
|
||||
}
|
||||
const minutes = (h: Harness, eventId: number) =>
|
||||
h.device.rows('reminders').filter((r) => Number(r[Reminders.EVENT_ID]) === eventId).map((r) => Number(r[Reminders.MINUTES]));
|
||||
|
||||
it('uploads a reminder saved with the move, and keeps the event', async () => {
|
||||
const h = real();
|
||||
const work = h.server.addCalendar('a', { name: 'Work' });
|
||||
const id = h.server.addEvent('a', {
|
||||
uid: 'lunch-uid',
|
||||
title: 'Lunch',
|
||||
start: '2026-10-06T12:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'Europe/Berlin',
|
||||
keywords: { important: true },
|
||||
alerts: { al1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' } },
|
||||
calendarIds: { [h.calendar]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const master = Number(h.events().find((e) => e[Events._SYNC_ID] === `a/${id}`)!._id);
|
||||
const workRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `a/${work}`)!._id);
|
||||
|
||||
const moved = etarMove(h, master, workRow, [{ minutes: 60 }]);
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 0, deleted: 0 } } });
|
||||
const event = h.server.get('CalendarEvent', 'a', id)!;
|
||||
expect(event).toMatchObject({ uid: 'lunch-uid', calendarIds: { [work]: true }, keywords: { important: true } });
|
||||
const alerts = Object.values(event.alerts as Record<string, { trigger: { offset: string } }>).map((a) => a.trigger.offset);
|
||||
expect(alerts).toEqual(['-PT1H']);
|
||||
expect(h.events()).toEqual([expect.objectContaining({ _id: moved, _sync_id: `a/${id}`, [Events.DIRTY]: 0 })]);
|
||||
expect(minutes(h, moved)).toEqual([60]);
|
||||
|
||||
const before = h.batches.log.length;
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
expect(h.batches.log.slice(before).filter((ops) => ops.some((op) => op.op !== 'syncState' && op.op !== 'assert'))).toEqual([]);
|
||||
});
|
||||
|
||||
describe('when the server deleted the event meanwhile', () => {
|
||||
async function movedLunch() {
|
||||
const h = real();
|
||||
const work = h.server.addCalendar('a', { name: 'Work' });
|
||||
const id = h.server.addEvent('a', {
|
||||
uid: 'lunch-uid',
|
||||
title: 'Lunch',
|
||||
start: '2026-10-06T12:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'Europe/Berlin',
|
||||
calendarIds: { [h.calendar]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const master = Number(h.events().find((e) => e[Events._SYNC_ID] === `a/${id}`)!._id);
|
||||
const workRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `a/${work}`)!._id);
|
||||
etarMove(h, master, workRow, [{ minutes: 30 }]);
|
||||
return { h, id };
|
||||
}
|
||||
const creates = (h: Harness) =>
|
||||
h.server.calls('CalendarEvent/set').filter(([, args]) => Object.keys((args as { create?: object }).create ?? {}).length);
|
||||
|
||||
it('removes the moved event when the patch of the move finds it gone', async () => {
|
||||
const { h, id } = await movedLunch();
|
||||
h.server.setErrorFor('CalendarEvent', 'a', id, { type: 'notFound' });
|
||||
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', conflicts: 1, stats: { uploaded: { created: 0 } } });
|
||||
expect(creates(h)).toEqual([]);
|
||||
expect(h.events()).toEqual([]);
|
||||
});
|
||||
|
||||
it('removes the moved event when the download finds it destroyed', async () => {
|
||||
const { h, id } = await movedLunch();
|
||||
h.server.serverDestroy('CalendarEvent', 'a', id);
|
||||
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], conflicts: 1, stats: { uploaded: { created: 0 } } });
|
||||
expect(h.server.all('CalendarEvent', 'a')).toEqual([]);
|
||||
expect(h.events()).toEqual([]);
|
||||
});
|
||||
|
||||
it('removes the moved event when it is destroyed while the move is on its way', async () => {
|
||||
const { h, id } = await movedLunch();
|
||||
const stop = h.server.onBeforeRequest((request) => {
|
||||
if (!request.methods.includes('CalendarEvent/set')) return;
|
||||
stop();
|
||||
h.server.serverDestroy('CalendarEvent', 'a', id);
|
||||
});
|
||||
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 0 } } });
|
||||
expect(h.server.all('CalendarEvent', 'a')).toEqual([]);
|
||||
expect(h.events()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps an occurrence edit that had not reached the server when the series moves', async () => {
|
||||
const h = real();
|
||||
const work = h.server.addCalendar('a', { name: 'Work' });
|
||||
const id = h.server.addEvent('a', {
|
||||
uid: 'standup-uid',
|
||||
title: 'Standup',
|
||||
start: '2026-09-28T09:00:00',
|
||||
duration: 'PT15M',
|
||||
timeZone: 'Europe/Berlin',
|
||||
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily', count: 5 },
|
||||
recurrenceOverrides: { '2026-09-29T09:00:00': { title: 'Planning' } },
|
||||
calendarIds: { [h.calendar]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const master = Number(h.events().find((e) => e[Events._SYNC_ID] === `a/${id}`)!._id);
|
||||
const exception = Number(h.events().find((e) => e[Events.ORIGINAL_SYNC_ID] === `a/${id}`)!._id);
|
||||
const workRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `a/${work}`)!._id);
|
||||
// The occurrence is renamed on the device; before it syncs, Etar moves the series (it copies only unsynced exceptions).
|
||||
h.device.user.updateEvent(exception, { [Events.TITLE]: 'Sprint planning' });
|
||||
|
||||
const moved = etarMove(h, master, workRow, []);
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 0, deleted: 0 } } });
|
||||
const event = h.server.get('CalendarEvent', 'a', id)!;
|
||||
expect(event.calendarIds).toEqual({ [work]: true });
|
||||
expect((event.recurrenceOverrides as Overrides)['2026-09-29T09:00:00']).toMatchObject({ title: 'Sprint planning' });
|
||||
const rows = h.events();
|
||||
expect(rows.filter((e) => e[Events.ORIGINAL_ID] === null)).toEqual([expect.objectContaining({ _id: moved, _sync_id: `a/${id}`, [Events.DIRTY]: 0 })]);
|
||||
expect(rows.filter((e) => e[Events.ORIGINAL_ID] !== null)).toEqual([
|
||||
expect.objectContaining({ [Events.ORIGINAL_ID]: moved, [Events.CALENDAR_ID]: workRow, [Events.TITLE]: 'Sprint planning', [Events.DIRTY]: 0 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('removes an event created in a read-only calendar and reports it', async () => {
|
||||
const h = real({ accounts: 'withShared' });
|
||||
const team = h.server.addCalendar('team', {
|
||||
name: 'Team events',
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false },
|
||||
});
|
||||
h.prefs.calendarSelection[`team/${team}`] = true;
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const teamRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `team/${team}`)!._id);
|
||||
|
||||
const id = h.device.user.insertEvent(teamRow, {
|
||||
[Events.TITLE]: 'Mine',
|
||||
[Events.DTSTART]: Date.UTC(2026, 9, 14, 13),
|
||||
[Events.DTEND]: Date.UTC(2026, 9, 14, 14),
|
||||
[Events.EVENT_TIMEZONE]: 'Europe/Berlin',
|
||||
});
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', stats: { uploaded: { created: 0 }, skipped: 1 } });
|
||||
expect(report.itemErrors).toEqual([expect.objectContaining({ ref: `row:${id}`, side: 'upload', type: 'readOnly' })]);
|
||||
expect(h.events().find((e) => Number(e._id) === id)).toBeUndefined();
|
||||
expect(h.server.all('CalendarEvent', 'team')).toEqual([]);
|
||||
// Nothing is left waiting: the next run has nothing to say about it.
|
||||
expect((await h.run(CALENDAR_AUTHORITY)).itemErrors).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves nothing waiting for turning sync off after an event was created in a read-only calendar', async () => {
|
||||
const h = real({ accounts: 'withShared' });
|
||||
const team = h.server.addCalendar('team', { name: 'Team events', myRights: { mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayRSVP: false } });
|
||||
h.prefs.calendarSelection[`team/${team}`] = true;
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const teamRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `team/${team}`)!._id);
|
||||
h.device.user.insertEvent(teamRow, { [Events.TITLE]: 'Mine', [Events.DTSTART]: Date.UTC(2026, 9, 14, 13), [Events.DTEND]: Date.UTC(2026, 9, 14, 14), [Events.EVENT_TIMEZONE]: 'Europe/Berlin' });
|
||||
|
||||
expect(await h.teardown(CALENDAR_AUTHORITY)).toEqual({ pending: 0 });
|
||||
expect(h.server.all('CalendarEvent', 'team')).toEqual([]);
|
||||
});
|
||||
|
||||
describe('an occurrence\'s own colour and reminders removed on the device', () => {
|
||||
async function standup(extra: Record<string, unknown>, override: Record<string, unknown>) {
|
||||
const h = real();
|
||||
const id = h.server.addEvent('a', {
|
||||
uid: 'standup-uid',
|
||||
title: 'Standup',
|
||||
start: '2026-09-28T09:00:00',
|
||||
duration: 'PT15M',
|
||||
timeZone: 'Europe/Berlin',
|
||||
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily', count: 5 },
|
||||
recurrenceOverrides: { '2026-09-29T09:00:00': { title: 'Standup (late)', ...override } },
|
||||
calendarIds: { [h.calendar]: true },
|
||||
...extra,
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const exception = () => h.events().find((e) => e[Events.ORIGINAL_SYNC_ID] === `a/${id}`)!;
|
||||
const minutes = () => h.device.rows('reminders').filter((r) => Number(r[Reminders.EVENT_ID]) === Number(exception()._id)).map((r) => Number(r[Reminders.MINUTES]));
|
||||
return { h, id, exception, minutes };
|
||||
}
|
||||
|
||||
it('keeps a colour cleared on one occurrence cleared', async () => {
|
||||
const { h, id, exception } = await standup({ color: 'steelblue' }, { color: 'red' });
|
||||
|
||||
h.device.user.updateEvent(Number(exception()._id), { [Events.EVENT_COLOR]: null });
|
||||
expect(await h.run(CALENDAR_AUTHORITY)).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { updated: 1 } } });
|
||||
|
||||
expect(exception()).toMatchObject({ [Events.EVENT_COLOR]: null, [Events.DIRTY]: 0 });
|
||||
expect((h.server.get('CalendarEvent', 'a', id)!.recurrenceOverrides as Overrides)['2026-09-29T09:00:00']).toMatchObject({ color: '' });
|
||||
});
|
||||
|
||||
it('puts back the removal of every reminder of an occurrence whose series shows its own, and reports it', async () => {
|
||||
const alert = { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' };
|
||||
const { h, id, exception, minutes } = await standup({ alerts: { al1: alert } }, {});
|
||||
expect(minutes()).toEqual([15]);
|
||||
const sets = () => h.server.requests.filter((r) => r.methods.includes('CalendarEvent/set')).length;
|
||||
const before = sets();
|
||||
|
||||
h.device.user.setReminders(Number(exception()._id), []);
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report.itemErrors).toEqual([expect.objectContaining({ ref: `a/${id}`, side: 'upload', type: 'remindersNotRepresentable' })]);
|
||||
expect(sets()).toBe(before);
|
||||
expect(minutes()).toEqual([15]);
|
||||
expect(exception()[Events.DIRTY]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a description and location cleared on one occurrence cleared', async () => {
|
||||
const h = real();
|
||||
const id = h.server.addEvent('a', {
|
||||
uid: 'standup-uid',
|
||||
title: 'Standup',
|
||||
description: 'Daily agenda',
|
||||
start: '2026-09-28T09:00:00',
|
||||
duration: 'PT15M',
|
||||
timeZone: 'Europe/Berlin',
|
||||
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily', count: 5 },
|
||||
locations: { locA: { '@type': 'Location', name: 'Room 1' } },
|
||||
recurrenceOverrides: {
|
||||
'2026-09-29T09:00:00': { title: 'Standup', description: 'Special agenda', locations: { locB: { '@type': 'Location', name: 'Room 2' } } },
|
||||
},
|
||||
calendarIds: { [h.calendar]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const exception = () => h.events().find((e) => e[Events.ORIGINAL_SYNC_ID] === `a/${id}`)!;
|
||||
expect(exception()).toMatchObject({ [Events.DESCRIPTION]: 'Special agenda', [Events.EVENT_LOCATION]: 'Room 2' });
|
||||
|
||||
h.device.user.updateEvent(Number(exception()._id), { [Events.DESCRIPTION]: null, [Events.EVENT_LOCATION]: null });
|
||||
expect(await h.run(CALENDAR_AUTHORITY)).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { updated: 1 } } });
|
||||
|
||||
expect(exception()).toMatchObject({ [Events.DESCRIPTION]: null, [Events.EVENT_LOCATION]: null, [Events.DIRTY]: 0 });
|
||||
const overrides = h.server.get('CalendarEvent', 'a', id)!.recurrenceOverrides as Overrides;
|
||||
expect(overrides['2026-09-29T09:00:00']).toMatchObject({ description: '', locations: { locB: { name: '' } } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,548 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Attendees, Events, Reminders } from '../../android-columns';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import { cssColorToArgb } from '../../calendar/values';
|
||||
import type { LocalEvent, UploadPlan } from '../../planner';
|
||||
import type { CalendarEventWire } from '../../wire';
|
||||
import { ACCT, Harness, ME, serverApply, single, utc, weekly } from './harness';
|
||||
|
||||
type Plan = UploadPlan<CalendarEventWire>;
|
||||
|
||||
function patchOf(plan: Plan) {
|
||||
expect(plan.kind).toBe('upload');
|
||||
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'update') throw new Error(JSON.stringify(plan));
|
||||
return plan.actions[0];
|
||||
}
|
||||
|
||||
async function synced(event: CalendarEventWire) {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(event);
|
||||
return { h, id: Number(h.masterRow(event.id)._id) };
|
||||
}
|
||||
|
||||
/** Uploads, lets the server apply the patch, applies the accepted plan and returns the new server object. */
|
||||
async function accept(h: Harness, event: CalendarEventWire, local: LocalEvent, plan: Plan): Promise<CalendarEventWire> {
|
||||
const action = patchOf(plan);
|
||||
const server = serverApply(event, action.patch);
|
||||
const accepted = calendarPlanner.planAccepted(local, server, h.ctx);
|
||||
await h.apply(accepted.ops);
|
||||
return server;
|
||||
}
|
||||
|
||||
const NO_FORBIDDEN = ['@type', 'organizerCalendarAddress', 'privacy', 'uid', 'recurrenceRule', 'recurrenceOverrides', 'recurrenceId', 'method', 'prodId', 'sentBy'];
|
||||
|
||||
describe('calendar planner: recurrence edits made on the device', () => {
|
||||
it('uploads an Etar "this event" edit as a new override carrying the master\'s details', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.insertException(id, utc('2026-10-14T13:00:00Z'), {
|
||||
[Events.TITLE]: 'Only this Wednesday',
|
||||
[Events.DTSTART]: utc('2026-10-14T14:00:00Z'),
|
||||
[Events.DTEND]: utc('2026-10-14T15:30:00Z'),
|
||||
});
|
||||
const local = (await h.local('bl'))!;
|
||||
expect(local.exceptions.find((x) => !x.cells[Events.SYNC_DATA2])?.recurrenceId).toBe('2026-10-14T09:00:00');
|
||||
const plan = calendarPlanner.planUpload(local, h.ctx);
|
||||
const action = patchOf(plan);
|
||||
expect(Object.keys(action.patch)).toEqual(['recurrenceOverrides/2026-10-14T09:00:00']);
|
||||
const override = action.patch['recurrenceOverrides/2026-10-14T09:00:00'] as Record<string, unknown>;
|
||||
expect(override).toMatchObject({
|
||||
title: 'Only this Wednesday',
|
||||
start: '2026-10-14T10:00:00',
|
||||
duration: 'PT1H30M',
|
||||
locations: weekly().locations,
|
||||
participants: weekly().participants,
|
||||
description: 'desc',
|
||||
});
|
||||
for (const key of NO_FORBIDDEN) expect(override).not.toHaveProperty(key);
|
||||
// The organizer changed an instance: attendees are told.
|
||||
expect(action.sendSchedulingMessages).toBe(true);
|
||||
|
||||
const server = await accept(h, weekly(), local, plan);
|
||||
const after = (await h.local('bl'))!;
|
||||
expect(after.dirty).toBe(false);
|
||||
expect(after.exceptions.map((x) => [x.syncId, x.dirty])).toEqual([
|
||||
[`${ACCT}/bl#2026-10-07T09:00:00`, false],
|
||||
[`${ACCT}/bl#2026-10-14T09:00:00`, false],
|
||||
]);
|
||||
// The download of our own change writes nothing.
|
||||
expect(calendarPlanner.planDownload(server, after, h.ctx).effect).toBe('none');
|
||||
});
|
||||
|
||||
it('sends the whole overrides map for the first override of a series', async () => {
|
||||
const event = single({ recurrenceRule: { frequency: 'daily', count: 5 } });
|
||||
const { h, id } = await synced(event);
|
||||
h.fake.user.insertException(id, utc('2026-10-07T10:00:00Z'), { [Events.TITLE]: 'Tuesday lunch', [Events.DTSTART]: utc('2026-10-07T10:00:00Z'), [Events.DTEND]: utc('2026-10-07T11:00:00Z') });
|
||||
const action = patchOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx));
|
||||
expect(Object.keys(action.patch)).toEqual(['recurrenceOverrides']);
|
||||
expect(action.patch.recurrenceOverrides).toEqual({
|
||||
'2026-10-07T12:00:00': { title: 'Tuesday lunch', start: '2026-10-07T12:00:00', duration: 'PT1H' },
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads "delete this event" (a cancelled exception) as an exclusion and cleans up after', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.cancelInstance(id, utc('2026-10-14T13:00:00Z'), utc('2026-10-14T14:30:00Z'));
|
||||
const local = (await h.local('bl'))!;
|
||||
const plan = calendarPlanner.planUpload(local, h.ctx);
|
||||
expect(patchOf(plan).patch).toEqual({ 'recurrenceOverrides/2026-10-14T09:00:00': { excluded: true } });
|
||||
const server = await accept(h, weekly(), local, plan);
|
||||
expect(h.masterRow('bl')[Events.EXDATE]).toBe('20261012T130000Z,20261014T130000Z');
|
||||
const after = (await h.local('bl'))!;
|
||||
expect(after.exceptions.map((x) => x.recurrenceId)).toEqual(['2026-10-07T09:00:00']);
|
||||
expect(calendarPlanner.planDownload(server, after, h.ctx).effect).toBe('none');
|
||||
});
|
||||
|
||||
it('excludes an instance whose synced exception row was deleted', async () => {
|
||||
const { h } = await synced(weekly());
|
||||
const x = (await h.local('bl'))!.exceptions[0];
|
||||
h.fake.user.deleteEvent(x.eventId);
|
||||
expect(h.row(x.eventId)).toMatchObject({ [Events.DELETED]: 1, [Events.DIRTY]: 1 });
|
||||
const local = (await h.local('bl'))!;
|
||||
const plan = calendarPlanner.planUpload(local, h.ctx);
|
||||
expect(patchOf(plan).patch).toEqual({ 'recurrenceOverrides/2026-10-07T09:00:00': { excluded: true } });
|
||||
await accept(h, weekly(), local, plan);
|
||||
expect(h.row(x.eventId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not make an occurrence an app left without a status (Google Calendar\'s "this event") tentative', async () => {
|
||||
const event = single({ recurrenceRule: { frequency: 'weekly', count: 5 } });
|
||||
const { h, id } = await synced(event);
|
||||
h.fake.user.insertException(id, utc('2026-10-13T10:00:00Z'), {
|
||||
[Events.TITLE]: 'Only this Tuesday', [Events.DTSTART]: utc('2026-10-13T10:00:00Z'), [Events.DTEND]: utc('2026-10-13T11:00:00Z'), [Events.STATUS]: null,
|
||||
});
|
||||
const local = (await h.local('e1'))!;
|
||||
const plan = calendarPlanner.planUpload(local, h.ctx);
|
||||
const overrides = patchOf(plan).patch.recurrenceOverrides as Record<string, Record<string, unknown>>;
|
||||
expect(overrides['2026-10-13T12:00:00']).toEqual({ title: 'Only this Tuesday', start: '2026-10-13T12:00:00', duration: 'PT1H' });
|
||||
await accept(h, event, local, plan);
|
||||
const [x] = (await h.local('e1'))!.exceptions;
|
||||
expect(x.cells).toMatchObject({ [Events.STATUS]: 1, [Events.DIRTY]: 0 });
|
||||
});
|
||||
|
||||
it('clears an occurrence\'s own description and location instead of letting the series\' come back', async () => {
|
||||
const event = weekly();
|
||||
event.description = 'Daily agenda';
|
||||
event.recurrenceOverrides!['2026-10-07T09:00:00'] = {
|
||||
...event.recurrenceOverrides!['2026-10-07T09:00:00'],
|
||||
description: 'Special agenda',
|
||||
locations: { locB: { '@type': 'Location', name: 'Room 2' } },
|
||||
};
|
||||
const { h } = await synced(event);
|
||||
const x = (await h.local('bl'))!.exceptions[0];
|
||||
expect(h.row(x.eventId)).toMatchObject({ [Events.DESCRIPTION]: 'Special agenda', [Events.EVENT_LOCATION]: 'Room 2' });
|
||||
h.fake.user.updateEvent(x.eventId, { [Events.DESCRIPTION]: null, [Events.EVENT_LOCATION]: null });
|
||||
const local = (await h.local('bl'))!;
|
||||
const plan = calendarPlanner.planUpload(local, h.ctx);
|
||||
const server = await accept(h, event, local, plan);
|
||||
expect(h.row(x.eventId)).toMatchObject({ [Events.DESCRIPTION]: null, [Events.EVENT_LOCATION]: null, [Events.DIRTY]: 0 });
|
||||
expect(calendarPlanner.planDownload(server, (await h.local('bl'))!, h.ctx).effect).toBe('none');
|
||||
// Without a value of its own the override would show the series' "Daily agenda" and "Room 1" again, so it
|
||||
// gets the empty ones Stalwart keeps (an empty map would read back as no locations, i.e. the series' ones).
|
||||
expect(patchOf(plan).patch).toEqual({
|
||||
'recurrenceOverrides/2026-10-07T09:00:00/description': '',
|
||||
'recurrenceOverrides/2026-10-07T09:00:00/locations': { locB: { '@type': 'Location', name: '' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('removes an occurrence\'s own description and location when the series has none to show instead', async () => {
|
||||
const event = weekly();
|
||||
delete event.description;
|
||||
delete event.locations;
|
||||
event.recurrenceOverrides!['2026-10-07T09:00:00'] = {
|
||||
...event.recurrenceOverrides!['2026-10-07T09:00:00'],
|
||||
description: 'Special agenda',
|
||||
locations: { locB: { '@type': 'Location', name: 'Room 2' } },
|
||||
};
|
||||
const { h } = await synced(event);
|
||||
const x = (await h.local('bl'))!.exceptions[0];
|
||||
h.fake.user.updateEvent(x.eventId, { [Events.DESCRIPTION]: null, [Events.EVENT_LOCATION]: null });
|
||||
expect(patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)).patch).toEqual({
|
||||
'recurrenceOverrides/2026-10-07T09:00:00/description': null,
|
||||
'recurrenceOverrides/2026-10-07T09:00:00/locations': null,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a reminder changed on one occurrence of a series that uses the calendar\'s default alerts', async () => {
|
||||
const event = weekly();
|
||||
delete event.alerts;
|
||||
event.useDefaultAlerts = true;
|
||||
const { h, id } = await synced(event);
|
||||
const minutes = (eventId: number) => h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [eventId]).map((r) => Number(r[Reminders.MINUTES]));
|
||||
const x = (await h.local('bl'))!.exceptions[0];
|
||||
// The calendar's default (10 minutes) on the series and on its occurrences.
|
||||
expect([minutes(id), minutes(x.eventId)]).toEqual([[10], [10]]);
|
||||
h.fake.user.setReminders(x.eventId, [{ minutes: 60 }]);
|
||||
// A new occurrence edit with its own reminder, too (Etar "this event").
|
||||
const added = h.fake.user.insertException(id, utc('2026-10-14T13:00:00Z'), {
|
||||
[Events.TITLE]: 'Probe weekly', [Events.DTSTART]: utc('2026-10-14T13:00:00Z'), [Events.DTEND]: utc('2026-10-14T14:30:00Z'),
|
||||
});
|
||||
h.fake.user.setReminders(added, [{ minutes: 5 }]);
|
||||
const local = (await h.local('bl'))!;
|
||||
const plan = calendarPlanner.planUpload(local, h.ctx);
|
||||
const server = await accept(h, event, local, plan);
|
||||
const after = (await h.local('bl'))!;
|
||||
expect(after.exceptions.map((e) => [e.recurrenceId, minutes(e.eventId), e.dirty])).toEqual([
|
||||
['2026-10-07T09:00:00', [60], false],
|
||||
['2026-10-14T09:00:00', [5], false],
|
||||
]);
|
||||
// The series and its other occurrences keep the calendar's defaults.
|
||||
expect(server.useDefaultAlerts).toBe(true);
|
||||
expect(minutes(id)).toEqual([10]);
|
||||
expect(calendarPlanner.planDownload(server, after, h.ctx).effect).toBe('none');
|
||||
// Only the occurrences leave the defaults: `useDefaultAlerts` is a property an override can patch.
|
||||
const patch = patchOf(plan).patch;
|
||||
expect(patch['recurrenceOverrides/2026-10-07T09:00:00/useDefaultAlerts']).toBe(false);
|
||||
expect(patch['recurrenceOverrides/2026-10-14T09:00:00']).toMatchObject({ useDefaultAlerts: false });
|
||||
});
|
||||
|
||||
it('clears an occurrence\'s own colour instead of letting the series\' come back', async () => {
|
||||
const event = weekly();
|
||||
event.recurrenceOverrides!['2026-10-07T09:00:00'] = { ...event.recurrenceOverrides!['2026-10-07T09:00:00'], color: 'red' };
|
||||
const { h } = await synced(event);
|
||||
const x = (await h.local('bl'))!.exceptions[0];
|
||||
expect(h.row(x.eventId)[Events.EVENT_COLOR]).toBe(cssColorToArgb('red'));
|
||||
h.fake.user.updateEvent(x.eventId, { [Events.EVENT_COLOR]: null });
|
||||
const local = (await h.local('bl'))!;
|
||||
|
||||
const plan = calendarPlanner.planUpload(local, h.ctx);
|
||||
|
||||
// Without a colour of its own the occurrence would show the series' steelblue again: an empty one, which Stalwart keeps.
|
||||
expect(patchOf(plan).patch).toEqual({ 'recurrenceOverrides/2026-10-07T09:00:00/color': '' });
|
||||
const server = await accept(h, event, local, plan);
|
||||
expect(h.row(x.eventId)).toMatchObject({ [Events.EVENT_COLOR]: null, [Events.DIRTY]: 0 });
|
||||
expect(calendarPlanner.planDownload(server, (await h.local('bl'))!, h.ctx).effect).toBe('none');
|
||||
|
||||
// A series without a colour has none to show: the occurrence's own is removed.
|
||||
const plain = weekly();
|
||||
delete plain.color;
|
||||
plain.recurrenceOverrides!['2026-10-07T09:00:00'] = { ...plain.recurrenceOverrides!['2026-10-07T09:00:00'], color: 'red' };
|
||||
const p = await synced(plain);
|
||||
const px = (await p.h.local('bl'))!.exceptions[0];
|
||||
p.h.fake.user.updateEvent(px.eventId, { [Events.EVENT_COLOR]: null });
|
||||
expect(patchOf(calendarPlanner.planUpload((await p.h.local('bl'))!, p.h.ctx)).patch).toEqual({ 'recurrenceOverrides/2026-10-07T09:00:00/color': null });
|
||||
});
|
||||
|
||||
it('removes every reminder of one occurrence of a series on the calendar\'s defaults', async () => {
|
||||
const event = weekly();
|
||||
delete event.alerts;
|
||||
event.useDefaultAlerts = true;
|
||||
event.recurrenceOverrides!['2026-10-07T09:00:00'] = {
|
||||
...event.recurrenceOverrides!['2026-10-07T09:00:00'],
|
||||
useDefaultAlerts: false,
|
||||
alerts: { a60: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT1H', relativeTo: 'start' }, action: 'display' } },
|
||||
};
|
||||
const { h } = await synced(event);
|
||||
const x = (await h.local('bl'))!.exceptions[0];
|
||||
h.fake.user.setReminders(x.eventId, []);
|
||||
const local = (await h.local('bl'))!;
|
||||
|
||||
const plan = calendarPlanner.planUpload(local, h.ctx);
|
||||
const server = await accept(h, event, local, plan);
|
||||
|
||||
// Stalwart drops the empty map, but the series has no alerts of its own to show instead.
|
||||
expect(server.recurrenceOverrides!['2026-10-07T09:00:00']).toMatchObject({ useDefaultAlerts: false });
|
||||
expect(server.recurrenceOverrides!['2026-10-07T09:00:00']).not.toHaveProperty('alerts');
|
||||
expect(h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [x.eventId])).toEqual([]);
|
||||
expect(h.row(x.eventId)[Events.DIRTY]).toBe(0);
|
||||
expect(calendarPlanner.planDownload(server, (await h.local('bl'))!, h.ctx).effect).toBe('none');
|
||||
});
|
||||
|
||||
it('puts back and reports the removal of every reminder of one occurrence whose series would show its own again', async () => {
|
||||
const event = weekly();
|
||||
event.alerts = { al1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' }, action: 'display' } };
|
||||
const { h } = await synced(event);
|
||||
const minutes = (eventId: number) => h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [eventId]).map((r) => Number(r[Reminders.MINUTES]));
|
||||
const x = (await h.local('bl'))!.exceptions[0];
|
||||
expect(minutes(x.eventId)).toEqual([15]);
|
||||
h.fake.user.setReminders(x.eventId, []);
|
||||
|
||||
// An override without alerts shows the series' (Stalwart writes no VALARM for an empty map): none can't be stored.
|
||||
const plan = calendarPlanner.planUpload((await h.local('bl'))!, h.ctx);
|
||||
expect(plan).toMatchObject({ kind: 'revert', reason: 'remindersNotRepresentable' });
|
||||
if (plan.kind === 'revert') await h.apply(plan.ops);
|
||||
expect(minutes(x.eventId)).toEqual([15]);
|
||||
expect(h.row(x.eventId)[Events.DIRTY]).toBe(0);
|
||||
|
||||
// With another edit, that one goes up alone.
|
||||
h.fake.user.setReminders(x.eventId, []);
|
||||
h.fake.user.updateEvent(x.eventId, { [Events.TITLE]: 'Moved again' });
|
||||
expect(patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)).patch).toEqual({ 'recurrenceOverrides/2026-10-07T09:00:00/title': 'Moved again' });
|
||||
});
|
||||
|
||||
it('patches an existing override one level deep, never deeper', async () => {
|
||||
const { h } = await synced(weekly());
|
||||
const x = (await h.local('bl'))!.exceptions[0];
|
||||
h.fake.user.updateEvent(x.eventId, { [Events.TITLE]: 'Moved again', [Events.EVENT_LOCATION]: 'Annex' });
|
||||
const action = patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
|
||||
expect(action.patch).toEqual({
|
||||
'recurrenceOverrides/2026-10-07T09:00:00/title': 'Moved again',
|
||||
'recurrenceOverrides/2026-10-07T09:00:00/locations': { locA: { name: 'Annex', '@type': 'Location' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('answers one instance (CONTENT_EXCEPTION_URI) with the override\'s whole participants map', async () => {
|
||||
const invite = weekly();
|
||||
invite.organizerCalendarAddress = 'mailto:boss@example.net';
|
||||
invite.participants = {
|
||||
boss: { calendarAddress: 'mailto:boss@example.net', roles: { owner: true }, participationStatus: 'accepted' },
|
||||
me: { calendarAddress: 'mailto:usera@example.org', roles: { attendee: true }, participationStatus: 'needs-action' },
|
||||
other: { calendarAddress: 'mailto:other@example.net', roles: { attendee: true }, participationStatus: 'accepted' },
|
||||
};
|
||||
const { h, id } = await synced(invite);
|
||||
h.fake.user.exceptionViaUri(id, utc('2026-10-19T13:00:00Z'), { [Events.SELF_ATTENDEE_STATUS]: 1, [Events.STATUS]: 1 });
|
||||
const action = patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
|
||||
const override = action.patch['recurrenceOverrides/2026-10-19T09:00:00'] as Record<string, unknown>;
|
||||
expect(override.participants).toEqual({ ...invite.participants, me: { ...invite.participants!.me, participationStatus: 'accepted' } });
|
||||
// Etar's STATUS_CONFIRMED on the instance is not the attendee's to upload: the series' status is kept.
|
||||
expect(override).toMatchObject({ status: 'tentative', start: '2026-10-19T09:00:00', duration: 'PT1H30M', title: 'Probe weekly' });
|
||||
for (const key of NO_FORBIDDEN) expect(override).not.toHaveProperty(key);
|
||||
// An attendee's RSVP sends the REPLY.
|
||||
expect(action.sendSchedulingMessages).toBe(true);
|
||||
});
|
||||
|
||||
it('lets an answer to one occurrence through in a calendar that grants only RSVP, and nothing more', async () => {
|
||||
const invite = weekly();
|
||||
invite.calendarIds = { r: true };
|
||||
invite.organizerCalendarAddress = 'mailto:boss@example.net';
|
||||
invite.participants = {
|
||||
boss: { calendarAddress: 'mailto:boss@example.net', roles: { owner: true }, participationStatus: 'accepted' },
|
||||
me: { calendarAddress: 'mailto:usera@example.org', roles: { attendee: true }, participationStatus: 'needs-action' },
|
||||
};
|
||||
const { h, id } = await synced(invite);
|
||||
// Etar answers Oct 19 through CONTENT_EXCEPTION_URI (a new override)...
|
||||
const added = h.fake.user.exceptionViaUri(id, utc('2026-10-19T13:00:00Z'), { [Events.SELF_ATTENDEE_STATUS]: 2, [Events.STATUS]: 1 });
|
||||
// ... and another app answers the existing override of Oct 7 on its row.
|
||||
const existing = (await h.local('bl'))!.exceptions.find((x) => x.recurrenceId === '2026-10-07T09:00:00')!;
|
||||
h.fake.table('attendees').forEach((a) => {
|
||||
if (a.event_id === existing.eventId && a[Attendees.ATTENDEE_EMAIL] === ME) a[Attendees.ATTENDEE_STATUS] = 1;
|
||||
});
|
||||
h.fake.user.updateEvent(existing.eventId, {});
|
||||
const action = patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
|
||||
type Answer = { participants: Record<string, { participationStatus?: string }> };
|
||||
expect((action.patch['recurrenceOverrides/2026-10-19T09:00:00'] as Answer).participants.me.participationStatus).toBe('declined');
|
||||
expect((action.patch['recurrenceOverrides/2026-10-07T09:00:00/participants'] as Answer['participants']).me.participationStatus).toBe('accepted');
|
||||
expect(action.sendSchedulingMessages).toBe(true);
|
||||
// Anything more than the answer is still the calendar owner's to change.
|
||||
h.fake.user.updateEvent(added, { [Events.TITLE]: 'Mine now' });
|
||||
expect(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)).toMatchObject({ kind: 'revert', reason: 'readOnly' });
|
||||
});
|
||||
|
||||
it('turns EXDATE entries an app adds or removes into exclusions and re-inclusions', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.updateEvent(id, { [Events.EXDATE]: '20261012T130000Z\nAmerica/New_York;20261021T090000' });
|
||||
expect(patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)).patch).toEqual({
|
||||
'recurrenceOverrides/2026-10-21T09:00:00': { excluded: true },
|
||||
});
|
||||
h.fake.user.updateEvent(id, { [Events.EXDATE]: null });
|
||||
expect(patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)).patch).toEqual({
|
||||
'recurrenceOverrides/2026-10-12T09:00:00': null,
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads Etar\'s "this and following" as a capped rule, prunes later overrides, and creates the new series', async () => {
|
||||
const event = weekly();
|
||||
event.recurrenceRule = { frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'we' }] };
|
||||
event.recurrenceOverrides = {
|
||||
'2026-10-07T09:00:00': { title: 'Moved Wednesday', start: '2026-10-07T11:00:00' },
|
||||
'2026-10-21T09:00:00': { title: 'Later one' },
|
||||
'2026-10-26T09:00:00': { excluded: true },
|
||||
};
|
||||
const { h, id } = await synced(event);
|
||||
// Split at Mon 2026-10-19 09:00 New York: UNTIL = the instance minus one second, in UTC.
|
||||
h.fake.user.updateEvent(id, { [Events.RRULE]: 'FREQ=WEEKLY;UNTIL=20261019T125959Z;BYDAY=MO,WE' });
|
||||
const fresh = h.fake.user.insertEvent(h.rowIds.get('b')!, {
|
||||
[Events.TITLE]: 'Probe weekly (new time)',
|
||||
[Events.DTSTART]: utc('2026-10-19T14:00:00Z'),
|
||||
[Events.DURATION]: 'P5400S',
|
||||
[Events.RRULE]: 'FREQ=WEEKLY;BYDAY=MO,WE',
|
||||
[Events.EVENT_TIMEZONE]: 'America/New_York',
|
||||
[Events.STATUS]: 1,
|
||||
});
|
||||
const action = patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
|
||||
expect(action.patch).toEqual({
|
||||
recurrenceRule: { frequency: 'weekly', until: '2026-10-19T08:59:59', byDay: [{ day: 'mo' }, { day: 'we' }] },
|
||||
'recurrenceOverrides/2026-10-21T09:00:00': null,
|
||||
'recurrenceOverrides/2026-10-26T09:00:00': null,
|
||||
});
|
||||
|
||||
// The new series is a new event: claimed with a fresh uid, then created.
|
||||
const newRow = await h.byRow(fresh);
|
||||
const claim = calendarPlanner.planUpload(newRow, h.ctx);
|
||||
expect(claim.kind).toBe('claim');
|
||||
if (claim.kind === 'claim') await h.apply(claim.ops);
|
||||
const claimed = await h.byRow(fresh);
|
||||
expect(claimed.syncId).toMatch(/^~pending\//);
|
||||
expect(claimed.pending).toEqual({ uid: claimed.cells[Events.UID_2445], target: `${ACCT}/b` });
|
||||
const create = calendarPlanner.planUpload(claimed, h.ctx);
|
||||
expect(create.kind).toBe('upload');
|
||||
if (create.kind !== 'upload' || create.actions[0].kind !== 'create') throw new Error('no create');
|
||||
expect(create.actions[0]).toMatchObject({
|
||||
kind: 'create',
|
||||
uid: claimed.pending!.uid,
|
||||
collectionId: 'b',
|
||||
object: {
|
||||
'@type': 'Event',
|
||||
uid: claimed.pending!.uid,
|
||||
calendarIds: { b: true },
|
||||
title: 'Probe weekly (new time)',
|
||||
start: '2026-10-19T10:00:00',
|
||||
timeZone: 'America/New_York',
|
||||
duration: 'PT1H30M',
|
||||
recurrenceRule: { frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'we' }] },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('prunes overrides after a series Etar capped with a lower COUNT, using the provider\'s LAST_DATE', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
// COUNT 10 → 3 (Mon 5, Wed 7, Mon 12); the provider computed LAST_DATE = last start + duration.
|
||||
h.fake.user.updateEvent(id, { [Events.RRULE]: 'FREQ=WEEKLY;COUNT=3;BYDAY=MO,WE' });
|
||||
h.fake.table('events').get(id)![Events.LAST_DATE] = utc('2026-10-12T13:00:00Z') + 5_400_000;
|
||||
const event = weekly();
|
||||
event.recurrenceOverrides!['2026-10-14T09:00:00'] = { title: 'x' };
|
||||
const local = (await h.local('bl'))!;
|
||||
local.shadow = event;
|
||||
expect(patchOf(calendarPlanner.planUpload(local, h.ctx)).patch).toEqual({
|
||||
recurrenceRule: { frequency: 'weekly', count: 3, byDay: [{ day: 'mo' }, { day: 'we' }] },
|
||||
'recurrenceOverrides/2026-10-14T09:00:00': null,
|
||||
});
|
||||
});
|
||||
|
||||
it('moves every recurrence id with an "all events" start change', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-05T14:00:00Z') });
|
||||
const action = patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
|
||||
expect(action.patch).toEqual({
|
||||
start: '2026-10-05T10:00:00',
|
||||
recurrenceOverrides: {
|
||||
'2026-10-12T10:00:00': { excluded: true },
|
||||
// Its own start was a move elsewhere: it stays.
|
||||
'2026-10-07T10:00:00': { updated: '2026-09-26T22:57:38Z', title: 'Moved Wednesday', start: '2026-10-07T11:00:00' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves a CONTENT_EXCEPTION_URI split: the clone becomes a new event, the source uploads its capped rule', async () => {
|
||||
const event = weekly();
|
||||
event.recurrenceRule = { frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'we' }] };
|
||||
event.recurrenceOverrides = { '2026-10-07T09:00:00': { title: 'Moved Wednesday', start: '2026-10-07T11:00:00' }, '2026-10-21T09:00:00': { title: 'Later' } };
|
||||
const { h, id } = await synced(event);
|
||||
const clone = h.fake.user.splitViaUri(id, utc('2026-10-19T13:00:00Z'), '20261019T125959Z', { [Events.TITLE]: 'New series' });
|
||||
const all = await h.events();
|
||||
const source = all.find((e) => e.eventId === id)!;
|
||||
const cloneEvent = all.find((e) => e.eventId === clone)!;
|
||||
expect([source.split, cloneEvent.split]).toEqual(['source', 'clone']);
|
||||
expect(source.dirty).toBe(false);
|
||||
|
||||
// The source's rule was capped without DIRTY; it uploads as it stands.
|
||||
expect(patchOf(calendarPlanner.planUpload(source, h.ctx)).patch).toEqual({
|
||||
recurrenceRule: { frequency: 'weekly', until: '2026-10-19T08:59:59', byDay: [{ day: 'mo' }, { day: 'we' }] },
|
||||
'recurrenceOverrides/2026-10-21T09:00:00': null,
|
||||
});
|
||||
|
||||
// The clone is claimed under a fresh uid, drops the source's sync data and marks the source dirty.
|
||||
const claim = calendarPlanner.planUpload(cloneEvent, h.ctx);
|
||||
expect(claim.kind).toBe('claim');
|
||||
if (claim.kind === 'claim') await h.apply(claim.ops);
|
||||
const after = await h.events();
|
||||
const claimed = after.find((e) => e.eventId === clone)!;
|
||||
expect(claimed.syncId).toMatch(/^~pending\//);
|
||||
expect(claimed.cells[Events.UID_2445]).not.toBe(event.uid);
|
||||
expect(claimed.shadow).toBeNull();
|
||||
expect(claimed.split).toBeUndefined();
|
||||
const sourceAfter = after.find((e) => e.eventId === id)!;
|
||||
expect(sourceAfter.dirty).toBe(true);
|
||||
// The source keeps its exceptions (and their link) whatever the provider's trigger did.
|
||||
expect(sourceAfter.exceptions.map((x) => x.cells[Events.ORIGINAL_SYNC_ID])).toEqual([`${ACCT}/bl`, `${ACCT}/bl`]);
|
||||
expect(patchOf(calendarPlanner.planUpload(sourceAfter, h.ctx)).patch.recurrenceRule).toMatchObject({ until: '2026-10-19T08:59:59' });
|
||||
});
|
||||
|
||||
it('keeps a local exception edit through a download that changed another instance', async () => {
|
||||
const { h } = await synced(weekly());
|
||||
const x = (await h.local('bl'))!.exceptions[0];
|
||||
h.fake.user.updateEvent(x.eventId, { [Events.TITLE]: 'Local title' });
|
||||
const remote = weekly();
|
||||
remote.recurrenceOverrides!['2026-10-19T09:00:00'] = { title: 'Remote added' };
|
||||
const plan = await h.download(remote);
|
||||
expect(plan).toMatchObject({ conflicts: 0, stillDirty: true });
|
||||
const after = (await h.local('bl'))!;
|
||||
expect(after.exceptions.map((e) => [e.recurrenceId, e.cells[Events.TITLE], e.dirty])).toEqual([
|
||||
['2026-10-07T09:00:00', 'Local title', true],
|
||||
['2026-10-19T09:00:00', 'Remote added', false],
|
||||
]);
|
||||
expect(patchOf(calendarPlanner.planUpload(after, h.ctx)).patch).toEqual({ 'recurrenceOverrides/2026-10-07T09:00:00/title': 'Local title' });
|
||||
});
|
||||
|
||||
it('lets a server removal of an override win over a local edit of it', async () => {
|
||||
const { h } = await synced(weekly());
|
||||
const x = (await h.local('bl'))!.exceptions[0];
|
||||
h.fake.user.updateEvent(x.eventId, { [Events.TITLE]: 'Local title' });
|
||||
const remote = weekly();
|
||||
delete remote.recurrenceOverrides!['2026-10-07T09:00:00'];
|
||||
const plan = await h.download(remote);
|
||||
expect(plan.conflicts).toBe(1);
|
||||
expect(h.row(x.eventId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lets a device deletion of an instance win over a server edit of it', async () => {
|
||||
const { h } = await synced(weekly());
|
||||
const x = (await h.local('bl'))!.exceptions[0];
|
||||
h.fake.user.deleteEvent(x.eventId);
|
||||
const remote = weekly();
|
||||
remote.recurrenceOverrides!['2026-10-07T09:00:00'] = { title: 'Remote title', start: '2026-10-07T11:00:00' };
|
||||
const plan = await h.download(remote);
|
||||
expect(plan).toMatchObject({ conflicts: 0, stillDirty: true });
|
||||
expect(patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)).patch).toEqual({
|
||||
'recurrenceOverrides/2026-10-07T09:00:00': { excluded: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('writes an all-day series\' exceptions at UTC midnight and keys them by date', async () => {
|
||||
const event = single({
|
||||
id: 'ad',
|
||||
start: '2026-10-05T00:00:00',
|
||||
timeZone: null,
|
||||
showWithoutTime: true,
|
||||
duration: 'P1D',
|
||||
recurrenceRule: { frequency: 'daily', count: 5 },
|
||||
recurrenceOverrides: { '2026-10-06T00:00:00': { title: 'Day two' }, '2026-10-07T00:00:00': { excluded: true } },
|
||||
});
|
||||
const { h, id } = await synced(event);
|
||||
expect(h.row(id)).toMatchObject({ [Events.EXDATE]: '20261007', [Events.DURATION]: 'P1D' });
|
||||
const [x] = (await h.local('ad'))!.exceptions;
|
||||
expect(x.cells).toMatchObject({ [Events.ORIGINAL_INSTANCE_TIME]: utc('2026-10-06T00:00:00Z'), [Events.ORIGINAL_ALL_DAY]: 1, [Events.DTSTART]: utc('2026-10-06T00:00:00Z'), [Events.DTEND]: utc('2026-10-07T00:00:00Z') });
|
||||
// Etar cancels day four.
|
||||
h.fake.user.cancelInstance(id, utc('2026-10-08T00:00:00Z'), utc('2026-10-09T00:00:00Z'));
|
||||
expect(patchOf(calendarPlanner.planUpload((await h.local('ad'))!, h.ctx)).patch).toEqual({ 'recurrenceOverrides/2026-10-08T00:00:00': { excluded: true } });
|
||||
});
|
||||
|
||||
it('skips a series whose instance to change is a repeated wall time', async () => {
|
||||
const event = single({ id: 'n', start: '2026-10-20T02:30:00', recurrenceRule: { frequency: 'daily' } });
|
||||
const { h, id } = await synced(event);
|
||||
// Etar "this event" on the 25th: 02:30 happens twice that night in Berlin.
|
||||
h.fake.user.insertException(id, utc('2026-10-25T00:30:00Z'), { [Events.TITLE]: 'x', [Events.DTSTART]: utc('2026-10-25T00:30:00Z'), [Events.DTEND]: utc('2026-10-25T01:30:00Z') });
|
||||
expect(calendarPlanner.planUpload((await h.local('n'))!, h.ctx)).toEqual({ kind: 'skip', reason: 'dstAmbiguous' });
|
||||
});
|
||||
|
||||
it('moves an UNTIL that falls into a DST gap or overlap one hour later', async () => {
|
||||
const { h, id } = await synced(single({ recurrenceRule: { frequency: 'daily' } }));
|
||||
// Until 2026-10-25 00:30Z = 02:30 CEST, a repeated wall time Stalwart would drop.
|
||||
h.fake.user.updateEvent(id, { [Events.RRULE]: 'FREQ=DAILY;UNTIL=20261025T003000Z' });
|
||||
expect(patchOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({
|
||||
recurrenceRule: { frequency: 'daily', until: '2026-10-25T03:30:00' },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the organizer and other attendees on the instance a pointer RSVP would have dropped', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
const x = (await h.local('bl'))!.exceptions[0];
|
||||
h.fake.table('attendees').forEach((a) => {
|
||||
if (a.event_id === x.eventId && a[Attendees.ATTENDEE_EMAIL] === ME) a[Attendees.ATTENDEE_STATUS] = 2;
|
||||
});
|
||||
h.fake.user.updateEvent(x.eventId, {});
|
||||
const action = patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
|
||||
expect(Object.keys(action.patch)).toEqual(['recurrenceOverrides/2026-10-07T09:00:00/participants']);
|
||||
expect(Object.keys(action.patch['recurrenceOverrides/2026-10-07T09:00:00/participants'] as object)).toEqual(['me1', 'guestX']);
|
||||
expect(id).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Test harness for the calendar planner: the fake CalendarProvider, a
|
||||
* CalendarContext over a few server calendars, and helpers that plan, apply
|
||||
* the op groups the way the engine does and read the rows back.
|
||||
*/
|
||||
import { expect } from 'vitest';
|
||||
import { FakeDeviceProviders } from '../fakes/fake-provider';
|
||||
import { CALENDAR_AUTHORITY, type ProviderPort, type Row } from '../../types';
|
||||
import type { CalendarContext, LocalCalendar, LocalEvent, OpGroup } from '../../planner';
|
||||
import type { CalendarEventWire, CalendarLike } from '../../wire';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import { applyPatch, type PatchObject } from '../../common/patch';
|
||||
import { makeKeyMinter, parseCollectionKey, uuidFrom } from '../../common/ids';
|
||||
import { Events } from '../../android-columns';
|
||||
|
||||
export const ACCOUNT = 'usera@example.org';
|
||||
export const ME = 'usera@example.org';
|
||||
export const ALIAS = 'alias@example.org';
|
||||
export const ACCT = 'c';
|
||||
|
||||
export function seeded(seed: number) {
|
||||
return () => {
|
||||
seed = (seed * 1103515245 + 12345) % 2147483648;
|
||||
return seed / 2147483648;
|
||||
};
|
||||
}
|
||||
|
||||
export const CALENDARS: Record<string, CalendarLike> = {
|
||||
b: {
|
||||
id: 'b',
|
||||
name: 'Personal',
|
||||
color: '#3b82f6',
|
||||
myRights: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayRSVP: true, mayDelete: true },
|
||||
defaultAlertsWithTime: { d1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT10M' }, action: 'display' } },
|
||||
defaultAlertsWithoutTime: { d2: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15H' }, action: 'display' } },
|
||||
} as CalendarLike,
|
||||
w: { id: 'w', name: 'Work', color: '#22c55e', myRights: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayRSVP: true } } as CalendarLike,
|
||||
r: { id: 'r', name: 'Team (read)', myRights: { mayReadItems: true, mayRSVP: true } } as CalendarLike,
|
||||
};
|
||||
|
||||
export interface HarnessOptions {
|
||||
deviceZone?: string;
|
||||
reminderOwner?: 'device' | 'bulwark';
|
||||
readOnly?: string[];
|
||||
now?: number;
|
||||
}
|
||||
|
||||
export class Harness {
|
||||
readonly fake = new FakeDeviceProviders();
|
||||
readonly port: ProviderPort = this.fake.port(ACCOUNT, CALENDAR_AUTHORITY);
|
||||
readonly rowIds = new Map<string, number>();
|
||||
ctx: CalendarContext;
|
||||
|
||||
constructor(options: HarnessOptions = {}) {
|
||||
const random = seeded(7);
|
||||
const readOnly = new Set(options.readOnly ?? ['r']);
|
||||
this.ctx = {
|
||||
jmapAccountId: ACCT,
|
||||
now: options.now ?? Date.parse('2026-09-27T12:00:00Z'),
|
||||
mintKey: makeKeyMinter(random),
|
||||
mintUid: () => uuidFrom(random),
|
||||
deviceZone: options.deviceZone ?? 'Europe/Berlin',
|
||||
ownerAccount: ME,
|
||||
selfAddresses: [ME, ALIAS],
|
||||
reminderOwner: options.reminderOwner ?? 'device',
|
||||
calendar: (id) => CALENDARS[id],
|
||||
calendarRowId: (id) => this.rowIds.get(id) ?? null,
|
||||
calendarIdOfRow: (rowId) => {
|
||||
for (const [id, row] of this.rowIds) if (row === rowId) return { jmapAccountId: ACCT, calendarId: id };
|
||||
return null;
|
||||
},
|
||||
isReadOnly: (id) => readOnly.has(id),
|
||||
isSelected: (key) => parseCollectionKey(key)?.accountId === ACCT,
|
||||
};
|
||||
}
|
||||
|
||||
/** Inserts the calendar rows through planCalendars. */
|
||||
async setup(ids: string[] = ['b', 'w', 'r']): Promise<this> {
|
||||
const plan = calendarPlanner.planCalendars(
|
||||
ids.map((id) => ({ jmapAccountId: ACCT, calendar: CALENDARS[id], readOnly: this.ctx.isReadOnly(id), accountName: ME })),
|
||||
[],
|
||||
this.ctx,
|
||||
);
|
||||
for (const group of plan.groups) await this.apply(group);
|
||||
for (const cal of await this.calendars()) this.rowIds.set(parseCollectionKey(cal.syncId)!.id, cal.calendarRowId);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Applies a group and the groups after it (`next`), each in a batch of its own, as the engine does. */
|
||||
async apply(group: OpGroup): Promise<void> {
|
||||
for (const g of [group, ...(group.next ?? [])]) {
|
||||
const result = await this.port.applyBatch(g.ops);
|
||||
if (!result.ok) throw new Error(`batch ${group.ref} failed: ${result.reason} ${result.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async tryApply(group: OpGroup) {
|
||||
return this.port.applyBatch(group.ops);
|
||||
}
|
||||
|
||||
async query(table: 'events' | 'attendees' | 'reminders' | 'calendars', columns: readonly string[]): Promise<Row[]> {
|
||||
const res = await this.port.query({ table, columns: [...columns] });
|
||||
return res.rows.map((cells) => Object.fromEntries(res.columns.map((c, i) => [c, cells[i]])));
|
||||
}
|
||||
|
||||
async calendars(): Promise<LocalCalendar[]> {
|
||||
return (await this.query('calendars', calendarPlanner.calendarColumns)).map(calendarPlanner.decodeCalendar);
|
||||
}
|
||||
|
||||
async events(): Promise<LocalEvent[]> {
|
||||
return calendarPlanner.decodeEvents(
|
||||
await this.query('events', calendarPlanner.eventColumns),
|
||||
await this.query('attendees', calendarPlanner.attendeeColumns),
|
||||
await this.query('reminders', calendarPlanner.reminderColumns),
|
||||
);
|
||||
}
|
||||
|
||||
/** The local event with this server id (or null). */
|
||||
async local(id: string): Promise<LocalEvent | null> {
|
||||
return (await this.events()).find((e) => e.syncId === `${ACCT}/${id}`) ?? null;
|
||||
}
|
||||
|
||||
async byRow(eventId: number): Promise<LocalEvent> {
|
||||
const all = await this.events();
|
||||
const found = all.find((e) => e.eventId === eventId);
|
||||
if (!found) throw new Error(`no event row ${eventId}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Plans and applies a download of `event`, then heals baselines like the engine's read-back. */
|
||||
async download(event: CalendarEventWire) {
|
||||
const local = await this.local(event.id);
|
||||
const plan = calendarPlanner.planDownload(event, local, this.ctx);
|
||||
if (plan.effect !== 'none') await this.apply(plan.ops);
|
||||
await this.heal(event.id);
|
||||
return plan;
|
||||
}
|
||||
|
||||
async heal(id: string): Promise<void> {
|
||||
const local = await this.local(id);
|
||||
if (!local) return;
|
||||
const heal = calendarPlanner.planBaselineHeal(local);
|
||||
if (heal) await this.apply(heal);
|
||||
}
|
||||
|
||||
row(eventId: number): Row {
|
||||
return this.fake.row('events', eventId)!;
|
||||
}
|
||||
|
||||
masterRow(id: string): Row {
|
||||
const rows = this.fake.rows('events', `${Events._SYNC_ID} = ?`, [`${ACCT}/${id}`]);
|
||||
expect(rows).toHaveLength(1);
|
||||
return rows[0];
|
||||
}
|
||||
}
|
||||
|
||||
/** The server applying an accepted patch (Stalwart's normalisation of what matters here). */
|
||||
export function serverApply(event: CalendarEventWire, patch: PatchObject): CalendarEventWire {
|
||||
const next = applyPatch(event, patch);
|
||||
if (!next) throw new Error(`patch does not apply: ${JSON.stringify(patch)}`);
|
||||
const overrides = next.recurrenceOverrides;
|
||||
if (overrides) {
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
if (!value || typeof value !== 'object') continue;
|
||||
if ((value as Record<string, unknown>).excluded === true) {
|
||||
overrides[key] = { excluded: true };
|
||||
continue;
|
||||
}
|
||||
for (const forbidden of ['@type', 'method', 'organizerCalendarAddress', 'privacy', 'prodId', 'recurrenceId', 'recurrenceIdTimeZone', 'sentBy', 'uid', 'recurrenceRule', 'recurrenceOverrides']) {
|
||||
delete (value as Record<string, unknown>)[forbidden];
|
||||
}
|
||||
// An override is stored as its own VEVENT: an empty map writes nothing, so it reads back without it.
|
||||
for (const map of ['participants', 'alerts', 'locations']) {
|
||||
const inner = (value as Record<string, unknown>)[map];
|
||||
if (inner && typeof inner === 'object' && !Object.keys(inner).length) delete (value as Record<string, unknown>)[map];
|
||||
}
|
||||
}
|
||||
if (!Object.keys(overrides).length) delete next.recurrenceOverrides;
|
||||
}
|
||||
for (const map of ['participants', 'alerts', 'locations'] as const) {
|
||||
if (next[map] && !Object.keys(next[map] as object).length) delete next[map];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** The probe's weekly event as Stalwart returned it (scratchpad/probe/calendar.out.txt). */
|
||||
export function weekly(): CalendarEventWire {
|
||||
return {
|
||||
id: 'bl',
|
||||
'@type': 'Event',
|
||||
uid: 'probe-rec@example.org',
|
||||
calendarIds: { b: true },
|
||||
title: 'Probe weekly',
|
||||
description: 'desc',
|
||||
start: '2026-10-05T09:00:00',
|
||||
timeZone: 'America/New_York',
|
||||
duration: 'PT1H30M',
|
||||
status: 'tentative',
|
||||
freeBusyStatus: 'free',
|
||||
privacy: 'private',
|
||||
color: 'steelblue',
|
||||
keywords: { probe: true },
|
||||
priority: 5,
|
||||
recurrenceRule: { frequency: 'weekly', count: 10, firstDayOfWeek: 'mo', byDay: [{ day: 'mo' }, { day: 'we' }] },
|
||||
recurrenceOverrides: {
|
||||
'2026-10-12T09:00:00': { excluded: true },
|
||||
'2026-10-07T09:00:00': { updated: '2026-09-26T22:57:38Z', title: 'Moved Wednesday', start: '2026-10-07T11:00:00' },
|
||||
},
|
||||
locations: { locA: { name: 'Room 1', '@type': 'Location' } },
|
||||
participants: {
|
||||
me1: { calendarAddress: 'mailto:usera@example.org', '@type': 'Participant', participationStatus: 'accepted', roles: { owner: true, attendee: true } },
|
||||
guestX: { calendarAddress: 'mailto:guest@example.net', '@type': 'Participant', participationStatus: 'needs-action', name: 'Guest', expectReply: true, roles: { attendee: true } },
|
||||
},
|
||||
organizerCalendarAddress: 'mailto:usera@example.org',
|
||||
alerts: {
|
||||
al1: { action: 'display', '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' } },
|
||||
alEnd: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: 'PT5M', relativeTo: 'end' } },
|
||||
alAbs: { '@type': 'Alert', action: 'email', trigger: { '@type': 'AbsoluteTrigger', when: '2026-10-01T08:00:00Z' } },
|
||||
},
|
||||
updated: '2026-09-26T22:57:38Z',
|
||||
isDraft: false,
|
||||
} as CalendarEventWire;
|
||||
}
|
||||
|
||||
export function single(extra: Partial<CalendarEventWire> = {}): CalendarEventWire {
|
||||
return {
|
||||
id: 'e1',
|
||||
'@type': 'Event',
|
||||
uid: 'single-1@example.org',
|
||||
calendarIds: { b: true },
|
||||
title: 'Lunch',
|
||||
start: '2026-10-06T12:00:00',
|
||||
timeZone: 'Europe/Berlin',
|
||||
duration: 'PT1H',
|
||||
updated: '2026-09-26T10:00:00Z',
|
||||
...extra,
|
||||
} as CalendarEventWire;
|
||||
}
|
||||
|
||||
export const utc = (iso: string) => Date.parse(iso);
|
||||
@@ -0,0 +1,694 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Attendees, Events, Reminders } from '../../android-columns';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import type { LocalEvent, UploadPlan } from '../../planner';
|
||||
import type { CalendarEventWire } from '../../wire';
|
||||
import { ACCT, Harness, ME, serverApply, single, utc, weekly } from './harness';
|
||||
|
||||
type Plan = UploadPlan<CalendarEventWire>;
|
||||
|
||||
async function synced(event: CalendarEventWire, options = {}) {
|
||||
const h = await new Harness(options).setup();
|
||||
await h.download(event);
|
||||
return { h, id: Number(h.masterRow(event.id)._id) };
|
||||
}
|
||||
|
||||
function actionsOf(plan: Plan) {
|
||||
if (plan.kind !== 'upload') throw new Error(`expected an upload, got ${JSON.stringify(plan)}`);
|
||||
return plan.actions;
|
||||
}
|
||||
|
||||
/** Claims a new row, then returns its create action. */
|
||||
async function claimAndCreate(h: Harness, eventId: number) {
|
||||
const claim = calendarPlanner.planUpload(await h.byRow(eventId), h.ctx);
|
||||
expect(claim.kind).toBe('claim');
|
||||
if (claim.kind === 'claim') await h.apply(claim.ops);
|
||||
const local = await h.byRow(eventId);
|
||||
const [create] = actionsOf(calendarPlanner.planUpload(local, h.ctx));
|
||||
if (create.kind !== 'create') throw new Error('not a create');
|
||||
return { local, create };
|
||||
}
|
||||
|
||||
describe('calendar planner: new events', () => {
|
||||
it('claims, creates and adopts a device event without writing anything on its echo', async () => {
|
||||
const h = await new Harness().setup();
|
||||
const id = h.fake.user.insertEvent(
|
||||
h.rowIds.get('b')!,
|
||||
{ [Events.TITLE]: 'Dentist', [Events.DTSTART]: utc('2026-10-09T08:00:00Z'), [Events.DTEND]: utc('2026-10-09T09:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.EVENT_LOCATION]: 'Main St 1', [Events.STATUS]: 1, [Events.ACCESS_LEVEL]: 2, [Events.AVAILABILITY]: 0 },
|
||||
{ reminders: [{ [Reminders.MINUTES]: 30, [Reminders.METHOD]: 1 }] },
|
||||
);
|
||||
const { local, create } = await claimAndCreate(h, id);
|
||||
expect(local.syncId).toBe(`~pending/${create.uid}`);
|
||||
expect(create).toMatchObject({ collectionId: 'b', uid: local.pending!.uid });
|
||||
expect(create.sendSchedulingMessages).toBeUndefined();
|
||||
const object = create.object as Record<string, unknown>;
|
||||
expect(object).toMatchObject({
|
||||
'@type': 'Event',
|
||||
uid: create.uid,
|
||||
calendarIds: { b: true },
|
||||
title: 'Dentist',
|
||||
start: '2026-10-09T10:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'Europe/Berlin',
|
||||
status: 'confirmed',
|
||||
freeBusyStatus: 'busy',
|
||||
privacy: 'private',
|
||||
});
|
||||
expect(Object.values(object.locations as object)).toEqual([{ '@type': 'Location', name: 'Main St 1' }]);
|
||||
expect(Object.values(object.alerts as object)).toEqual([{ '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT30M', relativeTo: 'start' }, action: 'display' }]);
|
||||
|
||||
// The server stores it; the engine re-fetches it and applies the accepted plan.
|
||||
const server = { ...(object as CalendarEventWire), id: 'n1', updated: '2026-09-27T12:00:00Z' } as CalendarEventWire;
|
||||
const accepted = calendarPlanner.planAccepted(local, server, h.ctx);
|
||||
await h.apply(accepted.ops);
|
||||
const after = (await h.local('n1'))!;
|
||||
expect(after).toMatchObject({ syncId: `${ACCT}/n1`, dirty: false, pending: null });
|
||||
expect(after.cells[Events.UID_2445]).toBe(create.uid);
|
||||
expect(calendarPlanner.planDownload(server, after, h.ctx).effect).toBe('none');
|
||||
});
|
||||
|
||||
it('creates a new series with the exceptions and exclusions made before its upload', async () => {
|
||||
const h = await new Harness().setup();
|
||||
const id = h.fake.user.insertEvent(h.rowIds.get('b')!, {
|
||||
[Events.TITLE]: 'Standup',
|
||||
[Events.DTSTART]: utc('2026-10-05T07:00:00Z'),
|
||||
[Events.DURATION]: 'P900S',
|
||||
[Events.RRULE]: 'FREQ=DAILY;COUNT=5',
|
||||
[Events.EVENT_TIMEZONE]: 'Europe/Berlin',
|
||||
[Events.STATUS]: 1,
|
||||
});
|
||||
// Claimed first (the pending _SYNC_ID lets Etar offer "this event").
|
||||
const claim = calendarPlanner.planUpload(await h.byRow(id), h.ctx);
|
||||
if (claim.kind === 'claim') await h.apply(claim.ops);
|
||||
h.fake.user.insertException(id, utc('2026-10-06T07:00:00Z'), { [Events.TITLE]: 'Standup (late)', [Events.DTSTART]: utc('2026-10-06T08:00:00Z'), [Events.DTEND]: utc('2026-10-06T08:15:00Z') });
|
||||
h.fake.user.cancelInstance(id, utc('2026-10-07T07:00:00Z'), utc('2026-10-07T07:15:00Z'));
|
||||
const local = await h.byRow(id);
|
||||
expect(local.exceptions).toHaveLength(2);
|
||||
const [create] = actionsOf(calendarPlanner.planUpload(local, h.ctx));
|
||||
if (create.kind !== 'create') throw new Error('not a create');
|
||||
expect(create.object.recurrenceRule).toEqual({ frequency: 'daily', count: 5 });
|
||||
expect(create.object.recurrenceOverrides).toEqual({
|
||||
// The series' details a new override needs come along (withNewOverrideDetails).
|
||||
'2026-10-06T09:00:00': { title: 'Standup (late)', start: '2026-10-06T10:00:00', duration: 'PT15M', status: 'confirmed', freeBusyStatus: 'busy' },
|
||||
'2026-10-07T09:00:00': { excluded: true },
|
||||
});
|
||||
const server = { ...(create.object as CalendarEventWire), id: 's1' } as CalendarEventWire;
|
||||
await h.apply(calendarPlanner.planAccepted(local, server, h.ctx).ops);
|
||||
const after = (await h.local('s1'))!;
|
||||
expect(after.exceptions.map((x) => [x.syncId, x.dirty])).toEqual([[`${ACCT}/s1#2026-10-06T09:00:00`, false]]);
|
||||
expect(h.masterRow('s1')[Events.EXDATE]).toBe('20261007T070000Z');
|
||||
expect(calendarPlanner.planDownload(server, after, h.ctx).effect).toBe('none');
|
||||
});
|
||||
|
||||
it('creates an event an app left without a status (Google Calendar) without making it tentative', async () => {
|
||||
const h = await new Harness().setup();
|
||||
// CalendarProvider has no default for STATUS: an app that does not write it leaves it NULL.
|
||||
const id = h.fake.user.insertEvent(h.rowIds.get('b')!, {
|
||||
[Events.TITLE]: 'From Google Calendar', [Events.DTSTART]: utc('2026-10-14T15:00:00Z'), [Events.DTEND]: utc('2026-10-14T16:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: null,
|
||||
});
|
||||
const { local, create } = await claimAndCreate(h, id);
|
||||
expect(create.object).not.toHaveProperty('status');
|
||||
const server = { ...(create.object as CalendarEventWire), id: 'g1' } as CalendarEventWire;
|
||||
await h.apply(calendarPlanner.planAccepted(local, server, h.ctx).ops);
|
||||
// Written as JSCalendar's default, confirmed.
|
||||
expect(h.row(id)).toMatchObject({ [Events.STATUS]: 1, [Events.DIRTY]: 0 });
|
||||
expect(calendarPlanner.planDownload(server, (await h.local('g1'))!, h.ctx).effect).toBe('none');
|
||||
});
|
||||
|
||||
it('leaves out exclusions a new series inherited from before its start (Etar\'s split copies the EXDATE)', async () => {
|
||||
const h = await new Harness().setup();
|
||||
const id = h.fake.user.insertEvent(h.rowIds.get('b')!, {
|
||||
[Events.TITLE]: 'Weekly (new time)',
|
||||
[Events.DTSTART]: utc('2026-11-16T09:00:00Z'),
|
||||
[Events.DURATION]: 'P3600S',
|
||||
[Events.RRULE]: 'FREQ=WEEKLY;COUNT=3;BYDAY=MO',
|
||||
[Events.EVENT_TIMEZONE]: 'Europe/Berlin',
|
||||
// The old series' exclusions (Oct 12, Nov 2) and one of this series (Nov 23).
|
||||
[Events.EXDATE]: '20261012T060000Z,20261102T070000Z,20261123T090000Z',
|
||||
[Events.STATUS]: 1,
|
||||
});
|
||||
const { create } = await claimAndCreate(h, id);
|
||||
expect(create.object.recurrenceOverrides).toEqual({ '2026-11-23T10:00:00': { excluded: true } });
|
||||
});
|
||||
|
||||
it('invites the attendees of a new event as its organizer', async () => {
|
||||
const h = await new Harness().setup();
|
||||
const id = h.fake.user.insertEvent(
|
||||
h.rowIds.get('b')!,
|
||||
{ [Events.TITLE]: 'Review', [Events.DTSTART]: utc('2026-10-09T08:00:00Z'), [Events.DTEND]: utc('2026-10-09T09:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: 1 },
|
||||
{
|
||||
attendees: [
|
||||
{ [Attendees.ATTENDEE_EMAIL]: ME, [Attendees.ATTENDEE_RELATIONSHIP]: 2, [Attendees.ATTENDEE_TYPE]: 1, [Attendees.ATTENDEE_STATUS]: 1 },
|
||||
{ [Attendees.ATTENDEE_EMAIL]: 'guest@example.net', [Attendees.ATTENDEE_NAME]: 'Guest', [Attendees.ATTENDEE_RELATIONSHIP]: 1, [Attendees.ATTENDEE_TYPE]: 1, [Attendees.ATTENDEE_STATUS]: 3 },
|
||||
],
|
||||
},
|
||||
);
|
||||
const { create } = await claimAndCreate(h, id);
|
||||
expect(create.sendSchedulingMessages).toBe(true);
|
||||
const participants = Object.values(create.object.participants ?? {});
|
||||
expect(participants).toEqual([
|
||||
{ '@type': 'Participant', calendarAddress: `mailto:${ME}`, roles: { owner: true, attendee: true }, participationStatus: 'accepted' },
|
||||
{ '@type': 'Participant', calendarAddress: 'mailto:guest@example.net', name: 'Guest', roles: { attendee: true }, participationStatus: 'needs-action', expectReply: true },
|
||||
]);
|
||||
expect(create.object.organizerCalendarAddress).toBe(`mailto:${ME}`);
|
||||
});
|
||||
|
||||
it('gives a new event carrying another row\'s uid a fresh one', async () => {
|
||||
const { h } = await synced(single());
|
||||
const copy = h.fake.user.insertEvent(h.rowIds.get('b')!, {
|
||||
[Events.TITLE]: 'Copy', [Events.DTSTART]: utc('2026-10-09T08:00:00Z'), [Events.DTEND]: utc('2026-10-09T09:00:00Z'), [Events.EVENT_TIMEZONE]: 'UTC', [Events.UID_2445]: 'single-1@example.org', [Events.STATUS]: 1,
|
||||
});
|
||||
const local = await h.byRow(copy);
|
||||
expect(local.split).toBe('clone');
|
||||
const { create } = await claimAndCreate(h, copy);
|
||||
expect(create.uid).not.toBe('single-1@example.org');
|
||||
// A uid nobody else carries is kept (an imported invitation keeps its UID).
|
||||
const own = h.fake.user.insertEvent(h.rowIds.get('b')!, {
|
||||
[Events.TITLE]: 'Imported', [Events.DTSTART]: utc('2026-10-09T08:00:00Z'), [Events.DTEND]: utc('2026-10-09T09:00:00Z'), [Events.EVENT_TIMEZONE]: 'UTC', [Events.UID_2445]: 'ics-uid@example.net', [Events.STATUS]: 1,
|
||||
});
|
||||
expect((await claimAndCreate(h, own)).create.uid).toBe('ics-uid@example.net');
|
||||
});
|
||||
|
||||
it('adopts its own create found by uid on download, keeping later local edits', async () => {
|
||||
const h = await new Harness().setup();
|
||||
const id = h.fake.user.insertEvent(h.rowIds.get('b')!, { [Events.TITLE]: 'Draft', [Events.DTSTART]: utc('2026-10-09T08:00:00Z'), [Events.DTEND]: utc('2026-10-09T09:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: 1 });
|
||||
const { create } = await claimAndCreate(h, id);
|
||||
// The create reached the server, but the response was lost; meanwhile the user renamed it.
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Final' });
|
||||
const server = { ...(create.object as CalendarEventWire), id: 'n7' } as CalendarEventWire;
|
||||
const plan = calendarPlanner.planDownload(server, await h.byRow(id), h.ctx);
|
||||
expect(plan).toMatchObject({ effect: 'update', stillDirty: true, conflicts: 0 });
|
||||
await h.apply(plan.ops);
|
||||
const adopted = (await h.local('n7'))!;
|
||||
expect(adopted).toMatchObject({ eventId: id, dirty: true, pending: null });
|
||||
const [update] = actionsOf(calendarPlanner.planUpload(adopted, h.ctx));
|
||||
expect(update).toMatchObject({ kind: 'update', id: 'n7', patch: { title: 'Final' } });
|
||||
});
|
||||
|
||||
it('removes a new event created in a read-only calendar', async () => {
|
||||
const h = await new Harness().setup();
|
||||
const id = h.fake.user.insertEvent(h.rowIds.get('r')!, { [Events.TITLE]: 'x', [Events.DTSTART]: 0, [Events.DTEND]: 1000, [Events.EVENT_TIMEZONE]: 'UTC', [Events.STATUS]: 1 });
|
||||
const plan = calendarPlanner.planUpload(await h.byRow(id), h.ctx);
|
||||
expect(plan).toMatchObject({ kind: 'revert', reason: 'readOnly' });
|
||||
if (plan.kind === 'revert') await h.apply(plan.ops);
|
||||
expect(h.row(id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar planner: accepted uploads, merges and deletions', () => {
|
||||
it('keeps a row edited again during its upload dirty, with the uploaded value as its baseline', async () => {
|
||||
const { h, id } = await synced(single());
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'First' });
|
||||
const local = (await h.local('e1'))!;
|
||||
const [update] = actionsOf(calendarPlanner.planUpload(local, h.ctx));
|
||||
if (update.kind !== 'update') throw new Error();
|
||||
const server = serverApply(single(), update.patch);
|
||||
const accepted = calendarPlanner.planAccepted(local, server, h.ctx);
|
||||
// The user edits again before the clearing batch: its assert fails.
|
||||
h.fake.beforeNextBatch(() => h.fake.user.updateEvent(id, { [Events.TITLE]: 'Second' }));
|
||||
expect((await h.tryApply(accepted.ops)).ok).toBe(false);
|
||||
await h.apply(accepted.keepDirtyOps);
|
||||
const after = (await h.local('e1'))!;
|
||||
expect(after).toMatchObject({ dirty: true });
|
||||
expect(after.cells[Events.TITLE]).toBe('Second');
|
||||
expect(after.shadow?.title).toBe('First');
|
||||
const [next] = actionsOf(calendarPlanner.planUpload(after, h.ctx));
|
||||
expect(next).toMatchObject({ patch: { title: 'Second' } });
|
||||
// A revert to the old value uploads too.
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Lunch' });
|
||||
expect(actionsOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx))[0]).toMatchObject({ patch: { title: 'Lunch' } });
|
||||
});
|
||||
|
||||
it('purges a cancelled instance whose exclusion was uploaded even when the series was edited meanwhile', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
const cancelled = h.fake.user.cancelInstance(id, utc('2026-10-19T13:00:00Z'), utc('2026-10-19T14:30:00Z'));
|
||||
const local = (await h.local('bl'))!;
|
||||
const [update] = actionsOf(calendarPlanner.planUpload(local, h.ctx));
|
||||
if (update.kind !== 'update') throw new Error();
|
||||
const server = serverApply(weekly(), update.patch);
|
||||
const accepted = calendarPlanner.planAccepted(local, server, h.ctx);
|
||||
h.fake.beforeNextBatch(() => h.fake.user.updateEvent(id, { [Events.TITLE]: 'Edited meanwhile' }));
|
||||
expect((await h.tryApply(accepted.ops)).ok).toBe(false);
|
||||
await h.apply(accepted.keepDirtyOps);
|
||||
expect(h.row(cancelled)).toBeUndefined();
|
||||
const after = (await h.local('bl'))!;
|
||||
const [next] = actionsOf(calendarPlanner.planUpload(after, h.ctx));
|
||||
// Only the newer edit: the exclusion is on the server already and stays one.
|
||||
expect(next).toMatchObject({ kind: 'update', patch: { title: 'Edited meanwhile' } });
|
||||
});
|
||||
|
||||
it('fails a download write when the user edits the event meanwhile', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
const plan = calendarPlanner.planDownload({ ...weekly(), title: 'Server' }, await h.local('bl'), h.ctx);
|
||||
h.fake.beforeNextBatch(() => h.fake.user.updateEvent(id, { [Events.DESCRIPTION]: 'mine' }));
|
||||
const result = await h.tryApply(plan.ops);
|
||||
expect(result).toMatchObject({ ok: false, reason: 'assert' });
|
||||
expect(h.row(id)[Events.TITLE]).toBe('Probe weekly');
|
||||
});
|
||||
|
||||
it('merges per unit: local and server changes to different fields both survive', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Local title' });
|
||||
const plan = await h.download({ ...weekly(), description: 'Server description' });
|
||||
expect(plan).toMatchObject({ conflicts: 0, stillDirty: true });
|
||||
expect(h.row(id)).toMatchObject({ [Events.TITLE]: 'Local title', [Events.DESCRIPTION]: 'Server description', [Events.DIRTY]: 1 });
|
||||
expect(actionsOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx))[0]).toMatchObject({ patch: { title: 'Local title' } });
|
||||
});
|
||||
|
||||
it('lets the server win a real conflict and counts it', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Local title' });
|
||||
const plan = await h.download({ ...weekly(), title: 'Server title' });
|
||||
expect(plan).toMatchObject({ conflicts: 1, stillDirty: false });
|
||||
expect(h.row(id)).toMatchObject({ [Events.TITLE]: 'Server title', [Events.DIRTY]: 0 });
|
||||
});
|
||||
|
||||
it('never reads a status an app left NULL as an edit, in uploads or merges', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
expect(h.row(id)[Events.STATUS]).toBe(0);
|
||||
// A row an app wrote without STATUS (only an insert can leave it NULL), then edited.
|
||||
h.fake.table('events').get(id)![Events.STATUS] = null;
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Renamed' });
|
||||
expect(actionsOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx))[0]).toMatchObject({ kind: 'update', patch: { title: 'Renamed' } });
|
||||
expect(Object.keys((actionsOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx))[0] as { patch: object }).patch)).toEqual(['title']);
|
||||
// The server's status change meanwhile is no conflict: the row takes it, the title stays for the upload.
|
||||
const plan = await h.download({ ...weekly(), status: 'cancelled' });
|
||||
expect(plan).toMatchObject({ conflicts: 0, stillDirty: true });
|
||||
expect(h.row(id)).toMatchObject({ [Events.STATUS]: 2, [Events.TITLE]: 'Renamed', [Events.DIRTY]: 1 });
|
||||
});
|
||||
|
||||
it('treats the same change on both sides as converged', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Same' });
|
||||
const plan = await h.download({ ...weekly(), title: 'Same' });
|
||||
expect(plan).toMatchObject({ conflicts: 0, stillDirty: false });
|
||||
expect(h.row(id)[Events.DIRTY]).toBe(0);
|
||||
});
|
||||
|
||||
it('merges attendees by address and keeps the user\'s RSVP', async () => {
|
||||
const invite = single({
|
||||
organizerCalendarAddress: 'mailto:boss@example.net',
|
||||
participants: {
|
||||
boss: { calendarAddress: 'mailto:boss@example.net', roles: { owner: true } },
|
||||
me: { calendarAddress: 'mailto:usera@example.org', roles: { attendee: true }, participationStatus: 'needs-action' },
|
||||
},
|
||||
} as Partial<CalendarEventWire>);
|
||||
const { h, id } = await synced(invite);
|
||||
h.fake.user.setAttendeeStatus(id, ME, 1);
|
||||
const remote = { ...invite, participants: { ...invite.participants, new: { calendarAddress: 'mailto:new@example.net', roles: { attendee: true } } } } as CalendarEventWire;
|
||||
const plan = await h.download(remote);
|
||||
expect(plan).toMatchObject({ conflicts: 0, stillDirty: true });
|
||||
const rows = h.fake.rows('attendees', `${Attendees.EVENT_ID} = ?`, [id]);
|
||||
expect(rows.map((a) => [a[Attendees.ATTENDEE_EMAIL], a[Attendees.ATTENDEE_STATUS]])).toEqual([
|
||||
['boss@example.net', 3],
|
||||
[ME, 1],
|
||||
['new@example.net', 3],
|
||||
]);
|
||||
expect(actionsOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx))[0]).toMatchObject({
|
||||
patch: { 'participants/me/participationStatus': 'accepted' },
|
||||
sendSchedulingMessages: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('destroys an event deleted on the device, with a CANCEL when others take part', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.deleteEvent(id);
|
||||
expect(actionsOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx))).toEqual([
|
||||
{ kind: 'destroy', id: 'bl', uid: 'probe-rec@example.org', sendSchedulingMessages: true },
|
||||
]);
|
||||
// Its exceptions go with it.
|
||||
const local = (await h.local('bl'))!;
|
||||
const group = calendarPlanner.planLocalDelete(local);
|
||||
await h.apply(group);
|
||||
expect(await h.events()).toEqual([]);
|
||||
});
|
||||
|
||||
it('removes only the synced membership of an event that is in several calendars', async () => {
|
||||
const h = await new Harness().setup(['b', 'w', 'r']);
|
||||
const isSelected = h.ctx.isSelected;
|
||||
h.ctx = { ...h.ctx, isSelected: (key) => key !== `${ACCT}/x` && isSelected(key) };
|
||||
await h.download(single({ calendarIds: { b: true, x: true } }));
|
||||
h.fake.user.deleteEvent(Number(h.masterRow('e1')._id));
|
||||
expect(actionsOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx))).toEqual([{ kind: 'update', id: 'e1', patch: { 'calendarIds/b': null } }]);
|
||||
// The server keeps it elsewhere: the rows go.
|
||||
const accepted = calendarPlanner.planAccepted((await h.local('e1'))!, single({ calendarIds: { x: true } }), h.ctx);
|
||||
await h.apply(accepted.ops);
|
||||
expect(await h.events()).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves an event the server moved out of every synced calendar before the deletion reached it, and only purges the rows', async () => {
|
||||
const { h, id } = await synced(single());
|
||||
const isSelected = h.ctx.isSelected;
|
||||
h.ctx = { ...h.ctx, isSelected: (key) => key !== `${ACCT}/x` && isSelected(key) };
|
||||
h.fake.user.deleteEvent(id);
|
||||
// The shadow follows the server although no synced calendar holds the event now.
|
||||
expect(await h.download(single({ calendarIds: { x: true } }))).toMatchObject({ stillDirty: true, effect: 'update' });
|
||||
expect(h.row(id)).toMatchObject({ [Events.DELETED]: 1 });
|
||||
|
||||
const plan = calendarPlanner.planUpload((await h.local('e1'))!, h.ctx);
|
||||
expect(plan.kind).toBe('purge');
|
||||
if (plan.kind === 'purge') await h.apply(plan.ops);
|
||||
expect(await h.events()).toEqual([]);
|
||||
});
|
||||
|
||||
it('still destroys an event the server moved to another synced calendar, or whose calendar was deselected before the upload', async () => {
|
||||
const destroy = [{ kind: 'destroy', id: 'e1', uid: 'single-1@example.org' }];
|
||||
const moved = await synced(single());
|
||||
moved.h.fake.user.deleteEvent(moved.id);
|
||||
await moved.h.download(single({ calendarIds: { w: true } }));
|
||||
expect(actionsOf(calendarPlanner.planUpload((await moved.h.local('e1'))!, moved.h.ctx))).toEqual(destroy);
|
||||
|
||||
const deselected = await synced(single());
|
||||
deselected.h.fake.user.deleteEvent(deselected.id);
|
||||
deselected.h.ctx = { ...deselected.h.ctx, isSelected: () => false };
|
||||
expect(actionsOf(calendarPlanner.planUpload((await deselected.h.local('e1'))!, deselected.h.ctx))).toEqual(destroy);
|
||||
});
|
||||
|
||||
it('destroys a deleted event whose create had an unknown outcome by its uid', async () => {
|
||||
const h = await new Harness().setup();
|
||||
const id = h.fake.user.insertEvent(h.rowIds.get('b')!, { [Events.TITLE]: 'x', [Events.DTSTART]: 0, [Events.DTEND]: 1000, [Events.EVENT_TIMEZONE]: 'UTC', [Events.STATUS]: 1 });
|
||||
const { local } = await claimAndCreate(h, id);
|
||||
h.fake.user.deleteEvent(id);
|
||||
// With the pending _SYNC_ID the app delete was a soft delete the engine can act on.
|
||||
expect(h.row(id)).toMatchObject({ [Events.DELETED]: 1 });
|
||||
expect(actionsOf(calendarPlanner.planUpload(await h.byRow(id), h.ctx))).toEqual([{ kind: 'destroy', id: null, uid: local.pending!.uid }]);
|
||||
});
|
||||
|
||||
it('keeps a device deletion when the server edited the event meanwhile', async () => {
|
||||
const { h, id } = await synced(single());
|
||||
h.fake.user.deleteEvent(id);
|
||||
const plan = await h.download(single({ title: 'Server edit' }));
|
||||
expect(plan).toMatchObject({ stillDirty: true, effect: 'update', writes: 1 });
|
||||
expect(h.row(id)).toMatchObject({ [Events.DELETED]: 1, [Events.TITLE]: 'Lunch' });
|
||||
expect(actionsOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx))[0].kind).toBe('destroy');
|
||||
});
|
||||
|
||||
it('purges an exception row whose master is gone instead of uploading it', async () => {
|
||||
const { h } = await synced(weekly());
|
||||
const local = (await h.local('bl'))!;
|
||||
await h.apply({ ref: 'x', ops: [{ op: 'delete', table: 'events', id: local.eventId, expectCount: 1 }] });
|
||||
const [orphan] = await h.events();
|
||||
expect(orphan.cells[Events.ORIGINAL_SYNC_ID]).toBe(`${ACCT}/bl`);
|
||||
const plan = calendarPlanner.planUpload(orphan, h.ctx);
|
||||
expect(plan.kind).toBe('purge');
|
||||
if (plan.kind === 'purge') await h.apply(plan.ops);
|
||||
expect(await h.events()).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses tasks everywhere', async () => {
|
||||
const { h } = await synced(single());
|
||||
const local = (await h.local('e1'))!;
|
||||
const task = { ...single(), '@type': 'Task' } as CalendarEventWire;
|
||||
expect(() => calendarPlanner.planAccepted(local, task, h.ctx)).toThrow(/tasks/);
|
||||
expect(() => calendarPlanner.planUpload({ ...local, shadow: task }, h.ctx)).toThrow(/tasks/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar planner: pairs', () => {
|
||||
it('uploads Etar\'s move to another calendar (delete + insert) as a calendarIds swap', async () => {
|
||||
const { h, id } = await synced(single());
|
||||
h.fake.user.deleteEvent(id);
|
||||
const moved = h.fake.user.insertEvent(h.rowIds.get('w')!, {
|
||||
[Events.TITLE]: 'Lunch', [Events.DTSTART]: utc('2026-10-06T10:00:00Z'), [Events.DTEND]: utc('2026-10-06T11:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: 1, [Events.AVAILABILITY]: 0, [Events.ACCESS_LEVEL]: 0,
|
||||
});
|
||||
const events = await h.events();
|
||||
const pairs = calendarPlanner.planPairs(events.filter((e) => e.deleted), events.filter((e) => !e.syncId), h.ctx);
|
||||
expect(pairs).toHaveLength(1);
|
||||
expect(pairs[0].actions).toEqual([{ kind: 'update', id: 'e1', patch: { 'calendarIds/b': null, 'calendarIds/w': true } }]);
|
||||
await h.apply(pairs[0].ops);
|
||||
const after = await h.events();
|
||||
expect(after).toHaveLength(1);
|
||||
expect(after[0]).toMatchObject({ eventId: moved, syncId: `${ACCT}/e1`, dirty: false });
|
||||
expect(after[0].shadow?.calendarIds).toEqual({ w: true });
|
||||
// The server's echo writes nothing.
|
||||
expect(calendarPlanner.planDownload(single({ calendarIds: { w: true } }), after[0], h.ctx).effect).toBe('none');
|
||||
});
|
||||
|
||||
it('uploads a series turned into a single event by delete + insert as a rule removal', async () => {
|
||||
const { h, id } = await synced(single({ recurrenceRule: { frequency: 'daily' } }));
|
||||
h.fake.user.deleteEvent(id);
|
||||
h.fake.user.insertEvent(h.rowIds.get('b')!, {
|
||||
[Events.TITLE]: 'Lunch', [Events.DTSTART]: utc('2026-10-06T10:00:00Z'), [Events.DTEND]: utc('2026-10-06T11:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: 1, [Events.AVAILABILITY]: 0, [Events.ACCESS_LEVEL]: 0,
|
||||
});
|
||||
const events = await h.events();
|
||||
const pairs = calendarPlanner.planPairs(events.filter((e) => e.deleted), events.filter((e) => !e.syncId), h.ctx);
|
||||
expect(pairs.map((p) => p.actions)).toEqual([[{ kind: 'update', id: 'e1', patch: { recurrenceRule: null } }]]);
|
||||
});
|
||||
|
||||
it('pairs a copy an app inserted without a status (Google Calendar\'s "Copy to" + delete) with the original', async () => {
|
||||
const { h, id } = await synced(single());
|
||||
h.fake.user.deleteEvent(id);
|
||||
h.fake.user.insertEvent(h.rowIds.get('w')!, {
|
||||
[Events.TITLE]: 'Lunch', [Events.DTSTART]: utc('2026-10-06T10:00:00Z'), [Events.DTEND]: utc('2026-10-06T11:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: null, [Events.AVAILABILITY]: 0, [Events.ACCESS_LEVEL]: 0,
|
||||
});
|
||||
const events = await h.events();
|
||||
const pairs = calendarPlanner.planPairs(events.filter((e) => e.deleted), events.filter((e) => !e.syncId), h.ctx);
|
||||
expect(pairs.map((p) => p.actions)).toEqual([[{ kind: 'update', id: 'e1', patch: { 'calendarIds/b': null, 'calendarIds/w': true } }]]);
|
||||
});
|
||||
|
||||
it('does not pair unrelated rows', async () => {
|
||||
const { h, id } = await synced(single());
|
||||
h.fake.user.deleteEvent(id);
|
||||
h.fake.user.insertEvent(h.rowIds.get('w')!, { [Events.TITLE]: 'Something else', [Events.DTSTART]: utc('2026-10-06T10:00:00Z'), [Events.DTEND]: utc('2026-10-06T11:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: 1 });
|
||||
const events = await h.events();
|
||||
expect(calendarPlanner.planPairs(events.filter((e) => e.deleted), events.filter((e) => !e.syncId), h.ctx)).toEqual([]);
|
||||
});
|
||||
|
||||
/** Etar's move of `single()` (delete + insert into Work), with `extra` columns and children on the new row. */
|
||||
const lunchColumns = {
|
||||
[Events.TITLE]: 'Lunch', [Events.DTSTART]: utc('2026-10-06T10:00:00Z'), [Events.DTEND]: utc('2026-10-06T11:00:00Z'), [Events.EVENT_TIMEZONE]: 'Europe/Berlin', [Events.STATUS]: 1, [Events.AVAILABILITY]: 0, [Events.ACCESS_LEVEL]: 0,
|
||||
};
|
||||
async function pairAndApply(h: Harness) {
|
||||
const events = await h.events();
|
||||
const pairs = calendarPlanner.planPairs(events.filter((e) => e.deleted), events.filter((e) => !e.syncId && !e.deleted), h.ctx);
|
||||
expect(pairs).toHaveLength(1);
|
||||
await h.apply(pairs[0].ops);
|
||||
return pairs[0];
|
||||
}
|
||||
const alert15 = { al1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' }, action: 'display' } } as CalendarEventWire['alerts'];
|
||||
|
||||
it('keeps a reminder saved with the move for the next upload instead of taking it as the baseline', async () => {
|
||||
const { h, id } = await synced(single({ alerts: alert15 }));
|
||||
h.fake.user.deleteEvent(id);
|
||||
const moved = h.fake.user.insertEvent(h.rowIds.get('w')!, lunchColumns, { reminders: [{ [Reminders.MINUTES]: 60, [Reminders.METHOD]: 1 }] });
|
||||
|
||||
const pair = await pairAndApply(h);
|
||||
|
||||
expect(pair.actions).toEqual([{ kind: 'update', id: 'e1', patch: { 'calendarIds/b': null, 'calendarIds/w': true } }]);
|
||||
const after = (await h.local('e1'))!;
|
||||
expect(after).toMatchObject({ eventId: moved, dirty: true });
|
||||
const [update] = actionsOf(calendarPlanner.planUpload(after, h.ctx));
|
||||
if (update.kind !== 'update') throw new Error('not an update');
|
||||
// Only the reminder is left to send: the move went up with the pair.
|
||||
expect(Object.keys(update.patch).every((k) => k.startsWith('alerts/'))).toBe(true);
|
||||
const alerts = serverApply(after.shadow!, update.patch).alerts!;
|
||||
expect(Object.values(alerts).map((a) => (a.trigger as { offset?: string }).offset)).toEqual(['-PT1H']);
|
||||
});
|
||||
|
||||
it('uploads an edit made before the move, which the new row copied', async () => {
|
||||
const { h, id } = await synced(single({ description: 'Bring salad' }));
|
||||
h.fake.user.updateEvent(id, { [Events.DESCRIPTION]: 'Bring soup' });
|
||||
h.fake.user.deleteEvent(id);
|
||||
h.fake.user.insertEvent(h.rowIds.get('w')!, { ...lunchColumns, [Events.DESCRIPTION]: 'Bring soup' });
|
||||
|
||||
await pairAndApply(h);
|
||||
|
||||
const after = (await h.local('e1'))!;
|
||||
expect(after.dirty).toBe(true);
|
||||
expect(actionsOf(calendarPlanner.planUpload(after, h.ctx))).toEqual([{ kind: 'update', id: 'e1', patch: { description: 'Bring soup' } }]);
|
||||
});
|
||||
|
||||
it('takes attendee rows an app re-inserted with constant details as they are, uploading nothing for them', async () => {
|
||||
const participants = {
|
||||
bob: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.net', name: 'Bob', roles: { attendee: true, optional: true }, participationStatus: 'accepted' },
|
||||
carol: { '@type': 'Participant', calendarAddress: 'mailto:carol@example.net', name: 'Carol', roles: { attendee: true }, participationStatus: 'declined' },
|
||||
} as CalendarEventWire['participants'];
|
||||
const { h, id } = await synced(single({ participants, organizerCalendarAddress: 'mailto:boss@example.net' }));
|
||||
const attendees = h.fake.rows('attendees', `${Attendees.EVENT_ID} = ?`, [id]).map((a) => ({
|
||||
// Etar writes every attendee it re-inserts as a required attendee without a status.
|
||||
[Attendees.ATTENDEE_EMAIL]: a[Attendees.ATTENDEE_EMAIL], [Attendees.ATTENDEE_NAME]: a[Attendees.ATTENDEE_NAME],
|
||||
[Attendees.ATTENDEE_RELATIONSHIP]: 1, [Attendees.ATTENDEE_TYPE]: 1, [Attendees.ATTENDEE_STATUS]: 0,
|
||||
}));
|
||||
const organizer = h.row(id)[Events.ORGANIZER];
|
||||
h.fake.user.deleteEvent(id);
|
||||
h.fake.user.insertEvent(h.rowIds.get('w')!, { ...lunchColumns, [Events.ORGANIZER]: organizer }, { attendees });
|
||||
|
||||
await pairAndApply(h);
|
||||
|
||||
const after = (await h.local('e1'))!;
|
||||
expect(after.dirty).toBe(false);
|
||||
expect(calendarPlanner.planUpload({ ...after, dirty: true }, h.ctx).kind).toBe('clean');
|
||||
});
|
||||
|
||||
describe("Etar's move of a meeting (its copy leaves the user's own row out, or adds the user as organizer)", () => {
|
||||
const emailsOf = (h: Harness, eventId: number) =>
|
||||
h.fake.rows('attendees', `${Attendees.EVENT_ID} = ?`, [eventId]).map((a) => String(a[Attendees.ATTENDEE_EMAIL])).sort();
|
||||
const person = (address: string, extra: Record<string, unknown> = {}) => ({ '@type': 'Participant', calendarAddress: `mailto:${address}`, roles: { attendee: true }, ...extra });
|
||||
const moveToWork = { kind: 'update', id: 'e1', patch: { 'calendarIds/b': null, 'calendarIds/w': true } };
|
||||
|
||||
it('pairs a meeting that lists the user', async () => {
|
||||
const participants = {
|
||||
me: person(ME, { roles: { owner: true, attendee: true }, participationStatus: 'accepted' }),
|
||||
bob: person('bob@example.net', { name: 'Bob', participationStatus: 'accepted' }),
|
||||
} as CalendarEventWire['participants'];
|
||||
const { h, id } = await synced(single({ participants, organizerCalendarAddress: `mailto:${ME}` }));
|
||||
|
||||
const moved = h.fake.user.etarMove(id, h.rowIds.get('w')!);
|
||||
|
||||
expect(emailsOf(h, moved)).toEqual(['bob@example.net']);
|
||||
expect((await pairAndApply(h)).actions).toEqual([moveToWork]);
|
||||
expect((await h.local('e1'))!).toMatchObject({ eventId: moved, dirty: false });
|
||||
});
|
||||
|
||||
it('pairs a meeting that does not list the user', async () => {
|
||||
const participants = {
|
||||
boss: person('boss@example.net', { roles: { owner: true, attendee: true }, participationStatus: 'accepted' }),
|
||||
bob: person('bob@example.net', { participationStatus: 'needs-action' }),
|
||||
} as CalendarEventWire['participants'];
|
||||
const { h, id } = await synced(single({ participants, organizerCalendarAddress: 'mailto:boss@example.net' }));
|
||||
|
||||
const moved = h.fake.user.etarMove(id, h.rowIds.get('w')!);
|
||||
|
||||
expect(emailsOf(h, moved)).toEqual(['bob@example.net', 'boss@example.net', ME]);
|
||||
expect((await pairAndApply(h)).actions).toEqual([moveToWork]);
|
||||
});
|
||||
|
||||
it('never pairs a copy that lost or gained someone else', async () => {
|
||||
const participants = {
|
||||
me: person(ME, { roles: { owner: true, attendee: true }, participationStatus: 'accepted' }),
|
||||
bob: person('bob@example.net'),
|
||||
carol: person('carol@example.net'),
|
||||
} as CalendarEventWire['participants'];
|
||||
const attendee = (email: string) => ({ [Attendees.ATTENDEE_EMAIL]: email, [Attendees.ATTENDEE_RELATIONSHIP]: 1, [Attendees.ATTENDEE_TYPE]: 1, [Attendees.ATTENDEE_STATUS]: 0 });
|
||||
for (const copied of [['bob@example.net'], ['bob@example.net', 'carol@example.net', 'dave@example.net']]) {
|
||||
const { h, id } = await synced(single({ participants, organizerCalendarAddress: `mailto:${ME}` }));
|
||||
h.fake.user.deleteEvent(id);
|
||||
h.fake.user.insertEvent(h.rowIds.get('w')!, lunchColumns, { attendees: copied.map(attendee) });
|
||||
const events = await h.events();
|
||||
expect(calendarPlanner.planPairs(events.filter((e) => e.deleted), events.filter((e) => !e.syncId), h.ctx), copied.join()).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the timing of a series turned single against the series, so a start moved before uploads', async () => {
|
||||
const { h, id } = await synced(single({ recurrenceRule: { frequency: 'daily' } }));
|
||||
// Moved an hour later in place, then turned into a single event (Etar: delete + insert).
|
||||
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-06T11:00:00Z') });
|
||||
h.fake.user.deleteEvent(id);
|
||||
h.fake.user.insertEvent(h.rowIds.get('b')!, { ...lunchColumns, [Events.DTSTART]: utc('2026-10-06T11:00:00Z'), [Events.DTEND]: utc('2026-10-06T12:00:00Z') });
|
||||
|
||||
const pair = await pairAndApply(h);
|
||||
|
||||
expect(pair.actions).toEqual([{ kind: 'update', id: 'e1', patch: { recurrenceRule: null } }]);
|
||||
const after = (await h.local('e1'))!;
|
||||
expect(after.dirty).toBe(true);
|
||||
expect(actionsOf(calendarPlanner.planUpload(after, h.ctx))).toEqual([{ kind: 'update', id: 'e1', patch: { start: '2026-10-06T13:00:00' } }]);
|
||||
|
||||
// Without the earlier edit, the single event matches what the server holds after the pair: nothing left.
|
||||
const plain = await synced(single({ recurrenceRule: { frequency: 'daily' } }));
|
||||
plain.h.fake.user.deleteEvent(plain.id);
|
||||
plain.h.fake.user.insertEvent(plain.h.rowIds.get('b')!, lunchColumns);
|
||||
await pairAndApply(plain.h);
|
||||
expect((await plain.h.local('e1'))!.dirty).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar planner: baselines, zones and reminder owners', () => {
|
||||
it('heals a clean row\'s baseline after provider normalisation, but never a series\' timing or rule', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
// Provider-side changes without DIRTY (as CONTENT_EXCEPTION_URI's split does to the rule).
|
||||
const row = h.fake.table('events').get(id)!;
|
||||
row[Events.ORGANIZER] = 'someone@example.net';
|
||||
row[Events.RRULE] = 'FREQ=WEEKLY;UNTIL=20261019T125959Z;BYDAY=MO,WE';
|
||||
const heal = calendarPlanner.planBaselineHeal((await h.local('bl'))!)!;
|
||||
expect(heal).not.toBeNull();
|
||||
await h.apply(heal);
|
||||
const baseline = (await h.local('bl'))!.baseline!;
|
||||
expect(baseline.cells[Events.ORGANIZER]).toBe('someone@example.net');
|
||||
expect(baseline.cells[Events.RRULE]).toBe('FREQ=WEEKLY;COUNT=10;BYDAY=MO,WE');
|
||||
expect(calendarPlanner.planBaselineHeal((await h.local('bl'))!)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not heal a row that turned dirty', async () => {
|
||||
const { h, id } = await synced(single());
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'x' });
|
||||
expect(calendarPlanner.planBaselineHeal((await h.local('e1'))!)).toBeNull();
|
||||
});
|
||||
|
||||
it('rewrites floating events for a new device zone, keeping their wall time', async () => {
|
||||
const { h, id } = await synced(single({ id: 'f', timeZone: null, start: '2026-10-06T14:00:00' }));
|
||||
expect(h.row(id)[Events.DTSTART]).toBe(utc('2026-10-06T12:00:00Z'));
|
||||
h.ctx = { ...h.ctx, deviceZone: 'America/New_York' };
|
||||
const group = calendarPlanner.planZoneChange((await h.local('f'))!, 'Europe/Berlin', h.ctx)!;
|
||||
await h.apply(group);
|
||||
expect(h.row(id)).toMatchObject({ [Events.DTSTART]: utc('2026-10-06T18:00:00Z'), [Events.EVENT_TIMEZONE]: 'America/New_York' });
|
||||
expect(calendarPlanner.planZoneChange((await h.local('f'))!, 'Europe/Berlin', h.ctx)).toBeNull();
|
||||
// Events with a zone are not floating.
|
||||
const t = await synced(single());
|
||||
expect(calendarPlanner.planZoneChange((await t.h.local('e1'))!, 'Europe/Berlin', { ...t.h.ctx, deviceZone: 'Asia/Tokyo' })).toBeNull();
|
||||
});
|
||||
|
||||
it('rewrites Reminders rows when the reminder owner changes, without server writes', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
expect(h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [id])).toHaveLength(1);
|
||||
h.ctx = { ...h.ctx, reminderOwner: 'bulwark' };
|
||||
await h.apply(calendarPlanner.planReminderOwnerChange((await h.local('bl'))!, h.ctx)!);
|
||||
expect(h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [id])).toHaveLength(0);
|
||||
expect(calendarPlanner.planReminderOwnerChange((await h.local('bl'))!, h.ctx)).toBeNull();
|
||||
h.ctx = { ...h.ctx, reminderOwner: 'device' };
|
||||
await h.apply(calendarPlanner.planReminderOwnerChange((await h.local('bl'))!, h.ctx)!);
|
||||
expect(h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [id]).map((r) => r[Reminders.MINUTES])).toEqual([15]);
|
||||
// Back with the calendar app: an echo download writes nothing.
|
||||
expect(calendarPlanner.planDownload(weekly(), await h.local('bl'), h.ctx).effect).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar planner: decoding', () => {
|
||||
it('tolerates garbage in sync columns and numbers read back as text', async () => {
|
||||
const [event] = calendarPlanner.decodeEvents(
|
||||
[
|
||||
{
|
||||
_id: '5', calendar_id: '1', _sync_id: 'c/x', dirty: '1', deleted: '0', sync_data1: '{broken', sync_data3: '{"uid":1}',
|
||||
sync_data4: '[]', sync_data5: 'nope', title: 'T', allDay: '0', dtstart: '1000', rrule: null, original_id: null, original_sync_id: null,
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
);
|
||||
expect(event).toMatchObject({ eventId: 5, calendarRowId: 1, syncId: 'c/x', dirty: true, deleted: false, shadow: null, pending: null, baseline: null, poison: null });
|
||||
});
|
||||
|
||||
it('groups exceptions by ORIGINAL_ID, else ORIGINAL_SYNC_ID, and returns orphans as masters', () => {
|
||||
const events = calendarPlanner.decodeEvents(
|
||||
[
|
||||
{ _id: 1, calendar_id: 1, _sync_id: 'c/m', eventTimezone: 'Europe/Berlin', allDay: 0, dirty: 0, deleted: 0 },
|
||||
{ _id: 2, calendar_id: 1, _sync_id: null, original_id: 1, originalInstanceTime: utc('2026-10-07T07:00:00Z'), dirty: 1, deleted: 0 },
|
||||
{ _id: 3, calendar_id: 1, _sync_id: 'c/m#2026-10-08T09:00:00', original_sync_id: 'c/m', sync_data2: '2026-10-08T09:00:00', dirty: 0, deleted: 0 },
|
||||
{ _id: 4, calendar_id: 1, _sync_id: null, original_sync_id: 'c/gone', originalInstanceTime: 0, dirty: 1, deleted: 0 },
|
||||
],
|
||||
[],
|
||||
[],
|
||||
);
|
||||
expect(events.map((e) => [e.eventId, e.exceptions.map((x) => [x.eventId, (x as { recurrenceId: string }).recurrenceId])])).toEqual([
|
||||
[1, [[2, '2026-10-07T09:00:00'], [3, '2026-10-08T09:00:00']]],
|
||||
[4, []],
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks two masters sharing a _SYNC_ID as a split source and clone', () => {
|
||||
const events = calendarPlanner.decodeEvents(
|
||||
[
|
||||
{ _id: 1, calendar_id: 1, _sync_id: 'c/m' },
|
||||
{ _id: 9, calendar_id: 1, _sync_id: 'c/m' },
|
||||
{ _id: 3, calendar_id: 1, _sync_id: 'c/n' },
|
||||
],
|
||||
[],
|
||||
[],
|
||||
);
|
||||
expect(events.map((e: LocalEvent) => [e.eventId, e.split])).toEqual([
|
||||
[1, 'source'],
|
||||
[3, undefined],
|
||||
[9, 'clone'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Attendees, Events, Reminders } from '../../android-columns';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import { Harness, single, utc, weekly } from './harness';
|
||||
|
||||
describe('calendar planner: what the real providers hand back', () => {
|
||||
it('sees no edit in numbers read back as text', async () => {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(weekly());
|
||||
const id = Number(h.masterRow('bl')._id);
|
||||
// Some provider columns come back through the bridge as text.
|
||||
const row = h.fake.table('events').get(id)!;
|
||||
for (const c of [Events.STATUS, Events.AVAILABILITY, Events.ACCESS_LEVEL, Events.DTSTART, Events.ALL_DAY, Events.EVENT_COLOR, Events.CALENDAR_ID]) {
|
||||
row[c] = String(row[c]);
|
||||
}
|
||||
for (const a of h.fake.table('attendees').values()) {
|
||||
if (a.event_id === id) for (const c of [Attendees.ATTENDEE_STATUS, Attendees.ATTENDEE_TYPE, Attendees.ATTENDEE_RELATIONSHIP]) a[c] = String(a[c]);
|
||||
}
|
||||
for (const r of h.fake.table('reminders').values()) {
|
||||
if (r.event_id === id) for (const c of [Reminders.MINUTES, Reminders.METHOD]) r[c] = String(r[c]);
|
||||
}
|
||||
expect(calendarPlanner.planDownload(weekly(), await h.local('bl'), h.ctx).effect).toBe('none');
|
||||
h.fake.user.updateEvent(id, {});
|
||||
expect(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx).kind).toBe('clean');
|
||||
});
|
||||
|
||||
it('asserts DIRTY as read, so a row inserted with a NULL DIRTY still takes a guarded write', async () => {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(single());
|
||||
const id = Number(h.masterRow('e1')._id);
|
||||
h.fake.table('events').get(id)![Events.DIRTY] = null;
|
||||
const local = (await h.local('e1'))!;
|
||||
expect(local.dirty).toBe(false);
|
||||
const plan = calendarPlanner.planDownload(single({ title: 'Changed' }), local, h.ctx);
|
||||
const assert = plan.ops.ops.find((op) => op.op === 'assert' && 'id' in op && op.id === id) as { values: Record<string, unknown> };
|
||||
expect(assert.values[Events.DIRTY]).toBeNull();
|
||||
await h.apply(plan.ops);
|
||||
expect(h.row(id)[Events.TITLE]).toBe('Changed');
|
||||
});
|
||||
|
||||
it('writes DIRTY = 0 on every row it inserts', async () => {
|
||||
const h = await new Harness().setup();
|
||||
const plan = calendarPlanner.planDownload(weekly(), null, h.ctx);
|
||||
const inserts = plan.ops.ops.filter((op) => op.op === 'insert' && op.table === 'events') as Array<{ values: Record<string, unknown> }>;
|
||||
expect(inserts).toHaveLength(2);
|
||||
for (const insert of inserts) expect(insert.values[Events.DIRTY]).toBe(0);
|
||||
});
|
||||
|
||||
it('addresses attendee and reminder rows by id only', async () => {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(weekly());
|
||||
const plan = calendarPlanner.planDownload(
|
||||
{ ...weekly(), participants: { me1: weekly().participants!.me1 }, alerts: {} },
|
||||
await h.local('bl'),
|
||||
h.ctx,
|
||||
);
|
||||
for (const op of plan.ops.ops) {
|
||||
if ((op.op === 'update' || op.op === 'delete') && (op.table === 'attendees' || op.table === 'reminders')) {
|
||||
expect(op).toMatchObject({ expectCount: 1 });
|
||||
expect(typeof op.id).toBe('number');
|
||||
expect(op.where).toBeUndefined();
|
||||
}
|
||||
if ('where' in op && op.where) expect(op.where).not.toMatch(/\b_id\b/);
|
||||
}
|
||||
await h.apply(plan.ops);
|
||||
});
|
||||
|
||||
it('computes instance times exactly across a DST change of the series\' zone', async () => {
|
||||
const h = await new Harness().setup();
|
||||
const event = weekly();
|
||||
event.recurrenceRule = { frequency: 'weekly', byDay: [{ day: 'mo' }] };
|
||||
event.recurrenceOverrides = { '2026-11-02T09:00:00': { title: 'After the change' } };
|
||||
await h.download(event);
|
||||
const [x] = (await h.local('bl'))!.exceptions;
|
||||
// New York left DST on 1 November: 09:00 is 14:00Z from then on, not 13:00Z.
|
||||
expect(x.cells[Events.ORIGINAL_INSTANCE_TIME]).toBe(utc('2026-11-02T14:00:00Z'));
|
||||
expect(x.cells[Events.DTSTART]).toBe(utc('2026-11-02T14:00:00Z'));
|
||||
});
|
||||
|
||||
it('derives the recurrence id of an app\'s exception from its instance time in the master\'s zone', async () => {
|
||||
const h = await new Harness().setup();
|
||||
const event = weekly();
|
||||
event.recurrenceRule = { frequency: 'weekly', byDay: [{ day: 'mo' }] };
|
||||
event.recurrenceOverrides = {};
|
||||
await h.download(event);
|
||||
const id = Number(h.masterRow('bl')._id);
|
||||
h.fake.user.insertException(id, utc('2026-11-02T14:00:00Z'), { [Events.TITLE]: 'x', [Events.DTSTART]: utc('2026-11-02T14:00:00Z'), [Events.DTEND]: utc('2026-11-02T15:30:00Z') });
|
||||
expect((await h.local('bl'))!.exceptions[0].recurrenceId).toBe('2026-11-02T09:00:00');
|
||||
});
|
||||
|
||||
it('ignores an EXRULE or RDATE an app wrote', async () => {
|
||||
const h = await new Harness().setup();
|
||||
await h.download(weekly());
|
||||
const id = Number(h.masterRow('bl')._id);
|
||||
h.fake.user.updateEvent(id, { [Events.EXRULE]: 'FREQ=MONTHLY' });
|
||||
expect(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx).kind).toBe('clean');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { RecurrenceRule } from '../../../api/types';
|
||||
import {
|
||||
canonicalRRule,
|
||||
isRuleRepresentable,
|
||||
parseUntil,
|
||||
ruleToRRule,
|
||||
rruleToRule,
|
||||
unrepresentableParts,
|
||||
untilUtc,
|
||||
} from '../../calendar/rrule';
|
||||
import { aospRruleValid } from '../fakes/fake-provider';
|
||||
import { exdateSet, formatExdate, parseExdate } from '../../calendar/exdate';
|
||||
|
||||
const timed = { allDay: false, zone: 'Europe/Berlin' };
|
||||
const allDay = { allDay: true, zone: 'UTC' };
|
||||
|
||||
describe('calendar/rrule', () => {
|
||||
it.each<[string, RecurrenceRule, string]>([
|
||||
['daily', { frequency: 'daily' }, 'FREQ=DAILY'],
|
||||
['interval', { frequency: 'weekly', interval: 2 }, 'FREQ=WEEKLY;INTERVAL=2'],
|
||||
['count', { frequency: 'daily', count: 10 }, 'FREQ=DAILY;COUNT=10'],
|
||||
['byDay', { frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'we' }] }, 'FREQ=WEEKLY;BYDAY=MO,WE'],
|
||||
['nth weekday', { frequency: 'monthly', byDay: [{ day: 'fr', nthOfPeriod: -1 }, { day: 'tu', nthOfPeriod: 2 }] }, 'FREQ=MONTHLY;BYDAY=-1FR,2TU'],
|
||||
['byMonthDay', { frequency: 'monthly', byMonthDay: [1, 15, -1] }, 'FREQ=MONTHLY;BYMONTHDAY=1,15,-1'],
|
||||
['byMonth', { frequency: 'yearly', byMonth: ['3', '10'] }, 'FREQ=YEARLY;BYMONTH=3,10'],
|
||||
['byYearDay', { frequency: 'yearly', byYearDay: [1, -1] }, 'FREQ=YEARLY;BYYEARDAY=1,-1'],
|
||||
['byWeekNo', { frequency: 'yearly', byWeekNo: [20], byDay: [{ day: 'mo' }] }, 'FREQ=YEARLY;BYDAY=MO;BYWEEKNO=20'],
|
||||
['byHour/Minute/Second', { frequency: 'daily', byHour: [9, 17], byMinute: [0, 30], bySecond: [0] }, 'FREQ=DAILY;BYSECOND=0;BYMINUTE=0,30;BYHOUR=9,17'],
|
||||
['bySetPosition', { frequency: 'monthly', byDay: [{ day: 'mo' }, { day: 'tu' }], bySetPosition: [-1] }, 'FREQ=MONTHLY;BYDAY=MO,TU;BYSETPOS=-1'],
|
||||
['wkst', { frequency: 'weekly', interval: 2, firstDayOfWeek: 'su' }, 'FREQ=WEEKLY;INTERVAL=2;WKST=SU'],
|
||||
])('writes and reads back %s', (_name, rule, rrule) => {
|
||||
const written = ruleToRRule(rule, timed)!;
|
||||
expect(written).toBe(rrule);
|
||||
expect(aospRruleValid(written)).toBe(true);
|
||||
expect(rruleToRule(written, timed)).toEqual(rule);
|
||||
});
|
||||
|
||||
it('leaves out the defaults: INTERVAL=1, WKST=MO, rscale gregorian, skip omit', () => {
|
||||
expect(ruleToRRule({ frequency: 'weekly', interval: 1, firstDayOfWeek: 'mo', rscale: 'gregorian', skip: 'omit' }, timed)).toBe('FREQ=WEEKLY');
|
||||
});
|
||||
|
||||
it('writes UNTIL as UTC for timed rules, converted from the event zone, and as a date for all-day rules', () => {
|
||||
expect(ruleToRRule({ frequency: 'daily', until: '2026-10-30T09:00:00' }, timed)).toBe('FREQ=DAILY;UNTIL=20261030T080000Z');
|
||||
expect(ruleToRRule({ frequency: 'daily', until: '2026-07-30T09:00:00' }, timed)).toBe('FREQ=DAILY;UNTIL=20260730T070000Z');
|
||||
expect(ruleToRRule({ frequency: 'daily', until: '2026-10-30T23:59:59' }, allDay)).toBe('FREQ=DAILY;UNTIL=20261030');
|
||||
expect(rruleToRule('FREQ=DAILY;UNTIL=20261030T080000Z', timed)).toEqual({ frequency: 'daily', until: '2026-10-30T09:00:00' });
|
||||
expect(rruleToRule('FREQ=DAILY;UNTIL=20261030', allDay)).toEqual({ frequency: 'daily', until: '2026-10-30T00:00:00' });
|
||||
});
|
||||
|
||||
it('converts UNTIL across DST edges', () => {
|
||||
// New York spring gap: 02:30 does not exist, UNTIL moves forward like the instance does.
|
||||
expect(ruleToRRule({ frequency: 'daily', until: '2026-03-08T02:30:00' }, { allDay: false, zone: 'America/New_York' })).toBe(
|
||||
'FREQ=DAILY;UNTIL=20260308T073000Z',
|
||||
);
|
||||
// Berlin autumn overlap: the first instant.
|
||||
expect(ruleToRRule({ frequency: 'daily', until: '2026-10-25T02:30:00' }, timed)).toBe('FREQ=DAILY;UNTIL=20261025T003000Z');
|
||||
expect(untilUtc({ frequency: 'daily', until: '2026-10-25T02:30:00' }, timed)).toBe(Date.parse('2026-10-25T00:30:00Z'));
|
||||
// Sydney's autumn overlap.
|
||||
expect(ruleToRRule({ frequency: 'weekly', until: '2026-04-05T02:30:00' }, { allDay: false, zone: 'Australia/Sydney' })).toBe(
|
||||
'FREQ=WEEKLY;UNTIL=20260404T153000Z',
|
||||
);
|
||||
});
|
||||
|
||||
it('reads rules the way device apps write them', () => {
|
||||
expect(rruleToRule('rrule:freq=weekly;byday=mo,we;x-fossify=1', timed)).toEqual({ frequency: 'weekly', byDay: [{ day: 'mo' }, { day: 'we' }] });
|
||||
// Etar writes all-day UNTIL as a UTC date-time (the instance start minus 1 s).
|
||||
expect(rruleToRule('FREQ=DAILY;UNTIL=20261015T235959Z', allDay)).toEqual({ frequency: 'daily', until: '2026-10-15T00:00:00' });
|
||||
// A floating UNTIL is UTC to AOSP, and so is a DATE on a timed rule.
|
||||
expect(parseUntil('20261030T080000', timed)).toBe('2026-10-30T09:00:00');
|
||||
expect(parseUntil('20261030', timed)).toBe('2026-10-30T01:00:00');
|
||||
// COUNT wins over UNTIL next to it.
|
||||
expect(rruleToRule('FREQ=DAILY;COUNT=5;UNTIL=20261030T080000Z', timed)).toEqual({ frequency: 'daily', count: 5 });
|
||||
// Several rules: the first one.
|
||||
expect(rruleToRule('FREQ=DAILY\nFREQ=WEEKLY', timed)).toEqual({ frequency: 'daily' });
|
||||
expect(rruleToRule('INTERVAL=2', timed)).toBeNull();
|
||||
expect(rruleToRule('FREQ=FORTNIGHTLY', timed)).toBeNull();
|
||||
expect(rruleToRule(null, timed)).toBeNull();
|
||||
});
|
||||
|
||||
it('tells rules AOSP cannot hold', () => {
|
||||
expect(unrepresentableParts({ frequency: 'yearly', rscale: 'chinese' })).toEqual(['rscale']);
|
||||
expect(unrepresentableParts({ frequency: 'monthly', skip: 'forward' })).toEqual(['skip']);
|
||||
expect(unrepresentableParts({ frequency: 'yearly', byMonth: ['5L'] })).toEqual(['byMonth']);
|
||||
expect(isRuleRepresentable({ frequency: 'yearly', rscale: 'GREGORIAN', skip: 'omit' })).toBe(true);
|
||||
// Never RSCALE or SKIP; a leap month is dropped, not written.
|
||||
const written = ruleToRRule({ frequency: 'yearly', rscale: 'chinese', skip: 'forward', byMonth: ['5L', '6'] }, timed)!;
|
||||
expect(written).toBe('FREQ=YEARLY;BYMONTH=6');
|
||||
expect(aospRruleValid(written)).toBe(true);
|
||||
});
|
||||
|
||||
it('compares rules by meaning', () => {
|
||||
expect(canonicalRRule('FREQ=WEEKLY;BYDAY=WE,MO;INTERVAL=1;WKST=MO')).toBe(canonicalRRule('freq=weekly;byday=mo,we'));
|
||||
expect(canonicalRRule('FREQ=WEEKLY;BYDAY=MO')).not.toBe(canonicalRRule('FREQ=WEEKLY;BYDAY=TU'));
|
||||
expect(canonicalRRule(null)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calendar/exdate', () => {
|
||||
it('writes excluded instances as UTC date-times, or dates for all-day events', () => {
|
||||
expect(formatExdate(['2026-10-14T09:00:00', '2026-10-12T09:00:00'], timed)).toBe('20261012T070000Z,20261014T070000Z');
|
||||
expect(formatExdate(['2026-10-12T00:00:00'], allDay)).toBe('20261012');
|
||||
expect(formatExdate([], timed)).toBeNull();
|
||||
});
|
||||
|
||||
it('reads every form back into recurrence ids in the event zone', () => {
|
||||
expect(parseExdate('20261012T070000Z,20261014T070000Z', timed)).toEqual(['2026-10-12T09:00:00', '2026-10-14T09:00:00']);
|
||||
// A zone prefix, a floating time without one (UTC), several lines.
|
||||
expect(parseExdate('Europe/Berlin;20261012T090000\n20261014T070000', timed)).toEqual(['2026-10-12T09:00:00', '2026-10-14T09:00:00']);
|
||||
expect(parseExdate('20261012,20261013T000000Z', allDay)).toEqual(['2026-10-12T00:00:00', '2026-10-13T00:00:00']);
|
||||
expect(parseExdate('garbage,20261012T070000Z', timed)).toEqual(['2026-10-12T09:00:00']);
|
||||
expect(parseExdate(null, timed)).toEqual([]);
|
||||
});
|
||||
|
||||
it('compares columns by the instants they name', () => {
|
||||
expect([...exdateSet('Europe/Berlin;20261012T090000')]).toEqual(['20261012T070000Z']);
|
||||
expect([...exdateSet('20261012T000000Z', true)]).toEqual(['20261012']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Attendees, Events, Reminders } from '../../android-columns';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import type { UploadPlan } from '../../planner';
|
||||
import type { CalendarEventWire } from '../../wire';
|
||||
import type { Row } from '../../types';
|
||||
import { cssColorToArgb } from '../../calendar/values';
|
||||
import { Harness, ME, serverApply, single, utc, weekly } from './harness';
|
||||
|
||||
type Plan = UploadPlan<CalendarEventWire>;
|
||||
|
||||
function updateOf(plan: Plan) {
|
||||
expect(plan.kind).toBe('upload');
|
||||
if (plan.kind !== 'upload') throw new Error('not an upload');
|
||||
expect(plan.actions).toHaveLength(1);
|
||||
const action = plan.actions[0];
|
||||
if (action.kind !== 'update') throw new Error(`not an update: ${action.kind}`);
|
||||
return action;
|
||||
}
|
||||
|
||||
async function synced(event: CalendarEventWire, options = {}) {
|
||||
const h = await new Harness(options).setup();
|
||||
await h.download(event);
|
||||
const id = Number(h.masterRow(event.id)._id);
|
||||
return { h, id };
|
||||
}
|
||||
|
||||
const FIXTURES: Array<[string, () => CalendarEventWire]> = [
|
||||
['a recurring event with overrides', weekly],
|
||||
['a single timed event', () => single()],
|
||||
['a floating event', () => single({ id: 'f', timeZone: null })],
|
||||
['an all-day event', () => single({ id: 'a', start: '2026-10-08T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P3D' })],
|
||||
['a recurring all-day event', () => single({ id: 'y', start: '2026-10-08T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P1D', recurrenceRule: { frequency: 'yearly' } })],
|
||||
['an HTML description and a CSS colour name', () => single({ id: 'h', description: '<p>Hi <b>there</b></p>', descriptionContentType: 'text/html', color: 'rebeccapurple' })],
|
||||
];
|
||||
|
||||
describe('calendar planner: uploads of edited events', () => {
|
||||
it.each(FIXTURES)('round-trips %s: rows untouched → clean', async (_name, make) => {
|
||||
const event = make();
|
||||
const { h, id } = await synced(event);
|
||||
// An app saving the event without changing anything.
|
||||
h.fake.user.updateEvent(id, {});
|
||||
const plan = calendarPlanner.planUpload((await h.local(event.id))!, h.ctx);
|
||||
expect(plan.kind).toBe('clean');
|
||||
if (plan.kind !== 'clean') return;
|
||||
await h.apply(plan.ops);
|
||||
expect(h.row(id)[Events.DIRTY]).toBe(0);
|
||||
// And the next download of the same event writes nothing.
|
||||
expect(calendarPlanner.planDownload(event, await h.local(event.id), h.ctx).effect).toBe('none');
|
||||
});
|
||||
|
||||
it.each<[string, Row, Record<string, unknown>]>([
|
||||
['title', { [Events.TITLE]: 'New title' }, { title: 'New title' }],
|
||||
['description', { [Events.DESCRIPTION]: 'New text' }, { description: 'New text' }],
|
||||
['a cleared description', { [Events.DESCRIPTION]: '' }, { description: null }],
|
||||
['location', { [Events.EVENT_LOCATION]: 'Room 2' }, { 'locations/locA/name': 'Room 2' }],
|
||||
['a cleared location', { [Events.EVENT_LOCATION]: null }, { 'locations/locA': null }],
|
||||
['status', { [Events.STATUS]: 1 }, { status: 'confirmed' }],
|
||||
['availability', { [Events.AVAILABILITY]: 0 }, { freeBusyStatus: 'busy' }],
|
||||
['tentative availability (busy)', { [Events.AVAILABILITY]: 2 }, { freeBusyStatus: 'busy' }],
|
||||
['privacy', { [Events.ACCESS_LEVEL]: 3 }, { privacy: 'public' }],
|
||||
['default privacy', { [Events.ACCESS_LEVEL]: 0 }, { privacy: null }],
|
||||
['colour', { [Events.EVENT_COLOR]: cssColorToArgb('#ff8800') }, { color: '#ff8800' }],
|
||||
['duration', { [Events.DURATION]: 'P7200S' }, { duration: 'PT2H' }],
|
||||
])('uploads one edited field of a series as a patch of only that field: %s', async (_name, values, patch) => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.updateEvent(id, values);
|
||||
const action = updateOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
|
||||
expect(action.id).toBe('bl');
|
||||
expect(action.patch).toEqual(patch);
|
||||
});
|
||||
|
||||
it('adds the first location as a whole map', async () => {
|
||||
const { h, id } = await synced(single());
|
||||
h.fake.user.updateEvent(id, { [Events.EVENT_LOCATION]: 'Café' });
|
||||
const action = updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx));
|
||||
const [[key, value]] = Object.entries(action.patch.locations as Record<string, unknown>);
|
||||
expect(Object.keys(action.patch)).toEqual(['locations']);
|
||||
expect(key).toMatch(/^b[0-9a-z]{8}$/);
|
||||
expect(value).toEqual({ '@type': 'Location', name: 'Café' });
|
||||
});
|
||||
|
||||
it('uploads an HTML description as text/plain only when it was edited', async () => {
|
||||
const { h, id } = await synced(single({ description: '<p>Hi <b>there</b></p>', descriptionContentType: 'text/html' }));
|
||||
expect(h.row(id)[Events.DESCRIPTION]).toBe('Hi there');
|
||||
h.fake.user.updateEvent(id, { [Events.DESCRIPTION]: 'Hi there!' });
|
||||
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({
|
||||
description: 'Hi there!',
|
||||
descriptionContentType: 'text/plain',
|
||||
});
|
||||
});
|
||||
|
||||
it('never uploads an edit of a description that was written truncated', async () => {
|
||||
const long = 'x'.repeat(70_000);
|
||||
const { h, id } = await synced(single({ description: long }));
|
||||
expect(String(h.row(id)[Events.DESCRIPTION]).length).toBe(65_536);
|
||||
h.fake.user.updateEvent(id, { [Events.DESCRIPTION]: 'short now', [Events.TITLE]: 'T' });
|
||||
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({ title: 'T' });
|
||||
});
|
||||
|
||||
it('moves a single event: start in its zone, the length unchanged', async () => {
|
||||
const { h, id } = await synced(single());
|
||||
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-06T12:30:00Z'), [Events.DTEND]: utc('2026-10-06T13:30:00Z') });
|
||||
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({ start: '2026-10-06T14:30:00' });
|
||||
});
|
||||
|
||||
it('keeps a floating event floating, and takes a zone the user picked', async () => {
|
||||
const { h, id } = await synced(single({ id: 'f', timeZone: null }));
|
||||
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-06T11:00:00Z'), [Events.DTEND]: utc('2026-10-06T12:00:00Z') });
|
||||
expect(updateOf(calendarPlanner.planUpload((await h.local('f'))!, h.ctx)).patch).toEqual({ start: '2026-10-06T13:00:00' });
|
||||
h.fake.user.updateEvent(id, { [Events.EVENT_TIMEZONE]: 'Asia/Tokyo' });
|
||||
expect(updateOf(calendarPlanner.planUpload((await h.local('f'))!, h.ctx)).patch).toEqual({ start: '2026-10-06T20:00:00', timeZone: 'Asia/Tokyo' });
|
||||
});
|
||||
|
||||
it('keeps all-day events dates and turns timed ones into all-day ones', async () => {
|
||||
const { h, id } = await synced(single({ id: 'a', start: '2026-10-08T00:00:00', timeZone: null, showWithoutTime: true, duration: 'P1D' }));
|
||||
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-09T00:00:00Z'), [Events.DTEND]: utc('2026-10-11T00:00:00Z') });
|
||||
expect(updateOf(calendarPlanner.planUpload((await h.local('a'))!, h.ctx)).patch).toEqual({ start: '2026-10-09T00:00:00', duration: 'P2D' });
|
||||
|
||||
const t = await synced(single());
|
||||
t.h.fake.user.updateEvent(t.id, {
|
||||
[Events.ALL_DAY]: 1,
|
||||
[Events.EVENT_TIMEZONE]: 'UTC',
|
||||
[Events.DTSTART]: utc('2026-10-06T00:00:00Z'),
|
||||
[Events.DTEND]: utc('2026-10-07T00:00:00Z'),
|
||||
});
|
||||
expect(updateOf(calendarPlanner.planUpload((await t.h.local('e1'))!, t.h.ctx)).patch).toEqual({
|
||||
start: '2026-10-06T00:00:00',
|
||||
duration: 'P1D',
|
||||
timeZone: null,
|
||||
showWithoutTime: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('sends a single event whose new start is a repeated wall time in a fixed-offset zone', async () => {
|
||||
const { h, id } = await synced(single());
|
||||
// 02:30 CEST on the night the clocks go back: Stalwart would drop it in Europe/Berlin.
|
||||
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-25T00:30:00Z'), [Events.DTEND]: utc('2026-10-25T01:30:00Z') });
|
||||
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({ start: '2026-10-25T02:30:00', timeZone: 'Etc/GMT-2' });
|
||||
// The second 02:30 (CET) is the same wall time an hour later.
|
||||
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-25T01:30:00Z'), [Events.DTEND]: utc('2026-10-25T02:30:00Z') });
|
||||
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({ start: '2026-10-25T02:30:00', timeZone: 'Etc/GMT-1' });
|
||||
});
|
||||
|
||||
it('skips a series whose new start is a repeated wall time', async () => {
|
||||
const { h, id } = await synced(single({ recurrenceRule: { frequency: 'daily' } }));
|
||||
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-25T00:30:00Z') });
|
||||
expect(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).toEqual({ kind: 'skip', reason: 'dstAmbiguous' });
|
||||
});
|
||||
|
||||
it('never uploads the timing of a rule Android cannot represent', async () => {
|
||||
const event = single({ recurrenceRule: { frequency: 'yearly', rscale: 'chinese', byMonth: ['5L'] } });
|
||||
const { h, id } = await synced(event);
|
||||
expect(h.row(id)[Events.RRULE]).toBe('FREQ=YEARLY');
|
||||
h.fake.user.updateEvent(id, { [Events.DTSTART]: utc('2026-10-06T11:00:00Z') });
|
||||
expect(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).toEqual({ kind: 'skip', reason: 'ruleNotRepresentable' });
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Festival' });
|
||||
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({ title: 'Festival' });
|
||||
});
|
||||
|
||||
it('uploads the user\'s RSVP with scheduling messages', async () => {
|
||||
const event = single({
|
||||
organizerCalendarAddress: 'mailto:boss@example.net',
|
||||
participants: {
|
||||
boss: { calendarAddress: 'mailto:boss@example.net', roles: { owner: true } },
|
||||
me: { calendarAddress: 'mailto:usera@example.org', roles: { attendee: true }, participationStatus: 'needs-action' },
|
||||
},
|
||||
} as Partial<CalendarEventWire>);
|
||||
const { h, id } = await synced(event);
|
||||
h.fake.user.setAttendeeStatus(id, ME, 1);
|
||||
const action = updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx));
|
||||
expect(action).toMatchObject({ patch: { 'participants/me/participationStatus': 'accepted' }, sendSchedulingMessages: true });
|
||||
});
|
||||
|
||||
it('adds and removes attendees; the organizer notifies them', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
const guest = h.fake.rows('attendees', `${Attendees.EVENT_ID} = ? AND ${Attendees.ATTENDEE_EMAIL} = ?`, [id, 'guest@example.net'])[0];
|
||||
h.fake.user.removeAttendee(Number(guest._id));
|
||||
h.fake.user.addAttendee(id, {
|
||||
[Attendees.ATTENDEE_EMAIL]: 'new@example.net',
|
||||
[Attendees.ATTENDEE_NAME]: 'New',
|
||||
[Attendees.ATTENDEE_TYPE]: 2,
|
||||
[Attendees.ATTENDEE_RELATIONSHIP]: 1,
|
||||
[Attendees.ATTENDEE_STATUS]: 3,
|
||||
});
|
||||
const action = updateOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
|
||||
expect(action.sendSchedulingMessages).toBe(true);
|
||||
const added = Object.entries(action.patch).find(([k, v]) => k.startsWith('participants/') && v);
|
||||
expect(added![1]).toEqual({
|
||||
'@type': 'Participant',
|
||||
calendarAddress: 'mailto:new@example.net',
|
||||
name: 'New',
|
||||
roles: { attendee: true, optional: true },
|
||||
participationStatus: 'needs-action',
|
||||
expectReply: true,
|
||||
});
|
||||
expect(action.patch['participants/guestX']).toBeNull();
|
||||
});
|
||||
|
||||
it('sends the whole participants map for the first attendee', async () => {
|
||||
const { h, id } = await synced(single());
|
||||
h.fake.user.addAttendee(id, { [Attendees.ATTENDEE_EMAIL]: 'guest@example.net', [Attendees.ATTENDEE_TYPE]: 1, [Attendees.ATTENDEE_STATUS]: 3 });
|
||||
const action = updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx));
|
||||
expect(Object.keys(action.patch)).toEqual(['participants']);
|
||||
expect(Object.values(action.patch.participants as object)).toHaveLength(1);
|
||||
expect(action.sendSchedulingMessages).toBe(true);
|
||||
});
|
||||
|
||||
it('replaces the reminders it can show, keeping the matching keys and the others', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.setReminders(id, [{ minutes: 15 }, { minutes: 60, method: 2 }]);
|
||||
const action = updateOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
|
||||
const keys = Object.keys(action.patch);
|
||||
expect(keys).toHaveLength(1);
|
||||
expect(keys[0]).toMatch(/^alerts\/b[0-9a-z]{8}$/);
|
||||
expect(action.patch[keys[0]]).toEqual({ '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT1H', relativeTo: 'start' }, action: 'email' });
|
||||
// Reminders alone notify nobody.
|
||||
expect(action.sendSchedulingMessages).toBeUndefined();
|
||||
|
||||
h.fake.user.setReminders(id, []);
|
||||
expect(updateOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx)).patch).toEqual({ 'alerts/al1': null });
|
||||
});
|
||||
|
||||
it('switches off the calendar defaults when reminders of an event that used them change', async () => {
|
||||
const { h, id } = await synced(single({ useDefaultAlerts: true }));
|
||||
h.fake.user.setReminders(id, [{ minutes: 10 }, { minutes: 30 }]);
|
||||
const patch = updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch;
|
||||
expect(patch.useDefaultAlerts).toBe(false);
|
||||
const alerts = patch.alerts as Record<string, { trigger: { offset: string } }>;
|
||||
// The default's key is kept for the reminder that matches it.
|
||||
expect(alerts.d1.trigger.offset).toBe('-PT10M');
|
||||
expect(Object.values(alerts).map((a) => a.trigger.offset).sort()).toEqual(['-PT10M', '-PT30M']);
|
||||
});
|
||||
|
||||
it('does not upload reminders when Bulwark owns them', async () => {
|
||||
const { h, id } = await synced(weekly(), { reminderOwner: 'bulwark' });
|
||||
h.fake.user.setReminders(id, [{ minutes: 5 }]);
|
||||
expect(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx).kind).toBe('clean');
|
||||
});
|
||||
|
||||
it('ignores attendee types and relationships a lossy editor rewrote, and merges its rule part by part', async () => {
|
||||
const event = weekly();
|
||||
event.recurrenceRule = { frequency: 'monthly', byDay: [{ day: 'mo' }, { day: 'tu' }], bySetPosition: [-1], count: 10 };
|
||||
const { h, id } = await synced(event);
|
||||
const attendees = h.fake.rows('attendees', `${Attendees.EVENT_ID} = ?`, [id]).map((a) => ({
|
||||
[Attendees.ATTENDEE_EMAIL]: a[Attendees.ATTENDEE_EMAIL],
|
||||
[Attendees.ATTENDEE_NAME]: a[Attendees.ATTENDEE_NAME],
|
||||
[Attendees.ATTENDEE_RELATIONSHIP]: 1,
|
||||
[Attendees.ATTENDEE_TYPE]: 1,
|
||||
[Attendees.ATTENDEE_STATUS]: a[Attendees.ATTENDEE_STATUS],
|
||||
}));
|
||||
const reminders = h.fake.rows('reminders', `${Reminders.EVENT_ID} = ?`, [id]).map((r) => ({ minutes: Number(r[Reminders.MINUTES]) }));
|
||||
// Fossify rebuilds the rule without BYSETPOS and changes the count.
|
||||
h.fake.user.fossifySaveEvent(id, { [Events.RRULE]: 'FREQ=MONTHLY;COUNT=12;BYDAY=MO,TU' }, attendees, reminders);
|
||||
h.fake.table('events').get(id)![Events.MUTATORS] = 'org.fossify.calendar';
|
||||
const action = updateOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
|
||||
expect(action.patch).toEqual({
|
||||
recurrenceRule: { frequency: 'monthly', count: 12, byDay: [{ day: 'mo' }, { day: 'tu' }], bySetPosition: [-1] },
|
||||
});
|
||||
});
|
||||
|
||||
it('turns a series into a single event in place', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.updateEvent(id, { [Events.RRULE]: null, [Events.DURATION]: null, [Events.DTEND]: utc('2026-10-05T14:30:00Z') });
|
||||
const action = updateOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
|
||||
expect(action.patch).toEqual({ recurrenceRule: null, recurrenceOverrides: null });
|
||||
expect(action.sendSchedulingMessages).toBe(true);
|
||||
});
|
||||
|
||||
it('moves an event between calendars of its account by swapping calendarIds', async () => {
|
||||
const { h, id } = await synced(single());
|
||||
h.fake.user.updateEvent(id, { [Events.CALENDAR_ID]: h.rowIds.get('w')! });
|
||||
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({ 'calendarIds/b': null, 'calendarIds/w': true });
|
||||
});
|
||||
|
||||
it('reverts edits in a read-only calendar, but lets an RSVP through', async () => {
|
||||
const event = single({
|
||||
calendarIds: { r: true },
|
||||
organizerCalendarAddress: 'mailto:boss@example.net',
|
||||
participants: {
|
||||
boss: { calendarAddress: 'mailto:boss@example.net', roles: { owner: true } },
|
||||
me: { calendarAddress: 'mailto:usera@example.org', roles: { attendee: true }, participationStatus: 'needs-action' },
|
||||
},
|
||||
} as Partial<CalendarEventWire>);
|
||||
const { h, id } = await synced(event);
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Mine now' });
|
||||
const plan = calendarPlanner.planUpload((await h.local('e1'))!, h.ctx);
|
||||
expect(plan).toMatchObject({ kind: 'revert', reason: 'readOnly' });
|
||||
if (plan.kind === 'revert') await h.apply(plan.ops);
|
||||
expect(h.row(id)).toMatchObject({ [Events.TITLE]: 'Lunch', [Events.DIRTY]: 0 });
|
||||
|
||||
h.fake.user.setAttendeeStatus(id, ME, 2);
|
||||
expect(updateOf(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).patch).toEqual({ 'participants/me/participationStatus': 'declined' });
|
||||
|
||||
// Without mayRSVP, the answer is put back too, and says why.
|
||||
const noRsvp = { ...h.ctx, calendar: (cid: string) => (cid === 'r' ? { ...h.ctx.calendar(cid)!, myRights: { mayReadItems: true } } : h.ctx.calendar(cid)) };
|
||||
expect(calendarPlanner.planUpload((await h.local('e1'))!, noRsvp)).toMatchObject({ kind: 'revert', reason: 'rsvpRefused' });
|
||||
});
|
||||
|
||||
it('skips a poisoned item until it changes or the back-off ends', async () => {
|
||||
const { h, id } = await synced(single());
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Bad' });
|
||||
const local = (await h.local('e1'))!;
|
||||
const { poisonFingerprint, poisonOps } = await import('../../calendar/planner');
|
||||
const { nextMarker } = await import('../../engine/poison');
|
||||
// The marker as the engine writes it after an invalidProperties SetError.
|
||||
const marker = nextMarker(null, poisonFingerprint(local), { type: 'invalidProperties' }, h.ctx.now, { firstMs: 3_600_000, maxMs: 86_400_000 });
|
||||
await h.apply(poisonOps(local, marker));
|
||||
expect(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx)).toEqual({ kind: 'skip', reason: 'invalidProperties' });
|
||||
expect(calendarPlanner.planUpload((await h.local('e1'))!, { ...h.ctx, now: h.ctx.now + 7_200_000 }).kind).toBe('upload');
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Better' });
|
||||
expect(calendarPlanner.planUpload((await h.local('e1'))!, h.ctx).kind).toBe('upload');
|
||||
});
|
||||
|
||||
it('applies a patch the server accepts (round trip through serverApply)', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.updateEvent(id, { [Events.TITLE]: 'Renamed', [Events.EVENT_LOCATION]: 'Hall' });
|
||||
const action = updateOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
|
||||
const server = serverApply(weekly(), action.patch);
|
||||
expect(server.title).toBe('Renamed');
|
||||
expect(server.locations).toEqual({ locA: { name: 'Hall', '@type': 'Location' } });
|
||||
expect(server.participants).toEqual(weekly().participants);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
canonicalZone,
|
||||
classifyLocal,
|
||||
dateToUtcMidnight,
|
||||
fixedOffsetZone,
|
||||
isValidTimeZone,
|
||||
localToUtc,
|
||||
offsetAt,
|
||||
parseLocalDateTime,
|
||||
utcMidnightToDate,
|
||||
utcToLocal,
|
||||
} from '../../calendar/zoned-time';
|
||||
|
||||
const utc = (iso: string) => Date.parse(iso);
|
||||
|
||||
describe('calendar/zoned-time', () => {
|
||||
it('reads LocalDateTimes like Stalwart: fraction and suffix ignored, impossible dates refused', () => {
|
||||
expect(parseLocalDateTime('2026-10-25T02:30:00')).toEqual({ year: 2026, month: 10, day: 25, hour: 2, minute: 30, second: 0 });
|
||||
expect(parseLocalDateTime('2026-10-25T02:30:00.250Z')).toMatchObject({ hour: 2, minute: 30, second: 0 });
|
||||
expect(parseLocalDateTime('2026-10-25')).toMatchObject({ hour: 0, minute: 0 });
|
||||
expect(parseLocalDateTime('2026-02-30T00:00:00')).toBeNull();
|
||||
expect(parseLocalDateTime('2026-01-01T24:00:00')).toBeNull();
|
||||
expect(parseLocalDateTime('garbage')).toBeNull();
|
||||
});
|
||||
|
||||
// R3 B1: the app's localDateTimeToInstant gets these wrong in one direction or the other.
|
||||
it.each([
|
||||
['America/New_York', '2026-03-08T02:30:00', 'gap', '2026-03-08T07:30:00Z'],
|
||||
['America/New_York', '2026-11-01T01:30:00', 'overlap', '2026-11-01T05:30:00Z'],
|
||||
['Europe/Berlin', '2026-03-29T02:30:00', 'gap', '2026-03-29T01:30:00Z'],
|
||||
['Europe/Berlin', '2026-10-25T02:30:00', 'overlap', '2026-10-25T00:30:00Z'],
|
||||
['Australia/Sydney', '2026-04-05T02:30:00', 'overlap', '2026-04-04T15:30:00Z'],
|
||||
['Australia/Sydney', '2026-10-04T02:30:00', 'gap', '2026-10-03T16:30:00Z'],
|
||||
['Australia/Lord_Howe', '2026-10-04T02:15:00', 'gap', '2026-10-03T15:45:00Z'],
|
||||
['Europe/Berlin', '2026-10-25T04:30:00', 'ok', '2026-10-25T03:30:00Z'],
|
||||
['Asia/Kolkata', '2026-06-01T10:00:00', 'ok', '2026-06-01T04:30:00Z'],
|
||||
])('resolves %s %s (%s) with RFC 5545 semantics', (zone, local, kind, expected) => {
|
||||
expect(classifyLocal(local, zone)).toBe(kind);
|
||||
expect(new Date(localToUtc(local, zone)!).toISOString().replace('.000', '')).toBe(expected);
|
||||
});
|
||||
|
||||
it('moves a gap time forward by the gap and keeps the wall time of an overlap', () => {
|
||||
expect(utcToLocal(localToUtc('2026-03-29T02:30:00', 'Europe/Berlin')!, 'Europe/Berlin')).toBe('2026-03-29T03:30:00');
|
||||
expect(utcToLocal(localToUtc('2026-03-08T02:30:00', 'America/New_York')!, 'America/New_York')).toBe('2026-03-08T03:30:00');
|
||||
// Both instants of the repeated hour read back as the same wall time.
|
||||
expect(utcToLocal(utc('2026-10-25T00:30:00Z'), 'Europe/Berlin')).toBe('2026-10-25T02:30:00');
|
||||
expect(utcToLocal(utc('2026-10-25T01:30:00Z'), 'Europe/Berlin')).toBe('2026-10-25T02:30:00');
|
||||
});
|
||||
|
||||
it('does not depend on the zone the process runs in', () => {
|
||||
// The device's own gap day is no special case: fields are parsed, not handed to Date.
|
||||
expect(localToUtc('2026-03-29T02:30:00', 'UTC')).toBe(utc('2026-03-29T02:30:00Z'));
|
||||
expect(offsetAt(utc('2026-07-01T00:00:00Z'), 'Europe/Berlin')).toBe(7_200_000);
|
||||
expect(offsetAt(utc('2026-01-01T00:00:00Z'), 'America/New_York')).toBe(-18_000_000);
|
||||
});
|
||||
|
||||
it('validates zones with Intl and names them canonically', () => {
|
||||
expect(isValidTimeZone('Europe/Berlin')).toBe(true);
|
||||
expect(isValidTimeZone('Mars/Olympus_Mons')).toBe(false);
|
||||
expect(isValidTimeZone('')).toBe(false);
|
||||
expect(isValidTimeZone(null)).toBe(false);
|
||||
expect(canonicalZone('europe/berlin')).toBe('Europe/Berlin');
|
||||
expect(canonicalZone('US/Eastern')).toBe('America/New_York');
|
||||
});
|
||||
|
||||
it('names fixed-offset zones the Etc way, UTC for fractional offsets', () => {
|
||||
expect(fixedOffsetZone(7_200_000)).toBe('Etc/GMT-2');
|
||||
expect(fixedOffsetZone(-18_000_000)).toBe('Etc/GMT+5');
|
||||
expect(fixedOffsetZone(0)).toBe('Etc/UTC');
|
||||
expect(fixedOffsetZone(19_800_000)).toBe('Etc/UTC');
|
||||
});
|
||||
|
||||
it('maps all-day dates to UTC midnight and back', () => {
|
||||
expect(dateToUtcMidnight('2026-10-08T00:00:00')).toBe(utc('2026-10-08T00:00:00Z'));
|
||||
expect(dateToUtcMidnight('2026-10-08T09:00:00')).toBe(utc('2026-10-08T00:00:00Z'));
|
||||
expect(utcMidnightToDate(utc('2026-10-08T00:00:00Z'))).toBe('2026-10-08T00:00:00');
|
||||
// Date.UTC would read year 50 as 1950.
|
||||
expect(new Date(dateToUtcMidnight('0050-01-01T00:00:00')!).toISOString()).toBe('0050-01-01T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { canonicalJson, deepEqual, jsonHash, parseJsonColumn } from '../common/json';
|
||||
import { applyPatch, assertPatchWellFormed, ptr, ptrSegments, setInPatch } from '../common/patch';
|
||||
import {
|
||||
exceptionRef,
|
||||
makeKeyMinter,
|
||||
objectRef,
|
||||
parseObjectRef,
|
||||
pendingRef,
|
||||
pendingUidOf,
|
||||
uuidFrom,
|
||||
} from '../common/ids';
|
||||
|
||||
function seeded(seed: number) {
|
||||
return () => {
|
||||
seed = (seed * 1103515245 + 12345) % 2147483648;
|
||||
return seed / 2147483648;
|
||||
};
|
||||
}
|
||||
|
||||
describe('common/json', () => {
|
||||
it('compares and hashes independently of key order and undefined members', () => {
|
||||
const a = { b: 1, a: { d: [1, { y: 2, x: 1 }], c: undefined } };
|
||||
const b = { a: { d: [1, { x: 1, y: 2 }] }, b: 1 };
|
||||
expect(canonicalJson(a)).toBe(canonicalJson(b));
|
||||
expect(deepEqual(a, b)).toBe(true);
|
||||
expect(jsonHash(a)).toBe(jsonHash(b));
|
||||
expect(deepEqual({ a: [1, 2] }, { a: [2, 1] })).toBe(false);
|
||||
expect(deepEqual({ a: null }, {})).toBe(true);
|
||||
});
|
||||
|
||||
it('reads sync columns defensively', () => {
|
||||
expect(parseJsonColumn('{"v":1}')).toEqual({ v: 1 });
|
||||
expect(parseJsonColumn('not json')).toBeNull();
|
||||
expect(parseJsonColumn(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('common/patch', () => {
|
||||
it('escapes pointer segments', () => {
|
||||
expect(ptr('emails', 'x/y', 'label')).toBe('emails/x~1y/label');
|
||||
expect(ptrSegments('emails/x~1y/a~0b')).toEqual(['emails', 'x/y', 'a~b']);
|
||||
});
|
||||
|
||||
it('applies patches like Stalwart, refusing a missing parent', () => {
|
||||
const card = { emails: { e1: { address: 'a@x' } }, name: { full: 'A' } };
|
||||
expect(applyPatch(card, { 'emails/e1/address': 'b@x', 'emails/e2': { address: 'c@x' }, 'name/full': null })).toEqual({
|
||||
emails: { e1: { address: 'b@x' }, e2: { address: 'c@x' } },
|
||||
name: {},
|
||||
});
|
||||
expect(applyPatch(card, { 'phones/p1': { number: '1' } })).toBeNull();
|
||||
expect(card.emails.e1.address).toBe('a@x');
|
||||
});
|
||||
|
||||
it('keeps patches free of overlapping pointers', () => {
|
||||
expect(() => assertPatchWellFormed({ emails: {}, 'emails/e1': null })).toThrow(/prefix/);
|
||||
const patch: Record<string, unknown> = { 'emails/e1/label': 'Office' };
|
||||
setInPatch(patch, 'emails', { e1: { address: 'a@x', label: 'Office' } });
|
||||
expect(patch).toEqual({ emails: { e1: { address: 'a@x', label: 'Office' } } });
|
||||
setInPatch(patch, 'emails/e1/address', 'b@x');
|
||||
expect(patch).toEqual({ emails: { e1: { address: 'b@x', label: 'Office' } } });
|
||||
assertPatchWellFormed(patch);
|
||||
});
|
||||
});
|
||||
|
||||
describe('common/ids', () => {
|
||||
it('mints keys Stalwart treats as plain keys', () => {
|
||||
const mint = makeKeyMinter(seeded(7));
|
||||
const keys = Array.from({ length: 50 }, () => mint());
|
||||
for (const key of keys) {
|
||||
expect(key).toMatch(/^b[0-9a-z]{8}$/);
|
||||
expect(key).not.toMatch(/^k\d+$/);
|
||||
}
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
const again = makeKeyMinter(() => 0);
|
||||
expect(again(['b00000000'])).not.toBe('b00000000');
|
||||
});
|
||||
|
||||
it('builds v4 uuids and object refs', () => {
|
||||
expect(uuidFrom(seeded(1))).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||
expect(objectRef('c', 'e9')).toBe('c/e9');
|
||||
expect(parseObjectRef('c/e9')).toEqual({ accountId: 'c', id: 'e9' });
|
||||
expect(parseObjectRef(pendingRef('u1'))).toBeNull();
|
||||
expect(pendingUidOf(pendingRef('urn:uuid:1'))).toBe('urn:uuid:1');
|
||||
expect(exceptionRef('c/e9', '2026-10-07T09:00:00')).toBe('c/e9#2026-10-07T09:00:00');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { RawContacts } from '../../android-columns';
|
||||
import { sha256Hex } from '../../../lib/sha256';
|
||||
import { base64ToBytes } from '../../contacts/photo';
|
||||
import { appleCard, JPEG2, probeCard } from './fixtures';
|
||||
import { Harness } from './harness';
|
||||
|
||||
async function patchOf(h: Harness, rawId: number) {
|
||||
const plan = h.planner.planUpload(await h.contact(rawId), h.ctx);
|
||||
return plan.kind === 'upload' && plan.actions[0].kind === 'update' ? plan.actions[0].patch : plan.kind;
|
||||
}
|
||||
|
||||
describe('contacts planAccepted', () => {
|
||||
it('writes the new shadow and baselines and clears DIRTY behind the VERSION read before the upload', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(appleCard());
|
||||
const email = h.rowsByKey(rawId)['emails:k1']._id as number;
|
||||
h.device.user.updateData(email, { data1: 'new@example.com' });
|
||||
const local = await h.contact(rawId);
|
||||
const plan = h.planner.planUpload(local, h.ctx);
|
||||
if (plan.kind !== 'upload') throw new Error(plan.kind);
|
||||
h.send(plan.actions);
|
||||
const accepted = h.planner.planAccepted(local, h.card(id), h.ctx);
|
||||
expect(accepted.ops.ops[0]).toEqual({ op: 'assert', table: 'raw_contacts', id: rawId, values: { version: local.version }, expectCount: 1 });
|
||||
await h.applyOk(accepted.ops);
|
||||
const after = await h.contact(rawId);
|
||||
expect(after.dirty).toBe(false);
|
||||
expect(after.shadow?.emails).toEqual(h.card(id).emails);
|
||||
expect(after.rows.find((r) => r.id === email)?.baseline?.data1).toBe('new@example.com');
|
||||
expect(h.planner.planDownload(h.card(id), after, h.ctx).effect).toBe('none');
|
||||
expect(h.planner.planBaselineHeal(after)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps DIRTY and uploads only the newer edit when the contact was edited during the upload', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
const rows = h.rowsByKey(rawId);
|
||||
h.device.user.updateData(rows['emails:k1']._id as number, { data1: 'first@example.com' });
|
||||
await h.upload(rawId, () => h.device.user.updateData(rows['phones:k1']._id as number, { data1: '+1 555 0142' }));
|
||||
expect(h.dirty(rawId)).toBe(true);
|
||||
expect(await patchOf(h, rawId)).toEqual({ 'phones/k1/number': '+1 555 0142' });
|
||||
});
|
||||
|
||||
it('uploads a second edit of the same field made during the upload, and a revert to the old value', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
const email = h.rowsByKey(rawId)['emails:k1']._id as number;
|
||||
h.device.user.updateData(email, { data1: 'a1@example.com' });
|
||||
await h.upload(rawId, () => h.device.user.updateData(email, { data1: 'a2@example.com' }));
|
||||
expect(await patchOf(h, rawId)).toEqual({ 'emails/k1/address': 'a2@example.com' });
|
||||
|
||||
const h2 = new Harness();
|
||||
const seeded = await h2.seed(appleCard());
|
||||
const email2 = h2.rowsByKey(seeded.rawId)['emails:k1']._id as number;
|
||||
h2.device.user.updateData(email2, { data1: 'a1@example.com' });
|
||||
await h2.upload(seeded.rawId, () => h2.device.user.updateData(email2, { data1: 'anna@example.com' }));
|
||||
expect(await patchOf(h2, seeded.rawId)).toEqual({ 'emails/k1/address': 'anna@example.com' });
|
||||
});
|
||||
|
||||
it('writes the identity of a create even when the contact changed meanwhile', async () => {
|
||||
const h = new Harness();
|
||||
const rawId = h.device.user.insertContact('usera@example.org', [
|
||||
{ mimetype: 'vnd.android.cursor.item/name', data1: 'Late Edit', data2: 'Late', data3: 'Edit' },
|
||||
]);
|
||||
await h.upload(rawId, () => h.device.user.star(rawId));
|
||||
const local = await h.contact(rawId);
|
||||
expect(local.sourceId).toMatch(/^c\//);
|
||||
expect(local.pending).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the device photo after uploading it and records the server hash', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(probeCard());
|
||||
h.device.user.setPhoto(rawId, JPEG2);
|
||||
h.devicePhotos.set(rawId, JPEG2);
|
||||
const fileId = h.rowsByKey(rawId)['media:ph'].data14;
|
||||
await h.upload(rawId);
|
||||
const photo = h.rowsByKey(rawId)['media:ph'];
|
||||
expect(photo.data14).toBe(fileId);
|
||||
expect(photo.data_sync2).toBe(`sha256:${sha256Hex(base64ToBytes(JPEG2)!)}`);
|
||||
expect(h.dirty(rawId)).toBe(false);
|
||||
const local = await h.contact(rawId);
|
||||
expect(h.planner.planDownload(h.card(id), local, h.ctx).effect).toBe('none');
|
||||
expect(h.planner.planBaselineHeal(local)).toBeNull();
|
||||
});
|
||||
|
||||
it('clears the poison marker and the pending create once accepted', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
await h.applyOk({ ref: 'x', ops: [{ op: 'update', table: 'raw_contacts', id: rawId, values: { [RawContacts.SYNC4]: JSON.stringify({ fp: 'old', type: 'invalidProperties', n: 1, until: 1 }) } }] });
|
||||
h.device.user.updateData(h.rowsByKey(rawId)['emails:k1']._id as number, { data1: 'z@example.com' });
|
||||
await h.upload(rawId);
|
||||
expect(h.rawContact(rawId)[RawContacts.SYNC4]).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Data, MimeType, RawContacts } from '../../android-columns';
|
||||
import { sha256Hex } from '../../../lib/sha256';
|
||||
import { base64ToBytes } from '../../contacts/photo';
|
||||
import type { Row } from '../../types';
|
||||
import { appleCard, googleCard, JPEG, probeCard } from './fixtures';
|
||||
import { Harness, JMAP } from './harness';
|
||||
|
||||
/** Cells as text, so the fixtures don't depend on how the provider types a column. */
|
||||
function cells(row: Row | undefined, columns: string[]): Record<string, string | null> {
|
||||
if (!row) throw new Error('missing row');
|
||||
return Object.fromEntries(columns.map((c) => [c, row[c] === null || row[c] === undefined ? null : String(row[c])]));
|
||||
}
|
||||
|
||||
const NAME = ['data1', 'data2', 'data3', 'data4', 'data5', 'data6', 'data7', 'data8', 'data9'];
|
||||
const TYPED = ['data1', 'data2', 'data3'];
|
||||
const PRIMARY = ['data1', 'data2', 'data3', 'is_primary'];
|
||||
const POSTAL = ['data1', 'data2', 'data3', 'data4', 'data5', 'data6', 'data7', 'data8', 'data9', 'data10'];
|
||||
const ORG = ['data1', 'data4', 'data5', 'data6'];
|
||||
|
||||
const photoHash = (b64: string) => `sha256:${sha256Hex(base64ToBytes(b64)!)}`;
|
||||
|
||||
describe('contacts download: golden rows', () => {
|
||||
it('writes the probe card as one row per entry with its keys', async () => {
|
||||
const h = new Harness();
|
||||
h.names.set('urn:uuid:other-person', 'Other Person');
|
||||
const { id, rawId } = await h.seed(probeCard());
|
||||
const rows = h.rowsByKey(rawId);
|
||||
expect(Object.keys(rows).sort()).toEqual([
|
||||
'addresses:home', 'anniversaries:bday', 'anniversaries:wed', 'emails:work1', 'emails:x/y', 'emails:zz9',
|
||||
'links:l1', 'media:ph', 'name', 'nicknames:nk', 'notes:n1', 'organizations:o1|titles:t1,r1', 'phones:fax7',
|
||||
'phones:p-a', 'relatedTo:urn:uuid:other-person',
|
||||
]);
|
||||
expect(cells(rows.name, NAME)).toEqual({
|
||||
data1: 'Dr. Probe Person', data2: 'Probe', data3: 'Person', data4: 'Dr.', data5: null, data6: null,
|
||||
data7: 'Proob', data8: null, data9: null,
|
||||
});
|
||||
expect(cells(rows['nicknames:nk'], ['data1', 'data2'])).toEqual({ data1: 'Probby', data2: '1' });
|
||||
expect(cells(rows['emails:work1'], PRIMARY)).toEqual({ data1: 'probe@work.example', data2: '0', data3: 'Office', is_primary: '1' });
|
||||
expect(cells(rows['emails:x/y'], PRIMARY)).toEqual({ data1: 'slash@example.org', data2: '3', data3: null, is_primary: '0' });
|
||||
expect(cells(rows['emails:zz9'], PRIMARY)).toEqual({ data1: 'private@example.org', data2: '1', data3: null, is_primary: '0' });
|
||||
expect(cells(rows['phones:p-a'], TYPED)).toEqual({ data1: '+49 30 1234', data2: '2', data3: null });
|
||||
expect(cells(rows['phones:fax7'], TYPED)).toEqual({ data1: '+49 30 9999', data2: '4', data3: null });
|
||||
expect(cells(rows['addresses:home'], POSTAL)).toEqual({
|
||||
data1: 'Main St 12, 10115 Berlin, Germany', data2: '1', data3: null, data4: 'Main St 12 4b', data5: null,
|
||||
data6: null, data7: 'Berlin', data8: null, data9: '10115', data10: 'Germany',
|
||||
});
|
||||
expect(cells(rows['organizations:o1|titles:t1,r1'], ORG)).toEqual({ data1: 'ACME', data4: 'Chief Prober', data5: 'R&D', data6: 'Lead' });
|
||||
expect(cells(rows['links:l1'], TYPED)).toEqual({ data1: 'https://probe.example', data2: '5', data3: null });
|
||||
expect(cells(rows['anniversaries:bday'], TYPED)).toEqual({ data1: '--03-14', data2: '3', data3: null });
|
||||
expect(cells(rows['anniversaries:wed'], TYPED)).toEqual({ data1: '2010-06-01', data2: '1', data3: null });
|
||||
expect(cells(rows['relatedTo:urn:uuid:other-person'], TYPED)).toEqual({ data1: 'Other Person', data2: '6', data3: null });
|
||||
expect(cells(rows['notes:n1'], ['data1'])).toEqual({ data1: 'first note' });
|
||||
expect(rows['media:ph'].data_sync2).toBe(photoHash(JPEG));
|
||||
// A 1×1 photo is kept as the thumbnail alone (no PHOTO_FILE_ID), and read from it.
|
||||
expect(rows['media:ph'].data14).toBeNull();
|
||||
expect((await h.port.readPhoto(rawId, 512))?.jpegBase64).toBe(JPEG);
|
||||
|
||||
const raw = h.rawContact(rawId);
|
||||
expect(raw).toMatchObject({
|
||||
[RawContacts.SOURCE_ID]: `${JMAP}/${id}`,
|
||||
[RawContacts.SYNC1]: `${JMAP}/${h.book}`,
|
||||
[RawContacts.SYNC3]: null,
|
||||
[RawContacts.DIRTY]: 0,
|
||||
});
|
||||
expect(Number(raw[RawContacts.RAW_CONTACT_IS_READ_ONLY])).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps the photo out of the shadow: its data URI becomes the hash of its bytes', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(probeCard());
|
||||
const shadow = JSON.parse(h.rawContact(rawId)[RawContacts.SYNC2] as string);
|
||||
expect(shadow.media.ph.uri).toBe(photoHash(JPEG));
|
||||
expect({ ...shadow, media: undefined, '~memberOf': undefined }).toEqual({ ...h.card(id), media: undefined });
|
||||
});
|
||||
|
||||
it('records each row as stored, so a clean contact needs no baseline heal', async () => {
|
||||
const h = new Harness();
|
||||
for (const card of [probeCard(), appleCard(), googleCard(), { name: { full: 'Madonna' } }]) {
|
||||
const { rawId } = await h.seed(card);
|
||||
const local = await h.contact(rawId);
|
||||
expect(h.planner.planBaselineHeal(local)).toBeNull();
|
||||
for (const row of local.rows) if (row.mimetype !== MimeType.PHOTO) expect(row.baseline).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('maps an Apple card with positional keys, filling the formatted address itself', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
const rows = h.rowsByKey(rawId);
|
||||
expect(cells(rows['emails:k1'], PRIMARY)).toEqual({ data1: 'anna@example.com', data2: '1', data3: null, is_primary: '1' });
|
||||
expect(cells(rows['emails:k2'], PRIMARY)).toEqual({ data1: 'anna@work.example', data2: '2', data3: null, is_primary: '0' });
|
||||
expect(cells(rows['phones:k1'], TYPED)).toMatchObject({ data2: '2' });
|
||||
expect(cells(rows['phones:k2'], TYPED)).toMatchObject({ data2: '3' });
|
||||
expect(cells(rows['phones:k3'], TYPED)).toMatchObject({ data2: '4' });
|
||||
expect(cells(rows['addresses:k1'], POSTAL)).toEqual({
|
||||
data1: '1 Infinite Loop, 95014 Cupertino, CA, USA', data2: '2', data3: null, data4: '1 Infinite Loop', data5: null,
|
||||
data6: null, data7: 'Cupertino', data8: 'CA', data9: '95014', data10: 'USA',
|
||||
});
|
||||
expect(cells(rows['anniversaries:k1'], TYPED)).toEqual({ data1: '1980-05-04', data2: '3', data3: null });
|
||||
expect(Object.keys(rows).some((k) => k.startsWith('onlineServices'))).toBe(false);
|
||||
});
|
||||
|
||||
it('maps a Google card: custom display name, legacy cell phone, profile link, department list, relation by name', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(googleCard());
|
||||
const rows = h.rowsByKey(rawId);
|
||||
expect(cells(rows.name, NAME)).toEqual({
|
||||
data1: 'Groß, Günther', data2: 'Günther', data3: 'Groß', data4: 'Prof.', data5: 'Karl', data6: 'PhD',
|
||||
data7: null, data8: null, data9: null,
|
||||
});
|
||||
expect(cells(rows['nicknames:k1'], ['data1'])).toEqual({ data1: 'Gü' });
|
||||
expect(cells(rows['nicknames:k2'], ['data1'])).toEqual({ data1: 'GKG' });
|
||||
expect(cells(rows['phones:k1'], TYPED)).toEqual({ data1: '0171 2345678', data2: '2', data3: null });
|
||||
// The label names a type Android has.
|
||||
expect(cells(rows['links:k1'], TYPED)).toEqual({ data1: 'https://example.org/~guenther', data2: '3', data3: null });
|
||||
expect(cells(rows['organizations:k1|titles:k1,'], ORG)).toEqual({ data1: 'Uni Beispiel', data4: 'Professor', data5: 'Informatik, AG Sync', data6: null });
|
||||
expect(cells(rows['relatedTo:Maria Groß'], TYPED)).toEqual({ data1: 'Maria Groß', data2: '14', data3: null });
|
||||
expect(cells(rows['anniversaries:k1'], TYPED)).toEqual({ data1: '--08-15', data2: '1', data3: null });
|
||||
});
|
||||
|
||||
it('writes a name with only `full` as the display name and lets the provider split it', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed({ name: { full: 'Madonna' } });
|
||||
expect(cells(h.rowsByKey(rawId).name, ['data1', 'data2', 'data3'])).toEqual({ data1: 'Madonna', data2: 'Madonna', data3: null });
|
||||
});
|
||||
|
||||
it('writes text longer than the provider keeps already cut, and never uploads the cut column', async () => {
|
||||
const h = new Harness();
|
||||
const long = 'x'.repeat(12_000);
|
||||
const { id, rawId } = await h.seed({ name: { full: 'Long Note' }, notes: { n1: { note: long } }, phones: { p1: { number: '1'.repeat(1200) } } });
|
||||
const rows = h.rowsByKey(rawId);
|
||||
expect((rows['notes:n1'].data1 as string).length).toBe(10 * 1024);
|
||||
expect((rows['phones:p1'].data1 as string).length).toBe(1000);
|
||||
expect(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx).effect).toBe('none');
|
||||
h.device.user.updateData(rows['notes:n1']._id as number, { data1: `${rows['notes:n1'].data1 as string}!` });
|
||||
expect(h.planner.planUpload(await h.contact(rawId), h.ctx).kind).toBe('clean');
|
||||
});
|
||||
|
||||
it('marks a card of read-only books read-only', async () => {
|
||||
const h = new Harness();
|
||||
h.readOnlyBooks.add(h.book);
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
expect(Number(h.rawContact(rawId)[RawContacts.RAW_CONTACT_IS_READ_ONLY])).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('contacts download: echoes and updates', () => {
|
||||
it.each([['probe', probeCard], ['apple', appleCard], ['google', googleCard]])('writes nothing for an unchanged %s card', async (_n, make) => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(make());
|
||||
const plan = h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx);
|
||||
expect(plan).toMatchObject({ effect: 'none', writes: 0, conflicts: 0 });
|
||||
expect(plan.ops.ops).toEqual([]);
|
||||
});
|
||||
|
||||
it('writes nothing when the shadow itself comes back (a re-plan after a group change)', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(probeCard());
|
||||
const local = await h.contact(rawId);
|
||||
const plan = h.planner.planDownload(local.shadow!, local, h.ctx);
|
||||
expect(plan.effect).toBe('none');
|
||||
});
|
||||
|
||||
it('rewrites only the row of the entry the server changed, behind VERSION and DIRTY', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(appleCard());
|
||||
h.server.serverUpdate('ContactCard', JMAP, id, { 'emails/k2/address': 'anna@new-work.example' });
|
||||
const local = await h.contact(rawId);
|
||||
const plan = h.planner.planDownload(h.card(id), local, h.ctx);
|
||||
const [assert, raw, ...data] = plan.ops.ops;
|
||||
expect(assert).toEqual({ op: 'assert', table: 'raw_contacts', id: rawId, values: { version: local.version, dirty: 0 }, expectCount: 1 });
|
||||
expect(raw).toMatchObject({ op: 'update', table: 'raw_contacts', values: { [RawContacts.SYNC2]: expect.any(String) } });
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0]).toMatchObject({ op: 'update', table: 'data', expectCount: 1, values: { data1: 'anna@new-work.example', data_sync1: 'emails:k2' } });
|
||||
await h.applyOk(plan.ops);
|
||||
expect(h.rowsByKey(rawId)['emails:k2'].data1).toBe('anna@new-work.example');
|
||||
expect(h.dirty(rawId)).toBe(false);
|
||||
});
|
||||
|
||||
it('inserts entries the server added and deletes the ones it removed', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(appleCard());
|
||||
h.server.serverUpdate('ContactCard', JMAP, id, { 'phones/k3': null, 'links': { k1: { uri: 'https://anna.example' } } });
|
||||
await h.download(id);
|
||||
const rows = h.rowsByKey(rawId);
|
||||
expect(rows['phones:k3']).toBeUndefined();
|
||||
expect(cells(rows['links:k1'], TYPED)).toEqual({ data1: 'https://anna.example', data2: '7', data3: null });
|
||||
expect(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx).effect).toBe('none');
|
||||
});
|
||||
|
||||
it('follows keys Stalwart shifted (a CardDAV insert at the front) by content, with no rewrite of the rows', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(appleCard());
|
||||
const before = h.rowsByKey(rawId);
|
||||
h.server.serverUpdate('ContactCard', JMAP, id, {
|
||||
emails: {
|
||||
k1: { address: 'first@example.com' },
|
||||
k2: { address: 'anna@example.com', contexts: { private: true }, pref: 1 },
|
||||
k3: { address: 'anna@work.example', contexts: { work: true } },
|
||||
},
|
||||
});
|
||||
const plan = h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx);
|
||||
const data = plan.ops.ops.filter((o) => o.op !== 'assert' && o.op !== 'syncState' && o.table === 'data');
|
||||
expect(data).toHaveLength(3);
|
||||
const renames = data.filter((o) => o.op === 'update');
|
||||
for (const r of renames) expect(Object.keys((r as { values: object }).values).sort()).toEqual(['data_sync1', 'data_sync3']);
|
||||
await h.applyOk(plan.ops);
|
||||
const rows = h.rowsByKey(rawId);
|
||||
expect(rows['emails:k2']._id).toBe(before['emails:k1']._id);
|
||||
expect(rows['emails:k3']._id).toBe(before['emails:k2']._id);
|
||||
expect(rows['emails:k1'].data1).toBe('first@example.com');
|
||||
});
|
||||
|
||||
it('fails as a whole when the user edits the contact between read and write', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(appleCard());
|
||||
h.server.serverUpdate('ContactCard', JMAP, id, { 'notes/k1/note': 'from the server' });
|
||||
const plan = h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx);
|
||||
const phone = h.rowsByKey(rawId)['phones:k1']._id as number;
|
||||
h.device.beforeNextBatch(() => h.device.user.updateData(phone, { [Data.DATA1]: '+1 555 0199' }));
|
||||
const res = await h.apply(plan.ops);
|
||||
expect(res).toMatchObject({ ok: false, reason: 'assert' });
|
||||
expect(h.rowsByKey(rawId)['notes:k1'].data1).toBe('Met at WWDC');
|
||||
});
|
||||
|
||||
it('adopts its own create whose identity was never written, keeping the local rows', async () => {
|
||||
const h = new Harness();
|
||||
const rawId = h.device.user.insertContact('usera@example.org', [
|
||||
{ [Data.MIMETYPE]: MimeType.STRUCTURED_NAME, [Data.DATA1]: 'New Person', [Data.DATA2]: 'New', [Data.DATA3]: 'Person' },
|
||||
{ [Data.MIMETYPE]: MimeType.EMAIL, [Data.DATA1]: 'new@example.org', [Data.DATA2]: 1 },
|
||||
]);
|
||||
const plan = await h.planUpload(rawId);
|
||||
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'create') throw new Error(`expected a create, got ${plan.kind}`);
|
||||
const cardId = h.send(plan.actions)!;
|
||||
// The response was lost: the next download finds the card and the pending uid.
|
||||
const local = await h.contact(rawId);
|
||||
const adopt = h.planner.planDownload(h.card(cardId), local, h.ctx);
|
||||
expect(adopt.effect).toBe('update');
|
||||
expect(adopt.stillDirty).toBe(true);
|
||||
await h.applyOk(adopt.ops);
|
||||
const after = await h.contact(rawId);
|
||||
expect(after).toMatchObject({ sourceId: `${JMAP}/${cardId}`, pending: null, dirty: true });
|
||||
expect(h.planner.planUpload(after, h.ctx).kind).toBe('clean');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Server cards for the contacts planner tests. `probeCard` is what the local
|
||||
* Stalwart returned for a card created over JMAP with client keys (live probe
|
||||
* output); the others are shaped like cards synced in over CardDAV from
|
||||
* Apple and Google clients, whose keys Stalwart generates by position.
|
||||
*/
|
||||
|
||||
/** A 1×1 JPEG. */
|
||||
export const JPEG = '/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////wgALCAABAAEBAREA/8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABPxA=';
|
||||
/** Another picture's bytes (any base64 does for the fakes). */
|
||||
export const JPEG2 = '/9j/4AAQSkZJRgABAgAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==';
|
||||
|
||||
export function probeCard(): Record<string, unknown> {
|
||||
return {
|
||||
uid: 'urn:uuid:probe-1790463389675',
|
||||
kind: 'individual',
|
||||
name: {
|
||||
full: 'Dr. Probe Person',
|
||||
phoneticSystem: 'ipa',
|
||||
components: [
|
||||
{ kind: 'title', value: 'Dr.' },
|
||||
{ kind: 'given', value: 'Probe', phonetic: 'Proob' },
|
||||
{ kind: 'surname', value: 'Person' },
|
||||
],
|
||||
isOrdered: true,
|
||||
},
|
||||
nicknames: { nk: { name: 'Probby' } },
|
||||
emails: {
|
||||
work1: { address: 'probe@work.example', contexts: { work: true }, pref: 1, label: 'Office' },
|
||||
'x/y': { address: 'slash@example.org' },
|
||||
zz9: { address: 'private@example.org', contexts: { private: true } },
|
||||
},
|
||||
phones: {
|
||||
'p-a': { number: '+49 30 1234', contexts: { private: true }, features: { voice: true, mobile: true } },
|
||||
fax7: { number: '+49 30 9999', contexts: { work: true }, features: { fax: true } },
|
||||
},
|
||||
addresses: {
|
||||
home: {
|
||||
contexts: { private: true },
|
||||
coordinates: 'geo:52.5,13.4',
|
||||
timeZone: 'Europe/Berlin',
|
||||
countryCode: 'DE',
|
||||
full: 'Main St 12, 10115 Berlin, Germany',
|
||||
components: [
|
||||
{ kind: 'name', value: 'Main St' },
|
||||
{ kind: 'separator', value: ' ' },
|
||||
{ kind: 'number', value: '12' },
|
||||
{ kind: 'locality', value: 'Berlin' },
|
||||
{ kind: 'postcode', value: '10115' },
|
||||
{ kind: 'country', value: 'Germany' },
|
||||
{ kind: 'apartment', value: '4b' },
|
||||
],
|
||||
isOrdered: true,
|
||||
},
|
||||
},
|
||||
organizations: { o1: { name: 'ACME', units: [{ name: 'R&D' }] } },
|
||||
titles: {
|
||||
t1: { name: 'Chief Prober', kind: 'title', organizationId: 'o1' },
|
||||
r1: { name: 'Lead', kind: 'role', organizationId: 'o1' },
|
||||
},
|
||||
links: { l1: { uri: 'https://probe.example', contexts: { work: true } } },
|
||||
onlineServices: { im1: { uri: 'xmpp:probe@example.org', service: 'XMPP' } },
|
||||
anniversaries: {
|
||||
bday: { date: { '@type': 'PartialDate', month: 3, day: 14 }, kind: 'birth' },
|
||||
wed: { date: { '@type': 'PartialDate', year: 2010, month: 6, day: 1 }, kind: 'wedding' },
|
||||
},
|
||||
relatedTo: { 'urn:uuid:other-person': { relation: { friend: true } } },
|
||||
notes: { n1: { note: 'first note' }, n2: { note: 'second note' } },
|
||||
media: { ph: { uri: `data:image/jpeg;base64,${JPEG}`, kind: 'photo', mediaType: 'image/jpeg' } },
|
||||
keywords: { vip: true },
|
||||
personalInfo: { pi1: { value: 'probing', kind: 'hobby' } },
|
||||
preferredLanguages: { lang1: { language: 'de', pref: 1 } },
|
||||
speakToAs: { grammaticalGender: 'neuter' },
|
||||
};
|
||||
}
|
||||
|
||||
/** Apple Contacts over CardDAV: no PROP-IDs, so `k1…kN`; an address without `full`. */
|
||||
export function appleCard(): Record<string, unknown> {
|
||||
return {
|
||||
uid: 'urn:uuid:5F2C9B1E-APPLE',
|
||||
kind: 'individual',
|
||||
prodId: '-//Apple Inc.//iPhone OS 18.0//EN',
|
||||
name: {
|
||||
components: [{ kind: 'given', value: 'Anna' }, { kind: 'surname', value: 'Apfel' }],
|
||||
isOrdered: true,
|
||||
full: 'Anna Apfel',
|
||||
},
|
||||
emails: {
|
||||
k1: { address: 'anna@example.com', contexts: { private: true }, pref: 1 },
|
||||
k2: { address: 'anna@work.example', contexts: { work: true } },
|
||||
},
|
||||
phones: {
|
||||
k1: { number: '+1 555 0100', features: { mobile: true, voice: true } },
|
||||
k2: { number: '+1 555 0101', contexts: { work: true }, features: { voice: true } },
|
||||
k3: { number: '+1 555 0102', contexts: { work: true }, features: { fax: true } },
|
||||
},
|
||||
addresses: {
|
||||
k1: {
|
||||
components: [
|
||||
{ kind: 'name', value: '1 Infinite Loop' },
|
||||
{ kind: 'locality', value: 'Cupertino' },
|
||||
{ kind: 'region', value: 'CA' },
|
||||
{ kind: 'postcode', value: '95014' },
|
||||
{ kind: 'country', value: 'USA' },
|
||||
],
|
||||
isOrdered: true,
|
||||
contexts: { work: true },
|
||||
},
|
||||
},
|
||||
anniversaries: { k1: { kind: 'birth', date: { '@type': 'PartialDate', year: 1980, month: 5, day: 4 } } },
|
||||
notes: { k1: { note: 'Met at WWDC' } },
|
||||
onlineServices: { k1: { service: 'Skype', user: 'anna.apfel' } },
|
||||
keywords: { friends: true },
|
||||
};
|
||||
}
|
||||
|
||||
/** Google Contacts over CardDAV: a legacy `cell` feature, a custom display name, two nicknames. */
|
||||
export function googleCard(): Record<string, unknown> {
|
||||
return {
|
||||
uid: 'google-8a7d2f',
|
||||
kind: 'individual',
|
||||
name: {
|
||||
components: [
|
||||
{ kind: 'title', value: 'Prof.' },
|
||||
{ kind: 'given', value: 'Günther' },
|
||||
{ kind: 'given2', value: 'Karl' },
|
||||
{ kind: 'surname', value: 'Groß' },
|
||||
{ kind: 'credential', value: 'PhD' },
|
||||
],
|
||||
isOrdered: true,
|
||||
full: 'Groß, Günther',
|
||||
},
|
||||
nicknames: { k1: { name: 'Gü' }, k2: { name: 'GKG' } },
|
||||
phones: { k1: { number: '0171 2345678', features: { cell: true } } },
|
||||
links: { k1: { uri: 'https://example.org/~guenther', label: 'profile' } },
|
||||
organizations: { k1: { name: 'Uni Beispiel', units: [{ name: 'Informatik' }, { name: 'AG Sync' }] } },
|
||||
titles: { k1: { name: 'Professor', kind: 'title', organizationId: 'k1' } },
|
||||
relatedTo: { 'Maria Groß': { relation: { spouse: true } } },
|
||||
anniversaries: { k1: { kind: 'wedding', date: { '@type': 'PartialDate', month: 8, day: 15 } } },
|
||||
speakToAs: { grammaticalGender: 'masculine' },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Data, Groups, MimeType } from '../../android-columns';
|
||||
import type { ContactCardWire, OpGroup, UploadPlan } from '../../planner';
|
||||
import { appleCard, googleCard } from './fixtures';
|
||||
import { ACCOUNT, Harness, JMAP } from './harness';
|
||||
|
||||
function actionsOf(plan: UploadPlan<ContactCardWire>) {
|
||||
if (plan.kind !== 'upload') throw new Error(`expected an upload, got ${plan.kind}`);
|
||||
return plan.actions;
|
||||
}
|
||||
|
||||
function opsOf(plan: UploadPlan<ContactCardWire>): OpGroup {
|
||||
if (plan.kind === 'upload' || plan.kind === 'skip') throw new Error(`expected ops, got ${plan.kind}`);
|
||||
return plan.ops;
|
||||
}
|
||||
|
||||
async function localGroup(h: Harness, groupId: string) {
|
||||
const g = (await h.groups()).find((x) => x.sourceId === `${JMAP}/${groupId}`);
|
||||
if (!g) throw new Error(`no group row for ${groupId}`);
|
||||
return g;
|
||||
}
|
||||
|
||||
/** A group card in an address book this user may only read, on the device. */
|
||||
async function readOnlyGroup(h: Harness, members: Record<string, boolean> = {}) {
|
||||
const shared = h.server.addAddressBook(JMAP, { name: 'Shared' });
|
||||
h.readOnlyBooks.add(shared);
|
||||
const gid = h.addCard({ uid: 'urn:uuid:group-shared', kind: 'group', name: { full: 'Shared' }, members, addressBookIds: { [shared]: true } });
|
||||
await h.download(gid);
|
||||
return gid;
|
||||
}
|
||||
|
||||
/** A group card holding Anna (apple card), both on the device. */
|
||||
async function withGroup(members: Record<string, boolean> = { [appleCard().uid as string]: true }) {
|
||||
const h = new Harness();
|
||||
const gid = h.addCard({ uid: 'urn:uuid:group-friends', kind: 'group', name: { full: 'Friends' }, members });
|
||||
await h.download(gid);
|
||||
const { id, rawId } = await h.seed(appleCard());
|
||||
return { h, gid, id, rawId };
|
||||
}
|
||||
|
||||
describe('contacts groups: download', () => {
|
||||
it('writes a group card as a visible group row, and nothing for its echo', async () => {
|
||||
const { h, gid } = await withGroup();
|
||||
const row = h.device.rows('groups', 'sourceid = ?', [`${JMAP}/${gid}`])[0];
|
||||
expect(row).toMatchObject({ [Groups.TITLE]: 'Friends', [Groups.GROUP_VISIBLE]: 1, [Groups.DIRTY]: 0 });
|
||||
const plan = h.planner.planGroupDownload(h.card(gid), await localGroup(h, gid), h.ctx);
|
||||
expect(plan).toMatchObject({ effect: 'none', ops: { ops: [] } });
|
||||
});
|
||||
|
||||
it('writes one membership row per group card that lists the contact', async () => {
|
||||
const { h, gid, rawId } = await withGroup();
|
||||
expect(h.membershipGroups(rawId)).toEqual([`${JMAP}/${gid}`]);
|
||||
const local = await h.contact(rawId);
|
||||
expect(local.rows.find((r) => r.mimetype === MimeType.GROUP_MEMBERSHIP)?.key).toBe(`members:${JMAP}/${gid}`);
|
||||
expect(local.shadow?.['~memberOf']).toEqual([`${JMAP}/${gid}`]);
|
||||
});
|
||||
|
||||
it('leaves memberships alone when the engine has no group index', async () => {
|
||||
const h = new Harness();
|
||||
h.groupIndex = false;
|
||||
const gid = h.addCard({ uid: 'urn:uuid:g', kind: 'group', name: { full: 'G' }, members: { [appleCard().uid as string]: true } });
|
||||
await h.download(gid);
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
expect(h.membershipGroups(rawId)).toEqual([]);
|
||||
expect((await h.contact(rawId)).shadow?.['~memberOf']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('follows the group card when members change on the server (a re-plan of the unchanged contact)', async () => {
|
||||
const { h, gid, rawId } = await withGroup();
|
||||
h.server.serverUpdate('ContactCard', JMAP, gid, { [`members/${appleCard().uid as string}`]: null });
|
||||
await h.download(gid);
|
||||
let local = await h.contact(rawId);
|
||||
await h.applyOk(h.planner.planDownload(local.shadow!, local, h.ctx).ops);
|
||||
expect(h.membershipGroups(rawId)).toEqual([]);
|
||||
h.server.serverUpdate('ContactCard', JMAP, gid, { members: { [appleCard().uid as string]: true } });
|
||||
await h.download(gid);
|
||||
local = await h.contact(rawId);
|
||||
await h.applyOk(h.planner.planDownload(local.shadow!, local, h.ctx).ops);
|
||||
expect(h.membershipGroups(rawId)).toEqual([`${JMAP}/${gid}`]);
|
||||
});
|
||||
|
||||
it('lets the server win when both sides renamed the group', async () => {
|
||||
const { h, gid } = await withGroup();
|
||||
const g = await localGroup(h, gid);
|
||||
h.device.user.updateGroup(g.groupId, { [Groups.TITLE]: 'Mates' });
|
||||
h.server.serverUpdate('ContactCard', JMAP, gid, { 'name/full': 'Pals' });
|
||||
const plan = h.planner.planGroupDownload(h.card(gid), await localGroup(h, gid), h.ctx);
|
||||
expect(plan.conflicts).toBe(1);
|
||||
await h.applyOk(plan.ops);
|
||||
expect((await localGroup(h, gid)).title).toBe('Pals');
|
||||
});
|
||||
});
|
||||
|
||||
describe('contacts groups: membership edits', () => {
|
||||
it('adds the contact to a group card with `members/<uid>: true`, keeping it dirty until the server has it', async () => {
|
||||
const h = new Harness();
|
||||
const other = h.addCard({ uid: 'urn:uuid:g2', kind: 'group', name: { full: 'Work' }, members: { 'urn:uuid:someone': true } });
|
||||
await h.download(other);
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
const g = await localGroup(h, other);
|
||||
h.device.user.addToGroup(rawId, g.groupId);
|
||||
expect((await h.upload(rawId)).kind).toBe('clean');
|
||||
expect(h.dirty(rawId)).toBe(true);
|
||||
const actions = h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx);
|
||||
expect(actions).toEqual([{ kind: 'update', id: other, patch: { [`members/${appleCard().uid as string}`]: true } }]);
|
||||
h.send(actions);
|
||||
const accepted = h.planner.planGroupAccepted(g, h.card(other), h.ctx);
|
||||
await h.applyOk(accepted.ops);
|
||||
await h.upload(rawId);
|
||||
expect(h.dirty(rawId)).toBe(false);
|
||||
expect(h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx)).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a rename the server does not have when a membership patch of the group is accepted', async () => {
|
||||
const h = new Harness();
|
||||
const gid = h.addCard({ uid: 'urn:uuid:g2', kind: 'group', name: { full: 'Work' }, members: { 'urn:uuid:someone': true } });
|
||||
await h.download(gid);
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
const g = await localGroup(h, gid);
|
||||
h.device.user.addToGroup(rawId, g.groupId);
|
||||
await h.upload(rawId);
|
||||
// Renamed after the group's own upload was planned, before the membership phase read it.
|
||||
h.device.user.updateGroup(g.groupId, { [Groups.TITLE]: 'Colleagues' });
|
||||
const read = await localGroup(h, gid);
|
||||
h.send(h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx));
|
||||
await h.applyOk(h.planner.planGroupAccepted(read, h.card(gid), h.ctx).ops);
|
||||
expect(await localGroup(h, gid)).toMatchObject({ title: 'Colleagues', dirty: true, shadow: { members: { [appleCard().uid as string]: true } } });
|
||||
expect(h.planner.planGroupUpload(await localGroup(h, gid), h.ctx)).toEqual({
|
||||
kind: 'upload', actions: [{ kind: 'update', id: gid, patch: { 'name/full': 'Colleagues' } }],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the groups of a contact created on the device and adds it to their cards', async () => {
|
||||
const h = new Harness();
|
||||
const gid = h.addCard({ uid: 'urn:uuid:g7', kind: 'group', name: { full: 'Team' }, members: { 'urn:uuid:someone': true } });
|
||||
await h.download(gid);
|
||||
const g = await localGroup(h, gid);
|
||||
const rawId = h.device.user.insertContact(ACCOUNT, [
|
||||
{ [Data.MIMETYPE]: MimeType.STRUCTURED_NAME, data1: 'New Person', data2: 'New', data3: 'Person' },
|
||||
{ [Data.MIMETYPE]: MimeType.GROUP_MEMBERSHIP, [Data.DATA1]: g.groupId },
|
||||
]);
|
||||
await h.upload(rawId);
|
||||
expect(h.membershipGroups(rawId)).toEqual([`${JMAP}/${gid}`]);
|
||||
expect(h.dirty(rawId)).toBe(true);
|
||||
const uid = (await h.contact(rawId)).shadow!.uid as string;
|
||||
const actions = h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx);
|
||||
expect(actions).toEqual([{ kind: 'update', id: gid, patch: { [`members/${uid}`]: true } }]);
|
||||
h.send(actions);
|
||||
await h.applyOk(h.planner.planGroupAccepted(g, h.card(gid), h.ctx).ops);
|
||||
await h.upload(rawId);
|
||||
expect(h.dirty(rawId)).toBe(false);
|
||||
expect(h.membershipGroups(rawId)).toEqual([`${JMAP}/${gid}`]);
|
||||
});
|
||||
|
||||
it('sends the whole map for the first member of an empty group', async () => {
|
||||
const h = new Harness();
|
||||
const empty = h.addCard({ uid: 'urn:uuid:g3', kind: 'group', name: { full: 'Empty' } });
|
||||
await h.download(empty);
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
h.device.user.addToGroup(rawId, (await localGroup(h, empty)).groupId);
|
||||
const actions = h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx);
|
||||
expect(actions).toEqual([{ kind: 'update', id: empty, patch: { members: { [appleCard().uid as string]: true } } }]);
|
||||
});
|
||||
|
||||
it('removes the contact with `members/<uid>: null` when its membership row was deleted in place', async () => {
|
||||
const { h, gid, rawId } = await withGroup({ [appleCard().uid as string]: true, 'urn:uuid:someone': true });
|
||||
const membership = (await h.contact(rawId)).rows.find((r) => r.mimetype === MimeType.GROUP_MEMBERSHIP)!;
|
||||
h.device.user.deleteData(membership.id);
|
||||
const actions = h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx);
|
||||
expect(actions).toEqual([{ kind: 'update', id: gid, patch: { [`members/${appleCard().uid as string}`]: null } }]);
|
||||
});
|
||||
|
||||
it('uploads nothing when an editor re-inserted the same memberships without keys', async () => {
|
||||
const { h, rawId } = await withGroup();
|
||||
const rows = h.device.rows('data', 'raw_contact_id = ?', [rawId]).filter((r) => r[Data.MIMETYPE] === MimeType.GROUP_MEMBERSHIP);
|
||||
h.device.user.fossifySave(rawId, null, rows.map((r) => ({ [Data.MIMETYPE]: MimeType.GROUP_MEMBERSHIP, data1: r.data1 })));
|
||||
expect(h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx)).toEqual([]);
|
||||
});
|
||||
|
||||
it('puts back an addition to a group in read-only books instead of keeping the contact dirty', async () => {
|
||||
const h = new Harness();
|
||||
const gid = await readOnlyGroup(h, { 'urn:uuid:someone': true });
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
h.device.user.addToGroup(rawId, (await localGroup(h, gid)).groupId);
|
||||
expect(await h.upload(rawId)).toMatchObject({ kind: 'revert', reason: 'groupNotWritable' });
|
||||
expect(h.membershipGroups(rawId)).toEqual([]);
|
||||
expect(h.dirty(rawId)).toBe(false);
|
||||
expect(h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx)).toEqual([]);
|
||||
});
|
||||
|
||||
it('puts back a removal from a group in read-only books', async () => {
|
||||
const h = new Harness();
|
||||
const gid = await readOnlyGroup(h, { [appleCard().uid as string]: true });
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
const membership = (await h.contact(rawId)).rows.find((r) => r.mimetype === MimeType.GROUP_MEMBERSHIP)!;
|
||||
h.device.user.deleteData(membership.id);
|
||||
expect(await h.upload(rawId)).toMatchObject({ kind: 'revert', reason: 'groupNotWritable' });
|
||||
expect(h.membershipGroups(rawId)).toEqual([`${JMAP}/${gid}`]);
|
||||
expect(h.dirty(rawId)).toBe(false);
|
||||
});
|
||||
|
||||
it('puts back an addition to a group of another JMAP account', async () => {
|
||||
const h = new Harness();
|
||||
h.server.addAccount('t', { name: 'Team' });
|
||||
const book = h.server.addAddressBook('t', { name: 'Team contacts' });
|
||||
const team = h.server.addCard('t', { uid: 'urn:uuid:team', kind: 'group', name: { full: 'Team' }, addressBookIds: { [book]: true } });
|
||||
const card = h.server.get('ContactCard', 't', team) as unknown as ContactCardWire;
|
||||
await h.applyOk(h.planner.planGroupDownload(card, null, { ...h.ctx, jmapAccountId: 't' }).ops);
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
h.device.user.addToGroup(rawId, (await h.groups()).find((g) => g.sourceId === `t/${team}`)!.groupId);
|
||||
expect(h.membershipGroups(rawId)).toEqual([`t/${team}`]);
|
||||
expect(await h.upload(rawId)).toMatchObject({ kind: 'revert', reason: 'groupNotWritable' });
|
||||
expect(h.membershipGroups(rawId)).toEqual([]);
|
||||
expect(h.dirty(rawId)).toBe(false);
|
||||
});
|
||||
|
||||
it('still uploads a read-only contact joining a writable group (the group card is what changes)', async () => {
|
||||
const h = new Harness();
|
||||
const other = h.server.addAddressBook(JMAP, { name: 'Shared' });
|
||||
h.readOnlyBooks.add(other);
|
||||
const gid = h.addCard({ uid: 'urn:uuid:g5', kind: 'group', name: { full: 'Mine' }, members: { 'urn:uuid:someone': true } });
|
||||
await h.download(gid);
|
||||
const { rawId } = await h.seed({ ...appleCard(), addressBookIds: { [other]: true } });
|
||||
h.device.user.addToGroup(rawId, (await localGroup(h, gid)).groupId);
|
||||
const plan = await h.upload(rawId);
|
||||
expect(plan.kind).toBe('revert');
|
||||
// Nothing of its own was put back: nothing to report.
|
||||
expect(plan).not.toHaveProperty('reason');
|
||||
expect(h.membershipGroups(rawId)).toEqual([`${JMAP}/${gid}`]);
|
||||
expect(h.dirty(rawId)).toBe(true);
|
||||
expect(h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx)).toEqual([
|
||||
{ kind: 'update', id: gid, patch: { [`members/${appleCard().uid as string}`]: true } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('uploads the edits it can next to a membership it puts back, and settles', async () => {
|
||||
const h = new Harness();
|
||||
const gid = await readOnlyGroup(h);
|
||||
const { id, rawId } = await h.seed(appleCard());
|
||||
h.device.user.addToGroup(rawId, (await localGroup(h, gid)).groupId);
|
||||
h.device.user.updateData(h.rowsByKey(rawId)['emails:k1']._id as number, { data1: 'anna@new.example' });
|
||||
const plan = await h.upload(rawId);
|
||||
expect(actionsOf(plan)).toEqual([{ kind: 'update', id, patch: { 'emails/k1/address': 'anna@new.example' } }]);
|
||||
expect(h.membershipGroups(rawId)).toEqual([]);
|
||||
expect(h.dirty(rawId)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not take a server-side addition for a local removal while the contact is dirty', async () => {
|
||||
const { h, gid } = await withGroup({});
|
||||
const { rawId } = await h.seed(googleCard());
|
||||
h.device.user.updateData(h.rowsByKey(rawId)['phones:k1']._id as number, { data1: '0171 999' });
|
||||
h.server.serverUpdate('ContactCard', JMAP, gid, { members: { [googleCard().uid as string]: true } });
|
||||
await h.download(gid);
|
||||
const local = await h.contact(rawId);
|
||||
await h.applyOk(h.planner.planDownload(local.shadow!, local, h.ctx).ops);
|
||||
expect(h.membershipGroups(rawId)).toEqual([`${JMAP}/${gid}`]);
|
||||
expect(h.planner.planMembershipUploads([await h.contact(rawId)], await h.groups(), h.ctx)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('contacts groups: device edits of groups', () => {
|
||||
it('uploads a rename as `name/full`', async () => {
|
||||
const { h, gid } = await withGroup();
|
||||
const g = await localGroup(h, gid);
|
||||
h.device.user.updateGroup(g.groupId, { [Groups.TITLE]: 'Close friends' });
|
||||
const plan = h.planner.planGroupUpload(await localGroup(h, gid), h.ctx);
|
||||
expect(plan).toEqual({ kind: 'upload', actions: [{ kind: 'update', id: gid, patch: { 'name/full': 'Close friends' } }] });
|
||||
h.send(actionsOf(plan));
|
||||
const accepted = h.planner.planGroupAccepted(await localGroup(h, gid), h.card(gid), h.ctx);
|
||||
await h.applyOk(accepted.ops);
|
||||
expect(await localGroup(h, gid)).toMatchObject({ dirty: false, title: 'Close friends' });
|
||||
expect(h.planner.planGroupDownload(h.card(gid), await localGroup(h, gid), h.ctx).effect).toBe('none');
|
||||
});
|
||||
|
||||
it('claims and creates a group made on the device', async () => {
|
||||
const h = new Harness();
|
||||
const groupId = h.device.user.insertGroup('usera@example.org', 'Book club');
|
||||
const decode = async () => (await h.groups()).find((g) => g.groupId === groupId)!;
|
||||
const claim = h.planner.planGroupUpload(await decode(), h.ctx);
|
||||
expect(claim.kind).toBe('claim');
|
||||
await h.applyOk(opsOf(claim));
|
||||
const pending = (await decode()).pending!;
|
||||
const plan = h.planner.planGroupUpload(await decode(), h.ctx);
|
||||
expect(plan).toEqual({
|
||||
kind: 'upload',
|
||||
actions: [{
|
||||
kind: 'create', uid: pending.uid, collectionId: h.book,
|
||||
object: { '@type': 'Card', version: '1.0', uid: pending.uid, kind: 'group', addressBookIds: { [h.book]: true }, name: { full: 'Book club' } },
|
||||
}],
|
||||
});
|
||||
const id = h.send(actionsOf(plan))!;
|
||||
await h.applyOk(h.planner.planGroupAccepted(await decode(), h.card(id), h.ctx).ops);
|
||||
expect(await decode()).toMatchObject({ sourceId: `${JMAP}/${id}`, dirty: false, pending: null });
|
||||
});
|
||||
|
||||
it('destroys a group deleted on the device', async () => {
|
||||
const { h, gid } = await withGroup();
|
||||
h.device.user.deleteGroup((await localGroup(h, gid)).groupId);
|
||||
expect(h.planner.planGroupUpload(await localGroup(h, gid), h.ctx)).toEqual({
|
||||
kind: 'upload', actions: [{ kind: 'destroy', id: gid, uid: 'urn:uuid:group-friends' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('only leaves the synced book when the server filed the group in an unsynced one after the device deleted it', async () => {
|
||||
const { h, gid } = await withGroup();
|
||||
const archive = h.server.addAddressBook(JMAP, { name: 'Archive' });
|
||||
h.unselected.add(`${JMAP}/${archive}`);
|
||||
h.device.user.deleteGroup((await localGroup(h, gid)).groupId);
|
||||
h.server.serverUpdate('ContactCard', JMAP, gid, { [`addressBookIds/${archive}`]: true, 'name/full': 'Old friends' });
|
||||
const plan = h.planner.planGroupDownload(h.card(gid), await localGroup(h, gid), h.ctx);
|
||||
await h.applyOk(plan.ops);
|
||||
// The delete wins over the rename; only the shadow follows the card.
|
||||
expect(await localGroup(h, gid)).toMatchObject({ deleted: true, title: 'Friends' });
|
||||
expect(h.planner.planGroupUpload(await localGroup(h, gid), h.ctx)).toEqual({
|
||||
kind: 'upload', actions: [{ kind: 'update', id: gid, patch: { [`addressBookIds/${h.book}`]: null } }],
|
||||
});
|
||||
});
|
||||
|
||||
it('reverts a rename of a read-only group', async () => {
|
||||
const { h, gid } = await withGroup();
|
||||
h.readOnlyBooks.add(h.book);
|
||||
const g = await localGroup(h, gid);
|
||||
h.device.user.updateGroup(g.groupId, { [Groups.TITLE]: 'Mine now' });
|
||||
const plan = h.planner.planGroupUpload(await localGroup(h, gid), h.ctx);
|
||||
expect(plan).toMatchObject({ kind: 'revert', reason: 'readOnly' });
|
||||
await h.applyOk(opsOf(plan));
|
||||
expect(await localGroup(h, gid)).toMatchObject({ title: 'Friends', dirty: false });
|
||||
});
|
||||
|
||||
it('deletes the group row for a server delete', async () => {
|
||||
const { h, gid, rawId } = await withGroup();
|
||||
await h.applyOk(h.planner.planGroupLocalDelete(await localGroup(h, gid)));
|
||||
expect(h.device.rows('groups', 'sourceid = ?', [`${JMAP}/${gid}`])).toEqual([]);
|
||||
expect(h.membershipGroups(rawId)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Test rig for the contacts planner: the fake device providers, the fake
|
||||
* Stalwart, and a ContactsContext wired between them. Plans are applied the
|
||||
* way the engine applies them (one op group per batch); uploads go through
|
||||
* the fake server's patch handling and come back through its normalisation.
|
||||
*/
|
||||
import { GroupMembership, Groups, RawContacts } from '../../android-columns';
|
||||
import { makeKeyMinter, parseObjectRef, uuidFrom } from '../../common/ids';
|
||||
import { contactsPlanner } from '../../contacts/planner';
|
||||
import type {
|
||||
ContactCardWire,
|
||||
ContactsContext,
|
||||
LocalContact,
|
||||
LocalGroup,
|
||||
OpGroup,
|
||||
UploadAction,
|
||||
UploadPlan,
|
||||
} from '../../planner';
|
||||
import { CONTACTS_AUTHORITY, type BatchResult, type ProviderPort, type Row } from '../../types';
|
||||
import { FakeJmapServer } from '../fakes/fake-jmap-server';
|
||||
import { FakeDeviceProviders } from '../fakes/fake-provider';
|
||||
|
||||
export const ACCOUNT = 'usera@example.org';
|
||||
export const JMAP = 'c';
|
||||
|
||||
export function seeded(seed = 7): () => number {
|
||||
return () => {
|
||||
seed = (seed * 1103515245 + 12345) % 2147483648;
|
||||
return seed / 2147483648;
|
||||
};
|
||||
}
|
||||
|
||||
function rowsOf(res: { columns: string[]; rows: unknown[][] }): Row[] {
|
||||
return res.rows.map((cells) => Object.fromEntries(res.columns.map((c, i) => [c, cells[i]])) as Row);
|
||||
}
|
||||
|
||||
export class Harness {
|
||||
readonly device = new FakeDeviceProviders();
|
||||
readonly port: ProviderPort = this.device.port(ACCOUNT, CONTACTS_AUTHORITY);
|
||||
readonly server = new FakeJmapServer();
|
||||
readonly book: string;
|
||||
readonly planner = contactsPlanner;
|
||||
/** Base64 of blob-backed photos by blobId, and the device photo per raw contact. */
|
||||
readonly blobs = new Map<string, string>();
|
||||
readonly devicePhotos = new Map<number, string>();
|
||||
readonly names = new Map<string, string>();
|
||||
readonly readOnlyBooks = new Set<string>();
|
||||
readonly unselected = new Set<string>();
|
||||
/** When false the context has no `groupsOf` (memberships not synced). */
|
||||
groupIndex = true;
|
||||
readonly ctx: ContactsContext;
|
||||
|
||||
constructor() {
|
||||
this.server.addAccount(JMAP, { name: ACCOUNT });
|
||||
this.book = this.server.addAddressBook(JMAP, { name: 'Personal' });
|
||||
const random = seeded();
|
||||
const mintKey = makeKeyMinter(random);
|
||||
const self = this;
|
||||
this.ctx = {
|
||||
jmapAccountId: JMAP,
|
||||
now: 1_800_000_000_000,
|
||||
mintKey,
|
||||
mintUid: () => `urn:uuid:${uuidFrom(random)}`,
|
||||
selectedCollections: (card) =>
|
||||
Object.keys(card.addressBookIds ?? {}).map((id) => `${JMAP}/${id}`).filter((k) => !self.unselected.has(k)),
|
||||
isReadOnly: (card) => Object.keys(card.addressBookIds ?? {}).every((id) => self.readOnlyBooks.has(id)),
|
||||
isSelected: (key) => !self.unselected.has(key),
|
||||
createTarget: () => `${JMAP}/${self.book}`,
|
||||
photoBytes: (entry) => (entry.blobId ? self.blobs.get(entry.blobId) ?? null : null),
|
||||
devicePhoto: (id) => self.devicePhotos.get(id) ?? null,
|
||||
nameForUid: (uid) => self.names.get(uid) ?? null,
|
||||
groupRowIdBySourceId: (sourceId) => {
|
||||
const row = self.device.rows('groups', 'sourceid = ?', [sourceId])[0];
|
||||
return row ? Number(row[Groups._ID]) : null;
|
||||
},
|
||||
groupsOf(uid: string) {
|
||||
return self.server
|
||||
.all('ContactCard', JMAP)
|
||||
.filter((c) => c.kind === 'group' && (c.members as Record<string, boolean> | undefined)?.[uid] === true)
|
||||
.map((c) => `${JMAP}/${c.id as string}`);
|
||||
},
|
||||
groupReadOnly(sourceId: string) {
|
||||
const ref = parseObjectRef(sourceId);
|
||||
const card = ref ? self.server.get('ContactCard', ref.accountId, ref.id) : undefined;
|
||||
const books = Object.keys((card?.addressBookIds as Record<string, boolean> | undefined) ?? {});
|
||||
return books.length > 0 && books.every((id) => self.readOnlyBooks.has(id));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The context as the engine may build it without a group index. */
|
||||
context(): ContactsContext {
|
||||
if (this.groupIndex) return this.ctx;
|
||||
const { groupsOf: _drop, ...rest } = this.ctx;
|
||||
return rest as ContactsContext;
|
||||
}
|
||||
|
||||
async apply(group: OpGroup): Promise<BatchResult> {
|
||||
if (!group.ops.length) return { ok: true, results: [] };
|
||||
return this.port.applyBatch(group.ops);
|
||||
}
|
||||
|
||||
async applyOk(group: OpGroup): Promise<void> {
|
||||
const res = await this.apply(group);
|
||||
if (!res.ok) throw new Error(`batch failed: ${res.reason} ${res.message}`);
|
||||
}
|
||||
|
||||
async contacts(): Promise<LocalContact[]> {
|
||||
const raw = rowsOf(await this.port.query({ table: 'raw_contacts', columns: [...this.planner.rawContactColumns] }));
|
||||
const data = rowsOf(await this.port.query({ table: 'data', columns: [...this.planner.dataColumns] }));
|
||||
return raw.map((r) => this.planner.decodeContact(r, data.filter((d) => d.raw_contact_id === r._id)));
|
||||
}
|
||||
|
||||
async contact(rawContactId: number): Promise<LocalContact> {
|
||||
const found = (await this.contacts()).find((c) => c.rawContactId === rawContactId);
|
||||
if (!found) throw new Error(`no raw contact ${rawContactId}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
async bySource(cardId: string): Promise<LocalContact | null> {
|
||||
return (await this.contacts()).find((c) => c.sourceId === `${JMAP}/${cardId}`) ?? null;
|
||||
}
|
||||
|
||||
async groups(): Promise<LocalGroup[]> {
|
||||
const rows = rowsOf(await this.port.query({ table: 'groups', columns: [...this.planner.groupColumns] }));
|
||||
return rows.map((r) => this.planner.decodeGroup(r));
|
||||
}
|
||||
|
||||
card(id: string): ContactCardWire {
|
||||
const card = this.server.get('ContactCard', JMAP, id);
|
||||
if (!card) throw new Error(`no card ${id}`);
|
||||
return card as ContactCardWire;
|
||||
}
|
||||
|
||||
addCard(card: Record<string, unknown>): string {
|
||||
return this.server.addCard(JMAP, { addressBookIds: { [this.book]: true }, ...card });
|
||||
}
|
||||
|
||||
/** Downloads a server card into its local contact (or a new one); returns the raw contact id. */
|
||||
async download(cardId: string): Promise<number> {
|
||||
const card = this.card(cardId);
|
||||
const local = await this.bySource(cardId);
|
||||
const plan = card.kind === 'group'
|
||||
? this.planner.planGroupDownload(card, (await this.groups()).find((g) => g.sourceId === `${JMAP}/${cardId}`) ?? null, this.context())
|
||||
: this.planner.planDownload(card, local, this.context());
|
||||
await this.applyOk(plan.ops);
|
||||
if (card.kind === 'group') return -1;
|
||||
const after = await this.bySource(cardId);
|
||||
if (!after) throw new Error('download wrote no contact');
|
||||
const heal = this.planner.planBaselineHeal(after);
|
||||
if (heal) await this.applyOk(heal);
|
||||
return after.rawContactId;
|
||||
}
|
||||
|
||||
/** A card on the server and on the device, clean. */
|
||||
async seed(card: Record<string, unknown>): Promise<{ id: string; rawId: number }> {
|
||||
const id = this.addCard(card);
|
||||
return { id, rawId: await this.download(id) };
|
||||
}
|
||||
|
||||
/** Sends upload actions to the fake server the way the engine would; returns the touched card id. */
|
||||
send(actions: UploadAction<ContactCardWire>[]): string | null {
|
||||
let touched: string | null = null;
|
||||
for (const a of actions) {
|
||||
if (a.kind === 'update') {
|
||||
this.server.serverUpdate('ContactCard', JMAP, a.id, a.patch);
|
||||
touched = a.id;
|
||||
} else if (a.kind === 'create') {
|
||||
touched = this.server.addCard(JMAP, a.object as Record<string, unknown>);
|
||||
} else if (a.id) {
|
||||
this.server.serverDestroy('ContactCard', JMAP, a.id);
|
||||
}
|
||||
}
|
||||
return touched;
|
||||
}
|
||||
|
||||
/** Plans an upload for a contact, claiming it first when it is new. */
|
||||
async planUpload(rawContactId: number): Promise<UploadPlan<ContactCardWire>> {
|
||||
let plan = this.planner.planUpload(await this.contact(rawContactId), this.context());
|
||||
if (plan.kind === 'claim') {
|
||||
await this.applyOk(plan.ops);
|
||||
plan = this.planner.planUpload(await this.contact(rawContactId), this.context());
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* One upload round for a contact: plan, send, re-read the card, apply the
|
||||
* accepted plan (or its keep-dirty fallback). Returns the plan.
|
||||
*/
|
||||
async upload(rawContactId: number, between?: () => void): Promise<UploadPlan<ContactCardWire>> {
|
||||
const plan = await this.planUpload(rawContactId);
|
||||
const local = await this.contact(rawContactId);
|
||||
if (plan.kind === 'upload') {
|
||||
const touched = this.send(plan.actions);
|
||||
between?.();
|
||||
if (local.deleted) {
|
||||
const accepted = this.planner.planAccepted(local, {} as ContactCardWire, this.context());
|
||||
await this.applyOk(accepted.ops);
|
||||
return plan;
|
||||
}
|
||||
const server = this.card(touched!);
|
||||
const accepted = this.planner.planAccepted(local, server, this.context());
|
||||
const res = await this.apply(accepted.ops);
|
||||
if (!res.ok) await this.applyOk(accepted.keepDirtyOps);
|
||||
} else if (plan.kind !== 'skip') {
|
||||
between?.();
|
||||
await this.applyOk(plan.ops);
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
cardIdOf(local: LocalContact): string {
|
||||
return parseObjectRef(local.sourceId)!.id;
|
||||
}
|
||||
|
||||
/** Data rows of a raw contact as the provider holds them, keyed by DATA_SYNC1 (or `row:<id>`). */
|
||||
rowsByKey(rawContactId: number): Record<string, Row> {
|
||||
const out: Record<string, Row> = {};
|
||||
for (const r of this.device.rows('data', 'raw_contact_id = ?', [rawContactId])) {
|
||||
out[(r.data_sync1 as string | null) ?? `row:${r._id}`] = r;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
rawContact(rawContactId: number): Row {
|
||||
return this.device.row('raw_contacts', rawContactId)!;
|
||||
}
|
||||
|
||||
dirty(rawContactId: number): boolean {
|
||||
return Number(this.rawContact(rawContactId)[RawContacts.DIRTY]) === 1;
|
||||
}
|
||||
|
||||
membershipGroups(rawContactId: number): string[] {
|
||||
return this.device
|
||||
.rows('data', 'raw_contact_id = ? AND mimetype = ?', [rawContactId, 'vnd.android.cursor.item/group_membership'])
|
||||
.map((r) => r[GroupMembership.GROUP_SOURCE_ID] as string)
|
||||
.sort();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Data, MimeType } from '../../android-columns';
|
||||
import { MAX_PHOTO_BASE64 } from '../../contacts/photo';
|
||||
import type { LocalContact } from '../../planner';
|
||||
import { JPEG, JPEG2, probeCard } from './fixtures';
|
||||
import { Harness, JMAP } from './harness';
|
||||
|
||||
/** The engine's view after every write: a clean contact whose rows the server card describes. */
|
||||
async function expectSettled(h: Harness, id: string, rawId: number): Promise<LocalContact> {
|
||||
const local = await h.contact(rawId);
|
||||
expect(local.dirty).toBe(false);
|
||||
expect(h.planner.planDownload(h.card(id), local, h.ctx).effect).toBe('none');
|
||||
expect(h.planner.planBaselineHeal(local)).toBeNull();
|
||||
return local;
|
||||
}
|
||||
|
||||
describe('contacts lifecycle', () => {
|
||||
it('settles after device edits, an editor rewrite and server edits in turn', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(probeCard());
|
||||
let rows = h.rowsByKey(rawId);
|
||||
|
||||
// Several fields at once in AOSP Contacts.
|
||||
h.device.user.updateData(rows.name._id as number, { data2: 'Probert', data1: 'Dr. Probert Person' });
|
||||
h.device.user.updateData(rows['emails:zz9']._id as number, { data1: 'home@example.org' });
|
||||
h.device.user.updateData(rows['addresses:home']._id as number, { data9: '10117' });
|
||||
h.device.user.insertData(rawId, { [Data.MIMETYPE]: MimeType.PHONE, data1: '+49 30 7777', data2: 3 });
|
||||
await h.upload(rawId);
|
||||
const card = h.card(id);
|
||||
expect(card.name).toMatchObject({ full: 'Dr. Probert Person' });
|
||||
expect(card.emails!.zz9.address).toBe('home@example.org');
|
||||
expect(card.addresses!.home.components).toContainEqual({ kind: 'postcode', value: '10117' });
|
||||
expect(Object.values(card.phones!).map((p) => p.number)).toContain('+49 30 7777');
|
||||
expect(card.onlineServices).toEqual(probeCard().onlineServices);
|
||||
expect(card.notes!.n2).toEqual({ note: 'second note' });
|
||||
await expectSettled(h, id, rawId);
|
||||
|
||||
// Fossify rewrites everything but the name; nothing uploads, the rows come back whole.
|
||||
rows = h.rowsByKey(rawId);
|
||||
h.device.user.fossifySave(rawId, null, Object.values(rows)
|
||||
.filter((r) => r[Data.MIMETYPE] === MimeType.EMAIL || r[Data.MIMETYPE] === MimeType.PHONE)
|
||||
.map((r) => ({ [Data.MIMETYPE]: r[Data.MIMETYPE], data1: r.data1, data2: r.data2 })));
|
||||
expect((await h.upload(rawId)).kind).toBe('clean');
|
||||
await expectSettled(h, id, rawId);
|
||||
expect(h.rowsByKey(rawId)['organizations:o1|titles:t1,r1'].data5).toBe('R&D');
|
||||
|
||||
// The server edits and removes things.
|
||||
h.server.serverUpdate('ContactCard', JMAP, id, { 'nicknames/nk/name': 'Probo', 'links/l1': null, 'addresses/home/components': [{ kind: 'locality', value: 'Hamburg' }] });
|
||||
await h.download(id);
|
||||
await expectSettled(h, id, rawId);
|
||||
rows = h.rowsByKey(rawId);
|
||||
expect(rows['nicknames:nk'].data1).toBe('Probo');
|
||||
expect(rows['links:l1']).toBeUndefined();
|
||||
expect(rows['addresses:home']).toMatchObject({ data7: 'Hamburg', data4: null });
|
||||
});
|
||||
|
||||
it('follows Stalwart re-keying the whole card by position, without rewriting a row', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(probeCard());
|
||||
const before = h.rowsByKey(rawId);
|
||||
h.server.rekeyPositionally(JMAP, id, 'ContactCard');
|
||||
const plan = h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx);
|
||||
for (const op of plan.ops.ops) {
|
||||
if (op.op === 'update' && op.table === 'data') expect(Object.keys(op.values).every((c) => c.startsWith('data_sync'))).toBe(true);
|
||||
expect(op.op === 'insert' || op.op === 'delete').toBe(false);
|
||||
}
|
||||
await h.applyOk(plan.ops);
|
||||
const after = h.rowsByKey(rawId);
|
||||
expect(after['organizations:k1|titles:k1,k2']._id).toBe(before['organizations:o1|titles:t1,r1']._id);
|
||||
expect(after['emails:k2']._id).toBe(before['emails:x/y']._id);
|
||||
await expectSettled(h, id, rawId);
|
||||
|
||||
// The next device edit patches the entry under its new key.
|
||||
h.device.user.updateData(after['emails:k2']._id as number, { data1: 'slash3@example.org' });
|
||||
const upload = h.planner.planUpload(await h.contact(rawId), h.ctx);
|
||||
expect(upload).toMatchObject({ kind: 'upload', actions: [{ patch: { 'emails/k2/address': 'slash3@example.org' } }] });
|
||||
});
|
||||
|
||||
it('writes a blob-backed photo from the bytes the engine fetched, and nothing for its echo', async () => {
|
||||
const h = new Harness();
|
||||
h.blobs.set('B1', JPEG);
|
||||
const { id, rawId } = await h.seed({ name: { full: 'Blob Photo' }, media: { p: { kind: 'photo', blobId: 'B1', mediaType: 'image/jpeg' } } });
|
||||
expect((await h.port.readPhoto(rawId, 512))?.jpegBase64).toBe(JPEG);
|
||||
expect((await h.contact(rawId)).shadow?.media).toEqual({ p: { kind: 'photo', blobId: 'B1', mediaType: 'image/jpeg' } });
|
||||
await expectSettled(h, id, rawId);
|
||||
});
|
||||
|
||||
it('keeps the photo row when the photo bytes could not be fetched, and never reads it as deleted', async () => {
|
||||
const h = new Harness();
|
||||
h.blobs.set('B1', JPEG);
|
||||
const { id, rawId } = await h.seed({ name: { full: 'Blob Photo' }, emails: { e1: { address: 'a@example.org' } }, media: { p: { kind: 'photo', blobId: 'B1' } } });
|
||||
const photoRow = h.rowsByKey(rawId)['media:p']._id;
|
||||
h.blobs.delete('B1');
|
||||
h.server.serverUpdate('ContactCard', JMAP, id, { 'emails/e1/address': 'b@example.org' });
|
||||
await h.download(id);
|
||||
expect(h.rowsByKey(rawId)['media:p']?._id).toBe(photoRow);
|
||||
h.device.user.updateData(h.rowsByKey(rawId)['emails:e1']._id as number, { data1: 'c@example.org' });
|
||||
expect(h.planner.planUpload(await h.contact(rawId), h.ctx)).toMatchObject({ kind: 'upload', actions: [{ patch: { 'emails/e1/address': 'c@example.org' } }] });
|
||||
const plan = h.planner.planUpload(await h.contact(rawId), h.ctx);
|
||||
expect(plan.kind === 'upload' && Object.keys((plan.actions[0] as { patch: object }).patch)).toEqual(['emails/e1/address']);
|
||||
});
|
||||
|
||||
it('keeps a photo too large for a provider batch on the server only, and never reads it as deleted', async () => {
|
||||
const h = new Harness();
|
||||
// About 900 KB of JPEG: the fake provider refuses a batch over 1 MB, as Binder does.
|
||||
const big = 'A'.repeat(1_200_000);
|
||||
const { id, rawId } = await h.seed({
|
||||
name: { full: 'Big Photo' },
|
||||
emails: { e1: { address: 'a@example.org' } },
|
||||
media: { p: { kind: 'photo', uri: `data:image/jpeg;base64,${big}`, mediaType: 'image/jpeg' } },
|
||||
});
|
||||
// The contact is there, without the photo; its echo writes nothing.
|
||||
expect(Object.keys(h.rowsByKey(rawId)).sort()).toEqual(['emails:e1', 'name']);
|
||||
await expectSettled(h, id, rawId);
|
||||
|
||||
h.device.user.updateData(h.rowsByKey(rawId)['emails:e1']._id as number, { data1: 'b@example.org' });
|
||||
expect(h.planner.planUpload(await h.contact(rawId), h.ctx)).toEqual({
|
||||
kind: 'upload', actions: [{ kind: 'update', id, patch: { 'emails/e1/address': 'b@example.org' } }],
|
||||
});
|
||||
await h.upload(rawId);
|
||||
expect(h.card(id).media).toEqual({ p: { kind: 'photo', uri: `data:image/jpeg;base64,${big}`, mediaType: 'image/jpeg' } });
|
||||
await expectSettled(h, id, rawId);
|
||||
|
||||
// A photo set on the device replaces it.
|
||||
h.device.user.setPhoto(rawId, JPEG2);
|
||||
h.devicePhotos.set(rawId, JPEG2);
|
||||
expect(h.planner.planUpload(await h.contact(rawId), h.ctx)).toEqual({
|
||||
kind: 'upload', actions: [{ kind: 'update', id, patch: { 'media/p/uri': `data:image/jpeg;base64,${JPEG2}`, 'media/p/mediaType': 'image/jpeg' } }],
|
||||
});
|
||||
await h.upload(rawId);
|
||||
expect(h.device.photo(Number(h.rowsByKey(rawId)['media:p'].data14))).toBe(JPEG2);
|
||||
await expectSettled(h, id, rawId);
|
||||
});
|
||||
|
||||
it('writes a photo up to the budget of a provider batch, and none beyond it', async () => {
|
||||
const h = new Harness();
|
||||
const seedPhoto = (b64: string) => h.seed({ name: { full: 'Sized' }, media: { p: { kind: 'photo', uri: `data:image/jpeg;base64,${b64}` } } });
|
||||
const fits = 'A'.repeat(MAX_PHOTO_BASE64);
|
||||
const within = await seedPhoto(fits);
|
||||
expect(h.device.photo(Number(h.rowsByKey(within.rawId)['media:p'].data14))).toBe(fits);
|
||||
await expectSettled(h, within.id, within.rawId);
|
||||
const beyond = await seedPhoto('A'.repeat(MAX_PHOTO_BASE64 + 4));
|
||||
expect(h.rowsByKey(beyond.rawId)['media:p']).toBeUndefined();
|
||||
await expectSettled(h, beyond.id, beyond.rawId);
|
||||
});
|
||||
|
||||
it('never reads a photo whose bytes could not be fetched as deleted, and writes it once they can be', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed({
|
||||
name: { full: 'Blob Photo' },
|
||||
emails: { e1: { address: 'a@example.org' } },
|
||||
media: { p: { kind: 'photo', blobId: 'B1', mediaType: 'image/jpeg' } },
|
||||
});
|
||||
expect(h.rowsByKey(rawId)['media:p']).toBeUndefined();
|
||||
h.device.user.updateData(h.rowsByKey(rawId)['emails:e1']._id as number, { data1: 'b@example.org' });
|
||||
expect(h.planner.planUpload(await h.contact(rawId), h.ctx)).toEqual({
|
||||
kind: 'upload', actions: [{ kind: 'update', id, patch: { 'emails/e1/address': 'b@example.org' } }],
|
||||
});
|
||||
await h.upload(rawId);
|
||||
expect(h.card(id).media).toEqual({ p: { kind: 'photo', blobId: 'B1', mediaType: 'image/jpeg' } });
|
||||
|
||||
// With the bytes at hand, the next write of the card brings the photo.
|
||||
h.blobs.set('B1', JPEG);
|
||||
h.server.serverUpdate('ContactCard', JMAP, id, { 'emails/e1/address': 'c@example.org' });
|
||||
await h.download(id);
|
||||
expect((await h.port.readPhoto(rawId, 512))?.jpegBase64).toBe(JPEG);
|
||||
await expectSettled(h, id, rawId);
|
||||
});
|
||||
|
||||
it('does not read a server photo it could not write as deleted after merging it into an edited contact', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed({ name: { full: 'Merge Photo' }, emails: { e1: { address: 'a@example.org' } }, phones: { p1: { number: '1' } } });
|
||||
// The server adds a photo too large to write while the device edits the phone.
|
||||
h.device.user.updateData(h.rowsByKey(rawId)['phones:p1']._id as number, { data1: '2' });
|
||||
const big = 'A'.repeat(1_200_000);
|
||||
h.server.serverUpdate('ContactCard', JMAP, id, { media: { p: { kind: 'photo', uri: `data:image/jpeg;base64,${big}` } } });
|
||||
await h.applyOk(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx).ops);
|
||||
expect(h.planner.planUpload(await h.contact(rawId), h.ctx)).toEqual({
|
||||
kind: 'upload', actions: [{ kind: 'update', id, patch: { 'phones/p1/number': '2' } }],
|
||||
});
|
||||
});
|
||||
|
||||
describe('a small photo the provider keeps as its thumbnail only (no PHOTO_FILE_ID)', () => {
|
||||
const photo = { ph: { kind: 'photo', uri: `data:image/jpeg;base64,${JPEG}`, mediaType: 'image/jpeg' } };
|
||||
const small = () => ({ name: { full: 'Photo Smalltest' }, emails: { e1: { address: 'small@example.org' } }, notes: { n1: { note: 'v1' } }, media: photo });
|
||||
const photos = (h: Harness, rawId: number) => h.device.rows('data', 'raw_contact_id = ? AND mimetype = ?', [rawId, MimeType.PHOTO]);
|
||||
|
||||
it('keeps one row through downloads and merges, and never reads it as deleted', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(small());
|
||||
expect(photos(h, rawId)).toMatchObject([{ data14: null, data_sync1: 'media:ph' }]);
|
||||
await expectSettled(h, id, rawId);
|
||||
for (const note of ['v2', 'v3']) {
|
||||
h.server.serverUpdate('ContactCard', JMAP, id, { 'notes/n1/note': note });
|
||||
await h.download(id);
|
||||
expect(photos(h, rawId)).toHaveLength(1);
|
||||
}
|
||||
// A merge into an edited contact.
|
||||
h.device.user.updateData(h.rowsByKey(rawId)['emails:e1']._id as number, { data1: 'small2@example.org' });
|
||||
h.server.serverUpdate('ContactCard', JMAP, id, { 'notes/n1/note': 'v4' });
|
||||
await h.applyOk(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx).ops);
|
||||
expect(photos(h, rawId)).toHaveLength(1);
|
||||
// An edit of another field uploads alone.
|
||||
expect(h.planner.planUpload(await h.contact(rawId), h.ctx)).toEqual({
|
||||
kind: 'upload', actions: [{ kind: 'update', id, patch: { 'emails/e1/address': 'small2@example.org' } }],
|
||||
});
|
||||
await h.upload(rawId);
|
||||
expect(h.card(id).media).toEqual(photo);
|
||||
expect(photos(h, rawId)).toHaveLength(1);
|
||||
await expectSettled(h, id, rawId);
|
||||
// A photo set on the device (it gets a display photo) replaces it.
|
||||
h.device.user.setPhoto(rawId, JPEG2);
|
||||
h.devicePhotos.set(rawId, JPEG2);
|
||||
expect(h.planner.planUpload(await h.contact(rawId), h.ctx)).toEqual({
|
||||
kind: 'upload', actions: [{ kind: 'update', id, patch: { 'media/ph/uri': `data:image/jpeg;base64,${JPEG2}`, 'media/ph/mediaType': 'image/jpeg' } }],
|
||||
});
|
||||
});
|
||||
|
||||
it('heals the photo rows the same photo was inserted as again, keeping one and the server photo', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(small());
|
||||
const [row] = photos(h, rawId);
|
||||
const again = { [Data.MIMETYPE]: MimeType.PHOTO, [Data.DATA15]: { b64: JPEG }, data_sync1: row.data_sync1, data_sync2: row.data_sync2, data_sync3: row.data_sync3 };
|
||||
for (let i = 0; i < 2; i++) await h.applyOk({ ref: 'again', ops: [{ op: 'insert', table: 'data', values: { ...again, [Data.RAW_CONTACT_ID]: rawId } }] });
|
||||
expect(photos(h, rawId)).toHaveLength(3);
|
||||
// An edit on the device uploads without touching the photo, and its accepted write keeps one row.
|
||||
h.device.user.updateData(h.rowsByKey(rawId)['notes:n1']._id as number, { data1: 'v2 edited on device' });
|
||||
expect(h.planner.planUpload(await h.contact(rawId), h.ctx)).toEqual({
|
||||
kind: 'upload', actions: [{ kind: 'update', id, patch: { 'notes/n1/note': 'v2 edited on device' } }],
|
||||
});
|
||||
await h.upload(rawId);
|
||||
expect(photos(h, rawId)).toHaveLength(1);
|
||||
expect(h.card(id).media).toEqual(photo);
|
||||
await expectSettled(h, id, rawId);
|
||||
// An unchanged card downloaded again (a full reconcile) heals too.
|
||||
for (let i = 0; i < 2; i++) await h.applyOk({ ref: 'again', ops: [{ op: 'insert', table: 'data', values: { ...again, [Data.RAW_CONTACT_ID]: rawId } }] });
|
||||
await h.applyOk(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx).ops);
|
||||
expect(photos(h, rawId)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('heals a baseline the provider stored differently from the prediction', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed({ name: { full: 'Dr. Jane Q. Public' } });
|
||||
const name = h.rowsByKey(rawId).name;
|
||||
// The real NameSplitter knows titles and middle names; the prediction does not.
|
||||
await h.applyOk({ ref: 'split', ops: [{ op: 'update', table: 'data', id: name._id as number, values: { data4: 'Dr.', data2: 'Jane', data5: 'Q.', data3: 'Public' } }] });
|
||||
const heal = h.planner.planBaselineHeal(await h.contact(rawId));
|
||||
expect(heal?.ops.map((o) => o.op)).toEqual(['assert', 'update']);
|
||||
await h.applyOk(heal!);
|
||||
await expectSettled(h, id, rawId);
|
||||
h.device.user.star(rawId);
|
||||
expect((await h.upload(rawId)).kind).toBe('clean');
|
||||
});
|
||||
|
||||
it('does not heal a dirty contact: its edit must still count', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(probeCard());
|
||||
h.device.user.updateData(h.rowsByKey(rawId)['notes:n1']._id as number, { data1: 'edited' });
|
||||
expect(h.planner.planBaselineHeal(await h.contact(rawId))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Data, MimeType, RawContacts } from '../../android-columns';
|
||||
import type { ContactCardWire } from '../../planner';
|
||||
import { appleCard, googleCard, probeCard } from './fixtures';
|
||||
import { Harness, JMAP } from './harness';
|
||||
|
||||
/** A seeded card, edited on the device (in place, like AOSP) and on the server. */
|
||||
async function bothEdited(
|
||||
make: () => Record<string, unknown>,
|
||||
device: (h: Harness, rawId: number) => void,
|
||||
server: Record<string, unknown>,
|
||||
) {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(make());
|
||||
device(h, rawId);
|
||||
h.server.serverUpdate('ContactCard', JMAP, id, server);
|
||||
const plan = h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx);
|
||||
await h.applyOk(plan.ops);
|
||||
return { h, id, rawId, plan };
|
||||
}
|
||||
|
||||
const edit = (key: string, values: Record<string, string | number | null>) => (h: Harness, rawId: number) =>
|
||||
h.device.user.updateData(h.rowsByKey(rawId)[key]._id as number, values);
|
||||
|
||||
async function patchOf(h: Harness, rawId: number) {
|
||||
const plan = h.planner.planUpload(await h.contact(rawId), h.ctx);
|
||||
return plan.kind === 'upload' && plan.actions[0].kind === 'update' ? plan.actions[0].patch : plan.kind;
|
||||
}
|
||||
|
||||
describe('contacts merge: a dirty contact meets a new server version', () => {
|
||||
it('lets the server win a real conflict and counts it', async () => {
|
||||
const { h, rawId, plan } = await bothEdited(appleCard, edit('emails:k1', { data1: 'mine@example.com' }), { 'emails/k1/address': 'theirs@example.com' });
|
||||
expect(plan).toMatchObject({ conflicts: 1, stillDirty: false, effect: 'update' });
|
||||
expect(h.rowsByKey(rawId)['emails:k1'].data1).toBe('theirs@example.com');
|
||||
expect(h.dirty(rawId)).toBe(true);
|
||||
expect(await patchOf(h, rawId)).toBe('clean');
|
||||
});
|
||||
|
||||
it('keeps a local edit of one unit and takes the server edit of another', async () => {
|
||||
const { h, rawId, plan } = await bothEdited(appleCard, edit('phones:k1', { data1: '+1 555 0199' }), { 'emails/k2/address': 'anna@new.example' });
|
||||
expect(plan).toMatchObject({ conflicts: 0, stillDirty: true });
|
||||
expect(h.rowsByKey(rawId)['emails:k2'].data1).toBe('anna@new.example');
|
||||
expect(await patchOf(h, rawId)).toEqual({ 'phones/k1/number': '+1 555 0199' });
|
||||
});
|
||||
|
||||
it('treats the same change on both sides as converged', async () => {
|
||||
const { h, rawId, plan } = await bothEdited(appleCard, edit('emails:k1', { data1: 'same@example.com' }), { 'emails/k1/address': 'same@example.com' });
|
||||
expect(plan).toMatchObject({ conflicts: 0, stillDirty: false });
|
||||
expect(await patchOf(h, rawId)).toBe('clean');
|
||||
});
|
||||
|
||||
it('does not count a server change to a sub-field Android does not show', async () => {
|
||||
const { h, id, rawId, plan } = await bothEdited(appleCard, edit('emails:k1', { data1: 'mine@example.com' }), { 'emails/k1/contexts': { private: true, school: true } });
|
||||
expect(plan.conflicts).toBe(0);
|
||||
expect(await patchOf(h, rawId)).toEqual({ 'emails/k1/address': 'mine@example.com' });
|
||||
await h.upload(rawId);
|
||||
expect(h.card(id).emails!.k1).toMatchObject({ address: 'mine@example.com', contexts: { private: true, school: true } });
|
||||
});
|
||||
|
||||
it('deletes a row the server deleted, over a local edit (counted)', async () => {
|
||||
const { h, rawId, plan } = await bothEdited(appleCard, edit('phones:k3', { data1: '+1 555 0177' }), { 'phones/k3': null });
|
||||
expect(plan.conflicts).toBe(1);
|
||||
expect(h.rowsByKey(rawId)['phones:k3']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('puts back an entry deleted on the device that the server changed (counted)', async () => {
|
||||
const { h, rawId, plan } = await bothEdited(
|
||||
appleCard,
|
||||
(hh, raw) => hh.device.user.deleteData(hh.rowsByKey(raw)['phones:k3']._id as number),
|
||||
{ 'phones/k3/number': '+1 555 0188' },
|
||||
);
|
||||
expect(plan.conflicts).toBe(1);
|
||||
expect(h.rowsByKey(rawId)['phones:k3'].data1).toBe('+1 555 0188');
|
||||
});
|
||||
|
||||
it('keeps a device deletion of an entry the server left alone', async () => {
|
||||
const { h, rawId, plan } = await bothEdited(
|
||||
appleCard,
|
||||
(hh, raw) => hh.device.user.deleteData(hh.rowsByKey(raw)['phones:k3']._id as number),
|
||||
{ 'emails/k2/address': 'anna@new.example' },
|
||||
);
|
||||
expect(plan).toMatchObject({ conflicts: 0, stillDirty: true });
|
||||
const patch = await patchOf(h, rawId) as Record<string, unknown>;
|
||||
expect(Object.keys(patch)).toEqual(['phones']);
|
||||
expect(Object.keys(patch.phones as object)).toEqual(['k1', 'k2']);
|
||||
});
|
||||
|
||||
it('still uploads a photo deleted on the device after merging a server edit of another entry', async () => {
|
||||
const { h, rawId, plan } = await bothEdited(
|
||||
probeCard,
|
||||
(hh, raw) => hh.device.user.deleteData(hh.rowsByKey(raw)['media:ph']._id as number),
|
||||
{ 'emails/zz9/address': 'new@example.org' },
|
||||
);
|
||||
expect(plan).toMatchObject({ conflicts: 0, stillDirty: true });
|
||||
expect(await patchOf(h, rawId)).toEqual({ media: null });
|
||||
});
|
||||
|
||||
it('matches an entry both sides added instead of duplicating it', async () => {
|
||||
const { h, rawId, plan } = await bothEdited(
|
||||
appleCard,
|
||||
(hh, raw) => hh.device.user.insertData(raw, { [Data.MIMETYPE]: MimeType.EMAIL, data1: 'both@example.com', data2: 3 }),
|
||||
{ 'emails/k3': { address: 'both@example.com' } },
|
||||
);
|
||||
expect(plan.conflicts).toBe(0);
|
||||
const emails = h.device.rows('data', 'raw_contact_id = ? AND mimetype = ?', [rawId, MimeType.EMAIL]);
|
||||
expect(emails.filter((r) => r.data1 === 'both@example.com')).toHaveLength(1);
|
||||
expect(await patchOf(h, rawId)).toBe('clean');
|
||||
});
|
||||
|
||||
it('inserts what the server added next to a pending local edit', async () => {
|
||||
const { h, rawId, plan } = await bothEdited(appleCard, edit('phones:k1', { data1: '+1 555 0199' }), { 'emails/k3': { address: 'third@example.com' } });
|
||||
expect(plan.stillDirty).toBe(true);
|
||||
expect(h.rowsByKey(rawId)['emails:k3'].data1).toBe('third@example.com');
|
||||
expect(await patchOf(h, rawId)).toEqual({ 'phones/k1/number': '+1 555 0199' });
|
||||
});
|
||||
|
||||
it('follows shifted keys and uploads the local edit under the new key', async () => {
|
||||
const { h, rawId, plan } = await bothEdited(appleCard, edit('emails:k2', { data1: 'anna@work2.example' }), {
|
||||
emails: {
|
||||
k1: { address: 'first@example.com' },
|
||||
k2: { address: 'anna@example.com', contexts: { private: true }, pref: 1 },
|
||||
k3: { address: 'anna@work.example', contexts: { work: true } },
|
||||
},
|
||||
});
|
||||
expect(plan.conflicts).toBe(0);
|
||||
const rows = h.rowsByKey(rawId);
|
||||
expect(rows['emails:k3'].data1).toBe('anna@work2.example');
|
||||
expect(rows['emails:k1'].data1).toBe('first@example.com');
|
||||
expect(await patchOf(h, rawId)).toEqual({ 'emails/k3/address': 'anna@work2.example' });
|
||||
});
|
||||
|
||||
it('merges the name as one unit: a typed name on the server beats a local component edit', async () => {
|
||||
const { h, rawId, plan } = await bothEdited(appleCard, edit('name', { data2: 'Annie', data1: 'Annie Apfel' }), { 'name/full': 'A. Apfel' });
|
||||
expect(plan.conflicts).toBe(1);
|
||||
expect(h.rowsByKey(rawId).name).toMatchObject({ data1: 'A. Apfel', data2: 'Anna' });
|
||||
});
|
||||
|
||||
it('never gives keys to a kind an editor rewrote, so a nickname it could not show is not deleted later', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(googleCard());
|
||||
// Fossify keeps one nickname, re-inserted without its key.
|
||||
h.device.user.fossifySave(rawId, null, [{ [Data.MIMETYPE]: MimeType.NICKNAME, data1: 'Gü' }]);
|
||||
h.server.serverUpdate('ContactCard', JMAP, id, { 'nicknames/k1/name': 'Guenther' });
|
||||
const plan = h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx);
|
||||
await h.applyOk(plan.ops);
|
||||
const nick = h.device.rows('data', 'raw_contact_id = ? AND mimetype = ?', [rawId, MimeType.NICKNAME]);
|
||||
expect(nick.map((r) => [r.data1, r.data_sync1])).toEqual([['Guenther', null]]);
|
||||
const patch = await patchOf(h, rawId);
|
||||
expect(typeof patch === 'object' && Object.keys(patch).some((k) => k.startsWith('nicknames'))).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves the rows of a contact deleted on the device alone: the delete wins and uploads', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(appleCard());
|
||||
h.device.user.deleteContact(rawId);
|
||||
h.server.serverUpdate('ContactCard', JMAP, id, { 'emails/k1/address': 'x@example.com' });
|
||||
const plan = h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx);
|
||||
// Only the shadow follows the card, for the deletion's upload.
|
||||
expect(plan).toMatchObject({ effect: 'update', stillDirty: true, conflicts: 0 });
|
||||
expect(plan.ops.ops.filter((o) => o.op !== 'assert')).toEqual([{
|
||||
op: 'update', table: 'raw_contacts', id: rawId, expectCount: 1,
|
||||
values: { [RawContacts.SYNC2]: expect.any(String) },
|
||||
}]);
|
||||
await h.applyOk(plan.ops);
|
||||
expect(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx)).toMatchObject({ effect: 'none', ops: { ops: [] } });
|
||||
expect(h.planner.planUpload(await h.contact(rawId), h.ctx)).toMatchObject({ kind: 'upload', actions: [{ kind: 'destroy', id }] });
|
||||
});
|
||||
|
||||
it('replaces the rows of a different object behind the same id (another uid)', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(appleCard());
|
||||
edit('emails:k1', { data1: 'mine@example.com' })(h, rawId);
|
||||
const other = { ...(googleCard() as object), id, addressBookIds: { [h.book]: true } } as unknown as ContactCardWire;
|
||||
const plan = h.planner.planDownload(other, await h.contact(rawId), h.ctx);
|
||||
await h.applyOk(plan.ops);
|
||||
const rows = h.rowsByKey(rawId);
|
||||
expect(rows['emails:k1']).toBeUndefined();
|
||||
expect(rows['nicknames:k1'].data1).toBe('Gü');
|
||||
expect(h.dirty(rawId)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
// The contacts planner driven by the engine (fake providers, fake Stalwart):
|
||||
// cases that only show end to end, over several runs. The planner's rules
|
||||
// themselves are tested in the other files of this folder.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Data, Groups, MimeType } from '../../android-columns';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import { contactsPlanner } from '../../contacts/planner';
|
||||
import { addServerCards, ANDROID_ACCOUNT, CONTACTS_AUTHORITY, createHarness, renameDeviceContact, rowWrites, type Harness } from '../engine/harness';
|
||||
import type { SetTarget } from '../fakes/fake-jmap-server';
|
||||
import { JPEG } from './fixtures';
|
||||
|
||||
function real(options: Parameters<typeof createHarness>[0] = {}): Harness {
|
||||
const h = createHarness(options);
|
||||
h.deps.planners = { contacts: contactsPlanner, calendar: calendarPlanner };
|
||||
return h;
|
||||
}
|
||||
|
||||
function memberships(h: Harness, rawContactId: number) {
|
||||
return h.device.rows('data').filter((d) => Number(d.raw_contact_id) === rawContactId && d.mimetype === MimeType.GROUP_MEMBERSHIP);
|
||||
}
|
||||
|
||||
/** An address book of the personal account that this device does not sync. */
|
||||
function unsyncedBook(h: Harness, name = 'Archive'): string {
|
||||
const id = h.server.addAddressBook('a', { name });
|
||||
h.prefs.contactsSelection[`a/${id}`] = false;
|
||||
return id;
|
||||
}
|
||||
|
||||
describe('contacts through the engine', () => {
|
||||
it('keeps a card deleted on the device that the server meanwhile also filed in an unsynced book', async () => {
|
||||
const h = real();
|
||||
const archive = unsyncedBook(h);
|
||||
const [ada] = addServerCards(h, ['Ada Lovelace']);
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
|
||||
h.device.user.deleteContact(h.contactNamed('Ada Lovelace')!.id);
|
||||
h.server.serverUpdate('ContactCard', 'a', ada, { [`addressBookIds/${archive}`]: true });
|
||||
const report = await h.run();
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { deleted: 1 } } });
|
||||
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ addressBookIds: { [archive]: true } });
|
||||
expect(h.contacts()).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a group deleted on the device that the server meanwhile also filed in an unsynced book', async () => {
|
||||
const h = real();
|
||||
const archive = unsyncedBook(h);
|
||||
const group = h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' }, addressBookIds: { [h.book]: true } });
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
|
||||
const row = h.device.rows('groups').find((g) => g.sourceid === `a/${group}`)!;
|
||||
h.device.user.deleteGroup(Number(row._id));
|
||||
h.server.serverUpdate('ContactCard', 'a', group, { [`addressBookIds/${archive}`]: true });
|
||||
const report = await h.run();
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { deleted: 1 } } });
|
||||
expect(h.server.get('ContactCard', 'a', group)).toMatchObject({ addressBookIds: { [archive]: true } });
|
||||
expect(h.device.rows('groups')).toEqual([]);
|
||||
});
|
||||
|
||||
it('puts back a membership in a group of another account, so no change waits for ever', async () => {
|
||||
const h = real({ accounts: 'withShared' });
|
||||
const book = h.server.all('AddressBook', 'team')[0].id as string;
|
||||
h.prefs.contactsSelection[`team/${book}`] = true;
|
||||
const team = h.server.addCard('team', { uid: 'g-team', kind: 'group', name: { full: 'Team' }, addressBookIds: { [book]: true } });
|
||||
addServerCards(h, ['Ada Lovelace']);
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
|
||||
const ada = h.contactNamed('Ada Lovelace')!.id;
|
||||
h.device.user.addToGroup(ada, Number(h.device.rows('groups').find((g) => g.sourceid === `team/${team}`)!._id));
|
||||
const report = await h.run();
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', stats: { skipped: 1 } });
|
||||
expect(report.itemErrors).toEqual([expect.objectContaining({ side: 'upload', type: 'groupNotWritable' })]);
|
||||
expect(h.contactNamed('Ada Lovelace')).toMatchObject({ dirty: false });
|
||||
expect(memberships(h, ada)).toEqual([]);
|
||||
expect(h.server.get('ContactCard', 'team', team)?.members).toBeUndefined();
|
||||
// Turning sync off finds nothing waiting.
|
||||
expect(await h.teardown(CONTACTS_AUTHORITY)).toEqual({ pending: 0 });
|
||||
});
|
||||
|
||||
it('puts back a membership in a group of a read-only book, so no change waits for ever', async () => {
|
||||
const h = real();
|
||||
const shared = h.server.addAddressBook('a', { name: 'Shared', myRights: { mayRead: true, mayWrite: false, mayShare: false, mayDelete: false } });
|
||||
h.prefs.contactsSelection[`a/${shared}`] = true;
|
||||
const team = h.server.addCard('a', { uid: 'g-team', kind: 'group', name: { full: 'Team' }, members: { other: true }, addressBookIds: { [shared]: true } });
|
||||
addServerCards(h, ['Ada Lovelace']);
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
|
||||
const ada = h.contactNamed('Ada Lovelace')!.id;
|
||||
h.device.user.addToGroup(ada, Number(h.device.rows('groups').find((g) => g.sourceid === `a/${team}`)!._id));
|
||||
const report = await h.run();
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', stats: { skipped: 1 } });
|
||||
expect(report.itemErrors).toEqual([expect.objectContaining({ side: 'upload', type: 'groupNotWritable' })]);
|
||||
expect(h.contactNamed('Ada Lovelace')).toMatchObject({ dirty: false });
|
||||
expect(memberships(h, ada)).toEqual([]);
|
||||
expect(h.server.get('ContactCard', 'a', team)?.members).toEqual({ other: true });
|
||||
// Turning sync off finds nothing waiting.
|
||||
expect(await h.teardown(CONTACTS_AUTHORITY)).toEqual({ pending: 0 });
|
||||
});
|
||||
|
||||
it('puts back an edit of a card that is only in a read-only book, without asking the server', async () => {
|
||||
const h = real();
|
||||
const shared = h.server.addAddressBook('a', { name: 'Shared', myRights: { mayRead: true, mayWrite: false, mayShare: false, mayDelete: false } });
|
||||
h.prefs.contactsSelection[`a/${shared}`] = true;
|
||||
const [ada] = addServerCards(h, ['Ada Lovelace'], 'a', shared);
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
|
||||
renameDeviceContact(h, h.contactNamed('Ada Lovelace')!.id, 'Ada King');
|
||||
const report = await h.run();
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', stats: { skipped: 1, uploaded: { updated: 0 } } });
|
||||
expect(report.itemErrors).toEqual([expect.objectContaining({ side: 'upload', type: 'readOnly' })]);
|
||||
expect(h.contactNamed('Ada Lovelace')).toMatchObject({ dirty: false });
|
||||
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ name: { full: 'Ada Lovelace' } });
|
||||
});
|
||||
|
||||
it('puts a contact created on the device into the group it was added to', async () => {
|
||||
const h = real();
|
||||
const group = h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' }, members: { other: true }, addressBookIds: { [h.book]: true } });
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
const id = h.device.user.insertContact(ANDROID_ACCOUNT, [
|
||||
{ [Data.MIMETYPE]: MimeType.STRUCTURED_NAME, [Data.DATA1]: 'New Person' },
|
||||
{ [Data.MIMETYPE]: MimeType.GROUP_MEMBERSHIP, [Data.DATA1]: Number(h.device.rows('groups')[0]._id) },
|
||||
]);
|
||||
|
||||
expect(await h.run()).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 1, updated: 1 } } });
|
||||
const uid = h.server.all('ContactCard', 'a').find((c) => c.kind !== 'group')!.uid as string;
|
||||
expect(h.server.get('ContactCard', 'a', group)?.members).toEqual({ other: true, [uid]: true });
|
||||
expect(memberships(h, id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps one row for a small photo the provider stores as its thumbnail, and never deletes it on the server', async () => {
|
||||
const h = real();
|
||||
const media = { ph: { kind: 'photo', uri: `data:image/jpeg;base64,${JPEG}`, mediaType: 'image/jpeg' } };
|
||||
const card = h.server.addCard('a', { uid: 'u-small', name: { full: 'Photo Smalltest' }, notes: { n1: { note: 'v1' } }, addressBookIds: { [h.book]: true }, media });
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
const id = h.contactNamed('Photo Smalltest')!.id;
|
||||
const rowsOf = (mimetype: string) => h.device.rows('data').filter((d) => Number(d.raw_contact_id) === id && d.mimetype === mimetype);
|
||||
expect(rowsOf(MimeType.PHOTO)).toMatchObject([{ data14: null }]);
|
||||
|
||||
for (const note of ['v2', 'v3']) {
|
||||
h.server.serverUpdate('ContactCard', 'a', card, { 'notes/n1/note': note });
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
expect(rowsOf(MimeType.PHOTO)).toHaveLength(1);
|
||||
}
|
||||
h.device.user.updateData(Number(rowsOf(MimeType.NOTE)[0]._id), { [Data.DATA1]: 'v4 edited on device' });
|
||||
expect(await h.run()).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { updated: 1 } } });
|
||||
expect(h.server.get('ContactCard', 'a', card)).toMatchObject({ notes: { n1: { note: 'v4 edited on device' } }, media });
|
||||
expect(rowsOf(MimeType.PHOTO)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('syncs a contact whose photo is too large for a provider batch, without the photo', async () => {
|
||||
const h = real();
|
||||
// About 900 KB of JPEG as a data: URI (a camera photo set over CardDAV).
|
||||
const media = { p1: { '@type': 'Media', kind: 'photo', uri: `data:image/jpeg;base64,${'A'.repeat(1_200_000)}`, mediaType: 'image/jpeg' } };
|
||||
const card = h.server.addCard('a', { uid: 'u-photo', name: { full: 'Photo Person' }, emails: { e1: { address: 'photo@example.org' } }, addressBookIds: { [h.book]: true }, media });
|
||||
addServerCards(h, ['Plain Person']);
|
||||
|
||||
expect(await h.run()).toMatchObject({ outcome: 'ok', itemErrors: [] });
|
||||
expect(h.contacts().map((c) => c.name).sort()).toEqual(['Photo Person', 'Plain Person']);
|
||||
expect(h.state()?.accounts.a.stale).toEqual([]);
|
||||
const before = h.batches.log.length;
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
expect(rowWrites(h.batches.log.slice(before))).toEqual([]);
|
||||
|
||||
// Its device edits upload, and the photo stays on the server.
|
||||
const id = h.contactNamed('Photo Person')!.id;
|
||||
const email = h.device.rows('data').find((d) => Number(d.raw_contact_id) === id && d.mimetype === MimeType.EMAIL)!;
|
||||
h.device.user.updateData(Number(email._id), { data1: 'photo2@example.org' });
|
||||
expect(await h.run()).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { updated: 1 } } });
|
||||
expect(h.server.get('ContactCard', 'a', card)).toMatchObject({ emails: { e1: { address: 'photo2@example.org' } }, media });
|
||||
});
|
||||
|
||||
it('keeps a group rename the server refused when a membership patch of that group goes through', async () => {
|
||||
const h = real();
|
||||
const [ada] = addServerCards(h, ['Ada Lovelace']);
|
||||
const group = h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' }, members: { other: true }, addressBookIds: { [h.book]: true } });
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
const row = () => h.device.rows('groups').find((g) => g.sourceid === `a/${group}`)!;
|
||||
const groupId = Number(row()._id);
|
||||
h.device.user.updateGroup(groupId, { [Groups.TITLE]: 'Best friends' });
|
||||
h.device.user.addToGroup(h.contactNamed('Ada Lovelace')!.id, groupId);
|
||||
const rename = (t: SetTarget) => t.op === 'update' && t.id === group && !!t.object && 'name/full' in t.object;
|
||||
h.server.setErrorFor('ContactCard', 'a', rename, { type: 'invalidProperties', properties: ['name'] });
|
||||
|
||||
const report = await h.run();
|
||||
expect(report.itemErrors).toMatchObject([{ ref: `a/${group}`, type: 'invalidProperties' }]);
|
||||
const uid = h.server.get('ContactCard', 'a', ada)!.uid as string;
|
||||
expect(h.server.get('ContactCard', 'a', group)).toMatchObject({ name: { full: 'Friends' }, members: { other: true, [uid]: true } });
|
||||
expect(row()).toMatchObject({ [Groups.TITLE]: 'Best friends', [Groups.DIRTY]: 1 });
|
||||
|
||||
// The rename is still the device's and goes up once the server takes it.
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
expect(h.server.get('ContactCard', 'a', group)).toMatchObject({ name: { full: 'Best friends' } });
|
||||
expect(row()).toMatchObject({ [Groups.TITLE]: 'Best friends', [Groups.DIRTY]: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Data, EmailType, MimeType, PhoneType, RelationType, WebsiteType } from '../../android-columns';
|
||||
import { contactsPlanner } from '../../contacts/planner';
|
||||
import { entryKey, orgKey, parseEntryKey, parseOrgKey } from '../../contacts/keys';
|
||||
import { editPostalComponents, postalCells, resplitPostal } from '../../contacts/postal';
|
||||
import { base64ToBytes, dataUriBase64, shadowOf } from '../../contacts/photo';
|
||||
import { fullIsDerived, nameCells } from '../../contacts/name';
|
||||
import { emailType, phoneFlags, phoneType, relationName, relationType, websiteType } from '../../contacts/types-map';
|
||||
import type { ContactCardWire } from '../../planner';
|
||||
import { JPEG } from './fixtures';
|
||||
|
||||
describe('contacts decode', () => {
|
||||
it('queries the columns the planner reads', () => {
|
||||
expect(contactsPlanner.rawContactColumns).toEqual(expect.arrayContaining([
|
||||
'_id', 'sourceid', 'version', 'dirty', 'deleted', 'sync1', 'sync2', 'sync3', 'sync4',
|
||||
]));
|
||||
// The providers refuse it in every projection ("Invalid column"): it is only written.
|
||||
expect(contactsPlanner.rawContactColumns).not.toContain('raw_contact_is_read_only');
|
||||
expect(contactsPlanner.dataColumns).toEqual(expect.arrayContaining([
|
||||
'_id', 'raw_contact_id', 'mimetype', 'is_primary', 'data1', 'data14', 'data_sync1', 'data_sync2', 'data_sync3', 'group_sourceid',
|
||||
]));
|
||||
expect(contactsPlanner.dataColumns).not.toContain('data15');
|
||||
expect(contactsPlanner.groupColumns).toEqual(expect.arrayContaining(['_id', 'sourceid', 'version', 'dirty', 'deleted', 'title', 'sync2', 'sync3', 'sync4']));
|
||||
});
|
||||
|
||||
it('reads numbers given as text (data columns are TEXT) and tolerates what other apps left in sync columns', () => {
|
||||
const local = contactsPlanner.decodeContact(
|
||||
{ _id: '5', sourceid: 'c/e1', version: '3', dirty: '1', deleted: null, sync1: 'c/b,garbage,,c/x', sync2: '{"not":"a card"}', sync3: '{"uid":1}', sync4: 'nope' },
|
||||
[
|
||||
{ _id: '9', raw_contact_id: '5', mimetype: MimeType.EMAIL, data1: 'a@b', data2: '2', data_sync1: 'emails:e1', data_sync2: '', data_sync3: '[1]' },
|
||||
{ _id: 10, raw_contact_id: 6, mimetype: MimeType.NOTE, data1: 'another contact' },
|
||||
],
|
||||
);
|
||||
expect(local).toMatchObject({
|
||||
rawContactId: 5, sourceId: 'c/e1', version: 3, dirty: true, deleted: false,
|
||||
collections: ['c/b', 'c/x'], shadow: null, pending: null, poison: null,
|
||||
});
|
||||
expect(local.rows).toEqual([{ id: 9, mimetype: MimeType.EMAIL, cells: { data1: 'a@b', data2: '2' }, key: 'emails:e1', photoHash: null, baseline: null }]);
|
||||
});
|
||||
|
||||
it('decodes a pending create, a poison marker and a group', () => {
|
||||
const local = contactsPlanner.decodeContact({
|
||||
_id: 1, sourceid: null, version: 1, dirty: 1, deleted: 0,
|
||||
sync3: JSON.stringify({ uid: 'urn:uuid:x', target: 'c/b' }),
|
||||
sync4: JSON.stringify({ fp: 'f', type: 'invalidProperties', n: 2, until: 5 }),
|
||||
}, []);
|
||||
expect(local.pending).toEqual({ uid: 'urn:uuid:x', target: 'c/b' });
|
||||
expect(local.poison).toEqual({ fp: 'f', type: 'invalidProperties', n: 2, until: 5 });
|
||||
expect(contactsPlanner.decodeGroup({ _id: 4, sourceid: 'c/g', version: 2, dirty: 0, deleted: 1, title: 'T', sync2: '{"id":"g","kind":"group"}' }))
|
||||
.toMatchObject({ groupId: 4, sourceId: 'c/g', deleted: true, title: 'T', shadow: { id: 'g', kind: 'group' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('contacts keys', () => {
|
||||
it('round-trips entry keys with any character', () => {
|
||||
for (const key of ['work1', 'x/y', 'a,b|c%d', 'urn:uuid:1', 'k:1']) {
|
||||
expect(parseEntryKey(entryKey('emails', key))).toEqual({ map: 'emails', key });
|
||||
}
|
||||
expect(parseEntryKey('name')).toBeNull();
|
||||
expect(parseEntryKey('garbage')).toBeNull();
|
||||
});
|
||||
|
||||
it('round-trips organization keys, titles positional as title then role', () => {
|
||||
expect(orgKey({ org: 'o1', title: 't1', role: 'r1' })).toBe('organizations:o1|titles:t1,r1');
|
||||
expect(orgKey({ org: null, title: null, role: 'r1' })).toBe('titles:,r1');
|
||||
expect(orgKey({ org: 'o1', title: null, role: null })).toBe('organizations:o1');
|
||||
for (const parts of [{ org: 'o|1', title: 't,1', role: null }, { org: null, title: 't1', role: 'r1' }, { org: 'o', title: null, role: null }]) {
|
||||
expect(parseOrgKey(orgKey(parts))).toEqual(parts);
|
||||
}
|
||||
expect(parseOrgKey('emails:e1')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('contacts type tables', () => {
|
||||
it.each([
|
||||
[{ features: { mobile: true }, contexts: { work: true } }, PhoneType.WORK_MOBILE],
|
||||
[{ features: { cell: true } }, PhoneType.MOBILE],
|
||||
[{ features: { fax: true }, contexts: { private: true } }, PhoneType.FAX_HOME],
|
||||
[{ features: { fax: true } }, PhoneType.OTHER_FAX],
|
||||
[{ features: { pager: true }, contexts: { work: true } }, PhoneType.WORK_PAGER],
|
||||
[{ features: { textphone: true } }, PhoneType.TTY_TDD],
|
||||
[{ features: { 'main-number': true } }, PhoneType.MAIN],
|
||||
[{ features: { voice: true }, contexts: { work: true } }, PhoneType.WORK],
|
||||
[{ contexts: { private: true } }, PhoneType.HOME],
|
||||
[{ features: { video: true } }, PhoneType.OTHER],
|
||||
[{ features: { mobile: true }, label: 'Boat' }, PhoneType.CUSTOM],
|
||||
])('phone %j → TYPE %i', (entry, type) => {
|
||||
expect(phoneType(entry).type).toBe(type);
|
||||
});
|
||||
|
||||
it('reads the phone table right to left, OTHER meaning no features', () => {
|
||||
expect(phoneFlags(PhoneType.OTHER, null)).toEqual({ contexts: [], features: [], label: null });
|
||||
expect(phoneFlags(PhoneType.FAX_WORK, null)).toEqual({ contexts: ['work'], features: ['fax'], label: null });
|
||||
expect(phoneFlags(PhoneType.CAR, null)).toEqual({ contexts: [], features: ['voice'], label: 'car' });
|
||||
});
|
||||
|
||||
it('reads back the types RFC 9553 lacks from the label they upload with', () => {
|
||||
expect(phoneType({ features: { 'main-number': true }, contexts: { work: true } }).type).toBe(PhoneType.COMPANY_MAIN);
|
||||
expect(phoneType({ features: { voice: true }, label: 'car' })).toEqual({ type: PhoneType.CAR, label: null });
|
||||
expect(phoneType({ features: { text: true }, label: 'mms' })).toEqual({ type: PhoneType.MMS, label: null });
|
||||
// A custom label uploads without the feature, so it stays custom; a lone `text` is no MMS.
|
||||
expect(phoneType({ label: 'car' })).toEqual({ type: PhoneType.CUSTOM, label: 'car' });
|
||||
expect(phoneType({ features: { text: true } }).type).toBe(PhoneType.OTHER);
|
||||
expect(emailType({ label: 'mobile' })).toEqual({ type: EmailType.MOBILE, label: null });
|
||||
expect(websiteType({ label: 'blog', contexts: { work: true } })).toEqual({ type: WebsiteType.BLOG, label: null });
|
||||
});
|
||||
|
||||
it('maps relations both ways, device-only types to the nearest RFC type', () => {
|
||||
expect(relationType({ friend: true })).toEqual({ type: RelationType.FRIEND, label: null });
|
||||
expect(relationType({ 'co-worker': true })).toEqual({ type: RelationType.CUSTOM, label: 'co-worker' });
|
||||
expect(relationType({ agent: true })).toEqual({ type: RelationType.ASSISTANT, label: null });
|
||||
expect(relationName(RelationType.BROTHER, null)).toBe('sibling');
|
||||
expect(relationName(RelationType.REFERRED_BY, null)).toBe('contact');
|
||||
expect(relationName(RelationType.CUSTOM, 'Emergency')).toBe('emergency');
|
||||
});
|
||||
});
|
||||
|
||||
describe('contacts name and postal helpers', () => {
|
||||
it('fills the family name from surname and surname2, the suffix from the first of its kinds, and a missing `full` with deriveFullName', () => {
|
||||
// deriveFullName (the app's) leaves out surname2 and credential.
|
||||
expect(nameCells({ components: [{ kind: 'surname', value: 'García' }, { kind: 'surname2', value: 'López' }, { kind: 'credential', value: 'MD' }] }))
|
||||
.toMatchObject({ data3: 'García López', data6: 'MD', data1: 'García' });
|
||||
});
|
||||
|
||||
it('knows a derived `full` in the app’s and in Stalwart’s form', () => {
|
||||
const components = [{ kind: 'given', value: 'A' }, { kind: 'surname', value: 'B' }, { kind: 'surname2', value: 'C' }];
|
||||
expect(fullIsDerived({ components, full: 'A B' })).toBe(true);
|
||||
expect(fullIsDerived({ components, full: 'A B C' })).toBe(true);
|
||||
expect(fullIsDerived({ components, full: 'B, A' })).toBe(false);
|
||||
});
|
||||
|
||||
it('reads the parts of a rewritten one-line address back along the old line', () => {
|
||||
// Apple's layout: street / city region postcode / country.
|
||||
const old = { data1: '1 Infinite Loop\nCupertino CA 95014\nUSA', data4: '1 Infinite Loop', data7: 'Cupertino', data8: 'CA', data9: '95014', data10: 'USA' };
|
||||
const parts = (line: string) => {
|
||||
const out = resplitPostal(old, line);
|
||||
return out && { street: out.data4, city: out.data7, region: out.data8, postcode: out.data9, country: out.data10 };
|
||||
};
|
||||
expect(parts('1 Apple Park Way\nCupertino CA 95014\nUSA')).toEqual({ street: '1 Apple Park Way', city: 'Cupertino', region: 'CA', postcode: '95014', country: 'USA' });
|
||||
expect(parts('1 Infinite Loop\nSan Jose CA 95014\nUSA')).toMatchObject({ city: 'San Jose', region: 'CA', postcode: '95014' });
|
||||
expect(parts('1 Infinite Loop\nCupertino 95014\nUSA')).toMatchObject({ city: 'Cupertino', region: null, postcode: '95014' });
|
||||
expect(parts('1 Infinite Loop, Cupertino CA 95014, USA')).toMatchObject({ street: '1 Infinite Loop', city: 'Cupertino' });
|
||||
// Two parts of one segment changed, or the layout changed: nothing to pin the words to.
|
||||
expect(parts('1 Infinite Loop\nSan Jose NV 95014\nUSA')).toBeNull();
|
||||
expect(parts('1 Infinite Loop\nCupertino CA 95014\nCalifornia\nUSA')).toBeNull();
|
||||
// An old line that isn't made of the parts (a `full` written apart from its components).
|
||||
expect(resplitPostal({ ...old, data1: 'Apple HQ, Cupertino' }, 'Apple Park, Cupertino')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps separators between street parts and falls back to the legacy flat fields', () => {
|
||||
expect(postalCells({ street: 'Old Rd 1', locality: 'Town' })).toMatchObject({ data4: 'Old Rd 1', data7: 'Town' });
|
||||
const address = { components: [{ kind: 'number', value: '12' }, { kind: 'separator', value: '-' }, { kind: 'name', value: 'Rue X' }] };
|
||||
expect(postalCells(address).data4).toBe('12-Rue X');
|
||||
expect(editPostalComponents(address, { data4: 'Rue Y 3' }, new Set(['data4']))).toEqual([{ kind: 'name', value: 'Rue Y 3' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('contacts photos', () => {
|
||||
it('decodes base64 and data URIs, also without a media type', () => {
|
||||
expect(base64ToBytes('AAECAw==')).toEqual(new Uint8Array([0, 1, 2, 3]));
|
||||
expect(base64ToBytes('!!')).toBeNull();
|
||||
expect(dataUriBase64(`data:image/jpeg;base64,${JPEG}`)).toBe(JPEG);
|
||||
expect(dataUriBase64(`data:base64,${JPEG}`)).toBe(JPEG);
|
||||
expect(dataUriBase64('https://example.org/p.jpg')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps hashes, not bytes, in the shadow and leaves blob-backed entries alone', () => {
|
||||
const card = { id: 'e1', media: { a: { kind: 'photo', uri: `data:image/jpeg;base64,${JPEG}` }, b: { kind: 'logo', blobId: 'B1' } } } as unknown as ContactCardWire;
|
||||
const shadow = shadowOf(card);
|
||||
expect((shadow.media as Record<string, { uri?: string }>).a.uri).toMatch(/^sha256:[0-9a-f]{64}$/);
|
||||
expect((shadow.media as Record<string, unknown>).b).toEqual({ kind: 'logo', blobId: 'B1' });
|
||||
expect((card.media as Record<string, { uri?: string }>).a.uri).toContain('base64');
|
||||
});
|
||||
});
|
||||
|
||||
describe('contacts rows of kinds the planner does not map', () => {
|
||||
it('ignores them on download and upload', async () => {
|
||||
const { Harness } = await import('./harness');
|
||||
const { appleCard } = await import('./fixtures');
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(appleCard());
|
||||
h.device.user.insertData(rawId, { [Data.MIMETYPE]: 'vnd.android.cursor.item/im', data1: 'anna@jabber.example' });
|
||||
expect(h.planner.planUpload(await h.contact(rawId), h.ctx).kind).toBe('clean');
|
||||
expect(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx).effect).toBe('none');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,418 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Data, MimeType, RawContacts } from '../../android-columns';
|
||||
import { contactFingerprint } from '../../contacts/planner';
|
||||
import type { Row } from '../../types';
|
||||
import { appleCard, googleCard, JPEG2, probeCard } from './fixtures';
|
||||
import { ACCOUNT, Harness, JMAP } from './harness';
|
||||
|
||||
/**
|
||||
* The rows Fossify Contacts writes back on save: every kind it models
|
||||
* re-inserted without DATA_SYNC, IS_PRIMARY or the columns it has no field
|
||||
* for (a department, a job description, a website's type), one nickname and
|
||||
* one organization only.
|
||||
*/
|
||||
function fossifyRows(h: Harness, rawId: number, edit: (rows: Row[]) => void = () => {}): Row[] {
|
||||
const out: Row[] = [];
|
||||
let nickname = false;
|
||||
let organization = false;
|
||||
for (const r of h.device.rows('data', 'raw_contact_id = ?', [rawId])) {
|
||||
const pick = (...columns: string[]) => {
|
||||
const row: Row = { [Data.MIMETYPE]: r[Data.MIMETYPE] };
|
||||
for (const c of columns) row[c] = r[c];
|
||||
out.push(row);
|
||||
};
|
||||
switch (r[Data.MIMETYPE]) {
|
||||
case MimeType.NICKNAME: if (!nickname) pick('data1'); nickname = true; break;
|
||||
case MimeType.PHONE: case MimeType.EMAIL: pick('data1', 'data2', 'data3'); break;
|
||||
case MimeType.STRUCTURED_POSTAL: pick('data1', 'data2'); break;
|
||||
case MimeType.EVENT: pick('data1', 'data2'); break;
|
||||
case MimeType.NOTE: pick('data1'); break;
|
||||
case MimeType.ORGANIZATION: if (!organization) pick('data1', 'data4'); organization = true; break;
|
||||
case MimeType.WEBSITE: out.push({ [Data.MIMETYPE]: MimeType.WEBSITE, data1: r.data1, data2: 1 }); break;
|
||||
case MimeType.GROUP_MEMBERSHIP: pick('data1'); break;
|
||||
}
|
||||
}
|
||||
edit(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function uploadPlan(h: Harness, rawId: number) {
|
||||
return h.planner.planUpload(await h.contact(rawId), h.ctx);
|
||||
}
|
||||
|
||||
async function patchOf(h: Harness, rawId: number) {
|
||||
const plan = await uploadPlan(h, rawId);
|
||||
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'update') throw new Error(`expected an update, got ${plan.kind}`);
|
||||
return plan.actions[0].patch;
|
||||
}
|
||||
|
||||
describe('contacts upload: Fossify re-inserts rows', () => {
|
||||
it('uploads nothing for a save without changes, then restores what it dropped', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(probeCard());
|
||||
h.device.user.fossifySave(rawId, null, fossifyRows(h, rawId));
|
||||
const plan = await uploadPlan(h, rawId);
|
||||
expect(plan.kind).toBe('clean');
|
||||
await h.upload(rawId);
|
||||
const rows = h.rowsByKey(rawId);
|
||||
expect(rows['organizations:o1|titles:t1,r1']).toMatchObject({ data1: 'ACME', data4: 'Chief Prober', data5: 'R&D', data6: 'Lead' });
|
||||
expect(Number(rows['links:l1'].data2)).toBe(5);
|
||||
expect(Number(rows['emails:work1'].is_primary)).toBe(1);
|
||||
expect(h.dirty(rawId)).toBe(false);
|
||||
expect(h.planner.planBaselineHeal(await h.contact(rawId))).toBeNull();
|
||||
});
|
||||
|
||||
it('never clears the department it drops (a value-matched row has no baseline)', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(probeCard());
|
||||
h.device.user.fossifySave(rawId, null, fossifyRows(h, rawId, (rows) => {
|
||||
rows.find((r) => r[Data.MIMETYPE] === MimeType.ORGANIZATION)!.data4 = 'Chief Tester';
|
||||
}));
|
||||
expect(await patchOf(h, rawId)).toEqual({ 'titles/t1/name': 'Chief Tester' });
|
||||
});
|
||||
|
||||
it('uploads the one email it changed, matching the others by value', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
h.device.user.fossifySave(rawId, null, fossifyRows(h, rawId, (rows) => {
|
||||
rows.find((r) => r.data1 === 'anna@work.example')!.data1 = 'anna@new.example';
|
||||
}));
|
||||
expect(await patchOf(h, rawId)).toEqual({ 'emails/k2/address': 'anna@new.example' });
|
||||
});
|
||||
|
||||
it('keeps the second nickname it could not show, and puts it back on the device', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(googleCard());
|
||||
h.device.user.fossifySave(rawId, null, fossifyRows(h, rawId));
|
||||
expect((await uploadPlan(h, rawId)).kind).toBe('clean');
|
||||
await h.upload(rawId);
|
||||
expect(h.rowsByKey(rawId)['nicknames:k2'].data1).toBe('GKG');
|
||||
expect(h.card(id).nicknames).toEqual(googleCard().nicknames);
|
||||
});
|
||||
|
||||
it('does not delete an entry it rewrote away (a documented limitation)', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
h.device.user.fossifySave(rawId, null, fossifyRows(h, rawId, (rows) => {
|
||||
rows.splice(rows.findIndex((r) => r.data1 === 'anna@work.example'), 1);
|
||||
}));
|
||||
expect((await uploadPlan(h, rawId)).kind).toBe('clean');
|
||||
});
|
||||
});
|
||||
|
||||
describe('contacts upload: deletions (in-place editor)', () => {
|
||||
it('resends the map minus the entry, its other entries as the server has them', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(probeCard());
|
||||
h.device.user.deleteData(h.rowsByKey(rawId)['emails:zz9']._id as number);
|
||||
const { emails } = h.card(id) as { emails: Record<string, unknown> };
|
||||
expect(await patchOf(h, rawId)).toEqual({ emails: { work1: emails.work1, 'x/y': emails['x/y'] } });
|
||||
});
|
||||
|
||||
it('deletes an address with a null (clean on Stalwart)', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(probeCard());
|
||||
h.device.user.deleteData(h.rowsByKey(rawId)['addresses:home']._id as number);
|
||||
expect(await patchOf(h, rawId)).toEqual({ 'addresses/home': null });
|
||||
});
|
||||
|
||||
it('keeps the notes after the first when the note is deleted', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(probeCard());
|
||||
h.device.user.deleteData(h.rowsByKey(rawId)['notes:n1']._id as number);
|
||||
expect(await patchOf(h, rawId)).toEqual({ notes: { n2: { note: 'second note' } } });
|
||||
});
|
||||
|
||||
it('removes a map whose last entry went, when other rows show an in-place editor', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(probeCard());
|
||||
h.device.user.deleteData(h.rowsByKey(rawId)['nicknames:nk']._id as number);
|
||||
expect(await patchOf(h, rawId)).toEqual({ nicknames: null });
|
||||
});
|
||||
|
||||
it('deletes an organization with its titles', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(probeCard());
|
||||
h.device.user.deleteData(h.rowsByKey(rawId)['organizations:o1|titles:t1,r1']._id as number);
|
||||
expect(await patchOf(h, rawId)).toEqual({ organizations: null, titles: null });
|
||||
});
|
||||
|
||||
it('deletes the photo', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(probeCard());
|
||||
h.device.user.deleteData(h.rowsByKey(rawId)['media:ph']._id as number);
|
||||
expect(await patchOf(h, rawId)).toEqual({ media: null });
|
||||
});
|
||||
|
||||
it('deletes a relation by resending relatedTo (Stalwart ignores a null there)', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(probeCard());
|
||||
h.device.user.deleteData(h.rowsByKey(rawId)['relatedTo:urn:uuid:other-person']._id as number);
|
||||
expect(await patchOf(h, rawId)).toEqual({ relatedTo: null });
|
||||
});
|
||||
|
||||
it('folds an edit into a map it resends', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(probeCard());
|
||||
const rows = h.rowsByKey(rawId);
|
||||
h.device.user.deleteData(rows['emails:zz9']._id as number);
|
||||
h.device.user.updateData(rows['emails:work1']._id as number, { data1: 'new@work.example' });
|
||||
const { emails } = h.card(id) as unknown as { emails: Record<string, Record<string, unknown>> };
|
||||
expect(await patchOf(h, rawId)).toEqual({ emails: { work1: { ...emails.work1, address: 'new@work.example' }, 'x/y': emails['x/y'] } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('contacts upload: additions', () => {
|
||||
it('adds an entry to an existing map under a new key', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(probeCard());
|
||||
h.device.user.insertData(rawId, { [Data.MIMETYPE]: MimeType.EMAIL, data1: 'added@example.org', data2: 2 });
|
||||
const patch = await patchOf(h, rawId);
|
||||
const [pointer] = Object.keys(patch);
|
||||
expect(pointer).toMatch(/^emails\/b[0-9a-z]{8}$/);
|
||||
expect(patch[pointer]).toEqual({ address: 'added@example.org', contexts: { work: true } });
|
||||
});
|
||||
|
||||
it('sends the whole map for the first entry of a map', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
h.device.user.insertData(rawId, { [Data.MIMETYPE]: MimeType.WEBSITE, data1: 'https://anna.example', data2: 7 });
|
||||
const patch = await patchOf(h, rawId);
|
||||
expect(Object.keys(patch)).toEqual(['links']);
|
||||
expect(Object.values(patch.links as object)).toEqual([{ uri: 'https://anna.example' }]);
|
||||
});
|
||||
|
||||
it('adds a work phone with its features and contexts', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(probeCard());
|
||||
h.device.user.insertData(rawId, { [Data.MIMETYPE]: MimeType.PHONE, data1: '+49 89 1', data2: 3 });
|
||||
expect(Object.values(await patchOf(h, rawId))).toEqual([{ number: '+49 89 1', features: { voice: true }, contexts: { work: true } }]);
|
||||
});
|
||||
|
||||
it('adds an organization and a title that points at it', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
h.device.user.insertData(rawId, { [Data.MIMETYPE]: MimeType.ORGANIZATION, data1: 'Apple', data4: 'Engineer' });
|
||||
const patch = await patchOf(h, rawId) as { organizations: Record<string, unknown>; titles: Record<string, Record<string, unknown>> };
|
||||
const [orgKey] = Object.keys(patch.organizations);
|
||||
expect(patch.organizations[orgKey]).toEqual({ name: 'Apple' });
|
||||
expect(Object.values(patch.titles)).toEqual([{ name: 'Engineer', kind: 'title', organizationId: orgKey }]);
|
||||
});
|
||||
|
||||
it('adds a relation keyed by its name', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(probeCard());
|
||||
h.device.user.insertData(rawId, { [Data.MIMETYPE]: MimeType.RELATION, data1: 'Jane Doe', data2: 14 });
|
||||
expect(await patchOf(h, rawId)).toEqual({ 'relatedTo/Jane Doe': { relation: { spouse: true } } });
|
||||
});
|
||||
|
||||
it('adds a photo from the device as a JPEG data URI', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
h.device.user.setPhoto(rawId, JPEG2);
|
||||
h.devicePhotos.set(rawId, JPEG2);
|
||||
const patch = await patchOf(h, rawId);
|
||||
expect(Object.values(patch.media as object)).toEqual([{ kind: 'photo', uri: `data:image/jpeg;base64,${JPEG2}`, mediaType: 'image/jpeg' }]);
|
||||
});
|
||||
|
||||
it('replaces a changed photo in place', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(probeCard());
|
||||
h.device.user.setPhoto(rawId, JPEG2);
|
||||
h.devicePhotos.set(rawId, JPEG2);
|
||||
expect(await patchOf(h, rawId)).toEqual({ 'media/ph/uri': `data:image/jpeg;base64,${JPEG2}`, 'media/ph/mediaType': 'image/jpeg' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('contacts upload: new contacts', () => {
|
||||
function insertNew(h: Harness): number {
|
||||
return h.device.user.insertContact(ACCOUNT, [
|
||||
{ [Data.MIMETYPE]: MimeType.STRUCTURED_NAME, data1: 'Neu Person', data2: 'Neu', data3: 'Person' },
|
||||
{ [Data.MIMETYPE]: MimeType.EMAIL, data1: 'neu@example.org', data2: 1, is_primary: 1 },
|
||||
{ [Data.MIMETYPE]: MimeType.PHONE, data1: '+49 1', data2: 2 },
|
||||
{ [Data.MIMETYPE]: MimeType.STRUCTURED_POSTAL, data1: 'Weg 1, 12345 Ort', data2: 1, data4: 'Weg 1', data7: 'Ort', data9: '12345' },
|
||||
{ [Data.MIMETYPE]: MimeType.EVENT, data1: '1990-01-02', data2: 3 },
|
||||
{ [Data.MIMETYPE]: MimeType.NOTE, data1: 'hello' },
|
||||
{ [Data.MIMETYPE]: MimeType.NICKNAME, data1: 'Neuling' },
|
||||
]);
|
||||
}
|
||||
|
||||
it('claims a uid and target first, then creates the card from its rows', async () => {
|
||||
const h = new Harness();
|
||||
const rawId = insertNew(h);
|
||||
const claim = h.planner.planUpload(await h.contact(rawId), h.ctx);
|
||||
expect(claim.kind).toBe('claim');
|
||||
await h.applyOk((claim as { ops: import('../../planner').OpGroup }).ops);
|
||||
const pending = (await h.contact(rawId)).pending!;
|
||||
expect(pending.target).toBe(`${JMAP}/${h.book}`);
|
||||
const plan = h.planner.planUpload(await h.contact(rawId), h.ctx);
|
||||
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'create') throw new Error(plan.kind);
|
||||
const { object, uid, collectionId } = plan.actions[0];
|
||||
expect(uid).toBe(pending.uid);
|
||||
expect(collectionId).toBe(h.book);
|
||||
const values = (map: unknown) => Object.values(map as object);
|
||||
expect(object).toMatchObject({ '@type': 'Card', version: '1.0', uid: pending.uid, addressBookIds: { [h.book]: true } });
|
||||
expect(object.name).toEqual({ components: [{ kind: 'given', value: 'Neu' }, { kind: 'surname', value: 'Person' }], full: 'Neu Person' });
|
||||
expect(values(object.emails)).toEqual([{ address: 'neu@example.org', contexts: { private: true }, pref: 1 }]);
|
||||
expect(values(object.phones)).toEqual([{ number: '+49 1', features: { mobile: true } }]);
|
||||
expect(values(object.addresses)).toEqual([{
|
||||
components: [{ kind: 'name', value: 'Weg 1' }, { kind: 'locality', value: 'Ort' }, { kind: 'postcode', value: '12345' }],
|
||||
full: 'Weg 1, 12345 Ort',
|
||||
contexts: { private: true },
|
||||
}]);
|
||||
expect(values(object.anniversaries)).toEqual([{ kind: 'birth', date: { '@type': 'PartialDate', year: 1990, month: 1, day: 2 } }]);
|
||||
expect(values(object.notes)).toEqual([{ note: 'hello' }]);
|
||||
expect(values(object.nicknames)).toEqual([{ name: 'Neuling' }]);
|
||||
expect(Object.keys(object).sort()).toEqual([
|
||||
'@type', 'addressBookIds', 'addresses', 'anniversaries', 'emails', 'name', 'nicknames', 'notes', 'phones', 'uid', 'version',
|
||||
]);
|
||||
});
|
||||
|
||||
it('takes the identity after the create and writes nothing when the card comes back', async () => {
|
||||
const h = new Harness();
|
||||
const rawId = insertNew(h);
|
||||
await h.upload(rawId);
|
||||
const local = await h.contact(rawId);
|
||||
expect(local).toMatchObject({ dirty: false, pending: null });
|
||||
expect(local.sourceId).toMatch(/^c\//);
|
||||
expect(local.rows.every((r) => r.key !== null)).toBe(true);
|
||||
const plan = h.planner.planDownload(h.card(h.cardIdOf(local)), local, h.ctx);
|
||||
expect(plan.effect).toBe('none');
|
||||
});
|
||||
|
||||
it('is skipped when no writable book is selected', async () => {
|
||||
const h = new Harness();
|
||||
const rawId = insertNew(h);
|
||||
const plan = h.planner.planUpload(await h.contact(rawId), { ...h.ctx, createTarget: () => null });
|
||||
expect(plan).toEqual({ kind: 'skip', reason: 'noWritableAddressBook' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('contacts upload: deleted contacts', () => {
|
||||
it('destroys the card, then purges the rows', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(appleCard());
|
||||
h.device.user.deleteContact(rawId);
|
||||
const plan = await uploadPlan(h, rawId);
|
||||
expect(plan).toEqual({ kind: 'upload', actions: [{ kind: 'destroy', id, uid: appleCard().uid }] });
|
||||
await h.upload(rawId);
|
||||
expect(h.device.row('raw_contacts', rawId)).toBeUndefined();
|
||||
expect(h.server.get('ContactCard', JMAP, id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('only leaves the synced book of a card that is also in an unsynced one', async () => {
|
||||
const h = new Harness();
|
||||
const other = h.server.addAddressBook(JMAP, { name: 'Other' });
|
||||
h.unselected.add(`${JMAP}/${other}`);
|
||||
const { rawId } = await h.seed({ ...appleCard(), addressBookIds: { [h.book]: true, [other]: true } });
|
||||
h.device.user.deleteContact(rawId);
|
||||
expect(await patchOf(h, rawId)).toEqual({ [`addressBookIds/${h.book}`]: null });
|
||||
});
|
||||
|
||||
it('only leaves the synced book when the server filed the card in an unsynced one after the device deleted it', async () => {
|
||||
const h = new Harness();
|
||||
const archive = h.server.addAddressBook(JMAP, { name: 'Archive' });
|
||||
h.unselected.add(`${JMAP}/${archive}`);
|
||||
const { id, rawId } = await h.seed(appleCard());
|
||||
h.device.user.deleteContact(rawId);
|
||||
h.server.serverUpdate('ContactCard', JMAP, id, { [`addressBookIds/${archive}`]: true, 'emails/k1/address': 'new@example.com' });
|
||||
const rows = JSON.stringify(h.device.rows('data', 'raw_contact_id = ?', [rawId]));
|
||||
await h.applyOk(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx).ops);
|
||||
// The delete wins over the server's edit: the rows stay as they were, only the shadow follows.
|
||||
expect(JSON.stringify(h.device.rows('data', 'raw_contact_id = ?', [rawId]))).toBe(rows);
|
||||
expect(await uploadPlan(h, rawId)).toEqual({ kind: 'upload', actions: [{ kind: 'update', id, patch: { [`addressBookIds/${h.book}`]: null } }] });
|
||||
await h.upload(rawId);
|
||||
expect(h.server.get('ContactCard', JMAP, id)).toMatchObject({ addressBookIds: { [archive]: true } });
|
||||
expect(h.device.row('raw_contacts', rawId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves a card the server moved out of every synced book before the deletion reached it, and only purges the rows', async () => {
|
||||
const h = new Harness();
|
||||
const archive = h.server.addAddressBook(JMAP, { name: 'Archive' });
|
||||
h.unselected.add(`${JMAP}/${archive}`);
|
||||
const { id, rawId } = await h.seed(appleCard());
|
||||
h.device.user.deleteContact(rawId);
|
||||
h.server.serverUpdate('ContactCard', JMAP, id, { addressBookIds: { [archive]: true } });
|
||||
await h.applyOk(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx).ops);
|
||||
expect(await uploadPlan(h, rawId)).toMatchObject({ kind: 'purge' });
|
||||
await h.upload(rawId);
|
||||
expect(h.device.row('raw_contacts', rawId)).toBeUndefined();
|
||||
expect(h.server.get('ContactCard', JMAP, id)).toMatchObject({ addressBookIds: { [archive]: true } });
|
||||
});
|
||||
|
||||
it('still destroys a card the server moved to another synced book, or whose book was deselected before the upload', async () => {
|
||||
const moved = new Harness();
|
||||
const work = moved.server.addAddressBook(JMAP, { name: 'Work' });
|
||||
const a = await moved.seed(appleCard());
|
||||
moved.device.user.deleteContact(a.rawId);
|
||||
moved.server.serverUpdate('ContactCard', JMAP, a.id, { addressBookIds: { [work]: true } });
|
||||
await moved.applyOk(moved.planner.planDownload(moved.card(a.id), await moved.contact(a.rawId), moved.ctx).ops);
|
||||
expect(await uploadPlan(moved, a.rawId)).toEqual({ kind: 'upload', actions: [{ kind: 'destroy', id: a.id, uid: appleCard().uid }] });
|
||||
|
||||
const deselected = new Harness();
|
||||
const b = await deselected.seed(appleCard());
|
||||
deselected.device.user.deleteContact(b.rawId);
|
||||
deselected.unselected.add(`${JMAP}/${deselected.book}`);
|
||||
expect(await uploadPlan(deselected, b.rawId)).toEqual({ kind: 'upload', actions: [{ kind: 'destroy', id: b.id, uid: appleCard().uid }] });
|
||||
});
|
||||
|
||||
it('deletes its own create by id once the download found it, not by a uid lookup', async () => {
|
||||
const h = new Harness();
|
||||
const rawId = h.device.user.insertContact(ACCOUNT, [{ [Data.MIMETYPE]: MimeType.NOTE, data1: 'y' }]);
|
||||
const plan = await h.planUpload(rawId);
|
||||
if (plan.kind !== 'upload') throw new Error(plan.kind);
|
||||
// The create went through but its response was lost; then the user deleted the contact.
|
||||
const id = h.send(plan.actions)!;
|
||||
h.device.user.deleteContact(rawId);
|
||||
await h.applyOk(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx).ops);
|
||||
expect(await h.contact(rawId)).toMatchObject({ sourceId: `${JMAP}/${id}`, pending: null, deleted: true });
|
||||
expect(await uploadPlan(h, rawId)).toEqual({ kind: 'upload', actions: [{ kind: 'destroy', id, uid: h.card(id).uid }] });
|
||||
});
|
||||
|
||||
it('purges a new contact that never reached the server, and destroys a claimed one by uid', async () => {
|
||||
const h = new Harness();
|
||||
const fresh = h.device.user.insertContact(ACCOUNT, [{ [Data.MIMETYPE]: MimeType.NOTE, data1: 'x' }]);
|
||||
h.device.user.deleteContact(fresh);
|
||||
expect((await uploadPlan(h, fresh)).kind).toBe('purge');
|
||||
const claimed = h.device.user.insertContact(ACCOUNT, [{ [Data.MIMETYPE]: MimeType.NOTE, data1: 'y' }]);
|
||||
await h.planUpload(claimed);
|
||||
const uid = (await h.contact(claimed)).pending!.uid;
|
||||
h.device.user.deleteContact(claimed);
|
||||
expect(await uploadPlan(h, claimed)).toEqual({ kind: 'upload', actions: [{ kind: 'destroy', id: null, uid }] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('contacts upload: read-only and poisoned contacts', () => {
|
||||
it('reverts an edit of a read-only contact from the shadow', async () => {
|
||||
const h = new Harness();
|
||||
h.readOnlyBooks.add(h.book);
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
h.device.user.updateData(h.rowsByKey(rawId)['emails:k1']._id as number, { data1: 'hacked@example.org' });
|
||||
const plan = await uploadPlan(h, rawId);
|
||||
expect(plan).toMatchObject({ kind: 'revert', reason: 'readOnly' });
|
||||
await h.upload(rawId);
|
||||
expect(h.rowsByKey(rawId)['emails:k1'].data1).toBe('anna@example.com');
|
||||
expect(h.dirty(rawId)).toBe(false);
|
||||
});
|
||||
|
||||
it('purges a deleted read-only contact and asks for it again', async () => {
|
||||
const h = new Harness();
|
||||
h.readOnlyBooks.add(h.book);
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
h.device.user.deleteContact(rawId);
|
||||
expect(await uploadPlan(h, rawId)).toMatchObject({ kind: 'revert', refetch: true, reason: 'readOnly' });
|
||||
});
|
||||
|
||||
it('skips a poisoned contact until it changes or the back-off ends', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
const email = h.rowsByKey(rawId)['emails:k1']._id as number;
|
||||
h.device.user.updateData(email, { data1: 'bad@example.org' });
|
||||
const poison = { fp: contactFingerprint(await h.contact(rawId)), type: 'invalidProperties', n: 1, until: h.ctx.now + 3_600_000 };
|
||||
await h.applyOk({ ref: 'x', ops: [{ op: 'update', table: 'raw_contacts', id: rawId, values: { [RawContacts.SYNC4]: JSON.stringify(poison) } }] });
|
||||
expect(await uploadPlan(h, rawId)).toEqual({ kind: 'skip', reason: 'poisoned:invalidProperties' });
|
||||
expect(h.planner.planUpload(await h.contact(rawId), { ...h.ctx, now: poison.until + 1 }).kind).toBe('upload');
|
||||
h.device.user.updateData(email, { data1: 'good@example.org' });
|
||||
expect((await uploadPlan(h, rawId)).kind).toBe('upload');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,372 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Data, EmailType, MimeType, PhoneType, WebsiteType } from '../../android-columns';
|
||||
import { deepEqual } from '../../common/json';
|
||||
import { applyPatch, assertPatchWellFormed, type PatchObject } from '../../common/patch';
|
||||
import type { ContactCardWire } from '../../planner';
|
||||
import type { Row } from '../../types';
|
||||
import { appleCard, googleCard, probeCard } from './fixtures';
|
||||
import { Harness } from './harness';
|
||||
|
||||
/** Card data Android has no column for: every patch must leave it as it was. */
|
||||
const UNMAPPED = [
|
||||
'onlineServices', 'speakToAs', 'keywords', 'personalInfo', 'preferredLanguages', 'notes/n2', 'name/phoneticSystem',
|
||||
'addresses/home/coordinates', 'addresses/home/timeZone', 'addresses/home/countryCode', 'kind', 'uid',
|
||||
];
|
||||
|
||||
function at(card: unknown, path: string): unknown {
|
||||
return path.split('/').reduce<unknown>((v, k) => (v && typeof v === 'object' ? (v as Record<string, unknown>)[k] : undefined), card);
|
||||
}
|
||||
|
||||
/** Seeds a card, edits one row in place like AOSP Contacts, and returns the upload patch. */
|
||||
async function editedPatch(make: () => Record<string, unknown>, key: string, values: Row): Promise<{ patch: PatchObject; card: ContactCardWire }> {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(make());
|
||||
const row = h.rowsByKey(rawId)[key];
|
||||
if (!row) throw new Error(`no row ${key}`);
|
||||
h.device.user.updateData(row._id as number, values);
|
||||
const plan = h.planner.planUpload(await h.contact(rawId), h.ctx);
|
||||
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'update') throw new Error(`expected an update, got ${plan.kind}`);
|
||||
return { patch: plan.actions[0].patch, card: h.card(id) };
|
||||
}
|
||||
|
||||
const probeEdits: Array<[string, string, Row, PatchObject]> = [
|
||||
['email address', 'emails:work1', { data1: 'new@work.example' }, { 'emails/work1/address': 'new@work.example' }],
|
||||
['email type (home → work)', 'emails:zz9', { data2: 2 }, { 'emails/zz9/contexts': { work: true } }],
|
||||
['email custom label', 'emails:work1', { data3: 'Desk' }, { 'emails/work1/label': 'Desk' }],
|
||||
['a key with a slash', 'emails:x/y', { data1: 'slash2@example.org' }, { 'emails/x~1y/address': 'slash2@example.org' }],
|
||||
['phone number', 'phones:p-a', { data1: '+49 30 5555' }, { 'phones/p-a/number': '+49 30 5555' }],
|
||||
['phone type (mobile → work mobile)', 'phones:p-a', { data2: 17 }, { 'phones/p-a/contexts': { work: true }, 'phones/p-a/features': { mobile: true } }],
|
||||
['nickname', 'nicknames:nk', { data1: 'Probster' }, { 'nicknames/nk/name': 'Probster' }],
|
||||
['street', 'addresses:home', { data4: 'Hauptstr. 1' }, {
|
||||
'addresses/home/components': [
|
||||
{ kind: 'name', value: 'Hauptstr. 1' },
|
||||
{ kind: 'locality', value: 'Berlin' },
|
||||
{ kind: 'postcode', value: '10115' },
|
||||
{ kind: 'country', value: 'Germany' },
|
||||
],
|
||||
}],
|
||||
['city', 'addresses:home', { data7: 'Potsdam' }, {
|
||||
'addresses/home/components': [
|
||||
{ kind: 'name', value: 'Main St' }, { kind: 'separator', value: ' ' }, { kind: 'number', value: '12' },
|
||||
{ kind: 'locality', value: 'Potsdam' }, { kind: 'postcode', value: '10115' }, { kind: 'country', value: 'Germany' },
|
||||
{ kind: 'apartment', value: '4b' },
|
||||
],
|
||||
}],
|
||||
['region, which the address did not have', 'addresses:home', { data8: 'Berlin' }, {
|
||||
'addresses/home/components': [
|
||||
{ kind: 'name', value: 'Main St' }, { kind: 'separator', value: ' ' }, { kind: 'number', value: '12' },
|
||||
{ kind: 'locality', value: 'Berlin' }, { kind: 'region', value: 'Berlin' }, { kind: 'postcode', value: '10115' },
|
||||
{ kind: 'country', value: 'Germany' }, { kind: 'apartment', value: '4b' },
|
||||
],
|
||||
}],
|
||||
['formatted address', 'addresses:home', { data1: 'Main St 12\n10115 Berlin' }, { 'addresses/home/full': 'Main St 12\n10115 Berlin' }],
|
||||
['address type (home → work)', 'addresses:home', { data2: 2 }, { 'addresses/home/contexts': { work: true } }],
|
||||
['company', 'organizations:o1|titles:t1,r1', { data1: 'ACME Corp' }, { 'organizations/o1/name': 'ACME Corp' }],
|
||||
['department', 'organizations:o1|titles:t1,r1', { data5: 'QA, Tools' }, { 'organizations/o1/units': [{ name: 'QA' }, { name: 'Tools' }] }],
|
||||
['job title', 'organizations:o1|titles:t1,r1', { data4: 'Chief Tester' }, { 'titles/t1/name': 'Chief Tester' }],
|
||||
['job description (role)', 'organizations:o1|titles:t1,r1', { data6: 'Mentor' }, { 'titles/r1/name': 'Mentor' }],
|
||||
['website', 'links:l1', { data1: 'https://probe2.example' }, { 'links/l1/uri': 'https://probe2.example' }],
|
||||
['website type (work → home)', 'links:l1', { data2: 4 }, { 'links/l1/contexts': { private: true } }],
|
||||
['birthday date', 'anniversaries:bday', { data1: '--03-15' }, { 'anniversaries/bday/date': { '@type': 'PartialDate', month: 3, day: 15 } }],
|
||||
['anniversary with a year', 'anniversaries:wed', { data1: '2011-06-01' }, { 'anniversaries/wed/date': { '@type': 'PartialDate', year: 2011, month: 6, day: 1 } }],
|
||||
['anniversary type (birthday → other)', 'anniversaries:bday', { data2: 2 }, { 'anniversaries/bday/kind': 'other' }],
|
||||
['relation type (friend → spouse)', 'relatedTo:urn:uuid:other-person', { data2: 14 }, { 'relatedTo/urn:uuid:other-person/relation': { spouse: true } }],
|
||||
['relation type without an RFC twin (brother)', 'relatedTo:urn:uuid:other-person', { data2: 2 }, { 'relatedTo/urn:uuid:other-person/relation': { sibling: true } }],
|
||||
['note', 'notes:n1', { data1: 'edited note' }, { 'notes/n1/note': 'edited note' }],
|
||||
];
|
||||
|
||||
describe('contacts upload: one edited field, one pointer', () => {
|
||||
it.each(probeEdits)('%s', async (_name, key, values, expected) => {
|
||||
const { patch, card } = await editedPatch(probeCard, key, values);
|
||||
expect(patch).toEqual(expected);
|
||||
assertPatchWellFormed(patch);
|
||||
const applied = applyPatch(card, patch);
|
||||
expect(applied).not.toBeNull();
|
||||
for (const path of UNMAPPED) expect(deepEqual(at(applied, path), at(card, path))).toBe(true);
|
||||
});
|
||||
|
||||
it('moves IS_PRIMARY as pref', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(probeCard());
|
||||
const rows = h.rowsByKey(rawId);
|
||||
h.device.user.updateData(rows['emails:x/y']._id as number, { is_primary: 1 });
|
||||
h.device.user.updateData(rows['emails:work1']._id as number, { is_primary: 0 });
|
||||
const plan = h.planner.planUpload(await h.contact(rawId), h.ctx);
|
||||
expect(plan).toMatchObject({ kind: 'upload', actions: [{ kind: 'update', patch: { 'emails/x~1y/pref': 1, 'emails/work1/pref': null } }] });
|
||||
});
|
||||
|
||||
it('renames a relation by moving it to the new key (relatedTo is keyed by value)', async () => {
|
||||
const { patch } = await editedPatch(probeCard, 'relatedTo:urn:uuid:other-person', { data1: 'Jane Roe' });
|
||||
expect(patch).toEqual({ relatedTo: { 'Jane Roe': { relation: { friend: true } } } });
|
||||
});
|
||||
|
||||
it('keeps a free-text date the server cannot take on the device only', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(probeCard());
|
||||
h.device.user.updateData(h.rowsByKey(rawId)['anniversaries:bday']._id as number, { data1: 'next spring' });
|
||||
expect(h.planner.planUpload(await h.contact(rawId), h.ctx).kind).toBe('clean');
|
||||
});
|
||||
});
|
||||
|
||||
describe('contacts upload: every device type comes back as the device wrote it', () => {
|
||||
const cases = (types: Record<string, number>, custom: number) =>
|
||||
Object.entries(types).map(([name, type]) => [name, type, type === custom ? 'Boat' : null] as const);
|
||||
const kinds = [
|
||||
{ mimetype: MimeType.PHONE, value: '+49 30 1', map: 'phones', entry: { number: '+49 30 1', features: { voice: true } }, types: cases(PhoneType, PhoneType.CUSTOM) },
|
||||
{ mimetype: MimeType.EMAIL, value: 'typed@example.org', map: 'emails', entry: { address: 'typed@example.org' }, types: cases(EmailType, EmailType.CUSTOM) },
|
||||
{ mimetype: MimeType.WEBSITE, value: 'https://typed.example', map: 'links', entry: { uri: 'https://typed.example' }, types: cases(WebsiteType, WebsiteType.CUSTOM) },
|
||||
];
|
||||
const typeOf = (row: Row) => [Number(row.data2), row.data3 ?? null];
|
||||
|
||||
describe.each(kinds)('$map', ({ mimetype, value, map, entry, types }) => {
|
||||
it.each(types)('%s, on a contact created on the device', async (_name, type, label) => {
|
||||
const h = new Harness();
|
||||
const rawId = h.device.user.insertContact('usera@example.org', [
|
||||
{ [Data.MIMETYPE]: MimeType.STRUCTURED_NAME, data1: 'Typed Person', data2: 'Typed', data3: 'Person' },
|
||||
{ [Data.MIMETYPE]: mimetype, data1: value, data2: type, data3: label },
|
||||
]);
|
||||
await h.upload(rawId);
|
||||
const [row] = h.device.rows('data', 'raw_contact_id = ? AND mimetype = ?', [rawId, mimetype]);
|
||||
expect(typeOf(row)).toEqual([type, label]);
|
||||
const local = await h.contact(rawId);
|
||||
expect(local.dirty).toBe(false);
|
||||
expect(h.planner.planDownload(h.card(h.cardIdOf(local)), local, h.ctx).effect).toBe('none');
|
||||
});
|
||||
|
||||
it.each(types)('%s, chosen on the device for a synced entry', async (_name, type, label) => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed({ name: { full: 'Typed Person' }, [map]: { e1: entry } });
|
||||
h.device.user.updateData(h.rowsByKey(rawId)[`${map}:e1`]._id as number, { data2: type, data3: label });
|
||||
await h.upload(rawId);
|
||||
expect(typeOf(h.rowsByKey(rawId)[`${map}:e1`])).toEqual([type, label]);
|
||||
expect(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx).effect).toBe('none');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('contacts upload: an address edited as one line (AOSP Contacts)', () => {
|
||||
const munich = () => ({
|
||||
name: { full: 'Maria Muster' },
|
||||
addresses: {
|
||||
home: {
|
||||
contexts: { private: true },
|
||||
coordinates: 'geo:48.137,11.575',
|
||||
components: [
|
||||
{ kind: 'name', value: 'Marienplatz 1' },
|
||||
{ kind: 'postcode', value: '80331' },
|
||||
{ kind: 'locality', value: 'Munich' },
|
||||
{ kind: 'region', value: 'Bavaria' },
|
||||
{ kind: 'country', value: 'Germany' },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const LINE = 'Marienplatz 1, 80331 Munich, Bavaria, Germany';
|
||||
|
||||
/** AOSP writes the formatted address alone; the provider keeps it in STREET and empties the other parts. */
|
||||
async function editLine(line: string) {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed(munich());
|
||||
const row = h.rowsByKey(rawId)['addresses:home'];
|
||||
expect(row.data1).toBe(LINE);
|
||||
h.device.user.updateData(row._id as number, { data1: line, data4: null, data5: null, data6: null, data7: null, data8: null, data9: null, data10: null });
|
||||
expect(h.rowsByKey(rawId)['addresses:home']).toMatchObject({ data1: line, data4: line, data7: null });
|
||||
return { h, id, rawId };
|
||||
}
|
||||
|
||||
async function patchOf(h: Harness, rawId: number): Promise<PatchObject> {
|
||||
const plan = h.planner.planUpload(await h.contact(rawId), h.ctx);
|
||||
if (plan.kind !== 'upload' || plan.actions[0].kind !== 'update') throw new Error(`expected an update, got ${plan.kind}`);
|
||||
return plan.actions[0].patch;
|
||||
}
|
||||
|
||||
const components = (street: string, locality = 'Munich', postcode = '80331') => [
|
||||
{ kind: 'name', value: street },
|
||||
{ kind: 'postcode', value: postcode },
|
||||
{ kind: 'locality', value: locality },
|
||||
{ kind: 'region', value: 'Bavaria' },
|
||||
{ kind: 'country', value: 'Germany' },
|
||||
];
|
||||
|
||||
it('reads the parts back from the new line, so `full` and the components agree', async () => {
|
||||
const line = 'Industrieweg 42, 80331 Augsburg, Bavaria, Germany';
|
||||
const { h, id, rawId } = await editLine(line);
|
||||
expect(await patchOf(h, rawId)).toEqual({
|
||||
'addresses/home/components': components('Industrieweg 42', 'Augsburg'),
|
||||
'addresses/home/full': line,
|
||||
});
|
||||
await h.upload(rawId);
|
||||
expect(h.card(id).addresses!.home).toEqual({
|
||||
contexts: { private: true }, coordinates: 'geo:48.137,11.575', isOrdered: true, full: line,
|
||||
components: components('Industrieweg 42', 'Augsburg'),
|
||||
});
|
||||
// The device gets its parts back and settles.
|
||||
expect(h.rowsByKey(rawId)['addresses:home']).toMatchObject({ data1: line, data4: 'Industrieweg 42', data7: 'Augsburg', data9: '80331' });
|
||||
const local = await h.contact(rawId);
|
||||
expect(local.dirty).toBe(false);
|
||||
expect(h.planner.planDownload(h.card(id), local, h.ctx).effect).toBe('none');
|
||||
});
|
||||
|
||||
it('changes only the part whose words changed', async () => {
|
||||
const { h, rawId } = await editLine('Marienplatz 2, 80331 Munich, Bavaria, Germany');
|
||||
expect(await patchOf(h, rawId)).toEqual({
|
||||
'addresses/home/components': components('Marienplatz 2'),
|
||||
'addresses/home/full': 'Marienplatz 2, 80331 Munich, Bavaria, Germany',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the whole line as the street when the change can not be pinned to one part', async () => {
|
||||
const line = 'Industrieweg 42, 86150 Augsburg, Bavaria, Germany';
|
||||
const { h, id, rawId } = await editLine(line);
|
||||
expect(await patchOf(h, rawId)).toEqual({ 'addresses/home/components': [{ kind: 'name', value: line }], 'addresses/home/full': line });
|
||||
await h.upload(rawId);
|
||||
expect(h.card(id).addresses!.home).toMatchObject({ contexts: { private: true }, coordinates: 'geo:48.137,11.575', full: line, components: [{ kind: 'name', value: line }] });
|
||||
const local = await h.contact(rawId);
|
||||
expect(local.dirty).toBe(false);
|
||||
expect(h.planner.planDownload(h.card(id), local, h.ctx).effect).toBe('none');
|
||||
});
|
||||
|
||||
it('uploads nothing for the same line saved again', async () => {
|
||||
const { h, rawId } = await editLine(LINE);
|
||||
expect(h.planner.planUpload(await h.contact(rawId), h.ctx).kind).toBe('clean');
|
||||
});
|
||||
|
||||
it('reads the parts back from a line an editor re-inserted without its key', async () => {
|
||||
const line = 'Marienplatz 1, 80331 Munich, Upper Bavaria, Germany';
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(munich());
|
||||
h.device.user.fossifySave(rawId, null, [{ [Data.MIMETYPE]: MimeType.STRUCTURED_POSTAL, data1: line, data2: 1 }]);
|
||||
expect(await patchOf(h, rawId)).toEqual({
|
||||
'addresses/home/components': components('Marienplatz 1').map((c) => (c.kind === 'region' ? { ...c, value: 'Upper Bavaria' } : c)),
|
||||
'addresses/home/full': line,
|
||||
});
|
||||
});
|
||||
|
||||
it('edits the parts an editor kept as parts, whatever its line says', async () => {
|
||||
const line = 'Marienplatz 1, 80331 Augsburg, Bavaria, Germany';
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(munich());
|
||||
h.device.user.updateData(h.rowsByKey(rawId)['addresses:home']._id as number, { data7: 'Augsburg', data1: line });
|
||||
expect(await patchOf(h, rawId)).toEqual({ 'addresses/home/components': components('Marienplatz 1', 'Augsburg'), 'addresses/home/full': line });
|
||||
});
|
||||
});
|
||||
|
||||
describe('contacts upload: the name', () => {
|
||||
const NAME_KEY = 'name';
|
||||
|
||||
it('re-derives a derived `full` when a component changes (and the provider recomputed the display name)', async () => {
|
||||
const { patch } = await editedPatch(probeCard, NAME_KEY, { data2: 'Probert', data1: 'Dr. Probert Person' });
|
||||
expect(patch).toEqual({
|
||||
'name/components': [
|
||||
{ kind: 'title', value: 'Dr.' },
|
||||
{ kind: 'given', value: 'Probert', phonetic: 'Proob' },
|
||||
{ kind: 'surname', value: 'Person' },
|
||||
],
|
||||
'name/full': 'Dr. Probert Person',
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads a typed display name as `full` alone', async () => {
|
||||
const { patch } = await editedPatch(probeCard, NAME_KEY, { data1: 'Probe P.' });
|
||||
expect(patch).toEqual({ 'name/full': 'Probe P.' });
|
||||
});
|
||||
|
||||
it('leaves a custom `full` alone when only components change', async () => {
|
||||
const { patch } = await editedPatch(googleCard, NAME_KEY, { data3: 'Gross', data1: 'Prof. Günther Karl Gross, PhD' });
|
||||
expect(patch).toEqual({
|
||||
'name/components': [
|
||||
{ kind: 'title', value: 'Prof.' },
|
||||
{ kind: 'given', value: 'Günther' },
|
||||
{ kind: 'given2', value: 'Karl' },
|
||||
{ kind: 'surname', value: 'Gross' },
|
||||
{ kind: 'credential', value: 'PhD' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a component the name did not have in display order', async () => {
|
||||
const { patch } = await editedPatch(appleCard, NAME_KEY, { data5: 'Maria', data1: 'Anna Maria Apfel' });
|
||||
expect(patch).toEqual({
|
||||
'name/components': [{ kind: 'given', value: 'Anna' }, { kind: 'given2', value: 'Maria' }, { kind: 'surname', value: 'Apfel' }],
|
||||
'name/full': 'Anna Maria Apfel',
|
||||
});
|
||||
});
|
||||
|
||||
it('removes a component whose column was emptied', async () => {
|
||||
const { patch } = await editedPatch(probeCard, NAME_KEY, { data4: null, data1: 'Probe Person' });
|
||||
expect(patch['name/components']).toEqual([{ kind: 'given', value: 'Probe', phonetic: 'Proob' }, { kind: 'surname', value: 'Person' }]);
|
||||
expect(patch['name/full']).toBe('Probe Person');
|
||||
});
|
||||
|
||||
it('uploads a phonetic name as the component phonetic', async () => {
|
||||
const { patch } = await editedPatch(probeCard, NAME_KEY, { data9: 'Pörson' });
|
||||
expect(patch).toEqual({
|
||||
'name/components': [
|
||||
{ kind: 'title', value: 'Dr.' },
|
||||
{ kind: 'given', value: 'Probe', phonetic: 'Proob' },
|
||||
{ kind: 'surname', value: 'Person', phonetic: 'Pörson' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads a retyped name the server holds as `full` alone whole: `full` and every component', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed({ name: { full: 'Ada' } });
|
||||
// A typed display name: the provider splits it (the fake does so when no component is given).
|
||||
h.device.user.updateData(h.rowsByKey(rawId).name._id as number, { data1: 'Ada King', data2: null, data3: null, data4: null, data5: null, data6: null });
|
||||
expect(h.rowsByKey(rawId).name).toMatchObject({ data1: 'Ada King', data2: 'Ada', data3: 'King' });
|
||||
const plan = h.planner.planUpload(await h.contact(rawId), h.ctx);
|
||||
expect(plan).toMatchObject({
|
||||
kind: 'upload',
|
||||
actions: [{ patch: { 'name/components': [{ kind: 'given', value: 'Ada' }, { kind: 'surname', value: 'King' }], 'name/full': 'Ada King' } }],
|
||||
});
|
||||
await h.upload(rawId);
|
||||
expect(h.card(id).name).toMatchObject({ full: 'Ada King', components: [{ kind: 'given', value: 'Ada' }, { kind: 'surname', value: 'King' }] });
|
||||
const local = await h.contact(rawId);
|
||||
expect(local.dirty).toBe(false);
|
||||
expect(h.rowsByKey(rawId).name).toMatchObject({ data1: 'Ada King', data2: 'Ada', data3: 'King' });
|
||||
expect(h.planner.planDownload(h.card(id), local, h.ctx).effect).toBe('none');
|
||||
});
|
||||
|
||||
it('re-derives a derived `full` when a retyped display name adds a component', async () => {
|
||||
const h = new Harness();
|
||||
const { id, rawId } = await h.seed({ name: { components: [{ kind: 'given', value: 'Ada' }], full: 'Ada' } });
|
||||
h.device.user.updateData(h.rowsByKey(rawId).name._id as number, { data1: 'Ada King', data2: null, data3: null, data4: null, data5: null, data6: null });
|
||||
await h.upload(rawId);
|
||||
expect(h.card(id).name).toMatchObject({ full: 'Ada King', components: [{ kind: 'given', value: 'Ada' }, { kind: 'surname', value: 'King' }] });
|
||||
});
|
||||
|
||||
it('drops surname2 only when the family name changed', async () => {
|
||||
const card = () => ({ name: { components: [{ kind: 'given', value: 'Ana' }, { kind: 'surname', value: 'García' }, { kind: 'surname2', value: 'López' }], full: 'Ana García López' } });
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(card());
|
||||
expect(h.rowsByKey(rawId).name.data3).toBe('García López');
|
||||
const { patch } = await editedPatch(card, NAME_KEY, { data3: 'García Pérez', data1: 'Ana García Pérez' });
|
||||
expect(patch['name/components']).toEqual([{ kind: 'given', value: 'Ana' }, { kind: 'surname', value: 'García Pérez' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('contacts upload: nothing mapped changed', () => {
|
||||
it.each([['probe', probeCard], ['apple', appleCard], ['google', googleCard]])('clears DIRTY without a request for a starred %s card', async (_n, make) => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(make());
|
||||
const before = JSON.stringify(h.device.rows('data', 'raw_contact_id = ?', [rawId]));
|
||||
h.device.user.star(rawId);
|
||||
const plan = h.planner.planUpload(await h.contact(rawId), h.ctx);
|
||||
expect(plan.kind).toBe('clean');
|
||||
if (plan.kind !== 'clean') return;
|
||||
expect(plan.ops.ops.filter((o) => o.op !== 'assert' && o.op !== 'syncState' && o.table === 'data')).toEqual([]);
|
||||
await h.applyOk(plan.ops);
|
||||
expect(h.dirty(rawId)).toBe(false);
|
||||
expect(JSON.stringify(h.device.rows('data', 'raw_contact_id = ?', [rawId]))).toBe(before);
|
||||
});
|
||||
|
||||
it('never uploads IS_PRIMARY of a row an editor re-inserted', async () => {
|
||||
const h = new Harness();
|
||||
const { rawId } = await h.seed(appleCard());
|
||||
const rows = h.rowsByKey(rawId);
|
||||
h.device.user.deleteData(rows['emails:k1']._id as number);
|
||||
h.device.user.insertData(rawId, { [Data.MIMETYPE]: MimeType.EMAIL, [Data.DATA1]: 'anna@example.com', [Data.DATA2]: 1, is_primary: 0 });
|
||||
expect(h.planner.planUpload(await h.contact(rawId), h.ctx).kind).toBe('clean');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
// Engine runs for calendars, and the teardown the app calls when sync is
|
||||
// turned off (docs/device-sync.md, "Calendar mapping", "App integration").
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Events } from '../../android-columns';
|
||||
import { parseJsonColumn } from '../../common/json';
|
||||
import { teardownAuthority } from '../../engine/teardown';
|
||||
import type { PoisonMarker } from '../../planner';
|
||||
import {
|
||||
addDeviceContact,
|
||||
addServerCards,
|
||||
ANDROID_ACCOUNT,
|
||||
CALENDAR_AUTHORITY,
|
||||
CONTACTS_AUTHORITY,
|
||||
createHarness,
|
||||
REGISTRY_ID,
|
||||
renameDeviceContact,
|
||||
type Harness,
|
||||
} from './harness';
|
||||
|
||||
const START = '2026-09-28T09:00:00';
|
||||
|
||||
function addServerEvent(h: Harness, title: string, calendarId = h.calendar, extra: Record<string, unknown> = {}): string {
|
||||
return h.server.addEvent('a', {
|
||||
uid: `uid-${title}`,
|
||||
title,
|
||||
start: START,
|
||||
duration: 'PT1H',
|
||||
timeZone: 'Etc/UTC',
|
||||
calendarIds: { [calendarId]: true },
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
function calendarRow(h: Harness, calendarId: string) {
|
||||
return h.device.rows('calendars').find((c) => c._sync_id === `a/${calendarId}`);
|
||||
}
|
||||
|
||||
function titles(h: Harness): string[] {
|
||||
return h.events().map((e) => String(e[Events.TITLE])).sort();
|
||||
}
|
||||
|
||||
describe('device sync engine: calendar', () => {
|
||||
it('writes calendar rows for the synced calendars and downloads their events, never tasks', async () => {
|
||||
const h = createHarness();
|
||||
const standup = addServerEvent(h, 'Standup');
|
||||
h.server.addEvent('a', { uid: 't1', '@type': 'Task', title: 'Buy milk', calendarIds: { [h.calendar]: true } });
|
||||
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', stats: { downloaded: { created: 1 } } });
|
||||
expect(calendarRow(h, h.calendar)).toMatchObject({ ownerAccount: 'alice@example.com', sync_events: 1, name: 'Calendar' });
|
||||
expect(h.events()).toEqual([expect.objectContaining({ title: 'Standup', _sync_id: `a/${standup}`, uid2445: 'uid-Standup' })]);
|
||||
expect(h.state(CALENDAR_AUTHORITY)).toMatchObject({
|
||||
accounts: { a: { selected: [`a/${h.calendar}`], reconcile: null } },
|
||||
deviceZone: 'Europe/Berlin',
|
||||
reminderOwner: 'device',
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads a new device event, an edit and a deletion', async () => {
|
||||
const h = createHarness();
|
||||
const standup = addServerEvent(h, 'Standup');
|
||||
const retro = addServerEvent(h, 'Retro');
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const rowId = Number(calendarRow(h, h.calendar)!._id);
|
||||
const byTitle = (t: string) => h.events().find((e) => e.title === t)!;
|
||||
|
||||
h.device.user.insertEvent(rowId, { title: 'Lunch', dtstart: Date.UTC(2026, 8, 28, 12), dtend: Date.UTC(2026, 8, 28, 13), eventTimezone: 'UTC' });
|
||||
h.device.user.updateEvent(Number(byTitle('Standup')._id), { title: 'Daily standup' });
|
||||
h.device.user.deleteEvent(Number(byTitle('Retro')._id));
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', stats: { uploaded: { created: 1, updated: 1, deleted: 1 } } });
|
||||
expect(h.server.get('CalendarEvent', 'a', standup)).toMatchObject({ title: 'Daily standup' });
|
||||
expect(h.server.get('CalendarEvent', 'a', retro)).toBeUndefined();
|
||||
const lunch = h.server.all('CalendarEvent', 'a').find((e) => e.title === 'Lunch')!;
|
||||
expect(lunch).toMatchObject({ start: '2026-09-28T12:00:00', calendarIds: { [h.calendar]: true } });
|
||||
expect(titles(h)).toEqual(['Daily standup', 'Lunch']);
|
||||
expect(byTitle('Lunch')).toMatchObject({ _sync_id: `a/${lunch.id}`, dirty: 0 });
|
||||
});
|
||||
|
||||
it('gives a calendar that holds only tasks no row', async () => {
|
||||
const h = createHarness();
|
||||
const todo = h.server.addCalendar('a', { name: 'To do' });
|
||||
for (let i = 0; i < 3; i++) h.server.addEvent('a', { uid: `t${i}`, '@type': 'Task', title: `Task ${i}`, calendarIds: { [todo]: true } });
|
||||
addServerEvent(h, 'Standup');
|
||||
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(calendarRow(h, todo)).toBeUndefined();
|
||||
expect(h.state(CALENDAR_AUTHORITY)?.accounts.a.taskOnly).toEqual([todo]);
|
||||
expect(titles(h)).toEqual(['Standup']);
|
||||
});
|
||||
|
||||
it('drops a deselected calendar once its events are uploaded, keeping the others', async () => {
|
||||
const h = createHarness();
|
||||
const work = h.server.addCalendar('a', { name: 'Work' });
|
||||
addServerEvent(h, 'Standup');
|
||||
const review = addServerEvent(h, 'Review', work);
|
||||
addServerEvent(h, 'Planning', work);
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
expect(titles(h)).toEqual(['Planning', 'Review', 'Standup']);
|
||||
|
||||
h.device.user.updateEvent(Number(h.events().find((e) => e.title === 'Review')!._id), { title: 'Code review' });
|
||||
h.prefs.calendarSelection[`a/${work}`] = false;
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(h.server.get('CalendarEvent', 'a', review)).toMatchObject({ title: 'Code review' });
|
||||
expect(calendarRow(h, work)).toBeUndefined();
|
||||
expect(titles(h)).toEqual(['Standup']);
|
||||
expect(h.state(CALENDAR_AUTHORITY)?.accounts.a.selected).toEqual([`a/${h.calendar}`]);
|
||||
});
|
||||
|
||||
it('keeps a deselected calendar while one of its events could not be uploaded', async () => {
|
||||
const h = createHarness();
|
||||
const work = h.server.addCalendar('a', { name: 'Work' });
|
||||
const review = addServerEvent(h, 'Review', work);
|
||||
addServerEvent(h, 'Planning', work);
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
h.device.user.updateEvent(Number(h.events().find((e) => e.title === 'Review')!._id), { title: 'Code review' });
|
||||
h.server.setErrorFor('CalendarEvent', 'a', review, { type: 'tooLarge' });
|
||||
h.prefs.calendarSelection[`a/${work}`] = false;
|
||||
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report.itemErrors).toEqual([expect.objectContaining({ ref: `a/${review}`, type: 'tooLarge' })]);
|
||||
expect(calendarRow(h, work)).toBeDefined();
|
||||
expect(titles(h)).toEqual(['Code review']);
|
||||
// Not complete any more (selected again, it is loaded again); its row is dropped again next run.
|
||||
expect(h.state(CALENDAR_AUTHORITY)?.accounts.a.selected).toEqual([`a/${h.calendar}`]);
|
||||
|
||||
h.clock.now += 2 * 3_600_000;
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(h.server.get('CalendarEvent', 'a', review)).toMatchObject({ title: 'Code review' });
|
||||
expect(calendarRow(h, work)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('marks a calendar that mirrors a feed subscription read-only', async () => {
|
||||
const h = createHarness();
|
||||
const feed = h.server.addCalendar('a', { name: 'Holidays' });
|
||||
h.deps.subscriptionCalendars = async () => [{ jmapAccountId: null, calendarId: feed }];
|
||||
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(calendarRow(h, feed)).toMatchObject({ calendar_access_level: 200 });
|
||||
expect(calendarRow(h, h.calendar)).toMatchObject({ calendar_access_level: 700 });
|
||||
});
|
||||
|
||||
it('never gives a device event the identity of an unrelated event that has its uid (event uids are unique per account)', async () => {
|
||||
const h = createHarness();
|
||||
const hidden = h.server.addCalendar('a', { name: 'Hidden' });
|
||||
h.prefs.calendarSelection[`a/${hidden}`] = false;
|
||||
const other = addServerEvent(h, 'Other', hidden, { uid: 'dup' });
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const rowId = Number(calendarRow(h, h.calendar)!._id);
|
||||
const id = h.device.user.insertEvent(rowId, { title: 'Mine', dtstart: Date.UTC(2026, 8, 28, 12), dtend: Date.UTC(2026, 8, 28, 13), eventTimezone: 'UTC' });
|
||||
// An app wrote the uid, and the claim keeps it.
|
||||
await h.device.port(ANDROID_ACCOUNT, CALENDAR_AUTHORITY).applyBatch([
|
||||
{
|
||||
op: 'update',
|
||||
table: 'events',
|
||||
id,
|
||||
values: { _sync_id: '~pending/dup', uid2445: 'dup', sync_data3: JSON.stringify({ uid: 'dup', target: `a/${h.calendar}` }) },
|
||||
},
|
||||
]);
|
||||
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report.itemErrors).toEqual([expect.objectContaining({ type: 'uidConflict' })]);
|
||||
expect(h.server.get('CalendarEvent', 'a', other)).toMatchObject({ title: 'Other', calendarIds: { [hidden]: true } });
|
||||
const row = h.events().find((e) => Number(e._id) === id)!;
|
||||
expect(row).toMatchObject({ title: 'Mine', _sync_id: '~pending/dup' });
|
||||
expect(parseJsonColumn<PoisonMarker>(row.sync_data5)).toMatchObject({ type: 'uidConflict', n: 1 });
|
||||
});
|
||||
|
||||
it('adopts an event its lost create left on the server, with a lagging uid index', async () => {
|
||||
const h = createHarness();
|
||||
addServerEvent(h, 'Standup');
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
h.server.setUidIndexLag(3);
|
||||
const rowId = Number(calendarRow(h, h.calendar)!._id);
|
||||
const id = h.device.user.insertEvent(rowId, { title: 'Lunch', dtstart: Date.UTC(2026, 8, 28, 12), dtend: Date.UTC(2026, 8, 28, 13), eventTimezone: 'UTC' });
|
||||
h.server.applyThenLoseResponse({ match: 'CalendarEvent/set' });
|
||||
|
||||
expect((await h.run(CALENDAR_AUTHORITY)).outcome).toBe('io');
|
||||
expect((await h.run(CALENDAR_AUTHORITY)).outcome).toBe('ok');
|
||||
|
||||
expect(h.server.all('CalendarEvent', 'a').map((e) => e.title).sort()).toEqual(['Lunch', 'Standup']);
|
||||
expect(h.events()).toHaveLength(2);
|
||||
expect(h.events().find((e) => Number(e._id) === id)).toMatchObject({ dirty: 0, _sync_id: expect.stringMatching(/^a\//) });
|
||||
});
|
||||
});
|
||||
|
||||
describe('device sync engine: teardown', () => {
|
||||
it('uploads waiting changes, then removes every row and the SyncState of the authority', async () => {
|
||||
const h = createHarness();
|
||||
const [ada] = addServerCards(h, ['Ada']);
|
||||
await h.run();
|
||||
renameDeviceContact(h, h.contactNamed('Ada')!.id, 'Ada King');
|
||||
addDeviceContact(h, 'Hedy');
|
||||
|
||||
const result = await h.teardown(CONTACTS_AUTHORITY);
|
||||
|
||||
expect(result).toEqual({ pending: 0 });
|
||||
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ name: { full: 'Ada King' } });
|
||||
expect(h.serverNames()).toEqual(['Ada King', 'Hedy']);
|
||||
expect(h.device.rows('raw_contacts')).toEqual([]);
|
||||
expect(h.device.rows('groups')).toEqual([]);
|
||||
expect(h.device.rows('settings')).toEqual([]);
|
||||
expect(h.device.readSyncState(ANDROID_ACCOUNT, CONTACTS_AUTHORITY)).toBe('');
|
||||
});
|
||||
|
||||
it('deletes nothing while changes could not be uploaded, unless forced', async () => {
|
||||
const h = createHarness();
|
||||
addServerCards(h, ['Ada']);
|
||||
await h.run();
|
||||
addDeviceContact(h, 'Hedy');
|
||||
h.deps.jmap = async () => {
|
||||
const error = new Error('Network request failed');
|
||||
error.name = 'NetworkError';
|
||||
throw error;
|
||||
};
|
||||
|
||||
expect(await h.teardown(CONTACTS_AUTHORITY)).toEqual({ pending: 1 });
|
||||
expect(h.contacts()).toHaveLength(2);
|
||||
|
||||
expect(await h.teardown(CONTACTS_AUTHORITY, { force: true })).toEqual({ pending: 0 });
|
||||
expect(h.contacts()).toHaveLength(0);
|
||||
expect(h.serverNames()).toEqual(['Ada']);
|
||||
});
|
||||
|
||||
it('holds mass deletions back like a sync run, and asks', async () => {
|
||||
const h = createHarness();
|
||||
addServerCards(h, Array.from({ length: 80 }, (_, i) => `P${i}`));
|
||||
await h.run();
|
||||
for (const contact of h.contacts().slice(0, 60)) h.device.user.deleteContact(contact.id);
|
||||
|
||||
expect(await h.teardown(CONTACTS_AUTHORITY)).toEqual({ pending: 60 });
|
||||
expect(h.serverNames()).toHaveLength(80);
|
||||
});
|
||||
|
||||
it("finishes while JS timers are paused (the UI's teardown with the app in the background)", async () => {
|
||||
const h = createHarness();
|
||||
const [ada] = addServerCards(h, ['Ada']);
|
||||
await h.run();
|
||||
renameDeviceContact(h, h.contactNamed('Ada')!.id, 'Ada King');
|
||||
// The app passes neither a yield nor a sleep: the engine's own must not need a timer here.
|
||||
const { yieldThread: _yield, sleep: _sleep, ...deps } = h.deps;
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await expect(teardownAuthority(deps, REGISTRY_ID, ANDROID_ACCOUNT, CONTACTS_AUTHORITY)).resolves.toEqual({ pending: 0 });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ name: { full: 'Ada King' } });
|
||||
expect(h.contacts()).toEqual([]);
|
||||
}, 3_000);
|
||||
|
||||
it('removes the calendar rows, and their events with them', async () => {
|
||||
const h = createHarness();
|
||||
addServerEvent(h, 'Standup');
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(await h.teardown(CALENDAR_AUTHORITY)).toEqual({ pending: 0 });
|
||||
expect(h.device.rows('calendars')).toEqual([]);
|
||||
expect(h.device.rows('events')).toEqual([]);
|
||||
});
|
||||
|
||||
it('stops a running sync of the account and waits for it', async () => {
|
||||
const h = createHarness();
|
||||
addServerCards(h, Array.from({ length: 200 }, (_, i) => `P${i}`));
|
||||
let teardown: Promise<{ pending: number }> | null = null;
|
||||
h.checkpoints.onCheckpoint = (n) => {
|
||||
if (n === 3) teardown = h.teardown(CONTACTS_AUTHORITY);
|
||||
};
|
||||
|
||||
const report = await h.run();
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'cancelled' });
|
||||
expect(await teardown).toEqual({ pending: 0 });
|
||||
expect(h.contacts()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
// The calendar passes that need no server writes (device zone change,
|
||||
// reminder owner change) and the local checks before an upload (a series
|
||||
// split through CONTENT_EXCEPTION_URI).
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Events } from '../../android-columns';
|
||||
import type { CalendarPlanner } from '../../planner';
|
||||
import { ANDROID_ACCOUNT, CALENDAR_AUTHORITY, createHarness, type Harness } from './harness';
|
||||
import { toyCalendarPlanner } from './toy-planners';
|
||||
|
||||
function withPlanner(h: Harness, overrides: Partial<CalendarPlanner>): CalendarPlanner {
|
||||
const planner = { ...toyCalendarPlanner, ...overrides } as CalendarPlanner;
|
||||
h.deps.planners = { ...h.deps.planners, calendar: planner };
|
||||
return planner;
|
||||
}
|
||||
|
||||
function addServerEvent(h: Harness, title: string): string {
|
||||
return h.server.addEvent('a', { uid: `uid-${title}`, title, start: '2026-09-28T09:00:00', duration: 'PT1H', calendarIds: { [h.calendar]: true } });
|
||||
}
|
||||
|
||||
describe('device sync engine: calendar passes', () => {
|
||||
it('rewrites clean events for a new device zone, and stores the zone with the last chunk', async () => {
|
||||
const h = createHarness({ tuning: { chunkSize: 2 } });
|
||||
for (const title of ['A', 'B', 'C']) addServerEvent(h, title);
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const planZoneChange = vi.fn((local, previousZone: string, ctx) => ({
|
||||
ref: String(local.syncId),
|
||||
ops: [{ op: 'update' as const, table: 'events' as const, id: local.eventId, values: { eventTimezone: ctx.deviceZone } }],
|
||||
previousZone,
|
||||
}));
|
||||
withPlanner(h, { planZoneChange });
|
||||
h.deps.deviceZone = () => 'America/New_York';
|
||||
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report.outcome).toBe('ok');
|
||||
expect(planZoneChange).toHaveBeenCalledTimes(3);
|
||||
expect(planZoneChange.mock.calls.every(([, previous]) => previous === 'Europe/Berlin')).toBe(true);
|
||||
expect(h.events().map((e) => e[Events.EVENT_TIMEZONE])).toEqual(['America/New_York', 'America/New_York', 'America/New_York']);
|
||||
expect(h.state(CALENDAR_AUTHORITY)).toMatchObject({ deviceZone: 'America/New_York', deviceZonePending: null });
|
||||
});
|
||||
|
||||
it('finishes an interrupted zone pass in the next run', async () => {
|
||||
const h = createHarness({ tuning: { chunkSize: 1 } });
|
||||
for (const title of ['A', 'B', 'C']) addServerEvent(h, title);
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const planZoneChange = vi.fn((local: { eventId: number; syncId: string | null }) => ({
|
||||
ref: String(local.syncId),
|
||||
ops: [{ op: 'update' as const, table: 'events' as const, id: local.eventId, values: { eventTimezone: 'America/New_York' } }],
|
||||
}));
|
||||
withPlanner(h, { planZoneChange });
|
||||
h.deps.deviceZone = () => 'America/New_York';
|
||||
let zoneChunks = 0;
|
||||
h.checkpoints.onCheckpoint = () => {
|
||||
if (planZoneChange.mock.calls.length > 0 && ++zoneChunks === 2) h.checkpoints.crashAt = h.checkpoints.count;
|
||||
};
|
||||
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
expect(h.state(CALENDAR_AUTHORITY)).toMatchObject({ deviceZone: 'Europe/Berlin', deviceZonePending: 'America/New_York' });
|
||||
|
||||
h.checkpoints.crashAt = null;
|
||||
h.checkpoints.onCheckpoint = undefined;
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
expect(h.state(CALENDAR_AUTHORITY)).toMatchObject({ deviceZone: 'America/New_York', deviceZonePending: null });
|
||||
expect(h.events().every((e) => e[Events.EVENT_TIMEZONE] === 'America/New_York')).toBe(true);
|
||||
});
|
||||
|
||||
it('uploads with the old reminder owner, then rewrites the reminders and the calendar rows for the new one', async () => {
|
||||
const h = createHarness();
|
||||
addServerEvent(h, 'Standup');
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
expect(h.device.rows('calendars')[0]).toMatchObject({ maxReminders: 5 });
|
||||
const owners: string[] = [];
|
||||
const planReminderOwnerChange = vi.fn((local: { eventId: number; syncId: string | null }, ctx: { reminderOwner: string }) => {
|
||||
owners.push(ctx.reminderOwner);
|
||||
return { ref: String(local.syncId), ops: [{ op: 'delete' as const, table: 'reminders' as const, where: 'event_id = ?', args: [local.eventId] }] };
|
||||
});
|
||||
const planUpload = vi.fn(toyCalendarPlanner.planUpload);
|
||||
withPlanner(h, { planReminderOwnerChange, planUpload });
|
||||
h.device.user.setReminders(Number(h.events()[0]._id), [{ minutes: 10 }]);
|
||||
h.device.user.updateEvent(Number(h.events()[0]._id), { title: 'Daily standup' });
|
||||
h.prefs.reminderOwner = 'bulwark';
|
||||
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(planUpload.mock.calls.map(([, ctx]) => ctx.reminderOwner)).toEqual(['device']);
|
||||
expect(owners).toEqual(['bulwark']);
|
||||
expect(h.device.rows('reminders')).toEqual([]);
|
||||
expect(h.device.rows('calendars')[0]).toMatchObject({ maxReminders: 0 });
|
||||
expect(h.state(CALENDAR_AUTHORITY)?.reminderOwner).toBe('bulwark');
|
||||
});
|
||||
|
||||
it('uploads the clone of a CONTENT_EXCEPTION_URI split as a new event after its source', async () => {
|
||||
const h = createHarness();
|
||||
const series = addServerEvent(h, 'Series');
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const master = h.events()[0];
|
||||
// A recurring master, as a calendar app sees it.
|
||||
await h.device.port(ANDROID_ACCOUNT, CALENDAR_AUTHORITY).applyBatch([
|
||||
{ op: 'update', table: 'events', id: Number(master._id), values: { rrule: 'FREQ=DAILY', duration: 'P3600S', dtend: null } },
|
||||
]);
|
||||
const planUpload = vi.fn(toyCalendarPlanner.planUpload);
|
||||
withPlanner(h, { planUpload });
|
||||
const clone = h.device.user.splitViaUri(Number(master._id), Date.UTC(2026, 9, 1, 9), '20260930T235959Z', { title: 'Series (later)' });
|
||||
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report.outcome).toBe('ok');
|
||||
// The source was planned while the split was visible, before its clone was claimed.
|
||||
const splits = planUpload.mock.calls.map(([local]) => local.split);
|
||||
expect(splits.indexOf('source')).toBeLessThan(splits.indexOf('clone'));
|
||||
const events = h.server.all('CalendarEvent', 'a');
|
||||
expect(events.map((e) => e.title).sort()).toEqual(['Series', 'Series (later)']);
|
||||
const cloneRow = h.events().find((e) => Number(e._id) === clone)!;
|
||||
expect(cloneRow._sync_id).not.toBe(`a/${series}`);
|
||||
expect(cloneRow._sync_id).toMatch(/^a\//);
|
||||
expect(new Set(h.events().map((e) => e._sync_id)).size).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,390 @@
|
||||
// Engine runs for contacts against the fake provider and the fake JMAP
|
||||
// server: first sync, incremental /changes, uploads, echoes, selection
|
||||
// changes and groups (docs/device-sync.md, "A sync run").
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Data, MimeType } from '../../android-columns';
|
||||
import { runDeviceSync } from '../../engine/run';
|
||||
import {
|
||||
addDeviceContact,
|
||||
addServerCards,
|
||||
ANDROID_ACCOUNT,
|
||||
CONTACTS_AUTHORITY,
|
||||
createHarness,
|
||||
REGISTRY_ID,
|
||||
renameDeviceContact,
|
||||
RUN_BUDGET,
|
||||
rowWrites,
|
||||
type Harness,
|
||||
} from './harness';
|
||||
|
||||
function runAs(h: Harness, registryId: string) {
|
||||
return runDeviceSync(
|
||||
{ runId: 'other', accountName: ANDROID_ACCOUNT, registryId, authority: CONTACTS_AUTHORITY, extras: {}, deadline: h.clock.now + RUN_BUDGET },
|
||||
h.deps,
|
||||
);
|
||||
}
|
||||
|
||||
describe('device sync engine: contacts', () => {
|
||||
it('downloads every card of the synced address books on the first sync (a full reconcile)', async () => {
|
||||
const h = createHarness();
|
||||
addServerCards(h, ['Ada', 'Grace', 'Linus']);
|
||||
|
||||
const report = await h.run();
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', stats: { downloaded: { created: 3, updated: 0, deleted: 0 } } });
|
||||
expect(h.contacts().map((c) => c.name).sort()).toEqual(['Ada', 'Grace', 'Linus']);
|
||||
expect(h.contacts().every((c) => c.sourceId?.startsWith('a/') && !c.dirty)).toBe(true);
|
||||
const state = h.state();
|
||||
expect(state?.owner).toEqual({ registryId: REGISTRY_ID, origin: 'https://mail.example.com' });
|
||||
expect(state?.accounts.a).toMatchObject({
|
||||
itemsState: h.server.state('ContactCard', 'a'),
|
||||
reconcile: null,
|
||||
selected: [`a/${h.book}`],
|
||||
stale: [],
|
||||
});
|
||||
expect(h.device.rows('settings')).toEqual([expect.objectContaining({ ungrouped_visible: 1, should_sync: 1 })]);
|
||||
expect(h.deps.recordStatus).toHaveBeenCalledWith(REGISTRY_ID, CONTACTS_AUTHORITY, expect.objectContaining({ outcome: 'ok' }));
|
||||
});
|
||||
|
||||
it('applies server creates, updates and deletes from /changes', async () => {
|
||||
const h = createHarness();
|
||||
const [ada, grace] = addServerCards(h, ['Ada', 'Grace']);
|
||||
await h.run();
|
||||
|
||||
h.server.serverUpdate('ContactCard', 'a', ada, { 'name/full': 'Ada Lovelace' });
|
||||
h.server.serverDestroy('ContactCard', 'a', grace);
|
||||
addServerCards(h, ['Katherine']);
|
||||
const report = await h.run();
|
||||
|
||||
expect(report.stats.downloaded).toEqual({ created: 1, updated: 1, deleted: 1 });
|
||||
expect(h.contacts().map((c) => c.name).sort()).toEqual(['Ada Lovelace', 'Katherine']);
|
||||
expect(h.server.calls('ContactCard/changes').length).toBeGreaterThan(0);
|
||||
expect(h.state()?.accounts.a.itemsState).toBe(h.server.state('ContactCard', 'a'));
|
||||
});
|
||||
|
||||
it('pages through /changes and stores the state with every page', async () => {
|
||||
const h = createHarness();
|
||||
await h.run();
|
||||
addServerCards(h, Array.from({ length: 300 }, (_, i) => `Person ${i}`));
|
||||
const before = h.server.calls('ContactCard/changes').length;
|
||||
|
||||
const report = await h.run();
|
||||
|
||||
const pages = h.server.calls('ContactCard/changes').slice(before);
|
||||
expect(pages.map(([, args]) => args.maxChanges)).toEqual([256, 256]);
|
||||
expect(report.stats.downloaded.created).toBe(300);
|
||||
expect(h.contacts()).toHaveLength(300);
|
||||
// One state op per page, each the last op of its batch.
|
||||
const stateBatches = h.batches.log.filter((ops) => ops.some((op) => op.op === 'syncState'));
|
||||
for (const ops of stateBatches) expect(ops[ops.length - 1].op).toBe('syncState');
|
||||
});
|
||||
|
||||
it('uploads a device edit as a patch and a new device contact as a create with a client uid', async () => {
|
||||
const h = createHarness();
|
||||
const [ada] = addServerCards(h, ['Ada']);
|
||||
await h.run();
|
||||
|
||||
renameDeviceContact(h, h.contactNamed('Ada')!.id, 'Ada King');
|
||||
const fresh = addDeviceContact(h, 'Hedy');
|
||||
const report = await h.run();
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', stats: { uploaded: { created: 1, updated: 1, deleted: 0 } } });
|
||||
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ name: { full: 'Ada King' } });
|
||||
const created = h.server.all('ContactCard', 'a').find((c) => (c.name as { full: string }).full === 'Hedy')!;
|
||||
expect(created.uid).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(h.contacts().find((c) => c.id === fresh)).toMatchObject({ sourceId: `a/${created.id}`, dirty: false });
|
||||
expect(h.contacts().every((c) => !c.dirty)).toBe(true);
|
||||
const set = h.server.calls('ContactCard/set');
|
||||
expect(set.every(([, args]) => typeof args.ifInState === 'string')).toBe(true);
|
||||
});
|
||||
|
||||
it('uploads to an account without any card yet, whose /changes state is spelled differently from its type state', async () => {
|
||||
const h = createHarness();
|
||||
await h.run();
|
||||
addDeviceContact(h, 'Hedy');
|
||||
|
||||
const report = await h.run();
|
||||
|
||||
expect(report.outcome).toBe('ok');
|
||||
expect(h.serverNames()).toEqual(['Hedy']);
|
||||
expect(h.contacts()).toEqual([expect.objectContaining({ name: 'Hedy', dirty: false, sourceId: expect.stringMatching(/^a\//) })]);
|
||||
});
|
||||
|
||||
it('writes nothing but the state when its own upload comes back through /changes (echo)', async () => {
|
||||
const h = createHarness();
|
||||
addServerCards(h, ['Ada']);
|
||||
await h.run();
|
||||
renameDeviceContact(h, h.contactNamed('Ada')!.id, 'Ada King');
|
||||
addDeviceContact(h, 'Hedy');
|
||||
await h.run();
|
||||
const before = h.batches.log.length;
|
||||
|
||||
const report = await h.run();
|
||||
|
||||
expect(report.stats.downloaded).toEqual({ created: 0, updated: 0, deleted: 0 });
|
||||
expect(rowWrites(h.batches.log.slice(before))).toEqual([]);
|
||||
expect(h.contacts().map((c) => c.name).sort()).toEqual(['Ada King', 'Hedy']);
|
||||
});
|
||||
|
||||
it('keeps a contact dirty when the user edits it again while its upload is on the way', async () => {
|
||||
const h = createHarness();
|
||||
const [ada] = addServerCards(h, ['Ada']);
|
||||
await h.run();
|
||||
const id = h.contactNamed('Ada')!.id;
|
||||
renameDeviceContact(h, id, 'Ada King');
|
||||
const stop = h.server.onAfterRequest((request) => {
|
||||
if (request.methods.includes('ContactCard/set')) renameDeviceContact(h, id, 'Ada, Countess');
|
||||
});
|
||||
|
||||
await h.run();
|
||||
stop();
|
||||
|
||||
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ name: { full: 'Ada King' } });
|
||||
expect(h.contacts().find((c) => c.id === id)).toMatchObject({ name: 'Ada, Countess', dirty: true });
|
||||
|
||||
await h.run();
|
||||
|
||||
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ name: { full: 'Ada, Countess' } });
|
||||
expect(h.contacts().find((c) => c.id === id)).toMatchObject({ dirty: false });
|
||||
});
|
||||
|
||||
it('lets the server win a conflicting edit of the same unit and counts it', async () => {
|
||||
const h = createHarness();
|
||||
const [ada] = addServerCards(h, ['Ada']);
|
||||
await h.run();
|
||||
renameDeviceContact(h, h.contactNamed('Ada')!.id, 'Ada (device)');
|
||||
h.server.serverUpdate('ContactCard', 'a', ada, { 'name/full': 'Ada (server)' });
|
||||
|
||||
const report = await h.run();
|
||||
|
||||
expect(report.conflicts).toBe(1);
|
||||
expect(h.contacts().map((c) => c.name)).toEqual(['Ada (server)']);
|
||||
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ name: { full: 'Ada (server)' } });
|
||||
});
|
||||
|
||||
it('deletes on the server what the user deleted, and purges the rows', async () => {
|
||||
const h = createHarness();
|
||||
const [ada] = addServerCards(h, ['Ada', 'Grace']);
|
||||
await h.run();
|
||||
|
||||
h.device.user.deleteContact(h.contactNamed('Ada')!.id);
|
||||
const report = await h.run();
|
||||
|
||||
expect(report.stats.uploaded.deleted).toBe(1);
|
||||
expect(h.server.get('ContactCard', 'a', ada)).toBeUndefined();
|
||||
expect(h.contacts().map((c) => c.name)).toEqual(['Grace']);
|
||||
});
|
||||
|
||||
it('drops only the clean rows of a deselected address book, after uploading its dirty ones', async () => {
|
||||
const h = createHarness();
|
||||
const work = h.server.addAddressBook('a', { name: 'Work' });
|
||||
addServerCards(h, ['Ada']);
|
||||
const [boss, colleague] = addServerCards(h, ['Boss', 'Colleague'], 'a', work);
|
||||
await h.run();
|
||||
expect(h.contacts()).toHaveLength(3);
|
||||
|
||||
renameDeviceContact(h, h.contactNamed('Boss')!.id, 'The Boss');
|
||||
h.prefs.contactsSelection[`a/${work}`] = false;
|
||||
const report = await h.run();
|
||||
|
||||
expect(report.outcome).toBe('ok');
|
||||
expect(h.contacts().map((c) => c.name)).toEqual(['Ada']);
|
||||
expect(h.server.get('ContactCard', 'a', boss)).toMatchObject({ name: { full: 'The Boss' } });
|
||||
expect(h.server.get('ContactCard', 'a', colleague)).toBeDefined();
|
||||
expect(h.state()?.accounts.a.selected).toEqual([`a/${h.book}`]);
|
||||
});
|
||||
|
||||
it("leaves the webmail's Trusted Senders book off the device unless the user picked it", async () => {
|
||||
const h = createHarness();
|
||||
const trusted = h.server.addAddressBook('a', { name: 'Trusted Senders' });
|
||||
addServerCards(h, ['Ada']);
|
||||
addServerCards(h, ['newsletter@example.com'], 'a', trusted);
|
||||
|
||||
await h.run();
|
||||
expect(h.contacts().map((c) => c.name)).toEqual(['Ada']);
|
||||
|
||||
h.prefs.contactsSelection[`a/${trusted}`] = true;
|
||||
await h.run();
|
||||
expect(h.contacts().map((c) => c.name).sort()).toEqual(['Ada', 'newsletter@example.com']);
|
||||
});
|
||||
|
||||
it('loads a newly selected address book once the account is up to date', async () => {
|
||||
const h = createHarness({ accounts: 'withShared' });
|
||||
addServerCards(h, ['Ada']);
|
||||
const teamBook = h.server.all('AddressBook', 'team')[0].id as string;
|
||||
h.server.addCard('team', { uid: 'u-team', name: { full: 'Teammate' }, addressBookIds: { [teamBook]: true } });
|
||||
await h.run();
|
||||
expect(h.contacts().map((c) => c.name)).toEqual(['Ada']);
|
||||
|
||||
h.prefs.contactsSelection[`team/${teamBook}`] = true;
|
||||
const report = await h.run();
|
||||
|
||||
expect(report.stats.downloaded.created).toBe(1);
|
||||
expect(h.contacts().map((c) => c.name).sort()).toEqual(['Ada', 'Teammate']);
|
||||
expect(h.contactNamed('Teammate')?.sourceId).toMatch(/^team\//);
|
||||
expect(h.state()?.accounts.team.selected).toEqual([`team/${teamBook}`]);
|
||||
});
|
||||
|
||||
it('writes group cards before the contacts that are their members', async () => {
|
||||
const h = createHarness();
|
||||
const [ada] = addServerCards(h, ['Ada']);
|
||||
const adaUid = h.server.get('ContactCard', 'a', ada)!.uid as string;
|
||||
h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' }, members: { [adaUid]: true } });
|
||||
|
||||
await h.run();
|
||||
|
||||
const groups = h.device.rows('groups');
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0]).toMatchObject({ title: 'Friends', group_visible: 1 });
|
||||
const memberships = h.device.rows('data').filter((d) => d.mimetype === MimeType.GROUP_MEMBERSHIP);
|
||||
expect(memberships).toHaveLength(1);
|
||||
// data1 is a TEXT column: the group row id may read back as text.
|
||||
expect(String(memberships[0][Data.DATA1])).toBe(String(groups[0]._id));
|
||||
expect(memberships[0].group_sourceid).toBe(groups[0].sourceid);
|
||||
expect(h.state()?.accounts.a.groups).toEqual([groups[0].sourceid]);
|
||||
});
|
||||
|
||||
it("updates a member's rows when only the group card's members changed on the server", async () => {
|
||||
const h = createHarness();
|
||||
const [ada, grace] = addServerCards(h, ['Ada', 'Grace']);
|
||||
const adaUid = h.server.get('ContactCard', 'a', ada)!.uid as string;
|
||||
const graceUid = h.server.get('ContactCard', 'a', grace)!.uid as string;
|
||||
const group = h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' }, members: { [adaUid]: true } });
|
||||
await h.run();
|
||||
const members = () =>
|
||||
h.device
|
||||
.rows('data')
|
||||
.filter((d) => d.mimetype === MimeType.GROUP_MEMBERSHIP)
|
||||
.map((d) => h.contacts().find((c) => c.id === Number(d.raw_contact_id))?.name)
|
||||
.sort();
|
||||
expect(members()).toEqual(['Ada']);
|
||||
|
||||
// Another client moves the membership: only the group card changes.
|
||||
h.server.serverUpdate('ContactCard', 'a', group, { members: { [graceUid]: true } });
|
||||
await h.run();
|
||||
|
||||
expect(members()).toEqual(['Grace']);
|
||||
expect(h.contacts().every((c) => !c.dirty)).toBe(true);
|
||||
});
|
||||
|
||||
it('uploads a membership added on the device as a patch of the group card', async () => {
|
||||
const h = createHarness();
|
||||
const [ada] = addServerCards(h, ['Ada']);
|
||||
const group = h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' }, members: {} });
|
||||
await h.run();
|
||||
|
||||
const groupRow = h.device.rows('groups')[0];
|
||||
h.device.user.addToGroup(h.contactNamed('Ada')!.id, Number(groupRow._id));
|
||||
await h.run();
|
||||
|
||||
const adaUid = h.server.get('ContactCard', 'a', ada)!.uid as string;
|
||||
expect(h.server.get('ContactCard', 'a', group)?.members).toEqual({ [adaUid]: true });
|
||||
expect(h.contacts().every((c) => !c.dirty)).toBe(true);
|
||||
});
|
||||
|
||||
it('skips the network for an upload-only sync with nothing to upload', async () => {
|
||||
const h = createHarness();
|
||||
addServerCards(h, ['Ada']);
|
||||
await h.run();
|
||||
const requests = h.server.requests.length;
|
||||
|
||||
const report = await h.run(CONTACTS_AUTHORITY, { upload: true });
|
||||
|
||||
expect(report.outcome).toBe('ok');
|
||||
expect(h.server.requests.length).toBe(requests);
|
||||
});
|
||||
|
||||
it("keeps the last run's status when an upload-only sync finds nothing to do, moving only its time on", async () => {
|
||||
const h = createHarness();
|
||||
addServerCards(h, ['Ada']);
|
||||
await h.run();
|
||||
const status = h.lastStatus()!;
|
||||
expect(status).toMatchObject({ outcome: 'ok', stats: { downloaded: { created: 1 } } });
|
||||
|
||||
// The upload sync Android starts right after a manual one: nothing waits on the device.
|
||||
h.clock.now += 1_000;
|
||||
expect((await h.run(CONTACTS_AUTHORITY, { upload: true })).outcome).toBe('ok');
|
||||
|
||||
expect(h.lastStatus()).toEqual({ ...status, at: h.clock.now });
|
||||
// One that has something to upload is a run like any other.
|
||||
renameDeviceContact(h, h.contactNamed('Ada')!.id, 'Ada King');
|
||||
await h.run(CONTACTS_AUTHORITY, { upload: true });
|
||||
expect(h.lastStatus()).toMatchObject({ stats: { downloaded: { created: 0 }, uploaded: { updated: 1 } } });
|
||||
});
|
||||
|
||||
it('reports disabled, unsupported and rows of another owner without touching anything', async () => {
|
||||
const off = createHarness();
|
||||
off.deps.isSyncEnabled = async () => false;
|
||||
expect((await off.run()).outcome).toBe('disabled');
|
||||
expect(off.server.requests).toHaveLength(0);
|
||||
|
||||
const bare = createHarness();
|
||||
const port = bare.server.port();
|
||||
bare.deps.jmap = async () => ({
|
||||
origin: 'https://mail.example.com',
|
||||
port: { ...port, session: () => ({ ...port.session(), primaryAccounts: {}, accounts: {} }) },
|
||||
});
|
||||
expect((await bare.run()).outcome).toBe('unsupported');
|
||||
|
||||
const other = createHarness();
|
||||
addServerCards(other, ['Ada']);
|
||||
await other.run();
|
||||
other.deps.jmap = async () => ({ port: other.server.port(), origin: 'https://other.example.com' });
|
||||
const moved = await other.run();
|
||||
expect(moved.outcome).toBe('internal');
|
||||
expect(moved.message).toMatch(/another server/);
|
||||
expect(other.contacts()).toHaveLength(1);
|
||||
const foreign = await runAs(other, 'bob@mail.example.com');
|
||||
expect(foreign.outcome).toBe('internal');
|
||||
expect(foreign.message).toMatch(/another account/);
|
||||
});
|
||||
|
||||
it('keeps one request in flight and stays within the server limits', async () => {
|
||||
const h = createHarness();
|
||||
h.server.setLimits({ maxObjectsInGet: 20, maxObjectsInSet: 10 });
|
||||
addServerCards(h, Array.from({ length: 60 }, (_, i) => `Server ${i}`));
|
||||
await h.run();
|
||||
for (let i = 0; i < 25; i++) addDeviceContact(h, `Device ${i}`);
|
||||
|
||||
const report = await h.run();
|
||||
|
||||
expect(report.outcome).toBe('ok');
|
||||
expect(h.server.maxInFlight).toBe(1);
|
||||
for (const [, args] of h.server.calls('ContactCard/get')) {
|
||||
expect(Array.isArray(args.ids)).toBe(true);
|
||||
expect((args.ids as string[]).length).toBeLessThanOrEqual(20);
|
||||
}
|
||||
for (const [, args] of h.server.calls('ContactCard/set')) {
|
||||
const count = Object.keys(args.create ?? {}).length + Object.keys(args.update ?? {}).length + ((args.destroy as string[]) ?? []).length;
|
||||
expect(count).toBeLessThanOrEqual(10);
|
||||
}
|
||||
expect(h.serverNames()).toHaveLength(85);
|
||||
expect(h.contacts()).toHaveLength(85);
|
||||
expect(ANDROID_ACCOUNT).toBe('alice@example.com');
|
||||
});
|
||||
|
||||
it('packs provider batches of at most 400 ops with a yield point at every group and the state op last', async () => {
|
||||
const h = createHarness();
|
||||
addServerCards(h, Array.from({ length: 450 }, (_, i) => `Person ${i}`));
|
||||
|
||||
await h.run();
|
||||
|
||||
expect(h.contacts()).toHaveLength(450);
|
||||
for (const ops of h.batches.log) {
|
||||
expect(ops.length).toBeLessThanOrEqual(400);
|
||||
const state = ops.findIndex((op) => op.op === 'syncState');
|
||||
if (state >= 0) {
|
||||
expect(state).toBe(ops.length - 1);
|
||||
expect((ops[state] as { yieldAllowed?: boolean }).yieldAllowed).toBeFalsy();
|
||||
}
|
||||
// Every download insert starts a group with its "not there yet" guard: a yield point, and nothing inside a group is one.
|
||||
ops.forEach((op, i) => {
|
||||
if (op.op === 'insert' && op.table === 'raw_contacts') {
|
||||
expect(ops[i - 1]).toMatchObject({ op: 'assert', table: 'raw_contacts', expectCount: 0, yieldAllowed: true });
|
||||
expect(op.yieldAllowed).toBeFalsy();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
// Items created on the device and deleted before any run claimed them.
|
||||
// ContactsProvider soft-deletes every raw contact and group of a sync account
|
||||
// (DELETED=1, SOURCE_ID NULL), CalendarProvider hard-deletes an event without
|
||||
// `_SYNC_ID` at once. Such an item never reached the server: it is purged,
|
||||
// nothing uploads, and it never counts as a change waiting for an upload.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import { contactsPlanner } from '../../contacts/planner';
|
||||
import {
|
||||
addDeviceContact,
|
||||
addServerCards,
|
||||
ANDROID_ACCOUNT,
|
||||
CALENDAR_AUTHORITY,
|
||||
CONTACTS_AUTHORITY,
|
||||
createHarness,
|
||||
type Harness,
|
||||
} from './harness';
|
||||
|
||||
function real(): Harness {
|
||||
const h = createHarness();
|
||||
h.deps.planners = { contacts: contactsPlanner, calendar: calendarPlanner };
|
||||
return h;
|
||||
}
|
||||
|
||||
/** Synced once, then a contact and a group created on the device and deleted again. */
|
||||
async function createdAndDeleted(): Promise<{ h: Harness; contact: number; group: number }> {
|
||||
const h = real();
|
||||
addServerCards(h, ['Ada Lovelace']);
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
const contact = addDeviceContact(h, 'Hedy Lamarr');
|
||||
h.device.user.deleteContact(contact);
|
||||
const group = h.device.user.insertGroup(ANDROID_ACCOUNT, 'Friends');
|
||||
h.device.user.deleteGroup(group);
|
||||
return { h, contact, group };
|
||||
}
|
||||
|
||||
function offline(h: Harness): void {
|
||||
h.deps.jmap = async () => {
|
||||
const error = new Error('Network request failed');
|
||||
error.name = 'NetworkError';
|
||||
throw error;
|
||||
};
|
||||
}
|
||||
|
||||
describe('device sync engine: an item deleted on the device before it was claimed', () => {
|
||||
it('purges a contact and a group, and uploads nothing', async () => {
|
||||
const { h, contact, group } = await createdAndDeleted();
|
||||
|
||||
const report = await h.run();
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 0, updated: 0, deleted: 0 } } });
|
||||
expect(h.device.row('raw_contacts', contact)).toBeUndefined();
|
||||
expect(h.device.rows('data', 'raw_contact_id = ?', [contact])).toEqual([]);
|
||||
expect(h.device.row('groups', group)).toBeUndefined();
|
||||
expect(h.server.all('ContactCard', 'a').map((c) => c.name)).toEqual([{ full: 'Ada Lovelace' }]);
|
||||
});
|
||||
|
||||
it('does not turn an upload-only sync into a full one', async () => {
|
||||
const { h } = await createdAndDeleted();
|
||||
const requests = h.server.requests.length;
|
||||
|
||||
expect((await h.run(CONTACTS_AUTHORITY, { upload: true })).outcome).toBe('ok');
|
||||
|
||||
expect(h.server.requests.length).toBe(requests);
|
||||
});
|
||||
|
||||
it('is no change that turning sync off would lose, also while offline', async () => {
|
||||
const { h } = await createdAndDeleted();
|
||||
offline(h);
|
||||
|
||||
expect(await h.teardown(CONTACTS_AUTHORITY)).toEqual({ pending: 0 });
|
||||
|
||||
expect(h.device.rows('raw_contacts')).toEqual([]);
|
||||
expect(h.device.rows('groups')).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves nothing behind for an event: CalendarProvider removes it at once', async () => {
|
||||
const h = real();
|
||||
expect((await h.run(CALENDAR_AUTHORITY)).outcome).toBe('ok');
|
||||
const calendarRow = Number(h.device.rows('calendars')[0]._id);
|
||||
const event = h.device.user.insertEvent(calendarRow, {
|
||||
title: 'Dentist',
|
||||
dtstart: Date.UTC(2026, 9, 1, 8),
|
||||
dtend: Date.UTC(2026, 9, 1, 9),
|
||||
eventTimezone: 'Europe/Berlin',
|
||||
});
|
||||
h.device.user.deleteEvent(event);
|
||||
expect(h.events()).toEqual([]);
|
||||
const requests = h.server.requests.length;
|
||||
|
||||
expect((await h.run(CALENDAR_AUTHORITY, { upload: true })).outcome).toBe('ok');
|
||||
expect(h.server.requests.length).toBe(requests);
|
||||
offline(h);
|
||||
expect(await h.teardown(CALENDAR_AUTHORITY)).toEqual({ pending: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,672 @@
|
||||
// How the engine survives crashes, lost responses and server trouble, and
|
||||
// its guards (docs/device-sync.md, "Invariants", "Failure matrix",
|
||||
// "Deletion threshold").
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseJsonColumn } from '../../common/json';
|
||||
import type { PoisonMarker } from '../../planner';
|
||||
import type { JmapInvocation, JmapResponse } from '../../types';
|
||||
import {
|
||||
addDeviceContact,
|
||||
addServerCards,
|
||||
CALENDAR_AUTHORITY,
|
||||
CONTACTS_AUTHORITY,
|
||||
createHarness,
|
||||
CrashError,
|
||||
renameDeviceContact,
|
||||
REGISTRY_ID,
|
||||
type Harness,
|
||||
} from './harness';
|
||||
|
||||
const FINAL = ['Ada King', 'Grace Hopper', 'Hedy', 'Margaret'];
|
||||
|
||||
/** Work on both sides: device edit, create and delete; server update, create and delete. */
|
||||
async function scenario(options: Parameters<typeof createHarness>[0] = {}): Promise<Harness> {
|
||||
const h = createHarness(options);
|
||||
const [, grace, , katherine] = addServerCards(h, ['Ada', 'Grace', 'Linus', 'Katherine']);
|
||||
await h.run();
|
||||
renameDeviceContact(h, h.contactNamed('Ada')!.id, 'Ada King');
|
||||
addDeviceContact(h, 'Hedy');
|
||||
h.device.user.deleteContact(h.contactNamed('Linus')!.id);
|
||||
h.server.serverUpdate('ContactCard', 'a', grace, { 'name/full': 'Grace Hopper' });
|
||||
addServerCards(h, ['Margaret']);
|
||||
h.server.serverDestroy('ContactCard', 'a', katherine);
|
||||
h.checkpoints.count = 0;
|
||||
h.batches.applied = 0;
|
||||
return h;
|
||||
}
|
||||
|
||||
/** Both sides hold exactly the expected contacts, once each, and nothing waits for an upload. */
|
||||
function expectConverged(h: Harness, names = FINAL): void {
|
||||
const device = h.contacts();
|
||||
expect(device.map((c) => c.name).sort()).toEqual(names);
|
||||
expect(device.every((c) => !c.dirty && !c.deleted && c.sourceId)).toBe(true);
|
||||
expect(h.serverNames()).toEqual(names);
|
||||
const refs = device.map((c) => c.sourceId);
|
||||
expect(new Set(refs).size).toBe(refs.length);
|
||||
for (const contact of device) {
|
||||
const card = h.server.get('ContactCard', 'a', contact.sourceId!.split('/')[1]);
|
||||
expect((card?.name as { full: string }).full).toBe(contact.name);
|
||||
}
|
||||
const uids = h.server.all('ContactCard', 'a').map((c) => c.uid);
|
||||
expect(new Set(uids).size).toBe(uids.length);
|
||||
}
|
||||
|
||||
describe('device sync engine: crashes and lost responses', () => {
|
||||
it('converges after a crash at any checkpoint, with no duplicates and no lost edits', async () => {
|
||||
const probe = await scenario();
|
||||
await probe.run();
|
||||
expectConverged(probe);
|
||||
const checkpoints = probe.checkpoints.count;
|
||||
expect(checkpoints).toBeGreaterThan(5);
|
||||
|
||||
for (let n = 1; n <= checkpoints; n++) {
|
||||
const h = await scenario();
|
||||
h.checkpoints.crashAt = n;
|
||||
const crashed = await h.run();
|
||||
expect(crashed.outcome, `crash at checkpoint ${n}`).toBe('internal');
|
||||
h.checkpoints.crashAt = null;
|
||||
const report = await h.run();
|
||||
expect(report.outcome, `after a crash at checkpoint ${n}`).toBe('ok');
|
||||
expectConverged(h);
|
||||
}
|
||||
});
|
||||
|
||||
it('converges after a crash after any number of provider batches', async () => {
|
||||
const probe = await scenario();
|
||||
await probe.run();
|
||||
const batches = probe.batches.applied;
|
||||
expect(batches).toBeGreaterThan(3);
|
||||
|
||||
for (let n = 0; n < batches; n++) {
|
||||
const h = await scenario();
|
||||
h.batches.crashAfter = n;
|
||||
await h.run();
|
||||
h.batches.crashAfter = null;
|
||||
const report = await h.run();
|
||||
expect(report.outcome, `after a crash following batch ${n}`).toBe('ok');
|
||||
expectConverged(h);
|
||||
}
|
||||
});
|
||||
|
||||
it('converges when the server applied any request but its response was lost', async () => {
|
||||
const probe = await scenario();
|
||||
const before = probe.server.requests.length;
|
||||
await probe.run();
|
||||
const requests = probe.server.requests.length - before;
|
||||
|
||||
for (let n = 1; n <= requests; n++) {
|
||||
const h = await scenario();
|
||||
// The uid index lags behind, as on Stalwart: a lookup right after the lost create misses it.
|
||||
h.server.setUidIndexLag(2);
|
||||
let seen = 0;
|
||||
const stop = h.server.onBeforeRequest(() => {
|
||||
if (++seen === n) h.server.applyThenLoseResponse();
|
||||
});
|
||||
const lost = await h.run();
|
||||
stop();
|
||||
expect(lost.outcome, `lost response ${n}`).toBe('io');
|
||||
const report = await h.run();
|
||||
expect(report.outcome, `after losing response ${n}`).toBe('ok');
|
||||
expectConverged(h);
|
||||
}
|
||||
});
|
||||
|
||||
it('converges from a crash anywhere in a first sync (a checkpointed full reconcile)', async () => {
|
||||
const names = Array.from({ length: 120 }, (_, i) => `P${String(i).padStart(3, '0')}`);
|
||||
const fresh = () => {
|
||||
const h = createHarness();
|
||||
addServerCards(h, names);
|
||||
return h;
|
||||
};
|
||||
const probe = fresh();
|
||||
await probe.run();
|
||||
const checkpoints = probe.checkpoints.count;
|
||||
|
||||
for (let n = 1; n <= checkpoints; n += 2) {
|
||||
const h = fresh();
|
||||
h.checkpoints.crashAt = n;
|
||||
await h.run();
|
||||
h.checkpoints.crashAt = null;
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
expect(h.contacts().map((c) => c.name).sort()).toEqual(names);
|
||||
expect(new Set(h.contacts().map((c) => c.sourceId)).size).toBe(120);
|
||||
}
|
||||
});
|
||||
|
||||
it('resumes a reconcile after its last stored chunk instead of starting over', async () => {
|
||||
const names = Array.from({ length: 150 }, (_, i) => `P${String(i).padStart(3, '0')}`);
|
||||
const h = createHarness();
|
||||
addServerCards(h, names);
|
||||
// Batches: the Settings row, the reconcile marker, then one per chunk of 50. Stop after the second chunk.
|
||||
h.batches.crashAfter = 4;
|
||||
await h.run();
|
||||
const marker = h.state()?.accounts.a.reconcile;
|
||||
expect(marker).toMatchObject({ phase: 'objects', position: 100 });
|
||||
h.batches.crashAfter = null;
|
||||
const gets = h.server.calls('ContactCard/get').length;
|
||||
|
||||
await h.run();
|
||||
|
||||
const fetched = h.server
|
||||
.calls('ContactCard/get')
|
||||
.slice(gets)
|
||||
.filter(([, args]) => (args.properties as string[]).includes('name'))
|
||||
.flatMap(([, args]) => args.ids as string[]);
|
||||
expect(fetched).toHaveLength(50);
|
||||
expect(h.contacts()).toHaveLength(150);
|
||||
expect(h.state()?.accounts.a.reconcile).toBeNull();
|
||||
});
|
||||
|
||||
it('adopts its own create whose response was lost instead of creating it twice', async () => {
|
||||
const h = createHarness();
|
||||
addServerCards(h, ['Ada']);
|
||||
await h.run();
|
||||
h.server.setUidIndexLag(3);
|
||||
const id = addDeviceContact(h, 'Hedy');
|
||||
h.server.applyThenLoseResponse({ match: 'ContactCard/set' });
|
||||
|
||||
expect((await h.run()).outcome).toBe('io');
|
||||
expect(h.serverNames()).toEqual(['Ada', 'Hedy']);
|
||||
expect(h.contacts().find((c) => c.id === id)?.sourceId).toBeNull();
|
||||
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
expect(h.serverNames()).toEqual(['Ada', 'Hedy']);
|
||||
expect(h.contacts().find((c) => c.id === id)).toMatchObject({ name: 'Hedy', dirty: false, sourceId: expect.stringMatching(/^a\//) });
|
||||
expect(h.contacts()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("adopts the card a duplicate-uid error names (the client's automatic retry of a create)", async () => {
|
||||
const h = createHarness();
|
||||
await h.run();
|
||||
addDeviceContact(h, 'Hedy');
|
||||
// The first attempt was applied; the retry answers with Stalwart's duplicate-uid error.
|
||||
const port = h.server.port();
|
||||
let retried = false;
|
||||
h.deps.jmap = async () => ({
|
||||
origin: 'https://mail.example.com',
|
||||
port: {
|
||||
...port,
|
||||
request: async (calls: JmapInvocation[], using: string[]): Promise<JmapResponse> => {
|
||||
const response = await port.request(calls, using);
|
||||
const [name, args] = response.methodResponses[0];
|
||||
if (retried || name !== 'ContactCard/set' || !args.created) return response;
|
||||
retried = true;
|
||||
const [[key, created]] = Object.entries(args.created as Record<string, { id: string }>);
|
||||
const uid = (calls[0][1].create as Record<string, { uid: string }>)[key].uid;
|
||||
const notCreated = {
|
||||
[key]: { type: 'invalidProperties', properties: ['uid'], description: `Contact with UID ${uid} already exists with id ${created.id}.` },
|
||||
};
|
||||
return { ...response, methodResponses: [[name, { ...args, created: undefined, notCreated }, response.methodResponses[0][2]]] };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const report = await h.run();
|
||||
|
||||
expect(report.outcome).toBe('ok');
|
||||
expect(retried).toBe(true);
|
||||
expect(h.serverNames()).toEqual(['Hedy']);
|
||||
expect(h.contacts()).toEqual([expect.objectContaining({ name: 'Hedy', dirty: false, sourceId: expect.stringMatching(/^a\//) })]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('device sync engine: server trouble', () => {
|
||||
it('downloads again and retries the upload after a stateMismatch', async () => {
|
||||
const h = createHarness();
|
||||
const [ada, grace] = addServerCards(h, ['Ada', 'Grace']);
|
||||
await h.run();
|
||||
renameDeviceContact(h, h.contactNamed('Ada')!.id, 'Ada King');
|
||||
let sets = 0;
|
||||
const stop = h.server.onBeforeRequest((request) => {
|
||||
// Another client writes right before our first upload.
|
||||
if (request.methods.includes('ContactCard/set') && ++sets === 1) {
|
||||
h.server.serverUpdate('ContactCard', 'a', grace, { 'name/full': 'Grace Hopper' });
|
||||
}
|
||||
});
|
||||
|
||||
const report = await h.run();
|
||||
stop();
|
||||
|
||||
expect(report.outcome).toBe('ok');
|
||||
const set = h.server.requests.filter((r) => r.methods.includes('ContactCard/set'));
|
||||
expect(set[0].responses?.[0]).toEqual(['error', { type: 'stateMismatch' }, 's']);
|
||||
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ name: { full: 'Ada King' } });
|
||||
expect(h.contacts().map((c) => c.name).sort()).toEqual(['Ada King', 'Grace Hopper']);
|
||||
});
|
||||
|
||||
it('gives up with io and no progress when the server keeps changing', async () => {
|
||||
const h = createHarness();
|
||||
const [, grace] = addServerCards(h, ['Ada', 'Grace']);
|
||||
await h.run();
|
||||
renameDeviceContact(h, h.contactNamed('Ada')!.id, 'Ada King');
|
||||
let n = 0;
|
||||
const stop = h.server.onBeforeRequest((request) => {
|
||||
if (request.methods.includes('ContactCard/set')) h.server.serverUpdate('ContactCard', 'a', grace, { 'name/full': `Grace ${++n}` });
|
||||
});
|
||||
|
||||
const report = await h.run();
|
||||
stop();
|
||||
|
||||
expect(report.outcome).toBe('io');
|
||||
expect(report.stats.downloaded).toEqual({ created: 0, updated: 0, deleted: 0 });
|
||||
expect(n).toBe(4);
|
||||
expect(h.contactNamed('Ada King')?.dirty).toBe(true);
|
||||
});
|
||||
|
||||
it('holds back the uploads of an account that refuses writes, and uploads the other accounts', async () => {
|
||||
const h = createHarness({ accounts: 'withShared' });
|
||||
const teamBook = h.server.all('AddressBook', 'team')[0].id as string;
|
||||
h.prefs.contactsSelection[`team/${teamBook}`] = true;
|
||||
addServerCards(h, ['Ada']);
|
||||
const [mate] = addServerCards(h, ['Teammate'], 'team', teamBook);
|
||||
await h.run();
|
||||
renameDeviceContact(h, h.contactNamed('Ada')!.id, 'Ada King');
|
||||
renameDeviceContact(h, h.contactNamed('Teammate')!.id, 'Team mate');
|
||||
h.server.failNextMethod('ContactCard/set', { type: 'accountReadOnly' }, { accountId: 'team' });
|
||||
|
||||
const report = await h.run();
|
||||
|
||||
expect(report.outcome).toBe('ok');
|
||||
expect(report.itemErrors).toEqual([expect.objectContaining({ ref: 'team', type: 'accountReadOnly' })]);
|
||||
expect(h.serverNames()).toEqual(['Ada King']);
|
||||
expect(h.server.get('ContactCard', 'team', mate)).toMatchObject({ name: { full: 'Teammate' } });
|
||||
expect(h.contactNamed('Team mate')?.dirty).toBe(true);
|
||||
|
||||
await h.run();
|
||||
expect(h.server.get('ContactCard', 'team', mate)).toMatchObject({ name: { full: 'Team mate' } });
|
||||
});
|
||||
|
||||
it('reports a 429 as io with delayUntil from Retry-After', async () => {
|
||||
const h = createHarness();
|
||||
await h.run();
|
||||
h.server.failNextRequest('rateLimit', { retryAfterMs: 120_000 });
|
||||
|
||||
const report = await h.run();
|
||||
|
||||
expect(report.outcome).toBe('io');
|
||||
expect(report.delayUntil).toBe(Math.ceil((h.clock.now + 120_000) / 1000));
|
||||
});
|
||||
|
||||
it('rebuilds the client once when the server rejects the credentials, then reports auth', async () => {
|
||||
const h = createHarness();
|
||||
addServerCards(h, ['Ada']);
|
||||
h.server.failNextRequest('auth');
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
expect(h.deps.notifyAuthProblem).not.toHaveBeenCalled();
|
||||
|
||||
h.server.failNextRequest('auth', { times: 2 });
|
||||
const report = await h.run();
|
||||
|
||||
expect(report.outcome).toBe('auth');
|
||||
expect(h.deps.notifyAuthProblem).toHaveBeenCalledWith(REGISTRY_ID, 'alice@example.com', CONTACTS_AUTHORITY);
|
||||
expect(h.contacts()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports auth without touching anything when the account has no stored credentials', async () => {
|
||||
const h = createHarness();
|
||||
addServerCards(h, ['Ada']);
|
||||
await h.run();
|
||||
const writes = h.batches.log.length;
|
||||
h.deps.jmap = async () => {
|
||||
const error = new Error('No usable credentials for this account');
|
||||
error.name = 'AuthenticationError';
|
||||
throw error;
|
||||
};
|
||||
|
||||
const report = await h.run();
|
||||
|
||||
expect(report.outcome).toBe('auth');
|
||||
expect(h.batches.log.length).toBe(writes);
|
||||
});
|
||||
|
||||
it('full-reconciles an account after cannotCalculateChanges', async () => {
|
||||
const h = createHarness();
|
||||
const [ada, grace] = addServerCards(h, ['Ada', 'Grace', 'Linus']);
|
||||
await h.run();
|
||||
h.server.serverUpdate('ContactCard', 'a', ada, { 'name/full': 'Ada King' });
|
||||
h.server.serverDestroy('ContactCard', 'a', grace);
|
||||
h.server.truncateChangeLog('a', 'contacts');
|
||||
|
||||
const report = await h.run();
|
||||
|
||||
expect(report.outcome).toBe('ok');
|
||||
expect(h.server.requests.some((r) => r.responses?.some(([, args]) => (args as { type?: string }).type === 'cannotCalculateChanges'))).toBe(true);
|
||||
expect(h.contacts().map((c) => c.name).sort()).toEqual(['Ada King', 'Linus']);
|
||||
expect(h.state()?.accounts.a).toMatchObject({ reconcile: null, itemsState: h.server.state('ContactCard', 'a') });
|
||||
});
|
||||
|
||||
it('stops a reconcile that finds an empty server while the device is full (safetyAbort)', async () => {
|
||||
const h = createHarness();
|
||||
const ids = addServerCards(h, Array.from({ length: 12 }, (_, i) => `P${i}`));
|
||||
await h.run();
|
||||
for (const id of ids) h.server.serverDestroy('ContactCard', 'a', id);
|
||||
h.server.truncateChangeLog('a', 'contacts');
|
||||
|
||||
const report = await h.run();
|
||||
|
||||
expect(report.outcome).toBe('safetyAbort');
|
||||
expect(h.contacts()).toHaveLength(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('device sync engine: time and cancellation', () => {
|
||||
it('stops cleanly near the deadline and asks for another sync', async () => {
|
||||
const h = createHarness();
|
||||
addServerCards(h, Array.from({ length: 150 }, (_, i) => `P${i}`));
|
||||
h.checkpoints.onCheckpoint = (n) => {
|
||||
// Time runs out in the middle of the download.
|
||||
if (n === 4) h.clock.now += 9 * 60_000;
|
||||
};
|
||||
|
||||
const report = await h.run();
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'cancelled', moreRecordsToGet: true });
|
||||
expect(h.contacts().length).toBeGreaterThan(0);
|
||||
expect(h.contacts().length).toBeLessThan(150);
|
||||
h.checkpoints.onCheckpoint = undefined;
|
||||
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
expect(h.contacts()).toHaveLength(150);
|
||||
});
|
||||
|
||||
it('stops at the next checkpoint when the framework cancels the run', async () => {
|
||||
const h = createHarness();
|
||||
addServerCards(h, Array.from({ length: 150 }, (_, i) => `P${i}`));
|
||||
h.deps.isCancelled = async () => true;
|
||||
|
||||
const report = await h.run();
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'cancelled', moreRecordsToGet: true });
|
||||
expect(h.contacts()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('sends Core/echo when a local phase went 40 s without a JMAP request', async () => {
|
||||
const h = createHarness();
|
||||
addServerCards(h, Array.from({ length: 120 }, (_, i) => `P${i}`));
|
||||
let n = 0;
|
||||
const port = h.deps.provider;
|
||||
h.deps.provider = (name, authority) => {
|
||||
const inner = port(name, authority);
|
||||
return {
|
||||
...inner,
|
||||
applyBatch: async (ops) => {
|
||||
h.clock.now += 45_000;
|
||||
n++;
|
||||
return inner.applyBatch(ops);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
await h.run();
|
||||
|
||||
expect(n).toBeGreaterThan(2);
|
||||
expect(h.server.calls('Core/echo').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('runs one sync of an account and authority at a time', async () => {
|
||||
const h = createHarness();
|
||||
addServerCards(h, Array.from({ length: 60 }, (_, i) => `P${i}`));
|
||||
|
||||
const [first, second] = await Promise.all([h.run(), h.run()]);
|
||||
|
||||
expect([first.outcome, second.outcome]).toEqual(['ok', 'ok']);
|
||||
expect(h.server.maxInFlight).toBe(1);
|
||||
expect(h.contacts()).toHaveLength(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe('device sync engine: poisoned items', () => {
|
||||
it('backs a refused item off, retries it after the back-off, and at once when it changes', async () => {
|
||||
const h = createHarness();
|
||||
const [ada] = addServerCards(h, ['Ada', 'Grace']);
|
||||
await h.run();
|
||||
const id = h.contactNamed('Ada')!.id;
|
||||
renameDeviceContact(h, id, 'Ada King');
|
||||
h.server.setErrorFor('ContactCard', 'a', (t) => t.op === 'update' && t.id === ada, {
|
||||
type: 'invalidProperties',
|
||||
properties: ['name'],
|
||||
description: 'Invalid property.',
|
||||
}, 3);
|
||||
|
||||
const first = await h.run();
|
||||
|
||||
expect(first.outcome).toBe('ok');
|
||||
expect(first.stats.skipped).toBe(1);
|
||||
expect(first.itemErrors).toEqual([
|
||||
expect.objectContaining({ ref: `a/${ada}`, side: 'upload', type: 'invalidProperties', retryAt: h.clock.now + 3_600_000 }),
|
||||
]);
|
||||
const marker = parseJsonColumn<PoisonMarker>(h.contacts().find((c) => c.id === id)!.row.sync4);
|
||||
expect(marker).toMatchObject({ type: 'invalidProperties', n: 1, until: h.clock.now + 3_600_000 });
|
||||
|
||||
// Not retried hot.
|
||||
const sets = h.server.calls('ContactCard/set').length;
|
||||
const again = await h.run();
|
||||
expect(h.server.calls('ContactCard/set').length).toBe(sets);
|
||||
expect(again.itemErrors).toEqual([expect.objectContaining({ type: 'invalidProperties' })]);
|
||||
|
||||
// Retried once the back-off is over; refused again, the back-off doubles.
|
||||
h.clock.now += 3_600_001;
|
||||
await h.run();
|
||||
expect(h.server.calls('ContactCard/set').length).toBe(sets + 1);
|
||||
expect(parseJsonColumn<PoisonMarker>(h.contacts().find((c) => c.id === id)!.row.sync4)).toMatchObject({ n: 2, until: h.clock.now + 7_200_000 });
|
||||
|
||||
// Changed by the user: retried at once (the server refuses once more), then accepted.
|
||||
renameDeviceContact(h, id, 'Ada, Countess');
|
||||
await h.run();
|
||||
expect(h.server.calls('ContactCard/set').length).toBe(sets + 2);
|
||||
renameDeviceContact(h, id, 'Ada Lovelace');
|
||||
const fixed = await h.run();
|
||||
expect(fixed.itemErrors).toEqual([]);
|
||||
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ name: { full: 'Ada Lovelace' } });
|
||||
expect(h.contacts().find((c) => c.id === id)!.row.sync4).toBeNull();
|
||||
});
|
||||
|
||||
it('never uploads an item whose rows could not be written until it was fetched again', async () => {
|
||||
const h = createHarness();
|
||||
const [ada] = addServerCards(h, ['Ada']);
|
||||
await h.run();
|
||||
h.server.serverUpdate('ContactCard', 'a', ada, { 'name/full': 'Ada King' });
|
||||
// The provider refuses the item's rows (in its batch and alone): it goes on `stale`, the state still moves on.
|
||||
const provider = h.deps.provider;
|
||||
let refusals = 2;
|
||||
h.deps.provider = (name, authority) => {
|
||||
const port = provider(name, authority);
|
||||
return {
|
||||
...port,
|
||||
applyBatch: async (ops) =>
|
||||
refusals > 0 && ops.some((op) => op.op === 'update' && op.table === 'data') && refusals--
|
||||
? { ok: false, reason: 'provider', message: 'SQLiteException' }
|
||||
: port.applyBatch(ops),
|
||||
};
|
||||
};
|
||||
const failing = await h.run();
|
||||
expect(refusals).toBe(0);
|
||||
expect(failing.itemErrors).toEqual([expect.objectContaining({ ref: `a/${ada}`, side: 'download', type: 'provider' })]);
|
||||
expect(h.state()?.accounts.a.stale).toEqual([ada]);
|
||||
expect(h.state()?.accounts.a.itemsState).toBe(h.server.state('ContactCard', 'a'));
|
||||
|
||||
const next = await h.run();
|
||||
|
||||
expect(next.outcome).toBe('ok');
|
||||
expect(h.contacts().map((c) => c.name)).toEqual(['Ada King']);
|
||||
expect(h.state()?.accounts.a.stale).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('device sync engine: planner failures', () => {
|
||||
it('keeps syncing everything else when a planner throws on one item, and tries that one again next run', async () => {
|
||||
const h = createHarness();
|
||||
const [, grace] = addServerCards(h, ['Ada', 'Grace', 'Linus']);
|
||||
let broken = true;
|
||||
const planner = h.deps.planners.contacts;
|
||||
h.deps.planners = {
|
||||
...h.deps.planners,
|
||||
contacts: {
|
||||
...planner,
|
||||
planDownload: (card, local, ctx) => {
|
||||
if (broken && card.id === grace) throw new TypeError('cannot read the card');
|
||||
return planner.planDownload(card, local, ctx);
|
||||
},
|
||||
planUpload: (local, ctx) => {
|
||||
if (broken && local.rows.some((r) => r.cells.data1 === 'Hedy')) throw new TypeError('cannot read the rows');
|
||||
return planner.planUpload(local, ctx);
|
||||
},
|
||||
},
|
||||
};
|
||||
addDeviceContact(h, 'Hedy');
|
||||
addDeviceContact(h, 'Mary');
|
||||
|
||||
const report = await h.run();
|
||||
|
||||
expect(report.outcome).toBe('ok');
|
||||
expect(report.itemErrors).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ ref: `a/${grace}`, side: 'download', type: 'plannerError' }),
|
||||
expect.objectContaining({ side: 'upload', type: 'plannerError' }),
|
||||
]),
|
||||
);
|
||||
expect(h.contacts().map((c) => c.name).sort()).toEqual(['Ada', 'Hedy', 'Linus', 'Mary']);
|
||||
expect(h.serverNames()).toEqual(['Ada', 'Grace', 'Linus', 'Mary']);
|
||||
// The reconcile completed past the item that could not be planned.
|
||||
expect(h.state()?.accounts.a).toMatchObject({ stale: [grace], reconcile: null, itemsState: expect.any(String) });
|
||||
|
||||
broken = false;
|
||||
expect((await h.run()).itemErrors).toEqual([]);
|
||||
expect(h.contacts().map((c) => c.name).sort()).toEqual(['Ada', 'Grace', 'Hedy', 'Linus', 'Mary']);
|
||||
expect(h.serverNames()).toEqual(['Ada', 'Grace', 'Hedy', 'Linus', 'Mary']);
|
||||
expect(h.state()?.accounts.a.stale).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('device sync engine: deletion guards', () => {
|
||||
async function withDeletions(total: number, deleted: number): Promise<Harness> {
|
||||
const h = createHarness();
|
||||
addServerCards(h, Array.from({ length: total }, (_, i) => `P${String(i).padStart(3, '0')}`));
|
||||
await h.run();
|
||||
for (const contact of h.contacts().slice(0, deleted)) h.device.user.deleteContact(contact.id);
|
||||
return h;
|
||||
}
|
||||
|
||||
it('holds every deletion back above 50 and 20 % (tooManyDeletions)', async () => {
|
||||
const h = await withDeletions(100, 55);
|
||||
|
||||
const report = await h.run();
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'tooManyDeletions', tooManyDeletions: { count: 55, threshold: 50 } });
|
||||
expect(h.serverNames()).toHaveLength(100);
|
||||
expect(h.contacts().filter((c) => c.deleted)).toHaveLength(55);
|
||||
});
|
||||
|
||||
it('uploads the deletions when the run overrides the threshold', async () => {
|
||||
const h = await withDeletions(100, 55);
|
||||
|
||||
const report = await h.run(CONTACTS_AUTHORITY, { overrideTooManyDeletions: true });
|
||||
|
||||
expect(report.outcome).toBe('ok');
|
||||
expect(report.stats.uploaded.deleted).toBe(55);
|
||||
expect(h.serverNames()).toHaveLength(45);
|
||||
expect(h.contacts()).toHaveLength(45);
|
||||
});
|
||||
|
||||
it('restores the deleted contacts from the server when the run discards the deletions', async () => {
|
||||
const h = await withDeletions(100, 55);
|
||||
|
||||
const discard = await h.run(CONTACTS_AUTHORITY, { discardLocalDeletions: true });
|
||||
expect(discard.outcome).toBe('ok');
|
||||
expect(h.serverNames()).toHaveLength(100);
|
||||
|
||||
const next = await h.run(CONTACTS_AUTHORITY, { upload: true });
|
||||
expect(next.outcome).toBe('ok');
|
||||
expect(h.contacts()).toHaveLength(100);
|
||||
expect(h.contacts().every((c) => !c.deleted && !c.dirty)).toBe(true);
|
||||
});
|
||||
|
||||
it('uploads deletions below the threshold', async () => {
|
||||
const h = await withDeletions(100, 20);
|
||||
|
||||
const report = await h.run();
|
||||
|
||||
expect(report.outcome).toBe('ok');
|
||||
expect(h.serverNames()).toHaveLength(80);
|
||||
});
|
||||
|
||||
it('counts groups an app hard-deleted, found by their absence', async () => {
|
||||
const h = createHarness();
|
||||
const group = h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' } });
|
||||
addServerCards(h, ['Ada']);
|
||||
await h.run();
|
||||
|
||||
h.device.user.fossifyDeleteGroup(Number(h.device.rows('groups')[0]._id));
|
||||
const report = await h.run();
|
||||
|
||||
expect(report.stats.uploaded.deleted).toBe(1);
|
||||
expect(h.server.get('ContactCard', 'a', group)).toBeUndefined();
|
||||
expect(h.state()?.accounts.a.groups).toEqual([]);
|
||||
});
|
||||
|
||||
it('fails a crash-free run with CrashError only when injected', () => {
|
||||
expect(new CrashError('x').name).toBe('CrashError');
|
||||
});
|
||||
});
|
||||
|
||||
describe('device sync engine: accounts that refuse access', () => {
|
||||
// Stalwart lists an account shared for mail only with every capability,
|
||||
// and its /get calls answer `forbidden`.
|
||||
it('syncs the personal account when a shared one refuses to list its address books', async () => {
|
||||
const h = createHarness({ accounts: 'withShared' });
|
||||
addServerCards(h, ['Ada']);
|
||||
h.server.failNextMethod('AddressBook/get', { type: 'forbidden', description: 'You do not have access to account team' }, { accountId: 'team' });
|
||||
|
||||
const report = await h.run();
|
||||
|
||||
expect(report.outcome).toBe('ok');
|
||||
expect(report.itemErrors).toEqual([]);
|
||||
expect(h.contactNamed('Ada')).toBeDefined();
|
||||
});
|
||||
|
||||
it('keeps the contacts of an account that stops listing its address books, and syncs it again once it does', async () => {
|
||||
const h = createHarness({ accounts: 'withShared' });
|
||||
const teamBook = h.server.all('AddressBook', 'team')[0].id as string;
|
||||
h.prefs.contactsSelection[`team/${teamBook}`] = true;
|
||||
const [mate] = addServerCards(h, ['Teammate'], 'team', teamBook);
|
||||
await h.run();
|
||||
expect(h.contactNamed('Teammate')).toBeDefined();
|
||||
|
||||
addServerCards(h, ['Ada']);
|
||||
h.server.serverUpdate('ContactCard', 'team', mate, { 'name/full': 'Team mate' });
|
||||
h.server.failNextMethod('AddressBook/get', { type: 'forbidden' }, { accountId: 'team' });
|
||||
const report = await h.run();
|
||||
|
||||
expect(report.outcome).toBe('ok');
|
||||
expect(h.contactNamed('Ada')).toBeDefined();
|
||||
expect(h.contactNamed('Teammate')).toBeDefined();
|
||||
|
||||
expect((await h.run()).itemErrors).toEqual([]);
|
||||
expect(h.contactNamed('Team mate')).toBeDefined();
|
||||
});
|
||||
|
||||
it('keeps the calendar and events of an account that refuses to list its calendars', async () => {
|
||||
const h = createHarness({ accounts: 'withShared' });
|
||||
const teamCalendar = h.server.all('Calendar', 'team')[0].id as string;
|
||||
h.prefs.calendarSelection[`team/${teamCalendar}`] = true;
|
||||
h.server.addEvent('team', {
|
||||
uid: 'uid-team',
|
||||
title: 'Team meeting',
|
||||
start: '2026-09-28T09:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'Etc/UTC',
|
||||
calendarIds: { [teamCalendar]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
expect(h.events()).toHaveLength(1);
|
||||
|
||||
h.server.failNextMethod('Calendar/get', { type: 'forbidden' }, { accountId: 'team' });
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report.outcome).toBe('ok');
|
||||
expect(report.itemErrors).toEqual([]);
|
||||
expect(h.events()).toHaveLength(1);
|
||||
expect(h.device.rows('calendars').some((c) => c._sync_id === `team/${teamCalendar}`)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
// SyncState.groups lists the group cards present on the device, so a group an
|
||||
// app hard-deleted (Fossify) is found by its absence and deleted on the server.
|
||||
// The list must change in the same batch as the rows: a group row the engine
|
||||
// removed but still listed reads as a hard delete and destroys the card, and a
|
||||
// row it inserted but never listed hides a later hard delete.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MimeType } from '../../android-columns';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import { contactsPlanner } from '../../contacts/planner';
|
||||
import { serializeSyncState } from '../../engine/sync-state';
|
||||
import { addServerCards, ANDROID_ACCOUNT, CONTACTS_AUTHORITY, createHarness, RUN_BUDGET, type Harness } from './harness';
|
||||
|
||||
function real(): Harness {
|
||||
const h = createHarness();
|
||||
h.deps.planners = { contacts: contactsPlanner, calendar: calendarPlanner };
|
||||
return h;
|
||||
}
|
||||
|
||||
/** The personal book with Ada, and a Work book with Boss, Colleague and the group "Team" (Boss). */
|
||||
async function withWorkGroup() {
|
||||
const h = real();
|
||||
const work = h.server.addAddressBook('a', { name: 'Work' });
|
||||
addServerCards(h, ['Ada']);
|
||||
const [boss] = addServerCards(h, ['Boss', 'Colleague'], 'a', work);
|
||||
const bossUid = h.server.get('ContactCard', 'a', boss)!.uid as string;
|
||||
const group = h.server.addCard('a', {
|
||||
uid: 'g-team',
|
||||
kind: 'group',
|
||||
name: { full: 'Team' },
|
||||
addressBookIds: { [work]: true },
|
||||
members: { [bossUid]: true },
|
||||
});
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
expect(h.state()?.accounts.a.groups).toEqual([`a/${group}`]);
|
||||
h.checkpoints.count = 0;
|
||||
return { h, group, work };
|
||||
}
|
||||
|
||||
/** Replaces the stored SyncState's `groups` of account `a` (a list that drifted from the rows). */
|
||||
async function storeGroups(h: Harness, groups: string[]): Promise<void> {
|
||||
const state = h.state()!;
|
||||
state.accounts.a.groups = groups;
|
||||
await h.device.port(ANDROID_ACCOUNT, CONTACTS_AUTHORITY).applyBatch([{ op: 'syncState', value: serializeSyncState(state) }]);
|
||||
}
|
||||
|
||||
describe('device sync engine: the groups a SyncState lists', () => {
|
||||
it('keeps a group card on the server after a deselection stopped at any checkpoint', async () => {
|
||||
const probe = await withWorkGroup();
|
||||
probe.h.prefs.contactsSelection[`a/${probe.work}`] = false;
|
||||
await probe.h.run();
|
||||
const total = probe.h.checkpoints.count;
|
||||
expect(probe.h.server.get('ContactCard', 'a', probe.group)).toBeDefined();
|
||||
|
||||
for (let n = 1; n <= total; n++) {
|
||||
const { h, group, work } = await withWorkGroup();
|
||||
h.prefs.contactsSelection[`a/${work}`] = false;
|
||||
h.checkpoints.crashAt = n;
|
||||
await h.run();
|
||||
h.checkpoints.crashAt = null;
|
||||
expect((await h.run()).outcome, `run after a crash at checkpoint ${n}`).toBe('ok');
|
||||
expect(h.server.get('ContactCard', 'a', group), `group card after a crash at checkpoint ${n}`).toBeDefined();
|
||||
expect(h.device.rows('groups')).toEqual([]);
|
||||
expect(h.state()?.accounts.a.groups).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a group card on the server when its book is selected again after a stopped deselection', async () => {
|
||||
const { h, group, work } = await withWorkGroup();
|
||||
h.prefs.contactsSelection[`a/${work}`] = false;
|
||||
// Checkpoint 3 comes right after the deselected book's group rows went.
|
||||
h.checkpoints.crashAt = 3;
|
||||
await h.run();
|
||||
h.checkpoints.crashAt = null;
|
||||
expect(h.device.rows('groups')).toEqual([]);
|
||||
|
||||
h.prefs.contactsSelection[`a/${work}`] = true;
|
||||
const report = await h.run();
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', stats: { uploaded: { deleted: 0 } } });
|
||||
expect(h.server.get('ContactCard', 'a', group)).toBeDefined();
|
||||
});
|
||||
|
||||
it('keeps a group card on the server when sync is turned off while a deselection drops it', async () => {
|
||||
const { h, group, work } = await withWorkGroup();
|
||||
h.prefs.contactsSelection[`a/${work}`] = false;
|
||||
let teardown: Promise<{ pending: number }> | null = null;
|
||||
h.checkpoints.onCheckpoint = (n) => {
|
||||
// Right before the run drops the deselected book's rows.
|
||||
if (n === 2 && !teardown) teardown = h.teardown(CONTACTS_AUTHORITY);
|
||||
};
|
||||
|
||||
const run = await h.run();
|
||||
h.checkpoints.onCheckpoint = undefined;
|
||||
|
||||
expect(run.outcome).toBe('cancelled');
|
||||
expect(await teardown!).toEqual({ pending: 0 });
|
||||
expect(h.contacts()).toHaveLength(0);
|
||||
expect(h.server.get('ContactCard', 'a', group)).toBeDefined();
|
||||
});
|
||||
|
||||
it('keeps a group card on the server when the deselecting run runs out of time after dropping the group', async () => {
|
||||
const { h, group, work } = await withWorkGroup();
|
||||
h.prefs.contactsSelection[`a/${work}`] = false;
|
||||
h.checkpoints.onCheckpoint = (n) => {
|
||||
if (n === 2) h.clock.now += RUN_BUDGET;
|
||||
};
|
||||
|
||||
expect((await h.run()).outcome).toBe('cancelled');
|
||||
h.checkpoints.onCheckpoint = undefined;
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
|
||||
expect(h.server.get('ContactCard', 'a', group)).toBeDefined();
|
||||
});
|
||||
|
||||
it('keeps a group card the server moved to a book that does not sync when the run stops within the page', async () => {
|
||||
const h = real();
|
||||
const archive = h.server.addAddressBook('a', { name: 'Archive' });
|
||||
h.prefs.contactsSelection[`a/${archive}`] = false;
|
||||
const ids = addServerCards(h, Array.from({ length: 60 }, (_, i) => `P${String(i).padStart(2, '0')}`));
|
||||
const group = h.server.addCard('a', { uid: 'g-team', kind: 'group', name: { full: 'Team' }, addressBookIds: { [h.book]: true }, members: {} });
|
||||
await h.run();
|
||||
expect(h.device.rows('groups')).toHaveLength(1);
|
||||
// Another client moves the group to Archive and renames 60 contacts: one /changes page, several chunks.
|
||||
h.server.serverUpdate('ContactCard', 'a', group, { addressBookIds: { [archive]: true } });
|
||||
ids.forEach((id, i) => h.server.serverUpdate('ContactCard', 'a', id, { 'name/full': `Q${i}` }));
|
||||
// Checkpoints: 'changes', 'download' (the group's chunk), 'download' (the first contacts chunk) …
|
||||
h.checkpoints.count = 0;
|
||||
h.checkpoints.crashAt = 3;
|
||||
|
||||
await h.run();
|
||||
h.checkpoints.crashAt = null;
|
||||
expect(h.device.rows('groups')).toHaveLength(0);
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
|
||||
expect(h.server.get('ContactCard', 'a', group)).toBeDefined();
|
||||
expect(h.state()?.accounts.a.groups).toEqual([]);
|
||||
});
|
||||
|
||||
it('lists a group written before an interruption, so a hard delete by an app still reaches the server', async () => {
|
||||
const h = real();
|
||||
addServerCards(h, Array.from({ length: 60 }, (_, i) => `P${String(i).padStart(2, '0')}`));
|
||||
const group = h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' }, members: {} });
|
||||
// First sync: 'reconcile', 'reconcile:ids', 'reconcile:objects' (the group's chunk), then out of time.
|
||||
h.checkpoints.onCheckpoint = (n) => {
|
||||
if (n === 3) h.clock.now += RUN_BUDGET;
|
||||
};
|
||||
expect((await h.run()).outcome).toBe('cancelled');
|
||||
h.checkpoints.onCheckpoint = undefined;
|
||||
expect(h.device.rows('groups')).toHaveLength(1);
|
||||
expect(h.state()?.accounts.a.groups).toEqual([`a/${group}`]);
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
expect(h.contacts()).toHaveLength(60);
|
||||
|
||||
// Fossify hard-deletes the group through a sync-adapter URI.
|
||||
h.device.user.fossifyDeleteGroup(Number(h.device.rows('groups')[0]._id));
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
|
||||
expect(h.server.get('ContactCard', 'a', group)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lists a group again when it comes back as an echo', async () => {
|
||||
const h = real();
|
||||
const group = h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' }, members: {} });
|
||||
await h.run();
|
||||
// A list that lost the group (a write committed without its state op), then a download of the unchanged card.
|
||||
await storeGroups(h, []);
|
||||
addServerCards(h, ['Ada']);
|
||||
h.server.truncateChangeLog('a', 'contacts');
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
expect(h.state()?.accounts.a.groups).toEqual([`a/${group}`]);
|
||||
|
||||
h.device.user.fossifyDeleteGroup(Number(h.device.rows('groups')[0]._id));
|
||||
await h.run();
|
||||
|
||||
expect(h.server.get('ContactCard', 'a', group)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("moves a group's membership on the device even when time ran out after the group's chunk", async () => {
|
||||
const h = real();
|
||||
const [ada, grace] = addServerCards(h, ['Ada Lovelace', 'Grace Hopper']);
|
||||
const uid = (id: string) => h.server.get('ContactCard', 'a', id)!.uid as string;
|
||||
const group = h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' }, members: { [uid(ada)]: true } });
|
||||
await h.run();
|
||||
const members = () =>
|
||||
h.device.rows('data')
|
||||
.filter((d) => d.mimetype === MimeType.GROUP_MEMBERSHIP)
|
||||
.map((d) => h.contacts().find((c) => c.id === Number(d.raw_contact_id))?.name)
|
||||
.sort();
|
||||
expect(members()).toEqual(['Ada Lovelace']);
|
||||
// Another client moves the membership from Ada to Grace: only the group card changes.
|
||||
h.server.serverUpdate('ContactCard', 'a', group, { members: { [uid(grace)]: true } });
|
||||
// 'changes', 'download' (the group's chunk), then out of time before the members' chunk.
|
||||
h.checkpoints.count = 0;
|
||||
h.checkpoints.onCheckpoint = (n) => {
|
||||
if (n === 2) h.clock.now += RUN_BUDGET;
|
||||
};
|
||||
expect((await h.run()).outcome).toBe('cancelled');
|
||||
h.checkpoints.onCheckpoint = undefined;
|
||||
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
|
||||
expect(members()).toEqual(['Grace Hopper']);
|
||||
expect(h.state()?.accounts.a.stale).toEqual([]);
|
||||
});
|
||||
|
||||
it('never destroys a listed group card that is in no synced book or no longer a group, and forgets it', async () => {
|
||||
const h = real();
|
||||
const archive = h.server.addAddressBook('a', { name: 'Archive' });
|
||||
h.prefs.contactsSelection[`a/${archive}`] = false;
|
||||
const filed = h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Filed' }, addressBookIds: { [archive]: true }, members: {} });
|
||||
const person = h.server.addCard('a', { uid: 'p1', name: { full: 'Was a group' }, addressBookIds: { [h.book]: true } });
|
||||
await h.run();
|
||||
// The list names both as groups whose rows are gone (the engine removed them, and the batch storing that was lost).
|
||||
await storeGroups(h, [`a/${filed}`, `a/${person}`]);
|
||||
|
||||
const report = await h.run();
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', stats: { uploaded: { deleted: 0 } } });
|
||||
expect(h.server.get('ContactCard', 'a', filed)).toBeDefined();
|
||||
expect(h.server.get('ContactCard', 'a', person)).toBeDefined();
|
||||
expect(h.state()?.accounts.a.groups).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* The engine under test, wired to the fake providers, the fake JMAP server
|
||||
* and the toy planners, with helpers to read both sides and inject faults.
|
||||
*/
|
||||
import { vi } from 'vitest';
|
||||
import { Data, MimeType } from '../../android-columns';
|
||||
import { parseJsonColumn } from '../../common/json';
|
||||
import { runDeviceSync } from '../../engine/run';
|
||||
import { teardownAuthority, type TeardownOptions } from '../../engine/teardown';
|
||||
import type { EngineDeps, JmapConnection, Tuning } from '../../engine/deps';
|
||||
import { openWithAuthRebuild } from '../../jmap/connection';
|
||||
import type { SyncState } from '../../engine/sync-state';
|
||||
import {
|
||||
CALENDAR_AUTHORITY,
|
||||
CONTACTS_AUTHORITY,
|
||||
type AccountSyncPrefs,
|
||||
type Authority,
|
||||
type ProviderOp,
|
||||
type ProviderPort,
|
||||
type RunExtras,
|
||||
type RunReport,
|
||||
type RunStatus,
|
||||
type Row,
|
||||
} from '../../types';
|
||||
import { FakeDeviceProviders } from '../fakes/fake-provider';
|
||||
import { FakeJmapServer } from '../fakes/fake-jmap-server';
|
||||
import { toyCalendarPlanner, toyContactsPlanner } from './toy-planners';
|
||||
|
||||
export const ANDROID_ACCOUNT = 'alice@example.com';
|
||||
export const REGISTRY_ID = 'alice@mail.example.com';
|
||||
export const ORIGIN = 'https://mail.example.com';
|
||||
export const START = Date.UTC(2026, 8, 27, 10, 0, 0);
|
||||
export const RUN_BUDGET = 9 * 60_000;
|
||||
|
||||
/** Deterministic randomness: uids and keys differ between items but repeat between test runs. */
|
||||
export function seededRandom(seed = 42): () => number {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s * 1664525 + 1013904223) >>> 0;
|
||||
return s / 2 ** 32;
|
||||
};
|
||||
}
|
||||
|
||||
export class CrashError extends Error {
|
||||
constructor(where: string) {
|
||||
super(`crash injected ${where}`);
|
||||
this.name = 'CrashError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface Harness {
|
||||
server: FakeJmapServer;
|
||||
device: FakeDeviceProviders;
|
||||
deps: EngineDeps;
|
||||
prefs: AccountSyncPrefs;
|
||||
book: string;
|
||||
calendar: string;
|
||||
clock: { now: number };
|
||||
/** Checkpoints seen by the last runs (each call of isCancelled). */
|
||||
checkpoints: { count: number; crashAt: number | null; onCheckpoint?: (n: number) => void };
|
||||
/** Batches applied through the engine's provider port; crash after `crashAfter` of them. */
|
||||
batches: { applied: number; crashAfter: number | null; log: ProviderOp[][] };
|
||||
run(authority?: Authority, extras?: RunExtras): Promise<RunReport>;
|
||||
teardown(authority: Authority, options?: TeardownOptions): Promise<{ pending: number }>;
|
||||
state(authority?: Authority): SyncState | null;
|
||||
contacts(): Array<{ id: number; sourceId: string | null; name: string | null; dirty: boolean; deleted: boolean; row: Row }>;
|
||||
contactNamed(name: string): { id: number; sourceId: string | null; dirty: boolean; deleted: boolean } | undefined;
|
||||
nameDataRow(rawContactId: number): Row | undefined;
|
||||
events(): Row[];
|
||||
serverNames(accountId?: string): string[];
|
||||
/** The status the runs recorded last for the settings (what `deps.lastStatus` answers). */
|
||||
lastStatus(authority?: Authority): RunStatus | undefined;
|
||||
}
|
||||
|
||||
export function createHarness(options: { tuning?: Partial<Tuning>; accounts?: 'personal' | 'withShared'; rebuild?: boolean } = {}): Harness {
|
||||
const clock = { now: START };
|
||||
const server = new FakeJmapServer({ now: () => clock.now });
|
||||
server.addAccount('a', { name: ANDROID_ACCOUNT });
|
||||
const book = server.addAddressBook('a', { name: 'Personal', isDefault: true });
|
||||
const calendar = server.addCalendar('a', { name: 'Calendar', isDefault: true });
|
||||
if (options.accounts === 'withShared') {
|
||||
server.addAccount('team', { name: 'Team', isPersonal: false });
|
||||
server.addAddressBook('team', { name: 'Team contacts' });
|
||||
server.addCalendar('team', { name: 'Team calendar' });
|
||||
}
|
||||
const device = new FakeDeviceProviders();
|
||||
const prefs: AccountSyncPrefs = { contactsSelection: {}, calendarSelection: {}, reminderOwner: 'device' };
|
||||
const checkpoints: Harness['checkpoints'] = { count: 0, crashAt: null };
|
||||
const batches: Harness['batches'] = { applied: 0, crashAfter: null, log: [] };
|
||||
const statuses = new Map<Authority, RunStatus>();
|
||||
|
||||
const wrap = (port: ProviderPort): ProviderPort => ({
|
||||
accountName: port.accountName,
|
||||
authority: port.authority,
|
||||
query: (q) => port.query(q),
|
||||
readSyncState: () => port.readSyncState(),
|
||||
readPhoto: (id, px) => port.readPhoto(id, px),
|
||||
applyBatch: async (ops) => {
|
||||
if (batches.crashAfter !== null && batches.applied >= batches.crashAfter) throw new CrashError(`after ${batches.applied} batches`);
|
||||
const result = await port.applyBatch(ops);
|
||||
if (result.ok) {
|
||||
batches.applied++;
|
||||
batches.log.push(ops);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
const connection = (): JmapConnection => ({ port: server.port(), origin: ORIGIN });
|
||||
const deps: EngineDeps = {
|
||||
provider: (name, authority) => wrap(device.port(name, authority)),
|
||||
jmap: async () => (options.rebuild === false ? connection() : openWithAuthRebuild(async () => connection())),
|
||||
prefs: async () => prefs,
|
||||
planners: { contacts: toyContactsPlanner, calendar: toyCalendarPlanner },
|
||||
now: () => clock.now,
|
||||
isCancelled: async () => {
|
||||
checkpoints.count++;
|
||||
checkpoints.onCheckpoint?.(checkpoints.count);
|
||||
if (checkpoints.crashAt !== null && checkpoints.count >= checkpoints.crashAt) throw new CrashError(`at checkpoint ${checkpoints.count}`);
|
||||
return false;
|
||||
},
|
||||
random: seededRandom(),
|
||||
subscriptionCalendars: async () => [],
|
||||
isSyncEnabled: async () => true,
|
||||
deviceZone: () => 'Europe/Berlin',
|
||||
recordStatus: vi.fn((_registryId: string, authority: Authority, status: RunStatus) => {
|
||||
statuses.set(authority, status);
|
||||
}),
|
||||
lastStatus: (_registryId, authority) => statuses.get(authority),
|
||||
recordKnownState: vi.fn(),
|
||||
notifyAuthProblem: vi.fn(),
|
||||
yieldThread: async () => undefined,
|
||||
sleep: async () => undefined,
|
||||
tuning: options.tuning,
|
||||
};
|
||||
|
||||
let runs = 0;
|
||||
const harness: Harness = {
|
||||
server,
|
||||
device,
|
||||
deps,
|
||||
prefs,
|
||||
book,
|
||||
calendar,
|
||||
clock,
|
||||
checkpoints,
|
||||
batches,
|
||||
run: (authority = CONTACTS_AUTHORITY, extras = {}) =>
|
||||
runDeviceSync(
|
||||
{ runId: `run-${++runs}`, accountName: ANDROID_ACCOUNT, registryId: REGISTRY_ID, authority, extras, deadline: clock.now + RUN_BUDGET },
|
||||
deps,
|
||||
),
|
||||
teardown: (authority, teardownOptions) => teardownAuthority(deps, REGISTRY_ID, ANDROID_ACCOUNT, authority, teardownOptions),
|
||||
state: (authority = CONTACTS_AUTHORITY) => parseJsonColumn<SyncState>(device.readSyncState(ANDROID_ACCOUNT, authority)),
|
||||
contacts: () =>
|
||||
device.rows('raw_contacts').map((row) => {
|
||||
const id = Number(row._id);
|
||||
const nameRow = harness.nameDataRow(id);
|
||||
return {
|
||||
id,
|
||||
sourceId: (row.sourceid as string | null) ?? null,
|
||||
name: (nameRow?.[Data.DATA1] as string | null) ?? null,
|
||||
dirty: Number(row.dirty) === 1,
|
||||
deleted: Number(row.deleted) === 1,
|
||||
row,
|
||||
};
|
||||
}),
|
||||
contactNamed: (name) => harness.contacts().find((c) => c.name === name),
|
||||
nameDataRow: (rawContactId) =>
|
||||
device.rows('data').find((d) => Number(d.raw_contact_id) === rawContactId && d.mimetype === MimeType.STRUCTURED_NAME),
|
||||
events: () => device.rows('events'),
|
||||
serverNames: (accountId = 'a') =>
|
||||
server
|
||||
.all('ContactCard', accountId)
|
||||
.filter((c) => c.kind !== 'group')
|
||||
.map((c) => String((c.name as { full?: string } | undefined)?.full ?? ''))
|
||||
.sort(),
|
||||
lastStatus: (authority = CONTACTS_AUTHORITY) => statuses.get(authority),
|
||||
};
|
||||
return harness;
|
||||
}
|
||||
|
||||
/** A contact added on the device the way a Contacts app does (DIRTY, no SOURCE_ID). */
|
||||
export function addDeviceContact(h: Harness, name: string): number {
|
||||
return h.device.user.insertContact(ANDROID_ACCOUNT, [{ [Data.MIMETYPE]: MimeType.STRUCTURED_NAME, [Data.DATA1]: name }]);
|
||||
}
|
||||
|
||||
/** An in-place edit of a contact's name (AOSP Contacts). */
|
||||
export function renameDeviceContact(h: Harness, rawContactId: number, name: string): void {
|
||||
const row = h.nameDataRow(rawContactId);
|
||||
if (!row) throw new Error(`no name row for ${rawContactId}`);
|
||||
h.device.user.updateData(Number(row._id), { [Data.DATA1]: name, [Data.DATA2]: null, [Data.DATA3]: null });
|
||||
}
|
||||
|
||||
export function addServerCards(h: Harness, names: string[], accountId = 'a', book = h.book): string[] {
|
||||
return names.map((name, i) => h.server.addCard(accountId, { uid: `uid-${name}-${i}`, name: { full: name }, addressBookIds: { [book]: true } }));
|
||||
}
|
||||
|
||||
/** Batches that wrote rows (anything but the SyncState op). */
|
||||
export function rowWrites(batches: ProviderOp[][]): ProviderOp[][] {
|
||||
return batches.filter((ops) => ops.some((op) => op.op !== 'syncState' && op.op !== 'assert'));
|
||||
}
|
||||
|
||||
export { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY };
|
||||
@@ -0,0 +1,506 @@
|
||||
// The engine's building blocks on their own: provider batches, the
|
||||
// SyncState blob, the run lock, SetError handling, query paging, the auth
|
||||
// rebuild and how failures map to outcomes.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { BatchWriter, buildBatch, estimateBatchBytes, MAX_OPS_PER_YIELD, prependOps, type Work } from '../../engine/batch';
|
||||
import { classifyFailure, RunAbort, StateMismatch, StopRun } from '../../engine/errors';
|
||||
import { acquireLock } from '../../engine/mutex';
|
||||
import { isBackedOff, nextMarker } from '../../engine/poison';
|
||||
import { emptyStats, isQuietRun, statusToRecord } from '../../engine/report';
|
||||
import { collectionDefaultOn, isCollectionSelected } from '../../engine/selection';
|
||||
import { accountOf, emptySyncState, parseSyncState, serializeSyncState, StateStore, type SyncState } from '../../engine/sync-state';
|
||||
import { classifySetError, existingIdFromUidError, JmapCaller } from '../../jmap/caller';
|
||||
import { openWithAuthRebuild } from '../../jmap/connection';
|
||||
import { JMAPMethodError } from '../../jmap/errors';
|
||||
import { accountsWithCapability, calendarAddresses } from '../../jmap/session';
|
||||
import { JMAP_CALENDARS, JMAP_CONTACTS, type BatchResult, type ProviderOp, type ProviderPort, type RunStats, type RunStatus } from '../../types';
|
||||
import { FakeJmapServer } from '../fakes/fake-jmap-server';
|
||||
|
||||
function port(apply: (ops: ProviderOp[]) => BatchResult | Promise<BatchResult>): ProviderPort & { sent: ProviderOp[][] } {
|
||||
const sent: ProviderOp[][] = [];
|
||||
return {
|
||||
accountName: 'x',
|
||||
authority: 'com.android.contacts',
|
||||
sent,
|
||||
query: async () => ({ columns: [], rows: [] }),
|
||||
readSyncState: async () => null,
|
||||
readPhoto: async () => null,
|
||||
applyBatch: async (ops) => {
|
||||
sent.push(ops);
|
||||
return apply(ops);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ok = (ops: ProviderOp[]): BatchResult => ({ ok: true, results: ops.map(() => ({ count: 1 })) });
|
||||
const insert = (table: 'raw_contacts' | 'data', values: Record<string, string> = {}): ProviderOp => ({ op: 'insert', table, values });
|
||||
const writer = (p: ProviderPort, options: Partial<{ maxOps: number; maxBytes: number; maxReplans: number }> = {}) =>
|
||||
new BatchWriter(p, { maxOps: 400, maxBytes: 300_000, maxReplans: 3, ...options });
|
||||
|
||||
describe('provider batches', () => {
|
||||
it('rebases refs, makes each group start at a yield point and puts the state op last', () => {
|
||||
const group = { ref: 'g', ops: [insert('raw_contacts'), { op: 'insert', table: 'data', values: {}, refs: { raw_contact_id: 0 }, yieldAllowed: true } as ProviderOp] };
|
||||
|
||||
const ops = buildBatch([group, group], { op: 'syncState', value: '{}', yieldAllowed: true } as ProviderOp);
|
||||
|
||||
expect(ops.map((op) => (op as { yieldAllowed?: boolean }).yieldAllowed ?? false)).toEqual([true, false, true, false, false]);
|
||||
expect((ops[3] as { refs: Record<string, number> }).refs).toEqual({ raw_contact_id: 2 });
|
||||
expect(ops[4]).toEqual({ op: 'syncState', value: '{}' });
|
||||
});
|
||||
|
||||
it('prepends ops to a group, shifting its refs', () => {
|
||||
const group = prependOps({ ref: 'g', ops: [insert('raw_contacts'), { op: 'insert', table: 'data', values: {}, refs: { raw_contact_id: 0 } }] }, [
|
||||
{ op: 'assert', table: 'raw_contacts', where: 'sourceid = ?', args: ['a/1'], expectCount: 0 },
|
||||
]);
|
||||
expect((group.ops[2] as { refs: Record<string, number> }).refs).toEqual({ raw_contact_id: 1 });
|
||||
});
|
||||
|
||||
it('never splits a group, keeps batches within the op budget and sends photo groups alone', async () => {
|
||||
const p = port(ok);
|
||||
const works: Work[] = [
|
||||
...Array.from({ length: 5 }, (_, i) => ({ group: { ref: `g${i}`, ops: [insert('raw_contacts'), insert('data'), insert('data')] } })),
|
||||
{ group: { ref: 'photo', ops: [{ op: 'insert', table: 'data', values: { data15: { b64: 'AAAA' } } }] } },
|
||||
{ group: { ref: 'g5', ops: [insert('raw_contacts')] } },
|
||||
];
|
||||
|
||||
await writer(p, { maxOps: 7 }).write(works, { op: () => ({ op: 'syncState', value: 's' }), applied: () => undefined });
|
||||
|
||||
expect(p.sent.map((b) => b.length)).toEqual([6, 6, 3, 1, 2]);
|
||||
expect(p.sent[3]).toEqual([expect.objectContaining({ values: { data15: { b64: 'AAAA' } } })]);
|
||||
expect(p.sent[4][1]).toEqual({ op: 'syncState', value: 's' });
|
||||
});
|
||||
|
||||
it('retries the groups of a failed batch one by one and re-plans a failed assert at most three times', async () => {
|
||||
let failing = 5;
|
||||
const p = port((ops) =>
|
||||
ops.some((op) => op.op === 'assert') && failing-- > 0 ? { ok: false, reason: 'assert', message: 'stale' } : ok(ops),
|
||||
);
|
||||
const applied: string[] = [];
|
||||
const failed = vi.fn();
|
||||
let replans = 0;
|
||||
const stale: Work = {
|
||||
group: { ref: 'stale', ops: [{ op: 'assert', table: 'raw_contacts', id: 1, values: { version: 1 } }, insert('data')] },
|
||||
applied: () => {
|
||||
applied.push('stale');
|
||||
},
|
||||
failed,
|
||||
};
|
||||
stale.replan = async () => {
|
||||
replans++;
|
||||
return stale;
|
||||
};
|
||||
const fine: Work = {
|
||||
group: { ref: 'fine', ops: [insert('raw_contacts')] },
|
||||
applied: () => {
|
||||
applied.push('fine');
|
||||
},
|
||||
};
|
||||
const tail = { op: () => ({ op: 'syncState', value: 's' }) as ProviderOp, applied: vi.fn() };
|
||||
|
||||
await writer(p).write([fine, stale], tail);
|
||||
|
||||
expect(applied).toEqual(['fine']);
|
||||
expect(replans).toBe(3);
|
||||
expect(failed).toHaveBeenCalledWith('assert', 'stale');
|
||||
expect(tail.applied).toHaveBeenCalledOnce();
|
||||
expect(p.sent.at(-1)).toEqual([{ op: 'syncState', value: 's' }]);
|
||||
});
|
||||
|
||||
it('splits a batch the Binder refused (tooLarge) and gives up a single group that is too large', async () => {
|
||||
const p = port((ops) => (ops.length > 2 ? { ok: false, reason: 'tooLarge', message: 'TransactionTooLargeException' } : ok(ops)));
|
||||
const failed = vi.fn();
|
||||
const works: Work[] = [
|
||||
...Array.from({ length: 4 }, (_, i) => ({ group: { ref: `g${i}`, ops: [insert('raw_contacts')] } })),
|
||||
{ group: { ref: 'big', ops: [insert('raw_contacts'), insert('data'), insert('data')] }, failed },
|
||||
];
|
||||
|
||||
await writer(p).write(works);
|
||||
|
||||
expect(failed).toHaveBeenCalledWith('tooLarge', 'TransactionTooLargeException');
|
||||
expect(p.sent.filter((b) => b.length <= 2).flat()).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('ends the run when the permission is gone and refuses to retry an op outside the account', async () => {
|
||||
const denied = port(() => ({ ok: false, reason: 'permission', message: 'SecurityException' }));
|
||||
await expect(writer(denied).write([{ group: { ref: 'g', ops: [insert('raw_contacts')] } }])).rejects.toMatchObject({ outcome: 'permission' });
|
||||
const scope = port(() => ({ ok: false, reason: 'scope', message: 'not our account' }));
|
||||
await expect(writer(scope).write([{ group: { ref: 'g', ops: [insert('raw_contacts')] } }])).rejects.toThrow(/not our account/);
|
||||
});
|
||||
|
||||
it("stores a group's state change in the batch that applies it, its retry included, and never without it", async () => {
|
||||
const store = new StateStore(emptySyncState(), { registryId: 'r', origin: 'o' });
|
||||
let failures = 1;
|
||||
const p = port((ops) => (ops.some((op) => op.op === 'assert') && failures-- > 0 ? { ok: false, reason: 'assert', message: 'stale' } : ok(ops)));
|
||||
const listed = (ref: string) => (next: SyncState) => {
|
||||
accountOf(next, 'a').groups = [...(accountOf(next, 'a').groups ?? []), ref];
|
||||
};
|
||||
const groupOps = (id: number): ProviderOp[] => [
|
||||
{ op: 'assert', table: 'groups', id, values: { version: 1 } },
|
||||
{ op: 'update', table: 'groups', id, values: { sourceid: `a/g${id}` } },
|
||||
];
|
||||
const plain: Work = { group: { ref: 'c', ops: [insert('raw_contacts'), insert('data')] } };
|
||||
const retried: Work = { group: { ref: 'a/g1', ops: groupOps(1) }, state: listed('a/g1') };
|
||||
retried.replan = async () => ({ group: retried.group, state: retried.state });
|
||||
const w = new BatchWriter(p, { maxOps: 2, maxBytes: 300_000, maxReplans: 3 }, store);
|
||||
|
||||
await w.write([plain, retried]);
|
||||
|
||||
// The plain group alone, without a state op; the group with its state op, refused; its retry, with the state op.
|
||||
expect(p.sent.map((b) => b.map((op) => op.op))).toEqual([['insert', 'insert'], ['assert', 'update', 'syncState'], ['assert', 'update', 'syncState']]);
|
||||
expect(store.committed.accounts.a.groups).toEqual(['a/g1']);
|
||||
|
||||
failures = Infinity;
|
||||
const given: Work = { group: { ref: 'a/g2', ops: groupOps(2) }, state: listed('a/g2'), replan: async () => null };
|
||||
await w.write([given], store.tail(() => undefined));
|
||||
|
||||
expect(p.sent.at(-1)!.map((op) => op.op)).toEqual(['syncState']);
|
||||
expect(store.committed.accounts.a.groups).toEqual(['a/g1']);
|
||||
});
|
||||
|
||||
it('plans a download again after a failed batch, which may have been committed up to a yield point', async () => {
|
||||
let failures = 1;
|
||||
const p = port((ops) => (failures-- > 0 ? { ok: false, reason: 'provider', message: 'failed after a yield point' } : ok(ops)));
|
||||
const planned: Work = { group: { ref: 'a/1', ops: [{ op: 'update', table: 'events', id: 1, values: { title: 'Planned from the new read' } }] } };
|
||||
const download: Work = { group: { ref: 'a/1', ops: [insert('data', { stale: 'group' })] }, fresh: true, replan: async () => planned };
|
||||
const other: Work = { group: { ref: 'a/2', ops: [insert('raw_contacts')] } };
|
||||
|
||||
await writer(p).write([download, other]);
|
||||
|
||||
expect(p.sent.slice(1)).toEqual([planned.group.ops.map((op) => ({ ...op, yieldAllowed: true })), buildBatch([other.group])]);
|
||||
});
|
||||
|
||||
it('weighs a batch as the Parcel carries it: two bytes per character of its JSON and 300 per op', () => {
|
||||
const ops: ProviderOp[] = [insert('data', { data1: 'x'.repeat(1000) }), insert('raw_contacts')];
|
||||
|
||||
expect(estimateBatchBytes(ops)).toBe(2 * JSON.stringify(ops).length + 2 * 300);
|
||||
expect(estimateBatchBytes(ops)).toBeGreaterThan(2 * 1000);
|
||||
});
|
||||
|
||||
it('packs batches by that weight', async () => {
|
||||
const p = port(ok);
|
||||
const group = (i: number): Work => ({ group: { ref: `g${i}`, ops: [insert('data', { data1: 'x'.repeat(1000) })] } });
|
||||
|
||||
await writer(p, { maxBytes: 2 * estimateBatchBytes(group(0).group.ops) }).write([group(0), group(1), group(2)]);
|
||||
|
||||
expect(p.sent.map((b) => b.length)).toEqual([2, 1]);
|
||||
});
|
||||
|
||||
it('sends the state op alone after a group that fills a yield window, and with a group that leaves room', async () => {
|
||||
const tail = () => ({ op: () => ({ op: 'syncState', value: 's' }) as ProviderOp, applied: () => undefined });
|
||||
const group = (n: number): Work => ({ group: { ref: `g${n}`, ops: Array.from({ length: n }, () => insert('data')) } });
|
||||
const p = port(ok);
|
||||
|
||||
await writer(p, { maxOps: 1000 }).write([group(MAX_OPS_PER_YIELD)], tail());
|
||||
await writer(p, { maxOps: 1000 }).write([group(MAX_OPS_PER_YIELD - 1)], tail());
|
||||
|
||||
expect(p.sent.map((b) => b.length)).toEqual([MAX_OPS_PER_YIELD, 1, MAX_OPS_PER_YIELD]);
|
||||
});
|
||||
|
||||
it('skips groups that only assert', async () => {
|
||||
const p = port(ok);
|
||||
await writer(p).write([{ group: { ref: 'g', ops: [{ op: 'assert', table: 'raw_contacts', id: 1, values: { dirty: 0 } }] } }]);
|
||||
expect(p.sent).toEqual([]);
|
||||
});
|
||||
|
||||
describe('an item too big for one group (a chain)', () => {
|
||||
const named = (n: string) => insert('data', { n });
|
||||
const namesOf = (batches: ProviderOp[][]) => batches.map((b) => b.map((op) => (op.op === 'insert' ? op.values.n : op.op)));
|
||||
const chained = (extra: Partial<Work> = {}): Work => ({
|
||||
group: { ref: 'big', ops: [named('1')], next: [{ ref: 'big', ops: [named('2')] }, { ref: 'big', ops: [named('3')] }] },
|
||||
...extra,
|
||||
});
|
||||
|
||||
it('applies its groups in order, each in a batch of its own, between the other works, and counts it after the last', async () => {
|
||||
const p = port(ok);
|
||||
const applied: string[] = [];
|
||||
const store = new StateStore(emptySyncState(), { registryId: 'r', origin: 'o' });
|
||||
const big = chained({
|
||||
applied: (results) => void applied.push(`big:${results.length}`),
|
||||
state: (next) => {
|
||||
accountOf(next, 'a').stale = ['big'];
|
||||
},
|
||||
});
|
||||
const other = (n: string): Work => ({ group: { ref: n, ops: [named(n)] }, applied: () => void applied.push(n) });
|
||||
|
||||
await new BatchWriter(p, { maxOps: 400, maxBytes: 300_000, maxReplans: 3 }, store).write(
|
||||
[other('a'), big, other('b')],
|
||||
store.tail(() => undefined),
|
||||
);
|
||||
|
||||
expect(namesOf(p.sent)).toEqual([['a'], ['1'], ['2'], ['3', 'syncState'], ['b', 'syncState']]);
|
||||
expect(applied).toEqual(['a', 'big:3', 'b']);
|
||||
expect(store.committed.accounts.a.stale).toEqual(['big']);
|
||||
});
|
||||
|
||||
it('plans the item again from what its first groups wrote when a later one fails, never sending that one again', async () => {
|
||||
let failures = 1;
|
||||
const p = port((ops) => (namesOf([ops])[0].includes('2') && failures-- > 0 ? { ok: false, reason: 'assert', message: 'changed' } : ok(ops)));
|
||||
const rest: Work = { group: { ref: 'big', ops: [named('rest')] } };
|
||||
|
||||
await writer(p).write([chained({ replan: async () => rest })]);
|
||||
|
||||
expect(namesOf(p.sent)).toEqual([['1'], ['2'], ['rest']]);
|
||||
});
|
||||
|
||||
it('gives the item up when one of its groups is refused for good', async () => {
|
||||
const p = port((ops) => (namesOf([ops])[0].includes('2') ? { ok: false, reason: 'tooLarge', message: 'TransactionTooLargeException' } : ok(ops)));
|
||||
const failed = vi.fn();
|
||||
|
||||
await writer(p).write([chained({ failed, replan: async () => null })]);
|
||||
|
||||
expect(namesOf(p.sent)).toEqual([['1'], ['2']]);
|
||||
expect(failed).toHaveBeenCalledWith('tooLarge', 'TransactionTooLargeException');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SyncState', () => {
|
||||
it('round-trips and tolerates missing parts', () => {
|
||||
const state = emptySyncState();
|
||||
state.owner = { registryId: 'r', origin: 'https://o' };
|
||||
state.accounts.a = { collectionsState: 's1', itemsState: 's2', selected: ['a/b'], stale: ['c1'], reconcile: null, groups: ['a/g'] };
|
||||
state.deviceZone = 'Europe/Berlin';
|
||||
|
||||
expect(parseSyncState(serializeSyncState(state))).toEqual({ state, readable: true });
|
||||
expect(parseSyncState(JSON.stringify({ v: 1, accounts: { a: { itemsState: 'x' } } })).state.accounts.a).toEqual({
|
||||
collectionsState: null,
|
||||
itemsState: 'x',
|
||||
selected: [],
|
||||
stale: [],
|
||||
reconcile: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('starts over when the blob is unreadable or of another version', () => {
|
||||
expect(parseSyncState('{nope').readable).toBe(false);
|
||||
expect(parseSyncState(JSON.stringify({ v: 2, accounts: {} })).readable).toBe(false);
|
||||
expect(parseSyncState(null)).toEqual({ state: emptySyncState(), readable: true });
|
||||
expect(parseSyncState('')).toEqual({ state: emptySyncState(), readable: true });
|
||||
});
|
||||
|
||||
it('adopts a proposed state only after its batch applied', () => {
|
||||
const store = new StateStore(emptySyncState(), { registryId: 'r', origin: 'o' });
|
||||
const tail = store.tail((next) => {
|
||||
next.deviceZone = 'UTC';
|
||||
});
|
||||
const op = tail.op();
|
||||
expect(store.committed.deviceZone).toBeUndefined();
|
||||
expect(JSON.parse((op as { value: string }).value)).toMatchObject({ deviceZone: 'UTC', owner: { registryId: 'r' } });
|
||||
tail.applied();
|
||||
expect(store.committed.deviceZone).toBe('UTC');
|
||||
});
|
||||
});
|
||||
|
||||
describe('run lock', () => {
|
||||
it('serialises holders and hands on the turn of a waiter that gave up', async () => {
|
||||
const order: string[] = [];
|
||||
const first = await acquireLock('k');
|
||||
const second = acquireLock('k', 10);
|
||||
const third = acquireLock('k');
|
||||
expect(await second).toBeNull();
|
||||
first!();
|
||||
const release = await third;
|
||||
order.push('third');
|
||||
release!();
|
||||
expect(order).toEqual(['third']);
|
||||
const again = await acquireLock('k', 10);
|
||||
expect(again).not.toBeNull();
|
||||
again!();
|
||||
});
|
||||
});
|
||||
|
||||
describe('poison markers', () => {
|
||||
it('backs off an hour, doubling to a day, while the item stays the same', () => {
|
||||
const first = nextMarker(null, 'fp', { type: 'invalidPatch' }, 0, { firstMs: 3_600_000, maxMs: 86_400_000 });
|
||||
const second = nextMarker(first, 'fp', { type: 'invalidPatch' }, 0, { firstMs: 3_600_000, maxMs: 86_400_000 });
|
||||
let marker = second;
|
||||
for (let i = 0; i < 10; i++) marker = nextMarker(marker, 'fp', { type: 'invalidPatch' }, 0, { firstMs: 3_600_000, maxMs: 86_400_000 });
|
||||
expect([first.until, second.until, marker.until]).toEqual([3_600_000, 7_200_000, 86_400_000]);
|
||||
expect(nextMarker(second, 'changed', { type: 'invalidPatch' }, 0, { firstMs: 1, maxMs: 10 }).n).toBe(1);
|
||||
expect(isBackedOff(first, 'fp', 1)).toBe(true);
|
||||
expect(isBackedOff(first, 'other', 1)).toBe(false);
|
||||
expect(isBackedOff(first, 'fp', 3_600_001)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JMAP side', () => {
|
||||
it('classifies SetErrors', () => {
|
||||
expect(classifySetError({ type: 'notFound' }, 'update')).toBe('notFound');
|
||||
expect(classifySetError({ type: 'forbidden' }, 'destroy')).toBe('forbidden');
|
||||
expect(classifySetError({ type: 'invalidProperties', properties: ['uid'] }, 'create')).toBe('uidExists');
|
||||
expect(classifySetError({ type: 'invalidProperties', properties: ['uid'] }, 'update')).toBe('poison');
|
||||
expect(classifySetError({ type: 'overQuota' }, 'create')).toBe('poison');
|
||||
expect(existingIdFromUidError({ type: 'invalidProperties', description: 'Contact with UID u1 already exists with id c5.' })).toBe('c5');
|
||||
expect(existingIdFromUidError({ type: 'invalidProperties', description: 'An event with UID u1 already exists.' })).toBeNull();
|
||||
});
|
||||
|
||||
it('pages /query by position until the total, with overlapping pages', async () => {
|
||||
const server = new FakeJmapServer();
|
||||
server.addAccount('a', { name: 'alice@example.com' });
|
||||
const book = server.addAddressBook('a', { name: 'Personal' });
|
||||
for (let i = 0; i < 30; i++) server.addCard('a', { uid: `u${i}` });
|
||||
server.setLimits({ maxQueryResults: 8 });
|
||||
const caller = new JmapCaller(server.port(), () => 0);
|
||||
|
||||
const ids = await caller.queryAll('ContactCard', 'a', { inAddressBook: book });
|
||||
|
||||
expect(ids).toHaveLength(30);
|
||||
expect(server.calls('ContactCard/query').length).toBeGreaterThan(3);
|
||||
});
|
||||
|
||||
it('looks uids up in one query and maps them back', async () => {
|
||||
const server = new FakeJmapServer();
|
||||
server.addAccount('a', { name: 'alice@example.com' });
|
||||
const book = server.addAddressBook('a', { name: 'Personal' });
|
||||
const other = server.addAddressBook('a', { name: 'Other' });
|
||||
const c1 = server.addCard('a', { uid: 'u1', addressBookIds: { [book]: true } });
|
||||
server.addCard('a', { uid: 'u2', addressBookIds: { [other]: true } });
|
||||
const caller = new JmapCaller(server.port(), () => 0);
|
||||
|
||||
const found = await caller.lookupUids('ContactCard', 'a', ['u1', 'u2', 'u3'], book);
|
||||
|
||||
expect([...found]).toEqual([['u1', [c1]]]);
|
||||
});
|
||||
|
||||
it('names the accounts with a capability, the primary first', () => {
|
||||
const server = new FakeJmapServer();
|
||||
server.addAccount('team', { name: 'Team', isPersonal: false, capabilities: ['contacts'] });
|
||||
server.addAccount('a', { name: 'alice@example.com' });
|
||||
const session = server.port().session();
|
||||
|
||||
expect(accountsWithCapability(session, JMAP_CONTACTS).map((a) => [a.id, a.primary, a.personal])).toEqual([
|
||||
['a', true, true],
|
||||
['team', false, false],
|
||||
]);
|
||||
expect(accountsWithCapability(session, JMAP_CALENDARS).map((a) => a.id)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it("counts only the capability's primary account as personal, like the app's chooser", () => {
|
||||
const server = new FakeJmapServer();
|
||||
server.addAccount('a', { name: 'alice@example.com' });
|
||||
// Another account the server calls personal (a second mailbox of the same login, say).
|
||||
server.addAccount('home', { name: 'alice-home@example.com', isPersonal: true });
|
||||
const session = server.port().session();
|
||||
|
||||
expect(accountsWithCapability(session, JMAP_CONTACTS).map((a) => [a.id, a.primary, a.personal])).toEqual([
|
||||
['a', true, true],
|
||||
['home', false, false],
|
||||
]);
|
||||
});
|
||||
|
||||
it('takes the login as the owner address and survives a server without identities', async () => {
|
||||
const server = new FakeJmapServer();
|
||||
server.addAccount('a', { name: 'Alice@Example.com' });
|
||||
const addresses = await calendarAddresses(new JmapCaller(server.port(), () => 0), 'fallback');
|
||||
expect(addresses).toEqual({ ownerAccount: 'alice@example.com', selfAddresses: ['alice@example.com'] });
|
||||
});
|
||||
|
||||
it('rebuilds the connection once when the credentials are rejected', async () => {
|
||||
const server = new FakeJmapServer();
|
||||
server.addAccount('a', { name: 'alice@example.com' });
|
||||
const load = vi.fn(async () => ({ port: server.port(), origin: 'o' }));
|
||||
const connection = await openWithAuthRebuild(load);
|
||||
server.failNextRequest('auth');
|
||||
|
||||
await connection.port.request([['Core/echo', {}, '0']], ['urn:ietf:params:jmap:core']);
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
|
||||
server.failNextRequest('auth');
|
||||
await expect(connection.port.request([['Core/echo', {}, '0']], ['urn:ietf:params:jmap:core'])).rejects.toMatchObject({ name: 'AuthenticationError' });
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('opens with a second client when the first has no session, and gives up after that', async () => {
|
||||
const connection = { port: new FakeJmapServer().port(), origin: 'o' };
|
||||
const loads = [null, connection];
|
||||
await expect(openWithAuthRebuild(async () => loads.shift() ?? null)).resolves.toMatchObject({ origin: 'o' });
|
||||
expect(loads).toEqual([]);
|
||||
const rejected = Object.assign(new Error('401'), { name: 'AuthenticationError' });
|
||||
await expect(openWithAuthRebuild(async () => { throw rejected; })).rejects.toMatchObject({ name: 'AuthenticationError' });
|
||||
await expect(openWithAuthRebuild(async () => null)).rejects.toMatchObject({ name: 'AuthenticationError' });
|
||||
const offline = Object.assign(new Error('offline'), { name: 'NetworkError' });
|
||||
await expect(openWithAuthRebuild(async () => { throw offline; })).rejects.toBe(offline);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selection defaults', () => {
|
||||
it("syncs the personal account's collections, not shared ones nor the Trusted Senders book, unless chosen", () => {
|
||||
expect(collectionDefaultOn(true, 'com.android.contacts', 'Personal')).toBe(true);
|
||||
expect(collectionDefaultOn(true, 'com.android.contacts', 'Trusted Senders')).toBe(false);
|
||||
expect(collectionDefaultOn(true, 'com.android.calendar', 'Trusted Senders')).toBe(true);
|
||||
expect(collectionDefaultOn(false, 'com.android.contacts', 'Team')).toBe(false);
|
||||
expect(collectionDefaultOn(true, 'com.android.calendar', null)).toBe(true);
|
||||
|
||||
const personal = { id: 'a', personal: true };
|
||||
const trusted = { id: 'ab9', name: 'Trusted Senders' };
|
||||
expect(isCollectionSelected({}, personal, 'com.android.contacts', trusted)).toBe(false);
|
||||
expect(isCollectionSelected({ 'a/ab9': true }, personal, 'com.android.contacts', trusted)).toBe(true);
|
||||
expect(isCollectionSelected({ 'a/ab1': false }, personal, 'com.android.contacts', { id: 'ab1', name: 'Personal' })).toBe(false);
|
||||
expect(isCollectionSelected({ 't/c1': true }, { id: 't', personal: false }, 'com.android.calendar', { id: 'c1' })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('outcomes', () => {
|
||||
it('maps failures to report outcomes', () => {
|
||||
const named = (name: string, extra: object = {}) => Object.assign(new Error(name), { name, ...extra });
|
||||
expect(classifyFailure(new StopRun('deadline'), 0)).toMatchObject({ outcome: 'cancelled', moreRecordsToGet: true });
|
||||
expect(classifyFailure(new RunAbort('safetyAbort', 'x'), 0)).toMatchObject({ outcome: 'safetyAbort' });
|
||||
expect(classifyFailure(new StateMismatch('a'), 0)).toMatchObject({ outcome: 'io', noProgress: true });
|
||||
expect(classifyFailure(named('AuthenticationError'), 0)).toMatchObject({ outcome: 'auth', authProblem: true });
|
||||
expect(classifyFailure(named('NetworkError'), 0)).toMatchObject({ outcome: 'io' });
|
||||
expect(classifyFailure(named('RequestTimeoutError'), 0)).toMatchObject({ outcome: 'io' });
|
||||
expect(classifyFailure(named('RateLimitError', { retryAfterMs: 5000 }), 10_000)).toMatchObject({ outcome: 'io', delayUntil: 15 });
|
||||
expect(classifyFailure(new JMAPMethodError('serverFail'), 0)).toMatchObject({ outcome: 'io' });
|
||||
expect(classifyFailure(new Error('JMAP request failed: 503 - busy'), 0)).toMatchObject({ outcome: 'io' });
|
||||
expect(
|
||||
classifyFailure(new Error('JMAP request failed: 400 - {"type":"urn:ietf:params:jmap:error:limit","limit":"maxCallsInRequest"}'), 0),
|
||||
).toMatchObject({ outcome: 'internal' });
|
||||
expect(classifyFailure(Object.assign(new Error('denied'), { code: 'permission' }), 0)).toMatchObject({ outcome: 'permission' });
|
||||
expect(classifyFailure(new TypeError('x is undefined'), 0)).toMatchObject({ outcome: 'internal' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the status a run leaves for the settings', () => {
|
||||
const status = (over: Partial<RunStatus> = {}): RunStatus => ({ at: 2_000, outcome: 'ok', durationMs: 50, conflicts: 0, itemErrors: 0, stats: emptyStats(), ...over });
|
||||
const withStats = (edit: (stats: RunStats) => void) => {
|
||||
const stats = emptyStats();
|
||||
edit(stats);
|
||||
return status({ stats });
|
||||
};
|
||||
const reported = status({ at: 1_000, durationMs: 900, conflicts: 1, itemErrors: 2, message: 'invitations not sent', stats: { ...emptyStats(), skipped: 2 } });
|
||||
|
||||
it('keeps what the run before reported through a run that did nothing, moving the time on', () => {
|
||||
expect(isQuietRun(status())).toBe(true);
|
||||
expect(statusToRecord(status(), reported)).toEqual({ ...reported, at: 2_000 });
|
||||
expect(statusToRecord(status(), undefined)).toEqual(status());
|
||||
});
|
||||
|
||||
it('shows a failure followed by a run that did nothing as recovered, with what the failed run reported', () => {
|
||||
const failed = { ...reported, outcome: 'io' as const, message: 'Could not reach the server' };
|
||||
const recorded = statusToRecord(status(), failed);
|
||||
|
||||
expect(recorded).toEqual({ at: 2_000, outcome: 'ok', durationMs: 900, conflicts: 1, itemErrors: 2, stats: reported.stats });
|
||||
});
|
||||
|
||||
it('replaces it with a run that wrote, skipped or reported anything, or failed', () => {
|
||||
const busy = [
|
||||
withStats((s) => (s.downloaded.created = 1)),
|
||||
withStats((s) => (s.downloaded.deleted = 1)),
|
||||
withStats((s) => (s.uploaded.updated = 1)),
|
||||
withStats((s) => (s.skipped = 1)),
|
||||
status({ conflicts: 1 }),
|
||||
status({ itemErrors: 1 }),
|
||||
status({ message: 'invitations not sent' }),
|
||||
status({ outcome: 'io', message: 'Could not reach the server' }),
|
||||
status({ outcome: 'cancelled' }),
|
||||
status({ outcome: 'disabled', message: 'Sync is off for this account' }),
|
||||
];
|
||||
for (const run of busy) {
|
||||
expect(isQuietRun(run)).toBe(false);
|
||||
expect(statusToRecord(run, reported)).toBe(run);
|
||||
}
|
||||
// Items only looked at are no work.
|
||||
expect(isQuietRun(withStats((s) => (s.entries = 3)))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
// Objects our uploads created. The stored state stays where the download left
|
||||
// it, and `/changes` omits an object created and destroyed after its
|
||||
// `sinceState` (Stalwart does, RFC 8620 allows it): such an object must still
|
||||
// leave the device when another client destroys it before the next download.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import { contactsPlanner } from '../../contacts/planner';
|
||||
import { addDeviceContact, addServerCards, CALENDAR_AUTHORITY, createHarness, type Harness } from './harness';
|
||||
|
||||
function real(): Harness {
|
||||
const h = createHarness();
|
||||
h.deps.planners = { contacts: contactsPlanner, calendar: calendarPlanner };
|
||||
return h;
|
||||
}
|
||||
|
||||
describe('device sync engine: objects our uploads created', () => {
|
||||
it('removes a device-created contact that another client destroyed before the next download', async () => {
|
||||
const h = real();
|
||||
addServerCards(h, ['Ada Lovelace']);
|
||||
await h.run();
|
||||
const id = addDeviceContact(h, 'Hedy Lamarr');
|
||||
expect((await h.run()).stats.uploaded.created).toBe(1);
|
||||
const hedy = h.contacts().find((c) => c.id === id)!.sourceId!.split('/')[1];
|
||||
h.server.serverDestroy('ContactCard', 'a', hedy);
|
||||
|
||||
const report = await h.run();
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', stats: { downloaded: { deleted: 1 } } });
|
||||
expect(h.contacts().map((c) => c.name)).toEqual(['Ada Lovelace']);
|
||||
expect(h.state()?.accounts.a.created ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it('removes a device-created event that another client destroyed before the next download', async () => {
|
||||
const h = real();
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const rowId = Number(h.device.rows('calendars')[0]._id);
|
||||
const id = h.device.user.insertEvent(rowId, { title: 'Dentist', dtstart: Date.UTC(2026, 9, 1, 8), dtend: Date.UTC(2026, 9, 1, 9), eventTimezone: 'Europe/Berlin' });
|
||||
expect((await h.run(CALENDAR_AUTHORITY)).stats.uploaded.created).toBe(1);
|
||||
const created = String(h.events().find((e) => Number(e._id) === id)!._sync_id).split('/')[1];
|
||||
h.server.serverDestroy('CalendarEvent', 'a', created);
|
||||
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', stats: { downloaded: { deleted: 1 } } });
|
||||
expect(h.events()).toEqual([]);
|
||||
});
|
||||
|
||||
it('fetches a created object once, and writes nothing when it is unchanged', async () => {
|
||||
const h = real();
|
||||
await h.run();
|
||||
addDeviceContact(h, 'Hedy Lamarr');
|
||||
await h.run();
|
||||
expect(h.state()?.accounts.a.created).toHaveLength(1);
|
||||
|
||||
const before = h.batches.log.length;
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
|
||||
expect(h.state()?.accounts.a.created).toEqual([]);
|
||||
expect(h.batches.log.slice(before).flat().filter((op) => op.op !== 'syncState')).toEqual([]);
|
||||
expect(h.contacts().map((c) => c.name)).toEqual(['Hedy Lamarr']);
|
||||
});
|
||||
|
||||
it('turns an upload-only sync into a full one while a created object waits to be checked', async () => {
|
||||
const h = real();
|
||||
await h.run();
|
||||
const id = addDeviceContact(h, 'Hedy Lamarr');
|
||||
await h.run();
|
||||
const hedy = h.contacts().find((c) => c.id === id)!.sourceId!.split('/')[1];
|
||||
h.server.serverDestroy('ContactCard', 'a', hedy);
|
||||
|
||||
// Android's upload sync 30 s after an app wrote anything, with nothing dirty in this account.
|
||||
expect((await h.run(undefined, { upload: true })).outcome).toBe('ok');
|
||||
|
||||
expect(h.contacts()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,978 @@
|
||||
// The engine with the real contacts and calendar planners (not the toy
|
||||
// ones): the contract between them holds end to end. Mapping details are
|
||||
// the planners' own tests; these check what the engine guarantees.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Attendees, Data, Events, MimeType } from '../../android-columns';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import { contactsPlanner } from '../../contacts/planner';
|
||||
import type { Row } from '../../types';
|
||||
import {
|
||||
addDeviceContact,
|
||||
addServerCards,
|
||||
CALENDAR_AUTHORITY,
|
||||
CONTACTS_AUTHORITY,
|
||||
createHarness,
|
||||
renameDeviceContact,
|
||||
rowWrites,
|
||||
RUN_BUDGET,
|
||||
type Harness,
|
||||
} from './harness';
|
||||
|
||||
function real(options: Parameters<typeof createHarness>[0] = {}): Harness {
|
||||
const h = createHarness(options);
|
||||
h.deps.planners = { contacts: contactsPlanner, calendar: calendarPlanner };
|
||||
return h;
|
||||
}
|
||||
|
||||
/** The event columns Etar copies into the row it inserts when it moves an event or ends its recurrence. */
|
||||
const ETAR_COPY = [
|
||||
Events.TITLE, Events.DESCRIPTION, Events.EVENT_LOCATION, Events.STATUS, Events.AVAILABILITY, Events.ACCESS_LEVEL,
|
||||
Events.EVENT_COLOR, Events.DTSTART, Events.DTEND, Events.DURATION, Events.EVENT_TIMEZONE, Events.ALL_DAY, Events.RRULE,
|
||||
];
|
||||
|
||||
/** Etar's move or "series to single event": the row is deleted and a copy (with its attendees) inserted. */
|
||||
function etarReinsert(h: Harness, old: Row, calendarRowId: number, changes: Row = {}): number {
|
||||
const values: Row = {};
|
||||
for (const c of ETAR_COPY) if (old[c] !== undefined) values[c] = old[c];
|
||||
const attendees = h.device.rows('attendees')
|
||||
.filter((a) => Number(a.event_id) === Number(old._id))
|
||||
.map((a) => ({
|
||||
[Attendees.ATTENDEE_EMAIL]: a[Attendees.ATTENDEE_EMAIL],
|
||||
[Attendees.ATTENDEE_NAME]: a[Attendees.ATTENDEE_NAME],
|
||||
[Attendees.ATTENDEE_RELATIONSHIP]: a[Attendees.ATTENDEE_RELATIONSHIP],
|
||||
[Attendees.ATTENDEE_TYPE]: a[Attendees.ATTENDEE_TYPE],
|
||||
[Attendees.ATTENDEE_STATUS]: a[Attendees.ATTENDEE_STATUS],
|
||||
}));
|
||||
h.device.user.deleteEvent(Number(old._id));
|
||||
return h.device.user.insertEvent(calendarRowId, { ...values, ...changes }, { attendees });
|
||||
}
|
||||
|
||||
/** Masters (no ORIGINAL_*) that share a `_SYNC_ID`: a split the next run would resolve by destroy + create. */
|
||||
function sharedIdentities(h: Harness): string[] {
|
||||
const masters = h.events().filter((e) => e[Events.ORIGINAL_ID] === null && e[Events.ORIGINAL_SYNC_ID] === null && e._sync_id);
|
||||
const ids = masters.map((e) => String(e._sync_id));
|
||||
return ids.filter((id, i) => ids.indexOf(id) !== i);
|
||||
}
|
||||
|
||||
/** A device edit of the display name only, as AOSP Contacts makes it (the provider fills the components). */
|
||||
function retypeName(h: Harness, rawContactId: number, name: string): void {
|
||||
const row = h.nameDataRow(rawContactId)!;
|
||||
h.device.user.updateData(Number(row._id), { [Data.DATA1]: name });
|
||||
}
|
||||
|
||||
function serverUids(h: Harness): string[] {
|
||||
return h.server.all('ContactCard', 'a').map((c) => String(c.uid));
|
||||
}
|
||||
|
||||
describe('device sync engine with the real planners', () => {
|
||||
it('syncs contacts both ways and writes nothing for its own echo', async () => {
|
||||
const h = real();
|
||||
const [, grace] = addServerCards(h, ['Ada Lovelace', 'Grace Hopper']);
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
expect(h.contacts().map((c) => c.name).sort()).toEqual(['Ada Lovelace', 'Grace Hopper']);
|
||||
|
||||
addDeviceContact(h, 'Hedy Lamarr');
|
||||
h.device.user.deleteContact(h.contactNamed('Grace Hopper')!.id);
|
||||
const up = await h.run();
|
||||
expect(up).toMatchObject({ outcome: 'ok', itemErrors: [] });
|
||||
expect(up.stats.uploaded).toMatchObject({ created: 1, deleted: 1 });
|
||||
expect(h.server.get('ContactCard', 'a', grace)).toBeUndefined();
|
||||
expect(h.contacts().every((c) => c.sourceId && !c.dirty)).toBe(true);
|
||||
expect(new Set(serverUids(h)).size).toBe(2);
|
||||
|
||||
const before = h.batches.log.length;
|
||||
const echo = await h.run();
|
||||
expect(echo.outcome).toBe('ok');
|
||||
expect(rowWrites(h.batches.log.slice(before))).toEqual([]);
|
||||
});
|
||||
|
||||
it('converges with the real contacts planner after a crash at any checkpoint', async () => {
|
||||
const setup = async () => {
|
||||
const h = real();
|
||||
addServerCards(h, ['Ada Lovelace', 'Grace Hopper', 'Linus Torvalds']);
|
||||
await h.run();
|
||||
retypeName(h, h.contactNamed('Grace Hopper')!.id, 'Grace B. Hopper');
|
||||
addDeviceContact(h, 'Hedy Lamarr');
|
||||
h.device.user.deleteContact(h.contactNamed('Linus Torvalds')!.id);
|
||||
h.server.serverUpdate('ContactCard', 'a', h.contactNamed('Ada Lovelace')!.sourceId!.split('/')[1], { 'name/full': 'Ada King' });
|
||||
addServerCards(h, ['Margaret Hamilton']);
|
||||
h.checkpoints.count = 0;
|
||||
return h;
|
||||
};
|
||||
const probe = await setup();
|
||||
await probe.run();
|
||||
const final = probe.contacts().map((c) => c.name).sort();
|
||||
const finalServer = probe.serverNames();
|
||||
const checkpoints = probe.checkpoints.count;
|
||||
|
||||
for (let n = 1; n <= checkpoints; n++) {
|
||||
const h = await setup();
|
||||
h.checkpoints.crashAt = n;
|
||||
await h.run();
|
||||
h.checkpoints.crashAt = null;
|
||||
const report = await h.run();
|
||||
expect(report.outcome, `crash at checkpoint ${n}`).toBe('ok');
|
||||
expect(h.contacts().map((c) => c.name).sort(), `device after crash ${n}`).toEqual(final);
|
||||
expect(h.serverNames(), `server after crash ${n}`).toEqual(finalServer);
|
||||
expect(h.contacts().every((c) => !c.dirty && !c.deleted)).toBe(true);
|
||||
expect(new Set(serverUids(h)).size).toBe(serverUids(h).length);
|
||||
}
|
||||
});
|
||||
|
||||
it('adopts a lost create by uid with the real contacts planner', async () => {
|
||||
const h = real();
|
||||
addServerCards(h, ['Ada Lovelace']);
|
||||
await h.run();
|
||||
h.server.setUidIndexLag(3);
|
||||
const id = addDeviceContact(h, 'Hedy Lamarr');
|
||||
h.server.applyThenLoseResponse({ match: 'ContactCard/set' });
|
||||
|
||||
expect((await h.run()).outcome).toBe('io');
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
|
||||
expect(h.serverNames()).toEqual(['Ada Lovelace', 'Hedy Lamarr']);
|
||||
expect(h.contacts()).toHaveLength(2);
|
||||
expect(h.contacts().find((c) => c.id === id)).toMatchObject({ dirty: false, sourceId: expect.stringMatching(/^a\//) });
|
||||
expect(h.device.rows('data').filter((d) => d.mimetype === MimeType.STRUCTURED_NAME)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("follows a group's members when only the group card changed on the server", async () => {
|
||||
const h = real();
|
||||
const [ada, grace] = addServerCards(h, ['Ada Lovelace', 'Grace Hopper']);
|
||||
const uid = (id: string) => h.server.get('ContactCard', 'a', id)!.uid as string;
|
||||
const group = h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' }, members: { [uid(ada)]: true } });
|
||||
const members = () =>
|
||||
h.device
|
||||
.rows('data')
|
||||
.filter((d) => d.mimetype === MimeType.GROUP_MEMBERSHIP)
|
||||
.map((d) => h.contacts().find((c) => c.id === Number(d.raw_contact_id))?.name)
|
||||
.sort();
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
expect(members()).toEqual(['Ada Lovelace']);
|
||||
|
||||
h.server.serverUpdate('ContactCard', 'a', group, { members: { [uid(grace)]: true } });
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
|
||||
expect(members()).toEqual(['Grace Hopper']);
|
||||
expect(h.contacts().every((c) => !c.dirty)).toBe(true);
|
||||
});
|
||||
|
||||
describe('a membership edit on the device', () => {
|
||||
async function adaJoinsFriends() {
|
||||
const h = real();
|
||||
const [ada] = addServerCards(h, ['Ada Lovelace']);
|
||||
const group = h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' }, members: {} });
|
||||
await h.run();
|
||||
const contact = h.contactNamed('Ada Lovelace')!;
|
||||
h.device.user.addToGroup(contact.id, Number(h.device.rows('groups')[0]._id));
|
||||
return { h, ada, group, contact };
|
||||
}
|
||||
|
||||
it('leaves the contact clean once the group card shows it', async () => {
|
||||
const { h, ada, group, contact } = await adaJoinsFriends();
|
||||
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
|
||||
const uid = h.server.get('ContactCard', 'a', ada)!.uid as string;
|
||||
expect(h.server.get('ContactCard', 'a', group)!.members).toEqual({ [uid]: true });
|
||||
expect(h.contacts().find((c) => c.id === contact.id)).toMatchObject({ dirty: false });
|
||||
});
|
||||
|
||||
it('leaves nothing waiting for the teardown once it is uploaded', async () => {
|
||||
const { h, ada, group } = await adaJoinsFriends();
|
||||
|
||||
expect(await h.teardown(CONTACTS_AUTHORITY)).toEqual({ pending: 0 });
|
||||
|
||||
const uid = h.server.get('ContactCard', 'a', ada)!.uid as string;
|
||||
expect(h.server.get('ContactCard', 'a', group)!.members).toEqual({ [uid]: true });
|
||||
});
|
||||
|
||||
it('waits with a membership edit while the group card is stale, instead of resending its members', async () => {
|
||||
const { h, ada, group } = await adaJoinsFriends();
|
||||
const [bob] = addServerCards(h, ['Bob']);
|
||||
const bobUid = h.server.get('ContactCard', 'a', bob)!.uid as string;
|
||||
// Another client adds Bob; this device cannot store the new version of the group this run.
|
||||
h.server.serverUpdate('ContactCard', 'a', group, { members: { [bobUid]: true } });
|
||||
h.deps.planners = {
|
||||
...h.deps.planners,
|
||||
contacts: {
|
||||
...contactsPlanner,
|
||||
planGroupDownload: (card, local, ctx) => {
|
||||
if (card.id === group) throw new Error('cannot store this group now');
|
||||
return contactsPlanner.planGroupDownload(card, local, ctx);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await h.run();
|
||||
|
||||
const adaUid = h.server.get('ContactCard', 'a', ada)!.uid as string;
|
||||
expect(h.server.get('ContactCard', 'a', group)!.members).toEqual({ [bobUid]: true });
|
||||
expect(h.contacts().find((c) => c.name === 'Ada Lovelace')).toMatchObject({ dirty: true });
|
||||
|
||||
h.deps.planners = { contacts: contactsPlanner, calendar: calendarPlanner };
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
|
||||
expect(h.server.get('ContactCard', 'a', group)!.members).toEqual({ [bobUid]: true, [adaUid]: true });
|
||||
expect(h.contacts().find((c) => c.name === 'Ada Lovelace')).toMatchObject({ dirty: false });
|
||||
});
|
||||
|
||||
describe('an upload that downloads nothing first', () => {
|
||||
/** Ada in Friends (and Grace); Ada's name edited in place (AOSP); nothing new on the server. */
|
||||
async function adaRenamedInFriends() {
|
||||
const h = real();
|
||||
const [ada, grace] = addServerCards(h, ['Ada Lovelace', 'Grace Hopper']);
|
||||
const uid = h.server.get('ContactCard', 'a', ada)!.uid as string;
|
||||
h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' }, members: { [uid]: true } });
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
const row = h.contactNamed('Ada Lovelace')!.id;
|
||||
h.device.user.updateData(Number(h.nameDataRow(row)!._id), { [Data.DATA1]: 'Ada King' });
|
||||
const memberships = () => h.device.rows('data').filter((d) => Number(d.raw_contact_id) === row && d.mimetype === MimeType.GROUP_MEMBERSHIP);
|
||||
return { h, ada, grace, memberships };
|
||||
}
|
||||
|
||||
it("keeps the uploaded contact's memberships in a sync run", async () => {
|
||||
const { h, ada, memberships } = await adaRenamedInFriends();
|
||||
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
|
||||
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ name: { full: 'Ada King' } });
|
||||
expect(memberships()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the uploaded contact's memberships in a teardown's upload", async () => {
|
||||
const { h, ada, grace, memberships } = await adaRenamedInFriends();
|
||||
// Grace's edit cannot go up, so the teardown keeps every row and asks.
|
||||
renameDeviceContact(h, h.contactNamed('Grace Hopper')!.id, 'Grace B. Hopper');
|
||||
h.server.setErrorFor('ContactCard', 'a', grace, { type: 'tooLarge' });
|
||||
|
||||
expect(await h.teardown(CONTACTS_AUTHORITY)).toEqual({ pending: 1 });
|
||||
|
||||
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ name: { full: 'Ada King' } });
|
||||
expect(memberships()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads a removal from a group in a run that downloads no contact', async () => {
|
||||
const h = real();
|
||||
// A keyed phone row shows the editor works in place, so a missing membership row is a removal.
|
||||
h.server.addCard('a', { uid: 'ada-uid', name: { full: 'Ada Lovelace' }, phones: { p1: { '@type': 'Phone', number: '+1 111' } } });
|
||||
const group = h.server.addCard('a', { uid: 'g1', kind: 'group', name: { full: 'Friends' }, members: { 'ada-uid': true } });
|
||||
await h.run();
|
||||
const contact = h.contactNamed('Ada Lovelace')!;
|
||||
const membership = h.device.rows('data').find((d) => Number(d.raw_contact_id) === contact.id && d.mimetype === MimeType.GROUP_MEMBERSHIP)!;
|
||||
h.device.user.deleteData(Number(membership._id));
|
||||
|
||||
// The upload sync Android requests after the edit: the server has nothing new.
|
||||
expect((await h.run(CONTACTS_AUTHORITY, { upload: true })).outcome).toBe('ok');
|
||||
|
||||
expect(h.server.get('ContactCard', 'a', group)!.members ?? {}).toEqual({});
|
||||
expect(h.contacts().find((c) => c.id === contact.id)).toMatchObject({ dirty: false });
|
||||
expect(h.device.rows('data').filter((d) => d.mimetype === MimeType.GROUP_MEMBERSHIP)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a batch the provider committed up to a yield point before it failed', () => {
|
||||
const alert = { al1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' } };
|
||||
|
||||
it("never inserts a clean event's new reminder twice", async () => {
|
||||
const h = real();
|
||||
const event = (uid: string, title: string) =>
|
||||
h.server.addEvent('a', { uid, title, start: '2026-10-06T12:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', calendarIds: { [h.calendar]: true } });
|
||||
const lunch = event('lunch-uid', 'Lunch');
|
||||
const retro = event('retro-uid', 'Retro');
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
// Only an alert for Lunch (its row's columns stay), a new title for Retro: one download batch, two groups.
|
||||
h.server.serverUpdate('CalendarEvent', 'a', lunch, { alerts: alert });
|
||||
h.server.serverUpdate('CalendarEvent', 'a', retro, { title: 'Retrospective' });
|
||||
h.device.partialCommitNextBatch(1, 'assert', (ops) => ops.some((op) => op.op === 'insert' && op.table === 'reminders'));
|
||||
|
||||
expect((await h.run(CALENDAR_AUTHORITY)).outcome).toBe('ok');
|
||||
|
||||
const row = h.events().find((e) => e.title === 'Lunch')!;
|
||||
expect(h.device.rows('reminders').filter((r) => Number(r.event_id) === Number(row._id)).map((r) => r.minutes)).toEqual([15]);
|
||||
expect(h.events().map((e) => e.title).sort()).toEqual(['Lunch', 'Retrospective']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a deletion decided on the server version', () => {
|
||||
/** The contacts planner as fixed for data finding 4: a deleted contact's shadow follows the server. */
|
||||
function refreshingDeletedShadows(h: Harness): void {
|
||||
h.deps.planners = {
|
||||
...h.deps.planners,
|
||||
contacts: {
|
||||
...contactsPlanner,
|
||||
planDownload: (card, local, ctx) => {
|
||||
if (!local?.deleted || !local.sourceId) return contactsPlanner.planDownload(card, local, ctx);
|
||||
const shadow = JSON.stringify({ ...card, '~memberOf': local.shadow?.['~memberOf'] ?? [] });
|
||||
const ops = [{ op: 'update' as const, table: 'raw_contacts' as const, id: local.rawContactId, values: { sync2: shadow } }];
|
||||
return { ops: { ref: local.sourceId, ops }, conflicts: 0, stillDirty: true, effect: 'update' as const, writes: 1 };
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('in a teardown, when the server changed since the last sync', async () => {
|
||||
const h = real();
|
||||
const archive = h.server.addAddressBook('a', { name: 'Archive' });
|
||||
h.prefs.contactsSelection[`a/${archive}`] = false;
|
||||
const [ada] = addServerCards(h, ['Ada Lovelace']);
|
||||
await h.run();
|
||||
refreshingDeletedShadows(h);
|
||||
h.device.user.deleteContact(h.contactNamed('Ada Lovelace')!.id);
|
||||
// Meanwhile another client files Ada in Archive too.
|
||||
h.server.serverUpdate('ContactCard', 'a', ada, { [`addressBookIds/${archive}`]: true });
|
||||
|
||||
expect(await h.teardown(CONTACTS_AUTHORITY)).toEqual({ pending: 0 });
|
||||
|
||||
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ addressBookIds: { [archive]: true } });
|
||||
expect((h.server.get('ContactCard', 'a', ada)!.addressBookIds as object)).not.toHaveProperty(h.book);
|
||||
});
|
||||
|
||||
it('lets the planner see the server version of a contact deleted on the device and filed away meanwhile', async () => {
|
||||
const h = real();
|
||||
const archive = h.server.addAddressBook('a', { name: 'Archive' });
|
||||
h.prefs.contactsSelection[`a/${archive}`] = false;
|
||||
const [ada] = addServerCards(h, ['Ada Lovelace']);
|
||||
await h.run();
|
||||
refreshingDeletedShadows(h);
|
||||
const planned: string[][] = [];
|
||||
const contacts = h.deps.planners.contacts;
|
||||
h.deps.planners = {
|
||||
...h.deps.planners,
|
||||
contacts: {
|
||||
...contacts,
|
||||
planUpload: (local, ctx) => {
|
||||
if (local.deleted) planned.push(Object.keys(local.shadow?.addressBookIds ?? {}));
|
||||
return contacts.planUpload(local, ctx);
|
||||
},
|
||||
},
|
||||
};
|
||||
h.device.user.deleteContact(h.contactNamed('Ada Lovelace')!.id);
|
||||
// Another client files Ada in Archive only: she left every synced book.
|
||||
h.server.serverUpdate('ContactCard', 'a', ada, { addressBookIds: { [archive]: true } });
|
||||
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
|
||||
expect(planned).toEqual([[archive]]);
|
||||
});
|
||||
|
||||
it('leaves a contact deleted on the device that another client moved out of every synced book', async () => {
|
||||
const h = real();
|
||||
const archive = h.server.addAddressBook('a', { name: 'Archive' });
|
||||
h.prefs.contactsSelection[`a/${archive}`] = false;
|
||||
const [ada] = addServerCards(h, ['Ada Lovelace', 'Grace Hopper']);
|
||||
await h.run();
|
||||
h.device.user.deleteContact(h.contactNamed('Ada Lovelace')!.id);
|
||||
// Another client files Ada in Archive only before the deletion reached the server.
|
||||
h.server.serverUpdate('ContactCard', 'a', ada, { addressBookIds: { [archive]: true } });
|
||||
|
||||
const report = await h.run();
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 0, updated: 0, deleted: 0 } } });
|
||||
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ addressBookIds: { [archive]: true } });
|
||||
expect(h.contacts().map((c) => c.name)).toEqual(['Grace Hopper']);
|
||||
});
|
||||
|
||||
/** Lunch synced, deleted on the device, then filed by another client in Archive, which this device does not sync. */
|
||||
async function lunchDeletedAndFiledAway() {
|
||||
const h = real();
|
||||
const archive = h.server.addCalendar('a', { name: 'Archive' });
|
||||
h.prefs.calendarSelection[`a/${archive}`] = false;
|
||||
const lunch = h.server.addEvent('a', { uid: 'lunch-uid', title: 'Lunch', start: '2026-10-06T12:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', calendarIds: { [h.calendar]: true } });
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
h.device.user.deleteEvent(Number(h.events()[0]._id));
|
||||
h.server.serverUpdate('CalendarEvent', 'a', lunch, { calendarIds: { [archive]: true } });
|
||||
return { h, archive, lunch };
|
||||
}
|
||||
|
||||
it('leaves an event deleted on the device that another client moved out of every synced calendar', async () => {
|
||||
const { h, archive, lunch } = await lunchDeletedAndFiledAway();
|
||||
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 0, updated: 0, deleted: 0 } } });
|
||||
expect(h.server.get('CalendarEvent', 'a', lunch)).toMatchObject({ calendarIds: { [archive]: true } });
|
||||
expect(h.events()).toEqual([]);
|
||||
});
|
||||
|
||||
it('still uploads the deletion as it was planned when the planner cannot take the server version', async () => {
|
||||
const { h, lunch } = await lunchDeletedAndFiledAway();
|
||||
h.deps.planners = {
|
||||
...h.deps.planners,
|
||||
calendar: {
|
||||
...calendarPlanner,
|
||||
planDownload: (event, local, ctx) => {
|
||||
if (local?.deleted) throw new Error('cannot take it');
|
||||
return calendarPlanner.planDownload(event, local, ctx);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { deleted: 1 } } });
|
||||
expect(h.server.get('CalendarEvent', 'a', lunch)).toBeUndefined();
|
||||
expect(h.events()).toEqual([]);
|
||||
});
|
||||
|
||||
it('waits in a teardown, with the edits, while no state guards the upload (the first sync never ended)', async () => {
|
||||
const h = real();
|
||||
const [ada] = addServerCards(h, ['Ada Lovelace', 'Grace Hopper', ...Array.from({ length: 60 }, (_, i) => `P${i}`)]);
|
||||
// The first sync (a full reconcile) runs out of time after its first chunk (Ada and Grace): no state yet.
|
||||
h.checkpoints.onCheckpoint = (n) => {
|
||||
if (n === 3) h.clock.now += RUN_BUDGET;
|
||||
};
|
||||
expect((await h.run()).outcome).toBe('cancelled');
|
||||
h.checkpoints.onCheckpoint = undefined;
|
||||
expect(h.state()?.accounts.a.itemsState).toBeNull();
|
||||
renameDeviceContact(h, h.contactNamed('Ada Lovelace')!.id, 'Ada King');
|
||||
h.device.user.deleteContact(h.contactNamed('Grace Hopper')!.id);
|
||||
addDeviceContact(h, 'Hedy Lamarr');
|
||||
// Meanwhile another client changes Ada.
|
||||
h.server.serverUpdate('ContactCard', 'a', ada, { 'name/full': 'Ada, Countess of Lovelace' });
|
||||
|
||||
expect(await h.teardown(CONTACTS_AUTHORITY)).toEqual({ pending: 2 });
|
||||
|
||||
expect(h.serverNames().filter((n) => !/^P\d+$/.test(n))).toEqual(['Ada, Countess of Lovelace', 'Grace Hopper', 'Hedy Lamarr']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a device edit the server refuses (forbidden)', () => {
|
||||
it('puts a contact back in place, keeping what only the device knows', async () => {
|
||||
const h = real();
|
||||
const [ada] = addServerCards(h, ['Ada Lovelace']);
|
||||
await h.run();
|
||||
const before = h.contactNamed('Ada Lovelace')!;
|
||||
h.device.user.star(before.id);
|
||||
renameDeviceContact(h, before.id, 'Ada King');
|
||||
h.server.setErrorFor('ContactCard', 'a', ada, { type: 'forbidden' });
|
||||
|
||||
const report = await h.run();
|
||||
|
||||
expect(report.itemErrors).toEqual([expect.objectContaining({ ref: `a/${ada}`, type: 'forbidden' })]);
|
||||
expect(h.server.get('ContactCard', 'a', ada)).toMatchObject({ name: { full: 'Ada Lovelace' } });
|
||||
expect(h.contacts()).toEqual([expect.objectContaining({ id: before.id, name: 'Ada Lovelace', dirty: false })]);
|
||||
expect(h.contacts()[0].row.starred).toBe(1);
|
||||
});
|
||||
|
||||
it('brings a contact back whose deletion the server refuses', async () => {
|
||||
const h = real();
|
||||
const [ada] = addServerCards(h, ['Ada Lovelace']);
|
||||
await h.run();
|
||||
h.device.user.deleteContact(h.contactNamed('Ada Lovelace')!.id);
|
||||
h.server.setErrorFor('ContactCard', 'a', ada, { type: 'forbidden' });
|
||||
|
||||
const report = await h.run();
|
||||
|
||||
expect(report.itemErrors).toEqual([expect.objectContaining({ ref: `a/${ada}`, type: 'forbidden' })]);
|
||||
expect(h.server.get('ContactCard', 'a', ada)).toBeDefined();
|
||||
expect(h.contacts()).toEqual([expect.objectContaining({ sourceId: `a/${ada}`, name: 'Ada Lovelace', dirty: false, deleted: false })]);
|
||||
});
|
||||
|
||||
it('puts an event back in place', async () => {
|
||||
const h = real();
|
||||
const standup = h.server.addEvent('a', {
|
||||
uid: 'standup-uid',
|
||||
title: 'Standup',
|
||||
start: '2026-09-28T09:00:00',
|
||||
duration: 'PT15M',
|
||||
timeZone: 'Europe/Berlin',
|
||||
calendarIds: { [h.calendar]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const row = h.events()[0];
|
||||
h.device.user.updateEvent(Number(row._id), { [Events.TITLE]: 'Mine', [Events.CUSTOM_APP_PACKAGE]: 'org.example.app' });
|
||||
h.server.setErrorFor('CalendarEvent', 'a', standup, { type: 'forbidden' });
|
||||
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report.itemErrors).toEqual([expect.objectContaining({ ref: `a/${standup}`, type: 'forbidden' })]);
|
||||
expect(h.events()).toEqual([
|
||||
expect.objectContaining({ _id: row._id, title: 'Standup', dirty: 0, [Events.CUSTOM_APP_PACKAGE]: 'org.example.app' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the last run status the settings show', () => {
|
||||
/** A run that puts back an edit the server refuses: reported once, nothing waits afterwards. */
|
||||
async function refusedEdit() {
|
||||
const h = real();
|
||||
const [ada] = addServerCards(h, ['Ada Lovelace', 'Grace Hopper']);
|
||||
await h.run();
|
||||
renameDeviceContact(h, h.contactNamed('Ada Lovelace')!.id, 'Ada King');
|
||||
h.server.setErrorFor('ContactCard', 'a', ada, { type: 'forbidden' });
|
||||
h.clock.now += 60_000;
|
||||
expect((await h.run()).itemErrors).toEqual([expect.objectContaining({ type: 'forbidden' })]);
|
||||
const status = h.lastStatus()!;
|
||||
expect(status).toMatchObject({ outcome: 'ok', itemErrors: 1 });
|
||||
return { h, status };
|
||||
}
|
||||
|
||||
it('keeps what a run reported through an upload sync after it that finds nothing to do', async () => {
|
||||
const { h, status } = await refusedEdit();
|
||||
h.clock.now += 2_000;
|
||||
|
||||
await h.run(CONTACTS_AUTHORITY, { upload: true });
|
||||
|
||||
expect(h.lastStatus()).toEqual({ ...status, at: expect.any(Number) });
|
||||
expect(h.lastStatus()!.at).toBeGreaterThan(status.at);
|
||||
});
|
||||
|
||||
it('keeps what a run reported through a follow-up sync that finds nothing new on the server', async () => {
|
||||
const { h, status } = await refusedEdit();
|
||||
h.clock.now += 2_000;
|
||||
|
||||
expect((await h.run()).itemErrors).toEqual([]);
|
||||
|
||||
expect(h.lastStatus()).toEqual({ ...status, at: expect.any(Number) });
|
||||
expect(h.lastStatus()!.at).toBeGreaterThan(status.at);
|
||||
});
|
||||
|
||||
it('replaces it with a run that did something', async () => {
|
||||
const { h } = await refusedEdit();
|
||||
addServerCards(h, ['Linus Torvalds']);
|
||||
|
||||
await h.run();
|
||||
|
||||
expect(h.lastStatus()).toMatchObject({ outcome: 'ok', itemErrors: 0, stats: { downloaded: { created: 1 } } });
|
||||
});
|
||||
|
||||
it('shows a sync that finds nothing to do after a failed one as recovered', async () => {
|
||||
const h = real();
|
||||
addServerCards(h, ['Ada Lovelace']);
|
||||
await h.run();
|
||||
renameDeviceContact(h, h.contactNamed('Ada Lovelace')!.id, 'Ada King');
|
||||
h.server.failNextRequest('network', { match: 'ContactCard/set' });
|
||||
expect((await h.run()).outcome).toBe('io');
|
||||
expect(h.lastStatus()).toMatchObject({ outcome: 'io' });
|
||||
// Uploaded by the next sync; the one after it finds nothing to do.
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
h.clock.now += 2_000;
|
||||
h.server.failNextRequest('network', { match: 'ContactCard/changes' });
|
||||
expect((await h.run()).outcome).toBe('io');
|
||||
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
|
||||
expect(h.lastStatus()).toMatchObject({ outcome: 'ok', itemErrors: 0, conflicts: 0 });
|
||||
expect(h.lastStatus()).not.toHaveProperty('message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a create whose target the server deleted after the claim', () => {
|
||||
it('claims a new contact again for a book that exists', async () => {
|
||||
const h = real();
|
||||
const work = h.server.addAddressBook('a', { name: 'Work' });
|
||||
h.prefs.newContactsAddressBook = `a/${work}`;
|
||||
await h.run();
|
||||
const id = addDeviceContact(h, 'Hedy Lamarr');
|
||||
// The claim names Work, the create is lost; then another client deletes Work.
|
||||
h.server.failNextRequest('network', { match: 'ContactCard/set' });
|
||||
expect((await h.run()).outcome).toBe('io');
|
||||
h.server.serverDestroy('AddressBook', 'a', work);
|
||||
|
||||
const report = await h.run();
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 1 } } });
|
||||
const hedy = h.server.all('ContactCard', 'a').find((c) => (c.name as { full?: string }).full === 'Hedy Lamarr')!;
|
||||
expect(hedy.addressBookIds).toEqual({ [h.book]: true });
|
||||
expect(h.contacts().find((c) => c.id === id)).toMatchObject({ sourceId: `a/${hedy.id}`, dirty: false });
|
||||
expect(h.contacts().find((c) => c.id === id)!.row.sync4).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a new event whose calendar is gone, without poisoning it, until the user moves it', async () => {
|
||||
const h = real();
|
||||
const work = h.server.addCalendar('a', { name: 'Work' });
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const rowOf = (calendarId: string) => Number(h.device.rows('calendars').find((c) => c._sync_id === `a/${calendarId}`)!._id);
|
||||
const workRow = rowOf(work);
|
||||
const dentist = { title: 'Dentist', dtstart: Date.UTC(2026, 9, 1, 8), dtend: Date.UTC(2026, 9, 1, 9), eventTimezone: 'Europe/Berlin' };
|
||||
const id = h.device.user.insertEvent(workRow, dentist);
|
||||
h.server.failNextRequest('network', { match: 'CalendarEvent/set' });
|
||||
expect((await h.run(CALENDAR_AUTHORITY)).outcome).toBe('io');
|
||||
h.server.serverDestroy('Calendar', 'a', work);
|
||||
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report.itemErrors).toEqual([expect.objectContaining({ ref: `row:${id}`, type: 'collectionGone' })]);
|
||||
expect(h.server.all('CalendarEvent', 'a')).toEqual([]);
|
||||
expect(h.events().find((e) => Number(e._id) === id)).toMatchObject({ title: 'Dentist', sync_data5: null });
|
||||
|
||||
// The user moves it to the personal calendar (Etar: delete + insert).
|
||||
h.device.user.deleteEvent(id);
|
||||
h.device.user.insertEvent(rowOf(h.calendar), dentist);
|
||||
expect(await h.run(CALENDAR_AUTHORITY)).toMatchObject({ outcome: 'ok', itemErrors: [] });
|
||||
|
||||
expect(h.server.all('CalendarEvent', 'a').map((e) => [e.title, e.calendarIds])).toEqual([['Dentist', { [h.calendar]: true }]]);
|
||||
expect(h.device.rows('calendars').map((c) => c._sync_id)).toEqual([`a/${h.calendar}`]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a device edit of an object the server moved out of every synced collection', () => {
|
||||
/** Ada with two numbers in the synced book; the device deletes the second in place (AOSP). */
|
||||
async function adaWithDeletedNumber() {
|
||||
const h = real();
|
||||
const archive = h.server.addAddressBook('a', { name: 'Archive' });
|
||||
h.prefs.contactsSelection[`a/${archive}`] = false;
|
||||
const ada = h.server.addCard('a', {
|
||||
uid: 'ada-uid',
|
||||
name: { full: 'Ada Lovelace' },
|
||||
phones: { p1: { '@type': 'Phone', number: '+1 111' }, p2: { '@type': 'Phone', number: '+1 222' } },
|
||||
addressBookIds: { [h.book]: true },
|
||||
});
|
||||
await h.run();
|
||||
const contact = h.contactNamed('Ada Lovelace')!;
|
||||
const p2 = h.device.rows('data').find((d) => Number(d.raw_contact_id) === contact.id && d[Data.DATA1] === '+1 222')!;
|
||||
h.device.user.deleteData(Number(p2._id));
|
||||
// Meanwhile another client files Ada in Archive only and adds a number.
|
||||
h.server.serverUpdate('ContactCard', 'a', ada, { addressBookIds: { [archive]: true }, 'phones/p3': { '@type': 'Phone', number: '+1 333' } });
|
||||
return { h, ada, archive };
|
||||
}
|
||||
|
||||
const numbers = (card: Record<string, unknown>) =>
|
||||
Object.values((card.phones ?? {}) as Record<string, { number: string }>).map((p) => p.number).sort();
|
||||
|
||||
it('uploads it against the server version, then drops the rows', async () => {
|
||||
const { h, ada, archive } = await adaWithDeletedNumber();
|
||||
|
||||
const report = await h.run();
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { updated: 1 } } });
|
||||
const card = h.server.get('ContactCard', 'a', ada)!;
|
||||
expect(numbers(card)).toEqual(['+1 111', '+1 333']);
|
||||
expect(card.addressBookIds).toEqual({ [archive]: true });
|
||||
expect(h.contacts()).toEqual([]);
|
||||
});
|
||||
|
||||
it('does the same when a full reconcile finds the object outside', async () => {
|
||||
const { h, ada, archive } = await adaWithDeletedNumber();
|
||||
h.server.truncateChangeLog('a', 'contacts');
|
||||
|
||||
const report = await h.run();
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [] });
|
||||
const card = h.server.get('ContactCard', 'a', ada)!;
|
||||
expect(numbers(card)).toEqual(['+1 111', '+1 333']);
|
||||
expect(card.addressBookIds).toEqual({ [archive]: true });
|
||||
expect(h.contacts()).toEqual([]);
|
||||
});
|
||||
|
||||
it('never uploads an event edit against an outdated shadow', async () => {
|
||||
const h = real();
|
||||
const archive = h.server.addCalendar('a', { name: 'Archive' });
|
||||
h.prefs.calendarSelection[`a/${archive}`] = false;
|
||||
const series = h.server.addEvent('a', {
|
||||
uid: 'series-uid',
|
||||
title: 'Standup',
|
||||
start: '2026-09-28T09:00:00',
|
||||
duration: 'PT15M',
|
||||
timeZone: 'Europe/Berlin',
|
||||
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily' },
|
||||
recurrenceOverrides: { '2026-09-29T09:00:00': { title: 'Planning' } },
|
||||
calendarIds: { [h.calendar]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const master = h.events().find((e) => e._sync_id === `a/${series}`)!;
|
||||
const later = Number(master[Events.DTSTART]) + 3_600_000;
|
||||
// The device moves the series by an hour; another client files it in Archive and changes another instance.
|
||||
h.device.user.updateEvent(Number(master._id), { [Events.DTSTART]: later });
|
||||
h.server.serverUpdate('CalendarEvent', 'a', series, {
|
||||
calendarIds: { [archive]: true },
|
||||
'recurrenceOverrides/2026-10-01T09:00:00': { title: 'Retro' },
|
||||
});
|
||||
|
||||
expect(await h.run(CALENDAR_AUTHORITY)).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { updated: 1 } } });
|
||||
|
||||
const event = h.server.get('CalendarEvent', 'a', series)!;
|
||||
const titles = Object.values((event.recurrenceOverrides ?? {}) as Record<string, { title?: string }>).map((o) => o.title).sort();
|
||||
expect(titles).toEqual(['Planning', 'Retro']);
|
||||
// The move went up against the server's version, and the rows went with the event.
|
||||
expect(event).toMatchObject({ start: '2026-09-28T10:00:00', calendarIds: { [archive]: true } });
|
||||
expect(h.events()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a device event that carries the uid of a synced event (an app copied it) under a fresh uid', async () => {
|
||||
const h = real();
|
||||
const standup = h.server.addEvent('a', {
|
||||
uid: 'standup-uid',
|
||||
title: 'Standup',
|
||||
start: '2026-09-28T09:00:00',
|
||||
duration: 'PT15M',
|
||||
timeZone: 'Europe/Berlin',
|
||||
calendarIds: { [h.calendar]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const rowId = Number(h.device.rows('calendars')[0]._id);
|
||||
|
||||
const copy = h.device.user.insertEvent(rowId, {
|
||||
title: 'Standup (copy)',
|
||||
dtstart: Date.UTC(2026, 8, 29, 7),
|
||||
dtend: Date.UTC(2026, 8, 29, 7, 15),
|
||||
eventTimezone: 'Europe/Berlin',
|
||||
uid2445: 'standup-uid',
|
||||
});
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 1 } } });
|
||||
expect(h.server.get('CalendarEvent', 'a', standup)).toMatchObject({ title: 'Standup', uid: 'standup-uid' });
|
||||
const created = h.server.all('CalendarEvent', 'a').find((e) => e.title === 'Standup (copy)')!;
|
||||
expect(created.uid).not.toBe('standup-uid');
|
||||
expect(h.events().find((e) => Number(e._id) === copy)).toMatchObject({ _sync_id: `a/${created.id}`, uid2445: created.uid });
|
||||
expect(h.events().every((e) => Number(e.dirty ?? 0) === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('uploads "this and following" in one run: the capped rule and the new series', async () => {
|
||||
const h = real();
|
||||
const series = h.server.addEvent('a', {
|
||||
uid: 'series-uid',
|
||||
title: 'Standup',
|
||||
start: '2026-09-28T09:00:00',
|
||||
duration: 'PT15M',
|
||||
timeZone: 'Europe/Berlin',
|
||||
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily' },
|
||||
calendarIds: { [h.calendar]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const master = h.events()[0];
|
||||
expect(master).toMatchObject({ rrule: 'FREQ=DAILY' });
|
||||
const rowId = Number(master.calendar_id);
|
||||
|
||||
// Etar: the master's rule ends before the split, a new series starts there (it copies the uid).
|
||||
h.device.user.updateEvent(Number(master._id), { rrule: 'FREQ=DAILY;UNTIL=20260930T235959Z' });
|
||||
const later = h.device.user.insertEvent(rowId, {
|
||||
title: 'Standup',
|
||||
dtstart: Date.UTC(2026, 9, 1, 7),
|
||||
duration: 'P900S',
|
||||
rrule: 'FREQ=DAILY',
|
||||
eventTimezone: 'Europe/Berlin',
|
||||
uid2445: 'series-uid',
|
||||
});
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 1, updated: 1 } } });
|
||||
const capped = h.server.get('CalendarEvent', 'a', series)!;
|
||||
expect(capped.recurrenceRule).toMatchObject({ frequency: 'daily', until: expect.any(String) });
|
||||
const next = h.server.all('CalendarEvent', 'a').find((e) => e.id !== series)!;
|
||||
expect(next).toMatchObject({ start: '2026-10-01T09:00:00', timeZone: 'Europe/Berlin', recurrenceRule: { frequency: 'daily' } });
|
||||
expect(next.uid).not.toBe('series-uid');
|
||||
expect(h.events().find((e) => Number(e._id) === later)).toMatchObject({ _sync_id: `a/${next.id}` });
|
||||
expect(h.events().every((e) => Number(e.dirty ?? 0) === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("uploads Etar's move to another calendar as a patch of the same event, keeping what the device can't show", async () => {
|
||||
const h = real();
|
||||
const work = h.server.addCalendar('a', { name: 'Work' });
|
||||
const lunch = h.server.addEvent('a', {
|
||||
uid: 'lunch-uid',
|
||||
title: 'Lunch',
|
||||
start: '2026-10-06T12:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'Europe/Berlin',
|
||||
keywords: { important: true },
|
||||
virtualLocations: { v1: { '@type': 'VirtualLocation', uri: 'https://meet.example.com/lunch' } },
|
||||
organizerCalendarAddress: 'mailto:alice@example.com',
|
||||
participants: {
|
||||
me: { '@type': 'Participant', calendarAddress: 'mailto:alice@example.com', roles: { owner: true, attendee: true }, participationStatus: 'accepted' },
|
||||
bob: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.com', name: 'Bob', roles: { attendee: true }, participationStatus: 'accepted' },
|
||||
},
|
||||
calendarIds: { [h.calendar]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const old = h.events().find((e) => e._sync_id === `a/${lunch}`)!;
|
||||
const workRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `a/${work}`)!._id);
|
||||
|
||||
const moved = etarReinsert(h, old, workRow);
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 0, updated: 1, deleted: 0 } } });
|
||||
expect(h.server.all('CalendarEvent', 'a')).toHaveLength(1);
|
||||
const event = h.server.get('CalendarEvent', 'a', lunch)!;
|
||||
expect(event.calendarIds).toEqual({ [work]: true });
|
||||
expect(event).toMatchObject({
|
||||
uid: 'lunch-uid',
|
||||
keywords: { important: true },
|
||||
virtualLocations: { v1: { uri: 'https://meet.example.com/lunch' } },
|
||||
participants: { bob: { participationStatus: 'accepted' } },
|
||||
});
|
||||
// A move tells nobody.
|
||||
expect(h.server.scheduling).toEqual([]);
|
||||
expect(h.events()).toEqual([expect.objectContaining({ _id: moved, _sync_id: `a/${lunch}`, uid2445: 'lunch-uid', calendar_id: workRow, dirty: 0 })]);
|
||||
|
||||
const before = h.batches.log.length;
|
||||
expect((await h.run(CALENDAR_AUTHORITY)).outcome).toBe('ok');
|
||||
expect(rowWrites(h.batches.log.slice(before))).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a meeting Etar moved the same event, without cancelling and inviting anyone again', async () => {
|
||||
const h = real();
|
||||
const work = h.server.addCalendar('a', { name: 'Work' });
|
||||
const lunch = h.server.addEvent('a', {
|
||||
uid: 'lunch-uid',
|
||||
title: 'Lunch',
|
||||
start: '2026-10-06T12:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'Europe/Berlin',
|
||||
organizerCalendarAddress: 'mailto:alice@example.com',
|
||||
participants: {
|
||||
me: { '@type': 'Participant', calendarAddress: 'mailto:alice@example.com', roles: { owner: true, attendee: true }, participationStatus: 'accepted' },
|
||||
bob: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.com', name: 'Bob', roles: { attendee: true, optional: true }, participationStatus: 'accepted' },
|
||||
},
|
||||
calendarIds: { [h.calendar]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const old = h.events().find((e) => e._sync_id === `a/${lunch}`)!;
|
||||
const workRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `a/${work}`)!._id);
|
||||
|
||||
// What Etar really inserts: Bob as a required attendee without an answer, and no row for the user.
|
||||
const moved = h.device.user.etarMove(Number(old._id), workRow);
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 0, updated: 1, deleted: 0 } } });
|
||||
expect(h.server.all('CalendarEvent', 'a')).toHaveLength(1);
|
||||
expect(h.server.get('CalendarEvent', 'a', lunch)).toMatchObject({
|
||||
uid: 'lunch-uid',
|
||||
calendarIds: { [work]: true },
|
||||
participants: { bob: { roles: { attendee: true, optional: true }, participationStatus: 'accepted' } },
|
||||
});
|
||||
expect(h.server.scheduling).toEqual([]);
|
||||
// The rows show the meeting as the server holds it again: the user's own row and Bob's answer.
|
||||
const attendees = h.device.rows('attendees').filter((a) => Number(a.event_id) === moved);
|
||||
expect(attendees.map((a) => [a[Attendees.ATTENDEE_EMAIL], a[Attendees.ATTENDEE_STATUS], a[Attendees.ATTENDEE_TYPE]]).sort()).toEqual([
|
||||
['alice@example.com', 1, 1],
|
||||
['bob@example.com', 1, 2],
|
||||
]);
|
||||
expect(h.events()).toEqual([expect.objectContaining({ _id: moved, _sync_id: `a/${lunch}`, dirty: 0 })]);
|
||||
});
|
||||
|
||||
it('uploads a series Etar turned into a single event as the removal of its rule', async () => {
|
||||
const h = real();
|
||||
const series = h.server.addEvent('a', {
|
||||
uid: 'series-uid',
|
||||
title: 'Standup',
|
||||
start: '2026-09-28T09:00:00',
|
||||
duration: 'PT15M',
|
||||
timeZone: 'Europe/Berlin',
|
||||
keywords: { team: true },
|
||||
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'daily' },
|
||||
calendarIds: { [h.calendar]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const old = h.events().find((e) => e._sync_id === `a/${series}`)!;
|
||||
|
||||
const single = etarReinsert(h, old, Number(old.calendar_id), {
|
||||
[Events.RRULE]: null,
|
||||
[Events.DURATION]: null,
|
||||
[Events.DTEND]: Number(old[Events.DTSTART]) + 15 * 60_000,
|
||||
});
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 0, updated: 1, deleted: 0 } } });
|
||||
expect(h.server.all('CalendarEvent', 'a')).toHaveLength(1);
|
||||
const event = h.server.get('CalendarEvent', 'a', series)!;
|
||||
expect(event).toMatchObject({ uid: 'series-uid', title: 'Standup', keywords: { team: true } });
|
||||
expect(event.recurrenceRule).toBeUndefined();
|
||||
expect(h.events()).toEqual([expect.objectContaining({ _id: single, _sync_id: `a/${series}`, rrule: null, dirty: 0 })]);
|
||||
});
|
||||
|
||||
it('never leaves two rows with one identity when the moved event is edited while its move uploads', async () => {
|
||||
const h = real();
|
||||
const work = h.server.addCalendar('a', { name: 'Work' });
|
||||
const lunch = h.server.addEvent('a', {
|
||||
uid: 'lunch-uid',
|
||||
title: 'Lunch',
|
||||
start: '2026-10-06T12:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'Europe/Berlin',
|
||||
calendarIds: { [h.calendar]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const old = h.events().find((e) => e._sync_id === `a/${lunch}`)!;
|
||||
const workRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `a/${work}`)!._id);
|
||||
const moved = etarReinsert(h, old, workRow);
|
||||
let edited = false;
|
||||
const stop = h.server.onAfterRequest((request) => {
|
||||
if (edited || !request.methods.includes('CalendarEvent/set')) return;
|
||||
edited = true;
|
||||
h.device.user.updateEvent(moved, { [Events.TITLE]: 'Team lunch' });
|
||||
});
|
||||
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
stop();
|
||||
|
||||
expect(edited).toBe(true);
|
||||
expect(sharedIdentities(h)).toEqual([]);
|
||||
expect(h.events().find((e) => Number(e._id) === moved)).toMatchObject({ title: 'Team lunch' });
|
||||
|
||||
expect((await h.run(CALENDAR_AUTHORITY)).outcome).toBe('ok');
|
||||
expect(sharedIdentities(h)).toEqual([]);
|
||||
const server = h.server.all('CalendarEvent', 'a');
|
||||
expect(server.map((e) => [e.title, e.calendarIds])).toEqual([['Team lunch', { [work]: true }]]);
|
||||
expect(h.events()).toEqual([expect.objectContaining({ _id: moved, _sync_id: `a/${server[0].id}`, dirty: 0 })]);
|
||||
});
|
||||
|
||||
it('pairs the rows again when an app rewrote the moved event unchanged while its move uploaded', async () => {
|
||||
const h = real();
|
||||
const work = h.server.addCalendar('a', { name: 'Work' });
|
||||
const lunch = h.server.addEvent('a', {
|
||||
uid: 'lunch-uid',
|
||||
title: 'Lunch',
|
||||
start: '2026-10-06T12:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'Europe/Berlin',
|
||||
keywords: { important: true },
|
||||
organizerCalendarAddress: 'mailto:alice@example.com',
|
||||
participants: {
|
||||
me: { '@type': 'Participant', calendarAddress: 'mailto:alice@example.com', roles: { owner: true, attendee: true }, participationStatus: 'accepted' },
|
||||
bob: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.com', name: 'Bob', roles: { attendee: true }, participationStatus: 'accepted' },
|
||||
},
|
||||
calendarIds: { [h.calendar]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const old = h.events().find((e) => e._sync_id === `a/${lunch}`)!;
|
||||
const workRow = Number(h.device.rows('calendars').find((c) => c._sync_id === `a/${work}`)!._id);
|
||||
const moved = etarReinsert(h, old, workRow);
|
||||
const stop = h.server.onAfterRequest((request) => {
|
||||
if (!request.methods.includes('CalendarEvent/set')) return;
|
||||
stop();
|
||||
// Fossify's save: the same attendees, deleted and inserted again.
|
||||
const attendees = h.device.rows('attendees').filter((a) => Number(a.event_id) === moved).map(({ _id, event_id, ...a }) => a);
|
||||
h.device.user.fossifySaveEvent(moved, {}, attendees, []);
|
||||
});
|
||||
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(report).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 0, updated: 1, deleted: 0 } } });
|
||||
expect(h.server.all('CalendarEvent', 'a')).toHaveLength(1);
|
||||
expect(h.server.get('CalendarEvent', 'a', lunch)).toMatchObject({ uid: 'lunch-uid', calendarIds: { [work]: true }, keywords: { important: true } });
|
||||
expect(h.events()).toEqual([expect.objectContaining({ _id: moved, _sync_id: `a/${lunch}`, dirty: 0 })]);
|
||||
});
|
||||
|
||||
it('syncs events both ways and writes nothing for its own echo', async () => {
|
||||
const h = real();
|
||||
const standup = h.server.addEvent('a', {
|
||||
uid: 'e1',
|
||||
title: 'Standup',
|
||||
start: '2026-09-28T09:00:00',
|
||||
duration: 'PT15M',
|
||||
timeZone: 'Europe/Berlin',
|
||||
calendarIds: { [h.calendar]: true },
|
||||
});
|
||||
expect((await h.run(CALENDAR_AUTHORITY)).outcome).toBe('ok');
|
||||
const rowId = Number(h.device.rows('calendars')[0]._id);
|
||||
|
||||
h.device.user.insertEvent(rowId, { title: 'Lunch', dtstart: Date.UTC(2026, 8, 28, 10), dtend: Date.UTC(2026, 8, 28, 11), eventTimezone: 'Europe/Berlin' });
|
||||
h.device.user.updateEvent(Number(h.events().find((e) => e.title === 'Standup')!._id), { title: 'Daily standup' });
|
||||
const up = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
expect(up).toMatchObject({ outcome: 'ok', itemErrors: [], stats: { uploaded: { created: 1, updated: 1 } } });
|
||||
expect(h.server.get('CalendarEvent', 'a', standup)).toMatchObject({ title: 'Daily standup' });
|
||||
expect(h.server.all('CalendarEvent', 'a').find((e) => e.title === 'Lunch')).toMatchObject({ start: '2026-09-28T12:00:00', timeZone: 'Europe/Berlin' });
|
||||
expect(h.events().every((e) => Number(e.dirty ?? 0) === 0 && String(e._sync_id).startsWith('a/'))).toBe(true);
|
||||
|
||||
const before = h.batches.log.length;
|
||||
expect((await h.run(CALENDAR_AUTHORITY)).outcome).toBe('ok');
|
||||
expect(rowWrites(h.batches.log.slice(before))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
// SyncState `selected` names the collections whose rows are all on the
|
||||
// device: a newly selected collection is loaded, a deselected one dropped.
|
||||
// A load or a drop that stopped half-way must neither hide the collection
|
||||
// from the next load nor keep its rows from the next drop.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Events } from '../../android-columns';
|
||||
import { calendarPlanner } from '../../calendar/planner';
|
||||
import { contactsPlanner } from '../../contacts/planner';
|
||||
import { addServerCards, CALENDAR_AUTHORITY, createHarness, RUN_BUDGET, type Harness } from './harness';
|
||||
|
||||
function real(): Harness {
|
||||
const h = createHarness();
|
||||
h.deps.planners = { contacts: contactsPlanner, calendar: calendarPlanner };
|
||||
return h;
|
||||
}
|
||||
|
||||
/** Runs out of time right after checkpoint `n` of the next run. */
|
||||
function outOfTimeAt(h: Harness, n: number): void {
|
||||
h.checkpoints.count = 0;
|
||||
h.checkpoints.onCheckpoint = (count) => {
|
||||
if (count === n) h.clock.now += RUN_BUDGET;
|
||||
};
|
||||
}
|
||||
|
||||
describe('device sync engine: collections loaded or dropped in part', () => {
|
||||
it('brings every card back when a book whose drop stopped half-way is selected again', async () => {
|
||||
const h = real();
|
||||
const work = h.server.addAddressBook('a', { name: 'Work' });
|
||||
addServerCards(h, ['Ada']);
|
||||
addServerCards(h, Array.from({ length: 120 }, (_, i) => `W${String(i).padStart(3, '0')}`), 'a', work);
|
||||
await h.run();
|
||||
expect(h.contacts()).toHaveLength(121);
|
||||
h.prefs.contactsSelection[`a/${work}`] = false;
|
||||
// 'changes', 'deselect' (sync), 'deselect' (chunk 1): out of time after the first chunk went.
|
||||
outOfTimeAt(h, 3);
|
||||
expect((await h.run()).outcome).toBe('cancelled');
|
||||
h.checkpoints.onCheckpoint = undefined;
|
||||
const left = h.contacts().length;
|
||||
expect(left).toBeGreaterThan(1);
|
||||
expect(left).toBeLessThan(121);
|
||||
|
||||
h.prefs.contactsSelection[`a/${work}`] = true;
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
|
||||
expect(h.contacts()).toHaveLength(121);
|
||||
expect(h.state()?.accounts.a.selected.sort()).toEqual([`a/${h.book}`, `a/${work}`].sort());
|
||||
});
|
||||
|
||||
it('finishes the drop of a book in the next run when it stopped half-way', async () => {
|
||||
const h = real();
|
||||
const work = h.server.addAddressBook('a', { name: 'Work' });
|
||||
addServerCards(h, ['Ada']);
|
||||
addServerCards(h, Array.from({ length: 120 }, (_, i) => `W${String(i).padStart(3, '0')}`), 'a', work);
|
||||
await h.run();
|
||||
h.prefs.contactsSelection[`a/${work}`] = false;
|
||||
outOfTimeAt(h, 3);
|
||||
expect((await h.run()).outcome).toBe('cancelled');
|
||||
h.checkpoints.onCheckpoint = undefined;
|
||||
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
|
||||
expect(h.contacts().map((c) => c.name)).toEqual(['Ada']);
|
||||
expect(h.state()?.accounts.a.selected).toEqual([`a/${h.book}`]);
|
||||
});
|
||||
|
||||
it('keeps a deselected book partial while a dirty card of it waits, and drops it once that is uploaded', async () => {
|
||||
const h = real();
|
||||
const work = h.server.addAddressBook('a', { name: 'Work' });
|
||||
addServerCards(h, ['Ada']);
|
||||
const [boss] = addServerCards(h, ['Boss', 'Colleague'], 'a', work);
|
||||
await h.run();
|
||||
const bossRow = h.contactNamed('Boss')!.id;
|
||||
const name = h.nameDataRow(bossRow)!;
|
||||
h.device.user.updateData(Number(name._id), { data1: 'The Boss' });
|
||||
h.server.setErrorFor('ContactCard', 'a', boss, { type: 'tooLarge' });
|
||||
h.prefs.contactsSelection[`a/${work}`] = false;
|
||||
|
||||
await h.run();
|
||||
|
||||
expect(h.contacts().map((c) => c.name).sort()).toEqual(['Ada', 'The Boss']);
|
||||
expect(h.state()?.accounts.a).toMatchObject({ selected: [`a/${h.book}`], partial: [`a/${work}`] });
|
||||
|
||||
h.clock.now += 2 * 3_600_000;
|
||||
await h.run();
|
||||
|
||||
expect(h.server.get('ContactCard', 'a', boss)).toMatchObject({ name: { full: 'The Boss' } });
|
||||
expect(h.contacts().map((c) => c.name)).toEqual(['Ada']);
|
||||
expect(h.state()?.accounts.a).toMatchObject({ selected: [`a/${h.book}`], partial: [] });
|
||||
});
|
||||
|
||||
it('drops the cards of a book deselected again while its load had stopped half-way', async () => {
|
||||
const h = real();
|
||||
const work = h.server.addAddressBook('a', { name: 'Work' });
|
||||
h.prefs.contactsSelection[`a/${work}`] = false;
|
||||
addServerCards(h, ['Ada']);
|
||||
addServerCards(h, Array.from({ length: 120 }, (_, i) => `W${String(i).padStart(3, '0')}`), 'a', work);
|
||||
await h.run();
|
||||
expect(h.contacts()).toHaveLength(1);
|
||||
h.prefs.contactsSelection[`a/${work}`] = true;
|
||||
// 'changes', 'select', 'download' (chunk 1): out of time after the first chunk came.
|
||||
outOfTimeAt(h, 3);
|
||||
expect((await h.run()).outcome).toBe('cancelled');
|
||||
h.checkpoints.onCheckpoint = undefined;
|
||||
expect(h.contacts().length).toBeGreaterThan(1);
|
||||
|
||||
h.prefs.contactsSelection[`a/${work}`] = false;
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
|
||||
expect(h.contacts().map((c) => c.name)).toEqual(['Ada']);
|
||||
});
|
||||
|
||||
it('downloads every card of a book selected while a first sync was cut short', async () => {
|
||||
const h = createHarness();
|
||||
const extra = h.server.addAddressBook('a', { name: 'Friends' });
|
||||
const friends = addServerCards(h, Array.from({ length: 10 }, (_, i) => `F${i}`), 'a', extra);
|
||||
addServerCards(h, Array.from({ length: 120 }, (_, i) => `P${String(i).padStart(3, '0')}`));
|
||||
h.prefs.contactsSelection[`a/${extra}`] = false;
|
||||
// The first sync (a full reconcile): 'reconcile', 'reconcile:ids', 'reconcile:objects': out of time after chunk 1.
|
||||
outOfTimeAt(h, 3);
|
||||
expect((await h.run()).outcome).toBe('cancelled');
|
||||
h.checkpoints.onCheckpoint = undefined;
|
||||
const marker = h.state()!.accounts.a.reconcile!;
|
||||
expect(friends.filter((id) => id <= (marker.after as string)).length).toBeGreaterThan(0);
|
||||
|
||||
// The user turns Friends on before the next sync.
|
||||
h.prefs.contactsSelection[`a/${extra}`] = true;
|
||||
expect((await h.run()).outcome).toBe('ok');
|
||||
|
||||
const onDevice = new Set(h.contacts().map((c) => c.sourceId));
|
||||
expect(friends.filter((id) => !onDevice.has(`a/${id}`))).toEqual([]);
|
||||
expect(h.contacts()).toHaveLength(130);
|
||||
expect(h.state()!.accounts.a).toMatchObject({ reconcile: null });
|
||||
expect(h.state()!.accounts.a.selected.sort()).toEqual([`a/${h.book}`, `a/${extra}`].sort());
|
||||
});
|
||||
|
||||
it('loads the events of a calendar dropped by a stopped run when it is selected again', async () => {
|
||||
const h = real();
|
||||
const holidays = h.server.addCalendar('a', { name: 'Holidays' });
|
||||
const sports = h.server.addCalendar('a', { name: 'Sports' });
|
||||
for (const [calendar, title] of [[holidays, 'Easter'], [holidays, 'Christmas'], [sports, 'Match']] as const) {
|
||||
h.server.addEvent('a', { uid: `u-${title}`, title, start: '2026-10-01T09:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin', calendarIds: { [calendar]: true } });
|
||||
}
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
expect(h.events()).toHaveLength(3);
|
||||
// Both deselected; the run is cancelled once the first calendar's rows are gone.
|
||||
h.prefs.calendarSelection[`a/${holidays}`] = false;
|
||||
h.prefs.calendarSelection[`a/${sports}`] = false;
|
||||
const original = h.deps.isCancelled;
|
||||
let cancelled = false;
|
||||
h.deps.isCancelled = async (runId) => {
|
||||
const left = h.events().length;
|
||||
if (!cancelled && left > 0 && left < 3) cancelled = true;
|
||||
return cancelled || original(runId);
|
||||
};
|
||||
expect((await h.run(CALENDAR_AUTHORITY)).outcome).toBe('cancelled');
|
||||
h.deps.isCancelled = original;
|
||||
const titles = () => h.events().map((e) => String(e[Events.TITLE])).sort();
|
||||
const dropped = titles().includes('Match') ? holidays : sports;
|
||||
const expected = dropped === holidays ? ['Christmas', 'Easter'] : ['Match'];
|
||||
|
||||
h.prefs.calendarSelection[`a/${dropped}`] = true;
|
||||
expect((await h.run(CALENDAR_AUTHORITY)).outcome).toBe('ok');
|
||||
|
||||
expect(titles()).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
// The headless task entry: every run ends with finishRun and nothing
|
||||
// escapes it (docs/device-sync.md, "The Android shell").
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../native', () => ({
|
||||
createProviderPort: vi.fn(),
|
||||
finishRun: vi.fn(async () => true),
|
||||
getSyncSettings: vi.fn(async () => ({
|
||||
masterAutomatic: true,
|
||||
authorities: { 'com.android.contacts': { automatic: false, syncable: 1, periodicSeconds: 0, active: false, pending: false } },
|
||||
})),
|
||||
isRunCancelled: vi.fn(async () => false),
|
||||
showSyncProblem: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
import * as native from '../../native';
|
||||
import { emptyStats } from '../../engine/report';
|
||||
import { createTaskDeps, runDeviceSyncTask, teardownAuthority } from '../../task';
|
||||
import { useDeviceSyncStore } from '../../../stores/device-sync-store';
|
||||
import type { RunPayload } from '../../types';
|
||||
|
||||
const payload: RunPayload = {
|
||||
runId: 'r1',
|
||||
accountName: 'alice@example.com',
|
||||
registryId: 'alice@mail.example.com',
|
||||
authority: 'com.android.contacts',
|
||||
extras: {},
|
||||
deadline: Date.now() + 60_000,
|
||||
};
|
||||
|
||||
describe('device sync task', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(native.finishRun).mockClear();
|
||||
});
|
||||
|
||||
it('hands the report to the adapter before settling, and records the status', async () => {
|
||||
await runDeviceSyncTask(payload);
|
||||
|
||||
expect(native.finishRun).toHaveBeenCalledWith('r1', expect.objectContaining({ v: 1, runId: 'r1', outcome: 'disabled' }));
|
||||
expect(useDeviceSyncStore.getState().accounts['alice@mail.example.com']?.lastRun?.['com.android.contacts']).toMatchObject({
|
||||
outcome: 'disabled',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads back the status it records, for a run that did nothing to keep', async () => {
|
||||
const deps = createTaskDeps();
|
||||
const status = { at: 5, outcome: 'ok' as const, durationMs: 3, conflicts: 1, itemErrors: 2, stats: emptyStats() };
|
||||
|
||||
await deps.recordStatus('bob@mail.example.com', 'com.android.calendar', status);
|
||||
|
||||
await expect(deps.lastStatus('bob@mail.example.com', 'com.android.calendar')).resolves.toEqual(status);
|
||||
await expect(deps.lastStatus('bob@mail.example.com', 'com.android.contacts')).resolves.toBeUndefined();
|
||||
await expect(deps.lastStatus('nobody@mail.example.com', 'com.android.calendar')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('reports a failure instead of throwing, and survives a refused finishRun', async () => {
|
||||
vi.mocked(native.getSyncSettings).mockRejectedValueOnce(new Error('module gone'));
|
||||
vi.mocked(native.finishRun).mockRejectedValueOnce(new Error('bridge down'));
|
||||
|
||||
await expect(runDeviceSyncTask({ ...payload, runId: 'r2' })).resolves.toBeUndefined();
|
||||
|
||||
expect(native.finishRun).toHaveBeenCalledWith('r2', expect.objectContaining({ outcome: 'internal', message: 'module gone' }));
|
||||
});
|
||||
|
||||
it('ignores a payload without a run', async () => {
|
||||
await runDeviceSyncTask({} as RunPayload);
|
||||
expect(native.finishRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exports the teardown the app calls', () => {
|
||||
expect(teardownAuthority).toBeTypeOf('function');
|
||||
expect(teardownAuthority.length).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,656 @@
|
||||
/**
|
||||
* Small planners for the engine tests, faithful to the planner contract
|
||||
* (src/device-sync/planner.ts) for a toy mapping:
|
||||
*
|
||||
* - contacts: `name.full` ↔ one StructuredName row (DISPLAY_NAME), group
|
||||
* cards ↔ Groups rows (TITLE), memberships ↔ GroupMembership rows;
|
||||
* - calendar: `title` ↔ one event row (plus the columns the provider
|
||||
* requires), calendars ↔ Calendars rows.
|
||||
*
|
||||
* Like the real planners they assert what they read first (VERSION and
|
||||
* DIRTY, or the event projection), keep baselines in DATA_SYNC3 /
|
||||
* SYNC_DATA4, merge dirty items per unit (server wins a real conflict),
|
||||
* adopt pending creates by uid, claim new items before creating them, and
|
||||
* return `effect: 'none'` for echoes.
|
||||
*/
|
||||
import { Data, Events, GroupMembership, MimeType } from '../../android-columns';
|
||||
import { collectionKey, parseCollectionKey, parseObjectRef, pendingRef, pendingUidOf } from '../../common/ids';
|
||||
import { deepEqual, parseJsonColumn } from '../../common/json';
|
||||
import { ptr } from '../../common/patch';
|
||||
import type {
|
||||
AcceptedPlan,
|
||||
CalendarContext,
|
||||
CalendarEventWire,
|
||||
CalendarPlanner,
|
||||
ContactCardWire,
|
||||
ContactsContext,
|
||||
ContactsPlanner,
|
||||
DownloadPlan,
|
||||
LocalCalendar,
|
||||
LocalContact,
|
||||
LocalEvent,
|
||||
LocalGroup,
|
||||
OpGroup,
|
||||
UploadAction,
|
||||
UploadPlan,
|
||||
} from '../../planner';
|
||||
import type { ProviderOp, Row } from '../../types';
|
||||
|
||||
const num = (v: unknown) => Number(v ?? 0);
|
||||
const flag = (v: unknown) => Number(v ?? 0) === 1;
|
||||
const str = (v: unknown): string | null => (typeof v === 'string' && v ? v : null);
|
||||
const onIds = (map: Record<string, boolean> | undefined | null) =>
|
||||
Object.entries(map ?? {}).filter(([, on]) => on === true).map(([id]) => id);
|
||||
|
||||
interface ToyContactsContext extends ContactsContext {
|
||||
groupsOf?(uid: string): string[];
|
||||
}
|
||||
|
||||
// ─── Contacts ───────────────────────────────────────────
|
||||
|
||||
function nameOf(card: ContactCardWire | null | undefined): string {
|
||||
return card?.name?.full ?? '';
|
||||
}
|
||||
|
||||
function nameRow(local: LocalContact) {
|
||||
return local.rows.find((r) => r.mimetype === MimeType.STRUCTURED_NAME) ?? null;
|
||||
}
|
||||
|
||||
function membershipRows(local: LocalContact) {
|
||||
return local.rows.filter((r) => r.mimetype === MimeType.GROUP_MEMBERSHIP);
|
||||
}
|
||||
|
||||
function cardCollections(acct: string, card: ContactCardWire): string {
|
||||
return onIds(card.addressBookIds).map((id) => collectionKey(acct, id)).join(',');
|
||||
}
|
||||
|
||||
function shadowOf(card: ContactCardWire): string {
|
||||
return JSON.stringify(card);
|
||||
}
|
||||
|
||||
/** Group SOURCE_IDs a card should be a member of, with their row ids. */
|
||||
function wantedGroups(card: ContactCardWire, ctx: ToyContactsContext): Array<{ sourceId: string; rowId: number }> {
|
||||
if (typeof card.uid !== 'string' || !ctx.groupsOf) return [];
|
||||
return ctx
|
||||
.groupsOf(card.uid)
|
||||
.map((sourceId) => ({ sourceId, rowId: ctx.groupRowIdBySourceId(sourceId) }))
|
||||
.filter((g): g is { sourceId: string; rowId: number } => g.rowId !== null);
|
||||
}
|
||||
|
||||
function membershipOps(local: LocalContact | null, card: ContactCardWire, ctx: ToyContactsContext, parent: number | null): ProviderOp[] {
|
||||
const wanted = wantedGroups(card, ctx);
|
||||
const have = local ? membershipRows(local) : [];
|
||||
const ops: ProviderOp[] = [];
|
||||
for (const row of have) {
|
||||
if (!wanted.some((g) => g.sourceId === row.cells.group_sourceid)) ops.push({ op: 'delete', table: 'data', id: row.id, expectCount: 1 });
|
||||
}
|
||||
for (const group of wanted) {
|
||||
if (have.some((r) => r.cells.group_sourceid === group.sourceId)) continue;
|
||||
const values = { [Data.MIMETYPE]: MimeType.GROUP_MEMBERSHIP, [GroupMembership.GROUP_ROW_ID]: group.rowId };
|
||||
ops.push(
|
||||
parent === null
|
||||
? { op: 'insert', table: 'data', values, refs: { [Data.RAW_CONTACT_ID]: 0 } }
|
||||
: { op: 'insert', table: 'data', values: { ...values, [Data.RAW_CONTACT_ID]: parent } },
|
||||
);
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
export const toyContactsPlanner: ContactsPlanner = {
|
||||
rawContactColumns: ['_id', 'sourceid', 'version', 'dirty', 'deleted', 'sync1', 'sync2', 'sync3', 'sync4'],
|
||||
dataColumns: ['_id', 'raw_contact_id', 'mimetype', 'data1', 'data_sync1', 'data_sync3', 'group_sourceid'],
|
||||
groupColumns: ['_id', 'sourceid', 'version', 'dirty', 'deleted', 'title', 'sync2', 'sync3', 'sync4'],
|
||||
|
||||
decodeContact(rc: Row, data: Row[]): LocalContact {
|
||||
return {
|
||||
rawContactId: num(rc._id),
|
||||
sourceId: str(rc.sourceid),
|
||||
version: num(rc.version),
|
||||
dirty: flag(rc.dirty),
|
||||
deleted: flag(rc.deleted),
|
||||
collections: (str(rc.sync1) ?? '').split(',').filter(Boolean),
|
||||
shadow: parseJsonColumn<ContactCardWire>(rc.sync2),
|
||||
pending: parseJsonColumn(rc.sync3),
|
||||
poison: parseJsonColumn(rc.sync4),
|
||||
rows: data.map((d) => ({
|
||||
id: num(d._id),
|
||||
mimetype: String(d.mimetype),
|
||||
cells: { data1: d.data1 ?? null, group_sourceid: d.group_sourceid ?? null },
|
||||
key: str(d.data_sync1),
|
||||
photoHash: null,
|
||||
baseline: parseJsonColumn<Row>(d.data_sync3),
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
decodeGroup(row: Row): LocalGroup {
|
||||
return {
|
||||
groupId: num(row._id),
|
||||
sourceId: str(row.sourceid),
|
||||
version: num(row.version),
|
||||
dirty: flag(row.dirty),
|
||||
deleted: flag(row.deleted),
|
||||
title: str(row.title),
|
||||
shadow: parseJsonColumn<ContactCardWire>(row.sync2),
|
||||
pending: parseJsonColumn(row.sync3),
|
||||
poison: parseJsonColumn(row.sync4),
|
||||
};
|
||||
},
|
||||
|
||||
planDownload(card, local, context): DownloadPlan {
|
||||
const ctx = context as ToyContactsContext;
|
||||
const acct = ctx.jmapAccountId;
|
||||
const ref = `${acct}/${card.id}`;
|
||||
const name = nameOf(card);
|
||||
const raw = { sourceid: ref, sync1: cardCollections(acct, card), sync2: shadowOf(card), raw_contact_is_read_only: ctx.isReadOnly(card) ? 1 : 0 };
|
||||
if (!local) {
|
||||
const ops: ProviderOp[] = [
|
||||
{ op: 'insert', table: 'raw_contacts', values: raw },
|
||||
{
|
||||
op: 'insert',
|
||||
table: 'data',
|
||||
values: { [Data.MIMETYPE]: MimeType.STRUCTURED_NAME, data1: name, data_sync1: 'name', data_sync3: JSON.stringify({ data1: name }) },
|
||||
refs: { [Data.RAW_CONTACT_ID]: 0 },
|
||||
},
|
||||
...membershipOps(null, card, ctx, null),
|
||||
];
|
||||
return { ops: { ref, ops }, conflicts: 0, stillDirty: false, effect: 'insert', writes: ops.length };
|
||||
}
|
||||
const row = nameRow(local);
|
||||
const guard: ProviderOp = {
|
||||
op: 'assert',
|
||||
table: 'raw_contacts',
|
||||
id: local.rawContactId,
|
||||
values: { version: local.version, dirty: local.dirty ? 1 : 0 },
|
||||
expectCount: 1,
|
||||
};
|
||||
const ops: ProviderOp[] = [guard];
|
||||
let conflicts = 0;
|
||||
let stillDirty = false;
|
||||
const writeName = (value: string) => {
|
||||
if (row) {
|
||||
ops.push({ op: 'update', table: 'data', id: row.id, values: { data1: value, data_sync3: JSON.stringify({ data1: value }) }, expectCount: 1 });
|
||||
} else {
|
||||
ops.push({
|
||||
op: 'insert',
|
||||
table: 'data',
|
||||
values: { [Data.RAW_CONTACT_ID]: local.rawContactId, [Data.MIMETYPE]: MimeType.STRUCTURED_NAME, data1: value, data_sync1: 'name', data_sync3: JSON.stringify({ data1: value }) },
|
||||
});
|
||||
}
|
||||
};
|
||||
const adopting = !local.sourceId && local.pending?.uid === card.uid;
|
||||
if (local.deleted) {
|
||||
// The device delete wins over a server edit; the destroy is on its way.
|
||||
return { ops: { ref, ops: [guard] }, conflicts: 0, stillDirty: true, effect: 'none', writes: 0 };
|
||||
}
|
||||
const localValue = String(row?.cells.data1 ?? '');
|
||||
if (adopting) {
|
||||
// Our own create: identity and shadow; the rows stay the user's, measured against the server's value.
|
||||
ops.push({ op: 'update', table: 'raw_contacts', id: local.rawContactId, values: { ...raw, sync3: null } });
|
||||
if (row) ops.push({ op: 'update', table: 'data', id: row.id, values: { data_sync3: JSON.stringify({ data1: name }) }, expectCount: 1 });
|
||||
return { ops: { ref, ops }, conflicts: 0, stillDirty: localValue !== name, effect: 'update', writes: ops.length - 1 };
|
||||
}
|
||||
const shadowName = nameOf(local.shadow);
|
||||
const baseValue = String(row?.baseline?.data1 ?? shadowName);
|
||||
const localChanged = local.dirty && localValue !== baseValue;
|
||||
const remoteChanged = name !== shadowName;
|
||||
if (!localChanged) {
|
||||
if (localValue !== name) writeName(name);
|
||||
} else if (remoteChanged) {
|
||||
if (localValue === name) writeName(name);
|
||||
else {
|
||||
writeName(name);
|
||||
conflicts++;
|
||||
}
|
||||
} else {
|
||||
stillDirty = true;
|
||||
}
|
||||
ops.push(...membershipOps(local, card, ctx, local.rawContactId));
|
||||
const shadowSame = deepEqual(local.shadow, card) && local.collections.join(',') === raw.sync1;
|
||||
if (!shadowSame) ops.push({ op: 'update', table: 'raw_contacts', id: local.rawContactId, values: raw });
|
||||
const writes = ops.length - 1;
|
||||
return { ops: { ref, ops }, conflicts, stillDirty, effect: writes > 0 ? 'update' : 'none', writes };
|
||||
},
|
||||
|
||||
planLocalDelete(local): OpGroup {
|
||||
return { ref: local.sourceId ?? `row:${local.rawContactId}`, ops: [{ op: 'delete', table: 'raw_contacts', id: local.rawContactId }] };
|
||||
},
|
||||
|
||||
planBaselineHeal(local): OpGroup | null {
|
||||
const row = nameRow(local);
|
||||
if (local.dirty || !row || deepEqual(row.baseline, { data1: row.cells.data1 })) return null;
|
||||
return {
|
||||
ref: local.sourceId ?? `row:${local.rawContactId}`,
|
||||
ops: [
|
||||
{ op: 'assert', table: 'raw_contacts', id: local.rawContactId, values: { version: local.version, dirty: 0 }, expectCount: 1 },
|
||||
{ op: 'update', table: 'data', id: row.id, values: { data_sync3: JSON.stringify({ data1: row.cells.data1 }) }, expectCount: 1 },
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
planUpload(local, ctx): UploadPlan<ContactCardWire> {
|
||||
const ref = local.sourceId ?? `row:${local.rawContactId}`;
|
||||
const guard: ProviderOp = { op: 'assert', table: 'raw_contacts', id: local.rawContactId, values: { version: local.version }, expectCount: 1 };
|
||||
const parsed = parseObjectRef(local.sourceId);
|
||||
if (local.deleted) {
|
||||
if (parsed) {
|
||||
const books = onIds(local.shadow?.addressBookIds);
|
||||
const synced = books.filter((b) => ctx.isSelected(collectionKey(parsed.accountId, b)));
|
||||
if (synced.length && synced.length < books.length) {
|
||||
return { kind: 'upload', actions: [{ kind: 'update', id: parsed.id, patch: Object.fromEntries(synced.map((b) => [`addressBookIds/${b}`, null])) }] };
|
||||
}
|
||||
return { kind: 'upload', actions: [{ kind: 'destroy', id: parsed.id, uid: null }] };
|
||||
}
|
||||
if (local.pending) return { kind: 'upload', actions: [{ kind: 'destroy', id: null, uid: local.pending.uid }] };
|
||||
return { kind: 'purge', ops: { ref, ops: [{ op: 'delete', table: 'raw_contacts', id: local.rawContactId }] } };
|
||||
}
|
||||
const row = nameRow(local);
|
||||
const value = String(row?.cells.data1 ?? '');
|
||||
if (!parsed) {
|
||||
if (!local.pending) {
|
||||
const target = ctx.createTarget();
|
||||
if (!target) return { kind: 'skip', reason: 'noWritableAddressBook' };
|
||||
return {
|
||||
kind: 'claim',
|
||||
ops: { ref, ops: [guard, { op: 'update', table: 'raw_contacts', id: local.rawContactId, values: { sync3: JSON.stringify({ uid: ctx.mintUid(), target }) } }] },
|
||||
};
|
||||
}
|
||||
const target = parseCollectionKey(local.pending.target);
|
||||
if (!target) return { kind: 'skip', reason: 'badTarget' };
|
||||
return {
|
||||
kind: 'upload',
|
||||
actions: [{ kind: 'create', uid: local.pending.uid, collectionId: target.id, object: { uid: local.pending.uid, name: { full: value } } }],
|
||||
};
|
||||
}
|
||||
if (local.shadow && ctx.isReadOnly(local.shadow)) {
|
||||
const shadowName = nameOf(local.shadow);
|
||||
return {
|
||||
kind: 'revert',
|
||||
ops: {
|
||||
ref,
|
||||
ops: [
|
||||
guard,
|
||||
...(row ? [{ op: 'update' as const, table: 'data' as const, id: row.id, values: { data1: shadowName, data_sync3: JSON.stringify({ data1: shadowName }) } }] : []),
|
||||
{ op: 'update', table: 'raw_contacts', id: local.rawContactId, values: { dirty: 0 } },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
const base = String(row?.baseline?.data1 ?? nameOf(local.shadow));
|
||||
if (value === base) {
|
||||
return { kind: 'clean', ops: { ref, ops: [{ ...guard, values: { version: local.version, dirty: 1 } }, { op: 'update', table: 'raw_contacts', id: local.rawContactId, values: { dirty: 0 } }] } };
|
||||
}
|
||||
const patch = local.shadow?.name ? { 'name/full': value } : { name: { full: value } };
|
||||
return { kind: 'upload', actions: [{ kind: 'update', id: parsed.id, patch }] };
|
||||
},
|
||||
|
||||
planAccepted(local, server, ctx): AcceptedPlan {
|
||||
const acct = ctx.jmapAccountId;
|
||||
const ref = `${acct}/${server.id}`;
|
||||
const row = nameRow(local);
|
||||
const identity = { sourceid: ref, sync1: cardCollections(acct, server), sync2: shadowOf(server), sync3: null };
|
||||
const baseline: ProviderOp[] = row
|
||||
? [{ op: 'update', table: 'data', id: row.id, values: { data_sync3: JSON.stringify({ data1: row.cells.data1 }) }, expectCount: 1 }]
|
||||
: [];
|
||||
return {
|
||||
ops: {
|
||||
ref,
|
||||
ops: [
|
||||
{ op: 'assert', table: 'raw_contacts', id: local.rawContactId, values: { version: local.version }, expectCount: 1 },
|
||||
{ op: 'update', table: 'raw_contacts', id: local.rawContactId, values: { ...identity, dirty: 0 } },
|
||||
...baseline,
|
||||
],
|
||||
},
|
||||
keepDirtyOps: { ref, ops: [{ op: 'update', table: 'raw_contacts', id: local.rawContactId, values: identity }, ...baseline] },
|
||||
};
|
||||
},
|
||||
|
||||
planGroupDownload(card, local, ctx): DownloadPlan {
|
||||
const acct = ctx.jmapAccountId;
|
||||
const ref = `${acct}/${card.id}`;
|
||||
const values = { sourceid: ref, title: nameOf(card), group_visible: 1, sync2: shadowOf(card) };
|
||||
if (!local) {
|
||||
return { ops: { ref, ops: [{ op: 'insert', table: 'groups', values }] }, conflicts: 0, stillDirty: false, effect: 'insert', writes: 1 };
|
||||
}
|
||||
if (local.deleted) return { ops: { ref, ops: [] }, conflicts: 0, stillDirty: true, effect: 'none', writes: 0 };
|
||||
const adopting = !local.sourceId && local.pending?.uid === card.uid;
|
||||
if (!adopting && local.title === values.title && deepEqual(local.shadow, card)) {
|
||||
return { ops: { ref, ops: [] }, conflicts: 0, stillDirty: local.dirty, effect: 'none', writes: 0 };
|
||||
}
|
||||
const localChanged = local.dirty && local.title !== nameOf(local.shadow);
|
||||
const keepTitle = adopting || (localChanged && values.title === nameOf(local.shadow));
|
||||
return {
|
||||
ops: {
|
||||
ref,
|
||||
ops: [
|
||||
{ op: 'assert', table: 'groups', id: local.groupId, values: { version: local.version }, expectCount: 1 },
|
||||
{ op: 'update', table: 'groups', id: local.groupId, values: { ...values, ...(keepTitle ? { title: local.title } : {}), sync3: null } },
|
||||
],
|
||||
},
|
||||
conflicts: localChanged && !keepTitle && values.title !== local.title ? 1 : 0,
|
||||
stillDirty: keepTitle && local.dirty,
|
||||
effect: 'update',
|
||||
writes: 1,
|
||||
};
|
||||
},
|
||||
|
||||
planGroupLocalDelete(local): OpGroup {
|
||||
return { ref: local.sourceId ?? `group:${local.groupId}`, ops: [{ op: 'delete', table: 'groups', id: local.groupId }] };
|
||||
},
|
||||
|
||||
planGroupUpload(local, ctx): UploadPlan<ContactCardWire> {
|
||||
const ref = local.sourceId ?? `group:${local.groupId}`;
|
||||
const parsed = parseObjectRef(local.sourceId);
|
||||
if (local.deleted) {
|
||||
if (parsed) return { kind: 'upload', actions: [{ kind: 'destroy', id: parsed.id, uid: null }] };
|
||||
return { kind: 'purge', ops: { ref, ops: [{ op: 'delete', table: 'groups', id: local.groupId }] } };
|
||||
}
|
||||
if (!parsed) {
|
||||
if (!local.pending) {
|
||||
const target = ctx.createTarget();
|
||||
if (!target) return { kind: 'skip', reason: 'noWritableAddressBook' };
|
||||
return {
|
||||
kind: 'claim',
|
||||
ops: {
|
||||
ref,
|
||||
ops: [
|
||||
{ op: 'assert', table: 'groups', id: local.groupId, values: { version: local.version }, expectCount: 1 },
|
||||
{ op: 'update', table: 'groups', id: local.groupId, values: { sync3: JSON.stringify({ uid: ctx.mintUid(), target }) } },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
const target = parseCollectionKey(local.pending.target);
|
||||
if (!target) return { kind: 'skip', reason: 'badTarget' };
|
||||
return {
|
||||
kind: 'upload',
|
||||
actions: [{ kind: 'create', uid: local.pending.uid, collectionId: target.id, object: { uid: local.pending.uid, kind: 'group', name: { full: local.title ?? '' } } }],
|
||||
};
|
||||
}
|
||||
if ((local.title ?? '') === nameOf(local.shadow)) {
|
||||
return {
|
||||
kind: 'clean',
|
||||
ops: { ref, ops: [{ op: 'assert', table: 'groups', id: local.groupId, values: { version: local.version }, expectCount: 1 }, { op: 'update', table: 'groups', id: local.groupId, values: { dirty: 0 } }] },
|
||||
};
|
||||
}
|
||||
return { kind: 'upload', actions: [{ kind: 'update', id: parsed.id, patch: { 'name/full': local.title ?? '' } }] };
|
||||
},
|
||||
|
||||
planGroupAccepted(local, server, ctx): AcceptedPlan {
|
||||
const ref = `${ctx.jmapAccountId}/${server.id}`;
|
||||
const values = { sourceid: ref, sync2: shadowOf(server), sync3: null };
|
||||
return {
|
||||
ops: {
|
||||
ref,
|
||||
ops: [
|
||||
{ op: 'assert', table: 'groups', id: local.groupId, values: { version: local.version }, expectCount: 1 },
|
||||
{ op: 'update', table: 'groups', id: local.groupId, values: { ...values, dirty: 0 } },
|
||||
],
|
||||
},
|
||||
keepDirtyOps: { ref, ops: [{ op: 'update', table: 'groups', id: local.groupId, values }] },
|
||||
};
|
||||
},
|
||||
|
||||
planMembershipUploads(contacts, groups, ctx): UploadAction<ContactCardWire>[] {
|
||||
const acct = ctx.jmapAccountId;
|
||||
const patches = new Map<string, Record<string, unknown>>();
|
||||
for (const contact of contacts) {
|
||||
const uid = contact.shadow?.uid ?? contact.pending?.uid;
|
||||
if (!contact.sourceId || typeof uid !== 'string') continue;
|
||||
const wanted = new Set(membershipRows(contact).map((r) => String(r.cells.group_sourceid)));
|
||||
for (const group of groups) {
|
||||
const parsed = parseObjectRef(group.sourceId);
|
||||
if (!parsed || parsed.accountId !== acct) continue;
|
||||
const member = group.shadow?.members?.[uid] === true;
|
||||
const want = wanted.has(group.sourceId as string);
|
||||
if (member === want) continue;
|
||||
const patch = patches.get(parsed.id) ?? {};
|
||||
patch[ptr('members', uid)] = want ? true : null;
|
||||
patches.set(parsed.id, patch);
|
||||
}
|
||||
}
|
||||
return [...patches].map(([id, patch]) => ({ kind: 'update', id, patch }));
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Calendar ───────────────────────────────────────────
|
||||
|
||||
const EVENT_HOUR = 3_600_000;
|
||||
|
||||
function startMs(event: CalendarEventWire): number {
|
||||
return Date.parse(`${String(event.start ?? '2026-01-01T00:00:00').slice(0, 19)}Z`);
|
||||
}
|
||||
|
||||
function localStart(ms: number): string {
|
||||
return new Date(ms).toISOString().slice(0, 19);
|
||||
}
|
||||
|
||||
function eventRef(ctx: CalendarContext, event: CalendarEventWire): string {
|
||||
return `${ctx.jmapAccountId}/${event.id}`;
|
||||
}
|
||||
|
||||
export const toyCalendarPlanner: CalendarPlanner = {
|
||||
calendarColumns: ['_id', '_sync_id', 'name', 'calendar_displayName', 'calendar_access_level', 'maxReminders', 'cal_sync2', 'cal_sync3'],
|
||||
eventColumns: [
|
||||
'_id', 'calendar_id', '_sync_id', 'dirty', 'deleted', 'title', 'dtstart', 'dtend', 'eventTimezone', 'original_id',
|
||||
'original_sync_id', 'originalInstanceTime', 'sync_data1', 'sync_data2', 'sync_data3', 'sync_data4', 'sync_data5', 'uid2445', 'mutators',
|
||||
],
|
||||
attendeeColumns: ['_id', 'event_id', 'attendeeEmail'],
|
||||
reminderColumns: ['_id', 'event_id', 'minutes'],
|
||||
|
||||
decodeCalendar(row: Row): LocalCalendar {
|
||||
return {
|
||||
calendarRowId: num(row._id),
|
||||
syncId: str(row._sync_id),
|
||||
cells: row,
|
||||
shadow: parseJsonColumn(row.cal_sync2),
|
||||
flags: parseJsonColumn(row.cal_sync3),
|
||||
};
|
||||
},
|
||||
|
||||
decodeEvents(events: Row[], attendees: Row[], reminders: Row[]): LocalEvent[] {
|
||||
const toRow = (row: Row) => ({
|
||||
eventId: num(row._id),
|
||||
calendarRowId: num(row.calendar_id),
|
||||
syncId: str(row._sync_id),
|
||||
dirty: flag(row.dirty),
|
||||
deleted: flag(row.deleted),
|
||||
cells: { title: row.title ?? null, dtstart: row.dtstart ?? null, dtend: row.dtend ?? null },
|
||||
attendees: attendees.filter((a) => num(a.event_id) === num(row._id)).map((a) => ({ id: num(a._id), cells: { attendeeEmail: a.attendeeEmail ?? null } })),
|
||||
reminders: reminders.filter((r) => num(r.event_id) === num(row._id)).map((r) => ({ id: num(r._id), cells: { minutes: r.minutes ?? null } })),
|
||||
baseline: parseJsonColumn<{ cells: Row; attendees: Row[]; reminders: Row[] }>(row.sync_data4),
|
||||
pending: parseJsonColumn(row.sync_data3),
|
||||
poison: parseJsonColumn(row.sync_data5),
|
||||
mutators: str(row.mutators),
|
||||
});
|
||||
const isException = (row: Row) => row.original_id !== null && row.original_id !== undefined || !!str(row.original_sync_id);
|
||||
const masters = events.filter((r) => !isException(r)).map((row) => ({
|
||||
...toRow(row),
|
||||
shadow: parseJsonColumn<CalendarEventWire>(row.sync_data1),
|
||||
exceptions: events
|
||||
.filter((x) => isException(x) && (num(x.original_id) === num(row._id) || (str(x.original_sync_id) !== null && str(x.original_sync_id) === str(row._sync_id))))
|
||||
.map((x) => ({ ...toRow(x), recurrenceId: str(x.sync_data2), originalInstanceTime: x.originalInstanceTime === null ? null : num(x.originalInstanceTime) })),
|
||||
})) as LocalEvent[];
|
||||
const bySyncId = new Map<string, LocalEvent[]>();
|
||||
for (const m of masters) if (m.syncId) bySyncId.set(m.syncId, [...(bySyncId.get(m.syncId) ?? []), m]);
|
||||
for (const pair of bySyncId.values()) {
|
||||
if (pair.length < 2) continue;
|
||||
pair.sort((a, b) => a.eventId - b.eventId);
|
||||
pair[0].split = 'source';
|
||||
for (const clone of pair.slice(1)) clone.split = 'clone';
|
||||
}
|
||||
return masters;
|
||||
},
|
||||
|
||||
planCalendars(selected, local, ctx) {
|
||||
const groups: OpGroup[] = [];
|
||||
const keys = new Set<string>();
|
||||
for (const { jmapAccountId, calendar, readOnly, accountName } of selected) {
|
||||
const key = collectionKey(jmapAccountId, calendar.id);
|
||||
keys.add(key);
|
||||
const values = {
|
||||
name: calendar.name,
|
||||
calendar_displayName: accountName ? `${calendar.name} (${accountName})` : calendar.name,
|
||||
calendar_access_level: readOnly ? 200 : 700,
|
||||
maxReminders: ctx.reminderOwner === 'device' ? 5 : 0,
|
||||
cal_sync2: JSON.stringify(calendar),
|
||||
cal_sync3: JSON.stringify(readOnly ? { readOnly: 'rights' } : {}),
|
||||
};
|
||||
const existing = local.find((l) => l.syncId === key);
|
||||
if (!existing) {
|
||||
groups.push({
|
||||
ref: key,
|
||||
ops: [{ op: 'insert', table: 'calendars', values: { ...values, _sync_id: key, calendar_color: -16776961, ownerAccount: ctx.ownerAccount, sync_events: 1, visible: 1 } }],
|
||||
});
|
||||
} else if (Object.entries(values).some(([k, v]) => String(existing.cells[k] ?? '') !== String(v))) {
|
||||
groups.push({ ref: key, ops: [{ op: 'update', table: 'calendars', id: existing.calendarRowId, values }] });
|
||||
}
|
||||
}
|
||||
return { groups, deleteCalendarRows: local.filter((l) => !l.syncId || !keys.has(l.syncId)).map((l) => l.calendarRowId) };
|
||||
},
|
||||
|
||||
planDownload(event, local, ctx): DownloadPlan {
|
||||
const ref = eventRef(ctx, event);
|
||||
const calendarRowId = onIds(event.calendarIds).map((id) => ctx.calendarRowId(id)).find((id) => id !== null) ?? null;
|
||||
const start = startMs(event);
|
||||
const cells = { title: event.title ?? '', dtstart: start, dtend: start + EVENT_HOUR };
|
||||
const baseline = JSON.stringify({ cells: { title: cells.title }, attendees: [], reminders: [] });
|
||||
if (!local) {
|
||||
if (calendarRowId === null) return { ops: { ref, ops: [] }, conflicts: 0, stillDirty: false, effect: 'none', writes: 0 };
|
||||
const ops: ProviderOp[] = [{
|
||||
op: 'insert',
|
||||
table: 'events',
|
||||
// CalendarProvider leaves DIRTY NULL on a sync-adapter insert unless it is written.
|
||||
values: { calendar_id: calendarRowId, _sync_id: ref, ...cells, eventTimezone: 'UTC', eventStatus: 1, dirty: 0, uid2445: event.uid, sync_data1: JSON.stringify(event), sync_data4: baseline },
|
||||
}];
|
||||
return { ops: { ref, ops }, conflicts: 0, stillDirty: false, effect: 'insert', writes: 1 };
|
||||
}
|
||||
const guard: ProviderOp = { op: 'assert', table: 'events', id: local.eventId, values: { dirty: local.dirty ? 1 : 0, title: local.cells.title }, expectCount: 1 };
|
||||
if (local.deleted) return { ops: { ref, ops: [guard] }, conflicts: 0, stillDirty: true, effect: 'none', writes: 0 };
|
||||
const adopting = !parseObjectRef(local.syncId) && (local.pending?.uid ?? pendingUidOf(local.syncId)) === event.uid;
|
||||
if (adopting) {
|
||||
return {
|
||||
ops: { ref, ops: [guard, { op: 'update', table: 'events', id: local.eventId, values: { _sync_id: ref, sync_data1: JSON.stringify(event), sync_data3: null, sync_data4: baseline } }] },
|
||||
conflicts: 0,
|
||||
stillDirty: local.cells.title !== event.title,
|
||||
effect: 'update',
|
||||
writes: 1,
|
||||
};
|
||||
}
|
||||
const shadowTitle = local.shadow?.title ?? '';
|
||||
const baseTitle = String(local.baseline?.cells.title ?? shadowTitle);
|
||||
const localChanged = local.dirty && local.cells.title !== baseTitle;
|
||||
const remoteChanged = (event.title ?? '') !== shadowTitle;
|
||||
const values: Row = {};
|
||||
let conflicts = 0;
|
||||
if (!localChanged || remoteChanged) {
|
||||
if (local.cells.title !== cells.title || local.cells.dtstart !== cells.dtstart) Object.assign(values, cells, { sync_data4: baseline });
|
||||
if (localChanged && local.cells.title !== cells.title) conflicts = 1;
|
||||
}
|
||||
if (!deepEqual(local.shadow, event)) values.sync_data1 = JSON.stringify(event);
|
||||
if (!Object.keys(values).length) return { ops: { ref, ops: [guard] }, conflicts: 0, stillDirty: localChanged && !remoteChanged, effect: 'none', writes: 0 };
|
||||
return {
|
||||
ops: { ref, ops: [guard, { op: 'update', table: 'events', id: local.eventId, values, expectCount: 1 }] },
|
||||
conflicts,
|
||||
stillDirty: localChanged && !remoteChanged,
|
||||
effect: 'update',
|
||||
writes: 1,
|
||||
};
|
||||
},
|
||||
|
||||
planLocalDelete(local): OpGroup {
|
||||
return {
|
||||
ref: local.syncId ?? `row:${local.eventId}`,
|
||||
ops: [
|
||||
...local.exceptions.map((x) => ({ op: 'delete' as const, table: 'events' as const, id: x.eventId })),
|
||||
{ op: 'delete', table: 'events', id: local.eventId },
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
planBaselineHeal() {
|
||||
return null;
|
||||
},
|
||||
|
||||
planUpload(local, ctx): UploadPlan<CalendarEventWire> {
|
||||
const ref = local.syncId ?? `row:${local.eventId}`;
|
||||
const identity = local.split === 'clone' ? null : parseObjectRef(local.syncId);
|
||||
const pendingUid = local.pending?.uid ?? pendingUidOf(local.syncId);
|
||||
if (local.deleted) {
|
||||
if (identity) return { kind: 'upload', actions: [{ kind: 'destroy', id: identity.id, uid: null }] };
|
||||
if (pendingUid) return { kind: 'upload', actions: [{ kind: 'destroy', id: null, uid: pendingUid }] };
|
||||
return { kind: 'purge', ops: { ref, ops: [{ op: 'delete', table: 'events', id: local.eventId }] } };
|
||||
}
|
||||
const guard: ProviderOp = { op: 'assert', table: 'events', id: local.eventId, values: { title: local.cells.title }, expectCount: 1 };
|
||||
if (!identity) {
|
||||
const calendar = ctx.calendarIdOfRow(local.calendarRowId);
|
||||
if (!calendar) return { kind: 'skip', reason: 'notOurCalendar' };
|
||||
if (!pendingUid || local.split === 'clone') {
|
||||
const uid = ctx.mintUid();
|
||||
return {
|
||||
kind: 'claim',
|
||||
ops: {
|
||||
ref,
|
||||
ops: [guard, {
|
||||
op: 'update',
|
||||
table: 'events',
|
||||
id: local.eventId,
|
||||
values: { _sync_id: pendingRef(uid), uid2445: uid, sync_data3: JSON.stringify({ uid, target: collectionKey(calendar.jmapAccountId, calendar.calendarId) }) },
|
||||
}],
|
||||
},
|
||||
};
|
||||
}
|
||||
const start = Number(local.cells.dtstart ?? 0);
|
||||
return {
|
||||
kind: 'upload',
|
||||
actions: [{
|
||||
kind: 'create',
|
||||
uid: pendingUid,
|
||||
collectionId: calendar.calendarId,
|
||||
object: { uid: pendingUid, title: String(local.cells.title ?? ''), start: localStart(start), duration: 'PT1H', timeZone: 'Etc/UTC' },
|
||||
}],
|
||||
};
|
||||
}
|
||||
if (ctx.isReadOnly(onIds(local.shadow?.calendarIds)[0] ?? '')) {
|
||||
return { kind: 'revert', ops: { ref, ops: [guard, { op: 'update', table: 'events', id: local.eventId, values: { title: local.shadow?.title ?? '', dirty: 0 } }] } };
|
||||
}
|
||||
const base = String(local.baseline?.cells.title ?? local.shadow?.title ?? '');
|
||||
if (local.cells.title === base) {
|
||||
return { kind: 'clean', ops: { ref, ops: [guard, { op: 'update', table: 'events', id: local.eventId, values: { dirty: 0 } }] } };
|
||||
}
|
||||
return { kind: 'upload', actions: [{ kind: 'update', id: identity.id, patch: { title: String(local.cells.title ?? '') } }] };
|
||||
},
|
||||
|
||||
planAccepted(local, server, ctx): AcceptedPlan {
|
||||
const ref = eventRef(ctx, server);
|
||||
const values = {
|
||||
_sync_id: ref,
|
||||
sync_data1: JSON.stringify(server),
|
||||
sync_data3: null,
|
||||
sync_data4: JSON.stringify({ cells: { title: local.cells.title }, attendees: [], reminders: [] }),
|
||||
};
|
||||
return {
|
||||
ops: {
|
||||
ref,
|
||||
ops: [
|
||||
{ op: 'assert', table: 'events', id: local.eventId, values: { title: local.cells.title }, expectCount: 1 },
|
||||
{ op: 'update', table: 'events', id: local.eventId, values: { ...values, dirty: 0 } },
|
||||
],
|
||||
},
|
||||
keepDirtyOps: { ref, ops: [{ op: 'update', table: 'events', id: local.eventId, values }] },
|
||||
};
|
||||
},
|
||||
|
||||
planPairs() {
|
||||
return [];
|
||||
},
|
||||
planZoneChange() {
|
||||
return null;
|
||||
},
|
||||
planReminderOwnerChange() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
/** An Events column list the fake accepts in `where`, for tests that read rows. */
|
||||
export const EVENT_TITLE = Events.TITLE;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,518 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
Attendees,
|
||||
Calendars,
|
||||
Data,
|
||||
Events,
|
||||
MimeType,
|
||||
RawContacts,
|
||||
StructuredName,
|
||||
StructuredPostal,
|
||||
} from '../android-columns';
|
||||
import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY, type ProviderOp } from '../types';
|
||||
import { JPEG } from './contacts/fixtures';
|
||||
import { FakeDeviceProviders, aospDurationValid, aospRruleValid } from './fakes/fake-provider';
|
||||
import { compileWhere } from './fakes/fake-sql';
|
||||
|
||||
const ME = 'usera@example.org';
|
||||
const OTHER = 'someone@example.org';
|
||||
|
||||
async function contactWithEmail(fake: FakeDeviceProviders, account = ME) {
|
||||
const port = fake.port(account, CONTACTS_AUTHORITY);
|
||||
const res = await port.applyBatch([
|
||||
{ op: 'insert', table: 'raw_contacts', values: { [RawContacts.SOURCE_ID]: 'c/e1' } },
|
||||
{
|
||||
op: 'insert',
|
||||
table: 'data',
|
||||
values: { [Data.MIMETYPE]: MimeType.EMAIL, [Data.DATA1]: 'a@example.org', [Data.DATA_SYNC1]: 'emails:e1' },
|
||||
refs: { [Data.RAW_CONTACT_ID]: 0 },
|
||||
},
|
||||
]);
|
||||
if (!res.ok) throw new Error(res.message);
|
||||
return { port, rawId: res.results[0].id!, dataId: res.results[1].id! };
|
||||
}
|
||||
|
||||
describe('fake-sql', () => {
|
||||
const row = (r: Record<string, string | number | null>) => (c: string) => r[c] ?? null;
|
||||
|
||||
it('evaluates the documented subset with SQL precedence and NULL rules', () => {
|
||||
const where = compileWhere('dirty = 1 OR deleted = 1 AND sourceid IS NOT NULL', []);
|
||||
expect(where(row({ dirty: 0, deleted: 1, sourceid: null }))).toBe(false);
|
||||
expect(where(row({ dirty: 1, deleted: 0, sourceid: null }))).toBe(true);
|
||||
expect(compileWhere('x IN (?, ?, 3)', ['1', 2])(row({ x: 3 }))).toBe(true);
|
||||
expect(compileWhere('x NOT IN (?)', ['1'])(row({ x: null }))).toBe(false);
|
||||
expect(compileWhere("mimetype LIKE '%/email%'")(row({ mimetype: 'vnd.android.cursor.item/email_v2' }))).toBe(true);
|
||||
expect(compileWhere('version = ?', ['2'])(row({ version: 2 }))).toBe(true);
|
||||
expect(compileWhere('NOT (a = 1)')(row({ a: null }))).toBe(true);
|
||||
expect(() => compileWhere('a = ?', [])).toThrow(/placeholders/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FakeDeviceProviders: contacts', () => {
|
||||
it('scopes every read and write to the port account', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const mine = await contactWithEmail(fake, ME);
|
||||
const theirs = await contactWithEmail(fake, OTHER);
|
||||
const rows = await mine.port.query({ table: 'raw_contacts', columns: [RawContacts._ID] });
|
||||
expect(rows.rows).toEqual([[mine.rawId]]);
|
||||
const data = await mine.port.query({ table: 'data', columns: [Data._ID] });
|
||||
expect(data.rows).toEqual([[mine.dataId]]);
|
||||
const denied = await mine.port.applyBatch([{ op: 'update', table: 'raw_contacts', id: theirs.rawId, values: { [RawContacts.SYNC1]: 'x' } }]);
|
||||
expect(denied).toMatchObject({ ok: false, reason: 'scope' });
|
||||
const orphan = await mine.port.applyBatch([
|
||||
{ op: 'insert', table: 'data', values: { [Data.RAW_CONTACT_ID]: theirs.rawId, [Data.MIMETYPE]: MimeType.NOTE } },
|
||||
]);
|
||||
expect(orphan).toMatchObject({ ok: false, reason: 'scope' });
|
||||
await expect(mine.port.query({ table: 'events', columns: ['_id'] })).rejects.toMatchObject({ code: 'scope' });
|
||||
});
|
||||
|
||||
it('never sets DIRTY for the sync adapter but does for app edits, and only apps soft-delete', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const { rawId, dataId } = await contactWithEmail(fake);
|
||||
expect(fake.row('raw_contacts', rawId)![RawContacts.DIRTY]).toBe(0);
|
||||
fake.user.updateData(dataId, { [Data.DATA1]: 'b@example.org' });
|
||||
expect(fake.row('raw_contacts', rawId)![RawContacts.DIRTY]).toBe(1);
|
||||
expect(fake.row('data', dataId)![Data.DATA_SYNC1]).toBe('emails:e1');
|
||||
fake.user.deleteContact(rawId);
|
||||
expect(fake.row('raw_contacts', rawId)).toMatchObject({ [RawContacts.DELETED]: 1, [RawContacts.DIRTY]: 1 });
|
||||
const port = fake.port(ME, CONTACTS_AUTHORITY);
|
||||
await port.applyBatch([{ op: 'delete', table: 'raw_contacts', id: rawId }]);
|
||||
expect(fake.row('raw_contacts', rawId)).toBeUndefined();
|
||||
expect(fake.row('data', dataId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('bumps VERSION for data writes by anyone but not for DIRTY, SOURCE_ID or SYNC columns', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const { port, rawId, dataId } = await contactWithEmail(fake);
|
||||
const version = () => fake.row('raw_contacts', rawId)![RawContacts.VERSION];
|
||||
expect(version()).toBe(2); // one bump for the batch's data inserts
|
||||
await port.applyBatch([
|
||||
{ op: 'update', table: 'raw_contacts', id: rawId, values: { [RawContacts.DIRTY]: 0, [RawContacts.SYNC2]: '{}', [RawContacts.SOURCE_ID]: 'c/e2' } },
|
||||
]);
|
||||
expect(version()).toBe(2);
|
||||
await port.applyBatch([{ op: 'update', table: 'data', id: dataId, values: { [Data.DATA_SYNC3]: '{}' } }]);
|
||||
expect(version()).toBe(3);
|
||||
fake.user.star(rawId);
|
||||
expect(version()).toBe(3);
|
||||
expect(fake.row('raw_contacts', rawId)![RawContacts.DIRTY]).toBe(1);
|
||||
});
|
||||
|
||||
it('applies a batch atomically and fails it on a stale VERSION assert', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const { port, rawId, dataId } = await contactWithEmail(fake);
|
||||
fake.user.updateData(dataId, { [Data.DATA1]: 'edited@example.org' });
|
||||
const clear: ProviderOp[] = [
|
||||
{ op: 'assert', table: 'raw_contacts', id: rawId, values: { [RawContacts.VERSION]: 2 } },
|
||||
{ op: 'update', table: 'raw_contacts', id: rawId, values: { [RawContacts.DIRTY]: 0 } },
|
||||
];
|
||||
expect(await port.applyBatch(clear)).toMatchObject({ ok: false, reason: 'assert' });
|
||||
expect(fake.row('raw_contacts', rawId)![RawContacts.DIRTY]).toBe(1);
|
||||
const current = fake.row('raw_contacts', rawId)![RawContacts.VERSION] as number;
|
||||
const ok = await port.applyBatch([
|
||||
{ op: 'assert', table: 'raw_contacts', id: rawId, values: { [RawContacts.VERSION]: current } },
|
||||
{ op: 'update', table: 'raw_contacts', id: rawId, values: { [RawContacts.DIRTY]: 0 } },
|
||||
{ op: 'syncState', value: '{"v":1}' },
|
||||
]);
|
||||
expect(ok.ok).toBe(true);
|
||||
expect(fake.readSyncState(ME, CONTACTS_AUTHORITY)).toBe('{"v":1}');
|
||||
});
|
||||
|
||||
it('runs a hook right before the next batch, for concurrent-edit tests', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const { port, rawId, dataId } = await contactWithEmail(fake);
|
||||
const v = fake.row('raw_contacts', rawId)![RawContacts.VERSION] as number;
|
||||
fake.beforeNextBatch(() => fake.user.updateData(dataId, { [Data.DATA1]: 'late@example.org' }));
|
||||
const res = await port.applyBatch([
|
||||
{ op: 'assert', table: 'raw_contacts', id: rawId, values: { [RawContacts.VERSION]: v } },
|
||||
{ op: 'update', table: 'raw_contacts', id: rawId, values: { [RawContacts.DIRTY]: 0 } },
|
||||
]);
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses 500 contacts ops without a yield point and oversized batches', async () => {
|
||||
const fake = new FakeDeviceProviders({ maxBatchBytes: 500_000 });
|
||||
const port = fake.port(ME, CONTACTS_AUTHORITY);
|
||||
const ops: ProviderOp[] = Array.from({ length: 500 }, () => ({ op: 'insert', table: 'raw_contacts', values: {} }) as ProviderOp);
|
||||
expect(await port.applyBatch(ops)).toMatchObject({ ok: false, reason: 'provider' });
|
||||
ops[250] = { ...ops[250], yieldAllowed: true };
|
||||
expect((await port.applyBatch(ops)).ok).toBe(true);
|
||||
const big = await port.applyBatch([{ op: 'insert', table: 'raw_contacts', values: { [RawContacts.SYNC2]: 'x'.repeat(300_000) } }]);
|
||||
expect(big).toMatchObject({ ok: false, reason: 'tooLarge' });
|
||||
});
|
||||
|
||||
it('normalises names and addresses like ContactsProvider', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const { port, rawId } = await contactWithEmail(fake);
|
||||
const res = await port.applyBatch([
|
||||
{ op: 'insert', table: 'data', values: { [Data.RAW_CONTACT_ID]: rawId, [Data.MIMETYPE]: MimeType.STRUCTURED_NAME, [StructuredName.DISPLAY_NAME]: 'Ada Lovelace' } },
|
||||
{ op: 'insert', table: 'data', values: { [Data.RAW_CONTACT_ID]: rawId, [Data.MIMETYPE]: MimeType.STRUCTURED_POSTAL, [StructuredPostal.CITY]: 'London', [StructuredPostal.COUNTRY]: 'UK' } },
|
||||
]);
|
||||
if (!res.ok) throw new Error(res.message);
|
||||
expect(fake.row('data', res.results[0].id!)).toMatchObject({ [StructuredName.GIVEN_NAME]: 'Ada', [StructuredName.FAMILY_NAME]: 'Lovelace' });
|
||||
expect(fake.row('data', res.results[1].id!)![StructuredPostal.FORMATTED_ADDRESS]).toBe('London, UK');
|
||||
});
|
||||
|
||||
it('stores a photo written as a blob and serves it to readPhoto', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const { port, rawId } = await contactWithEmail(fake);
|
||||
const res = await port.applyBatch([
|
||||
{ op: 'insert', table: 'data', values: { [Data.RAW_CONTACT_ID]: rawId, [Data.MIMETYPE]: MimeType.PHOTO, [Data.DATA15]: { b64: 'AAAA' } } },
|
||||
]);
|
||||
expect(res.ok).toBe(true);
|
||||
const photo = await port.readPhoto(rawId, 512);
|
||||
expect(photo?.jpegBase64).toBe('AAAA');
|
||||
expect(photo?.fileId).toEqual(expect.any(Number));
|
||||
const rows = await port.query({ table: 'data', columns: [Data.DATA14, Data.DATA15], where: `${Data.MIMETYPE} = ?`, args: [MimeType.PHOTO] });
|
||||
// data14 is a TEXT column: the file id reads back as text.
|
||||
expect(rows.rows[0]).toEqual([String(photo!.fileId), null]);
|
||||
});
|
||||
|
||||
it('keeps a photo within the 96 px thumbnail as the thumbnail alone, and serves that to readPhoto', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const { port, rawId } = await contactWithEmail(fake);
|
||||
const res = await port.applyBatch([
|
||||
{ op: 'insert', table: 'data', values: { [Data.RAW_CONTACT_ID]: rawId, [Data.MIMETYPE]: MimeType.PHOTO, [Data.DATA15]: { b64: JPEG } } },
|
||||
]);
|
||||
expect(res.ok).toBe(true);
|
||||
const rows = await port.query({ table: 'data', columns: [Data.DATA14, Data.DATA15], where: `${Data.MIMETYPE} = ?`, args: [MimeType.PHOTO] });
|
||||
// No PHOTO_FILE_ID for a 1×1 JPEG, and the thumbnail does not read back.
|
||||
expect(rows.rows[0]).toEqual([null, null]);
|
||||
expect(await port.readPhoto(rawId, 512)).toEqual({ jpegBase64: JPEG, fileId: null });
|
||||
});
|
||||
|
||||
it('weighs a batch as its Binder transaction does: two bytes per character of its JSON, a photo by its bytes', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const { port, rawId } = await contactWithEmail(fake);
|
||||
// 600,000 characters of text are about 1.2 MB in the Parcel; the same number of base64 characters are 450 KB of photo.
|
||||
const text = await port.applyBatch([{ op: 'update', table: 'raw_contacts', id: rawId, values: { [RawContacts.SYNC2]: 'x'.repeat(600_000) } }]);
|
||||
expect(text).toMatchObject({ ok: false, reason: 'tooLarge' });
|
||||
const photo = await port.applyBatch([
|
||||
{ op: 'insert', table: 'data', values: { [Data.RAW_CONTACT_ID]: rawId, [Data.MIMETYPE]: MimeType.PHOTO, [Data.DATA15]: { b64: 'A'.repeat(600_000) } } },
|
||||
]);
|
||||
expect(photo.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('models Fossify saves: rows re-inserted without DATA_SYNC', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const { rawId, dataId } = await contactWithEmail(fake);
|
||||
fake.user.fossifySave(rawId, null, [{ [Data.MIMETYPE]: MimeType.EMAIL, [Data.DATA1]: 'a@example.org' }]);
|
||||
expect(fake.row('data', dataId)).toBeUndefined();
|
||||
const emails = fake.rows('data', `${Data.RAW_CONTACT_ID} = ?`, [rawId]);
|
||||
expect(emails).toHaveLength(1);
|
||||
expect(emails[0][Data.DATA_SYNC1]).toBeNull();
|
||||
expect(fake.row('raw_contacts', rawId)![RawContacts.DIRTY]).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FakeDeviceProviders: calendar', () => {
|
||||
async function calendar(fake: FakeDeviceProviders) {
|
||||
const port = fake.port(ME, CALENDAR_AUTHORITY);
|
||||
const res = await port.applyBatch([
|
||||
{
|
||||
op: 'insert',
|
||||
table: 'calendars',
|
||||
values: {
|
||||
[Calendars._SYNC_ID]: 'c/b',
|
||||
[Calendars.NAME]: 'Personal',
|
||||
[Calendars.CALENDAR_DISPLAY_NAME]: 'Personal',
|
||||
[Calendars.CALENDAR_COLOR]: -16776961,
|
||||
[Calendars.CALENDAR_ACCESS_LEVEL]: 700,
|
||||
[Calendars.OWNER_ACCOUNT]: ME,
|
||||
[Calendars.SYNC_EVENTS]: 1,
|
||||
},
|
||||
},
|
||||
]);
|
||||
if (!res.ok) throw new Error(res.message);
|
||||
return { port, calendarId: res.results[0].id! };
|
||||
}
|
||||
|
||||
it('requires the calendar columns the provider requires', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const port = fake.port(ME, CALENDAR_AUTHORITY);
|
||||
const res = await port.applyBatch([{ op: 'insert', table: 'calendars', values: { [Calendars.NAME]: 'x' } }]);
|
||||
expect(res).toMatchObject({ ok: false, reason: 'provider' });
|
||||
});
|
||||
|
||||
it('enforces recurring/exception column rules, all-day fixes and the NULL-status crash', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const { port, calendarId } = await calendar(fake);
|
||||
const base = { [Events.CALENDAR_ID]: calendarId, [Events.EVENT_TIMEZONE]: 'UTC', [Events.STATUS]: 1 };
|
||||
const master = await port.applyBatch([
|
||||
{ op: 'insert', table: 'events', values: { ...base, [Events._SYNC_ID]: 'c/e1', [Events.DTSTART]: 1_000, [Events.DTEND]: 5_000, [Events.RRULE]: 'FREQ=DAILY', [Events.DURATION]: 'P3600S' } },
|
||||
]);
|
||||
if (!master.ok) throw new Error(master.message);
|
||||
const m = fake.row('events', master.results[0].id!)!;
|
||||
expect(m[Events.DTEND]).toBeNull();
|
||||
expect(m[Events.ORGANIZER]).toBe(ME);
|
||||
expect(await port.applyBatch([{ op: 'insert', table: 'events', values: { ...base, [Events.DTSTART]: 0, [Events.RRULE]: 'FREQ=DAILY' } }])).toMatchObject({ ok: false });
|
||||
expect(await port.applyBatch([{ op: 'insert', table: 'events', values: { ...base, [Events.DTSTART]: 0, [Events.RRULE]: 'FREQ=YEARLY;RSCALE=CHINESE', [Events.DURATION]: 'P1D' } }])).toMatchObject({ ok: false });
|
||||
expect(await port.applyBatch([{ op: 'insert', table: 'events', values: { ...base, [Events.ALL_DAY]: 1, [Events.DTSTART]: 0, [Events.RRULE]: 'FREQ=DAILY', [Events.DURATION]: 'PT0S' } }])).toMatchObject({ ok: false });
|
||||
const allDay = await port.applyBatch([
|
||||
{ op: 'insert', table: 'events', values: { ...base, [Events.ALL_DAY]: 1, [Events.DTSTART]: 86_400_000 + 3_600_000, [Events.RRULE]: 'FREQ=DAILY', [Events.DURATION]: 'P86400S' } },
|
||||
]);
|
||||
if (!allDay.ok) throw new Error(allDay.message);
|
||||
expect(fake.row('events', allDay.results[0].id!)).toMatchObject({ [Events.DTSTART]: 86_400_000, [Events.DURATION]: 'P1D' });
|
||||
expect(await port.applyBatch([{ op: 'update', table: 'events', id: master.results[0].id!, values: { [Events.STATUS]: null } }])).toMatchObject({ ok: false });
|
||||
expect(await port.applyBatch([{ op: 'update', table: 'events', id: master.results[0].id!, values: { [Events.SELF_ATTENDEE_STATUS]: 1 } }])).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it('links exceptions to their master and keeps them when a sync adapter deletes the master', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const { port, calendarId } = await calendar(fake);
|
||||
const res = await port.applyBatch([
|
||||
{ op: 'insert', table: 'events', values: { [Events.CALENDAR_ID]: calendarId, [Events._SYNC_ID]: 'c/e1', [Events.DTSTART]: 0, [Events.RRULE]: 'FREQ=DAILY', [Events.DURATION]: 'P3600S', [Events.EVENT_TIMEZONE]: 'UTC', [Events.STATUS]: 1 } },
|
||||
{ op: 'insert', table: 'events', values: { [Events.CALENDAR_ID]: calendarId, [Events.ORIGINAL_SYNC_ID]: 'c/e1', [Events.ORIGINAL_INSTANCE_TIME]: 86_400_000, [Events.DTSTART]: 86_400_000, [Events.DTEND]: 90_000_000, [Events.EVENT_TIMEZONE]: 'UTC', [Events.STATUS]: 1 } },
|
||||
]);
|
||||
if (!res.ok) throw new Error(res.message);
|
||||
const [masterId, exceptionId] = res.results.map((r) => r.id!);
|
||||
expect(fake.row('events', exceptionId)![Events.ORIGINAL_ID]).toBe(masterId);
|
||||
await port.applyBatch([{ op: 'update', table: 'events', id: masterId, values: { [Events._SYNC_ID]: 'c/e9' } }]);
|
||||
expect(fake.row('events', exceptionId)![Events.ORIGINAL_SYNC_ID]).toBe('c/e9');
|
||||
await port.applyBatch([{ op: 'delete', table: 'events', id: masterId }]);
|
||||
expect(fake.row('events', exceptionId)).toBeDefined();
|
||||
});
|
||||
|
||||
it('leaves STATUS NULL on an event an app inserts without one (the column has no default)', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const { calendarId } = await calendar(fake);
|
||||
const id = fake.user.insertEvent(calendarId, { [Events.DTSTART]: 0, [Events.DTEND]: 1000, [Events.EVENT_TIMEZONE]: 'UTC', [Events.TITLE]: 'x' });
|
||||
expect(fake.row('events', id)![Events.STATUS]).toBeNull();
|
||||
// Unlike AVAILABILITY and ACCESS_LEVEL (NOT NULL DEFAULT 0).
|
||||
expect(fake.row('events', id)).toMatchObject({ [Events.AVAILABILITY]: 0, [Events.ACCESS_LEVEL]: 0 });
|
||||
});
|
||||
|
||||
it('hard-deletes app-deleted events without _SYNC_ID and soft-deletes synced ones', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const { calendarId } = await calendar(fake);
|
||||
const local = fake.user.insertEvent(calendarId, { [Events.DTSTART]: 0, [Events.DTEND]: 1000, [Events.EVENT_TIMEZONE]: 'UTC', [Events.TITLE]: 'x', [Events.STATUS]: 1 });
|
||||
expect(fake.row('events', local)).toMatchObject({ [Events.DIRTY]: 1, [Events.MUTATORS]: expect.any(String) });
|
||||
fake.user.deleteEvent(local);
|
||||
expect(fake.row('events', local)).toBeUndefined();
|
||||
const synced = fake.user.insertEvent(calendarId, { [Events.DTSTART]: 0, [Events.DTEND]: 1000, [Events.EVENT_TIMEZONE]: 'UTC', [Events._SYNC_ID]: '~pending/u1', [Events.STATUS]: 1 }, { reminders: [{ minutes: 10, method: 1 }] });
|
||||
fake.user.deleteEvent(synced);
|
||||
expect(fake.row('events', synced)).toMatchObject({ [Events.DELETED]: 1, [Events.DIRTY]: 1 });
|
||||
expect(fake.rows('reminders', 'event_id = ?', [synced])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('models CONTENT_EXCEPTION_URI: only the exception is dirty; a split clones _SYNC_ID', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const { port, calendarId } = await calendar(fake);
|
||||
const res = await port.applyBatch([
|
||||
{ op: 'insert', table: 'events', values: { [Events.CALENDAR_ID]: calendarId, [Events._SYNC_ID]: 'c/e1', [Events.UID_2445]: 'u1', [Events.DTSTART]: 0, [Events.RRULE]: 'FREQ=DAILY', [Events.DURATION]: 'P3600S', [Events.EVENT_TIMEZONE]: 'UTC', [Events.STATUS]: 1 } },
|
||||
{ op: 'insert', table: 'attendees', values: { [Attendees.ATTENDEE_EMAIL]: ME, [Attendees.ATTENDEE_STATUS]: 3 }, refs: { event_id: 0 } },
|
||||
]);
|
||||
if (!res.ok) throw new Error(res.message);
|
||||
const masterId = res.results[0].id!;
|
||||
const ex = fake.user.exceptionViaUri(masterId, 86_400_000, { [Events.SELF_ATTENDEE_STATUS]: 1 });
|
||||
expect(fake.row('events', masterId)![Events.DIRTY]).toBeNull();
|
||||
expect(fake.row('events', ex)).toMatchObject({ [Events.DIRTY]: 1, [Events._SYNC_ID]: null, [Events.UID_2445]: 'u1', [Events.ORIGINAL_SYNC_ID]: 'c/e1' });
|
||||
expect(fake.rows('attendees', 'event_id = ?', [ex])[0][Attendees.ATTENDEE_STATUS]).toBe(1);
|
||||
const split = fake.user.splitViaUri(masterId, 5 * 86_400_000, '20260101T000000Z', {});
|
||||
expect(fake.row('events', split)![Events._SYNC_ID]).toBe('c/e1');
|
||||
expect(fake.row('events', masterId)![Events.RRULE]).toContain('UNTIL=20260101T000000Z');
|
||||
expect(fake.row('events', masterId)![Events.DIRTY]).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AOSP format checks', () => {
|
||||
it('accepts the Duration forms calendarcommon2 parses', () => {
|
||||
for (const ok of ['P1D', 'PT1H30M', 'P1DT2H', 'P2W', 'P1W2D', '-PT15M', 'P3600S', 'P0DT1H30M0S']) expect(aospDurationValid(ok)).toBe(true);
|
||||
for (const bad of ['1D', 'P1Y', 'PT1.5S', 'P', '']) expect(aospDurationValid(bad)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects RRULE parts EventRecurrence does not know', () => {
|
||||
expect(aospRruleValid('FREQ=MONTHLY;BYDAY=MO;BYSETPOS=-1;WKST=SU;X-FOO=1')).toBe(true);
|
||||
expect(aospRruleValid('RRULE:FREQ=DAILY')).toBe(false);
|
||||
expect(aospRruleValid('FREQ=YEARLY;RSCALE=GREGORIAN;SKIP=OMIT')).toBe(false);
|
||||
expect(aospRruleValid('COUNT=2')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FakeDeviceProviders: the native bridge rules', () => {
|
||||
async function calendarWithEvent(fake: FakeDeviceProviders, account = ME) {
|
||||
const port = fake.port(account, CALENDAR_AUTHORITY);
|
||||
const res = await port.applyBatch([
|
||||
{
|
||||
op: 'insert',
|
||||
table: 'calendars',
|
||||
values: {
|
||||
[Calendars.NAME]: 'Personal',
|
||||
[Calendars.CALENDAR_DISPLAY_NAME]: 'Personal',
|
||||
[Calendars.CALENDAR_COLOR]: -16776961,
|
||||
[Calendars.CALENDAR_ACCESS_LEVEL]: 700,
|
||||
[Calendars.OWNER_ACCOUNT]: account,
|
||||
},
|
||||
},
|
||||
{
|
||||
op: 'insert',
|
||||
table: 'events',
|
||||
values: { [Events.DTSTART]: 0, [Events.DTEND]: 3_600_000, [Events.EVENT_TIMEZONE]: 'UTC', [Events.STATUS]: 1 },
|
||||
refs: { [Events.CALENDAR_ID]: 0 },
|
||||
},
|
||||
{ op: 'insert', table: 'attendees', values: { [Attendees.ATTENDEE_EMAIL]: OTHER }, refs: { event_id: 1 } },
|
||||
{ op: 'insert', table: 'extended_properties', values: { name: 'x', value: '1' }, refs: { event_id: 1 } },
|
||||
]);
|
||||
if (!res.ok) throw new Error(res.message);
|
||||
return {
|
||||
port,
|
||||
calendarId: res.results[0].id!,
|
||||
eventId: res.results[1].id!,
|
||||
attendeeId: res.results[2].id!,
|
||||
propertyId: res.results[3].id!,
|
||||
};
|
||||
}
|
||||
|
||||
it('stores numbers written to data1..data14 as text', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const { port, rawId } = await contactWithEmail(fake);
|
||||
const res = await port.applyBatch([
|
||||
{
|
||||
op: 'insert',
|
||||
table: 'data',
|
||||
values: { [Data.RAW_CONTACT_ID]: rawId, [Data.MIMETYPE]: MimeType.PHONE, [Data.DATA1]: '+1 555', [Data.DATA2]: 2 },
|
||||
},
|
||||
]);
|
||||
if (!res.ok) throw new Error(res.message);
|
||||
const rows = await port.query({
|
||||
table: 'data',
|
||||
columns: [Data.DATA2, Data.RAW_CONTACT_ID],
|
||||
where: `${Data.MIMETYPE} = ?`,
|
||||
args: [MimeType.PHONE],
|
||||
});
|
||||
expect(rows.rows).toEqual([['2', rawId]]);
|
||||
// Asserts compare as text, so a number still matches.
|
||||
const ok = await port.applyBatch([{ op: 'assert', table: 'data', id: res.results[0].id!, values: { [Data.DATA2]: 2 } }]);
|
||||
expect(ok.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves DIRTY NULL on events and calendars the sync adapter inserts', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const { port, calendarId, eventId } = await calendarWithEvent(fake);
|
||||
expect(fake.row('calendars', calendarId)![Calendars.DIRTY]).toBeNull();
|
||||
expect(fake.row('events', eventId)![Events.DIRTY]).toBeNull();
|
||||
expect((await port.query({ table: 'events', columns: ['_id'], where: 'dirty = 0' })).rows).toEqual([]);
|
||||
expect((await port.query({ table: 'events', columns: ['_id'], where: 'dirty = 1' })).rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('expects exactly the id row unless told otherwise, and applies where to it', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const { port, rawId } = await contactWithEmail(fake);
|
||||
const gone = await port.applyBatch([{ op: 'delete', table: 'raw_contacts', id: rawId + 1000 }]);
|
||||
expect(gone).toMatchObject({ ok: false, reason: 'assert' });
|
||||
const tolerated = await port.applyBatch([{ op: 'delete', table: 'raw_contacts', id: rawId + 1000, expectCount: 0 }]);
|
||||
expect(tolerated).toEqual({ ok: true, results: [{ count: 0 }] });
|
||||
const mismatch = await port.applyBatch([
|
||||
{ op: 'update', table: 'raw_contacts', id: rawId, where: `${RawContacts.DIRTY} = 1`, values: { [RawContacts.SYNC1]: 'x' } },
|
||||
]);
|
||||
expect(mismatch).toMatchObject({ ok: false, reason: 'assert' });
|
||||
const matched = await port.applyBatch([
|
||||
{ op: 'update', table: 'raw_contacts', id: rawId, where: `${RawContacts.DIRTY} = 0`, values: { [RawContacts.SYNC1]: 'x' } },
|
||||
]);
|
||||
expect(matched).toEqual({ ok: true, results: [{ count: 1 }] });
|
||||
});
|
||||
|
||||
it('refuses _id in where on calendar child tables, and updates extended properties by id only', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const { port, eventId, attendeeId, propertyId } = await calendarWithEvent(fake);
|
||||
await expect(
|
||||
port.query({ table: 'attendees', columns: ['event_id'], where: '_id = ?', args: [attendeeId] }),
|
||||
).rejects.toMatchObject({ code: 'provider' });
|
||||
const byWhere = await port.applyBatch([{ op: 'delete', table: 'attendees', where: '_id = ?', args: [attendeeId] }]);
|
||||
expect(byWhere).toMatchObject({ ok: false, reason: 'provider' });
|
||||
const byId = await port.applyBatch([{ op: 'delete', table: 'attendees', id: attendeeId }]);
|
||||
expect(byId).toEqual({ ok: true, results: [{ count: 1 }] });
|
||||
const propertiesByWhere = await port.applyBatch([
|
||||
{ op: 'update', table: 'extended_properties', where: 'event_id = ?', args: [eventId], values: { value: '2' } },
|
||||
]);
|
||||
expect(propertiesByWhere).toMatchObject({ ok: false, reason: 'provider' });
|
||||
const propertiesById = await port.applyBatch([
|
||||
{ op: 'update', table: 'extended_properties', id: propertyId, values: { value: '2' } },
|
||||
]);
|
||||
expect(propertiesById.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('fails with assert for a parent that is gone and with scope for one of another account', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const mine = await calendarWithEvent(fake);
|
||||
const theirs = await calendarWithEvent(fake, OTHER);
|
||||
const gone = await mine.port.applyBatch([
|
||||
{ op: 'insert', table: 'reminders', values: { event_id: theirs.eventId + 1000, minutes: 10 } },
|
||||
]);
|
||||
expect(gone).toMatchObject({ ok: false, reason: 'assert' });
|
||||
const foreign = await mine.port.applyBatch([
|
||||
{ op: 'insert', table: 'reminders', values: { event_id: theirs.eventId, minutes: 10 } },
|
||||
]);
|
||||
expect(foreign).toMatchObject({ ok: false, reason: 'scope' });
|
||||
const unnamed = await mine.port.applyBatch([{ op: 'insert', table: 'reminders', values: { minutes: 10 } }]);
|
||||
expect(unnamed).toMatchObject({ ok: false, reason: 'scope' });
|
||||
const moveAway = await mine.port.applyBatch([
|
||||
{ op: 'update', table: 'events', id: mine.eventId, values: { [Events.CALENDAR_ID]: theirs.calendarId } },
|
||||
]);
|
||||
expect(moveAway).toMatchObject({ ok: false, reason: 'scope' });
|
||||
const wrongRef = await mine.port.applyBatch([
|
||||
{ op: 'insert', table: 'colors', values: { color_type: 0, color_index: '1', color: 1 } },
|
||||
{ op: 'insert', table: 'reminders', values: { minutes: 10 }, refs: { event_id: 0 } },
|
||||
]);
|
||||
expect(wrongRef).toMatchObject({ ok: false, reason: 'scope' });
|
||||
const leaves = await mine.port.applyBatch([
|
||||
{ op: 'update', table: 'calendars', id: mine.calendarId, values: { [Calendars.ACCOUNT_NAME]: OTHER } },
|
||||
]);
|
||||
expect(leaves).toMatchObject({ ok: false, reason: 'scope' });
|
||||
});
|
||||
|
||||
it('wants syncState last and never at a yield point', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const { port, rawId } = await contactWithEmail(fake);
|
||||
const early = await port.applyBatch([
|
||||
{ op: 'syncState', value: '{}' },
|
||||
{ op: 'update', table: 'raw_contacts', id: rawId, values: { [RawContacts.SYNC1]: 'x' } },
|
||||
]);
|
||||
expect(early).toMatchObject({ ok: false, reason: 'scope' });
|
||||
const yielding = await port.applyBatch([
|
||||
{ op: 'update', table: 'raw_contacts', id: rawId, values: { [RawContacts.SYNC1]: 'x' } },
|
||||
{ op: 'syncState', value: '{}', yieldAllowed: true },
|
||||
]);
|
||||
expect(yielding).toMatchObject({ ok: false, reason: 'scope' });
|
||||
expect(fake.readSyncState(ME, CONTACTS_AUTHORITY)).toBeNull();
|
||||
});
|
||||
|
||||
it('takes raw_contact_is_read_only on writes but refuses it in projections and selections', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const port = fake.port(ME, CONTACTS_AUTHORITY);
|
||||
const res = await port.applyBatch([{ op: 'insert', table: 'raw_contacts', values: { [RawContacts.RAW_CONTACT_IS_READ_ONLY]: 1 } }]);
|
||||
expect(res.ok).toBe(true);
|
||||
await expect(port.query({ table: 'raw_contacts', columns: [RawContacts.RAW_CONTACT_IS_READ_ONLY] })).rejects.toMatchObject({
|
||||
code: 'provider',
|
||||
});
|
||||
await expect(
|
||||
port.query({ table: 'raw_contacts', columns: [RawContacts._ID], where: `${RawContacts.RAW_CONTACT_IS_READ_ONLY} = 1` }),
|
||||
).rejects.toMatchObject({ code: 'provider' });
|
||||
});
|
||||
|
||||
it('returns no id for a settings insert, which cannot be addressed by id either', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const port = fake.port(ME, CONTACTS_AUTHORITY);
|
||||
const res = await port.applyBatch([{ op: 'insert', table: 'settings', values: { ungrouped_visible: 1 } }]);
|
||||
expect(res).toEqual({ ok: true, results: [{}] });
|
||||
const byId = await port.applyBatch([{ op: 'delete', table: 'settings', id: 1 }]);
|
||||
expect(byId).toMatchObject({ ok: false, reason: 'provider' });
|
||||
});
|
||||
|
||||
it('counts the op at a yield point before resetting, like ContactsProvider', async () => {
|
||||
const fake = new FakeDeviceProviders();
|
||||
const { port, rawId } = await contactWithEmail(fake);
|
||||
const update = (yieldAllowed = false): ProviderOp => ({
|
||||
op: 'update',
|
||||
table: 'raw_contacts',
|
||||
id: rawId,
|
||||
values: { [RawContacts.SYNC2]: 'y' },
|
||||
yieldAllowed,
|
||||
});
|
||||
// 499 ops, then a yield point: fine.
|
||||
const fits = [...Array.from({ length: 498 }, () => update()), update(true), ...Array.from({ length: 10 }, () => update())];
|
||||
expect((await port.applyBatch(fits)).ok).toBe(true);
|
||||
// The yield point is the 500th op since the start: refused.
|
||||
const late = [...Array.from({ length: 499 }, () => update()), update(true)];
|
||||
expect(await port.applyBatch(late)).toMatchObject({ ok: false, reason: 'provider' });
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* The small SQL subset the device sync engine may use in `ProviderQuery.where`
|
||||
* and op selections, evaluated over in-memory rows for the fake providers.
|
||||
* Keeping the engine to this subset keeps it portable to the real providers
|
||||
* (SQLite) and testable here:
|
||||
*
|
||||
* expr := or
|
||||
* or := and ('OR' and)*
|
||||
* and := not ('AND' not)*
|
||||
* not := 'NOT' not | primary
|
||||
* primary := '(' expr ')'
|
||||
* | column ('=' | '!=' | '<>' | '<' | '<=' | '>' | '>=') value
|
||||
* | column 'IS' ['NOT'] 'NULL'
|
||||
* | column ['NOT'] 'IN' '(' value (',' value)* ')'
|
||||
* | column ['NOT'] 'LIKE' value
|
||||
* value := '?' | 'string' | number
|
||||
*
|
||||
* Comparisons follow SQLite closely enough: NULL compares false, numbers and
|
||||
* numeric strings compare as numbers (column affinity), LIKE is
|
||||
* case-insensitive with `%` and `_`.
|
||||
*/
|
||||
import type { Cell } from '../../types';
|
||||
|
||||
type Value = { kind: 'arg'; index: number } | { kind: 'lit'; value: string | number };
|
||||
|
||||
type Node =
|
||||
| { t: 'or'; parts: Node[] }
|
||||
| { t: 'and'; parts: Node[] }
|
||||
| { t: 'not'; inner: Node }
|
||||
| { t: 'cmp'; column: string; op: string; value: Value }
|
||||
| { t: 'isnull'; column: string; negate: boolean }
|
||||
| { t: 'in'; column: string; values: Value[]; negate: boolean }
|
||||
| { t: 'like'; column: string; value: Value; negate: boolean };
|
||||
|
||||
type Token = { k: 'id' | 'str' | 'num' | 'op' | 'arg' | 'lp' | 'rp' | 'comma'; v: string };
|
||||
|
||||
function tokenize(src: string): Token[] {
|
||||
const out: Token[] = [];
|
||||
let i = 0;
|
||||
while (i < src.length) {
|
||||
const c = src[i];
|
||||
if (/\s/.test(c)) { i++; continue; }
|
||||
if (c === '(') { out.push({ k: 'lp', v: c }); i++; continue; }
|
||||
if (c === ')') { out.push({ k: 'rp', v: c }); i++; continue; }
|
||||
if (c === ',') { out.push({ k: 'comma', v: c }); i++; continue; }
|
||||
if (c === '?') { out.push({ k: 'arg', v: c }); i++; continue; }
|
||||
if (c === "'") {
|
||||
let j = i + 1;
|
||||
let s = '';
|
||||
while (j < src.length) {
|
||||
if (src[j] === "'" && src[j + 1] === "'") { s += "'"; j += 2; continue; }
|
||||
if (src[j] === "'") break;
|
||||
s += src[j++];
|
||||
}
|
||||
if (j >= src.length) throw new Error(`fake-sql: unterminated string in ${src}`);
|
||||
out.push({ k: 'str', v: s });
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
const two = src.slice(i, i + 2);
|
||||
if (['!=', '<>', '<=', '>='].includes(two)) { out.push({ k: 'op', v: two }); i += 2; continue; }
|
||||
if (['=', '<', '>'].includes(c)) { out.push({ k: 'op', v: c }); i++; continue; }
|
||||
const num = /^-?\d+(\.\d+)?/.exec(src.slice(i));
|
||||
if (num) { out.push({ k: 'num', v: num[0] }); i += num[0].length; continue; }
|
||||
const id = /^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i));
|
||||
if (id) { out.push({ k: 'id', v: id[0] }); i += id[0].length; continue; }
|
||||
throw new Error(`fake-sql: unexpected '${c}' in ${src}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
class Parser {
|
||||
private pos = 0;
|
||||
private argIndex = 0;
|
||||
constructor(private readonly tokens: Token[], private readonly src: string) {}
|
||||
|
||||
parse(): Node {
|
||||
const node = this.or();
|
||||
if (this.pos !== this.tokens.length) this.fail('trailing input');
|
||||
return node;
|
||||
}
|
||||
|
||||
private peek(): Token | undefined { return this.tokens[this.pos]; }
|
||||
private kw(word: string): boolean {
|
||||
const t = this.peek();
|
||||
if (t?.k === 'id' && t.v.toUpperCase() === word) { this.pos++; return true; }
|
||||
return false;
|
||||
}
|
||||
private fail(msg: string): never { throw new Error(`fake-sql: ${msg} in "${this.src}"`); }
|
||||
|
||||
private or(): Node {
|
||||
const parts = [this.and()];
|
||||
while (this.kw('OR')) parts.push(this.and());
|
||||
return parts.length === 1 ? parts[0] : { t: 'or', parts };
|
||||
}
|
||||
private and(): Node {
|
||||
const parts = [this.not()];
|
||||
while (this.kw('AND')) parts.push(this.not());
|
||||
return parts.length === 1 ? parts[0] : { t: 'and', parts };
|
||||
}
|
||||
private not(): Node {
|
||||
if (this.kw('NOT')) return { t: 'not', inner: this.not() };
|
||||
return this.primary();
|
||||
}
|
||||
private value(): Value {
|
||||
const t = this.tokens[this.pos++];
|
||||
if (!t) this.fail('missing value');
|
||||
if (t.k === 'arg') return { kind: 'arg', index: this.argIndex++ };
|
||||
if (t.k === 'str') return { kind: 'lit', value: t.v };
|
||||
if (t.k === 'num') return { kind: 'lit', value: Number(t.v) };
|
||||
return this.fail(`expected a value, got ${t.v}`);
|
||||
}
|
||||
private primary(): Node {
|
||||
const t = this.peek();
|
||||
if (!t) this.fail('unexpected end');
|
||||
if (t.k === 'lp') {
|
||||
this.pos++;
|
||||
const inner = this.or();
|
||||
if (this.peek()?.k !== 'rp') this.fail('missing )');
|
||||
this.pos++;
|
||||
return inner;
|
||||
}
|
||||
if (t.k !== 'id') this.fail(`expected a column, got ${t.v}`);
|
||||
this.pos++;
|
||||
const column = t.v;
|
||||
if (this.kw('IS')) {
|
||||
const negate = this.kw('NOT');
|
||||
if (!this.kw('NULL')) this.fail('expected NULL');
|
||||
return { t: 'isnull', column, negate };
|
||||
}
|
||||
const negate = this.kw('NOT');
|
||||
if (this.kw('IN')) {
|
||||
if (this.peek()?.k !== 'lp') this.fail('expected (');
|
||||
this.pos++;
|
||||
const values = [this.value()];
|
||||
while (this.peek()?.k === 'comma') { this.pos++; values.push(this.value()); }
|
||||
if (this.peek()?.k !== 'rp') this.fail('missing )');
|
||||
this.pos++;
|
||||
return { t: 'in', column, values, negate };
|
||||
}
|
||||
if (this.kw('LIKE')) return { t: 'like', column, value: this.value(), negate };
|
||||
if (negate) this.fail('NOT must precede IN or LIKE here');
|
||||
const op = this.tokens[this.pos++];
|
||||
if (op?.k !== 'op') this.fail(`expected an operator after ${column}`);
|
||||
return { t: 'cmp', column, op: op.v, value: this.value() };
|
||||
}
|
||||
}
|
||||
|
||||
const cache = new Map<string, { node: Node; argCount: number }>();
|
||||
|
||||
function compile(where: string): { node: Node; argCount: number } {
|
||||
let hit = cache.get(where);
|
||||
if (!hit) {
|
||||
const tokens = tokenize(where);
|
||||
const node = new Parser(tokens, where).parse();
|
||||
hit = { node, argCount: tokens.filter((t) => t.k === 'arg').length };
|
||||
cache.set(where, hit);
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
function asNumber(v: unknown): number | null {
|
||||
if (typeof v === 'number') return v;
|
||||
if (typeof v === 'string' && /^-?\d+(\.\d+)?$/.test(v.trim())) return Number(v);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** SQLite-like comparison: -1/0/1, or null when either side is NULL. */
|
||||
export function compareCells(a: Cell, b: Cell): number | null {
|
||||
if (a === null || a === undefined || b === null || b === undefined) return null;
|
||||
const na = asNumber(a);
|
||||
const nb = asNumber(b);
|
||||
if (na !== null && nb !== null) return na === nb ? 0 : na < nb ? -1 : 1;
|
||||
const sa = String(a);
|
||||
const sb = String(b);
|
||||
return sa === sb ? 0 : sa < sb ? -1 : 1;
|
||||
}
|
||||
|
||||
function likeToRegex(pattern: string): RegExp {
|
||||
const esc = pattern.replace(/[.*+^${}()|[\]\\]/g, '\\$&').replace(/%/g, '.*').replace(/_/g, '.');
|
||||
return new RegExp(`^${esc}$`, 'is');
|
||||
}
|
||||
|
||||
export type ColumnReader = (column: string) => Cell;
|
||||
|
||||
/** A predicate for rows, from a WHERE clause and its arguments. */
|
||||
export function compileWhere(where: string | undefined, args: Array<string | number> = []): (read: ColumnReader) => boolean {
|
||||
if (!where || !where.trim()) return () => true;
|
||||
const { node, argCount } = compile(where);
|
||||
if (argCount !== args.length) {
|
||||
throw new Error(`fake-sql: "${where}" has ${argCount} placeholders but ${args.length} arguments`);
|
||||
}
|
||||
const val = (v: Value): Cell => (v.kind === 'arg' ? args[v.index] : v.value);
|
||||
const evalNode = (n: Node, read: ColumnReader): boolean => {
|
||||
switch (n.t) {
|
||||
case 'or': return n.parts.some((p) => evalNode(p, read));
|
||||
case 'and': return n.parts.every((p) => evalNode(p, read));
|
||||
case 'not': return !evalNode(n.inner, read);
|
||||
case 'isnull': {
|
||||
const isNull = read(n.column) === null || read(n.column) === undefined;
|
||||
return n.negate ? !isNull : isNull;
|
||||
}
|
||||
case 'in': {
|
||||
const cell = read(n.column);
|
||||
if (cell === null || cell === undefined) return false;
|
||||
const hit = n.values.some((v) => compareCells(cell, val(v)) === 0);
|
||||
return n.negate ? !hit : hit;
|
||||
}
|
||||
case 'like': {
|
||||
const cell = read(n.column);
|
||||
if (cell === null || cell === undefined) return false;
|
||||
const hit = likeToRegex(String(val(n.value))).test(String(cell));
|
||||
return n.negate ? !hit : hit;
|
||||
}
|
||||
case 'cmp': {
|
||||
const c = compareCells(read(n.column), val(n.value));
|
||||
if (c === null) return false;
|
||||
switch (n.op) {
|
||||
case '=': return c === 0;
|
||||
case '!=':
|
||||
case '<>': return c !== 0;
|
||||
case '<': return c < 0;
|
||||
case '<=': return c <= 0;
|
||||
case '>': return c > 0;
|
||||
case '>=': return c >= 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
return (read) => evalNode(node, read);
|
||||
}
|
||||
|
||||
/** Columns a WHERE clause mentions, so the fake can reject unknown ones like SQLite does. */
|
||||
export function whereColumns(where: string | undefined): string[] {
|
||||
if (!where || !where.trim()) return [];
|
||||
const { node } = compile(where);
|
||||
const out: string[] = [];
|
||||
const walk = (n: Node): void => {
|
||||
switch (n.t) {
|
||||
case 'or':
|
||||
case 'and': n.parts.forEach(walk); break;
|
||||
case 'not': walk(n.inner); break;
|
||||
default: out.push(n.column);
|
||||
}
|
||||
};
|
||||
walk(node);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A comparator for `orderBy` ("col [ASC|DESC], …"); `_id` ascending when absent. */
|
||||
export function compileOrderBy(orderBy: string | undefined): (a: ColumnReader, b: ColumnReader) => number {
|
||||
const terms = (orderBy?.trim() ? orderBy.split(',') : ['_id'])
|
||||
.map((term) => term.trim().split(/\s+/))
|
||||
.map(([column, dir]) => ({ column, desc: dir?.toUpperCase() === 'DESC' }));
|
||||
return (a, b) => {
|
||||
for (const { column, desc } of terms) {
|
||||
const va = a(column);
|
||||
const vb = b(column);
|
||||
let c: number;
|
||||
if (va === null && vb === null) c = 0;
|
||||
else if (va === null) c = -1; // SQLite sorts NULL first
|
||||
else if (vb === null) c = 1;
|
||||
else c = compareCells(va, vb) ?? 0;
|
||||
if (c !== 0) return desc ? -c : c;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
|
||||
import {
|
||||
clearSyncProblem,
|
||||
createProviderPort,
|
||||
ensureAndroidAccount,
|
||||
finishRun,
|
||||
getDeviceSyncModule,
|
||||
getSyncSettings,
|
||||
isDeviceSyncAvailable,
|
||||
isRunCancelled,
|
||||
listAndroidAccounts,
|
||||
onAccountsChanged,
|
||||
openAccountSettings,
|
||||
removeAndroidAccount,
|
||||
requestSync,
|
||||
setPeriodicSync,
|
||||
setPushRoutes,
|
||||
setSyncEnabled,
|
||||
showSyncProblem,
|
||||
} from '../native';
|
||||
import {
|
||||
ACCOUNTS_CHANGED_EVENT,
|
||||
CALENDAR_AUTHORITY,
|
||||
CONTACTS_AUTHORITY,
|
||||
type AccountSyncSettings,
|
||||
type Authority,
|
||||
type ProviderOp,
|
||||
type RunReport,
|
||||
} from '../types';
|
||||
|
||||
const modules = NativeModules as Record<string, unknown>;
|
||||
const ACCOUNT = 'alice@example.org';
|
||||
|
||||
const SETTINGS: AccountSyncSettings = {
|
||||
masterAutomatic: true,
|
||||
authorities: {
|
||||
[CONTACTS_AUTHORITY]: { syncable: 1, automatic: true, periodicSeconds: 3600, active: false, pending: false },
|
||||
[CALENDAR_AUTHORITY]: { syncable: 1, automatic: false, periodicSeconds: 0, active: false, pending: true },
|
||||
},
|
||||
};
|
||||
|
||||
/** Installs a BulwarkDeviceSync module whose methods resolve like the Kotlin one. */
|
||||
function installModule(overrides: Record<string, unknown> = {}) {
|
||||
const module = {
|
||||
getInfo: vi.fn(async () => ({ accountType: 'com.anonymous.bulwarkmobile.account', sdkInt: 34 })),
|
||||
listAccounts: vi.fn(async () => [{ name: ACCOUNT, registryId: 'alice@example.org@mail.example.org' }]),
|
||||
ensureAccount: vi.fn(async () => true),
|
||||
removeAccount: vi.fn(async () => true),
|
||||
getSyncSettings: vi.fn(async () => SETTINGS),
|
||||
setSyncEnabled: vi.fn(async () => undefined),
|
||||
setPeriodicSync: vi.fn(async () => undefined),
|
||||
requestSync: vi.fn(async () => undefined),
|
||||
openAccountSettings: vi.fn(async () => undefined),
|
||||
finishRun: vi.fn(async () => true),
|
||||
isRunCancelled: vi.fn(async () => false),
|
||||
setPushRoutes: vi.fn(async () => undefined),
|
||||
query: vi.fn(async () => JSON.stringify({ columns: ['_id', 'sourceid'], rows: [[1, 'c/a'], [2, null]] })),
|
||||
applyBatch: vi.fn(async () => JSON.stringify({ ok: true, results: [{ id: 5 }, { count: 1 }] })),
|
||||
readSyncState: vi.fn(async (): Promise<string | null> => null),
|
||||
readPhoto: vi.fn(async (): Promise<string | null> => null),
|
||||
showSyncProblem: vi.fn(async () => undefined),
|
||||
clearSyncProblem: vi.fn(async () => undefined),
|
||||
addListener: vi.fn(),
|
||||
removeListeners: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
modules.BulwarkDeviceSync = module;
|
||||
return module;
|
||||
}
|
||||
|
||||
const TWO_OPS: ProviderOp[] = [
|
||||
{ op: 'insert', table: 'raw_contacts', values: { sourceid: null } },
|
||||
{ op: 'update', table: 'data', id: 7, values: { data1: 'x' } },
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
delete modules.BulwarkDeviceSync;
|
||||
(Platform as { OS: string }).OS = 'android';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('module lookup', () => {
|
||||
it('reads NativeModules on every call instead of caching the module', () => {
|
||||
expect(getDeviceSyncModule()).toBeNull();
|
||||
const first = installModule();
|
||||
expect(getDeviceSyncModule()).toBe(first);
|
||||
const second = installModule();
|
||||
expect(getDeviceSyncModule()).toBe(second);
|
||||
delete modules.BulwarkDeviceSync;
|
||||
expect(getDeviceSyncModule()).toBeNull();
|
||||
});
|
||||
|
||||
it('is off on iOS even if a module of that name exists', () => {
|
||||
installModule();
|
||||
(Platform as { OS: string }).OS = 'ios';
|
||||
expect(getDeviceSyncModule()).toBeNull();
|
||||
expect(isDeviceSyncAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it('is available on Android with the module', () => {
|
||||
expect(isDeviceSyncAvailable()).toBe(false);
|
||||
installModule();
|
||||
expect(isDeviceSyncAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects everything, and subscribes nothing, without the module', async () => {
|
||||
const calls: Array<() => Promise<unknown>> = [
|
||||
() => listAndroidAccounts(),
|
||||
() => ensureAndroidAccount(ACCOUNT, 'r'),
|
||||
() => removeAndroidAccount(ACCOUNT),
|
||||
() => getSyncSettings(ACCOUNT),
|
||||
() => setSyncEnabled(ACCOUNT, CONTACTS_AUTHORITY, true),
|
||||
() => setPeriodicSync(ACCOUNT, CONTACTS_AUTHORITY, 3600),
|
||||
() => requestSync(ACCOUNT, CONTACTS_AUTHORITY),
|
||||
() => openAccountSettings(ACCOUNT),
|
||||
() => finishRun('run', {} as RunReport),
|
||||
() => isRunCancelled('run'),
|
||||
() => setPushRoutes({}),
|
||||
() => showSyncProblem(ACCOUNT, 't', 'x', 'bulwarkmobile://login'),
|
||||
() => clearSyncProblem(ACCOUNT),
|
||||
() => createProviderPort(ACCOUNT, CONTACTS_AUTHORITY).query({ table: 'raw_contacts', columns: ['_id'] }),
|
||||
() => createProviderPort(ACCOUNT, CONTACTS_AUTHORITY).applyBatch([]),
|
||||
() => createProviderPort(ACCOUNT, CONTACTS_AUTHORITY).readSyncState(),
|
||||
() => createProviderPort(ACCOUNT, CONTACTS_AUTHORITY).readPhoto(1, 512),
|
||||
];
|
||||
for (const call of calls) {
|
||||
await expect(call()).rejects.toMatchObject({ code: 'unavailable' });
|
||||
}
|
||||
const unsubscribe = onAccountsChanged(() => undefined);
|
||||
expect(() => unsubscribe()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createProviderPort', () => {
|
||||
it('is bound to its account and authority', () => {
|
||||
const port = createProviderPort(ACCOUNT, CALENDAR_AUTHORITY);
|
||||
expect(port.accountName).toBe(ACCOUNT);
|
||||
expect(port.authority).toBe(CALENDAR_AUTHORITY);
|
||||
});
|
||||
|
||||
it('sends a query as JSON and parses the rows', async () => {
|
||||
const module = installModule();
|
||||
const port = createProviderPort(ACCOUNT, CONTACTS_AUTHORITY);
|
||||
const rows = await port.query({ table: 'raw_contacts', columns: ['_id', 'sourceid'], where: 'dirty = ?', args: [1] });
|
||||
expect(rows).toEqual({ columns: ['_id', 'sourceid'], rows: [[1, 'c/a'], [2, null]] });
|
||||
expect(module.query).toHaveBeenCalledWith(
|
||||
ACCOUNT,
|
||||
CONTACTS_AUTHORITY,
|
||||
JSON.stringify({ table: 'raw_contacts', columns: ['_id', 'sourceid'], where: 'dirty = ?', args: [1] }),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on query replies that are not ProviderRows', async () => {
|
||||
const port = createProviderPort(ACCOUNT, CONTACTS_AUTHORITY);
|
||||
const q = { table: 'raw_contacts' as const, columns: ['_id'] };
|
||||
for (const reply of [
|
||||
'not json',
|
||||
JSON.stringify({ columns: ['_id'] }),
|
||||
JSON.stringify({ columns: ['_id'], rows: [[1, 2]] }),
|
||||
JSON.stringify({ columns: ['_id'], rows: [[{ b64: 'AA' }]] }),
|
||||
JSON.stringify({ columns: [7], rows: [] }),
|
||||
42,
|
||||
]) {
|
||||
installModule({ query: vi.fn(async () => reply) });
|
||||
await expect(port.query(q)).rejects.toThrow(/BulwarkDeviceSync\.query/);
|
||||
}
|
||||
});
|
||||
|
||||
it('passes native rejections through with their code', async () => {
|
||||
const scope = Object.assign(new Error('Table instances is not part of com.android.calendar'), { code: 'scope' });
|
||||
installModule({ query: vi.fn(async () => Promise.reject(scope)) });
|
||||
await expect(
|
||||
createProviderPort(ACCOUNT, CALENDAR_AUTHORITY).query({ table: 'events', columns: ['_id'] }),
|
||||
).rejects.toBe(scope);
|
||||
});
|
||||
|
||||
it('sends ops as JSON and returns the batch result', async () => {
|
||||
const module = installModule();
|
||||
const result = await createProviderPort(ACCOUNT, CONTACTS_AUTHORITY).applyBatch(TWO_OPS);
|
||||
expect(result).toEqual({ ok: true, results: [{ id: 5 }, { count: 1 }] });
|
||||
expect(module.applyBatch).toHaveBeenCalledWith(ACCOUNT, CONTACTS_AUTHORITY, JSON.stringify(TWO_OPS));
|
||||
});
|
||||
|
||||
it('returns failed batches as results', async () => {
|
||||
installModule({
|
||||
applyBatch: vi.fn(async () => JSON.stringify({ ok: false, reason: 'assert', message: 'Expected 1 rows but actual 0' })),
|
||||
});
|
||||
await expect(createProviderPort(ACCOUNT, CONTACTS_AUTHORITY).applyBatch(TWO_OPS)).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: 'assert',
|
||||
message: 'Expected 1 rows but actual 0',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on batch replies that are not BatchResults', async () => {
|
||||
const port = createProviderPort(ACCOUNT, CONTACTS_AUTHORITY);
|
||||
for (const reply of [
|
||||
JSON.stringify({ ok: false, reason: 'weird', message: 'x' }),
|
||||
JSON.stringify({ ok: true, results: [{ id: 'five' }, {}] }),
|
||||
JSON.stringify({ ok: true, results: [{ id: 5 }] }),
|
||||
JSON.stringify({ ok: true }),
|
||||
'[]',
|
||||
]) {
|
||||
installModule({ applyBatch: vi.fn(async () => reply) });
|
||||
await expect(port.applyBatch(TWO_OPS)).rejects.toThrow(/BulwarkDeviceSync\.applyBatch/);
|
||||
}
|
||||
});
|
||||
|
||||
it('reads the SyncState as text', async () => {
|
||||
const module = installModule();
|
||||
const port = createProviderPort(ACCOUNT, CALENDAR_AUTHORITY);
|
||||
await expect(port.readSyncState()).resolves.toBeNull();
|
||||
module.readSyncState.mockResolvedValueOnce('{"v":1}');
|
||||
await expect(port.readSyncState()).resolves.toBe('{"v":1}');
|
||||
expect(module.readSyncState).toHaveBeenCalledWith(ACCOUNT, CALENDAR_AUTHORITY);
|
||||
});
|
||||
|
||||
it('reads photos of contacts only', async () => {
|
||||
const module = installModule();
|
||||
const port = createProviderPort(ACCOUNT, CONTACTS_AUTHORITY);
|
||||
await expect(port.readPhoto(3, 512)).resolves.toBeNull();
|
||||
module.readPhoto.mockResolvedValueOnce(JSON.stringify({ jpegBase64: '/9j/', fileId: 12 }));
|
||||
await expect(port.readPhoto(3, 512)).resolves.toEqual({ jpegBase64: '/9j/', fileId: 12 });
|
||||
expect(module.readPhoto).toHaveBeenLastCalledWith(ACCOUNT, 3, 512);
|
||||
module.readPhoto.mockResolvedValueOnce(JSON.stringify({ jpegBase64: '/9j/' }));
|
||||
await expect(port.readPhoto(3, 512)).rejects.toThrow(/readPhoto/);
|
||||
await expect(createProviderPort(ACCOUNT, CALENDAR_AUTHORITY).readPhoto(3, 512)).rejects.toThrow(/contacts/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('accounts and sync settings', () => {
|
||||
it('passes account calls through', async () => {
|
||||
const module = installModule();
|
||||
await expect(listAndroidAccounts()).resolves.toEqual([{ name: ACCOUNT, registryId: 'alice@example.org@mail.example.org' }]);
|
||||
await expect(ensureAndroidAccount(ACCOUNT, 'reg')).resolves.toBe(true);
|
||||
expect(module.ensureAccount).toHaveBeenCalledWith(ACCOUNT, 'reg');
|
||||
await expect(removeAndroidAccount(ACCOUNT)).resolves.toBe(true);
|
||||
expect(module.removeAccount).toHaveBeenCalledWith(ACCOUNT);
|
||||
await expect(getSyncSettings(ACCOUNT)).resolves.toEqual(SETTINGS);
|
||||
await setSyncEnabled(ACCOUNT, CALENDAR_AUTHORITY, false);
|
||||
expect(module.setSyncEnabled).toHaveBeenCalledWith(ACCOUNT, CALENDAR_AUTHORITY, false);
|
||||
await openAccountSettings(ACCOUNT);
|
||||
expect(module.openAccountSettings).toHaveBeenCalledWith(ACCOUNT);
|
||||
});
|
||||
|
||||
it('lets a registry id conflict reject with its code', async () => {
|
||||
const conflict = Object.assign(new Error('belongs to another app account'), { code: 'conflict' });
|
||||
installModule({ ensureAccount: vi.fn(async () => Promise.reject(conflict)) });
|
||||
await expect(ensureAndroidAccount(ACCOUNT, 'other')).rejects.toMatchObject({ code: 'conflict' });
|
||||
});
|
||||
|
||||
it('sends whole seconds for periodic syncs and refuses nonsense', async () => {
|
||||
const module = installModule();
|
||||
await setPeriodicSync(ACCOUNT, CONTACTS_AUTHORITY, 899.6);
|
||||
expect(module.setPeriodicSync).toHaveBeenCalledWith(ACCOUNT, CONTACTS_AUTHORITY, 900);
|
||||
await setPeriodicSync(ACCOUNT, CONTACTS_AUTHORITY, 0);
|
||||
expect(module.setPeriodicSync).toHaveBeenLastCalledWith(ACCOUNT, CONTACTS_AUTHORITY, 0);
|
||||
await expect(setPeriodicSync(ACCOUNT, CONTACTS_AUTHORITY, -1)).rejects.toBeInstanceOf(RangeError);
|
||||
await expect(setPeriodicSync(ACCOUNT, CONTACTS_AUTHORITY, Number.NaN)).rejects.toBeInstanceOf(RangeError);
|
||||
});
|
||||
|
||||
it('sends sync request options as JSON', async () => {
|
||||
const module = installModule();
|
||||
await requestSync(ACCOUNT, CONTACTS_AUTHORITY);
|
||||
expect(module.requestSync).toHaveBeenLastCalledWith(ACCOUNT, CONTACTS_AUTHORITY, '{}');
|
||||
await requestSync(ACCOUNT, CALENDAR_AUTHORITY, { manual: true, expedited: true });
|
||||
expect(module.requestSync).toHaveBeenLastCalledWith(
|
||||
ACCOUNT,
|
||||
CALENDAR_AUTHORITY,
|
||||
JSON.stringify({ manual: true, expedited: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('run handshake', () => {
|
||||
it('sends the report as JSON and returns whether it was accepted', async () => {
|
||||
const module = installModule({ finishRun: vi.fn(async () => false) });
|
||||
const report = { v: 1, runId: 'r1', authority: CONTACTS_AUTHORITY, outcome: 'ok' } as RunReport;
|
||||
await expect(finishRun('r1', report)).resolves.toBe(false);
|
||||
expect(module.finishRun).toHaveBeenCalledWith('r1', JSON.stringify(report));
|
||||
});
|
||||
|
||||
it('asks whether a run was cancelled', async () => {
|
||||
const module = installModule({ isRunCancelled: vi.fn(async () => true) });
|
||||
await expect(isRunCancelled('r1')).resolves.toBe(true);
|
||||
expect(module.isRunCancelled).toHaveBeenCalledWith('r1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('push routes and notifications', () => {
|
||||
it('stores push routes as JSON', async () => {
|
||||
const module = installModule();
|
||||
const routes: Record<string, Array<{ accountName: string; authorities: Authority[] }>> = {
|
||||
c: [{ accountName: ACCOUNT, authorities: [CONTACTS_AUTHORITY, CALENDAR_AUTHORITY] }],
|
||||
};
|
||||
await setPushRoutes(routes);
|
||||
expect(module.setPushRoutes).toHaveBeenCalledWith(JSON.stringify(routes));
|
||||
});
|
||||
|
||||
it('posts and clears the sync problem notification', async () => {
|
||||
const module = installModule();
|
||||
await showSyncProblem(ACCOUNT, 'Sign in again', 'Contacts stopped syncing', 'bulwarkmobile://login');
|
||||
expect(module.showSyncProblem).toHaveBeenLastCalledWith(
|
||||
ACCOUNT,
|
||||
'Sign in again',
|
||||
'Contacts stopped syncing',
|
||||
'bulwarkmobile://login',
|
||||
null,
|
||||
);
|
||||
await showSyncProblem(ACCOUNT, 'Erneut anmelden', 'Kontakte', 'bulwarkmobile://login', 'Synchronisierungsprobleme');
|
||||
expect(module.showSyncProblem).toHaveBeenLastCalledWith(
|
||||
ACCOUNT,
|
||||
'Erneut anmelden',
|
||||
'Kontakte',
|
||||
'bulwarkmobile://login',
|
||||
'Synchronisierungsprobleme',
|
||||
);
|
||||
await clearSyncProblem(ACCOUNT);
|
||||
expect(module.clearSyncProblem).toHaveBeenCalledWith(ACCOUNT);
|
||||
});
|
||||
|
||||
it('listens for account changes until unsubscribed', () => {
|
||||
installModule();
|
||||
const remove = vi.fn();
|
||||
let handler: (() => void) | undefined;
|
||||
const addListener = vi
|
||||
.spyOn(NativeEventEmitter.prototype, 'addListener')
|
||||
.mockImplementation(((event: string, h: () => void) => {
|
||||
expect(event).toBe(ACCOUNTS_CHANGED_EVENT);
|
||||
handler = h;
|
||||
return { remove };
|
||||
}) as never);
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = onAccountsChanged(listener);
|
||||
expect(addListener).toHaveBeenCalledTimes(1);
|
||||
handler?.();
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
unsubscribe();
|
||||
expect(remove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* The device sync mappers, merge rules and engine must stay pure TypeScript:
|
||||
* no react-native, expo, zustand stores or the jmapClient singleton anywhere
|
||||
* in their import graph (docs/device-sync.md, "Architecture"). src/test-setup.ts
|
||||
* mocks those modules globally, so an impure import would not fail any other
|
||||
* test; this one walks the graph instead.
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
/**
|
||||
* Modules allowed to touch React Native: the bridge wrapper, the task entry,
|
||||
* the detached client, and the app-side glue under `app/` (lifecycle,
|
||||
* triggers), which the engine never imports.
|
||||
*/
|
||||
const RN_BOUNDARY = new Set(['native.ts', 'task.ts', path.join('jmap', 'client-port.ts')].map((p) => path.join(ROOT, p)));
|
||||
const RN_BOUNDARY_DIRS = [path.join(ROOT, 'app') + path.sep];
|
||||
const isBoundary = (file: string) => RN_BOUNDARY.has(file) || RN_BOUNDARY_DIRS.some((dir) => file.startsWith(dir));
|
||||
|
||||
const FORBIDDEN = [
|
||||
/^react-native($|\/)/,
|
||||
/^react($|\/)/,
|
||||
/^expo/,
|
||||
/^@expo\//,
|
||||
/^zustand/,
|
||||
/^@react-native/,
|
||||
/^@react-navigation/,
|
||||
];
|
||||
const FORBIDDEN_FILES = [/[\\/]src[\\/]stores[\\/]/, /[\\/]api[\\/]jmap-client\.ts$/, /[\\/]lib[\\/](random|uuid)\.ts$/];
|
||||
|
||||
function sourceFiles(dir: string): string[] {
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
const full = path.join(dir, name);
|
||||
if (statSync(full).isDirectory()) return name === '__tests__' ? [] : sourceFiles(full);
|
||||
return /\.tsx?$/.test(name) ? [full] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function specifiers(file: string): string[] {
|
||||
const src = readFileSync(file, 'utf8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/^\s*\/\/.*$/gm, '');
|
||||
const out: string[] = [];
|
||||
const patterns = [
|
||||
/^\s*import\s+(?!type\s)(?:[^'"]*?\s+from\s+)?['"]([^'"]+)['"]/gm,
|
||||
/^\s*export\s+(?!type\s)[^'"]*?\s+from\s+['"]([^'"]+)['"]/gm,
|
||||
/\brequire\(\s*['"]([^'"]+)['"]\s*\)/g,
|
||||
/\bimport\(\s*['"]([^'"]+)['"]\s*\)/g,
|
||||
];
|
||||
for (const re of patterns) {
|
||||
for (const m of src.matchAll(re)) {
|
||||
// `import { type A, type B } from` is erased too.
|
||||
const clause = m[0];
|
||||
if (/import\s*\{\s*(type\s+[\w$]+\s*,?\s*)+\}\s*from/.test(clause)) continue;
|
||||
out.push(m[1]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolve(from: string, spec: string): string | null {
|
||||
if (!spec.startsWith('.')) return null;
|
||||
const base = path.resolve(path.dirname(from), spec);
|
||||
for (const candidate of [base, `${base}.ts`, `${base}.tsx`, path.join(base, 'index.ts')]) {
|
||||
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function impureChains(entry: string): string[] {
|
||||
const problems: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const walk = (file: string, chain: string[]) => {
|
||||
if (seen.has(file)) return;
|
||||
seen.add(file);
|
||||
for (const spec of specifiers(file)) {
|
||||
const resolved = resolve(file, spec);
|
||||
const at = [...chain, path.relative(ROOT, file)];
|
||||
if (!resolved) {
|
||||
if (FORBIDDEN.some((re) => re.test(spec))) problems.push(`${at.join(' → ')} → ${spec}`);
|
||||
continue;
|
||||
}
|
||||
if (FORBIDDEN_FILES.some((re) => re.test(resolved))) {
|
||||
problems.push(`${at.join(' → ')} → ${path.relative(ROOT, resolved)}`);
|
||||
continue;
|
||||
}
|
||||
walk(resolved, at);
|
||||
}
|
||||
};
|
||||
walk(entry, []);
|
||||
return problems;
|
||||
}
|
||||
|
||||
describe('device sync purity', () => {
|
||||
const entries = sourceFiles(ROOT).filter((f) => !isBoundary(f));
|
||||
|
||||
it('scans the pure modules', () => {
|
||||
expect(entries.map((f) => path.relative(ROOT, f))).toEqual(expect.arrayContaining(['types.ts', path.join('common', 'json.ts')]));
|
||||
});
|
||||
|
||||
it('keeps react-native, expo, stores and the jmapClient singleton out of the mappers, merge rules and engine', () => {
|
||||
const problems = entries.flatMap(impureChains);
|
||||
expect(problems).toEqual([]);
|
||||
});
|
||||
|
||||
it('would catch an impure import', () => {
|
||||
const probe = path.join(ROOT, '..', 'lib', 'calendar-timezone.ts');
|
||||
expect(impureChains(probe).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* Column names and constant values of Android's ContactsContract and
|
||||
* CalendarContract, as the providers spell them. The engine reads and writes
|
||||
* rows by these names through the native bridge (src/device-sync/native.ts),
|
||||
* so a typo here is a column the provider silently ignores or rejects.
|
||||
*
|
||||
* Only what device sync uses is listed. Pure constants: no React Native import.
|
||||
*/
|
||||
|
||||
// ─── ContactsContract ──────────────────────────────────
|
||||
|
||||
export const RawContacts = {
|
||||
_ID: '_id',
|
||||
ACCOUNT_NAME: 'account_name',
|
||||
ACCOUNT_TYPE: 'account_type',
|
||||
SOURCE_ID: 'sourceid',
|
||||
VERSION: 'version',
|
||||
DIRTY: 'dirty',
|
||||
DELETED: 'deleted',
|
||||
SYNC1: 'sync1',
|
||||
SYNC2: 'sync2',
|
||||
SYNC3: 'sync3',
|
||||
SYNC4: 'sync4',
|
||||
RAW_CONTACT_IS_READ_ONLY: 'raw_contact_is_read_only',
|
||||
AGGREGATION_MODE: 'aggregation_mode',
|
||||
STARRED: 'starred',
|
||||
DISPLAY_NAME_PRIMARY: 'display_name',
|
||||
} as const;
|
||||
|
||||
export const Data = {
|
||||
_ID: '_id',
|
||||
RAW_CONTACT_ID: 'raw_contact_id',
|
||||
MIMETYPE: 'mimetype',
|
||||
IS_PRIMARY: 'is_primary',
|
||||
IS_SUPER_PRIMARY: 'is_super_primary',
|
||||
IS_READ_ONLY: 'is_read_only',
|
||||
DATA_VERSION: 'data_version',
|
||||
DATA1: 'data1',
|
||||
DATA2: 'data2',
|
||||
DATA3: 'data3',
|
||||
DATA4: 'data4',
|
||||
DATA5: 'data5',
|
||||
DATA6: 'data6',
|
||||
DATA7: 'data7',
|
||||
DATA8: 'data8',
|
||||
DATA9: 'data9',
|
||||
DATA10: 'data10',
|
||||
DATA11: 'data11',
|
||||
DATA12: 'data12',
|
||||
DATA13: 'data13',
|
||||
DATA14: 'data14',
|
||||
DATA15: 'data15',
|
||||
DATA_SYNC1: 'data_sync1',
|
||||
DATA_SYNC2: 'data_sync2',
|
||||
DATA_SYNC3: 'data_sync3',
|
||||
DATA_SYNC4: 'data_sync4',
|
||||
} as const;
|
||||
|
||||
/** `Data.MIMETYPE` values of the kinds device sync maps. */
|
||||
export const MimeType = {
|
||||
STRUCTURED_NAME: 'vnd.android.cursor.item/name',
|
||||
NICKNAME: 'vnd.android.cursor.item/nickname',
|
||||
EMAIL: 'vnd.android.cursor.item/email_v2',
|
||||
PHONE: 'vnd.android.cursor.item/phone_v2',
|
||||
STRUCTURED_POSTAL: 'vnd.android.cursor.item/postal-address_v2',
|
||||
ORGANIZATION: 'vnd.android.cursor.item/organization',
|
||||
WEBSITE: 'vnd.android.cursor.item/website',
|
||||
EVENT: 'vnd.android.cursor.item/contact_event',
|
||||
RELATION: 'vnd.android.cursor.item/relation',
|
||||
NOTE: 'vnd.android.cursor.item/note',
|
||||
PHOTO: 'vnd.android.cursor.item/photo',
|
||||
GROUP_MEMBERSHIP: 'vnd.android.cursor.item/group_membership',
|
||||
} as const;
|
||||
|
||||
export const StructuredName = {
|
||||
DISPLAY_NAME: 'data1',
|
||||
GIVEN_NAME: 'data2',
|
||||
FAMILY_NAME: 'data3',
|
||||
PREFIX: 'data4',
|
||||
MIDDLE_NAME: 'data5',
|
||||
SUFFIX: 'data6',
|
||||
PHONETIC_GIVEN_NAME: 'data7',
|
||||
PHONETIC_MIDDLE_NAME: 'data8',
|
||||
PHONETIC_FAMILY_NAME: 'data9',
|
||||
FULL_NAME_STYLE: 'data10',
|
||||
PHONETIC_NAME_STYLE: 'data11',
|
||||
} as const;
|
||||
|
||||
/** Columns shared by the typed kinds (Email, Phone, Postal, …). */
|
||||
export const CommonKind = {
|
||||
DATA: 'data1',
|
||||
TYPE: 'data2',
|
||||
LABEL: 'data3',
|
||||
} as const;
|
||||
|
||||
export const Nickname = { NAME: 'data1', TYPE: 'data2', LABEL: 'data3' } as const;
|
||||
export const NicknameType = { CUSTOM: 0, DEFAULT: 1, OTHER_NAME: 2, MAIDEN_NAME: 3, SHORT_NAME: 4, INITIALS: 5 } as const;
|
||||
|
||||
export const Email = { ADDRESS: 'data1', TYPE: 'data2', LABEL: 'data3', DISPLAY_NAME: 'data4' } as const;
|
||||
export const EmailType = { CUSTOM: 0, HOME: 1, WORK: 2, OTHER: 3, MOBILE: 4 } as const;
|
||||
|
||||
export const Phone = { NUMBER: 'data1', TYPE: 'data2', LABEL: 'data3', NORMALIZED_NUMBER: 'data4' } as const;
|
||||
export const PhoneType = {
|
||||
CUSTOM: 0,
|
||||
HOME: 1,
|
||||
MOBILE: 2,
|
||||
WORK: 3,
|
||||
FAX_WORK: 4,
|
||||
FAX_HOME: 5,
|
||||
PAGER: 6,
|
||||
OTHER: 7,
|
||||
CALLBACK: 8,
|
||||
CAR: 9,
|
||||
COMPANY_MAIN: 10,
|
||||
ISDN: 11,
|
||||
MAIN: 12,
|
||||
OTHER_FAX: 13,
|
||||
RADIO: 14,
|
||||
TELEX: 15,
|
||||
TTY_TDD: 16,
|
||||
WORK_MOBILE: 17,
|
||||
WORK_PAGER: 18,
|
||||
ASSISTANT: 19,
|
||||
MMS: 20,
|
||||
} as const;
|
||||
|
||||
export const StructuredPostal = {
|
||||
FORMATTED_ADDRESS: 'data1',
|
||||
TYPE: 'data2',
|
||||
LABEL: 'data3',
|
||||
STREET: 'data4',
|
||||
POBOX: 'data5',
|
||||
NEIGHBORHOOD: 'data6',
|
||||
CITY: 'data7',
|
||||
REGION: 'data8',
|
||||
POSTCODE: 'data9',
|
||||
COUNTRY: 'data10',
|
||||
} as const;
|
||||
export const PostalType = { CUSTOM: 0, HOME: 1, WORK: 2, OTHER: 3 } as const;
|
||||
|
||||
export const Organization = {
|
||||
COMPANY: 'data1',
|
||||
TYPE: 'data2',
|
||||
LABEL: 'data3',
|
||||
TITLE: 'data4',
|
||||
DEPARTMENT: 'data5',
|
||||
JOB_DESCRIPTION: 'data6',
|
||||
SYMBOL: 'data7',
|
||||
PHONETIC_NAME: 'data8',
|
||||
OFFICE_LOCATION: 'data9',
|
||||
} as const;
|
||||
export const OrganizationType = { CUSTOM: 0, WORK: 1, OTHER: 2 } as const;
|
||||
|
||||
export const Website = { URL: 'data1', TYPE: 'data2', LABEL: 'data3' } as const;
|
||||
export const WebsiteType = { CUSTOM: 0, HOMEPAGE: 1, BLOG: 2, PROFILE: 3, HOME: 4, WORK: 5, FTP: 6, OTHER: 7 } as const;
|
||||
|
||||
export const ContactEvent = { START_DATE: 'data1', TYPE: 'data2', LABEL: 'data3' } as const;
|
||||
export const ContactEventType = { CUSTOM: 0, ANNIVERSARY: 1, OTHER: 2, BIRTHDAY: 3 } as const;
|
||||
|
||||
export const Relation = { NAME: 'data1', TYPE: 'data2', LABEL: 'data3' } as const;
|
||||
export const RelationType = {
|
||||
CUSTOM: 0,
|
||||
ASSISTANT: 1,
|
||||
BROTHER: 2,
|
||||
CHILD: 3,
|
||||
DOMESTIC_PARTNER: 4,
|
||||
FATHER: 5,
|
||||
FRIEND: 6,
|
||||
MANAGER: 7,
|
||||
MOTHER: 8,
|
||||
PARENT: 9,
|
||||
PARTNER: 10,
|
||||
REFERRED_BY: 11,
|
||||
RELATIVE: 12,
|
||||
SISTER: 13,
|
||||
SPOUSE: 14,
|
||||
} as const;
|
||||
|
||||
export const Note = { NOTE: 'data1' } as const;
|
||||
|
||||
export const Photo = { PHOTO_FILE_ID: 'data14', PHOTO: 'data15' } as const;
|
||||
|
||||
export const GroupMembership = { GROUP_ROW_ID: 'data1', GROUP_SOURCE_ID: 'group_sourceid' } as const;
|
||||
|
||||
export const Groups = {
|
||||
_ID: '_id',
|
||||
ACCOUNT_NAME: 'account_name',
|
||||
ACCOUNT_TYPE: 'account_type',
|
||||
SOURCE_ID: 'sourceid',
|
||||
VERSION: 'version',
|
||||
DIRTY: 'dirty',
|
||||
DELETED: 'deleted',
|
||||
TITLE: 'title',
|
||||
NOTES: 'notes',
|
||||
GROUP_VISIBLE: 'group_visible',
|
||||
SHOULD_SYNC: 'should_sync',
|
||||
GROUP_IS_READ_ONLY: 'group_is_read_only',
|
||||
SYNC1: 'sync1',
|
||||
SYNC2: 'sync2',
|
||||
SYNC3: 'sync3',
|
||||
SYNC4: 'sync4',
|
||||
} as const;
|
||||
|
||||
export const ContactsSettings = {
|
||||
ACCOUNT_NAME: 'account_name',
|
||||
ACCOUNT_TYPE: 'account_type',
|
||||
UNGROUPED_VISIBLE: 'ungrouped_visible',
|
||||
SHOULD_SYNC: 'should_sync',
|
||||
} as const;
|
||||
|
||||
// ─── CalendarContract ──────────────────────────────────
|
||||
|
||||
export const Calendars = {
|
||||
_ID: '_id',
|
||||
ACCOUNT_NAME: 'account_name',
|
||||
ACCOUNT_TYPE: 'account_type',
|
||||
_SYNC_ID: '_sync_id',
|
||||
DIRTY: 'dirty',
|
||||
NAME: 'name',
|
||||
CALENDAR_DISPLAY_NAME: 'calendar_displayName',
|
||||
CALENDAR_COLOR: 'calendar_color',
|
||||
CALENDAR_COLOR_KEY: 'calendar_color_index',
|
||||
CALENDAR_ACCESS_LEVEL: 'calendar_access_level',
|
||||
OWNER_ACCOUNT: 'ownerAccount',
|
||||
VISIBLE: 'visible',
|
||||
SYNC_EVENTS: 'sync_events',
|
||||
CALENDAR_TIME_ZONE: 'calendar_timezone',
|
||||
ALLOWED_REMINDERS: 'allowedReminders',
|
||||
ALLOWED_AVAILABILITY: 'allowedAvailability',
|
||||
ALLOWED_ATTENDEE_TYPES: 'allowedAttendeeTypes',
|
||||
MAX_REMINDERS: 'maxReminders',
|
||||
CAN_ORGANIZER_RESPOND: 'canOrganizerRespond',
|
||||
CAN_MODIFY_TIME_ZONE: 'canModifyTimeZone',
|
||||
/** Must stay 0: with 1 the provider keeps a LAST_SYNCED copy of every event an app edits. */
|
||||
CAN_PARTIALLY_UPDATE: 'canPartiallyUpdate',
|
||||
IS_PRIMARY: 'isPrimary',
|
||||
CAL_SYNC1: 'cal_sync1',
|
||||
CAL_SYNC2: 'cal_sync2',
|
||||
CAL_SYNC3: 'cal_sync3',
|
||||
} as const;
|
||||
|
||||
/** `Calendars.CALENDAR_ACCESS_LEVEL` values. */
|
||||
export const CalendarAccess = {
|
||||
NONE: 0,
|
||||
FREEBUSY: 100,
|
||||
READ: 200,
|
||||
RESPOND: 300,
|
||||
OVERRIDE: 400,
|
||||
CONTRIBUTOR: 500,
|
||||
EDITOR: 600,
|
||||
OWNER: 700,
|
||||
ROOT: 800,
|
||||
} as const;
|
||||
|
||||
export const Events = {
|
||||
_ID: '_id',
|
||||
CALENDAR_ID: 'calendar_id',
|
||||
ACCOUNT_NAME: 'account_name',
|
||||
ACCOUNT_TYPE: 'account_type',
|
||||
_SYNC_ID: '_sync_id',
|
||||
DIRTY: 'dirty',
|
||||
DELETED: 'deleted',
|
||||
MUTATORS: 'mutators',
|
||||
SYNC_DATA1: 'sync_data1',
|
||||
SYNC_DATA2: 'sync_data2',
|
||||
SYNC_DATA3: 'sync_data3',
|
||||
SYNC_DATA4: 'sync_data4',
|
||||
SYNC_DATA5: 'sync_data5',
|
||||
SYNC_DATA6: 'sync_data6',
|
||||
UID_2445: 'uid2445',
|
||||
TITLE: 'title',
|
||||
DESCRIPTION: 'description',
|
||||
EVENT_LOCATION: 'eventLocation',
|
||||
STATUS: 'eventStatus',
|
||||
AVAILABILITY: 'availability',
|
||||
ACCESS_LEVEL: 'accessLevel',
|
||||
EVENT_COLOR: 'eventColor',
|
||||
EVENT_COLOR_KEY: 'eventColor_index',
|
||||
ORGANIZER: 'organizer',
|
||||
IS_ORGANIZER: 'isOrganizer',
|
||||
SELF_ATTENDEE_STATUS: 'selfAttendeeStatus',
|
||||
HAS_ALARM: 'hasAlarm',
|
||||
HAS_ATTENDEE_DATA: 'hasAttendeeData',
|
||||
GUESTS_CAN_MODIFY: 'guestsCanModify',
|
||||
GUESTS_CAN_INVITE_OTHERS: 'guestsCanInviteOthers',
|
||||
GUESTS_CAN_SEE_GUESTS: 'guestsCanSeeGuests',
|
||||
DTSTART: 'dtstart',
|
||||
DTEND: 'dtend',
|
||||
DURATION: 'duration',
|
||||
EVENT_TIMEZONE: 'eventTimezone',
|
||||
EVENT_END_TIMEZONE: 'eventEndTimezone',
|
||||
ALL_DAY: 'allDay',
|
||||
RRULE: 'rrule',
|
||||
RDATE: 'rdate',
|
||||
EXRULE: 'exrule',
|
||||
EXDATE: 'exdate',
|
||||
LAST_DATE: 'lastDate',
|
||||
ORIGINAL_ID: 'original_id',
|
||||
ORIGINAL_SYNC_ID: 'original_sync_id',
|
||||
ORIGINAL_INSTANCE_TIME: 'originalInstanceTime',
|
||||
ORIGINAL_ALL_DAY: 'originalAllDay',
|
||||
CUSTOM_APP_PACKAGE: 'customAppPackage',
|
||||
CUSTOM_APP_URI: 'customAppUri',
|
||||
} as const;
|
||||
|
||||
export const EventStatus = { TENTATIVE: 0, CONFIRMED: 1, CANCELED: 2 } as const;
|
||||
export const EventAvailability = { BUSY: 0, FREE: 1, TENTATIVE: 2 } as const;
|
||||
export const EventAccess = { DEFAULT: 0, CONFIDENTIAL: 1, PRIVATE: 2, PUBLIC: 3 } as const;
|
||||
|
||||
export const Attendees = {
|
||||
_ID: '_id',
|
||||
EVENT_ID: 'event_id',
|
||||
ATTENDEE_NAME: 'attendeeName',
|
||||
ATTENDEE_EMAIL: 'attendeeEmail',
|
||||
ATTENDEE_RELATIONSHIP: 'attendeeRelationship',
|
||||
ATTENDEE_TYPE: 'attendeeType',
|
||||
ATTENDEE_STATUS: 'attendeeStatus',
|
||||
} as const;
|
||||
export const AttendeeRelationship = { NONE: 0, ATTENDEE: 1, ORGANIZER: 2, PERFORMER: 3, SPEAKER: 4 } as const;
|
||||
export const AttendeeType = { NONE: 0, REQUIRED: 1, OPTIONAL: 2, RESOURCE: 3 } as const;
|
||||
export const AttendeeStatus = { NONE: 0, ACCEPTED: 1, DECLINED: 2, INVITED: 3, TENTATIVE: 4 } as const;
|
||||
|
||||
export const Reminders = {
|
||||
_ID: '_id',
|
||||
EVENT_ID: 'event_id',
|
||||
MINUTES: 'minutes',
|
||||
METHOD: 'method',
|
||||
} as const;
|
||||
export const ReminderMethod = { DEFAULT: 0, ALERT: 1, EMAIL: 2, SMS: 3, ALARM: 4 } as const;
|
||||
|
||||
export const ExtendedProperties = {
|
||||
_ID: '_id',
|
||||
EVENT_ID: 'event_id',
|
||||
NAME: 'name',
|
||||
VALUE: 'value',
|
||||
} as const;
|
||||
|
||||
export const Colors = {
|
||||
_ID: '_id',
|
||||
ACCOUNT_NAME: 'account_name',
|
||||
ACCOUNT_TYPE: 'account_type',
|
||||
COLOR_TYPE: 'color_type',
|
||||
COLOR_KEY: 'color_index',
|
||||
COLOR: 'color',
|
||||
} as const;
|
||||
export const ColorType = { CALENDAR: 0, EVENT: 1 } as const;
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* The gate of every app-side device sync feature (docs/device-sync.md, "App
|
||||
* integration"): Android, and a build that carries the native module. iOS,
|
||||
* and an Android build without the module, behave as if device sync did not
|
||||
* exist.
|
||||
*/
|
||||
import { supportsDeviceSync } from '../../lib/platform-capabilities';
|
||||
import { isDeviceSyncAvailable } from '../native';
|
||||
|
||||
export function deviceSyncAvailable(): boolean {
|
||||
if (!supportsDeviceSync) return false;
|
||||
try {
|
||||
return isDeviceSyncAvailable();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* The address books and calendars an app account can sync, for the chooser in
|
||||
* Settings (docs/device-sync.md, "Settings sections").
|
||||
*
|
||||
* Listed through a JMAPClient of the account's own, never the UI's
|
||||
* singleton: the chooser covers every signed-in account, and re-binding the
|
||||
* singleton would send the UI's requests with another account's credentials.
|
||||
* Collections of the capability's primary account are "personal" (on by
|
||||
* default); every other session account that advertises the capability holds
|
||||
* shared ones (off by default). Calendars without `mayReadItems` and
|
||||
* calendars that hold only tasks are not offered (the engine does not sync
|
||||
* them); the birthday calendar is the app's own and never on the server.
|
||||
*/
|
||||
import { AuthenticationError, JMAPClient, NetworkError } from '../../api/jmap-client';
|
||||
import type { JMAPMethodCall } from '../../api/types';
|
||||
import {
|
||||
findTasksOnlyCalendarIds,
|
||||
SCAN_PROPERTIES,
|
||||
type ScannedCalendarObject,
|
||||
} from '../../lib/calendar-component-detection';
|
||||
import {
|
||||
isCollectionSelected,
|
||||
selectionFor,
|
||||
syncOnInApp,
|
||||
useDeviceSyncStore,
|
||||
type AccountDeviceSync,
|
||||
} from '../../stores/device-sync-store';
|
||||
import { collectionKey } from '../common/ids';
|
||||
import {
|
||||
CALENDAR_AUTHORITY,
|
||||
CONTACTS_AUTHORITY,
|
||||
JMAP_CALENDARS,
|
||||
JMAP_CONTACTS,
|
||||
JMAP_CORE,
|
||||
type Authority,
|
||||
type CollectionKey,
|
||||
} from '../types';
|
||||
|
||||
export interface SyncCollection {
|
||||
/** `<jmapAccountId>/<id>`, as in the selections. */
|
||||
key: CollectionKey;
|
||||
jmapAccountId: string;
|
||||
id: string;
|
||||
name: string;
|
||||
/** The JMAP account's name (who shares it, for a shared collection). */
|
||||
accountName: string;
|
||||
/** In the capability's primary account: synced unless the user turned it off. */
|
||||
isPersonal: boolean;
|
||||
/** No write rights, or (calendars) a subscribed feed. */
|
||||
readOnly: boolean;
|
||||
isDefault: boolean;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/** Why the list could not be loaded. */
|
||||
export type CollectionsErrorKind = 'signedOut' | 'auth' | 'network' | 'server';
|
||||
|
||||
export class CollectionsError extends Error {
|
||||
constructor(readonly kind: CollectionsErrorKind, message: string) {
|
||||
super(message);
|
||||
this.name = 'CollectionsError';
|
||||
}
|
||||
}
|
||||
|
||||
/** The part of JMAPClient the listing uses. */
|
||||
export interface CollectionsClient {
|
||||
loadAccount(registryId: string): Promise<boolean>;
|
||||
readonly currentSession: {
|
||||
primaryAccounts: Record<string, string>;
|
||||
accounts: Record<string, { name: string; isPersonal: boolean; accountCapabilities?: Record<string, unknown> }>;
|
||||
} | null;
|
||||
request(calls: JMAPMethodCall[], using?: string[]): Promise<{ methodResponses: Array<[string, any, string]> }>;
|
||||
getMaxCallsInRequest(): number;
|
||||
}
|
||||
|
||||
export interface ListOptions {
|
||||
/** Skip the cache. */
|
||||
force?: boolean;
|
||||
/** For tests: the client to list with. */
|
||||
client?: CollectionsClient;
|
||||
/** Calendars mirroring subscribed iCal feeds (read-only on the device); `accountId` absent = the primary account. */
|
||||
feeds?: ReadonlyArray<{ calendarId: string; accountId?: string }>;
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 2 * 60_000;
|
||||
/** How many objects of a calendar tell whether it holds only tasks (the engine checks the same). */
|
||||
const TASK_SCAN_LIMIT = 50;
|
||||
|
||||
const ADDRESS_BOOK_PROPERTIES = ['id', 'name', 'isDefault', 'sortOrder', 'myRights'];
|
||||
const CALENDAR_PROPERTIES = ['id', 'name', 'color', 'isDefault', 'sortOrder', 'isSubscribed', 'myRights'];
|
||||
|
||||
const cache = new Map<string, { at: number; collections: SyncCollection[] }>();
|
||||
const inFlight = new Map<string, Promise<SyncCollection[]>>();
|
||||
|
||||
type LoadedListener = (registryId: string, authority: Authority, collections: SyncCollection[]) => void;
|
||||
const loadedListeners = new Set<LoadedListener>();
|
||||
|
||||
const cacheKey = (registryId: string, authority: Authority) => `${registryId}\n${authority}`;
|
||||
|
||||
/**
|
||||
* Calls `listener` with every list loaded from the server, so each view of an
|
||||
* account's collections (the settings row's counts, the chooser) shows the
|
||||
* freshest one. Returns the unsubscribe.
|
||||
*/
|
||||
export function onSyncCollectionsLoaded(listener: LoadedListener): () => void {
|
||||
loadedListeners.add(listener);
|
||||
return () => {
|
||||
loadedListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** The last list loaded for an account, if it is still fresh. */
|
||||
export function cachedSyncCollections(registryId: string, authority: Authority): SyncCollection[] | null {
|
||||
const hit = cache.get(cacheKey(registryId, authority));
|
||||
return hit && Date.now() - hit.at < CACHE_TTL_MS ? hit.collections : null;
|
||||
}
|
||||
|
||||
/** Drops the cached lists of one account, or of all (tests). */
|
||||
export function forgetSyncCollections(registryId?: string): void {
|
||||
for (const key of [...cache.keys()]) {
|
||||
if (!registryId || key.startsWith(`${registryId}\n`)) cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** The collections of one app account for an authority, personal ones first. */
|
||||
export function listSyncCollections(
|
||||
registryId: string,
|
||||
authority: Authority,
|
||||
options: ListOptions = {},
|
||||
): Promise<SyncCollection[]> {
|
||||
const key = cacheKey(registryId, authority);
|
||||
if (!options.force && !options.client) {
|
||||
const hit = cachedSyncCollections(registryId, authority);
|
||||
if (hit) return Promise.resolve(hit);
|
||||
const running = inFlight.get(key);
|
||||
if (running) return running;
|
||||
}
|
||||
const run = load(registryId, authority, options).then((collections) => {
|
||||
cache.set(key, { at: Date.now(), collections });
|
||||
for (const listener of [...loadedListeners]) listener(registryId, authority, collections);
|
||||
return collections;
|
||||
});
|
||||
if (!options.client) {
|
||||
inFlight.set(key, run);
|
||||
void run.finally(() => {
|
||||
if (inFlight.get(key) === run) inFlight.delete(key);
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
async function openClient(registryId: string, client: CollectionsClient): Promise<CollectionsClient> {
|
||||
let ok: boolean;
|
||||
try {
|
||||
ok = await client.loadAccount(registryId);
|
||||
} catch (err) {
|
||||
if (err instanceof AuthenticationError || (err instanceof Error && err.name === 'AuthenticationError')) {
|
||||
throw new CollectionsError('auth', err.message);
|
||||
}
|
||||
if (err instanceof NetworkError || (err instanceof Error && err.name === 'NetworkError')) {
|
||||
throw new CollectionsError('network', err.message);
|
||||
}
|
||||
throw new CollectionsError('server', err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
if (!ok) throw new CollectionsError('signedOut', 'No stored credentials');
|
||||
return client;
|
||||
}
|
||||
|
||||
async function send(
|
||||
client: CollectionsClient,
|
||||
calls: JMAPMethodCall[],
|
||||
using: string[],
|
||||
): Promise<Array<[string, any, string]>> {
|
||||
const out: Array<[string, any, string]> = [];
|
||||
// Result references only point inside one request, so a query and the
|
||||
// get that reads it always go together.
|
||||
const perRequest = Math.max(2, client.getMaxCallsInRequest() - (client.getMaxCallsInRequest() % 2));
|
||||
for (let i = 0; i < calls.length; i += perRequest) {
|
||||
try {
|
||||
const res = await client.request(calls.slice(i, i + perRequest), using);
|
||||
out.push(...(res.methodResponses ?? []));
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AuthenticationError') throw new CollectionsError('auth', err.message);
|
||||
throw new CollectionsError('network', err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function responseOf(responses: Array<[string, any, string]>, callId: string): any {
|
||||
const hit = responses.find((r) => r[2] === callId);
|
||||
return hit && hit[0] !== 'error' ? hit[1] : null;
|
||||
}
|
||||
|
||||
interface RawCollection {
|
||||
id: string;
|
||||
name?: string;
|
||||
color?: string;
|
||||
isDefault?: boolean;
|
||||
sortOrder?: number;
|
||||
myRights?: Record<string, boolean | undefined>;
|
||||
}
|
||||
|
||||
async function load(registryId: string, authority: Authority, options: ListOptions): Promise<SyncCollection[]> {
|
||||
const client = await openClient(registryId, options.client ?? new JMAPClient());
|
||||
const session = client.currentSession;
|
||||
if (!session) throw new CollectionsError('server', 'No session');
|
||||
const capability = authority === CONTACTS_AUTHORITY ? JMAP_CONTACTS : JMAP_CALENDARS;
|
||||
const using = [JMAP_CORE, capability];
|
||||
const capable = Object.entries(session.accounts ?? {})
|
||||
.filter(([, info]) => !!info?.accountCapabilities?.[capability])
|
||||
.map(([id]) => id);
|
||||
const primary = session.primaryAccounts?.[capability] ?? capable[0];
|
||||
if (!primary) return [];
|
||||
const accountIds = [primary, ...capable.filter((id) => id !== primary)];
|
||||
// For the push routes and the trigger filter; only into a store that has
|
||||
// been read, which would otherwise be overwritten.
|
||||
if (useDeviceSyncStore.persist.hasHydrated()
|
||||
&& useDeviceSyncStore.getState().accounts[registryId]?.primaryJmapAccounts?.[authority] !== primary) {
|
||||
useDeviceSyncStore.getState().rememberPrimaryJmapAccount(registryId, authority, primary);
|
||||
}
|
||||
|
||||
const method = authority === CONTACTS_AUTHORITY ? 'AddressBook/get' : 'Calendar/get';
|
||||
const properties = authority === CONTACTS_AUTHORITY ? ADDRESS_BOOK_PROPERTIES : CALENDAR_PROPERTIES;
|
||||
const responses = await send(
|
||||
client,
|
||||
accountIds.map((accountId, i) => [method, { accountId, properties }, `g${i}`]),
|
||||
using,
|
||||
);
|
||||
|
||||
const listed: Array<{ jmapAccountId: string; raw: RawCollection }> = [];
|
||||
accountIds.forEach((jmapAccountId, i) => {
|
||||
const body = responseOf(responses, `g${i}`);
|
||||
// An account that refuses (a revoked share) must not hide the others.
|
||||
if (!body) {
|
||||
if (jmapAccountId === primary) throw new CollectionsError('server', `${method} failed`);
|
||||
return;
|
||||
}
|
||||
for (const raw of (body.list ?? []) as RawCollection[]) {
|
||||
if (raw && typeof raw.id === 'string') listed.push({ jmapAccountId, raw });
|
||||
}
|
||||
});
|
||||
|
||||
let offered = listed;
|
||||
if (authority === CALENDAR_AUTHORITY) {
|
||||
offered = listed.filter(({ raw }) => raw.myRights?.mayReadItems !== false);
|
||||
const taskOnly = await taskOnlyCalendars(client, offered, using);
|
||||
offered = offered.filter(({ jmapAccountId, raw }) => !taskOnly.has(collectionKey(jmapAccountId, raw.id)));
|
||||
}
|
||||
|
||||
const feeds = new Set((options.feeds ?? []).map((f) => collectionKey(f.accountId ?? primary, f.calendarId)));
|
||||
const collections = offered.map(({ jmapAccountId, raw }): SyncCollection => {
|
||||
const key = collectionKey(jmapAccountId, raw.id);
|
||||
const rights = raw.myRights;
|
||||
const readOnly = authority === CONTACTS_AUTHORITY
|
||||
? rights?.mayWrite === false
|
||||
: feeds.has(key) || (!!rights && !rights.mayWriteAll && !rights.mayWriteOwn);
|
||||
return {
|
||||
key,
|
||||
jmapAccountId,
|
||||
id: raw.id,
|
||||
name: raw.name?.trim() || raw.id,
|
||||
accountName: session.accounts?.[jmapAccountId]?.name ?? jmapAccountId,
|
||||
isPersonal: jmapAccountId === primary,
|
||||
readOnly,
|
||||
isDefault: !!raw.isDefault,
|
||||
...(typeof raw.color === 'string' && raw.color ? { color: raw.color } : {}),
|
||||
};
|
||||
});
|
||||
const order = new Map(accountIds.map((id, i) => [id, i]));
|
||||
const sortOf = new Map(offered.map(({ jmapAccountId, raw }) => [collectionKey(jmapAccountId, raw.id), raw.sortOrder ?? 0]));
|
||||
return collections.sort((a, b) =>
|
||||
(order.get(a.jmapAccountId)! - order.get(b.jmapAccountId)!)
|
||||
|| (sortOf.get(a.key)! - sortOf.get(b.key)!)
|
||||
|| a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendars whose first objects are all tasks. One query and one get per
|
||||
* calendar; a calendar whose scan fails is kept (offered).
|
||||
*/
|
||||
async function taskOnlyCalendars(
|
||||
client: CollectionsClient,
|
||||
calendars: Array<{ jmapAccountId: string; raw: RawCollection }>,
|
||||
using: string[],
|
||||
): Promise<Set<CollectionKey>> {
|
||||
const calls: JMAPMethodCall[] = [];
|
||||
calendars.forEach(({ jmapAccountId, raw }, i) => {
|
||||
calls.push(['CalendarEvent/query', { accountId: jmapAccountId, filter: { inCalendar: raw.id }, limit: TASK_SCAN_LIMIT }, `q${i}`]);
|
||||
calls.push([
|
||||
'CalendarEvent/get',
|
||||
{
|
||||
accountId: jmapAccountId,
|
||||
'#ids': { resultOf: `q${i}`, name: 'CalendarEvent/query', path: '/ids' },
|
||||
properties: SCAN_PROPERTIES,
|
||||
},
|
||||
`o${i}`,
|
||||
]);
|
||||
});
|
||||
const out = new Set<CollectionKey>();
|
||||
if (calls.length === 0) return out;
|
||||
let responses: Array<[string, any, string]>;
|
||||
try {
|
||||
responses = await send(client, calls, using);
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
calendars.forEach(({ jmapAccountId, raw }, i) => {
|
||||
const body = responseOf(responses, `o${i}`);
|
||||
if (!body) return;
|
||||
const objects = ((body.list ?? []) as ScannedCalendarObject[])
|
||||
.map((o) => ({ ...o, calendarIds: { [raw.id]: true } }));
|
||||
if (findTasksOnlyCalendarIds(objects, [raw.id]).has(raw.id)) out.add(collectionKey(jmapAccountId, raw.id));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function serverOf(serverUrl: string): string {
|
||||
try {
|
||||
return new URL(serverUrl).host.toLowerCase();
|
||||
} catch {
|
||||
return serverUrl.replace(/^https?:\/\//i, '').replace(/\/.*$/, '').toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The other signed-in accounts on the same server that already sync this
|
||||
* collection (the same JMAP account and id): the chooser warns about the
|
||||
* second copy on the device, it does not refuse it.
|
||||
*/
|
||||
export function otherAccountsSyncing(
|
||||
registryId: string,
|
||||
authority: Authority,
|
||||
collection: Pick<SyncCollection, 'key' | 'jmapAccountId'> & Partial<Pick<SyncCollection, 'name'>>,
|
||||
registry: ReadonlyArray<{ id: string; serverUrl: string; email?: string; username: string }>,
|
||||
accounts: Record<string, AccountDeviceSync>,
|
||||
): string[] {
|
||||
const self = registry.find((a) => a.id === registryId);
|
||||
if (!self) return [];
|
||||
const server = serverOf(self.serverUrl);
|
||||
return registry
|
||||
.filter((other) => other.id !== registryId && serverOf(other.serverUrl) === server)
|
||||
.filter((other) => {
|
||||
const entry = accounts[other.id];
|
||||
if (!entry || !syncOnInApp(entry, authority)) return false;
|
||||
const isPersonal = entry.primaryJmapAccounts?.[authority] === collection.jmapAccountId;
|
||||
return isCollectionSelected(selectionFor(entry, authority), authority, { ...collection, isPersonal });
|
||||
})
|
||||
.map((other) => other.email || other.username);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* The questions device sync asks outside the settings screen, as system
|
||||
* alerts: signing out while changes made on the device have not reached the
|
||||
* server (docs/device-sync.md, "Lifecycle").
|
||||
*/
|
||||
import { Alert } from 'react-native';
|
||||
import { t } from '../../stores/locale-store';
|
||||
|
||||
/**
|
||||
* Asks whether to sign out although `pending` device changes (unknown when
|
||||
* the upload could not even be tried) would be lost. Resolves true to go on.
|
||||
*/
|
||||
export function confirmSignOutLosingChanges(pending: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const answer = (value: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(value);
|
||||
};
|
||||
Alert.alert(
|
||||
t('device_sync.sign_out_pending_title', 'Sign out anyway?'),
|
||||
pending > 0
|
||||
? t(
|
||||
'device_sync.sign_out_pending_message',
|
||||
"{count, plural, one {# change made on this device hasn't reached the server. It is lost if you sign out now.} other {# changes made on this device haven't reached the server. They are lost if you sign out now.}}",
|
||||
{ count: pending },
|
||||
)
|
||||
: t(
|
||||
'device_sync.sign_out_unknown_message',
|
||||
"Changes made on this device may not have reached the server. They are lost if you sign out now.",
|
||||
),
|
||||
[
|
||||
{ text: t('common.cancel', 'Cancel'), style: 'cancel', onPress: () => answer(false) },
|
||||
{ text: t('device_sync.sign_out_anyway', 'Sign out'), style: 'destructive', onPress: () => answer(true) },
|
||||
],
|
||||
{ cancelable: true, onDismiss: () => answer(false) },
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,699 @@
|
||||
/**
|
||||
* Turning device sync on and off, signing out, and keeping Android's accounts
|
||||
* in step with the app's account registry (docs/device-sync.md, "Lifecycle").
|
||||
*
|
||||
* - Enable: permissions (asked by the settings first) → the Android account
|
||||
* → automatic sync on → the periodic sync → a manual sync → push routes and
|
||||
* push types.
|
||||
* - Disable an authority: automatic sync off, then the engine's teardown
|
||||
* uploads what the device changed and deletes the authority's rows. When
|
||||
* changes could not be uploaded nothing is deleted and the caller asks the
|
||||
* user; only a confirmed turn-off deletes them. With both authorities off
|
||||
* the Android account goes.
|
||||
* - Sign-out: the same for every authority, before the credentials go.
|
||||
* - Reconcile (launch, foreground, accounts changed): an Android account whose
|
||||
* app account the registry dropped is suspended (sync off, one notification,
|
||||
* data kept); an app account whose Android account was removed in Android
|
||||
* Settings shows sync off and is never recreated on its own.
|
||||
*
|
||||
* The engine (`../task`) is imported on first use only, so starting the app
|
||||
* does not load it.
|
||||
*/
|
||||
import {
|
||||
clearSyncProblem,
|
||||
ensureAndroidAccount,
|
||||
getSyncSettings,
|
||||
listAndroidAccounts,
|
||||
openAccountSettings,
|
||||
removeAndroidAccount,
|
||||
requestSync,
|
||||
setPeriodicSync,
|
||||
setPushRoutes,
|
||||
setSyncEnabled,
|
||||
showSyncProblem,
|
||||
} from '../native';
|
||||
import {
|
||||
AUTHORITIES,
|
||||
CALENDAR_AUTHORITY,
|
||||
type AndroidAccount,
|
||||
type Authority,
|
||||
type ReminderOwner,
|
||||
} from '../types';
|
||||
import { useAccountStore } from '../../stores/account-store';
|
||||
import {
|
||||
accountDeviceSync,
|
||||
anySyncOnInApp,
|
||||
computePushRoutes,
|
||||
intervalFor,
|
||||
syncOnInApp,
|
||||
useDeviceSyncStore,
|
||||
waitForDeviceSyncHydration,
|
||||
type AccountDeviceSync,
|
||||
} from '../../stores/device-sync-store';
|
||||
import { t } from '../../stores/locale-store';
|
||||
import { toast } from '../../stores/toast-store';
|
||||
import { refreshPushSubscriptionTypes } from '../../lib/push-notifications';
|
||||
import { deviceSyncAvailable } from './available';
|
||||
import { confirmSignOutLosingChanges } from './confirm';
|
||||
import { hasSyncPermissions } from './permissions';
|
||||
import { STATE_TYPES_BY_AUTHORITY } from './prefs';
|
||||
import { cancelDeviceSyncRequests } from './request';
|
||||
|
||||
/** How long the teardown may spend uploading before it gives up (and asks). */
|
||||
export const TEARDOWN_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** After the user agreed to lose what is waiting: one short last try to upload it. */
|
||||
export const FORCED_TEARDOWN_TIMEOUT_MS = 5_000;
|
||||
|
||||
/** Where the "sign in again" notification of a suspended account leads: the accounts pane. */
|
||||
export const SIGN_IN_AGAIN_URI = 'bulwarkmobile://settings/account';
|
||||
|
||||
const REGISTRY_HYDRATION_TIMEOUT_MS = 5000;
|
||||
|
||||
const noop = () => undefined;
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error && err.message ? err.message : String(err);
|
||||
}
|
||||
|
||||
/** The registry, once read; null when it could not be read in time. */
|
||||
async function readRegistry(): Promise<ReturnType<typeof useAccountStore.getState>['accounts'] | null> {
|
||||
const persist = useAccountStore.persist;
|
||||
if (!persist.hasHydrated()) {
|
||||
const hydrated = await new Promise<boolean>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
unsubscribe();
|
||||
resolve(false);
|
||||
}, REGISTRY_HYDRATION_TIMEOUT_MS);
|
||||
const unsubscribe = persist.onFinishHydration(() => {
|
||||
clearTimeout(timer);
|
||||
unsubscribe();
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
if (!hydrated) return null;
|
||||
}
|
||||
return useAccountStore.getState().accounts;
|
||||
}
|
||||
|
||||
// ─── Android account names ─────────────────────────────
|
||||
|
||||
/**
|
||||
* The Android account name for an app account: its login, or its registry id
|
||||
* when another app account's Android account already has that name (the same
|
||||
* login on two servers).
|
||||
*/
|
||||
export function preferredAndroidAccountName(registryId: string, androidAccounts: readonly AndroidAccount[]): string {
|
||||
const login = useAccountStore.getState().getAccountById(registryId)?.username?.trim();
|
||||
if (!login) return registryId;
|
||||
const takenOnDevice = androidAccounts.some((a) => a.name === login && a.registryId !== registryId);
|
||||
const takenInApp = Object.entries(useDeviceSyncStore.getState().accounts)
|
||||
.some(([id, entry]) => id !== registryId && entry.androidAccountName === login);
|
||||
return takenOnDevice || takenInApp ? registryId : login;
|
||||
}
|
||||
|
||||
/** Creates the app account's Android account if needed; resolves its name. */
|
||||
async function ensureAccountFor(registryId: string): Promise<string> {
|
||||
const androidAccounts = await listAndroidAccounts();
|
||||
const own = androidAccounts.find((a) => a.registryId === registryId);
|
||||
if (own) return own.name;
|
||||
const name = preferredAndroidAccountName(registryId, androidAccounts);
|
||||
try {
|
||||
await ensureAndroidAccount(name, registryId);
|
||||
return name;
|
||||
} catch (err) {
|
||||
// The name belongs to another app account after all (the list was stale). Any
|
||||
// other failure fails the turn-on: a second account would show in the apps.
|
||||
if (name === registryId || (err as { code?: unknown } | null)?.code !== 'conflict') throw err;
|
||||
await ensureAndroidAccount(registryId, registryId);
|
||||
return registryId;
|
||||
}
|
||||
}
|
||||
|
||||
async function androidAccountOf(registryId: string): Promise<AndroidAccount | null> {
|
||||
try {
|
||||
return (await listAndroidAccounts()).find((a) => a.registryId === registryId) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Push ──────────────────────────────────────────────
|
||||
|
||||
let lastRoutesJson: string | null = null;
|
||||
|
||||
/** Hands the native push router the JMAP accounts that feed each Android account. */
|
||||
export async function refreshPushRoutes(): Promise<void> {
|
||||
if (!deviceSyncAvailable()) return;
|
||||
await waitForDeviceSyncHydration();
|
||||
const routes = computePushRoutes(useDeviceSyncStore.getState().accounts);
|
||||
const json = JSON.stringify(routes);
|
||||
if (json === lastRoutesJson) return;
|
||||
try {
|
||||
await setPushRoutes(routes);
|
||||
lastRoutesJson = json;
|
||||
} catch {
|
||||
// Kept for the next refresh.
|
||||
}
|
||||
}
|
||||
|
||||
/** Push routes plus the account's push subscription types, after a toggle changed. */
|
||||
async function refreshPushState(registryId: string): Promise<void> {
|
||||
await refreshPushRoutes();
|
||||
await refreshPushSubscriptionTypes(registryId).catch(noop);
|
||||
}
|
||||
|
||||
// ─── One step at a time ────────────────────────────────
|
||||
|
||||
// Turning sync on and off for one app account runs one step after the other:
|
||||
// a turn-on that starts while a turn-off still tears down (the user left the
|
||||
// settings and came back) would otherwise be undone by it, down to the
|
||||
// Android account being removed right after it was turned on again.
|
||||
const accountSteps = new Map<string, Promise<unknown>>();
|
||||
|
||||
function oneAtATime<T>(registryId: string, step: () => Promise<T>): Promise<T> {
|
||||
const next = (accountSteps.get(registryId) ?? Promise.resolve()).catch(noop).then(step);
|
||||
const settled = next.catch(noop);
|
||||
accountSteps.set(registryId, settled);
|
||||
void settled.then(() => {
|
||||
if (accountSteps.get(registryId) === settled) accountSteps.delete(registryId);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
/** One step in the queue of every one of the accounts, taken in a fixed order so two such steps never wait for each other. */
|
||||
function oneAtATimeForAll<T>(registryIds: readonly string[], step: () => Promise<T>): Promise<T> {
|
||||
const [first, ...rest] = registryIds;
|
||||
return first === undefined ? step() : oneAtATime(first, () => oneAtATimeForAll(rest, step));
|
||||
}
|
||||
|
||||
// Sign-outs under way, per app account: true once they released it.
|
||||
const signOuts = new Map<string, Promise<boolean>>();
|
||||
|
||||
/**
|
||||
* A turn-on or turn-off of one app account, in its queue. One asked for
|
||||
* while the account signs out runs after the sign-out, and does nothing
|
||||
* (`released`) when that released the account.
|
||||
*/
|
||||
function accountStep<T>(registryId: string, step: () => Promise<T>, released: T): Promise<T> {
|
||||
const signOut = signOuts.get(registryId);
|
||||
return oneAtATime(registryId, async () => ((await signOut) ? released : step()));
|
||||
}
|
||||
|
||||
// ─── Enable ────────────────────────────────────────────
|
||||
|
||||
export type EnableOutcome =
|
||||
| { kind: 'enabled'; accountName: string }
|
||||
/** The runtime permissions are missing: the caller asks for them first. */
|
||||
| { kind: 'permission' }
|
||||
| { kind: 'failed'; message: string };
|
||||
|
||||
/**
|
||||
* Turns device sync on for one authority of an app account. The caller has
|
||||
* asked for the permissions (and, for calendars, who reminds the user).
|
||||
*/
|
||||
export function enableDeviceSync(registryId: string, authority: Authority): Promise<EnableOutcome> {
|
||||
return accountStep(registryId, () => enable(registryId, authority), { kind: 'failed', message: 'The account was signed out' });
|
||||
}
|
||||
|
||||
async function enable(registryId: string, authority: Authority): Promise<EnableOutcome> {
|
||||
if (!deviceSyncAvailable()) return { kind: 'failed', message: 'Device sync is not available' };
|
||||
if (!(await hasSyncPermissions(authority))) return { kind: 'permission' };
|
||||
await waitForDeviceSyncHydration();
|
||||
let accountName: string | null = null;
|
||||
try {
|
||||
accountName = await ensureAccountFor(registryId);
|
||||
useDeviceSyncStore.getState().update(registryId, {
|
||||
androidAccountName: accountName,
|
||||
removedInAndroidSettings: undefined,
|
||||
suspended: undefined,
|
||||
});
|
||||
await setSyncEnabled(accountName, authority, true);
|
||||
await setPeriodicSync(accountName, authority, intervalFor(accountDeviceSync(registryId), authority));
|
||||
const entry = accountDeviceSync(registryId);
|
||||
useDeviceSyncStore.getState().update(registryId, { enabled: { ...entry.enabled, [authority]: true } });
|
||||
await requestSync(accountName, authority, { manual: true }).catch(noop);
|
||||
void refreshPushState(registryId);
|
||||
return { kind: 'enabled', accountName };
|
||||
} catch (err) {
|
||||
if (accountName) {
|
||||
await setSyncEnabled(accountName, authority, false).catch(noop);
|
||||
// Don't leave an account behind that syncs nothing.
|
||||
if (!anySyncOnInApp(accountDeviceSync(registryId))) {
|
||||
useDeviceSyncStore.getState().update(registryId, { androidAccountName: undefined });
|
||||
await removeAndroidAccount(accountName).catch(noop);
|
||||
}
|
||||
}
|
||||
return { kind: 'failed', message: errorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/** Automatic sync back on after the user kept syncing instead of losing changes. */
|
||||
export function resumeDeviceSync(registryId: string, authority: Authority): Promise<void> {
|
||||
return accountStep(registryId, () => resume(registryId, authority), undefined);
|
||||
}
|
||||
|
||||
async function resume(registryId: string, authority: Authority): Promise<void> {
|
||||
if (!deviceSyncAvailable()) return;
|
||||
await waitForDeviceSyncHydration();
|
||||
const accountName = accountDeviceSync(registryId).androidAccountName ?? (await androidAccountOf(registryId))?.name;
|
||||
if (!accountName) return;
|
||||
await setSyncEnabled(accountName, authority, true).catch(noop);
|
||||
await requestSync(accountName, authority, { manual: true }).catch(noop);
|
||||
}
|
||||
|
||||
// ─── Disable ───────────────────────────────────────────
|
||||
|
||||
export interface TurnOffResult {
|
||||
/** Rows deleted and the authority off in the app. */
|
||||
done: boolean;
|
||||
/** Device changes that could not be uploaded (nothing was deleted). */
|
||||
pending: number;
|
||||
/** The teardown itself failed: whether changes are waiting is unknown. */
|
||||
failed?: boolean;
|
||||
}
|
||||
|
||||
interface TeardownResult {
|
||||
pending: number;
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
// The engine, loaded once on first use (sign-outs tear several accounts down
|
||||
// at the same time).
|
||||
let engine: Promise<typeof import('../task')> | null = null;
|
||||
|
||||
function loadEngine(): Promise<typeof import('../task')> {
|
||||
engine ??= import('../task').catch((err: unknown) => {
|
||||
engine = null;
|
||||
throw err;
|
||||
});
|
||||
return engine;
|
||||
}
|
||||
|
||||
async function teardown(
|
||||
registryId: string,
|
||||
accountName: string,
|
||||
authority: Authority,
|
||||
force: boolean,
|
||||
): Promise<TeardownResult> {
|
||||
try {
|
||||
const { teardownAuthority } = await loadEngine();
|
||||
const result = await teardownAuthority(
|
||||
registryId,
|
||||
accountName,
|
||||
authority,
|
||||
force
|
||||
? { force: true, timeoutMs: FORCED_TEARDOWN_TIMEOUT_MS }
|
||||
: { uploadFirst: true, timeoutMs: TEARDOWN_TIMEOUT_MS },
|
||||
);
|
||||
return { pending: Math.max(0, result?.pending ?? 0), failed: false };
|
||||
} catch (err) {
|
||||
console.warn('[device-sync] teardown failed', errorMessage(err));
|
||||
return { pending: 0, failed: true };
|
||||
}
|
||||
}
|
||||
|
||||
/** The authority's JMAP states, dropped with its rows so no old state passes for an echo. */
|
||||
function withoutAuthorityStates(entry: AccountDeviceSync, authority: Authority): AccountDeviceSync['knownStates'] {
|
||||
if (!entry.knownStates) return undefined;
|
||||
const types = STATE_TYPES_BY_AUTHORITY[authority];
|
||||
const out: Record<string, Record<string, string>> = {};
|
||||
for (const [jmapAccountId, states] of Object.entries(entry.knownStates)) {
|
||||
const kept = Object.fromEntries(Object.entries(states ?? {}).filter(([type]) => !types.includes(type)));
|
||||
if (Object.keys(kept).length > 0) out[jmapAccountId] = kept;
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns device sync off for one authority. Uploads what the device changed
|
||||
* first; when that is not possible, nothing is deleted and the result says
|
||||
* how many changes are waiting: call again with `force` once the user agreed
|
||||
* to lose them, or `resumeDeviceSync` to keep syncing.
|
||||
*/
|
||||
export function disableDeviceSync(
|
||||
registryId: string,
|
||||
authority: Authority,
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<TurnOffResult> {
|
||||
// Released for signing out meanwhile: nothing of it is left on the device.
|
||||
return accountStep(registryId, () => disable(registryId, authority, options), { done: true, pending: 0 });
|
||||
}
|
||||
|
||||
async function disable(registryId: string, authority: Authority, options: { force?: boolean }): Promise<TurnOffResult> {
|
||||
if (!deviceSyncAvailable()) return { done: true, pending: 0 };
|
||||
await waitForDeviceSyncHydration();
|
||||
const store = useDeviceSyncStore.getState();
|
||||
const accountName = accountDeviceSync(registryId).androidAccountName ?? (await androidAccountOf(registryId))?.name;
|
||||
cancelDeviceSyncRequests(registryId, authority);
|
||||
if (!accountName) {
|
||||
store.setEnabled(registryId, authority, false);
|
||||
return { done: true, pending: 0 };
|
||||
}
|
||||
await setSyncEnabled(accountName, authority, false).catch(noop);
|
||||
const result = await teardown(registryId, accountName, authority, !!options.force);
|
||||
if (result.failed || result.pending > 0) {
|
||||
return { done: false, pending: result.pending, failed: result.failed || undefined };
|
||||
}
|
||||
|
||||
const before = accountDeviceSync(registryId);
|
||||
const { [authority]: _lastRun, ...lastRun } = before.lastRun;
|
||||
useDeviceSyncStore.getState().update(registryId, {
|
||||
enabled: { ...before.enabled, [authority]: false },
|
||||
lastRun,
|
||||
knownStates: withoutAuthorityStates(before, authority),
|
||||
// Asked again the next time calendar sync is turned on.
|
||||
...(authority === CALENDAR_AUTHORITY ? { reminderOwner: null } : {}),
|
||||
});
|
||||
if (!anySyncOnInApp(accountDeviceSync(registryId))) {
|
||||
// Forget the name first: the removal fires `accountsChanged`, and the
|
||||
// reconcile must not take it for a removal in Android Settings.
|
||||
useDeviceSyncStore.getState().update(registryId, { androidAccountName: undefined, knownStates: undefined });
|
||||
await removeAndroidAccount(accountName).catch(noop);
|
||||
await clearSyncProblem(accountName).catch(noop);
|
||||
}
|
||||
void refreshPushState(registryId);
|
||||
return { done: true, pending: 0 };
|
||||
}
|
||||
|
||||
// ─── Sign-out ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Before app accounts sign out (`auth-store` logout, logoutAll,
|
||||
* removeAccount): uploads what their devices changed, deletes the synced
|
||||
* rows and removes their Android accounts, while the credentials are still
|
||||
* there. When changes could not be uploaded, `confirm` asks whether to sign
|
||||
* out anyway. Resolves false when the user chose to stay signed in; their
|
||||
* accounts then keep syncing. It is a step of the accounts' own turn-ons and
|
||||
* turn-offs: it waits for those under way, and those asked for meanwhile find
|
||||
* the accounts released and do nothing.
|
||||
*/
|
||||
export function releaseDeviceSyncBeforeSignOut(
|
||||
registryIds: readonly string[],
|
||||
confirm: (pending: number) => Promise<boolean> = confirmSignOutLosingChanges,
|
||||
): Promise<boolean> {
|
||||
if (!deviceSyncAvailable() || registryIds.length === 0) return Promise.resolve(true);
|
||||
const ids = [...new Set(registryIds)].sort();
|
||||
const signOut = oneAtATimeForAll(ids, async () => {
|
||||
try {
|
||||
return await release(registryIds, confirm);
|
||||
} catch (err) {
|
||||
// Never keep the user from signing out over a bug here; an Android
|
||||
// account left behind is suspended by the next reconcile.
|
||||
console.warn('[device-sync] release before sign-out failed', errorMessage(err));
|
||||
return true;
|
||||
}
|
||||
});
|
||||
for (const id of ids) signOuts.set(id, signOut);
|
||||
void signOut.then(() => {
|
||||
for (const id of ids) if (signOuts.get(id) === signOut) signOuts.delete(id);
|
||||
});
|
||||
return signOut;
|
||||
}
|
||||
|
||||
async function release(
|
||||
registryIds: readonly string[],
|
||||
confirm: (pending: number) => Promise<boolean>,
|
||||
): Promise<boolean> {
|
||||
await waitForDeviceSyncHydration();
|
||||
let androidAccounts: AndroidAccount[] = [];
|
||||
try {
|
||||
androidAccounts = await listAndroidAccounts();
|
||||
} catch {
|
||||
androidAccounts = [];
|
||||
}
|
||||
const targets = registryIds.flatMap((registryId) => {
|
||||
const accountName = androidAccounts.find((a) => a.registryId === registryId)?.name
|
||||
?? accountDeviceSync(registryId).androidAccountName;
|
||||
return accountName ? [{ registryId, accountName }] : [];
|
||||
});
|
||||
if (targets.length > 0) {
|
||||
toast.info(t('device_sync.signing_out', 'Removing synced contacts and calendars from this device…'));
|
||||
}
|
||||
|
||||
// Accounts in parallel (each has its own server and run lock), the
|
||||
// authorities of one account one after the other.
|
||||
const leftovers: Array<{ registryId: string; accountName: string; authority: Authority }> = [];
|
||||
let pending = 0;
|
||||
await Promise.all(targets.map(async ({ registryId, accountName }) => {
|
||||
cancelDeviceSyncRequests(registryId);
|
||||
for (const authority of AUTHORITIES) {
|
||||
await setSyncEnabled(accountName, authority, false).catch(noop);
|
||||
const result = await teardown(registryId, accountName, authority, false);
|
||||
if (result.failed || result.pending > 0) {
|
||||
leftovers.push({ registryId, accountName, authority });
|
||||
pending += result.pending;
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
if (leftovers.length > 0) {
|
||||
if (!(await confirm(pending))) {
|
||||
// Staying signed in: sync goes on where it was on.
|
||||
for (const { registryId, accountName } of targets) {
|
||||
const entry = accountDeviceSync(registryId);
|
||||
for (const authority of AUTHORITIES) {
|
||||
if (syncOnInApp(entry, authority)) await setSyncEnabled(accountName, authority, true).catch(noop);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
for (const { registryId, accountName, authority } of leftovers) {
|
||||
await teardown(registryId, accountName, authority, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Forgotten before the removal: its `accountsChanged` must not read as a
|
||||
// removal in Android Settings.
|
||||
for (const registryId of registryIds) {
|
||||
cancelDeviceSyncRequests(registryId);
|
||||
useDeviceSyncStore.getState().forget(registryId);
|
||||
}
|
||||
for (const { accountName } of targets) {
|
||||
await removeAndroidAccount(accountName).catch(noop);
|
||||
await clearSyncProblem(accountName).catch(noop);
|
||||
}
|
||||
void refreshPushRoutes();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Sign-in and reconcile ─────────────────────────────
|
||||
|
||||
/**
|
||||
* After an app account signed in successfully: clears its "sign in again"
|
||||
* notification, resumes a suspended account where it synced, and syncs what
|
||||
* a failed sign-in held back.
|
||||
*/
|
||||
export async function deviceSyncSignedIn(registryId: string): Promise<void> {
|
||||
if (!deviceSyncAvailable()) return;
|
||||
try {
|
||||
await waitForDeviceSyncHydration();
|
||||
const account = await androidAccountOf(registryId);
|
||||
if (!account) return;
|
||||
await clearSyncProblem(account.name).catch(noop);
|
||||
const entry = accountDeviceSync(registryId);
|
||||
useDeviceSyncStore.getState().update(registryId, { androidAccountName: account.name, suspended: undefined });
|
||||
const resumed = accountDeviceSync(registryId);
|
||||
for (const authority of AUTHORITIES) {
|
||||
if (!syncOnInApp(resumed, authority)) continue;
|
||||
if (entry.suspended) await setSyncEnabled(account.name, authority, true).catch(noop);
|
||||
await requestSync(account.name, authority, { manual: true }).catch(noop);
|
||||
}
|
||||
await refreshPushRoutes();
|
||||
} catch {
|
||||
// Best effort: the next reconcile catches up.
|
||||
}
|
||||
}
|
||||
|
||||
/** An Android account whose app account the registry dropped: sync off, one notification, data kept. */
|
||||
async function suspendAccount(account: AndroidAccount & { registryId: string }): Promise<void> {
|
||||
const entry = useDeviceSyncStore.getState().accounts[account.registryId];
|
||||
if (entry?.suspended) return;
|
||||
// Remember what synced, so signing in again resumes exactly that.
|
||||
const enabled: Partial<Record<Authority, boolean>> = { ...entry?.enabled };
|
||||
try {
|
||||
const settings = await getSyncSettings(account.name);
|
||||
for (const authority of AUTHORITIES) {
|
||||
if (settings.authorities?.[authority]?.automatic) enabled[authority] = true;
|
||||
}
|
||||
} catch {
|
||||
// What the app knows will do.
|
||||
}
|
||||
for (const authority of AUTHORITIES) await setSyncEnabled(account.name, authority, false).catch(noop);
|
||||
cancelDeviceSyncRequests(account.registryId);
|
||||
useDeviceSyncStore.getState().update(account.registryId, {
|
||||
androidAccountName: account.name,
|
||||
enabled,
|
||||
suspended: true,
|
||||
});
|
||||
await showSyncProblem(
|
||||
account.name,
|
||||
t('device_sync.signed_out_title', 'Sign in to keep syncing'),
|
||||
t(
|
||||
'device_sync.signed_out_text',
|
||||
'{account} was signed out, so its contacts and calendars on this device no longer sync. Sign in again to resume. Changes made on this device are kept.',
|
||||
{ account: account.name },
|
||||
),
|
||||
SIGN_IN_AGAIN_URI,
|
||||
t('device_sync.problem_channel', 'Sync problems'),
|
||||
).catch(noop);
|
||||
}
|
||||
|
||||
let reconciling: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Brings the app's view in step with Android's accounts: at launch, on
|
||||
* foreground and when accounts of our type change. Concurrent calls share
|
||||
* one pass.
|
||||
*/
|
||||
export function reconcileDeviceAccounts(): Promise<void> {
|
||||
if (!deviceSyncAvailable()) return Promise.resolve();
|
||||
if (!reconciling) {
|
||||
reconciling = reconcile()
|
||||
.catch((err) => console.warn('[device-sync] reconcile failed', errorMessage(err)))
|
||||
.finally(() => {
|
||||
reconciling = null;
|
||||
});
|
||||
}
|
||||
return reconciling;
|
||||
}
|
||||
|
||||
async function reconcile(): Promise<void> {
|
||||
await waitForDeviceSyncHydration();
|
||||
const registry = await readRegistry();
|
||||
// Never act on an empty or unreadable registry: a failed read must not
|
||||
// suspend or forget anything.
|
||||
if (!registry || registry.length === 0) return;
|
||||
const androidAccounts = await listAndroidAccounts();
|
||||
const registryIds = new Set(registry.map((a) => a.id));
|
||||
const store = () => useDeviceSyncStore.getState();
|
||||
|
||||
// Android accounts that are gone: removed in Android Settings (or by the
|
||||
// system). Sync shows off and the app does not recreate them.
|
||||
for (const [registryId, entry] of Object.entries(store().accounts)) {
|
||||
const name = entry.androidAccountName;
|
||||
if (!name || androidAccounts.some((a) => a.name === name && (a.registryId ?? registryId) === registryId)) continue;
|
||||
cancelDeviceSyncRequests(registryId);
|
||||
if (!registryIds.has(registryId)) {
|
||||
// Neither signed in nor on the device any more: nothing left to keep.
|
||||
store().forget(registryId);
|
||||
continue;
|
||||
}
|
||||
store().update(registryId, {
|
||||
androidAccountName: undefined,
|
||||
enabled: {},
|
||||
suspended: undefined,
|
||||
knownStates: undefined,
|
||||
removedInAndroidSettings: anySyncOnInApp(entry) || entry.removedInAndroidSettings || undefined,
|
||||
});
|
||||
void refreshPushSubscriptionTypes(registryId).catch(noop);
|
||||
}
|
||||
|
||||
for (const account of androidAccounts) {
|
||||
const registryId = account.registryId;
|
||||
if (!registryId) continue;
|
||||
if (!registryIds.has(registryId)) {
|
||||
await suspendAccount({ ...account, registryId });
|
||||
continue;
|
||||
}
|
||||
const entry = accountDeviceSync(registryId);
|
||||
if (entry.suspended) {
|
||||
// Signed in again through a path that did not report it.
|
||||
await deviceSyncSignedIn(registryId);
|
||||
continue;
|
||||
}
|
||||
// Android is the source of truth: an authority turned on in Android
|
||||
// Settings counts as on in the app too.
|
||||
const enabled = { ...entry.enabled };
|
||||
let changed = entry.androidAccountName !== account.name || !!entry.removedInAndroidSettings;
|
||||
try {
|
||||
const settings = await getSyncSettings(account.name);
|
||||
for (const authority of AUTHORITIES) {
|
||||
if (settings.authorities?.[authority]?.automatic && !enabled[authority]) {
|
||||
enabled[authority] = true;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Keep what the app knows.
|
||||
}
|
||||
if (changed) {
|
||||
store().update(registryId, { androidAccountName: account.name, enabled, removedInAndroidSettings: undefined });
|
||||
}
|
||||
}
|
||||
await refreshPushRoutes();
|
||||
}
|
||||
|
||||
// ─── Settings actions ──────────────────────────────────
|
||||
|
||||
/** The periodic sync interval (0 = manual only). */
|
||||
export async function changeSyncInterval(registryId: string, authority: Authority, seconds: number): Promise<void> {
|
||||
await waitForDeviceSyncHydration();
|
||||
useDeviceSyncStore.getState().setIntervalSeconds(registryId, authority, seconds);
|
||||
const entry = accountDeviceSync(registryId);
|
||||
if (!deviceSyncAvailable() || !entry.androidAccountName || !syncOnInApp(entry, authority)) return;
|
||||
await setPeriodicSync(entry.androidAccountName, authority, intervalFor(entry, authority)).catch(noop);
|
||||
}
|
||||
|
||||
/** Who reminds the user of synced events; a change rewrites the device's reminders with a calendar sync. */
|
||||
export async function changeReminderOwner(registryId: string, owner: ReminderOwner): Promise<void> {
|
||||
await waitForDeviceSyncHydration();
|
||||
const before = accountDeviceSync(registryId).reminderOwner;
|
||||
useDeviceSyncStore.getState().setReminderOwner(registryId, owner);
|
||||
const entry = accountDeviceSync(registryId);
|
||||
if (before === owner || !deviceSyncAvailable() || !entry.androidAccountName) return;
|
||||
if (!syncOnInApp(entry, CALENDAR_AUTHORITY)) return;
|
||||
await requestSync(entry.androidAccountName, CALENDAR_AUTHORITY, { manual: true }).catch(noop);
|
||||
}
|
||||
|
||||
/** A sync right away: manual and expedited. Resolves false when the account has no Android account. */
|
||||
export async function syncNow(registryId: string, authority: Authority): Promise<boolean> {
|
||||
const accountName = accountDeviceSync(registryId).androidAccountName;
|
||||
if (!deviceSyncAvailable() || !accountName) return false;
|
||||
await requestSync(accountName, authority, { manual: true, expedited: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a run held back many deletions made on the device
|
||||
* (`tooManyDeletions`): delete those items on the server too, or bring them
|
||||
* back on the device from the server. Android's own screens offer neither.
|
||||
*/
|
||||
export async function resolveDeletions(registryId: string, authority: Authority, choice: 'delete' | 'restore'): Promise<boolean> {
|
||||
const accountName = accountDeviceSync(registryId).androidAccountName;
|
||||
if (!deviceSyncAvailable() || !accountName) return false;
|
||||
await requestSync(
|
||||
accountName,
|
||||
authority,
|
||||
choice === 'delete' ? { manual: true, overrideTooManyDeletions: true } : { manual: true, discardLocalDeletions: true },
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Android's sync screen of the account ("Android account settings"). */
|
||||
export async function openAndroidAccountSettings(registryId: string): Promise<boolean> {
|
||||
const accountName = accountDeviceSync(registryId).androidAccountName;
|
||||
if (!deviceSyncAvailable() || !accountName) return false;
|
||||
await openAccountSettings(accountName);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops a suspended account (one the app signed out while it synced) from
|
||||
* this device: its Android account and, with it, every synced row. Changes
|
||||
* made on the device that never reached the server are lost; the caller
|
||||
* asks first.
|
||||
*/
|
||||
export async function removeSuspendedAccount(registryId: string): Promise<void> {
|
||||
if (!deviceSyncAvailable()) return;
|
||||
await waitForDeviceSyncHydration();
|
||||
const accountName = accountDeviceSync(registryId).androidAccountName ?? (await androidAccountOf(registryId))?.name;
|
||||
cancelDeviceSyncRequests(registryId);
|
||||
// Forgotten first, so the removal's `accountsChanged` finds nothing to mark.
|
||||
useDeviceSyncStore.getState().forget(registryId);
|
||||
if (accountName) {
|
||||
await removeAndroidAccount(accountName).catch(noop);
|
||||
await clearSyncProblem(accountName).catch(noop);
|
||||
}
|
||||
void refreshPushRoutes();
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Runtime permissions device sync needs (docs/device-sync.md, "Settings
|
||||
* sections"): read and write access to contacts or calendars. Without them
|
||||
* Android fails every sync before our code runs, so the settings check them
|
||||
* themselves.
|
||||
*/
|
||||
import { Linking, PermissionsAndroid } from 'react-native';
|
||||
import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY, type Authority } from '../types';
|
||||
|
||||
type Permission = Parameters<typeof PermissionsAndroid.check>[0];
|
||||
|
||||
export const SYNC_PERMISSIONS: Record<Authority, readonly Permission[]> = {
|
||||
[CONTACTS_AUTHORITY]: ['android.permission.READ_CONTACTS', 'android.permission.WRITE_CONTACTS'],
|
||||
[CALENDAR_AUTHORITY]: ['android.permission.READ_CALENDAR', 'android.permission.WRITE_CALENDAR'],
|
||||
};
|
||||
|
||||
/**
|
||||
* `granted`: both permissions held. `denied`: the user said no; asking again
|
||||
* shows the dialog again. `blocked`: Android no longer asks ("don't ask
|
||||
* again", or denied twice), only the app's system settings can grant them.
|
||||
*/
|
||||
export type PermissionOutcome = 'granted' | 'denied' | 'blocked';
|
||||
|
||||
export async function hasSyncPermissions(authority: Authority): Promise<boolean> {
|
||||
try {
|
||||
const held = await Promise.all(SYNC_PERMISSIONS[authority].map((p) => PermissionsAndroid.check(p)));
|
||||
return held.every(Boolean);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestSyncPermissions(authority: Authority): Promise<PermissionOutcome> {
|
||||
if (await hasSyncPermissions(authority)) return 'granted';
|
||||
const wanted = SYNC_PERMISSIONS[authority];
|
||||
try {
|
||||
const results = (await PermissionsAndroid.requestMultiple([...wanted])) as Record<string, string | undefined>;
|
||||
const answers = wanted.map((p) => results[p]);
|
||||
if (answers.every((a) => a === 'granted')) return 'granted';
|
||||
if (answers.some((a) => a === 'never_ask_again')) return 'blocked';
|
||||
return 'denied';
|
||||
} catch {
|
||||
return 'denied';
|
||||
}
|
||||
}
|
||||
|
||||
/** The app's page in Android's settings, where blocked permissions are granted. */
|
||||
export async function openAppSystemSettings(): Promise<void> {
|
||||
try {
|
||||
await Linking.openSettings();
|
||||
} catch {
|
||||
// Nothing else to offer.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Pure helpers over the persisted device sync preferences, shared by the
|
||||
* store (src/stores/device-sync-store.ts) and by code that must not create
|
||||
* the store: the push setup reads the persisted JSON straight from
|
||||
* AsyncStorage (docs/device-sync.md, "Triggers").
|
||||
*
|
||||
* Types only; nothing here may import react-native or a store.
|
||||
*/
|
||||
import {
|
||||
AUTHORITIES,
|
||||
CALENDAR_AUTHORITY,
|
||||
CONTACTS_AUTHORITY,
|
||||
type Authority,
|
||||
} from '../types';
|
||||
|
||||
/** AsyncStorage key of the device sync store (zustand `persist` name). */
|
||||
export const DEVICE_SYNC_STORAGE_KEY = 'device-sync:v1';
|
||||
|
||||
/** The fields that say whether an account syncs an authority. */
|
||||
export interface SyncSwitches {
|
||||
/** Authorities the user turned on in the app. */
|
||||
enabled?: Partial<Record<Authority, boolean>>;
|
||||
/** The Android account was removed in Android Settings. */
|
||||
removedInAndroidSettings?: boolean;
|
||||
/** The app dropped the account from its registry; automatic sync is off until the user signs in again. */
|
||||
suspended?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the app has device sync on for this authority: turned on in the
|
||||
* app and not removed in Android Settings since. Android's own toggle can
|
||||
* still pause it (see `getSyncAutomatically`).
|
||||
*/
|
||||
export function syncOnInApp(entry: SyncSwitches | undefined | null, authority: Authority): boolean {
|
||||
return !!entry && !!entry.enabled?.[authority] && !entry.removedInAndroidSettings && !entry.suspended;
|
||||
}
|
||||
|
||||
/** JMAP data types whose changes a synced authority cares about. */
|
||||
export const STATE_TYPES_BY_AUTHORITY: Record<Authority, readonly string[]> = {
|
||||
[CONTACTS_AUTHORITY]: ['ContactCard', 'AddressBook'],
|
||||
[CALENDAR_AUTHORITY]: ['CalendarEvent', 'Calendar'],
|
||||
};
|
||||
|
||||
/** The authority a JMAP data type belongs to, if any. */
|
||||
export function authorityOfType(type: string): Authority | null {
|
||||
for (const authority of AUTHORITIES) {
|
||||
if (STATE_TYPES_BY_AUTHORITY[authority].includes(type)) return authority;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push types device sync adds to an account's push subscription:
|
||||
* `ContactCard`, `AddressBook` while contacts sync, `CalendarEvent`,
|
||||
* `Calendar` while calendars sync.
|
||||
*/
|
||||
export function deviceSyncPushTypes(entry: SyncSwitches | undefined | null): string[] {
|
||||
const types: string[] = [];
|
||||
for (const authority of AUTHORITIES) {
|
||||
if (syncOnInApp(entry, authority)) types.push(...STATE_TYPES_BY_AUTHORITY[authority]);
|
||||
}
|
||||
return types;
|
||||
}
|
||||
|
||||
/**
|
||||
* The persisted accounts map from the store's JSON (`{ state: { accounts } }`),
|
||||
* or an empty map when it is missing or unreadable.
|
||||
*/
|
||||
export function persistedAccounts(raw: string | null): Record<string, SyncSwitches> {
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { state?: { accounts?: unknown } } | null;
|
||||
const accounts = parsed?.state?.accounts;
|
||||
return accounts && typeof accounts === 'object' && !Array.isArray(accounts)
|
||||
? (accounts as Record<string, SyncSwitches>)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Who reminds the user of synced events (docs/device-sync.md, "Reminder
|
||||
* owner"). When an account's calendars sync to the device and the calendar
|
||||
* app owns the reminders, the device fires them from its Reminders rows, so
|
||||
* Bulwark's own scheduler skips the events of those calendars. Tasks are not
|
||||
* synced and keep Bulwark's reminders.
|
||||
*
|
||||
* Only while the device calendar receives the server's events, as Android
|
||||
* last said: not while the account's calendar sync is paused in Android's
|
||||
* settings, the device's auto-sync is off (Android then runs manual syncs
|
||||
* only, and the periodic, pushed and app-triggered ones are not manual), or
|
||||
* the calendar permission is revoked. Bulwark reminds then, and until
|
||||
* Android's side was read: a double reminder is better than a missed one.
|
||||
*/
|
||||
import type { CalendarEvent } from '../../api/types';
|
||||
import { getSyncSettings } from '../native';
|
||||
import {
|
||||
isCollectionSelected,
|
||||
selectionFor,
|
||||
syncOnInApp,
|
||||
useDeviceSyncStore,
|
||||
waitForDeviceSyncHydration,
|
||||
type AccountDeviceSync,
|
||||
} from '../../stores/device-sync-store';
|
||||
import { collectionKey } from '../common/ids';
|
||||
import { CALENDAR_AUTHORITY } from '../types';
|
||||
import { deviceSyncAvailable } from './available';
|
||||
import { hasSyncPermissions } from './permissions';
|
||||
|
||||
/** Whether the device's calendar app fires the reminders of an account's synced events. */
|
||||
function deviceOwnsReminders(entry: AccountDeviceSync | undefined): entry is AccountDeviceSync {
|
||||
// Unasked means the calendar app, as the engine writes Reminders rows then.
|
||||
return !!entry && syncOnInApp(entry, CALENDAR_AUTHORITY) && (entry.reminderOwner ?? 'device') === 'device';
|
||||
}
|
||||
|
||||
// ─── What Android says ─────────────────────────────────
|
||||
|
||||
/** App accounts whose device calendar receives the server's events, as last read from Android. */
|
||||
let receiving = new Set<string>();
|
||||
const androidListeners = new Set<() => void>();
|
||||
let reads = 0;
|
||||
|
||||
async function calendarReceives(accountName: string | undefined): Promise<boolean> {
|
||||
if (!accountName) return false;
|
||||
try {
|
||||
const settings = await getSyncSettings(accountName);
|
||||
return settings.masterAutomatic !== false && !!settings.authorities?.[CALENDAR_AUTHORITY]?.automatic;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads from Android whose device calendars receive the server's events;
|
||||
* a change calls the `onDeviceRemindersChange` listeners. Only the latest
|
||||
* read lands.
|
||||
*/
|
||||
async function readAndroid(): Promise<void> {
|
||||
const read = ++reads;
|
||||
await waitForDeviceSyncHydration();
|
||||
const owners = Object.entries(useDeviceSyncStore.getState().accounts).filter(([, entry]) => deviceOwnsReminders(entry));
|
||||
const next = new Set<string>();
|
||||
if (owners.length > 0 && (await hasSyncPermissions(CALENDAR_AUTHORITY))) {
|
||||
for (const [registryId, entry] of owners) {
|
||||
if (await calendarReceives(entry.androidAccountName)) next.add(registryId);
|
||||
}
|
||||
}
|
||||
if (read !== reads) return;
|
||||
if (next.size === receiving.size && [...next].every((id) => receiving.has(id))) return;
|
||||
receiving = next;
|
||||
for (const listener of [...androidListeners]) listener();
|
||||
}
|
||||
|
||||
// ─── The filter ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A test for the events whose reminders the device's calendar app fires for
|
||||
* this app account, so Bulwark schedules none; null when there are none.
|
||||
* `primaryJmapAccountId`: the JMAP account the app's calendar store loads
|
||||
* events from when they carry no `accountId` (the user's own). Android's side
|
||||
* is read again each time, for the next time: a change calls the
|
||||
* `onDeviceRemindersChange` listeners.
|
||||
*/
|
||||
export function remindedOnDevice(
|
||||
registryId: string | null | undefined,
|
||||
primaryJmapAccountId: string | null | undefined,
|
||||
): ((event: CalendarEvent) => boolean) | null {
|
||||
if (!registryId || !deviceSyncAvailable()) return null;
|
||||
const entry = useDeviceSyncStore.getState().accounts[registryId];
|
||||
if (!deviceOwnsReminders(entry)) return null;
|
||||
void readAndroid();
|
||||
if (!receiving.has(registryId)) return null;
|
||||
const selection = selectionFor(entry, CALENDAR_AUTHORITY);
|
||||
const personal = entry.primaryJmapAccounts?.[CALENDAR_AUTHORITY] ?? primaryJmapAccountId ?? null;
|
||||
return (event) => {
|
||||
// Events of shared calendars carry their account and a namespaced copy
|
||||
// of calendarIds; the raw ids are in originalCalendarIds.
|
||||
const jmapAccountId = event.accountId ?? primaryJmapAccountId;
|
||||
if (!jmapAccountId) return false;
|
||||
const calendarIds = event.originalCalendarIds ?? event.calendarIds ?? {};
|
||||
return Object.entries(calendarIds).some(([calendarId, member]) =>
|
||||
!!member && isCollectionSelected(selection, CALENDAR_AUTHORITY, {
|
||||
key: collectionKey(jmapAccountId, calendarId),
|
||||
isPersonal: jmapAccountId === personal,
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
// What decides which events the device reminds of, as one comparable string.
|
||||
function reminderSignature(accounts: Record<string, AccountDeviceSync>): string {
|
||||
const parts: unknown[] = [];
|
||||
for (const registryId of Object.keys(accounts).sort()) {
|
||||
const entry = accounts[registryId];
|
||||
if (!deviceOwnsReminders(entry)) continue;
|
||||
parts.push([registryId, entry.calendarSelection, entry.primaryJmapAccounts?.[CALENDAR_AUTHORITY] ?? null]);
|
||||
}
|
||||
return JSON.stringify(parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `listener` whenever the set of events the device reminds of may have
|
||||
* changed (reminder owner, calendar sync on or off, the calendar selection,
|
||||
* whether Android lets the device calendar sync). Android's side is read
|
||||
* right away, so the first reminders are scheduled with it known. Returns
|
||||
* the unsubscribe.
|
||||
*/
|
||||
export function onDeviceRemindersChange(listener: () => void): () => void {
|
||||
let last = reminderSignature(useDeviceSyncStore.getState().accounts);
|
||||
const unsubscribe = useDeviceSyncStore.subscribe((state) => {
|
||||
const next = reminderSignature(state.accounts);
|
||||
if (next === last) return;
|
||||
last = next;
|
||||
listener();
|
||||
});
|
||||
const android = () => listener();
|
||||
androidListeners.add(android);
|
||||
if (deviceSyncAvailable()) void readAndroid();
|
||||
return () => {
|
||||
unsubscribe();
|
||||
androidListeners.delete(android);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Asks Android to sync one account's contacts or calendars because something
|
||||
* changed while the app runs (docs/device-sync.md, "Triggers"): a StateChange
|
||||
* on the live stream, the app's own contact or calendar edit, the app coming
|
||||
* back to the foreground.
|
||||
*
|
||||
* Requests are coalesced per account and authority: the first one arms a
|
||||
* 5-second timer and the rest ride along, so an edit and the StateChange
|
||||
* echoing it make one sync. A request caused only by StateChanges is dropped
|
||||
* when the timer fires if the engine has synced exactly those states in the
|
||||
* meantime (its own uploads echo back).
|
||||
*
|
||||
* The request is an ordinary (not manual) one: Android drops it while the
|
||||
* user has paused the account or the device's auto-sync is off.
|
||||
*/
|
||||
import { requestSync } from '../native';
|
||||
import type { Authority } from '../types';
|
||||
import { useAccountStore } from '../../stores/account-store';
|
||||
import { isKnownState, syncOnInApp, useDeviceSyncStore } from '../../stores/device-sync-store';
|
||||
import { deviceSyncAvailable } from './available';
|
||||
|
||||
export const TRIGGER_DELAY_MS = 5000;
|
||||
|
||||
/** The StateChange that asked for a sync. */
|
||||
export interface StateReason {
|
||||
jmapAccountId: string;
|
||||
type: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
reasons: StateReason[];
|
||||
/** Asked for by something other than a StateChange: never skipped as an echo. */
|
||||
unconditional: boolean;
|
||||
}
|
||||
|
||||
const pending = new Map<string, Pending>();
|
||||
|
||||
const slotKey = (registryId: string, authority: Authority) => `${registryId}\n${authority}`;
|
||||
|
||||
/**
|
||||
* Request a sync of `authority` for an app account (the active one when
|
||||
* `registryId` is omitted), coalesced for 5 seconds. A no-op unless the
|
||||
* account has device sync on for the authority.
|
||||
*/
|
||||
export function requestDeviceSync(authority: Authority, registryId?: string | null, reason?: StateReason): void {
|
||||
if (!deviceSyncAvailable()) return;
|
||||
const id = registryId ?? useAccountStore.getState().activeAccountId;
|
||||
if (!id) return;
|
||||
if (!syncOnInApp(useDeviceSyncStore.getState().accounts[id], authority)) return;
|
||||
const key = slotKey(id, authority);
|
||||
let slot = pending.get(key);
|
||||
if (!slot) {
|
||||
slot = {
|
||||
timer: setTimeout(() => fire(key, id, authority), TRIGGER_DELAY_MS),
|
||||
reasons: [],
|
||||
unconditional: false,
|
||||
};
|
||||
pending.set(key, slot);
|
||||
}
|
||||
if (reason) slot.reasons.push(reason);
|
||||
else slot.unconditional = true;
|
||||
}
|
||||
|
||||
function fire(key: string, registryId: string, authority: Authority): void {
|
||||
const slot = pending.get(key);
|
||||
pending.delete(key);
|
||||
if (!slot) return;
|
||||
const entry = useDeviceSyncStore.getState().accounts[registryId];
|
||||
const accountName = entry?.androidAccountName;
|
||||
if (!accountName || !syncOnInApp(entry, authority)) return;
|
||||
const echoesOnly = !slot.unconditional
|
||||
&& slot.reasons.every((r) => isKnownState(registryId, r.jmapAccountId, r.type, r.state));
|
||||
if (echoesOnly) return;
|
||||
void Promise.resolve()
|
||||
.then(() => requestSync(accountName, authority, {}))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops waiting requests: of one authority of an account, of every authority
|
||||
* of an account, or all of them. Used before sync is turned off.
|
||||
*/
|
||||
export function cancelDeviceSyncRequests(registryId?: string, authority?: Authority): void {
|
||||
for (const [key, slot] of pending) {
|
||||
if (registryId && authority && key !== slotKey(registryId, authority)) continue;
|
||||
if (registryId && !key.startsWith(`${registryId}\n`)) continue;
|
||||
clearTimeout(slot.timer);
|
||||
pending.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* What makes the running app ask Android for device syncs
|
||||
* (docs/device-sync.md, "Triggers"), started once from the app's launch
|
||||
* effect:
|
||||
*
|
||||
* - StateChanges on the live stream for `AddressBook`/`ContactCard` and
|
||||
* `Calendar`/`CalendarEvent` (the stream serves the active account), except
|
||||
* the states the engine synced itself;
|
||||
* - the app returning to the foreground, and the launch itself: every account
|
||||
* with sync on, after the accounts were reconciled with Android's;
|
||||
* - accounts of our type changing (removed in Android Settings);
|
||||
* - the preferences changing: the native push router's routes follow them.
|
||||
*
|
||||
* The app's own contact and calendar edits call `requestDeviceSync` from the
|
||||
* stores. Every request is coalesced per account and authority (./request).
|
||||
*/
|
||||
import { AppState, type AppStateStatus } from 'react-native';
|
||||
import { onAccountsChanged } from '../native';
|
||||
import { AUTHORITIES, type Authority } from '../types';
|
||||
import { onStateChangeType } from '../../lib/state-change-bus';
|
||||
import { useAccountStore } from '../../stores/account-store';
|
||||
import {
|
||||
feedingJmapAccounts,
|
||||
isKnownState,
|
||||
syncOnInApp,
|
||||
useDeviceSyncStore,
|
||||
} from '../../stores/device-sync-store';
|
||||
import { deviceSyncAvailable } from './available';
|
||||
import { reconcileDeviceAccounts, refreshPushRoutes } from './lifecycle';
|
||||
import { STATE_TYPES_BY_AUTHORITY } from './prefs';
|
||||
import { requestDeviceSync } from './request';
|
||||
|
||||
/** Foreground syncs of one account and authority at most this often. */
|
||||
export const FOREGROUND_MIN_INTERVAL_MS = 60_000;
|
||||
const ROUTES_DELAY_MS = 1000;
|
||||
|
||||
let stopTriggers: (() => void) | null = null;
|
||||
const lastForegroundRequest = new Map<string, number>();
|
||||
|
||||
/** A StateChange of the active account's stream. */
|
||||
export function handleStateChange(authority: Authority, type: string, jmapAccountId: string, state: string): void {
|
||||
const registryId = useAccountStore.getState().activeAccountId;
|
||||
if (!registryId) return;
|
||||
const entry = useDeviceSyncStore.getState().accounts[registryId];
|
||||
if (!entry || !syncOnInApp(entry, authority)) return;
|
||||
// The engine's own upload echoing back.
|
||||
if (isKnownState(registryId, jmapAccountId, type, state)) return;
|
||||
// A shared account nothing is synced from, once the app knows which
|
||||
// accounts feed the device.
|
||||
if (entry.primaryJmapAccounts?.[authority] && !feedingJmapAccounts(entry, authority).includes(jmapAccountId)) return;
|
||||
requestDeviceSync(authority, registryId, { jmapAccountId, type, state });
|
||||
}
|
||||
|
||||
/** Launch and every return to the foreground. */
|
||||
export async function handleForeground(now: number = Date.now()): Promise<void> {
|
||||
await reconcileDeviceAccounts();
|
||||
const { accounts } = useDeviceSyncStore.getState();
|
||||
for (const account of useAccountStore.getState().accounts) {
|
||||
for (const authority of AUTHORITIES) {
|
||||
if (!syncOnInApp(accounts[account.id], authority)) continue;
|
||||
const key = `${account.id}\n${authority}`;
|
||||
const last = lastForegroundRequest.get(key);
|
||||
if (last !== undefined && now - last < FOREGROUND_MIN_INTERVAL_MS) continue;
|
||||
lastForegroundRequest.set(key, now);
|
||||
requestDeviceSync(authority, account.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts the triggers; idempotent. Returns the stop function (for tests). */
|
||||
export function startDeviceSyncTriggers(): () => void {
|
||||
if (stopTriggers) return stopTriggers;
|
||||
if (!deviceSyncAvailable()) return () => undefined;
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
for (const authority of AUTHORITIES) {
|
||||
for (const type of STATE_TYPES_BY_AUTHORITY[authority]) {
|
||||
cleanups.push(onStateChangeType(type, (jmapAccountId, state) => {
|
||||
handleStateChange(authority, type, jmapAccountId, state);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
let appState: AppStateStatus = AppState.currentState;
|
||||
const appStateSubscription = AppState.addEventListener('change', (next) => {
|
||||
const cameBack = next === 'active' && appState !== 'active';
|
||||
appState = next;
|
||||
if (cameBack) void handleForeground();
|
||||
});
|
||||
cleanups.push(() => appStateSubscription.remove());
|
||||
|
||||
try {
|
||||
cleanups.push(onAccountsChanged(() => { void reconcileDeviceAccounts(); }));
|
||||
} catch {
|
||||
// Without the event, launch and foreground still reconcile.
|
||||
}
|
||||
|
||||
let routesTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
cleanups.push(useDeviceSyncStore.subscribe((state, prev) => {
|
||||
if (state.accounts === prev.accounts) return;
|
||||
if (routesTimer) clearTimeout(routesTimer);
|
||||
routesTimer = setTimeout(() => {
|
||||
routesTimer = null;
|
||||
void refreshPushRoutes();
|
||||
}, ROUTES_DELAY_MS);
|
||||
}));
|
||||
cleanups.push(() => {
|
||||
if (routesTimer) clearTimeout(routesTimer);
|
||||
});
|
||||
|
||||
void handleForeground();
|
||||
|
||||
stopTriggers = () => {
|
||||
for (const cleanup of cleanups) cleanup();
|
||||
stopTriggers = null;
|
||||
lastForegroundRequest.clear();
|
||||
};
|
||||
return stopTriggers;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* After the server accepted an upload (docs/device-sync.md, "Uploads",
|
||||
* "Afterwards"): when the rows were edited again while the upload was under
|
||||
* way, the clearing group's assert fails and `keepDirtyOps` apply instead.
|
||||
* They write the identity and the new shadow, and set the baselines of the
|
||||
* uploaded units to the values that were uploaded (the rows as read before
|
||||
* the upload), leaving the rows and DIRTY alone: the newer edit, or a revert
|
||||
* to the old value, uploads next time.
|
||||
*/
|
||||
import { Events } from '../android-columns';
|
||||
import type { CalendarContext, EventBaseline, LocalEvent, LocalEventRow, OpGroup } from '../planner';
|
||||
import type { CalendarEventWire } from '../wire';
|
||||
import { exceptionRef, objectRef } from '../common/ids';
|
||||
import { clone } from '../common/json';
|
||||
import { EXCEPTION_CELLS, MASTER_CELLS, encodeBaseline, makeBaseline } from './columns';
|
||||
import { COLUMN_UNITS } from './units';
|
||||
|
||||
export interface UploadedUnits {
|
||||
/** Everything was uploaded (a create). */
|
||||
all: boolean;
|
||||
master: Set<string>;
|
||||
/** By exception row id. */
|
||||
exceptions: Map<number, Set<string>>;
|
||||
/** Exception rows whose instance is excluded on the server now. */
|
||||
removed: Set<number>;
|
||||
}
|
||||
|
||||
function unitColumns(unit: string): readonly string[] {
|
||||
return COLUMN_UNITS[unit] ?? (unit.startsWith('exdate:') ? [Events.EXDATE] : []);
|
||||
}
|
||||
|
||||
function uploadedBaseline(row: LocalEventRow, isException: boolean, units: Set<string> | null, all: boolean): EventBaseline {
|
||||
const uploaded = makeBaseline(
|
||||
row.cells,
|
||||
isException ? EXCEPTION_CELLS : MASTER_CELLS,
|
||||
row.attendees.map((a) => a.cells),
|
||||
row.reminders.map((r) => r.cells),
|
||||
);
|
||||
if (all || !row.baseline) return uploaded;
|
||||
const next = clone(row.baseline);
|
||||
for (const unit of units ?? []) {
|
||||
for (const c of unitColumns(unit)) next.cells[c] = uploaded.cells[c] ?? null;
|
||||
if (unit === 'reminders') next.reminders = uploaded.reminders;
|
||||
if (unit.startsWith('attendee')) next.attendees = uploaded.attendees;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function keepDirtyGroup(local: LocalEvent, server: CalendarEventWire, uploaded: UploadedUnits, ctx: CalendarContext): OpGroup {
|
||||
const masterRef = objectRef(ctx.jmapAccountId, server.id);
|
||||
const ops: OpGroup['ops'] = [
|
||||
{
|
||||
op: 'update',
|
||||
table: 'events',
|
||||
id: local.eventId,
|
||||
values: {
|
||||
[Events._SYNC_ID]: masterRef,
|
||||
[Events.UID_2445]: typeof server.uid === 'string' ? server.uid : null,
|
||||
[Events.SYNC_DATA1]: JSON.stringify(server),
|
||||
[Events.SYNC_DATA3]: null,
|
||||
[Events.SYNC_DATA4]: encodeBaseline(uploadedBaseline(local, false, uploaded.master, uploaded.all)),
|
||||
[Events.SYNC_DATA5]: null,
|
||||
},
|
||||
expectCount: 1,
|
||||
},
|
||||
];
|
||||
for (const x of local.exceptions) {
|
||||
// An instance deleted on the device is an exclusion on the server now: its row has done its job.
|
||||
if (uploaded.removed.has(x.eventId)) {
|
||||
ops.push({ op: 'delete', table: 'events', id: x.eventId, expectCount: 1 });
|
||||
continue;
|
||||
}
|
||||
const units = uploaded.exceptions.get(x.eventId);
|
||||
if ((!uploaded.all && !units) || !x.recurrenceId) continue;
|
||||
const all = uploaded.all || !!units?.has('new');
|
||||
ops.push({
|
||||
op: 'update',
|
||||
table: 'events',
|
||||
id: x.eventId,
|
||||
values: {
|
||||
[Events._SYNC_ID]: exceptionRef(masterRef, x.recurrenceId),
|
||||
[Events.SYNC_DATA2]: x.recurrenceId,
|
||||
[Events.ORIGINAL_SYNC_ID]: masterRef,
|
||||
[Events.SYNC_DATA4]: encodeBaseline(uploadedBaseline(x, true, units ?? null, all)),
|
||||
},
|
||||
expectCount: 1,
|
||||
});
|
||||
}
|
||||
return { ref: masterRef, ops };
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Participants ↔ Attendees rows (docs/device-sync.md, "Other properties").
|
||||
*
|
||||
* Only participants with a `mailto:` calendar address become rows; the rest
|
||||
* stay on the server untouched. Android decides who "me" is by comparing
|
||||
* ATTENDEE_EMAIL with the calendar's OWNER_ACCOUNT case-sensitively, so the
|
||||
* user's own address, under whichever alias, is written as exactly
|
||||
* OWNER_ACCOUNT. Stalwart can list one address twice (the organizer derived
|
||||
* from ORGANIZER plus its ATTENDEE entry); such entries become one row.
|
||||
* Participant ids never reach the device: rows are matched to participants
|
||||
* by address.
|
||||
*/
|
||||
import type { Participant } from '../../api/types';
|
||||
import { AttendeeRelationship, Attendees } from '../android-columns';
|
||||
import type { Row } from '../types';
|
||||
import { attendeeTypeToDevice, participationToDevice, relationshipToDevice } from './values';
|
||||
|
||||
export interface SelfContext {
|
||||
/** OWNER_ACCOUNT of our calendars, lowercase, no `mailto:`. */
|
||||
ownerAccount: string;
|
||||
/** Every address of the user, lowercase, no `mailto:`. */
|
||||
selfAddresses: string[];
|
||||
}
|
||||
|
||||
type Participants = Record<string, Participant> | null | undefined;
|
||||
|
||||
/** The address of a `mailto:` calendar address (lowercased), else null. */
|
||||
export function mailtoAddress(p: Participant | null | undefined): string | null {
|
||||
const addr = p?.calendarAddress;
|
||||
if (typeof addr !== 'string') return null;
|
||||
const m = /^mailto:(.+)$/i.exec(addr.trim());
|
||||
if (!m) return null;
|
||||
let email = m[1].trim();
|
||||
try {
|
||||
email = decodeURIComponent(email);
|
||||
} catch {
|
||||
// Keep it as written.
|
||||
}
|
||||
return email ? email.toLowerCase() : null;
|
||||
}
|
||||
|
||||
export function isSelfAddress(email: string | null | undefined, ctx: SelfContext): boolean {
|
||||
if (!email) return false;
|
||||
const e = email.trim().replace(/^mailto:/i, '').toLowerCase();
|
||||
return e === ctx.ownerAccount.toLowerCase() || ctx.selfAddresses.some((a) => a.toLowerCase() === e);
|
||||
}
|
||||
|
||||
/** The address as written to ATTENDEE_EMAIL / ORGANIZER: the user's own ones as OWNER_ACCOUNT. */
|
||||
export function deviceAddress(email: string, ctx: SelfContext): string {
|
||||
return isSelfAddress(email, ctx) ? ctx.ownerAccount : email;
|
||||
}
|
||||
|
||||
/** A device address as the key rows and participants are matched by (the user's aliases are one key). */
|
||||
export function addressKey(email: unknown, ctx: SelfContext): string {
|
||||
const e = String(email ?? '').trim().replace(/^mailto:/i, '').toLowerCase();
|
||||
return isSelfAddress(e, ctx) ? `self:${ctx.ownerAccount.toLowerCase()}` : e;
|
||||
}
|
||||
|
||||
interface AddressGroup {
|
||||
key: string;
|
||||
email: string;
|
||||
ids: string[];
|
||||
preferred: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Participants with a `mailto:` address grouped by address, in server
|
||||
* order. The preferred entry of a group is the one that attends (Stalwart's
|
||||
* organizer entry has no `attendee` role), then one with a status.
|
||||
*/
|
||||
export function addressGroups(participants: Participants, ctx: SelfContext): AddressGroup[] {
|
||||
const groups = new Map<string, AddressGroup>();
|
||||
for (const [id, p] of Object.entries(participants ?? {})) {
|
||||
const email = mailtoAddress(p);
|
||||
if (!email) continue;
|
||||
const key = addressKey(email, ctx);
|
||||
const group = groups.get(key);
|
||||
if (group) group.ids.push(id);
|
||||
else groups.set(key, { key, email, ids: [id], preferred: id });
|
||||
}
|
||||
const list = participants ?? {};
|
||||
for (const group of groups.values()) {
|
||||
const score = (id: string) => (list[id]?.roles?.attendee ? 2 : 0) + (list[id]?.participationStatus ? 1 : 0);
|
||||
group.preferred = group.ids.reduce((best, id) => (score(id) > score(best) ? id : best), group.ids[0]);
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
/** The organizer's address for ORGANIZER: `organizerCalendarAddress`, else an owner participant's. */
|
||||
export function organizerAddress(
|
||||
event: { organizerCalendarAddress?: string | null; participants?: Participants },
|
||||
ctx: SelfContext,
|
||||
): string | null {
|
||||
const direct = event.organizerCalendarAddress
|
||||
? mailtoAddress({ calendarAddress: /^mailto:/i.test(event.organizerCalendarAddress) ? event.organizerCalendarAddress : `mailto:${event.organizerCalendarAddress}` })
|
||||
: null;
|
||||
if (direct) return deviceAddress(direct, ctx);
|
||||
for (const p of Object.values(event.participants ?? {})) {
|
||||
if (p?.roles?.owner) {
|
||||
const email = mailtoAddress(p);
|
||||
if (email) return deviceAddress(email, ctx);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether the event has participants that become Attendees rows. */
|
||||
export function hasMailtoParticipants(participants: Participants): boolean {
|
||||
return Object.values(participants ?? {}).some((p) => mailtoAddress(p) !== null);
|
||||
}
|
||||
|
||||
/** Attendees rows for the participants, one per address, in server order. */
|
||||
export function attendeeRows(
|
||||
event: { organizerCalendarAddress?: string | null; participants?: Participants },
|
||||
ctx: SelfContext,
|
||||
): Row[] {
|
||||
const participants = event.participants ?? {};
|
||||
const organizer = organizerAddress(event, ctx);
|
||||
return addressGroups(participants, ctx).map((group) => {
|
||||
const p = participants[group.preferred];
|
||||
const email = deviceAddress(group.email, ctx);
|
||||
const isOrganizer =
|
||||
group.ids.some((id) => participants[id]?.roles?.owner) || (organizer !== null && organizer.toLowerCase() === email.toLowerCase());
|
||||
const name = p.name ?? group.ids.map((id) => participants[id]?.name).find((n) => !!n) ?? null;
|
||||
return {
|
||||
[Attendees.ATTENDEE_EMAIL]: email,
|
||||
[Attendees.ATTENDEE_NAME]: name || null,
|
||||
[Attendees.ATTENDEE_RELATIONSHIP]: relationshipToDevice(isOrganizer),
|
||||
[Attendees.ATTENDEE_TYPE]: attendeeTypeToDevice(p),
|
||||
[Attendees.ATTENDEE_STATUS]: participationToDevice(p.participationStatus),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** The participant id a device address stands for (the group's preferred entry), or null. */
|
||||
export function participantIdFor(participants: Participants, email: unknown, ctx: SelfContext): string | null {
|
||||
const key = addressKey(email, ctx);
|
||||
return addressGroups(participants, ctx).find((g) => g.key === key)?.preferred ?? null;
|
||||
}
|
||||
|
||||
/** Whether an attendee row is the organizer's. */
|
||||
export function isOrganizerRow(row: Row): boolean {
|
||||
return Number(row[Attendees.ATTENDEE_RELATIONSHIP]) === AttendeeRelationship.ORGANIZER;
|
||||
}
|
||||
|
||||
/** Whether anyone but the user takes part. */
|
||||
export function hasOtherParticipants(participants: Participants, ctx: SelfContext): boolean {
|
||||
return Object.values(participants ?? {}).some((p) => {
|
||||
const email = mailtoAddress(p);
|
||||
return email !== null && !isSelfAddress(email, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user organizes the event: the organizer address or an owner
|
||||
* participant is theirs. An event without an organizer counts too: once it
|
||||
* has attendees Stalwart makes the account's first address its organizer.
|
||||
*/
|
||||
export function userIsOrganizer(
|
||||
event: { organizerCalendarAddress?: string | null; participants?: Participants },
|
||||
ctx: SelfContext,
|
||||
): boolean {
|
||||
const organizer = organizerAddress(event, ctx);
|
||||
return organizer ? isSelfAddress(organizer, ctx) : true;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* JMAP calendars ↔ CalendarContract.Calendars rows (docs/device-sync.md,
|
||||
* "Calendar rows").
|
||||
*
|
||||
* Every column CalendarProvider and the calendar apps need is written:
|
||||
* SYNC_EVENTS=1 (the default 0 hides every instance), a non-null
|
||||
* OWNER_ACCOUNT (Etar crashes on NULL), CAN_PARTIALLY_UPDATE=0 (else the
|
||||
* provider keeps LAST_SYNCED copies), MAX_REMINDERS by who owns reminders.
|
||||
* VISIBLE is written on insert only: afterwards it is the user's. CAL_SYNC1
|
||||
* stays empty because the provider sends it as the `feed` extra of the sync
|
||||
* it requests for such calendars. A row is updated only where it differs.
|
||||
*/
|
||||
import { CalendarAccess, Calendars } from '../android-columns';
|
||||
import type { CalendarContext, LocalCalendar, OpGroup } from '../planner';
|
||||
import type { Row, WriteRow } from '../types';
|
||||
import type { CalendarLike } from '../wire';
|
||||
import { collectionKey, parseCollectionKey } from '../common/ids';
|
||||
import { deepEqual, parseJsonColumn } from '../common/json';
|
||||
import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar';
|
||||
import { colors } from '../../theme/tokens';
|
||||
import { isSelfAddress } from './attendees';
|
||||
import { CAN_PARTIALLY_UPDATE } from './columns';
|
||||
import { cssColorToArgb } from './values';
|
||||
import { canonicalZone } from './zoned-time';
|
||||
|
||||
export interface SelectedCalendar {
|
||||
jmapAccountId: string;
|
||||
calendar: CalendarLike;
|
||||
readOnly: boolean;
|
||||
/** The JMAP account's name, appended to calendars of shared accounts. */
|
||||
accountName: string;
|
||||
}
|
||||
|
||||
type CalendarsContext = Omit<CalendarContext, 'jmapAccountId'>;
|
||||
|
||||
/** The app's palette colour for a calendar without one (`getCalendarColor` in lib/calendar-utils.ts). */
|
||||
const PALETTE = [
|
||||
colors.calendar.blue,
|
||||
colors.calendar.green,
|
||||
colors.calendar.purple,
|
||||
colors.calendar.orange,
|
||||
colors.calendar.red,
|
||||
colors.calendar.pink,
|
||||
colors.calendar.teal,
|
||||
colors.calendar.indigo,
|
||||
];
|
||||
|
||||
function paletteColor(id: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) | 0;
|
||||
return PALETTE[Math.abs(hash) % PALETTE.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* CALENDAR_ACCESS_LEVEL from `myRights`: write all → OWNER, write own →
|
||||
* CONTRIBUTOR (Etar's threshold for editing), RSVP → RESPOND, else READ. A
|
||||
* read-only calendar whose rights allow writing is a subscribed feed: READ.
|
||||
*/
|
||||
export function accessLevel(calendar: CalendarLike, readOnly: boolean): { level: number; reason: 'rights' | 'subscription' | null } {
|
||||
const rights = calendar.myRights;
|
||||
const writable = !rights || !!rights.mayWriteAll || !!rights.mayWriteOwn;
|
||||
if (readOnly && writable) return { level: CalendarAccess.READ, reason: 'subscription' };
|
||||
const level = !rights || rights.mayWriteAll
|
||||
? CalendarAccess.OWNER
|
||||
: rights.mayWriteOwn
|
||||
? CalendarAccess.CONTRIBUTOR
|
||||
: rights.mayRSVP
|
||||
? CalendarAccess.RESPOND
|
||||
: CalendarAccess.READ;
|
||||
return { level: readOnly && level >= CalendarAccess.CONTRIBUTOR ? CalendarAccess.RESPOND : level, reason: readOnly ? 'rights' : null };
|
||||
}
|
||||
|
||||
/** The columns device sync keeps on a calendar row (VISIBLE aside). */
|
||||
export function calendarValues(selected: SelectedCalendar, ctx: CalendarsContext): WriteRow {
|
||||
const { calendar } = selected;
|
||||
const shared = !!selected.accountName && !isSelfAddress(selected.accountName, ctx);
|
||||
const name = calendar.name || calendar.id;
|
||||
const access = accessLevel(calendar, selected.readOnly);
|
||||
const flags = access.reason ? { readOnly: access.reason } : {};
|
||||
return {
|
||||
[Calendars._SYNC_ID]: collectionKey(selected.jmapAccountId, calendar.id),
|
||||
[Calendars.NAME]: name,
|
||||
[Calendars.CALENDAR_DISPLAY_NAME]: shared ? `${name} (${selected.accountName})` : name,
|
||||
[Calendars.CALENDAR_COLOR]: cssColorToArgb(calendar.color) ?? cssColorToArgb(paletteColor(calendar.id))!,
|
||||
[Calendars.CALENDAR_ACCESS_LEVEL]: access.level,
|
||||
[Calendars.OWNER_ACCOUNT]: ctx.ownerAccount,
|
||||
[Calendars.SYNC_EVENTS]: 1,
|
||||
[Calendars.CALENDAR_TIME_ZONE]: canonicalZone(calendar.timeZone) ?? ctx.deviceZone,
|
||||
[Calendars.ALLOWED_REMINDERS]: '1,2',
|
||||
[Calendars.ALLOWED_AVAILABILITY]: '0,1',
|
||||
[Calendars.ALLOWED_ATTENDEE_TYPES]: '0,1,2,3',
|
||||
[Calendars.MAX_REMINDERS]: ctx.reminderOwner === 'bulwark' ? 0 : 5,
|
||||
[Calendars.CAN_ORGANIZER_RESPOND]: 0,
|
||||
[CAN_PARTIALLY_UPDATE]: 0,
|
||||
[Calendars.CAL_SYNC2]: JSON.stringify(calendar),
|
||||
[Calendars.CAL_SYNC3]: JSON.stringify(flags),
|
||||
};
|
||||
}
|
||||
|
||||
const JSON_COLUMNS = new Set<string>([Calendars.CAL_SYNC2, Calendars.CAL_SYNC3]);
|
||||
|
||||
function differs(column: string, have: Row[string] | undefined, want: WriteRow[string]): boolean {
|
||||
if (JSON_COLUMNS.has(column)) return !deepEqual(parseJsonColumn(have), parseJsonColumn(want));
|
||||
if (typeof want === 'number') return Number(have) !== want || have === null || have === undefined;
|
||||
return (have ?? null) !== (want ?? null);
|
||||
}
|
||||
|
||||
export function planCalendars(
|
||||
selected: SelectedCalendar[],
|
||||
local: LocalCalendar[],
|
||||
ctx: CalendarsContext,
|
||||
): { groups: OpGroup[]; deleteCalendarRows: number[] } {
|
||||
const groups: OpGroup[] = [];
|
||||
const rows = new Map<string, LocalCalendar>();
|
||||
for (const row of local) if (row.syncId && !rows.has(row.syncId)) rows.set(row.syncId, row);
|
||||
const wanted = new Set<string>();
|
||||
for (const s of selected) {
|
||||
// The birthday calendar is the app's own, generated from contacts.
|
||||
if (s.calendar.id === BIRTHDAY_CALENDAR_ID) continue;
|
||||
const key = collectionKey(s.jmapAccountId, s.calendar.id);
|
||||
wanted.add(key);
|
||||
const values = calendarValues(s, ctx);
|
||||
const row = rows.get(key);
|
||||
if (!row) {
|
||||
// DIRTY is NULL after a sync-adapter insert unless written.
|
||||
groups.push({ ref: key, ops: [{ op: 'insert', table: 'calendars', values: { ...values, [Calendars.VISIBLE]: 1, [Calendars.DIRTY]: 0 } }] });
|
||||
continue;
|
||||
}
|
||||
const changes: WriteRow = {};
|
||||
for (const [column, want] of Object.entries(values)) {
|
||||
if (differs(column, row.cells[column], want)) changes[column] = want;
|
||||
}
|
||||
if (Object.keys(changes).length) {
|
||||
groups.push({ ref: key, ops: [{ op: 'update', table: 'calendars', id: row.calendarRowId, values: changes, expectCount: 1 }] });
|
||||
}
|
||||
}
|
||||
const deleteCalendarRows = local
|
||||
.filter((row) => row.syncId && parseCollectionKey(row.syncId) && !wanted.has(row.syncId))
|
||||
.map((row) => row.calendarRowId);
|
||||
return { groups, deleteCalendarRows };
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* What the calendar planner reads and writes on each CalendarContract row,
|
||||
* how a cell compares to another (an app's `''` is the provider's NULL, a
|
||||
* `P3600S` is a `PT1H`) and the baseline format in SYNC_DATA4
|
||||
* (docs/device-sync.md, "Calendar columns" and "Baselines").
|
||||
*/
|
||||
import { Attendees, Calendars, Events, Reminders } from '../android-columns';
|
||||
import type { Cell, Row } from '../types';
|
||||
import type { EventBaseline } from '../planner';
|
||||
import { parseJsonColumn, utf8 } from '../common/json';
|
||||
import { sha256Hex } from '../../lib/sha256';
|
||||
import { canonicalRRule } from './rrule';
|
||||
import { exdateSet } from './exdate';
|
||||
import { durationTextSeconds } from './duration';
|
||||
|
||||
/** Calendars.CAN_PARTIALLY_UPDATE, re-exported for the calendar modules. */
|
||||
export const CAN_PARTIALLY_UPDATE = Calendars.CAN_PARTIALLY_UPDATE;
|
||||
|
||||
export const CALENDAR_COLUMNS = [
|
||||
Calendars._ID,
|
||||
Calendars._SYNC_ID,
|
||||
Calendars.NAME,
|
||||
Calendars.CALENDAR_DISPLAY_NAME,
|
||||
Calendars.CALENDAR_COLOR,
|
||||
Calendars.CALENDAR_ACCESS_LEVEL,
|
||||
Calendars.OWNER_ACCOUNT,
|
||||
Calendars.VISIBLE,
|
||||
Calendars.SYNC_EVENTS,
|
||||
Calendars.CALENDAR_TIME_ZONE,
|
||||
Calendars.ALLOWED_REMINDERS,
|
||||
Calendars.ALLOWED_AVAILABILITY,
|
||||
Calendars.ALLOWED_ATTENDEE_TYPES,
|
||||
Calendars.MAX_REMINDERS,
|
||||
Calendars.CAN_ORGANIZER_RESPOND,
|
||||
CAN_PARTIALLY_UPDATE,
|
||||
Calendars.CAL_SYNC2,
|
||||
Calendars.CAL_SYNC3,
|
||||
] as const;
|
||||
|
||||
/** Event columns that map to JSCalendar (a master's projection). */
|
||||
export const MASTER_CELLS = [
|
||||
Events.CALENDAR_ID,
|
||||
Events.TITLE,
|
||||
Events.DESCRIPTION,
|
||||
Events.EVENT_LOCATION,
|
||||
Events.STATUS,
|
||||
Events.AVAILABILITY,
|
||||
Events.ACCESS_LEVEL,
|
||||
Events.EVENT_COLOR,
|
||||
Events.ORGANIZER,
|
||||
Events.DTSTART,
|
||||
Events.DTEND,
|
||||
Events.DURATION,
|
||||
Events.EVENT_TIMEZONE,
|
||||
Events.ALL_DAY,
|
||||
Events.RRULE,
|
||||
Events.EXDATE,
|
||||
] as const;
|
||||
|
||||
/** An exception row's projection: no rule of its own, plus the instance it replaces. */
|
||||
export const EXCEPTION_CELLS = [
|
||||
Events.CALENDAR_ID,
|
||||
Events.TITLE,
|
||||
Events.DESCRIPTION,
|
||||
Events.EVENT_LOCATION,
|
||||
Events.STATUS,
|
||||
Events.AVAILABILITY,
|
||||
Events.ACCESS_LEVEL,
|
||||
Events.EVENT_COLOR,
|
||||
Events.ORGANIZER,
|
||||
Events.DTSTART,
|
||||
Events.DTEND,
|
||||
Events.DURATION,
|
||||
Events.EVENT_TIMEZONE,
|
||||
Events.ALL_DAY,
|
||||
Events.ORIGINAL_INSTANCE_TIME,
|
||||
Events.ORIGINAL_ALL_DAY,
|
||||
] as const;
|
||||
|
||||
/** Read but never part of a projection: identity, sync state and provider-computed columns. */
|
||||
const EVENT_EXTRA_COLUMNS = [
|
||||
Events._ID,
|
||||
Events._SYNC_ID,
|
||||
Events.DIRTY,
|
||||
Events.DELETED,
|
||||
Events.MUTATORS,
|
||||
Events.SYNC_DATA1,
|
||||
Events.SYNC_DATA2,
|
||||
Events.SYNC_DATA3,
|
||||
Events.SYNC_DATA4,
|
||||
Events.SYNC_DATA5,
|
||||
Events.UID_2445,
|
||||
Events.HAS_ATTENDEE_DATA,
|
||||
Events.RDATE,
|
||||
Events.EXRULE,
|
||||
Events.LAST_DATE,
|
||||
Events.ORIGINAL_ID,
|
||||
Events.ORIGINAL_SYNC_ID,
|
||||
] as const;
|
||||
|
||||
export const EVENT_COLUMNS = [...new Set<string>([...EVENT_EXTRA_COLUMNS, ...MASTER_CELLS, ...EXCEPTION_CELLS])];
|
||||
|
||||
export const ATTENDEE_CELLS = [
|
||||
Attendees.ATTENDEE_EMAIL,
|
||||
Attendees.ATTENDEE_NAME,
|
||||
Attendees.ATTENDEE_RELATIONSHIP,
|
||||
Attendees.ATTENDEE_TYPE,
|
||||
Attendees.ATTENDEE_STATUS,
|
||||
] as const;
|
||||
export const ATTENDEE_COLUMNS = [Attendees._ID, Attendees.EVENT_ID, ...ATTENDEE_CELLS];
|
||||
|
||||
export const REMINDER_CELLS = [Reminders.MINUTES, Reminders.METHOD] as const;
|
||||
export const REMINDER_COLUMNS = [Reminders._ID, Reminders.EVENT_ID, ...REMINDER_CELLS];
|
||||
|
||||
// ─── Cells ──────────────────────────────────────────────
|
||||
|
||||
const TEXT_COLUMNS = new Set<string>([
|
||||
Events.TITLE,
|
||||
Events.DESCRIPTION,
|
||||
Events.EVENT_LOCATION,
|
||||
Events.ORGANIZER,
|
||||
Events.EVENT_TIMEZONE,
|
||||
Attendees.ATTENDEE_EMAIL,
|
||||
Attendees.ATTENDEE_NAME,
|
||||
]);
|
||||
|
||||
/** A cell as the bridge may hand it back: numbers stay numbers, `''` and NULL are one value for text. */
|
||||
export function normalCell(column: string, value: unknown): Cell {
|
||||
if (value === undefined || value === null) return TEXT_COLUMNS.has(column) ? '' : null;
|
||||
if (TEXT_COLUMNS.has(column)) return String(value);
|
||||
if (typeof value === 'number') return value;
|
||||
if (typeof value === 'string') {
|
||||
if (value === '') return null;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && /^-?\d+(\.\d+)?$/.test(value.trim()) ? n : value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether two values of a column mean the same, the way device sync reads them. */
|
||||
export function sameCell(column: string, a: unknown, b: unknown): boolean {
|
||||
if (column === Events.DURATION) {
|
||||
const sa = durationTextSeconds(a);
|
||||
const sb = durationTextSeconds(b);
|
||||
if (sa !== null || sb !== null) return sa === sb;
|
||||
return empty(a) === empty(b);
|
||||
}
|
||||
if (column === Events.RRULE) return canonicalRRule(a) === canonicalRRule(b);
|
||||
if (column === Events.EXDATE) return sameSet(exdateSet(a), exdateSet(b));
|
||||
if (column === Events.ALL_DAY || column === Events.ORIGINAL_ALL_DAY) return truthy(a) === truthy(b);
|
||||
if (isHashed(a) || isHashed(b)) return textDigest(a) === textDigest(b);
|
||||
return normalCell(column, a) === normalCell(column, b);
|
||||
}
|
||||
|
||||
const empty = (v: unknown) => v === null || v === undefined || v === '';
|
||||
const truthy = (v: unknown) => Number(v ?? 0) === 1;
|
||||
|
||||
function sameSet(a: Set<string>, b: Set<string>): boolean {
|
||||
return a.size === b.size && [...a].every((x) => b.has(x));
|
||||
}
|
||||
|
||||
// ─── Long text in baselines ─────────────────────────────
|
||||
|
||||
const HASH_PREFIX = 'sha256:';
|
||||
const TRUNCATED_PREFIX = 'truncated:';
|
||||
const LONG_TEXT_BYTES = 1024;
|
||||
|
||||
/** The largest description written to a row; longer ones are cut, marked in the baseline and never uploaded. */
|
||||
export const DESCRIPTION_LIMIT_BYTES = 65_536;
|
||||
|
||||
function isHashed(v: unknown): boolean {
|
||||
return typeof v === 'string' && (v.startsWith(HASH_PREFIX) || v.startsWith(TRUNCATED_PREFIX + HASH_PREFIX));
|
||||
}
|
||||
|
||||
/** Text over 1 KB as `sha256:<hex>`, anything else unchanged. */
|
||||
export function baselineText(value: string): string {
|
||||
return utf8(value).length > LONG_TEXT_BYTES ? HASH_PREFIX + sha256Hex(utf8(value)) : value;
|
||||
}
|
||||
|
||||
/** A truncated description's baseline: its hash, marked so a later edit is never uploaded. */
|
||||
export function truncatedBaseline(value: string): string {
|
||||
return TRUNCATED_PREFIX + HASH_PREFIX + sha256Hex(utf8(value));
|
||||
}
|
||||
|
||||
export function isTruncatedBaseline(value: unknown): boolean {
|
||||
return typeof value === 'string' && value.startsWith(TRUNCATED_PREFIX);
|
||||
}
|
||||
|
||||
/** What a text cell or its baseline form is compared by: the hash for hashed or long text. */
|
||||
function textDigest(v: unknown): string {
|
||||
if (typeof v !== 'string') return empty(v) ? '' : String(v);
|
||||
if (v.startsWith(TRUNCATED_PREFIX)) return v.slice(TRUNCATED_PREFIX.length);
|
||||
if (v.startsWith(HASH_PREFIX)) return v;
|
||||
return utf8(v).length > LONG_TEXT_BYTES ? HASH_PREFIX + sha256Hex(utf8(v)) : v;
|
||||
}
|
||||
|
||||
/** Cuts text to at most `limit` UTF-8 bytes without splitting a character. */
|
||||
export function truncateUtf8(text: string, limit = DESCRIPTION_LIMIT_BYTES): { text: string; truncated: boolean } {
|
||||
if (utf8(text).length <= limit) return { text, truncated: false };
|
||||
let lo = 0;
|
||||
let hi = text.length;
|
||||
while (lo < hi) {
|
||||
const mid = Math.ceil((lo + hi) / 2);
|
||||
if (utf8(text.slice(0, mid)).length <= limit) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
let end = lo;
|
||||
// Never end on the first half of a surrogate pair.
|
||||
if (end > 0 && /[\uD800-\uDBFF]/.test(text[end - 1])) end -= 1;
|
||||
return { text: text.slice(0, end), truncated: true };
|
||||
}
|
||||
|
||||
// ─── Baselines (SYNC_DATA4) ─────────────────────────────
|
||||
|
||||
/**
|
||||
* The columns of a row, each in its normal form (numbers as numbers even when
|
||||
* the provider handed them back as text), so a read-back never differs from
|
||||
* a baseline by type alone.
|
||||
*/
|
||||
function normalRow(row: Row, columns: readonly string[]): Row {
|
||||
const out: Row = {};
|
||||
for (const c of columns) {
|
||||
const v = row[c] ?? null;
|
||||
out[c] = v === null || TEXT_COLUMNS.has(c) || typeof v !== 'string' ? v : normalCell(c, v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A baseline from cells as stored: normal forms, long text hashed, a truncated description marked. */
|
||||
export function makeBaseline(
|
||||
cells: Row,
|
||||
columns: readonly string[],
|
||||
attendees: Row[],
|
||||
reminders: Row[],
|
||||
truncatedDescription = false,
|
||||
): EventBaseline {
|
||||
const out = normalRow(cells, columns);
|
||||
for (const c of columns) {
|
||||
const v = out[c];
|
||||
if (c === Events.DESCRIPTION && truncatedDescription && typeof v === 'string') out[c] = truncatedBaseline(v);
|
||||
else if (typeof v === 'string' && !isHashed(v)) out[c] = baselineText(v);
|
||||
}
|
||||
return {
|
||||
cells: out,
|
||||
attendees: attendees.map((a) => normalRow(a, ATTENDEE_CELLS)),
|
||||
reminders: reminders.map((r) => normalRow(r, REMINDER_CELLS)),
|
||||
};
|
||||
}
|
||||
|
||||
export function encodeBaseline(baseline: EventBaseline): string {
|
||||
return JSON.stringify({ v: 1, ...baseline });
|
||||
}
|
||||
|
||||
/** SYNC_DATA4 read defensively: another app may have written anything there. */
|
||||
export function decodeBaseline(text: unknown): EventBaseline | null {
|
||||
const parsed = parseJsonColumn<Record<string, unknown>>(text);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||
const cells = parsed.cells;
|
||||
if (!cells || typeof cells !== 'object' || Array.isArray(cells)) return null;
|
||||
const rows = (v: unknown): Row[] =>
|
||||
Array.isArray(v) ? v.filter((r): r is Row => !!r && typeof r === 'object' && !Array.isArray(r)) : [];
|
||||
return { cells: cells as Row, attendees: rows(parsed.attendees), reminders: rows(parsed.reminders) };
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* New and deleted events (docs/device-sync.md, "Uploads" and "Identity").
|
||||
*
|
||||
* A new master row is claimed before anything is sent: the uid and target go
|
||||
* into SYNC_DATA3, UID_2445 and `_SYNC_ID = ~pending/<uid>`, so an app delete
|
||||
* becomes a soft delete the engine can act on and a retried create is found
|
||||
* by uid. The row keeps a uid it carries unless another row does too (a
|
||||
* CONTENT_EXCEPTION_URI split clones UID_2445). The create then sends the
|
||||
* whole event, with the exceptions and exclusions made before its upload as
|
||||
* overrides. A deleted event is destroyed, or loses only its synced calendar
|
||||
* memberships when it is in others as well.
|
||||
*/
|
||||
import type { Participant } from '../../api/types';
|
||||
import { AttendeeType, Attendees, Events } from '../android-columns';
|
||||
import type { CalendarContext, LocalEvent, LocalException, OpGroup, UploadAction } from '../planner';
|
||||
import type { Row } from '../types';
|
||||
import type { CalendarEventWire } from '../wire';
|
||||
import { collectionKey, parseCollectionKey, parseObjectRef, pendingRef, pendingUidOf } from '../common/ids';
|
||||
import type { PatchObject } from '../common/patch';
|
||||
import { isSelfAddress } from './attendees';
|
||||
import { applyAttendeeEdits, attendeeEdits, descriptionValue, locationChanges, reminderChanges, titleValue } from './edits';
|
||||
import { exdateEntryKey } from './exdate';
|
||||
import type { Override } from './exceptions';
|
||||
import { isRemovedInstance } from './merge';
|
||||
import { rruleParts, type RuleZone } from './rrule';
|
||||
import { sendsSchedulingMessages } from './scheduling';
|
||||
import { fixedOffsetTiming, rowTiming } from './timing';
|
||||
import { exdateEntries, sideOfRow } from './units';
|
||||
import { SKIP, SkipUpload, deviceRule, newOverride, put } from './upload';
|
||||
import { argbToCss, availabilityFromDevice, participationFromDevice, privacyFromDevice, statusFromDevice } from './values';
|
||||
import { canonicalZone, classifyLocal } from './zoned-time';
|
||||
|
||||
// ─── Creates ────────────────────────────────────────────
|
||||
|
||||
export interface CreateComputation {
|
||||
object: Partial<CalendarEventWire>;
|
||||
sendSchedulingMessages: boolean;
|
||||
}
|
||||
|
||||
/** The event a new master row (with its exceptions and EXDATE) creates. Throws `SkipUpload`. */
|
||||
export function computeCreate(local: LocalEvent, uid: string, calendarId: string, ctx: CalendarContext): CreateComputation {
|
||||
const cells = local.cells;
|
||||
const recurring = !!rruleParts(cells[Events.RRULE]);
|
||||
let t = rowTiming(cells, { server: null, baselineZone: null, recurring, deviceZone: ctx.deviceZone });
|
||||
if (!t) throw new SkipUpload('noStart');
|
||||
if (t.ambiguous) {
|
||||
if (recurring) throw new SkipUpload(SKIP.dstAmbiguous);
|
||||
t = fixedOffsetTiming(t);
|
||||
}
|
||||
const object: Record<string, unknown> = {
|
||||
'@type': 'Event',
|
||||
uid,
|
||||
calendarIds: { [calendarId]: true },
|
||||
title: titleValue(cells),
|
||||
start: t.start,
|
||||
duration: t.duration,
|
||||
freeBusyStatus: availabilityFromDevice(cells[Events.AVAILABILITY]),
|
||||
};
|
||||
// A row without STATUS gets JSCalendar's default, confirmed.
|
||||
const status = statusFromDevice(cells[Events.STATUS]);
|
||||
if (status) object.status = status;
|
||||
if (t.showWithoutTime) object.showWithoutTime = true;
|
||||
else object.timeZone = t.timeZone;
|
||||
const description = descriptionValue(cells);
|
||||
if (description) object.description = description;
|
||||
const privacy = privacyFromDevice(cells[Events.ACCESS_LEVEL]);
|
||||
if (privacy) object.privacy = privacy;
|
||||
const color = argbToCss(cells[Events.EVENT_COLOR]);
|
||||
if (color) object.color = color;
|
||||
const location = locationChanges({}, cells[Events.EVENT_LOCATION], ctx.mintKey).locations;
|
||||
if (location) object.locations = location;
|
||||
|
||||
const { participants, organizer } = createParticipants(local, ctx);
|
||||
if (participants) {
|
||||
object.participants = participants;
|
||||
object.organizerCalendarAddress = `mailto:${organizer}`;
|
||||
}
|
||||
if (ctx.reminderOwner === 'device' && local.reminders.length) {
|
||||
const alerts = reminderChanges({}, undefined, local.reminders.map((r) => r.cells), ctx.mintKey).alerts;
|
||||
if (Object.keys(alerts).length) object.alerts = alerts;
|
||||
}
|
||||
|
||||
const zone: RuleZone = t.showWithoutTime ? { allDay: true, zone: 'UTC' } : { allDay: false, zone: canonicalZone(t.timeZone) ?? 'UTC' };
|
||||
const rule = recurring ? deviceRule(cells[Events.RRULE], null, null, zone, false) : null;
|
||||
if (rule) {
|
||||
object.recurrenceRule = rule;
|
||||
const overrides: Record<string, Override> = {};
|
||||
const checked = (key: string | null) => {
|
||||
if (key && !zone.allDay && classifyLocal(key, zone.zone) === 'overlap') throw new SkipUpload(SKIP.dstAmbiguous);
|
||||
return key;
|
||||
};
|
||||
for (const entry of exdateEntries(sideOfRow(local))) {
|
||||
const key = exdateEntryKey(entry, zone);
|
||||
// Etar's "this and following" copies the old series' EXDATE: dates before the start are none of this series.
|
||||
if (!key || key < t.start) continue;
|
||||
overrides[checked(key)!] = { excluded: true };
|
||||
}
|
||||
for (const x of local.exceptions) {
|
||||
const key = checked(x.recurrenceId);
|
||||
if (!key) continue;
|
||||
overrides[key] = isRemovedInstance(x) ? { excluded: true } : createOverride(x, object as CalendarEventWire, key, local, ctx);
|
||||
}
|
||||
if (Object.keys(overrides).length) object.recurrenceOverrides = overrides;
|
||||
}
|
||||
return {
|
||||
object: object as Partial<CalendarEventWire>,
|
||||
sendSchedulingMessages: sendsSchedulingMessages(object as CalendarEventWire, { kind: 'create' }, ctx),
|
||||
};
|
||||
}
|
||||
|
||||
/** An exception of a series that is created with it: what the exception row says differently from its master row. */
|
||||
function createOverride(x: LocalException, master: CalendarEventWire, key: string, local: LocalEvent, ctx: CalendarContext): Override {
|
||||
const values: Record<string, unknown> = {};
|
||||
let t = rowTiming(x.cells, {
|
||||
server: master,
|
||||
baselineZone: String(local.cells[Events.EVENT_TIMEZONE] ?? ''),
|
||||
recurring: false,
|
||||
deviceZone: ctx.deviceZone,
|
||||
});
|
||||
if (t) {
|
||||
if (t.ambiguous) throw new SkipUpload(SKIP.dstAmbiguous);
|
||||
if (t.showWithoutTime !== !!master.showWithoutTime) t = { ...t, showWithoutTime: !!master.showWithoutTime };
|
||||
values.start = t.start;
|
||||
values.duration = t.duration;
|
||||
}
|
||||
const cells = x.cells;
|
||||
// What the exception row leaves empty is one its app did not model: it comes from the series.
|
||||
const differs = (column: string) => {
|
||||
const v = cells[column];
|
||||
return v !== null && v !== undefined && v !== '' && String(v) !== String(local.cells[column] ?? '');
|
||||
};
|
||||
if (differs(Events.TITLE)) values.title = titleValue(cells);
|
||||
if (differs(Events.DESCRIPTION)) values.description = descriptionValue(cells);
|
||||
if (differs(Events.EVENT_LOCATION)) values.locations = locationChanges(master, cells[Events.EVENT_LOCATION], ctx.mintKey).locations;
|
||||
if (differs(Events.STATUS)) values.status = statusFromDevice(cells[Events.STATUS]);
|
||||
if (differs(Events.AVAILABILITY)) values.freeBusyStatus = availabilityFromDevice(cells[Events.AVAILABILITY]);
|
||||
if (differs(Events.EVENT_COLOR)) values.color = argbToCss(cells[Events.EVENT_COLOR]);
|
||||
if (x.attendees.length) {
|
||||
const edits = attendeeEdits(x.attendees.map((a) => a.cells), local.attendees.map((a) => a.cells), ctx, false);
|
||||
if (edits.length) values.participants = applyAttendeeEdits(master.participants, edits, ctx, ctx.mintKey, false).participants;
|
||||
}
|
||||
return newOverride(master, key, values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Participants of a new event from its attendee rows: the user as organizer
|
||||
* (unless ORGANIZER names someone else) and everyone else as attendees.
|
||||
* None when the user is alone.
|
||||
*/
|
||||
function createParticipants(local: LocalEvent, ctx: CalendarContext): { participants: Record<string, Participant> | null; organizer: string } {
|
||||
const rows = local.attendees.map((a) => a.cells);
|
||||
const isSelf = (row: Row) => isSelfAddress(String(row[Attendees.ATTENDEE_EMAIL] ?? ''), ctx);
|
||||
const organizerCell = String(local.cells[Events.ORGANIZER] ?? '').trim();
|
||||
const organizer = organizerCell && !isSelfAddress(organizerCell, ctx) ? organizerCell : ctx.ownerAccount;
|
||||
const guests = rows.filter((r) => !isSelf(r));
|
||||
if (!guests.length) return { participants: null, organizer };
|
||||
const participants: Record<string, Participant> = {};
|
||||
const self = rows.find(isSelf);
|
||||
const iOrganize = isSelfAddress(organizer, ctx);
|
||||
if (iOrganize || self) {
|
||||
const status = self ? participationFromDevice(self[Attendees.ATTENDEE_STATUS]) : null;
|
||||
participants[ctx.mintKey(Object.keys(participants))] = {
|
||||
'@type': 'Participant',
|
||||
calendarAddress: `mailto:${ctx.ownerAccount}`,
|
||||
roles: iOrganize ? { owner: true, attendee: true } : { attendee: true },
|
||||
participationStatus: status ?? (iOrganize ? 'accepted' : 'needs-action'),
|
||||
};
|
||||
}
|
||||
for (const row of guests) {
|
||||
const email = String(row[Attendees.ATTENDEE_EMAIL]).trim();
|
||||
const p: Participant = {
|
||||
'@type': 'Participant',
|
||||
calendarAddress: `mailto:${email}`,
|
||||
roles: email.toLowerCase() === organizer.toLowerCase() ? { owner: true } : { attendee: true },
|
||||
participationStatus: 'needs-action',
|
||||
expectReply: true,
|
||||
};
|
||||
const name = row[Attendees.ATTENDEE_NAME];
|
||||
if (typeof name === 'string' && name.trim()) p.name = name.trim();
|
||||
if (Number(row[Attendees.ATTENDEE_TYPE]) === AttendeeType.OPTIONAL) p.roles = { ...p.roles, optional: true };
|
||||
if (Number(row[Attendees.ATTENDEE_TYPE]) === AttendeeType.RESOURCE) p.kind = 'resource';
|
||||
participants[ctx.mintKey(Object.keys(participants))] = p;
|
||||
}
|
||||
return { participants, organizer };
|
||||
}
|
||||
|
||||
// ─── Claims ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The claim of a new row (see the header). A split clone also drops the
|
||||
* source's shadow and baseline it was cloned with, points the source's
|
||||
* exceptions back at the source (the provider's trigger may have followed the
|
||||
* `_SYNC_ID` change) and marks the source dirty, so the source's capped rule
|
||||
* uploads even when this group is applied before the source is planned.
|
||||
*/
|
||||
export function claimOps(local: LocalEvent, uid: string, target: string): OpGroup {
|
||||
const ops: OpGroup['ops'] = [
|
||||
{
|
||||
op: 'assert',
|
||||
table: 'events',
|
||||
id: local.eventId,
|
||||
values: { [Events._SYNC_ID]: local.syncId, [Events.DELETED]: local.cells[Events.DELETED] ?? null },
|
||||
expectCount: 1,
|
||||
},
|
||||
];
|
||||
const values: Row = {
|
||||
[Events._SYNC_ID]: pendingRef(uid),
|
||||
[Events.UID_2445]: uid,
|
||||
[Events.SYNC_DATA3]: JSON.stringify({ uid, target }),
|
||||
};
|
||||
const sharedSyncId = local.split === 'clone' && parseObjectRef(local.syncId) ? local.syncId : null;
|
||||
if (local.split === 'clone') {
|
||||
Object.assign(values, { [Events.SYNC_DATA1]: null, [Events.SYNC_DATA2]: null, [Events.SYNC_DATA4]: null, [Events.SYNC_DATA5]: null });
|
||||
}
|
||||
ops.push({ op: 'update', table: 'events', id: local.eventId, values, expectCount: 1 });
|
||||
if (sharedSyncId) {
|
||||
ops.push({
|
||||
op: 'update',
|
||||
table: 'events',
|
||||
where: `${Events.ORIGINAL_SYNC_ID} = ? AND (${Events.ORIGINAL_ID} IS NULL OR ${Events.ORIGINAL_ID} != ?)`,
|
||||
args: [pendingRef(uid), local.eventId],
|
||||
values: { [Events.ORIGINAL_SYNC_ID]: sharedSyncId },
|
||||
});
|
||||
// The clone no longer carries the shared `_SYNC_ID` here, so this reaches the source master(s) only.
|
||||
ops.push({
|
||||
op: 'update',
|
||||
table: 'events',
|
||||
where: `${Events._SYNC_ID} = ? AND ${Events.ORIGINAL_ID} IS NULL`,
|
||||
args: [sharedSyncId],
|
||||
values: { [Events.DIRTY]: 1 },
|
||||
});
|
||||
}
|
||||
return { ref: `row:${local.eventId}`, ops };
|
||||
}
|
||||
|
||||
/** The uid of a new row's complete, consistent claim, or null when it has to be claimed (again). */
|
||||
export function claimedUid(local: LocalEvent): string | null {
|
||||
if (local.split === 'clone' || !local.pending) return null;
|
||||
const uid = local.pending.uid;
|
||||
if (pendingUidOf(local.syncId) !== uid || local.cells[Events.UID_2445] !== uid) return null;
|
||||
return uid;
|
||||
}
|
||||
|
||||
/** The uid a claim writes: the row's own unless it is a clone of another row's. */
|
||||
export function claimUid(local: LocalEvent, ctx: CalendarContext): string {
|
||||
if (local.split === 'clone') return ctx.mintUid();
|
||||
const own = local.cells[Events.UID_2445];
|
||||
if (typeof own === 'string' && own.trim()) return own;
|
||||
return pendingUidOf(local.syncId) ?? ctx.mintUid();
|
||||
}
|
||||
|
||||
/** Where a new row's create goes: the target fixed at its claim, else the calendar of its row. */
|
||||
export function createTarget(local: LocalEvent, ctx: CalendarContext): { key: string; calendarId: string } | null {
|
||||
if (local.pending && local.split !== 'clone') {
|
||||
const parsed = parseCollectionKey(local.pending.target);
|
||||
if (parsed) return { key: local.pending.target, calendarId: parsed.id };
|
||||
}
|
||||
const cal = ctx.calendarIdOfRow(local.calendarRowId);
|
||||
return cal ? { key: collectionKey(cal.jmapAccountId, cal.calendarId), calendarId: cal.calendarId } : null;
|
||||
}
|
||||
|
||||
// ─── Deletions ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Whether the server moved a deleted event out of the calendar its row is in
|
||||
* (`own`), and into none this device syncs, before the device's deletion
|
||||
* reached it: the deletion is moot then, and the event stays where it is now.
|
||||
* A calendar deselected since is still the row's own, so that deletion goes
|
||||
* up; so does one of an event moved into another synced calendar.
|
||||
*/
|
||||
export function movedAway(local: LocalEvent, own: { jmapAccountId: string; calendarId: string }, ctx: CalendarContext): boolean {
|
||||
if (!local.shadow) return false;
|
||||
const ownKey = collectionKey(own.jmapAccountId, own.calendarId);
|
||||
const keys = Object.entries(local.shadow.calendarIds ?? {}).filter(([, on]) => on).map(([id]) => collectionKey(ctx.jmapAccountId, id));
|
||||
return !keys.some((key) => key === ownKey || ctx.isSelected(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* A deleted event with identity: an object in several calendars loses only
|
||||
* the selected memberships; otherwise it is destroyed, with scheduling
|
||||
* messages when others take part.
|
||||
*/
|
||||
export function deleteActions(local: LocalEvent, ctx: CalendarContext): UploadAction<CalendarEventWire>[] {
|
||||
const ref = parseObjectRef(local.syncId)!;
|
||||
const shadow = local.shadow;
|
||||
if (shadow) {
|
||||
const memberships = Object.entries(shadow.calendarIds ?? {}).filter(([, on]) => on).map(([id]) => id);
|
||||
const selected = memberships.filter((id) => ctx.isSelected(collectionKey(ctx.jmapAccountId, id)));
|
||||
if (selected.length && selected.length < memberships.length) {
|
||||
const patch: PatchObject = {};
|
||||
for (const id of selected) put(patch, ['calendarIds', id], null);
|
||||
return [{ kind: 'update', id: ref.id, patch }];
|
||||
}
|
||||
}
|
||||
const action: UploadAction<CalendarEventWire> = { kind: 'destroy', id: ref.id, uid: typeof shadow?.uid === 'string' ? shadow.uid : null };
|
||||
if (shadow && sendsSchedulingMessages(shadow, { kind: 'destroy' }, ctx)) action.sendSchedulingMessages = true;
|
||||
return [action];
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Provider rows → the planner's view of local events and calendars
|
||||
* (docs/device-sync.md, "Device data model"). Every sync column is read
|
||||
* defensively: another app could have written anything there, and a value
|
||||
* that doesn't parse counts as absent.
|
||||
*
|
||||
* Exception rows are grouped under their master by ORIGINAL_ID, else by
|
||||
* ORIGINAL_SYNC_ID. Two masters sharing a `_SYNC_ID` are a
|
||||
* CONTENT_EXCEPTION_URI split (the provider clones `_SYNC_ID`, SYNC_DATA*
|
||||
* and UID_2445 and caps the old rule without DIRTY): the newer row is marked
|
||||
* `clone`, the older `source`. A new master whose UID_2445 another row
|
||||
* already carries is marked `clone` too, so its create gets a fresh uid
|
||||
* instead of adopting the other row's server object.
|
||||
*/
|
||||
import { Attendees, Calendars, Events, Reminders } from '../android-columns';
|
||||
import type { LocalAttendee, LocalCalendar, LocalEvent, LocalEventRow, LocalException, LocalReminder, PendingCreate, PoisonMarker } from '../planner';
|
||||
import type { Row } from '../types';
|
||||
import type { CalendarEventWire, CalendarLike } from '../wire';
|
||||
import { parseJsonColumn } from '../common/json';
|
||||
import { parseObjectRef, pendingUidOf } from '../common/ids';
|
||||
import { decodeBaseline } from './columns';
|
||||
import { instanceTimeToKey } from './exceptions';
|
||||
import { canonicalZone, parseLocalDateTime } from './zoned-time';
|
||||
|
||||
const has = (v: unknown) => v !== null && v !== undefined && v !== '';
|
||||
const num = (v: unknown) => (has(v) && Number.isFinite(Number(v)) ? Number(v) : null);
|
||||
const str = (v: unknown) => (typeof v === 'string' && v ? v : typeof v === 'number' ? String(v) : null);
|
||||
|
||||
export function decodePending(text: unknown): PendingCreate | null {
|
||||
const p = parseJsonColumn<Record<string, unknown>>(text);
|
||||
if (!p || typeof p.uid !== 'string' || !p.uid || typeof p.target !== 'string' || !p.target) return null;
|
||||
return { uid: p.uid, target: p.target };
|
||||
}
|
||||
|
||||
export function decodePoison(text: unknown): PoisonMarker | null {
|
||||
const p = parseJsonColumn<Record<string, unknown>>(text);
|
||||
if (!p || typeof p.fp !== 'string' || typeof p.type !== 'string') return null;
|
||||
const n = Number(p.n);
|
||||
const until = Number(p.until);
|
||||
return {
|
||||
fp: p.fp,
|
||||
type: p.type,
|
||||
...(typeof p.description === 'string' ? { description: p.description } : {}),
|
||||
n: Number.isFinite(n) ? n : 1,
|
||||
until: Number.isFinite(until) ? until : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeShadow(text: unknown): CalendarEventWire | null {
|
||||
const e = parseJsonColumn<CalendarEventWire>(text);
|
||||
return e && typeof e === 'object' && !Array.isArray(e) && typeof e.id === 'string' ? e : null;
|
||||
}
|
||||
|
||||
/** Whether a row is an exception (ORIGINAL_ID or ORIGINAL_SYNC_ID set): it never uploads as an event of its own. */
|
||||
export function isExceptionRow(cells: Row): boolean {
|
||||
return has(cells[Events.ORIGINAL_ID]) || has(cells[Events.ORIGINAL_SYNC_ID]);
|
||||
}
|
||||
|
||||
function decodeRow(row: Row, attendees: LocalAttendee[], reminders: LocalReminder[]): LocalEventRow {
|
||||
const cells: Row = { ...row };
|
||||
for (const c of [Events.SYNC_DATA1, Events.SYNC_DATA3, Events.SYNC_DATA4, Events.SYNC_DATA5]) delete cells[c];
|
||||
return {
|
||||
eventId: Number(row[Events._ID]),
|
||||
calendarRowId: Number(row[Events.CALENDAR_ID]),
|
||||
syncId: str(row[Events._SYNC_ID]),
|
||||
dirty: Number(row[Events.DIRTY] ?? 0) === 1,
|
||||
deleted: Number(row[Events.DELETED] ?? 0) === 1,
|
||||
cells,
|
||||
attendees,
|
||||
reminders,
|
||||
baseline: decodeBaseline(row[Events.SYNC_DATA4]),
|
||||
pending: decodePending(row[Events.SYNC_DATA3]),
|
||||
poison: decodePoison(row[Events.SYNC_DATA5]),
|
||||
mutators: str(row[Events.MUTATORS]),
|
||||
};
|
||||
}
|
||||
|
||||
/** The recurrence id of an exception row: SYNC_DATA2 on our rows, else ORIGINAL_INSTANCE_TIME in the master's zone. */
|
||||
export function exceptionKey(row: LocalEventRow, master: LocalEventRow | null): string | null {
|
||||
const stored = row.cells[Events.SYNC_DATA2];
|
||||
if (typeof stored === 'string' && parseLocalDateTime(stored)) return stored;
|
||||
const oit = num(row.cells[Events.ORIGINAL_INSTANCE_TIME]);
|
||||
if (oit === null) return null;
|
||||
const source = master ?? row;
|
||||
const allDay = Number(source.cells[Events.ALL_DAY] ?? row.cells[Events.ORIGINAL_ALL_DAY] ?? 0) === 1;
|
||||
const zone = canonicalZone(source.cells[Events.EVENT_TIMEZONE]) ?? 'UTC';
|
||||
return instanceTimeToKey(oit, allDay, allDay ? 'UTC' : zone);
|
||||
}
|
||||
|
||||
export function decodeEvents(events: Row[], attendees: Row[], reminders: Row[]): LocalEvent[] {
|
||||
const attendeesOf = new Map<number, LocalAttendee[]>();
|
||||
for (const a of attendees) {
|
||||
const id = Number(a[Attendees.EVENT_ID]);
|
||||
const list = attendeesOf.get(id) ?? [];
|
||||
const cells: Row = { ...a };
|
||||
delete cells[Attendees._ID];
|
||||
delete cells[Attendees.EVENT_ID];
|
||||
list.push({ id: Number(a[Attendees._ID]), cells });
|
||||
attendeesOf.set(id, list);
|
||||
}
|
||||
const remindersOf = new Map<number, LocalReminder[]>();
|
||||
for (const r of reminders) {
|
||||
const id = Number(r[Reminders.EVENT_ID]);
|
||||
const list = remindersOf.get(id) ?? [];
|
||||
const cells: Row = { ...r };
|
||||
delete cells[Reminders._ID];
|
||||
delete cells[Reminders.EVENT_ID];
|
||||
list.push({ id: Number(r[Reminders._ID]), cells });
|
||||
remindersOf.set(id, list);
|
||||
}
|
||||
|
||||
const masters: LocalEvent[] = [];
|
||||
const exceptionRows: LocalEventRow[] = [];
|
||||
for (const row of [...events].sort((a, b) => Number(a[Events._ID]) - Number(b[Events._ID]))) {
|
||||
const id = Number(row[Events._ID]);
|
||||
const decoded = decodeRow(row, attendeesOf.get(id) ?? [], remindersOf.get(id) ?? []);
|
||||
if (isExceptionRow(decoded.cells)) {
|
||||
exceptionRows.push(decoded);
|
||||
} else {
|
||||
masters.push({ ...decoded, shadow: decodeShadow(row[Events.SYNC_DATA1]), exceptions: [] });
|
||||
}
|
||||
}
|
||||
|
||||
const byId = new Map(masters.map((m) => [m.eventId, m]));
|
||||
const bySyncId = new Map<string, LocalEvent[]>();
|
||||
for (const m of masters) {
|
||||
if (!m.syncId) continue;
|
||||
const list = bySyncId.get(m.syncId) ?? [];
|
||||
list.push(m);
|
||||
bySyncId.set(m.syncId, list);
|
||||
}
|
||||
|
||||
const orphans: LocalEvent[] = [];
|
||||
for (const row of exceptionRows) {
|
||||
const originalId = num(row.cells[Events.ORIGINAL_ID]);
|
||||
let master = originalId !== null ? byId.get(originalId) : undefined;
|
||||
if (!master) {
|
||||
const candidates = bySyncId.get(String(row.cells[Events.ORIGINAL_SYNC_ID] ?? '')) ?? [];
|
||||
master = candidates.find((m) => m.calendarRowId === row.calendarRowId) ?? candidates[0];
|
||||
}
|
||||
if (!master) {
|
||||
orphans.push({ ...row, shadow: null, exceptions: [] });
|
||||
continue;
|
||||
}
|
||||
const exception: LocalException = {
|
||||
...row,
|
||||
recurrenceId: exceptionKey(row, master),
|
||||
originalInstanceTime: num(row.cells[Events.ORIGINAL_INSTANCE_TIME]),
|
||||
};
|
||||
master.exceptions.push(exception);
|
||||
}
|
||||
|
||||
// A CONTENT_EXCEPTION_URI split: the oldest row keeps the identity, the others are new events.
|
||||
for (const group of bySyncId.values()) {
|
||||
if (group.length < 2) continue;
|
||||
group.sort((a, b) => a.eventId - b.eventId);
|
||||
group[0].split = 'source';
|
||||
for (const clone of group.slice(1)) clone.split = 'clone';
|
||||
}
|
||||
// A new row carrying a uid another row uses: create it under a fresh uid.
|
||||
const uidCount = new Map<string, number>();
|
||||
const uidsOf = (m: LocalEvent) => {
|
||||
const uids = new Set<string>();
|
||||
const uid = str(m.cells[Events.UID_2445]);
|
||||
if (uid) uids.add(uid);
|
||||
const pendingUid = pendingUidOf(m.syncId) ?? m.pending?.uid;
|
||||
if (pendingUid) uids.add(pendingUid);
|
||||
return uids;
|
||||
};
|
||||
for (const m of masters) for (const uid of uidsOf(m)) uidCount.set(uid, (uidCount.get(uid) ?? 0) + 1);
|
||||
for (const m of masters) {
|
||||
if (m.split || m.syncId !== null) continue;
|
||||
if ([...uidsOf(m)].some((uid) => (uidCount.get(uid) ?? 0) > 1)) m.split = 'clone';
|
||||
}
|
||||
|
||||
return [...masters, ...orphans];
|
||||
}
|
||||
|
||||
export function decodeCalendar(row: Row): LocalCalendar {
|
||||
const flags = parseJsonColumn<Record<string, unknown>>(row[Calendars.CAL_SYNC3]);
|
||||
const shadow = parseJsonColumn<CalendarLike>(row[Calendars.CAL_SYNC2]);
|
||||
const readOnly = flags?.readOnly === 'rights' || flags?.readOnly === 'subscription' ? flags.readOnly : undefined;
|
||||
return {
|
||||
calendarRowId: Number(row[Calendars._ID]),
|
||||
syncId: str(row[Calendars._SYNC_ID]),
|
||||
cells: { ...row },
|
||||
shadow: shadow && typeof shadow === 'object' && !Array.isArray(shadow) && typeof shadow.id === 'string' ? shadow : null,
|
||||
flags: flags && typeof flags === 'object' ? { ...(readOnly ? { readOnly } : {}), ...(flags.taskOnly === true ? { taskOnly: true } : {}) } : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a master has no server identity yet (no `_SYNC_ID`, or a pending one). */
|
||||
export function isNewMaster(local: LocalEventRow): boolean {
|
||||
return parseObjectRef(local.syncId) === null;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Durations in the three dialects device sync meets (docs/device-sync.md,
|
||||
* "Timing"):
|
||||
*
|
||||
* - JSCalendar (RFC 8984 §1.4.6) on the server: weeks, days and a time part
|
||||
* with fractional seconds; `P1W` arrives from CalDAV DTENDs. The app's own
|
||||
* parsers ignore weeks (`P1W` → 0) and signs, so none of them is reused.
|
||||
* - AOSP's calendarcommon2 Duration in the DURATION column: `[+-]P` then
|
||||
* `<digits><W|D|H|M|S>`, `T` ignored, `M` always minutes, no fractions and
|
||||
* no `Y`. All-day rows must say `P<n>D`: CalendarProvider rewrites `P<n>S`
|
||||
* with `Integer.parseInt` and throws on anything else ending in `S`.
|
||||
* - Whole days are nominal (a calendar day in the event's zone), the rest is
|
||||
* exact time.
|
||||
*/
|
||||
import {
|
||||
addToWall,
|
||||
canonicalZone,
|
||||
resolveWallClock,
|
||||
wallClockAt,
|
||||
wallMs,
|
||||
type WallClock,
|
||||
} from './zoned-time';
|
||||
|
||||
export interface Duration {
|
||||
negative: boolean;
|
||||
/** Whole nominal days (weeks included). */
|
||||
days: number;
|
||||
/** The exact part in seconds (may be fractional). */
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
const DAY_S = 86_400;
|
||||
|
||||
/**
|
||||
* Parses any duration either side writes: RFC 8984/5545 text, signed or not,
|
||||
* weeks and days, `M` as minutes, a fraction on the seconds, lower case, and
|
||||
* AOSP's forms (`P3600S`, `P1DT0S`, `P0DT1H30M0S`). Null for anything else
|
||||
* (years, months before the time part, an empty `P`).
|
||||
*/
|
||||
export function parseDuration(text: unknown): Duration | null {
|
||||
if (typeof text !== 'string') return null;
|
||||
const m = /^([+-])?P(.*)$/.exec(text.trim().toUpperCase());
|
||||
if (!m || !m[2]) return null;
|
||||
let days = 0;
|
||||
let seconds = 0;
|
||||
let any = false;
|
||||
const token = /^(?:T|(\d+(?:[.,]\d+)?)([WDHMS]))/;
|
||||
let rest = m[2];
|
||||
while (rest) {
|
||||
const t = token.exec(rest);
|
||||
if (!t) return null;
|
||||
rest = rest.slice(t[0].length);
|
||||
if (t[0] === 'T') continue;
|
||||
const unit = t[2];
|
||||
const fractional = /[.,]/.test(t[1]);
|
||||
if (fractional && unit !== 'S') return null;
|
||||
const n = Number(t[1].replace(',', '.'));
|
||||
any = true;
|
||||
if (unit === 'W') days += n * 7;
|
||||
else if (unit === 'D') days += n;
|
||||
else if (unit === 'H') seconds += n * 3600;
|
||||
else if (unit === 'M') seconds += n * 60;
|
||||
else seconds += n;
|
||||
}
|
||||
if (!any) return null;
|
||||
return { negative: m[1] === '-', days, seconds };
|
||||
}
|
||||
|
||||
/** Total seconds with a day counted as 24 hours, signed: CalendarProvider's arithmetic. */
|
||||
export function durationSeconds(d: Duration): number {
|
||||
const total = d.days * DAY_S + d.seconds;
|
||||
return d.negative ? -total : total;
|
||||
}
|
||||
|
||||
/** Seconds of a duration text, or null when it can't be read. */
|
||||
export function durationTextSeconds(text: unknown): number | null {
|
||||
const d = parseDuration(text);
|
||||
return d ? durationSeconds(d) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A JSCalendar Duration in the strict grammar: `P<n>D`, `T<h>H<m>M<s>S` with
|
||||
* minutes kept between hours and seconds (`PT1H0M5S`; `PT1H5S` is not
|
||||
* valid), whole seconds, never weeks, `PT0S` for zero. Negative input is
|
||||
* treated as zero: an event's duration is unsigned.
|
||||
*/
|
||||
export function formatJsDuration(days: number, seconds: number): string {
|
||||
const d = Math.max(0, Math.floor(days));
|
||||
let s = Math.max(0, Math.round(seconds));
|
||||
let out = 'P';
|
||||
if (d > 0) out += `${d}D`;
|
||||
if (s > 0) {
|
||||
const h = Math.floor(s / 3600);
|
||||
s -= h * 3600;
|
||||
const min = Math.floor(s / 60);
|
||||
s -= min * 60;
|
||||
out += 'T';
|
||||
if (h > 0) out += `${h}H`;
|
||||
if (min > 0 || (h > 0 && s > 0)) out += `${min}M`;
|
||||
if (s > 0) out += `${s}S`;
|
||||
}
|
||||
return out === 'P' ? 'PT0S' : out;
|
||||
}
|
||||
|
||||
/** A fixed number of seconds as a JSCalendar duration, with whole days split off as nominal days. */
|
||||
export function formatJsDurationSeconds(totalSeconds: number): string {
|
||||
const s = Math.max(0, Math.round(totalSeconds));
|
||||
return formatJsDuration(Math.floor(s / DAY_S), s % DAY_S);
|
||||
}
|
||||
|
||||
/**
|
||||
* The DURATION column: `P<n>D` for all-day rows (at least one day; a time
|
||||
* part is rounded up), `P<seconds>S` for timed rows. Never a fraction, a
|
||||
* `Y`, a sign or weeks.
|
||||
*/
|
||||
export function formatAospDuration(totalSeconds: number, allDay: boolean): string {
|
||||
if (allDay) return `P${Math.max(1, Math.ceil(totalSeconds / DAY_S))}D`;
|
||||
return `P${Math.max(0, Math.round(totalSeconds))}S`;
|
||||
}
|
||||
|
||||
/** Whole days of an all-day duration on the device: at least one, a time part rounded up. */
|
||||
export function allDayDays(d: Duration | null): number {
|
||||
if (!d || d.negative) return 1;
|
||||
return Math.max(1, Math.ceil((d.days * DAY_S + d.seconds) / DAY_S));
|
||||
}
|
||||
|
||||
/**
|
||||
* The end of a duration that starts at `startUtc`: whole days in the zone's
|
||||
* wall clock, then exact seconds. With no whole days the start instant is
|
||||
* used as is, so a start that is the second instant of a repeated hour does
|
||||
* not collapse onto the first one.
|
||||
*/
|
||||
export function addDuration(startUtc: number, d: Duration, zone: string): number {
|
||||
const days = d.negative ? 0 : d.days;
|
||||
const seconds = d.negative ? 0 : d.seconds;
|
||||
if (days === 0) return startUtc + Math.round(seconds * 1000);
|
||||
const z = canonicalZone(zone) ?? 'UTC';
|
||||
const wall = wallClockAt(startUtc, z);
|
||||
const shifted = resolveWallClock(addToWall(wall, days), z).utc + (startUtc % 1000 + 1000) % 1000;
|
||||
return shifted + Math.round(seconds * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* DTSTART/DTEND as a duration with whole days nominal in `zone` and the rest
|
||||
* exact: 00:00 to 00:00 the next day is `P1D` even across a DST change.
|
||||
*/
|
||||
export function durationBetween(startUtc: number, endUtc: number, zone: string): Duration {
|
||||
if (!(endUtc > startUtc)) return { negative: false, days: 0, seconds: 0 };
|
||||
const z = canonicalZone(zone) ?? 'UTC';
|
||||
const sw = wallClockAt(startUtc, z);
|
||||
const ew = wallClockAt(endUtc, z);
|
||||
let days = Math.round((dateMs(ew) - dateMs(sw)) / 86_400_000);
|
||||
if (timeOfDay(ew) < timeOfDay(sw)) days -= 1;
|
||||
days = Math.max(0, days);
|
||||
let anchor = days > 0 ? addDuration(startUtc, { negative: false, days, seconds: 0 }, z) : startUtc;
|
||||
while (days > 0 && anchor > endUtc) {
|
||||
days -= 1;
|
||||
anchor = days > 0 ? addDuration(startUtc, { negative: false, days, seconds: 0 }, z) : startUtc;
|
||||
}
|
||||
return { negative: false, days, seconds: Math.max(0, (endUtc - anchor) / 1000) };
|
||||
}
|
||||
|
||||
function dateMs(w: WallClock): number {
|
||||
return wallMs({ ...w, hour: 0, minute: 0, second: 0 });
|
||||
}
|
||||
|
||||
function timeOfDay(w: WallClock): number {
|
||||
return w.hour * 3600 + w.minute * 60 + w.second;
|
||||
}
|
||||
|
||||
/** Equal lengths with a day counted as 24 hours (`PT24H` and `P1D` are the same to CalendarProvider). */
|
||||
export function sameDurationLength(a: unknown, b: unknown): boolean {
|
||||
const sa = durationTextSeconds(a);
|
||||
const sb = durationTextSeconds(b);
|
||||
if (sa === null || sb === null) return (a ?? null) === (b ?? null);
|
||||
return sa === sb;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Device edits of attendees, reminders and the location, turned into
|
||||
* JSCalendar values (docs/device-sync.md, "Other properties").
|
||||
*
|
||||
* - Attendees: the user's own status is their RSVP; an added row is a new
|
||||
* participant (`roles: {attendee: true}`, `needs-action`, `expectReply`);
|
||||
* a removed row deletes its participants. Other people's statuses, the
|
||||
* organizer relationship and, for lossy editors, the attendee type are not
|
||||
* the device's to change.
|
||||
* - Reminders replace the alerts a Reminders row can hold, keeping the keys
|
||||
* of those still there; the others stay.
|
||||
* - The location edits the first location's name, adds one, or deletes it.
|
||||
*
|
||||
* Changes come back as paths (raw member names) so the caller escapes them
|
||||
* once, with `ptr`, wherever the patch is rooted.
|
||||
*/
|
||||
import type { Alert, Participant } from '../../api/types';
|
||||
import { Attendees, AttendeeType, Events } from '../android-columns';
|
||||
import type { Row } from '../types';
|
||||
import type { CalendarLike, CalendarEventWire } from '../wire';
|
||||
import { clone } from '../common/json';
|
||||
import { addressGroups, isSelfAddress, participantIdFor, type SelfContext } from './attendees';
|
||||
import { attendeeDiffers, attendeesByKey } from './units';
|
||||
import { effectiveAlerts, remindersToAlerts } from './reminders';
|
||||
import { participationFromDevice } from './values';
|
||||
|
||||
export type MintKey = (taken?: Iterable<string>) => string;
|
||||
|
||||
/** One change: a path of raw member names below some root, and the value (null removes). */
|
||||
export interface PathChange {
|
||||
path: string[];
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export interface AttendeeEdit {
|
||||
kind: 'add' | 'remove' | 'change';
|
||||
key: string;
|
||||
row?: Row;
|
||||
baseline?: Row;
|
||||
}
|
||||
|
||||
/** What the device changed in the attendee rows since `baselineRows`. */
|
||||
export function attendeeEdits(currentRows: Row[], baselineRows: Row[], self: SelfContext, lossy: boolean): AttendeeEdit[] {
|
||||
const cur = attendeesByKey(currentRows, self);
|
||||
const bl = attendeesByKey(baselineRows, self);
|
||||
const edits: AttendeeEdit[] = [];
|
||||
for (const key of new Set([...bl.keys(), ...cur.keys()])) {
|
||||
const row = cur.get(key);
|
||||
const baseline = bl.get(key);
|
||||
if (row && !baseline) edits.push({ kind: 'add', key, row });
|
||||
else if (!row && baseline) edits.push({ kind: 'remove', key, baseline });
|
||||
else if (attendeeDiffers(row, baseline, lossy)) edits.push({ kind: 'change', key, row, baseline });
|
||||
}
|
||||
return edits;
|
||||
}
|
||||
|
||||
/** Whether an attendee address key is the user's. */
|
||||
export function isSelfKey(key: string): boolean {
|
||||
return key.startsWith('self:');
|
||||
}
|
||||
|
||||
/** A participant for an attendee row an app added. */
|
||||
export function newParticipant(row: Row, self: SelfContext): Participant {
|
||||
const email = String(row[Attendees.ATTENDEE_EMAIL] ?? '').trim();
|
||||
const p: Participant = {
|
||||
'@type': 'Participant',
|
||||
calendarAddress: `mailto:${email}`,
|
||||
roles: { attendee: true },
|
||||
participationStatus: 'needs-action',
|
||||
expectReply: true,
|
||||
};
|
||||
const name = row[Attendees.ATTENDEE_NAME];
|
||||
if (typeof name === 'string' && name.trim()) p.name = name.trim();
|
||||
const type = Number(row[Attendees.ATTENDEE_TYPE]);
|
||||
if (type === AttendeeType.OPTIONAL) p.roles = { attendee: true, optional: true };
|
||||
if (type === AttendeeType.RESOURCE) p.kind = 'resource';
|
||||
if (isSelfAddress(email, self)) {
|
||||
const status = participationFromDevice(row[Attendees.ATTENDEE_STATUS]);
|
||||
if (status) p.participationStatus = status;
|
||||
delete p.expectReply;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
export interface AttendeeChanges {
|
||||
/** Changes below the participants map (`[id]`, `[id, 'participationStatus']`, …). */
|
||||
changes: PathChange[];
|
||||
/** The whole map after the edits. */
|
||||
participants: Record<string, Participant>;
|
||||
/** The user's own status changed. */
|
||||
rsvp: boolean;
|
||||
/** Someone was added or removed, or a name or type changed. */
|
||||
others: boolean;
|
||||
}
|
||||
|
||||
/** Applies attendee edits to a participants map (not changed in place). */
|
||||
export function applyAttendeeEdits(
|
||||
participants: Record<string, Participant> | null | undefined,
|
||||
edits: AttendeeEdit[],
|
||||
self: SelfContext,
|
||||
mintKey: MintKey,
|
||||
lossy: boolean,
|
||||
): AttendeeChanges {
|
||||
const map = clone(participants ?? {}) as Record<string, Participant>;
|
||||
const changes: PathChange[] = [];
|
||||
let rsvp = false;
|
||||
let others = false;
|
||||
const groups = addressGroups(map, self);
|
||||
const idsOf = (key: string) => groups.find((g) => g.key === key)?.ids ?? [];
|
||||
const setStatus = (id: string, row: Row, baseline?: Row) => {
|
||||
if (baseline && Number(row[Attendees.ATTENDEE_STATUS]) === Number(baseline[Attendees.ATTENDEE_STATUS])) return;
|
||||
const status = participationFromDevice(row[Attendees.ATTENDEE_STATUS]);
|
||||
if (!status || map[id].participationStatus === status) return;
|
||||
map[id] = { ...map[id], participationStatus: status };
|
||||
changes.push({ path: [id, 'participationStatus'], value: status });
|
||||
rsvp = true;
|
||||
};
|
||||
for (const edit of edits) {
|
||||
const selfRow = isSelfKey(edit.key);
|
||||
if (edit.kind === 'add') {
|
||||
const existing = participantIdFor(map, edit.row![Attendees.ATTENDEE_EMAIL], self);
|
||||
if (existing) {
|
||||
// The server has this address already (added meanwhile): only the user's own status counts.
|
||||
if (selfRow) setStatus(existing, edit.row!);
|
||||
continue;
|
||||
}
|
||||
// An app adding the owner's row next to new guests: Stalwart adds the organizer itself.
|
||||
if (selfRow) continue;
|
||||
const id = mintKey(Object.keys(map));
|
||||
map[id] = newParticipant(edit.row!, self);
|
||||
changes.push({ path: [id], value: map[id] });
|
||||
others = true;
|
||||
} else if (edit.kind === 'remove') {
|
||||
if (selfRow) continue;
|
||||
for (const id of idsOf(edit.key)) {
|
||||
delete map[id];
|
||||
changes.push({ path: [id], value: null });
|
||||
others = true;
|
||||
}
|
||||
} else {
|
||||
const id = participantIdFor(map, edit.row![Attendees.ATTENDEE_EMAIL], self);
|
||||
if (!id) continue;
|
||||
if (selfRow) setStatus(id, edit.row!, edit.baseline);
|
||||
const name = edit.row![Attendees.ATTENDEE_NAME];
|
||||
if (String(name ?? '') !== String(edit.baseline![Attendees.ATTENDEE_NAME] ?? '')) {
|
||||
if (typeof name === 'string' && name.trim()) {
|
||||
map[id] = { ...map[id], name: name.trim() };
|
||||
changes.push({ path: [id, 'name'], value: name.trim() });
|
||||
} else if (map[id].name !== undefined) {
|
||||
const { name: _gone, ...rest } = map[id];
|
||||
map[id] = rest;
|
||||
changes.push({ path: [id, 'name'], value: null });
|
||||
}
|
||||
others = true;
|
||||
}
|
||||
const type = Number(edit.row![Attendees.ATTENDEE_TYPE]);
|
||||
if (!lossy && type !== Number(edit.baseline![Attendees.ATTENDEE_TYPE])) {
|
||||
const roles = { ...(map[id].roles ?? { attendee: true }) };
|
||||
if (type === AttendeeType.OPTIONAL) roles.optional = true;
|
||||
else delete roles.optional;
|
||||
map[id] = { ...map[id], roles };
|
||||
changes.push({ path: [id, 'roles'], value: roles });
|
||||
if (type === AttendeeType.RESOURCE && map[id].kind !== 'resource') {
|
||||
map[id] = { ...map[id], kind: 'resource' };
|
||||
changes.push({ path: [id, 'kind'], value: 'resource' });
|
||||
} else if (type !== AttendeeType.RESOURCE && map[id].kind === 'resource') {
|
||||
const { kind: _gone, ...rest } = map[id];
|
||||
map[id] = rest;
|
||||
changes.push({ path: [id, 'kind'], value: null });
|
||||
}
|
||||
others = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { changes, participants: map, rsvp, others };
|
||||
}
|
||||
|
||||
export interface AlertChanges {
|
||||
/** Changes relative to the event: `['alerts', k]`, `['useDefaultAlerts']` or the whole `['alerts']`. */
|
||||
changes: PathChange[];
|
||||
/** The alerts in force afterwards. */
|
||||
alerts: Record<string, Alert>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The device's reminders as alert changes. With `useDefaultAlerts` the
|
||||
* calendar's defaults were in force: the event switches to explicit alerts
|
||||
* (built from them). An event without alerts gets the whole map, since a
|
||||
* pointer below a missing property fails.
|
||||
*/
|
||||
export function reminderChanges(
|
||||
event: Pick<CalendarEventWire, 'alerts' | 'useDefaultAlerts' | 'showWithoutTime'>,
|
||||
calendar: CalendarLike | undefined,
|
||||
reminders: Row[],
|
||||
mintKey: MintKey,
|
||||
): AlertChanges {
|
||||
const current = effectiveAlerts(event, calendar);
|
||||
const { alerts, removed, added } = remindersToAlerts(current, reminders, mintKey);
|
||||
const changes: PathChange[] = [];
|
||||
if (event.useDefaultAlerts) {
|
||||
changes.push({ path: ['useDefaultAlerts'], value: false });
|
||||
changes.push({ path: ['alerts'], value: Object.keys(alerts).length ? alerts : null });
|
||||
} else if (!Object.keys(event.alerts ?? {}).length) {
|
||||
if (Object.keys(added).length) changes.push({ path: ['alerts'], value: alerts });
|
||||
} else {
|
||||
for (const key of removed) changes.push({ path: ['alerts', key], value: null });
|
||||
for (const [key, alert] of Object.entries(added)) changes.push({ path: ['alerts', key], value: alert });
|
||||
}
|
||||
return { changes, alerts };
|
||||
}
|
||||
|
||||
/** The location edit, as changes relative to the event and as the resulting map. */
|
||||
export function locationChanges(
|
||||
event: Pick<CalendarEventWire, 'locations'>,
|
||||
name: unknown,
|
||||
mintKey: MintKey,
|
||||
): { changes: PathChange[]; locations: Record<string, unknown> | null } {
|
||||
const locations = clone((event.locations ?? {}) as Record<string, Record<string, unknown>>);
|
||||
const first = Object.keys(locations)[0];
|
||||
const text = typeof name === 'string' ? name.trim() : '';
|
||||
const changes: PathChange[] = [];
|
||||
if (!text) {
|
||||
if (first) {
|
||||
delete locations[first];
|
||||
changes.push({ path: ['locations', first], value: null });
|
||||
}
|
||||
} else if (first) {
|
||||
locations[first] = { ...locations[first], name: text };
|
||||
changes.push({ path: ['locations', first, 'name'], value: text });
|
||||
} else {
|
||||
const key = mintKey();
|
||||
locations[key] = { '@type': 'Location', name: text };
|
||||
changes.push({ path: ['locations'], value: { [key]: locations[key] } });
|
||||
}
|
||||
return { changes, locations: Object.keys(locations).length ? locations : null };
|
||||
}
|
||||
|
||||
/** The title cell as JSCalendar text. */
|
||||
export function titleValue(cells: Row): string {
|
||||
const t = cells[Events.TITLE];
|
||||
return typeof t === 'string' ? t : t === null || t === undefined ? '' : String(t);
|
||||
}
|
||||
|
||||
/** The description cell as JSCalendar text; null removes it. */
|
||||
export function descriptionValue(cells: Row): string | null {
|
||||
const d = cells[Events.DESCRIPTION];
|
||||
return typeof d === 'string' && d ? d : null;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Recurrence overrides and the exception rows they become
|
||||
* (docs/device-sync.md, "Recurrence").
|
||||
*
|
||||
* An override value is a PatchObject against the master (RFC 8984 §4.3.4),
|
||||
* so an exception row shows master ⊕ override, applied pointer by pointer
|
||||
* (the app's own expansion shallow-merges and turns
|
||||
* `participants/p1/participationStatus` into a junk key, R3 B10). Stalwart
|
||||
* drops a handful of properties from every override it stores; they are
|
||||
* never sent in one.
|
||||
*/
|
||||
import type { CalendarEventWire } from '../wire';
|
||||
import { applyPatch, ptrSegments } from '../common/patch';
|
||||
import { clone } from '../common/json';
|
||||
import { dateToUtcMidnight, localToUtc, utcMidnightToDate, utcToLocal } from './zoned-time';
|
||||
|
||||
/** Properties calcard removes from an override (`remove_forbidden_override_patches`). */
|
||||
export const FORBIDDEN_OVERRIDE_KEYS = [
|
||||
'@type',
|
||||
'method',
|
||||
'organizerCalendarAddress',
|
||||
'privacy',
|
||||
'prodId',
|
||||
'recurrenceId',
|
||||
'recurrenceIdTimeZone',
|
||||
'sentBy',
|
||||
'uid',
|
||||
'recurrenceRule',
|
||||
'recurrenceOverrides',
|
||||
] as const;
|
||||
|
||||
const FORBIDDEN = new Set<string>(FORBIDDEN_OVERRIDE_KEYS);
|
||||
|
||||
/** Properties of the master that never belong to an instance. */
|
||||
const SERIES_ONLY = ['recurrenceRule', 'recurrenceRules', 'recurrenceOverrides', 'excludedRecurrenceRule', 'excludedRecurrenceRules'];
|
||||
|
||||
export type Override = Record<string, unknown>;
|
||||
|
||||
export function isExcluded(override: unknown): boolean {
|
||||
return !!override && typeof override === 'object' && (override as Override).excluded === true;
|
||||
}
|
||||
|
||||
/** An override without what Stalwart would drop from it (top-level and pointer keys alike). */
|
||||
export function withoutForbiddenKeys(override: Override): Override {
|
||||
const out: Override = {};
|
||||
for (const [key, value] of Object.entries(override)) {
|
||||
if (FORBIDDEN.has(ptrSegments(key)[0])) continue;
|
||||
out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* One instance of a series: the master without its recurrence, patched with
|
||||
* the override (pointer by pointer; a pointer whose parent is missing is
|
||||
* skipped rather than failing the instance), starting at the recurrence id
|
||||
* unless the override moves it.
|
||||
*/
|
||||
export function instanceOf(master: CalendarEventWire, key: string, override?: Override | null): CalendarEventWire {
|
||||
let instance = clone(master) as Record<string, unknown>;
|
||||
for (const name of SERIES_ONLY) delete instance[name];
|
||||
instance.start = key;
|
||||
for (const [pointer, value] of Object.entries(override ?? {})) {
|
||||
if (pointer === 'excluded' || pointer === 'updated') continue;
|
||||
if (FORBIDDEN.has(ptrSegments(pointer)[0])) continue;
|
||||
const patched = applyPatch(instance, { [pointer]: value });
|
||||
if (patched) instance = patched;
|
||||
}
|
||||
return instance as CalendarEventWire;
|
||||
}
|
||||
|
||||
/** ORIGINAL_INSTANCE_TIME of a recurrence id: its instant in the master's zone, UTC midnight for all-day series. */
|
||||
export function keyToInstanceTime(key: string, allDay: boolean, zone: string): number | null {
|
||||
return allDay ? dateToUtcMidnight(key) : localToUtc(key, zone);
|
||||
}
|
||||
|
||||
/** The recurrence id of an ORIGINAL_INSTANCE_TIME (the inverse of `keyToInstanceTime`). */
|
||||
export function instanceTimeToKey(originalInstanceTime: number, allDay: boolean, zone: string): string {
|
||||
return allDay ? utcMidnightToDate(originalInstanceTime) : utcToLocal(originalInstanceTime, zone);
|
||||
}
|
||||
|
||||
/** The overrides of an event as a plain map (Stalwart omits the property when there are none). */
|
||||
export function overridesOf(event: CalendarEventWire | null | undefined): Record<string, Override> {
|
||||
const map = event?.recurrenceOverrides;
|
||||
return map && typeof map === 'object' ? (map as Record<string, Override>) : {};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* The EXDATE column ↔ excluded recurrence overrides (docs/device-sync.md,
|
||||
* "Recurrence"). AOSP's RecurrenceSet reads lines split by `\n`, each
|
||||
* `[<tzid>;]<date>[,<date>…]` with a bare Olson id as prefix; dates are
|
||||
* `YYYYMMDD`, `YYYYMMDDTHHMMSS` or `…Z`, and a line without a prefix is UTC,
|
||||
* not the event's zone. Device sync writes one line without a prefix: UTC
|
||||
* date-times for timed events, dates for all-day ones.
|
||||
*/
|
||||
import {
|
||||
canonicalZone,
|
||||
dateToUtcMidnight,
|
||||
formatDate,
|
||||
localToUtc,
|
||||
resolveWallClock,
|
||||
utcMidnightToDate,
|
||||
utcToLocal,
|
||||
wallFromMs,
|
||||
type WallClock,
|
||||
} from './zoned-time';
|
||||
import type { RuleZone } from './rrule';
|
||||
|
||||
const compactUtc = (utc: number) => {
|
||||
const w = wallFromMs(utc);
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${formatDate(w).replace(/-/g, '')}T${p(w.hour)}${p(w.minute)}${p(w.second)}Z`;
|
||||
};
|
||||
|
||||
/** One EXDATE entry for a recurrence id (a LocalDateTime in the event's zone). */
|
||||
export function exdateEntry(key: string, ctx: RuleZone): string | null {
|
||||
if (ctx.allDay) {
|
||||
const midnight = dateToUtcMidnight(key);
|
||||
return midnight === null ? null : formatDate(wallFromMs(midnight)).replace(/-/g, '');
|
||||
}
|
||||
const utc = localToUtc(key, ctx.zone);
|
||||
return utc === null ? null : compactUtc(utc);
|
||||
}
|
||||
|
||||
/** The EXDATE column for a set of recurrence ids, sorted; null for none. */
|
||||
export function formatExdate(keys: Iterable<string>, ctx: RuleZone): string | null {
|
||||
const entries = [...new Set([...keys].map((k) => exdateEntry(k, ctx)).filter((e): e is string => !!e))].sort();
|
||||
return entries.length ? entries.join(',') : null;
|
||||
}
|
||||
|
||||
interface ParsedEntry {
|
||||
/** The instant (timed) or UTC midnight of the date. */
|
||||
utc: number;
|
||||
dateOnly: boolean;
|
||||
}
|
||||
|
||||
function parseEntry(text: string, zone: string | null): ParsedEntry | null {
|
||||
const m = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/i.exec(text.trim());
|
||||
if (!m) return null;
|
||||
const wall: WallClock = {
|
||||
year: Number(m[1]),
|
||||
month: Number(m[2]),
|
||||
day: Number(m[3]),
|
||||
hour: Number(m[4] ?? 0),
|
||||
minute: Number(m[5] ?? 0),
|
||||
second: Number(m[6] ?? 0),
|
||||
};
|
||||
const dateOnly = m[4] === undefined;
|
||||
if (m[7] || !zone) return { utc: resolveWallClock(wall, 'UTC').utc, dateOnly };
|
||||
return { utc: resolveWallClock(wall, zone).utc, dateOnly };
|
||||
}
|
||||
|
||||
/** Every entry of an EXDATE (or RDATE) column as an instant; garbage is skipped. */
|
||||
export function exdateInstants(text: unknown): ParsedEntry[] {
|
||||
if (typeof text !== 'string' || !text.trim()) return [];
|
||||
const out: ParsedEntry[] = [];
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
if (!line.trim()) continue;
|
||||
const semi = line.indexOf(';');
|
||||
const zone = semi >= 0 ? canonicalZone(line.slice(0, semi).trim()) : null;
|
||||
const dates = semi >= 0 ? line.slice(semi + 1) : line;
|
||||
for (const d of dates.split(',')) {
|
||||
const entry = parseEntry(d, zone);
|
||||
if (entry) out.push(entry);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The recurrence ids an EXDATE column excludes, in the event's zone
|
||||
* (all-day: the date at midnight). A timed entry's key is its instant in the
|
||||
* zone; a date-only entry of a timed event is AOSP's UTC midnight of it.
|
||||
*/
|
||||
export function parseExdate(text: unknown, ctx: RuleZone): string[] {
|
||||
const keys = new Set<string>();
|
||||
for (const entry of exdateInstants(text)) {
|
||||
keys.add(ctx.allDay ? utcMidnightToDate(entry.utc) : utcToLocal(entry.utc, ctx.zone));
|
||||
}
|
||||
return [...keys].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* The entries as a set of normalised instants (`YYYYMMDDTHHMMSSZ`, or
|
||||
* `YYYYMMDD` for dates and for every entry of an all-day event): the
|
||||
* column's meaning independent of how an app spelled it.
|
||||
*/
|
||||
export function exdateSet(text: unknown, allDay = false): Set<string> {
|
||||
const out = new Set<string>();
|
||||
for (const entry of exdateInstants(text)) {
|
||||
out.add(entry.dateOnly || allDay ? formatDate(wallFromMs(entry.utc)).replace(/-/g, '') : compactUtc(entry.utc));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The recurrence id of one normalised entry (see `exdateSet`). */
|
||||
export function exdateEntryKey(entry: string, ctx: RuleZone): string | null {
|
||||
const parsed = parseEntry(entry, null);
|
||||
if (!parsed) return null;
|
||||
return ctx.allDay ? utcMidnightToDate(parsed.utc) : utcToLocal(parsed.utc, ctx.zone);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* A server event as the rows it becomes on the device: the master row's
|
||||
* mapped cells, its attendees and reminders, and one exception row per
|
||||
* override that is not an exclusion (docs/device-sync.md, "Calendar
|
||||
* mapping"). The cells are what CalendarProvider stores after the write:
|
||||
* all-day times at UTC midnight, DURATION only on recurring masters, the
|
||||
* organizer the provider would fill in, STATUS never NULL. Predicting the
|
||||
* provider keeps echo downloads free of writes.
|
||||
*/
|
||||
import { Events } from '../android-columns';
|
||||
import type { CalendarContext } from '../planner';
|
||||
import type { Row } from '../types';
|
||||
import type { CalendarEventWire } from '../wire';
|
||||
import { htmlToPlainText } from '../../lib/compose-html';
|
||||
import { attendeeRows, hasMailtoParticipants, organizerAddress } from './attendees';
|
||||
import { truncateUtf8 } from './columns';
|
||||
import { formatExdate } from './exdate';
|
||||
import { instanceOf, isExcluded, keyToInstanceTime, overridesOf } from './exceptions';
|
||||
import { effectiveAlerts, reminderRows } from './reminders';
|
||||
import { isRuleRepresentable, ruleToRRule } from './rrule';
|
||||
import { eventZone, timingCells } from './timing';
|
||||
import {
|
||||
availabilityToDevice,
|
||||
cssColorToArgb,
|
||||
privacyToDevice,
|
||||
statusToDevice,
|
||||
} from './values';
|
||||
import { parseLocalDateTime } from './zoned-time';
|
||||
|
||||
export type ImageContext = Pick<
|
||||
CalendarContext,
|
||||
'deviceZone' | 'ownerAccount' | 'selfAddresses' | 'reminderOwner' | 'calendar'
|
||||
>;
|
||||
|
||||
export interface RowImage {
|
||||
cells: Row;
|
||||
attendees: Row[];
|
||||
/** Null when Bulwark owns reminders: Reminders rows are then left alone. */
|
||||
reminders: Row[] | null;
|
||||
/** Whether the event has `mailto:` participants (ORGANIZER is written only then). */
|
||||
hasParticipants: boolean;
|
||||
truncatedDescription: boolean;
|
||||
}
|
||||
|
||||
export interface ExceptionImage extends RowImage {
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface EventImage {
|
||||
master: RowImage;
|
||||
/** Exception rows by recurrence id. */
|
||||
exceptions: Map<string, ExceptionImage>;
|
||||
/** Recurrence ids of `{excluded: true}` overrides (the EXDATE column). */
|
||||
excluded: Set<string>;
|
||||
zone: string;
|
||||
allDay: boolean;
|
||||
floating: boolean;
|
||||
recurring: boolean;
|
||||
/** False when the rule needs RSCALE, SKIP or leap months: shown approximately, timing never uploaded. */
|
||||
representable: boolean;
|
||||
}
|
||||
|
||||
/** DESCRIPTION: HTML turned to text (and uploaded as text/plain only when edited), cut at 64 KB. */
|
||||
export function descriptionCell(event: CalendarEventWire): { text: string | null; truncated: boolean } {
|
||||
let text = typeof event.description === 'string' ? event.description : '';
|
||||
if (text && String(event.descriptionContentType ?? '').toLowerCase().startsWith('text/html')) text = htmlToPlainText(text);
|
||||
if (!text) return { text: null, truncated: false };
|
||||
const cut = truncateUtf8(text);
|
||||
return { text: cut.text, truncated: cut.truncated };
|
||||
}
|
||||
|
||||
/** The first location's name, in server order. */
|
||||
export function locationCell(event: CalendarEventWire): string | null {
|
||||
const first = Object.values(event.locations ?? {})[0];
|
||||
const name = first && typeof first === 'object' ? first.name : undefined;
|
||||
return typeof name === 'string' && name ? name : null;
|
||||
}
|
||||
|
||||
function rowImage(
|
||||
event: CalendarEventWire,
|
||||
timing: Row,
|
||||
calendarRowId: number,
|
||||
calendarId: string,
|
||||
ctx: ImageContext,
|
||||
): RowImage {
|
||||
const description = descriptionCell(event);
|
||||
const hasParticipants = hasMailtoParticipants(event.participants);
|
||||
const cells: Row = {
|
||||
[Events.CALENDAR_ID]: calendarRowId,
|
||||
[Events.TITLE]: typeof event.title === 'string' && event.title ? event.title : null,
|
||||
[Events.DESCRIPTION]: description.text,
|
||||
[Events.EVENT_LOCATION]: locationCell(event),
|
||||
[Events.STATUS]: statusToDevice(event.status),
|
||||
[Events.AVAILABILITY]: availabilityToDevice(event.freeBusyStatus),
|
||||
[Events.ACCESS_LEVEL]: privacyToDevice(event.privacy),
|
||||
[Events.EVENT_COLOR]: cssColorToArgb(event.color),
|
||||
[Events.ORGANIZER]: (hasParticipants ? organizerAddress(event, ctx) : null) ?? ctx.ownerAccount,
|
||||
...timing,
|
||||
};
|
||||
const reminders =
|
||||
ctx.reminderOwner === 'device'
|
||||
? reminderRows(effectiveAlerts(event, ctx.calendar(calendarId)))
|
||||
: null;
|
||||
return {
|
||||
cells,
|
||||
attendees: hasParticipants ? attendeeRows(event, ctx) : [],
|
||||
reminders,
|
||||
hasParticipants,
|
||||
truncatedDescription: description.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows of a server event in the calendar row `calendarRowId` (server
|
||||
* calendar `calendarId`, whose default alerts apply). Null when the event's
|
||||
* start can't be read.
|
||||
*/
|
||||
export function eventImage(
|
||||
event: CalendarEventWire,
|
||||
calendarRowId: number,
|
||||
calendarId: string,
|
||||
ctx: ImageContext,
|
||||
): EventImage | null {
|
||||
const { zone, allDay, floating } = eventZone(event, ctx.deviceZone);
|
||||
const rule = event.recurrenceRule ?? null;
|
||||
const rrule = rule ? ruleToRRule(rule, { allDay, zone }) : null;
|
||||
const recurring = rrule !== null;
|
||||
const timing = timingCells(event, recurring, ctx.deviceZone);
|
||||
if (!timing) return null;
|
||||
const overrides = recurring ? overridesOf(event) : {};
|
||||
const excluded = new Set<string>();
|
||||
const exceptions = new Map<string, ExceptionImage>();
|
||||
for (const [key, override] of Object.entries(overrides)) {
|
||||
if (!parseLocalDateTime(key) || !override || typeof override !== 'object') continue;
|
||||
if (isExcluded(override)) {
|
||||
excluded.add(key);
|
||||
continue;
|
||||
}
|
||||
const originalInstanceTime = keyToInstanceTime(key, allDay, zone);
|
||||
if (originalInstanceTime === null) continue;
|
||||
const instance = instanceOf(event, key, override);
|
||||
// An instance keeps the series' kind of time; only its start and length move.
|
||||
const instanceTiming = timingCells(
|
||||
{ ...instance, showWithoutTime: event.showWithoutTime, timeZone: instance.timeZone ?? event.timeZone },
|
||||
false,
|
||||
ctx.deviceZone,
|
||||
);
|
||||
if (!instanceTiming) continue;
|
||||
const image = rowImage(instance, instanceTiming, calendarRowId, calendarId, ctx);
|
||||
image.cells[Events.ORIGINAL_INSTANCE_TIME] = originalInstanceTime;
|
||||
image.cells[Events.ORIGINAL_ALL_DAY] = allDay ? 1 : 0;
|
||||
exceptions.set(key, { ...image, key });
|
||||
}
|
||||
const master = rowImage(event, timing, calendarRowId, calendarId, ctx);
|
||||
master.cells[Events.RRULE] = rrule;
|
||||
master.cells[Events.EXDATE] = recurring ? formatExdate(excluded, { allDay, zone }) : null;
|
||||
return {
|
||||
master,
|
||||
exceptions,
|
||||
excluded,
|
||||
zone,
|
||||
allDay,
|
||||
floating,
|
||||
recurring,
|
||||
representable: isRuleRepresentable(rule),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The row an instance of the series would have without an exception row:
|
||||
* the master at that recurrence id. The baseline of an exception an app
|
||||
* creates, and what its changes are measured against.
|
||||
*/
|
||||
export function plainInstanceImage(
|
||||
master: CalendarEventWire,
|
||||
key: string,
|
||||
calendarRowId: number,
|
||||
calendarId: string,
|
||||
ctx: ImageContext,
|
||||
): ExceptionImage | null {
|
||||
const { zone, allDay } = eventZone(master, ctx.deviceZone);
|
||||
const override = overridesOf(master)[key];
|
||||
const instance = instanceOf(master, key, override && !isExcluded(override) ? override : null);
|
||||
const timing = timingCells(
|
||||
{ ...instance, showWithoutTime: master.showWithoutTime, timeZone: instance.timeZone ?? master.timeZone },
|
||||
false,
|
||||
ctx.deviceZone,
|
||||
);
|
||||
const originalInstanceTime = keyToInstanceTime(key, allDay, zone);
|
||||
if (!timing || originalInstanceTime === null) return null;
|
||||
const image = rowImage(instance, timing, calendarRowId, calendarId, ctx);
|
||||
image.cells[Events.ORIGINAL_INSTANCE_TIME] = originalInstanceTime;
|
||||
image.cells[Events.ORIGINAL_ALL_DAY] = allDay ? 1 : 0;
|
||||
return { ...image, key };
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
/**
|
||||
* A server event into its rows (docs/device-sync.md, "Change detection and
|
||||
* merge rules"). One writer serves downloads, accepted uploads, reverts and
|
||||
* zone changes:
|
||||
*
|
||||
* - a clean row is rewritten to the image of the server event (only cells
|
||||
* that differ are written, so an echo writes nothing);
|
||||
* - a dirty row is merged per unit against its baseline (what the device
|
||||
* changed) and the old shadow's image (what the server changed): the server
|
||||
* wins real conflicts, local changes the server did not touch stay dirty
|
||||
* for the upload;
|
||||
* - exception rows are matched to overrides by recurrence id and merged the
|
||||
* same way; a device deletion of an instance wins over a server edit, a
|
||||
* server removal wins over a device edit.
|
||||
*
|
||||
* Every write into an existing item is guarded: the group first asserts
|
||||
* DIRTY and the projection the plan was made from (with attendees and
|
||||
* reminders for the rows that are dirty: a clean row's DIRTY catches any app
|
||||
* edit of them) and the number of exception rows.
|
||||
*/
|
||||
import { Events } from '../android-columns';
|
||||
import type { CalendarContext, EventBaseline, LocalEvent, LocalEventRow, LocalException, OpGroup } from '../planner';
|
||||
import type { Row } from '../types';
|
||||
import type { CalendarEventWire } from '../wire';
|
||||
import { collectionKey, exceptionRef, objectRef } from '../common/ids';
|
||||
import { EventStatus } from '../android-columns';
|
||||
import type { SelfContext } from './attendees';
|
||||
import { eventImage, plainInstanceImage, type EventImage, type ExceptionImage, type RowImage } from './image';
|
||||
import {
|
||||
GroupBuilder,
|
||||
assertExceptionCount,
|
||||
assertRow,
|
||||
deleteRow,
|
||||
eventInsert,
|
||||
eventWrite,
|
||||
insertRow,
|
||||
updateRow,
|
||||
type RowState,
|
||||
} from './rows';
|
||||
import {
|
||||
COLUMN_UNITS,
|
||||
EXCEPTION_COLUMN_UNITS,
|
||||
MASTER_COLUMN_UNITS,
|
||||
attendeeDiffers,
|
||||
attendeesByKey,
|
||||
baselineOfImage,
|
||||
columnUnitDiffers,
|
||||
deviceChangedUnit,
|
||||
exdateEntries,
|
||||
isLossyEditor,
|
||||
remindersDiffer,
|
||||
sideOfBaseline,
|
||||
sideOfImage,
|
||||
sideOfRow,
|
||||
} from './units';
|
||||
|
||||
export interface CalendarTarget {
|
||||
calendarRowId: number;
|
||||
calendarId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar row an event lives in: the row it is already in while the
|
||||
* event still belongs to that calendar, else its first selected calendar by
|
||||
* id (docs/device-sync.md, "Decisions and limitations").
|
||||
*/
|
||||
export function pickCalendar(
|
||||
event: CalendarEventWire,
|
||||
local: LocalEventRow | null,
|
||||
ctx: Pick<CalendarContext, 'jmapAccountId' | 'calendarRowId' | 'calendarIdOfRow' | 'isSelected'>,
|
||||
): CalendarTarget | null {
|
||||
const ids = Object.entries(event.calendarIds ?? {})
|
||||
.filter(([, on]) => on)
|
||||
.map(([id]) => id)
|
||||
.sort();
|
||||
const candidates = ids.filter((id) => ctx.calendarRowId(id) !== null && ctx.isSelected(collectionKey(ctx.jmapAccountId, id)));
|
||||
if (local) {
|
||||
const current = ctx.calendarIdOfRow(local.calendarRowId);
|
||||
if (current && current.jmapAccountId === ctx.jmapAccountId && candidates.includes(current.calendarId)) {
|
||||
return { calendarRowId: local.calendarRowId, calendarId: current.calendarId };
|
||||
}
|
||||
}
|
||||
if (!candidates.length) return null;
|
||||
return { calendarRowId: ctx.calendarRowId(candidates[0])!, calendarId: candidates[0] };
|
||||
}
|
||||
|
||||
// ─── One row ────────────────────────────────────────────
|
||||
|
||||
export interface RowMerge {
|
||||
cells: Row;
|
||||
attendees: Row[];
|
||||
reminders: Row[] | null;
|
||||
baseline: EventBaseline;
|
||||
/** Units whose local change survived (the row stays dirty). */
|
||||
kept: string[];
|
||||
conflicts: number;
|
||||
}
|
||||
|
||||
export interface RowMergeInput {
|
||||
isException: boolean;
|
||||
current: LocalEventRow;
|
||||
/** What the device last wrote (SYNC_DATA4), or an image standing in for it. */
|
||||
baseline: EventBaseline;
|
||||
/** The old server state's image of this row. */
|
||||
base: RowImage;
|
||||
/** The new server state's image of this row. */
|
||||
remote: RowImage;
|
||||
/** Whether the row's differences from its baseline are edits (DIRTY). */
|
||||
dirty: boolean;
|
||||
/** Units counted as edited even on a clean row (a split source's rule). */
|
||||
forced?: ReadonlySet<string>;
|
||||
/** An editor that rewrites attendee types (Fossify). */
|
||||
lossy?: boolean;
|
||||
self: SelfContext;
|
||||
}
|
||||
|
||||
/** Per-unit merge of one row (see the header): what to write, the new baseline, what stays dirty. */
|
||||
export function mergeRow(input: RowMergeInput): RowMerge {
|
||||
const { current, baseline, base, remote, dirty } = input;
|
||||
const forced = input.forced ?? new Set<string>();
|
||||
const cur = sideOfRow(current);
|
||||
const bl = sideOfBaseline(baseline);
|
||||
const bs = sideOfImage(base);
|
||||
const rm = sideOfImage(remote);
|
||||
const cells: Row = { ...remote.cells };
|
||||
const remoteBaseline = baselineOfImage(remote, input.isException);
|
||||
const baseCells: Row = { ...remoteBaseline.cells };
|
||||
const kept: string[] = [];
|
||||
let conflicts = 0;
|
||||
|
||||
const decide = (unit: string, local: boolean, remoteChanged: boolean, same: boolean): 'local' | 'remote' => {
|
||||
if (!local) return 'remote';
|
||||
if (!remoteChanged) {
|
||||
kept.push(unit);
|
||||
return 'local';
|
||||
}
|
||||
if (!same) conflicts += 1;
|
||||
return 'remote';
|
||||
};
|
||||
|
||||
for (const unit of input.isException ? EXCEPTION_COLUMN_UNITS : MASTER_COLUMN_UNITS) {
|
||||
const local = (dirty || forced.has(unit)) && deviceChangedUnit(unit, cur, bl);
|
||||
const choice = decide(unit, local, columnUnitDiffers(unit, bs, rm), !columnUnitDiffers(unit, cur, rm));
|
||||
if (choice === 'local') {
|
||||
for (const c of COLUMN_UNITS[unit]) {
|
||||
cells[c] = current.cells[c] ?? null;
|
||||
baseCells[c] = baseline.cells[c] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!input.isException) {
|
||||
const curE = exdateEntries(cur);
|
||||
const blE = exdateEntries(bl);
|
||||
const bsE = exdateEntries(bs);
|
||||
const rmE = exdateEntries(rm);
|
||||
const final = new Set<string>();
|
||||
const finalBaseline = new Set<string>();
|
||||
for (const e of new Set([...curE, ...blE, ...bsE, ...rmE])) {
|
||||
const choice = decide(`exdate:${e}`, dirty && curE.has(e) !== blE.has(e), bsE.has(e) !== rmE.has(e), curE.has(e) === rmE.has(e));
|
||||
if (choice === 'remote') {
|
||||
if (rmE.has(e)) {
|
||||
final.add(e);
|
||||
finalBaseline.add(e);
|
||||
}
|
||||
} else {
|
||||
if (curE.has(e)) final.add(e);
|
||||
if (blE.has(e)) finalBaseline.add(e);
|
||||
}
|
||||
}
|
||||
const recurring = !!cells[Events.RRULE];
|
||||
// Our own column text when nothing local is kept, so an echo compares equal character for character.
|
||||
cells[Events.EXDATE] = !recurring || !final.size ? null : sameEntries(final, rmE) ? remote.cells[Events.EXDATE] : [...final].sort().join(',');
|
||||
baseCells[Events.EXDATE] = !finalBaseline.size ? null : [...finalBaseline].sort().join(',');
|
||||
}
|
||||
|
||||
const curA = attendeesByKey(cur.attendees, input.self);
|
||||
const blA = attendeesByKey(bl.attendees, input.self);
|
||||
const bsA = attendeesByKey(bs.attendees, input.self);
|
||||
const rmA = attendeesByKey(rm.attendees, input.self);
|
||||
const attendees: Row[] = [];
|
||||
const baselineAttendees: Row[] = [];
|
||||
for (const key of new Set([...rmA.keys(), ...curA.keys(), ...blA.keys(), ...bsA.keys()])) {
|
||||
const local = dirty && attendeeDiffers(curA.get(key), blA.get(key), input.lossy);
|
||||
const choice = decide(
|
||||
`attendee:${key}`,
|
||||
local,
|
||||
attendeeDiffers(bsA.get(key), rmA.get(key)),
|
||||
!attendeeDiffers(curA.get(key), rmA.get(key), input.lossy),
|
||||
);
|
||||
if (choice === 'remote') {
|
||||
const r = rmA.get(key);
|
||||
if (r) {
|
||||
attendees.push(r);
|
||||
baselineAttendees.push(r);
|
||||
}
|
||||
} else {
|
||||
const c = curA.get(key);
|
||||
const b = blA.get(key);
|
||||
if (c) attendees.push(c);
|
||||
if (b) baselineAttendees.push(b);
|
||||
}
|
||||
}
|
||||
|
||||
let reminders: Row[] | null = null;
|
||||
let baselineReminders: Row[];
|
||||
if (remote.reminders === null) {
|
||||
// Bulwark owns reminders: the rows are an app's business, neither written nor uploaded.
|
||||
baselineReminders = current.reminders.map((r) => r.cells);
|
||||
} else {
|
||||
const local = dirty && remindersDiffer(cur, bl);
|
||||
const choice = decide('reminders', local, remindersDiffer(bs, rm), !remindersDiffer(cur, rm));
|
||||
reminders = choice === 'remote' ? remote.reminders : current.reminders.map((r) => r.cells);
|
||||
baselineReminders = choice === 'remote' ? remote.reminders : bl.reminders ?? [];
|
||||
}
|
||||
|
||||
return {
|
||||
cells,
|
||||
attendees,
|
||||
reminders,
|
||||
baseline: { cells: baseCells, attendees: baselineAttendees, reminders: baselineReminders },
|
||||
kept,
|
||||
conflicts,
|
||||
};
|
||||
}
|
||||
|
||||
/** Exception rows the provider links to the master by ORIGINAL_ID (what the count assert counts). */
|
||||
export function linkedExceptionCount(local: LocalEvent): number {
|
||||
return local.exceptions.filter((x) => Number(x.cells[Events.ORIGINAL_ID]) === local.eventId).length;
|
||||
}
|
||||
|
||||
function sameEntries(a: Set<string>, b: Set<string>): boolean {
|
||||
return a.size === b.size && [...a].every((e) => b.has(e));
|
||||
}
|
||||
|
||||
// ─── A whole event ──────────────────────────────────────
|
||||
|
||||
export type WriteMode =
|
||||
/** A download: dirty rows are merged per unit. */
|
||||
| 'merge'
|
||||
/** After an accepted upload, a revert or a zone change: every row becomes the image, DIRTY cleared. */
|
||||
| 'overwrite';
|
||||
|
||||
export interface EventWriteResult {
|
||||
/** One group, or a chain for an event too big for one (see `eventWrite`). */
|
||||
ops: OpGroup;
|
||||
conflicts: number;
|
||||
stillDirty: boolean;
|
||||
}
|
||||
|
||||
/** Whether an exception row stands for "this instance was deleted" (a DELETED row or a new cancellation). */
|
||||
export function isRemovedInstance(row: LocalEventRow): boolean {
|
||||
if (row.deleted) return true;
|
||||
if (Number(row.cells[Events.STATUS]) !== EventStatus.CANCELED) return false;
|
||||
// A cancellation the row already had when device sync wrote it is the server's own status.
|
||||
return !row.baseline || Number(row.baseline.cells[Events.STATUS]) !== EventStatus.CANCELED;
|
||||
}
|
||||
|
||||
export interface EventWriteOptions {
|
||||
mode: WriteMode;
|
||||
ref: string;
|
||||
ctx: CalendarContext;
|
||||
/** The old server state the rows were written from; null: adopt (the rows' changes are measured against `event`). */
|
||||
base: CalendarEventWire | null;
|
||||
/** SYNC_DATA3 to write; undefined leaves it. */
|
||||
pending?: null;
|
||||
/** SYNC_DATA5 to write; undefined leaves it. */
|
||||
clearPoison?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ops that bring a local event (master and exceptions) to `event`,
|
||||
* merged per `options.mode`. `image` is `event`'s image in its calendar.
|
||||
*/
|
||||
export function writeEvent(
|
||||
event: CalendarEventWire,
|
||||
image: EventImage,
|
||||
target: CalendarTarget,
|
||||
local: LocalEvent,
|
||||
options: EventWriteOptions,
|
||||
): EventWriteResult {
|
||||
const { ctx } = options;
|
||||
const overwrite = options.mode === 'overwrite';
|
||||
const masterRef = objectRef(ctx.jmapAccountId, event.id);
|
||||
const uid = typeof event.uid === 'string' && event.uid ? event.uid : null;
|
||||
const baseEvent = options.base ?? event;
|
||||
const baseTarget = options.base ? { calendarRowId: local.calendarRowId, calendarId: ctx.calendarIdOfRow(local.calendarRowId)?.calendarId ?? target.calendarId } : target;
|
||||
const baseImage = options.base ? eventImage(baseEvent, baseTarget.calendarRowId, baseTarget.calendarId, ctx) ?? image : image;
|
||||
const lossy = isLossyEditor(local.mutators);
|
||||
|
||||
// Asserts first: the rows as read. Attendees and reminders only for dirty rows (see `assertRow`): a big
|
||||
// meeting's occurrences would otherwise assert every attendee and outgrow one provider transaction.
|
||||
const opsOf = (build: (group: GroupBuilder) => void) => {
|
||||
const group = new GroupBuilder(options.ref);
|
||||
build(group);
|
||||
return group.build().ops;
|
||||
};
|
||||
const masterAsserts = opsOf((g) => assertRow(g, local, false, local.dirty || local.deleted));
|
||||
const rowAsserts = new Map(local.exceptions.map((x) => [x.eventId, opsOf((g) => assertRow(g, x, true, x.dirty || x.deleted))]));
|
||||
const countAssert = opsOf((g) => assertExceptionCount(g, local.eventId, linkedExceptionCount(local)));
|
||||
|
||||
let conflicts = 0;
|
||||
let stillDirty = false;
|
||||
|
||||
const masterMerge = mergeRow({
|
||||
isException: false,
|
||||
current: local,
|
||||
baseline: options.base && local.baseline ? local.baseline : baselineOfImage(options.base ? baseImage.master : image.master, false),
|
||||
base: baseImage.master,
|
||||
remote: image.master,
|
||||
dirty: !overwrite && local.dirty,
|
||||
// A CONTENT_EXCEPTION_URI split capped this rule without DIRTY.
|
||||
forced: !overwrite && local.split === 'source' ? new Set(['rule']) : undefined,
|
||||
lossy,
|
||||
self: ctx,
|
||||
});
|
||||
conflicts += masterMerge.conflicts;
|
||||
if (masterMerge.kept.length) stillDirty = true;
|
||||
const masterState: RowState = {
|
||||
isException: false,
|
||||
cells: masterMerge.cells,
|
||||
attendees: masterMerge.attendees,
|
||||
reminders: masterMerge.reminders,
|
||||
baseline: masterMerge.baseline,
|
||||
syncId: masterRef,
|
||||
uid,
|
||||
shadow: event,
|
||||
...(options.pending === null ? { pending: null } : {}),
|
||||
...(options.clearPoison ? { poison: null } : {}),
|
||||
clearDirty: overwrite || (local.dirty && !masterMerge.kept.length),
|
||||
hasParticipants: image.master.hasParticipants,
|
||||
};
|
||||
|
||||
const deletes: LocalException[] = [];
|
||||
const updates: Array<{ row: LocalEventRow; state: RowState }> = [];
|
||||
const inserts: RowState[] = [];
|
||||
|
||||
const byKey = new Map<string, LocalException[]>();
|
||||
for (const x of local.exceptions) {
|
||||
if (!x.recurrenceId) continue;
|
||||
const list = byKey.get(x.recurrenceId) ?? [];
|
||||
list.push(x);
|
||||
byKey.set(x.recurrenceId, list);
|
||||
}
|
||||
const keys = new Set<string>([...byKey.keys(), ...image.exceptions.keys(), ...baseImage.exceptions.keys()]);
|
||||
const finalCalendarRowId = Number(masterMerge.cells[Events.CALENDAR_ID]);
|
||||
|
||||
const exceptionState = (key: string, merged: { cells: Row; attendees: Row[]; reminders: Row[] | null; baseline: EventBaseline }, dirtyLeft: boolean, row: LocalEventRow | null, ours: boolean): RowState => ({
|
||||
isException: true,
|
||||
cells: { ...merged.cells, [Events.CALENDAR_ID]: finalCalendarRowId },
|
||||
attendees: merged.attendees,
|
||||
reminders: merged.reminders,
|
||||
baseline: { ...merged.baseline, cells: { ...merged.baseline.cells, [Events.CALENDAR_ID]: finalCalendarRowId } },
|
||||
syncId: ours ? exceptionRef(masterRef, key) : row?.syncId ?? null,
|
||||
uid,
|
||||
recurrenceKey: ours ? key : ((row?.cells[Events.SYNC_DATA2] as string | null | undefined) ?? null),
|
||||
originalSyncId: masterRef,
|
||||
clearDirty: !!row?.dirty && !dirtyLeft,
|
||||
hasParticipants: merged.attendees.length > 0,
|
||||
});
|
||||
|
||||
for (const key of [...keys].sort()) {
|
||||
const rows = byKey.get(key) ?? [];
|
||||
const [row, ...duplicates] = rows;
|
||||
// Two rows for one instance (an app's own duplicate): the extra ones go when clean.
|
||||
for (const extra of duplicates) {
|
||||
if (overwrite || !(extra.dirty || extra.deleted)) deletes.push(extra);
|
||||
else stillDirty = true;
|
||||
}
|
||||
const r = image.exceptions.get(key);
|
||||
const b = baseImage.exceptions.get(key);
|
||||
if (!row) {
|
||||
if (r) inserts.push(imageState(r, finalCalendarRowId, masterRef, uid));
|
||||
continue;
|
||||
}
|
||||
if (overwrite && row.deleted) {
|
||||
// Its exclusion was uploaded (or is being reverted): a soft-deleted row is purged either way.
|
||||
deletes.push(row);
|
||||
if (r) inserts.push(imageState(r, finalCalendarRowId, masterRef, uid));
|
||||
continue;
|
||||
}
|
||||
const rowDirty = !overwrite && (row.dirty || row.deleted);
|
||||
if (!rowDirty) {
|
||||
if (r) updates.push({ row, state: exceptionState(key, { ...r, baseline: baselineOfImage(r, true) }, false, row, true) });
|
||||
else deletes.push(row);
|
||||
continue;
|
||||
}
|
||||
if (isRemovedInstance(row)) {
|
||||
// The device deleted this instance: that wins over a server edit and uploads as an exclusion.
|
||||
stillDirty = true;
|
||||
continue;
|
||||
}
|
||||
if ((b && !r) || image.excluded.has(key)) {
|
||||
// The server removed or excluded the instance's override: the server delete wins.
|
||||
deletes.push(row);
|
||||
conflicts += 1;
|
||||
continue;
|
||||
}
|
||||
const remoteSide = r ?? plainInstanceImage(event, key, finalCalendarRowId, target.calendarId, ctx);
|
||||
const baseSide = b ?? plainInstanceImage(baseEvent, key, baseTarget.calendarRowId, baseTarget.calendarId, ctx) ?? remoteSide;
|
||||
if (!remoteSide || !baseSide) {
|
||||
stillDirty = true;
|
||||
continue;
|
||||
}
|
||||
const merged = mergeRow({
|
||||
isException: true,
|
||||
current: row,
|
||||
baseline: row.baseline ?? baselineOfImage(baseSide, true),
|
||||
base: baseSide,
|
||||
remote: remoteSide,
|
||||
dirty: true,
|
||||
lossy,
|
||||
self: ctx,
|
||||
});
|
||||
conflicts += merged.conflicts;
|
||||
// A local exception the server has no override for keeps its local identity until it is uploaded.
|
||||
const left = merged.kept.length > 0 || !r;
|
||||
if (left) stillDirty = true;
|
||||
updates.push({ row, state: exceptionState(key, merged, left, row, !!r) });
|
||||
}
|
||||
|
||||
const ops = eventWrite(options.ref, {
|
||||
master: masterAsserts,
|
||||
rows: rowAsserts,
|
||||
count: countAssert,
|
||||
deletes: deletes.map((row) => ({ row: row.eventId, asserts: [], writes: opsOf((g) => deleteRow(g, row.eventId)) })),
|
||||
masterWrite: opsOf((g) => updateRow(g, local, masterState)),
|
||||
updates: updates.map(({ row, state }) => ({ row: row.eventId, asserts: [], writes: opsOf((g) => updateRow(g, row, state)) })),
|
||||
inserts: inserts.map((state) => ({ asserts: [], writes: opsOf((g) => insertRow(g, state, { id: local.eventId })) })),
|
||||
});
|
||||
|
||||
return { ops, conflicts, stillDirty: !overwrite && (stillDirty || local.deleted) };
|
||||
}
|
||||
|
||||
/** The row an exception image becomes, written clean. */
|
||||
export function imageState(x: ExceptionImage, calendarRowId: number, masterRef: string, uid: string | null): RowState {
|
||||
const cells = { ...x.cells, [Events.CALENDAR_ID]: calendarRowId };
|
||||
const baseline = baselineOfImage({ ...x, cells }, true);
|
||||
return {
|
||||
isException: true,
|
||||
cells,
|
||||
attendees: x.attendees,
|
||||
reminders: x.reminders,
|
||||
baseline,
|
||||
syncId: exceptionRef(masterRef, x.key),
|
||||
uid,
|
||||
recurrenceKey: x.key,
|
||||
originalSyncId: masterRef,
|
||||
hasParticipants: x.hasParticipants,
|
||||
};
|
||||
}
|
||||
|
||||
function masterInsertState(event: CalendarEventWire, image: EventImage, masterRef: string, uid: string | null): RowState {
|
||||
return {
|
||||
isException: false,
|
||||
cells: image.master.cells,
|
||||
attendees: image.master.attendees,
|
||||
reminders: image.master.reminders,
|
||||
baseline: baselineOfImage(image.master, false),
|
||||
syncId: masterRef,
|
||||
uid,
|
||||
shadow: event,
|
||||
hasParticipants: image.master.hasParticipants,
|
||||
};
|
||||
}
|
||||
|
||||
/** A new event's rows: the master and one row per exception, attendees and reminders included. */
|
||||
export function insertEvent(event: CalendarEventWire, image: EventImage, ctx: CalendarContext, ref: string): GroupBuilder {
|
||||
const group = new GroupBuilder(ref);
|
||||
const masterRef = objectRef(ctx.jmapAccountId, event.id);
|
||||
const uid = typeof event.uid === 'string' && event.uid ? event.uid : null;
|
||||
const master = insertRow(group, masterInsertState(event, image, masterRef, uid));
|
||||
for (const key of [...image.exceptions.keys()].sort()) {
|
||||
const x = image.exceptions.get(key)!;
|
||||
insertRow(group, imageState(x, Number(image.master.cells[Events.CALENDAR_ID]), masterRef, uid), { ref: master });
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
/** A new event's rows as one group, or as a chain for an event too big for one (see `eventInsert`). */
|
||||
export function insertEventOps(event: CalendarEventWire, image: EventImage, ctx: CalendarContext, ref: string): OpGroup {
|
||||
const masterRef = objectRef(ctx.jmapAccountId, event.id);
|
||||
const uid = typeof event.uid === 'string' && event.uid ? event.uid : null;
|
||||
const calendarRowId = Number(image.master.cells[Events.CALENDAR_ID]);
|
||||
const opsOf = (build: (group: GroupBuilder) => void) => {
|
||||
const group = new GroupBuilder(ref);
|
||||
build(group);
|
||||
return group.build().ops;
|
||||
};
|
||||
return eventInsert(ref, insertEvent(event, image, ctx, ref).build(), () => ({
|
||||
masterRef,
|
||||
master: opsOf((g) => insertRow(g, { ...masterInsertState(event, image, masterRef, uid), shadow: undefined })),
|
||||
exceptions: [...image.exceptions.keys()].sort().map((key) => ({
|
||||
asserts: [],
|
||||
writes: opsOf((g) => insertRow(g, imageState(image.exceptions.get(key)!, calendarRowId, masterRef, uid))),
|
||||
})),
|
||||
shadow: event,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Deletions and inserts that are one edit (docs/device-sync.md, "Uploads",
|
||||
* "Pairs"). Etar moves an event to another calendar, and turns a series into
|
||||
* a single event, by deleting the row and inserting a copy. A deleted row
|
||||
* and a new row of the same run whose projections match apart from the
|
||||
* calendar (or the rule) upload as a patch of the existing object, so its
|
||||
* uid, participants and history stay; the new row then takes the old one's
|
||||
* identity. Across JMAP accounts nothing pairs: that move is a create in the
|
||||
* target account and a destroy in the source.
|
||||
*/
|
||||
import { Attendees, Events } from '../android-columns';
|
||||
import type { CalendarContext, EventBaseline, LocalEvent, LocalEventRow, OpGroup } from '../planner';
|
||||
import type { Row } from '../types';
|
||||
import type { CalendarEventWire } from '../wire';
|
||||
import { exceptionRef, parseObjectRef } from '../common/ids';
|
||||
import { clone } from '../common/json';
|
||||
import { applyPatch, type PatchObject } from '../common/patch';
|
||||
import { isSelfAddress, type SelfContext } from './attendees';
|
||||
import { EXCEPTION_CELLS, MASTER_CELLS, encodeBaseline, makeBaseline } from './columns';
|
||||
import { durationTextSeconds } from './duration';
|
||||
import { isExcluded, overridesOf } from './exceptions';
|
||||
import { linkedExceptionCount } from './merge';
|
||||
import { GroupBuilder, assertExceptionCount, assertRow, deleteRow, updateRow } from './rows';
|
||||
import { rruleParts } from './rrule';
|
||||
import { sendsSchedulingMessages } from './scheduling';
|
||||
import { MASTER_COLUMN_UNITS, deviceChangedUnit, exdateEntries, remindersDiffer, sideOfBaseline, sideOfRow } from './units';
|
||||
import { put } from './upload';
|
||||
import { isUnsetStatus } from './values';
|
||||
|
||||
/** What must be equal for two rows to be one event (the calendar and the rule aside). */
|
||||
const PAIR_COLUMNS = [
|
||||
Events.TITLE,
|
||||
Events.DESCRIPTION,
|
||||
Events.EVENT_LOCATION,
|
||||
Events.STATUS,
|
||||
Events.AVAILABILITY,
|
||||
Events.ACCESS_LEVEL,
|
||||
Events.EVENT_COLOR,
|
||||
Events.DTSTART,
|
||||
Events.ALL_DAY,
|
||||
];
|
||||
|
||||
const num = (v: unknown) => (v === null || v === undefined || v === '' ? null : Number(v));
|
||||
|
||||
function lengthMs(cells: Row): number | null {
|
||||
const dtstart = num(cells[Events.DTSTART]);
|
||||
const dtend = num(cells[Events.DTEND]);
|
||||
if (dtstart !== null && dtend !== null) return dtend - dtstart;
|
||||
const seconds = durationTextSeconds(cells[Events.DURATION]);
|
||||
return seconds === null ? null : seconds * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* The attendees' addresses, the user's own left out: Etar's copy of a meeting
|
||||
* has no row for the user when the user was listed (its editor keeps the
|
||||
* owner's row out of its attendee list), and one as the organizer when not.
|
||||
*/
|
||||
const attendeeSet = (row: LocalEventRow, self: SelfContext) =>
|
||||
row.attendees
|
||||
.map((a) => String(a.cells[Attendees.ATTENDEE_EMAIL] ?? '').toLowerCase())
|
||||
.filter((email) => !isSelfAddress(email, self))
|
||||
.sort()
|
||||
.join(',');
|
||||
|
||||
export interface PairKind {
|
||||
/** The same event in another calendar. */
|
||||
move: boolean;
|
||||
/** A series turned into a single event. */
|
||||
unrecur: boolean;
|
||||
}
|
||||
|
||||
/** How a deleted and a new row are one edit, or null. */
|
||||
export function pairKind(deleted: LocalEvent, fresh: LocalEvent, self: SelfContext): PairKind | null {
|
||||
for (const c of PAIR_COLUMNS) {
|
||||
// A copy inserted without STATUS (Google Calendar) says nothing about it.
|
||||
if (c === Events.STATUS && (isUnsetStatus(deleted.cells[c]) || isUnsetStatus(fresh.cells[c]))) continue;
|
||||
if (String(deleted.cells[c] ?? '') !== String(fresh.cells[c] ?? '')) return null;
|
||||
}
|
||||
const allDay = Number(fresh.cells[Events.ALL_DAY] ?? 0) === 1;
|
||||
if (!allDay && String(deleted.cells[Events.EVENT_TIMEZONE] ?? '') !== String(fresh.cells[Events.EVENT_TIMEZONE] ?? '')) return null;
|
||||
if (lengthMs(deleted.cells) !== lengthMs(fresh.cells)) return null;
|
||||
if (attendeeSet(deleted, self) !== attendeeSet(fresh, self)) return null;
|
||||
const move = deleted.calendarRowId !== fresh.calendarRowId;
|
||||
const deletedRule = rruleParts(deleted.cells[Events.RRULE]);
|
||||
const freshRule = rruleParts(fresh.cells[Events.RRULE]);
|
||||
const unrecur = !!deletedRule && !freshRule;
|
||||
if (!unrecur && String(deleted.cells[Events.RRULE] ?? '') !== String(fresh.cells[Events.RRULE] ?? '')) return null;
|
||||
return move || unrecur ? { move, unrecur } : null;
|
||||
}
|
||||
|
||||
/** The patch a pair uploads: the calendar membership swapped, the rule and its overrides removed. */
|
||||
export function pairPatch(
|
||||
deleted: LocalEvent,
|
||||
fresh: LocalEvent,
|
||||
kind: PairKind,
|
||||
ctx: CalendarContext,
|
||||
): { patch: PatchObject; sendSchedulingMessages: boolean } | null {
|
||||
const shadow = deleted.shadow;
|
||||
if (!parseObjectRef(deleted.syncId) || !shadow) return null;
|
||||
const patch: PatchObject = {};
|
||||
if (kind.move) {
|
||||
const from = ctx.calendarIdOfRow(deleted.calendarRowId);
|
||||
const to = ctx.calendarIdOfRow(fresh.calendarRowId);
|
||||
if (!from || !to || from.jmapAccountId !== ctx.jmapAccountId || to.jmapAccountId !== ctx.jmapAccountId) return null;
|
||||
put(patch, ['calendarIds', from.calendarId], null);
|
||||
put(patch, ['calendarIds', to.calendarId], true);
|
||||
}
|
||||
if (kind.unrecur) {
|
||||
put(patch, ['recurrenceRule'], null);
|
||||
if (Object.keys(overridesOf(shadow)).length) put(patch, ['recurrenceOverrides'], null);
|
||||
}
|
||||
// A move alone tells nobody; a series ending its recurrence does.
|
||||
const scheduling = kind.unrecur && sendsSchedulingMessages(shadow, { kind: 'update', notifying: true, rsvp: false, addsAttendees: false }, ctx);
|
||||
return { patch, sendSchedulingMessages: scheduling };
|
||||
}
|
||||
|
||||
const children = (row: LocalEventRow) => ({ attendees: row.attendees.map((a) => a.cells), reminders: row.reminders.map((r) => r.cells) });
|
||||
|
||||
/**
|
||||
* The new row's baseline: the old row's (what the server held, as last
|
||||
* written) with what the patch changed, the calendar and, for a series turned
|
||||
* single, its rule and its timing as a single event. So an edit saved with the
|
||||
* move, or made before it, still differs from the baseline and uploads next.
|
||||
* Attendee rows are the app's re-insert (Etar writes every attendee it copies
|
||||
* as required, without a status), so their details are no edits: they are
|
||||
* taken as they are, like the reminders of a row that copied none.
|
||||
*/
|
||||
function pairedBaseline(deleted: LocalEvent, fresh: LocalEvent, kind: PairKind, ctx: CalendarContext): EventBaseline {
|
||||
const own = makeBaseline(fresh.cells, MASTER_CELLS, children(fresh).attendees, children(fresh).reminders);
|
||||
if (!deleted.baseline) return own;
|
||||
const next = clone(deleted.baseline);
|
||||
next.cells[Events.CALENDAR_ID] = own.cells[Events.CALENDAR_ID];
|
||||
next.attendees = own.attendees;
|
||||
if (ctx.reminderOwner !== 'device' || !fresh.reminders.length) next.reminders = own.reminders;
|
||||
if (kind.unrecur) {
|
||||
const start = num(deleted.baseline.cells[Events.DTSTART]);
|
||||
const length = lengthMs(deleted.baseline.cells);
|
||||
next.cells[Events.DTEND] = start !== null && length !== null ? start + length : own.cells[Events.DTEND];
|
||||
next.cells[Events.DURATION] = null;
|
||||
next.cells[Events.RRULE] = own.cells[Events.RRULE];
|
||||
next.cells[Events.EXDATE] = own.cells[Events.EXDATE];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Whether a master holds a change against `baseline` that an upload would look at. */
|
||||
function changedSince(row: LocalEventRow, baseline: EventBaseline, ctx: CalendarContext): boolean {
|
||||
const cur = sideOfRow(row);
|
||||
const bl = sideOfBaseline(baseline);
|
||||
if (MASTER_COLUMN_UNITS.some((unit) => deviceChangedUnit(unit, cur, bl))) return true;
|
||||
const [now, before] = [exdateEntries(cur), exdateEntries(bl)];
|
||||
if (now.size !== before.size || [...now].some((e) => !before.has(e))) return true;
|
||||
return ctx.reminderOwner === 'device' && remindersDiffer(cur, bl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applied once the pair's patch is accepted: the new row takes the identity
|
||||
* and the old row goes. The old row's exception rows move to the new row with
|
||||
* what they wait to upload (Etar copies only the exceptions it never synced;
|
||||
* an app's copy of one the old row has goes). A copied exception takes the
|
||||
* identity of its override. The shadow is the old one with the patch applied
|
||||
* until the server's comes back. A row that still differs from its baseline
|
||||
* (see `pairedBaseline`) stays dirty and uploads next.
|
||||
*/
|
||||
export function pairOps(deleted: LocalEvent, fresh: LocalEvent, kind: PairKind, patch: PatchObject, ctx: CalendarContext): OpGroup {
|
||||
const syncId = deleted.syncId!;
|
||||
const shadow: CalendarEventWire = applyPatch(deleted.shadow!, patch) ?? deleted.shadow!;
|
||||
const group = new GroupBuilder(syncId);
|
||||
assertRow(group, deleted, false, false);
|
||||
for (const x of deleted.exceptions) assertRow(group, x, true, x.dirty || x.deleted);
|
||||
assertExceptionCount(group, deleted.eventId, linkedExceptionCount(deleted));
|
||||
assertRow(group, fresh, false, true);
|
||||
for (const x of fresh.exceptions) assertRow(group, x, true, true);
|
||||
assertExceptionCount(group, fresh.eventId, linkedExceptionCount(fresh));
|
||||
const kept = new Set<string>();
|
||||
for (const x of deleted.exceptions) {
|
||||
// A series turned single has no occurrences left.
|
||||
if (kind.unrecur || !x.recurrenceId) {
|
||||
deleteRow(group, x.eventId);
|
||||
continue;
|
||||
}
|
||||
kept.add(x.recurrenceId);
|
||||
const values: Row = { [Events.ORIGINAL_ID]: fresh.eventId, [Events.CALENDAR_ID]: fresh.calendarRowId };
|
||||
if (x.baseline) values[Events.SYNC_DATA4] = encodeBaseline({ ...x.baseline, cells: { ...x.baseline.cells, [Events.CALENDAR_ID]: fresh.calendarRowId } });
|
||||
group.write({ op: 'update', table: 'events', id: x.eventId, values, expectCount: 1 });
|
||||
}
|
||||
deleteRow(group, deleted.eventId);
|
||||
const cells: Row = {};
|
||||
for (const c of MASTER_CELLS) cells[c] = fresh.cells[c] ?? null;
|
||||
const baseline = pairedBaseline(deleted, fresh, kind, ctx);
|
||||
updateRow(
|
||||
group,
|
||||
{ ...fresh, shadow: null },
|
||||
{
|
||||
isException: false,
|
||||
cells,
|
||||
...children(fresh),
|
||||
baseline,
|
||||
syncId,
|
||||
uid: typeof shadow.uid === 'string' ? shadow.uid : null,
|
||||
shadow,
|
||||
pending: null,
|
||||
clearDirty: !changedSince(fresh, baseline, ctx),
|
||||
hasParticipants: fresh.attendees.length > 0,
|
||||
},
|
||||
);
|
||||
const overrides = overridesOf(shadow);
|
||||
for (const x of fresh.exceptions) {
|
||||
const key = x.recurrenceId;
|
||||
if (key && kept.has(key)) {
|
||||
deleteRow(group, x.eventId);
|
||||
continue;
|
||||
}
|
||||
const values: Row = { [Events.ORIGINAL_SYNC_ID]: syncId };
|
||||
if (key && overrides[key] && !isExcluded(overrides[key])) {
|
||||
Object.assign(values, {
|
||||
[Events._SYNC_ID]: exceptionRef(syncId, key),
|
||||
[Events.SYNC_DATA2]: key,
|
||||
[Events.SYNC_DATA4]: encodeBaseline(makeBaseline(x.cells, EXCEPTION_CELLS, children(x).attendees, children(x).reminders)),
|
||||
[Events.DIRTY]: 0,
|
||||
});
|
||||
}
|
||||
group.write({ op: 'update', table: 'events', id: x.eventId, values, expectCount: 1 });
|
||||
}
|
||||
return group.build();
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
/**
|
||||
* The calendar planner (docs/device-sync.md, "Calendar mapping"): JSCalendar
|
||||
* events and calendars ↔ CalendarContract rows, pure. The engine owns all
|
||||
* I/O; this module turns rows and server objects into op groups and upload
|
||||
* actions, following the contract in ../planner.ts.
|
||||
*
|
||||
* Tasks never reach CalendarContract: the engine filters them, and every
|
||||
* entry point here refuses one as well.
|
||||
*/
|
||||
import { Events } from '../android-columns';
|
||||
import type {
|
||||
AcceptedPlan,
|
||||
CalendarContext,
|
||||
CalendarPlanner,
|
||||
DownloadPlan,
|
||||
LocalEvent,
|
||||
LocalEventRow,
|
||||
OpGroup,
|
||||
PoisonMarker,
|
||||
UploadAction,
|
||||
UploadPlan,
|
||||
} from '../planner';
|
||||
import type { CalendarEventWire } from '../wire';
|
||||
import { objectRef, parseObjectRef, pendingUidOf } from '../common/ids';
|
||||
import { deepEqual, jsonHash } from '../common/json';
|
||||
import { isTaskLikeObject } from '../../lib/calendar-component-detection';
|
||||
import { keepDirtyGroup, type UploadedUnits } from './accepted';
|
||||
import { ATTENDEE_COLUMNS, CALENDAR_COLUMNS, EVENT_COLUMNS, EXCEPTION_CELLS, MASTER_CELLS, REMINDER_COLUMNS, isTruncatedBaseline, makeBaseline } from './columns';
|
||||
import { planCalendars } from './calendars';
|
||||
import { claimOps, claimUid, claimedUid, computeCreate, createTarget, deleteActions, movedAway } from './create';
|
||||
import { decodeCalendar, decodeEvents, isExceptionRow, isNewMaster } from './decode';
|
||||
import { isExcluded, overridesOf } from './exceptions';
|
||||
import { eventImage } from './image';
|
||||
import { insertEvent, insertEventOps, isRemovedInstance, linkedExceptionCount, pickCalendar, writeEvent, type CalendarTarget } from './merge';
|
||||
import { pairKind, pairOps, pairPatch } from './pairs';
|
||||
import { GroupBuilder, assertExceptionCount, assertRow, deleteRow, writesOf } from './rows';
|
||||
import { eventZone } from './timing';
|
||||
import { columnUnitDiffers, remindersDiffer, sideOfBaseline, sideOfRow } from './units';
|
||||
import { SKIP, SkipUpload, computeUpdate, type UploadComputation } from './upload';
|
||||
|
||||
type Plan = UploadPlan<CalendarEventWire>;
|
||||
|
||||
function refuseTask(event: CalendarEventWire | null | undefined): void {
|
||||
if (event && isTaskLikeObject(event)) {
|
||||
throw new Error('calendar planner: tasks are not synced to the device (filter them before planning)');
|
||||
}
|
||||
}
|
||||
|
||||
function refOf(local: LocalEventRow | null, event?: CalendarEventWire, jmapAccountId?: string): string {
|
||||
if (local?.syncId) return local.syncId;
|
||||
if (event && jmapAccountId) return objectRef(jmapAccountId, event.id);
|
||||
return local ? `row:${local.eventId}` : 'new';
|
||||
}
|
||||
|
||||
/** The calendar a row is in, when it is one of ours. */
|
||||
function rowCalendar(local: LocalEventRow, ctx: CalendarContext): CalendarTarget | null {
|
||||
const cal = ctx.calendarIdOfRow(local.calendarRowId);
|
||||
return cal ? { calendarRowId: local.calendarRowId, calendarId: cal.calendarId } : null;
|
||||
}
|
||||
|
||||
// ─── Downloads ──────────────────────────────────────────
|
||||
|
||||
function planDownload(event: CalendarEventWire, local: LocalEvent | null, ctx: CalendarContext): DownloadPlan {
|
||||
refuseTask(event);
|
||||
refuseTask(local?.shadow);
|
||||
const ref = refOf(local, event, ctx.jmapAccountId);
|
||||
const none = (stillDirty: boolean): DownloadPlan => ({ ops: { ref, ops: [] }, conflicts: 0, stillDirty, effect: 'none', writes: 0 });
|
||||
// A different object under a reused id: its rows are replaced, never patched.
|
||||
const uidChanged = !!local?.shadow && typeof local.shadow.uid === 'string' && typeof event.uid === 'string' && local.shadow.uid !== event.uid;
|
||||
|
||||
if (local?.deleted && local.split !== 'clone' && !uidChanged) {
|
||||
// Deleted on the device: that wins over a server edit, so only the shadow follows, for the deletion.
|
||||
// Also when no synced calendar holds the event any more: the deletion is then moot (see planUpload).
|
||||
if (!local.shadow || deepEqual(local.shadow, event)) return none(true);
|
||||
const group = new GroupBuilder(ref);
|
||||
group.assert({ op: 'assert', table: 'events', id: local.eventId, values: { [Events.DELETED]: 1, [Events._SYNC_ID]: local.syncId }, expectCount: 1 });
|
||||
group.write({ op: 'update', table: 'events', id: local.eventId, values: { [Events.SYNC_DATA1]: JSON.stringify(event) }, expectCount: 1 });
|
||||
const ops = group.build();
|
||||
return { ops, conflicts: 0, stillDirty: true, effect: 'update', writes: writesOf(ops) };
|
||||
}
|
||||
|
||||
// An event another client moved out of every synced calendar still merges into its rows where they are
|
||||
// (they wait for an upload; the accepted write then removes them).
|
||||
const target = pickCalendar(event, local, ctx) ?? (local ? rowCalendar(local, ctx) : null);
|
||||
if (!target) throw new Error(`calendar planner: ${ref} is in no synced calendar (the engine removes such rows)`);
|
||||
const image = eventImage(event, target.calendarRowId, target.calendarId, ctx);
|
||||
// Stalwart drops a start it can't resolve (a DST gap or overlap): nothing to show.
|
||||
if (!image) return none(!!local && (local.dirty || local.deleted));
|
||||
|
||||
if (!local) {
|
||||
const ops = insertEventOps(event, image, ctx, ref);
|
||||
return { ops, conflicts: 0, stillDirty: false, effect: 'insert', writes: writesOf(ops) };
|
||||
}
|
||||
// A split clone only looks like this object: it is a new event and uploads as one. The source row is the object's.
|
||||
if (local.split === 'clone') return none(true);
|
||||
|
||||
if (uidChanged) {
|
||||
const group = new GroupBuilder(ref);
|
||||
for (const x of local.exceptions) deleteRow(group, x.eventId);
|
||||
deleteRow(group, local.eventId);
|
||||
const inserted = insertEvent(event, image, ctx, ref).build().ops;
|
||||
const offset = group.writeCount;
|
||||
for (const op of inserted) {
|
||||
if (op.op === 'insert' && op.refs) {
|
||||
const refs = Object.fromEntries(Object.entries(op.refs).map(([k, v]) => [k, v + offset]));
|
||||
group.write({ ...op, refs });
|
||||
} else group.write(op);
|
||||
}
|
||||
const ops = group.build();
|
||||
return { ops, conflicts: 0, stillDirty: false, effect: 'update', writes: writesOf(ops) };
|
||||
}
|
||||
|
||||
const itemDirty = local.dirty || local.split === 'source' || local.exceptions.some((x) => x.dirty || x.deleted);
|
||||
const result = writeEvent(event, image, target, local, {
|
||||
mode: 'merge',
|
||||
ref,
|
||||
ctx,
|
||||
// No shadow: our own create whose identity was never written. Adopt it; its rows count as edits of `event`.
|
||||
base: local.shadow,
|
||||
...(local.shadow ? {} : { pending: null }),
|
||||
clearPoison: !itemDirty,
|
||||
});
|
||||
const ops = result.ops;
|
||||
const writes = writesOf(ops);
|
||||
return {
|
||||
ops: writes ? ops : { ref, ops: [] },
|
||||
conflicts: result.conflicts,
|
||||
stillDirty: result.stillDirty,
|
||||
effect: writes ? 'update' : 'none',
|
||||
writes,
|
||||
};
|
||||
}
|
||||
|
||||
function planLocalDelete(local: LocalEvent): OpGroup {
|
||||
const group = new GroupBuilder(refOf(local));
|
||||
assertExceptionCount(group, local.eventId, linkedExceptionCount(local));
|
||||
for (const x of local.exceptions) deleteRow(group, x.eventId);
|
||||
deleteRow(group, local.eventId);
|
||||
return group.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites the baselines of clean rows that drifted from them (the provider
|
||||
* normalised a write, or a crash hit between a chunk and its read-back),
|
||||
* behind DIRTY=0. A recurring master's timing and rule are never healed: a
|
||||
* CONTENT_EXCEPTION_URI split changes them without DIRTY.
|
||||
*/
|
||||
function planBaselineHeal(local: LocalEvent): OpGroup | null {
|
||||
if (local.split || local.deleted || isExceptionRow(local.cells)) return null;
|
||||
const group = new GroupBuilder(refOf(local));
|
||||
const heals: Array<{ row: LocalEventRow; isException: boolean; baseline: ReturnType<typeof makeBaseline> }> = [];
|
||||
const consider = (row: LocalEventRow, isException: boolean) => {
|
||||
if (row.dirty || row.deleted) return;
|
||||
const columns = isException ? EXCEPTION_CELLS : MASTER_CELLS;
|
||||
const truncated = isTruncatedBaseline(row.baseline?.cells[Events.DESCRIPTION]) && !columnUnitDiffers('description', sideOfRow(row), sideOfBaseline(row.baseline!));
|
||||
const next = makeBaseline(row.cells, columns, row.attendees.map((a) => a.cells), row.reminders.map((r) => r.cells), truncated);
|
||||
if (truncated) next.cells[Events.DESCRIPTION] = row.baseline!.cells[Events.DESCRIPTION];
|
||||
const recurring = !isException && (!!row.cells[Events.RRULE] || !!row.baseline?.cells[Events.RRULE]);
|
||||
if (recurring && row.baseline) {
|
||||
for (const c of [Events.DTSTART, Events.DTEND, Events.DURATION, Events.EVENT_TIMEZONE, Events.ALL_DAY, Events.RRULE]) {
|
||||
next.cells[c] = row.baseline.cells[c] ?? null;
|
||||
}
|
||||
}
|
||||
if (row.baseline && deepEqual(next, row.baseline)) return;
|
||||
heals.push({ row, isException, baseline: next });
|
||||
};
|
||||
consider(local, false);
|
||||
for (const x of local.exceptions) consider(x, true);
|
||||
if (!heals.length) return null;
|
||||
for (const h of heals) assertRow(group, h.row, h.isException, false);
|
||||
for (const h of heals) {
|
||||
group.write({ op: 'update', table: 'events', id: h.row.eventId, values: { [Events.SYNC_DATA4]: JSON.stringify({ v: 1, ...h.baseline }) }, expectCount: 1 });
|
||||
}
|
||||
return group.build();
|
||||
}
|
||||
|
||||
// ─── Uploads ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The fingerprint a poison marker records for an event: what it would upload
|
||||
* from (its flags, shadow, rows, attendees, reminders and exceptions), so the
|
||||
* back-off holds while nothing changes and lifts when the user edits it. The
|
||||
* engine writes the markers and checks them before planning
|
||||
* (engine/calendar-sync.ts); this is the same content, so an item handed in
|
||||
* anyway is still skipped.
|
||||
*/
|
||||
export function poisonFingerprint(e: LocalEvent): string {
|
||||
return jsonHash({
|
||||
deleted: e.deleted,
|
||||
shadow: e.shadow,
|
||||
cells: e.cells,
|
||||
attendees: e.attendees.map((a) => JSON.stringify(a.cells)).sort(),
|
||||
reminders: e.reminders.map((r) => JSON.stringify(r.cells)).sort(),
|
||||
exceptions: e.exceptions
|
||||
.map((x) => JSON.stringify([x.deleted, x.cells, x.attendees.map((a) => a.cells), x.reminders.map((r) => r.cells)]))
|
||||
.sort(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Writes a poison marker (SYNC_DATA5) on the master. */
|
||||
export function poisonOps(local: LocalEvent, marker: PoisonMarker): OpGroup {
|
||||
return {
|
||||
ref: refOf(local),
|
||||
ops: [{ op: 'update', table: 'events', id: local.eventId, values: { [Events.SYNC_DATA5]: JSON.stringify(marker) }, expectCount: 1 }],
|
||||
};
|
||||
}
|
||||
|
||||
function activePoison(local: LocalEvent, ctx: CalendarContext): PoisonMarker | null {
|
||||
const p = local.poison;
|
||||
if (!p || ctx.now >= p.until) return null;
|
||||
return p.fp === poisonFingerprint(local) ? p : null;
|
||||
}
|
||||
|
||||
/** Deletes a master and its exceptions (a purge, a revert that refetches). */
|
||||
function purgeGroup(local: LocalEvent): OpGroup {
|
||||
return planLocalDelete(local);
|
||||
}
|
||||
|
||||
/** Rows rewritten from the shadow, DIRTY cleared: a read-only event's device edits are undone. */
|
||||
function revertGroup(local: LocalEvent, ctx: CalendarContext): OpGroup | null {
|
||||
const shadow = local.shadow;
|
||||
if (!shadow) return null;
|
||||
const target = pickCalendar(shadow, local, ctx) ?? rowCalendar(local, ctx);
|
||||
if (!target) return null;
|
||||
const image = eventImage(shadow, target.calendarRowId, target.calendarId, ctx);
|
||||
if (!image) return null;
|
||||
return writeEvent(shadow, image, target, local, { mode: 'overwrite', ref: refOf(local), ctx, base: shadow }).ops;
|
||||
}
|
||||
|
||||
/** Clears DIRTY where nothing uploadable changed, taking the rows as they are as the new baselines. */
|
||||
function cleanGroup(local: LocalEvent, computation: UploadComputation | null): OpGroup {
|
||||
const group = new GroupBuilder(refOf(local));
|
||||
// Attendees and reminders of the dirty rows only (see `assertRow`), so a big meeting fits one transaction.
|
||||
assertRow(group, local, false, local.dirty || local.deleted);
|
||||
for (const x of local.exceptions) assertRow(group, x, true, x.dirty || x.deleted);
|
||||
const settle = (row: LocalEventRow, isException: boolean) => {
|
||||
if (!row.dirty) return;
|
||||
const truncated = isTruncatedBaseline(row.baseline?.cells[Events.DESCRIPTION]);
|
||||
const baseline = makeBaseline(row.cells, isException ? EXCEPTION_CELLS : MASTER_CELLS, row.attendees.map((a) => a.cells), row.reminders.map((r) => r.cells), truncated);
|
||||
group.write({ op: 'update', table: 'events', id: row.eventId, values: { [Events.DIRTY]: 0, [Events.SYNC_DATA4]: JSON.stringify({ v: 1, ...baseline }) }, expectCount: 1 });
|
||||
};
|
||||
settle(local, false);
|
||||
const overrides = overridesOf(local.shadow);
|
||||
for (const x of local.exceptions) {
|
||||
const excludedAlready = !!x.recurrenceId && isExcluded(overrides[x.recurrenceId]);
|
||||
if ((computation?.removedExceptions.has(x.eventId) || isRemovedInstance(x)) && excludedAlready) {
|
||||
deleteRow(group, x.eventId);
|
||||
continue;
|
||||
}
|
||||
settle(x, true);
|
||||
}
|
||||
return group.build();
|
||||
}
|
||||
|
||||
function planUpload(local: LocalEvent, ctx: CalendarContext): Plan {
|
||||
refuseTask(local.shadow);
|
||||
// An exception row whose master is gone never uploads as an event of its own.
|
||||
if (isExceptionRow(local.cells)) return { kind: 'purge', ops: purgeGroup(local) };
|
||||
const calendar = ctx.calendarIdOfRow(local.calendarRowId);
|
||||
if (!calendar) return { kind: 'skip', reason: SKIP.notOurCalendar };
|
||||
const isNew = local.split === 'clone' || (!local.shadow && isNewMaster(local));
|
||||
|
||||
if (local.deleted) {
|
||||
if (isNew) {
|
||||
// Never created (or its outcome unknown): destroy whatever carries the pending uid, else just purge.
|
||||
const uid = local.split === 'clone' ? null : local.pending?.uid ?? pendingUidOf(local.syncId);
|
||||
if (!uid) return { kind: 'purge', ops: purgeGroup(local) };
|
||||
return { kind: 'upload', actions: [{ kind: 'destroy', id: null, uid }] };
|
||||
}
|
||||
if (!parseObjectRef(local.syncId)) return { kind: 'purge', ops: purgeGroup(local) };
|
||||
// Moved away on the server before the deletion reached it: nothing goes up, only the rows go.
|
||||
if (movedAway(local, calendar, ctx)) return { kind: 'purge', ops: purgeGroup(local) };
|
||||
if (ctx.isReadOnly(calendar.calendarId)) return { kind: 'revert', ops: purgeGroup(local), refetch: true, reason: SKIP.readOnly };
|
||||
return { kind: 'upload', actions: deleteActions(local, ctx) };
|
||||
}
|
||||
|
||||
if (isNew) {
|
||||
if (ctx.isReadOnly(calendar.calendarId)) return { kind: 'revert', ops: purgeGroup(local), reason: SKIP.readOnly };
|
||||
const uid = claimedUid(local);
|
||||
const target = createTarget(local, ctx);
|
||||
if (!target) return { kind: 'skip', reason: SKIP.notOurCalendar };
|
||||
if (!uid) return { kind: 'claim', ops: claimOps(local, claimUid(local, ctx), target.key) };
|
||||
const poison = activePoison(local, ctx);
|
||||
if (poison) return { kind: 'skip', reason: poison.type };
|
||||
try {
|
||||
const create = computeCreate(local, uid, target.calendarId, ctx);
|
||||
const action: UploadAction<CalendarEventWire> = { kind: 'create', uid, collectionId: target.calendarId, object: create.object };
|
||||
if (create.sendSchedulingMessages) action.sendSchedulingMessages = true;
|
||||
return { kind: 'upload', actions: [action] };
|
||||
} catch (e) {
|
||||
if (e instanceof SkipUpload) return { kind: 'skip', reason: e.reason };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const ref = parseObjectRef(local.syncId);
|
||||
const shadow = local.shadow;
|
||||
if (!ref || !shadow) return { kind: 'skip', reason: 'noShadow' };
|
||||
const poison = activePoison(local, ctx);
|
||||
if (poison) return { kind: 'skip', reason: poison.type };
|
||||
// An instance Stalwart holds without its series can't be changed on its own.
|
||||
if (shadow.recurrenceId && !shadow.recurrenceRule) {
|
||||
const ops = revertGroup(local, ctx);
|
||||
return ops ? { kind: 'revert', ops, reason: SKIP.instanceOnly } : { kind: 'skip', reason: SKIP.instanceOnly };
|
||||
}
|
||||
|
||||
let computation: UploadComputation;
|
||||
try {
|
||||
computation = computeUpdate(local, ctx);
|
||||
} catch (e) {
|
||||
if (e instanceof SkipUpload) return { kind: 'skip', reason: e.reason };
|
||||
throw e;
|
||||
}
|
||||
const keys = Object.keys(computation.patch);
|
||||
const readOnly = ctx.isReadOnly(calendar.calendarId);
|
||||
const movedToReadOnly = keys.some((k) => {
|
||||
const m = /^calendarIds\/(.+)$/.exec(k);
|
||||
return m && computation.patch[k] === true && ctx.isReadOnly(m[1]);
|
||||
});
|
||||
if ((readOnly && keys.length) || movedToReadOnly) {
|
||||
// RSVP, to the series or to occurrences, is the one change a calendar without write rights may take.
|
||||
const rsvp = computation.rsvpOnly && !movedToReadOnly;
|
||||
if (!(rsvp && ctx.calendar(calendar.calendarId)?.myRights?.mayRSVP)) {
|
||||
const ops = revertGroup(local, ctx);
|
||||
const reason = rsvp ? SKIP.rsvpRefused : SKIP.readOnly;
|
||||
return ops ? { kind: 'revert', ops, reason } : { kind: 'skip', reason };
|
||||
}
|
||||
}
|
||||
if (!keys.length) {
|
||||
if (computation.dropped.some((u) => u === 'timing' || u === 'rule')) return { kind: 'skip', reason: SKIP.ruleNotRepresentable };
|
||||
// A move into a calendar of another JMAP account is no patch (only new rows are created across accounts):
|
||||
// the row goes back to its calendar instead of staying there unsynced. With other changes, their accepted
|
||||
// write puts it back.
|
||||
if (computation.dropped.includes('calendar')) {
|
||||
const ops = revertGroup(local, ctx);
|
||||
if (ops) return { kind: 'revert', ops, reason: SKIP.crossAccountMove };
|
||||
}
|
||||
// Every reminder of an occurrence removed where the server can't store none: back to what it holds, reported.
|
||||
if (computation.dropped.includes('reminders')) {
|
||||
const ops = revertGroup(local, ctx);
|
||||
if (ops) return { kind: 'revert', ops, reason: SKIP.remindersNotRepresentable };
|
||||
}
|
||||
return { kind: 'clean', ops: cleanGroup(local, computation) };
|
||||
}
|
||||
const action: UploadAction<CalendarEventWire> = { kind: 'update', id: ref.id, patch: computation.patch };
|
||||
if (computation.sendSchedulingMessages) action.sendSchedulingMessages = true;
|
||||
return { kind: 'upload', actions: [action] };
|
||||
}
|
||||
|
||||
function planAccepted(local: LocalEvent, server: CalendarEventWire, ctx: CalendarContext): AcceptedPlan {
|
||||
refuseTask(server);
|
||||
const target = pickCalendar(server, local, ctx);
|
||||
if (!target) {
|
||||
// Moved out of every synced calendar (a membership removal): the rows go.
|
||||
const ops = purgeGroup(local);
|
||||
return { ops, keepDirtyOps: ops };
|
||||
}
|
||||
const wasNew = local.split === 'clone' || (!local.shadow && isNewMaster(local));
|
||||
let uploaded: UploadedUnits;
|
||||
if (wasNew || !local.shadow) {
|
||||
uploaded = { all: true, master: new Set(), exceptions: new Map(), removed: new Set(local.exceptions.filter(isRemovedInstance).map((x) => x.eventId)) };
|
||||
} else {
|
||||
try {
|
||||
const c = computeUpdate(local, ctx);
|
||||
uploaded = { all: false, master: c.masterUnits, exceptions: c.exceptionUnits, removed: c.removedExceptions };
|
||||
} catch {
|
||||
uploaded = { all: false, master: new Set(), exceptions: new Map(), removed: new Set() };
|
||||
}
|
||||
}
|
||||
const keepDirtyOps = keepDirtyGroup(local, server, uploaded, ctx);
|
||||
const image = eventImage(server, target.calendarRowId, target.calendarId, ctx);
|
||||
if (!image) return { ops: keepDirtyOps, keepDirtyOps };
|
||||
const ops = writeEvent(server, image, target, local, {
|
||||
mode: 'overwrite',
|
||||
ref: objectRef(ctx.jmapAccountId, server.id),
|
||||
ctx,
|
||||
base: local.shadow ?? server,
|
||||
pending: null,
|
||||
clearPoison: true,
|
||||
}).ops;
|
||||
return { ops, keepDirtyOps };
|
||||
}
|
||||
|
||||
function planPairs(
|
||||
deleted: LocalEvent[],
|
||||
fresh: LocalEvent[],
|
||||
ctx: CalendarContext,
|
||||
): Array<{ deleted: LocalEvent; fresh: LocalEvent; actions: UploadAction<CalendarEventWire>[]; ops: OpGroup }> {
|
||||
const pairs: Array<{ deleted: LocalEvent; fresh: LocalEvent; actions: UploadAction<CalendarEventWire>[]; ops: OpGroup }> = [];
|
||||
const used = new Set<number>();
|
||||
const inAccount = (row: LocalEventRow) => ctx.calendarIdOfRow(row.calendarRowId)?.jmapAccountId === ctx.jmapAccountId;
|
||||
for (const d of deleted) {
|
||||
const ref = parseObjectRef(d.syncId);
|
||||
if (!ref || !d.shadow || !d.deleted || d.split || !inAccount(d)) continue;
|
||||
refuseTask(d.shadow);
|
||||
for (const f of fresh) {
|
||||
if (used.has(f.eventId) || f.syncId !== null || f.pending || f.deleted || isExceptionRow(f.cells) || !inAccount(f)) continue;
|
||||
const kind = pairKind(d, f, ctx);
|
||||
if (!kind) continue;
|
||||
const pair = pairPatch(d, f, kind, ctx);
|
||||
if (!pair) continue;
|
||||
const action: UploadAction<CalendarEventWire> = { kind: 'update', id: ref.id, patch: pair.patch };
|
||||
if (pair.sendSchedulingMessages) action.sendSchedulingMessages = true;
|
||||
pairs.push({ deleted: d, fresh: f, actions: [action], ops: pairOps(d, f, kind, pair.patch, ctx) });
|
||||
used.add(f.eventId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
// ─── Device zone and reminder owner ─────────────────────
|
||||
|
||||
function planZoneChange(local: LocalEvent, previousZone: string, ctx: CalendarContext): OpGroup | null {
|
||||
const shadow = local.shadow;
|
||||
if (!shadow || local.split || local.deleted || local.dirty || local.exceptions.some((x) => x.dirty || x.deleted)) return null;
|
||||
refuseTask(shadow);
|
||||
if (previousZone === ctx.deviceZone || !eventZone(shadow, ctx.deviceZone).floating) return null;
|
||||
const target = pickCalendar(shadow, local, ctx);
|
||||
if (!target) return null;
|
||||
const image = eventImage(shadow, target.calendarRowId, target.calendarId, ctx);
|
||||
if (!image) return null;
|
||||
const ops = writeEvent(shadow, image, target, local, { mode: 'overwrite', ref: refOf(local), ctx, base: shadow }).ops;
|
||||
return writesOf(ops) ? ops : null;
|
||||
}
|
||||
|
||||
function planReminderOwnerChange(local: LocalEvent, ctx: CalendarContext): OpGroup | null {
|
||||
const shadow = local.shadow;
|
||||
if (!shadow || local.deleted || isExceptionRow(local.cells)) return null;
|
||||
refuseTask(shadow);
|
||||
const target = pickCalendar(shadow, local, ctx);
|
||||
if (!target) return null;
|
||||
const image = eventImage(shadow, target.calendarRowId, target.calendarId, { ...ctx, reminderOwner: 'device' });
|
||||
if (!image) return null;
|
||||
const rows: Array<{ row: LocalEventRow; isException: boolean; wanted: LocalEventRow['cells'][] }> = [];
|
||||
const add = (row: LocalEventRow, isException: boolean, wanted: LocalEventRow['cells'][] | null) => {
|
||||
if (!wanted) return;
|
||||
// A reminder edit that has not reached the server yet is not overwritten.
|
||||
if (row.dirty && row.baseline && remindersDiffer(sideOfRow(row), sideOfBaseline(row.baseline))) return;
|
||||
rows.push({ row, isException, wanted: ctx.reminderOwner === 'device' ? wanted : [] });
|
||||
};
|
||||
add(local, false, image.master.reminders);
|
||||
for (const x of local.exceptions) {
|
||||
if (x.deleted || !x.recurrenceId) continue;
|
||||
const xi = image.exceptions.get(x.recurrenceId);
|
||||
add(x, true, xi ? xi.reminders : null);
|
||||
}
|
||||
const changing = rows.filter(({ row, wanted }) => {
|
||||
const have = row.reminders.map((r) => r.cells);
|
||||
return have.length !== wanted.length || remindersDiffer({ cells: {}, attendees: [], reminders: have }, { cells: {}, attendees: [], reminders: wanted });
|
||||
});
|
||||
if (!changing.length) return null;
|
||||
const group = new GroupBuilder(refOf(local));
|
||||
for (const { row, isException } of changing) assertRow(group, row, isException, true);
|
||||
for (const { row, isException, wanted } of changing) {
|
||||
for (const r of row.reminders) group.write({ op: 'delete', table: 'reminders', id: r.id, expectCount: 1 });
|
||||
for (const w of wanted) group.write({ op: 'insert', table: 'reminders', values: { ...w, event_id: row.eventId } });
|
||||
const baseline = row.baseline ?? makeBaseline(row.cells, isException ? EXCEPTION_CELLS : MASTER_CELLS, row.attendees.map((a) => a.cells), []);
|
||||
group.write({
|
||||
op: 'update',
|
||||
table: 'events',
|
||||
id: row.eventId,
|
||||
values: { [Events.SYNC_DATA4]: JSON.stringify({ v: 1, ...baseline, reminders: wanted }) },
|
||||
expectCount: 1,
|
||||
});
|
||||
}
|
||||
return group.build();
|
||||
}
|
||||
|
||||
export const calendarPlanner: CalendarPlanner = {
|
||||
calendarColumns: CALENDAR_COLUMNS,
|
||||
eventColumns: EVENT_COLUMNS,
|
||||
attendeeColumns: ATTENDEE_COLUMNS,
|
||||
reminderColumns: REMINDER_COLUMNS,
|
||||
decodeCalendar,
|
||||
decodeEvents,
|
||||
planCalendars,
|
||||
planDownload,
|
||||
planLocalDelete,
|
||||
planBaselineHeal,
|
||||
planUpload,
|
||||
planAccepted,
|
||||
planPairs,
|
||||
planZoneChange,
|
||||
planReminderOwnerChange,
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Alerts ↔ Reminders rows (docs/device-sync.md, "Other properties" and
|
||||
* "Reminder owner").
|
||||
*
|
||||
* A Reminders row is "minutes before the start" plus a method, so only an
|
||||
* offset alert relative to the start (or with no `relativeTo`) that fires at
|
||||
* or before it is shown; alerts relative to the end, absolute alerts and
|
||||
* alerts after the start stay on the server. The app's
|
||||
* `offsetToMinutesBefore` rejects the valid `+PT15M` (and fractions), so
|
||||
* offsets are read with this module's own duration parser.
|
||||
*/
|
||||
import type { Alert } from '../../api/types';
|
||||
import { Reminders } from '../android-columns';
|
||||
import type { CalendarLike } from '../wire';
|
||||
import type { Row } from '../types';
|
||||
import { minutesBeforeToOffset } from '../../lib/calendar-alerts';
|
||||
import { durationSeconds, parseDuration } from './duration';
|
||||
import { reminderActionFromDevice, reminderMethodToDevice } from './values';
|
||||
|
||||
export interface ReminderValue {
|
||||
minutes: number;
|
||||
/** Reminders.METHOD, normalised: EMAIL (2) or ALERT (1). */
|
||||
method: number;
|
||||
}
|
||||
|
||||
type Alerts = Record<string, Alert> | null | undefined;
|
||||
|
||||
/**
|
||||
* The alerts that are in force: with `useDefaultAlerts` the calendar's
|
||||
* defaults for timed or all-day events (and the event's own `alerts` are
|
||||
* ignored, as the server and the app's scheduler do), else the event's own.
|
||||
*/
|
||||
export function effectiveAlerts(
|
||||
event: { useDefaultAlerts?: boolean | null; alerts?: Alerts; showWithoutTime?: boolean | null },
|
||||
calendar: CalendarLike | undefined,
|
||||
): Record<string, Alert> {
|
||||
if (event.useDefaultAlerts) {
|
||||
const defaults = event.showWithoutTime ? calendar?.defaultAlertsWithoutTime : calendar?.defaultAlertsWithTime;
|
||||
return { ...(defaults ?? {}) };
|
||||
}
|
||||
return { ...(event.alerts ?? {}) };
|
||||
}
|
||||
|
||||
/** The reminder an alert shows as, or null when a Reminders row cannot hold it. */
|
||||
export function alertReminder(alert: Alert | null | undefined): ReminderValue | null {
|
||||
const trigger = alert?.trigger;
|
||||
if (!trigger || typeof trigger !== 'object') return null;
|
||||
const type = trigger['@type'];
|
||||
if (type !== undefined && type !== 'OffsetTrigger') return null;
|
||||
if (trigger.offset === undefined || trigger.when !== undefined) return null;
|
||||
if (trigger.relativeTo !== undefined && trigger.relativeTo !== null && trigger.relativeTo !== 'start') return null;
|
||||
const d = parseDuration(trigger.offset);
|
||||
if (!d) return null;
|
||||
const seconds = durationSeconds(d);
|
||||
if (seconds > 0) return null;
|
||||
return { minutes: Math.round(-seconds / 60), method: reminderMethodToDevice(alert.action) };
|
||||
}
|
||||
|
||||
export function isRepresentableAlert(alert: Alert | null | undefined): boolean {
|
||||
return alertReminder(alert) !== null;
|
||||
}
|
||||
|
||||
export function reminderRow(value: ReminderValue): Row {
|
||||
return { [Reminders.MINUTES]: value.minutes, [Reminders.METHOD]: value.method };
|
||||
}
|
||||
|
||||
/** Reminders rows for the alerts that have one, in the alerts' order. */
|
||||
export function reminderRows(alerts: Alerts): Row[] {
|
||||
return Object.values(alerts ?? {})
|
||||
.map(alertReminder)
|
||||
.filter((r): r is ReminderValue => r !== null)
|
||||
.map(reminderRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* A device row as a reminder, or null for one that means nothing to the
|
||||
* server (MINUTES = -1 is "the app's default").
|
||||
*/
|
||||
export function rowReminder(row: Row): ReminderValue | null {
|
||||
const minutes = Number(row[Reminders.MINUTES]);
|
||||
if (!Number.isInteger(minutes) || minutes < 0) return null;
|
||||
return { minutes, method: reminderMethodToDevice(reminderActionFromDevice(row[Reminders.METHOD])) };
|
||||
}
|
||||
|
||||
const keyOf = (r: ReminderValue) => `${r.minutes}/${r.method}`;
|
||||
|
||||
/** Two reminder lists as multisets of (minutes, method). */
|
||||
export function sameReminders(a: Row[], b: Row[]): boolean {
|
||||
const count = (rows: Row[]) => {
|
||||
const m = new Map<string, number>();
|
||||
for (const r of rows) {
|
||||
const v = rowReminder(r);
|
||||
if (v) m.set(keyOf(v), (m.get(keyOf(v)) ?? 0) + 1);
|
||||
}
|
||||
return m;
|
||||
};
|
||||
const ca = count(a);
|
||||
const cb = count(b);
|
||||
if (ca.size !== cb.size) return false;
|
||||
for (const [k, n] of ca) if (cb.get(k) !== n) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A new alert for a device reminder, shaped like the app's reminder picker writes them. */
|
||||
export function reminderAlert(value: ReminderValue): Alert {
|
||||
return {
|
||||
'@type': 'Alert',
|
||||
trigger: { '@type': 'OffsetTrigger', offset: minutesBeforeToOffset(value.minutes), relativeTo: 'start' },
|
||||
action: reminderActionFromDevice(value.method),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The alerts after the device's reminders replaced the representable ones:
|
||||
* existing alerts whose (minutes, method) a reminder still has keep their
|
||||
* key and value, the others go, unmatched reminders are added under minted
|
||||
* keys, and alerts a Reminders row can't hold stay as they are.
|
||||
*/
|
||||
export function remindersToAlerts(
|
||||
current: Record<string, Alert>,
|
||||
reminders: Row[],
|
||||
mintKey: (taken: Iterable<string>) => string,
|
||||
): { alerts: Record<string, Alert>; removed: string[]; added: Record<string, Alert> } {
|
||||
const wanted = reminders.map(rowReminder).filter((r): r is ReminderValue => r !== null);
|
||||
const pool = new Map<string, number>();
|
||||
for (const r of wanted) pool.set(keyOf(r), (pool.get(keyOf(r)) ?? 0) + 1);
|
||||
const alerts: Record<string, Alert> = {};
|
||||
const removed: string[] = [];
|
||||
for (const [key, alert] of Object.entries(current)) {
|
||||
const r = alertReminder(alert);
|
||||
if (!r) {
|
||||
alerts[key] = alert;
|
||||
continue;
|
||||
}
|
||||
const left = pool.get(keyOf(r)) ?? 0;
|
||||
if (left > 0) {
|
||||
pool.set(keyOf(r), left - 1);
|
||||
alerts[key] = alert;
|
||||
} else {
|
||||
removed.push(key);
|
||||
}
|
||||
}
|
||||
const added: Record<string, Alert> = {};
|
||||
for (const r of wanted) {
|
||||
const left = pool.get(keyOf(r)) ?? 0;
|
||||
if (left <= 0) continue;
|
||||
pool.set(keyOf(r), left - 1);
|
||||
const key = mintKey([...Object.keys(current), ...Object.keys(added)]);
|
||||
added[key] = reminderAlert(r);
|
||||
alerts[key] = added[key];
|
||||
}
|
||||
return { alerts, removed, added };
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* Writing event rows: the op group builder and the translation of "this row
|
||||
* should hold that" into the fewest provider ops (docs/device-sync.md,
|
||||
* "Merge rules" and "Uploads").
|
||||
*
|
||||
* A group is atomic and its asserts come first, so the builder refuses an
|
||||
* assert after the first write; `refs` are indexes into the group. Rows are
|
||||
* addressed by id with `expectCount: 1`, exception rows are deleted by id
|
||||
* only (a sync-adapter delete never cascades to them), and a write that
|
||||
* would not change a cell is left out, so an echo writes nothing.
|
||||
*/
|
||||
import { Attendees, Events, Reminders } from '../android-columns';
|
||||
import type { EventBaseline, LocalEventRow, OpGroup, PendingCreate, PoisonMarker } from '../planner';
|
||||
import type { ProviderOp, Row, WriteRow } from '../types';
|
||||
import type { CalendarEventWire } from '../wire';
|
||||
import { deepEqual } from '../common/json';
|
||||
import { MAX_OPS_PER_YIELD, estimateBatchBytes } from '../engine/batch';
|
||||
import { DEFAULT_TUNING } from '../engine/deps';
|
||||
import { ATTENDEE_CELLS, MASTER_CELLS, EXCEPTION_CELLS, REMINDER_CELLS, encodeBaseline, sameCell } from './columns';
|
||||
import { rowReminder } from './reminders';
|
||||
|
||||
export class GroupBuilder {
|
||||
private readonly asserts: ProviderOp[] = [];
|
||||
private readonly writes: ProviderOp[] = [];
|
||||
|
||||
constructor(readonly ref: string) {}
|
||||
|
||||
assert(op: Extract<ProviderOp, { op: 'assert' }>): void {
|
||||
if (this.writes.length) throw new Error('calendar planner: an assert after a write');
|
||||
this.asserts.push(op);
|
||||
}
|
||||
|
||||
/** Adds a write and returns its index in the group (for `refs`). */
|
||||
write(op: ProviderOp): number {
|
||||
this.writes.push(op);
|
||||
return this.asserts.length + this.writes.length - 1;
|
||||
}
|
||||
|
||||
get writeCount(): number {
|
||||
return this.writes.length;
|
||||
}
|
||||
|
||||
build(): OpGroup {
|
||||
return { ref: this.ref, ops: [...this.asserts, ...this.writes] };
|
||||
}
|
||||
}
|
||||
|
||||
/** Columns asserted for a row: sync state plus its projection. */
|
||||
const ASSERT_EXTRA = [Events.DIRTY, Events.DELETED, Events._SYNC_ID] as const;
|
||||
|
||||
export function projectionColumns(isException: boolean): readonly string[] {
|
||||
return isException ? EXCEPTION_CELLS : MASTER_CELLS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a row still holds what was read: DIRTY, DELETED, `_SYNC_ID`
|
||||
* and its mapped cells, and with `children` its attendees and reminders
|
||||
* (each row's cells plus their number, so an added one fails too). DIRTY
|
||||
* alone guards a clean row against app edits (every app write to an event,
|
||||
* its attendees or reminders sets it); a dirty row needs the whole
|
||||
* projection.
|
||||
*/
|
||||
export function assertRow(group: GroupBuilder, row: LocalEventRow, isException: boolean, children: boolean): void {
|
||||
const values: Row = {};
|
||||
// As read: a row a sync adapter inserted without DIRTY holds NULL there, not 0.
|
||||
values[Events.DIRTY] = row.cells[Events.DIRTY] ?? null;
|
||||
values[Events.DELETED] = row.cells[Events.DELETED] ?? null;
|
||||
values[Events._SYNC_ID] = row.syncId;
|
||||
for (const c of projectionColumns(isException)) values[c] = row.cells[c] ?? null;
|
||||
for (const c of ASSERT_EXTRA) if (!(c in values)) values[c] = row.cells[c] ?? null;
|
||||
group.assert({ op: 'assert', table: 'events', id: row.eventId, values, expectCount: 1 });
|
||||
if (!children) return;
|
||||
for (const a of row.attendees) {
|
||||
group.assert({ op: 'assert', table: 'attendees', id: a.id, values: pick(a.cells, ATTENDEE_CELLS), expectCount: 1 });
|
||||
}
|
||||
group.assert({ op: 'assert', table: 'attendees', where: `${Attendees.EVENT_ID} = ?`, args: [row.eventId], expectCount: row.attendees.length });
|
||||
for (const r of row.reminders) {
|
||||
group.assert({ op: 'assert', table: 'reminders', id: r.id, values: pick(r.cells, REMINDER_CELLS), expectCount: 1 });
|
||||
}
|
||||
group.assert({ op: 'assert', table: 'reminders', where: `${Reminders.EVENT_ID} = ?`, args: [row.eventId], expectCount: row.reminders.length });
|
||||
}
|
||||
|
||||
/** Asserts the number of exception rows of a master, so one an app adds meanwhile fails the group. */
|
||||
export function assertExceptionCount(group: GroupBuilder, masterId: number, count: number): void {
|
||||
group.assert({ op: 'assert', table: 'events', where: `${Events.ORIGINAL_ID} = ?`, args: [masterId], expectCount: count });
|
||||
}
|
||||
|
||||
function pick(row: Row, columns: readonly string[]): Row {
|
||||
const out: Row = {};
|
||||
for (const c of columns) out[c] = row[c] ?? null;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** What a row should hold after the group: content, sync columns, children. */
|
||||
export interface RowState {
|
||||
isException: boolean;
|
||||
/** Mapped cells (the projection). */
|
||||
cells: Row;
|
||||
attendees: Row[];
|
||||
/** Null: Reminders rows are left as they are. */
|
||||
reminders: Row[] | null;
|
||||
baseline: EventBaseline;
|
||||
syncId: string | null;
|
||||
uid: string | null;
|
||||
/** Masters: SYNC_DATA1; undefined leaves it. */
|
||||
shadow?: CalendarEventWire | null;
|
||||
/** Exceptions: SYNC_DATA2 (the recurrence id). */
|
||||
recurrenceKey?: string | null;
|
||||
/** SYNC_DATA3; undefined leaves it. */
|
||||
pending?: PendingCreate | null;
|
||||
/** SYNC_DATA5; undefined leaves it. */
|
||||
poison?: PoisonMarker | null;
|
||||
/** Exceptions: ORIGINAL_SYNC_ID. */
|
||||
originalSyncId?: string | null;
|
||||
/** Written as DIRTY=0 when set; DIRTY is otherwise left to the provider. */
|
||||
clearDirty?: boolean;
|
||||
/** ORGANIZER is written on insert only when the event has participants (the provider fills the owner otherwise). */
|
||||
hasParticipants: boolean;
|
||||
}
|
||||
|
||||
function syncValues(state: RowState): WriteRow {
|
||||
const values: WriteRow = {};
|
||||
values[Events._SYNC_ID] = state.syncId;
|
||||
values[Events.UID_2445] = state.uid;
|
||||
if (state.shadow !== undefined) values[Events.SYNC_DATA1] = state.shadow ? JSON.stringify(state.shadow) : null;
|
||||
if (state.recurrenceKey !== undefined) values[Events.SYNC_DATA2] = state.recurrenceKey;
|
||||
if (state.pending !== undefined) values[Events.SYNC_DATA3] = state.pending ? JSON.stringify(state.pending) : null;
|
||||
values[Events.SYNC_DATA4] = encodeBaseline(state.baseline);
|
||||
if (state.poison !== undefined) values[Events.SYNC_DATA5] = state.poison ? JSON.stringify(state.poison) : null;
|
||||
if (state.originalSyncId !== undefined) values[Events.ORIGINAL_SYNC_ID] = state.originalSyncId;
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a row and its attendees and reminders. `originalId` links an
|
||||
* exception to its master: an id, or the index of the master's insert in
|
||||
* this group.
|
||||
*/
|
||||
export function insertRow(group: GroupBuilder, state: RowState, originalId?: { id: number } | { ref: number }): number {
|
||||
// DIRTY is NULL after a sync-adapter insert unless written, and later asserts expect 0.
|
||||
const values: WriteRow = { ...syncValues(state), [Events.HAS_ATTENDEE_DATA]: 1, [Events.DIRTY]: 0 };
|
||||
for (const [c, v] of Object.entries(state.cells)) {
|
||||
if (c === Events.ORGANIZER && !state.hasParticipants) continue;
|
||||
values[c] = v;
|
||||
}
|
||||
const refs: Record<string, number> = {};
|
||||
if (originalId && 'id' in originalId) values[Events.ORIGINAL_ID] = originalId.id;
|
||||
if (originalId && 'ref' in originalId) refs[Events.ORIGINAL_ID] = originalId.ref;
|
||||
const index = group.write({ op: 'insert', table: 'events', values, ...(Object.keys(refs).length ? { refs } : {}) });
|
||||
for (const a of state.attendees) group.write({ op: 'insert', table: 'attendees', values: { ...a }, refs: { [Attendees.EVENT_ID]: index } });
|
||||
for (const r of state.reminders ?? []) group.write({ op: 'insert', table: 'reminders', values: { ...r }, refs: { [Reminders.EVENT_ID]: index } });
|
||||
return index;
|
||||
}
|
||||
|
||||
export function deleteRow(group: GroupBuilder, eventId: number): void {
|
||||
group.write({ op: 'delete', table: 'events', id: eventId, expectCount: 1 });
|
||||
}
|
||||
|
||||
const sameJson = (a: unknown, b: unknown) => deepEqual(a ?? null, b ?? null);
|
||||
|
||||
/**
|
||||
* Updates an existing row to `state`, writing only cells and sync columns
|
||||
* that differ, then its attendees (matched by address) and reminders
|
||||
* (matched by minutes and method). Returns the number of write ops.
|
||||
*/
|
||||
export function updateRow(group: GroupBuilder, current: LocalEventRow & { shadow?: CalendarEventWire | null }, state: RowState): number {
|
||||
const before = group.writeCount;
|
||||
const values: WriteRow = {};
|
||||
for (const [c, v] of Object.entries(state.cells)) {
|
||||
const have = current.cells[c] ?? null;
|
||||
const want = v ?? null;
|
||||
// Same meaning (`''` and NULL, `P3600S` and `PT1H`) is no reason to write.
|
||||
if (have === want || sameCell(c, have, want)) continue;
|
||||
values[c] = want;
|
||||
}
|
||||
// Timing columns go together: the provider keeps DTEND and DURATION apart by what is written.
|
||||
const timing = [Events.DTSTART, Events.DTEND, Events.DURATION, Events.EVENT_TIMEZONE, Events.ALL_DAY, Events.RRULE];
|
||||
if (timing.some((c) => c in values)) for (const c of timing) if (c in state.cells) values[c] = state.cells[c];
|
||||
if ((current.syncId ?? null) !== state.syncId) values[Events._SYNC_ID] = state.syncId;
|
||||
if ((current.cells[Events.UID_2445] ?? null) !== state.uid) values[Events.UID_2445] = state.uid;
|
||||
if (state.shadow !== undefined && !sameJson(current.shadow, state.shadow)) {
|
||||
values[Events.SYNC_DATA1] = state.shadow ? JSON.stringify(state.shadow) : null;
|
||||
}
|
||||
if (state.recurrenceKey !== undefined && (current.cells[Events.SYNC_DATA2] ?? null) !== state.recurrenceKey) {
|
||||
values[Events.SYNC_DATA2] = state.recurrenceKey;
|
||||
}
|
||||
if (state.pending !== undefined && !sameJson(current.pending, state.pending)) {
|
||||
values[Events.SYNC_DATA3] = state.pending ? JSON.stringify(state.pending) : null;
|
||||
}
|
||||
if (!sameJson(current.baseline, state.baseline)) values[Events.SYNC_DATA4] = encodeBaseline(state.baseline);
|
||||
if (state.poison !== undefined && !sameJson(current.poison, state.poison)) {
|
||||
values[Events.SYNC_DATA5] = state.poison ? JSON.stringify(state.poison) : null;
|
||||
}
|
||||
if (state.originalSyncId !== undefined && (current.cells[Events.ORIGINAL_SYNC_ID] ?? null) !== state.originalSyncId) {
|
||||
values[Events.ORIGINAL_SYNC_ID] = state.originalSyncId;
|
||||
}
|
||||
if (state.clearDirty && current.dirty) values[Events.DIRTY] = 0;
|
||||
if (Object.keys(values).length) group.write({ op: 'update', table: 'events', id: current.eventId, values, expectCount: 1 });
|
||||
|
||||
writeAttendees(group, current, state.attendees);
|
||||
if (state.reminders !== null) writeReminders(group, current, state.reminders);
|
||||
return group.writeCount - before;
|
||||
}
|
||||
|
||||
const emailKey = (row: Row) => String(row[Attendees.ATTENDEE_EMAIL] ?? '').trim().toLowerCase();
|
||||
|
||||
function writeAttendees(group: GroupBuilder, current: LocalEventRow, wanted: Row[]): void {
|
||||
const unmatched = [...current.attendees];
|
||||
const inserts: Row[] = [];
|
||||
const updates: Array<{ id: number; values: Row }> = [];
|
||||
for (const want of wanted) {
|
||||
const i = unmatched.findIndex((a) => emailKey(a.cells) === emailKey(want));
|
||||
if (i < 0) {
|
||||
inserts.push(want);
|
||||
continue;
|
||||
}
|
||||
const [have] = unmatched.splice(i, 1);
|
||||
const values: Row = {};
|
||||
for (const c of ATTENDEE_CELLS) {
|
||||
const v = want[c] ?? null;
|
||||
const h = have.cells[c] ?? null;
|
||||
if (h === v) continue;
|
||||
// The email's case matters: Android finds "me" by comparing it with OWNER_ACCOUNT exactly.
|
||||
if (c !== Attendees.ATTENDEE_EMAIL && sameCell(c, h, v)) continue;
|
||||
values[c] = v;
|
||||
}
|
||||
if (Object.keys(values).length) updates.push({ id: have.id, values });
|
||||
}
|
||||
for (const gone of unmatched) group.write({ op: 'delete', table: 'attendees', id: gone.id, expectCount: 1 });
|
||||
for (const u of updates) group.write({ op: 'update', table: 'attendees', id: u.id, values: u.values, expectCount: 1 });
|
||||
for (const add of inserts) group.write({ op: 'insert', table: 'attendees', values: { ...add, [Attendees.EVENT_ID]: current.eventId } });
|
||||
}
|
||||
|
||||
function writeReminders(group: GroupBuilder, current: LocalEventRow, wanted: Row[]): void {
|
||||
const key = (row: Row) => {
|
||||
const r = rowReminder(row);
|
||||
return r ? `${r.minutes}/${r.method}` : `raw:${String(row[Reminders.MINUTES])}/${String(row[Reminders.METHOD])}`;
|
||||
};
|
||||
const unmatched = [...current.reminders];
|
||||
const inserts: Row[] = [];
|
||||
for (const want of wanted) {
|
||||
const i = unmatched.findIndex((r) => key(r.cells) === key(want));
|
||||
if (i < 0) inserts.push(want);
|
||||
else unmatched.splice(i, 1);
|
||||
}
|
||||
for (const gone of unmatched) group.write({ op: 'delete', table: 'reminders', id: gone.id, expectCount: 1 });
|
||||
for (const add of inserts) group.write({ op: 'insert', table: 'reminders', values: { ...add, [Reminders.EVENT_ID]: current.eventId } });
|
||||
}
|
||||
|
||||
/** Counts the provider writes of a group and the groups after it (asserts are not writes). */
|
||||
export function writesOf(group: OpGroup): number {
|
||||
return [group, ...(group.next ?? [])].reduce(
|
||||
(n, g) => n + g.ops.filter((op) => op.op === 'insert' || op.op === 'update' || op.op === 'delete').length,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Items too big for one group ────────────────────────
|
||||
|
||||
/**
|
||||
* What one group may hold: the engine's batch budget as `estimateBatchBytes`
|
||||
* weighs it, and one op less than a provider yield window (room for the
|
||||
* engine's insert guard). A bigger event is written as a chain of groups
|
||||
* (`OpGroup.next`); a master's shadow is one column and may take a group of
|
||||
* its own beyond the budget (under one transaction for any event that syncs).
|
||||
*/
|
||||
const GROUP_BYTES = DEFAULT_TUNING.maxBatchBytes;
|
||||
const GROUP_OPS = MAX_OPS_PER_YIELD - 1;
|
||||
|
||||
const weight = (ops: readonly ProviderOp[]) => (ops.length ? estimateBatchBytes(ops) : 0);
|
||||
|
||||
/** One group from asserts and write blocks whose `refs` index into their own block, asserts first. */
|
||||
function assemble(ref: string, asserts: readonly ProviderOp[], blocks: ReadonlyArray<readonly ProviderOp[]>): OpGroup {
|
||||
const ops: ProviderOp[] = [...asserts];
|
||||
for (const block of blocks) {
|
||||
const offset = ops.length;
|
||||
for (const op of block) {
|
||||
if (op.op !== 'insert' || !op.refs) ops.push(op);
|
||||
else ops.push({ ...op, refs: Object.fromEntries(Object.entries(op.refs).map(([column, i]) => [column, i + offset])) });
|
||||
}
|
||||
}
|
||||
return { ref, ops };
|
||||
}
|
||||
|
||||
/** Writes of one row (`refs` index into them), and the asserts that guard that row (none for a new row). */
|
||||
export interface RowWrite {
|
||||
asserts: ProviderOp[];
|
||||
writes: ProviderOp[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuts row writes into groups of the budget, in order, a row never split:
|
||||
* each group starts with `guard`, the first also with `first`.
|
||||
*/
|
||||
function cutRows(ref: string, rows: readonly RowWrite[], guard: readonly ProviderOp[], first: readonly ProviderOp[] = []): OpGroup[] {
|
||||
const groups: OpGroup[] = [];
|
||||
let asserts: ProviderOp[] = [...guard, ...first];
|
||||
let blocks: ProviderOp[][] = [];
|
||||
let ops = asserts.length;
|
||||
let bytes = weight(asserts);
|
||||
for (const row of rows) {
|
||||
const n = row.asserts.length + row.writes.length;
|
||||
const b = weight(row.asserts) + weight(row.writes);
|
||||
if (blocks.length && (ops + n > GROUP_OPS || bytes + b > GROUP_BYTES)) {
|
||||
groups.push(assemble(ref, asserts, blocks));
|
||||
asserts = [...guard];
|
||||
blocks = [];
|
||||
ops = asserts.length;
|
||||
bytes = weight(asserts);
|
||||
}
|
||||
asserts.push(...row.asserts);
|
||||
blocks.push(row.writes);
|
||||
ops += n;
|
||||
bytes += b;
|
||||
}
|
||||
if (blocks.length) groups.push(assemble(ref, asserts, blocks));
|
||||
return groups;
|
||||
}
|
||||
|
||||
function chained(groups: readonly OpGroup[]): OpGroup {
|
||||
const [first, ...next] = groups;
|
||||
return next.length ? { ...first, next } : first;
|
||||
}
|
||||
|
||||
/**
|
||||
* The writes into an existing event as one group: asserts first (`master`,
|
||||
* the exception rows' `rows` in order, `count`), then the writes (`deletes`,
|
||||
* the master's `master`, `updates`, `inserts`). When that is more than a group
|
||||
* may hold, a chain: the exception rows' writes in groups of the budget, each
|
||||
* asserting the master row and the rows it writes, the first also the number
|
||||
* of exception rows; then the master's write, with its new shadow, last,
|
||||
* asserting the other rows. Cut off in between, the item is planned again
|
||||
* from the old shadow, so no server change to a row not written yet is taken
|
||||
* as done.
|
||||
*/
|
||||
export function eventWrite(
|
||||
ref: string,
|
||||
parts: {
|
||||
master: ProviderOp[];
|
||||
rows: Map<number, ProviderOp[]>;
|
||||
count: ProviderOp[];
|
||||
deletes: Array<RowWrite & { row: number }>;
|
||||
masterWrite: ProviderOp[];
|
||||
updates: Array<RowWrite & { row: number }>;
|
||||
inserts: RowWrite[];
|
||||
},
|
||||
): OpGroup {
|
||||
const rowAsserts = [...parts.rows.values()].flat();
|
||||
const single = assemble(ref, [...parts.master, ...rowAsserts, ...parts.count], [
|
||||
...parts.deletes.map((d) => d.writes),
|
||||
parts.masterWrite,
|
||||
...parts.updates.map((u) => u.writes),
|
||||
...parts.inserts.map((i) => i.writes),
|
||||
]);
|
||||
if (single.ops.length <= GROUP_OPS && estimateBatchBytes(single.ops) <= GROUP_BYTES) return single;
|
||||
const writing = [...parts.deletes, ...parts.updates].filter((w) => w.writes.length);
|
||||
if (!writing.length && !parts.inserts.length) return single;
|
||||
const written = new Set(writing.map((w) => w.row));
|
||||
const rows: RowWrite[] = [
|
||||
...writing.map((w) => ({ asserts: parts.rows.get(w.row) ?? [], writes: w.writes })),
|
||||
...parts.inserts,
|
||||
];
|
||||
// The master row's own assert guards every group; its attendees and reminders go with its write.
|
||||
const groups = cutRows(ref, rows, parts.master.slice(0, 1), parts.count);
|
||||
const rest = [...parts.rows].filter(([row]) => !written.has(row)).flatMap(([, asserts]) => asserts);
|
||||
groups.push(assemble(ref, [...parts.master, ...rest], [parts.masterWrite]));
|
||||
return chained(groups);
|
||||
}
|
||||
|
||||
/**
|
||||
* A new event's rows as one group, or, when they are more than a group may
|
||||
* hold, as a chain: `master` (the master's insert with its identity but
|
||||
* without its shadow), its exception rows in groups of the budget, each
|
||||
* asserting the master by `masterRef` and linked to it by ORIGINAL_SYNC_ID
|
||||
* (the provider fills ORIGINAL_ID from it and CALENDAR_ID), then `shadow`.
|
||||
* Cut off in between, the next download finds the master by its identity and
|
||||
* writes what is missing; a master without a shadow merges like an adopted one.
|
||||
*/
|
||||
export function eventInsert(
|
||||
ref: string,
|
||||
single: OpGroup,
|
||||
partsOf: () => { masterRef: string; master: ProviderOp[]; exceptions: RowWrite[]; shadow: CalendarEventWire },
|
||||
): OpGroup {
|
||||
if (single.ops.length <= GROUP_OPS && estimateBatchBytes(single.ops) <= GROUP_BYTES) return single;
|
||||
const parts = partsOf();
|
||||
const byIdentity = { table: 'events' as const, where: `${Events._SYNC_ID} = ?`, args: [parts.masterRef], expectCount: 1 };
|
||||
const groups = [assemble(ref, [], [parts.master]), ...cutRows(ref, parts.exceptions, [{ op: 'assert', ...byIdentity }])];
|
||||
groups.push({ ref, ops: [{ op: 'update', ...byIdentity, values: { [Events.SYNC_DATA1]: JSON.stringify(parts.shadow) } }] });
|
||||
return chained(groups);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* JSCalendar `recurrenceRule` ↔ the RRULE column (docs/device-sync.md,
|
||||
* "Recurrence").
|
||||
*
|
||||
* AOSP's EventRecurrence rejects every part it does not know except `X-…`,
|
||||
* so RSCALE and SKIP are never written (the #805 failure); a rule that needs
|
||||
* them (a non-Gregorian `rscale`, a `skip` other than `omit`, a leap month
|
||||
* such as `5L`) is written without them and reported as not representable.
|
||||
* UNTIL is UTC (`…Z`) for timed events and a DATE for all-day ones, because
|
||||
* AOSP reads a floating UNTIL as UTC anyway. Rules written by device apps
|
||||
* are read tolerantly: any case, an `RRULE:` prefix, `X-` parts, UNTIL as a
|
||||
* DATE, a floating or a UTC date-time (Etar writes UTC date-times even for
|
||||
* all-day events), COUNT together with UNTIL.
|
||||
*/
|
||||
import type { RecurrenceRule } from '../../api/types';
|
||||
import {
|
||||
canonicalZone,
|
||||
dateToUtcMidnight,
|
||||
formatDate,
|
||||
localToUtc,
|
||||
parseLocalDateTime,
|
||||
utcToLocal,
|
||||
wallFromMs,
|
||||
wallMs,
|
||||
type WallClock,
|
||||
} from './zoned-time';
|
||||
|
||||
export interface RuleZone {
|
||||
allDay: boolean;
|
||||
/** The event's zone for timed events (the device zone for floating ones). */
|
||||
zone: string;
|
||||
}
|
||||
|
||||
const FREQUENCIES = ['yearly', 'monthly', 'weekly', 'daily', 'hourly', 'minutely', 'secondly'];
|
||||
const WEEKDAYS = ['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'];
|
||||
|
||||
/** Parts EventRecurrence knows, in the order they are written. */
|
||||
const PART_ORDER = [
|
||||
'FREQ', 'UNTIL', 'COUNT', 'INTERVAL', 'BYSECOND', 'BYMINUTE', 'BYHOUR', 'BYDAY', 'BYMONTHDAY',
|
||||
'BYYEARDAY', 'BYWEEKNO', 'BYMONTH', 'BYSETPOS', 'WKST',
|
||||
] as const;
|
||||
const KNOWN_PARTS = new Set<string>(PART_ORDER);
|
||||
|
||||
/** Why a rule can only be shown approximately; empty when it is representable. */
|
||||
export function unrepresentableParts(rule: RecurrenceRule | null | undefined): string[] {
|
||||
if (!rule) return [];
|
||||
const out: string[] = [];
|
||||
if (rule.rscale && rule.rscale.toLowerCase() !== 'gregorian') out.push('rscale');
|
||||
if (rule.skip && rule.skip.toLowerCase() !== 'omit') out.push('skip');
|
||||
if ((rule.byMonth ?? []).some((m) => /l$/i.test(String(m)))) out.push('byMonth');
|
||||
if (!FREQUENCIES.includes(String(rule.frequency ?? '').toLowerCase())) out.push('frequency');
|
||||
return out;
|
||||
}
|
||||
|
||||
export function isRuleRepresentable(rule: RecurrenceRule | null | undefined): boolean {
|
||||
return unrepresentableParts(rule).length === 0;
|
||||
}
|
||||
|
||||
const ints = (list: unknown): number[] =>
|
||||
Array.isArray(list) ? list.filter((n): n is number => Number.isInteger(n)) : [];
|
||||
|
||||
/**
|
||||
* The RRULE column for a rule, or null when it cannot be written at all (no
|
||||
* known frequency). Unrepresentable parts are left out (see the header).
|
||||
*/
|
||||
export function ruleToRRule(rule: RecurrenceRule | null | undefined, ctx: RuleZone): string | null {
|
||||
if (!rule) return null;
|
||||
const freq = String(rule.frequency ?? '').toLowerCase();
|
||||
if (!FREQUENCIES.includes(freq)) return null;
|
||||
const parts: Array<[string, string]> = [['FREQ', freq.toUpperCase()]];
|
||||
if (Number.isInteger(rule.count) && (rule.count as number) > 0) {
|
||||
parts.push(['COUNT', String(rule.count)]);
|
||||
} else if (rule.until) {
|
||||
const until = formatUntil(rule.until, ctx);
|
||||
if (until) parts.push(['UNTIL', until]);
|
||||
}
|
||||
if (Number.isInteger(rule.interval) && (rule.interval as number) > 1) parts.push(['INTERVAL', String(rule.interval)]);
|
||||
const list = (name: string, values: Array<string | number>) => {
|
||||
if (values.length) parts.push([name, values.join(',')]);
|
||||
};
|
||||
list('BYSECOND', ints(rule.bySecond));
|
||||
list('BYMINUTE', ints(rule.byMinute));
|
||||
list('BYHOUR', ints(rule.byHour));
|
||||
list(
|
||||
'BYDAY',
|
||||
(rule.byDay ?? [])
|
||||
.filter((d) => d && WEEKDAYS.includes(String(d.day).toLowerCase()))
|
||||
.map((d) => `${Number.isInteger(d.nthOfPeriod) && d.nthOfPeriod !== 0 ? d.nthOfPeriod : ''}${String(d.day).toUpperCase()}`),
|
||||
);
|
||||
list('BYMONTHDAY', ints(rule.byMonthDay));
|
||||
list('BYYEARDAY', ints(rule.byYearDay));
|
||||
list('BYWEEKNO', ints(rule.byWeekNo));
|
||||
list('BYMONTH', (rule.byMonth ?? []).map(String).filter((m) => /^\d{1,2}$/.test(m)).map(Number));
|
||||
list('BYSETPOS', ints(rule.bySetPosition));
|
||||
const wkst = String(rule.firstDayOfWeek ?? '').toLowerCase();
|
||||
if (WEEKDAYS.includes(wkst) && wkst !== 'mo') parts.push(['WKST', wkst.toUpperCase()]);
|
||||
return parts.map(([k, v]) => `${k}=${v}`).join(';');
|
||||
}
|
||||
|
||||
const compact = (w: WallClock, withTime: boolean) => {
|
||||
const date = formatDate(w).replace(/-/g, '');
|
||||
if (!withTime) return date;
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${date}T${p(w.hour)}${p(w.minute)}${p(w.second)}`;
|
||||
};
|
||||
|
||||
/** UNTIL: the date for all-day rules, else the instant of the LocalDateTime in the event's zone, in UTC. */
|
||||
export function formatUntil(until: string, ctx: RuleZone): string | null {
|
||||
const wall = parseLocalDateTime(until);
|
||||
if (!wall) return null;
|
||||
if (ctx.allDay) return compact(wall, false);
|
||||
const utc = localToUtc(until, ctx.zone);
|
||||
return utc === null ? null : `${compact(wallFromMs(utc), true)}Z`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The instant an UNTIL value stands for, the way AOSP's RecurrenceProcessor
|
||||
* reads it: a DATE is midnight UTC, a floating date-time is UTC as well.
|
||||
*/
|
||||
export function untilInstant(value: string): number | null {
|
||||
const m = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})Z?)?$/i.exec(value.trim());
|
||||
if (!m) return null;
|
||||
return wallMs({
|
||||
year: Number(m[1]),
|
||||
month: Number(m[2]),
|
||||
day: Number(m[3]),
|
||||
hour: Number(m[4] ?? 0),
|
||||
minute: Number(m[5] ?? 0),
|
||||
second: Number(m[6] ?? 0),
|
||||
});
|
||||
}
|
||||
|
||||
/** UNTIL as JSCalendar's LocalDateTime: the UTC date for all-day rules, the instant in the event's zone otherwise. */
|
||||
export function parseUntil(value: string, ctx: RuleZone): string | null {
|
||||
const utc = untilInstant(value);
|
||||
if (utc === null) return null;
|
||||
if (ctx.allDay) return `${formatDate(wallFromMs(utc))}T00:00:00`;
|
||||
return utcToLocal(utc, canonicalZone(ctx.zone) ?? 'UTC');
|
||||
}
|
||||
|
||||
/**
|
||||
* The parts of an RRULE column as a map (upper-case names and values), from
|
||||
* its first rule, or null when there is no rule. `X-` parts are dropped and
|
||||
* so are names EventRecurrence would reject.
|
||||
*/
|
||||
export function rruleParts(text: unknown): Map<string, string> | null {
|
||||
if (typeof text !== 'string') return null;
|
||||
const first = text.split(/\r?\n/).map((l) => l.trim()).find(Boolean);
|
||||
if (!first) return null;
|
||||
const parts = new Map<string, string>();
|
||||
for (const raw of first.replace(/^RRULE:/i, '').split(';')) {
|
||||
const eq = raw.indexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
const name = raw.slice(0, eq).trim().toUpperCase();
|
||||
if (name.startsWith('X-') || !KNOWN_PARTS.has(name) || parts.has(name)) continue;
|
||||
parts.set(name, raw.slice(eq + 1).trim().toUpperCase());
|
||||
}
|
||||
return parts.has('FREQ') ? parts : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A comparable form of an RRULE column: parts in a fixed order, list values
|
||||
* sorted, defaults (INTERVAL=1, WKST=MO) dropped. Two texts with the same
|
||||
* meaning compare equal, so an app that rewrites a rule unchanged (Fossify
|
||||
* rebuilds it on every save) is not an edit. Empty for no rule.
|
||||
*/
|
||||
export function canonicalRRule(text: unknown): string {
|
||||
const parts = rruleParts(text);
|
||||
if (!parts) return '';
|
||||
if (parts.get('INTERVAL') === '1') parts.delete('INTERVAL');
|
||||
if (parts.get('WKST') === 'MO') parts.delete('WKST');
|
||||
return PART_ORDER.filter((p) => parts.has(p))
|
||||
.map((p) => {
|
||||
const v = parts.get(p)!;
|
||||
return `${p}=${p.startsWith('BY') ? v.split(',').sort().join(',') : v}`;
|
||||
})
|
||||
.join(';');
|
||||
}
|
||||
|
||||
const numberList = (v: string | undefined) =>
|
||||
v === undefined ? [] : v.split(',').map((n) => Number(n)).filter((n) => Number.isInteger(n));
|
||||
|
||||
/**
|
||||
* A rule from an RRULE column, or null when there is none or its FREQ is
|
||||
* unknown. A COUNT wins over an UNTIL next to it (RFC 5545 forbids both;
|
||||
* AOSP only warns).
|
||||
*/
|
||||
export function rruleToRule(text: unknown, ctx: RuleZone): RecurrenceRule | null {
|
||||
const parts = rruleParts(text);
|
||||
if (!parts) return null;
|
||||
return partsToRule(parts, ctx);
|
||||
}
|
||||
|
||||
export function partsToRule(parts: Map<string, string>, ctx: RuleZone): RecurrenceRule | null {
|
||||
const frequency = (parts.get('FREQ') ?? '').toLowerCase();
|
||||
if (!FREQUENCIES.includes(frequency)) return null;
|
||||
const rule: RecurrenceRule = { frequency };
|
||||
const interval = Number(parts.get('INTERVAL'));
|
||||
if (Number.isInteger(interval) && interval > 1) rule.interval = interval;
|
||||
const count = Number(parts.get('COUNT'));
|
||||
if (parts.has('COUNT') && Number.isInteger(count) && count > 0) {
|
||||
rule.count = count;
|
||||
} else if (parts.has('UNTIL')) {
|
||||
const until = parseUntil(parts.get('UNTIL')!, ctx);
|
||||
if (until) rule.until = until;
|
||||
}
|
||||
const byDay = (parts.get('BYDAY') ?? '')
|
||||
.split(',')
|
||||
.map((d) => /^([+-]?\d{1,2})?(MO|TU|WE|TH|FR|SA|SU)$/.exec(d.trim()))
|
||||
.filter((m): m is RegExpExecArray => !!m)
|
||||
.map((m) => (m[1] && Number(m[1]) !== 0 ? { day: m[2].toLowerCase(), nthOfPeriod: Number(m[1]) } : { day: m[2].toLowerCase() }));
|
||||
if (byDay.length) rule.byDay = byDay;
|
||||
const lists: Array<[keyof RecurrenceRule, string]> = [
|
||||
['byMonthDay', 'BYMONTHDAY'],
|
||||
['byYearDay', 'BYYEARDAY'],
|
||||
['byWeekNo', 'BYWEEKNO'],
|
||||
['byHour', 'BYHOUR'],
|
||||
['byMinute', 'BYMINUTE'],
|
||||
['bySecond', 'BYSECOND'],
|
||||
['bySetPosition', 'BYSETPOS'],
|
||||
];
|
||||
for (const [key, part] of lists) {
|
||||
const values = numberList(parts.get(part));
|
||||
if (values.length) (rule as unknown as Record<string, unknown>)[key] = values;
|
||||
}
|
||||
const byMonth = numberList(parts.get('BYMONTH')).filter((m) => m >= 1 && m <= 12).map(String);
|
||||
if (byMonth.length) rule.byMonth = byMonth;
|
||||
const wkst = (parts.get('WKST') ?? '').toLowerCase();
|
||||
if (WEEKDAYS.includes(wkst) && wkst !== 'mo') rule.firstDayOfWeek = wkst;
|
||||
return rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a rule stops, for pruning overrides after a "this and following"
|
||||
* cut: the UNTIL instant, or null when the rule is unbounded or bounded by
|
||||
* COUNT (the caller then asks CalendarProvider's LAST_DATE).
|
||||
*/
|
||||
export function untilUtc(rule: RecurrenceRule | null | undefined, ctx: RuleZone): number | null {
|
||||
if (!rule?.until || rule.count) return null;
|
||||
if (ctx.allDay) {
|
||||
const midnight = dateToUtcMidnight(rule.until);
|
||||
return midnight === null ? null : midnight + 86_399_000;
|
||||
}
|
||||
return localToUtc(rule.until, ctx.zone);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* When a device change asks Stalwart to send iTIP messages
|
||||
* (docs/device-sync.md, "Scheduling messages"). Stalwart schedules only when
|
||||
* a `/set` says so, while CalDAV clients get implicit scheduling on every
|
||||
* change; device sync follows CalDAV for the changes a calendar app would
|
||||
* notify about:
|
||||
*
|
||||
* - the organizer, with others taking part: a change to the title,
|
||||
* description, timing, rule, overrides, location, status or attendees, a
|
||||
* create, and a destroy (CANCEL); reminders, colour, availability and
|
||||
* privacy alone send nothing;
|
||||
* - an attendee: their own RSVP, and a destroy (a declining REPLY); their
|
||||
* other edits are stored for them only.
|
||||
*
|
||||
* Events in the past need no rule here: Stalwart sends nothing for them.
|
||||
*/
|
||||
import type { Participant } from '../../api/types';
|
||||
import { hasOtherParticipants, userIsOrganizer, type SelfContext } from './attendees';
|
||||
|
||||
/** Master units whose change the organizer tells the attendees about. */
|
||||
export const NOTIFYING_UNITS = ['title', 'description', 'timing', 'rule', 'location', 'status'] as const;
|
||||
|
||||
type Scheduled = { organizerCalendarAddress?: string | null; participants?: Record<string, Participant> | null };
|
||||
|
||||
export type SchedulingChange =
|
||||
| {
|
||||
kind: 'update';
|
||||
/** A notifying unit, an override or an attendee changed. */
|
||||
notifying: boolean;
|
||||
/** The user's own participation status changed. */
|
||||
rsvp: boolean;
|
||||
/** Attendees were added (the event may have had no one else before). */
|
||||
addsAttendees: boolean;
|
||||
}
|
||||
| { kind: 'create' }
|
||||
| { kind: 'destroy' };
|
||||
|
||||
/** Whether the write carries `sendSchedulingMessages: true`, for `event` as it is before (or, for a create, as it is sent). */
|
||||
export function sendsSchedulingMessages(event: Scheduled, change: SchedulingChange, ctx: SelfContext): boolean {
|
||||
const others = hasOtherParticipants(event.participants, ctx);
|
||||
switch (change.kind) {
|
||||
case 'destroy':
|
||||
return others;
|
||||
case 'create':
|
||||
return others && userIsOrganizer(event, ctx);
|
||||
case 'update':
|
||||
if (userIsOrganizer(event, ctx)) return (others || change.addsAttendees) && change.notifying;
|
||||
return change.rsvp;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* An event's timing ↔ DTSTART, DTEND, DURATION, EVENT_TIMEZONE and ALL_DAY
|
||||
* (docs/device-sync.md, "Timing" and "DST edges").
|
||||
*
|
||||
* - Timed events are written at the instant of `start` in `timeZone`;
|
||||
* floating events (no zone) in the device zone, which the row's
|
||||
* EVENT_TIMEZONE then records.
|
||||
* - All-day events are UTC midnight of the date with EVENT_TIMEZONE `UTC`,
|
||||
* the only form CalendarProvider accepts.
|
||||
* - Single events get DTEND (days nominal in the zone), recurring masters a
|
||||
* DURATION (`P<n>D` all-day, `P<seconds>S` timed), exceptions DTEND.
|
||||
*
|
||||
* Uploads go the other way only for a changed timing: the start is read back
|
||||
* in the event's zone (floating stays floating, all-day stays a date), and a
|
||||
* start whose wall time repeats in the zone is flagged, because Stalwart
|
||||
* drops such a start.
|
||||
*/
|
||||
import { Events } from '../android-columns';
|
||||
import type { Row } from '../types';
|
||||
import {
|
||||
allDayDays,
|
||||
addDuration,
|
||||
durationBetween,
|
||||
durationSeconds,
|
||||
formatAospDuration,
|
||||
formatJsDuration,
|
||||
parseDuration,
|
||||
type Duration,
|
||||
} from './duration';
|
||||
import {
|
||||
DAY_MS,
|
||||
canonicalZone,
|
||||
classifyLocal,
|
||||
dateToUtcMidnight,
|
||||
fixedOffsetZone,
|
||||
localToUtc,
|
||||
offsetAt,
|
||||
utcMidnightToDate,
|
||||
utcToLocal,
|
||||
} from './zoned-time';
|
||||
|
||||
export interface TimingSource {
|
||||
start?: string | null;
|
||||
duration?: string | null;
|
||||
timeZone?: string | null;
|
||||
showWithoutTime?: boolean | null;
|
||||
}
|
||||
|
||||
export interface EventZone {
|
||||
/** The zone timed values are computed in; `UTC` for all-day events. */
|
||||
zone: string;
|
||||
allDay: boolean;
|
||||
/** No usable `timeZone`: written in the device zone. */
|
||||
floating: boolean;
|
||||
}
|
||||
|
||||
export function eventZone(event: TimingSource, deviceZone: string): EventZone {
|
||||
if (event.showWithoutTime) return { zone: 'UTC', allDay: true, floating: false };
|
||||
const zone = canonicalZone(event.timeZone);
|
||||
if (zone) return { zone, allDay: false, floating: false };
|
||||
return { zone: canonicalZone(deviceZone) ?? 'UTC', allDay: false, floating: true };
|
||||
}
|
||||
|
||||
/** The timing cells of an event (or of one instance, `recurring` false). Null when `start` is unreadable. */
|
||||
export function timingCells(event: TimingSource, recurring: boolean, deviceZone: string): Row | null {
|
||||
const { zone, allDay } = eventZone(event, deviceZone);
|
||||
const duration = parseDuration(event.duration ?? 'PT0S') ?? { negative: false, days: 0, seconds: 0 };
|
||||
if (allDay) {
|
||||
const dtstart = dateToUtcMidnight(event.start ?? '');
|
||||
if (dtstart === null) return null;
|
||||
const days = allDayDays(duration);
|
||||
return {
|
||||
[Events.DTSTART]: dtstart,
|
||||
[Events.DTEND]: recurring ? null : dtstart + days * DAY_MS,
|
||||
[Events.DURATION]: recurring ? `P${days}D` : null,
|
||||
[Events.EVENT_TIMEZONE]: 'UTC',
|
||||
[Events.ALL_DAY]: 1,
|
||||
};
|
||||
}
|
||||
const dtstart = localToUtc(event.start ?? '', zone);
|
||||
if (dtstart === null) return null;
|
||||
const nonNegative: Duration = duration.negative ? { negative: false, days: 0, seconds: 0 } : duration;
|
||||
return {
|
||||
[Events.DTSTART]: dtstart,
|
||||
[Events.DTEND]: recurring ? null : addDuration(dtstart, nonNegative, zone),
|
||||
[Events.DURATION]: recurring ? formatAospDuration(durationSeconds(nonNegative), false) : null,
|
||||
[Events.EVENT_TIMEZONE]: zone,
|
||||
[Events.ALL_DAY]: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** The timing a row holds, as JSCalendar properties. */
|
||||
export interface RowTiming {
|
||||
start: string;
|
||||
duration: string;
|
||||
/** The zone the start is in; null for floating and for all-day events without one. */
|
||||
timeZone: string | null;
|
||||
showWithoutTime: boolean;
|
||||
/** Whether `start` names a repeated wall time in `timeZone` (Stalwart drops those). */
|
||||
ambiguous: boolean;
|
||||
/** The instant of the start, for fixed-offset fallbacks. */
|
||||
dtstart: number;
|
||||
}
|
||||
|
||||
export interface RowTimingContext {
|
||||
/** The event's timing on the server (the shadow), for what an edit left alone. */
|
||||
server: TimingSource | null;
|
||||
/** EVENT_TIMEZONE as last written by device sync (the baseline), to tell a zone change from none. */
|
||||
baselineZone: string | null;
|
||||
recurring: boolean;
|
||||
deviceZone: string;
|
||||
}
|
||||
|
||||
const truthy = (v: unknown) => Number(v ?? 0) === 1;
|
||||
|
||||
/**
|
||||
* JSCalendar timing from a row: all-day rows give a date and whole days;
|
||||
* timed rows give the start in the event's zone and the duration with whole
|
||||
* days nominal. A floating event stays floating while its row keeps the
|
||||
* zone it was written in; a zone the user picked (or an all-day event made
|
||||
* timed) becomes the event's `timeZone`. An EVENT_TIMEZONE Intl doesn't know
|
||||
* is read as Android reads it, as GMT.
|
||||
*/
|
||||
export function rowTiming(cells: Row, ctx: RowTimingContext): RowTiming | null {
|
||||
const dtstart = Number(cells[Events.DTSTART]);
|
||||
if (cells[Events.DTSTART] === null || cells[Events.DTSTART] === undefined || !Number.isFinite(dtstart)) return null;
|
||||
const allDay = truthy(cells[Events.ALL_DAY]);
|
||||
const lengthOf = (zone: string): Duration => {
|
||||
if (ctx.recurring || cells[Events.DTEND] === null || cells[Events.DTEND] === undefined) {
|
||||
const d = parseDuration(cells[Events.DURATION]);
|
||||
if (!d || d.negative) return { negative: false, days: 0, seconds: 0 };
|
||||
const s = durationSeconds(d);
|
||||
return { negative: false, days: Math.floor(s / 86_400), seconds: s % 86_400 };
|
||||
}
|
||||
return durationBetween(dtstart, Number(cells[Events.DTEND]), zone);
|
||||
};
|
||||
if (allDay) {
|
||||
const d = lengthOf('UTC');
|
||||
const days = allDayDays(d);
|
||||
const keepZone = ctx.server?.showWithoutTime ? (ctx.server.timeZone ?? null) : null;
|
||||
return {
|
||||
start: utcMidnightToDate(dtstart),
|
||||
duration: formatJsDuration(days, 0),
|
||||
timeZone: keepZone,
|
||||
showWithoutTime: true,
|
||||
ambiguous: false,
|
||||
dtstart,
|
||||
};
|
||||
}
|
||||
const rowZoneRaw = String(cells[Events.EVENT_TIMEZONE] ?? '');
|
||||
const rowZone = canonicalZone(rowZoneRaw);
|
||||
const zoneUnchanged = ctx.baselineZone !== null && rowZoneRaw === ctx.baselineZone;
|
||||
const server = ctx.server;
|
||||
const serverTimed = !!server && !server.showWithoutTime;
|
||||
let timeZone: string | null;
|
||||
let computeIn: string;
|
||||
if (serverTimed && zoneUnchanged && !canonicalZone(server!.timeZone)) {
|
||||
// Floating on the server (or a zone Intl doesn't know): shown in the zone it was written in.
|
||||
timeZone = server!.timeZone ?? null;
|
||||
computeIn = rowZone ?? 'UTC';
|
||||
} else if (serverTimed && zoneUnchanged && server!.timeZone) {
|
||||
timeZone = server!.timeZone;
|
||||
computeIn = canonicalZone(server!.timeZone)!;
|
||||
} else if (rowZone) {
|
||||
timeZone = rowZone;
|
||||
computeIn = rowZone;
|
||||
} else {
|
||||
timeZone = 'Etc/UTC';
|
||||
computeIn = 'UTC';
|
||||
}
|
||||
const start = utcToLocal(dtstart, computeIn);
|
||||
const d = lengthOf(computeIn);
|
||||
return {
|
||||
start,
|
||||
duration: formatJsDuration(d.days, d.seconds),
|
||||
timeZone,
|
||||
showWithoutTime: false,
|
||||
ambiguous: timeZone !== null && classifyLocal(start, computeIn) === 'overlap',
|
||||
dtstart,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The same instant and wall time in a fixed-offset zone, for a start that is
|
||||
* a repeated wall time in its own zone (non-recurring events only).
|
||||
*/
|
||||
export function fixedOffsetTiming(timing: RowTiming): RowTiming {
|
||||
const zone = fixedOffsetZone(offsetAt(timing.dtstart, timing.timeZone ? canonicalZone(timing.timeZone) ?? 'UTC' : 'UTC'));
|
||||
const computeIn = canonicalZone(zone) ?? 'UTC';
|
||||
return { ...timing, timeZone: zone, start: utcToLocal(timing.dtstart, computeIn), ambiguous: false };
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* The units changes are detected, merged and uploaded in (docs/device-sync.md,
|
||||
* "Units"): groups of related columns, each attendee (by address), the
|
||||
* reminder set, and for a master each EXDATE entry. Exception rows are
|
||||
* compared with the same units, recursively.
|
||||
*/
|
||||
import { Attendees, Events } from '../android-columns';
|
||||
import type { EventBaseline, LocalEventRow } from '../planner';
|
||||
import type { Row } from '../types';
|
||||
import { addressKey, type SelfContext } from './attendees';
|
||||
import { MASTER_CELLS, EXCEPTION_CELLS, makeBaseline, sameCell } from './columns';
|
||||
import { exdateSet } from './exdate';
|
||||
import { sameReminders } from './reminders';
|
||||
import type { RowImage } from './image';
|
||||
import { isUnsetStatus } from './values';
|
||||
|
||||
export const COLUMN_UNITS: Record<string, readonly string[]> = {
|
||||
title: [Events.TITLE],
|
||||
description: [Events.DESCRIPTION],
|
||||
location: [Events.EVENT_LOCATION],
|
||||
timing: [Events.DTSTART, Events.DTEND, Events.DURATION, Events.EVENT_TIMEZONE, Events.ALL_DAY],
|
||||
rule: [Events.RRULE],
|
||||
status: [Events.STATUS],
|
||||
availability: [Events.AVAILABILITY],
|
||||
privacy: [Events.ACCESS_LEVEL],
|
||||
color: [Events.EVENT_COLOR],
|
||||
calendar: [Events.CALENDAR_ID],
|
||||
};
|
||||
|
||||
export const MASTER_COLUMN_UNITS = Object.keys(COLUMN_UNITS);
|
||||
/** An exception has no rule of its own and lives in its master's calendar. */
|
||||
export const EXCEPTION_COLUMN_UNITS = MASTER_COLUMN_UNITS.filter((u) => u !== 'rule' && u !== 'calendar');
|
||||
|
||||
/** Apps that rewrite what they don't model (docs/device-sync.md, "Lossy calendar editors"). */
|
||||
const LOSSY_EDITORS = ['org.fossify.calendar', 'com.simplemobiletools.calendar.pro', 'com.simplemobiletools.calendar'];
|
||||
|
||||
/** Whether MUTATORS names an editor whose attendee types and rule parts can't be trusted. */
|
||||
export function isLossyEditor(mutators: string | null | undefined): boolean {
|
||||
if (!mutators) return false;
|
||||
return mutators.split(',').some((m) => LOSSY_EDITORS.includes(m.trim()));
|
||||
}
|
||||
|
||||
/** One side of a comparison: a row as read, a baseline, or an image. */
|
||||
export interface Side {
|
||||
cells: Row;
|
||||
attendees: Row[];
|
||||
/** Null: not managed (Bulwark owns reminders). */
|
||||
reminders: Row[] | null;
|
||||
}
|
||||
|
||||
export function sideOfRow(row: LocalEventRow): Side {
|
||||
return { cells: row.cells, attendees: row.attendees.map((a) => a.cells), reminders: row.reminders.map((r) => r.cells) };
|
||||
}
|
||||
|
||||
export function sideOfBaseline(b: EventBaseline): Side {
|
||||
return { cells: b.cells, attendees: b.attendees, reminders: b.reminders };
|
||||
}
|
||||
|
||||
export function sideOfImage(image: RowImage): Side {
|
||||
return { cells: image.cells, attendees: image.attendees, reminders: image.reminders };
|
||||
}
|
||||
|
||||
export function baselineOfImage(image: RowImage, isException: boolean): EventBaseline {
|
||||
return makeBaseline(
|
||||
image.cells,
|
||||
isException ? EXCEPTION_CELLS : MASTER_CELLS,
|
||||
image.attendees,
|
||||
image.reminders ?? [],
|
||||
image.truncatedDescription,
|
||||
);
|
||||
}
|
||||
|
||||
export function columnUnitDiffers(unit: string, a: Side, b: Side): boolean {
|
||||
return (COLUMN_UNITS[unit] ?? []).some((c) => !sameCell(c, a.cells[c], b.cells[c]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the device changed a column unit against what it is measured by
|
||||
* (the baseline, or the plain instance of a new exception row). A STATUS an
|
||||
* app left NULL is no change: it sets no status (`statusFromDevice`).
|
||||
*/
|
||||
export function deviceChangedUnit(unit: string, current: Side, reference: Side): boolean {
|
||||
if (unit === 'status' && isUnsetStatus(current.cells[Events.STATUS])) return false;
|
||||
return columnUnitDiffers(unit, current, reference);
|
||||
}
|
||||
|
||||
/** Attendee rows by address key (the user's aliases are one key). */
|
||||
export function attendeesByKey(rows: Row[], ctx: SelfContext): Map<string, Row> {
|
||||
const out = new Map<string, Row>();
|
||||
for (const row of rows) {
|
||||
const key = addressKey(row[Attendees.ATTENDEE_EMAIL], ctx);
|
||||
if (key && !out.has(key)) out.set(key, row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const ATTENDEE_COMPARED = [Attendees.ATTENDEE_NAME, Attendees.ATTENDEE_RELATIONSHIP, Attendees.ATTENDEE_TYPE, Attendees.ATTENDEE_STATUS];
|
||||
/** What a lossy editor rewrites on every save and so never counts as an edit. */
|
||||
const LOSSY_IGNORED = new Set<string>([Attendees.ATTENDEE_RELATIONSHIP, Attendees.ATTENDEE_TYPE]);
|
||||
|
||||
export function attendeeDiffers(a: Row | undefined, b: Row | undefined, lossy = false): boolean {
|
||||
if (!a || !b) return !!a !== !!b;
|
||||
return ATTENDEE_COMPARED.some((c) => !(lossy && LOSSY_IGNORED.has(c)) && !sameCell(c, a[c], b[c]));
|
||||
}
|
||||
|
||||
export function remindersDiffer(a: Side, b: Side): boolean {
|
||||
if (a.reminders === null || b.reminders === null) return false;
|
||||
return !sameReminders(a.reminders, b.reminders);
|
||||
}
|
||||
|
||||
/** EXDATE entries of a side, normalised (see `exdateSet`). */
|
||||
export function exdateEntries(side: Side): Set<string> {
|
||||
return exdateSet(side.cells[Events.EXDATE], Number(side.cells[Events.ALL_DAY] ?? 0) === 1);
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
/**
|
||||
* Device changes → JMAP writes (docs/device-sync.md, "Uploads", "Device-side
|
||||
* changes" and "Scheduling messages").
|
||||
*
|
||||
* A dirty event uploads a patch built per unit from what differs from its
|
||||
* baseline, against the current shadow: pointers where Stalwart takes them,
|
||||
* a whole map where it needs one (the first participant, alert or override).
|
||||
* Exception rows upload only as overrides of their master: a new override
|
||||
* carries the master's details (Stalwart stores a bare one as a separate
|
||||
* event otherwise) without the properties Stalwart drops from overrides; an
|
||||
* existing one is patched one level deep, never deeper, because a deeper
|
||||
* pointer becomes a partial override that loses the other attendees.
|
||||
*
|
||||
* What Android can't represent is never sent: timing and rule of events
|
||||
* whose rule needs RSCALE, SKIP or leap months; a truncated description.
|
||||
* Stalwart drops local times it can't resolve, so a start in a repeated hour
|
||||
* goes out in a fixed-offset zone (single events) or not at all (series:
|
||||
* `dstAmbiguous`), and an UNTIL in a skipped or repeated hour moves an hour
|
||||
* later.
|
||||
*/
|
||||
import type { RecurrenceRule } from '../../api/types';
|
||||
import { EventStatus, Events } from '../android-columns';
|
||||
import type { CalendarContext, LocalEvent, LocalEventRow, LocalException } from '../planner';
|
||||
import type { Row } from '../types';
|
||||
import type { CalendarEventWire } from '../wire';
|
||||
import { clone } from '../common/json';
|
||||
import { ptr, setInPatch, type PatchObject } from '../common/patch';
|
||||
import { withNewOverrideDetails } from '../../lib/recurrence-instances';
|
||||
import { userIsOrganizer } from './attendees';
|
||||
import { isTruncatedBaseline } from './columns';
|
||||
import { durationTextSeconds, sameDurationLength } from './duration';
|
||||
import {
|
||||
applyAttendeeEdits,
|
||||
attendeeEdits,
|
||||
descriptionValue,
|
||||
locationChanges,
|
||||
reminderChanges,
|
||||
titleValue,
|
||||
type PathChange,
|
||||
} from './edits';
|
||||
import { exdateEntryKey } from './exdate';
|
||||
import {
|
||||
FORBIDDEN_OVERRIDE_KEYS,
|
||||
instanceOf,
|
||||
isExcluded,
|
||||
keyToInstanceTime,
|
||||
overridesOf,
|
||||
withoutForbiddenKeys,
|
||||
type Override,
|
||||
} from './exceptions';
|
||||
import { eventImage, plainInstanceImage } from './image';
|
||||
import { isRemovedInstance } from './merge';
|
||||
import { reminderRows } from './reminders';
|
||||
import { isRuleRepresentable, partsToRule, ruleToRRule, rruleParts, untilUtc, type RuleZone } from './rrule';
|
||||
import { NOTIFYING_UNITS, sendsSchedulingMessages } from './scheduling';
|
||||
import { fixedOffsetTiming, rowTiming, type RowTiming } from './timing';
|
||||
import {
|
||||
EXCEPTION_COLUMN_UNITS,
|
||||
MASTER_COLUMN_UNITS,
|
||||
baselineOfImage,
|
||||
columnUnitDiffers,
|
||||
deviceChangedUnit,
|
||||
exdateEntries,
|
||||
isLossyEditor,
|
||||
remindersDiffer,
|
||||
sideOfBaseline,
|
||||
sideOfRow,
|
||||
type Side,
|
||||
} from './units';
|
||||
import { argbToCss, availabilityFromDevice, privacyFromDevice, statusFromDevice } from './values';
|
||||
import {
|
||||
canonicalZone,
|
||||
classifyLocal,
|
||||
formatLocalDateTime,
|
||||
parseLocalDateTime,
|
||||
resolveWallClock,
|
||||
sameLocalDateTime,
|
||||
utcToLocal,
|
||||
wallFromMs,
|
||||
wallMs,
|
||||
} from './zoned-time';
|
||||
|
||||
/** Why an item is not uploaded now (a skip), or why its change was put back (a revert). */
|
||||
export const SKIP = {
|
||||
dstAmbiguous: 'dstAmbiguous',
|
||||
ruleNotRepresentable: 'ruleNotRepresentable',
|
||||
instanceOnly: 'instanceOnly',
|
||||
notOurCalendar: 'notOurCalendar',
|
||||
readOnly: 'readOnly',
|
||||
/** The user's answer, in a calendar that takes no answers either. */
|
||||
rsvpRefused: 'rsvpRefused',
|
||||
/** Moved in place to a calendar of another JMAP account: no patch can do that. */
|
||||
crossAccountMove: 'crossAccountMove',
|
||||
/** Every reminder removed from one occurrence of a series with alerts of its own: the occurrence would show those. */
|
||||
remindersNotRepresentable: 'remindersNotRepresentable',
|
||||
} as const;
|
||||
|
||||
export class SkipUpload extends Error {
|
||||
constructor(readonly reason: string) {
|
||||
super(reason);
|
||||
}
|
||||
}
|
||||
|
||||
const num = (v: unknown) => (v === null || v === undefined || v === '' ? null : Number.isFinite(Number(v)) ? Number(v) : null);
|
||||
|
||||
/** The zone and all-day flag the series' LocalDateTimes (start, keys, UNTIL) are in, as written to the rows. */
|
||||
export function seriesZone(local: LocalEventRow, shadow: CalendarEventWire | null, deviceZone: string): RuleZone {
|
||||
const allDay = shadow ? !!shadow.showWithoutTime : Number(local.cells[Events.ALL_DAY] ?? 0) === 1;
|
||||
if (allDay) return { allDay: true, zone: 'UTC' };
|
||||
const zone =
|
||||
canonicalZone(shadow?.timeZone) ??
|
||||
canonicalZone(local.baseline?.cells[Events.EVENT_TIMEZONE]) ??
|
||||
canonicalZone(local.cells[Events.EVENT_TIMEZONE]) ??
|
||||
canonicalZone(deviceZone) ??
|
||||
'UTC';
|
||||
return { allDay: false, zone };
|
||||
}
|
||||
|
||||
/** A wall time Stalwart would drop (skipped or repeated) moved an hour later. */
|
||||
function safeUntil(until: string, zone: RuleZone): string {
|
||||
if (zone.allDay) return until;
|
||||
const kind = classifyLocal(until, zone.zone);
|
||||
if (kind !== 'gap' && kind !== 'overlap') return until;
|
||||
const wall = parseLocalDateTime(until)!;
|
||||
return formatLocalDateTime(wallFromMs(wallMs(wall) + 3_600_000));
|
||||
}
|
||||
|
||||
/** Parts Fossify Calendar's rule editor models; others it drops, and they stay. */
|
||||
const LOSSY_RULE_PARTS = ['FREQ', 'INTERVAL', 'COUNT', 'UNTIL', 'BYDAY', 'BYMONTHDAY', 'BYMONTH'];
|
||||
|
||||
/**
|
||||
* The rule a device RRULE stands for. A lossy editor's rule is applied part
|
||||
* by part onto the server's rule: parts it models and changed are taken,
|
||||
* parts it dropped stay (docs/device-sync.md, "Lossy calendar editors").
|
||||
*/
|
||||
export function deviceRule(
|
||||
rrule: unknown,
|
||||
baselineRRule: unknown,
|
||||
server: RecurrenceRule | null | undefined,
|
||||
zone: RuleZone,
|
||||
lossy: boolean,
|
||||
): RecurrenceRule | null {
|
||||
const parts = rruleParts(rrule);
|
||||
if (!parts) return null;
|
||||
let merged = parts;
|
||||
if (lossy && server) {
|
||||
const before = rruleParts(baselineRRule) ?? new Map<string, string>();
|
||||
const serverParts = rruleParts(ruleText(server, zone)) ?? new Map<string, string>();
|
||||
merged = new Map(serverParts);
|
||||
for (const part of LOSSY_RULE_PARTS) {
|
||||
const now = parts.get(part);
|
||||
if (now !== undefined && now !== before.get(part)) {
|
||||
merged.set(part, now);
|
||||
if (part === 'COUNT') merged.delete('UNTIL');
|
||||
if (part === 'UNTIL') merged.delete('COUNT');
|
||||
}
|
||||
}
|
||||
}
|
||||
const rule = partsToRule(merged, zone);
|
||||
if (rule?.until) rule.until = safeUntil(rule.until, zone);
|
||||
return rule;
|
||||
}
|
||||
|
||||
/** The server's rule in RRULE parts, so both sides of a lossy merge compare in one dialect. */
|
||||
function ruleText(rule: RecurrenceRule, zone: RuleZone): string {
|
||||
return ruleToRRule(rule, zone) ?? '';
|
||||
}
|
||||
|
||||
// ─── Patches for a dirty event ──────────────────────────
|
||||
|
||||
export interface UploadComputation {
|
||||
patch: PatchObject;
|
||||
/** Master units in the patch. */
|
||||
masterUnits: Set<string>;
|
||||
/** Exception rows (by id) whose changes are in the patch. */
|
||||
exceptionUnits: Map<number, Set<string>>;
|
||||
/** Exception rows whose instance is excluded by the patch, or already was. */
|
||||
removedExceptions: Set<number>;
|
||||
sendSchedulingMessages: boolean;
|
||||
/** The patch holds nothing but the user's own answer, to the series or to occurrences (what `mayRSVP` allows). */
|
||||
rsvpOnly: boolean;
|
||||
/** Units changed on the device that can't be uploaded (reported when nothing else is). */
|
||||
dropped: string[];
|
||||
}
|
||||
|
||||
interface OverrideWork {
|
||||
/** Whole override values (or null) by recurrence id. */
|
||||
whole: Map<string, Override | null>;
|
||||
/** One-level properties of existing overrides by recurrence id. */
|
||||
props: Map<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/** Sets a raw path in a patch: escaped once, never overlapping a pointer already there. */
|
||||
export function put(patch: PatchObject, path: string[], value: unknown): void {
|
||||
setInPatch(patch, ptr(...path), value);
|
||||
}
|
||||
|
||||
function putAll(patch: PatchObject, root: string[], changes: PathChange[]): void {
|
||||
for (const c of changes) put(patch, [...root, ...c.path], c.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The patch for a dirty event with identity (see the header). Throws
|
||||
* `SkipUpload` for an item that must not be uploaded now.
|
||||
*/
|
||||
export function computeUpdate(local: LocalEvent, ctx: CalendarContext): UploadComputation {
|
||||
const shadow = local.shadow!;
|
||||
const calendar = ctx.calendarIdOfRow(local.calendarRowId);
|
||||
const calendarId = calendar?.calendarId ?? Object.keys(shadow.calendarIds ?? {})[0] ?? '';
|
||||
const image = eventImage(shadow, local.calendarRowId, calendarId, ctx);
|
||||
const baseline = local.baseline ?? (image ? baselineOfImage(image.master, false) : null);
|
||||
const zone = seriesZone(local, shadow, ctx.deviceZone);
|
||||
const lossy = isLossyEditor(local.mutators);
|
||||
const patch: PatchObject = {};
|
||||
const masterUnits = new Set<string>();
|
||||
const exceptionUnits = new Map<number, Set<string>>();
|
||||
const removedExceptions = new Set<number>();
|
||||
const dropped: string[] = [];
|
||||
const overrides: OverrideWork = { whole: new Map(), props: new Map() };
|
||||
const representable = isRuleRepresentable(shadow.recurrenceRule) && !shadow.recurrenceId;
|
||||
const serverOverrides = overridesOf(shadow);
|
||||
let rsvp = false;
|
||||
let attendeeOthers = false;
|
||||
let overridesChanged = false;
|
||||
let ruleRemoved = false;
|
||||
let rekey: number | null = null;
|
||||
|
||||
const cur = sideOfRow(local);
|
||||
const bl: Side | null = baseline ? sideOfBaseline(baseline) : null;
|
||||
const changed = (unit: string) => {
|
||||
if (!bl) return false;
|
||||
const forced = local.split === 'source' && unit === 'rule';
|
||||
return (local.dirty || forced) && deviceChangedUnit(unit, cur, bl);
|
||||
};
|
||||
|
||||
for (const unit of MASTER_COLUMN_UNITS) {
|
||||
if (!changed(unit)) continue;
|
||||
const cells = local.cells;
|
||||
switch (unit) {
|
||||
case 'title':
|
||||
put(patch, ['title'], titleValue(cells));
|
||||
break;
|
||||
case 'description':
|
||||
if (isTruncatedBaseline(baseline!.cells[Events.DESCRIPTION])) {
|
||||
dropped.push(unit);
|
||||
continue;
|
||||
}
|
||||
put(patch, ['description'], descriptionValue(cells));
|
||||
if (String(shadow.descriptionContentType ?? '').toLowerCase().startsWith('text/html')) {
|
||||
put(patch, ['descriptionContentType'], 'text/plain');
|
||||
}
|
||||
break;
|
||||
case 'location':
|
||||
putAll(patch, [], locationChanges(shadow, cells[Events.EVENT_LOCATION], ctx.mintKey).changes);
|
||||
break;
|
||||
case 'status':
|
||||
put(patch, ['status'], statusFromDevice(cells[Events.STATUS]));
|
||||
break;
|
||||
case 'availability':
|
||||
put(patch, ['freeBusyStatus'], availabilityFromDevice(cells[Events.AVAILABILITY]));
|
||||
break;
|
||||
case 'privacy':
|
||||
put(patch, ['privacy'], privacyFromDevice(cells[Events.ACCESS_LEVEL]));
|
||||
break;
|
||||
case 'color':
|
||||
put(patch, ['color'], argbToCss(cells[Events.EVENT_COLOR]));
|
||||
break;
|
||||
case 'calendar': {
|
||||
const to = ctx.calendarIdOfRow(Number(cells[Events.CALENDAR_ID]));
|
||||
const from = ctx.calendarIdOfRow(Number(baseline!.cells[Events.CALENDAR_ID]));
|
||||
if (!to || !from || to.jmapAccountId !== ctx.jmapAccountId || from.jmapAccountId !== ctx.jmapAccountId) {
|
||||
dropped.push(unit);
|
||||
continue;
|
||||
}
|
||||
put(patch, ['calendarIds', from.calendarId], null);
|
||||
put(patch, ['calendarIds', to.calendarId], true);
|
||||
break;
|
||||
}
|
||||
case 'timing': {
|
||||
if (!representable) {
|
||||
dropped.push(unit);
|
||||
continue;
|
||||
}
|
||||
const recurring = !!cells[Events.RRULE];
|
||||
let t = rowTiming(cells, {
|
||||
server: shadow,
|
||||
baselineZone: baseline!.cells[Events.EVENT_TIMEZONE] === null ? null : String(baseline!.cells[Events.EVENT_TIMEZONE] ?? ''),
|
||||
recurring,
|
||||
deviceZone: ctx.deviceZone,
|
||||
});
|
||||
if (!t) {
|
||||
dropped.push(unit);
|
||||
continue;
|
||||
}
|
||||
if (t.ambiguous) {
|
||||
if (recurring) throw new SkipUpload(SKIP.dstAmbiguous);
|
||||
t = fixedOffsetTiming(t);
|
||||
}
|
||||
applyTiming(patch, [], t, shadow);
|
||||
if (recurring && shadow.recurrenceRule && !sameLocalDateTime(t.start, shadow.start)) {
|
||||
rekey = wallMs(parseLocalDateTime(t.start)!) - wallMs(parseLocalDateTime(shadow.start)!);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'rule': {
|
||||
if (!representable) {
|
||||
dropped.push(unit);
|
||||
continue;
|
||||
}
|
||||
const ruleZone: RuleZone = { allDay: Number(cells[Events.ALL_DAY] ?? 0) === 1, zone: zone.zone };
|
||||
const rule = deviceRule(cells[Events.RRULE], baseline!.cells[Events.RRULE], shadow.recurrenceRule, ruleZone, lossy);
|
||||
if (!rule) {
|
||||
put(patch, ['recurrenceRule'], null);
|
||||
if (Object.keys(serverOverrides).length) put(patch, ['recurrenceOverrides'], null);
|
||||
ruleRemoved = true;
|
||||
break;
|
||||
}
|
||||
put(patch, ['recurrenceRule'], rule);
|
||||
// "This and following": overrides after the new end go.
|
||||
const end = seriesEnd(rule, local, ruleZone);
|
||||
if (end !== null) {
|
||||
for (const key of Object.keys(serverOverrides)) {
|
||||
const at = keyToInstanceTime(key, ruleZone.allDay, ruleZone.zone);
|
||||
if (at !== null && at > end) {
|
||||
overrides.whole.set(key, null);
|
||||
overridesChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
masterUnits.add(unit);
|
||||
}
|
||||
|
||||
// EXDATE entries added or removed on the device.
|
||||
if (bl && local.dirty && !ruleRemoved) {
|
||||
const now = exdateEntries(cur);
|
||||
const before = exdateEntries(bl);
|
||||
for (const entry of new Set([...now, ...before])) {
|
||||
if (now.has(entry) === before.has(entry)) continue;
|
||||
const key = exdateEntryKey(entry, zone);
|
||||
if (!key) continue;
|
||||
if (!zone.allDay && classifyLocal(key, zone.zone) === 'overlap') throw new SkipUpload(SKIP.dstAmbiguous);
|
||||
if (now.has(entry) && !isExcluded(serverOverrides[key])) overrides.whole.set(key, { excluded: true });
|
||||
else if (!now.has(entry) && isExcluded(serverOverrides[key])) overrides.whole.set(key, null);
|
||||
else continue;
|
||||
overridesChanged = true;
|
||||
masterUnits.add(`exdate:${entry}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Attendees.
|
||||
if (bl && local.dirty) {
|
||||
const edits = attendeeEdits(cur.attendees, bl.attendees, ctx, lossy);
|
||||
if (edits.length) {
|
||||
const result = applyAttendeeEdits(shadow.participants, edits, ctx, ctx.mintKey, lossy);
|
||||
if (result.changes.length) {
|
||||
if (!Object.keys(shadow.participants ?? {}).length) put(patch, ['participants'], result.participants);
|
||||
else putAll(patch, ['participants'], result.changes);
|
||||
rsvp = rsvp || result.rsvp;
|
||||
attendeeOthers = attendeeOthers || result.others;
|
||||
for (const e of edits) masterUnits.add(`attendee:${e.key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reminders, only when the calendar app owns them.
|
||||
if (bl && local.dirty && ctx.reminderOwner === 'device' && remindersDiffer(cur, bl)) {
|
||||
putAll(patch, [], reminderChanges(shadow, ctx.calendar(calendarId), cur.reminders!, ctx.mintKey).changes);
|
||||
masterUnits.add('reminders');
|
||||
}
|
||||
|
||||
// Exception rows.
|
||||
let answersOnly = true;
|
||||
if (!ruleRemoved && shadow.recurrenceRule) {
|
||||
for (const x of local.exceptions) {
|
||||
const units = exceptionChanges(x, local, shadow, calendarId, zone, overrides, removedExceptions, ctx, lossy);
|
||||
if (units.dropped) dropped.push(...units.dropped);
|
||||
if (units.units.size) {
|
||||
exceptionUnits.set(x.eventId, units.units);
|
||||
overridesChanged = true;
|
||||
rsvp = rsvp || units.rsvp;
|
||||
answersOnly &&= units.rsvp && !units.others && units.units.size === 1 && units.units.has('attendees');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!ruleRemoved) materializeOverrides(patch, shadow, overrides, rekey, zone);
|
||||
|
||||
const notifying = NOTIFYING_UNITS.some((u) => masterUnits.has(u)) || overridesChanged || attendeeOthers;
|
||||
const sendSchedulingMessages = sendsSchedulingMessages(shadow, { kind: 'update', notifying, rsvp, addsAttendees: attendeeOthers }, ctx);
|
||||
const rsvpOnly = rsvp && answersOnly && !attendeeOthers && [...masterUnits].every((u) => u.startsWith('attendee:'));
|
||||
|
||||
return { patch, masterUnits, exceptionUnits, removedExceptions, sendSchedulingMessages, dropped, rsvpOnly };
|
||||
}
|
||||
|
||||
/** Timing properties that differ from the server's. */
|
||||
function applyTiming(patch: PatchObject, root: string[], t: RowTiming, server: { start?: string; duration?: string; timeZone?: string | null; showWithoutTime?: boolean | null }): void {
|
||||
if (!sameLocalDateTime(t.start, server.start)) put(patch, [...root, 'start'], t.start);
|
||||
if (!sameDurationLength(t.duration, server.duration ?? 'PT0S')) put(patch, [...root, 'duration'], t.duration);
|
||||
if (root.length === 0 && (t.timeZone ?? null) !== (server.timeZone ?? null)) put(patch, ['timeZone'], t.timeZone);
|
||||
if (root.length === 0 && !!t.showWithoutTime !== !!server.showWithoutTime) put(patch, ['showWithoutTime'], t.showWithoutTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the capped series ends: the UNTIL instant, or for a COUNT the start
|
||||
* of the last instance as CalendarProvider computed it (LAST_DATE minus the
|
||||
* duration). Null when unknown or unbounded.
|
||||
*/
|
||||
function seriesEnd(rule: RecurrenceRule, local: LocalEventRow, zone: RuleZone): number | null {
|
||||
if (rule.until && !rule.count) return untilUtc(rule, zone);
|
||||
if (!rule.count) return null;
|
||||
const lastDate = num(local.cells[Events.LAST_DATE]);
|
||||
const seconds = durationTextSeconds(local.cells[Events.DURATION]);
|
||||
if (lastDate === null || seconds === null) return null;
|
||||
return lastDate - seconds * 1000;
|
||||
}
|
||||
|
||||
function exceptionChanges(
|
||||
x: LocalException,
|
||||
master: LocalEvent,
|
||||
shadow: CalendarEventWire,
|
||||
calendarId: string,
|
||||
zone: RuleZone,
|
||||
work: OverrideWork,
|
||||
removed: Set<number>,
|
||||
ctx: CalendarContext,
|
||||
lossy: boolean,
|
||||
): { units: Set<string>; rsvp: boolean; others?: boolean; dropped?: string[] } {
|
||||
const units = new Set<string>();
|
||||
const key = x.recurrenceId;
|
||||
if (!key) return { units, rsvp: false };
|
||||
const ours = typeof x.cells[Events.SYNC_DATA2] === 'string' && !!x.cells[Events.SYNC_DATA2];
|
||||
if (!ours && !zone.allDay && classifyLocal(key, zone.zone) === 'overlap') throw new SkipUpload(SKIP.dstAmbiguous);
|
||||
const server = overridesOf(shadow)[key];
|
||||
const cancelled = Number(x.cells[Events.STATUS]) === EventStatus.CANCELED;
|
||||
if (isRemovedInstance(x) || (cancelled && isExcluded(server))) {
|
||||
removed.add(x.eventId);
|
||||
if (!isExcluded(server)) {
|
||||
work.whole.set(key, { excluded: true });
|
||||
units.add('removed');
|
||||
}
|
||||
return { units, rsvp: false };
|
||||
}
|
||||
if (!x.dirty) return { units, rsvp: false };
|
||||
const plain = plainInstanceImage(shadow, key, master.calendarRowId, calendarId, ctx);
|
||||
if (!plain) return { units, rsvp: false };
|
||||
const existing = !!server && !isExcluded(server);
|
||||
const instance = instanceOf(shadow, key, existing ? server : null);
|
||||
// An existing override changes against what device sync last wrote; a new one against the plain instance.
|
||||
const reference: Side = existing && x.baseline ? sideOfBaseline(x.baseline) : { cells: plain.cells, attendees: plain.attendees, reminders: plain.reminders };
|
||||
const cur = sideOfRow(x);
|
||||
const values: Record<string, unknown> = {};
|
||||
let rsvp = false;
|
||||
let others = false;
|
||||
const organizer = userIsOrganizer(shadow, ctx);
|
||||
|
||||
for (const unit of EXCEPTION_COLUMN_UNITS) {
|
||||
if (unit === 'privacy') continue; // Stalwart drops privacy from overrides.
|
||||
// Etar answers one instance with STATUS_CONFIRMED on it; an attendee may not change an event's status.
|
||||
if (unit === 'status' && !organizer) continue;
|
||||
if (unit !== 'timing' && !deviceChangedUnit(unit, cur, reference)) continue;
|
||||
// A new exception row holds what its app copied: an empty value there is one it did not model, not a deletion.
|
||||
if (!existing && INHERITED_WHEN_EMPTY.has(unit) && isEmptyUnit(unit, x.cells)) continue;
|
||||
const cells = x.cells;
|
||||
switch (unit) {
|
||||
case 'title':
|
||||
values.title = titleValue(cells);
|
||||
break;
|
||||
case 'description':
|
||||
if (x.baseline && isTruncatedBaseline(x.baseline.cells[Events.DESCRIPTION])) continue;
|
||||
values.description = descriptionValue(cells) ?? clearedInOverride('description', instance, shadow);
|
||||
break;
|
||||
case 'location':
|
||||
values.locations = locationChanges(instance, cells[Events.EVENT_LOCATION], ctx.mintKey).locations ?? clearedInOverride('location', instance, shadow);
|
||||
break;
|
||||
case 'status':
|
||||
values.status = statusFromDevice(cells[Events.STATUS]);
|
||||
break;
|
||||
case 'availability':
|
||||
values.freeBusyStatus = availabilityFromDevice(cells[Events.AVAILABILITY]);
|
||||
break;
|
||||
case 'color':
|
||||
values.color = argbToCss(cells[Events.EVENT_COLOR]) ?? clearedInOverride('color', instance, shadow);
|
||||
break;
|
||||
case 'timing': {
|
||||
const moved = columnUnitDiffers('timing', cur, reference);
|
||||
if (existing && !moved) continue;
|
||||
let t = rowTiming(cells, {
|
||||
server: { ...instance, showWithoutTime: shadow.showWithoutTime, timeZone: instance.timeZone ?? shadow.timeZone },
|
||||
baselineZone: String(reference.cells[Events.EVENT_TIMEZONE] ?? ''),
|
||||
recurring: false,
|
||||
deviceZone: ctx.deviceZone,
|
||||
});
|
||||
if (!t) continue;
|
||||
if (t.ambiguous) throw new SkipUpload(SKIP.dstAmbiguous);
|
||||
if (t.showWithoutTime !== !!shadow.showWithoutTime) t = { ...t, showWithoutTime: !!shadow.showWithoutTime };
|
||||
if (existing) {
|
||||
if (!sameLocalDateTime(t.start, instance.start)) values.start = t.start;
|
||||
if (!sameDurationLength(t.duration, instance.duration ?? 'PT0S')) values.duration = t.duration;
|
||||
if (!('start' in values) && !('duration' in values)) continue;
|
||||
} else {
|
||||
// A new override pins its start and length, as the app's own occurrence edits do.
|
||||
values.start = t.start;
|
||||
values.duration = t.duration;
|
||||
if (!moved) continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
units.add(unit);
|
||||
}
|
||||
|
||||
// A new exception row without attendees or reminders did not copy them: the series' apply.
|
||||
const edits = existing || cur.attendees.length ? attendeeEdits(cur.attendees, reference.attendees, ctx, lossy) : [];
|
||||
if (edits.length) {
|
||||
const result = applyAttendeeEdits(instance.participants, edits, ctx, ctx.mintKey, lossy);
|
||||
if (result.changes.length) {
|
||||
values.participants = result.participants;
|
||||
rsvp = result.rsvp;
|
||||
others = result.others;
|
||||
units.add('attendees');
|
||||
}
|
||||
}
|
||||
let dropped: string[] | undefined;
|
||||
if (ctx.reminderOwner === 'device' && (existing || cur.reminders!.length) && remindersDiffer(cur, reference)) {
|
||||
const alerts = reminderChanges(instance, ctx.calendar(calendarId), cur.reminders!, ctx.mintKey).alerts;
|
||||
if (!Object.keys(alerts).length && reminderRows(shadow.alerts).length) {
|
||||
// An override is stored as a VEVENT of its own and an empty map writes no VALARM, so an override without
|
||||
// alerts shows the series' again: none at all can't be stored while the series has alerts of its own.
|
||||
dropped = ['reminders'];
|
||||
} else {
|
||||
values.alerts = alerts;
|
||||
// An occurrence on the calendar's defaults ignores its own alerts: it leaves them, as a series does
|
||||
// (Stalwart keeps the override's `useDefaultAlerts` as a JSPROP; the series stays on the defaults).
|
||||
if (instance.useDefaultAlerts) values.useDefaultAlerts = false;
|
||||
units.add('reminders');
|
||||
}
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
if (units.size) work.props.set(key, { ...(work.props.get(key) ?? {}), ...values });
|
||||
return { units, rsvp, others, dropped };
|
||||
}
|
||||
// A new override: nothing but its pinned time is still an override (an app saving the instance unchanged).
|
||||
if (!units.size) units.add('new');
|
||||
work.whole.set(key, newOverride(shadow, key, values));
|
||||
return { units, rsvp, others, dropped };
|
||||
}
|
||||
|
||||
/** Units a new exception row inherits from its series when it leaves them empty. */
|
||||
const INHERITED_WHEN_EMPTY = new Set(['title', 'description', 'location', 'color']);
|
||||
|
||||
function isEmptyUnit(unit: string, cells: Row): boolean {
|
||||
const column = { title: Events.TITLE, description: Events.DESCRIPTION, location: Events.EVENT_LOCATION, color: Events.EVENT_COLOR }[unit];
|
||||
const v = column ? cells[column] : null;
|
||||
return v === null || v === undefined || v === '';
|
||||
}
|
||||
|
||||
/**
|
||||
* A description, location or colour cleared on an existing override. An
|
||||
* override without one of its own shows the series' again, so while the
|
||||
* series has one the override gets the empty value Stalwart keeps: the empty
|
||||
* text or colour (an empty DESCRIPTION or COLOR; an empty colour is none, the
|
||||
* calendar's, on the device), or a location without a name (an empty map
|
||||
* writes no LOCATION line and reads back as none of its own). Null, a clean
|
||||
* removal, when nothing would come back.
|
||||
*/
|
||||
function clearedInOverride(unit: 'description' | 'location' | 'color', instance: CalendarEventWire, master: CalendarEventWire): unknown {
|
||||
if (unit === 'description') return typeof master.description === 'string' && master.description ? '' : null;
|
||||
if (unit === 'color') return typeof master.color === 'string' && master.color ? '' : null;
|
||||
const series = Object.keys(master.locations ?? {});
|
||||
if (!series.length) return null;
|
||||
const key = Object.keys(instance.locations ?? {})[0] ?? series[0];
|
||||
return { [key]: { '@type': 'Location', name: '' } };
|
||||
}
|
||||
|
||||
/**
|
||||
* A new override: the changes plus the details Stalwart needs on it (title,
|
||||
* locations, participants, sequence, …; `withNewOverrideDetails`), minus what
|
||||
* Stalwart drops from overrides. The master's own alerts are not copied
|
||||
* while it uses the calendar's defaults: they are not in force.
|
||||
*/
|
||||
export function newOverride(master: CalendarEventWire, key: string, values: Record<string, unknown>): Override {
|
||||
const occurrence = {
|
||||
...(master as unknown as Record<string, unknown>),
|
||||
recurrenceId: key,
|
||||
recurrenceOverrides: master.recurrenceOverrides ?? {},
|
||||
start: undefined,
|
||||
baseEventId: undefined,
|
||||
};
|
||||
const withDetails = withNewOverrideDetails(occurrence as never, values as never) as Record<string, unknown>;
|
||||
if (master.useDefaultAlerts && !('alerts' in values)) delete withDetails.alerts;
|
||||
const out = withoutForbiddenKeys(withDetails);
|
||||
for (const name of FORBIDDEN_OVERRIDE_KEYS) delete out[name];
|
||||
return out;
|
||||
}
|
||||
|
||||
function materializeOverrides(patch: PatchObject, shadow: CalendarEventWire, work: OverrideWork, rekey: number | null, zone: RuleZone): void {
|
||||
const server = overridesOf(shadow);
|
||||
if (rekey !== null && Object.keys(server).length) {
|
||||
// "All events" moved the series: every recurrence id moves by the same wall-clock offset.
|
||||
const map: Record<string, Override> = clone(server);
|
||||
for (const [key, value] of work.whole) {
|
||||
if (value === null) delete map[key];
|
||||
else map[key] = value;
|
||||
}
|
||||
for (const [key, props] of work.props) if (map[key]) map[key] = { ...map[key], ...props };
|
||||
const shifted: Record<string, Override> = {};
|
||||
for (const [key, value] of Object.entries(map)) {
|
||||
const moved = shiftLocal(key, rekey, zone);
|
||||
const next = clone(value);
|
||||
if (!isExcluded(next) && typeof next.start === 'string' && sameLocalDateTime(next.start, key)) next.start = shiftLocal(next.start, rekey, zone);
|
||||
shifted[moved] = next;
|
||||
}
|
||||
put(patch, ['recurrenceOverrides'], Object.keys(shifted).length ? shifted : null);
|
||||
return;
|
||||
}
|
||||
if (!Object.keys(server).length) {
|
||||
// The first override: a pointer below a missing map fails, so the whole map goes.
|
||||
const whole: Record<string, Override> = {};
|
||||
for (const [key, value] of work.whole) if (value) whole[key] = value;
|
||||
if (Object.keys(whole).length) put(patch, ['recurrenceOverrides'], whole);
|
||||
return;
|
||||
}
|
||||
for (const [key, value] of work.whole) put(patch, ['recurrenceOverrides', key], value);
|
||||
for (const [key, props] of work.props) for (const [name, value] of Object.entries(props)) put(patch, ['recurrenceOverrides', key, name], value);
|
||||
}
|
||||
|
||||
/** A LocalDateTime moved by wall-clock milliseconds; a result in a skipped hour moves forward like the instance does. */
|
||||
function shiftLocal(value: string, deltaMs: number, zone: RuleZone): string {
|
||||
const wall = parseLocalDateTime(value);
|
||||
if (!wall) return value;
|
||||
const moved = wallFromMs(wallMs(wall) + deltaMs);
|
||||
const text = formatLocalDateTime(moved);
|
||||
if (zone.allDay) return text;
|
||||
const kind = classifyLocal(text, zone.zone);
|
||||
if (kind === 'overlap') throw new SkipUpload(SKIP.dstAmbiguous);
|
||||
if (kind === 'gap') return utcToLocal(resolveWallClock(moved, canonicalZone(zone.zone) ?? 'UTC').utc, zone.zone);
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Value tables between JSCalendar and CalendarContract (docs/device-sync.md,
|
||||
* "Other properties"): status, availability, privacy, colours and the
|
||||
* attendee enums. Pure lookups, both directions.
|
||||
*/
|
||||
import {
|
||||
AttendeeRelationship,
|
||||
AttendeeStatus,
|
||||
AttendeeType,
|
||||
EventAccess,
|
||||
EventAvailability,
|
||||
EventStatus,
|
||||
ReminderMethod,
|
||||
} from '../android-columns';
|
||||
|
||||
// ─── Event status, availability, privacy ─────────────────
|
||||
|
||||
/** `status` → STATUS; absent (JSCalendar's default is confirmed) → CONFIRMED. Never null: the provider throws on it. */
|
||||
export function statusToDevice(status: unknown): number {
|
||||
switch (String(status ?? '').toLowerCase()) {
|
||||
case 'tentative':
|
||||
return EventStatus.TENTATIVE;
|
||||
case 'cancelled':
|
||||
return EventStatus.CANCELED;
|
||||
default:
|
||||
return EventStatus.CONFIRMED;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* STATUS → `status`; null when the row has none. CalendarProvider has no
|
||||
* default for STATUS, and apps that do not model it (Google Calendar) leave
|
||||
* it NULL on the events and exceptions they insert: that sets no status.
|
||||
*/
|
||||
export function statusFromDevice(value: unknown): 'confirmed' | 'tentative' | 'cancelled' | null {
|
||||
if (isUnsetStatus(value)) return null;
|
||||
const n = Number(value);
|
||||
if (n === EventStatus.TENTATIVE) return 'tentative';
|
||||
if (n === EventStatus.CANCELED) return 'cancelled';
|
||||
return 'confirmed';
|
||||
}
|
||||
|
||||
/** A STATUS cell an app left NULL (see `statusFromDevice`). */
|
||||
export function isUnsetStatus(value: unknown): boolean {
|
||||
return value === null || value === undefined || value === '';
|
||||
}
|
||||
|
||||
/** `freeBusyStatus` → AVAILABILITY; JSCalendar's default is busy. */
|
||||
export function availabilityToDevice(freeBusy: unknown): number {
|
||||
return String(freeBusy ?? '').toLowerCase() === 'free' ? EventAvailability.FREE : EventAvailability.BUSY;
|
||||
}
|
||||
|
||||
/** AVAILABILITY → `freeBusyStatus`; TENTATIVE has no JSCalendar value and uploads as busy. */
|
||||
export function availabilityFromDevice(value: unknown): 'free' | 'busy' {
|
||||
return Number(value) === EventAvailability.FREE ? 'free' : 'busy';
|
||||
}
|
||||
|
||||
/** `privacy` → ACCESS_LEVEL: absent DEFAULT, public PUBLIC, private PRIVATE, secret CONFIDENTIAL. */
|
||||
export function privacyToDevice(privacy: unknown): number {
|
||||
switch (String(privacy ?? '').toLowerCase()) {
|
||||
case 'public':
|
||||
return EventAccess.PUBLIC;
|
||||
case 'private':
|
||||
return EventAccess.PRIVATE;
|
||||
case 'secret':
|
||||
return EventAccess.CONFIDENTIAL;
|
||||
default:
|
||||
return EventAccess.DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
/** ACCESS_LEVEL → `privacy`; DEFAULT removes the property. */
|
||||
export function privacyFromDevice(value: unknown): 'public' | 'private' | 'secret' | null {
|
||||
switch (Number(value)) {
|
||||
case EventAccess.PUBLIC:
|
||||
return 'public';
|
||||
case EventAccess.PRIVATE:
|
||||
return 'private';
|
||||
case EventAccess.CONFIDENTIAL:
|
||||
return 'secret';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Attendees ──────────────────────────────────────────
|
||||
|
||||
/** participationStatus → ATTENDEE_STATUS: accepted 1, declined 2, needs-action (the default) 3, tentative 4, else NONE. */
|
||||
export function participationToDevice(status: unknown): number {
|
||||
switch (String(status ?? 'needs-action').toLowerCase()) {
|
||||
case 'accepted':
|
||||
return AttendeeStatus.ACCEPTED;
|
||||
case 'declined':
|
||||
return AttendeeStatus.DECLINED;
|
||||
case 'needs-action':
|
||||
return AttendeeStatus.INVITED;
|
||||
case 'tentative':
|
||||
return AttendeeStatus.TENTATIVE;
|
||||
default:
|
||||
return AttendeeStatus.NONE;
|
||||
}
|
||||
}
|
||||
|
||||
/** ATTENDEE_STATUS → participationStatus; NONE has no JSCalendar value. */
|
||||
export function participationFromDevice(value: unknown): string | null {
|
||||
switch (Number(value)) {
|
||||
case AttendeeStatus.ACCEPTED:
|
||||
return 'accepted';
|
||||
case AttendeeStatus.DECLINED:
|
||||
return 'declined';
|
||||
case AttendeeStatus.INVITED:
|
||||
return 'needs-action';
|
||||
case AttendeeStatus.TENTATIVE:
|
||||
return 'tentative';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** ATTENDEE_TYPE: the `optional` role → OPTIONAL, kind resource/location → RESOURCE, else REQUIRED. */
|
||||
export function attendeeTypeToDevice(participant: { roles?: Record<string, boolean> | null; kind?: string | null }): number {
|
||||
const kind = String(participant.kind ?? '').toLowerCase();
|
||||
if (kind === 'resource' || kind === 'location') return AttendeeType.RESOURCE;
|
||||
if (participant.roles?.optional) return AttendeeType.OPTIONAL;
|
||||
return AttendeeType.REQUIRED;
|
||||
}
|
||||
|
||||
export function relationshipToDevice(isOrganizer: boolean): number {
|
||||
return isOrganizer ? AttendeeRelationship.ORGANIZER : AttendeeRelationship.ATTENDEE;
|
||||
}
|
||||
|
||||
// ─── Reminders ──────────────────────────────────────────
|
||||
|
||||
/** Reminders.METHOD for an alert action: email → EMAIL, anything else → ALERT. */
|
||||
export function reminderMethodToDevice(action: unknown): number {
|
||||
return String(action ?? '').toLowerCase() === 'email' ? ReminderMethod.EMAIL : ReminderMethod.ALERT;
|
||||
}
|
||||
|
||||
/** The alert action of a METHOD: EMAIL → email; DEFAULT, ALERT, ALARM and SMS all fire a notification. */
|
||||
export function reminderActionFromDevice(method: unknown): 'email' | 'display' {
|
||||
return Number(method) === ReminderMethod.EMAIL ? 'email' : 'display';
|
||||
}
|
||||
|
||||
// ─── Colours ────────────────────────────────────────────
|
||||
|
||||
/** CSS named colours (CSS Color Module Level 4), as `name:rrggbb`. */
|
||||
const NAMED_COLORS = Object.fromEntries(
|
||||
(
|
||||
'aliceblue:f0f8ff antiquewhite:faebd7 aqua:00ffff aquamarine:7fffd4 azure:f0ffff beige:f5f5dc bisque:ffe4c4 ' +
|
||||
'black:000000 blanchedalmond:ffebcd blue:0000ff blueviolet:8a2be2 brown:a52a2a burlywood:deb887 ' +
|
||||
'cadetblue:5f9ea0 chartreuse:7fff00 chocolate:d2691e coral:ff7f50 cornflowerblue:6495ed cornsilk:fff8dc ' +
|
||||
'crimson:dc143c cyan:00ffff darkblue:00008b darkcyan:008b8b darkgoldenrod:b8860b darkgray:a9a9a9 ' +
|
||||
'darkgreen:006400 darkgrey:a9a9a9 darkkhaki:bdb76b darkmagenta:8b008b darkolivegreen:556b2f ' +
|
||||
'darkorange:ff8c00 darkorchid:9932cc darkred:8b0000 darksalmon:e9967a darkseagreen:8fbc8f ' +
|
||||
'darkslateblue:483d8b darkslategray:2f4f4f darkslategrey:2f4f4f darkturquoise:00ced1 darkviolet:9400d3 ' +
|
||||
'deeppink:ff1493 deepskyblue:00bfff dimgray:696969 dimgrey:696969 dodgerblue:1e90ff firebrick:b22222 ' +
|
||||
'floralwhite:fffaf0 forestgreen:228b22 fuchsia:ff00ff gainsboro:dcdcdc ghostwhite:f8f8ff gold:ffd700 ' +
|
||||
'goldenrod:daa520 gray:808080 green:008000 greenyellow:adff2f grey:808080 honeydew:f0fff0 hotpink:ff69b4 ' +
|
||||
'indianred:cd5c5c indigo:4b0082 ivory:fffff0 khaki:f0e68c lavender:e6e6fa lavenderblush:fff0f5 ' +
|
||||
'lawngreen:7cfc00 lemonchiffon:fffacd lightblue:add8e6 lightcoral:f08080 lightcyan:e0ffff ' +
|
||||
'lightgoldenrodyellow:fafad2 lightgray:d3d3d3 lightgreen:90ee90 lightgrey:d3d3d3 lightpink:ffb6c1 ' +
|
||||
'lightsalmon:ffa07a lightseagreen:20b2aa lightskyblue:87cefa lightslategray:778899 lightslategrey:778899 ' +
|
||||
'lightsteelblue:b0c4de lightyellow:ffffe0 lime:00ff00 limegreen:32cd32 linen:faf0e6 magenta:ff00ff ' +
|
||||
'maroon:800000 mediumaquamarine:66cdaa mediumblue:0000cd mediumorchid:ba55d3 mediumpurple:9370db ' +
|
||||
'mediumseagreen:3cb371 mediumslateblue:7b68ee mediumspringgreen:00fa9a mediumturquoise:48d1cc ' +
|
||||
'mediumvioletred:c71585 midnightblue:191970 mintcream:f5fffa mistyrose:ffe4e1 moccasin:ffe4b5 ' +
|
||||
'navajowhite:ffdead navy:000080 oldlace:fdf5e6 olive:808000 olivedrab:6b8e23 orange:ffa500 ' +
|
||||
'orangered:ff4500 orchid:da70d6 palegoldenrod:eee8aa palegreen:98fb98 paleturquoise:afeeee ' +
|
||||
'palevioletred:db7093 papayawhip:ffefd5 peachpuff:ffdab9 peru:cd853f pink:ffc0cb plum:dda0dd ' +
|
||||
'powderblue:b0e0e6 purple:800080 rebeccapurple:663399 red:ff0000 rosybrown:bc8f8f royalblue:4169e1 ' +
|
||||
'saddlebrown:8b4513 salmon:fa8072 sandybrown:f4a460 seagreen:2e8b57 seashell:fff5ee sienna:a0522d ' +
|
||||
'silver:c0c0c0 skyblue:87ceeb slateblue:6a5acd slategray:708090 slategrey:708090 snow:fffafa ' +
|
||||
'springgreen:00ff7f steelblue:4682b4 tan:d2b48c teal:008080 thistle:d8bfd8 tomato:ff6347 ' +
|
||||
'turquoise:40e0d0 violet:ee82ee wheat:f5deb3 white:ffffff whitesmoke:f5f5f5 yellow:ffff00 ' +
|
||||
'yellowgreen:9acd32'
|
||||
)
|
||||
.split(' ')
|
||||
.map((pair) => pair.split(':') as [string, string]),
|
||||
);
|
||||
|
||||
/**
|
||||
* A CSS colour (a name, `#rgb` or `#rrggbb`) as the signed 32-bit ARGB int
|
||||
* the provider stores, fully opaque; null for anything else.
|
||||
*/
|
||||
export function cssColorToArgb(color: unknown): number | null {
|
||||
if (typeof color !== 'string') return null;
|
||||
const c = color.trim().toLowerCase();
|
||||
let hex = NAMED_COLORS[c] ?? null;
|
||||
if (!hex) {
|
||||
const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/.exec(c);
|
||||
if (!m) return null;
|
||||
hex = m[1].length === 3 ? m[1].split('').map((ch) => ch + ch).join('') : m[1];
|
||||
}
|
||||
return (0xff000000 | parseInt(hex, 16)) | 0;
|
||||
}
|
||||
|
||||
/** An ARGB int as `#rrggbb` (the alpha is dropped: JSCalendar colours are opaque). */
|
||||
export function argbToCss(value: unknown): string | null {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
return `#${((n >>> 0) & 0xffffff).toString(16).padStart(6, '0')}`;
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* JSCalendar LocalDateTimes in an IANA zone ↔ UTC instants, for mapping
|
||||
* events to CalendarProvider's millisecond columns (docs/device-sync.md,
|
||||
* "Timing" and "DST edges"). Only `Intl` is used, so it behaves the same on
|
||||
* Hermes and in vitest, whatever zone the process runs in.
|
||||
*
|
||||
* RFC 5545 §3.3.5 decides the transition hours: a wall time in a DST gap is
|
||||
* read with the offset before the gap (it moves forward by the gap), and a
|
||||
* wall time in an overlap is the earlier of its two instants. The app's
|
||||
* `localDateTimeToInstant` (lib/calendar-timezone.ts) gets one of the two
|
||||
* wrong depending on the sign of the zone's offset, and its module imports
|
||||
* the settings store, so it is not reused here.
|
||||
*/
|
||||
|
||||
export interface WallClock {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hour: number;
|
||||
minute: number;
|
||||
second: number;
|
||||
}
|
||||
|
||||
export type LocalTimeKind = 'ok' | 'gap' | 'overlap';
|
||||
|
||||
export const DAY_MS = 86_400_000;
|
||||
const HOUR_MS = 3_600_000;
|
||||
|
||||
const LOCAL_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:[.,]\d+)?)?)?/;
|
||||
|
||||
/**
|
||||
* The fields of a LocalDateTime (`2026-10-25T02:30:00`) or a date
|
||||
* (`2026-10-25`, read as midnight). Like Stalwart, a fraction and a trailing
|
||||
* `Z` or offset are ignored. Null for anything that is not a real date-time.
|
||||
*/
|
||||
export function parseLocalDateTime(value: unknown): WallClock | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const m = LOCAL_DATE_TIME.exec(value.trim());
|
||||
if (!m) return null;
|
||||
const wall: WallClock = {
|
||||
year: Number(m[1]),
|
||||
month: Number(m[2]),
|
||||
day: Number(m[3]),
|
||||
hour: Number(m[4] ?? 0),
|
||||
minute: Number(m[5] ?? 0),
|
||||
second: Number(m[6] ?? 0),
|
||||
};
|
||||
if (wall.hour > 23 || wall.minute > 59 || wall.second > 59) return null;
|
||||
// Date arithmetic rolls 2026-02-30 over into March; a real date survives the round trip.
|
||||
const back = wallFromMs(wallMs(wall));
|
||||
return back.month === wall.month && back.day === wall.day ? wall : null;
|
||||
}
|
||||
|
||||
const pad = (n: number, len = 2) => String(n).padStart(len, '0');
|
||||
|
||||
export function formatLocalDateTime(wall: WallClock): string {
|
||||
return `${pad(wall.year, 4)}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}:${pad(wall.second)}`;
|
||||
}
|
||||
|
||||
/** `YYYY-MM-DD` of a wall clock. */
|
||||
export function formatDate(wall: WallClock): string {
|
||||
return `${pad(wall.year, 4)}-${pad(wall.month)}-${pad(wall.day)}`;
|
||||
}
|
||||
|
||||
/** The wall clock read as if it were UTC, in milliseconds (years below 100 included). */
|
||||
export function wallMs(wall: WallClock): number {
|
||||
const d = new Date(0);
|
||||
d.setUTCFullYear(wall.year, wall.month - 1, wall.day);
|
||||
d.setUTCHours(wall.hour, wall.minute, wall.second, 0);
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
export function wallFromMs(ms: number): WallClock {
|
||||
const d = new Date(ms);
|
||||
return {
|
||||
year: d.getUTCFullYear(),
|
||||
month: d.getUTCMonth() + 1,
|
||||
day: d.getUTCDate(),
|
||||
hour: d.getUTCHours(),
|
||||
minute: d.getUTCMinutes(),
|
||||
second: d.getUTCSeconds(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Wall-clock arithmetic: calendar days, then an exact amount of wall time. */
|
||||
export function addToWall(wall: WallClock, days: number, ms = 0): WallClock {
|
||||
return wallFromMs(wallMs(wall) + days * DAY_MS + ms);
|
||||
}
|
||||
|
||||
// ─── Zones ──────────────────────────────────────────────
|
||||
|
||||
const zoneCache = new Map<string, string | null>();
|
||||
|
||||
/**
|
||||
* The zone as Intl names it (canonical case, aliases resolved: `US/Eastern`
|
||||
* → `America/New_York`, `Etc/UTC` → `UTC`), or null when Intl does not know
|
||||
* it. Android's TimeZone silently turns an unknown id into GMT and Stalwart
|
||||
* turns one into a floating event, so every zone is checked before either
|
||||
* write.
|
||||
*/
|
||||
export function canonicalZone(zone: unknown): string | null {
|
||||
if (typeof zone !== 'string' || !zone) return null;
|
||||
let canonical = zoneCache.get(zone);
|
||||
if (canonical === undefined) {
|
||||
try {
|
||||
canonical = new Intl.DateTimeFormat('en-US', { timeZone: zone }).resolvedOptions().timeZone ?? zone;
|
||||
} catch {
|
||||
canonical = null;
|
||||
}
|
||||
zoneCache.set(zone, canonical);
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
export function isValidTimeZone(zone: unknown): zone is string {
|
||||
return canonicalZone(zone) !== null;
|
||||
}
|
||||
|
||||
const formatters = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
function formatter(zone: string): Intl.DateTimeFormat {
|
||||
let f = formatters.get(zone);
|
||||
if (!f) {
|
||||
f = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: zone,
|
||||
hourCycle: 'h23',
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric',
|
||||
});
|
||||
formatters.set(zone, f);
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
/** Wall-clock fields of an instant in `zone` (whole seconds). */
|
||||
export function wallClockAt(utcMs: number, zone: string): WallClock {
|
||||
const fields: Record<string, number> = {};
|
||||
for (const part of formatter(zone).formatToParts(new Date(floorSecond(utcMs)))) {
|
||||
if (part.type !== 'literal') fields[part.type] = Number(part.value);
|
||||
}
|
||||
return {
|
||||
year: fields.year,
|
||||
month: fields.month,
|
||||
day: fields.day,
|
||||
// Some engines still say "24" at midnight despite h23.
|
||||
hour: fields.hour === 24 ? 0 : fields.hour,
|
||||
minute: fields.minute,
|
||||
second: fields.second,
|
||||
};
|
||||
}
|
||||
|
||||
function floorSecond(ms: number): number {
|
||||
return Math.floor(ms / 1000) * 1000;
|
||||
}
|
||||
|
||||
/** The zone's UTC offset at an instant in ms, east positive. */
|
||||
export function offsetAt(utcMs: number, zone: string): number {
|
||||
const at = floorSecond(utcMs);
|
||||
return wallMs(wallClockAt(at, zone)) - at;
|
||||
}
|
||||
|
||||
export interface ResolvedLocalTime {
|
||||
/** The instant per RFC 5545: gap → the offset before it; overlap → the earlier one. */
|
||||
utc: number;
|
||||
kind: LocalTimeKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* The instant of a wall clock in `zone`, and whether the wall time is
|
||||
* normal, skipped (gap) or repeated (overlap). Assumes at most one offset
|
||||
* change within a day of the wall time, which holds for every real zone.
|
||||
*/
|
||||
export function resolveWallClock(wall: WallClock, zone: string): ResolvedLocalTime {
|
||||
const w = wallMs(wall);
|
||||
const before = offsetAt(w - DAY_MS, zone);
|
||||
const after = offsetAt(w + DAY_MS, zone);
|
||||
const tBefore = w - before;
|
||||
const tAfter = w - after;
|
||||
const okBefore = offsetAt(tBefore, zone) === before;
|
||||
const okAfter = offsetAt(tAfter, zone) === after;
|
||||
if (okBefore && okAfter) {
|
||||
return tBefore === tAfter ? { utc: tBefore, kind: 'ok' } : { utc: Math.min(tBefore, tAfter), kind: 'overlap' };
|
||||
}
|
||||
if (okBefore) return { utc: tBefore, kind: 'ok' };
|
||||
if (okAfter) return { utc: tAfter, kind: 'ok' };
|
||||
return { utc: tBefore, kind: 'gap' };
|
||||
}
|
||||
|
||||
/** A LocalDateTime in `zone` as a UTC instant (RFC 5545 semantics), or null when unreadable. */
|
||||
export function localToUtc(value: string, zone: string): number | null {
|
||||
const wall = parseLocalDateTime(value);
|
||||
const z = canonicalZone(zone);
|
||||
if (!wall || !z) return null;
|
||||
return resolveWallClock(wall, z).utc;
|
||||
}
|
||||
|
||||
/** Whether a LocalDateTime is an ordinary, a skipped or a repeated wall time in `zone`. */
|
||||
export function classifyLocal(value: string, zone: string): LocalTimeKind | null {
|
||||
const wall = parseLocalDateTime(value);
|
||||
const z = canonicalZone(zone);
|
||||
if (!wall || !z) return null;
|
||||
return resolveWallClock(wall, z).kind;
|
||||
}
|
||||
|
||||
/** The instant as a LocalDateTime in `zone`. Both instants of an overlap give the same text. */
|
||||
export function utcToLocal(utcMs: number, zone: string): string {
|
||||
return formatLocalDateTime(wallClockAt(utcMs, canonicalZone(zone) ?? 'UTC'));
|
||||
}
|
||||
|
||||
/** UTC midnight of a LocalDateTime's date: how CalendarProvider stores all-day times. */
|
||||
export function dateToUtcMidnight(value: string): number | null {
|
||||
const wall = parseLocalDateTime(value);
|
||||
return wall ? wallMs({ ...wall, hour: 0, minute: 0, second: 0 }) : null;
|
||||
}
|
||||
|
||||
/** The UTC date of an instant as a LocalDateTime at midnight (`2026-10-08T00:00:00`). */
|
||||
export function utcMidnightToDate(utcMs: number): string {
|
||||
return `${formatDate(wallFromMs(utcMs))}T00:00:00`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fixed-offset zone for an instant: `Etc/GMT-2` for UTC+2 (the Etc names
|
||||
* flip the sign), `Etc/UTC` when the offset is zero or not a whole number of
|
||||
* hours. Stalwart drops a start whose local time is repeated in its zone; the
|
||||
* same instant and wall time in a fixed-offset zone survive (checked on the
|
||||
* live server).
|
||||
*/
|
||||
export function fixedOffsetZone(offsetMs: number): string {
|
||||
const hours = offsetMs / HOUR_MS;
|
||||
if (!Number.isInteger(hours) || hours === 0 || hours < -12 || hours > 14) return 'Etc/UTC';
|
||||
return `Etc/GMT${hours > 0 ? '-' : '+'}${Math.abs(hours)}`;
|
||||
}
|
||||
|
||||
/** Compares two LocalDateTimes by their fields (a fraction or suffix is ignored). */
|
||||
export function sameLocalDateTime(a: unknown, b: unknown): boolean {
|
||||
const wa = parseLocalDateTime(a);
|
||||
const wb = parseLocalDateTime(b);
|
||||
if (!wa || !wb) return wa === wb && a === b;
|
||||
return wallMs(wa) === wallMs(wb);
|
||||
}
|
||||
|
||||
/** Orders LocalDateTimes (ISO text compares chronologically once normalised). */
|
||||
export function compareLocal(a: string, b: string): number {
|
||||
const wa = parseLocalDateTime(a);
|
||||
const wb = parseLocalDateTime(b);
|
||||
if (!wa || !wb) return a < b ? -1 : a > b ? 1 : 0;
|
||||
return wallMs(wa) - wallMs(wb);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Identifiers device sync mints and parses: map keys for new JSContact /
|
||||
* JSCalendar entries, uids for new objects, and the `<jmapAccountId>/<id>`
|
||||
* identities kept in SOURCE_ID / _SYNC_ID (docs/device-sync.md, "Identity").
|
||||
*/
|
||||
|
||||
/** A random source in [0, 1); injectable so tests are deterministic. */
|
||||
export type RandomSource = () => number;
|
||||
|
||||
const BASE36 = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
|
||||
function randomChars(random: RandomSource, n: number): string {
|
||||
let s = '';
|
||||
for (let i = 0; i < n; i++) s += BASE36[Math.floor(random() * 36) % 36];
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys for new map entries: `b` + 8 base36 characters. Stalwart parses a
|
||||
* numeric key as an array index and generates `k<n>` keys itself, so neither
|
||||
* shape is ever minted.
|
||||
*/
|
||||
export function makeKeyMinter(random: RandomSource = Math.random): (taken?: Iterable<string>) => string {
|
||||
let counter = 0;
|
||||
return (taken) => {
|
||||
const used = new Set(taken ?? []);
|
||||
for (let attempt = 0; attempt < 16; attempt++) {
|
||||
const key = `b${randomChars(random, 8)}`;
|
||||
if (!used.has(key)) return key;
|
||||
}
|
||||
// A degenerate random source: count instead of looping forever.
|
||||
for (;;) {
|
||||
const key = `b${(counter++).toString(36).padStart(8, '0')}`;
|
||||
if (!used.has(key)) return key;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** An RFC 4122 v4 UUID from the random source. */
|
||||
export function uuidFrom(random: RandomSource): string {
|
||||
const hex = Array.from({ length: 32 }, () => Math.floor(random() * 16).toString(16));
|
||||
hex[12] = '4';
|
||||
hex[16] = ((parseInt(hex[16], 16) & 0x3) | 0x8).toString(16);
|
||||
const h = hex.join('');
|
||||
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
||||
}
|
||||
|
||||
/** SOURCE_ID / _SYNC_ID of a server object. JMAP ids never contain `/`. */
|
||||
export function objectRef(jmapAccountId: string, id: string): string {
|
||||
return `${jmapAccountId}/${id}`;
|
||||
}
|
||||
|
||||
/** The parts of an object ref, or null for anything else (a pending marker, another app's value). */
|
||||
export function parseObjectRef(ref: unknown): { accountId: string; id: string } | null {
|
||||
if (typeof ref !== 'string') return null;
|
||||
const m = /^([A-Za-z0-9_-]+)\/([A-Za-z0-9_-]+)$/.exec(ref);
|
||||
return m ? { accountId: m[1], id: m[2] } : null;
|
||||
}
|
||||
|
||||
/** `_SYNC_ID` of a local event claimed for upload; `~` never occurs in JMAP ids. */
|
||||
export const PENDING_PREFIX = '~pending/';
|
||||
|
||||
export function pendingRef(uid: string): string {
|
||||
return `${PENDING_PREFIX}${uid}`;
|
||||
}
|
||||
|
||||
export function pendingUidOf(ref: unknown): string | null {
|
||||
return typeof ref === 'string' && ref.startsWith(PENDING_PREFIX) ? ref.slice(PENDING_PREFIX.length) : null;
|
||||
}
|
||||
|
||||
/** `_SYNC_ID` of an exception row: the master's ref plus `#` and the recurrence id. */
|
||||
export function exceptionRef(masterRef: string, recurrenceId: string): string {
|
||||
return `${masterRef}#${recurrenceId}`;
|
||||
}
|
||||
|
||||
/** Collection keys (`<jmapAccountId>/<collectionId>`), as in SYNC1 and the selections. */
|
||||
export const collectionKey = objectRef;
|
||||
export const parseCollectionKey = parseObjectRef;
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* JSON helpers for comparing server objects, shadows and baselines by value.
|
||||
* Pure: device sync's mappers, merge rules and engine build on them.
|
||||
*/
|
||||
import { sha256Hex } from '../../lib/sha256';
|
||||
|
||||
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
|
||||
|
||||
/** JSON with object keys sorted and `undefined` members dropped, so equal values give equal text. */
|
||||
export function canonicalJson(value: unknown): string {
|
||||
return JSON.stringify(canonicalize(value));
|
||||
}
|
||||
|
||||
function canonicalize(value: unknown): Json {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (Array.isArray(value)) return value.map((v) => (v === undefined ? null : canonicalize(v)));
|
||||
if (typeof value === 'object') {
|
||||
const out: { [key: string]: Json } = {};
|
||||
for (const key of Object.keys(value as object).sort()) {
|
||||
const v = (value as Record<string, unknown>)[key];
|
||||
if (v !== undefined) out[key] = canonicalize(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (typeof value === 'number' && !Number.isFinite(value)) return null;
|
||||
return value as Json;
|
||||
}
|
||||
|
||||
/** Structural equality of JSON values; key order and `undefined` members don't matter. */
|
||||
export function deepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
if (a === undefined || b === undefined || a === null || b === null) {
|
||||
return (a ?? null) === (b ?? null);
|
||||
}
|
||||
if (typeof a !== typeof b) return false;
|
||||
if (Array.isArray(a)) {
|
||||
if (!Array.isArray(b) || a.length !== b.length) return false;
|
||||
return a.every((v, i) => deepEqual(v, b[i]));
|
||||
}
|
||||
if (typeof a === 'object') {
|
||||
if (Array.isArray(b)) return false;
|
||||
const ao = a as Record<string, unknown>;
|
||||
const bo = b as Record<string, unknown>;
|
||||
const keys = new Set([...Object.keys(ao), ...Object.keys(bo)]);
|
||||
for (const key of keys) if (!deepEqual(ao[key], bo[key])) return false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function utf8(text: string): Uint8Array {
|
||||
return new TextEncoder().encode(text);
|
||||
}
|
||||
|
||||
/** SHA-256 (hex) of the canonical JSON of a value. */
|
||||
export function jsonHash(value: unknown): string {
|
||||
return sha256Hex(utf8(canonicalJson(value)));
|
||||
}
|
||||
|
||||
/** Parses JSON a sync column holds; null for empty or unreadable text (a column another app wrote). */
|
||||
export function parseJsonColumn<T>(text: unknown): T | null {
|
||||
if (typeof text !== 'string' || !text) return null;
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clone<T>(value: T): T {
|
||||
return value === undefined ? value : (JSON.parse(JSON.stringify(value)) as T);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* JMAP PatchObjects (RFC 8620 §5.3): keys are JSON Pointers (RFC 6901)
|
||||
* relative to the object, values replace what they point at, `null` removes.
|
||||
*
|
||||
* Stalwart specifics the engine relies on (docs/device-sync.md, "Uploads"):
|
||||
* a pointer whose parent does not exist fails the whole update ("Patch
|
||||
* operation failed"), so the first entry of a map is sent as the whole map;
|
||||
* and no pointer may be a prefix of another in one patch.
|
||||
*/
|
||||
import { pointerToken, pointerTokenValue } from '../../api/patch-pointer';
|
||||
import { clone } from './json';
|
||||
|
||||
export type PatchObject = Record<string, unknown>;
|
||||
|
||||
/** Joins raw member names into a pointer, escaping `~` and `/` in each. */
|
||||
export function ptr(...segments: string[]): string {
|
||||
return segments.map(pointerToken).join('/');
|
||||
}
|
||||
|
||||
/** The raw member names of a pointer. */
|
||||
export function ptrSegments(pointer: string): string[] {
|
||||
return pointer.split('/').map(pointerTokenValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws when one pointer of the patch is a prefix of another (RFC 8620
|
||||
* §5.3 makes that an invalidPatch), or when a pointer is empty.
|
||||
*/
|
||||
export function assertPatchWellFormed(patch: PatchObject): void {
|
||||
const keys = Object.keys(patch).sort();
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
if (!keys[i]) throw new Error('empty pointer in patch');
|
||||
if (i > 0 && keys[i].startsWith(`${keys[i - 1]}/`)) {
|
||||
throw new Error(`patch pointer ${keys[i - 1]} is a prefix of ${keys[i]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a PatchObject to a copy of `target` the way Stalwart does. Returns
|
||||
* null when a pointer's parent does not exist (Stalwart rejects the update).
|
||||
* Array elements cannot be addressed; the engine never tries.
|
||||
*/
|
||||
export function applyPatch<T extends object>(target: T, patch: PatchObject): T | null {
|
||||
const out = clone(target) as Record<string, unknown>;
|
||||
for (const [pointer, value] of Object.entries(patch)) {
|
||||
const segments = ptrSegments(pointer);
|
||||
let parent: Record<string, unknown> = out;
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
const next = parent[segments[i]];
|
||||
if (next === null || typeof next !== 'object' || Array.isArray(next)) return null;
|
||||
parent = next as Record<string, unknown>;
|
||||
}
|
||||
const leaf = segments[segments.length - 1];
|
||||
if (value === null) delete parent[leaf];
|
||||
else parent[leaf] = clone(value);
|
||||
}
|
||||
return out as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds `value` at `pointer` to a patch, unless a shorter pointer of the patch
|
||||
* already covers it (then the covering value is updated instead).
|
||||
*/
|
||||
export function setInPatch(patch: PatchObject, pointer: string, value: unknown): void {
|
||||
for (const existing of Object.keys(patch)) {
|
||||
if (pointer === existing) {
|
||||
patch[existing] = value;
|
||||
return;
|
||||
}
|
||||
if (pointer.startsWith(`${existing}/`)) {
|
||||
const covered = patch[existing];
|
||||
if (covered === null || typeof covered !== 'object') {
|
||||
throw new Error(`cannot patch ${pointer} below ${existing}, which is set to a scalar`);
|
||||
}
|
||||
const rest = pointer.slice(existing.length + 1);
|
||||
const applied = applyPatch(covered as object, { [rest]: value });
|
||||
if (!applied) throw new Error(`cannot patch ${pointer}: missing parent below ${existing}`);
|
||||
patch[existing] = applied;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// A new covering pointer replaces the longer pointers it covers.
|
||||
for (const existing of Object.keys(patch)) {
|
||||
if (existing.startsWith(`${pointer}/`)) delete patch[existing];
|
||||
}
|
||||
patch[pointer] = value;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Data-row cells as the contacts planner compares them. The bridge hands
|
||||
* numbers back as numbers or text, "" and NULL mean the same (the design's
|
||||
* rule for names, and what editors do to every field), and IS_PRIMARY 0 is
|
||||
* the same as unset. Baselines (DATA_SYNC3) keep text over 1 KB as its hash.
|
||||
*/
|
||||
import { sha256Hex } from '../../lib/sha256';
|
||||
import { Data } from '../android-columns';
|
||||
import { parseJsonColumn, utf8 } from '../common/json';
|
||||
import type { Cell, Row } from '../types';
|
||||
|
||||
const LONG_TEXT = 1024;
|
||||
const HASH_PREFIX = '#sha256:';
|
||||
|
||||
/** Comparable text of a cell; null for NULL, "" and anything that is not a cell. */
|
||||
export function cellText(value: unknown, column?: string): string | null {
|
||||
let text: string | null = null;
|
||||
if (typeof value === 'string') text = value === '' ? null : value;
|
||||
else if (typeof value === 'number' && Number.isFinite(value)) text = String(value);
|
||||
if (column === Data.IS_PRIMARY && text === '0') return null;
|
||||
return text;
|
||||
}
|
||||
|
||||
export function sameCell(a: unknown, b: unknown, column?: string): boolean {
|
||||
return cellText(a, column) === cellText(b, column);
|
||||
}
|
||||
|
||||
function baselineValue(value: unknown, column: string): string | null {
|
||||
const text = cellText(value, column);
|
||||
if (text !== null && text.length > LONG_TEXT) return `${HASH_PREFIX}${sha256Hex(utf8(text))}`;
|
||||
return text;
|
||||
}
|
||||
|
||||
/** DATA_SYNC3 of a row: its mapped cells, long text hashed. */
|
||||
export function encodeBaseline(cells: Row, columns: readonly string[]): string {
|
||||
const out: Record<string, string | null> = {};
|
||||
for (const c of columns) if (c in cells) out[c] = baselineValue(cells[c], c);
|
||||
return JSON.stringify(out);
|
||||
}
|
||||
|
||||
/** A baseline read back from DATA_SYNC3; null when another app left something else there. */
|
||||
export function decodeBaseline(text: unknown): Row | null {
|
||||
const parsed = parseJsonColumn<unknown>(text);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||
const out: Row = {};
|
||||
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (v !== null && typeof v !== 'string' && typeof v !== 'number') return null;
|
||||
out[k] = v as Cell;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether a cell still holds what the baseline recorded for it. */
|
||||
function matchesBaseline(value: unknown, baseline: Row, column: string): boolean {
|
||||
const recorded = cellText(baseline[column], column);
|
||||
if (recorded !== null && recorded.startsWith(HASH_PREFIX)) return baselineValue(value, column) === recorded;
|
||||
return cellText(value, column) === recorded;
|
||||
}
|
||||
|
||||
/** Mapped columns that differ from the baseline; columns the baseline never recorded count as unchanged. */
|
||||
export function changedFromBaseline(cells: Row, baseline: Row, columns: readonly string[]): string[] {
|
||||
return columns.filter((c) => c in baseline && !matchesBaseline(cells[c], baseline, c));
|
||||
}
|
||||
|
||||
/** The cell as text for building card values; null when blank. */
|
||||
export function text(value: unknown): string | null {
|
||||
const t = cellText(value);
|
||||
return t === null || t.trim() === '' ? null : t;
|
||||
}
|
||||
|
||||
export function num(value: unknown): number | null {
|
||||
const t = cellText(value);
|
||||
if (t === null) return null;
|
||||
const n = Number(t);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* The columns the engine queries for contacts, and the decoders that turn
|
||||
* rows into `LocalContact`/`LocalGroup`. Sync columns are read defensively:
|
||||
* any app holding WRITE_CONTACTS can write them, so what does not parse is
|
||||
* treated as absent.
|
||||
*/
|
||||
import { Data, GroupMembership, Groups, RawContacts } from '../android-columns';
|
||||
import { parseCollectionKey } from '../common/ids';
|
||||
import { parseJsonColumn } from '../common/json';
|
||||
import type { ContactCardWire, LocalContact, LocalDataRow, LocalGroup, PendingCreate, PoisonMarker } from '../planner';
|
||||
import type { Row } from '../types';
|
||||
import { cellText, decodeBaseline, num } from './cells';
|
||||
|
||||
const DATA_CELLS = [
|
||||
Data.IS_PRIMARY, Data.DATA1, Data.DATA2, Data.DATA3, Data.DATA4, Data.DATA5, Data.DATA6, Data.DATA7,
|
||||
Data.DATA8, Data.DATA9, Data.DATA10, Data.DATA11, Data.DATA12, Data.DATA13, Data.DATA14,
|
||||
GroupMembership.GROUP_SOURCE_ID,
|
||||
];
|
||||
|
||||
/**
|
||||
* RAW_CONTACT_IS_READ_ONLY is not among them: the providers take it on
|
||||
* writes but refuse it in every projection ("Invalid column").
|
||||
*/
|
||||
export const RAW_CONTACT_COLUMNS: readonly string[] = [
|
||||
RawContacts._ID, RawContacts.SOURCE_ID, RawContacts.VERSION, RawContacts.DIRTY, RawContacts.DELETED,
|
||||
RawContacts.SYNC1, RawContacts.SYNC2, RawContacts.SYNC3, RawContacts.SYNC4,
|
||||
];
|
||||
|
||||
export const DATA_COLUMNS: readonly string[] = [
|
||||
Data._ID, Data.RAW_CONTACT_ID, Data.MIMETYPE, ...DATA_CELLS, Data.DATA_SYNC1, Data.DATA_SYNC2, Data.DATA_SYNC3,
|
||||
];
|
||||
|
||||
export const GROUP_COLUMNS: readonly string[] = [
|
||||
Groups._ID, Groups.SOURCE_ID, Groups.VERSION, Groups.DIRTY, Groups.DELETED, Groups.TITLE,
|
||||
Groups.SYNC2, Groups.SYNC3, Groups.SYNC4,
|
||||
];
|
||||
|
||||
const flag = (v: unknown) => num(v) === 1;
|
||||
|
||||
function stringOrNull(v: unknown): string | null {
|
||||
return typeof v === 'string' && v !== '' ? v : null;
|
||||
}
|
||||
|
||||
export function decodeShadow(v: unknown): ContactCardWire | null {
|
||||
const card = parseJsonColumn<unknown>(v);
|
||||
return card && typeof card === 'object' && !Array.isArray(card) && typeof (card as { id?: unknown }).id === 'string'
|
||||
? (card as ContactCardWire)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function decodePending(v: unknown): PendingCreate | null {
|
||||
const p = parseJsonColumn<Record<string, unknown>>(v);
|
||||
if (!p || typeof p !== 'object' || typeof p.uid !== 'string' || !p.uid || typeof p.target !== 'string') return null;
|
||||
return parseCollectionKey(p.target) ? { uid: p.uid, target: p.target } : null;
|
||||
}
|
||||
|
||||
export function decodePoison(v: unknown): PoisonMarker | null {
|
||||
const p = parseJsonColumn<Record<string, unknown>>(v);
|
||||
if (!p || typeof p !== 'object' || typeof p.fp !== 'string' || typeof p.type !== 'string') return null;
|
||||
if (typeof p.n !== 'number' || typeof p.until !== 'number') return null;
|
||||
return {
|
||||
fp: p.fp,
|
||||
type: p.type,
|
||||
n: p.n,
|
||||
until: p.until,
|
||||
...(typeof p.description === 'string' ? { description: p.description } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeDataRow(row: Row): LocalDataRow {
|
||||
const cells: Row = {};
|
||||
for (const c of DATA_CELLS) if (c in row) cells[c] = row[c];
|
||||
return {
|
||||
id: Number(row[Data._ID]),
|
||||
mimetype: String(row[Data.MIMETYPE] ?? ''),
|
||||
cells,
|
||||
key: stringOrNull(row[Data.DATA_SYNC1]),
|
||||
photoHash: stringOrNull(row[Data.DATA_SYNC2]),
|
||||
baseline: decodeBaseline(row[Data.DATA_SYNC3]),
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeContact(rawContact: Row, data: Row[]): LocalContact {
|
||||
const id = Number(rawContact[RawContacts._ID]);
|
||||
const own = data.filter((d) => !(Data.RAW_CONTACT_ID in d) || Number(d[Data.RAW_CONTACT_ID]) === id);
|
||||
const collections = (cellText(rawContact[RawContacts.SYNC1]) ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => parseCollectionKey(s));
|
||||
return {
|
||||
rawContactId: id,
|
||||
sourceId: stringOrNull(rawContact[RawContacts.SOURCE_ID]),
|
||||
version: num(rawContact[RawContacts.VERSION]) ?? 0,
|
||||
dirty: flag(rawContact[RawContacts.DIRTY]),
|
||||
deleted: flag(rawContact[RawContacts.DELETED]),
|
||||
collections,
|
||||
shadow: decodeShadow(rawContact[RawContacts.SYNC2]),
|
||||
pending: decodePending(rawContact[RawContacts.SYNC3]),
|
||||
poison: decodePoison(rawContact[RawContacts.SYNC4]),
|
||||
rows: own.map(decodeDataRow),
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeGroup(group: Row): LocalGroup {
|
||||
return {
|
||||
groupId: Number(group[Groups._ID]),
|
||||
sourceId: stringOrNull(group[Groups.SOURCE_ID]),
|
||||
version: num(group[Groups.VERSION]) ?? 0,
|
||||
dirty: flag(group[Groups.DIRTY]),
|
||||
deleted: flag(group[Groups.DELETED]),
|
||||
title: typeof group[Groups.TITLE] === 'string' ? (group[Groups.TITLE] as string) : null,
|
||||
shadow: decodeShadow(group[Groups.SYNC2]),
|
||||
pending: decodePending(group[Groups.SYNC3]),
|
||||
poison: decodePoison(group[Groups.SYNC4]),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* What changed in a unit, on either side (docs/device-sync.md, "Row
|
||||
* matching"). A row matched by its key is compared column by column with its
|
||||
* baseline, so an emptied column is a real deletion. A row matched by value
|
||||
* has no baseline: its values count only where they are non-empty and differ
|
||||
* from the shadow's projection, TYPE and LABEL only for phones, emails,
|
||||
* addresses and events, and IS_PRIMARY never.
|
||||
*/
|
||||
import { CommonKind, Data, MimeType, StructuredPostal as SP } from '../android-columns';
|
||||
import type { Row } from '../types';
|
||||
import { changedFromBaseline, sameCell, text } from './cells';
|
||||
import type { Match } from './matching';
|
||||
import { POSTAL_PART_COLUMNS, providerSplitPostal, resplitPostal } from './postal';
|
||||
import { SPECS, type Unit } from './project';
|
||||
|
||||
const isPart = (column: string) => (POSTAL_PART_COLUMNS as readonly string[]).includes(column);
|
||||
|
||||
/** A postal row holding the provider's split of a formatted address an editor wrote alone. */
|
||||
function isProviderSplit(m: Match): boolean {
|
||||
const { row } = m;
|
||||
if (row.mimetype !== MimeType.STRUCTURED_POSTAL || !providerSplitPostal(row.cells)) return false;
|
||||
// A multi-part formatted address in STREET was the provider's split, not a typed street.
|
||||
return m.how !== 'key' || !row.baseline || /[,\n]/.test(text(row.cells[SP.FORMATTED_ADDRESS]) ?? '');
|
||||
}
|
||||
|
||||
/** The address as the device showed it before the edit: a keyed row's baseline, else the shadow's. */
|
||||
function postalBefore(m: Match): Row {
|
||||
return m.how === 'key' && m.row.baseline ? m.row.baseline : m.unit.cells;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cells an upload takes a row's values from: its own, except for a
|
||||
* provider split whose line changed. Its parts are read back from the new
|
||||
* line along the old one (`resplitPostal`), else they stay what the device
|
||||
* holds, the whole line as the street: `full` and the components never
|
||||
* contradict each other on the server.
|
||||
*/
|
||||
export function uploadCells(m: Match): Row {
|
||||
if (!isProviderSplit(m)) return m.row.cells;
|
||||
const before = postalBefore(m);
|
||||
const line = text(m.row.cells[SP.FORMATTED_ADDRESS]);
|
||||
if (!line || sameCell(line, before[SP.FORMATTED_ADDRESS])) return m.row.cells;
|
||||
return { ...m.row.cells, ...(resplitPostal(before, line) ?? {}) };
|
||||
}
|
||||
|
||||
export function localChanges(m: Match): string[] {
|
||||
const { unit } = m;
|
||||
const changed = rawChanges(m).filter((c) => !unit.truncated?.includes(c));
|
||||
if (!isProviderSplit(m)) return changed;
|
||||
// The provider's split is no typed street: only a new line changes the parts, to what it says.
|
||||
const others = changed.filter((c) => !isPart(c));
|
||||
if (!others.includes(SP.FORMATTED_ADDRESS)) return others;
|
||||
const before = postalBefore(m);
|
||||
const after = uploadCells(m);
|
||||
return [...others, ...POSTAL_PART_COLUMNS.filter((c) => !sameCell(after[c], before[c]))];
|
||||
}
|
||||
|
||||
function rawChanges(m: Match): string[] {
|
||||
const spec = SPECS[m.row.mimetype];
|
||||
const { row, unit } = m;
|
||||
if (m.how === 'key' && row.baseline) return changedFromBaseline(row.cells, row.baseline, spec.columns);
|
||||
if (row.mimetype === MimeType.PHOTO) {
|
||||
// A photo row without our baseline was put there by an editor: its picture counts as new.
|
||||
return m.how === 'key' ? [] : [Data.DATA14];
|
||||
}
|
||||
const out = spec.valueColumns.filter((c) => text(row.cells[c]) !== null && !sameCell(row.cells[c], unit.cells[c], c));
|
||||
if (spec.typed && text(row.cells[CommonKind.TYPE]) !== null && !sameCell(row.cells[CommonKind.TYPE], unit.cells[CommonKind.TYPE])) {
|
||||
out.push(CommonKind.TYPE, CommonKind.LABEL);
|
||||
} else if (spec.typed && text(row.cells[CommonKind.LABEL]) !== null && !sameCell(row.cells[CommonKind.LABEL], unit.cells[CommonKind.LABEL])) {
|
||||
out.push(CommonKind.LABEL);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether two units show the same on the device (sub-fields Android can't hold don't count). */
|
||||
export function sameUnit(a: Unit, b: Unit): boolean {
|
||||
if (a.mimetype !== b.mimetype) return false;
|
||||
// A photo whose bytes could not be had compares as unchanged.
|
||||
if (a.mimetype === MimeType.PHOTO) return a.photoHash === undefined || b.photoHash === undefined || a.photoHash === b.photoHash;
|
||||
return SPECS[a.mimetype].columns.every((c) => sameCell(a.cells[c], b.cells[c], c));
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* Server cards into rows (docs/device-sync.md, "Change detection and merge
|
||||
* rules"): inserts, writes that make a contact's rows the card's (a clean
|
||||
* contact, an accepted upload, a revert), and the per-unit merge of a dirty
|
||||
* contact with a new server version.
|
||||
*
|
||||
* Rows are matched to the shadow's units after its keys were mapped to the
|
||||
* new version's (Stalwart re-keys cards written over CardDAV). A clean write
|
||||
* then touches only rows that differ, so an echo of our own upload writes
|
||||
* nothing. A merge writes only units the server changed, and never gives a
|
||||
* key to a row of a kind an editor rewrote (all its rows keyless): a keyed
|
||||
* row there would later make the entries the editor could not show look
|
||||
* deleted.
|
||||
*/
|
||||
import { Data, MimeType, RawContacts } from '../android-columns';
|
||||
import { canonicalJson } from '../common/json';
|
||||
import { objectRef, parseCollectionKey, parseObjectRef } from '../common/ids';
|
||||
import type { ContactCardWire, ContactsContext, DownloadPlan, LocalContact, LocalDataRow } from '../planner';
|
||||
import type { ProviderOp, WriteRow } from '../types';
|
||||
import { changedFromBaseline, encodeBaseline, sameCell } from './cells';
|
||||
import { localChanges, sameUnit } from './diff';
|
||||
import { deletionsInferred, inPlaceEvidence, matchKind, reconcileKeys, type KindMatch } from './matching';
|
||||
import { editableGroups, planMemberships, remoteMemberships, shadowMemberOf, withMemberOf, withoutMemberOf } from './members';
|
||||
import { assertContact, deleteData, insertUnit, updateData, updateRawContact, updateUnit } from './ops';
|
||||
import { isPhotoAbsent, shadowOf, withPhotoAbsent } from './photo';
|
||||
import { KIND_ORDER, projectCard, rowMatchesUnit, SPECS, type Unit } from './project';
|
||||
|
||||
const ENTRY_KINDS = KIND_ORDER.filter((k) => k !== MimeType.GROUP_MEMBERSHIP);
|
||||
|
||||
type Ctx = ContactsContext;
|
||||
|
||||
export function cardUnits(card: ContactCardWire | null, ctx: Pick<Ctx, 'nameForUid' | 'photoBytes' | 'groupRowIdBySourceId'>): Unit[] {
|
||||
return card ? projectCard(withoutMemberOf(card), ctx, { memberOf: null }) : [];
|
||||
}
|
||||
|
||||
/** Raw contact columns that describe the server card: collections, shadow, read-only flag. */
|
||||
function cardColumns(card: ContactCardWire, shadow: ContactCardWire, ctx: Ctx): WriteRow {
|
||||
return {
|
||||
[RawContacts.SYNC1]: ctx.selectedCollections(card).join(',') || null,
|
||||
[RawContacts.SYNC2]: JSON.stringify(shadow),
|
||||
[RawContacts.RAW_CONTACT_IS_READ_ONLY]: ctx.isReadOnly(card) ? 1 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Only the raw contact columns that differ from what the local item holds. */
|
||||
function changedRawColumns(local: LocalContact, values: WriteRow): WriteRow {
|
||||
const current: Record<string, unknown> = {
|
||||
[RawContacts.SOURCE_ID]: local.sourceId,
|
||||
[RawContacts.SYNC1]: local.collections.join(',') || null,
|
||||
[RawContacts.SYNC2]: local.shadow ? JSON.stringify(local.shadow) : null,
|
||||
[RawContacts.SYNC3]: local.pending ? JSON.stringify(local.pending) : null,
|
||||
[RawContacts.SYNC4]: local.poison ? JSON.stringify(local.poison) : null,
|
||||
[RawContacts.DIRTY]: local.dirty ? 1 : 0,
|
||||
};
|
||||
const out: WriteRow = {};
|
||||
for (const [column, value] of Object.entries(values)) {
|
||||
if (column === RawContacts.RAW_CONTACT_IS_READ_ONLY) continue;
|
||||
if (column === RawContacts.SYNC2) {
|
||||
const before = local.shadow ? canonicalJson(local.shadow) : null;
|
||||
const after = typeof value === 'string' ? canonicalJson(JSON.parse(value)) : null;
|
||||
if (before !== after) out[column] = value;
|
||||
} else if (!sameCell(current[column], value as unknown)) {
|
||||
out[column] = value;
|
||||
}
|
||||
}
|
||||
// The providers never return the read-only flag, so it can't be compared:
|
||||
// it goes along whenever the raw contact is written anyway.
|
||||
if (Object.keys(out).length && RawContacts.RAW_CONTACT_IS_READ_ONLY in values) {
|
||||
out[RawContacts.RAW_CONTACT_IS_READ_ONLY] = values[RawContacts.RAW_CONTACT_IS_READ_ONLY];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Row keys after the server re-keyed: mapped, or marked gone so they can't meet a new entry's key. */
|
||||
function renameRows(rows: LocalDataRow[], base: Unit[], keyMap: Map<string, string>): LocalDataRow[] {
|
||||
const baseKeys = new Set(base.map((u) => u.key));
|
||||
return rows.map((r) => {
|
||||
if (!r.key) return r;
|
||||
const key = keyMap.get(r.key) ?? (baseKeys.has(r.key) ? `~gone:${r.key}` : r.key);
|
||||
return key === r.key ? r : { ...r, key };
|
||||
});
|
||||
}
|
||||
|
||||
function renameUnits(base: Unit[], keyMap: Map<string, string>): Unit[] {
|
||||
return base.map((u) => ({ ...u, key: keyMap.get(u.key) ?? `~gone:${u.key}` }));
|
||||
}
|
||||
|
||||
interface RowPlan {
|
||||
ops: ProviderOp[];
|
||||
conflicts: number;
|
||||
stillDirty: boolean;
|
||||
evidence: boolean;
|
||||
/** The card's photo is left without a row because it could not be written (the shadow's `~noPhoto`). */
|
||||
photoAbsent: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the rows of every entry kind the card's: rows matched to a unit are
|
||||
* rewritten when they differ (or only get their key and baseline), rows no
|
||||
* unit accounts for go, units without a row are inserted.
|
||||
*/
|
||||
function cleanRows(local: LocalContact, remote: Unit[], keyMap: Map<string, string>, base: Unit[], accepted: boolean): RowPlan {
|
||||
const ops: ProviderOp[] = [];
|
||||
let photoAbsent = false;
|
||||
const rows = renameRows(local.rows, base, keyMap);
|
||||
const kinds = ENTRY_KINDS.map((k) => matchKind(k, rows, remote));
|
||||
for (const kind of kinds) {
|
||||
for (const m of kind.matched) {
|
||||
const original = local.rows.find((r) => r.id === m.row.id)!;
|
||||
const { unit } = m;
|
||||
const columns = SPECS[unit.mimetype].columns;
|
||||
if (unit.mimetype === MimeType.PHOTO) {
|
||||
if (accepted && unit.photoHash && original.photoHash !== unit.photoHash && localChanges({ ...m, row: original }).length) {
|
||||
// The device's own photo just uploaded: record the server's hash, keep the picture.
|
||||
// (A photo that could not be sent keeps its old baseline, so it still counts as changed.)
|
||||
ops.push(updateData(m.row.id, {
|
||||
[Data.DATA_SYNC1]: unit.key,
|
||||
[Data.DATA_SYNC2]: unit.photoHash ?? null,
|
||||
[Data.DATA_SYNC3]: encodeBaseline(m.row.cells, columns),
|
||||
}));
|
||||
} else if (original.photoHash !== unit.photoHash && unit.extra) {
|
||||
ops.push(updateUnit(m.row.id, unit, true));
|
||||
} else if (original.key !== unit.key) {
|
||||
ops.push(updateData(m.row.id, { [Data.DATA_SYNC1]: unit.key }));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!rowMatchesUnit(unit, m.row.cells, sameCell)) ops.push(updateUnit(m.row.id, unit, true));
|
||||
else if (original.key !== unit.key || !original.baseline || changedFromBaseline(m.row.cells, original.baseline, columns).length) {
|
||||
// Same content: only the key and the baseline (what the provider holds now) are written.
|
||||
ops.push(updateData(m.row.id, { [Data.DATA_SYNC1]: unit.key, [Data.DATA_SYNC3]: encodeBaseline(m.row.cells, columns) }));
|
||||
}
|
||||
}
|
||||
for (const row of kind.fresh) ops.push(deleteData(row.id));
|
||||
for (const unit of kind.missing) {
|
||||
if (unit.mimetype === MimeType.PHOTO && !unit.extra) {
|
||||
photoAbsent = true;
|
||||
continue;
|
||||
}
|
||||
ops.push(insertUnit(local.rawContactId, unit, true));
|
||||
}
|
||||
}
|
||||
return { ops, conflicts: 0, stillDirty: false, evidence: inPlaceEvidence(kinds), photoAbsent };
|
||||
}
|
||||
|
||||
/** Per-unit merge of a dirty contact's rows with a new server version; the server wins real conflicts. */
|
||||
function mergeRows(local: LocalContact, base: Unit[], remote: Unit[], keyMap: Map<string, string>): RowPlan {
|
||||
const ops: ProviderOp[] = [];
|
||||
let conflicts = 0;
|
||||
let stillDirty = false;
|
||||
let photoAbsent = false;
|
||||
const rows = renameRows(local.rows, base, keyMap);
|
||||
const renamedBase = renameUnits(base, keyMap);
|
||||
const kinds: KindMatch[] = ENTRY_KINDS.map((k) => matchKind(k, rows, renamedBase));
|
||||
const evidence = inPlaceEvidence(kinds);
|
||||
const mapped = new Set(keyMap.values());
|
||||
|
||||
for (const kind of kinds) {
|
||||
const start = ops.length;
|
||||
const remoteOf = new Map(remote.filter((u) => u.mimetype === kind.mimetype).map((u) => [u.key, u]));
|
||||
const hasKeyed = kind.matched.some((m) => m.how === 'key');
|
||||
const hasKeyless = kind.fresh.length > 0 || kind.matched.some((m) => m.how !== 'key');
|
||||
const keyed = hasKeyed || !hasKeyless;
|
||||
// A photo the device never held (`~noPhoto`) was not deleted there.
|
||||
const deletions = kind.mimetype !== MimeType.STRUCTURED_NAME && deletionsInferred(kind, evidence)
|
||||
&& !(kind.mimetype === MimeType.PHOTO && isPhotoAbsent(local.shadow));
|
||||
const write = (row: LocalDataRow, unit: Unit) => {
|
||||
if (unit.mimetype === MimeType.PHOTO && !unit.extra) return;
|
||||
ops.push(updateUnit(row.id, unit, keyed));
|
||||
};
|
||||
|
||||
for (const m of kind.matched) {
|
||||
const original = local.rows.find((r) => r.id === m.row.id)!;
|
||||
const remoteUnit = remoteOf.get(m.unit.key);
|
||||
const changedHere = localChanges({ ...m, row: original }).length > 0;
|
||||
if (!remoteUnit) {
|
||||
// Removed on the server: the delete wins, over a local edit too.
|
||||
if (changedHere) conflicts++;
|
||||
ops.push(deleteData(m.row.id));
|
||||
} else if (sameUnit(m.unit, remoteUnit)) {
|
||||
if (changedHere) stillDirty = true;
|
||||
if (m.how === 'key' && original.key !== remoteUnit.key) ops.push(updateData(m.row.id, { [Data.DATA_SYNC1]: remoteUnit.key }));
|
||||
} else if (!changedHere) {
|
||||
write(m.row, remoteUnit);
|
||||
} else if (remoteUnit.mimetype !== MimeType.PHOTO && rowMatchesUnit(remoteUnit, m.row.cells, sameCell)) {
|
||||
// Both sides made the same change: converged, new baseline.
|
||||
if (keyed) {
|
||||
ops.push(updateData(m.row.id, { [Data.DATA_SYNC1]: remoteUnit.key, [Data.DATA_SYNC3]: encodeBaseline(m.row.cells, SPECS[kind.mimetype].columns) }));
|
||||
}
|
||||
} else {
|
||||
conflicts++;
|
||||
write(m.row, remoteUnit);
|
||||
}
|
||||
}
|
||||
|
||||
for (const unit of kind.missing) {
|
||||
const remoteUnit = remoteOf.get(unit.key);
|
||||
if (!deletions) continue;
|
||||
if (!remoteUnit) continue;
|
||||
if (sameUnit(unit, remoteUnit)) stillDirty = true;
|
||||
else if (!(remoteUnit.mimetype === MimeType.PHOTO && !remoteUnit.extra)) {
|
||||
conflicts++;
|
||||
ops.push(insertUnit(local.rawContactId, remoteUnit, keyed));
|
||||
}
|
||||
}
|
||||
|
||||
// Entries the server added: a new local row with the same value converged with it.
|
||||
const fresh = [...kind.fresh];
|
||||
const spec = SPECS[kind.mimetype];
|
||||
for (const unit of remoteOf.values()) {
|
||||
if (mapped.has(unit.key)) continue;
|
||||
const p = spec.primary(unit.cells);
|
||||
const twin = p === null ? -1 : fresh.findIndex((r) => spec.primary(r.cells) === p);
|
||||
if (twin >= 0) {
|
||||
const [row] = fresh.splice(twin, 1);
|
||||
if (keyed) ops.push(updateUnit(row.id, unit, true));
|
||||
} else if (!(unit.mimetype === MimeType.PHOTO && !unit.extra)) {
|
||||
ops.push(insertUnit(local.rawContactId, unit, keyed));
|
||||
}
|
||||
}
|
||||
if (fresh.length) stillDirty = true;
|
||||
|
||||
if (kind.mimetype === MimeType.PHOTO) {
|
||||
// The server's photo is left without a row it could not get, unless a local deletion of it waits for its upload.
|
||||
const photo = [...remoteOf.values()][0];
|
||||
const deletionWaits = !!photo && deletions && kind.missing.some((u) => sameUnit(u, photo));
|
||||
photoAbsent = !!photo && !kind.rows.length && !ops.slice(start).some((o) => o.op === 'insert') && !deletionWaits;
|
||||
}
|
||||
}
|
||||
return { ops, conflicts, stillDirty, evidence, photoAbsent };
|
||||
}
|
||||
|
||||
export interface CleanWriteOptions {
|
||||
/** DIRTY the assert expects; null asserts VERSION only (an accepted upload, a revert). */
|
||||
assertDirty: boolean | null;
|
||||
/** Clear DIRTY (unless membership changes still wait for the server). */
|
||||
clearDirty: boolean;
|
||||
/** Extra raw contact columns: identity, SYNC3/SYNC4 resets, DELETED. */
|
||||
raw?: WriteRow;
|
||||
/** The rows' changes were just uploaded (planAccepted): a changed photo is the server's now. */
|
||||
accepted?: boolean;
|
||||
/** The memberships the rows held at the last write, when there is no shadow to say: a new contact's are none. */
|
||||
memberBase?: string[];
|
||||
}
|
||||
|
||||
/** The JMAP account a local contact belongs to. */
|
||||
export function accountOf(local: LocalContact, ctx: Ctx): string {
|
||||
return parseObjectRef(local.sourceId)?.accountId ?? parseCollectionKey(local.pending?.target)?.accountId ?? ctx.jmapAccountId;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ops that make a local contact's rows, shadow and raw columns the
|
||||
* server card's, behind the VERSION assert. Membership changes that the
|
||||
* server does not have yet stay (and keep the contact dirty), unless it can
|
||||
* never take them: those are put back (`revertedMembers`).
|
||||
*/
|
||||
export function cleanWrite(local: LocalContact, card: ContactCardWire, ctx: Ctx, options: CleanWriteOptions) {
|
||||
const server = withoutMemberOf(card);
|
||||
const base = cardUnits(local.shadow, ctx);
|
||||
const remote = cardUnits(server, ctx);
|
||||
const keyMap = reconcileKeys(base, remote);
|
||||
const rows = cleanRows(local, remote, keyMap, base, options.accepted ?? false);
|
||||
const members = planMemberships({
|
||||
rows: local.rows,
|
||||
base: shadowMemberOf(local.shadow) ?? options.memberBase ?? null,
|
||||
remote: remoteMemberships(server, ctx),
|
||||
evidence: rows.evidence,
|
||||
restore: true,
|
||||
parent: local.rawContactId,
|
||||
groupRowIdBySourceId: (g) => ctx.groupRowIdBySourceId(g),
|
||||
editable: editableGroups(ctx, accountOf(local, ctx)),
|
||||
});
|
||||
const shadow = withPhotoAbsent(withMemberOf(shadowOf(server), members.memberOf), rows.photoAbsent);
|
||||
const raw = changedRawColumns(local, {
|
||||
...cardColumns(server, shadow, ctx),
|
||||
...(options.raw ?? {}),
|
||||
...(options.clearDirty && !members.pending ? { [RawContacts.DIRTY]: 0 } : {}),
|
||||
});
|
||||
const dataOps = [...rows.ops, ...members.ops];
|
||||
const ops: ProviderOp[] = [];
|
||||
if (Object.keys(raw).length) ops.push(updateRawContact(local.rawContactId, raw));
|
||||
ops.push(...dataOps);
|
||||
return {
|
||||
ops: ops.length ? [assertContact(local, options.assertDirty), ...ops] : [],
|
||||
writes: ops.length,
|
||||
pendingMembers: members.pending,
|
||||
revertedMembers: members.reverted,
|
||||
};
|
||||
}
|
||||
|
||||
function insertPlan(card: ContactCardWire, ctx: Ctx): DownloadPlan {
|
||||
const identity = objectRef(ctx.jmapAccountId, card.id);
|
||||
const units = cardUnits(card, ctx);
|
||||
const remote = remoteMemberships(card, ctx);
|
||||
const members = planMemberships({
|
||||
rows: [],
|
||||
base: remote === null ? null : [],
|
||||
remote,
|
||||
evidence: false,
|
||||
restore: true,
|
||||
parent: { ref: 0 },
|
||||
groupRowIdBySourceId: (g) => ctx.groupRowIdBySourceId(g),
|
||||
});
|
||||
// A photo without bytes to write (too large, or not fetched) stays on the server only.
|
||||
const shadow = withPhotoAbsent(withMemberOf(shadowOf(card), members.memberOf), units.some((u) => u.mimetype === MimeType.PHOTO && !u.extra));
|
||||
const ops: ProviderOp[] = [
|
||||
{ op: 'insert', table: 'raw_contacts', values: { [RawContacts.SOURCE_ID]: identity, [RawContacts.DIRTY]: 0, ...cardColumns(card, shadow, ctx) } },
|
||||
...units.filter((u) => u.mimetype !== MimeType.PHOTO || u.extra).map((u) => insertUnit({ ref: 0 }, u, true)),
|
||||
...members.ops,
|
||||
];
|
||||
return { ops: { ref: identity, ops }, conflicts: 0, stillDirty: false, effect: 'insert', writes: ops.length };
|
||||
}
|
||||
|
||||
export function planContactDownload(card: ContactCardWire, local: LocalContact | null, ctx: Ctx): DownloadPlan {
|
||||
const server = withoutMemberOf(card);
|
||||
const identity = objectRef(ctx.jmapAccountId, server.id);
|
||||
if (!local) return insertPlan(server, ctx);
|
||||
const none = (stillDirty: boolean): DownloadPlan => ({ ops: { ref: identity, ops: [] }, conflicts: 0, stillDirty, effect: 'none', writes: 0 });
|
||||
if (local.deleted) {
|
||||
// Deleted on the device: the delete wins over a server edit, so the rows stay as they are.
|
||||
// Only the shadow (and an identity never written) follows the card: the deletion uploads
|
||||
// against the card as it is now, which may also sit in books this device does not sync.
|
||||
const shadow = withMemberOf(shadowOf(server), shadowMemberOf(local.shadow));
|
||||
const raw = changedRawColumns(local, {
|
||||
...(local.sourceId ? {} : { [RawContacts.SOURCE_ID]: identity, [RawContacts.SYNC3]: null }),
|
||||
[RawContacts.SYNC2]: JSON.stringify(shadow),
|
||||
});
|
||||
if (!Object.keys(raw).length) return none(true);
|
||||
const ops = [assertContact(local, local.dirty), updateRawContact(local.rawContactId, raw)];
|
||||
return { ops: { ref: identity, ops }, conflicts: 0, stillDirty: true, effect: 'update', writes: 1 };
|
||||
}
|
||||
|
||||
if (!local.sourceId) {
|
||||
// Our own create whose identity was never written: adopt it. The rows stay
|
||||
// as they are and upload against the card as their new shadow.
|
||||
const members = shadowMemberOf(local.shadow) ?? [];
|
||||
const shadow = withMemberOf(shadowOf(server), remoteMemberships(server, ctx) === null ? null : members);
|
||||
const ops = [
|
||||
assertContact(local, local.dirty),
|
||||
updateRawContact(local.rawContactId, { [RawContacts.SOURCE_ID]: identity, [RawContacts.SYNC3]: null, ...cardColumns(server, shadow, ctx) }),
|
||||
];
|
||||
return { ops: { ref: identity, ops }, conflicts: 0, stillDirty: true, effect: 'update', writes: 1 };
|
||||
}
|
||||
|
||||
const uidChanged = typeof local.shadow?.uid === 'string' && typeof server.uid === 'string' && local.shadow!.uid !== server.uid;
|
||||
if (!local.dirty || uidChanged) {
|
||||
// A different object behind the same id is replaced, never merged into.
|
||||
const target = uidChanged ? { ...local, shadow: null, rows: local.rows.map((r) => ({ ...r, key: null })) } : local;
|
||||
const write = cleanWrite(target, server, ctx, { assertDirty: local.dirty, clearDirty: uidChanged });
|
||||
return {
|
||||
ops: { ref: identity, ops: write.ops },
|
||||
conflicts: 0,
|
||||
stillDirty: write.pendingMembers,
|
||||
effect: write.writes ? 'update' : 'none',
|
||||
writes: write.writes,
|
||||
};
|
||||
}
|
||||
|
||||
const base = cardUnits(local.shadow, ctx);
|
||||
const remote = cardUnits(server, ctx);
|
||||
const keyMap = reconcileKeys(base, remote);
|
||||
const rows = mergeRows(local, base, remote, keyMap);
|
||||
const members = planMemberships({
|
||||
rows: local.rows,
|
||||
base: shadowMemberOf(local.shadow),
|
||||
remote: remoteMemberships(server, ctx),
|
||||
evidence: rows.evidence,
|
||||
restore: false,
|
||||
parent: local.rawContactId,
|
||||
groupRowIdBySourceId: (g) => ctx.groupRowIdBySourceId(g),
|
||||
});
|
||||
const shadow = withPhotoAbsent(withMemberOf(shadowOf(server), members.memberOf), rows.photoAbsent);
|
||||
const raw = changedRawColumns(local, cardColumns(server, shadow, ctx));
|
||||
const ops: ProviderOp[] = [];
|
||||
if (Object.keys(raw).length) ops.push(updateRawContact(local.rawContactId, raw));
|
||||
ops.push(...rows.ops, ...members.ops);
|
||||
return {
|
||||
ops: { ref: identity, ops: ops.length ? [assertContact(local, true), ...ops] : [] },
|
||||
conflicts: rows.conflicts,
|
||||
stillDirty: rows.stillDirty || members.pending,
|
||||
effect: ops.length ? 'update' : 'none',
|
||||
writes: ops.length,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user