Migrare un forum XenForo a Discourse

Nel forum che ho trasferito, una volta era installato xengallery, quindi ho dovuto cambiare quanto segue, poiché la tabella xfgallery non esisteva più.

  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 Mi Piace

Puoi provare a eseguire nuovamente lo script e vedere se termina.

Il tuo sistema è su una singola partizione?

Il passaggio di installazione di mysql-server è ora obsoleto. È necessario installare mariadb-server.

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

Sono stato in grado di seguire il resto dei passaggi e importare il DB di XF, quindi qualcuno dovrebbe aggiornare la guida.

Ora sono bloccato sul seguente passaggio e ho bisogno di aiuto.

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

L’esecuzione di quanto sopra mi dà il seguente errore. Ho controllato il Gemfile e contiene solo questa riga: gem ‘mysql2’

Questo Gemfile non include una sorgente globale esplicita.
Non utilizzare una sorgente globale esplicita potrebbe comportare la generazione di un lockfile diverso a seconda delle gem installate localmente prima dell'esecuzione di bundler.
Definisci invece una sorgente globale nel tuo Gemfile in questo modo: source "https://rubygems.org".
Impossibile trovare la gem 'mysql2' nelle gem installate localmente.
root@ip-172-566-459-13-app:/#

Ok, sono riuscito a passare alla fase successiva. Qualcuno sopra ha scritto che dobbiamo trovarci nella cartella /var/www/discourse sul container e poi aggiungere la gem.

Ora all’ultimo passaggio

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

Sto ricevendo questo errore. Cosa potrei star sbagliando?

`/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”: Non siamo riusciti a trovare il tuo database: discourse.
Le configurazioni del database disponibili si trovano in config/database.yml. (ActiveRecord::NoDatabaseError)Per risolvere questo errore:

  • Non hai creato il database o lo hai eliminato? Per creare il database, esegui:
    bin/rails db:create
  • È cambiato il nome del database? Verifica che config/database.yml contenga il nome del database corretto.`

Risolto: stavo eseguendo come utente root, ho dovuto passare all’utente ‘discourse’. L’importazione è iniziata.

4 Mi Piace

Ho acquistato un server ragionevolmente buono con 4 CPU e 16 GB di RAM. Al ritmo con cui i post vengono migrati, ci vorranno 9 giorni solo per la migrazione dei post. La migrazione degli utenti ha richiesto 2,5 ore. Si può dire tranquillamente che così com’è non fa per me, ma almeno posso passare qualche mese a familiarizzare finché non troverò una soluzione per questa migrazione di massa.

PS:
Nello script di migrazione vedo che le email duplicate non vengono importate. Quali sono i diversi modi in cui viene determinato un duplicato? Ho notato che xyz@gmail.com viene trattato allo stesso modo di xyz+1@gmail.com e xy.z@gmail.com

Esiste anche qualche altro schema?

Ho provato a fare migrazioni su VPS con specifiche simili al mio computer personale, ma per qualche motivo è sempre stato molto, molto più lento che usare il mio computer.

Oggigiorno, faccio sempre le mie migrazioni localmente. Quanti post hai?

1 Mi Piace

2,5 milioni di post.
Proverò la migrazione locale su un Mac M1 per confrontare.

1 Mi Piace

Questo è praticamente tutto. Il controllo di unicità viene eseguito sulla versione minuscola e normalizzata dell’indirizzo email fornito.

Normalizziamo rimuovendo tutti i punti e ignorando tutto ciò che segue il + nel nome utente.

3 Mi Piace

La velocità della singola CPU è il fattore importante.

Sulle mie macchine, un tasso di 800-1000 utenti o post/minuto è abbastanza tipico.

Nota che quando esegui l’importazione finale, importerà solo gli utenti e i post che non sono già stati importati, quindi non ci vorrà molto.

Disattiva l’impostazione del sito Normalize emails (disattivato era l’impostazione predefinita fino a poco tempo fa). Probabilmente dovrebbe essere disattivato in questa funzione qui:

Puoi metterlo nella tua versione personalizzata dello script xenforo con SiteSetting.normalize_emails=false. Non sono sicuro di cosa sia successo a quegli utenti con email duplicate; ci sono due cose ovvie da fare, dare loro un indirizzo email fittizio o saltare l’importazione. Sembra che dia loro quelli fittizi? (Ed è molto probabile che siano, di fatto, utenti fittizi comunque). Se li ha saltati, eseguire nuovamente lo script li importerà.

3 Mi Piace

Sì, sul mio portatile, sta elaborando le cose molto più velocemente a 1000 elementi al minuto. Circa 2 volte più veloce rispetto al server. Comunque ci vogliono circa 3 giorni.

Ho esaminato le email saltate e sembra che stia facendo un buon lavoro ignorando quegli account. Li unirò prima dell’importazione finale. Appena 20 casi circa.

Nota che quando farai l’importazione finale, importerà solo gli utenti e i post che non sono già stati importati, quindi non richiederà molto tempo.

Grazie per averlo fatto notare. L’ho osservato anch’io e sembra che questo sia ciò che salverà la situazione quando farò l’importazione finale. Quindi faccio un backup e un ripristino su D-3 e poi un altro backup e ripristino con il nuovo file di backup del database il Giorno 0. È corretto?

1 Mi Piace

Quei backup e ripristini sono sul sito Xenforo, o hai un sito Discourse live a cui importerai i dati Xenforo?

Finché non apporti modifiche allo script che richiedono la reimportazione dei dati e ciò che hai ora sul tuo laptop è ciò che desideri sul tuo server Discourse, puoi semplicemente continuare a ottenere nuovi dump del database Xenforo e importarli (per testare, vedere quanto tempo ci vuole, ecc.) e poi il giorno del passaggio, blocchi il sito Xenforo, ottieni quel database, esegui nuovamente lo script e carichi sul tuo server Discourse.

Se hai già dati sul tuo sito Discourse che desideri conservare, le cose sono molto più complicate poiché dovrai bloccare quel sito, quindi ottenere i dati Xenforo e quindi procedere come descritto sopra.

1 Mi Piace

Sarà una nuova installazione di Discourse, il che renderà le cose più semplici.

Ho parecchio tempo a disposizione poiché voglio testare le migrazioni più volte, familiarizzare a fondo con Discourse, configurare tutti i componenti aggiuntivi come desidero e magari anche sporcarmi le mani con la personalizzazione di alcuni componenti aggiuntivi.

Ciò che hai spiegato mi toglie un grosso peso dallo stomaco, dato che pensavo di dover anche capire come gestire le importazioni massive.

2 Mi Piace

Sono tornato con una domanda, lo script di importazione produce dei log? La mia importazione di prova è bloccata al 98,2% da alcune ore.

Un’altra cosa che ho capito, se riavvio la migrazione, ci vogliono circa 30 secondi per saltare un batch di 1000 post. Quindi, in effetti, la velocità è ora di 2000 elementi al minuto. Non è un miglioramento significativo rispetto ai 1000 post al minuto della prima importazione, poiché anche nell’ultima importazione il giorno del cutover, ci vorrà circa un giorno. 23 ore delle quali serviranno solo a saltare elementi già importati.

Solo quello che vedi.

Dovresti probabilmente fermarla e riavviarla.

Sì, salterà tutti i dati che sono già stati importati. E lo fa molto più velocemente di 2000 post/minuto. Sospetto che vedrai quando la riavvierai ora.

Questo è quello che ho fatto, ho riavviato e poi ho scritto il post qui sopra. Sono 2000 post/minuto. Per essere sicuro, ci ho riprovato.

1 Mi Piace

Sono riuscito a importare avatar e allegati. Ho copiato queste cartelle.

/internal_data/attachments
/data/avatars

Per rispondere alla mia domanda, avatar e allegati vengono finalizzati una volta importati. Se un utente cambia il proprio avatar dopo che il suo ID è stato importato, non verrà importato/aggiornato perché quel post o utente verrà saltato nella seconda esecuzione.

Ora devo solo capire l’importazione delle conversazioni (si può saltare ma è utile averla) e i reindirizzamenti permanenti.

@Fajfi - Grazie per il tuo contributo allo script di importazione. Ha funzionato perfettamente per avatar e allegati. È ancora in esecuzione e non ho ancora raggiunto la parte dei “mi piace”.

Corretta l’importazione delle conversazioni. Sono riuscito a importare oltre mezzo milione di messaggi da XF2.3 in Discourse. Ho sollevato una PR nel caso qualcuno fosse interessato.

----EDIT----

Sollevata un’altra PR con una correzione per l’importazione dei “mi piace”. È sorprendente che nessuno abbia migrato da XF2.1+ a Discourse fino ad ora. I “mi piace” sono stati rinominati in “reazioni” nel 2019, quando è uscito XF2.1.

6 Mi Piace

Ho avuto alcuni problemi con l’importazione degli allegati.

Questo si manifestava con [attach]numero[/attach] nei post.

Penso che ciò sia dovuto ai nomi dei file hashati che a volte non sono sincronizzati con il record del database.

Ho modificato questa parte di codice:

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 "Impossibile trovare il file per data_id #{data_id}. Saltato."
    end
    upload
  end

Sembra funzionare nella mia importazione da XenForo 2.3 a Discourse.

2 Mi Piace

Ancora nessun risultato soddisfacente. Ha restituito un errore dopo 3/4 milioni di importazioni e ha segnalato che XFMG non era presente.

Dovrò ritrovare un po’ più di entusiasmo per farla funzionare.

Ho rifattorizzato gran parte del codice. Al momento sembra funzionare piuttosto bene. I campi personalizzati che potresti aver configurato non verranno importati, ma ho riscritto anche quella parte (vedi sotto) e ripulirò l’importazione per ricominciare.

Funzionamento confermato su Xenforo 2.3 verso Discourse self-hosted:

Utenti

Campi utente personalizzati

PM / Conversazioni

Galleria multimediale XenForo

Gestore risorse Xenforo

Mi piace e reazioni

Gestione di email duplicate e non valide

Gestione del codice BB XF2.3

Galleria multimediale XF

Risorse XF

Ho testato questo su 750.000 post, 20.000 membri e 20,7 GB di allegati. L’importazione richiede circa 20 ore. Servono alcune ore aggiuntive dopo l’importazione perché Sidekiq si metta in pari.

Alcune note.

Ho dovuto autorizzare il mio Docker ad accedere al DB locale in CSF.

I mount sono importanti:

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

Questo ti evita di dover spostare i dati nell’istanza Docker. Qui stai creando un collegamento

# frozen_string_literal: true

# Script di importazione da XenForo 2.3 a Discourse
# Configurato specificamente per TurboRenault (dev.turborenault.co.uk)
# Supporta:
#   - Oltre 750.000 post con Keyset Pagination (batching rapido O(1))
#   - Allegati e avatar da 20,7 GB (risoluzione dei percorsi XF 2.3)
#   - Campi profilo utente personalizzati e campi personalizzati per thread/post
#   - Gruppi utente e appartenenze a gruppi secondari (troncamento sicuro da collisioni a 20 caratteri)
#   - Messaggi privati / Conversazioni e destinatari multipli (con fallback per utenti eliminati)
#   - Galleria multimediale XenForo (XFMG)
#   - Gestore risorse XenForo (XFRM con risoluzione accurata di thread e aggiornamenti)
#   - Mi piace e reazioni
#   - Gestione automatica del fallback per email duplicate e non valide
#   - Parser di formattazione / BBCode XF 2.3
#
# Esecuzione:
#   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"

# Risoluzione robusta dei percorsi per 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
  # Credenziali del database precompilate
  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

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

  # Interruttori delle funzionalità
  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 "--> Connessione al database MySQL '#{XENFORO_DB}' su #{DB_HOST}:#{DB_PORT} come '#{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

  # =========================================================================
  # Ottimizzazione delle prestazioni per Discourse moderno
  # =========================================================================

  def optimize_discourse_for_import
    puts "", "--> Applicazione ottimizzazioni ad alta velocità per l'importazione in 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 "   Nota ottimizzazione: #{e.message}"
    end
  end

  def report_missing_files_summary
    if @missing_files_count > 0
      puts "", "   *** #{@missing_files_count} file NON trovati su disco durante l'importazione ***"
    else
      puts "   Tutti i file di allegati/avatar trovati con successo."
    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   [MANCANTE #{label}]\n#{details}"
  end

  def restore_discourse_settings
    puts "", "--> Ripristino delle impostazioni di sito Discourse predefinite..."
    begin
      SiteSetting.disable_emails = "non_staff" if SiteSetting.respond_to?(:disable_emails=)
      RateLimiter.enable rescue nil
    rescue StandardError
      # ignora
    end
    puts "--> Importazione completata con successo!"
  end

  # =========================================================================
  # 1. Campi profilo utente personalizzati
  # =========================================================================

  def import_custom_user_field_definitions
    puts "", "--> Importazione definizioni dei campi utente personalizzati..."
    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 "   Nota frasi: #{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 = "Campo profilo personalizzato: #{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 "   Mappate #{@user_custom_fields_map.size} definizioni di campi profilo personalizzati."
  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
          # lascia invariato
        end
      elsif val.to_s.start_with?("[", "{")
        begin
          parsed = JSON.parse(val)
          val = parsed.is_a?(Array) ? parsed.join(", ") : val
        rescue JSON::ParserError
          # lascia invariato
        end
      end

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

    custom_fields
  end

  # =========================================================================
  # 2. Utenti e avatar
  # =========================================================================

  def import_users
    puts "", "--> Importazione utenti..."
    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 "   Trovati #{total_count} utenti validi."

    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

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

    # Verifica duplicati nell'intero set di dati o nella tabella UserEmail esistente di 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 "Errore nell'importazione dell'avatar per l'utente #{xf_user_id}: #{e.message}"
  end

  # =========================================================================
  # 3. Gruppi utente e appartenenze (troncamento sicuro da collisioni)
  # =========================================================================

  def import_groups
    puts "", "--> Importazione gruppi utente e appartenenze..."

    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)

      # Salta i gruppi di sistema XF predefiniti (1 = Non registrato, 2 = Registrato)
      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 "   Mappati #{@group_mappings.size} gruppi utente personalizzati."

    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 "   Importazione appartenenze ai gruppi completata con successo."
  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. Categorie (gerarchia del forum)
  # =========================================================================

  def import_categories
    puts "", "--> Importazione categorie..."

    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. Argomenti e post (contatore live per oltre 750.000 post)
  # =========================================================================

  def import_posts
    puts "", "--> Importazione argomenti e post (Keyset Pagination per 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 "   Totale post da importare: #{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. Gestore risorse XenForo (XFRM)
  # =========================================================================

 # =========================================================================
  # 6. Gestore risorse XenForo (XFRM)
  # =========================================================================

  def import_xfrm
    puts "", "--> Importazione Gestore risorse XenForo (XFRM)..."

    has_xfrm = mysql_query("SHOW TABLES LIKE '#{TABLE_PREFIX}rm_resource'").to_a.present?
    unless has_xfrm
      puts "   Nessuna tabella XFRM trovata, salto."
      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 "   Trovate #{resources.size} risorse XFRM."

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

      raw = ""
      raw += "**#{tagline}**\n\n" if tagline.present?
      raw += body_text.present? ? body_text : "*(Nessuna descrizione fornita)*"

      # --- NUOVO: RECUPERO DEI FILE RISORSA EFFETTIVI (ZIP, PDF) ---
      # Aggiornato 'v.version_id' a 'v.resource_version_id' di 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### File scaricabili\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### Download esterni\n"
        external_urls.each do |ext|
          raw += "\n* [Versione #{ext[:version_string]}](#{ext[:download_url]})"
        end
      end
      # --- FINE RECUPERO NUOVI FILE ---

      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*Thread di discussione originale: [Vedi qui](/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]}", # Aumentato a v5 per cancellare la cache corrotta
        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. Galleria multimediale XenForo (XFMG)
  # =========================================================================

  def import_xfmg
    puts "", "--> Importazione Galleria multimediale XenForo (XFMG)..."

    has_xfmg = mysql_query("SHOW TABLES LIKE '#{TABLE_PREFIX}mg_media_item'").to_a.present?
    unless has_xfmg
      puts "   Nessuna tabella XFMG trovata, salto."
      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 "   Trovati #{items.size} elementi multimediali XFMG."

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

      # Se è un video incorporato (YouTube, Vimeo, ecc.), includi l'URL di incorporamento
      if item[:media_type] == "embed" && item[:media_tag].present?
        raw = "#{item[:media_tag]}\n\n#{raw}"
      else
        # Elabora gli allegati immagine usando il content_type 'xfmg_media' di XF 2.x
        raw = process_xf_attachments("xfmg_media", raw, item[:media_id])
      end

      raw = "*(Nessun contenuto multimediale)*" 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. Messaggi privati (Conversazioni)
  # =========================================================================

  def import_private_messages
    puts "", "--> Importazione messaggi privati (Conversazioni)..."

    last_conv_id = 0
    total_count = mysql_query("SELECT COUNT(*) AS count FROM #{TABLE_PREFIX}conversation_master").to_a.first[:count]
    puts "   Trovate #{total_count} conversazioni di messaggi privati."
    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. Reazioni e Mi piace
  # =========================================================================

def import_likes
    puts "", "--> Cancellazione dei mi piace contaminati e reset delle cache dai tentativi precedenti..."
    # Cancella le azioni grezze
    PostAction.where(post_action_type_id: PostActionType.types[:like]).delete_all
    # Azzera i contatori in cache nell'interfaccia utente
    Post.update_all(like_count: 0)
    UserStat.update_all(likes_given: 0, likes_received: 0)

    puts "--> Importazione reazioni ai post di XenForo 2.3 (Mi piace)..."

    # Conta usando values.first per sicurezza cross-driver
    total_count = mysql_query(
      "SELECT COUNT(*) FROM #{TABLE_PREFIX}reaction_content WHERE content_type = 'post' AND is_counted = 1"
    ).first.values.first

    puts "   Trovate #{total_count} reazioni ai post attive."

    last_reaction_content_id = 0
    processed = 0

    loop do
      # Pagina in modo sicuro usando la chiave primaria (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?

      # Estrae in modo sicuro la chiave primaria più alta per il batch di paginazione successivo
      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"]

        # PASSA GLI ID XENFORO GREZZI DIRETTAMENTE
        # L'helper `create_likes` di Discourse li traduce automaticamente in 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. Parser BBCode e trasformatore di formattazione
  # =========================================================================

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

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

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

    s = CGI.unescapeHTML(s)

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

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

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

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

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

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

    # Rimuovi i tag BBCode di presentazione non utilizzati da Markdown
    s.gsub!(%r{\[/?(?:LEFT|RIGHT|CENTER|JUSTIFY|FONT|SIZE|COLOR|INDENT)(?:=[^\]]+)?\]}i, '')

    # Elabori e sostituisce i tag [ATTACH] con gli upload di Discourse
    s = process_xf_attachments("post", s, import_id) if import_id.to_i > 0

    s.strip
  end

 # =========================================================================
  # 11. Risoluzione allegati e gestione file
  # =========================================================================

  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])

      # Corrisponde agli standard [ATTACH]123[/ATTACH] nonché alle varianti con attributi di XF 2.3 come [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

    # Aggiunge eventuali allegati non inseriti inline nel corpo del testo
    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

    # Passata di pulizia finale: rimuove qualsiasi markup [ATTACH] orfano o mancante
    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

Alcuni utenti non sono stati importati a causa di indirizzi email bizzarri creati da spambats. Ad esempio, email come Peter.Today.Rain.Import.Something@Dave.Gmail.com.

Aggiornerrò/modificherò il mio codice di importazione sopra una volta che i campi personalizzati saranno disponibili, poiché sono importanti per me.

.

4 Mi Piace