summaryrefslogtreecommitdiffstats
path: root/file_replace.module
blob: ba9e4d100c2289d1d640c6c1abf10c71dff507c4 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
<?php

/**
 * @file
 * Adds an in-place replace option for managed files within a file field.
 */


/**
 * Implements hook_menu().
 */
function file_replace_menu() {
  $items = array();
  // Add a new menu item for a custom AJAX response that will handle the
  // rendering of a modal file replacement form.
  $items['file_replace_ajax/%'] = array(
    'title' => 'File Replace',
    'description' => 'Deliver file replace form via ajax',
    'page callback' => 'file_replace_ajax_callback',
    'page arguments' => array(1),
    'access arguments' => array('file replace files'),
    'type' => MENU_CALLBACK,
  );
  return $items;
}


/**
 * Implements hook_permission().
 */
function file_replace_permission() {
  return array(
    'file replace files' => array(
      'title' => t('Replace Files'),
      'description' => t('Perform in-place replacements of managed files via the field UI.'),
    ),
  );
}


/**
 * Implements hook_field_widget_info_alter().
 */
function file_replace_field_info_alter(&$info) {
  // Enable a setting to toggle the visibility of our "Replace" button on file
  // and image fields.
  $info['file']['settings']['file_replace_button'] = TRUE;
  $info['image']['settings']['file_replace_button'] = TRUE;
}


/**
 * Implements hook_form_field_ui_field_edit_form_alter().
 */
function file_replace_form_field_ui_field_edit_form_alter(&$form, &$form_state, $form_id) {
  // Add the form element for the setting that will toggle the visibility of our
  // "Replace" button on file and image fields. Though this setting will only
  // apply to generic file and image widgets we have to add it at the field
  // instance level (regardless of widget) as Drupal does not appear to allow
  // widget-specific settings alterations.
  if ($form['#field']['type'] == 'file' || $form['#field']['type'] == 'image') {
    $default_value = isset($form['#instance']['settings']['file_replace_button']) ? $form['#instance']['settings']['file_replace_button'] : $form['#field']['settings']['file_replace_button'];
    $form['instance']['settings']['file_replace_button'] = array(
      '#type' => 'checkbox',
      '#weight' => 99,
      '#default_value' => $default_value,
      '#title' => t('Show File Replace Button'),
      '#description' => t('Activate a "Replace" button for in-place file replacement control. This setting only applies for fields using the generic "File" or "Image" widgets and for users with the "Replace Files" permission.'),
    );
  }
}


/**
 * Implements hook_field_widget_form_alter().
 */
function file_replace_field_widget_form_alter(&$element, &$form_state, $context) {
  // File fields using the file_generic widget should be altered to add links
  // to the file replace modal. We'll do that in process callbacks.
  $widget_type = $context['instance']['widget']['type'];
  if ($widget_type == 'file_generic' || $widget_type == 'image_image') {
    $active = isset($context['instance']['settings']['file_replace_button']) ? $context['instance']['settings']['file_replace_button'] : $context['field']['settings']['file_replace_button'];
    if (user_access('file replace files') && !empty($active)) {
      foreach (element_children($element) as $key) {
        $element[$key]['#process'][] = 'file_replace_process_widget_postcore';
      }
      // Add some fallback support for non-JS cases. Note that this form's
      // $form_state['#file_replace_orig'] will only be set if the submit callback
      // was run on the "replace" ajax button, which should only happen if
      // js/ajax is not available.
      if (!empty($form_state['#file_replace_orig']) && $form_state['#file_replace_orig']['parent']['#field_name'] == $element['#field_name']) {
        $form_addition_state = array('#file_replace_orig' => $form_state['#file_replace_orig']);
        $form_addition = file_replace_upload_form(array(), $form_addition_state);
        // We are adding the form to this existing form, so no form-specific
        // submit/action logic is needed, just the main replace upload container.
        $form_addition = $form_addition['file_replace'];
        $element['replace_fallback'] = $form_addition;
      }
    }
  }
}


/**
 * Process callback for a file widget.
 *
 * This callback runs AFTER file_field_widget_process() and
 * file_managed_file_process().
 */
function file_replace_process_widget_postcore($element, &$form_state, $form) {
  // Add our replace button for existing file elements.
  if (!empty($element['#value']['fid'])) {
    $element['replace_button'] = array(
      '#type' => 'submit',
      '#name' => implode('_', $element['#parents']) . '_replace_button',
      '#submit' => array('file_replace_ajax_submit'),
      '#value' => t('Replace'),
      // This button will use a custom ajax callback and pass-through the
      // array parents as arguments.
      '#ajax' => array(
        'wrapper' => 'file-replace-ajax-wrapper-' . $element['#field_name'],
        'path' => 'file_replace_ajax/' . implode('/', $element['#array_parents']),
      ),
    );
    ctools_include('modal');
    ctools_modal_add_js();
  }
  return $element;
}


/**
 * Submit callback for the replace button on the parent form widget.
 */
function file_replace_ajax_submit($form, &$form_state) {
  // This function should only be called when AJAX is not available and when the
  // "replace" button is triggering a normal form submission. In this case we
  // need to set the details of the file to be replaced on the parent form so
  // that our fallback rendering logic can kick-in.
  // @see file_replace_field_widget_form_alter().
  $parents = $form_state['triggering_element']['#parents'];
  array_pop($parents);
  $form_state['#file_replace_orig'] = _file_replace_get_orig_from_parents($parents, $form, $form_state);
  $form_state['#file_replace_orig']['allow_empty'] = TRUE;
  $form_state['rebuild'] = TRUE;
}


/**
 * Menu callback for the ajax response that renders a file replace upload form
 * within a modal.
 */
function file_replace_ajax_callback() {
  // Get the details of the triggering form. This may be the parent form or the
  // actual file replace form that was added injected on a previous AJAX
  // request.
  list($form_orig, $form_state_orig, $form_id_orig, $form_build_id_orig, $commands_orid) = ajax_get_form();
  // If we already have a #file_replace_orig property then we know this is a
  // submittion of the file replace form. In this case we just carry-through the
  // form state.
  if (!empty($form_state_orig['#file_replace_orig'])) {
    $form_state = $form_state_orig;
  }
  // Otherwise we can assume that the triggering form is the parent field edit
  // form. In this case we need to extract that relevant details from it in
  // order to initialize a fresh form state that's suitable for the file replace
  // form.
  else {
    $form_state = array();
    $file_replace_orig = _file_replace_get_orig_from_parents(func_get_args(), $form_orig, $form_state_orig);
    if ($file_replace_orig) {
      $form_state = array(
        'ajax' => TRUE,
        'title' => t('Replace File'),
        'cache' => TRUE,
        '#file_replace_orig' => $file_replace_orig,
      );
    }
  }
  // Now we can actaully (re)build and (re)display the file replace form.
  ctools_include('ajax');
  ctools_include('modal');
  // Generate the modal content which gets added to the page independed of the
  // ajax wrapper div.
  $commands = ctools_modal_form_wrapper('file_replace_upload_form', $form_state);
  // Incorporate any additional commands from the submitted state (if
  // applicable).
  if (!empty($form_state['ajax_commands'])) {
    $commands = $form_state['ajax_commands'];
  }
  print ajax_render($commands);
  drupal_exit();
}


/**
 * File replacement form definition.
 */
function file_replace_upload_form($form, &$form_state) {
  $form = array();
  if (!empty($form_state['#file_replace_orig']['file'])) {
    $file_orig = $form_state['#file_replace_orig']['file'];
    $message = t('<p>Use the field below to upload a replacement for the file at the following path:<br/><strong>@url</strong></p><p>This action cannot be undone. The replacement file will completely overwrite the original. Upon completion any links or internal system references to this path will point to the new file.</p>', array('@url' => str_replace($GLOBALS['base_url'], '', file_create_url($file_orig->uri))));
    $form['file_replace'] = array(
      '#type' => 'container',
      '#prefix' => '<div class="file-replace-container">',
      '#suffix' => '</div>',
    );
    $form['file_replace']['message'] = array(
      '#markup' => $message,
    );
    $form['file_replace']['upload'] = array(
      '#name' => 'files[replace_upload]',
      '#type' => 'file',
      '#title' => t('Replacement file'),
      '#description' => t('Specify a replacement file to upload.'),
      '#element_validate' => array('file_replace_upload_validate'),
    );
    $form['actions'] = array('#type' => 'actions');
    $form['actions']['submit'] = array(
      '#type' => 'submit',
      '#value' => t('Submit'),
    );
    $form['actions']['close'] = array(
      '#type' => 'submit',
      '#value' => t('Close'),
    );
    $form['#submit'] = array('file_replace_upload_submit');
  }
  return $form;
}


/**
 * Validation callback for file replacement form.
 *
 * This is where the physical file replace action happens (we handle validation
 * and submission in parallel).
 */
function file_replace_upload_validate($form, &$form_state) {
  $button_pressed = array_pop($form_state['triggering_element']['#array_parents']);
  // If the "close" button was pressed there is no validation or other
  // processing to do. We just set the appropriate ajax modal dismiss commands.
  if ($button_pressed == 'close') {
    $form_state['ajax_commands'][] = ctools_modal_command_dismiss();
  }
  elseif (isset($form_state['#file_replace_orig'])) {
    $file_orig = $form_state['#file_replace_orig']['file'];
    $passed_validators = $form_state['#file_replace_orig']['upload_validators'];
    $allow_empty = $form_state['#file_replace_orig']['allow_empty'];
    $physical_filename_orig = drupal_basename($file_orig->uri);
    // Extract the destination from the original file's URI.
    $destination = str_replace($physical_filename_orig, '', $file_orig->uri);
    // Add validation rule to ensure that the replacement filename matches the
    // original physical filename.
    $validators = $passed_validators += array(
      'file_replace_validate_filename' => array($physical_filename_orig),
    );
    // Save the uploaded file. This will also handle all of our validation
    // internally in one step.
    $replaced_file = file_save_upload('replace_upload', $validators, $destination, FILE_EXISTS_REPLACE);
    if ($replaced_file) {
      // file_save_upload() will set a status of 0 (temporary) to the uploaded
      // file, even when a replacement happens. Because of this we need to take
      // a special step to restore the status to its original state.
      $replaced_file->status = $file_orig->status;
      file_save($replaced_file);
      // If this is an image it might make use of image styles, so we should
      // flush those as well.
      if (module_exists('image')) {
        image_path_flush($replaced_file->uri);
      }
      drupal_set_message(t('The file replacement was successful.'));
      drupal_set_message(t('Please note that any users who recently accessed the file path may continue to see the old version for a short time due to local caching within their network and/or browser.'), 'warning');
    }
    else {
      // If no replacement file was saved, and there were no other validation
      // errors, it's likely that no file was specifed. We only trigger an error
      // message in this case under certain conditions.
      if (!$allow_empty) {
        form_set_error('replace_upload', t('A valid replacement file is required.'));
      }
    }
  }
}


/**
 * Submit callback for file replacement form.
 */
function file_replace_upload_submit($form, &$form_state) {
  // Ensure that the form is rebuilt so that any messages can be displayed.
  $form_state['rebuild'] = TRUE;
}


/**
 * File validation callback to force a specific filename for an uploaded file.
 */
function file_replace_validate_filename(stdClass $file, $filename) {
  $errors = array();
  // Require the uploaded filename to match the filename specified.
  if ($file->filename != $filename) {
    $errors[] = t('The filename of your replacement file must exactly match the original file. Please upload a file named <strong>@name</strong>.', array('@name' => $filename));
  }
  return $errors;
}


/**
 * Private utility to extract data about the original file (that's being
 * replaced) from the form data that contains a file field.
 *
 * @param array $parents
 *   An indexed array of parent keys that points to a specific file field file
 *   child element within a form hierarchy.
 * @param array $form
 *   A drupal form element containing a field field.
 * @param array $form_state
 *   Drupal form state data that includes "input" values.
 * @return array
 *   Returns an associative array of describing the original file. Includes:
 *   - file: The file object referenced in the specific file field child element
 *     that's designated by the $parents array.
 *   - upload_validators: An array of upload validation data (as used by
 *     file_save_upload()) that was used to validate the original file upload.
 *   - parent: An associative array describing the parent element that contains
 *     a specific file field element (for context).
 */
function _file_replace_get_orig_from_parents($parents, $form, $form_state) {
  $file_replace_orig = array();
  // Drill-down into the specific parent element that containes the file
  // element.
  $element = $form;
  $element_state = $form_state['input'];
  foreach ($parents as $parent) {
    if (isset($element[$parent]) && isset($element_state[$parent])) {
      $element_parent = $element;
      $element = $element[$parent];
      $element_state = $element_state[$parent];
    }
  }
  // Sanity-check our parent element values and intialize the data array to be
  // returned.
  if (isset($element['#type']) && $element['#type'] == 'managed_file' && isset($element_state['fid'])) {
    $file_replace_orig = array(
      'file' => file_load($element_state['fid']),
      'upload_validators' => isset($element['#upload_validators']) ? $element['#upload_validators'] : array(),
      'parent' => $element_parent,
      'allow_empty' => FALSE,
    );
  }
  return $file_replace_orig;
}