Feat/incremental sync #1
@@ -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/
|
||||||
|
|||||||
+5
-2
@@ -7,6 +7,9 @@ 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"]
|
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"]
|
||||||
|
|||||||
+8
-4
@@ -8,7 +8,11 @@ services:
|
|||||||
- 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ class SyncState:
|
|||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
return self
|
|
||||||
|
|
||||||
def get_event_uids(self) -> set[str]:
|
def get_event_uids(self) -> set[str]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|||||||
@@ -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