@acms-subir/core 0.3.57 → 0.3.59

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.
@@ -18,20 +18,25 @@
18
18
  // - deleted: cancelled on the site (struck through, room freed, restorable,
19
19
  // Q6), or, when the event has another linked copy, just this link's room
20
20
  // and calendar taken off it
21
- // - "this and following": the new Google series is linked to the same site
22
- // event as a later part (kind segment) and listed for staff; C4 ("from this
23
- // date on") will apply those later
21
+ // - "this and following": C4's changeFromDate from the date Google split at,
22
+ // as GOOGLE_SYNC (RSVPs and dates follow; quiet in full reads); the new
23
+ // Google series is that later part's pair (kind segment). Its later edits
24
+ // change the part again, its single dates go through C1 like any date, and
25
+ // deleting it, or a series Google ends sooner, cancels those dates
26
+ // (cancelFromDate: struck through, restorable, Q6)
24
27
  import { EventLocation, RoomHold, type CalEvent } from '../../schema';
25
28
  import { eventSideEffects } from '../../content-service';
26
- import { expandEvents } from '../events';
29
+ import { eventTimeZone, expandEvents, findOccurrence, locationLine, seriesParts, type SeriesPart } from '../events';
27
30
  import { rruleMap } from '../rrule';
31
+ import { cancelFromDate, changeFromDate, restoreFromDate } from '../segments';
28
32
  import { cancelOccurrence, restoreOccurrence, uniqueSlug } from '../schedule';
29
33
  import { GOOGLE_SYNC, moveOccurrence, moveOccurrenceBack } from '../moves';
30
34
  import { clashNote, entry, type SyncContext } from './context';
31
35
  import { GoogleError } from './client';
32
36
  import {
33
37
  BOOKING_TAG, NONE, asSiteEvent, changed, dateHash, describe, fieldHash, fieldHashes, fieldWord, googleFields, hiddenLink, inWindow, instanceKey,
34
- instanceState, isBooking, keyStart, linkCarries, notAnEvent, paragraphsHtml, sameValue, siteFields, siteUpdate, splitFrom, windowStart, type DateState, type Fields,
38
+ instanceState, isBooking, keyStart, linkCarries, notAnEvent, paragraphsHtml, sameButEnd, sameValue, siteFields, siteUpdate, splitFrom, splitKey, windowStart, withEndOf,
39
+ type DateState, type Fields,
35
40
  } from './map';
36
41
  import type { GcalItem, GcalLink, GoogleEvent, Hashes, NewLogEntry } from './types';
37
42
 
@@ -139,7 +144,11 @@ async function handleEvent(ctx: SyncContext, link: GcalLink, g: GoogleEvent, opt
139
144
  const item = await ctx.repo.gcal.getItem(link.id, g.id);
140
145
  if (g.status === 'cancelled') { if (item && !item.gone) await googleDeleted(ctx, link, item, opts); return; }
141
146
  if (item) {
142
- if (item.kind === 'segment') return;
147
+ if (item.kind === 'segment') {
148
+ if (item.gone) await segmentBack(ctx, link, item, opts);
149
+ await applySegmentChange(ctx, link, { ...item, gone: false }, g, opts);
150
+ return;
151
+ }
143
152
  if (item.gone) await backInGoogle(ctx, link, item, opts);
144
153
  await applyGoogleChange(ctx, link, { ...item, gone: false }, g, opts);
145
154
  return;
@@ -148,9 +157,18 @@ async function handleEvent(ctx: SyncContext, link: GcalLink, g: GoogleEvent, opt
148
157
  const base = splitFrom(g.id);
149
158
  if (base) {
150
159
  const from = await ctx.repo.gcal.getItem(link.id, base);
151
- if (from && from.kind !== 'segment') return void await recordSplit(ctx, link, from, g);
160
+ if (from && from.kind !== 'segment') return void await applySplit(ctx, link, from, g, opts);
152
161
  }
153
162
  const hidden = g.extendedProperties?.private;
163
+ if (hidden?.acmsEventId && hidden.acmsSite === ctx.origin && hidden.acmsSegment) {
164
+ // a later part's own series, copied there by the site: paired again (a resync, a new link)
165
+ const e = await ctx.repo.getEvent(hidden.acmsEventId);
166
+ const part = e ? seriesParts(e).find((p) => p.from === hidden.acmsSegment) : undefined;
167
+ const paired = e && (await ctx.repo.gcal.itemsForEvent(e.id)).some((i) => i.linkId === link.id && i.kind === 'segment' && i.segmentFrom === hidden.acmsSegment);
168
+ if (e && part && !paired && linkCarries(e, link)) return void await pairSegment(ctx, link, e, part, g);
169
+ // the site doesn't have that part any more: left as it is rather than made a second event
170
+ if (e) return;
171
+ }
154
172
  if (hidden?.acmsEventId && hidden.acmsSite === ctx.origin) {
155
173
  const e = await ctx.repo.getEvent(hidden.acmsEventId);
156
174
  const paired = e && (await ctx.repo.gcal.itemsForEvent(e.id)).some((i) => i.linkId === link.id && i.kind !== 'segment');
@@ -184,6 +202,17 @@ export async function pairExisting(ctx: SyncContext, link: GcalLink, e: CalEvent
184
202
  await ctx.repo.gcal.log([entry(link, item, e.title, { direction: 'info', action: 'paired', summary: `Paired with “${g.summary ?? ''}” in ${link.googleName} by ${why}. Both keep their titles until one is edited.` })], ctx.now);
185
203
  }
186
204
 
205
+ /** A later part's Google series paired as it is (a resync, a new link): both sides' fields become the agreed starting point. */
206
+ async function pairSegment(ctx: SyncContext, link: GcalLink, e: CalEvent, part: SeriesPart, g: GoogleEvent) {
207
+ const gf = googleFields(g, link, ctx.settings);
208
+ const item: GcalItem = {
209
+ linkId: link.id, googleId: g.id, eventId: e.id, icalUid: g.iCalUID ?? '', kind: 'segment', segmentFrom: part.from, etag: g.etag ?? '', htmlLink: g.htmlLink ?? '',
210
+ site: fieldHashes(siteFields(e, link, ctx.map, part)), google: gf ? fieldHashes(gf) : {}, descGoogle: false, booking: false, gone: false, leftOut: false, conflict: null, syncedAt: ctx.now, createdAt: ctx.now,
211
+ };
212
+ await ctx.repo.gcal.putItem(item);
213
+ await ctx.repo.gcal.log([entry(link, item, e.title, { direction: 'info', action: 'paired', summary: `Paired its dates from ${part.from.replace('T', ' ')} on with their series in ${link.googleName}, by its hidden link.` })], ctx.now);
214
+ }
215
+
187
216
  /** A new Google event: published on the site at once (Q5), in the link's calendar, holding its rooms. */
188
217
  async function createSiteEvent(ctx: SyncContext, link: GcalLink, g: GoogleEvent, opts: InboundOptions) {
189
218
  const gf = googleFields(g, link, ctx.settings);
@@ -233,17 +262,50 @@ async function backInGoogle(ctx: SyncContext, link: GcalLink, item: GcalItem, op
233
262
  await ctx.repo.gcal.log([entry(link, item, e?.title ?? '', { direction: 'in', action: 'restored', summary: `Back in ${link.googleName}: no longer cancelled.` })], ctx.now);
234
263
  }
235
264
 
236
- /** True when Google's rule is the site's with an earlier end (an end added or moved sooner): Google dropped dates the site still has. */
237
- function endsSooner(siteRule: string, googleRule: string): boolean {
238
- if (!siteRule || !googleRule) return false;
239
- const a = rruleMap(siteRule), b = rruleMap(googleRule);
240
- if (!b.UNTIL && !b.COUNT) return false;
241
- const rest = (m: Record<string, string>) => JSON.stringify(Object.entries(m).filter(([k]) => k !== 'UNTIL' && k !== 'COUNT').sort());
242
- if (rest(a) !== rest(b)) return false;
243
- if (!a.UNTIL && !a.COUNT) return true;
244
- if (a.COUNT && b.COUNT) return Number(b.COUNT) < Number(a.COUNT);
245
- if (a.UNTIL && b.UNTIL) return b.UNTIL.slice(0, 8) < a.UNTIL.slice(0, 8);
246
- return true;
265
+ const DAY = 86_400_000;
266
+ /** The site event's first date (any part, moves aside) starting after `ms`, as its key. */
267
+ function firstDateAfter(ctx: SyncContext, e: CalEvent, ms: number): string | null {
268
+ return expandEvents([{ ...e, movedDates: {} }], ctx.settings, { displayTz: ctx.settings.timezone || 'UTC', now: ms, windowStartMs: ms + 1, windowEndMs: ms + 800 * DAY })
269
+ .find((o) => o.start > ms)?.key ?? null;
270
+ }
271
+ /** When Google's series has its last date (UTC ms), or null when it goes on. */
272
+ function googleEnd(ctx: SyncContext, gf: Fields): number | null {
273
+ const parts = rruleMap(gf.repeat.rrule);
274
+ if (!gf.repeat.rrule || (!parts.UNTIL && !parts.COUNT)) return null;
275
+ const shape = asSiteEvent(gf, ctx.settings);
276
+ const dates = expandEvents([shape], ctx.settings, { displayTz: ctx.settings.timezone || 'UTC', now: shape.startAt, windowStartMs: shape.startAt, windowEndMs: shape.startAt + 20 * 366 * DAY });
277
+ return dates.length ? dates[dates.length - 1].start : shape.startAt;
278
+ }
279
+ /** A place for C4 from Google's: null when it's the event's own. */
280
+ const placeInput = (place: string, e: CalEvent) =>
281
+ place === locationLine(e.location) ? null : { name: place.slice(0, 300), address: '', placeId: '', lat: null, lng: null, mapImage: '', mapImageDark: '' };
282
+
283
+ /**
284
+ * A change to the end of the first part's rule in Google (its other parts
285
+ * alike). Google ending it where the site's next part takes over is the same
286
+ * cut ("this and following"): nothing to do. Ending it sooner dropped dates
287
+ * the site still has: they're cancelled from the first one on
288
+ * (cancelFromDate, Q6), and Google going on again past a cancelled part
289
+ * restores it. Returns what it did, or null when it's an ordinary rule change.
290
+ */
291
+ async function seriesEnd(ctx: SyncContext, link: GcalLink, e: CalEvent, gf: Fields, opts: InboundOptions): Promise<{ words: string } | null> {
292
+ if (!e.rrule || !sameButEnd(e.rrule, gf.repeat.rrule)) return null;
293
+ const parts = seriesParts(e);
294
+ const next = parts[1];
295
+ const end = googleEnd(ctx, gf);
296
+ const firstGone = end === null ? null : firstDateAfter(ctx, { ...e, segments: [] }, end);
297
+ const cut = next ? next.from.slice(0, 10) : null;
298
+ // Google's dates run to the site's next part: the same series
299
+ if (cut && firstGone && firstGone.slice(0, 10) >= cut) return { words: '' };
300
+ if (cut && end === null && next.cancelled) {
301
+ if (link.direction === 'out') return { words: '' };
302
+ await restoreFromDate(ctx.repo, GOOGLE_SYNC, e.id, next.from, ctx.hooks, { quiet: opts.quiet, now: ctx.now });
303
+ return { words: `Google’s series goes on again: the dates from ${next.from.replace('T', ' ')} on are back.` };
304
+ }
305
+ if (!firstGone) return null;
306
+ if (link.direction === 'out') return { words: '' };
307
+ await cancelFromDate(ctx.repo, GOOGLE_SYNC, e.id, firstGone, ctx.hooks, { quiet: opts.quiet, now: ctx.now });
308
+ return { words: `Google ended this series sooner: the dates from ${firstGone.replace('T', ' ')} on are cancelled here (restorable).` };
247
309
  }
248
310
 
249
311
  /** The value of one field from Google, for "Use Google's version" later. */
@@ -272,8 +334,8 @@ export async function applyGoogleChange(ctx: SyncContext, link: GcalLink, item:
272
334
  return;
273
335
  }
274
336
  const sf = siteFields(e, link, ctx.map), sh = fieldHashes(sf);
275
- const split = (await ctx.repo.gcal.itemsForEvent(e.id)).some((i) => i.linkId === link.id && i.kind === 'segment');
276
337
  const google: Hashes = { ...item.google }, site: Hashes = { ...item.site };
338
+ let reparted = false;
277
339
  const notes: NewLogEntry[] = [];
278
340
  const apply: string[] = [];
279
341
  let conflict = item.conflict, sendBack = false;
@@ -290,13 +352,19 @@ export async function applyGoogleChange(ctx: SyncContext, link: GcalLink, item:
290
352
  sendBack = true;
291
353
  continue;
292
354
  }
293
- if (split && (n === 'when' || n === 'repeat')) {
294
- notes.push(entry(link, item, e.title, { direction: 'info', action: 'split', summary: `${fieldWord(n)} changed when Google split this series (“this and following”). The site keeps one event as it was for now: change the later dates on the site by hand if needed.` }));
295
- continue;
296
- }
297
- if (n === 'repeat' && endsSooner(sf.repeat.rrule, gf.repeat.rrule)) {
298
- notes.push(entry(link, item, e.title, { direction: 'info', action: 'ended', summary: `Google ended this series sooner (${describe(n, gf)}). The site keeps its later dates: end the series or cancel those dates on the site if that’s right.` }));
299
- continue;
355
+ if (n === 'repeat' && !item.booking) {
356
+ try {
357
+ const done = await seriesEnd(ctx, link, e, gf, opts);
358
+ if (done) {
359
+ reparted = true;
360
+ if (done.words) notes.push(entry(link, item, e.title, { direction: 'in', action: 'ended', summary: done.words }));
361
+ continue;
362
+ }
363
+ } catch (err) {
364
+ rethrowGoogle(err);
365
+ notes.push(entry(link, item, e.title, { direction: 'info', action: 'ended', summary: `Google changed when this series ends (${describe(n, gf)}), but the site couldn’t follow: ${errorText(err)}` }));
366
+ continue;
367
+ }
300
368
  }
301
369
  // a Google → site link mirrors Google; otherwise a field both sides changed keeps the site's value (Q4)
302
370
  const siteChanged = (sh[n] ?? '') !== (item.site[n] ?? '');
@@ -311,7 +379,9 @@ export async function applyGoogleChange(ctx: SyncContext, link: GcalLink, item:
311
379
  apply.push(n);
312
380
  }
313
381
  if (apply.length) {
314
- const update = siteUpdate(e, gf, apply, { rules: link.rules, descGoogle: item.descGoogle, settings: ctx.settings });
382
+ // a series changed from a date on keeps its parts: the first part's own rule changes, its end stays where the next takes over
383
+ const parted = seriesParts(e).length > 1 ? { ...gf, repeat: { ...gf.repeat, rrule: withEndOf(gf.repeat.rrule, e.rrule) } } : gf;
384
+ const update = siteUpdate(e, parted, apply, { rules: link.rules, descGoogle: item.descGoogle, settings: ctx.settings });
315
385
  if (update) {
316
386
  const after = await writeSite(ctx, e, update, opts.quiet);
317
387
  if (update.tags) await ctx.repo.ensureTags(update.tags);
@@ -329,6 +399,13 @@ export async function applyGoogleChange(ctx: SyncContext, link: GcalLink, item:
329
399
  if (conflict && apply.includes(conflict.field)) conflict = null;
330
400
  } else for (const n of apply) setHash(site, n, sh[n] ?? '');
331
401
  }
402
+ if (reparted) {
403
+ // the parts moved (a cut, cancelled or restored dates): the first part's rule and dates as the site has them now
404
+ const now = fieldHashes(siteFields((await ctx.repo.getEvent(e.id)) ?? e, link, ctx.map));
405
+ setHash(site, 'repeat', now.repeat);
406
+ for (const k of Object.keys(site)) if (k.startsWith('date:')) delete site[k];
407
+ for (const [k, v] of Object.entries(now)) if (k.startsWith('date:')) site[k] = v;
408
+ }
332
409
  await ctx.repo.gcal.patchItem(link.id, item.googleId, { site, google, conflict, etag: g.etag ?? item.etag, htmlLink: g.htmlLink ?? item.htmlLink, kind: gf.repeat.rrule ? 'series' : 'single', syncedAt: ctx.now });
333
410
  await ctx.repo.gcal.log(notes, ctx.now);
334
411
  if (sendBack) await ctx.repo.gcal.enqueue([e.id], ctx.now);
@@ -337,12 +414,16 @@ export async function applyGoogleChange(ctx: SyncContext, link: GcalLink, item:
337
414
  /** Single dates of a series that changed in Google: cancelled, moved, or back to normal. */
338
415
  async function handleDates(ctx: SyncContext, link: GcalLink, masterId: string, list: GoogleEvent[], master: GoogleEvent | undefined, opts: InboundOptions) {
339
416
  const item = await ctx.repo.gcal.getItem(link.id, masterId);
340
- // dates of a later part (Google moved them to the split series), of a booking, or of a series no longer paired
341
- if (!item || item.kind === 'segment' || item.booking || item.gone) return;
417
+ // dates of a booking, or of a series no longer paired
418
+ if (!item || item.booking || item.gone) return;
342
419
  let e = await ctx.repo.getEvent(item.eventId);
343
420
  if (!e) return;
421
+ // a later part's dates (its own Google series): only while the site has that part
422
+ const partOfItem = (ev: CalEvent) => (item.kind === 'segment' ? seriesParts(ev).find((p) => p.from === item.segmentFrom) : undefined);
423
+ let part = partOfItem(e);
424
+ if (item.kind === 'segment' && (!part || part.cancelled)) return;
344
425
  const m = master ?? await ctx.client.getEvent(link.googleId, masterId).catch((err) => { if (err instanceof GoogleError && (err.code === 'notfound' || err.code === 'gone')) return null; throw err; });
345
- let sf = siteFields(e, link, ctx.map);
426
+ let sf = siteFields(e, link, ctx.map, part);
346
427
  // Google's dates read in the site event's zone, where its date keys are
347
428
  const gf = m && m.status !== 'cancelled' ? googleFields(m, link, ctx.settings, sf.when.tz) : null;
348
429
  if (!gf || !gf.repeat.rrule) return;
@@ -375,7 +456,8 @@ async function handleDates(ctx: SyncContext, link: GcalLink, masterId: string, l
375
456
  try {
376
457
  const words = await applyDate(ctx, e, key, state, mine, sf, opts);
377
458
  e = (await ctx.repo.getEvent(e.id))!;
378
- sf = siteFields(e, link, ctx.map);
459
+ part = partOfItem(e);
460
+ sf = siteFields(e, link, ctx.map, part);
379
461
  setHash(site, n, dateHash(sf.dates[key] ?? 'none'));
380
462
  notes.push(entry(link, item, e.title, { direction: 'in', action: 'date', summary: `${key.replace('T', ' ')}: ${words}` }));
381
463
  } catch (err) {
@@ -406,8 +488,17 @@ async function applyDate(ctx: SyncContext, e: CalEvent, key: string, state: Date
406
488
  async function googleDeleted(ctx: SyncContext, link: GcalLink, item: GcalItem, opts: InboundOptions) {
407
489
  const e = await ctx.repo.getEvent(item.eventId);
408
490
  if (item.kind === 'segment') {
409
- await ctx.repo.gcal.deleteItem(link.id, item.googleId);
410
- await ctx.repo.gcal.log([entry(link, item, e?.title ?? '', { direction: 'info', action: 'split', summary: `Google deleted the later part of this series${item.segmentFrom ? ` (from ${item.segmentFrom.replace('T', ' ')})` : ''}. The site keeps those dates: cancel them on the site if that’s right.` })], ctx.now);
491
+ const part = e ? seriesParts(e).find((p) => p.from === item.segmentFrom) : undefined;
492
+ // a split Google had at the first match, never on the site: nothing to cancel
493
+ if (!e || !part) { await ctx.repo.gcal.deleteItem(link.id, item.googleId); return; }
494
+ let words = `Google deleted the dates from ${item.segmentFrom.replace('T', ' ')} on: they’re cancelled here (restorable).`;
495
+ if (!part.cancelled && link.direction !== 'out') {
496
+ try { await cancelFromDate(ctx.repo, GOOGLE_SYNC, e.id, item.segmentFrom, ctx.hooks, { quiet: opts.quiet, now: ctx.now }); }
497
+ catch (err) { rethrowGoogle(err); words = `Google deleted the dates from ${item.segmentFrom.replace('T', ' ')} on, but the site couldn’t cancel them: ${errorText(err)}`; }
498
+ }
499
+ // kept as the pair: restoring the dates on the site (or in Google) makes the series again
500
+ await ctx.repo.gcal.patchItem(link.id, item.googleId, { gone: true, leftOut: false, syncedAt: ctx.now });
501
+ await ctx.repo.gcal.log([entry(link, item, e.title, { direction: 'in', action: 'cancelled', summary: words })], ctx.now);
411
502
  return;
412
503
  }
413
504
  if (!e) { await ctx.repo.gcal.deleteItem(link.id, item.googleId); return; }
@@ -437,23 +528,132 @@ async function googleDeleted(ctx: SyncContext, link: GcalLink, item: GcalItem, o
437
528
  await ctx.repo.gcal.log([entry(link, item, e.title, { direction: 'in', action: 'cancelled', summary: `Deleted in ${link.googleName}: cancelled on the site. Restore it there to bring it back in Google.` })], ctx.now);
438
529
  }
439
530
 
440
- /** Google split a series ("this and following"): the new series is linked to the same site event as a later part, listed for staff (C4 will apply these). */
441
- async function recordSplit(ctx: SyncContext, link: GcalLink, base: GcalItem, g: GoogleEvent) {
531
+ /**
532
+ * Google split a series ("this and following"): the site changes it from
533
+ * that date on (C4's changeFromDate, as GOOGLE_SYNC), and the new Google
534
+ * series becomes that later part's pair. The date is the one the split's id
535
+ * names, else the first date from the new part's first day. At the first
536
+ * match it's only recorded (both sides as they are, Q18).
537
+ */
538
+ async function applySplit(ctx: SyncContext, link: GcalLink, base: GcalItem, g: GoogleEvent, opts: InboundOptions) {
442
539
  const e = await ctx.repo.getEvent(base.eventId);
443
540
  const gf = googleFields(g, link, ctx.settings);
444
- // the first date of the site's series from the new part's first day on
541
+ const tz = e ? eventTimeZone(e, ctx.settings) : '';
542
+ const inZone = e ? googleFields(g, link, ctx.settings, e.allDay ? undefined : tz) : null;
445
543
  let from = '';
446
- if (e && gf) {
447
- const day = asSiteEvent({ ...gf, when: { ...gf.when, start: gf.when.start.slice(0, 10), end: gf.when.start.slice(0, 10), allDay: true } }, ctx.settings).startAt;
448
- from = expandEvents([{ ...e, movedDates: {} }], ctx.settings, { displayTz: ctx.settings.timezone || 'UTC', now: day, windowStartMs: day, windowEndMs: day + 400 * 86_400_000 })
449
- .find((o) => o.start >= day)?.key ?? '';
544
+ if (e && inZone) {
545
+ const key = splitKey(g.id, { allDay: e.allDay, tz });
546
+ from = key && findOccurrence({ ...e, movedDates: {} }, key, ctx.settings) ? key : '';
547
+ if (!from) {
548
+ const day = asSiteEvent({ ...inZone, when: { ...inZone.when, start: inZone.when.start.slice(0, 10), end: inZone.when.start.slice(0, 10), allDay: true } }, ctx.settings).startAt;
549
+ from = firstDateAfter(ctx, e, day - 1) ?? '';
550
+ }
450
551
  }
451
- await ctx.repo.gcal.putItem({
452
- linkId: link.id, googleId: g.id, eventId: base.eventId, icalUid: g.iCalUID ?? '', kind: 'segment', segmentFrom: from, etag: g.etag ?? '', htmlLink: g.htmlLink ?? '',
453
- site: {}, google: gf ? fieldHashes(gf) : {}, descGoogle: false, booking: false, gone: false, leftOut: false, conflict: null, syncedAt: ctx.now, createdAt: ctx.now,
552
+ const record = (site: GcalItem['site'], segmentFrom = from) => ctx.repo.gcal.putItem({
553
+ linkId: link.id, googleId: g.id, eventId: base.eventId, icalUid: g.iCalUID ?? '', kind: 'segment', segmentFrom, etag: g.etag ?? '', htmlLink: g.htmlLink ?? '',
554
+ site, google: gf ? fieldHashes(gf) : {}, descGoogle: false, booking: false, gone: false, leftOut: false, conflict: null, syncedAt: ctx.now, createdAt: ctx.now,
454
555
  });
455
- await ctx.repo.gcal.log([entry(link, base, e?.title ?? g.summary ?? '', { direction: 'info', action: 'split',
456
- summary: `Google changed this series from ${from ? from.replace('T', ' ') : 'a later date'} on (“this and following”)${gf ? `: ${describe('when', gf)}, ${describe('repeat', gf)}` : ''}. The site keeps one event as it was for now; change the later dates on the site by hand if needed.` })], ctx.now);
556
+ const date = from ? from.replace('T', ' ') : 'a later date';
557
+ const what = gf ? `: ${describe('when', gf)}, ${describe('repeat', gf)}` : '';
558
+ if (!e || !inZone || !from || opts.adopt?.has(base.googleId) || link.direction === 'out') {
559
+ await record({});
560
+ await ctx.repo.gcal.log([entry(link, base, e?.title ?? g.summary ?? '', { direction: 'info', action: 'split',
561
+ summary: `Google has this series changed from ${date} on (“this and following”)${what}. Both stay as they are until one of them changes.` })], ctx.now);
562
+ return;
563
+ }
564
+ try {
565
+ const r = await changeFromDate(ctx.repo, GOOGLE_SYNC, e.id, from, { start: inZone.when.start, end: inZone.when.end, rrule: inZone.repeat.rrule, location: placeInput(inZone.place, e) }, ctx.hooks, { quiet: opts.quiet, now: ctx.now });
566
+ const after = r.event;
567
+ const part = seriesParts(after).filter((p) => p.from && p.from <= from).pop();
568
+ await record(part ? fieldHashes(siteFields(after, link, ctx.map, part)) : {}, part?.from ?? from);
569
+ // the first part's rule now stops there: agreed for the base series too (Google's own cut is the same change)
570
+ const base2 = await ctx.repo.gcal.getItem(link.id, base.googleId);
571
+ if (base2) {
572
+ const now = fieldHashes(siteFields(after, link, ctx.map));
573
+ const site = { ...base2.site, repeat: now.repeat };
574
+ for (const k of Object.keys(site)) if (k.startsWith('date:')) delete site[k];
575
+ for (const [k, v] of Object.entries(now)) if (k.startsWith('date:')) site[k] = v;
576
+ await ctx.repo.gcal.patchItem(link.id, base.googleId, { site });
577
+ }
578
+ const rsvps = r.rsvps.moved || r.rsvps.cancelled ? ` Sign-ups: ${r.rsvps.moved} moved, ${r.rsvps.cancelled} cancelled.` : '';
579
+ const dropped = r.dropped.length ? ` Dates with no match: ${r.dropped.map((k) => k.replace('T', ' ')).join(', ')}.` : '';
580
+ await ctx.repo.gcal.log([entry(link, base, after.title, { direction: 'in', action: 'split',
581
+ summary: `Google changed this series from ${date} on (“this and following”)${what}. ${r.changed ? 'The site changed it from that date on too.' : 'The site already had it so.'}${rsvps}${dropped}` })], ctx.now);
582
+ const clash = r.clashes.length ? await clashNote(ctx, link, base, after) : null;
583
+ if (clash) await ctx.repo.gcal.log([clash], ctx.now);
584
+ } catch (err) {
585
+ rethrowGoogle(err);
586
+ await record({});
587
+ await ctx.repo.gcal.log([entry(link, base, e.title, { direction: 'info', action: 'split',
588
+ summary: `Google changed this series from ${date} on (“this and following”)${what}, but the site couldn’t follow: ${errorText(err)}` })], ctx.now);
589
+ }
590
+ }
591
+
592
+ /**
593
+ * A later part's own Google series changed: its time, rule or place change
594
+ * that part on the site (changeFromDate at the same date, replacing it); a
595
+ * part's own title or description isn't kept (one for the whole series). The
596
+ * site wins where both changed (Q4).
597
+ */
598
+ export async function applySegmentChange(ctx: SyncContext, link: GcalLink, item: GcalItem, g: GoogleEvent, opts: InboundOptions = { quiet: false }) {
599
+ const e = await ctx.repo.getEvent(item.eventId);
600
+ const gf = googleFields(g, link, ctx.settings);
601
+ if (!e || !gf) return;
602
+ const gh = fieldHashes(gf);
603
+ const chg = changed(masterHashes(gh), masterHashes(item.google));
604
+ if (!chg.length) {
605
+ if (g.etag && g.etag !== item.etag) await ctx.repo.gcal.patchItem(link.id, item.googleId, { etag: g.etag, htmlLink: g.htmlLink ?? item.htmlLink });
606
+ return;
607
+ }
608
+ const inZone = googleFields(g, link, ctx.settings, e.allDay ? undefined : eventTimeZone(e, ctx.settings))!;
609
+ const part = seriesParts(e).find((p) => p.from === item.segmentFrom);
610
+ const sf = part ? siteFields(e, link, ctx.map, part) : null, sh = sf ? fieldHashes(sf) : {};
611
+ const google: Hashes = { ...item.google }, site: Hashes = { ...item.site };
612
+ for (const n of chg) setHash(google, n, gh[n] ?? '');
613
+ const notes: NewLogEntry[] = [];
614
+ const date = item.segmentFrom.replace('T', ' ');
615
+ const own = chg.filter((n) => n === 'title' || n === 'desc' || n.startsWith('rule:'));
616
+ if (own.length) notes.push(entry(link, item, e.title, { direction: 'info', action: 'split', summary: `The dates from ${date} on have their own ${own.includes('desc') ? 'description' : 'title'} in Google. The site keeps one for the whole series, so that edit stays in Google only.` }));
617
+ let apply = chg.filter((n) => n === 'when' || n === 'repeat' || n === 'place');
618
+ let sendBack = false;
619
+ if (link.direction === 'out') apply = [];
620
+ else if (part && sf && link.direction !== 'in' && Object.keys(item.site).length) {
621
+ const both = apply.filter((n) => (sh[n] ?? '') !== (item.site[n] ?? '') && !sameValue(n, sf, inZone, link.rules));
622
+ if (both.length) {
623
+ notes.push(entry(link, item, e.title, { direction: 'clash', action: 'split', summary: `From ${date} on, ${both.map((n) => fieldWord(n).toLowerCase()).join(' and ')} changed on both sides: kept the site’s.` }));
624
+ apply = apply.filter((n) => !both.includes(n));
625
+ sendBack = true;
626
+ }
627
+ }
628
+ if (apply.length) {
629
+ try {
630
+ const r = await changeFromDate(ctx.repo, GOOGLE_SYNC, e.id, item.segmentFrom, { start: inZone.when.start, end: inZone.when.end, rrule: inZone.repeat.rrule, location: placeInput(inZone.place, e) }, ctx.hooks, { quiet: opts.quiet, now: ctx.now });
631
+ const next = seriesParts(r.event).find((p) => p.from === item.segmentFrom);
632
+ const sh2 = next ? fieldHashes(siteFields(r.event, link, ctx.map, next)) : {};
633
+ for (const n of apply) setHash(site, n, sh2[n] ?? '');
634
+ for (const k of Object.keys(site)) if (k.startsWith('date:')) delete site[k];
635
+ for (const [k, v] of Object.entries(sh2)) if (k.startsWith('date:')) site[k] = v;
636
+ if (r.changed) notes.unshift(entry(link, item, r.event.title, { direction: 'in', action: 'updated', summary: `From ${date} on: ${describe('when', inZone)}, ${describe('repeat', inZone)}${inZone.place ? `, at ${inZone.place}` : ''}.` }));
637
+ } catch (err) {
638
+ rethrowGoogle(err);
639
+ notes.push(entry(link, item, e.title, { direction: 'info', action: 'split', summary: `The dates from ${date} on changed in Google, but the site couldn’t follow: ${errorText(err)}` }));
640
+ }
641
+ }
642
+ await ctx.repo.gcal.patchItem(link.id, item.googleId, { site, google, etag: g.etag ?? item.etag, htmlLink: g.htmlLink ?? item.htmlLink, syncedAt: ctx.now });
643
+ await ctx.repo.gcal.log(notes, ctx.now);
644
+ if (sendBack) await ctx.repo.gcal.enqueue([e.id], ctx.now);
645
+ }
646
+
647
+ /** A later part's series Google brought back (its trash): the dates are restored on the site. */
648
+ async function segmentBack(ctx: SyncContext, link: GcalLink, item: GcalItem, opts: InboundOptions) {
649
+ const e = await ctx.repo.getEvent(item.eventId);
650
+ const part = e ? seriesParts(e).find((p) => p.from === item.segmentFrom) : undefined;
651
+ if (e && part?.cancelled && link.direction !== 'out') {
652
+ try { await restoreFromDate(ctx.repo, GOOGLE_SYNC, e.id, item.segmentFrom, ctx.hooks, { quiet: opts.quiet, now: ctx.now }); }
653
+ catch (err) { rethrowGoogle(err); }
654
+ }
655
+ await ctx.repo.gcal.patchItem(link.id, item.googleId, { gone: false, leftOut: false });
656
+ await ctx.repo.gcal.log([entry(link, item, e?.title ?? '', { direction: 'in', action: 'restored', summary: `The dates from ${item.segmentFrom.replace('T', ' ')} on are back in ${link.googleName}, and on the site.` })], ctx.now);
457
657
  }
458
658
 
459
659
  /** After a full read: pairs Google didn't list (deleted long ago, or a list that skipped them) are checked one by one. */
@@ -17,7 +17,8 @@
17
17
  import { createHash } from 'node:crypto';
18
18
  import type { CalEvent, Settings } from '../../schema';
19
19
  import { EventLocation, hasTag } from '../../schema';
20
- import { addWallDays, eventInstants, eventTimeZone, expandEvents, findOccurrence, formatWall, isValidTimeZone, locationLine, normalizeEventTimes, parseWall } from '../events';
20
+ import { addWallDays, cutsAt, eventInstants, eventTimeZone, expandEvents, findOccurrence, formatWall, isValidTimeZone, locationLine, normalizeEventTimes, parseWall, seriesParts, type SeriesPart } from '../events';
21
+ import { ruleUntil } from '../ics-write';
21
22
  import { tzParts, zonedToUtc } from '../ics';
22
23
  import { cleanRrule, describeRepeat, rruleMap } from '../rrule';
23
24
  import type { GcalLink, GoogleEvent, GoogleTime, Hashes, PrefixRule } from './types';
@@ -261,18 +262,30 @@ export function repeatFromGoogle(lines: string[] | undefined, tz: string, allDay
261
262
 
262
263
  // ---- each side's fields ----
263
264
 
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 {
265
+ /**
266
+ * The site event's synced fields: of its series' first part, or of `part`, a
267
+ * later one from "from this date on" (CalEvent.segments), which Google keeps
268
+ * as a series of its own. A part's rule stops where the next one takes over,
269
+ * as in the site's feeds. A series' changed dates count from the link's start
270
+ * date on, each with the part it belongs to.
271
+ */
272
+ export function siteFields(e: CalEvent, link: Pick<GcalLink, 'rules' | 'startDate'>, ctx: MapContext, part?: SeriesPart): SiteFields {
266
273
  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);
274
+ const parts = seriesParts(e);
275
+ const p = part ?? parts[0];
276
+ const when: When = e.allDay ? { allDay: true, start: p.start, end: p.end || p.start, tz: '' } : { allDay: false, start: p.start, end: p.end || p.start, tz };
277
+ // the part's own skipped dates: from where it takes over until the next part does
278
+ const mine = (key: string) => (!p.from || key.slice(0, 10) >= p.from.slice(0, 10)) && !cutsAt(p.until, key);
279
+ const rule = p.until ? ruleUntil(e, p, ctx.settings.timezone) ?? cleanRrule(p.rrule) : cleanRrule(p.rrule);
280
+ const repeat: Repeat = e.rrule ? { rrule: rule, exdates: [...new Set(e.exdates)].filter(mine).sort() } : { rrule: '', exdates: [] };
281
+ const place = locationLine(p.location ?? e.location);
270
282
  const dates: Record<string, DateState> = {};
271
283
  if (e.rrule) {
272
284
  const series = { ...e, movedDates: {} };
273
- for (const key of e.cancelledDates) if (key >= link.startDate && findOccurrence(series, key, ctx.settings)) dates[key] = 'cancelled';
285
+ const inPart = (key: string) => { const o = findOccurrence(series, key, ctx.settings); return !!o && (o.segment ?? '') === p.from; };
286
+ for (const key of e.cancelledDates) if (key >= link.startDate && inPart(key)) dates[key] = 'cancelled';
274
287
  for (const [key, m] of Object.entries(e.movedDates)) {
275
- if (key < link.startDate || dates[key] || !findOccurrence(series, key, ctx.settings)) continue;
288
+ if (key < link.startDate || dates[key] || !inPart(key)) continue;
276
289
  dates[key] = { start: m.start, end: m.end, place: m.location ? locationLine(m.location) : place };
277
290
  }
278
291
  }
@@ -430,6 +443,31 @@ export function isBooking(g: GoogleEvent, link: Pick<GcalLink, 'bookingTitles'>)
430
443
  /** A later part made by Google's "this and following" ("<series id>_R<when>"): the series it was split from. */
431
444
  export const splitFrom = (googleId: string) => /^(.+)_R\d{8}(?:T\d{6}Z?)?$/.exec(googleId)?.[1] ?? null;
432
445
 
446
+ /**
447
+ * Where a Google split takes over, from its id: "_R" and the original start
448
+ * of the date it was split at (UTC, as people see Google write it), as the
449
+ * site's occurrence key in the series' zone; null when the id has no time.
450
+ */
451
+ export function splitKey(googleId: string, when: Pick<When, 'allDay' | 'tz'>): string | null {
452
+ const m = /_R(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})Z?)?$/.exec(googleId);
453
+ if (!m) return null;
454
+ if (when.allDay) return `${m[1]}-${m[2]}-${m[3]}`;
455
+ if (!m[4]) return null;
456
+ return wallOfMs(Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]), when.tz || 'UTC');
457
+ }
458
+
459
+ /** Two rules alike but for their end (UNTIL or COUNT). */
460
+ export function sameButEnd(a: string, b: string): boolean {
461
+ const rest = (r: string) => JSON.stringify(Object.entries(rruleMap(r)).filter(([k]) => k !== 'UNTIL' && k !== 'COUNT').sort());
462
+ return !!a && !!b && rest(a) === rest(b);
463
+ }
464
+ /** A rule without its end, or with another rule's end. */
465
+ export function withEndOf(rule: string, endFrom: string): string {
466
+ const keep = Object.entries(rruleMap(rule)).filter(([k]) => k !== 'UNTIL' && k !== 'COUNT');
467
+ const end = Object.entries(rruleMap(endFrom)).filter(([k]) => k === 'UNTIL' || k === 'COUNT');
468
+ return cleanRrule([...keep, ...end].map(([k, v]) => `${k}=${v}`).join(';'));
469
+ }
470
+
433
471
  // ---- what Google gets ----
434
472
 
435
473
  /**
@@ -438,23 +476,25 @@ export const splitFrom = (googleId: string) => /^(.+)_R\d{8}(?:T\d{6}Z?)?$/.exec
438
476
  * again instead of making a second one. A copy made again after Google
439
477
  * deleted the first gets a new id (deleted ids stay taken).
440
478
  */
441
- export function googleIdFor(eventId: string, again = 0): string | undefined {
479
+ export function googleIdFor(eventId: string, again = 0, segment = ''): string | undefined {
442
480
  const hex = eventId.toLowerCase().replace(/-/g, '');
443
481
  if (!/^[0-9a-f]{32}$/.test(hex)) return undefined;
444
- return `acms${hex}${again ? `r${again.toString(32)}` : ''}`;
482
+ // a later part's copy ("from this date on"): its `from` key, digits only
483
+ return `acms${hex}${segment ? `s${segment.replace(/\D/g, '')}` : ''}${again ? `r${again.toString(32)}` : ''}`;
445
484
  }
446
485
 
447
- export const hiddenLink = (eventId: string, origin: string) => ({ private: { acmsEventId: eventId, acmsSite: origin } });
486
+ /** The hidden link on every Google copy (a later part's also names its `from`), so a re-link or a full resync finds the pairs again. */
487
+ export const hiddenLink = (eventId: string, origin: string, segment = '') => ({ private: { acmsEventId: eventId, acmsSite: origin, ...(segment ? { acmsSegment: segment } : {}) } });
448
488
 
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> {
489
+ /** A whole new Google event for the site event, or for one later part of its series (description left out while it's Google's). */
490
+ export function googleBody(e: CalEvent, f: SiteFields, link: Pick<GcalLink, 'rules'>, ctx: MapContext, opts: { desc: boolean; segment?: string }): Record<string, unknown> {
451
491
  return {
452
492
  summary: googleTitle(e, link.rules, ctx.words),
453
493
  ...(opts.desc ? { description: googleDescription(f.desc, f.link, ctx.words) } : {}),
454
494
  location: f.place,
455
495
  ...googleTimes(f.when),
456
496
  ...(f.repeat.rrule ? { recurrence: googleRecurrence(f.repeat, f.when) } : {}),
457
- extendedProperties: hiddenLink(e.id, ctx.origin),
497
+ extendedProperties: hiddenLink(e.id, ctx.origin, opts.segment),
458
498
  };
459
499
  }
460
500
 
@@ -16,7 +16,7 @@ import { hasTag } from '../../schema';
16
16
  import type { SyncContext } from './context';
17
17
  import { processEvents, listChanges, pairExisting } from './inbound';
18
18
  import { queueLinkCalendar } from './links';
19
- import { BOOKING_TAG, asSiteEvent, comparableTitle, googleFields, inWindow, isBooking, linkCarries, notAnEvent, windowStart } from './map';
19
+ import { BOOKING_TAG, asSiteEvent, comparableTitle, googleFields, inWindow, isBooking, linkCarries, notAnEvent, sameButEnd, windowStart } from './map';
20
20
  import type { GcalLink, MatchPair, MatchState } from './types';
21
21
 
22
22
  const REASON_WORDS: Record<MatchPair['reason'], string> = {
@@ -41,7 +41,8 @@ export async function buildMatch(ctx: SyncContext, link: GcalLink): Promise<Matc
41
41
  ['hidden-id', 'linked', (e, x) => x.g.extendedProperties?.private?.acmsEventId === e.id && x.g.extendedProperties?.private?.acmsSite === ctx.origin],
42
42
  ['uid', 'linked', (e, x) => !!e.sourceUid && e.sourceUid === x.g.iCalUID],
43
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],
44
+ // a series either side changed from a date on ends sooner: the same series all the same
45
+ ['time-repeat', 'suggested', (e, x) => e.startAt === x.shape.startAt && e.allDay === x.f.when.allDay && (e.rrule === x.f.repeat.rrule || sameButEnd(e.rrule, x.f.repeat.rrule))],
45
46
  ];
46
47
  for (const [reason, state, same] of passes) {
47
48
  for (const x of google) {