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:
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
אם היה לי קצת זמן, כנראה שהייתי מנסה להמיר את זה לתוסף אמיתי. אני חושב שלא יהיה קשה מדי ליצור כמה הגדרות ולהמיר את הפייתון לרובי ולשים את זה במשימה.
רעיון נוסף, שיכול להיות שימושי עבור אנשים שמארחים ורוצים להשתמש בזה, יהיה להמיר את המשימה לפעולת גיטהאב ולגרום לה להריץ את המשימה מדי יום. עשיתי זאת עבור כמה סקריפטים שלקוח מארח היה צריך להריץ מדי יום לפני זמן מה וזה עובד די טוב. זה קשה יותר (זה דורש ללמוד זרימות עבודה של גיטהאב ואיך להתמודד עם סודות במקום משימת cron ישנה וטובה) וקל יותר (אתה לא צריך ללמוד איך להתעסק עם התקנת דברים על מכונה דרך ממשק שורת פקודה).
אם old_clean == fresh_clean: אין עדכון (מונע תנודתיות).
אם הם שונים: בודק אם השינוי הוא “משמעותי”:
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 → רק תיקון, ללא דחיפה).
תגיות משולבות (מבטיח שתגיות סטטיות/ברירת מחדל קיימות, לעולם לא מסיר תגיות מנהל/ידניות).
כותרת וקטגוריה לעולם אינם משתנים בעדכון.
זרימת עדכון ללא התאמת UID
הסקריפט מנסה אימוץ:
• בונה שלשות מועמדות של התחלה/סיום/מיקום (או התחלה/סיום בלבד עם --time-only-dedupe).
• מחפש ב-/search.json וב-/latest.json אירוע קיים עם תכונות תואמות.
• אם נמצא → מאמץ את הנושא, מתקין מחדש את סמן ה-UID + תגיות (גוף נשאר ללא שינוי בשלב זה).
• אם לא נמצא → יוצר נושא חדש לגמרי עם הסמן והתגיות.
לאחר אימוץ או יצירה, כל סנכרונים עתידיים יפתרו ישירות לפי UID.
⸻
השלכות מעשיות
• שינויי זמן
• ברירת מחדל: אימוץ נכשל (זמנים שונים) → נוצר נושא חדש.
• עם --time-only-dedupe: אימוץ נכשל באותה צורה; נוצר נושא חדש.
• שינויי מיקום
• ברירת מחדל: אימוץ נכשל (מיקום שונה) → נוצר נושא חדש.
• עם --time-only-dedupe: אימוץ מצליח (זמנים תואמים), אך הבדל במיקום מסומן כ"משמעותי" → עדכון עם דחיפה.
• שינויי תיאור
• אם טקסט ה-DESCRIPTION משתנה אך התחלה/סיום/מיקום לא:
• הגוף מתעדכן בשקט (bypass_bump=True).
• נוצר תיקון לנושא, אך ללא דחיפה ב’הכי חדש’.
• אם DESCRIPTION ללא שינוי (או רק רעש כמו Last Updated: שמתנרמל), לא מתבצע כלל עדכון.
• סמן UID
• מבטיח התאמה אמינה בסנכרונים עתידיים.
• פירושו ששדות DESCRIPTION רועשים אינם משפיעים על מציאת הנושא הנכון.
⸻
מדוע ה-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.
Meanwhile, the poor Meta topic that hosted it all was… well, doomed.
It began life replying as a sockpuppet (strong start ), 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.
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.
So here’s to the doomed topic:
You didn’t bump Latest, but you bumped our hearts.
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).
סטטוס מהיר: כרגע אני מריץ שלוש מופעים של ייבוא ה-Python שלי מ-ICS ל-Discourse (לוח זמנים של Uni, הזמנות של מרכז הספורט ולוח שנה של Outlook). התחלתי לעטוף אותו כתוסף Discourse, אבל גרסת התוסף לא עמדה במערך התכונות של הסקריפט - בעיקר מכיוון שכל הזנה דורשת טיפול מותאם אישית (מוזרויות UID, עדכונים חלקיים, ביטולים, שינויים רועשים וכו’). התוסף של אנגוס מצוין למקרים רבים; מקרי השימוש שלי נראים יותר “ספציפיים להזנה”.
יש לי גם PR פתוח נגד הליבה שמטרתו להפחית את הרעש של הכפתור הכחול “הכי חדש” במהלך עדכוני ICS גדולים/מתפרצים. עם הזנות עמוסות (כמו לוחות זמנים של אוניברסיטאות), קבוצת עריכות בעלות ערך נמוך יכולה להמשיך להקפיץ את “הכי חדש”; ה-PR למעשה מבטל את פעולת כפתור “נושאים חדשים” כאשר “הכי חדש” נשאר פתוח בזמן שקבוצה אוטומטית פועלת. אשמח לקשר את ה-PR הזה כאן אם זה מועיל.
לטווח ארוך יותר: אני כרגע ב-IONOS בהוסט עצמי. אם אעבור להוסט רשמי מאוחר יותר, עדיין אשמח למצוא דרך לשמור על זרימת ה-Python (או מקבילה) מבלי להזדקק לתכונות Enterprise, אם ICS inbound קיים שם. אני חושב שפתרון כללי של ליבה/תוסף יכול לעבוד אם הוא יאפשר “מתאמים” ניתנים לחיבור לכל הזנה תוך שמירה על איתנות אידמפוטנטיות (ICS UID), טיפול בביטולים, וסמנטיקה של עריכה ללא קפיצה.
אם יש עניין, אני יכול לשרטט ממשק מתאם מינימלי ונתיב הגירה מהסקריפט שלי ל-Ruby job, או לתרום חלקים שאינם תלויים בהזנה (מיפוי UID, השהייה/עדכונים ללא קפיצה, לוגיקת ביטול) לתוסף לוח השנה/אירועים.
זו שאלה טובה, ניית’ן — ואני חושב שיש בהחלט מקום לגישה מינימלית, שאינה תלויה ב-feed, שיכולה להתקיים כהרחבה קטנה לפלאגין Calendar/Event או כ-job ליבה קל משקל.
כדי ש-PR יהיה שימושי באופן כללי, המפתח נראה כמבצע את הייבוא מבוסס מתאם (adapter-based) ולא ספציפי ל-feed. משהו כמו:
כל feed מגדיר מתאם קטן (יכול להיות Python, YAML, או Ruby) שממפה שדות ICS → שדות נושא Discourse (title, body, tags, start, end, location, וכו’).
פלאגינים או הגדרות אתר יכולים להגדיר מרווח סקר, מיפויי תגים, ומדיניות דחיפה (always, never, on major change).
בצורה זו, מוסדות עם feeds רועשים או מורכבים (לוחות זמנים של אוניברסיטאות, הזמנות חדרים, יומני Outlook, וכו’) יכולים לספק מתאם המתאים לנתונים שלהם מבלי לקודד שום דבר בליבה.
אם יש עניין, אשמח לשרטט את ממשק המתאם הזה או ליצור אב-טיפוס של ה-helper “ICS upsert” בליבה כ-job Ruby שאחרים יוכלו לבנות עליו — כך שזה יוכל להתפתח בהדרגה מסקריפטים עצמאיים של Python למשהו שניתן לתחזק וגנרי בתוך האקוסיסטם של Discourse.