diff --git a/apply.py b/apply.py index 294b0cb..8cab090 100644 --- a/apply.py +++ b/apply.py @@ -5,8 +5,11 @@ from typing import Any logger = logging.getLogger(__name__) -def _extract_uid(ical_data: bytes) -> str: - for line in ical_data.decode("utf-8", errors="replace").split("\r\n"): +def _extract_uid(ical_data) -> str: + raw = ical_data + if isinstance(raw, bytes): + raw = raw.decode("utf-8", errors="replace") + for line in str(raw).split("\r\n"): if line.upper().startswith("UID:"): return line[4:].strip() return "" @@ -15,7 +18,11 @@ def _extract_uid(ical_data: bytes) -> str: def _find_event_by_uid(calendar: Any, uid: str) -> Any | None: try: for event in calendar.events(): - content = event.data.decode("utf-8", errors="replace") + raw = event.data + if isinstance(raw, bytes): + content = raw.decode("utf-8", errors="replace") + else: + content = str(raw) for line in content.split("\r\n"): if line.upper().startswith("UID:"): if line[4:].strip() == uid: diff --git a/diff.py b/diff.py index d0d4051..7f18706 100644 --- a/diff.py +++ b/diff.py @@ -5,6 +5,19 @@ from icalendar import Calendar logger = logging.getLogger(__name__) +def _stable_event_key(component) -> str: + event_bytes = component.to_ical() + text = event_bytes.decode("utf-8", errors="replace") + stable_lines = [] + for line in text.split("\r\n"): + key = line.split(":")[0].upper() if ":" in line else "" + if key in ("DTSTAMP", "LAST-MODIFIED"): + continue + stable_lines.append(line) + stable_text = "\r\n".join(stable_lines) + return hashlib.sha256(stable_text.encode("utf-8")).hexdigest() + + def parse_ics_events(ics_text: str) -> dict[str, str]: cal = Calendar.from_ical(ics_text.encode() if isinstance(ics_text, str) else ics_text) result = {} @@ -17,9 +30,7 @@ def parse_ics_events(ics_text: str) -> dict[str, str]: continue uid_str = str(uid) try: - event_bytes = component.to_ical() - file_hash = hashlib.sha256(event_bytes).hexdigest() - result[uid_str] = file_hash + result[uid_str] = _stable_event_key(component) except Exception as e: logger.warning("Failed to process event %s: %s", uid_str, e) return result diff --git a/sync_calendar.py b/sync_calendar.py index b8c6e94..b265ab0 100644 --- a/sync_calendar.py +++ b/sync_calendar.py @@ -56,9 +56,14 @@ def sync_once(state: SyncState, health: HealthServer, config: Config) -> bool: logger.info("Starting sync cycle...") try: - r = requests.head(config.ics_url, headers=HEADERS, timeout=30, allow_redirects=True) - r.raise_for_status() - remote_etag = r.headers.get("ETag") + remote_etag = None + try: + r = requests.head(config.ics_url, headers=HEADERS, timeout=15, allow_redirects=True) + if r.status_code < 400: + remote_etag = r.headers.get("ETag") + except Exception: + pass + cached_hash, cached_etag, _ = state.get_ics_cache() if remote_etag and cached_etag == remote_etag: @@ -91,6 +96,8 @@ def sync_once(state: SyncState, health: HealthServer, config: Config) -> bool: to_update = deltas["to_update"] to_delete = deltas["to_delete"] + is_first_run = not state.get_event_uids() and not cached_hash + if not to_add and not to_update and not to_delete: logger.info("Calendar is already in sync.") duration = time.time() - start_time @@ -100,6 +107,24 @@ def sync_once(state: SyncState, health: HealthServer, config: Config) -> bool: True, len(ics_uids), ) + for uid, h in ics_uids.items(): + state.upsert_event(uid, h) + return True + + if is_first_run and not to_delete: + logger.info( + "First run detected: %d events in ICS. Registering state without re-adding to calendar.", + len(to_add), + ) + for uid, h in ics_uids.items(): + state.upsert_event(uid, h) + duration = time.time() - start_time + health.update_status( + datetime.now(timezone.utc), + duration, + True, + len(ics_uids), + ) return True logger.info(