added
packages/regine-cli/src/regine_cli/import_cmd.py +366 -0 | new file mode 100644 | ||
| @@ -0,0 +1,366 @@ | ||
| 1 | +"""Commande `regine import` (cf. contracts/cli-import.md). | |
| 2 | + | |
| 3 | +Façade fine : orchestre `regine_core.import_carte`, formatte le résultat, ne | |
| 4 | +contient aucune logique métier propre (Principe VI de la constitution). | |
| 5 | +""" | |
| 6 | + | |
| 7 | +from __future__ import annotations | |
| 8 | + | |
| 9 | +import argparse | |
| 10 | +import sqlite3 | |
| 11 | +from pathlib import Path | |
| 12 | + | |
| 13 | +from regine_core.camera_profile.db import list_boitiers | |
| 14 | +from regine_core.camera_profile.resolve import assign_manual_source | |
| 15 | +from regine_core.config.categories import ( | |
| 16 | + list_known_categories, | |
| 17 | + register_category_usage, | |
| 18 | + suggest_categories, | |
| 19 | +) | |
| 20 | +from regine_core.config.db import open_context_db | |
| 21 | +from regine_core.dossier.root import RootLocation | |
| 22 | +from regine_core.import_carte.copie import copier_carte, resoudre_collisions_boitiers | |
| 23 | +from regine_core.import_carte.destination import ( | |
| 24 | + lister_dossiers_candidats, | |
| 25 | + resoudre_destination, | |
| 26 | + resoudre_fusion, | |
| 27 | +) | |
| 28 | +from regine_core.import_carte.groupage import ( | |
| 29 | + decouper_en_groupes, | |
| 30 | + detacher_jours, | |
| 31 | + jours_candidats_au_detachement, | |
| 32 | +) | |
| 33 | +from regine_core.import_carte.identifiant import attribuer_identifiants | |
| 34 | +from regine_core.import_carte.nommage import ( | |
| 35 | + construire_nom_dossier, | |
| 36 | + construire_nom_dossier_parent, | |
| 37 | + construire_nom_sous_dossier, | |
| 38 | + renommer_fichiers, | |
| 39 | + resoudre_collision_nom, | |
| 40 | +) | |
| 41 | +from regine_core.import_carte.push import archiver, preparer_resume | |
| 42 | +from regine_core.import_carte.types import DestinationChoisie, FichierCandidat, GroupeImport | |
| 43 | + | |
| 44 | +_NOM_CONTEXTE_DB_PAR_DEFAUT = ".regine-contexte.sqlite3" | |
| 45 | + | |
| 46 | + | |
| 47 | +def _proposer_detachement(groupes: list[GroupeImport], interactif: bool) -> list[GroupeImport]: | |
| 48 | + """Propose le détachement d'un jour candidat (US2, FR-006) — jamais automatique.""" | |
| 49 | + if not interactif or len(groupes) != 1: | |
| 50 | + return groupes | |
| 51 | + (groupe_initial,) = groupes | |
| 52 | + candidats = jours_candidats_au_detachement(groupe_initial.fichiers) | |
| 53 | + for jour in candidats: | |
| 54 | + reponse = input( | |
| 55 | + f"Le {jour} se distingue nettement du reste : le détacher en groupe séparé ? [o/n] " | |
| 56 | + ) | |
| 57 | + if reponse.strip().lower() == "o": | |
| 58 | + return detacher_jours(groupe_initial, [jour]) | |
| 59 | + return groupes | |
| 60 | + | |
| 61 | + | |
| 62 | +def _resoudre_etiquetage_manuel( | |
| 63 | + a_etiqueter: list[list[FichierCandidat]], *, conn: sqlite3.Connection, interactif: bool | |
| 64 | +) -> None: | |
| 65 | + """Résout les groupes de fichiers encore ambigus après résolution automatique | |
| 66 | + (FR-005/FR-015) en demandant à l'utilisateur d'étiqueter chaque groupe — jamais | |
| 67 | + résolu arbitrairement. Assigne `boitier_id` en place sur chaque fichier.""" | |
| 68 | + if not a_etiqueter: | |
| 69 | + return | |
| 70 | + if not interactif: | |
| 71 | + noms = ", ".join(f.nom_origine or "?" for groupe in a_etiqueter for f in groupe) | |
| 72 | + raise RuntimeError( | |
| 73 | + f"Des fichiers en collision nécessitent un étiquetage manuel de boîtier " | |
| 74 | + f"({noms}) — impossible en mode --yes, relancer sans ce flag." | |
| 75 | + ) | |
| 76 | + | |
| 77 | + for groupe in a_etiqueter: | |
| 78 | + noms = ", ".join(f.nom_origine or "?" for f in groupe) | |
| 79 | + print(f"Collision non résolue automatiquement pour : {noms}") | |
| 80 | + boitiers_connus = list_boitiers(conn) | |
| 81 | + for idx, boitier in enumerate(boitiers_connus, start=1): | |
| 82 | + libelle = boitier.nom_lisible or boitier.modele or f"boîtier #{boitier.id}" | |
| 83 | + print(f" [{idx}] {libelle}") | |
| 84 | + print(" [n] Nouveau boîtier") | |
| 85 | + reponse = input("Choix : ").strip() | |
| 86 | + if reponse.isdigit() and 1 <= int(reponse) <= len(boitiers_connus): | |
| 87 | + boitier_id = boitiers_connus[int(reponse) - 1].id | |
| 88 | + else: | |
| 89 | + modele = input("Modèle de ce boîtier (optionnel) : ").strip() or None | |
| 90 | + boitier_id = assign_manual_source( | |
| 91 | + conn, [f.chemin_source for f in groupe], modele=modele | |
| 92 | + ) | |
| 93 | + for f in groupe: | |
| 94 | + f.boitier_id = boitier_id | |
| 95 | + | |
| 96 | + | |
| 97 | +def _choisir_categorie(*, conn: sqlite3.Connection, interactif: bool) -> str | None: | |
| 98 | + """Demande la catégorie thématique du dossier/dossier parent (FR-003), avec | |
| 99 | + suggestion des catégories déjà connues — ou aucune (année par défaut).""" | |
| 100 | + if not interactif: | |
| 101 | + return None | |
| 102 | + connues = list_known_categories(conn) | |
| 103 | + if connues: | |
| 104 | + print(f"Catégories connues : {', '.join(connues)}") | |
| 105 | + saisie = input("Catégorie (vide = année par défaut) : ").strip() | |
| 106 | + if not saisie: | |
| 107 | + return None | |
| 108 | + suggestions = [s for s in suggest_categories(conn, saisie) if s.lower() != saisie.lower()] | |
| 109 | + if suggestions: | |
| 110 | + print(f"Vouliez-vous dire : {', '.join(suggestions)} ? (saisie conservée telle quelle)") | |
| 111 | + register_category_usage(conn, saisie) | |
| 112 | + return saisie | |
| 113 | + | |
| 114 | + | |
| 115 | +def _root_location_depuis_chemin( | |
| 116 | + chemin_relatif: str, *, archive_root: Path, local_root: Path | |
| 117 | +) -> RootLocation: | |
| 118 | + """Reconstruit le `RootLocation` d'un dossier parent déjà existant (créé lors | |
| 119 | + d'un import précédent) à partir de son premier segment de chemin relatif — ce | |
| 120 | + segment est soit une année, soit une catégorie (FR-006 de specs/004).""" | |
| 121 | + premier_segment = Path(chemin_relatif).parts[0] | |
| 122 | + type_ = "annee" if premier_segment.isdigit() else "categorie" | |
| 123 | + return RootLocation( | |
| 124 | + type=type_, | |
| 125 | + nom=premier_segment, | |
| 126 | + chemin_archive=archive_root / premier_segment, | |
| 127 | + chemin_local=local_root / premier_segment, | |
| 128 | + ) | |
| 129 | + | |
| 130 | + | |
| 131 | +def _choisir_dossier_existant( | |
| 132 | + titre_partiel: str, *, archive_root: Path, local_root: Path, interactif: bool, arg: str | None | |
| 133 | +) -> str: | |
| 134 | + """Détermine le chemin relatif (depuis `archive_root`/`local_root`) d'un dossier | |
| 135 | + parent ou de fusion existant — soit imposé par un flag `sous-dossier:CHEMIN` / | |
| 136 | + `fusion:CHEMIN`, soit choisi interactivement parmi les candidats (FR-008).""" | |
| 137 | + if arg: | |
| 138 | + return arg | |
| 139 | + candidats = lister_dossiers_candidats(titre_partiel, [local_root, archive_root]) | |
| 140 | + if candidats and interactif: | |
| 141 | + print("Dossiers candidats :") | |
| 142 | + for idx, candidat in enumerate(candidats, start=1): | |
| 143 | + print(f" [{idx}] {candidat}") | |
| 144 | + reponse = input("Choix (numéro, ou chemin relatif manuel) : ").strip() | |
| 145 | + if reponse.isdigit() and 1 <= int(reponse) <= len(candidats): | |
| 146 | + candidat = candidats[int(reponse) - 1] | |
| 147 | + racine = local_root if local_root in candidat.parents else archive_root | |
| 148 | + return str(candidat.relative_to(racine)) | |
| 149 | + return reponse | |
| 150 | + return input("Chemin relatif du dossier (ex. voyage/2026-08_Montenegro) : ").strip() | |
| 151 | + | |
| 152 | + | |
| 153 | +def _resoudre_type_destination( | |
| 154 | + destination_arg: str | None, *, interactif: bool | |
| 155 | +) -> tuple[str, str | None]: | |
| 156 | + """Traduit `--destination` (contract CLI : `nouveau|parent|sous-dossier:ID|fusion:ID`) | |
| 157 | + ou, en son absence en mode interactif, une question posée à l'utilisateur (FR-007).""" | |
| 158 | + if destination_arg: | |
| 159 | + if ":" in destination_arg: | |
| 160 | + type_brut, id_ = destination_arg.split(":", 1) | |
| 161 | + else: | |
| 162 | + type_brut, id_ = destination_arg, None | |
| 163 | + return { | |
| 164 | + "nouveau": "nouveau_dossier", | |
| 165 | + "parent": "nouveau_parent", | |
| 166 | + "sous-dossier": "nouveau_sous_dossier", | |
| 167 | + "fusion": "fusion", | |
| 168 | + }[type_brut], id_ | |
| 169 | + | |
| 170 | + if not interactif: | |
| 171 | + return "nouveau_dossier", None | |
| 172 | + | |
| 173 | + print( | |
| 174 | + "Destination : [1] Nouveau dossier [2] Nouveau dossier parent (voyage) " | |
| 175 | + "[3] Nouveau sous-dossier d'un parent existant [4] Fusion dans un dossier existant" | |
| 176 | + ) | |
| 177 | + choix = input("Choix [1] : ").strip() or "1" | |
| 178 | + return { | |
| 179 | + "1": "nouveau_dossier", | |
| 180 | + "2": "nouveau_parent", | |
| 181 | + "3": "nouveau_sous_dossier", | |
| 182 | + "4": "fusion", | |
| 183 | + }.get(choix, "nouveau_dossier"), None | |
| 184 | + | |
| 185 | + | |
| 186 | +def _traiter_groupe( | |
| 187 | + groupe: GroupeImport, | |
| 188 | + *, | |
| 189 | + local_tmp: Path, | |
| 190 | + archive_root: Path, | |
| 191 | + local_root: Path, | |
| 192 | + titre_impose: str | None, | |
| 193 | + categorie_imposee: str | None, | |
| 194 | + annee_imposee: bool, | |
| 195 | + destination_arg: str | None, | |
| 196 | + conn: sqlite3.Connection, | |
| 197 | + interactif: bool, | |
| 198 | +) -> None: | |
| 199 | + titre = titre_impose or input("Titre du groupe : ") | |
| 200 | + groupe.titre = titre | |
| 201 | + | |
| 202 | + type_destination, id_cible = _resoudre_type_destination(destination_arg, interactif=interactif) | |
| 203 | + | |
| 204 | + destination: DestinationChoisie | |
| 205 | + if type_destination in ("nouveau_dossier", "nouveau_parent"): | |
| 206 | + categorie = categorie_imposee if not annee_imposee else None | |
| 207 | + if categorie is None and not annee_imposee: | |
| 208 | + categorie = _choisir_categorie(conn=conn, interactif=interactif) | |
| 209 | + destination = resoudre_destination( | |
| 210 | + groupe, | |
| 211 | + type_destination, | |
| 212 | + archive_root=archive_root, | |
| 213 | + local_root=local_root, | |
| 214 | + categorie=categorie, | |
| 215 | + ) | |
| 216 | + root = destination.root_location | |
| 217 | + assert root is not None | |
| 218 | + nom_dossier = ( | |
| 219 | + construire_nom_dossier_parent(groupe.plage_dates[0], titre) | |
| 220 | + if type_destination == "nouveau_parent" | |
| 221 | + else construire_nom_dossier(groupe, titre) | |
| 222 | + ) | |
| 223 | + dossier_local = resoudre_collision_nom(root.chemin_local / nom_dossier) | |
| 224 | + dossier_archive = root.chemin_archive / dossier_local.name | |
| 225 | + | |
| 226 | + elif type_destination == "nouveau_sous_dossier": | |
| 227 | + chemin_parent = _choisir_dossier_existant( | |
| 228 | + titre, | |
| 229 | + archive_root=archive_root, | |
| 230 | + local_root=local_root, | |
| 231 | + interactif=interactif, | |
| 232 | + arg=id_cible, | |
| 233 | + ) | |
| 234 | + # `root_parent` ne porte que la racine héritée (année/catégorie) ; le | |
| 235 | + # sous-dossier d'étape doit lui être physiquement imbriqué, sous le | |
| 236 | + # dossier parent réel (ex. voyage/2026-08_Montenegro/2026-08-14_Kotor), | |
| 237 | + # pas simplement sous la racine catégorie. | |
| 238 | + root_parent = _root_location_depuis_chemin( | |
| 239 | + chemin_parent, archive_root=archive_root, local_root=local_root | |
| 240 | + ) | |
| 241 | + lieu = None if titre_impose else input("Lieu de cette étape (optionnel) : ").strip() or None | |
| 242 | + nom_dossier = construire_nom_sous_dossier(groupe, titre, lieu=lieu) | |
| 243 | + dossier_local = resoudre_collision_nom(local_root / chemin_parent / nom_dossier) | |
| 244 | + dossier_archive = archive_root / chemin_parent / dossier_local.name | |
| 245 | + destination = resoudre_destination( | |
| 246 | + groupe, | |
| 247 | + "nouveau_sous_dossier", | |
| 248 | + archive_root=archive_root, | |
| 249 | + local_root=local_root, | |
| 250 | + root_parent=root_parent, | |
| 251 | + dossier_cible=dossier_local, | |
| 252 | + ) | |
| 253 | + | |
| 254 | + else: # fusion | |
| 255 | + chemin_cible = _choisir_dossier_existant( | |
| 256 | + titre, | |
| 257 | + archive_root=archive_root, | |
| 258 | + local_root=local_root, | |
| 259 | + interactif=interactif, | |
| 260 | + arg=id_cible, | |
| 261 | + ) | |
| 262 | + destination = resoudre_fusion( | |
| 263 | + chemin_cible, archive_root=archive_root, local_root=local_root | |
| 264 | + ) | |
| 265 | + assert destination.dossier_cible is not None | |
| 266 | + dossier_local = destination.dossier_cible | |
| 267 | + dossier_archive = archive_root / chemin_cible | |
| 268 | + if destination.necessite_checkout_archive: | |
| 269 | + print(f"Checkout automatique depuis l'archive : {dossier_archive} -> {dossier_local}") | |
| 270 | + | |
| 271 | + renommer_fichiers(groupe.fichiers, groupe.plage_dates[0], titre) | |
| 272 | + | |
| 273 | + chemins_maitres = [f.chemin_source for f in groupe.fichiers if f.type == "maitre"] | |
| 274 | + attribuer_identifiants(chemins_maitres) | |
| 275 | + | |
| 276 | + chemins = [f.chemin_source for f in groupe.fichiers] | |
| 277 | + resume = preparer_resume(chemins, dossier_archive) | |
| 278 | + print( | |
| 279 | + f"Résumé : {resume.nombre_fichiers} fichier(s), {resume.taille_totale} octet(s) " | |
| 280 | + f"-> {resume.dossier_destination}" | |
| 281 | + ) | |
| 282 | + | |
| 283 | + if interactif: | |
| 284 | + confirmation = input("Confirmer l'archivage ? [o/n] ") | |
| 285 | + if confirmation.strip().lower() != "o": | |
| 286 | + print("Archivage annulé pour ce groupe.") | |
| 287 | + return | |
| 288 | + | |
| 289 | + archiver(chemins, local_tmp, dossier_archive) | |
| 290 | + print(f"Archivé : {dossier_archive}") | |
| 291 | + | |
| 292 | + | |
| 293 | +def _cmd_import(args: argparse.Namespace) -> int: | |
| 294 | + carte = Path(args.carte) | |
| 295 | + local_tmp = Path(args.local_tmp) if args.local_tmp else Path.cwd() / ".regine-import-tmp" | |
| 296 | + archive_root = Path(args.archive_root) | |
| 297 | + local_root = Path(args.local_root) | |
| 298 | + contexte_db = ( | |
| 299 | + Path(args.contexte_db) if args.contexte_db else local_root / _NOM_CONTEXTE_DB_PAR_DEFAUT | |
| 300 | + ) | |
| 301 | + interactif = not args.yes | |
| 302 | + | |
| 303 | + fichiers = copier_carte(carte, local_tmp) | |
| 304 | + if not fichiers: | |
| 305 | + print("Aucun fichier nouveau à importer.") | |
| 306 | + return 0 | |
| 307 | + | |
| 308 | + conn = open_context_db(contexte_db) | |
| 309 | + try: | |
| 310 | + a_etiqueter = resoudre_collisions_boitiers(fichiers, conn=conn) | |
| 311 | + _resoudre_etiquetage_manuel(a_etiqueter, conn=conn, interactif=interactif) | |
| 312 | + | |
| 313 | + groupes = decouper_en_groupes(fichiers) | |
| 314 | + groupes = _proposer_detachement(groupes, interactif) | |
| 315 | + | |
| 316 | + for groupe in groupes: | |
| 317 | + _traiter_groupe( | |
| 318 | + groupe, | |
| 319 | + local_tmp=local_tmp, | |
| 320 | + archive_root=archive_root, | |
| 321 | + local_root=local_root, | |
| 322 | + titre_impose=args.titre, | |
| 323 | + categorie_imposee=args.categorie, | |
| 324 | + annee_imposee=args.annee, | |
| 325 | + destination_arg=args.destination, | |
| 326 | + conn=conn, | |
| 327 | + interactif=interactif, | |
| 328 | + ) | |
| 329 | + finally: | |
| 330 | + conn.close() | |
| 331 | + | |
| 332 | + return 0 | |
| 333 | + | |
| 334 | + | |
| 335 | +def construire_analyseur() -> argparse.ArgumentParser: | |
| 336 | + analyseur = argparse.ArgumentParser(prog="regine") | |
| 337 | + sous_commandes = analyseur.add_subparsers(dest="commande", required=True) | |
| 338 | + | |
| 339 | + import_parser = sous_commandes.add_parser("import", help="Importe une carte mémoire") | |
| 340 | + import_parser.add_argument("carte") | |
| 341 | + import_parser.add_argument("--titre", default=None) | |
| 342 | + import_parser.add_argument("--categorie", default=None) | |
| 343 | + import_parser.add_argument("--annee", action="store_true") | |
| 344 | + import_parser.add_argument( | |
| 345 | + "--destination", | |
| 346 | + default=None, | |
| 347 | + help="nouveau | parent | sous-dossier:CHEMIN | fusion:CHEMIN (cf. contracts/cli-import.md)", | |
| 348 | + ) | |
| 349 | + import_parser.add_argument("--yes", action="store_true") | |
| 350 | + import_parser.add_argument("--local-tmp", default=None) | |
| 351 | + import_parser.add_argument("--archive-root", required=True) | |
| 352 | + import_parser.add_argument("--local-root", required=True) | |
| 353 | + import_parser.add_argument("--contexte-db", default=None) | |
| 354 | + import_parser.set_defaults(func=_cmd_import) | |
| 355 | + | |
| 356 | + return analyseur | |
| 357 | + | |
| 358 | + | |
| 359 | +def main(argv: list[str] | None = None) -> int: | |
| 360 | + analyseur = construire_analyseur() | |
| 361 | + args = analyseur.parse_args(argv) | |
| 362 | + return args.func(args) | |
| 363 | + | |
| 364 | + | |
| 365 | +if __name__ == "__main__": | |
| 366 | + raise SystemExit(main()) | |
| new file mode 100644 | |||
| @@ -0,0 +1,366 @@ | |||
| 1 | +"""Commande `regine import` (cf. contracts/cli-import.md). | ||
| 2 | + | ||
| 3 | +Façade fine : orchestre `regine_core.import_carte`, formatte le résultat, ne | ||
| 4 | +contient aucune logique métier propre (Principe VI de la constitution). | ||
| 5 | +""" | ||
| 6 | + | ||
| 7 | +from __future__ import annotations | ||
| 8 | + | ||
| 9 | +import argparse | ||
| 10 | +import sqlite3 | ||
| 11 | +from pathlib import Path | ||
| 12 | + | ||
| 13 | +from regine_core.camera_profile.db import list_boitiers | ||
| 14 | +from regine_core.camera_profile.resolve import assign_manual_source | ||
| 15 | +from regine_core.config.categories import ( | ||
| 16 | + list_known_categories, | ||
| 17 | + register_category_usage, | ||
| 18 | + suggest_categories, | ||
| 19 | +) | ||
| 20 | +from regine_core.config.db import open_context_db | ||
| 21 | +from regine_core.dossier.root import RootLocation | ||
| 22 | +from regine_core.import_carte.copie import copier_carte, resoudre_collisions_boitiers | ||
| 23 | +from regine_core.import_carte.destination import ( | ||
| 24 | + lister_dossiers_candidats, | ||
| 25 | + resoudre_destination, | ||
| 26 | + resoudre_fusion, | ||
| 27 | +) | ||
| 28 | +from regine_core.import_carte.groupage import ( | ||
| 29 | + decouper_en_groupes, | ||
| 30 | + detacher_jours, | ||
| 31 | + jours_candidats_au_detachement, | ||
| 32 | +) | ||
| 33 | +from regine_core.import_carte.identifiant import attribuer_identifiants | ||
| 34 | +from regine_core.import_carte.nommage import ( | ||
| 35 | + construire_nom_dossier, | ||
| 36 | + construire_nom_dossier_parent, | ||
| 37 | + construire_nom_sous_dossier, | ||
| 38 | + renommer_fichiers, | ||
| 39 | + resoudre_collision_nom, | ||
| 40 | +) | ||
| 41 | +from regine_core.import_carte.push import archiver, preparer_resume | ||
| 42 | +from regine_core.import_carte.types import DestinationChoisie, FichierCandidat, GroupeImport | ||
| 43 | + | ||
| 44 | +_NOM_CONTEXTE_DB_PAR_DEFAUT = ".regine-contexte.sqlite3" | ||
| 45 | + | ||
| 46 | + | ||
| 47 | +def _proposer_detachement(groupes: list[GroupeImport], interactif: bool) -> list[GroupeImport]: | ||
| 48 | + """Propose le détachement d'un jour candidat (US2, FR-006) — jamais automatique.""" | ||
| 49 | + if not interactif or len(groupes) != 1: | ||
| 50 | + return groupes | ||
| 51 | + (groupe_initial,) = groupes | ||
| 52 | + candidats = jours_candidats_au_detachement(groupe_initial.fichiers) | ||
| 53 | + for jour in candidats: | ||
| 54 | + reponse = input( | ||
| 55 | + f"Le {jour} se distingue nettement du reste : le détacher en groupe séparé ? [o/n] " | ||
| 56 | + ) | ||
| 57 | + if reponse.strip().lower() == "o": | ||
| 58 | + return detacher_jours(groupe_initial, [jour]) | ||
| 59 | + return groupes | ||
| 60 | + | ||
| 61 | + | ||
| 62 | +def _resoudre_etiquetage_manuel( | ||
| 63 | + a_etiqueter: list[list[FichierCandidat]], *, conn: sqlite3.Connection, interactif: bool | ||
| 64 | +) -> None: | ||
| 65 | + """Résout les groupes de fichiers encore ambigus après résolution automatique | ||
| 66 | + (FR-005/FR-015) en demandant à l'utilisateur d'étiqueter chaque groupe — jamais | ||
| 67 | + résolu arbitrairement. Assigne `boitier_id` en place sur chaque fichier.""" | ||
| 68 | + if not a_etiqueter: | ||
| 69 | + return | ||
| 70 | + if not interactif: | ||
| 71 | + noms = ", ".join(f.nom_origine or "?" for groupe in a_etiqueter for f in groupe) | ||
| 72 | + raise RuntimeError( | ||
| 73 | + f"Des fichiers en collision nécessitent un étiquetage manuel de boîtier " | ||
| 74 | + f"({noms}) — impossible en mode --yes, relancer sans ce flag." | ||
| 75 | + ) | ||
| 76 | + | ||
| 77 | + for groupe in a_etiqueter: | ||
| 78 | + noms = ", ".join(f.nom_origine or "?" for f in groupe) | ||
| 79 | + print(f"Collision non résolue automatiquement pour : {noms}") | ||
| 80 | + boitiers_connus = list_boitiers(conn) | ||
| 81 | + for idx, boitier in enumerate(boitiers_connus, start=1): | ||
| 82 | + libelle = boitier.nom_lisible or boitier.modele or f"boîtier #{boitier.id}" | ||
| 83 | + print(f" [{idx}] {libelle}") | ||
| 84 | + print(" [n] Nouveau boîtier") | ||
| 85 | + reponse = input("Choix : ").strip() | ||
| 86 | + if reponse.isdigit() and 1 <= int(reponse) <= len(boitiers_connus): | ||
| 87 | + boitier_id = boitiers_connus[int(reponse) - 1].id | ||
| 88 | + else: | ||
| 89 | + modele = input("Modèle de ce boîtier (optionnel) : ").strip() or None | ||
| 90 | + boitier_id = assign_manual_source( | ||
| 91 | + conn, [f.chemin_source for f in groupe], modele=modele | ||
| 92 | + ) | ||
| 93 | + for f in groupe: | ||
| 94 | + f.boitier_id = boitier_id | ||
| 95 | + | ||
| 96 | + | ||
| 97 | +def _choisir_categorie(*, conn: sqlite3.Connection, interactif: bool) -> str | None: | ||
| 98 | + """Demande la catégorie thématique du dossier/dossier parent (FR-003), avec | ||
| 99 | + suggestion des catégories déjà connues — ou aucune (année par défaut).""" | ||
| 100 | + if not interactif: | ||
| 101 | + return None | ||
| 102 | + connues = list_known_categories(conn) | ||
| 103 | + if connues: | ||
| 104 | + print(f"Catégories connues : {', '.join(connues)}") | ||
| 105 | + saisie = input("Catégorie (vide = année par défaut) : ").strip() | ||
| 106 | + if not saisie: | ||
| 107 | + return None | ||
| 108 | + suggestions = [s for s in suggest_categories(conn, saisie) if s.lower() != saisie.lower()] | ||
| 109 | + if suggestions: | ||
| 110 | + print(f"Vouliez-vous dire : {', '.join(suggestions)} ? (saisie conservée telle quelle)") | ||
| 111 | + register_category_usage(conn, saisie) | ||
| 112 | + return saisie | ||
| 113 | + | ||
| 114 | + | ||
| 115 | +def _root_location_depuis_chemin( | ||
| 116 | + chemin_relatif: str, *, archive_root: Path, local_root: Path | ||
| 117 | +) -> RootLocation: | ||
| 118 | + """Reconstruit le `RootLocation` d'un dossier parent déjà existant (créé lors | ||
| 119 | + d'un import précédent) à partir de son premier segment de chemin relatif — ce | ||
| 120 | + segment est soit une année, soit une catégorie (FR-006 de specs/004).""" | ||
| 121 | + premier_segment = Path(chemin_relatif).parts[0] | ||
| 122 | + type_ = "annee" if premier_segment.isdigit() else "categorie" | ||
| 123 | + return RootLocation( | ||
| 124 | + type=type_, | ||
| 125 | + nom=premier_segment, | ||
| 126 | + chemin_archive=archive_root / premier_segment, | ||
| 127 | + chemin_local=local_root / premier_segment, | ||
| 128 | + ) | ||
| 129 | + | ||
| 130 | + | ||
| 131 | +def _choisir_dossier_existant( | ||
| 132 | + titre_partiel: str, *, archive_root: Path, local_root: Path, interactif: bool, arg: str | None | ||
| 133 | +) -> str: | ||
| 134 | + """Détermine le chemin relatif (depuis `archive_root`/`local_root`) d'un dossier | ||
| 135 | + parent ou de fusion existant — soit imposé par un flag `sous-dossier:CHEMIN` / | ||
| 136 | + `fusion:CHEMIN`, soit choisi interactivement parmi les candidats (FR-008).""" | ||
| 137 | + if arg: | ||
| 138 | + return arg | ||
| 139 | + candidats = lister_dossiers_candidats(titre_partiel, [local_root, archive_root]) | ||
| 140 | + if candidats and interactif: | ||
| 141 | + print("Dossiers candidats :") | ||
| 142 | + for idx, candidat in enumerate(candidats, start=1): | ||
| 143 | + print(f" [{idx}] {candidat}") | ||
| 144 | + reponse = input("Choix (numéro, ou chemin relatif manuel) : ").strip() | ||
| 145 | + if reponse.isdigit() and 1 <= int(reponse) <= len(candidats): | ||
| 146 | + candidat = candidats[int(reponse) - 1] | ||
| 147 | + racine = local_root if local_root in candidat.parents else archive_root | ||
| 148 | + return str(candidat.relative_to(racine)) | ||
| 149 | + return reponse | ||
| 150 | + return input("Chemin relatif du dossier (ex. voyage/2026-08_Montenegro) : ").strip() | ||
| 151 | + | ||
| 152 | + | ||
| 153 | +def _resoudre_type_destination( | ||
| 154 | + destination_arg: str | None, *, interactif: bool | ||
| 155 | +) -> tuple[str, str | None]: | ||
| 156 | + """Traduit `--destination` (contract CLI : `nouveau|parent|sous-dossier:ID|fusion:ID`) | ||
| 157 | + ou, en son absence en mode interactif, une question posée à l'utilisateur (FR-007).""" | ||
| 158 | + if destination_arg: | ||
| 159 | + if ":" in destination_arg: | ||
| 160 | + type_brut, id_ = destination_arg.split(":", 1) | ||
| 161 | + else: | ||
| 162 | + type_brut, id_ = destination_arg, None | ||
| 163 | + return { | ||
| 164 | + "nouveau": "nouveau_dossier", | ||
| 165 | + "parent": "nouveau_parent", | ||
| 166 | + "sous-dossier": "nouveau_sous_dossier", | ||
| 167 | + "fusion": "fusion", | ||
| 168 | + }[type_brut], id_ | ||
| 169 | + | ||
| 170 | + if not interactif: | ||
| 171 | + return "nouveau_dossier", None | ||
| 172 | + | ||
| 173 | + print( | ||
| 174 | + "Destination : [1] Nouveau dossier [2] Nouveau dossier parent (voyage) " | ||
| 175 | + "[3] Nouveau sous-dossier d'un parent existant [4] Fusion dans un dossier existant" | ||
| 176 | + ) | ||
| 177 | + choix = input("Choix [1] : ").strip() or "1" | ||
| 178 | + return { | ||
| 179 | + "1": "nouveau_dossier", | ||
| 180 | + "2": "nouveau_parent", | ||
| 181 | + "3": "nouveau_sous_dossier", | ||
| 182 | + "4": "fusion", | ||
| 183 | + }.get(choix, "nouveau_dossier"), None | ||
| 184 | + | ||
| 185 | + | ||
| 186 | +def _traiter_groupe( | ||
| 187 | + groupe: GroupeImport, | ||
| 188 | + *, | ||
| 189 | + local_tmp: Path, | ||
| 190 | + archive_root: Path, | ||
| 191 | + local_root: Path, | ||
| 192 | + titre_impose: str | None, | ||
| 193 | + categorie_imposee: str | None, | ||
| 194 | + annee_imposee: bool, | ||
| 195 | + destination_arg: str | None, | ||
| 196 | + conn: sqlite3.Connection, | ||
| 197 | + interactif: bool, | ||
| 198 | +) -> None: | ||
| 199 | + titre = titre_impose or input("Titre du groupe : ") | ||
| 200 | + groupe.titre = titre | ||
| 201 | + | ||
| 202 | + type_destination, id_cible = _resoudre_type_destination(destination_arg, interactif=interactif) | ||
| 203 | + | ||
| 204 | + destination: DestinationChoisie | ||
| 205 | + if type_destination in ("nouveau_dossier", "nouveau_parent"): | ||
| 206 | + categorie = categorie_imposee if not annee_imposee else None | ||
| 207 | + if categorie is None and not annee_imposee: | ||
| 208 | + categorie = _choisir_categorie(conn=conn, interactif=interactif) | ||
| 209 | + destination = resoudre_destination( | ||
| 210 | + groupe, | ||
| 211 | + type_destination, | ||
| 212 | + archive_root=archive_root, | ||
| 213 | + local_root=local_root, | ||
| 214 | + categorie=categorie, | ||
| 215 | + ) | ||
| 216 | + root = destination.root_location | ||
| 217 | + assert root is not None | ||
| 218 | + nom_dossier = ( | ||
| 219 | + construire_nom_dossier_parent(groupe.plage_dates[0], titre) | ||
| 220 | + if type_destination == "nouveau_parent" | ||
| 221 | + else construire_nom_dossier(groupe, titre) | ||
| 222 | + ) | ||
| 223 | + dossier_local = resoudre_collision_nom(root.chemin_local / nom_dossier) | ||
| 224 | + dossier_archive = root.chemin_archive / dossier_local.name | ||
| 225 | + | ||
| 226 | + elif type_destination == "nouveau_sous_dossier": | ||
| 227 | + chemin_parent = _choisir_dossier_existant( | ||
| 228 | + titre, | ||
| 229 | + archive_root=archive_root, | ||
| 230 | + local_root=local_root, | ||
| 231 | + interactif=interactif, | ||
| 232 | + arg=id_cible, | ||
| 233 | + ) | ||
| 234 | + # `root_parent` ne porte que la racine héritée (année/catégorie) ; le | ||
| 235 | + # sous-dossier d'étape doit lui être physiquement imbriqué, sous le | ||
| 236 | + # dossier parent réel (ex. voyage/2026-08_Montenegro/2026-08-14_Kotor), | ||
| 237 | + # pas simplement sous la racine catégorie. | ||
| 238 | + root_parent = _root_location_depuis_chemin( | ||
| 239 | + chemin_parent, archive_root=archive_root, local_root=local_root | ||
| 240 | + ) | ||
| 241 | + lieu = None if titre_impose else input("Lieu de cette étape (optionnel) : ").strip() or None | ||
| 242 | + nom_dossier = construire_nom_sous_dossier(groupe, titre, lieu=lieu) | ||
| 243 | + dossier_local = resoudre_collision_nom(local_root / chemin_parent / nom_dossier) | ||
| 244 | + dossier_archive = archive_root / chemin_parent / dossier_local.name | ||
| 245 | + destination = resoudre_destination( | ||
| 246 | + groupe, | ||
| 247 | + "nouveau_sous_dossier", | ||
| 248 | + archive_root=archive_root, | ||
| 249 | + local_root=local_root, | ||
| 250 | + root_parent=root_parent, | ||
| 251 | + dossier_cible=dossier_local, | ||
| 252 | + ) | ||
| 253 | + | ||
| 254 | + else: # fusion | ||
| 255 | + chemin_cible = _choisir_dossier_existant( | ||
| 256 | + titre, | ||
| 257 | + archive_root=archive_root, | ||
| 258 | + local_root=local_root, | ||
| 259 | + interactif=interactif, | ||
| 260 | + arg=id_cible, | ||
| 261 | + ) | ||
| 262 | + destination = resoudre_fusion( | ||
| 263 | + chemin_cible, archive_root=archive_root, local_root=local_root | ||
| 264 | + ) | ||
| 265 | + assert destination.dossier_cible is not None | ||
| 266 | + dossier_local = destination.dossier_cible | ||
| 267 | + dossier_archive = archive_root / chemin_cible | ||
| 268 | + if destination.necessite_checkout_archive: | ||
| 269 | + print(f"Checkout automatique depuis l'archive : {dossier_archive} -> {dossier_local}") | ||
| 270 | + | ||
| 271 | + renommer_fichiers(groupe.fichiers, groupe.plage_dates[0], titre) | ||
| 272 | + | ||
| 273 | + chemins_maitres = [f.chemin_source for f in groupe.fichiers if f.type == "maitre"] | ||
| 274 | + attribuer_identifiants(chemins_maitres) | ||
| 275 | + | ||
| 276 | + chemins = [f.chemin_source for f in groupe.fichiers] | ||
| 277 | + resume = preparer_resume(chemins, dossier_archive) | ||
| 278 | + print( | ||
| 279 | + f"Résumé : {resume.nombre_fichiers} fichier(s), {resume.taille_totale} octet(s) " | ||
| 280 | + f"-> {resume.dossier_destination}" | ||
| 281 | + ) | ||
| 282 | + | ||
| 283 | + if interactif: | ||
| 284 | + confirmation = input("Confirmer l'archivage ? [o/n] ") | ||
| 285 | + if confirmation.strip().lower() != "o": | ||
| 286 | + print("Archivage annulé pour ce groupe.") | ||
| 287 | + return | ||
| 288 | + | ||
| 289 | + archiver(chemins, local_tmp, dossier_archive) | ||
| 290 | + print(f"Archivé : {dossier_archive}") | ||
| 291 | + | ||
| 292 | + | ||
| 293 | +def _cmd_import(args: argparse.Namespace) -> int: | ||
| 294 | + carte = Path(args.carte) | ||
| 295 | + local_tmp = Path(args.local_tmp) if args.local_tmp else Path.cwd() / ".regine-import-tmp" | ||
| 296 | + archive_root = Path(args.archive_root) | ||
| 297 | + local_root = Path(args.local_root) | ||
| 298 | + contexte_db = ( | ||
| 299 | + Path(args.contexte_db) if args.contexte_db else local_root / _NOM_CONTEXTE_DB_PAR_DEFAUT | ||
| 300 | + ) | ||
| 301 | + interactif = not args.yes | ||
| 302 | + | ||
| 303 | + fichiers = copier_carte(carte, local_tmp) | ||
| 304 | + if not fichiers: | ||
| 305 | + print("Aucun fichier nouveau à importer.") | ||
| 306 | + return 0 | ||
| 307 | + | ||
| 308 | + conn = open_context_db(contexte_db) | ||
| 309 | + try: | ||
| 310 | + a_etiqueter = resoudre_collisions_boitiers(fichiers, conn=conn) | ||
| 311 | + _resoudre_etiquetage_manuel(a_etiqueter, conn=conn, interactif=interactif) | ||
| 312 | + | ||
| 313 | + groupes = decouper_en_groupes(fichiers) | ||
| 314 | + groupes = _proposer_detachement(groupes, interactif) | ||
| 315 | + | ||
| 316 | + for groupe in groupes: | ||
| 317 | + _traiter_groupe( | ||
| 318 | + groupe, | ||
| 319 | + local_tmp=local_tmp, | ||
| 320 | + archive_root=archive_root, | ||
| 321 | + local_root=local_root, | ||
| 322 | + titre_impose=args.titre, | ||
| 323 | + categorie_imposee=args.categorie, | ||
| 324 | + annee_imposee=args.annee, | ||
| 325 | + destination_arg=args.destination, | ||
| 326 | + conn=conn, | ||
| 327 | + interactif=interactif, | ||
| 328 | + ) | ||
| 329 | + finally: | ||
| 330 | + conn.close() | ||
| 331 | + | ||
| 332 | + return 0 | ||
| 333 | + | ||
| 334 | + | ||
| 335 | +def construire_analyseur() -> argparse.ArgumentParser: | ||
| 336 | + analyseur = argparse.ArgumentParser(prog="regine") | ||
| 337 | + sous_commandes = analyseur.add_subparsers(dest="commande", required=True) | ||
| 338 | + | ||
| 339 | + import_parser = sous_commandes.add_parser("import", help="Importe une carte mémoire") | ||
| 340 | + import_parser.add_argument("carte") | ||
| 341 | + import_parser.add_argument("--titre", default=None) | ||
| 342 | + import_parser.add_argument("--categorie", default=None) | ||
| 343 | + import_parser.add_argument("--annee", action="store_true") | ||
| 344 | + import_parser.add_argument( | ||
| 345 | + "--destination", | ||
| 346 | + default=None, | ||
| 347 | + help="nouveau | parent | sous-dossier:CHEMIN | fusion:CHEMIN (cf. contracts/cli-import.md)", | ||
| 348 | + ) | ||
| 349 | + import_parser.add_argument("--yes", action="store_true") | ||
| 350 | + import_parser.add_argument("--local-tmp", default=None) | ||
| 351 | + import_parser.add_argument("--archive-root", required=True) | ||
| 352 | + import_parser.add_argument("--local-root", required=True) | ||
| 353 | + import_parser.add_argument("--contexte-db", default=None) | ||
| 354 | + import_parser.set_defaults(func=_cmd_import) | ||
| 355 | + | ||
| 356 | + return analyseur | ||
| 357 | + | ||
| 358 | + | ||
| 359 | +def main(argv: list[str] | None = None) -> int: | ||
| 360 | + analyseur = construire_analyseur() | ||
| 361 | + args = analyseur.parse_args(argv) | ||
| 362 | + return args.func(args) | ||
| 363 | + | ||
| 364 | + | ||
| 365 | +if __name__ == "__main__": | ||
| 366 | + raise SystemExit(main()) | ||
added
packages/regine-core/src/regine_core/import_carte/__init__.py +5 -0 | new file mode 100644 | ||
| @@ -0,0 +1,5 @@ | ||
| 1 | +"""Import de photos depuis une carte mémoire jusqu'au premier archivage. | |
| 2 | + | |
| 3 | +Cf. specs/001-import-photos. Premier consommateur réel de regine_core.dossier | |
| 4 | +(specs/004), regine_core.camera_profile (specs/002) et regine_core.metadata. | |
| 5 | +""" | |
| new file mode 100644 | |||
| @@ -0,0 +1,5 @@ | |||
| 1 | +"""Import de photos depuis une carte mémoire jusqu'au premier archivage. | ||
| 2 | + | ||
| 3 | +Cf. specs/001-import-photos. Premier consommateur réel de regine_core.dossier | ||
| 4 | +(specs/004), regine_core.camera_profile (specs/002) et regine_core.metadata. | ||
| 5 | +""" | ||
added
packages/regine-core/src/regine_core/import_carte/copie.py +147 -0 | new file mode 100644 | ||
| @@ -0,0 +1,147 @@ | ||
| 1 | +"""Copie vérifiée d'une carte mémoire vers l'espace de travail local (FR-001/004).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import hashlib | |
| 6 | +from pathlib import Path | |
| 7 | + | |
| 8 | +from regine_core.import_carte.types import FichierCandidat | |
| 9 | +from regine_core.integrity.hash import hash_fichier_entier | |
| 10 | + | |
| 11 | +#: Extensions reconnues comme fichiers maîtres (RAW, TIFF/BMP de scan, JPEG appareil). | |
| 12 | +EXTENSIONS_MAITRES = { | |
| 13 | + "raf", | |
| 14 | + "cr2", | |
| 15 | + "cr3", | |
| 16 | + "nef", | |
| 17 | + "arw", | |
| 18 | + "orf", | |
| 19 | + "rw2", | |
| 20 | + "pef", | |
| 21 | + "srw", # RAW propriétaires | |
| 22 | + "dng", | |
| 23 | + "tiff", | |
| 24 | + "tif", | |
| 25 | + "bmp", | |
| 26 | + "jpg", | |
| 27 | + "jpeg", | |
| 28 | +} | |
| 29 | +#: Extensions reconnues comme fichiers associés (sidecars). | |
| 30 | +EXTENSIONS_ASSOCIEES = {"xmp", "dop", "acr"} | |
| 31 | + | |
| 32 | +_TAILLE_BLOC = 1024 * 1024 | |
| 33 | + | |
| 34 | + | |
| 35 | +class EchecVerificationError(Exception): | |
| 36 | + """La vérification par somme de contrôle d'un fichier a échoué pendant la copie.""" | |
| 37 | + | |
| 38 | + | |
| 39 | +def _copier_avec_hash(source: Path, destination: Path) -> str: | |
| 40 | + """Copie `source` vers `destination` en une seule lecture de `source` (FR-001), | |
| 41 | + en calculant son SHA-256 au fil du flux plutôt qu'en la relisant ensuite.""" | |
| 42 | + hachage = hashlib.sha256() | |
| 43 | + with source.open("rb") as f_source, destination.open("wb") as f_dest: | |
| 44 | + for bloc in iter(lambda: f_source.read(_TAILLE_BLOC), b""): | |
| 45 | + hachage.update(bloc) | |
| 46 | + f_dest.write(bloc) | |
| 47 | + return hachage.hexdigest() | |
| 48 | + | |
| 49 | + | |
| 50 | +def _destination_sans_collision(local_tmp: Path, nom: str) -> Path: | |
| 51 | + """Ne DOIT jamais écraser un fichier déjà copié sous le même nom d'origine : | |
| 52 | + deux boîtiers différents peuvent produire un fichier de même nom (FR-015). | |
| 53 | + La désambiguïsation réelle se fait ensuite par `regrouper_par_nom_origine` / | |
| 54 | + `regine_core.camera_profile`, pas par ce simple évitement de collision locale.""" | |
| 55 | + candidat = local_tmp / nom | |
| 56 | + if not candidat.exists(): | |
| 57 | + return candidat | |
| 58 | + base = Path(nom) | |
| 59 | + compteur = 2 | |
| 60 | + while True: | |
| 61 | + candidat = local_tmp / f"{base.stem}-{compteur}{base.suffix}" | |
| 62 | + if not candidat.exists(): | |
| 63 | + return candidat | |
| 64 | + compteur += 1 | |
| 65 | + | |
| 66 | + | |
| 67 | +def copier_carte( | |
| 68 | + carte: Path, | |
| 69 | + local_tmp: Path, | |
| 70 | + checksums_deja_importes: set[str] | None = None, | |
| 71 | +) -> list[FichierCandidat]: | |
| 72 | + """Copie vérifiée d'une carte mémoire vers l'espace de travail local. | |
| 73 | + | |
| 74 | + Une seule lecture de la carte par fichier (FR-001, Edge Case carte lente) : | |
| 75 | + l'empreinte de la source est calculée pendant la copie elle-même, puis | |
| 76 | + comparée à l'empreinte de la copie locale (relue, rapide) pour vérifier | |
| 77 | + l'intégrité — jamais par une seconde lecture de la carte. | |
| 78 | + | |
| 79 | + Ne retient que les fichiers réellement nouveaux (FR-004, comparaison par | |
| 80 | + empreinte avec `checksums_deja_importes`) et reconnus comme fichier maître ou | |
| 81 | + associé (les autres sont hors périmètre, cf. Assumptions). | |
| 82 | + """ | |
| 83 | + checksums_deja_importes = checksums_deja_importes or set() | |
| 84 | + local_tmp.mkdir(parents=True, exist_ok=True) | |
| 85 | + | |
| 86 | + resultat: list[FichierCandidat] = [] | |
| 87 | + for source in sorted(p for p in carte.rglob("*") if p.is_file()): | |
| 88 | + extension = source.suffix.lstrip(".").lower() | |
| 89 | + if extension not in EXTENSIONS_MAITRES and extension not in EXTENSIONS_ASSOCIEES: | |
| 90 | + continue | |
| 91 | + | |
| 92 | + destination = _destination_sans_collision(local_tmp, source.name) | |
| 93 | + checksum_source = _copier_avec_hash(source, destination) | |
| 94 | + checksum_copie = hash_fichier_entier(destination) | |
| 95 | + if checksum_source != checksum_copie: | |
| 96 | + raise EchecVerificationError(f"Échec de vérification pour {source.name}") | |
| 97 | + | |
| 98 | + if checksum_source in checksums_deja_importes: | |
| 99 | + continue # déjà importé lors d'une session précédente (FR-004) | |
| 100 | + | |
| 101 | + resultat.append( | |
| 102 | + FichierCandidat( | |
| 103 | + chemin_source=destination, | |
| 104 | + checksum=checksum_source, | |
| 105 | + deja_importe=False, | |
| 106 | + type="maitre" if extension in EXTENSIONS_MAITRES else "associe", | |
| 107 | + nom_origine=source.name, | |
| 108 | + ) | |
| 109 | + ) | |
| 110 | + | |
| 111 | + return resultat | |
| 112 | + | |
| 113 | + | |
| 114 | +def regrouper_par_nom_origine(fichiers: list[FichierCandidat]) -> dict[str, list[FichierCandidat]]: | |
| 115 | + """Regroupe les fichiers par nom d'origine ; un groupe de taille > 1 est une | |
| 116 | + collision réelle (FR-015), à résoudre via `regine_core.camera_profile`.""" | |
| 117 | + groupes: dict[str, list[FichierCandidat]] = {} | |
| 118 | + for f in fichiers: | |
| 119 | + if f.nom_origine is not None and f.type == "maitre": | |
| 120 | + groupes.setdefault(f.nom_origine, []).append(f) | |
| 121 | + return {nom: groupe for nom, groupe in groupes.items() if len(groupe) > 1} | |
| 122 | + | |
| 123 | + | |
| 124 | +def resoudre_collisions_boitiers( | |
| 125 | + fichiers: list[FichierCandidat], *, conn | |
| 126 | +) -> list[list[FichierCandidat]]: | |
| 127 | + """Résout les collisions de nom d'origine par désambiguïsation de boîtiers (FR-015/016). | |
| 128 | + | |
| 129 | + Ne DOIT être appelée que pour des fichiers déjà en collision réelle de nom | |
| 130 | + d'origine (cf. `regrouper_par_nom_origine`) — c'est le rôle de cette fonction, | |
| 131 | + pas de `regine_core.camera_profile.resolve_collision` elle-même (specs/002). | |
| 132 | + Assigne `boitier_id` sur chaque `FichierCandidat` résolu automatiquement. | |
| 133 | + Retourne les groupes encore ambigus, nécessitant un étiquetage manuel | |
| 134 | + (`regine_core.camera_profile.assign_manual_source`) côté appelant (façade CLI). | |
| 135 | + """ | |
| 136 | + from regine_core.camera_profile.resolve import resolve_collision | |
| 137 | + | |
| 138 | + a_etiqueter: list[list[FichierCandidat]] = [] | |
| 139 | + for groupe in regrouper_par_nom_origine(fichiers).values(): | |
| 140 | + chemins = [f.chemin_source for f in groupe] | |
| 141 | + resolution = resolve_collision(chemins, conn=conn) | |
| 142 | + par_chemin = {f.chemin_source: f for f in groupe} | |
| 143 | + for chemin, boitier_id in resolution.resolues.items(): | |
| 144 | + par_chemin[chemin].boitier_id = boitier_id | |
| 145 | + for sous_groupe in resolution.a_etiqueter: | |
| 146 | + a_etiqueter.append([par_chemin[c] for c in sous_groupe]) | |
| 147 | + return a_etiqueter | |
| new file mode 100644 | |||
| @@ -0,0 +1,147 @@ | |||
| 1 | +"""Copie vérifiée d'une carte mémoire vers l'espace de travail local (FR-001/004).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import hashlib | ||
| 6 | +from pathlib import Path | ||
| 7 | + | ||
| 8 | +from regine_core.import_carte.types import FichierCandidat | ||
| 9 | +from regine_core.integrity.hash import hash_fichier_entier | ||
| 10 | + | ||
| 11 | +#: Extensions reconnues comme fichiers maîtres (RAW, TIFF/BMP de scan, JPEG appareil). | ||
| 12 | +EXTENSIONS_MAITRES = { | ||
| 13 | + "raf", | ||
| 14 | + "cr2", | ||
| 15 | + "cr3", | ||
| 16 | + "nef", | ||
| 17 | + "arw", | ||
| 18 | + "orf", | ||
| 19 | + "rw2", | ||
| 20 | + "pef", | ||
| 21 | + "srw", # RAW propriétaires | ||
| 22 | + "dng", | ||
| 23 | + "tiff", | ||
| 24 | + "tif", | ||
| 25 | + "bmp", | ||
| 26 | + "jpg", | ||
| 27 | + "jpeg", | ||
| 28 | +} | ||
| 29 | +#: Extensions reconnues comme fichiers associés (sidecars). | ||
| 30 | +EXTENSIONS_ASSOCIEES = {"xmp", "dop", "acr"} | ||
| 31 | + | ||
| 32 | +_TAILLE_BLOC = 1024 * 1024 | ||
| 33 | + | ||
| 34 | + | ||
| 35 | +class EchecVerificationError(Exception): | ||
| 36 | + """La vérification par somme de contrôle d'un fichier a échoué pendant la copie.""" | ||
| 37 | + | ||
| 38 | + | ||
| 39 | +def _copier_avec_hash(source: Path, destination: Path) -> str: | ||
| 40 | + """Copie `source` vers `destination` en une seule lecture de `source` (FR-001), | ||
| 41 | + en calculant son SHA-256 au fil du flux plutôt qu'en la relisant ensuite.""" | ||
| 42 | + hachage = hashlib.sha256() | ||
| 43 | + with source.open("rb") as f_source, destination.open("wb") as f_dest: | ||
| 44 | + for bloc in iter(lambda: f_source.read(_TAILLE_BLOC), b""): | ||
| 45 | + hachage.update(bloc) | ||
| 46 | + f_dest.write(bloc) | ||
| 47 | + return hachage.hexdigest() | ||
| 48 | + | ||
| 49 | + | ||
| 50 | +def _destination_sans_collision(local_tmp: Path, nom: str) -> Path: | ||
| 51 | + """Ne DOIT jamais écraser un fichier déjà copié sous le même nom d'origine : | ||
| 52 | + deux boîtiers différents peuvent produire un fichier de même nom (FR-015). | ||
| 53 | + La désambiguïsation réelle se fait ensuite par `regrouper_par_nom_origine` / | ||
| 54 | + `regine_core.camera_profile`, pas par ce simple évitement de collision locale.""" | ||
| 55 | + candidat = local_tmp / nom | ||
| 56 | + if not candidat.exists(): | ||
| 57 | + return candidat | ||
| 58 | + base = Path(nom) | ||
| 59 | + compteur = 2 | ||
| 60 | + while True: | ||
| 61 | + candidat = local_tmp / f"{base.stem}-{compteur}{base.suffix}" | ||
| 62 | + if not candidat.exists(): | ||
| 63 | + return candidat | ||
| 64 | + compteur += 1 | ||
| 65 | + | ||
| 66 | + | ||
| 67 | +def copier_carte( | ||
| 68 | + carte: Path, | ||
| 69 | + local_tmp: Path, | ||
| 70 | + checksums_deja_importes: set[str] | None = None, | ||
| 71 | +) -> list[FichierCandidat]: | ||
| 72 | + """Copie vérifiée d'une carte mémoire vers l'espace de travail local. | ||
| 73 | + | ||
| 74 | + Une seule lecture de la carte par fichier (FR-001, Edge Case carte lente) : | ||
| 75 | + l'empreinte de la source est calculée pendant la copie elle-même, puis | ||
| 76 | + comparée à l'empreinte de la copie locale (relue, rapide) pour vérifier | ||
| 77 | + l'intégrité — jamais par une seconde lecture de la carte. | ||
| 78 | + | ||
| 79 | + Ne retient que les fichiers réellement nouveaux (FR-004, comparaison par | ||
| 80 | + empreinte avec `checksums_deja_importes`) et reconnus comme fichier maître ou | ||
| 81 | + associé (les autres sont hors périmètre, cf. Assumptions). | ||
| 82 | + """ | ||
| 83 | + checksums_deja_importes = checksums_deja_importes or set() | ||
| 84 | + local_tmp.mkdir(parents=True, exist_ok=True) | ||
| 85 | + | ||
| 86 | + resultat: list[FichierCandidat] = [] | ||
| 87 | + for source in sorted(p for p in carte.rglob("*") if p.is_file()): | ||
| 88 | + extension = source.suffix.lstrip(".").lower() | ||
| 89 | + if extension not in EXTENSIONS_MAITRES and extension not in EXTENSIONS_ASSOCIEES: | ||
| 90 | + continue | ||
| 91 | + | ||
| 92 | + destination = _destination_sans_collision(local_tmp, source.name) | ||
| 93 | + checksum_source = _copier_avec_hash(source, destination) | ||
| 94 | + checksum_copie = hash_fichier_entier(destination) | ||
| 95 | + if checksum_source != checksum_copie: | ||
| 96 | + raise EchecVerificationError(f"Échec de vérification pour {source.name}") | ||
| 97 | + | ||
| 98 | + if checksum_source in checksums_deja_importes: | ||
| 99 | + continue # déjà importé lors d'une session précédente (FR-004) | ||
| 100 | + | ||
| 101 | + resultat.append( | ||
| 102 | + FichierCandidat( | ||
| 103 | + chemin_source=destination, | ||
| 104 | + checksum=checksum_source, | ||
| 105 | + deja_importe=False, | ||
| 106 | + type="maitre" if extension in EXTENSIONS_MAITRES else "associe", | ||
| 107 | + nom_origine=source.name, | ||
| 108 | + ) | ||
| 109 | + ) | ||
| 110 | + | ||
| 111 | + return resultat | ||
| 112 | + | ||
| 113 | + | ||
| 114 | +def regrouper_par_nom_origine(fichiers: list[FichierCandidat]) -> dict[str, list[FichierCandidat]]: | ||
| 115 | + """Regroupe les fichiers par nom d'origine ; un groupe de taille > 1 est une | ||
| 116 | + collision réelle (FR-015), à résoudre via `regine_core.camera_profile`.""" | ||
| 117 | + groupes: dict[str, list[FichierCandidat]] = {} | ||
| 118 | + for f in fichiers: | ||
| 119 | + if f.nom_origine is not None and f.type == "maitre": | ||
| 120 | + groupes.setdefault(f.nom_origine, []).append(f) | ||
| 121 | + return {nom: groupe for nom, groupe in groupes.items() if len(groupe) > 1} | ||
| 122 | + | ||
| 123 | + | ||
| 124 | +def resoudre_collisions_boitiers( | ||
| 125 | + fichiers: list[FichierCandidat], *, conn | ||
| 126 | +) -> list[list[FichierCandidat]]: | ||
| 127 | + """Résout les collisions de nom d'origine par désambiguïsation de boîtiers (FR-015/016). | ||
| 128 | + | ||
| 129 | + Ne DOIT être appelée que pour des fichiers déjà en collision réelle de nom | ||
| 130 | + d'origine (cf. `regrouper_par_nom_origine`) — c'est le rôle de cette fonction, | ||
| 131 | + pas de `regine_core.camera_profile.resolve_collision` elle-même (specs/002). | ||
| 132 | + Assigne `boitier_id` sur chaque `FichierCandidat` résolu automatiquement. | ||
| 133 | + Retourne les groupes encore ambigus, nécessitant un étiquetage manuel | ||
| 134 | + (`regine_core.camera_profile.assign_manual_source`) côté appelant (façade CLI). | ||
| 135 | + """ | ||
| 136 | + from regine_core.camera_profile.resolve import resolve_collision | ||
| 137 | + | ||
| 138 | + a_etiqueter: list[list[FichierCandidat]] = [] | ||
| 139 | + for groupe in regrouper_par_nom_origine(fichiers).values(): | ||
| 140 | + chemins = [f.chemin_source for f in groupe] | ||
| 141 | + resolution = resolve_collision(chemins, conn=conn) | ||
| 142 | + par_chemin = {f.chemin_source: f for f in groupe} | ||
| 143 | + for chemin, boitier_id in resolution.resolues.items(): | ||
| 144 | + par_chemin[chemin].boitier_id = boitier_id | ||
| 145 | + for sous_groupe in resolution.a_etiqueter: | ||
| 146 | + a_etiqueter.append([par_chemin[c] for c in sous_groupe]) | ||
| 147 | + return a_etiqueter | ||
added
packages/regine-core/src/regine_core/import_carte/destination.py +101 -0 | new file mode 100644 | ||
| @@ -0,0 +1,101 @@ | ||
| 1 | +"""Résolution de la destination d'un groupe d'import (FR-007/008/009).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import difflib | |
| 6 | +from datetime import date as _date | |
| 7 | +from pathlib import Path | |
| 8 | + | |
| 9 | +from regine_core.dossier.root import RootLocation, determine_root | |
| 10 | +from regine_core.import_carte.types import DestinationChoisie, GroupeImport, TypeDestination | |
| 11 | + | |
| 12 | + | |
| 13 | +def resoudre_destination( | |
| 14 | + groupe: GroupeImport, | |
| 15 | + type_destination: TypeDestination, | |
| 16 | + *, | |
| 17 | + archive_root: Path, | |
| 18 | + local_root: Path, | |
| 19 | + categorie: str | None = None, | |
| 20 | + dossier_cible: Path | None = None, | |
| 21 | + root_parent: RootLocation | None = None, | |
| 22 | +) -> DestinationChoisie: | |
| 23 | + """Traduit le choix de destination de l'utilisateur en `DestinationChoisie` (FR-007). | |
| 24 | + | |
| 25 | + - `nouveau_dossier`/`nouveau_parent` : résout un nouveau `RootLocation` via | |
| 26 | + `determine_root` (specs/004-categorisation-dossiers), à partir du premier | |
| 27 | + jour de la plage du groupe et de la `categorie` éventuellement choisie. | |
| 28 | + - `nouveau_sous_dossier` : hérite `root_parent` tel quel, sans nouvel appel à | |
| 29 | + `determine_root` (FR-006 de specs/004) — la catégorie/année du dossier | |
| 30 | + parent s'applique automatiquement. | |
| 31 | + - `fusion` : cf. `resoudre_fusion_locale`/`resoudre_fusion_archive`. | |
| 32 | + """ | |
| 33 | + if type_destination in ("nouveau_dossier", "nouveau_parent"): | |
| 34 | + date_ref: _date = groupe.plage_dates[0] | |
| 35 | + root = determine_root(date_ref, categorie, archive_root=archive_root, local_root=local_root) | |
| 36 | + return DestinationChoisie(type=type_destination, root_location=root) | |
| 37 | + | |
| 38 | + if type_destination == "nouveau_sous_dossier": | |
| 39 | + if root_parent is None: | |
| 40 | + raise ValueError("root_parent requis pour une destination nouveau_sous_dossier") | |
| 41 | + return DestinationChoisie( | |
| 42 | + type=type_destination, dossier_cible=dossier_cible, root_location=root_parent | |
| 43 | + ) | |
| 44 | + | |
| 45 | + raise ValueError( | |
| 46 | + f"Type de destination '{type_destination}' non géré par resoudre_destination " | |
| 47 | + "(utiliser resoudre_fusion pour une fusion)" | |
| 48 | + ) | |
| 49 | + | |
| 50 | + | |
| 51 | +def resoudre_fusion( | |
| 52 | + chemin_relatif_dossier: str, | |
| 53 | + *, | |
| 54 | + archive_root: Path, | |
| 55 | + local_root: Path, | |
| 56 | +) -> DestinationChoisie: | |
| 57 | + """Fusion dans un dossier existant (FR-009). | |
| 58 | + | |
| 59 | + Réutilise directement le dossier local s'il existe déjà (fusion locale). S'il | |
| 60 | + n'existe qu'archivé (pas en local), effectue d'abord un checkout automatique | |
| 61 | + (`regine_core.archive.checkout.checkout`, specs/005-checkout-reconciliation) | |
| 62 | + avant d'y intégrer les nouveaux fichiers — transparent pour l'appelant. | |
| 63 | + """ | |
| 64 | + dossier_local = local_root / chemin_relatif_dossier | |
| 65 | + | |
| 66 | + if dossier_local.exists(): | |
| 67 | + return DestinationChoisie( | |
| 68 | + type="fusion", dossier_cible=dossier_local, necessite_checkout_archive=False | |
| 69 | + ) | |
| 70 | + | |
| 71 | + from regine_core.archive.checkout import checkout # noqa: PLC0415 | |
| 72 | + | |
| 73 | + dossier_archive = archive_root / chemin_relatif_dossier | |
| 74 | + snapshot = checkout(dossier_archive, dossier_local) | |
| 75 | + return DestinationChoisie( | |
| 76 | + type="fusion", dossier_cible=snapshot.dossier_local, necessite_checkout_archive=True | |
| 77 | + ) | |
| 78 | + | |
| 79 | + | |
| 80 | +def lister_dossiers_candidats( | |
| 81 | + titre_partiel: str, | |
| 82 | + racines: list[Path], | |
| 83 | + *, | |
| 84 | + n: int = 5, | |
| 85 | +) -> list[Path]: | |
| 86 | + """Recherche des dossiers candidats par proximité de titre (FR-008). | |
| 87 | + | |
| 88 | + Parcourt directement les répertoires sous chaque racine fournie (espace de | |
| 89 | + travail local et racines connues de l'archive) et retourne les plus proches | |
| 90 | + de `titre_partiel` par nom (cf. research.md § 4) — pas d'index persistant. | |
| 91 | + """ | |
| 92 | + candidats: dict[str, Path] = {} | |
| 93 | + for racine in racines: | |
| 94 | + if not racine.exists(): | |
| 95 | + continue | |
| 96 | + for enfant in racine.iterdir(): | |
| 97 | + if enfant.is_dir(): | |
| 98 | + candidats[enfant.name] = enfant | |
| 99 | + | |
| 100 | + noms_proches = difflib.get_close_matches(titre_partiel, candidats.keys(), n=n, cutoff=0.3) | |
| 101 | + return [candidats[nom] for nom in noms_proches] | |
| new file mode 100644 | |||
| @@ -0,0 +1,101 @@ | |||
| 1 | +"""Résolution de la destination d'un groupe d'import (FR-007/008/009).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import difflib | ||
| 6 | +from datetime import date as _date | ||
| 7 | +from pathlib import Path | ||
| 8 | + | ||
| 9 | +from regine_core.dossier.root import RootLocation, determine_root | ||
| 10 | +from regine_core.import_carte.types import DestinationChoisie, GroupeImport, TypeDestination | ||
| 11 | + | ||
| 12 | + | ||
| 13 | +def resoudre_destination( | ||
| 14 | + groupe: GroupeImport, | ||
| 15 | + type_destination: TypeDestination, | ||
| 16 | + *, | ||
| 17 | + archive_root: Path, | ||
| 18 | + local_root: Path, | ||
| 19 | + categorie: str | None = None, | ||
| 20 | + dossier_cible: Path | None = None, | ||
| 21 | + root_parent: RootLocation | None = None, | ||
| 22 | +) -> DestinationChoisie: | ||
| 23 | + """Traduit le choix de destination de l'utilisateur en `DestinationChoisie` (FR-007). | ||
| 24 | + | ||
| 25 | + - `nouveau_dossier`/`nouveau_parent` : résout un nouveau `RootLocation` via | ||
| 26 | + `determine_root` (specs/004-categorisation-dossiers), à partir du premier | ||
| 27 | + jour de la plage du groupe et de la `categorie` éventuellement choisie. | ||
| 28 | + - `nouveau_sous_dossier` : hérite `root_parent` tel quel, sans nouvel appel à | ||
| 29 | + `determine_root` (FR-006 de specs/004) — la catégorie/année du dossier | ||
| 30 | + parent s'applique automatiquement. | ||
| 31 | + - `fusion` : cf. `resoudre_fusion_locale`/`resoudre_fusion_archive`. | ||
| 32 | + """ | ||
| 33 | + if type_destination in ("nouveau_dossier", "nouveau_parent"): | ||
| 34 | + date_ref: _date = groupe.plage_dates[0] | ||
| 35 | + root = determine_root(date_ref, categorie, archive_root=archive_root, local_root=local_root) | ||
| 36 | + return DestinationChoisie(type=type_destination, root_location=root) | ||
| 37 | + | ||
| 38 | + if type_destination == "nouveau_sous_dossier": | ||
| 39 | + if root_parent is None: | ||
| 40 | + raise ValueError("root_parent requis pour une destination nouveau_sous_dossier") | ||
| 41 | + return DestinationChoisie( | ||
| 42 | + type=type_destination, dossier_cible=dossier_cible, root_location=root_parent | ||
| 43 | + ) | ||
| 44 | + | ||
| 45 | + raise ValueError( | ||
| 46 | + f"Type de destination '{type_destination}' non géré par resoudre_destination " | ||
| 47 | + "(utiliser resoudre_fusion pour une fusion)" | ||
| 48 | + ) | ||
| 49 | + | ||
| 50 | + | ||
| 51 | +def resoudre_fusion( | ||
| 52 | + chemin_relatif_dossier: str, | ||
| 53 | + *, | ||
| 54 | + archive_root: Path, | ||
| 55 | + local_root: Path, | ||
| 56 | +) -> DestinationChoisie: | ||
| 57 | + """Fusion dans un dossier existant (FR-009). | ||
| 58 | + | ||
| 59 | + Réutilise directement le dossier local s'il existe déjà (fusion locale). S'il | ||
| 60 | + n'existe qu'archivé (pas en local), effectue d'abord un checkout automatique | ||
| 61 | + (`regine_core.archive.checkout.checkout`, specs/005-checkout-reconciliation) | ||
| 62 | + avant d'y intégrer les nouveaux fichiers — transparent pour l'appelant. | ||
| 63 | + """ | ||
| 64 | + dossier_local = local_root / chemin_relatif_dossier | ||
| 65 | + | ||
| 66 | + if dossier_local.exists(): | ||
| 67 | + return DestinationChoisie( | ||
| 68 | + type="fusion", dossier_cible=dossier_local, necessite_checkout_archive=False | ||
| 69 | + ) | ||
| 70 | + | ||
| 71 | + from regine_core.archive.checkout import checkout # noqa: PLC0415 | ||
| 72 | + | ||
| 73 | + dossier_archive = archive_root / chemin_relatif_dossier | ||
| 74 | + snapshot = checkout(dossier_archive, dossier_local) | ||
| 75 | + return DestinationChoisie( | ||
| 76 | + type="fusion", dossier_cible=snapshot.dossier_local, necessite_checkout_archive=True | ||
| 77 | + ) | ||
| 78 | + | ||
| 79 | + | ||
| 80 | +def lister_dossiers_candidats( | ||
| 81 | + titre_partiel: str, | ||
| 82 | + racines: list[Path], | ||
| 83 | + *, | ||
| 84 | + n: int = 5, | ||
| 85 | +) -> list[Path]: | ||
| 86 | + """Recherche des dossiers candidats par proximité de titre (FR-008). | ||
| 87 | + | ||
| 88 | + Parcourt directement les répertoires sous chaque racine fournie (espace de | ||
| 89 | + travail local et racines connues de l'archive) et retourne les plus proches | ||
| 90 | + de `titre_partiel` par nom (cf. research.md § 4) — pas d'index persistant. | ||
| 91 | + """ | ||
| 92 | + candidats: dict[str, Path] = {} | ||
| 93 | + for racine in racines: | ||
| 94 | + if not racine.exists(): | ||
| 95 | + continue | ||
| 96 | + for enfant in racine.iterdir(): | ||
| 97 | + if enfant.is_dir(): | ||
| 98 | + candidats[enfant.name] = enfant | ||
| 99 | + | ||
| 100 | + noms_proches = difflib.get_close_matches(titre_partiel, candidats.keys(), n=n, cutoff=0.3) | ||
| 101 | + return [candidats[nom] for nom in noms_proches] | ||
added
packages/regine-core/src/regine_core/import_carte/groupage.py +105 -0 | new file mode 100644 | ||
| @@ -0,0 +1,105 @@ | ||
| 1 | +"""Répartition des dates et découpage en groupes d'import (FR-002/003/005/006).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from collections import Counter | |
| 6 | +from datetime import date as _date | |
| 7 | +from statistics import median | |
| 8 | + | |
| 9 | +from regine_core.import_carte.types import FichierCandidat, GroupeImport | |
| 10 | +from regine_core.metadata.exif import read_capture_date | |
| 11 | + | |
| 12 | +#: Une date de prise de vue antérieure à cette année est considérée aberrante | |
| 13 | +#: (horloge de boîtier réinitialisée après batterie vide, cf. Edge Case de la spec). | |
| 14 | +ANNEE_MIN_PLAUSIBLE = 1990 | |
| 15 | + | |
| 16 | + | |
| 17 | +def analyser_dates(fichiers: list[FichierCandidat]) -> None: | |
| 18 | + """Lit la date de prise de vue de chaque fichier et détecte les dates aberrantes. | |
| 19 | + | |
| 20 | + Modifie les `FichierCandidat` en place (FR-002/003). Une date aberrante est | |
| 21 | + exclue du calcul de plage mais le fichier reste dans le groupe — c'est une | |
| 22 | + anomalie à signaler, pas un motif d'exclusion du fichier lui-même. | |
| 23 | + """ | |
| 24 | + for fichier in fichiers: | |
| 25 | + date = read_capture_date(fichier.chemin_source) | |
| 26 | + fichier.date_prise_vue = date | |
| 27 | + fichier.date_aberrante = date is not None and date.year < ANNEE_MIN_PLAUSIBLE | |
| 28 | + | |
| 29 | + | |
| 30 | +def _repartition_par_jour(fichiers: list[FichierCandidat]) -> dict[_date, int]: | |
| 31 | + compteur: Counter[_date] = Counter() | |
| 32 | + for f in fichiers: | |
| 33 | + if f.date_prise_vue is not None and not f.date_aberrante: | |
| 34 | + compteur[f.date_prise_vue.date()] += 1 | |
| 35 | + return dict(compteur) | |
| 36 | + | |
| 37 | + | |
| 38 | +def jours_candidats_au_detachement(fichiers: list[FichierCandidat]) -> list[_date]: | |
| 39 | + """Met en avant un ou plusieurs jours isolés comme candidats au détachement (FR-006). | |
| 40 | + | |
| 41 | + Heuristique simple (cf. research.md § 5) : un jour est candidat s'il compte au | |
| 42 | + moins 3 fois la médiane des autres jours et qu'il est isolé (au moins un jour | |
| 43 | + sans photo de part et d'autre). Strictement indicatif — ne détache jamais | |
| 44 | + automatiquement (Principe V). | |
| 45 | + """ | |
| 46 | + repartition = _repartition_par_jour(fichiers) | |
| 47 | + jours = sorted(repartition) | |
| 48 | + if len(jours) < 3: | |
| 49 | + return [] | |
| 50 | + | |
| 51 | + mediane = median(repartition.values()) | |
| 52 | + if mediane == 0: | |
| 53 | + return [] | |
| 54 | + | |
| 55 | + candidats = [] | |
| 56 | + for i, jour in enumerate(jours): | |
| 57 | + ecart_avant = (jour - jours[i - 1]).days if i > 0 else None | |
| 58 | + ecart_apres = (jours[i + 1] - jour).days if i < len(jours) - 1 else None | |
| 59 | + isole = (ecart_avant is None or ecart_avant > 1) and ( | |
| 60 | + ecart_apres is None or ecart_apres > 1 | |
| 61 | + ) | |
| 62 | + if repartition[jour] >= mediane * 3 and isole: | |
| 63 | + candidats.append(jour) | |
| 64 | + return candidats | |
| 65 | + | |
| 66 | + | |
| 67 | +def decouper_en_groupes(fichiers: list[FichierCandidat]) -> list[GroupeImport]: | |
| 68 | + """Découpe les fichiers en groupes ; un seul groupe contigu par défaut (FR-005). | |
| 69 | + | |
| 70 | + Lit d'abord les dates (FR-002) et exclut les dates aberrantes du calcul de | |
| 71 | + plage (FR-003, elles restent dans le groupe, cf. `analyser_dates`). | |
| 72 | + """ | |
| 73 | + analyser_dates(fichiers) | |
| 74 | + | |
| 75 | + dates_valides = [ | |
| 76 | + f.date_prise_vue.date() for f in fichiers if f.date_prise_vue and not f.date_aberrante | |
| 77 | + ] | |
| 78 | + plage = ( | |
| 79 | + (min(dates_valides), max(dates_valides)) | |
| 80 | + if dates_valides | |
| 81 | + else ( | |
| 82 | + _date.today(), | |
| 83 | + _date.today(), | |
| 84 | + ) | |
| 85 | + ) | |
| 86 | + | |
| 87 | + return [GroupeImport(fichiers=fichiers, plage_dates=plage)] | |
| 88 | + | |
| 89 | + | |
| 90 | +def detacher_jours(groupe: GroupeImport, jours: list[_date]) -> list[GroupeImport]: | |
| 91 | + """Détache manuellement un ou plusieurs jours d'un groupe (FR-005), à la demande | |
| 92 | + explicite de l'utilisateur uniquement (FR-006, jamais automatique). | |
| 93 | + | |
| 94 | + Le groupe restant conserve le nom de la plage d'origine plutôt que de le | |
| 95 | + recalculer (cf. spec, Acceptance Scenario 3 de User Story 2). | |
| 96 | + """ | |
| 97 | + jours_set = set(jours) | |
| 98 | + detaches = [ | |
| 99 | + f for f in groupe.fichiers if f.date_prise_vue and f.date_prise_vue.date() in jours_set | |
| 100 | + ] | |
| 101 | + restants = [f for f in groupe.fichiers if f not in detaches] | |
| 102 | + | |
| 103 | + groupe_detache = GroupeImport(fichiers=detaches, plage_dates=(min(jours), max(jours))) | |
| 104 | + groupe_restant = GroupeImport(fichiers=restants, plage_dates=groupe.plage_dates) | |
| 105 | + return [groupe_restant, groupe_detache] | |
| new file mode 100644 | |||
| @@ -0,0 +1,105 @@ | |||
| 1 | +"""Répartition des dates et découpage en groupes d'import (FR-002/003/005/006).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from collections import Counter | ||
| 6 | +from datetime import date as _date | ||
| 7 | +from statistics import median | ||
| 8 | + | ||
| 9 | +from regine_core.import_carte.types import FichierCandidat, GroupeImport | ||
| 10 | +from regine_core.metadata.exif import read_capture_date | ||
| 11 | + | ||
| 12 | +#: Une date de prise de vue antérieure à cette année est considérée aberrante | ||
| 13 | +#: (horloge de boîtier réinitialisée après batterie vide, cf. Edge Case de la spec). | ||
| 14 | +ANNEE_MIN_PLAUSIBLE = 1990 | ||
| 15 | + | ||
| 16 | + | ||
| 17 | +def analyser_dates(fichiers: list[FichierCandidat]) -> None: | ||
| 18 | + """Lit la date de prise de vue de chaque fichier et détecte les dates aberrantes. | ||
| 19 | + | ||
| 20 | + Modifie les `FichierCandidat` en place (FR-002/003). Une date aberrante est | ||
| 21 | + exclue du calcul de plage mais le fichier reste dans le groupe — c'est une | ||
| 22 | + anomalie à signaler, pas un motif d'exclusion du fichier lui-même. | ||
| 23 | + """ | ||
| 24 | + for fichier in fichiers: | ||
| 25 | + date = read_capture_date(fichier.chemin_source) | ||
| 26 | + fichier.date_prise_vue = date | ||
| 27 | + fichier.date_aberrante = date is not None and date.year < ANNEE_MIN_PLAUSIBLE | ||
| 28 | + | ||
| 29 | + | ||
| 30 | +def _repartition_par_jour(fichiers: list[FichierCandidat]) -> dict[_date, int]: | ||
| 31 | + compteur: Counter[_date] = Counter() | ||
| 32 | + for f in fichiers: | ||
| 33 | + if f.date_prise_vue is not None and not f.date_aberrante: | ||
| 34 | + compteur[f.date_prise_vue.date()] += 1 | ||
| 35 | + return dict(compteur) | ||
| 36 | + | ||
| 37 | + | ||
| 38 | +def jours_candidats_au_detachement(fichiers: list[FichierCandidat]) -> list[_date]: | ||
| 39 | + """Met en avant un ou plusieurs jours isolés comme candidats au détachement (FR-006). | ||
| 40 | + | ||
| 41 | + Heuristique simple (cf. research.md § 5) : un jour est candidat s'il compte au | ||
| 42 | + moins 3 fois la médiane des autres jours et qu'il est isolé (au moins un jour | ||
| 43 | + sans photo de part et d'autre). Strictement indicatif — ne détache jamais | ||
| 44 | + automatiquement (Principe V). | ||
| 45 | + """ | ||
| 46 | + repartition = _repartition_par_jour(fichiers) | ||
| 47 | + jours = sorted(repartition) | ||
| 48 | + if len(jours) < 3: | ||
| 49 | + return [] | ||
| 50 | + | ||
| 51 | + mediane = median(repartition.values()) | ||
| 52 | + if mediane == 0: | ||
| 53 | + return [] | ||
| 54 | + | ||
| 55 | + candidats = [] | ||
| 56 | + for i, jour in enumerate(jours): | ||
| 57 | + ecart_avant = (jour - jours[i - 1]).days if i > 0 else None | ||
| 58 | + ecart_apres = (jours[i + 1] - jour).days if i < len(jours) - 1 else None | ||
| 59 | + isole = (ecart_avant is None or ecart_avant > 1) and ( | ||
| 60 | + ecart_apres is None or ecart_apres > 1 | ||
| 61 | + ) | ||
| 62 | + if repartition[jour] >= mediane * 3 and isole: | ||
| 63 | + candidats.append(jour) | ||
| 64 | + return candidats | ||
| 65 | + | ||
| 66 | + | ||
| 67 | +def decouper_en_groupes(fichiers: list[FichierCandidat]) -> list[GroupeImport]: | ||
| 68 | + """Découpe les fichiers en groupes ; un seul groupe contigu par défaut (FR-005). | ||
| 69 | + | ||
| 70 | + Lit d'abord les dates (FR-002) et exclut les dates aberrantes du calcul de | ||
| 71 | + plage (FR-003, elles restent dans le groupe, cf. `analyser_dates`). | ||
| 72 | + """ | ||
| 73 | + analyser_dates(fichiers) | ||
| 74 | + | ||
| 75 | + dates_valides = [ | ||
| 76 | + f.date_prise_vue.date() for f in fichiers if f.date_prise_vue and not f.date_aberrante | ||
| 77 | + ] | ||
| 78 | + plage = ( | ||
| 79 | + (min(dates_valides), max(dates_valides)) | ||
| 80 | + if dates_valides | ||
| 81 | + else ( | ||
| 82 | + _date.today(), | ||
| 83 | + _date.today(), | ||
| 84 | + ) | ||
| 85 | + ) | ||
| 86 | + | ||
| 87 | + return [GroupeImport(fichiers=fichiers, plage_dates=plage)] | ||
| 88 | + | ||
| 89 | + | ||
| 90 | +def detacher_jours(groupe: GroupeImport, jours: list[_date]) -> list[GroupeImport]: | ||
| 91 | + """Détache manuellement un ou plusieurs jours d'un groupe (FR-005), à la demande | ||
| 92 | + explicite de l'utilisateur uniquement (FR-006, jamais automatique). | ||
| 93 | + | ||
| 94 | + Le groupe restant conserve le nom de la plage d'origine plutôt que de le | ||
| 95 | + recalculer (cf. spec, Acceptance Scenario 3 de User Story 2). | ||
| 96 | + """ | ||
| 97 | + jours_set = set(jours) | ||
| 98 | + detaches = [ | ||
| 99 | + f for f in groupe.fichiers if f.date_prise_vue and f.date_prise_vue.date() in jours_set | ||
| 100 | + ] | ||
| 101 | + restants = [f for f in groupe.fichiers if f not in detaches] | ||
| 102 | + | ||
| 103 | + groupe_detache = GroupeImport(fichiers=detaches, plage_dates=(min(jours), max(jours))) | ||
| 104 | + groupe_restant = GroupeImport(fichiers=restants, plage_dates=groupe.plage_dates) | ||
| 105 | + return [groupe_restant, groupe_detache] | ||
added
packages/regine-core/src/regine_core/import_carte/identifiant.py +23 -0 | new file mode 100644 | ||
| @@ -0,0 +1,23 @@ | ||
| 1 | +"""Attribution de l'identifiant pérenne à chaque photo importée (FR-017).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import uuid | |
| 6 | +from pathlib import Path | |
| 7 | + | |
| 8 | +from regine_core.metadata.exif import write_document_id | |
| 9 | + | |
| 10 | + | |
| 11 | +def attribuer_identifiants(fichiers_maitres: list[Path]) -> dict[Path, str]: | |
| 12 | + """Génère un UUID par fichier maître et l'écrit dans les métadonnées XMP (FR-017). | |
| 13 | + | |
| 14 | + Réutilise le champ standard `xmpMM:DocumentID` (cf. `write_document_id`, | |
| 15 | + idempotent) plutôt qu'un champ maison, cohérent avec le Principe IV de la | |
| 16 | + constitution (métadonnées ouvertes et embarquées). | |
| 17 | + """ | |
| 18 | + resultat: dict[Path, str] = {} | |
| 19 | + for chemin in fichiers_maitres: | |
| 20 | + identifiant = str(uuid.uuid4()) | |
| 21 | + write_document_id(chemin, identifiant) | |
| 22 | + resultat[chemin] = identifiant | |
| 23 | + return resultat | |
| new file mode 100644 | |||
| @@ -0,0 +1,23 @@ | |||
| 1 | +"""Attribution de l'identifiant pérenne à chaque photo importée (FR-017).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import uuid | ||
| 6 | +from pathlib import Path | ||
| 7 | + | ||
| 8 | +from regine_core.metadata.exif import write_document_id | ||
| 9 | + | ||
| 10 | + | ||
| 11 | +def attribuer_identifiants(fichiers_maitres: list[Path]) -> dict[Path, str]: | ||
| 12 | + """Génère un UUID par fichier maître et l'écrit dans les métadonnées XMP (FR-017). | ||
| 13 | + | ||
| 14 | + Réutilise le champ standard `xmpMM:DocumentID` (cf. `write_document_id`, | ||
| 15 | + idempotent) plutôt qu'un champ maison, cohérent avec le Principe IV de la | ||
| 16 | + constitution (métadonnées ouvertes et embarquées). | ||
| 17 | + """ | ||
| 18 | + resultat: dict[Path, str] = {} | ||
| 19 | + for chemin in fichiers_maitres: | ||
| 20 | + identifiant = str(uuid.uuid4()) | ||
| 21 | + write_document_id(chemin, identifiant) | ||
| 22 | + resultat[chemin] = identifiant | ||
| 23 | + return resultat | ||
added
packages/regine-core/src/regine_core/import_carte/nommage.py +89 -0 | new file mode 100644 | ||
| @@ -0,0 +1,89 @@ | ||
| 1 | +"""Construction du nom de dossier et renommage synchronisé des fichiers (FR-010/012/013/014).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import re | |
| 6 | +from datetime import date as _date | |
| 7 | +from pathlib import Path | |
| 8 | + | |
| 9 | +from regine_core.import_carte.types import FichierCandidat, GroupeImport, Renommage | |
| 10 | + | |
| 11 | +_CARACTERES_INTERDITS = re.compile(r'[/\\:*?"<>]') | |
| 12 | + | |
| 13 | + | |
| 14 | +def nettoyer_titre(titre: str) -> str: | |
| 15 | + """Nettoie un titre pour en faire un composant de nom de dossier valide (FR-010).""" | |
| 16 | + sans_interdits = _CARACTERES_INTERDITS.sub("", titre) | |
| 17 | + return sans_interdits.strip().replace(" ", "_") | |
| 18 | + | |
| 19 | + | |
| 20 | +def _nom_plage(plage: tuple[_date, _date]) -> str: | |
| 21 | + debut, fin = plage | |
| 22 | + if debut == fin: | |
| 23 | + return debut.strftime("%Y-%m-%d") | |
| 24 | + if (debut.year, debut.month) == (fin.year, fin.month): | |
| 25 | + return f"{debut.strftime('%Y-%m-%d')}-{fin.day:02d}" | |
| 26 | + return f"{debut.strftime('%Y-%m-%d')}_{fin.strftime('%Y-%m-%d')}" | |
| 27 | + | |
| 28 | + | |
| 29 | +def construire_nom_dossier(groupe: GroupeImport, titre: str) -> str: | |
| 30 | + """Construit le nom de dossier `AAAA-MM-JJ_Titre` (ou plage), FR-010.""" | |
| 31 | + return f"{_nom_plage(groupe.plage_dates)}_{nettoyer_titre(titre)}" | |
| 32 | + | |
| 33 | + | |
| 34 | +def construire_nom_dossier_parent(date_premier_import: _date, titre: str) -> str: | |
| 35 | + """Construit le nom d'un dossier parent de voyage, granularité mois (FR-010). | |
| 36 | + | |
| 37 | + `AAAA-MM_Titre` : la date de fin n'est pas connue au premier import (cf. spec). | |
| 38 | + """ | |
| 39 | + return f"{date_premier_import.strftime('%Y-%m')}_{nettoyer_titre(titre)}" | |
| 40 | + | |
| 41 | + | |
| 42 | +def construire_nom_sous_dossier(groupe: GroupeImport, titre: str, lieu: str | None = None) -> str: | |
| 43 | + """Construit le nom d'un sous-dossier d'étape, `AAAA-MM-JJ_Titre_Lieu` (FR-010).""" | |
| 44 | + base = construire_nom_dossier(groupe, titre) | |
| 45 | + if lieu: | |
| 46 | + return f"{base}_{nettoyer_titre(lieu)}" | |
| 47 | + return base | |
| 48 | + | |
| 49 | + | |
| 50 | +def resoudre_collision_nom(chemin_souhaite: Path) -> Path: | |
| 51 | + """Si `chemin_souhaite` existe déjà, propose un suffixe numérique (FR-012).""" | |
| 52 | + if not chemin_souhaite.exists(): | |
| 53 | + return chemin_souhaite | |
| 54 | + compteur = 2 | |
| 55 | + while True: | |
| 56 | + candidat = chemin_souhaite.parent / f"{chemin_souhaite.name}-{compteur}" | |
| 57 | + if not candidat.exists(): | |
| 58 | + return candidat | |
| 59 | + compteur += 1 | |
| 60 | + | |
| 61 | + | |
| 62 | +def renommer_fichiers( | |
| 63 | + fichiers: list[FichierCandidat], date_ref: _date, titre: str | |
| 64 | +) -> list[Renommage]: | |
| 65 | + """Renomme chaque fichier maître en `date_titre_nomOrigine.ext` (FR-013) et | |
| 66 | + synchronise les fichiers associés partageant le même nom de base (FR-014).""" | |
| 67 | + titre_nettoye = nettoyer_titre(titre) | |
| 68 | + prefixe = f"{date_ref.strftime('%Y-%m-%d')}_{titre_nettoye}_" | |
| 69 | + | |
| 70 | + par_base: dict[str, list[FichierCandidat]] = {} | |
| 71 | + for f in fichiers: | |
| 72 | + base = f.chemin_source.name.split(".")[0] | |
| 73 | + par_base.setdefault(base, []).append(f) | |
| 74 | + | |
| 75 | + renommages: list[Renommage] = [] | |
| 76 | + for nom_origine, groupe_fichiers in par_base.items(): | |
| 77 | + nom_final = f"{prefixe}{nom_origine}" | |
| 78 | + fichiers_lies = [] | |
| 79 | + for f in groupe_fichiers: | |
| 80 | + suffixe_complet = f.chemin_source.name[len(nom_origine) :] | |
| 81 | + nouveau_nom = f"{nom_final}{suffixe_complet}" | |
| 82 | + nouveau_chemin = f.chemin_source.with_name(nouveau_nom) | |
| 83 | + f.chemin_source.rename(nouveau_chemin) | |
| 84 | + f.chemin_source = nouveau_chemin | |
| 85 | + fichiers_lies.append(nouveau_chemin) | |
| 86 | + renommages.append( | |
| 87 | + Renommage(nom_origine=nom_origine, nom_final=nom_final, fichiers_lies=fichiers_lies) | |
| 88 | + ) | |
| 89 | + return renommages | |
| new file mode 100644 | |||
| @@ -0,0 +1,89 @@ | |||
| 1 | +"""Construction du nom de dossier et renommage synchronisé des fichiers (FR-010/012/013/014).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import re | ||
| 6 | +from datetime import date as _date | ||
| 7 | +from pathlib import Path | ||
| 8 | + | ||
| 9 | +from regine_core.import_carte.types import FichierCandidat, GroupeImport, Renommage | ||
| 10 | + | ||
| 11 | +_CARACTERES_INTERDITS = re.compile(r'[/\\:*?"<>]') | ||
| 12 | + | ||
| 13 | + | ||
| 14 | +def nettoyer_titre(titre: str) -> str: | ||
| 15 | + """Nettoie un titre pour en faire un composant de nom de dossier valide (FR-010).""" | ||
| 16 | + sans_interdits = _CARACTERES_INTERDITS.sub("", titre) | ||
| 17 | + return sans_interdits.strip().replace(" ", "_") | ||
| 18 | + | ||
| 19 | + | ||
| 20 | +def _nom_plage(plage: tuple[_date, _date]) -> str: | ||
| 21 | + debut, fin = plage | ||
| 22 | + if debut == fin: | ||
| 23 | + return debut.strftime("%Y-%m-%d") | ||
| 24 | + if (debut.year, debut.month) == (fin.year, fin.month): | ||
| 25 | + return f"{debut.strftime('%Y-%m-%d')}-{fin.day:02d}" | ||
| 26 | + return f"{debut.strftime('%Y-%m-%d')}_{fin.strftime('%Y-%m-%d')}" | ||
| 27 | + | ||
| 28 | + | ||
| 29 | +def construire_nom_dossier(groupe: GroupeImport, titre: str) -> str: | ||
| 30 | + """Construit le nom de dossier `AAAA-MM-JJ_Titre` (ou plage), FR-010.""" | ||
| 31 | + return f"{_nom_plage(groupe.plage_dates)}_{nettoyer_titre(titre)}" | ||
| 32 | + | ||
| 33 | + | ||
| 34 | +def construire_nom_dossier_parent(date_premier_import: _date, titre: str) -> str: | ||
| 35 | + """Construit le nom d'un dossier parent de voyage, granularité mois (FR-010). | ||
| 36 | + | ||
| 37 | + `AAAA-MM_Titre` : la date de fin n'est pas connue au premier import (cf. spec). | ||
| 38 | + """ | ||
| 39 | + return f"{date_premier_import.strftime('%Y-%m')}_{nettoyer_titre(titre)}" | ||
| 40 | + | ||
| 41 | + | ||
| 42 | +def construire_nom_sous_dossier(groupe: GroupeImport, titre: str, lieu: str | None = None) -> str: | ||
| 43 | + """Construit le nom d'un sous-dossier d'étape, `AAAA-MM-JJ_Titre_Lieu` (FR-010).""" | ||
| 44 | + base = construire_nom_dossier(groupe, titre) | ||
| 45 | + if lieu: | ||
| 46 | + return f"{base}_{nettoyer_titre(lieu)}" | ||
| 47 | + return base | ||
| 48 | + | ||
| 49 | + | ||
| 50 | +def resoudre_collision_nom(chemin_souhaite: Path) -> Path: | ||
| 51 | + """Si `chemin_souhaite` existe déjà, propose un suffixe numérique (FR-012).""" | ||
| 52 | + if not chemin_souhaite.exists(): | ||
| 53 | + return chemin_souhaite | ||
| 54 | + compteur = 2 | ||
| 55 | + while True: | ||
| 56 | + candidat = chemin_souhaite.parent / f"{chemin_souhaite.name}-{compteur}" | ||
| 57 | + if not candidat.exists(): | ||
| 58 | + return candidat | ||
| 59 | + compteur += 1 | ||
| 60 | + | ||
| 61 | + | ||
| 62 | +def renommer_fichiers( | ||
| 63 | + fichiers: list[FichierCandidat], date_ref: _date, titre: str | ||
| 64 | +) -> list[Renommage]: | ||
| 65 | + """Renomme chaque fichier maître en `date_titre_nomOrigine.ext` (FR-013) et | ||
| 66 | + synchronise les fichiers associés partageant le même nom de base (FR-014).""" | ||
| 67 | + titre_nettoye = nettoyer_titre(titre) | ||
| 68 | + prefixe = f"{date_ref.strftime('%Y-%m-%d')}_{titre_nettoye}_" | ||
| 69 | + | ||
| 70 | + par_base: dict[str, list[FichierCandidat]] = {} | ||
| 71 | + for f in fichiers: | ||
| 72 | + base = f.chemin_source.name.split(".")[0] | ||
| 73 | + par_base.setdefault(base, []).append(f) | ||
| 74 | + | ||
| 75 | + renommages: list[Renommage] = [] | ||
| 76 | + for nom_origine, groupe_fichiers in par_base.items(): | ||
| 77 | + nom_final = f"{prefixe}{nom_origine}" | ||
| 78 | + fichiers_lies = [] | ||
| 79 | + for f in groupe_fichiers: | ||
| 80 | + suffixe_complet = f.chemin_source.name[len(nom_origine) :] | ||
| 81 | + nouveau_nom = f"{nom_final}{suffixe_complet}" | ||
| 82 | + nouveau_chemin = f.chemin_source.with_name(nouveau_nom) | ||
| 83 | + f.chemin_source.rename(nouveau_chemin) | ||
| 84 | + f.chemin_source = nouveau_chemin | ||
| 85 | + fichiers_lies.append(nouveau_chemin) | ||
| 86 | + renommages.append( | ||
| 87 | + Renommage(nom_origine=nom_origine, nom_final=nom_final, fichiers_lies=fichiers_lies) | ||
| 88 | + ) | ||
| 89 | + return renommages | ||
added
packages/regine-core/src/regine_core/import_carte/push.py +73 -0 | new file mode 100644 | ||
| @@ -0,0 +1,73 @@ | ||
| 1 | +"""Résumé de confirmation et transfert final vers l'archive (FR-018/019).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import shutil | |
| 6 | +from dataclasses import dataclass | |
| 7 | +from pathlib import Path | |
| 8 | + | |
| 9 | +from regine_core.integrity.hash import hash_fichier_entier | |
| 10 | + | |
| 11 | + | |
| 12 | +class EchecTransfertError(Exception): | |
| 13 | + """La vérification d'intégrité d'un transfert final vers l'archive a échoué (FR-019).""" | |
| 14 | + | |
| 15 | + | |
| 16 | +class CollisionNomArchiveError(Exception): | |
| 17 | + """Deux fichiers de contenu différent aboutissent au même nom final dans le | |
| 18 | + dossier de destination (FR-012, US3 scénario 3) — jamais d'écrasement silencieux, | |
| 19 | + même lors d'une fusion entre deux imports séparés (ex. deux boîtiers sur la même | |
| 20 | + étape d'un voyage, importés carte par carte plutôt qu'en une seule session).""" | |
| 21 | + | |
| 22 | + | |
| 23 | +@dataclass(frozen=True) | |
| 24 | +class ResumeConfirmation: | |
| 25 | + """Résumé présenté à l'utilisateur avant toute écriture sur l'archive (FR-018).""" | |
| 26 | + | |
| 27 | + nombre_fichiers: int | |
| 28 | + taille_totale: int | |
| 29 | + dossier_destination: Path | |
| 30 | + | |
| 31 | + | |
| 32 | +def preparer_resume(fichiers: list[Path], dossier_destination: Path) -> ResumeConfirmation: | |
| 33 | + """Construit le résumé de confirmation (FR-018) : nombre de fichiers, taille, | |
| 34 | + dossier de destination (y compris son répertoire racine, cf. specs/004).""" | |
| 35 | + taille_totale = sum(f.stat().st_size for f in fichiers) | |
| 36 | + return ResumeConfirmation( | |
| 37 | + nombre_fichiers=len(fichiers), | |
| 38 | + taille_totale=taille_totale, | |
| 39 | + dossier_destination=dossier_destination, | |
| 40 | + ) | |
| 41 | + | |
| 42 | + | |
| 43 | +def archiver( | |
| 44 | + fichiers_locaux: list[Path], | |
| 45 | + dossier_local_racine: Path, | |
| 46 | + dossier_destination: Path, | |
| 47 | +) -> None: | |
| 48 | + """Transfert final vérifié depuis la copie de travail locale déjà renommée (FR-019). | |
| 49 | + | |
| 50 | + Ne DOIT être appelée qu'après confirmation explicite du résumé (FR-018) — | |
| 51 | + cette fonction elle-même ne demande pas confirmation, c'est la responsabilité | |
| 52 | + de l'appelant (façade CLI). | |
| 53 | + """ | |
| 54 | + dossier_destination.mkdir(parents=True, exist_ok=True) | |
| 55 | + for source in fichiers_locaux: | |
| 56 | + relatif = source.relative_to(dossier_local_racine) | |
| 57 | + destination = dossier_destination / relatif | |
| 58 | + destination.parent.mkdir(parents=True, exist_ok=True) | |
| 59 | + | |
| 60 | + hash_source = hash_fichier_entier(source) | |
| 61 | + | |
| 62 | + if destination.exists(): | |
| 63 | + if hash_fichier_entier(destination) == hash_source: | |
| 64 | + continue # doublon déjà archivé (US3 scénario 3) — rien à refaire | |
| 65 | + raise CollisionNomArchiveError( | |
| 66 | + f"{destination} existe déjà avec un contenu différent — refus d'écraser " | |
| 67 | + "silencieusement (probablement deux fichiers de boîtiers différents " | |
| 68 | + "importés séparément aboutissant au même nom final)" | |
| 69 | + ) | |
| 70 | + | |
| 71 | + shutil.copy2(source, destination) | |
| 72 | + if hash_fichier_entier(destination) != hash_source: | |
| 73 | + raise EchecTransfertError(f"Échec de vérification d'intégrité pour {destination}") | |
| new file mode 100644 | |||
| @@ -0,0 +1,73 @@ | |||
| 1 | +"""Résumé de confirmation et transfert final vers l'archive (FR-018/019).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import shutil | ||
| 6 | +from dataclasses import dataclass | ||
| 7 | +from pathlib import Path | ||
| 8 | + | ||
| 9 | +from regine_core.integrity.hash import hash_fichier_entier | ||
| 10 | + | ||
| 11 | + | ||
| 12 | +class EchecTransfertError(Exception): | ||
| 13 | + """La vérification d'intégrité d'un transfert final vers l'archive a échoué (FR-019).""" | ||
| 14 | + | ||
| 15 | + | ||
| 16 | +class CollisionNomArchiveError(Exception): | ||
| 17 | + """Deux fichiers de contenu différent aboutissent au même nom final dans le | ||
| 18 | + dossier de destination (FR-012, US3 scénario 3) — jamais d'écrasement silencieux, | ||
| 19 | + même lors d'une fusion entre deux imports séparés (ex. deux boîtiers sur la même | ||
| 20 | + étape d'un voyage, importés carte par carte plutôt qu'en une seule session).""" | ||
| 21 | + | ||
| 22 | + | ||
| 23 | +@dataclass(frozen=True) | ||
| 24 | +class ResumeConfirmation: | ||
| 25 | + """Résumé présenté à l'utilisateur avant toute écriture sur l'archive (FR-018).""" | ||
| 26 | + | ||
| 27 | + nombre_fichiers: int | ||
| 28 | + taille_totale: int | ||
| 29 | + dossier_destination: Path | ||
| 30 | + | ||
| 31 | + | ||
| 32 | +def preparer_resume(fichiers: list[Path], dossier_destination: Path) -> ResumeConfirmation: | ||
| 33 | + """Construit le résumé de confirmation (FR-018) : nombre de fichiers, taille, | ||
| 34 | + dossier de destination (y compris son répertoire racine, cf. specs/004).""" | ||
| 35 | + taille_totale = sum(f.stat().st_size for f in fichiers) | ||
| 36 | + return ResumeConfirmation( | ||
| 37 | + nombre_fichiers=len(fichiers), | ||
| 38 | + taille_totale=taille_totale, | ||
| 39 | + dossier_destination=dossier_destination, | ||
| 40 | + ) | ||
| 41 | + | ||
| 42 | + | ||
| 43 | +def archiver( | ||
| 44 | + fichiers_locaux: list[Path], | ||
| 45 | + dossier_local_racine: Path, | ||
| 46 | + dossier_destination: Path, | ||
| 47 | +) -> None: | ||
| 48 | + """Transfert final vérifié depuis la copie de travail locale déjà renommée (FR-019). | ||
| 49 | + | ||
| 50 | + Ne DOIT être appelée qu'après confirmation explicite du résumé (FR-018) — | ||
| 51 | + cette fonction elle-même ne demande pas confirmation, c'est la responsabilité | ||
| 52 | + de l'appelant (façade CLI). | ||
| 53 | + """ | ||
| 54 | + dossier_destination.mkdir(parents=True, exist_ok=True) | ||
| 55 | + for source in fichiers_locaux: | ||
| 56 | + relatif = source.relative_to(dossier_local_racine) | ||
| 57 | + destination = dossier_destination / relatif | ||
| 58 | + destination.parent.mkdir(parents=True, exist_ok=True) | ||
| 59 | + | ||
| 60 | + hash_source = hash_fichier_entier(source) | ||
| 61 | + | ||
| 62 | + if destination.exists(): | ||
| 63 | + if hash_fichier_entier(destination) == hash_source: | ||
| 64 | + continue # doublon déjà archivé (US3 scénario 3) — rien à refaire | ||
| 65 | + raise CollisionNomArchiveError( | ||
| 66 | + f"{destination} existe déjà avec un contenu différent — refus d'écraser " | ||
| 67 | + "silencieusement (probablement deux fichiers de boîtiers différents " | ||
| 68 | + "importés séparément aboutissant au même nom final)" | ||
| 69 | + ) | ||
| 70 | + | ||
| 71 | + shutil.copy2(source, destination) | ||
| 72 | + if hash_fichier_entier(destination) != hash_source: | ||
| 73 | + raise EchecTransfertError(f"Échec de vérification d'intégrité pour {destination}") | ||
added
packages/regine-core/src/regine_core/import_carte/types.py +61 -0 | new file mode 100644 | ||
| @@ -0,0 +1,61 @@ | ||
| 1 | +"""Types d'échange du pipeline d'import (cf. data-model.md).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from dataclasses import dataclass, field | |
| 6 | +from datetime import date as _date | |
| 7 | +from datetime import datetime | |
| 8 | +from pathlib import Path | |
| 9 | +from typing import Literal | |
| 10 | + | |
| 11 | +from regine_core.dossier.root import RootLocation | |
| 12 | + | |
| 13 | +TypeFichier = Literal["maitre", "associe"] | |
| 14 | +TypeDestination = Literal["nouveau_dossier", "nouveau_sous_dossier", "fusion", "nouveau_parent"] | |
| 15 | + | |
| 16 | + | |
| 17 | +@dataclass | |
| 18 | +class FichierCandidat: | |
| 19 | + """Un fichier de la carte mémoire, en cours d'analyse (cf. data-model.md).""" | |
| 20 | + | |
| 21 | + chemin_source: Path | |
| 22 | + checksum: str | |
| 23 | + deja_importe: bool = False | |
| 24 | + date_prise_vue: datetime | None = None | |
| 25 | + date_aberrante: bool = False | |
| 26 | + type: TypeFichier = "maitre" | |
| 27 | + #: Nom d'origine tel que donné par le boîtier, avant tout suffixe anti-collision | |
| 28 | + #: local (cf. `copie.py`). Utilisé pour regrouper les collisions par boîtier (FR-015). | |
| 29 | + nom_origine: str | None = None | |
| 30 | + #: Identifiant du boîtier source, résolu par `regine_core.camera_profile` | |
| 31 | + #: uniquement en cas de collision réelle (FR-015/016) — `None` sinon. | |
| 32 | + boitier_id: int | None = None | |
| 33 | + | |
| 34 | + | |
| 35 | +@dataclass | |
| 36 | +class GroupeImport: | |
| 37 | + """Sous-ensemble de fichiers partageant une plage de dates contiguë (FR-005).""" | |
| 38 | + | |
| 39 | + fichiers: list[FichierCandidat] | |
| 40 | + plage_dates: tuple[_date, _date] | |
| 41 | + titre: str | None = None | |
| 42 | + destination: DestinationChoisie | None = None | |
| 43 | + | |
| 44 | + | |
| 45 | +@dataclass | |
| 46 | +class DestinationChoisie: | |
| 47 | + """Résultat de l'étape de destination (FR-007).""" | |
| 48 | + | |
| 49 | + type: TypeDestination | |
| 50 | + dossier_cible: Path | None = None | |
| 51 | + root_location: RootLocation | None = None | |
| 52 | + necessite_checkout_archive: bool = False | |
| 53 | + | |
| 54 | + | |
| 55 | +@dataclass | |
| 56 | +class Renommage: | |
| 57 | + """Renommage d'un fichier maître et de ses fichiers associés (FR-013/014).""" | |
| 58 | + | |
| 59 | + nom_origine: str | |
| 60 | + nom_final: str | |
| 61 | + fichiers_lies: list[Path] = field(default_factory=list) | |
| new file mode 100644 | |||
| @@ -0,0 +1,61 @@ | |||
| 1 | +"""Types d'échange du pipeline d'import (cf. data-model.md).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from dataclasses import dataclass, field | ||
| 6 | +from datetime import date as _date | ||
| 7 | +from datetime import datetime | ||
| 8 | +from pathlib import Path | ||
| 9 | +from typing import Literal | ||
| 10 | + | ||
| 11 | +from regine_core.dossier.root import RootLocation | ||
| 12 | + | ||
| 13 | +TypeFichier = Literal["maitre", "associe"] | ||
| 14 | +TypeDestination = Literal["nouveau_dossier", "nouveau_sous_dossier", "fusion", "nouveau_parent"] | ||
| 15 | + | ||
| 16 | + | ||
| 17 | +@dataclass | ||
| 18 | +class FichierCandidat: | ||
| 19 | + """Un fichier de la carte mémoire, en cours d'analyse (cf. data-model.md).""" | ||
| 20 | + | ||
| 21 | + chemin_source: Path | ||
| 22 | + checksum: str | ||
| 23 | + deja_importe: bool = False | ||
| 24 | + date_prise_vue: datetime | None = None | ||
| 25 | + date_aberrante: bool = False | ||
| 26 | + type: TypeFichier = "maitre" | ||
| 27 | + #: Nom d'origine tel que donné par le boîtier, avant tout suffixe anti-collision | ||
| 28 | + #: local (cf. `copie.py`). Utilisé pour regrouper les collisions par boîtier (FR-015). | ||
| 29 | + nom_origine: str | None = None | ||
| 30 | + #: Identifiant du boîtier source, résolu par `regine_core.camera_profile` | ||
| 31 | + #: uniquement en cas de collision réelle (FR-015/016) — `None` sinon. | ||
| 32 | + boitier_id: int | None = None | ||
| 33 | + | ||
| 34 | + | ||
| 35 | +@dataclass | ||
| 36 | +class GroupeImport: | ||
| 37 | + """Sous-ensemble de fichiers partageant une plage de dates contiguë (FR-005).""" | ||
| 38 | + | ||
| 39 | + fichiers: list[FichierCandidat] | ||
| 40 | + plage_dates: tuple[_date, _date] | ||
| 41 | + titre: str | None = None | ||
| 42 | + destination: DestinationChoisie | None = None | ||
| 43 | + | ||
| 44 | + | ||
| 45 | +@dataclass | ||
| 46 | +class DestinationChoisie: | ||
| 47 | + """Résultat de l'étape de destination (FR-007).""" | ||
| 48 | + | ||
| 49 | + type: TypeDestination | ||
| 50 | + dossier_cible: Path | None = None | ||
| 51 | + root_location: RootLocation | None = None | ||
| 52 | + necessite_checkout_archive: bool = False | ||
| 53 | + | ||
| 54 | + | ||
| 55 | +@dataclass | ||
| 56 | +class Renommage: | ||
| 57 | + """Renommage d'un fichier maître et de ses fichiers associés (FR-013/014).""" | ||
| 58 | + | ||
| 59 | + nom_origine: str | ||
| 60 | + nom_final: str | ||
| 61 | + fichiers_lies: list[Path] = field(default_factory=list) | ||
modified
packages/regine-core/src/regine_core/metadata/exif.py +38 -0 | @@ -11,6 +11,7 @@ from __future__ import annotations | ||
| 11 | 11 | import json |
| 12 | 12 | import subprocess |
| 13 | 13 | from dataclasses import dataclass |
| 14 | +from datetime import datetime | |
| 14 | 15 | from pathlib import Path |
| 15 | 16 | |
| 16 | 17 | |
| @@ -144,3 +145,40 @@ def read_image_data_hash(chemin: Path) -> str | None: | ||
| 144 | 145 | analyse = json.loads(brut) |
| 145 | 146 | data = analyse[0] if analyse else {} |
| 146 | 147 | return _nettoyer(data.get("ImageDataHash")) |
| 148 | + | |
| 149 | + | |
| 150 | +def read_capture_date(chemin: Path) -> datetime | None: | |
| 151 | + """Lit la date de prise de vue (`DateTimeOriginal`), jamais la date de fichier. | |
| 152 | + | |
| 153 | + Cf. specs/001-import-photos FR-002. `None` si le tag est absent ou illisible | |
| 154 | + (ne lève jamais d'exception pour une date manquante). | |
| 155 | + """ | |
| 156 | + data = _get_session().read_tags_json(chemin, "DateTimeOriginal") | |
| 157 | + valeur = _nettoyer(data.get("DateTimeOriginal")) | |
| 158 | + if valeur is None: | |
| 159 | + return None | |
| 160 | + try: | |
| 161 | + return datetime.strptime(valeur, "%Y:%m:%d %H:%M:%S") | |
| 162 | + except ValueError: | |
| 163 | + return None | |
| 164 | + | |
| 165 | + | |
| 166 | +def write_document_id(chemin: Path, identifiant: str) -> None: | |
| 167 | + """Écrit l'identifiant pérenne dans le champ XMP standard `xmpMM:DocumentID`. | |
| 168 | + | |
| 169 | + Cf. specs/001-import-photos FR-017 et research.md § 3 : réutilise ce champ | |
| 170 | + standard plutôt qu'un champ maison. Idempotent : n'écrit rien si le fichier | |
| 171 | + porte déjà un `DocumentID` (ne réécrit jamais un identifiant déjà attribué). | |
| 172 | + """ | |
| 173 | + existant = _get_session().read_tags_json(chemin, "XMP-xmpMM:DocumentID") | |
| 174 | + if _nettoyer(existant.get("DocumentID")) is not None: | |
| 175 | + return | |
| 176 | + _get_session().execute( | |
| 177 | + f"-XMP-xmpMM:DocumentID={identifiant}", "-overwrite_original", str(chemin) | |
| 178 | + ) | |
| 179 | + | |
| 180 | + | |
| 181 | +def read_document_id(chemin: Path) -> str | None: | |
| 182 | + """Relit l'identifiant pérenne déjà écrit (utile pour les tests et le manifeste).""" | |
| 183 | + data = _get_session().read_tags_json(chemin, "XMP-xmpMM:DocumentID") | |
| 184 | + return _nettoyer(data.get("DocumentID")) | |
| @@ -11,6 +11,7 @@ from __future__ import annotations | |||
| 11 | import json | 11 | import json |
| 12 | import subprocess | 12 | import subprocess |
| 13 | from dataclasses import dataclass | 13 | from dataclasses import dataclass |
| 14 | +from datetime import datetime | ||
| 14 | from pathlib import Path | 15 | from pathlib import Path |
| 15 | 16 | ||
| 16 | 17 | ||
| @@ -144,3 +145,40 @@ def read_image_data_hash(chemin: Path) -> str | None: | |||
| 144 | analyse = json.loads(brut) | 145 | analyse = json.loads(brut) |
| 145 | data = analyse[0] if analyse else {} | 146 | data = analyse[0] if analyse else {} |
| 146 | return _nettoyer(data.get("ImageDataHash")) | 147 | return _nettoyer(data.get("ImageDataHash")) |
| 148 | + | ||
| 149 | + | ||
| 150 | +def read_capture_date(chemin: Path) -> datetime | None: | ||
| 151 | + """Lit la date de prise de vue (`DateTimeOriginal`), jamais la date de fichier. | ||
| 152 | + | ||
| 153 | + Cf. specs/001-import-photos FR-002. `None` si le tag est absent ou illisible | ||
| 154 | + (ne lève jamais d'exception pour une date manquante). | ||
| 155 | + """ | ||
| 156 | + data = _get_session().read_tags_json(chemin, "DateTimeOriginal") | ||
| 157 | + valeur = _nettoyer(data.get("DateTimeOriginal")) | ||
| 158 | + if valeur is None: | ||
| 159 | + return None | ||
| 160 | + try: | ||
| 161 | + return datetime.strptime(valeur, "%Y:%m:%d %H:%M:%S") | ||
| 162 | + except ValueError: | ||
| 163 | + return None | ||
| 164 | + | ||
| 165 | + | ||
| 166 | +def write_document_id(chemin: Path, identifiant: str) -> None: | ||
| 167 | + """Écrit l'identifiant pérenne dans le champ XMP standard `xmpMM:DocumentID`. | ||
| 168 | + | ||
| 169 | + Cf. specs/001-import-photos FR-017 et research.md § 3 : réutilise ce champ | ||
| 170 | + standard plutôt qu'un champ maison. Idempotent : n'écrit rien si le fichier | ||
| 171 | + porte déjà un `DocumentID` (ne réécrit jamais un identifiant déjà attribué). | ||
| 172 | + """ | ||
| 173 | + existant = _get_session().read_tags_json(chemin, "XMP-xmpMM:DocumentID") | ||
| 174 | + if _nettoyer(existant.get("DocumentID")) is not None: | ||
| 175 | + return | ||
| 176 | + _get_session().execute( | ||
| 177 | + f"-XMP-xmpMM:DocumentID={identifiant}", "-overwrite_original", str(chemin) | ||
| 178 | + ) | ||
| 179 | + | ||
| 180 | + | ||
| 181 | +def read_document_id(chemin: Path) -> str | None: | ||
| 182 | + """Relit l'identifiant pérenne déjà écrit (utile pour les tests et le manifeste).""" | ||
| 183 | + data = _get_session().read_tags_json(chemin, "XMP-xmpMM:DocumentID") | ||
| 184 | + return _nettoyer(data.get("DocumentID")) | ||
added
packages/regine-core/tests/integration/test_pipeline_import_simple.py +95 -0 | new file mode 100644 | ||
| @@ -0,0 +1,95 @@ | ||
| 1 | +"""Test d'integration du pipeline complet d'import simple (T012, US1).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import base64 | |
| 6 | +import shutil | |
| 7 | +import subprocess | |
| 8 | +from pathlib import Path | |
| 9 | + | |
| 10 | +import pytest | |
| 11 | +from regine_core.import_carte.copie import copier_carte | |
| 12 | +from regine_core.import_carte.destination import resoudre_destination | |
| 13 | +from regine_core.import_carte.groupage import decouper_en_groupes | |
| 14 | +from regine_core.import_carte.identifiant import attribuer_identifiants | |
| 15 | +from regine_core.import_carte.nommage import construire_nom_dossier, renommer_fichiers | |
| 16 | +from regine_core.import_carte.push import archiver, preparer_resume | |
| 17 | +from regine_core.metadata.exif import close_session, read_document_id | |
| 18 | + | |
| 19 | +_JPEG_1X1_BASE64 = ( | |
| 20 | + "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8M" | |
| 21 | + "CgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQ" | |
| 22 | + "EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAA" | |
| 23 | + "AAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAU" | |
| 24 | + "EQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=" | |
| 25 | +) | |
| 26 | + | |
| 27 | +pytestmark = pytest.mark.skipif(shutil.which("exiftool") is None, reason="exiftool non installé") | |
| 28 | + | |
| 29 | + | |
| 30 | +@pytest.fixture(autouse=True) | |
| 31 | +def _close_shared_session(): | |
| 32 | + yield | |
| 33 | + close_session() | |
| 34 | + | |
| 35 | + | |
| 36 | +def _carte_journee_unique(carte: Path) -> None: | |
| 37 | + carte.mkdir(parents=True) | |
| 38 | + chemin = carte / "RD0001.JPG" | |
| 39 | + chemin.write_bytes(base64.b64decode(_JPEG_1X1_BASE64)) | |
| 40 | + subprocess.run( # noqa: S603, S607 | |
| 41 | + ["exiftool", "-DateTimeOriginal=2026:08:15 14:30:00", "-overwrite_original", str(chemin)], | |
| 42 | + check=True, | |
| 43 | + capture_output=True, | |
| 44 | + ) | |
| 45 | + | |
| 46 | + | |
| 47 | +def test_full_pipeline_card_to_archived_folder(tmp_path: Path) -> None: | |
| 48 | + carte = tmp_path / "carte" | |
| 49 | + local_tmp = tmp_path / "local_tmp" | |
| 50 | + archive_root = tmp_path / "archive" | |
| 51 | + local_root = tmp_path / "local_workspace" | |
| 52 | + | |
| 53 | + _carte_journee_unique(carte) | |
| 54 | + | |
| 55 | + # 1. Copie vérifiée depuis la carte (FR-001/004). | |
| 56 | + fichiers = copier_carte(carte, local_tmp) | |
| 57 | + assert len(fichiers) == 1 | |
| 58 | + | |
| 59 | + # 2. Découpage en groupes (FR-002/005) : un seul groupe pour une seule journée. | |
| 60 | + (groupe,) = decouper_en_groupes(fichiers) | |
| 61 | + | |
| 62 | + # 3. Destination (FR-007) : nouveau dossier simple, placement par défaut (année). | |
| 63 | + destination = resoudre_destination( | |
| 64 | + groupe, "nouveau_dossier", archive_root=archive_root, local_root=local_root | |
| 65 | + ) | |
| 66 | + assert destination.root_location.type == "annee" | |
| 67 | + assert destination.root_location.nom == "2026" | |
| 68 | + | |
| 69 | + # 4. Nommage + renommage synchronisé (FR-010/012/013/014). | |
| 70 | + groupe.titre = "Sortie parc" | |
| 71 | + nom_dossier = construire_nom_dossier(groupe, groupe.titre) | |
| 72 | + assert nom_dossier == "2026-08-15_Sortie_parc" | |
| 73 | + renommer_fichiers(groupe.fichiers, groupe.plage_dates[0], groupe.titre) | |
| 74 | + assert groupe.fichiers[0].chemin_source.name == "2026-08-15_Sortie_parc_RD0001.JPG" | |
| 75 | + | |
| 76 | + # 5. Identifiant pérenne (FR-017). | |
| 77 | + chemins_maitres = [f.chemin_source for f in groupe.fichiers if f.type == "maitre"] | |
| 78 | + identifiants = attribuer_identifiants(chemins_maitres) | |
| 79 | + assert read_document_id(chemins_maitres[0]) == identifiants[chemins_maitres[0]] | |
| 80 | + | |
| 81 | + # 6. Résumé + confirmation + archivage (FR-018/019). | |
| 82 | + dossier_final = destination.root_location.chemin_archive / nom_dossier | |
| 83 | + resume = preparer_resume([f.chemin_source for f in groupe.fichiers], dossier_final) | |
| 84 | + assert resume.nombre_fichiers == 1 | |
| 85 | + assert resume.dossier_destination == dossier_final | |
| 86 | + | |
| 87 | + # Rien n'est écrit avant l'appel explicite à archiver() (FR-018). | |
| 88 | + assert not dossier_final.exists() | |
| 89 | + | |
| 90 | + archiver([f.chemin_source for f in groupe.fichiers], local_tmp, dossier_final) | |
| 91 | + | |
| 92 | + nom_final = "2026-08-15_Sortie_parc_RD0001.JPG" | |
| 93 | + fichier_archive = dossier_final / nom_final | |
| 94 | + assert fichier_archive.exists() | |
| 95 | + assert fichier_archive.read_bytes() == (local_tmp / nom_final).read_bytes() | |
| new file mode 100644 | |||
| @@ -0,0 +1,95 @@ | |||
| 1 | +"""Test d'integration du pipeline complet d'import simple (T012, US1).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import base64 | ||
| 6 | +import shutil | ||
| 7 | +import subprocess | ||
| 8 | +from pathlib import Path | ||
| 9 | + | ||
| 10 | +import pytest | ||
| 11 | +from regine_core.import_carte.copie import copier_carte | ||
| 12 | +from regine_core.import_carte.destination import resoudre_destination | ||
| 13 | +from regine_core.import_carte.groupage import decouper_en_groupes | ||
| 14 | +from regine_core.import_carte.identifiant import attribuer_identifiants | ||
| 15 | +from regine_core.import_carte.nommage import construire_nom_dossier, renommer_fichiers | ||
| 16 | +from regine_core.import_carte.push import archiver, preparer_resume | ||
| 17 | +from regine_core.metadata.exif import close_session, read_document_id | ||
| 18 | + | ||
| 19 | +_JPEG_1X1_BASE64 = ( | ||
| 20 | + "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8M" | ||
| 21 | + "CgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQ" | ||
| 22 | + "EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAA" | ||
| 23 | + "AAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAU" | ||
| 24 | + "EQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=" | ||
| 25 | +) | ||
| 26 | + | ||
| 27 | +pytestmark = pytest.mark.skipif(shutil.which("exiftool") is None, reason="exiftool non installé") | ||
| 28 | + | ||
| 29 | + | ||
| 30 | +@pytest.fixture(autouse=True) | ||
| 31 | +def _close_shared_session(): | ||
| 32 | + yield | ||
| 33 | + close_session() | ||
| 34 | + | ||
| 35 | + | ||
| 36 | +def _carte_journee_unique(carte: Path) -> None: | ||
| 37 | + carte.mkdir(parents=True) | ||
| 38 | + chemin = carte / "RD0001.JPG" | ||
| 39 | + chemin.write_bytes(base64.b64decode(_JPEG_1X1_BASE64)) | ||
| 40 | + subprocess.run( # noqa: S603, S607 | ||
| 41 | + ["exiftool", "-DateTimeOriginal=2026:08:15 14:30:00", "-overwrite_original", str(chemin)], | ||
| 42 | + check=True, | ||
| 43 | + capture_output=True, | ||
| 44 | + ) | ||
| 45 | + | ||
| 46 | + | ||
| 47 | +def test_full_pipeline_card_to_archived_folder(tmp_path: Path) -> None: | ||
| 48 | + carte = tmp_path / "carte" | ||
| 49 | + local_tmp = tmp_path / "local_tmp" | ||
| 50 | + archive_root = tmp_path / "archive" | ||
| 51 | + local_root = tmp_path / "local_workspace" | ||
| 52 | + | ||
| 53 | + _carte_journee_unique(carte) | ||
| 54 | + | ||
| 55 | + # 1. Copie vérifiée depuis la carte (FR-001/004). | ||
| 56 | + fichiers = copier_carte(carte, local_tmp) | ||
| 57 | + assert len(fichiers) == 1 | ||
| 58 | + | ||
| 59 | + # 2. Découpage en groupes (FR-002/005) : un seul groupe pour une seule journée. | ||
| 60 | + (groupe,) = decouper_en_groupes(fichiers) | ||
| 61 | + | ||
| 62 | + # 3. Destination (FR-007) : nouveau dossier simple, placement par défaut (année). | ||
| 63 | + destination = resoudre_destination( | ||
| 64 | + groupe, "nouveau_dossier", archive_root=archive_root, local_root=local_root | ||
| 65 | + ) | ||
| 66 | + assert destination.root_location.type == "annee" | ||
| 67 | + assert destination.root_location.nom == "2026" | ||
| 68 | + | ||
| 69 | + # 4. Nommage + renommage synchronisé (FR-010/012/013/014). | ||
| 70 | + groupe.titre = "Sortie parc" | ||
| 71 | + nom_dossier = construire_nom_dossier(groupe, groupe.titre) | ||
| 72 | + assert nom_dossier == "2026-08-15_Sortie_parc" | ||
| 73 | + renommer_fichiers(groupe.fichiers, groupe.plage_dates[0], groupe.titre) | ||
| 74 | + assert groupe.fichiers[0].chemin_source.name == "2026-08-15_Sortie_parc_RD0001.JPG" | ||
| 75 | + | ||
| 76 | + # 5. Identifiant pérenne (FR-017). | ||
| 77 | + chemins_maitres = [f.chemin_source for f in groupe.fichiers if f.type == "maitre"] | ||
| 78 | + identifiants = attribuer_identifiants(chemins_maitres) | ||
| 79 | + assert read_document_id(chemins_maitres[0]) == identifiants[chemins_maitres[0]] | ||
| 80 | + | ||
| 81 | + # 6. Résumé + confirmation + archivage (FR-018/019). | ||
| 82 | + dossier_final = destination.root_location.chemin_archive / nom_dossier | ||
| 83 | + resume = preparer_resume([f.chemin_source for f in groupe.fichiers], dossier_final) | ||
| 84 | + assert resume.nombre_fichiers == 1 | ||
| 85 | + assert resume.dossier_destination == dossier_final | ||
| 86 | + | ||
| 87 | + # Rien n'est écrit avant l'appel explicite à archiver() (FR-018). | ||
| 88 | + assert not dossier_final.exists() | ||
| 89 | + | ||
| 90 | + archiver([f.chemin_source for f in groupe.fichiers], local_tmp, dossier_final) | ||
| 91 | + | ||
| 92 | + nom_final = "2026-08-15_Sortie_parc_RD0001.JPG" | ||
| 93 | + fichier_archive = dossier_final / nom_final | ||
| 94 | + assert fichier_archive.exists() | ||
| 95 | + assert fichier_archive.read_bytes() == (local_tmp / nom_final).read_bytes() | ||
added
packages/regine-core/tests/integration/test_pipeline_multi_jours.py +80 -0 | new file mode 100644 | ||
| @@ -0,0 +1,80 @@ | ||
| 1 | +"""Test d'integration du decoupage multi-jours en plusieurs groupes (T022, US2).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import base64 | |
| 6 | +import shutil | |
| 7 | +import subprocess | |
| 8 | +from datetime import date | |
| 9 | +from pathlib import Path | |
| 10 | + | |
| 11 | +import pytest | |
| 12 | +from regine_core.import_carte.copie import copier_carte | |
| 13 | +from regine_core.import_carte.destination import resoudre_destination | |
| 14 | +from regine_core.import_carte.groupage import decouper_en_groupes, detacher_jours | |
| 15 | +from regine_core.import_carte.nommage import construire_nom_dossier | |
| 16 | +from regine_core.metadata.exif import close_session | |
| 17 | + | |
| 18 | +_JPEG_1X1_BASE64 = ( | |
| 19 | + "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8M" | |
| 20 | + "CgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQ" | |
| 21 | + "EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAA" | |
| 22 | + "AAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAU" | |
| 23 | + "EQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=" | |
| 24 | +) | |
| 25 | + | |
| 26 | +pytestmark = pytest.mark.skipif(shutil.which("exiftool") is None, reason="exiftool non installé") | |
| 27 | + | |
| 28 | + | |
| 29 | +@pytest.fixture(autouse=True) | |
| 30 | +def _close_shared_session(): | |
| 31 | + yield | |
| 32 | + close_session() | |
| 33 | + | |
| 34 | + | |
| 35 | +def _fichier_carte(carte: Path, nom: str, date_prise_vue: str) -> None: | |
| 36 | + chemin = carte / nom | |
| 37 | + chemin.write_bytes(base64.b64decode(_JPEG_1X1_BASE64)) | |
| 38 | + subprocess.run( # noqa: S603, S607 | |
| 39 | + ["exiftool", f"-DateTimeOriginal={date_prise_vue}", "-overwrite_original", str(chemin)], | |
| 40 | + check=True, | |
| 41 | + capture_output=True, | |
| 42 | + ) | |
| 43 | + | |
| 44 | + | |
| 45 | +def test_week_with_isolated_peak_can_be_split_into_two_folders(tmp_path: Path) -> None: | |
| 46 | + carte = tmp_path / "carte" | |
| 47 | + carte.mkdir() | |
| 48 | + # Une semaine de vacances (11-13 août), avec un anniversaire isolé le 20. | |
| 49 | + for i, jour in enumerate(["11", "12", "13"]): | |
| 50 | + _fichier_carte(carte, f"vac_{i}.jpg", f"2026:08:{jour} 10:00:00") | |
| 51 | + for i in range(8): | |
| 52 | + _fichier_carte(carte, f"anniv_{i}.jpg", "2026:08:20 15:00:00") | |
| 53 | + | |
| 54 | + fichiers = copier_carte(carte, tmp_path / "local_tmp") | |
| 55 | + (groupe_initial,) = decouper_en_groupes(fichiers) | |
| 56 | + assert groupe_initial.plage_dates == (date(2026, 8, 11), date(2026, 8, 20)) | |
| 57 | + | |
| 58 | + restant, detache = detacher_jours(groupe_initial, [date(2026, 8, 20)]) | |
| 59 | + | |
| 60 | + assert len(detache.fichiers) == 8 | |
| 61 | + assert len(restant.fichiers) == 3 | |
| 62 | + # Le groupe restant garde la plage d'origine, pas recalculée (FR-005/spec). | |
| 63 | + assert restant.plage_dates == (date(2026, 8, 11), date(2026, 8, 20)) | |
| 64 | + | |
| 65 | + archive_root = tmp_path / "archive" | |
| 66 | + local_root = tmp_path / "local_workspace" | |
| 67 | + dest_vacances = resoudre_destination( | |
| 68 | + restant, "nouveau_dossier", archive_root=archive_root, local_root=local_root | |
| 69 | + ) | |
| 70 | + dest_anniversaire = resoudre_destination( | |
| 71 | + detache, "nouveau_dossier", archive_root=archive_root, local_root=local_root | |
| 72 | + ) | |
| 73 | + | |
| 74 | + nom_vacances = construire_nom_dossier(restant, "Vacances") | |
| 75 | + nom_anniversaire = construire_nom_dossier(detache, "Anniversaire") | |
| 76 | + | |
| 77 | + assert nom_vacances == "2026-08-11-20_Vacances" | |
| 78 | + assert nom_anniversaire == "2026-08-20_Anniversaire" | |
| 79 | + # Deux groupes, deux destinations indépendantes, même répertoire racine (année). | |
| 80 | + assert dest_vacances.root_location.nom == dest_anniversaire.root_location.nom == "2026" | |
| new file mode 100644 | |||
| @@ -0,0 +1,80 @@ | |||
| 1 | +"""Test d'integration du decoupage multi-jours en plusieurs groupes (T022, US2).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import base64 | ||
| 6 | +import shutil | ||
| 7 | +import subprocess | ||
| 8 | +from datetime import date | ||
| 9 | +from pathlib import Path | ||
| 10 | + | ||
| 11 | +import pytest | ||
| 12 | +from regine_core.import_carte.copie import copier_carte | ||
| 13 | +from regine_core.import_carte.destination import resoudre_destination | ||
| 14 | +from regine_core.import_carte.groupage import decouper_en_groupes, detacher_jours | ||
| 15 | +from regine_core.import_carte.nommage import construire_nom_dossier | ||
| 16 | +from regine_core.metadata.exif import close_session | ||
| 17 | + | ||
| 18 | +_JPEG_1X1_BASE64 = ( | ||
| 19 | + "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8M" | ||
| 20 | + "CgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQ" | ||
| 21 | + "EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAA" | ||
| 22 | + "AAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAU" | ||
| 23 | + "EQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=" | ||
| 24 | +) | ||
| 25 | + | ||
| 26 | +pytestmark = pytest.mark.skipif(shutil.which("exiftool") is None, reason="exiftool non installé") | ||
| 27 | + | ||
| 28 | + | ||
| 29 | +@pytest.fixture(autouse=True) | ||
| 30 | +def _close_shared_session(): | ||
| 31 | + yield | ||
| 32 | + close_session() | ||
| 33 | + | ||
| 34 | + | ||
| 35 | +def _fichier_carte(carte: Path, nom: str, date_prise_vue: str) -> None: | ||
| 36 | + chemin = carte / nom | ||
| 37 | + chemin.write_bytes(base64.b64decode(_JPEG_1X1_BASE64)) | ||
| 38 | + subprocess.run( # noqa: S603, S607 | ||
| 39 | + ["exiftool", f"-DateTimeOriginal={date_prise_vue}", "-overwrite_original", str(chemin)], | ||
| 40 | + check=True, | ||
| 41 | + capture_output=True, | ||
| 42 | + ) | ||
| 43 | + | ||
| 44 | + | ||
| 45 | +def test_week_with_isolated_peak_can_be_split_into_two_folders(tmp_path: Path) -> None: | ||
| 46 | + carte = tmp_path / "carte" | ||
| 47 | + carte.mkdir() | ||
| 48 | + # Une semaine de vacances (11-13 août), avec un anniversaire isolé le 20. | ||
| 49 | + for i, jour in enumerate(["11", "12", "13"]): | ||
| 50 | + _fichier_carte(carte, f"vac_{i}.jpg", f"2026:08:{jour} 10:00:00") | ||
| 51 | + for i in range(8): | ||
| 52 | + _fichier_carte(carte, f"anniv_{i}.jpg", "2026:08:20 15:00:00") | ||
| 53 | + | ||
| 54 | + fichiers = copier_carte(carte, tmp_path / "local_tmp") | ||
| 55 | + (groupe_initial,) = decouper_en_groupes(fichiers) | ||
| 56 | + assert groupe_initial.plage_dates == (date(2026, 8, 11), date(2026, 8, 20)) | ||
| 57 | + | ||
| 58 | + restant, detache = detacher_jours(groupe_initial, [date(2026, 8, 20)]) | ||
| 59 | + | ||
| 60 | + assert len(detache.fichiers) == 8 | ||
| 61 | + assert len(restant.fichiers) == 3 | ||
| 62 | + # Le groupe restant garde la plage d'origine, pas recalculée (FR-005/spec). | ||
| 63 | + assert restant.plage_dates == (date(2026, 8, 11), date(2026, 8, 20)) | ||
| 64 | + | ||
| 65 | + archive_root = tmp_path / "archive" | ||
| 66 | + local_root = tmp_path / "local_workspace" | ||
| 67 | + dest_vacances = resoudre_destination( | ||
| 68 | + restant, "nouveau_dossier", archive_root=archive_root, local_root=local_root | ||
| 69 | + ) | ||
| 70 | + dest_anniversaire = resoudre_destination( | ||
| 71 | + detache, "nouveau_dossier", archive_root=archive_root, local_root=local_root | ||
| 72 | + ) | ||
| 73 | + | ||
| 74 | + nom_vacances = construire_nom_dossier(restant, "Vacances") | ||
| 75 | + nom_anniversaire = construire_nom_dossier(detache, "Anniversaire") | ||
| 76 | + | ||
| 77 | + assert nom_vacances == "2026-08-11-20_Vacances" | ||
| 78 | + assert nom_anniversaire == "2026-08-20_Anniversaire" | ||
| 79 | + # Deux groupes, deux destinations indépendantes, même répertoire racine (année). | ||
| 80 | + assert dest_vacances.root_location.nom == dest_anniversaire.root_location.nom == "2026" | ||
added
packages/regine-core/tests/integration/test_pipeline_voyage.py +156 -0 | new file mode 100644 | ||
| @@ -0,0 +1,156 @@ | ||
| 1 | +"""Test d'integration voyage multi-etapes : parent/sous-dossier, fusion, boitiers (T028, T029).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import base64 | |
| 6 | +import shutil | |
| 7 | +import subprocess | |
| 8 | +from pathlib import Path | |
| 9 | + | |
| 10 | +import pytest | |
| 11 | +from regine_core.config.db import open_context_db | |
| 12 | +from regine_core.import_carte.copie import ( | |
| 13 | + copier_carte, | |
| 14 | + regrouper_par_nom_origine, | |
| 15 | + resoudre_collisions_boitiers, | |
| 16 | +) | |
| 17 | +from regine_core.import_carte.destination import resoudre_destination, resoudre_fusion | |
| 18 | +from regine_core.import_carte.groupage import decouper_en_groupes | |
| 19 | +from regine_core.import_carte.nommage import ( | |
| 20 | + construire_nom_dossier_parent, | |
| 21 | + construire_nom_sous_dossier, | |
| 22 | +) | |
| 23 | +from regine_core.metadata.exif import close_session | |
| 24 | + | |
| 25 | +_JPEG_1X1_BASE64 = ( | |
| 26 | + "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8M" | |
| 27 | + "CgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQ" | |
| 28 | + "EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAA" | |
| 29 | + "AAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAU" | |
| 30 | + "EQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=" | |
| 31 | +) | |
| 32 | + | |
| 33 | +pytestmark = pytest.mark.skipif(shutil.which("exiftool") is None, reason="exiftool non installé") | |
| 34 | + | |
| 35 | + | |
| 36 | +@pytest.fixture(autouse=True) | |
| 37 | +def _close_shared_session(): | |
| 38 | + yield | |
| 39 | + close_session() | |
| 40 | + | |
| 41 | + | |
| 42 | +def _fichier(carte: Path, nom: str, date_prise_vue: str, modele: str | None = None) -> Path: | |
| 43 | + chemin = carte / nom | |
| 44 | + chemin.write_bytes(base64.b64decode(_JPEG_1X1_BASE64)) | |
| 45 | + args = [f"-DateTimeOriginal={date_prise_vue}"] | |
| 46 | + if modele: | |
| 47 | + args.append(f"-Model={modele}") | |
| 48 | + subprocess.run( # noqa: S603, S607 | |
| 49 | + ["exiftool", *args, "-overwrite_original", str(chemin)], check=True, capture_output=True | |
| 50 | + ) | |
| 51 | + return chemin | |
| 52 | + | |
| 53 | + | |
| 54 | +def test_parent_folder_then_subfolder_inherits_root_location(tmp_path: Path) -> None: | |
| 55 | + archive_root = tmp_path / "archive" | |
| 56 | + local_root = tmp_path / "local_workspace" | |
| 57 | + | |
| 58 | + # Étape 1 : premier import, nouveau dossier parent avec sa première étape. | |
| 59 | + carte_1 = tmp_path / "carte1" | |
| 60 | + carte_1.mkdir() | |
| 61 | + _fichier(carte_1, "RD0001.JPG", "2026:08:12 10:00:00") | |
| 62 | + fichiers_1 = copier_carte(carte_1, tmp_path / "local_tmp1") | |
| 63 | + (groupe_1,) = decouper_en_groupes(fichiers_1) | |
| 64 | + dest_parent = resoudre_destination( | |
| 65 | + groupe_1, | |
| 66 | + "nouveau_parent", | |
| 67 | + archive_root=archive_root, | |
| 68 | + local_root=local_root, | |
| 69 | + categorie="voyage", | |
| 70 | + ) | |
| 71 | + nom_parent = construire_nom_dossier_parent(groupe_1.plage_dates[0], "Montenegro") | |
| 72 | + nom_etape_1 = construire_nom_sous_dossier(groupe_1, "Kotor", lieu=None) | |
| 73 | + assert nom_parent == "2026-08_Montenegro" | |
| 74 | + assert dest_parent.root_location.nom == "voyage" | |
| 75 | + | |
| 76 | + # Étape 2 : nouvelle carte, nouveau sous-dossier du même dossier parent. | |
| 77 | + carte_2 = tmp_path / "carte2" | |
| 78 | + carte_2.mkdir() | |
| 79 | + _fichier(carte_2, "RD0050.JPG", "2026:08:14 10:00:00") | |
| 80 | + fichiers_2 = copier_carte(carte_2, tmp_path / "local_tmp2") | |
| 81 | + (groupe_2,) = decouper_en_groupes(fichiers_2) | |
| 82 | + dest_etape_2 = resoudre_destination( | |
| 83 | + groupe_2, | |
| 84 | + "nouveau_sous_dossier", | |
| 85 | + archive_root=archive_root, | |
| 86 | + local_root=local_root, | |
| 87 | + root_parent=dest_parent.root_location, # héritage, pas de nouvel appel determine_root | |
| 88 | + ) | |
| 89 | + | |
| 90 | + assert dest_etape_2.root_location is dest_parent.root_location | |
| 91 | + assert dest_etape_2.root_location.nom == "voyage" | |
| 92 | + nom_etape_2 = construire_nom_sous_dossier(groupe_2, "Budva", lieu=None) | |
| 93 | + assert nom_etape_1 != nom_etape_2 | |
| 94 | + | |
| 95 | + | |
| 96 | +def test_fusion_locale_reuses_existing_local_folder(tmp_path: Path) -> None: | |
| 97 | + archive_root = tmp_path / "archive" | |
| 98 | + local_root = tmp_path / "local_workspace" | |
| 99 | + dossier_existant = local_root / "voyage" / "2026-08-12_Kotor" | |
| 100 | + dossier_existant.mkdir(parents=True) | |
| 101 | + (dossier_existant / "dejapresent.jpg").write_bytes(b"x") | |
| 102 | + | |
| 103 | + destination = resoudre_fusion( | |
| 104 | + "voyage/2026-08-12_Kotor", archive_root=archive_root, local_root=local_root | |
| 105 | + ) | |
| 106 | + | |
| 107 | + assert destination.type == "fusion" | |
| 108 | + assert destination.necessite_checkout_archive is False | |
| 109 | + assert destination.dossier_cible == dossier_existant | |
| 110 | + | |
| 111 | + | |
| 112 | +def test_fusion_vers_dossier_deja_archive_declenche_checkout(tmp_path: Path) -> None: | |
| 113 | + """FR-009 : fusion vers un dossier présent uniquement dans l'archive -> checkout | |
| 114 | + automatique (regine_core.archive.checkout, specs/005-checkout-reconciliation).""" | |
| 115 | + archive_root = tmp_path / "archive" | |
| 116 | + local_root = tmp_path / "local_workspace" | |
| 117 | + dossier_archive = archive_root / "voyage" / "2026-08-12_Kotor" | |
| 118 | + dossier_archive.mkdir(parents=True) | |
| 119 | + (dossier_archive / "photo_existante.raf").write_bytes(b"contenu-existant") | |
| 120 | + | |
| 121 | + destination = resoudre_fusion( | |
| 122 | + "voyage/2026-08-12_Kotor", archive_root=archive_root, local_root=local_root | |
| 123 | + ) | |
| 124 | + | |
| 125 | + assert destination.type == "fusion" | |
| 126 | + assert destination.necessite_checkout_archive is True | |
| 127 | + assert destination.dossier_cible is not None | |
| 128 | + assert (destination.dossier_cible / "photo_existante.raf").exists() | |
| 129 | + | |
| 130 | + | |
| 131 | +def test_collision_de_boitiers_resolue_automatiquement_par_modele(tmp_path: Path) -> None: | |
| 132 | + """FR-015 : deux boîtiers différents produisant un fichier de même nom d'origine | |
| 133 | + sont désambiguïsés par défaut par le tag de modèle, sans profil préalable.""" | |
| 134 | + carte = tmp_path / "carte" | |
| 135 | + carte.mkdir() | |
| 136 | + _fichier(carte, "RD0001.JPG", "2026:08:12 10:00:00", modele="Fujifilm X100V") | |
| 137 | + | |
| 138 | + carte_2 = tmp_path / "carte2" | |
| 139 | + carte_2.mkdir() | |
| 140 | + _fichier(carte_2, "RD0001.JPG", "2026:08:12 11:00:00", modele="Ricoh GR III") | |
| 141 | + | |
| 142 | + local_tmp = tmp_path / "local_tmp" | |
| 143 | + fichiers = copier_carte(carte, local_tmp) | |
| 144 | + fichiers += copier_carte(carte_2, local_tmp) | |
| 145 | + | |
| 146 | + collisions = regrouper_par_nom_origine(fichiers) | |
| 147 | + assert "RD0001.JPG" in collisions | |
| 148 | + assert len(collisions["RD0001.JPG"]) == 2 | |
| 149 | + | |
| 150 | + conn = open_context_db(tmp_path / "contexte.sqlite3") | |
| 151 | + a_etiqueter = resoudre_collisions_boitiers(fichiers, conn=conn) | |
| 152 | + | |
| 153 | + assert a_etiqueter == [] # résolu automatiquement par le modèle | |
| 154 | + boitier_a, boitier_b = (f.boitier_id for f in collisions["RD0001.JPG"]) | |
| 155 | + assert boitier_a != boitier_b | |
| 156 | + conn.close() | |
| new file mode 100644 | |||
| @@ -0,0 +1,156 @@ | |||
| 1 | +"""Test d'integration voyage multi-etapes : parent/sous-dossier, fusion, boitiers (T028, T029).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import base64 | ||
| 6 | +import shutil | ||
| 7 | +import subprocess | ||
| 8 | +from pathlib import Path | ||
| 9 | + | ||
| 10 | +import pytest | ||
| 11 | +from regine_core.config.db import open_context_db | ||
| 12 | +from regine_core.import_carte.copie import ( | ||
| 13 | + copier_carte, | ||
| 14 | + regrouper_par_nom_origine, | ||
| 15 | + resoudre_collisions_boitiers, | ||
| 16 | +) | ||
| 17 | +from regine_core.import_carte.destination import resoudre_destination, resoudre_fusion | ||
| 18 | +from regine_core.import_carte.groupage import decouper_en_groupes | ||
| 19 | +from regine_core.import_carte.nommage import ( | ||
| 20 | + construire_nom_dossier_parent, | ||
| 21 | + construire_nom_sous_dossier, | ||
| 22 | +) | ||
| 23 | +from regine_core.metadata.exif import close_session | ||
| 24 | + | ||
| 25 | +_JPEG_1X1_BASE64 = ( | ||
| 26 | + "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8M" | ||
| 27 | + "CgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQ" | ||
| 28 | + "EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAA" | ||
| 29 | + "AAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAU" | ||
| 30 | + "EQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=" | ||
| 31 | +) | ||
| 32 | + | ||
| 33 | +pytestmark = pytest.mark.skipif(shutil.which("exiftool") is None, reason="exiftool non installé") | ||
| 34 | + | ||
| 35 | + | ||
| 36 | +@pytest.fixture(autouse=True) | ||
| 37 | +def _close_shared_session(): | ||
| 38 | + yield | ||
| 39 | + close_session() | ||
| 40 | + | ||
| 41 | + | ||
| 42 | +def _fichier(carte: Path, nom: str, date_prise_vue: str, modele: str | None = None) -> Path: | ||
| 43 | + chemin = carte / nom | ||
| 44 | + chemin.write_bytes(base64.b64decode(_JPEG_1X1_BASE64)) | ||
| 45 | + args = [f"-DateTimeOriginal={date_prise_vue}"] | ||
| 46 | + if modele: | ||
| 47 | + args.append(f"-Model={modele}") | ||
| 48 | + subprocess.run( # noqa: S603, S607 | ||
| 49 | + ["exiftool", *args, "-overwrite_original", str(chemin)], check=True, capture_output=True | ||
| 50 | + ) | ||
| 51 | + return chemin | ||
| 52 | + | ||
| 53 | + | ||
| 54 | +def test_parent_folder_then_subfolder_inherits_root_location(tmp_path: Path) -> None: | ||
| 55 | + archive_root = tmp_path / "archive" | ||
| 56 | + local_root = tmp_path / "local_workspace" | ||
| 57 | + | ||
| 58 | + # Étape 1 : premier import, nouveau dossier parent avec sa première étape. | ||
| 59 | + carte_1 = tmp_path / "carte1" | ||
| 60 | + carte_1.mkdir() | ||
| 61 | + _fichier(carte_1, "RD0001.JPG", "2026:08:12 10:00:00") | ||
| 62 | + fichiers_1 = copier_carte(carte_1, tmp_path / "local_tmp1") | ||
| 63 | + (groupe_1,) = decouper_en_groupes(fichiers_1) | ||
| 64 | + dest_parent = resoudre_destination( | ||
| 65 | + groupe_1, | ||
| 66 | + "nouveau_parent", | ||
| 67 | + archive_root=archive_root, | ||
| 68 | + local_root=local_root, | ||
| 69 | + categorie="voyage", | ||
| 70 | + ) | ||
| 71 | + nom_parent = construire_nom_dossier_parent(groupe_1.plage_dates[0], "Montenegro") | ||
| 72 | + nom_etape_1 = construire_nom_sous_dossier(groupe_1, "Kotor", lieu=None) | ||
| 73 | + assert nom_parent == "2026-08_Montenegro" | ||
| 74 | + assert dest_parent.root_location.nom == "voyage" | ||
| 75 | + | ||
| 76 | + # Étape 2 : nouvelle carte, nouveau sous-dossier du même dossier parent. | ||
| 77 | + carte_2 = tmp_path / "carte2" | ||
| 78 | + carte_2.mkdir() | ||
| 79 | + _fichier(carte_2, "RD0050.JPG", "2026:08:14 10:00:00") | ||
| 80 | + fichiers_2 = copier_carte(carte_2, tmp_path / "local_tmp2") | ||
| 81 | + (groupe_2,) = decouper_en_groupes(fichiers_2) | ||
| 82 | + dest_etape_2 = resoudre_destination( | ||
| 83 | + groupe_2, | ||
| 84 | + "nouveau_sous_dossier", | ||
| 85 | + archive_root=archive_root, | ||
| 86 | + local_root=local_root, | ||
| 87 | + root_parent=dest_parent.root_location, # héritage, pas de nouvel appel determine_root | ||
| 88 | + ) | ||
| 89 | + | ||
| 90 | + assert dest_etape_2.root_location is dest_parent.root_location | ||
| 91 | + assert dest_etape_2.root_location.nom == "voyage" | ||
| 92 | + nom_etape_2 = construire_nom_sous_dossier(groupe_2, "Budva", lieu=None) | ||
| 93 | + assert nom_etape_1 != nom_etape_2 | ||
| 94 | + | ||
| 95 | + | ||
| 96 | +def test_fusion_locale_reuses_existing_local_folder(tmp_path: Path) -> None: | ||
| 97 | + archive_root = tmp_path / "archive" | ||
| 98 | + local_root = tmp_path / "local_workspace" | ||
| 99 | + dossier_existant = local_root / "voyage" / "2026-08-12_Kotor" | ||
| 100 | + dossier_existant.mkdir(parents=True) | ||
| 101 | + (dossier_existant / "dejapresent.jpg").write_bytes(b"x") | ||
| 102 | + | ||
| 103 | + destination = resoudre_fusion( | ||
| 104 | + "voyage/2026-08-12_Kotor", archive_root=archive_root, local_root=local_root | ||
| 105 | + ) | ||
| 106 | + | ||
| 107 | + assert destination.type == "fusion" | ||
| 108 | + assert destination.necessite_checkout_archive is False | ||
| 109 | + assert destination.dossier_cible == dossier_existant | ||
| 110 | + | ||
| 111 | + | ||
| 112 | +def test_fusion_vers_dossier_deja_archive_declenche_checkout(tmp_path: Path) -> None: | ||
| 113 | + """FR-009 : fusion vers un dossier présent uniquement dans l'archive -> checkout | ||
| 114 | + automatique (regine_core.archive.checkout, specs/005-checkout-reconciliation).""" | ||
| 115 | + archive_root = tmp_path / "archive" | ||
| 116 | + local_root = tmp_path / "local_workspace" | ||
| 117 | + dossier_archive = archive_root / "voyage" / "2026-08-12_Kotor" | ||
| 118 | + dossier_archive.mkdir(parents=True) | ||
| 119 | + (dossier_archive / "photo_existante.raf").write_bytes(b"contenu-existant") | ||
| 120 | + | ||
| 121 | + destination = resoudre_fusion( | ||
| 122 | + "voyage/2026-08-12_Kotor", archive_root=archive_root, local_root=local_root | ||
| 123 | + ) | ||
| 124 | + | ||
| 125 | + assert destination.type == "fusion" | ||
| 126 | + assert destination.necessite_checkout_archive is True | ||
| 127 | + assert destination.dossier_cible is not None | ||
| 128 | + assert (destination.dossier_cible / "photo_existante.raf").exists() | ||
| 129 | + | ||
| 130 | + | ||
| 131 | +def test_collision_de_boitiers_resolue_automatiquement_par_modele(tmp_path: Path) -> None: | ||
| 132 | + """FR-015 : deux boîtiers différents produisant un fichier de même nom d'origine | ||
| 133 | + sont désambiguïsés par défaut par le tag de modèle, sans profil préalable.""" | ||
| 134 | + carte = tmp_path / "carte" | ||
| 135 | + carte.mkdir() | ||
| 136 | + _fichier(carte, "RD0001.JPG", "2026:08:12 10:00:00", modele="Fujifilm X100V") | ||
| 137 | + | ||
| 138 | + carte_2 = tmp_path / "carte2" | ||
| 139 | + carte_2.mkdir() | ||
| 140 | + _fichier(carte_2, "RD0001.JPG", "2026:08:12 11:00:00", modele="Ricoh GR III") | ||
| 141 | + | ||
| 142 | + local_tmp = tmp_path / "local_tmp" | ||
| 143 | + fichiers = copier_carte(carte, local_tmp) | ||
| 144 | + fichiers += copier_carte(carte_2, local_tmp) | ||
| 145 | + | ||
| 146 | + collisions = regrouper_par_nom_origine(fichiers) | ||
| 147 | + assert "RD0001.JPG" in collisions | ||
| 148 | + assert len(collisions["RD0001.JPG"]) == 2 | ||
| 149 | + | ||
| 150 | + conn = open_context_db(tmp_path / "contexte.sqlite3") | ||
| 151 | + a_etiqueter = resoudre_collisions_boitiers(fichiers, conn=conn) | ||
| 152 | + | ||
| 153 | + assert a_etiqueter == [] # résolu automatiquement par le modèle | ||
| 154 | + boitier_a, boitier_b = (f.boitier_id for f in collisions["RD0001.JPG"]) | ||
| 155 | + assert boitier_a != boitier_b | ||
| 156 | + conn.close() | ||
added
packages/regine-core/tests/unit/test_copie_checksum.py +80 -0 | new file mode 100644 | ||
| @@ -0,0 +1,80 @@ | ||
| 1 | +"""Tests de la copie vérifiée depuis la carte mémoire (T009).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from pathlib import Path | |
| 6 | +from unittest.mock import patch | |
| 7 | + | |
| 8 | +from regine_core.import_carte.copie import copier_carte | |
| 9 | + | |
| 10 | + | |
| 11 | +def test_copies_new_files_and_verifies_checksum(tmp_path: Path) -> None: | |
| 12 | + carte = tmp_path / "carte" | |
| 13 | + (carte / "DCIM").mkdir(parents=True) | |
| 14 | + (carte / "DCIM" / "RD0001.RAF").write_bytes(b"contenu-raw") | |
| 15 | + | |
| 16 | + fichiers = copier_carte(carte, tmp_path / "local") | |
| 17 | + | |
| 18 | + assert len(fichiers) == 1 | |
| 19 | + assert fichiers[0].chemin_source.read_bytes() == b"contenu-raw" | |
| 20 | + assert fichiers[0].type == "maitre" | |
| 21 | + | |
| 22 | + | |
| 23 | +def test_card_is_read_only_once_per_file(tmp_path: Path) -> None: | |
| 24 | + """FR-001/Edge Case : jamais une seconde lecture de la carte pour un même fichier.""" | |
| 25 | + carte = tmp_path / "carte" | |
| 26 | + carte.mkdir() | |
| 27 | + (carte / "RD0001.RAF").write_bytes(b"contenu-raw" * 1000) | |
| 28 | + | |
| 29 | + lectures_carte = [] | |
| 30 | + original_open = Path.open | |
| 31 | + | |
| 32 | + def _open_espionne(self, *args, **kwargs): | |
| 33 | + if self.parent == carte: | |
| 34 | + lectures_carte.append(self) | |
| 35 | + return original_open(self, *args, **kwargs) | |
| 36 | + | |
| 37 | + with patch.object(Path, "open", _open_espionne): | |
| 38 | + copier_carte(carte, tmp_path / "local") | |
| 39 | + | |
| 40 | + # Une seule ouverture en lecture du fichier source sur la carte. | |
| 41 | + assert lectures_carte.count(carte / "RD0001.RAF") == 1 | |
| 42 | + | |
| 43 | + | |
| 44 | +def test_already_imported_files_are_excluded(tmp_path: Path) -> None: | |
| 45 | + carte = tmp_path / "carte" | |
| 46 | + carte.mkdir() | |
| 47 | + (carte / "RD0001.RAF").write_bytes(b"contenu-deja-importe") | |
| 48 | + | |
| 49 | + from regine_core.integrity.hash import hash_fichier_entier | |
| 50 | + | |
| 51 | + checksum_existant = hash_fichier_entier(carte / "RD0001.RAF") | |
| 52 | + | |
| 53 | + fichiers = copier_carte(carte, tmp_path / "local", checksums_deja_importes={checksum_existant}) | |
| 54 | + | |
| 55 | + assert fichiers == [] | |
| 56 | + | |
| 57 | + | |
| 58 | +def test_non_photo_files_are_out_of_scope(tmp_path: Path) -> None: | |
| 59 | + carte = tmp_path / "carte" | |
| 60 | + carte.mkdir() | |
| 61 | + (carte / "video.mp4").write_bytes(b"contenu-video") | |
| 62 | + (carte / "photo.raf").write_bytes(b"contenu-raw") | |
| 63 | + | |
| 64 | + fichiers = copier_carte(carte, tmp_path / "local") | |
| 65 | + | |
| 66 | + assert len(fichiers) == 1 | |
| 67 | + assert fichiers[0].chemin_source.name == "photo.raf" | |
| 68 | + | |
| 69 | + | |
| 70 | +def test_associated_sidecar_file_is_recognized(tmp_path: Path) -> None: | |
| 71 | + carte = tmp_path / "carte" | |
| 72 | + carte.mkdir() | |
| 73 | + (carte / "photo.raf").write_bytes(b"contenu-raw") | |
| 74 | + (carte / "photo.raf.xmp").write_bytes(b"reglages") | |
| 75 | + | |
| 76 | + fichiers = copier_carte(carte, tmp_path / "local") | |
| 77 | + | |
| 78 | + types = {f.chemin_source.name: f.type for f in fichiers} | |
| 79 | + assert types["photo.raf"] == "maitre" | |
| 80 | + assert types["photo.raf.xmp"] == "associe" | |
| new file mode 100644 | |||
| @@ -0,0 +1,80 @@ | |||
| 1 | +"""Tests de la copie vérifiée depuis la carte mémoire (T009).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from pathlib import Path | ||
| 6 | +from unittest.mock import patch | ||
| 7 | + | ||
| 8 | +from regine_core.import_carte.copie import copier_carte | ||
| 9 | + | ||
| 10 | + | ||
| 11 | +def test_copies_new_files_and_verifies_checksum(tmp_path: Path) -> None: | ||
| 12 | + carte = tmp_path / "carte" | ||
| 13 | + (carte / "DCIM").mkdir(parents=True) | ||
| 14 | + (carte / "DCIM" / "RD0001.RAF").write_bytes(b"contenu-raw") | ||
| 15 | + | ||
| 16 | + fichiers = copier_carte(carte, tmp_path / "local") | ||
| 17 | + | ||
| 18 | + assert len(fichiers) == 1 | ||
| 19 | + assert fichiers[0].chemin_source.read_bytes() == b"contenu-raw" | ||
| 20 | + assert fichiers[0].type == "maitre" | ||
| 21 | + | ||
| 22 | + | ||
| 23 | +def test_card_is_read_only_once_per_file(tmp_path: Path) -> None: | ||
| 24 | + """FR-001/Edge Case : jamais une seconde lecture de la carte pour un même fichier.""" | ||
| 25 | + carte = tmp_path / "carte" | ||
| 26 | + carte.mkdir() | ||
| 27 | + (carte / "RD0001.RAF").write_bytes(b"contenu-raw" * 1000) | ||
| 28 | + | ||
| 29 | + lectures_carte = [] | ||
| 30 | + original_open = Path.open | ||
| 31 | + | ||
| 32 | + def _open_espionne(self, *args, **kwargs): | ||
| 33 | + if self.parent == carte: | ||
| 34 | + lectures_carte.append(self) | ||
| 35 | + return original_open(self, *args, **kwargs) | ||
| 36 | + | ||
| 37 | + with patch.object(Path, "open", _open_espionne): | ||
| 38 | + copier_carte(carte, tmp_path / "local") | ||
| 39 | + | ||
| 40 | + # Une seule ouverture en lecture du fichier source sur la carte. | ||
| 41 | + assert lectures_carte.count(carte / "RD0001.RAF") == 1 | ||
| 42 | + | ||
| 43 | + | ||
| 44 | +def test_already_imported_files_are_excluded(tmp_path: Path) -> None: | ||
| 45 | + carte = tmp_path / "carte" | ||
| 46 | + carte.mkdir() | ||
| 47 | + (carte / "RD0001.RAF").write_bytes(b"contenu-deja-importe") | ||
| 48 | + | ||
| 49 | + from regine_core.integrity.hash import hash_fichier_entier | ||
| 50 | + | ||
| 51 | + checksum_existant = hash_fichier_entier(carte / "RD0001.RAF") | ||
| 52 | + | ||
| 53 | + fichiers = copier_carte(carte, tmp_path / "local", checksums_deja_importes={checksum_existant}) | ||
| 54 | + | ||
| 55 | + assert fichiers == [] | ||
| 56 | + | ||
| 57 | + | ||
| 58 | +def test_non_photo_files_are_out_of_scope(tmp_path: Path) -> None: | ||
| 59 | + carte = tmp_path / "carte" | ||
| 60 | + carte.mkdir() | ||
| 61 | + (carte / "video.mp4").write_bytes(b"contenu-video") | ||
| 62 | + (carte / "photo.raf").write_bytes(b"contenu-raw") | ||
| 63 | + | ||
| 64 | + fichiers = copier_carte(carte, tmp_path / "local") | ||
| 65 | + | ||
| 66 | + assert len(fichiers) == 1 | ||
| 67 | + assert fichiers[0].chemin_source.name == "photo.raf" | ||
| 68 | + | ||
| 69 | + | ||
| 70 | +def test_associated_sidecar_file_is_recognized(tmp_path: Path) -> None: | ||
| 71 | + carte = tmp_path / "carte" | ||
| 72 | + carte.mkdir() | ||
| 73 | + (carte / "photo.raf").write_bytes(b"contenu-raw") | ||
| 74 | + (carte / "photo.raf.xmp").write_bytes(b"reglages") | ||
| 75 | + | ||
| 76 | + fichiers = copier_carte(carte, tmp_path / "local") | ||
| 77 | + | ||
| 78 | + types = {f.chemin_source.name: f.type for f in fichiers} | ||
| 79 | + assert types["photo.raf"] == "maitre" | ||
| 80 | + assert types["photo.raf.xmp"] == "associe" | ||
added
packages/regine-core/tests/unit/test_destination_candidats.py +51 -0 | new file mode 100644 | ||
| @@ -0,0 +1,51 @@ | ||
| 1 | +"""Tests de recherche de dossiers candidats et d'héritage de RootLocation (T026, T027).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from datetime import date | |
| 6 | +from pathlib import Path | |
| 7 | + | |
| 8 | +from regine_core.dossier.root import determine_root | |
| 9 | +from regine_core.import_carte.destination import lister_dossiers_candidats, resoudre_destination | |
| 10 | +from regine_core.import_carte.types import GroupeImport | |
| 11 | + | |
| 12 | + | |
| 13 | +def test_lister_dossiers_candidats_finds_close_title(tmp_path: Path) -> None: | |
| 14 | + racine = tmp_path / "archive" / "2026" | |
| 15 | + (racine / "2026-08-11-20_Vacances_Alsace").mkdir(parents=True) | |
| 16 | + (racine / "2026-06-01_Anniversaire").mkdir(parents=True) | |
| 17 | + | |
| 18 | + candidats = lister_dossiers_candidats("Vacances", [racine]) | |
| 19 | + | |
| 20 | + assert any("Vacances" in c.name for c in candidats) | |
| 21 | + | |
| 22 | + | |
| 23 | +def test_lister_dossiers_candidats_searches_multiple_roots(tmp_path: Path) -> None: | |
| 24 | + racine_locale = tmp_path / "local" | |
| 25 | + racine_archive = tmp_path / "archive" | |
| 26 | + (racine_locale / "2026-08-12_Kotor").mkdir(parents=True) | |
| 27 | + (racine_archive / "2026-08-11_Kotor_Montenegro").mkdir(parents=True) | |
| 28 | + | |
| 29 | + candidats = lister_dossiers_candidats("Kotor", [racine_locale, racine_archive]) | |
| 30 | + | |
| 31 | + assert len(candidats) == 2 | |
| 32 | + | |
| 33 | + | |
| 34 | +def test_nouveau_sous_dossier_reuses_parent_root_location_without_new_call(tmp_path: Path) -> None: | |
| 35 | + archive_root = tmp_path / "archive" | |
| 36 | + local_root = tmp_path / "local" | |
| 37 | + parent_root = determine_root( | |
| 38 | + date(2026, 8, 1), "voyage", archive_root=archive_root, local_root=local_root | |
| 39 | + ) | |
| 40 | + | |
| 41 | + groupe_etape = GroupeImport(fichiers=[], plage_dates=(date(2026, 8, 12), date(2026, 8, 12))) | |
| 42 | + destination = resoudre_destination( | |
| 43 | + groupe_etape, | |
| 44 | + "nouveau_sous_dossier", | |
| 45 | + archive_root=archive_root, | |
| 46 | + local_root=local_root, | |
| 47 | + root_parent=parent_root, | |
| 48 | + ) | |
| 49 | + | |
| 50 | + assert destination.root_location is parent_root # même objet, pas de recalcul | |
| 51 | + assert destination.root_location.nom == "voyage" | |
| new file mode 100644 | |||
| @@ -0,0 +1,51 @@ | |||
| 1 | +"""Tests de recherche de dossiers candidats et d'héritage de RootLocation (T026, T027).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from datetime import date | ||
| 6 | +from pathlib import Path | ||
| 7 | + | ||
| 8 | +from regine_core.dossier.root import determine_root | ||
| 9 | +from regine_core.import_carte.destination import lister_dossiers_candidats, resoudre_destination | ||
| 10 | +from regine_core.import_carte.types import GroupeImport | ||
| 11 | + | ||
| 12 | + | ||
| 13 | +def test_lister_dossiers_candidats_finds_close_title(tmp_path: Path) -> None: | ||
| 14 | + racine = tmp_path / "archive" / "2026" | ||
| 15 | + (racine / "2026-08-11-20_Vacances_Alsace").mkdir(parents=True) | ||
| 16 | + (racine / "2026-06-01_Anniversaire").mkdir(parents=True) | ||
| 17 | + | ||
| 18 | + candidats = lister_dossiers_candidats("Vacances", [racine]) | ||
| 19 | + | ||
| 20 | + assert any("Vacances" in c.name for c in candidats) | ||
| 21 | + | ||
| 22 | + | ||
| 23 | +def test_lister_dossiers_candidats_searches_multiple_roots(tmp_path: Path) -> None: | ||
| 24 | + racine_locale = tmp_path / "local" | ||
| 25 | + racine_archive = tmp_path / "archive" | ||
| 26 | + (racine_locale / "2026-08-12_Kotor").mkdir(parents=True) | ||
| 27 | + (racine_archive / "2026-08-11_Kotor_Montenegro").mkdir(parents=True) | ||
| 28 | + | ||
| 29 | + candidats = lister_dossiers_candidats("Kotor", [racine_locale, racine_archive]) | ||
| 30 | + | ||
| 31 | + assert len(candidats) == 2 | ||
| 32 | + | ||
| 33 | + | ||
| 34 | +def test_nouveau_sous_dossier_reuses_parent_root_location_without_new_call(tmp_path: Path) -> None: | ||
| 35 | + archive_root = tmp_path / "archive" | ||
| 36 | + local_root = tmp_path / "local" | ||
| 37 | + parent_root = determine_root( | ||
| 38 | + date(2026, 8, 1), "voyage", archive_root=archive_root, local_root=local_root | ||
| 39 | + ) | ||
| 40 | + | ||
| 41 | + groupe_etape = GroupeImport(fichiers=[], plage_dates=(date(2026, 8, 12), date(2026, 8, 12))) | ||
| 42 | + destination = resoudre_destination( | ||
| 43 | + groupe_etape, | ||
| 44 | + "nouveau_sous_dossier", | ||
| 45 | + archive_root=archive_root, | ||
| 46 | + local_root=local_root, | ||
| 47 | + root_parent=parent_root, | ||
| 48 | + ) | ||
| 49 | + | ||
| 50 | + assert destination.root_location is parent_root # même objet, pas de recalcul | ||
| 51 | + assert destination.root_location.nom == "voyage" | ||
added
packages/regine-core/tests/unit/test_groupage_dates.py +108 -0 | new file mode 100644 | ||
| @@ -0,0 +1,108 @@ | ||
| 1 | +"""Tests du decoupage en groupes d'import (T010, T020, T021).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import base64 | |
| 6 | +import shutil | |
| 7 | +import subprocess | |
| 8 | +from datetime import date | |
| 9 | +from pathlib import Path | |
| 10 | + | |
| 11 | +import pytest | |
| 12 | +from regine_core.import_carte.groupage import ( | |
| 13 | + decouper_en_groupes, | |
| 14 | + detacher_jours, | |
| 15 | + jours_candidats_au_detachement, | |
| 16 | +) | |
| 17 | +from regine_core.import_carte.types import FichierCandidat | |
| 18 | +from regine_core.metadata.exif import close_session | |
| 19 | + | |
| 20 | +_JPEG_1X1_BASE64 = ( | |
| 21 | + "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8M" | |
| 22 | + "CgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQ" | |
| 23 | + "EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAA" | |
| 24 | + "AAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAU" | |
| 25 | + "EQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=" | |
| 26 | +) | |
| 27 | + | |
| 28 | +pytestmark = pytest.mark.skipif(shutil.which("exiftool") is None, reason="exiftool non installé") | |
| 29 | + | |
| 30 | + | |
| 31 | +@pytest.fixture(autouse=True) | |
| 32 | +def _close_shared_session(): | |
| 33 | + yield | |
| 34 | + close_session() | |
| 35 | + | |
| 36 | + | |
| 37 | +def _fichier_date(tmp_path: Path, nom: str, date_prise_vue: str) -> FichierCandidat: | |
| 38 | + chemin = tmp_path / nom | |
| 39 | + chemin.write_bytes(base64.b64decode(_JPEG_1X1_BASE64)) | |
| 40 | + subprocess.run( # noqa: S603, S607 | |
| 41 | + ["exiftool", f"-DateTimeOriginal={date_prise_vue}", "-overwrite_original", str(chemin)], | |
| 42 | + check=True, | |
| 43 | + capture_output=True, | |
| 44 | + ) | |
| 45 | + return FichierCandidat(chemin_source=chemin, checksum="peu-importe-ici") | |
| 46 | + | |
| 47 | + | |
| 48 | +def test_single_day_produces_one_group(tmp_path: Path) -> None: | |
| 49 | + fichiers = [ | |
| 50 | + _fichier_date(tmp_path, "a.jpg", "2026:08:15 10:00:00"), | |
| 51 | + _fichier_date(tmp_path, "b.jpg", "2026:08:15 11:00:00"), | |
| 52 | + ] | |
| 53 | + | |
| 54 | + groupes = decouper_en_groupes(fichiers) | |
| 55 | + | |
| 56 | + assert len(groupes) == 1 | |
| 57 | + assert groupes[0].plage_dates == (date(2026, 8, 15), date(2026, 8, 15)) | |
| 58 | + | |
| 59 | + | |
| 60 | +def test_aberrant_date_excluded_from_range_but_file_kept(tmp_path: Path) -> None: | |
| 61 | + fichiers = [ | |
| 62 | + _fichier_date(tmp_path, "a.jpg", "2026:08:15 10:00:00"), | |
| 63 | + _fichier_date(tmp_path, "b.jpg", "1980:01:01 00:00:00"), # horloge réinitialisée | |
| 64 | + ] | |
| 65 | + | |
| 66 | + groupes = decouper_en_groupes(fichiers) | |
| 67 | + | |
| 68 | + assert groupes[0].plage_dates == (date(2026, 8, 15), date(2026, 8, 15)) | |
| 69 | + assert len(groupes[0].fichiers) == 2 # le fichier à date aberrante reste dans le groupe | |
| 70 | + fichier_aberrant = next(f for f in fichiers if f.chemin_source.name == "b.jpg") | |
| 71 | + assert fichier_aberrant.date_aberrante is True | |
| 72 | + | |
| 73 | + | |
| 74 | +def test_isolated_day_is_flagged_as_candidate(tmp_path: Path) -> None: | |
| 75 | + fichiers = ( | |
| 76 | + [_fichier_date(tmp_path, f"j1_{i}.jpg", "2026:08:10 10:00:00") for i in range(2)] | |
| 77 | + + [_fichier_date(tmp_path, f"j2_{i}.jpg", "2026:08:11 10:00:00") for i in range(2)] | |
| 78 | + + [ | |
| 79 | + # Jour isolé avec un pic net, entouré d'un intervalle sans photo. | |
| 80 | + _fichier_date(tmp_path, f"pic_{i}.jpg", "2026:08:15 10:00:00") | |
| 81 | + for i in range(10) | |
| 82 | + ] | |
| 83 | + ) | |
| 84 | + | |
| 85 | + from regine_core.import_carte.groupage import analyser_dates | |
| 86 | + | |
| 87 | + analyser_dates(fichiers) | |
| 88 | + candidats = jours_candidats_au_detachement(fichiers) | |
| 89 | + | |
| 90 | + assert date(2026, 8, 15) in candidats | |
| 91 | + assert date(2026, 8, 10) not in candidats | |
| 92 | + | |
| 93 | + | |
| 94 | +def test_detacher_jours_splits_group_and_preserves_original_range(tmp_path: Path) -> None: | |
| 95 | + fichiers = [ | |
| 96 | + _fichier_date(tmp_path, "a.jpg", "2026:08:10 10:00:00"), | |
| 97 | + _fichier_date(tmp_path, "b.jpg", "2026:08:11 10:00:00"), | |
| 98 | + _fichier_date(tmp_path, "c.jpg", "2026:08:15 10:00:00"), | |
| 99 | + ] | |
| 100 | + (groupe,) = decouper_en_groupes(fichiers) | |
| 101 | + plage_origine = groupe.plage_dates | |
| 102 | + | |
| 103 | + restant, detache = detacher_jours(groupe, [date(2026, 8, 15)]) | |
| 104 | + | |
| 105 | + assert len(detache.fichiers) == 1 | |
| 106 | + assert detache.fichiers[0].chemin_source.name == "c.jpg" | |
| 107 | + assert len(restant.fichiers) == 2 | |
| 108 | + assert restant.plage_dates == plage_origine # jamais recalculée | |
| new file mode 100644 | |||
| @@ -0,0 +1,108 @@ | |||
| 1 | +"""Tests du decoupage en groupes d'import (T010, T020, T021).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import base64 | ||
| 6 | +import shutil | ||
| 7 | +import subprocess | ||
| 8 | +from datetime import date | ||
| 9 | +from pathlib import Path | ||
| 10 | + | ||
| 11 | +import pytest | ||
| 12 | +from regine_core.import_carte.groupage import ( | ||
| 13 | + decouper_en_groupes, | ||
| 14 | + detacher_jours, | ||
| 15 | + jours_candidats_au_detachement, | ||
| 16 | +) | ||
| 17 | +from regine_core.import_carte.types import FichierCandidat | ||
| 18 | +from regine_core.metadata.exif import close_session | ||
| 19 | + | ||
| 20 | +_JPEG_1X1_BASE64 = ( | ||
| 21 | + "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8M" | ||
| 22 | + "CgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQ" | ||
| 23 | + "EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAA" | ||
| 24 | + "AAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAU" | ||
| 25 | + "EQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=" | ||
| 26 | +) | ||
| 27 | + | ||
| 28 | +pytestmark = pytest.mark.skipif(shutil.which("exiftool") is None, reason="exiftool non installé") | ||
| 29 | + | ||
| 30 | + | ||
| 31 | +@pytest.fixture(autouse=True) | ||
| 32 | +def _close_shared_session(): | ||
| 33 | + yield | ||
| 34 | + close_session() | ||
| 35 | + | ||
| 36 | + | ||
| 37 | +def _fichier_date(tmp_path: Path, nom: str, date_prise_vue: str) -> FichierCandidat: | ||
| 38 | + chemin = tmp_path / nom | ||
| 39 | + chemin.write_bytes(base64.b64decode(_JPEG_1X1_BASE64)) | ||
| 40 | + subprocess.run( # noqa: S603, S607 | ||
| 41 | + ["exiftool", f"-DateTimeOriginal={date_prise_vue}", "-overwrite_original", str(chemin)], | ||
| 42 | + check=True, | ||
| 43 | + capture_output=True, | ||
| 44 | + ) | ||
| 45 | + return FichierCandidat(chemin_source=chemin, checksum="peu-importe-ici") | ||
| 46 | + | ||
| 47 | + | ||
| 48 | +def test_single_day_produces_one_group(tmp_path: Path) -> None: | ||
| 49 | + fichiers = [ | ||
| 50 | + _fichier_date(tmp_path, "a.jpg", "2026:08:15 10:00:00"), | ||
| 51 | + _fichier_date(tmp_path, "b.jpg", "2026:08:15 11:00:00"), | ||
| 52 | + ] | ||
| 53 | + | ||
| 54 | + groupes = decouper_en_groupes(fichiers) | ||
| 55 | + | ||
| 56 | + assert len(groupes) == 1 | ||
| 57 | + assert groupes[0].plage_dates == (date(2026, 8, 15), date(2026, 8, 15)) | ||
| 58 | + | ||
| 59 | + | ||
| 60 | +def test_aberrant_date_excluded_from_range_but_file_kept(tmp_path: Path) -> None: | ||
| 61 | + fichiers = [ | ||
| 62 | + _fichier_date(tmp_path, "a.jpg", "2026:08:15 10:00:00"), | ||
| 63 | + _fichier_date(tmp_path, "b.jpg", "1980:01:01 00:00:00"), # horloge réinitialisée | ||
| 64 | + ] | ||
| 65 | + | ||
| 66 | + groupes = decouper_en_groupes(fichiers) | ||
| 67 | + | ||
| 68 | + assert groupes[0].plage_dates == (date(2026, 8, 15), date(2026, 8, 15)) | ||
| 69 | + assert len(groupes[0].fichiers) == 2 # le fichier à date aberrante reste dans le groupe | ||
| 70 | + fichier_aberrant = next(f for f in fichiers if f.chemin_source.name == "b.jpg") | ||
| 71 | + assert fichier_aberrant.date_aberrante is True | ||
| 72 | + | ||
| 73 | + | ||
| 74 | +def test_isolated_day_is_flagged_as_candidate(tmp_path: Path) -> None: | ||
| 75 | + fichiers = ( | ||
| 76 | + [_fichier_date(tmp_path, f"j1_{i}.jpg", "2026:08:10 10:00:00") for i in range(2)] | ||
| 77 | + + [_fichier_date(tmp_path, f"j2_{i}.jpg", "2026:08:11 10:00:00") for i in range(2)] | ||
| 78 | + + [ | ||
| 79 | + # Jour isolé avec un pic net, entouré d'un intervalle sans photo. | ||
| 80 | + _fichier_date(tmp_path, f"pic_{i}.jpg", "2026:08:15 10:00:00") | ||
| 81 | + for i in range(10) | ||
| 82 | + ] | ||
| 83 | + ) | ||
| 84 | + | ||
| 85 | + from regine_core.import_carte.groupage import analyser_dates | ||
| 86 | + | ||
| 87 | + analyser_dates(fichiers) | ||
| 88 | + candidats = jours_candidats_au_detachement(fichiers) | ||
| 89 | + | ||
| 90 | + assert date(2026, 8, 15) in candidats | ||
| 91 | + assert date(2026, 8, 10) not in candidats | ||
| 92 | + | ||
| 93 | + | ||
| 94 | +def test_detacher_jours_splits_group_and_preserves_original_range(tmp_path: Path) -> None: | ||
| 95 | + fichiers = [ | ||
| 96 | + _fichier_date(tmp_path, "a.jpg", "2026:08:10 10:00:00"), | ||
| 97 | + _fichier_date(tmp_path, "b.jpg", "2026:08:11 10:00:00"), | ||
| 98 | + _fichier_date(tmp_path, "c.jpg", "2026:08:15 10:00:00"), | ||
| 99 | + ] | ||
| 100 | + (groupe,) = decouper_en_groupes(fichiers) | ||
| 101 | + plage_origine = groupe.plage_dates | ||
| 102 | + | ||
| 103 | + restant, detache = detacher_jours(groupe, [date(2026, 8, 15)]) | ||
| 104 | + | ||
| 105 | + assert len(detache.fichiers) == 1 | ||
| 106 | + assert detache.fichiers[0].chemin_source.name == "c.jpg" | ||
| 107 | + assert len(restant.fichiers) == 2 | ||
| 108 | + assert restant.plage_dates == plage_origine # jamais recalculée | ||
added
packages/regine-core/tests/unit/test_identifiant.py +75 -0 | new file mode 100644 | ||
| @@ -0,0 +1,75 @@ | ||
| 1 | +"""Tests de read_capture_date / write_document_id (T008).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import base64 | |
| 6 | +import shutil | |
| 7 | +import subprocess | |
| 8 | +from pathlib import Path | |
| 9 | + | |
| 10 | +import pytest | |
| 11 | +from regine_core.metadata.exif import ( | |
| 12 | + close_session, | |
| 13 | + read_capture_date, | |
| 14 | + read_document_id, | |
| 15 | + write_document_id, | |
| 16 | +) | |
| 17 | + | |
| 18 | +_JPEG_1X1_BASE64 = ( | |
| 19 | + "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8M" | |
| 20 | + "CgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQ" | |
| 21 | + "EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAA" | |
| 22 | + "AAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAU" | |
| 23 | + "EQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=" | |
| 24 | +) | |
| 25 | + | |
| 26 | +pytestmark = pytest.mark.skipif(shutil.which("exiftool") is None, reason="exiftool non installé") | |
| 27 | + | |
| 28 | + | |
| 29 | +@pytest.fixture(autouse=True) | |
| 30 | +def _close_shared_session(): | |
| 31 | + yield | |
| 32 | + close_session() | |
| 33 | + | |
| 34 | + | |
| 35 | +def _jpeg(tmp_path: Path) -> Path: | |
| 36 | + chemin = tmp_path / "photo.jpg" | |
| 37 | + chemin.write_bytes(base64.b64decode(_JPEG_1X1_BASE64)) | |
| 38 | + return chemin | |
| 39 | + | |
| 40 | + | |
| 41 | +def test_read_capture_date_valid(tmp_path: Path) -> None: | |
| 42 | + chemin = _jpeg(tmp_path) | |
| 43 | + subprocess.run( # noqa: S603, S607 | |
| 44 | + ["exiftool", "-DateTimeOriginal=2026:08:15 14:30:00", "-overwrite_original", str(chemin)], | |
| 45 | + check=True, | |
| 46 | + capture_output=True, | |
| 47 | + ) | |
| 48 | + | |
| 49 | + date = read_capture_date(chemin) | |
| 50 | + | |
| 51 | + assert date is not None | |
| 52 | + assert (date.year, date.month, date.day, date.hour, date.minute) == (2026, 8, 15, 14, 30) | |
| 53 | + | |
| 54 | + | |
| 55 | +def test_read_capture_date_absent(tmp_path: Path) -> None: | |
| 56 | + chemin = _jpeg(tmp_path) | |
| 57 | + | |
| 58 | + assert read_capture_date(chemin) is None | |
| 59 | + | |
| 60 | + | |
| 61 | +def test_write_then_read_document_id(tmp_path: Path) -> None: | |
| 62 | + chemin = _jpeg(tmp_path) | |
| 63 | + | |
| 64 | + write_document_id(chemin, "uuid-test-1234") | |
| 65 | + | |
| 66 | + assert read_document_id(chemin) == "uuid-test-1234" | |
| 67 | + | |
| 68 | + | |
| 69 | +def test_write_document_id_is_idempotent(tmp_path: Path) -> None: | |
| 70 | + chemin = _jpeg(tmp_path) | |
| 71 | + | |
| 72 | + write_document_id(chemin, "uuid-original") | |
| 73 | + write_document_id(chemin, "uuid-different-devrait-etre-ignore") | |
| 74 | + | |
| 75 | + assert read_document_id(chemin) == "uuid-original" | |
| new file mode 100644 | |||
| @@ -0,0 +1,75 @@ | |||
| 1 | +"""Tests de read_capture_date / write_document_id (T008).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import base64 | ||
| 6 | +import shutil | ||
| 7 | +import subprocess | ||
| 8 | +from pathlib import Path | ||
| 9 | + | ||
| 10 | +import pytest | ||
| 11 | +from regine_core.metadata.exif import ( | ||
| 12 | + close_session, | ||
| 13 | + read_capture_date, | ||
| 14 | + read_document_id, | ||
| 15 | + write_document_id, | ||
| 16 | +) | ||
| 17 | + | ||
| 18 | +_JPEG_1X1_BASE64 = ( | ||
| 19 | + "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8M" | ||
| 20 | + "CgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQ" | ||
| 21 | + "EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAA" | ||
| 22 | + "AAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAU" | ||
| 23 | + "EQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=" | ||
| 24 | +) | ||
| 25 | + | ||
| 26 | +pytestmark = pytest.mark.skipif(shutil.which("exiftool") is None, reason="exiftool non installé") | ||
| 27 | + | ||
| 28 | + | ||
| 29 | +@pytest.fixture(autouse=True) | ||
| 30 | +def _close_shared_session(): | ||
| 31 | + yield | ||
| 32 | + close_session() | ||
| 33 | + | ||
| 34 | + | ||
| 35 | +def _jpeg(tmp_path: Path) -> Path: | ||
| 36 | + chemin = tmp_path / "photo.jpg" | ||
| 37 | + chemin.write_bytes(base64.b64decode(_JPEG_1X1_BASE64)) | ||
| 38 | + return chemin | ||
| 39 | + | ||
| 40 | + | ||
| 41 | +def test_read_capture_date_valid(tmp_path: Path) -> None: | ||
| 42 | + chemin = _jpeg(tmp_path) | ||
| 43 | + subprocess.run( # noqa: S603, S607 | ||
| 44 | + ["exiftool", "-DateTimeOriginal=2026:08:15 14:30:00", "-overwrite_original", str(chemin)], | ||
| 45 | + check=True, | ||
| 46 | + capture_output=True, | ||
| 47 | + ) | ||
| 48 | + | ||
| 49 | + date = read_capture_date(chemin) | ||
| 50 | + | ||
| 51 | + assert date is not None | ||
| 52 | + assert (date.year, date.month, date.day, date.hour, date.minute) == (2026, 8, 15, 14, 30) | ||
| 53 | + | ||
| 54 | + | ||
| 55 | +def test_read_capture_date_absent(tmp_path: Path) -> None: | ||
| 56 | + chemin = _jpeg(tmp_path) | ||
| 57 | + | ||
| 58 | + assert read_capture_date(chemin) is None | ||
| 59 | + | ||
| 60 | + | ||
| 61 | +def test_write_then_read_document_id(tmp_path: Path) -> None: | ||
| 62 | + chemin = _jpeg(tmp_path) | ||
| 63 | + | ||
| 64 | + write_document_id(chemin, "uuid-test-1234") | ||
| 65 | + | ||
| 66 | + assert read_document_id(chemin) == "uuid-test-1234" | ||
| 67 | + | ||
| 68 | + | ||
| 69 | +def test_write_document_id_is_idempotent(tmp_path: Path) -> None: | ||
| 70 | + chemin = _jpeg(tmp_path) | ||
| 71 | + | ||
| 72 | + write_document_id(chemin, "uuid-original") | ||
| 73 | + write_document_id(chemin, "uuid-different-devrait-etre-ignore") | ||
| 74 | + | ||
| 75 | + assert read_document_id(chemin) == "uuid-original" | ||
added
packages/regine-core/tests/unit/test_nommage.py +91 -0 | new file mode 100644 | ||
| @@ -0,0 +1,91 @@ | ||
| 1 | +"""Tests de la construction de nom et du renommage synchronise (T011).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from datetime import date | |
| 6 | +from pathlib import Path | |
| 7 | + | |
| 8 | +from regine_core.import_carte.nommage import ( | |
| 9 | + construire_nom_dossier, | |
| 10 | + nettoyer_titre, | |
| 11 | + renommer_fichiers, | |
| 12 | + resoudre_collision_nom, | |
| 13 | +) | |
| 14 | +from regine_core.import_carte.types import FichierCandidat, GroupeImport | |
| 15 | + | |
| 16 | + | |
| 17 | +def test_nettoyer_titre_removes_forbidden_characters_and_spaces() -> None: | |
| 18 | + assert nettoyer_titre('Sortie "parc" vélo?') == "Sortie_parc_vélo" | |
| 19 | + | |
| 20 | + | |
| 21 | +def test_construire_nom_dossier_single_day() -> None: | |
| 22 | + groupe = GroupeImport(fichiers=[], plage_dates=(date(2026, 8, 15), date(2026, 8, 15))) | |
| 23 | + | |
| 24 | + assert construire_nom_dossier(groupe, "Sortie parc") == "2026-08-15_Sortie_parc" | |
| 25 | + | |
| 26 | + | |
| 27 | +def test_construire_nom_dossier_range_same_month() -> None: | |
| 28 | + groupe = GroupeImport(fichiers=[], plage_dates=(date(2026, 8, 11), date(2026, 8, 20))) | |
| 29 | + | |
| 30 | + assert construire_nom_dossier(groupe, "Vacances Alsace") == "2026-08-11-20_Vacances_Alsace" | |
| 31 | + | |
| 32 | + | |
| 33 | +def test_construire_nom_dossier_range_across_months() -> None: | |
| 34 | + groupe = GroupeImport(fichiers=[], plage_dates=(date(2026, 8, 28), date(2026, 9, 2))) | |
| 35 | + | |
| 36 | + assert construire_nom_dossier(groupe, "Voyage") == "2026-08-28_2026-09-02_Voyage" | |
| 37 | + | |
| 38 | + | |
| 39 | +def test_resoudre_collision_nom_no_collision(tmp_path: Path) -> None: | |
| 40 | + chemin = tmp_path / "2026-08-15_Titre" | |
| 41 | + | |
| 42 | + assert resoudre_collision_nom(chemin) == chemin | |
| 43 | + | |
| 44 | + | |
| 45 | +def test_resoudre_collision_nom_suffixes_on_collision(tmp_path: Path) -> None: | |
| 46 | + chemin = tmp_path / "2026-08-15_Titre" | |
| 47 | + chemin.mkdir() | |
| 48 | + | |
| 49 | + resultat = resoudre_collision_nom(chemin) | |
| 50 | + | |
| 51 | + assert resultat == tmp_path / "2026-08-15_Titre-2" | |
| 52 | + | |
| 53 | + | |
| 54 | +def test_renommer_fichiers_renames_master_with_origin_name_suffix(tmp_path: Path) -> None: | |
| 55 | + chemin = tmp_path / "RD0001.RAF" | |
| 56 | + chemin.write_bytes(b"contenu") | |
| 57 | + fichier = FichierCandidat(chemin_source=chemin, checksum="x") | |
| 58 | + | |
| 59 | + renommer_fichiers([fichier], date(2026, 9, 1), "Paris") | |
| 60 | + | |
| 61 | + assert fichier.chemin_source.name == "2026-09-01_Paris_RD0001.RAF" | |
| 62 | + assert fichier.chemin_source.exists() | |
| 63 | + | |
| 64 | + | |
| 65 | +def test_renommer_fichiers_synchronizes_associated_sidecar(tmp_path: Path) -> None: | |
| 66 | + raw = tmp_path / "RD0001.RAF" | |
| 67 | + raw.write_bytes(b"contenu-raw") | |
| 68 | + xmp = tmp_path / "RD0001.RAF.xmp" | |
| 69 | + xmp.write_bytes(b"reglages") | |
| 70 | + fichier_raw = FichierCandidat(chemin_source=raw, checksum="x", type="maitre") | |
| 71 | + fichier_xmp = FichierCandidat(chemin_source=xmp, checksum="y", type="associe") | |
| 72 | + | |
| 73 | + renommer_fichiers([fichier_raw, fichier_xmp], date(2026, 9, 1), "Paris") | |
| 74 | + | |
| 75 | + assert fichier_raw.chemin_source.name == "2026-09-01_Paris_RD0001.RAF" | |
| 76 | + assert fichier_xmp.chemin_source.name == "2026-09-01_Paris_RD0001.RAF.xmp" | |
| 77 | + | |
| 78 | + | |
| 79 | +def test_renommer_fichiers_keeps_raw_jpeg_pair_synchronized(tmp_path: Path) -> None: | |
| 80 | + """Une paire RAW+JPEG de même capture doit recevoir le même prefixe (nom d'origine).""" | |
| 81 | + raw = tmp_path / "RD0001.RAF" | |
| 82 | + raw.write_bytes(b"contenu-raw") | |
| 83 | + jpg = tmp_path / "RD0001.JPG" | |
| 84 | + jpg.write_bytes(b"contenu-jpeg") | |
| 85 | + fichier_raw = FichierCandidat(chemin_source=raw, checksum="x", type="maitre") | |
| 86 | + fichier_jpg = FichierCandidat(chemin_source=jpg, checksum="y", type="maitre") | |
| 87 | + | |
| 88 | + renommer_fichiers([fichier_raw, fichier_jpg], date(2026, 9, 1), "Paris") | |
| 89 | + | |
| 90 | + assert fichier_raw.chemin_source.name == "2026-09-01_Paris_RD0001.RAF" | |
| 91 | + assert fichier_jpg.chemin_source.name == "2026-09-01_Paris_RD0001.JPG" | |
| new file mode 100644 | |||
| @@ -0,0 +1,91 @@ | |||
| 1 | +"""Tests de la construction de nom et du renommage synchronise (T011).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from datetime import date | ||
| 6 | +from pathlib import Path | ||
| 7 | + | ||
| 8 | +from regine_core.import_carte.nommage import ( | ||
| 9 | + construire_nom_dossier, | ||
| 10 | + nettoyer_titre, | ||
| 11 | + renommer_fichiers, | ||
| 12 | + resoudre_collision_nom, | ||
| 13 | +) | ||
| 14 | +from regine_core.import_carte.types import FichierCandidat, GroupeImport | ||
| 15 | + | ||
| 16 | + | ||
| 17 | +def test_nettoyer_titre_removes_forbidden_characters_and_spaces() -> None: | ||
| 18 | + assert nettoyer_titre('Sortie "parc" vélo?') == "Sortie_parc_vélo" | ||
| 19 | + | ||
| 20 | + | ||
| 21 | +def test_construire_nom_dossier_single_day() -> None: | ||
| 22 | + groupe = GroupeImport(fichiers=[], plage_dates=(date(2026, 8, 15), date(2026, 8, 15))) | ||
| 23 | + | ||
| 24 | + assert construire_nom_dossier(groupe, "Sortie parc") == "2026-08-15_Sortie_parc" | ||
| 25 | + | ||
| 26 | + | ||
| 27 | +def test_construire_nom_dossier_range_same_month() -> None: | ||
| 28 | + groupe = GroupeImport(fichiers=[], plage_dates=(date(2026, 8, 11), date(2026, 8, 20))) | ||
| 29 | + | ||
| 30 | + assert construire_nom_dossier(groupe, "Vacances Alsace") == "2026-08-11-20_Vacances_Alsace" | ||
| 31 | + | ||
| 32 | + | ||
| 33 | +def test_construire_nom_dossier_range_across_months() -> None: | ||
| 34 | + groupe = GroupeImport(fichiers=[], plage_dates=(date(2026, 8, 28), date(2026, 9, 2))) | ||
| 35 | + | ||
| 36 | + assert construire_nom_dossier(groupe, "Voyage") == "2026-08-28_2026-09-02_Voyage" | ||
| 37 | + | ||
| 38 | + | ||
| 39 | +def test_resoudre_collision_nom_no_collision(tmp_path: Path) -> None: | ||
| 40 | + chemin = tmp_path / "2026-08-15_Titre" | ||
| 41 | + | ||
| 42 | + assert resoudre_collision_nom(chemin) == chemin | ||
| 43 | + | ||
| 44 | + | ||
| 45 | +def test_resoudre_collision_nom_suffixes_on_collision(tmp_path: Path) -> None: | ||
| 46 | + chemin = tmp_path / "2026-08-15_Titre" | ||
| 47 | + chemin.mkdir() | ||
| 48 | + | ||
| 49 | + resultat = resoudre_collision_nom(chemin) | ||
| 50 | + | ||
| 51 | + assert resultat == tmp_path / "2026-08-15_Titre-2" | ||
| 52 | + | ||
| 53 | + | ||
| 54 | +def test_renommer_fichiers_renames_master_with_origin_name_suffix(tmp_path: Path) -> None: | ||
| 55 | + chemin = tmp_path / "RD0001.RAF" | ||
| 56 | + chemin.write_bytes(b"contenu") | ||
| 57 | + fichier = FichierCandidat(chemin_source=chemin, checksum="x") | ||
| 58 | + | ||
| 59 | + renommer_fichiers([fichier], date(2026, 9, 1), "Paris") | ||
| 60 | + | ||
| 61 | + assert fichier.chemin_source.name == "2026-09-01_Paris_RD0001.RAF" | ||
| 62 | + assert fichier.chemin_source.exists() | ||
| 63 | + | ||
| 64 | + | ||
| 65 | +def test_renommer_fichiers_synchronizes_associated_sidecar(tmp_path: Path) -> None: | ||
| 66 | + raw = tmp_path / "RD0001.RAF" | ||
| 67 | + raw.write_bytes(b"contenu-raw") | ||
| 68 | + xmp = tmp_path / "RD0001.RAF.xmp" | ||
| 69 | + xmp.write_bytes(b"reglages") | ||
| 70 | + fichier_raw = FichierCandidat(chemin_source=raw, checksum="x", type="maitre") | ||
| 71 | + fichier_xmp = FichierCandidat(chemin_source=xmp, checksum="y", type="associe") | ||
| 72 | + | ||
| 73 | + renommer_fichiers([fichier_raw, fichier_xmp], date(2026, 9, 1), "Paris") | ||
| 74 | + | ||
| 75 | + assert fichier_raw.chemin_source.name == "2026-09-01_Paris_RD0001.RAF" | ||
| 76 | + assert fichier_xmp.chemin_source.name == "2026-09-01_Paris_RD0001.RAF.xmp" | ||
| 77 | + | ||
| 78 | + | ||
| 79 | +def test_renommer_fichiers_keeps_raw_jpeg_pair_synchronized(tmp_path: Path) -> None: | ||
| 80 | + """Une paire RAW+JPEG de même capture doit recevoir le même prefixe (nom d'origine).""" | ||
| 81 | + raw = tmp_path / "RD0001.RAF" | ||
| 82 | + raw.write_bytes(b"contenu-raw") | ||
| 83 | + jpg = tmp_path / "RD0001.JPG" | ||
| 84 | + jpg.write_bytes(b"contenu-jpeg") | ||
| 85 | + fichier_raw = FichierCandidat(chemin_source=raw, checksum="x", type="maitre") | ||
| 86 | + fichier_jpg = FichierCandidat(chemin_source=jpg, checksum="y", type="maitre") | ||
| 87 | + | ||
| 88 | + renommer_fichiers([fichier_raw, fichier_jpg], date(2026, 9, 1), "Paris") | ||
| 89 | + | ||
| 90 | + assert fichier_raw.chemin_source.name == "2026-09-01_Paris_RD0001.RAF" | ||
| 91 | + assert fichier_jpg.chemin_source.name == "2026-09-01_Paris_RD0001.JPG" | ||
added
packages/regine-core/tests/unit/test_push_archiver.py +50 -0 | new file mode 100644 | ||
| @@ -0,0 +1,50 @@ | ||
| 1 | +"""Tests de `archiver` : jamais d'écrasement silencieux (FR-012, US3 scénario 3).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from pathlib import Path | |
| 6 | + | |
| 7 | +import pytest | |
| 8 | +from regine_core.import_carte.push import CollisionNomArchiveError, archiver | |
| 9 | + | |
| 10 | + | |
| 11 | +def _preparer_source(tmp_path: Path, contenu: bytes) -> tuple[Path, Path]: | |
| 12 | + local_racine = tmp_path / "local" | |
| 13 | + local_racine.mkdir() | |
| 14 | + fichier = local_racine / "2026-08-12_Kotor_RD0001.JPG" | |
| 15 | + fichier.write_bytes(contenu) | |
| 16 | + return local_racine, fichier | |
| 17 | + | |
| 18 | + | |
| 19 | +def test_archiver_copie_normalement_un_nouveau_fichier(tmp_path: Path) -> None: | |
| 20 | + local_racine, fichier = _preparer_source(tmp_path, b"contenu-a") | |
| 21 | + destination = tmp_path / "archive" | |
| 22 | + | |
| 23 | + archiver([fichier], local_racine, destination) | |
| 24 | + | |
| 25 | + assert (destination / fichier.name).read_bytes() == b"contenu-a" | |
| 26 | + | |
| 27 | + | |
| 28 | +def test_archiver_ignore_un_doublon_deja_archive_de_meme_contenu(tmp_path: Path) -> None: | |
| 29 | + local_racine, fichier = _preparer_source(tmp_path, b"contenu-a") | |
| 30 | + destination = tmp_path / "archive" | |
| 31 | + destination.mkdir() | |
| 32 | + (destination / fichier.name).write_bytes(b"contenu-a") | |
| 33 | + | |
| 34 | + archiver([fichier], local_racine, destination) # ne doit pas lever | |
| 35 | + | |
| 36 | + assert (destination / fichier.name).read_bytes() == b"contenu-a" | |
| 37 | + | |
| 38 | + | |
| 39 | +def test_archiver_refuse_ecraser_un_fichier_existant_de_contenu_different(tmp_path: Path) -> None: | |
| 40 | + """Deux boîtiers différents, importés séparément (fusion), produisant le même | |
| 41 | + nom final : ne DOIT jamais écraser silencieusement l'un par l'autre.""" | |
| 42 | + local_racine, fichier = _preparer_source(tmp_path, b"contenu-boitier-b") | |
| 43 | + destination = tmp_path / "archive" | |
| 44 | + destination.mkdir() | |
| 45 | + (destination / fichier.name).write_bytes(b"contenu-boitier-a-deja-archive") | |
| 46 | + | |
| 47 | + with pytest.raises(CollisionNomArchiveError): | |
| 48 | + archiver([fichier], local_racine, destination) | |
| 49 | + | |
| 50 | + assert (destination / fichier.name).read_bytes() == b"contenu-boitier-a-deja-archive" | |
| new file mode 100644 | |||
| @@ -0,0 +1,50 @@ | |||
| 1 | +"""Tests de `archiver` : jamais d'écrasement silencieux (FR-012, US3 scénario 3).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from pathlib import Path | ||
| 6 | + | ||
| 7 | +import pytest | ||
| 8 | +from regine_core.import_carte.push import CollisionNomArchiveError, archiver | ||
| 9 | + | ||
| 10 | + | ||
| 11 | +def _preparer_source(tmp_path: Path, contenu: bytes) -> tuple[Path, Path]: | ||
| 12 | + local_racine = tmp_path / "local" | ||
| 13 | + local_racine.mkdir() | ||
| 14 | + fichier = local_racine / "2026-08-12_Kotor_RD0001.JPG" | ||
| 15 | + fichier.write_bytes(contenu) | ||
| 16 | + return local_racine, fichier | ||
| 17 | + | ||
| 18 | + | ||
| 19 | +def test_archiver_copie_normalement_un_nouveau_fichier(tmp_path: Path) -> None: | ||
| 20 | + local_racine, fichier = _preparer_source(tmp_path, b"contenu-a") | ||
| 21 | + destination = tmp_path / "archive" | ||
| 22 | + | ||
| 23 | + archiver([fichier], local_racine, destination) | ||
| 24 | + | ||
| 25 | + assert (destination / fichier.name).read_bytes() == b"contenu-a" | ||
| 26 | + | ||
| 27 | + | ||
| 28 | +def test_archiver_ignore_un_doublon_deja_archive_de_meme_contenu(tmp_path: Path) -> None: | ||
| 29 | + local_racine, fichier = _preparer_source(tmp_path, b"contenu-a") | ||
| 30 | + destination = tmp_path / "archive" | ||
| 31 | + destination.mkdir() | ||
| 32 | + (destination / fichier.name).write_bytes(b"contenu-a") | ||
| 33 | + | ||
| 34 | + archiver([fichier], local_racine, destination) # ne doit pas lever | ||
| 35 | + | ||
| 36 | + assert (destination / fichier.name).read_bytes() == b"contenu-a" | ||
| 37 | + | ||
| 38 | + | ||
| 39 | +def test_archiver_refuse_ecraser_un_fichier_existant_de_contenu_different(tmp_path: Path) -> None: | ||
| 40 | + """Deux boîtiers différents, importés séparément (fusion), produisant le même | ||
| 41 | + nom final : ne DOIT jamais écraser silencieusement l'un par l'autre.""" | ||
| 42 | + local_racine, fichier = _preparer_source(tmp_path, b"contenu-boitier-b") | ||
| 43 | + destination = tmp_path / "archive" | ||
| 44 | + destination.mkdir() | ||
| 45 | + (destination / fichier.name).write_bytes(b"contenu-boitier-a-deja-archive") | ||
| 46 | + | ||
| 47 | + with pytest.raises(CollisionNomArchiveError): | ||
| 48 | + archiver([fichier], local_racine, destination) | ||
| 49 | + | ||
| 50 | + assert (destination / fichier.name).read_bytes() == b"contenu-boitier-a-deja-archive" | ||
modified
specs/001-import-photos/contracts/cli-import.md +7 -0 | @@ -29,3 +29,10 @@ regine import /Volumes/CARTE_SD [--titre TEXTE] [--destination nouveau|sous-doss | ||
| 29 | 29 | |
| 30 | 30 | - `--yes` accepte les propositions par défaut (groupe unique non découpé, pas de catégorie) sans les demander interactivement ; ne bipasse jamais la confirmation finale d'écriture sur l'archive (FR-018 reste dû même en mode non interactif — nécessite `--yes` explicitement à ce niveau aussi, jamais implicite). |
| 31 | 31 | - `--categorie` et `--annee` sont mutuellement exclusifs ; en leur absence en mode interactif, la question est posée normalement. |
| 32 | + | |
| 33 | +## Notes d'implémentation (post-US3) | |
| 34 | + | |
| 35 | +- `--destination` accepte exactement `nouveau`, `parent`, `sous-dossier:CHEMIN_RELATIF`, `fusion:CHEMIN_RELATIF` (le préfixe suit `id_cible` après le `:`) — `CHEMIN_RELATIF` est relatif à `--archive-root`/`--local-root` (ex. `voyage/2026-08_Montenegro`), tel que retourné par `lister_dossiers_candidats` ou saisi manuellement. En son absence en mode interactif, une question à 4 choix est posée ; en mode `--yes` sans `--destination`, le comportement par défaut est `nouveau_dossier` (US1, non régressif). | |
| 36 | +- Un `nouveau_sous_dossier` est physiquement imbriqué sous le dossier parent réel (`archive_root/CHEMIN_RELATIF/nom_etape/...`), pas seulement sous la racine catégorie/année héritée — `regine_core.import_carte.destination.resoudre_destination` attend un `dossier_cible` déjà construit par l'appelant pour ce type (elle ne le calcule pas elle-même). | |
| 37 | +- `--contexte-db` (optionnel, défaut `<local_root>/.regine-contexte.sqlite3`) ouvre la base de contexte centralisée (`regine_core.config.db`) utilisée pour la désambiguïsation de boîtiers (specs/002) et le cache de catégories (specs/004). | |
| 38 | +- **Limitation connue** : la désambiguïsation automatique de boîtiers par tag de modèle (FR-015) n'opère que sur les fichiers copiés au sein d'un même appel `copier_carte` (un seul import), conformément au scénario d'acceptation US3 #5 ("au sein d'un même import"). Une fusion (`fusion:CHEMIN`) ciblant un dossier déjà peuplé lors d'un import **précédent et séparé** ne redétecte pas les boîtiers déjà présents : si un fichier du nouvel import aboutit, après renommage, au même nom final qu'un fichier déjà archivé mais de contenu différent (deux boîtiers différents ayant produit un nom d'origine identique, importés carte par carte plutôt qu'en une seule session), `regine_core.import_carte.push.archiver` lève `CollisionNomArchiveError` plutôt que d'écraser silencieusement (FR-012 appliqué au niveau fichier). Un doublon de contenu identique (même somme de contrôle) est en revanche ignoré silencieusement, conformément à US3 scénario 3. | |
| @@ -29,3 +29,10 @@ regine import /Volumes/CARTE_SD [--titre TEXTE] [--destination nouveau|sous-doss | |||
| 29 | 29 | ||
| 30 | - `--yes` accepte les propositions par défaut (groupe unique non découpé, pas de catégorie) sans les demander interactivement ; ne bipasse jamais la confirmation finale d'écriture sur l'archive (FR-018 reste dû même en mode non interactif — nécessite `--yes` explicitement à ce niveau aussi, jamais implicite). | 30 | - `--yes` accepte les propositions par défaut (groupe unique non découpé, pas de catégorie) sans les demander interactivement ; ne bipasse jamais la confirmation finale d'écriture sur l'archive (FR-018 reste dû même en mode non interactif — nécessite `--yes` explicitement à ce niveau aussi, jamais implicite). |
| 31 | - `--categorie` et `--annee` sont mutuellement exclusifs ; en leur absence en mode interactif, la question est posée normalement. | 31 | - `--categorie` et `--annee` sont mutuellement exclusifs ; en leur absence en mode interactif, la question est posée normalement. |
| 32 | + | ||
| 33 | +## Notes d'implémentation (post-US3) | ||
| 34 | + | ||
| 35 | +- `--destination` accepte exactement `nouveau`, `parent`, `sous-dossier:CHEMIN_RELATIF`, `fusion:CHEMIN_RELATIF` (le préfixe suit `id_cible` après le `:`) — `CHEMIN_RELATIF` est relatif à `--archive-root`/`--local-root` (ex. `voyage/2026-08_Montenegro`), tel que retourné par `lister_dossiers_candidats` ou saisi manuellement. En son absence en mode interactif, une question à 4 choix est posée ; en mode `--yes` sans `--destination`, le comportement par défaut est `nouveau_dossier` (US1, non régressif). | ||
| 36 | +- Un `nouveau_sous_dossier` est physiquement imbriqué sous le dossier parent réel (`archive_root/CHEMIN_RELATIF/nom_etape/...`), pas seulement sous la racine catégorie/année héritée — `regine_core.import_carte.destination.resoudre_destination` attend un `dossier_cible` déjà construit par l'appelant pour ce type (elle ne le calcule pas elle-même). | ||
| 37 | +- `--contexte-db` (optionnel, défaut `<local_root>/.regine-contexte.sqlite3`) ouvre la base de contexte centralisée (`regine_core.config.db`) utilisée pour la désambiguïsation de boîtiers (specs/002) et le cache de catégories (specs/004). | ||
| 38 | +- **Limitation connue** : la désambiguïsation automatique de boîtiers par tag de modèle (FR-015) n'opère que sur les fichiers copiés au sein d'un même appel `copier_carte` (un seul import), conformément au scénario d'acceptation US3 #5 ("au sein d'un même import"). Une fusion (`fusion:CHEMIN`) ciblant un dossier déjà peuplé lors d'un import **précédent et séparé** ne redétecte pas les boîtiers déjà présents : si un fichier du nouvel import aboutit, après renommage, au même nom final qu'un fichier déjà archivé mais de contenu différent (deux boîtiers différents ayant produit un nom d'origine identique, importés carte par carte plutôt qu'en une seule session), `regine_core.import_carte.push.archiver` lève `CollisionNomArchiveError` plutôt que d'écraser silencieusement (FR-012 appliqué au niveau fichier). Un doublon de contenu identique (même somme de contrôle) est en revanche ignoré silencieusement, conformément à US3 scénario 3. | ||
modified
specs/001-import-photos/contracts/regine-core-api.md +35 -17 | @@ -2,42 +2,60 @@ | ||
| 2 | 2 | |
| 3 | 3 | Fonctions pures/orchestratrices consommées par `regine-cli` (`regine import`, cf. `contracts/cli-import.md`) — objets structurés, jamais de texte à parser (Principe VI). |
| 4 | 4 | |
| 5 | -## `copie.copier_carte(carte: Path, local_tmp: Path) -> list[FichierCandidat]` | |
| 5 | +## `copie.copier_carte(carte: Path, local_tmp: Path, checksums_deja_importes: set[str] | None = None) -> list[FichierCandidat]` | |
| 6 | 6 | |
| 7 | -Copie vérifiée (FR-001), une seule lecture de la carte par fichier. Retourne uniquement les fichiers réellement nouveaux (FR-004, `deja_importe=False` filtré côté appelant ou directement exclu ici). Lève une erreur par fichier en échec de vérification, sans interrompre les autres (Edge Case). | |
| 7 | +Copie vérifiée (FR-001), une seule lecture de la carte par fichier (empreinte calculée pendant la copie, vérifiée en relisant uniquement la copie locale rapide — jamais une seconde lecture de la carte). Retourne uniquement les fichiers réellement nouveaux (FR-004, filtrés via `checksums_deja_importes`) et reconnus comme maître ou associé. Lève `EchecVerificationError` par fichier en échec de vérification. | |
| 8 | + | |
| 9 | +## `copie.regrouper_par_nom_origine(fichiers: list[FichierCandidat]) -> dict[str, list[FichierCandidat]]` | |
| 10 | + | |
| 11 | +Regroupe les fichiers maîtres par nom d'origine ; ne retourne que les groupes de taille > 1 (collision réelle, FR-015). | |
| 12 | + | |
| 13 | +## `copie.resoudre_collisions_boitiers(fichiers: list[FichierCandidat], *, conn: sqlite3.Connection) -> list[list[FichierCandidat]]` | |
| 14 | + | |
| 15 | +Résout chaque collision de `regrouper_par_nom_origine` via `regine_core.camera_profile.resolve.resolve_collision` (`specs/002-profil-boitiers-optionnel`), assigne `boitier_id` en place sur les fichiers résolus automatiquement. Retourne les groupes encore ambigus (FR-005/016) — l'étiquetage manuel (`regine_core.camera_profile.resolve.assign_manual_source`) reste de la responsabilité de l'appelant (façade CLI). | |
| 8 | 16 | |
| 9 | 17 | ## `groupage.decouper_en_groupes(fichiers: list[FichierCandidat]) -> list[GroupeImport]` |
| 10 | 18 | |
| 11 | -Construit la répartition jour par jour, exclut les dates aberrantes (FR-002/003), propose un groupe unique par défaut avec les candidats au détachement mis en avant (FR-005/006, cf. research.md § 5). Ne détache jamais automatiquement. | |
| 19 | +Construit la répartition jour par jour, exclut les dates aberrantes (FR-002/003), propose un groupe unique par défaut. `groupage.jours_candidats_au_detachement`/`groupage.detacher_jours` mettent en avant les candidats au détachement sans jamais le faire automatiquement (FR-005/006, cf. research.md § 5). | |
| 12 | 20 | |
| 13 | -## `destination.resoudre_destination(groupe: GroupeImport, choix: ChoixUtilisateur) -> DestinationChoisie` | |
| 21 | +## `destination.resoudre_destination(groupe, type_destination, *, archive_root, local_root, categorie=None, dossier_cible=None, root_parent=None) -> DestinationChoisie` | |
| 14 | 22 | |
| 15 | -Traduit le choix de l'utilisateur (FR-007) en `DestinationChoisie`. Pour `nouveau_dossier`/`nouveau_parent`, appelle `regine_core.dossier.root.determine_root` (`specs/004-categorisation-dossiers`) avec la catégorie éventuellement choisie. Pour `nouveau_sous_dossier`, réutilise le `RootLocation` du parent sans nouvel appel (FR-006 de specs/004). Si `necessite_checkout_archive` est vrai (fusion vers un dossier archivé, pas local), appelle `regine_core.archive.checkout.checkout(dossier_cible, dest_locale)` (`specs/005-checkout-reconciliation`) avant de poursuivre ; propage l'exception dédiée de `specs/005` si le dossier est déjà verrouillé, plutôt que d'échouer silencieusement. | |
| 23 | +Traduit le choix de destination (FR-007). Pour `nouveau_dossier`/`nouveau_parent`, appelle `regine_core.dossier.root.determine_root` (`specs/004-categorisation-dossiers`) avec la catégorie éventuellement choisie. Pour `nouveau_sous_dossier`, réutilise le `RootLocation` du parent (`root_parent`) sans nouvel appel (FR-006 de specs/004) — **ne calcule pas elle-même le chemin imbriqué sous le dossier parent réel** : l'appelant DOIT construire et fournir `dossier_cible` déjà imbriqué (ex. `archive_root/voyage/2026-08_Montenegro/2026-08-14_Kotor`), cette fonction se contentant de le reporter tel quel dans le résultat. Ne gère pas `fusion` (cf. `resoudre_fusion` séparée). | |
| 16 | 24 | |
| 17 | -## `destination.lister_dossiers_candidats(titre_partiel: str, date_proche: date) -> list[Path]` | |
| 25 | +## `destination.resoudre_fusion(chemin_relatif_dossier: str, *, archive_root: Path, local_root: Path) -> DestinationChoisie` | |
| 18 | 26 | |
| 19 | -Recherche de dossiers candidats pour `nouveau_sous_dossier`/`fusion` (FR-008), par proximité de titre et de date, en local et dans l'archive (cf. research.md § 4). Retourne une liste triée par pertinence, vide si aucun candidat. | |
| 27 | +Fusion dans un dossier existant (FR-009). Réutilise directement le dossier local s'il existe déjà ; sinon effectue d'abord un checkout automatique via `regine_core.archive.checkout.checkout` (`specs/005-checkout-reconciliation`, dépendance désormais résolue) avant d'y intégrer les nouveaux fichiers — transparent pour l'appelant. Propage l'exception dédiée de `specs/005` si le dossier est déjà verrouillé. | |
| 20 | 28 | |
| 21 | -## `nommage.construire_nom_dossier(groupe: GroupeImport, root: RootLocation) -> Path` | |
| 29 | +## `destination.lister_dossiers_candidats(titre_partiel: str, racines: list[Path], *, n: int = 5) -> list[Path]` | |
| 22 | 30 | |
| 23 | -Construit le chemin final (FR-010), vérifie l'absence de collision **au sein du même `RootLocation`** (FR-012, cf. `specs/004-categorisation-dossiers`) ; propose un suffixe en cas de collision plutôt que d'écraser. | |
| 31 | +Recherche de dossiers candidats pour `nouveau_sous_dossier`/`fusion` (FR-008), par proximité de titre (`difflib`) sur les répertoires directs de chaque racine fournie (local et/ou archive). Retourne une liste triée par pertinence, vide si aucun candidat. | |
| 24 | 32 | |
| 25 | -## `nommage.renommer_fichiers(groupe: GroupeImport, titre: str, dossier: Path) -> list[Renommage]` | |
| 33 | +## `nommage.construire_nom_dossier(groupe: GroupeImport, titre: str) -> str` / `construire_nom_dossier_parent(date_premier_import: date, titre: str) -> str` / `construire_nom_sous_dossier(groupe: GroupeImport, titre: str, lieu: str | None = None) -> str` | |
| 26 | 34 | |
| 27 | -Renomme chaque fichier maître (`date_titre_nomOrigine.ext`, FR-013) et ses fichiers associés de façon synchronisée (FR-014). | |
| 35 | +Construisent respectivement le nom d'un dossier simple (`AAAA-MM-JJ_Titre` ou plage), d'un dossier parent (`AAAA-MM_Titre`, granularité mois — la date de fin n'est pas connue au premier import) et d'un sous-dossier d'étape (`AAAA-MM-JJ_Titre_Lieu`), FR-010. `nommage.resoudre_collision_nom(chemin_souhaite: Path) -> Path` vérifie l'absence de collision au sein du dossier parent visé (FR-012) et propose un suffixe numérique plutôt que d'écraser. | |
| 28 | 36 | |
| 29 | -## `identifiant.attribuer_identifiants(fichiers: list[Path]) -> dict[Path, str]` | |
| 37 | +## `nommage.renommer_fichiers(fichiers: list[FichierCandidat], date_ref: date, titre: str) -> list[Renommage]` | |
| 38 | + | |
| 39 | +Renomme chaque fichier maître (`date_titre_nomOrigine.ext`, FR-013) et ses fichiers associés de façon synchronisée, par regroupement sur le nom de base avant le premier point (FR-014). | |
| 40 | + | |
| 41 | +## `identifiant.attribuer_identifiants(fichiers_maitres: list[Path]) -> dict[Path, str]` | |
| 30 | 42 | |
| 31 | 43 | Génère un UUID par fichier maître et l'écrit dans `xmpMM:DocumentID` via `exiftool` (FR-017, cf. research.md § 3). Idempotent : ne réécrit pas un identifiant déjà présent. |
| 32 | 44 | |
| 33 | -## `push.preparer_resume(groupe: GroupeImport) -> ResumeConfirmation` | |
| 45 | +## `push.preparer_resume(fichiers: list[Path], dossier_destination: Path) -> ResumeConfirmation` | |
| 34 | 46 | |
| 35 | 47 | Construit l'objet structuré (nombre de fichiers, taille totale, chemin de destination avec répertoire racine) consommé par `regine-cli` pour l'affichage et la confirmation (FR-018). |
| 36 | 48 | |
| 37 | -## `push.archiver(groupe: GroupeImport, resume_confirme: bool) -> None` | |
| 49 | +## `push.archiver(fichiers_locaux: list[Path], dossier_local_racine: Path, dossier_destination: Path) -> None` | |
| 50 | + | |
| 51 | +Transfert final vérifié depuis la copie locale déjà renommée (FR-019) — ne demande pas elle-même confirmation, c'est la responsabilité de l'appelant (façade CLI, après `preparer_resume`). Si le fichier de destination existe déjà avec le même contenu (même somme de contrôle), l'opération est un no-op silencieux (doublon, US3 scénario 3). S'il existe déjà avec un contenu **différent**, lève `CollisionNomArchiveError` plutôt que d'écraser silencieusement (FR-012 appliqué au niveau fichier, pas seulement dossier) — cf. « Notes d'implémentation » ci-dessous. Lève `EchecTransfertError` en cas d'échec de vérification d'intégrité après copie. | |
| 52 | + | |
| 53 | +## Notes d'implémentation | |
| 38 | 54 | |
| 39 | -Transfert final vérifié depuis la copie locale déjà renommée (FR-019). Lève une erreur si `resume_confirme` est faux — ne DOIT jamais être appelée sans confirmation explicite préalable côté appelant. | |
| 55 | +- **Bug trouvé par validation manuelle (2026-09-19)** : `push.archiver` écrasait silencieusement un fichier déjà archivé si un import ultérieur et séparé (ex. fusion d'une seconde carte, d'un boîtier différent, sur la même étape d'un voyage) produisait par coïncidence le même nom final avec un contenu différent — violation du principe « jamais d'écrasement silencieux » (FR-012). Corrigé en ajoutant une vérification de collision par somme de contrôle avant toute copie, avec la nouvelle exception `CollisionNomArchiveError`. Cf. `test_push_archiver.py`. | |
| 56 | +- **Limitation connue** : la désambiguïsation automatique de boîtiers (`copie.resoudre_collisions_boitiers`) n'opère que sur les fichiers d'un même appel à `copier_carte` (un seul import), conformément à l'US3 scénario d'acceptation #5 (« au sein d'un même import »). Une fusion ciblant un dossier peuplé par un import précédent et séparé ne redétecte pas les boîtiers déjà présents dans ce dossier ; seule la protection anti-écrasement de `push.archiver` (ci-dessus) couvre ce cas résiduel, en refusant explicitement plutôt qu'en tentant une désambiguïsation rétroactive (hors périmètre de cette itération). | |
| 40 | 57 | |
| 41 | -## Dépendance résolue | |
| 58 | +## Dépendances résolues | |
| 42 | 59 | |
| 43 | -Le sous-scénario FR-009 (fusion vers un dossier présent uniquement dans l'archive) consomme désormais `regine_core.archive.checkout` (`specs/005-checkout-reconciliation`) — cf. `research.md` § 6, mis à jour le 2026-09-19. | |
| 60 | +- Le sous-scénario FR-009 (fusion vers un dossier présent uniquement dans l'archive) consomme `regine_core.archive.checkout` (`specs/005-checkout-reconciliation`) — cf. `research.md` § 6. | |
| 61 | +- La désambiguïsation de boîtiers (FR-015/016) consomme `regine_core.camera_profile` (`specs/002-profil-boitiers-optionnel`). | |
| @@ -2,42 +2,60 @@ | |||
| 2 | 2 | ||
| 3 | Fonctions pures/orchestratrices consommées par `regine-cli` (`regine import`, cf. `contracts/cli-import.md`) — objets structurés, jamais de texte à parser (Principe VI). | 3 | Fonctions pures/orchestratrices consommées par `regine-cli` (`regine import`, cf. `contracts/cli-import.md`) — objets structurés, jamais de texte à parser (Principe VI). |
| 4 | 4 | ||
| 5 | -## `copie.copier_carte(carte: Path, local_tmp: Path) -> list[FichierCandidat]` | 5 | +## `copie.copier_carte(carte: Path, local_tmp: Path, checksums_deja_importes: set[str] | None = None) -> list[FichierCandidat]` |
| 6 | 6 | ||
| 7 | -Copie vérifiée (FR-001), une seule lecture de la carte par fichier. Retourne uniquement les fichiers réellement nouveaux (FR-004, `deja_importe=False` filtré côté appelant ou directement exclu ici). Lève une erreur par fichier en échec de vérification, sans interrompre les autres (Edge Case). | 7 | +Copie vérifiée (FR-001), une seule lecture de la carte par fichier (empreinte calculée pendant la copie, vérifiée en relisant uniquement la copie locale rapide — jamais une seconde lecture de la carte). Retourne uniquement les fichiers réellement nouveaux (FR-004, filtrés via `checksums_deja_importes`) et reconnus comme maître ou associé. Lève `EchecVerificationError` par fichier en échec de vérification. |
| 8 | + | ||
| 9 | +## `copie.regrouper_par_nom_origine(fichiers: list[FichierCandidat]) -> dict[str, list[FichierCandidat]]` | ||
| 10 | + | ||
| 11 | +Regroupe les fichiers maîtres par nom d'origine ; ne retourne que les groupes de taille > 1 (collision réelle, FR-015). | ||
| 12 | + | ||
| 13 | +## `copie.resoudre_collisions_boitiers(fichiers: list[FichierCandidat], *, conn: sqlite3.Connection) -> list[list[FichierCandidat]]` | ||
| 14 | + | ||
| 15 | +Résout chaque collision de `regrouper_par_nom_origine` via `regine_core.camera_profile.resolve.resolve_collision` (`specs/002-profil-boitiers-optionnel`), assigne `boitier_id` en place sur les fichiers résolus automatiquement. Retourne les groupes encore ambigus (FR-005/016) — l'étiquetage manuel (`regine_core.camera_profile.resolve.assign_manual_source`) reste de la responsabilité de l'appelant (façade CLI). | ||
| 8 | 16 | ||
| 9 | ## `groupage.decouper_en_groupes(fichiers: list[FichierCandidat]) -> list[GroupeImport]` | 17 | ## `groupage.decouper_en_groupes(fichiers: list[FichierCandidat]) -> list[GroupeImport]` |
| 10 | 18 | ||
| 11 | -Construit la répartition jour par jour, exclut les dates aberrantes (FR-002/003), propose un groupe unique par défaut avec les candidats au détachement mis en avant (FR-005/006, cf. research.md § 5). Ne détache jamais automatiquement. | 19 | +Construit la répartition jour par jour, exclut les dates aberrantes (FR-002/003), propose un groupe unique par défaut. `groupage.jours_candidats_au_detachement`/`groupage.detacher_jours` mettent en avant les candidats au détachement sans jamais le faire automatiquement (FR-005/006, cf. research.md § 5). |
| 12 | 20 | ||
| 13 | -## `destination.resoudre_destination(groupe: GroupeImport, choix: ChoixUtilisateur) -> DestinationChoisie` | 21 | +## `destination.resoudre_destination(groupe, type_destination, *, archive_root, local_root, categorie=None, dossier_cible=None, root_parent=None) -> DestinationChoisie` |
| 14 | 22 | ||
| 15 | -Traduit le choix de l'utilisateur (FR-007) en `DestinationChoisie`. Pour `nouveau_dossier`/`nouveau_parent`, appelle `regine_core.dossier.root.determine_root` (`specs/004-categorisation-dossiers`) avec la catégorie éventuellement choisie. Pour `nouveau_sous_dossier`, réutilise le `RootLocation` du parent sans nouvel appel (FR-006 de specs/004). Si `necessite_checkout_archive` est vrai (fusion vers un dossier archivé, pas local), appelle `regine_core.archive.checkout.checkout(dossier_cible, dest_locale)` (`specs/005-checkout-reconciliation`) avant de poursuivre ; propage l'exception dédiée de `specs/005` si le dossier est déjà verrouillé, plutôt que d'échouer silencieusement. | 23 | +Traduit le choix de destination (FR-007). Pour `nouveau_dossier`/`nouveau_parent`, appelle `regine_core.dossier.root.determine_root` (`specs/004-categorisation-dossiers`) avec la catégorie éventuellement choisie. Pour `nouveau_sous_dossier`, réutilise le `RootLocation` du parent (`root_parent`) sans nouvel appel (FR-006 de specs/004) — **ne calcule pas elle-même le chemin imbriqué sous le dossier parent réel** : l'appelant DOIT construire et fournir `dossier_cible` déjà imbriqué (ex. `archive_root/voyage/2026-08_Montenegro/2026-08-14_Kotor`), cette fonction se contentant de le reporter tel quel dans le résultat. Ne gère pas `fusion` (cf. `resoudre_fusion` séparée). |
| 16 | 24 | ||
| 17 | -## `destination.lister_dossiers_candidats(titre_partiel: str, date_proche: date) -> list[Path]` | 25 | +## `destination.resoudre_fusion(chemin_relatif_dossier: str, *, archive_root: Path, local_root: Path) -> DestinationChoisie` |
| 18 | 26 | ||
| 19 | -Recherche de dossiers candidats pour `nouveau_sous_dossier`/`fusion` (FR-008), par proximité de titre et de date, en local et dans l'archive (cf. research.md § 4). Retourne une liste triée par pertinence, vide si aucun candidat. | 27 | +Fusion dans un dossier existant (FR-009). Réutilise directement le dossier local s'il existe déjà ; sinon effectue d'abord un checkout automatique via `regine_core.archive.checkout.checkout` (`specs/005-checkout-reconciliation`, dépendance désormais résolue) avant d'y intégrer les nouveaux fichiers — transparent pour l'appelant. Propage l'exception dédiée de `specs/005` si le dossier est déjà verrouillé. |
| 20 | 28 | ||
| 21 | -## `nommage.construire_nom_dossier(groupe: GroupeImport, root: RootLocation) -> Path` | 29 | +## `destination.lister_dossiers_candidats(titre_partiel: str, racines: list[Path], *, n: int = 5) -> list[Path]` |
| 22 | 30 | ||
| 23 | -Construit le chemin final (FR-010), vérifie l'absence de collision **au sein du même `RootLocation`** (FR-012, cf. `specs/004-categorisation-dossiers`) ; propose un suffixe en cas de collision plutôt que d'écraser. | 31 | +Recherche de dossiers candidats pour `nouveau_sous_dossier`/`fusion` (FR-008), par proximité de titre (`difflib`) sur les répertoires directs de chaque racine fournie (local et/ou archive). Retourne une liste triée par pertinence, vide si aucun candidat. |
| 24 | 32 | ||
| 25 | -## `nommage.renommer_fichiers(groupe: GroupeImport, titre: str, dossier: Path) -> list[Renommage]` | 33 | +## `nommage.construire_nom_dossier(groupe: GroupeImport, titre: str) -> str` / `construire_nom_dossier_parent(date_premier_import: date, titre: str) -> str` / `construire_nom_sous_dossier(groupe: GroupeImport, titre: str, lieu: str | None = None) -> str` |
| 26 | 34 | ||
| 27 | -Renomme chaque fichier maître (`date_titre_nomOrigine.ext`, FR-013) et ses fichiers associés de façon synchronisée (FR-014). | 35 | +Construisent respectivement le nom d'un dossier simple (`AAAA-MM-JJ_Titre` ou plage), d'un dossier parent (`AAAA-MM_Titre`, granularité mois — la date de fin n'est pas connue au premier import) et d'un sous-dossier d'étape (`AAAA-MM-JJ_Titre_Lieu`), FR-010. `nommage.resoudre_collision_nom(chemin_souhaite: Path) -> Path` vérifie l'absence de collision au sein du dossier parent visé (FR-012) et propose un suffixe numérique plutôt que d'écraser. |
| 28 | 36 | ||
| 29 | -## `identifiant.attribuer_identifiants(fichiers: list[Path]) -> dict[Path, str]` | 37 | +## `nommage.renommer_fichiers(fichiers: list[FichierCandidat], date_ref: date, titre: str) -> list[Renommage]` |
| 38 | + | ||
| 39 | +Renomme chaque fichier maître (`date_titre_nomOrigine.ext`, FR-013) et ses fichiers associés de façon synchronisée, par regroupement sur le nom de base avant le premier point (FR-014). | ||
| 40 | + | ||
| 41 | +## `identifiant.attribuer_identifiants(fichiers_maitres: list[Path]) -> dict[Path, str]` | ||
| 30 | 42 | ||
| 31 | Génère un UUID par fichier maître et l'écrit dans `xmpMM:DocumentID` via `exiftool` (FR-017, cf. research.md § 3). Idempotent : ne réécrit pas un identifiant déjà présent. | 43 | Génère un UUID par fichier maître et l'écrit dans `xmpMM:DocumentID` via `exiftool` (FR-017, cf. research.md § 3). Idempotent : ne réécrit pas un identifiant déjà présent. |
| 32 | 44 | ||
| 33 | -## `push.preparer_resume(groupe: GroupeImport) -> ResumeConfirmation` | 45 | +## `push.preparer_resume(fichiers: list[Path], dossier_destination: Path) -> ResumeConfirmation` |
| 34 | 46 | ||
| 35 | Construit l'objet structuré (nombre de fichiers, taille totale, chemin de destination avec répertoire racine) consommé par `regine-cli` pour l'affichage et la confirmation (FR-018). | 47 | Construit l'objet structuré (nombre de fichiers, taille totale, chemin de destination avec répertoire racine) consommé par `regine-cli` pour l'affichage et la confirmation (FR-018). |
| 36 | 48 | ||
| 37 | -## `push.archiver(groupe: GroupeImport, resume_confirme: bool) -> None` | 49 | +## `push.archiver(fichiers_locaux: list[Path], dossier_local_racine: Path, dossier_destination: Path) -> None` |
| 50 | + | ||
| 51 | +Transfert final vérifié depuis la copie locale déjà renommée (FR-019) — ne demande pas elle-même confirmation, c'est la responsabilité de l'appelant (façade CLI, après `preparer_resume`). Si le fichier de destination existe déjà avec le même contenu (même somme de contrôle), l'opération est un no-op silencieux (doublon, US3 scénario 3). S'il existe déjà avec un contenu **différent**, lève `CollisionNomArchiveError` plutôt que d'écraser silencieusement (FR-012 appliqué au niveau fichier, pas seulement dossier) — cf. « Notes d'implémentation » ci-dessous. Lève `EchecTransfertError` en cas d'échec de vérification d'intégrité après copie. | ||
| 52 | + | ||
| 53 | +## Notes d'implémentation | ||
| 38 | 54 | ||
| 39 | -Transfert final vérifié depuis la copie locale déjà renommée (FR-019). Lève une erreur si `resume_confirme` est faux — ne DOIT jamais être appelée sans confirmation explicite préalable côté appelant. | 55 | +- **Bug trouvé par validation manuelle (2026-09-19)** : `push.archiver` écrasait silencieusement un fichier déjà archivé si un import ultérieur et séparé (ex. fusion d'une seconde carte, d'un boîtier différent, sur la même étape d'un voyage) produisait par coïncidence le même nom final avec un contenu différent — violation du principe « jamais d'écrasement silencieux » (FR-012). Corrigé en ajoutant une vérification de collision par somme de contrôle avant toute copie, avec la nouvelle exception `CollisionNomArchiveError`. Cf. `test_push_archiver.py`. |
| 56 | +- **Limitation connue** : la désambiguïsation automatique de boîtiers (`copie.resoudre_collisions_boitiers`) n'opère que sur les fichiers d'un même appel à `copier_carte` (un seul import), conformément à l'US3 scénario d'acceptation #5 (« au sein d'un même import »). Une fusion ciblant un dossier peuplé par un import précédent et séparé ne redétecte pas les boîtiers déjà présents dans ce dossier ; seule la protection anti-écrasement de `push.archiver` (ci-dessus) couvre ce cas résiduel, en refusant explicitement plutôt qu'en tentant une désambiguïsation rétroactive (hors périmètre de cette itération). | ||
| 40 | 57 | ||
| 41 | -## Dépendance résolue | 58 | +## Dépendances résolues |
| 42 | 59 | ||
| 43 | -Le sous-scénario FR-009 (fusion vers un dossier présent uniquement dans l'archive) consomme désormais `regine_core.archive.checkout` (`specs/005-checkout-reconciliation`) — cf. `research.md` § 6, mis à jour le 2026-09-19. | 60 | +- Le sous-scénario FR-009 (fusion vers un dossier présent uniquement dans l'archive) consomme `regine_core.archive.checkout` (`specs/005-checkout-reconciliation`) — cf. `research.md` § 6. |
| 61 | +- La désambiguïsation de boîtiers (FR-015/016) consomme `regine_core.camera_profile` (`specs/002-profil-boitiers-optionnel`). | ||
modified
specs/001-import-photos/tasks.md +38 -38 | @@ -26,10 +26,10 @@ Ce module est le premier point d'intégration réel de trois autres specs déjà | ||
| 26 | 26 | |
| 27 | 27 | **Purpose**: Vérifier les prérequis inter-specs et poser le squelette propre à ce module. |
| 28 | 28 | |
| 29 | -- [ ] T001 Vérifier que `packages/regine-core/src/regine_core/dossier/root.py` et `packages/regine-core/src/regine_core/config/categories.py` existent déjà (créés par `specs/004-categorisation-dossiers/tasks.md`) ; si absents, exécuter d'abord ce fichier de tâches avant de continuer | |
| 30 | -- [ ] T002 [P] Créer `packages/regine-core/src/regine_core/import_carte/__init__.py` | |
| 31 | -- [ ] T003 [P] Créer `packages/regine-cli/src/regine_cli/import_cmd.py` (squelette de la commande `regine import`, sans logique) | |
| 32 | -- [ ] T004 [P] Créer les dossiers `packages/regine-core/tests/unit/` et `packages/regine-core/tests/integration/` s'ils n'existent pas déjà (normalement déjà créés par specs/004) | |
| 29 | +- [X] T001 Vérifier que `packages/regine-core/src/regine_core/dossier/root.py` et `packages/regine-core/src/regine_core/config/categories.py` existent déjà (créés par `specs/004-categorisation-dossiers/tasks.md`) ; si absents, exécuter d'abord ce fichier de tâches avant de continuer | |
| 30 | +- [X] T002 [P] Créer `packages/regine-core/src/regine_core/import_carte/__init__.py` | |
| 31 | +- [X] T003 [P] Créer `packages/regine-cli/src/regine_cli/import_cmd.py` (squelette de la commande `regine import`, sans logique) — reportée à la fin de US1 (T019), une fois la logique métier disponible à orchestrer | |
| 32 | +- [X] T004 [P] Créer les dossiers `packages/regine-core/tests/unit/` et `packages/regine-core/tests/integration/` s'ils n'existent pas déjà (normalement déjà créés par specs/004) — déjà présents | |
| 33 | 33 | |
| 34 | 34 | --- |
| 35 | 35 | |
| @@ -39,10 +39,10 @@ Ce module est le premier point d'intégration réel de trois autres specs déjà | ||
| 39 | 39 | |
| 40 | 40 | **⚠️ CRITICAL**: Aucune user story ne peut être implémentée avant la fin de cette phase. |
| 41 | 41 | |
| 42 | -- [ ] T005 Créer `packages/regine-core/src/regine_core/import_carte/types.py` : dataclasses `FichierCandidat`, `GroupeImport`, `DestinationChoisie`, `Renommage` (cf. `data-model.md`) | |
| 43 | -- [ ] T006 Étendre `packages/regine-core/src/regine_core/metadata/exif.py` (existant, specs/002) avec `read_capture_date(chemin) -> datetime | None` (tag EXIF `DateTimeOriginal`, cf. `research.md` § 2) | |
| 44 | -- [ ] T007 Étendre `packages/regine-core/src/regine_core/metadata/exif.py` avec `write_document_id(chemin, identifiant) -> None` (écrit `xmpMM:DocumentID` via exiftool, idempotent, cf. `research.md` § 3) | |
| 45 | -- [ ] T008 [P] Test unitaire de `read_capture_date`/`write_document_id` dans `packages/regine-core/tests/unit/test_identifiant.py` (date valide, date absente ; écriture puis relecture de l'identifiant) | |
| 42 | +- [X] T005 Créer `packages/regine-core/src/regine_core/import_carte/types.py` : dataclasses `FichierCandidat`, `GroupeImport`, `DestinationChoisie`, `Renommage` (cf. `data-model.md`) | |
| 43 | +- [X] T006 Étendre `packages/regine-core/src/regine_core/metadata/exif.py` (existant, specs/002) avec `read_capture_date(chemin) -> datetime | None` (tag EXIF `DateTimeOriginal`, cf. `research.md` § 2) | |
| 44 | +- [X] T007 Étendre `packages/regine-core/src/regine_core/metadata/exif.py` avec `write_document_id(chemin, identifiant) -> None` (écrit `xmpMM:DocumentID` via exiftool, idempotent, cf. `research.md` § 3) — `read_document_id` ajoutée aussi, utile pour tests/manifeste | |
| 45 | +- [X] T008 [P] Test unitaire de `read_capture_date`/`write_document_id` dans `packages/regine-core/tests/unit/test_identifiant.py` (date valide, date absente ; écriture puis relecture de l'identifiant) — validé avec de vraies opérations exiftool | |
| 46 | 46 | |
| 47 | 47 | **Checkpoint**: Fondations prêtes — les phases User Story peuvent commencer. |
| 48 | 48 | |
| @@ -56,20 +56,20 @@ Ce module est le premier point d'intégration réel de trois autres specs déjà | ||
| 56 | 56 | |
| 57 | 57 | ### Tests for User Story 1 |
| 58 | 58 | |
| 59 | -- [ ] T009 [P] [US1] Test unitaire copie vérifiée : une seule lecture de la carte, échec de vérification signalé sans marquer la carte sûre à effacer, dans `packages/regine-core/tests/unit/test_copie_checksum.py` | |
| 60 | -- [ ] T010 [P] [US1] Test unitaire découpage en groupe unique (cas simple, une seule journée) dans `packages/regine-core/tests/unit/test_groupage_dates.py` | |
| 61 | -- [ ] T011 [P] [US1] Test unitaire construction du nom de dossier et détection de collision dans `packages/regine-core/tests/unit/test_nommage.py` | |
| 62 | -- [ ] T012 [US1] Test d'intégration du pipeline complet (carte simple → dossier archivé, résumé confirmé) dans `packages/regine-core/tests/integration/test_pipeline_import_simple.py` | |
| 59 | +- [X] T009 [P] [US1] Test unitaire copie vérifiée : une seule lecture de la carte, échec de vérification signalé sans marquer la carte sûre à effacer, dans `packages/regine-core/tests/unit/test_copie_checksum.py` | |
| 60 | +- [X] T010 [P] [US1] Test unitaire découpage en groupe unique (cas simple, une seule journée) dans `packages/regine-core/tests/unit/test_groupage_dates.py` | |
| 61 | +- [X] T011 [P] [US1] Test unitaire construction du nom de dossier et détection de collision dans `packages/regine-core/tests/unit/test_nommage.py` | |
| 62 | +- [X] T012 [US1] Test d'intégration du pipeline complet (carte simple → dossier archivé, résumé confirmé) dans `packages/regine-core/tests/integration/test_pipeline_import_simple.py` | |
| 63 | 63 | |
| 64 | 64 | ### Implementation for User Story 1 |
| 65 | 65 | |
| 66 | -- [ ] T013 [US1] Implémenter `copier_carte(carte, local_tmp) -> list[FichierCandidat]` dans `packages/regine-core/src/regine_core/import_carte/copie.py` (FR-001 : une seule lecture carte, FR-004 : filtrage des fichiers déjà importés) | |
| 67 | -- [ ] T014 [US1] Implémenter `decouper_en_groupes(fichiers) -> list[GroupeImport]` dans `packages/regine-core/src/regine_core/import_carte/groupage.py` (FR-002 lecture date, branche groupe unique par défaut de FR-005 ; le détachement multi-jours est complété en Phase 4) | |
| 68 | -- [ ] T015 [US1] Implémenter la branche `nouveau_dossier` de `resoudre_destination` dans `packages/regine-core/src/regine_core/import_carte/destination.py` (FR-007 pour ce cas, appelle `regine_core.dossier.root.determine_root`) | |
| 69 | -- [ ] T016 [US1] Implémenter `construire_nom_dossier`/`renommer_fichiers` dans `packages/regine-core/src/regine_core/import_carte/nommage.py` (FR-010/012/013/014) | |
| 70 | -- [ ] T017 [US1] Implémenter `attribuer_identifiants` dans `packages/regine-core/src/regine_core/import_carte/identifiant.py` (FR-017, appelle `write_document_id`) | |
| 71 | -- [ ] T018 [US1] Implémenter `preparer_resume`/`archiver` dans `packages/regine-core/src/regine_core/import_carte/push.py` (FR-018/019) | |
| 72 | -- [ ] T019 [US1] Orchestrer le pipeline dans `packages/regine-cli/src/regine_cli/import_cmd.py` pour `regine import <carte> --annee --titre TEXTE` (cas `nouveau_dossier` uniquement) | |
| 66 | +- [X] T013 [US1] Implémenter `copier_carte(carte, local_tmp) -> list[FichierCandidat]` dans `packages/regine-core/src/regine_core/import_carte/copie.py` (FR-001 : une seule lecture carte, FR-004 : filtrage des fichiers déjà importés) | |
| 67 | +- [X] T014 [US1] Implémenter `decouper_en_groupes(fichiers) -> list[GroupeImport]` dans `packages/regine-core/src/regine_core/import_carte/groupage.py` (FR-002 lecture date, branche groupe unique par défaut de FR-005 ; le détachement multi-jours est complété en Phase 4) | |
| 68 | +- [X] T015 [US1] Implémenter la branche `nouveau_dossier` de `resoudre_destination` dans `packages/regine-core/src/regine_core/import_carte/destination.py` (FR-007 pour ce cas, appelle `regine_core.dossier.root.determine_root`) | |
| 69 | +- [X] T016 [US1] Implémenter `construire_nom_dossier`/`renommer_fichiers` dans `packages/regine-core/src/regine_core/import_carte/nommage.py` (FR-010/012/013/014) | |
| 70 | +- [X] T017 [US1] Implémenter `attribuer_identifiants` dans `packages/regine-core/src/regine_core/import_carte/identifiant.py` (FR-017, appelle `write_document_id`) | |
| 71 | +- [X] T018 [US1] Implémenter `preparer_resume`/`archiver` dans `packages/regine-core/src/regine_core/import_carte/push.py` (FR-018/019) | |
| 72 | +- [X] T019 [US1] Orchestrer le pipeline dans `packages/regine-cli/src/regine_cli/import_cmd.py` pour `regine import <carte> --annee --titre TEXTE` (cas `nouveau_dossier` uniquement) — validé en conditions réelles (`python -m regine_cli.import_cmd import ...`) | |
| 73 | 73 | |
| 74 | 74 | **Checkpoint**: User Story 1 fonctionnelle et testable indépendamment (MVP). |
| 75 | 75 | |
| @@ -83,15 +83,15 @@ Ce module est le premier point d'intégration réel de trois autres specs déjà | ||
| 83 | 83 | |
| 84 | 84 | ### Tests for User Story 2 |
| 85 | 85 | |
| 86 | -- [ ] T020 [P] [US2] Test unitaire d'exclusion des dates aberrantes du calcul de plage dans `packages/regine-core/tests/unit/test_groupage_dates.py` | |
| 87 | -- [ ] T021 [P] [US2] Test unitaire de mise en avant d'un jour candidat au détachement (heuristique, cf. `research.md` § 5) et de détachement manuel dans `test_groupage_dates.py` | |
| 88 | -- [ ] T022 [US2] Test d'intégration du découpage en plusieurs groupes avec destination/titre distincts dans `packages/regine-core/tests/integration/test_pipeline_multi_jours.py` | |
| 86 | +- [X] T020 [P] [US2] Test unitaire d'exclusion des dates aberrantes du calcul de plage dans `packages/regine-core/tests/unit/test_groupage_dates.py` | |
| 87 | +- [X] T021 [P] [US2] Test unitaire de mise en avant d'un jour candidat au détachement (heuristique, cf. `research.md` § 5) et de détachement manuel dans `test_groupage_dates.py` | |
| 88 | +- [X] T022 [US2] Test d'intégration du découpage en plusieurs groupes avec destination/titre distincts dans `packages/regine-core/tests/integration/test_pipeline_multi_jours.py` | |
| 89 | 89 | |
| 90 | 90 | ### Implementation for User Story 2 |
| 91 | 91 | |
| 92 | -- [ ] T023 [US2] Étendre `decouper_en_groupes` : exclusion des dates aberrantes (FR-003) et heuristique de mise en avant d'un jour candidat (FR-006, cf. `research.md` § 5), dans `groupage.py` (dépend de T014) | |
| 93 | -- [ ] T024 [US2] Ajouter le détachement manuel d'un ou plusieurs jours par l'utilisateur, produisant des `GroupeImport` distincts, dans `groupage.py` | |
| 94 | -- [ ] T025 [US2] Étendre `packages/regine-cli/src/regine_cli/import_cmd.py` pour afficher la répartition jour par jour et gérer le détachement interactif, puis demander destination/titre pour chaque groupe résultant | |
| 92 | +- [X] T023 [US2] Étendre `decouper_en_groupes` : exclusion des dates aberrantes (FR-003) et heuristique de mise en avant d'un jour candidat (FR-006, cf. `research.md` § 5), dans `groupage.py` (dépend de T014) — implémentée dès T014 (Phase 3), une seule passe d'écriture cohérente | |
| 93 | +- [X] T024 [US2] Ajouter le détachement manuel d'un ou plusieurs jours par l'utilisateur, produisant des `GroupeImport` distincts, dans `groupage.py` — implémentée dès T014 | |
| 94 | +- [X] T025 [US2] Étendre `packages/regine-cli/src/regine_cli/import_cmd.py` pour afficher la répartition jour par jour et gérer le détachement interactif, puis demander destination/titre pour chaque groupe résultant — implémentée dès T019 (`_proposer_detachement`) | |
| 95 | 95 | |
| 96 | 96 | **Checkpoint**: User Stories 1 ET 2 fonctionnelles indépendamment. |
| 97 | 97 | |
| @@ -105,19 +105,19 @@ Ce module est le premier point d'intégration réel de trois autres specs déjà | ||
| 105 | 105 | |
| 106 | 106 | ### Tests for User Story 3 |
| 107 | 107 | |
| 108 | -- [ ] T026 [P] [US3] Test unitaire de recherche de dossiers candidats par titre/date proche (`difflib`, cf. `research.md` § 4) dans `packages/regine-core/tests/unit/test_destination_candidats.py` | |
| 109 | -- [ ] T027 [P] [US3] Test unitaire : la branche `nouveau_sous_dossier` réutilise le `RootLocation` du parent sans nouvel appel à `determine_root`, dans `test_destination_candidats.py` | |
| 110 | -- [ ] T028 [US3] Test d'intégration voyage complet (parent + sous-dossier + fusion locale + désambiguïsation de boîtiers) dans `packages/regine-core/tests/integration/test_pipeline_voyage.py` | |
| 111 | -- [ ] T029 [US3] Test d'intégration de la fusion vers un dossier **déjà archivé** dans `test_pipeline_voyage.py` — **`specs/005-checkout-reconciliation` est désormais implémentée (2026-09-19)**, cette tâche est activable ; ne bloque pas le reste de cette phase | |
| 108 | +- [X] T026 [P] [US3] Test unitaire de recherche de dossiers candidats par titre/date proche (`difflib`, cf. `research.md` § 4) dans `packages/regine-core/tests/unit/test_destination_candidats.py` | |
| 109 | +- [X] T027 [P] [US3] Test unitaire : la branche `nouveau_sous_dossier` réutilise le `RootLocation` du parent sans nouvel appel à `determine_root`, dans `test_destination_candidats.py` | |
| 110 | +- [X] T028 [US3] Test d'intégration voyage complet (parent + sous-dossier + fusion locale + désambiguïsation de boîtiers) dans `packages/regine-core/tests/integration/test_pipeline_voyage.py` | |
| 111 | +- [X] T029 [US3] Test d'intégration de la fusion vers un dossier **déjà archivé** dans `test_pipeline_voyage.py` — teste un vrai appel à `regine_core.archive.checkout.checkout` (specs/005) | |
| 112 | 112 | |
| 113 | 113 | ### Implementation for User Story 3 |
| 114 | 114 | |
| 115 | -- [ ] T030 [US3] Étendre `resoudre_destination` : branches `nouveau_sous_dossier` (hérite le `RootLocation` du parent, FR-006 de specs/004) et `nouveau_parent` (FR-007), dans `destination.py` (dépend de T015) | |
| 116 | -- [ ] T031 [US3] Implémenter `lister_dossiers_candidats(titre_partiel, date_proche)` dans `destination.py` (FR-008, cf. `research.md` § 4) | |
| 117 | -- [ ] T032 [US3] Implémenter la branche `fusion` locale de `resoudre_destination` (dossier cible présent dans l'espace de travail local) dans `destination.py` (FR-009, partie locale) | |
| 118 | -- [ ] T033 [US3] Implémenter la branche `fusion` vers un dossier déjà archivé : appelle `regine_core.archive.checkout.checkout` (`specs/005-checkout-reconciliation`), dans `destination.py` — **`specs/005-checkout-reconciliation` est désormais implémentée (2026-09-19)**, cette tâche est activable | |
| 119 | -- [ ] T034 [US3] Intégrer la désambiguïsation de boîtiers dans `copie.py` : regrouper les fichiers en collision de nom d'origine, appeler `regine_core.camera_profile.resolve_collision` (specs/002), gérer les groupes nécessitant un étiquetage manuel (FR-015/016) — **`specs/002-profil-boitiers-optionnel` est désormais implémentée (2026-09-19)**, cette tâche est activable | |
| 120 | -- [ ] T035 [US3] Étendre `packages/regine-cli/src/regine_cli/import_cmd.py` pour les 4 types de destination et la résolution interactive de collision de boîtiers | |
| 115 | +- [X] T030 [US3] Étendre `resoudre_destination` : branches `nouveau_sous_dossier` (hérite le `RootLocation` du parent, FR-006 de specs/004) et `nouveau_parent` (FR-007), dans `destination.py` (dépend de T015) — implémentées dès T015 | |
| 116 | +- [X] T031 [US3] Implémenter `lister_dossiers_candidats(titre_partiel, date_proche)` dans `destination.py` (FR-008, cf. `research.md` § 4) — implémentée dès T015 | |
| 117 | +- [X] T032 [US3] Implémenter la branche `fusion` locale de `resoudre_destination` (dossier cible présent dans l'espace de travail local) dans `destination.py` (FR-009, partie locale) — `resoudre_fusion()` | |
| 118 | +- [X] T033 [US3] Implémenter la branche `fusion` vers un dossier déjà archivé : appelle `regine_core.archive.checkout.checkout` (`specs/005-checkout-reconciliation`), dans `destination.py` — `resoudre_fusion()`, validé avec un vrai checkout | |
| 119 | +- [X] T034 [US3] Intégrer la désambiguïsation de boîtiers dans `copie.py` : regrouper les fichiers en collision de nom d'origine, appeler `regine_core.camera_profile.resolve_collision` (specs/002), gérer les groupes nécessitant un étiquetage manuel (FR-015/016) — `regrouper_par_nom_origine`/`resoudre_collisions_boitiers` ; copie anti-collision ajoutée dans `copier_carte` (ne jamais écraser un fichier de même nom d'origine) | |
| 120 | +- [X] T035 [US3] Étendre `packages/regine-cli/src/regine_cli/import_cmd.py` pour les 4 types de destination et la résolution interactive de collision de boîtiers — `--destination nouveau|parent|sous-dossier:CHEMIN|fusion:CHEMIN` (cf. `contracts/cli-import.md`), étiquetage manuel interactif via `list_boitiers`/`assign_manual_source`. Validation manuelle (CLI réel, pas seulement tests unitaires) a révélé et corrigé 2 bugs : (1) le sous-dossier d'étape n'était pas physiquement imbriqué sous le dossier parent réel, seulement sous la racine catégorie héritée ; (2) `push.archiver` écrasait silencieusement un fichier archivé de contenu différent en cas de collision de nom entre deux imports séparés — cf. `CollisionNomArchiveError` et `contracts/regine-core-api.md` | |
| 121 | 121 | |
| 122 | 122 | **Checkpoint**: User Stories 1, 2 et 3 fonctionnelles (la fusion vers un dossier déjà archivé reste conditionnée à `specs/005`, tout le reste est indépendant). |
| 123 | 123 | |
| @@ -125,9 +125,9 @@ Ce module est le premier point d'intégration réel de trois autres specs déjà | ||
| 125 | 125 | |
| 126 | 126 | ## Phase 6: Polish & Cross-Cutting Concerns |
| 127 | 127 | |
| 128 | -- [ ] T036 [P] Exécuter manuellement les 3 scénarios de `specs/001-import-photos/quickstart.md` et consigner le résultat | |
| 129 | -- [ ] T037 [P] `ruff check --fix` sur `packages/regine-core` et `packages/regine-cli` | |
| 130 | -- [ ] T038 Mettre à jour `contracts/cli-import.md` et `contracts/regine-core-api.md` si l'implémentation révèle un écart avec les signatures documentées | |
| 128 | +- [X] T036 [P] Exécuter manuellement les 3 scénarios de `specs/001-import-photos/quickstart.md` et consigner le résultat — exécutés via la vraie CLI (`uv run python -m regine_cli.import_cmd import ...`, entrées interactives réelles pour le détachement) : scénario 1 (import simple) OK, scénario 2 (détachement du jour au pic isolé, réponse "o") OK, scénario 3 (parent + sous-dossier imbriqué + fusion avec checkout automatique + désambiguïsation de boîtiers) OK | |
| 129 | +- [X] T037 [P] `ruff check --fix` sur `packages/regine-core` et `packages/regine-cli` | |
| 130 | +- [X] T038 Mettre à jour `contracts/cli-import.md` et `contracts/regine-core-api.md` si l'implémentation révèle un écart avec les signatures documentées — les deux contrats réécrits pour refléter les signatures réelles (`resoudre_destination`/`resoudre_fusion`/`regrouper_par_nom_origine`/`resoudre_collisions_boitiers`, syntaxe `--destination`), et pour documenter `CollisionNomArchiveError` | |
| 131 | 131 | |
| 132 | 132 | --- |
| 133 | 133 | |
| @@ -26,10 +26,10 @@ Ce module est le premier point d'intégration réel de trois autres specs déjà | |||
| 26 | 26 | ||
| 27 | **Purpose**: Vérifier les prérequis inter-specs et poser le squelette propre à ce module. | 27 | **Purpose**: Vérifier les prérequis inter-specs et poser le squelette propre à ce module. |
| 28 | 28 | ||
| 29 | -- [ ] T001 Vérifier que `packages/regine-core/src/regine_core/dossier/root.py` et `packages/regine-core/src/regine_core/config/categories.py` existent déjà (créés par `specs/004-categorisation-dossiers/tasks.md`) ; si absents, exécuter d'abord ce fichier de tâches avant de continuer | 29 | +- [X] T001 Vérifier que `packages/regine-core/src/regine_core/dossier/root.py` et `packages/regine-core/src/regine_core/config/categories.py` existent déjà (créés par `specs/004-categorisation-dossiers/tasks.md`) ; si absents, exécuter d'abord ce fichier de tâches avant de continuer |
| 30 | -- [ ] T002 [P] Créer `packages/regine-core/src/regine_core/import_carte/__init__.py` | 30 | +- [X] T002 [P] Créer `packages/regine-core/src/regine_core/import_carte/__init__.py` |
| 31 | -- [ ] T003 [P] Créer `packages/regine-cli/src/regine_cli/import_cmd.py` (squelette de la commande `regine import`, sans logique) | 31 | +- [X] T003 [P] Créer `packages/regine-cli/src/regine_cli/import_cmd.py` (squelette de la commande `regine import`, sans logique) — reportée à la fin de US1 (T019), une fois la logique métier disponible à orchestrer |
| 32 | -- [ ] T004 [P] Créer les dossiers `packages/regine-core/tests/unit/` et `packages/regine-core/tests/integration/` s'ils n'existent pas déjà (normalement déjà créés par specs/004) | 32 | +- [X] T004 [P] Créer les dossiers `packages/regine-core/tests/unit/` et `packages/regine-core/tests/integration/` s'ils n'existent pas déjà (normalement déjà créés par specs/004) — déjà présents |
| 33 | 33 | ||
| 34 | --- | 34 | --- |
| 35 | 35 | ||
| @@ -39,10 +39,10 @@ Ce module est le premier point d'intégration réel de trois autres specs déjà | |||
| 39 | 39 | ||
| 40 | **⚠️ CRITICAL**: Aucune user story ne peut être implémentée avant la fin de cette phase. | 40 | **⚠️ CRITICAL**: Aucune user story ne peut être implémentée avant la fin de cette phase. |
| 41 | 41 | ||
| 42 | -- [ ] T005 Créer `packages/regine-core/src/regine_core/import_carte/types.py` : dataclasses `FichierCandidat`, `GroupeImport`, `DestinationChoisie`, `Renommage` (cf. `data-model.md`) | 42 | +- [X] T005 Créer `packages/regine-core/src/regine_core/import_carte/types.py` : dataclasses `FichierCandidat`, `GroupeImport`, `DestinationChoisie`, `Renommage` (cf. `data-model.md`) |
| 43 | -- [ ] T006 Étendre `packages/regine-core/src/regine_core/metadata/exif.py` (existant, specs/002) avec `read_capture_date(chemin) -> datetime | None` (tag EXIF `DateTimeOriginal`, cf. `research.md` § 2) | 43 | +- [X] T006 Étendre `packages/regine-core/src/regine_core/metadata/exif.py` (existant, specs/002) avec `read_capture_date(chemin) -> datetime | None` (tag EXIF `DateTimeOriginal`, cf. `research.md` § 2) |
| 44 | -- [ ] T007 Étendre `packages/regine-core/src/regine_core/metadata/exif.py` avec `write_document_id(chemin, identifiant) -> None` (écrit `xmpMM:DocumentID` via exiftool, idempotent, cf. `research.md` § 3) | 44 | +- [X] T007 Étendre `packages/regine-core/src/regine_core/metadata/exif.py` avec `write_document_id(chemin, identifiant) -> None` (écrit `xmpMM:DocumentID` via exiftool, idempotent, cf. `research.md` § 3) — `read_document_id` ajoutée aussi, utile pour tests/manifeste |
| 45 | -- [ ] T008 [P] Test unitaire de `read_capture_date`/`write_document_id` dans `packages/regine-core/tests/unit/test_identifiant.py` (date valide, date absente ; écriture puis relecture de l'identifiant) | 45 | +- [X] T008 [P] Test unitaire de `read_capture_date`/`write_document_id` dans `packages/regine-core/tests/unit/test_identifiant.py` (date valide, date absente ; écriture puis relecture de l'identifiant) — validé avec de vraies opérations exiftool |
| 46 | 46 | ||
| 47 | **Checkpoint**: Fondations prêtes — les phases User Story peuvent commencer. | 47 | **Checkpoint**: Fondations prêtes — les phases User Story peuvent commencer. |
| 48 | 48 | ||
| @@ -56,20 +56,20 @@ Ce module est le premier point d'intégration réel de trois autres specs déjà | |||
| 56 | 56 | ||
| 57 | ### Tests for User Story 1 | 57 | ### Tests for User Story 1 |
| 58 | 58 | ||
| 59 | -- [ ] T009 [P] [US1] Test unitaire copie vérifiée : une seule lecture de la carte, échec de vérification signalé sans marquer la carte sûre à effacer, dans `packages/regine-core/tests/unit/test_copie_checksum.py` | 59 | +- [X] T009 [P] [US1] Test unitaire copie vérifiée : une seule lecture de la carte, échec de vérification signalé sans marquer la carte sûre à effacer, dans `packages/regine-core/tests/unit/test_copie_checksum.py` |
| 60 | -- [ ] T010 [P] [US1] Test unitaire découpage en groupe unique (cas simple, une seule journée) dans `packages/regine-core/tests/unit/test_groupage_dates.py` | 60 | +- [X] T010 [P] [US1] Test unitaire découpage en groupe unique (cas simple, une seule journée) dans `packages/regine-core/tests/unit/test_groupage_dates.py` |
| 61 | -- [ ] T011 [P] [US1] Test unitaire construction du nom de dossier et détection de collision dans `packages/regine-core/tests/unit/test_nommage.py` | 61 | +- [X] T011 [P] [US1] Test unitaire construction du nom de dossier et détection de collision dans `packages/regine-core/tests/unit/test_nommage.py` |
| 62 | -- [ ] T012 [US1] Test d'intégration du pipeline complet (carte simple → dossier archivé, résumé confirmé) dans `packages/regine-core/tests/integration/test_pipeline_import_simple.py` | 62 | +- [X] T012 [US1] Test d'intégration du pipeline complet (carte simple → dossier archivé, résumé confirmé) dans `packages/regine-core/tests/integration/test_pipeline_import_simple.py` |
| 63 | 63 | ||
| 64 | ### Implementation for User Story 1 | 64 | ### Implementation for User Story 1 |
| 65 | 65 | ||
| 66 | -- [ ] T013 [US1] Implémenter `copier_carte(carte, local_tmp) -> list[FichierCandidat]` dans `packages/regine-core/src/regine_core/import_carte/copie.py` (FR-001 : une seule lecture carte, FR-004 : filtrage des fichiers déjà importés) | 66 | +- [X] T013 [US1] Implémenter `copier_carte(carte, local_tmp) -> list[FichierCandidat]` dans `packages/regine-core/src/regine_core/import_carte/copie.py` (FR-001 : une seule lecture carte, FR-004 : filtrage des fichiers déjà importés) |
| 67 | -- [ ] T014 [US1] Implémenter `decouper_en_groupes(fichiers) -> list[GroupeImport]` dans `packages/regine-core/src/regine_core/import_carte/groupage.py` (FR-002 lecture date, branche groupe unique par défaut de FR-005 ; le détachement multi-jours est complété en Phase 4) | 67 | +- [X] T014 [US1] Implémenter `decouper_en_groupes(fichiers) -> list[GroupeImport]` dans `packages/regine-core/src/regine_core/import_carte/groupage.py` (FR-002 lecture date, branche groupe unique par défaut de FR-005 ; le détachement multi-jours est complété en Phase 4) |
| 68 | -- [ ] T015 [US1] Implémenter la branche `nouveau_dossier` de `resoudre_destination` dans `packages/regine-core/src/regine_core/import_carte/destination.py` (FR-007 pour ce cas, appelle `regine_core.dossier.root.determine_root`) | 68 | +- [X] T015 [US1] Implémenter la branche `nouveau_dossier` de `resoudre_destination` dans `packages/regine-core/src/regine_core/import_carte/destination.py` (FR-007 pour ce cas, appelle `regine_core.dossier.root.determine_root`) |
| 69 | -- [ ] T016 [US1] Implémenter `construire_nom_dossier`/`renommer_fichiers` dans `packages/regine-core/src/regine_core/import_carte/nommage.py` (FR-010/012/013/014) | 69 | +- [X] T016 [US1] Implémenter `construire_nom_dossier`/`renommer_fichiers` dans `packages/regine-core/src/regine_core/import_carte/nommage.py` (FR-010/012/013/014) |
| 70 | -- [ ] T017 [US1] Implémenter `attribuer_identifiants` dans `packages/regine-core/src/regine_core/import_carte/identifiant.py` (FR-017, appelle `write_document_id`) | 70 | +- [X] T017 [US1] Implémenter `attribuer_identifiants` dans `packages/regine-core/src/regine_core/import_carte/identifiant.py` (FR-017, appelle `write_document_id`) |
| 71 | -- [ ] T018 [US1] Implémenter `preparer_resume`/`archiver` dans `packages/regine-core/src/regine_core/import_carte/push.py` (FR-018/019) | 71 | +- [X] T018 [US1] Implémenter `preparer_resume`/`archiver` dans `packages/regine-core/src/regine_core/import_carte/push.py` (FR-018/019) |
| 72 | -- [ ] T019 [US1] Orchestrer le pipeline dans `packages/regine-cli/src/regine_cli/import_cmd.py` pour `regine import <carte> --annee --titre TEXTE` (cas `nouveau_dossier` uniquement) | 72 | +- [X] T019 [US1] Orchestrer le pipeline dans `packages/regine-cli/src/regine_cli/import_cmd.py` pour `regine import <carte> --annee --titre TEXTE` (cas `nouveau_dossier` uniquement) — validé en conditions réelles (`python -m regine_cli.import_cmd import ...`) |
| 73 | 73 | ||
| 74 | **Checkpoint**: User Story 1 fonctionnelle et testable indépendamment (MVP). | 74 | **Checkpoint**: User Story 1 fonctionnelle et testable indépendamment (MVP). |
| 75 | 75 | ||
| @@ -83,15 +83,15 @@ Ce module est le premier point d'intégration réel de trois autres specs déjà | |||
| 83 | 83 | ||
| 84 | ### Tests for User Story 2 | 84 | ### Tests for User Story 2 |
| 85 | 85 | ||
| 86 | -- [ ] T020 [P] [US2] Test unitaire d'exclusion des dates aberrantes du calcul de plage dans `packages/regine-core/tests/unit/test_groupage_dates.py` | 86 | +- [X] T020 [P] [US2] Test unitaire d'exclusion des dates aberrantes du calcul de plage dans `packages/regine-core/tests/unit/test_groupage_dates.py` |
| 87 | -- [ ] T021 [P] [US2] Test unitaire de mise en avant d'un jour candidat au détachement (heuristique, cf. `research.md` § 5) et de détachement manuel dans `test_groupage_dates.py` | 87 | +- [X] T021 [P] [US2] Test unitaire de mise en avant d'un jour candidat au détachement (heuristique, cf. `research.md` § 5) et de détachement manuel dans `test_groupage_dates.py` |
| 88 | -- [ ] T022 [US2] Test d'intégration du découpage en plusieurs groupes avec destination/titre distincts dans `packages/regine-core/tests/integration/test_pipeline_multi_jours.py` | 88 | +- [X] T022 [US2] Test d'intégration du découpage en plusieurs groupes avec destination/titre distincts dans `packages/regine-core/tests/integration/test_pipeline_multi_jours.py` |
| 89 | 89 | ||
| 90 | ### Implementation for User Story 2 | 90 | ### Implementation for User Story 2 |
| 91 | 91 | ||
| 92 | -- [ ] T023 [US2] Étendre `decouper_en_groupes` : exclusion des dates aberrantes (FR-003) et heuristique de mise en avant d'un jour candidat (FR-006, cf. `research.md` § 5), dans `groupage.py` (dépend de T014) | 92 | +- [X] T023 [US2] Étendre `decouper_en_groupes` : exclusion des dates aberrantes (FR-003) et heuristique de mise en avant d'un jour candidat (FR-006, cf. `research.md` § 5), dans `groupage.py` (dépend de T014) — implémentée dès T014 (Phase 3), une seule passe d'écriture cohérente |
| 93 | -- [ ] T024 [US2] Ajouter le détachement manuel d'un ou plusieurs jours par l'utilisateur, produisant des `GroupeImport` distincts, dans `groupage.py` | 93 | +- [X] T024 [US2] Ajouter le détachement manuel d'un ou plusieurs jours par l'utilisateur, produisant des `GroupeImport` distincts, dans `groupage.py` — implémentée dès T014 |
| 94 | -- [ ] T025 [US2] Étendre `packages/regine-cli/src/regine_cli/import_cmd.py` pour afficher la répartition jour par jour et gérer le détachement interactif, puis demander destination/titre pour chaque groupe résultant | 94 | +- [X] T025 [US2] Étendre `packages/regine-cli/src/regine_cli/import_cmd.py` pour afficher la répartition jour par jour et gérer le détachement interactif, puis demander destination/titre pour chaque groupe résultant — implémentée dès T019 (`_proposer_detachement`) |
| 95 | 95 | ||
| 96 | **Checkpoint**: User Stories 1 ET 2 fonctionnelles indépendamment. | 96 | **Checkpoint**: User Stories 1 ET 2 fonctionnelles indépendamment. |
| 97 | 97 | ||
| @@ -105,19 +105,19 @@ Ce module est le premier point d'intégration réel de trois autres specs déjà | |||
| 105 | 105 | ||
| 106 | ### Tests for User Story 3 | 106 | ### Tests for User Story 3 |
| 107 | 107 | ||
| 108 | -- [ ] T026 [P] [US3] Test unitaire de recherche de dossiers candidats par titre/date proche (`difflib`, cf. `research.md` § 4) dans `packages/regine-core/tests/unit/test_destination_candidats.py` | 108 | +- [X] T026 [P] [US3] Test unitaire de recherche de dossiers candidats par titre/date proche (`difflib`, cf. `research.md` § 4) dans `packages/regine-core/tests/unit/test_destination_candidats.py` |
| 109 | -- [ ] T027 [P] [US3] Test unitaire : la branche `nouveau_sous_dossier` réutilise le `RootLocation` du parent sans nouvel appel à `determine_root`, dans `test_destination_candidats.py` | 109 | +- [X] T027 [P] [US3] Test unitaire : la branche `nouveau_sous_dossier` réutilise le `RootLocation` du parent sans nouvel appel à `determine_root`, dans `test_destination_candidats.py` |
| 110 | -- [ ] T028 [US3] Test d'intégration voyage complet (parent + sous-dossier + fusion locale + désambiguïsation de boîtiers) dans `packages/regine-core/tests/integration/test_pipeline_voyage.py` | 110 | +- [X] T028 [US3] Test d'intégration voyage complet (parent + sous-dossier + fusion locale + désambiguïsation de boîtiers) dans `packages/regine-core/tests/integration/test_pipeline_voyage.py` |
| 111 | -- [ ] T029 [US3] Test d'intégration de la fusion vers un dossier **déjà archivé** dans `test_pipeline_voyage.py` — **`specs/005-checkout-reconciliation` est désormais implémentée (2026-09-19)**, cette tâche est activable ; ne bloque pas le reste de cette phase | 111 | +- [X] T029 [US3] Test d'intégration de la fusion vers un dossier **déjà archivé** dans `test_pipeline_voyage.py` — teste un vrai appel à `regine_core.archive.checkout.checkout` (specs/005) |
| 112 | 112 | ||
| 113 | ### Implementation for User Story 3 | 113 | ### Implementation for User Story 3 |
| 114 | 114 | ||
| 115 | -- [ ] T030 [US3] Étendre `resoudre_destination` : branches `nouveau_sous_dossier` (hérite le `RootLocation` du parent, FR-006 de specs/004) et `nouveau_parent` (FR-007), dans `destination.py` (dépend de T015) | 115 | +- [X] T030 [US3] Étendre `resoudre_destination` : branches `nouveau_sous_dossier` (hérite le `RootLocation` du parent, FR-006 de specs/004) et `nouveau_parent` (FR-007), dans `destination.py` (dépend de T015) — implémentées dès T015 |
| 116 | -- [ ] T031 [US3] Implémenter `lister_dossiers_candidats(titre_partiel, date_proche)` dans `destination.py` (FR-008, cf. `research.md` § 4) | 116 | +- [X] T031 [US3] Implémenter `lister_dossiers_candidats(titre_partiel, date_proche)` dans `destination.py` (FR-008, cf. `research.md` § 4) — implémentée dès T015 |
| 117 | -- [ ] T032 [US3] Implémenter la branche `fusion` locale de `resoudre_destination` (dossier cible présent dans l'espace de travail local) dans `destination.py` (FR-009, partie locale) | 117 | +- [X] T032 [US3] Implémenter la branche `fusion` locale de `resoudre_destination` (dossier cible présent dans l'espace de travail local) dans `destination.py` (FR-009, partie locale) — `resoudre_fusion()` |
| 118 | -- [ ] T033 [US3] Implémenter la branche `fusion` vers un dossier déjà archivé : appelle `regine_core.archive.checkout.checkout` (`specs/005-checkout-reconciliation`), dans `destination.py` — **`specs/005-checkout-reconciliation` est désormais implémentée (2026-09-19)**, cette tâche est activable | 118 | +- [X] T033 [US3] Implémenter la branche `fusion` vers un dossier déjà archivé : appelle `regine_core.archive.checkout.checkout` (`specs/005-checkout-reconciliation`), dans `destination.py` — `resoudre_fusion()`, validé avec un vrai checkout |
| 119 | -- [ ] T034 [US3] Intégrer la désambiguïsation de boîtiers dans `copie.py` : regrouper les fichiers en collision de nom d'origine, appeler `regine_core.camera_profile.resolve_collision` (specs/002), gérer les groupes nécessitant un étiquetage manuel (FR-015/016) — **`specs/002-profil-boitiers-optionnel` est désormais implémentée (2026-09-19)**, cette tâche est activable | 119 | +- [X] T034 [US3] Intégrer la désambiguïsation de boîtiers dans `copie.py` : regrouper les fichiers en collision de nom d'origine, appeler `regine_core.camera_profile.resolve_collision` (specs/002), gérer les groupes nécessitant un étiquetage manuel (FR-015/016) — `regrouper_par_nom_origine`/`resoudre_collisions_boitiers` ; copie anti-collision ajoutée dans `copier_carte` (ne jamais écraser un fichier de même nom d'origine) |
| 120 | -- [ ] T035 [US3] Étendre `packages/regine-cli/src/regine_cli/import_cmd.py` pour les 4 types de destination et la résolution interactive de collision de boîtiers | 120 | +- [X] T035 [US3] Étendre `packages/regine-cli/src/regine_cli/import_cmd.py` pour les 4 types de destination et la résolution interactive de collision de boîtiers — `--destination nouveau|parent|sous-dossier:CHEMIN|fusion:CHEMIN` (cf. `contracts/cli-import.md`), étiquetage manuel interactif via `list_boitiers`/`assign_manual_source`. Validation manuelle (CLI réel, pas seulement tests unitaires) a révélé et corrigé 2 bugs : (1) le sous-dossier d'étape n'était pas physiquement imbriqué sous le dossier parent réel, seulement sous la racine catégorie héritée ; (2) `push.archiver` écrasait silencieusement un fichier archivé de contenu différent en cas de collision de nom entre deux imports séparés — cf. `CollisionNomArchiveError` et `contracts/regine-core-api.md` |
| 121 | 121 | ||
| 122 | **Checkpoint**: User Stories 1, 2 et 3 fonctionnelles (la fusion vers un dossier déjà archivé reste conditionnée à `specs/005`, tout le reste est indépendant). | 122 | **Checkpoint**: User Stories 1, 2 et 3 fonctionnelles (la fusion vers un dossier déjà archivé reste conditionnée à `specs/005`, tout le reste est indépendant). |
| 123 | 123 | ||
| @@ -125,9 +125,9 @@ Ce module est le premier point d'intégration réel de trois autres specs déjà | |||
| 125 | 125 | ||
| 126 | ## Phase 6: Polish & Cross-Cutting Concerns | 126 | ## Phase 6: Polish & Cross-Cutting Concerns |
| 127 | 127 | ||
| 128 | -- [ ] T036 [P] Exécuter manuellement les 3 scénarios de `specs/001-import-photos/quickstart.md` et consigner le résultat | 128 | +- [X] T036 [P] Exécuter manuellement les 3 scénarios de `specs/001-import-photos/quickstart.md` et consigner le résultat — exécutés via la vraie CLI (`uv run python -m regine_cli.import_cmd import ...`, entrées interactives réelles pour le détachement) : scénario 1 (import simple) OK, scénario 2 (détachement du jour au pic isolé, réponse "o") OK, scénario 3 (parent + sous-dossier imbriqué + fusion avec checkout automatique + désambiguïsation de boîtiers) OK |
| 129 | -- [ ] T037 [P] `ruff check --fix` sur `packages/regine-core` et `packages/regine-cli` | 129 | +- [X] T037 [P] `ruff check --fix` sur `packages/regine-core` et `packages/regine-cli` |
| 130 | -- [ ] T038 Mettre à jour `contracts/cli-import.md` et `contracts/regine-core-api.md` si l'implémentation révèle un écart avec les signatures documentées | 130 | +- [X] T038 Mettre à jour `contracts/cli-import.md` et `contracts/regine-core-api.md` si l'implémentation révèle un écart avec les signatures documentées — les deux contrats réécrits pour refléter les signatures réelles (`resoudre_destination`/`resoudre_fusion`/`regrouper_par_nom_origine`/`resoudre_collisions_boitiers`, syntaxe `--destination`), et pour documenter `CollisionNomArchiveError` |
| 131 | 131 | ||
| 132 | --- | 132 | --- |
| 133 | 133 | ||