<?php
declare(strict_types = 1);

/**
 * Scan du répertoire des albums.
 *
 * @license https://www.gnu.org/licenses/gpl-3.0.html
 * @link https://www.igalerie.org/
 */
class Scan
{
	/**
	 * Indique si le scan a pu être initialisé avec succès.
	 *
	 * @var bool
	 */
	public $getInit = FALSE;

	/**
	 * Identifiants des groupes qui seront notifiés par courriel.
	 *
	 * @var array
	 */
	public $getNotifyGroups = [];

	/**
	 * Date et heure d'ajout des fichiers.
	 *
	 * @var string
	 */
	public $getNow = '';

	/**
	 * Rapport détaillé du scan.
	 *
	 * @var array
	 */
	public $getReport = [];

	/**
	 * Indique si le temps d'exécution maximum a été dépassé.
	 *
	 * @var bool
	 */
	public $getTimeExceeded = FALSE;

	/**
	 * Indique si l'on doit supprimer les fichiers et répertoires
	 * qui ont été rejetés.
	 *
	 * @var bool
	 */
	public $setDeleteRejectedFiles = FALSE;

	/**
	 * Indique si l'on doit forcer le scan de chaque répertoire,
	 * c'est à dire ne pas tenir compte de la date de dernière modification.
	 *
	 * @var bool
	 */
	public $setForcedScan = FALSE;

	/**
	 * Indique si le scan doit se faire en mode HTTP, c'est à dire ne
	 * scanner que le répertoire indiqué et ne récupérer que les
	 * fichiers indiqués dans $setHttpFiles.
	 *
	 * @var bool
	 */
	public $setHttp = FALSE;

	/**
	 * Informations de l'album dans lequel seront ajoutés
	 * les fichiers en mode HTTP.
	 *
	 * @var array
	 */
	public $setHttpAlbum = [];

	/**
	 * Fichiers à ajouter lors du scan en mode HTTP, accompagnées
	 * des informations éventuelles de ces images (titre, description...).
	 *
	 * @var array
	 */
	public $setHttpFiles = [];

	/**
	 * Répertoire temporaire où se trouvent les images originales
	 * ajoutées en mode HTTP, utilisé pour récupérer les métadonnées
	 * lorsque les images sont redimensionnées.
	 *
	 * @var string
	 */
	public $setHttpOriginalDir = '';

	/**
	 * Indique si l'on doit notifier les nouveaux fichiers
	 * par courriel aux utilisateurs autorisés.
	 *
	 * @var bool
	 */
	public $setMailAlert = TRUE;

	/**
	 * Poids maximum d'une image (en octets).
	 * 0 pour aucune limite.
	 *
	 * @var int
	 */
	public $setMaxImageFileSize = 0;

	/**
	 * Poids maximum d'une vidéo (en octets).
	 * 0 pour aucune limite.
	 *
	 * @var int
	 */
	public $setMaxVideoFileSize = 0;

	/**
	 * Identifiants des groupes à exclure de la notification.
	 *
	 * @var array
	 */
	public $setNotifyGroupsExclude = [];

	/**
	 * Indique si l'on doit ajouter au rapport les fichiers non valides.
	 *
	 * @var bool
	 */
	public $setReportAllFiles = FALSE;

	/**
	 * Mode simulation (uniquement pour tests et débogage).
	 * Si TRUE, ne renomme pas les fichiers, n'exécute pas
	 * la transaction et n'envoi pas de courriel.
	 *
	 * @var bool
	 */
	public $setSimulate = FALSE;

	/**
	 * État des objets ajoutés (catégories et fichiers).
	 *
	 * @var int
	 */
	public $setStatus = 1;

	/**
	 * Indique si l'on doit mettre à jour les images et vidéos existantes,
	 * c'est à dire vérifier si les informations utiles des fichiers sont
	 * différentes de celles enregistrées dans la base de données
	 * (ce qui peut augmenter considérablement la durée du scan).
	 *
	 * @var bool
	 */
	public $setUpdateFiles = FALSE;

	/**
	 * Indique si l'on doit conserver les données des colonnes à
	 * mettre à jour si les metadonnées correspondantes sont vides.
	 * Données concernées : Titre, Description, Date de création,
	 * Latitude, Longitude, Tags.
	 *
	 * @var bool
	 */
	public $setUpdateKeepData = TRUE;

	/**
	 * Indique si l'on doit choisir une nouvelle vignette pour
	 * les catégories dont on a ajouté de nouveaux fichiers.
	 *
	 * @var bool
	 */
	public $setUpdateThumbId = FALSE;

	/**
	 * Identifiant de l'utilisateur qui a déclenché le scan.
	 *
	 * @var int
	 */
	public $setUserId = 1;

	/**
	 * Nom de l'utilisateur qui a déclenché le scan.
	 *
	 * @var string
	 */
	public $setUserName = '';



	/**
	 * Chemin du répertoire des albums.
	 *
	 * @var string
	 */
	private $_albumsPath;

	/**
	 * Tableau des fabriquants et modèles d'appareil
	 * associés aux images leurs correspondant.
	 *
	 * @var array
	 */
	private $_camerasImages = [];

	/**
	 * Tableau établissant la correspondance entre le nom de répertoire
	 * original de chaque catégorie et le nom du répertoire renommé
	 * par _renameFile().
	 *
	 * @var array
	 */
	private $_catNames = [];

	/**
	 * Informations utiles des catégories enregistrées en base de données.
	 *
	 * @var array
	 */
	private $_dbCategories;

	/**
	 * Tableau contenant les fichiers ou répertoires renommés qu'il faut
	 * ignorer, ceci afin d'éviter les duplications lorsqu'un fichier renommé
	 * se retrouve une nouvelle fois scanné à cause de son nouveau nom.
	 *
	 * @var array
	 */
	private $_filesRename = [];

	/**
	 * Tableau des mots-clés trouvés dans les images,
	 * associés aux images comportant ces mots-clés.
	 *
	 * @var array
	 */
	private $_keywordsImages = [];

	/**
	 * Identifiants des images dont on doit supprimer les
	 * associations tags - images lors de mises à jour de ces images.
	 *
	 * @var array
	 */
	private $_keywordsImagesDelete = [];

	/**
	 * Instance de la classe Metadata.
	 *
	 * @var object
	 */
	private $_metadata;

	/**
	 * Tableau contenant les albums dont il ne faut pas ajouter ou mettre à jour
	 * la date de dernière modification du répertoire.
	 * Ceci est utile pour forcer à re-scanner le répertoire la prochaine fois
	 * lorsqu'un message à propos d'un des fichiers de cet album figure dans
	 * le rapport (de façon à ce que ce message soit visible à l'administrateur
	 * lors de chaque scan tant qu'il n'aura pas corrigé le problème).
	 *
	 * @var array
	 */
	private $_noFilemtime = [];

	/**
	 * Existe-t-il des fichiers à la racine du répertoire des albums ?
	 *
	 * @var bool
	 */
	private $_rootFiles = FALSE;

	/**
	 * Tableau contenant toutes les requêtes préparées en "INSERT" ou "UPDATE".
	 *
	 * @var array
	 */
	private $_sql;

	/**
	 * Nombre d'albums enfants activés que contient une catégorie.
	 *
	 * @var array
	 */
	private $_subActiveAlbs = [];

	/**
	 * Nombre de catégories enfants activées que contient une catégorie.
	 *
	 * @var array
	 */
	private $_subActiveCats = [];

	/**
	 * Nombre d'albums enfants désactivés que contient une catégorie.
	 *
	 * @var array
	 */
	private $_subDeactiveAlbs = [];

	/**
	 * Nombre de catégories enfants désactivées que contient une catégorie.
	 *
	 * @var array
	 */
	private $_subDeactiveCats = [];

	/**
	 * Date et heure du début du scan, qui sert de temps de contrôle
	 * pour $_timeLimit.
	 *
	 * @var int
	 */
	private $_timeControl;

	/**
	 * Durée maximum d'exécution du scan.
	 *
	 * @var int
	 */
	private $_timeLimit;

	/**
	 * Types de fichiers autorisés.
	 *
	 * @var array
	 */
	private $_types;

	/**
	 * Chemin associé à l'identifiant de chaque fichier
	 * vérifié avec l'option de mise à jour des fichiers.
	 *
	 * @var array
	 */
	private $_updateItemPath = [];



	/**
	 * Initialisation des paramètres de scan.
	 *
	 * @return void
	 */
	public function __construct()
	{
		// Répertoire des albums.
		$this->_albumsPath = CONF_ALBUMS_PATH;

		// Contrôle du temps d'exécution.
		$this->_timeControl = time();
		$this->_timeLimit = 8;

		// Calcul du temps maximum d'exécution du script.
		if (function_exists('ini_get'))
		{
			$max = (int) ini_get('max_execution_time');
			$this->_timeLimit = $max > 16 ? $max - 8 : 8;
		}

		// Tableau de rapport.
		$this->getReport =
		[
			'albums_added' => [],
			'albums_updated' => [],
			'cameras_added' => 0,
			'dirs_rejected' => [],
			'files_errors' => [],
			'files_rejected' => [],
			'images_added' => 0,
			'images_updated' => 0,
			'tags_added' => 0,
			'videos_added' => 0,
			'videos_updated' => 0
		];

		// Début de la transaction.
		if (!DB::beginTransaction())
		{
			return;
		}

		// Date et heure courantes.
		$this->getNow = date('Y-m-d H:i:s');
		if (!$this->setStatus)
		{
			$dates = Config::$params['dates_publication'];
			while (in_array($this->getNow, is_array($dates) ? $dates : []))
			{
				$this->getNow = date('Y-m-d H:i:s', strtotime($this->getNow) + 1);
			}
		}

		// On récupère les informations utiles de toutes les 
		// catégories enregistrées dans la base de données.
		$sql = 'SELECT cat.cat_id,
					   thumb_id,
					   password_id,
					   cat_path,
					   cat_a_size,
					   cat_a_images,
					   cat_d_images,
					   cat_a_videos,
					   cat_d_videos,
					   cat_crtdt,
					   cat_filemtime,
					   cat_status,
					   i.item_path
				  FROM {categories} AS cat
			 LEFT JOIN {items} AS i
					ON thumb_id = i.item_id
			  ORDER BY cat_name';
		if (!DB::execute($sql))
		{
			return;
		}
		$this->_dbCategories = DB::fetchAll('cat_path');

		// Tableau des requêtes préparées.
		$this->_sql =
		[
			'insert_items' => [],
			'update_items' => [],
			'insert_categories' => [],
			'update_insert_categories' => [],
			'update_categories' => [],
			'update_categories_filemtime' => []
		];

		$this->_sql['insert_items']['sql'] =
			"INSERT INTO {items} (
			cat_id, item_type, user_id, item_path, item_url, item_height, item_width,
			item_filesize, item_exif, item_iptc, item_xmp, item_orientation,
			item_name, item_desc, item_adddt, item_pubdt, item_crtdt, item_lat,
			item_long, item_status)
			VALUES (:cat_id, :item_type, :user_id, :item_path, :item_url, :item_height,
			:item_width, :item_filesize, :item_exif, :item_iptc, :item_xmp,
			:item_orientation, :item_name, :item_desc, '{$this->getNow}', :item_pubdt,
			:item_crtdt, :item_lat, :item_long, :item_status)";
		$this->_sql['insert_items']['params'] = [];

		$this->_sql['update_items']['sql'] =
			'UPDATE {items}
			SET item_type = :item_type, item_name = :item_name,
			item_width = :item_width, item_height = :item_height,
			item_filesize = :item_filesize, item_orientation = :item_orientation,
			item_url = :item_url, item_desc = :item_desc, item_crtdt = :item_crtdt,
			item_lat = :item_lat, item_long = :item_long
			WHERE item_id = :item_id';
		$this->_sql['update_items']['params'] = [];

		$this->_sql['insert_categories']['sql'] =
			"INSERT INTO {categories} (
			user_id, thumb_id, password_id, cat_parents, parent_id, cat_path, cat_name, cat_url,
			cat_a_size, cat_a_subalbs, cat_a_subcats, cat_a_albums, cat_a_images, cat_a_videos,
			cat_d_size, cat_d_subalbs, cat_d_subcats, cat_d_albums, cat_d_images, cat_d_videos,
			cat_crtdt, cat_lastpubdt, cat_filemtime, cat_status)
			VALUES (:user_id, :thumb_id, :password_id, :cat_parents, :parent_id, :cat_path,
			:cat_name, :cat_url, :cat_a_size, :cat_a_subalbs, :cat_a_subcats, :cat_a_albums,
			:cat_a_images, :cat_a_videos, :cat_d_size, :cat_d_subalbs, :cat_d_subcats,
			:cat_d_albums, :cat_d_images, :cat_d_videos, '{$this->getNow}', :cat_lastpubdt,
			:cat_filemtime, :cat_status)";
		$this->_sql['insert_categories']['params'] = [];

		$this->_sql['update_insert_categories']['sql'] =
			'UPDATE {categories}
			SET parent_id = :parent_id, cat_parents = :cat_parents, cat_position = cat_id
			WHERE cat_path = :cat_path';
		$this->_sql['update_insert_categories']['params'] = [];

		$this->_sql['update_categories_filemtime']['sql'] =
			'UPDATE {categories} SET cat_filemtime = :cat_filemtime WHERE cat_path = :cat_path';
		$this->_sql['update_categories_filemtime']['params'] = [];

		// Types de fichiers.
		$this->_types['image'] = Item::getTypesSupported('image');
		$this->_types['video'] = Item::getTypesSupported('video');

		// Initialisation réussie.
		$this->getInit = TRUE;
	}

	/**
	 * Scan récursif du répertoire des albums
	 * ou d'un répertoire de catégorie.
	 *
	 * @param string $dir
	 *   Chemin du répertoire à scanner, relatif au répertoire des albums.
	 *
	 * @return mixed
	 *   Retourne FALSE en cas d'erreur.
	 */
	public function start(string $dir = '')
	{
		if (!$this->getInit)
		{
			return;
		}

		if (!is_dir($this->_albumsPath))
		{
			trigger_error('Directory not found.', E_USER_WARNING);
			return;
		}

		if ($this->setSimulate && !CONF_DB_TRAN)
		{
			trigger_error('Transactions disabled.', E_USER_WARNING);
			return;
		}

		$cat_infos =
		[
			'a_albums' => 0, 'd_albums' => 0, 'a_images' => 0, 'd_images' => 0,
			'a_videos' => 0, 'd_videos' => 0, 'a_size' => 0, 'd_size' => 0
		];

		$sub_dir = FALSE;

		// Si le mode HTTP n'est pas activé,
		// on parcours le répertoire s'il n'est pas un album,
		// et si aucun fichier n'a été trouvé à la racine.
		if (!$this->_rootFiles && !$this->setHttp
		&& (array_key_exists($dir, $this->_dbCategories) &&
		$this->_dbCategories[$dir]['cat_filemtime'] !== NULL) === FALSE)
		{
			if (($res = opendir($this->_albumsPath . '/' . $dir)) === FALSE)
			{
				if ($dir === '')
				{
					$dir = __('répertoire des albums');
				}
				$this->getReport['files_errors'][] =
				[
					'file' => $dir,
					'message' => __('Impossible d\'accéder au répertoire.')
				];
				return FALSE;
			}
			$dir = $dir === '' ? '' : $dir . '/';

			while (($ent = readdir($res)) !== FALSE)
			{
				// Contrôle du temps d'exécution.
				if ((time() - $this->_timeControl) > $this->_timeLimit)
				{
					$this->getTimeExceeded = TRUE;
					break;
				}

				// Si c'est un répertoire.
				if (is_dir($this->_albumsPath . '/' . $dir . $ent)
				&& $ent !== '.' && $ent !== '..'

				// Et s'il ne contient aucun caractère invalide.
				&& !strstr($ent, '?')

				// Et s'il ne figure pas parmi les répertoires renommés.
				&& !in_array($dir . $ent, $this->_filesRename)

				// Et si le nom est correct.
				&& ($ent = $this->_checkDirName($ent, $dir)) !== FALSE)
				{
					// On scan le répertoire.
					if (is_array($i = $this->start($dir . $ent)))
					{
						foreach (['albums', 'images', 'videos', 'size'] as &$c)
						{
							foreach (['a_', 'd_'] as &$s)
							{
								$cat_infos[$s . $c] += $i[$s . $c];
							}
						}
					}

					$sub_dir = TRUE;
				}
			}
			closedir($res);
		}
		else
		{
			$dir = $dir === '' ? '' : $dir . '/';
		}

		// Récupération des fichiers de l'album.
		// On suppose que c'est un album lorsqu'il n'y a aucun sous-répertoire.
		if (($dir !== '' || $this->_rootFiles) && !$sub_dir
		&& ($i = $this->_getItems($dir)) !== FALSE)
		{
			$cat_infos = $i;
			$cat_infos['a_albums'] = 0;
			$cat_infos['d_albums'] = 0;
			$cat_infos['filemtime'] = filemtime($this->_albumsPath . '/' . $dir);
			if (!$this->_rootFiles)
			{
				// Si l'album n'existe pas en base de données,
				// on incrémente le compteur du nombre d'albums enfants.
				if (!isset($this->_dbCategories[substr($dir, 0, -1)]))
				{
					// Activé.
					if ($this->setStatus)
					{
						// Albums.
						$cat_infos['a_albums'] = 1;

						// Sous-albums.
						if (isset($this->_subActiveAlbs[dirname($dir)]))
						{
							$this->_subActiveAlbs[dirname($dir)]++;
						}
						else
						{
							$this->_subActiveAlbs[dirname($dir)] = 1;
						}
					}

					// Non activé.
					else
					{
						// Albums.
						$cat_infos['d_albums'] = 1;

						// Sous-albums.
						if (isset($this->_subDeactiveAlbs[dirname($dir)]))
						{
							$this->_subDeactiveAlbs[dirname($dir)]++;
						}
						else
						{
							$this->_subDeactiveAlbs[dirname($dir)] = 1;
						}
					}
				}

				// Si l'album est désactivé
				// et qu'au moins un fichier activé a été ajouté,
				// on incrémente le nombre d'albums activés,
				// et on décrémente le nombre d'albums désactivés
				// pour les catégories parentes.
				else if ($this->_dbCategories[substr($dir, 0, -1)]['cat_status'] == 0
				&& ($cat_infos['a_images'] + $cat_infos['a_videos']) > 0)
				{
					// Albums.
					$cat_infos['a_albums'] = 1;
					$cat_infos['d_albums'] = -1;

					// Sous-albums.
					if (isset($this->_subActiveAlbs[dirname($dir)]))
					{
						$this->_subActiveAlbs[dirname($dir)]++;
					}
					else
					{
						$this->_subActiveAlbs[dirname($dir)] = 1;
					}
					if (isset($this->_subDeactiveAlbs[dirname($dir)]))
					{
						$this->_subDeactiveAlbs[dirname($dir)]--;
					}
					else
					{
						$this->_subDeactiveAlbs[dirname($dir)] = -1;
					}
				}
			}
		}

		// Préparation des requêtes pour les catégories.
		if (!$this->setHttp && ($cat_infos['a_size'] !== 0 || $cat_infos['d_size'] !== 0))
		{
			$cat_dir = $dir === '' ? '.' : substr($dir, 0, -1);
			if (isset($this->_dbCategories[$cat_dir]))
			{
				$this->_updateCategory($cat_dir, $cat_infos);
			}
			else
			{
				$this->_insertCategory($cat_dir, $cat_infos);
			}
			if ($dir !== '')
			{
				return $cat_infos;
			}
		}

		// En mode HTTP, on update les informations pour les catégories parentes.
		if ($this->setHttp)
		{
			$cat_dir = substr($dir, 0, -1);
			while ($cat_dir != '')
			{
				$cat_infos['filemtime'] = ($cat_dir == substr($dir, 0, -1)
					&& isset($cat_infos['filemtime']))
					? $cat_infos['filemtime']
					: 0;
				$this->_updateCategory($cat_dir, $cat_infos);
				$cat_dir = $cat_dir == '.' ? '' : dirname($cat_dir);
			}
		}

		// Fin du scan.
		if ($this->setHttp || $dir === '')
		{
			// Suppression des fichiers et répertoires rejetés ?
			if ($this->setDeleteRejectedFiles)
			{
				foreach ($this->getReport['dirs_rejected'] as &$f)
				{
					if (is_dir($delete = $this->_albumsPath . '/' . $f['dir']))
					{
						File::rmdir($delete);
					}
				}
				foreach ($this->getReport['files_rejected'] as &$f)
				{
					if (file_exists($delete = $this->_albumsPath . '/' . $f['dir'] . $f['file']))
					{
						File::unlink($delete);
					}
				}
			}

			// On met à jour la base de données si nécessaire.
			if ($cat_infos['a_size'] !== 0 || $cat_infos['d_size'] !== 0
			|| count($this->_sql['update_categories_filemtime']['params']) > 0)
			{
				return $this->_execute($cat_infos);
			}
		}

		return TRUE;
	}



	/**
	 * Vérifie et renomme si nécessaire le nom d'un répertoire.
	 *
	 * @param string $f
	 *   Nom du répertoire à vérifier.
	 * @param string $dir
	 *   Chemin du répertoire parent de $f.
	 *
	 * @return bool|string
	 */
	private function _checkDirName(string $f, string $dir)
	{
		$new_f = $this->_renameFile($f, $dir, 'dir');
		$this->_catNames[$new_f] = $f;

		return $new_f;
	}

	/**
	 * Vérifie et renomme si nécessaire le nom d'un fichier.
	 *
	 * @param string $f
	 *   Nom du fichier à vérifier.
	 * @param string $dir
	 *   Chemin du répertoire parent de $f.
	 *
	 * @return bool|string
	 */
	private function _checkFileName(string $f, string $dir)
	{
		return $this->_renameFile($f, $dir, 'file');
	}

	/**
	 * Exécute toutes les requêtes SQL à partir des informations
	 * récoltées lors du scan, et notifie par courriel en cas de succès.
	 *
	 * @param array $infos
	 *   Informations utiles des fichiers scannés.
	 *
	 * @return bool
	 */
	private function _execute(array &$infos): bool
	{
		$albums = [];
		$cat_items_id = [];
		$categories_id = ['.' => 1];
		$item_path_id = $this->_updateItemPath;

		if (($infos['a_videos'] + $infos['d_videos']) > 0)
		{
			if (Config::changeDBParams(['video_captures' => 1]) < 0)
			{
				return DB::rollback(FALSE);
			}
		}

		// Nouveaux fichiers.
		if (($infos['a_images'] + $infos['d_images']
		   + $infos['a_videos'] + $infos['d_videos']) > 0)
		{
			// Première partie du code servant à récupérer
			// l'id d'un fichier pour chaque catégorie.
			$items_count = 0;
			$params = [];
			foreach ($this->_sql['insert_items']['params'] as $a => &$p)
			{
				$count = count($p);
				if ($count < 1)
				{
					continue;
				}
				$items_count = $items_count + $count;
				$albums[$items_count - 1] = substr($a, 0, -1);
				$params = array_merge($params, $p);
			}

			// On INSERT les nouveaux fichiers.
			if (!DB::execute($this->_sql['insert_items']['sql'], $params,
			['table' => '{items}', 'column' => 'item_id']))
			{
				return DB::rollback(FALSE);
			}

			// On établit la correspondance chemin => identifiant
			// de chaque fichier inséré.
			for ($i = 0; $i < count($params); $i++)
			{
				$item_path_id[$params[$i]['item_path']] = DB::allInsertId()[$i];
			}

			// On détermine l'id de la vignette de chaque catégorie.
			if (!$this->_rootFiles)
			{
				foreach ($albums as $i => $cat)
				{
					$id = DB::allInsertId()[$i];
					$cat_items_id[$cat] = $id;
					while (($cat = dirname($cat)) !== '.')
					{
						$cat_items_id[$cat] = $id;
					}
				}
			}
		}

		// Nouvelles catégories.
		if (count($this->_sql['insert_categories']['params']) > 0)
		{
			// On ajoute l'id de la vignette de chaque catégorie.
			$cat_paths = [];
			for ($i = 0, $count = count($this->_sql['insert_categories']['params']);
			$i < $count; $i++)
			{
				$path = $this->_sql['insert_categories']['params'][$i]['cat_path'];
				$this->_sql['insert_categories']['params'][$i]['thumb_id']
					= $cat_items_id[$path];
				$cat_paths[] = $path;
			}

			// On INSERT les nouvelles catégories.
			if (!DB::execute($this->_sql['insert_categories']['sql'],
			$this->_sql['insert_categories']['params'],
			['table' => '{categories}', 'column' => 'cat_id']))
			{
				return DB::rollback(FALSE);
			}

			// On récupère l'id de chaque catégorie.
			for ($i = 0, $count = count($cat_paths); $i < $count; $i++)
			{
				$categories_id[$cat_paths[$i]] = DB::allInsertId()[$i];
			}
		}

		// On ajoute les bons 'cat_id' et 'item_position' aux nouveaux fichiers.
		if (($infos['a_images'] + $infos['d_images']
		   + $infos['a_videos'] + $infos['d_videos']) > 0)
		{
			if (count($this->_sql['update_categories']) > 0)
			{
				for ($i = 0, $count = count($this->_sql['update_categories']);
				$i < $count; $i++)
				{
					$path = $this->_sql['update_categories'][$i]['dir'];
					$categories_id[$path] = $this->_dbCategories[$path]['cat_id'];
				}
			}
			$params = [];
			if ($this->_rootFiles)
			{
				$sql = 'UPDATE {items} SET item_position = item_id WHERE item_position = 0';
			}
			else
			{
				$sql = 'UPDATE {items}
						   SET cat_id = ?,
							   item_position = item_id
						 WHERE item_path LIKE ?
						   AND item_position = 0';
				foreach (array_flip($albums) as $path => &$id)
				{
					$params[] = [$categories_id[$path], DB::likeEscape((string) $path) . '/%'];
				}
			}
			if (!DB::execute($sql, $params))
			{
				return DB::rollback(FALSE);
			}
		}

		// On ajoute les bonnes valeurs pour les colonnes
		// 'parent_id', 'cat_parents' et 'cat_position'.
		if (count($this->_sql['insert_categories']['params']) > 0)
		{
			for ($i = 0, $count = count($this->_sql['insert_categories']['params']);
			$i < $count; $i++)
			{
				$cat_path = $path = $this->_sql['insert_categories']['params'][$i]['cat_path'];
				$cat_parents = [];
				while ($cat_path != '.')
				{
					$cat_path = dirname($cat_path);
					$cat_parents[] = $categories_id[$cat_path];
				}
				$cat_parents = implode(Parents::SEPARATOR, array_reverse($cat_parents));
				$this->_sql['update_insert_categories']['params'][] =
				[
					'parent_id' => $categories_id[dirname($path)],
					'cat_path' => $path,
					'cat_parents' => $cat_parents . Parents::SEPARATOR
				];
			}
			if (count($this->_sql['update_insert_categories']['params']) > 0)
			{
				if (!DB::execute($this->_sql['update_insert_categories']['sql'],
				$this->_sql['update_insert_categories']['params']))
				{
					return DB::rollback(FALSE);
				}
			}
		}

		// Mise à jour des catégories.
		if (count($this->_sql['update_categories']) > 0)
		{
			$up_cat = $this->_sql['update_categories'];
			for ($i = 0, $count = count($up_cat); $i < $count; $i++)
			{
				$cat_path = $up_cat[$i]['dir'] === '' ? '.' : $up_cat[$i]['dir'];

				// Si la catégorie est désactivée ou vide on choisi
				// une nouvelle vignette, mais à condition qu'il y ait
				// au moins un nouveau fichier dans cette catégorie.
				$thumb_id = '';
				$new_items = $up_cat[$i]['items_infos']['a_images']
						   + $up_cat[$i]['items_infos']['d_images']
						   + $up_cat[$i]['items_infos']['a_videos']
						   + $up_cat[$i]['items_infos']['d_videos'];
				$cat_a_items = $this->_dbCategories[$up_cat[$i]['dir']]['cat_a_images']
							 + $this->_dbCategories[$up_cat[$i]['dir']]['cat_a_videos'];
				$cat_d_items = $this->setStatus
					? 0
					: $this->_dbCategories[$up_cat[$i]['dir']]['cat_d_images']
					+ $this->_dbCategories[$up_cat[$i]['dir']]['cat_d_videos'];
				if ($up_cat[$i]['dir'] !== '.' &&
				($this->setUpdateThumbId || ($new_items > 0 && (($cat_a_items + $cat_d_items) == 0
				|| $this->_dbCategories[$up_cat[$i]['dir']]['thumb_id'] == -1))))
				{
					$thumb_id = "thumb_id = '{$cat_items_id[$up_cat[$i]['dir']]}', ";
				}

				// Nombre d'albums et de catégories enfants.
				$cat_a_subalbs = $this->_subActiveAlbs[$cat_path] ?? 0;
				$cat_a_subcats = $this->_subActiveCats[$cat_path] ?? 0;
				$cat_d_subalbs = $this->_subDeactiveAlbs[$cat_path] ?? 0;
				$cat_d_subcats = $this->_subDeactiveCats[$cat_path] ?? 0;

				// Nombre total d'albums dans la catégorie.
				$cat_a_albums = $up_cat[$i]['cat_filemtime'] === NULL
					? $up_cat[$i]['items_infos']['a_albums']
					: 0;
				$cat_d_albums = $up_cat[$i]['cat_filemtime'] === NULL
					? $up_cat[$i]['items_infos']['d_albums']
					: 0;

				// Date de dernière modification du répertoire.
				$filemtime = $up_cat[$i]['cat_filemtime'] === NULL
					? 'NULL'
					: "'{$up_cat[$i]['cat_filemtime']}'";

				$up = &$up_cat[$i]['items_infos'];
				$sql = "UPDATE {categories}
						   SET {$thumb_id}
							   {$up_cat[$i]['cat_lastpubdt']}
							   {$up_cat[$i]['cat_status']}
							   cat_a_size = cat_a_size + {$up['a_size']},
							   cat_d_size = cat_d_size + {$up['d_size']},
							   cat_a_subalbs = cat_a_subalbs + {$cat_a_subalbs},
							   cat_d_subalbs = cat_d_subalbs + {$cat_d_subalbs},
							   cat_a_subcats = cat_a_subcats + {$cat_a_subcats},
							   cat_d_subcats = cat_d_subcats + {$cat_d_subcats},
							   cat_a_albums = cat_a_albums + {$cat_a_albums},
							   cat_d_albums = cat_d_albums + {$cat_d_albums},
							   cat_a_images = cat_a_images + {$up['a_images']},
							   cat_d_images = cat_d_images + {$up['d_images']},
							   cat_a_videos = cat_a_videos + {$up['a_videos']},
							   cat_d_videos = cat_d_videos + {$up['d_videos']},
							   cat_filemtime = {$filemtime}
					     WHERE cat_path = ?";
				if (!DB::execute($sql, $cat_path))
				{
					return DB::rollback(FALSE);
				}
			}
		}

		// Mise à jour des fichiers.
		if (count($this->_sql['update_items']['params']) > 0)
		{
			if (!DB::execute($this->_sql['update_items']['sql'],
			$this->_sql['update_items']['params']))
			{
				return DB::rollback(FALSE);
			}
		}

		// Mise à jour du 'filemtime' des catégories.
		if (count($this->_sql['update_categories_filemtime']['params']) > 0)
		{
			if (!DB::execute($this->_sql['update_categories_filemtime']['sql'],
			$this->_sql['update_categories_filemtime']['params']))
			{
				return DB::rollback(FALSE);
			}
		}

		// Identifiants des fichiers dont on met à jour les tags (ajout ou suppression).
		$tags_images_update = [];

		// Supprimer les associations tags - images lors des mises à jour des fichiers ?
		if (count($this->_keywordsImagesDelete) > 0)
		{
			$sql_items_id = DB::inInt($this->_keywordsImagesDelete);

			// Récupération des identifiants des images associés à des tags.
			if (!DB::execute("SELECT item_id FROM {items} WHERE item_id IN ($sql_items_id)"))
			{
				return DB::rollback(FALSE);
			}
			$tags_images_update = DB::fetchCol('item_id');

			// Suppression des associations tags - images.
			if (!DB::execute("DELETE FROM {tags_items} WHERE item_id IN ($sql_items_id)"))
			{
				return DB::rollback(FALSE);
			}
		}

		// On ajoute les nouveaux tags.
		if (count($this->_keywordsImages) > 0)
		{
			$tags = [];
			foreach ($this->_keywordsImages as &$i)
			{
				foreach ($i['files'] as &$path)
				{
					$tags[] = ['item_id' => $item_path_id[$path], 'tag_name' => $i['name']];
				}
			}
			if (($count = Tags::itemsAdd($tags)) < 0)
			{
				return DB::rollback(FALSE);
			}
			else if ($count > 0)
			{
				$tags_images_update = array_values(array_unique(array_merge(
					$tags_images_update,
					array_column($tags, 'item_id')
				)));
			}
		}

		// On incrémente le nombre de fichiers mis à jour si, lors
		// d'une procédure de mise à jour de fichiers, seuls les
		// tags d'un fichier ont été ajoutés ou supprimés alors
		// qu'aucune autre information du fichier n'a été mise à jour.
		if ($this->setUpdateFiles && $tags_images_update)
		{
			$tags_images_update = array_keys(array_flip($tags_images_update));
			$update_items_id = array_column($this->_sql['update_items']['params'], 'item_id');
			foreach ($tags_images_update as &$item_id)
			{
				if (!in_array($item_id, $update_items_id))
				{
					$this->getReport['images_updated']++;
				}
			}
		}

		// On ajoute les nouveaux fabriquants et modèles d'appareil
		// et les associations modèles d'appareil - images.
		if (count($this->_camerasImages) > 0)
		{
			// Enregistrement des fabriquants d'appareils.
			$params = [];
			foreach ($this->_camerasImages as $make => &$models)
			{
				$params[] = [$make, App::getURLName((string) $make)];
			}
			$sql = 'INSERT IGNORE INTO {cameras_brands}
					(camera_brand_name, camera_brand_url) VALUES (?, ?)';
			if (!DB::execute($sql, $params))
			{
				return DB::rollback(FALSE);
			}

			// On récupère les informations utiles de tous les fabriquants.
			if (!DB::execute('SELECT camera_brand_id, camera_brand_name FROM {cameras_brands}'))
			{
				return DB::rollback(FALSE);
			}
			$camera_brand_name_id = DB::fetchAll('camera_brand_name', 'camera_brand_id');

			// Enregistrement des modèles d'appareils.
			$params = [];
			foreach ($this->_camerasImages as $make => &$models)
			{
				foreach ($models as $model => &$images)
				{
					if (!isset($camera_brand_name_id[$make]))
					{
						continue;
					}
					$params[] =
					[
						(int) $camera_brand_name_id[$make],
						$model,
						App::getURLName((string) $model)
					];
				}
			}
			$sql = 'INSERT IGNORE INTO {cameras_models}
					(camera_brand_id, camera_model_name, camera_model_url) VALUES (?, ?, ?)';
			if (!DB::execute($sql, $params))
			{
				return DB::rollback(FALSE);
			}
			$this->getReport['cameras_added'] = DB::rowCount();

			// On récupère les informations utiles de tous les modèles.
			$sql = 'SELECT camera_model_id,
						   camera_model_name,
						   camera_brand_name
					  FROM {cameras_models}
				 LEFT JOIN {cameras_brands} USING (camera_brand_id)';
			if (!DB::execute($sql))
			{
				return DB::rollback(FALSE);
			}
			$camera_model_id_infos = DB::fetchAll('camera_model_id');

			// Enregistrement des associations modèles d'appareil - images.
			$params = [];
			foreach ($camera_model_id_infos as $model_id => &$infos)
			{
				if (!isset($this->_camerasImages[$infos['camera_brand_name']]
				[$infos['camera_model_name']]))
				{
					continue;
				}
				$camera_model_images = $this->_camerasImages[$infos['camera_brand_name']]
					[$infos['camera_model_name']];
				foreach ($camera_model_images as &$image_path)
				{
					$params[] = [$model_id, $item_path_id[$image_path]];
				}
			}
			$sql = 'INSERT IGNORE INTO {cameras_items} (camera_model_id, item_id) VALUES (?, ?)';
			if (!DB::execute($sql, $params))
			{
				return DB::rollback(FALSE);
			}
		}

		// Mise à jour des informations des catégories.
		$update_infos = [];
		foreach ($this->_sql['update_categories'] as &$i)
		{
			$update_infos[] = $this->_dbCategories[$i['dir']]['cat_id'];
		}
		if ($update_infos)
		{
			if (!Parents::updateInfos($update_infos) || !Parents::updateSubCats($update_infos))
			{
				return DB::rollback(FALSE);
			}
		}

		if (CONF_DEV_MODE)
		{
			// Contrôle de la cohérence de la table des fichiers.
			$sql = 'SELECT COUNT(*) FROM {items} WHERE item_position = 0 LIMIT 1';
			if (!DB::execute($sql) || DB::fetchVal() > 0)
			{
				trigger_error('Incoherent table "items".', E_USER_WARNING);
				return DB::rollback(FALSE);
			}

			// Contrôle de la cohérence de la table des catégories.
			$sql = 'SELECT COUNT(*)
					  FROM {categories}
					 WHERE cat_id > 1
					   AND ((thumb_id = 0 AND (cat_a_images + cat_d_images
						   + cat_a_videos + cat_d_videos = 0)) OR
						   parent_id = 0 OR cat_position = 0)
					 LIMIT 1';
			if (!DB::execute($sql) || DB::fetchVal() > 0)
			{
				trigger_error('Incoherent table "categories".', E_USER_WARNING);
				return DB::rollback(FALSE);
			}

			// Vérification des stats.
			if (Maintenance::dbStats() !== 0)
			{
				trigger_error('Gallery stats error.', E_USER_WARNING);
				return DB::rollback(FALSE);
			}
		}

		// Mode simulation.
		if ($this->setSimulate)
		{
			return DB::rollback(TRUE);
		}

		// Exécution de la transaction.
		if (!DB::commitTransaction())
		{
			return FALSE;
		}

		// Notification par courriel.
		if ($this->setMailAlert && $this->setStatus
		&& count($this->_sql['insert_items']['params']))
		{
			$infos = 
			[
				'user_id' => $this->setUserId,
				'user_name' => $this->setUserName,
			];
			$notification = 'items';
			if ($this->setHttp && $this->setHttpAlbum)
			{
				$infos['cat_id'] = $this->setHttpAlbum['cat_id'];
				$infos['cat_name'] = $this->setHttpAlbum['cat_name'];
				$notification .= '-http';
			}
			$mail = new Mail();
			$groups_notified = $mail->notify(
				$notification,
				$albums,
				$this->setUserId,
				$infos,
				$this->setNotifyGroupsExclude
			);
			if (is_array($groups_notified))
			{
				$this->getNotifyGroups = $groups_notified;
			}
			$mail->send();
		}

		return TRUE;
	}

	/**
	 * Récupère les informations des images et vidéos d'un album.
	 *
	 * @param string $dir
	 *   Chemin du répertoire à scanner.
	 *
	 * @return bool|array
	 */
	private function _getItems(string $dir)
	{
		$dir_path = $this->_albumsPath . '/' . $dir;
		if (!file_exists($dir_path) || !is_dir($dir_path))
		{
			$this->getReport['files_errors'][] =
			[
				'file' => $dir,
				'message' => __('Le répertoire n\'existe pas.')
			];
			return FALSE;
		}
		$filemtime = date('Y-m-d H:i:s', filemtime($dir_path));
		$cat_path = $this->_rootFiles ? '.' : substr($dir, 0, -1);

		// Si l'on est pas en scan forcé,
		// et si la date de dernière modification du répertoire n'a pas changée
		// par rapport à celle enregistrée lors du précédent scan, ou bien
		// si c'est une catégorie, alors on ne va pas plus loin.
		if (!$this->setForcedScan
		&& (isset($this->_dbCategories[$cat_path])
		&& ($filemtime == $this->_dbCategories[$cat_path]['cat_filemtime']
		|| $this->_dbCategories[$cat_path]['cat_filemtime'] === NULL)))
		{
			return FALSE;
		}

		$cat_infos =
		[
			'a_images' => 0, 'd_images' => 0,
			'a_videos' => 0, 'd_videos' => 0,
			'a_size' => 0, 'd_size' => 0
		];

		// Récupération du chemin de toutes les fichiers de l'album
		// enregistrés dans la base de données.
		$db_items_infos = [];
		if (isset($this->_dbCategories[$cat_path]))
		{
			$sql_select = $this->setUpdateFiles
				? ', item_id, item_url, item_type, item_width, item_height,
				    item_filesize, item_status, item_crtdt, item_name, item_desc,
					item_lat, item_long, item_orientation'
				: '';
			$sql = "SELECT item_path$sql_select FROM {items} WHERE item_path LIKE ?";
			if (!DB::execute($sql, DB::likeEscape($dir) . '%'))
			{
				$this->getReport['files_errors'][] =
				[
					'file' => $dir,
					'message' => __('Requête SQL échouée.')
				];
				return FALSE;
			}
			$db_items_infos = DB::fetchAll('item_path');
		}

		// État des fichiers à ajouter.
		if ($this->setStatus)
		{
			$status_images = 'a_images';
			$status_videos = 'a_videos';
			$status_size = 'a_size';
		}
		else
		{
			$status_images = 'd_images';
			$status_videos = 'd_videos';
			$status_size = 'd_size';
		}

		// Scan du répertoire à la recherche de fichiers valides.
		if (($res = opendir($this->_albumsPath . '/' . $dir)) === FALSE)
		{
			$this->getReport['files_errors'][] =
			[
				'file' => $dir,
				'message' => __('Impossible d\'accéder au répertoire.')
			];
			return FALSE;
		}
		$this->_sql['insert_items']['params'][$dir] = [];
		while (($ent = readdir($res)) !== FALSE)
		{
			// Caractères non valide.
			if (strstr($ent, '?') && $this->setReportAllFiles)
			{
				$this->getReport['files_rejected'][] =
				[
					'file' => Utility::UTF8($ent),
					'dir' => $dir,
					'message' => __('Nom de fichier invalide.')
				];
				continue;
			}

			// On ne recherche que des fichiers.
			if (!is_file($this->_albumsPath . '/' . $dir . $ent))
			{
				continue;
			}

			// En mode HTTP, on ne s'occupe que des
			// fichiers spécifiées dans setHttpFiles.
			if ($this->setHttp && !isset($this->setHttpFiles[$ent]))
			{
				continue;
			}

			// Image.
			if (preg_match('`.\.(' . implode('|',
			$this->_types['image']['ext']) . ')$`i', $ent))
			{
				// Si l'image est déjà présente en base de données,
				// on l'UPDATE si nécessaire.
				if (isset($db_items_infos[$dir . $ent]) &&
				($up = $this->_updateImage($dir, $ent,
				$db_items_infos[$dir . $ent])) !== FALSE)
				{
					$cat_infos['a_size'] += $up['a_size'];
					$cat_infos['d_size'] += $up['d_size'];
				}

				// Sinon, si l'image n'est pas présente en base de données,
				// on l'INSERT.
				else if (!isset($db_items_infos[$dir . $ent]) &&
				!in_array($dir . $ent, $this->_filesRename) &&
				($size = $this->_insertImage($dir, $ent)) !== FALSE)
				{
					$cat_infos[$status_images]++;
					$cat_infos[$status_size] += $size;
				}
			}

			// Vidéo.
			else if (preg_match('`.\.(' . implode('|',
			$this->_types['video']['ext']) . ')$`i', $ent))
			{
				// Si la vidéo est déjà présente en base de données,
				// on l'UPDATE si nécessaire.
				if (isset($db_items_infos[$dir . $ent]) &&
				($up = $this->_updateVideo($dir, $ent,
				$db_items_infos[$dir . $ent])) !== FALSE)
				{
					$cat_infos['a_size'] += $up['a_size'];
					$cat_infos['d_size'] += $up['d_size'];
				}

				// Sinon, si la vidéo n'est pas présente en base de données,
				// on l'INSERT.
				else if (!isset($db_items_infos[$dir . $ent]) &&
				!in_array($dir . $ent, $this->_filesRename) &&
				($size = $this->_insertVideo($dir, $ent)) !== FALSE)
				{
					$cat_infos[$status_videos]++;
					$cat_infos[$status_size] += $size;
				}
			}

			// Type non pris en charge.
			else if (!isset($db_items_infos[$dir . $ent]))
			{
				$this->getReport['files_rejected'][] =
				[
					'file' => Utility::UTF8($ent),
					'dir' => $dir,
					'message' => __('Extension incorrecte.')
				];
				$this->_noFilemtime[$dir] = 1;
			}

			// Contrôle du temps d'exécution.
			if ((time() - $this->_timeControl) > $this->_timeLimit)
			{
				$this->_noFilemtime[$dir] = 1;
				$this->getTimeExceeded = TRUE;
				break;
			}
		}
		closedir($res);

		// Si le répertoire contient de nouveaux fichiers valides,
		// ou bien si le poids de certains fichiers existants a changé
		// on retourne les informations de ces fichiers.
		if ($cat_infos['a_size'] !== 0 || $cat_infos['d_size'] !== 0)
		{
			return $cat_infos;
		}

		// Sinon, si l'album n'est pas présent dans la base de données
		// on ajoute l'information au rapport.
		else if (!isset($this->_dbCategories[$cat_path]))
		{
			$this->getReport['dirs_rejected'][] =
			[
				'dir' => Utility::UTF8($dir),
				'message' => __('Le répertoire ne contient aucun fichier valide.')
			];
		}

		// Sinon on UPDATE la date de dernière modification du répertoire
		// pour éviter de le scanner la prochaine fois, mais seulement s'il
		// n'y a pas de message à propos d'une image/vidéo rejetée dans le rapport
		// et si ce n'est pas une catégorie vide créée en admin.
		else if (!isset($this->_noFilemtime[$dir])
		&& $this->_dbCategories[$cat_path]['thumb_id'] != -1)
		{
			$this->_sql['update_categories_filemtime']['params'][] =
			[
				'cat_filemtime' => $filemtime,
				'cat_path' => $cat_path
			];
		}

		unset($this->_sql['insert_items']['params'][$dir]);
		return FALSE;
	}

	/**
	 * Retourne la métadonnée $info.
	 *
	 * @param string $info
	 *   Information à récupérer.
	 *
	 * @return mixed
	 */
	private function _getMetadataInfo(string $info)
	{
		$get_data = function(string $name, string $meta): string
		{
			// Valeur XMP.
			$val = Config::$params['xmp_get_data']
				? $this->_metadata->getXmpValue($name)
				: '';
			$xmp = '';
			if ($val)
			{
				// Liste.
				if (isset($val[0]))
				{
					$xmp = implode (', ', $val);
				}

				// Langues.
				else if (isset($val['x-default']))
				{
					$xmp = $val['x-default'];
				}
			}

			// Si XMP est prioritaire sur IPTC et qu'une information XMP
			// a été trouvée, alors on retourne celle-ci.
			if ($xmp && Config::$params['xmp_priority'] && $meta == 'iptc')
			{
				return (string) $xmp;
			}

			// Si XMP n'est pas prioritaire et qu'une information alternative
			// (IPTC ou EXIF) a été trouvée, alors on retourne celle-ci.
			else
			{
				$alt = (string) (($meta == 'exif')
					? $this->_metadata->getExifValue($name)
					: (Config::$params['iptc_get_data']
						? $this->_metadata->getIptcValue($name)
						: ''));
				return $alt ? $alt : $xmp;
			}
		};

		switch ($info)
		{
			// Date de création.
			case 'datetime' :
				return ($val = $get_data($info, 'exif'))
					? $val
					: NULL;

			// Description.
			// Mots-clés.
			// Titre.
			case 'description' :
			case 'keywords' :
			case 'title' :
				return $get_data($info, 'iptc');

			// Latitude.
			// Longitude.
			case 'latitude' :
			case 'longitude' :
				return ($val = $get_data('gps_' . $info, 'exif'))
					? $val
					: NULL;

			// Fabriquant de l'appareil.
			case 'make' :
				return $this->_metadata->getExifCameraMake(
					(string) $get_data('make', 'exif')
				);

			// Modèle de l'appareil.
			case 'model' :
				return $get_data('model', 'exif');

			// Orientation.
			case 'orientation' :
				$orientation = $get_data($info, 'exif');
				return $orientation ? $orientation : '1';
		}
	}

	/**
	 * Enregistre les informations de la catégorie à ajouter.
	 *
	 * @param string $dir
	 *   Chemin du répertoire de la catégorie.
	 * @param array $cat_infos
	 *   Informations utiles de la catégorie.
	 *
	 * @return void
	 */
	private function _insertCategory(string $dir, array $cat_infos): void
	{
		// On incrémente le compteur du nombre de catégories enfants.
		if (empty($cat_infos['filemtime']) && !isset($this->_dbCategories[$dir]))
		{
			if ($this->setStatus)
			{
				if (isset($this->_subActiveCats[dirname($dir)]))
				{
					$this->_subActiveCats[dirname($dir)]++;
				}
				else
				{
					$this->_subActiveCats[dirname($dir)] = 1;
				}
			}
			else
			{
				if (isset($this->_subDeactiveCats[dirname($dir)]))
				{
					$this->_subDeactiveCats[dirname($dir)]++;
				}
				else
				{
					$this->_subDeactiveCats[dirname($dir)] = 1;
				}
			}
		}

		// Date de dernière modification.
		$cat_filemtime = empty($cat_infos['filemtime'])
			? NULL
			: (empty($this->_noFilemtime[$dir . '/'])
				? date('Y-m-d H:i:s', $cat_infos['filemtime'])
				: date('2000-m-d H:i:s', $cat_infos['filemtime']));

		// Titre.
		$cat_name = Category::getTitle($this->_catNames[basename($dir)]);

		// On ajoute l'éventuel mot de passe d'une catégorie parente.
		$password_id = NULL;
		$parent_dir = dirname($dir);
		while ($parent_dir != '.')
		{
			if (isset($this->_dbCategories[$parent_dir])
			&& $this->_dbCategories[$parent_dir]['password_id'] !== NULL)
			{
				$password_id = $this->_dbCategories[$parent_dir]['password_id'];
				break;
			}
			$parent_dir = dirname($parent_dir);
		}

		// Paramètres de la requête préparée.
		$this->_sql['insert_categories']['params'][] =
		[
			'user_id' => (int) $this->setUserId,
			'password_id' => $password_id,
			'cat_parents' => '1' . Parents::SEPARATOR,
			'parent_id' => 1,
			'cat_path' => $dir,
			'cat_name' => $cat_name,
			'cat_url' => App::getURLName($cat_name),
			'cat_a_size' => (int) $cat_infos['a_size'],
			'cat_a_subalbs' => (int) ($this->_subActiveAlbs[$dir] ?? 0),
			'cat_a_subcats' => (int) ($this->_subActiveCats[$dir] ?? 0),
			'cat_a_albums' => (int) (empty($cat_infos['filemtime']) ? $cat_infos['a_albums'] : 0),
			'cat_a_images' => (int) $cat_infos['a_images'],
			'cat_a_videos' => (int) $cat_infos['a_videos'],
			'cat_d_size' => (int) $cat_infos['d_size'],
			'cat_d_subalbs' => (int) ($this->_subDeactiveAlbs[$dir] ?? 0),
			'cat_d_subcats' => (int) ($this->_subDeactiveCats[$dir] ?? 0),
			'cat_d_albums' => (int) (empty($cat_infos['filemtime']) ? $cat_infos['d_albums'] : 0),
			'cat_d_images' => (int) $cat_infos['d_images'],
			'cat_d_videos' => (int) $cat_infos['d_videos'],
			'cat_lastpubdt' => $this->setStatus ? $this->getNow : NULL,
			'cat_filemtime' => $cat_filemtime,
			'cat_status' => (int) $this->setStatus
		];

		// On ajoute les informations pour le rapport seulement pour les albums.
		if (!empty($cat_infos['filemtime']))
		{
			$this->getReport['albums_added'][] =
			[
				'album' => $dir . '/',
				'images' => $cat_infos['a_images'] + $cat_infos['d_images'],
				'videos' => $cat_infos['a_videos'] + $cat_infos['d_videos'],
				'size' => $cat_infos['a_size'] + $cat_infos['d_size']
			];
		}
	}

	/**
	 * Enregistre les paramètres de l'image à ajouter à la base de données.
	 *
	 * @param string $album
	 *   Chemin du répertoire parent de $image.
	 * @param string $image
	 *   Image à ajouter à la base de données.
	 *
	 * @return bool|string
	 */
	private function _insertImage(string $album, string $image)
	{
		$file = $this->_albumsPath . '/' . $album . $image;

		// On vérifie que l'image est valide.
		if (!$i = Image::getTypeSize($file))
		{
			if ($this->setReportAllFiles)
			{
				$this->getReport['files_rejected'][] =
				[
					'file' => Utility::UTF8($image),
					'dir' => $album,
					'message' => __('Image non valide.')
				];
				$this->_noFilemtime[$album] = 1;
			}
			return FALSE;
		}

		// Poids du fichier.
		if (!$image_filesize = (int) filesize($file))
		{
			$this->getReport['files_rejected'][] =
			[
				'file' => Utility::UTF8($image),
				'dir' => $album,
				'message' => __('Fichier vide.')
			];
			$this->_noFilemtime[$album] = 1;
			return FALSE;
		}

		// Doit-on limiter le poids du fichier ?
		if ($this->setMaxImageFileSize && ($image_filesize > $this->setMaxImageFileSize))
		{
			$message = sprintf(
				__('Le poids du fichier (%s) dépasse la limite autorisée (%s).'),
				L10N::formatFilesize($image_filesize),
				L10N::formatFilesize($this->setMaxImageFileSize)
			);
			$this->getReport['files_rejected'][] =
			[
				'file' => Utility::UTF8($image),
				'dir' => $album,
				'message' => $message
			];
			$this->_noFilemtime[$album] = 1;
			return FALSE;
		}

		// Vérification du type de fichier.
		$item_type = Item::getTypeCode($file, $mime, TRUE);
		if (!in_array($item_type, $this->_types['image']['app']))
		{
			$this->getReport['files_rejected'][] =
			[
				'file' => Utility::UTF8($image),
				'dir' => $album,
				'message' => $mime
					? sprintf(__('Type de fichier non accepté (%s).'), $mime)
					: __('Type de fichier inconnu.')
			];
			$this->_noFilemtime[$album] = 1;
			return FALSE;
		}
		$item_type = $item_type < 1 ? Item::TYPE_JPEG : $item_type;

		// Vérification du nom de fichier.
		if (($file_name = $this->_checkFileName($image, $album)) === FALSE)
		{
			$this->_noFilemtime[$album] = 1;
			return FALSE;
		}
		$image_path = $this->_albumsPath . '/' . $album . $file_name;

		// Identifiant de l'album.
		$cat_id = 1;
		if (isset($this->_dbCategories[$album]))
		{
			$cat_id = $this->_dbCategories[$album]['cat_id'];
		}

		// Identifiant de l'utilisateur.
		$user_id = ($this->setHttp && isset($this->setHttpFiles[$image]['user_id']))
			? $this->setHttpFiles[$image]['user_id']
			: $this->setUserId;

		// Récupération des métadonnées.
		$image_exif = NULL;
		$image_iptc = NULL;
		$image_xmp = NULL;
		$this->_metadata = NULL;
		if ($this->setHttp)
		{
			// Récupération des métadonnées dans le fichier original.
			if (is_dir($this->setHttpOriginalDir))
			{
				$file_original = $this->setHttpOriginalDir . '/' . $image;
				if (file_exists($file_original))
				{
					$this->_metadata = new Metadata($file_original);
					$image_exif = Utility::jsonEncode($this->_metadata->getExifData());
					$image_iptc = Utility::jsonEncode($this->_metadata->getIptcData());
					$image_xmp = $this->_metadata->getXmpData();
				}
			}

			// Métadonnées fournies.
			else
			{
				$data = [];
				foreach (['exif', 'iptc', 'xmp'] as &$m)
				{
					if (!empty($this->setHttpFiles[$image]['item_' . $m])
					&& ($arr = Utility::jsonDecode($this->setHttpFiles[$image]['item_' . $m])))
					{
						$data[$m] = $arr;
					}
				}
				if ($data)
				{
					$this->_metadata = new Metadata('', $data);
					$image_exif = Utility::jsonEncode($this->_metadata->getExifData());
					$image_iptc = Utility::jsonEncode($this->_metadata->getIptcData());
					$image_xmp = $this->_metadata->getXmpData();
				}
			}
		}
		if ($this->_metadata === NULL)
		{
			$this->_metadata = new Metadata($image_path);
		}

		// Orientation.
		$orientation = (isset($file_original)
			&& file_exists($file_original . '.rotate'))
			|| (isset($this->setHttpFiles[$image])
			&& !empty($this->setHttpFiles[$image]['rotate']))
			? 1
			: $this->_getMetadataInfo('orientation');

		// Titre.
		if (Utility::isEmpty($image_name = $this->_getMetadataInfo('title')))
		{
			$image_name = Item::getTitle($image);
		}

		// Description.
		$image_desc = $this->_getMetadataInfo('description');
		$image_desc = Utility::isEmpty($image_desc) ? NULL : Utility::trimAll($image_desc);

		// Tables de métadonnées.
		$this->_metadataTables($album . $file_name);

		// Paramètres de la requête préparée.
		$this->_sql['insert_items']['params'][$album][] =
		[
			'cat_id' => (int) $cat_id,
			'item_type' => $item_type,
			'user_id' => (int) $user_id,
			'item_path' => $album . $file_name,
			'item_url' => App::getURLName($image_name),
			'item_height' => $i['height'],
			'item_width' => $i['width'],
			'item_filesize' => (int) $image_filesize,
			'item_exif' => $image_exif,
			'item_iptc' => $image_iptc,
			'item_xmp' => $image_xmp,
			'item_orientation' => $orientation,
			'item_lat' => $this->_getMetadataInfo('latitude'),
			'item_long' => $this->_getMetadataInfo('longitude'),
			'item_name' => Utility::trimAll($image_name),
			'item_desc' => $image_desc,
			'item_pubdt' => $this->setStatus ? $this->getNow : NULL,
			'item_crtdt' => $this->_getMetadataInfo('datetime'),
			'item_status' => $this->setStatus ? '1' : '-1'
		];

		$this->getReport['images_added']++;

		return $image_filesize;
	}

	/**
	 * Enregistre les paramètres de la vidéo à ajouter à la base de données.
	 *
	 * @param string $album
	 *   Chemin du répertoire parent de $video.
	 * @param string $video
	 *   Vidéo à ajouter à la base de données.
	 *
	 * @return bool|string
	 */
	private function _insertVideo(string $album, string $video)
	{
		$file = $this->_albumsPath . '/' . $album . $video;

		// Poids du fichier.
		if (!$filesize = (int) filesize($file))
		{
			$this->getReport['files_rejected'][] =
			[
				'file' => Utility::UTF8($video),
				'dir' => $album,
				'message' => __('Fichier vide.')
			];
			$this->_noFilemtime[$album] = 1;
			return FALSE;
		}

		// Doit-on limiter le poids du fichier ?
		if ($this->setMaxVideoFileSize && ($filesize > $this->setMaxVideoFileSize))
		{
			$message = sprintf(
				__('Le poids du fichier (%s) dépasse la limite autorisée (%s).'),
				L10N::formatFilesize($filesize),
				L10N::formatFilesize($this->setMaxVideoFileSize)
			);
			$this->getReport['files_rejected'][] =
			[
				'file' => Utility::UTF8($video),
				'dir' => $album,
				'message' => $message
			];
			$this->_noFilemtime[$album] = 1;
			return FALSE;
		}

		// Vérification du type de fichier.
		$item_type = Item::getTypeCode($file, $mime, function_exists('mime_content_type'));
		if (!in_array($item_type, $this->_types['video']['app']))
		{
			$this->getReport['files_rejected'][] =
			[
				'file' => Utility::UTF8($video),
				'dir' => $album,
				'message' => $mime
					? sprintf(__('Type de fichier non accepté (%s).'), $mime)
					: __('Type de fichier inconnu.')
			];
			$this->_noFilemtime[$album] = 1;
			return FALSE;
		}
		$item_type = $item_type < 1 ? Item::TYPE_MP4 : $item_type;

		// Vérification du nom de fichier.
		if (($file_name = $this->_checkFileName($video, $album)) === FALSE)
		{
			$this->_noFilemtime[$album] = 1;
			return FALSE;
		}
		$video_path = $this->_albumsPath . '/' . $album . $file_name;

		// Identifiant de l'album.
		$cat_id = 1;
		if (isset($this->_dbCategories[$album]))
		{
			$cat_id = $this->_dbCategories[$album]['cat_id'];
		}

		// Identifiant de l'utilisateur.
		$user_id = ($this->setHttp && isset($this->setHttpFiles[$video]['user_id']))
			? $this->setHttpFiles[$video]['user_id']
			: $this->setUserId;

		// Titre de la vidéo.
		$video_name = Item::getTitle($video);

		// Paramètres de la requête préparée.
		$this->_sql['insert_items']['params'][$album][] =
		[
			'cat_id' => (int) $cat_id,
			'item_type' => $item_type,
			'user_id' => (int) $user_id,
			'item_path' => $album . $file_name,
			'item_url' => App::getURLName($video_name),
			'item_height' => 0,
			'item_width' => 0,
			'item_filesize' => (int) $filesize,
			'item_exif' => NULL,
			'item_iptc' => NULL,
			'item_xmp' => NULL,
			'item_orientation' => '1',
			'item_lat' => NULL,
			'item_long' => NULL,
			'item_name' => $video_name,
			'item_desc' => NULL,
			'item_pubdt' => $this->setStatus ? $this->getNow : NULL,
			'item_crtdt' => NULL,
			'item_status' => $this->setStatus ? '1' : '-1'
		];

		$this->getReport['videos_added']++;

		return $filesize;
	}

	/**
	 * Prépare les informations de l'image $image_path
	 * destinées à remplir les tables de métadonnées (tags, cameras).
	 *
	 * @param string $image_path
	 *   Chemin de l'image.
	 *
	 * @return void
	 */
	private function _metadataTables(string $image_path): void
	{
		// Mots-clés.
		$keywords = $this->_getMetadataInfo('keywords');
		if (!empty($keywords))
		{
			$keywords = explode(', ', $keywords);
			foreach ($keywords as &$keyword)
			{
				if (!isset($this->_keywordsImages[$keyword_lower = mb_strtolower($keyword)]))
				{
					$this->_keywordsImages[$keyword_lower] = ['name' => $keyword, 'files' => []];
				}
				if (!in_array($image_path, $this->_keywordsImages[$keyword_lower]['files']))
				{
					$this->_keywordsImages[$keyword_lower]['files'][] = $image_path;
				}
			}
		}

		// Fabriquant et modèle de l'appareil.
		$make = $this->_getMetadataInfo('make');
		$model = $this->_getMetadataInfo('model');
		if (!empty($make) && !empty($model))
		{
			if (!isset($this->_camerasImages[$make]))
			{
				$this->_camerasImages[$make] = [];
			}
			if (!isset($this->_camerasImages[$make][$model]))
			{
				$this->_camerasImages[$make][$model] = [];
			}
			$this->_camerasImages[$make][$model][] = $image_path;
		}
	}

	/**
	 * Renomme un fichier ou un répertoire, si nécessaire.
	 *
	 * @param string $f
	 *   Nom du fichier ou du répertoire à renommer.
	 * @param string $dir
	 *   Chemin du répertoire parent de $f.
	 * @param string $type
	 *   Répertoire (dir) ou fichier (file) ?
	 *
	 * @return bool|string
	 */
	private function _renameFile(string $f, string $dir, string $type)
	{
		$path = $this->_albumsPath . '/' . $dir;

		// Nouveau nom de fichier.
		$new_f = ($type == 'dir')
			? App::getValidDirname($f)
			: App::getValidFilename($f);
		if ($new_f != $f)
		{
			$new_f = File::getSecureFilename($new_f, $path);
		}

		// S'il n'y a pas besoin de renommer le nom de fichier
		// ou de répertoire, inutile d'aller plus loin.
		if ($f == $new_f)
		{
			return $f;
		}

		// On renomme, sauf en mode simulation.
		if ($this->setSimulate)
		{
			return $f;
		}
		else if (!file_exists($path . $new_f) && File::rename($path . $f, $path . $new_f))
		{
			$this->_filesRename[] = $dir . $new_f;
			return $new_f;
		}
		else
		{
			$this->getReport['files_errors'][] =
			[
				'file' => $dir . $f,
				'message' => __('Renommage impossible.')
			];
			return FALSE;
		}
	}

	/**
	 * Enregistre les informations de la catégorie à updater.
	 *
	 * @param string $dir
	 *   Chemin du répertoire de la catégorie.
	 * @param array $cat_infos
	 *   Informations utiles de la catégorie.
	 *
	 * @return void
	 */
	private function _updateCategory(string $dir, array $cat_infos): void
	{
		// Date de dernière modification.
		$cat_filemtime = empty($cat_infos['filemtime'])
			? NULL
			: (empty($this->_noFilemtime[$dir . '/'])
				? date('Y-m-d H:i:s', $cat_infos['filemtime'])
				: date('2000-m-d H:i:s', $cat_infos['filemtime']));

		// Date de publication du dernier fichier.
		$cat_lastpubdt = ($cat_infos['a_images'] + $cat_infos['a_videos']) > 0
			? "cat_lastpubdt = '{$this->getNow}', "
			: '';

		// S'il n'y a aucun nouveau fichier activé, on ne touche pas à l'état,
		// sinon on force l'état sur 'activé'.
		$cat_status = ($cat_infos['a_images'] + $cat_infos['a_videos']) > 0
			? 'cat_status = "1", '
			: '';

		// Paramètres de la requête SQL.
		$this->_sql['update_categories'][] =
		[
			'dir' => $dir,
			'cat_filemtime' => $cat_filemtime,
			'cat_lastpubdt' => $cat_lastpubdt,
			'cat_status' => $cat_status,
			'items_infos' => $cat_infos
		];

		// Si c'est une catégorie désactivée et qu'au moins une image/vidéo activée
		// y a été ajoutée, on ajoute 1 au nombre de sous-catégries activées,
		// et on retire 1 au nombre de sous-catégories désactivées pour la
		// catégorie parente.
		if ($dir !== '.' && $this->_dbCategories[$dir]['cat_status'] == 0
		&& ($cat_infos['a_images'] + $cat_infos['a_videos']) > 0
		&& empty($cat_infos['filemtime']))
		{
			if (isset($this->_subActiveCats[dirname($dir)]))
			{
				$this->_subActiveCats[dirname($dir)]++;
			}
			else
			{
				$this->_subActiveCats[dirname($dir)] = 1;
			}
			if (isset($this->_subDeactiveCats[dirname($dir)]))
			{
				$this->_subDeactiveCats[dirname($dir)]--;
			}
			else
			{
				$this->_subDeactiveCats[dirname($dir)] = -1;
			}
		}

		// On ajoute les informations pour le rapport seulement pour les albums.
		if ($dir !== '.' && !empty($cat_infos['filemtime']))
		{
			$this->getReport['albums_updated'][] =
			[
				'album' => $dir . '/',
				'images' => $cat_infos['a_images'] + $cat_infos['d_images'],
				'videos' => $cat_infos['a_videos'] + $cat_infos['d_videos'],
				'size' => $cat_infos['a_size'] + $cat_infos['d_size']
			];
		}
	}

	/**
	 * Mise à jour des informations d'une image.
	 *
	 * @param string $album
	 *   Chemin du répertoire parent.
	 * @param string $image
	 *   Nom de fichier à vérifier.
	 * @param array $db_infos
	 *   Informations provenant de la base de données.
	 *
	 * @return bool|array
	 */
	private function _updateImage(string $album, string $image, array $db_infos)
	{
		// Si l'option de mise à jour des images est désactivée, on arrête là.
		if (!$this->setUpdateFiles)
		{
			return FALSE;
		}

		// Informations à mettre à jour pour les catégories parentes.
		$updade_infos = ['a_size' => 0, 'd_size' => 0];

		// Récupération du poids et des dimensions de l'image.
		$file = $this->_albumsPath . '/' . $album . $image;
		if (!$image_size = Image::getTypeSize($file))
		{
			return FALSE;
		}
		if (($filesize = filesize($file)) === FALSE)
		{
			return FALSE;
		}
		$filesize = (int) $filesize;

		// Type de fichier.
		$type = Item::getTypeCode($file);
		$type = $type < 1 ? Item::TYPE_JPEG : $type;

		// Récupération des métadonnées.
		$this->_metadata = new Metadata($file);

		// Titre.
		if (Utility::isEmpty($image_name = $this->_getMetadataInfo('title')))
		{
			$image_name = $this->setUpdateKeepData
				? $db_infos['item_name']
				: Item::getTitle($image);
		}

		// Description.
		if (Utility::isEmpty($image_desc = $this->_getMetadataInfo('description')))
		{
			$image_desc = $this->setUpdateKeepData ? $db_infos['item_desc'] : NULL;
		}

		// Date de création.
		if (!$image_crtdt = $this->_getMetadataInfo('datetime'))
		{
			$image_crtdt = $this->setUpdateKeepData ? $db_infos['item_crtdt'] : NULL;
		}

		// Latitude.
		if (!$image_latitude = $this->_getMetadataInfo('latitude'))
		{
			$image_latitude = $this->setUpdateKeepData ? $db_infos['item_lat'] : NULL;
		}

		// Longitude.
		if (!$image_longitude = $this->_getMetadataInfo('longitude'))
		{
			$image_longitude = $this->setUpdateKeepData ? $db_infos['item_long'] : NULL;
		}

		// Orientation.
		$image_orientation = $this->_getMetadataInfo('orientation');

		// Suppression des tags ?
		if (!$this->setUpdateKeepData)
		{
			$this->_keywordsImagesDelete[] = $db_infos['item_id'];
		}

		// Tables de métadonnées.
		$this->_metadataTables($album . $image);

		// Les informations de l'image sont-elles différentes
		// de celles enregistrées en base de données ?
		if ($type != $db_infos['item_type']
		|| $filesize != $db_infos['item_filesize']
		|| $image_size['width'] != $db_infos['item_width']
		|| $image_size['height'] != $db_infos['item_height']
		|| $image_orientation != $db_infos['item_orientation']
		|| $image_name != $db_infos['item_name']
		|| $image_desc !== $db_infos['item_desc']
		|| $image_latitude != $db_infos['item_lat']
		|| $image_longitude != $db_infos['item_long']
		|| $image_crtdt != $db_infos['item_crtdt'])
		{
			$diff_filesize = $filesize - $db_infos['item_filesize'];

			if ($db_infos['item_status'] == '1')
			{
				$updade_infos['a_size'] += $diff_filesize;
			}
			else
			{
				$updade_infos['d_size'] += $diff_filesize;
			}

			// Paramètres de la requête préparée.
			$this->_sql['update_items']['params'][] =
			[
				'item_id' => $db_infos['item_id'],
				'item_type' => $type,
				'item_width' => $image_size['width'],
				'item_height' => $image_size['height'],
				'item_filesize' => (int) $filesize,
				'item_orientation' => $image_orientation,
				'item_lat' => $image_latitude,
				'item_long' => $image_longitude,
				'item_name' => $image_name,
				'item_url' => App::getURLName($image_name),
				'item_desc' => $image_desc,
				'item_crtdt' => $image_crtdt
			];

			$this->getReport['images_updated']++;
		}

		$this->_updateItemPath[$db_infos['item_path']] = $db_infos['item_id'];
		return $updade_infos;
	}

	/**
	 * Mise à jour des informations d'une vidéo.
	 *
	 * @param string $album
	 *   Chemin du répertoire parent.
	 * @param string $video
	 *   Nom de fichier à vérifier.
	 * @param array $db_infos
	 *   Informations provenant de la base de données.
	 *
	 * @return bool|array
	 */
	private function _updateVideo(string $album, string $video, array $db_infos)
	{
		// Si l'option de mise à jour des fichiers est désactivée, on arrête là.
		if (!$this->setUpdateFiles)
		{
			return FALSE;
		}

		// Informations à mettre à jour pour les catégories parentes.
		$updade_infos = ['a_size' => 0, 'd_size' => 0];

		// Récupération du poids de la vidéo.
		$file = $this->_albumsPath . '/' . $album . $video;
		if (($filesize = filesize($file)) === FALSE)
		{
			return FALSE;
		}
		$filesize = (int) $filesize;

		// Type de fichier.
		$type = Item::getTypeCode($file);
		$type = $type < 1 ? Item::TYPE_MP4 : $type;

		// Les informations de la vidéo sont-elles différentes
		// de celles enregistrées en base de données ?
		if ($type != $db_infos['item_type'] || $filesize != $db_infos['item_filesize'])
		{
			$diff_filesize = $filesize - $db_infos['item_filesize'];

			if ($db_infos['item_status'] == '1')
			{
				$updade_infos['a_size'] += $diff_filesize;
			}
			else
			{
				$updade_infos['d_size'] += $diff_filesize;
			}

			// Paramètres de la requête préparée.
			$this->_sql['update_items']['params'][] =
			[
				'item_id' => $db_infos['item_id'],
				'item_type' => $type,
				'item_width' => $db_infos['item_width'],
				'item_height' => $db_infos['item_height'],
				'item_filesize' => (int) $filesize,
				'item_orientation' => $db_infos['item_orientation'],
				'item_lat' => $db_infos['item_lat'],
				'item_long' => $db_infos['item_long'],
				'item_name' => $db_infos['item_name'],
				'item_url' => $db_infos['item_url'],
				'item_desc' => $db_infos['item_desc'],
				'item_crtdt' => $db_infos['item_crtdt']
			];

			$this->getReport['videos_updated']++;
		}

		$this->_updateItemPath[$db_infos['item_path']] = $db_infos['item_id'];
		return $updade_infos;
	}
}
?>