calendar-sync is a flexible utility to sync one or more ICS feeds (iCalendar) into a CalDAV-compatible calendar — ideal for mailbox.org, Nextcloud, Synology, and more.
It supports features such as:
- ✅ Deterministic UID generation for clean deduplication
- 📅 Emoji mapping for more readable calendar events
- 🔁 Automatic expansion of
RRULE:FREQ=YEARLYevents - 🔁 Full support for recurring events (e.g., yearly holidays) and custom extra events (Mother's Day, Advent Sundays, etc.)
- 🧼 Cleanup mode with multi-prefix support (
--cleanup PREFIX1,PREFIX2) - 📍 Location-based filtering (e.g., for regional holidays in Austria)
- 🐳 Docker deployment for simple automation
- 💡 Dry-run mode to preview changes without writing
- 🕓 Timezone-aware handling for accurate scheduling
This is perfect for importing:
- 🗑️ Municipal waste collection schedules (e.g., Müll App)
- 🇦🇹 Austrian public holidays
- 🏎️ Formula 1 calendar with free practice, qualifying, and GP events
Unlike subscription-based ICS calendars, this tool writes events directly into your calendar, giving you full control over notifications, offline visibility, and data retention.
Use it on your Synology NAS, a server, or as a cron-triggered Docker container — and never miss a bin collection or Grand Prix again.
calendar-sync.mp4
🍺 Please support me: Although all my software is free, it is always appreciated if you can support my efforts on Github with a contribution via Paypal - this allows me to write cool projects like this in my personal time and hopefully help you or your business.
- 🔁 Sync multiple ICS feeds to any CalDAV calendar
- 🧠 Deterministic UID generation & deduplication
- 🔁 Automatic expansion of YEARLY recurring events
- 📍 Location-based filtering for region-specific holidays
- 🧹 Optional cleanup of old imported events
- 📅 Supports emoji mapping for event names
- 🛑 Dry run mode to test before writing
- 🐳 Docker support for simple deployment
# Default config file (config.json)
python src/calendar_sync.py --import
python src/calendar_sync.py --import --dry-run
python src/calendar_sync.py --cleanup # cleans global prefix
python src/calendar_sync.py --cleanup MUELL-,F1- # cleans multiple prefixes
# Custom config file
python src/calendar_sync.py --import --config /path/to/another_config.json
python src/calendar_sync.py --import --dry-run --config /path/to/another_config.json
python src/calendar_sync.py --cleanup --config /path/to/another_config.jsonFirst, build the container:
docker-compose buildThen run the sync:
# Default config file
docker-compose run --rm calendar-sync --import
docker-compose run --rm calendar-sync --import --dry-run
docker-compose run --rm calendar-sync --cleanup
# Custom config file (mount the config file into the container)
docker-compose run --rm -v /path/to/another_config.json:/app/config.json calendar-sync --import
docker-compose run --rm -v /path/to/another_config.json:/app/config.json calendar-sync --import --dry-run
docker-compose run --rm -v /path/to/another_config.json:/app/config.json calendar-sync --cleanupgit clone https://github.com/magicdude4eva/calendar-sync.git
cd calendar-sync
python3.14 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txtYou can use multiple config files to manage different calendars. By default, the script uses config.json. To use a different config file, pass it via the --config argument:
python src/calendar_sync.py --import --config /path/to/another_config.jsonExample config:
{
"caldav_url": "https://dav-sso.mailbox.org/caldav/...",
"username": "your@email.com",
"password": "your-app-password",
"timezone": "Europe/Vienna",
"uid_prefix": "ICS-",
"future_event_limit_days": 365,
"ics_feeds": [
{
"url": "https://example.com/my.ics",
"uid_prefix": "EXAMPLE-",
"emoji_mapping": {
"Papier": "♻️",
"default": "📦"
},
"default_reminder": "1d"
}
]
}- Fetches events from each configured ICS feed
- Normalizes dates and checks if the UID exists
- Skips, adds, or replaces events as needed
- Uses emoji mappings to prefix event names
- All-day events are handled properly (no time zone shift)
- Recurring
RRULE:FREQ=YEARLYevents are expanded into individual years - Events can be filtered by
LOCATIONusingimport_locations
🗺️ For import_locations, configure it per feed. For example:
{
"url": "https://www.feiertage-oesterreich.at/kalender-download/ics/feiertage-oesterreich.ics",
"import_locations": "K,St,V",
"emoji_mapping": {
"§": "🇦🇹",
"default": "🗓️"
}
}To discover valid locations, run the sync once and check the logs. Example:
INFO: ⏭️ Skipping 'St. Florian' (2025-05-04) due to unmatched location: OÖ
Emoji mapping allows you to prefix event titles with emojis for better visual organization in your calendar. This is particularly useful when syncing multiple ICS feeds to distinguish between different event types at a glance.
How it works:
The emoji_mapping object in your feed configuration uses event title matching to determine which emoji to prepend to an event:
- Exact key matching: The script checks if any key in the
emoji_mappingobject matches part of the event's title - Prepending: When a match is found, the corresponding emoji is prepended to the event title
- Fallback: If the event title doesn't match any configured keys, the
"default"emoji is used - Case-sensitive: Matching is case-sensitive
Key points:
- The
emoji_mappingobject accepts any string as a key (words, phrases, special characters, etc.) - The
"default"key is a special reserved field that serves as a fallback emoji when no other keys match - Values must be emojis or text that will be prepended to the event title
- You can configure multiple mappings per feed
Examples:
{
"url": "https://example.com/waste.ics",
"emoji_mapping": {
"Papier": "♻️",
"Plastik": "🟡",
"Glas": "🟢",
"Restmüll": "⚫",
"default": "🗑️"
}
}With the above configuration, an event titled "Papier Collection" would become "♻️ Papier Collection", while an unknown event type would become "🗑️ Unknown Event".
Another example (Austrian holidays):
{
"url": "https://www.feiertage-oesterreich.at/kalender-download/ics/feiertage-oesterreich.ics",
"emoji_mapping": {
"Ostern": "🐰",
"Weihnachten": "🎄",
"Neujahr": "🎆",
"§": "🇦🇹",
"default": "🗓️"
}
}Categories allow you to organize and filter events in your calendar client.

The categories configuration provides comprehensive control over how event categories are handled:
The categories object in your feed configuration supports multiple operations:
| Option | Type | Description | Example |
|---|---|---|---|
append |
Array | Add categories to the end of existing ones | ["Imported", "Synced"] |
prepend |
Array | Add categories to the beginning of existing ones | ["Custom"] |
remove |
Array | Remove unwanted categories | ["SPAM", "ADVERTISING"] |
replace_if_empty |
Array | Replace all categories if event has none | ["Default"] |
deduplicate |
Boolean | Remove duplicate categories (default: true) |
true or false |
- Upstream categories are preserved from the ICS feed
- Custom categories are added based on your configuration
- Filtering removes unwanted categories
- Deduplication ensures each category appears only once
- Start with upstream categories from the ICS feed (if any)
- Apply
replace_if_emptyif event has no categories and this is configured - Apply
prependcategories (added to beginning) - Apply
appendcategories (added to end) - Apply
removefiltering - Deduplicate if enabled
Example 1: Adding custom categories
{
"url": "https://example.com/my.ics",
"categories": {
"append": ["Imported", "Synced"]
}
}Result: Events get their original categories + "Imported" and "Synced"
Example 2: Filtering unwanted categories
{
"url": "https://example.com/third-party.ics",
"categories": {
"append": ["Imported"],
"remove": ["SPAM", "ADVERTISING", "Third-Party"]
}
}Result: Events get original categories + "Imported", minus any unwanted ones
Example 3: Replacing empty categories
{
"url": "https://example.com/empty-categories.ics",
"categories": {
"replace_if_empty": ["Default Category"],
"append": ["Synced Events"]
}
}Result: Events with no categories get ["Default Category", "Synced Events"]
Example 4: Prepending categories
{
"url": "https://example.com/priority.ics",
"categories": {
"prepend": ["High Priority", "Important"]
}
}Result: Events get ["High Priority", "Important"] + original categories
Example 5: Complete control
{
"url": "https://example.com/clean.ics",
"categories": {
"prepend": ["Custom"],
"append": ["Imported"],
"remove": ["SPAM"],
"deduplicate": true
}
}Result: Full control over category composition with deduplication
- Identify synced events: Add "Imported" or "Synced" to all events
- Filter spam: Remove unwanted categories like "SPAM" or "ADVERTISING"
- Organize by type: Add categories like "Holidays", "Sports", "Work"
- Clean up third-party feeds: Remove feed-specific spam categories
- Standardize categories: Ensure consistent categorization across feeds
Many calendar clients (Thunderbird, Evolution, etc.) support category-based coloring:
- Events with the same category share the same color
- You can configure your client to assign specific colors to specific categories
- This provides visual organization without modifying the event data
Note: Color assignment is client-specific and not part of the CalDAV standard. See your calendar client's documentation for details.
Example Thunderbird setup:
- Go to Calendar Properties
- Select "Categories" tab
- Assign colors to each category
- Events will automatically use those colors
Reference: Thunderbird Calendar Categories
You can assign colors to individual events using the calendar_color property in your feed configuration. This follows the RFC 7986 standard for iCalendar color properties. For supported colors in refer to CSS Color Names.
Add calendar_color to any feed configuration:
{
"url": "https://example.com/my.ics",
"calendar_color": "#FF5733", // Orange-red color
"categories": {
"append": ["Important"]
}
}| Format | Example | Description |
|---|---|---|
| Hex (3-digit) | #RGB |
#F00 for red |
| Hex (6-digit) | #RRGGBB |
#FF5733 for orange-red |
| Color name | red, blue, green |
Standard color names |
{
"ics_feeds": [
{
"url": "https://app.muellapp.com/ical/74418",
"calendar_color": "#4CAF50", // Green for waste collection
"categories": {
"append": ["Waste Collection", "Municipal"]
}
},
{
"url": "https://feiertage-oesterreich.at/kalender-download/ics/feiertage-oesterreich.ics",
"calendar_color": "#FF5733", // Orange for holidays
"categories": {
"prepend": ["Austrian"]
}
},
{
"url": "https://better-f1-calendar.vercel.app/api/calendar.ics",
"calendar_color": "#2196F3", // Blue for F1 racing
"categories": {
"append": ["Sports", "Racing"]
}
}
]
}- The
calendar_colorproperty is added to each event from the feed - The color is embedded in the iCalendar data (RFC 7986 compliant)
- Modern CalDAV clients that support RFC 7986 will display these colors
- The color assignment is per-event and can vary by feed
- ✅ Thunderbird: Supports RFC 7986 color property
- ✅ Evolution: Supports RFC 7986 color property
- ✅ Nextcloud Calendar: Supports RFC 7986 color property
- ✅ mailbox.org: Supports RFC 7986 color property
⚠️ Some mobile clients: May not support RFC 7986 yet
Note: Even if a client doesn't support RFC 7986, the color information is stored in the event and won't cause issues.
If your CalDAV client doesn't support RFC 7986 color properties, the events will still be created normally. The color information will be embedded in the event data but may be ignored by the client.
- Visual organization: Different feeds use different colors
- Quick identification: Waste collection vs holidays vs sports events
- Calendar segmentation: Each type of event has its own color
- Client-side filtering: Use colors to identify event categories
- Color assignment is client-dependent
- Not all CalDAV clients support RFC 7986 yet
- Colors may not be visible in all calendar applications
- The color property is additional metadata, not required for functionality
The default_reminder field specifies when you should receive a notification for imported events. It uses a duration format with unit suffixes:
| Format | Meaning |
|---|---|
15m |
15 minutes before the event |
1h |
1 hour before the event |
1d |
1 day before the event |
2d |
2 days before the event |
1w |
1 week before the event |
m= minutesh= hoursd= daysw= weeks
Examples:
{
"ics_feeds": [
{
"url": "https://example.com/my.ics",
"default_reminder": "15m" // Notify 15 minutes before
},
{
"url": "https://example.com/holidays.ics",
"default_reminder": "1d" // Notify 1 day before
},
{
"url": "https://example.com/formula1.ics",
"default_reminder": "2h" // Notify 2 hours before
}
]
}The script automatically expands ICS events with RRULE:FREQ=YEARLY rules into individual event instances for each year, up to the configured future limit (future_event_limit_days). This ensures recurring events like public holidays or anniversaries are correctly synced across multiple years.
Behavior:
- Detects yearly recurring events by scanning raw
RRULEdata. - Expands the base event for each year (e.g. from 2025 to 2026).
- Skips events in the past or beyond the future limit.
- Deduplicates intelligently using UID hashing per year.
In addition to ICS feeds, you can define your own custom events using the extra_events entry in config.json.
This allows you to add things like:
- 🌷 Mother's Day (2nd Sunday of May)
- 👨👧👦 Father's Day (2nd Sunday of June)
- 🔥 Summer Solstice (21st June)
- 🎃 Halloween (31st October)
- 🕯️ Advent Sundays
- 🧾 Tax Deadlines
- ☀️ Daylight Saving Time changes
Supported Formats:
| Format | Description | Example |
|---|---|---|
N.Weekday.Month |
Nth weekday of a month | 2.Sunday.5 → 2nd Sunday in May |
-N.Weekday.Month |
Nth weekday from end of month | -1.Sunday.3 → last Sunday in March |
DD.MM.fixed |
Fixed date | 31.10.fixed → 31st October |
Sample:
"extra_events": [
"☀️ Sommerzeit beginnt:-1.Sunday.3",
"🌷 Muttertag:2.Sunday.5",
"👨👧👦 Vatertag:2.Sunday.6",
"🔥 Sonnwendfeier:21.6.fixed",
"🧾 Steuererklärung:30.6.fixed",
"🌒 Sommerzeit endet:-1.Sunday.10",
"🎃 Halloween:31.10.fixed",
"🕯️ 1. Advent:-4.Sunday.12",
"🕯️ 2. Advent:-3.Sunday.12",
"🕯️ 3. Advent:-2.Sunday.12",
"🕯️ 4. Advent:-1.Sunday.12",
"👹 Krampusnacht:5.12.fixed",
"🎅 Nikolaus:6.12.fixed"
],Add --dry-run to see what would happen without making changes:
docker-compose run --rm calendar-sync --import --dry-runcalendar-sync/
├── src/
│ ├── calendar_sync.py # Entry script
│ └── utils.py # Core sync logic
├── config.json # Configuration
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
└── README.md
Go to Settings → Security → Application Passwords
Select Calendar and Addressbook Client (CalDAV/CardDAV)

Go to the Calendar section → click + Add new calendar

Right-click your new calendar → Properties → Copy the URL

This project is licensed under the MIT License.
PRs welcome! File issues or ideas via GitHub.
🍻 Support my work
All my software is free and built in my personal time. If it helps you or your business, please consider a small donation via PayPal — it keeps the coffee ☕ and ideas flowing!
💸 Crypto Donations
You can also send crypto to one of the addresses below:
(BTC) bc1qdgdkk7l98pje8ny9u4xavsvrea8dw6yu8jpnyf
(ETH) 0x5986f713A538D6bCaC0865564dCD45E2600A3469
(POL) 0x5986f713A538D6bCaC0865564dCD45E2600A3469
(CRO) 0xb83c3Fe378F5224fAdD7a0f8a7dD33a6C96C422C (Cronos or Crypto.com Paystring magicdude$paystring.crypto.com)
(BNB) 0x5986f713A538D6bCaC0865564dCD45E2600A3469
(LTC) ltc1qexst2exxksfyg7erfzlfrm23twkjgf7e5fn64t
(DOGE) DMQsxc9XGF6526drBJDZeX7AjFDJsEz4mN
(SOL) t4bYQCUuoCUrp7kJ4Mz314npcTuKoUSXj28UgdMrfTb
🧾 Recommended Platforms
- 👉 Curve.com: Add your Crypto.com card to Apple Pay
- 🔐 Crypto.com: Stake and get your free Crypto Visa card
- 📈 Binance: Trade altcoins easily