نقل منتدى vBulletin 3 إلى Discourse عبر XenForo

أكتب هذا بينما لا يزال حديثاً في ذهني - إنه حالياً في طور التطوير، لذا تأكد من اختباره والتحقق من أنه يلبي احتياجاتك.

لا يوجد ما يعرفه أحد عن أداة استيراد من vB3 إلى Discourse، كما أنني لا أملك أي تراخيص vB4/5 التي أعتقد أن أدوات الاستيراد الخاصة بـ Discourse مخصصة لها - لكنني أملك ترخيص XenForo 1.4 وهناك أداة استيراد لـ Discourse مخصصة له! لأولئك الذين لا يملكون ترخيص XF، يمكنهم غالباً شراء ترخيص في السوق المستعمل، أو يمكنهم طلب الدفع لشخص ما للقيام بالاستيراد وإعطائك قاعدة بيانات XF.

لقد قمت سابقاً باستيراد من vB3.6 إلى XF، لذا أعرف أن ذلك يعمل بشكل جيد (الشيء الوحيد الذي لا يتم استيراده هو صور الملفات الشخصية لأن XF يحتوي فقط على صور رمزية - لكن لدي حل لذلك).

حسناً…

أولاً، قم باستيراد منتدى vB الخاص بك إلى XF كما تفعل عادةً.

أوصي بالاحتفاظ بمنتدى vB الخاص بك نشطاً ويمكن الوصول إليه عبر الإنترنت (لذا قم بالاستيراد إلى XF في دليل فرعي). هذا مجرد إجراء احتياطي في حال قررت لاحقاً أنك تريد الاحتفاظ بمنتدى vB، وكذلك لأننا سنقوم بنسخ صور الملفات الشخصية من الموقع الحي (على الرغم من أنه يمكنك استخدام سكريبت نسخ الصور الشخصية أدناه مسبقاً إذا كنت بحاجة ماسة لذلك).

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

جهاز التطوير الخاص بي هو Mac، لذا فإن هذه التعليمات مخصصة لـ macOS.

brew install mysql 
// تأكد أيضاً من تشغيله

mysql -u root

create database xenforo_db;
exit;

mysql -u root -p xenforo_db < /path/to/your/backup/and/downloaded/xenforo_db.sql

قم بإعداد بيئة تطوير Discourse كالمعتاد (راجع هذا لـ macOS) ثم:

افتح database.yml وقم بتغيير اسم قاعدة البيانات إلى شيء مثل discourse_development_sitename_01 - استخدم الأرقام حتى تتمكن من إعادة الاستيراد عدة مرات ببساطة عن طريق تغيير الرقم.

bundle
bundle exec rake db:create
bundle exec rake db:migrate
RAILS_ENV=development bundle exec rake admin:create 
RAILS_ENV=development bundle exec rake admin:create

بالنسبة لحساب المسؤول الأول، حاول استخدام نفس عنوان البريد الإلكتروني لحساب المسؤول الموجود في تثبيت vB/XF الخاص بك. اختر ‘Y’ عندما يُسأل عما إذا كنت تريد منحها صلاحيات المسؤول.

بالنسبة للمرحلة الثانية من إنشاء حساب، اجعل البريد الإلكتروني شيئاً مثل guest@something.com واختر ‘n’ عندما يُسأل عما إذا كنت تريد إنشاؤه كحساب مسؤول. نحتاج إلى هذا الحساب للمشاركات المرتبطة بالزوار/المستخدمين المحذوفين. يمكنك الدخول إلى rails c ثم User.last للتحقق من معرفه، لكنه سيكون على الأرجح 2. سنقوم بإضافته إلى سكريبت الاستيراد.

لقد قمت ببعض التغييرات على سكريبت الاستيراد، إليك نسختي من السكريبت (استبدل محتويات script/import_scripts/xenforo.rb به):

# frozen_string_literal: true

require "mysql2"
require_relative "base"

require "set" # قد لا يكون ضرورياً - لا أتذكر سبب إضافته الآن
require "htmlentities" # قد لا يكون ضرورياً - لا أتذكر سبب إضافته الآن

require File.expand_path(File.dirname(__FILE__) + "/base.rb")

# استدعه هكذا:
#   RAILS_ENV=production bundle exec ruby script/import_scripts/xenforo.rb
class ImportScripts::XenForo < ImportScripts::Base

  XENFORO_DB = "xenforo_db_3"
  TABLE_PREFIX = "xf_"
  BATCH_SIZE = 1000
  ATTACHMENT_DIR = '/full/path/to/attachments/eg/name/projects/discourse/sitename/discourse/tmp/attachments'
  AVATAR_DIR = '/full/path/to/avatars/eg/name/projects/discourse/sitename/discourse/tmp/avatars'
  PROFILE_PIC_DIR = '/full/path/to/profilepics/eg/name/projects/discourse/sitename/discourse/tmp/profilepics'

  def initialize
    super
    @client = Mysql2::Client.new(
      host: "localhost",
      username: "root",
      password: "",
      database: XENFORO_DB
    )

    @category_mappings = {}
    @prefix_as_category = false
  end

  def execute
    import_users
    import_avatars
    import_categories
    import_posts
  end

  def import_users
    puts '', "creating users"

    total_count = mysql_query("SELECT count(*) count FROM #{TABLE_PREFIX}user;").first['count']

    batches(BATCH_SIZE) do |offset|
      results = mysql_query(
        "SELECT user_id id, username, email, custom_title title, register_date created_at,
                last_activity last_visit_time, user_group_id, is_moderator, is_admin, is_staff
         FROM #{TABLE_PREFIX}user
         LIMIT #{BATCH_SIZE}
         OFFSET #{offset};")

      break if results.size < 1

      next if all_records_exist? :users, results.map { |u| u["id"].to_i }

      create_users(results, total: total_count, offset: offset) do |user|
        next if user['username'].blank?
        { id: user['id'],
          email: user['email'],
          username: user['username'],
          title: user['title'],
          created_at: Time.zone.at(user['created_at']),
          last_seen_at: Time.zone.at(user['last_visit_time']),
          moderator: user['is_moderator'] == 1 || user['is_staff'] == 1,
          admin: user['is_admin'] == 1 }
      end
    end
  end

  def import_user_profiles
    puts "Importing user profiles..."

    user_profiles = mysql_query("
        SELECT user_id, location, about
        FROM #{TABLE_PREFIX}user_profile
        ORDER BY user_id;
    ")
    
    puts "Importing profiles: fetching info"
    user_profiles.each do |row|
      usf = UserCustomField.find_by(name: "import_id", value: row["user_id"])
      if user = User.find(usf.user_id)
        puts "Updating profile for #{user.username}"
        profile = user.user_profile
        profile.location = row["location"]
        profile.bio_raw = row["about"]
        profile.save
      end
    end
  end

  def import_categories
    puts "", "importing categories..."

    categories = mysql_query("
        SELECT node_id id,
               title,
               description,
               parent_node_id,
               display_order
          FROM #{TABLE_PREFIX}node
      ORDER BY parent_node_id, display_order
      ").to_a

    top_level_categories = categories.select { |c| c["parent_node_id"] == 0 }

    create_categories(top_level_categories) do |c|
      {
        id: c['id'],
        name: c['title'],
        description: c['description'],
        position: c['display_order']
      }
    end

    top_level_category_ids = Set.new(top_level_categories.map { |c| c["id"] })

    subcategories = categories.select { |c| top_level_category_ids.include?(c["parent_node_id"]) }

    create_categories(subcategories) do |c|
      {
        id: c['id'],
        name: c['title'],
        description: c['description'],
        position: c['display_order'],
        parent_category_id: category_id_from_imported_category_id(c['parent_node_id'])
      }
    end

    subcategory_ids = Set.new(subcategories.map { |c| c['id'] })

    # تحتاج الفئات الأعمق إلى أن تكون وسوم
    categories.each do |c|
      next if c['parent_node_id'] == 0
      next if top_level_category_ids.include?(c['id'])
      next if subcategory_ids.include?(c['id'])

      # ابحث عن فئة فرعية للمواضيع في هذه الفئة
      parent = c
      while !parent.nil? && !subcategory_ids.include?(parent['id'])
        parent = categories.find { |subcat| subcat['id'] == parent['parent_node_id'] }
      end

      if parent
        tag_name = DiscourseTagging.clean_tag(c['title'])
        @category_mappings[c['id']] = {
          category_id: category_id_from_imported_category_id(parent['id']),
          tag: Tag.find_by_name(tag_name) || Tag.create(name: tag_name)
        }
      else
        puts '', "Couldn't find a category for #{c['id']} '#{c['title']}'!"
      end
    end
  end

  # هذه الطريقة بديلة لـ import_categories.
  # تستخدم بادئات بدلاً من العقد.
  def import_categories_from_thread_prefixes
    puts "", "importing categories..."

    categories = mysql_query("
                              SELECT prefix_id id
                              FROM #{TABLE_PREFIX}thread_prefix
                              ORDER BY prefix_id ASC
                            ").to_a

    create_categories(categories) do |category|
      {
        id: category["id"],
        name: "Category-#{category["id"]}"
      }
    end

    @prefix_as_category = true
  end

  def import_posts
    puts "", "creating topics and posts"

    total_count = mysql_query("SELECT count(*) count from #{TABLE_PREFIX}post").first["count"]

    posts_sql = "
        SELECT p.post_id id,
               t.thread_id topic_id,
               #{@prefix_as_category ? 't.prefix_id' : 't.node_id'} category_id,
               t.title title,
               t.first_post_id first_post_id,
               p.user_id user_id,
               p.message raw,
               p.post_date created_at
        FROM #{TABLE_PREFIX}post p,
             #{TABLE_PREFIX}thread t
        WHERE p.thread_id = t.thread_id
        AND p.message_state = 'visible'
        AND t.discussion_state = 'visible'
        ORDER BY p.post_date
        LIMIT #{BATCH_SIZE}" # يحتاج OFFSET

    batches(BATCH_SIZE) do |offset|
      results = mysql_query("#{posts_sql} OFFSET #{offset};").to_a

      break if results.size < 1
      next if all_records_exist? :posts, results.map { |p| p['id'] }

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

        mapped[:id] = m['id']
        mapped[:user_id] = user_id_from_imported_user_id(m['user_id']) || 2
        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']
          if m['category_id'].to_i == 0 || m['category_id'].nil?
            mapped[:category] = SiteSetting.uncategorized_category_id
          else
            mapped[:category] = category_id_from_imported_category_id(m['category_id'].to_i) ||
              @category_mappings[m['category_id']].try(:[], :category_id)
          end
          mapped[:title] = CGI.unescapeHTML(m['title'])
        else
          parent = topic_lookup_from_imported_post_id(m['first_post_id'])
          if parent
            mapped[:topic_id] = parent[:topic_id]
          else
            puts "Parent post #{m['first_post_id']} doesn't exist. Skipping #{m["id"]}: #{m["title"][0..40]}"
            skip = true
          end
        end

        skip ? nil : mapped
      end
    end

    # تطبيق الوسوم
    batches(BATCH_SIZE) do |offset|
      results = mysql_query("#{posts_sql} OFFSET #{offset};").to_a
      break if results.size < 1

      results.each do |m|
        next unless m['id'] == m['first_post_id'] && m['category_id'].to_i > 0
        next unless tag = @category_mappings[m['category_id']].try(:[], :tag)
        next unless topic_mapping = topic_lookup_from_imported_post_id(m['id'])

        topic = Topic.find_by_id(topic_mapping[:topic_id])

        topic.tags = [tag] if topic
      end
    end

  end
  
  def process_xenforo_post(raw, import_id)
    s = raw.dup

    # :) يتم ترميزها كـ <!-- s:) --><img src="{SMILIES_PATH}/icon_e_smile.gif" alt=":)" title="Smile" /><!-- s:) -->
    s.gsub!(/<!-- s(\S+) --><img (?:[^>]+) \/><!-- s(?:\S+) -->/, '\1')

    # بعض الروابط تبدو هكذا: <!-- m --><a class="postlink" href="http://www.onegameamonth.com">http://www.onegameamonth.com</a><!-- m -->
    s.gsub!(/<!-- \w --><a(?:.+)href="(\S+)"(?:.*)>(.+)<\/a><!-- \w -->/, '[\2](\1)')

    # العديد من وسوم bbcode الخاصة بـ phpBB لها تجزئة ملحقة بها. أمثلة:
    #   [url=https&#58;//google&#46;com:1qh1i7ky]click here[/url:1qh1i7ky]
    #   [quote=&quot;cybereality&quot;:b0wtlzex]Some text.[/quote:b0wtlzex]
    s.gsub!(/:(?:\w{8})\]/, ']')

    # إزالة وسوم الفيديو الخاصة بـ mybb.
    s.gsub!(/(^\[video=.*?\])|(\[\/video\]$)/, '')

    s = CGI.unescapeHTML(s)

    # phpBB تقصر نص الرابط هكذا، مما يكسر معالجة markdown لدينا:
    #   [http://answers.yahoo.com/question/index ... 223AAkkPli](http://answers.yahoo.com/question/index?qid=20070920134223AAkkPli)
    #
    # إصلاح للخطأ: xenforo.rb: 160: في `gsub!': تسلسل بايت غير صالح في UTF-8 (ArgumentError)
    if ! s.valid_encoding?
      s = s.encode("UTF-16be", invalid: :replace, replace: "?").encode('UTF-8')
    end

    # العمل حولها مؤقتاً:
    s.gsub!(/\[http(s)?:\/\/(www\.)?/, '[')

    # [QUOTE]...[/QUOTE]
    s.gsub!(/\[quote\](.+?)\[\/quote\]/im) { "\n> #{$1}\n" }

    # اقتباسات متداخلة
    s.gsub!(/(\[\/?QUOTE.*?\])/mi) { |q| "\n#{q}\n" }

    # [QUOTE="username, post: 28662, member: 1283"]
    s.gsub!(/\[quote="(\w+), post: (\d*), member: (\d*)"\]/i) do
      username, imported_post_id, _imported_user_id = $1, $2, $3

      topic_mapping = topic_lookup_from_imported_post_id(imported_post_id)

      if topic_mapping
        "\n[quote=\"#{username}, post:#{topic_mapping[:post_number]}, topic:#{topic_mapping[:topic_id]}\"]\n"
      else
        "\n[quote=\"#{username}\"]\n"
      end
    end

    # [URL=...]...[/URL]
    s.gsub!(/\[url="?(.+?)"?\](.+)\[\/url\]/i) { "[#{$2}](#{$1})" }

    # [IMG]...[/IMG]
    s.gsub!(/\[\/?img\]/i, "")

    # تحويل وسوم القائمة إلى ul ووسوم list=1 إلى ol
    # (بشكل أساسي، نحن نفتقد فقط list=a هنا...)
    s.gsub!(/\[list\](.*?)\[\/list:u\]/m, '[ul]\1[/ul]')
    s.gsub!(/\[list=1\](.*?)\[\/list:o\]/m, '[ol]\1[/ol]')
    # تحويل وسوم *- إلى وسوم li حتى يتمكن bbcode-to-md من فعل سحره على قوائم phpBB:
    s.gsub!(/\[\*\](.*?)\[\/\*:m\]/, '[li]\1[/li]')

    # [YOUTUBE]<id>[/YOUTUBE]
    s.gsub!(/\[youtube\](.+?)\[\/youtube\]/i) { "\nhttps://www.youtube.com/watch?v=#{$1}\n" }

    # [youtube=425,350]id[/youtube]
    s.gsub!(/\[youtube="?(.+?)"?\](.+)\[\/youtube\]/i) { "\nhttps://www.youtube.com/watch?v=#{$2}\n" }

    # [MEDIA=youtube]id[/MEDIA]
    s.gsub!(/\[MEDIA=youtube\](.+?)\[\/MEDIA\]/i) { "\nhttps://www.youtube.com/watch?v=#{$1}\n" }

    # [ame="youtube_link"]title[/ame]
    s.gsub!(/\[ame="?(.+?)"?\](.+)\[\/ame\]/i) { "\n#{$1}\n" }

    # [VIDEO=youtube;<id>]...[/VIDEO]
    s.gsub!(/\[video=youtube;([^\]]+)\].*?\[\/video\]/i) { "\nhttps://www.youtube.com/watch?v=#{$1}\n" }

    # [USER=706]@username[/USER]
    s.gsub!(/\[user="?(.+?)"?\](.+)\[\/user\]/i) { $2 }

    # إزالة وسم اللون
    s.gsub!(/\[color=[#a-z0-9]+\]/i, "")
    s.gsub!(/\[\/color\]/i, "")

    if Dir.exist? ATTACHMENT_DIR
      s = process_xf_attachments(:gallery, s)
      s = process_xf_attachments(:attachment, s)
    end

    s
  end

  def process_xf_attachments(xf_type, s)
    ids = Set.new
    ids.merge(s.scan(get_xf_regexp(xf_type)).map { |x| x[0].to_i })
    ids.each do |id|
      next unless id
      sql = get_xf_sql(xf_type, id).squish!
      results = mysql_query(sql)
      if results.size < 1
        # إزالة المرفق
        s.gsub!(get_xf_regexp(xf_type, id), '')
        STDERR.puts "#{xf_type.capitalize} id #{id} not found in source database. Stripping."
        next
      end
      original_filename = results.first['filename']
      result = results.first
      upload = import_xf_attachment(result['data_id'], result['file_hash'], result['user_id'], original_filename)
      next unless upload
      if upload.present? && upload.persisted?
        s.gsub!(get_xf_regexp(xf_type, id), @uploader.html_for_upload(upload, original_filename))
      else
        STDERR.puts "Could not find upload: #{upload.id}. Skipping attachment id #{id}"
      end
    end
    s
  end

  def import_xf_attachment(data_id, file_hash, owner_id, original_filename)
    current_filename = "#{data_id}-#{file_hash}.data"
    path = Pathname.new(ATTACHMENT_DIR + "/#{data_id / 1000}/#{current_filename}")
    new_path = path.dirname + original_filename
    upload = nil
    if File.exist? path
      FileUtils.cp path, new_path
      upload = @uploader.create_upload owner_id, new_path, original_filename
      FileUtils.rm new_path
    else
      STDERR.puts "Could not find file #{path}. Skipping attachment id #{data_id}"
    end
    upload
  end

  def get_xf_regexp(type, id = nil)
    case type
    when :gallery
      Regexp.new(/\[GALLERY=media,\s#{id ? id : '(\d+)'}\].+?\]/i)
    when :attachment
      Regexp.new(/\[ATTACH(?>=\w+)?\]#{id ? id : '(\d+)'}\[\/ATTACH\]/i)
    end
  end

  def get_xf_sql(type, id)
    case type
    when :gallery
      <<-SQL
		SELECT m.media_id, m.media_title, a.attachment_id, a.data_id, d.filename, d.file_hash,d.user_id
		FROM xengallery_media as m
		INNER JOIN #{TABLE_PREFIX}attachment a on m.attachment_id = a.attachment_id
		INNER JOIN #{TABLE_PREFIX}attachment_data d on a.data_id = d.data_id
		WHERE media_id = #{id}
      SQL
    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}
      SQL
    end
  end

  def mysql_query(sql)
    @client.query(sql, cache_rows: false)
  end
  
  def import_avatars
    if AVATAR_DIR
      users = User.all
      users.each do |u|
        unless u.custom_fields["import_id"].nil?
          import_id = u.custom_fields["import_id"]
          if import_id.to_i < 1000
            dir_num = "0"
          elsif import_id.to_i > 1000
            dir_num = import_id.first
          end
        
          filename = "#{import_id}.jpg"
          avatar_file_path = "#{AVATAR_DIR}/l/#{dir_num}"
          avatar_file_path_and_name = "#{avatar_file_path}/#{filename}"
          profile_pic_file_path_and_name = "#{PROFILE_PIC_DIR}/#{filename}"
          
          if File.exists?(profile_pic_file_path_and_name)
            upload_pic_or_avatar(u, profile_pic_file_path_and_name, filename)
          elsif File.exists?(avatar_file_path_and_name)
            upload_pic_or_avatar(u, avatar_file_path_and_name, filename)
          end
        end
      end
    end
  end
  
  def upload_pic_or_avatar(u, file_path_and_name, filename)
    upload = create_upload(u.id, file_path_and_name, filename)
    if upload.persisted?
      puts "upload persisted"
      u.import_mode = false
      u.create_user_avatar
      u.import_mode = true
      u.user_avatar.update(custom_upload_id: upload.id)
      u.update(uploaded_avatar_id: upload.id)
    else
      puts "Error: Upload did not persist for #{u.username} #{filename}!"
    end
  end
  
  
end

ImportScripts::XenForo.new.perform

ملاحظات:

  • يضيف خطوة/طريقة import_avatars (يجب أن تكون هذه ملفات jpg)
  • نضيف مسارات إلى الصور الرمزية وصور الملفات الشخصية
  • نضيف معرف مستخدم الزائر الجديد كاحتياطي عندما لا يوجد مستخدم ولكن هناك مشاركة (مستخدمو الزوار)

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

ناسخ صور الملفات الشخصية:

أولاً، قم بتثبيت Down باستخدام gem install down

ثم قم بإنشاء ملف جديد مع:

require 'down'

(1..NUMBER_OF_USERS).each do |u|
  puts "Fetching user #{u}"
  puts ""
  profile_pic_url = "https://www.forum-name.com/image.php?u=#{u}&type=profile"
  destination = "/full/path/where/you/want/to/save/profile/pics/#{u}.jpg"
  begin
    Down.download(profile_pic_url, destination: destination)
    puts "Completed #{u}"
  rescue
    puts "Failed #{u}"
  end
  puts ""
end

ملاحظات:

  • يفترض أن جميع صور الملفات الشخصية (والصور الرمزية) هي ملفات jpg. لحسن الحظ، سمحنا فقط بملفات jpg كصور رمزية وصور ملفات شخصية، لذا يعمل هذا معنا.
  • تأكد من أن مساراتك وعنوان URL صحيح وأن الملفات الشخصية وصور الملفات الشخصية مرئية للزوار.
  • استبدل NUMBER_OF_USERS بعدد المستخدمين لديك (مثال 3872).

ثم قم بتشغيل السكريبت في الطرفية بتشغيل ruby /path/to/name-of-script.rb في الطرفية. سيؤدي هذا إلى نسخ جميع صور ملفاتك الشخصية إلى هذا الدليل، ثم اذهب إليه ببساطة، ورتب حسب حجم الملف، ثم احذف أي ملفات فارغة (سيكون هناك الكثير - لأن ليس الجميع يرفع صورة ملف شخصي).

القيام بالاستيراد:

بمجرد الانتهاء من كل ما سبق، أنت مستعد للبدء :smiley:

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

يستغرق حوالي 90 دقيقة لاستيراد منتدى يحتوي على 100 ألف مشاركة وعدد قليل من الآلاف من الأعضاء، وتبدو اختباراتي الأولية أنها تعمل بشكل جيد. ومع ذلك..

ملاحظات:

  • سيقوم باستيراد location و about فقط من الملفات الشخصية
  • لم أتحقق من رفع المرفقات لأننا لم نسمح أبداً برفع المرفقات على منتدى الاختبار الذي أستخدمه لهذه الاختبارات. لقد كنا على أحد المنتديات التي أريد استيرادها (إنه أكبر بكثير، ومن ثم استخدام هذا المنتدى الأصغر للاختبارات) لذا سأعود بالإبلاغ عن كيفية سير الأمر.
  • تم نقل الاستيراد الذي تم على جهاز التطوير بنجاح إلى تثبيت إنتاجي حي وتم كل شيء بشكل جيد :+1:
  • (لا يزال يتعين اختبار هذا على منتدى أكبر مع المرفقات - سأقوم بتحديث هذا المنشور عندما يتم ذلك)

أقوم بنشر هذا الآن لأنني أعتقد أن بعض الأشخاص يحاولون استيراد منتديات vB3 الخاصة بهم إلى Discourse حالياً.

5 إعجابات