D7 create zip archive from multiple custom uploaded files

In this article I would like to share another interesting task connected with upload files in D7.

Task is to have custom form that allows to upload multiple files at once and create zip archive from these files on finish.

I really liked the way google mail handles attached files form and tried to implement similar behavior but without writing custom javascript. Thanks to Form API and #ajax property it is very managable. So lets dive into code!

Big thanks to Examples module that helped me with example of dynamically adding form elements.

/**
 * Form builder.
 */
function example_zip_file_form($form, &$form_state) {
  // Init num_files and uploaded_files variables if they are not set yet.
  if (empty($form_state['num_files'])) {
    $form_state['num_files'] = 1;
  }
  if (empty($form_state['uploaded_files'])) {
    $form_state['uploaded_files'] = array();
  }
 
  $form['file_upload_fieldset'] = array(
    '#type' => 'fieldset',
    '#title' => t('Uploaded files'),
    // Set up the wrapper so that AJAX will be able to replace the fieldset.
    '#prefix' => '<div id="uploaded-files-fieldset-wrapper">',
    '#suffix' => '</div>',
  );
 
  for ($i = 0; $i < $form_state['num_files']; $i++) {
    // Show upload form element only if it is new or
    // it is not unset (name equal to FALSE).
    if (
      !isset($form_state['uploaded_files']['files']['name']['file_upload_' . $i]) ||
      (isset($form_state['uploaded_files']['files']['name']['file_upload_' . $i]) && $form_state['uploaded_files']['files']['name']['file_upload_' . $i] !== FALSE)) {
      $form['file_upload_fieldset']['file_upload_' . $i] = array(
        '#type' => 'file',
        '#prefix' => '<div class="clear-block">',
        '#size' => 22,
        '#theme_wrappers' => array(),
      );
      $form['file_upload_fieldset']['file_upload_remove_' . $i] = array(
        '#type' => 'submit',
        '#name' => 'file_upload_remove_' . $i,
        '#value' => t('Remove file'),
        '#submit' => array('example_zip_file_remove'),
        '#ajax' => array(
          'callback' => 'example_zip_file_refresh',
          'wrapper' => 'uploaded-files-fieldset-wrapper',
        ),
        '#suffix' => '</div>',
      );
 
      // If file already uploaded we add its name as prefix.
      if (    isset($form_state['uploaded_files']['files']['name']['file_upload_' . $i])
          && !empty($form_state['uploaded_files']['files']['name']['file_upload_' . $i])) {
        $form['file_upload_fieldset']['file_upload_' . $i]['#type'] = 'markup';
        $form['file_upload_fieldset']['file_upload_' . $i]['#markup'] = t('File: @filename', array('@filename' => $form_state['uploaded_files']['files']['name']['file_upload_' . $i]));
      }
    }
  }
 
  // Add new button.
  $form['add_new'] = array(
    '#type' => 'submit',
    '#value' => t('Add another file'),
    '#submit' => array('example_zip_file_add'),
    '#ajax' => array(
      'callback' => 'example_zip_file_refresh',
      'wrapper' => 'uploaded-files-fieldset-wrapper',
    ),
    '#limit_validation_errors' => array(),
  );
 
  // Submit button.
  $form['submit'] = array(
    '#type' => 'submit',
    '#value' => t('Create zip archive from uploaded files'),
  );
 
  return $form;
}

First of all we keep information about already uploaded files in $form_state['uploaded_files'] variable and number of file form elements to show in $form_state['num_files'] element.

On the form we have two ajaxified buttons "Add another file" and "Remove file". Their submit functions should add / remove new file form and keep information about already uploaded files. And of course mark form to be rebuild.

/**
 * Callback for Remove button.
 *
 * Remove uploaded file from 'uploaded_files' array and rebuild the form.
 */
function example_zip_file_remove($form, &$form_state) {
  $form_state['uploaded_files'] = example_zip_file_array_merge($form_state['uploaded_files'], $_FILES);
  $file_to_remove_name = str_replace('_remove', '', $form_state['clicked_button']['#name']);
  $form_state['uploaded_files']['files']['name'][$file_to_remove_name] = FALSE;
  $form_state['rebuild'] = TRUE;
}
 
/**
 * Add new form file input element and save uploaded files to 'uploaded_files' variable.
 */
function example_zip_file_add($form, &$form_state) {
  $form_state['num_files']++;
  $form_state['uploaded_files'] = example_zip_file_array_merge($form_state['uploaded_files'], $_FILES);
  $form_state['rebuild'] = TRUE;
}

Function example_zip_file_array_merge merges recursively arrays. It was not possible to use array_merge_recursive in this case as it create array element in hierarchy instead of replacing it. To be clear lets see example from official documentation page:

$ar1 = array("color" => array("favorite" => "red"), 5);
$ar2 = array(10, "color" => array("favorite" => "green", "blue"));
$result = array_merge_recursive($ar1, $ar2);
print_r($result);

This leads to result:

Array
(
    [color] => Array
        (
            [favorite] => Array
                (
                    [0] => red
                    [1] => green
                )

            [0] => blue
        )

    [0] => 5
    [1] => 10
)

But we need:

Array
(
    [color] => Array
        (
            [favorite] => green
            [0] => blue
        )

    [0] => 5
    [1] => 10
)

The ajax callback of above mentioned two buttons "example_zip_file_refresh" just returns fieldset with file form elements of rebuilt form.

/**
 * AJAX callback. Retrieve proper element.
 */
function example_zip_file_refresh($form, $form_state) {
  return $form['file_upload_fieldset'];
}

Now lets take a look at form submit handler, where we create Zip archive.

/**
 * Form submit handler.
 */
function example_zip_file_form_submit($form, &$form_state) {
  // Merge uploaded files.
  $form_state['uploaded_files'] = example_zip_file_array_merge($form_state['uploaded_files'], $_FILES);
  $_FILES = $form_state['uploaded_files'];
 
  // Walk through files and save uploaded files.
  $uploaded_files = array();
  foreach ($_FILES['files']['name'] as $file_key => $value) {
    $file = file_save_upload($file_key);
    $uploaded_files[] = $file;
  }
 
  // Create Zip archive form uploaded files.
  $archive_uri = 'temporary://download_' . REQUEST_TIME . '.zip';
  $zip = new ZipArchive;
  if ($zip->open(drupal_realpath($archive_uri), ZipArchive::CREATE) === TRUE) {
    foreach ($uploaded_files as $file) {
      $zip->addFile(drupal_realpath($file->uri), $file->filename);
    }
    $zip->close();
    drupal_set_message(t('Zip archive successfully created. !link', array('!link' => l(file_create_url($archive_uri), file_create_url($archive_uri)))));
  }
  else {
    drupal_set_message(t('Error creating Zip archive.'), 'error');
  }
}

So thats it. Now we can let users create their own Zip archives.

In my task I had to build custom form that is part of multistep form. In ideal situation I would probably go for letting user to submit node creating form with unlimited value filefield to handle all ajax file uploads for me. And then just add hook_node_presave implementation where I would create Zip archive and added this zip file to another filefield.

But this example is more to show how ajax forms work on real life task. Hope you find this interesting and useful.

You are welcome to test module attached to this article.

Attached files: 

Comments

I clarified some old concepts and learnt some new ones! Thanks!!

Wouldn't it make more sense to instead do this as a formatter for the file field (and its derivatives). That way you leave all the ajaxy stuff up to File field. All you do is add an additional link to the rendered field: "Download all files". You can then generate the archives on the fly if they don't already exist ala Imagecache (Image Styles in D7). P.S. Your CAPTCHA is darn near impossible.

I completely agree about using widget unlimited Filefield field. But this tutorial is more for educational purpose using #ajax propery of the Form API.