diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c97309c --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +ICS_URL=https://outlook.office365.com/owa/calendar/your-calendar.ics +BAIKAL_URL=https://your-baikal.com/dav.php/calendars/USER/calendar-id/ +BAIKAL_USER=your-username +BAIKAL_PASS=your-password +SYNC_FREQUENCY=5 +CALENDAR_ID= diff --git a/.gitignore b/.gitignore index 110b0a6..3891c11 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,13 @@ wheels/ # Virtual environments .venv .env + +# Sync state database +*.db + +# Playwright MCP artifacts +.playwright-mcp/ +*.png + +# Test artifacts +.pytest_cache/ diff --git a/Dockerfile b/Dockerfile index 12f6bc2..961f932 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,11 @@ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY sync_calendar.py . +COPY . . -CMD ["python", "-u", "sync_calendar.py"] +EXPOSE 8081 8082 + +HEALTHCHECK --interval=60s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8081/health')" || exit 1 + +CMD ["python", "-u", "main.py"] diff --git a/README.md b/README.md index 373ae07..d46d3c6 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,75 @@ # Baïkal Calendar Sync -A Dockerized Python service that periodically synchronizes an external Outlook ICS calendar to a local Baïkal CalDAV server. +An incremental CalDAV sync service that keeps your Baïkal calendar in sync with an Outlook ICS feed — with zero downtime and near-instant sync times. ## Features -- **Automatic Sync**: Runs periodically (default: every 5 minutes). -- **Environment Configurable**: All credentials and URLs are set via environment variables. -- **Docker Ready**: Includes `Dockerfile` and `docker-compose.yml` for easy deployment. + +- **Incremental Delta Sync**: Only adds, updates, or deletes events that actually changed +- **Zero Downtime**: Adds/updates events before deleting removed ones — calendar never goes empty +- **Smart Change Detection**: ETag check first, SHA-256 fallback. Skips sync when nothing changed +- **Stable Event Hashing**: Ignores volatile fields (DTSTAMP, LAST-MODIFIED) to avoid false updates +- **Automatic Backoff**: Exponential retry on failures (1m, 2m, 4m... max 30m) +- **Health Endpoint**: Built-in `/health` and `/metrics` (Prometheus-compatible) on port 8081 +- **Graceful Shutdown**: SIGTERM/SIGINT handlers finish current sync before exiting +- **Docker Ready**: Includes `Dockerfile`, `docker-compose.yml`, and HEALTHCHECK ## Prerequisites -- Docker & Docker Compose installed. -- An existing Baïkal instance. -- An Outlook ICS link. + +- Docker & Docker Compose installed +- An existing Baïkal instance +- An Outlook ICS link ## Configuration (Environment Variables) | Variable | Description | Default | | :--- | :--- | :--- | | `ICS_URL` | The public ICS URL of your Outlook calendar. | **Required** | -| `BAIKAL_URL` | The URL to your Baïkal calendar (e.g., `http://baikal/dav.php/calendars/user/id/`). | **Required** | +| `BAIKAL_URL` | The URL to your Baïkal calendar. | **Required** | | `BAIKAL_USER` | Your Baïkal username. | **Required** | | `BAIKAL_PASS` | Your Baïkal password. | **Required** | | `SYNC_FREQUENCY` | How often to sync **in minutes**. | `5` | +| `CALENDAR_ID` | Optional calendar ID to match against. | — | -## Quick Start with Docker Compose +## Quick Start -1. **Clone or download** this repository. -2. **Create a `.env` file** (optional but recommended) or export variables: +1. **Clone or download** this repository. +2. **Create a `.env` file** (optional but recommended): ```bash - ICS_URL="https://outlook.office365.com/..." - BAIKAL_URL="http://localhost:8080/dav.php/calendars/Lago/default/" - BAIKAL_USER="Lago" - BAIKAL_PASS="secret" + ICS_URL="https://outlook.office365.com/owa/calendar/your-calendar.ics" + BAIKAL_URL="https://your-baikal.com/dav.php/calendars/user/calendar-id/" + BAIKAL_USER="your-username" + BAIKAL_PASS="your-password" SYNC_FREQUENCY=5 ``` -3. **Run with Docker Compose**: +3. **Run with Docker Compose**: ```bash docker compose up -d ``` - *Note: This will verify the image usage. If you need to rebuild locally, run `docker compose up -d --build`.* -4. **Check Logs**: +4. **Check Logs**: ```bash docker compose logs -f baikal-sync ``` -## Building & Publishing the Image +5. **Check Health**: -1. **Build**: + ```bash + curl http://localhost:8081/health + curl http://localhost:8081/metrics + ``` + +## Building & Publishing + +1. **Build**: ```bash docker build -t lagortinez/baikal-sync:latest . ``` -2. **Push** (requires `docker login`): +2. **Push**: ```bash docker push lagortinez/baikal-sync:latest ``` diff --git a/apply.py b/apply.py new file mode 100644 index 0000000..8cab090 --- /dev/null +++ b/apply.py @@ -0,0 +1,114 @@ +import logging +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Any + +logger = logging.getLogger(__name__) + + +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 "" + + +def _find_event_by_uid(calendar: Any, uid: str) -> Any | None: + try: + for event in calendar.events(): + 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: + return event + except Exception as exc: + logger.error("Error scanning events for UID %s: %s", uid, exc) + return None + + +def _add_event(calendar: Any, uid: str, ical_data: bytes) -> bool: + try: + calendar.add_event(ical_data) + return True + except Exception as exc: + logger.error("Failed to add event %s: %s", uid, exc) + return False + + +def _delete_event(calendar: Any, uid: str) -> bool: + try: + event = _find_event_by_uid(calendar, uid) + if event: + event.delete() + return True + logger.error("Event with UID %s not found", uid) + return False + except Exception as exc: + logger.error("Failed to delete event %s: %s", uid, exc) + return False + + +def _update_event(calendar: Any, uid: str, ical_data: bytes) -> bool: + if not _delete_event(calendar, uid): + return False + return _add_event(calendar, uid, ical_data) + + +def apply_adds(calendar: Any, events: dict[str, bytes], max_workers: int = 10) -> tuple[int, int]: + success = 0 + errors = 0 + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = { + executor.submit(_add_event, calendar, uid, data): uid + for uid, data in events.items() + } + for future in as_completed(futures): + if future.result(): + success += 1 + else: + errors += 1 + + return success, errors + + +def apply_updates(calendar: Any, events: dict[str, bytes], max_workers: int = 10) -> tuple[int, int]: + success = 0 + errors = 0 + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = { + executor.submit(_update_event, calendar, uid, data): uid + for uid, data in events.items() + } + for future in as_completed(futures): + if future.result(): + success += 1 + else: + errors += 1 + + return success, errors + + +def apply_deletes(calendar: Any, uids: list[str], max_workers: int = 10) -> tuple[int, int]: + success = 0 + errors = 0 + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = { + executor.submit(_delete_event, calendar, uid): uid + for uid in uids + } + for future in as_completed(futures): + if future.result(): + success += 1 + else: + errors += 1 + + return success, errors diff --git a/config.py b/config.py new file mode 100644 index 0000000..8263c05 --- /dev/null +++ b/config.py @@ -0,0 +1,53 @@ +import os +from dataclasses import dataclass + + +HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Unraid-Sync/1.0"} + + +@dataclass +class Config: + ics_url: str + baikal_url: str + baikal_user: str + baikal_pass: str + sync_frequency: int + + +def validate() -> Config: + errors = [] + + ics_url = os.environ.get("ICS_URL") + if not ics_url: + errors.append("ICS_URL is required") + + baikal_url = os.environ.get("BAIKAL_URL") + if not baikal_url: + errors.append("BAIKAL_URL is required") + + baikal_user = os.environ.get("BAIKAL_USER") + if not baikal_user: + errors.append("BAIKAL_USER is required") + + baikal_pass = os.environ.get("BAIKAL_PASS") + if not baikal_pass: + errors.append("BAIKAL_PASS is required") + + sync_freq_raw = os.environ.get("SYNC_FREQUENCY", "5") + try: + sync_frequency = int(sync_freq_raw) + if sync_frequency <= 0: + raise ValueError + except ValueError: + errors.append("SYNC_FREQUENCY must be a positive integer") + + if errors: + raise ValueError("\n".join(errors)) + + return Config( + ics_url=ics_url, + baikal_url=baikal_url, + baikal_user=baikal_user, + baikal_pass=baikal_pass, + sync_frequency=sync_frequency, + ) diff --git a/dashboard.py b/dashboard.py new file mode 100644 index 0000000..0f36efb --- /dev/null +++ b/dashboard.py @@ -0,0 +1,157 @@ +import json +import os +import threading +import time +from http.server import HTTPServer, BaseHTTPRequestHandler +from socketserver import ThreadingMixIn + +from health import SyncSession + + +class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): + daemon_threads = True + + +class DashboardServer: + def __init__(self, port: int = 8082, session: SyncSession = None): + self.port = port + self.session = session + self.server = None + self.thread = None + self._lock = threading.Lock() + self._syncing = False + self._next_sync_in = 0 + self._event_count = 0 + self._backoff_min = 0 + self._config = {} + self._last_sync = None + self._last_duration = 0.0 + self._last_success = None + self._last_latency_ms = 0 + self._base_dir = os.path.dirname(os.path.abspath(__file__)) + + def start(self): + handler = self._make_handler() + self.server = ThreadingHTTPServer(("0.0.0.0", self.port), handler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + + def stop(self): + if self.server: + self.server.shutdown() + self.server = None + self.thread = None + + def update_config(self, config_dict: dict): + with self._lock: + self._config = dict(config_dict) + + def set_syncing(self, syncing: bool): + with self._lock: + self._syncing = syncing + + def set_next_sync_in(self, seconds: int): + with self._lock: + self._next_sync_in = seconds + + def set_event_count(self, n: int): + with self._lock: + self._event_count = n + + def set_backoff_min(self, n: int): + with self._lock: + self._backoff_min = n + + def set_last_sync(self, last_sync, duration: float, success: bool, latency_ms: int = 0): + with self._lock: + self._last_sync = last_sync + self._last_duration = duration + self._last_success = success + self._last_latency_ms = latency_ms + + def _get_status(self): + with self._lock: + syncing = self._syncing + next_sync_in = self._next_sync_in + event_count = self._event_count + backoff_min = self._backoff_min + config = dict(self._config) + last_sync = self._last_sync + duration = self._last_duration + last_success = self._last_success + latency_ms = self._last_latency_ms + + status = "idle" + if syncing: + status = "syncing" + elif last_success is False: + status = "error" + elif backoff_min > 0: + status = "backoff" + + session_data = {} + history = [] + if self.session: + session_data = self.session.get_status() + history = session_data.get("history", []) + + ics_latency = 0.0 + if self.session and self.session.non_skip_count > 0: + ics_latency = round(self.session.total_latency_ms / self.session.non_skip_count) + + return { + "status": status, + "last_sync": last_sync.isoformat() if last_sync else None, + "duration": duration, + "ics_latency_ms": ics_latency if ics_latency else latency_ms, + "event_count": event_count, + "next_sync_in": next_sync_in, + "session": session_data, + "history": history, + "config": config, + } + + def _make_handler(self): + server_self = self + base_dir = server_self._base_dir + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/api/status": + self._handle_api(server_self) + elif self.path == "/": + self._handle_dashboard(base_dir) + else: + self.send_response(404) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"error": "not found"}).encode()) + + def _handle_api(self, srv): + data = srv._get_status() + body = json.dumps(data).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(body) + + def _handle_dashboard(self, base): + html_path = os.path.join(base, "static", "dashboard.html") + try: + with open(html_path, "r", encoding="utf-8") as f: + content = f.read() + body = content.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write(body) + except FileNotFoundError: + self.send_response(404) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"error": "dashboard not found"}).encode()) + + def log_message(self, format, *args): + pass + + return Handler diff --git a/diff.py b/diff.py new file mode 100644 index 0000000..7f18706 --- /dev/null +++ b/diff.py @@ -0,0 +1,76 @@ +import hashlib +import logging +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 = {} + for component in cal.walk(): + if component.name != "VEVENT": + continue + uid = component.get("UID") + if uid is None: + logger.warning("Skipping event with no UID") + continue + uid_str = str(uid) + try: + result[uid_str] = _stable_event_key(component) + except Exception as e: + logger.warning("Failed to process event %s: %s", uid_str, e) + return result + + +def compute_diff(ics_uids: dict[str, str], known_uids: dict[str, str]) -> dict: + to_add = [] + to_update = [] + to_delete = [] + + for uid, file_hash in ics_uids.items(): + if uid not in known_uids: + to_add.append((uid, file_hash)) + elif known_uids[uid] != file_hash: + to_update.append((uid, file_hash)) + + for uid in known_uids: + if uid not in ics_uids: + to_delete.append(uid) + + return { + "to_add": to_add, + "to_update": to_update, + "to_delete": to_delete, + } + + +def parse_ics_events_with_data(ics_text: str) -> dict[str, bytes]: + cal = Calendar.from_ical(ics_text.encode() if isinstance(ics_text, str) else ics_text) + result = {} + for component in cal.walk(): + if component.name != "VEVENT": + continue + uid = component.get("UID") + if uid is None: + logger.warning("Skipping event with no UID") + continue + uid_str = str(uid) + try: + result[uid_str] = component.to_ical() + except Exception as e: + logger.warning("Failed to process event %s: %s", uid_str, e) + return result diff --git a/docker-compose.yml b/docker-compose.yml index 60e3517..6670ff5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,12 +3,18 @@ services: image: lagortinez/baikal-sync:latest container_name: baikal-sync restart: always + ports: + - "8082:8082" environment: - ICS_URL=${ICS_URL} - BAIKAL_URL=${BAIKAL_URL} - BAIKAL_USER=${BAIKAL_USER} - BAIKAL_PASS=${BAIKAL_PASS} - - SYNC_FREQUENCY=5 # Minutes - # If your Baikal is on the same host, you might need network_mode: "host" - # or ensure they share a network. - # network_mode: "host" + - SYNC_FREQUENCY=${SYNC_FREQUENCY:-5} + - CALENDAR_ID=${CALENDAR_ID:-} + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8081/health')"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 5s diff --git a/health.py b/health.py new file mode 100644 index 0000000..b220e91 --- /dev/null +++ b/health.py @@ -0,0 +1,184 @@ +import json +import threading +import time +from collections import deque +from datetime import datetime, timezone +from http.server import HTTPServer, BaseHTTPRequestHandler +from io import StringIO + + +class SyncSession: + def __init__(self): + self._lock = threading.Lock() + self.start_time = time.time() + self.syncs_total = 0 + self.syncs_failed = 0 + self.syncs_skipped = 0 + self.total_added = 0 + self.total_updated = 0 + self.total_deleted = 0 + self.total_duration = 0.0 + self.total_latency_ms = 0.0 + self.non_skip_count = 0 + self.bandwidth_bytes = 0 + self.bandwidth_saved_bytes = 0 + self.history = deque(maxlen=50) + + def record(self, ok: bool, duration: float, added: int, updated: int, deleted: int, skipped: bool, ics_latency_ms: float, msg: str, ics_download_size: int = 0) -> None: + with self._lock: + self.syncs_total += 1 + ts = time.strftime("%H:%M:%S", time.gmtime()) + if not ok: + self.syncs_failed += 1 + if skipped: + self.syncs_skipped += 1 + self.total_added += added + self.total_updated += updated + self.total_deleted += deleted + if not skipped: + self.non_skip_count += 1 + self.total_duration += duration + self.total_latency_ms += ics_latency_ms + if ics_download_size: + self.bandwidth_bytes += ics_download_size + self.history.append({ + "time": ts, + "ok": ok, + "duration": round(duration, 2), + "added": added, + "updated": updated, + "deleted": deleted, + "skipped": skipped, + "latency_ms": round(ics_latency_ms), + "msg": msg, + }) + + def get_status(self) -> dict: + with self._lock: + uptime = time.time() - self.start_time + avg_dur = round(self.total_duration / self.non_skip_count, 2) if self.non_skip_count > 0 else 0 + avg_lat = round(self.total_latency_ms / self.non_skip_count) if self.non_skip_count > 0 else 0 + return { + "uptime_sec": round(uptime), + "syncs_total": self.syncs_total, + "syncs_failed": self.syncs_failed, + "syncs_skipped": self.syncs_skipped, + "total_added": self.total_added, + "total_updated": self.total_updated, + "total_deleted": self.total_deleted, + "avg_duration": avg_dur, + "avg_latency_ms": avg_lat, + "bandwidth_bytes": self.bandwidth_bytes, + "bandwidth_saved_bytes": self.bandwidth_saved_bytes, + "history": list(self.history), + } + + def add_saved_bandwidth(self, bytes_saved: int) -> None: + with self._lock: + self.bandwidth_saved_bytes += bytes_saved + + +class HealthServer: + def __init__(self, port: int = 8081): + self.port = port + self.lock = threading.Lock() + self.last_sync = None + self.last_sync_duration = 0.0 + self.last_sync_success = None + self.event_count = 0 + self.syncs_total = 0 + self.syncs_failed = 0 + self.server = None + self.thread = None + + def start(self) -> None: + handler = self._make_handler() + + self.server = HTTPServer(("0.0.0.0", self.port), handler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + + def stop(self) -> None: + if self.server: + self.server.shutdown() + self.server = None + self.thread = None + + def update_status(self, last_sync: datetime, duration: float, success: bool, event_count: int) -> None: + with self.lock: + self.last_sync = last_sync + self.last_sync_duration = duration + self.last_sync_success = success + self.event_count = event_count + self.syncs_total += 1 + if not success: + self.syncs_failed += 1 + + def _make_handler(self): + server_self = self + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/health": + self._handle_health(server_self) + elif self.path == "/metrics": + self._handle_metrics(server_self) + else: + self.send_response(404) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"error": "not found"}).encode()) + + def _handle_health(self, srv): + with srv.lock: + status = "ok" + result = "none" + + if srv.last_sync_success is not None: + if srv.last_sync_success: + result = "success" + else: + result = "failure" + status = "error" + + payload = { + "status": status, + "last_sync": srv.last_sync.isoformat() if srv.last_sync else None, + "last_sync_duration_sec": srv.last_sync_duration, + "last_sync_result": result, + "event_count": srv.event_count, + "syncs_total": srv.syncs_total, + "syncs_failed": srv.syncs_failed, + } + + body = json.dumps(payload).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(body) + + def _handle_metrics(self, srv): + with srv.lock: + last_success = 1 if srv.last_sync_success else 0 + duration = srv.last_sync_duration + evt_count = srv.event_count + total = srv.syncs_total + failed = srv.syncs_failed + + buf = StringIO() + buf.write(f"baikal_sync_last_duration_seconds {duration}\n") + buf.write(f"baikal_sync_events_total {evt_count}\n") + buf.write(f"baikal_sync_total {total}\n") + buf.write(f"baikal_sync_failures_total {failed}\n") + buf.write(f"baikal_sync_last_success {last_success}\n") + + body = buf.getvalue().encode() + self.send_response(200) + self.send_header("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): + pass + + return Handler diff --git a/main.py b/main.py index 9d3c4bd..23f4e99 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,4 @@ -def main(): - print("Hello from ics-to-caldav!") - +from sync_calendar import main if __name__ == "__main__": main() diff --git a/pyproject.toml b/pyproject.toml index 4220eb6..81c71b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "ics-to-caldav" -version = "0.1.0" -description = "Add your description here" +version = "1.0.0" +description = "Incremental CalDAV sync from Outlook ICS to Baikal" readme = "README.md" requires-python = ">=3.13" dependencies = [ @@ -9,3 +9,11 @@ dependencies = [ "icalendar>=6.3.2", "requests>=2.32.5", ] + +[project.optional-dependencies] +test = [ + "pytest>=8.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/requirements.txt b/requirements.txt index eeb459b..9536ef1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ -caldav>=1.0.0 -icalendar>=5.0.0 -requests>=2.20.0 +caldav>=2.2.3 +icalendar>=6.3.2 +requests>=2.32.5 +pytest>=8.0 diff --git a/state.py b/state.py new file mode 100644 index 0000000..d9954ae --- /dev/null +++ b/state.py @@ -0,0 +1,105 @@ +import sqlite3 +import threading +from datetime import datetime, timezone + + +class SyncState: + def __init__(self, db_path="./sync.db"): + self.db_path = db_path + self._lock = threading.Lock() + self._conn = sqlite3.connect(db_path, timeout=30) + self._conn.execute("PRAGMA journal_mode=WAL") + self._conn.execute(""" + CREATE TABLE IF NOT EXISTS events ( + uid TEXT PRIMARY KEY, + content_hash TEXT NOT NULL, + synced_at TIMESTAMP NOT NULL + ) + """) + self._conn.execute(""" + CREATE TABLE IF NOT EXISTS ics_cache ( + id INTEGER PRIMARY KEY CHECK(id=1), + ics_hash TEXT, + etag TEXT, + fetched_at TIMESTAMP + ) + """) + self._conn.commit() + + def get_event_uids(self) -> set[str]: + with self._lock: + cur = self._conn.execute("SELECT uid FROM events") + return {row[0] for row in cur.fetchall()} + + def get_event_hash(self, uid: str) -> str | None: + with self._lock: + cur = self._conn.execute("SELECT content_hash FROM events WHERE uid = ?", (uid,)) + row = cur.fetchone() + return row[0] if row else None + + def upsert_event(self, uid: str, content_hash: str) -> None: + with self._lock: + self._conn.execute(""" + INSERT OR REPLACE INTO events (uid, content_hash, synced_at) + VALUES (?, ?, ?) + """, (uid, content_hash, datetime.now(timezone.utc).isoformat())) + self._conn.commit() + + def delete_event(self, uid: str) -> None: + with self._lock: + self._conn.execute("DELETE FROM events WHERE uid = ?", (uid,)) + self._conn.commit() + + def clear_events(self) -> int: + with self._lock: + cur = self._conn.execute("SELECT COUNT(*) FROM events") + count = cur.fetchone()[0] + self._conn.execute("DELETE FROM events") + self._conn.commit() + return count + + def get_ics_cache(self) -> tuple[str | None, str | None, str | None]: + with self._lock: + cur = self._conn.execute("SELECT ics_hash, etag, fetched_at FROM ics_cache WHERE id = 1") + row = cur.fetchone() + if row: + return (row[0], row[1], row[2]) + return (None, None, None) + + def set_ics_cache(self, ics_hash: str, etag: str | None) -> None: + with self._lock: + self._conn.execute(""" + INSERT INTO ics_cache (id, ics_hash, etag, fetched_at) + VALUES (1, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET ics_hash = ?, etag = ?, fetched_at = ? + """, (ics_hash, etag, datetime.now(timezone.utc).isoformat(), + ics_hash, etag, datetime.now(timezone.utc).isoformat())) + self._conn.commit() + + def clear_ics_cache(self) -> None: + with self._lock: + self._conn.execute("DELETE FROM ics_cache") + self._conn.commit() + + def snapshot(self) -> dict: + with self._lock: + cur = self._conn.execute("SELECT uid, content_hash FROM events") + rows = cur.fetchall() + return { + "uids": [r[0] for r in rows], + "hashes": {r[0]: r[1] for r in rows} + } + + def restore_snapshot(self, data: dict) -> None: + with self._lock: + self._conn.execute("DELETE FROM events") + for uid, content_hash in data.get("hashes", {}).items(): + self._conn.execute(""" + INSERT INTO events (uid, content_hash, synced_at) + VALUES (?, ?, ?) + """, (uid, content_hash, datetime.now(timezone.utc).isoformat())) + self._conn.commit() + + def close(self) -> None: + with self._lock: + self._conn.close() diff --git a/static/dashboard.html b/static/dashboard.html new file mode 100644 index 0000000..2d31a41 --- /dev/null +++ b/static/dashboard.html @@ -0,0 +1,737 @@ + + +
+ + +