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, поэтому кто-то должен обновить инструкцию.

Сейчас я застрял на следующем шаге и нужна помощь.

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”: База данных не найдена: discourse.
Доступные конфигурации баз данных можно найти в config/database.yml. (ActiveRecord::NoDatabaseError)
Чтобы исправить эту ошибку:

  • Не создали ли вы базу данных или не удалили ли её? Чтобы создать базу данных, выполните: bin/rails db:create
  • Не изменилось ли имя базы данных? Убедитесь, что в config/database.yml указано правильное имя базы данных.`

Решено: я запускал от имени пользователя root, пришлось переключиться на пользователя ‘discourse’. Импорт начался.

4 лайка

Итак, я купил вполне приличный сервер с 4 ядрами CPU и 16 ГБ ОЗУ. При текущей скорости миграции постов на их перенос у меня уйдет 9 дней. Пользователи были перенесены за 2,5 часа. Можно с уверенностью сказать, что в текущем виде это для меня не сработает, но хотя бы у меня будет несколько месяцев, чтобы разобраться в системе, пока я не найду решение для массовой миграции.

PS:
В скрипте миграции я заметил, что дубликаты адресов электронной почты не импортируются. По каким критериям определяется дубликат? Я обратил внимание, что xyz@gmail.com считается тем же самым, что и xyz+1@gmail.com и xy.z@gmail.com.

Есть ли какие-то другие подобные правила?

Я пробовал выполнять миграции на VPS с характеристиками, похожими на мой личный компьютер, но по какой-то причине это всегда было намного, намного медленнее, чем использование моего компьютера.

В наше время я всегда делаю миграции локально. Сколько у вас постов?

1 лайк

2,5 миллиона постов.
Попробую выполнить локальную миграцию на Mac с процессором M1 для сравнения.

1 лайк

По сути, всё именно так. Проверка на уникальность выполняется для приведённого к нижнему регистру и нормализованного варианта указанного адреса электронной почты.

Мы нормализуем адрес, удаляя все точки и игнорируя всё, что идёт после + в имени пользователя.

3 лайка

Важным фактором является скорость одного процессора.

На моих машинах скорость 800–1000 пользователей или сообщений в минуту является довольно типичной.

Обратите внимание, что при окончательном импорте будут импортированы только те пользователи и сообщения, которые еще не были импортированы, поэтому это займет немного времени.

Отключите настройку сайта Normalize emails (по умолчанию она была отключена до недавнего времени). Вероятно, её следует отключить в этой функции здесь:

Вы можете добавить это в свою модифицированную версию скрипта для XenForo, используя SiteSetting.normalize_emails=false. Не уверен, что случилось с пользователями, у которых были дубликаты адресов электронной почты; есть два очевидных варианта: присвоить им поддельный адрес электронной почты или пропустить их импорт. Похоже, что им присваивают поддельные адреса? (И есть довольно высокая вероятность, что эти пользователи на самом деле являются фейковыми). Если же они были пропущены, то повторный запуск скрипта импортирует их.

3 лайка

Да, на моем ноутбуке всё работает гораздо быстрее — около 1000 элементов в минуту. Это примерно в два раза быстрее, чем на сервере. Тем не менее, это всё ещё около трёх дней.

Я просмотрел пропущенные письма, и, похоже, система хорошо справляется с игнорированием этих аккаунтов. Я просто объединю их перед финальным импортом. Таких случаев всего около 20.

Обратите внимание: при финальном импорте будут импортированы только те пользователи и посты, которые ещё не были импортированы, поэтому это займёт совсем немного времени.

Спасибо, что обратили на это внимание. Я сам это заметил, и, похоже, именно это и станет решающим фактором при финальном импорте. Значит, я делаю резервную копию и восстанавливаю её за 3 дня до D-0, а затем снова делаю резервную копию и восстанавливаю её с новым файлом базы данных в день D-0. Правильно ли я понял?

1 лайк

Эти резервные копии и восстановления делаются на сайте XenForo, или у вас есть действующий сайт Discourse, в который вы планируете импортировать данные из XenForo?

Пока вы не вносите изменения в скрипт, требующие повторного импорта данных, и то, что сейчас находится на вашем ноутбуке, является тем, что вы хотите видеть на сервере Discourse, вы можете просто продолжать делать новые дампы базы данных XenForo и импортировать их (для проверки, оценки времени выполнения и т. д.). А в день перехода вы заморозите сайт XenForo, сделаете дамп базы данных, ещё раз запустите скрипт и загрузите данные на свой сервер Discourse.

Если на вашем сайте Discourse уже есть данные, которые вы хотите сохранить, всё становится гораздо сложнее, так как вам придётся заморозить этот сайт, затем получить данные из XenForo и далее действовать, как описано выше.

1 лайк

Это будет чистая установка Discourse, что упрощает задачу.

У меня есть достаточно времени, так как я хочу несколько раз протестировать миграции, thoroughly изучить Discourse, настроить все дополнения так, как мне нужно, и, возможно, даже заняться кастомизацией некоторых из них.

То, что вы объяснили, полностью снимает одну из моих главных проблем, так как я думал, что мне придётся разбираться ещё и с пакетным импортом.

2 лайка

Вернулся с вопросом: выводит ли скрипт импорта какие-либо логи? Мой тестовый импорт завис на отметке 98,2% уже несколько часов.

Ещё один момент, который я заметил: если перезапустить миграцию, то пропуск партии из 1000 постов занимает около 30 секунд. Таким образом, фактическая скорость теперь составляет 2000 элементов в минуту. Это незначительное улучшение по сравнению со скоростью первого импорта (1000 постов в минуту), так как даже при последнем импорте в день переключения это займёт примерно сутки. 23 часа из которых будут потрачены просто на пропуск уже импортированных элементов.

Только то, что вы видите.

Вам, вероятно, стоит остановить его и запустить заново.

Да, он пропустит все данные, которые уже были импортированы. При этом он делает это намного быстрее, чем 2000 сообщений в минуту. Я полагаю, вы это заметите, когда перезапустите его сейчас.

Вот именно, я перезапустил и затем написал вышеуказанный пост. Это 2000 постов в минуту. Чтобы убедиться, я попробовал ещё раз.

1 лайк

Удалось импортировать аватары и вложения. Скопировал следующие папки:

/internal_data/attachments
/data/avatars

Чтобы ответить на свой вопрос: аватары и вложения окончательно импортируются сразу после завершения процесса. Если пользователь изменит свой аватар после того, как его ID был импортирован, это изменение не будет импортировано или обновлено, так как этот пост или пользователь будут пропущены при втором запуске.

Теперь осталось разобраться с импортом сообщений (это можно пропустить, но было бы полезно) и постоянными перенаправлениями.

@Fajfi — спасибо за ваш вклад в скрипт импорта. Он безупречно сработал для аватаров и вложений. Процесс всё ещё выполняется, и пока не дошёл до этапа импорта лайков.

Исправил импорт сообщений. Удалось импортировать более полумиллиона сообщений из XF2.3 в Discourse. Создал PR, если кому-то это интересно.

----РЕДАКТИРОВАНИЕ----

Создал ещё один PR с исправлением для импорта лайков. Удивительно, что никто до сих пор не мигрировал с XF2.1+ на Discourse. Лайки были переименованы в реакции в 2019 году при выпуске XF2.1.

6 лайков

У меня возникли проблемы с импортом вложений.

Это проявлялось в виде [attach]number[/attach] в сообщениях.

Думаю, это связано с хешированными именами файлов, которые иногда расходятся с записями в базе данных.

Я изменил этот фрагмент кода:

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

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

Похоже, это работает при импорте из XenForo 2.3 в Discourse.

2 лайка

Результат всё равно не был хорошим. После импорта 3/4 миллиона записей возникла ошибка, и система сообщила, что XFMG отсутствует.

Мне придётся набраться ещё немного энтузиазма, чтобы заставить это работать.

Я значительно переработал код. На данный момент он работает достаточно хорошо. Пользовательские поля, которые у вас могли быть, не будут перенесены, но я переписал этот раздел снова (см. ниже) и очищу свой импорт, чтобы начать заново.

Подтверждено, что работает при переходе с Xenforo 2.3 на Self-hosted Discourse:

Пользователи

Пользовательские поля профиля

Личные сообщения / Разговоры

Медиагалерея XenForo

Менеджер ресурсов Xenforo

Лайки и реакции

Обработка дубликатов и недействительных email-адресов

Обработка BB-кодов XF2.3

Медиагалерея XF

Ресурсы XF

Я протестировал это на форуме с 750 000 сообщений, 20 000 участников и вложениями размером 20,7 ГБ. Импорт занимает около 20 часов. После импорта требуется ещё несколько часов, чтобы sidekiq доделал свою работу.

Важные замечания.

Мне пришлось разрешить моему Docker-контейнеру обращаться к локальной БД в CSF.

Монтирование важно:

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

Это избавляет вас от необходимости перемещать данные в Docker-инстанс. Здесь вы устанавливаете ссылку

# frozen_string_literal: true

# Скрипт импорта из XenForo 2.3 в Discourse
# Настроен специально для TurboRenault (dev.turborenault.co.uk)
# Поддерживает:
#   - Более 750 000 сообщений с Keyset Pagination (быстрая пакетная обработка O(1))
#   - Вложения и аватары размером 20,7 ГБ (разрешение путей XF 2.3)
#   - Пользовательские поля профиля и пользовательские поля тем/сообщений
#   - Группы пользователей и членство во вторичных группах (безопасное усечение до 20 символов при конфликтах)
#   - Личные сообщения / Разговоры и несколько получателей (с фолбэком для удалённых пользователей)
#   - Медиагалерея XenForo (XFMG)
#   - Менеджер ресурсов XenForo (XFRM с точным разрешением тем и обновлений)
#   - Лайки и реакции
#   - Автоматическая обработка дубликатов и недействительных email-адресов
#   - Парсер BB-кодов / форматирования XF 2.3
#
# Выполнение:
#   cd /var/discourse
#   ./launcher enter turborenault
#   su - discourse
#   cd /var/www/discourse
#   RAILS_ENV=production bundle exec ruby /shared/xenforo23.rb

require "mysql2"
require "set"

# Надёжное разрешение пути для base.rb
base_path = File.expand_path("../base.rb", __FILE__)
base_path = "/var/www/discourse/script/import_scripts/base.rb" unless File.exist?(base_path)
require base_path

class ImportScripts::XenForo23 < ImportScripts::Base
  # Предзаполненные учетные данные БД
  XENFORO_DB    = ENV["XF_DB_NAME"] || "YourDB"
  DB_HOST       = ENV["XF_DB_HOST"] || "172.17.0.1"
  DB_PORT       = (ENV["XF_DB_PORT"] || 3306).to_i
  DB_USER       = ENV["XF_DB_USER"] || "YourUserName"
  DB_PASS       = ENV["XF_DB_PASS"] || "YourPassword"
  
  TABLE_PREFIX  = ENV["XF_TABLE_PREFIX"] || "xf_"
  BATCH_SIZE    = (ENV["XF_BATCH_SIZE"] || 2000).to_i

  # Смонтированные пути
  ATTACHMENT_DIR = ENV["XF_ATTACHMENT_DIR"] || "/shared/import/internal_data/attachments"
  AVATAR_DIR     = ENV["XF_AVATAR_DIR"]     || "/shared/import/data/avatars"

  # Переключатели функций
  IMPORT_PM            = ENV.fetch("IMPORT_PM", "true") == "true"
  IMPORT_LIKES         = ENV.fetch("IMPORT_LIKES", "true") == "true"
  IMPORT_XFMG          = ENV.fetch("IMPORT_XFMG", "true") == "true"
  IMPORT_XFRM          = ENV.fetch("IMPORT_XFRM", "true") == "true"
  IMPORT_CUSTOM_FIELDS = ENV.fetch("IMPORT_CUSTOM_FIELDS", "true") == "true"

  def initialize
    super

    puts "--> Подключение к базе данных MySQL '#{XENFORO_DB}' на #{DB_HOST}:#{DB_PORT} как '#{DB_USER}'..."
    @client = Mysql2::Client.new(
      host: DB_HOST,
      port: DB_PORT,
      username: DB_USER,
      password: DB_PASS,
      database: XENFORO_DB,
      symbolize_keys: true,
      encoding: "utf8mb4",
      reconnect: true
    )

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

  def execute
    optimize_discourse_for_import

    import_custom_user_field_definitions if IMPORT_CUSTOM_FIELDS
    import_users
    import_groups
    import_categories
    import_posts

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

    restore_discourse_settings
    report_missing_files_summary
  end

  # =========================================================================
  # Оптимизация производительности для современного Discourse
  # =========================================================================

  def optimize_discourse_for_import
    puts "", "--> Применение оптимизаций для быстрого импорта в Discourse..."
    begin
      SiteSetting.disable_emails = "all" if SiteSetting.respond_to?(:disable_emails=)
      SiteSetting.process_with_fastimage = false if SiteSetting.respond_to?(:process_with_fastimage=)
      SiteSetting.max_topic_title_length = 500 if SiteSetting.respond_to?(:max_topic_title_length=)
      SiteSetting.min_topic_title_length = 1 if SiteSetting.respond_to?(:min_topic_title_length=)
      SiteSetting.min_post_length = 1 if SiteSetting.respond_to?(:min_post_length=)
      SiteSetting.allow_duplicate_topic_titles = true if SiteSetting.respond_to?(:allow_duplicate_topic_titles=)
      RateLimiter.disable rescue nil
    rescue StandardError => e
      puts "   Примечание по оптимизации: #{e.message}"
    end
  end

  def report_missing_files_summary
    if @missing_files_count > 0
      puts "", "   *** #{@missing_files_count} файлов НЕ найдено на диске во время импорта ***"
    else
      puts "   Все файлы вложений/аватаров найдены в порядке."
    end
  end

  def log_missing_file(type, context = {})
    @missing_files_count += 1
    label = type.to_s.upcase
    details = context.map { |k, v| "  #{k}: #{v}" }.join("\n")
    puts "\n   [ОТСУТСТВУЕТ #{label}]\n#{details}"
  end

  def restore_discourse_settings
    puts "", "--> Восстановление настроек сайта Discourse по умолчанию..."
    begin
      SiteSetting.disable_emails = "non_staff" if SiteSetting.respond_to?(:disable_emails=)
      RateLimiter.enable rescue nil
    rescue StandardError
      # игнорировать
    end
    puts "--> Импорт успешно завершён!"
  end

  # =========================================================================
  # 1. Пользовательские поля профиля
  # =========================================================================

  def import_custom_user_field_definitions
    puts "", "--> Импорт определений пользовательских полей..."
    fields = mysql_query("SELECT field_id, field_type FROM #{TABLE_PREFIX}user_field").to_a
    
    phrases = {}
    begin
      phrase_rows = mysql_query("SELECT title, phrase_text FROM #{TABLE_PREFIX}phrase WHERE title LIKE 'user_field_title.%' AND language_id = 1").to_a
      phrase_rows = mysql_query("SELECT title, phrase_text FROM #{TABLE_PREFIX}phrase WHERE title LIKE 'user_field_title.%'") if phrase_rows.empty?
      phrase_rows.each do |p|
        fid = p[:title].to_s.sub("user_field_title.", "")
        phrases[fid] = p[:phrase_text]
      end
    rescue StandardError => e
      puts "   Примечание по фразам: #{e.message}"
    end

    fields.each do |row|
      field_id = row[:field_id]
      title = phrases[field_id].presence || field_id.to_s.tr("_", " ").split.map(&:capitalize).join(" ")
      desc = "Пользовательское поле профиля: #{title}"

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

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

      @user_custom_fields_map[field_id] = uf.id
    end
    puts "   Сопоставлено определений пользовательских полей профиля: #{@user_custom_fields_map.size}."
  end

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

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

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

      val = row[:field_value]
      if val.to_s.start_with?("a:", "s:", "i:", "b:")
        begin
          val = PHP.unserialize(val)
          val = val.values.join(", ") if val.is_a?(Hash)
          val = val.join(", ") if val.is_a?(Array)
        rescue StandardError
          # оставить как есть
        end
      elsif val.to_s.start_with?("[", "{")
        begin
          parsed = JSON.parse(val)
          val = parsed.is_a?(Array) ? parsed.join(", ") : val
        rescue JSON::ParserError
          # оставить как есть
        end
      end

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

    custom_fields
  end

  # =========================================================================
  # 2. Пользователи и аватары
  # =========================================================================

  def import_users
    puts "", "--> Импорт пользователей..."
    total_count = mysql_query("SELECT COUNT(*) AS count FROM #{TABLE_PREFIX}user WHERE user_state = 'valid' AND is_banned = 0").to_a.first[:count]
    puts "   Найдено #{total_count} действительных пользователей."

    last_user_id = 0
    processed = 0

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

      results = mysql_query(sql).to_a

      break if results.empty?

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

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

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

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

      processed += results.size
    end
  end

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

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

    # Проверка стандартной валидности
    unless email_str.present? && email_str.include?("@") && email_str.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/)
      return "user_#{user_id}@imported.invalid"
    end

    # Проверка на дубликаты в наборе данных или в существующей таблице UserEmail Discourse
    if @seen_emails.include?(email_str) || UserEmail.exists?(email: email_str)
      prefix = email_str.split("@").first.gsub(/[^a-zA-Z0-9]/, "")
      return "duplicate_#{user_id}_#{prefix}@imported.invalid"
    end

    @seen_emails.add(email_str)
    email_str
  end

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

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

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

    imported_user.create_user_avatar if imported_user.user_avatar.nil?
    imported_user.user_avatar.update(custom_upload_id: upload.id)
    imported_user.update(uploaded_avatar_id: upload.id)
  rescue StandardError => e
    STDERR.puts "Ошибка импорта аватара для пользователя #{xf_user_id}: #{e.message}"
  end

  # =========================================================================
  # 3. Группы пользователей и членство (безопасное усечение при конфликтах)
  # =========================================================================

  def import_groups
    puts "", "--> Импорт групп пользователей и членства..."

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

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

      # Пропустить системные группы XF по умолчанию (1 = Не зарегистрированные, 2 = Зарегистрированные)
      next if [1, 2].include?(xf_gid)

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

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

      @group_mappings[xf_gid] = group.id
    end

    puts "   Сопоставлено пользовательских групп: #{@group_mappings.size}."

    last_user_id = 0

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

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

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

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

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

    puts "   Членство в группах успешно импортировано."
  end

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

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

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

    existing_names.add(candidate)
    candidate
  end

  # =========================================================================
  # 4. Категории (Иерархия форума)
  # =========================================================================

  def import_categories
    puts "", "--> Импорт категорий..."

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

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

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

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

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

  # =========================================================================
  # 5. Темы и сообщения (Живой счётчик для 750 000+ сообщений)
  # =========================================================================

  def import_posts
    puts "", "--> Импорт тем и сообщений (Keyset Pagination для 750k)..."

    total_posts = mysql_query("SELECT COUNT(*) AS count FROM #{TABLE_PREFIX}post p INNER JOIN #{TABLE_PREFIX}thread t ON p.thread_id = t.thread_id WHERE p.message_state = 'visible' AND t.discussion_state = 'visible'").to_a.first[:count]
    puts "   Всего сообщений для импорта: #{total_posts}"

    last_post_id = 0
    processed = 0

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

      break if results.empty?

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

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

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

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

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

        skip ? nil : mapped
      end

      processed += results.size
    end
  end

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

  # =========================================================================
  # 6. Менеджер ресурсов XenForo (XFRM)
  # =========================================================================

 # =========================================================================
  # 6. Менеджер ресурсов XenForo (XFRM)
  # =========================================================================

  def import_xfrm
    puts "", "--> Импорт Менеджера ресурсов XenForo (XFRM)..."

    has_xfrm = mysql_query("SHOW TABLES LIKE '#{TABLE_PREFIX}rm_resource'").to_a.present?
    unless has_xfrm
      puts "   Таблицы XFRM не найдены, пропускаем."
      return
    end

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

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

    puts "   Найдено ресурсов XFRM: #{resources.size}."

    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 : "*(Описание не предоставлено)*"

      # --- НОВОЕ: ПОЛУЧЕНИЕ ФАЙЛОВ РЕАЛЬНЫХ РЕСУРСОВ (ZIP, PDF) ---
      # Обновлено 'v.version_id' на 'v.resource_version_id' для XenForo 2
      version_files_sql = "
        SELECT a.attachment_id, a.data_id, d.filename, d.file_hash, d.user_id
        FROM #{TABLE_PREFIX}attachment a
        INNER JOIN #{TABLE_PREFIX}attachment_data d ON a.data_id = d.data_id
        INNER JOIN #{TABLE_PREFIX}rm_resource_version v ON v.resource_version_id = a.content_id
        WHERE a.content_type = 'resource_version' AND v.resource_id = #{res[:resource_id]}
      "
      resource_files = mysql_query(version_files_sql).to_a

      if resource_files.any?
        raw += "\n\n### Файлы для загрузки\n"
        resource_files.each do |file|
          upload = import_xf_attachment_file(file[:data_id], file[:file_hash], file[:user_id], file[:filename])
          if upload&.persisted?
            html = @uploader.html_for_upload(upload, file[:filename])
            raw += "\n* #{html}"
          end
        end
      end
      
      version_urls_sql = "
        SELECT version_string, download_url 
        FROM #{TABLE_PREFIX}rm_resource_version 
        WHERE resource_id = #{res[:resource_id]} AND download_url != ''
      "
      external_urls = mysql_query(version_urls_sql).to_a
      
      if external_urls.any?
        raw += "\n\n### Внешние загрузки\n"
        external_urls.each do |ext|
          raw += "\n* [Версия #{ext[:version_string]}](#{ext[:download_url]})"
        end
      end
      # --- КОНЕЦ ПОЛУЧЕНИЯ ФАЙЛОВ ---

      if res[:discussion_thread_id].to_i > 0 && res[:first_post_id].to_i > 0
        topic_map = topic_lookup_from_imported_post_id(res[:first_post_id].to_i)
        if topic_map
          raw += "\n\n---\n*Оригинальная тема обсуждения: [Посмотреть здесь](/t/-/#{topic_map[:topic_id]})*"
        end
      end

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

      create_post = {
        id: "xfrm_v5_#{res[:resource_id]}", # Обновлено до v5 для очистки упавшего кэша
        user_id: user_id,
        category: xfrm_cat.id,
        title: CGI.unescapeHTML(res[:title]),
        raw: raw,
        created_at: Time.zone.at(res[:resource_date])
      }

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

    # =========================================================================
  # 7. Медиагалерея XenForo (XFMG)
  # =========================================================================

  def import_xfmg
    puts "", "--> Импорт Медиагалереи XenForo (XFMG)..."

    has_xfmg = mysql_query("SHOW TABLES LIKE '#{TABLE_PREFIX}mg_media_item'").to_a.present?
    unless has_xfmg
      puts "   Таблицы XFMG не найдены, пропускаем."
      return
    end

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

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

    puts "   Найдено элементов медиагалереи XFMG: #{items.size}."

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

      # Если это встроенное видео (YouTube, Vimeo и т.д.), включите URL встраивания
      if item[:media_type] == "embed" && item[:media_tag].present?
        raw = "#{item[:media_tag]}\n\n#{raw}"
      else
        # Обработка изображений с использованием content_type 'xfmg_media' из XF 2.x
        raw = process_xf_attachments("xfmg_media", raw, item[:media_id])
      end

      raw = "*(Нет медиа-контента)*" if raw.blank?

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

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

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

  # =========================================================================
  # 8. Личные сообщения (Разговоры)
  # =========================================================================

  def import_private_messages
    puts "", "--> Импорт личных сообщений (Разговоры)..."

    last_conv_id = 0
    total_count = mysql_query("SELECT COUNT(*) AS count FROM #{TABLE_PREFIX}conversation_master").to_a.first[:count]
    puts "   Найдено разговоров с личными сообщениями: #{total_count}."
    processed = 0

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

      break if posts.empty?

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

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

        next if raw.blank?

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

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

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

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

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

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

        msg
      end

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

  # =========================================================================
  # 9. Реакции и лайки
  # =========================================================================

def import_likes
    puts "", "--> Очистка загрязнённых лайков и сброс кэшей с предыдущих попыток..."
    # Очистить сырые действия
    PostAction.where(post_action_type_id: PostActionType.types[:like]).delete_all
    # Обнулить кэшированные счётчики в UI
    Post.update_all(like_count: 0)
    UserStat.update_all(likes_given: 0, likes_received: 0)

    puts "--> Импорт реакций на сообщения XenForo 2.3 (Лайки)..."

    # Подсчёт с использованием values.first для безопасности между драйверами
    total_count = mysql_query(
      "SELECT COUNT(*) FROM #{TABLE_PREFIX}reaction_content WHERE content_type = 'post' AND is_counted = 1"
    ).first.values.first

    puts "   Найдено активных реакций на сообщения: #{total_count}."

    last_reaction_content_id = 0
    processed = 0

    loop do
      # Безопасная постраничная навигация с использованием первичного ключа (reaction_content_id)
      reactions = mysql_query(<<-SQL).to_a
        SELECT reaction_content_id, content_id, reaction_user_id, reaction_date
        FROM #{TABLE_PREFIX}reaction_content
        WHERE content_type = 'post'
          AND is_counted = 1
          AND reaction_content_id > #{last_reaction_content_id}
        ORDER BY reaction_content_id ASC
        LIMIT #{BATCH_SIZE}
      SQL

      break if reactions.empty?

      # Безопасное извлечение наибольшего первичного ключа для следующей страницы
      last_row = reactions.last
      last_reaction_content_id = (last_row[:reaction_content_id] || last_row["reaction_content_id"]).to_i

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

        # ПЕРЕДАЧА СЫРЫХ ID XENFORO НАПРЯМУЮ
        # Вспомогательный метод `create_likes` в Discourse автоматически преобразует их в фоновом режиме.
        {
          post_id: xf_post_id,
          user_id: xf_user_id,
          created_at: Time.zone.at(xf_date.to_i)
        }
      end

      processed += reactions.size

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

  # =========================================================================
  # 10. Парсер BB-кодов и трансформатор форматирования
  # =========================================================================

  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+)"?\)]) do
      username = $1
      imported_post_id = $2

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

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

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

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

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

    # Удаляем BBCode-теги, предназначенные только для оформления, которые не используются в Markdown
    s.gsub!(%r{\[/?(?:LEFT|RIGHT|CENTER|JUSTIFY|FONT|SIZE|COLOR|INDENT)(?:=[^\]]+)?\]}i, '')

    # Обрабатываем и заменяем теги [ATTACH] на загрузки Discourse
    s = process_xf_attachments("post", s, import_id) if import_id.to_i > 0

    s.strip
  end

 # =========================================================================
  # 11. Разрешение вложений и обработка файлов
  # =========================================================================

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

    embedded_ids = Set.new

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

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

      # Совпадение стандартных [ATTACH]123[/ATTACH], а также вариантов с атрибутами XF 2.3, таких как [ATTACH type="full" alt="..."]123[/ATTACH]
      pattern = %r{\[ATTACH[^\]]*\]\s*#{att_id}\s*\[/ATTACH\]}i
      if text.match?(pattern)
        text.gsub!(pattern, "\n#{html}\n")
        embedded_ids.add(att_id)
      end
    end

    # Добавляем вложения, которые не были размещены в тексте сообщения
    unattached = attachments.reject { |a| embedded_ids.include?(a[:attachment_id]) }
    if unattached.any?
      text += "\n\n"
      unattached.each do |att|
        upload = import_xf_attachment_file(att[:data_id], att[:file_hash], att[:user_id], att[:filename])
        next unless upload&.persisted?
        html = @uploader.html_for_upload(upload, att[:filename])
        text += "#{html}\n"
      end
    end

    # Финальная чистка: удаляем любые сиротские или отсутствующие разметки [ATTACH]
    text.gsub!(%r{\[ATTACH[^\]]*\]\s*\d*\s*\[/ATTACH\]}i, '')

    text
  end

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

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

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

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

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

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

    upload
  rescue StandardError => e
    STDERR.puts "Не удалось обработать вложение 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 лайка