@luckydye/calendar 1.1.1 → 1.1.2
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/package.json +4 -3
- package/src/ActiveCalendarStore.ts +96 -0
- package/src/CalDAVConfig.ts +1000 -0
- package/src/CalDAVSource.ts +506 -0
- package/src/CalendarIntegration.ts +68 -0
- package/src/CalendarInternal.ts +609 -0
- package/src/CalendarStorage.ts +54 -0
- package/src/CalendarView.ts +5290 -0
- package/src/Color.ts +64 -0
- package/src/GoogleCalendarSource.ts +717 -0
- package/src/ICal.ts +400 -0
- package/src/InMemorySource.ts +89 -0
- package/src/IndexedDBStorage.ts +393 -0
- package/src/InhouseBookingSource.ts +237 -0
- package/src/NotificationScheduler.ts +91 -0
- package/src/StatusBar.ts +128 -0
- package/src/StatusMessage.ts +122 -0
- package/src/Theme.ts +228 -0
- package/src/app.css +4 -0
- package/src/app.ts +932 -0
- package/src/lib.ts +4 -0
- package/src/service-worker.js +177 -0
package/src/app.ts
ADDED
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
import { CalendarViewElement } from "./lib.ts";
|
|
2
|
+
import "./CalDAVConfig.ts";
|
|
3
|
+
import type { CalDAVConfigElement } from "./CalDAVConfig.ts";
|
|
4
|
+
import {
|
|
5
|
+
CalendarIntegration,
|
|
6
|
+
type Calendar,
|
|
7
|
+
type CalendarSource,
|
|
8
|
+
} from "./CalendarIntegration.js";
|
|
9
|
+
import { CalDAVSource } from "./CalDAVSource.js";
|
|
10
|
+
import { fetchICalEventsWithNotifications, parseICalEvents, parseICalEventsWithNotifications } from "./ICal.js";
|
|
11
|
+
import { initializeTheme } from "./Theme.js";
|
|
12
|
+
import { InMemorySource } from "./InMemorySource.js";
|
|
13
|
+
import { GoogleCalendarSource } from "./GoogleCalendarSource.js";
|
|
14
|
+
import { InhouseBookingSource } from "./InhouseBookingSource.js";
|
|
15
|
+
import { queueStatus } from "./StatusMessage.js";
|
|
16
|
+
import { activeCalendarStore } from "./ActiveCalendarStore.js";
|
|
17
|
+
import type { CalendarEvent } from "./CalendarInternal.js";
|
|
18
|
+
import "./StatusBar.js";
|
|
19
|
+
import { NotificationScheduler } from "./NotificationScheduler.js";
|
|
20
|
+
import serviceWorkerUrl from "./service-worker.js?url";
|
|
21
|
+
import type { StatusBarElement } from "./StatusBar.js";
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
customElements.define("calendar-view", CalendarViewElement);
|
|
25
|
+
} catch(err) {
|
|
26
|
+
console.error(err);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Initialize theme on app start
|
|
30
|
+
initializeTheme();
|
|
31
|
+
|
|
32
|
+
const calendarElement = document.querySelector(
|
|
33
|
+
"calendar-view",
|
|
34
|
+
) as CalendarViewElement;
|
|
35
|
+
const calendar = calendarElement.internal;
|
|
36
|
+
|
|
37
|
+
let workerPromise;
|
|
38
|
+
|
|
39
|
+
// Register service worker for PWA
|
|
40
|
+
if ("serviceWorker" in navigator) {
|
|
41
|
+
workerPromise = navigator.serviceWorker
|
|
42
|
+
.register(serviceWorkerUrl)
|
|
43
|
+
.catch((error) => {
|
|
44
|
+
console.error("Service Worker registration failed:", error);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Initialize notification scheduler
|
|
49
|
+
const notificationScheduler = new NotificationScheduler(() => workerPromise);
|
|
50
|
+
|
|
51
|
+
async function scheduleNotificationsForEvents(events: CalendarEvent[]): Promise<void> {
|
|
52
|
+
const now = new Date();
|
|
53
|
+
const next24Hours = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
|
54
|
+
const upcoming = events.filter(e => e.start >= now && e.start <= next24Hours && e.reminders?.length);
|
|
55
|
+
for (const event of upcoming) {
|
|
56
|
+
await notificationScheduler.scheduleEventNotifications(event);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Create and add statusbar to app layer
|
|
61
|
+
const statusBar = document.querySelector<StatusBarElement>("status-bar");
|
|
62
|
+
|
|
63
|
+
// Update statusbar data
|
|
64
|
+
function updateStatusBar() {
|
|
65
|
+
statusBar.data = calendarElement.getStatusBarData();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Update statusbar on relevant events
|
|
69
|
+
calendarElement.addEventListener("selection-change", updateStatusBar);
|
|
70
|
+
|
|
71
|
+
// Update statusbar periodically for current time
|
|
72
|
+
setInterval(updateStatusBar, 10000); // Every 10 seconds
|
|
73
|
+
|
|
74
|
+
// Initial update after calendar is ready
|
|
75
|
+
calendar.initPromise.then(async (events) => {
|
|
76
|
+
await scheduleNotificationsForEvents(events);
|
|
77
|
+
updateStatusBar();
|
|
78
|
+
// Also update on any calendar interaction
|
|
79
|
+
calendarElement.addEventListener("event-click", updateStatusBar);
|
|
80
|
+
calendarElement.addEventListener("selection", updateStatusBar);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Request notification permission on first interaction
|
|
84
|
+
async function requestNotificationPermission() {
|
|
85
|
+
if (!("Notification" in window)) return;
|
|
86
|
+
if (Notification.permission === "default") {
|
|
87
|
+
const permission = await Notification.requestPermission();
|
|
88
|
+
if (permission === "granted") {
|
|
89
|
+
queueStatus("Notifications enabled");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
calendarElement.addEventListener("event-click", () => {
|
|
95
|
+
requestNotificationPermission();
|
|
96
|
+
}, { once: true });
|
|
97
|
+
|
|
98
|
+
// Load iCal files
|
|
99
|
+
const integration = new CalendarIntegration(calendar);
|
|
100
|
+
|
|
101
|
+
async function sync(cal: Calendar, options?: { force?: boolean }): Promise<void> {
|
|
102
|
+
const events = await integration.sync(cal, options);
|
|
103
|
+
await scheduleNotificationsForEvents(events);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Initialize calendar with storage first, then sync sources
|
|
107
|
+
(async () => {
|
|
108
|
+
await calendar.initPromise;
|
|
109
|
+
|
|
110
|
+
// Sync CalDAV sources
|
|
111
|
+
await syncCalDAV();
|
|
112
|
+
})();
|
|
113
|
+
|
|
114
|
+
// CalDAV configuration UI
|
|
115
|
+
calendarElement.addEventListener("caldav-config", () => {
|
|
116
|
+
showCalDAVConfig();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
function showCalDAVConfig() {
|
|
120
|
+
// Remove existing modal if present
|
|
121
|
+
const existing = document.querySelector("caldav-config-modal");
|
|
122
|
+
if (existing) {
|
|
123
|
+
existing.remove();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Create modal overlay
|
|
128
|
+
const modal = document.createElement("div");
|
|
129
|
+
modal.className = "caldav-config-modal";
|
|
130
|
+
modal.style.cssText = `
|
|
131
|
+
position: fixed;
|
|
132
|
+
top: 6.5rem;
|
|
133
|
+
left: 0.5rem;
|
|
134
|
+
bottom: 7rem;
|
|
135
|
+
width: 400px;
|
|
136
|
+
z-index: 1000;
|
|
137
|
+
`;
|
|
138
|
+
|
|
139
|
+
// Create CalDAV config component
|
|
140
|
+
const config = document.createElement("caldav-config") as CalDAVConfigElement;
|
|
141
|
+
|
|
142
|
+
config.addEventListener("close", () => {
|
|
143
|
+
modal.remove();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
config.addEventListener("sources-changed", async () => {
|
|
147
|
+
await syncCalDAV();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
modal.appendChild(config);
|
|
151
|
+
document.body.appendChild(modal);
|
|
152
|
+
|
|
153
|
+
// Close on backdrop click
|
|
154
|
+
modal.addEventListener("click", (e) => {
|
|
155
|
+
if (e.target === modal) {
|
|
156
|
+
modal.remove();
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Store for loaded calendars
|
|
162
|
+
const loadedCalendars: Map<string, Calendar> = new Map();
|
|
163
|
+
|
|
164
|
+
function findCalendarForEvent(event: CalendarEvent): Calendar | undefined {
|
|
165
|
+
if (event.calendarId) {
|
|
166
|
+
// Direct ID match (most sources)
|
|
167
|
+
const byId = loadedCalendars.get(event.calendarId);
|
|
168
|
+
if (byId) return byId;
|
|
169
|
+
// CalDAV: calendar.id is "sourceId::url" but event.calendarId is just the URL
|
|
170
|
+
for (const cal of loadedCalendars.values()) {
|
|
171
|
+
if (cal.calendarUrl === event.calendarId) return cal;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
for (const cal of loadedCalendars.values()) {
|
|
175
|
+
if (cal.name === event.calendar) return cal;
|
|
176
|
+
}
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function getActiveCalendar(): Calendar | null {
|
|
181
|
+
return activeCalendarStore.getActiveCalendar();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Create a CalDAV calendar wrapper
|
|
185
|
+
async function createCalDAVCalendars(
|
|
186
|
+
source: CalendarSource,
|
|
187
|
+
): Promise<{ calendars: Calendar[]; userEmail: string }> {
|
|
188
|
+
const serverUrl = source.credentials.serverUrl;
|
|
189
|
+
const username = source.credentials.username;
|
|
190
|
+
const password = source.credentials.password;
|
|
191
|
+
if (!serverUrl || !username || !password) {
|
|
192
|
+
throw new Error("Missing CalDAV credentials");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const caldavSource = new CalDAVSource(
|
|
196
|
+
source.id,
|
|
197
|
+
source.name,
|
|
198
|
+
source.color,
|
|
199
|
+
{
|
|
200
|
+
serverUrl,
|
|
201
|
+
username,
|
|
202
|
+
password,
|
|
203
|
+
},
|
|
204
|
+
source.enabled
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
// Fetch all calendars and the current user's email from the CalDAV server
|
|
208
|
+
const [calendarInfos, userEmail] = await Promise.all([
|
|
209
|
+
caldavSource.fetchCalendars(),
|
|
210
|
+
caldavSource.fetchCurrentUserEmail(),
|
|
211
|
+
]);
|
|
212
|
+
const calendars: Calendar[] = [];
|
|
213
|
+
|
|
214
|
+
for (const calInfo of calendarInfos) {
|
|
215
|
+
const calendarId = `${source.id}::${calInfo.url}`;
|
|
216
|
+
|
|
217
|
+
calendars.push({
|
|
218
|
+
id: calendarId,
|
|
219
|
+
name: calInfo.displayName,
|
|
220
|
+
color: calInfo.color || source.color,
|
|
221
|
+
enabled: source.enabled,
|
|
222
|
+
sourceId: source.id,
|
|
223
|
+
sourceType: "caldav",
|
|
224
|
+
calendarUrl: calInfo.url,
|
|
225
|
+
|
|
226
|
+
async fetchEvents(): Promise<CalendarEvent[]> {
|
|
227
|
+
return caldavSource.fetchEventsForCalendar(calInfo.url, calInfo.displayName, calInfo.color);
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
async createEvent(
|
|
231
|
+
event: Omit<CalendarEvent, "calendar" | "color">,
|
|
232
|
+
): Promise<CalendarEvent> {
|
|
233
|
+
return caldavSource.createEvent({ ...event, calendarId: calInfo.url });
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
async updateEvent(
|
|
237
|
+
id: string,
|
|
238
|
+
updates: Partial<CalendarEvent>,
|
|
239
|
+
): Promise<CalendarEvent> {
|
|
240
|
+
return caldavSource.updateEvent(id, { ...updates, calendarId: calInfo.url });
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
async moveEvent(
|
|
244
|
+
id: string,
|
|
245
|
+
newStart: Date,
|
|
246
|
+
newEnd: Date,
|
|
247
|
+
): Promise<CalendarEvent> {
|
|
248
|
+
return caldavSource.updateEvent(id, { start: newStart, end: newEnd, calendarId: calInfo.url });
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
async deleteEvent(id: string): Promise<void> {
|
|
252
|
+
const eventUrl = `${calInfo.url.replace(/\/$/, '')}/${id}.ics`;
|
|
253
|
+
await caldavSource.request(eventUrl, 'DELETE', undefined, {});
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return { calendars, userEmail };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Create an iCal calendar wrapper
|
|
262
|
+
function createICalCalendar(source: CalendarSource): Calendar {
|
|
263
|
+
return {
|
|
264
|
+
id: source.id,
|
|
265
|
+
name: source.name,
|
|
266
|
+
color: source.color,
|
|
267
|
+
enabled: source.enabled,
|
|
268
|
+
sourceId: source.id,
|
|
269
|
+
sourceType: "ical",
|
|
270
|
+
|
|
271
|
+
async fetchEvents(): Promise<CalendarEvent[]> {
|
|
272
|
+
const result = await fetchICalEventsWithNotifications(source.credentials, source.color, source.name);
|
|
273
|
+
return result.events;
|
|
274
|
+
},
|
|
275
|
+
// iCal calendars are read-only
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Create a Google Calendar wrapper
|
|
280
|
+
function createGoogleCalendar(source: CalendarSource): Calendar {
|
|
281
|
+
const calendarId = source.credentials.calendarId || "primary";
|
|
282
|
+
const googleSource = new GoogleCalendarSource(
|
|
283
|
+
source.id,
|
|
284
|
+
source.name,
|
|
285
|
+
source.color,
|
|
286
|
+
{
|
|
287
|
+
accessToken: source.credentials.accessToken,
|
|
288
|
+
refreshToken: source.credentials.refreshToken,
|
|
289
|
+
tokenExpiry: source.credentials.tokenExpiry,
|
|
290
|
+
calendarId: calendarId,
|
|
291
|
+
},
|
|
292
|
+
source.enabled
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
id: source.id,
|
|
297
|
+
name: source.name,
|
|
298
|
+
color: source.color,
|
|
299
|
+
enabled: source.enabled,
|
|
300
|
+
sourceId: source.id,
|
|
301
|
+
sourceType: "google",
|
|
302
|
+
calendarUrl: "https://calendar.google.com/calendar/u/0/r?tab=mc",
|
|
303
|
+
|
|
304
|
+
async fetchEvents(): Promise<CalendarEvent[]> {
|
|
305
|
+
return googleSource.fetchEvents();
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
async createEvent(
|
|
309
|
+
event: Omit<CalendarEvent, "calendar" | "color">,
|
|
310
|
+
): Promise<CalendarEvent> {
|
|
311
|
+
return googleSource.createEvent(event);
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
async updateEvent(
|
|
315
|
+
id: string,
|
|
316
|
+
updates: Partial<CalendarEvent>,
|
|
317
|
+
): Promise<CalendarEvent> {
|
|
318
|
+
return googleSource.updateEvent(id, updates);
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
async moveEvent(
|
|
322
|
+
id: string,
|
|
323
|
+
newStart: Date,
|
|
324
|
+
newEnd: Date,
|
|
325
|
+
): Promise<CalendarEvent> {
|
|
326
|
+
return googleSource.moveEvent(id, newStart, newEnd);
|
|
327
|
+
},
|
|
328
|
+
|
|
329
|
+
async deleteEvent(id: string): Promise<void> {
|
|
330
|
+
return googleSource.deleteEvent(id);
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Create an Inhouse Booking calendar wrapper
|
|
336
|
+
function createInhouseCalendar(source: CalendarSource): Calendar {
|
|
337
|
+
const inhouseSource = new InhouseBookingSource(
|
|
338
|
+
source.id,
|
|
339
|
+
source.name,
|
|
340
|
+
source.color,
|
|
341
|
+
{
|
|
342
|
+
sessionCookie: source.credentials.sessionCookie,
|
|
343
|
+
employeeId: source.credentials.employeeId,
|
|
344
|
+
unitId: source.credentials.unitId,
|
|
345
|
+
},
|
|
346
|
+
source.enabled
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
id: source.id,
|
|
351
|
+
name: source.name,
|
|
352
|
+
color: source.color,
|
|
353
|
+
enabled: source.enabled,
|
|
354
|
+
sourceId: source.id,
|
|
355
|
+
sourceType: "inhouse",
|
|
356
|
+
|
|
357
|
+
async fetchEvents(): Promise<CalendarEvent[]> {
|
|
358
|
+
return inhouseSource.fetchEvents();
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Create an in-memory calendar wrapper
|
|
364
|
+
function createInMemoryCalendar(source: InMemorySource): Calendar {
|
|
365
|
+
return {
|
|
366
|
+
id: source.id,
|
|
367
|
+
name: source.name,
|
|
368
|
+
color: source.color,
|
|
369
|
+
enabled: source.enabled,
|
|
370
|
+
sourceId: source.id,
|
|
371
|
+
sourceType: "in-memory",
|
|
372
|
+
|
|
373
|
+
async fetchEvents(): Promise<CalendarEvent[]> {
|
|
374
|
+
return source.fetchEvents();
|
|
375
|
+
},
|
|
376
|
+
|
|
377
|
+
async createEvent(
|
|
378
|
+
event: Omit<CalendarEvent, "calendar" | "color">,
|
|
379
|
+
): Promise<CalendarEvent> {
|
|
380
|
+
if (!source.createEvent)
|
|
381
|
+
throw new Error("Source does not support createEvent");
|
|
382
|
+
return source.createEvent(event);
|
|
383
|
+
},
|
|
384
|
+
|
|
385
|
+
async updateEvent(
|
|
386
|
+
id: string,
|
|
387
|
+
updates: Partial<CalendarEvent>,
|
|
388
|
+
): Promise<CalendarEvent> {
|
|
389
|
+
if (!source.updateEvent)
|
|
390
|
+
throw new Error("Source does not support updateEvent");
|
|
391
|
+
return source.updateEvent(id, updates);
|
|
392
|
+
},
|
|
393
|
+
|
|
394
|
+
async moveEvent(
|
|
395
|
+
id: string,
|
|
396
|
+
newStart: Date,
|
|
397
|
+
newEnd: Date,
|
|
398
|
+
): Promise<CalendarEvent> {
|
|
399
|
+
if (!source.moveEvent)
|
|
400
|
+
throw new Error("Source does not support moveEvent");
|
|
401
|
+
return source.moveEvent(id, newStart, newEnd);
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
async deleteEvent(id: string): Promise<void> {
|
|
405
|
+
if (!source.deleteEvent)
|
|
406
|
+
throw new Error("Source does not support deleteEvent");
|
|
407
|
+
return source.deleteEvent(id);
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Keep track of configured source IDs to detect removals
|
|
413
|
+
let configuredSourceIds: Set<string> = new Set();
|
|
414
|
+
|
|
415
|
+
// Initial CalDAV sync - sync all configured sources
|
|
416
|
+
async function syncCalDAV(force = false) {
|
|
417
|
+
const saved = localStorage.getItem("caldav-sources");
|
|
418
|
+
if (!saved) return;
|
|
419
|
+
|
|
420
|
+
let sources: CalendarSource[];
|
|
421
|
+
try {
|
|
422
|
+
sources = JSON.parse(saved);
|
|
423
|
+
} catch {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const newSourceIds = new Set(sources.map((s) => s.id));
|
|
428
|
+
|
|
429
|
+
// Remove calendars from sources that are no longer configured
|
|
430
|
+
for (const sourceId of configuredSourceIds) {
|
|
431
|
+
if (!newSourceIds.has(sourceId)) {
|
|
432
|
+
// Unregister all calendars from this source and clean up their events
|
|
433
|
+
for (const [id, calendarObj] of loadedCalendars) {
|
|
434
|
+
if (calendarObj.sourceId === sourceId) {
|
|
435
|
+
// Sync with empty events to trigger stale event deletion
|
|
436
|
+
calendar.sync(calendarObj.name, new Date(), []);
|
|
437
|
+
loadedCalendars.delete(id);
|
|
438
|
+
activeCalendarStore.unregisterCalendar(id);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
configuredSourceIds = newSourceIds;
|
|
444
|
+
|
|
445
|
+
for (const source of sources) {
|
|
446
|
+
if (source.type === "caldav") {
|
|
447
|
+
if (
|
|
448
|
+
!source.credentials?.serverUrl ||
|
|
449
|
+
!source.credentials?.username ||
|
|
450
|
+
!source.credentials?.password
|
|
451
|
+
) {
|
|
452
|
+
console.warn(
|
|
453
|
+
`Skipping CalDAV source ${source.name}: missing credentials`,
|
|
454
|
+
);
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
try {
|
|
459
|
+
const { calendars, userEmail } = await createCalDAVCalendars(source);
|
|
460
|
+
|
|
461
|
+
calendarElement.currentUserEmails.add(userEmail);
|
|
462
|
+
|
|
463
|
+
for (const calendar of calendars) {
|
|
464
|
+
loadedCalendars.set(calendar.id, calendar);
|
|
465
|
+
activeCalendarStore.registerCalendar(calendar);
|
|
466
|
+
|
|
467
|
+
// Sync this calendar
|
|
468
|
+
await sync(calendar, { force });
|
|
469
|
+
}
|
|
470
|
+
} catch (error) {
|
|
471
|
+
console.error(`Sync error for CalDAV source ${source.name}:`, error);
|
|
472
|
+
}
|
|
473
|
+
} else if (source.type === "ical") {
|
|
474
|
+
if (!source.credentials?.url) {
|
|
475
|
+
console.warn(`Skipping iCal source ${source.name}: missing URL`);
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// iCal sources have one calendar
|
|
480
|
+
const calendar = createICalCalendar(source);
|
|
481
|
+
loadedCalendars.set(calendar.id, calendar);
|
|
482
|
+
activeCalendarStore.registerCalendar(calendar);
|
|
483
|
+
|
|
484
|
+
try {
|
|
485
|
+
await sync(calendar, { force });
|
|
486
|
+
} catch (error) {
|
|
487
|
+
console.error(`Sync error for iCal source ${source.name}:`, error);
|
|
488
|
+
}
|
|
489
|
+
} else if (source.type === "google") {
|
|
490
|
+
if (!source.credentials?.accessToken) {
|
|
491
|
+
console.warn(`Skipping Google Calendar source ${source.name}: missing access token`);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Google Calendar sources have one calendar
|
|
496
|
+
const calendar = createGoogleCalendar(source);
|
|
497
|
+
loadedCalendars.set(calendar.id, calendar);
|
|
498
|
+
activeCalendarStore.registerCalendar(calendar);
|
|
499
|
+
|
|
500
|
+
try {
|
|
501
|
+
await sync(calendar, { force });
|
|
502
|
+
} catch (error) {
|
|
503
|
+
console.error(`Sync error for Google Calendar source ${source.name}:`, error);
|
|
504
|
+
}
|
|
505
|
+
} else if (source.type === "inhouse") {
|
|
506
|
+
if (!source.credentials?.sessionCookie || !source.credentials?.employeeId) {
|
|
507
|
+
console.warn(`Skipping Inhouse Booking source ${source.name}: missing session cookie or employee ID`);
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Inhouse Booking sources have one calendar
|
|
512
|
+
const calendar = createInhouseCalendar(source);
|
|
513
|
+
loadedCalendars.set(calendar.id, calendar);
|
|
514
|
+
activeCalendarStore.registerCalendar(calendar);
|
|
515
|
+
|
|
516
|
+
try {
|
|
517
|
+
await sync(calendar, { force });
|
|
518
|
+
} catch (error) {
|
|
519
|
+
console.error(`Sync error for Inhouse Booking source ${source.name}:`, error);
|
|
520
|
+
}
|
|
521
|
+
} else {
|
|
522
|
+
console.warn(`Unknown source type for ${source.name}: ${source.type}`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Update enabled calendars filter
|
|
527
|
+
updateEnabledCalendars();
|
|
528
|
+
|
|
529
|
+
// Update the UI selector and color after sync
|
|
530
|
+
updateActiveCalendarSelector();
|
|
531
|
+
updateActiveCalendarColor();
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Refresh CalDAV every 5 minutes
|
|
535
|
+
setInterval(syncCalDAV, 5 * 60 * 1000);
|
|
536
|
+
|
|
537
|
+
calendarElement.addEventListener("selection", (e) => {
|
|
538
|
+
console.log("Box selection time ranges:", e.detail.timeRanges);
|
|
539
|
+
console.log("Overall range:", e.detail.start, "to", e.detail.end);
|
|
540
|
+
});
|
|
541
|
+
calendarElement.addEventListener("event-click", (e) => {
|
|
542
|
+
console.log("Event clicked:", e.detail);
|
|
543
|
+
});
|
|
544
|
+
calendarElement.addEventListener("selection-change", (e) => {
|
|
545
|
+
console.log("Selection changed:", e.detail.selectedEvents);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
calendarElement.addEventListener("create-event", async (e) => {
|
|
549
|
+
const { start, end } = e.detail;
|
|
550
|
+
const calendar = getActiveCalendar();
|
|
551
|
+
if (!calendar) {
|
|
552
|
+
queueStatus("No calendar selected. Please configure a calendar first.");
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (!calendar.createEvent) {
|
|
557
|
+
queueStatus(`Calendar "${calendar.name}" is read-only.`);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const id = `event-${Date.now()}`;
|
|
562
|
+
console.log("Creating event:", id, start, end, "on calendar:", calendar.name);
|
|
563
|
+
await calendar.createEvent({
|
|
564
|
+
id,
|
|
565
|
+
title: "New Event",
|
|
566
|
+
start,
|
|
567
|
+
end,
|
|
568
|
+
});
|
|
569
|
+
await sync(calendar, { force: true });
|
|
570
|
+
updateStatusBar();
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
calendarElement.addEventListener("move-event", async (e) => {
|
|
574
|
+
const { event, start, end } = e.detail;
|
|
575
|
+
|
|
576
|
+
const calendar = findCalendarForEvent(event);
|
|
577
|
+
|
|
578
|
+
if (!calendar) {
|
|
579
|
+
queueStatus("Cannot move event: calendar not found.");
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (!calendar.moveEvent) {
|
|
584
|
+
queueStatus(`Calendar "${calendar.name}" does not support moving events.`);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
console.log(
|
|
589
|
+
"Moving event:",
|
|
590
|
+
event.id,
|
|
591
|
+
"from",
|
|
592
|
+
event.start,
|
|
593
|
+
"to",
|
|
594
|
+
start,
|
|
595
|
+
"on calendar:",
|
|
596
|
+
calendar.name,
|
|
597
|
+
);
|
|
598
|
+
try {
|
|
599
|
+
await calendar.moveEvent(event.id, start, end);
|
|
600
|
+
await sync(calendar, { force: true });
|
|
601
|
+
console.log("Move completed on calendar:", calendar.name);
|
|
602
|
+
updateStatusBar();
|
|
603
|
+
} catch (error) {
|
|
604
|
+
console.error("Failed to move event:", error);
|
|
605
|
+
queueStatus(`Failed to move event: ${error instanceof Error ? error.message : String(error)}`);
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
calendarElement.addEventListener("update-event", async (e) => {
|
|
610
|
+
const { event, updates } = e.detail;
|
|
611
|
+
|
|
612
|
+
const calendar = findCalendarForEvent(event);
|
|
613
|
+
|
|
614
|
+
if (!calendar) {
|
|
615
|
+
queueStatus("Cannot update event: calendar not found.");
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (!calendar.updateEvent) {
|
|
620
|
+
queueStatus(
|
|
621
|
+
`Calendar "${calendar.name}" does not support updating events.`,
|
|
622
|
+
);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
console.log(
|
|
627
|
+
"Updating event:",
|
|
628
|
+
event
|
|
629
|
+
);
|
|
630
|
+
try {
|
|
631
|
+
await calendar.updateEvent(event.id, updates);
|
|
632
|
+
await sync(calendar, { force: true });
|
|
633
|
+
|
|
634
|
+
if ("reminders" in updates) {
|
|
635
|
+
const eventWithReminders = { ...event, ...updates };
|
|
636
|
+
await notificationScheduler.scheduleEventNotifications(eventWithReminders);
|
|
637
|
+
|
|
638
|
+
if (calendarElement.selectedEventForDetail?.id === event.id) {
|
|
639
|
+
calendarElement.selectedEventForDetail = eventWithReminders;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
console.log("Update completed on calendar:", calendar.name);
|
|
644
|
+
updateStatusBar();
|
|
645
|
+
} catch (error) {
|
|
646
|
+
console.error("Failed to update event:", error);
|
|
647
|
+
queueStatus(`Failed to update event: ${error instanceof Error ? error.message : String(error)}`);
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
calendarElement.addEventListener("delete-events", async (e) => {
|
|
652
|
+
const { events } = e.detail;
|
|
653
|
+
|
|
654
|
+
// Group events by their calendarId
|
|
655
|
+
const eventsByCalendar = new Map<string, typeof events>();
|
|
656
|
+
for (const event of events) {
|
|
657
|
+
const calendarId = event.calendarId ?? "";
|
|
658
|
+
if (!eventsByCalendar.has(calendarId)) {
|
|
659
|
+
eventsByCalendar.set(calendarId, []);
|
|
660
|
+
}
|
|
661
|
+
const calendarEvents = eventsByCalendar.get(calendarId);
|
|
662
|
+
if (calendarEvents) {
|
|
663
|
+
calendarEvents.push(event);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Delete events from each calendar
|
|
668
|
+
for (const [, calendarEvents] of eventsByCalendar) {
|
|
669
|
+
const calendar = findCalendarForEvent(calendarEvents[0]);
|
|
670
|
+
|
|
671
|
+
if (!calendar) {
|
|
672
|
+
queueStatus(
|
|
673
|
+
`Cannot delete events: calendar "${calendarId}" not found.`,
|
|
674
|
+
);
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (!calendar.deleteEvent) {
|
|
679
|
+
queueStatus(
|
|
680
|
+
`Calendar "${calendar.name}" does not support deleting events.`,
|
|
681
|
+
);
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
console.log(
|
|
686
|
+
"Deleting events from",
|
|
687
|
+
calendar.name,
|
|
688
|
+
":",
|
|
689
|
+
calendarEvents.map((ev) => ev.id),
|
|
690
|
+
);
|
|
691
|
+
for (const event of calendarEvents) {
|
|
692
|
+
await calendar.deleteEvent(event.id);
|
|
693
|
+
}
|
|
694
|
+
await sync(calendar, { force: true });
|
|
695
|
+
}
|
|
696
|
+
console.log("Delete completed");
|
|
697
|
+
updateStatusBar();
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
calendarElement.addEventListener("force-sync", async () => {
|
|
701
|
+
queueStatus("Force syncing all calendars...");
|
|
702
|
+
console.log("Force sync triggered");
|
|
703
|
+
|
|
704
|
+
await syncCalDAV(true);
|
|
705
|
+
|
|
706
|
+
queueStatus("Sync completed");
|
|
707
|
+
console.log("Force sync completed");
|
|
708
|
+
updateStatusBar();
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
calendarElement.addEventListener("load-notifications", async () => {
|
|
712
|
+
try {
|
|
713
|
+
const notifications = await notificationScheduler.getScheduledNotifications();
|
|
714
|
+
calendarElement.setScheduledNotifications(notifications);
|
|
715
|
+
} catch (error) {
|
|
716
|
+
console.error("Failed to load scheduled notifications:", error);
|
|
717
|
+
}
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
calendarElement.addEventListener("import-ical", async (e) => {
|
|
721
|
+
const { icalText, fileName } = e.detail;
|
|
722
|
+
const calendar = getActiveCalendar();
|
|
723
|
+
|
|
724
|
+
if (!calendar) {
|
|
725
|
+
queueStatus("No calendar selected. Please configure a calendar first.");
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (!calendar.createEvent) {
|
|
730
|
+
queueStatus(
|
|
731
|
+
`Calendar "${calendar.name}" is read-only and cannot import events.`,
|
|
732
|
+
);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
try {
|
|
737
|
+
const parsedEvents = parseICalEvents(
|
|
738
|
+
icalText,
|
|
739
|
+
calendar.color,
|
|
740
|
+
calendar.name,
|
|
741
|
+
);
|
|
742
|
+
|
|
743
|
+
if (parsedEvents.length === 0) {
|
|
744
|
+
queueStatus(`No events found in ${fileName}`);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
queueStatus(
|
|
749
|
+
`Importing ${parsedEvents.length} event${
|
|
750
|
+
parsedEvents.length === 1 ? "" : "s"
|
|
751
|
+
} from ${fileName} into "${calendar.name}"...`,
|
|
752
|
+
);
|
|
753
|
+
|
|
754
|
+
const firstEventStart = parsedEvents[0].start;
|
|
755
|
+
|
|
756
|
+
for (const event of parsedEvents) {
|
|
757
|
+
await calendar.createEvent({
|
|
758
|
+
id: `imported-${Date.now()}-${Math.random()}`,
|
|
759
|
+
title: event.title,
|
|
760
|
+
start: event.start,
|
|
761
|
+
end: event.end,
|
|
762
|
+
description: event.description,
|
|
763
|
+
location: event.location,
|
|
764
|
+
url: event.url,
|
|
765
|
+
readOnly: false,
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
await sync(calendar, { force: true });
|
|
770
|
+
queueStatus(
|
|
771
|
+
`Successfully imported ${parsedEvents.length} event${
|
|
772
|
+
parsedEvents.length === 1 ? "" : "s"
|
|
773
|
+
} into "${calendar.name}"`,
|
|
774
|
+
);
|
|
775
|
+
|
|
776
|
+
calendarElement.scrollToDate(firstEventStart, 0.5, true, true);
|
|
777
|
+
updateStatusBar();
|
|
778
|
+
} catch (error) {
|
|
779
|
+
console.error("Import error:", error);
|
|
780
|
+
queueStatus(`Failed to import ${fileName}: ${error.message}`);
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
// --- In-Memory Source for Testing ---
|
|
785
|
+
let sampleSource: InMemorySource | null = null;
|
|
786
|
+
|
|
787
|
+
(async () => {
|
|
788
|
+
sampleSource = new InMemorySource('sample', "Sample Events", '#10b981');
|
|
789
|
+
|
|
790
|
+
// Register the sample source as a calendar
|
|
791
|
+
const sampleCalendar = createInMemoryCalendar(sampleSource);
|
|
792
|
+
loadedCalendars.set(sampleCalendar.id, sampleCalendar);
|
|
793
|
+
activeCalendarStore.registerCalendar(sampleCalendar);
|
|
794
|
+
|
|
795
|
+
// Create the active calendar selector UI
|
|
796
|
+
createActiveCalendarSelector();
|
|
797
|
+
|
|
798
|
+
// Update enabled calendars and active calendar color
|
|
799
|
+
updateEnabledCalendars();
|
|
800
|
+
updateActiveCalendarColor();
|
|
801
|
+
})();
|
|
802
|
+
|
|
803
|
+
// --- Active Calendar Selector UI ---
|
|
804
|
+
function createActiveCalendarSelector() {
|
|
805
|
+
// Check if selector already exists in the slot
|
|
806
|
+
if (document.getElementById("active-calendar-selector")) {
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const selector = document.createElement("div");
|
|
811
|
+
selector.id = "active-calendar-selector";
|
|
812
|
+
selector.setAttribute("slot", "toolbar-center");
|
|
813
|
+
selector.style.cssText = `
|
|
814
|
+
display: flex;
|
|
815
|
+
align-items: center;
|
|
816
|
+
gap: 8px;
|
|
817
|
+
font-size: 13px;
|
|
818
|
+
margin-right: auto;
|
|
819
|
+
`;
|
|
820
|
+
|
|
821
|
+
const label = document.createElement("span");
|
|
822
|
+
label.textContent = "Active:";
|
|
823
|
+
label.style.cssText = `
|
|
824
|
+
color: var(--text-muted, rgba(255, 255, 255, 0.5));
|
|
825
|
+
white-space: nowrap;
|
|
826
|
+
`;
|
|
827
|
+
|
|
828
|
+
const select = document.createElement("select");
|
|
829
|
+
select.id = "active-calendar-select";
|
|
830
|
+
select.style.cssText = `
|
|
831
|
+
background: var(--bg-input, rgba(0, 0, 0, 0.3));
|
|
832
|
+
border: 1px solid var(--grid-color, rgba(255, 255, 255, 0.1));
|
|
833
|
+
border-radius: var(--border-radius-sm, 4px);
|
|
834
|
+
color: var(--text-primary, rgba(255, 255, 255, 0.9));
|
|
835
|
+
padding: 4px 8px;
|
|
836
|
+
font-size: 13px;
|
|
837
|
+
cursor: pointer;
|
|
838
|
+
min-width: 150px;
|
|
839
|
+
`;
|
|
840
|
+
|
|
841
|
+
select.addEventListener("change", (e) => {
|
|
842
|
+
const target = e.target as HTMLSelectElement;
|
|
843
|
+
try {
|
|
844
|
+
activeCalendarStore.setActive(target.value || null);
|
|
845
|
+
updateActiveCalendarColor();
|
|
846
|
+
const calendar = activeCalendarStore.getActiveCalendar();
|
|
847
|
+
if (calendar) {
|
|
848
|
+
queueStatus(`Active calendar: ${calendar.name}`);
|
|
849
|
+
}
|
|
850
|
+
} catch (err) {
|
|
851
|
+
console.error("Failed to set active calendar:", err);
|
|
852
|
+
queueStatus(`Error: ${err.message}`);
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
selector.appendChild(label);
|
|
857
|
+
selector.appendChild(select);
|
|
858
|
+
calendarElement.appendChild(selector);
|
|
859
|
+
|
|
860
|
+
// Subscribe to changes and update the selector and color
|
|
861
|
+
activeCalendarStore.subscribe(() => {
|
|
862
|
+
updateActiveCalendarSelector();
|
|
863
|
+
updateActiveCalendarColor();
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
// Initial update
|
|
867
|
+
updateActiveCalendarSelector();
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function updateActiveCalendarColor() {
|
|
871
|
+
const calendar = activeCalendarStore.getActiveCalendar();
|
|
872
|
+
calendarElement.activeCalendarColor = calendar?.color ?? null;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function updateEnabledCalendars() {
|
|
876
|
+
const enabledCalendarIdentifiers = Array.from(loadedCalendars.values())
|
|
877
|
+
.filter(cal => cal.enabled)
|
|
878
|
+
.flatMap(cal => {
|
|
879
|
+
// For CalDAV, use the calendar URL; for others, use the source ID
|
|
880
|
+
const identifiers = [];
|
|
881
|
+
if (cal.calendarUrl) {
|
|
882
|
+
identifiers.push(cal.calendarUrl);
|
|
883
|
+
}
|
|
884
|
+
if (cal.sourceId) {
|
|
885
|
+
identifiers.push(cal.sourceId);
|
|
886
|
+
}
|
|
887
|
+
return identifiers;
|
|
888
|
+
});
|
|
889
|
+
calendar.setEnabledCalendars(enabledCalendarIdentifiers);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function getActiveCalendarSelect(): HTMLSelectElement | null {
|
|
893
|
+
// Look in the light DOM first (where slotted content lives)
|
|
894
|
+
const selector = document.getElementById("active-calendar-selector");
|
|
895
|
+
if (selector) {
|
|
896
|
+
return selector.querySelector(
|
|
897
|
+
"#active-calendar-select",
|
|
898
|
+
) as HTMLSelectElement | null;
|
|
899
|
+
}
|
|
900
|
+
return null;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function updateActiveCalendarSelector() {
|
|
904
|
+
const select = getActiveCalendarSelect();
|
|
905
|
+
if (!select) return;
|
|
906
|
+
|
|
907
|
+
const calendars = activeCalendarStore.getAvailableCalendars();
|
|
908
|
+
const activeId = activeCalendarStore.getActiveId();
|
|
909
|
+
|
|
910
|
+
// Clear current options
|
|
911
|
+
select.innerHTML = "";
|
|
912
|
+
|
|
913
|
+
if (calendars.length === 0) {
|
|
914
|
+
const option = document.createElement("option");
|
|
915
|
+
option.value = "";
|
|
916
|
+
option.textContent = "No calendars";
|
|
917
|
+
option.disabled = true;
|
|
918
|
+
option.selected = true;
|
|
919
|
+
select.appendChild(option);
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
for (const calendar of calendars) {
|
|
924
|
+
const option = document.createElement("option");
|
|
925
|
+
option.value = calendar.id;
|
|
926
|
+
option.textContent = calendar.name;
|
|
927
|
+
if (calendar.id === activeId) {
|
|
928
|
+
option.selected = true;
|
|
929
|
+
}
|
|
930
|
+
select.appendChild(option);
|
|
931
|
+
}
|
|
932
|
+
}
|