Sto scrivendo questo mentre è fresco nella mia mente - è attualmente in fase di sviluppo, quindi assicurati di testarlo e verificare che soddisfi le tue esigenze.
Per quanto ne sappia, non esiste un importatore da vB3 a Discourse, e non ho licenze vB4/5 per cui credo siano destinati gli importatori di Discourse. Tuttavia, ho una licenza XenForo 1.4 e esiste un importatore per quello! Per chi non ha una licenza XF, spesso è possibile acquistarle sul mercato dell’usato, oppure puoi chiedere a qualcuno di eseguire l’importazione per te e fornirti il database XF.
Ho già eseguito un’importazione da vB3.6 a XF, quindi so che funziona bene (l’unica cosa che non viene importata sono le foto del profilo, poiché XF ha solo avatar, ma ho una soluzione per questo).
Ok…
Importa prima il tuo forum vB in XF come faresti normalmente.
Consiglio di mantenere il tuo forum vB attivo e accessibile su Internet (quindi esegui l’importazione in XF in una sottodirectory). Questo è solo un sistema di sicurezza nel caso in cui in seguito decidessi di voler mantenere il forum vB, e anche perché copieremo le foto del profilo dal sito attivo (anche se puoi utilizzare lo script di copia del profilo sottostante in precedenza se ne hai davvero bisogno).
Una volta verificato che il forum sia stato convertito con successo in XF, crea un backup di questo nuovo database e copialo sulla tua macchina di sviluppo.
La mia macchina di sviluppo è un Mac, quindi queste istruzioni sono per macOS.
brew install mysql
// Assicurati anche che sia avviato
mysql -u root
create database xenforo_db;
exit;
mysql -u root -p xenforo_db < /percorso/del/tuo/backup/e/downloadato/xenforo_db.sql
Configura il tuo ambiente di sviluppo di Discourse come al solito (vedi questo per macOS) poi:
Apri database.yml e cambia il nome del database in qualcosa come discourse_development_nomesito_01 - usa numeri in modo da poter ripetere l’importazione più volte semplicemente cambiando il numero.
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
Per il primo account amministratore, cerca di utilizzare lo stesso indirizzo email del tuo account amministratore esistente sulla tua installazione vB/XF. Seleziona ‘S’ quando ti chiede se vuoi assegnargli i permessi di amministratore.
Per la seconda creazione di un account, usa un’email tipo ospite@qualcosa.com e seleziona ‘n’ quando ti chiede di crearlo come account amministratore. Abbiamo bisogno di questo account per i post associati agli ospiti/utenti eliminati. Puoi entrare in rails c e poi User.last per controllare il suo ID, ma sarà probabilmente 2. Lo aggiungeremo allo script di importazione.
Ho apportato alcune modifiche allo script di importazione, ecco la mia versione dello script (sostituisci il contenuto di script/import_scripts/xenforo.rb con questo):
# frozen_string_literal: true
require "mysql2"
require_relative "base"
require "set" # potrebbe non essere necessario - non ricordo perché l'ho aggiunto ora
require "htmlentities" # potrebbe non essere necessario - non ricordo perché l'ho aggiunto ora
require File.expand_path(File.dirname(__FILE__) + "/base.rb")
# Chiamalo così:
# 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 = '/percorso/completo/allegati/es/nome/progetti/discourse/nomesito/discourse/tmp/allegati'
AVATAR_DIR = '/percorso/completo/avatar/es/nome/progetti/discourse/nomesito/discourse/tmp/avatar'
PROFILE_PIC_DIR = '/percorso/completo/fotoprofilo/es/nome/progetti/discourse/nomesito/discourse/tmp/fotoprofilo'
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 '', "creazione utenti"
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 "Importazione profili utente..."
user_profiles = mysql_query("
SELECT user_id, location, about
FROM #{TABLE_PREFIX}user_profile
ORDER BY user_id;
")
puts "Importazione profili: recupero informazioni"
user_profiles.each do |row|
usf = UserCustomField.find_by(name: "import_id", value: row["user_id"])
if user = User.find(usf.user_id)
puts "Aggiornamento profilo per #{user.username}"
profile = user.user_profile
profile.location = row["location"]
profile.bio_raw = row["about"]
profile.save
end
end
end
def import_categories
puts "", "importazione categorie..."
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'] })
# le categorie più profonde devono essere tag
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'])
# Trova una sottocategoria per gli argomenti in questa categoria
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 '', "Impossibile trovare una categoria per #{c['id']} '#{c['title']}'!"
end
end
end
# Questo metodo è un'alternativa a import_categories.
# Utilizza i prefissi invece dei nodi.
def import_categories_from_thread_prefixes
puts "", "importazione categorie..."
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: "Categoria-#{category["id"]}"
}
end
@prefix_as_category = true
end
def import_posts
puts "", "creazione argomenti e post"
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}" # richiede 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 "Post genitore #{m['first_post_id']} non esiste. Salto #{m["id"]}: #{m["title"][0..40]}"
skip = true
end
end
skip ? nil : mapped
end
end
# Applica tag
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
# :) è codificato come <!-- s:) --><img src="{SMILIES_PATH}/icon_e_smile.gif" alt=":)" title="Smile" /><!-- s:) -->
s.gsub!(/<!-- s(\S+) --><img (?:[^>]+) \/><!-- s(?:\S+) -->/, '\1')
# Alcuni link sembrano così: <!-- 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)')
# Molti tag bbcode phpbb hanno un hash attaccato. Esempi:
# [url=https://google.com:1qh1i7ky]clicca qui[/url:1qh1i7ky]
# [quote="cybereality":b0wtlzex]Alcun testo.[/quote:b0wtlzex]
s.gsub!(/:(?:\w{8})\]/, ']')
# Rimuovi i tag video mybb.
s.gsub!(/(^\[video=.*?\])|(\[\/video\]$)/, '')
s = CGI.unescapeHTML(s)
# phpBB accorcia il testo dei link in questo modo, che interrompe l'elaborazione markdown:
# [http://answers.yahoo.com/question/index ... 223AAkkPli](http://answers.yahoo.com/question/index?qid=20070920134223AAkkPli)
#
# Soluzione per l'errore: xenforo.rb: 160: in `gsub!': sequenza di byte non valida in UTF-8 (ArgumentError)
if ! s.valid_encoding?
s = s.encode("UTF-16be", invalid: :replace, replace: "?").encode('UTF-8')
end
# Soluzione temporanea:
s.gsub!(/\[http(s)?:\/\/(www\.)?/, '[')
# [QUOTE]...[/QUOTE]
s.gsub!(/\[quote\](.+?)\[\/quote\]/im) { "\n> #{$1}\n" }
# Citazioni nidificate
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, "")
# converti i tag di lista in ul e i tag list=1 in ol
# (in pratica, ci manca solo list=a qui...)
s.gsub!(/\[list\](.*?)\[\/list:u\]/m, '[ul]\1[/ul]')
s.gsub!(/\[list=1\](.*?)\[\/list:o\]/m, '[ol]\1[/ol]')
# converti i tag *- in tag li in modo che bbcode-to-md possa fare la sua magia sulle liste di 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"]titolo[/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 }
# Rimuovi il tag colore
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
# Rimuovi allegato
s.gsub!(get_xf_regexp(xf_type, id), '')
STDERR.puts "#{xf_type.capitalize} id #{id} non trovato nel database sorgente. Rimozione."
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 "Impossibile trovare upload: #{upload.id}. Salto allegato 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 "Impossibile trovare file #{path}. Salto allegato 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 "upload salvato"
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 "Errore: Upload non salvato per #{u.username} #{filename}!"
end
end
end
ImportScripts::XenForo.new.perform
Note:
- Aggiunge un passaggio/metodo import_avatars (devono essere jpg)
- Aggiungiamo i percorsi per avatar e foto profilo
- Aggiungiamo l’ID del nuovo utente ospite creato come fallback quando un utente non esiste ma un post esiste (utenti ospiti)
Ora copiamo le foto profilo da usare come avatar se esistono - se non esistono verrà usato l’avatar dell’utente se ne ha caricato uno. Puoi saltare questo passaggio se vuoi solo un’importazione diretta da avatar a avatar.
Copiatore foto profilo:
Per prima cosa, installa Down con gem install down
Poi crea un nuovo file con:
require 'down'
(1..NUMERO_UTENTI).each do |u|
puts "Recupero utente #{u}"
puts ""
profile_pic_url = "https://www.nome-forum.com/image.php?u=#{u}&type=profile"
destination = "/percorso/completo/dove/vuoi/salvare/foto/profilo/#{u}.jpg"
begin
Down.download(profile_pic_url, destination: destination)
puts "Completato #{u}"
rescue
puts "Fallito #{u}"
end
puts ""
end
Note:
- Presuppone che tutte le foto profilo (e gli avatar) siano jpg. Fortunatamente abbiamo permesso solo jpg come avatar e foto profilo, quindi funziona per noi.
- Assicurati che i percorsi e l’URL siano corretti e che i profili e le foto profilo siano visibili agli ospiti.
- Sostituisci NUMERO_UTENTI con il numero di utenti che hai (es. 3872).
Poi esegui lo script nel terminale con ruby /percorso/nome-script.rb nel terminale. Questo copierà tutte le tue foto profilo in quella directory, poi vai semplicemente lì, ordina per dimensione file ed elimina tutti i file vuoti (ce ne saranno molti, poiché non tutti caricano una foto profilo).
Esecuzione dell’importazione:
Una volta fatto tutto quanto sopra sei pronto per iniziare ![]()
RAILS_ENV=development bundle exec ruby script/import_scripts/xenforo.rb
Ci vogliono circa 90 minuti per importare un forum con 100K post e qualche migliaio di membri e i miei test iniziali sembrano mostrare che funziona bene. Tuttavia..
NOTE:
- Importerà solo il testo
locationeaboutdai profili - Non ho controllato i caricamenti di allegati poiché non abbiamo mai permesso caricamenti di allegati sul forum di test che sto usando per questi test. Abbiamo avuto un forum che voglio importare (è molto più grande, ecco perché uso questo forum più piccolo per i test), quindi riferirò su come andrà.
- L’importazione eseguita sulla macchina di sviluppo è stata ora spostata/“ripristinata” con successo in un’installazione di produzione live ed è andata tutto bene

- (Devo ancora testarlo su un forum più grande con allegati - aggiornerò questo post quando sarà fatto)
Pubblico questo ora perché penso che alcune persone stiano cercando di importare i loro forum vB3 su Discourse al momento.