@mulmoclaude/core 1.2.1 → 1.4.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/README.md +49 -0
- package/assets/helps/google-calendar-collection.md +13 -2
- package/assets/helps/google.md +94 -0
- package/assets/helps/index.md +1 -0
- package/dist/collection/registry/server/index.cjs +3 -2
- package/dist/collection/registry/server/index.cjs.map +1 -1
- package/dist/collection/registry/server/index.js +3 -2
- package/dist/collection/registry/server/index.js.map +1 -1
- package/dist/collection/server/index.cjs +1 -1
- package/dist/collection/server/index.js +1 -1
- package/dist/collection-watchers/index.cjs +1 -1
- package/dist/collection-watchers/index.js +1 -1
- package/dist/feeds/server/index.cjs +2 -2
- package/dist/feeds/server/index.js +2 -2
- package/dist/files/index.cjs +3 -0
- package/dist/files/index.cjs.map +1 -1
- package/dist/files/index.d.ts +1 -0
- package/dist/files/index.js +2 -1
- package/dist/files/index.js.map +1 -1
- package/dist/files/relPath.d.ts +23 -0
- package/dist/google/calendar.d.ts +28 -0
- package/dist/google/collectionSync.d.ts +88 -4
- package/dist/google/index.cjs +235 -17
- package/dist/google/index.cjs.map +1 -1
- package/dist/google/index.d.ts +3 -3
- package/dist/google/index.js +223 -18
- package/dist/google/index.js.map +1 -1
- package/dist/google/tasks.d.ts +21 -0
- package/dist/relPath-CgGi4-Nv.cjs +46 -0
- package/dist/relPath-CgGi4-Nv.cjs.map +1 -0
- package/dist/relPath-DW8MC8VO.js +33 -0
- package/dist/relPath-DW8MC8VO.js.map +1 -0
- package/dist/{server-BZJCw2HU.js → server-D_6FLygK.js} +6 -2
- package/dist/server-D_6FLygK.js.map +1 -0
- package/dist/{server-w5ZZRLju.cjs → server-gEOeV_Nv.cjs} +6 -2
- package/dist/server-gEOeV_Nv.cjs.map +1 -0
- package/package.json +10 -1
- package/dist/server-BZJCw2HU.js.map +0 -1
- package/dist/server-w5ZZRLju.cjs.map +0 -1
|
@@ -14,6 +14,20 @@ export interface CalendarEventInput {
|
|
|
14
14
|
/** Event colour (Google event palette id "1".."11"); omit to inherit the calendar's colour. */
|
|
15
15
|
colorId?: string;
|
|
16
16
|
}
|
|
17
|
+
export interface UpdateCalendarEventInput {
|
|
18
|
+
eventId: string;
|
|
19
|
+
summary?: string;
|
|
20
|
+
startDateTime?: string;
|
|
21
|
+
endDateTime?: string;
|
|
22
|
+
/** `""` clears the description; omit to leave it untouched. */
|
|
23
|
+
description?: string;
|
|
24
|
+
calendarId?: string;
|
|
25
|
+
colorId?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface DeleteCalendarEventInput {
|
|
28
|
+
eventId: string;
|
|
29
|
+
calendarId?: string;
|
|
30
|
+
}
|
|
17
31
|
export interface ListEventsInput {
|
|
18
32
|
timeMin?: string;
|
|
19
33
|
maxResults?: number;
|
|
@@ -58,6 +72,20 @@ export declare const toCalendarSummary: (value: unknown) => CalendarSummary;
|
|
|
58
72
|
* helper now carries the wording. */
|
|
59
73
|
export declare const calendarApiError: (status: number, body: string) => Error;
|
|
60
74
|
export declare function createCalendarEvent(accessToken: string, input: CalendarEventInput): Promise<CalendarEventSummary>;
|
|
75
|
+
/** PATCH body for an event edit — only the fields the caller actually supplied.
|
|
76
|
+
*
|
|
77
|
+
* `undefined` means "leave as is" and `""` means "clear it", so the two cannot
|
|
78
|
+
* be collapsed: dropping `description: ""` would silently ignore a request to
|
|
79
|
+
* empty the body text. Pure so the distinction is testable without network. */
|
|
80
|
+
export declare const buildEventPatch: (input: UpdateCalendarEventInput) => Record<string, unknown>;
|
|
81
|
+
/** Edit an existing event in place. PATCH, not PUT — a PUT would need the whole
|
|
82
|
+
* event and would drop every field the caller never read (attendees,
|
|
83
|
+
* reminders, recurrence). */
|
|
84
|
+
export declare function updateCalendarEvent(accessToken: string, input: UpdateCalendarEventInput): Promise<CalendarEventSummary>;
|
|
85
|
+
/** Remove an event. Google answers 204 with no body, so there is nothing to
|
|
86
|
+
* return; a second delete of the same id answers 410, which surfaces as a
|
|
87
|
+
* GoogleApiError rather than being swallowed. */
|
|
88
|
+
export declare function deleteCalendarEvent(accessToken: string, input: DeleteCalendarEventInput): Promise<void>;
|
|
61
89
|
export declare function listCalendarEvents(accessToken: string, input?: ListEventsInput): Promise<CalendarEventSummary[]>;
|
|
62
90
|
export interface SyncEventsInput {
|
|
63
91
|
/** Calendar to sync; defaults to the user's primary. */
|
|
@@ -39,6 +39,13 @@ type ApplyOutcome = {
|
|
|
39
39
|
};
|
|
40
40
|
export declare function classifyWrite(eventId: string, kind: WriteItemResult["kind"]): ApplyOutcome;
|
|
41
41
|
export declare function classifyDelete(eventId: string, kind: DeleteItemResult["kind"]): ApplyOutcome;
|
|
42
|
+
/** Serialise `run` against whatever is already running for `key`.
|
|
43
|
+
*
|
|
44
|
+
* `locks` is passed in so the queuing rule is testable without module state;
|
|
45
|
+
* the key is dropped once nothing is queued behind it, so the map cannot grow
|
|
46
|
+
* an entry per calendar forever. A failed predecessor still releases the
|
|
47
|
+
* queue — `then(run, run)`. */
|
|
48
|
+
export declare function withKeyedLock<T>(locks: Map<string, Promise<unknown>>, key: string, run: () => Promise<T>): Promise<T>;
|
|
42
49
|
/** Sync ONE calendar and fan its events out to every collection bound to it.
|
|
43
50
|
*
|
|
44
51
|
* The fan-out is not an optimisation, it is correctness: the sync token is
|
|
@@ -46,8 +53,19 @@ export declare function classifyDelete(eventId: string, kind: DeleteItemResult["
|
|
|
46
53
|
* first collection advance the shared token and leave every later collection
|
|
47
54
|
* on the same calendar reading an already-consumed window — silently missing
|
|
48
55
|
* those events forever. Fetch once, apply to all, then advance the token.
|
|
49
|
-
* (Codex + CodeRabbit review on #2184.)
|
|
56
|
+
* (Codex + CodeRabbit review on #2184.)
|
|
57
|
+
*
|
|
58
|
+
* Queued per calendar for the same reason the fan-out exists: two passes over
|
|
59
|
+
* one calendar (a Refresh click landing during the scheduled run) would each
|
|
60
|
+
* load the SAME stored token and walk the same window. That is idempotent —
|
|
61
|
+
* writes are upserts by event id — but it is a wasted full walk. Queued, the
|
|
62
|
+
* second pass resumes from the token the first just stored and fetches only
|
|
63
|
+
* what is genuinely new. */
|
|
50
64
|
export declare function syncCalendarGroup(calendarId: string | undefined, collections: readonly LoadedCollection[], workspaceRoot: string): Promise<CalendarCollectionSyncResult[]>;
|
|
65
|
+
/** Liveness of the collections a sync just wrote to, checked against the skill
|
|
66
|
+
* dir `deleteCollection` removes. `exists` is injected so the rule is testable
|
|
67
|
+
* without a filesystem. An empty group has no survivor by definition. */
|
|
68
|
+
export declare function anySyncedCollectionSurvives(collections: readonly Pick<LoadedCollection, "skillDir">[], exists?: (absPath: string) => Promise<boolean>): Promise<boolean>;
|
|
51
69
|
/** Group the declaring collections by the calendar they read, so each calendar
|
|
52
70
|
* is fetched exactly once.
|
|
53
71
|
*
|
|
@@ -57,10 +75,76 @@ export declare function syncCalendarGroup(calendarId: string | undefined, collec
|
|
|
57
75
|
* from under the other — the very loss this grouping exists to prevent
|
|
58
76
|
* (Codex review #2184). */
|
|
59
77
|
export declare function groupByCalendar(collections: readonly LoadedCollection[]): Map<string, LoadedCollection[]>;
|
|
60
|
-
/**
|
|
61
|
-
*
|
|
62
|
-
|
|
78
|
+
/** The minimum a value needs for the orphan check: just the calendar it reads.
|
|
79
|
+
* Structural so the rule can be exercised without building a LoadedCollection. */
|
|
80
|
+
export interface CalendarDeclaring {
|
|
81
|
+
googleCalendar?: {
|
|
82
|
+
calendarId?: string;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** The canonical calendar whose sync token nothing needs any more, or null.
|
|
86
|
+
*
|
|
87
|
+
* Sync tokens are keyed by calendar, NOT by collection, so a deleted
|
|
88
|
+
* collection's token outlives it. Recreating a collection on the same calendar
|
|
89
|
+
* then resumes from that token and receives only the delta — the new
|
|
90
|
+
* collection never gets the history (#2428).
|
|
91
|
+
*
|
|
92
|
+
* Returns null while ANY remaining collection still reads that calendar:
|
|
93
|
+
* clearing a live calendar's token costs a full re-walk on the next sync. The
|
|
94
|
+
* comparison is on the CANONICAL id for the same reason `groupByCalendar` is —
|
|
95
|
+
* an omitted `calendarId` and an explicit `"primary"` are one calendar. */
|
|
96
|
+
export declare function orphanedCalendarId(deleted: CalendarDeclaring, remaining: readonly CalendarDeclaring[]): string | null;
|
|
97
|
+
/** Drop the sync token of a just-deleted collection's calendar, unless another
|
|
98
|
+
* collection still reads it. Call AFTER the delete lands — the check reads the
|
|
99
|
+
* collections that survive it.
|
|
100
|
+
*
|
|
101
|
+
* Returns the cleared calendar id, or null when nothing was cleared. Never
|
|
102
|
+
* throws: a failed cleanup must not fail the delete it follows. */
|
|
103
|
+
export declare function releaseOrphanedCalendarToken(deleted: CalendarDeclaring, workspaceRoot: string): Promise<string | null>;
|
|
104
|
+
/** Sync every collection that declares `googleCalendar`. */
|
|
63
105
|
export declare function syncDueCalendarCollections(workspaceRoot: string): Promise<CalendarCollectionSyncResult[]>;
|
|
106
|
+
/** The groups whose calendar has never synced. A missing token IS the "created
|
|
107
|
+
* since the last sync" signal — nothing else distinguishes a new collection
|
|
108
|
+
* from an edited one on the write path this feeds (#2427).
|
|
109
|
+
*
|
|
110
|
+
* Self-silencing by construction: the first sync stores a token, so a calendar
|
|
111
|
+
* matches at most once. `loadToken` is injected so the rule is testable without
|
|
112
|
+
* a workspace on disk. */
|
|
113
|
+
export declare function unsyncedGroups<T>(groups: Map<string, T>, loadToken: (calendarId: string) => Promise<string | null>): Promise<Map<string, T>>;
|
|
114
|
+
/** Sync only the calendars that have never synced — the first sync for a
|
|
115
|
+
* just-created collection, which otherwise stays empty until the hourly
|
|
116
|
+
* scheduler run (#2427). Cheap and safe to call on every config write. */
|
|
117
|
+
export declare function syncNewCalendarCollections(workspaceRoot: string): Promise<CalendarCollectionSyncResult[]>;
|
|
118
|
+
/** A user-triggered sync's outcome. `not-a-calendar` and `not-linked` are
|
|
119
|
+
* states the caller must report rather than swallow: a Refresh click that
|
|
120
|
+
* quietly returns "0 written" reads as an empty calendar, not as a setup gap. */
|
|
121
|
+
export type ManualCalendarSyncOutcome = {
|
|
122
|
+
kind: "synced";
|
|
123
|
+
results: CalendarCollectionSyncResult[];
|
|
124
|
+
} | {
|
|
125
|
+
kind: "not-a-calendar";
|
|
126
|
+
} | {
|
|
127
|
+
kind: "not-linked";
|
|
128
|
+
};
|
|
129
|
+
/** The I/O a manual sync crosses, injectable so the three outcomes can be
|
|
130
|
+
* exercised with fakes instead of a workspace on disk and a live Google grant
|
|
131
|
+
* (CodeRabbit review #2566). */
|
|
132
|
+
export interface ManualCalendarSyncDeps {
|
|
133
|
+
loadGroups: (workspaceRoot: string) => Promise<Map<string, LoadedCollection[]>>;
|
|
134
|
+
isLinked: () => Promise<boolean>;
|
|
135
|
+
runGroups: (groups: Map<string, LoadedCollection[]>, workspaceRoot: string) => Promise<CalendarCollectionSyncResult[]>;
|
|
136
|
+
}
|
|
137
|
+
/** Sync the calendar ONE collection reads, on demand (the Refresh button).
|
|
138
|
+
*
|
|
139
|
+
* Deliberately syncs the whole group, not just `slug`: the sync token is keyed
|
|
140
|
+
* by calendar, so consuming a window for one collection would leave the others
|
|
141
|
+
* on that calendar reading an already-consumed one. Returns every result of the
|
|
142
|
+
* group so the caller can report the requested slug's own counts.
|
|
143
|
+
*
|
|
144
|
+
* "Does this collection sync at all" is answered BEFORE "is Google linked":
|
|
145
|
+
* telling someone to link their account for a collection that never declared a
|
|
146
|
+
* calendar sends them fixing the wrong thing. */
|
|
147
|
+
export declare function syncCalendarForCollection(slug: string, workspaceRoot: string, deps?: ManualCalendarSyncDeps): Promise<ManualCalendarSyncOutcome>;
|
|
64
148
|
/** Scheduler registration, shaped like `feedRefreshTaskDef` so hosts wire it
|
|
65
149
|
* with a single line. */
|
|
66
150
|
export declare function googleCalendarSyncTaskDef(opts?: {
|
package/dist/google/index.cjs
CHANGED
|
@@ -655,6 +655,34 @@ async function createCalendarEvent(accessToken, input) {
|
|
|
655
655
|
body: JSON.stringify(body)
|
|
656
656
|
}));
|
|
657
657
|
}
|
|
658
|
+
var eventUrl = (calendarId, eventId) => `${eventsUrl(calendarId)}/${encodeURIComponent(eventId)}`;
|
|
659
|
+
/** PATCH body for an event edit — only the fields the caller actually supplied.
|
|
660
|
+
*
|
|
661
|
+
* `undefined` means "leave as is" and `""` means "clear it", so the two cannot
|
|
662
|
+
* be collapsed: dropping `description: ""` would silently ignore a request to
|
|
663
|
+
* empty the body text. Pure so the distinction is testable without network. */
|
|
664
|
+
var buildEventPatch = (input) => ({
|
|
665
|
+
...input.summary !== void 0 ? { summary: input.summary } : {},
|
|
666
|
+
...input.description !== void 0 ? { description: input.description } : {},
|
|
667
|
+
...input.startDateTime !== void 0 ? { start: { dateTime: input.startDateTime } } : {},
|
|
668
|
+
...input.endDateTime !== void 0 ? { end: { dateTime: input.endDateTime } } : {},
|
|
669
|
+
...input.colorId ? { colorId: input.colorId } : {}
|
|
670
|
+
});
|
|
671
|
+
/** Edit an existing event in place. PATCH, not PUT — a PUT would need the whole
|
|
672
|
+
* event and would drop every field the caller never read (attendees,
|
|
673
|
+
* reminders, recurrence). */
|
|
674
|
+
async function updateCalendarEvent(accessToken, input) {
|
|
675
|
+
return toEventSummary(await googleRequest(CALENDAR_API_LABEL, accessToken, eventUrl(input.calendarId, input.eventId), {
|
|
676
|
+
method: "PATCH",
|
|
677
|
+
body: JSON.stringify(buildEventPatch(input))
|
|
678
|
+
}));
|
|
679
|
+
}
|
|
680
|
+
/** Remove an event. Google answers 204 with no body, so there is nothing to
|
|
681
|
+
* return; a second delete of the same id answers 410, which surfaces as a
|
|
682
|
+
* GoogleApiError rather than being swallowed. */
|
|
683
|
+
async function deleteCalendarEvent(accessToken, input) {
|
|
684
|
+
await googleRequest(CALENDAR_API_LABEL, accessToken, eventUrl(input.calendarId, input.eventId), { method: "DELETE" });
|
|
685
|
+
}
|
|
658
686
|
async function listCalendarEvents(accessToken, input = {}) {
|
|
659
687
|
const params = new URLSearchParams({
|
|
660
688
|
timeMin: input.timeMin ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -883,6 +911,26 @@ async function restartFullSync(accessToken, calendarId, workspaceRoot) {
|
|
|
883
911
|
await clearCalendarSyncToken(calendarId, workspaceRoot);
|
|
884
912
|
return await syncCalendarEvents(accessToken, { calendarId });
|
|
885
913
|
}
|
|
914
|
+
/** Serialise `run` against whatever is already running for `key`.
|
|
915
|
+
*
|
|
916
|
+
* `locks` is passed in so the queuing rule is testable without module state;
|
|
917
|
+
* the key is dropped once nothing is queued behind it, so the map cannot grow
|
|
918
|
+
* an entry per calendar forever. A failed predecessor still releases the
|
|
919
|
+
* queue — `then(run, run)`. */
|
|
920
|
+
async function withKeyedLock(locks, key, run) {
|
|
921
|
+
const result = (locks.get(key) ?? Promise.resolve()).then(run, run);
|
|
922
|
+
const tail = result.then(() => void 0, () => void 0);
|
|
923
|
+
locks.set(key, tail);
|
|
924
|
+
try {
|
|
925
|
+
return await result;
|
|
926
|
+
} finally {
|
|
927
|
+
if (locks.get(key) === tail) locks.delete(key);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
/** In-flight sync per canonical calendar id. Module state on purpose: the
|
|
931
|
+
* scheduler, the create trigger and the Refresh button are three doors into
|
|
932
|
+
* the same calendar (CodeRabbit review #2566). */
|
|
933
|
+
var calendarLocks = /* @__PURE__ */ new Map();
|
|
886
934
|
/** Sync ONE calendar and fan its events out to every collection bound to it.
|
|
887
935
|
*
|
|
888
936
|
* The fan-out is not an optimisation, it is correctness: the sync token is
|
|
@@ -890,8 +938,18 @@ async function restartFullSync(accessToken, calendarId, workspaceRoot) {
|
|
|
890
938
|
* first collection advance the shared token and leave every later collection
|
|
891
939
|
* on the same calendar reading an already-consumed window — silently missing
|
|
892
940
|
* those events forever. Fetch once, apply to all, then advance the token.
|
|
893
|
-
* (Codex + CodeRabbit review on #2184.)
|
|
941
|
+
* (Codex + CodeRabbit review on #2184.)
|
|
942
|
+
*
|
|
943
|
+
* Queued per calendar for the same reason the fan-out exists: two passes over
|
|
944
|
+
* one calendar (a Refresh click landing during the scheduled run) would each
|
|
945
|
+
* load the SAME stored token and walk the same window. That is idempotent —
|
|
946
|
+
* writes are upserts by event id — but it is a wasted full walk. Queued, the
|
|
947
|
+
* second pass resumes from the token the first just stored and fetches only
|
|
948
|
+
* what is genuinely new. */
|
|
894
949
|
async function syncCalendarGroup(calendarId, collections, workspaceRoot) {
|
|
950
|
+
return await withKeyedLock(calendarLocks, canonicalCalendarId(calendarId), () => syncCalendarGroupNow(calendarId, collections, workspaceRoot));
|
|
951
|
+
}
|
|
952
|
+
async function syncCalendarGroupNow(calendarId, collections, workspaceRoot) {
|
|
895
953
|
const accessToken = await getGoogleAccessToken();
|
|
896
954
|
const first = await syncCalendarEvents(accessToken, {
|
|
897
955
|
calendarId,
|
|
@@ -906,13 +964,47 @@ async function syncCalendarGroup(calendarId, collections, workspaceRoot) {
|
|
|
906
964
|
unwritable
|
|
907
965
|
});
|
|
908
966
|
const failed = results.flatMap((entry) => entry.errors);
|
|
909
|
-
if (
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
967
|
+
if (failed.length > 0) {
|
|
968
|
+
log.warn("google", "holding back calendar sync token after failed writes", {
|
|
969
|
+
calendarId,
|
|
970
|
+
failed: failed.length
|
|
971
|
+
});
|
|
972
|
+
return results;
|
|
973
|
+
}
|
|
974
|
+
if (result.nextSyncToken) await advanceToken(calendarId, result.nextSyncToken, collections, workspaceRoot);
|
|
914
975
|
return results;
|
|
915
976
|
}
|
|
977
|
+
/** Save the window's token unless every collection that consumed it was deleted
|
|
978
|
+
* while the sync was in flight.
|
|
979
|
+
*
|
|
980
|
+
* The sync opens with a `discoverCollections()` snapshot, so a delete landing
|
|
981
|
+
* mid-run has already cleared this calendar's token by the time we get here
|
|
982
|
+
* (`releaseOrphanedCalendarToken`). Saving anyway would resurrect exactly the
|
|
983
|
+
* orphan the delete removed, and the next collection on this calendar would
|
|
984
|
+
* resume from a token describing records it never received (#2428).
|
|
985
|
+
*
|
|
986
|
+
* One survivor is enough: it still needs the incremental position. */
|
|
987
|
+
async function advanceToken(calendarId, nextSyncToken, collections, workspaceRoot) {
|
|
988
|
+
if (!await anySyncedCollectionSurvives(collections)) {
|
|
989
|
+
log.info("google", "not advancing the sync token — every collection on this calendar was deleted mid-sync", { calendarId });
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
await saveCalendarSyncToken(calendarId, nextSyncToken, workspaceRoot);
|
|
993
|
+
}
|
|
994
|
+
async function pathExists(absPath) {
|
|
995
|
+
try {
|
|
996
|
+
await (0, node_fs_promises.stat)(absPath);
|
|
997
|
+
return true;
|
|
998
|
+
} catch {
|
|
999
|
+
return false;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
/** Liveness of the collections a sync just wrote to, checked against the skill
|
|
1003
|
+
* dir `deleteCollection` removes. `exists` is injected so the rule is testable
|
|
1004
|
+
* without a filesystem. An empty group has no survivor by definition. */
|
|
1005
|
+
async function anySyncedCollectionSurvives(collections, exists = pathExists) {
|
|
1006
|
+
return (await Promise.all(collections.map((collection) => exists(collection.skillDir)))).some(Boolean);
|
|
1007
|
+
}
|
|
916
1008
|
async function applyEventsToCollection(collection, events, workspaceRoot) {
|
|
917
1009
|
const outcomes = [];
|
|
918
1010
|
for (const event of events) outcomes.push(await applyEvent(collection, event, workspaceRoot));
|
|
@@ -945,18 +1037,62 @@ function groupByCalendar(collections) {
|
|
|
945
1037
|
}
|
|
946
1038
|
return groups;
|
|
947
1039
|
}
|
|
948
|
-
/**
|
|
949
|
-
*
|
|
950
|
-
*
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1040
|
+
/** The canonical calendar whose sync token nothing needs any more, or null.
|
|
1041
|
+
*
|
|
1042
|
+
* Sync tokens are keyed by calendar, NOT by collection, so a deleted
|
|
1043
|
+
* collection's token outlives it. Recreating a collection on the same calendar
|
|
1044
|
+
* then resumes from that token and receives only the delta — the new
|
|
1045
|
+
* collection never gets the history (#2428).
|
|
1046
|
+
*
|
|
1047
|
+
* Returns null while ANY remaining collection still reads that calendar:
|
|
1048
|
+
* clearing a live calendar's token costs a full re-walk on the next sync. The
|
|
1049
|
+
* comparison is on the CANONICAL id for the same reason `groupByCalendar` is —
|
|
1050
|
+
* an omitted `calendarId` and an explicit `"primary"` are one calendar. */
|
|
1051
|
+
function orphanedCalendarId(deleted, remaining) {
|
|
1052
|
+
if (!deleted.googleCalendar) return null;
|
|
1053
|
+
const key = canonicalCalendarId(deleted.googleCalendar.calendarId);
|
|
1054
|
+
return remaining.some((other) => other.googleCalendar && canonicalCalendarId(other.googleCalendar.calendarId) === key) ? null : key;
|
|
1055
|
+
}
|
|
1056
|
+
/** Drop the sync token of a just-deleted collection's calendar, unless another
|
|
1057
|
+
* collection still reads it. Call AFTER the delete lands — the check reads the
|
|
1058
|
+
* collections that survive it.
|
|
1059
|
+
*
|
|
1060
|
+
* Returns the cleared calendar id, or null when nothing was cleared. Never
|
|
1061
|
+
* throws: a failed cleanup must not fail the delete it follows. */
|
|
1062
|
+
async function releaseOrphanedCalendarToken(deleted, workspaceRoot) {
|
|
1063
|
+
try {
|
|
1064
|
+
if (!deleted.googleCalendar) return null;
|
|
1065
|
+
const orphaned = orphanedCalendarId(deleted, (await require_discovery.discoverCollections({ workspaceRoot })).map((collection) => collection.schema));
|
|
1066
|
+
if (orphaned === null) return null;
|
|
1067
|
+
await clearCalendarSyncToken(orphaned, workspaceRoot);
|
|
1068
|
+
log.info("google", "cleared the sync token of a calendar no collection reads any more", { calendarId: orphaned });
|
|
1069
|
+
return orphaned;
|
|
1070
|
+
} catch (error) {
|
|
1071
|
+
log.warn("google", "could not release the deleted collection's calendar sync token", { error: String(error) });
|
|
1072
|
+
return null;
|
|
957
1073
|
}
|
|
1074
|
+
}
|
|
1075
|
+
/** Every declaring collection, grouped by the calendar it reads. */
|
|
1076
|
+
async function declaringGroups(workspaceRoot) {
|
|
1077
|
+
return groupByCalendar((await require_discovery.discoverCollections({ workspaceRoot })).filter((collection) => collection.schema.googleCalendar));
|
|
1078
|
+
}
|
|
1079
|
+
/** Whether a background sync of these groups may run at all.
|
|
1080
|
+
*
|
|
1081
|
+
* Authoring the collection before linking the account is an expected state,
|
|
1082
|
+
* not a failure. Checking once here keeps it a quiet skip instead of an
|
|
1083
|
+
* access-token throw per calendar, every hour, until the user links (#2188).
|
|
1084
|
+
* A user-triggered sync answers differently — it says so out loud. */
|
|
1085
|
+
async function backgroundSyncAllowed(groups) {
|
|
1086
|
+
if (groups.size === 0) return false;
|
|
1087
|
+
if (await isGoogleLinked()) return true;
|
|
1088
|
+
log.info("google", "skipping calendar sync — no Google account linked on this host", { calendars: groups.size });
|
|
1089
|
+
return false;
|
|
1090
|
+
}
|
|
1091
|
+
/** Run each group, isolating failures per calendar — one unreachable calendar
|
|
1092
|
+
* (or a revoked grant) must not stop the others. */
|
|
1093
|
+
async function runCalendarGroups(groups, workspaceRoot) {
|
|
958
1094
|
const results = [];
|
|
959
|
-
for (const [calendarId, collections] of
|
|
1095
|
+
for (const [calendarId, collections] of groups) try {
|
|
960
1096
|
results.push(...await syncCalendarGroup(calendarId, collections, workspaceRoot));
|
|
961
1097
|
} catch (error) {
|
|
962
1098
|
log.warn("google", "calendar sync failed", {
|
|
@@ -973,6 +1109,55 @@ async function syncDueCalendarCollections(workspaceRoot) {
|
|
|
973
1109
|
}
|
|
974
1110
|
return results;
|
|
975
1111
|
}
|
|
1112
|
+
/** Sync every collection that declares `googleCalendar`. */
|
|
1113
|
+
async function syncDueCalendarCollections(workspaceRoot) {
|
|
1114
|
+
const groups = await declaringGroups(workspaceRoot);
|
|
1115
|
+
return await backgroundSyncAllowed(groups) ? await runCalendarGroups(groups, workspaceRoot) : [];
|
|
1116
|
+
}
|
|
1117
|
+
/** The groups whose calendar has never synced. A missing token IS the "created
|
|
1118
|
+
* since the last sync" signal — nothing else distinguishes a new collection
|
|
1119
|
+
* from an edited one on the write path this feeds (#2427).
|
|
1120
|
+
*
|
|
1121
|
+
* Self-silencing by construction: the first sync stores a token, so a calendar
|
|
1122
|
+
* matches at most once. `loadToken` is injected so the rule is testable without
|
|
1123
|
+
* a workspace on disk. */
|
|
1124
|
+
async function unsyncedGroups(groups, loadToken) {
|
|
1125
|
+
const checked = await Promise.all([...groups].map(async (entry) => await loadToken(entry[0]) === null ? entry : null));
|
|
1126
|
+
return new Map(checked.filter((entry) => entry !== null));
|
|
1127
|
+
}
|
|
1128
|
+
/** Sync only the calendars that have never synced — the first sync for a
|
|
1129
|
+
* just-created collection, which otherwise stays empty until the hourly
|
|
1130
|
+
* scheduler run (#2427). Cheap and safe to call on every config write. */
|
|
1131
|
+
async function syncNewCalendarCollections(workspaceRoot) {
|
|
1132
|
+
const pending = await unsyncedGroups(await declaringGroups(workspaceRoot), (calendarId) => loadCalendarSyncToken(calendarId, workspaceRoot));
|
|
1133
|
+
if (!await backgroundSyncAllowed(pending)) return [];
|
|
1134
|
+
log.info("google", "running the first sync for newly declared calendars", { calendars: [...pending.keys()] });
|
|
1135
|
+
return await runCalendarGroups(pending, workspaceRoot);
|
|
1136
|
+
}
|
|
1137
|
+
var liveManualSyncDeps = {
|
|
1138
|
+
loadGroups: declaringGroups,
|
|
1139
|
+
isLinked: isGoogleLinked,
|
|
1140
|
+
runGroups: runCalendarGroups
|
|
1141
|
+
};
|
|
1142
|
+
/** Sync the calendar ONE collection reads, on demand (the Refresh button).
|
|
1143
|
+
*
|
|
1144
|
+
* Deliberately syncs the whole group, not just `slug`: the sync token is keyed
|
|
1145
|
+
* by calendar, so consuming a window for one collection would leave the others
|
|
1146
|
+
* on that calendar reading an already-consumed one. Returns every result of the
|
|
1147
|
+
* group so the caller can report the requested slug's own counts.
|
|
1148
|
+
*
|
|
1149
|
+
* "Does this collection sync at all" is answered BEFORE "is Google linked":
|
|
1150
|
+
* telling someone to link their account for a collection that never declared a
|
|
1151
|
+
* calendar sends them fixing the wrong thing. */
|
|
1152
|
+
async function syncCalendarForCollection(slug, workspaceRoot, deps = liveManualSyncDeps) {
|
|
1153
|
+
const owning = [...await deps.loadGroups(workspaceRoot)].filter(([, collections]) => collections.some((collection) => collection.slug === slug));
|
|
1154
|
+
if (owning.length === 0) return { kind: "not-a-calendar" };
|
|
1155
|
+
if (!await deps.isLinked()) return { kind: "not-linked" };
|
|
1156
|
+
return {
|
|
1157
|
+
kind: "synced",
|
|
1158
|
+
results: await deps.runGroups(new Map(owning), workspaceRoot)
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
976
1161
|
/** Scheduler registration, shaped like `feedRefreshTaskDef` so hosts wire it
|
|
977
1162
|
* with a single line. */
|
|
978
1163
|
function googleCalendarSyncTaskDef(opts) {
|
|
@@ -1012,7 +1197,12 @@ var toTaskSummary = (value) => {
|
|
|
1012
1197
|
notes: stringField(record, "notes")
|
|
1013
1198
|
};
|
|
1014
1199
|
};
|
|
1015
|
-
|
|
1200
|
+
/** Resolve a declared taskListId to the one the API addresses. `||` (not `??`)
|
|
1201
|
+
* so a blank string also falls back instead of building a malformed
|
|
1202
|
+
* `/lists//tasks` URL — the same rule, and the same reason, as
|
|
1203
|
+
* `canonicalCalendarId`. */
|
|
1204
|
+
var canonicalTaskListId = (taskListId) => taskListId?.trim() || DEFAULT_TASK_LIST_ID;
|
|
1205
|
+
var tasksUrl = (taskListId, suffix = "") => `${TASKS_BASE_URL}/lists/${encodeURIComponent(canonicalTaskListId(taskListId))}/tasks${suffix}`;
|
|
1016
1206
|
async function listTaskLists(accessToken) {
|
|
1017
1207
|
return itemsOf(await googleRequest(TASKS_API_LABEL, accessToken, `${TASKS_BASE_URL}/users/@me/lists?maxResults=${MAX_TASK_LISTS}`)).map(toTaskListSummary);
|
|
1018
1208
|
}
|
|
@@ -1034,6 +1224,21 @@ async function createTask(accessToken, input) {
|
|
|
1034
1224
|
body: JSON.stringify(body)
|
|
1035
1225
|
}));
|
|
1036
1226
|
}
|
|
1227
|
+
/** PATCH body for a task edit — only the fields the caller supplied. As with
|
|
1228
|
+
* events, `undefined` means "leave as is" and `""` means "clear it", so the
|
|
1229
|
+
* two must stay distinct. `status` is deliberately absent: `completeTask`
|
|
1230
|
+
* owns that transition, and two ways to set it would drift apart. */
|
|
1231
|
+
var buildTaskPatch = (input) => ({
|
|
1232
|
+
...input.title !== void 0 ? { title: input.title } : {},
|
|
1233
|
+
...input.notes !== void 0 ? { notes: input.notes } : {},
|
|
1234
|
+
...input.due !== void 0 ? { due: input.due } : {}
|
|
1235
|
+
});
|
|
1236
|
+
async function updateTask(accessToken, input) {
|
|
1237
|
+
return toTaskSummary(await googleRequest(TASKS_API_LABEL, accessToken, tasksUrl(input.taskListId, `/${encodeURIComponent(input.taskId)}`), {
|
|
1238
|
+
method: "PATCH",
|
|
1239
|
+
body: JSON.stringify(buildTaskPatch(input))
|
|
1240
|
+
}));
|
|
1241
|
+
}
|
|
1037
1242
|
async function completeTask(accessToken, input) {
|
|
1038
1243
|
return toTaskSummary(await googleRequest(TASKS_API_LABEL, accessToken, tasksUrl(input.taskListId, `/${encodeURIComponent(input.taskId)}`), {
|
|
1039
1244
|
method: "PATCH",
|
|
@@ -1147,16 +1352,20 @@ exports.GOOGLE_SCOPES = GOOGLE_SCOPES;
|
|
|
1147
1352
|
exports.GOOGLE_TASKS_SCOPE = GOOGLE_TASKS_SCOPE;
|
|
1148
1353
|
exports.GoogleApiError = GoogleApiError;
|
|
1149
1354
|
exports.MAX_LIST_RESULTS = MAX_LIST_RESULTS;
|
|
1355
|
+
exports.anySyncedCollectionSurvives = anySyncedCollectionSurvives;
|
|
1150
1356
|
exports.assertSafeMimeType = assertSafeMimeType;
|
|
1151
1357
|
exports.authorizeGoogle = authorizeGoogle;
|
|
1152
1358
|
exports.brokerBaseUrl = brokerBaseUrl;
|
|
1153
1359
|
exports.brokerExchange = brokerExchange;
|
|
1154
1360
|
exports.brokerRefresh = brokerRefresh;
|
|
1155
1361
|
exports.brokerStart = brokerStart;
|
|
1362
|
+
exports.buildEventPatch = buildEventPatch;
|
|
1156
1363
|
exports.buildMultipartBody = buildMultipartBody;
|
|
1364
|
+
exports.buildTaskPatch = buildTaskPatch;
|
|
1157
1365
|
exports.calendarApiError = calendarApiError;
|
|
1158
1366
|
exports.calendarSyncStatePath = calendarSyncStatePath;
|
|
1159
1367
|
exports.canonicalCalendarId = canonicalCalendarId;
|
|
1368
|
+
exports.canonicalTaskListId = canonicalTaskListId;
|
|
1160
1369
|
exports.classifyDelete = classifyDelete;
|
|
1161
1370
|
exports.classifyWrite = classifyWrite;
|
|
1162
1371
|
exports.clearCalendarSyncToken = clearCalendarSyncToken;
|
|
@@ -1169,6 +1378,7 @@ exports.createCalendarEvent = createCalendarEvent;
|
|
|
1169
1378
|
exports.createDriveFile = createDriveFile;
|
|
1170
1379
|
exports.createGoogleAuthFlow = createGoogleAuthFlow;
|
|
1171
1380
|
exports.createTask = createTask;
|
|
1381
|
+
exports.deleteCalendarEvent = deleteCalendarEvent;
|
|
1172
1382
|
exports.deleteDriveFile = deleteDriveFile;
|
|
1173
1383
|
exports.deleteGoogleTokens = deleteGoogleTokens;
|
|
1174
1384
|
exports.deleteTask = deleteTask;
|
|
@@ -1195,13 +1405,17 @@ exports.loadCalendarSyncToken = loadCalendarSyncToken;
|
|
|
1195
1405
|
exports.loadClientSecret = loadClientSecret;
|
|
1196
1406
|
exports.loadGoogleTokens = loadGoogleTokens;
|
|
1197
1407
|
exports.mergeGoogleTokens = mergeGoogleTokens;
|
|
1408
|
+
exports.orphanedCalendarId = orphanedCalendarId;
|
|
1198
1409
|
exports.pickBoundary = pickBoundary;
|
|
1199
1410
|
exports.readDriveFile = readDriveFile;
|
|
1411
|
+
exports.releaseOrphanedCalendarToken = releaseOrphanedCalendarToken;
|
|
1200
1412
|
exports.saveCalendarSyncToken = saveCalendarSyncToken;
|
|
1201
1413
|
exports.saveGoogleTokens = saveGoogleTokens;
|
|
1202
1414
|
exports.syncCalendarEvents = syncCalendarEvents;
|
|
1415
|
+
exports.syncCalendarForCollection = syncCalendarForCollection;
|
|
1203
1416
|
exports.syncCalendarGroup = syncCalendarGroup;
|
|
1204
1417
|
exports.syncDueCalendarCollections = syncDueCalendarCollections;
|
|
1418
|
+
exports.syncNewCalendarCollections = syncNewCalendarCollections;
|
|
1205
1419
|
exports.toCalendarSummary = toCalendarSummary;
|
|
1206
1420
|
exports.toCollectionDateTime = toCollectionDateTime;
|
|
1207
1421
|
exports.toCollectionRecord = toCollectionRecord;
|
|
@@ -1210,6 +1424,10 @@ exports.toEventSummary = toEventSummary;
|
|
|
1210
1424
|
exports.toTaskListSummary = toTaskListSummary;
|
|
1211
1425
|
exports.toTaskSummary = toTaskSummary;
|
|
1212
1426
|
exports.unlinkGoogle = unlinkGoogle;
|
|
1427
|
+
exports.unsyncedGroups = unsyncedGroups;
|
|
1428
|
+
exports.updateCalendarEvent = updateCalendarEvent;
|
|
1429
|
+
exports.updateTask = updateTask;
|
|
1213
1430
|
exports.waitForAuthCode = waitForAuthCode;
|
|
1431
|
+
exports.withKeyedLock = withKeyedLock;
|
|
1214
1432
|
|
|
1215
1433
|
//# sourceMappingURL=index.cjs.map
|