Grande migrazione forum Drupal, errori e limitazioni dell'importatore

Ciao, questo argomento fornisce un po’ di contesto sulla migrazione che sto lentamente pianificando e testando. Venerdì scorso ho finalmente provato l’importatore Drupal su un VPS di prova, utilizzando una combinazione di questo e questo. L’importatore è ancora in esecuzione mentre scrivo, quindi non sono ancora stato in grado di testare effettivamente la funzionalità del sito di prova, ma sta per finire.

Il problema più grande che sto affrontando è un “valore di chiave duplicato” su 8 nodi apparentemente casuali (l’equivalente degli argomenti in Discourse) su circa 80.000 nodi totali. Questi sono i numeri nid specifici nel caso in cui ci sia un bug matematico strano tipo Y2K:

42081, 53125, 57807, 63932, 66756, 76561, 78250, 82707

Questo stesso errore si verifica sempre sugli stessi nid quando si riesegue l’importatore:

Traceback (most recent call last):
	19: from script/import_scripts/drupal.rb:537:in `<main>'
	18: from /var/www/discourse/script/import_scripts/base.rb:47:in `perform'
	17: from script/import_scripts/drupal.rb:39:in `execute'
	16: from script/import_scripts/drupal.rb:169:in `import_forum_topics'
	15: from /var/www/discourse/script/import_scripts/base.rb:916:in `batches'
	14: from /var/www/discourse/script/import_scripts/base.rb:916:in `loop'
	13: from /var/www/discourse/script/import_scripts/base.rb:917:in `block in batches'
	12: from script/import_scripts/drupal.rb:195:in `block in import_forum_topics'
	11: from /var/www/discourse/script/import_scripts/base.rb:224:in `all_records_exist?'
	10: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-7.0.3.1/lib/active_record/transactions.rb:209:in `transaction'
	 9: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-7.0.3.1/lib/active_record/connection_adapters/abstract/database_statements.rb:316:in `transaction'
	 8: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-7.0.3.1/lib/active_record/connection_adapters/abstract/transaction.rb:317:in `within_new_transaction'
	 7: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activesupport-7.0.3.1/lib/active_support/concurrency/load_interlock_aware_monitor.rb:21:in `synchronize'
	 6: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activesupport-7.0.3.1/lib/active_support/concurrency/load_interlock_aware_monitor.rb:21:in `handle_interrupt'
	 5: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activesupport-7.0.3.1/lib/active_support/concurrency/load_interlock_aware_monitor.rb:25:in `block in synchronize'
	 4: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activesupport-7.0.3.1/lib/active_support/concurrency/load_interlock_aware_monitor.rb:25:in `handle_interrupt'
	 3: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-7.0.3.1/lib/active_record/connection_adapters/abstract/transaction.rb:319:in `block in within_new_transaction'
	 2: from /var/www/discourse/script/import_scripts/base.rb:231:in `block in all_records_exist?'
	 1: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/rack-mini-profiler-3.0.0/lib/patches/db/pg.rb:56:in `exec'
/var/www/discourse/vendor/bundle/ruby/2.7.0/gems/rack-mini-profiler-3.0.0/lib/patches/db/pg.rb:56:in `exec': ERROR:  duplicate key value violates unique constraint "import_ids_pkey" (PG::UniqueViolation)
DETAIL:  Key (val)=(nid:42081) already exists.
	20: from script/import_scripts/drupal.rb:537:in `<main>'
	19: from /var/www/discourse/script/import_scripts/base.rb:47:in `perform'
	18: from script/import_scripts/drupal.rb:39:in `execute'
	17: from script/import_scripts/drupal.rb:169:in `import_forum_topics'
	16: from /var/www/discourse/script/import_scripts/base.rb:916:in `batches'
	15: from /var/www/discourse/script/import_scripts/base.rb:916:in `loop'
	14: from /var/www/discourse/script/import_scripts/base.rb:917:in `block in batches'
	13: from script/import_scripts/drupal.rb:195:in `block in import_forum_topics'
	12: from /var/www/discourse/script/import_scripts/base.rb:224:in `all_records_exist?'
	11: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-7.0.3.1/lib/active_record/transactions.rb:209:in `transaction'
	10: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-7.0.3.1/lib/active_record/connection_adapters/abstract/database_statements.rb:316:in `transaction'
	 9: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-7.0.3.1/lib/active_record/connection_adapters/abstract/transaction.rb:317:in `within_new_transaction'
	 8: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activesupport-7.0.3.1/lib/active_support/concurrency/load_interlock_aware_monitor.rb:21:in `synchronize'
	 7: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activesupport-7.0.3.1/lib/active_support/concurrency/load_interlock_aware_monitor.rb:21:in `handle_interrupt'
	 6: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activesupport-7.0.3.1/lib/active_support/concurrency/load_interlock_aware_monitor.rb:25:in `block in synchronize'
	 5: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activesupport-7.0.3.1/lib/active_support/concurrency/load_interlock_aware_monitor.rb:25:in `handle_interrupt'
	 4: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-7.0.3.1/lib/active_record/connection_adapters/abstract/transaction.rb:319:in `block in within_new_transaction'
	 3: from /var/www/discourse/script/import_scripts/base.rb:243:in `block in all_records_exist?'
	 2: from /var/www/discourse/script/import_scripts/base.rb:243:in `ensure in block in all_records_exist?'
	 1: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/rack-mini-profiler-3.0.0/lib/patches/db/pg.rb:56:in `exec'
/var/www/discourse/vendor/bundle/ruby/2.7.0/gems/rack-mini-profiler-3.0.0/lib/patches/db/pg.rb:56:in `exec': ERROR:  current transaction is aborted, commands ignored until end of transaction block (PG::InFailedSqlTransaction)

L’unico modo in cui sono riuscito a farlo procedere è stato modificando le condizioni SQL:

...
	 LEFT JOIN node_counter nc ON nc.nid = n.nid
         WHERE n.type = 'forum'
           AND n.status = 1
AND n.nid != 42081
AND n.nid != 53125
AND n.nid != 57807
AND n.nid != 63932
AND n.nid != 66756
AND n.nid != 76561
AND n.nid != 78250
AND n.nid != 82707
         LIMIT #{BATCH_SIZE}
        OFFSET #{offset};
...

Ho ispezionato il primo nodo fallito e i nodi nid precedenti e successivi da entrambi i lati nel database Drupal di origine, e non riesco a vedere nulla di sbagliato. Il nid è impostato come chiave primaria e ha AUTO_INCREMENT, e il sito Drupal originale funziona bene, quindi non ci possono essere problemi fondamentali con l’integrità del database di origine.


Oltre al bug di cui sopra, queste sono le limitazioni che sto riscontrando con lo script:

  1. Permalink: Sembra che lo script di importazione creerà permalink per gli URL dei nodi precedenti example.com/node/XXXXXXX. Ma devo anche mantenere i link a commenti specifici all’interno di quei nodi, che hanno il formato: example.com/comment/YYYYYYY#comment-YYYYYYY (YYYYYYY è lo stesso in entrambe le occorrenze). Lo schema URL di Drupal non include l’ID del nodo a cui è associato il commento, mentre Discourse lo fa (example.com/t/topic-keywords/XXXXXXX/YY), quindi sembra una complicazione importante.

  2. Limitazioni sui nomi utente: Drupal consente spazi nei nomi utente. Capisco che Discourse no, almeno non consente ai nuovi utenti di crearli in quel modo. Questo post suggerisce che lo script di importazione “convertirà” automaticamente i nomi utente problematici, ma non vedo alcun codice per questo in /import_scripts/drupal.rb. Aggiornamento: In realtà sembra che Discourse abbia gestito questo automaticamente nel modo corretto.

  3. Utenti bannati: Sembra che lo script importi tutti gli utenti, inclusi gli account bannati. Potrei probabilmente aggiungere una condizione abbastanza facilmente alla selezione SQL WHERE status = 1 per importare solo gli account utente attivi, ma non sono sicuro che ciò causerebbe problemi con la serializzazione dei record. Soprattutto, preferirei mantenere quei nomi di account precedentemente bannati con i loro indirizzi email associati permanentemente bloccati in modo che gli stessi utenti problematici non si registrino di nuovo su Discourse.

  4. Campi del profilo utente: Qualcuno sa se c’è del codice di esempio in uno degli altri importatori per importare campi di informazioni personali dai profili utente? Ho solo un campo del profilo (“Location”) che devo importare.

  5. Avatar (non Gravatar): Sembra piuttosto strano che ci sia codice nell’importatore Drupal per importare Gravatar ma non per le immagini avatar dell’account locale, molto più comunemente utilizzate.

  6. Messaggi privati: Quasi tutti i forum Drupal 7 utilizzeranno probabilmente il modulo di terze parti privatemsg (non esiste una funzionalità ufficiale di PM di Drupal). L’importatore non supporta l’importazione di PM. Nel mio caso, devo importarne circa 1,5 milioni.

Grazie in anticipo per il tuo aiuto e per aver reso disponibile lo script di importazione Drupal.

Questo insieme di problemi è abbastanza normale per una grande importazione. Chiunque fosse stato scritto per non si è preoccupato (forse non abbastanza da notare) dei problemi che descrivi.

Il che suona come un bug in Drupal o nel database stesso (gli ID duplicati non dovrebbero accadere). Probabilmente avrei modificato lo script per testare e/o catturare l’errore in caso di duplicati, ma il tuo modo ha funzionato (a meno che non ce ne siano altri ancora).

Puoi dare un’occhiata ad altri script di importazione che creano permalink di post. L’import_id si trova nel PostCustomField di ogni post.

È in base.rb o nel suggeritore di nomi utente. Funziona per lo più e non c’è molto che tu possa fare per cambiarlo.

Probabilmente non vuoi farlo. Il problema è che i post creati da quegli utenti saranno di proprietà di system. Puoi cercare altri script per esempi su come disattivarli. fluxbb ha uno script suspend_users, che dovrebbe aiutare.

fluxbb (su cui sto lavorando ora) lo fa. Devi solo aggiungere qualcosa di simile allo script di importazione utente:

          location: user['location'],

I Gravatar sono gestiti dal core di Discourse, quindi lo script non fa nulla per importarli; funziona e basta. Puoi usare grep sugli altri script per “avatar” per trovare esempi su come farlo.

Cerca esempi. . . . ipboard ha import_private_messages.

Grazie per la risposta. Non credo che questo sia un problema con il database di Drupal, perché ho ispezionato il database di origine e non riesco a trovare chiavi nid duplicate.

Ahhh, quindi ha quella funzionalità al di fuori di drupal.rb. Ora che sto esaminando il sito di importazione di prova, in realtà sembra che abbia gestito molto bene le conversioni dei nomi utente. Grazie!

Qual sarebbe il modo più semplice per abilitare l’importazione di nomi utente unicode (senza convertirli, cioè mantenere il nome utente Narizón invece di convertirlo in Narizon)?

Ho fatto il mio primo test dell’importatore Drupal su un’istanza senza interfaccia grafica web configurata, quindi non avevo impostato l’opzione Discourse per consentire nomi utente unicode. Se fosse stata impostata, l’importatore l’avrebbe rispettata? Qual è il modo consigliato per abilitare questo per quando eseguirò la mia migrazione di produzione?

E nel frattempo, per la mia attuale istanza di test, esiste un comando rake per applicare il nome completo al nome utente? (Ho già attivato prioritize username in ux ma poiché i miei utenti di test sono abituati a Drupal che supporta solo nomi utente per l’accesso [non indirizzo email], penso che sarebbe meglio mantenere i loro nomi utente di produzione, che almeno sono stati mantenuti nel campo nome completo.)

Probabilmente?

Puoi impostare l’impostazione del sito all’inizio dello script.

Penso che cambiare i nomi utente sia una cattiva idea, ma se non ti piacciono potresti cambiare ciò che viene passato al generatore di nomi utente.

Grazie, intendi cambiarli dopo che l’importazione è stata completata?

Penso che intenda cambiarli affatto, a meno che nel vecchio sistema i nomi utente non fossero invisibili e si vedessero solo i nomi reali.

Se quest’ultimo è il caso, allora cambierei lo script per rendere il nome utente il loro nome reale. Il problema è che se non conoscono il loro indirizzo email non saranno in grado di trovare il loro account.

Capito. Sul forum Drupal ci sono solo nomi utente di sistema e nessun nome reale separato. E inoltre Drupal non consente di accedere con l’indirizzo email, ma solo con il nome utente. Ecco perché nel mio caso è molto importante mantenere il più possibile i nomi utente. (Ci saranno comunque alcuni nomi utente convertiti, come quelli con spazi.) Quindi devo verificare come impostare le impostazioni di Discourse all’inizio dello script di importazione.

Ma Discourse lo fa, quindi se conoscono il loro indirizzo email, possono usarlo per reimpostare la password, che è probabilmente quello che dovresti dire a tutti di fare, dato che non puoi indovinare chi non riesce a indovinare il proprio nome utente, immagino.

Penso che quello che farei sarebbe impostare SiteSetting.unicode_username=true nello script di importazione ed eseguirlo di nuovo per vedere se funziona. Potresti provare a testarlo nella console rails per vedere. Questo potrebbe dirti:

  User.create(username: 'Narizón', email: 'user@x.com',password: 'xxx', active: true)

Bene, penso che questo non richiami la funzione di creazione del nome utente, quindi dovrai chiamarla
UserNameSuggester.suggest(“Narizón”)

No. Questo ancora non ti dà un nome utente unicode. Dovrai trovare UsernameSuggester e modificarlo, immagino.

Ma se vuoi davvero cambiare i nomi utente, cambiarli ora invece di correggere lo script potrebbe essere quello che vuoi fare. Devi assicurarti che il modo in cui lo fai aggiorni il nome utente in tutti i post. Se stai usando un rake task, lo farà sicuramente.

Eccellente, grazie mille Jay! Ci proverò la prossima volta che eseguirò l’importatore.

Non penso che dovresti preoccuparti:

Quello si trova in lib/user_name_suggester.rb, ma forse vuoi User.normalize_username

Sicuro, avevi ragione. Non è nemmeno un bug in sé, si è rivelato essere un modo strano in cui Drupal gestisce gli argomenti spostati lasciando una traccia nella precedente categoria dell’argomento. Crea semplicemente una riga duplicata in una delle tante tabelle che vengono estratte in ciò che alla fine diventa un argomento Drupal completo. Quindi sembra che dovrò capire come applicare DISTINCT a solo una delle tabelle che vengono selezionate…

Sì. È incredibile come ogni importazione sia un fiocco di neve, e in qualche modo il tuo sia il primo forum ad aver avuto quel problema (naturalmente, molte persone potrebbero aver risolto il problema e non essere riuscite a inviare una PR con l’aggiornamento). O forse hanno ignorato gli errori?

Aha. Sospetto che non sia una funzione molto comunemente usata, quando un thread viene spostato in una nuova categoria c’è una casella di controllo opzionale per lasciare un link “Spostato in…” nella vecchia categoria.

Il duplicato incriminato si trova nella colonna nid di forum_index. Quindi sembra che possa risolverlo con un GROUP BY nid, giusto?

        SELECT fi.nid nid,
               fi.title title,
               fi.tid tid,
               n.uid uid,
               fi.created created,
               fi.sticky sticky,
               f.body_value body,
	       nc.totalcount views
          FROM forum_index fi
	 LEFT JOIN node n ON fi.nid = n.nid
	 LEFT JOIN field_data_body f ON f.entity_id = n.nid
	 LEFT JOIN node_counter nc ON nc.nid = n.nid
         WHERE n.type = 'forum'
           AND n.status = 1
         GROUP BY nid

Sembra promettente, perché quando eseguo la query con GROUP BY nid ci sono 8 righe in meno.

Potrebbe funzionare. Penserei che ci sarebbe un certo valore in quella tabella che dice che è stato spostato e potresti selezionare solo quelli senza quel valore.

Quello sarebbe decisamente il modo più logico per progettarlo. Immagino che sia una cosa di Drupal…

L’unica cosa che fa è cambiare il tid (ID della categoria). Segue lo stile che ho imparato durante questo calvario con il database Drupal. Non so nulla di progettazione di database, ma ho l’impressione che si possa memorizzare esplicitamente i dati, oppure lasciare alcune cose implicite e poi scoprirle tramite logica programmatica; Drupal sembra rientrare pienamente in quest’ultima categoria.

Beh, sembra che ci siamo quasi. Grazie mille a Jay per la guida.

Grazie, questo è stato fondamentale: in realtà è stato semplice come copiare la parte relativa al permalink dallo script di importazione di Drupal stesso e modificarlo per funzionare sui post invece che sui topic:

    ## Ho aggiunto i permalink per ogni link di commento (risposta) di Drupal: /comment/DIGIT#comment-DIGIT
    Post.find_each do |post|
      begin
        pcf = post.custom_fields
        if pcf && pcf['import_id']
          cid = pcf['import_id'][/cid:(\d+)/, 1]
          slug = "/comment/#{cid}" # La parte #comment-DIGIT rompe il permalink e non è necessaria
          Permalink.create(url: slug, post_id: post.id)
        end
      rescue => e
        puts e.message
        puts "Creazione del permalink fallita per cid #{post.id}"
      end
    end

Ero bloccato da un po’ con il mio tentativo iniziale, che includeva la parte relativa alla pagina #comment-DIGIT del link originale di Drupal, il quale rompeva completamente il permalink in Discourse. Poi ho realizzato che, ovviamente, la parte # di un link non viene effettivamente inviata al server web ed era necessaria solo a Drupal per far scorrere la pagina fino alla sezione dove si trovava il commento specifico. Quindi funziona perfettamente senza quella parte in Discourse, anche se si proviene da una pagina web esterna con un vecchio link /comment/AAAAAA#comment-AAAAA: in Discourse appare semplicemente come /comment/AAAAAA/t/titolo-argomento-parole/123456/X, e nella barra degli URL viene mostrato come /t/titolo-argomento-parole/123456/X#comment-AAAAAA; sembra non curarsi della parte fittizia #comment-AAAAA.

Per alcuni forum sospetto che la funzione postprocess_posts dello standard importatore di Drupal possa essere sufficiente. Va notato che deve essere adattata per ogni forum: c’è un’espressione regolare sostitutiva piuttosto disordinata e hard-coded per site.comcommunity.site.com. Ma dopo averla adattata, fa un buon lavoro nel riscrivere i link interni del forum da nodi → topic, nonché da commenti → risposte. Tuttavia, ho un numero considerevole di siti web esterni che collegano a singoli commenti (risposte) sul mio forum, e vale la pena conservarli. Inoltre, Google indicizza la maggior parte dei 1,7 milioni di URL /comment-AAAAA, e sarebbe probabile che il mio ranking ne risentisse se tutti questi scomparissero. Spero che avere circa 2 milioni di permalink non causi problemi a Discourse?


Grazie mille, ho ripreso quella funzione quasi senza modifiche, ho dovuto solo aggiustare alcuni nomi di colonne. Funziona benissimo.

  def suspend_users
    puts '', "aggiornamento utenti bannati"

    banned = 0
    failed = 0
    total = mysql_query("SELECT COUNT(*) AS count FROM users WHERE status = 0").first['count']

    system_user = Discourse.system_user

    mysql_query("SELECT name username, mail email FROM users WHERE status = 0").each do |b|
      user = User.find_by_email(b['email'])
      if user
        user.suspended_at = Time.now
        user.suspended_till = 200.years.from_now

        if user.save
          StaffActionLogger.new(system_user).log_user_suspend(user, "bannato durante l'importazione iniziale")
          banned += 1
        else
          puts "Impossibile sospendere l'utente #{user.username}. #{user.errors.try(:full_messages).try(:inspect)}"
          failed += 1
        end
      else
        puts "Non trovato: #{b['email']}"
        failed += 1
      end

      print_status banned + failed, total
    end
  end

Ha funzionato anche questo! Ho dovuto gestire lo schema del database diffuso di Drupal e LEFT JOIN profile_value location ON users.uid = location.uid per correlare un’altra tabella contenente i dati del profilo, ma è molto figo quanto sia facile aggiungerlo sul lato Discourse. Va notato che questo processo è circa il 50% più lento rispetto allo standard; sospetto sia dovuto al LEFT JOIN. Ma posso conviverci, dato che ho solo circa 80.000 utenti.


Questa è stata piuttosto difficile, ancora una volta a causa dello schema di database disgiunto di Drupal. Ho finito per usare jforum.rb come base, con un piccolo aiuto anche dall’importatore di Vanilla. Lo script originale era piuttosto paranoico nel controllare ogni singola variabile per assicurarsi che il nome del file dell’avatar non fosse nullo, quindi ho rimosso la maggior parte di quei controlli per rendere il codice meno disordinato. Il peggio che può succedere è che lo script si blocchi, ma con la query SQL che ho usato non penso che nemmeno questo possa andare storto.

  def import_users
    puts "", "importazione utenti"

    user_count = mysql_query("SELECT count(uid) count FROM users").first["count"]

    last_user_id = -1
    
    batches(BATCH_SIZE) do |offset|
      users = mysql_query(<<-SQL
          SELECT users.uid,
                 name username,
                 mail email,
                 created,
                 picture,
                 location.value location
            FROM users
             LEFT JOIN profile_value location ON users.uid = location.uid
           WHERE users.uid > #{last_user_id}
        ORDER BY uid
           LIMIT #{BATCH_SIZE}
      SQL
      ).to_a

      break if users.empty?

      last_user_id = users[-1]["uid"]

      users.reject! { |u| @lookup.user_already_imported?(u["uid"]) }

      create_users(users, total: user_count, offset: offset) do |row|
        if row['picture'] > 0
        	q = mysql_query("SELECT filename FROM file_managed WHERE fid = #{row['picture']};").first
        	avatar = q["filename"]
        end
        email = row["email"].presence || fake_email
        email = fake_email if !EmailAddressValidator.valid_value?(email)

        username = @htmlentities.decode(row["username"]).strip

        {
          id: row["uid"],
          name: username,
          email: email,
          location: row["location"],
          created_at: Time.zone.at(row["created"]),
	  	post_create_action: proc do |user|
		    import_avatar(user, avatar)
		end
        }
      end 
    end
  end
  def import_avatar(user, avatar_source)
    return if avatar_source.blank?

    path = File.join(ATTACHMENT_DIR, avatar_source)

      @uploader.create_avatar(user, path)
  end

Dopo il tuo aiuto a pagamento con la query SQL, ho finito per provare a integrarlo negli script per Discuz, IPboard e Xenforo. Ho continuato a imbattermi in vicoli ciechi con ognuno di essi; mi sono avvicinato di più al modello Discuz, che sembra avere uno schema di database molto simile, ma non sono riuscito a superare un bug con la variabile di istanza @first_post_id_by_topic_id. Dopo un sacco di tentativi ed errori, ho finalmente capito che era stata inizializzata in modo errato all’inizio dello script Discuz (ho provato a metterla nella stessa posizione nello script Drupal) e questo alla fine ha risolto il problema:

  def initialize
    super
    
    @first_post_id_by_topic_id = {}

    @htmlentities = HTMLEntities.new

    @client = Mysql2::Client.new(
      host: "172.17.0.3",
      username: "user",
      password: "pass",
      database: DRUPAL_DB
    )
  end

def import_private_messages
	puts '', 'creazione messaggi privati'

	pm_indexes = 'pm_index'
	pm_messages = 'pm_message'
	total_count = mysql_query("SELECT count(*) count FROM #{pm_indexes}").first['count']

	batches(BATCH_SIZE) do |offset|
		results = mysql_query("
SELECT pi.mid id, thread_id, pi.recipient to_user_id, pi.deleted deleted, pm.author user_id, pm.subject subject, pm.body message, pm.format format, pm.timestamp created_at FROM pm_index pi LEFT JOIN pm_message pm ON pi.mid=pm.mid WHERE deleted = 0
             LIMIT #{BATCH_SIZE}
            OFFSET #{offset};")

		break if results.size < 1

		# next if all_records_exist? :posts, results.map {|m| "pm:#{m['id']}"}

		create_posts(results, total: total_count, offset: offset) do |m|
			skip = false
			mapped = {}
			mapped[:id] = "pm:#{m['id']}"
			mapped[:user_id] = user_id_from_imported_user_id(m['user_id']) || -1
			mapped[:raw] = preprocess_raw(m['message'],m['format'])
			mapped[:created_at] = Time.zone.at(m['created_at'])
			thread_id = "pm_#{m['thread_id']}"
			if is_first_pm(m['id'], m['thread_id'])
				# trova il titolo dalla tabella delle liste
				#          pm_thread = mysql_query("
				#                SELECT thread_id, subject
				#                  FROM #{table_name 'ucenter_pm_lists'}
				#                 WHERE plid = #{m['thread_id']};").first
				mapped[:title] = m['subject']
				mapped[:archetype] = Archetype.private_message

          # Trova gli utenti che fanno parte di questo messaggio privato.
          import_user_ids = mysql_query("
                SELECT thread_id plid, recipient user_id
                  FROM pm_index
                 WHERE thread_id = #{m['thread_id']};
              ").map { |r| r['user_id'] }.uniq
          mapped[:target_usernames] = import_user_ids.map! do |import_user_id|
            import_user_id.to_s == m['user_id'].to_s ? nil : User.find_by(id: user_id_from_imported_user_id(import_user_id)).try(:username)
          end.compact
          if mapped[:target_usernames].empty? # pm con te stesso?
            skip = true
            puts "Salto pm:#{m['id']} per mancanza di destinatario"
          else
            @first_post_id_by_topic_id[thread_id] = mapped[:id]
          end
        else
          parent = topic_lookup_from_imported_post_id(@first_post_id_by_topic_id[thread_id])
          if parent
            mapped[:topic_id] = parent[:topic_id]
          else
            puts "Post genitore thread pm:#{thread_id} non esiste. Salto #{m["id"]}: #{m["message"][0..40]}"
            skip = true
          end
        end
        skip ? nil : mapped
      end

    end
end
# cerca il primo ID pm per la serie di pm
def is_first_pm(pm_id, thread_id)
	result = mysql_query("
          SELECT mid id
            FROM pm_index
           WHERE thread_id = #{thread_id}
        ORDER BY id")
	result.first['id'].to_s == pm_id.to_s
end

Oh, e per la maggior parte di queste query è anche necessario eseguire questo comando nel container MySQL per disabilitare un controllo di sicurezza SQL in modalità stretta:
mysql -u root -ppass -e "SET GLOBAL sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''));"


Un’altra cosa che ho notato mancava erano alcune migliaia di nodi Drupal di tipo poll. Ho provato inizialmente a includere WHERE type = 'forum' OR type = 'poll' nella funzione import_topics, ma c’è qualcosa di davvero strambo nel database originale di Drupal che fa sì che molti di essi vengano persi. Quindi ho finito per copiare import_topics in una nuova funzione import_polls:

    def import_poll_topics
    puts '', "importazione topic sondaggio"

    polls = mysql_query(<<-SQL
      SELECT n.nid nid, n.title title, n.uid uid, n.created created, n.sticky sticky, taxonomy_index.tid tid, node_counter.totalcount views
        FROM node n
        LEFT JOIN taxonomy_index ON n.nid = taxonomy_index.nid
        LEFT JOIN node_counter ON n.nid = node_counter.nid
       WHERE n.type = 'poll'
         AND n.status = 1
    SQL
    ).to_a

    create_posts(polls) do |topic|
      {
        id: "nid:#{topic['nid']}",
        user_id: user_id_from_imported_user_id(topic['uid']) || -1,
        category: category_id_from_imported_category_id(topic['tid']),
        raw: "### Puoi vedere i risultati archiviati del sondaggio su Wayback Machine:\n**https://web.archive.org/web/1234567890/http://myforum.com/node/#{topic['nid']}**",
        created_at: Time.zone.at(topic['created']),
        pinned_at: topic['sticky'].to_i == 1 ? Time.zone.at(topic['created']) : nil,
        title: topic['title'].try(:strip),
        views: topic['views'],
        custom_fields: { import_id: "nid:#{topic['nid']}" }
      }
    end
  end

Non mi importa molto di importare i risultati effettivi del sondaggio, e richiederebbe di riscrivere l’intero algoritmo che Drupal usa per sommare tutti i voti ed eliminare i duplicati. Voglio principalmente importare i commenti di follow-up nel thread del sondaggio. Ma nel caso qualcuno voglia vedere i risultati originali del sondaggio, ho fatto in modo che scrivesse un link diretto al nodo originale del forum su Wayback Machine.


Quindi il codice non è per nulla elegante e probabilmente non è molto efficiente, ma per un’operazione una tantum dovrebbe fare il suo lavoro.

Scusa per i muri di codice, fammi sapere se infastidisce qualcuno e posso spostarli su un URL di pastebin.

Ed è così che vanno la maggior parte di essi. Questo argomento è un ottimo esempio da seguire per qualcun altro (dato che inizia con un set di competenze simile al tuo).

Hai una stima di quanto tempo hai dedicato alle tue personalizzazioni?

Congratulazioni!

Grazie Jay! Apprezzo l’incoraggiamento.

Ugh, preferirei non pensarci. :stuck_out_tongue_winking_eye: Probabilmente sono state più di 15 o 20 ore dopo che mi hai messo sulla giusta strada con la query SQL.

Mi piacerebbe approfondire questo argomento se hai qualche idea:

Ci sono volute circa 70 ore per fare una prova completa con dati di produzione su un VPS molto potente. Vorrei far interagire di nuovo i miei utenti il prima possibile, anche se l’importazione di post e messaggi privati è ancora incompleta. Oppure un’altra idea alternativa a cui ho pensato sarebbe disabilitare la funzione preprocess_posts, che ho anche modificato pesantemente con ulteriori sostituzioni regexp gsub e anche per elaborare tutti i post e i messaggi privati tramite Pandoc con uno o due comandi diversi a seconda che il post originale fosse markup Textile o puro HTML. Se disabilitassi l’intera routine preprocess_posts si ridurrebbe probabilmente quasi della metà il tempo di importazione, e poi potrei aggiungere tutta quella roba di formattazione nella sezione postprocess_posts una volta importati tutti i dati grezzi. Ma lo svantaggio è che dopo non sarei più in grado di accedere facilmente alla colonna del database originale che mostra il formato sorgente (Textile o HTML) per ogni post, il che è una condizione per la mia manipolazione di Pandoc. O potrei aggiungere un campo personalizzato a ogni post etichettandolo come textile o html e recuperarlo in seguito durante l’elaborazione post-importazione? Non lo so, sto solo pensando ad alta voce.

Quando si riesegue lo script di importazione solo con i nuovi dati, l’operazione sarà molto più veloce poiché non importerà nuovamente i dati. Pertanto, richiederà solo poche ore. Ogni esecuzione successiva sarà più rapida poiché ci saranno meno dati da importare.

È quindi possibile velocizzare l’operazione modificando le query in modo che restituiscano solo i dati più recenti di un certo periodo. La maggior parte degli script che ho gestito dispone di un’impostazione import_after proprio a questo scopo (ma anche per consentire uno sviluppo più rapido importando un piccolo sottoinsieme dei dati).