Subversion Repositories eFlore/Applications.del

Rev

Rev 1684 | Rev 1748 | Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | RSS feed

<?php
/**
 * @author              Raphaël Droz <raphael@tela-botanica.org>
 * @copyright   Copyright (c) 2013, Tela Botanica (accueil@tela-botanica.org)
 * @license     http://www.cecill.info/licences/Licence_CeCILL_V2-fr.txt Licence CECILL
 * @license     http://www.gnu.org/licenses/gpl.html Licence GNU-GPL
 * @see http://www.tela-botanica.org/wikini/eflore/wakka.php?wiki=ApiIdentiplante01Images
 * @see http://www.tela-botanica.org/wikini/identiplante/wakka.php?wiki=IdentiPlante_PictoFlora_MoteurRecherche
 *
 * Backend pour PictoFlora (del.html#page_recherche_images)
 *
 *
 * == Notes ==
 *
 * tri=votes et tri=tags: affectent le choix des images affichées (donc getIdImages())
 * Cependant ce total ne nous intéresse même pas (MoyenneVotePresenteur.java s'en occupe).
 * Seul tri=date_transmission nous évite l'AVG() + GROUP BY
 *
 * protocole: il affecte l'affichage des information, mais le JSON contient déjà
 * l'intégralité (chercher les données de vote pour 1 ou plusieurs protocoles) est quasi-identique.
 * Par contre, le tri par moyenne des votes, sous-entend "pour un protocole donné".
 * Dès lors le choix d'un protocole doit avoir été fait afin de régler le JOIN et ainsi l'ORDER BY.
 * (cf requestFilterParams())
 *
 * Histoire: auparavant (pré-r142x) un AVG + GROUP BY étaient utilisés pour générer on-the-fly les valeurs
 * utilisées ensuite pour l'ORDER BY. La situation à base de del_image_stat
 * est déjà bien meilleure sans être pour autant optimale. cf commentaire de sqlAddConstraint()
 *
 *
 * Tags:
 * Le comportement habituel dans le masque *général*: les mots sont séparés par des espaces,
 * implod()ed par des AND (tous les mots doivent matcher).
 * Et le test effectué doit matcher sur:
 * %(les tags d'observations)% *OU* %(les tags d'images)% *OU* %(les tags publics)%
 *
 * Le comportement habituel dans le masque *tag*: les mots ne sont *pas* splittés (1 seule expression),
 * Et le test effectué doit matcher sur:
 * ^(expression)% *OU* %(expression)% [cf getConditionsImages()]
 *
 * Par défaut les tags sont comma-separated (OU logique).
 * Cependant pour conserver le comportement du masque général qui sous-entend un ET logique sur
 * des tags séparés par des espaces recherche
 *
 * TODO:
 * -affiner la gestion de passage de mots-clefs dans le masque général.
 * - subqueries dans le FROM pour les critère WHERE portant directement sur v_del_image
 * plutôt que dans WHERE (qui nécessite dès lors un FULL-JOIN)
 * (http://www.mysqlperformanceblog.com/2007/04/06/using-delayed-join-to-optimize-count-and-limit-queries/)
 * - éviter de dépendre d'une jointure systématique sur `cel_obs`, uniquement pour `(date_)transmission
 * (cf VIEW del_image)
 * - poursuivre la réorganisation des méthodes statiques parmis Observation, ListeObservations et ListeImages2
 * - *peut-être*: passer requestFilterParams() en méthode de classe
 *
 *
 * MySQL sux:
 * EXPLAIN SELECT  id_image FROM v_del_image vdi WHERE vdi.id_image IN (SELECT ce_image FROM del_image_tag WHERE actif = 1 LIMIT 1);
 *      MySQL doesn't yet support 'LIMIT & IN/ALL/ANY/SOME subquery
 * EXPLAIN SELECT * FROM del_image WHERE id_image IN (SELECT 3);
 *      PRIMARY
 * EXPLAIN SELECT * FROM del_image WHERE id_image IN (SELECT MIN(3));
 *      DEPENDENT SUBQUERY ... ... ... mwarf !
 * EXPLAIN SELECT  id_image FROM v_del_image vdi WHERE vdi.id_image IN (SELECT ce_image FROM del_image_tag WHERE actif = 1);
 *      5.5: MATERIALIZED               del_image_tag   ALL                             ce_image NULL NULL NULL 38276 Using where
 *      5.1: DEPENDENT SUBQUERY del_image_tag   index_subquery  ce_image ce_image 8 func 1 Using where
 * FORCE INDEX/IGNORE INDEX semble incapable de résoudre le problème de l'optimiseur MySQL
 *
 */

require_once(dirname(__FILE__) . '/../DelTk.php');
require_once(dirname(__FILE__) . '/../observations/Observation.php');
restore_error_handler();
restore_exception_handler();
error_reporting(E_ALL);

// del/services/0.1/images?navigation.depart=0&navigation.limite=12&tri=votes&ordre=desc
// del/services/0.1/images?navigation.depart=0&navigation.limite=12&tri=votes&ordre=desc&masque=plop
// del/services/0.1/images?navigation.depart=0&navigation.limite=12&tri=votes&ordre=desc&protocole=3
// del/services/0.1/images?navigation.depart=0&navigation.limite=12&tri=votes&ordre=desc&protocole=3&masque=plop

class ListeImages {

        // TODO: PHP-x.y, ces variables devrait être des "const"
        static $format_image_possible = array('O','CRX2S','CRS','CXS','CS','XS','S','M','L','XL','X2L','X3L');

        static $tri_possible = array('date_transmission', 'date_observation', 'votes', 'tags', 'points');

        // en plus de ceux dans DelTk
        static $parametres_autorises = array('protocole', 'masque.tag_cel', 'masque.tag_pictoflora', 'masque.milieu');

        static $default_params = array('navigation.depart' => 0, 'navigation.limite' => 10,
                'tri' => 'date_transmission', 'ordre' => 'desc',
                // spécifiques à PictoFlora:
                'format' => 'XL');

        static $default_proto = 3; // proto par défaut: capitalisation d'img (utilisé uniquement pour tri=(tags|votes|points))

        static $mappings = array(
                'observations' => array( // v_del_image
                        "id_observation" => 1,
                        "date_observation" => 1,
                        "date_transmission" => 1,
                        "famille" => "determination.famille",
                        "nom_sel" => "determination.ns",
                        "nom_sel_nn" => "determination.nn",
                        "nom_referentiel" => "determination.referentiel",
                        "nt" => "determination.nt",
                        "ce_zone_geo" => "id_zone_geo",
                        "zone_geo" => 1,
                        "lieudit" => 1,
                        "station" => 1,
                        "milieu" => 1,
                        "mots_cles_texte" => "mots_cles_texte",
                        "commentaire" => 1,
                        "ce_utilisateur" => "auteur.id",
                        "nom_utilisateur" => "auteur.nom",
                        "prenom_utilisateur" => "auteur.prenom",
                        "courriel_utilisateur" => "auteur.courriel",),
                'images' => array( // v_del_image
                        'id_image' => 1,
                        // l'alias suivant est particulier: in-fine il doit s'appeler mots_cles_texte
                        // mais nous afin d'éviter un conflit d'alias nous le renommons plus tard (reformateImagesDoubleIndex)
                        'i_mots_cles_texte' => 1)
        );

        public function __construct(Conteneur $conteneur = null) {
                $this->conteneur = $conteneur == null ? new Conteneur() : $conteneur;
                $this->conteneur->chargerConfiguration('config_images.ini');
                $this->bdd = $conteneur->getGestionBdd()->getBdd();
        }

        public function consulter($ressources, $parametres) {
                /* Certes nous sélectionnons ici (nom|prenom|courriel)_utilisateur de cel_obs, mais il ne nous intéressent pas
                Par contre, ci-dessous nous prenons i_(nom|prenom|courriel)_utilisateur.
                Notons cependant qu'aucun moyen ne devrait permettre que i_*_utilisateur != *_utilisateur
                Le propriétaire d'une obs et de l'image associée est *toujours* le même. */
                array_walk(self::$mappings['observations'], create_function('&$val, $k', 'if($val==1) $val = $k;'));
                array_walk(self::$mappings['images'], create_function('&$val, $k', 'if($val==1) $val = $k;'));
                // pour les votes, les mappings de "Observation" nous suffisent
                array_walk(Observation::$mappings['votes'], create_function('&$val, $k', 'if($val==1) $val = $k;'));

                // la nécessité du 'groupby' dépend des 'join's utilisés (LEFT ou INNER) ainsi que de la cardinalité
                // de `ce_image` dans ces tables jointes.
                // Contrairement à IdentiPlantes, nous n'avons de HAVING pour PictoFlora, mais par contre un ORDER BY
                $req = array('select' => array(), 'join' => array(), 'where' => array(), 'groupby' => array(), 'orderby' => array());

                $db = $this->bdd;

                // filtrage de l'INPUT général, on réutilise 90% de identiplante en terme de paramètres autorisés
                // ($parametres_autorises) sauf... masque.type qui fait des modif' de WHERE sur les mots-clefs.
                // Évitons ce genre de chose pour PictoFlora et les risques de conflits avec masque.tag
                // même si ceux-ci sont improbables (pas d'<input> pour cela).
                $params_ip = DelTk::requestFilterParams($parametres,
                        array_diff(DelTk::$parametres_autorises, array('masque.type')),
                        $this->conteneur);

                // notre propre filtrage sur l'INPUT
                $params_pf = self::requestFilterParams($parametres,
                        array_merge(DelTk::$parametres_autorises, self::$parametres_autorises));

                /* filtrage des tags + sémantique des valeurs multiples:
                Lorsqu'on utilise masque.tag* pour chercher des tags, ils sont
                postulés comme séparés par des virgule, et l'un au moins des tags doit matcher. */
                $params_pf['masque.tag_cel'] = DelTk::buildTagsAST(@$parametres['masque.tag_cel'], 'OR', ',');

                if(!isset($parametres['masque.tag_pictoflora']) && isset($parametres['masque.tag'])) {
                        $parametres['masque.tag_pictoflora'] = $parametres['masque.tag'];
                }
                $params_pf['masque.tag_pictoflora'] = DelTk::buildTagsAST(@$parametres['masque.tag_pictoflora'], 'OR', ',');

                $params = array_merge(
                        DelTk::$default_params, // paramètre par défaut Identiplante
                        self::$default_params, // paramètres par défaut PictoFlora
                        $params_ip, // les paramètres passés, traités par Identiplante
                        $params_pf); // les paramètres passés, traités par PictoFlora

                if (isset($parametres['format'])) {
                        $params['format'] = $parametres['format'];
                }

                // création des contraintes (génériques de DelTk)
                DelTk::sqlAddConstraint($params, $db, $req);
                // création des contraintes spécifiques (sur les tags essentiellement)
                self::sqlAddConstraint($params, $db, $req, $this->conteneur);
                // création des contraintes spécifiques impliquées par le masque général
                self::sqlAddMasqueConstraint($params, $db, $req, $this->conteneur);
                // l'ORDER BY s'avére complexe
                self::sqlOrderBy($params, $db, $req);

                // 1) grunt-work: *la* requête de récupération des id valides (+ SQL_CALC_FOUND_ROWS)
                // $idobs_tab = ListeObservations::getIdObs($params, $req, $db);
                $idobs_tab = self::getIdImages($params, $req, $db);

                // Ce n'est pas la peine de continuer s'il n'y a pas eu de résultats dans la table del_obs_images
                if (!$idobs_tab) {
                        $resultat = new ResultatService();
                        $resultat->corps = array('entete' => DelTk::makeJSONHeader(0, $params, Config::get('url_service')),
                                'resultats' => array());
                        return $resultat;
                }

                // idobs est une liste (toujours ordonnée) des id d'observations recherchées
                $idobs = array_values(array_map(create_function('$a', 'return $a["id_image"];'), $idobs_tab));
                $total = $db->recuperer('SELECT FOUND_ROWS() AS c'); $total = intval($total['c']);

                $liaisons = self::chargerImages($db, $idobs);

                list($images, $images_keyed_by_id_image) = self::reformateImagesDoubleIndex(
                        $liaisons,
                        $this->conteneur->getParametre('images.url_images'),
                        $params['format']);

                // on charge les votes pour ces images et pour *tous* les protocoles
                $votes = Observation::chargerVotesImage($db, $liaisons, NULL);

                // subtilité, nous passons ici le tableau d'images indexé par id_image qui est bien plus pratique pour
                // associer les vote à un tableau, puisque nous ne connaissons pas les id d'observation.
                // Mais magiquement (par référence), cela va remplir notre tableau indexé par couple d'id (id_image, id_observation)
                // cf reformateImagesDoubleIndex() à qui revient la tâche de créer ces deux versions simultanément lorsque
                // c'est encore possible.
                if ($votes) {
                        Observation::mapVotesToImages($votes, $images_keyed_by_id_image);
                }

                // les deux masques de tags sont transformés en AST dans le processus de construction de la requête.
                // Reprenous les paramètres originaux non-nettoyés (ils sont valables car le nettoyage est déterministe)
                $params_header = array_merge($params, array_filter(array('masque.tag_cel' => @$parametres['masque.tag_cel'],
                        'masque.tag_pictoflora' => @$parametres['masque.tag_pictoflora'])));
                $resultat = new ResultatService();
                $resultat->corps = array(
                        'entete' => DelTk::makeJSONHeader($total, $params_header, Config::get('url_service')),
                        'resultats' => $images);
                return $resultat;
        }


        /**
         * Supprime une image directement dans le CEL en faisant un appel à un web service du CEL.
         * Utilisé uniquement par les admins.
         *
         * @param array         $ressources tableau des informations contenues dans l'url après le nom du service
         * @param array         $parametres contenu du post
         * @return mixed        Chaine "OK" (en majuscule) en cas de succès, booléen "false" en cas d'échec
         */
        public function supprimer($ressources) {
                $controlAcces = $this->conteneur->getControleAcces();
                $controlAcces->controlerIpAutorisees();
                $controlAcces->etreUtilisateurAvecDroitAdmin();

                $urlServiceBase = $this->conteneur->getParametre('urlServiceCelImage');
                $idImage = $ressources[0];
                $url = $urlServiceBase.$idImage;

                $clientHttp = $this->conteneur->getRestClient();
                $retourCel = $clientHttp->supprimer($url);
                $retour = preg_match('/^OK$/i', $retourCel) ? 'OK' : false;
                return $retour;
        }

        /**
         * TODO: partie spécifique liées à la complexité de PictoFlora:
         * génération de la clause ORDER BY (génère la valeur de la clef orderby' de $req)
         * nécessaire ? tableau sprintf(key (tri) => value (ordre), key => value ...).
         * Cependant il est impensable de joindre sur un AVG() des valeurs des votes pour
         * *chaque* couple (id_image, protocole) de la base afin de trouver les images
         * les "mieux notées", ou bien les images ayant le "plus de tags" (COUNT())
         */
        static function sqlOrderBy($p, $db, &$req) {
                // parmi self::$tri_possible
                if ($p['tri'] == 'votes') { // LEFT JOIN sur "dis" ci-dessous
                        $req['orderby'] = 'dis.moyenne ' . $p['ordre'] . ', dis.nb_votes ' . $p['ordre'];
                        return;
                }

                if ($p['tri'] == 'points') { // LEFT JOIN sur "dis" ci-dessous
                        $req['orderby'] = 'dis.nb_points ' . $p['ordre'] . ', dis.moyenne ' . $p['ordre'] . ', dis.nb_votes ' . $p['ordre'];
                        return;
                }

                if ($p['tri'] == 'tags') { // LEFT JOIN sur "dis" ci-dessous
                        $req['orderby'] = 'dis.nb_tags ' . $p['ordre'];
                        return;
                }

                if ($p['tri'] == 'date_observation') {
                        $req['orderby'] = 'date_observation ' . $p['ordre'] . ', id_observation ' . $p['ordre'];
                        return;
                }

                // tri == 'date_transmission'
                // avant cel:r1860, date_transmission pouvait être NULL
                // or nous voulons de la cohérence (notamment pour phpunit)
                $req['orderby'] = 'date_transmission ' . $p['ordre'] . ', id_observation ' . $p['ordre'];
        }

        /*
         * in $p: un tableau de paramètres, dont:
         * - 'masque.tag_cel': *tableau* de mots-clefs à chercher parmi cel_image.mots_clefs_texte
         * - 'masque.tag_pictoflora': *tableau* de mots-clefs à chercher parmi del_image_tag.tag_normalise
         * - 'tag_explode_semantic': défini si les éléments sont tous recherchés ou NON
         *
         * in/ou: $req: un tableau de structure de requête MySQL
         *
         * Attention, le fait que nous cherchions masque.tag_cel OU/ET masque.tag_cel
         * ne dépend pas de nous, mais du niveau supérieur de construction de la requête:
         * Soit directement $this->consulter() si des masque.tag* sont passés
         * (split sur ",", "AND" entre chaque condition, "OR" pour chaque valeur de tag)
         * Soit via sqlAddMasqueConstraint():
         * (pas de split, "OR" entre chaque condition) [ comportement historique ]
         * équivalent à:
         * (split sur " ", "OR" entre chaque condition, "AND" pour chaque valeur de tag)
         *
         */
        static function sqlAddConstraint($p, $db, &$req, Conteneur $c = NULL) {
                // TODO implement dans DelTk ?
                if (!empty($p['masque.milieu'])) {
                        $req['where'][] = 'vdi.milieu LIKE '.$db->proteger('%' . $p['masque.milieu'].'%');
                }

                /* Pour le tri par AVG() des votes nous avons toujours un protocole donné,
                        celui-ci indique sur quels votes porte l'AVG.
                        (c'est un *vote* qui porte sur un protocole et non l'image elle-même) */
                /* TODO: perf problème:
                        1) SQL_CALC_FOUND_ROWS: fixable en:
                                - dissociant le comptage de la récup d'id + javascript async
                                - ou ne rafraîchir le total *que* pour les requête impliquant un changement de pagination
                                (paramètre booléen "with-total" par exemple)
                        2) jointure forcées: en utilisant `del_imagè`, nous forçons les 2 premiers
                                JOIN sur cel_obs_images et cel_obs pour filtrer sur "transmission".
                                Dénormaliser cette valeur et l'intégrer à `cel_images` ferait économiser cette couteuse
                                jointure, ... lorsqu'aucun masque portant sur `cel_obs` n'est utilisé
                        3) non-problème: l'ordre des joins est forcé par l'usage de la vue:
                                (cel_images/cel_obs_images/cel_obs/del_image_stat)
                                Cependant c'est à l'optimiseur de définir son ordre préféré. */
                if ($p['tri'] == 'votes' || $p['tri'] == 'points') {
                        // $p['protocole'] *est* défini (cf requestFilterParams())
                        // petite optimisation: INNER JOIN si ordre DESC car les 0 à la fin
                        if($p['ordre'] == 'desc') {
                                // pas de group by nécessaire pour cette jointure
                                // PRIMARY KEY (`ce_image`, `ce_protocole`)
                                $req['join']['dis'] = sprintf('INNER JOIN del_image_stat dis'.
                                         ' ON vdi.id_image = dis.ce_image'.
                                         ' AND dis.ce_protocole = %d',
                                         $p['protocole']);
                        } else {
                                $req['join']['dis'] = sprintf('LEFT JOIN del_image_stat dis'.
                                         ' ON vdi.id_image = dis.ce_image'.
                                         ' AND dis.ce_protocole = %d',
                                         $p['protocole']);
                                // nécessaire (dup ce_image dans del_image_stat)
                                $req['groupby'][] = 'vdi.id_observation';
                        }
                }

                if ($p['tri'] == 'tags') {
                        $req['join'][] = sprintf('%s JOIN del_image_stat dis ON vdi.id_image = dis.ce_image',
                                ($p['ordre'] == 'desc') ? 'INNER' : 'LEFT');
                        // nécessaire (dup ce_image dans del_image_stat)
                        $req['groupby'][] = 'vdi.id_observation';
                }

                // car il ne sont pas traités par la générique requestFilterParams() les clefs "masque.tag_*"
                // sont toujours présentes; bien que parfois NULL.
                if ($p['masque.tag_cel']) {
                        if (isset($p['masque.tag_cel']['AND'])) {
                                // TODO: utiliser les tables de mots clefs normaliées dans tb_cel ?
                                // et auquel cas laisser au client le choix du couteux "%" ?
                                $tags = $p['masque.tag_cel']['AND'];
                                array_walk($tags, create_function('&$val, $k, $db',
                                        '$val = sprintf("CONCAT(IFNULL(vdi.mots_cles_texte,\'\'),IFNULL(vdi.i_mots_cles_texte,\'\')) LIKE %s",
                                        $db->proteger("%".$val."%"));'),
                                        $db);
                                $req['where'][] = '(' . implode(' AND ', $tags) . ')';
                        } else {
                                $req['where'][] = sprintf("CONCAT(IFNULL(vdi.mots_cles_texte,''),IFNULL(vdi.i_mots_cles_texte,'')) REGEXP %s",
                                        $db->proteger(implode('|', $p['masque.tag_cel']['OR'])));
                        }
                }

                if ($p['masque.tag_pictoflora']) {
                        // inutilisable pour l'instant
                        // self::sqlAddPictoFloraTagConstraint1($p, $db, $req);

                        // intéressante, mais problème d'optimiseur MySQL 5.5 (dependant subquery)
                        // self::sqlAddPictoFloraTagConstraint2($p, $db, $req);

                        // approche fiable mais sous-optimale
                        self::sqlAddPictoFloraTagConstraint3($p, $db, $req);
                }
        }

        /* approche intéressante si les deux problèmes suivants peuvent être résolu:
                - LEFT JOIN => dup => *gestion de multiples GROUP BY* (car in-fine un LIMIT est utilisé)
                - dans le cas d'un ET logique, comment chercher les observations correspondantes ? */
        static function sqlAddPictoFloraTagConstraint1($p, $db, &$req) {
                // XXX: utiliser tag plutôt que tag_normalise ?
                $req['join'][] = 'LEFT JOIN del_image_tag dit ON dit.ce_image = vdi.id_image';
                $req['where'][] = 'dit.actif = 1';
                $req['groupby'][] = 'vdi.id_image'; // TODO: nécessaire (car dup') mais risque de conflict en cas de tri (multiple GROUP BY)
                // XXX: en cas de ET, possibilité du GROUP_CONCAT(), mais probablement sans grand intérêt, d'où une boucle
                if (isset($p['masque.tag_pictoflora']['AND'])) {
                        // TODO/XXX : comment matcher les observations ayant tous les mots-clef passés ?
                        // ... le LEFT-JOIN n'y semble pas adapté
                } else {
                        $protected_tags = array();
                        foreach ($p['masque.tag_pictoflora']['OR'] as $tag) {
                                $protected_tags[] = $db->proteger(strtolower($tag));
                        }
                        $req['where'][] = sprintf('tag_normalise IN (%s)', implode(',', $protected_tags));
                }
        }

        // inutilisé pour l'instant pour cause de soucis d'optimiseur MySQL (cf commentaire en intro)
        static function sqlAddPictoFloraTagConstraint2($p, $db, &$req) {
                // Note à propos des 4 "@ instruction" ci-dessous (notamment sur recupererTous())
                // REGEXP permet un puissant mécanisme de sélection des obs/image à qui sait
                // l'utiliser, mais peut sortir une erreur en cas de REGEXP invalide
                // ex: REGEX "^(".
                // Pour l'heure nous ignorons ce type d'erreur car aucun de nos champ de recherche
                // ne peuvent (ou ne devrait) comporter des meta-caractères
                // ([])?*+\\
                if (isset($p['masque.tag_pictoflora']['AND'])) {
                        // optimsation: en cas de "AND" on sort() l'input et le GROUP_CONCAT()
                        // donc nous utilisons des ".*" plutôt que de multiples conditions et "|"
                        sort($p['masque.tag_pictoflora']['AND']);
                        $req['where'][] = sprintf("vdi.id_image IN (SELECT ce_image FROM del_image_tag WHERE actif = 1".
                                " GROUP BY ce_image".
                                " HAVING GROUP_CONCAT(tag_normalise ORDER BY tag_normalise) REGEXP %s)",
                                $db->proteger(implode('.*', $p['masque.tag_pictoflora']['AND'])));
                } else {
                        $req['where'][] = sprintf("vdi.id_image IN (SELECT ce_image FROM del_image_tag WHERE actif = 1".
                                " GROUP BY ce_image".
                                " HAVING GROUP_CONCAT(tag_normalise) REGEXP %s)",
                                $db->proteger(implode('|', $p['masque.tag_pictoflora']['OR'])));
                }
        }

        // si l'on est bassiné par les "DEPENDENT SUBQUERY", nous la faisons donc indépendemment via cette fonction
        static function sqlAddPictoFloraTagConstraint3($p, $db, &$req) {
                if (isset($p['masque.tag_pictoflora']['AND'])) {
                        // optimsation: en cas de "AND" on sort() l'input et le GROUP_CONCAT()
                        // donc nous utilisons des ".*" plutôt que de multiples conditions et "|"
                        sort($p['masque.tag_pictoflora']['AND']);

                        // plutôt que db->connexion->query->fetchColumn(), une API pourrie nous oblige à ...
                        $ids = @$db->recupererTous(sprintf(
                        "SELECT ce_image FROM del_image_tag WHERE actif = 1".
                        " GROUP BY ce_image".
                        " HAVING GROUP_CONCAT(tag_normalise ORDER BY tag_normalise) REGEXP %s",
                        $db->proteger(implode('.*', $p['masque.tag_pictoflora']['AND']))));

                        // puis:
                        $ids = @array_map(create_function('$e', 'return $e["ce_image"];'), $ids);
                        $ids = !empty($ids) ? implode(',', $ids) : 'SELECT ce_image FROM del_image_tag  WHERE false';
                        $req['where'][] = sprintf("vdi.id_image IN (%s)", $ids);
                } else {
                        $ids = @$db->recupererTous(sprintf(
                                "SELECT ce_image FROM del_image_tag WHERE actif = 1".
                                " GROUP BY ce_image".
                                " HAVING GROUP_CONCAT(tag_normalise) REGEXP %s",
                                $db->proteger(implode('|', $p['masque.tag_pictoflora']['OR']))));

                        $ids = @array_map(create_function('$e', 'return $e["ce_image"];'), $ids);
                        $ids = !empty($ids) ? implode(',', $ids) : 'SELECT ce_image FROM del_image_tag  WHERE false';
                        $req['where'][] = sprintf("vdi.id_image IN (%s)", $ids);
                }
        }

        static function getIdImages($p, $req, $db) {
                $req = sprintf(
                        'SELECT SQL_CALC_FOUND_ROWS id_image' .
                        //', dis.moyenne, dis.nb_points, dis.nb_votes' . // debug
                        ' FROM v_del_image vdi'.
                        ' %s' . // LEFT JOIN if any
                        ' WHERE %s'. // where-clause ou TRUE
                        ' %s'. // group-by
                        ' ORDER BY %s'.
                        ' LIMIT %d, %d -- %s',

                        $req['join'] ? implode(' ', array_unique($req['join'])) : '',
                        $req['where'] ? implode(' AND ', $req['where']) : 'TRUE',

                        $req['groupby'] ? ('GROUP BY ' . implode(', ', array_unique($req['groupby']))) : '',

                        $req['orderby'],

                        $p['navigation.depart'], $p['navigation.limite'], __FILE__ . ':' . __LINE__);
                return $db->recupererTous($req);
        }

        static function chargerImages($db, $idImg) {
                $obs_fields = DelTk::sqlFieldsToAlias(self::$mappings['observations'], NULL);
                $image_fields = DelTk::sqlFieldsToAlias(self::$mappings['images'], NULL);

                return $db->recupererTous(sprintf('SELECT '.
                        ' CONCAT(id_image, "-", id_observation) AS jsonindex,'.
                        ' %1$s, %2$s FROM v_del_image '.
                        ' WHERE %3$s'.
                        ' ORDER BY %4$s'. // important car MySQL ne conserve par l'ordre du IN()
                        ' -- %5$s',
                        $obs_fields, $image_fields,
                        sprintf('id_image IN (%s)', implode(',', $idImg)),
                        sprintf('FIELD(id_image, %s)', implode(',', $idImg)),
                        __FILE__ . ':' . __LINE__));
        }

        /* "masque" ne fait jamais que faire une requête sur la plupart des champs, (presque) tous traités
                de manière identique à la seule différence que:
                1) ils sont combinés par des "OU" logiques plutôt que des "ET".
                2) les tags sont traités différemment pour conserver la compatibilité avec l'utilisation historique:
                Tous les mots-clefs doivent matcher et sont séparés par des espaces
                (dit autrement, str_replace(" ", ".*", $mots-clefs-requête) =~ $mots-clefs-mysql)
                Pour plus d'information: (ListeObservations|DelTk)::sqlAddMasqueConstraint() */
        static function sqlAddMasqueConstraint($p, $db, &$req, Conteneur $c = NULL) {
                if (!empty($p['masque'])) {
                        $or_params = array('masque.auteur' => $p['masque'],
                                'masque.departement' => $p['masque'],
                                'masque.commune' => $p['masque'], // TODO/XXX ?
                                'masque.id_zone_geo' => $p['masque'],

                                /* tous-deux remplacent masque.tag
                                mais sont traité séparément des requestFilterParams() */
                                // 'masque.tag_cel' => $p['masque'],
                                // 'masque.tag_pictoflora' => $p['masque'],

                                'masque.ns' => $p['masque'],
                                'masque.famille' => $p['masque'],
                                'masque.date' => $p['masque'],
                                'masque.genre' => $p['masque'],
                                'masque.milieu' => $p['masque'],
                                'masque.tag_cel' => $p['masque'],
                                'masque.tag_pictoflora' => $p['masque'],

                                // tri est aussi nécessaire car affecte les contraintes de JOIN
                                'tri' => $p['tri'],
                                'ordre' => $p['ordre']);
                        if (array_key_exists('protocole', $p)) {
                                $or_params['protocole'] = $p['protocole'];
                        }

                        /* Cependant les champs spécifiques ont priorité sur le masque général.
                                Pour cette raison nous supprimons la génération de SQL du masque général sur les
                                champ spécifiques qui feront l'objet d'un traitement avec une valeur propre. */
                        if(isset($p['masque.auteur'])) unset($or_params['masque.auteur']);
                        if(isset($p['masque.departement'])) unset($or_params['masque.departement']);
                        if(isset($p['masque.commune'])) unset($or_params['masque.commune']);
                        if(isset($p['masque.id_zone_geo'])) unset($or_params['masque.id_zone_geo']);
                        if(isset($p['masque.ns'])) unset($or_params['masque.ns']);
                        if(isset($p['masque.famille'])) unset($or_params['masque.famille']);
                        if(isset($p['masque.date'])) unset($or_params['masque.date']);
                        if(isset($p['masque.genre'])) unset($or_params['masque.genre']);
                        if(isset($p['masque.milieu'])) unset($or_params['masque.milieu']);
                        if(isset($p['masque.tag_cel'])) unset($or_params['masque.tag_cel']);
                        if(isset($p['masque.tag_pictoflora'])) unset($or_params['masque.tag_pictoflora']);

                        $or_masque = array_merge(
                                DelTk::requestFilterParams($or_params, NULL, $c /* pour masque.departement */),
                                self::requestFilterParams($or_params)
                        );

                        /* Lorsqu'on utilise le masque général pour chercher des tags, ils sont
                                postulés comme séparés par des espaces, et doivent être tous matchés. */
                        if (isset($or_params['masque.tag_cel'])) {
                                $or_masque['masque.tag_cel'] = DelTk::buildTagsAST($p['masque'], 'AND', ' ');
                        }
                        if (isset($or_params['masque.tag_pictoflora'])) {
                                $or_masque['masque.tag_pictoflora'] = DelTk::buildTagsAST($p['masque'], 'AND', ' ');
                        }

                        // pas de select, groupby & co ici: uniquement 'join' et 'where'
                        $or_req = array('join' => array(), 'where' => array());
                        DelTk::sqlAddConstraint($or_masque, $db, $or_req);

                        self::sqlAddConstraint($or_masque, $db, $or_req);

                        if ($or_req['where']) {
                                $req['where'][] = '(' . implode(' OR ', $or_req['where']) . ')';
                                // utile au cas ou des jointures seraient rajoutées
                                $req['join'] = array_unique(array_merge($req['join'], $or_req['join']));
                        }
                }
        }


        // cf Observation::reformateObservationSimpleIndex() et ListeObservations::reformateObservation()
        // (trop de variétés de formatage, à unifier côté client pour unifier côté backend ...)
        static function reformateImagesDoubleIndex($obs, $url_pattern = '', $image_format = 'XL') {
                // XXX: cf Observation.php::consulter(), nous pourriouns ici
                // conserver les valeurs vides (pour les phptests notamment, ou non)
                // $obs = array_map('array_filter', $obs);
                $obs_merged = $obs_keyed_by_id_image = array();
                foreach ($obs as $o) {
                        // ceci nous complique la tâche pour le reste du processing...
                        $id = $o['jsonindex'];
                        // ainsi nous utilisons deux tableaux: le final, indexé par couple d'id(image-obs)
                        // et celui indexé par simple id_image qui est fort utile pour mapVotesToImages()
                        // mais tout deux partage leur référence à "protocole"
                        $image = array(
                                'id_image' => $o['id_image'],
                                'binaire.href' => sprintf($url_pattern, $o['id_image'], $image_format),
                                'mots_cles_texte' => @$o['i_mots_cles_texte'], // @, peut avoir été filtré par array_map() ci-dessus
                        );

                        unset($o['id_image'], $o['i_mots_cles_texte'], $o['jsonindex']);
                        if (!isset($obs_merged[$id])) {
                                $obs_merged[$id] = $image;
                        }
                        $obs_merged[$id]['observation'] = $o;
                        $obs_merged[$id]['protocoles_votes'] = array();

                        $obs_keyed_by_id_image[$image['id_image']]['protocoles_votes'] =& $obs_merged[$id]['protocoles_votes'];
                }

                return array($obs_merged,$obs_keyed_by_id_image);
        }

        // complete & override DelTk::requestFilterParams() (même usage)
        static function requestFilterParams(Array $params, $parametres_autorises = NULL) {
                if ($parametres_autorises) { // filtrage de toute clef inconnue
                        $params = array_intersect_key($params, array_flip($parametres_autorises));
                }

                $p = array();
                $p['tri'] = DelTk::unsetIfInvalid($params, 'tri', self::$tri_possible);
                $p['format'] = DelTk::unsetIfInvalid($params, 'format', self::$format_image_possible);

                // "milieu" inutile pour IdentiPlantes ?
                if (isset($params['masque.milieu'])) {
                        $p['masque.milieu'] = trim($params['masque.milieu']);
                }

                // compatibilité
                if (isset($params['masque.tag'])) {
                        $params['masque.tag_cel'] = $params['masque.tag_pictoflora'] = $params['masque.tag'];
                }

                if ($p['tri'] == 'votes' || $p['tri'] == 'tags' || $p['tri'] == 'points') {
                        // ces critère de tri des image à privilégier ne s'applique qu'à un protocole donné
                        if(!isset($params['protocole']) || !is_numeric($params['protocole'])) {
                                $p['protocole'] = self::$default_proto;
                        } else {
                                $p['protocole'] = intval($params['protocole']);
                        }
                }

                return array_filter($p, create_function('$a','return !in_array($a, array("",false,null),true);'));
        }

}
?>