diff --git a/Bin/calendar-events.py b/Bin/calendar-events.py new file mode 100755 index 00000000..febfe2ed --- /dev/null +++ b/Bin/calendar-events.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +import gi + +gi.require_version('EDataServer', '1.2') +gi.require_version('ECal', '2.0') +import json +import sys +import time +from datetime import datetime, timezone + +from gi.repository import ECal, EDataServer + +start_time = int(sys.argv[1]) +end_time = int(sys.argv[2]) + +print(f"Starting with time range: {start_time} to {end_time}", file=sys.stderr) + +all_events = [] + +def safe_get_time(ical_time): + """Safely get time from ICalTime object""" + if not ical_time: + return None + + try: + year = ical_time.get_year() + month = ical_time.get_month() + day = ical_time.get_day() + + if year < 1970 or year > 2100 or month < 1 or month > 12 or day < 1 or day > 31: + return None + + if ical_time.is_date(): + local_struct = time.struct_time((year, month, day, 0, 0, 0, 0, 0, -1)) + return int(time.mktime(local_struct)) + + hour = ical_time.get_hour() + minute = ical_time.get_minute() + second = ical_time.get_second() + + dt = datetime(year, month, day, hour, minute, second, tzinfo=timezone.utc) + return int(dt.timestamp()) + except Exception: + return None + +print("Getting registry...", file=sys.stderr) +registry = EDataServer.SourceRegistry.new_sync(None) +print("Registry obtained", file=sys.stderr) + +sources = registry.list_sources(EDataServer.SOURCE_EXTENSION_CALENDAR) +print(f"Found {len(sources)} calendar sources", file=sys.stderr) + +for source in sources: + if not source.get_enabled(): + print(f"Skipping disabled calendar: {source.get_display_name()}", file=sys.stderr) + continue + + calendar_name = source.get_display_name() + print(f"\nProcessing calendar: {calendar_name}", file=sys.stderr) + + try: + print(f" Connecting to {calendar_name}...", file=sys.stderr) + client = ECal.Client.connect_sync( + source, + ECal.ClientSourceType.EVENTS, + 30, + None + ) + print(f" Connected to {calendar_name}", file=sys.stderr) + + start_dt = datetime.fromtimestamp(start_time, tz=timezone.utc) + end_dt = datetime.fromtimestamp(end_time, tz=timezone.utc) + + start_str = start_dt.strftime("%Y%m%dT%H%M%SZ") + end_str = end_dt.strftime("%Y%m%dT%H%M%SZ") + + query = f'(occur-in-time-range? (make-time "{start_str}") (make-time "{end_str}"))' + print(f" Query: {query}", file=sys.stderr) + + print(f" Getting object list for {calendar_name}...", file=sys.stderr) + success, ical_objects = client.get_object_list_sync(query, None) + print(f" Got object list for {calendar_name}: success={success}, count={len(ical_objects) if ical_objects else 0}", file=sys.stderr) + + if not success or not ical_objects: + print(f" No events found in {calendar_name}", file=sys.stderr) + continue + + print(f" Processing {len(ical_objects)} events from {calendar_name}...", file=sys.stderr) + for idx, ical_obj in enumerate(ical_objects): + try: + if hasattr(ical_obj, 'get_summary'): + comp = ical_obj + else: + comp = ECal.Component.new_from_string(ical_obj) + + if not comp: + continue + + summary = comp.get_summary() or "(No title)" + + start_timestamp = safe_get_time(comp.get_dtstart()) + if start_timestamp is None: + continue + + end_timestamp = safe_get_time(comp.get_dtend()) + if end_timestamp is None or end_timestamp == start_timestamp: + end_timestamp = start_timestamp + 3600 + + location = comp.get_location() or "" + description = comp.get_description() or "" + + all_events.append({ + 'summary': summary, + 'start': start_timestamp, + 'end': end_timestamp, + 'location': location, + 'description': description, + 'calendar': calendar_name + }) + + if (idx + 1) % 10 == 0: + print(f" Processed {idx + 1} events from {calendar_name}...", file=sys.stderr) + except Exception as e: + print(f" Error processing event {idx} in {calendar_name}: {e}", file=sys.stderr) + continue + + print(f" Finished processing {calendar_name}, found {len([e for e in all_events if e['calendar'] == calendar_name])} events", file=sys.stderr) + + except Exception as e: + print(f" Error for {calendar_name}: {e}", file=sys.stderr) + +print(f"\nSorting {len(all_events)} total events...", file=sys.stderr) +all_events.sort(key=lambda x: x['start']) +print("Done! Outputting JSON...", file=sys.stderr) +print(json.dumps(all_events)) diff --git a/Bin/check-calendar.py b/Bin/check-calendar.py new file mode 100755 index 00000000..463d2e40 --- /dev/null +++ b/Bin/check-calendar.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +import gi + +gi.require_version('EDataServer', '1.2') +gi.require_version('ECal', '2.0') + +try: + from gi.repository import ECal, EDataServer + print("available") +except ImportError as e: + print(f"unavailable: {e}") diff --git a/Bin/list-calendars.py b/Bin/list-calendars.py new file mode 100755 index 00000000..12c67bd1 --- /dev/null +++ b/Bin/list-calendars.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +import gi + +gi.require_version('EDataServer', '1.2') +import json + +from gi.repository import EDataServer + +registry = EDataServer.SourceRegistry.new_sync(None) +sources = registry.list_sources(EDataServer.SOURCE_EXTENSION_CALENDAR) + +calendars = [] +for source in sources: + if source.get_enabled(): + calendars.append({ + 'uid': source.get_uid(), + 'name': source.get_display_name(), + 'enabled': True + }) + +print(json.dumps(calendars)) diff --git a/Modules/Bar/Calendar/CalendarPanel.qml b/Modules/Bar/Calendar/CalendarPanel.qml index b882324d..42ced01a 100644 --- a/Modules/Bar/Calendar/CalendarPanel.qml +++ b/Modules/Bar/Calendar/CalendarPanel.qml @@ -10,6 +10,7 @@ import qs.Widgets NPanel { id: root + property ShellScreen screen readonly property var now: Time.date preferredWidth: (Settings.data.location.showWeekNumberInCalendar ? 400 : 380) * Style.uiScaleRatio @@ -347,6 +348,14 @@ NPanel { grid.year = newDate.getFullYear() grid.month = newDate.getMonth() content.isCurrentMonth = content.checkIsCurrentMonth() + const now = new Date() + const monthStart = new Date(grid.year, grid.month, 1) + const monthEnd = new Date(grid.year, grid.month + 1, 0) + + const daysBehind = Math.max(0, Math.ceil((now - monthStart) / (24 * 60 * 60 * 1000))) + const daysAhead = Math.max(0, Math.ceil((monthEnd - now) / (24 * 60 * 60 * 1000))) + + CalendarService.loadEvents(daysAhead + 30, daysBehind + 30) } } NIconButton { @@ -355,6 +364,7 @@ NPanel { grid.month = Time.date.getMonth() grid.year = Time.date.getFullYear() content.isCurrentMonth = true + CalendarService.loadEvents() } } NIconButton { @@ -364,6 +374,14 @@ NPanel { grid.year = newDate.getFullYear() grid.month = newDate.getMonth() content.isCurrentMonth = content.checkIsCurrentMonth() + const now = new Date() + const monthStart = new Date(grid.year, grid.month, 1) + const monthEnd = new Date(grid.year, grid.month + 1, 0) + + const daysBehind = Math.max(0, Math.ceil((now - monthStart) / (24 * 60 * 60 * 1000))) + const daysAhead = Math.max(0, Math.ceil((monthEnd - now) / (24 * 60 * 60 * 1000))) + + CalendarService.loadEvents(daysAhead + 30, daysBehind + 30) } } } @@ -405,6 +423,71 @@ NPanel { Layout.fillWidth: true Layout.fillHeight: true spacing: 0 + + // Helper function to check if a date has events + function hasEventsOnDate(year, month, day) { + if (!CalendarService.available || CalendarService.events.length === 0) + return false + + const targetDate = new Date(year, month, day) + const targetStart = new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate()).getTime() / 1000 + const targetEnd = targetStart + 86400 // +24 hours + + return CalendarService.events.some(event => { + // Check if event starts or overlaps with this day + return (event.start >= targetStart && event.start < targetEnd) || (event.end > targetStart && event.end <= targetEnd) || (event.start < targetStart && event.end > targetEnd) + }) + } + + // Helper function to get events for a specific date + function getEventsForDate(year, month, day) { + if (!CalendarService.available || CalendarService.events.length === 0) + return [] + + const targetDate = new Date(year, month, day) + const targetStart = Math.floor(new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate()).getTime() / 1000) + const targetEnd = targetStart + 86400 // +24 hours + + return CalendarService.events.filter(event => { + return (event.start >= targetStart && event.start < targetEnd) || (event.end > targetStart && event.end <= targetEnd) || (event.start < targetStart && event.end > targetEnd) + }) + } + + // Helper function to check if an event is all-day + function isAllDayEvent(event) { + const duration = event.end - event.start + const startDate = new Date(event.start * 1000) + const isAtMidnight = startDate.getHours() === 0 && startDate.getMinutes() === 0 + return duration === 86400 && isAtMidnight + } + + // Helper function to check if an event is multi-day + function isMultiDayEvent(event) { + if (isAllDayEvent(event)) { + return false + } + + const startDate = new Date(event.start * 1000) + const endDate = new Date(event.end * 1000) + + const startDateOnly = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate()) + const endDateOnly = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate()) + + return startDateOnly.getTime() !== endDateOnly.getTime() + } + + // Helper function to get color for a specific event + function getEventColor(event, isToday) { + if (isMultiDayEvent(event)) { + return isToday ? Color.mOnSecondary : Color.mTertiary + } else if (isAllDayEvent(event)) { + return isToday ? Color.mOnSecondary : Color.mSecondary + } else { + return isToday ? Color.mOnSecondary : Color.mPrimary + } + } + + // Column of week numbers ColumnLayout { visible: Settings.data.location.showWeekNumberInCalendar Layout.preferredWidth: visible ? Style.baseWidgetSize * 0.7 : 0 @@ -475,6 +558,55 @@ NPanel { pointSize: Style.fontSizeM font.weight: model.today ? Style.fontWeightBold : Style.fontWeightMedium } + + // Event indicator dots + Row { + visible: parent.parent.parent.parent.parent.hasEventsOnDate(model.year, model.month, model.day) + spacing: 2 * scaling + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: Style.marginXS * scaling + + readonly property int currentYear: model.year + readonly property int currentMonth: model.month + readonly property int currentDay: model.day + readonly property bool isToday: model.today + + Repeater { + model: parent.parent.parent.parent.parent.parent.getEventsForDate(parent.currentYear, parent.currentMonth, parent.currentDay) + + Rectangle { + width: 4 * scaling + height: 4 * scaling + radius: 2 * scaling + color: parent.parent.parent.parent.parent.parent.getEventColor(modelData, model.today) + } + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + + onEntered: { + const events = parent.parent.parent.parent.parent.getEventsForDate(model.year, model.month, model.day) + if (events.length > 0) { + const summaries = events.map(e => e.summary).join('\n') + TooltipService.show(Screen, parent, summaries) + TooltipService.updateText(summaries) + } + } + + onClicked: { + const dateWithSlashes = `${model.month.toString().padStart(2, '0')}/${model.day.toString().padStart(2, '0')}/${model.year.toString().substring(2)}` + Quickshell.execDetached(["gnome-calendar", "--date", dateWithSlashes]) + } + + onExited: { + TooltipService.hide() + } + } + Behavior on color { ColorAnimation { duration: Style.animationFast diff --git a/Services/CalendarService.qml b/Services/CalendarService.qml new file mode 100644 index 00000000..c7498ed0 --- /dev/null +++ b/Services/CalendarService.qml @@ -0,0 +1,250 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Commons + +Singleton { + id: root + + // Core state + property var events: ([]) + property bool loading: false + property bool available: false + property string lastError: "" + property var calendars: ([]) + + // Persistent cache + property string cacheFile: Settings.cacheDir + "calendar.json" + + // Python scripts + readonly property string checkCalendarAvailableScript: Quickshell.shellDir + '/Bin/check-calendar.py' + readonly property string listCalendarsScript: Quickshell.shellDir + '/Bin/list-calendars.py' + readonly property string calendarEventsScript: Quickshell.shellDir + '/Bin/calendar-events.py' + + // Cache file handling + FileView { + id: cacheFileView + path: root.cacheFile + printErrors: false + + JsonAdapter { + id: cacheAdapter + property var cachedEvents: ([]) + property var cachedCalendars: ([]) + property string lastUpdate: "" + } + + onLoadFailed: { + cacheAdapter.cachedEvents = ([]) + cacheAdapter.cachedCalendars = ([]) + cacheAdapter.lastUpdate = "" + } + + onLoaded: { + loadFromCache() + } + } + + Component.onCompleted: { + Logger.log("Calendar", "Service initialized") + loadFromCache() + checkAvailability() + } + + // Save cache with debounce + Timer { + id: saveDebounce + interval: 1000 + onTriggered: cacheFileView.writeAdapter() + } + + function saveCache() { + saveDebounce.restart() + } + + // Load events and calendars from cache + function loadFromCache() { + if (cacheAdapter.cachedEvents && cacheAdapter.cachedEvents.length > 0) { + root.events = cacheAdapter.cachedEvents + Logger.log("Calendar", `Loaded ${cacheAdapter.cachedEvents.length} cached event(s)`) + } + + if (cacheAdapter.cachedCalendars && cacheAdapter.cachedCalendars.length > 0) { + root.calendars = cacheAdapter.cachedCalendars + Logger.log("Calendar", `Loaded ${cacheAdapter.cachedCalendars.length} cached calendar(s)`) + } + + if (cacheAdapter.lastUpdate) { + Logger.log("Calendar", `Cache last updated: ${cacheAdapter.lastUpdate}`) + } + } + + // Auto-refresh timer (every 5 minutes) + Timer { + id: refreshTimer + interval: 300000 + running: true + repeat: true + onTriggered: loadEvents() + } + + // Core functions + function checkAvailability() { + availabilityCheckProcess.running = true + } + + function loadCalendars() { + listCalendarsProcess.running = true + } + + function loadEvents(daysAhead = 31, daysBehind = 14) { + if (loading) + return + + loading = true + lastError = "" + + const now = new Date() + const startDate = new Date(now.getTime() - (daysBehind * 24 * 60 * 60 * 1000)) + const endDate = new Date(now.getTime() + (daysAhead * 24 * 60 * 60 * 1000)) + + loadEventsProcess.startTime = Math.floor(startDate.getTime() / 1000) + loadEventsProcess.endTime = Math.floor(endDate.getTime() / 1000) + loadEventsProcess.running = true + + Logger.log("Calendar", `Loading events (${daysBehind} days behind, ${daysAhead} days ahead): ${startDate.toLocaleDateString()} to ${endDate.toLocaleDateString()}`) + } + + // Helper to format date/time + function formatDateTime(timestamp) { + const date = new Date(timestamp * 1000) + return Qt.formatDateTime(date, "yyyy-MM-dd hh:mm") + } + + // Process to check for evolution-data-server libraries + Process { + id: availabilityCheckProcess + running: false + command: ["sh", "-c", "command -v python3 >/dev/null 2>&1 && python3 " + root.checkCalendarAvailableScript + " || echo 'unavailable: python3 not installed'"] + + stdout: StdioCollector { + onStreamFinished: { + const result = text.trim() + root.available = result === "available" + + if (root.available) { + Logger.log("Calendar", "EDS libraries available") + loadCalendars() + } else { + Logger.warn("Calendar", "EDS libraries not available: " + result) + root.lastError = "Evolution Data Server libraries not installed" + } + } + } + + stderr: StdioCollector { + onStreamFinished: { + if (text.trim()) { + Logger.warn("Calendar", "Availability check error: " + text) + root.available = false + root.lastError = "Failed to check library availability" + } + } + } + } + + // Process to list available calendars + Process { + id: listCalendarsProcess + running: false + command: ["python3", root.listCalendarsScript] + + stdout: StdioCollector { + onStreamFinished: { + try { + const result = JSON.parse(text.trim()) + root.calendars = result + cacheAdapter.cachedCalendars = result + saveCache() + + Logger.log("Calendar", `Found ${result.length} calendar(s)`) + + // Auto-load events after discovering calendars + // Only load if we have calendars and no cached events + if (result.length > 0 && root.events.length === 0) { + loadEvents() + } else if (result.length > 0) { + // If we already have cached events, load in background + loadEvents() + } + } catch (e) { + Logger.warn("Calendar", "Failed to parse calendars: " + e) + root.lastError = "Failed to parse calendar list" + } + } + } + + stderr: StdioCollector { + onStreamFinished: { + if (text.trim()) { + Logger.warn("Calendar", "List calendars error: " + text) + root.lastError = text.trim() + } + } + } + } + + // Process to load events + Process { + id: loadEventsProcess + running: false + property int startTime: 0 + property int endTime: 0 + + command: ["python3", root.calendarEventsScript, startTime.toString(), endTime.toString()] + + stdout: StdioCollector { + onStreamFinished: { + root.loading = false + + try { + const result = JSON.parse(text.trim()) + root.events = result + cacheAdapter.cachedEvents = result + cacheAdapter.lastUpdate = new Date().toISOString() + saveCache() + + Logger.log("Calendar", `Loaded ${result.length} event(s)`) + } catch (e) { + Logger.warn("Calendar", "Failed to parse events: " + e) + root.lastError = "Failed to parse events" + + // Fall back to cached events if available + if (cacheAdapter.cachedEvents.length > 0) { + root.events = cacheAdapter.cachedEvents + Logger.log("Calendar", "Using cached events") + } + } + } + } + + stderr: StdioCollector { + onStreamFinished: { + root.loading = false + + if (text.trim()) { + Logger.warn("Calendar", "Load events error: " + text) + root.lastError = text.trim() + + // Fall back to cached events if available + if (cacheAdapter.cachedEvents.length > 0) { + root.events = cacheAdapter.cachedEvents + Logger.log("Calendar", "Using cached events due to error") + } + } + } + } + } +}