diff --git a/README.txt b/README.txt index da936bf..dff05f1 100644 --- a/README.txt +++ b/README.txt @@ -61,7 +61,7 @@ The primary features include: APIs, but they are not needed by most sites and have no bearing on the Open Graph meta tags. -* The Pinterest meta tags may be added by enabling the "Metatag: Pinterest" +* The Pinterest meta tags may be added by enabling the "Metatag: Pinterest" submodule. * Site verification meta tags can be added, e.g. as used by the Google search @@ -231,6 +231,42 @@ type of meta tag, e.g. the generator meta tag uses the "content" attribute while the link tag uses the "href" attribute. +Migration / Upgrade from Drupal 7 +-------------------------------------------------------------------------------- +An upgrade path from Metatag on Drupal 7 is provided. + +Two migration processes are supported: + + 1. A guided migration using either the Migrate Drupal UI from core or the + Migrate Upgrade [2] contributed module. This will automatically create a + field named "field_metatag" and import any meta tag data that existed in D7. + + This is set up in metatag_migration_plugins_alter() and then leverages code + in metatag_migrate_prepare_row() and + \Drupal\metatag\Plugin\migrate\process\d7\MetatagD7Entities to do the actual + data migration. + + 2. A custom migration using Migrate Plus [2] and possibly Migrate Tools [3]. + This will require manually creating the meta tag fields and assigning a + custom process plugin as the source for its data. For example, if the name + of the field is "field_meta_tags" the lines fron the "process" section of + the migration yml file will look line the following: + +....................................... +process: + field_metatag: + plugin: metatag_d7_entities + source: pseudo_metatag_d7_entities +....................................... + + The important items are the plugin "metatag_d7_entities" and the source + value of "pseudo_metatag_d7_entities", if these are not present the + migration will not work as expected. + + This is handled by metatag_migrate_prepare_row() and + \Drupal\metatag\Plugin\migrate\process\d7\MetatagD7Entities. + + DrupalConsole integration -------------------------------------------------------------------------------- Using the DrupalConsole, it is possible to generate new meta tags, either for diff --git a/metatag.api.php b/metatag.api.php index cd2ad01..86041f4 100644 --- a/metatag.api.php +++ b/metatag.api.php @@ -57,3 +57,14 @@ function hook_metatags_attachments_alter(array &$metatag_attachments) { } } } + +/** + * Allow the list of Metatag D7's tags to be changed. + * + * @param array $tags_map + * An array of D7 tag names mapped against the D8 tag's IDs. + */ +function hook_metatag_migrate_tags_map_alter($tags_map) { + // This tag was renamed in D8. + $tags_map['custom:tag'] = 'custom_tag'; +} diff --git a/metatag.module b/metatag.module index 243a1f5..41cc037 100644 --- a/metatag.module +++ b/metatag.module @@ -5,6 +5,8 @@ * Contains metatag.module. */ +use Drupal\Component\Plugin\Factory\DefaultFactory; +use Drupal\Component\Utility\Html; use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeInterface; @@ -13,8 +15,15 @@ use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Routing\RouteMatchInterface; use Drupal\Core\Url; +use Drupal\migrate\Plugin\migrate\destination\EntityContentBase; +use Drupal\migrate\Plugin\MigrateSourceInterface; +use Drupal\migrate\Plugin\MigrationInterface; +use Drupal\migrate\Row; +use Drupal\node\Plugin\migrate\source\d7\Node; +use Drupal\taxonomy\Plugin\migrate\source\d7\Term; use Drupal\taxonomy\TermInterface; -use Drupal\Component\Utility\Html; +use Drupal\user\Plugin\migrate\source\d7\User; +use Drupal\metatag\MigrateHelper; /** * Implements hook_help(). @@ -614,3 +623,154 @@ function metatag_generate_entity_metatags($entity) { } return $values; } + +/** + * Implements hook_migrate_prepare_row(). + */ +function metatag_migrate_prepare_row(Row $row, MigrateSourceInterface $source, MigrationInterface $migration) { + // If there's no 'metatag' table in the source, there's no point in + // continuing. + if (!method_exists($source, 'getDatabase') + || !$source->getDatabase()->schema()->tableExists('metatag')) { + return; + } + + // @todo Write a more general version rather than hard-coded. + // Support a know subset of D7 sources. + if (is_a($source, Node::class)) { + // E.g. d7_node, d7_node_revision. + $source_type = 'node'; + } + elseif (is_a($source, Term::class)) { + // E.g. d7_taxonomy_term. + $source_type = 'taxonomy'; + } + elseif (is_a($source, User::class)) { + // E.g. d7_user. + $source_type = 'user'; + } + else { + // Not supported now, nothing to do. + return; + } + + if ($migration->getDestinationPlugin() instanceof EntityContentBase) { + $entity_type = NULL; + $entity_id = NULL; + $revision_id = NULL; + + // @todo Write a more general version rather than a switch statement. + switch ($source_type) { + case 'node': + $entity_type = 'node'; + $entity_id = $row->getSourceProperty('nid'); + $revision_id = $row->getSourceProperty('vid'); + break; + + case 'taxonomy': + $entity_type = 'taxonomy_term'; + $entity_id = $row->getSourceProperty('tid'); + break; + + case 'user': + $entity_type = 'user'; + $entity_id = $row->getSourceProperty('uid'); + break; + } + + /** @var \Drupal\migrate\Plugin\migrate\source\SqlBase $source */ + /** @var \Drupal\Core\Database\Query\SelectInterface $query */ + $query = $source->getDatabase()->select('metatag', 'm') + ->fields('m', ['data']) + ->condition('entity_type', $entity_type) + ->condition('entity_id', $entity_id); + if (!is_null($revision_id)) { + if ($source->getDatabase()->schema()->fieldExists('metatag', 'revision_id')) { + $query->condition('revision_id', $revision_id); + } + } + $value = $query->execute()->fetchCol(); + if (!empty($value) && is_array($value)) { + $value = array_pop($value); + } + + $row->setSourceProperty('pseudo_metatag_d7_entities', $value); + } +} + +/** + * Implements hook_migration_plugins_alter(). + */ +function metatag_migration_plugins_alter(array &$migrations) { + // This is used for guided migrations from Drupal 7 using either core's + // Migrate Drupal UI or the Migrate Upgrade contributed module. It will + // automatically create a field named "field_metatag" with the per-entity + // meta tag overrides for each entity. + // + // @see metatag_migrate_prepare_row() + // @see Drupal\metatag\Plugin\migrate\process\d7\MetatagD7 + // + // @todo Consider loading the relevant variables to determine which entities + // should be given the Metatag field. + // @todo Document how to change the field name. + foreach ($migrations as &$migration) { + if (isset($migration['destination']['plugin'])) { + // Follow logic on hook_entity_base_field_info() and exclude the metatag + // entity itself, plus some others. + $destinations_to_ignore = [ + 'entity:metatag', + 'color', + 'component_entity_display', + 'component_entity_form_display', + 'config', + 'd7_theme_settings', + 'entity:base_field_override', + 'entity:block', + 'entity:block_content', + 'entity:block_content_type', + 'entity:comment', + 'entity:comment_type', + 'entity:contact_form', + 'entity:date_format', + 'entity:entity_view_mode', + 'entity:field_config', + 'entity:field_storage_config', + 'entity:filter_format', + 'entity:image_style', + 'entity:menu', + 'entity:menu_link_content', + 'entity:node_type', + 'entity:rdf_mapping', + 'entity:shortcut', + 'entity:shortcut_set', + 'entity:taxonomy_vocabulary', + 'entity:user_role', + 'shortcut_set_users', + 'url_alias', + 'user_data', + ]; + if (in_array($migration['destination']['plugin'], $destinations_to_ignore)) { + continue; + } + + // Load the destination plugin. + $plugin_definition = \Drupal::service('plugin.manager.migrate.destination') + ->getDefinition($migration['destination']['plugin']); + $destination_plugin = DefaultFactory::getPluginClass($migration['destination']['plugin'], $plugin_definition); + + if (is_subclass_of($destination_plugin, EntityContentBase::class) || $destination_plugin == EntityContentBase::class) { + // Metatag-D7's data is handled via a custom process plugin that does + // the data conversion. + $migration['process']['field_metatag'] = [ + 'plugin' => 'metatag_d7_entities', + 'source' => 'pseudo_metatag_d7_entities', + ]; + + // List dependencies here so that they are processed first, otherwise + // the destination field won't be available for the data to go into. + $migration['migration_dependencies']['optional'][] = 'd7_metatag_field'; + $migration['migration_dependencies']['optional'][] = 'd7_metatag_field_instance'; + } + } + } +} diff --git a/migrations/d7_metatag_field.yml b/migrations/d7_metatag_field.yml new file mode 100644 index 0000000..0b6f0cd --- /dev/null +++ b/migrations/d7_metatag_field.yml @@ -0,0 +1,20 @@ +id: d7_metatag_field +label: Metatag field +migration_tags: + - Drupal 7 +source: + plugin: d7_metatag_field + ignore_map: true + constants: + status: true + langcode: und + field_name: field_metatag + type: metatag +process: + entity_type: entity_type + status: 'constants/status' + langcode: 'constants/langcode' + field_name: 'constants/field_name' + type: 'constants/type' +destination: + plugin: entity:field_storage_config diff --git a/migrations/d7_metatag_field_instance.yml b/migrations/d7_metatag_field_instance.yml new file mode 100644 index 0000000..f3bf586 --- /dev/null +++ b/migrations/d7_metatag_field_instance.yml @@ -0,0 +1,23 @@ +id: d7_metatag_field_instance +label: Metatag field instance +migration_tags: + - Drupal 7 +source: + plugin: d7_metatag_field_instance + source_module: metatag + ignore_map: true + constants: + field_name: field_metatag + label: Metatags +process: + entity_type: entity_type + field_name: 'constants/field_name' + bundle: bundle + label: 'constants/label' +destination: + plugin: entity:field_config +migration_dependencies: + required: + - d7_metatag_field + - d7_node_type + - d7_taxonomy_vocabulary diff --git a/migrations/d7_metatag_field_instance_widget_settings.yml b/migrations/d7_metatag_field_instance_widget_settings.yml new file mode 100644 index 0000000..2d9a5a2 --- /dev/null +++ b/migrations/d7_metatag_field_instance_widget_settings.yml @@ -0,0 +1,21 @@ +id: d7_metatag_field_instance_widget_settings +label: Metatag field instance widget settings +migration_tags: + - Drupal 7 +source: + plugin: d7_metatag_field_instance + source_module: metatag + ignore_map: true + constants: + form_mode: default + field_name: field_metatag +process: + bundle: bundle + form_mode: 'constants/form_mode' + field_name: 'constants/field_name' + entity_type: entity_type +destination: + plugin: component_entity_form_display +migration_dependencies: + required: + - d7_metatag_field_instance diff --git a/src/Plugin/migrate/process/d7/MetatagD7Entities.php b/src/Plugin/migrate/process/d7/MetatagD7Entities.php new file mode 100644 index 0000000..abab2e7 --- /dev/null +++ b/src/Plugin/migrate/process/d7/MetatagD7Entities.php @@ -0,0 +1,425 @@ +tagsMap(); + + $metatags = []; + + // Re-shape D7 entries into for D8 entries. + try { + $old_tags = unserialize($value); + } + catch (\Exception $e) { + throw new MigrateException('Data from Metatag was not a serialized array;' . $e->getMessage()); + } + + foreach ($old_tags as $d7_metatag_name => $data) { + // If there's no data for this tag, ignore everything. + if (empty($data)) { + continue; + } + + // @todo Skip these values for now, maybe some version supported these? + if (!is_array($data) || empty($data['value'])) { + continue; + } + + // Convert the D7 meta tag name to the D8 equivalent. If this meta tag + // is not recognized, skip it. + if (empty([$d7_metatag_name])) { + continue; + } + + // There's a D8 equivalent for this meta tag. + $d8_metatag_name = $tags_map[$d7_metatag_name]; + + // Convert the nested arrays to a flat structure. + if (is_array($data['value'])) { + // Remove empty values. + $data['value'] = array_filter($data['value']); + // Convert the array into a comma-separated list. + $data = implode(', ', $data['value']); + } + else { + $data = $data['value']; + } + + // Keep the entire data structure. + $metatags[$d8_metatag_name] = $data; + } + + return serialize($metatags); + } + + /** + * Match Metatag-D7 meta tags with their D8 counterparts. + * + * @return array + * An array of D7 tags to their D8 counterparts. + */ + public function tagsMap() { + $map = [ + // From the main Metatag module. + 'abstract' => 'abstract', + // @todo 'cache-control' => '', + 'canonical' => 'canonical_url', + 'content-language' => 'content_language', + 'description' => 'description', + // @todo 'expires' => '', + 'generator' => 'generator', + 'geo.placename' => 'geo_placename', + 'geo.position' => 'geo_position', + 'geo.region' => 'geo_region', + 'icbm' => 'icbm', + 'image_src' => 'image_src', + 'keywords' => 'keywords', + 'news_keywords' => 'news_keywords', + // @todo 'next' => '', + 'original-source' => 'original_source', + // @todo 'pragma' => '', + // @todo 'prev' => '', + 'rating' => 'rating', + 'referrer' => 'referrer', + // @todo 'refresh' => '', + // @todo 'revisit-after' => '', + 'rights' => 'rights', + 'robots' => 'robots', + 'set_cookie' => 'set_cookie', + 'shortlink' => 'shortlink', + 'standout' => 'standout', + 'title' => 'title', + // From metatag_app_links.metatag.inc: + 'al:android:app_name' => 'al_android_app_name', + 'al:android:class' => 'al_android_class', + 'al:android:package' => 'al_android_package', + 'al:android:url' => 'al_android_url', + 'al:ios:app_name' => 'al_ios_app_name', + 'al:ios:app_store_id' => 'al_ios_app_store_id', + 'al:ios:url' => 'al_ios_url', + 'al:ipad:app_name' => 'al_ipad_app_name', + 'al:ipad:app_store_id' => 'al_ipad_app_store_id', + 'al:ipad:url' => 'al_ipad_url', + 'al:iphone:app_name' => 'al_iphone_app_name', + 'al:iphone:app_store_id' => 'al_iphone_app_store_id', + 'al:iphone:url' => 'al_iphone_url', + 'al:web:should_fallback' => 'al_web_should_fallback', + 'al:web:url' => 'al_web_url', + 'al:windows:app_id' => 'al_windows_app_id', + 'al:windows:app_name' => 'al_windows_app_name', + 'al:windows:url' => 'al_windows_url', + 'al:windows_phone:app_id' => 'al_windows_phone_app_id', + 'al:windows_phone:app_name' => 'al_windows_phone_app_name', + 'al:windows_phone:url' => 'al_windows_phone_url', + 'al:windows_universal:app_id' => 'al_windows_universal_app_id', + 'al:windows_universal:app_name' => 'al_windows_universal_app_name', + 'al:windows_universal:url' => 'al_windows_universal_url', + // From metatag_dc.metatag.inc: + 'dcterms.contributor' => 'dcterms_contributor', + 'dcterms.coverage' => 'dcterms_coverage', + 'dcterms.creator' => 'dcterms_creator', + 'dcterms.date' => 'dcterms_date', + 'dcterms.description' => 'dcterms_description', + 'dcterms.format' => 'dcterms_format', + 'dcterms.identifier' => 'dcterms_identifier', + 'dcterms.language' => 'dcterms_language', + 'dcterms.publisher' => 'dcterms_publisher', + 'dcterms.relation' => 'dcterms_relation', + 'dcterms.rights' => 'dcterms_rights', + 'dcterms.source' => 'dcterms_source', + 'dcterms.subject' => 'dcterms_subject', + 'dcterms.title' => 'dcterms_title', + 'dcterms.type' => 'dcterms_type', + // From metatag_dc_advanced.metatag.inc: + 'dcterms.abstract' => 'dcterms_abstract', + 'dcterms.accessRights' => 'dcterms_access_rights', + 'dcterms.accrualMethod' => 'dcterms_accrual_method', + 'dcterms.accrualPeriodicity' => 'dcterms_accrual_periodicity', + 'dcterms.accrualPolicy' => 'dcterms_accrual_policy', + 'dcterms.alternative' => 'dcterms_alternative', + 'dcterms.audience' => 'dcterms_audience', + 'dcterms.available' => 'dcterms_available', + 'dcterms.bibliographicCitation' => 'dcterms_bibliographic_citation', + 'dcterms.conformsTo' => 'dcterms_conforms_to', + 'dcterms.created' => 'dcterms_created', + 'dcterms.dateAccepted' => 'dcterms_date_accepted', + 'dcterms.dateCopyrighted' => 'dcterms_date_copyrighted', + 'dcterms.dateSubmitted' => 'dcterms_date_submitted', + 'dcterms.educationLevel' => 'dcterms_education_level', + 'dcterms.extent' => 'dcterms_extent', + 'dcterms.hasFormat' => 'dcterms_has_format', + 'dcterms.hasPart' => 'dcterms_has_part', + 'dcterms.hasVersion' => 'dcterms_has_version', + 'dcterms.instructionalMethod' => 'dcterms_instructional_method', + 'dcterms.isFormatOf' => 'dcterms_is_format_of', + 'dcterms.isPartOf' => 'dcterms_is_part_of', + 'dcterms.isReferencedBy' => 'dcterms_is_referenced_by', + 'dcterms.isReplacedBy' => 'dcterms_is_replaced_by', + 'dcterms.isRequiredBy' => 'dcterms_is_required_by', + 'dcterms.issued' => 'dcterms_issued', + 'dcterms.isVersionOf' => 'dcterms_is_version_of', + 'dcterms.license' => 'dcterms_license', + 'dcterms.mediator' => 'dcterms_mediator', + 'dcterms.medium' => 'dcterms_medium', + 'dcterms.modified' => 'dcterms_modified', + 'dcterms.provenance' => 'dcterms_provenance', + 'dcterms.references' => 'dcterms_references', + 'dcterms.replaces' => 'dcterms_replaces', + 'dcterms.requires' => 'dcterms_requires', + 'dcterms.rightsHolder' => 'dcterms_rights_holder', + 'dcterms.spatial' => 'dcterms_spatial', + 'dcterms.tableOfContents' => 'dcterms_table_of_contents', + 'dcterms.temporal' => 'dcterms_temporal', + 'dcterms.valid' => 'dcterms_valid', + // From metatag_facebook.metatag.inc: + 'fb:admins' => 'fb_admins', + 'fb:app_id' => 'fb_app_id', + 'fb:pages' => 'fb_pages', + // From metatag_favicons.metatag.inc: + 'apple-touch-icon' => 'apple_touch_icon', + 'apple-touch-icon-precomposed' => 'apple_touch_icon_precomposed', + 'apple-touch-icon-precomposed_114x114' => 'apple_touch_icon_precomposed_114x114', + 'apple-touch-icon-precomposed_120x120' => 'apple_touch_icon_precomposed_120x120', + 'apple-touch-icon-precomposed_144x144' => 'apple_touch_icon_precomposed_144x144', + 'apple-touch-icon-precomposed_152x152' => 'apple_touch_icon_precomposed_152x152', + 'apple-touch-icon-precomposed_180x180' => 'apple_touch_icon_precomposed_180x180', + 'apple-touch-icon-precomposed_72x72' => 'apple_touch_icon_precomposed_72x72', + 'apple-touch-icon-precomposed_76x76' => 'apple_touch_icon_precomposed_76x76', + 'apple-touch-icon_114x114' => 'apple_touch_icon_114x114', + 'apple-touch-icon_120x120' => 'apple_touch_icon_120x120', + 'apple-touch-icon_144x144' => 'apple_touch_icon_144x144', + 'apple-touch-icon_152x152' => 'apple_touch_icon_152x152', + 'apple-touch-icon_180x180' => 'apple_touch_icon_180x180', + 'apple-touch-icon_72x72' => 'apple_touch_icon_72x72', + 'apple-touch-icon_76x76' => 'apple_touch_icon_76x76', + 'icon_16x16' => 'icon_16x16', + 'icon_192x192' => 'icon_192x192', + 'icon_32x32' => 'icon_32x32', + 'icon_96x96' => 'icon_96x96', + 'mask-icon' => 'mask-icon', + 'shortcut icon' => 'shortcut_icon', + // From metatag_google_cse.metatag.inc: + 'audience' => 'audience', + 'department' => 'department', + 'doc_status' => 'doc_status', + 'google_rating' => 'google_rating', + 'thumbnail' => 'thumbnail', + // From metatag_google_plus.metatag.inc; not doing these, Google+ closed. + 'itemtype' => '', + 'itemprop:name' => '', + 'itemprop:description' => '', + 'itemprop:image' => '', + 'author' => '', + 'publisher' => '', + // From metatag_hreflang.metatag.inc: + 'hreflang_xdefault' => 'hreflang_xdefault', + // @todo 'hreflang_' . $langcode => 'hreflang_per_language', + // From metatag_mobile.metatag.inc: + // @todo 'alternate_handheld' => '', + // @todo 'amphtml' => '', + 'android-app-link-alternative' => 'android_app_link_alternative', + 'android-manifest' => 'android_manifest', + 'apple-itunes-app' => 'apple_itunes_app', + 'apple-mobile-web-app-capable' => 'apple_mobile_web_app_capable', + 'apple-mobile-web-app-status-bar-style' => 'apple_mobile_web_app_status_bar_style', + 'apple-mobile-web-app-title' => 'apple_mobile_web_app_title', + 'application-name' => 'application_name', + 'cleartype' => 'cleartype', + 'format-detection' => 'format_detection', + 'HandheldFriendly' => 'handheldfriendly', + 'ios-app-link-alternative' => 'ios_app_link_alternative', + 'MobileOptimized' => 'mobileoptimized', + 'msapplication-allowDomainApiCalls' => 'msapplication_allowDomainApiCalls', + 'msapplication-allowDomainMetaTags' => 'msapplication_allowDomainMetaTags', + 'msapplication-badge' => 'msapplication_badge', + 'msapplication-config' => 'msapplication_config', + 'msapplication-navbutton-color' => 'msapplication_navbutton_color', + 'msapplication-notification' => 'msapplication_notification', + 'msapplication-square150x150logo' => 'msapplication_square150x150logo', + 'msapplication-square310x310logo' => 'msapplication_square310x310logo', + 'msapplication-square70x70logo' => 'msapplication_square70x70logo', + 'msapplication-starturl' => 'msapplication_starturl', + 'msapplication-task' => 'msapplication_task', + 'msapplication-task-separator' => 'msapplication_task_separator', + 'msapplication-tilecolor' => 'msapplication_tilecolor', + 'msapplication-tileimage' => 'msapplication_tileimage', + 'msapplication-tooltip' => 'msapplication_tooltip', + 'msapplication-wide310x150logo' => 'msapplication_wide310x150logo', + 'msapplication-window' => 'msapplication_window', + 'theme-color' => 'theme_color', + 'viewport' => 'viewport', + 'x-ua-compatible' => 'x_ua_compatible', + // From metatag_opengraph.metatag.inc: + 'article:author' => 'article_author', + 'article:expiration_time' => 'article_expiration_time', + 'article:modified_time' => 'article_modified_time', + 'article:published_time' => 'article_published_time', + 'article:publisher' => 'article_publisher', + 'article:section' => 'article_section', + 'article:tag' => 'article_tag', + 'book:author' => 'book_author', + 'book:isbn' => 'book_isbn', + 'book:release_date' => 'book_release_date', + 'book:tag' => 'book_tag', + // @todo 'og:audio' => '', + // @todo 'og:audio:secure_url' => '', + // @todo 'og:audio:type' => '', + 'og:country_name' => 'og_country_name', + 'og:description' => 'og_description', + 'og:determiner' => 'og_determiner', + 'og:email' => 'og_email', + 'og:fax_number' => 'og_fax_number', + 'og:image' => 'og_image', + // @todo '' => 'og_image_alt', + 'og:image:height' => 'og_image_height', + 'og:image:secure_url' => 'og_image_secure_url', + 'og:image:type' => 'og_image_type', + 'og:image:url' => 'og_image_url', + 'og:image:width' => 'og_image_width', + 'og:latitude' => 'og_latitude', + 'og:locale' => 'og_locale', + 'og:locale:alternate' => 'og_locale_alternative', + 'og:locality' => 'og_locality', + 'og:longitude' => 'og_longitude', + 'og:phone_number' => 'og_phone_number', + 'og:postal_code' => 'og_postal_code', + 'og:region' => 'og_region', + 'og:see_also' => 'og_see_also', + 'og:site_name' => 'og_site_name', + 'og:street_address' => 'og_street_address', + 'og:title' => 'og_title', + 'og:type' => 'og_type', + 'og:updated_time' => 'og_updated_time', + 'og:url' => 'og_url', + // @todo '' => 'og_video', + 'og:video:height' => 'og_video_height', + 'og:video:secure_url' => 'og_video_secure_url', + 'og:video:type' => 'og_video_type', + 'og:video:url' => 'og_video_url', + 'og:video:width' => 'og_video_width', + // @todo 'profile:first_name' => '', + // @todo 'profile:gender' => '', + // @todo 'profile:last_name' => '', + // @todo 'profile:username' => '', + // @todo 'video:actor' => '', + // @todo 'video:actor:role' => '', + // @todo 'video:director' => '', + // @todo 'video:duration' => '', + // @todo 'video:release_date' => '', + // @todo 'video:series' => '', + // @todo 'video:tag' => '', + // @todo 'video:writer' => '', + // From metatag_opengraph_products.metatag.inc: + 'product:price:amount' => 'product_price_amount', + 'product:price:currency' => 'product_price_currency', + // @todo 'product:availability' => '', + // @todo 'product:brand' => '', + // @todo 'product:upc' => '', + // @todo 'product:ean' => '', + // @todo 'product:isbn' => '', + // @todo 'product:plural_title' => '', + // @todo 'product:retailer' => '', + // @todo 'product:retailer_title' => '', + // @todo 'product:retailer_part_no' => '', + // @todo 'product:mfr_part_no' => '', + // @todo 'product:size' => '', + // @todo 'product:product_link' => '', + // @todo 'product:category' => '', + // @todo 'product:color' => '', + // @todo 'product:material' => '', + // @todo 'product:pattern' => '', + // @todo 'product:shipping_cost:amount' => '', + // @todo 'product:shipping_cost:currency' => '', + // @todo 'product:weight:value' => '', + // @todo 'product:weight:units' => '', + // @todo 'product:shipping_weight:value' => '', + // @todo 'product:shipping_weight:units' => '', + // @todo 'product:expiration_time' => '', + // @todo 'product:condition' => '', + // Pinterest. + // @todo '' => 'pinterest_id', + // @todo '' => 'pinterest_description', + // @todo '' => 'pinterest_nohover', + // @todo '' => 'pinterest_url', + // @todo '' => 'pinterest_media', + // @todo '' => 'pinterest_nopin', + // @todo '' => 'pinterest_nosearch', + // From metatag_twitter_cards.metatag.inc: + 'twitter:app:country' => 'twitter_cards_app_store_country', + 'twitter:app:id:googleplay' => 'twitter_cards_app_id_googleplay', + 'twitter:app:id:ipad' => 'twitter_cards_app_id_ipad', + 'twitter:app:id:iphone' => 'twitter_cards_app_id_iphone', + 'twitter:app:name:googleplay' => 'twitter_cards_app_name_googleplay', + 'twitter:app:name:ipad' => 'twitter_cards_app_name_ipad', + 'twitter:app:name:iphone' => 'twitter_cards_app_name_iphone', + 'twitter:app:url:googleplay' => 'twitter_cards_app_url_googleplay', + 'twitter:app:url:ipad' => 'twitter_cards_app_url_ipad', + 'twitter:app:url:iphone' => 'twitter_cards_app_url_iphone', + 'twitter:card' => 'twitter_cards_type', + 'twitter:creator' => 'twitter_cards_creator', + 'twitter:creator:id' => 'twitter_cards_creator_id', + 'twitter:data1' => 'twitter_cards_data1', + 'twitter:data2' => 'twitter_cards_data2', + 'twitter:description' => 'twitter_cards_description', + 'twitter:dnt' => 'twitter_cards_donottrack', + 'twitter:image' => 'twitter_cards_image', + 'twitter:image0' => 'twitter_cards_gallery_image0', + 'twitter:image1' => 'twitter_cards_gallery_image1', + 'twitter:image2' => 'twitter_cards_gallery_image2', + 'twitter:image3' => 'twitter_cards_gallery_image3', + 'twitter:image:alt' => 'twitter_cards_image_alt', + 'twitter:image:height' => 'twitter_cards_image_height', + 'twitter:image:width' => 'twitter_cards_image_width', + 'twitter:label1' => 'twitter_cards_label1', + 'twitter:label2' => 'twitter_cards_label2', + 'twitter:player' => 'twitter_cards_player', + 'twitter:player:height' => 'twitter_cards_player_height', + 'twitter:player:stream' => 'twitter_cards_player_stream', + 'twitter:player:stream:content_type' => 'twitter_cards_player_stream_content_type', + 'twitter:player:width' => 'twitter_cards_player_width', + 'twitter:site' => 'twitter_cards_site', + 'twitter:site:id' => 'twitter_cards_site_id', + 'twitter:title' => 'twitter_cards_title', + 'twitter:url' => 'twitter_cards_page_url', + // From metatag_verification.metatag.inc: + 'baidu-site-verification' => 'baidu', + 'google-site-verification' => 'bing', + 'msvalidate.01' => 'google', + 'norton-safeweb-site-verification' => 'norton_safe_web', + 'p:domain_verify' => 'pinterest', + // @todo '' => 'pocket', + 'yandex-verification' => 'yandex', + ]; + + // Trigger hook_metatag_migrate_tags_map_alter(). + // Allow modules to override tags or the entity used for token replacements. + \Drupal::service('module_handler')->alter('metatag_migrate_tags_map', $map); + + return $map; + } + +} diff --git a/src/Plugin/migrate/source/d7/MetatagField.php b/src/Plugin/migrate/source/d7/MetatagField.php new file mode 100644 index 0000000..305f239 --- /dev/null +++ b/src/Plugin/migrate/source/d7/MetatagField.php @@ -0,0 +1,42 @@ +select('metatag', 'm') + ->fields('m', ['entity_type']) + ->groupBy('entity_type'); + } + + /** + * {@inheritdoc} + */ + public function fields() { + $fields = ['entity_type' => $this->t('Entity type')]; + return $fields; + } + + /** + * {@inheritdoc} + */ + public function getIds() { + $ids['entity_type']['type'] = 'string'; + return $ids; + } + +} diff --git a/src/Plugin/migrate/source/d7/MetatagFieldInstance.php b/src/Plugin/migrate/source/d7/MetatagFieldInstance.php new file mode 100644 index 0000000..8b4f2eb --- /dev/null +++ b/src/Plugin/migrate/source/d7/MetatagFieldInstance.php @@ -0,0 +1,94 @@ +entityTypeBundleInfo = $entity_type_bundle_info; + } + + /** + * {@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('state'), + $container->get('entity.manager'), + $container->get('entity_type.bundle.info') + ); + } + + /** + * {@inheritdoc} + */ + public function query() { + return $this->select('metatag', 'm') + ->fields('m', ['entity_type']) + ->groupBy('entity_type'); + } + + /** + * {@inheritdoc} + */ + public function fields() { + return [ + 'entity_type' => $this->t('Entity type'), + 'bundle' => $this->t('Bundle'), + ]; + } + + /** + * Returns each entity_type/bundle pair. + */ + public function initializeIterator() { + $bundles = []; + foreach (parent::initializeIterator() as $instance) { + $bundle_info = $this->entityTypeBundleInfo + ->getBundleInfo($instance['entity_type']); + foreach (array_keys($bundle_info) as $bundle) { + $bundles[] = [ + 'entity_type' => $instance['entity_type'], + 'bundle' => $bundle, + ]; + } + } + return new \ArrayIterator($bundles); + } + + /** + * {@inheritdoc} + */ + public function getIds() { + $ids['entity_type']['type'] = 'string'; + $ids['bundle']['type'] = 'string'; + return $ids; + } + +} diff --git a/tests/fixtures/drupal7.php b/tests/fixtures/drupal7.php new file mode 100644 index 0000000..ebfd3e0 --- /dev/null +++ b/tests/fixtures/drupal7.php @@ -0,0 +1,258 @@ +schema()->createTable('metatag', [ + 'fields' => [ + 'entity_type' => [ + 'type' => 'varchar', + 'not null' => TRUE, + 'length' => '32', + 'default' => '', + ], + 'entity_id' => [ + 'type' => 'int', + 'not null' => TRUE, + 'size' => 'normal', + 'default' => '0', + 'unsigned' => TRUE, + ], + 'revision_id' => [ + 'type' => 'int', + 'not null' => TRUE, + 'size' => 'normal', + 'default' => '0', + 'unsigned' => TRUE, + ], + 'language' => [ + 'type' => 'varchar', + 'not null' => TRUE, + 'length' => '32', + 'default' => '', + ], + 'data' => [ + 'type' => 'blob', + 'not null' => TRUE, + 'size' => 'big', + ], + ], + 'primary key' => [ + 'entity_type', + 'entity_id', + 'revision_id', + 'language', + ], + 'indexes' => [ + 'type_revision' => [ + 'entity_type', + 'revision_id', + ], + ], + 'mysql_character_set' => 'utf8', +]); + +$connection->insert('metatag') + ->fields([ + 'entity_type', + 'entity_id', + 'revision_id', + 'language', + 'data', + ]) + ->values([ + 'entity_type' => 'node', + 'entity_id' => '998', + 'revision_id' => '998', + 'language' => 'und', + 'data' => serialize([ + // A very basic meta tag. + 'keywords' => ['value' => 'old revision'], + // A meta tag that changed its tag name in D8. + 'canonical' => ['value' => 'the-node'], + // A meta tag with multiple values. + 'robots' => [ + 'value' => [ + 'noindex' => 'noindex', + 'nofollow' => 'nofollow', + 'index' => 0, + 'follow' => 0, + 'noarchive' => 0, + 'nosnippet' => 0, + 'noodp' => 0, + 'noydir' => 0, + 'noimageindex' => 0, + 'notranslate' => 0, + ], + ], + ]), + ]) + ->values([ + 'entity_type' => 'node', + 'entity_id' => '998', + 'revision_id' => '999', + 'language' => 'und', + 'data' => serialize([ + 'keywords' => ['value' => 'current revision'], + 'canonical' => ['value' => 'the-node'], + 'robots' => [ + 'value' => [ + 'noindex' => 'noindex', + 'nofollow' => 'nofollow', + 'index' => 0, + 'follow' => 0, + 'noarchive' => 0, + 'nosnippet' => 0, + 'noodp' => 0, + 'noydir' => 0, + 'noimageindex' => 0, + 'notranslate' => 0, + ], + ], + ]), + ]) + ->values([ + 'entity_type' => 'user', + 'entity_id' => '2', + 'revision_id' => '0', + 'language' => 'und', + 'data' => serialize([ + 'keywords' => ['value' => 'a user'], + 'canonical' => ['value' => 'the-user'], + ]), + ]) + ->values([ + 'entity_type' => 'taxonomy_term', + 'entity_id' => '152', + 'revision_id' => '0', + 'language' => 'und', + 'data' => serialize([ + 'keywords' => ['value' => 'a taxonomy'], + 'canonical' => ['value' => 'the-term'], + ]), + ]) + ->execute(); + +$connection->insert('node') + ->fields([ + 'nid', + 'vid', + 'type', + 'language', + 'title', + 'uid', + 'status', + 'created', + 'changed', + 'comment', + 'promote', + 'sticky', + 'tnid', + 'translate', + ]) + ->values([ + 'nid' => '998', + 'vid' => '999', + 'type' => 'test_content_type', + 'language' => 'en', + 'title' => 'An Edited Node', + 'uid' => '2', + 'status' => '1', + 'created' => '1421727515', + 'changed' => '1441032132', + 'comment' => '2', + 'promote' => '1', + 'sticky' => '0', + 'tnid' => '0', + 'translate' => '0', + ]) + ->execute(); + +$connection->insert('node_revision') + ->fields([ + 'nid', + 'vid', + 'uid', + 'title', + 'log', + 'timestamp', + 'status', + 'comment', + 'promote', + 'sticky', + ]) + ->values([ + 'nid' => '998', + 'vid' => '998', + 'uid' => '1', + 'title' => 'A Node', + 'log' => '', + 'timestamp' => '1441032131', + 'status' => '1', + 'comment' => '2', + 'promote' => '1', + 'sticky' => '0', + ]) + ->values([ + 'nid' => '998', + 'vid' => '999', + 'uid' => '1', + 'title' => 'An Edited Node', + 'log' => '', + 'timestamp' => '1441032132', + 'status' => '1', + 'comment' => '2', + 'promote' => '1', + 'sticky' => '0', + ]) + ->execute(); + +$connection->insert('taxonomy_term_data') + ->fields([ + 'tid', + 'vid', + 'name', + 'description', + 'format', + 'weight', + ]) + ->values([ + '152', + '1', + 'A Term', + '', + 'plain_text', + '0', + ]) + ->execute(); + +$connection->insert('system') + ->fields([ + 'filename', + 'name', + 'type', + 'owner', + 'status', + 'bootstrap', + 'schema_version', + 'weight', + 'info', + ]) + ->values([ + 'filename' => 'sites/all/modules/metatag/metatag.module', + 'name' => 'metatag', + 'type' => 'module', + 'owner' => '', + 'status' => '1', + 'bootstrap' => '0', + 'schema_version' => '7115', + 'weight' => '0', + 'info' => 'a:12:{s:4:"name";s:7:"Metatag";s:11:"description";s:47:"Adds support and an API to implement meta tags.";s:7:"package";s:3:"SEO";s:4:"core";s:3:"7.x";s:12:"dependencies";a:3:{i:0;s:23:"drupal:system (>= 7.40)";i:1;s:13:"ctools:ctools";i:2;s:11:"token:token";}s:9:"configure";s:28:"admin/config/search/metatags";s:5:"files";a:30:{i:0;s:11:"metatag.inc";i:1;s:19:"metatag.migrate.inc";i:2;s:22:"metatag.search_api.inc";i:3;s:25:"tests/metatag.helper.test";i:4;s:23:"tests/metatag.unit.test";i:5;s:30:"tests/metatag.tags_helper.test";i:6;s:23:"tests/metatag.tags.test";i:7;s:23:"tests/metatag.node.test";i:8;s:23:"tests/metatag.term.test";i:9;s:23:"tests/metatag.user.test";i:10;s:35:"tests/metatag.core_tag_removal.test";i:11;s:30:"tests/metatag.bulk_revert.test";i:12;s:34:"tests/metatag.string_handling.test";i:13;s:44:"tests/metatag.string_handling_with_i18n.test";i:14;s:22:"tests/metatag.xss.test";i:15;s:33:"tests/metatag.output_caching.test";i:16;s:24:"tests/metatag.image.test";i:17;s:25:"tests/metatag.locale.test";i:18;s:33:"tests/metatag.node.with_i18n.test";i:19;s:33:"tests/metatag.term.with_i18n.test";i:20;s:35:"tests/metatag.with_i18n_output.test";i:21;s:37:"tests/metatag.with_i18n_disabled.test";i:22;s:35:"tests/metatag.with_i18n_config.test";i:23;s:26:"tests/metatag.with_me.test";i:24;s:29:"tests/metatag.with_media.test";i:25;s:30:"tests/metatag.with_panels.test";i:26;s:32:"tests/metatag.with_profile2.test";i:27;s:34:"tests/metatag.with_search_api.test";i:28;s:44:"tests/metatag.with_workbench_moderation.test";i:29;s:29:"tests/metatag.with_views.test";}s:17:"test_dependencies";a:14:{i:0;s:11:"devel:devel";i:1;s:33:"imagecache_token:imagecache_token";i:2;s:37:"entity_translation:entity_translation";i:3;s:9:"i18n:i18n";i:4;s:5:"me:me";i:5;s:23:"file_entity:file_entity";i:6;s:27:"media:media (>= 2.0, < 3.0)";i:7;s:13:"panels:panels";i:8;s:17:"profile2:profile2";i:9;s:13:"entity:entity";i:10;s:21:"search_api:search_api";i:11;s:41:"workbench_moderation:workbench_moderation";i:12;s:11:"views:views";i:13;s:15:"context:context";}s:5:"mtime";i:1550007449;s:7:"version";N;s:3:"php";s:5:"5.2.4";s:9:"bootstrap";i:0;}', + ]) + ->execute(); diff --git a/tests/src/Kernel/Migrate/d7/MigrateMetatagTest.php b/tests/src/Kernel/Migrate/d7/MigrateMetatagTest.php new file mode 100644 index 0000000..587f554 --- /dev/null +++ b/tests/src/Kernel/Migrate/d7/MigrateMetatagTest.php @@ -0,0 +1,156 @@ +installSchema('file', ['file_usage']); + $this->installEntitySchema('file'); + $this->container->get('stream_wrapper_manager') + ->registerWrapper('public', PublicStream::class, StreamWrapperInterface::NORMAL); + + $fs = \Drupal::service('file_system'); + // The public file directory active during the test will serve as the + // root of the fictional Drupal 7 site we're migrating. + $fs->mkdir('public://sites/default/files', NULL, TRUE); + file_put_contents('public://sites/default/files/cube.jpeg', str_repeat('*', 3620)); + + /** @var \Drupal\migrate\Plugin\Migration $migration */ + $migration = $this->getMigration('d7_file'); + // Set the source plugin's source_base_path configuration value, which + // would normally be set by the user running the migration. + $source = $migration->getSourceConfiguration(); + $source['constants']['source_base_path'] = $fs->realpath('public://'); + $migration->set('source', $source); + $this->executeMigration($migration); + } + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + $this->loadFixture(__DIR__ . '/../../../../fixtures/drupal7.php'); + + $this->installEntitySchema('node'); + $this->installEntitySchema('comment'); + $this->installEntitySchema('taxonomy_term'); + $this->installConfig(static::$modules); + $this->installSchema('node', ['node_access']); + $this->installSchema('system', ['sequences']); + $this->installEntitySchema('metatag_defaults'); + + $this->executeMigrations([ + 'd7_metatag_field', + 'd7_node_type', + 'd7_taxonomy_vocabulary', + 'd7_metatag_field_instance', + 'd7_metatag_field_instance_widget_settings', + 'd7_user_role', + 'd7_user', + 'd7_comment_type', + 'd7_field', + 'd7_field_instance', + ]); + $this->fileMigrationSetup(); + $this->executeMigrations([ + 'd7_node:test_content_type', + 'd7_node:article', + 'd7_node:forum', + 'd7_node:blog', + 'd7_node_revision:test_content_type', + 'd7_taxonomy_term', + ]); + } + + /** + * Test Metatag migration from Drupal 7 to 8. + */ + public function testMetatag() { + /** @var \Drupal\node\Entity\Node $node */ + $node = Node::load(998); + $this->assertTrue($node instanceof NodeInterface); + $this->assertTrue($node->hasField('field_metatag')); + // This should have the "current revision" keywords value, indicating it is + // the current revision. + $expected = [ + 'keywords' => 'current revision', + 'canonical_url' => 'the-node', + 'robots' => 'noindex, nofollow', + ]; + $this->assertSame(serialize($expected), $node->field_metatag->value); + + $node = node_revision_load(998); + $this->assertTrue($node instanceof NodeInterface); + $this->assertTrue($node->hasField('field_metatag')); + // This should have the "old revision" keywords value, indicating it is + // a non-current revision. + $expected = [ + 'keywords' => 'old revision', + 'canonical_url' => 'the-node', + 'robots' => 'noindex, nofollow', + ]; + $this->assertSame(serialize($expected), $node->field_metatag->value); + + /** @var \Drupal\user\Entity\User $user */ + $user = User::load(2); + $this->assertTrue($user instanceof UserInterface); + $this->assertTrue($user->hasField('field_metatag')); + $expected = [ + 'keywords' => 'a user', + 'canonical_url' => 'the-user', + ]; + $this->assertSame(serialize($expected), $user->field_metatag->value); + + /** @var \Drupal\taxonomy\Entity\Term $term */ + $term = Term::load(152); + $this->assertTrue($term instanceof TermInterface); + $this->assertTrue($term->hasField('field_metatag')); + $expected = [ + 'keywords' => 'a taxonomy', + 'canonical_url' => 'the-term', + ]; + $this->assertSame(serialize($expected), $term->field_metatag->value); + } + +} diff --git a/tests/src/Unit/Migrate/d7/MetatagFieldInstanceTest.php b/tests/src/Unit/Migrate/d7/MetatagFieldInstanceTest.php new file mode 100644 index 0000000..760e70e --- /dev/null +++ b/tests/src/Unit/Migrate/d7/MetatagFieldInstanceTest.php @@ -0,0 +1,87 @@ + 'test', + 'source' => [ + 'plugin' => 'd7_metatag_field_instance', + ], + ]; + + protected $expectedResults = [ + [ + 'entity_type' => 'node', + 'bundle' => 'test_content_type', + ], + [ + 'entity_type' => 'taxonomy_term', + 'bundle' => 'test_vocabulary', + ], + [ + 'entity_type' => 'user', + 'bundle' => 'user', + ], + ]; + + /** + * {@inheritdoc} + */ + protected function setUp() { + $this->databaseContents['metatag'] = $this->expectedResults; + + $module_handler = $this->getMock('Drupal\Core\Extension\ModuleHandlerInterface'); + $state = $this->getMock('Drupal\Core\State\StateInterface'); + $entity_manager = $this->getMock('Drupal\Core\Entity\EntityManagerInterface'); + $entity_type_bundle_info = $this->getMockBuilder('Drupal\Core\Entity\EntityTypeBundleInfo') + ->disableOriginalConstructor() + ->getMock(); + $entity_type_bundle_info->expects($this->any()) + ->method('getBundleInfo') + ->willReturnMap([ + ['node', ['test_content_type' => 'test_content_type']], + ['taxonomy_term', ['test_vocabulary' => 'test_vocabulary']], + ['user', ['user' => 'user']], + ]); + + $migration = $this->getMigration(); + // @todo Replace this. + // $migration->expects($this->any()) + // ->method('getHighWater') + // ->will($this->returnValue(static::ORIGINAL_HIGH_WATER)); + + // Setup the plugin. + $plugin_class = static::PLUGIN_CLASS; + $plugin = new $plugin_class($this->migrationConfiguration['source'], $this->migrationConfiguration['source']['plugin'], [], $migration, $state, $entity_manager, $entity_type_bundle_info); + + // Do some reflection to set the database and moduleHandler. + $plugin_reflection = new \ReflectionClass($plugin); + $database_property = $plugin_reflection->getProperty('database'); + $database_property->setAccessible(TRUE); + $module_handler_property = $plugin_reflection->getProperty('moduleHandler'); + $module_handler_property->setAccessible(TRUE); + + // Set the database and the module handler onto our plugin. + $database_property->setValue($plugin, $this->getDatabase($this->databaseContents + ['test_map' => []])); + $module_handler_property->setValue($plugin, $module_handler); + + $plugin->setStringTranslation($this->getStringTranslationStub()); + $migration->expects($this->any()) + ->method('getSourcePlugin') + ->will($this->returnValue($plugin)); + $this->source = $plugin; + $this->expectedCount = count($this->expectedResults); + } + +} diff --git a/tests/src/Unit/Migrate/d7/MetatagFieldTest.php b/tests/src/Unit/Migrate/d7/MetatagFieldTest.php new file mode 100644 index 0000000..2873418 --- /dev/null +++ b/tests/src/Unit/Migrate/d7/MetatagFieldTest.php @@ -0,0 +1,43 @@ + 'test', + 'source' => [ + 'plugin' => 'd7_metatag_field', + ], + ]; + + protected $expectedResults = [ + [ + 'entity_type' => 'node', + ], + [ + 'entity_type' => 'taxonomy_term', + ], + [ + 'entity_type' => 'user', + ], + ]; + + /** + * {@inheritdoc} + */ + protected function setUp() { + $this->databaseContents['metatag'] = $this->expectedResults; + parent::setUp(); + } + +}