forge-openclaw-plugin 0.2.18 → 0.2.19
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 +36 -4
- package/dist/assets/{board-2KevHCI0.js → board-8L3uX7_O.js} +2 -2
- package/dist/assets/{board-2KevHCI0.js.map → board-8L3uX7_O.js.map} +1 -1
- package/dist/assets/index-Cj1IBH_w.js +36 -0
- package/dist/assets/index-Cj1IBH_w.js.map +1 -0
- package/dist/assets/index-DQT6EbuS.css +1 -0
- package/dist/assets/{motion-q19HPmWs.js → motion-1GAqqi8M.js} +2 -2
- package/dist/assets/{motion-q19HPmWs.js.map → motion-1GAqqi8M.js.map} +1 -1
- package/dist/assets/{table-BDMHBY4a.js → table-DBGlgRjk.js} +2 -2
- package/dist/assets/{table-BDMHBY4a.js.map → table-DBGlgRjk.js.map} +1 -1
- package/dist/assets/{ui-CQ_AsFs8.js → ui-iTluWjC4.js} +2 -2
- package/dist/assets/{ui-CQ_AsFs8.js.map → ui-iTluWjC4.js.map} +1 -1
- package/dist/assets/{vendor-5HifrnRK.js → vendor-BvM2F9Dp.js} +139 -84
- package/dist/assets/vendor-BvM2F9Dp.js.map +1 -0
- package/dist/assets/{viz-CQzkRnTu.js → viz-CNeunkfu.js} +2 -2
- package/dist/assets/{viz-CQzkRnTu.js.map → viz-CNeunkfu.js.map} +1 -1
- package/dist/index.html +8 -8
- package/dist/openclaw/parity.js +1 -0
- package/dist/openclaw/routes.js +7 -0
- package/dist/openclaw/tools.js +183 -16
- package/dist/server/app.js +2509 -263
- package/dist/server/managers/platform/secrets-manager.js +44 -1
- package/dist/server/managers/runtime.js +3 -1
- package/dist/server/openapi.js +2037 -172
- package/dist/server/repositories/calendar.js +1101 -0
- package/dist/server/repositories/deleted-entities.js +10 -2
- package/dist/server/repositories/notes.js +161 -28
- package/dist/server/repositories/projects.js +45 -13
- package/dist/server/repositories/rewards.js +114 -6
- package/dist/server/repositories/settings.js +47 -5
- package/dist/server/repositories/task-runs.js +46 -10
- package/dist/server/repositories/tasks.js +25 -9
- package/dist/server/repositories/weekly-reviews.js +109 -0
- package/dist/server/repositories/work-adjustments.js +105 -0
- package/dist/server/services/calendar-runtime.js +1301 -0
- package/dist/server/services/entity-crud.js +94 -3
- package/dist/server/services/projects.js +32 -8
- package/dist/server/services/reviews.js +15 -1
- package/dist/server/services/work-time.js +27 -0
- package/dist/server/types.js +934 -49
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server/migrations/006_work_adjustments.sql +14 -0
- package/server/migrations/007_weekly_review_closures.sql +17 -0
- package/server/migrations/008_calendar_execution.sql +147 -0
- package/server/migrations/009_true_calendar_events.sql +195 -0
- package/server/migrations/010_calendar_selection_state.sql +6 -0
- package/server/migrations/011_calendar_timezone_backfill.sql +11 -0
- package/server/migrations/012_work_block_ranges.sql +7 -0
- package/server/migrations/013_microsoft_local_auth_settings.sql +8 -0
- package/server/migrations/014_note_tags_and_ephemeral.sql +8 -0
- package/skills/forge-openclaw/SKILL.md +117 -11
- package/dist/assets/index-CDYW4WDH.js +0 -36
- package/dist/assets/index-CDYW4WDH.js.map +0 -1
- package/dist/assets/index-yroQr6YZ.css +0 -1
- package/dist/assets/vendor-5HifrnRK.js.map +0 -1
|
@@ -0,0 +1,1301 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { CryptoProvider, PublicClientApplication } from "@azure/msal-node";
|
|
3
|
+
import ical from "node-ical";
|
|
4
|
+
import { createDAVClient, DAVNamespaceShort } from "tsdav";
|
|
5
|
+
import { getSettings } from "../repositories/settings.js";
|
|
6
|
+
import { createCalendarConnectionRecord, deleteCalendarConnectionRecord, deleteEncryptedSecret, deleteExternalEventsForConnection, detachConnectionFromForgeEvents, getCalendarById, getCalendarConnectionById, getCalendarEventStorageRecord, getCalendarOverview, listCalendarConnections, listCalendarEventSources, listCalendars, listTaskTimeboxes, markCalendarEventSourcesSyncState, readEncryptedSecret, registerCalendarEventSourceProjection, recordCalendarActivity, storeEncryptedSecret, updateCalendarConnectionRecord, updateTaskTimebox, upsertCalendarEventRecord, upsertCalendarRecord } from "../repositories/calendar.js";
|
|
7
|
+
function isWritableCalendarCredentials(credentials) {
|
|
8
|
+
return credentials.provider !== "microsoft";
|
|
9
|
+
}
|
|
10
|
+
const GOOGLE_CALDAV_URL = "https://apidata.googleusercontent.com/caldav/v2/";
|
|
11
|
+
const GOOGLE_TOKEN_URL = "https://accounts.google.com/o/oauth2/token";
|
|
12
|
+
const APPLE_CALDAV_URL = "https://caldav.icloud.com";
|
|
13
|
+
const MICROSOFT_GRAPH_URL = "https://graph.microsoft.com/v1.0";
|
|
14
|
+
const MICROSOFT_LOGIN_URL = "https://login.microsoftonline.com";
|
|
15
|
+
const MICROSOFT_CALLBACK_PATH = "/api/v1/calendar/oauth/microsoft/callback";
|
|
16
|
+
const MICROSOFT_GRAPH_SCOPES = ["User.Read", "Calendars.Read", "offline_access"];
|
|
17
|
+
const MICROSOFT_CLIENT_ID_PATTERN = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
18
|
+
const FORGE_CALENDAR_NAME = "Forge";
|
|
19
|
+
const FORGE_CALENDAR_COLOR = "#7dd3fc";
|
|
20
|
+
const microsoftOauthSessions = new Map();
|
|
21
|
+
export class CalendarConnectionConflictError extends Error {
|
|
22
|
+
connectionId;
|
|
23
|
+
constructor(message, connectionId) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "CalendarConnectionConflictError";
|
|
26
|
+
this.connectionId = connectionId;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function requireSecretRecord(secrets, secretId) {
|
|
30
|
+
const cipherText = readEncryptedSecret(secretId);
|
|
31
|
+
if (!cipherText) {
|
|
32
|
+
throw new Error(`Missing stored secret ${secretId}`);
|
|
33
|
+
}
|
|
34
|
+
return secrets.openJson(cipherText);
|
|
35
|
+
}
|
|
36
|
+
function microsoftAuthority(tenantId) {
|
|
37
|
+
return `${MICROSOFT_LOGIN_URL}/${encodeURIComponent(tenantId || "common")}`;
|
|
38
|
+
}
|
|
39
|
+
function defaultMicrosoftRedirectUri() {
|
|
40
|
+
const port = process.env.PORT?.trim() || "4317";
|
|
41
|
+
return `http://127.0.0.1:${port}${MICROSOFT_CALLBACK_PATH}`;
|
|
42
|
+
}
|
|
43
|
+
function validateMicrosoftRedirectUri(value) {
|
|
44
|
+
let url;
|
|
45
|
+
try {
|
|
46
|
+
url = new URL(value);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw new Error("Microsoft redirect URI must be a full URL.");
|
|
50
|
+
}
|
|
51
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
52
|
+
throw new Error("Microsoft redirect URI must use http or https.");
|
|
53
|
+
}
|
|
54
|
+
if (url.pathname !== MICROSOFT_CALLBACK_PATH) {
|
|
55
|
+
throw new Error(`Microsoft redirect URI must end with ${MICROSOFT_CALLBACK_PATH}.`);
|
|
56
|
+
}
|
|
57
|
+
return url.toString();
|
|
58
|
+
}
|
|
59
|
+
function normalizeMicrosoftRedirectUri(value) {
|
|
60
|
+
const trimmed = value?.trim();
|
|
61
|
+
return validateMicrosoftRedirectUri(trimmed && trimmed.length > 0 ? trimmed : defaultMicrosoftRedirectUri());
|
|
62
|
+
}
|
|
63
|
+
function normalizeMicrosoftTenantId(value) {
|
|
64
|
+
const trimmed = value?.trim();
|
|
65
|
+
return trimmed && trimmed.length > 0 ? trimmed : "common";
|
|
66
|
+
}
|
|
67
|
+
function validateMicrosoftClientId(value) {
|
|
68
|
+
const trimmed = value.trim();
|
|
69
|
+
if (!MICROSOFT_CLIENT_ID_PATTERN.test(trimmed)) {
|
|
70
|
+
throw new Error("Microsoft client IDs must use the standard app registration GUID format.");
|
|
71
|
+
}
|
|
72
|
+
return trimmed;
|
|
73
|
+
}
|
|
74
|
+
function resolveStoredMicrosoftOAuthSettings() {
|
|
75
|
+
return getSettings().calendarProviders.microsoft;
|
|
76
|
+
}
|
|
77
|
+
function resolveMicrosoftOAuthConfig(override) {
|
|
78
|
+
const fromSettings = resolveStoredMicrosoftOAuthSettings();
|
|
79
|
+
const rawSettingsClientId = override?.clientId?.trim() ?? fromSettings.clientId.trim();
|
|
80
|
+
const settingsTenantId = normalizeMicrosoftTenantId(override?.tenantId ?? fromSettings.tenantId);
|
|
81
|
+
const settingsRedirectUri = normalizeMicrosoftRedirectUri(override?.redirectUri ?? fromSettings.redirectUri);
|
|
82
|
+
if (rawSettingsClientId.length > 0) {
|
|
83
|
+
const settingsClientId = validateMicrosoftClientId(rawSettingsClientId);
|
|
84
|
+
return {
|
|
85
|
+
clientId: settingsClientId,
|
|
86
|
+
tenantId: settingsTenantId,
|
|
87
|
+
redirectUri: settingsRedirectUri,
|
|
88
|
+
authority: microsoftAuthority(settingsTenantId),
|
|
89
|
+
source: "settings"
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const envClientId = process.env.FORGE_MICROSOFT_CLIENT_ID?.trim() ?? "";
|
|
93
|
+
const envTenantId = normalizeMicrosoftTenantId(process.env.FORGE_MICROSOFT_TENANT_ID);
|
|
94
|
+
const envRedirectUri = normalizeMicrosoftRedirectUri(process.env.FORGE_MICROSOFT_REDIRECT_URI);
|
|
95
|
+
if (envClientId.length > 0) {
|
|
96
|
+
const normalizedEnvClientId = validateMicrosoftClientId(envClientId);
|
|
97
|
+
return {
|
|
98
|
+
clientId: normalizedEnvClientId,
|
|
99
|
+
tenantId: envTenantId,
|
|
100
|
+
redirectUri: envRedirectUri,
|
|
101
|
+
authority: microsoftAuthority(envTenantId),
|
|
102
|
+
source: "env"
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
throw new Error("Microsoft sign-in is not configured yet. Open Settings -> Calendar, save the Microsoft client ID and redirect URI for this Forge runtime, then try again.");
|
|
106
|
+
}
|
|
107
|
+
function createMicrosoftPublicClient(config) {
|
|
108
|
+
return new PublicClientApplication({
|
|
109
|
+
auth: {
|
|
110
|
+
clientId: config.clientId,
|
|
111
|
+
authority: config.authority
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
function pruneMicrosoftOauthSessions() {
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
for (const [sessionId, session] of microsoftOauthSessions.entries()) {
|
|
118
|
+
if (new Date(session.expiresAt).getTime() <= now) {
|
|
119
|
+
microsoftOauthSessions.set(sessionId, { ...session, status: "expired" });
|
|
120
|
+
}
|
|
121
|
+
if (session.status === "expired" ||
|
|
122
|
+
session.status === "consumed" ||
|
|
123
|
+
new Date(session.expiresAt).getTime() <= now - 5 * 60 * 1000) {
|
|
124
|
+
microsoftOauthSessions.delete(sessionId);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function normalizeUrl(value) {
|
|
129
|
+
const url = new URL(value);
|
|
130
|
+
if (!url.pathname.endsWith("/")) {
|
|
131
|
+
url.pathname = `${url.pathname}/`;
|
|
132
|
+
}
|
|
133
|
+
return url.toString();
|
|
134
|
+
}
|
|
135
|
+
function normalizeAccountIdentity(value) {
|
|
136
|
+
return value.trim().toLowerCase();
|
|
137
|
+
}
|
|
138
|
+
function safeDisplayName(value, fallback) {
|
|
139
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
140
|
+
return value.trim();
|
|
141
|
+
}
|
|
142
|
+
return fallback;
|
|
143
|
+
}
|
|
144
|
+
function isForgeName(value) {
|
|
145
|
+
return value.trim().toLowerCase() === FORGE_CALENDAR_NAME.toLowerCase();
|
|
146
|
+
}
|
|
147
|
+
function normalizeTimezone(value) {
|
|
148
|
+
const normalized = value?.trim();
|
|
149
|
+
return normalized && normalized.length > 0 ? normalized : "UTC";
|
|
150
|
+
}
|
|
151
|
+
function buildEventIcs(payload) {
|
|
152
|
+
const dt = (value) => value.replaceAll(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
153
|
+
return [
|
|
154
|
+
"BEGIN:VCALENDAR",
|
|
155
|
+
"VERSION:2.0",
|
|
156
|
+
"PRODID:-//Forge//Calendar//EN",
|
|
157
|
+
"BEGIN:VEVENT",
|
|
158
|
+
`UID:${payload.uid}`,
|
|
159
|
+
`DTSTAMP:${dt(new Date().toISOString())}`,
|
|
160
|
+
`DTSTART:${dt(payload.startsAt)}`,
|
|
161
|
+
`DTEND:${dt(payload.endsAt)}`,
|
|
162
|
+
`SUMMARY:${payload.title}`,
|
|
163
|
+
`DESCRIPTION:${payload.description ?? ""}`,
|
|
164
|
+
"END:VEVENT",
|
|
165
|
+
"END:VCALENDAR"
|
|
166
|
+
].join("\r\n");
|
|
167
|
+
}
|
|
168
|
+
function microsoftCalendarUrl(calendarId) {
|
|
169
|
+
return `${MICROSOFT_GRAPH_URL}/me/calendars/${encodeURIComponent(calendarId)}`;
|
|
170
|
+
}
|
|
171
|
+
function microsoftEventUrl(calendarId, eventId) {
|
|
172
|
+
return `${MICROSOFT_GRAPH_URL}/me/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`;
|
|
173
|
+
}
|
|
174
|
+
function microsoftColorToHex(color) {
|
|
175
|
+
switch ((color ?? "").toLowerCase()) {
|
|
176
|
+
case "lightblue":
|
|
177
|
+
return "#7dd3fc";
|
|
178
|
+
case "lightgreen":
|
|
179
|
+
return "#86efac";
|
|
180
|
+
case "lightorange":
|
|
181
|
+
return "#fdba74";
|
|
182
|
+
case "lightgray":
|
|
183
|
+
return "#cbd5e1";
|
|
184
|
+
case "lightyellow":
|
|
185
|
+
return "#fde68a";
|
|
186
|
+
case "lightteal":
|
|
187
|
+
return "#5eead4";
|
|
188
|
+
case "lightpink":
|
|
189
|
+
return "#f9a8d4";
|
|
190
|
+
case "lightbrown":
|
|
191
|
+
return "#d6a77a";
|
|
192
|
+
case "lightred":
|
|
193
|
+
return "#fca5a5";
|
|
194
|
+
case "maxcolor":
|
|
195
|
+
return "#60a5fa";
|
|
196
|
+
case "autocolor":
|
|
197
|
+
default:
|
|
198
|
+
return FORGE_CALENDAR_COLOR;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function microsoftGraphError(statusCode, payload, fallback) {
|
|
202
|
+
if (typeof payload === "object" &&
|
|
203
|
+
payload !== null &&
|
|
204
|
+
"error" in payload &&
|
|
205
|
+
typeof payload.error === "object" &&
|
|
206
|
+
payload.error !== null &&
|
|
207
|
+
"message" in payload.error &&
|
|
208
|
+
typeof payload.error.message === "string" &&
|
|
209
|
+
payload.error.message.trim().length > 0) {
|
|
210
|
+
return new Error(payload.error.message);
|
|
211
|
+
}
|
|
212
|
+
return new Error(`${fallback} (HTTP ${statusCode})`);
|
|
213
|
+
}
|
|
214
|
+
async function fetchMicrosoftCollection(accessToken, initialUrl) {
|
|
215
|
+
const values = [];
|
|
216
|
+
let nextUrl = initialUrl;
|
|
217
|
+
while (nextUrl) {
|
|
218
|
+
const response = await fetch(nextUrl, {
|
|
219
|
+
headers: {
|
|
220
|
+
Authorization: `Bearer ${accessToken}`,
|
|
221
|
+
Accept: "application/json"
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
const payload = (await response.json());
|
|
225
|
+
if (!response.ok) {
|
|
226
|
+
throw microsoftGraphError(response.status, payload, "Microsoft Graph request failed");
|
|
227
|
+
}
|
|
228
|
+
const pageValues = Array.isArray(payload.value) ? payload.value : [];
|
|
229
|
+
values.push(...pageValues);
|
|
230
|
+
nextUrl = typeof payload["@odata.nextLink"] === "string" ? payload["@odata.nextLink"] : null;
|
|
231
|
+
}
|
|
232
|
+
return values;
|
|
233
|
+
}
|
|
234
|
+
function parseMicrosoftDateTime(value) {
|
|
235
|
+
if (!value?.dateTime || value.dateTime.trim().length === 0) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
const candidate = new Date(value.dateTime);
|
|
239
|
+
if (!Number.isNaN(candidate.getTime())) {
|
|
240
|
+
return candidate.toISOString();
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
function mapMicrosoftEventToSyncInput(calendarId, event) {
|
|
245
|
+
const startAt = parseMicrosoftDateTime(event.start);
|
|
246
|
+
const endAt = parseMicrosoftDateTime(event.end);
|
|
247
|
+
if (!startAt || !endAt) {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
calendarRemoteId: microsoftCalendarUrl(calendarId),
|
|
252
|
+
remoteId: event.id,
|
|
253
|
+
remoteHref: microsoftEventUrl(calendarId, event.id),
|
|
254
|
+
remoteEtag: null,
|
|
255
|
+
ownership: "external",
|
|
256
|
+
status: event.isCancelled ? "cancelled" : "confirmed",
|
|
257
|
+
title: typeof event.subject === "string" && event.subject.trim().length > 0
|
|
258
|
+
? event.subject
|
|
259
|
+
: "(untitled event)",
|
|
260
|
+
description: typeof event.bodyPreview === "string" ? event.bodyPreview : "",
|
|
261
|
+
location: typeof event.location?.displayName === "string" ? event.location.displayName : "",
|
|
262
|
+
startAt,
|
|
263
|
+
endAt,
|
|
264
|
+
isAllDay: Boolean(event.isAllDay),
|
|
265
|
+
availability: event.showAs === "free" ? "free" : "busy",
|
|
266
|
+
eventType: "",
|
|
267
|
+
categories: Array.isArray(event.categories) ? event.categories.filter((value) => typeof value === "string") : [],
|
|
268
|
+
rawPayload: event,
|
|
269
|
+
remoteUpdatedAt: typeof event.lastModifiedDateTime === "string" ? event.lastModifiedDateTime : null,
|
|
270
|
+
deletedAt: event.isCancelled ? new Date().toISOString() : null
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
async function createProviderClient(credentials) {
|
|
274
|
+
if (credentials.provider === "microsoft") {
|
|
275
|
+
const client = createMicrosoftPublicClient({
|
|
276
|
+
clientId: credentials.clientId,
|
|
277
|
+
authority: credentials.authority
|
|
278
|
+
});
|
|
279
|
+
await client.getTokenCache().deserialize(credentials.tokenCache);
|
|
280
|
+
const account = (await client.getTokenCache().getAccountByHomeId(credentials.homeAccountId)) ??
|
|
281
|
+
(await client.getTokenCache().getAllAccounts())[0] ??
|
|
282
|
+
null;
|
|
283
|
+
if (!account) {
|
|
284
|
+
throw new Error("Forge could not restore the Microsoft sign-in session. Reconnect Exchange Online from Settings.");
|
|
285
|
+
}
|
|
286
|
+
const token = await client.acquireTokenSilent({
|
|
287
|
+
account,
|
|
288
|
+
scopes: MICROSOFT_GRAPH_SCOPES
|
|
289
|
+
});
|
|
290
|
+
if (!token?.accessToken) {
|
|
291
|
+
throw new Error("Forge could not refresh the Microsoft session silently. Reconnect Exchange Online from Settings.");
|
|
292
|
+
}
|
|
293
|
+
const [profileResponse, primaryResponse] = await Promise.all([
|
|
294
|
+
fetch(`${MICROSOFT_GRAPH_URL}/me?$select=mail,userPrincipalName,displayName`, {
|
|
295
|
+
headers: {
|
|
296
|
+
Authorization: `Bearer ${token.accessToken}`,
|
|
297
|
+
Accept: "application/json"
|
|
298
|
+
}
|
|
299
|
+
}),
|
|
300
|
+
fetch(`${MICROSOFT_GRAPH_URL}/me/calendar?$select=id`, {
|
|
301
|
+
headers: {
|
|
302
|
+
Authorization: `Bearer ${token.accessToken}`,
|
|
303
|
+
Accept: "application/json"
|
|
304
|
+
}
|
|
305
|
+
})
|
|
306
|
+
]);
|
|
307
|
+
const profilePayload = (await profileResponse.json());
|
|
308
|
+
if (!profileResponse.ok) {
|
|
309
|
+
throw microsoftGraphError(profileResponse.status, profilePayload, "Microsoft Graph profile lookup failed");
|
|
310
|
+
}
|
|
311
|
+
const primaryPayload = (await primaryResponse.json());
|
|
312
|
+
if (!primaryResponse.ok) {
|
|
313
|
+
throw microsoftGraphError(primaryResponse.status, primaryPayload, "Microsoft Graph primary calendar lookup failed");
|
|
314
|
+
}
|
|
315
|
+
const calendars = await fetchMicrosoftCollection(token.accessToken, `${MICROSOFT_GRAPH_URL}/me/calendars?$select=id,name,color,canEdit,owner`);
|
|
316
|
+
return {
|
|
317
|
+
mode: "microsoft",
|
|
318
|
+
accessToken: token.accessToken,
|
|
319
|
+
accountLabel: safeDisplayName(profilePayload.mail, "") ||
|
|
320
|
+
safeDisplayName(profilePayload.userPrincipalName, "") ||
|
|
321
|
+
safeDisplayName(profilePayload.displayName, credentials.username),
|
|
322
|
+
serverUrl: MICROSOFT_GRAPH_URL,
|
|
323
|
+
principalUrl: `${MICROSOFT_GRAPH_URL}/me`,
|
|
324
|
+
homeUrl: null,
|
|
325
|
+
calendars,
|
|
326
|
+
primaryCalendarId: typeof primaryPayload.id === "string" && primaryPayload.id.trim().length > 0
|
|
327
|
+
? primaryPayload.id
|
|
328
|
+
: null,
|
|
329
|
+
credentials: {
|
|
330
|
+
...credentials,
|
|
331
|
+
username: safeDisplayName(profilePayload.mail, "") ||
|
|
332
|
+
safeDisplayName(profilePayload.userPrincipalName, "") ||
|
|
333
|
+
account.username ||
|
|
334
|
+
credentials.username,
|
|
335
|
+
tokenCache: client.getTokenCache().serialize()
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
const client = credentials.provider === "google"
|
|
340
|
+
? await createDAVClient({
|
|
341
|
+
serverUrl: credentials.serverUrl,
|
|
342
|
+
credentials: {
|
|
343
|
+
username: credentials.username,
|
|
344
|
+
tokenUrl: GOOGLE_TOKEN_URL,
|
|
345
|
+
refreshToken: credentials.refreshToken,
|
|
346
|
+
clientId: credentials.clientId,
|
|
347
|
+
clientSecret: credentials.clientSecret
|
|
348
|
+
},
|
|
349
|
+
authMethod: "Oauth",
|
|
350
|
+
defaultAccountType: "caldav"
|
|
351
|
+
})
|
|
352
|
+
: await createDAVClient({
|
|
353
|
+
serverUrl: credentials.serverUrl,
|
|
354
|
+
credentials: {
|
|
355
|
+
username: credentials.username,
|
|
356
|
+
password: credentials.password
|
|
357
|
+
},
|
|
358
|
+
authMethod: "Basic",
|
|
359
|
+
defaultAccountType: "caldav"
|
|
360
|
+
});
|
|
361
|
+
const account = await client.createAccount({
|
|
362
|
+
account: {
|
|
363
|
+
accountType: "caldav"
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
const calendars = await client.fetchCalendars({ account });
|
|
367
|
+
return {
|
|
368
|
+
mode: "dav",
|
|
369
|
+
client,
|
|
370
|
+
account,
|
|
371
|
+
calendars,
|
|
372
|
+
accountLabel: credentials.username,
|
|
373
|
+
serverUrl: credentials.serverUrl
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
function mapDiscoveryPayload(provider, state) {
|
|
377
|
+
if (state.mode === "microsoft") {
|
|
378
|
+
return {
|
|
379
|
+
provider,
|
|
380
|
+
accountLabel: state.accountLabel,
|
|
381
|
+
serverUrl: state.serverUrl,
|
|
382
|
+
principalUrl: state.principalUrl ?? null,
|
|
383
|
+
homeUrl: state.homeUrl ?? null,
|
|
384
|
+
calendars: state.calendars.map((calendar) => ({
|
|
385
|
+
url: microsoftCalendarUrl(calendar.id),
|
|
386
|
+
displayName: safeDisplayName(calendar.name, "Calendar"),
|
|
387
|
+
description: typeof calendar.owner?.name === "string" && calendar.owner.name.trim().length > 0
|
|
388
|
+
? `Owned by ${calendar.owner.name}`
|
|
389
|
+
: "Exchange Online calendar",
|
|
390
|
+
color: microsoftColorToHex(calendar.color),
|
|
391
|
+
timezone: "UTC",
|
|
392
|
+
isPrimary: state.primaryCalendarId === calendar.id,
|
|
393
|
+
canWrite: false,
|
|
394
|
+
selectedByDefault: true,
|
|
395
|
+
isForgeCandidate: false
|
|
396
|
+
}))
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
provider,
|
|
401
|
+
accountLabel: state.accountLabel,
|
|
402
|
+
serverUrl: state.serverUrl,
|
|
403
|
+
principalUrl: state.account.principalUrl ?? null,
|
|
404
|
+
homeUrl: state.account.homeUrl ?? null,
|
|
405
|
+
calendars: state.calendars.map((calendar, index) => {
|
|
406
|
+
const displayName = safeDisplayName(calendar.displayName, `Calendar ${index + 1}`);
|
|
407
|
+
return {
|
|
408
|
+
url: normalizeUrl(calendar.url),
|
|
409
|
+
displayName,
|
|
410
|
+
description: typeof calendar.description === "string" ? calendar.description : "",
|
|
411
|
+
color: calendar.calendarColor ?? FORGE_CALENDAR_COLOR,
|
|
412
|
+
timezone: normalizeTimezone(calendar.timezone),
|
|
413
|
+
isPrimary: false,
|
|
414
|
+
canWrite: true,
|
|
415
|
+
selectedByDefault: !isForgeName(displayName),
|
|
416
|
+
isForgeCandidate: isForgeName(displayName)
|
|
417
|
+
};
|
|
418
|
+
})
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
function toMicrosoftOauthSessionPayload(session) {
|
|
422
|
+
return {
|
|
423
|
+
sessionId: session.sessionId,
|
|
424
|
+
status: session.status,
|
|
425
|
+
authUrl: session.authUrl,
|
|
426
|
+
accountLabel: session.accountLabel,
|
|
427
|
+
error: session.error,
|
|
428
|
+
discovery: session.discovery
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
function explainMicrosoftOauthError(input) {
|
|
432
|
+
const raw = `${input.error ?? ""} ${input.errorDescription ?? ""}`.toLowerCase();
|
|
433
|
+
if (raw.includes("aadsts50011") || raw.includes("redirect_uri")) {
|
|
434
|
+
return "Microsoft rejected the redirect URI. Add the exact Forge callback URI shown in Settings -> Calendar to your app registration, save the settings again, and retry sign-in.";
|
|
435
|
+
}
|
|
436
|
+
if (raw.includes("access_denied") || raw.includes("consent")) {
|
|
437
|
+
return "Microsoft consent was denied or cancelled. Review the requested Graph permissions, then retry the guided sign-in.";
|
|
438
|
+
}
|
|
439
|
+
if (raw.includes("aadsts700016") || raw.includes("application") && raw.includes("not found")) {
|
|
440
|
+
return "Microsoft could not find this client ID in the selected tenant. Check the client ID, supported account type, and tenant setting in Settings -> Calendar.";
|
|
441
|
+
}
|
|
442
|
+
if (raw.includes("aadsts50020") || raw.includes("aadsts50194") || raw.includes("tenant")) {
|
|
443
|
+
return "This account cannot sign in with the current tenant or supported-account setup. Use `common` for a broad self-hosted flow, or change the app registration to match this account type.";
|
|
444
|
+
}
|
|
445
|
+
return input.errorDescription?.trim() || input.error?.trim() || "Microsoft sign-in could not be completed.";
|
|
446
|
+
}
|
|
447
|
+
export async function testMicrosoftCalendarOauthConfiguration(input = null) {
|
|
448
|
+
const config = resolveMicrosoftOAuthConfig(input ?? undefined);
|
|
449
|
+
const client = createMicrosoftPublicClient(config);
|
|
450
|
+
const crypto = new CryptoProvider();
|
|
451
|
+
const pkce = await crypto.generatePkceCodes();
|
|
452
|
+
await client.getAuthCodeUrl({
|
|
453
|
+
redirectUri: config.redirectUri,
|
|
454
|
+
scopes: MICROSOFT_GRAPH_SCOPES,
|
|
455
|
+
state: `forge-microsoft-test-${randomUUID()}`,
|
|
456
|
+
codeChallenge: pkce.challenge,
|
|
457
|
+
codeChallengeMethod: "S256",
|
|
458
|
+
prompt: "select_account"
|
|
459
|
+
});
|
|
460
|
+
return {
|
|
461
|
+
ok: true,
|
|
462
|
+
message: "Forge can open a local Microsoft sign-in with this client ID and redirect URI. Final verification happens when you complete the Microsoft popup and consent.",
|
|
463
|
+
normalizedConfig: {
|
|
464
|
+
clientId: config.clientId,
|
|
465
|
+
tenantId: config.tenantId,
|
|
466
|
+
redirectUri: config.redirectUri,
|
|
467
|
+
usesClientSecret: false,
|
|
468
|
+
readOnly: true
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
export async function startMicrosoftCalendarOauth(input, origin) {
|
|
473
|
+
pruneMicrosoftOauthSessions();
|
|
474
|
+
const config = resolveMicrosoftOAuthConfig();
|
|
475
|
+
const sessionId = randomUUID();
|
|
476
|
+
const redirectUri = config.redirectUri;
|
|
477
|
+
const authority = config.authority;
|
|
478
|
+
const client = createMicrosoftPublicClient(config);
|
|
479
|
+
const crypto = new CryptoProvider();
|
|
480
|
+
const pkce = await crypto.generatePkceCodes();
|
|
481
|
+
const authUrl = await client.getAuthCodeUrl({
|
|
482
|
+
redirectUri,
|
|
483
|
+
scopes: MICROSOFT_GRAPH_SCOPES,
|
|
484
|
+
state: sessionId,
|
|
485
|
+
codeChallenge: pkce.challenge,
|
|
486
|
+
codeChallengeMethod: "S256",
|
|
487
|
+
prompt: "select_account"
|
|
488
|
+
});
|
|
489
|
+
const now = new Date();
|
|
490
|
+
microsoftOauthSessions.set(sessionId, {
|
|
491
|
+
sessionId,
|
|
492
|
+
state: sessionId,
|
|
493
|
+
label: input.label?.trim() || null,
|
|
494
|
+
origin,
|
|
495
|
+
redirectUri,
|
|
496
|
+
clientId: config.clientId,
|
|
497
|
+
authority,
|
|
498
|
+
tenantId: config.tenantId,
|
|
499
|
+
codeVerifier: pkce.verifier,
|
|
500
|
+
createdAt: now.toISOString(),
|
|
501
|
+
expiresAt: new Date(now.getTime() + 15 * 60 * 1000).toISOString(),
|
|
502
|
+
status: "pending",
|
|
503
|
+
authUrl,
|
|
504
|
+
accountLabel: null,
|
|
505
|
+
error: null,
|
|
506
|
+
discovery: null,
|
|
507
|
+
credentials: null
|
|
508
|
+
});
|
|
509
|
+
return {
|
|
510
|
+
session: toMicrosoftOauthSessionPayload(microsoftOauthSessions.get(sessionId))
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
export function getMicrosoftCalendarOauthSession(sessionId) {
|
|
514
|
+
pruneMicrosoftOauthSessions();
|
|
515
|
+
const session = microsoftOauthSessions.get(sessionId);
|
|
516
|
+
if (!session) {
|
|
517
|
+
throw new Error(`Unknown Microsoft calendar auth session ${sessionId}`);
|
|
518
|
+
}
|
|
519
|
+
return {
|
|
520
|
+
session: toMicrosoftOauthSessionPayload(session)
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
export async function completeMicrosoftCalendarOauth(input) {
|
|
524
|
+
pruneMicrosoftOauthSessions();
|
|
525
|
+
const sessionId = input.state?.trim() || "";
|
|
526
|
+
const session = microsoftOauthSessions.get(sessionId);
|
|
527
|
+
if (!session) {
|
|
528
|
+
throw new Error("Unknown Microsoft calendar auth session.");
|
|
529
|
+
}
|
|
530
|
+
if (new Date(session.expiresAt).getTime() <= Date.now()) {
|
|
531
|
+
session.status = "expired";
|
|
532
|
+
session.error = "The Microsoft sign-in session expired. Start the guided sign-in again.";
|
|
533
|
+
return { session: toMicrosoftOauthSessionPayload(session) };
|
|
534
|
+
}
|
|
535
|
+
if (input.error) {
|
|
536
|
+
session.status = "error";
|
|
537
|
+
session.error = explainMicrosoftOauthError(input);
|
|
538
|
+
return { session: toMicrosoftOauthSessionPayload(session) };
|
|
539
|
+
}
|
|
540
|
+
if (!input.code) {
|
|
541
|
+
session.status = "error";
|
|
542
|
+
session.error = "Microsoft did not return an authorization code.";
|
|
543
|
+
return { session: toMicrosoftOauthSessionPayload(session) };
|
|
544
|
+
}
|
|
545
|
+
try {
|
|
546
|
+
const client = createMicrosoftPublicClient({
|
|
547
|
+
clientId: session.clientId,
|
|
548
|
+
authority: session.authority
|
|
549
|
+
});
|
|
550
|
+
if (!session.codeVerifier) {
|
|
551
|
+
throw new Error("The Microsoft sign-in session is missing its PKCE verifier. Start the sign-in again.");
|
|
552
|
+
}
|
|
553
|
+
const result = await client.acquireTokenByCode({
|
|
554
|
+
code: input.code,
|
|
555
|
+
redirectUri: session.redirectUri,
|
|
556
|
+
scopes: MICROSOFT_GRAPH_SCOPES,
|
|
557
|
+
codeVerifier: session.codeVerifier
|
|
558
|
+
});
|
|
559
|
+
const account = result?.account;
|
|
560
|
+
if (!account) {
|
|
561
|
+
throw new Error("Microsoft sign-in completed without an account profile.");
|
|
562
|
+
}
|
|
563
|
+
const provisionalCredentials = {
|
|
564
|
+
provider: "microsoft",
|
|
565
|
+
serverUrl: MICROSOFT_GRAPH_URL,
|
|
566
|
+
username: account.username || "microsoft-account",
|
|
567
|
+
clientId: session.clientId,
|
|
568
|
+
tenantId: session.tenantId,
|
|
569
|
+
authority: session.authority,
|
|
570
|
+
homeAccountId: account.homeAccountId,
|
|
571
|
+
tokenCache: client.getTokenCache().serialize(),
|
|
572
|
+
selectedCalendarUrls: []
|
|
573
|
+
};
|
|
574
|
+
const state = await createProviderClient(provisionalCredentials);
|
|
575
|
+
if (state.mode !== "microsoft") {
|
|
576
|
+
throw new Error("Forge resolved a DAV provider state for a Microsoft calendar session.");
|
|
577
|
+
}
|
|
578
|
+
const discovery = mapDiscoveryPayload("microsoft", state);
|
|
579
|
+
session.status = "authorized";
|
|
580
|
+
session.accountLabel = state.accountLabel;
|
|
581
|
+
session.discovery = discovery;
|
|
582
|
+
session.credentials = {
|
|
583
|
+
...provisionalCredentials,
|
|
584
|
+
username: state.credentials.username,
|
|
585
|
+
tokenCache: state.credentials.tokenCache
|
|
586
|
+
};
|
|
587
|
+
session.codeVerifier = null;
|
|
588
|
+
session.error = null;
|
|
589
|
+
}
|
|
590
|
+
catch (error) {
|
|
591
|
+
session.status = "error";
|
|
592
|
+
session.error =
|
|
593
|
+
error instanceof Error ? error.message : "Microsoft sign-in failed.";
|
|
594
|
+
}
|
|
595
|
+
return { session: toMicrosoftOauthSessionPayload(session), openerOrigin: session.origin };
|
|
596
|
+
}
|
|
597
|
+
async function ensureForgeCalendar(state) {
|
|
598
|
+
if (state.mode !== "dav") {
|
|
599
|
+
throw new Error("This calendar provider is read-only, so Forge cannot create a dedicated write calendar there.");
|
|
600
|
+
}
|
|
601
|
+
const existingForge = state.calendars.find((calendar) => isForgeName(safeDisplayName(calendar.displayName, "")));
|
|
602
|
+
if (existingForge) {
|
|
603
|
+
return {
|
|
604
|
+
forgeCalendarUrl: normalizeUrl(existingForge.url),
|
|
605
|
+
calendars: state.calendars
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
if (!state.account.homeUrl) {
|
|
609
|
+
throw new Error("The provider did not expose a calendar home set, so Forge could not create a dedicated calendar automatically.");
|
|
610
|
+
}
|
|
611
|
+
const existingUrls = new Set(state.calendars.map((calendar) => normalizeUrl(calendar.url)));
|
|
612
|
+
let slug = "forge";
|
|
613
|
+
let attempt = 2;
|
|
614
|
+
let nextUrl = normalizeUrl(new URL(`${slug}/`, state.account.homeUrl).toString());
|
|
615
|
+
while (existingUrls.has(nextUrl)) {
|
|
616
|
+
slug = `forge-${attempt++}`;
|
|
617
|
+
nextUrl = normalizeUrl(new URL(`${slug}/`, state.account.homeUrl).toString());
|
|
618
|
+
}
|
|
619
|
+
await state.client.makeCalendar({
|
|
620
|
+
url: nextUrl,
|
|
621
|
+
props: {
|
|
622
|
+
[`${DAVNamespaceShort.DAV}:displayname`]: {
|
|
623
|
+
_cdata: FORGE_CALENDAR_NAME
|
|
624
|
+
},
|
|
625
|
+
[`${DAVNamespaceShort.CALDAV}:calendar-description`]: {
|
|
626
|
+
_cdata: "Forge-owned work blocks and task timeboxes"
|
|
627
|
+
},
|
|
628
|
+
[`${DAVNamespaceShort.CALDAV_APPLE}:calendar-color`]: {
|
|
629
|
+
_cdata: FORGE_CALENDAR_COLOR
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
const calendars = await state.client.fetchCalendars({ account: state.account });
|
|
634
|
+
return {
|
|
635
|
+
forgeCalendarUrl: nextUrl,
|
|
636
|
+
calendars
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
function inferRemoteId(object, parsed) {
|
|
640
|
+
const uid = typeof parsed.uid === "string" ? parsed.uid : null;
|
|
641
|
+
if (uid) {
|
|
642
|
+
return uid;
|
|
643
|
+
}
|
|
644
|
+
return object.url;
|
|
645
|
+
}
|
|
646
|
+
function mapDavObjectToEvents(calendarUrl, object, ownership) {
|
|
647
|
+
const payload = typeof object.data === "string" ? object.data : "";
|
|
648
|
+
const parsed = ical.sync.parseICS(payload);
|
|
649
|
+
const events = [];
|
|
650
|
+
for (const entry of Object.values(parsed)) {
|
|
651
|
+
if (entry.type !== "VEVENT") {
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
const start = entry.start instanceof Date ? entry.start.toISOString() : null;
|
|
655
|
+
const end = entry.end instanceof Date ? entry.end.toISOString() : null;
|
|
656
|
+
if (!start || !end) {
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
events.push({
|
|
660
|
+
calendarRemoteId: calendarUrl,
|
|
661
|
+
remoteId: inferRemoteId(object, entry),
|
|
662
|
+
remoteHref: object.url,
|
|
663
|
+
remoteEtag: object.etag ?? null,
|
|
664
|
+
ownership,
|
|
665
|
+
status: entry.status === "CANCELLED"
|
|
666
|
+
? "cancelled"
|
|
667
|
+
: entry.status === "TENTATIVE"
|
|
668
|
+
? "tentative"
|
|
669
|
+
: "confirmed",
|
|
670
|
+
title: typeof entry.summary === "string" ? entry.summary : "(untitled event)",
|
|
671
|
+
description: typeof entry.description === "string" ? entry.description : "",
|
|
672
|
+
location: typeof entry.location === "string" ? entry.location : "",
|
|
673
|
+
startAt: start,
|
|
674
|
+
endAt: end,
|
|
675
|
+
isAllDay: false,
|
|
676
|
+
availability: entry.transparency === "TRANSPARENT" ? "free" : "busy",
|
|
677
|
+
eventType: "",
|
|
678
|
+
categories: Array.isArray(entry.categories)
|
|
679
|
+
? entry.categories.map((value) => String(value))
|
|
680
|
+
: typeof entry.categories === "string"
|
|
681
|
+
? [entry.categories]
|
|
682
|
+
: [],
|
|
683
|
+
rawPayload: entry,
|
|
684
|
+
remoteUpdatedAt: entry.lastmodified instanceof Date
|
|
685
|
+
? entry.lastmodified.toISOString()
|
|
686
|
+
: null,
|
|
687
|
+
deletedAt: entry.status === "CANCELLED" ? new Date().toISOString() : null
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
return events;
|
|
691
|
+
}
|
|
692
|
+
function mapCalendarRecord(calendar, options) {
|
|
693
|
+
if ("url" in calendar) {
|
|
694
|
+
const forgeCalendarUrl = options.forgeCalendarUrl ? normalizeUrl(options.forgeCalendarUrl) : null;
|
|
695
|
+
const title = safeDisplayName(calendar.displayName, "Calendar");
|
|
696
|
+
const remoteUrl = normalizeUrl(calendar.url);
|
|
697
|
+
return {
|
|
698
|
+
remoteId: remoteUrl,
|
|
699
|
+
title,
|
|
700
|
+
description: typeof calendar.description === "string" ? calendar.description : "",
|
|
701
|
+
color: calendar.calendarColor ?? FORGE_CALENDAR_COLOR,
|
|
702
|
+
timezone: normalizeTimezone(calendar.timezone),
|
|
703
|
+
isPrimary: false,
|
|
704
|
+
canWrite: true,
|
|
705
|
+
selectedForSync: forgeCalendarUrl ? remoteUrl !== forgeCalendarUrl : true,
|
|
706
|
+
forgeManaged: forgeCalendarUrl ? remoteUrl === forgeCalendarUrl : false
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
return {
|
|
710
|
+
remoteId: microsoftCalendarUrl(calendar.id),
|
|
711
|
+
title: safeDisplayName(calendar.name, "Calendar"),
|
|
712
|
+
description: typeof calendar.owner?.name === "string" && calendar.owner.name.trim().length > 0
|
|
713
|
+
? `Owned by ${calendar.owner.name}`
|
|
714
|
+
: "Exchange Online calendar",
|
|
715
|
+
color: microsoftColorToHex(calendar.color),
|
|
716
|
+
timezone: "UTC",
|
|
717
|
+
isPrimary: options.primaryCalendarId === calendar.id,
|
|
718
|
+
canWrite: false,
|
|
719
|
+
selectedForSync: true,
|
|
720
|
+
forgeManaged: false
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
async function publishTaskTimeboxes(state, forgeCalendarUrl, connectionId) {
|
|
724
|
+
if (state.mode !== "dav" || !forgeCalendarUrl) {
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
const forgeCalendar = state.calendars.find((calendar) => normalizeUrl(calendar.url) === normalizeUrl(forgeCalendarUrl));
|
|
728
|
+
if (!forgeCalendar) {
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
const horizon = {
|
|
732
|
+
from: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
|
733
|
+
to: new Date(Date.now() + 180 * 24 * 60 * 60 * 1000).toISOString()
|
|
734
|
+
};
|
|
735
|
+
const timeboxes = listTaskTimeboxes(horizon);
|
|
736
|
+
for (const timebox of timeboxes) {
|
|
737
|
+
const remoteEventId = timebox.remoteEventId ?? `forge-${timebox.id}`;
|
|
738
|
+
const iCalString = buildEventIcs({
|
|
739
|
+
uid: remoteEventId,
|
|
740
|
+
title: timebox.title,
|
|
741
|
+
startsAt: timebox.startsAt,
|
|
742
|
+
endsAt: timebox.endsAt,
|
|
743
|
+
description: timebox.overrideReason ?? ""
|
|
744
|
+
});
|
|
745
|
+
if (timebox.remoteEventId) {
|
|
746
|
+
await state.client.updateCalendarObject({
|
|
747
|
+
calendarObject: {
|
|
748
|
+
url: new URL(`${remoteEventId}.ics`, forgeCalendar.url).toString(),
|
|
749
|
+
data: iCalString
|
|
750
|
+
}
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
else {
|
|
754
|
+
await state.client.createCalendarObject({
|
|
755
|
+
calendar: forgeCalendar,
|
|
756
|
+
iCalString,
|
|
757
|
+
filename: `${remoteEventId}.ics`
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
const localForgeCalendar = listCalendars(connectionId).find((entry) => normalizeUrl(entry.remoteId) === normalizeUrl(forgeCalendar.url));
|
|
761
|
+
updateTaskTimebox(timebox.id, {
|
|
762
|
+
connectionId,
|
|
763
|
+
calendarId: localForgeCalendar?.id ?? null,
|
|
764
|
+
remoteEventId
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
async function syncDiscoveredState(connectionId, credentials) {
|
|
769
|
+
const state = await createProviderClient(credentials);
|
|
770
|
+
if (!isWritableCalendarCredentials(credentials)) {
|
|
771
|
+
if (state.mode !== "microsoft") {
|
|
772
|
+
throw new Error("Forge expected a Microsoft provider state for this calendar connection.");
|
|
773
|
+
}
|
|
774
|
+
const selected = new Set(credentials.selectedCalendarUrls.map((value) => normalizeUrl(value)));
|
|
775
|
+
for (const calendar of state.calendars) {
|
|
776
|
+
const remoteId = normalizeUrl(microsoftCalendarUrl(calendar.id));
|
|
777
|
+
upsertCalendarRecord(connectionId, {
|
|
778
|
+
...mapCalendarRecord(calendar, { primaryCalendarId: state.primaryCalendarId }),
|
|
779
|
+
selectedForSync: selected.has(remoteId)
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
const now = new Date();
|
|
783
|
+
const start = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
784
|
+
const end = new Date(now.getTime() + 180 * 24 * 60 * 60 * 1000).toISOString();
|
|
785
|
+
const selectedCalendars = state.calendars.filter((calendar) => selected.has(normalizeUrl(microsoftCalendarUrl(calendar.id))));
|
|
786
|
+
for (const calendar of selectedCalendars) {
|
|
787
|
+
const events = await fetchMicrosoftCollection(state.accessToken, `${MICROSOFT_GRAPH_URL}/me/calendars/${encodeURIComponent(calendar.id)}/calendarView?startDateTime=${encodeURIComponent(start)}&endDateTime=${encodeURIComponent(end)}`);
|
|
788
|
+
for (const event of events) {
|
|
789
|
+
const mapped = mapMicrosoftEventToSyncInput(calendar.id, event);
|
|
790
|
+
if (!mapped) {
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
upsertCalendarEventRecord(connectionId, mapped);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
return {
|
|
797
|
+
state,
|
|
798
|
+
forgeCalendarUrl: null
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
if (state.mode !== "dav") {
|
|
802
|
+
throw new Error("Forge expected a DAV provider state for this writable calendar connection.");
|
|
803
|
+
}
|
|
804
|
+
const selected = new Set(credentials.selectedCalendarUrls.map((value) => normalizeUrl(value)));
|
|
805
|
+
const forgeCalendarUrl = normalizeUrl(credentials.forgeCalendarUrl);
|
|
806
|
+
const calendarsToSync = state.calendars.filter((calendar) => {
|
|
807
|
+
const normalized = normalizeUrl(calendar.url);
|
|
808
|
+
return selected.has(normalized) || normalized === forgeCalendarUrl;
|
|
809
|
+
});
|
|
810
|
+
for (const calendar of state.calendars) {
|
|
811
|
+
const normalized = normalizeUrl(calendar.url);
|
|
812
|
+
upsertCalendarRecord(connectionId, {
|
|
813
|
+
...mapCalendarRecord(calendar, { forgeCalendarUrl }),
|
|
814
|
+
selectedForSync: selected.has(normalized)
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
const now = new Date();
|
|
818
|
+
const start = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
819
|
+
const end = new Date(now.getTime() + 180 * 24 * 60 * 60 * 1000).toISOString();
|
|
820
|
+
for (const calendar of calendarsToSync) {
|
|
821
|
+
const ownership = normalizeUrl(calendar.url) === forgeCalendarUrl ? "forge" : "external";
|
|
822
|
+
const objects = await state.client.fetchCalendarObjects({
|
|
823
|
+
calendar,
|
|
824
|
+
timeRange: {
|
|
825
|
+
start,
|
|
826
|
+
end
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
for (const object of objects) {
|
|
830
|
+
const mapped = mapDavObjectToEvents(normalizeUrl(calendar.url), object, ownership);
|
|
831
|
+
for (const event of mapped) {
|
|
832
|
+
upsertCalendarEventRecord(connectionId, event);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
return {
|
|
837
|
+
state,
|
|
838
|
+
forgeCalendarUrl
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
function toStoredCredentials(input, forgeCalendarUrl) {
|
|
842
|
+
if (input.provider === "microsoft") {
|
|
843
|
+
throw new Error("Exchange Online connections must be created from a completed Microsoft sign-in session.");
|
|
844
|
+
}
|
|
845
|
+
if (input.provider === "google") {
|
|
846
|
+
return {
|
|
847
|
+
provider: "google",
|
|
848
|
+
serverUrl: GOOGLE_CALDAV_URL,
|
|
849
|
+
username: input.username,
|
|
850
|
+
clientId: input.clientId,
|
|
851
|
+
clientSecret: input.clientSecret,
|
|
852
|
+
refreshToken: input.refreshToken,
|
|
853
|
+
selectedCalendarUrls: input.selectedCalendarUrls.map(normalizeUrl),
|
|
854
|
+
forgeCalendarUrl: normalizeUrl(forgeCalendarUrl)
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
if (input.provider === "apple") {
|
|
858
|
+
return {
|
|
859
|
+
provider: "apple",
|
|
860
|
+
serverUrl: APPLE_CALDAV_URL,
|
|
861
|
+
username: input.username,
|
|
862
|
+
password: input.password,
|
|
863
|
+
selectedCalendarUrls: input.selectedCalendarUrls.map(normalizeUrl),
|
|
864
|
+
forgeCalendarUrl: normalizeUrl(forgeCalendarUrl)
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
return {
|
|
868
|
+
provider: "caldav",
|
|
869
|
+
serverUrl: normalizeUrl(input.serverUrl),
|
|
870
|
+
username: input.username,
|
|
871
|
+
password: input.password,
|
|
872
|
+
selectedCalendarUrls: input.selectedCalendarUrls.map(normalizeUrl),
|
|
873
|
+
forgeCalendarUrl: normalizeUrl(forgeCalendarUrl)
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
function credentialsMatch(existing, incoming) {
|
|
877
|
+
if (existing.provider !== incoming.provider) {
|
|
878
|
+
return false;
|
|
879
|
+
}
|
|
880
|
+
if (existing.provider === "google" && incoming.provider === "google") {
|
|
881
|
+
return (normalizeAccountIdentity(existing.username) === normalizeAccountIdentity(incoming.username) &&
|
|
882
|
+
normalizeUrl(existing.serverUrl) === normalizeUrl(incoming.serverUrl));
|
|
883
|
+
}
|
|
884
|
+
if (existing.provider === "apple" && incoming.provider === "apple") {
|
|
885
|
+
return normalizeAccountIdentity(existing.username) === normalizeAccountIdentity(incoming.username);
|
|
886
|
+
}
|
|
887
|
+
if (existing.provider === "caldav" && incoming.provider === "caldav") {
|
|
888
|
+
return (normalizeAccountIdentity(existing.username) === normalizeAccountIdentity(incoming.username) &&
|
|
889
|
+
normalizeUrl(existing.serverUrl) === normalizeUrl(incoming.serverUrl));
|
|
890
|
+
}
|
|
891
|
+
return false;
|
|
892
|
+
}
|
|
893
|
+
function findExistingCalendarConnection(incoming, secrets) {
|
|
894
|
+
return listCalendarConnections().find((connection) => {
|
|
895
|
+
try {
|
|
896
|
+
const existing = requireSecretRecord(secrets, connection.credentialsSecretId);
|
|
897
|
+
return credentialsMatch(existing, incoming);
|
|
898
|
+
}
|
|
899
|
+
catch {
|
|
900
|
+
return false;
|
|
901
|
+
}
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
function toDiscoveryCredentials(input) {
|
|
905
|
+
if (input.provider === "microsoft") {
|
|
906
|
+
throw new Error("Exchange Online discovery now uses the guided Microsoft sign-in flow.");
|
|
907
|
+
}
|
|
908
|
+
if (input.provider === "google") {
|
|
909
|
+
return {
|
|
910
|
+
provider: "google",
|
|
911
|
+
serverUrl: GOOGLE_CALDAV_URL,
|
|
912
|
+
username: input.username,
|
|
913
|
+
clientId: input.clientId,
|
|
914
|
+
clientSecret: input.clientSecret,
|
|
915
|
+
refreshToken: input.refreshToken
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
if (input.provider === "apple") {
|
|
919
|
+
return {
|
|
920
|
+
provider: "apple",
|
|
921
|
+
serverUrl: APPLE_CALDAV_URL,
|
|
922
|
+
username: input.username,
|
|
923
|
+
password: input.password
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
return {
|
|
927
|
+
provider: "caldav",
|
|
928
|
+
serverUrl: normalizeUrl(input.serverUrl),
|
|
929
|
+
username: input.username,
|
|
930
|
+
password: input.password
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
export async function discoverCalendarConnection(input) {
|
|
934
|
+
const state = await createProviderClient(toDiscoveryCredentials(input));
|
|
935
|
+
return mapDiscoveryPayload(input.provider, state);
|
|
936
|
+
}
|
|
937
|
+
export async function discoverExistingCalendarConnection(connectionId, secrets) {
|
|
938
|
+
const connection = getCalendarConnectionById(connectionId);
|
|
939
|
+
if (!connection) {
|
|
940
|
+
throw new Error(`Unknown calendar connection ${connectionId}`);
|
|
941
|
+
}
|
|
942
|
+
const credentials = requireSecretRecord(secrets, connection.credentialsSecretId);
|
|
943
|
+
const state = await createProviderClient(credentials);
|
|
944
|
+
return mapDiscoveryPayload(connection.provider, state);
|
|
945
|
+
}
|
|
946
|
+
export async function createCalendarConnection(input, secrets, activity = { source: "ui" }) {
|
|
947
|
+
if (input.provider === "microsoft") {
|
|
948
|
+
pruneMicrosoftOauthSessions();
|
|
949
|
+
const session = microsoftOauthSessions.get(input.authSessionId);
|
|
950
|
+
if (!session || session.status !== "authorized" || !session.discovery || !session.credentials) {
|
|
951
|
+
throw new Error("Complete the Microsoft sign-in flow before saving this Exchange Online connection.");
|
|
952
|
+
}
|
|
953
|
+
const existingConnection = listCalendarConnections().find((connection) => {
|
|
954
|
+
try {
|
|
955
|
+
const existing = requireSecretRecord(secrets, connection.credentialsSecretId);
|
|
956
|
+
return (existing.provider === "microsoft" &&
|
|
957
|
+
normalizeAccountIdentity(existing.username) ===
|
|
958
|
+
normalizeAccountIdentity(session.credentials?.username ?? ""));
|
|
959
|
+
}
|
|
960
|
+
catch {
|
|
961
|
+
return false;
|
|
962
|
+
}
|
|
963
|
+
});
|
|
964
|
+
if (existingConnection) {
|
|
965
|
+
throw new CalendarConnectionConflictError(`${existingConnection.label} is already connected for ${existingConnection.accountLabel || "this account"}. Remove it first if you want to reconnect with different settings.`, existingConnection.id);
|
|
966
|
+
}
|
|
967
|
+
const secretId = `calendar_secret_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
968
|
+
const storedCredentials = {
|
|
969
|
+
...session.credentials,
|
|
970
|
+
selectedCalendarUrls: input.selectedCalendarUrls.map(normalizeUrl)
|
|
971
|
+
};
|
|
972
|
+
storeEncryptedSecret(secretId, secrets.sealJson(storedCredentials), `${input.label} ${input.provider} calendar credentials`);
|
|
973
|
+
const connection = createCalendarConnectionRecord({
|
|
974
|
+
provider: "microsoft",
|
|
975
|
+
label: input.label,
|
|
976
|
+
accountLabel: session.accountLabel ?? session.discovery.accountLabel,
|
|
977
|
+
config: {
|
|
978
|
+
serverUrl: session.discovery.serverUrl,
|
|
979
|
+
selectedCalendarCount: storedCredentials.selectedCalendarUrls.length,
|
|
980
|
+
readOnly: true,
|
|
981
|
+
writeMode: "read_only"
|
|
982
|
+
},
|
|
983
|
+
credentialsSecretId: secretId
|
|
984
|
+
});
|
|
985
|
+
await syncCalendarConnection(connection.id, secrets, activity);
|
|
986
|
+
session.status = "consumed";
|
|
987
|
+
recordCalendarActivity("calendar_connection_created", "calendar_connection", connection.id, `Calendar connection created: ${connection.label}`, "Exchange Online is now connected to Forge through Microsoft sign-in in read-only mode.", activity, { provider: input.provider });
|
|
988
|
+
return getCalendarConnectionById(connection.id);
|
|
989
|
+
}
|
|
990
|
+
const discoveryCredentials = toDiscoveryCredentials(input);
|
|
991
|
+
const existingConnection = findExistingCalendarConnection(discoveryCredentials, secrets);
|
|
992
|
+
if (existingConnection) {
|
|
993
|
+
throw new CalendarConnectionConflictError(`${existingConnection.label} is already connected for ${existingConnection.accountLabel || "this account"}. Remove it first if you want to reconnect with different settings.`, existingConnection.id);
|
|
994
|
+
}
|
|
995
|
+
const state = await createProviderClient(discoveryCredentials);
|
|
996
|
+
if (state.mode !== "dav") {
|
|
997
|
+
throw new Error("Forge expected a writable DAV provider state for this calendar connection.");
|
|
998
|
+
}
|
|
999
|
+
const discovery = mapDiscoveryPayload(input.provider, state);
|
|
1000
|
+
let forgeCalendarUrl = null;
|
|
1001
|
+
forgeCalendarUrl =
|
|
1002
|
+
input.forgeCalendarUrl?.trim() ||
|
|
1003
|
+
discovery.calendars.find((calendar) => calendar.isForgeCandidate)?.url ||
|
|
1004
|
+
null;
|
|
1005
|
+
if (!forgeCalendarUrl && input.createForgeCalendar) {
|
|
1006
|
+
const created = await ensureForgeCalendar(state);
|
|
1007
|
+
forgeCalendarUrl = created.forgeCalendarUrl;
|
|
1008
|
+
}
|
|
1009
|
+
if (!forgeCalendarUrl) {
|
|
1010
|
+
throw new Error("Select the calendar Forge should write into, or create a new calendar named Forge.");
|
|
1011
|
+
}
|
|
1012
|
+
const secretId = `calendar_secret_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
1013
|
+
const storedCredentials = toStoredCredentials(input, forgeCalendarUrl);
|
|
1014
|
+
storeEncryptedSecret(secretId, secrets.sealJson(storedCredentials), `${input.label} ${input.provider} calendar credentials`);
|
|
1015
|
+
const connection = createCalendarConnectionRecord({
|
|
1016
|
+
provider: input.provider,
|
|
1017
|
+
label: input.label,
|
|
1018
|
+
accountLabel: discovery.accountLabel,
|
|
1019
|
+
config: {
|
|
1020
|
+
serverUrl: discovery.serverUrl,
|
|
1021
|
+
selectedCalendarCount: storedCredentials.selectedCalendarUrls.length,
|
|
1022
|
+
forgeCalendarUrl: normalizeUrl(storedCredentials.forgeCalendarUrl)
|
|
1023
|
+
},
|
|
1024
|
+
credentialsSecretId: secretId
|
|
1025
|
+
});
|
|
1026
|
+
await syncCalendarConnection(connection.id, secrets, activity);
|
|
1027
|
+
recordCalendarActivity("calendar_connection_created", "calendar_connection", connection.id, `Calendar connection created: ${connection.label}`, `${input.provider === "apple" ? "Apple Calendar" : input.provider === "google" ? "Google Calendar" : "Custom CalDAV"} is now connected to Forge.`, activity, { provider: input.provider });
|
|
1028
|
+
return getCalendarConnectionById(connection.id);
|
|
1029
|
+
}
|
|
1030
|
+
export async function removeCalendarConnection(connectionId, secrets, activity = { source: "ui" }) {
|
|
1031
|
+
const connection = getCalendarConnectionById(connectionId);
|
|
1032
|
+
if (!connection) {
|
|
1033
|
+
return undefined;
|
|
1034
|
+
}
|
|
1035
|
+
deleteExternalEventsForConnection(connectionId);
|
|
1036
|
+
detachConnectionFromForgeEvents(connectionId);
|
|
1037
|
+
deleteCalendarConnectionRecord(connectionId);
|
|
1038
|
+
deleteEncryptedSecret(connection.credentialsSecretId);
|
|
1039
|
+
recordCalendarActivity("calendar_connection_deleted", "calendar_connection", connectionId, `Calendar connection removed: ${connection.label}`, "The provider connection was removed. Mirrored external events were removed, while Forge-native calendar records stayed local.", activity, { provider: connection.provider });
|
|
1040
|
+
return connection;
|
|
1041
|
+
}
|
|
1042
|
+
export async function syncCalendarConnection(connectionId, secrets, activity = { source: "system" }) {
|
|
1043
|
+
const connection = getCalendarConnectionById(connectionId);
|
|
1044
|
+
if (!connection) {
|
|
1045
|
+
throw new Error(`Unknown calendar connection ${connectionId}`);
|
|
1046
|
+
}
|
|
1047
|
+
try {
|
|
1048
|
+
const credentials = requireSecretRecord(secrets, connection.credentialsSecretId);
|
|
1049
|
+
const { state, forgeCalendarUrl } = await syncDiscoveredState(connectionId, credentials);
|
|
1050
|
+
if (state.mode === "microsoft") {
|
|
1051
|
+
storeEncryptedSecret(connection.credentialsSecretId, secrets.sealJson(state.credentials), `${connection.label} ${connection.provider} calendar credentials`);
|
|
1052
|
+
}
|
|
1053
|
+
const forgeCalendar = forgeCalendarUrl
|
|
1054
|
+
? listCalendars(connectionId).find((entry) => normalizeUrl(entry.remoteId) === normalizeUrl(forgeCalendarUrl))
|
|
1055
|
+
: null;
|
|
1056
|
+
updateCalendarConnectionRecord(connectionId, {
|
|
1057
|
+
accountLabel: state.accountLabel,
|
|
1058
|
+
forgeCalendarId: forgeCalendar?.id ?? null,
|
|
1059
|
+
status: "connected",
|
|
1060
|
+
config: {
|
|
1061
|
+
serverUrl: credentials.serverUrl,
|
|
1062
|
+
selectedCalendarCount: credentials.selectedCalendarUrls.length,
|
|
1063
|
+
...(credentials.provider === "microsoft"
|
|
1064
|
+
? {
|
|
1065
|
+
readOnly: true,
|
|
1066
|
+
tenantId: credentials.tenantId,
|
|
1067
|
+
writeMode: "read_only"
|
|
1068
|
+
}
|
|
1069
|
+
: {
|
|
1070
|
+
forgeCalendarUrl: normalizeUrl(credentials.forgeCalendarUrl)
|
|
1071
|
+
})
|
|
1072
|
+
},
|
|
1073
|
+
lastSyncedAt: new Date().toISOString(),
|
|
1074
|
+
lastSyncError: null
|
|
1075
|
+
});
|
|
1076
|
+
await publishTaskTimeboxes(state, forgeCalendarUrl, connectionId);
|
|
1077
|
+
recordCalendarActivity("calendar_connection_synced", "calendar_connection", connectionId, `Calendar synced: ${connection.label}`, credentials.provider === "microsoft"
|
|
1078
|
+
? "Exchange Online calendars were mirrored into Forge in read-only mode."
|
|
1079
|
+
: "Provider events and Forge timeboxes were synchronized.", activity);
|
|
1080
|
+
return getCalendarConnectionById(connectionId);
|
|
1081
|
+
}
|
|
1082
|
+
catch (error) {
|
|
1083
|
+
updateCalendarConnectionRecord(connectionId, {
|
|
1084
|
+
status: "error",
|
|
1085
|
+
lastSyncError: error instanceof Error ? error.message : "Calendar sync failed"
|
|
1086
|
+
});
|
|
1087
|
+
throw error;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
export async function updateCalendarConnectionSelection(connectionId, input, secrets, activity = { source: "ui" }) {
|
|
1091
|
+
const connection = getCalendarConnectionById(connectionId);
|
|
1092
|
+
if (!connection) {
|
|
1093
|
+
throw new Error(`Unknown calendar connection ${connectionId}`);
|
|
1094
|
+
}
|
|
1095
|
+
const credentials = requireSecretRecord(secrets, connection.credentialsSecretId);
|
|
1096
|
+
if (input.selectedCalendarUrls) {
|
|
1097
|
+
const state = await createProviderClient(credentials);
|
|
1098
|
+
const discoveredUrls = new Set(state.mode === "microsoft"
|
|
1099
|
+
? state.calendars.map((calendar) => normalizeUrl(microsoftCalendarUrl(calendar.id)))
|
|
1100
|
+
: state.calendars.map((calendar) => normalizeUrl(calendar.url)));
|
|
1101
|
+
const nextSelectedCalendarUrls = Array.from(new Set(input.selectedCalendarUrls.map((value) => normalizeUrl(value))));
|
|
1102
|
+
for (const url of nextSelectedCalendarUrls) {
|
|
1103
|
+
if (!discoveredUrls.has(url)) {
|
|
1104
|
+
throw new Error(`Calendar ${url} is not available for this connection.`);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
credentials.selectedCalendarUrls = nextSelectedCalendarUrls;
|
|
1108
|
+
storeEncryptedSecret(connection.credentialsSecretId, secrets.sealJson(credentials), `${input.label ?? connection.label} ${connection.provider} calendar credentials`);
|
|
1109
|
+
}
|
|
1110
|
+
if (input.label) {
|
|
1111
|
+
updateCalendarConnectionRecord(connectionId, { label: input.label });
|
|
1112
|
+
}
|
|
1113
|
+
if (input.selectedCalendarUrls) {
|
|
1114
|
+
await syncCalendarConnection(connectionId, secrets, activity);
|
|
1115
|
+
}
|
|
1116
|
+
recordCalendarActivity("calendar_connection_updated", "calendar_connection", connectionId, `Calendar connection updated: ${input.label ?? connection.label}`, input.selectedCalendarUrls
|
|
1117
|
+
? "Calendar mirroring preferences were updated."
|
|
1118
|
+
: "Calendar connection details were updated.", activity, {
|
|
1119
|
+
provider: connection.provider,
|
|
1120
|
+
selectedCalendarCount: input.selectedCalendarUrls?.length ?? credentials.selectedCalendarUrls.length
|
|
1121
|
+
});
|
|
1122
|
+
return getCalendarConnectionById(connectionId);
|
|
1123
|
+
}
|
|
1124
|
+
export function readCalendarOverview(query) {
|
|
1125
|
+
return getCalendarOverview(query);
|
|
1126
|
+
}
|
|
1127
|
+
async function resolveProviderStateForConnection(connectionId, secrets) {
|
|
1128
|
+
const connection = getCalendarConnectionById(connectionId);
|
|
1129
|
+
if (!connection) {
|
|
1130
|
+
throw new Error(`Unknown calendar connection ${connectionId}`);
|
|
1131
|
+
}
|
|
1132
|
+
const credentials = requireSecretRecord(secrets, connection.credentialsSecretId);
|
|
1133
|
+
const state = await createProviderClient(credentials);
|
|
1134
|
+
return { connection, state };
|
|
1135
|
+
}
|
|
1136
|
+
function resolveDavCalendarFromLocalId(state, localCalendarId, connectionId) {
|
|
1137
|
+
if (state.mode !== "dav") {
|
|
1138
|
+
return null;
|
|
1139
|
+
}
|
|
1140
|
+
if (!localCalendarId) {
|
|
1141
|
+
return null;
|
|
1142
|
+
}
|
|
1143
|
+
const localCalendar = getCalendarById(localCalendarId);
|
|
1144
|
+
if (!localCalendar || localCalendar.connectionId !== connectionId) {
|
|
1145
|
+
return null;
|
|
1146
|
+
}
|
|
1147
|
+
return (state.calendars.find((entry) => normalizeUrl(entry.url) === normalizeUrl(localCalendar.remoteId)) ?? null);
|
|
1148
|
+
}
|
|
1149
|
+
export async function syncForgeCalendarEvent(eventId, secrets) {
|
|
1150
|
+
const event = getCalendarEventStorageRecord(eventId);
|
|
1151
|
+
if (!event || event.deleted_at) {
|
|
1152
|
+
throw new Error(`Unknown calendar event ${eventId}`);
|
|
1153
|
+
}
|
|
1154
|
+
const sourceMappings = listCalendarEventSources(eventId).filter((source) => source.connectionId && source.calendarId && source.syncState !== "deleted");
|
|
1155
|
+
if (sourceMappings.length > 0) {
|
|
1156
|
+
for (const source of sourceMappings) {
|
|
1157
|
+
if (!source.calendarId) {
|
|
1158
|
+
continue;
|
|
1159
|
+
}
|
|
1160
|
+
const { connection, state } = await resolveProviderStateForConnection(source.connectionId, secrets);
|
|
1161
|
+
if (state.mode !== "dav") {
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
1164
|
+
const calendar = resolveDavCalendarFromLocalId(state, source.calendarId, connection.id);
|
|
1165
|
+
const localCalendar = getCalendarById(source.calendarId);
|
|
1166
|
+
if (!calendar || localCalendar?.canWrite === false) {
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
const remoteUrl = source.remoteHref ??
|
|
1170
|
+
new URL(`${source.remoteEventId}.ics`, calendar.url).toString();
|
|
1171
|
+
await state.client.updateCalendarObject({
|
|
1172
|
+
calendarObject: {
|
|
1173
|
+
url: remoteUrl,
|
|
1174
|
+
etag: source.remoteEtag ?? undefined,
|
|
1175
|
+
data: buildEventIcs({
|
|
1176
|
+
uid: source.remoteUid ?? event.id,
|
|
1177
|
+
title: event.title,
|
|
1178
|
+
startsAt: event.start_at,
|
|
1179
|
+
endsAt: event.end_at,
|
|
1180
|
+
description: event.description
|
|
1181
|
+
})
|
|
1182
|
+
}
|
|
1183
|
+
});
|
|
1184
|
+
registerCalendarEventSourceProjection({
|
|
1185
|
+
forgeEventId: eventId,
|
|
1186
|
+
provider: connection.provider,
|
|
1187
|
+
connectionId: connection.id,
|
|
1188
|
+
calendarId: source.calendarId,
|
|
1189
|
+
remoteCalendarId: getCalendarById(source.calendarId)?.remoteId ?? null,
|
|
1190
|
+
remoteEventId: source.remoteEventId,
|
|
1191
|
+
remoteUid: source.remoteUid ?? event.id,
|
|
1192
|
+
recurrenceInstanceId: source.recurrenceInstanceId,
|
|
1193
|
+
isMasterRecurring: source.isMasterRecurring,
|
|
1194
|
+
remoteHref: remoteUrl,
|
|
1195
|
+
remoteEtag: source.remoteEtag,
|
|
1196
|
+
syncState: "synced",
|
|
1197
|
+
rawPayloadJson: JSON.stringify({ uid: source.remoteUid ?? event.id }),
|
|
1198
|
+
lastSyncedAt: new Date().toISOString()
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
if (!event.preferred_connection_id || !event.preferred_calendar_id) {
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
const { connection, state } = await resolveProviderStateForConnection(event.preferred_connection_id, secrets);
|
|
1207
|
+
if (state.mode !== "dav") {
|
|
1208
|
+
throw new Error(`Connection ${connection.id} is read-only, so Forge cannot publish this event there.`);
|
|
1209
|
+
}
|
|
1210
|
+
const calendar = resolveDavCalendarFromLocalId(state, event.preferred_calendar_id, connection.id);
|
|
1211
|
+
const localCalendar = getCalendarById(event.preferred_calendar_id);
|
|
1212
|
+
if (!calendar || localCalendar?.canWrite === false) {
|
|
1213
|
+
throw new Error(`Unknown remote calendar for event ${eventId}`);
|
|
1214
|
+
}
|
|
1215
|
+
const filename = `${event.id}.ics`;
|
|
1216
|
+
const remoteUrl = new URL(filename, calendar.url).toString();
|
|
1217
|
+
await state.client.createCalendarObject({
|
|
1218
|
+
calendar,
|
|
1219
|
+
iCalString: buildEventIcs({
|
|
1220
|
+
uid: event.id,
|
|
1221
|
+
title: event.title,
|
|
1222
|
+
startsAt: event.start_at,
|
|
1223
|
+
endsAt: event.end_at,
|
|
1224
|
+
description: event.description
|
|
1225
|
+
}),
|
|
1226
|
+
filename
|
|
1227
|
+
});
|
|
1228
|
+
registerCalendarEventSourceProjection({
|
|
1229
|
+
forgeEventId: eventId,
|
|
1230
|
+
provider: connection.provider,
|
|
1231
|
+
connectionId: connection.id,
|
|
1232
|
+
calendarId: event.preferred_calendar_id,
|
|
1233
|
+
remoteCalendarId: getCalendarById(event.preferred_calendar_id)?.remoteId ?? null,
|
|
1234
|
+
remoteEventId: event.id,
|
|
1235
|
+
remoteUid: event.id,
|
|
1236
|
+
remoteHref: remoteUrl,
|
|
1237
|
+
syncState: "synced",
|
|
1238
|
+
rawPayloadJson: JSON.stringify({ uid: event.id }),
|
|
1239
|
+
lastSyncedAt: new Date().toISOString()
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
export async function pushCalendarEventUpdate(eventId, secrets) {
|
|
1243
|
+
await syncForgeCalendarEvent(eventId, secrets);
|
|
1244
|
+
}
|
|
1245
|
+
export async function deleteCalendarEventProjection(eventId, secrets) {
|
|
1246
|
+
const sources = listCalendarEventSources(eventId).filter((source) => source.connectionId && source.calendarId && source.syncState !== "deleted");
|
|
1247
|
+
for (const source of sources) {
|
|
1248
|
+
if (!source.calendarId) {
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
1251
|
+
const { connection, state } = await resolveProviderStateForConnection(source.connectionId, secrets);
|
|
1252
|
+
if (state.mode !== "dav") {
|
|
1253
|
+
continue;
|
|
1254
|
+
}
|
|
1255
|
+
const calendar = resolveDavCalendarFromLocalId(state, source.calendarId, connection.id);
|
|
1256
|
+
const localCalendar = source.calendarId ? getCalendarById(source.calendarId) : null;
|
|
1257
|
+
if (!calendar || localCalendar?.canWrite === false) {
|
|
1258
|
+
continue;
|
|
1259
|
+
}
|
|
1260
|
+
const remoteUrl = source.remoteHref ??
|
|
1261
|
+
new URL(`${source.remoteEventId}.ics`, calendar.url).toString();
|
|
1262
|
+
await state.client.deleteCalendarObject({
|
|
1263
|
+
calendarObject: {
|
|
1264
|
+
url: remoteUrl,
|
|
1265
|
+
etag: source.remoteEtag ?? undefined
|
|
1266
|
+
}
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
markCalendarEventSourcesSyncState(eventId, "deleted");
|
|
1270
|
+
}
|
|
1271
|
+
export function listCalendarProviderMetadata() {
|
|
1272
|
+
return [
|
|
1273
|
+
{
|
|
1274
|
+
provider: "google",
|
|
1275
|
+
label: "Google Calendar",
|
|
1276
|
+
supportsDedicatedForgeCalendar: true,
|
|
1277
|
+
connectionHelp: "Use your Google email, client credentials, and refresh token. Forge discovers calendars and can create or reuse a Forge calendar automatically."
|
|
1278
|
+
},
|
|
1279
|
+
{
|
|
1280
|
+
provider: "apple",
|
|
1281
|
+
label: "Apple Calendar",
|
|
1282
|
+
supportsDedicatedForgeCalendar: true,
|
|
1283
|
+
connectionHelp: "Use your Apple ID email and an app-specific password. Forge starts from https://caldav.icloud.com and discovers the calendars for you."
|
|
1284
|
+
},
|
|
1285
|
+
{
|
|
1286
|
+
provider: "microsoft",
|
|
1287
|
+
label: "Exchange Online",
|
|
1288
|
+
supportsDedicatedForgeCalendar: false,
|
|
1289
|
+
connectionHelp: "Configure the Microsoft client ID and redirect URI in Settings first, then use the guided Microsoft sign-in flow. Forge mirrors the selected Exchange calendars in read-only mode for now."
|
|
1290
|
+
},
|
|
1291
|
+
{
|
|
1292
|
+
provider: "caldav",
|
|
1293
|
+
label: "Custom CalDAV",
|
|
1294
|
+
supportsDedicatedForgeCalendar: true,
|
|
1295
|
+
connectionHelp: "Use a CalDAV base server URL plus account credentials. Forge discovers the calendars available under that account before you pick what to sync."
|
|
1296
|
+
}
|
|
1297
|
+
];
|
|
1298
|
+
}
|
|
1299
|
+
export function listConnectedCalendarConnections() {
|
|
1300
|
+
return listCalendarConnections().map(({ credentialsSecretId: _secret, ...connection }) => connection);
|
|
1301
|
+
}
|