diff --git a/includes/media.filter.inc b/includes/media.filter.inc index 6b7f51a..38c78c7 100644 --- a/includes/media.filter.inc +++ b/includes/media.filter.inc @@ -7,6 +7,9 @@ * @TODO: Rename this file? */ +define('MEDIA_TOKEN_REGEX', '/\[\[.*?\]\]/s'); +define('MEDIA_TOKEN_REGEX_ALT', '/%7B.*?%7D/s'); + /** * Implements hook_wysiwyg_include_directory(). */ @@ -19,6 +22,182 @@ function media_wysiwyg_include_directory($type) { } /** + * Implements hook_field_attach_insert(). + * + * Track file usage for media files included in formatted text. Note that this + * is heavy-handed, and should be replaced when Drupal's filter system is + * context-aware. + */ +function media_field_attach_insert($entity_type, $entity) { + _media_filter_add_file_usage_from_fields($entity_type, $entity); +} + +/** + * Implements hook_field_attach_update(). + * + * @see media_field_attach_insert(). + */ +function media_field_attach_update($entity_type, $entity) { + _media_filter_add_file_usage_from_fields($entity_type, $entity); +} + +/** + * Add file usage from file references in an entity's text fields. + */ +function _media_filter_add_file_usage_from_fields($entity_type, $entity) { + // Track the total usage for files from all fields combined. + $entity_files = media_entity_field_count_files($entity_type, $entity); + + list($entity_id, $entity_vid, $entity_bundle) = entity_extract_ids($entity_type, $entity); + + // When an entity has revisions and then is saved again NOT as new version the + // previous revision of the entity has be loaded to get the last known good + // count of files. The saved data is compared against the last version + // so that a correct file count can be created for that (the current) version + // id. This code may assume some things about entities that are only true for + // node objects. This should be reviewed. + // @TODO this conditional can probably be condensed + if (empty($entity->revision) && empty($entity->old_vid) && empty($entity->is_new) && ! empty($entity->original)) { + $old_files = media_entity_field_count_files($entity_type, $entity->original); + foreach ($old_files as $fid => $old_file_count) { + // Were there more files on the node just prior to saving? + if (empty($entity_files[$fid])) { + $entity_files[$fid] = 0; + } + if ($old_file_count > $entity_files[$fid]) { + $deprecate = $old_file_count - $entity_files[$fid]; + // Now deprecate this usage + $file = file_load($fid); + file_usage_delete($file, 'media', $entity_type, $entity_id, $deprecate); + // Usage is deleted, nothing more to do with this file + unset($entity_files[$fid]); + } + // There are the same number of files, nothing to do + elseif ($entity_files[$fid] == $old_file_count) { + unset($entity_files[$fid]); + } + // There are more files now, adjust the difference for the greater number. + // file_usage incrementing will happen below. + else { + // We just need to adjust what the file count will account for the new + // images that have been added since the increment process below will + // just add these additional ones in + $entity_files[$fid] = $entity_files[$fid] - $old_file_count; + } + } + } + + // Each entity revision counts for file usage. If versions are not enabled + // the file_usage table will have no entries for this because of the delete + // query above. + foreach ($entity_files as $fid => $entity_count) { + $file = file_load($fid); + file_usage_add($file, 'media', $entity_type, $entity_id, $entity_count); + } + +} + +/** + * Parse file references from an entity's text fields and return them as an array. + */ +function media_filter_parse_from_fields($entity_type, $entity) { + $file_references = array(); + + foreach (_media_filter_fields_with_text_filtering($entity_type, $entity) as $field_name) { + if ($field_items = field_get_items($entity_type, $entity, $field_name)) { + foreach ($field_items as $field_item) { + preg_match_all(MEDIA_TOKEN_REGEX, $field_item['value'], $matches); + foreach ($matches[0] as $tag) { + $tag = str_replace(array('[[', ']]'), '', $tag); + $tag_info = drupal_json_decode($tag); + if (isset($tag_info['fid']) && $tag_info['type'] == 'media') { + $file_references[] = $tag_info; + } + } + + preg_match_all(MEDIA_TOKEN_REGEX_ALT, $field_item['value'], $matches_alt); + foreach ($matches_alt[0] as $tag) { + $tag = urldecode($tag); + $tag_info = drupal_json_decode($tag); + if (isset($tag_info['fid']) && $tag_info['type'] == 'media') { + $file_references[] = $tag_info; + } + } + } + } + } + + return $file_references; +} + +/** + * Returns an array containing the names of all fields that perform text filtering. + */ +function _media_filter_fields_with_text_filtering($entity_type, $entity) { + list($entity_id, $revision_id, $bundle) = entity_extract_ids($entity_type, $entity); + $fields = field_info_instances($entity_type, $bundle); + + // Get all of the fields on this entity that allow text filtering. + $fields_with_text_filtering = array(); + foreach ($fields as $field_name => $field) { + if (!empty($field['settings']['text_processing'])) { + $fields_with_text_filtering[] = $field_name; + } + } + + return $fields_with_text_filtering; +} + +/** + * Utility function to get the file count in this entity + * + * @param type $entity + * @param type $entity_type + * @return int + */ +function media_entity_field_count_files($entity_type, $entity) { + $entity_files = array(); + foreach (media_filter_parse_from_fields($entity_type, $entity) as $file_reference) { + if (empty($entity_files[$file_reference['fid']])) { + $entity_files[$file_reference['fid']] = 1; + } + else { + $entity_files[$file_reference['fid']]++; + } + } + return $entity_files; +} + +/** + * Implements hook_entity_delete(). + */ +function media_entity_delete($entity, $type) { + list($entity_id) = entity_extract_ids($type, $entity); + + db_delete('file_usage') + ->condition('module', 'media') + ->condition('type', $type) + ->condition('id', $entity_id) + ->execute(); +} + +/** + * Implements hook_field_attach_delete_revision(). + * + * @param type $entity_type + * @param type $entity + */ +function media_field_attach_delete_revision($entity_type, $entity) { + list($entity_id) = entity_extract_ids($entity_type, $entity); + $files = media_entity_field_count_files($entity_type, $entity); + foreach ($files as $fid => $count) { + if ($file = file_load($fid)) { + file_usage_delete($file, 'media', $entity_type , $entity_id, $count); + } + } +} + +/** * Filter callback for media markup filter. * * @TODO check for security probably pass text through filter_xss @@ -26,8 +205,7 @@ function media_wysiwyg_include_directory($type) { */ function media_filter($text) { $text = ' ' . $text . ' '; - $text = preg_replace_callback("/\[\[.*?\]\]/s", 'media_token_to_markup', $text); - + $text = preg_replace_callback(MEDIA_TOKEN_REGEX, 'media_token_to_markup', $text); return $text; } diff --git a/media.info b/media.info index 6b37c4f..7a159cd 100644 --- a/media.info +++ b/media.info @@ -7,4 +7,5 @@ dependencies[] = image files[] = includes/MediaReadOnlyStreamWrapper.inc files[] = test/media.types.test files[] = test/media.entity.test +files[] = test/media.file.usage.test configure = admin/config/media/browser diff --git a/test/media.file.usage.test b/test/media.file.usage.test new file mode 100644 index 0000000..6dc81d0 --- /dev/null +++ b/test/media.file.usage.test @@ -0,0 +1,268 @@ + t('File usage tracking'), + 'description' => t('Tests tracking of usage for files in text fields.'), + 'group' => t('Media'), + ); + } + + /** + * Enable media and file entity modules for testing. + */ + public function setUp() { + parent::setUp(array('media', 'file_entity')); + + // Create and log in a user. + $account = $this->drupalCreateUser(array('administer nodes', 'create article content')); + $this->drupalLogin($account); + } + + /** + * Generates markup to be inserted for a file. + * + * This is a PHP version of InsertMedia.insert() from js/wysiwyg-media.js. + * + * @param int $fid + * Drupal file id + * @param int $count + * Quantity of markup to insert + * + * @return string + * Filter markup. + */ + private function generateFileMarkup($fid, $count = 1) { + $file_usage_markup = ''; + + // Build the data that is used in a media tag. + $data = array( + 'fid' => $fid, + 'type' => 'media', + 'view_mode' => 'preview', + 'attributes' => array( + 'height' => 100, + 'width' => 100, + 'classes' => 'media-element file_preview', + ) + ); + + // Create the file usage markup. + for ($i = 1; $i <= $count; $i++) { + $file_usage_markup .= '

[[' . drupal_json_encode($data) . ']]

'; + } + + return $file_usage_markup; + } + + + /** + * Utility function to create a test node. + * + * @param int $fid + * Create the node with media markup in the body field + * + * @return int + * Returns the node id + */ + private function createNode($fid = FALSE) { + $markup = ''; + if (! empty($fid)) { + $markup = $this->generateFileMarkup($fid); + } + + // Create an article node with file markup in the body field. + $edit = array( + 'title' => $this->randomName(8), + 'body[und][0][value]' => $markup, + ); + // Save the article node. First argument is the URL, then the value array + // and the third is the label the button that should be "clicked". + $this->drupalPost('node/add/article', $edit, t('Save')); + + // Get the article node that was saved by the unique title. + $node = $this->drupalGetNodeByTitle($edit['title']); + return $node->nid; + } + + /** + * Tests the tracking of file usages for files submitted via the WYSIWYG editor. + */ + public function testFileUsageIncrementing() { + // Create a file. + $files = $this->drupalGetTestFiles('image'); + $file = file_save(file_uri_to_object($files[0]->uri)); + $fid = $file->fid; + + // There should be zero usages of this file prior to node creation, + $file_uses = file_usage_list($file); + $this->assertEqual(empty($file_uses), TRUE, t('Created a new file with zero uses.')); + + // Create a node to test with. + $nid = $this->createNode($fid); + + // Get the new file usage count. + $file_uses = file_usage_list($file); + + $this->assertEqual($file_uses['media']['node'][$nid], 1, t('File usage increases when added to a new node.')); + + // Create a new revision that has the file on it. File usage will be 2. + $node = node_load($nid); + $node->revision = TRUE; + node_save($node); + + $node = node_load($nid); + $file_uses = file_usage_list($file); + $revisions = count(node_revision_list($node)); + // Keep track of this VID to test deletion later on. + $delete_one = $node->vid; + + $this->assertEqual($revisions, 2, t('Node save created a second revision')); + $this->assertEqual($file_uses['media']['node'][$nid], 2, t('File usage incremented with a new node revision.')); + + // Create a new revision that has two instances of the file. File usage will + // be 4. + $node = node_load($nid); + $node->body[LANGUAGE_NONE][0]['value'] = $this->generateFileMarkup($fid, 2); + $node->revision = TRUE; + node_save($node); + + $node = node_load($nid); + $file_uses = file_usage_list($file); + $revisions = count(node_revision_list($node)); + // Keep track of this VID to test deletion later on. + $delete_two = $node->vid; + + $this->assertEqual($revisions, 3, t('Node save created a third revision.')); + $this->assertEqual($file_uses['media']['node'][$nid], 4, t('File usage incremented with multiple files and a new node revision.')); + + // Create a new revision that has no file on it. File usage will be 4. + $node = node_load($nid); + $node->body[LANGUAGE_NONE][0]['value'] = ''; + $node->revision = TRUE; + node_save($node); + + $node = node_load($nid); + $file_uses = file_usage_list($file); + $revisions = count(node_revision_list($node)); + // Keep track of this VID to test deletion later on. + $delete_zero = $node->vid; + + $this->assertEqual($revisions, 4, t('Node save created a fourth revision.')); + $this->assertEqual($file_uses['media']['node'][$nid], 4, t('File usage does not change with a new revision of the node without the file')); + + // Create a new revision that has the file on it. File usage will be 5. + $node = node_load($nid); + $node->body[LANGUAGE_NONE][0]['value'] = $this->generateFileMarkup($fid, 1); + $node->revision = TRUE; + node_save($node); + + $node = node_load($nid); + $file_uses = file_usage_list($file); + $revisions = count(node_revision_list($node)); + + $this->assertEqual($revisions, 5, t('Node save created a new revision.')); + $this->assertEqual($file_uses['media']['node'][$nid], 5, t('File usage incremented with a single file on a new node revision.')); + + // Delete a revision that has the file on it once. File usage will be 4. + node_revision_delete($delete_one); + $node = node_load($nid); + $file_uses = file_usage_list($file); + $this->assertEqual($file_uses['media']['node'][$nid], 4, t('Deleting revision with file decreases file usage')); + + // Delete a revision that has no file on it. File usage will be 4. + node_revision_delete($delete_zero); + $node = node_load($nid); + $file_uses = file_usage_list($file); + $this->assertEqual($file_uses['media']['node'][$nid], 4, t('Deleting revision without a file does not change file usage.')); + + // Delete a revision that has the file on it twice. File usage will be 2. + node_revision_delete($delete_two); + $node = node_load($nid); + $file_uses = file_usage_list($file); + $this->assertEqual($file_uses['media']['node'][$nid], 2, t('Deleting revision with file decreases file usage')); + + // Create a new revision with the file on it twice. File usage will be 4. + $node = node_load($nid); + $node->body[LANGUAGE_NONE][0]['value'] = $this->generateFileMarkup($fid, 2); + $node->revision = TRUE; + node_save($node); + + $node = node_load($nid); + $file_uses = file_usage_list($file); + + $this->assertEqual($file_uses['media']['node'][$nid], 4, t('File usage incremented with files on a new node revision.')); + + // Re-save current revision with file on it once instead of twice. File + // usage will be 3. + $node = node_load($nid); + $node->body[LANGUAGE_NONE][0]['value'] = $this->generateFileMarkup($fid, 1); + $saved_vid = $node->vid; + node_save($node); + + $node = node_load($nid); + $file_uses = file_usage_list($file); + + $this->assertEqual($node->vid, $saved_vid, t('Resaved node revision does not create new revision.')); + $this->assertEqual($file_uses['media']['node'][$nid], 3, t('Resaved node revision with fewer files reduces file usage.')); + + // Delete the node. File usage will be 0. + $node = node_load($nid); + node_delete($nid); + + $node = node_load($nid); + $file_uses = file_usage_list($file); + + $this->assertEqual(empty($node), TRUE, t('Node has been deleted.')); + $this->assertEqual(empty($file_uses), TRUE, t('Deleting the node removes all file uses.')); + } + + + /** + * Tests the behavior of node and file deletion. + */ + public function testFileUsageIncrementingDelete() { + // Create a node with file markup in the body field with a new file. + $files = $this->drupalGetTestFiles('image'); + $file = file_save(file_uri_to_object($files[1]->uri)); + $fid = $file->fid; + $file_uses = file_usage_list($file); + + $this->assertEqual(empty($file_uses), TRUE, t('Created a new file with zero uses.')); + + // Create a new node with file markup. + $nid = $this->createNode($fid); + $file_uses = file_usage_list($file); + + $this->assertEqual($file_uses['media']['node'][$nid], 1, t('Incremented file usage on node save.')); + + // Try to delete the file. file_delete() should return file_usage(). + $deleted = file_delete($file); + $this->assertTrue(is_array($deleted), t('File cannot be deleted while in use by a node.')); + + // Delete the node. + node_delete($nid); + $node = node_load($nid); + $file_uses = file_usage_list($file); + + $this->assertEqual(empty($node), TRUE, t('Node has been deleted.')); + $this->assertEqual(empty($file_uses), TRUE, t('File has zero usage after node is deleted.')); + + $deleted = file_delete($file); + $this->assertTrue($deleted, t('File can be deleted with no usage.')); + + $file = file_load($fid); + $this->assertTrue(empty($file), t('File no longer exists after delete.')); + } + +}