@acms-subir/core 0.3.58 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.59
4
+
5
+ ### Patch Changes
6
+
7
+ - fa476b6: The Google Calendar sync now maps Google's "this and following" to "from this date on" (C4), both ways.
8
+
9
+ - A split in Google changes the site event from that date on (`changeFromDate` as `GOOGLE_SYNC`). It stays one event, with the same page, hosts and RSVPs, and sign-ups move to the nearest new date with the "new time" email. The new Google series becomes that later part's pair. Its later time, rule or place edits change the part again, and its single dates are cancelled and moved like any date.
10
+ - A change from a date on, made on the site, gives the later part its own Google series. The first series gets an UNTIL before that day, as in the site's own feeds. Undo removes the later series again.
11
+ - Deleting the later series in Google, or ending a series sooner, cancels those dates on the site from the first one on (`cancelFromDate`). They're struck through, their rooms are freed, and they can be restored. Restoring them on either side brings them back on the other.
12
+ - At the first match, a split Google already has is recorded as it is (Q18). Matching now pairs a Google series that was split with its site twin.
13
+ - Before this, splits were only listed for staff, and both Google series stayed linked to the one site event. Those pairs carry on: the next change on either side applies the new way.
14
+
15
+ No migration. Nothing to do after updating.
16
+
3
17
  ## 0.3.58
4
18
 
5
19
  ### Patch Changes
package/docs/calendar.md CHANGED
@@ -440,8 +440,11 @@ calendars in step, both ways, within a few minutes. The full guide is
440
440
  - **Booked appointments** from Google booking pages come in as "Booked" with the
441
441
  internal tag `#google-booking` (`GOOGLE_BOOKING_TAG`), holding the link's room. Treat
442
442
  them as bookings: the tag never shows publicly.
443
- - **"This and following"** in Google is listed for staff, and the site series stays whole
444
- until "from this date on" exists.
443
+ - **"This and following"** maps to "from this date on", both ways:
444
+ - A Google split changes the event from that date on (`changeFromDate` as `GOOGLE_SYNC`).
445
+ - A change from a date on gives the later part its own Google series.
446
+ - Deleting the later series in Google, or ending a series sooner, cancels those dates
447
+ (`cancelFromDate`), so they stay restorable.
445
448
  - Site code can read an event's Google copies with `googleCopies(repo, eventId)`, and
446
449
  Recent changes with `googleSyncHistory(repo)`.
447
450
 
@@ -185,12 +185,28 @@ A series is one event with a rule on both sides. Single dates:
185
185
  taken room only warns. A date moved on the site moves that Google instance.
186
186
  - **Per-date titles or descriptions** from Google aren't applied, because the
187
187
  site keeps one title for every date. They're listed under Recent changes.
188
- - **"This and following"** in Google splits the series in two. Until the site
189
- has "from this date on", the new Google series is linked to the same site
190
- event as a later part. The split is listed for staff, and the site series
191
- stays whole: its time and rule don't change from either side. Likewise, a
192
- series Google ends sooner keeps its later dates on the site, listed for
193
- staff to end or cancel.
188
+ - **"This and following"** maps to "from this date on", both ways.
189
+ - **In Google:** a split makes a second Google series. The site changes the
190
+ same event from that date on (`changeFromDate`, as `GOOGLE_SYNC`), so its
191
+ page, hosts and RSVPs stay. Sign-ups move to the nearest new date (with the
192
+ "new time" email). The date is the one Google split at: its id ends in
193
+ `_R` and that date's original start.
194
+ - **Pairs:** the new Google series becomes the later part's pair. Its later
195
+ time, rule or place edits change that part again. Its single dates are
196
+ cancelled and moved like any date. Its own title or description isn't kept,
197
+ because the site keeps one for the whole series.
198
+ - **On the site:** a change from a date on gives the later part its own
199
+ Google series (its hidden link names the part), and the first series gets
200
+ an UNTIL before that day, as in the site's feeds. **Undo** removes the
201
+ later series again.
202
+ - **Deleting:** deleting the later series in Google, or ending a series
203
+ sooner, cancels those dates on the site from the first one on
204
+ (`cancelFromDate`): struck through, rooms freed, restorable (Q6).
205
+ Restoring them, in Google or on the site, brings them back on the other
206
+ side. Dates cancelled from a date on, on the site, are removed from
207
+ Google, like a cancelled date's instance.
208
+ - **First match:** a split Google already has is recorded as it is. Neither
209
+ side changes until one of them does (Q18).
194
210
 
195
211
  ## Clashes
196
212
 
@@ -284,9 +300,6 @@ insert, update and delete of `events`) and `gcal_log`.
284
300
 
285
301
  - **Google push notifications** (`events.watch`) for near-instant updates.
286
302
  Reading every 2 minutes is the mechanism.
287
- - **"This and following"** as a real change on the site, once "from this date
288
- on" exists. Until then, splits are listed and both Google series stay linked
289
- to the one site event.
290
303
 
291
304
  ## Troubleshooting
292
305
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.58",
3
+ "version": "0.3.59",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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) {
@@ -8,14 +8,22 @@
8
8
  // title. Only the fields the sync owns are patched; guests are never added
9
9
  // and Google never emails anyone (sendUpdates=none).
10
10
  //
11
+ // A series changed "from this date on" (CalEvent.segments) is one Google
12
+ // series per part, as Google does for "this and following": the first part's
13
+ // rule stops where the next takes over, and each later part is its own
14
+ // series (a segment pair, its hidden link naming its `from`). A cancelled
15
+ // part has no Google copy (its dates are removed in Google, like a cancelled
16
+ // date's instance), and a part the site took back is removed from Google.
17
+ //
11
18
  // A change that already matches what the pair agreed is never sent, which is
12
19
  // what stops loops: the site's write of a change that came from Google leaves
13
20
  // nothing to send back to that link (only to the event's other links).
14
21
  import { hasTag, type CalEvent } from '../../schema';
22
+ import { seriesParts, type SeriesPart } from '../events';
15
23
  import type { SyncContext } from './context';
16
24
  import { entry } from './context';
17
25
  import { GoogleError } from './client';
18
- import { applyGoogleChange } from './inbound';
26
+ import { applyGoogleChange, applySegmentChange } from './inbound';
19
27
  import {
20
28
  BOOKING_TAG, NONE, changed, dateHash, fieldHashes, fieldWord, googleBody, googleFields, googleIdFor, googlePatch, inWindow, instanceBody,
21
29
  instanceState, keyStart, linkCarries, originalStart, siteFields, windowStart, type DateState, type Fields,
@@ -96,8 +104,43 @@ async function pushToLink(ctx: SyncContext, link: GcalLink, e: CalEvent | null,
96
104
  // deleted from this Google calendar while the event stays in its other copies: it stays out
97
105
  if (main.leftOut) return;
98
106
  // deleted in Google (and so cancelled here, Q6): made again only once the site brings it back
99
- if (main.gone) { if (!e.cancelled) await createCopy(ctx, link, e, main); return; }
100
- await updateCopy(ctx, link, e, main, mine.some((i) => i.kind === 'segment'));
107
+ if (main.gone) { if (!e.cancelled) await createCopy(ctx, link, e, main); }
108
+ else await updateCopy(ctx, link, e, main);
109
+ if (e.rrule || mine.some((i) => i.kind === 'segment')) {
110
+ // as they are now (taking Google's changes may have written the event)
111
+ const fresh = await ctx.repo.getEvent(e.id);
112
+ if (fresh) await pushParts(ctx, link, fresh, (await ctx.repo.gcal.itemsForEvent(e.id)).filter((i) => i.linkId === link.id));
113
+ }
114
+ }
115
+
116
+ /** The later parts of a series changed from a date on: one Google series each. */
117
+ async function pushParts(ctx: SyncContext, link: GcalLink, e: CalEvent, mine: GcalItem[]) {
118
+ const parts = e.rrule ? seriesParts(e).slice(1) : [];
119
+ const segments = mine.filter((i) => i.kind === 'segment');
120
+ for (const p of parts) {
121
+ const item = segments.find((i) => i.segmentFrom === p.from);
122
+ if (p.cancelled) {
123
+ // its dates are cancelled: removed in Google (kept as the pair, so restoring the part makes it again)
124
+ if (item && !item.gone) {
125
+ await ctx.client.deleteEvent(link.googleId, item.googleId);
126
+ await ctx.repo.gcal.patchItem(link.id, item.googleId, { gone: true, leftOut: false, syncedAt: ctx.now });
127
+ await ctx.repo.gcal.log([entry(link, item, e.title, { direction: 'out', action: 'removed', summary: `Dates from ${p.from.replace('T', ' ')} on cancelled on the site: removed from ${link.googleName}.` })], ctx.now);
128
+ }
129
+ continue;
130
+ }
131
+ if (!item) await createCopy(ctx, link, e, undefined, p);
132
+ else if (item.gone) await createCopy(ctx, link, e, item, p);
133
+ else await updateCopy(ctx, link, e, item, p);
134
+ }
135
+ for (const item of segments) {
136
+ if (parts.some((p) => p.from === item.segmentFrom)) continue;
137
+ // a split Google had at the first match, never on the site: Google keeps it as it is
138
+ if (!Object.keys(item.site).length) continue;
139
+ // the site took that change back ("Undo"): its Google series goes
140
+ if (!item.gone) await ctx.client.deleteEvent(link.googleId, item.googleId);
141
+ await ctx.repo.gcal.deleteItem(link.id, item.googleId);
142
+ await ctx.repo.gcal.log([entry(link, item, e.title, { direction: 'out', action: 'removed', summary: `The change from ${item.segmentFrom.replace('T', ' ')} on was undone on the site: its series was removed from ${link.googleName}.` })], ctx.now);
143
+ }
101
144
  }
102
145
 
103
146
  /** Google won't let the account change this event there: listed once, and the site's change counts as seen (no retries). */
@@ -115,7 +158,7 @@ async function unpair(ctx: SyncContext, link: GcalLink, item: GcalItem, e: CalEv
115
158
  }
116
159
 
117
160
  async function removeCopy(ctx: SyncContext, link: GcalLink, item: GcalItem, all: GcalItem[], e: CalEvent | null) {
118
- // the event's later parts from a Google split go with it
161
+ // the event's later parts (their own Google series) go with it
119
162
  for (const i of all) {
120
163
  if (!i.gone) await ctx.client.deleteEvent(link.googleId, i.googleId);
121
164
  await ctx.repo.gcal.deleteItem(link.id, i.googleId);
@@ -151,18 +194,19 @@ function written(patch: Record<string, unknown>, rules: GcalLink['rules']): stri
151
194
  }
152
195
  const masterHashes = (h: Hashes) => Object.fromEntries(Object.entries(h).filter(([k]) => !k.startsWith('date:')));
153
196
 
154
- /** A new Google copy (or a new one after Google deleted the old: `previous`), with the series' changed dates. */
155
- export async function createCopy(ctx: SyncContext, link: GcalLink, e: CalEvent, previous?: GcalItem) {
156
- const f = siteFields(e, link, ctx.map);
157
- const body = googleBody(e, f, link, ctx.map, { desc: true });
158
- const id = googleIdFor(e.id, previous ? Math.floor(ctx.now / 1000) : 0);
197
+ /** A new Google copy (or a new one after Google deleted the old: `previous`), with the series' changed dates; `part`: a later part's own series. */
198
+ export async function createCopy(ctx: SyncContext, link: GcalLink, e: CalEvent, previous?: GcalItem, part?: SeriesPart) {
199
+ const f = siteFields(e, link, ctx.map, part);
200
+ const segment = part?.from ?? '';
201
+ const body = googleBody(e, f, link, ctx.map, { desc: true, segment });
202
+ const id = googleIdFor(e.id, previous ? Math.floor(ctx.now / 1000) : 0, segment);
159
203
  let g: GoogleEvent;
160
204
  try { g = await ctx.client.insertEvent(link.googleId, id ? { id, ...body } : body); }
161
205
  catch (err) {
162
206
  if (!(err instanceof GoogleError && err.code === 'conflict' && id)) throw err;
163
207
  // that id is taken: our own copy from a run that stopped before saving the pair, or one Google deleted (ids stay taken)
164
208
  const found = await ctx.client.getEvent(link.googleId, id).catch(() => null);
165
- g = found && found.status !== 'cancelled' && found.extendedProperties?.private?.acmsEventId === e.id
209
+ g = found && found.status !== 'cancelled' && found.extendedProperties?.private?.acmsEventId === e.id && (found.extendedProperties.private.acmsSegment ?? '') === segment
166
210
  ? await ctx.client.patchEvent(link.googleId, id, body)
167
211
  : await ctx.client.insertEvent(link.googleId, body);
168
212
  }
@@ -177,12 +221,13 @@ export async function createCopy(ctx: SyncContext, link: GcalLink, e: CalEvent,
177
221
  else google[`date:${key}`] = dateHash(after);
178
222
  }
179
223
  const item: GcalItem = {
180
- linkId: link.id, googleId: g.id, eventId: e.id, icalUid: g.iCalUID ?? '', kind: e.rrule ? 'series' : 'single', segmentFrom: '', etag: g.etag ?? '', htmlLink: g.htmlLink ?? '',
224
+ linkId: link.id, googleId: g.id, eventId: e.id, icalUid: g.iCalUID ?? '', kind: segment ? 'segment' : e.rrule ? 'series' : 'single', segmentFrom: segment, etag: g.etag ?? '', htmlLink: g.htmlLink ?? '',
181
225
  site: fieldHashes(f), google, descGoogle: false, booking: false, gone: false, leftOut: false, conflict: null, syncedAt: ctx.now, createdAt: previous?.createdAt ?? ctx.now,
182
226
  };
183
227
  if (previous) await ctx.repo.gcal.replaceItem(previous.googleId, item); else await ctx.repo.gcal.putItem(item);
184
228
  await ctx.repo.gcal.log([entry(link, item, e.title, { direction: 'out', action: 'created',
185
- summary: previous ? `Back on the site: made again in ${link.googleName}.` : `Copied to ${link.googleName}${e.cancelled ? ' as cancelled' : ''}.` }), ...notes], ctx.now);
229
+ summary: segment ? `From ${segment.replace('T', ' ')} on, a series of its own in ${link.googleName} (“this and following”).`
230
+ : previous ? `Back on the site: made again in ${link.googleName}.` : `Copied to ${link.googleName}${e.cancelled ? ' as cancelled' : ''}.` }), ...notes], ctx.now);
186
231
  }
187
232
 
188
233
  /** Sets one date of the Google series; returns its state there afterwards (null: Google has no such date). */
@@ -207,9 +252,11 @@ async function writeDate(ctx: SyncContext, link: GcalLink, masterId: string, key
207
252
  }
208
253
  }
209
254
 
210
- /** Sends the site's changes since the pair last agreed (Google's own changes are taken first, field by field). */
211
- async function updateCopy(ctx: SyncContext, link: GcalLink, e: CalEvent, item: GcalItem, split: boolean) {
212
- let f = siteFields(e, link, ctx.map);
255
+ /** Sends the site's changes since the pair last agreed (Google's own changes are taken first, field by field); `part`: a later part's series. */
256
+ async function updateCopy(ctx: SyncContext, link: GcalLink, e: CalEvent, item: GcalItem, part?: SeriesPart) {
257
+ const partFrom = part?.from;
258
+ const partNow = (ev: CalEvent) => (partFrom === undefined ? undefined : seriesParts(ev).find((p) => p.from === partFrom));
259
+ let f = siteFields(e, link, ctx.map, part);
213
260
  if (!pendingSite(fieldHashes(f), item, link, {}).ch.length) return;
214
261
  let g: GoogleEvent | null = await ctx.client.getEvent(link.googleId, item.googleId).catch((err) => {
215
262
  if (err instanceof GoogleError && (err.code === 'notfound' || err.code === 'gone')) return null;
@@ -223,10 +270,12 @@ async function updateCopy(ctx: SyncContext, link: GcalLink, e: CalEvent, item: G
223
270
  let gf = googleFields(g, link, ctx.settings);
224
271
  // Google changed fields too: take those first (the site wins where both changed, Q4), then send what's left
225
272
  if (link.direction === 'both' && gf && changed(masterHashes(fieldHashes(gf)), masterHashes(item.google)).length) {
226
- await applyGoogleChange(ctx, link, item, g);
273
+ await (part ? applySegmentChange(ctx, link, item, g) : applyGoogleChange(ctx, link, item, g));
227
274
  const [again, fresh] = await Promise.all([ctx.repo.gcal.getItem(link.id, item.googleId), ctx.repo.getEvent(e.id)]);
228
275
  if (!again || !fresh || again.gone) return;
229
- item = again; e = fresh; f = siteFields(e, link, ctx.map);
276
+ item = again; e = fresh;
277
+ if (part) { part = partNow(e); if (!part || part.cancelled) return; }
278
+ f = siteFields(e, link, ctx.map, part);
230
279
  }
231
280
  const cur = fieldHashes(f);
232
281
  const pending = pendingSite(cur, item, link, gf ? fieldHashes(gf) : {});
@@ -239,10 +288,7 @@ async function updateCopy(ctx: SyncContext, link: GcalLink, e: CalEvent, item: G
239
288
  descGoogle = false;
240
289
  notes.push(entry(link, item, e.title, { direction: 'info', action: 'description', summary: 'Description edited on the site: from now on Google gets the site’s text (Q8).' }));
241
290
  }
242
- // a series Google split ("this and following") keeps its time and rule until the site has "from this date on"
243
- const held: string[] = split ? ch.filter((n) => n === 'when' || n === 'repeat') : [];
244
- if (held.length) notes.push(entry(link, item, e.title, { direction: 'info', action: 'split', summary: `${held.map(fieldWord).join(' and ')} changed on the site, but Google split this series (“this and following”), so ${held.length > 1 ? 'they weren’t' : 'it wasn’t'} sent. Change the later part in Google, or both by hand.` }));
245
- const fields = ch.filter((n) => !n.startsWith('date:') && !held.includes(n) && !((n === 'desc' || n === 'link') && descGoogle));
291
+ const fields = ch.filter((n) => !n.startsWith('date:') && !((n === 'desc' || n === 'link') && descGoogle));
246
292
  const patch = fields.length ? googlePatch(e, f, link, ctx.map, fields, { desc: !descGoogle, googleSummary: g.summary ?? '' }) : {};
247
293
  if (Object.keys(patch).length) g = await ctx.client.patchEvent(link.googleId, item.googleId, patch);
248
294
  gf = googleFields(g, link, ctx.settings) ?? gf;
@@ -252,7 +298,7 @@ async function updateCopy(ctx: SyncContext, link: GcalLink, e: CalEvent, item: G
252
298
 
253
299
  // single dates, once the series itself is right (read in the site event's zone, where its keys are)
254
300
  const inSiteZone = googleFields(g, link, ctx.settings, f.when.tz);
255
- if (!split && inSiteZone) for (const n of ch.filter((x) => x.startsWith('date:'))) {
301
+ if (inSiteZone) for (const n of ch.filter((x) => x.startsWith('date:'))) {
256
302
  const key = n.slice(5);
257
303
  if (keyStart(key, f.when, ctx.settings) <= ctx.now) continue;
258
304
  const want = f.dates[key] ?? 'none';
@@ -265,8 +311,8 @@ async function updateCopy(ctx: SyncContext, link: GcalLink, e: CalEvent, item: G
265
311
  for (const n of ch) { if (cur[n] === undefined) delete site[n]; else site[n] = cur[n]; }
266
312
  // the clash note stays until the site edits that field again (sending the site's winning value isn't an edit)
267
313
  const conflict = !item.conflict || !ch.includes(item.conflict.field) ? item.conflict : item.conflict.sent ? null : { ...item.conflict, sent: true };
268
- await ctx.repo.gcal.patchItem(link.id, item.googleId, { site, google, descGoogle, conflict, etag: g.etag ?? item.etag, htmlLink: g.htmlLink ?? item.htmlLink, kind: e.rrule ? 'series' : 'single', syncedAt: ctx.now });
269
- const sent = [...new Set(ch.filter((n) => !held.includes(n)).map((n) => n.startsWith('date:') ? 'dates' : n.startsWith('rule:') ? 'title' : n === 'status' ? (e.cancelled ? 'cancelled' : 'restored') : n === 'link' ? 'desc' : n))];
270
- if (sent.length) notes.unshift(entry(link, item, e.title, { direction: 'out', action: 'updated', summary: `${sent.map((n) => n === 'dates' ? 'Single dates' : n === 'cancelled' ? 'Cancelled' : n === 'restored' ? 'No longer cancelled' : fieldWord(n)).join(', ')} → ${link.googleName}` }));
314
+ await ctx.repo.gcal.patchItem(link.id, item.googleId, { site, google, descGoogle, conflict, etag: g.etag ?? item.etag, htmlLink: g.htmlLink ?? item.htmlLink, kind: part ? 'segment' : e.rrule ? 'series' : 'single', syncedAt: ctx.now });
315
+ const sent = [...new Set(ch.map((n) => n.startsWith('date:') ? 'dates' : n.startsWith('rule:') ? 'title' : n === 'status' ? (e.cancelled ? 'cancelled' : 'restored') : n === 'link' ? 'desc' : n))];
316
+ if (sent.length) notes.unshift(entry(link, item, e.title, { direction: 'out', action: 'updated', summary: `${sent.map((n) => n === 'dates' ? 'Single dates' : n === 'cancelled' ? 'Cancelled' : n === 'restored' ? 'No longer cancelled' : fieldWord(n)).join(', ')}${part ? ` (from ${part.from.replace('T', ' ')} on)` : ''} → ${link.googleName}` }));
271
317
  await ctx.repo.gcal.log(notes, ctx.now);
272
318
  }
@@ -93,8 +93,9 @@ export function eventToVevent(e: CalEvent, siteTz: string, opts: FeedOptions): s
93
93
  // A part's rule for the feed: stopped just before the next part's `from`
94
94
  // (RFC 5545 UNTIL is inclusive: the instant before it for timed events, the
95
95
  // day before for all-day ones). COUNT becomes that UNTIL when the rule would
96
- // still have dates there; null when the part has none left before it.
97
- function ruleUntil(e: CalEvent, p: SeriesPart, siteTz: string): string | null {
96
+ // still have dates there; null when the part has none left before it. The
97
+ // Google Calendar sync gives each part's Google series the same rule.
98
+ export function ruleUntil(e: CalEvent, p: SeriesPart, siteTz: string): string | null {
98
99
  const clean = cleanRrule(p.rrule);
99
100
  if (!p.until || !clean) return clean;
100
101
  const cut = parseWall(p.until);