منشورات تأكيد الحضور للتقويم

:information_source: الملخص ينشئ ردود مواضيع قصيرة لأحداث تلبية الدعوة (RSVP)
:hammer_and_wrench: رابط المستودع GitHub - mariodsantana/discourse-calendar-rsvp-posts
:open_book: دليل التثبيت كيفية تثبيت الإضافات في Discourse

الميزات

  • إشعارات في الوقت الفعلي - نظرًا لأن أحداث تلبية الدعوة تنشئ الآن منشور رد في الموضوع، فإن كل تلبية دعوة تؤدي إلى تشغيل إشعارات لمراقبي الموضوع
  • أقل قدر من الفوضى - يتم حذف منشورات الإشعارات السابقة، لذلك يوجد منشوران كحد أقصى في أي وقت (1 للسجل + 1 لأحدث إشعار)
  • سجل كامل - يتم الاحتفاظ بجميع أنشطة تلبية الدعوة مع الطوابع الزمنية بترتيب زمني
  • يثبط التردد - تجعل الطوابع الزمنية تغييرات تلبية الدعوة المتكررة مرئية

الإعداد

تقوم إعدادات الموقع ذات الصلة (في المسؤول > الإعدادات > الإضافات) بتكوين الإعدادات التي يجب أن تؤدي تغييرات تلبية الدعوة التي تؤدي إلى إنشاء منشور - الذهاب، مهتم، غير ذاهب، أو إزالة تلبية الدعوة الحالية - وما إذا كان سيتم التشغيل عند تغيير تلبية الدعوة للأحداث التي تبدأ في الماضي.

يقوم الإعداد المتبقي بتبديل وضع السجل. عند تعطيل وضع السجل، يبقى فقط أحدث منشور إشعار في الموضوع. عند تمكين وضع السجل، تدير الإضافة “تعليق سجل” إضافي على النحو التالي:

  • عند تلبية الدعوة الأولى، ينشئ منشور إشعار بسيط يعلن عن تلبية الدعوة
  • عند تلبية الدعوة الثانية، يحول المنشور الأول إلى منشور سجل مختوم بالوقت، ثم ينشئ منشور إشعار جديد
  • عند تلبية الدعوات اللاحقة، يلحق تلبية الدعوة بمنشور السجل مع الطابع الزمني، ويحذف منشور الإشعار السابق، وينشئ واحدًا جديدًا

الإعدادات

الاسم الوصف
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 نشر عند إزالة تلبية دعوة
calendar_rsvp_posts_allow_past_events ما إذا كان سيتم النشر للأحداث التي تبدأ في الماضي
calendar_rsvp_posts_enable_history الاحتفاظ بمنشور سجل مختوم بالوقت (الافتراضي: ممكّن)
6 إعجابات

سيؤدي هذا إلى تحسين إدارة الفعاليات للكثيرين!! شكرًا جزيلاً على توفير هذا!!

إعجاب واحد (1)

شكراً على الإضافة :+1:
لدي سؤال صغير: هل هناك إمكانية لترجمة الردود في ملف؟

أعجبني هذا الاقتراح. يمكنني إضافة رابط إلى منشور السجل، والذي سيقوم بتنزيل ملف CSV. هل هذا هو النوع الذي تفكر فيه؟

لقد أسأت التعبير :sweat_smile:، أردت أن أعرف كيف أترجم إشعارات الردود لأنها باللغة الإنجليزية وأرغب في وضعها باللغة الفرنسية.

إعجاب واحد (1)

لول - فهمت الآن. حاليًا، النص الإنجليزي موجود مباشرة في الكود المصدري. أنا جديد على Discourse، ولكني متأكد من وجود طريقة قياسية لجعل المكون الإضافي قابلاً للترجمة. سأبحث في هذا الأمر عندما يتوفر لدي بعض الوقت.

من المحتمل أن أقوم أيضًا بتنفيذ رابط ملف CSV. :wink:

إعجاب واحد (1)

لا مشكلة :wink: . خذ وقتك، أنا لست مستعجلاً

حسناً! الإصدار الجديد متاح في المستودع. ليس فقط مع تدويل (i18n)، ولكن تم أيضاً العثور على خطأ وإصلاحه كان يمنع نشر التحديثات عندما يقوم شخص ما بإلغاء تأكيد حضوره.

أخبرني إذا واجهت أي مشاكل!

إعجاب واحد (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