Play – Upload de fichiers multiples avec le composant jQuery File Upload (File API HTML5)

Les nouvelles APIs « HTML5 » apportent des nouveautés intéressantes comme la File API, permettant de faire du Drag n Drop de fichiers directement depuis sa machine vers une page web. Très pratique pour éviter d’utiliser les « input type file » classiques (boutons « Parcourir… »), surtout quand vous avez plusieurs fichiers à uploader.

Un bon exemple d’utilisation est celui de http://cartodb.com/ (après inscription) sur lequel on peut envoyer un fichier SIG ShapeFile qui n’est pas constitué d’un seul fichier mais de 5/6 fichiers différents. Il est plus pratique de faire glisser les 6 fichiers vers la page web que de créer une archive zip à uploader.

Bref, on va voir comment faire cela de manière simple avec jQuery et Play Framework côté serveur.

Le composant jQuery File Upload

La File API n’est pas facile à manipuler, avec notamment pas mal de cas spéciaux et des comportements qui diffèrent selon les navigateurs. Il y a donc plusieurs librairies qui ont émergé comme uploadify (merci @clacote). Pour ma part, j’ai trouvé ce composant assez puissant qui fonctionne plutôt bien, le jQuery File Upload:

http://blueimp.github.com/jQuery-File-Upload/

Première chose à faire, aller récupérer le code source (et lire les très bonnes docs accessoirement):

https://github.com/blueimp/jQuery-File-Upload/downloads

Application Play Framework 1.x

Si vous n’avez pas téléchargez Play, rendez-vous sur le site officiel. Prenez la 1.2.5:

http://www.playframework.org/download

Dans ce tutorial, on utilisera la 1.x (v1.2.5 lors de l’écriture de cet article). Créez une application si vous n’en avez pas déjà une:

play new tuto-upload

Puis lancez la commande qui permet d’ouvrir le projet dans votre IDE préféré (« play idealize » pour IntelliJ par exemple).

Copiez les fichiers du composant jQuery File Upload que vous avez téléchargé dans le dossier /public/javascripts de votre application. Prenez tout ce qu’il y a dans « /js/ » et copiez-le dans /public/javascripts/ ». Prenez aussi ce fichier qui va se charger de faire du templating pour jQuery (jQ Templates):

http://blueimp.github.com/JavaScript-Templates/tmpl.min.js

Mettez ensuite le contenu de /css/ dans /stylesheets/. Au passage, allez aussi chercher le CSS de Twitter Bootstrap, qui va donner une bonne tête à votre page:

http://twitter.github.com/bootstrap/assets/css/bootstrap.css

Le template de base de Play qui sert de squelette pour vos pages (app/views/main.html) contient déjà jQuery, rien à changer de ce côté là. On va travailler dans le fichier index.html directement.

On va commencer par rajouter les links vers les fichiers JS que l’on vient de copiez en utilisant les hooks « moreScripts » et « moreStyles » + les balises Groovy #{script} et #{style} qui se chargent de setter les attributs obligatoires autres que src:

#{extends 'main.html' /}
#{set title:'Home' /}
#{set 'moreScripts'}
    #{get 'moreScripts' /}
    #{script 'vendor/jquery.ui.widget.js'/}
    #{script 'jquery.iframe-transport.js'/}
    #{script 'tmpl.min.js'/}
    #{script 'jquery.fileupload.js'/}
    #{script 'jquery.fileupload-fp.js'/}
    #{script 'jquery.fileupload-ui.js'/}
    #{script 'locale.js'/}
#{/set}
#{set 'moreStyles'}
    #{get 'moreStyles' /}
    #{stylesheet 'jquery.fileupload-ui.css'/}
    #{stylesheet 'bootstrap.css'/}
#{/set}

Création de l’action d’upload

On commence par ajouter une route dans le fichier /conf/routes:

# Home page
GET     /                                       Application.index
POST    /upload                                 Application.upload

On est bien en POST car on souhaite faire de l’upload. On va créer l’action dans notre controller Application.java:

package controllers;

import play.*;
import play.mvc.*;

import java.io.File;
import java.util.*;

import models.*;

public class Application extends Controller {

    public static void index() {
        render();
    }

    public static void upload(File[] files) {

    }

}

Maintenant, on va créer notre formulaire à partir de cette action dans notre template Groovy (index.html, là où on a mis les moreScript). Pour cela, Play dispose d’un tag #{form} qui permet de faire du reverse routing et de ne pas avoir à taper l’url d’upload (attribut « action ») à la main, pratique:

#{extends 'main.html' /}
#{set title:'Home' /}
#{set 'moreScripts'}
    #{get 'moreScripts' /}
    #{script 'vendor/jquery.ui.widget.js'/}
    #{script 'jquery.iframe-transport.js'/}
    #{script 'jquery.fileupload.js'/}
    #{script 'jquery.fileupload-fp.js'/}
    #{script 'jquery.fileupload-ui.js'/}
    #{script 'locale.js'/}
#{/set}
#{set 'moreStyles'}
    #{get 'moreStyles' /}
    #{stylesheet 'jquery.fileupload-ui.css'/}
#{/set}

#{form @Application.upload() , id:'fileupload', enctype:'multipart/form-data'}
#{/form}

On lui a juste ajouté un simple id pour plus tard et un enctype qui dit que l’on va faire de l’upload.

On va lancer notre application pour voir ce que cela donne. Lancez la commande :

play run

Le serveur Play est lancé sur le port 9000. Rendez-vous donc sur :

http://localhost:9000/

Si tout va bien, vous devriez arriver sur une page blanche. Allez voir la source de la page et vous verrez que votre formulaire est bien là:

<form action="/upload" method="POST" accept-charset="utf-8" enctype="multipart/form-data" id="fileupload" ><input type="hidden" name="authenticityToken" value="58abd56b3f8b6c62a0e0180b59cc65e1e74a6b43">

</form>

Maintenant, on va ajouter le composant jQuery File Upload.

Vous pouvez l’arranger à votre guise, perso j’ai choisi la méthode simple en reprenant la barre d’outils de la page d’exemple:

#{form @Application.upload() , id:'fileupload', enctype:'multipart/form-data'}
<!-- The fileupload-buttonbar contains buttons to add/delete files and start/cancel the upload -->
<div class="row fileupload-buttonbar">
    <div class="span7">
        <!-- The fileinput-button span is used to style the file input field as button -->
                <span class="btn btn-success fileinput-button">
                    <i class="icon-plus icon-white"></i>
                    <span>Add files...</span>
                    <input type="file" name="files[]" multiple>
                </span>
        <button type="submit" class="btn btn-primary start">
            <i class="icon-upload icon-white"></i>
            <span>Start upload</span>
        </button>
        <button type="reset" class="btn btn-warning cancel">
            <i class="icon-ban-circle icon-white"></i>
            <span>Cancel upload</span>
        </button>
        <button type="button" class="btn btn-danger delete">
            <i class="icon-trash icon-white"></i>
            <span>Delete</span>
        </button>
        <input type="checkbox" class="toggle">
    </div>
    <!-- The global progress information -->
    <div class="span5 fileupload-progress fade">
        <!-- The global progress bar -->
        <div class="progress progress-success progress-striped active" role="progressbar" aria-valuemin="0" aria-valuemax="100">
            <div class="bar" style="width:0%;"></div>
        </div>
        <!-- The extended global progress information -->
        <div class="progress-extended"> </div>
    </div>
</div>
    <!-- The loading indicator is shown during file processing -->
    <div class="fileupload-loading"></div>
    <br>
    <!-- The table listing the files available for upload/download -->
    <table role="presentation" class="table table-striped"><tbody class="files" data-toggle="modal-gallery" data-target="#modal-gallery"></tbody></table>
#{/form}

On va ensuite récupérer le formulaire par son id et le passer dans le composant jQuery:

...
#{/form}

<script type="text/javascript">
    var uploadCount = 0;
    $('#fileupload').fileupload({autoUpload: true}).bind('fileuploaddone', function (e, data) {
        alert("finished");
    });
</script>

Pour faire plus simple, je passe ici l’option autoUpload à true, ainsi, dès que l’on va déposer des fichiers, l’upload va commencer. Puis je bind un event handler sur ‘fileuploaddone’ qui va se déclencher dès qu’un upload est terminé.

Intégration des templates jQuery Template dans Play

Si vous regardez la source de la page d’exemple, vous verrez ceci:

Des templates sont définis dans la page, pour afficher pour chaque ligne, l’avancement de chaque upload, le nom du fichier, etc. Copiez donc ces 2 templates dans votre template Play (index.html toujours), juste après la fin du #{form}.

Rechargez la page et là, surprise:

Les caractères %{ sont en fait réservés en Groovy pour insérer du code Groovy dans un template. Play se mélange donc les pinceaux et essaie de compiler le template jQuery, ce qui n’est pas souhaité.

Après avoir essayé pas mal de feinte pour échapper le contenu du template, il y a deux techniques qui fonctionne. Dans les 2, vous aller en faire charger un fichier et l’afficher en brut. Au passage, le tag Play « verbatim » permet de ne pas échapper les caractères HTML mais ne fait rien pour les caractères qui nous intéressent.

Commencez donc par créer un fichier /app/jqt/templates.html contenant vos 2 templates et supprimez-les de la page index.html.

Attention, ne placez pas ce fichier dans /views/ car au moment de faire une « release production » de votre projet Play, celui-ci va essayer de compiler tout ce qui se trouve dans /views/ et vous aurez une erreur à nouveau.

Première technique (simple)

La première technique tient en une ligne à insérer dans index.html:

${play.libs.IO.readContentAsString(play.Play.getFile("/app/jqt/templates.html")).raw()}

Deuxième technique (création d’un tag custom)

La deuxième technique est de créer son propre tag Groovy qui va insérer le fichier qu’on lui passe en paramètre. Pour cela, créez un fichier /app/customtags/EscapeTag.java qui contient:

package customtags;

import groovy.lang.Closure;
import play.Logger;
import play.exceptions.TagInternalException;
import play.exceptions.TemplateExecutionException;
import play.exceptions.TemplateNotFoundException;
import play.templates.*;
import play.vfs.VirtualFile;

import java.io.PrintWriter;
import java.util.Map;

public class EscapeTag extends FastTags {

    public static void _includeVerbatim(Map<?, ?> args, Closure body, PrintWriter out, GroovyTemplate.ExecutableTemplate t, int fromLine) {
        try {
            final String path = args.get("arg").toString();
            final BaseTemplate template = (BaseTemplate) TemplateLoader.load(path);
            final String contents = template.source;
            //out.print(JavaExtensions.escapeHtml(contents));
            out.print(contents);
        } catch (TemplateNotFoundException e) {
            throw new TemplateNotFoundException(e.getPath(), t.template, fromLine);
        }
    }
}

puis dans index.html:

#{includeVerbatim path='../resources/jQueryUploadUITemplates.html'}
#{/includeVerbatim}

Je n’ai découvert la première solution qu’après la deuxième, mais je la met pour la gloire :)

Mise en place de l’upload

Maintenant que notre page web est ok, retournez donc sur http://localhost:9000. Si vous regardez dans la console, il est possible que vous ayez:

Uncaught TypeError: Object [object Object] has no method 'prop'

Si vous avez cette erreur, cela signifie que vous utilisez une version de jQuery incompatible avec le composant. Play Framework v1.2.3 intègre par exemple jQuery 1.5.2 (vieux) qui n’est pas compatible. Prenez donc une des dernières versions et copiez-là dans votre répertoire /public/javascripts/. Ensuite, n’oubliez pas de modifier le fichier main.html pour référencer le bon fichier jQuery :).

C’est parti, prenez une image sur votre PC et glissez-là sur la page. Vous devriez obtenir une barre de progression puis:

C’est déjà un début :).

Note : J’ai eu quelques problèmes avec la version 1.2.3 de Play qui ne faisait pas passer les arguments File. Je n’ai pas essayé d’aller plus loin, la version 1.2.5 fonctionne très bien.

Copiez le fichier uploadé

La première chose à faire est de copier le fichier uploadé. En effet, celui-ci sera temporairement dans le dossier /tmp/ de Play mais peut être vidé à tout moment. Pour être tranquille, on va le mettre dans /public/uploads/, comme cela, on pourra y accéder par http://localhost/uploads/:

    public static void upload(File[] files) {
        if (files == null || files.length < 1){
            return;
        }
        File file = files[0];
        File uploadDir = new File(Play.applicationPath, "/public/uploads/");
        if (!uploadDir.exists()){
            uploadDir.mkdirs();
        }
        try {
            FileUtils.moveFile(file, new File(uploadDir, file.getName()));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

Envoyez à nouveau votre fichier. Vous aurez toujours le message d’erreur mais le fichier se trouve maintenant dans /public/uploads/:

Avoir un répertoire par série d’uploads

Avant de corriger l’erreur du retour d’upload, on va faire un peu le ménage. Avec le code que l’on vient de taper, tous les fichiers vont être copiés dans /public/uploads/, il y a donc un fort risque de collision entre les utilisateurs de votre application. A partir de là, vous pouvez avoir votre propre technique pour compartimenter les uploads. Perso, voici ce que j’ai fait.

La première étape est de créer un ID aléatoire avec la classe Codec de Play:

    public static void index() {
        String randomID = Codec.UUID();
        render();
    }

Ensuite, il faut le passer à la page. Soit vous le passez directement dans render(), soit vous le passez dans les renderArgs, cela vous évitera de polluer votre méthode render:

    public static void index() {
        String randomID = Codec.UUID();
        renderArgs.put("randomID", randomID);
        render();
    }

Ensuite dans votre template, ajouter un input de type hidden pour que la valeur soit passée lors de la soumission du formulaire:

#{form @Application.upload() , id:'fileupload', enctype:'multipart/form-data'}
<input type="hidden" name="randomID" value="${randomID}" />

F5 et voilà vous avez votre input avec un ID unique à chaque session:

Maintenant, il suffit de récupérer ce paramètre dans la méthode upload et de créer le répertoire en conséquence:

public static void upload(File[] files, String randomID) {
        if (files == null || files.length < 1){
            return;
        }
        File file = files[0];
        File uploadDir = new File(Play.applicationPath, "/public/uploads/" + randomID);
...

Résultat:

Maintenant, passons à cette erreur sur le retour de l’upload

Envoyer le bon contenu en retour de l’upload

Pour l’instant, après l’upload du fichier, on obtient une erreur « Empty file upload result ». En effet, la méthode upload() ne renvoie rien. Le composant jQuery File Upload attend en fait un tableau d’objets JSON avec certaines valeurs comme le nom du fichier, une vignette, le chemin du fichier uploadé, … Vous pouvez voir cela sur la page d’exemple du composant avec un Firebug ou un Chrome inspector.

Pour faciliter la sérialisation, on va créer un objet Java UploadResult:

package models;

public class UploadResult {
    // url
    public String url;
    // "data:image/png;base64, ...
    public String thumbnail_url;
    public String name;
    public String type;
    public Long size;
    public String delete_url;
    public String delete_type;
    public String ref;
}

Puis on va créer cet objet avec les bonnes informations (au moins celles obligatoires comme la taille et le nom) puis le renvoyer. Attention, le composant attend un tableau d’objets JSON, on le passe donc vite fait dans une List:

        if (!uploadDir.exists()){
            uploadDir.mkdirs();
        }
        File uploadedFile = new File(uploadDir, file.getName());
        try {
            FileUtils.moveFile(file, uploadedFile);
        } catch (IOException e) {
            e.printStackTrace();
        }
        UploadResult uploadResult = new UploadResult();
        uploadResult.name = file.getName();
        uploadResult.type = "text/plain";
        uploadResult.ref = randomID;
        uploadResult.url = "public/uploads/" + randomID + "/" + file.getName();
        uploadResult.size = FileUtils.sizeOf(uploadedFile);
        // le composant d'upload attend un tableau for some reason
        ArrayList<UploadResult> result = new ArrayList<UploadResult>();
        result.add(uploadResult);
        renderJSON(result);

Retentez un upload:

Renvoyez une miniature de l’image

On va faire un peu de zèle et renvoyez une miniature de l’image qui sera affichée dans le composant File Upload directement. Pour cela, il suffit de remplir la propriété « thumbnail_url » de notre UploadResult.

La première étape est de créer la miniature. Pour cela, Play a une méthode qui va bien:

File thumbnail = new File(uploadDir, "thumb__" + file.getName());
        play.libs.Images.resize(uploadedFile, thumbnail, 100, 50);
        UploadResult uploadResult = new UploadResult();
        uploadResult.name = file.getName();
        uploadResult.type = "text/plain";
        uploadResult.ref = randomID;
        uploadResult.url = "public/uploads/" + randomID + "/" + file.getName();
        uploadResult.thumbnail_url = "public/uploads/" + randomID + "/" + thumbnail.getName();
        uploadResult.size = FileUtils.sizeOf(uploadedFile);

Et notre thumbnail va s’affiche dans notre page:

Conclusion

Voilà, un tutorial finalement assez long mais j’ai préféré détailler toutes les merdes qu’il pouvait vous arriver. Bien sûr, pour être propre, il faudrait vérifier le type du fichier, éviter d’étirer les petits fichiers, etc.

Vous pouvez aussi modifier les templates jQuery qui affichent les upload pour qu’ils correspondent au style de votre application.

N’oubliez pas de tenter de faire un drag & drop de plusieurs fichiers, c’était le but de l’exercice :)

Télécharger les sources du projet

4 réflexions au sujet de « Play – Upload de fichiers multiples avec le composant jQuery File Upload (File API HTML5) »

  1. badwolf

    Quel est l’intérêt de mettre mon commentaire (partagé pour une grande partie des dev’) sur twitter, tu n’es donc pas capable de répondre par toi même ???

    Je m’étonne que tu ne traites pas le sujet sur ton blog tout simplement, n’est-ce pas un site d’actualités et de tutoriaux sur HTML5 ?

    Répondre
  2. admin Auteur de l’article

    Salut badwolf,

    l’intérêt de le mettre sur twitter ? pas énorme mais je ne cherchais pas une réponse chez mes followers non plus, j’ai mon avis et je peux très bien répondre par moi-même.

    Pour moi ce « fork » entre les 2 organisations n’est pas « très » important, d’après ce que j’ai compris, ce sera juste un peu plus le bordel qu’avant. Avant on avait des différences d’implémentation, maintenant on pourrait avoir des différences de spécifications. D’après ce que j’ai compris de l’annonce, le W3C va travailler sur « les vieux trucs » (XML / XHTML) et l’autre va se concentrer sur les nouvelles specs. Donc au final, ça va rester un peu comme avant avec leur « spec vivante ».

    De dire que cela rend les articles / tutoriaux caduques est je pense très exagéré. On sait tous que les specs HTML5 ne sont pas finalisées, un « Draft » a été publié il y a un mois sur la File API qui est utilisée dans ce tutorial.

    Bref, d’après ce que je vois, cela ne change pas grand chose même si certains site « d’actualité informatique » on fait passer des vessies pour des lanternes. Sûrement l’effet d’annonce, un peu comme de dire que « flash est mort » & co.

    Je l’avais dit en introduction, html5-tutorial.fr n’a pas vocation a être un site de news (même si cela m’apporterai plus de visiteurs il faut l’avouer) mais avant tout un site de tutoriaux (d’où le nom), très technique . Je n’ai pas envie d’avoir des farandoles de commentaires de gens qui pensent avoir tout compris comme sur Clubic & co, avec des réflexions souvent complètement fausses.

    Voilà, maintenant tu dois comprendre pourquoi j’ai trouvé ton commentaire « trollesque » au premier abord, sans rancune ;)

    Fabien

    Fabien

    Répondre

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *