Bild zeigt Nodz bei der Arbeit mit Drupals Feeds-Modul.

Overfeeding: Drupals Feeds-Modul ausgereizt - i18n Import

18.03.14

Daten-Import in Drupal: Wie mit dem Feeds-Modul und einigen Kniffen mehrsprachige Daten importiert werden können.

Daten importieren mit Drupals Feeds-Modul

Bei der neuen Website von Kobler & Partner beschlossen wir, von Contao auf Drupal zu wechseln. Abgesehen davon, dass wir somit ein komplett anderes CMS als Basis verwendeten, hiess dies zudem, dass wir eine beträchtliche Menge Daten in Drupal importieren mussten. Ein Teil dieser Datenmenge war im vorgängig verwendeten CMS Contao abgelegt, der Rest in einem proprietären Datenbanksystem (FileMaker), wovon ich einzig exportierte CSV-Dateien zur Verfügung hatte. Die Architektur der Seite ist zugleich mehrsprachig und relational, mit vielen Entity-Referenzen zwischen den verschiedenen Node-Typen. Dies zu implementieren war das eine, das Ganze zu testen oder zu stylen war ohne Daten jedoch äusserst schwierig – der Daten-Import war also ein kritischer Schritt. Schwierig war daran natürlich dass die Daten a) relational und b) mehrsprachig waren; tatsächlich waren sie stets beides.

Ich werde in einem mehrteiligen Artikel Schritt für Schritt erklären, wie ich diesen komplexen Import zum Laufen bringen konnte. In diesem ersten Teil werde ich den Import mehrsprachiger Daten betrachten. Falls Sie das Drupal Feeds-Modul bereits kennen, nur meinen i18n-Import-Trick lesen möchten oder als Entwickler strikt keine Wörter mögen, können Sie auch direkt zum Abschnitt „Mehrsprachige Daten importieren“ und zum Code springen!

Optionen für den Import in Drupal

Bevor wir gleich lospreschen, kurz ein paar Worte zu den Drupal-Modulen, die sich für den Import von Daten anbieten. Zwei davon schienen sich für meine Aufgabe zu eignen: Feeds und Migrate. Aus verschiedenen Gründen entschied ich mich für Feeds. Beide Module haben ein relativ umfängliches Feature-Set und Extras: Bei Migrate ist dies Migrate Extras und bei Feeds Feeds Tamper. Feeds wird häufiger verwendet (60k Installationen verglichen mit 10k Installationen für Migrate), dafür scheint Migrate besseren Drush-Support zu haben. Einer der grossen Unterschiede ist jedoch, dass Feeds zudem auf kontinuierliche Imports und Aggregation ausgerichtet ist, sodass z.B. regelmässig von einem Blog oder, wie der Name impliziert, einem Feed importiert werden könnte.

Die Option eines kontinuierlichen Imports schien am Anfang des Projektes sehr attraktiv. Die zu importierenden Daten waren unvollständig und würden sicherlich noch verändert werden. Kontinuierliche, additive Imports hätten uns erlaubt, regelmässig neue Daten zu importieren, wobei die Daten in der ursprünglichen Quell-Datenbank hätten verbessert werden können.

Mit dem Fortschritt des Projekts änderten sich jedoch die Umstände und es stellte sich heraus, dass der Export der Quell-Daten sehr limitiert war. Die Formatänderungen bedingten viel Arbeit durch das Feeds Tamper UI. Schlussendlich war es jedoch nicht mehr möglich, die entstehenden Probleme so zu lösen; stattdessen schien ein Preprocessing-Schritt sinnvoll. Das Preprocessing bedeutete unter anderem, dass ein kontinuierlicher Feeds-Import nicht mehr möglich war – möglicherweise wäre Migrate also eine genauso gute Lösung gewesen. Da der Import mit Feeds zum gegebenen Zeitpunkt zu 90% funktionierte, entschied ich, Feeds weiterhin zu verwenden und meine Lösung einfach zu optimieren.

Feeds Basics & Preprocessing

Feeds kombiniert mit Feeds Tamper kann zwar viel, doch ständig Änderungen über das UI vorzunehmen ist relativ mühselig, insbesondere, wenn das Import-Format instabil oder Index-basiert ist. Noch komplizierter wird es, wenn die Tampers und Feeds dann noch auf eine Staging- oder Live-Website verteilt werden müssen. Handelt es sich also um einen einmaligen Migrationsimport, so ist es sinnvoll, mit einem Preprocessing-Schritt möglichst viele Daten aufzubereiten.

Hierfür werden die folgenden Module benötigt:

Internationalization und die dazugehörigen Module braucht es natürlich aufgrund der mehrsprachigen Daten. Feeds wird für den Import eingesetzt, Feeds Tamper verwendete ich, um leichte Änderungen am Import vorzunehmen um zuerst den Preprocessing-Schritt zu verhindern und später weitere Daten nachzuschlagen. Zusätzlich verwendete ich Features, um meine Feeds und Tampers zwischen Entwicklungs- und Staging-Websites hin- und herzuschieben.

Grundlegendes Feeds-Setup

Da ich meine eigenen Dateien zum exportieren hatte, verwendete ich keine der eingeplanten oder der online Pull-Settings. Dies sind meine grundlegenden Feeds-Einstellungen:

  • Attached to: [none]
  • Periodic import: off
  • Import on submission
  • Fetcher: File upload
    • Upload directory: private://feeds
  • Parser: CSV parser
    • No headers: checkbox (Achtung, der „No Headers“-Weg kann ganz schön anstrengend sein)
  • Processor: Node Processor
    • Bundle: ”Desired content type here”
  • Update existing nodes: update existing nodes (dies funktioniert nur mit einem Unique Identifier und/ oder mit mehreren Imports)
    • Text format: Plain text (kommt drauf an, was importiert wird, Markdown funktioniert gut mit altem HTML)
    • Author: Ich wählte hier meinen Chefredakteur
    • Authorize: Dies spielt bei einem einmaligen Import keine Rolle
    • Expire nodes: NIE! (ausser, Sie wollen das die Daten ab und zu verschwinden)
  • Mappings - Darauf gehe ich weiter unten noch genauer ein

Bevor ich mit dem Preprocessing begann, war die CSV-Parser-Einstellung auf „No Headers“ gesetzt, sodass ich für die Spalten-Indexe verwenden musste, um mich auf die Daten zu beziehen. Mit mehr als 80 Spalten und wechselnder Reihenfolge zwischen verschiedenen Versionen der Datei erwies sich dies als äusserst unpraktisch. Dank dem Preprocessing konnte ich eigene Header hinzufügen. Es ist viel schneller, den Spalten-Index in einer Datei zum Header-Namen zu ändern als dies mit Feeds zu tun, denn diese können nicht bearbeitet werden. Man müsste die Mappings löschen und neuerstellen (mit den zugehörigen Tampers), was ungefähr 3-5 Page Loads dauern kann. Mein Ratschlag ist, ein stabiles Dateiformat für den Import zu haben – es lohnt sich, auch wenn dazu ein Preprocessing-Schritt benötigt wird.

Mappings

Dies mag etwas gar düster aussehen, anzumerken ist hier, dass es zur gegebenen Zeit keine Möglichkeit gab, Einträge zu editieren. Auch ist der „delete, recreate“-Vorgang etwas langsam, es ist also Vorsicht angebracht.

Quelle: Text ist besser, doch wenn das CSV keine Header verwendet, kann es auch ein Zahlenindex sein.

Ziel: Node-Feld

Zielkonfiguration: Hier gibt es zwei verschiedene Möglichkeiten, die vom Zieltyp abhängen.

  • Für Titel „unique“ auswählen – bei ähnlichen Titeln nicht sehr hilfreich (Im Abschnitt „Feeds Tamper“ steht, wie man andere Felder als „unique“ definieren kann)
  • Für Taxonomies können Sie den Typ „search“ auswählen - dies ist sehr wichtig, denn es ermöglicht:
    • Konfigurieren des Search-Typs – so kann ein Taxonomy-Begriff mit einem übereinstimmenden Namen gefunden werden
    • Auto create – erstellt den Taxonomy-Begriff, falls dieser nicht gefunden wird, was hilfreich ist bei Daten-Imports, wo noch nicht alle Begriffe vorhanden sind

Dieser Screen mag ein wenig unflexibel erscheinen, doch wir werden dies mit Feeds Tamper wieder wett machen – lesen Sie also weiter!

Feeds Tamper

Feeds Tamper ermöglicht es, Felder nach dem Import jedoch noch vor der Speicherung der Nodes zu manipulieren. Dies kann mangelndes Preprocessing wettmachen und es können Aufgaben erledigt werden, die während dem Preprocessing nicht möglich sind, weil dazu Daten benötigt werden, die nur in Drupal verfügbar sind. Insbesondere beim Umgang mit relationalen Daten ist Feeds Tamper essentiell. Da ich relationale Daten jedoch im nächsten Artikel besprechen werde, betrachten wir nun erst einmal den Rest.

Auf dem Tamper-Screen sehen wir eine Liste aller Felder, die importiert werden, und können Tamper-Plugins zu jedem einzelnen hinzufügen. Betrachten wir einige Beispiele, wie dies verwendet werden kann. Extrem wichtig ist dabei, nicht zu vergessen, dass Tampers vom Mapping abhängig sind – löschen Sie das Mapping, so löschen Sie zugleich auch die Tampers, und wird ein Mapping erstellt, so müssen Tampers manuell wieder hinzugefügt werden.

Die Node-Sprache festlegen

In meinen ersten Import-Schritten enthielt jede Import-Reihe Felder beider Sprachen. Importiert wurde mittels zwei separater Feeds-Jobs, welche die Felder jeweils einer Sprache importierten. Um die Node-Sprache zu festzulegen, verwendete ich folgenden Trick:

  1. Setzen Sie ein zusätzliches Mapping mit einem Headernamen/ Index der nicht verwendet wird und der Sprache als Ziel (so dass der Wert leer sein wird)
  2. Fügen Sie in Tamper ein Set default value-Plugin hinzu
  3. Setzen Sie den Default value auf en oder de (für jeden Feeds-Jobs entsprechend, andere Sprachen sind natürlich auch möglich)

Dies wird versuchen, ein nicht-existentes Feld zu importieren, das entsprechend leer bleibt. Anschliessend, bevor das Feld gespeichert wird, wird Tamper es automatisch zur gewünschten Sprache aktualisieren. Nachdem ich das Preprocessing hinzufügte, enthielt jede Reihe nur noch eine Sprache, und beinhaltete das Sprachfeld in der Import-Datei, sodass ich diesen Trick nicht mehr benötigte. Nichtsdestotrotz kann er gelegentlich ganz nützlich sein.

Zeilenumbrüche

Einige Datenbanken verwenden andere Zeichen als Zeilenumbrüche, um Daten zu speichern oder als CSV zu exportieren (CSV hat eine spezielle Bedeutung für Zeilenumbrüche). Dies war beim Export aus FileMaker der Fall: Zeilenumbrüche waren durch Vertikal-Tabulatoren getrennt (auch bekannt als VT oder \v).

Hier kommt nun das „Find replace“-Regex-Plugin ins Spiel: Wie können unser Muster hinzufügen hinzufügen und es beispielsweise durch \n ersetzen. Ich hatte einige Mühe damit, die Regex in Tamper korrekt zum Laufen zu bringen aufgrund einiger seltsamer Zeichen, die ich ersetzen wollte. Letzten Endes ersetze ich die Zeichen in PHP mit der zufälligen Zeichenkette (MAGICAL_NEWLINE), sodass \n keine Probleme mit meiner CSV-Datei machen würde. Dann richtete ich das folgende Plugin ein:

  1. Find replace REGEX
  2. REGEX to find: /MAGICAL_NEWLINE/
  3. Replacement pattern: \n
  4. Limit number of replacements: <none>

Listen/ mehrere Werte

Was, wenn wir eine Liste von Items haben und diese in ein einziges Drupal-Feld, das mehrere Werte zulässt, importieren wollen? Dies ist durchaus möglich, wenn auch nicht in einem einzigen Schritt. Ich setze bei den vertikalen Tabs erneut meinen oben erwähnten Preprocessing-Trick ein. Somit ist der Platzhalter für meinen Separationswert MAGICAL_NEWLINE, es wäre jedoch auch möglich, Komma separiert oder mit Tabs zu arbeiten.

Dieses Mal brauchen wir zwei Plugins nacheinander:

  1. Explode plugin

    • String separator: MAGICAL_NEWLINE
    • Limit: <none>
  2. Filter empty items

    • Keine Optionen hier! (Dies ist hilfreich, um Validations-Fehlermeldungen über leere Items zu verhindern)

Nun werden mehrere Items ins Feld eingefügt. Natürlich kann man mit Tampers noch viel mehr machen, doch dies sind die hauptsächlichen Zwecke, für die ich Tampers nach dem Preprocessing einsetzte.

Der Import-Prozess

Da die Daten nun in ein privates Datei-System hochgeladen waren, konnte ich unkompliziert Dateien importieren, löschen und modifizieren, bis ich erreichte, was ich wollte. Dies funktioniert jedoch nur, wenn man auf einer Entwicklungs-Umgebung arbeitet und so Daten gegebenenfalls löschen kann. Wenn man beim Import eine unique ID setzen kann, könnten auch mehrere Imports gemacht werden. In meinem Fall war dies jedoch nicht möglich, da die zu importierenden Daten mit beiden Sprachen in der selben Reihe abgelegt waren, sodass sowohl meine EN- wie auch DE-Nodes die selbe Import Unique ID erhalten hätten. Dies ist gerade zu Beginn ein iterativer Prozess, sodass es sich lohnt, auf einer Entwicklungs-Umgebung zu arbeiten, wo Daten gelöscht und der Import mit anderen Einstellungen erneut gestartet werden kann, bis man die richtige Lösung gefunden hat.

Mehrsprachige Daten importieren

Wir haben nun sowohl die Daten wie auch die Sprache in Drupal. Allerdings sind keine der Nodes mit ihren entsprechenden Übersetzungen verknüpft. Das Preprocessing der Daten um eine Sprachspalte hinzuzufügen und diese auf EN oder DE zu einzustellen ist der einfache Teil, die importierten Daten zu verlinken ist etwas schwieriger.

Nodes in Drupal werden mit der Translation ID (tnid) verknüpft. Wenn wir zwei Nodes erstellen und die beiden verknüpfen, so sieht dies ungefähr folgendermassen aus:

Nid Sprache Tnid
1 EN 1
2 DE 1

Zwei Punkte sind hier wichtig: Drupal verwendet einen Quell- und eine Übersetzungsnode. In diesem Fall ist der DE-Node mit nid 2 die Übersetzung. Sie hat eine Translation ID 1, was heisst, dass der Node ID 1 in EN der Quell-Node ist. Betrachten wir die Tabelle genauer, so fällt jedoch auf, dass nid 1 ebenfalls tnid 1 hat. Dies ist unbedingt notwendig, denn ansonsten werden die Übersetzungen im Backend nicht angezeigt, selbst wenn die zweite Sprache auf die Quelle verweist.

Ich konnte keinen guten Weg finden, dies auf einfache Weise mit Feed oder Feeds Tamper zu realisieren, doch ich fand einen nützlichen Feeds Hook: hook_feeds_after_save. Dieser wird aufgerufen, nachdem der Node gespeichert wurde, also genau zu dem Zeitpunkt, an dem wir die tnid unseren Wünschen entsprechend verändern wollen. Wir können diese Änderungen nicht in Tamper vornehmen, da der Nodes vor dem Speichern natürlich noch keine ID haben – wenn Sie also nicht wie mein Arbeitskollege Rune aus der Zukunft kommen, wird die Sache schwierig. Spass beiseite, der Sinn dieses Hooks ist, dass wir, sobald der Node erstellt ist, die tnid entweder mit der nid des Node selbst (falls es die Quell-Sprache ist) aktualisieren können oder nach dem Quell-Node suchen können, um deren nid für die tnid zu verwenden.

Bevor es aussieht, als ob ich etwas auslasse, erkläre ich kurz, wie ich dieses Nachschlagen von einer Sprache zur anderen automatisch erledige. Die nachfolgenden Code-Beispiele beziehen sich auf sn_v oder travel_offer_number, dies ist die Datenbank ID der Datenbank, aus der ich importierte. Ich weiss, dass diese für beide Sprachen die selben sind, da sie ursprünglich aus der selben Reihe stammen; dieser Prozess bedingt also, dass Sie das Sprach-Mapping bereits in irgendeiner Form vorliegend haben, selbst wenn Sie dazu zusätzliche Felder erstellen müssen, die nie im Frontend angezeigt werden. Dies könnte ebenfalls via Preprocessing generiert oder formatiert werden. Nun aber zum Code!

  1. function feeds_my_modulename_feeds_after_save(FeedsSource $source, $entity, $item, $entity_id) {
  2. // Handle Travel Offer translations
  3. if ($entity->type == 'travel_offer') {
  4. // Load the node
  5. $node = node_load(array('nid' => $entity->nid));
  6.  
  7. // EN is source language
  8. if ($entity->language == 'en') {
  9. // Edit node with translation id of self to self
  10. $node->tnid = $entity->nid;
  11. }
  12. // DE is translation so lookup source
  13. else if ($entity->language == 'de') {
  14. // Load the sn_v number from the node's fields
  15. $fields = field_get_items('node', $node, 'field_travel_offer_number');
  16. if ($fields) {
  17. $field = reset($fields);
  18. $sn_v = $field['value'];
  19. } else {
  20. // Failed cannot update this node without looking up sn_v
  21. return;
  22. }
  23.  
  24. // look up the id of the node with the same sn_v in english:
  25. $query = new EntityFieldQuery();
  26. $query->entityCondition('entity_type', 'node');
  27. $query->propertyCondition('language', 'en');
  28. $query->propertyCondition('type', 'travel_offer');
  29. $query->fieldCondition('field_travel_offer_number', 'value', $sn_v, '=');
  30. $result = $query->execute();
  31.  
  32. if ($result) {
  33. // get first match (there should only be one)
  34. $translation_source_node = reset($result['node']);
  35.  
  36. // Set the translation id
  37. $node->tnid = $translation_source_node->nid;
  38. }
  39. }
  40.  
  41. // Finally save it
  42. node_save($node);
  43. }
  44. }

Dieser Hook wird bei jedem importierten Node aufgerufen. Und das genau geschieht:

  • Voraussetzung: Jeder Node, egal ob DE oder EN, hat die selbe sn_v Nummer
  • Überprüfen des Entity-Types - Ich hatte mehrere Imports, die nicht alle mehrsprachig verlinkt werden mussten. Der Hook ist global, es liegt also an Ihnen, zu prüfen, dass er einzig bei den gewünschten Imports ausgeführt wird
  • Überprüfen der Sprache
    • Wenn die Sprache die Quell-Sprache ist
    • Anpassen der tnid zur eigenen nid
    • Wenn die Sprache die Übersetzungs-Sprache
    • Suchen des Unique Identifiers/ der Tranlsation Mapping ID
    • Damit wird die Node der anderen Sprache mit entsprechend übereinstimmendem Identifier gesucht
    • Anpassen der tnid zur nid der Quell-Node
    • Speichern der Node

Nun ist alles so aufgesetzt, wie Drupal es erwartet, und obschon nicht weiser als zuvor, kann Drupal nun Übersetzungen anzeigen.

Schlussfolgerung

Dies ist der erste Teil meines Artikels zum Import mehrsprachiger relationaler Daten in Drupal. Sie kennen nun mein Feeds-Setup und wie ich dasselbe mit Hooks erweitert habe, sodass die Node-Übersetzungen während des Imports automatisch mit ihren Quellen verknüpft werden. Wie gesagt bedingt dies, dass das Mapping von Quelle zu Übersetzung bereits vor dem Import bekannt ist. Im nächsten Artikel werden ich diese Ausführungen vertiefen und erklären, wie ich mit Entity-Referenzen den Import relationaler Daten zu Stande bringen konnte und zeigen, wie man mit Feeds Referenzen nachschlagen und automatisch importieren kann. Dies, kombiniert mit dem bereits behandelten mehrsprachigen Import, ermöglicht es, relativ komplizierte Daten-Sets in Drupal zu importieren.

* Eine Randnotiz zur Umwandlung von CSV-Kodierung: FileMaker exportiert in manchen Fällen veraltete Mac OS-Kodierungen, die nur schwer in eine andere Kodierung umgewandelt werden können. Meine Lösung ist, die CSV-Datei in Libre Office Calc als Macintosh-Kodierung zu öffnen, sie als ods zu speichern, dies wieder als CSV zu speichern und die Datei dann zu einer Standard Unicode-Kodierung zu exportieren.