diff --git a/core/lib/Drupal/Component/Utility/DiffArray.php b/core/lib/Drupal/Component/Utility/DiffArray.php index 719049e305..d6a0cd64aa 100644 --- a/core/lib/Drupal/Component/Utility/DiffArray.php +++ b/core/lib/Drupal/Component/Utility/DiffArray.php @@ -47,4 +47,38 @@ public static function diffAssocRecursive(array $array1, array $array2) { return $difference; } + /** + * Computes the difference of arrays. + * + * The main difference from the array_diff() is that this method does not + * remove duplicates. For example: + * @code + * array_diff([1, 1, 1], [1]); // [] + * \Drupal\Component\Utility\DiffArray::diffOnce([1, 1, 1], [1]); // [1, 1] + * @endcode + * + * Keys are maintained from the $array1. + * + * The comparison of items is always performed in the strict (===) mode. + * + * @param array $array1 + * The array to compare from. + * @param array $array2 + * The array to compare to. + * + * @return array + * Returns the difference between the two arrays. + */ + public static function diffOnce(array $array1, array $array2) { + foreach ($array2 as $item) { + // Always use strict mode because otherwise there could be fatal errors on + // object conversions. + $key = array_search($item, $array1, TRUE); + if ($key !== FALSE) { + unset($array1[$key]); + } + } + return $array1; + } + } diff --git a/core/modules/editor/editor.module b/core/modules/editor/editor.module index 8df1f0460a..cbcbc13c0b 100644 --- a/core/modules/editor/editor.module +++ b/core/modules/editor/editor.module @@ -5,7 +5,10 @@ * Adds bindings for client-side "text editors" to text formats. */ +use Drupal\Component\Utility\DiffArray; use Drupal\Component\Utility\Html; +use Drupal\Core\Language\LanguageInterface; +use Drupal\Core\TypedData\TranslatableInterface; use Drupal\editor\Entity\Editor; use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Field\FieldDefinitionInterface; @@ -384,13 +387,13 @@ function editor_entity_update(EntityInterface $entity) { // Detect file usages that should be incremented. foreach ($uuids_by_field as $field => $uuids) { - $added_files = array_diff($uuids_by_field[$field], $original_uuids_by_field[$field]); + $added_files = DiffArray::diffOnce($uuids_by_field[$field], $original_uuids_by_field[$field]); _editor_record_file_usage($added_files, $entity); } // Detect file usages that should be decremented. foreach ($original_uuids_by_field as $field => $uuids) { - $removed_files = array_diff($original_uuids_by_field[$field], $uuids_by_field[$field]); + $removed_files = DiffArray::diffOnce($original_uuids_by_field[$field], $uuids_by_field[$field]); _editor_delete_file_usage($removed_files, $entity, 1); } } @@ -543,23 +546,39 @@ function editor_file_download($uri) { /** * Finds all files referenced (data-entity-uuid) by formatted text fields. * - * @param EntityInterface $entity + * @param \Drupal\Core\Entity\FieldableEntityInterface $entity * An entity whose fields to analyze. * * @return array * An array of file entity UUIDs. */ -function _editor_get_file_uuids_by_field(EntityInterface $entity) { +function _editor_get_file_uuids_by_field(FieldableEntityInterface $entity) { $uuids = array(); + $field_definitions = $entity->getFieldDefinitions(); $formatted_text_fields = _editor_get_formatted_text_fields($entity); foreach ($formatted_text_fields as $formatted_text_field) { + + // In case of a translatable field, iterate over all its translations. + if ($field_definitions[$formatted_text_field]->isTranslatable() && $entity instanceof TranslatableInterface) { + $langcodes = array_keys($entity->getTranslationLanguages()); + } + else { + $langcodes = [LanguageInterface::LANGCODE_NOT_APPLICABLE]; + } + $text = ''; - $field_items = $entity->get($formatted_text_field); - foreach ($field_items as $field_item) { - $text .= $field_item->value; - if ($field_item->getFieldDefinition()->getType() == 'text_with_summary') { - $text .= $field_item->summary; + foreach ($langcodes as $langcode) { + if ($langcode == LanguageInterface::LANGCODE_NOT_APPLICABLE) { + $field_items = $entity->get($formatted_text_field); + } + else { + $field_items = $entity->getTranslation($langcode)->get($formatted_text_field); + } + foreach ($field_items as $field_item) { + if ($field_item->getFieldDefinition()->getType() == 'text_with_summary') { + $text .= $field_item->summary; + } } } $uuids[$formatted_text_field] = _editor_parse_file_uuids($text); diff --git a/core/modules/editor/editor.post_update.php b/core/modules/editor/editor.post_update.php index 3715b6dc57..e2deb8d05e 100644 --- a/core/modules/editor/editor.post_update.php +++ b/core/modules/editor/editor.post_update.php @@ -5,6 +5,8 @@ * Post update functions for Editor. */ +use Drupal\Core\Entity\FieldableEntityInterface; + /** * @addtogroup updates-8.2.x * @{ @@ -19,3 +21,112 @@ function editor_post_update_clear_cache_for_file_reference_filter() { /** * @} End of "addtogroup updates-8.2.x". */ + +/** + * @addtogroup updates-8.3.x + * @{ + */ + +/** + * Recalculates file usages. + */ +function editor_post_update_recalculate_file_usage(&$sandbox) { + $entity_load_limit = 50; + + if (!\Drupal::moduleHandler()->moduleExists('file')) { + return; + } + + if (!isset($sandbox['current'])) { + $result = \Drupal::entityQuery('file')->count()->execute(); + if (empty($result)) { + return; + } + $sandbox['data']['total_file'] = $result; + $sandbox['data']['current_file_id'] = 0; + $sandbox['data']['count_file_id'] = 0; + + foreach (\Drupal::entityTypeManager()->getDefinitions() as $entity_type) { + if ($entity_type->isSubclassOf(FieldableEntityInterface::class)) { + $entity_type_id = $entity_type->id(); + $query = \Drupal::entityQuery($entity_type_id); + if ($entity_type->isRevisionable()) { + $query->allRevisions(); + } + $result = $query->count()->execute(); + if (!empty($result)) { + // We need result keys because they are either revision IDs (in case + // of revisionable entities) or entity IDs (in other case). + $sandbox['data']['total_entity_id'][$entity_type_id] = $result; + $sandbox['data']['current_entity_id'][$entity_type_id] = 0; + $sandbox['data']['count_entity_id'][$entity_type_id] = 0; + } + } + } + + $sandbox['current'] = 0; + $sandbox['max'] = $sandbox['data']['total_file'] + array_sum($sandbox['data']['total_entity_id']); + } + + if (!empty($sandbox['data']['total_file'])) { + // Step 1: delete existing file usages. + /** @var \Drupal\file\FileUsage\FileUsageInterface $file_usage */ + $file_usage = \Drupal::service('file.usage'); + + $file_ids = \Drupal::entityQuery('file') + ->condition('fid', $sandbox['current_file_id'], '>') + ->sort('fid', 'ASC') + ->pager($entity_load_limit) + ->execute(); + $files = $files = \Drupal::entityTypeManager() + ->getStorage('file') + ->loadMultiple($file_ids); + foreach ($files as $file) { + $usages = $file_usage->listUsage($file); + if (!empty($usages['editor'])) { + $file_usage->delete($file, 'editor', NULL, NULL, 0); + } + } + + $sandbox['data']['count_file_id'] += count($file_ids); + } + else { + // Step 2: recalculate file usages. + reset($sandbox['data']['last_entity_id']); + $entity_type_id = key($sandbox['data']['last_entity_id']); + + $entity_type = \Drupal::entityTypeManager()->getDefinition($entity_type_id); + $ids = \Drupal::entityQuery($entity_type_id) + ->condition($entity_type->getKey('id'), $sandbox['data']['last_entity_id'][$entity_type_id], '>') + ->pager($entity_load_limit) + ->sort($entity_type->getKey('id'), 'ASC') + ->execute(); + if ($entity_type->isRevisionable()) { + foreach ($ids as $revision_id) { + $entity = \Drupal::entityTypeManager() + ->getStorage($entity_type_id) + ->loadRevision($revision_id); + editor_entity_insert($entity); + } + } + else { + $entities = \Drupal::entityTypeManager() + ->getStorage($entity_type_id) + ->loadMultiple($ids); + foreach ($entities as $entity) { + editor_entity_insert($entity); + } + } + if (empty($ids)) { + unset($sandbox['data']['last_entity_id'][$entity_type_id]); + } + else { + $sandbox['data']['count_entity_id'][$entity_type_id] += count($ids); + $sandbox['data']['last_entity_id'][$entity_type_id] = end($ids); + } + } + + $current_amount = count($sandbox['data']['count_file_id']) + array_sum($sandbox['data']['count_entity_id']); + $sandbox['current'] = $sandbox['max'] - $current_amount; + $sandbox['#finished'] = empty($current_amount) ? 1 : ($sandbox['current'] / $sandbox['max']); +} diff --git a/core/modules/editor/src/Tests/Update/EditorUpdateTest.php b/core/modules/editor/src/Tests/Update/EditorUpdateTest.php index e7f0f4435b..65cc6b72dc 100644 --- a/core/modules/editor/src/Tests/Update/EditorUpdateTest.php +++ b/core/modules/editor/src/Tests/Update/EditorUpdateTest.php @@ -2,6 +2,10 @@ namespace Drupal\editor\Tests\Update; +use Drupal\field\Entity\FieldStorageConfig; +use Drupal\file\Entity\File; +use Drupal\language\Entity\ConfigurableLanguage; +use Drupal\node\Entity\Node; use Drupal\system\Tests\Update\UpdatePathTestBase; /** @@ -19,6 +23,7 @@ public function setDatabaseDumpFiles() { __DIR__ . '/../../../../system/tests/fixtures/update/drupal-8.bare.standard.php.gz', // Simulate an un-synchronized environment. __DIR__ . '/../../../../system/tests/fixtures/update/drupal-8.editor-editor_update_8001.php', + __DIR__ . '/../../../../system/tests/fixtures/update/drupal-8.language-enabled.php', ]; } @@ -67,4 +72,77 @@ public function testEditorUpdate8001() { $this->assertIdentical($format_full_html->get('status'), $editor_full_html->get('status')); } + /** + * Tests editor_update_8002(). + * + * @see editor_update_8002() + */ + public function testEditorUpdate8002() { + + // Prepare second language. + ConfigurableLanguage::createFromLangcode('fr')->save(); + + // Make body field translatable. + FieldStorageConfig::loadByName('node', 'body') + ->setTranslatable(TRUE) + ->save(); + + // Prepare a test file. + $file_path = 'core/misc/druplicon.png'; + $file = File::create(); + $file->setFileUri($file_path); + $file->setFilename($this->container->get('file_system')->basename($file->getFileUri())); + $file->save(); + + $img_string = ''; + + // Initially reference file 2 times. Total file usages should be 2. + $node = Node::create([ + 'language' => 'en', + 'type' => 'page', + 'title' => 'test', + 'body' => [ + 0 => [ + 'value' => str_repeat($img_string, 2), + 'format' => 'full_html', + ], + ], + 'uid' => 1, + ]); + $node->save(); + + // Add 2 more usages. Total file usages should be 4 now. + $node->get('body')->value = $node->get('body')->value . str_repeat($img_string, 2); + $node->save(); + + // Create a node translation having 5 file usages and force a new revision. + // Total file usages should be 15 now: + // initial revision: en - 4, fr - does not exists, + // new revision: en - 4, fr - 7. + $node->addTranslation('fr', [ + 'title' => 'test fr', + 'body' => [ + 0 => [ + 'value' => str_repeat($img_string, 7), + 'format' => 'full_html', + ], + ], + ]); + $node->setNewRevision(TRUE); + $node->save(); + + // Now mess up file usages. + /** @var \Drupal\file\FileUsage\FileUsageInterface $file_usage */ + $file_usage = $this->container->get('file.usage'); + $file_usage->add($file, 'editor', 'node', $node->id(), 99999); + + $usages = $file_usage->listUsage($file); + $this->assert($usages['editor']['node'][$node->id()] != 15, 'File usages are messed up.'); + + $this->runUpdates(); + + $usages = $file_usage->listUsage($file); + $this->assert($usages['editor']['node'][$node->id()] == 15, 'File usages are correct.'); + } + } diff --git a/core/modules/editor/tests/src/Kernel/EditorFileUsageTest.php b/core/modules/editor/tests/src/Kernel/EditorFileUsageTest.php index 04113880cd..eea3a857aa 100644 --- a/core/modules/editor/tests/src/Kernel/EditorFileUsageTest.php +++ b/core/modules/editor/tests/src/Kernel/EditorFileUsageTest.php @@ -4,6 +4,7 @@ use Drupal\editor\Entity\Editor; use Drupal\KernelTests\Core\Entity\EntityKernelTestBase; +use Drupal\language\Entity\ConfigurableLanguage; use Drupal\node\Entity\Node; use Drupal\node\Entity\NodeType; use Drupal\file\Entity\File; @@ -23,7 +24,14 @@ class EditorFileUsageTest extends EntityKernelTestBase { * * @var array */ - public static $modules = array('editor', 'editor_test', 'node', 'file'); + public static $modules = array('editor', 'editor_test', 'node', 'file', 'language'); + + /** + * Languages to enable. + * + * @var array + */ + protected $langcodes = array('fr', 'en'); protected function setUp() { parent::setUp(); @@ -41,8 +49,14 @@ protected function setUp() { )); $filtered_html_format->save(); + // Add languages. + foreach ($this->langcodes as $langcode) { + ConfigurableLanguage::createFromLangcode($langcode)->save(); + } + // Set cardinality for body field. FieldStorageConfig::loadByName('node', 'body') + ->setTranslatable(TRUE) ->setCardinality(FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED) ->save(); @@ -69,6 +83,9 @@ public function testEditorEntityHooks() { 2 => 'core/misc/help.png', ); + /** @var \Drupal\file\FileUsage\FileUsageInterface $file_usage */ + $file_usage = $this->container->get('file.usage'); + $image_entities = array(); foreach ($image_paths as $key => $image_path) { $image = File::create(); @@ -76,7 +93,6 @@ public function testEditorEntityHooks() { $image->setFilename(drupal_basename($image->getFileUri())); $image->save(); - $file_usage = $this->container->get('file.usage'); $this->assertIdentical(array(), $file_usage->listUsage($image), 'The image ' . $image_paths[$key] . ' has zero usages.'); $image_entities[] = $image; @@ -103,7 +119,8 @@ public function testEditorEntityHooks() { // Test editor_entity_insert(): increment. $this->createUser(); - $node = $node = Node::create([ + $node = Node::create([ + 'language' => 'en', 'type' => 'page', 'title' => 'test', 'body' => $body, @@ -199,11 +216,68 @@ public function testEditorEntityHooks() { $this->assertIdentical(array('editor' => array('node' => array(1 => '2'))), $file_usage->listUsage($image_entity), 'The image ' . $image_paths[$key] . ' has 2 usages.'); } + // Test editor_entity_translation_insert(): increment, by adding a new + // translation. + $translation = $node->addTranslation('fr', ['title' => 'test fr', 'body' => $original_values]); + $translation->save(); + foreach ($image_entities as $key => $image_entity) { + $this->assertIdentical(array('editor' => array('node' => array(1 => '3'))), $file_usage->listUsage($image_entity), 'The image ' . $image_paths[$key] . ' has 3 usages.'); + } + + // Test editor_entity_translation_delete(): decrement, by deleting a + // translation. + $node->removeTranslation('fr'); + $node->save(); + foreach ($image_entities as $key => $image_entity) { + $this->assertIdentical(array('editor' => array('node' => array(1 => '2'))), $file_usage->listUsage($image_entity), 'The image ' . $image_paths[$key] . ' has 2 usages.'); + } + // Test editor_entity_delete(). $node->delete(); foreach ($image_entities as $key => $image_entity) { $this->assertIdentical(array(), $file_usage->listUsage($image_entity), 'The image ' . $image_paths[$key] . ' has zero usages again.'); } + + // Prepare a new node having only one file entity referenced in the body + // field. + $image_entity_1 = $image_entities[0]; + $body_value = ''; + $node = Node::create([ + 'language' => 'en', + 'type' => 'page', + 'title' => 'test', + 'body' => [ + 0 => [ + 'value' => $body_value, + 'format' => 'filtered_html', + ] + ], + 'uid' => 1, + ]); + $node->save(); + $nid = $node->id(); + $this->assertIdentical(array('editor' => array('node' => array($nid => '1'))), $file_usage->listUsage($image_entity_1), 'The image ' . $image_entity_1->getFileUri() . ' has 1 usage.'); + + // Add a translation. + $translation = $node->addTranslation('fr', [ + 'title' => 'test fr', + 'body' => $node->get('body')->getValue(), + ]); + $node->save(); + $this->assertIdentical(array('editor' => array('node' => array($nid => '2'))), $file_usage->listUsage($image_entity_1), 'The image ' . $image_entity_1->getFileUri() . ' has 2 usages.'); + + // Replace image in the French translation. + $image_entity_2 = $image_entities[1]; + $translation->get('body')->value = '';; + $node->save(); + $this->assertIdentical(array('editor' => array('node' => array($nid => '1'))), $file_usage->listUsage($image_entity_1), 'The image ' . $image_entity_1->getFileUri() . ' has 1 usage.'); + $this->assertIdentical(array('editor' => array('node' => array($nid => '1'))), $file_usage->listUsage($image_entity_2), 'The image ' . $image_entity_2->getFileUri() . ' has 1 usage.'); + + // Re-save translation with no image and check if usage changed + $translation->get('body')->value = ''; + $node->save(); + $this->assertIdentical(array('editor' => array('node' => array($nid => '1'))), $file_usage->listUsage($image_entity_1), 'The image ' . $image_entity_1->getFileUri() . ' has 1 usage.'); + $this->assertIdentical(array(), $file_usage->listUsage($image_entity_2), 'The image ' . $image_entity_2->getFileUri() . ' has zero usages.'); } } diff --git a/core/tests/Drupal/Tests/Core/Common/DiffArrayTest.php b/core/tests/Drupal/Tests/Core/Common/DiffArrayTest.php index 4112eaeb16..3f590c8e62 100644 --- a/core/tests/Drupal/Tests/Core/Common/DiffArrayTest.php +++ b/core/tests/Drupal/Tests/Core/Common/DiffArrayTest.php @@ -9,27 +9,15 @@ * Tests the DiffArray helper class. * * @group Common + * @coversDefaultClass \Drupal\Component\Utility\DiffArray */ class DiffArrayTest extends UnitTestCase { /** - * Array to use for testing. - * - * @var array + * @covers ::diffAssocRecursive */ - protected $array1; - - /** - * Array to use for testing. - * - * @var array - */ - protected $array2; - - protected function setUp() { - parent::setUp(); - - $this->array1 = array( + public function testDiffAssocRecursive() { + $array1 = array( 'same' => 'yes', 'different' => 'no', 'array_empty_diff' => array(), @@ -40,7 +28,7 @@ protected function setUp() { 'string_compared_to_array' => 'value', 'new' => 'new', ); - $this->array2 = array( + $array2 = array( 'same' => 'yes', 'different' => 'yes', 'array_empty_diff' => array(), @@ -50,12 +38,7 @@ protected function setUp() { 'array_compared_to_string' => 'value', 'string_compared_to_array' => array('value'), ); - } - /** - * Tests DiffArray::diffAssocRecursive(). - */ - public function testDiffAssocRecursive() { $expected = array( 'different' => 'no', 'int_diff' => 1, @@ -66,7 +49,51 @@ public function testDiffAssocRecursive() { 'new' => 'new', ); - $this->assertSame(DiffArray::diffAssocRecursive($this->array1, $this->array2), $expected); + $this->assertSame(DiffArray::diffAssocRecursive($array1, $array2), $expected); + } + + /** + * @covers ::diffOnce + * @dataProvider providerTestDiffOnce + */ + public function testDiffOnce($array1, $array2, $expected) { + $this->assertSame($expected, DiffArray::diffOnce($array1, $array2)); + } + + /** + * Provides test data for testDiffOnce(). + */ + public function providerTestDiffOnce() { + $object1 = new \stdClass(); + $object2 = new \stdClass(); + $data = [ + [ + [1, 1, 1, 1, 1], + [1, 1, 1], + [3 => 1, 4 => 1], + ], + [ + [1, 1, 2, 2, 3, 3, 4], + ['4', 3, 2, 1], + [1 => 1, 3 => 2, 5 => 3, 6 => 4], + ], + [ + [' ', '', 0, FALSE], + [0, 0, 0, 0, 'not used'], + [0 => ' ', 1 => '', 3 => FALSE], + ], + [ + [$this, [], $object1, $object1, $object2], + [$object1, $object2, [], $this], + [3 => $object1], + ], + [ + [['x' => ['y' => 'z']], [$object1]], + [1, 2, 3, '4', TRUE, [$object2], ['x' => ['y' => 'z']]], + [1 => [$object1]], + ], + ]; + return $data; } }