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:
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user