Публикации RSVP в календаре

:information_source: Краткое описание Создает краткие ответы по теме для событий RSVP
:hammer_and_wrench: Ссылка на репозиторий GitHub - mariodsantana/discourse-calendar-rsvp-posts · GitHub
:open_book: Руководство по установке Как установить плагины в Discourse

Возможности

  • Уведомления в реальном времени — поскольку события RSVP теперь создают ответное сообщение в теме, каждое подтверждение участия вызывает уведомления для подписчиков темы
  • Минимальный беспорядок — предыдущие сообщения об уведомлениях удаляются, поэтому одновременно существует максимум 2 сообщения (1 история + 1 последнее уведомление)
  • Полная история — вся активность RSVP сохраняется с временными метками в хронологическом порядке
  • Сдерживает колебания — временные метки делают повторяющиеся изменения RSVP видимыми

Настройка

Соответствующие настройки сайта (в разделе Администрирование > Настройки > Плагины) в основном определяют, какие изменения RSVP должны вызывать сообщение: «Иду», «Интересует», «Не иду» или удаление существующего RSVP, а также следует ли реагировать на изменения RSVP для событий, которые начались в прошлом.

Оставшаяся настройка переключает режим истории. При отключенном режиме истории в теме остается только последнее сообщение об уведомлении. При включенном режиме истории плагин управляет дополнительным комментарием «История» следующим образом:

  • При первом RSVP создается простое сообщение об уведомлении, объявляющее о подтверждении участия
  • При втором RSVP первое сообщение преобразуется в сообщение об истории с временной меткой, затем создается новое сообщение об уведомлении
  • При последующих RSVP подтверждение участия добавляется в сообщение об истории с временной меткой, предыдущее сообщение об уведомлении удаляется, и создается новое

Настройки

Название Описание
calendar_rsvp_posts_on_new_going создавать сообщение при новом статусе «Иду»
calendar_rsvp_posts_on_new_interested создавать сообщение при новом статусе «Интересует»
calendar_rsvp_posts_on_new_not_going создавать сообщение при новом статусе «Не иду»
calendar_rsvp_posts_on_removed_rsvp создавать сообщение при удалении RSVP
calendar_rsvp_posts_allow_past_events создавать сообщения для событий, начавшихся в прошлом
calendar_rsvp_posts_enable_history сохранять сообщение об истории с временными метками (по умолчанию: включено)
6 лайков

Это улучшит управление мероприятиями для многих!! Большое спасибо за это!!

1 лайк

Спасибо за плагин :+1:

Всего один небольшой вопрос: есть ли возможность перевести ответы в файл?

Мне нравится эта идея. Я могу добавить ссылку на пост с историей, которая будет загружать CSV-файл. Это то, о чём вы думаете?

Я выразился не совсем точно :sweat_smile:, я хотел узнать, как переводить уведомления о ответах, так как они на английском, а я бы хотел перевести их на русский.

1 лайк

ЛОЛ — теперь я понял. Сейчас английский текст прямо в исходном коде. Я новичок в Discourse, но уверен, что есть стандартный способ сделать плагин переводимым. Разберусь с этим, когда будет время.

Также, вероятно, реализую ссылку на CSV. :wink:

1 лайк

Без проблем :wink: . Не торопись, я не спешу

Отлично! Новая версия доступна в репозитории. Теперь она поддерживает интернационализацию, а также мы нашли и исправили ошибку, из-за которой обновления не публиковались, когда кто-то отменял своё подтверждение участия (RSVP).

Сообщите, если возникнут проблемы!

1 лайк

Привет! У меня была проблема с пользователями, когда они участвовали в событии или нет: вместо одного аватара в событии отображались два, и это устранялось только обновлением страницы, что вводило пользователей в заблуждение. Я изменил код с своей стороны, не знаю, интересно ли это тебе.

# frozen_string_literal: true
# name: discourse-calendar-rsvp-posts
# about: Create short topic replies for RSVP events
# version: 0.4
# authors: Mario Santana

after_initialize do
 module ::CalendarRsvpPosts
   PLUGIN_NAME = "discourse-calendar-rsvp-posts"
   
   def self.history_marker
     I18n.t('calendar_rsvp_posts.markers.history')
   end
   
   def self.notification_marker
     I18n.t('calendar_rsvp_posts.markers.notification')
   end

   # Helper to decide if we should ignore this event
   def self.should_post_for_event?(event)
     return false if event.nil?
     return true if SiteSetting.calendar_rsvp_posts_allow_past_events
     return false if event.starts_at.nil?
     event.starts_at >= Time.current
   end

   # Find any RSVP post for an event (history or simple notification)
   def self.find_rsvp_posts(event)
     return [] if event.nil? || event.post.nil? || event.post.topic.nil?
     
     event.post.topic.posts
       .where(user_id: Discourse.system_user.id)
       .where("raw LIKE ? OR raw LIKE ?", 
              "%#{history_marker}%",
              "%#{notification_marker}%")
       .order(created_at: :asc)
   end

   # Find the history post specifically
   def self.find_history_post(event)
     return nil if event.nil? || event.post.nil? || event.post.topic.nil?
     
     event.post.topic.posts
       .where(user_id: Discourse.system_user.id)
       .where("raw LIKE ?", "%#{history_marker}%")
       .order(created_at: :asc)
       .first
   end

   # Find and delete all notification posts (but not history post)
   def self.delete_notification_posts(event)
     return if event.nil? || event.post.nil? || event.post.topic.nil?
     
     notification_posts = event.post.topic.posts
       .where(user_id: Discourse.system_user.id)
       .where("raw LIKE ?", "%#{notification_marker}%")
     
     notification_posts.each do |post|
       begin
         PostDestroyer.new(Discourse.system_user, post, context: "calendar-rsvp-posts cleanup").destroy
       rescue StandardError => e
         Rails.logger.warn("calendar-rsvp-posts: failed to delete notification post #{post.id}: #{e}")
       end
     end
   end

   # Build a history entry line with timestamp
   def self.build_history_entry(username, action_label, extra_text = nil)
     timestamp = Time.current.strftime("%Y-%m-%d %H:%M UTC")
     entry = "- **#{timestamp}** - #{username} #{action_label}"
     entry += " (#{extra_text})" if extra_text.present?
     entry
   end

   # Build or update the history post content
   def self.build_history_raw(event, new_entry)
     event_title = (event.name.presence || event.post.topic.title).to_s
     parts = []
     parts << history_marker
     parts << "### #{I18n.t('calendar_rsvp_posts.history.header', event_title: event_title)}"
     parts << ""
     parts << new_entry
     parts.join("\n")
   end

   # Append new entry to existing history
   def self.append_to_history(existing_raw, new_entry)
     lines = existing_raw.split("\n")
     header_end_idx = lines.index { |line| line.start_with?("### RSVP History") }
     
     if header_end_idx
       insert_idx = header_end_idx + 2
       lines.insert(insert_idx, new_entry)
     else
       lines << new_entry
     end
     
     lines.join("\n")
   end

   # Convert a simple notification post into history format
   def self.convert_to_history(simple_post, event)
     raw = simple_post.raw
     
     going_label = I18n.t('calendar_rsvp_posts.actions.going').gsub('(', '\\(').gsub(')', '\\)')
     interested_label = I18n.t('calendar_rsvp_posts.actions.interested').gsub('(', '\\(').gsub(')', '\\)')
     not_going_label = I18n.t('calendar_rsvp_posts.actions.not_going').gsub('(', '\\(').gsub(')', '\\)')
     removed_label = I18n.t('calendar_rsvp_posts.actions.removed').gsub('(', '\\(').gsub(')', '\\)')
     
     pattern = /\*\*([^\*]+)\s+(#{Regexp.escape(going_label)}|#{Regexp.escape(interested_label)}|#{Regexp.escape(not_going_label)}|#{Regexp.escape(removed_label)})\*\*/
     match = raw.match(pattern)
     
     if match
       username = match[1]
       action = match[2]
       
       extra_match = raw.match(/\.\s+([^.]+)\.$/)
       extra_text = extra_match ? extra_match[1] : nil
       
       timestamp = simple_post.created_at.strftime("%Y-%m-%d %H:%M UTC")
       first_entry = "- **#{timestamp}** - #{username} #{action}"
       first_entry += " (#{extra_text})" if extra_text.present?
       
       event_title = (event.name.presence || event.post.topic.title).to_s
       parts = []
       parts << history_marker
       parts << "### #{I18n.t('calendar_rsvp_posts.history.header', event_title: event_title)}"
       parts << ""
       parts << first_entry
       parts.join("\n")
     else
       event_title = (event.name.presence || event.post.topic.title).to_s
       history_marker + "\n### #{I18n.t('calendar_rsvp_posts.history.header', event_title: event_title)}\n\n" + raw
     end
   end

   # Build a notification post
   def self.build_notification_raw(username, action_label, event, extra_text = nil)
     event_title = (event.name.presence || event.post.topic.title).to_s
     extra_text_formatted = extra_text.present? ? "#{extra_text} " : ""
     
     notification_raw = notification_marker + "\n"
     notification_raw += I18n.t('calendar_rsvp_posts.notification.template', 
                               event_title: event_title,
                               extra: extra_text_formatted,
                               username: username,
                               action: action_label)
     notification_raw
   end

   # Centrale logic method to handle the creation/update of posts
   def self.publish_rsvp_update(event, username, action_label, extra_text = nil)
     if SiteSetting.calendar_rsvp_posts_enable_history
       history_post = find_history_post(event)
       all_rsvp_posts = find_rsvp_posts(event)
       new_entry = build_history_entry(username, action_label, extra_text)

       if all_rsvp_posts.empty?
         notification_raw = build_notification_raw(username, action_label, event, extra_text)
         PostCreator.create!(
           Discourse.system_user,
           topic_id: event.post.topic_id,
           raw: notification_raw,
           skip_validations: true
         )
       elsif history_post.nil?
         first_post = all_rsvp_posts.first
         history_raw = convert_to_history(first_post, event)
         history_raw = append_to_history(history_raw, new_entry)
         
         PostRevisor.new(first_post, event.post.topic).revise!(
           Discourse.system_user,
           raw: history_raw,
           skip_validations: true,
           skip_revision: false
         )

         notification_raw = build_notification_raw(username, action_label, event, extra_text)
         PostCreator.create!(
           Discourse.system_user,
           topic_id: event.post.topic_id,
           raw: notification_raw,
           skip_validations: true
         )
       else
         updated_raw = append_to_history(history_post.raw, new_entry)
         PostRevisor.new(history_post, event.post.topic).revise!(
           Discourse.system_user,
           raw: updated_raw,
           skip_validations: true,
           skip_revision: false
         )

         delete_notification_posts(event)

         notification_raw = build_notification_raw(username, action_label, event, extra_text)
         PostCreator.create!(
           Discourse.system_user,
           topic_id: event.post.topic_id,
           raw: notification_raw,
           skip_validations: true
         )
       end
     else
       all_rsvp_posts = find_rsvp_posts(event)
       
       all_rsvp_posts.each do |post|
         begin
           PostDestroyer.new(Discourse.system_user, post, context: "calendar-rsvp-posts cleanup").destroy
         rescue StandardError => e
           Rails.logger.warn("calendar-rsvp-posts: failed to delete post #{post.id}: #{e}")
         end
       end

       notification_raw = build_notification_raw(username, action_label, event, extra_text)
       PostCreator.create!(
         Discourse.system_user,
         topic_id: event.post.topic_id,
         raw: notification_raw,
         skip_validations: true
       )
     end
   end
 end

 # ==========================================
 # Background Job Definition
 # ==========================================
 module ::Jobs
   class ProcessCalendarRsvpPost < ::Jobs::Base
     def execute(args)
       event_id = args[:event_id]
       username = args[:username]
       action_label = args[:action_label]
       extra_text = args[:extra_text]

       event = DiscoursePostEvent::Event.find_by(id: event_id)
       return unless event

       ::CalendarRsvpPosts.publish_rsvp_update(event, username, action_label, extra_text)
     end
   end
 end

 # ==========================================
 # Event Handlers
 # ==========================================

 # Handler for create/update attendance
 proc_handler = proc do |invitee|
   begin
     event = invitee&.event
     next if event.nil?
     next unless CalendarRsvpPosts.should_post_for_event?(event)

     going_val = DiscoursePostEvent::Invitee.statuses[:going]
     interested_val = DiscoursePostEvent::Invitee.statuses[:interested]
     not_going_val = DiscoursePostEvent::Invitee.statuses[:not_going]

     new_status = invitee.status
     prev_status =
       if invitee.respond_to?(:previous_changes) && invitee.previous_changes["status"]
         invitee.previous_changes["status"][0]
       else
         nil
       end

     next if prev_status && prev_status == new_status

     username = invitee.user&.username || "someone"
     action_label = nil

     if new_status == going_val && SiteSetting.calendar_rsvp_posts_on_new_going
       action_label = I18n.t('calendar_rsvp_posts.actions.going')
     elsif new_status == interested_val && SiteSetting.calendar_rsvp_posts_on_new_interested
       action_label = I18n.t('calendar_rsvp_posts.actions.interested')
     elsif new_status == not_going_val && SiteSetting.calendar_rsvp_posts_on_new_not_going
       action_label = I18n.t('calendar_rsvp_posts.actions.not_going')
     end

     next if action_label.nil?

     extra_text = nil
     if event.max_attendees.present?
       current_going = event.going_count
       prev_going =
         if prev_status == going_val && new_status != going_val
           current_going + 1
         elsif prev_status != going_val && new_status == going_val
           current_going - 1
         else
           current_going
         end

       was_full = prev_going >= event.max_attendees
       is_full = current_going >= event.max_attendees

       if was_full && !is_full
         extra_text = I18n.t('calendar_rsvp_posts.capacity.spots_available')
       elsif !was_full && is_full
         extra_text = I18n.t('calendar_rsvp_posts.capacity.now_full')
       end
     end

     # Enqueue the job instead of blocking the request
     Jobs.enqueue(:process_calendar_rsvp_post, 
       event_id: event.id, 
       username: username, 
       action_label: action_label, 
       extra_text: extra_text
     )

   rescue StandardError => e
     Rails.logger.warn("calendar-rsvp-posts: handler error: #{e}")
     Rails.logger.warn(e.backtrace.join("\n"))
   end
 end

 on(:discourse_calendar_post_event_invitee_status_changed, &proc_handler)

 # Handler for explicit invitee deletions (removed RSVP)
 if defined?(DiscoursePostEvent::Invitee)
   DiscoursePostEvent::Invitee.class_eval do
     after_destroy do
       event = self.event
       
       should_process = event.present? &&
                        SiteSetting.calendar_rsvp_posts_on_removed_rsvp &&
                        (SiteSetting.calendar_rsvp_posts_allow_past_events || event.starts_at.nil? || event.starts_at >= Time.current)
       
       if should_process
         begin
           username = self.user&.username || "someone"
           action_label = I18n.t('calendar_rsvp_posts.actions.removed')

           # Enqueue the job instead of blocking the request
           Jobs.enqueue(:process_calendar_rsvp_post, 
             event_id: event.id, 
             username: username, 
             action_label: action_label,
             extra_text: nil
           )
           
         rescue StandardError => e
           Rails.logger.warn("calendar-rsvp-posts: after_destroy handler error: #{e}")
           Rails.logger.warn(e.backtrace.join("\n"))
         end
       end
     end
   end
 end
end
1 лайк

Это отличная идея, и я понимаю, как она может вызывать путаницу в интерфейсе. Я добавил Mutex, чтобы избежать состояний гонки, и выпустил новую версию плагина.

Дайте знать, что вы думаете!

1 лайк