@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 +14 -0
- package/docs/calendar.md +5 -2
- package/docs/google-calendar.md +22 -9
- package/package.json +1 -1
- package/src/lib/calendar/google/inbound.ts +245 -45
- package/src/lib/calendar/google/map.ts +54 -14
- package/src/lib/calendar/google/match.ts +3 -2
- package/src/lib/calendar/google/outbound.ts +71 -25
- package/src/lib/calendar/ics-write.ts +3 -2
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"**
|
|
444
|
-
|
|
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
|
|
package/docs/google-calendar.md
CHANGED
|
@@ -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"**
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
@@ -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":
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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,
|
|
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')
|
|
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
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if (
|
|
246
|
-
|
|
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 (
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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
|
|
341
|
-
if (!item || item.
|
|
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
|
-
|
|
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
|
-
|
|
410
|
-
|
|
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
|
-
/**
|
|
441
|
-
|
|
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
|
-
|
|
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 &&
|
|
447
|
-
const
|
|
448
|
-
from =
|
|
449
|
-
|
|
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
|
-
|
|
452
|
-
linkId: link.id, googleId: g.id, eventId: base.eventId, icalUid: g.iCalUID ?? '', kind: 'segment', segmentFrom
|
|
453
|
-
site
|
|
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
|
-
|
|
456
|
-
|
|
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
|
-
/**
|
|
265
|
-
|
|
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
|
|
268
|
-
const
|
|
269
|
-
const
|
|
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
|
-
|
|
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] || !
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
100
|
-
await updateCopy(ctx, link, e, main
|
|
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
|
|
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
|
|
158
|
-
const
|
|
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:
|
|
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:
|
|
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,
|
|
212
|
-
|
|
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;
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
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);
|