forge-openclaw-plugin 0.2.27 → 0.2.29
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 +2 -1
- package/dist/assets/{board-C6jCchjI.js → board-q8cfwaAW.js} +2 -2
- package/dist/assets/{board-C6jCchjI.js.map → board-q8cfwaAW.js.map} +1 -1
- package/dist/assets/index-C6PCeHD_.css +1 -0
- package/dist/assets/index-bfHIqj0-.js +85 -0
- package/dist/assets/index-bfHIqj0-.js.map +1 -0
- package/dist/assets/{motion-DFHrH2rd.js → motion-DHfqFntt.js} +2 -2
- package/dist/assets/{motion-DFHrH2rd.js.map → motion-DHfqFntt.js.map} +1 -1
- package/dist/assets/{table-ZL7Di_u3.js → table-DLweENXt.js} +2 -2
- package/dist/assets/{table-ZL7Di_u3.js.map → table-DLweENXt.js.map} +1 -1
- package/dist/assets/{ui-CKNPpz7q.js → ui-BV0OYxkH.js} +2 -2
- package/dist/assets/{ui-CKNPpz7q.js.map → ui-BV0OYxkH.js.map} +1 -1
- package/dist/assets/{vendor-DoNZuFhn.js → vendor-OwcH20PM.js} +204 -204
- package/dist/assets/vendor-OwcH20PM.js.map +1 -0
- package/dist/index.html +7 -7
- package/dist/server/server/migrations/044_macos_local_calendar_provider.sql +21 -0
- package/dist/server/server/src/app.js +331 -14
- package/dist/server/server/src/openapi.js +828 -3
- package/dist/server/server/src/repositories/calendar.js +295 -12
- package/dist/server/server/src/repositories/tasks.js +36 -17
- package/dist/server/server/src/services/calendar-runtime.js +613 -32
- package/dist/server/server/src/services/life-force-model.js +20 -0
- package/dist/server/server/src/services/life-force.js +1333 -97
- package/dist/server/server/src/services/macos-calendar-helper.js +748 -0
- package/dist/server/server/src/types.js +67 -3
- package/dist/server/src/lib/api-error.js +2 -0
- package/dist/server/src/lib/api.js +39 -2
- package/dist/server/src/lib/calendar-name-deduper.js +2 -0
- package/dist/server/src/lib/snapshot-normalizer.js +2 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server/migrations/044_macos_local_calendar_provider.sql +21 -0
- package/skills/forge-openclaw/SKILL.md +38 -5
- package/skills/forge-openclaw/entity_conversation_playbooks.md +326 -5
- package/skills/forge-openclaw/psyche_entity_playbooks.md +57 -0
- package/dist/assets/index-DVvS8iiU.css +0 -1
- package/dist/assets/index-zYB-9Dfo.js +0 -85
- package/dist/assets/index-zYB-9Dfo.js.map +0 -1
- package/dist/assets/vendor-DoNZuFhn.js.map +0 -1
|
@@ -2,13 +2,21 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { CryptoProvider, PublicClientApplication } from "@azure/msal-node";
|
|
3
3
|
import ical from "node-ical";
|
|
4
4
|
import { logForgeDebug } from "../debug.js";
|
|
5
|
+
import { buildMacOSLocalCalendarUrl, deleteMacOSLocalEvent, discoverMacOSLocalCalendars, ensureMacOSLocalForgeCalendar, getMacOSCalendarAuthStatus, openMacOSCalendarPrivacySettings, parseMacOSLocalCalendarUrl, requestMacOSCalendarAccess, upsertMacOSLocalEvent, listMacOSLocalEvents } from "./macos-calendar-helper.js";
|
|
5
6
|
import { getGoogleCalendarOauthCallbackPath, isGoogleCalendarOriginAllowed, isGoogleCalendarLoopbackOrigin, resolveGoogleCalendarOauthPrivateConfig } from "./google-calendar-oauth-config.js";
|
|
6
7
|
import { createDAVClient, DAVNamespaceShort } from "tsdav";
|
|
7
8
|
import { getSettings } from "../repositories/settings.js";
|
|
8
|
-
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";
|
|
9
|
+
import { createCalendarConnectionRecord, deleteCalendarConnectionRecord, deleteEncryptedSecret, deleteExternalEventsForConnection, detachConnectionFromForgeEvents, getCalendarById, getCalendarConnectionById, getCalendarEventStorageRecord, getCalendarOverview, isSupersededCalendarConnection, listCalendarConnections, listCalendarEventSources, listCalendars, listTaskTimeboxes, markCalendarEventSourcesSyncState, readEncryptedSecret, registerCalendarEventSourceProjection, recordCalendarActivity, storeEncryptedSecret, rehomeCalendarConnectionReferences, updateCalendarConnectionRecord, updateTaskTimebox, upsertCalendarEventRecord, upsertCalendarRecord } from "../repositories/calendar.js";
|
|
9
10
|
function isWritableCalendarCredentials(credentials) {
|
|
10
11
|
return credentials.provider !== "microsoft";
|
|
11
12
|
}
|
|
13
|
+
function normalizeOptionalUrl(value) {
|
|
14
|
+
if (typeof value !== "string") {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
const trimmed = value.trim();
|
|
18
|
+
return trimmed.length > 0 ? normalizeUrl(trimmed) : null;
|
|
19
|
+
}
|
|
12
20
|
const GOOGLE_CALDAV_URL = "https://apidata.googleusercontent.com/caldav/v2/";
|
|
13
21
|
const GOOGLE_OAUTH_AUTHORIZE_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
14
22
|
const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
@@ -41,6 +49,14 @@ export class CalendarConnectionConflictError extends Error {
|
|
|
41
49
|
this.connectionId = connectionId;
|
|
42
50
|
}
|
|
43
51
|
}
|
|
52
|
+
export class CalendarConnectionOverlapError extends Error {
|
|
53
|
+
connectionIds;
|
|
54
|
+
constructor(message, connectionIds) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = "CalendarConnectionOverlapError";
|
|
57
|
+
this.connectionIds = connectionIds;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
44
60
|
function requireSecretRecord(secrets, secretId) {
|
|
45
61
|
const cipherText = readEncryptedSecret(secretId);
|
|
46
62
|
if (!cipherText) {
|
|
@@ -349,6 +365,13 @@ function normalizeUrl(value) {
|
|
|
349
365
|
function normalizeAccountIdentity(value) {
|
|
350
366
|
return value.trim().toLowerCase();
|
|
351
367
|
}
|
|
368
|
+
function normalizeCalendarKey(value) {
|
|
369
|
+
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
|
370
|
+
}
|
|
371
|
+
function accountIdentityKeyForMacOSSource(input) {
|
|
372
|
+
const normalizedTitle = normalizeAccountIdentity(input.sourceTitle);
|
|
373
|
+
return `${input.sourceType}:${normalizedTitle}`;
|
|
374
|
+
}
|
|
352
375
|
function buildGoogleCalendarCollectionUrl(calendarId) {
|
|
353
376
|
return normalizeUrl(new URL(`${encodeURIComponent(calendarId)}/events`, GOOGLE_CALDAV_URL).toString());
|
|
354
377
|
}
|
|
@@ -691,6 +714,29 @@ function mapMicrosoftEventToSyncInput(calendarId, event) {
|
|
|
691
714
|
};
|
|
692
715
|
}
|
|
693
716
|
async function createProviderClient(credentials) {
|
|
717
|
+
if (credentials.provider === "macos_local") {
|
|
718
|
+
const discovery = await discoverMacOSLocalCalendars();
|
|
719
|
+
const source = discovery.sources.find((entry) => entry.sourceId === credentials.sourceId);
|
|
720
|
+
if (!source) {
|
|
721
|
+
throw new Error("Forge could not find that macOS calendar source anymore. Reconnect it from Settings -> Calendar.");
|
|
722
|
+
}
|
|
723
|
+
return {
|
|
724
|
+
mode: "macos_local",
|
|
725
|
+
accountLabel: source.accountLabel,
|
|
726
|
+
serverUrl: "forge-macos-local://eventkit/",
|
|
727
|
+
principalUrl: null,
|
|
728
|
+
homeUrl: null,
|
|
729
|
+
sourceId: source.sourceId,
|
|
730
|
+
sourceTitle: source.sourceTitle,
|
|
731
|
+
sourceType: source.sourceType,
|
|
732
|
+
accountIdentityKey: credentials.accountIdentityKey,
|
|
733
|
+
calendars: source.calendars.map((calendar) => ({
|
|
734
|
+
...calendar,
|
|
735
|
+
url: buildMacOSLocalCalendarUrl(source.sourceId, calendar.calendarId)
|
|
736
|
+
})),
|
|
737
|
+
credentials
|
|
738
|
+
};
|
|
739
|
+
}
|
|
694
740
|
if (credentials.provider === "microsoft") {
|
|
695
741
|
const client = createMicrosoftPublicClient({
|
|
696
742
|
clientId: credentials.clientId,
|
|
@@ -815,6 +861,32 @@ async function createProviderClient(credentials) {
|
|
|
815
861
|
};
|
|
816
862
|
}
|
|
817
863
|
function mapDiscoveryPayload(provider, state) {
|
|
864
|
+
if (state.mode === "macos_local") {
|
|
865
|
+
return {
|
|
866
|
+
provider,
|
|
867
|
+
accountLabel: state.accountLabel,
|
|
868
|
+
serverUrl: state.serverUrl,
|
|
869
|
+
principalUrl: null,
|
|
870
|
+
homeUrl: null,
|
|
871
|
+
calendars: state.calendars.map((calendar) => ({
|
|
872
|
+
url: calendar.url,
|
|
873
|
+
displayName: safeDisplayName(calendar.title, "Calendar"),
|
|
874
|
+
description: calendar.description,
|
|
875
|
+
color: calendar.color,
|
|
876
|
+
timezone: normalizeTimezone(calendar.timezone),
|
|
877
|
+
isPrimary: calendar.isPrimary,
|
|
878
|
+
canWrite: calendar.canWrite,
|
|
879
|
+
selectedByDefault: !isForgeName(calendar.title),
|
|
880
|
+
isForgeCandidate: isForgeName(calendar.title),
|
|
881
|
+
sourceId: calendar.sourceId,
|
|
882
|
+
sourceTitle: calendar.sourceTitle,
|
|
883
|
+
sourceType: calendar.sourceType,
|
|
884
|
+
calendarType: calendar.calendarType,
|
|
885
|
+
hostCalendarId: calendar.calendarId,
|
|
886
|
+
canonicalKey: `${state.accountIdentityKey}:${normalizeCalendarKey(calendar.title)}`
|
|
887
|
+
}))
|
|
888
|
+
};
|
|
889
|
+
}
|
|
818
890
|
if (state.mode === "microsoft") {
|
|
819
891
|
return {
|
|
820
892
|
provider,
|
|
@@ -833,7 +905,13 @@ function mapDiscoveryPayload(provider, state) {
|
|
|
833
905
|
isPrimary: state.primaryCalendarId === calendar.id,
|
|
834
906
|
canWrite: false,
|
|
835
907
|
selectedByDefault: true,
|
|
836
|
-
isForgeCandidate: false
|
|
908
|
+
isForgeCandidate: false,
|
|
909
|
+
sourceId: null,
|
|
910
|
+
sourceTitle: null,
|
|
911
|
+
sourceType: null,
|
|
912
|
+
calendarType: null,
|
|
913
|
+
hostCalendarId: null,
|
|
914
|
+
canonicalKey: null
|
|
837
915
|
}))
|
|
838
916
|
};
|
|
839
917
|
}
|
|
@@ -854,7 +932,13 @@ function mapDiscoveryPayload(provider, state) {
|
|
|
854
932
|
isPrimary: false,
|
|
855
933
|
canWrite: canWriteDavCalendar(calendar),
|
|
856
934
|
selectedByDefault: !isForgeName(displayName),
|
|
857
|
-
isForgeCandidate: isForgeName(displayName)
|
|
935
|
+
isForgeCandidate: isForgeName(displayName),
|
|
936
|
+
sourceId: null,
|
|
937
|
+
sourceTitle: null,
|
|
938
|
+
sourceType: null,
|
|
939
|
+
calendarType: null,
|
|
940
|
+
hostCalendarId: null,
|
|
941
|
+
canonicalKey: null
|
|
858
942
|
};
|
|
859
943
|
})
|
|
860
944
|
};
|
|
@@ -1348,6 +1432,32 @@ function mapDavObjectToEvents(calendarUrl, object, ownership) {
|
|
|
1348
1432
|
return events;
|
|
1349
1433
|
}
|
|
1350
1434
|
function mapCalendarRecord(calendar, options) {
|
|
1435
|
+
if ("calendarId" in calendar) {
|
|
1436
|
+
const forgeCalendarUrl = options.forgeCalendarUrl
|
|
1437
|
+
? normalizeUrl(options.forgeCalendarUrl)
|
|
1438
|
+
: null;
|
|
1439
|
+
return {
|
|
1440
|
+
remoteId: normalizeUrl(calendar.url),
|
|
1441
|
+
title: safeDisplayName(calendar.title, "Calendar"),
|
|
1442
|
+
description: calendar.description,
|
|
1443
|
+
color: calendar.color,
|
|
1444
|
+
timezone: normalizeTimezone(calendar.timezone),
|
|
1445
|
+
isPrimary: calendar.isPrimary,
|
|
1446
|
+
canWrite: calendar.canWrite,
|
|
1447
|
+
selectedForSync: forgeCalendarUrl
|
|
1448
|
+
? normalizeUrl(calendar.url) !== forgeCalendarUrl
|
|
1449
|
+
: true,
|
|
1450
|
+
forgeManaged: forgeCalendarUrl
|
|
1451
|
+
? normalizeUrl(calendar.url) === forgeCalendarUrl
|
|
1452
|
+
: false,
|
|
1453
|
+
sourceId: calendar.sourceId,
|
|
1454
|
+
sourceTitle: calendar.sourceTitle,
|
|
1455
|
+
sourceType: calendar.sourceType,
|
|
1456
|
+
calendarType: calendar.calendarType,
|
|
1457
|
+
hostCalendarId: calendar.calendarId,
|
|
1458
|
+
canonicalKey: `${options.accountIdentityKey ?? ""}:${normalizeCalendarKey(calendar.title)}`
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1351
1461
|
if ("url" in calendar) {
|
|
1352
1462
|
const forgeCalendarUrl = options.forgeCalendarUrl ? normalizeUrl(options.forgeCalendarUrl) : null;
|
|
1353
1463
|
const title = safeDisplayName(calendar.displayName, "Calendar");
|
|
@@ -1361,7 +1471,13 @@ function mapCalendarRecord(calendar, options) {
|
|
|
1361
1471
|
isPrimary: false,
|
|
1362
1472
|
canWrite: canWriteDavCalendar(calendar),
|
|
1363
1473
|
selectedForSync: forgeCalendarUrl ? remoteUrl !== forgeCalendarUrl : true,
|
|
1364
|
-
forgeManaged: forgeCalendarUrl ? remoteUrl === forgeCalendarUrl : false
|
|
1474
|
+
forgeManaged: forgeCalendarUrl ? remoteUrl === forgeCalendarUrl : false,
|
|
1475
|
+
sourceId: null,
|
|
1476
|
+
sourceTitle: null,
|
|
1477
|
+
sourceType: null,
|
|
1478
|
+
calendarType: null,
|
|
1479
|
+
hostCalendarId: null,
|
|
1480
|
+
canonicalKey: remoteUrl
|
|
1365
1481
|
};
|
|
1366
1482
|
}
|
|
1367
1483
|
return {
|
|
@@ -1375,11 +1491,73 @@ function mapCalendarRecord(calendar, options) {
|
|
|
1375
1491
|
isPrimary: options.primaryCalendarId === calendar.id,
|
|
1376
1492
|
canWrite: false,
|
|
1377
1493
|
selectedForSync: true,
|
|
1378
|
-
forgeManaged: false
|
|
1494
|
+
forgeManaged: false,
|
|
1495
|
+
sourceId: null,
|
|
1496
|
+
sourceTitle: null,
|
|
1497
|
+
sourceType: null,
|
|
1498
|
+
calendarType: null,
|
|
1499
|
+
hostCalendarId: null,
|
|
1500
|
+
canonicalKey: microsoftCalendarUrl(calendar.id)
|
|
1501
|
+
};
|
|
1502
|
+
}
|
|
1503
|
+
function mapMacOSLocalEventToSyncInput(calendarUrl, event, ownership) {
|
|
1504
|
+
return {
|
|
1505
|
+
calendarRemoteId: normalizeUrl(calendarUrl),
|
|
1506
|
+
remoteId: event.eventId,
|
|
1507
|
+
remoteHref: null,
|
|
1508
|
+
remoteEtag: null,
|
|
1509
|
+
ownership,
|
|
1510
|
+
status: "confirmed",
|
|
1511
|
+
title: event.title,
|
|
1512
|
+
description: event.notes,
|
|
1513
|
+
location: event.location,
|
|
1514
|
+
startAt: event.startAt,
|
|
1515
|
+
endAt: event.endAt,
|
|
1516
|
+
isAllDay: event.allDay,
|
|
1517
|
+
availability: event.availability,
|
|
1518
|
+
eventType: "",
|
|
1519
|
+
categories: [],
|
|
1520
|
+
rawPayload: {
|
|
1521
|
+
externalId: event.externalId,
|
|
1522
|
+
occurrenceDate: event.occurrenceDate
|
|
1523
|
+
},
|
|
1524
|
+
remoteUpdatedAt: event.lastModifiedAt,
|
|
1525
|
+
deletedAt: null
|
|
1379
1526
|
};
|
|
1380
1527
|
}
|
|
1381
1528
|
async function publishTaskTimeboxes(state, forgeCalendarUrl, connectionId) {
|
|
1382
|
-
if (
|
|
1529
|
+
if (!forgeCalendarUrl) {
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
if (state.mode === "macos_local") {
|
|
1533
|
+
const forgeCalendar = state.calendars.find((calendar) => normalizeUrl(calendar.url) === normalizeUrl(forgeCalendarUrl));
|
|
1534
|
+
if (!forgeCalendar || !forgeCalendar.canWrite) {
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
const horizon = {
|
|
1538
|
+
from: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
|
1539
|
+
to: new Date(Date.now() + 180 * 24 * 60 * 60 * 1000).toISOString()
|
|
1540
|
+
};
|
|
1541
|
+
const timeboxes = listTaskTimeboxes(horizon);
|
|
1542
|
+
for (const timebox of timeboxes) {
|
|
1543
|
+
const { event } = await upsertMacOSLocalEvent({
|
|
1544
|
+
calendarId: forgeCalendar.calendarId,
|
|
1545
|
+
eventId: timebox.remoteEventId,
|
|
1546
|
+
title: timebox.title,
|
|
1547
|
+
startAt: timebox.startsAt,
|
|
1548
|
+
endAt: timebox.endsAt,
|
|
1549
|
+
notes: timebox.overrideReason ?? ""
|
|
1550
|
+
});
|
|
1551
|
+
const localForgeCalendar = listCalendars(connectionId).find((entry) => normalizeUrl(entry.remoteId) === normalizeUrl(forgeCalendar.url));
|
|
1552
|
+
updateTaskTimebox(timebox.id, {
|
|
1553
|
+
connectionId,
|
|
1554
|
+
calendarId: localForgeCalendar?.id ?? null,
|
|
1555
|
+
remoteEventId: event.eventId
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
if (state.mode !== "dav") {
|
|
1383
1561
|
return;
|
|
1384
1562
|
}
|
|
1385
1563
|
const forgeCalendar = state.calendars.find((calendar) => normalizeUrl(calendar.url) === normalizeUrl(forgeCalendarUrl));
|
|
@@ -1425,6 +1603,55 @@ async function publishTaskTimeboxes(state, forgeCalendarUrl, connectionId) {
|
|
|
1425
1603
|
}
|
|
1426
1604
|
async function syncDiscoveredState(connectionId, credentials) {
|
|
1427
1605
|
const state = await createProviderClient(credentials);
|
|
1606
|
+
if (state.mode === "macos_local") {
|
|
1607
|
+
const selected = new Set(credentials.selectedCalendarUrls.map((value) => normalizeUrl(value)));
|
|
1608
|
+
const forgeCalendarUrl = normalizeOptionalUrl(credentials.forgeCalendarUrl);
|
|
1609
|
+
for (const calendar of state.calendars) {
|
|
1610
|
+
const normalized = normalizeUrl(calendar.url);
|
|
1611
|
+
upsertCalendarRecord(connectionId, {
|
|
1612
|
+
...mapCalendarRecord(calendar, {
|
|
1613
|
+
forgeCalendarUrl,
|
|
1614
|
+
accountIdentityKey: state.accountIdentityKey
|
|
1615
|
+
}),
|
|
1616
|
+
selectedForSync: selected.has(normalized)
|
|
1617
|
+
});
|
|
1618
|
+
if (!selected.has(normalized) && normalized !== forgeCalendarUrl) {
|
|
1619
|
+
continue;
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
const calendarIds = state.calendars
|
|
1623
|
+
.filter((calendar) => {
|
|
1624
|
+
const normalized = normalizeUrl(calendar.url);
|
|
1625
|
+
return (selected.has(normalized) ||
|
|
1626
|
+
normalized === forgeCalendarUrl);
|
|
1627
|
+
})
|
|
1628
|
+
.map((calendar) => calendar.calendarId);
|
|
1629
|
+
if (calendarIds.length > 0) {
|
|
1630
|
+
const now = new Date();
|
|
1631
|
+
const start = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
1632
|
+
const end = new Date(now.getTime() + 180 * 24 * 60 * 60 * 1000).toISOString();
|
|
1633
|
+
const { events } = await listMacOSLocalEvents({
|
|
1634
|
+
calendarIds,
|
|
1635
|
+
start,
|
|
1636
|
+
end
|
|
1637
|
+
});
|
|
1638
|
+
const calendarsById = new Map(state.calendars.map((calendar) => [calendar.calendarId, calendar]));
|
|
1639
|
+
for (const event of events) {
|
|
1640
|
+
const calendar = calendarsById.get(event.calendarId);
|
|
1641
|
+
if (!calendar) {
|
|
1642
|
+
continue;
|
|
1643
|
+
}
|
|
1644
|
+
const ownership = forgeCalendarUrl && normalizeUrl(calendar.url) === forgeCalendarUrl
|
|
1645
|
+
? "forge"
|
|
1646
|
+
: "external";
|
|
1647
|
+
upsertCalendarEventRecord(connectionId, mapMacOSLocalEventToSyncInput(calendar.url, event, ownership));
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
return {
|
|
1651
|
+
state,
|
|
1652
|
+
forgeCalendarUrl
|
|
1653
|
+
};
|
|
1654
|
+
}
|
|
1428
1655
|
if (!isWritableCalendarCredentials(credentials)) {
|
|
1429
1656
|
if (state.mode !== "microsoft") {
|
|
1430
1657
|
throw new Error("Forge expected a Microsoft provider state for this calendar connection.");
|
|
@@ -1460,17 +1687,19 @@ async function syncDiscoveredState(connectionId, credentials) {
|
|
|
1460
1687
|
throw new Error("Forge expected a DAV provider state for this writable calendar connection.");
|
|
1461
1688
|
}
|
|
1462
1689
|
const selected = new Set(credentials.selectedCalendarUrls.map((value) => normalizeUrl(value)));
|
|
1463
|
-
const forgeCalendarUrl =
|
|
1690
|
+
const forgeCalendarUrl = normalizeOptionalUrl(credentials.forgeCalendarUrl);
|
|
1464
1691
|
const calendarsToSync = state.calendars.filter((calendar) => {
|
|
1465
1692
|
const normalized = normalizeUrl(calendar.url);
|
|
1466
1693
|
if (selected.has(normalized)) {
|
|
1467
1694
|
return true;
|
|
1468
1695
|
}
|
|
1469
|
-
if (credentials.provider === "google" &&
|
|
1696
|
+
if (credentials.provider === "google" &&
|
|
1697
|
+
forgeCalendarUrl &&
|
|
1698
|
+
normalized === forgeCalendarUrl) {
|
|
1470
1699
|
logForgeDebug(`[forge-calendar-sync] skip_forge_readback connectionId=${JSON.stringify(connectionId)} provider=${JSON.stringify(credentials.provider)} calendarUrl=${JSON.stringify(normalized)}`);
|
|
1471
1700
|
return false;
|
|
1472
1701
|
}
|
|
1473
|
-
return normalized === forgeCalendarUrl;
|
|
1702
|
+
return forgeCalendarUrl ? normalized === forgeCalendarUrl : false;
|
|
1474
1703
|
});
|
|
1475
1704
|
for (const calendar of state.calendars) {
|
|
1476
1705
|
const normalized = normalizeUrl(calendar.url);
|
|
@@ -1483,7 +1712,9 @@ async function syncDiscoveredState(connectionId, credentials) {
|
|
|
1483
1712
|
const start = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
1484
1713
|
const end = new Date(now.getTime() + 180 * 24 * 60 * 60 * 1000).toISOString();
|
|
1485
1714
|
for (const calendar of calendarsToSync) {
|
|
1486
|
-
const ownership = normalizeUrl(calendar.url) === forgeCalendarUrl
|
|
1715
|
+
const ownership = forgeCalendarUrl && normalizeUrl(calendar.url) === forgeCalendarUrl
|
|
1716
|
+
? "forge"
|
|
1717
|
+
: "external";
|
|
1487
1718
|
const calendarUrl = normalizeUrl(calendar.url);
|
|
1488
1719
|
logForgeDebug(`[forge-calendar-sync] fetch_calendar_objects_start connectionId=${JSON.stringify(connectionId)} provider=${JSON.stringify(credentials.provider)} calendarUrl=${JSON.stringify(calendarUrl)} ownership=${JSON.stringify(ownership)}`);
|
|
1489
1720
|
if (credentials.provider === "google") {
|
|
@@ -1529,7 +1760,7 @@ function toStoredCredentials(input, forgeCalendarUrl) {
|
|
|
1529
1760
|
username: input.username,
|
|
1530
1761
|
password: input.password,
|
|
1531
1762
|
selectedCalendarUrls: input.selectedCalendarUrls.map(normalizeUrl),
|
|
1532
|
-
forgeCalendarUrl:
|
|
1763
|
+
forgeCalendarUrl: normalizeOptionalUrl(forgeCalendarUrl)
|
|
1533
1764
|
};
|
|
1534
1765
|
}
|
|
1535
1766
|
return {
|
|
@@ -1538,7 +1769,7 @@ function toStoredCredentials(input, forgeCalendarUrl) {
|
|
|
1538
1769
|
username: input.username,
|
|
1539
1770
|
password: input.password,
|
|
1540
1771
|
selectedCalendarUrls: input.selectedCalendarUrls.map(normalizeUrl),
|
|
1541
|
-
forgeCalendarUrl:
|
|
1772
|
+
forgeCalendarUrl: normalizeOptionalUrl(forgeCalendarUrl)
|
|
1542
1773
|
};
|
|
1543
1774
|
}
|
|
1544
1775
|
function credentialsMatch(existing, incoming) {
|
|
@@ -1569,6 +1800,73 @@ function findExistingCalendarConnection(incoming, secrets) {
|
|
|
1569
1800
|
}
|
|
1570
1801
|
});
|
|
1571
1802
|
}
|
|
1803
|
+
function readConnectionAccountIdentityKey(connection, secrets) {
|
|
1804
|
+
const configuredKey = typeof connection.config.accountIdentityKey === "string"
|
|
1805
|
+
? connection.config.accountIdentityKey.trim()
|
|
1806
|
+
: "";
|
|
1807
|
+
if (configuredKey) {
|
|
1808
|
+
return normalizeAccountIdentity(configuredKey);
|
|
1809
|
+
}
|
|
1810
|
+
try {
|
|
1811
|
+
const existing = requireSecretRecord(secrets, connection.credentialsSecretId);
|
|
1812
|
+
if (existing.provider === "macos_local") {
|
|
1813
|
+
return normalizeAccountIdentity(existing.accountIdentityKey);
|
|
1814
|
+
}
|
|
1815
|
+
if ("username" in existing && typeof existing.username === "string") {
|
|
1816
|
+
return normalizeAccountIdentity(existing.username);
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
catch {
|
|
1820
|
+
// Fall back to account label below.
|
|
1821
|
+
}
|
|
1822
|
+
return normalizeAccountIdentity(connection.accountLabel);
|
|
1823
|
+
}
|
|
1824
|
+
function findOverlappingConnectionsForAccount(accountIdentityKey, secrets) {
|
|
1825
|
+
const target = normalizeAccountIdentity(accountIdentityKey);
|
|
1826
|
+
return listCalendarConnections().filter((connection) => {
|
|
1827
|
+
if (typeof connection.config.replacedByConnectionId === "string" &&
|
|
1828
|
+
connection.config.replacedByConnectionId.trim().length > 0) {
|
|
1829
|
+
return false;
|
|
1830
|
+
}
|
|
1831
|
+
return readConnectionAccountIdentityKey(connection, secrets) === target;
|
|
1832
|
+
});
|
|
1833
|
+
}
|
|
1834
|
+
function findExistingForgeWriteTarget(options) {
|
|
1835
|
+
const excluded = new Set(options?.excludeConnectionIds ?? []);
|
|
1836
|
+
return listCalendarConnections().find((connection) => {
|
|
1837
|
+
if (excluded.has(connection.id)) {
|
|
1838
|
+
return false;
|
|
1839
|
+
}
|
|
1840
|
+
if (typeof connection.config.replacedByConnectionId === "string" &&
|
|
1841
|
+
connection.config.replacedByConnectionId.trim().length > 0) {
|
|
1842
|
+
return false;
|
|
1843
|
+
}
|
|
1844
|
+
return normalizeOptionalUrl(typeof connection.config.forgeCalendarUrl === "string"
|
|
1845
|
+
? connection.config.forgeCalendarUrl
|
|
1846
|
+
: null) !== null;
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
function supersedeCalendarConnections(connectionIds, replacingConnectionId) {
|
|
1850
|
+
for (const connectionId of connectionIds) {
|
|
1851
|
+
const connection = getCalendarConnectionById(connectionId);
|
|
1852
|
+
if (!connection) {
|
|
1853
|
+
continue;
|
|
1854
|
+
}
|
|
1855
|
+
updateCalendarConnectionRecord(connectionId, {
|
|
1856
|
+
status: "needs_attention",
|
|
1857
|
+
config: {
|
|
1858
|
+
...connection.config,
|
|
1859
|
+
replacedByConnectionId: replacingConnectionId,
|
|
1860
|
+
replacementTransport: "macos_local"
|
|
1861
|
+
}
|
|
1862
|
+
});
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
function requireActiveConnection(connectionId) {
|
|
1866
|
+
if (isSupersededCalendarConnection(connectionId)) {
|
|
1867
|
+
throw new Error("This calendar connection has been replaced by a newer canonical connection and can no longer be synced directly.");
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1572
1870
|
function toDiscoveryCredentials(input) {
|
|
1573
1871
|
if (input.provider === "microsoft" || input.provider === "google") {
|
|
1574
1872
|
throw new Error(`${input.provider === "google" ? "Google Calendar" : "Exchange Online"} discovery now uses the guided OAuth sign-in flow.`);
|
|
@@ -1592,7 +1890,72 @@ export async function discoverCalendarConnection(input) {
|
|
|
1592
1890
|
const state = await createProviderClient(toDiscoveryCredentials(input));
|
|
1593
1891
|
return mapDiscoveryPayload(input.provider, state);
|
|
1594
1892
|
}
|
|
1893
|
+
export async function getMacOSLocalCalendarAccessStatus() {
|
|
1894
|
+
return getMacOSCalendarAuthStatus();
|
|
1895
|
+
}
|
|
1896
|
+
export async function requestMacOSLocalCalendarAccess() {
|
|
1897
|
+
const result = await requestMacOSCalendarAccess();
|
|
1898
|
+
if (result.granted) {
|
|
1899
|
+
return result;
|
|
1900
|
+
}
|
|
1901
|
+
if (result.status === "not_determined") {
|
|
1902
|
+
const settings = await openMacOSCalendarPrivacySettings();
|
|
1903
|
+
return {
|
|
1904
|
+
...result,
|
|
1905
|
+
promptSuppressed: true,
|
|
1906
|
+
openedSystemSettings: settings.opened,
|
|
1907
|
+
message: settings.opened
|
|
1908
|
+
? "macOS did not present the Calendar permission prompt from the Forge background service. Forge opened System Settings > Privacy & Security > Calendars so you can allow access there, then return here and click Check access."
|
|
1909
|
+
: "macOS did not present the Calendar permission prompt from the Forge background service. Open System Settings > Privacy & Security > Calendars, allow Forge, then return here and click Check access."
|
|
1910
|
+
};
|
|
1911
|
+
}
|
|
1912
|
+
if (result.status === "denied" || result.status === "restricted") {
|
|
1913
|
+
return {
|
|
1914
|
+
...result,
|
|
1915
|
+
message: "Calendar access is blocked for Forge. Open System Settings > Privacy & Security > Calendars, allow Forge, then return here and click Check access."
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
return result;
|
|
1919
|
+
}
|
|
1920
|
+
export async function discoverMacOSLocalCalendarSources() {
|
|
1921
|
+
const discovery = await discoverMacOSLocalCalendars();
|
|
1922
|
+
return {
|
|
1923
|
+
status: discovery.status,
|
|
1924
|
+
requestedAt: discovery.requestedAt,
|
|
1925
|
+
sources: discovery.sources.map((source) => ({
|
|
1926
|
+
sourceId: source.sourceId,
|
|
1927
|
+
sourceTitle: source.sourceTitle,
|
|
1928
|
+
sourceType: source.sourceType,
|
|
1929
|
+
accountLabel: source.accountLabel,
|
|
1930
|
+
accountIdentityKey: accountIdentityKeyForMacOSSource({
|
|
1931
|
+
sourceTitle: source.sourceTitle,
|
|
1932
|
+
sourceType: source.sourceType
|
|
1933
|
+
}),
|
|
1934
|
+
calendars: source.calendars.map((calendar) => ({
|
|
1935
|
+
url: buildMacOSLocalCalendarUrl(source.sourceId, calendar.calendarId),
|
|
1936
|
+
displayName: calendar.title,
|
|
1937
|
+
description: calendar.description,
|
|
1938
|
+
color: calendar.color,
|
|
1939
|
+
timezone: normalizeTimezone(calendar.timezone),
|
|
1940
|
+
isPrimary: calendar.isPrimary,
|
|
1941
|
+
canWrite: calendar.canWrite,
|
|
1942
|
+
selectedByDefault: !isForgeName(calendar.title),
|
|
1943
|
+
isForgeCandidate: isForgeName(calendar.title),
|
|
1944
|
+
sourceId: source.sourceId,
|
|
1945
|
+
sourceTitle: source.sourceTitle,
|
|
1946
|
+
sourceType: source.sourceType,
|
|
1947
|
+
calendarType: calendar.calendarType,
|
|
1948
|
+
hostCalendarId: calendar.calendarId,
|
|
1949
|
+
canonicalKey: `${accountIdentityKeyForMacOSSource({
|
|
1950
|
+
sourceTitle: source.sourceTitle,
|
|
1951
|
+
sourceType: source.sourceType
|
|
1952
|
+
})}:${normalizeCalendarKey(calendar.title)}`
|
|
1953
|
+
}))
|
|
1954
|
+
}))
|
|
1955
|
+
};
|
|
1956
|
+
}
|
|
1595
1957
|
export async function discoverExistingCalendarConnection(connectionId, secrets) {
|
|
1958
|
+
requireActiveConnection(connectionId);
|
|
1596
1959
|
const connection = getCalendarConnectionById(connectionId);
|
|
1597
1960
|
if (!connection) {
|
|
1598
1961
|
throw new Error(`Unknown calendar connection ${connectionId}`);
|
|
@@ -1602,6 +1965,103 @@ export async function discoverExistingCalendarConnection(connectionId, secrets)
|
|
|
1602
1965
|
return mapDiscoveryPayload(connection.provider, state);
|
|
1603
1966
|
}
|
|
1604
1967
|
export async function createCalendarConnection(input, secrets, activity = { source: "ui" }) {
|
|
1968
|
+
const cleanupFailedConnectionCreation = (connectionId, secretId) => {
|
|
1969
|
+
try {
|
|
1970
|
+
deleteCalendarConnectionRecord(connectionId);
|
|
1971
|
+
}
|
|
1972
|
+
catch {
|
|
1973
|
+
// Best-effort cleanup for partially-created connections.
|
|
1974
|
+
}
|
|
1975
|
+
try {
|
|
1976
|
+
deleteEncryptedSecret(secretId);
|
|
1977
|
+
}
|
|
1978
|
+
catch {
|
|
1979
|
+
// Best-effort cleanup for partially-created credentials.
|
|
1980
|
+
}
|
|
1981
|
+
};
|
|
1982
|
+
if (input.provider === "macos_local") {
|
|
1983
|
+
const discovery = await discoverMacOSLocalCalendars();
|
|
1984
|
+
if (discovery.status !== "full_access") {
|
|
1985
|
+
throw new Error("Forge needs Calendar full access before it can connect the calendars already configured on this Mac.");
|
|
1986
|
+
}
|
|
1987
|
+
const source = discovery.sources.find((entry) => entry.sourceId === input.sourceId);
|
|
1988
|
+
if (!source) {
|
|
1989
|
+
throw new Error("Forge could not find that macOS calendar source anymore. Discover again and retry.");
|
|
1990
|
+
}
|
|
1991
|
+
const accountIdentityKey = accountIdentityKeyForMacOSSource({
|
|
1992
|
+
sourceTitle: source.sourceTitle,
|
|
1993
|
+
sourceType: source.sourceType
|
|
1994
|
+
});
|
|
1995
|
+
const overlaps = findOverlappingConnectionsForAccount(accountIdentityKey, secrets);
|
|
1996
|
+
const replaceIds = new Set(input.replaceConnectionIds ?? []);
|
|
1997
|
+
const unresolvedOverlaps = overlaps.filter((connection) => !replaceIds.has(connection.id));
|
|
1998
|
+
if (unresolvedOverlaps.length > 0) {
|
|
1999
|
+
throw new CalendarConnectionOverlapError(`Forge already syncs ${source.accountLabel || source.sourceTitle} through another calendar connection. Replace the older connection instead of keeping two copies of the same calendar account.`, unresolvedOverlaps.map((connection) => connection.id));
|
|
2000
|
+
}
|
|
2001
|
+
const discoveredCalendars = source.calendars.map((calendar) => ({
|
|
2002
|
+
...calendar,
|
|
2003
|
+
url: buildMacOSLocalCalendarUrl(source.sourceId, calendar.calendarId)
|
|
2004
|
+
}));
|
|
2005
|
+
const sharedWriteTarget = findExistingForgeWriteTarget({
|
|
2006
|
+
excludeConnectionIds: Array.from(replaceIds)
|
|
2007
|
+
});
|
|
2008
|
+
let forgeCalendarUrl = input.forgeCalendarUrl?.trim() ||
|
|
2009
|
+
(!sharedWriteTarget
|
|
2010
|
+
? discoveredCalendars.find((calendar) => isForgeName(calendar.title))?.url
|
|
2011
|
+
: null) ||
|
|
2012
|
+
null;
|
|
2013
|
+
if (!forgeCalendarUrl && input.createForgeCalendar && !sharedWriteTarget) {
|
|
2014
|
+
const created = await ensureMacOSLocalForgeCalendar(source.sourceId);
|
|
2015
|
+
forgeCalendarUrl = buildMacOSLocalCalendarUrl(created.calendar.sourceId, created.calendar.calendarId);
|
|
2016
|
+
}
|
|
2017
|
+
if (!forgeCalendarUrl && !sharedWriteTarget) {
|
|
2018
|
+
throw new Error("Select the calendar Forge should write into, ask Forge to create one, or keep using the existing shared Forge write target.");
|
|
2019
|
+
}
|
|
2020
|
+
const secretId = `calendar_secret_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
2021
|
+
const storedCredentials = {
|
|
2022
|
+
provider: "macos_local",
|
|
2023
|
+
sourceId: source.sourceId,
|
|
2024
|
+
sourceTitle: source.sourceTitle,
|
|
2025
|
+
sourceType: source.sourceType,
|
|
2026
|
+
accountIdentityKey,
|
|
2027
|
+
selectedCalendarUrls: input.selectedCalendarUrls.map(normalizeUrl),
|
|
2028
|
+
forgeCalendarUrl: normalizeOptionalUrl(forgeCalendarUrl)
|
|
2029
|
+
};
|
|
2030
|
+
storeEncryptedSecret(secretId, secrets.sealJson(storedCredentials), `${input.label} macOS local calendar credentials`);
|
|
2031
|
+
const connection = createCalendarConnectionRecord({
|
|
2032
|
+
provider: "macos_local",
|
|
2033
|
+
label: input.label,
|
|
2034
|
+
accountLabel: source.accountLabel,
|
|
2035
|
+
config: {
|
|
2036
|
+
serverUrl: "forge-macos-local://eventkit/",
|
|
2037
|
+
transportKind: "macos_local",
|
|
2038
|
+
sourceId: source.sourceId,
|
|
2039
|
+
sourceType: source.sourceType,
|
|
2040
|
+
accountIdentityKey,
|
|
2041
|
+
selectedCalendarCount: storedCredentials.selectedCalendarUrls.length,
|
|
2042
|
+
forgeCalendarUrl: storedCredentials.forgeCalendarUrl
|
|
2043
|
+
},
|
|
2044
|
+
credentialsSecretId: secretId
|
|
2045
|
+
});
|
|
2046
|
+
try {
|
|
2047
|
+
await syncCalendarConnection(connection.id, secrets, activity);
|
|
2048
|
+
}
|
|
2049
|
+
catch (error) {
|
|
2050
|
+
cleanupFailedConnectionCreation(connection.id, secretId);
|
|
2051
|
+
throw error;
|
|
2052
|
+
}
|
|
2053
|
+
if (replaceIds.size > 0) {
|
|
2054
|
+
for (const replacedConnectionId of replaceIds) {
|
|
2055
|
+
rehomeCalendarConnectionReferences({
|
|
2056
|
+
fromConnectionId: replacedConnectionId,
|
|
2057
|
+
toConnectionId: connection.id
|
|
2058
|
+
});
|
|
2059
|
+
}
|
|
2060
|
+
supersedeCalendarConnections(Array.from(replaceIds), connection.id);
|
|
2061
|
+
}
|
|
2062
|
+
recordCalendarActivity("calendar_connection_created", "calendar_connection", connection.id, `Calendar connection created: ${connection.label}`, "Forge is now mirroring the calendars already configured on this Mac through EventKit.", activity, { provider: input.provider });
|
|
2063
|
+
return getCalendarConnectionById(connection.id);
|
|
2064
|
+
}
|
|
1605
2065
|
if (input.provider === "google") {
|
|
1606
2066
|
pruneGoogleOauthSessions();
|
|
1607
2067
|
const session = googleOauthSessions.get(input.authSessionId);
|
|
@@ -1626,21 +2086,24 @@ export async function createCalendarConnection(input, secrets, activity = { sour
|
|
|
1626
2086
|
if (state.mode !== "dav") {
|
|
1627
2087
|
throw new Error("Forge expected a writable DAV provider state for this Google Calendar connection.");
|
|
1628
2088
|
}
|
|
2089
|
+
const sharedWriteTarget = findExistingForgeWriteTarget();
|
|
1629
2090
|
let forgeCalendarUrl = input.forgeCalendarUrl?.trim() ||
|
|
1630
|
-
|
|
2091
|
+
(!sharedWriteTarget
|
|
2092
|
+
? session.discovery.calendars.find((calendar) => calendar.isForgeCandidate)?.url
|
|
2093
|
+
: null) ||
|
|
1631
2094
|
null;
|
|
1632
|
-
if (!forgeCalendarUrl && input.createForgeCalendar) {
|
|
2095
|
+
if (!forgeCalendarUrl && input.createForgeCalendar && !sharedWriteTarget) {
|
|
1633
2096
|
const created = await ensureForgeCalendar(state);
|
|
1634
2097
|
forgeCalendarUrl = created.forgeCalendarUrl;
|
|
1635
2098
|
}
|
|
1636
|
-
if (!forgeCalendarUrl) {
|
|
1637
|
-
throw new Error("Select the calendar Forge should write into,
|
|
2099
|
+
if (!forgeCalendarUrl && !sharedWriteTarget) {
|
|
2100
|
+
throw new Error("Select the calendar Forge should write into, ask Forge to create one, or keep using the existing shared Forge write target.");
|
|
1638
2101
|
}
|
|
1639
2102
|
const secretId = `calendar_secret_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
1640
2103
|
const storedCredentials = {
|
|
1641
2104
|
...session.credentials,
|
|
1642
2105
|
selectedCalendarUrls: input.selectedCalendarUrls.map(normalizeUrl),
|
|
1643
|
-
forgeCalendarUrl:
|
|
2106
|
+
forgeCalendarUrl: normalizeOptionalUrl(forgeCalendarUrl)
|
|
1644
2107
|
};
|
|
1645
2108
|
// The app credentials belong to Forge itself. Only user-specific OAuth tokens
|
|
1646
2109
|
// are stored per calendar connection.
|
|
@@ -1652,11 +2115,17 @@ export async function createCalendarConnection(input, secrets, activity = { sour
|
|
|
1652
2115
|
config: {
|
|
1653
2116
|
serverUrl: session.discovery.serverUrl,
|
|
1654
2117
|
selectedCalendarCount: storedCredentials.selectedCalendarUrls.length,
|
|
1655
|
-
forgeCalendarUrl:
|
|
2118
|
+
forgeCalendarUrl: normalizeOptionalUrl(storedCredentials.forgeCalendarUrl)
|
|
1656
2119
|
},
|
|
1657
2120
|
credentialsSecretId: secretId
|
|
1658
2121
|
});
|
|
1659
|
-
|
|
2122
|
+
try {
|
|
2123
|
+
await syncCalendarConnection(connection.id, secrets, activity);
|
|
2124
|
+
}
|
|
2125
|
+
catch (error) {
|
|
2126
|
+
cleanupFailedConnectionCreation(connection.id, secretId);
|
|
2127
|
+
throw error;
|
|
2128
|
+
}
|
|
1660
2129
|
session.status = "consumed";
|
|
1661
2130
|
recordCalendarActivity("calendar_connection_created", "calendar_connection", connection.id, `Calendar connection created: ${connection.label}`, "Google Calendar is now connected to Forge through the Authorization Code + PKCE flow.", activity, { provider: input.provider });
|
|
1662
2131
|
return getCalendarConnectionById(connection.id);
|
|
@@ -1699,7 +2168,13 @@ export async function createCalendarConnection(input, secrets, activity = { sour
|
|
|
1699
2168
|
},
|
|
1700
2169
|
credentialsSecretId: secretId
|
|
1701
2170
|
});
|
|
1702
|
-
|
|
2171
|
+
try {
|
|
2172
|
+
await syncCalendarConnection(connection.id, secrets, activity);
|
|
2173
|
+
}
|
|
2174
|
+
catch (error) {
|
|
2175
|
+
cleanupFailedConnectionCreation(connection.id, secretId);
|
|
2176
|
+
throw error;
|
|
2177
|
+
}
|
|
1703
2178
|
session.status = "consumed";
|
|
1704
2179
|
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 });
|
|
1705
2180
|
return getCalendarConnectionById(connection.id);
|
|
@@ -1714,17 +2189,20 @@ export async function createCalendarConnection(input, secrets, activity = { sour
|
|
|
1714
2189
|
throw new Error("Forge expected a writable DAV provider state for this calendar connection.");
|
|
1715
2190
|
}
|
|
1716
2191
|
const discovery = mapDiscoveryPayload(input.provider, state);
|
|
2192
|
+
const sharedWriteTarget = findExistingForgeWriteTarget();
|
|
1717
2193
|
let forgeCalendarUrl = null;
|
|
1718
2194
|
forgeCalendarUrl =
|
|
1719
2195
|
input.forgeCalendarUrl?.trim() ||
|
|
1720
|
-
|
|
2196
|
+
(!sharedWriteTarget
|
|
2197
|
+
? discovery.calendars.find((calendar) => calendar.isForgeCandidate)?.url
|
|
2198
|
+
: null) ||
|
|
1721
2199
|
null;
|
|
1722
|
-
if (!forgeCalendarUrl && input.createForgeCalendar) {
|
|
2200
|
+
if (!forgeCalendarUrl && input.createForgeCalendar && !sharedWriteTarget) {
|
|
1723
2201
|
const created = await ensureForgeCalendar(state);
|
|
1724
2202
|
forgeCalendarUrl = created.forgeCalendarUrl;
|
|
1725
2203
|
}
|
|
1726
|
-
if (!forgeCalendarUrl) {
|
|
1727
|
-
throw new Error("Select the calendar Forge should write into,
|
|
2204
|
+
if (!forgeCalendarUrl && !sharedWriteTarget) {
|
|
2205
|
+
throw new Error("Select the calendar Forge should write into, ask Forge to create one, or keep using the existing shared Forge write target.");
|
|
1728
2206
|
}
|
|
1729
2207
|
const secretId = `calendar_secret_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
1730
2208
|
const storedCredentials = toStoredCredentials(input, forgeCalendarUrl);
|
|
@@ -1736,11 +2214,17 @@ export async function createCalendarConnection(input, secrets, activity = { sour
|
|
|
1736
2214
|
config: {
|
|
1737
2215
|
serverUrl: discovery.serverUrl,
|
|
1738
2216
|
selectedCalendarCount: storedCredentials.selectedCalendarUrls.length,
|
|
1739
|
-
forgeCalendarUrl:
|
|
2217
|
+
forgeCalendarUrl: normalizeOptionalUrl(storedCredentials.forgeCalendarUrl)
|
|
1740
2218
|
},
|
|
1741
2219
|
credentialsSecretId: secretId
|
|
1742
2220
|
});
|
|
1743
|
-
|
|
2221
|
+
try {
|
|
2222
|
+
await syncCalendarConnection(connection.id, secrets, activity);
|
|
2223
|
+
}
|
|
2224
|
+
catch (error) {
|
|
2225
|
+
cleanupFailedConnectionCreation(connection.id, secretId);
|
|
2226
|
+
throw error;
|
|
2227
|
+
}
|
|
1744
2228
|
recordCalendarActivity("calendar_connection_created", "calendar_connection", connection.id, `Calendar connection created: ${connection.label}`, `${input.provider === "apple" ? "Apple Calendar" : "Custom CalDAV"} is now connected to Forge.`, activity, { provider: input.provider });
|
|
1745
2229
|
return getCalendarConnectionById(connection.id);
|
|
1746
2230
|
}
|
|
@@ -1757,6 +2241,7 @@ export async function removeCalendarConnection(connectionId, secrets, activity =
|
|
|
1757
2241
|
return connection;
|
|
1758
2242
|
}
|
|
1759
2243
|
export async function syncCalendarConnection(connectionId, secrets, activity = { source: "system" }) {
|
|
2244
|
+
requireActiveConnection(connectionId);
|
|
1760
2245
|
const connection = getCalendarConnectionById(connectionId);
|
|
1761
2246
|
if (!connection) {
|
|
1762
2247
|
throw new Error(`Unknown calendar connection ${connectionId}`);
|
|
@@ -1775,7 +2260,9 @@ export async function syncCalendarConnection(connectionId, secrets, activity = {
|
|
|
1775
2260
|
forgeCalendarId: forgeCalendar?.id ?? null,
|
|
1776
2261
|
status: "connected",
|
|
1777
2262
|
config: {
|
|
1778
|
-
serverUrl: credentials.
|
|
2263
|
+
serverUrl: credentials.provider === "macos_local"
|
|
2264
|
+
? "forge-macos-local://eventkit/"
|
|
2265
|
+
: credentials.serverUrl,
|
|
1779
2266
|
selectedCalendarCount: credentials.selectedCalendarUrls.length,
|
|
1780
2267
|
...(credentials.provider === "microsoft"
|
|
1781
2268
|
? {
|
|
@@ -1783,9 +2270,17 @@ export async function syncCalendarConnection(connectionId, secrets, activity = {
|
|
|
1783
2270
|
tenantId: credentials.tenantId,
|
|
1784
2271
|
writeMode: "read_only"
|
|
1785
2272
|
}
|
|
1786
|
-
:
|
|
1787
|
-
|
|
1788
|
-
|
|
2273
|
+
: credentials.provider === "macos_local"
|
|
2274
|
+
? {
|
|
2275
|
+
transportKind: "macos_local",
|
|
2276
|
+
sourceId: credentials.sourceId,
|
|
2277
|
+
sourceType: credentials.sourceType,
|
|
2278
|
+
accountIdentityKey: credentials.accountIdentityKey,
|
|
2279
|
+
forgeCalendarUrl: normalizeOptionalUrl(credentials.forgeCalendarUrl)
|
|
2280
|
+
}
|
|
2281
|
+
: {
|
|
2282
|
+
forgeCalendarUrl: normalizeOptionalUrl(credentials.forgeCalendarUrl)
|
|
2283
|
+
})
|
|
1789
2284
|
},
|
|
1790
2285
|
lastSyncedAt: new Date().toISOString(),
|
|
1791
2286
|
lastSyncError: null
|
|
@@ -1793,7 +2288,9 @@ export async function syncCalendarConnection(connectionId, secrets, activity = {
|
|
|
1793
2288
|
await publishTaskTimeboxes(state, forgeCalendarUrl, connectionId);
|
|
1794
2289
|
recordCalendarActivity("calendar_connection_synced", "calendar_connection", connectionId, `Calendar synced: ${connection.label}`, credentials.provider === "microsoft"
|
|
1795
2290
|
? "Exchange Online calendars were mirrored into Forge in read-only mode."
|
|
1796
|
-
:
|
|
2291
|
+
: forgeCalendarUrl
|
|
2292
|
+
? "Provider events and Forge timeboxes were synchronized."
|
|
2293
|
+
: "Provider events were synchronized. Forge keeps publishing work blocks and timeboxes through the existing shared write target.", activity);
|
|
1797
2294
|
return getCalendarConnectionById(connectionId);
|
|
1798
2295
|
}
|
|
1799
2296
|
catch (error) {
|
|
@@ -1805,6 +2302,7 @@ export async function syncCalendarConnection(connectionId, secrets, activity = {
|
|
|
1805
2302
|
}
|
|
1806
2303
|
}
|
|
1807
2304
|
export async function updateCalendarConnectionSelection(connectionId, input, secrets, activity = { source: "ui" }) {
|
|
2305
|
+
requireActiveConnection(connectionId);
|
|
1808
2306
|
const connection = getCalendarConnectionById(connectionId);
|
|
1809
2307
|
if (!connection) {
|
|
1810
2308
|
throw new Error(`Unknown calendar connection ${connectionId}`);
|
|
@@ -1875,6 +2373,42 @@ export async function syncForgeCalendarEvent(eventId, secrets) {
|
|
|
1875
2373
|
continue;
|
|
1876
2374
|
}
|
|
1877
2375
|
const { connection, state } = await resolveProviderStateForConnection(source.connectionId, secrets);
|
|
2376
|
+
if (state.mode === "macos_local") {
|
|
2377
|
+
const localCalendar = getCalendarById(source.calendarId);
|
|
2378
|
+
if (!localCalendar || localCalendar.canWrite === false) {
|
|
2379
|
+
continue;
|
|
2380
|
+
}
|
|
2381
|
+
const hostCalendarId = localCalendar.hostCalendarId ??
|
|
2382
|
+
parseMacOSLocalCalendarUrl(localCalendar.remoteId).calendarId;
|
|
2383
|
+
const { event: remoteEvent } = await upsertMacOSLocalEvent({
|
|
2384
|
+
calendarId: hostCalendarId,
|
|
2385
|
+
eventId: source.remoteEventId,
|
|
2386
|
+
title: event.title,
|
|
2387
|
+
startAt: event.start_at,
|
|
2388
|
+
endAt: event.end_at,
|
|
2389
|
+
notes: event.description,
|
|
2390
|
+
location: event.location,
|
|
2391
|
+
allDay: Boolean(event.is_all_day)
|
|
2392
|
+
});
|
|
2393
|
+
registerCalendarEventSourceProjection({
|
|
2394
|
+
forgeEventId: eventId,
|
|
2395
|
+
provider: connection.provider,
|
|
2396
|
+
connectionId: connection.id,
|
|
2397
|
+
calendarId: source.calendarId,
|
|
2398
|
+
remoteCalendarId: getCalendarById(source.calendarId)?.remoteId ?? null,
|
|
2399
|
+
remoteEventId: remoteEvent.eventId,
|
|
2400
|
+
remoteUid: remoteEvent.externalId,
|
|
2401
|
+
recurrenceInstanceId: remoteEvent.occurrenceDate,
|
|
2402
|
+
isMasterRecurring: false,
|
|
2403
|
+
syncState: "synced",
|
|
2404
|
+
rawPayloadJson: JSON.stringify({
|
|
2405
|
+
externalId: remoteEvent.externalId,
|
|
2406
|
+
occurrenceDate: remoteEvent.occurrenceDate
|
|
2407
|
+
}),
|
|
2408
|
+
lastSyncedAt: new Date().toISOString()
|
|
2409
|
+
});
|
|
2410
|
+
continue;
|
|
2411
|
+
}
|
|
1878
2412
|
if (state.mode !== "dav") {
|
|
1879
2413
|
continue;
|
|
1880
2414
|
}
|
|
@@ -1921,6 +2455,40 @@ export async function syncForgeCalendarEvent(eventId, secrets) {
|
|
|
1921
2455
|
return;
|
|
1922
2456
|
}
|
|
1923
2457
|
const { connection, state } = await resolveProviderStateForConnection(event.preferred_connection_id, secrets);
|
|
2458
|
+
if (state.mode === "macos_local") {
|
|
2459
|
+
const localCalendar = getCalendarById(event.preferred_calendar_id);
|
|
2460
|
+
if (!localCalendar || localCalendar.canWrite === false) {
|
|
2461
|
+
throw new Error(`Unknown local calendar for event ${eventId}`);
|
|
2462
|
+
}
|
|
2463
|
+
const hostCalendarId = localCalendar.hostCalendarId ??
|
|
2464
|
+
parseMacOSLocalCalendarUrl(localCalendar.remoteId).calendarId;
|
|
2465
|
+
const { event: remoteEvent } = await upsertMacOSLocalEvent({
|
|
2466
|
+
calendarId: hostCalendarId,
|
|
2467
|
+
title: event.title,
|
|
2468
|
+
startAt: event.start_at,
|
|
2469
|
+
endAt: event.end_at,
|
|
2470
|
+
notes: event.description,
|
|
2471
|
+
location: event.location,
|
|
2472
|
+
allDay: Boolean(event.is_all_day)
|
|
2473
|
+
});
|
|
2474
|
+
registerCalendarEventSourceProjection({
|
|
2475
|
+
forgeEventId: eventId,
|
|
2476
|
+
provider: connection.provider,
|
|
2477
|
+
connectionId: connection.id,
|
|
2478
|
+
calendarId: event.preferred_calendar_id,
|
|
2479
|
+
remoteCalendarId: getCalendarById(event.preferred_calendar_id)?.remoteId ?? null,
|
|
2480
|
+
remoteEventId: remoteEvent.eventId,
|
|
2481
|
+
remoteUid: remoteEvent.externalId,
|
|
2482
|
+
recurrenceInstanceId: remoteEvent.occurrenceDate,
|
|
2483
|
+
syncState: "synced",
|
|
2484
|
+
rawPayloadJson: JSON.stringify({
|
|
2485
|
+
externalId: remoteEvent.externalId,
|
|
2486
|
+
occurrenceDate: remoteEvent.occurrenceDate
|
|
2487
|
+
}),
|
|
2488
|
+
lastSyncedAt: new Date().toISOString()
|
|
2489
|
+
});
|
|
2490
|
+
return;
|
|
2491
|
+
}
|
|
1924
2492
|
if (state.mode !== "dav") {
|
|
1925
2493
|
throw new Error(`Connection ${connection.id} is read-only, so Forge cannot publish this event there.`);
|
|
1926
2494
|
}
|
|
@@ -1966,6 +2534,10 @@ export async function deleteCalendarEventProjection(eventId, secrets) {
|
|
|
1966
2534
|
continue;
|
|
1967
2535
|
}
|
|
1968
2536
|
const { connection, state } = await resolveProviderStateForConnection(source.connectionId, secrets);
|
|
2537
|
+
if (state.mode === "macos_local") {
|
|
2538
|
+
await deleteMacOSLocalEvent(source.remoteEventId);
|
|
2539
|
+
continue;
|
|
2540
|
+
}
|
|
1969
2541
|
if (state.mode !== "dav") {
|
|
1970
2542
|
continue;
|
|
1971
2543
|
}
|
|
@@ -2010,9 +2582,18 @@ export function listCalendarProviderMetadata() {
|
|
|
2010
2582
|
label: "Custom CalDAV",
|
|
2011
2583
|
supportsDedicatedForgeCalendar: true,
|
|
2012
2584
|
connectionHelp: "Use a CalDAV base server URL plus account credentials. Forge discovers the calendars available under that account before you pick what to sync."
|
|
2585
|
+
},
|
|
2586
|
+
{
|
|
2587
|
+
provider: "macos_local",
|
|
2588
|
+
label: "Calendars On This Mac",
|
|
2589
|
+
supportsDedicatedForgeCalendar: true,
|
|
2590
|
+
connectionHelp: "Use EventKit to access the calendars already configured in Calendar.app on this Mac. When the same account is already connected remotely, Forge replaces the older connection instead of showing duplicate copies."
|
|
2013
2591
|
}
|
|
2014
2592
|
];
|
|
2015
2593
|
}
|
|
2016
2594
|
export function listConnectedCalendarConnections() {
|
|
2017
|
-
return listCalendarConnections()
|
|
2595
|
+
return listCalendarConnections()
|
|
2596
|
+
.filter((connection) => !(typeof connection.config.replacedByConnectionId === "string" &&
|
|
2597
|
+
connection.config.replacedByConnectionId.trim().length > 0))
|
|
2598
|
+
.map(({ credentialsSecretId: _secret, ...connection }) => connection);
|
|
2018
2599
|
}
|