Compare commits

...

7 Commits

20 changed files with 2064 additions and 189 deletions
+6
View File
@@ -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=
+10
View File
@@ -9,3 +9,13 @@ wheels/
# Virtual environments
.venv
.env
# Sync state database
*.db
# Playwright MCP artifacts
.playwright-mcp/
*.png
# Test artifacts
.pytest_cache/
+7 -2
View File
@@ -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"]
+35 -21
View File
@@ -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
```
+114
View File
@@ -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
+53
View File
@@ -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,
)
+157
View File
@@ -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
+76
View File
@@ -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
+10 -4
View File
@@ -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
+184
View File
@@ -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
+1 -3
View File
@@ -1,6 +1,4 @@
def main():
print("Hello from ics-to-caldav!")
from sync_calendar import main
if __name__ == "__main__":
main()
+10 -2
View File
@@ -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"]
+4 -3
View File
@@ -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
+105
View File
@@ -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()
+737
View File
@@ -0,0 +1,737 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Baikal Sync Dashboard</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--transition: 0.3s ease;
--green: #00b894;
--blue: #74b9ff;
--yellow: #fdcb6e;
--red: #e17055;
--purple: #a29bfe;
}
[data-theme="dark"] {
--bg: #0f1117;
--card: #1a1d27;
--border: #2e3345;
--text: #e4e6f0;
--text-secondary: #8b90a0;
--shadow: 0 2px 12px rgba(0,0,0,0.3);
--chart-bg: #12141c;
}
[data-theme="light"] {
--bg: #f5f7fa;
--card: #ffffff;
--border: #e2e8f0;
--text: #1a1d27;
--text-secondary: #64748b;
--shadow: 0 2px 12px rgba(0,0,0,0.08);
--chart-bg: #f0f3f8;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
transition: background var(--transition), color var(--transition);
min-height: 100vh;
}
.container { max-width: 1200px; margin: 0 auto; padding: 24px 16px; }
header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 28px;
}
.logo { display: flex; align-items: center; gap: 10px; font-size: 1.5rem; font-weight: 700; }
.status-dot {
width: 12px; height: 12px; border-radius: 50%; background: var(--green);
animation: pulse 2s ease-in-out infinite;
}
.status-dot.error { background: var(--red); }
.status-dot.syncing { background: var(--blue); animation-duration: 0.6s; }
.status-dot.backoff { background: var(--yellow); }
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(0,184,148,0.5); }
50% { box-shadow: 0 0 0 8px rgba(0,184,148,0); }
}
.theme-toggle {
background: var(--card); border: 1px solid var(--border); border-radius: 8px;
width: 40px; height: 40px; cursor: pointer; display: flex;
align-items: center; justify-content: center; font-size: 1.2rem;
transition: background var(--transition), border-color var(--transition);
}
.theme-toggle:hover { border-color: var(--text-secondary); }
.card {
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
padding: 20px; transition: background var(--transition), border-color var(--transition), box-shadow var(--transition);
box-shadow: var(--shadow);
}
.status-bar { margin-bottom: 24px; }
.status-main {
display: flex; align-items: center; justify-content: space-between;
flex-wrap: wrap; gap: 12px; margin-bottom: 16px;
}
.status-text { font-size: 1.35rem; font-weight: 600; }
.status-meta { display: flex; gap: 20px; flex-wrap: wrap; color: var(--text-secondary); font-size: 0.9rem; }
.countdown-bar { margin-top: 12px; }
.countdown-label { font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 6px; display: flex; justify-content: space-between; }
.progress-track {
height: 6px; background: var(--border); border-radius: 3px; overflow: hidden;
}
.progress-fill {
height: 100%; background: var(--green); border-radius: 3px;
transition: width 1s linear;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px; margin-bottom: 24px;
}
.stat-card { padding: 18px; }
.stat-label { font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
.stat-value { font-size: 1.7rem; font-weight: 700; }
.stat-sub { font-size: 0.8rem; color: var(--text-secondary); margin-top: 4px; }
.stat-card.green .stat-value { color: var(--green); }
.stat-card.blue .stat-value { color: var(--blue); }
.stat-card.yellow .stat-value { color: var(--yellow); }
.stat-card.red .stat-value { color: var(--red); }
.stat-card.purple .stat-value { color: var(--purple); }
.section-card { margin-bottom: 24px; }
.section-title { font-size: 1.05rem; font-weight: 600; margin-bottom: 16px; }
.stacked-bar-container { margin-bottom: 20px; }
.stacked-bar {
height: 32px; border-radius: 6px; overflow: hidden; display: flex;
background: var(--border);
}
.stacked-segment { height: 100%; transition: width 0.5s ease; min-width: 0; }
.legend {
display: flex; gap: 16px; flex-wrap: wrap; margin-top: 10px;
font-size: 0.85rem; color: var(--text-secondary);
}
.legend-item { display: flex; align-items: center; gap: 6px; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
.toggle-group {
display: flex; gap: 4px; margin-bottom: 16px;
background: var(--border); border-radius: 8px; padding: 3px; width: fit-content;
}
.toggle-btn {
background: transparent; border: none; color: var(--text-secondary);
padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 0.85rem;
transition: all var(--transition);
}
.toggle-btn.active { background: var(--card); color: var(--text); font-weight: 600; }
.chart-area {
display: flex; align-items: flex-end; gap: 2px; height: 120px;
background: var(--chart-bg); border-radius: 8px; padding: 8px; overflow: hidden;
}
.chart-bar {
flex: 1; border-radius: 2px 2px 0 0; transition: height 0.3s ease, background 0.3s ease;
min-width: 3px; position: relative;
}
.chart-bar.ok { background: var(--green); }
.chart-bar.fail { background: var(--red); }
.chart-labels {
display: flex; justify-content: space-between; margin-top: 4px;
font-size: 0.7rem; color: var(--text-secondary);
}
.config-section { margin-bottom: 24px; }
.copy-btn {
background: var(--green); color: #fff; border: none; border-radius: 8px;
padding: 8px 18px; cursor: pointer; font-size: 0.9rem; font-weight: 600;
transition: opacity var(--transition); margin-bottom: 16px;
}
.copy-btn:hover { opacity: 0.85; }
.copy-btn.copied { background: var(--blue); }
.config-table { width: 100%; border-collapse: collapse; }
.config-table td {
padding: 10px 12px; border-bottom: 1px solid var(--border);
font-size: 0.9rem;
}
.config-table tr:last-child td { border-bottom: none; }
.config-table td:first-child {
color: var(--text-secondary); font-weight: 500; width: 140px;
white-space: nowrap;
}
.config-table td:last-child { word-break: break-all; }
.password-cell { display: flex; align-items: center; gap: 8px; }
.password-text { flex: 1; }
.eye-btn {
background: none; border: none; cursor: pointer; font-size: 1.1rem;
opacity: 0.6; transition: opacity var(--transition); padding: 4px;
}
.eye-btn:hover { opacity: 1; }
.history-section { margin-bottom: 24px; }
.timeline { display: flex; flex-direction: column; gap: 6px; max-height: 400px; overflow-y: auto; }
.timeline-entry {
display: flex; align-items: center; gap: 10px; padding: 10px 14px;
border-radius: 8px; background: var(--chart-bg); font-size: 0.88rem;
transition: background var(--transition);
}
.entry-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.entry-dot.ok { background: var(--green); }
.entry-dot.fail { background: var(--red); }
.entry-time { color: var(--text-secondary); font-variant-numeric: tabular-nums; min-width: 60px; }
.entry-msg { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.badge {
padding: 2px 8px; border-radius: 4px; font-size: 0.78rem; font-weight: 600;
white-space: nowrap;
}
.badge.added { background: rgba(0,184,148,0.15); color: var(--green); }
.badge.updated { background: rgba(116,185,255,0.15); color: var(--blue); }
.badge.skipped { background: rgba(139,144,160,0.15); color: var(--text-secondary); }
.badge.error { background: rgba(225,112,85,0.15); color: var(--red); }
.empty-state {
text-align: center; padding: 40px 20px; color: var(--text-secondary);
font-size: 0.95rem;
}
@media (max-width: 768px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.status-main { flex-direction: column; align-items: flex-start; }
.status-meta { flex-direction: column; gap: 4px; }
}
@media (max-width: 480px) {
.stats-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="container">
<header>
<div class="logo">
<span class="status-dot" id="headerDot"></span>
Baikal Sync
</div>
<button class="theme-toggle" id="themeToggle" aria-label="Toggle theme">🌙</button>
</header>
<div class="card status-bar" id="statusBar">
<div class="empty-state">Loading…</div>
</div>
<div class="stats-grid" id="statsGrid">
<div class="card stat-card green"><div class="stat-label">Total Events</div><div class="stat-value" id="statEvents"></div></div>
<div class="card stat-card blue"><div class="stat-label">Total Added</div><div class="stat-value" id="statAdded"></div></div>
<div class="card stat-card purple"><div class="stat-label">Avg Events/Sync</div><div class="stat-value" id="statAvgEvents"></div></div>
<div class="card stat-card blue"><div class="stat-label">Syncs Total</div><div class="stat-value" id="statSyncs"></div><div class="stat-sub" id="statSyncsSub"></div></div>
<div class="card stat-card yellow"><div class="stat-label">Avg Sync Time</div><div class="stat-value" id="statAvgDuration"></div></div>
<div class="card stat-card purple"><div class="stat-label">Avg Latency</div><div class="stat-value" id="statAvgLatency"></div></div>
<div class="card stat-card green"><div class="stat-label">Bandwidth Saved</div><div class="stat-value" id="statBandwidth"></div></div>
<div class="card stat-card" id="backoffCard"><div class="stat-label">Backoff</div><div class="stat-value" id="statBackoff"></div></div>
</div>
<div class="card section-card">
<div class="section-title">Session Breakdown</div>
<div class="stacked-bar-container">
<div class="stacked-bar" id="stackedBar"></div>
<div class="legend" id="stackedLegend"></div>
</div>
<div class="toggle-group" id="chartToggle">
<button class="toggle-btn active" data-metric="duration">Duration</button>
<button class="toggle-btn" data-metric="events">Events</button>
<button class="toggle-btn" data-metric="latency">Latency</button>
</div>
<div class="chart-area" id="chartArea"></div>
<div class="chart-labels" id="chartLabels"></div>
</div>
<div class="card config-section">
<div class="section-title">Configuration</div>
<button class="copy-btn" id="copyBtn">Copy .env</button>
<table class="config-table" id="configTable">
<tbody id="configBody"></tbody>
</table>
</div>
<div class="card history-section">
<div class="section-title">Sync History</div>
<div class="timeline" id="timeline">
<div class="empty-state">No sync history yet.</div>
</div>
</div>
</div>
<script>
(function () {
"use strict";
const state = {
data: null,
chartMetric: "duration",
passwordVisible: false,
pollTimer: null,
countdownTimer: null,
lastPoll: null
};
let countdownRemaining = 0;
const $ = (sel) => document.querySelector(sel);
function fmtBytes(b) {
if (b == null || b === 0) return "0 B";
if (b >= 1048576) return (b / 1048576).toFixed(2) + " MB";
return (b / 1024).toFixed(1) + " KB";
}
function fmtUptime(sec) {
if (sec == null) return "—";
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
return h + "h " + m + "m";
}
function fmtDuration(s) {
if (s == null) return "—";
if (s < 1) return (s * 1000).toFixed(0) + "ms";
return s.toFixed(2) + "s";
}
function fmtTime(iso) {
if (!iso) return "Never";
try {
const d = new Date(iso);
return d.toLocaleTimeString();
} catch { return iso; }
}
function fmtCountdown(sec) {
if (sec == null || sec < 0) return "00:00";
const m = Math.floor(sec / 60);
const s = sec % 60;
return String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0");
}
function truncate(str, max) {
if (!str) return "";
max = max || 60;
return str.length > max ? str.slice(0, 30) + "…" : str;
}
function maskPassword(p) {
if (!p) return "";
return "•".repeat(Math.min(p.length, 20));
}
function getStatusLabel(s) {
switch (s) {
case "syncing": return "Syncing…";
case "error": return "Error";
case "backoff": return "Backing off";
case "idle":
default: return "Up to Date";
}
}
function getSyncStatusClass(s) {
switch (s) {
case "syncing": return "syncing";
case "error": return "error";
case "backoff": return "backoff";
default: return "";
}
}
/* ── Theme ── */
function initTheme() {
const saved = localStorage.getItem("baikal-theme");
const theme = saved || "dark";
document.documentElement.setAttribute("data-theme", theme);
$("#themeToggle").textContent = theme === "dark" ? "☀️" : "🌙";
}
function toggleTheme() {
const current = document.documentElement.getAttribute("data-theme");
const next = current === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
localStorage.setItem("baikal-theme", next);
$("#themeToggle").textContent = next === "dark" ? "☀️" : "🌙";
}
/* ── Render Status Bar ── */
function renderStatusBar(d) {
const cls = getSyncStatusClass(d.status);
$("#headerDot").className = "status-dot" + (cls ? " " + cls : "");
let statusText = "";
if (d.status === "backoff" && d.next_sync_in != null) {
const mins = Math.ceil(d.next_sync_in / 60);
statusText = "Backing off — " + mins + " min remaining";
} else {
statusText = "Sync " + getStatusLabel(d.status);
}
const progressPct = d.next_sync_in != null && d.config && d.config.sync_frequency
? Math.max(0, Math.min(100, ((d.config.sync_frequency * 60 - d.next_sync_in) / (d.config.sync_frequency * 60)) * 100))
: 0;
$("#statusBar").innerHTML =
'<div class="status-main">' +
'<div class="status-text">' + statusText + "</div>" +
'<div class="status-meta">' +
'<span>Last sync: ' + fmtTime(d.last_sync) + "</span>" +
(d.duration != null ? '<span>Duration: ' + fmtDuration(d.duration) + "</span>" : "") +
(d.ics_latency_ms != null ? '<span>Latency: ' + d.ics_latency_ms + "ms</span>" : "") +
'<span>Uptime: ' + fmtUptime(d.session.uptime_sec) + "</span>" +
"</div>" +
"</div>" +
'<div class="countdown-bar">' +
'<div class="countdown-label">' +
'<span>Next sync</span><span id="countdown">' + fmtCountdown(d.next_sync_in) + "</span>" +
"</div>" +
'<div class="progress-track"><div class="progress-fill" id="progressFill" style="width:' + progressPct + '%"></div></div>' +
"</div>";
}
/* ── Render Stats ── */
function renderStats(d) {
const s = d.session;
$("#statEvents").textContent = d.event_count != null ? d.event_count : "—";
$("#statAdded").textContent = s.total_added != null ? s.total_added : "—";
const totalOps = (s.total_added || 0) + (s.total_updated || 0);
const avgEvents = s.syncs_total > 0 ? (totalOps / s.syncs_total).toFixed(2) : "—";
$("#statAvgEvents").textContent = avgEvents;
$("#statSyncs").textContent = s.syncs_total || 0;
const skipPct = s.syncs_total > 0 ? ((s.syncs_skipped || 0) / s.syncs_total * 100).toFixed(0) : "0";
$("#statSyncsSub").textContent = (s.syncs_failed || 0) + " failed · " + skipPct + "% skipped";
$("#statAvgDuration").textContent = fmtDuration(s.avg_duration);
$("#statAvgLatency").textContent = s.avg_latency_ms != null ? s.avg_latency_ms + " ms" : "—";
$("#statBandwidth").textContent = fmtBytes(s.bandwidth_saved_bytes);
const bc = $("#backoffCard");
if (d.status === "backoff") {
bc.className = "card stat-card yellow";
const mins = Math.ceil((d.next_sync_in || 0) / 60);
$("#statBackoff").textContent = mins + " min";
} else {
bc.className = "card stat-card green";
$("#statBackoff").textContent = "None";
}
}
/* ── Render Stacked Bar ── */
function renderStackedBar(d) {
const s = d.session;
const added = s.total_added || 0;
const updated = s.total_updated || 0;
const deleted = s.total_deleted || 0;
const skipped = s.syncs_skipped || 0;
const total = added + updated + deleted + skipped || 1;
const w1 = (added / total * 100).toFixed(2);
const w2 = (updated / total * 100).toFixed(2);
const w3 = (deleted / total * 100).toFixed(2);
const w4 = (skipped / total * 100).toFixed(2);
$("#stackedBar").innerHTML =
'<div class="stacked-segment" style="width:' + w1 + '%;background:var(--green)"></div>' +
'<div class="stacked-segment" style="width:' + w2 + '%;background:var(--blue)"></div>' +
'<div class="stacked-segment" style="width:' + w3 + '%;background:var(--purple)"></div>' +
'<div class="stacked-segment" style="width:' + w4 + '%;background:var(--text-secondary)"></div>';
$("#stackedLegend").innerHTML =
'<div class="legend-item"><span class="legend-dot" style="background:var(--green)"></span>Added: ' + added + "</div>" +
'<div class="legend-item"><span class="legend-dot" style="background:var(--blue)"></span>Updated: ' + updated + "</div>" +
'<div class="legend-item"><span class="legend-dot" style="background:var(--purple)"></span>Deleted: ' + deleted + "</div>" +
'<div class="legend-item"><span class="legend-dot" style="background:var(--text-secondary)"></span>Skipped: ' + skipped + "</div>";
}
/* ── Render Chart ── */
function renderChart(d) {
const history = d.session.history || [];
const metric = state.chartMetric;
const sliced = history.slice(-50);
if (!sliced.length) {
$("#chartArea").innerHTML = "";
$("#chartLabels").innerHTML = "";
return;
}
let key = "";
if (metric === "duration") key = "duration";
else if (metric === "events") {
const vals = sliced.map(function (e) { return (e.added || 0) + (e.updated || 0) + (e.deleted || 0); });
const maxVal = Math.max(1, ...vals);
const bars = sliced.map(function (e, i) {
const v = (e.added || 0) + (e.updated || 0) + (e.deleted || 0);
const h = Math.max(2, (v / maxVal) * 100);
return '<div class="chart-bar ' + (e.ok ? "ok" : "fail") + '" style="height:' + h + '%" title="' + v + ' events"></div>';
}).join("");
$("#chartArea").innerHTML = bars;
const first = sliced[0];
const last = sliced[sliced.length - 1];
$("#chartLabels").innerHTML = '<span>' + (first.time || "") + "</span><span>" + (last.time || "") + "</span>";
return;
} else { key = "latency_ms"; }
const vals = sliced.map(function (e) { return e[key] || 0; });
const maxVal = Math.max(1, ...vals);
const bars = sliced.map(function (e) {
const v = e[key] || 0;
const h = Math.max(2, (v / maxVal) * 100);
const tip = metric === "duration" ? (v * 1000).toFixed(0) + "ms" : v + "ms";
return '<div class="chart-bar ' + (e.ok ? "ok" : "fail") + '" style="height:' + h + '%" title="' + tip + '"></div>';
}).join("");
$("#chartArea").innerHTML = bars;
const first = sliced[0];
const last = sliced[sliced.length - 1];
$("#chartLabels").innerHTML = '<span>' + (first.time || "") + "</span><span>" + (last.time || "") + "</span>";
}
/* ── Render Config ── */
function renderConfig(d) {
const c = d.config || {};
const rows = [
["ICS URL", truncate(c.ics_url || "")],
["Baikal URL", truncate(c.baikal_url || "")],
["User", c.baikal_user || ""],
["Password", "password", c.baikal_pass || ""],
["Frequency", c.sync_frequency ? c.sync_frequency + " minutes" : "—"],
["Calendar ID", c.calendar_id || "—"]
];
let html = "";
rows.forEach(function (r) {
if (r[1] === "password") {
const pw = state.passwordVisible ? r[2] : maskPassword(r[2]);
html += '<tr><td>' + r[0] + "</td><td>" +
'<div class="password-cell">' +
'<span class="password-text">' + pw + "</span>" +
'<button class="eye-btn" id="eyeBtn">' + (state.passwordVisible ? "🙈" : "👁️") + "</button>" +
"</div></td></tr>";
} else {
html += '<tr><td>' + r[0] + "</td><td>" + r[1] + "</td></tr>";
}
});
$("#configBody").innerHTML = html;
if ($("#eyeBtn")) {
$("#eyeBtn").addEventListener("click", function () {
state.passwordVisible = !state.passwordVisible;
renderConfig(state.data);
});
}
}
/* ── Render History ── */
function renderHistory(d) {
const history = d.session.history || [];
const sliced = history.slice(-50).reverse();
if (!sliced.length) {
$("#timeline").innerHTML = '<div class="empty-state">No sync history yet.</div>';
return;
}
let html = "";
sliced.forEach(function (entry) {
const ok = entry.ok !== false;
const badge = getBadge(entry);
html +=
'<div class="timeline-entry">' +
'<span class="entry-dot ' + (ok ? "ok" : "fail") + '"></span>' +
'<span class="entry-time">' + (entry.time || "") + "</span>" +
'<span class="entry-msg">' + escapeHtml(entry.msg || (ok ? "OK" : "Error")) + "</span>" +
badge +
"</div>";
});
$("#timeline").innerHTML = html;
}
function getBadge(e) {
const added = e.added || 0;
const updated = e.updated || 0;
const deleted = e.deleted || 0;
if (!e.ok) return '<span class="badge error">error</span>';
if (e.skipped || (added === 0 && updated === 0 && deleted === 0)) return '<span class="badge skipped">skipped</span>';
const parts = [];
if (added) parts.push("+" + added);
if (updated) parts.push("~" + updated);
if (deleted) parts.push("" + deleted);
return '<span class="badge ' + (added ? "added" : "updated") + '">' + parts.join(" / ") + "</span>";
}
function escapeHtml(str) {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
/* ── Copy .env ── */
function copyEnv() {
const c = state.data.config;
if (!c) return;
const lines = [
"ICS_URL=" + (c.ics_url || ""),
"BAIKAL_URL=" + (c.baikal_url || ""),
"BAIKAL_USER=" + (c.baikal_user || ""),
"BAIKAL_PASS=" + (c.baikal_pass || ""),
"SYNC_FREQUENCY=" + (c.sync_frequency || ""),
"CALENDAR_ID=" + (c.calendar_id || "")
].join("\n");
navigator.clipboard.writeText(lines).then(function () {
const btn = $("#copyBtn");
btn.textContent = "Copied!";
btn.classList.add("copied");
setTimeout(function () {
btn.textContent = "Copy .env";
btn.classList.remove("copied");
}, 2000);
}).catch(function () {
const ta = document.createElement("textarea");
ta.value = lines;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
const btn = $("#copyBtn");
btn.textContent = "Copied!";
btn.classList.add("copied");
setTimeout(function () {
btn.textContent = "Copy .env";
btn.classList.remove("copied");
}, 2000);
});
}
/* ── Polling ── */
function updateCountdown() {
if (!state.data) return;
const el = $("#countdown");
const fill = $("#progressFill");
if (!el || !fill) return;
countdownRemaining = Math.max(0, countdownRemaining - 1);
el.textContent = fmtCountdown(countdownRemaining);
const freq = state.data.config ? state.data.config.sync_frequency * 60 : 600;
const pct = freq > 0 ? Math.max(0, Math.min(100, ((freq - countdownRemaining) / freq) * 100)) : 100;
fill.style.width = pct + "%";
}
async function poll() {
try {
const res = await fetch("/api/status");
if (!res.ok) throw new Error("HTTP " + res.status);
state.data = await res.json();
state.lastPoll = Date.now();
if (state.data.last_sync && state.data.config && state.data.config.sync_frequency) {
const syncEpoch = new Date(state.data.last_sync).getTime();
const freqSec = state.data.config.sync_frequency * 60;
const now = Date.now();
countdownRemaining = Math.max(0, Math.round(((syncEpoch + freqSec * 1000) - now) / 1000));
}
renderStatusBar(state.data);
renderStats(state.data);
renderStackedBar(state.data);
renderChart(state.data);
renderConfig(state.data);
renderHistory(state.data);
} catch (err) {
console.error("Poll error:", err);
}
}
/* ── Init ── */
function init() {
initTheme();
$("#themeToggle").addEventListener("click", toggleTheme);
$("#copyBtn").addEventListener("click", copyEnv);
document.getElementById("chartToggle").addEventListener("click", function (e) {
const btn = e.target.closest(".toggle-btn");
if (!btn) return;
state.chartMetric = btn.getAttribute("data-metric");
document.querySelectorAll("#chartToggle .toggle-btn").forEach(function (b) { b.classList.remove("active"); });
btn.classList.add("active");
if (state.data) renderChart(state.data);
});
poll();
state.pollTimer = setInterval(poll, 10000);
state.countdownTimer = setInterval(updateCountdown, 1000);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
</script>
</body>
</html>
+293 -153
View File
@@ -1,182 +1,322 @@
import os
import signal
import time
import hashlib
import logging
import threading
import requests
import caldav
from caldav.elements import dav, cdav
from datetime import datetime
import os
import time
import concurrent.futures
from datetime import datetime, timezone
# --- CONFIGURACIÓN ---
# Default to 5 minutes
SYNC_FREQUENCY_MINUTES = int(os.getenv("SYNC_FREQUENCY", 5))
SYNC_FREQUENCY_SECONDS = SYNC_FREQUENCY_MINUTES * 60
from config import validate, HEADERS, Config
from state import SyncState
from diff import parse_ics_events, compute_diff, parse_ics_events_with_data
from apply import apply_adds, apply_updates, apply_deletes
from health import HealthServer, SyncSession
from dashboard import DashboardServer
# Tu URL de Outlook
ICS_URL = os.getenv("ICS_URL")
# Tu Baïkal
BAIKAL_URL = os.getenv("BAIKAL_URL")
BAIKAL_USER = os.getenv("BAIKAL_USER")
BAIKAL_PASS = os.getenv("BAIKAL_PASS")
CALENDAR_ID = os.getenv("CALENDAR_ID")
# Headers para parecer un navegador real y evitar 'Connection Reset'
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Unraid-Sync/1.0"
}
logger = logging.getLogger(__name__)
shutdown_event = threading.Event()
def normalize_url(url):
"""Normalize URLs to make matching robust against trailing slash differences."""
return str(url).strip().rstrip("/")
def setup_logging():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
def find_calendar_by_url(calendars, target_url):
"""Find a calendar whose URL matches the configured Baikal calendar URL."""
normalized_target = normalize_url(target_url)
def find_calendar(client, config):
principal = client.principal()
calendars = principal.calendars()
calendar_id = os.environ.get("CALENDAR_ID")
if calendar_id:
for cal in calendars:
if calendar_id in str(cal.url):
return cal
logger.error("Calendar with ID '%s' not found", calendar_id)
for c in calendars:
logger.error(" Available: %s", c.url)
return None
target = config.baikal_url.rstrip("/")
for cal in calendars:
if normalize_url(cal.url) == normalized_target:
if target in str(cal.url) or str(cal.url).rstrip("/") == target:
return cal
if calendars:
return calendars[0]
return None
def delete_event(event):
"""Helper function to delete a single event."""
def sync_once(
state: SyncState,
health: HealthServer,
session: SyncSession,
config: Config,
dashboard: DashboardServer,
) -> bool:
start_time = time.time()
ics_latency_ms = 0
added = 0
updated = 0
deleted = 0
skipped = False
ics_download_size = 0
msg = ""
dashboard.set_syncing(True)
logger.info("Starting sync cycle...")
try:
event.delete()
return True
except Exception as e:
print(f"!!! Error deleting event {event}: {e}")
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:
logger.info("No changes detected (ETag match). Skipping sync.")
duration = time.time() - start_time
skipped = True
msg = "no changes (ETag)"
session.record(True, duration, 0, 0, 0, True, 0, msg)
health.update_status(datetime.now(timezone.utc), duration, True, 0)
dashboard.set_last_sync(datetime.now(timezone.utc), duration, True, 0)
dashboard.set_syncing(False)
return True
dl_start = time.time()
response = requests.get(config.ics_url, headers=HEADERS, timeout=30)
response.raise_for_status()
ics_text = response.text
dl_end = time.time()
ics_latency_ms = round((dl_end - dl_start) * 1000)
ics_download_size = len(ics_text)
ics_hash = hashlib.sha256(ics_text.encode("utf-8")).hexdigest()
if cached_hash == ics_hash:
logger.info("No changes detected (hash match). Skipping sync.")
if remote_etag:
state.set_ics_cache(ics_hash, remote_etag)
duration = time.time() - start_time
skipped = True
msg = "no changes (hash)"
session.record(True, duration, 0, 0, 0, True, ics_latency_ms, msg, ics_download_size)
if ics_download_size:
pass
health.update_status(datetime.now(timezone.utc), duration, True, 0)
dashboard.set_last_sync(datetime.now(timezone.utc), duration, True, ics_latency_ms)
dashboard.set_syncing(False)
return True
state.set_ics_cache(ics_hash, remote_etag)
logger.info("ICS changed. Downloaded %d bytes, hash %s", len(ics_text), ics_hash[:12])
ics_uids = parse_ics_events(ics_text)
known_uids = {}
for uid in state.get_event_uids():
h = state.get_event_hash(uid)
if h:
known_uids[uid] = h
deltas = compute_diff(ics_uids, known_uids)
to_add = deltas["to_add"]
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
skipped = True
msg = "already in sync"
session.record(True, duration, 0, 0, 0, True, ics_latency_ms, msg, ics_download_size)
for uid, h in ics_uids.items():
state.upsert_event(uid, h)
health.update_status(datetime.now(timezone.utc), duration, True, len(ics_uids))
dashboard.set_last_sync(datetime.now(timezone.utc), duration, True, ics_latency_ms)
dashboard.set_event_count(len(ics_uids))
dashboard.set_syncing(False)
return True
if is_first_run and not to_delete:
logger.info(
"First run detected: %d events in ICS. Registering state without re-adding.",
len(to_add),
)
for uid, h in ics_uids.items():
state.upsert_event(uid, h)
duration = time.time() - start_time
msg = f"first run, registered {len(to_add)} events"
session.record(True, duration, len(to_add), 0, 0, False, ics_latency_ms, msg, ics_download_size)
health.update_status(datetime.now(timezone.utc), duration, True, len(ics_uids))
dashboard.set_last_sync(datetime.now(timezone.utc), duration, True, ics_latency_ms)
dashboard.set_event_count(len(ics_uids))
dashboard.set_syncing(False)
return True
logger.info(
"Delta: %d to add, %d to update, %d to delete",
len(to_add),
len(to_update),
len(to_delete),
)
client = caldav.DAVClient(
url=config.baikal_url,
username=config.baikal_user,
password=config.baikal_pass,
headers=HEADERS,
ssl_verify_cert=True,
)
calendar = find_calendar(client, config)
if not calendar:
logger.error("Failed to find calendar")
duration = time.time() - start_time
msg = "calendar not found"
session.record(False, duration, 0, 0, 0, False, ics_latency_ms, msg, ics_download_size)
health.update_status(datetime.now(timezone.utc), duration, False, 0)
dashboard.set_last_sync(datetime.now(timezone.utc), duration, False, ics_latency_ms)
dashboard.set_syncing(False)
return False
snapshot = state.snapshot()
events_data = parse_ics_events_with_data(ics_text)
add_events = {uid: events_data[uid] for uid, _ in to_add if uid in events_data}
update_events = {uid: events_data[uid] for uid, _ in to_update if uid in events_data}
delete_uids = to_delete
try:
logger.info("Phase 1: Adding %d events...", len(add_events))
s_a, e_a = 0, 0
if add_events:
s_a, e_a = apply_adds(calendar, add_events)
logger.info("Added %d/%d events (%d errors)", s_a, len(add_events), e_a)
logger.info("Phase 2: Updating %d events...", len(update_events))
s_u, e_u = 0, 0
if update_events:
s_u, e_u = apply_updates(calendar, update_events)
logger.info("Updated %d/%d events (%d errors)", s_u, len(update_events), e_u)
logger.info("Phase 3: Deleting %d events...", len(delete_uids))
s_d, e_d = 0, 0
if delete_uids:
s_d, e_d = apply_deletes(calendar, delete_uids)
logger.info("Deleted %d/%d events (%d errors)", s_d, len(delete_uids), e_d)
added = s_a
updated = s_u
deleted = s_d
for uid, h in ics_uids.items():
state.upsert_event(uid, h)
for uid in delete_uids:
state.delete_event(uid)
total = len(ics_uids)
duration = time.time() - start_time
msg = f"+{added} / ~{updated} / -{deleted}"
session.record(True, duration, added, updated, deleted, False, ics_latency_ms, msg, ics_download_size)
logger.info("Sync completed in %.1fs. Total events: %d", duration, total)
health.update_status(datetime.now(timezone.utc), duration, True, total)
dashboard.set_last_sync(datetime.now(timezone.utc), duration, True, ics_latency_ms)
dashboard.set_event_count(total)
dashboard.set_syncing(False)
return True
except Exception as exc:
logger.error("Sync failed: %s. Rolling back state.", exc)
state.restore_snapshot(snapshot)
duration = time.time() - start_time
msg = str(exc)[:80]
session.record(False, duration, 0, 0, 0, False, ics_latency_ms, msg, ics_download_size)
health.update_status(datetime.now(timezone.utc), duration, False, 0)
dashboard.set_last_sync(datetime.now(timezone.utc), duration, False, ics_latency_ms)
dashboard.set_syncing(False)
return False
except Exception as exc:
logger.error("Sync error: %s", exc)
duration = time.time() - start_time
msg = str(exc)[:80]
session.record(False, duration, 0, 0, 0, False, ics_latency_ms, msg, ics_download_size)
health.update_status(datetime.now(timezone.utc), duration, False, 0)
dashboard.set_last_sync(datetime.now(timezone.utc), duration, False, ics_latency_ms)
dashboard.set_syncing(False)
return False
def delete_all_events(calendar):
"""
Deletes all events in the calendar as fast as possible using threads.
"""
print("-> Buscando eventos para borrar...")
def main():
setup_logging()
logger.info("Starting Baikal Sync service...")
try:
events = calendar.events()
except Exception as e:
print(f"!!! Error al obtener eventos: {e}")
return
config = validate()
except ValueError as exc:
logger.error("Configuration error: %s", exc)
raise SystemExit(1)
total_events = len(events)
if total_events == 0:
print("-> El calendario ya está vacío.")
return
logger.info("Sync frequency: %d minutes", config.sync_frequency)
state = SyncState("./sync.db")
health = HealthServer(8081)
health.start()
logger.info("Health endpoint on :8081")
print(f"-> Borrando {total_events} eventos rápidamente...")
session = SyncSession()
dashboard = DashboardServer(8082, session)
dashboard.update_config({
"ics_url": config.ics_url,
"baikal_url": config.baikal_url,
"baikal_user": config.baikal_user,
"baikal_pass": config.baikal_pass,
"sync_frequency": config.sync_frequency,
"calendar_id": os.environ.get("CALENDAR_ID", ""),
})
dashboard.start()
logger.info("Dashboard on :8082")
# Usamos ThreadPoolExecutor para borrar en paralelo
deleted_count = 0
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
results = list(executor.map(delete_event, events))
deleted_count = results.count(True)
backoff = 0
print(f"-> Limpieza completada. Borrados {deleted_count}/{total_events} eventos.")
def handle_signal(signum, frame):
logger.info("Received signal %s. Shutting down...", signum)
shutdown_event.set()
def sync():
if not all([ICS_URL, BAIKAL_URL, BAIKAL_USER, BAIKAL_PASS]):
print(f"[{datetime.now()}] !!! Error: Faltan variables de entorno. Asegúrate de configurar ICS_URL, BAIKAL_URL, BAIKAL_USER y BAIKAL_PASS.")
return
signal.signal(signal.SIGTERM, handle_signal)
signal.signal(signal.SIGINT, handle_signal)
print(f"[{datetime.now()}] Iniciando sincronización...")
while not shutdown_event.is_set():
success = sync_once(state, health, session, config, dashboard)
# 1. Descargar ICS de Outlook
print("-> Descargando calendario de Outlook...")
try:
response = requests.get(ICS_URL, headers=HEADERS, timeout=30)
response.raise_for_status()
ics_data = response.text
print(f"-> Descarga exitosa ({len(ics_data)} bytes).")
except Exception as e:
print(f"!!! Error descargando Outlook: {e}")
return
# 2. Conectar a Baïkal
print("-> Conectando a Baïkal...")
try:
client = caldav.DAVClient(
url=BAIKAL_URL,
username=BAIKAL_USER,
password=BAIKAL_PASS,
headers=HEADERS, # Clave para evitar el bloqueo
ssl_verify_cert=True # Cambiar a False si tienes problemas de certificado SSL auto-firmado
)
principal = client.principal()
calendars = principal.calendars()
# Buscar el calendario correcto por ID si se proporciona
calendar = None
if CALENDAR_ID:
print(f"-> Buscando calendario con ID: {CALENDAR_ID}")
for cal in calendars:
# Comprobamos si el ID está en la URL del calendario
if CALENDAR_ID in str(cal.url):
calendar = cal
break
if not calendar:
print(f"!!! Error: No se encontró ningún calendario con el ID '{CALENDAR_ID}'. Calendarios disponibles:")
for c in calendars:
print(f" - {c.url}")
return
if success:
backoff = 0
sleep_time = config.sync_frequency * 60
else:
# Si no hay CALENDAR_ID, seleccionar por BAIKAL_URL
if not calendars:
print("!!! No se encontró ningún calendario en esa URL.")
return
backoff = max(1, min(backoff * 2 if backoff > 0 else 1, 30))
sleep_time = backoff * 60
logger.info("Sync failed. Backing off %d minutes...", backoff)
calendar = find_calendar_by_url(calendars, BAIKAL_URL)
if not calendar:
print("!!! Error: No se encontró un calendario que coincida con BAIKAL_URL.")
print(f" BAIKAL_URL configurado: {BAIKAL_URL}")
print(" Calendarios disponibles:")
for c in calendars:
print(f" - {c.url}")
return
dashboard.set_next_sync_in(sleep_time)
dashboard.set_backoff_min(backoff)
logger.info("Next sync in %d seconds...", sleep_time)
shutdown_event.wait(sleep_time)
print(f"-> Calendario seleccionado: {calendar}")
print(f"-> URL del calendario seleccionado: {calendar.url}")
state.close()
health.stop()
dashboard.stop()
logger.info("Shutdown complete.")
# 3. Borrar eventos antiguos (NUEVO)
delete_all_events(calendar)
# 4. Importar eventos
print("-> Procesando archivo ICS...")
from icalendar import Calendar
cal = Calendar.from_ical(ics_data)
events = cal.walk('vevent')
total_events = len(events)
print(f"-> Encontrados {total_events} eventos para importar.")
success_count = 0
error_count = 0
for i, component in enumerate(events, 1):
try:
# Intentamos pasar el string decodificado
calendar.add_event(component.to_ical().decode('utf-8'))
success_count += 1
except Exception as ev_err:
error_count += 1
# Solo imprimimos los primeros 5 errores para no saturar
if error_count <= 5:
summary = component.get('summary', 'sin titulo')
print(f"!!! Error ({i}/{total_events}) '{summary}': {ev_err}")
# Print progress every 50 events
if i % 50 == 0:
print(f" Procesados {i}/{total_events} (Exitos: {success_count}, Errores: {error_count})")
print(f"-> ¡Sincronización finalizada! Éxitos: {success_count}, Errores: {error_count}")
except Exception as e:
print(f"!!! Error en Baïkal: {e}")
if __name__ == "__main__":
print(f"Iniciando servicio de sincronización. Frecuencia: {SYNC_FREQUENCY_MINUTES} minutos ({SYNC_FREQUENCY_SECONDS} segundos).")
while True:
sync()
print(f"[{datetime.now()}] Durmiendo {SYNC_FREQUENCY_MINUTES} minutos...")
time.sleep(SYNC_FREQUENCY_SECONDS)
main()
+68
View File
@@ -0,0 +1,68 @@
import os
import sys
import pytest
import tempfile
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
@pytest.fixture
def ics_sample():
"""Return a sample ICS string with 2 events."""
return """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test
BEGIN:VEVENT
UID:evt-001@test.com
DTSTAMP:20240101T000000Z
DTSTART:20240101T100000Z
DTEND:20240101T110000Z
SUMMARY:Event One
END:VEVENT
BEGIN:VEVENT
UID:evt-002@test.com
DTSTAMP:20240101T000000Z
DTSTART:20240102T100000Z
DTEND:20240102T110000Z
SUMMARY:Event Two
END:VEVENT
END:VCALENDAR"""
@pytest.fixture
def ics_sample_modified():
"""Return ICS with one modified event and one new event."""
return """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test
BEGIN:VEVENT
UID:evt-001@test.com
DTSTAMP:20240101T000000Z
DTSTART:20240101T100000Z
DTEND:20240101T120000Z
SUMMARY:Event One Modified
END:VEVENT
BEGIN:VEVENT
UID:evt-003@test.com
DTSTAMP:20240101T000000Z
DTSTART:20240103T100000Z
DTEND:20240103T110000Z
SUMMARY:Event Three
END:VEVENT
END:VCALENDAR"""
@pytest.fixture
def tmp_db():
"""Return a temp database path."""
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
path = f.name
yield path
if os.path.exists(path):
os.unlink(path)
@pytest.fixture(autouse=True)
def env_vars(monkeypatch):
"""Set up required env vars for config tests."""
monkeypatch.setenv("ICS_URL", "https://example.com/cal.ics")
monkeypatch.setenv("BAIKAL_URL", "https://baikal.com/dav.php/calendars/user/cal/")
monkeypatch.setenv("BAIKAL_USER", "user")
monkeypatch.setenv("BAIKAL_PASS", "pass")
monkeypatch.setenv("SYNC_FREQUENCY", "5")
+49
View File
@@ -0,0 +1,49 @@
import os
import pytest
from config import validate, HEADERS, Config
class TestValidate:
def test_valid_config(self, env_vars):
cfg = validate()
assert isinstance(cfg, Config)
assert cfg.ics_url == "https://example.com/cal.ics"
assert cfg.baikal_url == "https://baikal.com/dav.php/calendars/user/cal/"
assert cfg.baikal_user == "user"
assert cfg.baikal_pass == "pass"
assert cfg.sync_frequency == 5
def test_default_frequency(self, monkeypatch, env_vars):
monkeypatch.delenv("SYNC_FREQUENCY", raising=False)
cfg = validate()
assert cfg.sync_frequency == 5
def test_missing_ics_url(self, monkeypatch, env_vars):
monkeypatch.delenv("ICS_URL", raising=False)
with pytest.raises(ValueError, match="ICS_URL"):
validate()
def test_missing_all_required(self, monkeypatch, env_vars):
for var in ["ICS_URL", "BAIKAL_URL", "BAIKAL_USER", "BAIKAL_PASS"]:
monkeypatch.delenv(var, raising=False)
with pytest.raises(ValueError):
validate()
def test_invalid_frequency(self, monkeypatch, env_vars):
monkeypatch.setenv("SYNC_FREQUENCY", "-1")
with pytest.raises(ValueError, match="SYNC_FREQUENCY"):
validate()
def test_frequency_zero(self, monkeypatch, env_vars):
monkeypatch.setenv("SYNC_FREQUENCY", "0")
with pytest.raises(ValueError, match="SYNC_FREQUENCY"):
validate()
def test_frequency_string(self, monkeypatch, env_vars):
monkeypatch.setenv("SYNC_FREQUENCY", "abc")
with pytest.raises(ValueError, match="SYNC_FREQUENCY"):
validate()
class TestHeaders:
def test_headers_exist(self):
assert "User-Agent" in HEADERS
assert "Unraid-Sync" in HEADERS["User-Agent"]
+73
View File
@@ -0,0 +1,73 @@
from diff import parse_ics_events, compute_diff, parse_ics_events_with_data
class TestParseIcsEvents:
def test_parse_basic(self, ics_sample):
result = parse_ics_events(ics_sample)
assert "evt-001@test.com" in result
assert "evt-002@test.com" in result
assert len(result) == 2
def test_parse_no_uid(self):
ics = """BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
DTSTAMP:20240101T000000Z
DTSTART:20240101T100000Z
SUMMARY:No UID
END:VEVENT
END:VCALENDAR"""
result = parse_ics_events(ics)
assert len(result) == 0
def test_hashes_are_sha256(self, ics_sample):
result = parse_ics_events(ics_sample)
for uid, h in result.items():
assert len(h) == 64
int(h, 16)
def test_same_event_same_hash(self, ics_sample):
r1 = parse_ics_events(ics_sample)
r2 = parse_ics_events(ics_sample)
for uid in r1:
assert r1[uid] == r2[uid]
def test_modified_event_different_hash(self, ics_sample, ics_sample_modified):
r1 = parse_ics_events(ics_sample)
r2 = parse_ics_events(ics_sample_modified)
assert r1["evt-001@test.com"] != r2["evt-001@test.com"]
class TestComputeDiff:
def test_no_changes(self, ics_sample):
ics_uids = parse_ics_events(ics_sample)
deltas = compute_diff(ics_uids, ics_uids)
assert len(deltas["to_add"]) == 0
assert len(deltas["to_update"]) == 0
assert len(deltas["to_delete"]) == 0
def test_add_events(self, ics_sample, ics_sample_modified):
original = parse_ics_events(ics_sample)
modified = parse_ics_events(ics_sample_modified)
deltas = compute_diff(modified, original)
assert ("evt-003@test.com", modified["evt-003@test.com"]) in deltas["to_add"]
def test_delete_events(self, ics_sample, ics_sample_modified):
original = parse_ics_events(ics_sample)
modified = parse_ics_events(ics_sample_modified)
deltas = compute_diff(modified, original)
assert "evt-002@test.com" in deltas["to_delete"]
def test_update_events(self, ics_sample, ics_sample_modified):
original = parse_ics_events(ics_sample)
modified = parse_ics_events(ics_sample_modified)
deltas = compute_diff(modified, original)
uid, new_hash = deltas["to_update"][0]
assert uid == "evt-001@test.com"
assert new_hash == modified["evt-001@test.com"]
class TestParseIcsEventsWithData:
def test_returns_bytes(self, ics_sample):
result = parse_ics_events_with_data(ics_sample)
assert len(result) == 2
for uid, data in result.items():
assert isinstance(data, bytes)
assert b"VEVENT" in data
+71
View File
@@ -0,0 +1,71 @@
from state import SyncState
class TestSyncState:
def test_create_tables(self, tmp_db):
state = SyncState(tmp_db)
assert True
state.close()
def test_upsert_and_get(self, tmp_db):
state = SyncState(tmp_db)
state.upsert_event("uid-1", "hash-abc")
assert state.get_event_hash("uid-1") == "hash-abc"
state.close()
def test_get_uids(self, tmp_db):
state = SyncState(tmp_db)
state.upsert_event("uid-1", "h1")
state.upsert_event("uid-2", "h2")
uids = state.get_event_uids()
assert "uid-1" in uids
assert "uid-2" in uids
assert len(uids) == 2
state.close()
def test_delete_event(self, tmp_db):
state = SyncState(tmp_db)
state.upsert_event("uid-1", "h1")
state.delete_event("uid-1")
assert state.get_event_hash("uid-1") is None
state.close()
def test_clear_events(self, tmp_db):
state = SyncState(tmp_db)
state.upsert_event("uid-1", "h1")
state.upsert_event("uid-2", "h2")
count = state.clear_events()
assert count == 2
assert len(state.get_event_uids()) == 0
state.close()
def test_ics_cache(self, tmp_db):
state = SyncState(tmp_db)
state.set_ics_cache("hash-xyz", "etag-123")
h, e, _ = state.get_ics_cache()
assert h == "hash-xyz"
assert e == "etag-123"
state.clear_ics_cache()
h, e, _ = state.get_ics_cache()
assert h is None
assert e is None
state.close()
def test_snapshot_and_restore(self, tmp_db):
state = SyncState(tmp_db)
state.upsert_event("uid-1", "h1")
state.upsert_event("uid-2", "h2")
snap = state.snapshot()
assert "uid-1" in snap["uids"]
state.clear_events()
assert len(state.get_event_uids()) == 0
state.restore_snapshot(snap)
assert len(state.get_event_uids()) == 2
assert state.get_event_hash("uid-1") == "h1"
state.close()
def test_empty_state(self, tmp_db):
state = SyncState(tmp_db)
assert len(state.get_event_uids()) == 0
h, e, _ = state.get_ics_cache()
assert h is None
state.close()