نقل منتدى XenForo إلى Discourse

في المنتدى الذي قمت بنقله، تم تثبيت xengallery ذات مرة، لذلك اضطررت إلى تغيير ما يلي، لأن الجدول xfgallery لم يعد موجودًا.

  def get_xf_sql(type, id)
    case type
    when :gallery
      return "SELECT NULL WHERE 1=0;"
    when :attachment
      <<-SQL
        SELECT a.attachment_id, a.data_id, d.filename, d.file_hash, d.user_id
        FROM #{TABLE_PREFIX}attachment AS a
        INNER JOIN #{TABLE_PREFIX}attachment_data d ON a.data_id = d.data_id
        WHERE attachment_id = #{id}
        AND content_type = 'post'
      SQL
    end
  end
إعجاب واحد (1)

يمكنك محاولة تشغيل البرنامج النصي مرة أخرى ومعرفة ما إذا كان سينتهي.

هل نظامك على قسم واحد؟

خطوة تثبيت mysql-server أصبحت قديمة. يجب تثبيت mariadb-server.

sudo apt-get install mariadb-server mariadb-client libmariadb-dev-compat libmariadb-dev

تمكنت من اتباع بقية الخطوات واستيراد قاعدة بيانات XF، لذا يجب على شخص ما تحديث الدليل.

أنا الآن عالق في الخطوة التالية وأحتاج إلى المساعدة.

echo "gem 'mysql2'" >>Gemfile
bundle install --no-deployment

يؤدي تشغيل ما سبق إلى ظهور الخطأ التالي. لقد تحققت من Gemfile ويحتوي فقط على هذا السطر الواحد - gem ‘mysql2’

لا يتضمن ملف Gemfile هذا مصدرًا عالميًا صريحًا.
قد يؤدي عدم استخدام مصدر عالمي صريح إلى إنشاء ملف قفل مختلف اعتمادًا على الأحجار الكريمة التي قمت بتثبيتها محليًا قبل تشغيل bundler.
بدلاً من ذلك، قم بتعريف مصدر عالمي في ملف Gemfile الخاص بك على النحو التالي: source "https://rubygems.org".
تعذر العثور على gem 'mysql2' في الأحجار الكريمة المثبتة محليًا.
root@ip-172-566-459-13-app:/#

حسنًا، لقد تمكنت من الانتقال إلى الخطوة التالية. نشر شخص ما أعلاه أننا بحاجة إلى أن نكون في مجلد /var/www/discourse على الحاوية ثم إضافة الجيم.

الآن في الخطوة النهائية

RAILS_ENV=production bundle exec ruby script/import_scripts/xenforo.rb

أتلقى هذا الخطأ. ما الذي يمكن أن أفعله بشكل خاطئ؟

`/var/www/discourse/vendor/bundle/ruby/3.3.0/gems/activerecord-7.2.2.1/lib/active_record/connection_adapters/postgresql_adapter.rb:63:in “rescue in new_client”: لم نتمكن من العثور على قاعدة بياناتك: discourse.
يمكن العثور على تكوينات قاعدة البيانات المتاحة في config/database.yml. (ActiveRecord::NoDatabaseError) لحل هذه المشكلة:

  • هل لم تقم بإنشاء قاعدة البيانات، أو هل قمت بحذفها؟ لإنشاء قاعدة البيانات، قم بتشغيل:
    bin/rails db:create
  • هل تغير اسم قاعدة البيانات؟ تحقق من أن config/database.yml يحتوي على اسم قاعدة البيانات الصحيح.`

تم الحل: كنت أقوم بتشغيل بصفتي المستخدم الجذر، واضطررت إلى التبديل إلى المستخدم ‘discourse’. بدأ الاستيراد.

4 إعجابات

لذا، لقد حصلت على خادم جيد بشكل معقول بمعالج 4 وحدات معالجة مركزية وذاكرة وصول عشوائي بسعة 16 جيجابايت. بالوتيرة التي يتم بها ترحيل المشاركات، سيستغرق الأمر 9 أيام لترحيل المشاركات فقط. استغرق المستخدمون ساعتين ونصف لترحيلهم. من الآمن القول أن هذا لن يكون مناسبًا لي كما هو الحال الآن، ولكن على الأقل يمكنني قضاء بضعة أشهر في التعرف على الأمر حتى أجد حلاً لهذه الهجرة الجماعية.

ملاحظة:
في نص الترحيل البرمجي، أرى أنه لا يتم استيراد رسائل البريد الإلكتروني المكررة. ما هي الطرق المختلفة التي يتم بها تحديد التكرار؟ لاحظت أن xyz@gmail.com تُعامل بنفس طريقة xyz+1@gmail.com و xy.z@gmail.com

هل هناك أي نمط آخر أيضًا.

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

في الوقت الحاضر، أقوم دائمًا بعمليات الترحيل محليًا. كم عدد المشاركات لديك؟

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

2.5 مليون مشاركة.
سأحاول الترحيل المحلي على جهاز ماك M1 للمقارنة.

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

هذا هو كل شيء تقريبًا. يتم إجراء فحص التفرد على النسخة المحولة إلى أحرف صغيرة والمُطَبَّعة لعنوان البريد الإلكتروني المُعطى.

نقوم بالتطبيع عن طريق إزالة جميع النقاط وتجاهل كل شيء بعد علامة + في اسم المستخدم.

3 إعجابات

سرعة وحدة المعالجة المركزية الفردية هي العامل المهم.

على أجهزتي، معدل 800-1000 مستخدم أو منشورات/دقيقة هو أمر شائع إلى حد ما.

لاحظ أنه عند إجراء الاستيراد النهائي، سيتم استيراد المستخدمين والمنشورات التي لم يتم استيرادها بالفعل فقط، لذلك لن يستغرق الأمر وقتًا طويلاً.

قم بإيقاف تشغيل إعداد الموقع Normalize emails (كان الإيقاف هو الافتراضي حتى وقت قريب). ربما يجب إيقاف تشغيله في هذه الوظيفة هنا:

يمكنك وضعه في نسختك المخصصة من البرنامج النصي لـ xenforo مع SiteSetting.normalize_emails=false. لست متأكدًا مما حدث لهؤلاء المستخدمين الذين لديهم رسائل بريد إلكتروني مكررة؛ هناك شيئان واضحان للقيام بهما، إعطاؤهم عنوان بريد إلكتروني وهمي أو تخطي استيرادهم. يبدو أنه يعطيهم عناوين وهمية؟ (وهناك فرصة جيدة جدًا لأنهم في الواقع مستخدمون وهميون على أي حال). إذا تخطاهم، فإن تشغيل البرنامج النصي مرة أخرى سيقوم باستيرادهم.

3 إعجابات

نعم على حاسوبي المحمول، إنه يعالج الأمور بشكل أسرع بكثير بمعدل 1000 عنصر في الدقيقة. هذا أسرع بحوالي مرتين من الخادم. ومع ذلك، هذا يستغرق حوالي 3 أيام.

لقد مررت عبر رسائل البريد الإلكتروني التي تم تخطيها ويبدو أنه يقوم بعمل جيد في تجاهل تلك الحسابات. سأقوم بدمجها قبل الاستيراد النهائي. بالكاد 20 حالة من هذا القبيل.

لاحظ أنه عندما تقوم بالاستيراد النهائي، فإنه سيستورد فقط المستخدمين والمشاركات التي لم يتم استيرادها بالفعل، لذلك لن يستغرق الأمر وقتًا طويلاً.

شكراً لتوضيحك هذا. لقد لاحظت هذا بنفسي ويبدو أن هذا هو ما سينقذ الموقف عندما أقوم بالاستيراد النهائي. لذا، هل أقوم بعمل نسخة احتياطية واستعادة في اليوم الثالث ثم نسخة احتياطية واستعادة أخرى مع ملف النسخ الاحتياطي الجديد لقاعدة البيانات في اليوم صفر. هل هذا صحيح؟

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

هل هذه النسخ الاحتياطية والاستعادة على موقع Xenforo، أم لديك موقع Discourse مباشر ستقوم باستيراد بيانات Xenforo إليه؟

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

إذا كان لديك بالفعل بيانات على موقع Discourse الخاص بك تريد الاحتفاظ بها، فإن الأمور تكون أكثر تعقيدًا نظرًا لأنك ستحتاج إلى تجميد هذا الموقع، ثم الحصول على بيانات Xenforo والمتابعة كما هو موضح أعلاه.

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

سيكون تثبيتًا جديدًا لـ Discourse، مما يجعل الأمر سهلاً.

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

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

إعجابَين (2)

هل عاد الاستيراد باستعلام، هل يخرج البرنامج النصي للاستيراد أي سجلات؟ لقد توقف الاستيراد التجريبي الخاص بي عند 98.2٪ لبضع ساعات.

شيء آخر أدركته، إذا قمت بإعادة تشغيل الترحيل، فسيستغرق الأمر حوالي 30 ثانية لتجاوز دفعة من 1000 منشور. لذا فإن السرعة الفعالة هي الآن 2000 عنصر في الدقيقة. ليس تحسنًا كبيرًا مقارنة بـ 1000 منشور في الدقيقة للاستيراد الأول، حيث سيستغرق الأمر يومًا واحدًا حتى في آخر استيراد في يوم التبديل. 23 ساعة منها ستكون مجرد تخطي العناصر التي تم استيرادها بالفعل.

فقط ما تراه.

يجب عليك على الأرجح إيقافه وتشغيله مرة أخرى.

نعم، سيتخطى جميع البيانات التي تم استيرادها بالفعل. وهو يفعل ذلك أسرع بكثير من 2000 مشاركة/دقيقة. أشك في أنك سترى ذلك عندما تعيد تشغيله الآن.

هذا ما فعلته، لقد أعدت التشغيل ثم قمت بالنشر أعلاه. إنها 2000 مشاركة/دقيقة. للتأكد، جربتها مرة أخرى.

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

تمكنت من استيراد الصور الرمزية والمرفقات. نسخت هذه المجلدات.

/internal_data/attachments
/data/avatars

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

الآن أحتاج فقط إلى معرفة كيفية استيراد المحادثات (يمكن تخطيها أيضًا ولكن من الجيد الحصول عليها) وإعادة التوجيه الدائمة.

@Fajfi - شكرًا لك على مساهمتك في برنامج الاستيراد النصي. لقد عمل بشكل لا تشوبه شائبة مع الصور الرمزية والمرفقات. لا يزال قيد التشغيل ولم يصل إلى جزء الإعجابات بعد.

تم إصلاح استيراد المحادثات. تمكنت من استيراد أكثر من نصف مليون رسالة من XF2.3 إلى discourse. لقد رفعت طلب سحب (PR) في حال كان هناك شخص مهتم.

----تعديل----

تم رفع طلب سحب آخر مع إصلاح لاستيراد الإعجابات. من المدهش أنه لم يقم أحد بالترحيل من XF2.1+ إلى discourse حتى الآن. تمت إعادة تسمية الإعجابات إلى ردود فعل في عام 2019 عندما تم إصدار XF2.1.

6 إعجابات

واجهت بعض المشكلات مع عدم استيراد المرفقات.

تظهر هذه المشكلة من خلال [attach]number[/attach] في المنشورات.

أعتقد أن السبب يعود إلى أسماء الملفات المشفرة التي تخرج أحيانًا عن التزامن مع سجل قاعدة البيانات

قمت بتغيير هذا الجزء من الكود:

def import_xf_attachment(data_id, file_hash, owner_id, original_filename)
    matching_files = Dir.glob(File.join(ATTACHMENT_DIR, "**", "#{data_id}-*.data"))
    filename = matching_files.first

    upload = nil
    if filename && File.exist?(filename)
      path = Pathname.new(filename)
      new_path = path.dirname.join(original_filename)
      FileUtils.cp path, new_path
      upload = @uploader.create_upload owner_id, new_path, original_filename
      FileUtils.rm(new_path) if File.exist?(new_path)
    else
      STDERR.puts "Could not find file for data_id #{data_id}. Skipping."
    end
    upload
  end

يبدو أنه يعمل بشكل صحيح في استيراد XenForo 2.3 إلى Discourse الخاص بي.

إعجابَين (2)

لم أحقق نتيجة رائعة بعد. توقفت العملية بعد استيراد ثلاثة أرباع مليون سجل وشكت من عدم وجود XFMG.

سيتعين عليّ تجميع المزيد من الحماس لجعلها تعمل

لقد أعيدت هيكلة الكثير من الكود. يبدو أن هذا يعمل بشكل جيد في الوقت الحالي. الحقول المخصصة التي قد تكون لديك لن يتم نقلها، لكنني أعدت كتابتها مرة أخرى (انظر أدناه) وسأنظف عملية الاستيراد وأبدأ من جديد.

تم التأكد من العمل على Xenforo 2.3 إلى Discourse المستضاف ذاتيًا:

المستخدمون

حقول المستخدم المخصصة

الرسائل الخاصة / المحادثات

معرض الوسائط في XenForo

مدير الموارد في Xenforo

الإعجابات والتفاعلات

التعامل مع عناوين البريد الإلكتروني المكررة وغير الصالحة

معالجة رموز BB في XF2.3

معرض الوسائط في XF

موارد XF

قمت باختبار هذا على 750 ألف منشور، و20 ألف عضو، ومرفقات بحجم 20.7 جيجابايت. تستغرق عملية الاستيراد حوالي 20 ساعة. بضع ساعات إضافية بعد الاستيراد حتى يلحق sidekiq بالركب.

نقاط يجب ملاحظتها.

اضطررت للسماح لـ docker بالوصول إلى قاعدة البيانات المحلية في CSF.

التركيبات (mounts) مهمة:

  ATTACHMENT_DIR = ENV["XF_ATTACHMENT_DIR"] || "/shared/import/internal_data/attachments"
  AVATAR_DIR     = ENV["XF_AVATAR_DIR"]     || "/shared/import/data/avatars"

هذا يوفر عليك نقل البيانات إلى حاوية docker. أنت تقوم بالربط هنا

# frozen_string_literal: true

# سكربت استيراد من XenForo 2.3 إلى Discourse
# مُعد خصيصًا لـ TurboRenault (dev.turborenault.co.uk)
# يدعم:
#   - أكثر من 750,000 منشور مع ترقيم الصفحات بالمفتاح (Keyset Pagination) (دفعة سريعة O(1))
#   - مرفقات وأفاتارات بحجم 20.7 جيجابايت (حل مسارات XF 2.3)
#   - حقول ملف تعريف المستخدم المخصصة وحقول الموضوع/المنشور المخصصة
#   - مجموعات المستخدمين وعضويات المجموعات الثانوية (اختصار آمن من التصادم بطول 20 حرفًا)
#   - الرسائل الخاصة / المحادثات والمستلمون المتعددون (مع مسار بديل للمستخدمين المحذوفين)
#   - معرض الوسائط في XenForo (XFMG)
#   - مدير الموارد في XenForo (XFRM مع حل دقيق للمواضيع والتحديثات)
#   - الإعجابات والتفاعلات
#   - معالجة تلقائية بديلة للبريد الإلكتروني المكرر وغير الصالح
#   - محلل تنسيق BBCode / التنسيق في XF 2.3
#
# التنفيذ:
#   cd /var/discourse
#   ./launcher enter turborenault
#   su - discourse
#   cd /var/www/discourse
#   RAILS_ENV=production bundle exec ruby /shared/xenforo23.rb

require "mysql2"
require "set"

# حل مسار قوي لـ base.rb
base_path = File.expand_path("../base.rb", __FILE__)
base_path = "/var/www/discourse/script/import_scripts/base.rb" unless File.exist?(base_path)
require base_path

class ImportScripts::XenForo23 < ImportScripts::Base
  # بيانات اعتماد قاعدة البيانات المعبأة مسبقًا
  XENFORO_DB    = ENV["XF_DB_NAME"] || "YourDB"
  DB_HOST       = ENV["XF_DB_HOST"] || "172.17.0.1"
  DB_PORT       = (ENV["XF_DB_PORT"] || 3306).to_i
  DB_USER       = ENV["XF_DB_USER"] || "YourUserName"
  DB_PASS       = ENV["XF_DB_PASS"] || "YourPassword"
  
  TABLE_PREFIX  = ENV["XF_TABLE_PREFIX"] || "xf_"
  BATCH_SIZE    = (ENV["XF_BATCH_SIZE"] || 2000).to_i

  # المسارات المركبة
  ATTACHMENT_DIR = ENV["XF_ATTACHMENT_DIR"] || "/shared/import/internal_data/attachments"
  AVATAR_DIR     = ENV["XF_AVATAR_DIR"]     || "/shared/import/data/avatars"

  # مفاتيح الميزات
  IMPORT_PM            = ENV.fetch("IMPORT_PM", "true") == "true"
  IMPORT_LIKES         = ENV.fetch("IMPORT_LIKES", "true") == "true"
  IMPORT_XFMG          = ENV.fetch("IMPORT_XFMG", "true") == "true"
  IMPORT_XFRM          = ENV.fetch("IMPORT_XFRM", "true") == "true"
  IMPORT_CUSTOM_FIELDS = ENV.fetch("IMPORT_CUSTOM_FIELDS", "true") == "true"

  def initialize
    super

    puts "--> جارٍ الاتصال بقاعدة بيانات MySQL '#{XENFORO_DB}' على #{DB_HOST}:#{DB_PORT} باسم المستخدم '#{DB_USER}'..."
    @client = Mysql2::Client.new(
      host: DB_HOST,
      port: DB_PORT,
      username: DB_USER,
      password: DB_PASS,
      database: XENFORO_DB,
      symbolize_keys: true,
      encoding: "utf8mb4",
      reconnect: true
    )

    @category_mappings = {}
    @group_mappings = {}
    @user_custom_fields_map = {}
    @seen_emails = Set.new
    @missing_files_count = 0
  end

  def execute
    optimize_discourse_for_import

    import_custom_user_field_definitions if IMPORT_CUSTOM_FIELDS
    import_users
    import_groups
    import_categories
    import_posts

    import_xfrm if IMPORT_XFRM
    import_xfmg if IMPORT_XFMG
    import_private_messages if IMPORT_PM
    import_likes if IMPORT_LIKES

    restore_discourse_settings
    report_missing_files_summary
  end

  # =========================================================================
  # ضبط الأداء لـ Discourse الحديثة
  # =========================================================================

  def optimize_discourse_for_import
    puts "", "--> جارٍ تطبيق تحسينات الاستيراد عالية السرعة لـ Discourse..."
    begin
      SiteSetting.disable_emails = "all" if SiteSetting.respond_to?(:disable_emails=)
      SiteSetting.process_with_fastimage = false if SiteSetting.respond_to?(:process_with_fastimage=)
      SiteSetting.max_topic_title_length = 500 if SiteSetting.respond_to?(:max_topic_title_length=)
      SiteSetting.min_topic_title_length = 1 if SiteSetting.respond_to?(:min_topic_title_length=)
      SiteSetting.min_post_length = 1 if SiteSetting.respond_to?(:min_post_length=)
      SiteSetting.allow_duplicate_topic_titles = true if SiteSetting.respond_to?(:allow_duplicate_topic_titles=)
      RateLimiter.disable rescue nil
    rescue StandardError => e
      puts "   ملاحظة الضبط: #{e.message}"
    end
  end

  def report_missing_files_summary
    if @missing_files_count > 0
      puts "", "   *** لم يتم العثور على #{@missing_files_count} ملف على القرص أثناء الاستيراد ***"
    else
      puts "   تم العثور على جميع ملفات المرفقات/الافاتارات بنجاح."
    end
  end

  def log_missing_file(type, context = {})
    @missing_files_count += 1
    label = type.to_s.upcase
    details = context.map { |k, v| "  #{k}: #{v}" }.join("\n")
    puts "\n   [ملف مفقود #{label}]\n#{details}"
  end

  def restore_discourse_settings
    puts "", "--> جارٍ استعادة إعدادات الموقع الافتراضية لـ Discourse..."
    begin
      SiteSetting.disable_emails = "non_staff" if SiteSetting.respond_to?(:disable_emails=)
      RateLimiter.enable rescue nil
    rescue StandardError
      # تجاهل
    end
    puts "--> اكتمل الاستيراد بنجاح!"
  end

  # =========================================================================
  # 1. حقول ملف تعريف المستخدم المخصصة
  # =========================================================================

  def import_custom_user_field_definitions
    puts "", "--> جارٍ استيراد تعريفات حقول المستخدم المخصصة..."
    fields = mysql_query("SELECT field_id, field_type FROM #{TABLE_PREFIX}user_field").to_a
    
    phrases = {}
    begin
      phrase_rows = mysql_query("SELECT title, phrase_text FROM #{TABLE_PREFIX}phrase WHERE title LIKE 'user_field_title.%' AND language_id = 1").to_a
      phrase_rows = mysql_query("SELECT title, phrase_text FROM #{TABLE_PREFIX}phrase WHERE title LIKE 'user_field_title.%'") if phrase_rows.empty?
      phrase_rows.each do |p|
        fid = p[:title].to_s.sub("user_field_title.", "")
        phrases[fid] = p[:phrase_text]
      end
    rescue StandardError => e
      puts "   ملاحظة العبارات: #{e.message}"
    end

    fields.each do |row|
      field_id = row[:field_id]
      title = phrases[field_id].presence || field_id.to_s.tr("_", " ").split.map(&:capitalize).join(" ")
      desc = "حقل ملف شخصي مخصص: #{title}"

      field_type = case row[:field_type]
                   when "textbox", "textarea" then "text"
                   when "select", "radio" then "dropdown"
                   when "checkbox" then "confirm"
                   else "text"
                   end

      uf = UserField.find_or_create_by!(name: title) do |f|
        f.description = desc
        f.field_type = field_type
        f.editable = true
        f.show_on_profile = true
        f.show_on_user_card = true
      end

      @user_custom_fields_map[field_id] = uf.id
    end
    puts "   تم ربط #{@user_custom_fields_map.size} تعريف لحقول الملف الشخصي المخصصة."
  end

  def fetch_user_custom_fields(xf_user_id)
    return {} unless IMPORT_CUSTOM_FIELDS && @user_custom_fields_map.present?

    sql = "SELECT field_id, field_value FROM #{TABLE_PREFIX}user_field_value WHERE user_id = #{xf_user_id.to_i}"
    rows = mysql_query(sql).to_a
    return {} if rows.empty?

    custom_fields = {}
    rows.each do |row|
      discourse_field_id = @user_custom_fields_map[row[:field_id]]
      next unless discourse_field_id

      val = row[:field_value]
      if val.to_s.start_with?("a:", "s:", "i:", "b:")
        begin
          val = PHP.unserialize(val)
          val = val.values.join(", ") if val.is_a?(Hash)
          val = val.join(", ") if val.is_a?(Array)
        rescue StandardError
          # اتركه كما هو
        end
      elsif val.to_s.start_with?("[", "{")
        begin
          parsed = JSON.parse(val)
          val = parsed.is_a?(Array) ? parsed.join(", ") : val
        rescue JSON::ParserError
          # اتركه كما هو
        end
      end

      custom_fields["user_field_#{discourse_field_id}"] = val.to_s if val.present?
    end

    custom_fields
  end

  # =========================================================================
  # 2. المستخدمون والافاتارات
  # =========================================================================

  def import_users
    puts "", "--> جارٍ استيراد المستخدمين..."
    total_count = mysql_query("SELECT COUNT(*) AS count FROM #{TABLE_PREFIX}user WHERE user_state = 'valid' AND is_banned = 0").to_a.first[:count]
    puts "   تم العثور على #{total_count} مستخدم صالح."

    last_user_id = 0
    processed = 0

    loop do
      sql = "SELECT u.user_id, u.username, u.email, u.custom_title, u.register_date,
                    u.last_activity, u.user_group_id, u.is_moderator, u.is_admin, u.is_staff, u.avatar_date
             FROM #{TABLE_PREFIX}user u
             WHERE u.user_state = 'valid' AND u.is_banned = 0 AND u.user_id > #{last_user_id}
             ORDER BY u.user_id ASC
             LIMIT #{BATCH_SIZE}"

      results = mysql_query(sql).to_a

      break if results.empty?

      last_user_id = results.last[:user_id]
      next if all_records_exist?(:users, results.map { |u| u[:user_id] })

      create_users(results, total: total_count, offset: processed) do |user|
        username = clean_username(user[:username])
        next if username.blank?

        email = clean_email(user[:email], user[:user_id])
        c_fields = fetch_user_custom_fields(user[:user_id])

        {
          id: user[:user_id],
          email: email,
          username: username,
          title: user[:custom_title],
          created_at: Time.zone.at(user[:register_date]),
          last_seen_at: Time.zone.at(user[:last_activity]),
          moderator: user[:is_moderator] == 1 || user[:is_staff] == 1,
          admin: user[:is_admin] == 1,
          custom_fields: c_fields,
          post_create_action: proc { |u| import_avatar(user[:user_id], user[:avatar_date], u) }
        }
      end

      processed += results.size
    end
  end

  def clean_username(name)
    return "" if name.blank?
    name = name.tr(" ", "_").gsub(/[^a-zA-Z0-9_\-\.]/, "")
    name.first(60)
  end

  def clean_email(email, user_id)
    email_str = email.to_s.strip.downcase

    # التحقق من الصلاحية القياسية
    unless email_str.present? && email_str.include?("@") && email_str.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/)
      return "user_#{user_id}@imported.invalid"
    end

    # التحقق من التكرار عبر مجموعة البيانات أو جدول UserEmail الحالي في Discourse
    if @seen_emails.include?(email_str) || UserEmail.exists?(email: email_str)
      prefix = email_str.split("@").first.gsub(/[^a-zA-Z0-9]/, "")
      return "duplicate_#{user_id}_#{prefix}@imported.invalid"
    end

    @seen_emails.add(email_str)
    email_str
  end

  def import_avatar(xf_user_id, avatar_date, imported_user)
    return if avatar_date.to_i == 0

    group_id = xf_user_id / 1000
    filename = File.join(AVATAR_DIR, "l", group_id.to_s, "#{xf_user_id}.jpg")
    filename = File.join(AVATAR_DIR, "o", group_id.to_s, "#{xf_user_id}.jpg") unless File.exist?(filename)
    unless File.exist?(filename)
      log_missing_file(:avatar,
        user_id: xf_user_id,
        expected_l: File.join(AVATAR_DIR, "l", group_id.to_s, "#{xf_user_id}.jpg"),
        expected_o: File.join(AVATAR_DIR, "o", group_id.to_s, "#{xf_user_id}.jpg")
      )
      return
    end

    upload = create_upload(imported_user.id, filename, "avatar_#{xf_user_id}.jpg")
    return if !upload&.persisted?

    imported_user.create_user_avatar if imported_user.user_avatar.nil?
    imported_user.user_avatar.update(custom_upload_id: upload.id)
    imported_user.update(uploaded_avatar_id: upload.id)
  rescue StandardError => e
    STDERR.puts "خطأ في استيراد الأفاتار للمستخدم #{xf_user_id}: #{e.message}"
  end

  # =========================================================================
  # 3. مجموعات المستخدمين والعضويات (اختصار آمن من التصادم)
  # =========================================================================

  def import_groups
    puts "", "--> جارٍ استيراد مجموعات المستخدمين والعضويات..."

    existing_group_names = Set.new(Group.pluck(:name))
    xf_groups = mysql_query("SELECT user_group_id, title FROM #{TABLE_PREFIX}user_group").to_a

    xf_groups.each do |g|
      xf_gid = g[:user_group_id]
      raw_title = CGI.unescapeHTML(g[:title].to_s.strip)

      # تخطي مجموعات النظام الافتراضية في XF (1 = غير مسجل، 2 = مسجل)
      next if [1, 2].include?(xf_gid)

      sanitised_name = generate_unique_group_name(raw_title, xf_gid, existing_group_names)
      next if sanitised_name.blank?

      group = Group.find_by(name: sanitised_name) || Group.create!(
        name: sanitised_name,
        full_name: raw_title,
        visibility_level: Group.visibility_levels[:members]
      )

      @group_mappings[xf_gid] = group.id
    end

    puts "   تم ربط #{@group_mappings.size} مجموعة مستخدمين مخصصة."

    last_user_id = 0

    loop do
      relations = mysql_query(
        "SELECT user_id, user_group_id 
         FROM #{TABLE_PREFIX}user_group_relation 
         WHERE user_id > #{last_user_id}
         ORDER BY user_id ASC
         LIMIT #{BATCH_SIZE}"
      ).to_a

      break if relations.empty?
      last_user_id = relations.last[:user_id]

      relations.each do |r|
        discourse_group_id = @group_mappings[r[:user_group_id]]
        next unless discourse_group_id

        discourse_user_id = user_id_from_imported_user_id(r[:user_id])
        next unless discourse_user_id

        GroupUser.find_or_create_by!(
          group_id: discourse_group_id,
          user_id: discourse_user_id
        )
      end
    end

    puts "   تم استيراد عضويات المجموعات بنجاح."
  end

  def generate_unique_group_name(title, group_id, existing_names)
    base_slug = title.to_s.parameterize(separator: '_')
    base_slug = "group_#{group_id}" if base_slug.blank?

    candidate = base_slug.slice(0, 20).chomp('_')

    counter = 1
    while existing_names.include?(candidate)
      suffix = "_#{counter}"
      max_base_len = 20 - suffix.length
      
      trimmed_base = base_slug.slice(0, max_base_len).chomp('_')
      candidate = "#{trimmed_base}#{suffix}"
      counter += 1
    end

    existing_names.add(candidate)
    candidate
  end

  # =========================================================================
  # 4. الفئات (هرمية المنتدى)
  # =========================================================================

  def import_categories
    puts "", "--> جارٍ استيراد الفئات..."

    nodes = mysql_query(
      "SELECT n.node_id, n.title, n.description, n.parent_node_id, n.node_name, n.display_order, n.node_type_id
       FROM #{TABLE_PREFIX}node n
       WHERE n.node_type_id IN ('Category', 'Forum')
       ORDER BY n.parent_node_id ASC, n.display_order ASC"
    ).to_a

    top_level = nodes.select { |n| n[:parent_node_id] == 0 }
    create_categories(top_level) do |c|
      {
        id: c[:node_id],
        name: CGI.unescapeHTML(c[:title]),
        description: c[:description],
        position: c[:display_order],
        post_create_action: proc do |cat|
          Permalink.find_or_create_by(url: "forums/#{c[:node_name]}.#{c[:node_id]}", category_id: cat.id) if c[:node_name].present?
        end
      }
    end

    top_ids = Set.new(top_level.map { |c| c[:node_id] })
    second_level = nodes.select { |n| top_ids.include?(n[:parent_node_id]) }
    create_categories(second_level) do |c|
      {
        id: c[:node_id],
        name: CGI.unescapeHTML(c[:title]),
        description: c[:description],
        position: c[:display_order],
        parent_category_id: category_id_from_imported_category_id(c[:parent_node_id]),
        post_create_action: proc do |cat|
          Permalink.find_or_create_by(url: "forums/#{c[:node_name]}.#{c[:node_id]}", category_id: cat.id) if c[:node_name].present?
        end
      }
    end

    second_ids = Set.new(second_level.map { |c| c[:node_id] })
    deeper = nodes.reject { |n| n[:parent_node_id] == 0 || top_ids.include?(n[:node_id]) || second_ids.include?(n[:node_id]) }

    deeper.each do |c|
      parent = c
      while parent && !second_ids.include?(parent[:node_id]) && !top_ids.include?(parent[:node_id])
        parent = nodes.find { |n| n[:node_id] == parent[:parent_node_id] }
      end
      if parent
        @category_mappings[c[:node_id]] = category_id_from_imported_category_id(parent[:node_id])
      end
    end
  end

  # =========================================================================
  # 5. المواضيع والمنشورات (عداد مباشر لأكثر من 750,000 منشور)
  # =========================================================================

  def import_posts
    puts "", "--> جارٍ استيراد المواضيع والمنشورات (ترقيم صفحات بالمفتاح لـ 750 ألف)..."

    total_posts = mysql_query("SELECT COUNT(*) AS count FROM #{TABLE_PREFIX}post p INNER JOIN #{TABLE_PREFIX}thread t ON p.thread_id = t.thread_id WHERE p.message_state = 'visible' AND t.discussion_state = 'visible'").to_a.first[:count]
    puts "   إجمالي المنشورات المراد استيرادها: #{total_posts}"

    last_post_id = 0
    processed = 0

    loop do
      results = mysql_query(
        "SELECT p.post_id AS id,
                p.thread_id AS topic_id,
                t.node_id AS category_id,
                t.title AS title,
                t.first_post_id AS first_post_id,
                t.view_count AS view_count,
                t.prefix_id AS prefix_id,
                p.user_id AS user_id,
                p.message AS raw,
                p.post_date AS created_at
         FROM #{TABLE_PREFIX}post p
         INNER JOIN #{TABLE_PREFIX}thread t ON p.thread_id = t.thread_id
         WHERE p.message_state = 'visible'
           AND t.discussion_state = 'visible'
           AND p.post_id > #{last_post_id}
         ORDER BY p.post_id ASC
         LIMIT #{BATCH_SIZE}"
      ).to_a

      break if results.empty?

      last_post_id = results.last[:id]
      next if all_records_exist?(:posts, results.map { |r| r[:id] })

      create_posts(results, total: total_posts, offset: processed) do |m|
        skip = false
        mapped = {}

        mapped[:id] = m[:id]
        mapped[:user_id] = user_id_from_imported_user_id(m[:user_id]) || Discourse::SYSTEM_USER_ID
        mapped[:raw] = process_xenforo_post(m[:raw], m[:id])
        mapped[:created_at] = Time.zone.at(m[:created_at])

        if m[:id] == m[:first_post_id]
          cat_id = category_id_from_imported_category_id(m[:category_id]) || @category_mappings[m[:category_id]] || SiteSetting.uncategorized_category_id
          mapped[:category] = cat_id
          mapped[:title] = CGI.unescapeHTML(m[:title])
          mapped[:views] = m[:view_count]
          
          tags = fetch_thread_tags(m[:topic_id])
          mapped[:tags] = tags if tags.present?

          mapped[:post_create_action] = proc do |pp|
            Permalink.find_or_create_by(url: "threads/#{m[:topic_id]}", topic_id: pp.topic_id)
          end
        else
          parent = topic_lookup_from_imported_post_id(m[:first_post_id])
          if parent
            mapped[:topic_id] = parent[:topic_id]
          else
            skip = true
          end
        end

        skip ? nil : mapped
      end

      processed += results.size
    end
  end

  def fetch_thread_tags(thread_id)
    sql = "SELECT t.tag FROM #{TABLE_PREFIX}tag_content tc INNER JOIN #{TABLE_PREFIX}tag t ON tc.tag_id = t.tag_id WHERE tc.content_type = 'thread' AND tc.content_id = #{thread_id.to_i}"
    rows = mysql_query(sql).to_a
    rows.map { |r| DiscourseTagging.clean_tag(r[:tag]) }.compact
  end

  # =========================================================================
  # 6. مدير الموارد في XenForo (XFRM)
  # =========================================================================

 # =========================================================================
  # 6. مدير الموارد في XenForo (XFRM)
  # =========================================================================

  def import_xfrm
    puts "", "--> جارٍ استيراد مدير الموارد في XenForo (XFRM)..."

    has_xfrm = mysql_query("SHOW TABLES LIKE '#{TABLE_PREFIX}rm_resource'").to_a.present?
    unless has_xfrm
      puts "   لم يتم العثور على جداول XFRM، جارٍ التخطي."
      return
    end

    xfrm_cat = Category.find_by_name("Resources") || Category.create!(name: "Resources", user_id: Discourse::SYSTEM_USER_ID)

    resources = mysql_query(
      "SELECT r.resource_id, r.title, r.tag_line, r.user_id, r.resource_category_id, r.resource_date, r.discussion_thread_id,
              t.first_post_id, u.message AS update_message, p.message AS post_message
       FROM #{TABLE_PREFIX}rm_resource r
       LEFT JOIN #{TABLE_PREFIX}thread t ON t.thread_id = r.discussion_thread_id
       LEFT JOIN #{TABLE_PREFIX}post p ON p.post_id = t.first_post_id
       LEFT JOIN #{TABLE_PREFIX}rm_resource_update u ON u.resource_update_id = r.description_update_id
       WHERE r.resource_state = 'visible'"
    ).to_a

    puts "   تم العثور على #{resources.size} مورد XFRM."

    resources.each do |res|
      raw_msg = res[:update_message].presence || res[:post_message].presence
      body_text = raw_msg.present? ? process_xenforo_post(raw_msg, res[:first_post_id].to_i) : ""
      tagline   = res[:tag_line].present? ? CGI.unescapeHTML(res[:tag_line]) : ""

      raw = ""
      raw += "**#{tagline}**\n\n" if tagline.present?
      raw += body_text.present? ? body_text : "*(لم يتم تقديم وصف)*"

      # --- جديد: جلب ملفات الموارد الفعلية (ZIPS, PDFS) ---
      # تم تحديث 'v.version_id' إلى 'v.resource_version_id' في XenForo 2
      version_files_sql = "
        SELECT a.attachment_id, a.data_id, d.filename, d.file_hash, d.user_id
        FROM #{TABLE_PREFIX}attachment a
        INNER JOIN #{TABLE_PREFIX}attachment_data d ON a.data_id = d.data_id
        INNER JOIN #{TABLE_PREFIX}rm_resource_version v ON v.resource_version_id = a.content_id
        WHERE a.content_type = 'resource_version' AND v.resource_id = #{res[:resource_id]}
      "
      resource_files = mysql_query(version_files_sql).to_a

      if resource_files.any?
        raw += "\n\n### ملفات قابلة للتحميل\n"
        resource_files.each do |file|
          upload = import_xf_attachment_file(file[:data_id], file[:file_hash], file[:user_id], file[:filename])
          if upload&.persisted?
            html = @uploader.html_for_upload(upload, file[:filename])
            raw += "\n* #{html}"
          end
        end
      end
      
      version_urls_sql = "
        SELECT version_string, download_url 
        FROM #{TABLE_PREFIX}rm_resource_version 
        WHERE resource_id = #{res[:resource_id]} AND download_url != ''
      "
      external_urls = mysql_query(version_urls_sql).to_a
      
      if external_urls.any?
        raw += "\n\n### تحميلات خارجية\n"
        external_urls.each do |ext|
          raw += "\n* [الإصدار #{ext[:version_string]}](#{ext[:download_url]})"
        end
      end
      # --- نهاية جلب الملفات الجديدة ---

      if res[:discussion_thread_id].to_i > 0 && res[:first_post_id].to_i > 0
        topic_map = topic_lookup_from_imported_post_id(res[:first_post_id].to_i)
        if topic_map
          raw += "\n\n---\n*خيط النقاش الأصلي: [عرض هنا](/t/-/#{topic_map[:topic_id]})*"
        end
      end

      user_id = user_id_from_imported_user_id(res[:user_id]) || Discourse::SYSTEM_USER_ID

      create_post = {
        id: "xfrm_v5_#{res[:resource_id]}", # تم رفعها إلى v5 لمسح ذاكرة التخزين المؤقت المعطلة
        user_id: user_id,
        category: xfrm_cat.id,
        title: CGI.unescapeHTML(res[:title]),
        raw: raw,
        created_at: Time.zone.at(res[:resource_date])
      }

      create_posts([create_post], total: resources.size, offset: 0) { |p| p }
    end
  end

    # =========================================================================
  # 7. معرض الوسائط في XenForo (XFMG)
  # =========================================================================

  def import_xfmg
    puts "", "--> جارٍ استيراد معرض الوسائط في XenForo (XFMG)..."

    has_xfmg = mysql_query("SHOW TABLES LIKE '#{TABLE_PREFIX}mg_media_item'").to_a.present?
    unless has_xfmg
      puts "   لم يتم العثور على جداول XFMG، جارٍ التخطي."
      return
    end

    xfmg_cat = Category.find_by_name("Media Gallery") || Category.create!(name: "Media Gallery", user_id: Discourse::SYSTEM_USER_ID)

    items = mysql_query(
      "SELECT m.media_id, m.title, m.description, m.media_type, m.media_tag, m.user_id, m.media_date
       FROM #{TABLE_PREFIX}mg_media_item m
       WHERE m.media_state = 'visible'"
    ).to_a

    puts "   تم العثور على #{items.size} عنصر وسائط XFMG."

    items.each do |item|
      raw = item[:description].presence || ""

      # إذا كان فيديو مضمّنًا (YouTube, Vimeo, إلخ)، شمل رابط التضمين
      if item[:media_type] == "embed" && item[:media_tag].present?
        raw = "#{item[:media_tag]}\n\n#{raw}"
      else
        # معالجة مرفقات الصور باستخدام 'xfmg_media' content_type في XF 2.x
        raw = process_xf_attachments("xfmg_media", raw, item[:media_id])
      end

      raw = "*(لا يوجد محتوى وسائط)*" if raw.blank?

      user_id = user_id_from_imported_user_id(item[:user_id]) || Discourse::SYSTEM_USER_ID

      post_data = {
        id: "xfmg_#{item[:media_id]}",
        user_id: user_id,
        category: xfmg_cat.id,
        title: CGI.unescapeHTML(item[:title].presence || "Media #{item[:media_id]}"),
        raw: raw,
        created_at: Time.zone.at(item[:media_date])
      }

      create_posts([post_data], total: items.size, offset: 0) { |p| p }
    end
  end

  # =========================================================================
  # 8. الرسائل الخاصة (المحادثات)
  # =========================================================================

  def import_private_messages
    puts "", "--> جارٍ استيراد الرسائل الخاصة (المحادثات)..."

    last_conv_id = 0
    total_count = mysql_query("SELECT COUNT(*) AS count FROM #{TABLE_PREFIX}conversation_master").to_a.first[:count]
    puts "   تم العثور على #{total_count} محادثة رسائل خاصة."
    processed = 0

    loop do
      posts = mysql_query(
        "SELECT c.conversation_id, c.title, m.message_id, m.message, m.user_id, m.message_date, c.first_message_id
         FROM #{TABLE_PREFIX}conversation_master c
         INNER JOIN #{TABLE_PREFIX}conversation_message m ON m.conversation_id = c.conversation_id
         WHERE c.conversation_id > #{last_conv_id}
         ORDER BY c.conversation_id ASC, m.message_id ASC
         LIMIT #{BATCH_SIZE}"
      ).to_a

      break if posts.empty?

      last_conv_id = posts.last[:conversation_id]
      next if all_records_exist?(:posts, posts.map { |p| "pm_#{p[:message_id]}" })

      create_posts(posts, total: total_count, offset: processed) do |post|
        user_id = user_id_from_imported_user_id(post[:user_id]) || Discourse::SYSTEM_USER_ID
        message_id = "pm_#{post[:message_id]}"
        raw = process_xenforo_post(post[:message], 0)

        next if raw.blank?

        msg = {
          id: message_id,
          user_id: user_id,
          raw: raw,
          created_at: Time.zone.at(post[:message_date].to_i),
          import_mode: true
        }

        if post[:message_id] == post[:first_message_id]
          msg[:title] = CGI.unescapeHTML(post[:title])
          msg[:archetype] = Archetype.private_message

          recipients_sql = "SELECT user_id FROM #{TABLE_PREFIX}conversation_recipient WHERE conversation_id = #{post[:conversation_id].to_i}"
          recip_rows = mysql_query(recipients_sql).to_a
          recip_user_ids = recip_rows.map { |r| user_id_from_imported_user_id(r[:user_id]) }.compact

          target_usernames = User.where(id: recip_user_ids).pluck(:username)

          if target_usernames.blank?
            fallback_user = User.find_by(id: Discourse::SYSTEM_USER_ID) || User.admins.first
            target_usernames = [fallback_user.username] if fallback_user
          end

          msg[:target_usernames] = target_usernames.join(",")
        else
          first_msg_map = topic_lookup_from_imported_post_id("pm_#{post[:first_message_id]}")
          if first_msg_map
            msg[:topic_id] = first_msg_map[:topic_id]
          else
            next
          end
        end

        msg
      end

      processed += posts.size
      if processed % 5000 == 0
        ActiveRecord::Base.connection.clear_query_cache
        GC.start
      end
    end
  end

  # =========================================================================
  # 9. التفاعلات والإعجابات
  # =========================================================================

def import_likes
    puts "", "--> جارٍ مسح الإعجابات الملوثات وإعادة تعيين ذاكرة التخزين المؤقت من المحاولات السابقة..."
    # مسح الإجراءات الخام
    PostAction.where(post_action_type_id: PostActionType.types[:like]).delete_all
    # تصفير العدادات المخزنة مؤقتًا في واجهة المستخدم
    Post.update_all(like_count: 0)
    UserStat.update_all(likes_given: 0, likes_received: 0)

    puts "--> جارٍ استيراد تفاعلات منشورات XenForo 2.3 (الإعجابات)..."

    # العد باستخدام values.first لسلامة بين محركات التشغيل
    total_count = mysql_query(
      "SELECT COUNT(*) FROM #{TABLE_PREFIX}reaction_content WHERE content_type = 'post' AND is_counted = 1"
    ).first.values.first

    puts "   تم العثور على #{total_count} تفاعل نشط في المنشورات."

    last_reaction_content_id = 0
    processed = 0

    loop do
      # ترقيم الصفحات بأمان باستخدام المفتاح الأساسي (reaction_content_id)
      reactions = mysql_query(<<-SQL).to_a
        SELECT reaction_content_id, content_id, reaction_user_id, reaction_date
        FROM #{TABLE_PREFIX}reaction_content
        WHERE content_type = 'post'
          AND is_counted = 1
          AND reaction_content_id > #{last_reaction_content_id}
        ORDER BY reaction_content_id ASC
        LIMIT #{BATCH_SIZE}
      SQL

      break if reactions.empty?

      # استخراج أعلى مفتاح أساسي بأمان لدفعة ترقيم الصفحات التالية
      last_row = reactions.last
      last_reaction_content_id = (last_row[:reaction_content_id] || last_row["reaction_content_id"]).to_i

      create_likes(reactions, total: total_count, offset: processed) do |row|
        xf_post_id = row[:content_id] || row["content_id"]
        xf_user_id = row[:reaction_user_id] || row["reaction_user_id"]
        xf_date    = row[:reaction_date] || row["reaction_date"]

        # تمرير معرّفات XENFORO الخام مباشرة
        # يقوم مساعد `create_likes` في Discourse بترجمة هذه تلقائيًا في الخلفية.
        {
          post_id: xf_post_id,
          user_id: xf_user_id,
          created_at: Time.zone.at(xf_date.to_i)
        }
      end

      processed += reactions.size

      if processed % 10000 == 0
        ActiveRecord::Base.connection.clear_query_cache
        GC.start
      end
    end
  end

  # =========================================================================
  # 10. محلل BBCode ومحوّل التنسيق
  # =========================================================================

  def process_xenforo_post(raw, import_id)
    return "" if raw.blank?
    s = raw.dup

    s = s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "") unless s.valid_encoding?

    s.gsub!(%r{<!-- s(\S+) --><img (?:[^>]+) /><!-- s(?:\S+) -->}, '\1')
    s.gsub!(%r{<!-- \w --><a(?:.+)href="(\S+)"(?:.*)>(.+)</a><!-- \w -->}, '[\2](\1)')
    s.gsub!(/:(?:\w{8})\]/, "]")

    s = CGI.unescapeHTML(s)

    s.gsub!(/\[QUOTE="?([^,\]]+)(?:,\s*post:\s*(\d+))?(?:,\s*member:\s*\d+)"?\)"]/i) do
      username = $1
      imported_post_id = $2

      if imported_post_id.present?
        topic_mapping = topic_lookup_from_imported_post_id(imported_post_id.to_i)
        if topic_mapping
          "\n[quote=\"#{username}, post:#{topic_mapping[:post_number]}, topic:#{topic_mapping[:topic_id]}\"]\n"
        else
          "\n[quote=\"#{username}\"]\n"
        end
      else
        "\n[quote=\"#{username}\"]\n"
      end
    end

    s.gsub!(%r{\[/QUOTE\]}i, "\n[/quote]\n")
    s.gsub!(%r{\[HEADING=1\](.+?)\[/HEADING\]}i) { "\n# #{$1}\n" }
    s.gsub!(%r{\[HEADING=2\](.+?)\[/HEADING\]}i) { "\n## #{$1}\n" }
    s.gsub!(%r{\[HEADING=3\](.+?)\[/HEADING\]}i) { "\n### #{$1}\n" }

    s.gsub!(%r{\[SPOILER="?([^\]]*?)"?\](.*?)\[/SPOILER\]}im) do
      title = $1.presence || "Spoiler"
      content = $2
      "\n[details=\"#{title}\"]\n#{content}\n[/details]\n"
    end

    s.gsub!(%r{\[CODE="?([a-zA-Z0-9_\-+]*)"?\](.*?)\[/CODE\]}im) do
      lang = $1.presence || ""
      code = $2
      "\n```#{lang}\n#{code}\n```\n"
    end

   s.gsub!(%r{\[USER=\d+\]@?(.+?)\[/USER\]}i, '@\1')
    s.gsub!(%r{\[MEDIA=youtube\](.+?)\[/MEDIA\]}i, 'https://www.youtube.com/watch?v=\1')
    s.gsub!(%r{\[MEDIA=[^\]]+\](.+?)\[/MEDIA\]}i, '\1')

    # إزالة وسمات BBCode العرضية التي لا يستخدمها Markdown
    s.gsub!(%r{\[/?(?:LEFT|RIGHT|CENTER|JUSTIFY|FONT|SIZE|COLOR|INDENT)(?:=[^\]]+)?\]}i, '')

    # معالجة واستبدال وسمات [ATTACH] برفعات Discourse
    s = process_xf_attachments("post", s, import_id) if import_id.to_i > 0

    s.strip
  end

 # =========================================================================
  # 11. حل المرفقات ومعالجة الملفات
  # =========================================================================

  def process_xf_attachments(content_type, text, content_id)
    sql = "
      SELECT a.attachment_id, a.data_id, d.filename, d.file_hash, d.user_id
      FROM #{TABLE_PREFIX}attachment a
      INNER JOIN #{TABLE_PREFIX}attachment_data d ON a.data_id = d.data_id
      WHERE a.content_type = '#{@client.escape(content_type.to_s)}'
        AND a.content_id = #{content_id.to_i}
    "
    attachments = mysql_query(sql).to_a
    return text if attachments.empty?

    embedded_ids = Set.new

    attachments.each do |att|
      att_id = att[:attachment_id]
      upload = import_xf_attachment_file(att[:data_id], att[:file_hash], att[:user_id], att[:filename])
      next unless upload&.persisted?

      html = @uploader.html_for_upload(upload, att[:filename])

      # مطابقة [ATTACH]123[/ATTACH] القياسية بالإضافة إلى متغيرات سمات XF 2.3 مثل [ATTACH type="full" alt="..."]123[/ATTACH]
      pattern = %r{\[ATTACH[^\]]*\]\s*#{att_id}\s*\[/ATTACH\]}i
      if text.match?(pattern)
        text.gsub!(pattern, "\n#{html}\n")
        embedded_ids.add(att_id)
      end
    end

    # إضافة أي مرفقات لم يتم وضعها ضمن نص المحتوى
    unattached = attachments.reject { |a| embedded_ids.include?(a[:attachment_id]) }
    if unattached.any?
      text += "\n\n"
      unattached.each do |att|
        upload = import_xf_attachment_file(att[:data_id], att[:file_hash], att[:user_id], att[:filename])
        next unless upload&.persisted?
        html = @uploader.html_for_upload(upload, att[:filename])
        text += "#{html}\n"
      end
    end

    # عملية تنظيف نهائية: إزالة أي علامات [ATTACH] متبقية أو مفقودة
    text.gsub!(%r{\[ATTACH[^\]]*\]\s*\d*\s*\[/ATTACH\]}i, '')

    text
  end

  def import_xf_attachment_file(data_id, file_hash, owner_id, original_filename)
    group_id = data_id.to_i / 1000

    exact_name   = "#{data_id}-#{file_hash}.data"
    path_grouped = Pathname.new(File.join(ATTACHMENT_DIR, group_id.to_s, exact_name))
    path_flat    = Pathname.new(File.join(ATTACHMENT_DIR, exact_name))

    path = if File.exist?(path_grouped)
             path_grouped
           elsif File.exist?(path_flat)
             path_flat
           else
             glob_grouped = Dir.glob(File.join(ATTACHMENT_DIR, group_id.to_s, "#{data_id}-*.data")).first
             glob_flat    = Dir.glob(File.join(ATTACHMENT_DIR, "#{data_id}-*.data")).first
             found = glob_grouped || glob_flat
             if found
               Pathname.new(found)
             else
               log_missing_file(:attachment,
                 data_id: data_id,
                 filename: original_filename,
                 db_hash: file_hash,
                 expected_grouped: path_grouped.to_s,
                 expected_flat: path_flat.to_s
               )
               return nil
             end
           end

    discourse_user_id = user_id_from_imported_user_id(owner_id) || Discourse::SYSTEM_USER_ID

    temp_path = path.dirname.join(original_filename)
    FileUtils.cp(path, temp_path)

    upload = create_upload(discourse_user_id, temp_path, original_filename)
    FileUtils.rm(temp_path) if File.exist?(temp_path)

    upload
  rescue StandardError => e
    STDERR.puts "Failed to process attachment data_id #{data_id}: #{e.message}"
    nil
  end

  def mysql_query(sql)
    @client.query(sql)
  end
end

ImportScripts::XenForo23.new.perform

واجهتُ بعض المستخدمين فشلًا في الاستيراد بسبب عناوين بريد إلكتروني غريبة أنشأها برامج البريد المزعج (Spambots). على سبيل المثال، عناوين مثل Peter.Today.Rain.Import.Something@Dave.Gmail.com.

سأقوم بتحديث/تعديل كود الاستيراد أعلاه بمجرد بدء ظهور الحقول المخصصة، حيث إنها مهمة بالنسبة لي.

.

4 إعجابات