Grande migration de forum Drupal, erreurs et limitations de l'importateur

Salut, ce sujet donne un peu de contexte sur la migration que je planifie et teste lentement. J’ai finalement essayé l’importateur Drupal vendredi dernier sur un VPS de test en utilisant une combinaison de ceci et ceci. L’importateur est toujours en cours au moment où j’écris ceci, donc je n’ai pas encore pu tester la fonctionnalité du site de test, mais il est sur le point de se terminer bientôt.

Le plus gros problème que je rencontre est une « valeur de clé dupliquée » sur 8 nœuds apparemment aléatoires (l’équivalent des sujets dans Discourse) sur environ 80 000 nœuds au total. Ce sont les numéros nid spécifiques au cas où il y aurait un bug mathématique étrange de type Y2K :

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

La même erreur se produit toujours sur les mêmes nid lors de la réexécution de l’importateur :

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)

La seule façon que j’ai trouvée pour qu’il continue a été de modifier les conditions 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};
...

J’ai inspecté le premier nœud échoué ainsi que les nid précédents et suivants de chaque côté dans la base de données Drupal source, et je ne vois rien de mal. Le nid est défini comme clé primaire et il a AUTO_INCREMENT, et le site Drupal d’origine fonctionne bien, donc il ne peut y avoir aucun problème fondamental avec l’intégrité de la base de données source.


Outre le bug ci-dessus, voici les limitations que je rencontre avec le script :

  1. Permaliens : Il semble que le script d’importation créera des permaliens pour les anciennes URL de nœuds example.com/node/XXXXXXX. Mais je dois également maintenir des liens vers des commentaires spécifiques dans ces nœuds, qui ont le format : example.com/comment/YYYYYYY#comment-YYYYYYY (YYYYYYY est le même dans les deux occurrences). Le schéma d’URL de Drupal n’inclut pas l’ID du nœud auquel le commentaire est associé, alors que Discourse le fait (example.com/t/topic-keywords/XXXXXXX/YY), ce qui semble être une complication majeure.

  2. Limitations des noms d’utilisateur : Drupal autorise les espaces dans les noms d’utilisateur. Je comprends que Discourse ne le fait pas, du moins il ne permet pas aux nouveaux utilisateurs d’en créer de cette façon. Ce post suggère que l’importateur « convertira » automatiquement les noms d’utilisateur problématiques, mais je ne vois aucun code pour cela dans /import_scripts/drupal.rb. Mise à jour : En fait, il semble que Discourse ait géré cela automatiquement de manière correcte.

  3. Utilisateurs bannis : Il semble que le script importe tous les utilisateurs, y compris les comptes bannis. Je pourrais probablement ajouter une condition assez facilement à la sélection SQL WHERE status = 1 pour n’importer que les comptes d’utilisateurs actifs, mais je ne suis pas sûr que cela causerait des problèmes avec la sérialisation des enregistrements. Par-dessus tout, je préférerais garder ces noms de comptes précédemment bannis avec leurs adresses e-mail associées bloqués en permanence afin que les mêmes utilisateurs problématiques ne s’inscrivent pas à nouveau sur Discourse.

  4. Champs de profil utilisateur : Quelqu’un sait-il s’il existe un exemple de code dans l’un des autres importateurs pour importer des champs d’informations personnelles à partir des profils de comptes utilisateurs ? J’ai un seul champ de profil (« Lieu ») que je dois importer.

  5. Avatars (pas Gravatars) : Il semble assez étrange qu’il y ait du code dans l’importateur Drupal pour importer les Gravatars mais pas pour les images d’avatar de compte local, beaucoup plus couramment utilisées.

  6. Messages privés : Presque tous les forums Drupal 7 utiliseront probablement le module tiers privatemsg (il n’y a pas de fonctionnalité officielle de PM Drupal). L’importateur ne prend pas en charge l’importation des PM. Dans mon cas, je dois en importer environ 1,5 million.

Merci d’avance pour votre aide et pour avoir rendu le script d’importation Drupal disponible.

Cet ensemble de problèmes est tout à fait normal pour une importation importante. Celui pour lequel cela a été écrit ne se souciait pas (peut-être pas assez pour le remarquer) des problèmes que vous décrivez.

Ce qui ressemble à un bug dans Drupal ou dans la base de données elle-même (les identifiants dupliqués ne devraient pas se produire). J’aurais probablement modifié le script pour tester et/ou intercepter l’erreur en cas de doublons, mais votre méthode a fonctionné (sauf s’il y en a d’autres).

Vous pouvez consulter d’autres scripts d’importation qui créent des permaliens de publication. L’import_id se trouve dans le PostCustomField de chaque publication.

Il se trouve soit dans base.rb, soit dans le suggéreur de nom d’utilisateur. Cela fonctionne globalement et il n’y a pas grand-chose que vous puissiez faire pour le changer.

Vous ne voudriez probablement pas faire cela. Le problème est que les publications créées par ces utilisateurs seront la propriété de system. Vous pouvez consulter d’autres scripts pour des exemples sur la façon de les désactiver. fluxbb a un script suspend_users, qui devrait aider.

fluxbb (sur lequel je travaille actuellement) fait cela. Il suffit d’ajouter quelque chose comme ceci au script d’importation d’utilisateur :

          location: user['location'],

Les Gravatars sont gérés par le cœur de discourse, donc le script ne fait rien pour les importer ; cela fonctionne simplement. Vous pouvez rechercher “avatar” dans les autres scripts pour trouver des exemples sur la façon de faire cela.

Recherchez des exemples. . . . ipboard a import_private_messages.

1 « J'aime »

Merci pour votre réponse. Je ne pense pas qu’il s’agisse d’un problème avec la base de données Drupal, car j’ai inspecté la base de données source et je ne trouve aucune clé nid en double.

Ahhh, donc il a cette fonctionnalité en dehors de drupal.rb. Maintenant que je fouille dans le site d’importation de test, il semble en fait qu’il ait très bien géré les conversions de noms d’utilisateur. Merci !

1 « J'aime »

Quelle serait la manière la plus simple d’activer l’importation des noms d’utilisateur unicode (sans les convertir, c’est-à-dire conserver le nom d’utilisateur Narizón au lieu de le convertir en Narizon) ?

J’ai effectué mon premier test de l’importateur Drupal sur une instance sans interface graphique web configurée, donc je n’avais pas défini l’option Discourse pour autoriser les noms d’utilisateur unicode. Si cela avait été défini, l’importateur l’aurait-il respecté ? Quelle est la méthode recommandée pour activer cela pour ma migration de production ?

Et en attendant, pour mon instance de test actuelle, existe-t-il une commande rake pour appliquer le nom complet au nom d’utilisateur ? (J’ai déjà activé prioritize username in ux mais comme mes utilisateurs de test sont habitués à Drupal qui ne prend en charge que les noms d’utilisateur pour la connexion [pas l’adresse e-mail], je pense qu’il serait préférable de conserver leurs noms d’utilisateur de production, qui ont au moins été conservés dans le champ nom complet.)

Probablement ?

Vous pouvez définir le paramètre du site au début du script.

Je pense que changer les noms d’utilisateur est une mauvaise idée, mais si vous ne les aimez pas, vous pourriez changer ce qui est passé au générateur de noms d’utilisateur.

Merci, vous voulez dire les changer après la fin de l’importation ?

Je pense que je veux dire les changer du tout, sauf si sur l’ancien système les noms d’utilisateur étaient invisibles et qu’ils ne voyaient que les vrais noms.

Si c’est le cas, alors je changerais le script pour que le nom d’utilisateur soit leur vrai nom. Le problème avec cela est que s’ils ne connaissent pas leur adresse e-mail, ils ne pourront pas trouver leur compte.

Bien reçu. Sur le forum Drupal, il n’y a que des noms d’utilisateur système et pas de vrais noms distincts. De plus, Drupal ne permet pas de se connecter avec l’adresse e-mail, mais uniquement avec le nom d’utilisateur. C’est pourquoi il est très important dans mon cas de conserver autant que possible les noms d’utilisateur. (Il y aura toujours des noms d’utilisateur convertis, comme ceux avec des espaces.) Je dois donc examiner comment configurer les paramètres de Discourse au début du script d’importation.

Mais Discourse le fait, donc s’ils connaissent leur adresse e-mail, ils peuvent l’utiliser pour réinitialiser leur mot de passe, ce qui est probablement ce que vous devriez dire à tout le monde de faire, puisque vous ne pouvez pas deviner qui ne peut pas deviner son nom d’utilisateur, je suppose.

Je pense que ce que je ferais, c’est définir SiteSetting.unicode_username=true dans le script d’importation et le relancer pour voir si cela fonctionne. Vous pourriez essayer de le tester dans la console Rails pour voir. Cela pourrait vous dire :

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

Eh bien, je pense que cela n’appellera pas le créateur de nom d’utilisateur, vous devrez donc l’appeler

  UserNameSuggester.suggest("Narizón")

Non. Cela ne vous donnera toujours pas un nom d’utilisateur unicode. Vous devrez trouver le UserNameSuggester et le modifier, je suppose.

Mais si vous voulez vraiment changer les noms d’utilisateur, les changer maintenant plutôt que de corriger le script pourrait être ce que vous voulez faire. Vous devez vous assurer que la façon dont vous le faites met à jour le nom d’utilisateur dans tous les messages. Si vous utilisez une tâche Rake, cela le fera certainement.

1 « J'aime »

Excellent, merci beaucoup Jay ! J’essaierai cela la prochaine fois que j’exécuterai l’importateur.

Je ne pense pas que vous devriez vous en soucier :

C’est dans lib/user_name_suggester.rb, mais peut-être voulez-vous User.normalize_username

1 « J'aime »

Effectivement, vous aviez raison. Ce n’est même pas un bug en soi, il s’est avéré être une manière étrange dont Drupal gère les sujets déplacés tout en laissant une trace dans l’ancienne catégorie de sujet. Il crée simplement une ligne en double dans l’une des nombreuses tables qui sont toutes récupérées pour ce qui devient finalement un sujet Drupal complet. Il semble donc que je doive trouver comment appliquer DISTINCT à une seule des tables sélectionnées…

1 « J'aime »

Ouais. C’est incroyable comment chaque importation est unique, et d’une manière ou d’une autre, le vôtre est le premier forum à avoir eu ce problème (bien sûr, beaucoup de gens ont peut-être résolu le problème et n’ont pas réussi à soumettre une PR avec la mise à jour). Ou peut-être ont-ils ignoré les erreurs ?

Aha. Je soupçonne que ce n’est pas une fonction très couramment utilisée, lorsqu’un fil de discussion est déplacé vers une nouvelle catégorie, il y a une case à cocher facultative pour laisser un lien « Déplacé vers… » dans l’ancienne catégorie.

Le duplicata fautif se trouve dans la colonne nid de forum_index. Donc, il semble que je puisse le corriger avec un GROUP BY nid, n’est-ce pas ?

        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

Cela semble prometteur, car lorsque j’exécute la requête avec le GROUP BY nid, il y a 8 lignes en moins.

Cela pourrait fonctionner. Je pensais qu’il y aurait une valeur dans cette table indiquant qu’elle avait été déplacée et que vous pourriez sélectionner uniquement celles qui n’ont pas cette valeur.

Ce serait certainement la façon la plus logique de le concevoir. Je suppose que c’est une chose propre à Drupal…

La seule chose que cela fait est de changer le tid (ID de catégorie). Cela suit le style que j’ai appris au cours de cette épreuve avec la base de données Drupal. Je ne sais rien de la conception de bases de données, mais j’ai l’impression que vous pouvez soit stocker explicitement des données, soit laisser certaines choses implicites, puis les déterminer par une logique programmatique ; Drupal semble tomber squarely dans ce dernier camp.

2 « J'aime »

Well, it looks like I’m almost there. Thanks a lot to Jay for the guidance.

Thanks, this was key, it was actually as simple as copying the permalink part of the Drupal import script itself and changing it to run on posts instead of topics:

    ## I added permalinks for each Drupal comment (reply) link: /comment/DIGITS#comment-DIGITS
    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}" # The #comment-DIGITS part breaks the permalink and isn't needed
          Permalink.create(url: slug, post_id: post.id)
        end
      rescue => e
        puts e.message
        puts "Permalink creation failed for cid #{post.id}"
      end
    end

I was stuck for a while with my original attempt that included the relative page #comment-DIGITS part of the original Drupal link, which completely breaks the permalink in Discourse. then I realized that of course the # part of a link doesn’t actually get passed to the webserver and was only needed for Drupal to make it scroll to the part of the page where the specific comment was located. So it works fine without that in Discourse even if coming from an external web page with an old /comment/YYYYYY#comment-YYYYY link, it simply looks like this in Discourse: /comment/YYYYYY/t/topic-title-words/123456/X and the URL bar shows like: /t/topic-title-words/123456/X#comment-YYYYYY , it doesn’t appear to care about the bogus #comment-YYYYYY part.

For some forums I suspect that the stock Drupal importer postprocess_posts function might actually be enough. It should be noted that it needs to be adjusted for each forum, there’s a rather sloppy hard-coded regexp replace for site.comcommunity.site.com. But after adjusting that it does a good job of rewriting internal forum links for nodes → topics as well as comments → replies. But I do have a fair number of external websites linking to individual comments (replies) on my forum and it’s worth conserving those. Plus Google indexes most of the 1.7M /comment-YYYYYY URLs and it would probably hurt my ranking if those all disappeared. I hope it won’t cause any problems for Discourse to have ~2M permalinks though?


Thanks a lot, I lifted that function almost without modifications, just had to adjust a few column names. Works great.

  def suspend_users
    puts '', "updating banned users"

    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, "banned during initial import")
          banned += 1
        else
          puts "Failed to suspend user #{user.username}. #{user.errors.try(:full_messages).try(:inspect)}"
          failed += 1
        end
      else
        puts "Not found: #{b['email']}"
        failed += 1
      end

      print_status banned + failed, total
    end
  end

Also worked! I did have to deal with Drupal’s diffuse DB schema and LEFT JOIN profile_value location ON users.uid = location.uid to correlate another table that contains the profile data, but very cool that it’s so easy to add on the Discourse side of things. It’s worth noting that this process runs about 50% slower than stock, I suspect it’s due to the LEFT JOIN. But I can live with it, as I only have about 80K users.


This was fairly hard, once again due to Drupal’s disjointed database schema. I ended up using jforum.rb as the basis with a little help from the Vanilla importer too. The original script was rather paranoid with checking at every single variable pass to make sure the avatar filename isn’t null, so I removed most of those checks to make the code less messy. The worst that can happen is that the script could crash, but with the SQL query I used I don’t think even that could go wrong.

  def import_users
    puts "", "importing users"

    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

After your paid help with the SQL query I ended up trying to hack it into the script for Discuz, IPboard, and Xenforo. I kept getting hitting dead ends with each one, I got closest with the Discuz model which appears to have a very similar database schema, but I couldn’t get past a bug with the @first_post_id_by_topic_id instance variable. After tons of trial and error I finally realized that it was improperly initialized at the beginning of the Discuz script (I tried to put it in the same location in the Drupal script) and this finally fixed it:

  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 '', 'creating private messages'

	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'])
				# find the title from list table
				#          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

          # Find the users who are part of this private message.
          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 with yourself?
            skip = true
            puts "Skipping pm:#{m['id']} due to no target"
          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 "Parent post pm thread:#{thread_id} doesn't exist. Skipping #{m["id"]}: #{m["message"][0..40]}"
            skip = true
          end
        end
        skip ? nil : mapped
      end

    end
end
# search for first pm id for the series of 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, and for most of these queries it also requires running this in the MySQL container to disable a strict mode SQL sanity check:
mysql -u root -ppass -e "SET GLOBAL sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''));"


Another thing I realized was missing were a few thousand Drupal nodes of type poll . I first tried to just include WHERE type = 'forum' OR type = 'poll' in the import_topics function, but there is some seriously janky stuff going on in the original Drupal database that causes it to miss many of them. So I ended up copying the import_topics into a new import_polls function:

    def import_poll_topics
    puts '', "importing poll topics"

    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: "### You can see the archived poll results on the 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

I don’t care too much about importing the actual poll results, and it would require re-coding the entire algorithm that Drupal uses to tally up all the votes and eliminates duplicates. I mainly just want to import the followup comments in the poll thread. But just in case anyone wants to see the original poll results I made it write out a direct link to the original forum node in the Wayback Machine.


So the code is not at all elegant and probably isn’t very efficient, but for a one-shot deal that should get the job done.

Sorry for the walls of code, let me know if that irritates anyone and I can move them to a pastebin URL.

1 « J'aime »

Et c’est comme ça que la plupart d’entre eux se déroulent. Ce sujet est un excellent exemple pour quelqu’un d’autre à suivre (étant donné qu’il commence avec un ensemble de compétences similaire au vôtre).

Avez-vous une estimation du temps que vous avez passé à faire vos personnalisations ?

Félicitations !

1 « J'aime »

Merci Jay ! J’apprécie tes encouragements.

Argh, je préférerais ne pas y penser. :stuck_out_tongue_winking_eye: C’était probablement plus de 15 ou 20 heures après que tu m’aies mis sur la bonne voie avec la requête SQL.

J’aimerais te poser quelques questions à ce sujet si tu as des idées :

Il a fallu environ 70 heures pour faire un essai complet avec des données de production sur un VPS très puissant. J’aimerais que mes utilisateurs interagissent à nouveau le plus rapidement possible, même si l’importation des posts et des messages privés est toujours incomplète. Ou une autre idée alternative à laquelle j’ai pensé serait de désactiver la fonction preprocess_posts, que j’ai également fortement modifiée avec des remplacements supplémentaires par expressions régulières gsub et aussi pour faire passer tous les posts et messages privés par Pandoc avec une ou deux commandes différentes selon que le post original était du balisage Textile ou du HTML pur. Si je désactive toute la routine preprocess_posts, cela réduirait probablement le temps d’importation de moitié, et je pourrais ensuite ajouter tout ce matériel de formatage dans la section postprocess_posts une fois que toutes les données brutes sont importées. Mais l’inconvénient est qu’après coup, je ne pourrais pas facilement accéder à la colonne de la base de données d’origine qui indique le format source (Textile ou HTML) pour chaque post, ce qui est une condition pour ma manipulation Pandoc. Ou pourrais-je ajouter un champ personnalisé à chaque post le labellisant comme textile ou html et le récupérer plus tard lors du post-traitement ? Je ne sais pas, je réfléchis à voix haute.

Lorsque vous exécutez à nouveau le script d’importation avec uniquement les nouvelles données, il sera beaucoup plus rapide car il n’importera pas les données à nouveau. Cela ne prendra donc que quelques heures. Et chaque exécution ultérieure sera plus rapide car il y aura moins de données à importer.

Vous pouvez ensuite accélérer cela en modifiant les requêtes pour qu’elles ne retournent que les données plus récentes qu’une certaine heure. La plupart des scripts que j’ai touchés ont un paramètre import_after à cet effet (mais aussi pour permettre un développement plus rapide en important un petit sous-ensemble des données).

1 « J'aime »