@acms-subir/core 0.3.54 → 0.3.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/cli/main.mjs +2 -1
  3. package/cli/migrations.generated.mjs +59 -6
  4. package/docs/calendar.md +29 -0
  5. package/docs/google-calendar.md +302 -0
  6. package/docs/server.md +7 -0
  7. package/integration.mjs +4 -0
  8. package/package.json +1 -1
  9. package/src/components/LegalSettings.tsx +1 -1
  10. package/src/lib/calendar/google/client.ts +167 -0
  11. package/src/lib/calendar/google/connect.ts +161 -0
  12. package/src/lib/calendar/google/context.ts +50 -0
  13. package/src/lib/calendar/google/inbound.ts +493 -0
  14. package/src/lib/calendar/google/links.ts +139 -0
  15. package/src/lib/calendar/google/map.ts +543 -0
  16. package/src/lib/calendar/google/match.ts +103 -0
  17. package/src/lib/calendar/google/migration.ts +71 -0
  18. package/src/lib/calendar/google/outbound.ts +272 -0
  19. package/src/lib/calendar/google/service.ts +193 -0
  20. package/src/lib/calendar/google/store.ts +241 -0
  21. package/src/lib/calendar/google/types.ts +188 -0
  22. package/src/lib/calendar/schedule.ts +8 -8
  23. package/src/lib/display/board.ts +3 -1
  24. package/src/lib/email/service.ts +11 -1
  25. package/src/lib/email/templates.ts +8 -0
  26. package/src/lib/legal/admin.ts +1 -1
  27. package/src/lib/legal/facts.ts +3 -0
  28. package/src/lib/legal/text-en.ts +4 -0
  29. package/src/lib/legal/text-es.ts +4 -0
  30. package/src/lib/live/crypto.ts +7 -28
  31. package/src/lib/repo/migrations.ts +4 -0
  32. package/src/lib/repo/sql.ts +18 -9
  33. package/src/lib/repo/types.ts +6 -2
  34. package/src/lib/scheduler.ts +3 -0
  35. package/src/lib/sealed.ts +46 -0
  36. package/src/pages/admin/calendar/events/[id].astro +42 -1
  37. package/src/pages/admin/calendar/google/match.astro +160 -0
  38. package/src/pages/admin/calendar/google.astro +107 -0
  39. package/src/pages/admin/calendar/settings.astro +273 -4
  40. package/src/pages/admin/integrations.astro +6 -0
  41. package/src/pages/api/calendar/google/oauth/callback.ts +16 -0
  42. package/src/server/index.ts +49 -0
@@ -0,0 +1,543 @@
1
+ // Site event ⇄ Google event, as pure functions (no repo, no fetch): the
2
+ // fields the sync owns on each side, their hashes, and the bodies written to
3
+ // either side. Everything else (Google's guests, reminders, colours,
4
+ // attachments and conference data; the site's hosts, RSVPs, pictures,
5
+ // buttons, custom fields and other tags) is never read or written.
6
+ //
7
+ // Each pair remembers both sides' hashes as last agreed (GcalItem.site /
8
+ // .google). A field changed on a side when its hash moved; that is what gets
9
+ // copied over, field by field. The two sides never have to be equal: at the
10
+ // first match a Spanish Google title and an English site title are recorded
11
+ // as they are (Q18), and later only an edit on one side copies over.
12
+ //
13
+ // Field names (hash keys): title, when, repeat, place, desc, link (site:
14
+ // the map/meeting URL and page link in Google's description), status (site:
15
+ // cancelled), rule:<prefix> (a title prefix ⇄ a tag), date:<key> (one date
16
+ // of a series: cancelled, or at another time or place).
17
+ import { createHash } from 'node:crypto';
18
+ import type { CalEvent, Settings } from '../../schema';
19
+ import { EventLocation, hasTag } from '../../schema';
20
+ import { addWallDays, eventInstants, eventTimeZone, expandEvents, findOccurrence, formatWall, isValidTimeZone, locationLine, normalizeEventTimes, parseWall } from '../events';
21
+ import { tzParts, zonedToUtc } from '../ics';
22
+ import { cleanRrule, describeRepeat, rruleMap } from '../rrule';
23
+ import type { GcalLink, GoogleEvent, GoogleTime, Hashes, PrefixRule } from './types';
24
+
25
+ const DAY = 86_400_000;
26
+ const pad = (n: number) => String(n).padStart(2, '0');
27
+
28
+ /** The internal tag on booked appointments from Google booking pages (Q16): never shown publicly. */
29
+ export const BOOKING_TAG = '#google-booking';
30
+
31
+ /** The few words the sync writes, in the site's main language (Q9). */
32
+ export interface SyncWords { cancelled: string; details: string; link: string; booked: string; untitled: string }
33
+ const WORDS: Record<string, SyncWords> = {
34
+ en: { cancelled: 'Cancelled: ', details: 'Details: ', link: 'Link: ', booked: 'Booked', untitled: 'Untitled event' },
35
+ es: { cancelled: 'Cancelado: ', details: 'Detalles: ', link: 'Enlace: ', booked: 'Reservado', untitled: 'Evento sin título' },
36
+ };
37
+ export const syncWords = (lang: string): SyncWords => WORDS[(lang || 'en').toLowerCase().split('-')[0]] ?? WORDS.en;
38
+ // every language's "Cancelled: ", so a Google title reads the same whichever one wrote it
39
+ const CANCELLED = [...new Set(Object.values(WORDS).map((w) => w.cancelled.trim().toLowerCase()))];
40
+
41
+ // ---- the fields ----
42
+
43
+ /** wall-clock in the event's zone, like CalEvent; all-day: dates, end inclusive, tz '' */
44
+ export interface When { allDay: boolean; start: string; end: string; tz: string }
45
+ export interface Repeat { rrule: string; exdates: string[] }
46
+ /** one date of a series that isn't as the series has it */
47
+ export type DateState = 'cancelled' | { start: string; end: string; place: string };
48
+ export interface Fields {
49
+ title: string;
50
+ when: When;
51
+ repeat: Repeat;
52
+ place: string;
53
+ desc: string;
54
+ /** rule prefix → whether it applies (site: has the tag; Google: the title starts with it) */
55
+ rules: Record<string, boolean>;
56
+ dates: Record<string, DateState>;
57
+ }
58
+ export interface SiteFields extends Fields { status: boolean; link: { url: string; page: string } }
59
+
60
+ export interface MapContext {
61
+ settings: Pick<Settings, 'timezone'>;
62
+ /** the site origin (the page link in Google's description) */
63
+ origin: string;
64
+ /** the event page path for a slug (lib/calendar/config.ts eventUrl) */
65
+ pagePath: (slug: string) => string;
66
+ words: SyncWords;
67
+ }
68
+
69
+ const titleKey = (s: string) => s.replace(/\s+/g, ' ').trim();
70
+ export const hasPrefix = (title: string, prefix: string) => title.trimStart().toLowerCase().startsWith(prefix.trim().toLowerCase());
71
+
72
+ /** A Google title without the "Cancelled: " the site puts in front of cancelled events (Q7). */
73
+ export function stripCancelled(title: string): string {
74
+ const t = title.trimStart();
75
+ const hit = CANCELLED.find((c) => t.toLowerCase().startsWith(c));
76
+ return titleKey(hit ? t.slice(hit.length) : t);
77
+ }
78
+
79
+ /** For matching and clash checks only: no "Cancelled: ", no rule prefixes, any case and spacing. Never written anywhere. */
80
+ export function comparableTitle(title: string, rules: PrefixRule[]): string {
81
+ let t = stripCancelled(title);
82
+ for (const r of rules) if (hasPrefix(t, r.prefix)) t = t.trimStart().slice(r.prefix.trim().length);
83
+ return titleKey(t).toLowerCase();
84
+ }
85
+
86
+ /**
87
+ * Google's own title, adjusted for what changed on the site without its title
88
+ * changing: "Cancelled: " added or removed, and the prefixes of these rules
89
+ * added or removed to match the site's tags. A Spanish Google title stays
90
+ * Spanish (Q18): only a title edit copies the site's title over.
91
+ */
92
+ export function adjustTitle(googleSummary: string, e: Pick<CalEvent, 'tags' | 'cancelled'>, rules: PrefixRule[], words: SyncWords): string {
93
+ let t = stripCancelled(googleSummary);
94
+ for (const r of rules) {
95
+ const has = hasPrefix(t, r.prefix);
96
+ if (hasTag(e.tags, r.tag) && !has) t = `${r.prefix.trim()} ${t}`;
97
+ if (!hasTag(e.tags, r.tag) && has) t = titleKey(t.trimStart().slice(r.prefix.trim().length));
98
+ }
99
+ t = t || words.untitled;
100
+ return e.cancelled ? `${words.cancelled}${t}` : t;
101
+ }
102
+
103
+ /** The site title with each rule's prefix whose tag the event has (kept when it's there already), and "Cancelled: " for a cancelled event. */
104
+ export function googleTitle(e: Pick<CalEvent, 'title' | 'tags' | 'cancelled'>, rules: PrefixRule[], words: SyncWords): string {
105
+ let t = titleKey(e.title) || words.untitled;
106
+ for (const r of [...rules].reverse()) if (hasTag(e.tags, r.tag) && !hasPrefix(t, r.prefix)) t = `${r.prefix.trim()} ${t}`;
107
+ return e.cancelled ? `${words.cancelled}${t}` : t;
108
+ }
109
+
110
+ // ---- text ----
111
+
112
+ const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', '#39': "'" };
113
+ const decode = (s: string) => s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+\d*|#39);/gi, (m, e: string) => {
114
+ if (e[0] === '#') { const n = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : Number(e.slice(1)); return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : ' '; }
115
+ return ENTITIES[e.toLowerCase()] ?? m;
116
+ });
117
+
118
+ /** Plain text with paragraphs from a site body or a Google description (which may be HTML after an edit in Google). */
119
+ export function plainText(html: string): string {
120
+ if (!html) return '';
121
+ let s = html.replace(/\r\n?/g, '\n');
122
+ if (/<[a-z/!][^>]*>/i.test(s)) {
123
+ s = s.replace(/\n/g, ' ')
124
+ .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, '')
125
+ .replace(/<br\s*\/?>/gi, '\n')
126
+ .replace(/<li\b[^>]*>/gi, '\n• ')
127
+ .replace(/<\/(p|div|h[1-6]|blockquote|pre|ul|ol|table|tr)>/gi, '\n\n')
128
+ .replace(/<[^>]*>/g, '');
129
+ }
130
+ return decode(s).split('\n').map((l) => l.replace(/[ \t ]+/g, ' ').trim()).join('\n').replace(/\n{3,}/g, '\n\n').trim();
131
+ }
132
+
133
+ const escHtml = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
134
+ /** Plain text as simple paragraphs (how .ics imports bring descriptions in). */
135
+ export const paragraphsHtml = (text: string) =>
136
+ text.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean).map((p) => `<p>${escHtml(p).replace(/\n/g, '<br>')}</p>`).join('');
137
+
138
+ /** What Google gets once the site's description wins (Q8): its text, the map or meeting link, and the event page. */
139
+ export function googleDescription(text: string, link: { url: string; page: string }, words: SyncWords): string {
140
+ return [text.trim(), link.url ? `${words.link}${link.url}` : '', link.page ? `${words.details}${link.page}` : ''].filter(Boolean).join('\n\n');
141
+ }
142
+
143
+ // ---- times ----
144
+
145
+ const isZone = (tz: string | undefined): tz is string => !!tz && isValidTimeZone(tz);
146
+ const wallOfMs = (ms: number, tz: string) => { const p = tzParts(ms, tz); return `${p.y}-${pad(p.m)}-${pad(p.d)}T${pad(p.hh)}:${pad(p.mm)}`; };
147
+ const wallToMs = (wall: string, tz: string) => { const w = parseWall(wall)!; return zonedToUtc(w.y, w.m, w.d, w.dateOnly ? 0 : w.hh, w.dateOnly ? 0 : w.mm, 0, tz); };
148
+
149
+ /** A Google start/end as wall-clock in `tz` (dates stay dates), or null when unusable. */
150
+ export function wallFromGoogle(t: GoogleTime | undefined, tz: string): string | null {
151
+ if (!t) return null;
152
+ if (t.date) return parseWall(t.date)?.dateOnly ? t.date : null;
153
+ if (!t.dateTime) return null;
154
+ // an offset (or Z) is an instant; without one it's a wall time in its own zone
155
+ if (/(?:Z|[+-]\d{2}:?\d{2})$/i.test(t.dateTime)) {
156
+ const ms = Date.parse(t.dateTime);
157
+ return Number.isFinite(ms) ? wallOfMs(ms, tz) : null;
158
+ }
159
+ const w = parseWall(t.dateTime.slice(0, 16));
160
+ if (!w) return null;
161
+ const own = isZone(t.timeZone) ? t.timeZone : tz;
162
+ return own === tz ? formatWall(w) : wallOfMs(wallToMs(formatWall(w), own), tz);
163
+ }
164
+
165
+ /** The event's zone in Google: its own, else the Google calendar's, else the site's. */
166
+ export const googleZone = (g: Pick<GoogleEvent, 'start'>, link: Pick<GcalLink, 'googleTz'>, settings: Pick<Settings, 'timezone'>) =>
167
+ [g.start?.timeZone, link.googleTz, settings.timezone, 'UTC'].find(isZone)!;
168
+
169
+ /**
170
+ * start/end for Google: dates (end exclusive) or wall times with their zone
171
+ * (no offset needed; recurring events need the zone). A patch merges start
172
+ * and end, so it clears the other form (an event switching to or from all day).
173
+ */
174
+ export function googleTimes(when: When, patch = false): { start: Record<string, string | null>; end: Record<string, string | null> } {
175
+ if (when.allDay) {
176
+ const end = addWallDays(parseWall(when.end || when.start)!, 1);
177
+ const clear: Record<string, null> = patch ? { dateTime: null, timeZone: null } : {};
178
+ return { start: { date: when.start, ...clear }, end: { date: formatWall({ ...end, dateOnly: true }), ...clear } };
179
+ }
180
+ const clear: Record<string, null> = patch ? { date: null } : {};
181
+ return { start: { dateTime: `${when.start}:00`, timeZone: when.tz, ...clear }, end: { dateTime: `${when.end}:00`, timeZone: when.tz, ...clear } };
182
+ }
183
+
184
+ function whenFromGoogle(g: GoogleEvent, tz: string): When | null {
185
+ const s = wallFromGoogle(g.start, tz);
186
+ if (!s) return null;
187
+ if (g.start?.date) {
188
+ const endEx = g.end?.date ? parseWall(g.end.date) : null;
189
+ const end = endEx ? formatWall({ ...addWallDays(endEx, -1), dateOnly: true }) : s;
190
+ return { allDay: true, ...normalizeEventTimes({ start: s, end: end < s ? s : end, allDay: true }), tz: '' };
191
+ }
192
+ const e = wallFromGoogle(g.end, tz) ?? s;
193
+ return { allDay: false, ...normalizeEventTimes({ start: s, end: e, allDay: false }), tz };
194
+ }
195
+
196
+ // ---- recurrence ----
197
+
198
+ const icsDate = (wall: string) => wall.replace(/[-:]/g, '').replace(/^(\d{8})T(\d{4})$/, '$1T$200');
199
+
200
+ /**
201
+ * The site's rule as Google wants it: RRULE plus EXDATE in the event's zone.
202
+ * A timed series' UNTIL must be UTC when the start has a zone (RFC 5545), so
203
+ * a date or floating UNTIL is read in the event's zone (a date counts its
204
+ * whole day, as the site's expander does); an all-day series keeps a date.
205
+ */
206
+ export function googleRecurrence(repeat: Repeat, when: When): string[] {
207
+ if (!repeat.rrule) return [];
208
+ const parts = rruleMap(repeat.rrule);
209
+ let rule = repeat.rrule;
210
+ if (parts.UNTIL) {
211
+ const u = parts.UNTIL;
212
+ let until = u;
213
+ if (when.allDay) until = u.slice(0, 8);
214
+ else if (!u.endsWith('Z')) {
215
+ const wall = `${u.slice(0, 4)}-${u.slice(4, 6)}-${u.slice(6, 8)}T${u.length > 8 ? `${u.slice(9, 11)}:${u.slice(11, 13)}` : '23:59'}`;
216
+ const ms = wallToMs(wall, when.tz) + (u.length > 8 ? 0 : 59_000);
217
+ const d = new Date(ms);
218
+ until = `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
219
+ }
220
+ rule = rule.replace(/UNTIL=[^;]*/, `UNTIL=${until}`);
221
+ }
222
+ const out = [`RRULE:${rule}`];
223
+ if (repeat.exdates.length) out.push(when.allDay ? `EXDATE;VALUE=DATE:${repeat.exdates.map((d) => icsDate(d.slice(0, 10))).join(',')}` : `EXDATE;TZID=${when.tz}:${repeat.exdates.map(icsDate).join(',')}`);
224
+ return out;
225
+ }
226
+
227
+ // an ICS date or date-time value → a wall key in `tz` (dates stay dates)
228
+ function keyFromIcs(value: string, tzid: string | undefined, tz: string, allDay: boolean): string | null {
229
+ const m = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?(Z)?)?$/.exec(value.trim());
230
+ if (!m) return null;
231
+ const date = `${m[1]}-${m[2]}-${m[3]}`;
232
+ if (!m[4]) return allDay ? date : null;
233
+ const wall = `${date}T${m[4]}:${m[5]}`;
234
+ if (allDay) return date;
235
+ if (m[7]) return wallOfMs(Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], Number(m[6] ?? 0)), tz);
236
+ const own = isZone(tzid) ? tzid : tz;
237
+ return own === tz ? wall : wallOfMs(wallToMs(wall, own), tz);
238
+ }
239
+
240
+ /** Google's recurrence lines in site terms; `unsupported` lists what the site can't hold (RDATE, rule parts it drops). */
241
+ export function repeatFromGoogle(lines: string[] | undefined, tz: string, allDay: boolean): Repeat & { unsupported: string[] } {
242
+ let rrule = '';
243
+ const exdates = new Set<string>();
244
+ const unsupported: string[] = [];
245
+ for (const line of lines ?? []) {
246
+ const colon = line.indexOf(':');
247
+ if (colon < 0) continue;
248
+ const head = line.slice(0, colon).toUpperCase(), value = line.slice(colon + 1);
249
+ const name = head.split(';')[0];
250
+ if (name === 'RRULE') {
251
+ rrule = cleanRrule(value);
252
+ const dropped = Object.keys(rruleMap(value)).filter((k) => !Object.hasOwn(rruleMap(rrule), k));
253
+ if (!rrule) unsupported.push('repeat rule'); else if (dropped.length) unsupported.push(`rule parts ${dropped.join(', ')}`);
254
+ } else if (name === 'EXDATE') {
255
+ const tzid = /;TZID=([^;:]+)/i.exec(line.slice(0, colon))?.[1];
256
+ for (const v of value.split(',')) { const k = keyFromIcs(v, tzid, tz, allDay); if (k) exdates.add(k); }
257
+ } else unsupported.push(name);
258
+ }
259
+ return { rrule, exdates: rrule ? [...exdates].sort() : [], unsupported };
260
+ }
261
+
262
+ // ---- each side's fields ----
263
+
264
+ /** The site event's synced fields. A series' changed dates count from the link's start date on. */
265
+ export function siteFields(e: CalEvent, link: Pick<GcalLink, 'rules' | 'startDate'>, ctx: MapContext): SiteFields {
266
+ const tz = eventTimeZone(e, ctx.settings);
267
+ const when: When = e.allDay ? { allDay: true, start: e.start, end: e.end || e.start, tz: '' } : { allDay: false, start: e.start, end: e.end || e.start, tz };
268
+ const repeat: Repeat = e.rrule ? { rrule: cleanRrule(e.rrule), exdates: [...new Set(e.exdates)].sort() } : { rrule: '', exdates: [] };
269
+ const place = locationLine(e.location);
270
+ const dates: Record<string, DateState> = {};
271
+ if (e.rrule) {
272
+ const series = { ...e, movedDates: {} };
273
+ for (const key of e.cancelledDates) if (key >= link.startDate && findOccurrence(series, key, ctx.settings)) dates[key] = 'cancelled';
274
+ for (const [key, m] of Object.entries(e.movedDates)) {
275
+ if (key < link.startDate || dates[key] || !findOccurrence(series, key, ctx.settings)) continue;
276
+ dates[key] = { start: m.start, end: m.end, place: m.location ? locationLine(m.location) : place };
277
+ }
278
+ }
279
+ return {
280
+ title: titleKey(e.title), when, repeat, place, desc: plainText(e.bodyHtml),
281
+ rules: Object.fromEntries(link.rules.map((r) => [r.prefix, hasTag(e.tags, r.tag)])),
282
+ dates, status: e.cancelled,
283
+ link: { url: e.location.url.trim(), page: e.slug ? `${ctx.origin}${ctx.pagePath(e.slug)}` : '' },
284
+ };
285
+ }
286
+
287
+ /**
288
+ * A Google event's synced fields in site terms (its dates come separately,
289
+ * from its instances), or null when it has no usable time. `inZone` reads the
290
+ * times in another zone (the site event's, so single-date keys match the site's).
291
+ */
292
+ export function googleFields(g: GoogleEvent, link: Pick<GcalLink, 'rules' | 'googleTz'>, settings: Pick<Settings, 'timezone'>, inZone?: string): (Fields & { unsupported: string[] }) | null {
293
+ const tz = inZone && isZone(inZone) ? inZone : googleZone(g, link, settings);
294
+ const when = whenFromGoogle(g, tz);
295
+ if (!when) return null;
296
+ const repeat = repeatFromGoogle(g.recurrence, tz, when.allDay);
297
+ const title = stripCancelled(g.summary ?? '');
298
+ return {
299
+ title, when, repeat: { rrule: repeat.rrule, exdates: repeat.exdates }, place: (g.location ?? '').trim(), desc: plainText(g.description ?? ''),
300
+ rules: Object.fromEntries(link.rules.map((r) => [r.prefix, hasPrefix(title, r.prefix)])), dates: {}, unsupported: repeat.unsupported,
301
+ };
302
+ }
303
+
304
+ /** The occurrence key a Google instance stands for (its original start, in the series' zone), or null. */
305
+ export function instanceKey(i: Pick<GoogleEvent, 'originalStartTime'>, master: When): string | null {
306
+ const t = i.originalStartTime;
307
+ if (!t) return null;
308
+ if (master.allDay) return t.date ?? (t.dateTime ? t.dateTime.slice(0, 10) : null);
309
+ return wallFromGoogle(t, master.tz);
310
+ }
311
+
312
+ /** One Google instance as a date state: cancelled, moved (another time or place), or 'none' (as the series has it). */
313
+ export function instanceState(i: GoogleEvent, master: Fields, key: string): DateState | 'none' {
314
+ if (i.status === 'cancelled') return 'cancelled';
315
+ const own = whenFromGoogle(i, master.when.tz || 'UTC');
316
+ if (!own || own.allDay !== master.when.allDay) return 'none';
317
+ const place = i.location === undefined ? master.place : i.location.trim();
318
+ const usual = seriesDate(master.when, key);
319
+ if (own.start === usual.start && own.end === usual.end && place === master.place) return 'none';
320
+ return { start: own.start, end: own.end, place };
321
+ }
322
+
323
+ /** Where a series puts one date: its key and the series' length. */
324
+ export function seriesDate(when: When, key: string): { start: string; end: string } {
325
+ const s = parseWall(when.start)!, e = parseWall(when.end) ?? s, k = parseWall(key);
326
+ if (!k) return { start: key, end: key };
327
+ const minutes = (Date.UTC(e.y, e.m - 1, e.d, e.hh, e.mm) - Date.UTC(s.y, s.m - 1, s.d, s.hh, s.mm)) / 60_000;
328
+ const end = new Date(Date.UTC(k.y, k.m - 1, k.d, k.hh, k.mm) + minutes * 60_000);
329
+ const w = { y: end.getUTCFullYear(), m: end.getUTCMonth() + 1, d: end.getUTCDate(), hh: end.getUTCHours(), mm: end.getUTCMinutes(), dateOnly: when.allDay };
330
+ return { start: key, end: formatWall(w) };
331
+ }
332
+
333
+ // ---- hashes ----
334
+
335
+ export const fieldHash = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 16);
336
+
337
+ /** Every field's hash; date keys only for dates that aren't as the series has them. */
338
+ export function fieldHashes(f: Fields & Partial<Pick<SiteFields, 'status' | 'link'>>): Hashes {
339
+ const out: Hashes = { title: fieldHash(f.title), when: fieldHash(f.when), repeat: fieldHash(f.repeat), place: fieldHash(f.place), desc: fieldHash(f.desc) };
340
+ if (f.status !== undefined) out.status = fieldHash(f.status);
341
+ if (f.link !== undefined) out.link = fieldHash(f.link);
342
+ for (const [prefix, on] of Object.entries(f.rules)) out[`rule:${prefix}`] = fieldHash(on);
343
+ for (const [key, d] of Object.entries(f.dates)) out[`date:${key}`] = fieldHash(d);
344
+ return out;
345
+ }
346
+ export const NONE = fieldHash('none');
347
+ export const dateHash = (d: DateState | 'none') => d === 'none' ? NONE : fieldHash(d);
348
+
349
+ /** The fields whose hash moved since the pair last agreed (a date missing on one side counts as "as the series has it"). */
350
+ export function changed(current: Hashes, agreed: Hashes, only?: (name: string) => boolean): string[] {
351
+ const names = new Set([...Object.keys(current), ...Object.keys(agreed)]);
352
+ return [...names].filter((n) => (!only || only(n)) && (current[n] ?? (n.startsWith('date:') ? NONE : '')) !== (agreed[n] ?? (n.startsWith('date:') ? NONE : ''))).sort();
353
+ }
354
+
355
+ /** Two sides' values that mean the same (so both changing a field to these isn't a clash). */
356
+ export function sameValue(name: string, site: Fields, google: Fields, rules: PrefixRule[]): boolean {
357
+ if (name === 'title') return comparableTitle(site.title, rules) === comparableTitle(google.title, rules);
358
+ if (name === 'when') return JSON.stringify(site.when) === JSON.stringify(google.when);
359
+ if (name === 'repeat') return JSON.stringify(site.repeat) === JSON.stringify(google.repeat);
360
+ if (name === 'place') return site.place.toLowerCase() === google.place.toLowerCase();
361
+ if (name === 'desc') return site.desc === google.desc;
362
+ if (name.startsWith('rule:')) return site.rules[name.slice(5)] === google.rules[name.slice(5)];
363
+ return false;
364
+ }
365
+
366
+ // ---- which link an event goes to ----
367
+
368
+ /**
369
+ * Whether this link carries the event: published, in the link's site
370
+ * calendar, and holding one of the link's rooms (a link without rooms carries
371
+ * its whole calendar; "no room" also carries the calendar's events that hold
372
+ * none). Booked appointments are Google's and never go out as new copies.
373
+ */
374
+ export function linkCarries(e: Pick<CalEvent, 'status' | 'calendarIds' | 'roomHold' | 'tags'>, link: Pick<GcalLink, 'calendarId' | 'roomIds' | 'noRoom'>): boolean {
375
+ if (e.status !== 'published' || !e.calendarIds.includes(link.calendarId)) return false;
376
+ if (!link.roomIds.length) return true;
377
+ const rooms = e.roomHold.resourceIds;
378
+ return rooms.some((r) => link.roomIds.includes(r)) || (link.noRoom && rooms.length === 0);
379
+ }
380
+
381
+ /** The link's start date as an instant (midnight in the site's zone). */
382
+ export const windowStart = (link: Pick<GcalLink, 'startDate'>, settings: Pick<Settings, 'timezone'>) => wallToMs(link.startDate, settings.timezone || 'UTC');
383
+
384
+ /** Within the link's range: ends on or after its start date, or a series with a date from then on (Q11). */
385
+ export function inWindow(e: Pick<CalEvent, 'endAt' | 'rrule'> & Partial<CalEvent>, startMs: number, settings: Pick<Settings, 'timezone'>): boolean {
386
+ if (e.endAt >= startMs) return true;
387
+ if (!e.rrule) return false;
388
+ return expandEvents([e as CalEvent], settings, { displayTz: settings.timezone || 'UTC', now: startMs, windowStartMs: startMs, windowEndMs: startMs + 800 * DAY }).length > 0;
389
+ }
390
+
391
+ /** A Google event's fields as an unsaved site event, for range checks and matching. */
392
+ export function asSiteEvent(f: Fields, settings: Pick<Settings, 'timezone'>, id = 'google'): CalEvent {
393
+ const timezone = f.when.allDay || f.when.tz === (settings.timezone || 'UTC') ? '' : f.when.tz;
394
+ const tz = eventTimeZone({ timezone }, settings);
395
+ const { startAt, endAt } = eventInstants({ start: f.when.start, end: f.when.end, allDay: f.when.allDay }, tz);
396
+ return {
397
+ id, slug: '', title: f.title, bodyJson: '', bodyHtml: '', excerpt: '', cover: '', coverAlt: '', seoTitle: '', seoDesc: '', status: 'published', featured: false,
398
+ tags: [], template: '', start: f.when.start, end: f.when.end, allDay: f.when.allDay, timezone, rrule: f.repeat.rrule, exdates: f.repeat.exdates, cancelled: false,
399
+ cancelledDates: [], movedDates: {}, location: EventLocation.parse({ name: f.place }), cta: { label: '', href: '', style: 'primary' }, calendarIds: [], fields: {},
400
+ sourceUid: '', rsvp: { enabled: false, capacity: 0, autoConfirm: true, maxSeats: 4, closesAt: null, closeBefore: 0, notifyStaff: true, waitlist: false },
401
+ roomHold: { resourceIds: [], span: 'event', before: 0, after: 0, whileDraft: false, keep: [] }, hostIds: [], request: null, startAt, endAt,
402
+ translations: {}, draft: null, publishedAt: null, scheduledAt: null, createdAt: 0, updatedAt: 0,
403
+ };
404
+ }
405
+
406
+ // ---- what comes from Google ----
407
+
408
+ /** Status blocks, not events: focus time, out of office, working location, birthdays. */
409
+ export const notAnEvent = (g: Pick<GoogleEvent, 'eventType'>) => !!g.eventType && !['default', 'fromGmail'].includes(g.eventType);
410
+
411
+ /**
412
+ * A booked appointment from one of the calendar's booking pages (Q16).
413
+ * Google's API has no marker for these (its event types don't include them,
414
+ * and open booking times aren't listed at all). Google copies the booking
415
+ * page's title into each booked event, so a link names its booking pages: an
416
+ * event with one of those titles (or that title and the booker's name after
417
+ * it) and a guest is a booking. So is one whose description links to a
418
+ * Google booking page.
419
+ */
420
+ export function isBooking(g: GoogleEvent, link: Pick<GcalLink, 'bookingTitles'>): boolean {
421
+ const title = titleKey(g.summary ?? '').toLowerCase();
422
+ const guest = (g.attendees ?? []).some((a) => !a.self && !a.organizer && !a.resource);
423
+ const named = link.bookingTitles.some((t) => {
424
+ const b = titleKey(t).toLowerCase();
425
+ return !!b && (title === b || title.startsWith(`${b} (`) || title.startsWith(`${b} - `) || title.startsWith(`${b}: `));
426
+ });
427
+ return (named && guest) || /calendar\.app\.google\/|calendar\.google\.com\/calendar\/(?:u\/\d+\/)?appointments\//i.test(g.description ?? '');
428
+ }
429
+
430
+ /** A later part made by Google's "this and following" ("<series id>_R<when>"): the series it was split from. */
431
+ export const splitFrom = (googleId: string) => /^(.+)_R\d{8}(?:T\d{6}Z?)?$/.exec(googleId)?.[1] ?? null;
432
+
433
+ // ---- what Google gets ----
434
+
435
+ /**
436
+ * The Google id for a site event's copy: chosen by the site (Google allows
437
+ * 0-9 and a-v), so a run that crashed after Google saved it finds that copy
438
+ * again instead of making a second one. A copy made again after Google
439
+ * deleted the first gets a new id (deleted ids stay taken).
440
+ */
441
+ export function googleIdFor(eventId: string, again = 0): string | undefined {
442
+ const hex = eventId.toLowerCase().replace(/-/g, '');
443
+ if (!/^[0-9a-f]{32}$/.test(hex)) return undefined;
444
+ return `acms${hex}${again ? `r${again.toString(32)}` : ''}`;
445
+ }
446
+
447
+ export const hiddenLink = (eventId: string, origin: string) => ({ private: { acmsEventId: eventId, acmsSite: origin } });
448
+
449
+ /** A whole new Google event for the site event (description left out while it's Google's). */
450
+ export function googleBody(e: CalEvent, f: SiteFields, link: Pick<GcalLink, 'rules'>, ctx: MapContext, opts: { desc: boolean }): Record<string, unknown> {
451
+ return {
452
+ summary: googleTitle(e, link.rules, ctx.words),
453
+ ...(opts.desc ? { description: googleDescription(f.desc, f.link, ctx.words) } : {}),
454
+ location: f.place,
455
+ ...googleTimes(f.when),
456
+ ...(f.repeat.rrule ? { recurrence: googleRecurrence(f.repeat, f.when) } : {}),
457
+ extendedProperties: hiddenLink(e.id, ctx.origin),
458
+ };
459
+ }
460
+
461
+ /** Just the Google fields for these changed site fields. `googleSummary`: Google's title now (kept, adjusted, unless the site's title changed). */
462
+ export function googlePatch(e: CalEvent, f: SiteFields, link: Pick<GcalLink, 'rules'>, ctx: MapContext, fields: string[], opts: { desc: boolean; googleSummary?: string }): Record<string, unknown> {
463
+ const out: Record<string, unknown> = {};
464
+ const has = (n: string) => fields.includes(n);
465
+ const rules = link.rules.filter((r) => has(`rule:${r.prefix}`));
466
+ if (has('title')) out.summary = googleTitle(e, link.rules, ctx.words);
467
+ else if (has('status') || rules.length) out.summary = opts.googleSummary === undefined ? googleTitle(e, link.rules, ctx.words) : adjustTitle(opts.googleSummary, e, rules, ctx.words);
468
+ if (opts.desc && (has('desc') || has('link'))) out.description = googleDescription(f.desc, f.link, ctx.words);
469
+ if (has('place')) out.location = f.place;
470
+ if (has('when') || has('repeat')) Object.assign(out, googleTimes(f.when, true));
471
+ // a rule change rewrites the whole list; [] makes a one-off of a series
472
+ if (has('repeat') || (has('when') && f.repeat.rrule)) out.recurrence = googleRecurrence(f.repeat, f.when);
473
+ return out;
474
+ }
475
+
476
+ /** start/end/location for one date of the series in Google (an instance). */
477
+ export function instanceBody(d: DateState | 'none', f: Fields, key: string): Record<string, unknown> {
478
+ if (d === 'cancelled') return { status: 'cancelled' };
479
+ const at = d === 'none' ? { ...seriesDate(f.when, key), place: f.place } : d;
480
+ return { status: 'confirmed', ...googleTimes({ allDay: f.when.allDay, start: at.start, end: at.end, tz: f.when.tz }, true), location: at.place };
481
+ }
482
+
483
+ /** The originalStart a Google instance is asked for by (events.instances): RFC 3339 in the series' zone, or the date. */
484
+ export function originalStart(key: string, when: When): string {
485
+ if (when.allDay) return key.slice(0, 10);
486
+ return new Date(wallToMs(key, when.tz)).toISOString().replace('.000Z', 'Z');
487
+ }
488
+
489
+ /** The site's write for these changed Google fields (null when none applies): the main-language title as typed (Q9), tags from prefixes (Q15). */
490
+ export function siteUpdate(e: CalEvent, g: Fields, fields: string[], opts: { rules: PrefixRule[]; descGoogle: boolean; settings: Pick<Settings, 'timezone'> }) {
491
+ const out: Partial<Pick<CalEvent, 'title' | 'tags' | 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'location' | 'bodyHtml' | 'bodyJson' | 'excerpt'>> = {};
492
+ const has = (n: string) => fields.includes(n);
493
+ if (has('title') && g.title) out.title = g.title.slice(0, 300);
494
+ const ruleFields = fields.filter((n) => n.startsWith('rule:'));
495
+ if (ruleFields.length) {
496
+ let tags = [...e.tags];
497
+ for (const n of ruleFields) {
498
+ const rule = opts.rules.find((r) => r.prefix === n.slice(5));
499
+ if (!rule) continue;
500
+ if (g.rules[rule.prefix] && !hasTag(tags, rule.tag)) tags.push(rule.tag);
501
+ if (!g.rules[rule.prefix]) tags = tags.filter((t) => !hasTag([t], rule.tag));
502
+ }
503
+ out.tags = tags;
504
+ }
505
+ if (has('when')) {
506
+ Object.assign(out, { start: g.when.start, end: g.when.end, allDay: g.when.allDay });
507
+ if (!g.when.allDay) out.timezone = g.when.tz === (opts.settings.timezone || 'UTC') ? '' : g.when.tz;
508
+ }
509
+ if (has('repeat')) Object.assign(out, { rrule: g.repeat.rrule, exdates: g.repeat.exdates });
510
+ // the place as typed in Google; a saved place or pin no longer applies (the map or meeting link stays)
511
+ if (has('place')) out.location = EventLocation.parse({ name: g.place.slice(0, 300), url: e.location.url, showMap: e.location.showMap });
512
+ if (has('desc') && opts.descGoogle) Object.assign(out, { bodyHtml: paragraphsHtml(g.desc), bodyJson: '', excerpt: g.desc.replace(/\s+/g, ' ').slice(0, 300) });
513
+ return Object.keys(out).length ? out : null;
514
+ }
515
+
516
+ // a wall time in words ("Wed, Oct 28" and "3:30 PM"), read as written: no zone conversion
517
+ function wallWords(wall: string) {
518
+ const p = parseWall(wall);
519
+ if (!p) return { day: wall, time: '' };
520
+ const d = Date.UTC(p.y, p.m - 1, p.d, p.hh, p.mm);
521
+ return { day: new Intl.DateTimeFormat('en-US', { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' }).format(d),
522
+ time: p.dateOnly ? '' : new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit', timeZone: 'UTC' }).format(d) };
523
+ }
524
+
525
+ /** Field values in words for Recent changes and clash notes (staff read these in English, like the rest of the admin). */
526
+ export function describe(name: string, f: Fields): string {
527
+ if (name === 'title') return f.title;
528
+ if (name === 'place') return f.place || '(no place)';
529
+ if (name === 'desc') return f.desc ? (f.desc.length > 140 ? `${f.desc.slice(0, 140)}…` : f.desc) : '(no description)';
530
+ if (name === 'when') {
531
+ const a = wallWords(f.when.start), b = wallWords(f.when.end);
532
+ if (f.when.allDay) return a.day === b.day ? `${a.day}, all day` : `${a.day} – ${b.day}`;
533
+ return a.day === b.day ? `${a.day}, ${a.time} – ${b.time}` : `${a.day}, ${a.time} – ${b.day}, ${b.time}`;
534
+ }
535
+ if (name === 'repeat') return f.repeat.rrule ? `${describeRepeat(f.repeat.rrule, f.when.start)}${f.repeat.exdates.length ? `, skipping ${f.repeat.exdates.length}` : ''}` : 'one date';
536
+ if (name.startsWith('rule:')) return f.rules[name.slice(5)] ? `“${name.slice(5)}” in the title` : `no “${name.slice(5)}”`;
537
+ return '';
538
+ }
539
+ export const FIELD_WORDS: Record<string, string> = { title: 'Title', when: 'Time', repeat: 'Repeat', place: 'Place', desc: 'Description', link: 'Links', status: 'Cancelled' };
540
+ export const fieldWord = (name: string) => FIELD_WORDS[name] ?? (name.startsWith('rule:') ? `“${name.slice(5)}”` : name.startsWith('date:') ? `Date ${name.slice(5).replace('T', ' ')}` : name);
541
+
542
+ /** Plain milliseconds of a wall key in the series' zone (for "has this date passed?"). */
543
+ export const keyStart = (key: string, when: When, settings: Pick<Settings, 'timezone'>) => wallToMs(key, when.allDay ? settings.timezone || 'UTC' : when.tz);
@@ -0,0 +1,103 @@
1
+ // The first match: before a link writes anything, staff see what's on both
2
+ // sides. Events typed by hand into both calendars are already doubles, so
3
+ // they're paired here and nothing is written until "Start syncing":
4
+ // - sure pairs: the hidden link on a Google copy, then the same calendar ID
5
+ // (copies made from a Google feed keep Google's iCalUID as sourceUid), then
6
+ // the same title (prefixes and case aside) at the same time → Linked (undoable)
7
+ // - likely pairs: same start and same repeat, different title → suggested,
8
+ // waiting for "Link" or "Not the same"
9
+ // Starting records every linked pair as it is: both titles stay as they are
10
+ // (Q18), and so does every other field and date, as the agreed starting point.
11
+ // Events only in Google come to the site, events only on the site go to
12
+ // Google, pairs marked "Not the same" count as two events, and no member
13
+ // notifications are sent for any of it (Q10).
14
+ import { ApiError } from '../../api';
15
+ import { hasTag } from '../../schema';
16
+ import type { SyncContext } from './context';
17
+ import { processEvents, listChanges, pairExisting } from './inbound';
18
+ import { queueLinkCalendar } from './links';
19
+ import { BOOKING_TAG, asSiteEvent, comparableTitle, googleFields, inWindow, isBooking, linkCarries, notAnEvent, windowStart } from './map';
20
+ import type { GcalLink, MatchPair, MatchState } from './types';
21
+
22
+ const REASON_WORDS: Record<MatchPair['reason'], string> = {
23
+ 'hidden-id': 'its hidden link', uid: 'the same calendar ID', 'title-time': 'the same title and time', 'time-repeat': 'the same time and repeat',
24
+ };
25
+ export const reasonWords = (r: MatchPair['reason']) => REASON_WORDS[r];
26
+
27
+ /** Reads both sides and proposes pairs. Writes nothing to either calendar. */
28
+ export async function buildMatch(ctx: SyncContext, link: GcalLink): Promise<MatchState> {
29
+ const startMs = windowStart(link, ctx.settings);
30
+ const { items } = await listChanges(ctx, link, true);
31
+ const google = items.filter((g) => !g.recurringEventId && g.status !== 'cancelled' && !notAnEvent(g))
32
+ .map((g) => { const f = googleFields(g, link, ctx.settings); return f ? { g, f, shape: asSiteEvent(f, ctx.settings) } : null; })
33
+ .filter((x): x is NonNullable<typeof x> => !!x && inWindow(x.shape, startMs, ctx.settings));
34
+ const site = (await ctx.repo.listEvents()).filter((e) => linkCarries(e, link) && !hasTag(e.tags, BOOKING_TAG) && inWindow(e, startMs, ctx.settings));
35
+ const pairs: MatchPair[] = [];
36
+ const usedSite = new Set<string>(), usedGoogle = new Set<string>();
37
+ const pair = (eventId: string, googleId: string, reason: MatchPair['reason'], state: MatchPair['state']) => {
38
+ pairs.push({ eventId, googleId, reason, state }); usedSite.add(eventId); usedGoogle.add(googleId);
39
+ };
40
+ const passes: [MatchPair['reason'], MatchPair['state'], (e: (typeof site)[number], x: (typeof google)[number]) => boolean][] = [
41
+ ['hidden-id', 'linked', (e, x) => x.g.extendedProperties?.private?.acmsEventId === e.id && x.g.extendedProperties?.private?.acmsSite === ctx.origin],
42
+ ['uid', 'linked', (e, x) => !!e.sourceUid && e.sourceUid === x.g.iCalUID],
43
+ ['title-time', 'linked', (e, x) => e.startAt === x.shape.startAt && !!e.rrule === !!x.f.repeat.rrule && comparableTitle(e.title, link.rules) === comparableTitle(x.f.title, link.rules)],
44
+ ['time-repeat', 'suggested', (e, x) => e.startAt === x.shape.startAt && e.allDay === x.f.when.allDay && e.rrule === x.f.repeat.rrule],
45
+ ];
46
+ for (const [reason, state, same] of passes) {
47
+ for (const x of google) {
48
+ if (usedGoogle.has(x.g.id) || isBooking(x.g, link)) continue;
49
+ const e = site.find((s) => !usedSite.has(s.id) && same(s, x));
50
+ if (e) pair(e.id, x.g.id, reason, state);
51
+ }
52
+ }
53
+ return {
54
+ builtAt: ctx.now, pairs,
55
+ siteOnly: site.filter((e) => !usedSite.has(e.id)).map((e) => e.id),
56
+ googleOnly: google.filter((x) => !usedGoogle.has(x.g.id)).map((x) => x.g.id),
57
+ google: Object.fromEntries(google.map((x) => [x.g.id, { title: x.g.summary ?? '', start: x.shape.startAt, allDay: x.f.when.allDay, repeats: !!x.f.repeat.rrule, booking: isBooking(x.g, link) }])),
58
+ };
59
+ }
60
+
61
+ /** Link, Not the same, or Undo (an automatic pair becomes two events) for one proposed pair. Saved on the link ("Save and finish later"). */
62
+ export async function decideMatch(ctx: Pick<SyncContext, 'repo'>, linkId: string, googleId: string, decision: 'link' | 'apart') {
63
+ const link = await ctx.repo.gcal.getLink(linkId);
64
+ if (!link || link.state !== 'matching' || !link.match) throw new ApiError(409, 'This link isn’t waiting for a match any more. Reload the page.');
65
+ const pairs = link.match.pairs.map((p) => p.googleId === googleId ? { ...p, state: decision === 'link' ? 'linked' as const : 'apart' as const } : p);
66
+ if (!await ctx.repo.gcal.patchLink(linkId, { match: { ...link.match, pairs } }, link.revision)) throw new ApiError(409, 'The link changed meanwhile. Reload and try again.');
67
+ }
68
+
69
+ /** "Start syncing": pairs as they are, the rest copied each way, quietly. The caller holds the link's lease. */
70
+ export async function startSyncing(ctx: SyncContext, link: GcalLink) {
71
+ const m = link.match;
72
+ if (link.state !== 'matching' || !m) throw new ApiError(409, 'This link isn’t waiting for a match. Reload the page.');
73
+ const waiting = m.pairs.filter((p) => p.state === 'suggested').length;
74
+ if (waiting) throw new ApiError(400, `Decide the ${waiting} suggested pair${waiting === 1 ? '' : 's'} first: Link, or Not the same.`);
75
+ const { items, syncToken } = await listChanges(ctx, link, true);
76
+ const byId = new Map(items.map((g) => [g.id, g]));
77
+ const adopt = new Set<string>(), skip = new Set<string>();
78
+ const apart = new Set(m.pairs.filter((p) => p.state === 'apart').map((p) => p.googleId));
79
+ let paired = 0;
80
+ const changedSince: string[] = [];
81
+ for (const p of m.pairs.filter((x) => x.state === 'linked')) {
82
+ const [g, e] = [byId.get(p.googleId), await ctx.repo.getEvent(p.eventId)];
83
+ if (!g || g.status === 'cancelled') continue;
84
+ // the site event was unpublished, deleted or moved out of this calendar or room since the review:
85
+ // its Google twin is left alone (neither paired, nor deleted, nor copied as a new event)
86
+ if (!e || !linkCarries(e, link)) { skip.add(g.id); changedSince.push(g.summary || e?.title || 'an event'); continue; }
87
+ if ((await ctx.repo.gcal.getItem(link.id, g.id))) continue;
88
+ await pairExisting(ctx, link, e, g, { quiet: true, adopt }, REASON_WORDS[p.reason]);
89
+ paired++;
90
+ }
91
+ // the rest as a quiet full read: only-in-Google events come in, pairs' dates are recorded as they are
92
+ // (a site → Google link leaves Google's own events in Google)
93
+ if (link.direction !== 'out') await processEvents(ctx, link, items, { quiet: true, apart, adopt, skip });
94
+ if (!await ctx.repo.gcal.patchLink(link.id, { state: 'active', matchedAt: ctx.now, match: null, syncToken, fullSync: false, failures: 0, failingSince: 0, error: '', lastOkAt: ctx.now, nextAt: ctx.now + 120_000 }, link.revision, link.lease)) {
95
+ throw new ApiError(409, 'The link changed meanwhile. Reload and try again.');
96
+ }
97
+ // the site's events go out: only-on-site ones are copied, pairs have nothing to send (a Google → site link sends none)
98
+ if (link.direction !== 'in') await queueLinkCalendar(ctx.repo, link);
99
+ if (changedSince.length) await ctx.repo.gcal.log([{ direction: 'info', action: 'started', linkId: link.id, title: link.googleName,
100
+ summary: `Left alone because their site event changed since the review (unpublished, deleted or moved): ${changedSince.slice(0, 10).map((t) => `“${t}”`).join(', ')}${changedSince.length > 10 ? ` and ${changedSince.length - 10} more` : ''}. They stay as they are in Google.` }], ctx.now);
101
+ await ctx.repo.gcal.log([{ direction: 'info', action: 'started', linkId: link.id, title: link.googleName,
102
+ summary: `Started syncing: ${paired} pair${paired === 1 ? '' : 's'} kept as they are, ${m.googleOnly.length + apart.size} event${m.googleOnly.length + apart.size === 1 ? '' : 's'} from Google, ${m.siteOnly.length + apart.size} going to Google. No notifications were sent.` }], ctx.now);
103
+ }