ICS → מייבא Discourse דרך ה-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 לייקים

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

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

מצוין! תודה על הבדיקה. כל מי שרוצה לנסות לסנכרן פיד ICS לתוך Discourse, אשמח לקבל משוב אם הפידים שלכם מתנהגים באותו אופן.

2 לייקים

כמה הערות.

אם היה לי קצת זמן, כנראה שהייתי מנסה להמיר את זה לתוסף אמיתי. אני חושב שלא יהיה קשה מדי ליצור כמה הגדרות ולהמיר את הפייתון לרובי ולשים את זה במשימה.

רעיון נוסף, שיכול להיות שימושי עבור אנשים שמארחים ורוצים להשתמש בזה, יהיה להמיר את המשימה לפעולת גיטהאב ולגרום לה להריץ את המשימה מדי יום. עשיתי זאת עבור כמה סקריפטים שלקוח מארח היה צריך להריץ מדי יום לפני זמן מה וזה עובד די טוב. זה קשה יותר (זה דורש ללמוד זרימות עבודה של גיטהאב ואיך להתמודד עם סודות במקום משימת cron ישנה וטובה) וקל יותר (אתה לא צריך ללמוד איך להתעסק עם התקנת דברים על מכונה דרך ממשק שורת פקודה).

2 לייקים

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

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

לייק 1

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

חחח, למען הבהירות, התכוונתי ש-GUI הוא המותרות האמיתית - CLI הוא המיומנות שאני צריך לשאוף אליה.

לייק 1

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

3 לייקים

הערות התנהגות מבדיקות של ics_to_discourse.py

הרצתי סדרת בדיקות על הסקריפט הזה (עם ובלי --time-only-dedupe) וחשבתי שיהיה שימושי לתעד את זרימת העדכון/אימוץ בפירוט.


1. כיצד נקבעת ייחודיות

  • מצב ברירת מחדל: אימוץ דורש התאמה מדויקת של התחלה + סיום + מיקום.
  • עם --time-only-dedupe: אימוץ דורש רק התחלה + סיום; המיקום נחשב כ"קרוב מספיק".

אם לא נמצא נושא קיים התואם לכללים אלה, נוצר נושא חדש.


2. תפקיד סמן ה-UID

  • כל נושא אירוע מקבל סמן HTML נסתר בפוסט הראשון:
  <!-- ICSUID:xxxxxxxxxxxxxxxx -->
  • בהרצות עוקבות, הסקריפט מחפש תחילה את הסמן הזה.
  • אם נמצא, הנושא נחשב כהתאמת UID ומעודכן ישירות, ללא קשר לכמה רועש או מיושן עשוי להיות טקסט ה-DESCRIPTION.
  • זה הופך את ה-UID למפתח הזהות האמיתי. שדות תיאור גלויים אינם משפיעים על ההתאמה.

3. זרימת עדכון עם התאמת UID

  1. הסקריפט מאחזר את הפוסט הראשון ומסיר את הסמן:
old_clean = strip_marker(old_raw)
fresh_clean = strip_marker(fresh_raw)
  1. אם old_clean == fresh_clean: אין עדכון (מונע תנודתיות).
  2. אם הם שונים: בודק אם השינוי הוא “משמעותי”:
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"))
)
  • אם meaningful = True → עדכון עם דחיפה (הנושא עולה ב’הכי חדש’).

  • אם meaningful = False → עדכון בשקט (bypass_bump=True → רק תיקון, ללא דחיפה).

    1. תגיות משולבות (מבטיח שתגיות סטטיות/ברירת מחדל קיימות, לעולם לא מסיר תגיות מנהל/ידניות).
    2. כותרת וקטגוריה לעולם אינם משתנים בעדכון.

  1. זרימת עדכון ללא התאמת UID
    1. הסקריפט מנסה אימוץ:
      • בונה שלשות מועמדות של התחלה/סיום/מיקום (או התחלה/סיום בלבד עם --time-only-dedupe).
      • מחפש ב-/search.json וב-/latest.json אירוע קיים עם תכונות תואמות.
      • אם נמצא → מאמץ את הנושא, מתקין מחדש את סמן ה-UID + תגיות (גוף נשאר ללא שינוי בשלב זה).
      • אם לא נמצא → יוצר נושא חדש לגמרי עם הסמן והתגיות.
    2. לאחר אימוץ או יצירה, כל סנכרונים עתידיים יפתרו ישירות לפי UID.

  1. השלכות מעשיות
    • שינויי זמן
    • ברירת מחדל: אימוץ נכשל (זמנים שונים) → נוצר נושא חדש.
    • עם --time-only-dedupe: אימוץ נכשל באותה צורה; נוצר נושא חדש.
    • שינויי מיקום
    • ברירת מחדל: אימוץ נכשל (מיקום שונה) → נוצר נושא חדש.
    • עם --time-only-dedupe: אימוץ מצליח (זמנים תואמים), אך הבדל במיקום מסומן כ"משמעותי" → עדכון עם דחיפה.
    • שינויי תיאור
    • אם טקסט ה-DESCRIPTION משתנה אך התחלה/סיום/מיקום לא:
    • הגוף מתעדכן בשקט (bypass_bump=True).
    • נוצר תיקון לנושא, אך ללא דחיפה ב’הכי חדש’.
    • אם DESCRIPTION ללא שינוי (או רק רעש כמו Last Updated: שמתנרמל), לא מתבצע כלל עדכון.
    • סמן UID
    • מבטיח התאמה אמינה בסנכרונים עתידיים.
    • פירושו ששדות DESCRIPTION רועשים אינם משפיעים על מציאת הנושא הנכון.

  1. מדוע ה-DESCRIPTION לפעמים “נשאר זהה”
    הסקריפט משווה את כל הגוף (ללא סמן ה-UID).
    אם רק שורה תנודתית כמו Last Updated: שונה, אך היא מתנרמלת (למשל, רווחים, סיומות שורה, יוניקוד), old_clean ו-fresh_clean נראים זהים → לא מתבצע עדכון.
    זה מכוון, כדי למנוע תנודתיות מרעש הזנה.

סיכום
• זמן מגדיר ייחודיות (תמיד יוצר נושא חדש כאשר זמנים משתנים).
• שינויי מיקום → דחיפה גלויה (כדי שמשתמשים ישימו לב לעדכוני מיקום).
• שינויי תיאור → עדכון שקט (תיקון אך ללא דחיפה).
• סמן UID = מפתח זהות אמין, מבטיח שהנושא הנכון תמיד יימצא, גם אם ה-DESCRIPTION מיושן או רועש.

זה מכה איזון טוב: שינויים חשובים מופיעים ב’הכי חדש’, תנודתיות לא חשובה נשארת בלתי נראית.

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 לייקים

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

כיצד הסקריפט הזה טוב יותר מהתוסף? (אה, אולי אינך יכול להתקין תוספים?) אם התוסף אינו עושה את הנדרש, אולי תגיש PR?

תודה על התזכורת!

סטטוס מהיר: כרגע אני מריץ שלוש מופעים של ייבוא ה-Python שלי מ-ICS ל-Discourse (לוח זמנים של Uni, הזמנות של מרכז הספורט ולוח שנה של Outlook). התחלתי לעטוף אותו כתוסף Discourse, אבל גרסת התוסף לא עמדה במערך התכונות של הסקריפט - בעיקר מכיוון שכל הזנה דורשת טיפול מותאם אישית (מוזרויות UID, עדכונים חלקיים, ביטולים, שינויים רועשים וכו’). התוסף של אנגוס מצוין למקרים רבים; מקרי השימוש שלי נראים יותר “ספציפיים להזנה”.

יש לי גם PR פתוח נגד הליבה שמטרתו להפחית את הרעש של הכפתור הכחול “הכי חדש” במהלך עדכוני ICS גדולים/מתפרצים. עם הזנות עמוסות (כמו לוחות זמנים של אוניברסיטאות), קבוצת עריכות בעלות ערך נמוך יכולה להמשיך להקפיץ את “הכי חדש”; ה-PR למעשה מבטל את פעולת כפתור “נושאים חדשים” כאשר “הכי חדש” נשאר פתוח בזמן שקבוצה אוטומטית פועלת. אשמח לקשר את ה-PR הזה כאן אם זה מועיל.

לטווח ארוך יותר: אני כרגע ב-IONOS בהוסט עצמי. אם אעבור להוסט רשמי מאוחר יותר, עדיין אשמח למצוא דרך לשמור על זרימת ה-Python (או מקבילה) מבלי להזדקק לתכונות Enterprise, אם ICS inbound קיים שם. אני חושב שפתרון כללי של ליבה/תוסף יכול לעבוד אם הוא יאפשר “מתאמים” ניתנים לחיבור לכל הזנה תוך שמירה על איתנות אידמפוטנטיות (ICS UID), טיפול בביטולים, וסמנטיקה של עריכה ללא קפיצה.

אם יש עניין, אני יכול לשרטט ממשק מתאם מינימלי ונתיב הגירה מהסקריפט שלי ל-Ruby job, או לתרום חלקים שאינם תלויים בהזנה (מיפוי UID, השהייה/עדכונים ללא קפיצה, לוגיקת ביטול) לתוסף לוח השנה/אירועים.

לייק 1

זו שאלה טובה, ניית’ן — ואני חושב שיש בהחלט מקום לגישה מינימלית, שאינה תלויה ב-feed, שיכולה להתקיים כהרחבה קטנה לפלאגין Calendar/Event או כ-job ליבה קל משקל.

כדי ש-PR יהיה שימושי באופן כללי, המפתח נראה כמבצע את הייבוא מבוסס מתאם (adapter-based) ולא ספציפי ל-feed. משהו כמו:

  • כל feed מגדיר מתאם קטן (יכול להיות Python, YAML, או Ruby) שממפה שדות ICS → שדות נושא Discourse (title, body, tags, start, end, location, וכו’).
  • הליבה מטפלת באדיפוטנטיות (מיפוי UID ↔ מזהה נושא), ביטולים (STATUS:CANCELLED), ועריכות שקטות (עדכון ללא דחיפה ל-Latest).
  • פלאגינים או הגדרות אתר יכולים להגדיר מרווח סקר, מיפויי תגים, ומדיניות דחיפה (always, never, on major change).

בצורה זו, מוסדות עם feeds רועשים או מורכבים (לוחות זמנים של אוניברסיטאות, הזמנות חדרים, יומני Outlook, וכו’) יכולים לספק מתאם המתאים לנתונים שלהם מבלי לקודד שום דבר בליבה.

אם יש עניין, אשמח לשרטט את ממשק המתאם הזה או ליצור אב-טיפוס של ה-helper “ICS upsert” בליבה כ-job Ruby שאחרים יוכלו לבנות עליו — כך שזה יוכל להתפתח בהדרגה מסקריפטים עצמאיים של Python למשהו שניתן לתחזק וגנרי בתוך האקוסיסטם של Discourse.

2 לייקים

no longer with the following commit, Thanks Discourse!

3 לייקים