From d7ca5e451d308eddc665c17f8f36be99fe4ae89e Mon Sep 17 00:00:00 2001 From: Tim Bendt Date: Wed, 16 Jul 2025 10:08:32 -0400 Subject: [PATCH] fix sync timestamp creation in calendar downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/utils/calendar_utils.py | 113 +++++++++++++++++++++++------------- 1 file changed, 72 insertions(+), 41 deletions(-) diff --git a/src/utils/calendar_utils.py b/src/utils/calendar_utils.py index 3e44931..6c699d0 100644 --- a/src/utils/calendar_utils.py +++ b/src/utils/calendar_utils.py @@ -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")