Motivation
So there's a flurry of activity concerning multilingual Drupal content from D6 & D7 being properly migrated (#2208401: [META] Remaining multilingual migration paths), but I'm not seeing much if anything concerning non-Drupal imports for multilingual content. Point of fact, I'm having trouble coming up with good examples of how multilingual migrations are intended to work, let alone actual working examples using Drupal or other sources.
Proposed resolution
I'd like to use this thread to compile migration solutions or examples that successfully implement multilingual content. Ultimately, that would become a documentation page, or depending on how things go a feature request or module.
Groundwork
So since I had an immediate need and was looking for solutions I ended up finding #2313265: How import Multilingual Content with Migrate Module. While it originated Pre-D8, @digitaldonkey had a good solution for a POST_ROW_SAVE event in D8. I liked it, but it wasn't flexible enough for me to I took it and made it a little more broadly based. I updated the 'updateTranslations' function of the original:
/**
* MigrateEvents::POST_ROW_SAVE event handler.
*
* @param MigratePostRowSaveEvent $event
* Instance of Drupal\migrate\Event\MigratePostRowSaveEvent.
*/
public function updateTranslations(MigratePostRowSaveEvent $event) {
$row = $event->getRow();
if ($row->hasSourceProperty('constants/available_languages')) {
// These are defined in migration.yml.
$available_languages = $row->getSource()['constants']['available_languages'];
$default_language = $row->getDestination()['langcode'];
// Unset default language from available languages.
unset($available_languages[$default_language]);
$migrated_entity = $event->destinationIdValues[0];
$dest_config = $event->getMigration()->getDestinationConfiguration();
$dest_plugin = explode(':', $dest_config['plugin']);
if ($dest_plugin[0] == 'entity') {
$entity = \Drupal::entityTypeManager()
->getStorage($dest_plugin[1])
->load($migrated_entity);
foreach ($available_languages as $key => $lang_map) {
$translated_entity = $entity->addTranslation($key);
foreach ($lang_map as $field => $source) {
$translated_entity->$field = $row->getSourceProperty($source);
}
$translated_entity->save();
}
$map = $event->getMigration()->getIdMap();
$map->saveIdMapping($event->getRow(), array($migrated_entity));
}
}
}
What this allows you to do is declare translations in your YAML file by adding available translations as constants. So your source would look something like
source:
...
constants:
available_languages:
en:
es:
title: title_spanish
Where title
is the name of the field you're entering and title_spanish
would be the field coming in from the source. This is perfect for a lightweight translation of content from the same source. Note that it only works on entities and that the main langcode MUST be set in the process.
For bulkier translations that need processing or those coming from a different source, I put together a custom destination. This takes a separate migration and appends identically ID'd translations to it.
namespace Drupal\custom_migrate\Plugin\migrate\destination;
use Drupal\Component\Plugin\DependentPluginInterface;
use Drupal\Core\Entity\DependencyTrait;
use Drupal\migrate\Plugin\MigratePluginManager;
use Drupal\migrate\Plugin\MigrationPluginManagerInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\migrate\Plugin\MigrationInterface;
use Drupal\migrate\Row;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\migrate\Plugin\migrate\destination\DestinationBase;
/**
* Provides Configuration Management destination plugin.
*
* Persist data to the config system.
*
* When a property is NULL, the default is used unless the configuration option
* 'store null' is set to TRUE.
*
* @MigrateDestination(
* id = "entity_translation"
* )
*/
class EntityTranslation extends DestinationBase implements ContainerFactoryPluginInterface, DependentPluginInterface {
use DependencyTrait;
/**
* The config object.
*
* @var \Drupal\migrate\Plugin\MigrationInterface
*/
protected $old_migration;
/**
* The language manager.
*
* @var \Drupal\Core\Language\LanguageManagerInterface
*/
protected $language_manager;
/**
* The process plugin manager.
*
* @var \Drupal\migrate\Plugin\MigratePluginManager
*/
protected $processPluginManager;
/**
* The migration plugin manager.
*
* @var \Drupal\migrate\Plugin\MigrationPluginManagerInterface
*/
protected $migrationPluginManager;
/**
* Constructs a Config destination object.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin ID for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Drupal\migrate\Plugin\MigrationInterface $migration
* The migration entity.
* @param \Drupal\Core\Language\ConfigurableLanguageManagerInterface $language_manager
* The language manager.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration, MigrationPluginManagerInterface $migration_plugin_manager, MigratePluginManager $process_plugin_manager, LanguageManagerInterface $language_manager) {
parent::__construct($configuration, $plugin_id, $plugin_definition, $migration);
$this->migrationPluginManager = $migration_plugin_manager;
$this->migration = $migration;
$this->processPluginManager = $process_plugin_manager;
$this->language_manager = $language_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration = NULL) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$migration,
$container->get('plugin.manager.migration'),
$container->get('plugin.manager.migrate.process'),
$container->get('language_manager')
);
}
/**
* {@inheritdoc}
*/
public function import(Row $row, array $old_destination_id_values = array()) {
$migration_id = $this->configuration['migration'];
$migration = $this->migrationPluginManager->createInstance($migration_id);
// TODO: I think this only works right now if the keys and key labels match, so I'll need to expand later
$source_id_values = $row->getSourceIdValues();
$destination_ids = $migration->getIdMap()->lookupDestinationID($source_id_values);
$dest_config = $migration->getDestinationConfiguration();
$dest_plugin = explode(':', $dest_config['plugin']);
$entity = \Drupal::entityTypeManager()
->getStorage($dest_plugin[1])
->load($destination_ids[0]);
$lang = $row->getDestinationProperty('langcode');
// TODO: Validate langcode against list of site languages
$translated_entity = $entity->addTranslation($lang, $row->getDestination());
$translated_entity->save();
return $destination_ids;
}
/**
* {@inheritdoc}
*/
public function fields(MigrationInterface $migration = NULL) {
// @todo Dynamically fetch fields using Config Schema API.
}
/**
* {@inheritdoc}
*/
public function getIds() {
$ids['config_name']['type'] = 'string';
return $ids;
}
/**
* {@inheritdoc}
*/
public function calculateDependencies() {
$provider = explode('.', $this->config->getName(), 2)[0];
$this->addDependency('module', $provider);
return $this->dependencies;
}
}
It's still very rough, but once it's in place you can use it to translate entities by adding this destination:
destination:
plugin: entity_translation
migration: original_content
Where original_content
is the targeted migration. Note that it's still very rough, but is certainly a step in the right direction. Once again, it will only work for entities and the langcode needs to be set in the process.
Comment | File | Size | Author |
---|---|---|---|
#21 | core-multilingual-migrations-for-non-drupal-sources-2733431-11.patch | 977 bytes | mrtndlmt |
#10 | core-multilingual-migrations-for-non-drupal-sources-2733431-10.diff | 1.19 KB | kriboogh |
Comments
Comment #2
mikeryan@dhansen - Please see (and better yet, review!) the work in progress at #2225775: Migrate Drupal 6 core node translation to Drupal 8. Among other things, it includes destination plugin support for calling addTranslation() where applicable.
Comment #3
vasi CreditAttribution: vasi at Evolving Web commentedThere's an example in the migrate_external_translated_test module in #2225775: Migrate Drupal 6 core node translation to Drupal 8 .
But I am concerned that our translated migrations may all have the pattern of:
This is not something that will necessarily map well to other types of external content. I'm a bit concerned that we could back ourselves into a corner if we ignore this too long in our rush to get D6 -> D8 finished. Let's try to set up examples and tests early for at least a couple of other translation patterns, chosen from:
@mikeryan, which pattern is most common in your experience? What does eg: Wordpress use?
Comment #4
mikeryanI have not seen a translated WordPress site - doing a little research, it seems that WordPress has no builtin multilingual capability, there are several plugins that provide it (such as WPML - which looks like it would map well to our tnid module).
I think the main priority will be supporting field translation from D7 - I'm not sure, lacking concrete examples, it's worth trying to speculate how else translations might be represented.
Comment #5
kriboogh CreditAttribution: kriboogh at Calibrate commentedHi, not sure if it's appropriate to ask this here, but been struggling with this for days now and this is the first thread I found related to our problem. We are trying to do something like this. We have a JSON file which contains a an array of objects which describe basically a page. We map these onto a D8 page content types. It has these properties: id, translation id, title, body content, body format (which we map to html, plain text) and a language.
Ex:
This is how I configured my migration:
The source plugin is a custom extended "url" with a "http" and "json" reader and fetcher (based on the migrate wine example except we read the JSON directly from a local file in stead of a REST setup) .
With the JSON above this generates 2 node entities in D8, but how can I tell it, that the Dutch entity is a translation of the English ?
I also tried by giving the two sources the same id (so english and dutch have id=1). This generates 1 node (as it should), but it messes up the language settings of the entity. The first row (the source language), is created with a defaultLanguage=en. The moment the second row is processed however, during the processing of the langcode field, the defaultLanguage property of the entity is overwritten to 'nl' (the language of the current row). Thus not saving a translation, but overwriting the existing english settings.
If you trace this, you can see that in the content_translation.module file there is a presave hook which should normaly handle this case correctly. Doing this through the normal add content and translation UI, you can see this works. But during a migration, the moment the 2nd row source values are saved, the $source_langcode gets a 'und' and not the expected 'en' (which it does when using the translate UI).
Comment #6
mikeryanMigrating multi-lingual entities (regardless of source) will require the entity destination changes that are part of #2225775: Migrate Drupal 6 core node translation to Drupal 8.
Comment #7
mikeryanComment #8
sylus CreditAttribution: sylus commentedI eventually got this to work but required a small patch/hack to the migration process plugin. ^_^
#2767643: Scalar to array migration returns NULL
Any idea why this was needed?
Comment #10
kriboogh CreditAttribution: kriboogh at Calibrate commentedA small update on this, in case anybody is trying to do this. This is related to issue #2809315 and #2767643
Short recap:
Source is a custom database table having at least these colums: source_id, source_langcode, ...
Destination is a multilingual entity (node, term,...)
Migrations config:
The custom source implements these:
You also need to setup your migration database parameters in the settings.php file, so add an extra database entry:
Now with the fix made in #2767643, we hit an other issue. In order for translations to be linked to the source node, the process configuration for nid is setup to lookup the already imported source_id. This is done in the getEntity method of the EntityContentBase class. The parent class, Entity tries to fetch the entity id. However the id's generated when using the destination 'translatable: true' options, are arrays. So the storage->load method fails and so it is assumed a new node has to be created. This then fails later on when trying to save the new node, because of a database primary key failure (the same id already exists).
I patched the EntityContentBase class, so that when translations is enabled, it retrieves only the entity id as a singular value, and no longer a array. The patch is against 8.2.4
Comment #11
meanderix CreditAttribution: meanderix commented#2073467: Migrate Drupal 7 Entity Translation settings to Drupal 8 has been a showstopper in my migration project. The current migration process works for the default "content translation" mechanism but not for Entity Translation (i.e. field level translation). When I perform a standard D7 -> D8 migration, no translations are added and the (untranslated) fields are populated with the wrong language.
I realized that it would be rather an involved process to add support for this migration path (both field migrations and node migrations would have to be added and I'm fairly new to the Migrate API).
In the end I decided to go with a CSV export using Entity Translation Export/Import. The patch in #2839848: CSV file invalid when exporting fields from multiple bundles was required for this to work. Additionally, I made some further changes:
I created two migrations "node_sv" and "node_en" (one for each language supported by the site).
Example of the "node_sv" migration:
Here's the code used in my
EntityTranslation
destination plugin:All in all, these measures seem to provide me with an upgrade path from the field translation used in D7.
Comment #12
sylus CreditAttribution: sylus commentedFor #10 I might be wrong but as my own implementation is quite similar.
I was having a problem initially but just needed to take into account this issue #2746293: Migrate content_translation_source when migrating node translations and the introduction of content_translation_source. Maybe this will help?
Comment #13
kriboogh CreditAttribution: kriboogh at Calibrate commented@sylus can you post a (simplified) example of your migration (specially the yml and how you used the content_translation_source) ?
Comment #15
jwilson3I'm running into some issues migrating multilingual content containing shared images with different translations of the "alt" text from CSVs.
I get the standard error:
I'd appreciate feedback or any insight on what I'm doing wrong in my yaml configs here:
http://drupal.stackexchange.com/questions/229750/how-to-migrate-multilin...
Comment #16
vasi CreditAttribution: vasi at Evolving Web commentedjwilson3's problem seems to be happening because of the following odd behaviour:
It seems that if we want a sensible translation, we need to explicitly set the translation source. Is that intended? Could it be automatically set when we call addTranslation()?
Comment #18
kriboogh CreditAttribution: kriboogh at Calibrate commentedI still think that the patch I had for #10 handles the multi id's key situation. What @vasi describes in #16 is an other issue, that can be fixed by issue #2544696-#11.
Comment #21
mrtndlmt CreditAttribution: mrtndlmt at Calibrate commentedUpdate patch for drupal 8.5.6!
Comment #22
borisson_Setting to needs review so that the testbot can have a look at the latest patch (in #21)
Comment #23
borisson_The testbot agrees with the latest patch, now all we need is tests.
Comment #24
phenaproximaMarking "Needs work" for tests.
Comment #28
quietone CreditAttribution: quietone as a volunteer commentedHad a look at this to see what test is needed here. The patch applies to 9.1.x so maybe maybe it is needed but I just have questions.
Is anyone using this patch?
The doc for the parent:getEntityId() state that it returns a string not an array. Is there an entity where the Id is an array?
Comment #29
quietone CreditAttribution: quietone as a volunteer commentedSetting NR to get an answer for #28
Comment #30
mikelutzI won't close this, but this isn't something to commit to core. Core dosn't include anything for migrating from non drupal sources, so we need a good documented reason to add a method like this. I'm not sure what the intent is here exactly, I assume to provide a means to get the original entity id for a specific custom purpose, but it needs more exploration/work.
Comment #31
quietone CreditAttribution: quietone as a volunteer commentedTriaging the support request and saved this till last.
The proposed resolution in the IS is to add documentation and yet in December 2016, kriboogh added a patch (#10). The patch was last updated in August 2018 (#21) and was tagged as needs tests in September 2018. So, is this issue for documentation or a patch or both? Please update the IS with some clarification.
Can anyone confirm that that you have used the patch in this issue and it worked? If so, please provide details about the source data. As mikelutz said in #30 if the patch is to be committed "we need a good documented reason to add a method like this".
If anyone can add a link to blog about migrating from non drupal sources please do so. The search I did just now didn't find anything about nodes or anything that was using this patch.
Since this is a support request and hasn't had any progress in almost 2 years changing status to PPMNMI. As it stands now there doesn't seem to be a interest in, or perhaps need, for this anymore.
Comment #35
kriboogh CreditAttribution: kriboogh at Calibrate commentedBeen going through some of our patches we still use and came across this old one. We still use it though, to migrate translated data from csv's to nodes.
Our source data has several translations (langcode) of the same data (id), each in their own rows.
So we have:
header: id, langcode
row 1: 1, nl, ....
row 2: 1, fr, ....
...
row x: x, nl, ....
row y: x, fr, ....
The way we use the migration is we define our unique 'ids' as combination of id, langcode
So to answer #28, when you specify your source ids like this, you get an array not a string.
In our case the returned array has the id at index 0, because we configured it as the first id key in 'ids'.
Maybe the solution proposed in the patch is indeed to specific and doesn't need to be merged. We can always use a local patch.
Comment #39
mikelutzClosing this, as it's ultimately not something we want to do to in the core plugins.
I'm still not quite sure the point of the patch, as the parent getEntityId is expected to always return a string, although it's technically returning whatever is in the destination array under the entity type's id key (like nid for nodes), so I suppose you could write a migration with a process that set nid to an array and the system would just return that. But that's not how the core destination plugins are designed to be used. The core destination plugins can import multilingual data when used properly, and they are indifferent to the source and processes used, provided the end result is a destination array in the format the destination plugins are expecting.
So in general, I would say if you are using the core plugins with custom sources and migrations, you are responsible for confirming the ultimate output of that into the format needed for the core destinations. If your source data isn't conducive to doing that easily and requires custom destination processing as well, by all means do so in a custom destination as well.