Migrer un forum vBulletin 3 vers Discourse via XenForo

Je rédige ceci pendant que c’est encore frais dans mon esprit — c’est actuellement un travail en cours, alors assurez-vous de le tester et de vérifier qu’il répond à vos besoins.

À ma connaissance, il n’existe pas d’importateur de vB3 vers Discourse, et je ne possède aucune licence vB4/5, qui sont celles pour lesquelles les importateurs de Discourse sont conçus. Cependant, je possède une licence XenForo 1.4 et il existe un importateur Discourse pour cela ! Pour ceux qui n’ont pas de licence XF, vous pouvez souvent en acheter d’occasion, ou demander/payer quelqu’un pour effectuer l’importation et vous fournir la base de données XF.

J’ai déjà effectué une importation de vB3.6 vers XF, donc je sais que cela fonctionne bien (la seule chose qui n’est pas importée sont les photos de profil, car XF ne dispose que d’avatars — mais j’ai une solution pour cela).

Ok…

Importez d’abord votre forum vB vers XF comme vous le faites habituellement.

Je recommande de garder votre forum vB en ligne et accessible sur Internet (effectuez donc l’importation vers XF dans un sous-répertoire). C’est simplement une mesure de sécurité au cas où vous décideriez plus tard de conserver le forum vB, et aussi parce que nous allons copier les photos de profil depuis le site en direct (bien que vous puissiez utiliser le script de copie de profil ci-dessous au préalable si vous en avez vraiment besoin).

Une fois que vous avez vérifié que le forum a été converti avec succès vers XF, faites une sauvegarde de cette nouvelle base de données et copiez-la sur votre machine de développement.

Ma machine de développement est un Mac, donc ces instructions sont pour macOS.

brew install mysql
// Assurez-vous également qu'il est démarré

mysql -u root

create database xenforo_db;
exit;

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

Configurez votre environnement de développement Discourse comme d’habitude (voir ce guide pour macOS), puis :

Ouvrez database.yml et changez le nom de la base de données en quelque chose comme discourse_development_nomdu_site_01 — utilisez des chiffres afin de pouvoir répéter l’importation plusieurs fois simplement en changeant le chiffre.

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

Pour le premier compte administrateur, essayez d’utiliser la même adresse e-mail que votre compte administrateur existant sur votre installation vB/XF. Sélectionnez « O » lorsqu’on vous demande si vous souhaitez lui accorder des permissions d’administrateur.

Pour la deuxième création de compte, utilisez une adresse e-mail du type invité@quelquechose.com et sélectionnez « N » lorsqu’on vous demande de le créer en tant que compte administrateur. Nous avons besoin de ce compte pour les publications associées aux invités/utilisateurs supprimés. Vous pouvez entrer dans rails c puis taper User.last pour vérifier son ID, mais il sera probablement 2. Nous l’ajouterons au script d’importation.

J’ai apporté quelques modifications au script d’importation, voici ma version du script (remplacez le contenu de script/import_scripts/xenforo.rb par celui-ci) :

# frozen_string_literal: true

require "mysql2"
require_relative "base"

require "set" # peut-être pas nécessaire — je ne me souviens plus pourquoi je l'ai ajouté
require "htmlentities" # peut-être pas nécessaire — je ne me souviens plus pourquoi je l'ai ajouté

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

# Appelez-le comme ceci :
#   RAILS_ENV=production bundle exec ruby script/import_scripts/xenforo.rb
class ImportScripts::XenForo < ImportScripts::Base

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

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

    @category_mappings = {}
    @prefix_as_category = false
  end

  def execute
    import_users
    import_avatars
    import_categories
    import_posts
  end

  def import_users
    puts '', "création des utilisateurs"

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

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

      break if results.size < 1

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

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

  def import_user_profiles
    puts "Importation des profils utilisateurs..."

    user_profiles = mysql_query("
        SELECT user_id, location, about
        FROM #{TABLE_PREFIX}user_profile
        ORDER BY user_id;
    ")
    
    puts "Importation des profils : récupération des informations"
    user_profiles.each do |row|
      usf = UserCustomField.find_by(name: "import_id", value: row["user_id"])
      if user = User.find(usf.user_id)
        puts "Mise à jour du profil pour #{user.username}"
        profile = user.user_profile
        profile.location = row["location"]
        profile.bio_raw = row["about"]
        profile.save
      end
    end
  end

  def import_categories
    puts "", "importation des catégories..."

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

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

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

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

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

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

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

    # les catégories plus profondes doivent devenir des tags
    categories.each do |c|
      next if c['parent_node_id'] == 0
      next if top_level_category_ids.include?(c['id'])
      next if subcategory_ids.include?(c['id'])

      # Trouver une sous-catégorie pour les sujets dans cette catégorie
      parent = c
      while !parent.nil? && !subcategory_ids.include?(parent['id'])
        parent = categories.find { |subcat| subcat['id'] == parent['parent_node_id'] }
      end

      if parent
        tag_name = DiscourseTagging.clean_tag(c['title'])
        @category_mappings[c['id']] = {
          category_id: category_id_from_imported_category_id(parent['id']),
          tag: Tag.find_by_name(tag_name) || Tag.create(name: tag_name)
        }
      else
        puts '', "Impossible de trouver une catégorie pour #{c['id']} '#{c['title']}' !"
      end
    end
  end

  # Cette méthode est une alternative à import_categories.
  # Elle utilise les préfixes au lieu des nœuds.
  def import_categories_from_thread_prefixes
    puts "", "importation des catégories..."

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

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

    @prefix_as_category = true
  end

  def import_posts
    puts "", "création des sujets et des messages"

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

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

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

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

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

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

        if m['id'] == m['first_post_id']
          if m['category_id'].to_i == 0 || m['category_id'].nil?
            mapped[:category] = SiteSetting.uncategorized_category_id
          else
            mapped[:category] = category_id_from_imported_category_id(m['category_id'].to_i) ||
              @category_mappings[m['category_id']].try(:[], :category_id)
          end
          mapped[:title] = CGI.unescapeHTML(m['title'])
        else
          parent = topic_lookup_from_imported_post_id(m['first_post_id'])
          if parent
            mapped[:topic_id] = parent[:topic_id]
          else
            puts "Le message parent #{m['first_post_id']} n'existe pas. Ignorance de #{m["id"]}: #{m["title"][0..40]}"
            skip = true
          end
        end

        skip ? nil : mapped
      end
    end

    # Appliquer les tags
    batches(BATCH_SIZE) do |offset|
      results = mysql_query("#{posts_sql} OFFSET #{offset};").to_a
      break if results.size < 1

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

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

        topic.tags = [tag] if topic
      end
    end

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

    # :) est encodé comme <!-- s:) --><img src="{SMILIES_PATH}/icon_e_smile.gif" alt=":)" title="Smile" /><!-- s:) -->
    s.gsub!(/<!-- s(\S+) --><img (?:[^>]+) \/><!-- s(?:\S+) -->/, '\1')

    # Certains liens ressemblent à ceci : <!-- m --><a class="postlink" href="http://www.onegameamonth.com">http://www.onegameamonth.com</a><!-- m -->
    s.gsub!(/<!-- \w --><a(?:.+)href="(\S+)"(?:.*)>(.+)<\/a><!-- \w -->/, '[\2](\1)')

    # De nombreux balises bbcode phpbb ont un hash attaché. Exemples :
    #   [url=https&#58;//google&#46;com:1qh1i7ky]cliquez ici[/url:1qh1i7ky]
    #   [quote=&quot;cybereality&quot;:b0wtlzex]Un texte.[/quote:b0wtlzex]
    s.gsub!(/:(?:\w{8})\]/, ']')

    # Supprimer les balises vidéo mybb.
    s.gsub!(/(^\[video=.*?\])|(\[\/video\]$)/, '')

    s = CGI.unescapeHTML(s)

    # phpBB raccourcit le texte des liens comme ceci, ce qui casse notre traitement markdown :
    #   [http://answers.yahoo.com/question/index ... 223AAkkPli](http://answers.yahoo.com/question/index?qid=20070920134223AAkkPli)
    #
    # Correctif pour l'erreur : xenforo.rb: 160: in `gsub!': invalid byte sequence in UTF-8 (ArgumentError)
    if ! s.valid_encoding?
      s = s.encode("UTF-16be", invalid: :replace, replace: "?").encode('UTF-8')
    end

    # Contournement pour le moment :
    s.gsub!(/\[http(s)?:\/\/(www\.)?/, '[')

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

    # Citations imbriquées
    s.gsub!(/(\[\/?QUOTE.*?\])/mi) { |q| "\n#{q}\n" }

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

      topic_mapping = topic_lookup_from_imported_post_id(imported_post_id)

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

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

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

    # convertir les balises de liste en ul et les balises list=1 en ol
    # (en gros, il nous manque seulement list=a ici...)
    s.gsub!(/\[list\](.*?)\[\/list:u\]/m, '[ul]\1[/ul]')
    s.gsub!(/\[list=1\](.*?)\[\/list:o\]/m, '[ol]\1[/ol]')
    # convertir les balises *-tags en balises li afin que bbcode-to-md puisse faire sa magie sur les listes de phpBB :
    s.gsub!(/\[\*\](.*?)\[\/\*:m\]/, '[li]\1[/li]')

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

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

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

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

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

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

    # Supprimer la balise de couleur
    s.gsub!(/\[color=[#a-z0-9]+\]/i, "")
    s.gsub!(/\[\/color\]/i, "")

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

    s
  end

  def process_xf_attachments(xf_type, s)
    ids = Set.new
    ids.merge(s.scan(get_xf_regexp(xf_type)).map { |x| x[0].to_i })
    ids.each do |id|
      next unless id
      sql = get_xf_sql(xf_type, id).squish!
      results = mysql_query(sql)
      if results.size < 1
        # Supprimer la pièce jointe
        s.gsub!(get_xf_regexp(xf_type, id), '')
        STDERR.puts "#{xf_type.capitalize} id #{id} introuvable dans la base de données source. Suppression."
        next
      end
      original_filename = results.first['filename']
      result = results.first
      upload = import_xf_attachment(result['data_id'], result['file_hash'], result['user_id'], original_filename)
      next unless upload
      if upload.present? && upload.persisted?
        s.gsub!(get_xf_regexp(xf_type, id), @uploader.html_for_upload(upload, original_filename))
      else
        STDERR.puts "Impossible de trouver le téléchargement : #{upload.id}. Ignorance de la pièce jointe id #{id}"
      end
    end
    s
  end

  def import_xf_attachment(data_id, file_hash, owner_id, original_filename)
    current_filename = "#{data_id}-#{file_hash}.data"
    path = Pathname.new(ATTACHMENT_DIR + "/#{data_id / 1000}/#{current_filename}")
    new_path = path.dirname + original_filename
    upload = nil
    if File.exist? path
      FileUtils.cp path, new_path
      upload = @uploader.create_upload owner_id, new_path, original_filename
      FileUtils.rm new_path
    else
      STDERR.puts "Impossible de trouver le fichier #{path}. Ignorance de la pièce jointe id #{data_id}"
    end
    upload
  end

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

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

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

ImportScripts::XenForo.new.perform

Notes :

  • Il ajoute une étape/méthode import_avatars (ceux-ci doivent être des jpg)
  • Nous ajoutons les chemins vers les avatars et les photos de profil
  • Nous ajoutons l’ID du nouvel utilisateur invité créé comme solution de repli lorsqu’un utilisateur n’existe pas mais qu’un message existe (utilisateurs invités)

Maintenant, copions les photos de profil pour les utiliser comme avatars si elles existent — si elles n’existent pas, l’avatar de l’utilisateur sera utilisé s’il en a téléchargé un. Vous pouvez sauter cette étape si vous voulez simplement une importation directe d’avatars vers avatars.

Copieur de photo de profil :

D’abord, installez Down avec gem install down

Ensuite, créez un nouveau fichier avec :

require 'down'

(1..NOMBRE_D_UTILISATEURS).each do |u|
  puts "Récupération de l'utilisateur #{u}"
  puts ""
  profile_pic_url = "https://www.nom-du-forum.com/image.php?u=#{u}&type=profile"
  destination = "/chemin/complet/où/vous/voulez/sauvegarder/les/photos/de/profil/#{u}.jpg"
  begin
    Down.download(profile_pic_url, destination: destination)
    puts "Terminé #{u}"
  rescue
    puts "Échec #{u}"
  end
  puts ""
end

Notes :

  • Suppose que toutes les photos de profil (et les avatars) sont des jpg. Heureusement, nous n’avons autorisé que des jpg comme avatars et photos de profil, donc cela fonctionne pour nous.
  • Assurez-vous que vos chemins et URL sont corrects et que les profils et les photos de profil sont visibles par les invités.
  • Remplacez NOMBRE_D_UTILISATEURS par le nombre d’utilisateurs que vous avez (par exemple 3872).

Ensuite, exécutez le script dans le terminal en tapant ruby /chemin/vers/nom-du-script.rb dans le terminal. Cela copiera toutes vos photos de profil dans ce répertoire, puis il vous suffira d’y aller, de trier par taille de fichier et de supprimer tous les fichiers vides (il y en aura beaucoup — car tout le monde ne télécharge pas de photo de profil).

Réalisation de l’importation :

Une fois tout cela fait, vous êtes prêt à commencer :smiley:

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

Cela prend environ 90 minutes pour importer un forum avec 100 000 messages et quelques milliers de membres, et mes tests initiaux semblent montrer que cela fonctionne bien. Cependant..

NOTES :

  • Il n’importe que le texte location et about des profils
  • Je n’ai pas vérifié les téléchargements de pièces jointes car nous n’avons jamais autorisé les téléchargements de pièces jointes sur le forum de test que j’utilise pour ces tests. Nous avons été sur l’un des forums que je souhaite importer (c’est beaucoup plus grand, d’où l’utilisation de ce plus petit forum pour les tests), donc je ferai un retour sur la suite.
  • L’importation effectuée sur la machine de développement a maintenant été déplacée avec succès/rétablie dans une installation de production en direct et tout s’est bien passé :+1:
  • (Il faut encore tester cela sur un plus grand forum avec des pièces jointes — je mettrai à jour ce message lorsque ce sera fait)

Je publie cela maintenant car je pense que plusieurs personnes essaient actuellement d’importer leurs forums vB3 vers Discourse.

5 « J'aime »