radicale-ics-sync 0.1.0__tar.gz
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.
- radicale_ics_sync-0.1.0/LICENSE +21 -0
- radicale_ics_sync-0.1.0/PKG-INFO +191 -0
- radicale_ics_sync-0.1.0/README.md +143 -0
- radicale_ics_sync-0.1.0/pyproject.toml +37 -0
- radicale_ics_sync-0.1.0/radicale_ics_sync/__init__.py +3 -0
- radicale_ics_sync-0.1.0/radicale_ics_sync/storage.py +318 -0
- radicale_ics_sync-0.1.0/radicale_ics_sync/upstream.py +195 -0
- radicale_ics_sync-0.1.0/radicale_ics_sync.egg-info/PKG-INFO +191 -0
- radicale_ics_sync-0.1.0/radicale_ics_sync.egg-info/SOURCES.txt +11 -0
- radicale_ics_sync-0.1.0/radicale_ics_sync.egg-info/dependency_links.txt +1 -0
- radicale_ics_sync-0.1.0/radicale_ics_sync.egg-info/requires.txt +2 -0
- radicale_ics_sync-0.1.0/radicale_ics_sync.egg-info/top_level.txt +1 -0
- radicale_ics_sync-0.1.0/setup.cfg +4 -0
|
@@ -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,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,143 @@
|
|
|
1
|
+
# radicale-ics-sync
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install radicale-ics-sync
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Then configure Radicale to use the plugin (see [Configuration](#configuration)).
|
|
14
|
+
|
|
15
|
+
## Configuration
|
|
16
|
+
|
|
17
|
+
### Radicale config
|
|
18
|
+
|
|
19
|
+
In your Radicale `config` file, set the storage type to `radicale_ics_sync.storage`:
|
|
20
|
+
|
|
21
|
+
```ini
|
|
22
|
+
[storage]
|
|
23
|
+
type = radicale_ics_sync.storage
|
|
24
|
+
filesystem_folder = /data/collections
|
|
25
|
+
# ics_config is optional; if omitted, defaults to /config/ics_sync.json
|
|
26
|
+
ics_config = /config/ics_sync.json
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Sync jobs (`ics_sync.json`)
|
|
30
|
+
|
|
31
|
+
Create a file `/config/ics_sync.json` that defines which feeds to sync and where.
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
[
|
|
35
|
+
{
|
|
36
|
+
"feed": "https://example.com/calendar.ics",
|
|
37
|
+
"collection": "username/calendar-name",
|
|
38
|
+
"sync_interval": 3600,
|
|
39
|
+
"include_patterns": [],
|
|
40
|
+
"exclude_patterns": []
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
```
|
|
44
|
+
You can provide multiple pairs of feed + collection.
|
|
45
|
+
Each sync job supports these fields:
|
|
46
|
+
|
|
47
|
+
| Field | Required | Default | Description |
|
|
48
|
+
|---|---|---|---|
|
|
49
|
+
| `feed` | ✅ | — | URL of the ICS feed |
|
|
50
|
+
| `collection` | ✅ | — | Radicale collection path |
|
|
51
|
+
| `sync_interval` | | `3600` | How often to poll the feed, in seconds |
|
|
52
|
+
| `include_patterns` | | `[]` | Can be strings or arbitrary regex patterns |
|
|
53
|
+
| `exclude_patterns` | | `[]` | Can be strings or arbitrary regex patterns |
|
|
54
|
+
|
|
55
|
+
> **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.
|
|
56
|
+
|
|
57
|
+
### Filtering
|
|
58
|
+
|
|
59
|
+
Patterns are matched case-insensitively against the event's `SUMMARY` (title) field.
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"feed": "https://university.example.com/timetable.ics",
|
|
64
|
+
"collection": "alice/uni",
|
|
65
|
+
"exclude_patterns": ["Tutorial", "Exercise"]
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
- If `include_patterns` is set: only events matching at least one pattern are synced
|
|
70
|
+
- If `exclude_patterns` is set: events matching any pattern are removed
|
|
71
|
+
|
|
72
|
+
## Docker setup
|
|
73
|
+
|
|
74
|
+
Here is a minimal `docker-compose.yml`:
|
|
75
|
+
|
|
76
|
+
```yaml
|
|
77
|
+
services:
|
|
78
|
+
radicale:
|
|
79
|
+
build: .
|
|
80
|
+
container_name: radicale
|
|
81
|
+
restart: always
|
|
82
|
+
ports:
|
|
83
|
+
- "5232:5232"
|
|
84
|
+
volumes:
|
|
85
|
+
- ./config:/config/config:ro # Radicale config file (not a directory)
|
|
86
|
+
- ./users:/config/users:ro
|
|
87
|
+
- ./ics_sync.json:/config/ics_sync.json:ro
|
|
88
|
+
- ./data:/data
|
|
89
|
+
read_only: true
|
|
90
|
+
tmpfs:
|
|
91
|
+
- /tmp
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
And a `Dockerfile` to install the plugin:
|
|
95
|
+
|
|
96
|
+
```dockerfile
|
|
97
|
+
FROM tomsquest/docker-radicale
|
|
98
|
+
RUN /venv/bin/pip install radicale-ics-sync --no-cache-dir
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Non-Docker setup
|
|
102
|
+
|
|
103
|
+
Install Radicale and the plugin:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
pip install radicale radicale-ics-sync
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Create your Radicale config (e.g. `~/.config/radicale/config`) and specify the path of your ics_sync.json:
|
|
110
|
+
|
|
111
|
+
```ini
|
|
112
|
+
[storage]
|
|
113
|
+
type = radicale_ics_sync.storage
|
|
114
|
+
filesystem_folder = ~/.local/share/radicale/collections
|
|
115
|
+
ics_config = ~/.config/radicale/ics_sync.json
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Then create `~/.config/radicale/ics_sync.json` and start Radicale:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
radicale
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## State
|
|
125
|
+
|
|
126
|
+
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.
|
|
127
|
+
|
|
128
|
+
## Behavior
|
|
129
|
+
|
|
130
|
+
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.
|
|
131
|
+
|
|
132
|
+
## Limitations & Roadmap
|
|
133
|
+
|
|
134
|
+
- **Filtering is `SUMMARY`-only** — other fields (`LOCATION`, `DESCRIPTION`) aren't supported yet, but are planned.
|
|
135
|
+
- **Upstream changes fully overwrite local edits** — field-level merge (e.g. keeping your local title while accepting upstream time changes) is planned.
|
|
136
|
+
|
|
137
|
+
## Contributing
|
|
138
|
+
|
|
139
|
+
Issues and pull requests are welcome.
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=42"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "radicale-ics-sync"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Radicale plugin: subscribe to ICS feeds with filtering and local edit support"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {file = "LICENSE"}
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Jonathan Lehmkuhl", email = "jonathanlehmkuhl@gmail.com" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["radicale", "caldav", "icalendar", "ics", "calendar", "plugin", "sync"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: System Administrators",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Office/Business :: Scheduling",
|
|
27
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
28
|
+
]
|
|
29
|
+
dependencies = [
|
|
30
|
+
"radicale>=3.0",
|
|
31
|
+
"vobject>=0.9.6",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/jlmkuhl/radicale-ics-sync"
|
|
36
|
+
Repository = "https://github.com/jlmkuhl/radicale-ics-sync"
|
|
37
|
+
Issues = "https://github.com/jlmkuhl/radicale-ics-sync/issues"
|
|
@@ -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,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
radicale_ics_sync/__init__.py
|
|
5
|
+
radicale_ics_sync/storage.py
|
|
6
|
+
radicale_ics_sync/upstream.py
|
|
7
|
+
radicale_ics_sync.egg-info/PKG-INFO
|
|
8
|
+
radicale_ics_sync.egg-info/SOURCES.txt
|
|
9
|
+
radicale_ics_sync.egg-info/dependency_links.txt
|
|
10
|
+
radicale_ics_sync.egg-info/requires.txt
|
|
11
|
+
radicale_ics_sync.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
radicale_ics_sync
|