Drupal-8-Migration

Submitted by Erik Wegner on
Aufmacherbild

Mit diesem Artikel möchte ich Einblicke geben, wie die Migration auf Drupal 8 als CMS für diese Webseite abläuft.

Warum Drupal 8?

Gestartet ist die Webseite mit ein paar statischen HTML-Seiten. Später kam PHP ins Spiel, um einen einheitlichen Rahmen um alle Seiten zu schaffen (Aussehen, Navigation, Fußzeile, etc.). Dann erfolgte die Umstellung auf Drupal 6. Dabei wurden alle alten Tipps importiert. Neu war nun die Möglichkeit, alles im Browser zu bearbeiten, mehrsprachig zu schreiben und zu bloggen.

Der Versionswechsel auf Drupal 7 erfolgte mit dem eingebauten Werkzeug, mit allen Vor- und Nachteilen. Zuerst mussten alle relevanten Module portiert werden, dies hat den Umstieg verzögert. Nach dem Umstieg sind verwaiste Daten in der Datenbank zurückgeblieben.

Der Schritt zu Drupal 8 bringt viele neue Konzepte mit sich: Drupal aus Entwicklersicht ist jetzt klassenbasiert, die Oberfläche schöner auf Mobilgeräten benutzbar. Die Verschlankung des Kerns und ein neues Release-Konzept bringen schneller Neuerungen in die Drupal-Welt. Die für diesen Punkt wichtigste Neuerung ist: es gibt kein Upgrade der Datenbank mehr. Stattdessen werden die Inhalte aus der alten Datenbank in eine frische Drupal-8-Installation importiert. Dazu dienen die Erweiterungen aus dem Migrate-Kosmos.

Migrate-Module

Die erste Idee ist, dem Vorschlag zur Nutzung der Migrate-Module zu folgen. Damit konnte ich die Inhaltstypen und Inhalte übertragen. Allerdings gibt es Probleme mit den Textformaten (Lösung). Weiterhin wird noch immer zu viel importiert (z. B. Benutzer, Rollen, Kontaktkategorien, Protokollierungseinstellungen, Block-Konfiguration, etc.).

Das Zusammenführen von bisher getrennten Feldern muss separat implementiert werden.

Insgesamt ist das Verhältnis Aufwand/Nutzen hier ungünstig, sodass ich diesen Weg vorerst nicht weiter verfolge.

Plan B

Alternative ist, wie schon beim ersten Befüllen der Drupal-6-Webseite, ein eigenes Script zum Migrieren der Inhalte. Die folgende Skizze zeigt den Ablauf an:

Manuell müssen die Inhaltstypen und die dazugehörigen Felder angelegt werden. Weiterhin werden zwei Kategorienlisten benötigt.

Darauf setzt nun der Importvorgang auf und führt die folgenden Schritte automatisch aus:

  1. Lade den Inhalt der Ausgangsseite
  2. Erzeuge den Inhalt in der Zielseite
  3. Erzeuge und verknüpfe die Schlagwörter
  4. Lade und verknüpfe die Anhänge und Bilder
  5. Korrigiere den Anrisstext
  6. Erstelle die Pfade, damit die alten Inhalte weiterhin erreichbar bleiben
  7. Lade und verknüpfe die Kommentare

Nachdem diese Schritte gelaufen sind, muss noch die Buchstruktur neu erzeugt werden.

Umsetzung

Ein leichter Weg, die Drupal-API zu nutzen, ist der Einsatz von drush. Damit lässt sich ein Kommando definieren, dass vollständigen Zugriff auf alle notwendigen Bereiche bekommt. Zuerst wird ein Drupal-8-Modul definiert über die Datei ewus.info.yml:

name: EWUS
type: module
description: Anpassungen und Konfiguration für EWUS
core: 8.x
package: Other
version: 8.x-1.0
dependencies:
  - pathologic
  - config_translation
  - content_translation

Der eigene Drush-Befehl und die notwendigen Hilfsfunktionen werden in der Datei ewus.drush.inc abgelegt:

<?php

use \Drupal\Core\Language\LanguageInterface;
use \Drupal\node\Entity\Node;
use \Drupal\taxonomy\Entity\Term;
use \Drupal\comment\Entity\Comment;
use \Drupal\file\Plugin\Field\FieldType\FileFieldItemList;
use \Drush\Log\LogLevel;

define('EWUS_ENDPOINT', 'https://ewus.de/service_endpoint/'); // access to web service
define('EWUS_FILEBASE', 'http://ewus.de/sites/ewus.de/files/'); // to download referenced files and images
define('EWUS_PATHBASE', 'https://ewus.de'); // this gets removed from the url alias, no trailing slash!
define('EWUS_APIKEY', 'dkw3na8K1CAlm2W5PTWz0Q'); // web service authentication through api key
define('EWUS_OLDFILEDIR', '/sites/ewus.de/files'); // migrate body image links from this path
define('EWUS_NEWFILEDIR', 'files:'); // to this path ('files:' is handled by pathologic module)
define('EWUS_FIRST_NODE_ID', 1); // Start import with this node
define('EWUS_LAST_NODE_ID', 722); // Stop import after this node
define('EWUS_DELETE_EXISTING_NODES', FALSE); // If a node already exists, delete it first?
define('EWUS_IMPORT_COMMENTS', TRUE); // Import comments? Set to FALSE, if comments have already been imported

/**
* Implements hook_drush_command().
*/
function ewus_drush_command() {
  $items = array();
  $items['ewus-import'] = [
    'description' => 'Run import',
  ];
  return $items;
}

function drush_ewus_import() {
  /* Load all nodes */
  for ($nid = EWUS_FIRST_NODE_ID; $nid < EWUS_LAST_NODE_ID + 1; $nid++) {
    print("Node " . $nid . PHP_EOL);
    $json = file_get_contents(EWUS_ENDPOINT . 'node/' . $nid . '?api-key=' . EWUS_APIKEY);
    $data = json_decode($json);

    if ($data === NULL) continue;

    // If load was successful, import data
    _ewus_import_node($data);
  }
}

/**
 *  Map old input format to new Drupal 8 input format
 */
function _ewus_format_map($format) {
  $format_map = array(
    'js' => '',
    'ds_code' => '',
    1 => 'basic_html', // Filtered HTML in D7.
    2 => 'full_html', // Full HTML in D7.
    3 => 'restricted_html', // Comments
    4 => 'plain_text', // Plain Text in D7.
    'php_code' => '',
    'rohdaten' => 'rohdaten'
  );

  if (array_key_exists($format, $format_map)) {
    return $format_map[$format];
  }

  return $format_map[3];
}

/*
 * Remove p- or div-tags surrounding the summary
 */
function _ewus_clean_summary($summary) {
  $summary = trim($summary);
  if (substr($summary, 0, 3) === '<p>' && substr($summary, -4) === '</p>') {
    return substr($summary, 3, -4);
  }
  if (substr($summary, 0, 5) === '<div>' && substr($summary, -6) === '</div>') {
    return substr($summary, 5, -6);
  }

  return $summary;
}

function _ewus_import_node_summary($node) {

  $v = $node->body->value;

  $needle = '<!--break-->';
  $pos = stripos($v, $needle);

  if ($pos === FALSE) {
    return;
  }

  if ($node->body->summary != "") {
    drush_log("Summary with --break-- " . $node->id, LogLevel::ERROR);
    return;
  }

  $summary = substr($v, 0, $pos);
  if (substr($summary, 0, 3) === '<p>') {
    $summary = substr($summary, 3);
  }
  if (substr($summary, 0, 5) === '<div>') {
    $summary = substr($summary, 5);
  }

  $body = str_ireplace($needle, '', $v);

  $node->body->summary = $summary;
  $node->body->value = $body;
}

/*
 * Replace all occurrences of EWUS_OLDFILEDIR with
 * EWUS_NEWFILEDIR in the text.
 */
function _ewus_import_text_update_file_references($text) {
  return str_replace(EWUS_OLDFILEDIR, EWUS_NEWFILEDIR, $text);
}

function _ewus_import_node_prism($node) {
  $node->body->value = preg_replace("/<pre class=\"brush: *(.*?)\">(.*?)<\/pre>/", "<pre><code class=\"language-$1\">$2</code></pre>", $node->body->value);
}

/*
 * Set aliaes for node
 */
function _ewus_set_url_aliases($node, $data) {
  // Url alias from source system
  $nodealias = urldecode(substr($data->path, strlen(EWUS_PATHBASE)));
  // Service for url aliases
  $alias_storage = \Drupal::service('path.alias_storage');

  // Default values
  $language = $data->language;
  $systempath = '/node/' . $node->id();

  if ($node->isTranslatable() && $node->getUntranslated()->id() != $data->nid) {

    // Path to the untranslated node
    $systempath = '/node/' . $node->getUntranslated()->id();
  }

  // translated node is not a node on its own: add its old path pointing to the untranslated node
  if ($systempath !== '/node/' . $data->nid && $alias_storage->aliasExists('/node/' . $data->nid, $data->language) === FALSE) {
    drush_print("Alias for " . $systempath . " from " . '/node/' . $data->nid);

    $save_result = $alias_storage->save($systempath, '/node/' . $data->nid, $data->language);
    if ($save_result === FALSE) {
      drush_log("Alias save failed for " . $systempath . " from " . '/node/' . $data->nid, LogLevel::ERROR);
    }
  }

  // translated node may have had an alias:
  if ($nodealias !== '/node/' . $data->nid && $alias_storage->aliasExists('/node/' . $nodealias, $language) === FALSE) {
    drush_print("Alias for " . $systempath . " from " . $nodealias);
    $save_result = $alias_storage->save($systempath, $nodealias, $language);
    if ($save_result === FALSE) {
      drush_log("Alias save failed for " . $systempath . " from " . $nodealias, LogLevel::ERROR);
    }
  }
}

/*
 * Create a new node. Removes existing node if required.
 */
function _ewus_get_or_create_node($data) {
  $node = Node::load($data->nid);

  /* Remove existing nodes if configured or it was a translated node */
  $is_translated_node = $data->tnid != $data->nid && $data->tnid > 0;
  if ($node !== NULL && (EWUS_DELETE_EXISTING_NODES || $is_translated_node)) {
    $node->delete();
    $node = NULL;
  }

  // Url alias from source system
  $nodealias = urldecode(substr($data->path, strlen(EWUS_PATHBASE)));

  //SELECT COUNT(`source`) AS c, `source`, `alias` FROM `d7_url_alias` GROUP BY `source` HAVING c > 1

  if ($node === NULL) {
    // Select language from source node
    if (!($data->language === "de" || $data->language === "en" || $data->language === "und")) {
      drush_log("Language: " . $data->language . " on node " . $data->nid, LogLevel::WARN);
      $data->language = "de";
    }

    // If source node is a translation of another node, it has tnid set to source node (i18n in Drupal 6/7)
    if ($data->tnid > 0 && $data->tnid != $data->nid) {
      // load the node to add a new translation
      $tsourceNode = Node::load($data->tnid);
      // adding a translation updates the date, save it here
      $tsourceChanged = $tsourceNode->changed->value;

      // add or get the translation
      if (!$tsourceNode->hasTranslation($data->language)) {
        $node = $tsourceNode->addTranslation($data->language);
      } else {
        $node = $tsourceNode->getTranslation($data->language);
      }

      // reset the modified date
      $tsourceNode->changed->value = $tsourceChanged;
    } else {
      $node = Node::create([
        'nid' => $data->nid,
        'type' => $data->type,
        'langcode' => $data->language,
        'path' => $nodealias,
      ]);
    }
  }

  // Extract body field from source node
  $body = $data->body->und[0];

  // Set or update fields
  $node->uid = 1;

  $node->title->value = $data->title;
  $node->status->value = $data->status;
  $node->created->value = $data->created;
  $node->changed->value = $data->changed;
  $node->body->value = _ewus_import_text_update_file_references($body->value);
  $node->body->summary = _ewus_clean_summary($body->summary);
  $node->body->format = _ewus_format_map($body->format);

  // Fix summary field
  _ewus_import_node_summary($node);

  return $node;
}

/*
 * Add remote files to local Drupal
 */
function _ewus_import_files($filearray) {
  static $uri_to_id_map = [];

  $r = [];
  foreach ($filearray as $filedata) {
    if (array_key_exists($filedata->uri, $uri_to_id_map) == FALSE) {
      if (file_exists(dirname(__FILE__) . '/../../files/' . $filedata->filename)) {
        //drush_log("Duplicate " . $filedata->filename, LogLevel::WARNING);
      }
      $fileuri = '';
      if (substr($filedata->uri, 0, 9) === 'public://') {
        $fileuri = EWUS_FILEBASE . substr($filedata->uri, 9);
      }
      $data = file_get_contents( $fileuri);
      $file = file_save_data($data, 'public://' . $filedata->filename, FILE_EXISTS_REPLACE);
      $attachmentdata = [
        'target_id' => $file->id(),

      ];
      if ($filedata->type == "document" || $filedata->type == "undefined") {
        $attachmentdata = array_merge($attachmentdata, [
          'display' => $filedata->display,
          'status' => $filedata->status,
          'description' => $filedata->description
        ]);
      }
      if ($filedata->type === 'image') {
        $attachmentdata = array_merge($attachmentdata, [
          'alt' => $filedata->alt,
          'title' => $filedata->title,
        ]);
      }

      // Save to cache
      $uri_to_id_map[$filedata->uri] = $attachmentdata;
    }

    $r[] = $uri_to_id_map[$filedata->uri];
  }

  return $r;
}

/*
 * When updating a node during import, update the attachment files, too.
 */
function _ewus_update_files(Node $node, string $fieldname, $upload_file_data) {
  $nodefield = $node->get($fieldname);
  foreach ($upload_file_data as $filedata) {
    $file = NULL;
    foreach($nodefield as $nodefile) {
      if ($nodefile->get('target_id')->getValue() == $filedata['target_id']) {
        $file = $nodefile;
      }
    }
    if ($file === NULL) {
      drush_log("File: " . $filedata['target_id'] . " on node " . $node->id(), LogLevel::ERROR);
      continue;
    }
    foreach($filedata as $key => $value) {
      $file->set($key, $value);
    }
  }
}

/*
 * Lookup new taxonomy id. $tid is unique, no matter which vocabulary is used.
 */
function _ewus_lookup_taxonomy_term($tid, $vocabulary) {
  static $tid_to_txt_map = [];

  if (array_key_exists($vocabulary . $tid, $tid_to_txt_map) == FALSE) {
    // Load information about term from old site
    $json = file_get_contents(EWUS_ENDPOINT . 'taxonomy_term/' . $tid . '?api-key=' . EWUS_APIKEY);
    $data = json_decode($json);
    $tid_to_txt_map[$vocabulary . $tid] = $data->name;
  }

  $term_text = $tid_to_txt_map[$vocabulary . $tid];

  return _ewus_lookup_taxonomy_term_text($term_text, $vocabulary);
}

/*
 * Lookup new taxonomy id for a given term.
 */
function _ewus_lookup_taxonomy_term_text($term_text, $vocabulary) {
  static $txt_to_tid_map = [];
  if (array_key_exists($vocabulary . $term_text, $txt_to_tid_map) == FALSE) {
    if ($terms = taxonomy_term_load_multiple_by_name($term_text, $vocabulary)) {
      $term = reset($terms);
    } else {
      // create new term
      $term = Term::create([
        'name' => $term_text,
        'vid' => $vocabulary,
      ]);
      $term->save();
    }

    $txt_to_tid_map[$vocabulary . $term_text] = $term->id();
  }

  return $txt_to_tid_map[$vocabulary . $term_text];
}

/*
 * Lookup multiple taxonomy ids
 */
function _ewus_lookup_taxonomy_terms($tids, $vocabulary) {
  $r=[];
  foreach($tids as $term) {
    $r[] = [
      'target_id' => _ewus_lookup_taxonomy_term($term->tid, $vocabulary),
    ];
  }
  return $r;
}

function _ewus_import_comments_compare($ca, $cb) {
  $a = $ca->created;
  $b = $cb->created;
  if ($a == $b) {
    return 0;
  }
  return ($a < $b) ? -1 : 1;
}

/*
 * Import comment for an existing node from an old node id
 */
function _ewus_import_comments($nid, $node) {
  // The old site had two separate nodes for translated content. Join both comments lists
  $json = file_get_contents(EWUS_ENDPOINT . 'node/' . $nid . '/comments?api-key=' . EWUS_APIKEY);
  $data = json_decode($json);

  if ($nid != $node->id()) {
    $json2 = file_get_contents(EWUS_ENDPOINT . 'node/' . $node->id() . '/comments?api-key=' . EWUS_APIKEY);
    $data2 = json_decode($json2);
    $data = array_merge($data, $data2);
  }

  // Sort the array by "created"-field
  usort($data, "_ewus_import_comments_compare");

  // Delete existing comments first, so that the "thread" field gets appropiate values
  foreach(array_reverse($data) as $commentdata) {
    $comment = Comment::load($commentdata->cid);
    if ($comment !== NULL) {
      $comment->delete();
    }
  }

  // Recreate comments
  foreach($data as $commentdata) {
    $comment = Comment::create([
      'entity_type' => 'node',
      // The new site has comments for the node at base translation, see https://www.drupal.org/node/2558923#comment-10310941
      'entity_id' => $node->id(),
      'field_name' => 'comment',
      'cid' => $commentdata->cid,
      'uid' => $commentdata->uid,
      'pid' => $commentdata->pid,
      'subject' => $commentdata->subject,
      'hostname' => $commentdata->hostname,
      'node_type' => $commentdata->node_type,
      'created' => $commentdata->created,
      'changed' => $commentdata->changed,
      'status' => $commentdata->status,
      'name' => $commentdata->name,
      'mail' => $commentdata->mail,
      'homepage' => $commentdata->homepage,
      'comment_body' => [
        'value' => $commentdata->comment_body->und[0]->value,
        'format' => _ewus_format_map($commentdata->comment_body->und[0]->format),
      ],
      'langcode' => $node->get('langcode')->get(0)->value,
    ]);

    $comment->save();
  }
}

/* Import a node */
function _ewus_import_node($data) {
  if ($data->type === 'quotes') {
    $data->type = 'quote';
  }
  if ($data->type === 'story') {
    $data->type = 'article';
  }

  $allowed_types = ['hint', 'blog', 'denglisch', 'book', 'page', 'quote', 'kaffeebewertung', 'software'];
  if (in_array($data->type, $allowed_types) === FALSE) {
    if ($data->type !== 'wishlistitem') {
      drush_log("Unsupported type " . $data->type, LogLevel::ERROR);
    }
    return;
  }

  $node = _ewus_get_or_create_node($data);

  if (count($data->upload) > 0) {
    $upload_file_data = _ewus_import_files($data->upload->und);
    if ($node->isNew()) {
      $node->field_upload = $upload_file_data;
    } else {
      _ewus_update_files($node, 'field_upload', $upload_file_data);
    }
  }

  if (count($data->taxonomy_vocabulary_1) > 0) {
    $node->field_tags = _ewus_lookup_taxonomy_terms($data->taxonomy_vocabulary_1->und, 'tags');
  }

  if (count($data->taxonomy_vocabulary_3) > 0) {
    $node->field_tags = _ewus_lookup_taxonomy_terms($data->taxonomy_vocabulary_3->und, 'tags');
  }

  if (count($data->field_fpimg) > 0) {
    $node->field_fpimg = _ewus_import_files($data->field_fpimg->und);
  }

  if (count($data->field_bild) > 0) {
    $node->field_image = _ewus_import_files($data->field_bild->und);
  }

  /* Zitate */
  if (count($data->taxonomy_quotes) > 0) {
    $node->field_tags = _ewus_lookup_taxonomy_terms($data->taxonomy_quotes->und, 'tags');
  }

  if (isset($data->quotes_author) && $data->quotes_author != "") {
    $node->field_author = [['target_id' => _ewus_lookup_taxonomy_term_text($data->quotes_author, 'tags')]];
  }

  if (isset($data->quotes_citation) && $data->quotes_citation != "") {
    $node->field_citation = [
      'uri' => $data->quotes_citation,
      'title' => $data->quotes_citation
    ];
  }

  /* Kaffee */
  if (count($data->field_verkaufer) > 0) {
    $node->field_verkaufer = _ewus_lookup_taxonomy_terms($data->field_verkaufer->und, 'kaffee');
  }

  if (count($data->field_hersteller) > 0) {
    $node->field_verkaufer = _ewus_lookup_taxonomy_terms($data->field_hersteller->und, 'kaffee');
  }

  if (count($data->field_geschmack) > 0) {
    $node->field_geschmack = $data->field_geschmack->und[0]->rating / 20;
  }

  if (count($data->field_vote) > 0) {
    $node->field_vote = $data->field_vote->und[0]->rating / 20;
  }

  if (count($data->field_duft) > 0) {
    $node->field_duft = $data->field_duft->und[0]->rating / 20;
  }

  $node->save();

  if ($data->comment_count > 0 && EWUS_IMPORT_COMMENTS) {
    _ewus_import_comments($data->nid, $node);
  }

  _ewus_set_url_aliases($node, $data);
}

Der Abruf der Daten erfolgt, indem in der Quelle das Services-Modul aktiviert wird. Notwendig ist der Zugriff auf die Ressourcen node/retrieve, node/comments, taxonomy_term/retrieve. Die Authentifizierung erfolgt über einen Api-Key im Request.

Das Kommando kann dann aufgerufen werden:

drush ewus-import

Offene Punkte

Die Übersetzung der Buchseiten im Tutorial wird nicht für den Aufbau des Inhaltsverzeichnisses genutzt. Beim Aufruf der englischen Übersetzung werden weiterhin die deutschen Titel der Unterseiten angezeigt. Dazu gibt es bereits eine Fehlermeldung: Make Book navigation translatable.

Während des Imports wird nur in geringem Maße darauf geachtet, ob es schon URL-Aliaspfade und Redirects gibt. Vor dem Import sollten folgende Tabellen geleert und ggfs. der Autoindex-Zähler wieder auf 1 gesetzt werden:

  1. url_alias
  2. rewrite

Im Zusammenspiel mit dem Pathauto-Modul müssen noch dessen Caches bereinigt werden, dazu wird dieser Befehl ausgeführt:

TRUNCATE redirect;
ALTER TABLE redirect AUTO_INCREMENT=1;
TRUNCATE url_alias;
ALTER TABLE url_alias AUTO_INCREMENT=1;
DELETE FROM key_value where collection = 'pathauto_state.node';
Associated categories

Add new comment

About text formats

Activitypub

  • Allowed HTML tags: <a href hreflang> <em> <strong> <ul type> <ol start type='1 A I'> <li> <dl> <dt> <dd> <h2 id='jump-*'> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.