@mulmoclaude/core 5.1.0 → 5.3.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/assets/helps/error-recovery.md +78 -0
- package/assets/helps/google-calendar-collection.md +92 -12
- package/dist/collection/core/schemaZ.d.ts +15 -2
- package/dist/collection/core/viewChatPolicy.d.ts +6 -3
- package/dist/collection/index.cjs +6 -3
- package/dist/collection/index.cjs.map +1 -1
- package/dist/collection/index.js +6 -3
- package/dist/collection/index.js.map +1 -1
- package/dist/collection/registry/server/index.cjs +2 -2
- package/dist/collection/registry/server/index.js +2 -2
- package/dist/collection/server/index.cjs +2 -2
- package/dist/collection/server/index.js +2 -2
- package/dist/collection-watchers/index.cjs +2 -2
- package/dist/collection-watchers/index.js +2 -2
- package/dist/{discovery-CBjI1lx9.js → discovery-B_64PBZU.js} +14 -3
- package/dist/{discovery-CBjI1lx9.js.map → discovery-B_64PBZU.js.map} +1 -1
- package/dist/{discovery-DCMW05Bp.cjs → discovery-CDXYswtw.cjs} +14 -3
- package/dist/{discovery-DCMW05Bp.cjs.map → discovery-CDXYswtw.cjs.map} +1 -1
- package/dist/feeds/server/index.cjs +2 -2
- package/dist/feeds/server/index.js +2 -2
- package/dist/google/calendar.d.ts +24 -0
- package/dist/google/collectionPush.d.ts +45 -2
- package/dist/google/deletePlan.d.ts +32 -0
- package/dist/google/eventDerived.d.ts +19 -0
- package/dist/google/index.cjs +179 -18
- package/dist/google/index.cjs.map +1 -1
- package/dist/google/index.d.ts +4 -2
- package/dist/google/index.js +174 -19
- package/dist/google/index.js.map +1 -1
- package/dist/remote-view/index.cjs +7 -7
- package/dist/remote-view/index.cjs.map +1 -1
- package/dist/remote-view/index.d.ts +3 -3
- package/dist/remote-view/index.js +7 -7
- package/dist/remote-view/index.js.map +1 -1
- package/dist/{server-BMZ_BcPH.js → server-7POrhHKv.js} +2 -2
- package/dist/{server-BMZ_BcPH.js.map → server-7POrhHKv.js.map} +1 -1
- package/dist/{server-rA9FSkl2.cjs → server-DfYwSX8b.cjs} +2 -2
- package/dist/{server-rA9FSkl2.cjs.map → server-DfYwSX8b.cjs.map} +1 -1
- package/package.json +1 -1
|
@@ -4,8 +4,8 @@ const require_dist = require("../../dist-Gj7ygaW2.cjs");
|
|
|
4
4
|
const require_root = require("../../root-BvwBUcol.cjs");
|
|
5
5
|
const require_itemId = require("../../itemId-CGT2J7YK.cjs");
|
|
6
6
|
const require_project = require("../../project-C1ep9pvo.cjs");
|
|
7
|
-
const require_discovery = require("../../discovery-
|
|
8
|
-
const require_server = require("../../server-
|
|
7
|
+
const require_discovery = require("../../discovery-CDXYswtw.cjs");
|
|
8
|
+
const require_server = require("../../server-DfYwSX8b.cjs");
|
|
9
9
|
const require_feeds_paths = require("../paths.cjs");
|
|
10
10
|
const require_ingestTypes = require("../../ingestTypes-DsVH9BYy.cjs");
|
|
11
11
|
const require_notifier = require("../../notifier-B65Mvngb.cjs");
|
|
@@ -2,8 +2,8 @@ import { a as isErrorWithCode, l as isUnknownArray, s as isRecord$1 } from "../.
|
|
|
2
2
|
import { t as canonicalRoot } from "../../root-BcS4-Kge.js";
|
|
3
3
|
import { G as INGEST_KINDS, W as FEED_SCHEDULES } from "../../itemId-4P9S_ehz.js";
|
|
4
4
|
import { t as mergeIntoExisting } from "../../project-B0j_Vf_Q.js";
|
|
5
|
-
import { W as resolveDataDir, _t as createForwardingLogger, d as storeFor, k as listItems, n as discoverCollections, q as safeSlugName, vt as createHostSlot } from "../../discovery-
|
|
6
|
-
import { F as buildCollectionActionSeedPrompt, I as promptPathsFor, z as readSkillTemplate } from "../../server-
|
|
5
|
+
import { W as resolveDataDir, _t as createForwardingLogger, d as storeFor, k as listItems, n as discoverCollections, q as safeSlugName, vt as createHostSlot } from "../../discovery-B_64PBZU.js";
|
|
6
|
+
import { F as buildCollectionActionSeedPrompt, I as promptPathsFor, z as readSkillTemplate } from "../../server-7POrhHKv.js";
|
|
7
7
|
import { FEEDS_DIR, feedDir, feedStatePath, feedsRoot, ingestStateDir, ingestStatePath } from "../paths.js";
|
|
8
8
|
import { n as DEFAULT_FEED_MAX_ITEMS, r as isFeedSchedule, t as AGENT_INGEST_KIND } from "../../ingestTypes-GkG7bJdB.js";
|
|
9
9
|
import { d as publish, n as clear } from "../../notifier-vAp5t6wL.js";
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/** A GET or DELETE on an event that is not there. Exported because the push's
|
|
2
|
+
* delete sweep has to read "already gone" as success, not as a failure to
|
|
3
|
+
* retry — a retained baseline would announce the same phantom deletion on
|
|
4
|
+
* every run. */
|
|
5
|
+
export declare const EVENT_ABSENT_STATUSES: readonly number[];
|
|
1
6
|
/** An `If-Match` write whose etag no longer matches — someone else changed the
|
|
2
7
|
* event since it was read. */
|
|
3
8
|
export declare const HTTP_PRECONDITION_FAILED = 412;
|
|
@@ -74,6 +79,12 @@ export interface UpdateCalendarEventInput {
|
|
|
74
79
|
export interface DeleteCalendarEventInput {
|
|
75
80
|
eventId: string;
|
|
76
81
|
calendarId?: string | undefined;
|
|
82
|
+
/** Etag of the version this decision was made against. Sent as `If-Match`, so
|
|
83
|
+
* Google answers 412 rather than removing an event that changed after it was
|
|
84
|
+
* read — the same guard `updateCalendarEvent` takes, and it matters more
|
|
85
|
+
* here: a delete cannot be undone from this side. Omit for an unconditional
|
|
86
|
+
* delete. */
|
|
87
|
+
ifMatch?: string | undefined;
|
|
77
88
|
}
|
|
78
89
|
export interface ListEventsInput {
|
|
79
90
|
timeMin?: string | undefined;
|
|
@@ -117,6 +128,14 @@ export interface CalendarEventSummary {
|
|
|
117
128
|
eventType: string;
|
|
118
129
|
/** Google Meet URL, "" when nothing is attached. */
|
|
119
130
|
hangoutLink: string;
|
|
131
|
+
/** The signed-in user's own `responseStatus`, folded out of `attendees`.
|
|
132
|
+
* `""` when Google reported none — which is every event that HAS no
|
|
133
|
+
* attendees, so it reads as "nothing said", never as "not going". */
|
|
134
|
+
selfResponseStatus: string;
|
|
135
|
+
/** The URL that joins this event's meeting, folded out of
|
|
136
|
+
* `conferenceData.entryPoints`. Covers the conferences `hangoutLink` does
|
|
137
|
+
* not: Zoom, Teams, anything attached as conference data. */
|
|
138
|
+
conferenceVideoUri: string;
|
|
120
139
|
}
|
|
121
140
|
export interface CalendarSummary {
|
|
122
141
|
id: string;
|
|
@@ -166,6 +185,11 @@ export interface FetchedCalendarEvent {
|
|
|
166
185
|
/** Google's `etag` for this version, `""` when absent. Sent back as
|
|
167
186
|
* `If-Match` so a PATCH cannot overwrite a version we never read. */
|
|
168
187
|
etag: string;
|
|
188
|
+
/** How many people the event lists, `0` for a solo one. Carried here rather
|
|
189
|
+
* than on the summary because only the delete guard reads it, and it is the
|
|
190
|
+
* one thing that guard cannot get from the record — by then the record is
|
|
191
|
+
* gone (`deletePlan.ts`). */
|
|
192
|
+
attendeeCount: number;
|
|
169
193
|
}
|
|
170
194
|
/** Read ONE event, or null when it is gone (404 / 410).
|
|
171
195
|
*
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { LoadedCollection } from '../collection/server/discoveredCollection.js';
|
|
2
2
|
import { CollectionFieldSpec, CollectionItem } from '../collection/core/schema.js';
|
|
3
|
-
import { CalendarEventSummary } from './calendar.js';
|
|
3
|
+
import { deleteCalendarEvent, getCalendarEvent, CalendarEventSummary } from './calendar.js';
|
|
4
4
|
import { ShadowEvent } from './calendarPushState.js';
|
|
5
5
|
import { PushableSourceField } from './pushPlan.js';
|
|
6
6
|
/** What the up-front writability gate knows about the target calendar. */
|
|
@@ -31,8 +31,12 @@ export interface CalendarCollectionPushResult {
|
|
|
31
31
|
updated: number;
|
|
32
32
|
/** Edited on both sides; skipped so neither version is destroyed. */
|
|
33
33
|
conflicts: number;
|
|
34
|
-
/** Records deleted locally. Reported
|
|
34
|
+
/** Records deleted locally. Reported whether or not the deletion carried, so
|
|
35
|
+
* the count still answers "how many rows went away here". */
|
|
35
36
|
localDeletes: number;
|
|
37
|
+
/** Of those, how many were deleted in Google too. Always `0` unless the
|
|
38
|
+
* collection opted in with `propagateDeletes` (#3234). */
|
|
39
|
+
deletedInGoogle: number;
|
|
36
40
|
/** Records that cannot be pushed as they stand, each with the reason. */
|
|
37
41
|
skipped: string[];
|
|
38
42
|
errors: string[];
|
|
@@ -72,6 +76,15 @@ export interface CalendarPushDeps {
|
|
|
72
76
|
isLinked: () => Promise<boolean>;
|
|
73
77
|
accessToken: () => Promise<string>;
|
|
74
78
|
calendarMeta: (accessToken: string, calendarId: string | undefined) => Promise<CalendarWriteTarget>;
|
|
79
|
+
/** Read and write for the delete guard. Injected like the rest so a test can
|
|
80
|
+
* exercise "this event has attendees" without a grant (#3234).
|
|
81
|
+
*
|
|
82
|
+
* OPTIONAL, unlike the rest: `CalendarPushDeps` is an exported type, and a
|
|
83
|
+
* deps object written before these existed must keep satisfying it — the
|
|
84
|
+
* more so because a collection that never opted into `propagateDeletes`
|
|
85
|
+
* cannot reach either of them. Absent means the live implementation. */
|
|
86
|
+
fetchEvent?: typeof getCalendarEvent;
|
|
87
|
+
deleteEvent?: typeof deleteCalendarEvent;
|
|
75
88
|
}
|
|
76
89
|
type PushOutcome = {
|
|
77
90
|
kind: "created";
|
|
@@ -114,6 +127,36 @@ export declare function locallyEditedIds(records: readonly CollectionItem[], sha
|
|
|
114
127
|
* from the stored baseline alone, so it works precisely when Google is the
|
|
115
128
|
* thing that is unreachable. */
|
|
116
129
|
export declare function unsentLocalEdits(collection: LoadedCollection, workspaceRoot: string): Promise<string[]>;
|
|
130
|
+
/** What one run's deletions did. `seen` counts every record that went away
|
|
131
|
+
* locally, whether or not the deletion carried — so the count means the same
|
|
132
|
+
* thing with the feature off as it always did. */
|
|
133
|
+
export interface DeleteSweep {
|
|
134
|
+
seen: number;
|
|
135
|
+
deleted: string[];
|
|
136
|
+
skipped: string[];
|
|
137
|
+
errors: string[];
|
|
138
|
+
}
|
|
139
|
+
/** What `sweepDeletes` needs, narrowed to the three effects it has. Taken as an
|
|
140
|
+
* argument rather than reached for, so the guard can be exercised against
|
|
141
|
+
* "this event has attendees" with no grant and no workspace. */
|
|
142
|
+
export interface DeleteSweepDeps {
|
|
143
|
+
/** What Google currently holds for the event, or null when it is gone. The
|
|
144
|
+
* `etag` is the version the decision was made against. */
|
|
145
|
+
fetchEvent: (eventId: string) => Promise<{
|
|
146
|
+
attendeeCount: number;
|
|
147
|
+
etag: string;
|
|
148
|
+
} | null>;
|
|
149
|
+
/** Delete it, conditional on that version. */
|
|
150
|
+
deleteEvent: (eventId: string, ifMatch: string) => Promise<void>;
|
|
151
|
+
/** Drop the event's baseline entry. */
|
|
152
|
+
forget: (eventId: string) => Promise<void>;
|
|
153
|
+
}
|
|
154
|
+
/** Carry every local deletion, when the collection opted in.
|
|
155
|
+
*
|
|
156
|
+
* Sequential rather than concurrent: each one writes the shared baseline file,
|
|
157
|
+
* and an interrupted run has to leave a state the next run resumes from rather
|
|
158
|
+
* than a partially applied batch. */
|
|
159
|
+
export declare function sweepDeletes(eventIds: readonly string[], propagate: boolean, deps: DeleteSweepDeps): Promise<DeleteSweep>;
|
|
117
160
|
/** Push one collection WITHOUT taking the calendar lock.
|
|
118
161
|
*
|
|
119
162
|
* The caller must already hold it. The scheduled push→pull cycle
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Why an event was left in Google although its record is gone. */
|
|
2
|
+
export type DeleteRefusal = {
|
|
3
|
+
kind: "has-attendees";
|
|
4
|
+
} | {
|
|
5
|
+
kind: "already-gone";
|
|
6
|
+
};
|
|
7
|
+
/** An approval carries the version it was made against, so the delete can be
|
|
8
|
+
* conditional on it. Keeping the two together is what stops a caller deleting
|
|
9
|
+
* against a version the guard never saw. */
|
|
10
|
+
export type DeleteDecision = {
|
|
11
|
+
ok: true;
|
|
12
|
+
etag: string;
|
|
13
|
+
} | ({
|
|
14
|
+
ok: false;
|
|
15
|
+
} & DeleteRefusal);
|
|
16
|
+
/** The message the push reports for a refusal, with the event named. */
|
|
17
|
+
export declare const deleteRefusalMessage: (eventId: string, refusal: DeleteRefusal) => string;
|
|
18
|
+
/** Decide one deletion against what Google currently holds.
|
|
19
|
+
*
|
|
20
|
+
* `null` means the event is already absent — a 404/410 on the read. Nothing to
|
|
21
|
+
* delete, and reported apart from a refusal so "we chose not to" never reads
|
|
22
|
+
* as "we could not".
|
|
23
|
+
*
|
|
24
|
+
* ANY attendee refuses, including the entry Google adds for the organiser. A
|
|
25
|
+
* rule that tried to exclude "only me" would have to work out which entry is
|
|
26
|
+
* the user from a payload that may not say, and being wrong there withdraws a
|
|
27
|
+
* real invitation. Solo events — the ones this feature exists for — carry no
|
|
28
|
+
* attendees at all, so the blunt rule costs them nothing. */
|
|
29
|
+
export declare function planDelete(fetched: {
|
|
30
|
+
attendeeCount: number;
|
|
31
|
+
etag: string;
|
|
32
|
+
} | null): DeleteDecision;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** The signed-in user's own `responseStatus` on this event, `""` when Google
|
|
2
|
+
* reported none.
|
|
3
|
+
*
|
|
4
|
+
* `""` is the common case, not an edge one: an event with no attendees has no
|
|
5
|
+
* entry to mark `self`, which is most of a personal calendar. So it means
|
|
6
|
+
* "Google said nothing", never "not going" — filter with `!= "declined"`, not
|
|
7
|
+
* with `== "accepted"`, or every solo event disappears too.
|
|
8
|
+
*
|
|
9
|
+
* Google's values: `needsAction`, `declined`, `tentative`, `accepted`. */
|
|
10
|
+
export declare function selfResponseStatus(attendees: unknown): string;
|
|
11
|
+
/** The URL that joins this event's meeting, `""` when it has none.
|
|
12
|
+
*
|
|
13
|
+
* Only the `video` entry point. The others are a phone number and a dial-in
|
|
14
|
+
* PIN page, and a column named for joining that sometimes holds `tel:` would
|
|
15
|
+
* be worse than one that is empty — the caller can see empty and fall back.
|
|
16
|
+
*
|
|
17
|
+
* `hangoutLink` (#3229) already carries this for Google Meet; this is what
|
|
18
|
+
* reaches the events whose conference is Zoom or Teams. */
|
|
19
|
+
export declare function conferenceVideoUri(conferenceData: unknown): string;
|
package/dist/google/index.cjs
CHANGED
|
@@ -7,7 +7,7 @@ const require_root = require("../root-BvwBUcol.cjs");
|
|
|
7
7
|
const require_utils_fetch = require("../utils/fetch.cjs");
|
|
8
8
|
const require_itemId = require("../itemId-CGT2J7YK.cjs");
|
|
9
9
|
const require_project = require("../project-C1ep9pvo.cjs");
|
|
10
|
-
const require_discovery = require("../discovery-
|
|
10
|
+
const require_discovery = require("../discovery-CDXYswtw.cjs");
|
|
11
11
|
const require_paths = require("../paths-C8-jxR0s.cjs");
|
|
12
12
|
let node_fs = require("node:fs");
|
|
13
13
|
let node_path = require("node:path");
|
|
@@ -45,6 +45,74 @@ var isIsoDateTimeWithOffset = (value) => {
|
|
|
45
45
|
return isRealCalendarDate(year, month, day) && timeInRange && offsetInRange;
|
|
46
46
|
};
|
|
47
47
|
//#endregion
|
|
48
|
+
//#region src/google/deletePlan.ts
|
|
49
|
+
var ATTENDEES_REASON = "it carries attendees — deleting an invited event withdraws it from their calendars too";
|
|
50
|
+
var ALREADY_GONE_REASON = "it is no longer in Google";
|
|
51
|
+
/** The message the push reports for a refusal, with the event named. */
|
|
52
|
+
var deleteRefusalMessage = (eventId, refusal) => `${eventId}: left in Google because ${refusal.kind === "has-attendees" ? ATTENDEES_REASON : ALREADY_GONE_REASON}`;
|
|
53
|
+
/** Decide one deletion against what Google currently holds.
|
|
54
|
+
*
|
|
55
|
+
* `null` means the event is already absent — a 404/410 on the read. Nothing to
|
|
56
|
+
* delete, and reported apart from a refusal so "we chose not to" never reads
|
|
57
|
+
* as "we could not".
|
|
58
|
+
*
|
|
59
|
+
* ANY attendee refuses, including the entry Google adds for the organiser. A
|
|
60
|
+
* rule that tried to exclude "only me" would have to work out which entry is
|
|
61
|
+
* the user from a payload that may not say, and being wrong there withdraws a
|
|
62
|
+
* real invitation. Solo events — the ones this feature exists for — carry no
|
|
63
|
+
* attendees at all, so the blunt rule costs them nothing. */
|
|
64
|
+
function planDelete(fetched) {
|
|
65
|
+
if (fetched === null) return {
|
|
66
|
+
ok: false,
|
|
67
|
+
kind: "already-gone"
|
|
68
|
+
};
|
|
69
|
+
return fetched.attendeeCount > 0 ? {
|
|
70
|
+
ok: false,
|
|
71
|
+
kind: "has-attendees"
|
|
72
|
+
} : {
|
|
73
|
+
ok: true,
|
|
74
|
+
etag: fetched.etag
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
//#endregion
|
|
78
|
+
//#region src/google/util.ts
|
|
79
|
+
var ONE_SECOND_MS = 1e3;
|
|
80
|
+
var ONE_MINUTE_MS = 6e4;
|
|
81
|
+
function isRecord(value) {
|
|
82
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
83
|
+
}
|
|
84
|
+
//#endregion
|
|
85
|
+
//#region src/google/eventDerived.ts
|
|
86
|
+
var stringAt = (record, key) => typeof record[key] === "string" ? record[key] : "";
|
|
87
|
+
var recordsIn = (value) => Array.isArray(value) ? value.filter(isRecord) : [];
|
|
88
|
+
/** The signed-in user's own `responseStatus` on this event, `""` when Google
|
|
89
|
+
* reported none.
|
|
90
|
+
*
|
|
91
|
+
* `""` is the common case, not an edge one: an event with no attendees has no
|
|
92
|
+
* entry to mark `self`, which is most of a personal calendar. So it means
|
|
93
|
+
* "Google said nothing", never "not going" — filter with `!= "declined"`, not
|
|
94
|
+
* with `== "accepted"`, or every solo event disappears too.
|
|
95
|
+
*
|
|
96
|
+
* Google's values: `needsAction`, `declined`, `tentative`, `accepted`. */
|
|
97
|
+
function selfResponseStatus(attendees) {
|
|
98
|
+
const self = recordsIn(attendees).find((attendee) => attendee.self === true);
|
|
99
|
+
return self === void 0 ? "" : stringAt(self, "responseStatus");
|
|
100
|
+
}
|
|
101
|
+
var VIDEO_ENTRY_POINT = "video";
|
|
102
|
+
/** The URL that joins this event's meeting, `""` when it has none.
|
|
103
|
+
*
|
|
104
|
+
* Only the `video` entry point. The others are a phone number and a dial-in
|
|
105
|
+
* PIN page, and a column named for joining that sometimes holds `tel:` would
|
|
106
|
+
* be worse than one that is empty — the caller can see empty and fall back.
|
|
107
|
+
*
|
|
108
|
+
* `hangoutLink` (#3229) already carries this for Google Meet; this is what
|
|
109
|
+
* reaches the events whose conference is Zoom or Teams. */
|
|
110
|
+
function conferenceVideoUri(conferenceData) {
|
|
111
|
+
if (!isRecord(conferenceData)) return "";
|
|
112
|
+
const video = recordsIn(conferenceData.entryPoints).find((entry) => entry.entryPointType === VIDEO_ENTRY_POINT);
|
|
113
|
+
return video === void 0 ? "" : stringAt(video, "uri");
|
|
114
|
+
}
|
|
115
|
+
//#endregion
|
|
48
116
|
//#region src/google/eventSpanInput.ts
|
|
49
117
|
var DATE_ONLY_RE$1 = /^\d{4}-\d{2}-\d{2}$/;
|
|
50
118
|
/** Whether `value` is a bare calendar day — the all-day spelling of an event
|
|
@@ -160,13 +228,6 @@ function googleSecretsDir(home) {
|
|
|
160
228
|
return (0, node_path.join)(home ?? (0, node_os.homedir)(), ".secrets");
|
|
161
229
|
}
|
|
162
230
|
//#endregion
|
|
163
|
-
//#region src/google/util.ts
|
|
164
|
-
var ONE_SECOND_MS = 1e3;
|
|
165
|
-
var ONE_MINUTE_MS = 6e4;
|
|
166
|
-
function isRecord(value) {
|
|
167
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
168
|
-
}
|
|
169
|
-
//#endregion
|
|
170
231
|
//#region src/google/clientSecret.ts
|
|
171
232
|
var isNonEmptyString = (value) => typeof value === "string" && value !== "";
|
|
172
233
|
var isInstalledClientSecret = (value) => {
|
|
@@ -697,6 +758,10 @@ var EVENT_SYNC_PAGE_SIZE = 2500;
|
|
|
697
758
|
var MAX_EVENT_SYNC_PAGES = 200;
|
|
698
759
|
var CALENDAR_META_PAGE_SIZE = 1;
|
|
699
760
|
var HTTP_GONE = 410;
|
|
761
|
+
/** A GET or DELETE on an event that is not there. Exported because the push's
|
|
762
|
+
* delete sweep has to read "already gone" as success, not as a failure to
|
|
763
|
+
* retry — a retained baseline would announce the same phantom deletion on
|
|
764
|
+
* every run. */
|
|
700
765
|
var EVENT_ABSENT_STATUSES = [404, HTTP_GONE];
|
|
701
766
|
/** An `If-Match` write whose etag no longer matches — someone else changed the
|
|
702
767
|
* event since it was read. */
|
|
@@ -714,6 +779,10 @@ var CANCELLED_EVENT_STATUS = "cancelled";
|
|
|
714
779
|
* apart while they share a sync token silently loses events (#2184). */
|
|
715
780
|
var canonicalCalendarId = (calendarId) => calendarId || DEFAULT_CALENDAR_ID;
|
|
716
781
|
var eventsUrl = (calendarId) => `${CALENDAR_BASE_URL}/calendars/${encodeURIComponent(canonicalCalendarId(calendarId))}/events`;
|
|
782
|
+
/** People Google lists on the event, `0` when it carries none. Counts EVERY
|
|
783
|
+
* entry, including the one Google adds for the organiser: see `deletePlan.ts`
|
|
784
|
+
* for why the guard is not cleverer than that. */
|
|
785
|
+
var attendeeCount = (record) => Array.isArray(record.attendees) ? record.attendees.length : 0;
|
|
717
786
|
var eventTime$1 = (value) => {
|
|
718
787
|
if (!isRecord(value)) return "";
|
|
719
788
|
if (typeof value.dateTime === "string") return value.dateTime;
|
|
@@ -737,7 +806,9 @@ var toEventSummary = (value) => {
|
|
|
737
806
|
updated: stringField(record, "updated"),
|
|
738
807
|
transparency: stringField(record, "transparency"),
|
|
739
808
|
eventType: stringField(record, "eventType"),
|
|
740
|
-
hangoutLink: stringField(record, "hangoutLink")
|
|
809
|
+
hangoutLink: stringField(record, "hangoutLink"),
|
|
810
|
+
selfResponseStatus: selfResponseStatus(record.attendees),
|
|
811
|
+
conferenceVideoUri: conferenceVideoUri(record.conferenceData)
|
|
741
812
|
};
|
|
742
813
|
};
|
|
743
814
|
var toCalendarSummary = (value) => {
|
|
@@ -828,9 +899,11 @@ async function updateCalendarEvent(accessToken, input) {
|
|
|
828
899
|
async function getCalendarEvent(accessToken, input) {
|
|
829
900
|
try {
|
|
830
901
|
const fetched = await googleRequest(CALENDAR_API_LABEL, accessToken, eventUrl(input.calendarId, input.eventId));
|
|
902
|
+
const record = asRecord(fetched);
|
|
831
903
|
return {
|
|
832
904
|
event: toEventSummary(fetched),
|
|
833
|
-
etag: stringField(
|
|
905
|
+
etag: stringField(record, "etag"),
|
|
906
|
+
attendeeCount: attendeeCount(record)
|
|
834
907
|
};
|
|
835
908
|
} catch (error) {
|
|
836
909
|
if (isGoogleApiError(error) && EVENT_ABSENT_STATUSES.includes(error.status)) return null;
|
|
@@ -841,7 +914,10 @@ async function getCalendarEvent(accessToken, input) {
|
|
|
841
914
|
* return; a second delete of the same id answers 410, which surfaces as a
|
|
842
915
|
* GoogleApiError rather than being swallowed. */
|
|
843
916
|
async function deleteCalendarEvent(accessToken, input) {
|
|
844
|
-
await googleRequest(CALENDAR_API_LABEL, accessToken, eventUrl(input.calendarId, input.eventId), {
|
|
917
|
+
await googleRequest(CALENDAR_API_LABEL, accessToken, eventUrl(input.calendarId, input.eventId), {
|
|
918
|
+
method: "DELETE",
|
|
919
|
+
...input.ifMatch ? { extraHeaders: { "If-Match": input.ifMatch } } : {}
|
|
920
|
+
});
|
|
845
921
|
}
|
|
846
922
|
async function listCalendarEvents(accessToken, input = {}) {
|
|
847
923
|
const params = new URLSearchParams({
|
|
@@ -1623,7 +1699,9 @@ var UNPUSHABLE_EVENT_FIELDS = {
|
|
|
1623
1699
|
updated: "",
|
|
1624
1700
|
transparency: "",
|
|
1625
1701
|
eventType: "",
|
|
1626
|
-
hangoutLink: ""
|
|
1702
|
+
hangoutLink: "",
|
|
1703
|
+
selfResponseStatus: "",
|
|
1704
|
+
conferenceVideoUri: ""
|
|
1627
1705
|
};
|
|
1628
1706
|
/** A baseline shaped as the event it came from, so the comparison can run
|
|
1629
1707
|
* through the very projection that wrote the record. */
|
|
@@ -1744,7 +1822,9 @@ var liveDeps = {
|
|
|
1744
1822
|
findCollection: findCalendarCollection,
|
|
1745
1823
|
isLinked: async () => Boolean((await loadGoogleTokens())?.refresh_token),
|
|
1746
1824
|
accessToken: getGoogleAccessToken,
|
|
1747
|
-
calendarMeta: liveCalendarMeta
|
|
1825
|
+
calendarMeta: liveCalendarMeta,
|
|
1826
|
+
fetchEvent: getCalendarEvent,
|
|
1827
|
+
deleteEvent: deleteCalendarEvent
|
|
1748
1828
|
};
|
|
1749
1829
|
var localValue = (ctx, record, source) => {
|
|
1750
1830
|
const field = ctx.bySource[source];
|
|
@@ -1954,7 +2034,7 @@ var UNPUSHED_KINDS = ["conflict", "error"];
|
|
|
1954
2034
|
/** Whether this outcome leaves a local edit that only exists locally, so the
|
|
1955
2035
|
* pull must not overwrite the record and the baseline must not advance. */
|
|
1956
2036
|
var isUnpushed = (kind) => UNPUSHED_KINDS.includes(kind);
|
|
1957
|
-
function tally(slug, attempts,
|
|
2037
|
+
function tally(slug, attempts, deletes) {
|
|
1958
2038
|
const outcomes = attempts.map((attempt) => attempt.outcome);
|
|
1959
2039
|
const count = (kind) => outcomes.filter((outcome) => outcome.kind === kind).length;
|
|
1960
2040
|
return {
|
|
@@ -1962,9 +2042,10 @@ function tally(slug, attempts, localDeletes) {
|
|
|
1962
2042
|
created: count("created"),
|
|
1963
2043
|
updated: count("updated"),
|
|
1964
2044
|
conflicts: count("conflict"),
|
|
1965
|
-
localDeletes,
|
|
1966
|
-
|
|
1967
|
-
|
|
2045
|
+
localDeletes: deletes.seen,
|
|
2046
|
+
deletedInGoogle: deletes.deleted.length,
|
|
2047
|
+
skipped: [...outcomes.flatMap((outcome) => outcome.kind === "skipped" ? [outcome.message] : []), ...deletes.skipped],
|
|
2048
|
+
errors: [...outcomes.flatMap((outcome) => outcome.kind === "error" ? [outcome.message] : []), ...deletes.errors],
|
|
1968
2049
|
unpushedIds: attempts.filter((attempt) => isUnpushed(attempt.outcome.kind)).map((attempt) => attempt.eventId)
|
|
1969
2050
|
};
|
|
1970
2051
|
}
|
|
@@ -1993,6 +2074,69 @@ async function unsentLocalEdits(collection, workspaceRoot) {
|
|
|
1993
2074
|
const shadow = await loadCalendarShadow(schema.googleCalendar?.calendarId, workspaceRoot);
|
|
1994
2075
|
return locallyEditedIds(await require_discovery.storeFor(collection, { workspaceRoot }).list(), shadow, pushableMap(schema.googleCalendar?.map ?? {}), schema.primaryKey, schema.fields);
|
|
1995
2076
|
}
|
|
2077
|
+
var noDeletes = (seen) => ({
|
|
2078
|
+
seen,
|
|
2079
|
+
deleted: [],
|
|
2080
|
+
skipped: [],
|
|
2081
|
+
errors: []
|
|
2082
|
+
});
|
|
2083
|
+
/** A delete whose event changed in Google between the guard reading it and the
|
|
2084
|
+
* delete landing. Reported rather than retried: what changed may be the very
|
|
2085
|
+
* thing the guard refuses, and re-reading in a loop would race the same way. */
|
|
2086
|
+
var staleDeleteMessage = (eventId) => `${eventId}: left in Google because it changed there while this push was checking it — press Push again to re-check`;
|
|
2087
|
+
/** Delete ONE event and forget its baseline, or say why it was left alone.
|
|
2088
|
+
*
|
|
2089
|
+
* The baseline entry goes only after Google confirms, and it must go: the
|
|
2090
|
+
* baseline is what `locallyDeletedIds` reads, so a kept entry would re-report
|
|
2091
|
+
* the same deletion on every run forever. A refusal keeps its entry on
|
|
2092
|
+
* purpose — the event IS still standing in Google, and the report is the only
|
|
2093
|
+
* thing saying so.
|
|
2094
|
+
*
|
|
2095
|
+
* The delete carries the etag the guard read. Without it, an attendee added
|
|
2096
|
+
* between the two requests would be withdrawn by a decision taken before they
|
|
2097
|
+
* existed — the same race `updateCalendarEvent` already guards, and it costs
|
|
2098
|
+
* more here because a delete cannot be undone from this side. */
|
|
2099
|
+
async function propagateOneDelete(eventId, deps) {
|
|
2100
|
+
const decision = planDelete(await deps.fetchEvent(eventId));
|
|
2101
|
+
if (!decision.ok) {
|
|
2102
|
+
if (decision.kind === "already-gone") {
|
|
2103
|
+
await deps.forget(eventId);
|
|
2104
|
+
return {};
|
|
2105
|
+
}
|
|
2106
|
+
return { skipped: [deleteRefusalMessage(eventId, decision)] };
|
|
2107
|
+
}
|
|
2108
|
+
try {
|
|
2109
|
+
await deps.deleteEvent(eventId, decision.etag);
|
|
2110
|
+
} catch (error) {
|
|
2111
|
+
if (!isGoogleApiError(error)) throw error;
|
|
2112
|
+
if (EVENT_ABSENT_STATUSES.includes(error.status)) {
|
|
2113
|
+
await deps.forget(eventId);
|
|
2114
|
+
return {};
|
|
2115
|
+
}
|
|
2116
|
+
if (error.status !== 412) throw error;
|
|
2117
|
+
return { skipped: [staleDeleteMessage(eventId)] };
|
|
2118
|
+
}
|
|
2119
|
+
await deps.forget(eventId);
|
|
2120
|
+
log.info("google", "calendar event deleted by push", { id: eventId });
|
|
2121
|
+
return { deleted: [eventId] };
|
|
2122
|
+
}
|
|
2123
|
+
/** Carry every local deletion, when the collection opted in.
|
|
2124
|
+
*
|
|
2125
|
+
* Sequential rather than concurrent: each one writes the shared baseline file,
|
|
2126
|
+
* and an interrupted run has to leave a state the next run resumes from rather
|
|
2127
|
+
* than a partially applied batch. */
|
|
2128
|
+
async function sweepDeletes(eventIds, propagate, deps) {
|
|
2129
|
+
if (!propagate || eventIds.length === 0) return noDeletes(eventIds.length);
|
|
2130
|
+
const sweep = noDeletes(eventIds.length);
|
|
2131
|
+
for (const eventId of eventIds) try {
|
|
2132
|
+
const one = await propagateOneDelete(eventId, deps);
|
|
2133
|
+
sweep.deleted.push(...one.deleted ?? []);
|
|
2134
|
+
sweep.skipped.push(...one.skipped ?? []);
|
|
2135
|
+
} catch (error) {
|
|
2136
|
+
sweep.errors.push(`${eventId}: could not be deleted in Google — ${require_dist.errorMessage(error)}`);
|
|
2137
|
+
}
|
|
2138
|
+
return sweep;
|
|
2139
|
+
}
|
|
1996
2140
|
/** Push one collection WITHOUT taking the calendar lock.
|
|
1997
2141
|
*
|
|
1998
2142
|
* The caller must already hold it. The scheduled push→pull cycle
|
|
@@ -2031,7 +2175,18 @@ async function pushCollectionNow(collection, workspaceRoot, deps = liveDeps) {
|
|
|
2031
2175
|
});
|
|
2032
2176
|
await saveCalendarShadow(calendarId, pushedShadow([outcome]), workspaceRoot);
|
|
2033
2177
|
}
|
|
2034
|
-
const result = tally(slug, attempts, locallyDeletedIds(shadow, records.map((record) => fieldText(record[schema.primaryKey]))).
|
|
2178
|
+
const result = tally(slug, attempts, await sweepDeletes(locallyDeletedIds(shadow, records.map((record) => fieldText(record[schema.primaryKey]))), schema.googleCalendar?.propagateDeletes === true, {
|
|
2179
|
+
fetchEvent: (eventId) => (deps.fetchEvent ?? getCalendarEvent)(accessToken, {
|
|
2180
|
+
calendarId,
|
|
2181
|
+
eventId
|
|
2182
|
+
}),
|
|
2183
|
+
deleteEvent: (eventId, ifMatch) => (deps.deleteEvent ?? deleteCalendarEvent)(accessToken, {
|
|
2184
|
+
calendarId,
|
|
2185
|
+
eventId,
|
|
2186
|
+
ifMatch
|
|
2187
|
+
}),
|
|
2188
|
+
forget: (eventId) => saveCalendarShadow(calendarId, { [eventId]: null }, workspaceRoot)
|
|
2189
|
+
}));
|
|
2035
2190
|
if (result.errors.length > 0) log.warn("google", "calendar push finished with errors", {
|
|
2036
2191
|
slug,
|
|
2037
2192
|
errors: result.errors.length
|
|
@@ -2992,6 +3147,7 @@ async function deleteDriveFile(accessToken, input) {
|
|
|
2992
3147
|
exports.ALL_DAY_END_HINT = ALL_DAY_END_HINT;
|
|
2993
3148
|
exports.CANCELLED_EVENT_STATUS = CANCELLED_EVENT_STATUS;
|
|
2994
3149
|
exports.DEFAULT_LIST_MAX_RESULTS = DEFAULT_LIST_MAX_RESULTS;
|
|
3150
|
+
exports.EVENT_ABSENT_STATUSES = EVENT_ABSENT_STATUSES;
|
|
2995
3151
|
exports.EVENT_TIME_HINT = EVENT_TIME_HINT;
|
|
2996
3152
|
exports.GOOGLE_AUTH_CANCELLED = GOOGLE_AUTH_CANCELLED;
|
|
2997
3153
|
exports.GOOGLE_CALENDARLIST_SCOPE = GOOGLE_CALENDARLIST_SCOPE;
|
|
@@ -3042,6 +3198,7 @@ exports.collectCalendarPages = collectCalendarPages;
|
|
|
3042
3198
|
exports.collectionsNeedingBackfill = collectionsNeedingBackfill;
|
|
3043
3199
|
exports.commitLinkedTokens = commitLinkedTokens;
|
|
3044
3200
|
exports.completeTask = completeTask;
|
|
3201
|
+
exports.conferenceVideoUri = conferenceVideoUri;
|
|
3045
3202
|
exports.configureGoogleHost = configureGoogleHost;
|
|
3046
3203
|
exports.conflictingFields = conflictingFields;
|
|
3047
3204
|
exports.createCalendarEvent = createCalendarEvent;
|
|
@@ -3051,6 +3208,7 @@ exports.createTask = createTask;
|
|
|
3051
3208
|
exports.deleteCalendarEvent = deleteCalendarEvent;
|
|
3052
3209
|
exports.deleteDriveFile = deleteDriveFile;
|
|
3053
3210
|
exports.deleteGoogleTokens = deleteGoogleTokens;
|
|
3211
|
+
exports.deleteRefusalMessage = deleteRefusalMessage;
|
|
3054
3212
|
exports.deleteTask = deleteTask;
|
|
3055
3213
|
exports.eventTimeHint = eventTimeHint;
|
|
3056
3214
|
exports.fieldText = fieldText;
|
|
@@ -3099,6 +3257,7 @@ exports.mergeShadow = mergeShadow;
|
|
|
3099
3257
|
exports.needsCalendarBackfill = needsCalendarBackfill;
|
|
3100
3258
|
exports.orphanedCalendarId = orphanedCalendarId;
|
|
3101
3259
|
exports.pickBoundary = pickBoundary;
|
|
3260
|
+
exports.planDelete = planDelete;
|
|
3102
3261
|
exports.planRecord = planRecord;
|
|
3103
3262
|
exports.pullProtectionFor = pullProtectionFor;
|
|
3104
3263
|
exports.pullableEvents = pullableEvents;
|
|
@@ -3116,8 +3275,10 @@ exports.resumableToken = resumableToken;
|
|
|
3116
3275
|
exports.saveCalendarShadow = saveCalendarShadow;
|
|
3117
3276
|
exports.saveCalendarSyncToken = saveCalendarSyncToken;
|
|
3118
3277
|
exports.saveGoogleTokens = saveGoogleTokens;
|
|
3278
|
+
exports.selfResponseStatus = selfResponseStatus;
|
|
3119
3279
|
exports.shadowUpdates = shadowUpdates;
|
|
3120
3280
|
exports.stateLockPath = stateLockPath;
|
|
3281
|
+
exports.sweepDeletes = sweepDeletes;
|
|
3121
3282
|
exports.syncCalendarEvents = syncCalendarEvents;
|
|
3122
3283
|
exports.syncCalendarForCollection = syncCalendarForCollection;
|
|
3123
3284
|
exports.syncCalendarGroup = syncCalendarGroup;
|