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.
"""
import re
import os
from datetime import datetime, timedelta
@@ -40,7 +41,7 @@ def clean_text(text):
if not text:
return ""
# 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):
@@ -63,8 +64,15 @@ def escape_ical_text(text):
text = text.replace(";", "\\;")
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.
@@ -85,7 +93,9 @@ async def fetch_calendar_events(headers, days_back=1, days_forward=6, fetch_func
# Calculate date range
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:
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"
try:
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:
print(f"Error fetching total events count: {e}")
total_events = 0
@@ -110,9 +120,9 @@ async def fetch_calendar_events(headers, days_back=1, days_forward=6, fetch_func
try:
response_data = await fetch_function(calendar_url, headers)
if response_data:
events.extend(response_data.get('value', []))
events.extend(response_data.get("value", []))
# Get the next page URL from @odata.nextLink
calendar_url = response_data.get('@odata.nextLink')
calendar_url = response_data.get("@odata.nextLink")
else:
print("Received empty response from calendar API")
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
return events, total_events
def write_event_to_ical(f, event, start, end):
"""
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")
# Handle multi-line description properly
description = event.get('bodyPreview', '')
description = event.get("bodyPreview", "")
if description:
escaped_description = escape_ical_text(description)
f.write(f"DESCRIPTION:{escaped_description}\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"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")
# Write start and end times with timezone info in iCalendar format
if start.tzinfo == UTC:
f.write(f"DTSTART:{start.strftime('%Y%m%dT%H%M%SZ')}\n")
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")
if end.tzinfo == UTC:
f.write(f"DTEND:{end.strftime('%Y%m%dT%H%M%SZ')}\n")
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")
# Handle recurrence rules
if 'recurrence' in event and event['recurrence']:
for rule in event['recurrence']:
if rule.startswith('RRULE'):
rule_parts = rule.split(';')
if "recurrence" in event and event["recurrence"]:
for rule in event["recurrence"]:
if rule.startswith("RRULE"):
rule_parts = rule.split(";")
new_rule_parts = []
for part in rule_parts:
if part.startswith('UNTIL='):
until_value = part.split('=')[1]
if part.startswith("UNTIL="):
until_value = part.split("=")[1]
until_date = parser.isoparse(until_value)
if start.tzinfo is not None and until_date.tzinfo is None:
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:
new_rule_parts.append(part)
rule = ';'.join(new_rule_parts)
rule = ";".join(new_rule_parts)
f.write(f"{rule}\n")
f.write("END:VEVENT\n")
def save_events_to_vdir(events, org_vdir_path, progress, task_id, dry_run=False):
"""
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
"""
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)
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")):
file_name = os.path.basename(file_path)
file_mod_time = os.path.getmtime(file_path)
existing_files[file_name] = {
'path': file_path,
'mtime': file_mod_time
}
existing_files[file_name] = {"path": file_path, "mtime": file_mod_time}
processed_files = set()
for event in events:
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
# Parse start and end times with timezone information
start = parser.isoparse(event['start']['dateTime'])
end = parser.isoparse(event['end']['dateTime'])
start = parser.isoparse(event["start"]["dateTime"])
end = parser.isoparse(event["end"]["dateTime"])
uid = event.get('iCalUId', '')
uid = event.get("iCalUId", "")
if not uid:
# Generate a unique ID if none exists
uid = f"outlook-{event.get('id', '')}"
# 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)
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
if safe_filename in existing_files:
# Only update if the event has been modified since the file was last updated
if 'lastModifiedDateTime' in event:
last_modified = parser.isoparse(event['lastModifiedDateTime']).timestamp()
file_mtime = existing_files[safe_filename]['mtime']
if "lastModifiedDateTime" in event:
last_modified = parser.isoparse(
event["lastModifiedDateTime"]
).timestamp()
file_mtime = existing_files[safe_filename]["mtime"]
if last_modified <= file_mtime:
should_update = False
progress.console.print(f"Skipping unchanged event: {event['subject']}")
progress.console.print(
f"Skipping unchanged event: {event['subject']}"
)
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")
write_event_to_ical(f, event, start, end)
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
for file_name in existing_files:
if file_name not in processed_files:
progress.console.print(f"Removing obsolete event file: {truncate_id(file_name)}")
os.remove(existing_files[file_name]['path'])
progress.console.print(
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}")
return len(events)
def save_events_to_file(events, output_file, progress, task_id, dry_run=False):
"""
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)
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")
for event in events:
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
start = parser.isoparse(event['start']['dateTime'])
end = parser.isoparse(event['end']['dateTime'])
start = parser.isoparse(event["start"]["dateTime"])
end = parser.isoparse(event["end"]["dateTime"])
write_event_to_ical(f, event, start, end)
f.write("END:VCALENDAR\n")