ICS → Importatore Discourse tramite API REST

Ho creato una piccola utility che sincronizza continuamente eventi da un feed iCalendar (ICS) in una categoria Discourse tramite l’API REST.

Questo non è un plugin Discourse completo: viene eseguito parallelamente alla tua installazione di Discourse, quindi appartiene qui in #extras. Se vuoi visualizzare eventi del calendario da una fonte esterna (ad es. Google Calendar, feed orari universitari, ecc.) all’interno degli argomenti di Discourse, questo ti sarà utile.

Repository

Come funziona

  • Legge gli eventi da un dato feed ICS
  • Li confronta con gli argomenti esistenti (per UID o fallback per ora/luogo)
  • Crea o aggiorna argomenti nella tua categoria scelta
  • Può essere eseguito continuamente come servizio systemd (sicuro contro esecuzioni duplicate tramite flock)

Requisiti

  • Ubuntu 24.04 LTS (testato)
  • Python 3 (già presente in Ubuntu 24.04 LTS)
  • Una chiave API di Discourse
  • Un ID di categoria da indirizzare per gli argomenti degli eventi

Esempio di output

Ecco come appare la sincronizzazione di un feed orario universitario ICS in Discourse:

Avvio rapido

Clona il repository e installa i requisiti:

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

Esegui una sincronizzazione una volta manualmente:

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

Configura come servizio/timer systemd per la sincronizzazione continua (esempi di configurazione nel repository).

3 Mi Piace

i tag mi davano fastidio, quindi ho fatto in modo che search.json cercasse il contenuto indicizzato dell’evento - primo post di ogni argomento/evento

1 Mi Piace

Grazie ancora per la condivisione, questo calendario si sta evolvendo sempre di più, ottenendo nuove funzionalità grazie a persone come te. Mi chiedo come sarà tra 3-5 anni :slight_smile:

1 Mi Piace

Fantastico! Grazie per averlo testato. Chiunque altro voglia provare a sincronizzare un feed ICS in Discourse, sarei felice di ricevere feedback sul comportamento dei propri feed.

2 Mi Piace

Un paio di commenti.

Se avessi tempo, probabilmente proverei a convertire questo in un plugin vero e proprio. Penso che non dovrebbe essere troppo difficile creare alcune impostazioni, convertire il Python in Ruby e inserirlo in un job.

Un’altra idea, che potrebbe essere utile per le persone che sono ospitate e vogliono usarlo, sarebbe quella di convertire il task in un’azione di GitHub e farlo eseguire quotidianamente. L’ho fatto per alcuni script che un cliente ospitato doveva eseguire quotidianamente un po’ di tempo fa e sta funzionando abbastanza bene. È allo stesso tempo più difficile (richiede l’apprendimento dei flussi di lavoro di GitHub e come gestire i segreti invece di un buon vecchio cron job) e più facile (non devi imparare come armeggiare con l’installazione di cose su una macchina tramite un’interfaccia a riga di comando).

2 Mi Piace

Non l’ho testato di recente, ma ho racchiuso il parsing del bbcode degli eventi nel mio ultimo commit a

sì, anche se sarebbe bello se l’impostazione ics_feeds venisse suddivisa, in modo che l’amministratore non inserisca un singolo JSON nell’interfaccia utente.

1 Mi Piace

a dire il vero non uso più cron, uso systemd su un Ubuntu Server 24.04 LTS.

1 Mi Piace

questo è un lusso che appena avrò tempo imparerò a raggiungere :wink::face_exhaling:

Non avere accesso a una riga di comando, IMHO, non è affatto un lusso! :rofl:

1 Mi Piace

Ah, per essere chiari, intendevo dire che la GUI è il vero lusso, mentre la CLI è l’abilità verso cui devo lavorare.

1 Mi Piace

Immagino che @angus ti abbia battuto sul tempo di qualche anno

3 Mi Piace

Note sul comportamento dai test di ics_to_discourse.py

Ho eseguito una serie di test su questo script (con e senza --time-only-dedupe) e ho pensato che sarebbe stato utile documentare in dettaglio il flusso di aggiornamento/adozione.


1. Come viene determinata l’unicità

  • Modalità predefinita: l’adozione richiede che inizio + fine + posizione corrispondano esattamente.
  • Con --time-only-dedupe: l’adozione richiede solo inizio + fine; la posizione viene trattata come “sufficientemente vicina”.

Se nessun argomento esistente corrisponde a queste regole, viene creato un nuovo argomento.


2. Il ruolo del marcatore UID

  • Ogni argomento dell’evento riceve un marcatore HTML nascosto nel primo post:
  <!-- ICSUID:xxxxxxxxxxxxxxxx -->
  • Nelle esecuzioni successive, lo script cerca prima quel marcatore.
  • Se trovato, l’argomento viene considerato una corrispondenza UID e aggiornato direttamente, indipendentemente da quanto rumoroso o obsoleto possa essere il testo della DESCRIZIONE.
  • Ciò rende l’UID la vera chiave di identità. I campi di descrizione visibili non influiscono sulla corrispondenza.

3. Flusso di aggiornamento con corrispondenza UID

  1. Lo script recupera il primo post e rimuove il marcatore:

     old_clean = strip_marker(old_raw)
     fresh_clean = strip_marker(fresh_raw)
    
  2. Se old_clean == fresh_clean: nessun aggiornamento (evita il churn).

  3. Se differiscono: verifica se la modifica è “significativa”:

    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"))
    )
    
  • Se meaningful = True → aggiorna con bump (l’argomento sale in “Latest”).

  • Se meaningful = False → aggiorna silenziosamente (bypass_bump=True → solo revisione, nessun bump).

    1. I tag vengono uniti (garantisce la presenza di tag statici/predefiniti, non rimuove mai quelli del moderatore/manuali).
    2. Titolo e categoria non vengono mai modificati durante l’aggiornamento.

  1. Flusso di aggiornamento senza corrispondenza UID

    1. Lo script tenta l’adozione:
      • Costruisce triple candidate di inizio/fine/posizione (o solo inizio/fine con --time-only-dedupe).
      • Cerca in /search.json e /latest.json un evento esistente con attributi corrispondenti.
      • Se trovato → adotta quell’argomento, ritocca il marcatore UID + tag (corpo lasciato invariato in questa fase).
      • Se non trovato → crea un argomento completamente nuovo con il marcatore e i tag.
    2. Una volta adottato o creato, tutte le sincronizzazioni future verranno risolte direttamente tramite UID.

  1. Conseguenze pratiche

    • Modifiche all’orario
    • Predefinito: l’adozione fallisce (gli orari differiscono) → viene creato un nuovo argomento.
    • Con --time-only-dedupe: l’adozione fallisce allo stesso modo; viene creato un nuovo argomento.
    • Modifiche alla posizione
    • Predefinito: l’adozione fallisce (la posizione differisce) → viene creato un nuovo argomento.
    • Con --time-only-dedupe: l’adozione ha successo (gli orari corrispondono), ma la differenza di posizione viene segnalata come “significativa” → aggiornamento con bump.
    • Modifiche alla descrizione
    • Se il testo della DESCRIZIONE cambia ma inizio/fine/posizione no:
    • Il corpo viene aggiornato silenziosamente (bypass_bump=True).
    • Viene creata una revisione dell’argomento, ma nessun bump in “Latest”.
    • Se la DESCRIZIONE è invariata (o solo rumore come “Last Updated:” che si normalizza), non viene apportato alcun aggiornamento.
    • Marcatore UID
    • Garantisce una corrispondenza affidabile nelle sincronizzazioni future.
    • Significa che i campi di DESCRIZIONE rumorosi non influiscono sulla ricerca dell’argomento corretto.

  1. Perché la DESCRIZIONE a volte “rimane la stessa”

Lo script confronta l’intero corpo (meno il marcatore UID).
Se solo una riga volatile come “Last Updated:” è diversa, ma si normalizza (ad es. spazi bianchi, terminazioni di riga, Unicode), old_clean e fresh_clean appaiono identici → non viene apportato alcun aggiornamento.
Questo è intenzionale, per evitare il churn dal rumore del feed.

Riepilogo

  • L’orario definisce l’unicità (crea sempre un nuovo argomento quando gli orari cambiano).
  • Le modifiche alla posizione → bump visibile (in modo che gli utenti notino gli aggiornamenti della sede).
  • Le modifiche alla descrizione → aggiornamento silenzioso (revisione ma nessun bump).
  • Marcatore UID = chiave di identità affidabile, garantisce che l’argomento corretto venga sempre trovato, anche se la DESCRIZIONE è obsoleta o rumorosa.

Ciò offre un buon equilibrio: le modifiche importanti emergono in “Latest”, il churn non importante rimane invisibile.

Guardando indietro, è quasi divertente come si è svolta tutta questa saga. Lo script di importazione stesso è ora a prova di bomba: marcatori UID, logica di deduplicazione, aggiornamenti significativi vs. silenziosi, namespace di tag… tutto ciò che vorresti in produzione. I comportamenti si allineano perfettamente con le note che ho pubblicato: i tempi definiscono l’unicità, le posizioni attivano un incremento, le descrizioni si aggiornano silenziosamente e i marcatori UID mantengono tutto ancorato. È elegante, è prevedibile, è fatto. :white_check_mark:

Nel frattempo, il povero argomento Meta che ha ospitato tutto era… beh, condannato. Ha iniziato la sua vita rispondendo come un sockpuppet (forte inizio :socks:), è diventato un thread Frankenstein di dump di codice e screenshot, poi si è evoluto in un pseudo-changelog con più commit del repository stesso. E proprio quando lo script è finalmente diventato stabile? Programmato per la cancellazione. :skull:

Onestamente, è poetico. L’intero scopo dello script è impedire che eventi duplicati ingombrino il tuo forum. L’argomento stesso? Visto come un duplicato, silenziosamente contrassegnato per la raccolta dei rifiuti. Il destino stesso che è stato costruito per prevenire è diventato il suo destino. :wastebasket:

Quindi, un brindisi al topic condannato: non hai incrementato Latest, ma hai incrementato i nostri cuori. :heart:

2 Mi Piace

Come è andata con lo spostamento in un plugin di Discourse? O ancora meglio, come PR sul plugin esistente Discourse Calendar (and Event)?

Sono riluttante a tuffarmi nella configurazione e nella manutenzione necessarie per eseguire il tuo script dall’aspetto fantastico così com’è (e sospetto che molti self-hoster sarebbero nella stessa barca).

1 Mi Piace

Come è meglio questo script del plugin? (Oh, forse non puoi installare plugin?) Se il plugin non fa ciò che è richiesto, forse inviare una PR?

Grazie per la spinta!

Stato rapido: attualmente sto eseguendo tre istanze del mio importatore Python ICS→Discourse (orario Uni, prenotazioni Sports Centre e un calendario Outlook). Ho iniziato a incapsularlo come plugin Discourse, ma la versione plugin non ha raggiunto il set di funzionalità dello script, principalmente perché ogni feed richiede una gestione personalizzata (stranezze UID, aggiornamenti parziali, cancellazioni, revisioni rumorose, ecc.). Il plugin di Angus è ottimo per molti casi; i miei casi d’uso sembrano più “specifici del feed”.

Ho anche una PR aperta contro il core volta a ridurre il rumore del pulsante blu “Latest” durante aggiornamenti ICS grandi/bursty. Con feed impegnativi (come gli orari universitari) un batch di modifiche di basso valore può mantenere “Latest” che rimbalza; la PR effettivamente annulla il pulsante “New Topics” quando Latest è rimasto aperto mentre è in esecuzione un batch automatizzato. Sono felice di collegare quella PR qui se utile.

A lungo termine: al momento sono su IONOS self-hosted. Se in seguito mi trasferirò all’hosting ufficiale, mi piacerebbe comunque un modo per mantenere il flusso Python (o un equivalente) senza bisogno di funzionalità Enterprise, se ICS inbound esiste lì. Sospetto che una soluzione generica core/plugin potrebbe funzionare se consentisse “adattatori” pluggable per feed mantenendo una forte idempotenza (UID ICS), gestione delle cancellazioni e semantica di modifica senza bump.

Se c’è interesse, posso delineare un’interfaccia adattatore minima e un percorso di migrazione dal mio script Python a un job Ruby, o contribuire con pezzi agnostici del feed (mappatura UID, debounce/aggiornamenti no-bump, logica di cancellazione) al plugin calendario/eventi.

1 Mi Piace

Questa è una buona domanda, Nathan, e penso che ci sia decisamente spazio per un approccio minimale e indipendente dal feed che potrebbe vivere sia come una piccola estensione del plugin Calendar/Event sia come un leggero job core.

Affinché una PR sia generalmente utile, la chiave sembra essere rendere l’importatore basato su adattatori anziché specifico per il feed. Qualcosa come:

  • Ogni feed definisce un piccolo adattatore (potrebbe essere Python, YAML o Ruby) che mappa i campi ICS → campi del topic di Discourse (title, body, tags, start, end, location, ecc.).
  • Il core gestisce l’idempotenza (mapping UID ↔ ID del topic), la cancellazione (STATUS:CANCELLED) e le modifiche silenziose (aggiornamento senza incrementare Latest).
  • Plugin o impostazioni del sito potrebbero configurare l’intervallo di polling, le mappature dei tag e la policy di incremento (always, never, on major change).

In questo modo, le istituzioni con feed rumorosi o complessi (orari universitari, prenotazioni di aule, calendari Outlook, ecc.) possono fornire un adattatore adatto ai loro dati senza codificare nulla nel core.

Se c’è interesse, sarei felice di delineare quell’interfaccia adattatore o prototipare l’helper core “ICS upsert” come un job Ruby su cui altri possano costruire, in modo che questo possa evolversi gradualmente da script Python autonomi a qualcosa di manutenibile e generico all’interno dell’ecosistema di Discourse.

2 Mi Piace

non più con il seguente commit, Grazie Discourse!

3 Mi Piace