@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.
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # @mulmoclaude/core
2
+
3
+ Shared server-side core for **MulmoClaude** and **MulmoTerminal** — the
4
+ subsystems the two hosts always ship together, consolidated behind subpath
5
+ exports so they cannot drift apart.
6
+
7
+ Not a general-purpose library: it exists so one implementation of collections,
8
+ wiki, feeds, Google integration, scheduling and the rest serves both hosts.
9
+ Every host specific (paths, logging, notification transport, …) is injected
10
+ rather than imported, which is what lets the same code run under either.
11
+
12
+ ## Subpath exports
13
+
14
+ | Area | Entries |
15
+ | --- | --- |
16
+ | Collections | `./collection`, `./collection/server`, `./collection/paths`, `./collection/registry`, `./collection/registry/server`, `./collection-watchers` |
17
+ | Knowledge | `./wiki`, `./wiki/server`, `./wiki/paths`, `./feeds`, `./feeds/server`, `./feeds/paths` |
18
+ | Google | `./google` — OAuth (loopback + PKCE), token store, Calendar / Tasks / Drive REST |
19
+ | Runtime | `./scheduler`, `./notifier`, `./skill-bridge`, `./file-change`, `./workspace-setup`, `./artifacts` |
20
+ | Remote | `./remote-host`, `./remote-host/server`, `./remote-view` |
21
+ | Voice | `./whisper`, `./whisper/client` |
22
+ | Plugin support | `./plugin-vue`, `./plugin-vue/i18n` |
23
+ | Utilities | `./utils`, `./files`, `./fetch` |
24
+
25
+ **Server-only**, except the browser-safe entries: `./artifacts`,
26
+ `./whisper/client`, `./workspace-setup/slug`, `./translation/client`,
27
+ `./remote-view`, `./remote-host` and `./plugin-vue`.
28
+
29
+ The package also ships `assets/helps/*` — the help documents the agent reads at
30
+ runtime — which is why a change there alone still warrants a release.
31
+
32
+ ## About MulmoClaude and MulmoTerminal
33
+
34
+ - **[MulmoClaude](https://github.com/receptron/mulmoclaude)** — an AI-native
35
+ application platform built on Claude Code. Chat summons the right GUI for each
36
+ task (documents, charts, forms, wikis, spreadsheets, 3D scenes), and
37
+ everything your assistant accumulates stays as plain files in your own
38
+ workspace, on your own machine.
39
+ - **[MulmoTerminal](https://github.com/receptron/mulmoterminal)** — run a whole
40
+ team of coding agents from your browser. Many Claude Code / Codex sessions at
41
+ once in a grid, colour-coded so you see which are working, which need you and
42
+ which are done, plus git worktrees, one-click PRs and cost readouts. One `npx`
43
+ command, no Electron, no config.
44
+
45
+ 📖 **User guide**: <https://receptron.github.io/mulmoterminal/> (日本語 / English)
46
+
47
+ ## License
48
+
49
+ MIT
@@ -5,8 +5,10 @@ Add a `googleCalendar` block to its `schema.json` and the host pulls changed
5
5
  events on a schedule and writes them as records — **without calling you**. No
6
6
  tool call, no tokens spent per sync, so hourly syncing is free.
7
7
 
8
- This is the only calendar mechanism: there is no calendar tool and no bundled
9
- calendar collection. You author the schema when the user asks for one.
8
+ This is the mechanism for *keeping a collection fresh*. For one-off reads and
9
+ for creating / editing / deleting events, use the `google` tool — see
10
+ [The `google` tool](google.md). There is no bundled calendar collection: you
11
+ author the schema when the user asks for one.
10
12
 
11
13
  ## Requirements
12
14
 
@@ -57,6 +59,15 @@ Declaring it in `map` is a schema error.
57
59
  Use `datetime` (not `date`) for start/end when events have real clock times —
58
60
  the calendar day view then draws each record as a proportional time block.
59
61
 
62
+ ## When sync runs
63
+
64
+ - **On creation** — the first sync starts as soon as the schema lands, so the
65
+ collection is not empty while the user waits for the schedule. The same
66
+ applies when you add a `googleCalendar` block to an existing collection.
67
+ - **Hourly** after that, in the background.
68
+ - **On demand** — the collection view has a Sync button. Tell the user about it
69
+ if they want the calendar refreshed right now.
70
+
60
71
  ## What sync does
61
72
 
62
73
  - New or edited events are written, keyed by event id (existing records are
@@ -0,0 +1,94 @@
1
+ # The `google` tool — Calendar, Tasks and Drive
2
+
3
+ One tool, one `kind` per operation, against the Google account the user linked
4
+ on this machine. The refresh token is stored locally in `~/.config/mulmo/` and
5
+ goes only to Google, to mint access tokens — never to claude.ai or any other
6
+ service. This works with no claude.ai Google connector involved.
7
+
8
+ Call `kind: "status"` when unsure whether the account is linked. If a call fails
9
+ with "Google account not linked", ask the user to link it in settings and retry
10
+ the original call — do not fall back to guessing.
11
+
12
+ ## Two mechanisms, different jobs
13
+
14
+ | You want | Use |
15
+ | --- | --- |
16
+ | Read or change specific events / tasks / files, right now | this tool |
17
+ | A collection that mirrors a calendar and keeps itself fresh | a `googleCalendar` block — see [Google Calendar sync](google-calendar-collection.md) |
18
+
19
+ The collection route costs no tokens per refresh, so prefer it for anything
20
+ recurring. Use this tool for one-off work and for everything the collection
21
+ route cannot do (creating, editing and deleting).
22
+
23
+ ## Calendar
24
+
25
+ Events default to the user's **primary** calendar. Pass `calendarId` — obtained
26
+ from `calendarListCalendars` — to target another.
27
+
28
+ | kind | What it does |
29
+ | --- | --- |
30
+ | `calendarListCalendars` | The calendars the user has added/subscribed to: `id`, `summary`, `primary`, colours, `accessRole` |
31
+ | `calendarColors` | Palettes mapping a `colorId` to hex, for events and for calendars |
32
+ | `calendarListEvents` | Upcoming events. Optional `calendarId`, `timeMin`, `maxResults` (1-50, default 10) |
33
+ | `calendarSync` | Only what CHANGED since the last sync, via a stored token. Returns counts plus a capped sample |
34
+ | `calendarCreateEvent` | Create. Requires `summary`, `start`, `end`; optional `description`, `calendarId`, `colorId` |
35
+ | `calendarUpdateEvent` | Edit in place. Requires `eventId` + at least one of `summary`, `start`, `end`, `description`, `colorId` |
36
+ | `calendarDeleteEvent` | Delete. Requires `eventId` |
37
+
38
+ **Date-times must carry a timezone offset** — `2026-07-17T09:00:00+09:00`, not
39
+ `2026-07-17` and not `2026-07-17T09:00:00`. Calendar rejects the others with an
40
+ opaque 400.
41
+
42
+ **Editing is a patch.** Fields you omit keep their current value, so changing a
43
+ title needs `eventId` + `summary` and nothing else. `description: ""` clears the
44
+ body. Moving one end of an event still has to leave the start before the end.
45
+
46
+ **`eventId` comes from `calendarListEvents` or `calendarSync`** — never invent
47
+ one. For a recurring event those kinds return per-occurrence ids, so editing or
48
+ deleting one affects that single occurrence.
49
+
50
+ **Deleting is not reversible** and removes the event for every attendee. Confirm
51
+ with the user before calling `calendarDeleteEvent`, and say which event you are
52
+ about to remove.
53
+
54
+ ## Tasks
55
+
56
+ Operate on the user's default task list unless `taskListId` is given.
57
+
58
+ | kind | What it does |
59
+ | --- | --- |
60
+ | `taskListsList` | The user's task lists (`id`, `title`). Only needed for a non-default list |
61
+ | `tasksList` | List tasks. Optional `taskListId`, `maxResults` (1-50, default 10), `showCompleted` (default false) |
62
+ | `tasksCreate` | Add a task. Requires `title`; optional `notes`, `due`, `taskListId` |
63
+ | `tasksUpdate` | Edit. Requires `taskId` + at least one of `title`, `notes`, `due`. `notes: ""` clears them; `due` can be changed but **not** cleared |
64
+ | `tasksComplete` | Mark done. Requires `taskId` |
65
+ | `tasksDelete` | Delete. Requires `taskId` — not reversible, so confirm first |
66
+
67
+ `tasksUpdate` does not change status: `tasksComplete` owns that transition.
68
+
69
+ **Google stores a DATE only for `due`** — the time part is accepted and then
70
+ ignored, so never promise the user a time of day on a task.
71
+
72
+ ## Drive
73
+
74
+ In practice this app sees **only the files it created itself**, never the user's
75
+ wider Drive. Never claim to have searched their Drive.
76
+
77
+ (The `drive.file` scope also covers files the user hands to an app through a
78
+ Google Picker, but this app has no Picker — so app-created is the whole set.)
79
+
80
+ | kind | What it does |
81
+ | --- | --- |
82
+ | `driveList` | Files this app created. Optional `maxResults` (1-50, default 10) |
83
+ | `driveCreate` | Create a text file. Requires `name`, `content`; optional `mimeType` (default `text/plain`) |
84
+ | `driveRead` | Read one of this app's files. Requires `fileId`. Text only |
85
+
86
+ ## Failure modes
87
+
88
+ - **"Google account not linked"** — the user has not linked, or the token was
89
+ revoked. Ask them to link in settings; do not retry blindly.
90
+ - **HTTP 403 naming an API** — that API is not enabled for the user's Cloud
91
+ project. The message says which one.
92
+ - **HTTP 400 on a create/update** — almost always a date-time without an offset,
93
+ or a start that is not before the end.
94
+ - **HTTP 410 on delete** — the event was already gone.
@@ -72,6 +72,7 @@ See [Wiki](config/helps/wiki.md) for details on how it works.
72
72
  - [Remote host](config/helps/remote-host.md) — drive MulmoClaude from a phone at mulmoserver.web.app: Google sign-in connect, host online vs. offline (queued chats, 7-day expiry), photo attachments, and the security model
73
73
  - [Feeds](config/helps/feeds.md) — register a self-refreshing data feed (RSS/Atom/JSON) by authoring `feeds/<slug>/schema.json`: schema shape, the `ingest` block, raw-item field mapping, and `maxItems` retention
74
74
  - [Google Calendar sync](config/helps/google-calendar-collection.md) — mirror a Google calendar into a collection with a `googleCalendar` block: field mapping, why the primary field holds the event id, and how deletions propagate
75
+ - [The `google` tool](config/helps/google.md) — every `kind` for Calendar, Tasks and Drive: reading, creating, editing and deleting events and tasks, the timezone-offset rule, patch semantics, and what `drive.file` scope cannot see
75
76
  - [GitHub repositories in the workspace](config/helps/github.md) — clone-destination rules under `github/<name>/` and how to handle existing directories with matching or different remotes
76
77
  - [Collection skills](config/helps/collection-skills.md) — build a data app (model + UI + relations + computed fields + action buttons) by authoring a `schema.json` collection skill: the DSL, field types, derived formulas, actions, records
77
78
  - [Custom views](config/helps/custom-view.md) — give a collection a view the built-ins don't cover (year/quarter overview, Gantt): an HTML file under `views/`, registered in `schema.json`, rendered in a sandboxed iframe over the records
@@ -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,7 +53,14 @@ 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[]>;
51
65
  /** Liveness of the collections a sync just wrote to, checked against the skill
52
66
  * dir `deleteCollection` removes. `exists` is injected so the rule is testable
@@ -87,10 +101,50 @@ export declare function orphanedCalendarId(deleted: CalendarDeclaring, remaining
87
101
  * Returns the cleared calendar id, or null when nothing was cleared. Never
88
102
  * throws: a failed cleanup must not fail the delete it follows. */
89
103
  export declare function releaseOrphanedCalendarToken(deleted: CalendarDeclaring, workspaceRoot: string): Promise<string | null>;
90
- /** Sync every collection that declares `googleCalendar`. Failures are isolated
91
- * per calendar — one unreachable calendar (or a revoked grant) must not stop
92
- * the others. */
104
+ /** Sync every collection that declares `googleCalendar`. */
93
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>;
94
148
  /** Scheduler registration, shaped like `feedRefreshTaskDef` so hosts wire it
95
149
  * with a single line. */
96
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,
@@ -1014,18 +1072,27 @@ async function releaseOrphanedCalendarToken(deleted, workspaceRoot) {
1014
1072
  return null;
1015
1073
  }
1016
1074
  }
1017
- /** Sync every collection that declares `googleCalendar`. Failures are isolated
1018
- * per calendar — one unreachable calendar (or a revoked grant) must not stop
1019
- * the others. */
1020
- async function syncDueCalendarCollections(workspaceRoot) {
1021
- const declaring = (await require_discovery.discoverCollections({ workspaceRoot })).filter((collection) => collection.schema.googleCalendar);
1022
- if (declaring.length === 0) return [];
1023
- if (!await isGoogleLinked()) {
1024
- log.info("google", "skipping calendar sync — no Google account linked on this host", { collections: declaring.length });
1025
- return [];
1026
- }
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) {
1027
1094
  const results = [];
1028
- for (const [calendarId, collections] of groupByCalendar(declaring)) try {
1095
+ for (const [calendarId, collections] of groups) try {
1029
1096
  results.push(...await syncCalendarGroup(calendarId, collections, workspaceRoot));
1030
1097
  } catch (error) {
1031
1098
  log.warn("google", "calendar sync failed", {
@@ -1042,6 +1109,55 @@ async function syncDueCalendarCollections(workspaceRoot) {
1042
1109
  }
1043
1110
  return results;
1044
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
+ }
1045
1161
  /** Scheduler registration, shaped like `feedRefreshTaskDef` so hosts wire it
1046
1162
  * with a single line. */
1047
1163
  function googleCalendarSyncTaskDef(opts) {
@@ -1063,6 +1179,7 @@ var TASKS_BASE_URL = "https://tasks.googleapis.com/tasks/v1";
1063
1179
  var TASKS_API_LABEL = "Google Tasks API";
1064
1180
  var DEFAULT_TASK_LIST_ID = "@default";
1065
1181
  var TASK_STATUS_COMPLETED = "completed";
1182
+ var TASK_STATUS_NEEDS_ACTION = "needsAction";
1066
1183
  var MAX_TASK_LISTS = 50;
1067
1184
  var toTaskListSummary = (value) => {
1068
1185
  const record = asRecord(value);
@@ -1081,7 +1198,12 @@ var toTaskSummary = (value) => {
1081
1198
  notes: stringField(record, "notes")
1082
1199
  };
1083
1200
  };
1084
- var tasksUrl = (taskListId, suffix = "") => `${TASKS_BASE_URL}/lists/${encodeURIComponent(taskListId ?? DEFAULT_TASK_LIST_ID)}/tasks${suffix}`;
1201
+ /** Resolve a declared taskListId to the one the API addresses. `||` (not `??`)
1202
+ * so a blank string also falls back instead of building a malformed
1203
+ * `/lists//tasks` URL — the same rule, and the same reason, as
1204
+ * `canonicalCalendarId`. */
1205
+ var canonicalTaskListId = (taskListId) => taskListId?.trim() || DEFAULT_TASK_LIST_ID;
1206
+ var tasksUrl = (taskListId, suffix = "") => `${TASKS_BASE_URL}/lists/${encodeURIComponent(canonicalTaskListId(taskListId))}/tasks${suffix}`;
1085
1207
  async function listTaskLists(accessToken) {
1086
1208
  return itemsOf(await googleRequest(TASKS_API_LABEL, accessToken, `${TASKS_BASE_URL}/users/@me/lists?maxResults=${MAX_TASK_LISTS}`)).map(toTaskListSummary);
1087
1209
  }
@@ -1103,12 +1225,47 @@ async function createTask(accessToken, input) {
1103
1225
  body: JSON.stringify(body)
1104
1226
  }));
1105
1227
  }
1228
+ /** PATCH body for a task edit — only the fields the caller supplied. As with
1229
+ * events, `undefined` means "leave as is" and `""` means "clear it", so the
1230
+ * two must stay distinct. `status` is deliberately absent: `completeTask` /
1231
+ * `uncompleteTask` own that transition, and two ways to set it would drift
1232
+ * apart. */
1233
+ var buildTaskPatch = (input) => ({
1234
+ ...input.title !== void 0 ? { title: input.title } : {},
1235
+ ...input.notes !== void 0 ? { notes: input.notes } : {},
1236
+ ...input.due !== void 0 ? { due: input.due } : {}
1237
+ });
1238
+ async function updateTask(accessToken, input) {
1239
+ return toTaskSummary(await googleRequest(TASKS_API_LABEL, accessToken, tasksUrl(input.taskListId, `/${encodeURIComponent(input.taskId)}`), {
1240
+ method: "PATCH",
1241
+ body: JSON.stringify(buildTaskPatch(input))
1242
+ }));
1243
+ }
1106
1244
  async function completeTask(accessToken, input) {
1107
1245
  return toTaskSummary(await googleRequest(TASKS_API_LABEL, accessToken, tasksUrl(input.taskListId, `/${encodeURIComponent(input.taskId)}`), {
1108
1246
  method: "PATCH",
1109
1247
  body: JSON.stringify({ status: TASK_STATUS_COMPLETED })
1110
1248
  }));
1111
1249
  }
1250
+ /** Send a completed task back to the to-do list.
1251
+ *
1252
+ * Its own function rather than a flag on `completeTask`, and deliberately not
1253
+ * a `status` field on `updateTask`: one kind per target state keeps the name
1254
+ * honest and leaves exactly one code path setting each value.
1255
+ *
1256
+ * The patch carries `status` alone — mirroring `completeTask`, which also
1257
+ * sets only `status` and lets Google fill in the `completed` timestamp. Note
1258
+ * that whether Google *clears* that timestamp on the way back is its
1259
+ * behaviour, not ours, and is unverified here: `TaskSummary` doesn't carry
1260
+ * `completed`, so nothing in this codebase would show a stale one. If a
1261
+ * reopened task ever displays a completion date in Google's own UI, this is
1262
+ * the place to add `completed: null` to the patch. */
1263
+ async function uncompleteTask(accessToken, input) {
1264
+ return toTaskSummary(await googleRequest(TASKS_API_LABEL, accessToken, tasksUrl(input.taskListId, `/${encodeURIComponent(input.taskId)}`), {
1265
+ method: "PATCH",
1266
+ body: JSON.stringify({ status: TASK_STATUS_NEEDS_ACTION })
1267
+ }));
1268
+ }
1112
1269
  async function deleteTask(accessToken, input) {
1113
1270
  await googleRequest(TASKS_API_LABEL, accessToken, tasksUrl(input.taskListId, `/${encodeURIComponent(input.taskId)}`), { method: "DELETE" });
1114
1271
  }
@@ -1223,10 +1380,13 @@ exports.brokerBaseUrl = brokerBaseUrl;
1223
1380
  exports.brokerExchange = brokerExchange;
1224
1381
  exports.brokerRefresh = brokerRefresh;
1225
1382
  exports.brokerStart = brokerStart;
1383
+ exports.buildEventPatch = buildEventPatch;
1226
1384
  exports.buildMultipartBody = buildMultipartBody;
1385
+ exports.buildTaskPatch = buildTaskPatch;
1227
1386
  exports.calendarApiError = calendarApiError;
1228
1387
  exports.calendarSyncStatePath = calendarSyncStatePath;
1229
1388
  exports.canonicalCalendarId = canonicalCalendarId;
1389
+ exports.canonicalTaskListId = canonicalTaskListId;
1230
1390
  exports.classifyDelete = classifyDelete;
1231
1391
  exports.classifyWrite = classifyWrite;
1232
1392
  exports.clearCalendarSyncToken = clearCalendarSyncToken;
@@ -1239,6 +1399,7 @@ exports.createCalendarEvent = createCalendarEvent;
1239
1399
  exports.createDriveFile = createDriveFile;
1240
1400
  exports.createGoogleAuthFlow = createGoogleAuthFlow;
1241
1401
  exports.createTask = createTask;
1402
+ exports.deleteCalendarEvent = deleteCalendarEvent;
1242
1403
  exports.deleteDriveFile = deleteDriveFile;
1243
1404
  exports.deleteGoogleTokens = deleteGoogleTokens;
1244
1405
  exports.deleteTask = deleteTask;
@@ -1272,8 +1433,10 @@ exports.releaseOrphanedCalendarToken = releaseOrphanedCalendarToken;
1272
1433
  exports.saveCalendarSyncToken = saveCalendarSyncToken;
1273
1434
  exports.saveGoogleTokens = saveGoogleTokens;
1274
1435
  exports.syncCalendarEvents = syncCalendarEvents;
1436
+ exports.syncCalendarForCollection = syncCalendarForCollection;
1275
1437
  exports.syncCalendarGroup = syncCalendarGroup;
1276
1438
  exports.syncDueCalendarCollections = syncDueCalendarCollections;
1439
+ exports.syncNewCalendarCollections = syncNewCalendarCollections;
1277
1440
  exports.toCalendarSummary = toCalendarSummary;
1278
1441
  exports.toCollectionDateTime = toCollectionDateTime;
1279
1442
  exports.toCollectionRecord = toCollectionRecord;
@@ -1281,7 +1444,12 @@ exports.toDriveFileSummary = toDriveFileSummary;
1281
1444
  exports.toEventSummary = toEventSummary;
1282
1445
  exports.toTaskListSummary = toTaskListSummary;
1283
1446
  exports.toTaskSummary = toTaskSummary;
1447
+ exports.uncompleteTask = uncompleteTask;
1284
1448
  exports.unlinkGoogle = unlinkGoogle;
1449
+ exports.unsyncedGroups = unsyncedGroups;
1450
+ exports.updateCalendarEvent = updateCalendarEvent;
1451
+ exports.updateTask = updateTask;
1285
1452
  exports.waitForAuthCode = waitForAuthCode;
1453
+ exports.withKeyedLock = withKeyedLock;
1286
1454
 
1287
1455
  //# sourceMappingURL=index.cjs.map