diff --git a/Bin/calendar-events.py b/Bin/calendar-events.py new file mode 100755 index 00000000..20ac44c3 --- /dev/null +++ b/Bin/calendar-events.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +import gi + +gi.require_version('EDataServer', '1.2') +import json +import re +import sqlite3 +import sys +from datetime import datetime +from pathlib import Path + +from gi.repository import EDataServer + +start_time = int(sys.argv[1]) +end_time = int(sys.argv[2]) + +all_events = [] + +def safe_get_time(ical_time_str): + """Parse iCalendar time string""" + try: + if not ical_time_str: + return None + + ical_time_str = ical_time_str.strip().replace('\r', '').replace('\n', '') + + # Check for TZID parameter (format: TZID=America/Los_Angeles:20240822T180000) + if 'TZID=' in ical_time_str: + # Split on the colon that comes after the TZID value + match = re.match(r'TZID=([^:]+):(.+)', ical_time_str) + if match: + ical_time_str = match.group(2) + elif ';' in ical_time_str and ':' in ical_time_str: + ical_time_str = ical_time_str.split(':', 1)[1] + + ical_time_str = ical_time_str.strip() + + if len(ical_time_str) == 8 and ical_time_str.isdigit(): + dt = datetime.strptime(ical_time_str, '%Y%m%d') + return int(dt.timestamp()) + + # DateTime (YYYYMMDDTHHMMSS or YYYYMMDDTHHMMSSZ) + is_utc = ical_time_str.endswith('Z') + ical_time_str = ical_time_str.rstrip('Z') + dt = datetime.strptime(ical_time_str, '%Y%m%dT%H%M%S') + + if not is_utc: + return int(dt.timestamp()) + + from datetime import timezone + dt = dt.replace(tzinfo=timezone.utc) + return int(dt.timestamp()) + except Exception: + return None + +def parse_ical_component(ical_string, calendar_name): + """Parse an iCalendar component""" + try: + lines = ical_string.split('\n') + event = {} + current_key = None + current_value = [] + + for line in lines: + line = line.replace('\r', '') + + if line.startswith(' ') and current_key: + current_value.append(line[1:]) + continue + + if current_key: + full_value = ''.join(current_value) + event[current_key] = full_value + current_value = [] + + if ':' in line: + key_part, value_part = line.split(':', 1) + + key = key_part.split(';')[0] + + current_key = key + current_value = [line] + + if current_key: + event[current_key] = ''.join(current_value) + + if 'DTSTART' not in event: + return None + + dtstart_line = event.get('DTSTART', '') + if ':' in dtstart_line: + dtstart_value = dtstart_line.split('DTSTART', 1)[1] + else: + dtstart_value = dtstart_line + + start_timestamp = safe_get_time(dtstart_value) + if not start_timestamp: + return None + + if start_timestamp < start_time or start_timestamp > end_time: + return None + + dtend_line = event.get('DTEND', '') + if dtend_line and ':' in dtend_line: + dtend_value = dtend_line.split('DTEND', 1)[1] + end_timestamp = safe_get_time(dtend_value) + else: + end_timestamp = None + + if not end_timestamp or end_timestamp == start_timestamp: + end_timestamp = start_timestamp + 3600 + + summary_line = event.get('SUMMARY', '(No title)') + if 'SUMMARY:' in summary_line: + summary = summary_line.split('SUMMARY:', 1)[1].strip() + else: + summary = summary_line.strip() or '(No title)' + + location_line = event.get('LOCATION', '') + if 'LOCATION:' in location_line: + location = location_line.split('LOCATION:', 1)[1].strip() + else: + location = location_line.strip() + + desc_line = event.get('DESCRIPTION', '') + if 'DESCRIPTION:' in desc_line: + description = desc_line.split('DESCRIPTION:', 1)[1].strip() + else: + description = desc_line.strip() + + return { + 'summary': summary, + 'start': start_timestamp, + 'end': end_timestamp, + 'location': location, + 'description': description, + 'calendar': calendar_name + } + except Exception: + return None + +registry = EDataServer.SourceRegistry.new_sync(None) +sources = registry.list_sources(EDataServer.SOURCE_EXTENSION_CALENDAR) + +cache_base = Path.home() / ".cache/evolution/calendar" + +for source in sources: + if not source.get_enabled(): + continue + + calendar_name = source.get_display_name() + source_uid = source.get_uid() + + cache_file = cache_base / source_uid / "cache.db" + + if not cache_file.exists(): + cache_file = Path.home() / ".local/share/evolution/calendar" / source_uid / "calendar.ics" + if cache_file.exists(): + try: + with open(cache_file, 'r') as f: + content = f.read() + events = content.split('BEGIN:VEVENT') + for event_str in events[1:]: + event_str = 'BEGIN:VEVENT' + event_str.split('END:VEVENT')[0] + 'END:VEVENT' + event = parse_ical_component(event_str, calendar_name) + if event: + all_events.append(event) + except Exception: + pass + continue + + try: + conn = sqlite3.connect(str(cache_file)) + cursor = conn.cursor() + + cursor.execute("SELECT ECacheOBJ FROM ECacheObjects") + rows = cursor.fetchall() + + for row in rows: + ical_string = row[0] + if ical_string and 'BEGIN:VEVENT' in str(ical_string): + event = parse_ical_component(str(ical_string), calendar_name) + if event: + all_events.append(event) + + conn.close() + except Exception as e: + print(f"Error processing {calendar_name}: {e}", file=sys.stderr) + +all_events.sort(key=lambda x: x['start']) +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 277745b5..f88318cf 100644 --- a/Modules/Bar/Calendar/CalendarPanel.qml +++ b/Modules/Bar/Calendar/CalendarPanel.qml @@ -10,6 +10,8 @@ import qs.Widgets NPanel { id: root + property ShellScreen screen + preferredWidth: Settings.data.location.showWeekNumberInCalendar ? 400 : 380 preferredHeight: 520 @@ -315,6 +317,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) } } @@ -324,6 +334,7 @@ NPanel { grid.month = Time.date.getMonth() grid.year = Time.date.getFullYear() content.isCurrentMonth = true + CalendarService.loadEvents() } } @@ -334,6 +345,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) } } } @@ -385,6 +404,35 @@ NPanel { 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 = 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) + }) + } + // Column of week numbers ColumnLayout { visible: Settings.data.location.showWeekNumberInCalendar @@ -466,6 +514,36 @@ NPanel { font.weight: model.today ? Style.fontWeightBold : Style.fontWeightMedium } + // Event indicator dot + Rectangle { + visible: parent.parent.parent.parent.parent.hasEventsOnDate(model.year, model.month, model.day) + width: 4 * scaling + height: 4 * scaling + radius: 2 * scaling + color: model.today ? Color.mOnSecondary : Color.mPrimary + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: Style.marginXS * scaling + } + + 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) + } + } + + 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..83aa5a2d --- /dev/null +++ b/Services/CalendarService.qml @@ -0,0 +1,224 @@ +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 = "" + } + } + + Component.onCompleted: { + Logger.log("Calendar", "Service initialized") + checkAvailability() + } + + // Save cache with debounce + Timer { + id: saveDebounce + interval: 1000 + onTriggered: cacheFileView.writeAdapter() + } + + function saveCache() { + saveDebounce.restart() + } + + // 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: ["python3", root.checkCalendarAvailableScript] + + 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 + if (result.length > 0) { + 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") + } + } + } + } + } +}