fix: stable event hashing, HEAD fallback, caldav str/bytes compat
This commit is contained in:
@@ -5,8 +5,11 @@ from typing import Any
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _extract_uid(ical_data: bytes) -> str:
|
def _extract_uid(ical_data) -> str:
|
||||||
for line in ical_data.decode("utf-8", errors="replace").split("\r\n"):
|
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:"):
|
if line.upper().startswith("UID:"):
|
||||||
return line[4:].strip()
|
return line[4:].strip()
|
||||||
return ""
|
return ""
|
||||||
@@ -15,7 +18,11 @@ def _extract_uid(ical_data: bytes) -> str:
|
|||||||
def _find_event_by_uid(calendar: Any, uid: str) -> Any | None:
|
def _find_event_by_uid(calendar: Any, uid: str) -> Any | None:
|
||||||
try:
|
try:
|
||||||
for event in calendar.events():
|
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"):
|
for line in content.split("\r\n"):
|
||||||
if line.upper().startswith("UID:"):
|
if line.upper().startswith("UID:"):
|
||||||
if line[4:].strip() == uid:
|
if line[4:].strip() == uid:
|
||||||
|
|||||||
@@ -5,6 +5,19 @@ from icalendar import Calendar
|
|||||||
logger = logging.getLogger(__name__)
|
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]:
|
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)
|
cal = Calendar.from_ical(ics_text.encode() if isinstance(ics_text, str) else ics_text)
|
||||||
result = {}
|
result = {}
|
||||||
@@ -17,9 +30,7 @@ def parse_ics_events(ics_text: str) -> dict[str, str]:
|
|||||||
continue
|
continue
|
||||||
uid_str = str(uid)
|
uid_str = str(uid)
|
||||||
try:
|
try:
|
||||||
event_bytes = component.to_ical()
|
result[uid_str] = _stable_event_key(component)
|
||||||
file_hash = hashlib.sha256(event_bytes).hexdigest()
|
|
||||||
result[uid_str] = file_hash
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to process event %s: %s", uid_str, e)
|
logger.warning("Failed to process event %s: %s", uid_str, e)
|
||||||
return result
|
return result
|
||||||
|
|||||||
+28
-3
@@ -56,9 +56,14 @@ def sync_once(state: SyncState, health: HealthServer, config: Config) -> bool:
|
|||||||
logger.info("Starting sync cycle...")
|
logger.info("Starting sync cycle...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
r = requests.head(config.ics_url, headers=HEADERS, timeout=30, allow_redirects=True)
|
remote_etag = None
|
||||||
r.raise_for_status()
|
try:
|
||||||
remote_etag = r.headers.get("ETag")
|
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()
|
cached_hash, cached_etag, _ = state.get_ics_cache()
|
||||||
|
|
||||||
if remote_etag and cached_etag == remote_etag:
|
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_update = deltas["to_update"]
|
||||||
to_delete = deltas["to_delete"]
|
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:
|
if not to_add and not to_update and not to_delete:
|
||||||
logger.info("Calendar is already in sync.")
|
logger.info("Calendar is already in sync.")
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
@@ -100,6 +107,24 @@ def sync_once(state: SyncState, health: HealthServer, config: Config) -> bool:
|
|||||||
True,
|
True,
|
||||||
len(ics_uids),
|
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
|
return True
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
Reference in New Issue
Block a user