@agent-native/core 0.0.0-beta-20260819191813 → 0.0.0-beta-20260819204836
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/corpus/README.md +1 -1
- package/corpus/templates/calendar/.agents/skills/event-management/SKILL.md +84 -0
- package/corpus/templates/calendar/AGENTS.md +5 -0
- package/corpus/templates/calendar/actions/delete-event.ts +1 -1
- package/corpus/templates/calendar/actions/delete-events.ts +508 -0
- package/corpus/templates/calendar/actions/event-action-helpers.ts +1 -1
- package/corpus/templates/calendar/actions/list-events.ts +53 -0
- package/corpus/templates/calendar/changelog/2026-08-18-the-agent-can-now-remove-many-meetings-at-once-such-as-every.md +6 -0
- package/corpus/templates/calendar/server/lib/event-weekday.ts +112 -0
- package/corpus/templates/factory/AGENTS.md +2 -1
- package/corpus/templates/factory/actions/get-factory-graph-version.ts +69 -0
- package/corpus/templates/factory/actions/list-factory-graph-versions.ts +82 -0
- package/corpus/templates/factory/actions/restore-factory-graph-version.ts +163 -0
- package/corpus/templates/factory/actions/save-factory-graph.ts +93 -60
- package/corpus/templates/factory/app/components/factory/FactoryHistoryView.tsx +709 -0
- package/corpus/templates/factory/app/components/factory/FactoryInspector.tsx +62 -2
- package/corpus/templates/factory/app/components/ui/alert-dialog.tsx +1 -0
- package/corpus/templates/factory/app/i18n/en-US.ts +43 -0
- package/corpus/templates/factory/app/routes/factory.tsx +115 -15
- package/corpus/templates/factory/server/plugins/agent-chat.ts +3 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +6 -6
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/docs/content/actions-access-control.mdx +136 -2
- package/docs/content/actions-agent-tools.mdx +101 -0
- package/docs/content/actions-defining.mdx +2 -2
- package/docs/content/audit-log.mdx +1 -1
- package/docs/content/authentication.mdx +1 -1
- package/docs/content/cloudflare.mdx +43 -0
- package/docs/content/creating-templates.mdx +1 -1
- package/docs/content/deploy-an-app.mdx +109 -0
- package/docs/content/deployment-environment-variables.mdx +101 -0
- package/docs/content/deployment.mdx +125 -792
- package/docs/content/docs-components.mdx +2 -2
- package/docs/content/environment-variables.mdx +41 -41
- package/docs/content/file-uploads.mdx +74 -40
- package/docs/content/harness-agents.mdx +2 -2
- package/docs/content/internationalization.mdx +61 -65
- package/docs/content/key-concepts.mdx +3 -3
- package/docs/content/multi-app-workspace.mdx +2 -2
- package/docs/content/netlify.mdx +47 -0
- package/docs/content/node-docker.mdx +138 -0
- package/docs/content/organizations-teams-permissions.mdx +5 -5
- package/docs/content/other-platforms.mdx +27 -0
- package/docs/content/ssr-caching.mdx +45 -0
- package/docs/content/template-assets-developers.mdx +2 -2
- package/docs/content/template-chat-developers.mdx +2 -2
- package/docs/content/template-content-developers.mdx +2 -2
- package/docs/content/{local-file-mode.mdx → template-content-local-files.mdx} +2 -1
- package/docs/content/template-content-sync.mdx +2 -2
- package/docs/content/toolkit-agent-ux.mdx +1 -1
- package/docs/content/toolkit-resources.mdx +2 -2
- package/docs/content/updating-ui-in-production.mdx +35 -0
- package/docs/content/vercel.mdx +38 -0
- package/docs/content/what-is-agent-native.mdx +1 -1
- package/docs/content/workspace-deployment.mdx +350 -0
- package/package.json +3 -3
- package/docs/content/agents.mdx +0 -9
- package/docs/content/database.mdx +0 -317
- package/docs/content/human-approval.mdx +0 -156
package/corpus/README.md
CHANGED
|
@@ -346,6 +346,88 @@ pnpm action delete-event --id google-event-id --scope thisAndFollowing
|
|
|
346
346
|
pnpm action delete-event --id google-event-id --removeOnly true
|
|
347
347
|
```
|
|
348
348
|
|
|
349
|
+
One event only. For more than one, use `delete-events`.
|
|
350
|
+
|
|
351
|
+
### delete-events
|
|
352
|
+
|
|
353
|
+
Every "remove all …" / "clear …" request goes here, in **one** call. Looping
|
|
354
|
+
`delete-event` per event cannot finish a real weekend cleanup inside a hosted
|
|
355
|
+
foreground run — that is the failure a user sees as "the agent stopped before
|
|
356
|
+
finishing" — and a partial loop leaves the calendar half-cleaned with no record
|
|
357
|
+
of which events survived. See the `reliable-mutations` skill.
|
|
358
|
+
|
|
359
|
+
Select by range plus `--daysOfWeek` and/or `--query`, or pass explicit `--ids`.
|
|
360
|
+
A filtered selection needs **both** `--from` and `--to` — a one-sided range would
|
|
361
|
+
silently widen or shrink a destructive request. Preview with `--dryRun true`,
|
|
362
|
+
show the user the matched list, then repeat the same call without `--dryRun`.
|
|
363
|
+
|
|
364
|
+
```bash
|
|
365
|
+
# What would go?
|
|
366
|
+
pnpm action delete-events \
|
|
367
|
+
--from 2026-04-01 --to 2026-05-01 \
|
|
368
|
+
--daysOfWeek saturday,sunday \
|
|
369
|
+
--dryRun true
|
|
370
|
+
|
|
371
|
+
# Delete it
|
|
372
|
+
pnpm action delete-events \
|
|
373
|
+
--from 2026-04-01 --to 2026-05-01 \
|
|
374
|
+
--daysOfWeek saturday,sunday
|
|
375
|
+
|
|
376
|
+
# Explicit ids from a previous list-events/search-events
|
|
377
|
+
pnpm action delete-events --ids google-a,google-b --accountEmail secondary@example.com
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
`--daysOfWeek` accepts full or 3-letter day names, `weekend`, or `weekdays`, and
|
|
381
|
+
resolves each event's day in the timezone the calendar is pinned to (the saved
|
|
382
|
+
`timezone` setting, then the browser's) — a Sunday 5pm America/Los_Angeles
|
|
383
|
+
meeting is Monday in UTC, so a UTC comparison deletes the wrong day. Pass
|
|
384
|
+
`--timezone` to override; an invalid zone is rejected, never silently treated as
|
|
385
|
+
UTC. Events are selected by where they **start**: `--from` inclusive, `--to`
|
|
386
|
+
exclusive, so an event starting exactly on the end bound, or a multi-day event
|
|
387
|
+
that began before `--from`, is left alone. Both bounds must be real dates; a
|
|
388
|
+
blank or impossible date (`2026-02-30`, with or without a time) is rejected
|
|
389
|
+
rather than rolled forward.
|
|
390
|
+
|
|
391
|
+
`--removeOnly true` cannot honor `--scope thisAndFollowing`: Google only lets a
|
|
392
|
+
non-organizer drop one occurrence at a time, so that pair is rejected instead of
|
|
393
|
+
reporting a series-wide removal that did not happen. Use `--scope single` per
|
|
394
|
+
occurrence, or `--scope all` to drop the whole series from your own calendar.
|
|
395
|
+
|
|
396
|
+
A filtered selection only ever removes the matched occurrences, so it accepts
|
|
397
|
+
`--scope single`. `all` and `thisAndFollowing` act on a whole recurring series —
|
|
398
|
+
which for a daily series would also delete the weekdays the user kept, and would
|
|
399
|
+
not match the dry-run preview — so they require explicit `--ids` or
|
|
400
|
+
`delete-event`, and take exactly one id per call because they mutate the series
|
|
401
|
+
master.
|
|
402
|
+
|
|
403
|
+
The commit re-reads the calendar, so it acts on the user's intent ("the weekend
|
|
404
|
+
is clear") rather than a frozen list — an event that moved onto a Saturday
|
|
405
|
+
between preview and confirmation is still removed. When the user should get
|
|
406
|
+
exactly the reviewed set and nothing else, pass the `--ids` from the dry-run
|
|
407
|
+
result instead of repeating the filter.
|
|
408
|
+
|
|
409
|
+
The result is a per-event report, not a boolean. Read `deleted`, `failed`, and
|
|
410
|
+
`skipped` and give the user the counts; a `failed` entry carries the provider
|
|
411
|
+
error and a `skipped` entry says why the app cannot delete it (ICS feeds are
|
|
412
|
+
read-only; a booking, including the Google event backing one, is cancelled from
|
|
413
|
+
the booking so that deleting the event cannot leave the booking confirmed — this
|
|
414
|
+
holds for explicit `--ids` too). Never report a bulk delete as done from the
|
|
415
|
+
absence of a thrown error.
|
|
416
|
+
|
|
417
|
+
`coverageComplete: false` plus `unreadableSources` means a feed could not be
|
|
418
|
+
read, so the sweep does not account for everything the user can see. Deletable
|
|
419
|
+
events are still deleted — a third-party feed outage should not block a cleanup,
|
|
420
|
+
and ICS events were never deletable — but say plainly that the feed was not
|
|
421
|
+
covered instead of reporting a clean pass.
|
|
422
|
+
|
|
423
|
+
The action refuses rather than guesses when the calendar read was incomplete (an
|
|
424
|
+
expired account token) or when more than 200 events match — narrow the filter
|
|
425
|
+
and run again. An `unreadableSources` entry means an ICS feed could not be read,
|
|
426
|
+
so mention that the sweep did not cover it.
|
|
427
|
+
|
|
428
|
+
`delete-events` only reads the signed-in user's own accounts, bookings, and
|
|
429
|
+
subscribed feeds; it cannot touch an overlaid person's calendar.
|
|
430
|
+
|
|
349
431
|
### rsvp-event
|
|
350
432
|
|
|
351
433
|
Accept, decline, or tentatively accept an invitation with the event's
|
|
@@ -428,6 +510,8 @@ When the user says:
|
|
|
428
510
|
| "move/rename/update a meeting" | `update-event --id ...` |
|
|
429
511
|
| "add Zoom to this meeting" | `update-event --id ... --addZoom=true` |
|
|
430
512
|
| "delete/remove a meeting" | `delete-event --id ...` |
|
|
513
|
+
| "remove all Saturday and Sunday meetings" | `delete-events --from ... --to ... --daysOfWeek saturday,sunday` |
|
|
514
|
+
| "clear my calendar next week" | `delete-events --from ... --to ...` (preview with `--dryRun true` first) |
|
|
431
515
|
| "remove weekends from a daily recurring event" | `update-event --id ... --recurrence "RRULE:FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR"` |
|
|
432
516
|
| "what's coming up" | `list-events` (uses default 30-day forward window) |
|
|
433
517
|
|
|
@@ -31,6 +31,11 @@ Detailed event, availability, booking, storage, and UI rules live in
|
|
|
31
31
|
Google Calendar. Return its link to the user; do not `fetch`
|
|
32
32
|
`/_agent-native/google/auth-url` from the agent backend because that route
|
|
33
33
|
requires the signed-in browser session.
|
|
34
|
+
- Satisfy a multi-event request with one batch call, never a loop of per-event
|
|
35
|
+
writes: `delete-events` handles every "remove all …" / "clear …" request,
|
|
36
|
+
including day-of-week filters, and `delete-event` is for exactly one event.
|
|
37
|
+
Preview with `dryRun` first, then report the returned `deleted` / `failed` /
|
|
38
|
+
`skipped` counts. See `event-management` and `reliable-mutations`.
|
|
34
39
|
- The action schema is authoritative when a parameter is unclear.
|
|
35
40
|
- Use the current date from runtime context, not a visible calendar date, when
|
|
36
41
|
the user says today/tomorrow/yesterday.
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
|
|
16
16
|
export default defineAction({
|
|
17
17
|
description:
|
|
18
|
-
"Delete or remove
|
|
18
|
+
"Delete or remove ONE Google Calendar event. For recurring events, choose just this instance, all events in the series, or this and following events. For more than one event — any 'remove all …' / 'clear …' request — use delete-events instead; never call this in a loop.",
|
|
19
19
|
schema: z.object({
|
|
20
20
|
id: z
|
|
21
21
|
.string()
|
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import { defineAction } from "@agent-native/core";
|
|
2
|
+
import { getUserSetting } from "@agent-native/core/settings";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
eventWeekday,
|
|
7
|
+
matchesWeekdays,
|
|
8
|
+
normalizeWeekdays,
|
|
9
|
+
requireValidTimezone,
|
|
10
|
+
type WeekdayName,
|
|
11
|
+
} from "../server/lib/event-weekday.js";
|
|
12
|
+
import { zonedDateTimeToUtcIso } from "../server/lib/find-time.js";
|
|
13
|
+
import * as googleCalendar from "../server/lib/google-calendar.js";
|
|
14
|
+
import type { CalendarEvent } from "../shared/api.js";
|
|
15
|
+
import {
|
|
16
|
+
cliBoolean,
|
|
17
|
+
isValidDateOnly,
|
|
18
|
+
normalizeGoogleEventId,
|
|
19
|
+
requireActionUserEmail,
|
|
20
|
+
resolveOwnedAccountEmail,
|
|
21
|
+
} from "./event-action-helpers.js";
|
|
22
|
+
import {
|
|
23
|
+
findBookedGoogleEvents,
|
|
24
|
+
listCalendarEvents,
|
|
25
|
+
resolveCalendarEventRange,
|
|
26
|
+
} from "./list-events.js";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A bulk delete is the one calendar write where a wrong filter is unrecoverable,
|
|
30
|
+
* so the match set is capped rather than paged: over the cap the action refuses
|
|
31
|
+
* and asks the caller to narrow, instead of deleting the first N of an unknown
|
|
32
|
+
* number and reporting success.
|
|
33
|
+
*/
|
|
34
|
+
const MAX_MATCHED_EVENTS = 200;
|
|
35
|
+
/** Google Calendar rate-limits per user, so fan out modestly rather than
|
|
36
|
+
* firing every delete at once and turning a clean batch into retries. */
|
|
37
|
+
const DELETE_CONCURRENCY = 4;
|
|
38
|
+
|
|
39
|
+
type Outcome = "deleted" | "matched" | "skipped" | "failed";
|
|
40
|
+
|
|
41
|
+
interface EventResult {
|
|
42
|
+
id: string;
|
|
43
|
+
title?: string;
|
|
44
|
+
start?: string;
|
|
45
|
+
weekday?: WeekdayName;
|
|
46
|
+
accountEmail?: string;
|
|
47
|
+
outcome: Outcome;
|
|
48
|
+
reason?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type BookedEvent = { googleEventId: string; calendarAccountId: string | null };
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Google event ids are scoped to a calendar, so a booking only protects the
|
|
55
|
+
* event on its own account. A booking whose account was never recorded still
|
|
56
|
+
* protects every match: reading that null as "some other account" would delete a
|
|
57
|
+
* booked event, which is the failure this guard exists to prevent.
|
|
58
|
+
*/
|
|
59
|
+
function isBookedOnAccount(
|
|
60
|
+
booked: readonly BookedEvent[],
|
|
61
|
+
googleEventId: string,
|
|
62
|
+
accountEmail: string | undefined,
|
|
63
|
+
): boolean {
|
|
64
|
+
return booked.some(
|
|
65
|
+
(row) =>
|
|
66
|
+
row.googleEventId === googleEventId &&
|
|
67
|
+
(!row.calendarAccountId ||
|
|
68
|
+
!accountEmail ||
|
|
69
|
+
row.calendarAccountId.trim().toLowerCase() ===
|
|
70
|
+
accountEmail.trim().toLowerCase()),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const BOOKED_EVENT_REASON =
|
|
75
|
+
"Is the Google event for an active booking; cancel the booking instead";
|
|
76
|
+
|
|
77
|
+
/** Why this app cannot delete an event, or undefined when it can. */
|
|
78
|
+
function undeletableReason(
|
|
79
|
+
event: CalendarEvent,
|
|
80
|
+
booked: readonly BookedEvent[],
|
|
81
|
+
): string | undefined {
|
|
82
|
+
// The shared read hides a booking whose linked Google event is present, so
|
|
83
|
+
// deleting that Google event here would leave the booking row confirmed and
|
|
84
|
+
// the event would reappear on the calendar as a local booking.
|
|
85
|
+
if (
|
|
86
|
+
event.googleEventId &&
|
|
87
|
+
isBookedOnAccount(booked, event.googleEventId, event.accountEmail)
|
|
88
|
+
) {
|
|
89
|
+
return BOOKED_EVENT_REASON;
|
|
90
|
+
}
|
|
91
|
+
if (event.source === "ical") {
|
|
92
|
+
return "Comes from a subscribed ICS feed, which is read-only";
|
|
93
|
+
}
|
|
94
|
+
if (event.source === "local") {
|
|
95
|
+
return "Is a booking; cancel it from the booking instead";
|
|
96
|
+
}
|
|
97
|
+
if (!event.googleEventId) {
|
|
98
|
+
return "Has no Google event id to delete";
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether an event *starts* inside the requested range: `from` inclusive, `to`
|
|
107
|
+
* exclusive.
|
|
108
|
+
*
|
|
109
|
+
* The shared calendar read deliberately returns anything that overlaps the range
|
|
110
|
+
* so the UI can draw a multi-day event and an event beginning on the boundary.
|
|
111
|
+
* Neither is what a caller naming a range for a delete means, and the reported
|
|
112
|
+
* `weekday` comes from the start, so an event starting outside the range would be
|
|
113
|
+
* previewed under a day the caller never queried. Re-checking both bounds here
|
|
114
|
+
* keeps the delete inside the dates the caller actually named.
|
|
115
|
+
*
|
|
116
|
+
* An all-day start carries no instant, so it is anchored to local midnight in the
|
|
117
|
+
* range's own timezone: parsing it as a UTC date would sort it before the zone's
|
|
118
|
+
* midnight, and comparing it as a date string would drop a same-day all-day event
|
|
119
|
+
* from a range ending at midday.
|
|
120
|
+
*/
|
|
121
|
+
function startsWithinRange(
|
|
122
|
+
start: string,
|
|
123
|
+
range: { from: string; to: string; timezone: string },
|
|
124
|
+
): boolean {
|
|
125
|
+
const startMs = DATE_ONLY_RE.test(start)
|
|
126
|
+
? new Date(zonedDateTimeToUtcIso(start, "00:00", range.timezone)).getTime()
|
|
127
|
+
: new Date(start).getTime();
|
|
128
|
+
return (
|
|
129
|
+
startMs >= new Date(range.from).getTime() &&
|
|
130
|
+
startMs < new Date(range.to).getTime()
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Reject a bound the shared resolver would silently reinterpret. An impossible
|
|
136
|
+
* day rolls forward instead of failing on both paths a bound can take —
|
|
137
|
+
* `Date.UTC` for a date-only value and `new Date` for a datetime, where V8
|
|
138
|
+
* turns `2026-02-30T00:00:00-08:00` into March 2 — so a destructive range would
|
|
139
|
+
* silently cover dates the caller never stated. Validated on the leading date
|
|
140
|
+
* for that reason; an out-of-range month or an unparseable value already fails
|
|
141
|
+
* in `normalizeDateBound`.
|
|
142
|
+
*/
|
|
143
|
+
function requireExplicitBound(value: string, label: "from" | "to"): string {
|
|
144
|
+
const trimmed = value.trim();
|
|
145
|
+
if (!trimmed) throw new Error(`${label} cannot be blank.`);
|
|
146
|
+
const datePart = trimmed.slice(0, 10);
|
|
147
|
+
if (DATE_ONLY_RE.test(datePart) && !isValidDateOnly(datePart)) {
|
|
148
|
+
throw new Error(`${label} is not a real calendar date: ${datePart}`);
|
|
149
|
+
}
|
|
150
|
+
return trimmed;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function mapWithConcurrency<T, R>(
|
|
154
|
+
items: readonly T[],
|
|
155
|
+
limit: number,
|
|
156
|
+
run: (item: T, index: number) => Promise<R>,
|
|
157
|
+
): Promise<R[]> {
|
|
158
|
+
const results = new Array<R>(items.length);
|
|
159
|
+
let next = 0;
|
|
160
|
+
await Promise.all(
|
|
161
|
+
Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
162
|
+
while (next < items.length) {
|
|
163
|
+
const index = next++;
|
|
164
|
+
results[index] = await run(items[index], index);
|
|
165
|
+
}
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
return results;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Which timezone decides an event's weekday, in the order the rest of the app
|
|
173
|
+
* uses it: an explicit argument, then the timezone the calendar is pinned to,
|
|
174
|
+
* then the browser's. Every layer is validated rather than normalized, because
|
|
175
|
+
* `normalizeTimezone`'s silent UTC fallback would move day boundaries under a
|
|
176
|
+
* delete without anyone being able to tell.
|
|
177
|
+
*/
|
|
178
|
+
async function resolveFilterTimezone(
|
|
179
|
+
requested: string | undefined,
|
|
180
|
+
ownerEmail: string,
|
|
181
|
+
): Promise<string | undefined> {
|
|
182
|
+
if (requested) return requireValidTimezone(requested);
|
|
183
|
+
const settings = (await getUserSetting(ownerEmail, "calendar-settings")) as {
|
|
184
|
+
timezone?: unknown;
|
|
185
|
+
} | null;
|
|
186
|
+
const saved = settings?.timezone;
|
|
187
|
+
if (typeof saved !== "string" || !saved.trim()) return undefined;
|
|
188
|
+
try {
|
|
189
|
+
return requireValidTimezone(saved.trim());
|
|
190
|
+
} catch {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`The saved calendar timezone (${saved}) is not a valid IANA timezone, so weekday filtering cannot be trusted. Fix it in Settings or pass timezone explicitly.`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export default defineAction({
|
|
198
|
+
description:
|
|
199
|
+
"Delete many calendar events in one call — the only supported way to satisfy a bulk request like 'remove all Saturday and Sunday meetings' or 'clear next week'. Never loop delete-event per event. Filter by date range plus daysOfWeek and/or a title query, or pass explicit ids. Call once with dryRun true to show the user exactly what matches, then once more without dryRun to delete. Weekdays are resolved in the calendar's timezone.",
|
|
200
|
+
schema: z.object({
|
|
201
|
+
ids: z
|
|
202
|
+
.array(z.string())
|
|
203
|
+
.max(MAX_MATCHED_EVENTS)
|
|
204
|
+
.optional()
|
|
205
|
+
.describe(
|
|
206
|
+
'Explicit Google event ids, with or without the "google-" prefix. Omit to select by filter instead.',
|
|
207
|
+
),
|
|
208
|
+
from: z
|
|
209
|
+
.string()
|
|
210
|
+
.optional()
|
|
211
|
+
.describe("Filter range start (ISO date or datetime)"),
|
|
212
|
+
to: z
|
|
213
|
+
.string()
|
|
214
|
+
.optional()
|
|
215
|
+
.describe("Filter range end, exclusive (ISO date or datetime)"),
|
|
216
|
+
daysOfWeek: z
|
|
217
|
+
.union([z.string(), z.array(z.string()).max(7)])
|
|
218
|
+
.optional()
|
|
219
|
+
.describe(
|
|
220
|
+
'Days to match, e.g. ["saturday","sunday"], "sat,sun", or "weekend"',
|
|
221
|
+
),
|
|
222
|
+
query: z
|
|
223
|
+
.string()
|
|
224
|
+
.max(500)
|
|
225
|
+
.optional()
|
|
226
|
+
.describe("Case-insensitive title/attendee/organizer filter"),
|
|
227
|
+
accountEmails: z
|
|
228
|
+
.array(z.string().email())
|
|
229
|
+
.min(1)
|
|
230
|
+
.max(20)
|
|
231
|
+
.optional()
|
|
232
|
+
.describe("Connected Google accounts to search; omitted searches all"),
|
|
233
|
+
accountEmail: z
|
|
234
|
+
.string()
|
|
235
|
+
.optional()
|
|
236
|
+
.describe("Account owning the events when passing explicit ids"),
|
|
237
|
+
timezone: z
|
|
238
|
+
.string()
|
|
239
|
+
.optional()
|
|
240
|
+
.describe(
|
|
241
|
+
"IANA timezone that defines the day boundaries; defaults to the saved calendar timezone",
|
|
242
|
+
),
|
|
243
|
+
scope: z
|
|
244
|
+
.enum(["single", "all", "thisAndFollowing"])
|
|
245
|
+
.optional()
|
|
246
|
+
.default("single")
|
|
247
|
+
.describe(
|
|
248
|
+
"Recurring-event delete scope. Filtered selection allows single only; all and thisAndFollowing require explicit ids because they act on the whole series.",
|
|
249
|
+
),
|
|
250
|
+
sendUpdates: z
|
|
251
|
+
.enum(["all", "none"])
|
|
252
|
+
.optional()
|
|
253
|
+
.default("none")
|
|
254
|
+
.describe("Whether Google should notify attendees of each cancellation"),
|
|
255
|
+
removeOnly: cliBoolean
|
|
256
|
+
.optional()
|
|
257
|
+
.describe(
|
|
258
|
+
"Use true when the user is not the organizer and wants the events removed from their own calendar only.",
|
|
259
|
+
),
|
|
260
|
+
dryRun: cliBoolean
|
|
261
|
+
.optional()
|
|
262
|
+
.describe("Return the matched events without deleting anything"),
|
|
263
|
+
}),
|
|
264
|
+
toolCallable: false,
|
|
265
|
+
run: async (args) => {
|
|
266
|
+
const ownerEmail = requireActionUserEmail();
|
|
267
|
+
if (!(await googleCalendar.isConnected(ownerEmail))) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
"Google Calendar not connected. Connect via Settings first.",
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const weekdays = normalizeWeekdays(args.daysOfWeek);
|
|
274
|
+
const hasIds = !!args.ids?.length;
|
|
275
|
+
if (hasIds && (args.from || args.to || weekdays.length > 0 || args.query)) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
"Pass either explicit ids or a filter (from/to, daysOfWeek, query), not both.",
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
// Both bounds, not one: a lone `to` would silently start the range at today
|
|
281
|
+
// and a lone `from` would silently shrink it to a single day, so an
|
|
282
|
+
// incomplete destructive request would delete a range nobody asked for.
|
|
283
|
+
// Trim and validate first, because the shared resolver trims its own inputs
|
|
284
|
+
// and a whitespace-only bound would pass a truthiness check here and then
|
|
285
|
+
// resolve to today's range.
|
|
286
|
+
const from = args.from
|
|
287
|
+
? requireExplicitBound(args.from, "from")
|
|
288
|
+
: undefined;
|
|
289
|
+
const to = args.to ? requireExplicitBound(args.to, "to") : undefined;
|
|
290
|
+
if (!hasIds && !(from && to)) {
|
|
291
|
+
throw new Error("A bulk delete needs both from and to, or explicit ids.");
|
|
292
|
+
}
|
|
293
|
+
// Google expands a recurring series into instances, so a weekend filter can
|
|
294
|
+
// match several occurrences of one daily series. Deleting with scope "all"
|
|
295
|
+
// would remove the whole series including the weekdays the user kept, and
|
|
296
|
+
// "thisAndFollowing" would have those occurrences race to rewrite the same
|
|
297
|
+
// master RRULE. Either way the dry-run preview would understate what
|
|
298
|
+
// happens, so a filtered selection is restricted to the matched occurrences.
|
|
299
|
+
// `removeEventFromCalendar` can only drop the named occurrence for
|
|
300
|
+
// "thisAndFollowing" — its own comment says so — so accepting the pair would
|
|
301
|
+
// report a series-wide removal while later occurrences stayed on the
|
|
302
|
+
// calendar. "all" resolves the master and is honored, so only this pair is
|
|
303
|
+
// rejected.
|
|
304
|
+
if (args.removeOnly && args.scope === "thisAndFollowing") {
|
|
305
|
+
throw new Error(
|
|
306
|
+
'removeOnly cannot honor scope "thisAndFollowing" — Google only lets a non-organizer drop one occurrence at a time. Use scope single per occurrence, or scope all to remove the whole series from your calendar.',
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
// A series scope acts on the series master, so batching several ids under it
|
|
310
|
+
// is incoherent: two occurrences of one series would either race to rewrite
|
|
311
|
+
// the same RRULE cutoff or have the second call 404 on an already-deleted
|
|
312
|
+
// master, and the per-event report would be wrong either way. One id per
|
|
313
|
+
// series operation removes the race by construction.
|
|
314
|
+
if (hasIds && args.scope !== "single" && args.ids!.length > 1) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
`scope "${args.scope}" acts on a whole recurring series, so it takes exactly one id. Call it once per series, or use scope single to remove specific occurrences.`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
if (!hasIds && args.scope !== "single") {
|
|
320
|
+
throw new Error(
|
|
321
|
+
`scope "${args.scope}" acts on a whole recurring series, which a filtered bulk delete cannot preview. Use scope single here, or pass the specific event as ids (or call delete-event) to change a series.`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const range = resolveCalendarEventRange({
|
|
326
|
+
from,
|
|
327
|
+
to,
|
|
328
|
+
timezone: await resolveFilterTimezone(args.timezone, ownerEmail),
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
let targets: Array<{
|
|
332
|
+
googleEventId: string;
|
|
333
|
+
accountEmail: string;
|
|
334
|
+
display: EventResult;
|
|
335
|
+
}> = [];
|
|
336
|
+
const results: EventResult[] = [];
|
|
337
|
+
// A feed that would only have contributed a skipped row still means the
|
|
338
|
+
// report does not cover everything on screen; say so rather than imply it.
|
|
339
|
+
const unreadableSources: Array<{ name: string; error: string }> = [];
|
|
340
|
+
|
|
341
|
+
if (hasIds) {
|
|
342
|
+
const accountEmail = await resolveOwnedAccountEmail(
|
|
343
|
+
args.accountEmail,
|
|
344
|
+
ownerEmail,
|
|
345
|
+
);
|
|
346
|
+
// Two spellings of one id ("google-a" and "a") would otherwise enqueue two
|
|
347
|
+
// writes for the same event: one succeeds, the other 404s, and the report
|
|
348
|
+
// claims a failure that never happened.
|
|
349
|
+
const requested = Array.from(
|
|
350
|
+
new Set(args.ids!.map(normalizeGoogleEventId)),
|
|
351
|
+
);
|
|
352
|
+
// Explicit ids get the same booking protection as a filtered selection:
|
|
353
|
+
// naming the event directly does not make leaving its booking confirmed
|
|
354
|
+
// any less of a silent inconsistency.
|
|
355
|
+
const booked = await findBookedGoogleEvents(requested);
|
|
356
|
+
for (const googleEventId of requested) {
|
|
357
|
+
const display: EventResult = {
|
|
358
|
+
id: `google-${googleEventId}`,
|
|
359
|
+
accountEmail,
|
|
360
|
+
outcome: "matched",
|
|
361
|
+
};
|
|
362
|
+
if (isBookedOnAccount(booked, googleEventId, accountEmail)) {
|
|
363
|
+
results.push({
|
|
364
|
+
...display,
|
|
365
|
+
outcome: "skipped",
|
|
366
|
+
reason: BOOKED_EVENT_REASON,
|
|
367
|
+
});
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
targets.push({ googleEventId, accountEmail, display });
|
|
371
|
+
}
|
|
372
|
+
} else {
|
|
373
|
+
// Read every source the user can see, not just Google. A weekend event
|
|
374
|
+
// from an ICS feed or a standalone booking is not deletable here, and
|
|
375
|
+
// narrowing the read to Google would drop it from the report entirely --
|
|
376
|
+
// so "deleted 3 of 3" comes back while the user still sees a fourth.
|
|
377
|
+
const listed = await listCalendarEvents(
|
|
378
|
+
{ query: args.query, accountEmails: args.accountEmails },
|
|
379
|
+
{ range },
|
|
380
|
+
);
|
|
381
|
+
// A provider read that partially failed is not an empty weekend. Deleting
|
|
382
|
+
// "everything that matched" out of an incomplete inventory would report a
|
|
383
|
+
// finished cleanup over events it never saw.
|
|
384
|
+
if (listed.errors.length > 0) {
|
|
385
|
+
throw new Error(
|
|
386
|
+
`Cannot bulk delete from an incomplete calendar read: ${listed.errors
|
|
387
|
+
.map((entry) => `${entry.email}: ${entry.error}`)
|
|
388
|
+
.join("; ")}`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
for (const feed of listed.icalErrors) {
|
|
393
|
+
unreadableSources.push({ name: feed.name, error: feed.error });
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const matched = listed.events.filter(
|
|
397
|
+
(event) =>
|
|
398
|
+
startsWithinRange(event.start, range) &&
|
|
399
|
+
matchesWeekdays(event.start, range.timezone, weekdays),
|
|
400
|
+
);
|
|
401
|
+
if (matched.length > MAX_MATCHED_EVENTS) {
|
|
402
|
+
throw new Error(
|
|
403
|
+
`${matched.length} events match, over the ${MAX_MATCHED_EVENTS} limit for one bulk delete. Narrow the range or filter and run again.`,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const booked = await findBookedGoogleEvents(
|
|
408
|
+
matched
|
|
409
|
+
.map((event) => event.googleEventId)
|
|
410
|
+
.filter((id): id is string => Boolean(id)),
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
for (const event of matched) {
|
|
414
|
+
const display: EventResult = {
|
|
415
|
+
id: event.googleEventId ? `google-${event.googleEventId}` : event.id,
|
|
416
|
+
title: event.title,
|
|
417
|
+
start: event.start,
|
|
418
|
+
weekday: eventWeekday(event.start, range.timezone),
|
|
419
|
+
accountEmail: event.accountEmail,
|
|
420
|
+
outcome: "matched",
|
|
421
|
+
};
|
|
422
|
+
const reason = undeletableReason(event, booked);
|
|
423
|
+
if (reason) {
|
|
424
|
+
results.push({ ...display, outcome: "skipped", reason });
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
targets.push({
|
|
428
|
+
googleEventId: event.googleEventId!,
|
|
429
|
+
accountEmail: event.accountEmail ?? ownerEmail,
|
|
430
|
+
display,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const summaryBase = {
|
|
436
|
+
range: { from: range.from, to: range.to, timezone: range.timezone },
|
|
437
|
+
daysOfWeek: weekdays,
|
|
438
|
+
matched: targets.length + results.length,
|
|
439
|
+
scope: args.scope,
|
|
440
|
+
// Same contract as list-events: a source that could not be read means this
|
|
441
|
+
// sweep does not account for everything the user can see, and that must be
|
|
442
|
+
// impossible to mistake for a clean full pass.
|
|
443
|
+
coverageComplete: unreadableSources.length === 0,
|
|
444
|
+
...(unreadableSources.length > 0 ? { unreadableSources } : {}),
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
if (args.dryRun) {
|
|
448
|
+
return {
|
|
449
|
+
...summaryBase,
|
|
450
|
+
dryRun: true,
|
|
451
|
+
deleted: 0,
|
|
452
|
+
failed: 0,
|
|
453
|
+
skipped: results.length,
|
|
454
|
+
events: [...targets.map((target) => target.display), ...results],
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const options = {
|
|
459
|
+
scope: args.scope,
|
|
460
|
+
sendUpdates: args.removeOnly ? ("none" as const) : args.sendUpdates,
|
|
461
|
+
};
|
|
462
|
+
const deleteResults = await mapWithConcurrency(
|
|
463
|
+
targets,
|
|
464
|
+
DELETE_CONCURRENCY,
|
|
465
|
+
async (target): Promise<EventResult> => {
|
|
466
|
+
const account = {
|
|
467
|
+
ownerEmail,
|
|
468
|
+
accountEmail: target.accountEmail,
|
|
469
|
+
};
|
|
470
|
+
try {
|
|
471
|
+
if (args.removeOnly) {
|
|
472
|
+
await googleCalendar.removeEventFromCalendar(
|
|
473
|
+
target.googleEventId,
|
|
474
|
+
account,
|
|
475
|
+
options,
|
|
476
|
+
);
|
|
477
|
+
} else {
|
|
478
|
+
await googleCalendar.deleteEvent(
|
|
479
|
+
target.googleEventId,
|
|
480
|
+
account,
|
|
481
|
+
options,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
return { ...target.display, outcome: "deleted" };
|
|
485
|
+
} catch (error) {
|
|
486
|
+
return {
|
|
487
|
+
...target.display,
|
|
488
|
+
outcome: "failed",
|
|
489
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
},
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
const events = [...deleteResults, ...results];
|
|
496
|
+
return {
|
|
497
|
+
...summaryBase,
|
|
498
|
+
dryRun: false,
|
|
499
|
+
deleted: deleteResults.filter((entry) => entry.outcome === "deleted")
|
|
500
|
+
.length,
|
|
501
|
+
failed: deleteResults.filter((entry) => entry.outcome === "failed")
|
|
502
|
+
.length,
|
|
503
|
+
skipped: results.length,
|
|
504
|
+
removedOnly: args.removeOnly ?? false,
|
|
505
|
+
events,
|
|
506
|
+
};
|
|
507
|
+
},
|
|
508
|
+
});
|
|
@@ -337,7 +337,7 @@ export function buildStatusEventFields(args: {
|
|
|
337
337
|
|
|
338
338
|
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
339
339
|
|
|
340
|
-
function isValidDateOnly(value: string): boolean {
|
|
340
|
+
export function isValidDateOnly(value: string): boolean {
|
|
341
341
|
if (!DATE_ONLY_PATTERN.test(value)) return false;
|
|
342
342
|
const [year, month, day] = value.split("-").map(Number);
|
|
343
343
|
const parsed = new Date(Date.UTC(year, month - 1, day));
|