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,1101 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { getDatabase, runInTransaction } from "../db.js";
|
|
3
|
+
import { recordActivityEvent } from "./activity-events.js";
|
|
4
|
+
import { getProjectById } from "./projects.js";
|
|
5
|
+
import { getTaskById } from "./tasks.js";
|
|
6
|
+
import { calendarConnectionSchema, calendarContextConflictSchema, calendarEventSchema, calendarEventLinkSchema, calendarEventSourceSchema, calendarOverviewPayloadSchema, calendarSchema, calendarSchedulingRulesSchema, taskTimeboxSchema, workBlockInstanceSchema, workBlockTemplateSchema } from "../types.js";
|
|
7
|
+
const DEFAULT_SCHEDULING_RULES = {
|
|
8
|
+
allowWorkBlockKinds: [],
|
|
9
|
+
blockWorkBlockKinds: [],
|
|
10
|
+
allowCalendarIds: [],
|
|
11
|
+
blockCalendarIds: [],
|
|
12
|
+
allowEventTypes: [],
|
|
13
|
+
blockEventTypes: [],
|
|
14
|
+
allowEventKeywords: [],
|
|
15
|
+
blockEventKeywords: [],
|
|
16
|
+
allowAvailability: [],
|
|
17
|
+
blockAvailability: []
|
|
18
|
+
};
|
|
19
|
+
function nowIso() {
|
|
20
|
+
return new Date().toISOString();
|
|
21
|
+
}
|
|
22
|
+
function dateOnly(value) {
|
|
23
|
+
return value.toISOString().slice(0, 10);
|
|
24
|
+
}
|
|
25
|
+
function dateOnlyToUtcDate(value) {
|
|
26
|
+
const [yearText, monthText, dayText] = value.split("-");
|
|
27
|
+
return new Date(Date.UTC(Number(yearText), Number(monthText) - 1, Number(dayText)));
|
|
28
|
+
}
|
|
29
|
+
function normalizeTimezone(value) {
|
|
30
|
+
const normalized = value?.trim();
|
|
31
|
+
return normalized && normalized.length > 0 ? normalized : "UTC";
|
|
32
|
+
}
|
|
33
|
+
function mapConnection(row) {
|
|
34
|
+
const base = calendarConnectionSchema.parse({
|
|
35
|
+
id: row.id,
|
|
36
|
+
provider: row.provider,
|
|
37
|
+
label: row.label,
|
|
38
|
+
accountLabel: row.account_label,
|
|
39
|
+
status: row.status,
|
|
40
|
+
config: JSON.parse(row.config_json || "{}"),
|
|
41
|
+
forgeCalendarId: row.forge_calendar_id,
|
|
42
|
+
lastSyncedAt: row.last_synced_at,
|
|
43
|
+
lastSyncError: row.last_sync_error,
|
|
44
|
+
createdAt: row.created_at,
|
|
45
|
+
updatedAt: row.updated_at
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
...base,
|
|
49
|
+
credentialsSecretId: row.credentials_secret_id
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function mapCalendar(row) {
|
|
53
|
+
return calendarSchema.parse({
|
|
54
|
+
id: row.id,
|
|
55
|
+
connectionId: row.connection_id,
|
|
56
|
+
remoteId: row.remote_id,
|
|
57
|
+
title: row.title,
|
|
58
|
+
description: row.description,
|
|
59
|
+
color: row.color,
|
|
60
|
+
timezone: normalizeTimezone(row.timezone),
|
|
61
|
+
isPrimary: Boolean(row.is_primary),
|
|
62
|
+
canWrite: Boolean(row.can_write),
|
|
63
|
+
selectedForSync: Boolean(row.selected_for_sync),
|
|
64
|
+
forgeManaged: Boolean(row.forge_managed),
|
|
65
|
+
lastSyncedAt: row.last_synced_at,
|
|
66
|
+
createdAt: row.created_at,
|
|
67
|
+
updatedAt: row.updated_at
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function mapEventSource(row) {
|
|
71
|
+
return calendarEventSourceSchema.parse({
|
|
72
|
+
id: row.id,
|
|
73
|
+
provider: row.provider,
|
|
74
|
+
connectionId: row.connection_id,
|
|
75
|
+
calendarId: row.calendar_id,
|
|
76
|
+
remoteCalendarId: row.remote_calendar_id,
|
|
77
|
+
remoteEventId: row.remote_event_id,
|
|
78
|
+
remoteUid: row.remote_uid,
|
|
79
|
+
recurrenceInstanceId: row.recurrence_instance_id,
|
|
80
|
+
isMasterRecurring: Boolean(row.is_master_recurring),
|
|
81
|
+
remoteHref: row.remote_href,
|
|
82
|
+
remoteEtag: row.remote_etag,
|
|
83
|
+
syncState: row.sync_state,
|
|
84
|
+
lastSyncedAt: row.last_synced_at,
|
|
85
|
+
createdAt: row.created_at,
|
|
86
|
+
updatedAt: row.updated_at
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function mapEventLink(row) {
|
|
90
|
+
return calendarEventLinkSchema.parse({
|
|
91
|
+
id: row.id,
|
|
92
|
+
entityType: row.entity_type,
|
|
93
|
+
entityId: row.entity_id,
|
|
94
|
+
relationshipType: row.relationship_type,
|
|
95
|
+
createdAt: row.created_at,
|
|
96
|
+
updatedAt: row.updated_at
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
function listEventSourcesForEvent(eventId) {
|
|
100
|
+
const rows = getDatabase()
|
|
101
|
+
.prepare(`SELECT id, forge_event_id, provider, connection_id, calendar_id, remote_calendar_id, remote_event_id, remote_uid,
|
|
102
|
+
recurrence_instance_id, is_master_recurring, remote_href, remote_etag, sync_state, raw_payload_json,
|
|
103
|
+
last_synced_at, created_at, updated_at
|
|
104
|
+
FROM forge_event_sources
|
|
105
|
+
WHERE forge_event_id = ?
|
|
106
|
+
ORDER BY updated_at DESC, created_at DESC`)
|
|
107
|
+
.all(eventId);
|
|
108
|
+
return rows.map(mapEventSource);
|
|
109
|
+
}
|
|
110
|
+
function listEventLinksForEvent(eventId) {
|
|
111
|
+
const rows = getDatabase()
|
|
112
|
+
.prepare(`SELECT id, forge_event_id, entity_type, entity_id, relationship_type, created_at, updated_at
|
|
113
|
+
FROM forge_event_links
|
|
114
|
+
WHERE forge_event_id = ?
|
|
115
|
+
ORDER BY updated_at DESC, created_at DESC`)
|
|
116
|
+
.all(eventId);
|
|
117
|
+
return rows.map(mapEventLink);
|
|
118
|
+
}
|
|
119
|
+
function mapEvent(row) {
|
|
120
|
+
const sourceMappings = listEventSourcesForEvent(row.id);
|
|
121
|
+
const primarySource = sourceMappings[0] ?? null;
|
|
122
|
+
return calendarEventSchema.parse({
|
|
123
|
+
id: row.id,
|
|
124
|
+
connectionId: row.preferred_connection_id ?? primarySource?.connectionId ?? null,
|
|
125
|
+
calendarId: row.preferred_calendar_id ?? primarySource?.calendarId ?? null,
|
|
126
|
+
remoteId: primarySource?.remoteEventId ?? null,
|
|
127
|
+
ownership: row.ownership,
|
|
128
|
+
originType: row.origin_type,
|
|
129
|
+
status: row.status,
|
|
130
|
+
title: row.title,
|
|
131
|
+
description: row.description,
|
|
132
|
+
location: row.location,
|
|
133
|
+
startAt: row.start_at,
|
|
134
|
+
endAt: row.end_at,
|
|
135
|
+
timezone: normalizeTimezone(row.timezone),
|
|
136
|
+
isAllDay: Boolean(row.is_all_day),
|
|
137
|
+
availability: row.availability,
|
|
138
|
+
eventType: row.event_type,
|
|
139
|
+
categories: JSON.parse(row.categories_json || "[]"),
|
|
140
|
+
sourceMappings,
|
|
141
|
+
links: listEventLinksForEvent(row.id),
|
|
142
|
+
remoteUpdatedAt: primarySource?.lastSyncedAt ?? null,
|
|
143
|
+
deletedAt: row.deleted_at,
|
|
144
|
+
createdAt: row.created_at,
|
|
145
|
+
updatedAt: row.updated_at
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
function mapWorkBlockTemplate(row) {
|
|
149
|
+
return workBlockTemplateSchema.parse({
|
|
150
|
+
id: row.id,
|
|
151
|
+
title: row.title,
|
|
152
|
+
kind: row.kind,
|
|
153
|
+
color: row.color,
|
|
154
|
+
timezone: normalizeTimezone(row.timezone),
|
|
155
|
+
weekDays: JSON.parse(row.weekdays_json || "[]"),
|
|
156
|
+
startMinute: row.start_minute,
|
|
157
|
+
endMinute: row.end_minute,
|
|
158
|
+
startsOn: row.starts_on,
|
|
159
|
+
endsOn: row.ends_on,
|
|
160
|
+
blockingState: row.blocking_state,
|
|
161
|
+
createdAt: row.created_at,
|
|
162
|
+
updatedAt: row.updated_at
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
function mapTimebox(row) {
|
|
166
|
+
return taskTimeboxSchema.parse({
|
|
167
|
+
id: row.id,
|
|
168
|
+
taskId: row.task_id,
|
|
169
|
+
projectId: row.project_id,
|
|
170
|
+
connectionId: row.connection_id,
|
|
171
|
+
calendarId: row.calendar_id,
|
|
172
|
+
remoteEventId: row.remote_event_id,
|
|
173
|
+
linkedTaskRunId: row.linked_task_run_id,
|
|
174
|
+
status: row.status,
|
|
175
|
+
source: row.source,
|
|
176
|
+
title: row.title,
|
|
177
|
+
startsAt: row.starts_at,
|
|
178
|
+
endsAt: row.ends_at,
|
|
179
|
+
overrideReason: row.override_reason,
|
|
180
|
+
createdAt: row.created_at,
|
|
181
|
+
updatedAt: row.updated_at
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
function addMinutes(date, minutes) {
|
|
185
|
+
return new Date(date.getTime() + minutes * 60 * 1000);
|
|
186
|
+
}
|
|
187
|
+
function normalizeRules(rules) {
|
|
188
|
+
return calendarSchedulingRulesSchema.parse(rules ?? DEFAULT_SCHEDULING_RULES);
|
|
189
|
+
}
|
|
190
|
+
export function storeEncryptedSecret(secretId, cipherText, description = "") {
|
|
191
|
+
const now = nowIso();
|
|
192
|
+
getDatabase()
|
|
193
|
+
.prepare(`INSERT INTO stored_secrets (id, cipher_text, description, created_at, updated_at)
|
|
194
|
+
VALUES (?, ?, ?, ?, ?)
|
|
195
|
+
ON CONFLICT(id) DO UPDATE SET cipher_text = excluded.cipher_text, description = excluded.description, updated_at = excluded.updated_at`)
|
|
196
|
+
.run(secretId, cipherText, description, now, now);
|
|
197
|
+
}
|
|
198
|
+
export function readEncryptedSecret(secretId) {
|
|
199
|
+
const row = getDatabase()
|
|
200
|
+
.prepare(`SELECT id, cipher_text FROM stored_secrets WHERE id = ?`)
|
|
201
|
+
.get(secretId);
|
|
202
|
+
return row?.cipher_text;
|
|
203
|
+
}
|
|
204
|
+
export function deleteEncryptedSecret(secretId) {
|
|
205
|
+
getDatabase().prepare(`DELETE FROM stored_secrets WHERE id = ?`).run(secretId);
|
|
206
|
+
}
|
|
207
|
+
export function listCalendarConnections() {
|
|
208
|
+
const rows = getDatabase()
|
|
209
|
+
.prepare(`SELECT id, provider, label, account_label, status, config_json, credentials_secret_id, forge_calendar_id,
|
|
210
|
+
last_synced_at, last_sync_error, created_at, updated_at
|
|
211
|
+
FROM calendar_connections
|
|
212
|
+
ORDER BY created_at DESC`)
|
|
213
|
+
.all();
|
|
214
|
+
return rows.map(mapConnection);
|
|
215
|
+
}
|
|
216
|
+
export function getCalendarConnectionById(connectionId) {
|
|
217
|
+
const row = getDatabase()
|
|
218
|
+
.prepare(`SELECT id, provider, label, account_label, status, config_json, credentials_secret_id, forge_calendar_id,
|
|
219
|
+
last_synced_at, last_sync_error, created_at, updated_at
|
|
220
|
+
FROM calendar_connections
|
|
221
|
+
WHERE id = ?`)
|
|
222
|
+
.get(connectionId);
|
|
223
|
+
return row ? mapConnection(row) : undefined;
|
|
224
|
+
}
|
|
225
|
+
export function createCalendarConnectionRecord(input) {
|
|
226
|
+
const now = nowIso();
|
|
227
|
+
const id = `calconn_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
228
|
+
getDatabase()
|
|
229
|
+
.prepare(`INSERT INTO calendar_connections (
|
|
230
|
+
id, provider, label, account_label, status, config_json, credentials_secret_id, created_at, updated_at
|
|
231
|
+
)
|
|
232
|
+
VALUES (?, ?, ?, ?, 'connected', ?, ?, ?, ?)`)
|
|
233
|
+
.run(id, input.provider, input.label, input.accountLabel ?? "", JSON.stringify(input.config), input.credentialsSecretId, now, now);
|
|
234
|
+
return getCalendarConnectionById(id);
|
|
235
|
+
}
|
|
236
|
+
export function updateCalendarConnectionRecord(connectionId, patch) {
|
|
237
|
+
const current = getCalendarConnectionById(connectionId);
|
|
238
|
+
if (!current) {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
const next = {
|
|
242
|
+
label: patch.label ?? current.label,
|
|
243
|
+
accountLabel: patch.accountLabel ?? current.accountLabel,
|
|
244
|
+
status: patch.status ?? current.status,
|
|
245
|
+
config: patch.config ?? current.config,
|
|
246
|
+
forgeCalendarId: patch.forgeCalendarId === undefined ? current.forgeCalendarId : patch.forgeCalendarId,
|
|
247
|
+
lastSyncedAt: patch.lastSyncedAt === undefined ? current.lastSyncedAt : patch.lastSyncedAt,
|
|
248
|
+
lastSyncError: patch.lastSyncError === undefined ? current.lastSyncError : patch.lastSyncError,
|
|
249
|
+
updatedAt: nowIso()
|
|
250
|
+
};
|
|
251
|
+
getDatabase()
|
|
252
|
+
.prepare(`UPDATE calendar_connections
|
|
253
|
+
SET label = ?, account_label = ?, status = ?, config_json = ?, forge_calendar_id = ?, last_synced_at = ?, last_sync_error = ?, updated_at = ?
|
|
254
|
+
WHERE id = ?`)
|
|
255
|
+
.run(next.label, next.accountLabel, next.status, JSON.stringify(next.config), next.forgeCalendarId, next.lastSyncedAt, next.lastSyncError, next.updatedAt, connectionId);
|
|
256
|
+
return getCalendarConnectionById(connectionId);
|
|
257
|
+
}
|
|
258
|
+
export function deleteCalendarConnectionRecord(connectionId) {
|
|
259
|
+
const current = getCalendarConnectionById(connectionId);
|
|
260
|
+
if (!current) {
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
getDatabase()
|
|
264
|
+
.prepare(`UPDATE calendar_connections
|
|
265
|
+
SET forge_calendar_id = NULL, updated_at = ?
|
|
266
|
+
WHERE id = ?`)
|
|
267
|
+
.run(nowIso(), connectionId);
|
|
268
|
+
getDatabase().prepare(`DELETE FROM calendar_connections WHERE id = ?`).run(connectionId);
|
|
269
|
+
return current;
|
|
270
|
+
}
|
|
271
|
+
export function deleteExternalEventsForConnection(connectionId) {
|
|
272
|
+
const rows = getDatabase()
|
|
273
|
+
.prepare(`SELECT id
|
|
274
|
+
FROM forge_events
|
|
275
|
+
WHERE ownership = 'external' AND preferred_connection_id = ?`)
|
|
276
|
+
.all(connectionId);
|
|
277
|
+
for (const row of rows) {
|
|
278
|
+
getDatabase().prepare(`DELETE FROM forge_events WHERE id = ?`).run(row.id);
|
|
279
|
+
}
|
|
280
|
+
return rows.map((row) => row.id);
|
|
281
|
+
}
|
|
282
|
+
export function detachConnectionFromForgeEvents(connectionId) {
|
|
283
|
+
const now = nowIso();
|
|
284
|
+
getDatabase()
|
|
285
|
+
.prepare(`UPDATE forge_events
|
|
286
|
+
SET preferred_connection_id = NULL,
|
|
287
|
+
preferred_calendar_id = NULL,
|
|
288
|
+
updated_at = ?
|
|
289
|
+
WHERE ownership = 'forge' AND preferred_connection_id = ?`)
|
|
290
|
+
.run(now, connectionId);
|
|
291
|
+
getDatabase()
|
|
292
|
+
.prepare(`DELETE FROM forge_event_sources
|
|
293
|
+
WHERE connection_id = ?`)
|
|
294
|
+
.run(connectionId);
|
|
295
|
+
}
|
|
296
|
+
export function listCalendars(connectionId, options = {}) {
|
|
297
|
+
const visibilityClause = options.includeUnselected
|
|
298
|
+
? ""
|
|
299
|
+
: connectionId
|
|
300
|
+
? "AND (selected_for_sync = 1 OR forge_managed = 1)"
|
|
301
|
+
: "WHERE (selected_for_sync = 1 OR forge_managed = 1)";
|
|
302
|
+
const rows = getDatabase()
|
|
303
|
+
.prepare(`SELECT id, connection_id, remote_id, title, description, color, timezone, is_primary, can_write, selected_for_sync, forge_managed,
|
|
304
|
+
last_synced_at, created_at, updated_at
|
|
305
|
+
FROM calendar_calendars
|
|
306
|
+
${connectionId ? `WHERE connection_id = ? ${visibilityClause}` : visibilityClause}
|
|
307
|
+
ORDER BY forge_managed DESC, title ASC`)
|
|
308
|
+
.all(...(connectionId ? [connectionId] : []));
|
|
309
|
+
return rows.map(mapCalendar);
|
|
310
|
+
}
|
|
311
|
+
export function getCalendarById(calendarId) {
|
|
312
|
+
const row = getDatabase()
|
|
313
|
+
.prepare(`SELECT id, connection_id, remote_id, title, description, color, timezone, is_primary, can_write, selected_for_sync, forge_managed,
|
|
314
|
+
last_synced_at, created_at, updated_at
|
|
315
|
+
FROM calendar_calendars
|
|
316
|
+
WHERE id = ?`)
|
|
317
|
+
.get(calendarId);
|
|
318
|
+
return row ? mapCalendar(row) : undefined;
|
|
319
|
+
}
|
|
320
|
+
function getDefaultWritableCalendar() {
|
|
321
|
+
const row = getDatabase()
|
|
322
|
+
.prepare(`SELECT id, connection_id, remote_id, title, description, color, timezone, is_primary, can_write, selected_for_sync, forge_managed,
|
|
323
|
+
last_synced_at, created_at, updated_at
|
|
324
|
+
FROM calendar_calendars
|
|
325
|
+
WHERE can_write = 1
|
|
326
|
+
AND (selected_for_sync = 1 OR forge_managed = 1)
|
|
327
|
+
ORDER BY forge_managed DESC, is_primary DESC, title ASC
|
|
328
|
+
LIMIT 1`)
|
|
329
|
+
.get();
|
|
330
|
+
return row ? mapCalendar(row) : undefined;
|
|
331
|
+
}
|
|
332
|
+
export function getCalendarByRemoteId(connectionId, remoteId) {
|
|
333
|
+
const row = getDatabase()
|
|
334
|
+
.prepare(`SELECT id, connection_id, remote_id, title, description, color, timezone, is_primary, can_write, selected_for_sync, forge_managed,
|
|
335
|
+
last_synced_at, created_at, updated_at
|
|
336
|
+
FROM calendar_calendars
|
|
337
|
+
WHERE connection_id = ? AND remote_id = ?`)
|
|
338
|
+
.get(connectionId, remoteId);
|
|
339
|
+
return row ? mapCalendar(row) : undefined;
|
|
340
|
+
}
|
|
341
|
+
export function upsertCalendarRecord(connectionId, input) {
|
|
342
|
+
const existing = getCalendarByRemoteId(connectionId, input.remoteId);
|
|
343
|
+
const now = nowIso();
|
|
344
|
+
if (existing) {
|
|
345
|
+
getDatabase()
|
|
346
|
+
.prepare(`UPDATE calendar_calendars
|
|
347
|
+
SET title = ?, description = ?, color = ?, timezone = ?, is_primary = ?, can_write = ?, selected_for_sync = ?, forge_managed = ?, last_synced_at = ?, updated_at = ?
|
|
348
|
+
WHERE id = ?`)
|
|
349
|
+
.run(input.title, input.description ?? existing.description, input.color ?? existing.color, normalizeTimezone(input.timezone ?? existing.timezone), input.isPrimary ? 1 : 0, input.canWrite === false ? 0 : 1, input.selectedForSync === false ? 0 : 1, input.forgeManaged ? 1 : 0, now, now, existing.id);
|
|
350
|
+
return getCalendarById(existing.id);
|
|
351
|
+
}
|
|
352
|
+
const id = `calendar_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
353
|
+
getDatabase()
|
|
354
|
+
.prepare(`INSERT INTO calendar_calendars (
|
|
355
|
+
id, connection_id, remote_id, title, description, color, timezone, is_primary, can_write, selected_for_sync, forge_managed, last_synced_at, created_at, updated_at
|
|
356
|
+
)
|
|
357
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
358
|
+
.run(id, connectionId, input.remoteId, input.title, input.description ?? "", input.color ?? "#7dd3fc", normalizeTimezone(input.timezone), input.isPrimary ? 1 : 0, input.canWrite === false ? 0 : 1, input.selectedForSync === false ? 0 : 1, input.forgeManaged ? 1 : 0, now, now, now);
|
|
359
|
+
return getCalendarById(id);
|
|
360
|
+
}
|
|
361
|
+
export function listCalendarEvents(query) {
|
|
362
|
+
const clauses = [
|
|
363
|
+
"deleted_at IS NULL",
|
|
364
|
+
`(ownership != 'external' OR preferred_calendar_id IS NULL OR EXISTS (
|
|
365
|
+
SELECT 1
|
|
366
|
+
FROM calendar_calendars visible_calendars
|
|
367
|
+
WHERE visible_calendars.id = forge_events.preferred_calendar_id
|
|
368
|
+
AND (visible_calendars.selected_for_sync = 1 OR visible_calendars.forge_managed = 1)
|
|
369
|
+
))`
|
|
370
|
+
];
|
|
371
|
+
const params = [];
|
|
372
|
+
if (query.connectionId) {
|
|
373
|
+
clauses.push("(preferred_connection_id = ? OR EXISTS (SELECT 1 FROM forge_event_sources src WHERE src.forge_event_id = forge_events.id AND src.connection_id = ?))");
|
|
374
|
+
params.push(query.connectionId);
|
|
375
|
+
params.push(query.connectionId);
|
|
376
|
+
}
|
|
377
|
+
if (query.calendarId) {
|
|
378
|
+
clauses.push("(preferred_calendar_id = ? OR EXISTS (SELECT 1 FROM forge_event_sources src WHERE src.forge_event_id = forge_events.id AND src.calendar_id = ?))");
|
|
379
|
+
params.push(query.calendarId);
|
|
380
|
+
params.push(query.calendarId);
|
|
381
|
+
}
|
|
382
|
+
clauses.push("end_at > ?");
|
|
383
|
+
params.push(query.from);
|
|
384
|
+
clauses.push("start_at < ?");
|
|
385
|
+
params.push(query.to);
|
|
386
|
+
const rows = getDatabase()
|
|
387
|
+
.prepare(`SELECT id, preferred_connection_id, preferred_calendar_id, ownership, origin_type, status, title, description, location,
|
|
388
|
+
start_at, end_at, timezone, is_all_day, availability, event_type, categories_json, deleted_at, created_at, updated_at
|
|
389
|
+
FROM forge_events
|
|
390
|
+
WHERE ${clauses.join(" AND ")}
|
|
391
|
+
ORDER BY start_at ASC, title ASC`)
|
|
392
|
+
.all(...params);
|
|
393
|
+
return rows.map(mapEvent);
|
|
394
|
+
}
|
|
395
|
+
export function getCalendarEventById(eventId) {
|
|
396
|
+
const row = getDatabase()
|
|
397
|
+
.prepare(`SELECT id, preferred_connection_id, preferred_calendar_id, ownership, origin_type, status, title, description, location,
|
|
398
|
+
start_at, end_at, timezone, is_all_day, availability, event_type, categories_json, deleted_at, created_at, updated_at
|
|
399
|
+
FROM forge_events
|
|
400
|
+
WHERE id = ?`)
|
|
401
|
+
.get(eventId);
|
|
402
|
+
return row ? mapEvent(row) : undefined;
|
|
403
|
+
}
|
|
404
|
+
export function getCalendarEventStorageRecord(eventId) {
|
|
405
|
+
return getDatabase()
|
|
406
|
+
.prepare(`SELECT id, preferred_connection_id, preferred_calendar_id, ownership, origin_type, status, title, description, location,
|
|
407
|
+
start_at, end_at, timezone, is_all_day, availability, event_type, categories_json, deleted_at, created_at, updated_at
|
|
408
|
+
FROM forge_events
|
|
409
|
+
WHERE id = ?`)
|
|
410
|
+
.get(eventId);
|
|
411
|
+
}
|
|
412
|
+
export function getCalendarEventByRemoteId(connectionId, calendarId, remoteId) {
|
|
413
|
+
const row = getDatabase()
|
|
414
|
+
.prepare(`SELECT forge_events.id, forge_events.preferred_connection_id, forge_events.preferred_calendar_id, forge_events.ownership,
|
|
415
|
+
forge_events.origin_type, forge_events.status, forge_events.title, forge_events.description, forge_events.location,
|
|
416
|
+
forge_events.start_at, forge_events.end_at, forge_events.timezone, forge_events.is_all_day, forge_events.availability,
|
|
417
|
+
forge_events.event_type, forge_events.categories_json, forge_events.deleted_at, forge_events.created_at, forge_events.updated_at
|
|
418
|
+
FROM forge_event_sources
|
|
419
|
+
INNER JOIN forge_events ON forge_events.id = forge_event_sources.forge_event_id
|
|
420
|
+
WHERE forge_event_sources.connection_id = ? AND forge_event_sources.calendar_id = ? AND forge_event_sources.remote_event_id = ?`)
|
|
421
|
+
.get(connectionId, calendarId, remoteId);
|
|
422
|
+
return row ? mapEvent(row) : undefined;
|
|
423
|
+
}
|
|
424
|
+
export function listCalendarEventSources(eventId) {
|
|
425
|
+
return listEventSourcesForEvent(eventId);
|
|
426
|
+
}
|
|
427
|
+
export function getPrimaryCalendarEventSource(eventId) {
|
|
428
|
+
return listEventSourcesForEvent(eventId)[0] ?? null;
|
|
429
|
+
}
|
|
430
|
+
function upsertEventSource(input) {
|
|
431
|
+
const now = nowIso();
|
|
432
|
+
const existing = getDatabase()
|
|
433
|
+
.prepare(`SELECT id
|
|
434
|
+
FROM forge_event_sources
|
|
435
|
+
WHERE provider = ? AND connection_id IS ? AND calendar_id IS ? AND remote_event_id = ?`)
|
|
436
|
+
.get(input.provider, input.connectionId ?? null, input.calendarId ?? null, input.remoteEventId);
|
|
437
|
+
if (existing) {
|
|
438
|
+
getDatabase()
|
|
439
|
+
.prepare(`UPDATE forge_event_sources
|
|
440
|
+
SET forge_event_id = ?, remote_calendar_id = ?, remote_uid = ?, recurrence_instance_id = ?, is_master_recurring = ?,
|
|
441
|
+
remote_href = ?, remote_etag = ?, sync_state = ?, raw_payload_json = ?, last_synced_at = ?, updated_at = ?
|
|
442
|
+
WHERE id = ?`)
|
|
443
|
+
.run(input.forgeEventId, input.remoteCalendarId ?? null, input.remoteUid ?? null, input.recurrenceInstanceId ?? null, input.isMasterRecurring ? 1 : 0, input.remoteHref ?? null, input.remoteEtag ?? null, input.syncState ?? "synced", input.rawPayloadJson ?? "{}", input.lastSyncedAt ?? null, now, existing.id);
|
|
444
|
+
return existing.id;
|
|
445
|
+
}
|
|
446
|
+
const id = `evsrc_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
447
|
+
getDatabase()
|
|
448
|
+
.prepare(`INSERT INTO forge_event_sources (
|
|
449
|
+
id, forge_event_id, provider, connection_id, calendar_id, remote_calendar_id, remote_event_id, remote_uid,
|
|
450
|
+
recurrence_instance_id, is_master_recurring, remote_href, remote_etag, sync_state, raw_payload_json, last_synced_at, created_at, updated_at
|
|
451
|
+
)
|
|
452
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
453
|
+
.run(id, input.forgeEventId, input.provider, input.connectionId ?? null, input.calendarId ?? null, input.remoteCalendarId ?? null, input.remoteEventId, input.remoteUid ?? null, input.recurrenceInstanceId ?? null, input.isMasterRecurring ? 1 : 0, input.remoteHref ?? null, input.remoteEtag ?? null, input.syncState ?? "synced", input.rawPayloadJson ?? "{}", input.lastSyncedAt ?? null, now, now);
|
|
454
|
+
return id;
|
|
455
|
+
}
|
|
456
|
+
export function registerCalendarEventSourceProjection(input) {
|
|
457
|
+
upsertEventSource(input);
|
|
458
|
+
return listEventSourcesForEvent(input.forgeEventId);
|
|
459
|
+
}
|
|
460
|
+
export function markCalendarEventSourcesSyncState(forgeEventId, syncState) {
|
|
461
|
+
const now = nowIso();
|
|
462
|
+
getDatabase()
|
|
463
|
+
.prepare(`UPDATE forge_event_sources
|
|
464
|
+
SET sync_state = ?, updated_at = ?
|
|
465
|
+
WHERE forge_event_id = ?`)
|
|
466
|
+
.run(syncState, now, forgeEventId);
|
|
467
|
+
}
|
|
468
|
+
function replaceEventLinks(forgeEventId, links) {
|
|
469
|
+
getDatabase().prepare(`DELETE FROM forge_event_links WHERE forge_event_id = ?`).run(forgeEventId);
|
|
470
|
+
const now = nowIso();
|
|
471
|
+
const insert = getDatabase().prepare(`INSERT INTO forge_event_links (id, forge_event_id, entity_type, entity_id, relationship_type, created_at, updated_at)
|
|
472
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
|
473
|
+
for (const link of links) {
|
|
474
|
+
insert.run(`evlink_${randomUUID().replaceAll("-", "").slice(0, 10)}`, forgeEventId, link.entityType, link.entityId, link.relationshipType ?? "context", now, now);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
export function upsertCalendarEventRecord(connectionId, input) {
|
|
478
|
+
const calendar = getCalendarByRemoteId(connectionId, input.calendarRemoteId);
|
|
479
|
+
if (!calendar) {
|
|
480
|
+
throw new Error(`Calendar ${input.calendarRemoteId} is not registered for connection ${connectionId}`);
|
|
481
|
+
}
|
|
482
|
+
const connection = getCalendarConnectionById(connectionId);
|
|
483
|
+
if (!connection) {
|
|
484
|
+
throw new Error(`Calendar connection ${connectionId} is not registered`);
|
|
485
|
+
}
|
|
486
|
+
const existing = getCalendarEventByRemoteId(connectionId, calendar.id, input.remoteId);
|
|
487
|
+
const now = nowIso();
|
|
488
|
+
if (existing) {
|
|
489
|
+
getDatabase()
|
|
490
|
+
.prepare(`UPDATE forge_events
|
|
491
|
+
SET preferred_connection_id = ?, preferred_calendar_id = ?, ownership = ?, origin_type = ?, status = ?, title = ?, description = ?, location = ?,
|
|
492
|
+
start_at = ?, end_at = ?, timezone = ?, is_all_day = ?, availability = ?, event_type = ?, categories_json = ?, deleted_at = ?, updated_at = ?
|
|
493
|
+
WHERE id = ?`)
|
|
494
|
+
.run(connectionId, calendar.id, input.ownership ?? existing.ownership, connection.provider, input.status ?? existing.status, input.title, input.description ?? "", input.location ?? "", input.startAt, input.endAt, calendar.timezone, input.isAllDay ? 1 : 0, input.availability ?? existing.availability, input.eventType ?? "", JSON.stringify(input.categories ?? []), input.deletedAt ?? null, now, existing.id);
|
|
495
|
+
upsertEventSource({
|
|
496
|
+
forgeEventId: existing.id,
|
|
497
|
+
provider: connection.provider,
|
|
498
|
+
connectionId,
|
|
499
|
+
calendarId: calendar.id,
|
|
500
|
+
remoteCalendarId: calendar.remoteId,
|
|
501
|
+
remoteEventId: input.remoteId,
|
|
502
|
+
remoteUid: typeof input.rawPayload?.uid === "string" ? String(input.rawPayload.uid) : null,
|
|
503
|
+
recurrenceInstanceId: typeof input.rawPayload?.recurrenceid === "string"
|
|
504
|
+
? String(input.rawPayload.recurrenceid)
|
|
505
|
+
: null,
|
|
506
|
+
isMasterRecurring: Boolean(input.rawPayload?.rrule),
|
|
507
|
+
remoteHref: input.remoteHref ?? null,
|
|
508
|
+
remoteEtag: input.remoteEtag ?? null,
|
|
509
|
+
syncState: input.deletedAt ? "deleted" : "synced",
|
|
510
|
+
rawPayloadJson: JSON.stringify(input.rawPayload ?? {}),
|
|
511
|
+
lastSyncedAt: input.remoteUpdatedAt ?? now
|
|
512
|
+
});
|
|
513
|
+
return getCalendarEventById(existing.id);
|
|
514
|
+
}
|
|
515
|
+
const id = `calevent_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
516
|
+
getDatabase()
|
|
517
|
+
.prepare(`INSERT INTO forge_events (
|
|
518
|
+
id, preferred_connection_id, preferred_calendar_id, ownership, origin_type, status, title, description, location,
|
|
519
|
+
start_at, end_at, timezone, is_all_day, availability, event_type, categories_json, deleted_at, created_at, updated_at
|
|
520
|
+
)
|
|
521
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
522
|
+
.run(id, connectionId, calendar.id, input.ownership ?? "external", connection.provider, input.status ?? "confirmed", input.title, input.description ?? "", input.location ?? "", input.startAt, input.endAt, calendar.timezone, input.isAllDay ? 1 : 0, input.availability ?? "busy", input.eventType ?? "", JSON.stringify(input.categories ?? []), input.deletedAt ?? null, now, now);
|
|
523
|
+
upsertEventSource({
|
|
524
|
+
forgeEventId: id,
|
|
525
|
+
provider: connection.provider,
|
|
526
|
+
connectionId,
|
|
527
|
+
calendarId: calendar.id,
|
|
528
|
+
remoteCalendarId: calendar.remoteId,
|
|
529
|
+
remoteEventId: input.remoteId,
|
|
530
|
+
remoteUid: typeof input.rawPayload?.uid === "string" ? String(input.rawPayload.uid) : null,
|
|
531
|
+
recurrenceInstanceId: typeof input.rawPayload?.recurrenceid === "string"
|
|
532
|
+
? String(input.rawPayload.recurrenceid)
|
|
533
|
+
: null,
|
|
534
|
+
isMasterRecurring: Boolean(input.rawPayload?.rrule),
|
|
535
|
+
remoteHref: input.remoteHref ?? null,
|
|
536
|
+
remoteEtag: input.remoteEtag ?? null,
|
|
537
|
+
syncState: input.deletedAt ? "deleted" : "synced",
|
|
538
|
+
rawPayloadJson: JSON.stringify(input.rawPayload ?? {}),
|
|
539
|
+
lastSyncedAt: input.remoteUpdatedAt ?? now
|
|
540
|
+
});
|
|
541
|
+
return getCalendarEventById(id);
|
|
542
|
+
}
|
|
543
|
+
export function createCalendarEvent(input) {
|
|
544
|
+
const now = nowIso();
|
|
545
|
+
const id = `calevent_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
546
|
+
const preferredCalendar = input.preferredCalendarId === undefined
|
|
547
|
+
? getDefaultWritableCalendar() ?? null
|
|
548
|
+
: input.preferredCalendarId
|
|
549
|
+
? getCalendarById(input.preferredCalendarId)
|
|
550
|
+
: null;
|
|
551
|
+
getDatabase()
|
|
552
|
+
.prepare(`INSERT INTO forge_events (
|
|
553
|
+
id, preferred_connection_id, preferred_calendar_id, ownership, origin_type, status, title, description, location,
|
|
554
|
+
start_at, end_at, timezone, is_all_day, availability, event_type, categories_json, created_at, updated_at
|
|
555
|
+
)
|
|
556
|
+
VALUES (?, ?, ?, 'forge', 'native', 'confirmed', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
557
|
+
.run(id, preferredCalendar?.connectionId ?? null, preferredCalendar?.id ?? null, input.title, input.description, input.location, input.startAt, input.endAt, normalizeTimezone(input.timezone), input.isAllDay ? 1 : 0, input.availability, input.eventType, JSON.stringify(input.categories), now, now);
|
|
558
|
+
replaceEventLinks(id, input.links);
|
|
559
|
+
return getCalendarEventById(id);
|
|
560
|
+
}
|
|
561
|
+
export function updateCalendarEvent(eventId, patch) {
|
|
562
|
+
const current = getCalendarEventById(eventId);
|
|
563
|
+
if (!current) {
|
|
564
|
+
return undefined;
|
|
565
|
+
}
|
|
566
|
+
const preferredCalendar = patch.preferredCalendarId === undefined
|
|
567
|
+
? current.calendarId
|
|
568
|
+
? getCalendarById(current.calendarId)
|
|
569
|
+
: null
|
|
570
|
+
: patch.preferredCalendarId
|
|
571
|
+
? getCalendarById(patch.preferredCalendarId)
|
|
572
|
+
: null;
|
|
573
|
+
const next = {
|
|
574
|
+
preferredConnectionId: preferredCalendar?.connectionId ?? null,
|
|
575
|
+
preferredCalendarId: patch.preferredCalendarId === undefined
|
|
576
|
+
? current.calendarId
|
|
577
|
+
: patch.preferredCalendarId,
|
|
578
|
+
title: patch.title ?? current.title,
|
|
579
|
+
description: patch.description ?? current.description,
|
|
580
|
+
location: patch.location ?? current.location,
|
|
581
|
+
startAt: patch.startAt ?? current.startAt,
|
|
582
|
+
endAt: patch.endAt ?? current.endAt,
|
|
583
|
+
timezone: normalizeTimezone(patch.timezone ?? current.timezone),
|
|
584
|
+
isAllDay: patch.isAllDay ?? current.isAllDay,
|
|
585
|
+
availability: patch.availability ?? current.availability,
|
|
586
|
+
eventType: patch.eventType ?? current.eventType,
|
|
587
|
+
categories: patch.categories ?? current.categories,
|
|
588
|
+
updatedAt: nowIso()
|
|
589
|
+
};
|
|
590
|
+
getDatabase()
|
|
591
|
+
.prepare(`UPDATE forge_events
|
|
592
|
+
SET preferred_connection_id = ?, preferred_calendar_id = ?, title = ?, description = ?, location = ?,
|
|
593
|
+
start_at = ?, end_at = ?, timezone = ?, is_all_day = ?, availability = ?, event_type = ?, categories_json = ?, updated_at = ?
|
|
594
|
+
WHERE id = ?`)
|
|
595
|
+
.run(next.preferredConnectionId, next.preferredCalendarId, next.title, next.description, next.location, next.startAt, next.endAt, next.timezone, next.isAllDay ? 1 : 0, next.availability, next.eventType, JSON.stringify(next.categories), next.updatedAt, eventId);
|
|
596
|
+
if (patch.links) {
|
|
597
|
+
replaceEventLinks(eventId, patch.links);
|
|
598
|
+
}
|
|
599
|
+
if (current.sourceMappings.length > 0) {
|
|
600
|
+
const nextSyncState = current.deletedAt !== null ? "deleted" : current.originType === "native" ? "pending_update" : "synced";
|
|
601
|
+
getDatabase()
|
|
602
|
+
.prepare(`UPDATE forge_event_sources
|
|
603
|
+
SET sync_state = ?, updated_at = ?
|
|
604
|
+
WHERE forge_event_id = ? AND sync_state != 'deleted'`)
|
|
605
|
+
.run(nextSyncState, next.updatedAt, eventId);
|
|
606
|
+
}
|
|
607
|
+
return getCalendarEventById(eventId);
|
|
608
|
+
}
|
|
609
|
+
export function deleteCalendarEvent(eventId) {
|
|
610
|
+
const current = getCalendarEventById(eventId);
|
|
611
|
+
if (!current) {
|
|
612
|
+
return undefined;
|
|
613
|
+
}
|
|
614
|
+
const deletedAt = nowIso();
|
|
615
|
+
getDatabase()
|
|
616
|
+
.prepare(`UPDATE forge_events
|
|
617
|
+
SET deleted_at = ?, updated_at = ?
|
|
618
|
+
WHERE id = ?`)
|
|
619
|
+
.run(deletedAt, deletedAt, eventId);
|
|
620
|
+
getDatabase()
|
|
621
|
+
.prepare(`UPDATE forge_event_sources
|
|
622
|
+
SET sync_state = CASE WHEN remote_event_id IS NOT NULL THEN 'pending_delete' ELSE sync_state END,
|
|
623
|
+
updated_at = ?
|
|
624
|
+
WHERE forge_event_id = ? AND sync_state != 'deleted'`)
|
|
625
|
+
.run(deletedAt, eventId);
|
|
626
|
+
return getCalendarEventById(eventId);
|
|
627
|
+
}
|
|
628
|
+
export function createWorkBlockTemplate(input) {
|
|
629
|
+
return runInTransaction(() => {
|
|
630
|
+
const now = nowIso();
|
|
631
|
+
const id = `wbtpl_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
632
|
+
getDatabase()
|
|
633
|
+
.prepare(`INSERT INTO work_block_templates (
|
|
634
|
+
id, title, kind, color, timezone, weekdays_json, start_minute, end_minute, starts_on, ends_on, blocking_state, created_at, updated_at
|
|
635
|
+
)
|
|
636
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
637
|
+
.run(id, input.title, input.kind, input.color, normalizeTimezone(input.timezone), JSON.stringify(input.weekDays), input.startMinute, input.endMinute, input.startsOn ?? null, input.endsOn ?? null, input.blockingState, now, now);
|
|
638
|
+
return getWorkBlockTemplateById(id);
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
export function listWorkBlockTemplates() {
|
|
642
|
+
const rows = getDatabase()
|
|
643
|
+
.prepare(`SELECT id, title, kind, color, timezone, weekdays_json, start_minute, end_minute, starts_on, ends_on, blocking_state, created_at, updated_at
|
|
644
|
+
FROM work_block_templates
|
|
645
|
+
ORDER BY COALESCE(starts_on, ''), start_minute ASC, title ASC`)
|
|
646
|
+
.all();
|
|
647
|
+
return rows.map(mapWorkBlockTemplate);
|
|
648
|
+
}
|
|
649
|
+
export function getWorkBlockTemplateById(templateId) {
|
|
650
|
+
const row = getDatabase()
|
|
651
|
+
.prepare(`SELECT id, title, kind, color, timezone, weekdays_json, start_minute, end_minute, starts_on, ends_on, blocking_state, created_at, updated_at
|
|
652
|
+
FROM work_block_templates
|
|
653
|
+
WHERE id = ?`)
|
|
654
|
+
.get(templateId);
|
|
655
|
+
return row ? mapWorkBlockTemplate(row) : undefined;
|
|
656
|
+
}
|
|
657
|
+
export function updateWorkBlockTemplate(templateId, patch) {
|
|
658
|
+
const current = getWorkBlockTemplateById(templateId);
|
|
659
|
+
if (!current) {
|
|
660
|
+
return undefined;
|
|
661
|
+
}
|
|
662
|
+
const next = {
|
|
663
|
+
title: patch.title ?? current.title,
|
|
664
|
+
kind: patch.kind ?? current.kind,
|
|
665
|
+
color: patch.color ?? current.color,
|
|
666
|
+
timezone: normalizeTimezone(patch.timezone ?? current.timezone),
|
|
667
|
+
weekDays: patch.weekDays ?? current.weekDays,
|
|
668
|
+
startMinute: patch.startMinute ?? current.startMinute,
|
|
669
|
+
endMinute: patch.endMinute ?? current.endMinute,
|
|
670
|
+
startsOn: patch.startsOn === undefined ? current.startsOn : patch.startsOn,
|
|
671
|
+
endsOn: patch.endsOn === undefined ? current.endsOn : patch.endsOn,
|
|
672
|
+
blockingState: patch.blockingState ?? current.blockingState,
|
|
673
|
+
updatedAt: nowIso()
|
|
674
|
+
};
|
|
675
|
+
getDatabase()
|
|
676
|
+
.prepare(`UPDATE work_block_templates
|
|
677
|
+
SET title = ?, kind = ?, color = ?, timezone = ?, weekdays_json = ?, start_minute = ?, end_minute = ?, starts_on = ?, ends_on = ?, blocking_state = ?, updated_at = ?
|
|
678
|
+
WHERE id = ?`)
|
|
679
|
+
.run(next.title, next.kind, next.color, next.timezone, JSON.stringify(next.weekDays), next.startMinute, next.endMinute, next.startsOn, next.endsOn, next.blockingState, next.updatedAt, templateId);
|
|
680
|
+
return getWorkBlockTemplateById(templateId);
|
|
681
|
+
}
|
|
682
|
+
export function deleteWorkBlockTemplate(templateId) {
|
|
683
|
+
const current = getWorkBlockTemplateById(templateId);
|
|
684
|
+
if (!current) {
|
|
685
|
+
return undefined;
|
|
686
|
+
}
|
|
687
|
+
getDatabase().prepare(`DELETE FROM work_block_templates WHERE id = ?`).run(templateId);
|
|
688
|
+
return current;
|
|
689
|
+
}
|
|
690
|
+
function deriveWorkBlockInstances(template, query) {
|
|
691
|
+
const queryStart = new Date(query.from);
|
|
692
|
+
const queryEnd = new Date(query.to);
|
|
693
|
+
const start = new Date(Date.UTC(queryStart.getUTCFullYear(), queryStart.getUTCMonth(), queryStart.getUTCDate()));
|
|
694
|
+
const end = new Date(Date.UTC(queryEnd.getUTCFullYear(), queryEnd.getUTCMonth(), queryEnd.getUTCDate()));
|
|
695
|
+
const templateStart = template.startsOn ? dateOnlyToUtcDate(template.startsOn) : null;
|
|
696
|
+
const templateEnd = template.endsOn ? dateOnlyToUtcDate(template.endsOn) : null;
|
|
697
|
+
const firstDay = templateStart && templateStart.getTime() > start.getTime() ? templateStart : start;
|
|
698
|
+
const lastDay = templateEnd && templateEnd.getTime() < end.getTime() ? templateEnd : end;
|
|
699
|
+
if (firstDay.getTime() > lastDay.getTime()) {
|
|
700
|
+
return [];
|
|
701
|
+
}
|
|
702
|
+
const rows = [];
|
|
703
|
+
for (let cursor = new Date(firstDay); cursor <= lastDay; cursor = addMinutes(cursor, 24 * 60)) {
|
|
704
|
+
if (!template.weekDays.includes(cursor.getUTCDay())) {
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
const blockStart = addMinutes(new Date(cursor), template.startMinute);
|
|
708
|
+
const blockEnd = addMinutes(new Date(cursor), template.endMinute);
|
|
709
|
+
if (blockEnd.toISOString() <= query.from || blockStart.toISOString() >= query.to) {
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
rows.push(workBlockInstanceSchema.parse({
|
|
713
|
+
id: `wbinst_${template.id}_${dateOnly(cursor)}`,
|
|
714
|
+
templateId: template.id,
|
|
715
|
+
dateKey: dateOnly(cursor),
|
|
716
|
+
startAt: blockStart.toISOString(),
|
|
717
|
+
endAt: blockEnd.toISOString(),
|
|
718
|
+
title: template.title,
|
|
719
|
+
kind: template.kind,
|
|
720
|
+
color: template.color,
|
|
721
|
+
blockingState: template.blockingState,
|
|
722
|
+
calendarEventId: null,
|
|
723
|
+
createdAt: template.createdAt,
|
|
724
|
+
updatedAt: template.updatedAt
|
|
725
|
+
}));
|
|
726
|
+
}
|
|
727
|
+
return rows;
|
|
728
|
+
}
|
|
729
|
+
export function ensureWorkBlockInstancesInRange(_query) {
|
|
730
|
+
return [];
|
|
731
|
+
}
|
|
732
|
+
export function listWorkBlockInstances(query) {
|
|
733
|
+
return listWorkBlockTemplates()
|
|
734
|
+
.flatMap((template) => deriveWorkBlockInstances(template, query))
|
|
735
|
+
.sort((left, right) => left.startAt.localeCompare(right.startAt) || left.title.localeCompare(right.title));
|
|
736
|
+
}
|
|
737
|
+
export function listTaskTimeboxes(query) {
|
|
738
|
+
const clauses = ["ends_at > ?", "starts_at < ?"];
|
|
739
|
+
const params = [query.from, query.to];
|
|
740
|
+
if (query.taskId) {
|
|
741
|
+
clauses.push("task_id = ?");
|
|
742
|
+
params.push(query.taskId);
|
|
743
|
+
}
|
|
744
|
+
if (query.projectId) {
|
|
745
|
+
clauses.push("project_id = ?");
|
|
746
|
+
params.push(query.projectId);
|
|
747
|
+
}
|
|
748
|
+
const rows = getDatabase()
|
|
749
|
+
.prepare(`SELECT id, task_id, project_id, connection_id, calendar_id, remote_event_id, linked_task_run_id, status, source, title,
|
|
750
|
+
starts_at, ends_at, override_reason, created_at, updated_at
|
|
751
|
+
FROM task_timeboxes
|
|
752
|
+
WHERE ${clauses.join(" AND ")}
|
|
753
|
+
ORDER BY starts_at ASC`)
|
|
754
|
+
.all(...params);
|
|
755
|
+
return rows.map(mapTimebox);
|
|
756
|
+
}
|
|
757
|
+
export function getTaskTimeboxById(timeboxId) {
|
|
758
|
+
const row = getDatabase()
|
|
759
|
+
.prepare(`SELECT id, task_id, project_id, connection_id, calendar_id, remote_event_id, linked_task_run_id, status, source, title,
|
|
760
|
+
starts_at, ends_at, override_reason, created_at, updated_at
|
|
761
|
+
FROM task_timeboxes
|
|
762
|
+
WHERE id = ?`)
|
|
763
|
+
.get(timeboxId);
|
|
764
|
+
return row ? mapTimebox(row) : undefined;
|
|
765
|
+
}
|
|
766
|
+
export function createTaskTimebox(input) {
|
|
767
|
+
const now = nowIso();
|
|
768
|
+
const id = `timebox_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
769
|
+
getDatabase()
|
|
770
|
+
.prepare(`INSERT INTO task_timeboxes (
|
|
771
|
+
id, task_id, project_id, connection_id, calendar_id, linked_task_run_id, status, source, title, starts_at, ends_at, override_reason, created_at, updated_at
|
|
772
|
+
)
|
|
773
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
774
|
+
.run(id, input.taskId, input.projectId ?? null, input.connectionId ?? null, input.calendarId ?? null, input.linkedTaskRunId ?? null, input.status ?? "planned", input.source ?? "manual", input.title, input.startsAt, input.endsAt, input.overrideReason ?? null, now, now);
|
|
775
|
+
return getTaskTimeboxById(id);
|
|
776
|
+
}
|
|
777
|
+
export function updateTaskTimebox(timeboxId, patch) {
|
|
778
|
+
const current = getTaskTimeboxById(timeboxId);
|
|
779
|
+
if (!current) {
|
|
780
|
+
return undefined;
|
|
781
|
+
}
|
|
782
|
+
const next = {
|
|
783
|
+
connectionId: patch.connectionId === undefined ? current.connectionId : patch.connectionId,
|
|
784
|
+
calendarId: patch.calendarId === undefined ? current.calendarId : patch.calendarId,
|
|
785
|
+
remoteEventId: patch.remoteEventId === undefined ? current.remoteEventId : patch.remoteEventId,
|
|
786
|
+
linkedTaskRunId: patch.linkedTaskRunId === undefined ? current.linkedTaskRunId : patch.linkedTaskRunId,
|
|
787
|
+
status: patch.status ?? current.status,
|
|
788
|
+
source: patch.source ?? current.source,
|
|
789
|
+
title: patch.title ?? current.title,
|
|
790
|
+
startsAt: patch.startsAt ?? current.startsAt,
|
|
791
|
+
endsAt: patch.endsAt ?? current.endsAt,
|
|
792
|
+
overrideReason: patch.overrideReason === undefined ? current.overrideReason : patch.overrideReason,
|
|
793
|
+
updatedAt: nowIso()
|
|
794
|
+
};
|
|
795
|
+
getDatabase()
|
|
796
|
+
.prepare(`UPDATE task_timeboxes
|
|
797
|
+
SET connection_id = ?, calendar_id = ?, remote_event_id = ?, linked_task_run_id = ?, status = ?, source = ?, title = ?,
|
|
798
|
+
starts_at = ?, ends_at = ?, override_reason = ?, updated_at = ?
|
|
799
|
+
WHERE id = ?`)
|
|
800
|
+
.run(next.connectionId, next.calendarId, next.remoteEventId, next.linkedTaskRunId, next.status, next.source, next.title, next.startsAt, next.endsAt, next.overrideReason, next.updatedAt, timeboxId);
|
|
801
|
+
return getTaskTimeboxById(timeboxId);
|
|
802
|
+
}
|
|
803
|
+
export function deleteTaskTimebox(timeboxId) {
|
|
804
|
+
const current = getTaskTimeboxById(timeboxId);
|
|
805
|
+
if (!current) {
|
|
806
|
+
return undefined;
|
|
807
|
+
}
|
|
808
|
+
getDatabase().prepare(`DELETE FROM task_timeboxes WHERE id = ?`).run(timeboxId);
|
|
809
|
+
return current;
|
|
810
|
+
}
|
|
811
|
+
export function findCoveringTimeboxForTask(taskId, at) {
|
|
812
|
+
const row = getDatabase()
|
|
813
|
+
.prepare(`SELECT id, task_id, project_id, connection_id, calendar_id, remote_event_id, linked_task_run_id, status, source, title,
|
|
814
|
+
starts_at, ends_at, override_reason, created_at, updated_at
|
|
815
|
+
FROM task_timeboxes
|
|
816
|
+
WHERE task_id = ? AND starts_at <= ? AND ends_at >= ?
|
|
817
|
+
ORDER BY starts_at DESC
|
|
818
|
+
LIMIT 1`)
|
|
819
|
+
.get(taskId, at.toISOString(), at.toISOString());
|
|
820
|
+
return row ? mapTimebox(row) : undefined;
|
|
821
|
+
}
|
|
822
|
+
export function bindTaskRunToTimebox(input) {
|
|
823
|
+
return runInTransaction(() => {
|
|
824
|
+
const existing = findCoveringTimeboxForTask(input.taskId, input.startedAt);
|
|
825
|
+
const startsAt = existing?.startsAt ?? input.startedAt.toISOString();
|
|
826
|
+
const endsAt = existing?.endsAt ??
|
|
827
|
+
addMinutes(input.startedAt, Math.max(15, Math.ceil((input.plannedDurationSeconds ?? 30 * 60) / 60))).toISOString();
|
|
828
|
+
if (existing) {
|
|
829
|
+
return updateTaskTimebox(existing.id, {
|
|
830
|
+
linkedTaskRunId: input.taskRunId,
|
|
831
|
+
status: "active",
|
|
832
|
+
title: input.title,
|
|
833
|
+
startsAt,
|
|
834
|
+
endsAt,
|
|
835
|
+
overrideReason: input.overrideReason ?? existing.overrideReason
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
return createTaskTimebox({
|
|
839
|
+
taskId: input.taskId,
|
|
840
|
+
projectId: input.projectId ?? null,
|
|
841
|
+
linkedTaskRunId: input.taskRunId,
|
|
842
|
+
status: "active",
|
|
843
|
+
source: "live_run",
|
|
844
|
+
title: input.title,
|
|
845
|
+
startsAt,
|
|
846
|
+
endsAt,
|
|
847
|
+
overrideReason: input.overrideReason ?? null
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
export function heartbeatTaskRunTimebox(taskRunId, patch) {
|
|
852
|
+
const row = getDatabase()
|
|
853
|
+
.prepare(`SELECT id
|
|
854
|
+
FROM task_timeboxes
|
|
855
|
+
WHERE linked_task_run_id = ?
|
|
856
|
+
ORDER BY updated_at DESC
|
|
857
|
+
LIMIT 1`)
|
|
858
|
+
.get(taskRunId);
|
|
859
|
+
if (!row) {
|
|
860
|
+
return undefined;
|
|
861
|
+
}
|
|
862
|
+
return updateTaskTimebox(row.id, {
|
|
863
|
+
title: patch.title,
|
|
864
|
+
endsAt: patch.endsAt,
|
|
865
|
+
status: "active",
|
|
866
|
+
overrideReason: patch.overrideReason ?? undefined
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
export function finalizeTaskRunTimebox(taskRunId, status, endsAt) {
|
|
870
|
+
const row = getDatabase()
|
|
871
|
+
.prepare(`SELECT id
|
|
872
|
+
FROM task_timeboxes
|
|
873
|
+
WHERE linked_task_run_id = ?
|
|
874
|
+
ORDER BY updated_at DESC
|
|
875
|
+
LIMIT 1`)
|
|
876
|
+
.get(taskRunId);
|
|
877
|
+
if (!row) {
|
|
878
|
+
return undefined;
|
|
879
|
+
}
|
|
880
|
+
return updateTaskTimebox(row.id, {
|
|
881
|
+
status,
|
|
882
|
+
endsAt,
|
|
883
|
+
linkedTaskRunId: taskRunId
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
function matchKeywords(keywords, haystack) {
|
|
887
|
+
if (keywords.length === 0) {
|
|
888
|
+
return false;
|
|
889
|
+
}
|
|
890
|
+
const normalized = haystack.toLowerCase();
|
|
891
|
+
return keywords.some((keyword) => normalized.includes(keyword.toLowerCase()));
|
|
892
|
+
}
|
|
893
|
+
export function evaluateSchedulingForTask(task, at = new Date()) {
|
|
894
|
+
const project = task.projectId ? getProjectById(task.projectId) ?? null : null;
|
|
895
|
+
const effectiveRules = normalizeRules(task.schedulingRules ?? project?.schedulingRules);
|
|
896
|
+
const currentEvents = listCalendarEvents({
|
|
897
|
+
from: addMinutes(at, -1).toISOString(),
|
|
898
|
+
to: addMinutes(at, 1).toISOString()
|
|
899
|
+
}).filter((event) => event.startAt <= at.toISOString() && event.endAt >= at.toISOString());
|
|
900
|
+
const currentBlocks = listWorkBlockInstances({
|
|
901
|
+
from: addMinutes(at, -1).toISOString(),
|
|
902
|
+
to: addMinutes(at, 1).toISOString()
|
|
903
|
+
}).filter((block) => block.startAt <= at.toISOString() && block.endAt >= at.toISOString());
|
|
904
|
+
const conflicts = [];
|
|
905
|
+
for (const event of currentEvents) {
|
|
906
|
+
if ((event.calendarId ? effectiveRules.blockCalendarIds.includes(event.calendarId) : false) ||
|
|
907
|
+
effectiveRules.blockEventTypes.includes(event.eventType) ||
|
|
908
|
+
effectiveRules.blockAvailability.includes(event.availability) ||
|
|
909
|
+
matchKeywords(effectiveRules.blockEventKeywords, `${event.title}\n${event.description}\n${event.location}`)) {
|
|
910
|
+
conflicts.push(calendarContextConflictSchema.parse({
|
|
911
|
+
kind: "external_event",
|
|
912
|
+
id: event.id,
|
|
913
|
+
title: event.title,
|
|
914
|
+
reason: "The active calendar event blocks this task or project.",
|
|
915
|
+
startsAt: event.startAt,
|
|
916
|
+
endsAt: event.endAt
|
|
917
|
+
}));
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
for (const block of currentBlocks) {
|
|
921
|
+
if (effectiveRules.blockWorkBlockKinds.includes(block.kind) ||
|
|
922
|
+
(effectiveRules.allowWorkBlockKinds.length > 0 &&
|
|
923
|
+
!effectiveRules.allowWorkBlockKinds.includes(block.kind))) {
|
|
924
|
+
conflicts.push(calendarContextConflictSchema.parse({
|
|
925
|
+
kind: "work_block",
|
|
926
|
+
id: block.id,
|
|
927
|
+
title: block.title,
|
|
928
|
+
reason: "The current work block does not allow this task or project.",
|
|
929
|
+
startsAt: block.startAt,
|
|
930
|
+
endsAt: block.endAt
|
|
931
|
+
}));
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
const anyAllowRules = effectiveRules.allowWorkBlockKinds.length > 0 ||
|
|
935
|
+
effectiveRules.allowCalendarIds.length > 0 ||
|
|
936
|
+
effectiveRules.allowEventTypes.length > 0 ||
|
|
937
|
+
effectiveRules.allowEventKeywords.length > 0 ||
|
|
938
|
+
effectiveRules.allowAvailability.length > 0;
|
|
939
|
+
let allowSatisfied = !anyAllowRules;
|
|
940
|
+
if (anyAllowRules) {
|
|
941
|
+
const syntheticFreeAllowed = effectiveRules.allowAvailability.includes("free") &&
|
|
942
|
+
currentEvents.every((event) => event.availability !== "busy");
|
|
943
|
+
allowSatisfied = syntheticFreeAllowed;
|
|
944
|
+
if (!allowSatisfied) {
|
|
945
|
+
allowSatisfied = currentBlocks.some((block) => effectiveRules.allowWorkBlockKinds.includes(block.kind));
|
|
946
|
+
}
|
|
947
|
+
if (!allowSatisfied) {
|
|
948
|
+
allowSatisfied = currentEvents.some((event) => (effectiveRules.allowCalendarIds.length === 0 ||
|
|
949
|
+
(event.calendarId ? effectiveRules.allowCalendarIds.includes(event.calendarId) : false)) &&
|
|
950
|
+
(effectiveRules.allowEventTypes.length === 0 ||
|
|
951
|
+
effectiveRules.allowEventTypes.includes(event.eventType)) &&
|
|
952
|
+
(effectiveRules.allowAvailability.length === 0 ||
|
|
953
|
+
effectiveRules.allowAvailability.includes(event.availability)) &&
|
|
954
|
+
(effectiveRules.allowEventKeywords.length === 0 ||
|
|
955
|
+
matchKeywords(effectiveRules.allowEventKeywords, `${event.title}\n${event.description}\n${event.location}`)));
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
if (!allowSatisfied) {
|
|
959
|
+
conflicts.push({
|
|
960
|
+
kind: currentBlocks[0] ? "work_block" : "external_event",
|
|
961
|
+
id: currentBlocks[0]?.id ?? currentEvents[0]?.id ?? "calendar_now",
|
|
962
|
+
title: currentBlocks[0]?.title ?? currentEvents[0]?.title ?? "Current context",
|
|
963
|
+
reason: "The current calendar context does not match the allowed rules for this task or project.",
|
|
964
|
+
startsAt: currentBlocks[0]?.startAt ?? currentEvents[0]?.startAt ?? at.toISOString(),
|
|
965
|
+
endsAt: currentBlocks[0]?.endAt ?? currentEvents[0]?.endAt ?? at.toISOString()
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
return {
|
|
969
|
+
blocked: conflicts.length > 0,
|
|
970
|
+
effectiveRules,
|
|
971
|
+
conflicts
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
function collectBusyIntervals(query) {
|
|
975
|
+
const busyIntervals = [];
|
|
976
|
+
for (const event of listCalendarEvents(query)) {
|
|
977
|
+
if (event.status !== "cancelled" && event.availability === "busy") {
|
|
978
|
+
busyIntervals.push({ startAt: event.startAt, endAt: event.endAt });
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
for (const block of listWorkBlockInstances(query)) {
|
|
982
|
+
if (block.blockingState === "blocked") {
|
|
983
|
+
busyIntervals.push({ startAt: block.startAt, endAt: block.endAt });
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
for (const timebox of listTaskTimeboxes(query)) {
|
|
987
|
+
if (timebox.status !== "cancelled") {
|
|
988
|
+
busyIntervals.push({ startAt: timebox.startsAt, endAt: timebox.endsAt });
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
return busyIntervals.sort((left, right) => left.startAt.localeCompare(right.startAt));
|
|
992
|
+
}
|
|
993
|
+
function hasOverlap(busyIntervals, startsAt, endsAt) {
|
|
994
|
+
return busyIntervals.some((interval) => Date.parse(interval.startAt) < endsAt.getTime() &&
|
|
995
|
+
Date.parse(interval.endAt) > startsAt.getTime());
|
|
996
|
+
}
|
|
997
|
+
export function suggestTaskTimeboxes(taskId, options = {}) {
|
|
998
|
+
const task = getTaskById(taskId);
|
|
999
|
+
if (!task) {
|
|
1000
|
+
return [];
|
|
1001
|
+
}
|
|
1002
|
+
const from = options.from ? new Date(options.from) : new Date();
|
|
1003
|
+
const to = options.to ? new Date(options.to) : addMinutes(from, 14 * 24 * 60);
|
|
1004
|
+
const durationMinutes = Math.max(15, Math.ceil(((task.plannedDurationSeconds ?? 30 * 60) / 60)));
|
|
1005
|
+
const query = { from: from.toISOString(), to: to.toISOString() };
|
|
1006
|
+
ensureWorkBlockInstancesInRange(query);
|
|
1007
|
+
const busyIntervals = collectBusyIntervals(query);
|
|
1008
|
+
const allowedBlocks = listWorkBlockInstances(query).filter((block) => block.blockingState === "allowed");
|
|
1009
|
+
const suggestions = [];
|
|
1010
|
+
const candidateWindows = allowedBlocks.length > 0
|
|
1011
|
+
? allowedBlocks.map((block) => ({ start: new Date(block.startAt), end: new Date(block.endAt) }))
|
|
1012
|
+
: Array.from({ length: 14 }, (_, index) => {
|
|
1013
|
+
const day = addMinutes(new Date(from), index * 24 * 60);
|
|
1014
|
+
const start = new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate(), 8, 0, 0));
|
|
1015
|
+
const end = new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate(), 18, 0, 0));
|
|
1016
|
+
return { start, end };
|
|
1017
|
+
});
|
|
1018
|
+
for (const window of candidateWindows) {
|
|
1019
|
+
for (let cursor = new Date(window.start); cursor.getTime() + durationMinutes * 60 * 1000 <= window.end.getTime(); cursor = addMinutes(cursor, 30)) {
|
|
1020
|
+
const slotEnd = addMinutes(cursor, durationMinutes);
|
|
1021
|
+
if (hasOverlap(busyIntervals, cursor, slotEnd)) {
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
const evaluation = evaluateSchedulingForTask(task, addMinutes(cursor, Math.floor(durationMinutes / 2)));
|
|
1025
|
+
if (evaluation.blocked) {
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
suggestions.push(taskTimeboxSchema.parse({
|
|
1029
|
+
id: `suggested_${task.id}_${cursor.getTime()}`,
|
|
1030
|
+
taskId: task.id,
|
|
1031
|
+
projectId: task.projectId,
|
|
1032
|
+
connectionId: null,
|
|
1033
|
+
calendarId: null,
|
|
1034
|
+
remoteEventId: null,
|
|
1035
|
+
linkedTaskRunId: null,
|
|
1036
|
+
status: "planned",
|
|
1037
|
+
source: "suggested",
|
|
1038
|
+
title: task.title,
|
|
1039
|
+
startsAt: cursor.toISOString(),
|
|
1040
|
+
endsAt: slotEnd.toISOString(),
|
|
1041
|
+
overrideReason: null,
|
|
1042
|
+
createdAt: nowIso(),
|
|
1043
|
+
updatedAt: nowIso()
|
|
1044
|
+
}));
|
|
1045
|
+
if (suggestions.length >= (options.limit ?? 6)) {
|
|
1046
|
+
return suggestions;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
return suggestions;
|
|
1051
|
+
}
|
|
1052
|
+
export function getCalendarOverview(query) {
|
|
1053
|
+
ensureWorkBlockInstancesInRange(query);
|
|
1054
|
+
return calendarOverviewPayloadSchema.parse({
|
|
1055
|
+
generatedAt: nowIso(),
|
|
1056
|
+
providers: [
|
|
1057
|
+
{
|
|
1058
|
+
provider: "google",
|
|
1059
|
+
label: "Google Calendar",
|
|
1060
|
+
supportsDedicatedForgeCalendar: true,
|
|
1061
|
+
connectionHelp: "Use a Google refresh token plus client credentials to sync calendars and publish Forge-owned events and timeboxes."
|
|
1062
|
+
},
|
|
1063
|
+
{
|
|
1064
|
+
provider: "apple",
|
|
1065
|
+
label: "Apple Calendar",
|
|
1066
|
+
supportsDedicatedForgeCalendar: true,
|
|
1067
|
+
connectionHelp: "Use your Apple ID email and an app-specific password. Forge discovers the writable calendars from https://caldav.icloud.com."
|
|
1068
|
+
},
|
|
1069
|
+
{
|
|
1070
|
+
provider: "microsoft",
|
|
1071
|
+
label: "Exchange Online",
|
|
1072
|
+
supportsDedicatedForgeCalendar: false,
|
|
1073
|
+
connectionHelp: "Save the Microsoft client ID and redirect URI in Calendar settings first, then sign in with Microsoft. Forge mirrors the selected calendars in read-only mode."
|
|
1074
|
+
},
|
|
1075
|
+
{
|
|
1076
|
+
provider: "caldav",
|
|
1077
|
+
label: "Custom CalDAV",
|
|
1078
|
+
supportsDedicatedForgeCalendar: true,
|
|
1079
|
+
connectionHelp: "Use an account-level CalDAV base URL, then let Forge discover the calendars before selecting sync and write targets."
|
|
1080
|
+
}
|
|
1081
|
+
],
|
|
1082
|
+
connections: listCalendarConnections().map(({ credentialsSecretId: _secret, ...connection }) => connection),
|
|
1083
|
+
calendars: listCalendars(),
|
|
1084
|
+
events: listCalendarEvents(query),
|
|
1085
|
+
workBlockTemplates: listWorkBlockTemplates(),
|
|
1086
|
+
workBlockInstances: listWorkBlockInstances(query),
|
|
1087
|
+
timeboxes: listTaskTimeboxes(query)
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
export function recordCalendarActivity(eventType, entityType, entityId, title, description, context, metadata = {}) {
|
|
1091
|
+
recordActivityEvent({
|
|
1092
|
+
entityType,
|
|
1093
|
+
entityId,
|
|
1094
|
+
eventType,
|
|
1095
|
+
title,
|
|
1096
|
+
description,
|
|
1097
|
+
actor: context.actor ?? null,
|
|
1098
|
+
source: context.source,
|
|
1099
|
+
metadata
|
|
1100
|
+
});
|
|
1101
|
+
}
|