2 * Project IT Calendar - Event Manager Component
3 * =============================================
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
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
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)
24 * ------------------------------------------------------------------
25 * SYSTEM INTEGRATION CHALLENGES:
26 * ------------------------------------------------------------------
27 * This component works around several Cinnamon/GJS limitations:
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
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
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
46 * ------------------------------------------------------------------
47 * DATA FLOW ARCHITECTURE:
48 * ------------------------------------------------------------------
50 * ┌─────────────┐ ┌──────────────┐ ┌──────────────┐
51 * │ UI Layer │ │ EventManager │ │ Data Source │
52 * │ (Calendar- │◄──►│ (THIS) │◄──►│ (EDS/ICS) │
54 * └─────────────┘ └──────────────┘ └──────────────┘
58 * Renders UI Caches events Persistent
59 * from events Signals updates storage
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'.
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.
72 * 3. ASYNCHRONOUS DBUS COMMUNICATION:
73 * Communicates with 'org.cinnamon.CalendarServer' via DBus. All calls are
74 * handled asynchronously to keep the UI responsive.
76 * 4. MODERN GJS STANDARDS:
77 * Uses TextDecoder or .toString() for data conversion instead of legacy
78 * byte-array wrappers where possible.
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
87 * @file EventManager.ts
88 * @brief Core data management layer for calendar events
90 * @details Handles all calendar event operations including synchronization
91 * with Evolution Data Server, ICS import, and event caching.
93 * @warning This component works around several Cinnamon/GJS limitations
94 * including read-only CalendarServer and complex EDS write operations.
96 * @author Arnold Schiller <calendar@projektit.de>
98 * @copyright GPL-3.0-or-later
102 * interface EventData
103 * @brief Internal representation of a calendar event
105 * @note This structure is optimized for the applet's internal use
106 * and may not map 1:1 with EDS/ICS representations.
110/* ================================================================
111 * GJS / CINNAMON IMPORTS
112 * ================================================================
114 * These are native GNOME/Cinnamon APIs exposed to JavaScript via GJS.
116 * IMPORTANT: These are NOT npm packages - they're provided by the
117 * runtime environment (Cinnamon/GNOME Shell).
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
129/* ================================================================
131 * ================================================================
133 * These interfaces define the data structures used throughout the
134 * EventManager. They provide TypeScript type safety during development.
138 * Internal representation of a calendar event.
140 * Note: This structure is optimized for the applet's internal use
141 * and may not map 1:1 with EDS/ICS representations.
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 */
154 * @class EventManager
155 * @extends Signals.Signals
156 * @brief Manages all calendar event operations
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)
164 * @note This is a PURE DATA LAYER component with no UI dependencies.
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
181 * Date range for event filtering.
183export interface DateRange {
184 from: Date; // Start of range (inclusive)
185 to: Date; // End of range (exclusive)
189 * TypeScript interface merging for GJS signals.
191 * This tells TypeScript that EventManager will have signal methods
192 * (connect, disconnect, emit) added at runtime via Signals.addSignalMethods.
194export interface EventManager extends Signals.Signals {}
196/* ================================================================
197 * EVENT MANAGER CLASS
198 * ================================================================
200 * Main class handling all calendar event operations.
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
210 * @class EventManager
211 * @brief Main event manager class
213 * @details For detailed documentation see the main class documentation.
216 * @class EventManager
217 * @brief Main event manager class
219 * @details For detailed documentation see the main class documentation.
221export class EventManager {
222 /* ============================================================
224 * ============================================================
228 * DBus proxy to Cinnamon.CalendarServer.
229 * Used for READ-ONLY event retrieval.
231 private _server: any = null;
234 * In-memory cache of calendar events.
235 * Structure: Flat array of EventData objects, filtered on demand.
237 private _events: EventData[] = [];
240 * Flag indicating if DBus connection is established.
242 private _isReady: boolean = false;
245 * Currently selected date for quick access patterns.
247 private _selectedDate: Date;
250 * Applet UUID for logging and identification.
252 private _uuid: string;
255 * EDS source registry for write operations.
256 * Null until first write operation is attempted.
258 private _registry: any | null = null;
261 * Cache of ECal.Client connections by source UID.
262 * Improves performance for multiple operations on same calendar.
264 private _clientCache = new Map<string, any>();
266 /* ============================================================
268 * ============================================================
270 * Initializes the EventManager with placeholder data and establishes
271 * DBus connection to Cinnamon CalendarServer.
273 * @param uuid - Unique identifier for logging (typically applet UUID)
276 constructor(uuid: string = "EventManager@default") {
278 this._selectedDate = new Date();
280 // Load placeholder data so UI is never empty
281 this._loadInitialData();
283 // Initialize DBus connection (async)
286 // Start periodic sync (every 60 seconds)
287 Mainloop.timeout_add_seconds(60, () => {
289 return true; // Keep timer active
293 /* ============================================================
294 * INITIALIZATION METHODS
295 * ============================================================
299 * Loads placeholder data during startup.
301 * IMPORTANT UX DECISION:
302 * The UI should NEVER be empty, even during initial loading.
303 * This placeholder gives users immediate visual feedback.
305 private _loadInitialData(): void {
306 const today = new Date();
310 sourceUid: "Teststring",
313 summary: "Calendar Manager Active",
314 description: "Synchronizing with system calendar...",
322 * Initializes DBus proxy connection to Cinnamon CalendarServer.
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
330 * LIMITATION: This proxy is READ-ONLY.
332 private _initProxy(): void {
333 Cinnamon.CalendarServerProxy.new_for_bus(
335 Gio.DBusProxyFlags.NONE,
336 "org.cinnamon.CalendarServer",
337 "/org/cinnamon/CalendarServer",
341 this._server = Cinnamon.CalendarServerProxy.new_for_bus_finish(res);
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));
347 this._isReady = true;
348 this.emit('manager-ready');
350 // Initial data fetch for current month
353 if (typeof global !== 'undefined') {
354 global.logError(`${this._uuid}: DBus Connection Error: ${e}`);
361 /* ============================================================
362 * PUBLIC API - DATE SELECTION AND FILTERING
363 * ============================================================
367 * Sets the currently selected date for quick access patterns.
369 * @param date - Date to select
371 public selectDate(date: Date): void {
372 this._selectedDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
376 * Gets events for the currently selected date.
377 * Convenience method for common UI pattern.
379 public getEventsForSelectedDate(): EventData[] {
380 return this.getEventsForDate(this._selectedDate);
384 * Checks if any events exist for a specific date.
386 * @param date - Date to check
387 * @returns true if at least one event exists
389 public hasEvents(date: Date): boolean {
390 return this.getEventsForDate(date).length > 0;
393 /* ============================================================
394 * PUBLIC API - EVENT RETRIEVAL
395 * ============================================================
397 * All methods filter the in-memory event cache.
398 * No DBus calls are made here - data is already cached.
402 * Gets all events for a specific date.
404 * @param date - Target date
405 * @returns Array of events occurring on this date
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 });
414 * Gets all events for a specific month.
416 * @param year - Year (e.g., 2026)
417 * @param month - Month (0-11, JavaScript convention)
418 * @returns Array of events in this month
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 });
427 * Gets all events for a specific year.
429 * @param year - Year (e.g., 2026)
430 * @returns Array of events in this year
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 });
439 * Gets events within a date range.
441 * @param range - Date range (inclusive start, exclusive end)
442 * @returns Array of events overlapping the range, sorted by start time
444 public getEventsForRange(range: DateRange): EventData[] {
445 const from = range.from.getTime();
446 const to = range.to.getTime();
450 const start = ev.start.getTime();
451 const end = ev.end.getTime();
452 return end >= from && start <= to;
454 .sort((a, b) => a.start.getTime() - b.start.getTime());
457 /* ============================================================
458 * PUBLIC API - DATA SYNCHRONIZATION
459 * ============================================================
463 * Fetches events for a specific date range from the server.
465 * This tells Cinnamon CalendarServer which time window we're interested in.
466 * The server will send events via DBus signals.
468 * @param start - Start of range
469 * @param end - End of range
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);
476 this._server.call_set_time_range(startUnix, endUnix, true, null, (server, res) => {
478 this._server.call_set_time_range_finish(res);
480 // Ignore errors during applet shutdown
486 * Refreshes the event cache for a 9-month window around current date.
488 * Window: 2 months back, current month, 6 months forward
489 * This provides smooth scrolling experience.
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);
499 /* ============================================================
500 * DBUS EVENT HANDLING
501 * ============================================================
505 * Callback for DBus event updates.
507 * Converts raw DBus data to internal EventData format and updates cache.
508 * Emits 'events-updated' signal to notify UI components.
510 * @param server - DBus proxy (unused)
511 * @param varray - DBus variant array containing event data
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();
519 let eventId = fullId;
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(':');
530 sourceUid: sourceUid,
533 start: new Date(start * 1000),
534 end: new Date(end * 1000),
538 this.emit('events-updated');
541 /* ============================================================
542 * ICS IMPORT FUNCTIONALITY
543 * ============================================================
545 * WARNING: This feature has significant limitations:
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
552 * This is provided as EXPERIMENTAL functionality only.
556 * Imports events from an ICS file.
558 * @param icsPath - Path to .ics file
559 * @param color - Color for imported events (default: "#ff6b6b")
560 * @returns Promise that resolves when import completes
562 public async importICSFile(icsPath: string, color: string = "#ff6b6b"): Promise<void> {
564 global.logError(this._uuid + ": CalendarServer not ready for ICS-Import");
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) => {
573 const [success, data] = f!.load_contents_finish(res);
574 resolve([success, data]);
576 resolve([false, new Uint8Array()]);
581 if (!ok) throw new Error("Can't read ICS file.");
582 const icsText = contents.toString();
584 // Extract VEVENT blocks from ICS file
585 const veventMatches = icsText.match(/BEGIN:VEVENT[\s\S]*?END:VEVENT/g);
586 if (!veventMatches) return;
588 let importedCount = 0;
589 for (const veventBlock of veventMatches) {
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);
596 if (!dtstartMatch) continue;
598 const startStr = dtstartMatch[1].trim();
599 const endStr = dtendMatch ? dtendMatch[1].trim() : startStr;
601 const start = this._parseICSDate(startStr);
602 const end = this._parseICSDate(endStr);
603 const allDay = startStr.length === 8;
605 const eventToImport: EventData = {
609 description: description,
616 this.addEvent(eventToImport);
619 global.logError(this._uuid + ": VEVENT parsing error: " + e);
622 global.log(this._uuid + `: ${importedCount} Events imported from ${icsPath}`);
624 global.logError(this._uuid + `: ICS Import Error ${icsPath}: ${e}`);
629 * Parses ICS date strings into JavaScript Date objects.
632 * - Basic format: 20231231 (all-day events)
633 * - Extended format: 20231231T120000Z (timed events)
635 * @param icsDate - ICS date string
636 * @returns JavaScript Date object
638 private _parseICSDate(icsDate: string): Date {
639 if (icsDate.length === 8) {
640 // Basic format: YYYYMMDD
642 parseInt(icsDate.substr(0,4)),
643 parseInt(icsDate.substr(4,2))-1,
644 parseInt(icsDate.substr(6,2))
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'));
651 /* ============================================================
652 * EVENT CREATION AND MODIFICATION
653 * ============================================================
655 * IMPORTANT ARCHITECTURAL NOTE:
657 * Cinnamon CalendarServer (DBus) is READ-ONLY for event data.
658 * To support event creation/modification, we must use:
660 * 1. Evolution Data Server (EDS) via libecal
661 * 2. Direct GObject Introspection (GIR) bindings
662 * 3. iCalendar (RFC 5545) component manipulation
664 * This bypasses Cinnamon's limited API but introduces complexity
665 * and potential compatibility issues across GNOME/Cinnamon versions.
669 * Public entry point for adding or updating events.
671 * Decides whether to create new event or modify existing one.
673 * @param ev - Event data
675 public addEvent(ev: EventData): void {
676 if (ev.id && ev.sourceUid) {
677 // Existing event - modify via EDS
678 this._modifyExistingEvent(ev);
680 // New event - create via EDS
681 this._createNewEvent(ev);
686 * Creates a new event in Evolution Data Server.
688 * @param ev - Event data
690 private _createNewEvent(ev: EventData): void {
691 const source = this._getDefaultWritableSource();
693 global.logError("Create: No writable calendar source found");
697 // Create iCalendar component
698 const comp = ECal.Component.new();
699 comp.set_new_vtype(ECal.ComponentVType.EVENT);
701 // UID is REQUIRED for iCalendar compliance
702 comp.set_uid(ev.id || GLib.uuid_string_random());
704 // Set summary (title)
705 const summaryText = ECal.ComponentText.new(
706 ev.summary || "New Event",
709 comp.set_summary(summaryText);
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);
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),
724 const end = ICal.Time.new_from_timet_with_zone(
725 Math.floor(ev.end.getTime() / 1000),
729 comp.set_dtstart(start);
732 // Connect to EDS and create event
735 ECal.ClientSourceType.EVENTS,
740 const client = ECal.Client.connect_finish(res);
742 client.create_object(comp, null, null, (_c, cres) => {
744 client.create_object_finish(cres);
745 global.log("✅ CREATE OK");
748 global.logError("❌ create_object_finish failed: " + e);
752 global.logError("❌ EDS connection failed: " + e);
759 * Modifies an existing event in Evolution Data Server.
761 * Uses "smart merge" to preserve fields not being modified.
763 * @param ev - Updated event data
765 private _modifyExistingEvent(ev: EventData): void {
766 const source = this._resolveSource(ev.sourceUid);
769 ECal.Client.connect(source, ECal.ClientSourceType.EVENTS, 30, null, (_obj, res) => {
771 const client = ECal.Client.connect_finish(res);
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) => {
777 const result = client.get_object_finish(getRes);
778 const icalComp = Array.isArray(result) ? result[1] : result;
781 let anyChange = false;
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);
788 global.log(`${this._uuid}: Update Summary`);
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);
796 global.log(`${this._uuid}: Update Description`);
799 // 3. TIMES - preserve duration when changing start time
801 const oldStartComp = icalComp.get_dtstart();
802 const oldEndComp = icalComp.get_dtend();
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;
808 const newStartSeconds = Math.floor(ev.start.getTime() / 1000);
810 if (oldStartTimeObj.as_timet() !== newStartSeconds) {
811 // Start time changed - preserve original duration
812 const durationSeconds = oldEndTimeObj.as_timet() - oldStartTimeObj.as_timet();
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);
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);
823 icalComp.set_dtstart(newStart);
824 icalComp.set_dtend(newEnd);
827 global.log(`${this._uuid}: Update Times (preserved ${durationSeconds/3600}h duration)`);
831 global.logWarning(`${this._uuid}: Time merge failed: ${e}`);
834 // 4. Save changes if any were made
836 client.modify_object(icalComp, ECal.ObjModType.THIS, 0, null, (_c: any, mRes: any) => {
838 client.modify_object_finish(mRes);
839 global.log(`${this._uuid}: Smart merge successful`);
842 global.logError("Modify finish failed: " + err);
846 global.log(`${this._uuid}: No changes needed - master data unchanged`);
850 // Event not found - create as new
851 global.logWarning(`${this._uuid}: Smart merge failed (ID not found), creating new: ${e}`);
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());
858 this._createAsNew(client, ev, fallbackComp);
862 // Invalid ID - create as new event
863 this._createNewEvent(ev);
866 global.logError("EDS connection failed: " + e);
871 /* ============================================================
872 * EDS SOURCE MANAGEMENT
873 * ============================================================
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)
882 * Resolves an EDS source by UID.
884 * @param sUid - Source UID (optional)
885 * @returns EDS source or null if not found/not writable
887 private _resolveSource(sUid?: string): any {
888 if (!this._registry) {
889 this._registry = EDataServer.SourceRegistry.new_sync(null);
894 let s = this._registry.ref_source(sUid);
899 const sources = this._registry.list_sources(EDataServer.SOURCE_EXTENSION_CALENDAR);
901 // 1. Prefer sources with specific names
902 let bestSource = sources.find((s: any) => {
904 const ext = s.get_extension(EDataServer.SOURCE_EXTENSION_CALENDAR);
906 const ro = (typeof ext.get_readonly === 'function') ? ext.get_readonly() : ext.readonly;
907 if (ro === true) return false;
909 const name = s.get_display_name().toLowerCase();
910 return name.includes("system") || name.includes("personal") || name.includes("local");
911 } catch(e) { return false; }
914 if (bestSource) return bestSource;
916 // 2. Fallback: any writable source
917 return sources.find((s: any) => {
919 const ext = s.get_extension(EDataServer.SOURCE_EXTENSION_CALENDAR);
920 const ro = (typeof ext.get_readonly === 'function') ? ext.get_readonly() : ext.readonly;
922 } catch(e) { return false; }
927 * Finds a default writable calendar source.
929 * CRITICAL: Must have a parent source (not a top-level aggregate).
930 * Some aggregate sources appear writable but fail on write operations.
932 private _getDefaultWritableSource(): any {
933 if (!this._registry) {
934 this._registry = EDataServer.SourceRegistry.new_sync(null);
937 const sources = this._registry.list_sources(EDataServer.SOURCE_EXTENSION_CALENDAR);
939 return sources.find((s: any) => {
941 const ext = s.get_extension(EDataServer.SOURCE_EXTENSION_CALENDAR);
942 if (ext.get_readonly && ext.get_readonly()) return false;
944 // Must have a parent (exclude top-level aggregates)
945 return s.get_parent() !== null;
952 /* ============================================================
954 * ============================================================
958 * Applies event data to an iCalendar component.
960 * @param ecalComp - ECal component to modify
961 * @param ev - Event data to apply
963 private _applyEventToComponent(ecalComp: any, ev: EventData): void {
964 // Get underlying iCalendar component
965 const ical = ecalComp.get_icalcomponent();
967 // Set UID (required)
970 // Set timestamp (current time)
972 ICal.Time.new_current_with_zone(
973 ICal.Timezone.get_utc_timezone()
979 const sumProp = ICal.Property.new_summary(ev.summary);
980 ical.add_property(sumProp);
984 if (ev.description && ev.description.trim() !== "") {
985 const descProp = ICal.Property.new_description(ev.description);
986 ical.add_property(descProp);
990 const tz = ICal.Timezone.get_utc_timezone();
996 // All-day events use DATE format (no time component)
997 start = ICal.Time.new_null_time();
999 ev.start.getFullYear(),
1000 ev.start.getMonth() + 1,
1003 start.set_is_date(true);
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);
1011 endDate.getFullYear(),
1012 endDate.getMonth() + 1,
1015 end.set_is_date(true);
1018 start = ICal.Time.new_from_timet_with_zone(
1019 Math.floor(ev.start.getTime() / 1000), 0, tz
1021 end = ICal.Time.new_from_timet_with_zone(
1022 Math.floor(ev.end.getTime() / 1000), 0, tz
1026 ical.set_dtstart(start);
1027 ical.set_dtend(end);
1031 * Creates a new event in EDS (fallback method).
1033 * @param client - ECal.Client connection
1034 * @param ev - Event data
1035 * @param icalComp - iCalendar component (optional)
1037 private _createAsNew(client: any, ev: EventData, icalComp: any): void {
1039 // Use provided component or create new one
1040 let comp = icalComp;
1042 comp = this._buildIcalComponent(ev);
1044 // Ensure component has latest data
1045 this._applyEventToComponent(comp, ev);
1048 // Save to EDS (4 arguments required by GJS bindings)
1049 client.create_object(comp, null, null, (_obj: any, res: any) => {
1051 client.create_object_finish(res);
1052 global.log(`${this._uuid}: Event successfully created`);
1055 global.logError(`${this._uuid}: create_object_finish failed: ${e}`);
1059 global.logError(`${this._uuid}: _createAsNew failed: ${e}`);
1064 * Factory method to build an iCalendar component from event data.
1066 * @param ev - Event data
1067 * @returns ECal.Component ready for EDS storage
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());
1074 // Apply all event data
1075 this._applyEventToComponent(icalComp, ev);
1081/* ================================================================
1082 * GJS SIGNAL SYSTEM INTEGRATION
1083 * ================================================================
1085 * Injects signal methods (connect, disconnect, emit) into EventManager prototype.
1086 * This enables the observer pattern used throughout the applet.
1088Signals.addSignalMethods(EventManager.prototype);
1090/* ================================================================
1091 * HYBRID EXPORT SYSTEM
1092 * ================================================================
1094 * Dual export pattern required for Cinnamon applet environment:
1096 * 1. CommonJS export: For TypeScript/development tools
1097 * 2. Global export: For Cinnamon runtime (no module system)
1100/* ----------------------------------------------------------------
1101 * CommonJS Export (Development & TypeScript)
1102 * ----------------------------------------------------------------
1104if (typeof exports !== 'undefined') {
1105 exports.EventManager = EventManager;
1108/* ----------------------------------------------------------------
1109 * Global Export (Cinnamon Runtime)
1110 * ----------------------------------------------------------------
1112(global as any).EventManager = EventManager;
1114/* ================================================================
1115 * TODOs AND FUTURE ENHANCEMENTS
1116 * ================================================================
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