diff --git a/core/modules/media/config/install/system.action.media_update_metadata.yml b/core/modules/media/config/install/system.action.media_update_metadata.yml new file mode 100644 index 0000000000..e31298e408 --- /dev/null +++ b/core/modules/media/config/install/system.action.media_update_metadata.yml @@ -0,0 +1,10 @@ +langcode: en +status: true +dependencies: + module: + - media +id: media_update_metadata +label: 'Update metadata' +type: media +plugin: media_update_metadata +configuration: { } diff --git a/core/modules/media/config/schema/media.schema.yml b/core/modules/media/config/schema/media.schema.yml index 08bf800f59..84ebc9504f 100644 --- a/core/modules/media/config/schema/media.schema.yml +++ b/core/modules/media/config/schema/media.schema.yml @@ -124,3 +124,7 @@ filter_settings.media_embed: sequence: type: string label: 'Media type' + +action.configuration.media_update_metadata: + type: action_configuration_default + label: 'Update metadata' diff --git a/core/modules/media/media.links.contextual.yml b/core/modules/media/media.links.contextual.yml index 1945ef5001..cf919f396c 100644 --- a/core/modules/media/media.links.contextual.yml +++ b/core/modules/media/media.links.contextual.yml @@ -3,6 +3,12 @@ entity.media.edit_form: group: media title: Edit +media.update_metadata: + route_name: media.update_metadata + group: media + title: 'Update metadata' + weight: 5 + entity.media.delete_form: route_name: entity.media.delete_form group: media diff --git a/core/modules/media/media.post_update.php b/core/modules/media/media.post_update.php index d28e788662..3d055a5d10 100644 --- a/core/modules/media/media.post_update.php +++ b/core/modules/media/media.post_update.php @@ -6,6 +6,7 @@ */ use Drupal\Core\Field\Entity\BaseFieldOverride; +use Drupal\system\Entity\Action; /** * Implements hook_removed_post_updates(). @@ -34,3 +35,18 @@ function media_post_update_modify_base_field_author_override() { $base_field_override->setDefaultValueCallback('Drupal\media\Entity\Media::getDefaultEntityOwner')->save(); } } + +/** + * Install the 'Update metadata' action. + */ +function media_post_update_install_update_metadata_action() { + if (!Action::load('media_update_metadata')) { + Action::create([ + 'id' => 'media_update_metadata', + 'label' => 'Update metadata', + 'type' => 'media', + 'plugin' => 'media_update_metadata', + ]) + ->save(); + } +} diff --git a/core/modules/media/media.routing.yml b/core/modules/media/media.routing.yml index 5dc97324d7..5cd033fcf0 100644 --- a/core/modules/media/media.routing.yml +++ b/core/modules/media/media.routing.yml @@ -13,6 +13,14 @@ entity.media.revision: _entity_access: 'media_revision.view all revisions' media: \d+ +media.update_metadata: + path: '/media/{media}/update-metadata' + defaults: + _controller: '\Drupal\media\Controller\UpdateMetadataController::updateMetadata' + requirements: + _entity_access: 'media.update' + _csrf_token: 'TRUE' + media.oembed_iframe: path: '/media/oembed' defaults: diff --git a/core/modules/media/src/Controller/UpdateMetadataController.php b/core/modules/media/src/Controller/UpdateMetadataController.php new file mode 100644 index 0000000000..d04ec11c9a --- /dev/null +++ b/core/modules/media/src/Controller/UpdateMetadataController.php @@ -0,0 +1,46 @@ +enforceMetadataUpdate(); + $this->entityTypeManager()->getStorage('media')->save($media); + $this->messenger()->addStatus($this->t('Updated metadata on media item %label', [ + '%label' => $media->label(), + ])); + return new RedirectResponse($this->reloadPage()); + } + + /** + * Reloads the previous page or return to the media overview. + */ + protected function reloadPage() { + if ($destination = $this->getRedirectDestination()->get()) { + return $destination; + } + return Url::fromRoute('entity.media.collection')->toString(); + } + +} diff --git a/core/modules/media/src/Entity/Media.php b/core/modules/media/src/Entity/Media.php index 6649cccfd0..0470c7feb7 100644 --- a/core/modules/media/src/Entity/Media.php +++ b/core/modules/media/src/Entity/Media.php @@ -88,6 +88,13 @@ class Media extends EditorialContentEntityBase implements MediaInterface { use EntityOwnerTrait; use StringTranslationTrait; + /** + * Force media item metadata update. + * + * @var bool + */ + protected $forceMetadataUpdate = FALSE; + /** * {@inheritdoc} */ @@ -287,8 +294,8 @@ protected function hasSourceFieldChanged() { */ protected function shouldUpdateThumbnail($is_new = FALSE) { // Update thumbnail if we don't have a thumbnail yet or when the source - // field value changes. - return !$this->get('thumbnail')->entity || $is_new || $this->hasSourceFieldChanged(); + // field value changed or when it was enforced. + return $is_new || $this->forceMetadataUpdate || !$this->get('thumbnail')->entity || $this->hasSourceFieldChanged(); } /** @@ -368,29 +375,57 @@ public function prepareSave() { ->loadUnchanged($id); } - $media_source = $this->getSource(); - foreach ($this->translations as $langcode => $data) { - if ($this->hasTranslation($langcode)) { - $translation = $this->getTranslation($langcode); - // Try to set fields provided by the media source and mapped in - // media type config. - foreach ($translation->bundle->entity->getFieldMap() as $metadata_attribute_name => $entity_field_name) { - // Only save value in entity field if empty. Do not overwrite existing - // data. - if ($translation->hasField($entity_field_name) && ($translation->get($entity_field_name)->isEmpty() || $translation->hasSourceFieldChanged())) { - $translation->set($entity_field_name, $media_source->getMetadata($translation, $metadata_attribute_name)); - } - } + foreach (array_keys($this->getTranslationLanguages()) as $langcode) { + $translation = $this->getTranslation($langcode); - // Try to set a default name for this media item if no name is provided. - if ($translation->get('name')->isEmpty()) { - $translation->setName($translation->getName()); - } + // Set fields provided by the media source and mapped in the media type + // config. + $this->updateMappedMetadata($translation, $this->forceMetadataUpdate); - // Set thumbnail. - if ($translation->shouldUpdateThumbnail($this->isNew())) { - $translation->updateThumbnail(); - } + // Try to set a default name for this media item if no name is provided. + if ($translation->get('name')->isEmpty()) { + $translation->setName($translation->getName()); + } + + // Set thumbnail. + if ($translation->shouldUpdateThumbnail($this->isNew())) { + $translation->updateThumbnail(); + } + } + } + + /** + * {@inheritdoc} + */ + public function enforceMetadataUpdate() { + $this->forceMetadataUpdate = TRUE; + + return $this; + } + + /** + * Maps metadata values into entity field values. + * + * @param \Drupal\media\MediaInterface $translation + * The media translation we are updating. + * @param bool $overwrite_existing + * (optional) If TRUE, metadata values will always be copied into mapped + * field values. If FALSE, values will be copied only if the mapped field is + * empty or if the media source field changed. Defaults to FALSE. + */ + protected function updateMappedMetadata(MediaInterface $translation, $overwrite_existing = FALSE) { + $media_source = $this->getSource(); + foreach ($translation->bundle->entity->getFieldMap() as $metadata_attribute_name => $entity_field_name) { + if (!$translation->hasField($entity_field_name)) { + continue; + } + + // Populate the field value in one of these scenarios: + // - The caller of this function asked for it explicitly. + // - The entity field is empty. + // - The media source field has changed. + if ($overwrite_existing || $translation->get($entity_field_name)->isEmpty() || $translation->hasSourceFieldChanged()) { + $translation->set($entity_field_name, $media_source->getMetadata($translation, $metadata_attribute_name)); } } } diff --git a/core/modules/media/src/MediaInterface.php b/core/modules/media/src/MediaInterface.php index 3aeaef56ab..5bc2dd7314 100644 --- a/core/modules/media/src/MediaInterface.php +++ b/core/modules/media/src/MediaInterface.php @@ -64,4 +64,11 @@ public function setCreatedTime($timestamp); */ public function getSource(); + /** + * Force metadata update in prepareSave(). + * + * @return $this + */ + public function enforceMetadataUpdate(); + } diff --git a/core/modules/media/src/MediaListBuilder.php b/core/modules/media/src/MediaListBuilder.php index 44ae6a8b7c..1b5afbf294 100644 --- a/core/modules/media/src/MediaListBuilder.php +++ b/core/modules/media/src/MediaListBuilder.php @@ -9,6 +9,7 @@ use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Language\LanguageManagerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; +use Drupal\Core\Url; /** * Provides a listing of media items. @@ -159,4 +160,21 @@ protected function getEntityIds() { return $query->execute(); } + /** + * {@inheritdoc} + */ + protected function getDefaultOperations(EntityInterface $entity) { + $operations = parent::getDefaultOperations($entity); + if ($entity->access('update')) { + $operations['update_metadata'] = [ + 'title' => t('Update metadata'), + 'url' => $this->ensureDestination(new Url('media.update_metadata', [ + 'media' => $entity->id(), + ])), + 'weight' => 150, + ]; + } + return $operations; + } + } diff --git a/core/modules/media/src/Plugin/Action/UpdateMetadataAction.php b/core/modules/media/src/Plugin/Action/UpdateMetadataAction.php new file mode 100644 index 0000000000..d125923793 --- /dev/null +++ b/core/modules/media/src/Plugin/Action/UpdateMetadataAction.php @@ -0,0 +1,77 @@ +mediaStorage = $entity_storage; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $container->get('entity_type.manager')->getStorage('media'), + $configuration, + $plugin_id, + $plugin_definition + ); + } + + /** + * {@inheritdoc} + */ + public function execute($entity = NULL) { + if ($entity) { + /** @var \Drupal\media\MediaInterface $entity */ + $entity->enforceMetadataUpdate(); + $this->mediaStorage->save($entity); + } + } + + /** + * {@inheritdoc} + */ + public function access($object, AccountInterface $account = NULL, $return_as_object = FALSE) { + /** @var \Drupal\Core\Entity\EntityInterface $object */ + $result = $object->access('update', $account, TRUE); + return $return_as_object ? $result : $result->isAllowed(); + } + +} diff --git a/core/modules/media/tests/src/Functional/MediaBulkFormTest.php b/core/modules/media/tests/src/Functional/MediaBulkFormTest.php index d65f34e2b0..a6006777d2 100644 --- a/core/modules/media/tests/src/Functional/MediaBulkFormTest.php +++ b/core/modules/media/tests/src/Functional/MediaBulkFormTest.php @@ -72,12 +72,14 @@ public function testBulkForm() { // Check the operations are accessible to the logged in user. $this->drupalGet('test-media-bulk-form'); - // Current available actions: Delete, Save, Publish, Unpublish. + // Current available actions: Delete, Save, Publish, Unpublish, Update + // Metadata. $available_actions = [ 'media_delete_action', 'media_publish_action', 'media_save_action', 'media_unpublish_action', + 'media_update_metadata', ]; foreach ($available_actions as $action_name) { $assert_session->optionExists('action', $action_name); diff --git a/core/modules/media/tests/src/Functional/MediaContextualLinksTest.php b/core/modules/media/tests/src/Functional/MediaContextualLinksTest.php deleted file mode 100644 index f5cf9a81bc..0000000000 --- a/core/modules/media/tests/src/Functional/MediaContextualLinksTest.php +++ /dev/null @@ -1,57 +0,0 @@ -getEditable('media.settings') - ->set('standalone_url', TRUE) - ->save(TRUE); - - $this->container->get('router.builder')->rebuild(); - - // Create a media type. - $mediaType = $this->createMediaType('test'); - - // Create a media item. - $media = Media::create([ - 'bundle' => $mediaType->id(), - 'name' => 'Unnamed', - ]); - $media->save(); - - $user = $this->drupalCreateUser([ - 'administer media', - 'access contextual links', - ]); - $this->drupalLogin($user); - - $this->drupalGet('media/' . $media->id()); - $this->assertSession()->elementAttributeContains('css', 'div[data-contextual-id]', 'data-contextual-id', 'media:media=' . $media->id() . ':'); - } - -} diff --git a/core/modules/media/tests/src/Functional/MediaOverviewPageTest.php b/core/modules/media/tests/src/Functional/MediaOverviewPageTest.php index ce6146cde5..ff903c07c1 100644 --- a/core/modules/media/tests/src/Functional/MediaOverviewPageTest.php +++ b/core/modules/media/tests/src/Functional/MediaOverviewPageTest.php @@ -139,6 +139,9 @@ public function testMediaOverviewPage() { $delete_link1 = $assert_session->elementExists('css', 'td.views-field-operations li.delete a', $row1); $this->assertSame('Delete', $delete_link1->getText()); $assert_session->linkByHrefExists('/media/' . $media1->id() . '/delete'); + $update_metadata_link1 = $assert_session->elementExists('css', 'td.views-field-operations li.update-metadata a', $row1); + $this->assertSame('Update metadata', $update_metadata_link1->getText()); + $assert_session->linkByHrefExists('/media/' . $media1->id() . '/update-metadata'); // Make the user the owner of the unpublished media item and assert the // media item is only visible with the 'view own unpublished media' diff --git a/core/modules/media/tests/src/Functional/MediaUpdateMetadataControllerTest.php b/core/modules/media/tests/src/Functional/MediaUpdateMetadataControllerTest.php new file mode 100644 index 0000000000..b64b51f770 --- /dev/null +++ b/core/modules/media/tests/src/Functional/MediaUpdateMetadataControllerTest.php @@ -0,0 +1,77 @@ +assertSession(); + + $media_type = $this->createMediaType('file'); + $source_plugin = $media_type->getSource(); + + // Initially the "name" metadata is the filename. + $file = FileEntity::create([ + 'uri' => 'public://foo.txt', + 'uid' => 1, + ]); + $file->setPermanent(); + $file->save(); + + /** @var \Drupal\media\MediaInterface $media */ + $media = Media::create([ + 'bundle' => $media_type->id(), + 'uid' => 1, + 'field_media_file' => [ + 'target_id' => $file->id(), + ], + ]); + $media->save(); + + $name_metadata = $source_plugin->getMetadata($media, File::METADATA_ATTRIBUTE_NAME); + $this->assertSame('foo.txt', $name_metadata); + + // Rename the file, simulating a remote metadata change. + $file_id = $source_plugin->getSourceFieldValue($media); + /** @var \Drupal\file\FileInterface $file */ + $file = $this->container->get('entity_type.manager') + ->getStorage('file') + ->load($file_id); + $file->setFilename('bar.txt'); + $file->save(); + + // Go to the update metadata page and verify the controller triggered an + // update. + $media_id = $media->id(); + $site_base_path = base_path(); + + $this->drupalGet("/admin/content/media"); + $this->assertSession()->elementExists( + 'xpath', + "//ul[contains(@class, dropbutton)]/li/a[starts-with(@href, '{$site_base_path}media/{$media_id}/update-metadata')]" + )->click(); + + $assert_session->statusCodeEquals(200); + + $name_metadata = $source_plugin->getMetadata($media, File::METADATA_ATTRIBUTE_NAME); + $this->assertSame('bar.txt', $name_metadata); + } + +} diff --git a/core/modules/media/tests/src/FunctionalJavascript/MediaContextualLinksTest.php b/core/modules/media/tests/src/FunctionalJavascript/MediaContextualLinksTest.php new file mode 100644 index 0000000000..d9619f520a --- /dev/null +++ b/core/modules/media/tests/src/FunctionalJavascript/MediaContextualLinksTest.php @@ -0,0 +1,84 @@ +getEditable('media.settings') + ->set('standalone_url', TRUE) + ->save(TRUE); + + $this->container->get('router.builder')->rebuild(); + } + + /** + * Tests contextual links. + */ + public function testMediaContextualLinks() { + // Create a media type. + $mediaType = $this->createMediaType('test'); + + // Create a media item. + $media = Media::create([ + 'bundle' => $mediaType->id(), + 'name' => 'Unnamed', + ]); + $media->save(); + + $user = $this->drupalCreateUser([ + 'administer media', + 'access contextual links', + 'view media', + ]); + $this->drupalLogin($user); + + $this->drupalGet('media/' . $media->id()); + + // Contextual links are populated by javascript after the page is loaded. + // Wait until they are on the page, click on the pencil so we make sure they + // are visible, and then we can assert their contents. + $this->assertSession()->waitForElement('css', 'div[data-contextual-id] ul.contextual-links'); + $this->getSession()->executeScript("jQuery('.contextual .trigger').toggleClass('visually-hidden');"); + $this->cssSelect('.contextual button')[0]->press(); + + // The contextual links container is there. + $this->assertSession()->elementAttributeContains('css', 'div[data-contextual-id]', 'data-contextual-id', 'media:media=' . $media->id() . ':'); + + // The "Edit" link is there. + $this->assertSession()->elementTextContains('css', 'ul.contextual-links li:first-child a', 'Edit'); + + // The "Update metadata" link is there. + $this->assertSession()->elementTextContains('css', 'ul.contextual-links li:nth-child(2) a', 'Update metadata'); + + // The "Delete" link is there. + $this->assertSession()->elementTextContains('css', 'ul.contextual-links li:nth-child(3) a', 'Delete'); + } + +} diff --git a/core/modules/media/tests/src/Kernel/MediaUpdateMetadataTest.php b/core/modules/media/tests/src/Kernel/MediaUpdateMetadataTest.php new file mode 100644 index 0000000000..c42bb48561 --- /dev/null +++ b/core/modules/media/tests/src/Kernel/MediaUpdateMetadataTest.php @@ -0,0 +1,43 @@ +createMediaType('file'); + $source_plugin = $media_type->getSource(); + + // Initially the "name" metadata is the filename. + $media = $this->generateMedia('foo.txt', $media_type); + $media->save(); + $name_metadata = $source_plugin->getMetadata($media, File::METADATA_ATTRIBUTE_NAME); + $this->assertSame('foo.txt', $name_metadata); + + // Rename the file, simulating a remote metadata change. + $file_id = $source_plugin->getSourceFieldValue($media); + /** @var \Drupal\file\FileInterface $file */ + $file = $this->container->get('entity_type.manager') + ->getStorage('file') + ->load($file_id); + $file->setFilename('bar.txt'); + $file->save(); + + // Update metadata, we should now pick the new name. + $media->enforceMetadataUpdate()->save(); + + $name_metadata = $source_plugin->getMetadata($media, File::METADATA_ATTRIBUTE_NAME); + $this->assertSame('bar.txt', $name_metadata); + } + +}