iCalendar(ICS) 피드에서 이벤트를 Discourse 카테고리로 지속적으로 동기화하는 작은 유틸리티를 만들었습니다.
이것은 완전한 Discourse 플러그인이 아니라 Discourse 설치와 함께 실행되므로, Customization > Extras 섹션에 올려야 합니다. 외부 소스(예: Google 캘린더, 대학 시간표 피드 등)의 캘린더 이벤트를 Discourse 토픽 내에서 표시하고 싶다면 유용할 것입니다.
저장소
작동 방식
주어진 ICS 피드에서 이벤트를 읽습니다.
기존 토픽과 매칭합니다 (UID 또는 시간/장소로 폴백).
선택한 카테고리에서 토픽을 생성하거나 업데이트합니다.
systemd 서비스로 지속적으로 실행할 수 있습니다 (flock을 통해 중복 실행 방지).
시간이 있다면 이걸 제대로 된 플러그인으로 변환해 볼 것 같습니다. 몇 가지 설정을 만들고 Python 코드를 Ruby로 변환해서 작업(job)으로 넣는 건 그렇게 어렵지 않을 것 같아요.
또 다른 아이디어로, 호스팅 서비스를 사용하면서 이걸 활용하고 싶은 사람들에게 유용할 수 있는 방법은 이 작업을 GitHub Action으로 변환해서 매일 실행되도록 하는 것입니다. 얼마 전 호스팅 클라이언트가 매일 실행해야 하는 몇 가지 스크립트에 대해 이 방법을 적용했는데 꽤 잘 작동하고 있습니다. 이 방식은 더 어렵기도(구식 cron 작업 대신 GitHub 워크플로우를 배우고 시크릿(secrets)을 다루는 방법을 알아야 하므로)하고 더 쉽기도(명령줄 인터페이스를 통해 머신에 프로그램을 설치하는 복잡한 과정을 배울 필요가 없으므로)합니다.
old_clean == fresh_clean인 경우: 업데이트하지 않음 (불필요한 변경 방지).
서로 다른 경우: 변경 사항이 "의미 있는(meaningful)"지 여부를 확인합니다:
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인 경우 → bump와 함께 업데이트 (토픽이 ‘최근’ 목록에서 위로 올라감).
meaningful = False인 경우 → 조용히 업데이트 (bypass_bump=True → 리비전만 생성, bump 없음).
태그가 병합됩니다 (정적/기본 태그가 존재하도록 보장하며, 관리자나 수동으로 추가된 태그는 절대 제거하지 않음).
업데이트 시 제목과 카테고리는 절대 변경되지 않습니다.
UID 일치 없이 업데이트 흐름
스크립트가 채택을 시도합니다:
• 시작/종료/위치(또는 --time-only-dedupe 사용 시 시작/종료만) 후보 3원조를 구성합니다.
• /search.json과 /latest.json에서 속성이 일치하는 기존 이벤트를 검색합니다.
• 발견되면 → 해당 토픽을 채택하고 UID 마커 + 태그를 후속 적용합니다 (이 단계에서는 본문은 변경되지 않음).
• 발견되지 않으면 → 마커와 태그가 포함된 완전히 새로운 토픽을 생성합니다.
채택 또는 생성이 완료되면, 모든 향후 동기화는 UID를 통해 직접 해결됩니다.
실질적 결과
• 시간 변경
• 기본: 채택 실패 (시간이 다름) → 새 토픽 생성.
• --time-only-dedupe 사용 시: 채택이 동일한 방식으로 실패함. 새 토픽 생성.
• 위치 변경
• 기본: 채택 실패 (위치가 다름) → 새 토픽 생성.
• --time-only-dedupe 사용 시: 채택 성공 (시간 일치), 하지만 위치 차이는 “의미 있는” 것으로 플래그가 지정되어 → bump와 함께 업데이트.
• 설명(Description) 변경
• DESCRIPTION 텍스트가 변경되었지만 시작/종료/위치는 변경되지 않은 경우:
• 본문을 조용히 업데이트 (bypass_bump=True).
• 토픽 리비전이 생성되지만 ‘최근’ 목록에서 bump는 발생하지 않음.
• DESCRIPTION이 변경되지 않았거나 (또는 Last Updated:와 같이 정규화되어 사라지는 노이즈만 있는 경우) 업데이트가 전혀 발생하지 않음.
• UID 마커
• 향후 동기화 시 신뢰할 수 있는 매칭을 보장합니다.
• 노이즈가 많은 DESCRIPTION 필드가 올바른 토픽이 발견되는지에 영향을 주지 않음을 의미합니다.
DESCRIPTION이 때로는 “변경되지 않은 것처럼” 보이는 이유
스크립트는 UID 마커를 제외한 전체 본문을 비교합니다. Last Updated:와 같이 변동성이 큰 줄만 다르고, 이것이 정규화되어 사라지는 경우(예: 공백, 줄바꿈, 유니코드) old_clean과 fresh_clean이 동일하게 보임 → 업데이트가 수행되지 않음.
이는 피드 노이즈로 인한 불필요한 변경을 방지하기 위한 의도적인 설계입니다.
요약
시간은 고유성을 정의합니다 (시간이 변경되면 항상 새 토픽이 생성됨).
위치 변경 → 가시적인 bump 발생 (사용자가 장소 업데이트를 인지할 수 있도록).
설명 변경 → 조용한 업데이트 (리비전은 생성되지만 bump 없음).
UID 마커 = 신뢰할 수 있는 식별 키, DESCRIPTION이 구식이거나 노이즈가 많아도 항상 올바른 토픽이 발견되도록 보장.
이는 좋은 균형을 맞춥니다: 중요한 변경 사항은 ‘최근’ 목록에 노출되고, 중요하지 않은 불필요한 변경은 보이지 않게 유지됩니다.
돌이켜 보면, 이 일련의 사건이 어떻게 흘러갔는지가 꽤나 웃기기도 합니다.
임포트 스크립트 자체는 이제 탄탄합니다: UID 마커, 중복 제거 로직, 의미 있는 업데이트와 조용한 업데이트, 태그 네임스페이스… 프로덕션 환경에서 실제로 원할 법한 모든 기능이 갖춰졌죠. 제가 올린 노트와 동작 방식이 완벽하게 일치합니다 — 시간이 고유성을 정의하고, 위치가 변경 시 업데이트를 트리거하며, 설명은 조용히 갱신되고, UID 마커가 모든 것을 고정해 줍니다. 우아하고, 예측 가능하며, 완성되었습니다.
그동안 이 모든 것을 담았던 불쌍한 메타 토픽은… 글쎄요, 운명이 정해져 있었습니다.
처음에는 소켓퍼펫(대체 계정)으로 답글을 올리며 시작했고(훌륭한 출발 ), 코드 덤프와 스크린샷으로 가득 찬 프랑켄슈타인 같은 스레드로 불어났으며, 결국 리포지토리 자체보다 커밋이 더 많은 유사 변경 이력(pseudo-changelog)으로 진화했습니다. 그리고 마침내 스크립트가 안정적으로 변했을 때? 삭제 예정으로 잡혀 있었습니다.
솔직히 시적(詩的)입니다. 스크립트의 궁극적인 목적은 중복 이벤트가 포럼을 어지럽히는 것을 막는 것이었죠. 그런데 토픽 자체는? 중복으로 간주되어 조용히 가비지 컬렉션 대상이 되었습니다. 스크립트가 방지하려던 그 운명이, 바로 그 토픽의 운명이 된 것입니다.
그래서 운명된 토픽에게 건배를 올립니다:
당신은 최신 게시글(Latest)을 올리지 못했지만, 우리의 마음을 울렸습니다.
간단한 현황 보고드립니다. 현재 Python 기반 ICS→Discourse 가져오기 도구를 세 가지 인스턴스로 운영 중입니다(대학 시간표, 스포츠 센터 예약, Outlook 캘린더). Discourse 플러그인으로 래핑하는 작업도 시작했지만, 플러그인 버전은 스크립트의 기능 범위에 미치지 못했습니다. 주로 각 피드가 개별적인 처리(UID 특수성, 부분 업데이트, 취소 처리, 노이즈가 많은 수정 사항 등)를 필요로 하기 때문입니다. Angus님의 플러그인은 많은 경우에 훌륭하지만, 제 사용 사례는 좀 더 "피드 특정"에 가깝습니다.
또한, 대량/단발성 ICS 업데이트 시 “최신(Latest)” 탭의 파란 버튼 노이즈를 줄이기 위해 코어에 대한 공개된 PR도 있습니다. 바쁜 피드(예: 대학 시간표)의 경우, 가치가 낮은 편집 일괄 처리가 “최신” 탭을 계속 흔들리게 만들 수 있습니다. 이 PR은 자동화된 배치 실행 중에 “최신” 탭이 열려 있는 상태에서 “새 주제” 버튼을 사실상 무효화(no-op)하는 효과를 냅니다. 유용하다면 해당 PR에 대한 크로스 링크를 여기에 달아도 좋습니다.
장기적으로는 현재 IONOS의 셀프호스팅을 사용 중입니다. 나중에 공식 호스팅으로 이전하더라도, ICS 수신 기능이 존재한다면 엔터프라이즈 기능이 아닌 Python 플로우(또는 동등한 방식)를 유지할 수 있는 방법을 원합니다. 피드별로 플러그인 가능한 "어댑터"를 허용하면서 강력한 멱등성(ICS UID), 취소 처리, 업데이트 시 알림(bump) 없이 편집하는 의미를 유지할 수 있다면, 일반적인 코어/플러그인 솔루션이 작동할 수 있을 것 같습니다.
관심이 있으시다면, Python 스크립트에서 Ruby 작업으로의 마이그레이션 경로와 최소한의 어댑터 인터페이스를 스케치하거나, 캘린더/이벤트 플러그인에 피드 비종속적 요소(UID 매핑, 디바운스/노-번 업데이트, 취소 로직)를 기여할 수 있습니다.
좋은 질문입니다, Nathan — 캘린더/이벤트 플러그인의 작은 확장으로, 또는 경량화된 코어 잡으로 구현할 수 있는 최소한의 피드 비종속적 접근 방식이 충분히 가능하다고 생각합니다.
PR이 일반적으로 유용하려면, 핵심은 가져오기(importer)를 피드 전용이 아닌 어댑터 기반으로 만드는 것입니다. 예를 들어 다음과 같은 방식이 가능합니다:
각 피드는 ICS 필드를 Discourse 토픽 필드(title, body, tags, start, end, location 등)로 매핑하는 작은 어댑터(Python, YAML, Ruby 등)를 정의합니다.
코어는 멱등성(UID ↔ 토픽 ID 매핑), 취소(STATUS:CANCELLED), 조용한 편집(Latest를 올리지 않고 업데이트)을 처리합니다.
플러그인 또는 사이트 설정을 통해 폴링 간격, 태그 매핑, 번(bump) 정책(always, never, on major change)을 구성할 수 있습니다.
이렇게 하면 소음이 많거나 복잡한 피드(대학 시간표, 강의실 예약, Outlook 캘린더 등)를 가진 기관이 코어에 아무것도 하드코딩하지 않고 자신의 데이터에 맞는 어댑터를 제공할 수 있습니다.
관심이 있으시다면, 그 어댑터 인터페이스를 개요로 정리하거나, 다른 사람들이 기반으로 구축할 수 있는 Ruby 잡 형태의 코어 “ICS 업서트(upsert)” 헬퍼를 프로토타입으로 만들어 볼 수 있습니다. 이를 통해 독립적인 Python 스크립트에서 Discourse 생태계 내에서 유지보수 가능하고 범용적인 것으로 점진적으로 발전시킬 수 있을 것입니다.