feat: load calendar events

This commit is contained in:
Kainoa Kanter
2025-10-08 18:57:03 -07:00
parent c4d1a142ab
commit c6858fea9d
5 changed files with 525 additions and 0 deletions

191
Bin/calendar-events.py Executable file
View File

@@ -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))

11
Bin/check-calendar.py Executable file
View File

@@ -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}")

21
Bin/list-calendars.py Executable file
View File

@@ -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))

View File

@@ -10,6 +10,8 @@ import qs.Widgets
NPanel { NPanel {
id: root id: root
property ShellScreen screen
preferredWidth: Settings.data.location.showWeekNumberInCalendar ? 400 : 380 preferredWidth: Settings.data.location.showWeekNumberInCalendar ? 400 : 380
preferredHeight: 520 preferredHeight: 520
@@ -315,6 +317,14 @@ NPanel {
grid.year = newDate.getFullYear() grid.year = newDate.getFullYear()
grid.month = newDate.getMonth() grid.month = newDate.getMonth()
content.isCurrentMonth = content.checkIsCurrentMonth() 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.month = Time.date.getMonth()
grid.year = Time.date.getFullYear() grid.year = Time.date.getFullYear()
content.isCurrentMonth = true content.isCurrentMonth = true
CalendarService.loadEvents()
} }
} }
@@ -334,6 +345,14 @@ NPanel {
grid.year = newDate.getFullYear() grid.year = newDate.getFullYear()
grid.month = newDate.getMonth() grid.month = newDate.getMonth()
content.isCurrentMonth = content.checkIsCurrentMonth() 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 Layout.fillHeight: true
spacing: 0 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 // Column of week numbers
ColumnLayout { ColumnLayout {
visible: Settings.data.location.showWeekNumberInCalendar visible: Settings.data.location.showWeekNumberInCalendar
@@ -466,6 +514,36 @@ NPanel {
font.weight: model.today ? Style.fontWeightBold : Style.fontWeightMedium 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 { Behavior on color {
ColorAnimation { ColorAnimation {
duration: Style.animationFast duration: Style.animationFast

View File

@@ -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")
}
}
}
}
}
}