@mulmoclaude/core 1.10.0 → 1.12.0
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/assets/helps/error-recovery.md +85 -0
- package/assets/helps/google-calendar-collection.md +41 -18
- package/dist/collection/core/schemaZ.d.ts +10 -1
- package/dist/collection/registry/server/index.cjs +2 -2
- package/dist/collection/registry/server/index.js +2 -2
- package/dist/collection/server/index.cjs +2 -2
- package/dist/collection/server/index.js +2 -2
- package/dist/collection-watchers/index.cjs +2 -2
- package/dist/collection-watchers/index.js +2 -2
- package/dist/{discovery-_fjsluLx.js → discovery-B4CZvrXR.js} +11 -3
- package/dist/{discovery-_fjsluLx.js.map → discovery-B4CZvrXR.js.map} +1 -1
- package/dist/{discovery-DYcR83qx.cjs → discovery-NRy3tyUA.cjs} +11 -3
- package/dist/{discovery-DYcR83qx.cjs.map → discovery-NRy3tyUA.cjs.map} +1 -1
- package/dist/feeds/server/index.cjs +2 -2
- package/dist/feeds/server/index.js +2 -2
- package/dist/google/calendar.d.ts +9 -0
- package/dist/google/calendarLock.d.ts +16 -0
- package/dist/google/calendarPushState.d.ts +1 -1
- package/dist/google/calendarSyncDue.d.ts +10 -0
- package/dist/google/calendarSyncStore.d.ts +20 -1
- package/dist/google/collectionProjection.d.ts +18 -0
- package/dist/google/collectionPush.d.ts +62 -0
- package/dist/google/collectionSync.d.ts +75 -28
- package/dist/google/index.cjs +1217 -771
- package/dist/google/index.cjs.map +1 -1
- package/dist/google/index.d.ts +6 -3
- package/dist/google/index.js +1204 -772
- package/dist/google/index.js.map +1 -1
- package/dist/google/pushPlan.d.ts +1 -1
- package/dist/{server-l4TZ5Lbf.cjs → server-7U-3DE2e.cjs} +2 -2
- package/dist/{server-l4TZ5Lbf.cjs.map → server-7U-3DE2e.cjs.map} +1 -1
- package/dist/{server-BlIrtGKl.js → server-DdyIvFhz.js} +2 -2
- package/dist/{server-BlIrtGKl.js.map → server-DdyIvFhz.js.map} +1 -1
- package/package.json +4 -4
package/dist/google/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { t as truncate } from "../text-CjDbP2O1.js";
|
|
|
3
3
|
import { t as writeFileAtomic } from "../atomic-DPpdrJzO.js";
|
|
4
4
|
import { fetchWithTimeout } from "../utils/fetch.js";
|
|
5
5
|
import { l as parseIsoDate, u as parseIsoDateTime } from "../calendarGrid-CrGxeCOG.js";
|
|
6
|
-
import { J as getWorkspaceRoot, d as storeFor, et as createForwardingLogger, n as discoverCollections, tt as createHostSlot } from "../discovery-
|
|
6
|
+
import { J as getWorkspaceRoot, d as storeFor, et as createForwardingLogger, n as discoverCollections, tt as createHostSlot } from "../discovery-B4CZvrXR.js";
|
|
7
7
|
import { promises } from "node:fs";
|
|
8
8
|
import path, { join } from "node:path";
|
|
9
9
|
import { randomBytes } from "node:crypto";
|
|
@@ -621,7 +621,9 @@ var toEventSummary = (value) => {
|
|
|
621
621
|
end: eventTime$1(record.end),
|
|
622
622
|
htmlLink: stringField(record, "htmlLink"),
|
|
623
623
|
status: stringField(record, "status"),
|
|
624
|
-
colorId: stringField(record, "colorId")
|
|
624
|
+
colorId: stringField(record, "colorId"),
|
|
625
|
+
description: stringField(record, "description"),
|
|
626
|
+
location: stringField(record, "location")
|
|
625
627
|
};
|
|
626
628
|
};
|
|
627
629
|
var toCalendarSummary = (value) => {
|
|
@@ -663,6 +665,7 @@ async function createCalendarEvent(accessToken, input) {
|
|
|
663
665
|
const body = {
|
|
664
666
|
summary: input.summary,
|
|
665
667
|
description: input.description,
|
|
668
|
+
location: input.location,
|
|
666
669
|
...resolveEventSpan(input),
|
|
667
670
|
...input.colorId ? { colorId: input.colorId } : {},
|
|
668
671
|
...input.eventId ? { id: input.eventId } : {}
|
|
@@ -687,6 +690,7 @@ var eventEnd = (key, structured, flat) => {
|
|
|
687
690
|
var buildEventPatch = (input) => ({
|
|
688
691
|
...input.summary !== void 0 ? { summary: input.summary } : {},
|
|
689
692
|
...input.description !== void 0 ? { description: input.description } : {},
|
|
693
|
+
...input.location !== void 0 ? { location: input.location } : {},
|
|
690
694
|
...eventEnd("start", input.start, input.startDateTime),
|
|
691
695
|
...eventEnd("end", input.end, input.endDateTime),
|
|
692
696
|
...input.colorId ? { colorId: input.colorId } : {}
|
|
@@ -837,34 +841,128 @@ function calendarSyncStatePath(workspaceRoot) {
|
|
|
837
841
|
return path.join(workspaceRoot ?? getWorkspaceRoot(), "data", "calendar", ".sync-state.json");
|
|
838
842
|
}
|
|
839
843
|
var calendarKey = canonicalCalendarId;
|
|
844
|
+
/** Read one map out of whatever is on disk. Tolerant on purpose: a file written
|
|
845
|
+
* before `lastSyncedAt` existed simply lacks the key, and a hand-edited one may
|
|
846
|
+
* hold anything. A dropped entry costs a full re-walk or a duplicate run, never
|
|
847
|
+
* data — throwing here would stop the sync entirely. */
|
|
848
|
+
function stringRecord(value) {
|
|
849
|
+
if (typeof value !== "object" || value === null) return {};
|
|
850
|
+
return Object.fromEntries(Object.entries(value).filter((entry) => typeof entry[1] === "string"));
|
|
851
|
+
}
|
|
840
852
|
async function readState$1(workspaceRoot) {
|
|
841
853
|
const stored = await readJsonOrNull(calendarSyncStatePath(workspaceRoot));
|
|
842
|
-
return
|
|
854
|
+
return {
|
|
855
|
+
tokens: stringRecord(stored?.tokens),
|
|
856
|
+
lastSyncedAt: stringRecord(stored?.lastSyncedAt)
|
|
857
|
+
};
|
|
843
858
|
}
|
|
844
859
|
var writeQueue$1 = { tail: Promise.resolve() };
|
|
845
|
-
|
|
860
|
+
/** Decide from the stored state and write the result, both inside the queue, so
|
|
861
|
+
* nothing in this process can read between the two. A `null` state skips the
|
|
862
|
+
* write; `result` is what the caller learns about the decision. */
|
|
863
|
+
async function updateStateWith(decide, workspaceRoot) {
|
|
846
864
|
const run = writeQueue$1.tail.then(async () => {
|
|
847
|
-
const state = await readState$1(workspaceRoot);
|
|
848
|
-
await writeJsonAtomicWithMode(calendarSyncStatePath(workspaceRoot),
|
|
865
|
+
const { state, result } = decide(await readState$1(workspaceRoot));
|
|
866
|
+
if (state) await writeJsonAtomicWithMode(calendarSyncStatePath(workspaceRoot), state, SYNC_STATE_MODE);
|
|
867
|
+
return result;
|
|
849
868
|
});
|
|
850
869
|
writeQueue$1.tail = run.catch(() => void 0);
|
|
851
870
|
return await run;
|
|
852
871
|
}
|
|
872
|
+
async function updateState$1(mutate, workspaceRoot) {
|
|
873
|
+
await updateStateWith((state) => ({
|
|
874
|
+
state: mutate(state),
|
|
875
|
+
result: void 0
|
|
876
|
+
}), workspaceRoot);
|
|
877
|
+
}
|
|
878
|
+
var withoutKey = (record, dropped) => Object.fromEntries(Object.entries(record).filter(([key]) => key !== dropped));
|
|
853
879
|
async function loadCalendarSyncToken(calendarId, workspaceRoot) {
|
|
854
880
|
return (await readState$1(workspaceRoot)).tokens[calendarKey(calendarId)] ?? null;
|
|
855
881
|
}
|
|
856
882
|
async function saveCalendarSyncToken(calendarId, syncToken, workspaceRoot) {
|
|
857
883
|
const stored = calendarKey(calendarId);
|
|
858
|
-
await updateState$1((
|
|
859
|
-
...
|
|
860
|
-
|
|
884
|
+
await updateState$1((state) => ({
|
|
885
|
+
...state,
|
|
886
|
+
tokens: {
|
|
887
|
+
...state.tokens,
|
|
888
|
+
[stored]: syncToken
|
|
889
|
+
}
|
|
861
890
|
}), workspaceRoot);
|
|
862
891
|
}
|
|
863
892
|
/** Drop one calendar's token — used when Google answers 410, so the next run
|
|
864
|
-
* starts a clean full sync.
|
|
893
|
+
* starts a clean full sync. Leaves the sync marker alone: "restart the walk"
|
|
894
|
+
* says nothing about "nobody is running". */
|
|
865
895
|
async function clearCalendarSyncToken(calendarId, workspaceRoot) {
|
|
866
896
|
const dropped = calendarKey(calendarId);
|
|
867
|
-
await updateState$1((
|
|
897
|
+
await updateState$1((state) => ({
|
|
898
|
+
...state,
|
|
899
|
+
tokens: withoutKey(state.tokens, dropped)
|
|
900
|
+
}), workspaceRoot);
|
|
901
|
+
}
|
|
902
|
+
async function loadCalendarLastSyncedAt(calendarId, workspaceRoot) {
|
|
903
|
+
return (await readState$1(workspaceRoot)).lastSyncedAt[calendarKey(calendarId)] ?? null;
|
|
904
|
+
}
|
|
905
|
+
/** Stamp when a sync of this calendar started, but only if `mayClaim` accepts
|
|
906
|
+
* what the marker currently says. Answers whether THIS call is the one that
|
|
907
|
+
* claimed it.
|
|
908
|
+
*
|
|
909
|
+
* The decision and the stamp share one pass through the queue on purpose.
|
|
910
|
+
* Deciding from a snapshot read earlier — as a caller filtering a whole map of
|
|
911
|
+
* calendars up front must — leaves both hosts believing the calendar is free,
|
|
912
|
+
* which is the race this exists to close (Codex review #2680). What remains is
|
|
913
|
+
* the read→write gap BETWEEN processes; closing that needs a real file lock
|
|
914
|
+
* (#2679), and this stays a soft dedup until then.
|
|
915
|
+
*
|
|
916
|
+
* The time is passed in rather than read here so the rule stays testable
|
|
917
|
+
* without freezing the clock. */
|
|
918
|
+
async function claimCalendarSyncIfDue(calendarId, startedAt, mayClaim, workspaceRoot) {
|
|
919
|
+
const stored = calendarKey(calendarId);
|
|
920
|
+
return await updateStateWith((state) => {
|
|
921
|
+
const claimed = mayClaim(state.lastSyncedAt[stored] ?? null);
|
|
922
|
+
return {
|
|
923
|
+
state: claimed ? {
|
|
924
|
+
...state,
|
|
925
|
+
lastSyncedAt: {
|
|
926
|
+
...state.lastSyncedAt,
|
|
927
|
+
[stored]: startedAt
|
|
928
|
+
}
|
|
929
|
+
} : null,
|
|
930
|
+
result: claimed
|
|
931
|
+
};
|
|
932
|
+
}, workspaceRoot);
|
|
933
|
+
}
|
|
934
|
+
/** Drop one calendar's marker, so the next tick — on either host — may sync it
|
|
935
|
+
* immediately instead of waiting the window out. */
|
|
936
|
+
async function clearCalendarLastSyncedAt(calendarId, workspaceRoot) {
|
|
937
|
+
const dropped = calendarKey(calendarId);
|
|
938
|
+
await updateState$1((state) => ({
|
|
939
|
+
...state,
|
|
940
|
+
lastSyncedAt: withoutKey(state.lastSyncedAt, dropped)
|
|
941
|
+
}), workspaceRoot);
|
|
942
|
+
}
|
|
943
|
+
//#endregion
|
|
944
|
+
//#region src/google/calendarSyncDue.ts
|
|
945
|
+
/** Slack between a tick and the marker it writes. Without it a lone host reads
|
|
946
|
+
* its OWN marker as "not due yet" — elapsed lands a hair under the interval
|
|
947
|
+
* every time — and syncs every other hour. */
|
|
948
|
+
var TICK_JITTER_TOLERANCE_MS = 300 * 1e3;
|
|
949
|
+
/** How long a fresh marker suppresses a sync: one interval, less the slack.
|
|
950
|
+
* The slack is halved rather than subtracted flat for a very short configured
|
|
951
|
+
* interval, where a fixed five minutes would swallow the window whole. */
|
|
952
|
+
function calendarSyncDueWindowMs(intervalMs) {
|
|
953
|
+
return intervalMs - Math.min(TICK_JITTER_TOLERANCE_MS, intervalMs / 2);
|
|
954
|
+
}
|
|
955
|
+
/** True iff no recent enough run stands in for this one.
|
|
956
|
+
*
|
|
957
|
+
* A marker that cannot be read as a time is treated as due: a stuck calendar is
|
|
958
|
+
* worse than a duplicate run, which the sync is built to tolerate anyway
|
|
959
|
+
* (writes are upserts by event id). */
|
|
960
|
+
function isCalendarSyncDue(lastSyncedAt, windowMs, now = Date.now()) {
|
|
961
|
+
if (!lastSyncedAt) return true;
|
|
962
|
+
const elapsed = now - Date.parse(lastSyncedAt);
|
|
963
|
+
if (!Number.isFinite(elapsed)) return true;
|
|
964
|
+
if (elapsed < -windowMs) return true;
|
|
965
|
+
return elapsed >= windowMs;
|
|
868
966
|
}
|
|
869
967
|
//#endregion
|
|
870
968
|
//#region src/google/calendarPushState.ts
|
|
@@ -877,7 +975,9 @@ var toShadowEvent = (event) => ({
|
|
|
877
975
|
summary: event.summary,
|
|
878
976
|
start: event.start,
|
|
879
977
|
end: event.end,
|
|
880
|
-
colorId: event.colorId
|
|
978
|
+
colorId: event.colorId,
|
|
979
|
+
description: event.description,
|
|
980
|
+
location: event.location
|
|
881
981
|
});
|
|
882
982
|
async function readState(workspaceRoot) {
|
|
883
983
|
const stored = await readJsonOrNull(calendarPushStatePath(workspaceRoot));
|
|
@@ -920,14 +1020,142 @@ async function clearCalendarShadow(calendarId, workspaceRoot) {
|
|
|
920
1020
|
await updateState((events) => Object.fromEntries(Object.entries(events).filter(([key]) => key !== dropped)), workspaceRoot);
|
|
921
1021
|
}
|
|
922
1022
|
//#endregion
|
|
1023
|
+
//#region src/google/calendarLock.ts
|
|
1024
|
+
/** Serialise `run` against whatever is already running for `key`.
|
|
1025
|
+
*
|
|
1026
|
+
* `locks` is passed in so the queuing rule is testable without module state;
|
|
1027
|
+
* the key is dropped once nothing is queued behind it, so the map cannot grow
|
|
1028
|
+
* an entry per calendar forever. A failed predecessor still releases the
|
|
1029
|
+
* queue — `then(run, run)`. */
|
|
1030
|
+
async function withKeyedLock(locks, key, run) {
|
|
1031
|
+
const result = (locks.get(key) ?? Promise.resolve()).then(run, run);
|
|
1032
|
+
const tail = result.then(() => void 0, () => void 0);
|
|
1033
|
+
locks.set(key, tail);
|
|
1034
|
+
try {
|
|
1035
|
+
return await result;
|
|
1036
|
+
} finally {
|
|
1037
|
+
if (locks.get(key) === tail) locks.delete(key);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
/** In-flight work per canonical calendar id. Module state on purpose: the
|
|
1041
|
+
* scheduler, the create trigger and the Refresh button are three doors into
|
|
1042
|
+
* the same calendar (CodeRabbit review #2566). */
|
|
1043
|
+
var calendarLocks = /* @__PURE__ */ new Map();
|
|
1044
|
+
/** Serialise anything that touches ONE calendar's sync state.
|
|
1045
|
+
*
|
|
1046
|
+
* Shared with the push path (#2598), not only the pull doors: a push that
|
|
1047
|
+
* overtakes an in-flight pull would record a baseline for events the pull is
|
|
1048
|
+
* still writing, so the next push would diff against a future it never saw.
|
|
1049
|
+
*
|
|
1050
|
+
* NOT reentrant — a holder must call the lock-free form of whatever it drives,
|
|
1051
|
+
* or it waits on itself forever. */
|
|
1052
|
+
async function withCalendarLock(calendarId, run) {
|
|
1053
|
+
return await withKeyedLock(calendarLocks, canonicalCalendarId(calendarId), run);
|
|
1054
|
+
}
|
|
1055
|
+
//#endregion
|
|
1056
|
+
//#region src/google/pushDateTime.ts
|
|
1057
|
+
/** RFC3339 zone designator: `Z`, `+09:00`, `-0500`. */
|
|
1058
|
+
var ZONE_SUFFIX_RE$1 = /(?:Z|[+-]\d{2}:?\d{2})$/;
|
|
1059
|
+
var FRACTIONAL_SECONDS_RE$1 = /\.\d+$/;
|
|
1060
|
+
var TWO_DIGITS_RE = /^\d{2}$/;
|
|
1061
|
+
var CLOCK_SEPARATOR = ":";
|
|
1062
|
+
var DATE_TIME_SEPARATOR = "T";
|
|
1063
|
+
var MIN_CLOCK_SEGMENTS = 2;
|
|
1064
|
+
var MAX_CLOCK_SEGMENTS = 3;
|
|
1065
|
+
var WHOLE_MINUTE_SECONDS = "00";
|
|
1066
|
+
var MAX_SECONDS = 59;
|
|
1067
|
+
/** Whether `value` is a real calendar day (`2026-02-30` is not).
|
|
1068
|
+
*
|
|
1069
|
+
* Delegated to the parser the record lint, the calendar grid and the day view
|
|
1070
|
+
* already share, so the push agrees with the rest of the app on what a valid
|
|
1071
|
+
* stored date IS — and rejects an impossible one here instead of turning it
|
|
1072
|
+
* into an opaque Google 400. */
|
|
1073
|
+
var isCalendarDate = (value) => parseIsoDate(value) !== null;
|
|
1074
|
+
/** A value Google already accepts verbatim, as the event time to send, or null.
|
|
1075
|
+
*
|
|
1076
|
+
* Reached when the schema maps `start` onto a `string` field: `projectValue`
|
|
1077
|
+
* normalises only `datetime` fields, so that column holds Google's own text,
|
|
1078
|
+
* zone and all. The civil part is still validated — matching a trailing `Z` is
|
|
1079
|
+
* not evidence that the rest is a date-time. */
|
|
1080
|
+
function rawGoogleTime(value) {
|
|
1081
|
+
if (isCalendarDate(value)) return { date: value };
|
|
1082
|
+
const zone = value.match(ZONE_SUFFIX_RE$1)?.[0];
|
|
1083
|
+
if (zone === void 0) return null;
|
|
1084
|
+
return parseIsoDateTime(value.slice(0, value.length - zone.length).replace(FRACTIONAL_SECONDS_RE$1, "")) === null ? null : { dateTime: value };
|
|
1085
|
+
}
|
|
1086
|
+
/** Google's own all-day `end` is EXCLUSIVE — a single day on the 12th ends on
|
|
1087
|
+
* the 13th. The pull stores that as `2026-05-13T00:00` and this takes the date
|
|
1088
|
+
* part straight back, so the exclusive end round-trips untouched. Looks
|
|
1089
|
+
* off-by-one; adjusting it would shorten every all-day event by a day. */
|
|
1090
|
+
function allDayFrom(datePart) {
|
|
1091
|
+
return { date: datePart };
|
|
1092
|
+
}
|
|
1093
|
+
/** Split `HH:MM[:SS]` into RFC3339's always-three segments, or null.
|
|
1094
|
+
*
|
|
1095
|
+
* Split rather than one regex: expressing the optional seconds as `(?::(\d{2}))?`
|
|
1096
|
+
* nests a quantifier inside a quantified group, which the ReDoS lint rejects. */
|
|
1097
|
+
function clockSegments(clockPart) {
|
|
1098
|
+
const segments = clockPart.split(CLOCK_SEPARATOR);
|
|
1099
|
+
if (segments.length < MIN_CLOCK_SEGMENTS || segments.length > MAX_CLOCK_SEGMENTS) return null;
|
|
1100
|
+
if (!segments.every((segment) => TWO_DIGITS_RE.test(segment))) return null;
|
|
1101
|
+
const [hours, minutes, seconds] = segments;
|
|
1102
|
+
if (seconds !== void 0 && Number(seconds) > MAX_SECONDS) return null;
|
|
1103
|
+
return [
|
|
1104
|
+
hours,
|
|
1105
|
+
minutes,
|
|
1106
|
+
seconds ?? WHOLE_MINUTE_SECONDS
|
|
1107
|
+
].join(CLOCK_SEPARATOR);
|
|
1108
|
+
}
|
|
1109
|
+
/** `YYYY-MM-DDTHH:MM[:SS]` → its date and its RFC3339 clock, or null.
|
|
1110
|
+
*
|
|
1111
|
+
* Validity comes from the shared strict parser, so an out-of-range clock or an
|
|
1112
|
+
* impossible day is refused here rather than sent to Google. */
|
|
1113
|
+
function parseStoredDateTime(value) {
|
|
1114
|
+
if (parseIsoDateTime(value) === null) return null;
|
|
1115
|
+
const [datePart, clockPart, ...extra] = value.split(DATE_TIME_SEPARATOR);
|
|
1116
|
+
if (datePart === void 0 || clockPart === void 0 || extra.length > 0) return null;
|
|
1117
|
+
const clock = clockSegments(clockPart);
|
|
1118
|
+
return clock === null ? null : {
|
|
1119
|
+
datePart,
|
|
1120
|
+
clock
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
/** The zone designator to re-attach, or null when `previous` carries none. */
|
|
1124
|
+
function zoneSuffixOf(previous) {
|
|
1125
|
+
if (previous === void 0) return null;
|
|
1126
|
+
return previous.match(ZONE_SUFFIX_RE$1)?.[0] ?? null;
|
|
1127
|
+
}
|
|
1128
|
+
/** Build the event time to send for one field.
|
|
1129
|
+
*
|
|
1130
|
+
* `previous` is the value Google last reported for this event, absent for a
|
|
1131
|
+
* locally-created record. Returns null when `local` is neither a stored
|
|
1132
|
+
* datetime nor a raw Google value — the caller reports that record rather than
|
|
1133
|
+
* letting this invent a time Google would silently accept. */
|
|
1134
|
+
function toGoogleEventTime(local, previous, calendarTimeZone) {
|
|
1135
|
+
if (typeof local !== "string") return null;
|
|
1136
|
+
const trimmed = local.trim();
|
|
1137
|
+
if (trimmed.length === 0) return null;
|
|
1138
|
+
const raw = rawGoogleTime(trimmed);
|
|
1139
|
+
if (raw !== null) return raw;
|
|
1140
|
+
const parts = parseStoredDateTime(trimmed);
|
|
1141
|
+
if (parts === null) return null;
|
|
1142
|
+
if (previous !== void 0 && isCalendarDate(previous)) return allDayFrom(parts.datePart);
|
|
1143
|
+
const zone = zoneSuffixOf(previous);
|
|
1144
|
+
const dateTime = `${parts.datePart}${DATE_TIME_SEPARATOR}${parts.clock}`;
|
|
1145
|
+
return zone === null ? {
|
|
1146
|
+
dateTime,
|
|
1147
|
+
timeZone: calendarTimeZone
|
|
1148
|
+
} : { dateTime: `${dateTime}${zone}` };
|
|
1149
|
+
}
|
|
1150
|
+
//#endregion
|
|
923
1151
|
//#region src/google/collectionDateTime.ts
|
|
924
1152
|
/** All-day events carry no clock; midnight is where the day view places them. */
|
|
925
1153
|
var ALL_DAY_CLOCK = "00:00";
|
|
926
1154
|
var DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
927
1155
|
/** RFC3339 zone designator: `Z`, `+09:00`, `-0500`. */
|
|
928
|
-
var ZONE_SUFFIX_RE
|
|
1156
|
+
var ZONE_SUFFIX_RE = /(?:Z|[+-]\d{2}:?\d{2})$/;
|
|
929
1157
|
/** Fractional seconds have no place in the collection datetime shape. */
|
|
930
|
-
var FRACTIONAL_SECONDS_RE
|
|
1158
|
+
var FRACTIONAL_SECONDS_RE = /\.\d+$/;
|
|
931
1159
|
/** Normalise one Google Calendar time value for storage in a `datetime` field.
|
|
932
1160
|
* Anything that isn't a recognisable Google shape (empty string, non-string,
|
|
933
1161
|
* free text) is returned untouched — the record lint should report it rather
|
|
@@ -937,12 +1165,10 @@ function toCollectionDateTime(value) {
|
|
|
937
1165
|
const trimmed = value.trim();
|
|
938
1166
|
if (DATE_ONLY_RE.test(trimmed)) return `${trimmed}T${ALL_DAY_CLOCK}`;
|
|
939
1167
|
if (!trimmed.includes("T")) return value;
|
|
940
|
-
return trimmed.replace(ZONE_SUFFIX_RE
|
|
1168
|
+
return trimmed.replace(ZONE_SUFFIX_RE, "").replace(FRACTIONAL_SECONDS_RE, "");
|
|
941
1169
|
}
|
|
942
1170
|
//#endregion
|
|
943
|
-
//#region src/google/
|
|
944
|
-
var GOOGLE_CALENDAR_SYNC_TASK_ID = "system:google-calendar-sync";
|
|
945
|
-
var DEFAULT_SYNC_INTERVAL_MS = 3600 * 1e3;
|
|
1171
|
+
//#region src/google/collectionProjection.ts
|
|
946
1172
|
var DATETIME_FIELD_TYPE = "datetime";
|
|
947
1173
|
/** Own-property lookup, mirroring what the record lint reads: a spec reachable
|
|
948
1174
|
* only through the prototype chain is not a DECLARED field, so it must not
|
|
@@ -967,848 +1193,1054 @@ function toCollectionRecord(event, map, primaryKey, fields) {
|
|
|
967
1193
|
[primaryKey]: event.id
|
|
968
1194
|
};
|
|
969
1195
|
}
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
message: `write ${eventId}: ${kind}`
|
|
979
|
-
};
|
|
980
|
-
}
|
|
981
|
-
function classifyDelete(eventId, kind) {
|
|
982
|
-
if (kind === "ok") return { kind: "removed" };
|
|
983
|
-
if (kind === "not-found") return { kind: "skipped" };
|
|
984
|
-
if (kind === "invalid-id") return {
|
|
985
|
-
kind: "unwritable",
|
|
986
|
-
message: `delete ${eventId}: invalid-id`
|
|
987
|
-
};
|
|
1196
|
+
/** Google's mapped values laid over whatever the record already holds.
|
|
1197
|
+
*
|
|
1198
|
+
* A record file is written whole (`writeItem`), so projecting alone would drop
|
|
1199
|
+
* every column the map does not name — a collection cannot then carry a local
|
|
1200
|
+
* note next to a mirrored event, because the next pull that touches that event
|
|
1201
|
+
* silently deletes it. The projection still wins on the fields it covers: those
|
|
1202
|
+
* are Google's to own. */
|
|
1203
|
+
function mergeIntoExisting(existing, projected) {
|
|
988
1204
|
return {
|
|
989
|
-
|
|
990
|
-
|
|
1205
|
+
...existing ?? {},
|
|
1206
|
+
...projected
|
|
991
1207
|
};
|
|
992
1208
|
}
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
message: `apply ${event.id}: ${String(error)}`
|
|
1012
|
-
};
|
|
1013
|
-
}
|
|
1014
|
-
}
|
|
1015
|
-
async function restartFullSync(accessToken, calendarId, workspaceRoot) {
|
|
1016
|
-
await clearCalendarSyncToken(calendarId, workspaceRoot);
|
|
1017
|
-
await clearCalendarShadow(calendarId, workspaceRoot);
|
|
1018
|
-
return await syncCalendarEvents(accessToken, { calendarId });
|
|
1209
|
+
//#endregion
|
|
1210
|
+
//#region src/google/pushPlan.ts
|
|
1211
|
+
/** The event fields Google lets a caller write. `htmlLink` and `status` are
|
|
1212
|
+
* read-only, so a record column mapped to either is ignored here rather than
|
|
1213
|
+
* rejected — the mapping was authored for the pull, and a push has no business
|
|
1214
|
+
* invalidating it. */
|
|
1215
|
+
var PUSHABLE_SOURCE_FIELDS = [
|
|
1216
|
+
"summary",
|
|
1217
|
+
"start",
|
|
1218
|
+
"end",
|
|
1219
|
+
"colorId",
|
|
1220
|
+
"description",
|
|
1221
|
+
"location"
|
|
1222
|
+
];
|
|
1223
|
+
var isPushableSource = (source) => PUSHABLE_SOURCE_FIELDS.includes(source);
|
|
1224
|
+
/** The subset of a schema's `map` a push can act on: collection field → event field. */
|
|
1225
|
+
function pushableMap(map) {
|
|
1226
|
+
return Object.fromEntries(Object.entries(map).filter((entry) => isPushableSource(entry[1])));
|
|
1019
1227
|
}
|
|
1020
|
-
/**
|
|
1021
|
-
*
|
|
1022
|
-
*
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
* queue — `then(run, run)`. */
|
|
1026
|
-
async function withKeyedLock(locks, key, run) {
|
|
1027
|
-
const result = (locks.get(key) ?? Promise.resolve()).then(run, run);
|
|
1028
|
-
const tail = result.then(() => void 0, () => void 0);
|
|
1029
|
-
locks.set(key, tail);
|
|
1030
|
-
try {
|
|
1031
|
-
return await result;
|
|
1032
|
-
} finally {
|
|
1033
|
-
if (locks.get(key) === tail) locks.delete(key);
|
|
1034
|
-
}
|
|
1228
|
+
/** Event field → the record column feeding it. The schema authors the map the
|
|
1229
|
+
* other way round (for the pull); a push looks up by event field. Two columns
|
|
1230
|
+
* mapped to one event field is already ambiguous for the pull — last wins. */
|
|
1231
|
+
function bySourceField(map) {
|
|
1232
|
+
return Object.fromEntries(Object.entries(map).map(([field, source]) => [source, field]));
|
|
1035
1233
|
}
|
|
1036
|
-
/**
|
|
1037
|
-
*
|
|
1038
|
-
* the
|
|
1039
|
-
var
|
|
1040
|
-
|
|
1234
|
+
/** Google accepts a caller-chosen event id of 5-1024 base32hex characters. A
|
|
1235
|
+
* record id outside that (a semantic id like `team-standup`) cannot be used,
|
|
1236
|
+
* and v1 reports it instead of re-keying the record — see the plan. */
|
|
1237
|
+
var CLIENT_EVENT_ID_RE = /^[0-9a-v]{5,1024}$/;
|
|
1238
|
+
var isClientSettableEventId = (eventId) => CLIENT_EVENT_ID_RE.test(eventId);
|
|
1239
|
+
/** A field's value as comparable/sendable text.
|
|
1041
1240
|
*
|
|
1042
|
-
*
|
|
1043
|
-
*
|
|
1044
|
-
*
|
|
1045
|
-
* on the same calendar reading an already-consumed window — silently missing
|
|
1046
|
-
* those events forever. Fetch once, apply to all, then advance the token.
|
|
1047
|
-
* (Codex + CodeRabbit review on #2184.)
|
|
1241
|
+
* Absent, null and `""` all collapse to `""`: Google reports an inherited colour
|
|
1242
|
+
* as `""` while the record file may omit the key entirely, and treating those as
|
|
1243
|
+
* different would report a local edit on every single push.
|
|
1048
1244
|
*
|
|
1049
|
-
*
|
|
1050
|
-
*
|
|
1051
|
-
*
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1245
|
+
* A non-primitive is JSON-encoded rather than left to `String`, whose
|
|
1246
|
+
* `[object Object]` would make two DIFFERENT objects compare equal — a
|
|
1247
|
+
* silently-skipped edit. */
|
|
1248
|
+
var fieldText = (value) => {
|
|
1249
|
+
if (value === void 0 || value === null) return "";
|
|
1250
|
+
if (typeof value === "string") return value;
|
|
1251
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
|
|
1252
|
+
return JSON.stringify(value) ?? "";
|
|
1253
|
+
};
|
|
1254
|
+
/** A stored datetime with no seconds — what the collection's own date-time input
|
|
1255
|
+
* produces. Google's values always carry seconds, so the pull writes them and a
|
|
1256
|
+
* hand-typed value does not. */
|
|
1257
|
+
var SECONDLESS_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
|
|
1258
|
+
var WHOLE_MINUTE = ":00";
|
|
1259
|
+
/** Field text with `…T09:30` and `…T09:30:00` collapsed to one spelling.
|
|
1056
1260
|
*
|
|
1057
|
-
*
|
|
1058
|
-
*
|
|
1059
|
-
*
|
|
1060
|
-
|
|
1061
|
-
|
|
1261
|
+
* Without this, a record the user typed a time into never stops looking edited:
|
|
1262
|
+
* the push writes it, Google answers with seconds, the baseline projection keeps
|
|
1263
|
+
* them, and the seconds-less record then differs from its own baseline on every
|
|
1264
|
+
* later push — re-PATCHing the same event on every click, forever.
|
|
1265
|
+
*
|
|
1266
|
+
* A value that carries a zone offset (a `start` mapped onto a `string` field) is
|
|
1267
|
+
* left alone: it is Google's own text on both sides already. */
|
|
1268
|
+
var comparableText = (value) => {
|
|
1269
|
+
const text = fieldText(value);
|
|
1270
|
+
return SECONDLESS_DATETIME_RE.test(text) ? `${text}${WHOLE_MINUTE}` : text;
|
|
1271
|
+
};
|
|
1272
|
+
/** A baseline shaped as the event it came from, so the comparison can run
|
|
1273
|
+
* through the very projection that wrote the record. `htmlLink`/`status` are
|
|
1274
|
+
* never pushable, so their absence from the baseline cannot matter. */
|
|
1275
|
+
var asEventSummary = (eventId, shadow) => ({
|
|
1276
|
+
id: eventId,
|
|
1277
|
+
htmlLink: "",
|
|
1278
|
+
status: "",
|
|
1279
|
+
...shadow
|
|
1280
|
+
});
|
|
1281
|
+
/** What the record WOULD hold if nobody had edited it since the baseline.
|
|
1282
|
+
*
|
|
1283
|
+
* Deliberately `toCollectionRecord` — the same function the pull writes with —
|
|
1284
|
+
* so "unchanged" here means exactly "the pull would produce this". A private
|
|
1285
|
+
* re-implementation would drift from it and report phantom edits. */
|
|
1286
|
+
function baselineRecord(eventId, shadow, map, primaryKey, fields) {
|
|
1287
|
+
return toCollectionRecord(asEventSummary(eventId, shadow), map, primaryKey, fields);
|
|
1062
1288
|
}
|
|
1063
|
-
|
|
1064
|
-
|
|
1289
|
+
/** The mapped fields whose local value no longer matches the baseline. */
|
|
1290
|
+
function locallyChangedFields(record, baseline, map) {
|
|
1291
|
+
return Object.entries(map).filter(([field]) => comparableText(record[field]) !== comparableText(baseline[field])).map(([, source]) => source);
|
|
1065
1292
|
}
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
for (const collection of collections) results.push(await applyEventsToCollection(collection, result.events, workspaceRoot));
|
|
1075
|
-
const unwritable = results.flatMap((entry) => entry.unwritable);
|
|
1076
|
-
if (unwritable.length > 0) log.warn("google", "skipping calendar events that can never be stored", {
|
|
1077
|
-
calendarId,
|
|
1078
|
-
unwritable
|
|
1079
|
-
});
|
|
1080
|
-
const failed = results.flatMap((entry) => entry.errors);
|
|
1081
|
-
if (failed.length > 0) {
|
|
1082
|
-
log.warn("google", "holding back calendar sync token after failed writes", {
|
|
1083
|
-
calendarId,
|
|
1084
|
-
failed: failed.length
|
|
1085
|
-
});
|
|
1086
|
-
return results;
|
|
1087
|
-
}
|
|
1088
|
-
await saveCalendarShadow(calendarId, shadowUpdates(result.events), workspaceRoot);
|
|
1089
|
-
if (result.nextSyncToken) await advanceToken(calendarId, result.nextSyncToken, collections, workspaceRoot);
|
|
1090
|
-
return results;
|
|
1293
|
+
/** The fields Google changed since the baseline, restricted to the ones the
|
|
1294
|
+
* local edit touches.
|
|
1295
|
+
*
|
|
1296
|
+
* Field-level on purpose: Google moving an event the user retitled locally is
|
|
1297
|
+
* not a conflict, because the patch only carries the title. Comparing whole
|
|
1298
|
+
* events would refuse those pushes for no reason. */
|
|
1299
|
+
function conflictingFields(shadow, current, changed) {
|
|
1300
|
+
return changed.filter((source) => comparableText(shadow[source]) !== comparableText(current[source]));
|
|
1091
1301
|
}
|
|
1092
|
-
/**
|
|
1093
|
-
*
|
|
1094
|
-
|
|
1095
|
-
|
|
1302
|
+
/** Classify one record against its baseline, before Google is consulted.
|
|
1303
|
+
*
|
|
1304
|
+
* No baseline means the record never came from a sync, so it is local-only and
|
|
1305
|
+
* has to be created. Google is queried ONLY for a `changed` record, which is
|
|
1306
|
+
* what keeps a push over a large collection cheap. */
|
|
1307
|
+
function planRecord(eventId, record, shadow, map, primaryKey, fields) {
|
|
1308
|
+
if (shadow === void 0) return { kind: "create" };
|
|
1309
|
+
const changed = locallyChangedFields(record, baselineRecord(eventId, shadow, map, primaryKey, fields), map);
|
|
1310
|
+
return changed.length === 0 ? { kind: "unchanged" } : {
|
|
1311
|
+
kind: "changed",
|
|
1312
|
+
fields: changed
|
|
1313
|
+
};
|
|
1096
1314
|
}
|
|
1097
|
-
/**
|
|
1098
|
-
*
|
|
1315
|
+
/** Whether an event that already holds this record's id may be adopted as its
|
|
1316
|
+
* baseline.
|
|
1099
1317
|
*
|
|
1100
|
-
*
|
|
1101
|
-
*
|
|
1102
|
-
*
|
|
1103
|
-
*
|
|
1104
|
-
*
|
|
1318
|
+
* A 409 from `insert` usually means a previous push created the event but its
|
|
1319
|
+
* baseline never landed. It can ALSO mean an unrelated event happens to hold
|
|
1320
|
+
* this id, and adopting that would bind the record to a stranger's event — so
|
|
1321
|
+
* adoption needs evidence the two are the same logical event, not just the same
|
|
1322
|
+
* id (Codex review).
|
|
1105
1323
|
*
|
|
1106
|
-
*
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
}
|
|
1112
|
-
await saveCalendarSyncToken(calendarId, nextSyncToken, workspaceRoot);
|
|
1113
|
-
}
|
|
1114
|
-
async function pathExists(absPath) {
|
|
1115
|
-
try {
|
|
1116
|
-
await stat(absPath);
|
|
1117
|
-
return true;
|
|
1118
|
-
} catch {
|
|
1119
|
-
return false;
|
|
1120
|
-
}
|
|
1324
|
+
* The evidence is that the remote event ALREADY equals what this record would
|
|
1325
|
+
* write. Adoption then records a baseline and writes nothing, so it cannot
|
|
1326
|
+
* modify whatever that event is. Anything else is refused. */
|
|
1327
|
+
function mayAdoptExisting(eventId, record, existing, map, primaryKey, fields) {
|
|
1328
|
+
return locallyChangedFields(record, baselineRecord(eventId, existing, map, primaryKey, fields), map).length === 0;
|
|
1121
1329
|
}
|
|
1122
|
-
/**
|
|
1123
|
-
*
|
|
1124
|
-
*
|
|
1125
|
-
|
|
1126
|
-
|
|
1330
|
+
/** Record ids the baseline knows but the collection no longer holds — deleted
|
|
1331
|
+
* locally. v1 reports the count and pushes nothing: `calendarDeleteEvent`
|
|
1332
|
+
* removes the event for every attendee and cannot be undone. */
|
|
1333
|
+
function locallyDeletedIds(shadow, presentIds) {
|
|
1334
|
+
const present = new Set(presentIds);
|
|
1335
|
+
return Object.keys(shadow).filter((eventId) => !present.has(eventId));
|
|
1127
1336
|
}
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1337
|
+
//#endregion
|
|
1338
|
+
//#region src/google/collectionPush.ts
|
|
1339
|
+
/** Access roles that may write events. `reader` / `freeBusyReader` cannot, and a
|
|
1340
|
+
* push under them fails per event with an opaque 403 — checked once up front so
|
|
1341
|
+
* the user is told the real reason. */
|
|
1342
|
+
var WRITABLE_ACCESS_ROLES = ["owner", "writer"];
|
|
1343
|
+
/** Whether to refuse the whole push before touching a single event.
|
|
1344
|
+
*
|
|
1345
|
+
* Only on POSITIVE evidence of a non-writable role. An unlisted calendar is
|
|
1346
|
+
* unknown and must fall through: hard-denying it would block the feature
|
|
1347
|
+
* outright for a calendar the user can in fact write to. Those attempts surface
|
|
1348
|
+
* Google's own 403 per record if the write really is not allowed. */
|
|
1349
|
+
var isDeniedAccessRole = (accessRole) => accessRole !== null && !WRITABLE_ACCESS_ROLES.includes(accessRole);
|
|
1350
|
+
/** Resolve the calendar a schema names, for its writability and its timezone.
|
|
1351
|
+
*
|
|
1352
|
+
* The list is consulted first because only it reports `accessRole`, which is
|
|
1353
|
+
* what lets a read-only calendar be refused with the real reason instead of an
|
|
1354
|
+
* opaque per-event 403. `"primary"` is matched on the `primary` flag, not the
|
|
1355
|
+
* id — the primary calendar's own id is the account's email address, which never
|
|
1356
|
+
* equals the literal the schema declares.
|
|
1357
|
+
*
|
|
1358
|
+
* A calendar absent from the list is NOT denied: it is fetched by id for its
|
|
1359
|
+
* timezone and its role left unknown. A 404 there means the calendar really is
|
|
1360
|
+
* unreachable, and propagates as a `failed` outcome. */
|
|
1361
|
+
async function liveCalendarMeta(accessToken, calendarId) {
|
|
1362
|
+
const key = canonicalCalendarId(calendarId);
|
|
1363
|
+
const calendars = await listCalendars(accessToken);
|
|
1364
|
+
const listed = key === "primary" ? calendars.find((calendar) => calendar.primary) : calendars.find((calendar) => calendar.id === key);
|
|
1365
|
+
if (listed) return {
|
|
1366
|
+
accessRole: listed.accessRole,
|
|
1367
|
+
timeZone: listed.timeZone
|
|
1368
|
+
};
|
|
1369
|
+
const direct = await getCalendar(accessToken, calendarId);
|
|
1370
|
+
log.info("google", "calendar is not in the user's list — pushing without an up-front role check", { calendarId: key });
|
|
1131
1371
|
return {
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
removed: outcomes.filter((outcome) => outcome.kind === "removed").length,
|
|
1135
|
-
unwritable: outcomes.flatMap((outcome) => outcome.kind === "unwritable" ? [outcome.message] : []),
|
|
1136
|
-
errors: outcomes.flatMap((outcome) => outcome.kind === "error" ? [outcome.message] : [])
|
|
1372
|
+
accessRole: null,
|
|
1373
|
+
timeZone: direct.timeZone
|
|
1137
1374
|
};
|
|
1138
1375
|
}
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
async function isGoogleLinked() {
|
|
1142
|
-
return Boolean((await loadGoogleTokens())?.refresh_token);
|
|
1376
|
+
async function findCalendarCollection(slug, workspaceRoot) {
|
|
1377
|
+
return (await discoverCollections({ workspaceRoot })).find((collection) => collection.slug === slug && collection.schema.googleCalendar) ?? null;
|
|
1143
1378
|
}
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
return
|
|
1379
|
+
var liveDeps = {
|
|
1380
|
+
findCollection: findCalendarCollection,
|
|
1381
|
+
isLinked: async () => Boolean((await loadGoogleTokens())?.refresh_token),
|
|
1382
|
+
accessToken: getGoogleAccessToken,
|
|
1383
|
+
calendarMeta: liveCalendarMeta
|
|
1384
|
+
};
|
|
1385
|
+
var localValue = (ctx, record, source) => {
|
|
1386
|
+
const field = ctx.bySource[source];
|
|
1387
|
+
return field === void 0 ? void 0 : record[field];
|
|
1388
|
+
};
|
|
1389
|
+
/** One end of the span, rebuilt from the stored clock plus whatever the baseline
|
|
1390
|
+
* remembers about the original (zone, all-day). */
|
|
1391
|
+
function eventTime(ctx, record, source, previous) {
|
|
1392
|
+
const value = localValue(ctx, record, source);
|
|
1393
|
+
if (value === void 0) return {
|
|
1394
|
+
ok: false,
|
|
1395
|
+
reason: `no \`${source}\` field is mapped`
|
|
1396
|
+
};
|
|
1397
|
+
const time = toGoogleEventTime(value, previous, ctx.timeZone);
|
|
1398
|
+
if (time === null) return {
|
|
1399
|
+
ok: false,
|
|
1400
|
+
reason: `\`${source}\` is not a date-time Google accepts (got ${JSON.stringify(value)})`
|
|
1401
|
+
};
|
|
1402
|
+
if ("dateTime" in time && time.timeZone === "") return {
|
|
1403
|
+
ok: false,
|
|
1404
|
+
reason: `the calendar reports no timezone, so \`${source}\` cannot be sent without one`
|
|
1405
|
+
};
|
|
1406
|
+
return {
|
|
1407
|
+
ok: true,
|
|
1408
|
+
time
|
|
1409
|
+
};
|
|
1159
1410
|
}
|
|
1160
|
-
/**
|
|
1161
|
-
*
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1411
|
+
/** `description` / `location` for a create — omitted when empty, since there is
|
|
1412
|
+
* nothing to state about an event that carries neither. */
|
|
1413
|
+
var optionalText = (ctx, record) => {
|
|
1414
|
+
const description = fieldText(localValue(ctx, record, "description"));
|
|
1415
|
+
const location = fieldText(localValue(ctx, record, "location"));
|
|
1416
|
+
return {
|
|
1417
|
+
...description ? { description } : {},
|
|
1418
|
+
...location ? { location } : {}
|
|
1419
|
+
};
|
|
1420
|
+
};
|
|
1421
|
+
async function createFromRecord(ctx, eventId, record) {
|
|
1422
|
+
if (!isClientSettableEventId(eventId)) return {
|
|
1423
|
+
kind: "skipped",
|
|
1424
|
+
message: `${eventId}: the record id cannot be used as a Google event id (needs 5-1024 characters from 0-9a-v)`
|
|
1425
|
+
};
|
|
1426
|
+
const start = eventTime(ctx, record, "start", void 0);
|
|
1427
|
+
const end = eventTime(ctx, record, "end", void 0);
|
|
1428
|
+
if (!start.ok) return {
|
|
1429
|
+
kind: "skipped",
|
|
1430
|
+
message: `${eventId}: ${start.reason}`
|
|
1431
|
+
};
|
|
1432
|
+
if (!end.ok) return {
|
|
1433
|
+
kind: "skipped",
|
|
1434
|
+
message: `${eventId}: ${end.reason}`
|
|
1435
|
+
};
|
|
1436
|
+
const colorId = fieldText(localValue(ctx, record, "colorId"));
|
|
1437
|
+
return {
|
|
1438
|
+
kind: "created",
|
|
1439
|
+
event: await createCalendarEvent(ctx.accessToken, {
|
|
1440
|
+
eventId,
|
|
1441
|
+
calendarId: ctx.calendarId,
|
|
1442
|
+
summary: fieldText(localValue(ctx, record, "summary")),
|
|
1443
|
+
start: start.time,
|
|
1444
|
+
end: end.time,
|
|
1445
|
+
...optionalText(ctx, record),
|
|
1446
|
+
...colorId ? { colorId } : {}
|
|
1447
|
+
})
|
|
1448
|
+
};
|
|
1175
1449
|
}
|
|
1176
|
-
/**
|
|
1177
|
-
*
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1450
|
+
/** The free-text fields of a patch, each only when the local edit touched it.
|
|
1451
|
+
* Unlike `colorId`, `""` is a real value here — Google clears the field. */
|
|
1452
|
+
var textPatch = (ctx, record, changed) => {
|
|
1453
|
+
const touched = [
|
|
1454
|
+
"summary",
|
|
1455
|
+
"description",
|
|
1456
|
+
"location"
|
|
1457
|
+
].filter((source) => changed.includes(source));
|
|
1458
|
+
return Object.fromEntries(touched.map((source) => [source, fieldText(localValue(ctx, record, source))]));
|
|
1459
|
+
};
|
|
1460
|
+
/** Only the fields the local edit touched — PATCH semantics, so an untouched
|
|
1461
|
+
* attendee list / recurrence rule is left alone. */
|
|
1462
|
+
function buildPatch(ctx, record, changed, shadow) {
|
|
1463
|
+
const resolved = ["start", "end"].filter((source) => changed.includes(source)).map((source) => ({
|
|
1464
|
+
source,
|
|
1465
|
+
result: eventTime(ctx, record, source, shadow[source])
|
|
1466
|
+
}));
|
|
1467
|
+
const [failure] = resolved.flatMap((entry) => entry.result.ok ? [] : [entry.result.reason]);
|
|
1468
|
+
if (failure !== void 0) return {
|
|
1469
|
+
ok: false,
|
|
1470
|
+
reason: failure
|
|
1471
|
+
};
|
|
1472
|
+
const colorId = fieldText(localValue(ctx, record, "colorId"));
|
|
1473
|
+
if (changed.includes("colorId") && colorId === "") return {
|
|
1474
|
+
ok: false,
|
|
1475
|
+
reason: "Google cannot clear an event colour once set"
|
|
1476
|
+
};
|
|
1477
|
+
return {
|
|
1478
|
+
ok: true,
|
|
1479
|
+
patch: {
|
|
1480
|
+
...textPatch(ctx, record, changed),
|
|
1481
|
+
...changed.includes("colorId") ? { colorId } : {},
|
|
1482
|
+
...Object.fromEntries(resolved.flatMap((entry) => entry.result.ok ? [[entry.source, entry.result.time]] : []))
|
|
1483
|
+
}
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
async function updateFromRecord(ctx, eventId, record, changed, shadow) {
|
|
1487
|
+
const fetched = await getCalendarEvent(ctx.accessToken, {
|
|
1488
|
+
calendarId: ctx.calendarId,
|
|
1489
|
+
eventId
|
|
1490
|
+
});
|
|
1491
|
+
if (fetched === null || fetched.event.status === "cancelled") return {
|
|
1492
|
+
kind: "skipped",
|
|
1493
|
+
message: `${eventId}: the event no longer exists in Google — sync first, then re-create it`
|
|
1494
|
+
};
|
|
1495
|
+
if (conflictingFields(shadow, toShadowEvent(fetched.event), changed).length > 0) return { kind: "conflict" };
|
|
1496
|
+
const built = buildPatch(ctx, record, changed, shadow);
|
|
1497
|
+
if (!built.ok) return {
|
|
1498
|
+
kind: "skipped",
|
|
1499
|
+
message: `${eventId}: ${built.reason}`
|
|
1500
|
+
};
|
|
1183
1501
|
try {
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1502
|
+
return {
|
|
1503
|
+
kind: "updated",
|
|
1504
|
+
event: await updateCalendarEvent(ctx.accessToken, {
|
|
1505
|
+
eventId,
|
|
1506
|
+
calendarId: ctx.calendarId,
|
|
1507
|
+
ifMatch: fetched.etag,
|
|
1508
|
+
...built.patch
|
|
1509
|
+
})
|
|
1510
|
+
};
|
|
1191
1511
|
} catch (error) {
|
|
1192
|
-
|
|
1193
|
-
|
|
1512
|
+
if (isGoogleApiError(error) && error.status === 412) return { kind: "conflict" };
|
|
1513
|
+
throw error;
|
|
1194
1514
|
}
|
|
1195
1515
|
}
|
|
1196
|
-
/**
|
|
1197
|
-
async function declaringGroups(workspaceRoot) {
|
|
1198
|
-
return groupByCalendar((await discoverCollections({ workspaceRoot })).filter((collection) => collection.schema.googleCalendar));
|
|
1199
|
-
}
|
|
1200
|
-
/** Whether a background sync of these groups may run at all.
|
|
1516
|
+
/** Create, recovering from the one 409 that is provably safe to recover from.
|
|
1201
1517
|
*
|
|
1202
|
-
*
|
|
1203
|
-
*
|
|
1204
|
-
*
|
|
1205
|
-
*
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
async function runCalendarGroups(groups, workspaceRoot) {
|
|
1215
|
-
const results = [];
|
|
1216
|
-
for (const [calendarId, collections] of groups) try {
|
|
1217
|
-
results.push(...await syncCalendarGroup(calendarId, collections, workspaceRoot));
|
|
1518
|
+
* A 409 means the id is taken, which is USUALLY a previous push whose baseline
|
|
1519
|
+
* never landed. It can also be an unrelated event that happens to hold this id,
|
|
1520
|
+
* so the recovery never writes: it adopts the baseline only when the remote
|
|
1521
|
+
* event already equals what this record would write, and otherwise reports the
|
|
1522
|
+
* fix. Patching whatever answered the 409 would let a push modify a stranger's
|
|
1523
|
+
* event (Codex review).
|
|
1524
|
+
*
|
|
1525
|
+
* Refusal is not a dead end — a Sync writes both the record and its baseline,
|
|
1526
|
+
* after which the record pushes normally. */
|
|
1527
|
+
async function createOrAdopt(ctx, eventId, record) {
|
|
1528
|
+
try {
|
|
1529
|
+
return await createFromRecord(ctx, eventId, record);
|
|
1218
1530
|
} catch (error) {
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1531
|
+
if (!isGoogleApiError(error) || error.status !== 409) throw error;
|
|
1532
|
+
const fetched = await getCalendarEvent(ctx.accessToken, {
|
|
1533
|
+
calendarId: ctx.calendarId,
|
|
1534
|
+
eventId
|
|
1222
1535
|
});
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1536
|
+
if (fetched === null) throw error;
|
|
1537
|
+
if (mayAdoptExisting(eventId, record, toShadowEvent(fetched.event), ctx.map, ctx.primaryKey, ctx.fields)) return {
|
|
1538
|
+
kind: "unchanged",
|
|
1539
|
+
event: fetched.event
|
|
1540
|
+
};
|
|
1541
|
+
return {
|
|
1542
|
+
kind: "skipped",
|
|
1543
|
+
message: `${eventId}: an event with this id already exists in Google and differs from this record — press Sync to adopt it, then push again`
|
|
1544
|
+
};
|
|
1230
1545
|
}
|
|
1231
|
-
return results;
|
|
1232
|
-
}
|
|
1233
|
-
/** Sync every collection that declares `googleCalendar`. */
|
|
1234
|
-
async function syncDueCalendarCollections(workspaceRoot) {
|
|
1235
|
-
const groups = await declaringGroups(workspaceRoot);
|
|
1236
|
-
return await backgroundSyncAllowed(groups) ? await runCalendarGroups(groups, workspaceRoot) : [];
|
|
1237
|
-
}
|
|
1238
|
-
/** The groups whose calendar has never synced. A missing token IS the "created
|
|
1239
|
-
* since the last sync" signal — nothing else distinguishes a new collection
|
|
1240
|
-
* from an edited one on the write path this feeds (#2427).
|
|
1241
|
-
*
|
|
1242
|
-
* Self-silencing by construction: the first sync stores a token, so a calendar
|
|
1243
|
-
* matches at most once. `loadToken` is injected so the rule is testable without
|
|
1244
|
-
* a workspace on disk. */
|
|
1245
|
-
async function unsyncedGroups(groups, loadToken) {
|
|
1246
|
-
const checked = await Promise.all([...groups].map(async (entry) => await loadToken(entry[0]) === null ? entry : null));
|
|
1247
|
-
return new Map(checked.filter((entry) => entry !== null));
|
|
1248
|
-
}
|
|
1249
|
-
/** Sync only the calendars that have never synced — the first sync for a
|
|
1250
|
-
* just-created collection, which otherwise stays empty until the hourly
|
|
1251
|
-
* scheduler run (#2427). Cheap and safe to call on every config write. */
|
|
1252
|
-
async function syncNewCalendarCollections(workspaceRoot) {
|
|
1253
|
-
const pending = await unsyncedGroups(await declaringGroups(workspaceRoot), (calendarId) => loadCalendarSyncToken(calendarId, workspaceRoot));
|
|
1254
|
-
if (!await backgroundSyncAllowed(pending)) return [];
|
|
1255
|
-
log.info("google", "running the first sync for newly declared calendars", { calendars: [...pending.keys()] });
|
|
1256
|
-
return await runCalendarGroups(pending, workspaceRoot);
|
|
1257
1546
|
}
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
* on that calendar reading an already-consumed one. Returns every result of the
|
|
1268
|
-
* group so the caller can report the requested slug's own counts.
|
|
1269
|
-
*
|
|
1270
|
-
* "Does this collection sync at all" is answered BEFORE "is Google linked":
|
|
1271
|
-
* telling someone to link their account for a collection that never declared a
|
|
1272
|
-
* calendar sends them fixing the wrong thing. */
|
|
1273
|
-
async function syncCalendarForCollection(slug, workspaceRoot, deps = liveManualSyncDeps) {
|
|
1274
|
-
const owning = [...await deps.loadGroups(workspaceRoot)].filter(([, collections]) => collections.some((collection) => collection.slug === slug));
|
|
1275
|
-
if (owning.length === 0) return { kind: "not-a-calendar" };
|
|
1276
|
-
if (!await deps.isLinked()) return { kind: "not-linked" };
|
|
1277
|
-
return {
|
|
1278
|
-
kind: "synced",
|
|
1279
|
-
results: await deps.runGroups(new Map(owning), workspaceRoot)
|
|
1547
|
+
/** A 403 on a write is a permissions answer, but `googleApiError` appends the
|
|
1548
|
+
* "is the API enabled for the Cloud project?" hint to every 403 — true for a
|
|
1549
|
+
* disabled API, misleading here. Reached when the up-front role check could not
|
|
1550
|
+
* run because the calendar is not in the user's list, which is exactly when the
|
|
1551
|
+
* user needs the real reason. */
|
|
1552
|
+
function writeFailure(eventId, error) {
|
|
1553
|
+
if (isGoogleApiError(error) && error.status === 403) return {
|
|
1554
|
+
kind: "skipped",
|
|
1555
|
+
message: `${eventId}: Google refused the write — you may not have permission to change events on this calendar`
|
|
1280
1556
|
};
|
|
1281
|
-
}
|
|
1282
|
-
/** Scheduler registration, shaped like `feedRefreshTaskDef` so hosts wire it
|
|
1283
|
-
* with a single line. */
|
|
1284
|
-
function googleCalendarSyncTaskDef(opts) {
|
|
1285
1557
|
return {
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
description: "Pulls changed Google Calendar events into any collection declaring `googleCalendar`, without invoking the LLM.",
|
|
1289
|
-
schedule: {
|
|
1290
|
-
type: SCHEDULE_TYPES.interval,
|
|
1291
|
-
intervalMs: opts?.intervalMs ?? DEFAULT_SYNC_INTERVAL_MS
|
|
1292
|
-
},
|
|
1293
|
-
missedRunPolicy: MISSED_RUN_POLICIES.runOnce,
|
|
1294
|
-
run: () => syncDueCalendarCollections(opts?.workspaceRoot ?? getWorkspaceRoot()).then(() => {})
|
|
1558
|
+
kind: "error",
|
|
1559
|
+
message: `${eventId}: ${String(error)}`
|
|
1295
1560
|
};
|
|
1296
1561
|
}
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
var WHOLE_MINUTE_SECONDS = "00";
|
|
1308
|
-
var MAX_SECONDS = 59;
|
|
1309
|
-
/** Whether `value` is a real calendar day (`2026-02-30` is not).
|
|
1310
|
-
*
|
|
1311
|
-
* Delegated to the parser the record lint, the calendar grid and the day view
|
|
1312
|
-
* already share, so the push agrees with the rest of the app on what a valid
|
|
1313
|
-
* stored date IS — and rejects an impossible one here instead of turning it
|
|
1314
|
-
* into an opaque Google 400. */
|
|
1315
|
-
var isCalendarDate = (value) => parseIsoDate(value) !== null;
|
|
1316
|
-
/** A value Google already accepts verbatim, as the event time to send, or null.
|
|
1317
|
-
*
|
|
1318
|
-
* Reached when the schema maps `start` onto a `string` field: `projectValue`
|
|
1319
|
-
* normalises only `datetime` fields, so that column holds Google's own text,
|
|
1320
|
-
* zone and all. The civil part is still validated — matching a trailing `Z` is
|
|
1321
|
-
* not evidence that the rest is a date-time. */
|
|
1322
|
-
function rawGoogleTime(value) {
|
|
1323
|
-
if (isCalendarDate(value)) return { date: value };
|
|
1324
|
-
const zone = value.match(ZONE_SUFFIX_RE)?.[0];
|
|
1325
|
-
if (zone === void 0) return null;
|
|
1326
|
-
return parseIsoDateTime(value.slice(0, value.length - zone.length).replace(FRACTIONAL_SECONDS_RE, "")) === null ? null : { dateTime: value };
|
|
1562
|
+
async function pushRecord(ctx, eventId, record) {
|
|
1563
|
+
try {
|
|
1564
|
+
const shadow = ctx.shadow[eventId];
|
|
1565
|
+
const plan = planRecord(eventId, record, shadow, ctx.map, ctx.primaryKey, ctx.fields);
|
|
1566
|
+
if (plan.kind === "unchanged") return { kind: "unchanged" };
|
|
1567
|
+
if (plan.kind === "create" || shadow === void 0) return await createOrAdopt(ctx, eventId, record);
|
|
1568
|
+
return await updateFromRecord(ctx, eventId, record, plan.fields, shadow);
|
|
1569
|
+
} catch (error) {
|
|
1570
|
+
return writeFailure(eventId, error);
|
|
1571
|
+
}
|
|
1327
1572
|
}
|
|
1328
|
-
/**
|
|
1329
|
-
*
|
|
1330
|
-
*
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
return
|
|
1573
|
+
/** The baseline an outcome establishes, so the next push sees the record as
|
|
1574
|
+
* unchanged instead of pushing it again. Empty for anything that wrote nothing
|
|
1575
|
+
* and had no baseline to repair. */
|
|
1576
|
+
function pushedShadow(outcomes) {
|
|
1577
|
+
const written = outcomes.flatMap((outcome) => "event" in outcome && outcome.event !== void 0 ? [outcome.event] : []);
|
|
1578
|
+
return Object.fromEntries(written.map((event) => [event.id, toShadowEvent(event)]));
|
|
1334
1579
|
}
|
|
1335
|
-
/**
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
*
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1580
|
+
/** See `unpushedIds` on the result for why `skipped` is not in this set. */
|
|
1581
|
+
var UNPUSHED_KINDS = ["conflict", "error"];
|
|
1582
|
+
/** Whether this outcome leaves a local edit that only exists locally, so the
|
|
1583
|
+
* pull must not overwrite the record and the baseline must not advance. */
|
|
1584
|
+
var isUnpushed = (kind) => UNPUSHED_KINDS.includes(kind);
|
|
1585
|
+
function tally(slug, attempts, localDeletes) {
|
|
1586
|
+
const outcomes = attempts.map((attempt) => attempt.outcome);
|
|
1587
|
+
const count = (kind) => outcomes.filter((outcome) => outcome.kind === kind).length;
|
|
1588
|
+
return {
|
|
1589
|
+
slug,
|
|
1590
|
+
created: count("created"),
|
|
1591
|
+
updated: count("updated"),
|
|
1592
|
+
conflicts: count("conflict"),
|
|
1593
|
+
localDeletes,
|
|
1594
|
+
skipped: outcomes.flatMap((outcome) => outcome.kind === "skipped" ? [outcome.message] : []),
|
|
1595
|
+
errors: outcomes.flatMap((outcome) => outcome.kind === "error" ? [outcome.message] : []),
|
|
1596
|
+
unpushedIds: attempts.filter((attempt) => isUnpushed(attempt.outcome.kind)).map((attempt) => attempt.eventId)
|
|
1597
|
+
};
|
|
1350
1598
|
}
|
|
1351
|
-
/**
|
|
1599
|
+
/** Record ids whose local value no longer matches the baseline — edits that have
|
|
1600
|
+
* not reached Google.
|
|
1352
1601
|
*
|
|
1353
|
-
*
|
|
1354
|
-
*
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1602
|
+
* Pure, and deliberately built from `planRecord`, so it answers with exactly the
|
|
1603
|
+
* records a push WOULD have acted on. A private re-implementation would drift
|
|
1604
|
+
* from the push and protect the wrong set. */
|
|
1605
|
+
function locallyEditedIds(records, shadow, map, primaryKey, fields) {
|
|
1606
|
+
return records.map((record) => ({
|
|
1607
|
+
eventId: fieldText(record[primaryKey]),
|
|
1608
|
+
record
|
|
1609
|
+
})).filter(({ eventId, record }) => planRecord(eventId, record, shadow[eventId], map, primaryKey, fields).kind !== "unchanged").map(({ eventId }) => eventId);
|
|
1610
|
+
}
|
|
1611
|
+
/** The same set, read from the workspace.
|
|
1612
|
+
*
|
|
1613
|
+
* Needed when a push cannot run AT ALL — a calendar whose role degraded to
|
|
1614
|
+
* reader, a revoked grant, an IO failure. The pull still runs, since reading
|
|
1615
|
+
* needs no write access, and would overwrite exactly these records while
|
|
1616
|
+
* advancing their baseline past the edit (CodeRabbit review #2666). Computed
|
|
1617
|
+
* from the stored baseline alone, so it works precisely when Google is the
|
|
1618
|
+
* thing that is unreachable. */
|
|
1619
|
+
async function unsentLocalEdits(collection, workspaceRoot) {
|
|
1620
|
+
const { schema } = collection;
|
|
1621
|
+
const shadow = await loadCalendarShadow(schema.googleCalendar?.calendarId, workspaceRoot);
|
|
1622
|
+
return locallyEditedIds(await storeFor(collection, { workspaceRoot }).list(), shadow, pushableMap(schema.googleCalendar?.map ?? {}), schema.primaryKey, schema.fields);
|
|
1364
1623
|
}
|
|
1365
|
-
/**
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1624
|
+
/** Push one collection WITHOUT taking the calendar lock.
|
|
1625
|
+
*
|
|
1626
|
+
* The caller must already hold it. The scheduled push→pull cycle
|
|
1627
|
+
* (`collectionSync.ts`) runs inside the lock it took for the pull, and the lock
|
|
1628
|
+
* is not reentrant — going through `pushCalendarForCollection` there would make
|
|
1629
|
+
* the sync wait on itself forever. */
|
|
1630
|
+
async function pushCollectionNow(collection, workspaceRoot, deps = liveDeps) {
|
|
1631
|
+
const { schema, slug } = collection;
|
|
1632
|
+
const calendarId = schema.googleCalendar?.calendarId;
|
|
1633
|
+
const accessToken = await deps.accessToken();
|
|
1634
|
+
const meta = await deps.calendarMeta(accessToken, calendarId);
|
|
1635
|
+
if (isDeniedAccessRole(meta.accessRole)) return {
|
|
1636
|
+
kind: "read-only",
|
|
1637
|
+
accessRole: meta.accessRole ?? ""
|
|
1638
|
+
};
|
|
1639
|
+
const map = pushableMap(schema.googleCalendar?.map ?? {});
|
|
1640
|
+
const shadow = await loadCalendarShadow(calendarId, workspaceRoot);
|
|
1641
|
+
const records = await storeFor(collection, { workspaceRoot }).list();
|
|
1642
|
+
const ctx = {
|
|
1643
|
+
accessToken,
|
|
1644
|
+
calendarId,
|
|
1645
|
+
timeZone: meta.timeZone,
|
|
1646
|
+
map,
|
|
1647
|
+
bySource: bySourceField(map),
|
|
1648
|
+
primaryKey: schema.primaryKey,
|
|
1649
|
+
fields: schema.fields,
|
|
1650
|
+
shadow
|
|
1651
|
+
};
|
|
1652
|
+
const attempts = [];
|
|
1653
|
+
for (const record of records) {
|
|
1654
|
+
const eventId = fieldText(record[schema.primaryKey]);
|
|
1655
|
+
const outcome = await pushRecord(ctx, eventId, record);
|
|
1656
|
+
attempts.push({
|
|
1657
|
+
eventId,
|
|
1658
|
+
outcome
|
|
1659
|
+
});
|
|
1660
|
+
await saveCalendarShadow(calendarId, pushedShadow([outcome]), workspaceRoot);
|
|
1661
|
+
}
|
|
1662
|
+
const result = tally(slug, attempts, locallyDeletedIds(shadow, records.map((record) => fieldText(record[schema.primaryKey]))).length);
|
|
1663
|
+
if (result.errors.length > 0) log.warn("google", "calendar push finished with errors", {
|
|
1664
|
+
slug,
|
|
1665
|
+
errors: result.errors.length
|
|
1666
|
+
});
|
|
1667
|
+
return {
|
|
1668
|
+
kind: "pushed",
|
|
1669
|
+
result
|
|
1670
|
+
};
|
|
1369
1671
|
}
|
|
1370
|
-
/**
|
|
1672
|
+
/** Push ONE collection's records to the calendar it declares.
|
|
1371
1673
|
*
|
|
1372
|
-
*
|
|
1373
|
-
*
|
|
1374
|
-
*
|
|
1375
|
-
*
|
|
1376
|
-
function
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1674
|
+
* "Does this collection sync at all" is answered before "is Google linked", for
|
|
1675
|
+
* the reason `syncCalendarForCollection` gives: telling someone to link their
|
|
1676
|
+
* account for a collection that never declared a calendar sends them fixing the
|
|
1677
|
+
* wrong thing. */
|
|
1678
|
+
async function pushCalendarForCollection(slug, workspaceRoot, deps = liveDeps) {
|
|
1679
|
+
try {
|
|
1680
|
+
const collection = await deps.findCollection(slug, workspaceRoot);
|
|
1681
|
+
if (collection === null) return { kind: "not-a-calendar" };
|
|
1682
|
+
if (!await deps.isLinked()) return { kind: "not-linked" };
|
|
1683
|
+
return await withCalendarLock(collection.schema.googleCalendar?.calendarId, () => pushCollectionNow(collection, workspaceRoot, deps));
|
|
1684
|
+
} catch (error) {
|
|
1685
|
+
log.warn("google", "calendar push could not start", {
|
|
1686
|
+
slug,
|
|
1687
|
+
error: String(error)
|
|
1688
|
+
});
|
|
1689
|
+
return {
|
|
1690
|
+
kind: "failed",
|
|
1691
|
+
message: String(error)
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1391
1694
|
}
|
|
1392
1695
|
//#endregion
|
|
1393
|
-
//#region src/google/
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
function pushableMap(map) {
|
|
1407
|
-
return Object.fromEntries(Object.entries(map).filter((entry) => isPushableSource(entry[1])));
|
|
1696
|
+
//#region src/google/collectionSync.ts
|
|
1697
|
+
var GOOGLE_CALENDAR_SYNC_TASK_ID = "system:google-calendar-sync";
|
|
1698
|
+
var DEFAULT_SYNC_INTERVAL_MS = 3600 * 1e3;
|
|
1699
|
+
function classifyWrite(eventId, kind) {
|
|
1700
|
+
if (kind === "ok") return { kind: "written" };
|
|
1701
|
+
if (kind === "invalid-id") return {
|
|
1702
|
+
kind: "unwritable",
|
|
1703
|
+
message: `write ${eventId}: invalid-id`
|
|
1704
|
+
};
|
|
1705
|
+
return {
|
|
1706
|
+
kind: "error",
|
|
1707
|
+
message: `write ${eventId}: ${kind}`
|
|
1708
|
+
};
|
|
1408
1709
|
}
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1710
|
+
function classifyDelete(eventId, kind) {
|
|
1711
|
+
if (kind === "ok") return { kind: "removed" };
|
|
1712
|
+
if (kind === "not-found") return { kind: "skipped" };
|
|
1713
|
+
if (kind === "invalid-id") return {
|
|
1714
|
+
kind: "unwritable",
|
|
1715
|
+
message: `delete ${eventId}: invalid-id`
|
|
1716
|
+
};
|
|
1717
|
+
return {
|
|
1718
|
+
kind: "error",
|
|
1719
|
+
message: `delete ${eventId}: ${kind}`
|
|
1720
|
+
};
|
|
1414
1721
|
}
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1722
|
+
async function applyEvent(collection, event, workspaceRoot) {
|
|
1723
|
+
const { schema } = collection;
|
|
1724
|
+
try {
|
|
1725
|
+
const store = storeFor(collection, { workspaceRoot });
|
|
1726
|
+
if (!store.write || !store.delete) return {
|
|
1727
|
+
kind: "unwritable",
|
|
1728
|
+
message: `collection '${collection.slug}' is read-only`
|
|
1729
|
+
};
|
|
1730
|
+
if (event.status === "cancelled") {
|
|
1731
|
+
const deleted = await store.delete(event.id);
|
|
1732
|
+
return classifyDelete(event.id, deleted.kind);
|
|
1733
|
+
}
|
|
1734
|
+
const record = toCollectionRecord(event, schema.googleCalendar?.map ?? {}, schema.primaryKey, schema.fields);
|
|
1735
|
+
const written = await store.write(event.id, mergeIntoExisting(await store.read(event.id), record));
|
|
1736
|
+
return classifyWrite(event.id, written.kind);
|
|
1737
|
+
} catch (error) {
|
|
1738
|
+
return {
|
|
1739
|
+
kind: "error",
|
|
1740
|
+
message: `apply ${event.id}: ${String(error)}`
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
/** The events of a window a pull may act on.
|
|
1421
1745
|
*
|
|
1422
|
-
*
|
|
1423
|
-
*
|
|
1424
|
-
*
|
|
1746
|
+
* A record the push just refused to send is edited on BOTH sides. Writing
|
|
1747
|
+
* Google's value over it destroys the local edit the push declined to resolve,
|
|
1748
|
+
* so the local one stands. Single-sourced because the record write and the
|
|
1749
|
+
* baseline write must agree exactly on which events they skip — disagreeing is
|
|
1750
|
+
* what would silently overwrite Google on the next push (#2620). */
|
|
1751
|
+
function pullableEvents(events, unpushed) {
|
|
1752
|
+
return events.filter((event) => !unpushed.has(event.id));
|
|
1753
|
+
}
|
|
1754
|
+
/** Report what an automatic push did, since nobody is watching a scheduled run.
|
|
1755
|
+
* A conflict or an error means that record now diverges from Google until
|
|
1756
|
+
* someone resolves it, which must not be silent. */
|
|
1757
|
+
function reportAutoPush(slug, result) {
|
|
1758
|
+
const { created, updated, conflicts, skipped, errors, unpushedIds } = result;
|
|
1759
|
+
if (created + updated > 0) log.info("google", "auto-pushed local calendar edits", {
|
|
1760
|
+
slug,
|
|
1761
|
+
created,
|
|
1762
|
+
updated
|
|
1763
|
+
});
|
|
1764
|
+
if (unpushedIds.length > 0) log.warn("google", "records the auto push could not send — the pull will leave them alone", {
|
|
1765
|
+
slug,
|
|
1766
|
+
conflicts,
|
|
1767
|
+
unpushedIds
|
|
1768
|
+
});
|
|
1769
|
+
if (skipped.length > 0) log.warn("google", "records the auto push skipped", {
|
|
1770
|
+
slug,
|
|
1771
|
+
skipped
|
|
1772
|
+
});
|
|
1773
|
+
if (errors.length > 0) log.warn("google", "auto push errors", {
|
|
1774
|
+
slug,
|
|
1775
|
+
errors
|
|
1776
|
+
});
|
|
1777
|
+
}
|
|
1778
|
+
var NOTHING_UNPUSHED = /* @__PURE__ */ new Set();
|
|
1779
|
+
/** A collection whose protection is unknown must not be pulled this run.
|
|
1425
1780
|
*
|
|
1426
|
-
*
|
|
1427
|
-
*
|
|
1428
|
-
*
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1781
|
+
* Reported through the retryable `errors` channel rather than as a special case:
|
|
1782
|
+
* that already holds the sync token AND skips the baseline save, so the window
|
|
1783
|
+
* simply replays next run with nothing lost. Failing OPEN here — pulling with no
|
|
1784
|
+
* protection — would overwrite the very edits this exists to protect; the read
|
|
1785
|
+
* that failed is no evidence that the pull's own writes would fail too, so they
|
|
1786
|
+
* would land (CodeRabbit review #2666). */
|
|
1787
|
+
var PROTECTION_UNKNOWN = "could not work out which records to protect after a failed push";
|
|
1788
|
+
/** What ONE collection's pull must leave alone: only what ITS OWN push failed to
|
|
1789
|
+
* send.
|
|
1790
|
+
*
|
|
1791
|
+
* Scoped per collection because a calendar can back several of them, and a
|
|
1792
|
+
* conflict in one says nothing about the others. Sharing one set across the
|
|
1793
|
+
* group starved a collection that never even declares `autoPush`: it cannot
|
|
1794
|
+
* conflict, yet a neighbour's conflict froze its records — and the sync token
|
|
1795
|
+
* still advanced, so Google never resent them (Codex review #2666). */
|
|
1796
|
+
var unpushedFor = (unpushed, slug) => {
|
|
1797
|
+
const protection = unpushed.get(slug);
|
|
1798
|
+
return protection === void 0 ? NOTHING_UNPUSHED : protection;
|
|
1434
1799
|
};
|
|
1435
|
-
/**
|
|
1436
|
-
*
|
|
1437
|
-
* hand-typed value does not. */
|
|
1438
|
-
var SECONDLESS_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
|
|
1439
|
-
var WHOLE_MINUTE = ":00";
|
|
1440
|
-
/** Field text with `…T09:30` and `…T09:30:00` collapsed to one spelling.
|
|
1800
|
+
/** What the calendar's BASELINE must leave alone: the union over every
|
|
1801
|
+
* collection.
|
|
1441
1802
|
*
|
|
1442
|
-
*
|
|
1443
|
-
*
|
|
1444
|
-
*
|
|
1445
|
-
*
|
|
1803
|
+
* Deliberately not per collection, unlike the records above. `.push-state.json`
|
|
1804
|
+
* holds ONE baseline per calendar, shared by every collection on it, so there is
|
|
1805
|
+
* no per-collection baseline to hold back. Advancing it while any collection
|
|
1806
|
+
* still has an unresolved conflict is the failure that silently overwrites
|
|
1807
|
+
* Google on the next push, and holding it back only ever means "keep reporting
|
|
1808
|
+
* the conflict" — so the union is the safe side of an asymmetry the shared
|
|
1809
|
+
* storage forces.
|
|
1446
1810
|
*
|
|
1447
|
-
* A
|
|
1448
|
-
*
|
|
1449
|
-
var
|
|
1450
|
-
|
|
1451
|
-
return SECONDLESS_DATETIME_RE.test(text) ? `${text}${WHOLE_MINUTE}` : text;
|
|
1452
|
-
};
|
|
1453
|
-
/** A baseline shaped as the event it came from, so the comparison can run
|
|
1454
|
-
* through the very projection that wrote the record. `htmlLink`/`status` are
|
|
1455
|
-
* never pushable, so their absence from the baseline cannot matter. */
|
|
1456
|
-
var asEventSummary = (eventId, shadow) => ({
|
|
1457
|
-
id: eventId,
|
|
1458
|
-
htmlLink: "",
|
|
1459
|
-
status: "",
|
|
1460
|
-
...shadow
|
|
1461
|
-
});
|
|
1462
|
-
/** What the record WOULD hold if nobody had edited it since the baseline.
|
|
1811
|
+
* A `null` (unknown) entry contributes nothing, because that collection reports
|
|
1812
|
+
* a retryable error instead — which stops the baseline being saved at all. */
|
|
1813
|
+
var allUnpushed = (unpushed) => new Set([...unpushed.values()].flatMap((ids) => ids === null ? [] : [...ids]));
|
|
1814
|
+
/** What a collection's pull must protect when its push did not run AT ALL.
|
|
1463
1815
|
*
|
|
1464
|
-
*
|
|
1465
|
-
*
|
|
1466
|
-
*
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1816
|
+
* Registering nothing here was a silent data loss of exactly the kind this
|
|
1817
|
+
* feature exists to prevent: a calendar whose role degrades to reader refuses
|
|
1818
|
+
* the whole push, yet the pull still runs — reading needs no write access — and
|
|
1819
|
+
* overwrote every unsent local edit while advancing its baseline past it, so the
|
|
1820
|
+
* next push could not even report the conflict (CodeRabbit review #2666).
|
|
1821
|
+
*
|
|
1822
|
+
* Protects the edited records rather than all of them, so an unchanged record
|
|
1823
|
+
* keeps syncing normally. `null` when even that could not be worked out — the
|
|
1824
|
+
* caller then refuses to pull the collection at all, because failing open here
|
|
1825
|
+
* destroys exactly what this protects. */
|
|
1826
|
+
async function protectUnsentEdits(collection, workspaceRoot) {
|
|
1827
|
+
try {
|
|
1828
|
+
const edited = await unsentLocalEdits(collection, workspaceRoot);
|
|
1829
|
+
if (edited.length > 0) log.warn("google", "protecting local edits a failed push could not send", {
|
|
1830
|
+
slug: collection.slug,
|
|
1831
|
+
edited
|
|
1832
|
+
});
|
|
1833
|
+
return new Set(edited);
|
|
1834
|
+
} catch (error) {
|
|
1835
|
+
log.warn("google", PROTECTION_UNKNOWN, {
|
|
1836
|
+
slug: collection.slug,
|
|
1837
|
+
error: String(error)
|
|
1838
|
+
});
|
|
1839
|
+
return null;
|
|
1840
|
+
}
|
|
1473
1841
|
}
|
|
1474
|
-
/**
|
|
1475
|
-
* local edit
|
|
1842
|
+
/** Push every `autoPush` collection in this group, and answer with the records
|
|
1843
|
+
* whose local edit did not reach Google, keyed by collection.
|
|
1476
1844
|
*
|
|
1477
|
-
*
|
|
1478
|
-
*
|
|
1479
|
-
*
|
|
1480
|
-
|
|
1481
|
-
|
|
1845
|
+
* MUST run inside the calendar lock the caller already holds — hence
|
|
1846
|
+
* `pushCollectionNow` rather than `pushCalendarForCollection`, which would take
|
|
1847
|
+
* the same non-reentrant lock and wait on itself forever.
|
|
1848
|
+
*
|
|
1849
|
+
* A failed push must not stop the pull: the pull is what keeps the collection
|
|
1850
|
+
* fresh, and a revoked write grant is no reason to freeze reading. */
|
|
1851
|
+
async function pushAutoCollections(collections, workspaceRoot) {
|
|
1852
|
+
const unpushed = /* @__PURE__ */ new Map();
|
|
1853
|
+
for (const collection of collections.filter((entry) => entry.schema.googleCalendar?.autoPush)) try {
|
|
1854
|
+
const outcome = await pushCollectionNow(collection, workspaceRoot);
|
|
1855
|
+
if (outcome.kind === "pushed") {
|
|
1856
|
+
reportAutoPush(collection.slug, outcome.result);
|
|
1857
|
+
unpushed.set(collection.slug, new Set(outcome.result.unpushedIds));
|
|
1858
|
+
continue;
|
|
1859
|
+
}
|
|
1860
|
+
log.warn("google", "auto push did not run", {
|
|
1861
|
+
slug: collection.slug,
|
|
1862
|
+
reason: outcome.kind
|
|
1863
|
+
});
|
|
1864
|
+
unpushed.set(collection.slug, await protectUnsentEdits(collection, workspaceRoot));
|
|
1865
|
+
} catch (error) {
|
|
1866
|
+
log.warn("google", "auto push failed — pulling anyway", {
|
|
1867
|
+
slug: collection.slug,
|
|
1868
|
+
error: String(error)
|
|
1869
|
+
});
|
|
1870
|
+
unpushed.set(collection.slug, await protectUnsentEdits(collection, workspaceRoot));
|
|
1871
|
+
}
|
|
1872
|
+
return unpushed;
|
|
1873
|
+
}
|
|
1874
|
+
async function restartFullSync(accessToken, calendarId, workspaceRoot) {
|
|
1875
|
+
await clearCalendarSyncToken(calendarId, workspaceRoot);
|
|
1876
|
+
await clearCalendarShadow(calendarId, workspaceRoot);
|
|
1877
|
+
return await syncCalendarEvents(accessToken, { calendarId });
|
|
1482
1878
|
}
|
|
1483
|
-
|
|
1879
|
+
var ALWAYS_CLAIM = () => true;
|
|
1880
|
+
/** Sync ONE calendar and fan its events out to every collection bound to it.
|
|
1484
1881
|
*
|
|
1485
|
-
*
|
|
1486
|
-
*
|
|
1487
|
-
*
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1882
|
+
* The fan-out is not an optimisation, it is correctness: the sync token is
|
|
1883
|
+
* keyed by `calendarId`, so syncing collection-by-collection would let the
|
|
1884
|
+
* first collection advance the shared token and leave every later collection
|
|
1885
|
+
* on the same calendar reading an already-consumed window — silently missing
|
|
1886
|
+
* those events forever. Fetch once, apply to all, then advance the token.
|
|
1887
|
+
* (Codex + CodeRabbit review on #2184.)
|
|
1888
|
+
*
|
|
1889
|
+
* Queued per calendar for the same reason the fan-out exists: two passes over
|
|
1890
|
+
* one calendar (a Refresh click landing during the scheduled run) would each
|
|
1891
|
+
* load the SAME stored token and walk the same window. That is idempotent —
|
|
1892
|
+
* writes are upserts by event id — but it is a wasted full walk. Queued, the
|
|
1893
|
+
* second pass resumes from the token the first just stored and fetches only
|
|
1894
|
+
* what is genuinely new. That queue is module state, so it orders the doors
|
|
1895
|
+
* into THIS process only; `claimThenSync` is what other hosts can see. */
|
|
1896
|
+
async function syncCalendarGroup(calendarId, collections, workspaceRoot, mayClaim = ALWAYS_CLAIM) {
|
|
1897
|
+
return await withCalendarLock(calendarId, () => claimThenSync(calendarId, collections, workspaceRoot, mayClaim));
|
|
1495
1898
|
}
|
|
1496
|
-
/**
|
|
1497
|
-
*
|
|
1899
|
+
/** Take the workspace-shared marker BEFORE the calendar is touched, sync only if
|
|
1900
|
+
* this run got it, and drop it again if the run could not happen at all.
|
|
1498
1901
|
*
|
|
1499
|
-
*
|
|
1500
|
-
*
|
|
1501
|
-
*
|
|
1502
|
-
*
|
|
1503
|
-
* id (Codex review).
|
|
1902
|
+
* Claiming first is the whole point: hosts tick in the same minute (interval
|
|
1903
|
+
* schedules align to wall-clock boundaries), so a marker written on completion
|
|
1904
|
+
* would leave the entire run — minutes, for a first full walk — open for a
|
|
1905
|
+
* second host to start alongside this one and lose the push baseline it writes.
|
|
1504
1906
|
*
|
|
1505
|
-
*
|
|
1506
|
-
*
|
|
1507
|
-
*
|
|
1508
|
-
|
|
1509
|
-
|
|
1907
|
+
* Released only when the sync could not run at all. A run that finished with
|
|
1908
|
+
* retryable per-collection errors KEEPS the marker: it already held the token
|
|
1909
|
+
* and the baseline back, so it replays on the next tick either way, and
|
|
1910
|
+
* releasing would hand a permanently-failing calendar back to both hosts at
|
|
1911
|
+
* once — the very overlap this guards (observed during Claude review). */
|
|
1912
|
+
async function claimThenSync(calendarId, collections, workspaceRoot, mayClaim) {
|
|
1913
|
+
if (!await claimCalendarSync(calendarId, workspaceRoot, mayClaim)) {
|
|
1914
|
+
log.info("google", "skipping a calendar this workspace synced recently — another host may be on it", { calendarId });
|
|
1915
|
+
return [];
|
|
1916
|
+
}
|
|
1917
|
+
try {
|
|
1918
|
+
return await syncCalendarGroupNow(calendarId, collections, workspaceRoot);
|
|
1919
|
+
} catch (error) {
|
|
1920
|
+
await releaseCalendarSyncClaim(calendarId, workspaceRoot);
|
|
1921
|
+
throw error;
|
|
1922
|
+
}
|
|
1510
1923
|
}
|
|
1511
|
-
/**
|
|
1512
|
-
*
|
|
1513
|
-
*
|
|
1514
|
-
function
|
|
1515
|
-
|
|
1516
|
-
|
|
1924
|
+
/** Never throws, and fails OPEN: the marker guards against duplicate work, it is
|
|
1925
|
+
* not a precondition for syncing correctly. A workspace that cannot store it
|
|
1926
|
+
* syncs the way it did before #2678. */
|
|
1927
|
+
async function claimCalendarSync(calendarId, workspaceRoot, mayClaim) {
|
|
1928
|
+
try {
|
|
1929
|
+
return await claimCalendarSyncIfDue(calendarId, (/* @__PURE__ */ new Date()).toISOString(), mayClaim, workspaceRoot);
|
|
1930
|
+
} catch (error) {
|
|
1931
|
+
log.warn("google", "could not stamp the calendar sync marker — another host may sync this calendar in parallel", {
|
|
1932
|
+
calendarId,
|
|
1933
|
+
error: String(error)
|
|
1934
|
+
});
|
|
1935
|
+
return true;
|
|
1936
|
+
}
|
|
1517
1937
|
}
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1938
|
+
async function releaseCalendarSyncClaim(calendarId, workspaceRoot) {
|
|
1939
|
+
try {
|
|
1940
|
+
await clearCalendarLastSyncedAt(calendarId, workspaceRoot);
|
|
1941
|
+
} catch (error) {
|
|
1942
|
+
log.warn("google", "could not release the calendar sync marker after a failed run", {
|
|
1943
|
+
calendarId,
|
|
1944
|
+
error: String(error)
|
|
1945
|
+
});
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
async function syncCalendarGroupNow(calendarId, collections, workspaceRoot) {
|
|
1949
|
+
const unpushed = await pushAutoCollections(collections, workspaceRoot);
|
|
1950
|
+
const accessToken = await getGoogleAccessToken();
|
|
1951
|
+
const first = await syncCalendarEvents(accessToken, {
|
|
1952
|
+
calendarId,
|
|
1953
|
+
syncToken: await loadCalendarSyncToken(calendarId, workspaceRoot) ?? void 0
|
|
1954
|
+
});
|
|
1955
|
+
const result = first.fullResyncRequired ? await restartFullSync(accessToken, calendarId, workspaceRoot) : first;
|
|
1956
|
+
const results = await applyWindowToGroup(collections, result.events, workspaceRoot, unpushed);
|
|
1957
|
+
if (windowFullyLanded(calendarId, results)) {
|
|
1958
|
+
await saveCalendarShadow(calendarId, shadowUpdates(result.events, allUnpushed(unpushed)), workspaceRoot);
|
|
1959
|
+
if (result.nextSyncToken) await advanceToken(calendarId, result.nextSyncToken, collections, workspaceRoot);
|
|
1960
|
+
}
|
|
1961
|
+
return results;
|
|
1962
|
+
}
|
|
1963
|
+
/** Apply one window to every collection on the calendar, honouring what each
|
|
1964
|
+
* one's own push protected. A collection whose protection could not be worked
|
|
1965
|
+
* out is not pulled at all — it reports a retryable error instead, which holds
|
|
1966
|
+
* the token and the baseline back for the whole group. */
|
|
1967
|
+
async function applyWindowToGroup(collections, events, workspaceRoot, unpushed) {
|
|
1968
|
+
const results = [];
|
|
1969
|
+
for (const collection of collections) {
|
|
1970
|
+
const protection = unpushedFor(unpushed, collection.slug);
|
|
1971
|
+
results.push(protection === null ? {
|
|
1972
|
+
slug: collection.slug,
|
|
1973
|
+
written: 0,
|
|
1974
|
+
removed: 0,
|
|
1975
|
+
unwritable: [],
|
|
1976
|
+
errors: [PROTECTION_UNKNOWN]
|
|
1977
|
+
} : await applyEventsToCollection(collection, events, workspaceRoot, protection));
|
|
1978
|
+
}
|
|
1979
|
+
return results;
|
|
1980
|
+
}
|
|
1981
|
+
/** Whether the token and baseline may advance past this window.
|
|
1525
1982
|
*
|
|
1526
|
-
*
|
|
1527
|
-
*
|
|
1528
|
-
*
|
|
1529
|
-
*
|
|
1530
|
-
|
|
1531
|
-
|
|
1983
|
+
* Google never resends a window, so advancing past a failed write would lose
|
|
1984
|
+
* those events for good; holding the token back just replays them next run
|
|
1985
|
+
* (writes are idempotent). An `unwritable` event can never succeed, so it does
|
|
1986
|
+
* NOT hold the token — but it is logged loudly, since it will silently never
|
|
1987
|
+
* appear in the collection. */
|
|
1988
|
+
function windowFullyLanded(calendarId, results) {
|
|
1989
|
+
results.filter((entry) => entry.unwritable.length > 0).forEach((entry) => log.warn("google", "skipping calendar events that can never be stored", {
|
|
1990
|
+
calendarId,
|
|
1991
|
+
slug: entry.slug,
|
|
1992
|
+
unwritable: entry.unwritable
|
|
1993
|
+
}));
|
|
1994
|
+
const failed = results.filter((entry) => entry.errors.length > 0);
|
|
1995
|
+
failed.forEach((entry) => log.warn("google", "holding back calendar sync token after failed writes", {
|
|
1996
|
+
calendarId,
|
|
1997
|
+
slug: entry.slug,
|
|
1998
|
+
errors: entry.errors
|
|
1999
|
+
}));
|
|
2000
|
+
return failed.length === 0;
|
|
2001
|
+
}
|
|
2002
|
+
/** The baseline this window establishes: what Google now says per event, and
|
|
2003
|
+
* `null` for a cancelled one so a recreate cannot resume from a dead baseline.
|
|
1532
2004
|
*
|
|
1533
|
-
*
|
|
1534
|
-
*
|
|
1535
|
-
*
|
|
1536
|
-
*
|
|
1537
|
-
*
|
|
2005
|
+
* An event whose record the push could not send is left OUT, and that omission
|
|
2006
|
+
* is load-bearing. Advancing its baseline to Google's new value while the record
|
|
2007
|
+
* keeps the local one would make the next push read a plain one-sided edit —
|
|
2008
|
+
* no conflict to detect any more — and quietly overwrite Google. Held back, the
|
|
2009
|
+
* baseline stays older than both sides, so the conflict keeps being reported
|
|
2010
|
+
* until someone resolves it (#2620). */
|
|
2011
|
+
function shadowUpdates(events, unpushed = /* @__PURE__ */ new Set()) {
|
|
2012
|
+
const carried = pullableEvents(events, unpushed);
|
|
2013
|
+
return Object.fromEntries(carried.map((event) => [event.id, event.status === "cancelled" ? null : toShadowEvent(event)]));
|
|
2014
|
+
}
|
|
2015
|
+
/** Save the window's token unless every collection that consumed it was deleted
|
|
2016
|
+
* while the sync was in flight.
|
|
1538
2017
|
*
|
|
1539
|
-
*
|
|
1540
|
-
*
|
|
1541
|
-
*
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
return {
|
|
1553
|
-
accessRole: null,
|
|
1554
|
-
timeZone: direct.timeZone
|
|
1555
|
-
};
|
|
2018
|
+
* The sync opens with a `discoverCollections()` snapshot, so a delete landing
|
|
2019
|
+
* mid-run has already cleared this calendar's token by the time we get here
|
|
2020
|
+
* (`releaseOrphanedCalendarToken`). Saving anyway would resurrect exactly the
|
|
2021
|
+
* orphan the delete removed, and the next collection on this calendar would
|
|
2022
|
+
* resume from a token describing records it never received (#2428).
|
|
2023
|
+
*
|
|
2024
|
+
* One survivor is enough: it still needs the incremental position. */
|
|
2025
|
+
async function advanceToken(calendarId, nextSyncToken, collections, workspaceRoot) {
|
|
2026
|
+
if (!await anySyncedCollectionSurvives(collections)) {
|
|
2027
|
+
log.info("google", "not advancing the sync token — every collection on this calendar was deleted mid-sync", { calendarId });
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
await saveCalendarSyncToken(calendarId, nextSyncToken, workspaceRoot);
|
|
1556
2031
|
}
|
|
1557
|
-
async function
|
|
1558
|
-
|
|
2032
|
+
async function pathExists(absPath) {
|
|
2033
|
+
try {
|
|
2034
|
+
await stat(absPath);
|
|
2035
|
+
return true;
|
|
2036
|
+
} catch {
|
|
2037
|
+
return false;
|
|
2038
|
+
}
|
|
1559
2039
|
}
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
};
|
|
1566
|
-
var localValue = (ctx, record, source) => {
|
|
1567
|
-
const field = ctx.bySource[source];
|
|
1568
|
-
return field === void 0 ? void 0 : record[field];
|
|
1569
|
-
};
|
|
1570
|
-
/** One end of the span, rebuilt from the stored clock plus whatever the baseline
|
|
1571
|
-
* remembers about the original (zone, all-day). */
|
|
1572
|
-
function eventTime(ctx, record, source, previous) {
|
|
1573
|
-
const value = localValue(ctx, record, source);
|
|
1574
|
-
if (value === void 0) return {
|
|
1575
|
-
ok: false,
|
|
1576
|
-
reason: `no \`${source}\` field is mapped`
|
|
1577
|
-
};
|
|
1578
|
-
const time = toGoogleEventTime(value, previous, ctx.timeZone);
|
|
1579
|
-
if (time === null) return {
|
|
1580
|
-
ok: false,
|
|
1581
|
-
reason: `\`${source}\` is not a date-time Google accepts (got ${JSON.stringify(value)})`
|
|
1582
|
-
};
|
|
1583
|
-
if ("dateTime" in time && time.timeZone === "") return {
|
|
1584
|
-
ok: false,
|
|
1585
|
-
reason: `the calendar reports no timezone, so \`${source}\` cannot be sent without one`
|
|
1586
|
-
};
|
|
1587
|
-
return {
|
|
1588
|
-
ok: true,
|
|
1589
|
-
time
|
|
1590
|
-
};
|
|
2040
|
+
/** Liveness of the collections a sync just wrote to, checked against the skill
|
|
2041
|
+
* dir `deleteCollection` removes. `exists` is injected so the rule is testable
|
|
2042
|
+
* without a filesystem. An empty group has no survivor by definition. */
|
|
2043
|
+
async function anySyncedCollectionSurvives(collections, exists = pathExists) {
|
|
2044
|
+
return (await Promise.all(collections.map((collection) => exists(collection.skillDir)))).some(Boolean);
|
|
1591
2045
|
}
|
|
1592
|
-
async function
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
message: `${eventId}: the record id cannot be used as a Google event id (needs 5-1024 characters from 0-9a-v)`
|
|
1596
|
-
};
|
|
1597
|
-
const start = eventTime(ctx, record, "start", void 0);
|
|
1598
|
-
const end = eventTime(ctx, record, "end", void 0);
|
|
1599
|
-
if (!start.ok) return {
|
|
1600
|
-
kind: "skipped",
|
|
1601
|
-
message: `${eventId}: ${start.reason}`
|
|
1602
|
-
};
|
|
1603
|
-
if (!end.ok) return {
|
|
1604
|
-
kind: "skipped",
|
|
1605
|
-
message: `${eventId}: ${end.reason}`
|
|
1606
|
-
};
|
|
1607
|
-
const colorId = fieldText(localValue(ctx, record, "colorId"));
|
|
2046
|
+
async function applyEventsToCollection(collection, events, workspaceRoot, unpushed) {
|
|
2047
|
+
const outcomes = [];
|
|
2048
|
+
for (const event of pullableEvents(events, unpushed)) outcomes.push(await applyEvent(collection, event, workspaceRoot));
|
|
1608
2049
|
return {
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
start: start.time,
|
|
1615
|
-
end: end.time,
|
|
1616
|
-
...colorId ? { colorId } : {}
|
|
1617
|
-
})
|
|
2050
|
+
slug: collection.slug,
|
|
2051
|
+
written: outcomes.filter((outcome) => outcome.kind === "written").length,
|
|
2052
|
+
removed: outcomes.filter((outcome) => outcome.kind === "removed").length,
|
|
2053
|
+
unwritable: outcomes.flatMap((outcome) => outcome.kind === "unwritable" ? [outcome.message] : []),
|
|
2054
|
+
errors: outcomes.flatMap((outcome) => outcome.kind === "error" ? [outcome.message] : [])
|
|
1618
2055
|
};
|
|
1619
2056
|
}
|
|
1620
|
-
/**
|
|
1621
|
-
*
|
|
1622
|
-
function
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
...changed.includes("summary") ? { summary: fieldText(localValue(ctx, record, "summary")) } : {},
|
|
1641
|
-
...changed.includes("colorId") ? { colorId } : {},
|
|
1642
|
-
...Object.fromEntries(resolved.flatMap((entry) => entry.result.ok ? [[entry.source, entry.result.time]] : []))
|
|
1643
|
-
}
|
|
1644
|
-
};
|
|
2057
|
+
/** A refresh token is what lets the scheduler run unattended; without one
|
|
2058
|
+
* there is nothing to sync with. */
|
|
2059
|
+
async function isGoogleLinked() {
|
|
2060
|
+
return Boolean((await loadGoogleTokens())?.refresh_token);
|
|
2061
|
+
}
|
|
2062
|
+
/** Group the declaring collections by the calendar they read, so each calendar
|
|
2063
|
+
* is fetched exactly once.
|
|
2064
|
+
*
|
|
2065
|
+
* Keyed by the CANONICAL id, not the declared one: an omitted `calendarId` and
|
|
2066
|
+
* an explicit `"primary"` address the same calendar and therefore share one
|
|
2067
|
+
* sync token, so grouping them apart would let one group advance the token out
|
|
2068
|
+
* from under the other — the very loss this grouping exists to prevent
|
|
2069
|
+
* (Codex review #2184). */
|
|
2070
|
+
function groupByCalendar(collections) {
|
|
2071
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2072
|
+
for (const collection of collections) {
|
|
2073
|
+
const key = canonicalCalendarId(collection.schema.googleCalendar?.calendarId);
|
|
2074
|
+
groups.set(key, [...groups.get(key) ?? [], collection]);
|
|
2075
|
+
}
|
|
2076
|
+
return groups;
|
|
1645
2077
|
}
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
2078
|
+
/** The canonical calendar whose sync token nothing needs any more, or null.
|
|
2079
|
+
*
|
|
2080
|
+
* Sync tokens are keyed by calendar, NOT by collection, so a deleted
|
|
2081
|
+
* collection's token outlives it. Recreating a collection on the same calendar
|
|
2082
|
+
* then resumes from that token and receives only the delta — the new
|
|
2083
|
+
* collection never gets the history (#2428).
|
|
2084
|
+
*
|
|
2085
|
+
* Returns null while ANY remaining collection still reads that calendar:
|
|
2086
|
+
* clearing a live calendar's token costs a full re-walk on the next sync. The
|
|
2087
|
+
* comparison is on the CANONICAL id for the same reason `groupByCalendar` is —
|
|
2088
|
+
* an omitted `calendarId` and an explicit `"primary"` are one calendar. */
|
|
2089
|
+
function orphanedCalendarId(deleted, remaining) {
|
|
2090
|
+
if (!deleted.googleCalendar) return null;
|
|
2091
|
+
const key = canonicalCalendarId(deleted.googleCalendar.calendarId);
|
|
2092
|
+
return remaining.some((other) => other.googleCalendar && canonicalCalendarId(other.googleCalendar.calendarId) === key) ? null : key;
|
|
2093
|
+
}
|
|
2094
|
+
/** Drop the sync token of a just-deleted collection's calendar, unless another
|
|
2095
|
+
* collection still reads it. Call AFTER the delete lands — the check reads the
|
|
2096
|
+
* collections that survive it.
|
|
2097
|
+
*
|
|
2098
|
+
* Returns the cleared calendar id, or null when nothing was cleared. Never
|
|
2099
|
+
* throws: a failed cleanup must not fail the delete it follows. */
|
|
2100
|
+
async function releaseOrphanedCalendarToken(deleted, workspaceRoot) {
|
|
1661
2101
|
try {
|
|
1662
|
-
return
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
};
|
|
2102
|
+
if (!deleted.googleCalendar) return null;
|
|
2103
|
+
const orphaned = orphanedCalendarId(deleted, (await discoverCollections({ workspaceRoot })).map((collection) => collection.schema));
|
|
2104
|
+
if (orphaned === null) return null;
|
|
2105
|
+
await clearCalendarSyncToken(orphaned, workspaceRoot);
|
|
2106
|
+
await clearCalendarShadow(orphaned, workspaceRoot);
|
|
2107
|
+
await clearCalendarLastSyncedAt(orphaned, workspaceRoot);
|
|
2108
|
+
log.info("google", "cleared the sync token of a calendar no collection reads any more", { calendarId: orphaned });
|
|
2109
|
+
return orphaned;
|
|
1671
2110
|
} catch (error) {
|
|
1672
|
-
|
|
1673
|
-
|
|
2111
|
+
log.warn("google", "could not release the deleted collection's calendar sync token", { error: String(error) });
|
|
2112
|
+
return null;
|
|
1674
2113
|
}
|
|
1675
2114
|
}
|
|
1676
|
-
/**
|
|
2115
|
+
/** Every declaring collection, grouped by the calendar it reads. */
|
|
2116
|
+
async function declaringGroups(workspaceRoot) {
|
|
2117
|
+
return groupByCalendar((await discoverCollections({ workspaceRoot })).filter((collection) => collection.schema.googleCalendar));
|
|
2118
|
+
}
|
|
2119
|
+
/** Whether a background sync of these groups may run at all.
|
|
1677
2120
|
*
|
|
1678
|
-
*
|
|
1679
|
-
*
|
|
1680
|
-
*
|
|
1681
|
-
*
|
|
1682
|
-
* fix. Patching whatever answered the 409 would let a push modify a stranger's
|
|
1683
|
-
* event (Codex review).
|
|
2121
|
+
* Authoring the collection before linking the account is an expected state,
|
|
2122
|
+
* not a failure. Checking once here keeps it a quiet skip instead of an
|
|
2123
|
+
* access-token throw per calendar, every hour, until the user links (#2188).
|
|
2124
|
+
* A user-triggered sync answers differently — it says so out loud.
|
|
1684
2125
|
*
|
|
1685
|
-
*
|
|
1686
|
-
*
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
2126
|
+
* Deliberately not phrased as a per-host state: the link lives in
|
|
2127
|
+
* `~/.config/mulmo`, which carries no app name, so every host on the machine
|
|
2128
|
+
* shares one answer. Reading it as "linked on this host only" is what made the
|
|
2129
|
+
* calendar look implicitly exclusive when it never was (#2678). */
|
|
2130
|
+
async function backgroundSyncAllowed(groups) {
|
|
2131
|
+
if (groups.size === 0) return false;
|
|
2132
|
+
if (await isGoogleLinked()) return true;
|
|
2133
|
+
log.info("google", "skipping calendar sync — no Google account linked", { calendars: groups.size });
|
|
2134
|
+
return false;
|
|
2135
|
+
}
|
|
2136
|
+
/** Run each group, isolating failures per calendar — one unreachable calendar
|
|
2137
|
+
* (or a revoked grant) must not stop the others. `mayClaim` is evaluated per
|
|
2138
|
+
* calendar as it comes up, never once for the whole map: an earlier calendar's
|
|
2139
|
+
* full walk takes minutes, and a decision made before it started says nothing
|
|
2140
|
+
* about who holds this one now. */
|
|
2141
|
+
async function runCalendarGroups(groups, workspaceRoot, mayClaim = ALWAYS_CLAIM) {
|
|
2142
|
+
const results = [];
|
|
2143
|
+
for (const [calendarId, collections] of groups) try {
|
|
2144
|
+
results.push(...await syncCalendarGroup(calendarId, collections, workspaceRoot, mayClaim));
|
|
1690
2145
|
} catch (error) {
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
eventId
|
|
2146
|
+
log.warn("google", "calendar sync failed", {
|
|
2147
|
+
calendarId,
|
|
2148
|
+
error: String(error)
|
|
1695
2149
|
});
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
message: `${eventId}: an event with this id already exists in Google and differs from this record — press Sync to adopt it, then push again`
|
|
1704
|
-
};
|
|
2150
|
+
results.push(...collections.map((collection) => ({
|
|
2151
|
+
slug: collection.slug,
|
|
2152
|
+
written: 0,
|
|
2153
|
+
removed: 0,
|
|
2154
|
+
unwritable: [],
|
|
2155
|
+
errors: [String(error)]
|
|
2156
|
+
})));
|
|
1705
2157
|
}
|
|
2158
|
+
return results;
|
|
1706
2159
|
}
|
|
1707
|
-
/**
|
|
1708
|
-
*
|
|
1709
|
-
*
|
|
1710
|
-
*
|
|
1711
|
-
*
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
return
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
};
|
|
2160
|
+
/** Sync every collection whose calendar is due — no host in this workspace has
|
|
2161
|
+
* started one within `intervalMs` (#2678). Without that gate this walked every
|
|
2162
|
+
* declaring group on every tick, so a second host registering the same task
|
|
2163
|
+
* simply doubled the runs, concurrently.
|
|
2164
|
+
*
|
|
2165
|
+
* Dueness is not decided here, only described: the guard is handed down and
|
|
2166
|
+
* evaluated where the marker is written, so the answer cannot go stale between
|
|
2167
|
+
* deciding and claiming (Codex review #2680). */
|
|
2168
|
+
async function syncDueCalendarCollections(workspaceRoot, intervalMs = DEFAULT_SYNC_INTERVAL_MS) {
|
|
2169
|
+
const groups = await declaringGroups(workspaceRoot);
|
|
2170
|
+
if (!await backgroundSyncAllowed(groups)) return [];
|
|
2171
|
+
const windowMs = calendarSyncDueWindowMs(intervalMs);
|
|
2172
|
+
return await runCalendarGroups(groups, workspaceRoot, (lastSyncedAt) => isCalendarSyncDue(lastSyncedAt, windowMs));
|
|
1721
2173
|
}
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
2174
|
+
/** The groups whose calendar has never synced. A missing token IS the "created
|
|
2175
|
+
* since the last sync" signal — nothing else distinguishes a new collection
|
|
2176
|
+
* from an edited one on the write path this feeds (#2427).
|
|
2177
|
+
*
|
|
2178
|
+
* Self-silencing by construction: the first sync stores a token, so a calendar
|
|
2179
|
+
* matches at most once. `loadToken` is injected so the rule is testable without
|
|
2180
|
+
* a workspace on disk. */
|
|
2181
|
+
async function unsyncedGroups(groups, loadToken) {
|
|
2182
|
+
const checked = await Promise.all([...groups].map(async (entry) => await loadToken(entry[0]) === null ? entry : null));
|
|
2183
|
+
return new Map(checked.filter((entry) => entry !== null));
|
|
1732
2184
|
}
|
|
1733
|
-
/**
|
|
1734
|
-
*
|
|
1735
|
-
*
|
|
1736
|
-
function
|
|
1737
|
-
const
|
|
1738
|
-
|
|
2185
|
+
/** Sync only the calendars that have never synced — the first sync for a
|
|
2186
|
+
* just-created collection, which otherwise stays empty until the hourly
|
|
2187
|
+
* scheduler run (#2427). Cheap and safe to call on every config write. */
|
|
2188
|
+
async function syncNewCalendarCollections(workspaceRoot) {
|
|
2189
|
+
const pending = await unsyncedGroups(await declaringGroups(workspaceRoot), (calendarId) => loadCalendarSyncToken(calendarId, workspaceRoot));
|
|
2190
|
+
if (!await backgroundSyncAllowed(pending)) return [];
|
|
2191
|
+
log.info("google", "running the first sync for newly declared calendars", { calendars: [...pending.keys()] });
|
|
2192
|
+
return await runCalendarGroups(pending, workspaceRoot);
|
|
1739
2193
|
}
|
|
1740
|
-
|
|
1741
|
-
|
|
2194
|
+
var liveManualSyncDeps = {
|
|
2195
|
+
loadGroups: declaringGroups,
|
|
2196
|
+
isLinked: isGoogleLinked,
|
|
2197
|
+
runGroups: runCalendarGroups
|
|
2198
|
+
};
|
|
2199
|
+
/** Sync the calendar ONE collection reads, on demand (the Refresh button).
|
|
2200
|
+
*
|
|
2201
|
+
* Deliberately syncs the whole group, not just `slug`: the sync token is keyed
|
|
2202
|
+
* by calendar, so consuming a window for one collection would leave the others
|
|
2203
|
+
* on that calendar reading an already-consumed one. Returns every result of the
|
|
2204
|
+
* group so the caller can report the requested slug's own counts.
|
|
2205
|
+
*
|
|
2206
|
+
* "Does this collection sync at all" is answered BEFORE "is Google linked":
|
|
2207
|
+
* telling someone to link their account for a collection that never declared a
|
|
2208
|
+
* calendar sends them fixing the wrong thing. */
|
|
2209
|
+
async function syncCalendarForCollection(slug, workspaceRoot, deps = liveManualSyncDeps) {
|
|
2210
|
+
const owning = [...await deps.loadGroups(workspaceRoot)].filter(([, collections]) => collections.some((collection) => collection.slug === slug));
|
|
2211
|
+
if (owning.length === 0) return { kind: "not-a-calendar" };
|
|
2212
|
+
if (!await deps.isLinked()) return { kind: "not-linked" };
|
|
1742
2213
|
return {
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
updated: count("updated"),
|
|
1746
|
-
conflicts: count("conflict"),
|
|
1747
|
-
localDeletes,
|
|
1748
|
-
skipped: outcomes.flatMap((outcome) => outcome.kind === "skipped" ? [outcome.message] : []),
|
|
1749
|
-
errors: outcomes.flatMap((outcome) => outcome.kind === "error" ? [outcome.message] : [])
|
|
2214
|
+
kind: "synced",
|
|
2215
|
+
results: await deps.runGroups(new Map(owning), workspaceRoot)
|
|
1750
2216
|
};
|
|
1751
2217
|
}
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
if (isDeniedAccessRole(meta.accessRole)) return {
|
|
1758
|
-
kind: "read-only",
|
|
1759
|
-
accessRole: meta.accessRole ?? ""
|
|
1760
|
-
};
|
|
1761
|
-
const map = pushableMap(schema.googleCalendar?.map ?? {});
|
|
1762
|
-
const shadow = await loadCalendarShadow(calendarId, workspaceRoot);
|
|
1763
|
-
const records = await storeFor(collection, { workspaceRoot }).list();
|
|
1764
|
-
const ctx = {
|
|
1765
|
-
accessToken,
|
|
1766
|
-
calendarId,
|
|
1767
|
-
timeZone: meta.timeZone,
|
|
1768
|
-
map,
|
|
1769
|
-
bySource: bySourceField(map),
|
|
1770
|
-
primaryKey: schema.primaryKey,
|
|
1771
|
-
fields: schema.fields,
|
|
1772
|
-
shadow
|
|
1773
|
-
};
|
|
1774
|
-
const outcomes = [];
|
|
1775
|
-
for (const record of records) {
|
|
1776
|
-
const outcome = await pushRecord(ctx, fieldText(record[schema.primaryKey]), record);
|
|
1777
|
-
outcomes.push(outcome);
|
|
1778
|
-
await saveCalendarShadow(calendarId, pushedShadow([outcome]), workspaceRoot);
|
|
1779
|
-
}
|
|
1780
|
-
const result = tally(slug, outcomes, locallyDeletedIds(shadow, records.map((record) => fieldText(record[schema.primaryKey]))).length);
|
|
1781
|
-
if (result.errors.length > 0) log.warn("google", "calendar push finished with errors", {
|
|
1782
|
-
slug,
|
|
1783
|
-
errors: result.errors.length
|
|
1784
|
-
});
|
|
1785
|
-
return {
|
|
1786
|
-
kind: "pushed",
|
|
1787
|
-
result
|
|
1788
|
-
};
|
|
2218
|
+
/** The interval this task is scheduled on right now. A non-interval schedule
|
|
2219
|
+
* cannot be reached — the factory below builds an interval one — but the type
|
|
2220
|
+
* allows it, so it falls back rather than asserting. */
|
|
2221
|
+
function scheduledIntervalMs(task) {
|
|
2222
|
+
return task.schedule.type === SCHEDULE_TYPES.interval ? task.schedule.intervalMs : DEFAULT_SYNC_INTERVAL_MS;
|
|
1789
2223
|
}
|
|
1790
|
-
/**
|
|
2224
|
+
/** Scheduler registration, shaped like `feedRefreshTaskDef` so hosts wire it
|
|
2225
|
+
* with a single line.
|
|
1791
2226
|
*
|
|
1792
|
-
*
|
|
1793
|
-
*
|
|
1794
|
-
*
|
|
1795
|
-
*
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
})
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
message: String(error)
|
|
1810
|
-
};
|
|
1811
|
-
}
|
|
2227
|
+
* `run` reads the interval back off the definition instead of closing over the
|
|
2228
|
+
* option: a host rewrites `schedule` from its own overrides file AFTER this
|
|
2229
|
+
* returns, and the due window has to follow it. Frozen at the default, a
|
|
2230
|
+
* shortened interval would tick often and skip nearly every tick. */
|
|
2231
|
+
function googleCalendarSyncTaskDef(opts) {
|
|
2232
|
+
const def = {
|
|
2233
|
+
id: GOOGLE_CALENDAR_SYNC_TASK_ID,
|
|
2234
|
+
name: "Google Calendar sync",
|
|
2235
|
+
description: "Pulls changed Google Calendar events into any collection declaring `googleCalendar`, without invoking the LLM.",
|
|
2236
|
+
schedule: {
|
|
2237
|
+
type: SCHEDULE_TYPES.interval,
|
|
2238
|
+
intervalMs: opts?.intervalMs ?? DEFAULT_SYNC_INTERVAL_MS
|
|
2239
|
+
},
|
|
2240
|
+
missedRunPolicy: MISSED_RUN_POLICIES.runOnce,
|
|
2241
|
+
run: () => syncDueCalendarCollections(opts?.workspaceRoot ?? getWorkspaceRoot(), scheduledIntervalMs(def)).then(() => {})
|
|
2242
|
+
};
|
|
2243
|
+
return def;
|
|
1812
2244
|
}
|
|
1813
2245
|
//#endregion
|
|
1814
2246
|
//#region src/google/tasks.ts
|
|
@@ -2005,6 +2437,6 @@ async function deleteDriveFile(accessToken, input) {
|
|
|
2005
2437
|
await googleRequest(DRIVE_API_LABEL, accessToken, `${DRIVE_FILES_URL}/${encodeURIComponent(input.fileId)}`, { method: "DELETE" });
|
|
2006
2438
|
}
|
|
2007
2439
|
//#endregion
|
|
2008
|
-
export { CANCELLED_EVENT_STATUS, DEFAULT_LIST_MAX_RESULTS, GOOGLE_AUTH_CANCELLED, GOOGLE_CALENDARLIST_SCOPE, GOOGLE_CALENDAR_SCOPE, GOOGLE_CALENDAR_SYNC_TASK_ID, GOOGLE_DRIVE_FILE_SCOPE, GOOGLE_SCOPES, GOOGLE_TASKS_SCOPE, GoogleApiError, HTTP_CONFLICT, HTTP_FORBIDDEN, HTTP_PRECONDITION_FAILED, MAX_LIST_RESULTS, PUSHABLE_SOURCE_FIELDS, anySyncedCollectionSurvives, assertSafeMimeType, authorizeGoogle, baselineRecord, brokerBaseUrl, brokerExchange, brokerRefresh, brokerStart, buildEventPatch, buildMultipartBody, buildTaskPatch, bySourceField, calendarApiError, calendarPushStatePath, calendarSyncStatePath, canonicalCalendarId, canonicalTaskListId, classifyDelete, classifyWrite, clearCalendarShadow, clearCalendarSyncToken, clientSecretPresence, collectCalendarPages, commitLinkedTokens, completeTask, configureGoogleHost, conflictingFields, createCalendarEvent, createDriveFile, createGoogleAuthFlow, createTask, deleteCalendarEvent, deleteDriveFile, deleteGoogleTokens, deleteTask, fieldText, findClientSecretPath, getCalendar, getCalendarColors, getCalendarEvent, getGoogleAccessToken, googleApiError, googleAuthFlow, googleCalendarSyncTaskDef, googleConfigDir, googleSecretsDir, googleTokenPath, groupByCalendar, isClientSettableEventId, isDeniedAccessRole, isGoogleApiError, isIsoDateTimeWithOffset, isTextMimeType, legacyGoogleTokenPath, listCalendarEvents, listCalendars, listDriveFiles, listTaskLists, listTasks, loadCalendarShadow, loadCalendarSyncToken, loadClientSecret, loadGoogleTokens, locallyChangedFields, locallyDeletedIds, mayAdoptExisting, mergeGoogleTokens, mergeShadow, orphanedCalendarId, pickBoundary, planRecord, pushCalendarForCollection, pushableMap, readDriveFile, releaseOrphanedCalendarToken, resolveEventSpan, saveCalendarShadow, saveCalendarSyncToken, saveGoogleTokens, shadowUpdates, syncCalendarEvents, syncCalendarForCollection, syncCalendarGroup, syncDueCalendarCollections, syncNewCalendarCollections, toCalendarSummary, toCollectionDateTime, toCollectionRecord, toDriveFileSummary, toEventSummary, toGoogleEventTime, toShadowEvent, toTaskListSummary, toTaskSummary, uncompleteTask, unlinkGoogle, unsyncedGroups, updateCalendarEvent, updateTask, waitForAuthCode, withCalendarLock, withKeyedLock, zoneSuffixOf };
|
|
2440
|
+
export { CANCELLED_EVENT_STATUS, DEFAULT_LIST_MAX_RESULTS, GOOGLE_AUTH_CANCELLED, GOOGLE_CALENDARLIST_SCOPE, GOOGLE_CALENDAR_SCOPE, GOOGLE_CALENDAR_SYNC_TASK_ID, GOOGLE_DRIVE_FILE_SCOPE, GOOGLE_SCOPES, GOOGLE_TASKS_SCOPE, GoogleApiError, HTTP_CONFLICT, HTTP_FORBIDDEN, HTTP_PRECONDITION_FAILED, MAX_LIST_RESULTS, PROTECTION_UNKNOWN, PUSHABLE_SOURCE_FIELDS, allUnpushed, anySyncedCollectionSurvives, assertSafeMimeType, authorizeGoogle, baselineRecord, brokerBaseUrl, brokerExchange, brokerRefresh, brokerStart, buildEventPatch, buildMultipartBody, buildTaskPatch, bySourceField, calendarApiError, calendarPushStatePath, calendarSyncDueWindowMs, calendarSyncStatePath, canonicalCalendarId, canonicalTaskListId, claimCalendarSyncIfDue, classifyDelete, classifyWrite, clearCalendarLastSyncedAt, clearCalendarShadow, clearCalendarSyncToken, clientSecretPresence, collectCalendarPages, commitLinkedTokens, completeTask, configureGoogleHost, conflictingFields, createCalendarEvent, createDriveFile, createGoogleAuthFlow, createTask, deleteCalendarEvent, deleteDriveFile, deleteGoogleTokens, deleteTask, fieldText, findClientSecretPath, getCalendar, getCalendarColors, getCalendarEvent, getGoogleAccessToken, googleApiError, googleAuthFlow, googleCalendarSyncTaskDef, googleConfigDir, googleSecretsDir, googleTokenPath, groupByCalendar, isCalendarSyncDue, isClientSettableEventId, isDeniedAccessRole, isGoogleApiError, isIsoDateTimeWithOffset, isTextMimeType, isUnpushed, legacyGoogleTokenPath, listCalendarEvents, listCalendars, listDriveFiles, listTaskLists, listTasks, loadCalendarLastSyncedAt, loadCalendarShadow, loadCalendarSyncToken, loadClientSecret, loadGoogleTokens, locallyChangedFields, locallyDeletedIds, locallyEditedIds, mayAdoptExisting, mergeGoogleTokens, mergeIntoExisting, mergeShadow, orphanedCalendarId, pickBoundary, planRecord, pullableEvents, pushCalendarForCollection, pushCollectionNow, pushableMap, readDriveFile, releaseOrphanedCalendarToken, resolveEventSpan, saveCalendarShadow, saveCalendarSyncToken, saveGoogleTokens, shadowUpdates, syncCalendarEvents, syncCalendarForCollection, syncCalendarGroup, syncDueCalendarCollections, syncNewCalendarCollections, toCalendarSummary, toCollectionDateTime, toCollectionRecord, toDriveFileSummary, toEventSummary, toGoogleEventTime, toShadowEvent, toTaskListSummary, toTaskSummary, uncompleteTask, unlinkGoogle, unpushedFor, unsentLocalEdits, unsyncedGroups, updateCalendarEvent, updateTask, waitForAuthCode, withCalendarLock, withKeyedLock, zoneSuffixOf };
|
|
2009
2441
|
|
|
2010
2442
|
//# sourceMappingURL=index.js.map
|