Compare commits
8 Commits
a4e86e837c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 201d928247 | |||
| 2c05502553 | |||
| 7a388653ed | |||
| 20247c92c2 | |||
| d628f03a82 | |||
| 0f390ff1e1 | |||
| 64d078f457 | |||
| ae1cbe27a4 |
@@ -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
@@ -9,3 +9,13 @@ wheels/
|
|||||||
# Virtual environments
|
# Virtual environments
|
||||||
.venv
|
.venv
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Sync state database
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# Playwright MCP artifacts
|
||||||
|
.playwright-mcp/
|
||||||
|
*.png
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
.pytest_cache/
|
||||||
|
|||||||
+7
-2
@@ -7,6 +7,11 @@ WORKDIR /app
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r 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"]
|
||||||
|
|||||||
@@ -1,61 +1,75 @@
|
|||||||
# Baïkal Calendar Sync
|
# 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
|
## Features
|
||||||
- **Automatic Sync**: Runs periodically (default: every 5 minutes).
|
|
||||||
- **Environment Configurable**: All credentials and URLs are set via environment variables.
|
- **Incremental Delta Sync**: Only adds, updates, or deletes events that actually changed
|
||||||
- **Docker Ready**: Includes `Dockerfile` and `docker-compose.yml` for easy deployment.
|
- **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
|
## Prerequisites
|
||||||
- Docker & Docker Compose installed.
|
|
||||||
- An existing Baïkal instance.
|
- Docker & Docker Compose installed
|
||||||
- An Outlook ICS link.
|
- An existing Baïkal instance
|
||||||
|
- An Outlook ICS link
|
||||||
|
|
||||||
## Configuration (Environment Variables)
|
## Configuration (Environment Variables)
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
| :--- | :--- | :--- |
|
| :--- | :--- | :--- |
|
||||||
| `ICS_URL` | The public ICS URL of your Outlook calendar. | **Required** |
|
| `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_USER` | Your Baïkal username. | **Required** |
|
||||||
| `BAIKAL_PASS` | Your Baïkal password. | **Required** |
|
| `BAIKAL_PASS` | Your Baïkal password. | **Required** |
|
||||||
| `SYNC_FREQUENCY` | How often to sync **in minutes**. | `5` |
|
| `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.
|
1. **Clone or download** this repository.
|
||||||
2. **Create a `.env` file** (optional but recommended) or export variables:
|
2. **Create a `.env` file** (optional but recommended):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ICS_URL="https://outlook.office365.com/..."
|
ICS_URL="https://outlook.office365.com/owa/calendar/your-calendar.ics"
|
||||||
BAIKAL_URL="http://localhost:8080/dav.php/calendars/Lago/default/"
|
BAIKAL_URL="https://your-baikal.com/dav.php/calendars/user/calendar-id/"
|
||||||
BAIKAL_USER="Lago"
|
BAIKAL_USER="your-username"
|
||||||
BAIKAL_PASS="secret"
|
BAIKAL_PASS="your-password"
|
||||||
SYNC_FREQUENCY=5
|
SYNC_FREQUENCY=5
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Run with Docker Compose**:
|
3. **Run with Docker Compose**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
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
|
```bash
|
||||||
docker compose logs -f baikal-sync
|
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
|
```bash
|
||||||
docker build -t lagortinez/baikal-sync:latest .
|
docker build -t lagortinez/baikal-sync:latest .
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Push** (requires `docker login`):
|
2. **Push**:
|
||||||
```bash
|
```bash
|
||||||
docker push lagortinez/baikal-sync:latest
|
docker push lagortinez/baikal-sync:latest
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
@@ -3,12 +3,18 @@ services:
|
|||||||
image: lagortinez/baikal-sync:latest
|
image: lagortinez/baikal-sync:latest
|
||||||
container_name: baikal-sync
|
container_name: baikal-sync
|
||||||
restart: always
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8082:8082"
|
||||||
environment:
|
environment:
|
||||||
- ICS_URL=${ICS_URL}
|
- ICS_URL=${ICS_URL}
|
||||||
- BAIKAL_URL=${BAIKAL_URL}
|
- BAIKAL_URL=${BAIKAL_URL}
|
||||||
- BAIKAL_USER=${BAIKAL_USER}
|
- BAIKAL_USER=${BAIKAL_USER}
|
||||||
- BAIKAL_PASS=${BAIKAL_PASS}
|
- BAIKAL_PASS=${BAIKAL_PASS}
|
||||||
- SYNC_FREQUENCY=5 # Minutes
|
- SYNC_FREQUENCY=${SYNC_FREQUENCY:-5}
|
||||||
# If your Baikal is on the same host, you might need network_mode: "host"
|
- CALENDAR_ID=${CALENDAR_ID:-}
|
||||||
# or ensure they share a network.
|
healthcheck:
|
||||||
# network_mode: "host"
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8081/health')"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
|
|||||||
@@ -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,6 +1,4 @@
|
|||||||
def main():
|
from sync_calendar import main
|
||||||
print("Hello from ics-to-caldav!")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
+10
-2
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "ics-to-caldav"
|
name = "ics-to-caldav"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
description = "Add your description here"
|
description = "Incremental CalDAV sync from Outlook ICS to Baikal"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@@ -9,3 +9,11 @@ dependencies = [
|
|||||||
"icalendar>=6.3.2",
|
"icalendar>=6.3.2",
|
||||||
"requests>=2.32.5",
|
"requests>=2.32.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
test = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|||||||
+4
-3
@@ -1,3 +1,4 @@
|
|||||||
caldav>=1.0.0
|
caldav>=2.2.3
|
||||||
icalendar>=5.0.0
|
icalendar>=6.3.2
|
||||||
requests>=2.20.0
|
requests>=2.32.5
|
||||||
|
pytest>=8.0
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
@@ -1,182 +1,322 @@
|
|||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
import requests
|
import requests
|
||||||
import caldav
|
import caldav
|
||||||
from caldav.elements import dav, cdav
|
from datetime import datetime, timezone
|
||||||
from datetime import datetime
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
import concurrent.futures
|
|
||||||
|
|
||||||
# --- CONFIGURACIÓN ---
|
from config import validate, HEADERS, Config
|
||||||
# Default to 5 minutes
|
from state import SyncState
|
||||||
SYNC_FREQUENCY_MINUTES = int(os.getenv("SYNC_FREQUENCY", 5))
|
from diff import parse_ics_events, compute_diff, parse_ics_events_with_data
|
||||||
SYNC_FREQUENCY_SECONDS = SYNC_FREQUENCY_MINUTES * 60
|
from apply import apply_adds, apply_updates, apply_deletes
|
||||||
|
from health import HealthServer, SyncSession
|
||||||
|
from dashboard import DashboardServer
|
||||||
|
|
||||||
# Tu URL de Outlook
|
logger = logging.getLogger(__name__)
|
||||||
ICS_URL = os.getenv("ICS_URL")
|
shutdown_event = threading.Event()
|
||||||
|
|
||||||
# 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"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_url(url):
|
def setup_logging():
|
||||||
"""Normalize URLs to make matching robust against trailing slash differences."""
|
logging.basicConfig(
|
||||||
return str(url).strip().rstrip("/")
|
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):
|
def find_calendar(client, config):
|
||||||
"""Find a calendar whose URL matches the configured Baikal calendar URL."""
|
principal = client.principal()
|
||||||
normalized_target = normalize_url(target_url)
|
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:
|
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
|
return cal
|
||||||
|
|
||||||
|
if calendars:
|
||||||
|
return calendars[0]
|
||||||
|
|
||||||
return None
|
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:
|
try:
|
||||||
event.delete()
|
remote_etag = None
|
||||||
return True
|
try:
|
||||||
except Exception as e:
|
r = requests.head(config.ics_url, headers=HEADERS, timeout=15, allow_redirects=True)
|
||||||
print(f"!!! Error deleting event {event}: {e}")
|
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
|
return False
|
||||||
|
|
||||||
def delete_all_events(calendar):
|
|
||||||
"""
|
def main():
|
||||||
Deletes all events in the calendar as fast as possible using threads.
|
setup_logging()
|
||||||
"""
|
logger.info("Starting Baikal Sync service...")
|
||||||
print("-> Buscando eventos para borrar...")
|
|
||||||
try:
|
try:
|
||||||
events = calendar.events()
|
config = validate()
|
||||||
except Exception as e:
|
except ValueError as exc:
|
||||||
print(f"!!! Error al obtener eventos: {e}")
|
logger.error("Configuration error: %s", exc)
|
||||||
return
|
raise SystemExit(1)
|
||||||
|
|
||||||
total_events = len(events)
|
logger.info("Sync frequency: %d minutes", config.sync_frequency)
|
||||||
if total_events == 0:
|
state = SyncState("./sync.db")
|
||||||
print("-> El calendario ya está vacío.")
|
health = HealthServer(8081)
|
||||||
return
|
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
|
backoff = 0
|
||||||
deleted_count = 0
|
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
|
|
||||||
results = list(executor.map(delete_event, events))
|
|
||||||
deleted_count = results.count(True)
|
|
||||||
|
|
||||||
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():
|
signal.signal(signal.SIGTERM, handle_signal)
|
||||||
if not all([ICS_URL, BAIKAL_URL, BAIKAL_USER, BAIKAL_PASS]):
|
signal.signal(signal.SIGINT, handle_signal)
|
||||||
print(f"[{datetime.now()}] !!! Error: Faltan variables de entorno. Asegúrate de configurar ICS_URL, BAIKAL_URL, BAIKAL_USER y BAIKAL_PASS.")
|
|
||||||
return
|
|
||||||
|
|
||||||
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
|
if success:
|
||||||
print("-> Descargando calendario de Outlook...")
|
backoff = 0
|
||||||
try:
|
sleep_time = config.sync_frequency * 60
|
||||||
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
|
|
||||||
else:
|
else:
|
||||||
# Si no hay CALENDAR_ID, seleccionar por BAIKAL_URL
|
backoff = max(1, min(backoff * 2 if backoff > 0 else 1, 30))
|
||||||
if not calendars:
|
sleep_time = backoff * 60
|
||||||
print("!!! No se encontró ningún calendario en esa URL.")
|
logger.info("Sync failed. Backing off %d minutes...", backoff)
|
||||||
return
|
|
||||||
|
|
||||||
calendar = find_calendar_by_url(calendars, BAIKAL_URL)
|
dashboard.set_next_sync_in(sleep_time)
|
||||||
if not calendar:
|
dashboard.set_backoff_min(backoff)
|
||||||
print("!!! Error: No se encontró un calendario que coincida con BAIKAL_URL.")
|
logger.info("Next sync in %d seconds...", sleep_time)
|
||||||
print(f" BAIKAL_URL configurado: {BAIKAL_URL}")
|
shutdown_event.wait(sleep_time)
|
||||||
print(" Calendarios disponibles:")
|
|
||||||
for c in calendars:
|
|
||||||
print(f" - {c.url}")
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f"-> Calendario seleccionado: {calendar}")
|
state.close()
|
||||||
print(f"-> URL del calendario seleccionado: {calendar.url}")
|
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__":
|
if __name__ == "__main__":
|
||||||
print(f"Iniciando servicio de sincronización. Frecuencia: {SYNC_FREQUENCY_MINUTES} minutos ({SYNC_FREQUENCY_SECONDS} segundos).")
|
main()
|
||||||
while True:
|
|
||||||
sync()
|
|
||||||
print(f"[{datetime.now()}] Durmiendo {SYNC_FREQUENCY_MINUTES} minutos...")
|
|
||||||
time.sleep(SYNC_FREQUENCY_SECONDS)
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user