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