fix sync timestamp creation in calendar downloads

Ensures .sync_timestamp file is created during regular calendar sync (server → local) so daemon can properly detect local calendar changes and track sync history.

🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>
This commit is contained in:
Tim Bendt
2025-07-16 10:08:32 -04:00
parent fc7d07ae6b
commit d7ca5e451d

View File

@@ -1,6 +1,7 @@
""" """
Utility module for handling calendar events and iCalendar operations. Utility module for handling calendar events and iCalendar operations.
""" """
import re import re
import os import os
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -40,7 +41,7 @@ def clean_text(text):
if not text: if not text:
return "" return ""
# Replace 3 or more consecutive underscores with 2 underscores # Replace 3 or more consecutive underscores with 2 underscores
return re.sub(r'_{3,}', '__', text) return re.sub(r"_{3,}", "__", text)
def escape_ical_text(text): def escape_ical_text(text):
@@ -63,8 +64,15 @@ def escape_ical_text(text):
text = text.replace(";", "\\;") text = text.replace(";", "\\;")
return text return text
async def fetch_calendar_events(headers, days_back=1, days_forward=6, fetch_function=None,
start_date=None, end_date=None): async def fetch_calendar_events(
headers,
days_back=1,
days_forward=6,
fetch_function=None,
start_date=None,
end_date=None,
):
""" """
Fetch calendar events from Microsoft Graph API. Fetch calendar events from Microsoft Graph API.
@@ -85,7 +93,9 @@ async def fetch_calendar_events(headers, days_back=1, days_forward=6, fetch_func
# Calculate date range # Calculate date range
if start_date is None: if start_date is None:
start_date = datetime.now().replace(hour=0, minute=0, second=0) - timedelta(days=days_back) start_date = datetime.now().replace(hour=0, minute=0, second=0) - timedelta(
days=days_back
)
if end_date is None: if end_date is None:
end_of_today = datetime.now().replace(hour=23, minute=59, second=59) end_of_today = datetime.now().replace(hour=23, minute=59, second=59)
@@ -99,7 +109,7 @@ async def fetch_calendar_events(headers, days_back=1, days_forward=6, fetch_func
total_event_url = f"{event_base_url}&$count=true&$select=id" total_event_url = f"{event_base_url}&$count=true&$select=id"
try: try:
total_response = await fetch_function(total_event_url, headers) total_response = await fetch_function(total_event_url, headers)
total_events = total_response.get('@odata.count', 0) total_events = total_response.get("@odata.count", 0)
except Exception as e: except Exception as e:
print(f"Error fetching total events count: {e}") print(f"Error fetching total events count: {e}")
total_events = 0 total_events = 0
@@ -110,9 +120,9 @@ async def fetch_calendar_events(headers, days_back=1, days_forward=6, fetch_func
try: try:
response_data = await fetch_function(calendar_url, headers) response_data = await fetch_function(calendar_url, headers)
if response_data: if response_data:
events.extend(response_data.get('value', [])) events.extend(response_data.get("value", []))
# Get the next page URL from @odata.nextLink # Get the next page URL from @odata.nextLink
calendar_url = response_data.get('@odata.nextLink') calendar_url = response_data.get("@odata.nextLink")
else: else:
print("Received empty response from calendar API") print("Received empty response from calendar API")
break break
@@ -123,6 +133,7 @@ async def fetch_calendar_events(headers, days_back=1, days_forward=6, fetch_func
# Only return the events and total_events # Only return the events and total_events
return events, total_events return events, total_events
def write_event_to_ical(f, event, start, end): def write_event_to_ical(f, event, start, end):
""" """
Write a single event to an iCalendar file. Write a single event to an iCalendar file.
@@ -140,52 +151,57 @@ def write_event_to_ical(f, event, start, end):
f.write(f"BEGIN:VEVENT\nSUMMARY:{escape_ical_text(event['subject'])}\n") f.write(f"BEGIN:VEVENT\nSUMMARY:{escape_ical_text(event['subject'])}\n")
# Handle multi-line description properly # Handle multi-line description properly
description = event.get('bodyPreview', '') description = event.get("bodyPreview", "")
if description: if description:
escaped_description = escape_ical_text(description) escaped_description = escape_ical_text(description)
f.write(f"DESCRIPTION:{escaped_description}\n") f.write(f"DESCRIPTION:{escaped_description}\n")
f.write(f"UID:{event.get('iCalUId', '')}\n") f.write(f"UID:{event.get('iCalUId', '')}\n")
f.write(f"LOCATION:{escape_ical_text(event.get('location', {}).get('displayName', ''))}\n") f.write(
f"LOCATION:{escape_ical_text(event.get('location', {}).get('displayName', ''))}\n"
)
f.write(f"CLASS:{event.get('showAs', '')}\n") f.write(f"CLASS:{event.get('showAs', '')}\n")
f.write(f"STATUS:{event.get('responseStatus', {}).get('response', '')}\n") f.write(f"STATUS:{event.get('responseStatus', {}).get('response', '')}\n")
if 'onlineMeeting' in event and event['onlineMeeting']: if "onlineMeeting" in event and event["onlineMeeting"]:
f.write(f"URL:{event.get('onlineMeeting', {}).get('joinUrl', '')}\n") f.write(f"URL:{event.get('onlineMeeting', {}).get('joinUrl', '')}\n")
# Write start and end times with timezone info in iCalendar format # Write start and end times with timezone info in iCalendar format
if start.tzinfo == UTC: if start.tzinfo == UTC:
f.write(f"DTSTART:{start.strftime('%Y%m%dT%H%M%SZ')}\n") f.write(f"DTSTART:{start.strftime('%Y%m%dT%H%M%SZ')}\n")
else: else:
tz_name = start_tz.tzname(None) if start_tz else 'UTC' tz_name = start_tz.tzname(None) if start_tz else "UTC"
f.write(f"DTSTART;TZID={tz_name}:{start.strftime('%Y%m%dT%H%M%S')}\n") f.write(f"DTSTART;TZID={tz_name}:{start.strftime('%Y%m%dT%H%M%S')}\n")
if end.tzinfo == UTC: if end.tzinfo == UTC:
f.write(f"DTEND:{end.strftime('%Y%m%dT%H%M%SZ')}\n") f.write(f"DTEND:{end.strftime('%Y%m%dT%H%M%SZ')}\n")
else: else:
tz_name = end_tz.tzname(None) if end_tz else 'UTC' tz_name = end_tz.tzname(None) if end_tz else "UTC"
f.write(f"DTEND;TZID={tz_name}:{end.strftime('%Y%m%dT%H%M%S')}\n") f.write(f"DTEND;TZID={tz_name}:{end.strftime('%Y%m%dT%H%M%S')}\n")
# Handle recurrence rules # Handle recurrence rules
if 'recurrence' in event and event['recurrence']: if "recurrence" in event and event["recurrence"]:
for rule in event['recurrence']: for rule in event["recurrence"]:
if rule.startswith('RRULE'): if rule.startswith("RRULE"):
rule_parts = rule.split(';') rule_parts = rule.split(";")
new_rule_parts = [] new_rule_parts = []
for part in rule_parts: for part in rule_parts:
if part.startswith('UNTIL='): if part.startswith("UNTIL="):
until_value = part.split('=')[1] until_value = part.split("=")[1]
until_date = parser.isoparse(until_value) until_date = parser.isoparse(until_value)
if start.tzinfo is not None and until_date.tzinfo is None: if start.tzinfo is not None and until_date.tzinfo is None:
until_date = until_date.replace(tzinfo=start.tzinfo) until_date = until_date.replace(tzinfo=start.tzinfo)
new_rule_parts.append(f"UNTIL={until_date.strftime('%Y%m%dT%H%M%SZ')}") new_rule_parts.append(
f"UNTIL={until_date.strftime('%Y%m%dT%H%M%SZ')}"
)
else: else:
new_rule_parts.append(part) new_rule_parts.append(part)
rule = ';'.join(new_rule_parts) rule = ";".join(new_rule_parts)
f.write(f"{rule}\n") f.write(f"{rule}\n")
f.write("END:VEVENT\n") f.write("END:VEVENT\n")
def save_events_to_vdir(events, org_vdir_path, progress, task_id, dry_run=False): def save_events_to_vdir(events, org_vdir_path, progress, task_id, dry_run=False):
""" """
Save events to vdir format (one file per event). Save events to vdir format (one file per event).
@@ -201,7 +217,9 @@ def save_events_to_vdir(events, org_vdir_path, progress, task_id, dry_run=False)
Number of events processed Number of events processed
""" """
if dry_run: if dry_run:
progress.console.print(f"[DRY-RUN] Would save {len(events)} events to vdir format in {org_vdir_path}") progress.console.print(
f"[DRY-RUN] Would save {len(events)} events to vdir format in {org_vdir_path}"
)
return len(events) return len(events)
os.makedirs(org_vdir_path, exist_ok=True) os.makedirs(org_vdir_path, exist_ok=True)
@@ -212,29 +230,26 @@ def save_events_to_vdir(events, org_vdir_path, progress, task_id, dry_run=False)
for file_path in glob.glob(os.path.join(org_vdir_path, "*.ics")): for file_path in glob.glob(os.path.join(org_vdir_path, "*.ics")):
file_name = os.path.basename(file_path) file_name = os.path.basename(file_path)
file_mod_time = os.path.getmtime(file_path) file_mod_time = os.path.getmtime(file_path)
existing_files[file_name] = { existing_files[file_name] = {"path": file_path, "mtime": file_mod_time}
'path': file_path,
'mtime': file_mod_time
}
processed_files = set() processed_files = set()
for event in events: for event in events:
progress.advance(task_id) progress.advance(task_id)
if 'start' not in event or 'end' not in event: if "start" not in event or "end" not in event:
continue continue
# Parse start and end times with timezone information # Parse start and end times with timezone information
start = parser.isoparse(event['start']['dateTime']) start = parser.isoparse(event["start"]["dateTime"])
end = parser.isoparse(event['end']['dateTime']) end = parser.isoparse(event["end"]["dateTime"])
uid = event.get('iCalUId', '') uid = event.get("iCalUId", "")
if not uid: if not uid:
# Generate a unique ID if none exists # Generate a unique ID if none exists
uid = f"outlook-{event.get('id', '')}" uid = f"outlook-{event.get('id', '')}"
# Create a filename based on the UID # Create a filename based on the UID
safe_filename = re.sub(r'[^\w\-]', '_', uid) + ".ics" safe_filename = re.sub(r"[^\w\-]", "_", uid) + ".ics"
event_path = os.path.join(org_vdir_path, safe_filename) event_path = os.path.join(org_vdir_path, safe_filename)
processed_files.add(safe_filename) processed_files.add(safe_filename)
@@ -242,15 +257,19 @@ def save_events_to_vdir(events, org_vdir_path, progress, task_id, dry_run=False)
should_update = True should_update = True
if safe_filename in existing_files: if safe_filename in existing_files:
# Only update if the event has been modified since the file was last updated # Only update if the event has been modified since the file was last updated
if 'lastModifiedDateTime' in event: if "lastModifiedDateTime" in event:
last_modified = parser.isoparse(event['lastModifiedDateTime']).timestamp() last_modified = parser.isoparse(
file_mtime = existing_files[safe_filename]['mtime'] event["lastModifiedDateTime"]
).timestamp()
file_mtime = existing_files[safe_filename]["mtime"]
if last_modified <= file_mtime: if last_modified <= file_mtime:
should_update = False should_update = False
progress.console.print(f"Skipping unchanged event: {event['subject']}") progress.console.print(
f"Skipping unchanged event: {event['subject']}"
)
if should_update: if should_update:
with open(event_path, 'w') as f: with open(event_path, "w") as f:
f.write("BEGIN:VCALENDAR\nVERSION:2.0\n") f.write("BEGIN:VCALENDAR\nVERSION:2.0\n")
write_event_to_ical(f, event, start, end) write_event_to_ical(f, event, start, end)
f.write("END:VCALENDAR\n") f.write("END:VCALENDAR\n")
@@ -258,12 +277,24 @@ def save_events_to_vdir(events, org_vdir_path, progress, task_id, dry_run=False)
# Remove files for events that no longer exist in the calendar view # Remove files for events that no longer exist in the calendar view
for file_name in existing_files: for file_name in existing_files:
if file_name not in processed_files: if file_name not in processed_files:
progress.console.print(f"Removing obsolete event file: {truncate_id(file_name)}") progress.console.print(
os.remove(existing_files[file_name]['path']) f"Removing obsolete event file: {truncate_id(file_name)}"
)
os.remove(existing_files[file_name]["path"])
# Create sync timestamp to track when this download sync completed
timestamp_file = os.path.join(org_vdir_path, ".sync_timestamp")
try:
with open(timestamp_file, "w") as f:
f.write(str(datetime.now().timestamp()))
progress.console.print(f"Updated sync timestamp for calendar monitoring")
except IOError as e:
progress.console.print(f"Warning: Could not create sync timestamp: {e}")
progress.console.print(f"Saved {len(events)} events to {org_vdir_path}") progress.console.print(f"Saved {len(events)} events to {org_vdir_path}")
return len(events) return len(events)
def save_events_to_file(events, output_file, progress, task_id, dry_run=False): def save_events_to_file(events, output_file, progress, task_id, dry_run=False):
""" """
Save all events to a single iCalendar file. Save all events to a single iCalendar file.
@@ -285,14 +316,14 @@ def save_events_to_file(events, output_file, progress, task_id, dry_run=False):
os.makedirs(os.path.dirname(output_file), exist_ok=True) os.makedirs(os.path.dirname(output_file), exist_ok=True)
progress.console.print(f"Saving events to {output_file}...") progress.console.print(f"Saving events to {output_file}...")
with open(output_file, 'w') as f: with open(output_file, "w") as f:
f.write("BEGIN:VCALENDAR\nVERSION:2.0\n") f.write("BEGIN:VCALENDAR\nVERSION:2.0\n")
for event in events: for event in events:
progress.advance(task_id) progress.advance(task_id)
if 'start' in event and 'end' in event: if "start" in event and "end" in event:
# Parse start and end times with timezone information # Parse start and end times with timezone information
start = parser.isoparse(event['start']['dateTime']) start = parser.isoparse(event["start"]["dateTime"])
end = parser.isoparse(event['end']['dateTime']) end = parser.isoparse(event["end"]["dateTime"])
write_event_to_ical(f, event, start, end) write_event_to_ical(f, event, start, end)
f.write("END:VCALENDAR\n") f.write("END:VCALENDAR\n")