ICS → Discourse-importeur via REST API

I’ve built a small utility that continuously syncs events from an iCalendar (ICS) feed into a Discourse category via the REST API.

This isn’t a full Discourse plugin — it runs alongside your Discourse install — so it belongs here in #extras. If you want to display calendar events from an external source (e.g. Google Calendar, University timetable feeds, etc.) inside Discourse topics, this will be useful.

Repository

How it works

  • Reads events from a given ICS feed
  • Matches them against existing topics (by UID or fallback to time/location)
  • Creates or updates topics in your chosen category
  • Can run continuously as a systemd service (safe against duplicate execution via flock)

Requirements

  • Ubuntu 24.04 LTS (tested)
  • Python 3 (already in Ubuntu 24.04 LTS)
  • A Discourse API key
  • A category ID to target for event topics

Example output

Here’s what it looks like when syncing a University timetable ICS feed into Discourse:

Quick start

Clone the repo and install requirements:

git clone https://github.com/Ethsim12/Discourse-ICS-importer-by-REST-API.git /opt/ics-sync
cd /opt/ics-sync
pip install -r requirements.txt

Run a sync once manually:

python3 ics_to_discourse.py \
  --ics "https://example.com/feed.ics" \
  --category-id 4 \
  --site-tz "Europe/London" \
  --static-tags "events,ics"

Set up as a systemd service/timer for continuous sync (example configs in the repo).

3 likes

the tags were annoying me, so i made sure the search.json look for indexed content of the event - first post of each topic/event

1 like

Thank you again for the share, this calendar is evolving more and more, getting new features thanks to people like you. I wonder how it will be like in 3-5 years :slight_smile:

1 like

Briljant! Bedankt voor het testen. Iedereen die een ICS-feed wil synchroniseren met Discourse, ik hoor graag feedback of jouw feeds zich hetzelfde gedragen.

2 likes

Een paar opmerkingen.

Als ik tijd had, zou ik dit waarschijnlijk proberen om te zetten naar een fatsoenlijke plugin. Ik denk dat het niet al te moeilijk zou moeten zijn om wat instellingen te maken en de Python om te zetten naar Ruby en in een job te plaatsen.

Een ander idee, dat nuttig zou kunnen zijn voor mensen die gehost worden en dit willen gebruiken, zou zijn om de taak om te zetten naar een GitHub-actie en deze dagelijks te laten draaien. Ik heb dit een tijdje geleden gedaan voor enkele scripts die een gehoste klant dagelijks moest uitvoeren en het werkt redelijk goed. Het is tegelijkertijd moeilijker (het vereist het leren van GitHub-workflows en hoe om te gaan met geheimen in plaats van een goede oude cron-job) en gemakkelijker (je hoeft niet te leren hoe je dingen op een machine installeert via een command-line-interface).

2 likes

I haven’t tested it lately, but wrapped up the event bbcode parsing in my latest commit to

yes, though it would be nice if the ics_feeds setting were to be broken down, so the admin isn’t inputting a single JSON into UI

1 like

to be honest i don’t use cron now, i use systemd on a Ubuntu Server 24.04 LTS.

1 like

this is a luxury that as soon as i have the time i will learn to achieve :wink::face_exhaling:

Not having access to a command line is, IMHO, no luxury at all! :rofl:

1 like

Haha, om duidelijk te zijn, ik bedoelde dat de GUI de echte luxe is - CLI is de vaardigheid waar ik naartoe moet werken.

1 like

I guess @angus beat you to that by a few years

3 likes

Gedragsnotities van het testen van ics_to_discourse.py

Ik heb een reeks tests uitgevoerd op dit script (met en zonder --time-only-dedupe) en dacht dat het nuttig zou zijn om de update/adoptiestroom in detail te documenteren.


1. Hoe uniciteit wordt bepaald

  • Standaardmodus: adoptie vereist dat start + eind + locatie exact overeenkomen.
  • Met --time-only-dedupe: adoptie vereist alleen start + eind; locatie wordt behandeld als “dichtbij genoeg”.

Als geen bestaand onderwerp aan deze regels voldoet, wordt een nieuw onderwerp aangemaakt.


2. De rol van de UID-marker

  • Elk evenementonderwerp krijgt een verborgen HTML-marker in het eerste bericht:
  <!-- ICSUID:xxxxxxxxxxxxxxxx -->
  • Bij volgende uitvoeringen zoekt het script eerst naar die marker.
  • Indien gevonden, wordt het onderwerp beschouwd als een UID-match en direct bijgewerkt, ongeacht hoe ruisend of verouderd de BESCHRIJVING-tekst is.
  • Dit maakt de UID de ware identiteitssleutel. Zichtbare beschrijvingsvelden hebben geen invloed op de matching.

3. Update-stroom met UID-match

  1. Script haalt het eerste bericht op en verwijdert de marker:
 old_clean = strip_marker(old_raw)
 fresh_clean = strip_marker(fresh_raw)
  1. Als old_clean == fresh_clean: geen update (voorkomt churn).
  2. Als ze verschillen: controleer of de wijziging “betekenisvol” is:
meaningful = (
    _norm_time(old_attrs.get("start")) != _norm_time(new_attrs.get("start"))
    or _norm_time(old_attrs.get("end")) != _norm_time(new_attrs.get("end"))
    or _norm_loc(old_attrs.get("location")) != _norm_loc(new_attrs.get("location"))
)
  • Als meaningful = True → update met bump (onderwerp stijgt in Laatste).
  • Als meaningful = False → update stilzwijgend (bypass_bump=True → alleen revisie, geen bump).
  1. Tags worden samengevoegd (zorgt ervoor dat statische/standaardtags aanwezig zijn, verwijdert nooit moderator/handmatige tags).
  2. Titel en categorie worden nooit gewijzigd bij een update.

  1. Update-stroom zonder UID-match
  2. Script probeert adoptie:
    • Bouwt kandidaat-triples van start/eind/locatie (of alleen start/eind met --time-only-dedupe).
    • Zoekt in /search.json en /latest.json naar een bestaand evenement met overeenkomende attributen.
    • Indien gevonden → adopteer dat onderwerp, pas UID-marker + tags aan (body blijft in dit stadium ongewijzigd).
    • Indien niet gevonden → maak een gloednieuw onderwerp aan met de marker en tags.
  3. Zodra geadopteerd of aangemaakt, zullen alle toekomstige synchronisaties direct via UID worden opgelost.

  1. Praktische gevolgen
    • Tijdswijzigingen
    • Standaard: adoptie mislukt (tijden verschillen) → nieuw onderwerp aangemaakt.
    • Met --time-only-dedupe: adoptie mislukt op dezelfde manier; nieuw onderwerp aangemaakt.
    • Locatiewijzigingen
    • Standaard: adoptie mislukt (locatie verschilt) → nieuw onderwerp aangemaakt.
    • Met --time-only-dedupe: adoptie slaagt (tijden komen overeen), maar locatiewijziging wordt gemarkeerd als “betekenisvol” → update met bump.
    • Beschrijvingswijzigingen
    • Als de BESCHRIJVING-tekst verandert, maar start/eind/locatie niet:
    • Body wordt stilzwijgend bijgewerkt (bypass_bump=True).
    • Onderwerp revisie aangemaakt, maar geen bump in Laatste.
    • Als BESCHRIJVING ongewijzigd is (of alleen ruis zoals Laatst bijgewerkt: dat normaliseert weg), vindt er helemaal geen update plaats.
    • UID-marker
    • Zorgt voor betrouwbare matching bij toekomstige synchronisaties.
    • Betekent dat ruisende BESCHRIJVING-velden er niet toe doen of het juiste onderwerp wordt gevonden.

  1. Waarom de BESCHRIJVING soms “hetzelfde blijft”
    Het script vergelijkt de volledige body (minus de UID-marker).
    Als alleen een vluchtige regel zoals Laatst bijgewerkt: anders is, maar deze normaliseert weg (bv. witruimte, regeleinden, Unicode), lijken old_clean en fresh_clean identiek → er wordt geen update uitgevoerd.
    Dit is opzettelijk, om churn door feed-ruis te voorkomen.

Samenvatting
• Tijd definieert uniciteit (creëert altijd een nieuw onderwerp wanneer tijden veranderen).
• Locatiewijzigingen → zichtbare bump (zodat gebruikers venue-updates opmerken).
• Beschrijvingswijzigingen → stille update (revisie maar geen bump).
• UID-marker = betrouwbare identiteitssleutel, zorgt ervoor dat het juiste onderwerp altijd wordt gevonden, zelfs als de BESCHRIJVING verouderd of ruisend is.

Dit biedt een goede balans: belangrijke wijzigingen komen naar voren in Laatste, onbelangrijke churn blijft onzichtbaar.

Looking back, it’s kind of hilarious how this whole saga unfolded.
The importer script itself is now rock-solid: UID markers, dedupe logic, meaningful vs. quiet updates, tag namespaces… all the stuff you’d actually want in production. The behaviours line up perfectly with the notes i posted — times define uniqueness, locations trigger a bump, descriptions update quietly, and UID markers keep everything anchored. It’s elegant, it’s predictable, it’s done. :white_check_mark:

Meanwhile, the poor Meta topic that hosted it all was… well, doomed.
It began life replying as a sockpuppet (strong start :socks:), ballooned into a Frankenstein thread of code dumps and screenshots, then evolved into a pseudo-changelog with more commits than the repo itself. And just as the script finally became stable? Scheduled for deletion. :skull:

Honestly, it’s poetic. The script’s entire purpose is to stop duplicate events from cluttering up your forum. The topic itself? Seen as a duplicate, quietly marked for garbage collection. The very fate it was built to prevent became its destiny. :wastebasket:

So here’s to the doomed topic:
You didn’t bump Latest, but you bumped our hearts. :heart:

2 likes

How did you get on with moving it to a Discourse plugin? Or better yet, as a PR on the existing Discourse Calendar (and Event) Plugin?

I’m reluctant to jump into the config and maintenance required to run your awesome looking script as is (and suspect that many self-hosters would be in the same boat).

1 like

Hoe is dit script beter dan de plugin? (Oh, misschien kun je geen plugins installeren?) Als de plugin niet doet wat nodig is, misschien een PR indienen?

Bedankt voor de aanmoediging!

Snelle status: Ik draai momenteel drie instanties van mijn Python ICS→Discourse importer (Uni-lesrooster, Sports Centre-boekingen en een Outlook-agenda). Ik begon het in te pakken als een Discourse-plugin, maar de pluginversie voldeed niet aan de functionaliteit van het script — voornamelijk omdat elke feed specifieke afhandeling nodig heeft (UID-eigenaardigheden, gedeeltelijke updates, annuleringen, ruisende revisies, enz.). Angus’ plugin is geweldig voor veel gevallen; mijn gebruiksscenario’s lijken meer “feed-specifiek”.

Ik heb ook een openstaande PR tegen de kern die gericht is op het verminderen van de ruis van de blauwe knop “Latest” tijdens grote/plotselinge ICS-updates. Bij drukke feeds (zoals universitaire lesroosters) kan een batch met bewerkingen van lage waarde “Latest” laten stuiteren; de PR schakelt effectief de knop “New Topics” uit wanneer Latest open heeft gestaan terwijl een geautomatiseerde batch draait. Ik kan die PR hier graag cross-linken als dat nuttig is.

Lange termijn: Ik draai momenteel op zelf-gehoste IONOS. Als ik later naar officiële hosting ga, zou ik nog steeds graag een manier willen hebben om de Python-flow (of een equivalent) te behouden zonder Enterprise-functies nodig te hebben, als ICS inbound daar bestaat. Ik vermoed dat een generieke kern/plugin-oplossing zou kunnen werken als het plugbare “adapters” per feed toestaat, terwijl sterke idempotentie (ICS UID), annuleringafhandeling en bewerken-zonder-bump-semantiek behouden blijven.

Als er interesse is, kan ik een minimale adapter-interface schetsen en een migratiepad van mijn Python-script naar een Ruby-taak, of feed-agnostische onderdelen (UID-mapping, debounce/no-bump-updates, annuleringslogica) bijdragen aan de kalender/evenementen-plugin.

1 like

Dat is een goede vraag, Nathan — en ik denk dat er zeker ruimte is voor een minimale, feed-agnostische aanpak die ofwel als een kleine uitbreiding op de Calendar/Event-plugin kan leven, of als een lichtgewicht core job.

Om een PR algemeen bruikbaar te maken, lijkt de sleutel te liggen in het maken van de importer adapter-gebaseerd in plaats van feed-specifiek. Iets als:

  • Elke feed definieert een kleine adapter (kan Python, YAML of Ruby zijn) die ICS-velden toewijst aan Discourse-topicvelden (title, body, tags, start, end, location, etc.).
  • Core beheert idempotentie (UID ↔ topic ID mapping), annulering (STATUS:CANCELLED), en stille bewerkingen (update zonder de Latest te bumpen).
  • Plugins of site-instellingen kunnen het polling-interval, tag-mappings en de bump-policy (always, never, on major change) configureren.

Op die manier kunnen instellingen met ruisige of complexe feeds (universiteitstijdschema’s, kamerboekingen, Outlook-agenda’s, etc.) een adapter bieden die geschikt is voor hun gegevens, zonder iets in de core te hardcoderen.

Als er interesse is, schets ik graag die adapterinterface of prototypeer ik de core “ICS upsert”-helper als een Ruby-job waar anderen op kunnen voortbouwen — zodat dit geleidelijk kan evolueren van standalone Python-scripts naar iets onderhoudbaars en algemeens binnen het ecosysteem van Discourse.

2 likes

no longer with the following commit, Thanks Discourse!

3 likes