ICS → Discourse sync: update event details (date/time/location) without changing title/category — and (optionally) add an edit reason
This post documents the behaviour of my ics_to_discourse.py
script and a small optional patch to add a visible edit reason for post edits made by the sync. It’s designed for Discourse sites using the [event] BBCode (from discourse-calendar / events) and an external ICS feed.
What the script already does
• Idempotent by ICS UID: one topic per UID.
• Preserves human-edited titles on update.
• Does NOT change category on update.
• Merges tags on update (never drops moderator/manual tags).
• Updates only the first post body when content changes (compares while ignoring an invisible marker).
• Adds an invisible marker (<!-- ICSUID:... -->) so the topic can be reliably found next time.
• Site-wide dedupe: on create, scans /latest.json pages to adopt an existing topic if start/end/location match (with legacy time-encoding tolerance).
What users see when updates happen
• The date, time, and location in the first post’s [event ...] block are updated from the ICS.
• The script does not change title or category on update.
• By default, no edit reason is shown (Discourse revision history will have a blank reason).
• If you enable the optional patch below, the revision will show a reason such as “Updated from ICS feed”.
⸻
Environment variables
The script supports these environment variables (via .env or your unit file). The new one is optional.
DISCOURSE_BASE_URL=https://forum.example.com
DISCOURSE_API_KEY=xxxxx
DISCOURSE_API_USERNAME=system
DISCOURSE_CATEGORY_ID=12 # used on CREATE only
DISCOURSE_DEFAULT_TAGS=calendar,events
SITE_TZ=Europe/London # render times in this timezone
# NEW (optional): default edit reason for updates
DISCOURSE_EDIT_REASON=Updated from ICS feed
You can still override most of these with CLI flags.
CLI usage
# Minimal
python3 ics_to_discourse.py --ics https://example.com/cal.ics --category-id 12
# With static tags merged on create/update
python3 ics_to_discourse.py --ics https://example.com/cal.ics \
--category-id 12 \
--static-tags calendar,events
# Optional: include an edit reason (after patch below)
python3 ics_to_discourse.py --ics https://example.com/cal.ics \
--category-id 12 \
--static-tags calendar,events \
--edit-reason "Updated from ICS feed"
One-line summary of the change
Yes, the script updates date/time/location in the first post body and, by default, does not set an edit reason.
The small patch below adds an optional --edit-reason flag (or DISCOURSE_EDIT_REASON) so Discourse shows a reason in the post revision.
Patch (minimal, safe)
Apply this diff to your script to support an edit reason:
diff --git a/ics_to_discourse.py b/ics_to_discourse.py
@@
BASE = os.environ.get("DISCOURSE_BASE_URL", "").rstrip("/")
API_KEY = os.environ.get("DISCOURSE_API_KEY", "")
API_USER = os.environ.get("DISCOURSE_API_USERNAME", "system")
ENV_CAT_ID = os.environ.get("DISCOURSE_CATEGORY_ID", "")
DEFAULT_TAGS = [t.strip() for t in os.environ.get("DISCOURSE_DEFAULT_TAGS", "").split(",") if t.strip()]
SITE_TZ_DEFAULT = os.environ.get("SITE_TZ", "Europe/London")
+EDIT_REASON = os.environ.get("DISCOURSE_EDIT_REASON", "")
@@
-def update_first_post_raw(s: requests.Session, post_id: int, new_raw: str) -> Dict[str, Any]:
- return put_form(s, f"/posts/{post_id}.json", [("post[raw]", new_raw)])
+def update_first_post_raw(
+ s: requests.Session,
+ post_id: int,
+ new_raw: str,
+ edit_reason: str | None = None,
+) -> Dict[str, Any]:
+ fields = [("post[raw]", new_raw)]
+ if edit_reason:
+ fields.append(("post[edit_reason]", edit_reason))
+ return put_form(s, f"/posts/{post_id}.json", fields)
@@ def sync_event(s: requests.Session, ev, args) -> Tuple[int | None, bool]:
- if old_clean.strip() != fresh_clean.strip():
+ if old_clean.strip() != fresh_clean.strip():
log.info("Updating topic %s first post.", topic_id)
- update_first_post_raw(s, post_id, fresh_raw)
+ update_first_post_raw(s, post_id, fresh_raw, getattr(args, "edit_reason", None))
@@ def main() -> None:
- ap = argparse.ArgumentParser(description="Sync an ICS into Discourse topics (idempotent by UID).")
+ ap = argparse.ArgumentParser(description="Sync an ICS into Discourse topics (idempotent by UID).")
@@
- ap.add_argument("--static-tags", default="", help="Comma separated static tags to add on create/update (merged with existing)")
+ ap.add_argument("--static-tags", default="", help="Comma separated static tags to add on create/update (merged with existing)")
ap.add_argument("--scan-pages", type=int, default=8, help="How many /latest pages to scan site-wide for duplicates (default: 8)")
ap.add_argument("--time-only-dedupe", action="store_true", default=False,
help="Treat events with same start/end as duplicates regardless of location (location becomes 'close' check)")
+ ap.add_argument("--edit-reason", default=EDIT_REASON,
+ help='Optional edit reason, e.g. "Updated from ICS feed"')
args = ap.parse_args()
Behaviour with the patch:
• If --edit-reason (or DISCOURSE_EDIT_REASON) is non-empty, the Discourse revision shows that reason.
• Leave it empty to keep edits silent (no reason string).
Systemd example
Using an .env file:
/opt/ics-sync/.env
DISCOURSE_BASE_URL=https://forum.example.com
DISCOURSE_API_KEY=xxxxx
DISCOURSE_API_USERNAME=system
DISCOURSE_CATEGORY_ID=12
DISCOURSE_DEFAULT_TAGS=calendar,events
SITE_TZ=Europe/London
DISCOURSE_EDIT_REASON=Updated from ICS feed
ICS_URL=https://example.com/my.ics
Service (excerpt):
# /etc/systemd/system/ics-sync.service
[Unit]
Description=ICS → Discourse sync
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/opt/ics-sync/discourse-ics-sync-tags
EnvironmentFile=/opt/ics-sync/.env
ExecStart=/usr/bin/flock -n /var/lock/ics-sync.lock /bin/bash -lc '\
. /opt/ics-sync/.venv/bin/activate && \
python3 -u ics_to_discourse.py \
--ics "$ICS_URL" \
--category-id "$DISCOURSE_CATEGORY_ID" \
--site-tz "$SITE_TZ" \
--static-tags "${DISCOURSE_DEFAULT_TAGS:-}" \
${DISCOURSE_EDIT_REASON:+--edit-reason "$DISCOURSE_EDIT_REASON"} \
'
StandardOutput=append:/opt/ics-sync/sync.log
StandardError=append:/opt/ics-sync/sync.log
Nice=10
Timer (hourly,
# /etc/systemd/system/ics-sync.timer
[Unit]
Description=Run ICS → Discourse sync hourly
[Timer]
OnBootSec=5min
OnUnitActiveSec=1h
Persistent=false
Unit=ics-sync.service
[Install]
WantedBy=timers.target
Notes & gotchas
• No title/category changes on update: by design, only the first post body is touched if the content changed.
• Tag merging: existing tags are preserved; the script adds static tags + ics-<short-hash> for the UID.
• Dedupe: on create, the script can adopt an existing topic if its [event] block’s start/end/location matches (with legacy “floating-time treated as UTC” tolerance). --time-only-dedupe loosens location to “close enough”.
• Silent vs reasoned edits: leaving the reason blank is fine; using --edit-reason just helps audit what the bot changed.
Not bumping topics: If you also want updates not to bump the topic, that’s a separate tweak (call the “reset bump date” endpoint after editing). Shout if you want a snippet for that.