@mulmoclaude/core 1.3.0 → 1.5.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.
@@ -7,9 +7,9 @@ export { brokerBaseUrl, brokerExchange, brokerRefresh, brokerStart, type BrokerS
7
7
  export { authorizeGoogle, commitLinkedTokens, getGoogleAccessToken, unlinkGoogle, waitForAuthCode, GOOGLE_AUTH_CANCELLED, GOOGLE_CALENDAR_SCOPE, GOOGLE_CALENDARLIST_SCOPE, GOOGLE_TASKS_SCOPE, GOOGLE_DRIVE_FILE_SCOPE, GOOGLE_SCOPES, type AuthorizeGoogleOptions, type RevokeFetch, } from './auth.js';
8
8
  export { createGoogleAuthFlow, googleAuthFlow, type GoogleAuthFlow, type GoogleAuthFlowStatus } from './authFlow.js';
9
9
  export { googleApiError, isGoogleApiError, GoogleApiError, DEFAULT_LIST_MAX_RESULTS, MAX_LIST_RESULTS } from './apiClient.js';
10
- export { calendarApiError, collectCalendarPages, createCalendarEvent, getCalendarColors, canonicalCalendarId, listCalendarEvents, listCalendars, syncCalendarEvents, toCalendarSummary, toEventSummary, type CalendarColorEntry, type CalendarColors, type CalendarEventInput, type CalendarEventSummary, type CalendarListPage, type CalendarSummary, type CalendarSyncResult, type ListEventsInput, type SyncEventsInput, } from './calendar.js';
10
+ export { buildEventPatch, calendarApiError, collectCalendarPages, createCalendarEvent, deleteCalendarEvent, getCalendarColors, canonicalCalendarId, listCalendarEvents, listCalendars, syncCalendarEvents, toCalendarSummary, toEventSummary, updateCalendarEvent, type CalendarColorEntry, type CalendarColors, type CalendarEventInput, type CalendarEventSummary, type CalendarListPage, type CalendarSummary, type CalendarSyncResult, type DeleteCalendarEventInput, type ListEventsInput, type SyncEventsInput, type UpdateCalendarEventInput, } from './calendar.js';
11
11
  export { calendarSyncStatePath, clearCalendarSyncToken, loadCalendarSyncToken, saveCalendarSyncToken } from './calendarSyncStore.js';
12
12
  export { toCollectionDateTime } from './collectionDateTime.js';
13
- export { googleCalendarSyncTaskDef, classifyDelete, classifyWrite, anySyncedCollectionSurvives, groupByCalendar, orphanedCalendarId, releaseOrphanedCalendarToken, syncCalendarGroup, syncDueCalendarCollections, toCollectionRecord, GOOGLE_CALENDAR_SYNC_TASK_ID, type CalendarCollectionSyncResult, type CalendarDeclaring, } from './collectionSync.js';
14
- export { completeTask, createTask, deleteTask, listTaskLists, listTasks, toTaskListSummary, toTaskSummary, type CompleteTaskInput, type CreateTaskInput, type ListTasksInput, type TaskListSummary, type TaskSummary, } from './tasks.js';
13
+ export { googleCalendarSyncTaskDef, classifyDelete, classifyWrite, anySyncedCollectionSurvives, groupByCalendar, orphanedCalendarId, releaseOrphanedCalendarToken, syncCalendarForCollection, syncCalendarGroup, syncDueCalendarCollections, syncNewCalendarCollections, toCollectionRecord, unsyncedGroups, withKeyedLock, GOOGLE_CALENDAR_SYNC_TASK_ID, type CalendarCollectionSyncResult, type CalendarDeclaring, type ManualCalendarSyncDeps, type ManualCalendarSyncOutcome, } from './collectionSync.js';
14
+ export { buildTaskPatch, canonicalTaskListId, completeTask, createTask, deleteTask, listTaskLists, listTasks, toTaskListSummary, toTaskSummary, uncompleteTask, updateTask, type CompleteTaskInput, type CreateTaskInput, type ListTasksInput, type TaskListSummary, type TaskSummary, type UpdateTaskInput, } from './tasks.js';
15
15
  export { assertSafeMimeType, buildMultipartBody, createDriveFile, deleteDriveFile, isTextMimeType, listDriveFiles, pickBoundary, readDriveFile, toDriveFileSummary, type CreateDriveFileInput, type DriveFileSummary, type ListDriveFilesInput, type ReadDriveFileInput, } from './driveFile.js';
@@ -651,6 +651,34 @@ async function createCalendarEvent(accessToken, input) {
651
651
  body: JSON.stringify(body)
652
652
  }));
653
653
  }
654
+ var eventUrl = (calendarId, eventId) => `${eventsUrl(calendarId)}/${encodeURIComponent(eventId)}`;
655
+ /** PATCH body for an event edit — only the fields the caller actually supplied.
656
+ *
657
+ * `undefined` means "leave as is" and `""` means "clear it", so the two cannot
658
+ * be collapsed: dropping `description: ""` would silently ignore a request to
659
+ * empty the body text. Pure so the distinction is testable without network. */
660
+ var buildEventPatch = (input) => ({
661
+ ...input.summary !== void 0 ? { summary: input.summary } : {},
662
+ ...input.description !== void 0 ? { description: input.description } : {},
663
+ ...input.startDateTime !== void 0 ? { start: { dateTime: input.startDateTime } } : {},
664
+ ...input.endDateTime !== void 0 ? { end: { dateTime: input.endDateTime } } : {},
665
+ ...input.colorId ? { colorId: input.colorId } : {}
666
+ });
667
+ /** Edit an existing event in place. PATCH, not PUT — a PUT would need the whole
668
+ * event and would drop every field the caller never read (attendees,
669
+ * reminders, recurrence). */
670
+ async function updateCalendarEvent(accessToken, input) {
671
+ return toEventSummary(await googleRequest(CALENDAR_API_LABEL, accessToken, eventUrl(input.calendarId, input.eventId), {
672
+ method: "PATCH",
673
+ body: JSON.stringify(buildEventPatch(input))
674
+ }));
675
+ }
676
+ /** Remove an event. Google answers 204 with no body, so there is nothing to
677
+ * return; a second delete of the same id answers 410, which surfaces as a
678
+ * GoogleApiError rather than being swallowed. */
679
+ async function deleteCalendarEvent(accessToken, input) {
680
+ await googleRequest(CALENDAR_API_LABEL, accessToken, eventUrl(input.calendarId, input.eventId), { method: "DELETE" });
681
+ }
654
682
  async function listCalendarEvents(accessToken, input = {}) {
655
683
  const params = new URLSearchParams({
656
684
  timeMin: input.timeMin ?? (/* @__PURE__ */ new Date()).toISOString(),
@@ -879,6 +907,26 @@ async function restartFullSync(accessToken, calendarId, workspaceRoot) {
879
907
  await clearCalendarSyncToken(calendarId, workspaceRoot);
880
908
  return await syncCalendarEvents(accessToken, { calendarId });
881
909
  }
910
+ /** Serialise `run` against whatever is already running for `key`.
911
+ *
912
+ * `locks` is passed in so the queuing rule is testable without module state;
913
+ * the key is dropped once nothing is queued behind it, so the map cannot grow
914
+ * an entry per calendar forever. A failed predecessor still releases the
915
+ * queue — `then(run, run)`. */
916
+ async function withKeyedLock(locks, key, run) {
917
+ const result = (locks.get(key) ?? Promise.resolve()).then(run, run);
918
+ const tail = result.then(() => void 0, () => void 0);
919
+ locks.set(key, tail);
920
+ try {
921
+ return await result;
922
+ } finally {
923
+ if (locks.get(key) === tail) locks.delete(key);
924
+ }
925
+ }
926
+ /** In-flight sync per canonical calendar id. Module state on purpose: the
927
+ * scheduler, the create trigger and the Refresh button are three doors into
928
+ * the same calendar (CodeRabbit review #2566). */
929
+ var calendarLocks = /* @__PURE__ */ new Map();
882
930
  /** Sync ONE calendar and fan its events out to every collection bound to it.
883
931
  *
884
932
  * The fan-out is not an optimisation, it is correctness: the sync token is
@@ -886,8 +934,18 @@ async function restartFullSync(accessToken, calendarId, workspaceRoot) {
886
934
  * first collection advance the shared token and leave every later collection
887
935
  * on the same calendar reading an already-consumed window — silently missing
888
936
  * those events forever. Fetch once, apply to all, then advance the token.
889
- * (Codex + CodeRabbit review on #2184.) */
937
+ * (Codex + CodeRabbit review on #2184.)
938
+ *
939
+ * Queued per calendar for the same reason the fan-out exists: two passes over
940
+ * one calendar (a Refresh click landing during the scheduled run) would each
941
+ * load the SAME stored token and walk the same window. That is idempotent —
942
+ * writes are upserts by event id — but it is a wasted full walk. Queued, the
943
+ * second pass resumes from the token the first just stored and fetches only
944
+ * what is genuinely new. */
890
945
  async function syncCalendarGroup(calendarId, collections, workspaceRoot) {
946
+ return await withKeyedLock(calendarLocks, canonicalCalendarId(calendarId), () => syncCalendarGroupNow(calendarId, collections, workspaceRoot));
947
+ }
948
+ async function syncCalendarGroupNow(calendarId, collections, workspaceRoot) {
891
949
  const accessToken = await getGoogleAccessToken();
892
950
  const first = await syncCalendarEvents(accessToken, {
893
951
  calendarId,
@@ -1010,18 +1068,27 @@ async function releaseOrphanedCalendarToken(deleted, workspaceRoot) {
1010
1068
  return null;
1011
1069
  }
1012
1070
  }
1013
- /** Sync every collection that declares `googleCalendar`. Failures are isolated
1014
- * per calendar — one unreachable calendar (or a revoked grant) must not stop
1015
- * the others. */
1016
- async function syncDueCalendarCollections(workspaceRoot) {
1017
- const declaring = (await discoverCollections({ workspaceRoot })).filter((collection) => collection.schema.googleCalendar);
1018
- if (declaring.length === 0) return [];
1019
- if (!await isGoogleLinked()) {
1020
- log.info("google", "skipping calendar sync — no Google account linked on this host", { collections: declaring.length });
1021
- return [];
1022
- }
1071
+ /** Every declaring collection, grouped by the calendar it reads. */
1072
+ async function declaringGroups(workspaceRoot) {
1073
+ return groupByCalendar((await discoverCollections({ workspaceRoot })).filter((collection) => collection.schema.googleCalendar));
1074
+ }
1075
+ /** Whether a background sync of these groups may run at all.
1076
+ *
1077
+ * Authoring the collection before linking the account is an expected state,
1078
+ * not a failure. Checking once here keeps it a quiet skip instead of an
1079
+ * access-token throw per calendar, every hour, until the user links (#2188).
1080
+ * A user-triggered sync answers differently — it says so out loud. */
1081
+ async function backgroundSyncAllowed(groups) {
1082
+ if (groups.size === 0) return false;
1083
+ if (await isGoogleLinked()) return true;
1084
+ log.info("google", "skipping calendar sync — no Google account linked on this host", { calendars: groups.size });
1085
+ return false;
1086
+ }
1087
+ /** Run each group, isolating failures per calendar — one unreachable calendar
1088
+ * (or a revoked grant) must not stop the others. */
1089
+ async function runCalendarGroups(groups, workspaceRoot) {
1023
1090
  const results = [];
1024
- for (const [calendarId, collections] of groupByCalendar(declaring)) try {
1091
+ for (const [calendarId, collections] of groups) try {
1025
1092
  results.push(...await syncCalendarGroup(calendarId, collections, workspaceRoot));
1026
1093
  } catch (error) {
1027
1094
  log.warn("google", "calendar sync failed", {
@@ -1038,6 +1105,55 @@ async function syncDueCalendarCollections(workspaceRoot) {
1038
1105
  }
1039
1106
  return results;
1040
1107
  }
1108
+ /** Sync every collection that declares `googleCalendar`. */
1109
+ async function syncDueCalendarCollections(workspaceRoot) {
1110
+ const groups = await declaringGroups(workspaceRoot);
1111
+ return await backgroundSyncAllowed(groups) ? await runCalendarGroups(groups, workspaceRoot) : [];
1112
+ }
1113
+ /** The groups whose calendar has never synced. A missing token IS the "created
1114
+ * since the last sync" signal — nothing else distinguishes a new collection
1115
+ * from an edited one on the write path this feeds (#2427).
1116
+ *
1117
+ * Self-silencing by construction: the first sync stores a token, so a calendar
1118
+ * matches at most once. `loadToken` is injected so the rule is testable without
1119
+ * a workspace on disk. */
1120
+ async function unsyncedGroups(groups, loadToken) {
1121
+ const checked = await Promise.all([...groups].map(async (entry) => await loadToken(entry[0]) === null ? entry : null));
1122
+ return new Map(checked.filter((entry) => entry !== null));
1123
+ }
1124
+ /** Sync only the calendars that have never synced — the first sync for a
1125
+ * just-created collection, which otherwise stays empty until the hourly
1126
+ * scheduler run (#2427). Cheap and safe to call on every config write. */
1127
+ async function syncNewCalendarCollections(workspaceRoot) {
1128
+ const pending = await unsyncedGroups(await declaringGroups(workspaceRoot), (calendarId) => loadCalendarSyncToken(calendarId, workspaceRoot));
1129
+ if (!await backgroundSyncAllowed(pending)) return [];
1130
+ log.info("google", "running the first sync for newly declared calendars", { calendars: [...pending.keys()] });
1131
+ return await runCalendarGroups(pending, workspaceRoot);
1132
+ }
1133
+ var liveManualSyncDeps = {
1134
+ loadGroups: declaringGroups,
1135
+ isLinked: isGoogleLinked,
1136
+ runGroups: runCalendarGroups
1137
+ };
1138
+ /** Sync the calendar ONE collection reads, on demand (the Refresh button).
1139
+ *
1140
+ * Deliberately syncs the whole group, not just `slug`: the sync token is keyed
1141
+ * by calendar, so consuming a window for one collection would leave the others
1142
+ * on that calendar reading an already-consumed one. Returns every result of the
1143
+ * group so the caller can report the requested slug's own counts.
1144
+ *
1145
+ * "Does this collection sync at all" is answered BEFORE "is Google linked":
1146
+ * telling someone to link their account for a collection that never declared a
1147
+ * calendar sends them fixing the wrong thing. */
1148
+ async function syncCalendarForCollection(slug, workspaceRoot, deps = liveManualSyncDeps) {
1149
+ const owning = [...await deps.loadGroups(workspaceRoot)].filter(([, collections]) => collections.some((collection) => collection.slug === slug));
1150
+ if (owning.length === 0) return { kind: "not-a-calendar" };
1151
+ if (!await deps.isLinked()) return { kind: "not-linked" };
1152
+ return {
1153
+ kind: "synced",
1154
+ results: await deps.runGroups(new Map(owning), workspaceRoot)
1155
+ };
1156
+ }
1041
1157
  /** Scheduler registration, shaped like `feedRefreshTaskDef` so hosts wire it
1042
1158
  * with a single line. */
1043
1159
  function googleCalendarSyncTaskDef(opts) {
@@ -1059,6 +1175,7 @@ var TASKS_BASE_URL = "https://tasks.googleapis.com/tasks/v1";
1059
1175
  var TASKS_API_LABEL = "Google Tasks API";
1060
1176
  var DEFAULT_TASK_LIST_ID = "@default";
1061
1177
  var TASK_STATUS_COMPLETED = "completed";
1178
+ var TASK_STATUS_NEEDS_ACTION = "needsAction";
1062
1179
  var MAX_TASK_LISTS = 50;
1063
1180
  var toTaskListSummary = (value) => {
1064
1181
  const record = asRecord(value);
@@ -1077,7 +1194,12 @@ var toTaskSummary = (value) => {
1077
1194
  notes: stringField(record, "notes")
1078
1195
  };
1079
1196
  };
1080
- var tasksUrl = (taskListId, suffix = "") => `${TASKS_BASE_URL}/lists/${encodeURIComponent(taskListId ?? DEFAULT_TASK_LIST_ID)}/tasks${suffix}`;
1197
+ /** Resolve a declared taskListId to the one the API addresses. `||` (not `??`)
1198
+ * so a blank string also falls back instead of building a malformed
1199
+ * `/lists//tasks` URL — the same rule, and the same reason, as
1200
+ * `canonicalCalendarId`. */
1201
+ var canonicalTaskListId = (taskListId) => taskListId?.trim() || DEFAULT_TASK_LIST_ID;
1202
+ var tasksUrl = (taskListId, suffix = "") => `${TASKS_BASE_URL}/lists/${encodeURIComponent(canonicalTaskListId(taskListId))}/tasks${suffix}`;
1081
1203
  async function listTaskLists(accessToken) {
1082
1204
  return itemsOf(await googleRequest(TASKS_API_LABEL, accessToken, `${TASKS_BASE_URL}/users/@me/lists?maxResults=${MAX_TASK_LISTS}`)).map(toTaskListSummary);
1083
1205
  }
@@ -1099,12 +1221,47 @@ async function createTask(accessToken, input) {
1099
1221
  body: JSON.stringify(body)
1100
1222
  }));
1101
1223
  }
1224
+ /** PATCH body for a task edit — only the fields the caller supplied. As with
1225
+ * events, `undefined` means "leave as is" and `""` means "clear it", so the
1226
+ * two must stay distinct. `status` is deliberately absent: `completeTask` /
1227
+ * `uncompleteTask` own that transition, and two ways to set it would drift
1228
+ * apart. */
1229
+ var buildTaskPatch = (input) => ({
1230
+ ...input.title !== void 0 ? { title: input.title } : {},
1231
+ ...input.notes !== void 0 ? { notes: input.notes } : {},
1232
+ ...input.due !== void 0 ? { due: input.due } : {}
1233
+ });
1234
+ async function updateTask(accessToken, input) {
1235
+ return toTaskSummary(await googleRequest(TASKS_API_LABEL, accessToken, tasksUrl(input.taskListId, `/${encodeURIComponent(input.taskId)}`), {
1236
+ method: "PATCH",
1237
+ body: JSON.stringify(buildTaskPatch(input))
1238
+ }));
1239
+ }
1102
1240
  async function completeTask(accessToken, input) {
1103
1241
  return toTaskSummary(await googleRequest(TASKS_API_LABEL, accessToken, tasksUrl(input.taskListId, `/${encodeURIComponent(input.taskId)}`), {
1104
1242
  method: "PATCH",
1105
1243
  body: JSON.stringify({ status: TASK_STATUS_COMPLETED })
1106
1244
  }));
1107
1245
  }
1246
+ /** Send a completed task back to the to-do list.
1247
+ *
1248
+ * Its own function rather than a flag on `completeTask`, and deliberately not
1249
+ * a `status` field on `updateTask`: one kind per target state keeps the name
1250
+ * honest and leaves exactly one code path setting each value.
1251
+ *
1252
+ * The patch carries `status` alone — mirroring `completeTask`, which also
1253
+ * sets only `status` and lets Google fill in the `completed` timestamp. Note
1254
+ * that whether Google *clears* that timestamp on the way back is its
1255
+ * behaviour, not ours, and is unverified here: `TaskSummary` doesn't carry
1256
+ * `completed`, so nothing in this codebase would show a stale one. If a
1257
+ * reopened task ever displays a completion date in Google's own UI, this is
1258
+ * the place to add `completed: null` to the patch. */
1259
+ async function uncompleteTask(accessToken, input) {
1260
+ return toTaskSummary(await googleRequest(TASKS_API_LABEL, accessToken, tasksUrl(input.taskListId, `/${encodeURIComponent(input.taskId)}`), {
1261
+ method: "PATCH",
1262
+ body: JSON.stringify({ status: TASK_STATUS_NEEDS_ACTION })
1263
+ }));
1264
+ }
1108
1265
  async function deleteTask(accessToken, input) {
1109
1266
  await googleRequest(TASKS_API_LABEL, accessToken, tasksUrl(input.taskListId, `/${encodeURIComponent(input.taskId)}`), { method: "DELETE" });
1110
1267
  }
@@ -1202,6 +1359,6 @@ async function deleteDriveFile(accessToken, input) {
1202
1359
  await googleRequest(DRIVE_API_LABEL, accessToken, `${DRIVE_FILES_URL}/${encodeURIComponent(input.fileId)}`, { method: "DELETE" });
1203
1360
  }
1204
1361
  //#endregion
1205
- export { 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, MAX_LIST_RESULTS, anySyncedCollectionSurvives, assertSafeMimeType, authorizeGoogle, brokerBaseUrl, brokerExchange, brokerRefresh, brokerStart, buildMultipartBody, calendarApiError, calendarSyncStatePath, canonicalCalendarId, classifyDelete, classifyWrite, clearCalendarSyncToken, clientSecretPresence, collectCalendarPages, commitLinkedTokens, completeTask, configureGoogleHost, createCalendarEvent, createDriveFile, createGoogleAuthFlow, createTask, deleteDriveFile, deleteGoogleTokens, deleteTask, findClientSecretPath, getCalendarColors, getGoogleAccessToken, googleApiError, googleAuthFlow, googleCalendarSyncTaskDef, googleConfigDir, googleSecretsDir, googleTokenPath, groupByCalendar, isGoogleApiError, isIsoDateTimeWithOffset, isTextMimeType, legacyGoogleTokenPath, listCalendarEvents, listCalendars, listDriveFiles, listTaskLists, listTasks, loadCalendarSyncToken, loadClientSecret, loadGoogleTokens, mergeGoogleTokens, orphanedCalendarId, pickBoundary, readDriveFile, releaseOrphanedCalendarToken, saveCalendarSyncToken, saveGoogleTokens, syncCalendarEvents, syncCalendarGroup, syncDueCalendarCollections, toCalendarSummary, toCollectionDateTime, toCollectionRecord, toDriveFileSummary, toEventSummary, toTaskListSummary, toTaskSummary, unlinkGoogle, waitForAuthCode };
1362
+ export { 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, MAX_LIST_RESULTS, anySyncedCollectionSurvives, assertSafeMimeType, authorizeGoogle, brokerBaseUrl, brokerExchange, brokerRefresh, brokerStart, buildEventPatch, buildMultipartBody, buildTaskPatch, calendarApiError, calendarSyncStatePath, canonicalCalendarId, canonicalTaskListId, classifyDelete, classifyWrite, clearCalendarSyncToken, clientSecretPresence, collectCalendarPages, commitLinkedTokens, completeTask, configureGoogleHost, createCalendarEvent, createDriveFile, createGoogleAuthFlow, createTask, deleteCalendarEvent, deleteDriveFile, deleteGoogleTokens, deleteTask, findClientSecretPath, getCalendarColors, getGoogleAccessToken, googleApiError, googleAuthFlow, googleCalendarSyncTaskDef, googleConfigDir, googleSecretsDir, googleTokenPath, groupByCalendar, isGoogleApiError, isIsoDateTimeWithOffset, isTextMimeType, legacyGoogleTokenPath, listCalendarEvents, listCalendars, listDriveFiles, listTaskLists, listTasks, loadCalendarSyncToken, loadClientSecret, loadGoogleTokens, mergeGoogleTokens, orphanedCalendarId, pickBoundary, readDriveFile, releaseOrphanedCalendarToken, saveCalendarSyncToken, saveGoogleTokens, syncCalendarEvents, syncCalendarForCollection, syncCalendarGroup, syncDueCalendarCollections, syncNewCalendarCollections, toCalendarSummary, toCollectionDateTime, toCollectionRecord, toDriveFileSummary, toEventSummary, toTaskListSummary, toTaskSummary, uncompleteTask, unlinkGoogle, unsyncedGroups, updateCalendarEvent, updateTask, waitForAuthCode, withKeyedLock };
1206
1363
 
1207
1364
  //# sourceMappingURL=index.js.map