@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.
Files changed (39) hide show
  1. package/README.md +49 -0
  2. package/assets/helps/google-calendar-collection.md +13 -2
  3. package/assets/helps/google.md +94 -0
  4. package/assets/helps/index.md +1 -0
  5. package/dist/collection/registry/server/index.cjs +3 -2
  6. package/dist/collection/registry/server/index.cjs.map +1 -1
  7. package/dist/collection/registry/server/index.js +3 -2
  8. package/dist/collection/registry/server/index.js.map +1 -1
  9. package/dist/collection/server/index.cjs +1 -1
  10. package/dist/collection/server/index.js +1 -1
  11. package/dist/collection-watchers/index.cjs +1 -1
  12. package/dist/collection-watchers/index.js +1 -1
  13. package/dist/feeds/server/index.cjs +2 -2
  14. package/dist/feeds/server/index.js +2 -2
  15. package/dist/files/index.cjs +3 -0
  16. package/dist/files/index.cjs.map +1 -1
  17. package/dist/files/index.d.ts +1 -0
  18. package/dist/files/index.js +2 -1
  19. package/dist/files/index.js.map +1 -1
  20. package/dist/files/relPath.d.ts +23 -0
  21. package/dist/google/calendar.d.ts +28 -0
  22. package/dist/google/collectionSync.d.ts +88 -4
  23. package/dist/google/index.cjs +235 -17
  24. package/dist/google/index.cjs.map +1 -1
  25. package/dist/google/index.d.ts +3 -3
  26. package/dist/google/index.js +223 -18
  27. package/dist/google/index.js.map +1 -1
  28. package/dist/google/tasks.d.ts +21 -0
  29. package/dist/relPath-CgGi4-Nv.cjs +46 -0
  30. package/dist/relPath-CgGi4-Nv.cjs.map +1 -0
  31. package/dist/relPath-DW8MC8VO.js +33 -0
  32. package/dist/relPath-DW8MC8VO.js.map +1 -0
  33. package/dist/{server-BZJCw2HU.js → server-D_6FLygK.js} +6 -2
  34. package/dist/server-D_6FLygK.js.map +1 -0
  35. package/dist/{server-w5ZZRLju.cjs → server-gEOeV_Nv.cjs} +6 -2
  36. package/dist/server-gEOeV_Nv.cjs.map +1 -0
  37. package/package.json +10 -1
  38. package/dist/server-BZJCw2HU.js.map +0 -1
  39. 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
- /** Sync every collection that declares `googleCalendar`. Failures are isolated
61
- * per calendar — one unreachable calendar (or a revoked grant) must not stop
62
- * the others. */
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?: {
@@ -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 (result.nextSyncToken && failed.length === 0) await saveCalendarSyncToken(calendarId, result.nextSyncToken, workspaceRoot);
910
- else if (failed.length > 0) log.warn("google", "holding back calendar sync token after failed writes", {
911
- calendarId,
912
- failed: failed.length
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
- /** Sync every collection that declares `googleCalendar`. Failures are isolated
949
- * per calendar — one unreachable calendar (or a revoked grant) must not stop
950
- * the others. */
951
- async function syncDueCalendarCollections(workspaceRoot) {
952
- const declaring = (await require_discovery.discoverCollections({ workspaceRoot })).filter((collection) => collection.schema.googleCalendar);
953
- if (declaring.length === 0) return [];
954
- if (!await isGoogleLinked()) {
955
- log.info("google", "skipping calendar sync — no Google account linked on this host", { collections: declaring.length });
956
- return [];
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 groupByCalendar(declaring)) try {
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
- var tasksUrl = (taskListId, suffix = "") => `${TASKS_BASE_URL}/lists/${encodeURIComponent(taskListId ?? DEFAULT_TASK_LIST_ID)}/tasks${suffix}`;
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