Migrate a XenForo forum to 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 DB를 성공적으로 가져올 수 있었으므로, 누군가가 가이드를 업데이트해야 합니다.

현재 다음 단계에서 막혀서 도움이 필요합니다.

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

위 명령을 실행하면 다음과 같은 오류가 발생합니다. Gemfile을 확인해 보니 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:/# 

아, 다음 단계로 넘어가는 데 성공했습니다. 위에 누군가가 컨테이너에서 /var/www/discourse 폴더로 이동한 후 gem을 추가해야 한다고 게시했습니다.

이제 마지막 단계에서

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": 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.

해결했습니다: root 사용자로 실행하고 있었는데, ‘discourse’ 사용자로 전환해야 했습니다. 가져오기가 시작되었습니다.

4개의 좋아요

그래서 4CPU와 16GB RAM을 갖춘 꽤 괜찮은 서버를 구매했습니다. 현재 게시글이 마이그레이션되는 속도로는 게시글만 마이그레이션하는 데 9일이 걸릴 것입니다. 사용자는 2.5시간 만에 마이그레이션이 완료되었습니다. 솔직히 말해서, 현재 상태로는 이 방식으로는 진행하기 어렵습니다. 하지만 대량 마이그레이션에 대한 해결책을 찾을 때까지 몇 달간 익숙해지는 데 시간을 쓸 수는 있습니다.

P.S.
마이그레이션 스크립트에서 중복된 이메일은 가져오지 않는 것을 확인했습니다. 중복이 판별되는 다양한 방식은 무엇인가요? xyz@gmail.com이 xyz+1@gmail.com과 xy.z@gmail.com과 동일하게 처리되는 것을 확인했습니다.

그 외 다른 패턴도 있나요?

개인 컴퓨터와 사양이 비슷한 VPS에서 마이그레이션을 해본 적이 있는데, 어떤 이유에서인지 항상 제 컴퓨터를 사용할 때보다 훨씬, 훨씬 느렸습니다.

요즘은 항상 로컬에서 마이그레이션을 진행합니다. 게시글이 얼마나 있으세요?

1개의 좋아요

250만 개의 게시물.
M1 맥에서 로컬 마이그레이션을 시도하여 비교해 보겠습니다.

1개의 좋아요

대략 그렇습니다. 고유성 체크는 주어진 이메일 주소를 소문자로 변환하고 정규화한 버전에서 수행됩니다.

정규화 과정에서 모든 마침표를 제거하고, 사용자 이름 부분의 + 이후의 내용은 무시합니다.

3개의 좋아요

단일 CPU 속도가 중요한 요인입니다.

제 머신에서는 분당 800~1000명의 사용자 또는 게시글 처리 속도가 비교적 일반적입니다.

최종 가져오기를 수행할 때, 이미 가져오지 않은 사용자 및 게시글만 가져오므로 오래 걸리지 않습니다.

이메일 정규화(Normalize emails) 사이트 설정을 끄세요(최근까지 기본값은 꺼져 있었습니다). 이 함수에서 꺼져 있어야 합니다:

SiteSetting.normalize_emails=false를 xenforo 스크립트의 사용자 정의 버전에 추가할 수 있습니다. 그 중복 이메일 사용자들에게 무슨 일이 발생했는지는 정확히 알 수 없습니다. 명확한 조치 방법은 두 가지로, 허위 이메일 주소를 부여하거나 가져오기를 건너뛰는 것입니다. 허위 이메일을 부여하는 것 같네요? (그리고 사실 그 사용자들은 본래 허위 사용자일 가능성이 꽤 높습니다). 만약 가져오기를 건너뛴 것이라면, 스크립트를 다시 실행하면 가져와집니다.

3개의 좋아요

네, 제 노트북에서는 1분에 1,000개 항목을 처리하며 훨씬 더 빠르게 진행되고 있습니다. 서버보다 약 2배 빠른 속도입니다. 그래도 약 3일이 걸리네요.

건너뛴 이메일들을 확인해 보니, 해당 계정들을 잘 무시하고 있는 것 같습니다. 최종 가져오기 전에 이들을 병합할 예정입니다. 그런 경우는 겨우 20건 정도입니다.

최종 가져오기를 수행할 때, 이미 가져온 사용자 및 게시물은 제외하고 나머지만 가져오므로 매우 빠르게 완료됩니다.

이 점을 알려 주셔서 감사합니다. 제自己也 이 부분을 직접 관찰했고, 최종 가져오기 시 이 기능이 큰 도움이 될 것 같습니다. 따라서 D-3일에 백업 후 복원하고, Day 0에는 새로운 DB 백업 파일로 다시 백업 후 복원하는 것이 맞나요?

1개의 좋아요

이 백업 및 복원 작업은 Xenforo 사이트에서 수행하는 건가요, 아니면 Xenforo 데이터를 가져다 넣을 라이브 Discourse 사이트가 따로 있나요?

데이터를 다시 가져와야 하는 스크립트 변경 사항이 없고, 현재 노트북에 있는 내용이 곧 Discourse 서버에 원하는 내용이라면, Xenforo 데이터베이스의 새로운 덤프를 계속 받아서 가져오기 작업을 수행할 수 있습니다(테스트, 소요 시간 확인 등). 그리고 전환 당일에는 Xenforo 사이트를 동결하고 해당 데이터베이스를 가져온 뒤, 스크립트를 한 번 더 실행하여 Discourse 서버에 업로드하면 됩니다.

만약 Discourse 사이트에 유지하고 싶은 데이터가 이미 있다면 상황이 훨씬 복잡해집니다. 해당 사이트를 먼저 동결한 뒤, Xenforo 데이터를 가져와야 하고, 그 후 위에서 설명한 대로 진행해야 하기 때문입니다.

1개의 좋아요

Discourse를 새로 설치하는 것이므로, 과정은 간단합니다.

이주 작업을 여러 번 테스트하고, Discourse를 충분히 익히며, 모든 애드온을 원하는 대로 구성하고, 직접 애드온 커스터마이징도 해보고 싶기 때문에 충분한 시간을 확보하고 있습니다.

설명해 주신 덕분에, 일괄 가져오기까지 알아서 처리해야 하나 걱정했던 부분이 완전히 해소되었습니다.

2개의 좋아요

질문 하나 더 올립니다. 가져오기 스크립트가 로그를 출력하나요? 제 테스트 가져오기가 몇 시간째 98.2%에 멈춰 있습니다.

또 다른 발견은, 마이그레이션을 다시 시작하면 1,000개 게시물 배치 건너뛰는 데 약 30초가 걸린다는 점입니다. 즉, 현재 속도는 분당 2,000개 항목 수준입니다. 이는 첫 가져오기 당시의 분당 1,000개 게시물 속도보다 큰 개선이 아니며, 전환일 마지막 가져오기에도 약 하루가 걸릴 것입니다. 이 중 23시간은 이미 가져온 항목을 건너뛰는 데만 소요될 것입니다.

보시는 그대로입니다.

중지하고 다시 시작하는 것이 좋을 것 같습니다.

네, 이미 가져온 모든 데이터를 건너뜁니다. 그리고 분당 2000개 게시글보다 훨씬 빠르게 처리합니다. 지금 다시 시작하면 그걸 보실 수 있을 것입니다.

맞아요, 재시작한 후 위에 게시글을 올렸습니다. 2000개/분입니다. 확실하게 확인하려고 한 번 더 테스트해 보았습니다.

1개의 좋아요

아바타와 첨부 파일을 성공적으로 가져왔습니다. 이 폴더들을 복사했습니다.

/internal_data/attachments
/data/avatars

질문에 대한 답변으로, 아바타와 첨부 파일은 가져오기가 완료되면 확정됩니다. 사용자가 ID가 가져와진 후 아바타를 변경하면, 해당 게시물이나 사용자는 두 번째 실행 시 건너뛰어지므로 가져오기/업데이트되지 않습니다.

이제 대화 가져오기(이것도 건너뛸 수 있지만 있으면 좋겠습니다)와 영구 리다이렉트만 해결하면 됩니다.

@Fajfi - 가져오기 스크립트에 기여해 주셔서 감사합니다. 아바타와 첨부 파일은 완벽하게 작동했습니다. 아직 실행 중이며 좋아요 부분은 아직 도달하지 못했습니다.

대화 내보내기 문제를 수정했습니다. XF2.3에서 Discourse로 50만 개 이상의 메시지를 가져오는 데 성공했습니다. 관심 있는 사람이 있을 경우를 대비해 PR을 올렸습니다.

----편집----

좋아요(likes) 내보내기 수정 사항을 포함한 또 다른 PR을 올렸습니다. XF2.1+에서 Discourse로 마이그레이션을 한 사람이 지금까지 아무도 없다는 점이 놀랍습니다. 좋아요는 2019년 XF2.1 출시 시점에 리액션(reactions)으로 이름이 변경되었습니다.

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