Große Drupal-Forum-Migration, Importfehler und Einschränkungen

Hallo, dieses Thema gibt einige Hintergrundinformationen zur Migration, die ich langsam plane und teste. Ich habe am vergangenen Freitag endlich den Drupal-Importer auf einem Test-VPS mit einer Kombination aus diesem und diesem ausprobiert. Der Importer läuft noch, während ich das hier tippe, daher konnte ich die Funktionalität der Testseite noch nicht wirklich testen, aber sie wird bald fertig sein.

Das größte Problem, mit dem ich konfrontiert bin, ist ein “duplicate key value” bei 8 scheinbar zufälligen Nodes (das Äquivalent von Themen in Discourse) von insgesamt ca. 80.000 Nodes. Hier sind die spezifischen nid-Nummern, falls es sich um einen seltsamen Y2K-ähnlichen mathematischen Fehler handelt:

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

derselbe Fehler tritt immer bei denselben nids auf, wenn der Importer erneut ausgeführt wird:

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)

Der einzige Weg, wie ich ihn weitermachen konnte, war durch Hacking der SQL-Bedingungen:

...
	 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};
...

Ich habe den ersten fehlgeschlagenen Node sowie die vorherigen und nächsten nids auf beiden Seiten im Quell-Drupal-Datenbank untersucht und kann nichts Falsches erkennen. Der nid ist als Primärschlüssel gesetzt und hat AUTO_INCREMENT, und die ursprüngliche Drupal-Seite funktioniert einwandfrei, sodass es kein grundlegendes Problem mit der Integrität der Quelldatenbank geben kann.


Abgesehen von dem oben genannten Fehler sind dies die Einschränkungen, die ich mit dem Skript habe:

  1. Permalinks: Es scheint, dass das Importer-Skript Permalinks für die ehemaligen Node-URLs example.com/node/XXXXXXX erstellt. Ich muss aber auch Links zu bestimmten Kommentaren innerhalb dieser Nodes beibehalten, die das Format haben: example.com/comment/YYYYYYY#comment-YYYYYYY (YYYYYYY ist in beiden Fällen gleich). Das Drupal-URL-Schema enthält nicht die Node-ID, mit der der Kommentar verknüpft ist, während Discourse dies tut (example.com/t/topic-keywords/XXXXXXX/YY), was eine große Komplikation darstellt.

  2. Benutzername-Beschränkungen: Drupal erlaubt Leerzeichen in Benutzernamen. Ich verstehe, dass Discourse dies nicht tut, zumindest erlaubt es neuen Benutzern nicht, sie auf diese Weise zu erstellen. Dieser Beitrag deutet darauf hin, dass das Importer-Skript die problematischen Benutzernamen automatisch “konvertiert”, aber ich kann keinen Code dafür in /import_scripts/drupal.rb finden. Update: Tatsächlich scheint es, dass Discourse dies automatisch und korrekt gehandhabt hat.

  3. Gesperrte Benutzer: Es scheint, dass das Skript alle Benutzer importiert, einschließlich gesperrter Konten. Ich könnte wahrscheinlich recht einfach eine Bedingung zur SQL-Auswahl WHERE status = 1 hinzufügen, um nur aktive Benutzerkonten zu importieren, aber ich bin mir nicht sicher, ob dies Probleme mit der Serialisierung der Datensätze verursachen würde. Vor allem möchte ich diese zuvor gesperrten Kontonamen mit ihren zugehörigen E-Mail-Adressen dauerhaft blockiert halten, damit dieselben problematischen Benutzer sich nicht erneut auf Discourse anmelden.

  4. Benutzerprofilfelder: Weiß jemand, ob es in einem anderen Importer Beispielcode zum Importieren von persönlichen Informationsfeldern aus Benutzerprofilen gibt? Ich habe nur ein Profilfeld (“Standort”), das ich importieren muss.

  5. Avatare (nicht Gravatars): Es erscheint etwas seltsam, dass im Drupal-Importer Code zum Importieren von Gravataren vorhanden ist, aber nicht für die weitaus häufiger verwendeten lokalen Avatarbilder.

  6. Private Nachrichten: Fast alle Drupal 7-Foren werden wahrscheinlich das Drittanbieter-Modul privatemsg verwenden (es gibt keine offizielle Drupal PM-Funktionalität). Der Importer unterstützt den Import von PMs nicht. In meinem Fall muss ich etwa 1,5 Millionen davon importieren.

Vielen Dank im Voraus für Ihre Hilfe und dafür, dass Sie das Drupal-Importer-Skript zur Verfügung gestellt haben.

Diese Reihe von Problemen ist so ziemlich das Übliche für einen großen Import. Wer auch immer dafür geschrieben wurde, hat sich nicht darum gekümmert (vielleicht nicht genug, um es zu bemerken) um die von Ihnen beschriebenen Probleme.

Was wie ein Fehler in Drupal oder der Datenbank selbst klingt (doppelte IDs sollten nicht vorkommen). Ich hätte das Skript wahrscheinlich modifiziert, um auf Duplikate zu testen und/oder Fehler abzufangen, aber Ihre Methode hat funktioniert (es sei denn, es gibt noch mehr).

Sie können sich andere Importskripte ansehen, die Post-Permalinks erstellen. Die import_id befindet sich im PostCustomField jedes Posts.

Er befindet sich entweder in base.rb oder im Benutzervorschlags-Tool. Es funktioniert meistens einfach und man kann nicht viel daran ändern.

Das möchten Sie wahrscheinlich nicht tun. Das Problem ist, dass die von diesen Benutzern erstellten Beiträge dem system gehören. Sie können sich andere Skripte ansehen, um Beispiele dafür zu finden, wie Sie sie deaktivieren können. fluxbb hat ein suspend_users-Skript, das helfen sollte.

fluxbb (an dem ich gerade arbeite) macht das. Sie fügen einfach etwas wie das hier zum Import-Benutzerskript hinzu:

location: user[‘location’],

Gravatars werden vom Discourse-Kern behandelt, daher tut das Skript nichts, um sie zu importieren; es funktioniert einfach. Sie können die anderen Skripte nach “avatar” durchsuchen, um Beispiele dafür zu finden, wie Sie das tun können.

Suchen Sie nach Beispielen. . . . ipboard hat import_private_messages.

1 „Gefällt mir“

Danke für die Antwort. Ich glaube nicht, dass dies ein Problem mit der Drupal-Datenbank ist, da ich die Quelldatenbank inspiziert habe und keine doppelten nid-Schlüssel finden kann.

Ahhh, es hat also diese Funktionalität außerhalb von drupal.rb. Jetzt, wo ich mich auf der Test-Import-Site umschaue, sieht es tatsächlich so aus, als ob die Benutzernamenkonvertierungen sehr gut gehandhabt wurden. Danke!

1 „Gefällt mir“

Was wäre der einfachste Weg, den Import von Unicode-Benutzernamen zu ermöglichen (ohne sie zu konvertieren, d. h. den Benutzernamen Narizón beizubehalten, anstatt ihn in Narizon zu konvertieren)?

Ich habe meinen ersten Test des Drupal-Importeurs auf einer Instanz ohne konfigurierte Web-GUI durchgeführt, daher hatte ich die Discourse-Option zum Zulassen von Unicode-Benutzernamen nicht aktiviert. Wenn diese aktiviert gewesen wäre, hätte der Importeur sie dann respektiert? Was ist der empfohlene Weg, dies für meine Produktionsmigration zu aktivieren?

Und in der Zwischenzeit, für meine aktuelle Testinstanz, gibt es einen rake-Befehl, um den vollständigen Namen auf den Benutzernamen anzuwenden? (Ich habe prioritize username in ux bereits aktiviert, aber da meine Testbenutzer an Drupal gewöhnt sind, das nur Benutzernamen für die Anmeldung unterstützt [nicht die E-Mail-Adresse], denke ich, es wäre am besten, ihre Produktionsbenutzernamen beizubehalten, die zumindest im Feld fullname beibehalten wurden.)

Wahrscheinlich?

Sie können die Website-Einstellung zu Beginn des Skripts festlegen.

Ich halte es für eine schlechte Idee, Benutzernamen zu ändern, aber wenn sie Ihnen nicht gefallen, könnten Sie ändern, was an den Benutzernamen-Generator übergeben wird.

Danke, meinst du, sie nach Abschluss des Imports zu ändern?

Ich glaube, ich meine, sie überhaupt zu ändern, es sei denn, im alten System waren Benutzernamen unsichtbar und sie sahen nur echte Namen.

Wenn letzteres der Fall ist, würde ich das Skript ändern, um den Benutzernamen zu ihrem echten Namen zu machen. Das Problem dabei ist, dass sie ihr Konto nicht finden können, wenn sie ihre E-Mail-Adresse nicht kennen.

Verstanden. Im Drupal-Forum gibt es nur System-Benutzernamen und keine separaten echten Namen. Und zusätzlich erlaubt Drupal kein Einloggen mit der E-Mail-Adresse, nur mit dem Benutzernamen. Deshalb ist es in meinem Fall sehr wichtig, die Benutzernamen so weit wie möglich beizubehalten. (Es wird immer noch einige konvertierte Benutzernamen geben, wie z. B. solche mit Leerzeichen.) Ich muss also untersuchen, wie die Discourse-Einstellungen zu Beginn des Import-Skripts festgelegt werden.

Aber Discourse tut das, also wenn sie ihre E-Mail-Adresse kennen, können sie diese zum Zurücksetzen des Passworts verwenden, was wahrscheinlich das ist, was Sie allen sagen sollten, da Sie nicht erraten können, wer seinen Benutzernamen nicht erraten kann, nehme ich an.

Ich denke, ich würde SiteSetting.unicode_username=true im Import-Skript setzen und es erneut ausführen, um zu sehen, ob es funktioniert. Sie könnten es in der Rails-Konsole testen, um zu sehen. Dies könnte Ihnen sagen:

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

Nun, ich denke, das würde nicht die Benutzernamen-Erzeuger-Sache aufrufen, also müssen Sie sie aufrufen

  UserNameSuggester.suggest("Narizón")

Nein. Das bringt Ihnen immer noch keinen Unicode-Benutzernamen. Sie müssen den UserNameSuggester finden und ihn anpassen, nehme ich an.

Aber wenn Sie die Benutzernamen wirklich ändern wollen, ist es vielleicht besser, sie jetzt zu ändern, anstatt das Skript zu reparieren. Sie müssen sicherstellen, dass die Art und Weise, wie Sie es tun, den Benutzernamen in allen Beiträgen aktualisiert. Wenn Sie eine Rake-Aufgabe verwenden, wird dies definitiv geschehen.

1 „Gefällt mir“

Ausgezeichnet, vielen Dank, Jay! Ich werde das beim nächsten Mal ausprobieren, wenn ich den Importer ausführe.

Ich glaube nicht, dass Sie sich die Mühe machen sollten:

Das ist in lib/user_name_suggester.rb, aber vielleicht möchten Sie User.normalize_username

1 „Gefällt mir“

Sicher, Sie hatten Recht. Es ist nicht einmal ein Fehler an sich, es stellte sich heraus, dass es eine seltsame Art und Weise war, wie Drupal verschobene Themen behandelt, während es eine Brotkrümelspur in der ehemaligen Themenkategorie hinterlässt. Es erstellt einfach eine doppelte Zeile in einer der vielen Tabellen, die alle zusammengezogen werden, um das zu werden, was schließlich ein vollständiges Drupal-Thema wird. Es sieht also so aus, als müsste ich herausfinden, wie ich DISTINCT nur auf eine der ausgewählten Tabellen anwenden kann…

1 „Gefällt mir“

Ja. Es ist erstaunlich, wie jeder Import ein Einzelstück ist und wie Ihres das erste Forum ist, das dieses Problem hatte (natürlich haben viele Leute das Problem vielleicht gelöst und es nicht geschafft, einen PR mit dem Update einzureichen). Oder vielleicht haben sie die Fehler ignoriert?

Aha. Ich vermute, es ist keine sehr gebräuchliche Funktion. Wenn ein Thread in eine neue Kategorie verschoben wird, gibt es ein optionales Kontrollkästchen, um einen „Verschoben nach…“-Link in der alten Kategorie zu hinterlassen.

Der fehlerhafte Duplikat befindet sich in der Spalte nid aus forum_index. Es sieht also so aus, als könnte ich es mit GROUP BY nid beheben, richtig?

        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

Es sieht vielversprechend aus, denn wenn ich die Abfrage mit GROUP BY nid ausführe, gibt es 8 Zeilen weniger.

Das könnte funktionieren. Ich würde denken, dass es in dieser Tabelle einen Wert gäbe, der besagt, dass sie verschoben wurde, und Sie könnten nur diejenigen auswählen, die diesen Wert nicht haben.

Das wäre definitiv die logischste Art, es zu gestalten. Ich schätze, das ist eine Drupal-Sache…

Das Einzige, was es tut, ist, die tid (Kategorie-ID) zu ändern. Das folgt dem Stil, den ich während dieser Tortur mit der Drupal-Datenbank gelernt habe. Ich weiß nichts über DB-Design, aber ich habe den Eindruck, dass man entweder Daten explizit speichern kann oder einige Dinge implizit lassen und sie dann durch programmatische Logik herausfinden kann; Drupal scheint eindeutig in die letztere Kategorie zu fallen.

2 „Gefällt mir“

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 „Gefällt mir“

Und so laufen die meisten davon ab. Dieses Thema ist ein großartiges Beispiel, dem jemand anderes folgen kann (vorausgesetzt, er beginnt mit einem ähnlichen Fähigkeitsset wie deines).

Hast du eine Schätzung, wie viel Zeit du mit deinen Anpassungen verbracht hast?

Glückwunsch!

1 „Gefällt mir“

Danke Jay! Ich schätze die Ermutigung.

Ugh, das möchte ich lieber nicht bedenken. :stuck_out_tongue_winking_eye: Es waren wahrscheinlich über 15 oder 20 Stunden, nachdem Sie mich mit der SQL-Abfrage auf den richtigen Weg gebracht hatten.

Ich würde gerne Ihre Meinung dazu hören, wenn Sie welche haben:

Es dauerte ungefähr 70 Stunden, um einen vollständigen Testlauf mit Produktionsdaten auf einem sehr leistungsstarken VPS durchzuführen. Ich möchte, dass meine Benutzer so schnell wie möglich wieder interagieren, auch wenn der Import von Beiträgen und privaten Nachrichten noch unvollständig ist. Oder eine andere alternative Idee, über die ich nachgedacht habe, wäre, die Funktion preprocess_posts zu deaktivieren, die ich ebenfalls stark mit zusätzlichen gsub-RegExp-Ersetzungen modifiziert habe und um alle Beiträge und privaten Nachrichten durch Pandoc zu leiten, mit einem von zwei verschiedenen Befehlen, je nachdem, ob der ursprüngliche Beitrag Textil-Markup oder reines HTML war. Wenn ich die gesamte preprocess_posts-Routine deaktiviere, würde dies die Importzeit wahrscheinlich fast halbieren, und dann könnte ich all diese Formatierungsarbeiten in den Abschnitt postprocess_posts aufnehmen, sobald alle Rohdaten importiert sind. Der Nachteil ist jedoch, dass ich später nicht mehr einfach auf die ursprüngliche Datenbankspalte zugreifen könnte, die das Quellformat (Textile oder HTML) für jeden Beitrag anzeigt, was eine Bedingung für meine Pandoc-Manipulation ist. Oder könnte ich jedem Beitrag ein benutzerdefiniertes Feld hinzufügen, das ihn als textile oder html kennzeichnet und diesen dann später während der Nachbearbeitung abruft? Keine Ahnung, ich denke nur laut nach.

Wenn Sie das Importskript erneut nur mit den neuen Daten ausführen, wird es viel schneller laufen, da die Daten nicht erneut importiert werden. Es wird also nur wenige Stunden dauern. Und jeder nachfolgende Lauf wird schneller sein, da weniger Daten importiert werden müssen.

Sie können dies beschleunigen, indem Sie die Abfragen so ändern, dass nur Daten zurückgegeben werden, die neuer als eine bestimmte Zeit sind. Die meisten Skripte, die ich bearbeitet habe, verfügen über eine import_after-Einstellung genau zu diesem Zweck (aber auch, um eine schnellere Entwicklung durch den Import einer kleinen Teilmenge der Daten zu ermöglichen).

1 „Gefällt mir“