Using Files from web browser and AngularJS: Select, Drag'n Drop, Read content, Upload to springboot
Files sandbox possibilities in Web Browser
Due to security restrictions, it is not possible from a web browser to perform Javascript operations on files, unless the user has manually choosen a file.
This little demo project shows how to perform operations on files using AngularJS (and springboot on server-side):
- Select files using html "<input type="file />"
- Drag'n Drop files
- Read file content
- Upload file content to server
The full repository code is available here: https://github.com/Arnaud-Nauwynck/test-snippets/tree/master/test-web-js-files
Here is the Angular screen to perform tests:
Select files using html "<input type="file" />"
<input type="file" multiple
set-model-on-change ng-model="ctrl.selectedFiles1"
ng-change="ctrl.onInputFileChange()">
<!-- using pure js event binding to angular..
<input type="file" multiple
onchange="angular.element(this).scope().ctrl.addSelectedFiles2(this.files)">
-->
<h2>Files</h2>
<div ng-repeat="file in ctrl.selectedFiles1">
</div>
Usually, ng-model binding works… except for this input type (??)</BR> You simply use the javascript File[] from your controller. It contains “trusted javascript file”, than you can read.
.controller('MyController', function($scope, $http) {
var self = this;
self.selectedFiles1 = []; // from input
self.onInputFileChange = function() {
console.log("onInputFileChange: selected files: ", self.selectedFiles1);
};
The directive “set-model-on-change” is custom, to bind “change” DOM events to angular for the input type=”file”.
.directive("setModelOnChange", function() {
return {
require: "ngModel",
link: function postLink(scope,elem,attrs,ngModel) {
elem.on("change", function(e) {
console.log("on change (from directive)", e);
var files = elem[0].files;
ngModel.$setViewValue(files);
})
}
}
})
You can see log in chrome dev tools after selecting 2 files:
Drag'n Drop files
Another nice way of selecting Files is to use Drag’n Drop: you drag a file from your system file explorer,
and drop it into a html Drop zone that accept you files.
Here is a drop-zone, using a custom AngularJS directive.
This directed is adapted from draganddrop.js, /*! Angular draganddrop v0.2.2 | (c) 2013 Greg Bergé | License MIT */
I have adapted it because I could not make it work on the filtering of accepted mime-types files.
<div id="drop_zone" style='border: solid'
drag-over="ctrl.onDragOver($event)" drag-over-class="drag-over"
drop-accept="ctrl.dropAccept($event)"
drop="ctrl.onDrop($data, $event)" drop-effect="link"
>Drop files here...</div>
In the js-part, I implements the 3 callbacks method onDragOver(), dropAccept() and onDrop().
The important one is onDrop() that simply get the dropped files and store them as angularJs model values (avoiding duplicates files based on their name+size+modifDate).
Notice that the file full path can not be consulted from Javascript, only the name.
self.selectedFiles2 = []; // trusted javascript File are appended when dropping files, from self.onDrop()
// optionnal, to add logs
self.onDragOver = function($event) {
// var files = $event.dataTransfer.files;
// console.log("onDragOver (using draganddrop.js module directive)", {event: $event, files: files} );
};
// optionnal, to decide if content files can be dropped. Should filter on mime-types.
self.dropAccept = function($event) {
// console.log("dropAccept (using draganddrop.js module directive)", $event);
// var files = $event.dataTransfer.files;
return true;
}
self.onDrop = function(data, $event) {
var files = $event.dataTransfer.files;
console.log("onDrop (using draganddrop.js module directive)", {data, $event, files});
// self.selectedFiles2.push(...files); do not add doublon..
for(var i = 0; i < files.length; i++) {
var f = files[i];
if (-1 === findFileElt(self.selectedFiles2, f)) { // indexOf() does not work!
self.selectedFiles2.push(f);
} else {
console.log('file already added ', f);
}
}
};
function findFileElt(ls, elt) {
for(var i = 0; i < ls.length; i++) {
if (ls[i].name === elt.name && // can not compare by full path?!!
ls[i].size === elt.size && ls[i].lastModified === elt.lastModified) {
return i;
}
}
return -1;
}
You can see logs in chrome:
Read Files content
After selecting/dropping files, click on button “Read selected files”, and the content will be read and displayed in textarea.
Here is the code in html
<button ng-click="ctrl.onReadSelectedFiles()">Read selected files</button>
<div ng-repeat="fc in ctrl.selectedFileWithContents">
{{fc.name}}:
<textarea cols='30' rows='5'>{{fc.textContent}}</textarea>
</div>
In Javascript, it is a bit more difficult because all I/O reading are asynchronous methods, with callbacks.
Also notice that the callback need to call angularJS “$scope.apply()” to perform a refresh.
self.onReadSelectedFiles = function() {
self.selectedFileWithContents = [];
var files = [...self.selectedFiles1, ...self.selectedFiles2];
for(var i = 0; i < files.length; i++) {
var file = files[i];
console.log("async read file[" + i + "]", file);
var reader = new FileReader();
reader.onload = (function(f) {
return function(e) {
$scope.$apply(function() { // ugly.. force angular refresh!!
var textContent = e.target.result;
console.log("finished read file[" + i + "]",{ name: f.name, textContent: textContent});
self.selectedFileWithContents.push({ name: f.name, textContent: textContent});
});
};
})(file);
reader.readAsText(file);
}
};
If you need to load multiple files, and call a single callback at the end, you have to chain callbacks…
A common way for that is to use $q Promises, and resolve “$q.all([ promise0, promise1 ]).then(..)”.
Upload file content to server
There are many ways to submit content data to server.
This can be a multi-part files, or simply a http Rest body request with “Content-type: application/whatever” …but maybe not “application/json” which is default when using AngularJS.
You can event wrap the bytes array content into one a the field of a regular json object, and send the json..
Uploading using MultipartFile
self.onUploadFilesMultipartFiles = function() {
var files = [ ...self.selectedFiles1, ...self.selectedFiles2 ];
var formData = new FormData();
for(var i = 0; i < files.length; i++) {
formData.append(files[i].name, files[i]);
}
$http.post('/app/uploadMultipartFiles', formData, {
transformRequest: angular.identity,
headers: { 'Content-Type': undefined }
}).then(function (data) {
self.uploadStatus += 'OK uploaded ' + files + ' using MultipartFile\n';
}, function (err) {
self.uploadStatus += 'Failed uploaded ' + files + ' using MultipartFile' + err + '\n';
});
};
In springboot,
@PostMapping(value="/uploadMultipartFiles", consumes = {"multipart/form-data"})
public void uploadMultipartFile(MultipartHttpServletRequest request) throws IOException {
LOG.info("/uploadMultipartFiles ");
for(Map.Entry<String, MultipartFile> e : request.getFileMap().entrySet()) {
String fileName = e.getKey();
byte[] content = e.getValue().getBytes();
logContentIfText(fileName, content);
}
}
If you have to upload only a fixed number of file(1,2..) whith given part name (like “attachment1”, “attachement3, “video”…), you can do this in springboot:
@PostMapping(value="/uploadMultipartFile", consumes = {"multipart/form-data"})
public void uploadMultipartFile12(@RequestPart("file") MultipartFile file) throws IOException {
LOG.info("/uploadMultipartFile " + file.getName() + " using MultipartFile");
logContentIfText(file.getName(), file.getBytes());
}
Uploading using Request Body Content
This method is more natural for a Rest endpoint, in particular when using curl:
curl -X POST --data-binary @myfile.txt http://localhost:8080/upload/myfile.txt
#Notice... curl -X POST -d @myfile.txt .... will work byt prune all newline characters!!! not what you want for a csv file for example!!
In Javascript, the difficulties are 1/ to read the file with async callback, then 2/ obtain a “Blob” to convert to “Int8Array”, submit using $http.post() without transforming to json, and finally change default http header to remove ‘Content-Type: application/json’.
self.onUploadFileBytes = function() {
var files = [ ...self.selectedFiles1, ...self.selectedFiles2 ];
for (var i = 0; i < files.length; i++) {
var file = files[i];
var fileName = file.name;
console.log("async read file[" + i + "]: '" + fileName + "'", file);
var reader = new FileReader();
reader.onload = (function(f) {
return function(e) {
var bytesContent = new Int8Array(e.target.result);
self.asyncUploadFileBytes(f.name, bytesContent);
};
})(file);
reader.readAsArrayBuffer(file);
}
};
self.asyncUploadFileBytes = function(fileName, int8ArrayContent) {
$http.post('/app/uploadFileBytes/' + fileName, int8ArrayContent, {
transformRequest: [],
headers: { 'Content-Type': undefined }
}).then(function(res) {
self.uploadStatus += 'OK uploaded ' + fileName + '\n';
}, function(err) {
self.uploadStatus += 'Failed uploaded ' + fileName + ' : ' + err + '\n';
});
};
From java, it is easy to handle… and there are also several possibilities in springboot: you can bind method parameters as HttpServletRequest, and @RequestBody InputStream, or as @RequestBody byte[].
The simpler in springboot is using byte[]:
@PostMapping(value="/uploadFileBytes/{fileName:.+}", consumes=MediaType.ALL_VALUE)
public void uploadFileBytes(@PathVariable(name="fileName", required=true) String fileName, @RequestBody byte[] inputBody) {
LOG.info("/uploadFile '" + fileName + "' using byte[]");
logContentIfText(fileName, inputBody);
}
2 remarks in java:
- the @PathVariable by default "{fileName}" would prune the file name extension... you have to use a regular expression: "{fileName:.+}"
- you need to override the default "application/json" to accept all mime-types: consumes=MediaType.ALL_VALUE
Conclusion
Javascript File API works great, and are really simple !
SpringBoot and Angular(JS) are awesome.