outlook-caldav-sync 0.2.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.
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.3
2
+ Name: outlook-caldav-sync
3
+ Version: 0.2.0
4
+ Summary: A Python tool to sync calendar events from an Outlook JSON export to a CalDAV server
5
+ Author: Max Mehl
6
+ Author-email: Max Mehl <mail@mehl.mx>
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.12
9
+ Classifier: Programming Language :: Python :: 3.13
10
+ Classifier: Programming Language :: Python :: 3.14
11
+ Requires-Dist: icalendar>=7.2.0,<7.3.0
12
+ Requires-Dist: caldav>=3.2.1,<3.3.0
13
+ Requires-Python: >=3.12, <4.0
14
+ Description-Content-Type: text/markdown
15
+
16
+ # Outlook CalDAV Sync
17
+
18
+ [![The latest version of this tool can be found on PyPI.](https://img.shields.io/pypi/v/outlook-caldav-sync.svg)](https://pypi.org/project/outlook-caldav-sync/)
19
+ [![Information on what versions of Python this tool supports can be found on PyPI.](https://img.shields.io/pypi/pyversions/outlook-caldav-sync.svg)](https://pypi.org/project/outlook-caldav-sync/)
20
+
21
+ A Python tool to sync calendar events from an **Outlook JSON export** to a **CalDAV server** (e.g. Nextcloud Calendar, Radicale, Baikal).
22
+
23
+ ## Overview
24
+
25
+ Outlook does not speak CalDAV natively. If you need your Outlook calendar available in a CalDAV-based application — whether for privacy, interoperability, or self-hosting reasons — the usual path involves manual exports or fragile third-party connectors.
26
+
27
+ This tool bridges that gap: it reads the JSON format that Microsoft's Power Automate (or a custom script) can export from an Outlook calendar and pushes the events to any CalDAV-compatible server. Only events that have actually changed are uploaded, keeping the sync efficient. Events that have disappeared from Outlook can optionally be removed from the CalDAV calendar as well.
28
+
29
+ ## Features
30
+
31
+ - Sync Outlook JSON calendar exports to any CalDAV server
32
+ - Skip unchanged events using SHA-256 hash comparison — only changed events are uploaded
33
+ - Configurable time window: how many days into the past and future to sync
34
+ - **Filter events** by Outlook category or subject regex — excluded events are also removed from the remote calendar
35
+ - **Anonymize attendee e-mail addresses** (replace domain with `@invalid.invalid`)
36
+ - Mark all synced events as `CONFIDENTIAL` (private) on the CalDAV side
37
+ - Dry-run mode: preview what would happen without making any changes
38
+ - Force-update mode: re-upload all events even if they appear unchanged
39
+ - Reset remote: wipe all events from the CalDAV calendar before a fresh sync
40
+ - Optional log file in addition to stdout
41
+
42
+ ## Install
43
+
44
+ Requires at least **Python 3.12**.
45
+
46
+ ```bash
47
+ pip install outlook-caldav-sync
48
+ ```
49
+
50
+ The command `outlook-caldav-sync` is then available. Run `outlook-caldav-sync --help` for a full list of options.
51
+
52
+ For development installs, use [uv](https://github.com/astral-sh/uv):
53
+
54
+ ```bash
55
+ uv sync
56
+ uv run outlook-caldav-sync --help
57
+ ```
58
+
59
+ ## Outlook JSON export
60
+
61
+ The tool expects a JSON file containing an array of calendar event objects in the format that **Microsoft Power Automate** produces when reading from the Outlook calendar connector. Each event object should contain at least the following fields:
62
+
63
+ | Field | Description |
64
+ | ------------------- | ------------------------------------------------------- |
65
+ | `iCalUId` | Unique identifier of the event |
66
+ | `subject` | Event title |
67
+ | `startWithTimeZone` | ISO 8601 start datetime with timezone |
68
+ | `endWithTimeZone` | ISO 8601 end datetime with timezone |
69
+ | `location` | (optional) Location string |
70
+ | `organizer` | (optional) Semicolon-separated organizer email(s) |
71
+ | `requiredAttendees` | (optional) Semicolon-separated required attendee emails |
72
+ | `optionalAttendees` | (optional) Semicolon-separated optional attendee emails |
73
+ | `categories` | (optional) List of Outlook category strings |
74
+
75
+ ## Configuration
76
+
77
+ Configuration is provided as a JSON file passed via `--config`. Example
78
+ (`config.json`):
79
+
80
+ ```json
81
+ {
82
+ dav_url: "https://nextcloud.example.com/remote.php/dav/",
83
+ dav_calendar: "my-calendar-id",
84
+ dav_username: "myuser",
85
+ dav_password: "secret",
86
+ dav_past_days: 3,
87
+ dav_future_days: 400,
88
+ log_file: "/var/log/outlook-caldav-sync.log",
89
+ anonymize_email: true,
90
+ private: false,
91
+ delete_missing: true,
92
+ nosync_categories: ["Reminder/Blocker"],
93
+ nosync_subject_regex: ["^Declined:"],
94
+ nosync_showas: ["free"]
95
+ }
96
+ ```
97
+
98
+ | Key | Required | Default | Description |
99
+ | ---------------------- | -------- | -------- | --------------------------------------------------------- |
100
+ | `dav_url` | yes | — | Base URL of the CalDAV server |
101
+ | `dav_calendar` | yes | — | Calendar ID or name on the server |
102
+ | `dav_username` | yes | — | CalDAV username |
103
+ | `dav_password` | yes | — | CalDAV password |
104
+ | `dav_past_days` | no | `3` | Days into the past to include in the sync window |
105
+ | `dav_future_days` | no | `365` | Days into the future to include in the sync window |
106
+ | `log_file` | no | _(none)_ | Path to an optional log file (appended to) |
107
+ | `anonymize_email` | no | `false` | Replace attendee email domains with `@invalid.invalid` |
108
+ | `private` | no | `false` | Mark all synced events as `CONFIDENTIAL` |
109
+ | `delete_missing` | no | `true` | Delete CalDAV events absent from the Outlook JSON |
110
+ | `nosync_categories` | no | `[]` | Outlook categories whose events are skipped and removed |
111
+ | `nosync_subject_regex` | no | `[]` | Regex patterns; matching subjects are skipped and removed |
112
+ | `nosync_showas` | no | `[]` | `showAs` values whose events are skipped and removed (e.g. `free`, `tentative`) |
113
+
114
+ ## Usage
115
+
116
+ ```
117
+ outlook-caldav-sync -c config.json -i calendar-export.json
118
+ ```
119
+
120
+ Full list of CLI options:
121
+
122
+ | Flag | Description |
123
+ | ------------------------ | ---------------------------------------------------------- |
124
+ | `-c`, `--config` | Path to the JSON configuration file (**required**) |
125
+ | `-i`, `--calendar-input` | Path to the Outlook JSON export file (**required**) |
126
+ | `-f`, `--force` | Re-upload all events even if they appear unchanged |
127
+ | `--dry` | Dry-run mode — no changes are written to the CalDAV server |
128
+ | `--reset-remote` | Interactively delete **all** events in the remote calendar |
129
+ | `-vv`, `--debug` | Enable DEBUG-level logging |
130
+
131
+ ## Filtering / no-sync
132
+
133
+ Events can be excluded from syncing in two ways:
134
+
135
+ - **By category**: add Outlook category names to `nosync_categories`. Events with those categories are skipped during upload _and_ removed from the remote if they exist there.
136
+ - **By subject regex**: add regular expressions to `nosync_subject_regex`. Events whose subject matches any pattern are treated the same way.
137
+ - **By showAs value**: add Outlook `showAs` values to `nosync_showas`. Known values include `free`, `busy`, `oof`, `tentative`. Events matching are skipped and removed from the remote.
138
+
139
+ This is useful for blocking personal reminders, declined meetings, or any category you do not want mirrored to your CalDAV calendar.
140
+
141
+ ## Logging
142
+
143
+ The tool logs to stdout by default. Set `log_file` in the config to additionally write to a file (append mode). Use `-vv` / `--debug` to enable verbose DEBUG output.
144
+
145
+ ## Contribute and Development
146
+
147
+ Contributions are welcome! The development is easiest with [uv](https://github.com/astral-sh/uv): `uv sync` installs all dependencies and `uv run outlook-caldav-sync` runs the tool directly.
148
+
149
+ Run the test suite with `uv run pytest`, linting with `uv run ruff check`, and type checking with `uv run ty check`.
150
+
151
+ ## License
152
+
153
+ Apache-2.0, Copyright Max Mehl. See [LICENSE](LICENSE) for details.
@@ -0,0 +1,138 @@
1
+ # Outlook CalDAV Sync
2
+
3
+ [![The latest version of this tool can be found on PyPI.](https://img.shields.io/pypi/v/outlook-caldav-sync.svg)](https://pypi.org/project/outlook-caldav-sync/)
4
+ [![Information on what versions of Python this tool supports can be found on PyPI.](https://img.shields.io/pypi/pyversions/outlook-caldav-sync.svg)](https://pypi.org/project/outlook-caldav-sync/)
5
+
6
+ A Python tool to sync calendar events from an **Outlook JSON export** to a **CalDAV server** (e.g. Nextcloud Calendar, Radicale, Baikal).
7
+
8
+ ## Overview
9
+
10
+ Outlook does not speak CalDAV natively. If you need your Outlook calendar available in a CalDAV-based application — whether for privacy, interoperability, or self-hosting reasons — the usual path involves manual exports or fragile third-party connectors.
11
+
12
+ This tool bridges that gap: it reads the JSON format that Microsoft's Power Automate (or a custom script) can export from an Outlook calendar and pushes the events to any CalDAV-compatible server. Only events that have actually changed are uploaded, keeping the sync efficient. Events that have disappeared from Outlook can optionally be removed from the CalDAV calendar as well.
13
+
14
+ ## Features
15
+
16
+ - Sync Outlook JSON calendar exports to any CalDAV server
17
+ - Skip unchanged events using SHA-256 hash comparison — only changed events are uploaded
18
+ - Configurable time window: how many days into the past and future to sync
19
+ - **Filter events** by Outlook category or subject regex — excluded events are also removed from the remote calendar
20
+ - **Anonymize attendee e-mail addresses** (replace domain with `@invalid.invalid`)
21
+ - Mark all synced events as `CONFIDENTIAL` (private) on the CalDAV side
22
+ - Dry-run mode: preview what would happen without making any changes
23
+ - Force-update mode: re-upload all events even if they appear unchanged
24
+ - Reset remote: wipe all events from the CalDAV calendar before a fresh sync
25
+ - Optional log file in addition to stdout
26
+
27
+ ## Install
28
+
29
+ Requires at least **Python 3.12**.
30
+
31
+ ```bash
32
+ pip install outlook-caldav-sync
33
+ ```
34
+
35
+ The command `outlook-caldav-sync` is then available. Run `outlook-caldav-sync --help` for a full list of options.
36
+
37
+ For development installs, use [uv](https://github.com/astral-sh/uv):
38
+
39
+ ```bash
40
+ uv sync
41
+ uv run outlook-caldav-sync --help
42
+ ```
43
+
44
+ ## Outlook JSON export
45
+
46
+ The tool expects a JSON file containing an array of calendar event objects in the format that **Microsoft Power Automate** produces when reading from the Outlook calendar connector. Each event object should contain at least the following fields:
47
+
48
+ | Field | Description |
49
+ | ------------------- | ------------------------------------------------------- |
50
+ | `iCalUId` | Unique identifier of the event |
51
+ | `subject` | Event title |
52
+ | `startWithTimeZone` | ISO 8601 start datetime with timezone |
53
+ | `endWithTimeZone` | ISO 8601 end datetime with timezone |
54
+ | `location` | (optional) Location string |
55
+ | `organizer` | (optional) Semicolon-separated organizer email(s) |
56
+ | `requiredAttendees` | (optional) Semicolon-separated required attendee emails |
57
+ | `optionalAttendees` | (optional) Semicolon-separated optional attendee emails |
58
+ | `categories` | (optional) List of Outlook category strings |
59
+
60
+ ## Configuration
61
+
62
+ Configuration is provided as a JSON file passed via `--config`. Example
63
+ (`config.json`):
64
+
65
+ ```json
66
+ {
67
+ dav_url: "https://nextcloud.example.com/remote.php/dav/",
68
+ dav_calendar: "my-calendar-id",
69
+ dav_username: "myuser",
70
+ dav_password: "secret",
71
+ dav_past_days: 3,
72
+ dav_future_days: 400,
73
+ log_file: "/var/log/outlook-caldav-sync.log",
74
+ anonymize_email: true,
75
+ private: false,
76
+ delete_missing: true,
77
+ nosync_categories: ["Reminder/Blocker"],
78
+ nosync_subject_regex: ["^Declined:"],
79
+ nosync_showas: ["free"]
80
+ }
81
+ ```
82
+
83
+ | Key | Required | Default | Description |
84
+ | ---------------------- | -------- | -------- | --------------------------------------------------------- |
85
+ | `dav_url` | yes | — | Base URL of the CalDAV server |
86
+ | `dav_calendar` | yes | — | Calendar ID or name on the server |
87
+ | `dav_username` | yes | — | CalDAV username |
88
+ | `dav_password` | yes | — | CalDAV password |
89
+ | `dav_past_days` | no | `3` | Days into the past to include in the sync window |
90
+ | `dav_future_days` | no | `365` | Days into the future to include in the sync window |
91
+ | `log_file` | no | _(none)_ | Path to an optional log file (appended to) |
92
+ | `anonymize_email` | no | `false` | Replace attendee email domains with `@invalid.invalid` |
93
+ | `private` | no | `false` | Mark all synced events as `CONFIDENTIAL` |
94
+ | `delete_missing` | no | `true` | Delete CalDAV events absent from the Outlook JSON |
95
+ | `nosync_categories` | no | `[]` | Outlook categories whose events are skipped and removed |
96
+ | `nosync_subject_regex` | no | `[]` | Regex patterns; matching subjects are skipped and removed |
97
+ | `nosync_showas` | no | `[]` | `showAs` values whose events are skipped and removed (e.g. `free`, `tentative`) |
98
+
99
+ ## Usage
100
+
101
+ ```
102
+ outlook-caldav-sync -c config.json -i calendar-export.json
103
+ ```
104
+
105
+ Full list of CLI options:
106
+
107
+ | Flag | Description |
108
+ | ------------------------ | ---------------------------------------------------------- |
109
+ | `-c`, `--config` | Path to the JSON configuration file (**required**) |
110
+ | `-i`, `--calendar-input` | Path to the Outlook JSON export file (**required**) |
111
+ | `-f`, `--force` | Re-upload all events even if they appear unchanged |
112
+ | `--dry` | Dry-run mode — no changes are written to the CalDAV server |
113
+ | `--reset-remote` | Interactively delete **all** events in the remote calendar |
114
+ | `-vv`, `--debug` | Enable DEBUG-level logging |
115
+
116
+ ## Filtering / no-sync
117
+
118
+ Events can be excluded from syncing in two ways:
119
+
120
+ - **By category**: add Outlook category names to `nosync_categories`. Events with those categories are skipped during upload _and_ removed from the remote if they exist there.
121
+ - **By subject regex**: add regular expressions to `nosync_subject_regex`. Events whose subject matches any pattern are treated the same way.
122
+ - **By showAs value**: add Outlook `showAs` values to `nosync_showas`. Known values include `free`, `busy`, `oof`, `tentative`. Events matching are skipped and removed from the remote.
123
+
124
+ This is useful for blocking personal reminders, declined meetings, or any category you do not want mirrored to your CalDAV calendar.
125
+
126
+ ## Logging
127
+
128
+ The tool logs to stdout by default. Set `log_file` in the config to additionally write to a file (append mode). Use `-vv` / `--debug` to enable verbose DEBUG output.
129
+
130
+ ## Contribute and Development
131
+
132
+ Contributions are welcome! The development is easiest with [uv](https://github.com/astral-sh/uv): `uv sync` installs all dependencies and `uv run outlook-caldav-sync` runs the tool directly.
133
+
134
+ Run the test suite with `uv run pytest`, linting with `uv run ruff check`, and type checking with `uv run ty check`.
135
+
136
+ ## License
137
+
138
+ Apache-2.0, Copyright Max Mehl. See [LICENSE](LICENSE) for details.
@@ -0,0 +1 @@
1
+ """Outlook CalDAV Sync package."""
@@ -0,0 +1,533 @@
1
+ """Sync Outlook calendar with CalDAV server using ICS export and JSON import."""
2
+
3
+ import argparse
4
+ import hashlib
5
+ import json
6
+ import logging
7
+ import re
8
+ from datetime import UTC, datetime, timedelta
9
+ from typing import cast
10
+
11
+ from caldav.calendarobjectresource import Event as CalDAVEvent
12
+ from caldav.collection import Calendar as CalDAVCalendar
13
+ from caldav.davclient import DAVClient
14
+ from icalendar import Calendar, Event
15
+
16
+
17
+ def configure_logger(log_file: str = "", verbose: bool = False) -> logging.Logger:
18
+ """Set logging options."""
19
+ log_handlers = [logging.StreamHandler()]
20
+ if log_file:
21
+ log_handlers.append(logging.FileHandler(log_file, mode="a", encoding="utf-8"))
22
+
23
+ log = logging.getLogger()
24
+ logging.basicConfig(
25
+ format="[%(asctime)s] %(levelname)s: %(message)s",
26
+ datefmt="%Y-%m-%d %H:%M:%S",
27
+ handlers=log_handlers,
28
+ )
29
+ if verbose:
30
+ log.setLevel(logging.DEBUG)
31
+ else:
32
+ log.setLevel(logging.INFO)
33
+
34
+ return log
35
+
36
+
37
+ def ask_user_yes_no(prompt: str) -> bool:
38
+ """Ask the user a yes/no question and return their choice as a boolean."""
39
+ while True:
40
+ choice = input(f"{prompt} (y/n): ").lower()
41
+ if choice == "y":
42
+ return True
43
+ if choice == "n":
44
+ return False
45
+ print("Invalid choice. Please enter 'y' or 'n'.")
46
+
47
+
48
+ def hash_event(event: Event) -> str:
49
+ """Generate a SHA256 hash of an event's relevant parts.
50
+
51
+ Args:
52
+ event (Event): iCalendar event.
53
+
54
+ Returns:
55
+ str: Hex digest of the event hash.
56
+ """
57
+ keys = ["summary", "dtstart", "dtend"]
58
+ content = "|".join(str(event.get(k)) for k in keys)
59
+ hashsum = hashlib.sha256(content.encode("utf-8")).hexdigest()
60
+ logging.debug("Hash of event %s is %s", event.get("summary"), hashsum)
61
+ return hashsum
62
+
63
+
64
+ def parse_ics_events(ics_data: str) -> dict:
65
+ """Parse .ics content and return a UID-to-event dictionary.
66
+
67
+ Args:
68
+ ics_data (str): Raw .ics content.
69
+
70
+ Returns:
71
+ dict: Mapping of UID to iCalendar events.
72
+ """
73
+ parsed = Calendar.from_ical(ics_data)
74
+ uid_map = {}
75
+ for component in parsed.walk():
76
+ if component.name == "VEVENT" and component.get("uid"):
77
+ uid_map[component.get("uid")] = component
78
+ return uid_map
79
+
80
+
81
+ def get_short_info_from_event_dict(
82
+ event: dict[str, str], title_key: str = "subject", start_key: str = "start"
83
+ ) -> tuple[str, str]:
84
+ """Extract short info from a JSON event.
85
+
86
+ Args:
87
+ event (dict): Event data from Outlook JSON export.
88
+ title_key (str): Key for the event title. Default is "subject".
89
+ start_key (str): Key for the event start time. Default is "start".
90
+
91
+ Returns:
92
+ tuple: Tuple containing UID and description (summary + start time).
93
+ """
94
+ uid = event.get("iCalUId") or event.get("UID", "")
95
+ subject = event.get(title_key, "")
96
+ start_time = event.get(start_key, "")
97
+ description = f"{subject} ({start_time})"
98
+ if not uid:
99
+ logging.warning("Event without UID: %s", description)
100
+ return "", description
101
+ return uid, description
102
+
103
+
104
+ def add_attendees_to_event(event: Event, attendees: str, role: str, anonymize_email: bool) -> None:
105
+ """Add attendees to an iCalendar event.
106
+
107
+ Args:
108
+ event (Event): iCalendar event.
109
+ attendees (str): Semicolon-separated list of attendee email addresses.
110
+ role (str): Role of the attendees ("ORGANIZER", "REQ-PARTICIPANT", "OPT-PARTICIPANT").
111
+ anonymize_email (bool): Whether to convert email addresses to a name, e.g.
112
+ "john.doe@example.com" to "john.doe@invalid.invalid"
113
+ """
114
+ for attendee in attendees.split(";"):
115
+ if attendee := attendee.strip():
116
+ parameters = {}
117
+ # If the attendee is an email address, format it correctly
118
+ if "@" in attendee:
119
+ if anonymize_email:
120
+ # Replace domain part for name-only format
121
+ attendee_name = attendee.split("@")[0]
122
+ parameters["CN"] = attendee_name
123
+ attendee = attendee_name + "@invalid.invalid" # noqa: PLW2901
124
+ else:
125
+ # Ensure email format for iCalendar
126
+ attendee = f"mailto:{attendee}" # noqa: PLW2901
127
+ # Add attendee to the event. If the role is ORGANIZER, use the organizer field;
128
+ # otherwise, use attendee
129
+ if role == "ORGANIZER":
130
+ event.add("organizer", attendee, parameters=parameters)
131
+ else:
132
+ parameters["ROLE"] = role
133
+ event.add(
134
+ name="attendee",
135
+ value=attendee,
136
+ parameters=parameters,
137
+ )
138
+
139
+
140
+ def create_icalendar_event(
141
+ json_event: dict[str, str], anonymize_email: bool, private: bool
142
+ ) -> Event:
143
+ """Convert a JSON calendar entry to an iCalendar VEVENT.
144
+
145
+ Args:
146
+ json_event (dict): Event data from Outlook JSON export.
147
+ anonymize_email (bool): Whether to anonymize email addresses.
148
+ private (bool): Whether the calendar event is private.
149
+
150
+ Returns:
151
+ Event: Constructed iCalendar event.
152
+ """
153
+ event = Event()
154
+ event.add("uid", json_event["iCalUId"])
155
+ event.add("summary", json_event["subject"])
156
+ event.add("dtstart", datetime.fromisoformat(json_event["startWithTimeZone"]).astimezone(UTC))
157
+ event.add("dtend", datetime.fromisoformat(json_event["endWithTimeZone"]).astimezone(UTC))
158
+ event.add("location", json_event.get("location", ""))
159
+
160
+ # Add organizer and attendees
161
+ add_attendees_to_event(
162
+ event,
163
+ json_event.get("organizer", ""),
164
+ "ORGANIZER",
165
+ anonymize_email=anonymize_email,
166
+ )
167
+ add_attendees_to_event(
168
+ event,
169
+ json_event.get("requiredAttendees", ""),
170
+ "REQ-PARTICIPANT",
171
+ anonymize_email=anonymize_email,
172
+ )
173
+ add_attendees_to_event(
174
+ event,
175
+ json_event.get("optionalAttendees", ""),
176
+ "OPT-PARTICIPANT",
177
+ anonymize_email=anonymize_email,
178
+ )
179
+
180
+ # Set classification based on private setting
181
+ if private:
182
+ event.add("class", "CONFIDENTIAL")
183
+
184
+ return event
185
+
186
+
187
+ def connect_to_caldav(config: dict) -> CalDAVCalendar:
188
+ """Connect to the CalDAV server and return the calendar object.
189
+
190
+ Args:
191
+ config (dict): Configuration dictionary.
192
+
193
+ Returns:
194
+ caldav.objects.Calendar: CalDAV calendar object.
195
+ """
196
+ client = DAVClient(
197
+ url=config.get("dav_url", ""),
198
+ username=config.get("dav_username", ""),
199
+ password=config.get("dav_password", ""),
200
+ )
201
+ principal = client.principal()
202
+ return principal.calendar(cal_id=config.get("dav_calendar", ""))
203
+
204
+
205
+ def get_existing_event_hashes(
206
+ calendar: CalDAVCalendar, past: datetime, future: datetime
207
+ ) -> dict[str, str]:
208
+ """Fetch and hash existing remote events.
209
+
210
+ Args:
211
+ calendar (Calendar): CalDAV calendar object.
212
+ past (datetime): Start of time window.
213
+ future (datetime): End of time window.
214
+
215
+ Returns:
216
+ dict: Mapping of UID to hash for existing events.
217
+ """
218
+ logging.info("Fetching events from CalDAV server...")
219
+ remote_events = cast(
220
+ "list[CalDAVEvent]", calendar.search(comp_class=CalDAVEvent, start=past, end=future)
221
+ )
222
+ hashes = {}
223
+
224
+ for e in remote_events:
225
+ try:
226
+ ical = e.icalendar_component
227
+ if ical.name == "VEVENT" and ical.get("UID"):
228
+ uid = str(ical.get("UID"))
229
+ hashes[uid] = hash_event(ical)
230
+ except Exception as err: # noqa: BLE001
231
+ logging.warning("Skipping broken event %s: %s", e, err)
232
+
233
+ logging.info("Found %d existing remote events", len(hashes))
234
+ return hashes
235
+
236
+
237
+ def caldav_event_to_dict(event: Event) -> dict[str, str]:
238
+ """Converts most important elements of a VEVENT to a plain dictionary with string values."""
239
+ return {
240
+ "SUMMARY": str(event.get("SUMMARY")),
241
+ "DTSTART": str(event.get("DTSTART").dt),
242
+ "DTEND": str(event.get("DTEND").dt),
243
+ "DTSTAMP": str(event.get("DTSTAMP").dt),
244
+ "UID": str(event.get("UID")),
245
+ "LOCATION": str(event.get("LOCATION")),
246
+ }
247
+
248
+
249
+ def delete_missing_events( # noqa: C901
250
+ calendar: CalDAVCalendar,
251
+ outlook_entries: list[dict[str, str]],
252
+ delete_enabled: bool = True,
253
+ nosync_uids: list[str] | None = None,
254
+ dry: bool = False,
255
+ ) -> int:
256
+ """Delete CalDAV events that are not in Outlook JSON but within the JSON date range.
257
+
258
+ Args:
259
+ calendar (CalDAVCalendar): CalDAV calendar object.
260
+ outlook_entries (list): List of Outlook event dictionaries.
261
+ delete_enabled (bool): Whether deletion is enabled.
262
+ nosync_uids (list): List of UIDs that will be deleted if present remotely.
263
+ dry (bool): Dry run mode; if True, no deletions are performed.
264
+
265
+ Returns:
266
+ int: Number of deleted events.
267
+ """
268
+ # Set default for nosync_uids
269
+ if nosync_uids is None:
270
+ nosync_uids = []
271
+
272
+ if not delete_enabled:
273
+ logging.info("Deletion of missing events is disabled by config.")
274
+ return 0
275
+
276
+ if not outlook_entries:
277
+ logging.warning("No Outlook entries loaded; skipping deletion check.")
278
+ return 0
279
+
280
+ # Determine JSON event date range
281
+ json_start_times = [
282
+ datetime.fromisoformat(e.get("startWithTimeZone", "")).astimezone(UTC)
283
+ for e in outlook_entries
284
+ if "startWithTimeZone" in e
285
+ ]
286
+ json_start_min = min(json_start_times)
287
+ json_start_max = max(json_start_times)
288
+
289
+ # Collect UIDs from Outlook
290
+ json_uids = {e.get("iCalUId", "") for e in outlook_entries if "iCalUId" in e}
291
+
292
+ # Find CalDAV events in the same time window
293
+ remote_events = cast(
294
+ "list[CalDAVEvent]",
295
+ calendar.search(
296
+ comp_class=CalDAVEvent,
297
+ start=json_start_min,
298
+ end=json_start_max,
299
+ ),
300
+ )
301
+
302
+ deleted = 0
303
+ for e in remote_events:
304
+ try:
305
+ ical: dict[str, str] = caldav_event_to_dict(e.icalendar_component)
306
+ except Exception as err: # noqa: BLE001
307
+ logging.warning("Skipping broken event %s: %s", e, err)
308
+ continue
309
+
310
+ uid, description = get_short_info_from_event_dict(
311
+ event=ical, title_key="SUMMARY", start_key="DTSTART"
312
+ )
313
+
314
+ try:
315
+ if uid:
316
+ if uid not in json_uids:
317
+ if not dry:
318
+ e.delete()
319
+ logging.info("Deleted stale event: %s (UID: %s)", description, uid)
320
+ deleted += 1
321
+ elif uid in nosync_uids:
322
+ if not dry:
323
+ e.delete()
324
+ logging.info("Deleted no-sync event: %s (UID: %s)", description, uid)
325
+ deleted += 1
326
+ except Exception as err: # noqa: BLE001
327
+ logging.warning("Failed to delete event %s (UID: %s): %s", description, uid, err)
328
+
329
+ logging.debug("Deletion pass complete. Deleted %d events.", deleted)
330
+
331
+ return deleted
332
+
333
+
334
+ def sync_events( # pylint: disable=too-many-locals
335
+ config: dict,
336
+ calendar: CalDAVCalendar,
337
+ existing_hashes: dict[str, str],
338
+ dry: bool = False,
339
+ force: bool = False,
340
+ ) -> None:
341
+ """Sync Outlook JSON events to the CalDAV server.
342
+
343
+ Args:
344
+ config (dict): Configuration dictionary.
345
+ calendar (Calendar): CalDAV calendar object.
346
+ existing_hashes (dict): Existing event hashes.
347
+ dry (bool): Dry run mode; if True, no changes are made.
348
+ force (bool): Force update of events even if unchanged.
349
+ """
350
+ created, updated, skipped, deleted = 0, 0, 0, 0
351
+ nosync_uids: list[str] = []
352
+
353
+ # Load Outlook JSON data
354
+ with open(config.get("outlook_calendar_file", ""), encoding="utf-8") as f:
355
+ outlook_entries: list[dict] = json.load(f)
356
+ logging.info("Processing %d events from Outlook JSON", len(outlook_entries))
357
+
358
+ for item in outlook_entries:
359
+ uid, description = get_short_info_from_event_dict(item)
360
+
361
+ # Check for no-sync categories
362
+ categories: list[str] = item.get("categories", [])
363
+ if any(cat in config.get("nosync_categories", []) for cat in categories):
364
+ uid, description = get_short_info_from_event_dict(item)
365
+ logging.debug(
366
+ "Skipping event in categories that must not be synced: %s (UID: %s)",
367
+ description,
368
+ uid,
369
+ )
370
+ nosync_uids.append(uid)
371
+ continue
372
+
373
+ # Check for no-sync showAs values
374
+ show_as: str = item.get("showAs", "")
375
+ if show_as in config.get("nosync_showas", []):
376
+ logging.debug(
377
+ "Skipping event with showAs '%s' that must not be synced: %s (UID: %s)",
378
+ show_as,
379
+ description,
380
+ uid,
381
+ )
382
+ nosync_uids.append(uid)
383
+ continue
384
+
385
+ # Check for no-sync subject regex
386
+ subject: str = item.get("subject", "")
387
+ if any(
388
+ regex for regex in config.get("nosync_subject_regex", []) if re.search(regex, subject)
389
+ ):
390
+ uid, description = get_short_info_from_event_dict(item)
391
+ logging.debug(
392
+ "Skipping event with subject that must not be synced: %s (UID: %s)",
393
+ description,
394
+ uid,
395
+ )
396
+ nosync_uids.append(uid)
397
+ continue
398
+
399
+ # Skip events without UID
400
+ if not uid:
401
+ logging.warning("Skipping event without UID: %s", item)
402
+ continue
403
+
404
+ new_event: Event = create_icalendar_event(
405
+ item,
406
+ anonymize_email=config.get("anonymize_email", False),
407
+ private=config.get("private", False),
408
+ )
409
+ new_hash = hash_event(new_event)
410
+
411
+ # Check if event is unchanged. If so, skip it unless force is True
412
+ if uid in existing_hashes and existing_hashes[uid] == new_hash and not force:
413
+ logging.debug("Skipping unchanged event: %s", description)
414
+ skipped += 1
415
+ continue
416
+
417
+ try:
418
+ if not dry:
419
+ calendar.add_event(new_event.to_ical().decode("utf-8"))
420
+ if uid in existing_hashes:
421
+ logging.info("Updated event: %s", description)
422
+ updated += 1
423
+ else:
424
+ logging.info("Created event: %s", description)
425
+ created += 1
426
+ except Exception:
427
+ logging.exception("Failed to upload event %s (UID: %s)", description, uid)
428
+
429
+ # Optional deletion step
430
+ deleted = delete_missing_events(
431
+ calendar=calendar,
432
+ outlook_entries=outlook_entries,
433
+ delete_enabled=config.get("delete_missing", True),
434
+ nosync_uids=nosync_uids,
435
+ dry=dry,
436
+ )
437
+
438
+ logging.info(
439
+ "Done. Created: %d, Updated: %d, Skipped: %d, No-sync: %s, Deleted: %d",
440
+ created,
441
+ updated,
442
+ skipped,
443
+ len(nosync_uids),
444
+ deleted,
445
+ )
446
+
447
+
448
+ def main() -> None:
449
+ """Main entry point."""
450
+ parser = argparse.ArgumentParser(description="Sync Outlook JSON calendar to CalDAV")
451
+ parser.add_argument(
452
+ "-c",
453
+ "--config",
454
+ type=str,
455
+ required=True,
456
+ help="Path to the configuration file (default: config.json)",
457
+ )
458
+ parser.add_argument(
459
+ "-i",
460
+ "--calendar-input",
461
+ type=str,
462
+ required=True,
463
+ help="Path to the Outlook JSON calendar export file",
464
+ )
465
+ parser.add_argument(
466
+ "-f",
467
+ "--force",
468
+ action="store_true",
469
+ help="Force update of remote events even if they seem to be equal",
470
+ )
471
+ parser.add_argument(
472
+ "--reset-remote",
473
+ action="store_true",
474
+ help="Force deletion of all remote events in the destination calendar (use with caution!)",
475
+ )
476
+ parser.add_argument("-vv", "--debug", action="store_true", help="Enable DEBUG logging")
477
+ parser.add_argument("--dry", action="store_true", help="Dry run mode (no changes made)")
478
+ args = parser.parse_args()
479
+
480
+ # --------------------------------------------------
481
+ # HANDLE CONFIGURATION AND ARGUMENTS
482
+ # --------------------------------------------------
483
+ # Load configuration file
484
+ with open(args.config, encoding="utf-8") as f:
485
+ config = json.load(f)
486
+
487
+ # Update config with command line arguments
488
+ config["outlook_calendar_file"] = args.calendar_input
489
+
490
+ # Configure logging (file + console)
491
+ configure_logger(config.get("log_file", ""), args.debug)
492
+
493
+ # --------------------------------------------------
494
+ # RESET REMOTE CALENDAR
495
+ # --------------------------------------------------
496
+ if args.reset_remote:
497
+ if ask_user_yes_no(
498
+ "Do you really want to DELETE ALL EVENTS in the remote calendar? "
499
+ "This action cannot be undone."
500
+ ):
501
+ calendar = connect_to_caldav(config)
502
+ remote_events = cast("list[CalDAVEvent]", calendar.events())
503
+ for e in remote_events:
504
+ try:
505
+ uid, description = get_short_info_from_event_dict(
506
+ event=caldav_event_to_dict(e.icalendar_component),
507
+ title_key="SUMMARY",
508
+ start_key="DTSTART",
509
+ )
510
+ if not args.dry:
511
+ e.delete()
512
+ logging.info("Deleted event: %s (UID: %s)", description, uid)
513
+ except Exception as err: # noqa: BLE001
514
+ logging.warning("Skipping broken event %s: %s", e, err)
515
+ logging.info("Remote calendar reset complete.")
516
+ return
517
+
518
+ # --------------------------------------------------
519
+ # SYNC CALENDAR
520
+ # --------------------------------------------------
521
+ # Set time window, based on midnight UTC today
522
+ now = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
523
+ past = now - timedelta(days=config.get("dav_past_days", 3))
524
+ future = now + timedelta(days=config.get("dav_future_days", 365))
525
+ logging.info("Sync time window: %s to %s", past.isoformat(), future.isoformat())
526
+
527
+ calendar = connect_to_caldav(config)
528
+ existing_hashes = get_existing_event_hashes(calendar, past, future)
529
+ sync_events(config, calendar, existing_hashes, dry=args.dry, force=args.force)
530
+
531
+
532
+ if __name__ == "__main__":
533
+ main()
@@ -0,0 +1,65 @@
1
+ [project]
2
+ name = "outlook-caldav-sync"
3
+ version = "0.2.0"
4
+ description = "A Python tool to sync calendar events from an Outlook JSON export to a CalDAV server"
5
+ authors = [{ name = "Max Mehl", email = "mail@mehl.mx" }]
6
+ requires-python = ">=3.12 ,<4.0"
7
+ readme = "README.md"
8
+ classifiers = [
9
+ "Programming Language :: Python :: 3",
10
+ "Programming Language :: Python :: 3.12",
11
+ "Programming Language :: Python :: 3.13",
12
+ "Programming Language :: Python :: 3.14",
13
+ ]
14
+ dependencies = [
15
+ "icalendar>=7.2.0,<7.3.0",
16
+ "caldav>=3.2.1,<3.3.0",
17
+ ]
18
+
19
+ [project.scripts]
20
+ outlook-caldav-sync = "outlook_caldav_sync.main:main"
21
+
22
+ [tool.uv.build-backend]
23
+ module-name = ["outlook_caldav_sync"]
24
+ module-root = ""
25
+
26
+ [build-system]
27
+ requires = ["uv_build>=0.11.6,<0.12.0"]
28
+ build-backend = "uv_build"
29
+
30
+ [dependency-groups]
31
+ dev = [
32
+ "ruff>=0.15.10",
33
+ "ty>=0.0.29",
34
+ "types-requests>=2.32.4.20250913,<3",
35
+ "pytest>=9.1.1,<10",
36
+ ]
37
+
38
+ [tool.uv]
39
+ default-groups = "all"
40
+
41
+ # FORMATTING and LINTING settings
42
+ [tool.ruff]
43
+ line-length = 100
44
+
45
+ [tool.ruff.lint]
46
+ select = ["ALL"]
47
+ ignore = [
48
+ "T",
49
+ "FBT",
50
+ "LOG015",
51
+ "COM812",
52
+ "ERA001",
53
+ "S310",
54
+ "D205",
55
+ "D212",
56
+ "PTH123",
57
+ ]
58
+ [tool.ruff.lint.extend-per-file-ignores]
59
+ "tests/*" = ["S101", "PLR", "PT011", "ANN001", "PT019"]
60
+ [tool.ruff.lint.pydocstyle]
61
+ convention = "google"
62
+
63
+ # TY settings
64
+ [tool.ty.src]
65
+ include = ["outlook_caldav_sync"]