@acms-subir/core 0.3.53 → 0.3.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/cli/main.mjs +2 -1
- package/cli/migrations.generated.mjs +76 -6
- package/docs/calendar.md +49 -2
- package/docs/google-calendar.md +302 -0
- package/docs/server.md +68 -1
- package/integration.mjs +15 -5
- package/package.json +1 -1
- package/src/components/LegalSettings.tsx +1 -1
- package/src/lib/api.ts +3 -0
- package/src/lib/backup/content.ts +4 -2
- package/src/lib/calendar/google/client.ts +167 -0
- package/src/lib/calendar/google/connect.ts +161 -0
- package/src/lib/calendar/google/context.ts +50 -0
- package/src/lib/calendar/google/inbound.ts +493 -0
- package/src/lib/calendar/google/links.ts +139 -0
- package/src/lib/calendar/google/map.ts +543 -0
- package/src/lib/calendar/google/match.ts +103 -0
- package/src/lib/calendar/google/migration.ts +71 -0
- package/src/lib/calendar/google/outbound.ts +272 -0
- package/src/lib/calendar/google/service.ts +193 -0
- package/src/lib/calendar/google/store.ts +241 -0
- package/src/lib/calendar/google/types.ts +188 -0
- package/src/lib/calendar/host-requests.ts +247 -0
- package/src/lib/calendar/member-form.ts +17 -11
- package/src/lib/calendar/member-words.ts +473 -0
- package/src/lib/calendar/members-config.ts +2 -2
- package/src/lib/calendar/moves.ts +81 -33
- package/src/lib/calendar/request-migration.ts +17 -0
- package/src/lib/calendar/request-store.ts +104 -0
- package/src/lib/calendar/requests.ts +24 -24
- package/src/lib/calendar/schedule.ts +8 -8
- package/src/lib/display/board.ts +3 -1
- package/src/lib/email/service.ts +22 -2
- package/src/lib/email/templates.ts +20 -2
- package/src/lib/legal/admin.ts +1 -1
- package/src/lib/legal/facts.ts +3 -0
- package/src/lib/legal/text-en.ts +4 -0
- package/src/lib/legal/text-es.ts +4 -0
- package/src/lib/live/crypto.ts +7 -28
- package/src/lib/repo/migrations.ts +8 -0
- package/src/lib/repo/sql.ts +25 -9
- package/src/lib/repo/types.ts +12 -3
- package/src/lib/scheduler.ts +3 -0
- package/src/lib/schema.ts +47 -0
- package/src/lib/sealed.ts +46 -0
- package/src/pages/admin/calendar/events/[id].astro +42 -1
- package/src/pages/admin/calendar/events/index.astro +82 -6
- package/src/pages/admin/calendar/google/match.astro +160 -0
- package/src/pages/admin/calendar/google.astro +107 -0
- package/src/pages/admin/calendar/settings.astro +273 -4
- package/src/pages/admin/index.astro +6 -2
- package/src/pages/admin/integrations.astro +6 -0
- package/src/pages/api/calendar/google/oauth/callback.ts +16 -0
- package/src/pages/members/_EventForm.astro +33 -27
- package/src/pages/members/_Layout.astro +25 -5
- package/src/pages/members/_http.ts +29 -6
- package/src/pages/members/events/[id].astro +21 -20
- package/src/pages/members/events/index.astro +295 -101
- package/src/pages/members/events/new.astro +11 -10
- package/src/server/index.ts +102 -0
- package/src/virtual.d.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.55
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 5ae8091: Two-way Google Calendar sync. Site calendars and Google calendars now stay in step, both ways: anything created, changed, moved, cancelled or deleted on one side shows up on the other within a few minutes, and Google keeps a full working copy if the site is ever down. It's in **Calendar settings → Synced with Google**, and the guide is `npx acms docs google-calendar`.
|
|
8
|
+
|
|
9
|
+
- **Connect Google** with its own OAuth client (it can share YouTube's Google Cloud project; disconnecting YouTube never cuts off the calendar). The sign-in works like YouTube's: a one-use pending state, a callback at `/api/calendar/google/oauth/callback` that only parks the code, and the signed-in admin finishing it. Tokens are sealed with `LIVE_ENCRYPTION_KEY`, else `SESSION_SECRET`, under their own key. No new environment variable.
|
|
10
|
+
- **Links:** one site calendar ⇄ one Google calendar, both ways, site → Google, or Google → site. A link can stand for rooms (events from it hold them, and site events holding them go there), so one site calendar can have a link per room, and one link can also take the events that hold no room. Title prefix ⇄ tag rules (`CLASS:` ⇄ `Class`) work both ways, with the prefix kept in the title.
|
|
11
|
+
- **First match:** a new link writes nothing until someone reviews what's on both sides and presses Start syncing. Pairs are found by the hidden link on Google copies, by Google's calendar ID (`sourceUid`), by title and time; same time and repeat with a different title is suggested. Linked pairs keep both titles as they are. Nothing sends member notifications.
|
|
12
|
+
- **What goes where:** title (main language), times and time zone, repeat rule and skipped dates, cancelled and moved single dates (through `cancelOccurrence` / `moveOccurrence` as `GOOGLE_SYNC`), place, description (Google's while the event is Google's, then the site's with "Details: <event page>"), rooms. A cancelled site event shows "Cancelled: " in Google. Unpublished or deleted on the site removes the Google copy. Deleted in Google cancels the site event (struck through, room freed, restorable), or, when the event has another linked copy, takes just that link's room or calendar off it. Hosts, RSVPs, private fields, pictures and other tags are never sent, guests are never added, and every write uses `sendUpdates=none`. Only owned fields are patched.
|
|
13
|
+
- **Titles:** cancelling or changing a tag on the site adjusts Google's own title rather than replacing it. Staged edits stay staged, and a staged field still at its old value follows Google's change.
|
|
14
|
+
- **Clashes:** field by field, each side keeps what it changed. When both changed a field, the site wins, and Google's value waits in Recent changes and the event editor with **Use Google's version**.
|
|
15
|
+
- **Booked appointments** from Google booking pages come in as "Booked", holding the link's room, with the internal tag `#google-booking`: a link names its booking pages, since Google's API has no marker for them.
|
|
16
|
+
- **"This and following"** edits in Google are listed for staff, and the site series stays whole until "from this date on" exists.
|
|
17
|
+
- **Safety:** links run only on the Site URL they were made on, pause after a restore until someone resumes them, and are never copied by environment sync or backups. Unlinking and disconnecting keep both copies. Google's "full sync required" (410) reads everything again without doubles or pushes. A revoked sign-in shows "Reconnect Google" and changes wait. Staff get one email when a link has failed for an hour. Recent changes are kept for 90 days.
|
|
18
|
+
- **For site code:** `googleCopies(repo, eventId)`, `googleSyncHistory(repo)`, `syncGoogleCalendar(repo, { locals })` and `GOOGLE_BOOKING_TAG` in `@acms-subir/core/server`. The event repository's `updateEvent` takes `{ keepDraft, quiet }`, and `cancelOccurrence` / `restoreOccurrence` take `{ quiet }`.
|
|
19
|
+
- The privacy notice gets a Google Calendar section, in English and Spanish, once Google is connected.
|
|
20
|
+
|
|
21
|
+
Migration 69 adds the sync's tables (`gcal_account`, `gcal_oauth_states`, `gcal_links`, `gcal_items`, `gcal_queue`, `gcal_log`) and triggers on `events` that queue changes for links. Sites with no links are unaffected. After `npx acms update`, nothing changes until someone connects Google. Set up the Google Cloud client as the guide describes. For a church on Google Workspace, use the "Internal" audience if the project belongs to the organization; otherwise use "External", set to "In production".
|
|
22
|
+
|
|
23
|
+
## 0.3.54
|
|
24
|
+
|
|
25
|
+
### Patch Changes
|
|
26
|
+
|
|
27
|
+
- 031e463: Hosts move single dates of their events from My events, and My events speaks the visitor's language.
|
|
28
|
+
|
|
29
|
+
- **Move… on each date.** Each upcoming date of a repeating event a host runs now has **Move…**, next to Cancel and Restore. It takes the date, the start and end, the date's rooms and a note for people who come. Hosts pick from the event's own rooms and the ones marked **Anyone can check if it’s free**. A date the host moved also gets **Move back**. People who signed up are emailed the new time, as with staff moves.
|
|
30
|
+
- **`acms({ members: { hostMoves } })`.** With `'direct'` (the default), a move applies at once while the rooms are free, and staff get the "Date moved" email. If a room is taken, the host sees which one and can **Send as a request** instead. With `'approve'`, every host move is a request.
|
|
31
|
+
- **Requests.** Host requests wait in Schedule → List → **Requests** under "Date changes from hosts", with what else holds the room at the new time. **Approve** makes the move (a clash only warns) and records it as the host's. **Decline…** keeps the date and sends the host your note. Staff get a "Date move to review" email, and the host hears the answer. The requests are stored on their own rows, in a new `event_requests` table (migration 68). Requests from people without an account will share that table.
|
|
32
|
+
- **Server API.** `@acms-subir/core/server` adds `moveHostedDate`, `moveHostedDateBack`, `requestDateMove`, `listMyDateRequests`, `withdrawDateRequest`, `approveDateRequest` and `declineDateRequest`. Member errors now carry a `code`.
|
|
33
|
+
- **My events under `/es`.** The pages, buttons, notices, the request form and their errors follow the address, so `/es/members/events` is in Spanish, including the event's translated title and the dates. The words exist in English, Spanish, French, German and Portuguese.
|
|
34
|
+
|
|
3
35
|
## 0.3.53
|
|
4
36
|
|
|
5
37
|
### Patch Changes
|
package/cli/main.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { addMobile, mobileCommand, listApps } from './mobile.mjs';
|
|
|
10
10
|
import { mobileDoctor, renderDoctor } from './mobile-doctor.mjs';
|
|
11
11
|
import { monitorCommand, renderMonitor } from './monitor.mjs';
|
|
12
12
|
const HELP=`acms — ACMS project, theme and agent tools
|
|
13
|
-
Discovery: inspect | schema theme | docs theme | docs sections | docs mobile | docs sync | docs server | docs calendar
|
|
13
|
+
Discovery: inspect | schema theme | docs theme | docs sections | docs mobile | docs sync | docs server | docs calendar | docs google-calendar
|
|
14
14
|
agent init [--mobile] write .agents/skills (theme; mobile when an app exists)
|
|
15
15
|
Check: check [--build] validate every project theme, refresh section types,
|
|
16
16
|
report sections without a native app version
|
|
@@ -141,6 +141,7 @@ export async function main() {
|
|
|
141
141
|
else if(cmd==='docs'&&sub==='sync')data={markdown:await readFile(resolve(CORE,'docs/environment-sync.md'),'utf8')};
|
|
142
142
|
else if(cmd==='docs'&&sub==='server')data={markdown:await readFile(resolve(CORE,'docs/server.md'),'utf8')};
|
|
143
143
|
else if(cmd==='docs'&&sub==='calendar')data={markdown:await readFile(resolve(CORE,'docs/calendar.md'),'utf8')};
|
|
144
|
+
else if(cmd==='docs'&&sub==='google-calendar')data={markdown:await readFile(resolve(CORE,'docs/google-calendar.md'),'utf8')};
|
|
144
145
|
else if(cmd==='section')data=await sectionCommand(sub,rest,flags);
|
|
145
146
|
else if(cmd==='check') {
|
|
146
147
|
const p=await project(),themes=(await listLocalThemes(p)).filter(t=>t.origin==='project');
|
|
@@ -447,6 +447,70 @@ var SCREENS_MIGRATION = {
|
|
|
447
447
|
]
|
|
448
448
|
};
|
|
449
449
|
|
|
450
|
+
// src/lib/calendar/request-migration.ts
|
|
451
|
+
var REQUEST_TABLE = [
|
|
452
|
+
`CREATE TABLE event_requests (
|
|
453
|
+
id TEXT PRIMARY KEY, kind TEXT NOT NULL, state TEXT NOT NULL, source TEXT NOT NULL,
|
|
454
|
+
user_id TEXT NOT NULL DEFAULT '', requester TEXT,
|
|
455
|
+
event_id TEXT NOT NULL DEFAULT '', occurrence_key TEXT NOT NULL DEFAULT '',
|
|
456
|
+
data TEXT NOT NULL DEFAULT '{}', note TEXT NOT NULL DEFAULT '', reply TEXT NOT NULL DEFAULT '',
|
|
457
|
+
reviewed_by TEXT NOT NULL DEFAULT '', reviewed_at INTEGER NOT NULL DEFAULT 0,
|
|
458
|
+
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
|
459
|
+
)`,
|
|
460
|
+
`CREATE INDEX event_requests_state ON event_requests (state, created_at)`,
|
|
461
|
+
`CREATE INDEX event_requests_event ON event_requests (event_id)`
|
|
462
|
+
];
|
|
463
|
+
|
|
464
|
+
// src/lib/calendar/google/migration.ts
|
|
465
|
+
var now5 = "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
|
|
466
|
+
var guard = "(SELECT suppressed FROM webhook_control WHERE id=1)=0 AND EXISTS (SELECT 1 FROM gcal_links)";
|
|
467
|
+
var enqueue3 = (id) => `INSERT INTO gcal_queue (event_id,version,due_at,attempts,claimed_until,error) VALUES (${id},1,${now5},0,0,'')
|
|
468
|
+
ON CONFLICT(event_id) DO UPDATE SET version=version+1,due_at=excluded.due_at,attempts=0,error='';`;
|
|
469
|
+
var GCAL_MIGRATION = { version: 69, statements: [
|
|
470
|
+
`CREATE TABLE gcal_account (
|
|
471
|
+
id INTEGER PRIMARY KEY CHECK(id=1), client_id TEXT NOT NULL, encrypted TEXT NOT NULL, email TEXT NOT NULL DEFAULT '',
|
|
472
|
+
status TEXT NOT NULL DEFAULT 'setup', error TEXT NOT NULL DEFAULT '', site_url TEXT NOT NULL DEFAULT '',
|
|
473
|
+
calendars TEXT NOT NULL DEFAULT '[]', calendars_at INTEGER NOT NULL DEFAULT 0, connected_by TEXT NOT NULL DEFAULT '',
|
|
474
|
+
connected_at INTEGER NOT NULL DEFAULT 0, revision INTEGER NOT NULL DEFAULT 1, updated_at INTEGER NOT NULL DEFAULT 0
|
|
475
|
+
)`,
|
|
476
|
+
`CREATE TABLE gcal_oauth_states (hash TEXT PRIMARY KEY, session_hash TEXT NOT NULL, revision INTEGER NOT NULL, encrypted TEXT NOT NULL, expires_at INTEGER NOT NULL)`,
|
|
477
|
+
`CREATE TABLE gcal_links (
|
|
478
|
+
id TEXT PRIMARY KEY, calendar_id TEXT NOT NULL, google_id TEXT NOT NULL UNIQUE, google_name TEXT NOT NULL DEFAULT '', google_tz TEXT NOT NULL DEFAULT '',
|
|
479
|
+
direction TEXT NOT NULL DEFAULT 'both' CHECK(direction IN ('both','out','in')), room_ids TEXT NOT NULL DEFAULT '[]',
|
|
480
|
+
no_room INTEGER NOT NULL DEFAULT 0, rules TEXT NOT NULL DEFAULT '[]', booking_titles TEXT NOT NULL DEFAULT '[]', start_date TEXT NOT NULL,
|
|
481
|
+
state TEXT NOT NULL DEFAULT 'matching' CHECK(state IN ('matching','active','paused')), paused_reason TEXT NOT NULL DEFAULT '',
|
|
482
|
+
site_url TEXT NOT NULL, matched_at INTEGER NOT NULL DEFAULT 0, match TEXT,
|
|
483
|
+
sync_token TEXT NOT NULL DEFAULT '', full_sync INTEGER NOT NULL DEFAULT 1,
|
|
484
|
+
lease TEXT NOT NULL DEFAULT '', lease_until INTEGER NOT NULL DEFAULT 0, next_at INTEGER NOT NULL DEFAULT 0,
|
|
485
|
+
failures INTEGER NOT NULL DEFAULT 0, failing_since INTEGER NOT NULL DEFAULT 0, alerted_at INTEGER NOT NULL DEFAULT 0,
|
|
486
|
+
last_ok_at INTEGER NOT NULL DEFAULT 0, error TEXT NOT NULL DEFAULT '', revision INTEGER NOT NULL DEFAULT 1,
|
|
487
|
+
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
|
488
|
+
)`,
|
|
489
|
+
`CREATE TABLE gcal_items (
|
|
490
|
+
link_id TEXT NOT NULL, google_id TEXT NOT NULL, event_id TEXT NOT NULL, ical_uid TEXT NOT NULL DEFAULT '',
|
|
491
|
+
kind TEXT NOT NULL DEFAULT 'single' CHECK(kind IN ('series','single','segment')), segment_from TEXT NOT NULL DEFAULT '',
|
|
492
|
+
etag TEXT NOT NULL DEFAULT '', html_link TEXT NOT NULL DEFAULT '', site TEXT NOT NULL DEFAULT '{}', google TEXT NOT NULL DEFAULT '{}',
|
|
493
|
+
desc_google INTEGER NOT NULL DEFAULT 0, booking INTEGER NOT NULL DEFAULT 0, gone INTEGER NOT NULL DEFAULT 0, conflict TEXT,
|
|
494
|
+
synced_at INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL,
|
|
495
|
+
PRIMARY KEY (link_id, google_id), UNIQUE (link_id, event_id, segment_from)
|
|
496
|
+
)`,
|
|
497
|
+
`CREATE INDEX gcal_items_event ON gcal_items (event_id)`,
|
|
498
|
+
`CREATE TABLE gcal_queue (
|
|
499
|
+
event_id TEXT PRIMARY KEY, version INTEGER NOT NULL DEFAULT 1, due_at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0,
|
|
500
|
+
claimed_until INTEGER NOT NULL DEFAULT 0, error TEXT NOT NULL DEFAULT ''
|
|
501
|
+
)`,
|
|
502
|
+
`CREATE TABLE gcal_log (
|
|
503
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, at INTEGER NOT NULL, link_id TEXT NOT NULL DEFAULT '', event_id TEXT NOT NULL DEFAULT '',
|
|
504
|
+
google_id TEXT NOT NULL DEFAULT '', direction TEXT NOT NULL CHECK(direction IN ('in','out','clash','info')), action TEXT NOT NULL,
|
|
505
|
+
title TEXT NOT NULL DEFAULT '', summary TEXT NOT NULL DEFAULT '', data TEXT, resolved INTEGER NOT NULL DEFAULT 0
|
|
506
|
+
)`,
|
|
507
|
+
`CREATE INDEX gcal_log_at ON gcal_log (at)`,
|
|
508
|
+
`CREATE INDEX gcal_log_event ON gcal_log (event_id, at)`,
|
|
509
|
+
`CREATE TRIGGER gcal_queue_insert AFTER INSERT ON events WHEN ${guard} BEGIN ${enqueue3("NEW.id")} END`,
|
|
510
|
+
`CREATE TRIGGER gcal_queue_update AFTER UPDATE ON events WHEN ${guard} BEGIN ${enqueue3("NEW.id")} END`,
|
|
511
|
+
`CREATE TRIGGER gcal_queue_delete AFTER DELETE ON events WHEN ${guard} BEGIN ${enqueue3("OLD.id")} END`
|
|
512
|
+
] };
|
|
513
|
+
|
|
450
514
|
// src/lib/repo/migrations.ts
|
|
451
515
|
var CONTENT_VERSION_COLUMNS = {
|
|
452
516
|
settings: [
|
|
@@ -1281,7 +1345,13 @@ var MIGRATIONS = [
|
|
|
1281
1345
|
// row, so the triggers on events (content version, event_changes) see a move
|
|
1282
1346
|
MOVED_DATES_MIGRATION(contentVersionTriggers("events", [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates", "moved_dates"])[2]),
|
|
1283
1347
|
// saved TV screens (Admin → Calendar → Screens, lib/display/store.ts)
|
|
1284
|
-
SCREENS_MIGRATION
|
|
1348
|
+
SCREENS_MIGRATION,
|
|
1349
|
+
// calendar requests on their own rows (RequestRecord): new events and
|
|
1350
|
+
// changes asked for without an account, and hosts' date moves
|
|
1351
|
+
{ version: 68, statements: REQUEST_TABLE },
|
|
1352
|
+
// two-way Google Calendar sync: the account, links, pairs, outgoing queue
|
|
1353
|
+
// (triggers on events) and Recent changes. Not public, so no cache trigger.
|
|
1354
|
+
GCAL_MIGRATION
|
|
1285
1355
|
];
|
|
1286
1356
|
var CURRENT_CONTENT_VERSION_COLUMNS = {
|
|
1287
1357
|
...CONTENT_VERSION_COLUMNS,
|
|
@@ -1310,7 +1380,7 @@ async function pendingMigrations(db) {
|
|
|
1310
1380
|
return { applied, pending: MIGRATIONS.filter((m) => m.version > applied) };
|
|
1311
1381
|
}
|
|
1312
1382
|
async function runMigrations(db, opts = {}) {
|
|
1313
|
-
const
|
|
1383
|
+
const now6 = opts.now ?? Date.now;
|
|
1314
1384
|
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1315
1385
|
const first = await pendingMigrations(db);
|
|
1316
1386
|
if (!first.pending.length) return { from: first.applied, to: first.applied, applied: [] };
|
|
@@ -1318,10 +1388,10 @@ async function runMigrations(db, opts = {}) {
|
|
|
1318
1388
|
if (refused) return { from: first.applied, to: first.applied, applied: [], deferred: refused.version };
|
|
1319
1389
|
await db.run(MIGRATION_LOCK_TABLE);
|
|
1320
1390
|
const owner = crypto.randomUUID();
|
|
1321
|
-
const deadline =
|
|
1391
|
+
const deadline = now6() + (opts.waitMs ?? 6e4);
|
|
1322
1392
|
let waited = false;
|
|
1323
1393
|
for (; ; ) {
|
|
1324
|
-
const t =
|
|
1394
|
+
const t = now6();
|
|
1325
1395
|
await db.run(
|
|
1326
1396
|
`INSERT INTO migration_lock (id, owner, expires_at) VALUES (1, ?, ?)
|
|
1327
1397
|
ON CONFLICT(id) DO UPDATE SET owner = excluded.owner, expires_at = excluded.expires_at WHERE migration_lock.expires_at < ?`,
|
|
@@ -1339,13 +1409,13 @@ async function runMigrations(db, opts = {}) {
|
|
|
1339
1409
|
try {
|
|
1340
1410
|
const { applied, pending } = await pendingMigrations(db);
|
|
1341
1411
|
if (applied === 1 && !await appliedVersion(db)) {
|
|
1342
|
-
await db.run("INSERT INTO migrations (version, applied_at) VALUES (1, ?)", [
|
|
1412
|
+
await db.run("INSERT INTO migrations (version, applied_at) VALUES (1, ?)", [now6()]);
|
|
1343
1413
|
}
|
|
1344
1414
|
for (const m of pending) {
|
|
1345
1415
|
opts.log?.(`Applying migration ${m.version}`);
|
|
1346
1416
|
await db.batch([
|
|
1347
1417
|
...m.statements.map((sql) => ({ sql })),
|
|
1348
|
-
{ sql: "INSERT INTO migrations (version, applied_at) VALUES (?, ?)", args: [m.version,
|
|
1418
|
+
{ sql: "INSERT INTO migrations (version, applied_at) VALUES (?, ?)", args: [m.version, now6()] }
|
|
1349
1419
|
]);
|
|
1350
1420
|
}
|
|
1351
1421
|
return { from: applied, to: pending.length ? pending[pending.length - 1].version : applied, applied: pending.map((m) => m.version) };
|
package/docs/calendar.md
CHANGED
|
@@ -99,8 +99,9 @@ and `moveOccurrenceBack(repo, who, eventId, key, ctx, { quiet? })` check who is
|
|
|
99
99
|
- **`GOOGLE_SYNC`** is a calendar sync acting with staff rights. A clash only warns,
|
|
100
100
|
since the change already happened in Google.
|
|
101
101
|
- **A host of the event** may move its dates while the rooms are free; a taken room
|
|
102
|
-
refuses the move (409).
|
|
103
|
-
|
|
102
|
+
refuses the move (409). They pick rooms among the event's own and the rooms marked
|
|
103
|
+
**Anyone can check if it’s free** (403 `room-not-allowed` otherwise). Staff
|
|
104
|
+
(`members.notify`, else the owners) are emailed about each move a host makes.
|
|
104
105
|
- Anyone else gets 404, and nobody signed in gets 401.
|
|
105
106
|
|
|
106
107
|
`end` defaults to the date's own length. `roomIds`, `location` and `note` replace the
|
|
@@ -114,6 +115,22 @@ write, webhook, push or email. A sync can retry, or re-apply what it already app
|
|
|
114
115
|
safely. Cancelling a date that's already cancelled, or restoring one that isn't, is a
|
|
115
116
|
no-op too, even after the date has passed.
|
|
116
117
|
|
|
118
|
+
### Hosts on My events
|
|
119
|
+
|
|
120
|
+
Hosts move dates from **My events** (`/members/events`): each upcoming date has
|
|
121
|
+
**Move…** next to **Cancel**, and a date they moved has **Move back**.
|
|
122
|
+
`acms({ members: { hostMoves } })` decides how a host's move applies:
|
|
123
|
+
|
|
124
|
+
- `'direct'` (the default): at once while the date's rooms are free, as above. When a
|
|
125
|
+
room is taken the host sees which one and can **Send as a request** instead.
|
|
126
|
+
- `'approve'`: every host move, and every move back, is a request.
|
|
127
|
+
|
|
128
|
+
Requests wait in Schedule → List → **Requests** under "Date changes from hosts", with
|
|
129
|
+
what else holds the rooms at the new time. **Approve** makes the move as you (a clash
|
|
130
|
+
only warns) and records it as the host's. **Decline…** keeps the date and sends the host
|
|
131
|
+
your note. Both answers email the host. The server API is `moveHostedDate`,
|
|
132
|
+
`requestDateMove` and `approveDateRequest` in `npx acms docs server`.
|
|
133
|
+
|
|
117
134
|
Errors carry a `status`, a message safe to show people, and a `code`:
|
|
118
135
|
|
|
119
136
|
| status | code | when |
|
|
@@ -127,6 +144,7 @@ Errors carry a `status`, a message safe to show people, and a `code`:
|
|
|
127
144
|
| 409 | `cancelled-event` | the whole event is cancelled |
|
|
128
145
|
| 409 | `cancelled-date` | the date is cancelled (restore it first) |
|
|
129
146
|
| 409 | `room-taken` | a host moving into a taken room |
|
|
147
|
+
| 403 | `room-not-allowed` | a host picking a room the event doesn't use and nobody may request |
|
|
130
148
|
| 401, 404 | | who is acting |
|
|
131
149
|
|
|
132
150
|
## Rooms
|
|
@@ -268,3 +286,32 @@ A theme's `theme.css` loads after the board's own styles. The simplest way to re
|
|
|
268
286
|
```
|
|
269
287
|
|
|
270
288
|
Keep blocks flat: the built-in look has no accent stripes or gradients. The display leaves out the site's header, footer, head code and analytics. A site file at `src/pages/display/calendar.astro` (or `display/s/[key].astro`) replaces that page.
|
|
289
|
+
|
|
290
|
+
## Google Calendar sync
|
|
291
|
+
|
|
292
|
+
Calendar settings → **Synced with Google** keeps linked site calendars and Google
|
|
293
|
+
calendars in step, both ways, within a few minutes. The full guide is
|
|
294
|
+
`acms docs google-calendar`. What it means for the calendar features above:
|
|
295
|
+
|
|
296
|
+
- **Single dates.** A date deleted or moved in Google goes through `cancelOccurrence`
|
|
297
|
+
and `moveOccurrence` with the `GOOGLE_SYNC` actor, so RSVPs, room holds and pushes
|
|
298
|
+
follow as they do for staff. A taken room only warns. Cancelled and moved dates on the
|
|
299
|
+
site change that date's instance in Google.
|
|
300
|
+
- **Rooms.** A link can stand for one or more rooms: events from that Google calendar
|
|
301
|
+
hold them, and site events holding them go there. One site calendar can have a link
|
|
302
|
+
per room.
|
|
303
|
+
- **Deleted in Google** means **cancelled** on the site (struck through, room freed,
|
|
304
|
+
restorable), never deleted. The exception is an event with another linked copy: only
|
|
305
|
+
that link's room or calendar comes off it.
|
|
306
|
+
- **Cancelled on the site** shows in Google as "Cancelled: " in front of the title.
|
|
307
|
+
Unpublished or deleted on the site removes the Google copy.
|
|
308
|
+
- **Writes from the sync** keep staged edits staged (`updateEvent(id, data, { keepDraft: true })`).
|
|
309
|
+
Changes from Google push to members like an admin edit, except during the first match
|
|
310
|
+
and full resyncs (`quiet`).
|
|
311
|
+
- **Booked appointments** from Google booking pages come in as "Booked" with the
|
|
312
|
+
internal tag `#google-booking` (`GOOGLE_BOOKING_TAG`), holding the link's room. Treat
|
|
313
|
+
them as bookings: the tag never shows publicly.
|
|
314
|
+
- **"This and following"** in Google is listed for staff, and the site series stays whole
|
|
315
|
+
until "from this date on" exists.
|
|
316
|
+
- Site code can read an event's Google copies with `googleCopies(repo, eventId)`, and
|
|
317
|
+
Recent changes with `googleSyncHistory(repo)`.
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# Google Calendar sync
|
|
2
|
+
|
|
3
|
+
Keeps site calendars and Google calendars in step, both ways. Anything
|
|
4
|
+
created, changed, moved, cancelled or deleted on one side shows up on the
|
|
5
|
+
other within a few minutes, and Google keeps a full working copy if the site
|
|
6
|
+
is ever down. The site is the main calendar.
|
|
7
|
+
|
|
8
|
+
It's set up in the admin, under **Calendar settings → Synced with Google**.
|
|
9
|
+
Only calendars you link are synced.
|
|
10
|
+
|
|
11
|
+
- Site changes go out on every scheduler run (once a minute).
|
|
12
|
+
- Each link asks Google what changed every 2 minutes (`events.list` with a sync token).
|
|
13
|
+
- The scheduler has to run: on Node, the `acms-email-runner` sidecar; on
|
|
14
|
+
Workers, the cron trigger.
|
|
15
|
+
|
|
16
|
+
## Google Cloud setup
|
|
17
|
+
|
|
18
|
+
The sync uses its **own OAuth client**. It can live in the same Google Cloud
|
|
19
|
+
project as YouTube live video, but it needs a separate client, so
|
|
20
|
+
disconnecting YouTube never cuts off the calendar.
|
|
21
|
+
|
|
22
|
+
1. In Google Cloud console, open the project. Under **APIs & Services →
|
|
23
|
+
Credentials**, create an **OAuth client ID** of type **Web application**
|
|
24
|
+
named "Calendar".
|
|
25
|
+
2. Enable the **Google Calendar API** in the same project.
|
|
26
|
+
3. Add the return address shown in Calendar settings as an **Authorized
|
|
27
|
+
redirect URI**: `https://<your site>/api/calendar/google/oauth/callback`.
|
|
28
|
+
4. Set the **Audience** (OAuth consent screen):
|
|
29
|
+
- **Internal** if the project belongs to your Google Workspace
|
|
30
|
+
organization (Google Cloud console → IAM & Admin → Settings shows it).
|
|
31
|
+
There's no "unverified app" screen and no expiry. Internal applies to the
|
|
32
|
+
whole project, so YouTube must also be connected with an account in that
|
|
33
|
+
organization.
|
|
34
|
+
- Otherwise **External**, set to **In production**. In "Testing", Google
|
|
35
|
+
ends the connection every 7 days. You click through "Google hasn't
|
|
36
|
+
verified this app" once.
|
|
37
|
+
5. If your Workspace admin restricts third-party apps, mark this client as
|
|
38
|
+
trusted: Google Admin console → Security → API controls.
|
|
39
|
+
6. Paste the client ID and secret into Calendar settings → Synced with Google
|
|
40
|
+
and press **Connect Google**. Sign in with the account that owns the
|
|
41
|
+
calendars (for a church, its Workspace account).
|
|
42
|
+
|
|
43
|
+
The site asks for two permissions:
|
|
44
|
+
|
|
45
|
+
- `calendar.events`: see and edit events on calendars the account can edit.
|
|
46
|
+
- `calendar.calendarlist.readonly`: list those calendars for the link picker.
|
|
47
|
+
|
|
48
|
+
The tokens are sealed with `LIVE_ENCRYPTION_KEY`, else `SESSION_SECRET`
|
|
49
|
+
(32+ characters), using their own key and data. There's no new environment
|
|
50
|
+
variable. Changing that secret means connecting Google again.
|
|
51
|
+
|
|
52
|
+
Permissions:
|
|
53
|
+
|
|
54
|
+
- **Connected services** (`services.manage`) connects and disconnects Google.
|
|
55
|
+
- **Manage rooms and calendars** (`schedule.manage`) links, pauses, resumes
|
|
56
|
+
and reviews matches.
|
|
57
|
+
|
|
58
|
+
## Linking a calendar
|
|
59
|
+
|
|
60
|
+
**+ Link a Google calendar** pairs one site calendar with one Google calendar
|
|
61
|
+
the account can edit. The settings:
|
|
62
|
+
|
|
63
|
+
- **Direction**:
|
|
64
|
+
- Both ways.
|
|
65
|
+
- Site → Google only: Google mirrors the site. Start here, then switch.
|
|
66
|
+
- Google → site only: site edits stay on the site.
|
|
67
|
+
- **Rooms**: the room(s) this Google calendar stands for. Events that come in
|
|
68
|
+
from it hold those rooms. Site events holding one of them go to it.
|
|
69
|
+
- **Also gets this calendar's events that hold no room**: one link per site
|
|
70
|
+
calendar may have this.
|
|
71
|
+
- A link with no rooms carries its whole site calendar.
|
|
72
|
+
- **Titles that set a tag**: a title prefix paired with a site tag, both ways,
|
|
73
|
+
for example `CLASS:` ⇄ `Class`.
|
|
74
|
+
- A Google title starting with the prefix gives the site event the tag.
|
|
75
|
+
- A tagged site event gets the prefix in Google.
|
|
76
|
+
- The prefix stays in the title on both sides. The tag stays the source of
|
|
77
|
+
truth for site features (class schedule, "Class changes" pushes).
|
|
78
|
+
- **Booking pages**: the titles of the Google calendar's appointment
|
|
79
|
+
schedules. See below.
|
|
80
|
+
- **Start from**: default 30 days ago. Events from that date on are synced,
|
|
81
|
+
plus every repeating series still running.
|
|
82
|
+
|
|
83
|
+
One site calendar can have several links, one per room. Example: the room
|
|
84
|
+
calendar ⇄ "Santuario | Sanctuary" holding the Sanctuary, and the room
|
|
85
|
+
calendar ⇄ "Multimedia Room #1" holding that room.
|
|
86
|
+
|
|
87
|
+
- An event in two linked site calendars, or holding two linked rooms, gets
|
|
88
|
+
one Google copy per link. An edit to any copy updates the site event, and
|
|
89
|
+
the other copies follow.
|
|
90
|
+
- Deleting one copy in Google removes only that link's room, or calendar,
|
|
91
|
+
from the event. When neither applies (a link that carries the whole
|
|
92
|
+
calendar, which another link also has), that Google calendar just keeps the
|
|
93
|
+
event out. The event is cancelled (below) only when no linked copy is left.
|
|
94
|
+
|
|
95
|
+
### The first match
|
|
96
|
+
|
|
97
|
+
A new link writes nothing until someone reviews it. The match page lists:
|
|
98
|
+
|
|
99
|
+
- **On both**:
|
|
100
|
+
- Paired for sure, each with **Undo**: a Google copy carrying the site's hidden link; the same
|
|
101
|
+
calendar ID (copies made with "Make an editable copy…" or imported from
|
|
102
|
+
Google's .ics keep Google's ID); or the same title (prefixes and case
|
|
103
|
+
aside) at the same time.
|
|
104
|
+
- Suggested: same time and repeat, different title. **Link** or **Not the
|
|
105
|
+
same**.
|
|
106
|
+
- **Only on the site**: copied to Google when you start.
|
|
107
|
+
- **Only in Google**: come to the site as published events in the linked
|
|
108
|
+
calendar, holding its rooms.
|
|
109
|
+
|
|
110
|
+
Decisions are saved as you make them. **Start syncing** needs every
|
|
111
|
+
suggestion decided.
|
|
112
|
+
|
|
113
|
+
- Linked pairs keep **both titles as they are**: a Spanish Google title and
|
|
114
|
+
an English site title both stay. Every field, and every changed date of a
|
|
115
|
+
series, is recorded as the agreed starting point, and only a later edit on
|
|
116
|
+
one side copies over. A pair whose site event has no description keeps
|
|
117
|
+
Google's.
|
|
118
|
+
- A pair whose site event was unpublished, deleted or moved to another
|
|
119
|
+
calendar or room after the review is left alone: it isn't paired, deleted
|
|
120
|
+
or copied, and Recent changes names it.
|
|
121
|
+
- "Not the same" pairs are treated as two events.
|
|
122
|
+
- No member notifications are sent for any of it.
|
|
123
|
+
|
|
124
|
+
## What goes where
|
|
125
|
+
|
|
126
|
+
| Site | Google | Direction |
|
|
127
|
+
| --- | --- | --- |
|
|
128
|
+
| Title (main language) | Title | both ways. A Google title edit sets the main-language title as typed; translations stay on the site |
|
|
129
|
+
| Cancelled event | "Cancelled: " in front of Google's title | site → Google |
|
|
130
|
+
| Date, time, all day, time zone | start, end | both ways (the site's inclusive all-day end ⇄ Google's exclusive one; each event keeps its zone) |
|
|
131
|
+
| Repeat rule, skipped dates | `RRULE`, `EXDATE` | both ways |
|
|
132
|
+
| Cancelled single date | that date's instance removed | both ways |
|
|
133
|
+
| Moved single date (new time or place) | that date's instance changed | both ways |
|
|
134
|
+
| Place name and address | location | both ways (from Google, the text becomes the place name; saved places and pins aren't guessed) |
|
|
135
|
+
| Description | plain-text description | while the event is Google's, from Google (below); after that, site → Google |
|
|
136
|
+
| Map or meeting link, event page | lines in the description | site → Google |
|
|
137
|
+
| Tag of a prefix rule | title prefix | both ways |
|
|
138
|
+
| Rooms | which linked Google calendar | both ways |
|
|
139
|
+
| Draft, scheduled | nothing | not sent until published, scheduled publishing included |
|
|
140
|
+
| Unpublished or deleted | deleted in Google | site → Google |
|
|
141
|
+
| Deleted in Google | **cancelled** on the site (struck through, room freed, restorable) | Google → site |
|
|
142
|
+
| Hidden link | `extendedProperties.private` `{ acmsEventId, acmsSite }` | site → Google |
|
|
143
|
+
|
|
144
|
+
Never sent:
|
|
145
|
+
|
|
146
|
+
- hosts, RSVPs, private fields, member requests, pictures, buttons, custom
|
|
147
|
+
fields, other tags;
|
|
148
|
+
- guests. Every write uses `sendUpdates=none`, so Google emails no one.
|
|
149
|
+
|
|
150
|
+
Only the fields above are patched. Guests, reminders, colours, attachments
|
|
151
|
+
and Meet links someone added in Google are left alone. Cancelling an event or
|
|
152
|
+
changing a rule's tag on the site adjusts Google's own title (adds or removes
|
|
153
|
+
"Cancelled: " or the prefix) rather than replacing it, so a Spanish Google
|
|
154
|
+
title stays Spanish until someone edits the title itself.
|
|
155
|
+
|
|
156
|
+
Changes from Google keep staff's staged edits staged. A staged field still at
|
|
157
|
+
its old value follows Google's change, so pressing Update later doesn't undo
|
|
158
|
+
it. A field staff did change stays as they staged it.
|
|
159
|
+
|
|
160
|
+
If Google refuses to change one event (for example, someone else's event
|
|
161
|
+
that only invites the room's calendar), Recent changes lists it once and the
|
|
162
|
+
other links still get the change.
|
|
163
|
+
|
|
164
|
+
**Descriptions.** An event created in Google takes its description from
|
|
165
|
+
Google, as simple paragraphs, until someone edits the description on the
|
|
166
|
+
site. From then on the site's text wins, and Google gets a plain-text copy
|
|
167
|
+
plus "Details: <event page>". A Google description edit after that is listed
|
|
168
|
+
under Recent changes with **Use Google's version**.
|
|
169
|
+
|
|
170
|
+
**New events from Google** are published on the site at once, in the linked
|
|
171
|
+
calendar, holding its rooms. Changes from Google send member pushes the way an
|
|
172
|
+
admin edit does, except during the first match and full resyncs. RSVP emails
|
|
173
|
+
always go, because those people's own booking changed.
|
|
174
|
+
|
|
175
|
+
## Repeating events
|
|
176
|
+
|
|
177
|
+
A series is one event with a rule on both sides. Single dates:
|
|
178
|
+
|
|
179
|
+
- **Cancelled.** Deleting one date in Google cancels it on the site with
|
|
180
|
+
`cancelOccurrence`: struck through, room freed, RSVPs cancelled with their
|
|
181
|
+
email. A date cancelled on the site is deleted from the Google series.
|
|
182
|
+
- **Moved.** A Google date with a new time, day or place becomes a moved date
|
|
183
|
+
on the site, through `moveOccurrence` with the `GOOGLE_SYNC` actor (see
|
|
184
|
+
[calendar](calendar.md)): RSVPs move and get the "new time" email, and a
|
|
185
|
+
taken room only warns. A date moved on the site moves that Google instance.
|
|
186
|
+
- **Per-date titles or descriptions** from Google aren't applied, because the
|
|
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.
|
|
194
|
+
|
|
195
|
+
## Clashes
|
|
196
|
+
|
|
197
|
+
Changes are compared field by field against what the pair last agreed:
|
|
198
|
+
|
|
199
|
+
- Each side keeps what it changed.
|
|
200
|
+
- If both sides changed the same field, **the site wins**. Google's value is
|
|
201
|
+
kept in Recent changes and in the event editor's bar, with **Use Google's
|
|
202
|
+
version**.
|
|
203
|
+
- A change from Google that puts an event on a room another event holds still
|
|
204
|
+
applies, because it already happened in Google. The clash shows in Recent
|
|
205
|
+
changes and the event editor.
|
|
206
|
+
|
|
207
|
+
## Booking pages (appointment schedules)
|
|
208
|
+
|
|
209
|
+
Google's API has no marker for appointments booked through a booking page.
|
|
210
|
+
Its event types don't include them, and open booking times aren't returned at
|
|
211
|
+
all, so those are never copied. Google copies the booking page's title into
|
|
212
|
+
each booked event. So a link names its booking pages ("Band", "Rehearsal
|
|
213
|
+
Time"), and an event with one of those titles (alone, or followed by
|
|
214
|
+
" (…)", " - …" or ": …") and a guest is treated as a booked appointment. So
|
|
215
|
+
is an event whose description links to a Google booking page
|
|
216
|
+
(`calendar.app.google`, `…/calendar/appointments/…`).
|
|
217
|
+
|
|
218
|
+
Booked appointments come in:
|
|
219
|
+
|
|
220
|
+
- titled "Booked" (in the site's main language), with no guests, place or
|
|
221
|
+
description;
|
|
222
|
+
- holding the link's room, with the internal tag `#google-booking`, which is
|
|
223
|
+
never shown publicly.
|
|
224
|
+
|
|
225
|
+
They're Google's: never written back, and deleting one on the site only
|
|
226
|
+
unpairs it.
|
|
227
|
+
|
|
228
|
+
Check this against a real booking before relying on it: book one and look
|
|
229
|
+
at it in Recent changes and on the site.
|
|
230
|
+
|
|
231
|
+
## Safety
|
|
232
|
+
|
|
233
|
+
- **Nothing before review.** A new link only reads, until Start syncing.
|
|
234
|
+
- **Only the real site writes.** A link remembers the Site URL it was made
|
|
235
|
+
on, and runs only while Settings → Site URL matches. A restored backup, a
|
|
236
|
+
staging copy or a local copy with another Site URL pauses its links ("This
|
|
237
|
+
copy isn't the site that linked Google").
|
|
238
|
+
- A raw database copy keeps the same Site URL. Change the copy's Site URL,
|
|
239
|
+
or run it with another `SESSION_SECRET`, so it can't use the sign-in.
|
|
240
|
+
- **Restore** pauses every link until someone presses Resume. Resuming reads
|
|
241
|
+
Google again from scratch, pairing by the hidden links:
|
|
242
|
+
- Google's copies take the restored details, because the site is the main
|
|
243
|
+
calendar. That replaces changes made since the backup, on either side.
|
|
244
|
+
- Events Google has that the backup doesn't come back to the site: an older
|
|
245
|
+
backup never deletes anything in Google.
|
|
246
|
+
- **Environment sync and backups** never copy the Google account, links or
|
|
247
|
+
pairs. Nothing is stored on the event row.
|
|
248
|
+
- **Unlink** and **Disconnect** keep both copies of every event. Linking again
|
|
249
|
+
pairs them by their hidden links.
|
|
250
|
+
- **Loops**: each pair stores a hash of every synced field, per side, as last
|
|
251
|
+
agreed. A change that already matches is never sent back.
|
|
252
|
+
- **Failures** back off like YouTube's, and honour Google's rate limits:
|
|
253
|
+
- when Google refuses the saved sign-in, the account shows "Reconnect
|
|
254
|
+
Google" and changes wait in the queue;
|
|
255
|
+
- a link failing for an hour emails the owners and people who can manage
|
|
256
|
+
rooms and calendars, once;
|
|
257
|
+
- a Google "full sync required" (410) reads everything again quietly, with
|
|
258
|
+
no doubles and no pushes.
|
|
259
|
+
- **Recent changes** keeps every change the sync made, and its clashes, for
|
|
260
|
+
90 days.
|
|
261
|
+
|
|
262
|
+
## For site code
|
|
263
|
+
|
|
264
|
+
`@acms-subir/core/server`:
|
|
265
|
+
|
|
266
|
+
- `googleCopies(repo, eventId)`: an event's Google copies: calendar, link,
|
|
267
|
+
last sync, whether its description is Google's, an unresolved clash.
|
|
268
|
+
- `googleSyncHistory(repo, { eventId?, limit? })`: Recent changes.
|
|
269
|
+
- `syncGoogleCalendar(repo, { locals })`: run the sync once now.
|
|
270
|
+
- `GOOGLE_BOOKING_TAG`: `'#google-booking'`.
|
|
271
|
+
|
|
272
|
+
Inside the framework, the sync lives in `lib/calendar/google/`:
|
|
273
|
+
|
|
274
|
+
- `map.ts`: the field mapping, as pure functions;
|
|
275
|
+
- `outbound.ts`, `inbound.ts`, `match.ts`;
|
|
276
|
+
- `client.ts`: Google's API over `fetch`;
|
|
277
|
+
- `service.ts`: the scheduler job and Sync now.
|
|
278
|
+
|
|
279
|
+
Its tables (migration 69): `gcal_account`, `gcal_oauth_states`, `gcal_links`,
|
|
280
|
+
`gcal_items` (one row per pair), `gcal_queue` (filled by triggers on every
|
|
281
|
+
insert, update and delete of `events`) and `gcal_log`.
|
|
282
|
+
|
|
283
|
+
## Not yet
|
|
284
|
+
|
|
285
|
+
- **Google push notifications** (`events.watch`) for near-instant updates.
|
|
286
|
+
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
|
+
|
|
291
|
+
## Troubleshooting
|
|
292
|
+
|
|
293
|
+
- **"Reconnect Google"**: Google refused the saved sign-in. It was revoked,
|
|
294
|
+
unused for 6 months, the project is still in "Testing", or a Workspace admin
|
|
295
|
+
policy applies. Press Reconnect; changes made meanwhile go out afterwards.
|
|
296
|
+
- **"This copy isn't the site that linked Google"**: Site URL differs from the
|
|
297
|
+
one the link was made on. Set it back, or unlink on this copy.
|
|
298
|
+
- **A Google calendar is missing from the picker**: the account needs "Make
|
|
299
|
+
changes to events" on it. Share it with the account, then **Refresh
|
|
300
|
+
calendars**.
|
|
301
|
+
- **Nothing syncs**: check that the scheduler runs. Calendar settings warns
|
|
302
|
+
when it hasn't run for a few minutes.
|