Subversion Repositories eFlore/Applications.cel

Rev

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

<?php

/*
        PHP 7

        Script used to synchronize PN occ with Flora Data

        Écrit en franglish pour une meilleure compréhension (lol)
*/

class PullPlantnet extends Script {

        protected $bdd;

        protected $debug = false;

        protected $allowedPartners = ['tb', 'pn'];

        // Nom du fichier contenant les correspondances d'ID TB/PN
        protected $correspondingIdsFilename = '';

        // Cache d'informations utilisateurs provenant de l'annuaire TB
        protected $userInfos = [];

        // Cache des obs id PN traitées pendant cette run
        protected $processedObsId = [];

        // Paramètre de suivi et de pagination de l'API des obs PN
        protected $currentPage = '';
        protected $currentProject = '';
        protected $hasMore = false;

        // Traduction des organes de PN
        protected $tagsImageTraduits = [
                'flower' => 'fleur',
                'fruit' => 'fruit',
                'leaf' => 'feuille',
                'bark' => 'écorce',
                'branch' => 'branche',
                'habit' => 'port',
        ];

        // Différents types de lifecle possible pour filtrer les obs
        // Modified renvoi les obs en fonction de leur date de modif
        // Created, en fonction de la date de création
        // Deleted, well, you already got it
        protected $lifecycles = [
                'created',
                'modified',
                'deleted', // deleted peut être très lent (pas d'index coté PN pour le moment)
        ];

        /**
         * Paramêtres par défaut disponibles pour la ligne de commande
         * le tableau se construit de la forme suivante :
         * - clé =  nom du paramêtre '-foo'
         * - value = contient un nouveau tableau composé de cette façon :
         *  - booléen: true si le paramêtre est obligatoire
         *  - booléen ou var : true si le paramêtre nécessite un valeur à sa suite ou la valeur par défaut
         *  - string: description du contenu du paramêtre
         * Les paramêtres optionels devraient être déclaré à la fin du tableau.
         * Le dernier parametre du tableau peut avoir la valeur '...',
         * il contiendra alors l'ensemble des paramêtres suivant trouvés sur la ligne de commande.
         * @var array
         */
        protected $parametres_autorises = array(
                '-startDate' => array(false, true, 'Date de début (parsée par strtotime). Ex: "YYYY:MM:DD HH:II:SS"'),
                '-lifecycle' => array(false, true, 'Par défaut : modified ; Au choix parmi : created/modified/deleted (deleted est lent)'),
                '-pnObsId' => array(false, true, "ID de l'obs chez PlantNet"),
                '-project' => array(false, true, "projectId de l'obs chez PlantNet"),
                '-tbObsId' => array(false, true, "ID de l'obs chez Tela"),
                '-debug' => array(false, false, "Mode débug (ajoute du log) Au choix parmi : debug/log (log est plus verbeux)"),
        );

        public function __construct($script_nom, $parametres_cli) {
                parent::__construct($script_nom, $parametres_cli);
                $this->bdd = new Bdd();

                $this->correspondingIdsFilename = Config::get('correspondingIdsFilename');

                $this->debug = $this->getParametre('debug');
        }

        public function executer() {
                $cmd = $this->getParametre('a');

                switch ($cmd) {
                        case 'updateLatest':
                                $this->updateLatest($this->getParametre('startDate'), $this->getParametre('lifecycle'));
                                break;
                        case 'updateOnePN':
                                $this->updateOnePN((int)$this->getParametre('pnObsId'), $this->getParametre('project'));
                                break;
                        case 'updateOneTB':
                                $this->updateOneTB((int)$this->getParametre('tbObsId'));
                                break;
                        case 'updateCorrespondingIdsTable':
                                $this->updateCorrespondingIdsTable();
                                break;
                        default:
                                $msg = "Erreur : la commande '$cmd' n'existe pas!\n"
                                                . ', utilisez plutôt :' . "\n"
                                                . 'php cli.php PullPlantnet -a updateLatest -startDate "YYYY:MM:DD HH:II:SS" [[-lifecycle [modified]/created/deleted]]' . "\n"
                                                . 'php cli.php PullPlantnet -a updateOnePN -pnObsId "1234567890" -project "xxxxxx"' . "\n"
                                                . 'php cli.php PullPlantnet -a updateOneTB -tbObsId "1234567890"' . "\n"
                                                . 'php cli.php PullPlantnet -a updateCorrespondingIdsTable' . "\n"
                                                ;
                                throw new Exception($msg);
                }
        }

        /**
         * Considering all modified pn obs since startDate
         * One pn project after an other
         * Inserting obs in tb db (if new and matching user email)
         */
        private function updateLatest(string $startDate = '1917:03:15 00:00:00', string $lifecycle = 'modified') {
                $startDate = strtotime($startDate) * 1000; // we need a microtimestamp

                $pnProjects = $this->getProjects(); // refresh projects list
                foreach ($pnProjects as $project) {
                        do {
                                $observations_PN = $this->getProjectLatest($project, $startDate, $lifecycle);
                                $this->updateObs($observations_PN);
                        } while ($this->hasMore);
                }
        }

        private function getProjectLatest(array $project, int $startDate, string $lifecycle): array {
                $this->currentProject = $project;

                if (!$this->currentPage) {
                        echo 'Projet ' . $project['name'] . "\n";

                        // hop, on appelle le service de PN
                        $url_service = str_replace(
                                ['{project}', '{token}', '{startDate}', '{lifecycle}'],
                                [$project['id'], Config::get('tokenPlantnet'), $startDate, $lifecycle],
                                Config::get('urlPlantnetBase').Config::get('urlPlantnetLatestChanges')
                        );

                        $this->debug("URL service derniers changements : $url_service");

                        $this->currentPage = $url_service;
                }

                $ch = curl_init($this->currentPage);
                curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
                $reponse = curl_exec($ch);
                $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
                curl_close($ch);

                // cool, maintenant on va trier ce qu'on a reçu
                if (!$reponse) {
                        throw new Exception("\nPN ne répond rien à l'adresse {$this->currentPage}\n");
                } elseif (200 != $code) {
                        // l'api répond avec une 404 quand y'a une date dans le futur ou simplement pas de nouvelle obs...
                        if (404 == $code && strpos($reponse, 'No more results')) {
                                $this->log("Pas d'autres résultats");
                                $this->hasMore = false;
                                $this->currentPage = '';
                                return [];
                        }
                        throw new Exception("\nPN répond avec une $code à l'adresse {$this->currentPage} : $reponse\n");
                }
                $responseJson = json_decode($reponse, true);
                $observations_PN = $responseJson['data'] ?? [];

                $this->hasMore = $responseJson['hasMore'];
                if ($this->hasMore) {
                        $this->currentPage = Config::get('urlPlantnetBase').$responseJson['next'];
                        $this->debug("URL service derniers changements, page suivante : {$this->currentPage}");
                } else {
                        $this->currentPage = '';
                }

                return $observations_PN;
        }

        private function getProjects(): array {
                // get PN projects list
                $url = str_replace('{token}', Config::get('tokenPlantnet'), Config::get('urlPlantnetBase').Config::get('urlPlantnetProjects'));

                $this->debug("URL service liste projets : $url");

                $ch = curl_init($url);
                curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
                $reponse = curl_exec($ch);
                $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
                curl_close($ch);

                if (!$reponse || 200 != $code) {
                        throw new Exception("\nPN répond avec une $code à l'adresse $url : $reponse\n");
                }
                $pnProjects = json_decode($reponse, true);

                echo 'Liste de projets récupérée : ' . count($pnProjects) . " projets dans la liste\n";

                $this->debug("Liste projets : " . json_encode($pnProjects));

                return $pnProjects;
        }

        /**
         * For a given bunch of plantnet obs, post new one threw saisie widget service
         */
        private function updateObs(array $observations_PN) {

                $url_cel_widget_saisie = Config::get('urlCelWidgetSaisie');

                foreach ($observations_PN as $obs) {
                        // est-ce qu'on a déjà traité cette obs ? (oui une même obs peut être dans plusieurs projects)
                        if (in_array($obs['id'], $this->processedObsId)) {
                                $this->log("Obs {$obs['id']} déjà traitée pendant cette run");
                                continue;
                        } else {
                                $this->processedObsId[] = $obs['id'];
                                $this->log("Obs {$obs['id']} ajoutée à la liste de cette run");
                        }

                        // on ne teste pas la suppression de l'obs ici, faut le faire après avoir vérifié si on l'a déjà synchro
                        if (!isset($obs['currentName'])) {
                                $this->log("Obs {$obs['id']} sans nom de taxon, on zap");
                                continue; // pas de nom de taxon, obs inutile
                        }
                        if (!isset($obs['geo'])) {
                                $this->log("Obs {$obs['id']} sans geom, on zap");
                                continue; // pas de position, obs inutile
                        }
                        if (!isset($obs['dateObs'])) {
                                $this->log("Obs {$obs['id']} sans date, on zap");
                                continue; // pas de date, obs inutile
                        }

                        if (isset($obs['partner']['id']) && 'tela' === $obs['partner']['id']) {
                                // c'est une obs tela
                                // si c'est mis à jour récemment coté PN et qu'on l'a pas supprimée chez nous entre temps
                                        // on update les votes PN
                                        // on update l'identification proposée par PN

                                // @TODO
                                $this->log("Obs {$obs['id']} venant de Tela, mise à jour pas implémentée, on zap");
                                continue;
                        } elseif (!isset($obs['partner']['id'])) {
                                // ce n'est pas une obs d'un partenaire, c'est donc une obs PN
                                // on récupère l'id utilisateur tela via son mail
                                $email = $obs['author']['email'];
                                $infos_utilisateur = $this->findUserInfo($email);
                                if (!$infos_utilisateur) {
                                        $this->log("Obs {$obs['id']} email $email ne provient pas d'un telabotaniste");
                                        continue; // c'est pas un telabotaniste
                                }
                                $this->log("Obs {$obs['id']} email $email provient d'un telabotaniste");
                                $this->log(json_encode($obs));
                                // die(var_dump($obs));

                                // vérification que l'obs n'a pas déjà été ajoutée à la BdD de Tela
                                $sql = "SELECT date_maj, id_observation FROM cel_plantnet WHERE id_plantnet = '{$obs['id']}'"
                                                . ' -- ' . __FILE__ . ':' . __LINE__;
                                $res = $this->bdd->recupererTous($sql);
                                // die(var_dump($res));
                                if ($res) {
                                        // l'obs existe déjà, on vérifie si il faut màj puis on passe à la suite
                                        // la date de l'obs est un microtime, donc on coupe les millièmes
                                        // die(var_dump((int)($obs['dateUpdated']/1000), strtotime($res[0]['date_maj'])));
                                        if ((int)($obs['dateUpdated']/1000) > strtotime($res[0]['date_maj'])) {
                                                echo "Obs déjà en base, mise à jour : ID PN {$obs['id']} ; ID TB {$res[0]['id_observation']} ; utilisateur_tb $email\n";
                                                $this->updateFromPN($obs, $res[0]['id_observation']);
                                        } else {
                                                echo "Obs déjà en base, déjà à jour : ID PN {$obs['id']} ; ID TB {$res[0]['id_observation']} ; utilisateur_tb $email\n";
                                        }
                                        continue;
                                }

                                if (isset($obs['deleted']) && (true === $obs['deleted'])) {
                                        $this->log("Obs {$obs['id']} supprimée coté PlantNet");
                                        continue; // obs supprimée chez PN sans être passée par nos serveurs
                                }

                                $images = [];
                                $tags_images = [];
                                $images_size = 0;
                                foreach ($obs['images'] ?? [] as $i => $image) {
                                        if ($image['deleted']) {
                                                $this->log("Obs {$obs['id']} image {$image['id']} supprimée coté PlantNet");
                                                continue;
                                        }

                                        // téléchargement de l'image PN
                                        $img = false;
                                        $retry = 3;
                                        do {
                                                $img = file_get_contents($image['o']);
                                                $retry--;
                                                $this->log("Obs {$obs['id']} lecture image {$image['id']} tentatives restantes : $retry");
                                        } while (!$img && $retry);

                                        if (!$img) {
                                                echo "Abandon, image impossible à télécharger : {$image['o']}\n";
                                                continue;
                                        }

                                        // Écriture dans un fichier temporaire
                                        $tempfile = tempnam("/tmp", "PullPN_") . ".jpg";
                                        $handle = fopen($tempfile, "w");
                                        fwrite($handle, $img);
                                        fclose($handle);
                                        $images_size += filesize($tempfile);

                                        $this->log("Obs {$obs['id']} image {$image['id']} créée " . number_format(filesize($tempfile), 0, ',', ' ') . " octets : $tempfile");

                                        $params = [
                                                'name' => 'image' . $i,
                                                'fichier' => new CURLFile($tempfile, 'image/jpg')
                                        ];

                                        // envoi de l'image PN à l'api de création
                                        $ch = curl_init(Config::get('urlCelUploadImageTemp'));
                                        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
                                        curl_setopt($ch, CURLOPT_POST, true);
                                        curl_setopt($ch, CURLOPT_POSTFIELDS, $params);
                                        $reponse = curl_exec($ch);
                                        $this->log("Obs {$obs['id']} image {$image['id']} envoyée à l'api de création. Réponse : $reponse");
                                        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
                                        curl_close($ch);

                                        unlink($tempfile);

                                        $reponse = simplexml_load_string($reponse);
                                        /* Response format if success:
                                        <?xml version="1.0" encoding="UTF-8"?>
                                        <root>
                                                <miniature-url>https://api-test.tela-botanica.org/tmp/cel/imgtmp/chaflipe_min.jpg</miniature-url>
                                                <image-nom>chaflipe.jpg</image-nom>
                                                <message></message>
                                                <debogage></debogage>
                                        </root>
                                        */
                                        // var_dump($reponse);
                                        if ($reponse && '' == $reponse->message /* && '' != $reponse->{'image-nom'} */) {
                                                $images[] = (string)$reponse->{'image-nom'};

                                                $tag = $this->tagsImageTraduits[$image['organ']] ?? $image['organ'];
                                                if (trim($tag)) {
                                                        $tags_images[] = $tag;
                                                }
                                        }
                                }
                                // var_dump($images, $tags_images);
                                // die();

                                $geo = $this->getGeoInfo($obs['geo']);
                                $this->log("Obs {$obs['id']} décodage du geom : " . json_encode($obs['geo']));
                                $this->log("Obs {$obs['id']} geom décodé : " . json_encode($geo));

                                // on insère l'obs via le service CelWidgetSaisie
                                $infos_obs = [
                                        'obsId1[date]' => date('d/m/Y', intdiv($obs['dateObs'], 1000)),
                                        'obsId1[latitude]' => $geo['lat'] ?? null,
                                        'obsId1[longitude]' => $geo['lon'] ?? null,
                                        'obsId1[pays]' => $geo['countryCode'] ?? null,
                                        'obsId1[code_postal]' => $geo['postcode'] ?? null,
                                        'obsId1[commune_nom]' => $geo['city'] ?? null,
                                        // 'obsId1[commune_code_insee]' => '',
                                        // 'obsId1[lieudit]' => '',
                                        // 'obsId1[milieu]' => '',
                                        'obsId1[nom_sel]' => $obs['currentName'],
                                        // 'obsId1[nom_ret]' => '',
                                        // 'obsId1[famille]' => '',
                                        'obsId1[certitude]' => 'douteux',
                                        // 'obsId1[notes]' => '',
                                        // 'obsId1[num_nom_ret]' => '',
                                        // 'obsId1[num_nom_sel]' => '',
                                        // 'obsId1[num_taxon]' => '',
                                        'obsId1[referentiel]' => $this->findProbableTaxoRepos($this->currentProject['id']), // @TODO faire une fois et mettre en cache
                                        // 'obsId1[station]' => '',
                                        'obsId1[obs_id]' => $obs['id'],
                                        'projet' => 'PlantNet',
                                        'tag-img' => implode(', ', $tags_images ?? []),
                                        'tag-obs' => 'plantnet, plantnet-mobile, pn:referentiel-' . $this->currentProject['id'],
                                        'utilisateur[courriel]' => $email,
                                        'utilisateur[id_utilisateur]' => $infos_utilisateur['id'],
                                        'utilisateur[nom]' => $infos_utilisateur['nom'],
                                        'utilisateur[prenom]' => $infos_utilisateur['prenom'],
                                        'origin' => 'pullPlantnet',
                                ];

                                foreach ($images as $i => $image) {
                                        $infos_obs["obsId1[image_nom][$i]"] = $image;
                                }

                                $this->log("Obs {$obs['id']} prête à être insérée : " . json_encode($infos_obs));

                                // curl post $infos_obs
                                $ch = curl_init($url_cel_widget_saisie);
                                curl_setopt($ch, CURLOPT_POST, true);
                                curl_setopt($ch, CURLOPT_POSTFIELDS, $infos_obs);
                                curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
                                $reponse = curl_exec($ch);
                                $this->log("Obs {$obs['id']} réponse de la requête d'insertion : " . json_encode($reponse));
                                $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
                                curl_close($ch);

                                if (!$reponse || 200 != $code) {
                                        throw new Exception("Ça a pété à l'envoi de l'obs :\n $reponse\n avec l'obs : " . json_encode($infos_obs) . "\n");
                                }

                                $id_obs_tb = json_decode($reponse, true)['id'];
                                $id_obs_pn = $obs['id'];
                                // on insère les ids de correspondance obsPN obsTB dans la table cel_plantnet
                                $sql = 'INSERT INTO cel_plantnet (id_observation, id_plantnet, date_maj)'
                                                .' VALUES (%s, %s, NOW())'
                                                . ' -- ' . __FILE__ . ':' . __LINE__;
                                $sql = sprintf($sql, $this->bdd->proteger($id_obs_tb), $this->bdd->proteger($id_obs_pn));
                                $this->bdd->requeter($sql);

                                $date = date('d/m/Y H:i:s', intdiv($obs['dateObs'], 1000));
                                $count_img = count($images);
                                $images_size = number_format($images_size, 0, ',', ' ');
                                echo "Obs insérée en base : ID PN $id_obs_pn ; ID TB $id_obs_tb ; utilisateur_tb $email ; date_obs $date ; images $count_img ; taille_octets $images_size \n";
                        }
                }
        }

        /**
         * Return TB taxo repos names for given PN project
         *
         * @return string
         */
        private function findProbableTaxoRepos($pnProjectId) {
                $selectedRepos = [];
                // Référentiels tela botanica indexés par référentiels PN (version octobre 2019)
                // @TODO à revoir en fonction des nouvelles mises en prod de nouveaux référentiels
                $tbTaxoRepos = [
                        'afn' => [/*'apd', */'isfan'],
                        'aft' => ['apd'],
                        'antilles' => ['bdtxa', 'taxref'],
                        'canada' => ['vascan'],
                        'comores' => ['apd'],
                        'endemia' => ['florical', 'taxref'],
                        'taxref ' => ['bdtfx'],
                        'guyane' => ['aublet', 'taxref'],
                        'hawai' => [],
                        'lapaz' => ['aublet', 'taxref'],
                        'martinique' => ['bdtxa', 'taxref'],
                        'maurice' => ['apd'],
                        'medor' => ['lbf'],
                        'namerica' => [],
                        'polynesiefr' => ['taxref'],
                        'prosea' => ['asia'],
                        'prota' => ['isfan', 'apd'],
                        'provence' => ['bdtfx', 'taxref'],
                        'reunion' => ['bdtre', 'taxref'],
                        'salad' => ['bdtfx', 'taxref'],
                        'useful' => [],
                        'weurope' => ['bdtfx'],
                        'the-plant-list' => [],
                        'iscantree' => ['apd'],
                        'central-america' => [],
                        'invasion' => ['bdtfx'],
                        'weeds' => ['bdtfx'],
                        'malaysia' => [],
                ];

                if (array_key_exists($pnProjectId, $tbTaxoRepos)) {
                        array_map(function($repo) use ($selectedRepos) {
                                $selectedRepos[] = $repo;
                        }, $tbTaxoRepos[$pnProjectId]);
                }

                // @TODO chercher le nom dans plusieurs référentiels avant d'envoyer l'obs
                // prendre "enrichirDonneesTaxonomiques" du service de saisie et le coller ici
                // en attendant on envoi juste le premier référentiel trouvé
                return $selectedRepos[0] ?? '';
        }

        /*
         * Retrouve les infos d'un utilisateur tela à partir de son email
         * @param string $email User email
         * @return mixed                Tableau des infos de l'utilisateur
         *                                              False si l'email n'est pas trouvé dans l'annuaire
         */
        private function findUserInfo($email) {
                // cherche dans le cache
                if (array_key_exists($email, $this->userInfos)) {
                        $infosUtilisateur = $this->userInfos[$email];
                        // echo("Email in cache : $email\n");
                } else {
                        $infosUtilisateur = false;
                        $urlInfosUtilisateur = str_replace('{email}', $email, Config::get('urlAnnuaireIdParEmail'));

                        $ch = curl_init($urlInfosUtilisateur);
                        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
                        $reponse = curl_exec($ch);
                        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
                        curl_close($ch);

                        // on peut pas tester le code de réponse de cette api, si l'utilisateur n'est pas trouvé ça fait une 500 >_<
                        // une bonne réponse ressemble à ça :
                        // {"killian@tela-botanica.org":{"id":"30489","prenom":"Killian","nom":"Stefanini","pseudo":"Killian Stefanini","pseudoUtilise":true,"intitule":"Killian Stefanini","avatar":"\/\/www.gravatar.com\/avatar\/a9b9b8484076540924c03af816c77fc8?s=50&r=g&d=mm","groupes":{"19226":"adm","18943":"","23152":"","21684":"","21598":"adm","23184":"","23516":""},"permissions":["editor"],"nomWiki":"KillianStefanini"}}
                        $reponse = json_decode($reponse, true);
                        if (!isset($reponse)) {
                                throw new Exception("\nL'annuaire n'a pas répondu avec du json : code $code à l'adresse $urlInfosUtilisateur : $reponse\n");
                        }

                        if (!isset($reponse['error'])) {
                                $infosUtilisateur = array_shift($reponse);
                        }

                        // met en cache
                        $this->userInfos[$email] = $infosUtilisateur;
                        // echo("Email cached : $email\n");
                }

                return $infosUtilisateur;
        }

        private function getGeoInfo($obs) {
                $geo = [];
                if (!isset($obs['lat']) && !isset($obs['lon'])) {
                        return $geo;
                }

                // $data = [
                //      'hitsPerPage' => 1,
                //      'aroundLatLng' => "{$obs['lat']},{$obs['lon']}"
                // ];
                $headers = [
                        'X-Algolia-Application-Id' => Config::get('algoliaApplicationId'),
                        'X-Algolia-API-Key' => Config::get('algoliaAPIKey'),
                ];

                $lat = number_format($obs['lat'], 6, '.', '');
                $lon = number_format($obs['lon'], 6, '.', '');

                $ch = curl_init(Config::get('urlReverseGeocodingLatLng')."$lat,$lon");
                curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
                curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
                $reponse = curl_exec($ch);
                $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
                curl_close($ch);

                if ($reponse) {
                        $reponse = json_decode($reponse, true);
                        // die(var_dump($reponse));
                        $infos = $reponse['hits'][0];

                        $geo = [
                                'lat' => (string) $obs['lat'],
                                'lon' => (string) $obs['lon'],
                                'country' => $infos['country']['fr'] ?? $infos['country']['default'],
                                'city' => $infos['city']['default'][0] ?? null,
                                'postcode' => $infos['postcode'][0] ?? null,
                                'countryCode' => strtoupper($infos['country_code']),
                        ];
                }

                return $geo;
        }

        private function updateFromPN(array $pnObs, string $tbObsId) {
                if (!is_array($pnObs) || !is_string($tbObsId)) {
                        // die(var_dump($pnObs, $tbObsId));
                        throw new Exception("\nBad params types, give me an array and an integer plz\n");
                }
                // die(var_dump($pnObs));

                // log update date to cel_plantnet
                $sql = "UPDATE cel_plantnet SET date_maj = NOW() WHERE id_observation = $tbObsId"
                                . ' -- ' . __FILE__ . ':' . __LINE__;
                $this->bdd->requeter($sql);

                // check for deleted
                if (isset($pnObs['deleted']) && (true === $pnObs['deleted'])) {
                        // est-ce une obs issue de PN ?
                        //// faut regarder le champ input_source == PlantNet
                        $sql = "SELECT input_source FROM occurrence WHERE id = '$tbObsId'"
                                        . ' -- ' . __FILE__ . ':' . __LINE__;
                        $res = $this->bdd->recupererTous($sql);

                        if (isset($res[0]) && ('PlantNet' === $res[0]['input_source'])) {
                                // oui ? alors supprimer obs !
                                echo "Obs supprimée coté PN, suppression : ID PN {$obs['id']} ; ID TB {$res[0]['id_observation']}\n";

                                $sql = "UPDATE photos SET occurrence_id = NULL WHERE occurrence_id = '$tbObsId'"
                                                . ' -- ' . __FILE__ . ':' . __LINE__;
                                $this->bdd->requeter($sql);

                                $sql = "DELETE FROM occurrence WHERE id = '$tbObsId'"
                                                . ' -- ' . __FILE__ . ':' . __LINE__;
                                $this->bdd->requeter($sql);
                        }
                }

                // check for deleted images
                foreach ($pnObs['images'] as $image) {
                        if ($image['deleted']) {
                                // no idea
                                // y'a pas encore de lien tangible entre les images PN et les notres
                        }
                }

                // @TODO : finir la comparaison des proposition entre celles de Tela et celles PN
                // // check for votes (notes et taxon)
                // $names = [];
                // foreach ($pnObs['computed']['votes'] as $vote) {
                //      echo($vote['name']);
                //      echo($vote['score']['pn']);
                //      echo($vote['score']['total']);

                //      $names[$vote['score']['pn']] = $vote['name'];
                // }
                // ksort($names, SORT_NUMERIC); // votes sorted by score asc
                // $pn_sorted_votes = array_reverse($names, true); // desc

                // // get existing votes
                // $sql = "SELECT id_commentaire, nom_sel, nom_sel_nn, nom_referentiel, nom_referentiel, utilisateur_courriel, proposition_initiale, proposition_retenue"
                //              . "WHERE ce_observation = '$tbObsId' AND ce_commentaire_parent = 0"
                //              . ' -- ' . __FILE__ . ':' . __LINE__;
                // $existing_votes = $this->bdd->recupererTous($sql);

                // // @TODO : compare votes
                // // insert_new_votes($pn_sorted_votes, $existing_votes);
        }

        private function updateOnePN($pnObsId, $pnProjectId) {
                if (!is_int($pnObsId) || !is_string($pnProjectId)) {
                        die(var_dump($pnObsId, $pnProjectId));
                        throw new Exception("\nBad params types, give me an integer and a string plz\n");
                }
                // get PN project list
                $list = [];
                $pnProjects = $this->getProjects(); // refresh projects list
                foreach ($pnProjects as $project) {
                        $list[$project['id']] = $project['name'];
                }

                // if project not in list display list
                if (!array_key_exists($pnProjectId, $list)) {
                        echo "Available projects:\n";
                        foreach ($list as $projectId => $projectName) {
                                echo " - $projectId ($projectName)\n";
                        }
                        throw new Exception("Project $pnProjectId does not exist\n");
                } else {
                        $this->currentProject = [
                                'id' => $pnProjectId,
                                'name' => $list[$pnProjectId],
                        ];
                }

                // get PN obs
                $urlInfosObs = str_replace(
                        ['{token}', '{project}', '{id}'],
                        [Config::get('tokenPlantnet'), $pnProjectId, $pnObsId],
                        Config::get('urlPlantnetBase').Config::get('urlPlantnetObsById')
                );
                $ch = curl_init($urlInfosObs);
                curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
                $response = curl_exec($ch);
                $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
                curl_close($ch);

                if (!$response || 200 != $code) {
                        throw new Exception("\nPlantnet api ($urlInfosObs) replied with code $code : $response\n");
                }

                // change last modification date to force update
                $obs = json_decode($response, true);
                $date = new DateTime();
                $date->setTimestamp(intdiv($obs['dateUpdated'], 1000) - 1);
                $date = $this->bdd->proteger($date->format('Y-m-d H:i:s'));
                $sql = "UPDATE cel_plantnet SET date_maj = $date WHERE id_plantnet = '$pnObsId'"
                                . ' -- ' . __FILE__ . ':' . __LINE__;
                $this->bdd->requeter($sql);

                $this->updateObs([$obs]);
        }

        private function updateOneTB($id) {
                $corres = $this->findMatchingPartnerId($id, 'tb');
                echo "L'obs tela avec l'id $id a été trouvée chez plantnet avec l'id : " .$corres['pn'] . "\n";
                echo "La suite n'a pas été implémentée.\n";
        }

        /*
         * Trouve les ids d'une obs chez les partenaires
         * Il faut lancer updateMatchingPartnersIds pour obtenir une liste fraiche
         *
         * @param       string $id                      L'id de l'obs
         * @param       string $partner         Le partenaire correspondant à l'id de l'obs
         *
         * @return  mixed                               Le tableau de correspondances d'ids
         *                                                              False si l'id n'est pas trouvé
         */
        private function findMatchingPartnerId($id, $partner) {
                if (!in_array($partner, $this->allowedPartners)) {
                        throw new Exception("\nUnknown partner : $partner. Available Partners : " . implode(', ', $this->allowedPartners) . "\n");
                }

                // le json ressemble à ça :
                // {"tb":"2369732","pn":"1001020199"}, {"tb":"2369733","pn":"1001020176"}, ...
                $ids = json_decode(file_get_contents($this->correspondingIdsFilename), true);
                // extrait la colonne du partenaire recherché du tableau multidimensionnel fraichement décodé
                $partnerColumn = array_column($ids, $partner);
                // cherche l'index de l'id recherché
                $index = array_search($id, $partnerColumn);

                return $index ? $ids[$index] : false;
        }

        private function updateCorrespondingIdsTable() {
                // first update cache file if necessary
                if (!file_exists($this->correspondingIdsFilename) || time() > (filemtime($this->correspondingIdsFilename) + (24 * 3600))) {
                        $this->updateMatchingPartnersIds();
                }

                $data = file_get_contents($this->correspondingIdsFilename);
                $data = json_decode($data, true);

                $now = new DateTime;
                $now = $this->bdd->proteger($now->format('Y-m-d H:i:s'));

                $sqlInsert = 'INSERT INTO cel_plantnet (id_observation, id_plantnet, date_maj)'
                                        . ' VALUES %s'
                                        . ' ON DUPLICATE KEY UPDATE id_observation=VALUES(id_observation), id_plantnet=VALUES(id_plantnet)'
                                        . ' -- ' . __FILE__ . ':' . __LINE__;
                $values = '';

                echo "Updating matching partners ids table\n";
                foreach ($data as $id => $corres) {
                        $id_obs_tb = $this->bdd->proteger($corres['tb']);
                        $id_obs_pn = $this->bdd->proteger($corres['pn']);
                        // on insère les ids de correspondance obsPN obsTB dans la table cel_plantnet
                        $values .= " ($id_obs_tb, $id_obs_pn, $now),";

                        if (0 === $id % 100) {
                                $values = substr($values, 0, -1); // retire la dernière virgule
                                $sql = sprintf($sqlInsert, $values);
                                // var_dump($sql);
                                $this->bdd->requeter($sql);
                                $values = '';
                        }
                }
                // Faut envoyer les dernières données qui sont pas passées dans le modulo
                $values = substr($values, 0, -1); // retire la dernière virgule
                $sql = sprintf($sqlInsert, $values);
                // var_dump($sql);
                $this->bdd->requeter($sql);

                $count = count($data);
                echo "Success: #$count updated\n";
        }

        private function updateMatchingPartnersIds() {
                // curl -X GET --header 'Accept: application/json' 'https://bourbonnais.cirad.fr:8081/v1/edi/observations/partnerids?token=f0ca433febe320675c24ee2ddfab8b82f65d556b' > partnerids.txt
                $matchingPartnersIdsServiceUrl = str_replace(
                        '{token}',
                        Config::get('tokenPlantnet'),
                        Config::get('urlPlantnetBase').Config::get('urlPlantnetMatchingPartner')
                );
                $ch = curl_init($matchingPartnersIdsServiceUrl);
                curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

                echo "Updating matching partners ids...\n";
                $reponse = curl_exec($ch);
                $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
                curl_close($ch);


                if (!$reponse || 200 != $code) {
                        throw new Exception("\nError updating partners ids, http error code \"$code\" whith url \"$matchingPartnersIdsServiceUrl\"...\n");
                }
                echo "Response data from PN api gathered, know computing...\n";

                if (!file_put_contents($this->correspondingIdsFilename, $reponse)) {
                        throw new Exception("\nError writing correspondingIdsFilename with path : " . $this->correspondingIdsFilename . "\n");
                }
                echo "Matching partners ids saved!";
        }

        private function debug($text) {
                if ($this->debug) {
                        echo 'DEBUG - ' . $text . "\n";
                }
        }

        private function log($text) {
                if ('log' == $this->debug) {
                        echo 'LOG - ' . $text . "\n";
                }
        }
}