Migrate a XenForo forum to Discourse

In the forum I transferred, the xengallery was once installed, so I had to change the following, because the table xfgallery no longer existed.

  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개의 좋아요

You can try running the script again and see if it finishes.

Your system is on a single partition?

The mysql-server install step is now obsolete. It needs to install mariadb-server.

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

I was able to follow rest of the steps and import XF DB, so someone should update the guide.

Now I am stuck on this following step and need help.

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

Running the above gives me the following error. I checked the Gemfile and it only contains this one line - gem ‘mysql2’

This Gemfile does not include an explicit global source. 
Not using an explicit global source may result in a different lockfile being generated depending on the gems you have installed locally before bundler is run. 
Instead, define a global source in your Gemfile like this: source "https://rubygems.org".
Could not find gem 'mysql2' in locally installed gems.
root@ip-172-566-459-13-app:/# 

Ok so I managed to move onto the next step. Someone above posted that we need to be in /var/www/discourse folder on the container and then add the gem.

Now on the final step

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

I am getting this error. What could I be doing wrong?

/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": We could not find your database: discourse. Available database configurations can be found in config/database.yml. (ActiveRecord::NoDatabaseError)To resolve this error:- Did you not create the database, or did you delete it? To create the database, run: bin/rails db:create- Has the database name changed? Verify that config/database.yml contains the correct database name.

Solved it: I was running as root user, had to switch to the ‘discourse’ user. Import has started.

4개의 좋아요

So I picked up a reasonably good server at 4CPU and 16GB RAM. At the rate at which the posts are getting migrated, it will take me 9 days for just the posts to get migrated. The users took 2.5 hours to get migrated. Safe to say that this is going to be a no go for me as is but at least I can spend some months familiarizing myself till I figure out a solution for this bulk migration.

PS:
In the migration script I see that duplicate emails are not imported. What are the different ways that duplicate is determined? I noticed that xyz@gmail.com is treated same as xyz+1@gmail.com and xy.z@gmail.com

Is there any other pattern as well.

I’ve tried doing migrations on VPS with specs similar to my personal computer, but for some reason it was always much, much slower than using my computer.

Nowadays, I always do my migrations locally. How many posts do you have?

1개의 좋아요

2.5 million posts.
Will try local migration on a M1 mac to compare.

1개의 좋아요

That’s pretty much it. The uniqueness check is performed on the downcased and normalized version of the given email address.

We normalize by removing all dots and ignoring everything after + in the username.

3개의 좋아요

Single cpu speed is the important factor.

On my machines, a rate of 800-1000 users or posts/minute is fairly typical.

Note that when you do the final import, it’ll import only the users and posts that haven’t been imported already, so it won’t take very long.

Turn off the Normalize emails site setting (off was the default until recently). It should probably get turned off in this function here:

You can put it in your customized version of the xenforo script with SiteSetting.normalize_emails=false. I’m not sure what happened to those duplicate email users; there are two obvious things to do, give them a bogus email address or skip importing them. Looks like it gives them bogus ones? (And there’s a pretty good chance that they are, in fact, bogus users anyway). If it skipped them, then running the script again will import them.

3개의 좋아요

Yes on my laptop, it is churning things much faster at 1000 items per minute. Thats about 2 times faster than the on server. Still thats about 3 days.

I went through the skipped emails and it seems its doing a good job ignoring those accounts. I will just merge them prior to the final import. Hardly 20 odd such cases.

Note that when you do the final import, it’ll import only the users and posts that haven’t been imported already, so it won’t take very long.

Thank you for pointing this out. I observed this myself and it seems this is what is going to save the day when I do the final import. So I take a backup and restore on D-3 and then another backup and restore with the new DB backup file on Day 0. Is that correct?

1개의 좋아요

Are those backups and restores on the Xenforo site, or do you have some live Discourse site that you’re going to import the Xenforo data to?

As long as you don’t make changes to the script that require re-importing data, and what you have on your laptop now is what you want on your Discourse server, then you can just keep getting new dumps of the Xenforo database and importing them (to test, see how long it takes, and so on) and then on the cut-over day, you freeze the Xenforo site, get that database, run the script once more and upload to your Discourse server.

If you already have data on your Discourse site that you want to keep, things are much more complicated since you’ll need to freeze that site, then get the Xenforo data and then proceed as described above.

1개의 좋아요

It’ll be a fresh install of Discourse so that makes it straightforward.

I have a decent amount of time at hand as I want to test migrations multiple times, familiarize myself with Discourse thoroughly, get all add-ons configured the way I want and maybe also get my hands dirty with some add-on customization myself.

What you’ve explained lifts one pain point off my chest completely as I thought I would have to figure out bulk imports too.

2개의 좋아요

Have come back with a query, does the import script output any logs? My test import is stuck at 98.2% for a few hours.

Another thing I realized, if I restart the migration, it takes around 30 seconds to skip over a batch of 1000 posts. So effectively the speed is now 2000 items per minute. Not a significant improvement over the 1000 posts per minute for the first import, as even on the last import on the day of the cutover, it will take about a days time. 23 hours out of which will just be skipping already imported items.

Just what you see.

You should probably stop it and start it again.

Yes, it’ll skip all data that’s been imported already. And it does it much faster than 2000 posts/minute. I suspect you’ll see when you restart it now.

Thats what, I restarted and then made the above post. It is 2000 posts/minute. To be sure I tried it again.

1개의 좋아요

So managed to get the avatars and attachments imported. Copied these folders.

/internal_data/attachments
/data/avatars

To answer my question, the avatars and attachments get finalized once imported. If a user changes their avatar after their ID is imported, it will not get imported/updated because that post or user will get skipped in the second run.

Now just need to figure out the conversations import (can skip too but good to have) and permanent redirects.

@Fajfi - Thank you for your contribution to the import script. Worked flawlessly for avatars and attachments. Its still running and have not reached the likes portion yet.

Fixed the conversations import. Was able to import over half a million messages from XF2.3 into discourse. Have raised a PR in case someone is interested.

https://github.com/discourse/discourse/pull/31508

----EDIT----

Raise another PR with a fix for likes import. It is surprising that nobody migrated from XF2.1+ to discourse till now. Likes were renamed to reactions in 2019 when XF2.1 released.

https://github.com/discourse/discourse/pull/31522

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

2.3 Xenforo에서 Discourse로 가져오는 과정에서 잘 작동하는 것 같습니다.

2개의 좋아요

아직 좋은 결과를 얻지 못했습니다. 3/4백만 건의 가져오기를 진행하던 중 오류가 발생했고, XFMG가 존재하지 않는다고 경고했습니다.

작동을시키기 위해 좀 더 열정을 불태워야 할 것 같습니다.

많은 양의 코드를 리팩터링했습니다. 현재까지 꽤 잘 작동하는 것 같습니다. 여러분이 가지고 있을 수 있는 사용자 정의 필드는 가져오지 않겠지만, 해당 부분을 다시 작성했습니다(아래 참조). 이제 가져오기를 정리하고 처음부터 다시 시작하겠습니다.

Xenforo 2.3에서 자체 호스팅 Discourse로의 작동 확인 완료:

사용자

사용자 정의 필드

PM / 대화

XenForo 미디어 갤러리

Xenforo 리소스 매니저

좋아요 및 반응

중복 및 무효 이메일 처리

XF2.3 BB 코드 처리

XF미디어 갤러리

XF 리소스

75만 개의 게시물, 2만 명의 멤버, 20.7GB의 첨부 파일로 테스트했습니다. 가져오기에 약 20시간이 소요됩니다. 가져오기 후 sidekiq가 따라잡는 데 몇 시간이 더 걸립니다.

참고할 점들.

CSF에서 도커가 로컬 DB에 접근할 수 있도록 허용해야 했습니다.

마운트가 중요합니다:

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

이렇게 하면 데이터를 도커 인스턴스로 이동할 필요가 없습니다. 여기서 링크를 설정합니다.

# frozen_string_literal: true

# XenForo 2.3 to Discourse Import Script
# Configured specifically for TurboRenault (dev.turborenault.co.uk)
# Supports:
#   - 750,000+ Posts with Keyset Pagination (O(1) fast batching)
#   - 20.7GB Attachments & Avatars (XF 2.3 path resolution)
#   - Custom User Profile Fields & Custom Thread/Post Fields
#   - User Groups & Secondary Group Memberships (Collision-safe 20-character truncation)
#   - Private Messages / Conversations & Multi-recipients (With Deleted User Fallback)
#   - XenForo Media Gallery (XFMG)
#   - XenForo Resource Manager (XFRM with accurate thread and update resolution)
#   - Likes & Reactions
#   - Duplicate & Invalid Email Auto-fallback Handling
#   - XF 2.3 BBCode / Formatting Parser
#
# Execution:
#   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"

# Robust path resolution for 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
  # Pre-filled Database Credentials
  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

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

  # Feature Toggles
  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 "--> Connecting to MySQL Database '#{XENFORO_DB}' on #{DB_HOST}:#{DB_PORT} as '#{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

  # =========================================================================
  # Performance Tuning for Modern Discourse
  # =========================================================================

  def optimize_discourse_for_import
    puts "", "--> Applying Discourse high-speed import optimizations..."
    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 "   Optimization note: #{e.message}"
    end
  end

  def report_missing_files_summary
    if @missing_files_count > 0
      puts "", "   *** #{@missing_files_count} files were NOT found on disk during import ***"
    else
      puts "   All attachment/avatar files found OK."
    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   [MISSING #{label}]\n#{details}"
  end

  def restore_discourse_settings
    puts "", "--> Restoring default Discourse site settings..."
    begin
      SiteSetting.disable_emails = "non_staff" if SiteSetting.respond_to?(:disable_emails=)
      RateLimiter.enable rescue nil
    rescue StandardError
      # ignore
    end
    puts "--> Import Completed Successfully!"
  end

  # =========================================================================
  # 1. Custom User Profile Fields
  # =========================================================================

  def import_custom_user_field_definitions
    puts "", "--> Importing Custom User Field Definitions..."
    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 "   Phrase note: #{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 = "Custom profile field: #{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 "   Mapped #{@user_custom_fields_map.size} custom profile field definitions."
  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
          # leave as is
        end
      elsif val.to_s.start_with?("[", "{")
        begin
          parsed = JSON.parse(val)
          val = parsed.is_a?(Array) ? parsed.join(", ") : val
        rescue JSON::ParserError
          # leave as is
        end
      end

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

    custom_fields
  end

  # =========================================================================
  # 2. Users & Avatars
  # =========================================================================

  def import_users
    puts "", "--> Importing Users..."
    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 "   Found #{total_count} valid users."

    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

    # Check standard validity
    unless email_str.present? && email_str.include?("@") && email_str.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/)
      return "user_#{user_id}@imported.invalid"
    end

    # Check for duplicates across dataset or existing Discourse UserEmail table
    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 "Error importing avatar for user #{xf_user_id}: #{e.message}"
  end

  # =========================================================================
  # 3. User Groups & Memberships (Collision-safe Truncation)
  # =========================================================================

  def import_groups
    puts "", "--> Importing User Groups and Memberships..."

    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)

      # Skip default XF system groups (1 = Unregistered, 2 = Registered)
      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 "   Mapped #{@group_mappings.size} custom user groups."

    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 "   Successfully imported group memberships."
  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. Categories (Forum Hierarchy)
  # =========================================================================

  def import_categories
    puts "", "--> Importing Categories..."

    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. Topics & Posts (Live Counter for 750,000+ Posts)
  # =========================================================================

  def import_posts
    puts "", "--> Importing Topics and Posts (750k Keyset Pagination)..."

    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 to import: #{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 Resource Manager (XFRM)
  # =========================================================================

 # =========================================================================
  # 6. XenForo Resource Manager (XFRM)
  # =========================================================================

  def import_xfrm
    puts "", "--> Importing XenForo Resource Manager (XFRM)..."

    has_xfrm = mysql_query("SHOW TABLES LIKE '#{TABLE_PREFIX}rm_resource'").to_a.present?
    unless has_xfrm
      puts "   No XFRM tables found, skipping."
      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 "   Found #{resources.size} XFRM resources."

    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 : "*(No description provided)*"

      # --- NEW: FETCH ACTUAL RESOURCE FILES (ZIPS, PDFS) ---
      # Updated 'v.version_id' to XenForo 2's 'v.resource_version_id'
      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### Downloadable Files\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### External Downloads\n"
        external_urls.each do |ext|
          raw += "\n* [Version #{ext[:version_string]}](#{ext[:download_url]})"
        end
      end
      # --- END NEW FILE FETCH ---

      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*Original Discussion Thread: [View Here](/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]}", # Bumped to v5 to clear the crashed cache
        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 Media Gallery (XFMG)
  # =========================================================================

  def import_xfmg
    puts "", "--> Importing XenForo Media Gallery (XFMG)..."

    has_xfmg = mysql_query("SHOW TABLES LIKE '#{TABLE_PREFIX}mg_media_item'").to_a.present?
    unless has_xfmg
      puts "   No XFMG tables found, skipping."
      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 "   Found #{items.size} XFMG media items."

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

      # If it's an embedded video (YouTube, Vimeo, etc.), include the embed URL
      if item[:media_type] == "embed" && item[:media_tag].present?
        raw = "#{item[:media_tag]}\n\n#{raw}"
      else
        # Process image attachments using XF 2.x 'xfmg_media' content_type
        raw = process_xf_attachments("xfmg_media", raw, item[:media_id])
      end

      raw = "*(No media content)*" 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. Private Messages (Conversations)
  # =========================================================================

  def import_private_messages
    puts "", "--> Importing Private Messages (Conversations)..."

    last_conv_id = 0
    total_count = mysql_query("SELECT COUNT(*) AS count FROM #{TABLE_PREFIX}conversation_master").to_a.first[:count]
    puts "   Found #{total_count} private message conversations."
    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. Reactions & Likes
  # =========================================================================

def import_likes
    puts "", "--> Wiping polluted likes and resetting caches from previous attempts..."
    # Clear the raw actions
    PostAction.where(post_action_type_id: PostActionType.types[:like]).delete_all
    # Zero out the cached counters in the UI
    Post.update_all(like_count: 0)
    UserStat.update_all(likes_given: 0, likes_received: 0)

    puts "--> Importing XenForo 2.3 Post Reactions (Likes)..."

    # Count using values.first for cross-driver safety
    total_count = mysql_query(
      "SELECT COUNT(*) FROM #{TABLE_PREFIX}reaction_content WHERE content_type = 'post' AND is_counted = 1"
    ).first.values.first

    puts "   Found #{total_count} active post reactions."

    last_reaction_content_id = 0
    processed = 0

    loop do
      # Paginate securely using the primary key (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?

      # Safely extract the highest primary key for the next pagination batch
      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"]

        # PASS RAW XENFORO IDS DIRECTLY
        # Discourse's `create_likes` helper translates these automatically in the background.
        {
          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 Parser & Formatting Transformer
  # =========================================================================

  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+)"?\)]
      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 태그 제거
    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] 형식과 [ATTACH type="full" alt="..."]123[/ATTACH]와 같은 XF 2.3 속성 변형 매칭
      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

스팸봇에 의해 생성된 이상한 이메일 주소 때문에 일부 사용자의 가져오기에 실패한 경우가 있었습니다. 예를 들어 Peter.Today.Rain.Import.Something@Dave.Gmail.com과 같은 이메일 주소입니다.

나중에 커스텀 필드가 처리되도록 되면, 이 부분은 제게 중요하기 때문에 위 가져오기 코드를 업데이트/편집할 것입니다.

.

4개의 좋아요