Index: modules/simpletest/tests/file.test =================================================================== RCS file: /cvs/drupal/drupal/modules/simpletest/tests/file.test,v retrieving revision 1.40 diff -u -r1.40 file.test --- modules/simpletest/tests/file.test 19 Aug 2009 08:38:09 -0000 1.40 +++ modules/simpletest/tests/file.test 27 Aug 2009 18:13:38 -0000 @@ -280,7 +280,7 @@ return array( 'name' => 'File space used tests', 'description' => 'Tests the file_space_used() function.', - 'group' => 'File', + 'group' => 'File API', ); } @@ -340,7 +340,7 @@ return array( 'name' => 'File validator tests', 'description' => 'Tests the functions used to validate uploaded files.', - 'group' => 'File', + 'group' => 'File API', ); } @@ -498,7 +498,7 @@ return array( 'name' => 'Unmanaged file save data', 'description' => 'Tests the unmanaged file save data function.', - 'group' => 'File', + 'group' => 'File API', ); } @@ -542,7 +542,7 @@ return array( 'name' => 'File uploading', 'description' => 'Tests the file uploading functions.', - 'group' => 'File', + 'group' => 'File API', ); } @@ -685,7 +685,7 @@ return array( 'name' => 'File paths and directories', 'description' => 'Tests operations dealing with directories.', - 'group' => 'File', + 'group' => 'File API', ); } @@ -810,7 +810,7 @@ return array( 'name' => 'File scan directory', 'description' => 'Tests the file_scan_directory() function.', - 'group' => 'File', + 'group' => 'File API', ); } @@ -940,7 +940,7 @@ return array( 'name' => 'Unmanaged file delete', 'description' => 'Tests the unmanaged file delete function.', - 'group' => 'File', + 'group' => 'File API', ); } @@ -986,7 +986,7 @@ return array( 'name' => 'Unmanaged recursive file delete', 'description' => 'Tests the unmanaged file delete recursive function.', - 'group' => 'File', + 'group' => 'File API', ); } @@ -1063,7 +1063,7 @@ return array( 'name' => 'Unmanaged file moving', 'description' => 'Tests the unmanaged file move function.', - 'group' => 'File', + 'group' => 'File API', ); } @@ -1136,7 +1136,7 @@ return array( 'name' => 'Unmanaged file copying', 'description' => 'Tests the unmanaged file copy function.', - 'group' => 'File', + 'group' => 'File API', ); } @@ -1224,7 +1224,7 @@ return array( 'name' => 'File delete', 'description' => 'Tests the file delete function.', - 'group' => 'File', + 'group' => 'File API', ); } @@ -1255,7 +1255,7 @@ return array( 'name' => 'File moving', 'description' => 'Tests the file move function.', - 'group' => 'File', + 'group' => 'File API', ); } @@ -1416,7 +1416,7 @@ return array( 'name' => 'File copying', 'description' => 'Tests the file copy function.', - 'group' => 'File', + 'group' => 'File API', ); } @@ -1562,7 +1562,7 @@ return array( 'name' => 'File loading', 'description' => 'Tests the file_load() function.', - 'group' => 'File', + 'group' => 'File API', ); } @@ -1643,7 +1643,7 @@ return array( 'name' => 'File saving', 'description' => 'Tests the file_save() function.', - 'group' => 'File', + 'group' => 'File API', ); } @@ -1700,7 +1700,7 @@ return array( 'name' => 'File validate', 'description' => 'Tests the file_validate() function.', - 'group' => 'File', + 'group' => 'File API', ); } @@ -1739,7 +1739,7 @@ return array( 'name' => 'File save data', 'description' => 'Tests the file save data function.', - 'group' => 'File', + 'group' => 'File API', ); } @@ -1870,7 +1870,7 @@ return array( 'name' => 'File download', 'description' => 'Tests for file download/transfer functions.', - 'group' => 'File', + 'group' => 'File API', ); } @@ -1915,7 +1915,7 @@ return array( 'name' => 'File naming', 'description' => 'Test filename munging and unmunging.', - 'group' => 'File', + 'group' => 'File API', ); } @@ -1976,7 +1976,7 @@ return array( 'name' => 'File mimetypes', 'description' => 'Test filename mimetype detection.', - 'group' => 'File', + 'group' => 'File API', ); } @@ -2062,7 +2062,7 @@ return array( 'name' => 'Stream Wrapper Registry', 'description' => 'Tests stream wrapper registry.', - 'group' => 'File', + 'group' => 'File API', ); } Index: modules/file/tests/file_module_test.module =================================================================== RCS file: modules/file/tests/file_module_test.module diff -N modules/file/tests/file_module_test.module --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ modules/file/tests/file_module_test.module 1 Jan 1970 00:00:00 -0000 @@ -0,0 +1,52 @@ + 'Managed file test', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('file_module_test_form'), + 'access arguments' => array('access content'), + ); + + return $items; +} + +function file_module_test_form($form_state) { + $form = array( + '#tree' => TRUE, + ); + + $form['file'] = array( + '#type' => 'managed_file', + '#title' => t('Managed file'), + '#upload_location' => 'public://test', + '#progress_message' => t('Please wait...'), + ); + + $form['textfield'] = array( + '#type' => 'textfield', + '#title' => t('Type a value and ensure it stays'), + ); + + $form['submit'] = array( + '#type' => 'submit', + '#value' => t('Save'), + ); + + return $form; +} + +function file_module_test_form_submit(&$form, &$form_state) { + debug($form_state); +} Index: modules/file/tests/file_module_test.info =================================================================== RCS file: modules/file/tests/file_module_test.info diff -N modules/file/tests/file_module_test.info --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ modules/file/tests/file_module_test.info 1 Jan 1970 00:00:00 -0000 @@ -0,0 +1,8 @@ +; $Id: file_module_test.info,v 1.2 2009/08/27 02:19:41 quicksketch Exp $ +name = File test +description = Provides hooks for testing File module functionality. +package = Core - fields +version = VERSION +core = 7.x +files[] = file_module_test.module +hidden = TRUE Index: modules/file/file.css =================================================================== RCS file: modules/file/file.css diff -N modules/file/file.css --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ modules/file/file.css 1 Jan 1970 00:00:00 -0000 @@ -0,0 +1,36 @@ +/* $Id: file.css,v 1.6 2009/08/23 23:59:07 quicksketch Exp $ */ + +/** + * Managed file element styles. + */ +.form-managed-file .form-file, +.form-managed-file .form-submit { + margin: 0; +} + +.form-managed-file input.progress-disabled { + float: none; + display: inline; +} + +.form-managed-file div.ajax-progress, +.form-managed-file div.throbber { + display: inline; + float: none; + padding: 1px 5px 2px 5px; +} + +.form-managed-file div.ajax-progress div { + display: inline; +} + +.form-managed-file div.ajax-progress-bar { + display: none; + margin-top: 4px; + width: 28em; + padding: 0; +} + +.form-managed-file div.ajax-progress-bar div.bar { + margin: 0; +} Index: modules/file/file.install =================================================================== RCS file: modules/file/file.install diff -N modules/file/file.install --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ modules/file/file.install 1 Jan 1970 00:00:00 -0000 @@ -0,0 +1,61 @@ +apc.rfc1867 = 1 to your php.ini configuration. Alternatively, it is recommended to use PECL uploadprogress, which supports more than one simultaneous upload.'); + $severity = REQUIREMENT_INFO; + } + elseif (!$implementation) { + $value = t('Not enabled'); + $description = t('Your server is capable of displaying file upload progress, but does not have the required libraries. It is recommended to install the PECL uploadprogress library (preferred) or to install APC.'); + $severity = REQUIREMENT_INFO; + } + elseif ($implementation == 'apc') { + $value = t('Enabled (APC RFC1867)'); + $description = t('Your server is capable of displaying file upload progress using APC RFC1867. Note that only one upload at a time is supported. It is recommended to use the PECL uploadprogress library if possible.'); + $severity = REQUIREMENT_OK; + } + elseif ($implementation == 'uploadprogress') { + $value = t('Enabled (PECL uploadprogress)'); + $severity = REQUIREMENT_OK; + } + $requirements['file_progress'] = array( + 'title' => t('Upload progress'), + 'value' => $value, + 'severity' => $severity, + 'description' => $description, + ); + } + + return $requirements; +} Index: modules/file/file.info =================================================================== RCS file: modules/file/file.info diff -N modules/file/file.info --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ modules/file/file.info 1 Jan 1970 00:00:00 -0000 @@ -0,0 +1,10 @@ +; $Id: file.info,v 1.5 2009/08/26 00:38:18 quicksketch Exp $ +name = File +description = Defines a file field type. +package = Core - fields +version = VERSION +core = 7.x +files[] = file.module +files[] = file.field.inc +files[] = file.install +files[] = tests/file.test Index: modules/file/file.module =================================================================== RCS file: modules/file/file.module diff -N modules/file/file.module --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ modules/file/file.module 1 Jan 1970 00:00:00 -0000 @@ -0,0 +1,956 @@ + 'file_ajax_upload', + 'access arguments' => array('access content'), + 'type' => MENU_CALLBACK, + ); + $items['file/progress'] = array( + 'page callback' => 'file_ajax_progress', + 'access arguments' => array('access content'), + 'type' => MENU_CALLBACK, + ); + + return $items; +} + +/** + * Implement hook_elements(). + */ +function file_elements() { + $elements = array(); + + $file_path = drupal_get_path('module', 'file'); + + // The managed file element may be used independently anywhere in Drupal. + $elements['managed_file'] = array( + '#input' => TRUE, + '#process' => array('file_managed_file_process'), + '#value_callback' => 'file_managed_file_value', + '#element_validate' => array('file_managed_file_validate'), + '#theme' => 'file_managed_file', + '#theme_wrappers' => array('form_element'), + '#progress_indicator' => 'throbber', + '#progress_message' => NULL, + '#upload_validators' => array(), + '#upload_location' => NULL, + '#extended' => FALSE, + '#attached_css' => array($file_path . '/file.css'), + '#attached_js' => array($file_path . '/file.js'), + ); + + return $elements; +} + +/** + * Implement hook_theme(). + */ +function file_theme() { + return array( + // file.module. + 'file_link' => array( + 'arguments' => array('file' => NULL), + ), + 'file_icon' => array( + 'arguments' => array('file' => NULL), + ), + 'file_managed_file' => array( + 'arguments' => array('element' => NULL), + ), + + // file.field.inc. + 'file_widget' => array( + 'arguments' => array('element' => NULL), + ), + 'file_widget_multiple' => array( + 'arguments' => array('element' => NULL), + ), + 'file_upload_help' => array( + 'arguments' => array('upload_validators' => NULL), + ), + 'field_formatter_file_default' => array( + 'arguments' => array('element' => NULL), + ), + 'field_formatter_file_table' => array( + 'arguments' => array('element' => NULL), + ), + 'field_formatter_file_url_plain' => array( + 'arguments' => array('element' => NULL), + ), + ); +} + +/** + * Implement hook_file_download(). + */ +function file_file_download($uri) { + global $user; + + // Get the file record based on the URI. If not in the database just return. + $files = file_load_multiple(array(), array('uri' => $uri)); + if (count($files)) { + $file = reset($files); + } + else { + return; + } + + // Find out which (if any) file fields contain this file. + $references = file_get_file_references($file); + + // TODO: Check field-level access if available here. + + $denied = NULL; + // Check access to content containing the file fields. If access is allowed + // to any of this content, allow the download. + foreach ($references as $field_name => $field_references) { + foreach ($field_references as $obj_type => $type_references) { + foreach ($type_references as $reference) { + // If access is allowed to any object, immediately stop and grant + // access. If access is denied, continue through in case another object + // grants access. + // TODO: Switch this to a universal access check mechanism if available. + if ($obj_type == 'node' && ($node = node_load($reference->nid))) { + if (node_access('view', $node)) { + $denied = FALSE; + break 3; + } + else { + $denied = TRUE; + } + } + if ($obj_type == 'user') { + if (user_access('access user profiles') || $user->uid == $reference->uid) { + $denied = FALSE; + break 3; + } + else { + $denied = TRUE; + } + } + } + } + } + + // No access was denied or granted. + if (!isset($denied)) { + return; + } + // Access specifically denied and not granted elsewhere. + elseif ($denied == TRUE) { + return -1; + } + + // Access is granted. + $name = mime_header_encode($file->filename); + $type = mime_header_encode($file->filemime); + // Serve images, text, and flash content for display rather than download. + $inline_types = variable_get('file_inline_types', array('^text/', '^image/', 'flash$')); + $disposition = 'attachment'; + foreach ($inline_types as $inline_type) { + // Exclamation marks are used as delimiters to avoid escaping slashes. + if (preg_match('!' . $inline_type . '!', $file->filemime)) { + $disposition = 'inline'; + } + } + + return array( + 'Content-Type' => $type . '; name="' . $name . '"', + 'Content-Length' => $file->filesize, + 'Content-Disposition' => $disposition . '; filename="' . $name . '"', + 'Cache-Control' => 'private', + ); +} + +/** + * Menu callback; Shared AJAX callback for file uploads and deletions. + * + * This rebuilds the form element for a particular field item. As long as the + * form processing is properly encapsulated in the widget element the form + * should rebuild correctly using FAPI without the need for additional callbacks + * or processing. + */ +function file_ajax_upload() { + $form_parents = func_get_args(); + $form_build_id = (string) array_pop($form_parents); + + if (empty($_POST['form_build_id']) || $form_build_id != $_POST['form_build_id']) { + // Invalid request. + drupal_set_message(t('An unrecoverable error occurred. The uploaded file likely exceeded the maximum file size (@size) that this server supports.', array('@size' => format_size(file_upload_max_size()))), 'error'); + $commands = array(); + $commands[] = ajax_command_replace(NULL, theme('status_messages')); + ajax_render($commands, FALSE); + } + + list($form, $form_state, $form_id, $form_build_id) = ajax_get_form(); + + if (!$form) { + // Invalid form_build_id. + drupal_set_message(t('An unrecoverable error occurred. Use of this form has expired. Try reloading the page and submitting again.'), 'error'); + $commands = array(); + $commands[] = ajax_command_replace(NULL, theme('status_messages')); + ajax_render($commands, FALSE); + } + + // Get the current element and count the number of files. + $current_element = $form; + foreach ($form_parents as $parent) { + $current_element = $current_element[$parent]; + } + $current_file_count = isset($current_element['#file_upload_delta']) ? $current_element['#file_upload_delta'] : 0; + + // Build, validate and if possible, submit the form. + drupal_process_form($form_id, $form, $form_state); + + // This call recreates the form relying solely on the form_state that the + // drupal_process_form set up. + $form = drupal_rebuild_form($form_id, $form_state, $form_build_id); + + // Retrieve the element to be rendered. + foreach ($form_parents as $parent) { + $form = $form[$parent]; + } + + // Add the special AJAX class if a new file was added. + if (isset($form['#file_upload_delta']) && $current_file_count < $form['#file_upload_delta']) { + $form[$current_file_count]['#attributes']['class'][] = 'ajax-new-content'; + } + // Otherwise just add the new content class on a placeholder. + else { + $form['#suffix'] .= ''; + } + + // drupal_render() MUST be run before the status messages. + $output = drupal_render($form); + $output = theme('status_messages') . $output; + $js = drupal_add_js(); + $settings = call_user_func_array('array_merge_recursive', $js['settings']['data']); + + $commands = array(); + $commands[] = ajax_command_replace(NULL, $output, $settings); + ajax_render($commands, FALSE); +} + +/** + * Menu callback for upload progress. + * + * @param $key + * The unique key for this upload process. + */ +function file_ajax_progress($key) { + $progress = array( + 'message' => t('Starting upload...'), + 'percentage' => -1, + ); + + $implementation = file_progress_implementation(); + if ($implementation == 'uploadprogress') { + $status = uploadprogress_get_info($key); + if (isset($status['bytes_uploaded']) && !empty($status['bytes_total'])) { + $progress['message'] = t('Uploading... (@current of @total)', array('@current' => format_size($status['bytes_uploaded']), '@total' => format_size($status['bytes_total']))); + $progress['percentage'] = round(100 * $status['bytes_uploaded'] / $status['bytes_total']); + } + } + elseif ($implementation == 'apc') { + $status = apc_fetch('upload_' . $key); + if (isset($status['current']) && !empty($status['total'])) { + $progress['message'] = t('Uploading... (@current of @total)', array('@current' => format_size($status['current']), '@total' => format_size($status['total']))); + $progress['percentage'] = round(100 * $status['current'] / $status['total']); + } + } + + drupal_json($progress); +} + +/** + * Determine the preferred upload progress implementation. + * + * @return + * A string indicating which upload progress system is available. Either "apc" + * or "uploadprogress". If neither are available, returns FALSE. + */ +function file_progress_implementation() { + static $implementation; + if (!isset($implementation)) { + $implementation = FALSE; + + // We prefer the PECL extension uploadprogress because it supports multiple + // simultaneous uploads. APC only supports one at a time. + if (extension_loaded('uploadprogress')) { + $implementation = 'uploadprogress'; + } + elseif (extension_loaded('apc') && ini_get('apc.rfc1867')) { + $implementation = 'apc'; + } + } + return $implementation; +} + +/** + * Implement hook_file_references(). + */ +function file_file_references($file) { + $count = file_get_file_reference_count($file); + return $count ? array('file' => $count) : NULL; +} + +/** + * Implement hook_file_delete(). + */ +function file_file_delete($file) { + // TODO: Remove references to a file that is in-use. +} + +/** + * Process function to expand the managed_file element type. + * + * Expands the file type to include Upload and Remove buttons, as well as + * support for a default value. + */ +function file_managed_file_process($element, &$form_state, $form) { + $fid = isset($element['#value']['fid']) ? $element['#value']['fid'] : 0; + + // Set some default element properties. + $element['#progress_indicator'] = empty($element['#progress_indicator']) ? 'none' : $element['#progress_indicator']; + $element['#file'] = $fid ? file_load($fid) : FALSE; + $element['#tree'] = TRUE; + + $ajax_settings = array( + 'path' => 'file/ajax/' . implode('/', $element['#parents']) . '/' . $form['form_build_id']['#value'], + 'wrapper' => $element['#id'] . '-ajax-wrapper', + 'method' => 'replace', + 'effect' => 'fade', + 'progress' => array( + 'type' => $element['#progress_indicator'], + 'message' => $element['#progress_message'], + ), + ); + + // Set up the buttons first since we need to check if they were clicked. + $element['upload_button'] = array( + '#name' => implode('_', $element['#parents']) . '_upload_button', + '#type' => 'submit', + '#value' => t('Upload'), + '#validate' => array(), + '#submit' => array('file_managed_file_submit'), + '#ajax' => $ajax_settings, + '#weight' => -5, + ); + + $ajax_settings['progress']['type'] ? $ajax_settings['progress']['type'] == 'bar' : 'throbber'; + $ajax_settings['progress']['message'] = NULL; + $ajax_settings['effect'] = 'none'; + $element['remove_button'] = array( + '#name' => implode('_', $element['#parents']) . '_remove_button', + '#type' => 'submit', + '#value' => t('Remove'), + '#validate' => array(), + '#submit' => array('file_managed_file_submit'), + '#ajax' => $ajax_settings, + '#weight' => -5, + ); + + // Because the output of this field changes depending on the button clicked, + // we need to ask FAPI immediately if the remove button was clicked. + // It's not good that we call this private function, but + // $form_state['clicked_button'] is only available after this #process + // callback is finished. + if (_form_button_was_clicked($element['remove_button'], $form_state)) { + // If it's a temporary file we can safely remove it immediately, otherwise + // it's up to the implementing module to clean up files that are in use. + if ($element['#file'] && $element['#file']->status == 0) { + file_delete($element['#file']); + } + $element['#file'] = FALSE; + $fid = 0; + } + + // Set access on the buttons. + $element['upload_button']['#access'] = empty($fid); + $element['remove_button']['#access'] = !empty($fid); + + $element['fid'] = array( + '#type' => 'hidden', + '#value' => $fid, + ); + + // Add progress bar support to the upload if possible. + if ($element['#progress_indicator'] == 'bar' && $implementation = file_progress_implementation()) { + $upload_progress_key = md5(mt_rand()); + + if ($implementation == 'uploadprogress') { + $element['UPLOAD_IDENTIFIER'] = array( + '#type' => 'hidden', + '#value' => $upload_progress_key, + '#attributes' => array('class' => array('file-progress')), + ); + } + elseif ($implementation == 'apc') { + $element['APC_UPLOAD_PROGRESS'] = array( + '#type' => 'hidden', + '#value' => $upload_progress_key, + '#attributes' => array('class' => array('file-progress')), + ); + } + + // Add the upload progress callback. + $element['upload_button']['#ajax']['progress']['path'] = 'file/progress/' . $upload_progress_key; + } + + // The file upload field itself. + $element['upload'] = array( + '#name' => 'files[' . implode('_', $element['#parents']) . ']', + '#type' => 'file', + '#size' => 22, + '#access' => empty($fid), + '#theme_wrappers' => array(), + '#weight' => -10, + ); + + if ($fid && $element['#file']) { + $element['filename'] = array( + '#type' => 'markup', + '#markup' => theme('file_link', $element['#file']) . ' ', + '#weight' => -10, + ); + } + + // The "accept" attribute is valid XHTML, but not enforced in browsers. + // We use it for our own purposes in our JavaScript validation. + if (isset($element['#upload_validators']['file_validate_extensions'][0])) { + $element['upload']['#attributes']['accept'] = implode(',', array_filter(explode(' ', $element['#upload_validators']['file_validate_extensions'][0]))); + } + + // Prefix and suffix used for AJAX replacement. + $element['#prefix'] = '
'; + $element['#suffix'] = '
'; + + return $element; +} + +/** + * The #value_callback for a managed_file type element. + */ +function file_managed_file_value(&$element, $input = FALSE, $form_state = NULL) { + $fid = 0; + + // Find the current value of this field from the form state. + $form_state_fid = $form_state['values']; + foreach ($element['#parents'] as $parent) { + $form_state_fid = isset($form_state_fid[$parent]) ? $form_state_fid[$parent] : 0; + } + + if ($element['#extended'] && isset($form_state_fid['fid'])) { + $fid = $form_state_fid['fid']; + } + elseif (is_numeric($form_state_fid)) { + $fid = $form_state_fid; + } + + // Process any input and save new uploads. + if ($input !== FALSE) { + $return = $input; + + // Uploads take priority over all other values. + if ($file = file_managed_file_save_upload($element)) { + $fid = $file->fid; + } + else { + // Check for #filefield_value_callback values. + // Because FAPI does not allow multiple #value_callback values like it + // does for #element_validate and #process, this fills the missing + // functionality to allow File fields to be extended through FAPI. + if (isset($element['#file_value_callbacks'])) { + foreach ($element['#file_value_callbacks'] as $callback) { + $callback($element, $input); + } + } + // Load file if the FID has changed to confirm it exists. + if (isset($input['fid']) && $file = file_load($input['fid'])) { + $fid = $file->fid; + } + } + } + + // If there is no input, set the default value. + else { + if ($element['#extended']) { + $default_fid = isset($element['#default_value']['fid']) ? $element['#default_value']['fid'] : 0; + $return = isset($element['#default_value']) ? $element['#default_value'] : array('fid' => 0); + } + else { + $default_fid = isset($element['#default_value']) ? $element['#default_value'] : 0; + $return = array('fid' => 0); + } + + // Confirm that the file exists when used as a default value. + if ($default_fid && $file = file_load($default_fid)) { + $fid = $file->fid; + } + } + + $return['fid'] = $fid; + + return $return; +} + +/** + * An #element_validate callback for the managed_file element. + */ +function file_managed_file_validate(&$element, &$form_state) { + // If referencing an existing file, only allow if there are existing + // references. This prevents unmanaged files from being deleted if this + // item were to be deleted. + $clicked_button = end($form_state['clicked_button']['#parents']); + if ($clicked_button != 'remove_button' && !empty($element['fid']['#value'])) { + if ($file = file_load($element['fid']['#value'])) { + if ($file->status == FILE_STATUS_PERMANENT) { + $reference_count = 0; + foreach (module_invoke_all('file_references', $file) as $module => $references) { + $reference_count += $references; + } + if ($reference_count == 0) { + form_error($element, t('Referencing to the file used in the !name field is not allowed.', array('!name' => $element['#title']))); + } + } + } + else { + form_error($element, t('The file referenced by the !name field does not exist.', array('!name' => $element['#title']))); + } + } + + // Check required property based on the FID. + if ($element['#required'] && empty($element['fid']['#value']) && !in_array($clicked_button, array('upload_button', 'remove_button'))) { + form_error($element['upload'], t('!name field is required.', array('!name' => $element['#title']))); + } + + // Consolidate the array value of this field to a single FID. + if (!$element['#extended']) { + form_set_value($element, $element['fid']['#value'], $form_state); + } +} + +/** + * Submit handler for non-JavaScript uploads. + */ +function file_managed_file_submit($form, &$form_state) { + // Do not redirect and leave the page after uploading a file. This keeps + // all the current form values in place. The file is saved by the + // #value_callback on the form element. + $form_state['redirect'] = FALSE; +} + +/** + * Given a managed_file element, save any files that have been uploaded into it. + * + * @param $element + * The FAPI element whose values are being saved. + * @return + * The file object representing the file that was saved, or FALSE if no file + * was saved. + */ +function file_managed_file_save_upload($element) { + $upload_name = implode('_', $element['#parents']); + if (empty($_FILES['files']['name'][$upload_name])) { + return FALSE; + } + + $destination = isset($element['#upload_location']) ? $element['#upload_location'] : NULL; + if (isset($destination) && !file_prepare_directory($destination, FILE_CREATE_DIRECTORY)) { + watchdog('file', 'The upload directory %directory for the file field !name could not be created or is not accessible. A newly uploaded file could not be saved in this directory as a consequence, and the upload was canceled.', array('%directory' => $destination, '!name' => $element['#field_name'])); + form_set_error($upload_name, t('The file could not be uploaded.')); + return FALSE; + } + + if (!$file = file_save_upload($upload_name, $element['#upload_validators'], $destination)) { + watchdog('file', 'The file upload failed. %upload', array('%upload' => $upload_name)); + form_set_error($upload_name, t('The file in the !name field was unable to be uploaded.', array('!name' => $element['#title']))); + return FALSE; + } + + return $file; +} + +/** + * Theme a managed file element. + */ +function theme_file_managed_file($element) { + // This wrapper is required to apply JS behaviors and CSS styling. + $output = ''; + $output .= '
'; + $output .= drupal_render_children($element); + $output .= '
'; + return $output; +} + +/** + * Output a link to a file. + * + * @param $file + * A file object to which the link will be created. + */ +function theme_file_link($file) { + $url = file_create_url($file->uri); + $icon = theme('file_icon', $file); + + // Set options as per anchor format described at + // http://microformats.org/wiki/file-format-examples + $options = array( + 'attributes' => array( + 'type' => $file->filemime . '; length=' . $file->filesize, + ), + ); + + // Use the description as the link text if available. + if (empty($file->data['description'])) { + $link_text = check_plain($file->filename); + } + else { + $link_text = check_plain($file->data['description']); + $options['attributes']['title'] = check_plain($file->filename); + } + + return '' . $icon . ' ' . l($link_text, $url, $options) . ''; +} + +/** + * Return an image with an appropriate icon for the given file. + * + * @param $file + * A file object for which to make an icon. + */ +function theme_file_icon($file) { + $mime = check_plain($file->filemime); + $icon_url = file_icon_url($file); + return ''; +} + +/** + * Given a file object, create a URL to a matching icon. + * + * @param $file + * A file object. + * @param $icon_directory + * (optional) A path to a directory of icons to be used for files. Defaults to + * the value of the "file_icon_directory" variable. + * @return + * A URL string to the icon, or FALSE if an appropriate icon cannot be found. + */ +function file_icon_url($file, $icon_directory = NULL) { + if ($icon_path = file_icon_path($file, $icon_directory)) { + return base_path() . $icon_path; + } + return FALSE; +} + +/** + * Given a file object, create a path to a matching icon. + * + * @param $file + * A file object. + * @param $icon_directory + * (optional) A path to a directory of icons to be used for files. Defaults to + * the value of the "file_icon_directory" variable. + * @return + * A string to the icon as a local path, or FALSE if an appropriate icon could + * not be found. + */ +function file_icon_path($file, $icon_directory = NULL) { + // Use the default set of icons if none specified. + if (!isset($icon_directory)) { + $icon_directory = variable_get('file_icon_directory', drupal_get_path('module', 'file') . '/icons'); + } + + // If there's an icon matching the exact mimetype, go for it. + $dashed_mime = strtr($file->filemime, array('/' => '-')); + $icon_path = $icon_directory . '/' . $dashed_mime . '.png'; + if (file_exists($icon_path)) { + return $icon_path; + } + + // For a few mimetypes, we can "manually" map to a generic icon. + $generic_mime = (string) file_icon_map($file); + $icon_path = $icon_directory . '/' . $generic_mime . '.png'; + if ($generic_mime && file_exists($icon_path)) { + return $icon_path; + } + + // Use generic icons for each category that provides such icons. + foreach (array('audio', 'image', 'text', 'video') as $category) { + if (strpos($file->filemime, $category . '/') === 0) { + $icon_path = $icon_directory . '/' . $category . '-x-generic.png'; + if (file_exists($icon_path)) { + return $icon_path; + } + } + } + + // Try application-octet-stream as last fallback. + $icon_path = $icon_directory . '/application-octet-stream.png'; + if (file_exists($icon_path)) { + return $icon_path; + } + + // No icon can be found. + return FALSE; +} + +/** + * Determine the generic icon MIME package based on a file's MIME type. + * + * @param $file + * A file object. + * @return + * The generic icon MIME package expected for this file. + */ +function file_icon_map($file) { + switch ($file->filemime) { + // Word document types. + case 'application/msword': + case 'application/vnd.ms-word.document.macroEnabled.12': + case 'application/vnd.oasis.opendocument.text': + case 'application/vnd.oasis.opendocument.text-template': + case 'application/vnd.oasis.opendocument.text-master': + case 'application/vnd.oasis.opendocument.text-web': + case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': + case 'application/vnd.stardivision.writer': + case 'application/vnd.sun.xml.writer': + case 'application/vnd.sun.xml.writer.template': + case 'application/vnd.sun.xml.writer.global': + case 'application/vnd.wordperfect': + case 'application/x-abiword': + case 'application/x-applix-word': + case 'application/x-kword': + case 'application/x-kword-crypt': + return 'x-office-document'; + + // Spreadsheet document types. + case 'application/vnd.ms-excel': + case 'application/vnd.ms-excel.sheet.macroEnabled.12': + case 'application/vnd.oasis.opendocument.spreadsheet': + case 'application/vnd.oasis.opendocument.spreadsheet-template': + case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': + case 'application/vnd.stardivision.calc': + case 'application/vnd.sun.xml.calc': + case 'application/vnd.sun.xml.calc.template': + case 'application/vnd.lotus-1-2-3': + case 'application/x-applix-spreadsheet': + case 'application/x-gnumeric': + case 'application/x-kspread': + case 'application/x-kspread-crypt': + return 'x-office-spreadsheet'; + + // Presentation document types. + case 'application/vnd.ms-powerpoint': + case 'application/vnd.ms-powerpoint.presentation.macroEnabled.12': + case 'application/vnd.oasis.opendocument.presentation': + case 'application/vnd.oasis.opendocument.presentation-template': + case 'application/vnd.openxmlformats-officedocument.presentationml.presentation': + case 'application/vnd.stardivision.impress': + case 'application/vnd.sun.xml.impress': + case 'application/vnd.sun.xml.impress.template': + case 'application/x-kpresenter': + return 'x-office-presentation'; + + // Compressed archive types. + case 'application/zip': + case 'application/x-zip': + case 'application/stuffit': + case 'application/x-stuffit': + case 'application/x-7z-compressed': + case 'application/x-ace': + case 'application/x-arj': + case 'application/x-bzip': + case 'application/x-bzip-compressed-tar': + case 'application/x-compress': + case 'application/x-compressed-tar': + case 'application/x-cpio-compressed': + case 'application/x-deb': + case 'application/x-gzip': + case 'application/x-java-archive': + case 'application/x-lha': + case 'application/x-lhz': + case 'application/x-lzop': + case 'application/x-rar': + case 'application/x-rpm': + case 'application/x-tzo': + case 'application/x-tar': + case 'application/x-tarz': + case 'application/x-tgz': + return 'package-x-generic'; + + // Script file types. + case 'application/ecmascript': + case 'application/javascript': + case 'application/mathematica': + case 'application/vnd.mozilla.xul+xml': + case 'application/x-asp': + case 'application/x-awk': + case 'application/x-cgi': + case 'application/x-csh': + case 'application/x-m4': + case 'application/x-perl': + case 'application/x-php': + case 'application/x-ruby': + case 'application/x-shellscript': + case 'text/vnd.wap.wmlscript': + case 'text/x-emacs-lisp': + case 'text/x-haskell': + case 'text/x-literate-haskell': + case 'text/x-lua': + case 'text/x-makefile': + case 'text/x-matlab': + case 'text/x-python': + case 'text/x-sql': + case 'text/x-tcl': + return 'text-x-script'; + + // HTML aliases. + case 'application/xhtml+xml': + return 'text-html'; + + // Executable types. + case 'application/x-macbinary': + case 'application/x-ms-dos-executable': + case 'application/x-pef-executable': + return 'application-x-executable'; + + default: + return FALSE; + } +} + +/******************************************************************************* + * Public API functions for File module. + ******************************************************************************/ + +/** + * Return an array of file fields in an bundle or by field name. + * + * @param $bundle_type + * (optional) The bundle type on which to filter the list of fields. In the + * case of nodes, this is the node type. + * @param $field + * (optional) A specific field name to retrieve. + */ +function file_get_field_list($bundle_type = NULL, $field = NULL) { + // Build the list of fields to be used for retrieval. + if (isset($field)) { + if (is_string($field)) { + $field = field_info_field($field); + } + $fields = array($field['field_name'] => $field); + } + elseif (isset($bundle_type)) { + $instances = field_info_instances($bundle_type); + $fields = array(); + foreach ($instances as $field_name => $instance) { + $fields[$field_name] = field_info_field($field_name); + } + } + else { + $fields = field_info_fields(); + } + + // Filter down the list to just file fields. + foreach ($fields as $key => $field) { + if ($field['type'] != 'file') { + unset($fields[$key]); + } + } + + return $fields; +} + +/** + * Count the number of times the file is referenced within a field. + * + * @param $file + * A file object. + * @param $field + * Optional. The CCK field array or field name as a string. + * @return + * An integer value. + */ +function file_get_file_reference_count($file, $field = NULL) { + $fields = file_get_field_list(NULL, $field); + $types = field_info_fieldable_types(); + $reference_count = 0; + + foreach ($fields as $field) { + // TODO: Use a more efficient mechanism rather than actually retrieving + // all the references themselves, such as using a COUNT() query. + $references = file_get_file_references($file, $field); + foreach ($references as $obj_type => $type_references) { + $reference_count += count($type_references); + } + + // If a field_name is present in the file object, the file is being deleted + // from this field. + if (isset($file->file_field_name) && $field['field_name'] == $file->file_field_name) { + // If deleting the entire piece of content, decrement references. + if (isset($file->file_field_type) && isset($file->file_field_id)) { + if ($file->file_field_type == $obj_type) { + $info = field_info_fieldable_types($obj_type); + $id = $types[$obj_type]['object keys']['id']; + foreach ($type_references as $reference) { + if ($file->file_field_id == $reference->$id) { + $reference_count--; + } + } + } + } + // Otherwise we're just deleting a single reference in this field. + else { + $reference_count--; + } + } + } + + return $reference_count; +} + + +/** + * Get a list of references to a file by bundle and ID. + * + * @param $file + * A file object. + * @param $field + * (optional) A field array to be used for this check. + * @param $age + * (optional) A constant that specifies which references to count. Use + * FIELD_LOAD_REVISION to retrieve all references within all revisions or + * FIELD_LOAD_CURRENT to retrieve references only in the current revisions. + * @return + * An integer value. + */ +function file_get_file_references($file, $field = NULL, $age = FIELD_LOAD_REVISION) { + $references = drupal_static(__FUNCTION__, array()); + $fields = file_get_field_list(NULL, $field); + + foreach ($fields as $field_name => $file_field) { + if (!isset($references[$field_name])) { + // Get each time this file is used within a field. + $cursor = 0; + $references[$field_name] = field_attach_query($file_field['id'], array(array('fid', $file->fid)), FIELD_QUERY_NO_LIMIT, $cursor, $age); + } + } + + return isset($field) ? $references[$field['field_name']] : $references; +} Index: modules/file/file.js =================================================================== RCS file: modules/file/file.js diff -N modules/file/file.js --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ modules/file/file.js 1 Jan 1970 00:00:00 -0000 @@ -0,0 +1,140 @@ +// $Id: file.js,v 1.5 2009/08/27 02:18:13 quicksketch Exp $ + +/** + * @file + * Provides JavaScript additions to the managed file field type. + * + * This file provides progress bar support (if available), popup windows for + * file previews, and disabling of other file fields during AJAX uploads (which + * prevents separate file fields from accidentally uploading files). + */ + +(function($) { + +/** + * Attach behaviors to managed file element upload fields. + */ +Drupal.behaviors.fileValidateAutoAttach = { + attach: function(context) { + $('div.form-managed-file input.form-file[accept]', context).bind('change', Drupal.file.validateExtension); + }, + detach: function(context) { + $('div.form-managed-file input.form-file[accept]', context).unbind('change', Drupal.file.validateExtension); + } +}; + +/** + * Attach behaviors to the file upload and remove buttons. + */ +Drupal.behaviors.fileButtons = { + attach: function(context) { + $('input.form-submit', context).bind('mousedown', Drupal.file.disableFields); + $('div.form-managed-file input.form-submit', context).bind('mousedown', Drupal.file.progressBar); + }, + unattach: function(context) { + $('input.form-submit', context).unbind('mousedown', Drupal.file.disableFields); + $('div.form-managed-file input.form-submit', context).unbind('mousedown', Drupal.file.progressBar); + } +}; + +/** + * Attach behaviors to links within managed file elements. + */ +Drupal.behaviors.filePreviewLinks = { + attach: function(context) { + $('div.form-managed-file .file a, .file-widget .file a', context).bind('click',Drupal.file.openInNewWindow); + }, + detach: function(context){ + $('div.form-managed-file .file a, .file-widget .file a', context).unbind('click', Drupal.file.openInNewWindow); + } +}; + +/** + * File upload utility functions. + */ +Drupal.file = Drupal.file || { + /** + * Client-side file input validation based on the HTML "accept" attribute. + */ + validateExtension: function(event) { + // Remove any previous errors. + $('.file-upload-js-error').remove(); + + // Add client side validation for the input[type=file] accept attribute. + var accept = this.accept.replace(/,\s*/g, '|'); + if (accept.length > 1 && this.value.length > 0) { + var acceptableMatch = new RegExp('\\.(' + accept + ')$', 'gi'); + if (!acceptableMatch.test(this.value)) { + var error = Drupal.t("The selected file %filename cannot not be uploaded. Only files with the following extensions are allowed: %extensions.", { + '%filename': this.value, + '%extensions': accept.replace(/\|/g, ', ') + }); + $(this).parents('div.form-managed-file').prepend('
' + error + '
'); + this.value = ''; + return false; + } + } + }, + /** + * Prevent file uploads when using buttons not intended to upload. + */ + disableFields: function(event){ + var clickedButton = this; + + // Only disable upload fields for AJAX buttons. + if (!$(clickedButton).hasClass('ajax-processed')) { + return; + } + + // Check if we're working with an "Upload" button. + var $enabledFields = []; + if ($(this).parents('div.form-managed-file').size() > 0) { + $enabledFields = $(this).parents('div.form-managed-file').find('input.form-file'); + } + + var $disabledFields = $('div.form-managed-file input.form-file').not($enabledFields); + + // Disable upload fields other than the one we're currently working with. + $disabledFields.attr('disabled', 'disabled'); + + // All the other mousedown handlers (like Drupal's AJAX behaviors) are + // excuted before any timeout functions will be called, so this effectively + // re-enables the file fields after other processing is complete even though + // it is only a 1 second timeout. + setTimeout(function(){ + $disabledFields.attr('disabled', ''); + }, 1000); + }, + /** + * Add progress bar support if possible. + */ + progressBar: function(event) { + var clickedButton = this; + var $progressId = $(clickedButton).parents('div.form-managed-file').find('input.file-progress'); + if ($progressId.size()) { + var originalName = $progressId.attr('name'); + + // Replace the name with the required identifier. + $progressId.attr('name', originalName.match(/APC_UPLOAD_PROGRESS|UPLOAD_IDENTIFIER/)[0]); + + // Restore the original name after the upload begins. + setTimeout(function() { + $progressId.attr('name', originalName); + }, 1000); + } + // Show the progress bar if the upload takes longer than half a second. + setTimeout(function() { + $(clickedButton).parents('div.form-managed-file').find('div.ajax-progress-bar').slideDown(); + }, 500); + }, + /** + * Open links to files within forms in a new window. + */ + openInNewWindow: function(event) { + $(this).attr('target', '_blank'); + window.open(this.href, 'filePreview', 'toolbar=0,scrollbars=1,location=1,statusbar=1,menubar=0,resizable=1,width=500,height=550'); + return false; + } +}; + +})(jQuery); Index: modules/file/tests/file.test =================================================================== RCS file: modules/file/tests/file.test diff -N modules/file/tests/file.test --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ modules/file/tests/file.test 1 Jan 1970 00:00:00 -0000 @@ -0,0 +1,553 @@ +admin_user = $this->drupalCreateUser(array('access content', 'access administration pages', 'administer site configuration', 'administer content types', 'administer nodes', 'create article content', 'edit any article content', 'delete any article content', 'administer files')); + $this->drupalLogin($this->admin_user); + } + + /** + * Get a sample file of the specified type. + */ + function getTestFile($type_name, $size = NULL) { + // Get a file to upload. + $file = current($this->drupalGetTestFiles($type_name, $size)); + + // Add a filesize property to files as would be read by file_load(). + $file->filesize = filesize($file->uri); + + return $file; + } + + /** + * Create a new file field. + * + * @param $name + * The name of the new field (all lowercase), exclude the "field_" prefix. + * @param $type_name + * The node type that this field will be added to. + * @param $field_settings + * A list of field settings that will be added to the defaults. + * @param $instance_settings + * A list of instance settings that will be added to the instance defaults. + * @param $widget_settings + * A list of widget settings that will be added to the widget defaults. + */ + function createFileField($name, $type_name, $field_settings = array(), $instance_settings = array(), $widget_settings = array()) { + $field = array( + 'field_name' => $name, + 'type' => 'file', + 'settings' => array(), + 'cardinality' => !empty($field_settings['cardinality']) ? $field_settings['cardinality'] : 1, + ); + $field['settings'] = array_merge($field['settings'], $field_settings); + field_create_field($field); + + $instance = array( + 'field_name' => $field['field_name'], + 'label' => $name, + 'bundle' => $type_name, + 'required' => !empty($instance_settings['required']), + 'settings' => array(), + 'widget' => array( + 'type' => 'file_generic', + 'settings' => array(), + ), + ); + $instance['settings'] = array_merge($instance['settings'], $instance_settings); + $instance['widget']['settings'] = array_merge($instance['widget']['settings'], $widget_settings); + field_create_instance($instance); + } + + /** + * Update an existing file field with new settings. + */ + function updateFileField($name, $type_name, $instance_settings = array(), $widget_settings = array()) { + $field = field_info_field($name); + $instance = field_info_instance($name, $type_name); + $instance['settings'] = array_merge($instance['settings'], $instance_settings); + $instance['widget']['settings'] = array_merge($instance['widget']['settings'], $widget_settings); + + field_update_instance($instance); + } + + /** + * Upload a file to a node. + */ + function uploadNodeFile($file, $field_name, $nid_or_type, $new_revision = TRUE) { + $edit = array( + 'title' => $this->randomName(), + 'revision' => (string) (int) $new_revision, + ); + + if (is_numeric($nid_or_type)) { + $node = node_load($nid_or_type); + $delta = isset($node->$field_name) ? count($node->$field_name) : 0; + $edit['files[' . $field_name . '_' . FIELD_LANGUAGE_NONE . '_' . $delta . ']'] = realpath($file->uri); + $this->drupalPost('node/' . $nid_or_type . '/edit', $edit, t('Save')); + } + else { + $edit['files[' . $field_name . '_' . FIELD_LANGUAGE_NONE . '_0]'] = realpath($file->uri); + $type_name = str_replace('_', '-', $nid_or_type); + $this->drupalPost('node/add/' . $type_name, $edit, t('Save')); + } + + $matches = array(); + preg_match('/node\/([0-9]+)/', $this->getUrl(), $matches); + return isset($matches[1]) ? $matches[1] : FALSE; + } + + /** + * Remove a file from a node. + * + * Note that if replacing a file, it must first be removed then added again. + */ + function removeNodeFile($nid, $new_revision = TRUE) { + $edit = array( + 'revision' => (string) (int) $new_revision, + ); + + $this->drupalPost('node/' . $nid . '/edit', array(), t('Remove')); + $this->drupalPost(NULL, $edit, t('Save')); + } + + /** + * Replace a file within a node. + */ + function replaceNodeFile($file, $field_name, $nid, $new_revision = TRUE) { + $edit = array( + 'files[' . $field_name . '_' . FIELD_LANGUAGE_NONE . '_0]' => realpath($file->uri), + 'revision' => (string) (int) $new_revision, + ); + + $this->drupalPost('node/' . $nid . '/edit', array(), t('Remove')); + $this->drupalPost(NULL, $edit, t('Save')); + } + + /** + * Assert that a file exists physically on disk. + */ + function assertFileExists($file, $message = NULL) { + $message = isset($message) ? $message : t('File %file exists on the disk.', array('%file' => $file->uri)); + $this->assertTrue(is_file($file->uri), $message); + } + + /** + * Assert that a file exists in the database. + */ + function assertFileEntryExists($file, $message = NULL) { + drupal_static_reset('file_load_multiple'); + $db_file = file_load($file->fid); + $message = isset($message) ? $message : t('File %file exists in database at the correct path.', array('%file' => $file->uri)); + $this->assertEqual($db_file->uri, $file->uri, $message); + } + + /** + * Assert that a file does not exist on disk. + */ + function assertFileNotExists($file, $message = NULL) { + $message = isset($message) ? $message : t('File %file exists on the disk.', array('%file' => $file->uri)); + $this->assertFalse(is_file($file->uri), $message); + } + + /** + * Assert that a file does not exist in the database. + */ + function assertFileEntryNotExists($file, $message) { + drupal_static_reset('file_load_multiple'); + $message = isset($message) ? $message : t('File %file exists in database at the correct path.', array('%file' => $file->uri)); + $this->assertFalse(file_load($file->fid), $message); + } +} + +/** + * Test class to test file handling with node revisions. + */ +class FileFieldRevisionTestCase extends FileFieldTestCase { + public function getInfo() { + return array( + 'name' => t('File field revision test'), + 'description' => t('Test creating and deleting revisions with files attached.'), + 'group' => t('File'), + ); + } + + /** + * Test creating multiple revisions of a node and managing the attached files. + * + * Expected behaviors: + * - Adding a new revision will make another entry in the field table, but + * the original file will not be duplicated. + * - Deleting a revision should not delete the original file if the file + * is in use by another revision. + * - When the last revision that uses a file is deleted, the original file + * should be deleted also. + */ + function testRevisions() { + $type_name = 'article'; + $field_name = 'field_' . strtolower($this->randomName()); + $this->createFileField($field_name, $type_name); + $field = field_info_field($field_name); + $instance = field_info_instance($field_name, $type_name); + + $test_file = $this->getTestFile('text'); + + // Create a new node with the uploaded file. + $nid = $this->uploadNodeFile($test_file, $field_name, $type_name); + + // Check that the file exists on disk and in the database. + $node = node_load($nid, NULL, TRUE); + $node_file_r1 = (object) $node->{$field_name}[FIELD_LANGUAGE_NONE][0]; + $node_vid_r1 = $node->vid; + $this->assertFileExists($node_file_r1, t('New file saved to disk on node creation.')); + $this->assertFileEntryExists($node_file_r1, t('File entry exists in database on node creation.')); + + // Upload another file to the same node in a new revision. + $this->replaceNodeFile($test_file, $field_name, $nid); + $node = node_load($nid, NULL, TRUE); + $node_file_r2 = (object) $node->{$field_name}[FIELD_LANGUAGE_NONE][0]; + $node_vid_r2 = $node->vid; + $this->assertFileExists($node_file_r2, t('Replacement file exists on disk after creating new revision.')); + $this->assertFileEntryExists($node_file_r2, t('Replacement file entry exists in database after creating new revision.')); + + // Check that the original file is still in place on the first revision. + $node = node_load($nid, $node_vid_r1, TRUE); + $this->assertEqual($node_file_r1, (object) $node->{$field_name}[FIELD_LANGUAGE_NONE][0], t('Original file still in place after replacing file in new revision.')); + $this->assertFileExists($node_file_r1, t('Original file still in place after replacing file in new revision.')); + $this->assertFileEntryExists($node_file_r1, t('Original file entry still in place after replacing file in new revision')); + + // Save a new version of the node without any changes. + // Check that the file is still the same as the previous revision. + $this->drupalPost('node/' . $nid . '/edit', array('revision' => '1'), t('Save')); + $node = node_load($nid, NULL, TRUE); + $node_file_r3 = (object) $node->{$field_name}[FIELD_LANGUAGE_NONE][0]; + $node_vid_r3 = $node->vid; + $this->assertEqual($node_file_r2, $node_file_r3, t('Previous revision file still in place after creating a new revision without a new file.')); + + // Revert to the first revision and check that the original file is active. + $this->drupalPost('node/' . $nid . '/revisions/' . $node_vid_r1 . '/revert', array(), t('Revert')); + $node = node_load($nid, NULL, TRUE); + $node_file_r4 = (object) $node->{$field_name}[FIELD_LANGUAGE_NONE][0]; + $node_vid_r4 = $node->vid; + $this->assertEqual($node_file_r1, $node_file_r4, t('Original revision file still in place after reverting to the original revision.')); + + // Delete the second revision and check that the file is kept (since it is + // still being used by the third revision). + $this->drupalPost('node/' . $nid . '/revisions/' . $node_vid_r2 . '/delete', array(), t('Delete')); + $this->assertFileExists($node_file_r3, t('Second file is still available after deleting second revision, since it is being used by the third revision.')); + $this->assertFileEntryExists($node_file_r3, t('Second file entry is still available after deleting second revision, since it is being used by the third revision.')); + + // Delete the third revision and check that the file is deleted also. + $this->drupalPost('node/' . $nid . '/revisions/' . $node_vid_r3 . '/delete', array(), t('Delete')); + // TODO: This seems like a bug in File API. Clearing the stat cache should + // not be necessary here. The file really is deleted, but stream wrappers + // doesn't seem to think so unless we clear the PHP file stat() cache. + clearstatcache(); + $this->assertFileNotExists($node_file_r3, t('Second file is now deleted after deleting third revision, since it is no longer being used by any other nodes.')); + $this->assertFileEntryNotExists($node_file_r3, t('Second file entry is now deleted after deleting third revision, since it is no longer being used by any other nodes.')); + + // Delete the entire node and check that the original file is deleted. + $this->drupalPost('node/' . $nid . '/delete', array(), t('Delete')); + $this->assertFileNotExists($node_file_r1, t('Original file is deleted after deleting the entire node with two revisions remaining.')); + $this->assertFileEntryNotExists($node_file_r1, t('Original file entry is deleted after deleting the entire node with two revisions remaining.')); + } +} + +/** + * Test class to check that formatters are working properly. + */ +class FileFieldDisplayTestCase extends FileFieldTestCase { + public function getInfo() { + return array( + 'name' => t('File field display tests'), + 'description' => t('Test the display of file fields in node and views.'), + 'group' => t('File'), + ); + } + + /** + * Test normal formatter display on node display. + */ + function testNodeDisplay() { + $field_name = 'field_' . strtolower($this->randomName()); + $type_name = 'article'; + $field_settings = array( + 'display_field' => '1', + 'display_default' => '1', + ); + $instance_settings = array(); + $widget_settings = array( + 'description_field' => '1', + ); + $this->createFileField($field_name, $type_name, $field_settings, $instance_settings, $widget_settings); + $field = field_info_field($field_name); + $instance = field_info_instance($field_name, $type_name); + + $test_file = $this->getTestFile('text'); + + // Create a new node with the uploaded file. + $nid = $this->uploadNodeFile($test_file, $field_name, $type_name); + $this->drupalGet('node/' . $nid . '/edit'); + + // Check that the default formatter is displaying with the file name. + $node = node_load($nid, NULL, TRUE); + $node_file = (object) $node->{$field_name}[FIELD_LANGUAGE_NONE][0]; + $default_output = theme('file_link', $node_file); + $this->assertRaw($default_output, t('Default formatter displaying correctly on full node view.')); + + // Turn the "display" option off and check that the file is no longer displayed. + $edit = array($field_name . '[' . FIELD_LANGUAGE_NONE . '][0][display]' => FALSE); + $this->drupalPost('node/' . $nid . '/edit', $edit, t('Save')); + + $this->assertNoRaw($default_output, t('Field is hidden when "display" option is unchecked.')); + + } +} + +/** + * Test class to check for various validations. + */ +class FileFieldValidateTestCase extends FileFieldTestCase { + protected $field; + protected $node_type; + + public function getInfo() { + return array( + 'name' => t('File field validation tests'), + 'description' => t('Tests validation functions such as file type, max file size, max size per node, and required.'), + 'group' => t('File'), + ); + } + + /** + * Test required property on file fields. + */ + function testRequired() { + $type_name = 'article'; + $field_name = 'field_' . strtolower($this->randomName()); + $this->createFileField($field_name, $type_name, array(), array('required' => '1')); + $field = field_info_field($field_name); + $instance = field_info_instance($field_name, $type_name); + + $test_file = $this->getTestFile('text'); + + // Try to post a new node without uploading a file. + $edit = array('title' => $this->randomName()); + $this->drupalPost('node/add/' . $type_name, $edit, t('Save')); + $this->assertRaw(t('!title field is required.', array('!title' => $instance['label'])), t('Node save failed when required file field was empty.')); + + // Create a new node with the uploaded file. + $nid = $this->uploadNodeFile($test_file, $field_name, $type_name); + $node = node_load($nid, NULL, TRUE); + + $node_file = (object) $node->{$field_name}[FIELD_LANGUAGE_NONE][0]; + $this->assertFileExists($node_file, t('File exists after uploading to the required field.')); + $this->assertFileEntryExists($node_file, t('File entry exists after uploading to the required field.')); + + // Try again with a multiple value field. + field_delete_field($field_name); + $this->createFileField($field_name, $type_name, array('cardinality' => FIELD_CARDINALITY_UNLIMITED), array('required' => '1')); + + // Try to post a new node without uploading a file in the multivalue field. + $edit = array('title' => $this->randomName()); + $this->drupalPost('node/add/' . $type_name, $edit, t('Save')); + $this->assertRaw(t('!title field is required.', array('!title' => $instance['label'])), t('Node save failed when required multiple value file field was empty.')); + + // Create a new node with the uploaded file into the multivalue field. + $nid = $this->uploadNodeFile($test_file, $field_name, $type_name); + $node = node_load($nid, NULL, TRUE); + $node_file = (object) $node->{$field_name}[FIELD_LANGUAGE_NONE][0]; + $this->assertFileExists($node_file, t('File exists after uploading to the required multiple value field.')); + $this->assertFileEntryExists($node_file, t('File entry exists after uploading to the required multipel value field.')); + + // Remove our file field. + field_delete_field($field_name); + } + + /** + * Test the max file size validator. + */ + function testFileMaxSize() { + $type_name = 'article'; + $field_name = 'field_' . strtolower($this->randomName()); + $this->createFileField($field_name, $type_name, array(), array('required' => '1')); + $field = field_info_field($field_name); + $instance = field_info_instance($field_name, $type_name); + + $small_file = $this->getTestFile('text', 131072); // 128KB. + $large_file = $this->getTestFile('text', 1310720); // 1.2MB + + // Test uploading both a large and small file with different increments. + $sizes = array( + '1M' => 1048576, + '1024K' => 1048576, + '1048576' => 1048576, + ); + + foreach ($sizes as $max_filesize => $file_limit) { + // Set the max file upload size. + $this->updateFileField($field_name, $type_name, array('max_filesize' => $max_filesize)); + $instance = field_info_instance($field_name, $type_name); + + // Create a new node with the small file, which should pass. + $nid = $this->uploadNodeFile($small_file, $field_name, $type_name); + $node = node_load($nid, NULL, TRUE); + $node_file = (object) $node->{$field_name}[FIELD_LANGUAGE_NONE][0]; + $this->assertFileExists($node_file, t('File exists after uploading a file (%filesize) under the max limit (%maxsize).', array('%filesize' => format_size($small_file->filesize), '%maxsize' => $max_filesize))); + $this->assertFileEntryExists($node_file, t('File entry exists after uploading a file (%filesize) under the max limit (%maxsize).', array('%filesize' => format_size($small_file->filesize), '%maxsize' => $max_filesize))); + + // Check that uploading the large file fails (1M limit). + $nid = $this->uploadNodeFile($large_file, $field_name, $type_name); + $error_message = t('The file is %filesize exceeding the maximum file size of %maxsize.', array('%filesize' => format_size($large_file->filesize), '%maxsize' => format_size($file_limit))); + $this->assertRaw($error_message, t('Node save failed when file (%filesize) exceeded the max upload size (%maxsize).', array('%filesize' => format_size($large_file->filesize), '%maxsize' => $max_filesize))); + } + + // Turn off the max filesize. + $this->updateFileField($field_name, $type_name, array('max_filesize' => '')); + + // Upload the big file successfully. + $nid = $this->uploadNodeFile($large_file, $field_name, $type_name); + $node = node_load($nid, NULL, TRUE); + $node_file = (object) $node->{$field_name}[FIELD_LANGUAGE_NONE][0]; + $this->assertFileExists($node_file, t('File exists after uploading a file (%filesize) with no max limit.', array('%filesize' => format_size($large_file->filesize)))); + $this->assertFileEntryExists($node_file, t('File entry exists after uploading a file (%filesize) with no max limit.', array('%filesize' => format_size($large_file->filesize)))); + + // Remove our file field. + field_delete_field($field_name); + } + + /** + * Test the file extension, do additional checks if mimedetect is installed. + */ + function testFileExtension() { + $type_name = 'article'; + $field_name = 'field_' . strtolower($this->randomName()); + $this->createFileField($field_name, $type_name); + $field = field_info_field($field_name); + $instance = field_info_instance($field_name, $type_name); + + // Get the test file (a GIF image). + $test_file = $this->getTestFile('image'); + + // Disable extension checking. + $this->updateFileField($field_name, $type_name, array('file_extensions' => '')); + + // Check that the file can be uploaded with no extension checking. + $nid = $this->uploadNodeFile($test_file, $field_name, $type_name); + $node = node_load($nid, NULL, TRUE); + $node_file = (object) $node->{$field_name}[FIELD_LANGUAGE_NONE][0]; + $this->assertFileExists($node_file, t('File exists after uploading a file with no extension checking.')); + $this->assertFileEntryExists($node_file, t('File entry exists after uploading a file with no extension checking.')); + + // Enable extension checking for text files. + $this->updateFileField($field_name, $type_name, array('file_extensions' => 'txt')); + + // Check that the file with the wrong extension cannot be uploaded. + $nid = $this->uploadNodeFile($test_file, $field_name, $type_name); + $error_message = t('Only files with the following extensions are allowed: %files-allowed.', array('%files-allowed' => 'txt')); + $this->assertRaw($error_message, t('Node save failed when file uploaded with the wrong extension.')); + + // Enable extension checking for text and image files. + $this->updateFileField($field_name, $type_name, array('file_extensions' => 'txt gif')); + + // Check that the file can be uploaded with extension checking. + $nid = $this->uploadNodeFile($test_file, $field_name, $type_name); + $node = node_load($nid, NULL, TRUE); + $node_file = (object) $node->{$field_name}[FIELD_LANGUAGE_NONE][0]; + $this->assertFileExists($node_file, t('File exists after uploading a file with extension checking.')); + $this->assertFileEntryExists($node_file, t('File entry exists after uploading a file with extension checking.')); + + // Remove our file field. + field_delete_field($field_name); + } +} + +/** + * Test class to check that files are uploaded to proper locations. + */ +class FileFieldPathTestCase extends FileFieldTestCase { + function getInfo() { + return array( + 'name' => t('File field file path tests'), + 'description' => t('Test that files are uploaded to the proper location with token support.'), + 'group' => t('File'), + ); + } + + /** + * Test normal formatter display on node display. + */ + function testUploadPath() { + $field_name = 'field_' . strtolower($this->randomName()); + $type_name = 'article'; + $field = $this->createFileField($field_name, $type_name); + $test_file = $this->getTestFile('text'); + + // Create a new node. + $nid = $this->uploadNodeFile($test_file, $field_name, $type_name); + + // Check that the file was uploaded to the file root. + $node = node_load($nid, NULL, TRUE); + $node_file = (object) $node->{$field_name}[FIELD_LANGUAGE_NONE][0]; + $this->assertPathMatch('public://' . $test_file->filename, $node_file->uri, t('The file %file was uploaded to the correct path.', array('%file' => $node_file->uri))); + + // Change the path to contain multiple subdirectories. + $field = $this->updateFileField($field_name, $type_name, array('file_directory' => 'foo/bar/baz')); + + // Upload a new file into the subdirectories. + $nid = $this->uploadNodeFile($test_file, $field_name, $type_name); + + // Check that the file was uploaded into the subdirectory. + $node = node_load($nid, NULL, TRUE); + $node_file = (object) $node->{$field_name}[FIELD_LANGUAGE_NONE][0]; + $this->assertPathMatch('public://foo/bar/baz/' . $test_file->filename, $node_file->uri, t('The file %file was uploaded to the correct path.', array('%file' => $node_file->uri))); + + // Check the path when used with tokens. + // Change the path to contain multiple token directories. + $field = $this->updateFileField($field_name, $type_name, array('file_directory' => '[user:uid]/[user:name]')); + + // Upload a new file into the token subdirectories. + $nid = $this->uploadNodeFile($test_file, $field_name, $type_name); + + // Check that the file was uploaded into the subdirectory. + $node = node_load($nid, NULL, TRUE); + $node_file = (object) $node->{$field_name}[FIELD_LANGUAGE_NONE][0]; + $data = array('user' => $this->admin_user); + $subdirectory = token_replace('[user:uid]/[user:name]', $data); + $this->assertPathMatch('public://' . $subdirectory . '/' . $test_file->filename, $node_file->uri, t('The file %file was uploaded to the correct path with token replacements.', array('%file' => $node_file->uri))); + } + + /** + * A loose assertion to check that a file is uploaded to the right location. + * + * @param $expected_path + * The location where the file is expected to be uploaded. Duplicate file + * names to not need to be taken into account. + * @param $actual_path + * Where the file was actually uploaded. + * @param $message + * The message to display with this assertion. + */ + function assertPathMatch($expected_path, $actual_path, $message) { + // Strip off the extension of the expected path to allow for _0, _1, etc. + // suffixes when the file hits a duplicate name. + $pos = strrpos($expected_path, '.'); + $base_path = substr($expected_path, 0, $pos); + $extension = substr($expected_path, $pos + 1); + + $result = preg_match('/' . preg_quote($base_path, '/') . '(_[0-9]+)?\.' . preg_quote($extension, '/') . '/', $actual_path); + $this->assertTrue($result, $message); + } +} Index: modules/file/file.field.inc =================================================================== RCS file: modules/file/file.field.inc diff -N modules/file/file.field.inc --- /dev/null 1 Jan 1970 00:00:00 -0000 +++ modules/file/file.field.inc 1 Jan 1970 00:00:00 -0000 @@ -0,0 +1,922 @@ + array( + 'label' => t('File'), + 'description' => t('This field stores the ID of a file as an integer value.'), + 'settings' => array( + 'display_field' => 0, + 'display_default' => 0, + 'uri_scheme' => 'public', + 'default_file' => 0, + ), + 'instance_settings' => array( + 'file_extensions' => 'txt', + 'file_directory' => '', + 'max_filesize' => '', + ), + 'default_value_function' => FALSE, + 'default_widget' => 'file_file', + 'default_formatter' => 'file_default', + ), + ); +} + +/** + * Implement hook_field_schema(). + */ +function file_field_schema($field) { + return array( + 'columns' => array( + 'fid' => array( + 'description' => 'The {files}.fid being referenced in this field.', + 'type' => 'int', + 'not null' => FALSE, + 'unsigned' => TRUE, + ), + 'display' => array( + 'description' => 'Flag to control whether this file should be displayed when viewing content.', + 'type' => 'int', + 'size' => 'tiny', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'default' => 1, + ), + 'data' => array( + 'description' => 'Serialized additional data about the file, such as a description.', + 'type' => 'text', + 'not null' => FALSE, + 'serialize' => TRUE, + ), + ), + 'indexes' => array( + 'fid' => array('fid'), + ), + ); +} + +/** + * Implement hook_field_settings_form(). + */ +function file_field_settings_form($field, $instance) { + $defaults = field_info_field_settings($field['type']); + $settings = array_merge($defaults, $field['settings']); + + $form = array( + '#attached_js' => array(drupal_get_path('module', 'file') . '/file.js'), + ); + + $form['display_field'] = array( + '#type' => 'checkbox', + '#title' => t('Enable Display field'), + '#default_value' => $settings['display_field'], + '#description' => t('The display option allows users to choose if a file should be shown when viewing the content.'), + ); + $form['display_default'] = array( + '#type' => 'checkbox', + '#title' => t('Files displayed by default'), + '#default_value' => $settings['display_default'], + '#description' => t('This setting only has an effect if the display option is enabled.'), + ); + + $scheme_options = array(); + foreach (file_get_stream_wrappers() as $scheme => $stream_wrapper) { + if ($scheme != 'temporary') { + $scheme_options[$scheme] = $stream_wrapper['name']; + } + } + $form['uri_scheme'] = array( + '#type' => 'radios', + '#title' => t('Upload destination'), + '#options' => $scheme_options, + '#default_value' => $settings['uri_scheme'], + '#description' => t('Select where the final files should be stored. Private file storage has significantly more overhead than public files, but allows restricted access to files within this field.'), + ); + + $form['default_file'] = array( + '#title' => t('Default file'), + '#type' => 'managed_file', + '#description' => t('If no file is uploaded, this file will be used on display.'), + '#default_value' => $field['settings']['default_file'], + '#upload_location' => 'public://default_files/', + ); + + return $form; +} + +/** + * Implement hook_field_instance_settings_form(). + */ +function file_field_instance_settings_form($field, $instance) { + $settings = $instance['settings']; + + $form = array( + '#attached_js' => array(drupal_get_path('module', 'file') . '/file.js'), + ); + + $form['max_filesize'] = array( + '#type' => 'textfield', + '#title' => t('Maximum upload size'), + '#default_value' => $settings['max_filesize'], + '#description' => t('Enter a value like "512" (bytes), "80 KB" (kilobytes) or "50 MB" (megabytes) in order to restrict the allowed file size. If left empty the file sizes will be limited only by PHP\'s maximum post and file upload sizes (current limit %limit).', array('%limit' => format_size(file_upload_max_size()))), + '#size' => 10, + '#element_validate' => array('_file_generic_settings_max_filesize'), + '#weight' => 3, + ); + + // Make the extension list a little more human-friendly by comma-separation. + $extensions = str_replace(' ', ', ', $settings['file_extensions']); + $form['file_extensions'] = array( + '#type' => 'textfield', + '#title' => t('Allowed file extensions'), + '#default_value' => $extensions, + '#size' => 64, + '#description' => t('Separate extensions with a space or comma and do not include the leading dot. Leaving this blank will allow users to upload a file with any extension.'), + '#element_validate' => array('_file_generic_settings_extensions'), + '#weight' => 4, + ); + + $form['destination'] = array( + '#type' => 'fieldset', + '#title' => t('Upload destination'), + '#collapsible' => TRUE, + '#collapsed' => TRUE, + '#weight' => 5, + ); + $form['destination']['file_directory'] = array( + '#type' => 'textfield', + '#title' => t('File directory'), + '#default_value' => $settings['file_directory'], + '#description' => t('Optional subdirectory within the upload destination where files will be stored. Do not include preceding or trailing slashes.', array('%directory' => variable_get('file_directory_path', 'files') . '/')), + '#element_validate' => array('_file_generic_settings_file_directory_validate'), + '#parents' => array('instance', 'settings', 'file_directory'), + ); + + return $form; +} + +/** + * Element validate callback for the maximum upload size field. + * + * Ensure a size that can be parsed by parse_size() has been entered. + */ +function _file_generic_settings_max_filesize($element, &$form_state) { + if (!empty($element['#value']) && !is_numeric(parse_size($element['#value']))) { + form_error($element, t('The "!name" option must contain a valid value. You may either leave the text field empty or enter a string like "512" (bytes), "80 KB" (kilobytes) or "50 MB" (megabytes).', array('!name' => t($element['title'])))); + } +} + +/** + * Element validate callback for the allowed file extensions field. + * + * This doubles as a convenience clean-up function and a validation routine. + * Commas are allowed by the end-user, but ultimately the value will be stored + * as a space-separated list for compatibility with file_validate_extensions(). + */ +function _file_generic_settings_extensions($element, &$form_state) { + if (!empty($element['#value'])) { + $extensions = preg_replace('/([, ]+\.?)/', ' ', trim(strtolower($element['#value']))); + $extensions = array_filter(explode(' ', $extensions)); + $extensions = implode(' ', array_unique($extensions)); + if (!preg_match('/^([a-z0-9]+([.][a-z0-9])* ?)+$/', $extensions)) { + form_error($element, t('The list of allowed extensions is not valid, be sure to exclude leading dots and to separate extensions with a comma or space.')); + } + else { + form_set_value($element, $extensions, $form_state); + } + } +} + +/** + * Element validate callback for the file destination field. + * + * Remove slashes from the beginning and end of the destination value and ensure + * that the file directory path is not included at the beginning of the value. + */ +function _file_generic_settings_file_directory_validate($element, &$form_state) { + // Strip slashes from the beginning and end of $widget['file_directory']. + $value = trim($element['#value'], '\\/'); + + // Do not allow the file path to be the same as the file_directory_path(). + // This causes all sorts of problems with things like file_create_url(). + if (strpos($value, file_directory_path()) === 0) { + form_error($element, t('The file directory (@file_directory) cannot start with the system files directory (@files_directory), as this may cause conflicts when building file URLs.', array('@file_directory' => $form_state['values']['file_directory'], '@files_directory' => file_directory_path()))); + } + else { + form_set_value($element, $value, $form_state); + } +} + +/** + * Implement hook_field_load(). + */ +function file_field_load($obj_type, $objects, $field, $instances, $langcode, &$items, $age) { + foreach ($objects as $obj_id => $object) { + // Load the files from the files table. + $fids = array(); + foreach ($items[$obj_id] as $delta => $item) { + $fids[] = $item['fid']; + } + $files = file_load_multiple($fids); + + foreach ($items[$obj_id] as $delta => $item) { + // If the file does not exist, mark the entire item as empty. + if (empty($item['fid']) || !isset($files[$item['fid']])) { + $items[$obj_id][$delta] = NULL; + } + // Unserialize the data column. + else { + $item['data'] = unserialize($item['data']); + $items[$obj_id][$delta] = array_merge($item, (array) $files[$item['fid']]); + } + } + } +} + +/** + * Implement hook_field_sanitize(). + */ +function file_field_sanitize($obj_type, $object, $field, $instance, $langcode, &$items) { + // If there are no files specified at all, use the default. + if (empty($items) && $field['settings']['default_file']) { + if ($file = file_load($field['settings']['default_file'])) { + $items[0] = (array) $file; + $items[0]['is_default'] = TRUE; + } + } + // Remove files from being displayed if they're not displayed. + else { + foreach ($items as $delta => $item) { + if (!file_field_displayed($item, $field)) { + unset($items[$delta]); + } + } + } + + // Return the items including only displayed files. + $items = array_values($items); +} + +/** + * Implement hook_field_insert(). + */ +function file_field_insert($obj_type, $object, $field, $instance, $langcode, &$items) { + file_field_update($obj_type, $object, $field, $instance, $langcode, $items); +} + +/** + * Implement hook_field_update(). + */ +function file_field_update($obj_type, $object, $field, $instance, $langcode, &$items) { + // Serialize the data column before storing. + foreach ($items as $delta => $item) { + $items[$delta]['data'] = serialize($item['data']); + } + + // Check for files that have been removed from the object. + + // On new revisions, old files are always maintained in the previous revision. + if ($object->is_new || (isset($object->revision) && $object->revision)) { + return; + } + + // Build a display of the current FIDs. + $fids = array(); + foreach ($items as $item) { + $fids[] = $item['fid']; + } + + // Delete items from original object. + list($id, $vid, $bundle) = field_attach_extract_ids($obj_type, $object); + $load_function = $obj_type . '_load'; + + $original = $load_function($id); + if (!empty($original->{$field['field_name']}[$langcode])) { + foreach ($original->{$field['field_name']}[$langcode] as $original_item) { + if (isset($original_item['fid']) && !in_array($original_item['fid'], $fids)) { + // For hook_file_references, remember that this is being deleted. + $original_item['file_field_name'] = $field['field_name']; + // Delete the file if possible. + file_field_delete_file($original_item, $field); + } + } + } +} + +/** + * Implement hook_field_delete(). + */ +function file_field_delete($obj_type, $object, $field, $instance, $langcode, &$items) { + list($id, $vid, $bundle) = field_attach_extract_ids($obj_type, $object); + foreach ($items as $delta => $item) { + // For hook_file_references(), remember that this is being deleted. + $item['file_field_name'] = $field['field_name']; + // Pass in the ID of the object that is being removed so all references can + // be counted in hook_file_references(). + $item['file_field_type'] = $obj_type; + $item['file_field_id'] = $id; + file_field_delete_file($item, $field); + } +} + +/** + * Implement hook_field_delete_revision(). + */ +function file_field_delete_revision($obj_type, $object, $field, $instance, $langcode, &$items) { + foreach ($items as $delta => $item) { + // For hook_file_references, remember that this file is being deleted. + $item['file_field_name'] = $field['field_name']; + if (file_field_delete_file($item, $field)) { + $items[$delta] = NULL; + } + } +} + +/** + * Check that File controls a file before attempting to delete it. + */ +function file_field_delete_file($item, $field) { + // Remove the file_field_name and file_field_id properties so that references + // can be counted including the files to be deleted. + $field_name = isset($item['file_field_name']) ? $item['file_field_name'] : NULL; + $field_id = isset($item['file_field_id']) ? $item['file_field_id'] : NULL; + unset($item['file_field_name'], $item['file_field_id']); + + // To prevent the file field from deleting files it doesn't know about, check + // the file reference count. Temporary files can be deleted because they + // are not yet associated with any content at all. + $file = (object) $item; + if ($file->status == 0 || file_get_file_reference_count($file, $field) > 0) { + $file->file_field_name = $field_name; + $file->file_field_id = $field_id; + return file_delete($file); + } + + // Even if the file is not deleted, return TRUE to indicate the file field + // record can be removed from the field database tables. + return TRUE; +} + +/** + * Implement hook_field_is_empty(). + */ +function file_field_is_empty($item, $field) { + return empty($item['fid']); +} + +/** + * Determine whether a file should be displayed when outputting field content. + * + * @param $item + * A field item array. + * @param $field + * A field array. + * @return + * Boolean TRUE if the file will be displayed, FALSE if the file is hidden. + */ +function file_field_displayed($item, $field) { + if (!empty($field['settings']['display_field'])) { + return (bool) $item['display']; + } + return TRUE; +} + +/** + * Implement hook_field_formatter_info(). + */ +function file_field_formatter_info() { + return array( + 'file_default' => array( + 'label' => t('Generic file'), + 'field types' => array('file'), + ), + 'file_table' => array( + 'label' => t('Table of files'), + 'field types' => array('file'), + 'behaviors' => array( + 'multiple values' => FIELD_BEHAVIOR_CUSTOM, + ), + ), + 'file_url_plain' => array( + 'label' => t('URL to file'), + 'field types' => array('file'), + ), + ); +} + +/** + * Implement hook_field_widget_info(). + */ +function file_field_widget_info() { + return array( + 'file_generic' => array( + 'label' => t('Generic file'), + 'field types' => array('file'), + 'settings' => array( + 'progress_indicator' => 'throbber', + 'description_field' => 0, + ), + 'behaviors' => array( + 'multiple values' => FIELD_BEHAVIOR_CUSTOM, + 'default value' => FIELD_BEHAVIOR_NONE, + ), + ), + ); +} + +/** + * Implement hook_field_widget_settings_form(). + */ +function file_field_widget_settings_form($field, $instance) { + $widget = $instance['widget']; + $settings = $widget['settings']; + + $form['progress_indicator'] = array( + '#type' => 'radios', + '#title' => t('Progress indicator'), + '#options' => array( + 'throbber' => t('Throbber'), + 'bar' => t('Bar with progress meter'), + ), + '#default_value' => $settings['progress_indicator'], + '#description' => t('The throbber display does not show the status of uploads but takes up space. The progress bar is helpful for monitoring progress on large uploads.'), + '#weight' => 2, + '#access' => file_progress_implementation(), + ); + + $form['additional'] = array( + '#type' => 'fieldset', + '#title' => t('Additional fields'), + '#collapsible' => TRUE, + '#collapsed' => TRUE, + '#weight' => 10, + ); + + $form['additional']['description_field'] = array( + '#type' => 'checkbox', + '#title' => t('Enable Description field'), + '#default_value' => $settings['description_field'], + '#description' => t('The description field allows users to enter a description about the uploaded file.'), + '#parents' => array('instance', 'widget', 'settings', 'description_field'), + ); + + return $form; +} + +/** + * Implementation of hook_field_widget(). + */ +function file_field_widget(&$form, &$form_state, $field, $instance, $langcode, $items, $delta = 0) { + $form['#attributes'] = array('enctype' => 'multipart/form-data'); + + $defaults = array( + 'fid' => 0, + 'display' => $field['settings']['display_default'], + 'data' => array('description' => ''), + ); + + // Retrieve any values set in $form_state, as will be the case during AJAX + // rebuilds of this form. + if (isset($form_state['values'][$field['field_name']][$langcode])) { + $items = $form_state['values'][$field['field_name']][$langcode]; + unset($form_state['values'][$field['field_name']][$langcode]); + } + + foreach ($items as $delta => $item) { + $items[$delta] = array_merge($defaults, $items[$delta]); + // Remove any items from being displayed that are not needed. + if ($items[$delta]['fid'] == 0) { + unset($items[$delta]); + } + } + + // Re-index deltas after removing empty items. + $items = array_values($items); + + // Update order according to weight. + $items = _field_sort_items($field, $items); + + // Essentially we use the managed_file type, extended with some enhancements. + $element_info = element_info('managed_file'); + $element = array( + '#type' => 'managed_file', + '#default_value' => isset($items[$delta]) ? $items[$delta] : $defaults, + '#required' => $instance['required'], + '#upload_location' => file_field_widget_uri($field, $instance), + '#upload_validators' => file_field_widget_upload_validators($field, $instance), + '#value_callback' => 'file_field_widget_value', + '#process' => array_merge($element_info['#process'], array('file_field_widget_process')), + // Allows this field to return an array instead of a single value. + '#extended' => TRUE, + // Add extra Field properties. + '#field_name' => $field['field_name'], + '#bundle' => $instance['bundle'], + ); + + if ($field['cardinality'] == 1) { + // If there's only one field, return it as delta 0. + $element['#title'] = $instance['label']; + if (empty($element['#default_value']['fid'])) { + $element['#description'] = theme('file_upload_help', $instance['description'], $element['#upload_validators']); + } + $elements = array($element); + } + else { + // If there are multiple values, add an element for each existing one. + $delta = -1; + foreach ($items as $delta => $item) { + $elements[$delta] = $element; + $elements[$delta]['#default_value'] = $item; + $elements[$delta]['#weight'] = $delta; + } + // And then add one more empty row for new uploads. + $delta++; + if ($field['cardinality'] == FIELD_CARDINALITY_UNLIMITED || $delta < $field['cardinality']) { + $elements[$delta] = $element; + $elements[$delta]['#default_value'] = $defaults; + $elements[$delta]['#weight'] = $delta; + $elements[$delta]['#required'] = $delta == 0; + } + // The group of elements all-together need some extra functionality + // after building up the full list (like draggable table rows). + $elements['#file_upload_delta'] = $delta; + $elements['#theme'] = 'file_widget_multiple'; + $elements['#theme_wrappers'] = array('fieldset'); + $elements['#attributes']['class'] = array('file-widget'); + $elements['#process'] = array('file_field_widget_process_multiple'); + $elements['#title'] = $instance['label']; + $elements['#description'] = $instance['description']; + + // Add some properties that will eventually be added to the file upload + // field. These are added here so that they may be referenced easily through + // a hook_form_alter(). + $elements['#file_upload_title'] = t('Add a new file'); + $elements['#file_upload_description'] = theme('file_upload_help', '', $elements[$delta]['#upload_validators']); + } + + return $elements; +} + +/** + * Get the upload validators for a file field. + * + * @param $field + * A field array. + * @return + * An array suitable for passing to file_save_upload() or the file field + * element's '#upload_validators' property. + */ +function file_field_widget_upload_validators($field, $instance) { + // Cap the upload size according to the PHP limit. + $max_filesize = parse_size(file_upload_max_size()); + if (!empty($instance['settings']['max_filesize']) && parse_size($instance['settings']['max_filesize']) < $max_filesize) { + $max_filesize = parse_size($instance['settings']['max_filesize']); + } + + $validators = array(); + + // There is always a file size limit due to the PHP server limit. + $validators['file_validate_size'] = array($max_filesize); + + // Add the extension check if necessary. + if (!empty($instance['settings']['file_extensions'])) { + $validators['file_validate_extensions'] = array($instance['settings']['file_extensions']); + } + + return $validators; +} + +/** + * Determine the URI for a file field instance. + * + * @param $field + * A field array. + * @param $instance + * A field instance array. + * @return + * A file directory URI with tokens replaced. + */ +function file_field_widget_uri($field, $instance, $account = NULL) { + $destination = trim($instance['settings']['file_directory'], '/'); + + // Replace tokens. + $data = array('user' => isset($account) ? $account : $GLOBALS['user']); + $destination = token_replace($destination, $data); + + return $field['settings']['uri_scheme'] . '://' . $destination; +} + +/** + * The #value_callback for the file_generic field element. + */ +function file_field_widget_value($element, $input = FALSE, $form_state) { + if ($input) { + // Checkboxes loose their value when empty. + // If the display field is present make sure its unchecked value is saved. + $field = field_info_field($element['#field_name']); + if (empty($input['display'])) { + $input['display'] = $field['settings']['display_field'] ? 0 : 1; + } + } + + // We depend on the managed file element to handle uploads. + $return = file_managed_file_value($element, $input, $form_state); + + // Ensure that all the required properties are returned even if empty. + $return += array( + 'fid' => 0, + 'display' => 1, + 'data' => array(), + ); + + return $return; +} + +/** + * An element #process callback for the file_generic field type. + * + * Expands the file_generic type to include the description and display fields. + */ +function file_field_widget_process($element, &$form_state, $form) { + $item = $element['#value']; + $item['fid'] = $element['fid']['#value']; + + $field = field_info_field($element['#field_name']); + $instance = field_info_instance($element['#field_name'], $element['#bundle']); + $settings = $instance['widget']['settings']; + + $element['#theme'] = 'file_widget'; + + // Data placeholder for widgets to store additional data. + $element['data'] = array( + '#tree' => TRUE, + '#title' => t('File data'), + '#collapsible' => TRUE, + '#collapsed' => TRUE, + '#access' => (bool) $item['fid'], + ); + + // Add the display field if enabled. + if ($field['settings']['display_field'] && $item['fid']) { + $element['display'] = array( + '#type' => empty($item['fid']) ? 'hidden' : 'checkbox', + '#title' => t('Include file in display'), + '#value' => isset($item['display']) ? $item['display'] : $field['settings']['display_default'], + '#attributes' => array('class' => array('file-display')), + ); + } + else { + $element['display'] = array( + '#type' => 'hidden', + '#value' => '1', + ); + } + + // Add the description field if enabled. + if ($settings['description_field'] && $item['fid']) { + $element['data']['description'] = array( + '#type' => 'textfield', + '#title' => t('Description'), + '#value' => isset($item['data']['description']) ? $item['data']['description'] : '', + '#type' => variable_get('file_description_type', 'textfield'), + '#maxlength' => variable_get('file_description_length', 128), + '#description' => t('The description may be used as the label of the link to the file.'), + ); + } + + // Adjust the AJAX settings to reduce the replacement by one parent. + // This makes upload and remove buttons replace the entire group. + if ($field['cardinality'] != 1) { + $new_path = preg_replace('/\/\d+\//', '/', $element['remove_button']['#ajax']['path'], 1); + $new_wrapper = preg_replace('/-\d+-/', '-', $element['remove_button']['#ajax']['wrapper'], 1); + foreach (element_children($element) as $key) { + if (isset($element[$key]['#ajax'])) { + $element[$key]['#ajax']['path'] = $new_path; + $element[$key]['#ajax']['wrapper'] = $new_wrapper; + } + } + unset($element['#prefix'], $element['#suffix']); + } + + return $element; +} + +/** + * An element #process callback for a group of file_generic fields. + * + * Adds the weight field to each row so it can be ordered and adds a new AJAX + * wrapper around the entire group so it can be replaced all at once. + */ +function file_field_widget_process_multiple($element, &$form_state, $form) { + $element_children = element_children($element, TRUE); + $count = count($element_children); + + foreach ($element_children as $delta => $key) { + if ($key != $element['#file_upload_delta']) { + $element[$key]['_weight'] = array( + '#type' => 'weight', + '#delta' => $count, + '#default_value' => $delta, + ); + } + else { + // The title needs to be assigned to the upload field so that validation + // errors include the correct widget label. + $element[$key]['#title'] = $element['#title']; + $element[$key]['_weight'] = array( + '#type' => 'hidden', + '#default_value' => $delta, + ); + } + } + + // Add a new wrapper around all the elements for AJAX replacement. + $element['#prefix'] = '
'; + $element['#suffix'] = '
'; + + return $element; +} + +/** + * Theme an individual file upload widget. + */ +function theme_file_widget($element) { + $output = ''; + // The "form-managed-file" class is required for proper AJAX functionality. + $output .= '
'; + if ($element['fid']['#value'] != 0) { + // Add the file size after the file name. + $element['filename']['#markup'] .= ' (' . format_size($element['#file']->filesize) . ') '; + } + $output .= drupal_render_children($element); + $output .= '
'; + + return $output; +} + +/** + * Theme a group of file upload widgets. + */ +function theme_file_widget_multiple($element) { + $field = field_info_field($element['#field_name']); + + // Get our list of widgets in order. + $widgets = array(); + foreach (element_children($element) as $key) { + $widgets[$key] = $element[$key]; + } + usort($widgets, '_field_sort_items_value_helper'); + + // Special ID and classes for draggable tables. + $weight_class = $element['#id'] . '-weight'; + $table_id = $element['#id'] . '-table'; + + // Build up a table of applicable fields. + $headers = array(); + $headers[] = t('File information'); + if ($field['settings']['display_field']) { + $headers[] = array( + 'data' => t('Display'), + 'class' => array('checkbox'), + ); + } + $headers[] = t('Weight'); + $headers[] = t('Operations'); + + $rows = array(); + foreach ($widgets as $key => $widget) { + // Save the uploading row for last. + if ($element[$key]['#file'] == FALSE) { + $element[$key]['#title'] = $element['#file_upload_title']; + $element[$key]['#description'] = $element['#file_upload_description']; + continue; + } + + // Render all the buttons in the field as an "operation". + $operations = ''; + foreach (element_children($element[$key]) as $sub_key) { + if (isset($element[$key][$sub_key]['#type']) && $element[$key][$sub_key]['#type'] == 'submit') { + $operations .= drupal_render($element[$key][$sub_key]); + } + } + + // Render the "Display" option in its own own column. + $display = ''; + if ($field['settings']['display_field']) { + unset($element[$key]['display']['#title']); + $display = array( + 'data' => drupal_render($element[$key]['display']), + 'class' => array('checkbox'), + ); + } + + // Render the weight in its own column. + $element[$key]['_weight']['#attributes']['class'] = array($weight_class); + $weight = drupal_render($element[$key]['_weight']); + + // Render everything else together in a column, without the normal wrappers. + $element[$key]['#theme_wrappers'] = array(); + $information = drupal_render($element[$key]); + + $row = array(); + $row[] = $information; + if ($field['settings']['display_field']) { + $row[] = $display; + } + $row[] = $weight; + $row[] = $operations; + $rows[] = array( + 'data' => $row, + 'class' => isset($element[$key]['#attributes']['class']) ? array_merge($element[$key]['#attributes']['class'], array('draggable')) : array('draggable'), + ); + } + + drupal_add_tabledrag($table_id, 'order', 'sibling', $weight_class); + + $output = ''; + $output = empty($rows) ? '' : theme('table', $headers, $rows, array('id' => $table_id)); + $output .= drupal_render_children($element); + return $output; +} + +/** + * Generate help text based on upload validators. + * + * @param $description + * The normal description for this field, specified by the user. + * @param $upload_validators + * An array of upload validators as used in $element['#upload_validators']. + * @return + * A string suitable for a file field description. + */ +function theme_file_upload_help($description, $upload_validators) { + $descriptions = array(); + + if (strlen($description)) { + $descriptions[] = $description; + } + if (isset($upload_validators['file_validate_size'])) { + $descriptions[] = t('Files must be less than !size.', array('!size' => '' . format_size($upload_validators['file_validate_size'][0]) . '')); + } + if (isset($upload_validators['file_validate_extensions'])) { + $descriptions[] = t('Allowed file types: !extensions.', array('!extensions' => '' . check_plain($upload_validators['file_validate_extensions'][0]) . '')); + } + if (isset($upload_validators['file_validate_image_resolution'])) { + $max = $upload_validators['file_validate_image_resolution'][0]; + $min = $upload_validators['file_validate_image_resolution'][1]; + if ($min && $max && $min == $max) { + $descriptions[] = t('Images must be exactly !size pixels.', array('!size' => '' . $max . '')); + } + elseif ($min && $max) { + $descriptions[] = t('Images must be between !min and !max pixels.', array('!min' => '' . $min . '', '!max' => '' . $max . '')); + } + elseif ($min) { + $descriptions[] = t('Images must be larger than !min pixels.', array('!min' => '' . $min . '')); + } + elseif ($max) { + $descriptions[] = t('Images must be smaller than !max pixels.', array('!max' => '' . $max . '')); + } + } + + return implode('
', $descriptions); +} + +/** + * Theme function for 'default' file field formatter. + */ +function theme_field_formatter_file_default($element) { + return theme('file_link', (object) $element['#item']); +} + +/** + * Theme function for 'url_plain' file field formatter. + */ +function theme_field_formatter_file_url_plain($element) { + return empty($element['#item']['uri']) ? '' : file_create_url($element['#item']['uri']); +} + +/** + * Theme function for the 'table' formatter. + */ +function theme_field_formatter_file_table($element) { + $header = array(t('Attachment'), t('Size')); + $rows = array(); + foreach (element_children($element) as $key) { + $rows[] = array( + theme('file_link', (object) $element[$key]['#item']), + format_size($element[$key]['#item']['filesize']), + ); + } + + return empty($rows) ? '' : theme('table', $header, $rows); +}