radicale-ics-sync 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """radicale-ics-sync: Subscribe to ICS feeds with filtering and local edit support."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,318 @@
1
+ """
2
+ Radicale ICS Sync - Storage Plugin
3
+
4
+ Wraps Radicale's multifilesystem storage and syncs upstream ICS feeds
5
+ into Radicale collections with event filtering.
6
+ Upstream changes are detected via event content hashes.
7
+ Newer upstream changes overwrite local changes.
8
+
9
+ Configuration is read from the path set by ics_config in [storage]
10
+ (default: /config/ics_sync.json).
11
+ """
12
+
13
+ import json
14
+ import os
15
+ import re
16
+ from typing import Dict, List
17
+
18
+ import radicale.item as radicale_item
19
+ from radicale.log import logger
20
+ from radicale.storage import BaseStorage, ComponentNotFoundError
21
+ from radicale.storage.multifilesystem import Storage as MultiFileSystemStorage
22
+
23
+ from .upstream import SyncJob
24
+
25
+ _DEFAULT_INTERVAL = 3600
26
+ _DEFAULT_CONFIG_PATH = "/config/ics_sync.json"
27
+ _BATCH_SIZE = 20
28
+
29
+ PLUGIN_CONFIG_SCHEMA = {
30
+ "storage": {
31
+ "ics_config": {
32
+ "value": _DEFAULT_CONFIG_PATH,
33
+ "help": "path to the ics_sync.json configuration file",
34
+ "type": str,
35
+ },
36
+ }
37
+ }
38
+
39
+
40
+ def _load_hashes(path: str) -> Dict[str, Dict[str, Dict[str, str]]]:
41
+ """Load persisted upstream hashes from disk.
42
+
43
+ Returns {collection_path: {feed_url: {uid: hash}}}.
44
+ """
45
+ try:
46
+ with open(path, "r", encoding="utf-8") as f:
47
+ data = json.load(f)
48
+ total = sum(
49
+ len(uids)
50
+ for feed_dicts in data.values()
51
+ for uids in feed_dicts.values()
52
+ )
53
+ logger.info(
54
+ "radicale-ics-sync: loaded %d upstream hashes across %d collections from %s",
55
+ total,
56
+ len(data),
57
+ path,
58
+ )
59
+ return data
60
+ except FileNotFoundError:
61
+ logger.info("radicale-ics-sync: no hash db found at %s, starting fresh", path)
62
+ return {}
63
+ except Exception as e:
64
+ logger.warning(
65
+ "radicale-ics-sync: failed to load hash db: %s, starting fresh", e
66
+ )
67
+ return {}
68
+
69
+
70
+ def _save_hashes(hashes: Dict[str, Dict[str, Dict[str, str]]], path: str) -> None:
71
+ """Persist upstream hashes to disk."""
72
+ try:
73
+ with open(path, "w", encoding="utf-8") as f:
74
+ json.dump(hashes, f, indent=2)
75
+ except Exception as e:
76
+ logger.warning("radicale-ics-sync: failed to save hash db: %s", e)
77
+
78
+
79
+ def _load_sync_config(config_path: str) -> List[dict]:
80
+ """Load the configuration file."""
81
+ try:
82
+ with open(config_path, "r", encoding="utf-8") as f:
83
+ data = json.load(f)
84
+ if not isinstance(data, list):
85
+ logger.error("radicale-ics-sync: %s must be a JSON array", config_path)
86
+ return []
87
+ logger.info(
88
+ "radicale-ics-sync: loaded %d sync job(s) from %s",
89
+ len(data),
90
+ config_path,
91
+ )
92
+ return data
93
+ except FileNotFoundError:
94
+ logger.info(
95
+ "radicale-ics-sync: no config found at %s, running in passthrough mode",
96
+ config_path,
97
+ )
98
+ return []
99
+ except Exception as e:
100
+ logger.error("radicale-ics-sync: failed to load config %s: %s", config_path, e)
101
+ return []
102
+
103
+
104
+ def _compile_patterns(patterns: List[str]) -> List[re.Pattern]:
105
+ """Compile a list of regex pattern strings, case-insensitive.
106
+
107
+ Invalid patterns are skipped with a warning.
108
+ """
109
+ compiled = []
110
+ for pattern in patterns:
111
+ try:
112
+ compiled.append(re.compile(pattern, re.IGNORECASE))
113
+ except re.error as e:
114
+ logger.warning("radicale-ics-sync: invalid pattern %r: %s", pattern, e)
115
+ return compiled
116
+
117
+
118
+ class Storage(BaseStorage):
119
+ """ICS Sync storage plugin.
120
+
121
+ Delegates all standard CalDAV operations to Radicale's built-in
122
+ multifilesystem storage, while adding upstream ICS feed syncing on top.
123
+ """
124
+
125
+ def __init__(self, configuration):
126
+ super().__init__(configuration.copy(PLUGIN_CONFIG_SCHEMA))
127
+ self._delegate = MultiFileSystemStorage(configuration)
128
+ self._sync_jobs: List[SyncJob] = []
129
+ filesystem_folder = configuration.get("storage", "filesystem_folder")
130
+ self._hash_db_path = os.path.join(
131
+ os.path.dirname(os.path.normpath(filesystem_folder)),
132
+ "ics_sync_hashes.json",
133
+ )
134
+ self._upstream_hashes: Dict[str, Dict[str, Dict[str, str]]] = _load_hashes(
135
+ self._hash_db_path
136
+ )
137
+ self._setup_sync_jobs(self.configuration)
138
+ logger.info("radicale-ics-sync: storage plugin loaded")
139
+
140
+ def _setup_sync_jobs(self, configuration) -> None:
141
+ """Read sync jobs from the config file and start a thread for each."""
142
+ config_path = configuration.get("storage", "ics_config")
143
+ sync_jobs = _load_sync_config(config_path)
144
+ if not sync_jobs:
145
+ return
146
+
147
+ for job in sync_jobs:
148
+ feed_url = job.get("feed")
149
+ collection_path = job.get("collection")
150
+
151
+ if not feed_url:
152
+ logger.error("radicale-ics-sync: sync job missing 'feed': %s", job)
153
+ continue
154
+ if not collection_path:
155
+ logger.error(
156
+ "radicale-ics-sync: sync job missing 'collection': %s", job
157
+ )
158
+ continue
159
+
160
+ interval = job.get("sync_interval", _DEFAULT_INTERVAL)
161
+ include_patterns = _compile_patterns(job.get("include_patterns", []))
162
+ exclude_patterns = _compile_patterns(job.get("exclude_patterns", []))
163
+
164
+ if include_patterns:
165
+ logger.info(
166
+ "radicale-ics-sync: [%s] include patterns: %s",
167
+ collection_path,
168
+ [p.pattern for p in include_patterns],
169
+ )
170
+ if exclude_patterns:
171
+ logger.info(
172
+ "radicale-ics-sync: [%s] exclude patterns: %s",
173
+ collection_path,
174
+ [p.pattern for p in exclude_patterns],
175
+ )
176
+
177
+ sync_job = SyncJob(
178
+ url=feed_url,
179
+ interval_seconds=interval,
180
+ on_update=lambda events, hashes, p=collection_path, u=feed_url: (
181
+ self._sync_events(p, u, events, hashes)
182
+ ),
183
+ include_patterns=include_patterns,
184
+ exclude_patterns=exclude_patterns,
185
+ )
186
+ sync_job.start()
187
+ self._sync_jobs.append(sync_job)
188
+ logger.info(
189
+ "radicale-ics-sync: registered sync job: %s → %s (every %ds)",
190
+ feed_url,
191
+ collection_path,
192
+ interval,
193
+ )
194
+
195
+ def _sync_events(
196
+ self,
197
+ collection_path: str,
198
+ feed_url: str,
199
+ events: Dict[str, str],
200
+ hashes: Dict[str, str],
201
+ ) -> None:
202
+ """Sync upstream events to Radicale for a specific feed + collection.
203
+
204
+ Deletes and writes are processed in batches of _BATCH_SIZE so that the
205
+ write lock is released between batches, allowing Radicale to serve
206
+ CalDAV requests in the gaps.
207
+ """
208
+ path = "/" + collection_path.strip("/")
209
+
210
+ # Phase 1: verify the collection exists and compute both work lists.
211
+ with self._delegate.acquire_lock("w"):
212
+ collections = list(self._delegate.discover(path, depth="0"))
213
+ if not collections:
214
+ logger.warning(
215
+ "radicale-ics-sync: collection %r not found, "
216
+ "skipping sync (create it in the Radicale web UI first)",
217
+ collection_path,
218
+ )
219
+ return
220
+
221
+ collection_feeds = self._upstream_hashes.setdefault(collection_path, {})
222
+ feed_hashes = collection_feeds.setdefault(feed_url, {})
223
+
224
+ to_delete = list(set(feed_hashes.keys()) - set(events.keys()))
225
+ to_write = [
226
+ (uid, ics_text)
227
+ for uid, ics_text in events.items()
228
+ if feed_hashes.get(uid) != hashes[uid]
229
+ ]
230
+ skipped = len(events) - len(to_write)
231
+
232
+ # Phase 2: delete stale events in batches.
233
+ deleted = 0
234
+ for i in range(0, len(to_delete), _BATCH_SIZE):
235
+ batch = to_delete[i : i + _BATCH_SIZE]
236
+ with self._delegate.acquire_lock("w"):
237
+ collections = list(self._delegate.discover(path, depth="0"))
238
+ if not collections:
239
+ logger.warning(
240
+ "radicale-ics-sync: collection %r disappeared during sync, "
241
+ "aborting",
242
+ collection_path,
243
+ )
244
+ return
245
+ collection = collections[0]
246
+
247
+ for uid in batch:
248
+ href = uid + ".ics"
249
+ try:
250
+ collection.delete(href)
251
+ del feed_hashes[uid]
252
+ deleted += 1
253
+ logger.debug("radicale-ics-sync: deleted event %r", uid)
254
+ except ComponentNotFoundError:
255
+ del feed_hashes[uid]
256
+ logger.debug(
257
+ "radicale-ics-sync: event %r already gone, skipping", uid
258
+ )
259
+ except Exception as e:
260
+ logger.warning(
261
+ "radicale-ics-sync: failed to delete event %r: %s", uid, e
262
+ )
263
+
264
+ _save_hashes(self._upstream_hashes, self._hash_db_path)
265
+
266
+ # Phase 3: write new/changed events in batches.
267
+ written = 0
268
+ for i in range(0, len(to_write), _BATCH_SIZE):
269
+ batch = to_write[i : i + _BATCH_SIZE]
270
+ with self._delegate.acquire_lock("w"):
271
+ collections = list(self._delegate.discover(path, depth="0"))
272
+ if not collections:
273
+ logger.warning(
274
+ "radicale-ics-sync: collection %r disappeared during sync, "
275
+ "aborting",
276
+ collection_path,
277
+ )
278
+ return
279
+ collection = collections[0]
280
+
281
+ for uid, ics_text in batch:
282
+ href = uid + ".ics"
283
+ try:
284
+ item = radicale_item.Item(collection=collection, text=ics_text)
285
+ collection.upload(href, item)
286
+ feed_hashes[uid] = hashes[uid]
287
+ written += 1
288
+ except Exception as e:
289
+ logger.warning(
290
+ "radicale-ics-sync: failed to write event %r: %s", uid, e
291
+ )
292
+
293
+ _save_hashes(self._upstream_hashes, self._hash_db_path)
294
+
295
+ logger.info(
296
+ "radicale-ics-sync: [%s] wrote %d, deleted %d, skipped %d unchanged",
297
+ collection_path,
298
+ written,
299
+ deleted,
300
+ skipped,
301
+ )
302
+
303
+ def discover(self, path, depth="0", child_context_manager=None, user_groups=None):
304
+ return self._delegate.discover(
305
+ path, depth, child_context_manager, user_groups or set()
306
+ )
307
+
308
+ def move(self, item, to_collection, to_href):
309
+ return self._delegate.move(item, to_collection, to_href)
310
+
311
+ def create_collection(self, href, items=None, props=None):
312
+ return self._delegate.create_collection(href, items, props)
313
+
314
+ def acquire_lock(self, mode, user="", *args, **kwargs):
315
+ return self._delegate.acquire_lock(mode, user, *args, **kwargs)
316
+
317
+ def verify(self):
318
+ return self._delegate.verify()
@@ -0,0 +1,195 @@
1
+ """
2
+ Upstream ICS feed syncing and filtering.
3
+
4
+ Fetches remote ICS feeds, filters events by patterns,
5
+ and detects changes via per-event content hashes.
6
+ Polling runs in a background thread at a configurable interval.
7
+ """
8
+
9
+ import copy
10
+ import hashlib
11
+ import json
12
+ import re
13
+ import threading
14
+ from typing import Callable, Dict, List, Optional, Tuple
15
+ from urllib.error import URLError
16
+ from urllib.request import Request, urlopen
17
+
18
+ import vobject
19
+ from radicale.log import logger
20
+ from radicale_ics_sync import __version__
21
+
22
+ # Fields that determine if an event has meaningfully changed.
23
+ _RELEVANT_FIELDS = ("SUMMARY", "DTSTART", "DTEND", "LOCATION", "DESCRIPTION")
24
+
25
+
26
+ def _fetch_raw(url: str) -> Optional[str]:
27
+ """Fetch raw text from a URL. Returns None on failure."""
28
+ try:
29
+ req = Request(url, headers={"User-Agent": f"radicale-ics-sync/{__version__}"})
30
+ with urlopen(req, timeout=30) as response:
31
+ charset = response.headers.get_content_charset("utf-8")
32
+ return response.read().decode(charset)
33
+ except URLError as e:
34
+ logger.warning("radicale-ics-sync: failed to fetch %r: %s", url, e)
35
+ return None
36
+ except Exception as e:
37
+ logger.warning("radicale-ics-sync: unexpected error fetching %r: %s", url, e)
38
+ return None
39
+
40
+
41
+ def _event_content_hash(component: vobject.base.Component) -> str:
42
+ """Compute a stable hash of an event's meaningful fields."""
43
+ fields = {}
44
+ for field in _RELEVANT_FIELDS:
45
+ field_list = component.contents.get(field.lower(), [])
46
+ fields[field] = str(field_list[0].value) if field_list else ""
47
+ stable = json.dumps(fields, sort_keys=True)
48
+ return hashlib.sha256(stable.encode()).hexdigest()
49
+
50
+
51
+ def _matches_any(summary: str, patterns: List[re.Pattern]) -> bool:
52
+ """Return True if summary matches any of the compiled patterns."""
53
+ return any(p.search(summary) for p in patterns)
54
+
55
+
56
+ def _filter_events(
57
+ components: List[vobject.base.Component],
58
+ include_patterns: List[re.Pattern],
59
+ exclude_patterns: List[re.Pattern],
60
+ ) -> List[vobject.base.Component]:
61
+ """Filter VEVENT components by include and exclude patterns on SUMMARY."""
62
+ result = []
63
+ for component in components:
64
+ try:
65
+ summary = component.summary.value
66
+ except AttributeError:
67
+ summary = ""
68
+
69
+ if include_patterns and not _matches_any(summary, include_patterns):
70
+ continue
71
+ if exclude_patterns and _matches_any(summary, exclude_patterns):
72
+ continue
73
+ result.append(component)
74
+ return result
75
+
76
+
77
+ def _parse_events(
78
+ ics_text: str,
79
+ include_patterns: Optional[List[re.Pattern]] = None,
80
+ exclude_patterns: Optional[List[re.Pattern]] = None,
81
+ ) -> Optional[Tuple[Dict[str, str], Dict[str, str]]]:
82
+ """Parse the content of an ICS file into per-event ICS text and per-event content hashes.
83
+ Applies filtering before hashing.
84
+
85
+ Returns None on parse failure.
86
+ Returns {uid: ics_text}, {uid: content_hash} on success.
87
+ """
88
+ events: Dict[str, str] = {}
89
+ hashes: Dict[str, str] = {}
90
+ try:
91
+ calendar = vobject.readOne(ics_text)
92
+ except Exception as e:
93
+ logger.error("radicale-ics-sync: failed to parse ICS: %s", e)
94
+ return None
95
+
96
+ all_components = list(calendar.components())
97
+ timezones = [c for c in all_components if c.name == "VTIMEZONE"]
98
+ components = [c for c in all_components if c.name == "VEVENT"]
99
+
100
+ # Apply filtering
101
+ filtered = _filter_events(
102
+ components,
103
+ include_patterns or [],
104
+ exclude_patterns or [],
105
+ )
106
+
107
+ if include_patterns or exclude_patterns:
108
+ logger.info(
109
+ "radicale-ics-sync: %d/%d events passed filter",
110
+ len(filtered),
111
+ len(components),
112
+ )
113
+
114
+ for component in filtered:
115
+ try:
116
+ uid = component.uid.value
117
+ except AttributeError:
118
+ uid = hashlib.sha256(component.serialize().encode()).hexdigest()
119
+ component.add("uid").value = uid
120
+
121
+ wrapper = vobject.iCalendar()
122
+ for tz in timezones:
123
+ wrapper.add(copy.deepcopy(tz))
124
+ wrapper.add(component)
125
+ events[uid] = wrapper.serialize()
126
+ hashes[uid] = _event_content_hash(component)
127
+
128
+ return events, hashes
129
+
130
+
131
+ def _feed_content_hash(hashes: Dict[str, str]) -> str:
132
+ """Compute a stable hash of the entire feed from per-event hashes."""
133
+ stable = json.dumps(hashes, sort_keys=True)
134
+ return hashlib.sha256(stable.encode()).hexdigest()
135
+
136
+
137
+ class SyncJob:
138
+ """Manages periodic polling of a single upstream ICS feed URL."""
139
+
140
+ def __init__(
141
+ self,
142
+ url: str,
143
+ interval_seconds: int,
144
+ on_update: Callable[[Dict[str, str], Dict[str, str]], None],
145
+ include_patterns: Optional[List[re.Pattern]] = None,
146
+ exclude_patterns: Optional[List[re.Pattern]] = None,
147
+ ) -> None:
148
+ self._url = url
149
+ self._interval = interval_seconds
150
+ self._on_update = on_update
151
+ self._include_patterns = include_patterns or []
152
+ self._exclude_patterns = exclude_patterns or []
153
+ self._last_feed_hash: Optional[str] = None
154
+ self._thread: Optional[threading.Thread] = None
155
+ self._stop_event = threading.Event()
156
+
157
+ def fetch_once(self) -> None:
158
+ """Fetch the feed once and call on_update if content changed."""
159
+ logger.info("radicale-ics-sync: fetching upstream feed %r", self._url)
160
+ raw = _fetch_raw(self._url)
161
+ if raw is None:
162
+ return
163
+
164
+ result = _parse_events(raw, self._include_patterns, self._exclude_patterns)
165
+ if result is None:
166
+ return
167
+ events, hashes = result
168
+
169
+ feed_hash = _feed_content_hash(hashes)
170
+ if feed_hash == self._last_feed_hash:
171
+ logger.info("radicale-ics-sync: feed unchanged, skipping")
172
+ return
173
+
174
+ logger.info("radicale-ics-sync: feed updated, %d events", len(events))
175
+ self._last_feed_hash = feed_hash
176
+ self._on_update(events, hashes)
177
+
178
+ def start(self) -> None:
179
+ """Start background polling thread."""
180
+ self._thread = threading.Thread(
181
+ target=self._poll_loop, daemon=True, name="ics-sync-poller"
182
+ )
183
+ self._thread.start()
184
+ logger.info(
185
+ "radicale-ics-sync: polling %r every %ds", self._url, self._interval
186
+ )
187
+
188
+ def stop(self) -> None:
189
+ """Stop background polling thread."""
190
+ self._stop_event.set()
191
+
192
+ def _poll_loop(self) -> None:
193
+ self.fetch_once()
194
+ while not self._stop_event.wait(timeout=self._interval):
195
+ self.fetch_once()
@@ -0,0 +1,191 @@
1
+ Metadata-Version: 2.4
2
+ Name: radicale-ics-sync
3
+ Version: 0.1.0
4
+ Summary: Radicale plugin: subscribe to ICS feeds with filtering and local edit support
5
+ Author-email: Jonathan Lehmkuhl <jonathanlehmkuhl@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Jonathan Lehmkuhl
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+ Project-URL: Homepage, https://github.com/jlmkuhl/radicale-ics-sync
28
+ Project-URL: Repository, https://github.com/jlmkuhl/radicale-ics-sync
29
+ Project-URL: Issues, https://github.com/jlmkuhl/radicale-ics-sync/issues
30
+ Keywords: radicale,caldav,icalendar,ics,calendar,plugin,sync
31
+ Classifier: Development Status :: 3 - Alpha
32
+ Classifier: Intended Audience :: System Administrators
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Programming Language :: Python :: 3.9
36
+ Classifier: Programming Language :: Python :: 3.10
37
+ Classifier: Programming Language :: Python :: 3.11
38
+ Classifier: Programming Language :: Python :: 3.12
39
+ Classifier: Programming Language :: Python :: 3.13
40
+ Classifier: Topic :: Office/Business :: Scheduling
41
+ Classifier: Topic :: Internet :: WWW/HTTP
42
+ Requires-Python: >=3.9
43
+ Description-Content-Type: text/markdown
44
+ License-File: LICENSE
45
+ Requires-Dist: radicale>=3.0
46
+ Requires-Dist: vobject>=0.9.6
47
+ Dynamic: license-file
48
+
49
+ # radicale-ics-sync
50
+
51
+ Ever wanted to subscribe to an ICS feed (like a university timetable or a shared calendar) in [Radicale](https://radicale.org), but still be able to edit events locally? That's what this plugin is for.
52
+
53
+ It syncs external ICS feeds into your Radicale calendars, keeps your local changes intact, and lets you filter out events you don't care about.
54
+
55
+ ## Installation
56
+
57
+ ```bash
58
+ pip install radicale-ics-sync
59
+ ```
60
+
61
+ Then configure Radicale to use the plugin (see [Configuration](#configuration)).
62
+
63
+ ## Configuration
64
+
65
+ ### Radicale config
66
+
67
+ In your Radicale `config` file, set the storage type to `radicale_ics_sync.storage`:
68
+
69
+ ```ini
70
+ [storage]
71
+ type = radicale_ics_sync.storage
72
+ filesystem_folder = /data/collections
73
+ # ics_config is optional; if omitted, defaults to /config/ics_sync.json
74
+ ics_config = /config/ics_sync.json
75
+ ```
76
+
77
+ ### Sync jobs (`ics_sync.json`)
78
+
79
+ Create a file `/config/ics_sync.json` that defines which feeds to sync and where.
80
+
81
+ ```json
82
+ [
83
+ {
84
+ "feed": "https://example.com/calendar.ics",
85
+ "collection": "username/calendar-name",
86
+ "sync_interval": 3600,
87
+ "include_patterns": [],
88
+ "exclude_patterns": []
89
+ }
90
+ ]
91
+ ```
92
+ You can provide multiple pairs of feed + collection.
93
+ Each sync job supports these fields:
94
+
95
+ | Field | Required | Default | Description |
96
+ |---|---|---|---|
97
+ | `feed` | ✅ | — | URL of the ICS feed |
98
+ | `collection` | ✅ | — | Radicale collection path |
99
+ | `sync_interval` | | `3600` | How often to poll the feed, in seconds |
100
+ | `include_patterns` | | `[]` | Can be strings or arbitrary regex patterns |
101
+ | `exclude_patterns` | | `[]` | Can be strings or arbitrary regex patterns |
102
+
103
+ > **Note:** The collection must already exist in Radicale before the plugin can sync to it. Create it through the Radicale web interface or your CalDAV client first.
104
+
105
+ ### Filtering
106
+
107
+ Patterns are matched case-insensitively against the event's `SUMMARY` (title) field.
108
+
109
+ ```json
110
+ {
111
+ "feed": "https://university.example.com/timetable.ics",
112
+ "collection": "alice/uni",
113
+ "exclude_patterns": ["Tutorial", "Exercise"]
114
+ }
115
+ ```
116
+
117
+ - If `include_patterns` is set: only events matching at least one pattern are synced
118
+ - If `exclude_patterns` is set: events matching any pattern are removed
119
+
120
+ ## Docker setup
121
+
122
+ Here is a minimal `docker-compose.yml`:
123
+
124
+ ```yaml
125
+ services:
126
+ radicale:
127
+ build: .
128
+ container_name: radicale
129
+ restart: always
130
+ ports:
131
+ - "5232:5232"
132
+ volumes:
133
+ - ./config:/config/config:ro # Radicale config file (not a directory)
134
+ - ./users:/config/users:ro
135
+ - ./ics_sync.json:/config/ics_sync.json:ro
136
+ - ./data:/data
137
+ read_only: true
138
+ tmpfs:
139
+ - /tmp
140
+ ```
141
+
142
+ And a `Dockerfile` to install the plugin:
143
+
144
+ ```dockerfile
145
+ FROM tomsquest/docker-radicale
146
+ RUN /venv/bin/pip install radicale-ics-sync --no-cache-dir
147
+ ```
148
+
149
+ ## Non-Docker setup
150
+
151
+ Install Radicale and the plugin:
152
+
153
+ ```bash
154
+ pip install radicale radicale-ics-sync
155
+ ```
156
+
157
+ Create your Radicale config (e.g. `~/.config/radicale/config`) and specify the path of your ics_sync.json:
158
+
159
+ ```ini
160
+ [storage]
161
+ type = radicale_ics_sync.storage
162
+ filesystem_folder = ~/.local/share/radicale/collections
163
+ ics_config = ~/.config/radicale/ics_sync.json
164
+ ```
165
+
166
+ Then create `~/.config/radicale/ics_sync.json` and start Radicale:
167
+
168
+ ```bash
169
+ radicale
170
+ ```
171
+
172
+ ## State
173
+
174
+ The plugin stores its sync state in `ics_sync_hashes.json`, written next to your Radicale data. It tracks the last known content hash per event so unchanged events aren't rewritten. Safe to delete if you want a full re-sync on next startup.
175
+
176
+ ## Behavior
177
+
178
+ The plugin polls ICS feeds at a regular interval. Events are filtered by include/exclude patterns before being written to Radicale. Events that disappear from the upstream feed or are filtered out are deleted from the Radicale collection. Local edits to upstream events are preserved, until the upstream event itself changes, in which case the upstream version wins. Local events are not touched.
179
+
180
+ ## Limitations & Roadmap
181
+
182
+ - **Filtering is `SUMMARY`-only** — other fields (`LOCATION`, `DESCRIPTION`) aren't supported yet, but are planned.
183
+ - **Upstream changes fully overwrite local edits** — field-level merge (e.g. keeping your local title while accepting upstream time changes) is planned.
184
+
185
+ ## Contributing
186
+
187
+ Issues and pull requests are welcome.
188
+
189
+ ## License
190
+
191
+ MIT
@@ -0,0 +1,8 @@
1
+ radicale_ics_sync/__init__.py,sha256=E3pN_1VabeeJxenrCexysmrLkWrOqNsVIgK9Y-c4gTk,110
2
+ radicale_ics_sync/storage.py,sha256=0zN0z8G2xuN6wGGNmHXzuxqtV0G6uyaGbMR6ECQsX_s,11952
3
+ radicale_ics_sync/upstream.py,sha256=ddYA9022fdrEO_Jm2ZR5qFe09qPo14xIbiLDTNOh1Y4,6815
4
+ radicale_ics_sync-0.1.0.dist-info/licenses/LICENSE,sha256=b0HQV6qPd7NSHE0aI6jGJ1bSaZRWvccZQ-0jslFBqgM,1093
5
+ radicale_ics_sync-0.1.0.dist-info/METADATA,sha256=mRrqsPFoYzcbDVMazQozZVNQt5NuebS52mfaNXqQIGY,6914
6
+ radicale_ics_sync-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
7
+ radicale_ics_sync-0.1.0.dist-info/top_level.txt,sha256=iQNrEEH8SLZRQBbQ3xGMHoBaFFtZYHzF1ELY6bLuPdQ,18
8
+ radicale_ics_sync-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jonathan Lehmkuhl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ radicale_ics_sync