@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 +49 -0
- package/assets/helps/google-calendar-collection.md +13 -2
- package/assets/helps/google.md +94 -0
- package/assets/helps/index.md +1 -0
- package/dist/google/calendar.d.ts +28 -0
- package/dist/google/collectionSync.d.ts +58 -4
- package/dist/google/index.cjs +181 -13
- package/dist/google/index.cjs.map +1 -1
- package/dist/google/index.d.ts +3 -3
- package/dist/google/index.js +171 -14
- package/dist/google/index.js.map +1 -1
- package/dist/google/tasks.d.ts +36 -0
- package/package.json +23 -6
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
|
|
9
|
-
|
|
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.
|
package/assets/helps/index.md
CHANGED
|
@@ -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`.
|
|
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?: {
|
package/dist/google/index.cjs
CHANGED
|
@@ -655,6 +655,34 @@ async function createCalendarEvent(accessToken, input) {
|
|
|
655
655
|
body: JSON.stringify(body)
|
|
656
656
|
}));
|
|
657
657
|
}
|
|
658
|
+
var eventUrl = (calendarId, eventId) => `${eventsUrl(calendarId)}/${encodeURIComponent(eventId)}`;
|
|
659
|
+
/** PATCH body for an event edit — only the fields the caller actually supplied.
|
|
660
|
+
*
|
|
661
|
+
* `undefined` means "leave as is" and `""` means "clear it", so the two cannot
|
|
662
|
+
* be collapsed: dropping `description: ""` would silently ignore a request to
|
|
663
|
+
* empty the body text. Pure so the distinction is testable without network. */
|
|
664
|
+
var buildEventPatch = (input) => ({
|
|
665
|
+
...input.summary !== void 0 ? { summary: input.summary } : {},
|
|
666
|
+
...input.description !== void 0 ? { description: input.description } : {},
|
|
667
|
+
...input.startDateTime !== void 0 ? { start: { dateTime: input.startDateTime } } : {},
|
|
668
|
+
...input.endDateTime !== void 0 ? { end: { dateTime: input.endDateTime } } : {},
|
|
669
|
+
...input.colorId ? { colorId: input.colorId } : {}
|
|
670
|
+
});
|
|
671
|
+
/** Edit an existing event in place. PATCH, not PUT — a PUT would need the whole
|
|
672
|
+
* event and would drop every field the caller never read (attendees,
|
|
673
|
+
* reminders, recurrence). */
|
|
674
|
+
async function updateCalendarEvent(accessToken, input) {
|
|
675
|
+
return toEventSummary(await googleRequest(CALENDAR_API_LABEL, accessToken, eventUrl(input.calendarId, input.eventId), {
|
|
676
|
+
method: "PATCH",
|
|
677
|
+
body: JSON.stringify(buildEventPatch(input))
|
|
678
|
+
}));
|
|
679
|
+
}
|
|
680
|
+
/** Remove an event. Google answers 204 with no body, so there is nothing to
|
|
681
|
+
* return; a second delete of the same id answers 410, which surfaces as a
|
|
682
|
+
* GoogleApiError rather than being swallowed. */
|
|
683
|
+
async function deleteCalendarEvent(accessToken, input) {
|
|
684
|
+
await googleRequest(CALENDAR_API_LABEL, accessToken, eventUrl(input.calendarId, input.eventId), { method: "DELETE" });
|
|
685
|
+
}
|
|
658
686
|
async function listCalendarEvents(accessToken, input = {}) {
|
|
659
687
|
const params = new URLSearchParams({
|
|
660
688
|
timeMin: input.timeMin ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -883,6 +911,26 @@ async function restartFullSync(accessToken, calendarId, workspaceRoot) {
|
|
|
883
911
|
await clearCalendarSyncToken(calendarId, workspaceRoot);
|
|
884
912
|
return await syncCalendarEvents(accessToken, { calendarId });
|
|
885
913
|
}
|
|
914
|
+
/** Serialise `run` against whatever is already running for `key`.
|
|
915
|
+
*
|
|
916
|
+
* `locks` is passed in so the queuing rule is testable without module state;
|
|
917
|
+
* the key is dropped once nothing is queued behind it, so the map cannot grow
|
|
918
|
+
* an entry per calendar forever. A failed predecessor still releases the
|
|
919
|
+
* queue — `then(run, run)`. */
|
|
920
|
+
async function withKeyedLock(locks, key, run) {
|
|
921
|
+
const result = (locks.get(key) ?? Promise.resolve()).then(run, run);
|
|
922
|
+
const tail = result.then(() => void 0, () => void 0);
|
|
923
|
+
locks.set(key, tail);
|
|
924
|
+
try {
|
|
925
|
+
return await result;
|
|
926
|
+
} finally {
|
|
927
|
+
if (locks.get(key) === tail) locks.delete(key);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
/** In-flight sync per canonical calendar id. Module state on purpose: the
|
|
931
|
+
* scheduler, the create trigger and the Refresh button are three doors into
|
|
932
|
+
* the same calendar (CodeRabbit review #2566). */
|
|
933
|
+
var calendarLocks = /* @__PURE__ */ new Map();
|
|
886
934
|
/** Sync ONE calendar and fan its events out to every collection bound to it.
|
|
887
935
|
*
|
|
888
936
|
* The fan-out is not an optimisation, it is correctness: the sync token is
|
|
@@ -890,8 +938,18 @@ async function restartFullSync(accessToken, calendarId, workspaceRoot) {
|
|
|
890
938
|
* first collection advance the shared token and leave every later collection
|
|
891
939
|
* on the same calendar reading an already-consumed window — silently missing
|
|
892
940
|
* those events forever. Fetch once, apply to all, then advance the token.
|
|
893
|
-
* (Codex + CodeRabbit review on #2184.)
|
|
941
|
+
* (Codex + CodeRabbit review on #2184.)
|
|
942
|
+
*
|
|
943
|
+
* Queued per calendar for the same reason the fan-out exists: two passes over
|
|
944
|
+
* one calendar (a Refresh click landing during the scheduled run) would each
|
|
945
|
+
* load the SAME stored token and walk the same window. That is idempotent —
|
|
946
|
+
* writes are upserts by event id — but it is a wasted full walk. Queued, the
|
|
947
|
+
* second pass resumes from the token the first just stored and fetches only
|
|
948
|
+
* what is genuinely new. */
|
|
894
949
|
async function syncCalendarGroup(calendarId, collections, workspaceRoot) {
|
|
950
|
+
return await withKeyedLock(calendarLocks, canonicalCalendarId(calendarId), () => syncCalendarGroupNow(calendarId, collections, workspaceRoot));
|
|
951
|
+
}
|
|
952
|
+
async function syncCalendarGroupNow(calendarId, collections, workspaceRoot) {
|
|
895
953
|
const accessToken = await getGoogleAccessToken();
|
|
896
954
|
const first = await syncCalendarEvents(accessToken, {
|
|
897
955
|
calendarId,
|
|
@@ -1014,18 +1072,27 @@ async function releaseOrphanedCalendarToken(deleted, workspaceRoot) {
|
|
|
1014
1072
|
return null;
|
|
1015
1073
|
}
|
|
1016
1074
|
}
|
|
1017
|
-
/**
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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
|
|
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
|
-
|
|
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
|