Project IT Calendar 1.0.0
Advanced Calendar Applet for Cinnamon Desktop Environment
Loading...
Searching...
No Matches
EventManager.ts
Go to the documentation of this file.
1/**
2 * Project IT Calendar - Event Manager Component
3 * =============================================
4 *
5 * This is the core data management layer of the calendar applet.
6 * It handles all calendar event operations including:
7 * - Synchronization with the system calendar (Evolution Data Server via Cinnamon CalendarServer)
8 * - ICS file import and parsing
9 * - Event creation, modification, and deletion
10 * - In-memory event caching and filtering
11 *
12 * IMPORTANT ARCHITECTURAL CONTEXT:
13 * ---------------------------------
14 * EventManager is a PURE DATA LAYER component:
15 * - NO UI elements or visual rendering
16 * - NO direct user interactions
17 * - All communication is via signals/events
18 *
19 * This strict separation ensures:
20 * - Testability (data operations isolated from UI)
21 * - Reusability (same data layer for different UIs)
22 * - Maintainability (clear separation of concerns)
23 *
24 * ------------------------------------------------------------------
25 * SYSTEM INTEGRATION CHALLENGES:
26 * ------------------------------------------------------------------
27 * This component works around several Cinnamon/GJS limitations:
28 *
29 * 1. Cinnamon CalendarServer (DBus):
30 * - READ-ONLY for event data retrieval
31 * - Cannot create or modify events
32 * - Limited description field support
33 * - Event IDs in "source:event" format
34 *
35 * 2. Evolution Data Server (EDS) via libecal:
36 * - Required for write operations (create/modify/delete)
37 * - Complex API with GJS binding issues
38 * - Permission and source management complexity
39 *
40 * 3. ICS Import Limitations:
41 * - Cinnamon CalendarServer doesn't support bulk imports
42 * - EDS write operations are source-dependent
43 * - Description fields often get lost in translation
44 * - Partial failure handling is complex
45 *
46 * ------------------------------------------------------------------
47 * DATA FLOW ARCHITECTURE:
48 * ------------------------------------------------------------------
49 *
50 * ┌─────────────┐ ┌──────────────┐ ┌──────────────┐
51 * │ UI Layer │ │ EventManager │ │ Data Source │
52 * │ (Calendar- │◄──►│ (THIS) │◄──►│ (EDS/ICS) │
53 * │ View) │ │ │ │ │
54 * └─────────────┘ └──────────────┘ └──────────────┘
55 * │ │ │
56 * │ │ │
57 * ▼ ▼ ▼
58 * Renders UI Caches events Persistent
59 * from events Signals updates storage
60 *
61 * ------------------------------------------------------------------
62 * CRITICAL DESIGN DECISIONS:
63 * ------------------------------------------------------------------
64 * 1. HYBRID MODULE SYSTEM:
65 * Uses 'export' for IDE/AMD support and 'global' assignment for monolithic
66 * bundling. This ensures compatibility with both 'module: None' and 'module: AMD'.
67 *
68 * 2. GJS SIGNALS INTEGRATION:
69 * Uses 'imports.signals' to add event-emitter capabilities. This allows the
70 * View to react to 'events-updated' signals without tight coupling.
71 *
72 * 3. ASYNCHRONOUS DBUS COMMUNICATION:
73 * Communicates with 'org.cinnamon.CalendarServer' via DBus. All calls are
74 * handled asynchronously to keep the UI responsive.
75 *
76 * 4. MODERN GJS STANDARDS:
77 * Uses TextDecoder or .toString() for data conversion instead of legacy
78 * byte-array wrappers where possible.
79 *
80 * ------------------------------------------------------------------
81 * @author Arnold Schiller <calendar@projektit.de>
82 * @link https://github.com/ArnoldSchiller/calendar
83 * @link https://projektit.de/kalender
84 * @license GPL-3.0-or-later
85 */
86/**
87 * @file EventManager.ts
88 * @brief Core data management layer for calendar events
89 *
90 * @details Handles all calendar event operations including synchronization
91 * with Evolution Data Server, ICS import, and event caching.
92 *
93 * @warning This component works around several Cinnamon/GJS limitations
94 * including read-only CalendarServer and complex EDS write operations.
95 *
96 * @author Arnold Schiller <calendar@projektit.de>
97 * @date 2023-2026
98 * @copyright GPL-3.0-or-later
99 */
100
101/**
102 * interface EventData
103 * @brief Internal representation of a calendar event
104 *
105 * @note This structure is optimized for the applet's internal use
106 * and may not map 1:1 with EDS/ICS representations.
107 */
108
109
110/* ================================================================
111 * GJS / CINNAMON IMPORTS
112 * ================================================================
113 *
114 * These are native GNOME/Cinnamon APIs exposed to JavaScript via GJS.
115 *
116 * IMPORTANT: These are NOT npm packages - they're provided by the
117 * runtime environment (Cinnamon/GNOME Shell).
118 */
119
120const Gio = imports.gi.Gio; // File I/O and DBus
121const Cinnamon = imports.gi.Cinnamon; // Cinnamon-specific APIs
122const GLib = imports.gi.GLib; // Low-level utilities
123const Signals = imports.signals; // Event/signal system
124const Mainloop = imports.mainloop; // Timer and main loop
125const ECal = imports.gi.ECal; // Evolution Calendar library
126const ICal = imports.gi.ICalGLib; // iCalendar format handling
127const EDataServer = imports.gi.EDataServer; // Evolution Data Server
128
129/* ================================================================
130 * TYPE DEFINITIONS
131 * ================================================================
132 *
133 * These interfaces define the data structures used throughout the
134 * EventManager. They provide TypeScript type safety during development.
135 */
136
137/**
138 * Internal representation of a calendar event.
139 *
140 * Note: This structure is optimized for the applet's internal use
141 * and may not map 1:1 with EDS/ICS representations.
142 */
143/* Event Data
144 /** @brief Unique event identifier */
145 /** @brief Calendar source identifier (EDS source UID) */
146 /** @brief Event start time */
147 /** @brief Event end time (for all-day: next day 00:00) */
148 /** @brief Event title/summary */
149 /** @brief Event description (optional) */
150 /** @brief Calendar color in hex format */
151 /** @brief All-day event flag */
152
153/**
154 * @class EventManager
155 * @extends Signals.Signals
156 * @brief Manages all calendar event operations
157 *
158 * @details Primary responsibilities:
159 * - DBus communication with Cinnamon.CalendarServer (read)
160 * - EDS write operations via libecal (create/modify)
161 * - Event caching and filtering
162 * - ICS file import (experimental)
163 *
164 * @note This is a PURE DATA LAYER component with no UI dependencies.
165 */
166
167
168
169export interface EventData {
170 id: string; // Unique event identifier
171 sourceUid: string; // Calendar source identifier (EDS source UID)
172 start: Date; // Event start time
173 end: Date; // Event end time (for all-day: next day 00:00)
174 summary: string; // Event title/description
175 description?: string; // Detailed description (optional)
176 color: string; // Calendar color for visual distinction
177 isFullDay: boolean; // All-day event flag
178}
179
180/**
181 * Date range for event filtering.
182 */
183export interface DateRange {
184 from: Date; // Start of range (inclusive)
185 to: Date; // End of range (exclusive)
186}
187
188/**
189 * TypeScript interface merging for GJS signals.
190 *
191 * This tells TypeScript that EventManager will have signal methods
192 * (connect, disconnect, emit) added at runtime via Signals.addSignalMethods.
193 */
194export interface EventManager extends Signals.Signals {}
195
196/* ================================================================
197 * EVENT MANAGER CLASS
198 * ================================================================
199 *
200 * Main class handling all calendar event operations.
201 *
202 * LIFECYCLE:
203 * 1. Constructor: Initializes DBus connection and loads placeholder data
204 * 2. Ready State: DBus proxy connected, ready for operations
205 * 3. Active: Regular sync with system calendar (60-second intervals)
206 * 4. Cleanup: Automatically handled by Cinnamon
207 */
208
209/**
210 * @class EventManager
211 * @brief Main event manager class
212 *
213 * @details For detailed documentation see the main class documentation.
214 */
215/**
216 * @class EventManager
217 * @brief Main event manager class
218 *
219 * @details For detailed documentation see the main class documentation.
220 */
221export class EventManager {
222 /* ============================================================
223 * PRIVATE PROPERTIES
224 * ============================================================
225 */
226
227 /**
228 * DBus proxy to Cinnamon.CalendarServer.
229 * Used for READ-ONLY event retrieval.
230 */
231 private _server: any = null;
232
233 /**
234 * In-memory cache of calendar events.
235 * Structure: Flat array of EventData objects, filtered on demand.
236 */
237 private _events: EventData[] = [];
238
239 /**
240 * Flag indicating if DBus connection is established.
241 */
242 private _isReady: boolean = false;
243
244 /**
245 * Currently selected date for quick access patterns.
246 */
247 private _selectedDate: Date;
248
249 /**
250 * Applet UUID for logging and identification.
251 */
252 private _uuid: string;
253
254 /**
255 * EDS source registry for write operations.
256 * Null until first write operation is attempted.
257 */
258 private _registry: any | null = null;
259
260 /**
261 * Cache of ECal.Client connections by source UID.
262 * Improves performance for multiple operations on same calendar.
263 */
264 private _clientCache = new Map<string, any>();
265
266 /* ============================================================
267 * CONSTRUCTOR
268 * ============================================================
269 *
270 * Initializes the EventManager with placeholder data and establishes
271 * DBus connection to Cinnamon CalendarServer.
272 *
273 * @param uuid - Unique identifier for logging (typically applet UUID)
274 */
275
276 constructor(uuid: string = "EventManager@default") {
277 this._uuid = uuid;
278 this._selectedDate = new Date();
279
280 // Load placeholder data so UI is never empty
281 this._loadInitialData();
282
283 // Initialize DBus connection (async)
284 this._initProxy();
285
286 // Start periodic sync (every 60 seconds)
287 Mainloop.timeout_add_seconds(60, () => {
288 this.refresh();
289 return true; // Keep timer active
290 });
291 }
292
293 /* ============================================================
294 * INITIALIZATION METHODS
295 * ============================================================
296 */
297
298 /**
299 * Loads placeholder data during startup.
300 *
301 * IMPORTANT UX DECISION:
302 * The UI should NEVER be empty, even during initial loading.
303 * This placeholder gives users immediate visual feedback.
304 */
305 private _loadInitialData(): void {
306 const today = new Date();
307 this._events = [
308 {
309 id: "init-state",
310 sourceUid: "Teststring",
311 start: today,
312 end: today,
313 summary: "Calendar Manager Active",
314 description: "Synchronizing with system calendar...",
315 color: "#3498db",
316 isFullDay: false
317 }
318 ];
319 }
320
321 /**
322 * Initializes DBus proxy connection to Cinnamon CalendarServer.
323 *
324 * This is the PRIMARY data source for READ operations.
325 * The proxy provides:
326 * - System calendar events (Evolution, Google Calendar, etc.)
327 * - Real-time updates via DBus signals
328 * - Filtered event retrieval by date range
329 *
330 * LIMITATION: This proxy is READ-ONLY.
331 */
332 private _initProxy(): void {
333 Cinnamon.CalendarServerProxy.new_for_bus(
334 Gio.BusType.SESSION,
335 Gio.DBusProxyFlags.NONE,
336 "org.cinnamon.CalendarServer",
337 "/org/cinnamon/CalendarServer",
338 null,
339 (obj, res) => {
340 try {
341 this._server = Cinnamon.CalendarServerProxy.new_for_bus_finish(res);
342
343 // Listen for server-side event changes
344 this._server.connect('events-added-or-updated', this._onEventsChanged.bind(this));
345 this._server.connect('events-removed', this._onEventsChanged.bind(this));
346
347 this._isReady = true;
348 this.emit('manager-ready');
349
350 // Initial data fetch for current month
351 this.refresh();
352 } catch (e) {
353 if (typeof global !== 'undefined') {
354 global.logError(`${this._uuid}: DBus Connection Error: ${e}`);
355 }
356 }
357 }
358 );
359 }
360
361 /* ============================================================
362 * PUBLIC API - DATE SELECTION AND FILTERING
363 * ============================================================
364 */
365
366 /**
367 * Sets the currently selected date for quick access patterns.
368 *
369 * @param date - Date to select
370 */
371 public selectDate(date: Date): void {
372 this._selectedDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
373 }
374
375 /**
376 * Gets events for the currently selected date.
377 * Convenience method for common UI pattern.
378 */
379 public getEventsForSelectedDate(): EventData[] {
380 return this.getEventsForDate(this._selectedDate);
381 }
382
383 /**
384 * Checks if any events exist for a specific date.
385 *
386 * @param date - Date to check
387 * @returns true if at least one event exists
388 */
389 public hasEvents(date: Date): boolean {
390 return this.getEventsForDate(date).length > 0;
391 }
392
393 /* ============================================================
394 * PUBLIC API - EVENT RETRIEVAL
395 * ============================================================
396 *
397 * All methods filter the in-memory event cache.
398 * No DBus calls are made here - data is already cached.
399 */
400
401 /**
402 * Gets all events for a specific date.
403 *
404 * @param date - Target date
405 * @returns Array of events occurring on this date
406 */
407 public getEventsForDate(date: Date): EventData[] {
408 const from = new Date(date.getFullYear(), date.getMonth(), date.getDate());
409 const to = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1);
410 return this.getEventsForRange({ from, to });
411 }
412
413 /**
414 * Gets all events for a specific month.
415 *
416 * @param year - Year (e.g., 2026)
417 * @param month - Month (0-11, JavaScript convention)
418 * @returns Array of events in this month
419 */
420 public getEventsForMonth(year: number, month: number): EventData[] {
421 const from = new Date(year, month, 1);
422 const to = new Date(year, month + 1, 0, 23, 59, 59);
423 return this.getEventsForRange({ from, to });
424 }
425
426 /**
427 * Gets all events for a specific year.
428 *
429 * @param year - Year (e.g., 2026)
430 * @returns Array of events in this year
431 */
432 public getEventsForYear(year: number): EventData[] {
433 const from = new Date(year, 0, 1);
434 const to = new Date(year, 11, 31, 23, 59, 59);
435 return this.getEventsForRange({ from, to });
436 }
437
438 /**
439 * Gets events within a date range.
440 *
441 * @param range - Date range (inclusive start, exclusive end)
442 * @returns Array of events overlapping the range, sorted by start time
443 */
444 public getEventsForRange(range: DateRange): EventData[] {
445 const from = range.from.getTime();
446 const to = range.to.getTime();
447
448 return this._events
449 .filter(ev => {
450 const start = ev.start.getTime();
451 const end = ev.end.getTime();
452 return end >= from && start <= to;
453 })
454 .sort((a, b) => a.start.getTime() - b.start.getTime());
455 }
456
457 /* ============================================================
458 * PUBLIC API - DATA SYNCHRONIZATION
459 * ============================================================
460 */
461
462 /**
463 * Fetches events for a specific date range from the server.
464 *
465 * This tells Cinnamon CalendarServer which time window we're interested in.
466 * The server will send events via DBus signals.
467 *
468 * @param start - Start of range
469 * @param end - End of range
470 */
471 public fetchRange(start: Date, end: Date): void {
472 if (!this._server) return;
473 let startUnix = Math.floor(start.getTime() / 1000);
474 let endUnix = Math.floor(end.getTime() / 1000);
475
476 this._server.call_set_time_range(startUnix, endUnix, true, null, (server, res) => {
477 try {
478 this._server.call_set_time_range_finish(res);
479 } catch (e) {
480 // Ignore errors during applet shutdown
481 }
482 });
483 }
484
485 /**
486 * Refreshes the event cache for a 9-month window around current date.
487 *
488 * Window: 2 months back, current month, 6 months forward
489 * This provides smooth scrolling experience.
490 */
491 public refresh(): void {
492 if (!this._server) return;
493 const now = new Date();
494 const start = new Date(now.getFullYear(), now.getMonth() - 2, 1);
495 const end = new Date(now.getFullYear(), now.getMonth() + 7, 0);
496 this.fetchRange(start, end);
497 }
498
499 /* ============================================================
500 * DBUS EVENT HANDLING
501 * ============================================================
502 */
503
504 /**
505 * Callback for DBus event updates.
506 *
507 * Converts raw DBus data to internal EventData format and updates cache.
508 * Emits 'events-updated' signal to notify UI components.
509 *
510 * @param server - DBus proxy (unused)
511 * @param varray - DBus variant array containing event data
512 */
513 private _onEventsChanged(server: any, varray: any): void {
514 const rawEvents = varray.unpack();
515 this._events = rawEvents.map((e: any) => {
516 const [fullId, color, summary, allDay, start, end] = e.deep_unpack();
517
518 let sourceUid = "";
519 let eventId = fullId;
520
521 // Parse "source:event" ID format
522 if (fullId.includes(':')) {
523 const parts = fullId.split(':');
524 sourceUid = parts[0];
525 eventId = parts.slice(1).join(':');
526 }
527
528 return {
529 id: eventId,
530 sourceUid: sourceUid,
531 summary: summary,
532 color: color,
533 start: new Date(start * 1000),
534 end: new Date(end * 1000),
535 isFullDay: allDay
536 };
537 });
538 this.emit('events-updated');
539 }
540
541 /* ============================================================
542 * ICS IMPORT FUNCTIONALITY
543 * ============================================================
544 *
545 * WARNING: This feature has significant limitations:
546 *
547 * 1. Cinnamon CalendarServer doesn't support bulk ICS import
548 * 2. EDS write operations are source-dependent and may fail
549 * 3. Description fields often get lost between ICS and EDS
550 * 4. No rollback for partial import failures
551 *
552 * This is provided as EXPERIMENTAL functionality only.
553 */
554
555 /**
556 * Imports events from an ICS file.
557 *
558 * @param icsPath - Path to .ics file
559 * @param color - Color for imported events (default: "#ff6b6b")
560 * @returns Promise that resolves when import completes
561 */
562 public async importICSFile(icsPath: string, color: string = "#ff6b6b"): Promise<void> {
563 if (!this._server) {
564 global.logError(this._uuid + ": CalendarServer not ready for ICS-Import");
565 return;
566 }
567
568 try {
569 const file = Gio.File.new_for_path(icsPath);
570 const [ok, contents] = await new Promise<[boolean, Uint8Array]>(resolve => {
571 file.load_contents_async(null, (f, res) => {
572 try {
573 const [success, data] = f!.load_contents_finish(res);
574 resolve([success, data]);
575 } catch (e) {
576 resolve([false, new Uint8Array()]);
577 }
578 });
579 });
580
581 if (!ok) throw new Error("Can't read ICS file.");
582 const icsText = contents.toString();
583
584 // Extract VEVENT blocks from ICS file
585 const veventMatches = icsText.match(/BEGIN:VEVENT[\s\S]*?END:VEVENT/g);
586 if (!veventMatches) return;
587
588 let importedCount = 0;
589 for (const veventBlock of veventMatches) {
590 try {
591 const summary = (veventBlock.match(/SUMMARY:(.*)/i)?.[1] || 'Unnamed').trim();
592 const description = (veventBlock.match(/DESCRIPTION:(.*)/i)?.[1] || '').trim();
593 const dtstartMatch = veventBlock.match(/DTSTART(?:;VALUE=DATE)?[:;]([^:\n\r]+)/i);
594 const dtendMatch = veventBlock.match(/DTEND(?:;VALUE=DATE)?[:;]([^:\n\r]+)/i);
595
596 if (!dtstartMatch) continue;
597
598 const startStr = dtstartMatch[1].trim();
599 const endStr = dtendMatch ? dtendMatch[1].trim() : startStr;
600
601 const start = this._parseICSDate(startStr);
602 const end = this._parseICSDate(endStr);
603 const allDay = startStr.length === 8;
604
605 const eventToImport: EventData = {
606 id: "",
607 sourceUid: "",
608 summary: summary,
609 description: description,
610 start: start,
611 end: end,
612 isFullDay: allDay,
613 color: "#3498db"
614 };
615
616 this.addEvent(eventToImport);
617 importedCount++;
618 } catch (e) {
619 global.logError(this._uuid + ": VEVENT parsing error: " + e);
620 }
621 }
622 global.log(this._uuid + `: ${importedCount} Events imported from ${icsPath}`);
623 } catch (e) {
624 global.logError(this._uuid + `: ICS Import Error ${icsPath}: ${e}`);
625 }
626 }
627
628 /**
629 * Parses ICS date strings into JavaScript Date objects.
630 *
631 * Supports both:
632 * - Basic format: 20231231 (all-day events)
633 * - Extended format: 20231231T120000Z (timed events)
634 *
635 * @param icsDate - ICS date string
636 * @returns JavaScript Date object
637 */
638 private _parseICSDate(icsDate: string): Date {
639 if (icsDate.length === 8) {
640 // Basic format: YYYYMMDD
641 return new Date(
642 parseInt(icsDate.substr(0,4)),
643 parseInt(icsDate.substr(4,2))-1,
644 parseInt(icsDate.substr(6,2))
645 );
646 }
647 // Extended format: YYYYMMDDTHHMMSSZ
648 return new Date(icsDate.replace(/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)/, '$1-$2-$3T$4:$5:$6$7'));
649 }
650
651 /* ============================================================
652 * EVENT CREATION AND MODIFICATION
653 * ============================================================
654 *
655 * IMPORTANT ARCHITECTURAL NOTE:
656 *
657 * Cinnamon CalendarServer (DBus) is READ-ONLY for event data.
658 * To support event creation/modification, we must use:
659 *
660 * 1. Evolution Data Server (EDS) via libecal
661 * 2. Direct GObject Introspection (GIR) bindings
662 * 3. iCalendar (RFC 5545) component manipulation
663 *
664 * This bypasses Cinnamon's limited API but introduces complexity
665 * and potential compatibility issues across GNOME/Cinnamon versions.
666 */
667
668 /**
669 * Public entry point for adding or updating events.
670 *
671 * Decides whether to create new event or modify existing one.
672 *
673 * @param ev - Event data
674 */
675 public addEvent(ev: EventData): void {
676 if (ev.id && ev.sourceUid) {
677 // Existing event - modify via EDS
678 this._modifyExistingEvent(ev);
679 } else {
680 // New event - create via EDS
681 this._createNewEvent(ev);
682 }
683 }
684
685 /**
686 * Creates a new event in Evolution Data Server.
687 *
688 * @param ev - Event data
689 */
690 private _createNewEvent(ev: EventData): void {
691 const source = this._getDefaultWritableSource();
692 if (!source) {
693 global.logError("Create: No writable calendar source found");
694 return;
695 }
696
697 // Create iCalendar component
698 const comp = ECal.Component.new();
699 comp.set_new_vtype(ECal.ComponentVType.EVENT);
700
701 // UID is REQUIRED for iCalendar compliance
702 comp.set_uid(ev.id || GLib.uuid_string_random());
703
704 // Set summary (title)
705 const summaryText = ECal.ComponentText.new(
706 ev.summary || "New Event",
707 null
708 );
709 comp.set_summary(summaryText);
710
711 // Set description (optional)
712 if (ev.description && ev.description.trim() !== "") {
713 const descText = ECal.ComponentText.new(ev.description, null);
714 comp.set_description(descText);
715 }
716
717 // Set times
718 const tz = ICal.Timezone.get_utc_timezone();
719 const start = ICal.Time.new_from_timet_with_zone(
720 Math.floor(ev.start.getTime() / 1000),
721 0,
722 tz
723 );
724 const end = ICal.Time.new_from_timet_with_zone(
725 Math.floor(ev.end.getTime() / 1000),
726 0,
727 tz
728 );
729 comp.set_dtstart(start);
730 comp.set_dtend(end);
731
732 // Connect to EDS and create event
733 ECal.Client.connect(
734 source,
735 ECal.ClientSourceType.EVENTS,
736 30,
737 null,
738 (_o, res) => {
739 try {
740 const client = ECal.Client.connect_finish(res);
741
742 client.create_object(comp, null, null, (_c, cres) => {
743 try {
744 client.create_object_finish(cres);
745 global.log("✅ CREATE OK");
746 this.refresh();
747 } catch (e) {
748 global.logError("❌ create_object_finish failed: " + e);
749 }
750 });
751 } catch (e) {
752 global.logError("❌ EDS connection failed: " + e);
753 }
754 }
755 );
756 }
757
758 /**
759 * Modifies an existing event in Evolution Data Server.
760 *
761 * Uses "smart merge" to preserve fields not being modified.
762 *
763 * @param ev - Updated event data
764 */
765 private _modifyExistingEvent(ev: EventData): void {
766 const source = this._resolveSource(ev.sourceUid);
767 if (!source) return;
768
769 ECal.Client.connect(source, ECal.ClientSourceType.EVENTS, 30, null, (_obj, res) => {
770 try {
771 const client = ECal.Client.connect_finish(res);
772
773 if (ev.id && ev.id !== "" && !ev.id.startsWith("ics_")) {
774 // Fetch existing event for smart merge
775 client.get_object(ev.id, null, null, (_obj2: any, getRes: any) => {
776 try {
777 const result = client.get_object_finish(getRes);
778 const icalComp = Array.isArray(result) ? result[1] : result;
779
780 if (icalComp) {
781 let anyChange = false;
782
783 // 1. SUMMARY - only update if changed and not empty
784 const oldSummary = icalComp.get_summary() || "";
785 if (ev.summary && ev.summary.trim() !== "" && ev.summary !== oldSummary) {
786 icalComp.set_summary(ev.summary);
787 anyChange = true;
788 global.log(`${this._uuid}: Update Summary`);
789 }
790
791 // 2. DESCRIPTION - only update if changed and not empty
792 const oldDesc = icalComp.get_description() || "";
793 if (ev.description && ev.description.trim() !== "" && ev.description !== oldDesc) {
794 icalComp.set_description(ev.description);
795 anyChange = true;
796 global.log(`${this._uuid}: Update Description`);
797 }
798
799 // 3. TIMES - preserve duration when changing start time
800 try {
801 const oldStartComp = icalComp.get_dtstart();
802 const oldEndComp = icalComp.get_dtend();
803
804 if (oldStartComp && oldEndComp) {
805 const oldStartTimeObj = (typeof oldStartComp.get_value === 'function') ? oldStartComp.get_value() : oldStartComp;
806 const oldEndTimeObj = (typeof oldEndComp.get_value === 'function') ? oldEndComp.get_value() : oldEndComp;
807
808 const newStartSeconds = Math.floor(ev.start.getTime() / 1000);
809
810 if (oldStartTimeObj.as_timet() !== newStartSeconds) {
811 // Start time changed - preserve original duration
812 const durationSeconds = oldEndTimeObj.as_timet() - oldStartTimeObj.as_timet();
813
814 // Create new start time
815 const tz = ICal.Timezone.get_utc_timezone();
816 let newStart = ICal.Time.new_from_timet_with_zone(newStartSeconds, 0, tz);
817 if (ev.isFullDay) newStart.set_is_date(true);
818
819 // Calculate new end time preserving duration
820 let newEnd = ICal.Time.new_from_timet_with_zone(newStartSeconds + durationSeconds, 0, tz);
821 if (ev.isFullDay) newEnd.set_is_date(true);
822
823 icalComp.set_dtstart(newStart);
824 icalComp.set_dtend(newEnd);
825
826 anyChange = true;
827 global.log(`${this._uuid}: Update Times (preserved ${durationSeconds/3600}h duration)`);
828 }
829 }
830 } catch (e) {
831 global.logWarning(`${this._uuid}: Time merge failed: ${e}`);
832 }
833
834 // 4. Save changes if any were made
835 if (anyChange) {
836 client.modify_object(icalComp, ECal.ObjModType.THIS, 0, null, (_c: any, mRes: any) => {
837 try {
838 client.modify_object_finish(mRes);
839 global.log(`${this._uuid}: Smart merge successful`);
840 this.refresh();
841 } catch (err) {
842 global.logError("Modify finish failed: " + err);
843 }
844 });
845 } else {
846 global.log(`${this._uuid}: No changes needed - master data unchanged`);
847 }
848 }
849 } catch (e) {
850 // Event not found - create as new
851 global.logWarning(`${this._uuid}: Smart merge failed (ID not found), creating new: ${e}`);
852
853 // Build component for new event
854 let fallbackComp = ECal.Component.new();
855 fallbackComp.set_new_vtype(ECal.ComponentVType.EVENT);
856 fallbackComp.set_uid(ev.id || GLib.uuid_string_random());
857
858 this._createAsNew(client, ev, fallbackComp);
859 }
860 });
861 } else {
862 // Invalid ID - create as new event
863 this._createNewEvent(ev);
864 }
865 } catch (e) {
866 global.logError("EDS connection failed: " + e);
867 }
868 });
869 }
870
871 /* ============================================================
872 * EDS SOURCE MANAGEMENT
873 * ============================================================
874 *
875 * Evolution Data Server uses a hierarchical source system:
876 * - Each calendar (Google, Local, Exchange) is a "source"
877 * - Sources can be read-only or writable
878 * - Some sources are aggregates (like "Personal" which may include multiple)
879 */
880
881 /**
882 * Resolves an EDS source by UID.
883 *
884 * @param sUid - Source UID (optional)
885 * @returns EDS source or null if not found/not writable
886 */
887 private _resolveSource(sUid?: string): any {
888 if (!this._registry) {
889 this._registry = EDataServer.SourceRegistry.new_sync(null);
890 }
891
892 if (sUid) {
893 try {
894 let s = this._registry.ref_source(sUid);
895 if (s) return s;
896 } catch (e) {}
897 }
898
899 const sources = this._registry.list_sources(EDataServer.SOURCE_EXTENSION_CALENDAR);
900
901 // 1. Prefer sources with specific names
902 let bestSource = sources.find((s: any) => {
903 try {
904 const ext = s.get_extension(EDataServer.SOURCE_EXTENSION_CALENDAR);
905 // Check if writable
906 const ro = (typeof ext.get_readonly === 'function') ? ext.get_readonly() : ext.readonly;
907 if (ro === true) return false;
908
909 const name = s.get_display_name().toLowerCase();
910 return name.includes("system") || name.includes("personal") || name.includes("local");
911 } catch(e) { return false; }
912 });
913
914 if (bestSource) return bestSource;
915
916 // 2. Fallback: any writable source
917 return sources.find((s: any) => {
918 try {
919 const ext = s.get_extension(EDataServer.SOURCE_EXTENSION_CALENDAR);
920 const ro = (typeof ext.get_readonly === 'function') ? ext.get_readonly() : ext.readonly;
921 return ro !== true;
922 } catch(e) { return false; }
923 });
924 }
925
926 /**
927 * Finds a default writable calendar source.
928 *
929 * CRITICAL: Must have a parent source (not a top-level aggregate).
930 * Some aggregate sources appear writable but fail on write operations.
931 */
932 private _getDefaultWritableSource(): any {
933 if (!this._registry) {
934 this._registry = EDataServer.SourceRegistry.new_sync(null);
935 }
936
937 const sources = this._registry.list_sources(EDataServer.SOURCE_EXTENSION_CALENDAR);
938
939 return sources.find((s: any) => {
940 try {
941 const ext = s.get_extension(EDataServer.SOURCE_EXTENSION_CALENDAR);
942 if (ext.get_readonly && ext.get_readonly()) return false;
943
944 // Must have a parent (exclude top-level aggregates)
945 return s.get_parent() !== null;
946 } catch {
947 return false;
948 }
949 });
950 }
951
952 /* ============================================================
953 * HELPER METHODS
954 * ============================================================
955 */
956
957 /**
958 * Applies event data to an iCalendar component.
959 *
960 * @param ecalComp - ECal component to modify
961 * @param ev - Event data to apply
962 */
963 private _applyEventToComponent(ecalComp: any, ev: EventData): void {
964 // Get underlying iCalendar component
965 const ical = ecalComp.get_icalcomponent();
966
967 // Set UID (required)
968 ical.set_uid(ev.id);
969
970 // Set timestamp (current time)
971 ical.set_dtstamp(
972 ICal.Time.new_current_with_zone(
973 ICal.Timezone.get_utc_timezone()
974 )
975 );
976
977 // Set summary/title
978 if (ev.summary) {
979 const sumProp = ICal.Property.new_summary(ev.summary);
980 ical.add_property(sumProp);
981 }
982
983 // Set description
984 if (ev.description && ev.description.trim() !== "") {
985 const descProp = ICal.Property.new_description(ev.description);
986 ical.add_property(descProp);
987 }
988
989 // Set times
990 const tz = ICal.Timezone.get_utc_timezone();
991
992 let start: any;
993 let end: any;
994
995 if (ev.isFullDay) {
996 // All-day events use DATE format (no time component)
997 start = ICal.Time.new_null_time();
998 start.set_date(
999 ev.start.getFullYear(),
1000 ev.start.getMonth() + 1,
1001 ev.start.getDate()
1002 );
1003 start.set_is_date(true);
1004
1005 end = ICal.Time.new_null_time();
1006 const endDate = new Date(ev.end);
1007 if (endDate.getTime() <= ev.start.getTime()) {
1008 endDate.setDate(ev.start.getDate() + 1);
1009 }
1010 end.set_date(
1011 endDate.getFullYear(),
1012 endDate.getMonth() + 1,
1013 endDate.getDate()
1014 );
1015 end.set_is_date(true);
1016 } else {
1017 // Timed events
1018 start = ICal.Time.new_from_timet_with_zone(
1019 Math.floor(ev.start.getTime() / 1000), 0, tz
1020 );
1021 end = ICal.Time.new_from_timet_with_zone(
1022 Math.floor(ev.end.getTime() / 1000), 0, tz
1023 );
1024 }
1025
1026 ical.set_dtstart(start);
1027 ical.set_dtend(end);
1028 }
1029
1030 /**
1031 * Creates a new event in EDS (fallback method).
1032 *
1033 * @param client - ECal.Client connection
1034 * @param ev - Event data
1035 * @param icalComp - iCalendar component (optional)
1036 */
1037 private _createAsNew(client: any, ev: EventData, icalComp: any): void {
1038 try {
1039 // Use provided component or create new one
1040 let comp = icalComp;
1041 if (!comp) {
1042 comp = this._buildIcalComponent(ev);
1043 } else {
1044 // Ensure component has latest data
1045 this._applyEventToComponent(comp, ev);
1046 }
1047
1048 // Save to EDS (4 arguments required by GJS bindings)
1049 client.create_object(comp, null, null, (_obj: any, res: any) => {
1050 try {
1051 client.create_object_finish(res);
1052 global.log(`${this._uuid}: Event successfully created`);
1053 this.refresh();
1054 } catch (e) {
1055 global.logError(`${this._uuid}: create_object_finish failed: ${e}`);
1056 }
1057 });
1058 } catch (e) {
1059 global.logError(`${this._uuid}: _createAsNew failed: ${e}`);
1060 }
1061 }
1062
1063 /**
1064 * Factory method to build an iCalendar component from event data.
1065 *
1066 * @param ev - Event data
1067 * @returns ECal.Component ready for EDS storage
1068 */
1069 private _buildIcalComponent(ev: EventData): any {
1070 const icalComp = ECal.Component.new();
1071 icalComp.set_new_vtype(ECal.ComponentVType.EVENT);
1072 icalComp.set_uid(ev.id || GLib.uuid_string_random());
1073
1074 // Apply all event data
1075 this._applyEventToComponent(icalComp, ev);
1076
1077 return icalComp;
1078 }
1079}
1080
1081/* ================================================================
1082 * GJS SIGNAL SYSTEM INTEGRATION
1083 * ================================================================
1084 *
1085 * Injects signal methods (connect, disconnect, emit) into EventManager prototype.
1086 * This enables the observer pattern used throughout the applet.
1087 */
1088Signals.addSignalMethods(EventManager.prototype);
1089
1090/* ================================================================
1091 * HYBRID EXPORT SYSTEM
1092 * ================================================================
1093 *
1094 * Dual export pattern required for Cinnamon applet environment:
1095 *
1096 * 1. CommonJS export: For TypeScript/development tools
1097 * 2. Global export: For Cinnamon runtime (no module system)
1098 */
1099
1100/* ----------------------------------------------------------------
1101 * CommonJS Export (Development & TypeScript)
1102 * ----------------------------------------------------------------
1103 */
1104if (typeof exports !== 'undefined') {
1105 exports.EventManager = EventManager;
1106}
1107
1108/* ----------------------------------------------------------------
1109 * Global Export (Cinnamon Runtime)
1110 * ----------------------------------------------------------------
1111 */
1112(global as any).EventManager = EventManager;
1113
1114/* ================================================================
1115 * TODOs AND FUTURE ENHANCEMENTS
1116 * ================================================================
1117 *
1118 * TODO: Implement event deletion functionality
1119 * TODO: Add support for recurring event patterns
1120 * TODO: Improve ICS import with conflict resolution
1121 * TODO: Add event search/filter capabilities
1122 * TODO: Implement calendar source selection UI
1123 * TODO: Add support for event categories/tags
1124 * TODO: Improve error handling for EDS write operations
1125 * TODO: Add support for event attachments
1126 * TODO: Implement event export functionality
1127 */