Select Page

The Setup

I recently had a client approach me about an interesting problem for which they needed a solution where their customers could drop a zip file onto the browser, unzip the file and parse an XML manifest. They wanted to know if it was possible to do this all in the browser without having to send the zip file to the server for processing. Geoff Goodman and I came up with a pretty easy solution that I wanted to share given its incredible utility. It was really cool hacking on a project with Geoff and seeing how he approached it. Everyone is unique in how they program and he was no exception!

We modified the solution to read a zip file with package.json file to make it easy for everyone to create a zip file to work with. In fact! We included a package.json file in the Plunker project so that you can download the project zip and use that to test the project. How meta!

The star of the show is JSZip which is a really easy to use library and makes parsing zip files a snap. Check out the plunk and let’s get started.

Demo

The Drop Container

The first thing that we are going to dig into is the mechanism that handles the actual drop event when a file is dropped onto the browser window. We have a dropFiles directive that handles the drop event, parses the dropped files and then passes those files to the parent controller for handling.

The one thing that I believe is the most important to understand about the dropFiles directive is that we are interacting with its isolate scope with an expression that has the same name as the directive. This allows us to define the directive like this drop-files=”main.handleFiles(files)” so that when we call ctrl.dropFiles({files: files}) from within the directive it will actually call handleFiles(files) on the MainCtrl.

Isolated scope is a powerful but sometimes confusing concept so if you would like to read more about it, check out this handy infographic I created for this post Infographic: Understanding AngularJS Isolated Scope

<body ng-controller="MainCtrl as main">

<div class="panel panel-default drop-target" drop-files="main.handleFiles(files)">
<div class="panel-body">
      Drop Your Zip File Here
    </div>

</div>
</body>

Define the Event Handlers

From within the directive, we will define our event handlers for the dragover and drop events. We will also define an event handler for the $destroy event on $scope and deregister the event handlers we just defined. This keeps things tidy and is important when we define the directive on elements that are dynamically being added to the DOM.

$element.on("dragover", handleDragOver);
$element.on("drop", handleDrop);

$scope.$on("$destroy", function () {
  $element.off("dragover", handleDragOver);
  $element.off("drop", handleDrop);
});

The Event Handlers

From within our event handlers, the first thing that we will do is to stop the event with e.preventDefault so that we can handle it ourselves. Within the handleDrop method, we are going to handle the files we just dropped by calling extractFiles and passing in the event. Once that is done, we will call ctrl.dropFiles({files: files}) which will delegate the real work to the parent controller.

var handleDragOver = function (e) {
  e.preventDefault();
};

var handleDrop = function (e) {
  e.preventDefault();

var files = extractFiles(e);

ctrl.dropFiles({files: files});
};

Extract the Files

All the extractFiles method does is extract the files from the event and pushes them into an array to be returned.

var extractFiles = function (e) {
  var files = e.dataTransfer.files;
  var filesArray = [];

for (var i = 0; i < files.length; i++) {
    filesArray.push(files[i]);
  }

return filesArray;
};

The Drop Files Directive

You can see the entire directive below. Another thing worth mentioning is that we are passing a fourth parameter into the linkFn function called ctrl which is actually the controller instance for the directive. This is why we are calling the dropFiles method as ctrl.dropFiles.

angular.module("fa.droppable", [
])

.directive("dropFiles", [ function () {
  var linkFn = function (scope, element, attrs, ctrl) {
    var extractFiles = function (e) {
      var files = e.dataTransfer.files;
      var filesArray = [];

<pre><code>  for (var i = 0, len = files.length; i < len; i++) {
    filesArray.push(files[i]);
  }

  return filesArray;
};

var handleDragOver = function (e) {
  e.preventDefault();
};

var handleDrop = function (e) {
  e.preventDefault();

  var files = extractFiles(e);

  ctrl.dropFiles({files: files});
};

$element.on("dragover", handleDragOver);
$element.on("drop", handleDrop);

$scope.$on("$destroy", function () {
  $element.off("dragover", handleDragOver);
  $element.off("drop", handleDrop);
});
</code></pre>
};

return {
    restrict: "A",
    controller: "DropFilesController",
    controllerAs: "drop",
    bindToController: true,
    require: "dropFiles",
    scope: {
      accepts: "&",
      dropFiles: "&",
    },
    link: linkFn,
  };
}])

.controller("DropFilesController", [ function () {
  var drop = this;
}])

And this is as far as we will take the dropFiles directive; but if someone were so inclined, they could easily add in extra functionality within the DropFilesController.

Parsing

Control is handed back to the MainCtrl when main.handleFiles is called. Since we are only handling a single file, we will pass the first item of the files array to the extractAndParse service for well… extraction and parsing.

app.controller('MainCtrl', ["extractAndParse", function (extractAndParse) {
  var main = this;
  main.handleFiles = function (files) {
    main.error = null;

<pre><code>extractAndParse(files[0])
  .then(function (file) {
    main.file = file;
    console.log('main', main.file);
  }, function(reason){
    main.error = reason.message;
  });
</code></pre>
};
}]);

The entry point for the extractAndParse service is a method of the same name that accepts a single parameter, which is the zip file. The extractAndParse method calls the unzip method which returns a promise; we then return the promise to the MainCtrl after calling JSON.parse on the results of the operation.

angular.module("fa.extractAndParse", [
])
.factory("extractAndParse", ["$q", function ($q) {
  //...

function extractAndParse (zipfile) {
    return unzip(zipfile)
      .then(JSON.parse);
  }

return extractAndParse;
}]);

The heavy lifting happens in the unzip method and it consists of two main steps. The first step is the instantiation of a new FileReader object which allows us to asynchronously read the contents of a file. We can initiate the reading of the zipfile by calling reader.readAsArrayBuffer and, more importantly, decide how we are going to handle the results in the onload event.

If there was an error, then we will simply reject the deferred promise we created. If not, we will create a new JSZip instance and pass in reader.result. The zip files are stored in key value pairs and since we are only interested in package.json, we will create a reference with the appropriately named file variable. If there is not a package.json file in the zip, then file will be undefined and we reject the promise with an error. If file is defined, we will resolve the promise with its contents in the form of a unicode string.

function unzip (zipfile) {
  var deferred = $q.defer();
  var reader = new FileReader();

reader.onerror = deferred.reject.bind(deferred);
  reader.onload = function (e) {
    if (!reader.result) deferred.reject(new Error("Unknown error"));

<pre><code>var zip = new JSZip(reader.result);
var file = zip.files['package.json'];

if(typeof file === 'undefined') {
  deferred.reject(new Error('package.json does not exist'));
}

return deferred.resolve(file.asText());
</code></pre>
};

reader.readAsArrayBuffer(zipfile);

return deferred.promise;
}

You can see the entirety of the extractAndParse service below.

angular.module("fa.extractAndParse", [
])
.factory("extractAndParse", ["$q", function ($q) {
  function unzip (zipfile) {
    var deferred = $q.defer();
    var reader = new FileReader();

<pre><code>reader.onerror = deferred.reject.bind(deferred);
reader.onload = function (e) {
  if (!reader.result) deferred.reject(new Error("Unknown error"));

  var zip = new JSZip(reader.result);
  var file = zip.files['package.json'];

  if(typeof file === 'undefined') {
    deferred.reject(new Error('package.json does not exist'));
  }

  return deferred.resolve(file.asText());
};

reader.readAsArrayBuffer(zipfile);

return deferred.promise;
</code></pre>
}

function extractAndParse (zipfile) {
    return unzip(zipfile)
      .then(JSON.parse);
  }

return extractAndParse;
}]);

Displaying

Dependencies

Now that we are getting a JSON file delivered to our controller all cleaned up and ready to roll, it is pretty much routine Angular from here on out. We are setting the contents of the package.json to main.file and then binding to it in our HTML. If something goes awry then we are setting the main.error property to give feedback to the user.

app.controller('MainCtrl', ["extractAndParse", function (extractAndParse) {
  var main = this;
  main.handleFiles = function (files) {
    main.error = null;

<pre><code>extractAndParse(files[0])
  .then(function (file) {
    main.file = file;
    console.log('main', main.file);
  }, function(reason){
    main.error = reason.message;
  });
</code></pre>
};
}]);

The HTML for the error looks like this.


<div class="alert alert-danger" role="alert" ng-if="main.error"><strong>Uh oh!</strong> {{main.error}}</div>

And if we are successful then we are going to do something arbitrary like display the dependencies in a table.


<div ng-if="main.file">
<table class="table table-striped">
<thead>
<tr>
<th>Dependency</th>
<th>Version</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="(dependency, version) in main.file.dependencies">
<th>{{dependency}}</th>
<td>{{version}}</td>
</tr>
</tbody>
</table>
<hr />
<pre ng-bind="main.file | json"></pre>
</div>

I am also dumping the contents of the package.json file just for fun as you can see in the pre tags.

Resources

This concludes a super fun session of “Hey! I wonder if we could actually do that…” and I hope you have learned how to do something new and can think of some clever ways to use this in your own projects. Again, a huge shout out to Geoff for doing a lot of the initial heavy lifting. He is super fun to hack with and I hope we get to do more of it. #highFive

FileReader Documentation

JSZip Homepage

Demo