tri raw/jpeg
02d8847 parent: eca67cf modified
packages/regine-cli/src/regine_cli/import_cmd.py +11 -7 | @@ -18,6 +18,7 @@ from regine_core.config.categories import ( | ||
| 18 | 18 | suggest_categories, |
| 19 | 19 | ) |
| 20 | 20 | from regine_core.config.db import open_context_db |
| 21 | +from regine_core.import_carte.classement import classer_par_format | |
| 21 | 22 | from regine_core.import_carte.copie import copier_carte, resoudre_collisions_boitiers |
| 22 | 23 | from regine_core.import_carte.destination import ( |
| 23 | 24 | lister_dossiers_candidats, |
| @@ -38,7 +39,7 @@ from regine_core.import_carte.nommage import ( | ||
| 38 | 39 | renommer_fichiers, |
| 39 | 40 | resoudre_collision_nom, |
| 40 | 41 | ) |
| 41 | -from regine_core.import_carte.push import archiver, preparer_resume, publier_copie_locale | |
| 42 | +from regine_core.import_carte.push import preparer_resume, publier_localement | |
| 42 | 43 | from regine_core.import_carte.types import DestinationChoisie, FichierCandidat, GroupeImport |
| 43 | 44 | |
| 44 | 45 | _NOM_CONTEXTE_DB_PAR_DEFAUT = ".regine-contexte.sqlite3" |
| @@ -253,27 +254,30 @@ def _traiter_groupe( | ||
| 253 | 254 | print(f"Checkout automatique depuis l'archive : {dossier_archive} -> {dossier_local}") |
| 254 | 255 | |
| 255 | 256 | renommer_fichiers(groupe.fichiers, groupe.plage_dates[0], titre) |
| 257 | + classer_par_format(groupe.fichiers, local_tmp) | |
| 256 | 258 | |
| 257 | 259 | chemins_maitres = [f.chemin_source for f in groupe.fichiers if f.type == "maitre"] |
| 258 | 260 | attribuer_identifiants(chemins_maitres) |
| 259 | 261 | |
| 260 | 262 | chemins = [f.chemin_source for f in groupe.fichiers] |
| 261 | - resume = preparer_resume(chemins, dossier_archive) | |
| 263 | + resume = preparer_resume(chemins, dossier_local) | |
| 262 | 264 | print( |
| 263 | 265 | f"Résumé : {resume.nombre_fichiers} fichier(s), {resume.taille_totale} octet(s) " |
| 264 | 266 | f"-> {resume.dossier_destination}" |
| 265 | 267 | ) |
| 266 | 268 | |
| 267 | 269 | if interactif: |
| 268 | - confirmation = input("Confirmer l'archivage ? [o/n] ") | |
| 270 | + confirmation = input("Confirmer la copie de travail locale ? [o/n] ") | |
| 269 | 271 | if confirmation.strip().lower() != "o": |
| 270 | - print("Archivage annulé pour ce groupe.") | |
| 272 | + print("Import annulé pour ce groupe.") | |
| 271 | 273 | return |
| 272 | 274 | |
| 273 | - archiver(chemins, local_tmp, dossier_archive) | |
| 274 | - publier_copie_locale(chemins, local_tmp, dossier_local, dossier_archive) | |
| 275 | - print(f"Archivé : {dossier_archive}") | |
| 275 | + publier_localement(chemins, local_tmp, dossier_local) | |
| 276 | 276 | print(f"Copie de travail locale prête : {dossier_local}") |
| 277 | + print( | |
| 278 | + f"Rien n'a été écrit sur l'archive : une fois votre sélection terminée, " | |
| 279 | + f"archivez avec `regine reconcile {dossier_archive} --local-dest {dossier_local}`." | |
| 280 | + ) | |
| 277 | 281 | |
| 278 | 282 | |
| 279 | 283 | def _cmd_import(args: argparse.Namespace) -> int: |
| @@ -18,6 +18,7 @@ from regine_core.config.categories import ( | |||
| 18 | suggest_categories, | 18 | suggest_categories, |
| 19 | ) | 19 | ) |
| 20 | from regine_core.config.db import open_context_db | 20 | from regine_core.config.db import open_context_db |
| 21 | +from regine_core.import_carte.classement import classer_par_format | ||
| 21 | from regine_core.import_carte.copie import copier_carte, resoudre_collisions_boitiers | 22 | from regine_core.import_carte.copie import copier_carte, resoudre_collisions_boitiers |
| 22 | from regine_core.import_carte.destination import ( | 23 | from regine_core.import_carte.destination import ( |
| 23 | lister_dossiers_candidats, | 24 | lister_dossiers_candidats, |
| @@ -38,7 +39,7 @@ from regine_core.import_carte.nommage import ( | |||
| 38 | renommer_fichiers, | 39 | renommer_fichiers, |
| 39 | resoudre_collision_nom, | 40 | resoudre_collision_nom, |
| 40 | ) | 41 | ) |
| 41 | -from regine_core.import_carte.push import archiver, preparer_resume, publier_copie_locale | 42 | +from regine_core.import_carte.push import preparer_resume, publier_localement |
| 42 | from regine_core.import_carte.types import DestinationChoisie, FichierCandidat, GroupeImport | 43 | from regine_core.import_carte.types import DestinationChoisie, FichierCandidat, GroupeImport |
| 43 | 44 | ||
| 44 | _NOM_CONTEXTE_DB_PAR_DEFAUT = ".regine-contexte.sqlite3" | 45 | _NOM_CONTEXTE_DB_PAR_DEFAUT = ".regine-contexte.sqlite3" |
| @@ -253,27 +254,30 @@ def _traiter_groupe( | |||
| 253 | print(f"Checkout automatique depuis l'archive : {dossier_archive} -> {dossier_local}") | 254 | print(f"Checkout automatique depuis l'archive : {dossier_archive} -> {dossier_local}") |
| 254 | 255 | ||
| 255 | renommer_fichiers(groupe.fichiers, groupe.plage_dates[0], titre) | 256 | renommer_fichiers(groupe.fichiers, groupe.plage_dates[0], titre) |
| 257 | + classer_par_format(groupe.fichiers, local_tmp) | ||
| 256 | 258 | ||
| 257 | chemins_maitres = [f.chemin_source for f in groupe.fichiers if f.type == "maitre"] | 259 | chemins_maitres = [f.chemin_source for f in groupe.fichiers if f.type == "maitre"] |
| 258 | attribuer_identifiants(chemins_maitres) | 260 | attribuer_identifiants(chemins_maitres) |
| 259 | 261 | ||
| 260 | chemins = [f.chemin_source for f in groupe.fichiers] | 262 | chemins = [f.chemin_source for f in groupe.fichiers] |
| 261 | - resume = preparer_resume(chemins, dossier_archive) | 263 | + resume = preparer_resume(chemins, dossier_local) |
| 262 | print( | 264 | print( |
| 263 | f"Résumé : {resume.nombre_fichiers} fichier(s), {resume.taille_totale} octet(s) " | 265 | f"Résumé : {resume.nombre_fichiers} fichier(s), {resume.taille_totale} octet(s) " |
| 264 | f"-> {resume.dossier_destination}" | 266 | f"-> {resume.dossier_destination}" |
| 265 | ) | 267 | ) |
| 266 | 268 | ||
| 267 | if interactif: | 269 | if interactif: |
| 268 | - confirmation = input("Confirmer l'archivage ? [o/n] ") | 270 | + confirmation = input("Confirmer la copie de travail locale ? [o/n] ") |
| 269 | if confirmation.strip().lower() != "o": | 271 | if confirmation.strip().lower() != "o": |
| 270 | - print("Archivage annulé pour ce groupe.") | 272 | + print("Import annulé pour ce groupe.") |
| 271 | return | 273 | return |
| 272 | 274 | ||
| 273 | - archiver(chemins, local_tmp, dossier_archive) | 275 | + publier_localement(chemins, local_tmp, dossier_local) |
| 274 | - publier_copie_locale(chemins, local_tmp, dossier_local, dossier_archive) | ||
| 275 | - print(f"Archivé : {dossier_archive}") | ||
| 276 | print(f"Copie de travail locale prête : {dossier_local}") | 276 | print(f"Copie de travail locale prête : {dossier_local}") |
| 277 | + print( | ||
| 278 | + f"Rien n'a été écrit sur l'archive : une fois votre sélection terminée, " | ||
| 279 | + f"archivez avec `regine reconcile {dossier_archive} --local-dest {dossier_local}`." | ||
| 280 | + ) | ||
| 277 | 281 | ||
| 278 | 282 | ||
| 279 | def _cmd_import(args: argparse.Namespace) -> int: | 283 | def _cmd_import(args: argparse.Namespace) -> int: |
modified
packages/regine-core/src/regine_core/archive/consultation.py +1 -1 | @@ -14,7 +14,7 @@ from regine_core.integrity.hash import hash_fichier_entier | ||
| 14 | 14 | class RestaurationCollisionError(Exception): |
| 15 | 15 | """`chemin_destination` existe déjà avec un contenu différent (FR-012) — jamais |
| 16 | 16 | d'écrasement silencieux, même principe que `regine_core.import_carte.push. |
| 17 | - CollisionNomArchiveError`.""" | |
| 17 | + CollisionNomLocalError`.""" | |
| 18 | 18 | |
| 19 | 19 | |
| 20 | 20 | @dataclass(frozen=True) |
| @@ -14,7 +14,7 @@ from regine_core.integrity.hash import hash_fichier_entier | |||
| 14 | class RestaurationCollisionError(Exception): | 14 | class RestaurationCollisionError(Exception): |
| 15 | """`chemin_destination` existe déjà avec un contenu différent (FR-012) — jamais | 15 | """`chemin_destination` existe déjà avec un contenu différent (FR-012) — jamais |
| 16 | d'écrasement silencieux, même principe que `regine_core.import_carte.push. | 16 | d'écrasement silencieux, même principe que `regine_core.import_carte.push. |
| 17 | - CollisionNomArchiveError`.""" | 17 | + CollisionNomLocalError`.""" |
| 18 | 18 | ||
| 19 | 19 | ||
| 20 | @dataclass(frozen=True) | 20 | @dataclass(frozen=True) |
modified
packages/regine-core/src/regine_core/dossier/selection.py +6 -29 | @@ -10,35 +10,12 @@ from __future__ import annotations | ||
| 10 | 10 | from dataclasses import dataclass |
| 11 | 11 | from pathlib import Path |
| 12 | 12 | |
| 13 | -from regine_core.import_carte.copie import EXTENSIONS_MAITRES | |
| 14 | - | |
| 15 | -#: RAW propriétaires + DNG : catégorie fonctionnelle unique `raw/`, pas une extension | |
| 16 | -#: par dossier (cf. docs/archivage-photo-elements-cles.md section 10). | |
| 17 | -_EXTENSIONS_RAW = {"raf", "cr2", "cr3", "nef", "arw", "orf", "rw2", "pef", "srw", "dng"} | |
| 18 | -_EXTENSIONS_TIFF = {"tiff", "tif"} | |
| 19 | -_EXTENSIONS_JPEG = {"jpg", "jpeg"} | |
| 20 | - | |
| 21 | - | |
| 22 | -def dossier_de_format(extension: str) -> str | None: | |
| 23 | - """Dossier de format attendu pour `extension` (sans le point, insensible à la casse). | |
| 24 | - | |
| 25 | - `raw/` regroupe toutes les extensions RAW propriétaires et le DNG ; `tiff/` et | |
| 26 | - `jpeg/` correspondent chacun à une extension précise. Un format maître plus | |
| 27 | - marginal (ex. `bmp`) obtient son propre dossier nommé d'après lui, créé à la | |
| 28 | - demande — jamais une liste figée à l'avance (section 10 des notes d'archivage). | |
| 29 | - Retourne `None` pour une extension associée (sidecar) ou non reconnue comme | |
| 30 | - fichier maître. | |
| 31 | - """ | |
| 32 | - ext = extension.lstrip(".").lower() | |
| 33 | - if ext in _EXTENSIONS_RAW: | |
| 34 | - return "raw" | |
| 35 | - if ext in _EXTENSIONS_TIFF: | |
| 36 | - return "tiff" | |
| 37 | - if ext in _EXTENSIONS_JPEG: | |
| 38 | - return "jpeg" | |
| 39 | - if ext in EXTENSIONS_MAITRES: | |
| 40 | - return ext | |
| 41 | - return None | |
| 13 | +# `dossier_de_format` vit désormais dans `regine_core.import_carte.copie` : l'import | |
| 14 | +# (specs/001) en a besoin directement pour classer les fichiers à l'arrivée | |
| 15 | +# (cf. `import_carte.classement`), donc la définition canonique a été déplacée vers | |
| 16 | +# ce module plus fondamental plutôt que de la dupliquer une seconde fois — ré-exportée | |
| 17 | +# ici telle quelle pour ne pas casser les appels existants de ce module (Principe VI). | |
| 18 | +from regine_core.import_carte.copie import dossier_de_format # noqa: F401 | |
| 42 | 19 | |
| 43 | 20 | |
| 44 | 21 | class StructureDossierInvalideError(Exception): |
| @@ -10,35 +10,12 @@ from __future__ import annotations | |||
| 10 | from dataclasses import dataclass | 10 | from dataclasses import dataclass |
| 11 | from pathlib import Path | 11 | from pathlib import Path |
| 12 | 12 | ||
| 13 | -from regine_core.import_carte.copie import EXTENSIONS_MAITRES | 13 | +# `dossier_de_format` vit désormais dans `regine_core.import_carte.copie` : l'import |
| 14 | - | 14 | +# (specs/001) en a besoin directement pour classer les fichiers à l'arrivée |
| 15 | -#: RAW propriétaires + DNG : catégorie fonctionnelle unique `raw/`, pas une extension | 15 | +# (cf. `import_carte.classement`), donc la définition canonique a été déplacée vers |
| 16 | -#: par dossier (cf. docs/archivage-photo-elements-cles.md section 10). | 16 | +# ce module plus fondamental plutôt que de la dupliquer une seconde fois — ré-exportée |
| 17 | -_EXTENSIONS_RAW = {"raf", "cr2", "cr3", "nef", "arw", "orf", "rw2", "pef", "srw", "dng"} | 17 | +# ici telle quelle pour ne pas casser les appels existants de ce module (Principe VI). |
| 18 | -_EXTENSIONS_TIFF = {"tiff", "tif"} | 18 | +from regine_core.import_carte.copie import dossier_de_format # noqa: F401 |
| 19 | -_EXTENSIONS_JPEG = {"jpg", "jpeg"} | ||
| 20 | - | ||
| 21 | - | ||
| 22 | -def dossier_de_format(extension: str) -> str | None: | ||
| 23 | - """Dossier de format attendu pour `extension` (sans le point, insensible à la casse). | ||
| 24 | - | ||
| 25 | - `raw/` regroupe toutes les extensions RAW propriétaires et le DNG ; `tiff/` et | ||
| 26 | - `jpeg/` correspondent chacun à une extension précise. Un format maître plus | ||
| 27 | - marginal (ex. `bmp`) obtient son propre dossier nommé d'après lui, créé à la | ||
| 28 | - demande — jamais une liste figée à l'avance (section 10 des notes d'archivage). | ||
| 29 | - Retourne `None` pour une extension associée (sidecar) ou non reconnue comme | ||
| 30 | - fichier maître. | ||
| 31 | - """ | ||
| 32 | - ext = extension.lstrip(".").lower() | ||
| 33 | - if ext in _EXTENSIONS_RAW: | ||
| 34 | - return "raw" | ||
| 35 | - if ext in _EXTENSIONS_TIFF: | ||
| 36 | - return "tiff" | ||
| 37 | - if ext in _EXTENSIONS_JPEG: | ||
| 38 | - return "jpeg" | ||
| 39 | - if ext in EXTENSIONS_MAITRES: | ||
| 40 | - return ext | ||
| 41 | - return None | ||
| 42 | 19 | ||
| 43 | 20 | ||
| 44 | class StructureDossierInvalideError(Exception): | 21 | class StructureDossierInvalideError(Exception): |
added
packages/regine-core/src/regine_core/import_carte/classement.py +66 -0 | new file mode 100644 | ||
| @@ -0,0 +1,66 @@ | ||
| 1 | +"""Classement des fichiers renommés dans leur dossier de format (FR-020). | |
| 2 | + | |
| 3 | +Cf. docs/archivage-photo-elements-cles.md section 10 : structure retenue pour un | |
| 4 | +dossier, un dossier de format (`raw/`, `jpeg/`, `tiff/`...) créé à la demande selon | |
| 5 | +ce qui est réellement présent — pas une liste figée à l'avance. Sans cette étape, | |
| 6 | +un dossier fraîchement importé se retrouve entièrement à plat, ce que l'écran de | |
| 7 | +tri (`regine_core.dossier.selection`, specs/006) interprète alors à tort comme | |
| 8 | +« déjà promu à la racine » pour chaque fichier (aucun fichier restant à trier). | |
| 9 | + | |
| 10 | +Réutilise `dossier_de_format` (`regine_core.import_carte.copie`) plutôt que de | |
| 11 | +dupliquer la correspondance extension -> dossier de format (Principe VI) : même | |
| 12 | +fonction déjà utilisée par l'écran de tri pour promouvoir/rétrograder. | |
| 13 | +""" | |
| 14 | + | |
| 15 | +from __future__ import annotations | |
| 16 | + | |
| 17 | +from pathlib import Path | |
| 18 | + | |
| 19 | +from regine_core.import_carte.copie import dossier_de_format | |
| 20 | +from regine_core.import_carte.types import FichierCandidat | |
| 21 | + | |
| 22 | + | |
| 23 | +def classer_par_format(fichiers: list[FichierCandidat], racine: Path) -> None: | |
| 24 | + """Déplace chaque fichier maître dans son dossier de format, créé à la demande. | |
| 25 | + | |
| 26 | + Un fichier maître utilise toujours son propre dossier de format, déduit de sa | |
| 27 | + propre extension — jamais celui d'un autre maître partageant le même nom de | |
| 28 | + base : une paire RAW+JPEG jumelle vit dans deux dossiers différents (`raw/` et | |
| 29 | + `jpeg/`), jamais le même (docs/archivage-photo-elements-cles.md section 10). | |
| 30 | + | |
| 31 | + Chaque fichier associé (sidecar) rejoint le dossier du maître RAW partageant | |
| 32 | + son nom de base s'il y en a un (cas réel : réglages de développement associés | |
| 33 | + au RAW, jamais au JPEG jumeau), sinon celui de n'importe quel autre maître de | |
| 34 | + même nom de base — jamais un dossier de format qui lui serait propre. | |
| 35 | + | |
| 36 | + Doit être appelée après `nommage.renommer_fichiers` (les fichiers portent déjà | |
| 37 | + leur nom final) et avant tout calcul de résumé/publication : le classement fait | |
| 38 | + partie du chemin relatif final utilisé pour reproduire la même structure côté | |
| 39 | + copie de travail locale (`push.publier_localement`) et, plus tard, côté archive | |
| 40 | + (`regine reconcile`, specs/005). | |
| 41 | + """ | |
| 42 | + formats_par_base: dict[str, list[str]] = {} | |
| 43 | + for f in fichiers: | |
| 44 | + if f.type != "maitre": | |
| 45 | + continue | |
| 46 | + format_ = dossier_de_format(f.chemin_source.suffix) | |
| 47 | + if format_ is None: | |
| 48 | + continue | |
| 49 | + base = f.chemin_source.name.split(".")[0] | |
| 50 | + formats_par_base.setdefault(base, []).append(format_) | |
| 51 | + | |
| 52 | + for f in fichiers: | |
| 53 | + base = f.chemin_source.name.split(".")[0] | |
| 54 | + if f.type == "maitre": | |
| 55 | + format_ = dossier_de_format(f.chemin_source.suffix) | |
| 56 | + else: | |
| 57 | + candidats = formats_par_base.get(base, []) | |
| 58 | + format_ = "raw" if "raw" in candidats else next(iter(candidats), None) | |
| 59 | + if format_ is None: | |
| 60 | + # fichier maître non reconnu (ne devrait pas arriver, cf. copie.py) ou sidecar orphelin | |
| 61 | + continue | |
| 62 | + | |
| 63 | + destination = racine / format_ / f.chemin_source.name | |
| 64 | + destination.parent.mkdir(parents=True, exist_ok=True) | |
| 65 | + f.chemin_source.rename(destination) | |
| 66 | + f.chemin_source = destination | |
| new file mode 100644 | |||
| @@ -0,0 +1,66 @@ | |||
| 1 | +"""Classement des fichiers renommés dans leur dossier de format (FR-020). | ||
| 2 | + | ||
| 3 | +Cf. docs/archivage-photo-elements-cles.md section 10 : structure retenue pour un | ||
| 4 | +dossier, un dossier de format (`raw/`, `jpeg/`, `tiff/`...) créé à la demande selon | ||
| 5 | +ce qui est réellement présent — pas une liste figée à l'avance. Sans cette étape, | ||
| 6 | +un dossier fraîchement importé se retrouve entièrement à plat, ce que l'écran de | ||
| 7 | +tri (`regine_core.dossier.selection`, specs/006) interprète alors à tort comme | ||
| 8 | +« déjà promu à la racine » pour chaque fichier (aucun fichier restant à trier). | ||
| 9 | + | ||
| 10 | +Réutilise `dossier_de_format` (`regine_core.import_carte.copie`) plutôt que de | ||
| 11 | +dupliquer la correspondance extension -> dossier de format (Principe VI) : même | ||
| 12 | +fonction déjà utilisée par l'écran de tri pour promouvoir/rétrograder. | ||
| 13 | +""" | ||
| 14 | + | ||
| 15 | +from __future__ import annotations | ||
| 16 | + | ||
| 17 | +from pathlib import Path | ||
| 18 | + | ||
| 19 | +from regine_core.import_carte.copie import dossier_de_format | ||
| 20 | +from regine_core.import_carte.types import FichierCandidat | ||
| 21 | + | ||
| 22 | + | ||
| 23 | +def classer_par_format(fichiers: list[FichierCandidat], racine: Path) -> None: | ||
| 24 | + """Déplace chaque fichier maître dans son dossier de format, créé à la demande. | ||
| 25 | + | ||
| 26 | + Un fichier maître utilise toujours son propre dossier de format, déduit de sa | ||
| 27 | + propre extension — jamais celui d'un autre maître partageant le même nom de | ||
| 28 | + base : une paire RAW+JPEG jumelle vit dans deux dossiers différents (`raw/` et | ||
| 29 | + `jpeg/`), jamais le même (docs/archivage-photo-elements-cles.md section 10). | ||
| 30 | + | ||
| 31 | + Chaque fichier associé (sidecar) rejoint le dossier du maître RAW partageant | ||
| 32 | + son nom de base s'il y en a un (cas réel : réglages de développement associés | ||
| 33 | + au RAW, jamais au JPEG jumeau), sinon celui de n'importe quel autre maître de | ||
| 34 | + même nom de base — jamais un dossier de format qui lui serait propre. | ||
| 35 | + | ||
| 36 | + Doit être appelée après `nommage.renommer_fichiers` (les fichiers portent déjà | ||
| 37 | + leur nom final) et avant tout calcul de résumé/publication : le classement fait | ||
| 38 | + partie du chemin relatif final utilisé pour reproduire la même structure côté | ||
| 39 | + copie de travail locale (`push.publier_localement`) et, plus tard, côté archive | ||
| 40 | + (`regine reconcile`, specs/005). | ||
| 41 | + """ | ||
| 42 | + formats_par_base: dict[str, list[str]] = {} | ||
| 43 | + for f in fichiers: | ||
| 44 | + if f.type != "maitre": | ||
| 45 | + continue | ||
| 46 | + format_ = dossier_de_format(f.chemin_source.suffix) | ||
| 47 | + if format_ is None: | ||
| 48 | + continue | ||
| 49 | + base = f.chemin_source.name.split(".")[0] | ||
| 50 | + formats_par_base.setdefault(base, []).append(format_) | ||
| 51 | + | ||
| 52 | + for f in fichiers: | ||
| 53 | + base = f.chemin_source.name.split(".")[0] | ||
| 54 | + if f.type == "maitre": | ||
| 55 | + format_ = dossier_de_format(f.chemin_source.suffix) | ||
| 56 | + else: | ||
| 57 | + candidats = formats_par_base.get(base, []) | ||
| 58 | + format_ = "raw" if "raw" in candidats else next(iter(candidats), None) | ||
| 59 | + if format_ is None: | ||
| 60 | + # fichier maître non reconnu (ne devrait pas arriver, cf. copie.py) ou sidecar orphelin | ||
| 61 | + continue | ||
| 62 | + | ||
| 63 | + destination = racine / format_ / f.chemin_source.name | ||
| 64 | + destination.parent.mkdir(parents=True, exist_ok=True) | ||
| 65 | + f.chemin_source.rename(destination) | ||
| 66 | + f.chemin_source = destination | ||
modified
packages/regine-core/src/regine_core/import_carte/copie.py +33 -0 | @@ -29,9 +29,42 @@ EXTENSIONS_MAITRES = { | ||
| 29 | 29 | #: Extensions reconnues comme fichiers associés (sidecars). |
| 30 | 30 | EXTENSIONS_ASSOCIEES = {"xmp", "dop", "acr"} |
| 31 | 31 | |
| 32 | +#: RAW propriétaires + DNG : catégorie fonctionnelle unique `raw/`, pas une extension | |
| 33 | +#: par dossier (cf. docs/archivage-photo-elements-cles.md section 10). | |
| 34 | +_EXTENSIONS_RAW = {"raf", "cr2", "cr3", "nef", "arw", "orf", "rw2", "pef", "srw", "dng"} | |
| 35 | +_EXTENSIONS_TIFF = {"tiff", "tif"} | |
| 36 | +_EXTENSIONS_JPEG = {"jpg", "jpeg"} | |
| 37 | + | |
| 32 | 38 | _TAILLE_BLOC = 1024 * 1024 |
| 33 | 39 | |
| 34 | 40 | |
| 41 | +def dossier_de_format(extension: str) -> str | None: | |
| 42 | + """Dossier de format attendu pour `extension` (sans le point, insensible à la casse). | |
| 43 | + | |
| 44 | + `raw/` regroupe toutes les extensions RAW propriétaires et le DNG ; `tiff/` et | |
| 45 | + `jpeg/` correspondent chacun à une extension précise. Un format maître plus | |
| 46 | + marginal (ex. `bmp`) obtient son propre dossier nommé d'après lui, créé à la | |
| 47 | + demande — jamais une liste figée à l'avance (section 10 des notes d'archivage). | |
| 48 | + Retourne `None` pour une extension associée (sidecar) ou non reconnue comme | |
| 49 | + fichier maître. | |
| 50 | + | |
| 51 | + Vit ici (plutôt que dans `regine_core.dossier.selection`, qui la réutilisait | |
| 52 | + jusqu'ici) car l'import (specs/001) en a désormais besoin directement pour | |
| 53 | + classer les fichiers à l'arrivée (cf. `classement.py`) — `dossier.selection` | |
| 54 | + ne fait plus que la ré-exporter, pour ne pas créer de dépendance inversée entre | |
| 55 | + les deux modules (Principe VI, une seule définition partagée).""" | |
| 56 | + ext = extension.lstrip(".").lower() | |
| 57 | + if ext in _EXTENSIONS_RAW: | |
| 58 | + return "raw" | |
| 59 | + if ext in _EXTENSIONS_TIFF: | |
| 60 | + return "tiff" | |
| 61 | + if ext in _EXTENSIONS_JPEG: | |
| 62 | + return "jpeg" | |
| 63 | + if ext in EXTENSIONS_MAITRES: | |
| 64 | + return ext | |
| 65 | + return None | |
| 66 | + | |
| 67 | + | |
| 35 | 68 | class EchecVerificationError(Exception): |
| 36 | 69 | """La vérification par somme de contrôle d'un fichier a échoué pendant la copie.""" |
| 37 | 70 | |
| @@ -29,9 +29,42 @@ EXTENSIONS_MAITRES = { | |||
| 29 | #: Extensions reconnues comme fichiers associés (sidecars). | 29 | #: Extensions reconnues comme fichiers associés (sidecars). |
| 30 | EXTENSIONS_ASSOCIEES = {"xmp", "dop", "acr"} | 30 | EXTENSIONS_ASSOCIEES = {"xmp", "dop", "acr"} |
| 31 | 31 | ||
| 32 | +#: RAW propriétaires + DNG : catégorie fonctionnelle unique `raw/`, pas une extension | ||
| 33 | +#: par dossier (cf. docs/archivage-photo-elements-cles.md section 10). | ||
| 34 | +_EXTENSIONS_RAW = {"raf", "cr2", "cr3", "nef", "arw", "orf", "rw2", "pef", "srw", "dng"} | ||
| 35 | +_EXTENSIONS_TIFF = {"tiff", "tif"} | ||
| 36 | +_EXTENSIONS_JPEG = {"jpg", "jpeg"} | ||
| 37 | + | ||
| 32 | _TAILLE_BLOC = 1024 * 1024 | 38 | _TAILLE_BLOC = 1024 * 1024 |
| 33 | 39 | ||
| 34 | 40 | ||
| 41 | +def dossier_de_format(extension: str) -> str | None: | ||
| 42 | + """Dossier de format attendu pour `extension` (sans le point, insensible à la casse). | ||
| 43 | + | ||
| 44 | + `raw/` regroupe toutes les extensions RAW propriétaires et le DNG ; `tiff/` et | ||
| 45 | + `jpeg/` correspondent chacun à une extension précise. Un format maître plus | ||
| 46 | + marginal (ex. `bmp`) obtient son propre dossier nommé d'après lui, créé à la | ||
| 47 | + demande — jamais une liste figée à l'avance (section 10 des notes d'archivage). | ||
| 48 | + Retourne `None` pour une extension associée (sidecar) ou non reconnue comme | ||
| 49 | + fichier maître. | ||
| 50 | + | ||
| 51 | + Vit ici (plutôt que dans `regine_core.dossier.selection`, qui la réutilisait | ||
| 52 | + jusqu'ici) car l'import (specs/001) en a désormais besoin directement pour | ||
| 53 | + classer les fichiers à l'arrivée (cf. `classement.py`) — `dossier.selection` | ||
| 54 | + ne fait plus que la ré-exporter, pour ne pas créer de dépendance inversée entre | ||
| 55 | + les deux modules (Principe VI, une seule définition partagée).""" | ||
| 56 | + ext = extension.lstrip(".").lower() | ||
| 57 | + if ext in _EXTENSIONS_RAW: | ||
| 58 | + return "raw" | ||
| 59 | + if ext in _EXTENSIONS_TIFF: | ||
| 60 | + return "tiff" | ||
| 61 | + if ext in _EXTENSIONS_JPEG: | ||
| 62 | + return "jpeg" | ||
| 63 | + if ext in EXTENSIONS_MAITRES: | ||
| 64 | + return ext | ||
| 65 | + return None | ||
| 66 | + | ||
| 67 | + | ||
| 35 | class EchecVerificationError(Exception): | 68 | class EchecVerificationError(Exception): |
| 36 | """La vérification par somme de contrôle d'un fichier a échoué pendant la copie.""" | 69 | """La vérification par somme de contrôle d'un fichier a échoué pendant la copie.""" |
| 37 | 70 | ||
modified
packages/regine-core/src/regine_core/import_carte/push.py +29 -70 | @@ -1,11 +1,15 @@ | ||
| 1 | -"""Résumé de confirmation et transfert final vers l'archive (FR-018/019). | |
| 2 | - | |
| 3 | -Laisse aussi une copie de travail locale structurée de façon identique à | |
| 4 | -l'archive (FR-010 : « de façon identique dans la copie de travail locale et | |
| 5 | -dans l'archive ») pour permettre de continuer à trier/retoucher immédiatement | |
| 6 | -après l'import, sans checkout séparé — en réutilisant le manifeste persistant | |
| 7 | -et le verrouillage de `regine_core.archive` (`specs/005-checkout-reconciliation`) | |
| 8 | -plutôt qu'en inventant un mécanisme parallèle (Principe VI). | |
| 1 | +"""Résumé de confirmation et publication finale dans l'espace de travail local (FR-018). | |
| 2 | + | |
| 3 | +**Révision (2026-09-22)** : l'import ne pousse plus vers l'archive NAS lui-même. | |
| 4 | +Il se termine par une copie locale vérifiée, classée par format (`raw/`, `jpeg/`, | |
| 5 | +`tiff/`..., cf. `classement.py`), que le photographe peut trier/retoucher avant de | |
| 6 | +décider de l'archiver — l'archivage vers le NAS devient une étape distincte et | |
| 7 | +explicite, via `regine_core.archive.reconciliation` (specs/005-checkout- | |
| 8 | +reconciliation), déjà conçue pour ce cas : un dossier jamais encore archivé produit | |
| 9 | +un manifeste vide au premier `regine reconcile`, donc chaque fichier local y est | |
| 10 | +naturellement classé "nouveau" et archivable sur confirmation explicite, sans | |
| 11 | +code supplémentaire à construire ici (Principe VI, pas de logique dupliquée). | |
| 12 | +Ancien comportement (archivage immédiat en fin d'import) : cf. historique git. | |
| 9 | 13 | """ |
| 10 | 14 | |
| 11 | 15 | from __future__ import annotations |
| @@ -14,25 +18,24 @@ import shutil | ||
| 14 | 18 | from dataclasses import dataclass |
| 15 | 19 | from pathlib import Path |
| 16 | 20 | |
| 17 | -from regine_core.archive.manifest import ouvrir_ou_creer | |
| 18 | -from regine_core.archive.verrou import poser, verifier | |
| 19 | -from regine_core.integrity.hash import empreinte, hash_fichier_entier | |
| 21 | +from regine_core.integrity.hash import hash_fichier_entier | |
| 20 | 22 | |
| 21 | 23 | |
| 22 | 24 | class EchecTransfertError(Exception): |
| 23 | - """La vérification d'intégrité d'un transfert final vers l'archive a échoué (FR-019).""" | |
| 25 | + """La vérification d'intégrité d'un transfert final a échoué (FR-019).""" | |
| 24 | 26 | |
| 25 | 27 | |
| 26 | -class CollisionNomArchiveError(Exception): | |
| 28 | +class CollisionNomLocalError(Exception): | |
| 27 | 29 | """Deux fichiers de contenu différent aboutissent au même nom final dans le |
| 28 | - dossier de destination (FR-012, US3 scénario 3) — jamais d'écrasement silencieux, | |
| 29 | - même lors d'une fusion entre deux imports séparés (ex. deux boîtiers sur la même | |
| 30 | - étape d'un voyage, importés carte par carte plutôt qu'en une seule session).""" | |
| 30 | + dossier de destination locale (FR-012, US3 scénario 3) — jamais d'écrasement | |
| 31 | + silencieux, même lors d'une fusion entre deux imports séparés (ex. deux | |
| 32 | + boîtiers sur la même étape d'un voyage, importés carte par carte plutôt qu'en | |
| 33 | + une seule session).""" | |
| 31 | 34 | |
| 32 | 35 | |
| 33 | 36 | @dataclass(frozen=True) |
| 34 | 37 | class ResumeConfirmation: |
| 35 | - """Résumé présenté à l'utilisateur avant toute écriture sur l'archive (FR-018).""" | |
| 38 | + """Résumé présenté à l'utilisateur avant toute écriture locale (FR-018).""" | |
| 36 | 39 | |
| 37 | 40 | nombre_fichiers: int |
| 38 | 41 | taille_totale: int |
| @@ -41,7 +44,7 @@ class ResumeConfirmation: | ||
| 41 | 44 | |
| 42 | 45 | def preparer_resume(fichiers: list[Path], dossier_destination: Path) -> ResumeConfirmation: |
| 43 | 46 | """Construit le résumé de confirmation (FR-018) : nombre de fichiers, taille, |
| 44 | - dossier de destination (y compris son répertoire racine, cf. specs/004).""" | |
| 47 | + dossier de destination locale (y compris son répertoire racine, cf. specs/004).""" | |
| 45 | 48 | taille_totale = sum(f.stat().st_size for f in fichiers) |
| 46 | 49 | return ResumeConfirmation( |
| 47 | 50 | nombre_fichiers=len(fichiers), |
| @@ -56,7 +59,7 @@ def _copier_verifie_sans_ecraser(source: Path, destination: Path) -> bool: | ||
| 56 | 59 | Ne DOIT jamais écraser un fichier déjà présent de contenu différent |
| 57 | 60 | (FR-012 appliqué au niveau fichier) : un doublon de contenu identique est |
| 58 | 61 | ignoré silencieusement (no-op, retourne `False`), un contenu différent |
| 59 | - lève `CollisionNomArchiveError` plutôt que d'écraser. | |
| 62 | + lève `CollisionNomLocalError` plutôt que d'écraser. | |
| 60 | 63 | """ |
| 61 | 64 | destination.parent.mkdir(parents=True, exist_ok=True) |
| 62 | 65 | hash_source = hash_fichier_entier(source) |
| @@ -64,7 +67,7 @@ def _copier_verifie_sans_ecraser(source: Path, destination: Path) -> bool: | ||
| 64 | 67 | if destination.exists(): |
| 65 | 68 | if hash_fichier_entier(destination) == hash_source: |
| 66 | 69 | return False # doublon déjà présent (US3 scénario 3) — rien à refaire |
| 67 | - raise CollisionNomArchiveError( | |
| 70 | + raise CollisionNomLocalError( | |
| 68 | 71 | f"{destination} existe déjà avec un contenu différent — refus d'écraser " |
| 69 | 72 | "silencieusement (probablement deux fichiers de boîtiers différents " |
| 70 | 73 | "importés séparément aboutissant au même nom final)" |
| @@ -76,65 +79,21 @@ def _copier_verifie_sans_ecraser(source: Path, destination: Path) -> bool: | ||
| 76 | 79 | return True |
| 77 | 80 | |
| 78 | 81 | |
| 79 | -def archiver( | |
| 82 | +def publier_localement( | |
| 80 | 83 | fichiers_locaux: list[Path], |
| 81 | 84 | dossier_local_racine: Path, |
| 82 | 85 | dossier_destination: Path, |
| 83 | 86 | ) -> None: |
| 84 | - """Transfert final vérifié depuis la copie de travail locale déjà renommée (FR-019). | |
| 87 | + """Transfert final vérifié depuis la copie de travail temporaire (déjà renommée | |
| 88 | + et classée par format) vers le dossier de travail local définitif (FR-019). | |
| 85 | 89 | |
| 86 | 90 | Ne DOIT être appelée qu'après confirmation explicite du résumé (FR-018) — |
| 87 | 91 | cette fonction elle-même ne demande pas confirmation, c'est la responsabilité |
| 88 | - de l'appelant (façade CLI). | |
| 92 | + de l'appelant (façade CLI/GUI). N'écrit rien sur l'archive NAS ni sur aucun | |
| 93 | + manifeste : cette copie reste purement locale, à archiver plus tard via | |
| 94 | + `regine reconcile` une fois le photographe prêt (cf. docstring de module). | |
| 89 | 95 | """ |
| 90 | 96 | dossier_destination.mkdir(parents=True, exist_ok=True) |
| 91 | 97 | for source in fichiers_locaux: |
| 92 | 98 | relatif = source.relative_to(dossier_local_racine) |
| 93 | 99 | _copier_verifie_sans_ecraser(source, dossier_destination / relatif) |
| 94 | - | |
| 95 | - | |
| 96 | -def publier_copie_locale( | |
| 97 | - fichiers_locaux: list[Path], | |
| 98 | - dossier_local_racine: Path, | |
| 99 | - dossier_local: Path, | |
| 100 | - dossier_archive: Path, | |
| 101 | -) -> None: | |
| 102 | - """Laisse une copie de travail locale des fichiers qui viennent d'être archivés, | |
| 103 | - et enregistre leur empreinte dans le manifeste persistant du dossier (FR-010). | |
| 104 | - | |
| 105 | - Ne DOIT être appelée qu'après un `archiver()` réussi vers `dossier_archive` — les | |
| 106 | - deux copies (archive et locale) sont alors identiques par construction. Ne copie | |
| 107 | - que les fichiers de cet import : contrairement à un checkout complet | |
| 108 | - (`regine_core.archive.checkout.checkout`), n'écrase jamais un fichier local déjà | |
| 109 | - présent (édition en cours d'un import précédent non encore réconcilié). Pose le | |
| 110 | - verrou du dossier s'il ne l'est pas déjà (cas d'une fusion ayant déclenché un | |
| 111 | - checkout juste avant, cf. `destination.resoudre_fusion`) — jamais un second | |
| 112 | - verrou. Laisse volontairement le dossier verrouillé : l'utilisateur travaille | |
| 113 | - maintenant sur une copie locale, exactement comme après un checkout explicite, et | |
| 114 | - devra passer par `regine_core.archive.reconciliation` pour le libérer. | |
| 115 | - """ | |
| 116 | - manifest = ouvrir_ou_creer(dossier_archive) | |
| 117 | - try: | |
| 118 | - if not verifier(manifest): | |
| 119 | - poser(manifest) | |
| 120 | - | |
| 121 | - for source in fichiers_locaux: | |
| 122 | - relatif = source.relative_to(dossier_local_racine) | |
| 123 | - _copier_verifie_sans_ecraser(source, dossier_local / relatif) | |
| 124 | - | |
| 125 | - emp = empreinte(source) | |
| 126 | - manifest.conn.execute( | |
| 127 | - "INSERT OR REPLACE INTO fichiers " | |
| 128 | - "(chemin_relatif, taille, hash_fichier_entier, hash_image_only, " | |
| 129 | - "identifiant_perenne) VALUES (?, ?, ?, ?, ?)", | |
| 130 | - ( | |
| 131 | - str(relatif), | |
| 132 | - source.stat().st_size, | |
| 133 | - emp.hash_fichier_entier, | |
| 134 | - emp.hash_image_only, | |
| 135 | - None, | |
| 136 | - ), | |
| 137 | - ) | |
| 138 | - manifest.conn.commit() | |
| 139 | - finally: | |
| 140 | - manifest.conn.close() | |
| @@ -1,11 +1,15 @@ | |||
| 1 | -"""Résumé de confirmation et transfert final vers l'archive (FR-018/019). | 1 | +"""Résumé de confirmation et publication finale dans l'espace de travail local (FR-018). |
| 2 | - | 2 | + |
| 3 | -Laisse aussi une copie de travail locale structurée de façon identique à | 3 | +**Révision (2026-09-22)** : l'import ne pousse plus vers l'archive NAS lui-même. |
| 4 | -l'archive (FR-010 : « de façon identique dans la copie de travail locale et | 4 | +Il se termine par une copie locale vérifiée, classée par format (`raw/`, `jpeg/`, |
| 5 | -dans l'archive ») pour permettre de continuer à trier/retoucher immédiatement | 5 | +`tiff/`..., cf. `classement.py`), que le photographe peut trier/retoucher avant de |
| 6 | -après l'import, sans checkout séparé — en réutilisant le manifeste persistant | 6 | +décider de l'archiver — l'archivage vers le NAS devient une étape distincte et |
| 7 | -et le verrouillage de `regine_core.archive` (`specs/005-checkout-reconciliation`) | 7 | +explicite, via `regine_core.archive.reconciliation` (specs/005-checkout- |
| 8 | -plutôt qu'en inventant un mécanisme parallèle (Principe VI). | 8 | +reconciliation), déjà conçue pour ce cas : un dossier jamais encore archivé produit |
| 9 | +un manifeste vide au premier `regine reconcile`, donc chaque fichier local y est | ||
| 10 | +naturellement classé "nouveau" et archivable sur confirmation explicite, sans | ||
| 11 | +code supplémentaire à construire ici (Principe VI, pas de logique dupliquée). | ||
| 12 | +Ancien comportement (archivage immédiat en fin d'import) : cf. historique git. | ||
| 9 | """ | 13 | """ |
| 10 | 14 | ||
| 11 | from __future__ import annotations | 15 | from __future__ import annotations |
| @@ -14,25 +18,24 @@ import shutil | |||
| 14 | from dataclasses import dataclass | 18 | from dataclasses import dataclass |
| 15 | from pathlib import Path | 19 | from pathlib import Path |
| 16 | 20 | ||
| 17 | -from regine_core.archive.manifest import ouvrir_ou_creer | 21 | +from regine_core.integrity.hash import hash_fichier_entier |
| 18 | -from regine_core.archive.verrou import poser, verifier | ||
| 19 | -from regine_core.integrity.hash import empreinte, hash_fichier_entier | ||
| 20 | 22 | ||
| 21 | 23 | ||
| 22 | class EchecTransfertError(Exception): | 24 | class EchecTransfertError(Exception): |
| 23 | - """La vérification d'intégrité d'un transfert final vers l'archive a échoué (FR-019).""" | 25 | + """La vérification d'intégrité d'un transfert final a échoué (FR-019).""" |
| 24 | 26 | ||
| 25 | 27 | ||
| 26 | -class CollisionNomArchiveError(Exception): | 28 | +class CollisionNomLocalError(Exception): |
| 27 | """Deux fichiers de contenu différent aboutissent au même nom final dans le | 29 | """Deux fichiers de contenu différent aboutissent au même nom final dans le |
| 28 | - dossier de destination (FR-012, US3 scénario 3) — jamais d'écrasement silencieux, | 30 | + dossier de destination locale (FR-012, US3 scénario 3) — jamais d'écrasement |
| 29 | - même lors d'une fusion entre deux imports séparés (ex. deux boîtiers sur la même | 31 | + silencieux, même lors d'une fusion entre deux imports séparés (ex. deux |
| 30 | - étape d'un voyage, importés carte par carte plutôt qu'en une seule session).""" | 32 | + boîtiers sur la même étape d'un voyage, importés carte par carte plutôt qu'en |
| 33 | + une seule session).""" | ||
| 31 | 34 | ||
| 32 | 35 | ||
| 33 | @dataclass(frozen=True) | 36 | @dataclass(frozen=True) |
| 34 | class ResumeConfirmation: | 37 | class ResumeConfirmation: |
| 35 | - """Résumé présenté à l'utilisateur avant toute écriture sur l'archive (FR-018).""" | 38 | + """Résumé présenté à l'utilisateur avant toute écriture locale (FR-018).""" |
| 36 | 39 | ||
| 37 | nombre_fichiers: int | 40 | nombre_fichiers: int |
| 38 | taille_totale: int | 41 | taille_totale: int |
| @@ -41,7 +44,7 @@ class ResumeConfirmation: | |||
| 41 | 44 | ||
| 42 | def preparer_resume(fichiers: list[Path], dossier_destination: Path) -> ResumeConfirmation: | 45 | def preparer_resume(fichiers: list[Path], dossier_destination: Path) -> ResumeConfirmation: |
| 43 | """Construit le résumé de confirmation (FR-018) : nombre de fichiers, taille, | 46 | """Construit le résumé de confirmation (FR-018) : nombre de fichiers, taille, |
| 44 | - dossier de destination (y compris son répertoire racine, cf. specs/004).""" | 47 | + dossier de destination locale (y compris son répertoire racine, cf. specs/004).""" |
| 45 | taille_totale = sum(f.stat().st_size for f in fichiers) | 48 | taille_totale = sum(f.stat().st_size for f in fichiers) |
| 46 | return ResumeConfirmation( | 49 | return ResumeConfirmation( |
| 47 | nombre_fichiers=len(fichiers), | 50 | nombre_fichiers=len(fichiers), |
| @@ -56,7 +59,7 @@ def _copier_verifie_sans_ecraser(source: Path, destination: Path) -> bool: | |||
| 56 | Ne DOIT jamais écraser un fichier déjà présent de contenu différent | 59 | Ne DOIT jamais écraser un fichier déjà présent de contenu différent |
| 57 | (FR-012 appliqué au niveau fichier) : un doublon de contenu identique est | 60 | (FR-012 appliqué au niveau fichier) : un doublon de contenu identique est |
| 58 | ignoré silencieusement (no-op, retourne `False`), un contenu différent | 61 | ignoré silencieusement (no-op, retourne `False`), un contenu différent |
| 59 | - lève `CollisionNomArchiveError` plutôt que d'écraser. | 62 | + lève `CollisionNomLocalError` plutôt que d'écraser. |
| 60 | """ | 63 | """ |
| 61 | destination.parent.mkdir(parents=True, exist_ok=True) | 64 | destination.parent.mkdir(parents=True, exist_ok=True) |
| 62 | hash_source = hash_fichier_entier(source) | 65 | hash_source = hash_fichier_entier(source) |
| @@ -64,7 +67,7 @@ def _copier_verifie_sans_ecraser(source: Path, destination: Path) -> bool: | |||
| 64 | if destination.exists(): | 67 | if destination.exists(): |
| 65 | if hash_fichier_entier(destination) == hash_source: | 68 | if hash_fichier_entier(destination) == hash_source: |
| 66 | return False # doublon déjà présent (US3 scénario 3) — rien à refaire | 69 | return False # doublon déjà présent (US3 scénario 3) — rien à refaire |
| 67 | - raise CollisionNomArchiveError( | 70 | + raise CollisionNomLocalError( |
| 68 | f"{destination} existe déjà avec un contenu différent — refus d'écraser " | 71 | f"{destination} existe déjà avec un contenu différent — refus d'écraser " |
| 69 | "silencieusement (probablement deux fichiers de boîtiers différents " | 72 | "silencieusement (probablement deux fichiers de boîtiers différents " |
| 70 | "importés séparément aboutissant au même nom final)" | 73 | "importés séparément aboutissant au même nom final)" |
| @@ -76,65 +79,21 @@ def _copier_verifie_sans_ecraser(source: Path, destination: Path) -> bool: | |||
| 76 | return True | 79 | return True |
| 77 | 80 | ||
| 78 | 81 | ||
| 79 | -def archiver( | 82 | +def publier_localement( |
| 80 | fichiers_locaux: list[Path], | 83 | fichiers_locaux: list[Path], |
| 81 | dossier_local_racine: Path, | 84 | dossier_local_racine: Path, |
| 82 | dossier_destination: Path, | 85 | dossier_destination: Path, |
| 83 | ) -> None: | 86 | ) -> None: |
| 84 | - """Transfert final vérifié depuis la copie de travail locale déjà renommée (FR-019). | 87 | + """Transfert final vérifié depuis la copie de travail temporaire (déjà renommée |
| 88 | + et classée par format) vers le dossier de travail local définitif (FR-019). | ||
| 85 | 89 | ||
| 86 | Ne DOIT être appelée qu'après confirmation explicite du résumé (FR-018) — | 90 | Ne DOIT être appelée qu'après confirmation explicite du résumé (FR-018) — |
| 87 | cette fonction elle-même ne demande pas confirmation, c'est la responsabilité | 91 | cette fonction elle-même ne demande pas confirmation, c'est la responsabilité |
| 88 | - de l'appelant (façade CLI). | 92 | + de l'appelant (façade CLI/GUI). N'écrit rien sur l'archive NAS ni sur aucun |
| 93 | + manifeste : cette copie reste purement locale, à archiver plus tard via | ||
| 94 | + `regine reconcile` une fois le photographe prêt (cf. docstring de module). | ||
| 89 | """ | 95 | """ |
| 90 | dossier_destination.mkdir(parents=True, exist_ok=True) | 96 | dossier_destination.mkdir(parents=True, exist_ok=True) |
| 91 | for source in fichiers_locaux: | 97 | for source in fichiers_locaux: |
| 92 | relatif = source.relative_to(dossier_local_racine) | 98 | relatif = source.relative_to(dossier_local_racine) |
| 93 | _copier_verifie_sans_ecraser(source, dossier_destination / relatif) | 99 | _copier_verifie_sans_ecraser(source, dossier_destination / relatif) |
| 94 | - | ||
| 95 | - | ||
| 96 | -def publier_copie_locale( | ||
| 97 | - fichiers_locaux: list[Path], | ||
| 98 | - dossier_local_racine: Path, | ||
| 99 | - dossier_local: Path, | ||
| 100 | - dossier_archive: Path, | ||
| 101 | -) -> None: | ||
| 102 | - """Laisse une copie de travail locale des fichiers qui viennent d'être archivés, | ||
| 103 | - et enregistre leur empreinte dans le manifeste persistant du dossier (FR-010). | ||
| 104 | - | ||
| 105 | - Ne DOIT être appelée qu'après un `archiver()` réussi vers `dossier_archive` — les | ||
| 106 | - deux copies (archive et locale) sont alors identiques par construction. Ne copie | ||
| 107 | - que les fichiers de cet import : contrairement à un checkout complet | ||
| 108 | - (`regine_core.archive.checkout.checkout`), n'écrase jamais un fichier local déjà | ||
| 109 | - présent (édition en cours d'un import précédent non encore réconcilié). Pose le | ||
| 110 | - verrou du dossier s'il ne l'est pas déjà (cas d'une fusion ayant déclenché un | ||
| 111 | - checkout juste avant, cf. `destination.resoudre_fusion`) — jamais un second | ||
| 112 | - verrou. Laisse volontairement le dossier verrouillé : l'utilisateur travaille | ||
| 113 | - maintenant sur une copie locale, exactement comme après un checkout explicite, et | ||
| 114 | - devra passer par `regine_core.archive.reconciliation` pour le libérer. | ||
| 115 | - """ | ||
| 116 | - manifest = ouvrir_ou_creer(dossier_archive) | ||
| 117 | - try: | ||
| 118 | - if not verifier(manifest): | ||
| 119 | - poser(manifest) | ||
| 120 | - | ||
| 121 | - for source in fichiers_locaux: | ||
| 122 | - relatif = source.relative_to(dossier_local_racine) | ||
| 123 | - _copier_verifie_sans_ecraser(source, dossier_local / relatif) | ||
| 124 | - | ||
| 125 | - emp = empreinte(source) | ||
| 126 | - manifest.conn.execute( | ||
| 127 | - "INSERT OR REPLACE INTO fichiers " | ||
| 128 | - "(chemin_relatif, taille, hash_fichier_entier, hash_image_only, " | ||
| 129 | - "identifiant_perenne) VALUES (?, ?, ?, ?, ?)", | ||
| 130 | - ( | ||
| 131 | - str(relatif), | ||
| 132 | - source.stat().st_size, | ||
| 133 | - emp.hash_fichier_entier, | ||
| 134 | - emp.hash_image_only, | ||
| 135 | - None, | ||
| 136 | - ), | ||
| 137 | - ) | ||
| 138 | - manifest.conn.commit() | ||
| 139 | - finally: | ||
| 140 | - manifest.conn.close() | ||
modified
packages/regine-core/tests/integration/test_pipeline_import_simple.py +20 -9 | @@ -8,12 +8,13 @@ import subprocess | ||
| 8 | 8 | from pathlib import Path |
| 9 | 9 | |
| 10 | 10 | import pytest |
| 11 | +from regine_core.import_carte.classement import classer_par_format | |
| 11 | 12 | from regine_core.import_carte.copie import copier_carte |
| 12 | 13 | from regine_core.import_carte.destination import resoudre_destination |
| 13 | 14 | from regine_core.import_carte.groupage import decouper_en_groupes |
| 14 | 15 | from regine_core.import_carte.identifiant import attribuer_identifiants |
| 15 | 16 | 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.import_carte.push import preparer_resume, publier_localement | |
| 17 | 18 | from regine_core.metadata.exif import close_session, read_document_id |
| 18 | 19 | |
| 19 | 20 | _JPEG_1X1_BASE64 = ( |
| @@ -73,23 +74,33 @@ def test_full_pipeline_card_to_archived_folder(tmp_path: Path) -> None: | ||
| 73 | 74 | renommer_fichiers(groupe.fichiers, groupe.plage_dates[0], groupe.titre) |
| 74 | 75 | assert groupe.fichiers[0].chemin_source.name == "2026-08-15_Sortie_parc_RD0001.JPG" |
| 75 | 76 | |
| 76 | - # 5. Identifiant pérenne (FR-017). | |
| 77 | + # 5. Classement par format (FR-020) : un JPEG rejoint jpeg/ dans la copie | |
| 78 | + # temporaire, avant toute publication locale. | |
| 79 | + classer_par_format(groupe.fichiers, local_tmp) | |
| 80 | + nom_renomme = "2026-08-15_Sortie_parc_RD0001.JPG" | |
| 81 | + assert groupe.fichiers[0].chemin_source == local_tmp / "jpeg" / nom_renomme | |
| 82 | + | |
| 83 | + # 6. Identifiant pérenne (FR-017), sur le chemin déjà classé. | |
| 77 | 84 | chemins_maitres = [f.chemin_source for f in groupe.fichiers if f.type == "maitre"] |
| 78 | 85 | identifiants = attribuer_identifiants(chemins_maitres) |
| 79 | 86 | assert read_document_id(chemins_maitres[0]) == identifiants[chemins_maitres[0]] |
| 80 | 87 | |
| 81 | - # 6. Résumé + confirmation + archivage (FR-018/019). | |
| 82 | - dossier_final = destination.root_location.chemin_archive / nom_dossier | |
| 88 | + # 7. Résumé + confirmation + publication locale (FR-018/019) — plus d'écriture | |
| 89 | + # sur l'archive NAS ici : ce dossier reste local, à archiver plus tard via | |
| 90 | + # `regine reconcile` une fois le photographe prêt (cf. push.py). | |
| 91 | + dossier_final = destination.root_location.chemin_local / nom_dossier | |
| 83 | 92 | resume = preparer_resume([f.chemin_source for f in groupe.fichiers], dossier_final) |
| 84 | 93 | assert resume.nombre_fichiers == 1 |
| 85 | 94 | assert resume.dossier_destination == dossier_final |
| 86 | 95 | |
| 87 | - # Rien n'est écrit avant l'appel explicite à archiver() (FR-018). | |
| 96 | + # Rien n'est écrit avant l'appel explicite à publier_localement() (FR-018). | |
| 88 | 97 | assert not dossier_final.exists() |
| 89 | 98 | |
| 90 | - archiver([f.chemin_source for f in groupe.fichiers], local_tmp, dossier_final) | |
| 99 | + publier_localement([f.chemin_source for f in groupe.fichiers], local_tmp, dossier_final) | |
| 91 | 100 | |
| 92 | 101 | 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() | |
| 102 | + fichier_local = dossier_final / "jpeg" / nom_final | |
| 103 | + assert fichier_local.exists() | |
| 104 | + assert fichier_local.read_bytes() == (local_tmp / "jpeg" / nom_final).read_bytes() | |
| 105 | + # L'archive NAS elle-même reste totalement intacte : import = copie locale uniquement. | |
| 106 | + assert not destination.root_location.chemin_archive.exists() | |
| @@ -8,12 +8,13 @@ import subprocess | |||
| 8 | from pathlib import Path | 8 | from pathlib import Path |
| 9 | 9 | ||
| 10 | import pytest | 10 | import pytest |
| 11 | +from regine_core.import_carte.classement import classer_par_format | ||
| 11 | from regine_core.import_carte.copie import copier_carte | 12 | 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.destination import resoudre_destination |
| 13 | from regine_core.import_carte.groupage import decouper_en_groupes | 14 | 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.identifiant import attribuer_identifiants |
| 15 | from regine_core.import_carte.nommage import construire_nom_dossier, renommer_fichiers | 16 | 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.import_carte.push import preparer_resume, publier_localement |
| 17 | from regine_core.metadata.exif import close_session, read_document_id | 18 | from regine_core.metadata.exif import close_session, read_document_id |
| 18 | 19 | ||
| 19 | _JPEG_1X1_BASE64 = ( | 20 | _JPEG_1X1_BASE64 = ( |
| @@ -73,23 +74,33 @@ def test_full_pipeline_card_to_archived_folder(tmp_path: Path) -> None: | |||
| 73 | renommer_fichiers(groupe.fichiers, groupe.plage_dates[0], groupe.titre) | 74 | 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 | assert groupe.fichiers[0].chemin_source.name == "2026-08-15_Sortie_parc_RD0001.JPG" |
| 75 | 76 | ||
| 76 | - # 5. Identifiant pérenne (FR-017). | 77 | + # 5. Classement par format (FR-020) : un JPEG rejoint jpeg/ dans la copie |
| 78 | + # temporaire, avant toute publication locale. | ||
| 79 | + classer_par_format(groupe.fichiers, local_tmp) | ||
| 80 | + nom_renomme = "2026-08-15_Sortie_parc_RD0001.JPG" | ||
| 81 | + assert groupe.fichiers[0].chemin_source == local_tmp / "jpeg" / nom_renomme | ||
| 82 | + | ||
| 83 | + # 6. Identifiant pérenne (FR-017), sur le chemin déjà classé. | ||
| 77 | chemins_maitres = [f.chemin_source for f in groupe.fichiers if f.type == "maitre"] | 84 | chemins_maitres = [f.chemin_source for f in groupe.fichiers if f.type == "maitre"] |
| 78 | identifiants = attribuer_identifiants(chemins_maitres) | 85 | identifiants = attribuer_identifiants(chemins_maitres) |
| 79 | assert read_document_id(chemins_maitres[0]) == identifiants[chemins_maitres[0]] | 86 | assert read_document_id(chemins_maitres[0]) == identifiants[chemins_maitres[0]] |
| 80 | 87 | ||
| 81 | - # 6. Résumé + confirmation + archivage (FR-018/019). | 88 | + # 7. Résumé + confirmation + publication locale (FR-018/019) — plus d'écriture |
| 82 | - dossier_final = destination.root_location.chemin_archive / nom_dossier | 89 | + # sur l'archive NAS ici : ce dossier reste local, à archiver plus tard via |
| 90 | + # `regine reconcile` une fois le photographe prêt (cf. push.py). | ||
| 91 | + dossier_final = destination.root_location.chemin_local / nom_dossier | ||
| 83 | resume = preparer_resume([f.chemin_source for f in groupe.fichiers], dossier_final) | 92 | resume = preparer_resume([f.chemin_source for f in groupe.fichiers], dossier_final) |
| 84 | assert resume.nombre_fichiers == 1 | 93 | assert resume.nombre_fichiers == 1 |
| 85 | assert resume.dossier_destination == dossier_final | 94 | assert resume.dossier_destination == dossier_final |
| 86 | 95 | ||
| 87 | - # Rien n'est écrit avant l'appel explicite à archiver() (FR-018). | 96 | + # Rien n'est écrit avant l'appel explicite à publier_localement() (FR-018). |
| 88 | assert not dossier_final.exists() | 97 | assert not dossier_final.exists() |
| 89 | 98 | ||
| 90 | - archiver([f.chemin_source for f in groupe.fichiers], local_tmp, dossier_final) | 99 | + publier_localement([f.chemin_source for f in groupe.fichiers], local_tmp, dossier_final) |
| 91 | 100 | ||
| 92 | nom_final = "2026-08-15_Sortie_parc_RD0001.JPG" | 101 | nom_final = "2026-08-15_Sortie_parc_RD0001.JPG" |
| 93 | - fichier_archive = dossier_final / nom_final | 102 | + fichier_local = dossier_final / "jpeg" / nom_final |
| 94 | - assert fichier_archive.exists() | 103 | + assert fichier_local.exists() |
| 95 | - assert fichier_archive.read_bytes() == (local_tmp / nom_final).read_bytes() | 104 | + assert fichier_local.read_bytes() == (local_tmp / "jpeg" / nom_final).read_bytes() |
| 105 | + # L'archive NAS elle-même reste totalement intacte : import = copie locale uniquement. | ||
| 106 | + assert not destination.root_location.chemin_archive.exists() | ||
added
packages/regine-core/tests/unit/test_classement.py +85 -0 | new file mode 100644 | ||
| @@ -0,0 +1,85 @@ | ||
| 1 | +"""Tests de `regine_core.import_carte.classement.classer_par_format` (FR-020).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from pathlib import Path | |
| 6 | + | |
| 7 | +from regine_core.import_carte.classement import classer_par_format | |
| 8 | +from regine_core.import_carte.types import FichierCandidat | |
| 9 | + | |
| 10 | + | |
| 11 | +def _fichier(chemin: Path, *, type_: str = "maitre") -> FichierCandidat: | |
| 12 | + return FichierCandidat(chemin_source=chemin, checksum="x", type=type_) | |
| 13 | + | |
| 14 | + | |
| 15 | +def test_classer_par_format_deplace_raw_et_jpeg_dans_leurs_dossiers(tmp_path: Path) -> None: | |
| 16 | + raw = tmp_path / "2026-08-15_Sortie_RD0001.RAF" | |
| 17 | + raw.write_bytes(b"raw") | |
| 18 | + jpeg = tmp_path / "2026-08-15_Sortie_RD0002.JPG" | |
| 19 | + jpeg.write_bytes(b"jpeg") | |
| 20 | + fichiers = [_fichier(raw), _fichier(jpeg)] | |
| 21 | + | |
| 22 | + classer_par_format(fichiers, tmp_path) | |
| 23 | + | |
| 24 | + assert (tmp_path / "raw" / "2026-08-15_Sortie_RD0001.RAF").exists() | |
| 25 | + assert (tmp_path / "jpeg" / "2026-08-15_Sortie_RD0002.JPG").exists() | |
| 26 | + assert fichiers[0].chemin_source == tmp_path / "raw" / "2026-08-15_Sortie_RD0001.RAF" | |
| 27 | + assert fichiers[1].chemin_source == tmp_path / "jpeg" / "2026-08-15_Sortie_RD0002.JPG" | |
| 28 | + | |
| 29 | + | |
| 30 | +def test_classer_par_format_scan_tiff_obtient_son_propre_dossier(tmp_path: Path) -> None: | |
| 31 | + tiff = tmp_path / "2026-08-15_Sortie_scan0001.TIF" | |
| 32 | + tiff.write_bytes(b"tiff") | |
| 33 | + | |
| 34 | + classer_par_format([_fichier(tiff)], tmp_path) | |
| 35 | + | |
| 36 | + assert (tmp_path / "tiff" / "2026-08-15_Sortie_scan0001.TIF").exists() | |
| 37 | + | |
| 38 | + | |
| 39 | +def test_classer_par_format_sidecar_rejoint_le_dossier_de_son_maitre(tmp_path: Path) -> None: | |
| 40 | + raw = tmp_path / "2026-08-15_Sortie_RD0001.RAF" | |
| 41 | + raw.write_bytes(b"raw") | |
| 42 | + sidecar = tmp_path / "2026-08-15_Sortie_RD0001.xmp" | |
| 43 | + sidecar.write_bytes(b"xmp") | |
| 44 | + fichiers = [_fichier(raw), _fichier(sidecar, type_="associe")] | |
| 45 | + | |
| 46 | + classer_par_format(fichiers, tmp_path) | |
| 47 | + | |
| 48 | + assert (tmp_path / "raw" / "2026-08-15_Sortie_RD0001.RAF").exists() | |
| 49 | + assert (tmp_path / "raw" / "2026-08-15_Sortie_RD0001.xmp").exists() | |
| 50 | + | |
| 51 | + | |
| 52 | +def test_classer_par_format_raw_et_jpeg_jumeaux_vivent_dans_des_dossiers_differents( | |
| 53 | + tmp_path: Path, | |
| 54 | +) -> None: | |
| 55 | + """Même nom de base, extensions différentes : chacun dans son propre dossier de | |
| 56 | + format (pas de dossier partagé), l'appariement restant purement par nom (specs/006).""" | |
| 57 | + raw = tmp_path / "2026-08-15_Sortie_RD0001.RAF" | |
| 58 | + raw.write_bytes(b"raw") | |
| 59 | + jpeg = tmp_path / "2026-08-15_Sortie_RD0001.JPG" | |
| 60 | + jpeg.write_bytes(b"jpeg") | |
| 61 | + | |
| 62 | + classer_par_format([_fichier(raw), _fichier(jpeg)], tmp_path) | |
| 63 | + | |
| 64 | + assert (tmp_path / "raw" / "2026-08-15_Sortie_RD0001.RAF").exists() | |
| 65 | + assert (tmp_path / "jpeg" / "2026-08-15_Sortie_RD0001.JPG").exists() | |
| 66 | + | |
| 67 | + | |
| 68 | +def test_classer_par_format_sidecar_d_une_paire_jumelle_suit_le_raw(tmp_path: Path) -> None: | |
| 69 | + """Une paire RAW+JPEG jumelle avec un sidecar de réglages : le sidecar doit | |
| 70 | + rejoindre raw/ (le RAW porte les réglages de développement), jamais jpeg/, | |
| 71 | + même s'il partage son nom de base avec les deux maîtres.""" | |
| 72 | + raw = tmp_path / "2026-08-15_Sortie_RD0001.RAF" | |
| 73 | + raw.write_bytes(b"raw") | |
| 74 | + jpeg = tmp_path / "2026-08-15_Sortie_RD0001.JPG" | |
| 75 | + jpeg.write_bytes(b"jpeg") | |
| 76 | + sidecar = tmp_path / "2026-08-15_Sortie_RD0001.xmp" | |
| 77 | + sidecar.write_bytes(b"xmp") | |
| 78 | + | |
| 79 | + classer_par_format( | |
| 80 | + [_fichier(raw), _fichier(jpeg), _fichier(sidecar, type_="associe")], tmp_path | |
| 81 | + ) | |
| 82 | + | |
| 83 | + assert (tmp_path / "raw" / "2026-08-15_Sortie_RD0001.RAF").exists() | |
| 84 | + assert (tmp_path / "jpeg" / "2026-08-15_Sortie_RD0001.JPG").exists() | |
| 85 | + assert (tmp_path / "raw" / "2026-08-15_Sortie_RD0001.xmp").exists() | |
| new file mode 100644 | |||
| @@ -0,0 +1,85 @@ | |||
| 1 | +"""Tests de `regine_core.import_carte.classement.classer_par_format` (FR-020).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from pathlib import Path | ||
| 6 | + | ||
| 7 | +from regine_core.import_carte.classement import classer_par_format | ||
| 8 | +from regine_core.import_carte.types import FichierCandidat | ||
| 9 | + | ||
| 10 | + | ||
| 11 | +def _fichier(chemin: Path, *, type_: str = "maitre") -> FichierCandidat: | ||
| 12 | + return FichierCandidat(chemin_source=chemin, checksum="x", type=type_) | ||
| 13 | + | ||
| 14 | + | ||
| 15 | +def test_classer_par_format_deplace_raw_et_jpeg_dans_leurs_dossiers(tmp_path: Path) -> None: | ||
| 16 | + raw = tmp_path / "2026-08-15_Sortie_RD0001.RAF" | ||
| 17 | + raw.write_bytes(b"raw") | ||
| 18 | + jpeg = tmp_path / "2026-08-15_Sortie_RD0002.JPG" | ||
| 19 | + jpeg.write_bytes(b"jpeg") | ||
| 20 | + fichiers = [_fichier(raw), _fichier(jpeg)] | ||
| 21 | + | ||
| 22 | + classer_par_format(fichiers, tmp_path) | ||
| 23 | + | ||
| 24 | + assert (tmp_path / "raw" / "2026-08-15_Sortie_RD0001.RAF").exists() | ||
| 25 | + assert (tmp_path / "jpeg" / "2026-08-15_Sortie_RD0002.JPG").exists() | ||
| 26 | + assert fichiers[0].chemin_source == tmp_path / "raw" / "2026-08-15_Sortie_RD0001.RAF" | ||
| 27 | + assert fichiers[1].chemin_source == tmp_path / "jpeg" / "2026-08-15_Sortie_RD0002.JPG" | ||
| 28 | + | ||
| 29 | + | ||
| 30 | +def test_classer_par_format_scan_tiff_obtient_son_propre_dossier(tmp_path: Path) -> None: | ||
| 31 | + tiff = tmp_path / "2026-08-15_Sortie_scan0001.TIF" | ||
| 32 | + tiff.write_bytes(b"tiff") | ||
| 33 | + | ||
| 34 | + classer_par_format([_fichier(tiff)], tmp_path) | ||
| 35 | + | ||
| 36 | + assert (tmp_path / "tiff" / "2026-08-15_Sortie_scan0001.TIF").exists() | ||
| 37 | + | ||
| 38 | + | ||
| 39 | +def test_classer_par_format_sidecar_rejoint_le_dossier_de_son_maitre(tmp_path: Path) -> None: | ||
| 40 | + raw = tmp_path / "2026-08-15_Sortie_RD0001.RAF" | ||
| 41 | + raw.write_bytes(b"raw") | ||
| 42 | + sidecar = tmp_path / "2026-08-15_Sortie_RD0001.xmp" | ||
| 43 | + sidecar.write_bytes(b"xmp") | ||
| 44 | + fichiers = [_fichier(raw), _fichier(sidecar, type_="associe")] | ||
| 45 | + | ||
| 46 | + classer_par_format(fichiers, tmp_path) | ||
| 47 | + | ||
| 48 | + assert (tmp_path / "raw" / "2026-08-15_Sortie_RD0001.RAF").exists() | ||
| 49 | + assert (tmp_path / "raw" / "2026-08-15_Sortie_RD0001.xmp").exists() | ||
| 50 | + | ||
| 51 | + | ||
| 52 | +def test_classer_par_format_raw_et_jpeg_jumeaux_vivent_dans_des_dossiers_differents( | ||
| 53 | + tmp_path: Path, | ||
| 54 | +) -> None: | ||
| 55 | + """Même nom de base, extensions différentes : chacun dans son propre dossier de | ||
| 56 | + format (pas de dossier partagé), l'appariement restant purement par nom (specs/006).""" | ||
| 57 | + raw = tmp_path / "2026-08-15_Sortie_RD0001.RAF" | ||
| 58 | + raw.write_bytes(b"raw") | ||
| 59 | + jpeg = tmp_path / "2026-08-15_Sortie_RD0001.JPG" | ||
| 60 | + jpeg.write_bytes(b"jpeg") | ||
| 61 | + | ||
| 62 | + classer_par_format([_fichier(raw), _fichier(jpeg)], tmp_path) | ||
| 63 | + | ||
| 64 | + assert (tmp_path / "raw" / "2026-08-15_Sortie_RD0001.RAF").exists() | ||
| 65 | + assert (tmp_path / "jpeg" / "2026-08-15_Sortie_RD0001.JPG").exists() | ||
| 66 | + | ||
| 67 | + | ||
| 68 | +def test_classer_par_format_sidecar_d_une_paire_jumelle_suit_le_raw(tmp_path: Path) -> None: | ||
| 69 | + """Une paire RAW+JPEG jumelle avec un sidecar de réglages : le sidecar doit | ||
| 70 | + rejoindre raw/ (le RAW porte les réglages de développement), jamais jpeg/, | ||
| 71 | + même s'il partage son nom de base avec les deux maîtres.""" | ||
| 72 | + raw = tmp_path / "2026-08-15_Sortie_RD0001.RAF" | ||
| 73 | + raw.write_bytes(b"raw") | ||
| 74 | + jpeg = tmp_path / "2026-08-15_Sortie_RD0001.JPG" | ||
| 75 | + jpeg.write_bytes(b"jpeg") | ||
| 76 | + sidecar = tmp_path / "2026-08-15_Sortie_RD0001.xmp" | ||
| 77 | + sidecar.write_bytes(b"xmp") | ||
| 78 | + | ||
| 79 | + classer_par_format( | ||
| 80 | + [_fichier(raw), _fichier(jpeg), _fichier(sidecar, type_="associe")], tmp_path | ||
| 81 | + ) | ||
| 82 | + | ||
| 83 | + assert (tmp_path / "raw" / "2026-08-15_Sortie_RD0001.RAF").exists() | ||
| 84 | + assert (tmp_path / "jpeg" / "2026-08-15_Sortie_RD0001.JPG").exists() | ||
| 85 | + assert (tmp_path / "raw" / "2026-08-15_Sortie_RD0001.xmp").exists() | ||
deleted
packages/regine-core/tests/unit/test_push_archiver.py +0 -120 | deleted file mode 100644 | ||
| @@ -1,120 +0,0 @@ | ||
| 1 | -"""Tests de `archiver`/`publier_copie_locale` : jamais d'écrasement silencieux | |
| 2 | -(FR-012, US3 scénario 3), copie de travail locale laissée après import (FR-010).""" | |
| 3 | - | |
| 4 | -from __future__ import annotations | |
| 5 | - | |
| 6 | -from pathlib import Path | |
| 7 | - | |
| 8 | -import pytest | |
| 9 | -from regine_core.archive.manifest import ouvrir_ou_creer | |
| 10 | -from regine_core.archive.verrou import lever, verifier | |
| 11 | -from regine_core.import_carte.push import ( | |
| 12 | - CollisionNomArchiveError, | |
| 13 | - archiver, | |
| 14 | - publier_copie_locale, | |
| 15 | -) | |
| 16 | - | |
| 17 | - | |
| 18 | -def _preparer_source(tmp_path: Path, contenu: bytes) -> tuple[Path, Path]: | |
| 19 | - local_racine = tmp_path / "local" | |
| 20 | - local_racine.mkdir() | |
| 21 | - fichier = local_racine / "2026-08-12_Kotor_RD0001.JPG" | |
| 22 | - fichier.write_bytes(contenu) | |
| 23 | - return local_racine, fichier | |
| 24 | - | |
| 25 | - | |
| 26 | -def test_archiver_copie_normalement_un_nouveau_fichier(tmp_path: Path) -> None: | |
| 27 | - local_racine, fichier = _preparer_source(tmp_path, b"contenu-a") | |
| 28 | - destination = tmp_path / "archive" | |
| 29 | - | |
| 30 | - archiver([fichier], local_racine, destination) | |
| 31 | - | |
| 32 | - assert (destination / fichier.name).read_bytes() == b"contenu-a" | |
| 33 | - | |
| 34 | - | |
| 35 | -def test_archiver_ignore_un_doublon_deja_archive_de_meme_contenu(tmp_path: Path) -> None: | |
| 36 | - local_racine, fichier = _preparer_source(tmp_path, b"contenu-a") | |
| 37 | - destination = tmp_path / "archive" | |
| 38 | - destination.mkdir() | |
| 39 | - (destination / fichier.name).write_bytes(b"contenu-a") | |
| 40 | - | |
| 41 | - archiver([fichier], local_racine, destination) # ne doit pas lever | |
| 42 | - | |
| 43 | - assert (destination / fichier.name).read_bytes() == b"contenu-a" | |
| 44 | - | |
| 45 | - | |
| 46 | -def test_archiver_refuse_ecraser_un_fichier_existant_de_contenu_different(tmp_path: Path) -> None: | |
| 47 | - """Deux boîtiers différents, importés séparément (fusion), produisant le même | |
| 48 | - nom final : ne DOIT jamais écraser silencieusement l'un par l'autre.""" | |
| 49 | - local_racine, fichier = _preparer_source(tmp_path, b"contenu-boitier-b") | |
| 50 | - destination = tmp_path / "archive" | |
| 51 | - destination.mkdir() | |
| 52 | - (destination / fichier.name).write_bytes(b"contenu-boitier-a-deja-archive") | |
| 53 | - | |
| 54 | - with pytest.raises(CollisionNomArchiveError): | |
| 55 | - archiver([fichier], local_racine, destination) | |
| 56 | - | |
| 57 | - assert (destination / fichier.name).read_bytes() == b"contenu-boitier-a-deja-archive" | |
| 58 | - | |
| 59 | - | |
| 60 | -def test_publier_copie_locale_laisse_une_copie_de_travail_et_verrouille(tmp_path: Path) -> None: | |
| 61 | - """FR-010 : après import, une copie locale identique à l'archive doit exister, | |
| 62 | - prête à être triée/retouchée immédiatement, sans checkout séparé.""" | |
| 63 | - local_tmp, fichier = _preparer_source(tmp_path, b"contenu-a") | |
| 64 | - dossier_archive = tmp_path / "archive" | |
| 65 | - dossier_local = tmp_path / "local_travail" | |
| 66 | - archiver([fichier], local_tmp, dossier_archive) | |
| 67 | - | |
| 68 | - publier_copie_locale([fichier], local_tmp, dossier_local, dossier_archive) | |
| 69 | - | |
| 70 | - assert (dossier_local / fichier.name).read_bytes() == b"contenu-a" | |
| 71 | - | |
| 72 | - manifest = ouvrir_ou_creer(dossier_archive) | |
| 73 | - ligne = manifest.conn.execute( | |
| 74 | - "SELECT hash_fichier_entier FROM fichiers WHERE chemin_relatif = ?", (fichier.name,) | |
| 75 | - ).fetchone() | |
| 76 | - assert ligne is not None | |
| 77 | - assert verifier(manifest) # dossier laissé verrouillé, comme après un checkout | |
| 78 | - manifest.conn.close() | |
| 79 | - | |
| 80 | - | |
| 81 | -def test_publier_copie_locale_ne_pose_pas_un_second_verrou_si_deja_verrouille( | |
| 82 | - tmp_path: Path, | |
| 83 | -) -> None: | |
| 84 | - """Cas d'une fusion ayant déjà déclenché un checkout (donc déjà verrouillé) juste | |
| 85 | - avant l'archivage : ne DOIT jamais lever une erreur de double verrouillage.""" | |
| 86 | - local_tmp, fichier = _preparer_source(tmp_path, b"contenu-a") | |
| 87 | - dossier_archive = tmp_path / "archive" | |
| 88 | - dossier_local = tmp_path / "local_travail" | |
| 89 | - archiver([fichier], local_tmp, dossier_archive) | |
| 90 | - | |
| 91 | - manifest_prealable = ouvrir_ou_creer(dossier_archive) | |
| 92 | - from regine_core.archive.verrou import poser # noqa: PLC0415 | |
| 93 | - | |
| 94 | - poser(manifest_prealable) | |
| 95 | - manifest_prealable.conn.close() | |
| 96 | - | |
| 97 | - publier_copie_locale([fichier], local_tmp, dossier_local, dossier_archive) # ne doit pas lever | |
| 98 | - | |
| 99 | - assert (dossier_local / fichier.name).read_bytes() == b"contenu-a" | |
| 100 | - | |
| 101 | - | |
| 102 | -def test_publier_copie_locale_ne_touche_pas_un_fichier_local_deja_present_non_concerne( | |
| 103 | - tmp_path: Path, | |
| 104 | -) -> None: | |
| 105 | - """Contrairement à un checkout complet, ne doit jamais écraser un fichier local | |
| 106 | - déjà là (ex. édition en cours d'un import précédent non encore réconcilié).""" | |
| 107 | - local_tmp, fichier = _preparer_source(tmp_path, b"contenu-a") | |
| 108 | - dossier_archive = tmp_path / "archive" | |
| 109 | - dossier_local = tmp_path / "local_travail" | |
| 110 | - dossier_local.mkdir() | |
| 111 | - (dossier_local / "deja_present.xmp").write_bytes(b"reglages-utilisateur-en-cours") | |
| 112 | - archiver([fichier], local_tmp, dossier_archive) | |
| 113 | - | |
| 114 | - publier_copie_locale([fichier], local_tmp, dossier_local, dossier_archive) | |
| 115 | - | |
| 116 | - assert (dossier_local / "deja_present.xmp").read_bytes() == b"reglages-utilisateur-en-cours" | |
| 117 | - | |
| 118 | - manifest = ouvrir_ou_creer(dossier_archive) | |
| 119 | - lever(manifest) # nettoyage, pas d'assertion supplémentaire nécessaire ici | |
| 120 | - manifest.conn.close() | |
| deleted file mode 100644 | |||
| @@ -1,120 +0,0 @@ | |||
| 1 | -"""Tests de `archiver`/`publier_copie_locale` : jamais d'écrasement silencieux | ||
| 2 | -(FR-012, US3 scénario 3), copie de travail locale laissée après import (FR-010).""" | ||
| 3 | - | ||
| 4 | -from __future__ import annotations | ||
| 5 | - | ||
| 6 | -from pathlib import Path | ||
| 7 | - | ||
| 8 | -import pytest | ||
| 9 | -from regine_core.archive.manifest import ouvrir_ou_creer | ||
| 10 | -from regine_core.archive.verrou import lever, verifier | ||
| 11 | -from regine_core.import_carte.push import ( | ||
| 12 | - CollisionNomArchiveError, | ||
| 13 | - archiver, | ||
| 14 | - publier_copie_locale, | ||
| 15 | -) | ||
| 16 | - | ||
| 17 | - | ||
| 18 | -def _preparer_source(tmp_path: Path, contenu: bytes) -> tuple[Path, Path]: | ||
| 19 | - local_racine = tmp_path / "local" | ||
| 20 | - local_racine.mkdir() | ||
| 21 | - fichier = local_racine / "2026-08-12_Kotor_RD0001.JPG" | ||
| 22 | - fichier.write_bytes(contenu) | ||
| 23 | - return local_racine, fichier | ||
| 24 | - | ||
| 25 | - | ||
| 26 | -def test_archiver_copie_normalement_un_nouveau_fichier(tmp_path: Path) -> None: | ||
| 27 | - local_racine, fichier = _preparer_source(tmp_path, b"contenu-a") | ||
| 28 | - destination = tmp_path / "archive" | ||
| 29 | - | ||
| 30 | - archiver([fichier], local_racine, destination) | ||
| 31 | - | ||
| 32 | - assert (destination / fichier.name).read_bytes() == b"contenu-a" | ||
| 33 | - | ||
| 34 | - | ||
| 35 | -def test_archiver_ignore_un_doublon_deja_archive_de_meme_contenu(tmp_path: Path) -> None: | ||
| 36 | - local_racine, fichier = _preparer_source(tmp_path, b"contenu-a") | ||
| 37 | - destination = tmp_path / "archive" | ||
| 38 | - destination.mkdir() | ||
| 39 | - (destination / fichier.name).write_bytes(b"contenu-a") | ||
| 40 | - | ||
| 41 | - archiver([fichier], local_racine, destination) # ne doit pas lever | ||
| 42 | - | ||
| 43 | - assert (destination / fichier.name).read_bytes() == b"contenu-a" | ||
| 44 | - | ||
| 45 | - | ||
| 46 | -def test_archiver_refuse_ecraser_un_fichier_existant_de_contenu_different(tmp_path: Path) -> None: | ||
| 47 | - """Deux boîtiers différents, importés séparément (fusion), produisant le même | ||
| 48 | - nom final : ne DOIT jamais écraser silencieusement l'un par l'autre.""" | ||
| 49 | - local_racine, fichier = _preparer_source(tmp_path, b"contenu-boitier-b") | ||
| 50 | - destination = tmp_path / "archive" | ||
| 51 | - destination.mkdir() | ||
| 52 | - (destination / fichier.name).write_bytes(b"contenu-boitier-a-deja-archive") | ||
| 53 | - | ||
| 54 | - with pytest.raises(CollisionNomArchiveError): | ||
| 55 | - archiver([fichier], local_racine, destination) | ||
| 56 | - | ||
| 57 | - assert (destination / fichier.name).read_bytes() == b"contenu-boitier-a-deja-archive" | ||
| 58 | - | ||
| 59 | - | ||
| 60 | -def test_publier_copie_locale_laisse_une_copie_de_travail_et_verrouille(tmp_path: Path) -> None: | ||
| 61 | - """FR-010 : après import, une copie locale identique à l'archive doit exister, | ||
| 62 | - prête à être triée/retouchée immédiatement, sans checkout séparé.""" | ||
| 63 | - local_tmp, fichier = _preparer_source(tmp_path, b"contenu-a") | ||
| 64 | - dossier_archive = tmp_path / "archive" | ||
| 65 | - dossier_local = tmp_path / "local_travail" | ||
| 66 | - archiver([fichier], local_tmp, dossier_archive) | ||
| 67 | - | ||
| 68 | - publier_copie_locale([fichier], local_tmp, dossier_local, dossier_archive) | ||
| 69 | - | ||
| 70 | - assert (dossier_local / fichier.name).read_bytes() == b"contenu-a" | ||
| 71 | - | ||
| 72 | - manifest = ouvrir_ou_creer(dossier_archive) | ||
| 73 | - ligne = manifest.conn.execute( | ||
| 74 | - "SELECT hash_fichier_entier FROM fichiers WHERE chemin_relatif = ?", (fichier.name,) | ||
| 75 | - ).fetchone() | ||
| 76 | - assert ligne is not None | ||
| 77 | - assert verifier(manifest) # dossier laissé verrouillé, comme après un checkout | ||
| 78 | - manifest.conn.close() | ||
| 79 | - | ||
| 80 | - | ||
| 81 | -def test_publier_copie_locale_ne_pose_pas_un_second_verrou_si_deja_verrouille( | ||
| 82 | - tmp_path: Path, | ||
| 83 | -) -> None: | ||
| 84 | - """Cas d'une fusion ayant déjà déclenché un checkout (donc déjà verrouillé) juste | ||
| 85 | - avant l'archivage : ne DOIT jamais lever une erreur de double verrouillage.""" | ||
| 86 | - local_tmp, fichier = _preparer_source(tmp_path, b"contenu-a") | ||
| 87 | - dossier_archive = tmp_path / "archive" | ||
| 88 | - dossier_local = tmp_path / "local_travail" | ||
| 89 | - archiver([fichier], local_tmp, dossier_archive) | ||
| 90 | - | ||
| 91 | - manifest_prealable = ouvrir_ou_creer(dossier_archive) | ||
| 92 | - from regine_core.archive.verrou import poser # noqa: PLC0415 | ||
| 93 | - | ||
| 94 | - poser(manifest_prealable) | ||
| 95 | - manifest_prealable.conn.close() | ||
| 96 | - | ||
| 97 | - publier_copie_locale([fichier], local_tmp, dossier_local, dossier_archive) # ne doit pas lever | ||
| 98 | - | ||
| 99 | - assert (dossier_local / fichier.name).read_bytes() == b"contenu-a" | ||
| 100 | - | ||
| 101 | - | ||
| 102 | -def test_publier_copie_locale_ne_touche_pas_un_fichier_local_deja_present_non_concerne( | ||
| 103 | - tmp_path: Path, | ||
| 104 | -) -> None: | ||
| 105 | - """Contrairement à un checkout complet, ne doit jamais écraser un fichier local | ||
| 106 | - déjà là (ex. édition en cours d'un import précédent non encore réconcilié).""" | ||
| 107 | - local_tmp, fichier = _preparer_source(tmp_path, b"contenu-a") | ||
| 108 | - dossier_archive = tmp_path / "archive" | ||
| 109 | - dossier_local = tmp_path / "local_travail" | ||
| 110 | - dossier_local.mkdir() | ||
| 111 | - (dossier_local / "deja_present.xmp").write_bytes(b"reglages-utilisateur-en-cours") | ||
| 112 | - archiver([fichier], local_tmp, dossier_archive) | ||
| 113 | - | ||
| 114 | - publier_copie_locale([fichier], local_tmp, dossier_local, dossier_archive) | ||
| 115 | - | ||
| 116 | - assert (dossier_local / "deja_present.xmp").read_bytes() == b"reglages-utilisateur-en-cours" | ||
| 117 | - | ||
| 118 | - manifest = ouvrir_ou_creer(dossier_archive) | ||
| 119 | - lever(manifest) # nettoyage, pas d'assertion supplémentaire nécessaire ici | ||
| 120 | - manifest.conn.close() | ||
added
packages/regine-core/tests/unit/test_push_publier_localement.py +93 -0 | new file mode 100644 | ||
| @@ -0,0 +1,93 @@ | ||
| 1 | +"""Tests de `publier_localement` : jamais d'écrasement silencieux (FR-012, US3 | |
| 2 | +scénario 3), transfert final vérifié vers l'espace de travail local (FR-019). | |
| 3 | + | |
| 4 | +Renommé depuis `test_push_archiver.py` (2026-09-22) : l'import ne pousse plus vers | |
| 5 | +l'archive NAS lui-même (cf. docstring de `push.py`) — `archiver`/`publier_copie_locale` | |
| 6 | +(qui écrivaient sur le NAS et un manifeste) sont remplacées par `publier_localement`, | |
| 7 | +une simple copie vérifiée sans manifeste ni verrou. Les anciens tests couvrant le | |
| 8 | +manifeste/verrou de `publier_copie_locale` sont retirés : ce comportement est | |
| 9 | +désormais entièrement porté par `regine_core.archive.reconciliation` (specs/005), | |
| 10 | +déclenché plus tard et séparément (`regine reconcile`), pas par l'import.""" | |
| 11 | + | |
| 12 | +from __future__ import annotations | |
| 13 | + | |
| 14 | +from pathlib import Path | |
| 15 | + | |
| 16 | +import pytest | |
| 17 | +from regine_core.import_carte.push import CollisionNomLocalError, publier_localement | |
| 18 | + | |
| 19 | + | |
| 20 | +def _preparer_source(tmp_path: Path, contenu: bytes) -> tuple[Path, Path]: | |
| 21 | + local_racine = tmp_path / "local_tmp" | |
| 22 | + local_racine.mkdir() | |
| 23 | + fichier = local_racine / "raw" / "2026-08-12_Kotor_RD0001.RAF" | |
| 24 | + fichier.parent.mkdir() | |
| 25 | + fichier.write_bytes(contenu) | |
| 26 | + return local_racine, fichier | |
| 27 | + | |
| 28 | + | |
| 29 | +def test_publier_localement_copie_normalement_un_nouveau_fichier(tmp_path: Path) -> None: | |
| 30 | + local_racine, fichier = _preparer_source(tmp_path, b"contenu-a") | |
| 31 | + destination = tmp_path / "local_travail" | |
| 32 | + | |
| 33 | + publier_localement([fichier], local_racine, destination) | |
| 34 | + | |
| 35 | + relatif = fichier.relative_to(local_racine) | |
| 36 | + assert (destination / relatif).read_bytes() == b"contenu-a" | |
| 37 | + | |
| 38 | + | |
| 39 | +def test_publier_localement_preserve_le_classement_par_format(tmp_path: Path) -> None: | |
| 40 | + """Le chemin relatif (incluant le sous-dossier de format, cf. classement.py) | |
| 41 | + est reproduit tel quel dans le dossier de destination.""" | |
| 42 | + local_racine, fichier = _preparer_source(tmp_path, b"contenu-a") | |
| 43 | + destination = tmp_path / "local_travail" | |
| 44 | + | |
| 45 | + publier_localement([fichier], local_racine, destination) | |
| 46 | + | |
| 47 | + assert (destination / "raw" / "2026-08-12_Kotor_RD0001.RAF").exists() | |
| 48 | + | |
| 49 | + | |
| 50 | +def test_publier_localement_ignore_un_doublon_deja_present_de_meme_contenu( | |
| 51 | + tmp_path: Path, | |
| 52 | +) -> None: | |
| 53 | + local_racine, fichier = _preparer_source(tmp_path, b"contenu-a") | |
| 54 | + destination = tmp_path / "local_travail" | |
| 55 | + relatif = fichier.relative_to(local_racine) | |
| 56 | + (destination / relatif).parent.mkdir(parents=True) | |
| 57 | + (destination / relatif).write_bytes(b"contenu-a") | |
| 58 | + | |
| 59 | + publier_localement([fichier], local_racine, destination) # ne doit pas lever | |
| 60 | + | |
| 61 | + assert (destination / relatif).read_bytes() == b"contenu-a" | |
| 62 | + | |
| 63 | + | |
| 64 | +def test_publier_localement_refuse_ecraser_un_fichier_existant_de_contenu_different( | |
| 65 | + tmp_path: Path, | |
| 66 | +) -> None: | |
| 67 | + """Deux boîtiers différents, importés séparément (fusion), produisant le même | |
| 68 | + nom final : ne DOIT jamais écraser silencieusement l'un par l'autre.""" | |
| 69 | + local_racine, fichier = _preparer_source(tmp_path, b"contenu-boitier-b") | |
| 70 | + destination = tmp_path / "local_travail" | |
| 71 | + relatif = fichier.relative_to(local_racine) | |
| 72 | + (destination / relatif).parent.mkdir(parents=True) | |
| 73 | + (destination / relatif).write_bytes(b"contenu-boitier-a-deja-present") | |
| 74 | + | |
| 75 | + with pytest.raises(CollisionNomLocalError): | |
| 76 | + publier_localement([fichier], local_racine, destination) | |
| 77 | + | |
| 78 | + assert (destination / relatif).read_bytes() == b"contenu-boitier-a-deja-present" | |
| 79 | + | |
| 80 | + | |
| 81 | +def test_publier_localement_ne_touche_pas_un_fichier_local_deja_present_non_concerne( | |
| 82 | + tmp_path: Path, | |
| 83 | +) -> None: | |
| 84 | + """N'écrase jamais un fichier local déjà là et non concerné par cet import | |
| 85 | + (ex. édition en cours d'un import précédent non encore réconcilié).""" | |
| 86 | + local_racine, fichier = _preparer_source(tmp_path, b"contenu-a") | |
| 87 | + destination = tmp_path / "local_travail" | |
| 88 | + destination.mkdir() | |
| 89 | + (destination / "deja_present.xmp").write_bytes(b"reglages-utilisateur-en-cours") | |
| 90 | + | |
| 91 | + publier_localement([fichier], local_racine, destination) | |
| 92 | + | |
| 93 | + assert (destination / "deja_present.xmp").read_bytes() == b"reglages-utilisateur-en-cours" | |
| new file mode 100644 | |||
| @@ -0,0 +1,93 @@ | |||
| 1 | +"""Tests de `publier_localement` : jamais d'écrasement silencieux (FR-012, US3 | ||
| 2 | +scénario 3), transfert final vérifié vers l'espace de travail local (FR-019). | ||
| 3 | + | ||
| 4 | +Renommé depuis `test_push_archiver.py` (2026-09-22) : l'import ne pousse plus vers | ||
| 5 | +l'archive NAS lui-même (cf. docstring de `push.py`) — `archiver`/`publier_copie_locale` | ||
| 6 | +(qui écrivaient sur le NAS et un manifeste) sont remplacées par `publier_localement`, | ||
| 7 | +une simple copie vérifiée sans manifeste ni verrou. Les anciens tests couvrant le | ||
| 8 | +manifeste/verrou de `publier_copie_locale` sont retirés : ce comportement est | ||
| 9 | +désormais entièrement porté par `regine_core.archive.reconciliation` (specs/005), | ||
| 10 | +déclenché plus tard et séparément (`regine reconcile`), pas par l'import.""" | ||
| 11 | + | ||
| 12 | +from __future__ import annotations | ||
| 13 | + | ||
| 14 | +from pathlib import Path | ||
| 15 | + | ||
| 16 | +import pytest | ||
| 17 | +from regine_core.import_carte.push import CollisionNomLocalError, publier_localement | ||
| 18 | + | ||
| 19 | + | ||
| 20 | +def _preparer_source(tmp_path: Path, contenu: bytes) -> tuple[Path, Path]: | ||
| 21 | + local_racine = tmp_path / "local_tmp" | ||
| 22 | + local_racine.mkdir() | ||
| 23 | + fichier = local_racine / "raw" / "2026-08-12_Kotor_RD0001.RAF" | ||
| 24 | + fichier.parent.mkdir() | ||
| 25 | + fichier.write_bytes(contenu) | ||
| 26 | + return local_racine, fichier | ||
| 27 | + | ||
| 28 | + | ||
| 29 | +def test_publier_localement_copie_normalement_un_nouveau_fichier(tmp_path: Path) -> None: | ||
| 30 | + local_racine, fichier = _preparer_source(tmp_path, b"contenu-a") | ||
| 31 | + destination = tmp_path / "local_travail" | ||
| 32 | + | ||
| 33 | + publier_localement([fichier], local_racine, destination) | ||
| 34 | + | ||
| 35 | + relatif = fichier.relative_to(local_racine) | ||
| 36 | + assert (destination / relatif).read_bytes() == b"contenu-a" | ||
| 37 | + | ||
| 38 | + | ||
| 39 | +def test_publier_localement_preserve_le_classement_par_format(tmp_path: Path) -> None: | ||
| 40 | + """Le chemin relatif (incluant le sous-dossier de format, cf. classement.py) | ||
| 41 | + est reproduit tel quel dans le dossier de destination.""" | ||
| 42 | + local_racine, fichier = _preparer_source(tmp_path, b"contenu-a") | ||
| 43 | + destination = tmp_path / "local_travail" | ||
| 44 | + | ||
| 45 | + publier_localement([fichier], local_racine, destination) | ||
| 46 | + | ||
| 47 | + assert (destination / "raw" / "2026-08-12_Kotor_RD0001.RAF").exists() | ||
| 48 | + | ||
| 49 | + | ||
| 50 | +def test_publier_localement_ignore_un_doublon_deja_present_de_meme_contenu( | ||
| 51 | + tmp_path: Path, | ||
| 52 | +) -> None: | ||
| 53 | + local_racine, fichier = _preparer_source(tmp_path, b"contenu-a") | ||
| 54 | + destination = tmp_path / "local_travail" | ||
| 55 | + relatif = fichier.relative_to(local_racine) | ||
| 56 | + (destination / relatif).parent.mkdir(parents=True) | ||
| 57 | + (destination / relatif).write_bytes(b"contenu-a") | ||
| 58 | + | ||
| 59 | + publier_localement([fichier], local_racine, destination) # ne doit pas lever | ||
| 60 | + | ||
| 61 | + assert (destination / relatif).read_bytes() == b"contenu-a" | ||
| 62 | + | ||
| 63 | + | ||
| 64 | +def test_publier_localement_refuse_ecraser_un_fichier_existant_de_contenu_different( | ||
| 65 | + tmp_path: Path, | ||
| 66 | +) -> None: | ||
| 67 | + """Deux boîtiers différents, importés séparément (fusion), produisant le même | ||
| 68 | + nom final : ne DOIT jamais écraser silencieusement l'un par l'autre.""" | ||
| 69 | + local_racine, fichier = _preparer_source(tmp_path, b"contenu-boitier-b") | ||
| 70 | + destination = tmp_path / "local_travail" | ||
| 71 | + relatif = fichier.relative_to(local_racine) | ||
| 72 | + (destination / relatif).parent.mkdir(parents=True) | ||
| 73 | + (destination / relatif).write_bytes(b"contenu-boitier-a-deja-present") | ||
| 74 | + | ||
| 75 | + with pytest.raises(CollisionNomLocalError): | ||
| 76 | + publier_localement([fichier], local_racine, destination) | ||
| 77 | + | ||
| 78 | + assert (destination / relatif).read_bytes() == b"contenu-boitier-a-deja-present" | ||
| 79 | + | ||
| 80 | + | ||
| 81 | +def test_publier_localement_ne_touche_pas_un_fichier_local_deja_present_non_concerne( | ||
| 82 | + tmp_path: Path, | ||
| 83 | +) -> None: | ||
| 84 | + """N'écrase jamais un fichier local déjà là et non concerné par cet import | ||
| 85 | + (ex. édition en cours d'un import précédent non encore réconcilié).""" | ||
| 86 | + local_racine, fichier = _preparer_source(tmp_path, b"contenu-a") | ||
| 87 | + destination = tmp_path / "local_travail" | ||
| 88 | + destination.mkdir() | ||
| 89 | + (destination / "deja_present.xmp").write_bytes(b"reglages-utilisateur-en-cours") | ||
| 90 | + | ||
| 91 | + publier_localement([fichier], local_racine, destination) | ||
| 92 | + | ||
| 93 | + assert (destination / "deja_present.xmp").read_bytes() == b"reglages-utilisateur-en-cours" | ||
modified
packages/regine-gui/src/regine_gui/import_view.py +94 -61 | @@ -1,13 +1,22 @@ | ||
| 1 | 1 | """Écran d'import (User Story 3, specs/006) — appelle exclusivement |
| 2 | 2 | `regine_core.import_carte` (copie, groupage, destination, nommage, identifiant, |
| 3 | -push), jamais `regine_cli.import_cmd` : ce dernier orchestre ses décisions via | |
| 4 | -`input()`/`print()`, incompatibles avec une boucle d'événements Qt, et ne porte | |
| 5 | -aucune règle métier qui ne soit déjà dans `regine_core` (research.md § 6). | |
| 3 | +classement, push), jamais `regine_cli.import_cmd` : ce dernier orchestre ses | |
| 4 | +décisions via `input()`/`print()`, incompatibles avec une boucle d'événements | |
| 5 | +Qt, et ne porte aucune règle métier qui ne soit déjà dans `regine_core` | |
| 6 | +(research.md § 6). | |
| 6 | 7 | |
| 7 | 8 | Reprend à l'identique les étapes déjà définies et testées par |
| 8 | 9 | `specs/001-import-photos` : analyse jour par jour, découpage en groupes, |
| 9 | 10 | destination, titre, confirmation avant écriture, désambiguïsation de collision |
| 10 | 11 | de boîtier — jamais une logique propre à la GUI (Principe VI). |
| 12 | + | |
| 13 | +**Révision (2026-09-22)** : l'import se termine par une copie de travail locale | |
| 14 | +classée par format (raw/jpeg/tiff), jamais une écriture sur l'archive NAS — | |
| 15 | +l'archivage devient une action séparée et explicite (`regine reconcile`), à | |
| 16 | +faire une fois la sélection du photographe terminée. Conséquence : l'onglet | |
| 17 | +n'a plus besoin que le partage SMB configuré soit actuellement monté pour | |
| 18 | +importer (seul son chemin sert à calculer où le dossier ira plus tard), ce qui | |
| 19 | +permet aussi de tester l'import sans NAS réel. | |
| 11 | 20 | """ |
| 12 | 21 | |
| 13 | 22 | from __future__ import annotations |
| @@ -33,7 +42,8 @@ from regine_core.camera_profile.resolve import assign_manual_source | ||
| 33 | 42 | from regine_core.config.categories import list_known_categories, register_category_usage |
| 34 | 43 | from regine_core.config.context import charger_contexte |
| 35 | 44 | from regine_core.config.db import open_context_db |
| 36 | -from regine_core.config.smb import resoudre_archive_locale | |
| 45 | +from regine_core.config.smb import chemin_local_du_partage, disponible | |
| 46 | +from regine_core.import_carte.classement import classer_par_format | |
| 37 | 47 | from regine_core.import_carte.copie import ( |
| 38 | 48 | EchecVerificationError, |
| 39 | 49 | copier_carte, |
| @@ -58,8 +68,8 @@ from regine_core.import_carte.nommage import ( | ||
| 58 | 68 | renommer_fichiers, |
| 59 | 69 | resoudre_collision_nom, |
| 60 | 70 | ) |
| 61 | -from regine_core.import_carte.push import archiver, preparer_resume, publier_copie_locale | |
| 62 | -from regine_core.import_carte.types import GroupeImport | |
| 71 | +from regine_core.import_carte.push import preparer_resume, publier_localement | |
| 72 | +from regine_core.import_carte.types import FichierCandidat, GroupeImport | |
| 63 | 73 | |
| 64 | 74 | _TYPES_DESTINATION = { |
| 65 | 75 | "Nouveau dossier": "nouveau_dossier", |
| @@ -69,39 +79,38 @@ _TYPES_DESTINATION = { | ||
| 69 | 79 | } |
| 70 | 80 | |
| 71 | 81 | |
| 72 | -class _ArchivageWorker(QObject): | |
| 73 | - """Exécute `archiver`/`publier_copie_locale` hors du thread principal Qt. | |
| 82 | +class _PublicationLocaleWorker(QObject): | |
| 83 | + """Exécute le classement par format, l'attribution d'identifiants et la copie | |
| 84 | + finale hors du thread principal Qt. | |
| 74 | 85 | |
| 75 | - Ces deux fonctions recalculent plusieurs sommes de contrôle par fichier | |
| 76 | - (copie carte→local, local→archive, archive→copie locale, plus le hash | |
| 77 | - image exiftool) — potentiellement long pour de gros fichiers RAW, et | |
| 78 | - strictement identique à ce que fait déjà la CLI (`specs/001-import-photos`). | |
| 79 | - Aucune logique métier ici : uniquement l'appel, déplacé sur un thread pour | |
| 80 | - ne pas geler la fenêtre pendant ce temps (FR-017 : retour visuel pendant | |
| 81 | - l'exécution, jamais une attente silencieuse).""" | |
| 86 | + `attribuer_identifiants` (appels exiftool) et la copie vérifiée par somme de | |
| 87 | + contrôle peuvent prendre un temps notable sur de gros fichiers RAW nombreux — | |
| 88 | + déplacés sur un thread pour ne pas geler la fenêtre pendant ce temps (FR-017 : | |
| 89 | + retour visuel pendant l'exécution, jamais une attente silencieuse). N'écrit | |
| 90 | + jamais sur l'archive NAS ni sur aucun manifeste (cf. docstring de module).""" | |
| 82 | 91 | |
| 83 | - succes = Signal(object, object) # dossier_archive: Path, dossier_local: Path | |
| 92 | + succes = Signal(object) # dossier_local: Path | |
| 84 | 93 | echec = Signal(str) |
| 85 | 94 | |
| 86 | 95 | def __init__( |
| 87 | - self, chemins: list[Path], local_tmp: Path, dossier_local: Path, dossier_archive: Path | |
| 96 | + self, fichiers: list[FichierCandidat], local_tmp: Path, dossier_local: Path | |
| 88 | 97 | ) -> None: |
| 89 | 98 | super().__init__() |
| 90 | - self._chemins = chemins | |
| 99 | + self._fichiers = fichiers | |
| 91 | 100 | self._local_tmp = local_tmp |
| 92 | 101 | self._dossier_local = dossier_local |
| 93 | - self._dossier_archive = dossier_archive | |
| 94 | 102 | |
| 95 | 103 | def run(self) -> None: |
| 96 | 104 | try: |
| 97 | - archiver(self._chemins, self._local_tmp, self._dossier_archive) | |
| 98 | - publier_copie_locale( | |
| 99 | - self._chemins, self._local_tmp, self._dossier_local, self._dossier_archive | |
| 100 | - ) | |
| 105 | + classer_par_format(self._fichiers, self._local_tmp) | |
| 106 | + chemins_maitres = [f.chemin_source for f in self._fichiers if f.type == "maitre"] | |
| 107 | + attribuer_identifiants(chemins_maitres) | |
| 108 | + chemins = [f.chemin_source for f in self._fichiers] | |
| 109 | + publier_localement(chemins, self._local_tmp, self._dossier_local) | |
| 101 | 110 | except Exception as exc: # rapporté au thread principal, jamais levé ici |
| 102 | 111 | self.echec.emit(str(exc)) |
| 103 | 112 | return |
| 104 | - self.succes.emit(self._dossier_archive, self._dossier_local) | |
| 113 | + self.succes.emit(self._dossier_local) | |
| 105 | 114 | |
| 106 | 115 | |
| 107 | 116 | class EcranImport(QMainWindow): |
| @@ -125,7 +134,9 @@ class EcranImport(QMainWindow): | ||
| 125 | 134 | self._local_tmp: Path | None = None |
| 126 | 135 | self._groupes: list[GroupeImport] = [] |
| 127 | 136 | self._thread: QThread | None = None |
| 128 | - self._worker: _ArchivageWorker | None = None | |
| 137 | + self._worker: _PublicationLocaleWorker | None = None | |
| 138 | + self._titre_en_cours: str | None = None | |
| 139 | + self._dossier_archive_en_cours: Path | None = None | |
| 129 | 140 | |
| 130 | 141 | conteneur = QWidget() |
| 131 | 142 | self.setCentralWidget(conteneur) |
| @@ -198,19 +209,22 @@ class EcranImport(QMainWindow): | ||
| 198 | 209 | QMessageBox.warning( |
| 199 | 210 | self, |
| 200 | 211 | "Archive non configurée", |
| 201 | - "Configurez d'abord l'emplacement de l'archive (onglet Configuration).", | |
| 212 | + "Configurez d'abord l'emplacement de l'archive (onglet Configuration) — " | |
| 213 | + "l'import en a besoin pour savoir où ce dossier sera archivé plus tard, " | |
| 214 | + "même si le partage n'est pas monté à l'instant.", | |
| 202 | 215 | ) |
| 203 | 216 | return |
| 204 | 217 | |
| 205 | - archive_root = resoudre_archive_locale(conn) | |
| 206 | - if archive_root is None: | |
| 207 | - conn.close() | |
| 208 | - QMessageBox.warning( | |
| 209 | - self, | |
| 210 | - "Archive inaccessible", | |
| 211 | - f"{contexte.archive_smb_path} n'est pas accessible — montage déclenché, réessayez.", | |
| 218 | + # Simple calcul de chemin (aucun accès réseau) : l'import ne pousse plus | |
| 219 | + # jamais vers l'archive lui-même (cf. docstring de module), donc le | |
| 220 | + # partage n'a pas besoin d'être monté pour importer une carte — seul son | |
| 221 | + # chemin sert à situer où ce dossier ira lors d'un futur `regine reconcile`. | |
| 222 | + archive_root = chemin_local_du_partage(contexte.archive_smb_path) | |
| 223 | + if not disponible(contexte.archive_smb_path): | |
| 224 | + self._log( | |
| 225 | + f"Note : {contexte.archive_smb_path} n'est pas monté actuellement — " | |
| 226 | + "sans conséquence pour importer, mais nécessaire plus tard pour archiver." | |
| 212 | 227 | ) |
| 213 | - return | |
| 214 | 228 | |
| 215 | 229 | local_root = contexte.local_work_dir |
| 216 | 230 | local_tmp = local_root / ".regine-import-tmp" |
| @@ -315,10 +329,11 @@ class EcranImport(QMainWindow): | ||
| 315 | 329 | try: |
| 316 | 330 | tache_lancee = self._traiter_groupe(groupe) |
| 317 | 331 | except Exception as exc: # dernier filet, jamais une exception technique brute — |
| 318 | - # CollisionNomArchiveError/EchecTransfertError (archiver/publier_copie_locale) | |
| 319 | - # ne peuvent plus remonter ici : elles sont capturées dans `_ArchivageWorker.run` | |
| 320 | - # et rapportées via le signal `echec` (_sur_archivage_echec), le thread étant | |
| 321 | - # lancé de façon non bloquante avant que cette méthode ne retourne. | |
| 332 | + # CollisionNomLocalError/EchecTransfertError (classement/publier_localement) | |
| 333 | + # ne peuvent plus remonter ici : elles sont capturées dans | |
| 334 | + # `_PublicationLocaleWorker.run` et rapportées via le signal `echec` | |
| 335 | + # (_sur_publication_echec), le thread étant lancé de façon non bloquante | |
| 336 | + # avant que cette méthode ne retourne. | |
| 322 | 337 | QMessageBox.warning(self, "Échec de l'import", str(exc)) |
| 323 | 338 | |
| 324 | 339 | if not tache_lancee: |
| @@ -436,48 +451,61 @@ class EcranImport(QMainWindow): | ||
| 436 | 451 | ) |
| 437 | 452 | |
| 438 | 453 | renommer_fichiers(groupe.fichiers, groupe.plage_dates[0], titre) |
| 439 | - chemins_maitres = [f.chemin_source for f in groupe.fichiers if f.type == "maitre"] | |
| 440 | - attribuer_identifiants(chemins_maitres) | |
| 441 | 454 | |
| 442 | - chemins = [f.chemin_source for f in groupe.fichiers] | |
| 443 | - resume = preparer_resume(chemins, dossier_archive) | |
| 455 | + # Résumé calculé avant classement/identifiants (mêmes fichiers, même | |
| 456 | + # taille) : reste correct que ces étapes lentes soient déjà passées ou | |
| 457 | + # non, et permet de confirmer sans attendre le classement par format. | |
| 458 | + chemins_avant_classement = [f.chemin_source for f in groupe.fichiers] | |
| 459 | + resume = preparer_resume(chemins_avant_classement, dossier_local) | |
| 444 | 460 | |
| 445 | 461 | confirmation = QMessageBox.question( |
| 446 | 462 | self, |
| 447 | - "Confirmer l'archivage", | |
| 463 | + "Confirmer la copie de travail locale", | |
| 448 | 464 | f"{resume.nombre_fichiers} fichier(s), {resume.taille_totale} octet(s)\n" |
| 449 | - f"→ {resume.dossier_destination}\n\nConfirmer l'archivage ?", | |
| 465 | + f"→ {resume.dossier_destination}\n\n" | |
| 466 | + "Rien ne sera écrit sur l'archive maintenant : l'archivage se fait plus " | |
| 467 | + "tard, une fois votre sélection terminée. Confirmer la copie locale ?", | |
| 450 | 468 | ) |
| 451 | 469 | if confirmation != QMessageBox.StandardButton.Yes: |
| 452 | - self._log(f"Archivage annulé pour le groupe « {titre} ».") | |
| 470 | + self._log(f"Import annulé pour le groupe « {titre} ».") | |
| 453 | 471 | return False |
| 454 | 472 | |
| 455 | 473 | self._log( |
| 456 | - f"Archivage en cours pour « {titre} » ({resume.nombre_fichiers} fichier(s), " | |
| 474 | + f"Copie locale en cours pour « {titre} » ({resume.nombre_fichiers} fichier(s), " | |
| 457 | 475 | f"{resume.taille_totale} octet(s)) — la fenêtre reste utilisable pendant ce temps…" |
| 458 | 476 | ) |
| 459 | - self._lancer_archivage(chemins, local_tmp, dossier_local, dossier_archive, titre) | |
| 477 | + self._lancer_publication(groupe.fichiers, local_tmp, dossier_local, dossier_archive, titre) | |
| 460 | 478 | return True |
| 461 | 479 | |
| 462 | - def _lancer_archivage( | |
| 480 | + def _lancer_publication( | |
| 463 | 481 | self, |
| 464 | - chemins: list[Path], | |
| 482 | + fichiers: list[FichierCandidat], | |
| 465 | 483 | local_tmp: Path, |
| 466 | 484 | dossier_local: Path, |
| 467 | 485 | dossier_archive: Path, |
| 468 | 486 | titre: str, |
| 469 | 487 | ) -> None: |
| 470 | - """Exécute `archiver`/`publier_copie_locale` sur un thread séparé — ces | |
| 471 | - fonctions recalculent plusieurs sommes de contrôle par fichier et | |
| 472 | - peuvent prendre un temps notable sur de gros fichiers RAW ; les lancer | |
| 473 | - directement sur le thread principal gèlerait toute la fenêtre pendant | |
| 474 | - ce temps (FR-017).""" | |
| 488 | + """Exécute classement + attribution d'identifiants + copie finale sur un | |
| 489 | + thread séparé — ces étapes peuvent prendre un temps notable sur de gros | |
| 490 | + fichiers RAW nombreux ; les lancer directement sur le thread principal | |
| 491 | + gèlerait toute la fenêtre pendant ce temps (FR-017).""" | |
| 492 | + # `titre`/`dossier_archive` sont mémorisés sur `self` plutôt que capturés | |
| 493 | + # par une lambda : les slots connectés doivent être de vraies méthodes | |
| 494 | + # liées d'un `QObject` (ici `self`, une `QMainWindow`) pour que Qt détecte | |
| 495 | + # correctement leur affinité de thread (thread principal) et les exécute | |
| 496 | + # via une connexion mise en file d'attente. Une lambda n'a pas d'objet | |
| 497 | + # récepteur reconnu : Qt exécuterait alors le slot en connexion directe, | |
| 498 | + # *dans le thread d'arrière-plan* — provoquant un accès concurrent | |
| 499 | + # illégal à la connexion SQLite et aux widgets (trouvé par test manuel). | |
| 500 | + self._titre_en_cours = titre | |
| 501 | + self._dossier_archive_en_cours = dossier_archive | |
| 502 | + | |
| 475 | 503 | thread = QThread(self) |
| 476 | - worker = _ArchivageWorker(chemins, local_tmp, dossier_local, dossier_archive) | |
| 504 | + worker = _PublicationLocaleWorker(fichiers, local_tmp, dossier_local) | |
| 477 | 505 | worker.moveToThread(thread) |
| 478 | 506 | thread.started.connect(worker.run) |
| 479 | - worker.succes.connect(lambda da, dl: self._sur_archivage_succes(da, dl, titre)) | |
| 480 | - worker.echec.connect(lambda msg: self._sur_archivage_echec(msg, titre)) | |
| 507 | + worker.succes.connect(self._sur_publication_succes) | |
| 508 | + worker.echec.connect(self._sur_publication_echec) | |
| 481 | 509 | worker.succes.connect(thread.quit) |
| 482 | 510 | worker.echec.connect(thread.quit) |
| 483 | 511 | thread.finished.connect(worker.deleteLater) |
| @@ -486,9 +514,13 @@ class EcranImport(QMainWindow): | ||
| 486 | 514 | self._worker = worker |
| 487 | 515 | thread.start() |
| 488 | 516 | |
| 489 | - def _sur_archivage_succes(self, dossier_archive: Path, dossier_local: Path, titre: str) -> None: | |
| 490 | - self._log(f"Archivé : {dossier_archive}") | |
| 517 | + def _sur_publication_succes(self, dossier_local: Path) -> None: | |
| 518 | + dossier_archive = self._dossier_archive_en_cours | |
| 491 | 519 | self._log(f"Copie de travail locale prête : {dossier_local}") |
| 520 | + self._log( | |
| 521 | + "Rien n'a été écrit sur l'archive. Une fois votre sélection terminée, " | |
| 522 | + f"archivez avec : regine reconcile {dossier_archive} --local-dest {dossier_local}" | |
| 523 | + ) | |
| 492 | 524 | self._thread = None |
| 493 | 525 | self._worker = None |
| 494 | 526 | |
| @@ -500,10 +532,11 @@ class EcranImport(QMainWindow): | ||
| 500 | 532 | |
| 501 | 533 | self._finaliser_groupe() |
| 502 | 534 | |
| 503 | - def _sur_archivage_echec(self, message: str, titre: str) -> None: | |
| 535 | + def _sur_publication_echec(self, message: str) -> None: | |
| 536 | + titre = self._titre_en_cours | |
| 504 | 537 | self._thread = None |
| 505 | 538 | self._worker = None |
| 506 | - QMessageBox.warning(self, "Échec de l'archivage", f"Groupe « {titre} » : {message}") | |
| 539 | + QMessageBox.warning(self, "Échec de la copie locale", f"Groupe « {titre} » : {message}") | |
| 507 | 540 | self._finaliser_groupe() |
| 508 | 541 | |
| 509 | 542 | def _choisir_dossier_existant( |
| @@ -1,13 +1,22 @@ | |||
| 1 | """Écran d'import (User Story 3, specs/006) — appelle exclusivement | 1 | """Écran d'import (User Story 3, specs/006) — appelle exclusivement |
| 2 | `regine_core.import_carte` (copie, groupage, destination, nommage, identifiant, | 2 | `regine_core.import_carte` (copie, groupage, destination, nommage, identifiant, |
| 3 | -push), jamais `regine_cli.import_cmd` : ce dernier orchestre ses décisions via | 3 | +classement, push), jamais `regine_cli.import_cmd` : ce dernier orchestre ses |
| 4 | -`input()`/`print()`, incompatibles avec une boucle d'événements Qt, et ne porte | 4 | +décisions via `input()`/`print()`, incompatibles avec une boucle d'événements |
| 5 | -aucune règle métier qui ne soit déjà dans `regine_core` (research.md § 6). | 5 | +Qt, et ne porte aucune règle métier qui ne soit déjà dans `regine_core` |
| 6 | +(research.md § 6). | ||
| 6 | 7 | ||
| 7 | Reprend à l'identique les étapes déjà définies et testées par | 8 | Reprend à l'identique les étapes déjà définies et testées par |
| 8 | `specs/001-import-photos` : analyse jour par jour, découpage en groupes, | 9 | `specs/001-import-photos` : analyse jour par jour, découpage en groupes, |
| 9 | destination, titre, confirmation avant écriture, désambiguïsation de collision | 10 | destination, titre, confirmation avant écriture, désambiguïsation de collision |
| 10 | de boîtier — jamais une logique propre à la GUI (Principe VI). | 11 | de boîtier — jamais une logique propre à la GUI (Principe VI). |
| 12 | + | ||
| 13 | +**Révision (2026-09-22)** : l'import se termine par une copie de travail locale | ||
| 14 | +classée par format (raw/jpeg/tiff), jamais une écriture sur l'archive NAS — | ||
| 15 | +l'archivage devient une action séparée et explicite (`regine reconcile`), à | ||
| 16 | +faire une fois la sélection du photographe terminée. Conséquence : l'onglet | ||
| 17 | +n'a plus besoin que le partage SMB configuré soit actuellement monté pour | ||
| 18 | +importer (seul son chemin sert à calculer où le dossier ira plus tard), ce qui | ||
| 19 | +permet aussi de tester l'import sans NAS réel. | ||
| 11 | """ | 20 | """ |
| 12 | 21 | ||
| 13 | from __future__ import annotations | 22 | from __future__ import annotations |
| @@ -33,7 +42,8 @@ from regine_core.camera_profile.resolve import assign_manual_source | |||
| 33 | from regine_core.config.categories import list_known_categories, register_category_usage | 42 | from regine_core.config.categories import list_known_categories, register_category_usage |
| 34 | from regine_core.config.context import charger_contexte | 43 | from regine_core.config.context import charger_contexte |
| 35 | from regine_core.config.db import open_context_db | 44 | from regine_core.config.db import open_context_db |
| 36 | -from regine_core.config.smb import resoudre_archive_locale | 45 | +from regine_core.config.smb import chemin_local_du_partage, disponible |
| 46 | +from regine_core.import_carte.classement import classer_par_format | ||
| 37 | from regine_core.import_carte.copie import ( | 47 | from regine_core.import_carte.copie import ( |
| 38 | EchecVerificationError, | 48 | EchecVerificationError, |
| 39 | copier_carte, | 49 | copier_carte, |
| @@ -58,8 +68,8 @@ from regine_core.import_carte.nommage import ( | |||
| 58 | renommer_fichiers, | 68 | renommer_fichiers, |
| 59 | resoudre_collision_nom, | 69 | resoudre_collision_nom, |
| 60 | ) | 70 | ) |
| 61 | -from regine_core.import_carte.push import archiver, preparer_resume, publier_copie_locale | 71 | +from regine_core.import_carte.push import preparer_resume, publier_localement |
| 62 | -from regine_core.import_carte.types import GroupeImport | 72 | +from regine_core.import_carte.types import FichierCandidat, GroupeImport |
| 63 | 73 | ||
| 64 | _TYPES_DESTINATION = { | 74 | _TYPES_DESTINATION = { |
| 65 | "Nouveau dossier": "nouveau_dossier", | 75 | "Nouveau dossier": "nouveau_dossier", |
| @@ -69,39 +79,38 @@ _TYPES_DESTINATION = { | |||
| 69 | } | 79 | } |
| 70 | 80 | ||
| 71 | 81 | ||
| 72 | -class _ArchivageWorker(QObject): | 82 | +class _PublicationLocaleWorker(QObject): |
| 73 | - """Exécute `archiver`/`publier_copie_locale` hors du thread principal Qt. | 83 | + """Exécute le classement par format, l'attribution d'identifiants et la copie |
| 84 | + finale hors du thread principal Qt. | ||
| 74 | 85 | ||
| 75 | - Ces deux fonctions recalculent plusieurs sommes de contrôle par fichier | 86 | + `attribuer_identifiants` (appels exiftool) et la copie vérifiée par somme de |
| 76 | - (copie carte→local, local→archive, archive→copie locale, plus le hash | 87 | + contrôle peuvent prendre un temps notable sur de gros fichiers RAW nombreux — |
| 77 | - image exiftool) — potentiellement long pour de gros fichiers RAW, et | 88 | + déplacés sur un thread pour ne pas geler la fenêtre pendant ce temps (FR-017 : |
| 78 | - strictement identique à ce que fait déjà la CLI (`specs/001-import-photos`). | 89 | + retour visuel pendant l'exécution, jamais une attente silencieuse). N'écrit |
| 79 | - Aucune logique métier ici : uniquement l'appel, déplacé sur un thread pour | 90 | + jamais sur l'archive NAS ni sur aucun manifeste (cf. docstring de module).""" |
| 80 | - ne pas geler la fenêtre pendant ce temps (FR-017 : retour visuel pendant | ||
| 81 | - l'exécution, jamais une attente silencieuse).""" | ||
| 82 | 91 | ||
| 83 | - succes = Signal(object, object) # dossier_archive: Path, dossier_local: Path | 92 | + succes = Signal(object) # dossier_local: Path |
| 84 | echec = Signal(str) | 93 | echec = Signal(str) |
| 85 | 94 | ||
| 86 | def __init__( | 95 | def __init__( |
| 87 | - self, chemins: list[Path], local_tmp: Path, dossier_local: Path, dossier_archive: Path | 96 | + self, fichiers: list[FichierCandidat], local_tmp: Path, dossier_local: Path |
| 88 | ) -> None: | 97 | ) -> None: |
| 89 | super().__init__() | 98 | super().__init__() |
| 90 | - self._chemins = chemins | 99 | + self._fichiers = fichiers |
| 91 | self._local_tmp = local_tmp | 100 | self._local_tmp = local_tmp |
| 92 | self._dossier_local = dossier_local | 101 | self._dossier_local = dossier_local |
| 93 | - self._dossier_archive = dossier_archive | ||
| 94 | 102 | ||
| 95 | def run(self) -> None: | 103 | def run(self) -> None: |
| 96 | try: | 104 | try: |
| 97 | - archiver(self._chemins, self._local_tmp, self._dossier_archive) | 105 | + classer_par_format(self._fichiers, self._local_tmp) |
| 98 | - publier_copie_locale( | 106 | + chemins_maitres = [f.chemin_source for f in self._fichiers if f.type == "maitre"] |
| 99 | - self._chemins, self._local_tmp, self._dossier_local, self._dossier_archive | 107 | + attribuer_identifiants(chemins_maitres) |
| 100 | - ) | 108 | + chemins = [f.chemin_source for f in self._fichiers] |
| 109 | + publier_localement(chemins, self._local_tmp, self._dossier_local) | ||
| 101 | except Exception as exc: # rapporté au thread principal, jamais levé ici | 110 | except Exception as exc: # rapporté au thread principal, jamais levé ici |
| 102 | self.echec.emit(str(exc)) | 111 | self.echec.emit(str(exc)) |
| 103 | return | 112 | return |
| 104 | - self.succes.emit(self._dossier_archive, self._dossier_local) | 113 | + self.succes.emit(self._dossier_local) |
| 105 | 114 | ||
| 106 | 115 | ||
| 107 | class EcranImport(QMainWindow): | 116 | class EcranImport(QMainWindow): |
| @@ -125,7 +134,9 @@ class EcranImport(QMainWindow): | |||
| 125 | self._local_tmp: Path | None = None | 134 | self._local_tmp: Path | None = None |
| 126 | self._groupes: list[GroupeImport] = [] | 135 | self._groupes: list[GroupeImport] = [] |
| 127 | self._thread: QThread | None = None | 136 | self._thread: QThread | None = None |
| 128 | - self._worker: _ArchivageWorker | None = None | 137 | + self._worker: _PublicationLocaleWorker | None = None |
| 138 | + self._titre_en_cours: str | None = None | ||
| 139 | + self._dossier_archive_en_cours: Path | None = None | ||
| 129 | 140 | ||
| 130 | conteneur = QWidget() | 141 | conteneur = QWidget() |
| 131 | self.setCentralWidget(conteneur) | 142 | self.setCentralWidget(conteneur) |
| @@ -198,19 +209,22 @@ class EcranImport(QMainWindow): | |||
| 198 | QMessageBox.warning( | 209 | QMessageBox.warning( |
| 199 | self, | 210 | self, |
| 200 | "Archive non configurée", | 211 | "Archive non configurée", |
| 201 | - "Configurez d'abord l'emplacement de l'archive (onglet Configuration).", | 212 | + "Configurez d'abord l'emplacement de l'archive (onglet Configuration) — " |
| 213 | + "l'import en a besoin pour savoir où ce dossier sera archivé plus tard, " | ||
| 214 | + "même si le partage n'est pas monté à l'instant.", | ||
| 202 | ) | 215 | ) |
| 203 | return | 216 | return |
| 204 | 217 | ||
| 205 | - archive_root = resoudre_archive_locale(conn) | 218 | + # Simple calcul de chemin (aucun accès réseau) : l'import ne pousse plus |
| 206 | - if archive_root is None: | 219 | + # jamais vers l'archive lui-même (cf. docstring de module), donc le |
| 207 | - conn.close() | 220 | + # partage n'a pas besoin d'être monté pour importer une carte — seul son |
| 208 | - QMessageBox.warning( | 221 | + # chemin sert à situer où ce dossier ira lors d'un futur `regine reconcile`. |
| 209 | - self, | 222 | + archive_root = chemin_local_du_partage(contexte.archive_smb_path) |
| 210 | - "Archive inaccessible", | 223 | + if not disponible(contexte.archive_smb_path): |
| 211 | - f"{contexte.archive_smb_path} n'est pas accessible — montage déclenché, réessayez.", | 224 | + self._log( |
| 225 | + f"Note : {contexte.archive_smb_path} n'est pas monté actuellement — " | ||
| 226 | + "sans conséquence pour importer, mais nécessaire plus tard pour archiver." | ||
| 212 | ) | 227 | ) |
| 213 | - return | ||
| 214 | 228 | ||
| 215 | local_root = contexte.local_work_dir | 229 | local_root = contexte.local_work_dir |
| 216 | local_tmp = local_root / ".regine-import-tmp" | 230 | local_tmp = local_root / ".regine-import-tmp" |
| @@ -315,10 +329,11 @@ class EcranImport(QMainWindow): | |||
| 315 | try: | 329 | try: |
| 316 | tache_lancee = self._traiter_groupe(groupe) | 330 | tache_lancee = self._traiter_groupe(groupe) |
| 317 | except Exception as exc: # dernier filet, jamais une exception technique brute — | 331 | except Exception as exc: # dernier filet, jamais une exception technique brute — |
| 318 | - # CollisionNomArchiveError/EchecTransfertError (archiver/publier_copie_locale) | 332 | + # CollisionNomLocalError/EchecTransfertError (classement/publier_localement) |
| 319 | - # ne peuvent plus remonter ici : elles sont capturées dans `_ArchivageWorker.run` | 333 | + # ne peuvent plus remonter ici : elles sont capturées dans |
| 320 | - # et rapportées via le signal `echec` (_sur_archivage_echec), le thread étant | 334 | + # `_PublicationLocaleWorker.run` et rapportées via le signal `echec` |
| 321 | - # lancé de façon non bloquante avant que cette méthode ne retourne. | 335 | + # (_sur_publication_echec), le thread étant lancé de façon non bloquante |
| 336 | + # avant que cette méthode ne retourne. | ||
| 322 | QMessageBox.warning(self, "Échec de l'import", str(exc)) | 337 | QMessageBox.warning(self, "Échec de l'import", str(exc)) |
| 323 | 338 | ||
| 324 | if not tache_lancee: | 339 | if not tache_lancee: |
| @@ -436,48 +451,61 @@ class EcranImport(QMainWindow): | |||
| 436 | ) | 451 | ) |
| 437 | 452 | ||
| 438 | renommer_fichiers(groupe.fichiers, groupe.plage_dates[0], titre) | 453 | renommer_fichiers(groupe.fichiers, groupe.plage_dates[0], titre) |
| 439 | - chemins_maitres = [f.chemin_source for f in groupe.fichiers if f.type == "maitre"] | ||
| 440 | - attribuer_identifiants(chemins_maitres) | ||
| 441 | 454 | ||
| 442 | - chemins = [f.chemin_source for f in groupe.fichiers] | 455 | + # Résumé calculé avant classement/identifiants (mêmes fichiers, même |
| 443 | - resume = preparer_resume(chemins, dossier_archive) | 456 | + # taille) : reste correct que ces étapes lentes soient déjà passées ou |
| 457 | + # non, et permet de confirmer sans attendre le classement par format. | ||
| 458 | + chemins_avant_classement = [f.chemin_source for f in groupe.fichiers] | ||
| 459 | + resume = preparer_resume(chemins_avant_classement, dossier_local) | ||
| 444 | 460 | ||
| 445 | confirmation = QMessageBox.question( | 461 | confirmation = QMessageBox.question( |
| 446 | self, | 462 | self, |
| 447 | - "Confirmer l'archivage", | 463 | + "Confirmer la copie de travail locale", |
| 448 | f"{resume.nombre_fichiers} fichier(s), {resume.taille_totale} octet(s)\n" | 464 | f"{resume.nombre_fichiers} fichier(s), {resume.taille_totale} octet(s)\n" |
| 449 | - f"→ {resume.dossier_destination}\n\nConfirmer l'archivage ?", | 465 | + f"→ {resume.dossier_destination}\n\n" |
| 466 | + "Rien ne sera écrit sur l'archive maintenant : l'archivage se fait plus " | ||
| 467 | + "tard, une fois votre sélection terminée. Confirmer la copie locale ?", | ||
| 450 | ) | 468 | ) |
| 451 | if confirmation != QMessageBox.StandardButton.Yes: | 469 | if confirmation != QMessageBox.StandardButton.Yes: |
| 452 | - self._log(f"Archivage annulé pour le groupe « {titre} ».") | 470 | + self._log(f"Import annulé pour le groupe « {titre} ».") |
| 453 | return False | 471 | return False |
| 454 | 472 | ||
| 455 | self._log( | 473 | self._log( |
| 456 | - f"Archivage en cours pour « {titre} » ({resume.nombre_fichiers} fichier(s), " | 474 | + f"Copie locale en cours pour « {titre} » ({resume.nombre_fichiers} fichier(s), " |
| 457 | f"{resume.taille_totale} octet(s)) — la fenêtre reste utilisable pendant ce temps…" | 475 | f"{resume.taille_totale} octet(s)) — la fenêtre reste utilisable pendant ce temps…" |
| 458 | ) | 476 | ) |
| 459 | - self._lancer_archivage(chemins, local_tmp, dossier_local, dossier_archive, titre) | 477 | + self._lancer_publication(groupe.fichiers, local_tmp, dossier_local, dossier_archive, titre) |
| 460 | return True | 478 | return True |
| 461 | 479 | ||
| 462 | - def _lancer_archivage( | 480 | + def _lancer_publication( |
| 463 | self, | 481 | self, |
| 464 | - chemins: list[Path], | 482 | + fichiers: list[FichierCandidat], |
| 465 | local_tmp: Path, | 483 | local_tmp: Path, |
| 466 | dossier_local: Path, | 484 | dossier_local: Path, |
| 467 | dossier_archive: Path, | 485 | dossier_archive: Path, |
| 468 | titre: str, | 486 | titre: str, |
| 469 | ) -> None: | 487 | ) -> None: |
| 470 | - """Exécute `archiver`/`publier_copie_locale` sur un thread séparé — ces | 488 | + """Exécute classement + attribution d'identifiants + copie finale sur un |
| 471 | - fonctions recalculent plusieurs sommes de contrôle par fichier et | 489 | + thread séparé — ces étapes peuvent prendre un temps notable sur de gros |
| 472 | - peuvent prendre un temps notable sur de gros fichiers RAW ; les lancer | 490 | + fichiers RAW nombreux ; les lancer directement sur le thread principal |
| 473 | - directement sur le thread principal gèlerait toute la fenêtre pendant | 491 | + gèlerait toute la fenêtre pendant ce temps (FR-017).""" |
| 474 | - ce temps (FR-017).""" | 492 | + # `titre`/`dossier_archive` sont mémorisés sur `self` plutôt que capturés |
| 493 | + # par une lambda : les slots connectés doivent être de vraies méthodes | ||
| 494 | + # liées d'un `QObject` (ici `self`, une `QMainWindow`) pour que Qt détecte | ||
| 495 | + # correctement leur affinité de thread (thread principal) et les exécute | ||
| 496 | + # via une connexion mise en file d'attente. Une lambda n'a pas d'objet | ||
| 497 | + # récepteur reconnu : Qt exécuterait alors le slot en connexion directe, | ||
| 498 | + # *dans le thread d'arrière-plan* — provoquant un accès concurrent | ||
| 499 | + # illégal à la connexion SQLite et aux widgets (trouvé par test manuel). | ||
| 500 | + self._titre_en_cours = titre | ||
| 501 | + self._dossier_archive_en_cours = dossier_archive | ||
| 502 | + | ||
| 475 | thread = QThread(self) | 503 | thread = QThread(self) |
| 476 | - worker = _ArchivageWorker(chemins, local_tmp, dossier_local, dossier_archive) | 504 | + worker = _PublicationLocaleWorker(fichiers, local_tmp, dossier_local) |
| 477 | worker.moveToThread(thread) | 505 | worker.moveToThread(thread) |
| 478 | thread.started.connect(worker.run) | 506 | thread.started.connect(worker.run) |
| 479 | - worker.succes.connect(lambda da, dl: self._sur_archivage_succes(da, dl, titre)) | 507 | + worker.succes.connect(self._sur_publication_succes) |
| 480 | - worker.echec.connect(lambda msg: self._sur_archivage_echec(msg, titre)) | 508 | + worker.echec.connect(self._sur_publication_echec) |
| 481 | worker.succes.connect(thread.quit) | 509 | worker.succes.connect(thread.quit) |
| 482 | worker.echec.connect(thread.quit) | 510 | worker.echec.connect(thread.quit) |
| 483 | thread.finished.connect(worker.deleteLater) | 511 | thread.finished.connect(worker.deleteLater) |
| @@ -486,9 +514,13 @@ class EcranImport(QMainWindow): | |||
| 486 | self._worker = worker | 514 | self._worker = worker |
| 487 | thread.start() | 515 | thread.start() |
| 488 | 516 | ||
| 489 | - def _sur_archivage_succes(self, dossier_archive: Path, dossier_local: Path, titre: str) -> None: | 517 | + def _sur_publication_succes(self, dossier_local: Path) -> None: |
| 490 | - self._log(f"Archivé : {dossier_archive}") | 518 | + dossier_archive = self._dossier_archive_en_cours |
| 491 | self._log(f"Copie de travail locale prête : {dossier_local}") | 519 | self._log(f"Copie de travail locale prête : {dossier_local}") |
| 520 | + self._log( | ||
| 521 | + "Rien n'a été écrit sur l'archive. Une fois votre sélection terminée, " | ||
| 522 | + f"archivez avec : regine reconcile {dossier_archive} --local-dest {dossier_local}" | ||
| 523 | + ) | ||
| 492 | self._thread = None | 524 | self._thread = None |
| 493 | self._worker = None | 525 | self._worker = None |
| 494 | 526 | ||
| @@ -500,10 +532,11 @@ class EcranImport(QMainWindow): | |||
| 500 | 532 | ||
| 501 | self._finaliser_groupe() | 533 | self._finaliser_groupe() |
| 502 | 534 | ||
| 503 | - def _sur_archivage_echec(self, message: str, titre: str) -> None: | 535 | + def _sur_publication_echec(self, message: str) -> None: |
| 536 | + titre = self._titre_en_cours | ||
| 504 | self._thread = None | 537 | self._thread = None |
| 505 | self._worker = None | 538 | self._worker = None |
| 506 | - QMessageBox.warning(self, "Échec de l'archivage", f"Groupe « {titre} » : {message}") | 539 | + QMessageBox.warning(self, "Échec de la copie locale", f"Groupe « {titre} » : {message}") |
| 507 | self._finaliser_groupe() | 540 | self._finaliser_groupe() |
| 508 | 541 | ||
| 509 | def _choisir_dossier_existant( | 542 | def _choisir_dossier_existant( |
modified
specs/001-import-photos/checklists/requirements.md +11 -0 | @@ -55,3 +55,14 @@ | ||
| 55 | 55 | (répertoire racine affiché dans le résumé de confirmation), Key Entities (Dossier / Dossier |
| 56 | 56 | parent), Acceptance Scenarios de User Story 3 (héritage du répertoire racine par les |
| 57 | 57 | sous-dossiers) et Assumptions (dépendance déclarée vers specs/004) mis à jour ; nouveau SC-008. |
| 58 | +- **Révision du 2026-09-22 (clarifications)** : deux écarts trouvés par test manuel de la GUI | |
| 59 | + (`specs/006-gui-tri-consultation` User Story 3) — (1) l'import poussait automatiquement vers | |
| 60 | + l'archive NAS sans laisser au photographe l'occasion de relire/retoucher sa copie locale | |
| 61 | + d'abord ; (2) les fichiers n'étaient jamais classés dans un dossier de format (`raw/`/`jpeg/`/ | |
| 62 | + `tiff/`), contrairement à `docs/archivage-photo-elements-cles.md` section 10 et à l'écran de tri | |
| 63 | + qui suppose déjà cette structure en place. Corrigés : FR-018/019 revus (l'import se termine à une | |
| 64 | + copie de travail locale, jamais une écriture NAS), nouveau FR-020 (classement par format), | |
| 65 | + nouvelles entités (Dossier de format, Copie de travail temporaire), SC-002/006/008 reformulés, | |
| 66 | + nouveaux SC-009/SC-010. L'archivage devient une action distincte, déclenchée plus tard via | |
| 67 | + `regine reconcile` (`specs/005-checkout-reconciliation`, déjà conçu pour ce cas sans modification | |
| 68 | + nécessaire). Tous les critères de la checklist restent satisfaits après cette révision. | |
| @@ -55,3 +55,14 @@ | |||
| 55 | (répertoire racine affiché dans le résumé de confirmation), Key Entities (Dossier / Dossier | 55 | (répertoire racine affiché dans le résumé de confirmation), Key Entities (Dossier / Dossier |
| 56 | parent), Acceptance Scenarios de User Story 3 (héritage du répertoire racine par les | 56 | parent), Acceptance Scenarios de User Story 3 (héritage du répertoire racine par les |
| 57 | sous-dossiers) et Assumptions (dépendance déclarée vers specs/004) mis à jour ; nouveau SC-008. | 57 | sous-dossiers) et Assumptions (dépendance déclarée vers specs/004) mis à jour ; nouveau SC-008. |
| 58 | +- **Révision du 2026-09-22 (clarifications)** : deux écarts trouvés par test manuel de la GUI | ||
| 59 | + (`specs/006-gui-tri-consultation` User Story 3) — (1) l'import poussait automatiquement vers | ||
| 60 | + l'archive NAS sans laisser au photographe l'occasion de relire/retoucher sa copie locale | ||
| 61 | + d'abord ; (2) les fichiers n'étaient jamais classés dans un dossier de format (`raw/`/`jpeg/`/ | ||
| 62 | + `tiff/`), contrairement à `docs/archivage-photo-elements-cles.md` section 10 et à l'écran de tri | ||
| 63 | + qui suppose déjà cette structure en place. Corrigés : FR-018/019 revus (l'import se termine à une | ||
| 64 | + copie de travail locale, jamais une écriture NAS), nouveau FR-020 (classement par format), | ||
| 65 | + nouvelles entités (Dossier de format, Copie de travail temporaire), SC-002/006/008 reformulés, | ||
| 66 | + nouveaux SC-009/SC-010. L'archivage devient une action distincte, déclenchée plus tard via | ||
| 67 | + `regine reconcile` (`specs/005-checkout-reconciliation`, déjà conçu pour ce cas sans modification | ||
| 68 | + nécessaire). Tous les critères de la checklist restent satisfaits après cette révision. | ||
modified
specs/001-import-photos/contracts/cli-import.md +15 -9 | @@ -14,20 +14,21 @@ regine import /Volumes/CARTE_SD [--titre TEXTE] [--destination nouveau|sous-doss | ||
| 14 | 14 | 2. Analyse des dates et proposition de groupe(s) (FR-002/003/005/006) — affiche la répartition jour par jour, invite à confirmer ou détacher des jours. |
| 15 | 15 | 3. Pour chaque groupe : demande la destination (FR-007), la catégorie/année si `nouveau_dossier`/`nouveau_parent` (avec suggestions `regine_core.config.categories.list_known_categories`/`suggest_categories`), puis le titre (FR-010). |
| 16 | 16 | 4. Si une collision de nom d'origine est détectée (FR-015) : résolution automatique silencieuse, ou question d'étiquetage manuel uniquement si `regine_core.camera_profile.resolve_collision` renvoie un groupe non résolu. |
| 17 | -5. Renommage local (FR-013/014) et attribution de l'identifiant pérenne (FR-017). | |
| 18 | -6. Résumé complet par groupe (fichiers, taille, dossier de destination avec répertoire racine) et confirmation explicite avant écriture sur l'archive (FR-018). | |
| 19 | -7. Transfert final vérifié (FR-019), puis publication d'une copie de travail locale identique (FR-010, `push.publier_copie_locale`) — prête pour un tri/une retouche immédiate, sans `regine checkout` séparé. | |
| 17 | +5. Renommage local (FR-013/014), classement par dossier de format `raw/`/`jpeg/`/`tiff/`... (FR-020, `import_carte.classement.classer_par_format`) et attribution de l'identifiant pérenne (FR-017). | |
| 18 | +6. Résumé complet par groupe (fichiers, taille, dossier de destination locale avec répertoire racine) et confirmation explicite avant toute écriture locale (FR-018) — **aucune écriture sur l'archive NAS à ce stade** (cf. Clarifications de `spec.md`, révision 2026-09-22). | |
| 19 | +7. Transfert final vérifié vers l'espace de travail local définitif (FR-019, `push.publier_localement`) — prêt pour un tri/une retouche immédiate. Rien n'est archivé : l'étape d'archivage est distincte et volontaire, cf. Sorties ci-dessous. | |
| 20 | 20 | |
| 21 | 21 | **Sorties** : |
| 22 | -- Succès : récapitulatif des dossiers archivés **et de la copie de travail locale correspondante** sur stdout, code `0`. Le dossier reste verrouillé côté archive à l'issue de l'import (comme après un checkout explicite) : un `regine reconcile` (même « à vide », sans changement) est nécessaire pour le libérer une fois le tri/la retouche locale terminée. | |
| 22 | +- Succès : récapitulatif des dossiers de travail locaux (classés par format) sur stdout, code `0`, accompagné de la commande à lancer plus tard pour archiver (`regine reconcile <dossier_archive> --local-dest <dossier_local>`) une fois la sélection terminée — ce dossier n'existe pas encore sur l'archive et n'est ni verrouillé ni référencé par aucun manifeste tant que cette commande n'a pas été lancée. | |
| 23 | 23 | - Échec de vérification d'un fichier (Edge Case lecture corrompue) : fichier signalé sur stderr, import interrompu pour ce fichier, carte non marquée sûre à effacer, code non-zéro. |
| 24 | 24 | - Espace disque insuffisant (Edge Case) : message clair avant toute copie, code non-zéro. |
| 25 | 25 | - Collision de nom de dossier (FR-012) : proposition de suffixe ou demande de confirmation sur stdout ; sans `--yes`, attend une réponse interactive. |
| 26 | -- **`--destination fusion:ID` ciblant un dossier présent uniquement dans l'archive (pas en local)** : la commande effectue d'abord un checkout de ce dossier (`regine_core.archive.checkout`, `specs/005-checkout-reconciliation`) avant d'y intégrer les nouveaux fichiers, puis suit le cycle normal de réconciliation pour le réarchivage — transparent pour l'utilisateur, hormis un temps de transfert réseau supplémentaire signalé sur stdout. Si ce dossier est déjà verrouillé par un autre checkout en cours, la commande échoue explicitement (cf. `specs/005-checkout-reconciliation/contracts/cli-checkout-reconcile.md`). | |
| 26 | +- **Partage NAS non monté ou inaccessible** : sans conséquence sur l'import (Edge Case, `spec.md`) — `--archive-root` n'a besoin d'être qu'un chemin valide, pas un chemin actuellement accessible, puisqu'aucune écriture n'y a lieu. | |
| 27 | +- **`--destination fusion:ID` ciblant un dossier présent uniquement dans l'archive (pas en local)** : la commande effectue d'abord un checkout de ce dossier (`regine_core.archive.checkout`, `specs/005-checkout-reconciliation`) avant d'y intégrer les nouveaux fichiers — ce cas particulier lit bien l'archive dès l'import (le dossier ciblé y existe déjà), contrairement au cas général ci-dessus. Si ce dossier est déjà verrouillé par un autre checkout en cours, la commande échoue explicitement (cf. `specs/005-checkout-reconciliation/contracts/cli-checkout-reconcile.md`). | |
| 27 | 28 | |
| 28 | 29 | ## Notes de scriptabilité |
| 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 | +- `--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 locale (FR-018 reste dû même en mode non interactif — nécessite `--yes` explicitement à ce niveau aussi, jamais implicite). | |
| 31 | 32 | - `--categorie` et `--annee` sont mutuellement exclusifs ; en leur absence en mode interactif, la question est posée normalement. |
| 32 | 33 | |
| 33 | 34 | ## Notes d'implémentation (post-US3) |
| @@ -35,10 +36,15 @@ regine import /Volumes/CARTE_SD [--titre TEXTE] [--destination nouveau|sous-doss | ||
| 35 | 36 | - `--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 | 37 | - 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 | 38 | - `--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. | |
| 39 | +- **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à présent localement 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.publier_localement` lève `CollisionNomLocalError` 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. | |
| 39 | 40 | |
| 40 | 41 | ## Notes d'implémentation (post-FR-010, 2026-09-19) |
| 41 | 42 | |
| 42 | 43 | - **Écart trouvé après relecture du README par l'utilisateur** : jusqu'ici, `regine import` ne laissait aucune copie de travail locale réellement exploitable après l'import (seulement un dossier temporaire à plat) — contrairement à FR-010, qui exige explicitement un chemin final construit « de façon identique dans la copie de travail locale et dans l'archive ». Il fallait un `regine checkout` séparé pour pouvoir continuer à travailler localement. |
| 43 | -- Corrigé : `regine import` publie désormais aussi une copie de travail locale (`push.publier_copie_locale`), en réutilisant le manifeste persistant et le verrouillage de `specs/005-checkout-reconciliation`. Le dossier archivé reste donc verrouillé à l'issue d'un import — la commande `regine reconcile` (cf. `specs/005-checkout-reconciliation/contracts/cli-checkout-reconcile.md`) devient l'étape normale pour le libérer, même sans aucun changement local (réconciliation « à vide »). | |
| 44 | -- Cas d'une `fusion:CHEMIN` vers un dossier déjà archivé (checkout automatique préalable, ligne ci-dessus) : la publication de la copie locale ne pose pas de second verrou (le checkout de `resoudre_fusion` l'a déjà posé) et n'écrase jamais un fichier local préexistant issu d'un import précédent non encore réconcilié — seuls les fichiers de l'import en cours sont copiés/ajoutés au manifeste. | |
| 44 | +- Corrigé (à l'époque) : `regine import` publiait alors aussi une copie de travail locale en plus de l'écriture immédiate sur l'archive, avec manifeste et verrou. **Ce comportement est remplacé par la révision ci-dessous (2026-09-22)** : l'import n'écrit plus du tout sur l'archive, donc ce manifeste/verrou immédiat n'existe plus à ce stade. | |
| 45 | + | |
| 46 | +## Notes d'implémentation (révision 2026-09-22 — archivage différé, classement par format) | |
| 47 | + | |
| 48 | +- **Écart trouvé par test manuel de la GUI (utilisateur)** : l'archivage immédiat en fin d'import ne laissait au photographe aucune occasion de relire/retoucher sa copie de travail avant l'écriture sur le NAS, et les fichiers importés n'étaient jamais classés dans un dossier de format (`raw/`/`jpeg/`/`tiff/`), contrairement à ce que `docs/archivage-photo-elements-cles.md` section 10 et l'écran de tri (`specs/006-gui-tri-consultation`) supposent déjà en place. Cf. Clarifications de `spec.md` pour le détail des deux décisions. | |
| 49 | +- `regine import` se termine désormais par `push.publier_localement` (renommée depuis `archiver`, plus jamais d'écriture NAS) précédée de `classement.classer_par_format` (nouveau, FR-020) — `push.publier_copie_locale` (manifeste + verrou immédiats) est supprimée : ce rôle est repris intégralement par `regine reconcile`, appelé plus tard et séparément par le photographe. Un dossier jamais encore archivé produit un manifeste vide au premier `regine reconcile` : chaque fichier local y est alors classé "nouveau", archivable sur confirmation explicite — comportement déjà existant de `regine_core.archive.reconciliation`, réutilisé sans modification. | |
| 50 | +- Cas d'une `fusion:CHEMIN` vers un dossier déjà archivé (checkout automatique préalable) : ce cas continue d'accéder au NAS dès l'import (lecture du dossier ciblé, déjà présent là-bas) — seule différence par rapport au cas général, documentée dans Sorties ci-dessus. La publication locale des nouveaux fichiers dans ce cas n'écrase jamais un fichier local préexistant issu d'un import précédent non encore réconcilié. | |
| @@ -14,20 +14,21 @@ regine import /Volumes/CARTE_SD [--titre TEXTE] [--destination nouveau|sous-doss | |||
| 14 | 2. Analyse des dates et proposition de groupe(s) (FR-002/003/005/006) — affiche la répartition jour par jour, invite à confirmer ou détacher des jours. | 14 | 2. Analyse des dates et proposition de groupe(s) (FR-002/003/005/006) — affiche la répartition jour par jour, invite à confirmer ou détacher des jours. |
| 15 | 3. Pour chaque groupe : demande la destination (FR-007), la catégorie/année si `nouveau_dossier`/`nouveau_parent` (avec suggestions `regine_core.config.categories.list_known_categories`/`suggest_categories`), puis le titre (FR-010). | 15 | 3. Pour chaque groupe : demande la destination (FR-007), la catégorie/année si `nouveau_dossier`/`nouveau_parent` (avec suggestions `regine_core.config.categories.list_known_categories`/`suggest_categories`), puis le titre (FR-010). |
| 16 | 4. Si une collision de nom d'origine est détectée (FR-015) : résolution automatique silencieuse, ou question d'étiquetage manuel uniquement si `regine_core.camera_profile.resolve_collision` renvoie un groupe non résolu. | 16 | 4. Si une collision de nom d'origine est détectée (FR-015) : résolution automatique silencieuse, ou question d'étiquetage manuel uniquement si `regine_core.camera_profile.resolve_collision` renvoie un groupe non résolu. |
| 17 | -5. Renommage local (FR-013/014) et attribution de l'identifiant pérenne (FR-017). | 17 | +5. Renommage local (FR-013/014), classement par dossier de format `raw/`/`jpeg/`/`tiff/`... (FR-020, `import_carte.classement.classer_par_format`) et attribution de l'identifiant pérenne (FR-017). |
| 18 | -6. Résumé complet par groupe (fichiers, taille, dossier de destination avec répertoire racine) et confirmation explicite avant écriture sur l'archive (FR-018). | 18 | +6. Résumé complet par groupe (fichiers, taille, dossier de destination locale avec répertoire racine) et confirmation explicite avant toute écriture locale (FR-018) — **aucune écriture sur l'archive NAS à ce stade** (cf. Clarifications de `spec.md`, révision 2026-09-22). |
| 19 | -7. Transfert final vérifié (FR-019), puis publication d'une copie de travail locale identique (FR-010, `push.publier_copie_locale`) — prête pour un tri/une retouche immédiate, sans `regine checkout` séparé. | 19 | +7. Transfert final vérifié vers l'espace de travail local définitif (FR-019, `push.publier_localement`) — prêt pour un tri/une retouche immédiate. Rien n'est archivé : l'étape d'archivage est distincte et volontaire, cf. Sorties ci-dessous. |
| 20 | 20 | ||
| 21 | **Sorties** : | 21 | **Sorties** : |
| 22 | -- Succès : récapitulatif des dossiers archivés **et de la copie de travail locale correspondante** sur stdout, code `0`. Le dossier reste verrouillé côté archive à l'issue de l'import (comme après un checkout explicite) : un `regine reconcile` (même « à vide », sans changement) est nécessaire pour le libérer une fois le tri/la retouche locale terminée. | 22 | +- Succès : récapitulatif des dossiers de travail locaux (classés par format) sur stdout, code `0`, accompagné de la commande à lancer plus tard pour archiver (`regine reconcile <dossier_archive> --local-dest <dossier_local>`) une fois la sélection terminée — ce dossier n'existe pas encore sur l'archive et n'est ni verrouillé ni référencé par aucun manifeste tant que cette commande n'a pas été lancée. |
| 23 | - Échec de vérification d'un fichier (Edge Case lecture corrompue) : fichier signalé sur stderr, import interrompu pour ce fichier, carte non marquée sûre à effacer, code non-zéro. | 23 | - Échec de vérification d'un fichier (Edge Case lecture corrompue) : fichier signalé sur stderr, import interrompu pour ce fichier, carte non marquée sûre à effacer, code non-zéro. |
| 24 | - Espace disque insuffisant (Edge Case) : message clair avant toute copie, code non-zéro. | 24 | - Espace disque insuffisant (Edge Case) : message clair avant toute copie, code non-zéro. |
| 25 | - Collision de nom de dossier (FR-012) : proposition de suffixe ou demande de confirmation sur stdout ; sans `--yes`, attend une réponse interactive. | 25 | - Collision de nom de dossier (FR-012) : proposition de suffixe ou demande de confirmation sur stdout ; sans `--yes`, attend une réponse interactive. |
| 26 | -- **`--destination fusion:ID` ciblant un dossier présent uniquement dans l'archive (pas en local)** : la commande effectue d'abord un checkout de ce dossier (`regine_core.archive.checkout`, `specs/005-checkout-reconciliation`) avant d'y intégrer les nouveaux fichiers, puis suit le cycle normal de réconciliation pour le réarchivage — transparent pour l'utilisateur, hormis un temps de transfert réseau supplémentaire signalé sur stdout. Si ce dossier est déjà verrouillé par un autre checkout en cours, la commande échoue explicitement (cf. `specs/005-checkout-reconciliation/contracts/cli-checkout-reconcile.md`). | 26 | +- **Partage NAS non monté ou inaccessible** : sans conséquence sur l'import (Edge Case, `spec.md`) — `--archive-root` n'a besoin d'être qu'un chemin valide, pas un chemin actuellement accessible, puisqu'aucune écriture n'y a lieu. |
| 27 | +- **`--destination fusion:ID` ciblant un dossier présent uniquement dans l'archive (pas en local)** : la commande effectue d'abord un checkout de ce dossier (`regine_core.archive.checkout`, `specs/005-checkout-reconciliation`) avant d'y intégrer les nouveaux fichiers — ce cas particulier lit bien l'archive dès l'import (le dossier ciblé y existe déjà), contrairement au cas général ci-dessus. Si ce dossier est déjà verrouillé par un autre checkout en cours, la commande échoue explicitement (cf. `specs/005-checkout-reconciliation/contracts/cli-checkout-reconcile.md`). | ||
| 27 | 28 | ||
| 28 | ## Notes de scriptabilité | 29 | ## Notes de scriptabilité |
| 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 | +- `--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 locale (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. | 32 | - `--categorie` et `--annee` sont mutuellement exclusifs ; en leur absence en mode interactif, la question est posée normalement. |
| 32 | 33 | ||
| 33 | ## Notes d'implémentation (post-US3) | 34 | ## Notes d'implémentation (post-US3) |
| @@ -35,10 +36,15 @@ regine import /Volumes/CARTE_SD [--titre TEXTE] [--destination nouveau|sous-doss | |||
| 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 | - `--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 | - 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 | - `--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. | 39 | +- **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à présent localement 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.publier_localement` lève `CollisionNomLocalError` 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. |
| 39 | 40 | ||
| 40 | ## Notes d'implémentation (post-FR-010, 2026-09-19) | 41 | ## Notes d'implémentation (post-FR-010, 2026-09-19) |
| 41 | 42 | ||
| 42 | - **Écart trouvé après relecture du README par l'utilisateur** : jusqu'ici, `regine import` ne laissait aucune copie de travail locale réellement exploitable après l'import (seulement un dossier temporaire à plat) — contrairement à FR-010, qui exige explicitement un chemin final construit « de façon identique dans la copie de travail locale et dans l'archive ». Il fallait un `regine checkout` séparé pour pouvoir continuer à travailler localement. | 43 | - **Écart trouvé après relecture du README par l'utilisateur** : jusqu'ici, `regine import` ne laissait aucune copie de travail locale réellement exploitable après l'import (seulement un dossier temporaire à plat) — contrairement à FR-010, qui exige explicitement un chemin final construit « de façon identique dans la copie de travail locale et dans l'archive ». Il fallait un `regine checkout` séparé pour pouvoir continuer à travailler localement. |
| 43 | -- Corrigé : `regine import` publie désormais aussi une copie de travail locale (`push.publier_copie_locale`), en réutilisant le manifeste persistant et le verrouillage de `specs/005-checkout-reconciliation`. Le dossier archivé reste donc verrouillé à l'issue d'un import — la commande `regine reconcile` (cf. `specs/005-checkout-reconciliation/contracts/cli-checkout-reconcile.md`) devient l'étape normale pour le libérer, même sans aucun changement local (réconciliation « à vide »). | 44 | +- Corrigé (à l'époque) : `regine import` publiait alors aussi une copie de travail locale en plus de l'écriture immédiate sur l'archive, avec manifeste et verrou. **Ce comportement est remplacé par la révision ci-dessous (2026-09-22)** : l'import n'écrit plus du tout sur l'archive, donc ce manifeste/verrou immédiat n'existe plus à ce stade. |
| 44 | -- Cas d'une `fusion:CHEMIN` vers un dossier déjà archivé (checkout automatique préalable, ligne ci-dessus) : la publication de la copie locale ne pose pas de second verrou (le checkout de `resoudre_fusion` l'a déjà posé) et n'écrase jamais un fichier local préexistant issu d'un import précédent non encore réconcilié — seuls les fichiers de l'import en cours sont copiés/ajoutés au manifeste. | 45 | + |
| 46 | +## Notes d'implémentation (révision 2026-09-22 — archivage différé, classement par format) | ||
| 47 | + | ||
| 48 | +- **Écart trouvé par test manuel de la GUI (utilisateur)** : l'archivage immédiat en fin d'import ne laissait au photographe aucune occasion de relire/retoucher sa copie de travail avant l'écriture sur le NAS, et les fichiers importés n'étaient jamais classés dans un dossier de format (`raw/`/`jpeg/`/`tiff/`), contrairement à ce que `docs/archivage-photo-elements-cles.md` section 10 et l'écran de tri (`specs/006-gui-tri-consultation`) supposent déjà en place. Cf. Clarifications de `spec.md` pour le détail des deux décisions. | ||
| 49 | +- `regine import` se termine désormais par `push.publier_localement` (renommée depuis `archiver`, plus jamais d'écriture NAS) précédée de `classement.classer_par_format` (nouveau, FR-020) — `push.publier_copie_locale` (manifeste + verrou immédiats) est supprimée : ce rôle est repris intégralement par `regine reconcile`, appelé plus tard et séparément par le photographe. Un dossier jamais encore archivé produit un manifeste vide au premier `regine reconcile` : chaque fichier local y est alors classé "nouveau", archivable sur confirmation explicite — comportement déjà existant de `regine_core.archive.reconciliation`, réutilisé sans modification. | ||
| 50 | +- Cas d'une `fusion:CHEMIN` vers un dossier déjà archivé (checkout automatique préalable) : ce cas continue d'accéder au NAS dès l'import (lecture du dossier ciblé, déjà présent là-bas) — seule différence par rapport au cas général, documentée dans Sorties ci-dessus. La publication locale des nouveaux fichiers dans ce cas n'écrase jamais un fichier local préexistant issu d'un import précédent non encore réconcilié. | ||
modified
specs/001-import-photos/contracts/regine-core-api.md +13 -12 | @@ -42,30 +42,31 @@ Renomme chaque fichier maître (`date_titre_nomOrigine.ext`, FR-013) et ses fich | ||
| 42 | 42 | |
| 43 | 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. |
| 44 | 44 | |
| 45 | -## `push.preparer_resume(fichiers: list[Path], dossier_destination: Path) -> ResumeConfirmation` | |
| 46 | - | |
| 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). | |
| 45 | +## `classement.classer_par_format(fichiers: list[FichierCandidat], racine: Path) -> None` | |
| 48 | 46 | |
| 49 | -## `push.archiver(fichiers_locaux: list[Path], dossier_local_racine: Path, dossier_destination: Path) -> None` | |
| 47 | +Déplace en place (mutation de `chemin_source`) chaque fichier maître dans son dossier de format, créé à la demande (FR-020, `raw`/`jpeg`/`tiff`/... via `copie.dossier_de_format`, réutilisée telle quelle plutôt que dupliquée — cf. `specs/006-gui-tri-consultation`). Un fichier maître utilise toujours son propre dossier de format (jamais celui d'un autre maître de même nom de base, cas RAW+JPEG jumeau). Un fichier associé (sidecar) rejoint le dossier du maître RAW partageant son nom de base s'il y en a un, sinon celui de n'importe quel autre maître de même nom de base. Doit être appelée après `nommage.renommer_fichiers` et avant `push.preparer_resume`/`push.publier_localement`. | |
| 50 | 48 | |
| 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. | |
| 49 | +## `push.preparer_resume(fichiers: list[Path], dossier_destination: Path) -> ResumeConfirmation` | |
| 52 | 50 | |
| 53 | -## `push.publier_copie_locale(fichiers_locaux: list[Path], dossier_local_racine: Path, dossier_local: Path, dossier_archive: Path) -> None` | |
| 51 | +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). `dossier_destination` désigne désormais le dossier de l'espace de travail local (cf. `push.publier_localement`), jamais l'archive. | |
| 54 | 52 | |
| 55 | -Laisse une copie de travail locale des fichiers qui viennent d'être archivés (FR-010 : « de façon identique dans la copie de travail locale et dans l'archive »), pour permettre de trier/retoucher immédiatement après l'import, sans `regine checkout` séparé. Ne DOIT être appelée qu'après un `archiver()` réussi vers `dossier_archive`. | |
| 53 | +## `push.publier_localement(fichiers_locaux: list[Path], dossier_local_racine: Path, dossier_destination: Path) -> None` | |
| 56 | 54 | |
| 57 | -Réutilise le manifeste persistant et le verrouillage de `regine_core.archive` (`specs/005-checkout-reconciliation`) plutôt qu'un mécanisme parallèle (Principe VI) : ouvre/crée le manifeste de `dossier_archive`, y enregistre chaque fichier (même schéma que `checkout.checkout`), et pose le verrou s'il n'est pas déjà posé (cas d'une fusion ayant déclenché un checkout juste avant via `destination.resoudre_fusion` — jamais un second verrou). Laisse volontairement le dossier verrouillé en sortie : l'utilisateur travaille maintenant sur une copie locale, exactement comme après un checkout explicite, et devra passer par `regine_core.archive.reconciliation` (même une réconciliation « à vide ») pour le libérer. | |
| 55 | +**Renommée depuis `push.archiver` (révision 2026-09-22, cf. Clarifications de `spec.md`)** : transfert final vérifié depuis la copie de travail temporaire déjà renommée et classée par format (FR-019), vers l'espace de travail local — jamais vers l'archive NAS. Ne demande pas elle-même confirmation, c'est la responsabilité de l'appelant (façade CLI/GUI, 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 `CollisionNomLocalError` (renommée depuis `CollisionNomArchiveError`, même sémantique) plutôt que d'écraser silencieusement (FR-012 appliqué au niveau fichier, pas seulement dossier). Lève `EchecTransfertError` en cas d'échec de vérification d'intégrité après copie. | |
| 58 | 56 | |
| 59 | -Contrairement à `checkout.checkout` (qui recopie l'intégralité du dossier depuis l'archive et écraserait donc un fichier local déjà en cours d'édition), ne copie que les fichiers de cet import et respecte la même protection anti-écrasement que `archiver` (`CollisionNomArchiveError`) — un fichier local déjà présent et non concerné par cet import n'est jamais touché. | |
| 57 | +`push.publier_copie_locale` (manifeste + verrou immédiats sur l'archive, appelée juste après `archiver`) est **supprimée** : ce rôle est repris intégralement, et sans aucune modification requise, par `regine_core.archive.reconciliation` au moment où le photographe déclenche `regine reconcile` — cf. `specs/005-checkout-reconciliation/spec.md` § Assumptions (révisé le 2026-09-22). | |
| 60 | 58 | |
| 61 | 59 | ## Notes d'implémentation |
| 62 | 60 | |
| 63 | -- **Écart avec FR-010 trouvé après relecture du README par l'utilisateur (2026-09-19)** : `push.archiver` ne transférait les fichiers que vers l'archive, jamais vers la copie de travail locale — contrairement à FR-010, qui exige un chemin final construit « de façon identique dans la copie de travail locale et dans l'archive ». Après un import, aucune copie locale structurée n'existait réellement (seulement un dossier temporaire à plat, `local_tmp`, jamais nettoyé), obligeant à un `regine checkout` séparé pour retravailler localement. Corrigé par l'ajout de `push.publier_copie_locale`, appelée par `regine_cli.import_cmd` juste après `archiver`. | |
| 61 | +- **Écart avec FR-010 trouvé après relecture du README par l'utilisateur (2026-09-19)** : `push.archiver` ne transférait les fichiers que vers l'archive, jamais vers la copie de travail locale — contrairement à FR-010, qui exige un chemin final construit « de façon identique dans la copie de travail locale et dans l'archive ». Après un import, aucune copie locale structurée n'existait réellement (seulement un dossier temporaire à plat, `local_tmp`, jamais nettoyé), obligeant à un `regine checkout` séparé pour retravailler localement. Corrigé à l'époque par l'ajout de `push.publier_copie_locale` — **ce correctif est lui-même remplacé par la révision du 2026-09-22 ci-dessous**, qui supprime l'écriture immédiate sur l'archive plutôt que de la dupliquer vers le local. | |
| 64 | 62 | |
| 65 | -- **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`. | |
| 66 | -- **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). | |
| 63 | +- **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 (comportement conservé à l'identique dans `push.publier_localement`, seule la cible a changé — local plutôt qu'archive). | |
| 64 | +- **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.publier_localement` (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). | |
| 65 | +- **Révision 2026-09-22 (archivage différé, cf. Clarifications de `spec.md`)** : `push.archiver`/`push.publier_copie_locale` (écriture NAS + manifeste + verrou immédiats) remplacées par `push.publier_localement` (copie locale uniquement, jamais de manifeste ni de verrou à l'import) et la nouvelle étape `classement.classer_par_format` (FR-020). Écart trouvé par test manuel de l'onglet d'import GUI : (1) le photographe n'avait aucune occasion de relire sa sélection avant l'écriture NAS ; (2) les fichiers n'étaient jamais classés par format, contrairement à `docs/archivage-photo-elements-cles.md` section 10 et à l'écran de tri (`specs/006`). | |
| 67 | 66 | |
| 68 | 67 | ## Dépendances résolues |
| 69 | 68 | |
| 70 | 69 | - 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. |
| 71 | 70 | - La désambiguïsation de boîtiers (FR-015/016) consomme `regine_core.camera_profile` (`specs/002-profil-boitiers-optionnel`). |
| 71 | +- **Depuis la révision 2026-09-22** : l'archivage effectif de tout dossier produit par ce module (qu'il s'agisse du tout premier archivage ou d'une réédition ultérieure) consomme entièrement `regine_core.archive.reconciliation` (`regine reconcile`, `specs/005-checkout-reconciliation`), sans aucune modification requise côté `specs/005` — cf. `specs/005-checkout-reconciliation/spec.md` § Assumptions. | |
| 72 | +- Le classement par format (FR-020) consomme `copie.dossier_de_format`, partagée avec `regine_core.dossier.selection` (`specs/006-gui-tri-consultation`) — définition canonique désormais dans `copie.py`, ré-exportée depuis `dossier.selection` pour compatibilité. | |
| @@ -42,30 +42,31 @@ Renomme chaque fichier maître (`date_titre_nomOrigine.ext`, FR-013) et ses fich | |||
| 42 | 42 | ||
| 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. | 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. |
| 44 | 44 | ||
| 45 | -## `push.preparer_resume(fichiers: list[Path], dossier_destination: Path) -> ResumeConfirmation` | 45 | +## `classement.classer_par_format(fichiers: list[FichierCandidat], racine: Path) -> None` |
| 46 | - | ||
| 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). | ||
| 48 | 46 | ||
| 49 | -## `push.archiver(fichiers_locaux: list[Path], dossier_local_racine: Path, dossier_destination: Path) -> None` | 47 | +Déplace en place (mutation de `chemin_source`) chaque fichier maître dans son dossier de format, créé à la demande (FR-020, `raw`/`jpeg`/`tiff`/... via `copie.dossier_de_format`, réutilisée telle quelle plutôt que dupliquée — cf. `specs/006-gui-tri-consultation`). Un fichier maître utilise toujours son propre dossier de format (jamais celui d'un autre maître de même nom de base, cas RAW+JPEG jumeau). Un fichier associé (sidecar) rejoint le dossier du maître RAW partageant son nom de base s'il y en a un, sinon celui de n'importe quel autre maître de même nom de base. Doit être appelée après `nommage.renommer_fichiers` et avant `push.preparer_resume`/`push.publier_localement`. |
| 50 | 48 | ||
| 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. | 49 | +## `push.preparer_resume(fichiers: list[Path], dossier_destination: Path) -> ResumeConfirmation` |
| 52 | 50 | ||
| 53 | -## `push.publier_copie_locale(fichiers_locaux: list[Path], dossier_local_racine: Path, dossier_local: Path, dossier_archive: Path) -> None` | 51 | +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). `dossier_destination` désigne désormais le dossier de l'espace de travail local (cf. `push.publier_localement`), jamais l'archive. |
| 54 | 52 | ||
| 55 | -Laisse une copie de travail locale des fichiers qui viennent d'être archivés (FR-010 : « de façon identique dans la copie de travail locale et dans l'archive »), pour permettre de trier/retoucher immédiatement après l'import, sans `regine checkout` séparé. Ne DOIT être appelée qu'après un `archiver()` réussi vers `dossier_archive`. | 53 | +## `push.publier_localement(fichiers_locaux: list[Path], dossier_local_racine: Path, dossier_destination: Path) -> None` |
| 56 | 54 | ||
| 57 | -Réutilise le manifeste persistant et le verrouillage de `regine_core.archive` (`specs/005-checkout-reconciliation`) plutôt qu'un mécanisme parallèle (Principe VI) : ouvre/crée le manifeste de `dossier_archive`, y enregistre chaque fichier (même schéma que `checkout.checkout`), et pose le verrou s'il n'est pas déjà posé (cas d'une fusion ayant déclenché un checkout juste avant via `destination.resoudre_fusion` — jamais un second verrou). Laisse volontairement le dossier verrouillé en sortie : l'utilisateur travaille maintenant sur une copie locale, exactement comme après un checkout explicite, et devra passer par `regine_core.archive.reconciliation` (même une réconciliation « à vide ») pour le libérer. | 55 | +**Renommée depuis `push.archiver` (révision 2026-09-22, cf. Clarifications de `spec.md`)** : transfert final vérifié depuis la copie de travail temporaire déjà renommée et classée par format (FR-019), vers l'espace de travail local — jamais vers l'archive NAS. Ne demande pas elle-même confirmation, c'est la responsabilité de l'appelant (façade CLI/GUI, 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 `CollisionNomLocalError` (renommée depuis `CollisionNomArchiveError`, même sémantique) plutôt que d'écraser silencieusement (FR-012 appliqué au niveau fichier, pas seulement dossier). Lève `EchecTransfertError` en cas d'échec de vérification d'intégrité après copie. |
| 58 | 56 | ||
| 59 | -Contrairement à `checkout.checkout` (qui recopie l'intégralité du dossier depuis l'archive et écraserait donc un fichier local déjà en cours d'édition), ne copie que les fichiers de cet import et respecte la même protection anti-écrasement que `archiver` (`CollisionNomArchiveError`) — un fichier local déjà présent et non concerné par cet import n'est jamais touché. | 57 | +`push.publier_copie_locale` (manifeste + verrou immédiats sur l'archive, appelée juste après `archiver`) est **supprimée** : ce rôle est repris intégralement, et sans aucune modification requise, par `regine_core.archive.reconciliation` au moment où le photographe déclenche `regine reconcile` — cf. `specs/005-checkout-reconciliation/spec.md` § Assumptions (révisé le 2026-09-22). |
| 60 | 58 | ||
| 61 | ## Notes d'implémentation | 59 | ## Notes d'implémentation |
| 62 | 60 | ||
| 63 | -- **Écart avec FR-010 trouvé après relecture du README par l'utilisateur (2026-09-19)** : `push.archiver` ne transférait les fichiers que vers l'archive, jamais vers la copie de travail locale — contrairement à FR-010, qui exige un chemin final construit « de façon identique dans la copie de travail locale et dans l'archive ». Après un import, aucune copie locale structurée n'existait réellement (seulement un dossier temporaire à plat, `local_tmp`, jamais nettoyé), obligeant à un `regine checkout` séparé pour retravailler localement. Corrigé par l'ajout de `push.publier_copie_locale`, appelée par `regine_cli.import_cmd` juste après `archiver`. | 61 | +- **Écart avec FR-010 trouvé après relecture du README par l'utilisateur (2026-09-19)** : `push.archiver` ne transférait les fichiers que vers l'archive, jamais vers la copie de travail locale — contrairement à FR-010, qui exige un chemin final construit « de façon identique dans la copie de travail locale et dans l'archive ». Après un import, aucune copie locale structurée n'existait réellement (seulement un dossier temporaire à plat, `local_tmp`, jamais nettoyé), obligeant à un `regine checkout` séparé pour retravailler localement. Corrigé à l'époque par l'ajout de `push.publier_copie_locale` — **ce correctif est lui-même remplacé par la révision du 2026-09-22 ci-dessous**, qui supprime l'écriture immédiate sur l'archive plutôt que de la dupliquer vers le local. |
| 64 | 62 | ||
| 65 | -- **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`. | 63 | +- **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 (comportement conservé à l'identique dans `push.publier_localement`, seule la cible a changé — local plutôt qu'archive). |
| 66 | -- **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). | 64 | +- **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.publier_localement` (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). |
| 65 | +- **Révision 2026-09-22 (archivage différé, cf. Clarifications de `spec.md`)** : `push.archiver`/`push.publier_copie_locale` (écriture NAS + manifeste + verrou immédiats) remplacées par `push.publier_localement` (copie locale uniquement, jamais de manifeste ni de verrou à l'import) et la nouvelle étape `classement.classer_par_format` (FR-020). Écart trouvé par test manuel de l'onglet d'import GUI : (1) le photographe n'avait aucune occasion de relire sa sélection avant l'écriture NAS ; (2) les fichiers n'étaient jamais classés par format, contrairement à `docs/archivage-photo-elements-cles.md` section 10 et à l'écran de tri (`specs/006`). | ||
| 67 | 66 | ||
| 68 | ## Dépendances résolues | 67 | ## Dépendances résolues |
| 69 | 68 | ||
| 70 | - 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. | 69 | - 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. |
| 71 | - La désambiguïsation de boîtiers (FR-015/016) consomme `regine_core.camera_profile` (`specs/002-profil-boitiers-optionnel`). | 70 | - La désambiguïsation de boîtiers (FR-015/016) consomme `regine_core.camera_profile` (`specs/002-profil-boitiers-optionnel`). |
| 71 | +- **Depuis la révision 2026-09-22** : l'archivage effectif de tout dossier produit par ce module (qu'il s'agisse du tout premier archivage ou d'une réédition ultérieure) consomme entièrement `regine_core.archive.reconciliation` (`regine reconcile`, `specs/005-checkout-reconciliation`), sans aucune modification requise côté `specs/005` — cf. `specs/005-checkout-reconciliation/spec.md` § Assumptions. | ||
| 72 | +- Le classement par format (FR-020) consomme `copie.dossier_de_format`, partagée avec `regine_core.dossier.selection` (`specs/006-gui-tri-consultation`) — définition canonique désormais dans `copie.py`, ré-exportée depuis `dossier.selection` pour compatibilité. | ||
modified
specs/001-import-photos/data-model.md +25 -5 | @@ -31,6 +31,15 @@ Entités dérivées de `spec.md` § Key Entities et Functional Requirements. Ré | ||
| 31 | 31 | | `root_location` | `RootLocation \| None` | Résolu via `regine_core.dossier.root.determine_root` (specs/004) uniquement si `type in {nouveau_dossier, nouveau_parent}` ; hérité du parent sinon (FR-006 de specs/004) | |
| 32 | 32 | | `necessite_checkout_archive` | bool | `True` si `type == fusion` et `dossier_cible` n'existe qu'archivé, pas en local (FR-009) — déclenche un appel à `regine_core.archive.checkout` (`specs/005-checkout-reconciliation`) avant d'intégrer les nouveaux fichiers | |
| 33 | 33 | |
| 34 | +## Dossier de format | |
| 35 | + | |
| 36 | +Créé à la demande lors du classement (FR-020), pas une entité stockée — un simple sous-répertoire résultant du déplacement d'un fichier maître déjà renommé. | |
| 37 | + | |
| 38 | +| Champ | Type | Règles | | |
| 39 | +|---|---|---| | |
| 40 | +| `nom` | str | `raw`, `jpeg`, `tiff`, ou le nom de l'extension elle-même pour un format maître marginal — cf. `regine_core.import_carte.copie.dossier_de_format` (partagée avec `specs/006-gui-tri-consultation`, jamais redéfinie ici) | | |
| 41 | +| `fichiers` | `list[FichierCandidat]` | Fichiers maîtres de ce format, plus leurs fichiers associés (même dossier que leur maître, cf. `classement.py`) | | |
| 42 | + | |
| 34 | 43 | ## Renommage |
| 35 | 44 | |
| 36 | 45 | | Champ | Type | Règles | |
| @@ -58,7 +67,14 @@ Carte mémoire ──▶ FichierCandidat (checksum, date) ──▶ Groupe d'imp | ||
| 58 | 67 | Renommage (par fichier maître + fichiers liés) |
| 59 | 68 | │ |
| 60 | 69 | ▼ |
| 61 | - Identifiant pérenne (écrit en XMP) ──▶ Confirmation ──▶ Archive | |
| 70 | + Dossier de format (classement, FR-020) | |
| 71 | + │ | |
| 72 | + ▼ | |
| 73 | + Identifiant pérenne (écrit en XMP) ──▶ Confirmation ──▶ Copie de travail locale | |
| 74 | + │ | |
| 75 | + (plus tard, séparément) | |
| 76 | + ▼ | |
| 77 | + regine reconcile ──▶ Archive | |
| 62 | 78 | ``` |
| 63 | 79 | |
| 64 | 80 | Collision de nom d'origine entre deux `FichierCandidat` de même `nom_origine` mais `checksum` différent → déléguée à `regine_core.camera_profile.resolve_collision` (`specs/002-profil-boitiers-optionnel`), pas redéfinie ici. |
| @@ -73,11 +89,15 @@ Collision de nom d'origine entre deux `FichierCandidat` de même `nom_origine` m | ||
| 73 | 89 | Découpé en sous-groupes (chacun redevient "Proposé") |
| 74 | 90 | │ utilisateur saisit titre + destination (+ catégorie si nouveau_dossier/nouveau_parent) |
| 75 | 91 | ▼ |
| 76 | - Destination résolue (RootLocation déterminé ou hérité) | |
| 77 | - │ renommage local (FR-013/014) + identifiant pérenne (FR-017) | |
| 92 | + Destination résolue (RootLocation déterminé ou hérité — chemin d'archive calculé, pas encore écrit) | |
| 93 | + │ renommage local (FR-013/014) + classement par format (FR-020) + identifiant pérenne (FR-017) | |
| 78 | 94 | ▼ |
| 79 | - Prêt pour archivage (résumé affiché, FR-018) | |
| 95 | + Prêt pour publication locale (résumé affiché, FR-018) | |
| 80 | 96 | │ confirmation explicite de l'utilisateur |
| 81 | 97 | ▼ |
| 82 | - Archivé (FR-019) | |
| 98 | + Copie de travail locale (FR-019) — dossier terminal de l'import | |
| 99 | + │ (hors périmètre de ce module) sélection/retouche par le photographe, | |
| 100 | + │ puis `regine reconcile` déclenché explicitement quand prêt | |
| 101 | + ▼ | |
| 102 | + Archivé (specs/005-checkout-reconciliation) | |
| 83 | 103 | ``` |
| @@ -31,6 +31,15 @@ Entités dérivées de `spec.md` § Key Entities et Functional Requirements. Ré | |||
| 31 | | `root_location` | `RootLocation \| None` | Résolu via `regine_core.dossier.root.determine_root` (specs/004) uniquement si `type in {nouveau_dossier, nouveau_parent}` ; hérité du parent sinon (FR-006 de specs/004) | | 31 | | `root_location` | `RootLocation \| None` | Résolu via `regine_core.dossier.root.determine_root` (specs/004) uniquement si `type in {nouveau_dossier, nouveau_parent}` ; hérité du parent sinon (FR-006 de specs/004) | |
| 32 | | `necessite_checkout_archive` | bool | `True` si `type == fusion` et `dossier_cible` n'existe qu'archivé, pas en local (FR-009) — déclenche un appel à `regine_core.archive.checkout` (`specs/005-checkout-reconciliation`) avant d'intégrer les nouveaux fichiers | | 32 | | `necessite_checkout_archive` | bool | `True` si `type == fusion` et `dossier_cible` n'existe qu'archivé, pas en local (FR-009) — déclenche un appel à `regine_core.archive.checkout` (`specs/005-checkout-reconciliation`) avant d'intégrer les nouveaux fichiers | |
| 33 | 33 | ||
| 34 | +## Dossier de format | ||
| 35 | + | ||
| 36 | +Créé à la demande lors du classement (FR-020), pas une entité stockée — un simple sous-répertoire résultant du déplacement d'un fichier maître déjà renommé. | ||
| 37 | + | ||
| 38 | +| Champ | Type | Règles | | ||
| 39 | +|---|---|---| | ||
| 40 | +| `nom` | str | `raw`, `jpeg`, `tiff`, ou le nom de l'extension elle-même pour un format maître marginal — cf. `regine_core.import_carte.copie.dossier_de_format` (partagée avec `specs/006-gui-tri-consultation`, jamais redéfinie ici) | | ||
| 41 | +| `fichiers` | `list[FichierCandidat]` | Fichiers maîtres de ce format, plus leurs fichiers associés (même dossier que leur maître, cf. `classement.py`) | | ||
| 42 | + | ||
| 34 | ## Renommage | 43 | ## Renommage |
| 35 | 44 | ||
| 36 | | Champ | Type | Règles | | 45 | | Champ | Type | Règles | |
| @@ -58,7 +67,14 @@ Carte mémoire ──▶ FichierCandidat (checksum, date) ──▶ Groupe d'imp | |||
| 58 | Renommage (par fichier maître + fichiers liés) | 67 | Renommage (par fichier maître + fichiers liés) |
| 59 | │ | 68 | │ |
| 60 | ▼ | 69 | ▼ |
| 61 | - Identifiant pérenne (écrit en XMP) ──▶ Confirmation ──▶ Archive | 70 | + Dossier de format (classement, FR-020) |
| 71 | + │ | ||
| 72 | + ▼ | ||
| 73 | + Identifiant pérenne (écrit en XMP) ──▶ Confirmation ──▶ Copie de travail locale | ||
| 74 | + │ | ||
| 75 | + (plus tard, séparément) | ||
| 76 | + ▼ | ||
| 77 | + regine reconcile ──▶ Archive | ||
| 62 | ``` | 78 | ``` |
| 63 | 79 | ||
| 64 | Collision de nom d'origine entre deux `FichierCandidat` de même `nom_origine` mais `checksum` différent → déléguée à `regine_core.camera_profile.resolve_collision` (`specs/002-profil-boitiers-optionnel`), pas redéfinie ici. | 80 | Collision de nom d'origine entre deux `FichierCandidat` de même `nom_origine` mais `checksum` différent → déléguée à `regine_core.camera_profile.resolve_collision` (`specs/002-profil-boitiers-optionnel`), pas redéfinie ici. |
| @@ -73,11 +89,15 @@ Collision de nom d'origine entre deux `FichierCandidat` de même `nom_origine` m | |||
| 73 | Découpé en sous-groupes (chacun redevient "Proposé") | 89 | Découpé en sous-groupes (chacun redevient "Proposé") |
| 74 | │ utilisateur saisit titre + destination (+ catégorie si nouveau_dossier/nouveau_parent) | 90 | │ utilisateur saisit titre + destination (+ catégorie si nouveau_dossier/nouveau_parent) |
| 75 | ▼ | 91 | ▼ |
| 76 | - Destination résolue (RootLocation déterminé ou hérité) | 92 | + Destination résolue (RootLocation déterminé ou hérité — chemin d'archive calculé, pas encore écrit) |
| 77 | - │ renommage local (FR-013/014) + identifiant pérenne (FR-017) | 93 | + │ renommage local (FR-013/014) + classement par format (FR-020) + identifiant pérenne (FR-017) |
| 78 | ▼ | 94 | ▼ |
| 79 | - Prêt pour archivage (résumé affiché, FR-018) | 95 | + Prêt pour publication locale (résumé affiché, FR-018) |
| 80 | │ confirmation explicite de l'utilisateur | 96 | │ confirmation explicite de l'utilisateur |
| 81 | ▼ | 97 | ▼ |
| 82 | - Archivé (FR-019) | 98 | + Copie de travail locale (FR-019) — dossier terminal de l'import |
| 99 | + │ (hors périmètre de ce module) sélection/retouche par le photographe, | ||
| 100 | + │ puis `regine reconcile` déclenché explicitement quand prêt | ||
| 101 | + ▼ | ||
| 102 | + Archivé (specs/005-checkout-reconciliation) | ||
| 83 | ``` | 103 | ``` |
modified
specs/001-import-photos/quickstart.md +7 -1 | @@ -14,7 +14,13 @@ Ce guide valide les 3 user stories de `spec.md` via la commande `regine import` | ||
| 14 | 14 | regine import ./fixtures/carte_journee_unique --titre "Sortie parc" --annee --yes |
| 15 | 15 | ``` |
| 16 | 16 | |
| 17 | -**Résultat attendu** : un seul groupe proposé (une journée), dossier créé sous `<archive>/<AAAA>/<AAAA-MM-JJ>_Sortie-parc/`, fichiers renommés `<AAAA-MM-JJ>_Sortie-parc_<nomOrigine>.ext`, résumé affiché puis archivage confirmé (`--yes`). Vérifier que la carte source n'a nécessité qu'une seule lecture par fichier (SC-001). | |
| 17 | +**Résultat attendu** : un seul groupe proposé (une journée), dossier créé sous `<local_work>/<AAAA>/<AAAA-MM-JJ>_Sortie-parc/`, fichiers renommés `<AAAA-MM-JJ>_Sortie-parc_<nomOrigine>.ext` et classés dans leur dossier de format (`raw/`, `jpeg/`...), résumé affiché puis publication locale confirmée (`--yes`). Vérifier que la carte source n'a nécessité qu'une seule lecture par fichier (SC-001), et qu'**aucun dossier n'apparaît sous `<archive>`** à l'issue de cette commande (SC-006/SC-010) — la sortie stdout indique la commande `regine reconcile` à lancer plus tard pour archiver. | |
| 18 | + | |
| 19 | +```bash | |
| 20 | +regine reconcile <archive>/<AAAA>/<AAAA-MM-JJ>_Sortie-parc --local-dest <local_work>/<AAAA>/<AAAA-MM-JJ>_Sortie-parc | |
| 21 | +``` | |
| 22 | + | |
| 23 | +**Résultat attendu** : ce dossier n'ayant jamais été archivé, tous ses fichiers sont classés « nouveau » ; en confirmant leur archivage, le dossier apparaît alors sous `<archive>` avec la même structure (`raw/`/`jpeg/`...) que la copie locale (cf. `specs/005-checkout-reconciliation`, aucune modification requise pour ce cas). | |
| 18 | 24 | |
| 19 | 25 | ## Scénario 2 — Découpage multi-jours (User Story 2, P2) |
| 20 | 26 | |
| @@ -14,7 +14,13 @@ Ce guide valide les 3 user stories de `spec.md` via la commande `regine import` | |||
| 14 | regine import ./fixtures/carte_journee_unique --titre "Sortie parc" --annee --yes | 14 | regine import ./fixtures/carte_journee_unique --titre "Sortie parc" --annee --yes |
| 15 | ``` | 15 | ``` |
| 16 | 16 | ||
| 17 | -**Résultat attendu** : un seul groupe proposé (une journée), dossier créé sous `<archive>/<AAAA>/<AAAA-MM-JJ>_Sortie-parc/`, fichiers renommés `<AAAA-MM-JJ>_Sortie-parc_<nomOrigine>.ext`, résumé affiché puis archivage confirmé (`--yes`). Vérifier que la carte source n'a nécessité qu'une seule lecture par fichier (SC-001). | 17 | +**Résultat attendu** : un seul groupe proposé (une journée), dossier créé sous `<local_work>/<AAAA>/<AAAA-MM-JJ>_Sortie-parc/`, fichiers renommés `<AAAA-MM-JJ>_Sortie-parc_<nomOrigine>.ext` et classés dans leur dossier de format (`raw/`, `jpeg/`...), résumé affiché puis publication locale confirmée (`--yes`). Vérifier que la carte source n'a nécessité qu'une seule lecture par fichier (SC-001), et qu'**aucun dossier n'apparaît sous `<archive>`** à l'issue de cette commande (SC-006/SC-010) — la sortie stdout indique la commande `regine reconcile` à lancer plus tard pour archiver. |
| 18 | + | ||
| 19 | +```bash | ||
| 20 | +regine reconcile <archive>/<AAAA>/<AAAA-MM-JJ>_Sortie-parc --local-dest <local_work>/<AAAA>/<AAAA-MM-JJ>_Sortie-parc | ||
| 21 | +``` | ||
| 22 | + | ||
| 23 | +**Résultat attendu** : ce dossier n'ayant jamais été archivé, tous ses fichiers sont classés « nouveau » ; en confirmant leur archivage, le dossier apparaît alors sous `<archive>` avec la même structure (`raw/`/`jpeg/`...) que la copie locale (cf. `specs/005-checkout-reconciliation`, aucune modification requise pour ce cas). | ||
| 18 | 24 | ||
| 19 | ## Scénario 2 — Découpage multi-jours (User Story 2, P2) | 25 | ## Scénario 2 — Découpage multi-jours (User Story 2, P2) |
| 20 | 26 | ||
modified
specs/001-import-photos/spec.md +30 -16 | @@ -8,23 +8,31 @@ | ||
| 8 | 8 | |
| 9 | 9 | **Input**: User description: "utilise les docs pour generer la specification du module d'importation des photos" |
| 10 | 10 | |
| 11 | +## Clarifications | |
| 12 | + | |
| 13 | +### Session 2026-09-22 | |
| 14 | + | |
| 15 | +- Q: L'import doit-il continuer à pousser automatiquement le dossier fini vers l'archive NAS, ou s'arrêter à une copie de travail locale ? → A: Il s'arrête à une copie de travail locale, classée par dossier de format (`raw/`/`jpeg/`/`tiff/`...). Le photographe doit pouvoir relire et retoucher cette copie avant tout envoi au NAS ; l'archivage devient une action distincte et explicite, déclenchée plus tard (réutilise `regine reconcile`, `specs/005-checkout-reconciliation`, déjà conçu pour traiter un dossier jamais encore archivé comme un ensemble de fichiers "nouveaux" à archiver sur confirmation). Conséquence directe : importer une carte ne nécessite plus que le partage NAS soit monté au moment de l'import (seul son chemin configuré sert à situer où le dossier ira plus tard) — utile aussi pour tester l'import sans accès réel au NAS. | |
| 16 | +- Q: Les fichiers importés doivent-ils être classés dans un dossier de format (`raw/`, `jpeg/`, `tiff/`...) dès l'import ? → A: Oui — écart trouvé à l'implémentation : cette spec ne le prévoyait pas explicitement alors que `docs/archivage-photo-elements-cles.md` section 10 et `specs/006-gui-tri-consultation` (écran de tri) supposent tous deux cette structure déjà en place dès qu'un dossier existe. Sans ce classement, un dossier fraîchement importé se retrouvait entièrement à plat, ce qui faisait apparaître chaque fichier comme "déjà promu à la racine" dans l'écran de tri — plus rien à trier. Corrigé par une nouvelle étape de classement (FR-020), après le renommage et avant la publication locale. | |
| 17 | + | |
| 11 | 18 | ## User Scenarios & Testing *(mandatory)* |
| 12 | 19 | |
| 13 | 20 | ### User Story 1 - Import simple d'une carte mémoire vers un nouveau dossier (Priority: P1) |
| 14 | 21 | |
| 15 | -Un photographe rentre d'une sortie photo avec une carte mémoire. Il demande à Régine d'importer le contenu de la carte : Régine copie les fichiers en sécurité, propose un titre et un nom de dossier à partir des dates de prise de vue, renomme les fichiers de façon lisible, puis archive le tout dans un nouveau dossier. | |
| 22 | +Un photographe rentre d'une sortie photo avec une carte mémoire. Il demande à Régine d'importer le contenu de la carte : Régine copie les fichiers en sécurité, propose un titre et un nom de dossier à partir des dates de prise de vue, renomme les fichiers de façon lisible, les classe dans leur dossier de format (`raw/`, `jpeg/`, `tiff/`...), puis les publie dans un nouveau dossier de l'espace de travail local — prêt à être trié et retouché. L'archivage vers le NAS n'a pas lieu automatiquement à l'import : c'est une étape distincte, déclenchée explicitement par le photographe une fois sa sélection terminée (cf. Clarifications, `specs/005-checkout-reconciliation`). | |
| 16 | 23 | |
| 17 | 24 | **Why this priority**: C'est le point d'entrée de toute photo dans Régine — sans cette histoire, aucun autre module (tri, sélection, archivage, planche-contact) n'a de matière à traiter. C'est aussi la première fois qu'une carte mémoire peut être effacée en confiance. |
| 18 | 25 | |
| 19 | -**Independent Test**: Peut être testé de façon autonome en branchant une carte mémoire contenant les photos d'une seule journée, en lançant l'import, et en vérifiant qu'un dossier correctement nommé apparaît dans l'archive avec tous les fichiers renommés et vérifiés — sans dépendre d'aucune autre fonctionnalité de Régine. | |
| 26 | +**Independent Test**: Peut être testé de façon autonome en branchant une carte mémoire contenant les photos d'une seule journée, en lançant l'import, et en vérifiant qu'un dossier correctement nommé apparaît dans l'espace de travail local, classé par format, avec tous les fichiers renommés et vérifiés — sans dépendre d'aucune autre fonctionnalité de Régine, ni d'un accès à l'archive NAS. | |
| 20 | 27 | |
| 21 | 28 | **Acceptance Scenarios**: |
| 22 | 29 | |
| 23 | 30 | 1. **Given** une carte mémoire contenant des photos d'une seule journée, jamais importées auparavant, **When** l'utilisateur lance l'import, **Then** Régine copie les fichiers vers un espace de travail local en vérifiant chaque fichier par somme de contrôle avant de signaler la carte comme sûre à effacer. |
| 24 | 31 | 2. **Given** la copie locale vérifiée d'une carte à une seule journée, **When** Régine analyse les dates de prise de vue, **Then** elle propose un seul groupe couvrant cette journée et invite l'utilisateur à donner un titre. |
| 25 | 32 | 3. **Given** un titre saisi par l'utilisateur, **When** Régine construit le nom du dossier, **Then** elle produit un nom au format `AAAA-MM-JJ_Titre`, nettoyé des caractères interdits et des espaces, le place sous le répertoire racine déterminé à l'étape de destination (année par défaut, ou catégorie thématique — cf. `specs/004-categorisation-dossiers`), et vérifie qu'aucun dossier identique n'existe déjà sous ce même répertoire racine avant de le créer. |
| 26 | -4. **Given** un nom de dossier validé, **When** Régine renomme les fichiers avant archivage, **Then** chaque fichier maître est renommé en `date_titre_nomOrigine.ext` en conservant le nom d'origine du boîtier en suffixe, et tout fichier associé (JPEG jumeau, sidecar) est renommé de façon synchronisée. | |
| 27 | -5. **Given** des fichiers renommés localement, **When** Régine pousse le dossier vers l'archive, **Then** elle présente d'abord un résumé (nombre de fichiers, taille, nom du dossier de destination) et attend une confirmation explicite avant d'écrire quoi que ce soit sur l'archive. | |
| 33 | +4. **Given** un nom de dossier validé, **When** Régine renomme les fichiers avant publication locale, **Then** chaque fichier maître est renommé en `date_titre_nomOrigine.ext` en conservant le nom d'origine du boîtier en suffixe, et tout fichier associé (JPEG jumeau, sidecar) est renommé de façon synchronisée. | |
| 34 | +5. **Given** des fichiers renommés localement, **When** Régine les classe par format, **Then** chaque fichier maître rejoint son dossier de format (`raw/` pour les RAW propriétaires, `jpeg/`, `tiff/` pour les scans, un dossier dédié pour un format maître plus marginal — cf. `docs/archivage-photo-elements-cles.md` section 10), et chaque fichier associé rejoint le dossier de son fichier maître. | |
| 35 | +6. **Given** des fichiers renommés et classés par format, **When** Régine les publie dans l'espace de travail local, **Then** elle présente d'abord un résumé (nombre de fichiers, taille, nom du dossier de destination) et attend une confirmation explicite avant d'écrire quoi que ce soit localement — aucune écriture n'a lieu sur l'archive NAS à ce stade. | |
| 28 | 36 | |
| 29 | 37 | --- |
| 30 | 38 | |
| @@ -41,7 +49,7 @@ Un photographe importe une carte couvrant plusieurs jours consécutifs (ex. une | ||
| 41 | 49 | 1. **Given** une copie locale vérifiée dont les dates EXIF couvrent une plage contiguë de plusieurs jours, **When** Régine analyse la répartition jour par jour, **Then** elle affiche le nombre de photos et la plage horaire par jour et propose par défaut un seul groupe couvrant toute la plage. |
| 42 | 50 | 2. **Given** cette répartition jour par jour, **When** un jour présente un pic de prises de vue nettement différent du reste, **Then** Régine met en avant ce jour comme candidat plausible à détacher, sans jamais le détacher elle-même. |
| 43 | 51 | 3. **Given** la proposition d'un seul groupe, **When** l'utilisateur détache un ou plusieurs jours au milieu de la plage, **Then** Régine crée un groupe séparé pour les jours détachés et conserve pour le groupe restant le nom de la plage d'origine plutôt que de la recalculer. |
| 44 | -4. **Given** plusieurs groupes issus du découpage, **When** l'utilisateur valide, **Then** Régine demande la destination et le titre de chaque groupe indépendamment, avant de renommer et d'archiver chacun séparément. | |
| 52 | +4. **Given** plusieurs groupes issus du découpage, **When** l'utilisateur valide, **Then** Régine demande la destination et le titre de chaque groupe indépendamment, avant de renommer, classer par format et publier chacun séparément dans l'espace de travail local. | |
| 45 | 53 | |
| 46 | 54 | --- |
| 47 | 55 | |
| @@ -72,6 +80,7 @@ Un photographe est en voyage sur plusieurs villes. Pour la deuxième étape, il | ||
| 72 | 80 | - Que se passe-t-il si le nom de dossier calculé entre en collision avec un dossier existant (local ou archivé) ? Le système DOIT proposer un suffixe ou demander une confirmation explicite, jamais écraser silencieusement le dossier existant. |
| 73 | 81 | - Que se passe-t-il si l'espace disque local est insuffisant pour accueillir la copie de travail avant même de commencer la copie ? Le système DOIT vérifier l'espace disponible avant de démarrer la copie et interrompre l'import avec un message clair plutôt que d'échouer en cours de copie. |
| 74 | 82 | - Comment le système traite-t-il un fichier présent sur la carte qui n'est ni une photo ni un fichier associé reconnu (ex. vidéo, fichier système du boîtier) ? Ce fichier est hors périmètre de l'import et n'est ni copié ni pris en compte dans l'analyse (cf. Assumptions). |
| 83 | +- Que se passe-t-il si le partage réseau de l'archive n'est pas monté ou pas accessible au moment de l'import ? L'import DOIT pouvoir se dérouler jusqu'au bout (copie, découpage, renommage, classement, publication locale) sans dépendre de cette accessibilité, puisqu'aucune écriture n'a lieu sur l'archive à ce stade (cf. Clarifications) — seul le chemin d'archive configuré est nécessaire, pas sa disponibilité effective. | |
| 75 | 84 | |
| 76 | 85 | ## Requirements *(mandatory)* |
| 77 | 86 | |
| @@ -86,23 +95,26 @@ Un photographe est en voyage sur plusieurs villes. Pour la deuxième étape, il | ||
| 86 | 95 | - **FR-007**: Pour chaque groupe résultant du découpage, le système DOIT demander explicitement à l'utilisateur sa destination, parmi : nouveau dossier simple, nouveau sous-dossier d'un dossier parent existant, fusion dans un dossier existant, ou nouveau dossier parent avec sa première étape — sans jamais retenir un état "voyage en cours" d'un import à l'autre. Pour un nouveau dossier simple ou un nouveau dossier parent (jamais pour un nouveau sous-dossier ni une fusion, qui héritent du placement déjà établi), le système DOIT également demander si ce dossier relève d'une catégorie thématique ou du placement par défaut par année (cf. `specs/004-categorisation-dossiers`). |
| 87 | 96 | - **FR-008**: Pour aider l'utilisateur à choisir une destination de sous-dossier ou de fusion, le système DOIT lister les dossiers candidats existants à la fois dans l'espace de travail local et dans l'archive (recherche par titre ou date proche). |
| 88 | 97 | - **FR-009**: Lorsqu'une fusion cible un dossier déjà présent dans l'archive (et pas seulement en local), le système DOIT d'abord effectuer une vérification (checkout) de ce dossier avant d'y intégrer les nouveaux fichiers. |
| 89 | -- **FR-010**: Le système DOIT demander un titre pour chaque groupe, le nettoyer pour en faire un nom de dossier valide (espaces convertis, caractères interdits refusés), puis le combiner avec la plage de dates selon la granularité appropriée (jour unique, plage dans le même mois, plage à cheval sur deux mois/années, ou granularité mois pour un dossier parent de voyage). Le chemin final DOIT également intégrer le répertoire racine déterminé à l'étape FR-007 (répertoire d'année par défaut, ou répertoire de la catégorie thématique choisie), de façon identique dans la copie de travail locale et dans l'archive (cf. `specs/004-categorisation-dossiers`). | |
| 98 | +- **FR-010**: Le système DOIT demander un titre pour chaque groupe, le nettoyer pour en faire un nom de dossier valide (espaces convertis, caractères interdits refusés), puis le combiner avec la plage de dates selon la granularité appropriée (jour unique, plage dans le même mois, plage à cheval sur deux mois/années, ou granularité mois pour un dossier parent de voyage). Le chemin final DOIT également intégrer le répertoire racine déterminé à l'étape FR-007 (répertoire d'année par défaut, ou répertoire de la catégorie thématique choisie) : ce chemin est appliqué immédiatement à la copie de travail locale, et calculé de façon identique pour l'emplacement futur sur l'archive (cf. `specs/004-categorisation-dossiers`), sans y écrire quoi que ce soit avant l'archivage explicite (cf. Clarifications). | |
| 90 | 99 | - **FR-011**: Le système PEUT suggérer automatiquement un lieu à partir de coordonnées GPS présentes dans les métadonnées EXIF, mais cette suggestion reste modifiable et ne DOIT jamais être imposée à l'utilisateur. |
| 91 | 100 | - **FR-012**: Le système DOIT vérifier, avant de créer un dossier final, qu'aucun dossier de nom identique n'existe déjà sous le même répertoire racine (année ou catégorie thématique — cf. `specs/004-categorisation-dossiers`) ; en cas de collision, il DOIT proposer un suffixe ou demander une confirmation explicite plutôt que d'écraser. |
| 92 | -- **FR-013**: Le système DOIT renommer chaque fichier maître importé selon la convention `date_titre_nomOrigine.ext`, en conservant le nom d'origine attribué par le boîtier en suffixe, une fois le titre du groupe choisi et juste avant l'archivage. | |
| 101 | +- **FR-013**: Le système DOIT renommer chaque fichier maître importé selon la convention `date_titre_nomOrigine.ext`, en conservant le nom d'origine attribué par le boîtier en suffixe, une fois le titre du groupe choisi et juste avant le classement par format et la publication locale. | |
| 93 | 102 | - **FR-014**: Le système DOIT renommer de façon synchronisée tout fichier partageant le même nom de base qu'un fichier maître renommé (JPEG jumeau en mode RAW+JPEG, sidecar de réglages), pour préserver leur appariement. |
| 94 | 103 | - **FR-015**: Le système DOIT désambiguïser par défaut deux fichiers de même nom d'origine provenant de boîtiers différents à partir du tag EXIF de modèle de chaque fichier, sans exiger qu'un profil de boîtiers ait été préalablement déclaré ; il ne DOIT solliciter l'utilisateur (profil, numéro de série, étiquetage manuel) qu'au moment où une collision réelle est détectée par somme de contrôle et où ce tag ne suffit pas à distinguer les sources (cf. `specs/002-profil-boitiers-optionnel`, qui définit ce mécanisme de désambiguïsation en détail). |
| 95 | 104 | - **FR-016**: La détection d'un modèle de boîtier jamais rencontré auparavant dans les métadonnées d'un fichier ne DOIT, en l'absence de toute collision réelle, ni interrompre l'import ni exiger une action sur le profil de boîtiers. |
| 96 | 105 | - **FR-017**: Le système DOIT attribuer un identifiant pérenne, indépendant du nom de fichier, à chaque photo importée, et l'inscrire dans les métadonnées embarquées du fichier. |
| 97 | -- **FR-018**: Avant d'écrire quoi que ce soit sur l'archive (création de dossier, archivage des fichiers renommés, fusion dans un dossier existant), le système DOIT présenter à l'utilisateur un résumé de l'opération (nombre de fichiers concernés, dossier de destination, y compris son répertoire racine — année ou catégorie thématique, cf. `specs/004-categorisation-dossiers`) et attendre une confirmation explicite. | |
| 98 | -- **FR-019**: Le système DOIT effectuer le transfert final vers l'archive à partir de la copie de travail locale déjà vérifiée et renommée, plutôt que par une copie directe de la carte mémoire vers l'archive. | |
| 106 | +- **FR-018**: Avant d'écrire quoi que ce soit dans l'espace de travail local définitif (création de dossier, publication des fichiers renommés, fusion dans un dossier existant), le système DOIT présenter à l'utilisateur un résumé de l'opération (nombre de fichiers concernés, dossier de destination, y compris son répertoire racine — année ou catégorie thématique, cf. `specs/004-categorisation-dossiers`) et attendre une confirmation explicite. | |
| 107 | +- **FR-019**: Le système DOIT effectuer le transfert final vers l'espace de travail local définitif à partir de la copie de travail temporaire déjà vérifiée, renommée et classée par format, plutôt que par une copie directe de la carte mémoire. Le système NE DOIT PAS écrire sur l'archive NAS à ce stade (cf. Clarifications) : l'archivage est une action distincte et explicite, hors périmètre de l'import lui-même (cf. Assumptions, `specs/005-checkout-reconciliation`). | |
| 108 | +- **FR-020**: Le système DOIT classer chaque fichier maître renommé dans son dossier de format (`raw/` pour les RAW propriétaires et le DNG, `jpeg/`, `tiff/` pour les scans, ou un dossier dédié pour un format maître plus marginal — cf. `docs/archivage-photo-elements-cles.md` section 10), créé à la demande selon ce qui est réellement présent dans le groupe. Chaque fichier associé (sidecar) DOIT rejoindre le dossier de format de son fichier maître, jamais un dossier qui lui serait propre. | |
| 99 | 109 | |
| 100 | 110 | ### Key Entities |
| 101 | 111 | |
| 102 | 112 | - **Carte mémoire** : support source d'un import, contenant des fichiers déjà importés lors de sessions précédentes et des fichiers réellement nouveaux à traiter. |
| 103 | -- **Copie de travail locale** : copie temporaire et vérifiée par somme de contrôle des fichiers nouveaux d'une carte, base de toute l'analyse et du renommage avant archivage. | |
| 104 | -- **Groupe d'import** : sous-ensemble de fichiers nouvellement importés partageant une plage de dates contiguë, destiné à devenir un dossier ou un sous-dossier de l'archive. | |
| 105 | -- **Dossier** : unité d'archive résultant d'un groupe simple, avec sa structure par format et sa racine de sélection, vivant sous un répertoire racine (année par défaut ou catégorie thématique — cf. `specs/004-categorisation-dossiers`). | |
| 113 | +- **Copie de travail temporaire** : copie vérifiée par somme de contrôle des fichiers nouveaux d'une carte, base de toute l'analyse, du renommage et du classement par format, avant publication dans l'espace de travail local définitif (distincte de la copie de travail locale ci-dessous). | |
| 114 | +- **Groupe d'import** : sous-ensemble de fichiers nouvellement importés partageant une plage de dates contiguë, destiné à devenir un dossier ou un sous-dossier de l'espace de travail local (et, plus tard, de l'archive). | |
| 115 | +- **Dossier de format** : sous-dossier créé à la demande selon ce qui est réellement présent (`raw/`, `jpeg/`, `tiff/`...), où chaque fichier maître renommé est classé (FR-020) — cf. `docs/archivage-photo-elements-cles.md` section 10, définition détaillée reprise par `specs/006-gui-tri-consultation`. | |
| 116 | +- **Dossier** : unité de l'espace de travail local résultant d'un groupe simple, avec sa structure par format et sa racine de sélection ; vit sous un répertoire racine (année par défaut ou catégorie thématique — cf. `specs/004-categorisation-dossiers`), à la même position relative qu'il occupera plus tard sur l'archive une fois archivé. | |
| 117 | +- **Copie de travail locale** : le dossier ci-dessus, une fois publié dans l'espace de travail local — prêt à être trié/retouché, pas encore archivé. | |
| 106 | 118 | - **Dossier parent / sous-dossier** : structure à deux niveaux résultant d'un import s'intégrant à un voyage en plusieurs étapes, partageant le même répertoire racine (année ou catégorie) que le dossier parent qui les englobe. |
| 107 | 119 | - **Profil de boîtiers** : liste optionnelle d'appareils, alimentée à la demande (cf. `specs/002-profil-boitiers-optionnel`), utilisée pour désambiguïser la source d'un fichier lorsqu'une collision réelle de nom d'origine survient et que le tag de modèle seul ne suffit pas à la résoudre. |
| 108 | 120 | - **Fichier maître** : photo issue directement du boîtier (RAW ou JPEG selon les cas), renommée à l'import et jamais modifiée une fois archivée. |
| @@ -114,17 +126,19 @@ Un photographe est en voyage sur plusieurs villes. Pour la deuxième étape, il | ||
| 114 | 126 | ### Measurable Outcomes |
| 115 | 127 | |
| 116 | 128 | - **SC-001**: 100% des fichiers copiés depuis une carte mémoire sont vérifiés par somme de contrôle avant que la carte ne soit signalée comme sûre à effacer, sans qu'aucun fichier ne nécessite plus d'une lecture complète de la carte, quelle que soit sa vitesse d'accès. |
| 117 | -- **SC-002**: Pour un import à une seule journée sur un seul boîtier, l'utilisateur obtient un dossier archivé, correctement nommé et vérifié, en validant au plus deux décisions (titre, confirmation d'archivage). | |
| 129 | +- **SC-002**: Pour un import à une seule journée sur un seul boîtier, l'utilisateur obtient un dossier local prêt à trier, correctement nommé, classé par format et vérifié, en validant au plus deux décisions (titre, confirmation de la copie locale). | |
| 118 | 130 | - **SC-003**: 100% des dates de prise de vue aberrantes détectées sur une carte sont signalées à l'utilisateur plutôt qu'intégrées silencieusement au découpage en groupes. |
| 119 | 131 | - **SC-004**: 100% des collisions de nom de dossier sont présentées à l'utilisateur avec un choix explicite ; aucune collision ne provoque d'écrasement silencieux. |
| 120 | 132 | - **SC-005**: 100% des paires de fichiers associés (fichier maître + JPEG jumeau ou sidecar) conservent leur appariement (même nom de base) après renommage à l'import. |
| 121 | -- **SC-006**: Aucune écriture sur l'archive (création de dossier, ajout de fichiers) ne survient sans qu'un résumé de l'opération n'ait été présenté et explicitement confirmé par l'utilisateur. | |
| 133 | +- **SC-006**: Aucune écriture dans l'espace de travail local définitif (création de dossier, ajout de fichiers) ne survient sans qu'un résumé de l'opération n'ait été présenté et explicitement confirmé par l'utilisateur ; et 100% des imports se terminent sans aucune écriture sur l'archive NAS (cf. Clarifications). | |
| 122 | 134 | - **SC-007**: Un utilisateur qui importe une carte couvrant un voyage en plusieurs étapes peut obtenir une structure à deux niveaux correctement rattachée sans avoir à renommer ou déplacer manuellement de dossier après l'import. |
| 123 | -- **SC-008**: Le répertoire racine de destination (année ou catégorie thématique) est visible dans 100% des résumés présentés avant confirmation d'archivage (cf. `specs/004-categorisation-dossiers`). | |
| 135 | +- **SC-008**: Le répertoire racine de destination (année ou catégorie thématique) est visible dans 100% des résumés présentés avant confirmation de la copie locale (cf. `specs/004-categorisation-dossiers`). | |
| 136 | +- **SC-009**: 100% des fichiers maîtres publiés localement se trouvent dans leur dossier de format attendu (`raw/`, `jpeg/`, `tiff/`...), et chaque fichier associé dans le dossier de format de son fichier maître. | |
| 137 | +- **SC-010**: Un import peut être mené jusqu'au bout (copie, groupes, renommage, classement, publication locale) sans qu'un partage d'archive configuré ait besoin d'être monté ou accessible durant l'opération. | |
| 124 | 138 | |
| 125 | 139 | ## Assumptions |
| 126 | 140 | |
| 127 | -- Le module d'importation couvre le trajet complet depuis la carte mémoire jusqu'au premier archivage des fichiers renommés ; il ne couvre pas le tri ultérieur (sélection, promotion à la racine) ni la suppression de déchets évidents, qui relèvent du flux de réconciliation lors d'une édition (hors périmètre de ce module). | |
| 141 | +- Le module d'importation couvre le trajet complet depuis la carte mémoire jusqu'à une copie de travail locale prête, classée par format et vérifiée (cf. Clarifications, 2026-09-22) ; il ne couvre ni le tri ultérieur (sélection, promotion à la racine), ni la suppression de déchets évidents, ni l'archivage effectif vers le NAS, qui relèvent tous du flux de réconciliation lors d'une édition (`specs/005-checkout-reconciliation`, hors périmètre de ce module). L'archivage d'un dossier jamais encore poussé au NAS réutilise `regine reconcile` tel quel : un manifeste vide y est créé au premier appel, donc chaque fichier local y est naturellement classé "nouveau" et archivable sur confirmation explicite — aucune capacité supplémentaire à construire côté `specs/005` pour ce cas. Une façade GUI dédiée à cette étape d'archivage n'est pas couverte par cette révision (CLI uniquement pour l'instant, `regine reconcile`) ; `specs/006-gui-tri-consultation` reste à jour séparément le jour où ce besoin est exprimé. | |
| 128 | 142 | - Les fichiers présents sur la carte mémoire qui ne sont ni des photos (RAW, JPEG, TIFF) ni des fichiers associés reconnus (sidecars) sont hors périmètre de l'import : ils ne sont ni copiés ni pris en compte dans l'analyse. |
| 129 | 143 | - Le profil de boîtiers est une capacité optionnelle, alimentée à la demande et non un prérequis avant le premier import (cf. `specs/002-profil-boitiers-optionnel`, qui révise et détaille ce comportement) : ce module se désambiguïse par défaut via le tag EXIF de modèle, et ne sollicite le profil, un numéro de série, ou un étiquetage manuel qu'en cas de collision réelle non résolue par ce tag seul. |
| 130 | 144 | - Le mécanisme de checkout/réconciliation d'un dossier déjà archivé (utilisé au cas de fusion vers un dossier existant sur l'archive) est une capacité déjà disponible ou livrée avant ou avec ce module ; ce module ne redéfinit pas ce mécanisme, il s'appuie dessus. |
| @@ -8,23 +8,31 @@ | |||
| 8 | 8 | ||
| 9 | **Input**: User description: "utilise les docs pour generer la specification du module d'importation des photos" | 9 | **Input**: User description: "utilise les docs pour generer la specification du module d'importation des photos" |
| 10 | 10 | ||
| 11 | +## Clarifications | ||
| 12 | + | ||
| 13 | +### Session 2026-09-22 | ||
| 14 | + | ||
| 15 | +- Q: L'import doit-il continuer à pousser automatiquement le dossier fini vers l'archive NAS, ou s'arrêter à une copie de travail locale ? → A: Il s'arrête à une copie de travail locale, classée par dossier de format (`raw/`/`jpeg/`/`tiff/`...). Le photographe doit pouvoir relire et retoucher cette copie avant tout envoi au NAS ; l'archivage devient une action distincte et explicite, déclenchée plus tard (réutilise `regine reconcile`, `specs/005-checkout-reconciliation`, déjà conçu pour traiter un dossier jamais encore archivé comme un ensemble de fichiers "nouveaux" à archiver sur confirmation). Conséquence directe : importer une carte ne nécessite plus que le partage NAS soit monté au moment de l'import (seul son chemin configuré sert à situer où le dossier ira plus tard) — utile aussi pour tester l'import sans accès réel au NAS. | ||
| 16 | +- Q: Les fichiers importés doivent-ils être classés dans un dossier de format (`raw/`, `jpeg/`, `tiff/`...) dès l'import ? → A: Oui — écart trouvé à l'implémentation : cette spec ne le prévoyait pas explicitement alors que `docs/archivage-photo-elements-cles.md` section 10 et `specs/006-gui-tri-consultation` (écran de tri) supposent tous deux cette structure déjà en place dès qu'un dossier existe. Sans ce classement, un dossier fraîchement importé se retrouvait entièrement à plat, ce qui faisait apparaître chaque fichier comme "déjà promu à la racine" dans l'écran de tri — plus rien à trier. Corrigé par une nouvelle étape de classement (FR-020), après le renommage et avant la publication locale. | ||
| 17 | + | ||
| 11 | ## User Scenarios & Testing *(mandatory)* | 18 | ## User Scenarios & Testing *(mandatory)* |
| 12 | 19 | ||
| 13 | ### User Story 1 - Import simple d'une carte mémoire vers un nouveau dossier (Priority: P1) | 20 | ### User Story 1 - Import simple d'une carte mémoire vers un nouveau dossier (Priority: P1) |
| 14 | 21 | ||
| 15 | -Un photographe rentre d'une sortie photo avec une carte mémoire. Il demande à Régine d'importer le contenu de la carte : Régine copie les fichiers en sécurité, propose un titre et un nom de dossier à partir des dates de prise de vue, renomme les fichiers de façon lisible, puis archive le tout dans un nouveau dossier. | 22 | +Un photographe rentre d'une sortie photo avec une carte mémoire. Il demande à Régine d'importer le contenu de la carte : Régine copie les fichiers en sécurité, propose un titre et un nom de dossier à partir des dates de prise de vue, renomme les fichiers de façon lisible, les classe dans leur dossier de format (`raw/`, `jpeg/`, `tiff/`...), puis les publie dans un nouveau dossier de l'espace de travail local — prêt à être trié et retouché. L'archivage vers le NAS n'a pas lieu automatiquement à l'import : c'est une étape distincte, déclenchée explicitement par le photographe une fois sa sélection terminée (cf. Clarifications, `specs/005-checkout-reconciliation`). |
| 16 | 23 | ||
| 17 | **Why this priority**: C'est le point d'entrée de toute photo dans Régine — sans cette histoire, aucun autre module (tri, sélection, archivage, planche-contact) n'a de matière à traiter. C'est aussi la première fois qu'une carte mémoire peut être effacée en confiance. | 24 | **Why this priority**: C'est le point d'entrée de toute photo dans Régine — sans cette histoire, aucun autre module (tri, sélection, archivage, planche-contact) n'a de matière à traiter. C'est aussi la première fois qu'une carte mémoire peut être effacée en confiance. |
| 18 | 25 | ||
| 19 | -**Independent Test**: Peut être testé de façon autonome en branchant une carte mémoire contenant les photos d'une seule journée, en lançant l'import, et en vérifiant qu'un dossier correctement nommé apparaît dans l'archive avec tous les fichiers renommés et vérifiés — sans dépendre d'aucune autre fonctionnalité de Régine. | 26 | +**Independent Test**: Peut être testé de façon autonome en branchant une carte mémoire contenant les photos d'une seule journée, en lançant l'import, et en vérifiant qu'un dossier correctement nommé apparaît dans l'espace de travail local, classé par format, avec tous les fichiers renommés et vérifiés — sans dépendre d'aucune autre fonctionnalité de Régine, ni d'un accès à l'archive NAS. |
| 20 | 27 | ||
| 21 | **Acceptance Scenarios**: | 28 | **Acceptance Scenarios**: |
| 22 | 29 | ||
| 23 | 1. **Given** une carte mémoire contenant des photos d'une seule journée, jamais importées auparavant, **When** l'utilisateur lance l'import, **Then** Régine copie les fichiers vers un espace de travail local en vérifiant chaque fichier par somme de contrôle avant de signaler la carte comme sûre à effacer. | 30 | 1. **Given** une carte mémoire contenant des photos d'une seule journée, jamais importées auparavant, **When** l'utilisateur lance l'import, **Then** Régine copie les fichiers vers un espace de travail local en vérifiant chaque fichier par somme de contrôle avant de signaler la carte comme sûre à effacer. |
| 24 | 2. **Given** la copie locale vérifiée d'une carte à une seule journée, **When** Régine analyse les dates de prise de vue, **Then** elle propose un seul groupe couvrant cette journée et invite l'utilisateur à donner un titre. | 31 | 2. **Given** la copie locale vérifiée d'une carte à une seule journée, **When** Régine analyse les dates de prise de vue, **Then** elle propose un seul groupe couvrant cette journée et invite l'utilisateur à donner un titre. |
| 25 | 3. **Given** un titre saisi par l'utilisateur, **When** Régine construit le nom du dossier, **Then** elle produit un nom au format `AAAA-MM-JJ_Titre`, nettoyé des caractères interdits et des espaces, le place sous le répertoire racine déterminé à l'étape de destination (année par défaut, ou catégorie thématique — cf. `specs/004-categorisation-dossiers`), et vérifie qu'aucun dossier identique n'existe déjà sous ce même répertoire racine avant de le créer. | 32 | 3. **Given** un titre saisi par l'utilisateur, **When** Régine construit le nom du dossier, **Then** elle produit un nom au format `AAAA-MM-JJ_Titre`, nettoyé des caractères interdits et des espaces, le place sous le répertoire racine déterminé à l'étape de destination (année par défaut, ou catégorie thématique — cf. `specs/004-categorisation-dossiers`), et vérifie qu'aucun dossier identique n'existe déjà sous ce même répertoire racine avant de le créer. |
| 26 | -4. **Given** un nom de dossier validé, **When** Régine renomme les fichiers avant archivage, **Then** chaque fichier maître est renommé en `date_titre_nomOrigine.ext` en conservant le nom d'origine du boîtier en suffixe, et tout fichier associé (JPEG jumeau, sidecar) est renommé de façon synchronisée. | 33 | +4. **Given** un nom de dossier validé, **When** Régine renomme les fichiers avant publication locale, **Then** chaque fichier maître est renommé en `date_titre_nomOrigine.ext` en conservant le nom d'origine du boîtier en suffixe, et tout fichier associé (JPEG jumeau, sidecar) est renommé de façon synchronisée. |
| 27 | -5. **Given** des fichiers renommés localement, **When** Régine pousse le dossier vers l'archive, **Then** elle présente d'abord un résumé (nombre de fichiers, taille, nom du dossier de destination) et attend une confirmation explicite avant d'écrire quoi que ce soit sur l'archive. | 34 | +5. **Given** des fichiers renommés localement, **When** Régine les classe par format, **Then** chaque fichier maître rejoint son dossier de format (`raw/` pour les RAW propriétaires, `jpeg/`, `tiff/` pour les scans, un dossier dédié pour un format maître plus marginal — cf. `docs/archivage-photo-elements-cles.md` section 10), et chaque fichier associé rejoint le dossier de son fichier maître. |
| 35 | +6. **Given** des fichiers renommés et classés par format, **When** Régine les publie dans l'espace de travail local, **Then** elle présente d'abord un résumé (nombre de fichiers, taille, nom du dossier de destination) et attend une confirmation explicite avant d'écrire quoi que ce soit localement — aucune écriture n'a lieu sur l'archive NAS à ce stade. | ||
| 28 | 36 | ||
| 29 | --- | 37 | --- |
| 30 | 38 | ||
| @@ -41,7 +49,7 @@ Un photographe importe une carte couvrant plusieurs jours consécutifs (ex. une | |||
| 41 | 1. **Given** une copie locale vérifiée dont les dates EXIF couvrent une plage contiguë de plusieurs jours, **When** Régine analyse la répartition jour par jour, **Then** elle affiche le nombre de photos et la plage horaire par jour et propose par défaut un seul groupe couvrant toute la plage. | 49 | 1. **Given** une copie locale vérifiée dont les dates EXIF couvrent une plage contiguë de plusieurs jours, **When** Régine analyse la répartition jour par jour, **Then** elle affiche le nombre de photos et la plage horaire par jour et propose par défaut un seul groupe couvrant toute la plage. |
| 42 | 2. **Given** cette répartition jour par jour, **When** un jour présente un pic de prises de vue nettement différent du reste, **Then** Régine met en avant ce jour comme candidat plausible à détacher, sans jamais le détacher elle-même. | 50 | 2. **Given** cette répartition jour par jour, **When** un jour présente un pic de prises de vue nettement différent du reste, **Then** Régine met en avant ce jour comme candidat plausible à détacher, sans jamais le détacher elle-même. |
| 43 | 3. **Given** la proposition d'un seul groupe, **When** l'utilisateur détache un ou plusieurs jours au milieu de la plage, **Then** Régine crée un groupe séparé pour les jours détachés et conserve pour le groupe restant le nom de la plage d'origine plutôt que de la recalculer. | 51 | 3. **Given** la proposition d'un seul groupe, **When** l'utilisateur détache un ou plusieurs jours au milieu de la plage, **Then** Régine crée un groupe séparé pour les jours détachés et conserve pour le groupe restant le nom de la plage d'origine plutôt que de la recalculer. |
| 44 | -4. **Given** plusieurs groupes issus du découpage, **When** l'utilisateur valide, **Then** Régine demande la destination et le titre de chaque groupe indépendamment, avant de renommer et d'archiver chacun séparément. | 52 | +4. **Given** plusieurs groupes issus du découpage, **When** l'utilisateur valide, **Then** Régine demande la destination et le titre de chaque groupe indépendamment, avant de renommer, classer par format et publier chacun séparément dans l'espace de travail local. |
| 45 | 53 | ||
| 46 | --- | 54 | --- |
| 47 | 55 | ||
| @@ -72,6 +80,7 @@ Un photographe est en voyage sur plusieurs villes. Pour la deuxième étape, il | |||
| 72 | - Que se passe-t-il si le nom de dossier calculé entre en collision avec un dossier existant (local ou archivé) ? Le système DOIT proposer un suffixe ou demander une confirmation explicite, jamais écraser silencieusement le dossier existant. | 80 | - Que se passe-t-il si le nom de dossier calculé entre en collision avec un dossier existant (local ou archivé) ? Le système DOIT proposer un suffixe ou demander une confirmation explicite, jamais écraser silencieusement le dossier existant. |
| 73 | - Que se passe-t-il si l'espace disque local est insuffisant pour accueillir la copie de travail avant même de commencer la copie ? Le système DOIT vérifier l'espace disponible avant de démarrer la copie et interrompre l'import avec un message clair plutôt que d'échouer en cours de copie. | 81 | - Que se passe-t-il si l'espace disque local est insuffisant pour accueillir la copie de travail avant même de commencer la copie ? Le système DOIT vérifier l'espace disponible avant de démarrer la copie et interrompre l'import avec un message clair plutôt que d'échouer en cours de copie. |
| 74 | - Comment le système traite-t-il un fichier présent sur la carte qui n'est ni une photo ni un fichier associé reconnu (ex. vidéo, fichier système du boîtier) ? Ce fichier est hors périmètre de l'import et n'est ni copié ni pris en compte dans l'analyse (cf. Assumptions). | 82 | - Comment le système traite-t-il un fichier présent sur la carte qui n'est ni une photo ni un fichier associé reconnu (ex. vidéo, fichier système du boîtier) ? Ce fichier est hors périmètre de l'import et n'est ni copié ni pris en compte dans l'analyse (cf. Assumptions). |
| 83 | +- Que se passe-t-il si le partage réseau de l'archive n'est pas monté ou pas accessible au moment de l'import ? L'import DOIT pouvoir se dérouler jusqu'au bout (copie, découpage, renommage, classement, publication locale) sans dépendre de cette accessibilité, puisqu'aucune écriture n'a lieu sur l'archive à ce stade (cf. Clarifications) — seul le chemin d'archive configuré est nécessaire, pas sa disponibilité effective. | ||
| 75 | 84 | ||
| 76 | ## Requirements *(mandatory)* | 85 | ## Requirements *(mandatory)* |
| 77 | 86 | ||
| @@ -86,23 +95,26 @@ Un photographe est en voyage sur plusieurs villes. Pour la deuxième étape, il | |||
| 86 | - **FR-007**: Pour chaque groupe résultant du découpage, le système DOIT demander explicitement à l'utilisateur sa destination, parmi : nouveau dossier simple, nouveau sous-dossier d'un dossier parent existant, fusion dans un dossier existant, ou nouveau dossier parent avec sa première étape — sans jamais retenir un état "voyage en cours" d'un import à l'autre. Pour un nouveau dossier simple ou un nouveau dossier parent (jamais pour un nouveau sous-dossier ni une fusion, qui héritent du placement déjà établi), le système DOIT également demander si ce dossier relève d'une catégorie thématique ou du placement par défaut par année (cf. `specs/004-categorisation-dossiers`). | 95 | - **FR-007**: Pour chaque groupe résultant du découpage, le système DOIT demander explicitement à l'utilisateur sa destination, parmi : nouveau dossier simple, nouveau sous-dossier d'un dossier parent existant, fusion dans un dossier existant, ou nouveau dossier parent avec sa première étape — sans jamais retenir un état "voyage en cours" d'un import à l'autre. Pour un nouveau dossier simple ou un nouveau dossier parent (jamais pour un nouveau sous-dossier ni une fusion, qui héritent du placement déjà établi), le système DOIT également demander si ce dossier relève d'une catégorie thématique ou du placement par défaut par année (cf. `specs/004-categorisation-dossiers`). |
| 87 | - **FR-008**: Pour aider l'utilisateur à choisir une destination de sous-dossier ou de fusion, le système DOIT lister les dossiers candidats existants à la fois dans l'espace de travail local et dans l'archive (recherche par titre ou date proche). | 96 | - **FR-008**: Pour aider l'utilisateur à choisir une destination de sous-dossier ou de fusion, le système DOIT lister les dossiers candidats existants à la fois dans l'espace de travail local et dans l'archive (recherche par titre ou date proche). |
| 88 | - **FR-009**: Lorsqu'une fusion cible un dossier déjà présent dans l'archive (et pas seulement en local), le système DOIT d'abord effectuer une vérification (checkout) de ce dossier avant d'y intégrer les nouveaux fichiers. | 97 | - **FR-009**: Lorsqu'une fusion cible un dossier déjà présent dans l'archive (et pas seulement en local), le système DOIT d'abord effectuer une vérification (checkout) de ce dossier avant d'y intégrer les nouveaux fichiers. |
| 89 | -- **FR-010**: Le système DOIT demander un titre pour chaque groupe, le nettoyer pour en faire un nom de dossier valide (espaces convertis, caractères interdits refusés), puis le combiner avec la plage de dates selon la granularité appropriée (jour unique, plage dans le même mois, plage à cheval sur deux mois/années, ou granularité mois pour un dossier parent de voyage). Le chemin final DOIT également intégrer le répertoire racine déterminé à l'étape FR-007 (répertoire d'année par défaut, ou répertoire de la catégorie thématique choisie), de façon identique dans la copie de travail locale et dans l'archive (cf. `specs/004-categorisation-dossiers`). | 98 | +- **FR-010**: Le système DOIT demander un titre pour chaque groupe, le nettoyer pour en faire un nom de dossier valide (espaces convertis, caractères interdits refusés), puis le combiner avec la plage de dates selon la granularité appropriée (jour unique, plage dans le même mois, plage à cheval sur deux mois/années, ou granularité mois pour un dossier parent de voyage). Le chemin final DOIT également intégrer le répertoire racine déterminé à l'étape FR-007 (répertoire d'année par défaut, ou répertoire de la catégorie thématique choisie) : ce chemin est appliqué immédiatement à la copie de travail locale, et calculé de façon identique pour l'emplacement futur sur l'archive (cf. `specs/004-categorisation-dossiers`), sans y écrire quoi que ce soit avant l'archivage explicite (cf. Clarifications). |
| 90 | - **FR-011**: Le système PEUT suggérer automatiquement un lieu à partir de coordonnées GPS présentes dans les métadonnées EXIF, mais cette suggestion reste modifiable et ne DOIT jamais être imposée à l'utilisateur. | 99 | - **FR-011**: Le système PEUT suggérer automatiquement un lieu à partir de coordonnées GPS présentes dans les métadonnées EXIF, mais cette suggestion reste modifiable et ne DOIT jamais être imposée à l'utilisateur. |
| 91 | - **FR-012**: Le système DOIT vérifier, avant de créer un dossier final, qu'aucun dossier de nom identique n'existe déjà sous le même répertoire racine (année ou catégorie thématique — cf. `specs/004-categorisation-dossiers`) ; en cas de collision, il DOIT proposer un suffixe ou demander une confirmation explicite plutôt que d'écraser. | 100 | - **FR-012**: Le système DOIT vérifier, avant de créer un dossier final, qu'aucun dossier de nom identique n'existe déjà sous le même répertoire racine (année ou catégorie thématique — cf. `specs/004-categorisation-dossiers`) ; en cas de collision, il DOIT proposer un suffixe ou demander une confirmation explicite plutôt que d'écraser. |
| 92 | -- **FR-013**: Le système DOIT renommer chaque fichier maître importé selon la convention `date_titre_nomOrigine.ext`, en conservant le nom d'origine attribué par le boîtier en suffixe, une fois le titre du groupe choisi et juste avant l'archivage. | 101 | +- **FR-013**: Le système DOIT renommer chaque fichier maître importé selon la convention `date_titre_nomOrigine.ext`, en conservant le nom d'origine attribué par le boîtier en suffixe, une fois le titre du groupe choisi et juste avant le classement par format et la publication locale. |
| 93 | - **FR-014**: Le système DOIT renommer de façon synchronisée tout fichier partageant le même nom de base qu'un fichier maître renommé (JPEG jumeau en mode RAW+JPEG, sidecar de réglages), pour préserver leur appariement. | 102 | - **FR-014**: Le système DOIT renommer de façon synchronisée tout fichier partageant le même nom de base qu'un fichier maître renommé (JPEG jumeau en mode RAW+JPEG, sidecar de réglages), pour préserver leur appariement. |
| 94 | - **FR-015**: Le système DOIT désambiguïser par défaut deux fichiers de même nom d'origine provenant de boîtiers différents à partir du tag EXIF de modèle de chaque fichier, sans exiger qu'un profil de boîtiers ait été préalablement déclaré ; il ne DOIT solliciter l'utilisateur (profil, numéro de série, étiquetage manuel) qu'au moment où une collision réelle est détectée par somme de contrôle et où ce tag ne suffit pas à distinguer les sources (cf. `specs/002-profil-boitiers-optionnel`, qui définit ce mécanisme de désambiguïsation en détail). | 103 | - **FR-015**: Le système DOIT désambiguïser par défaut deux fichiers de même nom d'origine provenant de boîtiers différents à partir du tag EXIF de modèle de chaque fichier, sans exiger qu'un profil de boîtiers ait été préalablement déclaré ; il ne DOIT solliciter l'utilisateur (profil, numéro de série, étiquetage manuel) qu'au moment où une collision réelle est détectée par somme de contrôle et où ce tag ne suffit pas à distinguer les sources (cf. `specs/002-profil-boitiers-optionnel`, qui définit ce mécanisme de désambiguïsation en détail). |
| 95 | - **FR-016**: La détection d'un modèle de boîtier jamais rencontré auparavant dans les métadonnées d'un fichier ne DOIT, en l'absence de toute collision réelle, ni interrompre l'import ni exiger une action sur le profil de boîtiers. | 104 | - **FR-016**: La détection d'un modèle de boîtier jamais rencontré auparavant dans les métadonnées d'un fichier ne DOIT, en l'absence de toute collision réelle, ni interrompre l'import ni exiger une action sur le profil de boîtiers. |
| 96 | - **FR-017**: Le système DOIT attribuer un identifiant pérenne, indépendant du nom de fichier, à chaque photo importée, et l'inscrire dans les métadonnées embarquées du fichier. | 105 | - **FR-017**: Le système DOIT attribuer un identifiant pérenne, indépendant du nom de fichier, à chaque photo importée, et l'inscrire dans les métadonnées embarquées du fichier. |
| 97 | -- **FR-018**: Avant d'écrire quoi que ce soit sur l'archive (création de dossier, archivage des fichiers renommés, fusion dans un dossier existant), le système DOIT présenter à l'utilisateur un résumé de l'opération (nombre de fichiers concernés, dossier de destination, y compris son répertoire racine — année ou catégorie thématique, cf. `specs/004-categorisation-dossiers`) et attendre une confirmation explicite. | 106 | +- **FR-018**: Avant d'écrire quoi que ce soit dans l'espace de travail local définitif (création de dossier, publication des fichiers renommés, fusion dans un dossier existant), le système DOIT présenter à l'utilisateur un résumé de l'opération (nombre de fichiers concernés, dossier de destination, y compris son répertoire racine — année ou catégorie thématique, cf. `specs/004-categorisation-dossiers`) et attendre une confirmation explicite. |
| 98 | -- **FR-019**: Le système DOIT effectuer le transfert final vers l'archive à partir de la copie de travail locale déjà vérifiée et renommée, plutôt que par une copie directe de la carte mémoire vers l'archive. | 107 | +- **FR-019**: Le système DOIT effectuer le transfert final vers l'espace de travail local définitif à partir de la copie de travail temporaire déjà vérifiée, renommée et classée par format, plutôt que par une copie directe de la carte mémoire. Le système NE DOIT PAS écrire sur l'archive NAS à ce stade (cf. Clarifications) : l'archivage est une action distincte et explicite, hors périmètre de l'import lui-même (cf. Assumptions, `specs/005-checkout-reconciliation`). |
| 108 | +- **FR-020**: Le système DOIT classer chaque fichier maître renommé dans son dossier de format (`raw/` pour les RAW propriétaires et le DNG, `jpeg/`, `tiff/` pour les scans, ou un dossier dédié pour un format maître plus marginal — cf. `docs/archivage-photo-elements-cles.md` section 10), créé à la demande selon ce qui est réellement présent dans le groupe. Chaque fichier associé (sidecar) DOIT rejoindre le dossier de format de son fichier maître, jamais un dossier qui lui serait propre. | ||
| 99 | 109 | ||
| 100 | ### Key Entities | 110 | ### Key Entities |
| 101 | 111 | ||
| 102 | - **Carte mémoire** : support source d'un import, contenant des fichiers déjà importés lors de sessions précédentes et des fichiers réellement nouveaux à traiter. | 112 | - **Carte mémoire** : support source d'un import, contenant des fichiers déjà importés lors de sessions précédentes et des fichiers réellement nouveaux à traiter. |
| 103 | -- **Copie de travail locale** : copie temporaire et vérifiée par somme de contrôle des fichiers nouveaux d'une carte, base de toute l'analyse et du renommage avant archivage. | 113 | +- **Copie de travail temporaire** : copie vérifiée par somme de contrôle des fichiers nouveaux d'une carte, base de toute l'analyse, du renommage et du classement par format, avant publication dans l'espace de travail local définitif (distincte de la copie de travail locale ci-dessous). |
| 104 | -- **Groupe d'import** : sous-ensemble de fichiers nouvellement importés partageant une plage de dates contiguë, destiné à devenir un dossier ou un sous-dossier de l'archive. | 114 | +- **Groupe d'import** : sous-ensemble de fichiers nouvellement importés partageant une plage de dates contiguë, destiné à devenir un dossier ou un sous-dossier de l'espace de travail local (et, plus tard, de l'archive). |
| 105 | -- **Dossier** : unité d'archive résultant d'un groupe simple, avec sa structure par format et sa racine de sélection, vivant sous un répertoire racine (année par défaut ou catégorie thématique — cf. `specs/004-categorisation-dossiers`). | 115 | +- **Dossier de format** : sous-dossier créé à la demande selon ce qui est réellement présent (`raw/`, `jpeg/`, `tiff/`...), où chaque fichier maître renommé est classé (FR-020) — cf. `docs/archivage-photo-elements-cles.md` section 10, définition détaillée reprise par `specs/006-gui-tri-consultation`. |
| 116 | +- **Dossier** : unité de l'espace de travail local résultant d'un groupe simple, avec sa structure par format et sa racine de sélection ; vit sous un répertoire racine (année par défaut ou catégorie thématique — cf. `specs/004-categorisation-dossiers`), à la même position relative qu'il occupera plus tard sur l'archive une fois archivé. | ||
| 117 | +- **Copie de travail locale** : le dossier ci-dessus, une fois publié dans l'espace de travail local — prêt à être trié/retouché, pas encore archivé. | ||
| 106 | - **Dossier parent / sous-dossier** : structure à deux niveaux résultant d'un import s'intégrant à un voyage en plusieurs étapes, partageant le même répertoire racine (année ou catégorie) que le dossier parent qui les englobe. | 118 | - **Dossier parent / sous-dossier** : structure à deux niveaux résultant d'un import s'intégrant à un voyage en plusieurs étapes, partageant le même répertoire racine (année ou catégorie) que le dossier parent qui les englobe. |
| 107 | - **Profil de boîtiers** : liste optionnelle d'appareils, alimentée à la demande (cf. `specs/002-profil-boitiers-optionnel`), utilisée pour désambiguïser la source d'un fichier lorsqu'une collision réelle de nom d'origine survient et que le tag de modèle seul ne suffit pas à la résoudre. | 119 | - **Profil de boîtiers** : liste optionnelle d'appareils, alimentée à la demande (cf. `specs/002-profil-boitiers-optionnel`), utilisée pour désambiguïser la source d'un fichier lorsqu'une collision réelle de nom d'origine survient et que le tag de modèle seul ne suffit pas à la résoudre. |
| 108 | - **Fichier maître** : photo issue directement du boîtier (RAW ou JPEG selon les cas), renommée à l'import et jamais modifiée une fois archivée. | 120 | - **Fichier maître** : photo issue directement du boîtier (RAW ou JPEG selon les cas), renommée à l'import et jamais modifiée une fois archivée. |
| @@ -114,17 +126,19 @@ Un photographe est en voyage sur plusieurs villes. Pour la deuxième étape, il | |||
| 114 | ### Measurable Outcomes | 126 | ### Measurable Outcomes |
| 115 | 127 | ||
| 116 | - **SC-001**: 100% des fichiers copiés depuis une carte mémoire sont vérifiés par somme de contrôle avant que la carte ne soit signalée comme sûre à effacer, sans qu'aucun fichier ne nécessite plus d'une lecture complète de la carte, quelle que soit sa vitesse d'accès. | 128 | - **SC-001**: 100% des fichiers copiés depuis une carte mémoire sont vérifiés par somme de contrôle avant que la carte ne soit signalée comme sûre à effacer, sans qu'aucun fichier ne nécessite plus d'une lecture complète de la carte, quelle que soit sa vitesse d'accès. |
| 117 | -- **SC-002**: Pour un import à une seule journée sur un seul boîtier, l'utilisateur obtient un dossier archivé, correctement nommé et vérifié, en validant au plus deux décisions (titre, confirmation d'archivage). | 129 | +- **SC-002**: Pour un import à une seule journée sur un seul boîtier, l'utilisateur obtient un dossier local prêt à trier, correctement nommé, classé par format et vérifié, en validant au plus deux décisions (titre, confirmation de la copie locale). |
| 118 | - **SC-003**: 100% des dates de prise de vue aberrantes détectées sur une carte sont signalées à l'utilisateur plutôt qu'intégrées silencieusement au découpage en groupes. | 130 | - **SC-003**: 100% des dates de prise de vue aberrantes détectées sur une carte sont signalées à l'utilisateur plutôt qu'intégrées silencieusement au découpage en groupes. |
| 119 | - **SC-004**: 100% des collisions de nom de dossier sont présentées à l'utilisateur avec un choix explicite ; aucune collision ne provoque d'écrasement silencieux. | 131 | - **SC-004**: 100% des collisions de nom de dossier sont présentées à l'utilisateur avec un choix explicite ; aucune collision ne provoque d'écrasement silencieux. |
| 120 | - **SC-005**: 100% des paires de fichiers associés (fichier maître + JPEG jumeau ou sidecar) conservent leur appariement (même nom de base) après renommage à l'import. | 132 | - **SC-005**: 100% des paires de fichiers associés (fichier maître + JPEG jumeau ou sidecar) conservent leur appariement (même nom de base) après renommage à l'import. |
| 121 | -- **SC-006**: Aucune écriture sur l'archive (création de dossier, ajout de fichiers) ne survient sans qu'un résumé de l'opération n'ait été présenté et explicitement confirmé par l'utilisateur. | 133 | +- **SC-006**: Aucune écriture dans l'espace de travail local définitif (création de dossier, ajout de fichiers) ne survient sans qu'un résumé de l'opération n'ait été présenté et explicitement confirmé par l'utilisateur ; et 100% des imports se terminent sans aucune écriture sur l'archive NAS (cf. Clarifications). |
| 122 | - **SC-007**: Un utilisateur qui importe une carte couvrant un voyage en plusieurs étapes peut obtenir une structure à deux niveaux correctement rattachée sans avoir à renommer ou déplacer manuellement de dossier après l'import. | 134 | - **SC-007**: Un utilisateur qui importe une carte couvrant un voyage en plusieurs étapes peut obtenir une structure à deux niveaux correctement rattachée sans avoir à renommer ou déplacer manuellement de dossier après l'import. |
| 123 | -- **SC-008**: Le répertoire racine de destination (année ou catégorie thématique) est visible dans 100% des résumés présentés avant confirmation d'archivage (cf. `specs/004-categorisation-dossiers`). | 135 | +- **SC-008**: Le répertoire racine de destination (année ou catégorie thématique) est visible dans 100% des résumés présentés avant confirmation de la copie locale (cf. `specs/004-categorisation-dossiers`). |
| 136 | +- **SC-009**: 100% des fichiers maîtres publiés localement se trouvent dans leur dossier de format attendu (`raw/`, `jpeg/`, `tiff/`...), et chaque fichier associé dans le dossier de format de son fichier maître. | ||
| 137 | +- **SC-010**: Un import peut être mené jusqu'au bout (copie, groupes, renommage, classement, publication locale) sans qu'un partage d'archive configuré ait besoin d'être monté ou accessible durant l'opération. | ||
| 124 | 138 | ||
| 125 | ## Assumptions | 139 | ## Assumptions |
| 126 | 140 | ||
| 127 | -- Le module d'importation couvre le trajet complet depuis la carte mémoire jusqu'au premier archivage des fichiers renommés ; il ne couvre pas le tri ultérieur (sélection, promotion à la racine) ni la suppression de déchets évidents, qui relèvent du flux de réconciliation lors d'une édition (hors périmètre de ce module). | 141 | +- Le module d'importation couvre le trajet complet depuis la carte mémoire jusqu'à une copie de travail locale prête, classée par format et vérifiée (cf. Clarifications, 2026-09-22) ; il ne couvre ni le tri ultérieur (sélection, promotion à la racine), ni la suppression de déchets évidents, ni l'archivage effectif vers le NAS, qui relèvent tous du flux de réconciliation lors d'une édition (`specs/005-checkout-reconciliation`, hors périmètre de ce module). L'archivage d'un dossier jamais encore poussé au NAS réutilise `regine reconcile` tel quel : un manifeste vide y est créé au premier appel, donc chaque fichier local y est naturellement classé "nouveau" et archivable sur confirmation explicite — aucune capacité supplémentaire à construire côté `specs/005` pour ce cas. Une façade GUI dédiée à cette étape d'archivage n'est pas couverte par cette révision (CLI uniquement pour l'instant, `regine reconcile`) ; `specs/006-gui-tri-consultation` reste à jour séparément le jour où ce besoin est exprimé. |
| 128 | - Les fichiers présents sur la carte mémoire qui ne sont ni des photos (RAW, JPEG, TIFF) ni des fichiers associés reconnus (sidecars) sont hors périmètre de l'import : ils ne sont ni copiés ni pris en compte dans l'analyse. | 142 | - Les fichiers présents sur la carte mémoire qui ne sont ni des photos (RAW, JPEG, TIFF) ni des fichiers associés reconnus (sidecars) sont hors périmètre de l'import : ils ne sont ni copiés ni pris en compte dans l'analyse. |
| 129 | - Le profil de boîtiers est une capacité optionnelle, alimentée à la demande et non un prérequis avant le premier import (cf. `specs/002-profil-boitiers-optionnel`, qui révise et détaille ce comportement) : ce module se désambiguïse par défaut via le tag EXIF de modèle, et ne sollicite le profil, un numéro de série, ou un étiquetage manuel qu'en cas de collision réelle non résolue par ce tag seul. | 143 | - Le profil de boîtiers est une capacité optionnelle, alimentée à la demande et non un prérequis avant le premier import (cf. `specs/002-profil-boitiers-optionnel`, qui révise et détaille ce comportement) : ce module se désambiguïse par défaut via le tag EXIF de modèle, et ne sollicite le profil, un numéro de série, ou un étiquetage manuel qu'en cas de collision réelle non résolue par ce tag seul. |
| 130 | - Le mécanisme de checkout/réconciliation d'un dossier déjà archivé (utilisé au cas de fusion vers un dossier existant sur l'archive) est une capacité déjà disponible ou livrée avant ou avec ce module ; ce module ne redéfinit pas ce mécanisme, il s'appuie dessus. | 144 | - Le mécanisme de checkout/réconciliation d'un dossier déjà archivé (utilisé au cas de fusion vers un dossier existant sur l'archive) est une capacité déjà disponible ou livrée avant ou avec ce module ; ce module ne redéfinit pas ce mécanisme, il s'appuie dessus. |
modified
specs/001-import-photos/tasks.md +17 -0 | @@ -131,6 +131,23 @@ Ce module est le premier point d'intégration réel de trois autres specs déjà | ||
| 131 | 131 | |
| 132 | 132 | --- |
| 133 | 133 | |
| 134 | +## Phase 7: Révision 2026-09-22 — archivage différé et classement par format | |
| 135 | + | |
| 136 | +**Contexte** : deux écarts trouvés par test manuel de l'onglet d'import GUI (`specs/006-gui-tri-consultation` User Story 3) — cf. Clarifications de `spec.md`. (1) L'import poussait automatiquement vers l'archive NAS en fin d'opération, sans laisser au photographe l'occasion de relire/retoucher sa copie locale d'abord, et empêchait de tester l'import sans accès à un NAS réel. (2) Les fichiers n'étaient jamais classés dans un dossier de format (`raw/`/`jpeg/`/`tiff/`), contrairement à `docs/archivage-photo-elements-cles.md` section 10 et à l'écran de tri (`specs/006`) qui suppose cette structure déjà en place. | |
| 137 | + | |
| 138 | +- [X] T039 [P] Déplacer `dossier_de_format` (et les ensembles d'extensions RAW/TIFF/JPEG) de `regine_core.dossier.selection` vers `regine_core.import_carte.copie`, ré-exportée depuis `dossier.selection` pour ne pas casser les appelants existants (`specs/006`) — évite une dépendance inversée entre les deux modules (Principe VI) | |
| 139 | +- [X] T040 Créer `regine_core.import_carte.classement.classer_par_format(fichiers, racine)` (FR-020) : déplace chaque fichier maître dans son dossier de format (déduit de sa propre extension, jamais celui d'un autre maître de même nom de base — cas RAW+JPEG jumeau), et chaque sidecar dans le dossier du maître RAW partageant son nom de base s'il y en a un, sinon celui de n'importe quel autre maître. Testé dans `packages/regine-core/tests/unit/test_classement.py` (5/5 tests, dont le cas jumeau RAW+JPEG+sidecar) | |
| 140 | +- [X] T041 Remplacer `push.archiver`/`push.publier_copie_locale` (écriture NAS + manifeste + verrou immédiats) par `push.publier_localement` (copie vérifiée vers l'espace de travail local uniquement, aucun manifeste/verrou/écriture NAS) — `CollisionNomArchiveError` renommée `CollisionNomLocalError`. Ancien fichier de test `test_push_archiver.py` remplacé par `test_push_publier_localement.py` (5/5 tests) | |
| 141 | +- [X] T042 Mettre à jour `test_pipeline_import_simple.py` (intégration) : ajoute l'étape de classement, publie vers `dossier_local` plutôt que `dossier_archive`, vérifie explicitement que l'archive n'est jamais créée | |
| 142 | +- [X] T043 Mettre à jour `packages/regine-cli/src/regine_cli/import_cmd.py` : `classer_par_format` avant `attribuer_identifiants`, `publier_localement` remplace `archiver`+`publier_copie_locale`, message final indiquant la commande `regine reconcile` à lancer plus tard pour archiver | |
| 143 | +- [X] T044 Mettre à jour `packages/regine-gui/src/regine_gui/import_view.py` : import ne bloque plus si le partage SMB n'est pas monté (seul son chemin, via `chemin_local_du_partage`, sert au calcul — plus `resoudre_archive_locale`, qui exigeait une disponibilité réelle) ; `_ArchivageWorker` renommé `_PublicationLocaleWorker`, regroupant désormais classement + identifiants + publication locale sur le thread d'arrière-plan (déplacement supplémentaire par rapport à la première version de US3, qui ne déplaçait que l'ancien `archiver`/`publier_copie_locale`) ; dialogues et journal reformulés (« Confirmer la copie de travail locale », jamais « archivage ») | |
| 144 | +- [X] T045 [P] `ruff check --fix`/`ruff format` sur `packages/regine-core`, `packages/regine-cli`, `packages/regine-gui` ; validation manuelle en mode headless (import complet sans archive configurée/montée, structure `raw/`/`jpeg/` obtenue, aucune écriture sous `/Volumes/...`) — 176/176 tests `regine-core`/`regine-cli` au vert | |
| 145 | +- [X] T046 Mettre à jour `spec.md` (Clarifications, User Story 1, FR-018/019, nouveau FR-020, Key Entities, Success Criteria SC-002/006/008 reformulés + nouveaux SC-009/SC-010, Assumptions), `data-model.md`, `contracts/cli-import.md`, `quickstart.md`, `checklists/requirements.md` pour refléter cette révision | |
| 146 | + | |
| 147 | +**Checkpoint** : l'import se termine par une copie de travail locale classée par format, jamais par une écriture sur l'archive ; l'archivage réutilise `regine reconcile` (`specs/005-checkout-reconciliation`) sans aucune modification de ce dernier. | |
| 148 | + | |
| 149 | +--- | |
| 150 | + | |
| 134 | 151 | ## Dependencies & Execution Order |
| 135 | 152 | |
| 136 | 153 | ### Phase Dependencies |
| @@ -131,6 +131,23 @@ Ce module est le premier point d'intégration réel de trois autres specs déjà | |||
| 131 | 131 | ||
| 132 | --- | 132 | --- |
| 133 | 133 | ||
| 134 | +## Phase 7: Révision 2026-09-22 — archivage différé et classement par format | ||
| 135 | + | ||
| 136 | +**Contexte** : deux écarts trouvés par test manuel de l'onglet d'import GUI (`specs/006-gui-tri-consultation` User Story 3) — cf. Clarifications de `spec.md`. (1) L'import poussait automatiquement vers l'archive NAS en fin d'opération, sans laisser au photographe l'occasion de relire/retoucher sa copie locale d'abord, et empêchait de tester l'import sans accès à un NAS réel. (2) Les fichiers n'étaient jamais classés dans un dossier de format (`raw/`/`jpeg/`/`tiff/`), contrairement à `docs/archivage-photo-elements-cles.md` section 10 et à l'écran de tri (`specs/006`) qui suppose cette structure déjà en place. | ||
| 137 | + | ||
| 138 | +- [X] T039 [P] Déplacer `dossier_de_format` (et les ensembles d'extensions RAW/TIFF/JPEG) de `regine_core.dossier.selection` vers `regine_core.import_carte.copie`, ré-exportée depuis `dossier.selection` pour ne pas casser les appelants existants (`specs/006`) — évite une dépendance inversée entre les deux modules (Principe VI) | ||
| 139 | +- [X] T040 Créer `regine_core.import_carte.classement.classer_par_format(fichiers, racine)` (FR-020) : déplace chaque fichier maître dans son dossier de format (déduit de sa propre extension, jamais celui d'un autre maître de même nom de base — cas RAW+JPEG jumeau), et chaque sidecar dans le dossier du maître RAW partageant son nom de base s'il y en a un, sinon celui de n'importe quel autre maître. Testé dans `packages/regine-core/tests/unit/test_classement.py` (5/5 tests, dont le cas jumeau RAW+JPEG+sidecar) | ||
| 140 | +- [X] T041 Remplacer `push.archiver`/`push.publier_copie_locale` (écriture NAS + manifeste + verrou immédiats) par `push.publier_localement` (copie vérifiée vers l'espace de travail local uniquement, aucun manifeste/verrou/écriture NAS) — `CollisionNomArchiveError` renommée `CollisionNomLocalError`. Ancien fichier de test `test_push_archiver.py` remplacé par `test_push_publier_localement.py` (5/5 tests) | ||
| 141 | +- [X] T042 Mettre à jour `test_pipeline_import_simple.py` (intégration) : ajoute l'étape de classement, publie vers `dossier_local` plutôt que `dossier_archive`, vérifie explicitement que l'archive n'est jamais créée | ||
| 142 | +- [X] T043 Mettre à jour `packages/regine-cli/src/regine_cli/import_cmd.py` : `classer_par_format` avant `attribuer_identifiants`, `publier_localement` remplace `archiver`+`publier_copie_locale`, message final indiquant la commande `regine reconcile` à lancer plus tard pour archiver | ||
| 143 | +- [X] T044 Mettre à jour `packages/regine-gui/src/regine_gui/import_view.py` : import ne bloque plus si le partage SMB n'est pas monté (seul son chemin, via `chemin_local_du_partage`, sert au calcul — plus `resoudre_archive_locale`, qui exigeait une disponibilité réelle) ; `_ArchivageWorker` renommé `_PublicationLocaleWorker`, regroupant désormais classement + identifiants + publication locale sur le thread d'arrière-plan (déplacement supplémentaire par rapport à la première version de US3, qui ne déplaçait que l'ancien `archiver`/`publier_copie_locale`) ; dialogues et journal reformulés (« Confirmer la copie de travail locale », jamais « archivage ») | ||
| 144 | +- [X] T045 [P] `ruff check --fix`/`ruff format` sur `packages/regine-core`, `packages/regine-cli`, `packages/regine-gui` ; validation manuelle en mode headless (import complet sans archive configurée/montée, structure `raw/`/`jpeg/` obtenue, aucune écriture sous `/Volumes/...`) — 176/176 tests `regine-core`/`regine-cli` au vert | ||
| 145 | +- [X] T046 Mettre à jour `spec.md` (Clarifications, User Story 1, FR-018/019, nouveau FR-020, Key Entities, Success Criteria SC-002/006/008 reformulés + nouveaux SC-009/SC-010, Assumptions), `data-model.md`, `contracts/cli-import.md`, `quickstart.md`, `checklists/requirements.md` pour refléter cette révision | ||
| 146 | + | ||
| 147 | +**Checkpoint** : l'import se termine par une copie de travail locale classée par format, jamais par une écriture sur l'archive ; l'archivage réutilise `regine reconcile` (`specs/005-checkout-reconciliation`) sans aucune modification de ce dernier. | ||
| 148 | + | ||
| 149 | +--- | ||
| 150 | + | ||
| 134 | ## Dependencies & Execution Order | 151 | ## Dependencies & Execution Order |
| 135 | 152 | ||
| 136 | ### Phase Dependencies | 153 | ### Phase Dependencies |
modified
specs/005-checkout-reconciliation/spec.md +1 -1 | @@ -165,7 +165,7 @@ Pour un tri rapide (ex. sélection en vacances), le photographe ne veut sortir q | ||
| 165 | 165 | |
| 166 | 166 | ## Assumptions |
| 167 | 167 | |
| 168 | -- Le premier archivage d'un dossier nouvellement importé (`specs/001-import-photos`) ne passe pas par ce mécanisme de réconciliation : il n'existe pas encore d'état de référence à comparer, seulement une création initiale. Ce mécanisme s'applique aux dossiers déjà archivés que l'on ré-édite. | |
| 168 | +- **Révisé le 2026-09-22** : le premier archivage d'un dossier nouvellement importé (`specs/001-import-photos`) passe désormais par ce mécanisme de réconciliation, sans aucune modification de celui-ci. Depuis que l'import (`specs/001`) s'arrête à une copie de travail locale sans jamais écrire sur le NAS (cf. Clarifications de `specs/001-import-photos/spec.md`), un dossier jamais encore archivé n'a tout simplement pas encore de manifeste sur l'archive : `ouvrir_ou_creer` en crée un vide au premier `regine reconcile`, `comparer` classe alors chaque fichier local en "nouveau" (aucune correspondance dans un manifeste vide), et `archiver` les écrit sur confirmation explicite de l'utilisateur — exactement le chemin déjà emprunté pour une réconciliation ordinaire, sans code dédié à un "premier archivage" (Principe VI). Ancienne assumption (devenue fausse) : « ne passe pas par ce mécanisme... » — supposait à tort que l'import écrirait lui-même l'état initial sur l'archive. | |
| 169 | 169 | - Le travail à plusieurs avec verrouillage fin (édition concurrente de deux sous-dossiers d'un même dossier parent) reste hors périmètre de cette itération, cohérent avec les notes de conception du projet : le verrouillage porte toujours sur le dossier complet (parent et tous ses sous-dossiers), jamais à un niveau plus fin. |
| 170 | 170 | - La vérification périodique indépendante du checkout (scrub, cf. constitution § Contraintes techniques) est une capacité distincte, hors périmètre de cette spécification ; elle réutilisera le même mécanisme d'empreintes de contenu sans dépendre d'un checkout. |
| 171 | 171 | - Le déverrouillage forcé d'un dossier resté verrouillé anormalement longtemps (oubli utilisateur) reste une action manuelle et avertie, hors périmètre détaillé de cette itération ; aucun mécanisme de récupération automatique n'est couvert ici. |
| @@ -165,7 +165,7 @@ Pour un tri rapide (ex. sélection en vacances), le photographe ne veut sortir q | |||
| 165 | 165 | ||
| 166 | ## Assumptions | 166 | ## Assumptions |
| 167 | 167 | ||
| 168 | -- Le premier archivage d'un dossier nouvellement importé (`specs/001-import-photos`) ne passe pas par ce mécanisme de réconciliation : il n'existe pas encore d'état de référence à comparer, seulement une création initiale. Ce mécanisme s'applique aux dossiers déjà archivés que l'on ré-édite. | 168 | +- **Révisé le 2026-09-22** : le premier archivage d'un dossier nouvellement importé (`specs/001-import-photos`) passe désormais par ce mécanisme de réconciliation, sans aucune modification de celui-ci. Depuis que l'import (`specs/001`) s'arrête à une copie de travail locale sans jamais écrire sur le NAS (cf. Clarifications de `specs/001-import-photos/spec.md`), un dossier jamais encore archivé n'a tout simplement pas encore de manifeste sur l'archive : `ouvrir_ou_creer` en crée un vide au premier `regine reconcile`, `comparer` classe alors chaque fichier local en "nouveau" (aucune correspondance dans un manifeste vide), et `archiver` les écrit sur confirmation explicite de l'utilisateur — exactement le chemin déjà emprunté pour une réconciliation ordinaire, sans code dédié à un "premier archivage" (Principe VI). Ancienne assumption (devenue fausse) : « ne passe pas par ce mécanisme... » — supposait à tort que l'import écrirait lui-même l'état initial sur l'archive. |
| 169 | - Le travail à plusieurs avec verrouillage fin (édition concurrente de deux sous-dossiers d'un même dossier parent) reste hors périmètre de cette itération, cohérent avec les notes de conception du projet : le verrouillage porte toujours sur le dossier complet (parent et tous ses sous-dossiers), jamais à un niveau plus fin. | 169 | - Le travail à plusieurs avec verrouillage fin (édition concurrente de deux sous-dossiers d'un même dossier parent) reste hors périmètre de cette itération, cohérent avec les notes de conception du projet : le verrouillage porte toujours sur le dossier complet (parent et tous ses sous-dossiers), jamais à un niveau plus fin. |
| 170 | - La vérification périodique indépendante du checkout (scrub, cf. constitution § Contraintes techniques) est une capacité distincte, hors périmètre de cette spécification ; elle réutilisera le même mécanisme d'empreintes de contenu sans dépendre d'un checkout. | 170 | - La vérification périodique indépendante du checkout (scrub, cf. constitution § Contraintes techniques) est une capacité distincte, hors périmètre de cette spécification ; elle réutilisera le même mécanisme d'empreintes de contenu sans dépendre d'un checkout. |
| 171 | - Le déverrouillage forcé d'un dossier resté verrouillé anormalement longtemps (oubli utilisateur) reste une action manuelle et avertie, hors périmètre détaillé de cette itération ; aucun mécanisme de récupération automatique n'est couvert ici. | 171 | - Le déverrouillage forcé d'un dossier resté verrouillé anormalement longtemps (oubli utilisateur) reste une action manuelle et avertie, hors périmètre détaillé de cette itération ; aucun mécanisme de récupération automatique n'est couvert ici. |
modified
specs/006-gui-tri-consultation/contracts/regine-core-api.md +3 -1 | @@ -43,8 +43,9 @@ Aucune fonction nouvelle : l'onglet d'import de la GUI (`import_view.py`) appell | ||
| 43 | 43 | - `destination.resoudre_destination(...) -> DestinationChoisie` / `destination.resoudre_fusion(...) -> DestinationChoisie` |
| 44 | 44 | - `destination.root_location_depuis_chemin(chemin_relatif, *, archive_root, local_root) -> RootLocation` — **nouvelle fonction, extraite pendant l'implémentation de US3** : reconstruit le `RootLocation` d'un dossier parent déjà existant à partir de son premier segment de chemin. Existait déjà comme fonction privée (`_root_location_depuis_chemin`) dans `regine_cli.import_cmd` ; l'onglet d'import GUI en avait besoin à l'identique pour le cas « nouveau sous-dossier ». Plutôt que de la dupliquer une seconde fois (même schéma déjà rencontré et corrigé pour `smb.resoudre_archive_locale`, cf. Notes d'implémentation ci-dessous), elle a été rendue publique dans `regine_core.import_carte.destination`, et `import_cmd.py` a été mis à jour pour l'appeler elle aussi — aucun changement de comportement côté CLI. |
| 45 | 45 | - `nommage.construire_nom_dossier`/`construire_nom_dossier_parent`/`construire_nom_sous_dossier`/`renommer_fichiers`/`resoudre_collision_nom` |
| 46 | +- `classement.classer_par_format(fichiers, racine) -> None` — **nouvelle fonction côté `specs/001`, révision 2026-09-22** : classe chaque fichier maître renommé dans son dossier de format (`raw`/`jpeg`/`tiff`/...), les sidecars rejoignant le dossier de leur maître. Appelée après `renommer_fichiers` et avant `attribuer_identifiants`/`preparer_resume`. | |
| 46 | 47 | - `identifiant.attribuer_identifiants(chemins_maitres) -> None` |
| 47 | -- `push.preparer_resume(chemins, dossier_archive) -> Resume` / `push.archiver(...)` / `push.publier_copie_locale(...)` | |
| 48 | +- `push.preparer_resume(chemins, dossier_local) -> Resume` / `push.publier_localement(chemins, local_tmp, dossier_local)` — **renommée depuis `push.archiver`/`push.publier_copie_locale`, révision 2026-09-22** : n'écrit plus que dans l'espace de travail local, jamais sur l'archive NAS (cf. Clarifications de `specs/001-import-photos/spec.md`) ; `CollisionNomArchiveError` renommée `CollisionNomLocalError`. | |
| 48 | 49 | |
| 49 | 50 | **Différence GUI vs CLI** : uniquement la façon de recueillir chaque décision utilisateur (dialogues Qt/formulaires côté GUI, `input()` côté CLI) — jamais la fonction appelée ni le résultat produit sur le système de fichiers (US3 Acceptance Scenarios 1 à 5, SC-007). |
| 50 | 51 | |
| @@ -53,3 +54,4 @@ Aucune fonction nouvelle : l'onglet d'import de la GUI (`import_view.py`) appell | ||
| 53 | 54 | - **Écart trouvé par un test dès l'implémentation** : la première version de `promouvoir` prenait seulement `fichier: Path` et déduisait la racine cible en vérifiant si `fichier.parent.name` correspondait à un dossier de format connu. Ce test a échoué sur le cas « promotion en deux temps » (racine d'une étape, puis racine du dossier parent, section 10 des notes d'archivage) : rien ne distingue, dans la seule position d'un fichier déjà hors de son dossier de format, « déjà promu à la racine d'une étape » de « déjà promu à la racine d'un dossier parent » — les deux se ressemblent structurellement. Corrigé en rendant `dossier` explicite : chaque promotion (étape ou dossier parent) est un geste distinct avec sa propre cible, jamais deviné. `retrograder(fichier)` n'a pas ce problème (un seul niveau possible, toujours vers le dossier de format immédiatement sous le parent courant du fichier) et garde sa signature à un seul argument. |
| 54 | 55 | - `regrouper` retourne des `EntreeRegroupee.fichiers: tuple[Path, ...]` (pas des champs fixes `fichier_raw`/`fichier_jpeg`) — cf. correction dans `data-model.md`. |
| 55 | 56 | - **Bug réel trouvé par test manuel headless de la GUI de consultation** : `archive_cmd.py` (CLI, écrit en premier par contrainte CLI-first) et `consultation_view.py` (GUI, écrit ensuite) dupliquaient chacun la même logique de résolution du point de montage de l'archive configurée, avec des styles d'import différents (import différé dans un cas, import de module dans l'autre) — un test qui monkeypatchait `smb.chemin_local_du_partage` pour l'un des deux appelants n'affectait pas l'autre, révélant une incohérence réelle entre CLI et GUI, pas seulement un artefact de test. Corrigé en extrayant `config.smb.resoudre_archive_locale`, désormais seule source de vérité pour les deux façades (Principe VI). |
| 57 | +- **Bugs réels trouvés par test manuel de l'utilisateur sur l'onglet d'import (2026-09-22, pas seulement le script headless)** : (1) `push.archiver`/`push.publier_copie_locale` (recalcul de plusieurs sommes de contrôle par fichier) tournaient sur le thread principal Qt sans retour visuel, gelant toute la fenêtre le temps de l'opération — corrigé en les déplaçant (sous leurs nouveaux noms `classer_par_format`/`publier_localement`) sur un `QThread` dédié (`_PublicationLocaleWorker`). (2) Les signaux de ce worker étaient d'abord connectés à des lambdas plutôt qu'à de vraies méthodes liées de `self` — Qt ne détectait alors pas l'affinité de thread du récepteur et exécutait les slots en connexion directe *dans le thread d'arrière-plan*, provoquant un `sqlite3.ProgrammingError` (connexion de contexte fermée hors de son thread de création). Corrigé en connectant les signaux à de vraies méthodes de `self` (`QMainWindow`), pour que Qt utilise une connexion en file d'attente vers le thread principal. (3) L'onglet exigeait que le partage SMB soit monté pour importer (`resoudre_archive_locale`, qui déclenche une vérification réelle) alors qu'aucune écriture n'y a lieu à l'import — remplacé par `config.smb.chemin_local_du_partage` (calcul de chemin pur, sans vérification de montage). | |
| @@ -43,8 +43,9 @@ Aucune fonction nouvelle : l'onglet d'import de la GUI (`import_view.py`) appell | |||
| 43 | - `destination.resoudre_destination(...) -> DestinationChoisie` / `destination.resoudre_fusion(...) -> DestinationChoisie` | 43 | - `destination.resoudre_destination(...) -> DestinationChoisie` / `destination.resoudre_fusion(...) -> DestinationChoisie` |
| 44 | - `destination.root_location_depuis_chemin(chemin_relatif, *, archive_root, local_root) -> RootLocation` — **nouvelle fonction, extraite pendant l'implémentation de US3** : reconstruit le `RootLocation` d'un dossier parent déjà existant à partir de son premier segment de chemin. Existait déjà comme fonction privée (`_root_location_depuis_chemin`) dans `regine_cli.import_cmd` ; l'onglet d'import GUI en avait besoin à l'identique pour le cas « nouveau sous-dossier ». Plutôt que de la dupliquer une seconde fois (même schéma déjà rencontré et corrigé pour `smb.resoudre_archive_locale`, cf. Notes d'implémentation ci-dessous), elle a été rendue publique dans `regine_core.import_carte.destination`, et `import_cmd.py` a été mis à jour pour l'appeler elle aussi — aucun changement de comportement côté CLI. | 44 | - `destination.root_location_depuis_chemin(chemin_relatif, *, archive_root, local_root) -> RootLocation` — **nouvelle fonction, extraite pendant l'implémentation de US3** : reconstruit le `RootLocation` d'un dossier parent déjà existant à partir de son premier segment de chemin. Existait déjà comme fonction privée (`_root_location_depuis_chemin`) dans `regine_cli.import_cmd` ; l'onglet d'import GUI en avait besoin à l'identique pour le cas « nouveau sous-dossier ». Plutôt que de la dupliquer une seconde fois (même schéma déjà rencontré et corrigé pour `smb.resoudre_archive_locale`, cf. Notes d'implémentation ci-dessous), elle a été rendue publique dans `regine_core.import_carte.destination`, et `import_cmd.py` a été mis à jour pour l'appeler elle aussi — aucun changement de comportement côté CLI. |
| 45 | - `nommage.construire_nom_dossier`/`construire_nom_dossier_parent`/`construire_nom_sous_dossier`/`renommer_fichiers`/`resoudre_collision_nom` | 45 | - `nommage.construire_nom_dossier`/`construire_nom_dossier_parent`/`construire_nom_sous_dossier`/`renommer_fichiers`/`resoudre_collision_nom` |
| 46 | +- `classement.classer_par_format(fichiers, racine) -> None` — **nouvelle fonction côté `specs/001`, révision 2026-09-22** : classe chaque fichier maître renommé dans son dossier de format (`raw`/`jpeg`/`tiff`/...), les sidecars rejoignant le dossier de leur maître. Appelée après `renommer_fichiers` et avant `attribuer_identifiants`/`preparer_resume`. | ||
| 46 | - `identifiant.attribuer_identifiants(chemins_maitres) -> None` | 47 | - `identifiant.attribuer_identifiants(chemins_maitres) -> None` |
| 47 | -- `push.preparer_resume(chemins, dossier_archive) -> Resume` / `push.archiver(...)` / `push.publier_copie_locale(...)` | 48 | +- `push.preparer_resume(chemins, dossier_local) -> Resume` / `push.publier_localement(chemins, local_tmp, dossier_local)` — **renommée depuis `push.archiver`/`push.publier_copie_locale`, révision 2026-09-22** : n'écrit plus que dans l'espace de travail local, jamais sur l'archive NAS (cf. Clarifications de `specs/001-import-photos/spec.md`) ; `CollisionNomArchiveError` renommée `CollisionNomLocalError`. |
| 48 | 49 | ||
| 49 | **Différence GUI vs CLI** : uniquement la façon de recueillir chaque décision utilisateur (dialogues Qt/formulaires côté GUI, `input()` côté CLI) — jamais la fonction appelée ni le résultat produit sur le système de fichiers (US3 Acceptance Scenarios 1 à 5, SC-007). | 50 | **Différence GUI vs CLI** : uniquement la façon de recueillir chaque décision utilisateur (dialogues Qt/formulaires côté GUI, `input()` côté CLI) — jamais la fonction appelée ni le résultat produit sur le système de fichiers (US3 Acceptance Scenarios 1 à 5, SC-007). |
| 50 | 51 | ||
| @@ -53,3 +54,4 @@ Aucune fonction nouvelle : l'onglet d'import de la GUI (`import_view.py`) appell | |||
| 53 | - **Écart trouvé par un test dès l'implémentation** : la première version de `promouvoir` prenait seulement `fichier: Path` et déduisait la racine cible en vérifiant si `fichier.parent.name` correspondait à un dossier de format connu. Ce test a échoué sur le cas « promotion en deux temps » (racine d'une étape, puis racine du dossier parent, section 10 des notes d'archivage) : rien ne distingue, dans la seule position d'un fichier déjà hors de son dossier de format, « déjà promu à la racine d'une étape » de « déjà promu à la racine d'un dossier parent » — les deux se ressemblent structurellement. Corrigé en rendant `dossier` explicite : chaque promotion (étape ou dossier parent) est un geste distinct avec sa propre cible, jamais deviné. `retrograder(fichier)` n'a pas ce problème (un seul niveau possible, toujours vers le dossier de format immédiatement sous le parent courant du fichier) et garde sa signature à un seul argument. | 54 | - **Écart trouvé par un test dès l'implémentation** : la première version de `promouvoir` prenait seulement `fichier: Path` et déduisait la racine cible en vérifiant si `fichier.parent.name` correspondait à un dossier de format connu. Ce test a échoué sur le cas « promotion en deux temps » (racine d'une étape, puis racine du dossier parent, section 10 des notes d'archivage) : rien ne distingue, dans la seule position d'un fichier déjà hors de son dossier de format, « déjà promu à la racine d'une étape » de « déjà promu à la racine d'un dossier parent » — les deux se ressemblent structurellement. Corrigé en rendant `dossier` explicite : chaque promotion (étape ou dossier parent) est un geste distinct avec sa propre cible, jamais deviné. `retrograder(fichier)` n'a pas ce problème (un seul niveau possible, toujours vers le dossier de format immédiatement sous le parent courant du fichier) et garde sa signature à un seul argument. |
| 54 | - `regrouper` retourne des `EntreeRegroupee.fichiers: tuple[Path, ...]` (pas des champs fixes `fichier_raw`/`fichier_jpeg`) — cf. correction dans `data-model.md`. | 55 | - `regrouper` retourne des `EntreeRegroupee.fichiers: tuple[Path, ...]` (pas des champs fixes `fichier_raw`/`fichier_jpeg`) — cf. correction dans `data-model.md`. |
| 55 | - **Bug réel trouvé par test manuel headless de la GUI de consultation** : `archive_cmd.py` (CLI, écrit en premier par contrainte CLI-first) et `consultation_view.py` (GUI, écrit ensuite) dupliquaient chacun la même logique de résolution du point de montage de l'archive configurée, avec des styles d'import différents (import différé dans un cas, import de module dans l'autre) — un test qui monkeypatchait `smb.chemin_local_du_partage` pour l'un des deux appelants n'affectait pas l'autre, révélant une incohérence réelle entre CLI et GUI, pas seulement un artefact de test. Corrigé en extrayant `config.smb.resoudre_archive_locale`, désormais seule source de vérité pour les deux façades (Principe VI). | 56 | - **Bug réel trouvé par test manuel headless de la GUI de consultation** : `archive_cmd.py` (CLI, écrit en premier par contrainte CLI-first) et `consultation_view.py` (GUI, écrit ensuite) dupliquaient chacun la même logique de résolution du point de montage de l'archive configurée, avec des styles d'import différents (import différé dans un cas, import de module dans l'autre) — un test qui monkeypatchait `smb.chemin_local_du_partage` pour l'un des deux appelants n'affectait pas l'autre, révélant une incohérence réelle entre CLI et GUI, pas seulement un artefact de test. Corrigé en extrayant `config.smb.resoudre_archive_locale`, désormais seule source de vérité pour les deux façades (Principe VI). |
| 57 | +- **Bugs réels trouvés par test manuel de l'utilisateur sur l'onglet d'import (2026-09-22, pas seulement le script headless)** : (1) `push.archiver`/`push.publier_copie_locale` (recalcul de plusieurs sommes de contrôle par fichier) tournaient sur le thread principal Qt sans retour visuel, gelant toute la fenêtre le temps de l'opération — corrigé en les déplaçant (sous leurs nouveaux noms `classer_par_format`/`publier_localement`) sur un `QThread` dédié (`_PublicationLocaleWorker`). (2) Les signaux de ce worker étaient d'abord connectés à des lambdas plutôt qu'à de vraies méthodes liées de `self` — Qt ne détectait alors pas l'affinité de thread du récepteur et exécutait les slots en connexion directe *dans le thread d'arrière-plan*, provoquant un `sqlite3.ProgrammingError` (connexion de contexte fermée hors de son thread de création). Corrigé en connectant les signaux à de vraies méthodes de `self` (`QMainWindow`), pour que Qt utilise une connexion en file d'attente vers le thread principal. (3) L'onglet exigeait que le partage SMB soit monté pour importer (`resoudre_archive_locale`, qui déclenche une vérification réelle) alors qu'aucune écriture n'y a lieu à l'import — remplacé par `config.smb.chemin_local_du_partage` (calcul de chemin pur, sans vérification de montage). | ||
modified
specs/006-gui-tri-consultation/data-model.md +2 -2 | @@ -77,6 +77,6 @@ Résultat d'une restauration d'un fichier repéré en consultation (US2, FR-009) | ||
| 77 | 77 | | `fichiers` | liste de `FichierCandidat` | Résultat de `copier_carte` (déjà copiés et vérifiés en local, cf. `specs/001` FR-001) | |
| 78 | 78 | | `groupes` | liste de `GroupeImport` | Résultat de `decouper_en_groupes`, éventuellement ajusté par `detacher_jours` (US3 Acceptance Scenario 2) | |
| 79 | 79 | | `groupe_courant` | `GroupeImport` \| `None` | Groupe en cours de traitement (destination, titre, confirmation) ; `None` une fois tous les groupes traités | |
| 80 | -| `resume_avant_ecriture` | `preparer_resume(...)` (déjà défini par `specs/001`) | Affiché et confirmé explicitement avant tout appel à `archiver` (US3 Acceptance Scenario 4, FR-015) | | |
| 80 | +| `resume_avant_ecriture` | `preparer_resume(...)` (déjà défini par `specs/001`) | Affiché et confirmé explicitement avant tout appel à `publier_localement` (US3 Acceptance Scenario 4, FR-015) | | |
| 81 | 81 | |
| 82 | -**Effet d'un import confirmé** : identique à celui de la CLI — écriture sur l'archive (`archiver`) puis publication de la copie de travail locale (`publier_copie_locale`), sans étape supplémentaire propre à la GUI. | |
| 82 | +**Effet d'un import confirmé** : identique à celui de la CLI — classement par format (`classer_par_format`), attribution d'identifiants, puis publication dans l'espace de travail local (`publier_localement`), sans étape supplémentaire propre à la GUI. **Révision 2026-09-22** : plus aucune écriture sur l'archive NAS à ce stade (`archiver`/`publier_copie_locale` supprimées côté `specs/001`) — l'archivage devient une action distincte et ultérieure (`regine reconcile`), hors périmètre de cet écran (cf. Assumptions de `spec.md`). | |
| @@ -77,6 +77,6 @@ Résultat d'une restauration d'un fichier repéré en consultation (US2, FR-009) | |||
| 77 | | `fichiers` | liste de `FichierCandidat` | Résultat de `copier_carte` (déjà copiés et vérifiés en local, cf. `specs/001` FR-001) | | 77 | | `fichiers` | liste de `FichierCandidat` | Résultat de `copier_carte` (déjà copiés et vérifiés en local, cf. `specs/001` FR-001) | |
| 78 | | `groupes` | liste de `GroupeImport` | Résultat de `decouper_en_groupes`, éventuellement ajusté par `detacher_jours` (US3 Acceptance Scenario 2) | | 78 | | `groupes` | liste de `GroupeImport` | Résultat de `decouper_en_groupes`, éventuellement ajusté par `detacher_jours` (US3 Acceptance Scenario 2) | |
| 79 | | `groupe_courant` | `GroupeImport` \| `None` | Groupe en cours de traitement (destination, titre, confirmation) ; `None` une fois tous les groupes traités | | 79 | | `groupe_courant` | `GroupeImport` \| `None` | Groupe en cours de traitement (destination, titre, confirmation) ; `None` une fois tous les groupes traités | |
| 80 | -| `resume_avant_ecriture` | `preparer_resume(...)` (déjà défini par `specs/001`) | Affiché et confirmé explicitement avant tout appel à `archiver` (US3 Acceptance Scenario 4, FR-015) | | 80 | +| `resume_avant_ecriture` | `preparer_resume(...)` (déjà défini par `specs/001`) | Affiché et confirmé explicitement avant tout appel à `publier_localement` (US3 Acceptance Scenario 4, FR-015) | |
| 81 | 81 | ||
| 82 | -**Effet d'un import confirmé** : identique à celui de la CLI — écriture sur l'archive (`archiver`) puis publication de la copie de travail locale (`publier_copie_locale`), sans étape supplémentaire propre à la GUI. | 82 | +**Effet d'un import confirmé** : identique à celui de la CLI — classement par format (`classer_par_format`), attribution d'identifiants, puis publication dans l'espace de travail local (`publier_localement`), sans étape supplémentaire propre à la GUI. **Révision 2026-09-22** : plus aucune écriture sur l'archive NAS à ce stade (`archiver`/`publier_copie_locale` supprimées côté `specs/001`) — l'archivage devient une action distincte et ultérieure (`regine reconcile`), hors périmètre de cet écran (cf. Assumptions de `spec.md`). |
modified
specs/006-gui-tri-consultation/quickstart.md +2 -2 | @@ -56,7 +56,7 @@ regine import ~/Volumes/CARTE_SD --titre "Sortie parc" --annee --yes \ | ||
| 56 | 56 | --archive-root ~/regine/archive --local-root ~/regine/local |
| 57 | 57 | ``` |
| 58 | 58 | |
| 59 | -**Résultat attendu** : un dossier `AAAA-MM-JJ_Sortie_parc` apparaît sous l'archive et sous le répertoire de travail local, fichiers renommés et vérifiés (comportement déjà couvert par `specs/001-import-photos`, servant ici de référence pour le scénario 8). | |
| 59 | +**Résultat attendu** : un dossier `AAAA-MM-JJ_Sortie_parc` apparaît sous le répertoire de travail local, classé par format (`raw/`/`jpeg/`...), fichiers renommés et vérifiés — **rien n'apparaît sous l'archive** (comportement révisé le 2026-09-22, cf. `specs/001-import-photos`) tant que `regine reconcile ~/regine/archive/<AAAA>/AAAA-MM-JJ_Sortie_parc --local-dest ~/regine/local/<AAAA>/AAAA-MM-JJ_Sortie_parc` n'a pas été lancé séparément. Sert ici de référence pour le scénario 8. | |
| 60 | 60 | |
| 61 | 61 | ## Scénario 8 — Importer une carte via la GUI (User Story 3, P3) — validation manuelle |
| 62 | 62 | |
| @@ -64,7 +64,7 @@ regine import ~/Volumes/CARTE_SD --titre "Sortie parc" --annee --yes \ | ||
| 64 | 64 | regine-gui |
| 65 | 65 | ``` |
| 66 | 66 | |
| 67 | -**Résultat attendu** : dans l'onglet Import, sélectionner la même carte que le scénario 7, suivre les mêmes étapes (répartition jour par jour, destination, titre, confirmation) puis valider ; le dossier archivé obtenu est strictement identique (même nom, mêmes fichiers renommés, cf. SC-007) à celui du scénario 7 — vérifiable en comparant les deux dossiers ou en rejouant le scénario 7 sur une seconde carte identique. Une collision de boîtier déclenchée pendant cet import (cf. `specs/002-profil-boitiers-optionnel`) doit présenter les mêmes options de résolution que la CLI (US3 Acceptance Scenario 5). Depuis le résultat de cet import, ouvrir le dossier obtenu dans l'onglet Tri doit fonctionner sans étape supplémentaire (US3 Acceptance Scenario 7). | |
| 67 | +**Résultat attendu** : dans l'onglet Import, sélectionner la même carte que le scénario 7 (sans qu'un partage SMB soit nécessairement monté, cf. Edge Case), suivre les mêmes étapes (répartition jour par jour, destination, titre, confirmation de la copie locale) puis valider ; la fenêtre reste utilisable pendant la publication locale (pas de gel, cf. research.md § 7). Le dossier local obtenu est strictement identique (même nom, mêmes fichiers renommés, même classement par format, cf. SC-007) à celui du scénario 7, et l'archive reste vide jusqu'à un `regine reconcile` explicite. Une collision de boîtier déclenchée pendant cet import (cf. `specs/002-profil-boitiers-optionnel`) doit présenter les mêmes options de résolution que la CLI (US3 Acceptance Scenario 5). Depuis le résultat de cet import, ouvrir le dossier obtenu dans l'onglet Tri doit fonctionner sans étape supplémentaire (US3 Acceptance Scenario 7). | |
| 68 | 68 | |
| 69 | 69 | ## Critères de sortie |
| 70 | 70 | |
| @@ -56,7 +56,7 @@ regine import ~/Volumes/CARTE_SD --titre "Sortie parc" --annee --yes \ | |||
| 56 | --archive-root ~/regine/archive --local-root ~/regine/local | 56 | --archive-root ~/regine/archive --local-root ~/regine/local |
| 57 | ``` | 57 | ``` |
| 58 | 58 | ||
| 59 | -**Résultat attendu** : un dossier `AAAA-MM-JJ_Sortie_parc` apparaît sous l'archive et sous le répertoire de travail local, fichiers renommés et vérifiés (comportement déjà couvert par `specs/001-import-photos`, servant ici de référence pour le scénario 8). | 59 | +**Résultat attendu** : un dossier `AAAA-MM-JJ_Sortie_parc` apparaît sous le répertoire de travail local, classé par format (`raw/`/`jpeg/`...), fichiers renommés et vérifiés — **rien n'apparaît sous l'archive** (comportement révisé le 2026-09-22, cf. `specs/001-import-photos`) tant que `regine reconcile ~/regine/archive/<AAAA>/AAAA-MM-JJ_Sortie_parc --local-dest ~/regine/local/<AAAA>/AAAA-MM-JJ_Sortie_parc` n'a pas été lancé séparément. Sert ici de référence pour le scénario 8. |
| 60 | 60 | ||
| 61 | ## Scénario 8 — Importer une carte via la GUI (User Story 3, P3) — validation manuelle | 61 | ## Scénario 8 — Importer une carte via la GUI (User Story 3, P3) — validation manuelle |
| 62 | 62 | ||
| @@ -64,7 +64,7 @@ regine import ~/Volumes/CARTE_SD --titre "Sortie parc" --annee --yes \ | |||
| 64 | regine-gui | 64 | regine-gui |
| 65 | ``` | 65 | ``` |
| 66 | 66 | ||
| 67 | -**Résultat attendu** : dans l'onglet Import, sélectionner la même carte que le scénario 7, suivre les mêmes étapes (répartition jour par jour, destination, titre, confirmation) puis valider ; le dossier archivé obtenu est strictement identique (même nom, mêmes fichiers renommés, cf. SC-007) à celui du scénario 7 — vérifiable en comparant les deux dossiers ou en rejouant le scénario 7 sur une seconde carte identique. Une collision de boîtier déclenchée pendant cet import (cf. `specs/002-profil-boitiers-optionnel`) doit présenter les mêmes options de résolution que la CLI (US3 Acceptance Scenario 5). Depuis le résultat de cet import, ouvrir le dossier obtenu dans l'onglet Tri doit fonctionner sans étape supplémentaire (US3 Acceptance Scenario 7). | 67 | +**Résultat attendu** : dans l'onglet Import, sélectionner la même carte que le scénario 7 (sans qu'un partage SMB soit nécessairement monté, cf. Edge Case), suivre les mêmes étapes (répartition jour par jour, destination, titre, confirmation de la copie locale) puis valider ; la fenêtre reste utilisable pendant la publication locale (pas de gel, cf. research.md § 7). Le dossier local obtenu est strictement identique (même nom, mêmes fichiers renommés, même classement par format, cf. SC-007) à celui du scénario 7, et l'archive reste vide jusqu'à un `regine reconcile` explicite. Une collision de boîtier déclenchée pendant cet import (cf. `specs/002-profil-boitiers-optionnel`) doit présenter les mêmes options de résolution que la CLI (US3 Acceptance Scenario 5). Depuis le résultat de cet import, ouvrir le dossier obtenu dans l'onglet Tri doit fonctionner sans étape supplémentaire (US3 Acceptance Scenario 7). |
| 68 | 68 | ||
| 69 | ## Critères de sortie | 69 | ## Critères de sortie |
| 70 | 70 | ||
modified
specs/006-gui-tri-consultation/research.md +11 -1 | @@ -46,7 +46,7 @@ | ||
| 46 | 46 | |
| 47 | 47 | ## 6. Onglet d'import (US3) : appel direct de `regine_core.import_carte`, jamais de `regine_cli.import_cmd` |
| 48 | 48 | |
| 49 | -**Decision**: L'onglet d'import de la GUI (`import_view.py`) appelle directement les fonctions de bas niveau de `regine_core.import_carte` (`copier_carte`, `resoudre_collisions_boitiers`, `decouper_en_groupes`, `jours_candidats_au_detachement`, `detacher_jours`, `resoudre_destination`/`resoudre_fusion`, `construire_nom_dossier*`, `renommer_fichiers`, `attribuer_identifiants`, `preparer_resume`, `archiver`, `publier_copie_locale`) — jamais `regine_cli.import_cmd._cmd_import` ni les fonctions privées `_traiter_groupe`/`_proposer_detachement`/etc. de ce module. | |
| 49 | +**Decision**: L'onglet d'import de la GUI (`import_view.py`) appelle directement les fonctions de bas niveau de `regine_core.import_carte` (`copier_carte`, `resoudre_collisions_boitiers`, `decouper_en_groupes`, `jours_candidats_au_detachement`, `detacher_jours`, `resoudre_destination`/`resoudre_fusion`, `construire_nom_dossier*`, `renommer_fichiers`, `classer_par_format`, `attribuer_identifiants`, `preparer_resume`, `publier_localement`) — jamais `regine_cli.import_cmd._cmd_import` ni les fonctions privées `_traiter_groupe`/`_proposer_detachement`/etc. de ce module. (`archiver`/`publier_copie_locale` cités dans une version antérieure de cette décision ont depuis été remplacées par `publier_localement` côté `specs/001-import-photos`, révision 2026-09-22 — l'import ne pousse plus vers l'archive, cf. § 7 ci-dessous.) | |
| 50 | 50 | |
| 51 | 51 | **Rationale**: `regine_cli/import_cmd.py` mélange déjà deux choses distinctes : la logique métier réelle (entièrement dans `regine_core.import_carte`, déjà testée) et l'orchestration interactive propre à la CLI (`input()` pour chaque décision — titre, détachement, destination, confirmation). Cette orchestration par `input()` est spécifique au terminal, pas une règle métier réutilisable : une GUI ne « répond » pas à `input()`, elle répond à des clics/formulaires. Appeler `regine_core.import_carte` directement, comme le fait déjà `import_cmd.py` lui-même, est donc la même conformité au Principe VI que pour les onglets Tri/Consultation/Configuration — pas un écart. Exactement le même choix que celui déjà documenté dans `contracts/regine-core-api.md` (« Notes d'implémentation ») pour `config.smb.resoudre_archive_locale`, ajoutée précisément pour éviter que deux façades dupliquent une même résolution. |
| 52 | 52 | |
| @@ -54,6 +54,16 @@ | ||
| 54 | 54 | - Faire appeler par la GUI la fonction `_cmd_import` de `regine_cli` (ou l'exécuter en sous-processus) — rejeté : cette fonction est bâtie autour de `input()`/`print()` bloquants, incompatible avec une boucle d'événements Qt, et son orchestration n'a aucune règle propre à en réutiliser (déjà entièrement dans `regine_core.import_carte`). |
| 55 | 55 | - Extraire un nouveau module d'orchestration neutre (« assistant d'import ») partagé entre CLI et GUI — rejeté pour cette itération : aucune règle métier supplémentaire n'apparaît en écrivant `import_view.py` (chaque étape appelle une fonction déjà testée de `import_carte`), donc aucune duplication réelle à éliminer, contrairement au cas SMB déjà corrigé. À reconsidérer si une troisième façade (ou une évolution de l'orchestration CLI) faisait apparaître une vraie règle dupliquée. |
| 56 | 56 | |
| 57 | +## 7. Import GUI et archivage différé (révision 2026-09-22) : thread d'arrière-plan pour la publication locale, plus aucune écriture NAS | |
| 58 | + | |
| 59 | +**Decision**: Suite à la révision de `specs/001-import-photos` (l'import s'arrête à une copie de travail locale classée par format, jamais une écriture NAS — cf. Clarifications de ce spec), l'onglet d'import ne bloque plus sur la disponibilité du partage SMB (`config.smb.chemin_local_du_partage`, calcul pur, remplace `resoudre_archive_locale` qui exigeait un montage réel) et exécute classement + attribution d'identifiants + copie finale (`_PublicationLocaleWorker`, ex-`_ArchivageWorker`) sur un `QThread` dédié plutôt que sur le thread principal. | |
| 60 | + | |
| 61 | +**Rationale**: Deux bugs réels trouvés par test manuel de l'utilisateur sur une vraie carte (pas seulement le script headless) : (1) l'archivage immédiat gelait entièrement la fenêtre le temps du transfert (plusieurs sommes de contrôle par fichier, potentiellement long), sans retour visuel pendant l'exécution malgré le journal déjà en place — violation de FR-017 ; (2) le photographe n'avait aucune occasion de relire sa sélection avant l'écriture sur le NAS, et testait la GUI sans accès à un vrai NAS. Déplacer le travail lourd sur un thread résout le premier point ; arrêter l'import à une copie locale (spec révisée) résout le second sans nécessiter de bascule "mode test" dédiée — ne pas écrire sur l'archive à l'import rend l'accessibilité du NAS non bloquante par construction. | |
| 62 | + | |
| 63 | +**Alternatives considered**: | |
| 64 | +- Ajouter un simple indicateur "mode test, ne pas archiver" dans la GUI en conservant l'archivage immédiat pour l'usage réel — rejeté : duplique un état supplémentaire à maintenir (utile seulement en test) alors que la vraie demande du photographe (relire avant d'archiver) s'applique aussi en usage réel, pas seulement en test. | |
| 65 | +- Garder l'archivage synchrone mais appeler `QApplication.processEvents()` par lot — rejeté : ne règle rien si le blocage vient d'un appel bloquant unique (ex. lecture réseau lente), contrairement à un vrai thread séparé. | |
| 66 | + | |
| 57 | 67 | ## Résumé |
| 58 | 68 | |
| 59 | 69 | Tous les points ouverts du Technical Context du plan sont résolus par les décisions ci-dessus. Une seule nouvelle dépendance externe introduite au total pour cette feature : PySide6, exclusivement pour `regine-gui` (déjà en place pour US1/US2, réutilisée telle quelle par US3). |
| @@ -46,7 +46,7 @@ | |||
| 46 | 46 | ||
| 47 | ## 6. Onglet d'import (US3) : appel direct de `regine_core.import_carte`, jamais de `regine_cli.import_cmd` | 47 | ## 6. Onglet d'import (US3) : appel direct de `regine_core.import_carte`, jamais de `regine_cli.import_cmd` |
| 48 | 48 | ||
| 49 | -**Decision**: L'onglet d'import de la GUI (`import_view.py`) appelle directement les fonctions de bas niveau de `regine_core.import_carte` (`copier_carte`, `resoudre_collisions_boitiers`, `decouper_en_groupes`, `jours_candidats_au_detachement`, `detacher_jours`, `resoudre_destination`/`resoudre_fusion`, `construire_nom_dossier*`, `renommer_fichiers`, `attribuer_identifiants`, `preparer_resume`, `archiver`, `publier_copie_locale`) — jamais `regine_cli.import_cmd._cmd_import` ni les fonctions privées `_traiter_groupe`/`_proposer_detachement`/etc. de ce module. | 49 | +**Decision**: L'onglet d'import de la GUI (`import_view.py`) appelle directement les fonctions de bas niveau de `regine_core.import_carte` (`copier_carte`, `resoudre_collisions_boitiers`, `decouper_en_groupes`, `jours_candidats_au_detachement`, `detacher_jours`, `resoudre_destination`/`resoudre_fusion`, `construire_nom_dossier*`, `renommer_fichiers`, `classer_par_format`, `attribuer_identifiants`, `preparer_resume`, `publier_localement`) — jamais `regine_cli.import_cmd._cmd_import` ni les fonctions privées `_traiter_groupe`/`_proposer_detachement`/etc. de ce module. (`archiver`/`publier_copie_locale` cités dans une version antérieure de cette décision ont depuis été remplacées par `publier_localement` côté `specs/001-import-photos`, révision 2026-09-22 — l'import ne pousse plus vers l'archive, cf. § 7 ci-dessous.) |
| 50 | 50 | ||
| 51 | **Rationale**: `regine_cli/import_cmd.py` mélange déjà deux choses distinctes : la logique métier réelle (entièrement dans `regine_core.import_carte`, déjà testée) et l'orchestration interactive propre à la CLI (`input()` pour chaque décision — titre, détachement, destination, confirmation). Cette orchestration par `input()` est spécifique au terminal, pas une règle métier réutilisable : une GUI ne « répond » pas à `input()`, elle répond à des clics/formulaires. Appeler `regine_core.import_carte` directement, comme le fait déjà `import_cmd.py` lui-même, est donc la même conformité au Principe VI que pour les onglets Tri/Consultation/Configuration — pas un écart. Exactement le même choix que celui déjà documenté dans `contracts/regine-core-api.md` (« Notes d'implémentation ») pour `config.smb.resoudre_archive_locale`, ajoutée précisément pour éviter que deux façades dupliquent une même résolution. | 51 | **Rationale**: `regine_cli/import_cmd.py` mélange déjà deux choses distinctes : la logique métier réelle (entièrement dans `regine_core.import_carte`, déjà testée) et l'orchestration interactive propre à la CLI (`input()` pour chaque décision — titre, détachement, destination, confirmation). Cette orchestration par `input()` est spécifique au terminal, pas une règle métier réutilisable : une GUI ne « répond » pas à `input()`, elle répond à des clics/formulaires. Appeler `regine_core.import_carte` directement, comme le fait déjà `import_cmd.py` lui-même, est donc la même conformité au Principe VI que pour les onglets Tri/Consultation/Configuration — pas un écart. Exactement le même choix que celui déjà documenté dans `contracts/regine-core-api.md` (« Notes d'implémentation ») pour `config.smb.resoudre_archive_locale`, ajoutée précisément pour éviter que deux façades dupliquent une même résolution. |
| 52 | 52 | ||
| @@ -54,6 +54,16 @@ | |||
| 54 | - Faire appeler par la GUI la fonction `_cmd_import` de `regine_cli` (ou l'exécuter en sous-processus) — rejeté : cette fonction est bâtie autour de `input()`/`print()` bloquants, incompatible avec une boucle d'événements Qt, et son orchestration n'a aucune règle propre à en réutiliser (déjà entièrement dans `regine_core.import_carte`). | 54 | - Faire appeler par la GUI la fonction `_cmd_import` de `regine_cli` (ou l'exécuter en sous-processus) — rejeté : cette fonction est bâtie autour de `input()`/`print()` bloquants, incompatible avec une boucle d'événements Qt, et son orchestration n'a aucune règle propre à en réutiliser (déjà entièrement dans `regine_core.import_carte`). |
| 55 | - Extraire un nouveau module d'orchestration neutre (« assistant d'import ») partagé entre CLI et GUI — rejeté pour cette itération : aucune règle métier supplémentaire n'apparaît en écrivant `import_view.py` (chaque étape appelle une fonction déjà testée de `import_carte`), donc aucune duplication réelle à éliminer, contrairement au cas SMB déjà corrigé. À reconsidérer si une troisième façade (ou une évolution de l'orchestration CLI) faisait apparaître une vraie règle dupliquée. | 55 | - Extraire un nouveau module d'orchestration neutre (« assistant d'import ») partagé entre CLI et GUI — rejeté pour cette itération : aucune règle métier supplémentaire n'apparaît en écrivant `import_view.py` (chaque étape appelle une fonction déjà testée de `import_carte`), donc aucune duplication réelle à éliminer, contrairement au cas SMB déjà corrigé. À reconsidérer si une troisième façade (ou une évolution de l'orchestration CLI) faisait apparaître une vraie règle dupliquée. |
| 56 | 56 | ||
| 57 | +## 7. Import GUI et archivage différé (révision 2026-09-22) : thread d'arrière-plan pour la publication locale, plus aucune écriture NAS | ||
| 58 | + | ||
| 59 | +**Decision**: Suite à la révision de `specs/001-import-photos` (l'import s'arrête à une copie de travail locale classée par format, jamais une écriture NAS — cf. Clarifications de ce spec), l'onglet d'import ne bloque plus sur la disponibilité du partage SMB (`config.smb.chemin_local_du_partage`, calcul pur, remplace `resoudre_archive_locale` qui exigeait un montage réel) et exécute classement + attribution d'identifiants + copie finale (`_PublicationLocaleWorker`, ex-`_ArchivageWorker`) sur un `QThread` dédié plutôt que sur le thread principal. | ||
| 60 | + | ||
| 61 | +**Rationale**: Deux bugs réels trouvés par test manuel de l'utilisateur sur une vraie carte (pas seulement le script headless) : (1) l'archivage immédiat gelait entièrement la fenêtre le temps du transfert (plusieurs sommes de contrôle par fichier, potentiellement long), sans retour visuel pendant l'exécution malgré le journal déjà en place — violation de FR-017 ; (2) le photographe n'avait aucune occasion de relire sa sélection avant l'écriture sur le NAS, et testait la GUI sans accès à un vrai NAS. Déplacer le travail lourd sur un thread résout le premier point ; arrêter l'import à une copie locale (spec révisée) résout le second sans nécessiter de bascule "mode test" dédiée — ne pas écrire sur l'archive à l'import rend l'accessibilité du NAS non bloquante par construction. | ||
| 62 | + | ||
| 63 | +**Alternatives considered**: | ||
| 64 | +- Ajouter un simple indicateur "mode test, ne pas archiver" dans la GUI en conservant l'archivage immédiat pour l'usage réel — rejeté : duplique un état supplémentaire à maintenir (utile seulement en test) alors que la vraie demande du photographe (relire avant d'archiver) s'applique aussi en usage réel, pas seulement en test. | ||
| 65 | +- Garder l'archivage synchrone mais appeler `QApplication.processEvents()` par lot — rejeté : ne règle rien si le blocage vient d'un appel bloquant unique (ex. lecture réseau lente), contrairement à un vrai thread séparé. | ||
| 66 | + | ||
| 57 | ## Résumé | 67 | ## Résumé |
| 58 | 68 | ||
| 59 | Tous les points ouverts du Technical Context du plan sont résolus par les décisions ci-dessus. Une seule nouvelle dépendance externe introduite au total pour cette feature : PySide6, exclusivement pour `regine-gui` (déjà en place pour US1/US2, réutilisée telle quelle par US3). | 69 | Tous les points ouverts du Technical Context du plan sont résolus par les décisions ci-dessus. Une seule nouvelle dépendance externe introduite au total pour cette feature : PySide6, exclusivement pour `regine-gui` (déjà en place pour US1/US2, réutilisée telle quelle par US3). |
modified
specs/006-gui-tri-consultation/spec.md +8 -6 | @@ -54,14 +54,14 @@ Un photographe qui utilise déjà l'interface graphique de Régine pour trier se | ||
| 54 | 54 | |
| 55 | 55 | **Why this priority**: Confort d'usage pur — la CLI (`regine import`) couvre déjà 100% de ces capacités et continue de fonctionner à l'identique ; un utilisateur à l'aise avec la ligne de commande peut se passer entièrement de cet onglet. Priorité identique à celle de l'onglet de configuration (`specs/003` User Story 4), pour la même raison. |
| 56 | 56 | |
| 57 | -**Independent Test**: Peut être testé en ouvrant l'onglet d'import de la GUI, en y import et une carte mémoire à une seule journée jamais importée, puis en vérifiant que le dossier archivé obtenu (nom, fichiers renommés) est strictement identique à celui que produirait `regine import` sur la même carte. | |
| 57 | +**Independent Test**: Peut être testé en ouvrant l'onglet d'import de la GUI, en y import et une carte mémoire à une seule journée jamais importée, puis en vérifiant que le dossier obtenu dans l'espace de travail local (nom, classement par format, fichiers renommés) est strictement identique à celui que produirait `regine import` sur la même carte — sans qu'aucune écriture n'ait eu lieu sur l'archive NAS (cf. `specs/001-import-photos`, révision 2026-09-22). | |
| 58 | 58 | |
| 59 | 59 | **Acceptance Scenarios**: |
| 60 | 60 | |
| 61 | 61 | 1. **Given** l'onglet d'import ouvert, **When** l'utilisateur y sélectionne une carte mémoire, **Then** la GUI affiche la répartition jour par jour (nombre de photos, plage horaire) calculée par `regine_core.import`, sans recalculer elle-même cette analyse (Principe VI). |
| 62 | 62 | 2. **Given** une répartition jour par jour affichée avec un jour mis en avant comme candidat au détachement, **When** l'utilisateur choisit de le détacher ou de conserver un seul groupe, **Then** la GUI applique exactement ce choix — jamais un détachement automatique sans validation (cf. FR-006 de `specs/001-import-photos`). |
| 63 | 63 | 3. **Given** un groupe prêt à être traité, **When** l'utilisateur choisit sa destination (nouveau dossier, nouveau sous-dossier, fusion, nouveau dossier parent de voyage) et saisit un titre, **Then** la GUI applique les mêmes règles de nommage, de répertoire racine et de détection de collision que la CLI (`specs/001-import-photos` FR-007/FR-010/FR-012), sans logique de validation propre à la GUI. |
| 64 | -4. **Given** un groupe prêt à être archivé, **When** l'utilisateur confirme, **Then** la GUI présente d'abord le même résumé que la CLI (nombre de fichiers, taille, dossier de destination) et attend une confirmation explicite avant toute écriture sur l'archive (cf. FR-018 de `specs/001-import-photos`). | |
| 64 | +4. **Given** un groupe prêt à être publié localement, **When** l'utilisateur confirme, **Then** la GUI présente d'abord le même résumé que la CLI (nombre de fichiers, taille, dossier de destination) et attend une confirmation explicite avant toute écriture dans l'espace de travail local — aucune écriture n'a lieu sur l'archive NAS à ce stade (cf. FR-018/019 de `specs/001-import-photos`, révision 2026-09-22 : l'archivage est une action distincte et ultérieure, `regine reconcile`). | |
| 65 | 65 | 5. **Given** une collision de nom d'origine entre deux boîtiers nécessitant une désambiguïsation manuelle, **When** cette collision survient pendant un import déclenché depuis la GUI, **Then** la GUI présente les mêmes options de résolution (profil, numéro de série, étiquetage manuel) que la CLI, jamais une désambiguïsation inventée par la GUI (cf. `specs/002-profil-boitiers-optionnel`). |
| 66 | 66 | 6. **Given** un import en cours depuis cet onglet, **When** les fichiers sont copiés et vérifiés, **Then** l'utilisateur voit la progression, puis un résultat (fichiers importés, fichiers ignorés car déjà présents sur une carte précédemment importée, anomalies de date signalées) équivalent à celui que rapporterait la CLI. |
| 67 | 67 | 7. **Given** un import venant d'aboutir depuis cet onglet, **When** l'utilisateur veut revoir le résultat, **Then** il peut ouvrir directement le dossier importé dans l'onglet Tri (User Story 1) sans quitter la GUI. |
| @@ -75,7 +75,8 @@ Un photographe qui utilise déjà l'interface graphique de Régine pour trier se | ||
| 75 | 75 | - Que se passe-t-il si l'utilisateur demande la restauration d'un fichier (User Story 2) vers un emplacement local qui contient déjà un fichier de même nom mais de contenu différent ? Le système DOIT refuser d'écraser silencieusement (cohérent avec le principe déjà appliqué à l'import et à l'archivage, `specs/001`). |
| 76 | 76 | - Que se passe-t-il si l'utilisateur ferme la GUI avec des promotions/rétrogradations effectuées mais le dossier non encore réconcilié ? Rien ne doit être perdu : ces changements restent sur le système de fichiers local, à réconcilier plus tard (`specs/005`) comme n'importe quel autre changement local. |
| 77 | 77 | - Que se passe-t-il si l'archive contient un très grand nombre de dossiers en mode consultation ? La recherche DOIT rester utilisable sans imposer de charger l'intégralité de l'archive en mémoire d'un coup. |
| 78 | -- Que se passe-t-il si l'utilisateur ferme l'onglet d'import (ou la GUI) pendant qu'une écriture sur l'archive est en cours ? Le système DOIT empêcher la fermeture pendant l'écriture, ou garantir que celle-ci se termine ou échoue proprement avant de fermer, jamais laisser un dossier archivé partiellement écrit. | |
| 78 | +- Que se passe-t-il si l'utilisateur ferme l'onglet d'import (ou la GUI) pendant qu'une publication locale est en cours ? Le système DOIT empêcher la fermeture pendant l'écriture, ou garantir que celle-ci se termine ou échoue proprement avant de fermer, jamais laisser un dossier local partiellement écrit. | |
| 79 | +- Que se passe-t-il si le partage réseau de l'archive n'est pas monté au moment d'un import déclenché depuis la GUI ? L'import DOIT pouvoir aboutir normalement (aucune écriture sur l'archive à ce stade, cf. `specs/001-import-photos` révision 2026-09-22) ; seule une note informative, jamais bloquante, rappelle que l'archivage ultérieur nécessitera que ce partage soit accessible. | |
| 79 | 80 | - Que se passe-t-il si l'emplacement sélectionné comme carte mémoire dans l'onglet d'import ne correspond pas à un support amovible réel (ex. sélection accidentelle d'un dossier de l'archive) ? La GUI DOIT signaler la même anomalie que la CLI le ferait, plutôt que de lancer un import sur une source invalide. |
| 80 | 81 | |
| 81 | 82 | ## Requirements *(mandatory)* |
| @@ -96,7 +97,7 @@ Un photographe qui utilise déjà l'interface graphique de Régine pour trier se | ||
| 96 | 97 | - **FR-012**: Le système DOIT refuser d'écraser silencieusement un fichier local déjà présent lors d'une restauration ciblée (User Story 2) si son contenu diffère de celui restauré. |
| 97 | 98 | - **FR-013**: Le système DOIT signaler clairement à l'utilisateur qu'un dossier ouvert en tri/culling n'a pas la structure attendue d'un dossier Régine, plutôt que d'afficher un résultat vide ou trompeur. |
| 98 | 99 | - **FR-014**: Le système DOIT exposer, dans un onglet dédié de la GUI, l'ensemble des étapes de l'import d'une carte mémoire (analyse jour par jour, découpage en groupes, choix de destination, titre, confirmation, résultat) en appelant directement les mêmes fonctions de `regine_core.import` que la CLI (`regine import`), sans dupliquer la moindre règle d'analyse, de nommage, de collision ou de désambiguïsation (Principe VI) — toute règle déjà vraie côté CLI (`specs/001-import-photos`) reste vraie à l'identique depuis la GUI. |
| 99 | -- **FR-015**: Le système DOIT présenter, avant toute écriture sur l'archive déclenchée depuis l'onglet d'import, le même résumé de confirmation (nombre de fichiers, taille, dossier de destination) que celui déjà requis côté CLI (FR-018 de `specs/001-import-photos`), et attendre une confirmation explicite. | |
| 100 | +- **FR-015**: Le système DOIT présenter, avant toute écriture locale déclenchée depuis l'onglet d'import, le même résumé de confirmation (nombre de fichiers, taille, dossier de destination) que celui déjà requis côté CLI (FR-018 de `specs/001-import-photos`), et attendre une confirmation explicite. Aucune écriture sur l'archive NAS ne DOIT survenir depuis cet onglet (FR-019 de `specs/001-import-photos`, révision 2026-09-22) : l'archivage reste une action distincte, hors périmètre de cet onglet pour l'instant (cf. Assumptions). | |
| 100 | 101 | - **FR-016**: Lorsqu'une collision de nom d'origine entre boîtiers nécessite une désambiguïsation manuelle (`specs/002-profil-boitiers-optionnel`), le système DOIT présenter depuis l'onglet d'import les mêmes options de résolution que la CLI, jamais une logique de désambiguïsation propre à la GUI. |
| 101 | 102 | - **FR-017**: Le système DOIT afficher la progression et le résultat de l'import (fichiers copiés et vérifiés, fichiers ignorés car déjà importés, anomalies de date signalées) au fur et à mesure de son exécution depuis l'onglet d'import, sans obliger l'utilisateur à attendre la fin sans retour visuel. |
| 102 | 103 | - **FR-018**: Chaque action de l'onglet d'import DOIT rester accomplissable de façon identique par la commande CLI équivalente (`regine import`) (Principe CLI-first de la constitution) — la GUI ne peut pas être la seule façon de réaliser un import. |
| @@ -119,8 +120,8 @@ Un photographe qui utilise déjà l'interface graphique de Régine pour trier se | ||
| 119 | 120 | - **SC-004**: 100% des restaurations ciblées depuis le mode consultation sont vérifiées par somme de contrôle avant d'être considérées réussies. |
| 120 | 121 | - **SC-005**: 0% des suppressions déclenchées depuis l'écran de tri n'aboutissent à une perte de fichier sans confirmation explicite préalable à la réconciliation. |
| 121 | 122 | - **SC-006**: Chaque action de promotion/rétrogradation disponible dans la GUI reste accomplissable via une commande CLI équivalente, sans exception. |
| 122 | -- **SC-007**: Un import réalisé depuis l'onglet GUI produit un dossier archivé strictement identique (même nom, mêmes fichiers renommés, même répertoire racine) à celui qu'aurait produit le même import via la CLI. | |
| 123 | -- **SC-008**: 0% des écritures sur l'archive déclenchées depuis l'onglet d'import ne surviennent sans confirmation explicite préalable de l'utilisateur. | |
| 123 | +- **SC-007**: Un import réalisé depuis l'onglet GUI produit un dossier local strictement identique (même nom, mêmes fichiers renommés, même classement par format, même répertoire racine prévu) à celui qu'aurait produit le même import via la CLI — et aucune écriture n'a lieu sur l'archive NAS (cf. `specs/001-import-photos`, révision 2026-09-22). | |
| 124 | +- **SC-008**: 0% des écritures locales déclenchées depuis l'onglet d'import ne surviennent sans confirmation explicite préalable de l'utilisateur. | |
| 124 | 125 | |
| 125 | 126 | ## Assumptions |
| 126 | 127 | |
| @@ -131,3 +132,4 @@ Un photographe qui utilise déjà l'interface graphique de Régine pour trier se | ||
| 131 | 132 | - La promotion/rétrogradation à la racine de sélection est une capacité entièrement nouvelle de `regine-core` (aucun module existant ne la couvre) ; cette spécification suppose qu'elle sera conçue comme un module à part entière (probablement `regine_core.dossier`, qui porte déjà la notion de racine de sélection) plutôt que dupliquée entre la façade CLI et la façade GUI (Principe VI). |
| 132 | 133 | - Le choix de la technologie d'interface graphique (PySide6 natif vs GUI web packagée, cf. `docs/interface-cli-gui-architecture.md`, non tranché) est une décision de planification, pas de cette spécification comportementale. |
| 133 | 134 | - L'onglet d'import (User Story 3) réutilise l'infrastructure à onglets déjà en place dans `regine-gui` (`app.py`, `QTabWidget`) plutôt que d'introduire une nouvelle application ; il n'ajoute aucune capacité que la CLI (`regine import`, `specs/001-import-photos`) n'a pas déjà — à l'image de l'onglet Configuration (`specs/003` User Story 4). |
| 135 | +- **Révisé le 2026-09-22** : suite à la révision de `specs/001-import-photos` (l'import s'arrête à une copie de travail locale, jamais une écriture NAS), l'onglet d'import ne déclenche plus aucun archivage lui-même. L'archivage effectif (`regine reconcile`) reste CLI uniquement pour l'instant : cette spécification ne couvre pas de bouton ou d'écran GUI dédié à la réconciliation/l'archivage — à ajouter comme une User Story distincte le jour où ce besoin est exprimé, sans que cela remette en cause l'onglet d'import tel que décrit ici. | |
| @@ -54,14 +54,14 @@ Un photographe qui utilise déjà l'interface graphique de Régine pour trier se | |||
| 54 | 54 | ||
| 55 | **Why this priority**: Confort d'usage pur — la CLI (`regine import`) couvre déjà 100% de ces capacités et continue de fonctionner à l'identique ; un utilisateur à l'aise avec la ligne de commande peut se passer entièrement de cet onglet. Priorité identique à celle de l'onglet de configuration (`specs/003` User Story 4), pour la même raison. | 55 | **Why this priority**: Confort d'usage pur — la CLI (`regine import`) couvre déjà 100% de ces capacités et continue de fonctionner à l'identique ; un utilisateur à l'aise avec la ligne de commande peut se passer entièrement de cet onglet. Priorité identique à celle de l'onglet de configuration (`specs/003` User Story 4), pour la même raison. |
| 56 | 56 | ||
| 57 | -**Independent Test**: Peut être testé en ouvrant l'onglet d'import de la GUI, en y import et une carte mémoire à une seule journée jamais importée, puis en vérifiant que le dossier archivé obtenu (nom, fichiers renommés) est strictement identique à celui que produirait `regine import` sur la même carte. | 57 | +**Independent Test**: Peut être testé en ouvrant l'onglet d'import de la GUI, en y import et une carte mémoire à une seule journée jamais importée, puis en vérifiant que le dossier obtenu dans l'espace de travail local (nom, classement par format, fichiers renommés) est strictement identique à celui que produirait `regine import` sur la même carte — sans qu'aucune écriture n'ait eu lieu sur l'archive NAS (cf. `specs/001-import-photos`, révision 2026-09-22). |
| 58 | 58 | ||
| 59 | **Acceptance Scenarios**: | 59 | **Acceptance Scenarios**: |
| 60 | 60 | ||
| 61 | 1. **Given** l'onglet d'import ouvert, **When** l'utilisateur y sélectionne une carte mémoire, **Then** la GUI affiche la répartition jour par jour (nombre de photos, plage horaire) calculée par `regine_core.import`, sans recalculer elle-même cette analyse (Principe VI). | 61 | 1. **Given** l'onglet d'import ouvert, **When** l'utilisateur y sélectionne une carte mémoire, **Then** la GUI affiche la répartition jour par jour (nombre de photos, plage horaire) calculée par `regine_core.import`, sans recalculer elle-même cette analyse (Principe VI). |
| 62 | 2. **Given** une répartition jour par jour affichée avec un jour mis en avant comme candidat au détachement, **When** l'utilisateur choisit de le détacher ou de conserver un seul groupe, **Then** la GUI applique exactement ce choix — jamais un détachement automatique sans validation (cf. FR-006 de `specs/001-import-photos`). | 62 | 2. **Given** une répartition jour par jour affichée avec un jour mis en avant comme candidat au détachement, **When** l'utilisateur choisit de le détacher ou de conserver un seul groupe, **Then** la GUI applique exactement ce choix — jamais un détachement automatique sans validation (cf. FR-006 de `specs/001-import-photos`). |
| 63 | 3. **Given** un groupe prêt à être traité, **When** l'utilisateur choisit sa destination (nouveau dossier, nouveau sous-dossier, fusion, nouveau dossier parent de voyage) et saisit un titre, **Then** la GUI applique les mêmes règles de nommage, de répertoire racine et de détection de collision que la CLI (`specs/001-import-photos` FR-007/FR-010/FR-012), sans logique de validation propre à la GUI. | 63 | 3. **Given** un groupe prêt à être traité, **When** l'utilisateur choisit sa destination (nouveau dossier, nouveau sous-dossier, fusion, nouveau dossier parent de voyage) et saisit un titre, **Then** la GUI applique les mêmes règles de nommage, de répertoire racine et de détection de collision que la CLI (`specs/001-import-photos` FR-007/FR-010/FR-012), sans logique de validation propre à la GUI. |
| 64 | -4. **Given** un groupe prêt à être archivé, **When** l'utilisateur confirme, **Then** la GUI présente d'abord le même résumé que la CLI (nombre de fichiers, taille, dossier de destination) et attend une confirmation explicite avant toute écriture sur l'archive (cf. FR-018 de `specs/001-import-photos`). | 64 | +4. **Given** un groupe prêt à être publié localement, **When** l'utilisateur confirme, **Then** la GUI présente d'abord le même résumé que la CLI (nombre de fichiers, taille, dossier de destination) et attend une confirmation explicite avant toute écriture dans l'espace de travail local — aucune écriture n'a lieu sur l'archive NAS à ce stade (cf. FR-018/019 de `specs/001-import-photos`, révision 2026-09-22 : l'archivage est une action distincte et ultérieure, `regine reconcile`). |
| 65 | 5. **Given** une collision de nom d'origine entre deux boîtiers nécessitant une désambiguïsation manuelle, **When** cette collision survient pendant un import déclenché depuis la GUI, **Then** la GUI présente les mêmes options de résolution (profil, numéro de série, étiquetage manuel) que la CLI, jamais une désambiguïsation inventée par la GUI (cf. `specs/002-profil-boitiers-optionnel`). | 65 | 5. **Given** une collision de nom d'origine entre deux boîtiers nécessitant une désambiguïsation manuelle, **When** cette collision survient pendant un import déclenché depuis la GUI, **Then** la GUI présente les mêmes options de résolution (profil, numéro de série, étiquetage manuel) que la CLI, jamais une désambiguïsation inventée par la GUI (cf. `specs/002-profil-boitiers-optionnel`). |
| 66 | 6. **Given** un import en cours depuis cet onglet, **When** les fichiers sont copiés et vérifiés, **Then** l'utilisateur voit la progression, puis un résultat (fichiers importés, fichiers ignorés car déjà présents sur une carte précédemment importée, anomalies de date signalées) équivalent à celui que rapporterait la CLI. | 66 | 6. **Given** un import en cours depuis cet onglet, **When** les fichiers sont copiés et vérifiés, **Then** l'utilisateur voit la progression, puis un résultat (fichiers importés, fichiers ignorés car déjà présents sur une carte précédemment importée, anomalies de date signalées) équivalent à celui que rapporterait la CLI. |
| 67 | 7. **Given** un import venant d'aboutir depuis cet onglet, **When** l'utilisateur veut revoir le résultat, **Then** il peut ouvrir directement le dossier importé dans l'onglet Tri (User Story 1) sans quitter la GUI. | 67 | 7. **Given** un import venant d'aboutir depuis cet onglet, **When** l'utilisateur veut revoir le résultat, **Then** il peut ouvrir directement le dossier importé dans l'onglet Tri (User Story 1) sans quitter la GUI. |
| @@ -75,7 +75,8 @@ Un photographe qui utilise déjà l'interface graphique de Régine pour trier se | |||
| 75 | - Que se passe-t-il si l'utilisateur demande la restauration d'un fichier (User Story 2) vers un emplacement local qui contient déjà un fichier de même nom mais de contenu différent ? Le système DOIT refuser d'écraser silencieusement (cohérent avec le principe déjà appliqué à l'import et à l'archivage, `specs/001`). | 75 | - Que se passe-t-il si l'utilisateur demande la restauration d'un fichier (User Story 2) vers un emplacement local qui contient déjà un fichier de même nom mais de contenu différent ? Le système DOIT refuser d'écraser silencieusement (cohérent avec le principe déjà appliqué à l'import et à l'archivage, `specs/001`). |
| 76 | - Que se passe-t-il si l'utilisateur ferme la GUI avec des promotions/rétrogradations effectuées mais le dossier non encore réconcilié ? Rien ne doit être perdu : ces changements restent sur le système de fichiers local, à réconcilier plus tard (`specs/005`) comme n'importe quel autre changement local. | 76 | - Que se passe-t-il si l'utilisateur ferme la GUI avec des promotions/rétrogradations effectuées mais le dossier non encore réconcilié ? Rien ne doit être perdu : ces changements restent sur le système de fichiers local, à réconcilier plus tard (`specs/005`) comme n'importe quel autre changement local. |
| 77 | - Que se passe-t-il si l'archive contient un très grand nombre de dossiers en mode consultation ? La recherche DOIT rester utilisable sans imposer de charger l'intégralité de l'archive en mémoire d'un coup. | 77 | - Que se passe-t-il si l'archive contient un très grand nombre de dossiers en mode consultation ? La recherche DOIT rester utilisable sans imposer de charger l'intégralité de l'archive en mémoire d'un coup. |
| 78 | -- Que se passe-t-il si l'utilisateur ferme l'onglet d'import (ou la GUI) pendant qu'une écriture sur l'archive est en cours ? Le système DOIT empêcher la fermeture pendant l'écriture, ou garantir que celle-ci se termine ou échoue proprement avant de fermer, jamais laisser un dossier archivé partiellement écrit. | 78 | +- Que se passe-t-il si l'utilisateur ferme l'onglet d'import (ou la GUI) pendant qu'une publication locale est en cours ? Le système DOIT empêcher la fermeture pendant l'écriture, ou garantir que celle-ci se termine ou échoue proprement avant de fermer, jamais laisser un dossier local partiellement écrit. |
| 79 | +- Que se passe-t-il si le partage réseau de l'archive n'est pas monté au moment d'un import déclenché depuis la GUI ? L'import DOIT pouvoir aboutir normalement (aucune écriture sur l'archive à ce stade, cf. `specs/001-import-photos` révision 2026-09-22) ; seule une note informative, jamais bloquante, rappelle que l'archivage ultérieur nécessitera que ce partage soit accessible. | ||
| 79 | - Que se passe-t-il si l'emplacement sélectionné comme carte mémoire dans l'onglet d'import ne correspond pas à un support amovible réel (ex. sélection accidentelle d'un dossier de l'archive) ? La GUI DOIT signaler la même anomalie que la CLI le ferait, plutôt que de lancer un import sur une source invalide. | 80 | - Que se passe-t-il si l'emplacement sélectionné comme carte mémoire dans l'onglet d'import ne correspond pas à un support amovible réel (ex. sélection accidentelle d'un dossier de l'archive) ? La GUI DOIT signaler la même anomalie que la CLI le ferait, plutôt que de lancer un import sur une source invalide. |
| 80 | 81 | ||
| 81 | ## Requirements *(mandatory)* | 82 | ## Requirements *(mandatory)* |
| @@ -96,7 +97,7 @@ Un photographe qui utilise déjà l'interface graphique de Régine pour trier se | |||
| 96 | - **FR-012**: Le système DOIT refuser d'écraser silencieusement un fichier local déjà présent lors d'une restauration ciblée (User Story 2) si son contenu diffère de celui restauré. | 97 | - **FR-012**: Le système DOIT refuser d'écraser silencieusement un fichier local déjà présent lors d'une restauration ciblée (User Story 2) si son contenu diffère de celui restauré. |
| 97 | - **FR-013**: Le système DOIT signaler clairement à l'utilisateur qu'un dossier ouvert en tri/culling n'a pas la structure attendue d'un dossier Régine, plutôt que d'afficher un résultat vide ou trompeur. | 98 | - **FR-013**: Le système DOIT signaler clairement à l'utilisateur qu'un dossier ouvert en tri/culling n'a pas la structure attendue d'un dossier Régine, plutôt que d'afficher un résultat vide ou trompeur. |
| 98 | - **FR-014**: Le système DOIT exposer, dans un onglet dédié de la GUI, l'ensemble des étapes de l'import d'une carte mémoire (analyse jour par jour, découpage en groupes, choix de destination, titre, confirmation, résultat) en appelant directement les mêmes fonctions de `regine_core.import` que la CLI (`regine import`), sans dupliquer la moindre règle d'analyse, de nommage, de collision ou de désambiguïsation (Principe VI) — toute règle déjà vraie côté CLI (`specs/001-import-photos`) reste vraie à l'identique depuis la GUI. | 99 | - **FR-014**: Le système DOIT exposer, dans un onglet dédié de la GUI, l'ensemble des étapes de l'import d'une carte mémoire (analyse jour par jour, découpage en groupes, choix de destination, titre, confirmation, résultat) en appelant directement les mêmes fonctions de `regine_core.import` que la CLI (`regine import`), sans dupliquer la moindre règle d'analyse, de nommage, de collision ou de désambiguïsation (Principe VI) — toute règle déjà vraie côté CLI (`specs/001-import-photos`) reste vraie à l'identique depuis la GUI. |
| 99 | -- **FR-015**: Le système DOIT présenter, avant toute écriture sur l'archive déclenchée depuis l'onglet d'import, le même résumé de confirmation (nombre de fichiers, taille, dossier de destination) que celui déjà requis côté CLI (FR-018 de `specs/001-import-photos`), et attendre une confirmation explicite. | 100 | +- **FR-015**: Le système DOIT présenter, avant toute écriture locale déclenchée depuis l'onglet d'import, le même résumé de confirmation (nombre de fichiers, taille, dossier de destination) que celui déjà requis côté CLI (FR-018 de `specs/001-import-photos`), et attendre une confirmation explicite. Aucune écriture sur l'archive NAS ne DOIT survenir depuis cet onglet (FR-019 de `specs/001-import-photos`, révision 2026-09-22) : l'archivage reste une action distincte, hors périmètre de cet onglet pour l'instant (cf. Assumptions). |
| 100 | - **FR-016**: Lorsqu'une collision de nom d'origine entre boîtiers nécessite une désambiguïsation manuelle (`specs/002-profil-boitiers-optionnel`), le système DOIT présenter depuis l'onglet d'import les mêmes options de résolution que la CLI, jamais une logique de désambiguïsation propre à la GUI. | 101 | - **FR-016**: Lorsqu'une collision de nom d'origine entre boîtiers nécessite une désambiguïsation manuelle (`specs/002-profil-boitiers-optionnel`), le système DOIT présenter depuis l'onglet d'import les mêmes options de résolution que la CLI, jamais une logique de désambiguïsation propre à la GUI. |
| 101 | - **FR-017**: Le système DOIT afficher la progression et le résultat de l'import (fichiers copiés et vérifiés, fichiers ignorés car déjà importés, anomalies de date signalées) au fur et à mesure de son exécution depuis l'onglet d'import, sans obliger l'utilisateur à attendre la fin sans retour visuel. | 102 | - **FR-017**: Le système DOIT afficher la progression et le résultat de l'import (fichiers copiés et vérifiés, fichiers ignorés car déjà importés, anomalies de date signalées) au fur et à mesure de son exécution depuis l'onglet d'import, sans obliger l'utilisateur à attendre la fin sans retour visuel. |
| 102 | - **FR-018**: Chaque action de l'onglet d'import DOIT rester accomplissable de façon identique par la commande CLI équivalente (`regine import`) (Principe CLI-first de la constitution) — la GUI ne peut pas être la seule façon de réaliser un import. | 103 | - **FR-018**: Chaque action de l'onglet d'import DOIT rester accomplissable de façon identique par la commande CLI équivalente (`regine import`) (Principe CLI-first de la constitution) — la GUI ne peut pas être la seule façon de réaliser un import. |
| @@ -119,8 +120,8 @@ Un photographe qui utilise déjà l'interface graphique de Régine pour trier se | |||
| 119 | - **SC-004**: 100% des restaurations ciblées depuis le mode consultation sont vérifiées par somme de contrôle avant d'être considérées réussies. | 120 | - **SC-004**: 100% des restaurations ciblées depuis le mode consultation sont vérifiées par somme de contrôle avant d'être considérées réussies. |
| 120 | - **SC-005**: 0% des suppressions déclenchées depuis l'écran de tri n'aboutissent à une perte de fichier sans confirmation explicite préalable à la réconciliation. | 121 | - **SC-005**: 0% des suppressions déclenchées depuis l'écran de tri n'aboutissent à une perte de fichier sans confirmation explicite préalable à la réconciliation. |
| 121 | - **SC-006**: Chaque action de promotion/rétrogradation disponible dans la GUI reste accomplissable via une commande CLI équivalente, sans exception. | 122 | - **SC-006**: Chaque action de promotion/rétrogradation disponible dans la GUI reste accomplissable via une commande CLI équivalente, sans exception. |
| 122 | -- **SC-007**: Un import réalisé depuis l'onglet GUI produit un dossier archivé strictement identique (même nom, mêmes fichiers renommés, même répertoire racine) à celui qu'aurait produit le même import via la CLI. | 123 | +- **SC-007**: Un import réalisé depuis l'onglet GUI produit un dossier local strictement identique (même nom, mêmes fichiers renommés, même classement par format, même répertoire racine prévu) à celui qu'aurait produit le même import via la CLI — et aucune écriture n'a lieu sur l'archive NAS (cf. `specs/001-import-photos`, révision 2026-09-22). |
| 123 | -- **SC-008**: 0% des écritures sur l'archive déclenchées depuis l'onglet d'import ne surviennent sans confirmation explicite préalable de l'utilisateur. | 124 | +- **SC-008**: 0% des écritures locales déclenchées depuis l'onglet d'import ne surviennent sans confirmation explicite préalable de l'utilisateur. |
| 124 | 125 | ||
| 125 | ## Assumptions | 126 | ## Assumptions |
| 126 | 127 | ||
| @@ -131,3 +132,4 @@ Un photographe qui utilise déjà l'interface graphique de Régine pour trier se | |||
| 131 | - La promotion/rétrogradation à la racine de sélection est une capacité entièrement nouvelle de `regine-core` (aucun module existant ne la couvre) ; cette spécification suppose qu'elle sera conçue comme un module à part entière (probablement `regine_core.dossier`, qui porte déjà la notion de racine de sélection) plutôt que dupliquée entre la façade CLI et la façade GUI (Principe VI). | 132 | - La promotion/rétrogradation à la racine de sélection est une capacité entièrement nouvelle de `regine-core` (aucun module existant ne la couvre) ; cette spécification suppose qu'elle sera conçue comme un module à part entière (probablement `regine_core.dossier`, qui porte déjà la notion de racine de sélection) plutôt que dupliquée entre la façade CLI et la façade GUI (Principe VI). |
| 132 | - Le choix de la technologie d'interface graphique (PySide6 natif vs GUI web packagée, cf. `docs/interface-cli-gui-architecture.md`, non tranché) est une décision de planification, pas de cette spécification comportementale. | 133 | - Le choix de la technologie d'interface graphique (PySide6 natif vs GUI web packagée, cf. `docs/interface-cli-gui-architecture.md`, non tranché) est une décision de planification, pas de cette spécification comportementale. |
| 133 | - L'onglet d'import (User Story 3) réutilise l'infrastructure à onglets déjà en place dans `regine-gui` (`app.py`, `QTabWidget`) plutôt que d'introduire une nouvelle application ; il n'ajoute aucune capacité que la CLI (`regine import`, `specs/001-import-photos`) n'a pas déjà — à l'image de l'onglet Configuration (`specs/003` User Story 4). | 134 | - L'onglet d'import (User Story 3) réutilise l'infrastructure à onglets déjà en place dans `regine-gui` (`app.py`, `QTabWidget`) plutôt que d'introduire une nouvelle application ; il n'ajoute aucune capacité que la CLI (`regine import`, `specs/001-import-photos`) n'a pas déjà — à l'image de l'onglet Configuration (`specs/003` User Story 4). |
| 135 | +- **Révisé le 2026-09-22** : suite à la révision de `specs/001-import-photos` (l'import s'arrête à une copie de travail locale, jamais une écriture NAS), l'onglet d'import ne déclenche plus aucun archivage lui-même. L'archivage effectif (`regine reconcile`) reste CLI uniquement pour l'instant : cette spécification ne couvre pas de bouton ou d'écran GUI dédié à la réconciliation/l'archivage — à ajouter comme une User Story distincte le jour où ce besoin est exprimé, sans que cela remette en cause l'onglet d'import tel que décrit ici. | ||
modified
specs/006-gui-tri-consultation/tasks.md +3 -2 | @@ -113,9 +113,9 @@ description: "Task list for feature implementation" | ||
| 113 | 113 | - [X] T033 [US3] Implémenter le choix de destination et le titre dans `import_view.py` : appel à `destination.resoudre_destination`/`resoudre_fusion` et `nommage.construire_nom_dossier*` selon le type choisi, sans logique de nommage ou de collision propre à la GUI (US3 Acceptance Scenario 3) — **écart trouvé à l'implémentation** : le cas « nouveau sous-dossier » nécessitait la même reconstruction de `RootLocation` à partir d'un chemin relatif que `regine_cli.import_cmd._root_location_depuis_chemin` (privée) ; plutôt que de la dupliquer une seconde fois (même schéma que `smb.resoudre_archive_locale`, déjà rencontré pour US1/US2), elle a été rendue publique dans `regine_core.import_carte.destination.root_location_depuis_chemin` et `import_cmd.py` mis à jour pour l'appeler elle aussi (aucun changement de comportement CLI, 172/172 tests toujours au vert) — cf. `contracts/regine-core-api.md` |
| 114 | 114 | - [X] T034 [US3] Implémenter le résumé de confirmation avant écriture dans `import_view.py` : appel à `push.preparer_resume`, attente d'une confirmation explicite (`QMessageBox`) avant tout appel à `push.archiver` (US3 Acceptance Scenario 4, FR-015) |
| 115 | 115 | - [X] T035 [US3] Implémenter la résolution de collision de boîtier dans `import_view.py` : appel à `copie.resoudre_collisions_boitiers`, présentation des mêmes options que la CLI (profil connu, numéro de série, étiquetage manuel) en cas de collision non résolue automatiquement (US3 Acceptance Scenario 5, FR-016, `specs/002-profil-boitiers-optionnel`) |
| 116 | -- [X] T036 [US3] Implémenter la progression et le résultat dans `import_view.py` : retour visuel (journal texte) pendant `push.archiver`/`push.publier_copie_locale`, résumé final (US3 Acceptance Scenario 6, FR-017) | |
| 116 | +- [X] T036 [US3] Implémenter la progression et le résultat dans `import_view.py` : retour visuel (journal texte) pendant `push.archiver`/`push.publier_copie_locale`, résumé final (US3 Acceptance Scenario 6, FR-017) — **bug réel trouvé par test manuel (utilisateur)** : la première version appelait `archiver`/`publier_copie_locale` directement sur le thread principal Qt ; ces fonctions recalculent plusieurs sommes de contrôle par fichier (copie carte→local, local→archive, archive→copie locale, plus le hash image exiftool), ce qui gelait entièrement la fenêtre le temps de l'opération — violation de FR-017 malgré le journal déjà en place. Corrigé en déplaçant `archiver`/`publier_copie_locale` sur un `QThread` dédié (`_ArchivageWorker`), la fenêtre restant utilisable pendant l'archivage. **Second bug trouvé pendant cette correction** : les signaux du worker étaient connectés à des lambdas plutôt qu'à de vraies méthodes liées de `self` (`QMainWindow`) — Qt ne détectait alors pas l'affinité de thread du récepteur et exécutait les slots en connexion directe *dans le thread d'arrière-plan*, provoquant un `sqlite3.ProgrammingError` (connexion utilisée hors de son thread de création) dès la fermeture de la connexion de contexte. Corrigé en connectant les signaux à de vraies méthodes de `self` (`_sur_archivage_succes`/`_sur_archivage_echec`), permettant à Qt d'utiliser une connexion en file d'attente vers le thread principal | |
| 117 | 117 | - [X] T037 [US3] Câbler l'onglet « Import » dans `packages/regine-gui/src/regine_gui/app.py` (`FenetrePrincipale`), aux côtés des onglets Tri/Consultation/Configuration déjà existants ; le dossier importé peut être ouvert directement dans l'onglet Tri via un signal Qt `dossier_importe` (US3 Acceptance Scenario 7) — `tri_view.EcranTri._charger_dossier` renommée en méthode publique `ouvrir_dossier` pour permettre cette connexion inter-onglets sans dupliquer le regroupement RAW+JPEG |
| 118 | -- [X] T038 [US3] Validé manuellement en mode headless (`QT_QPA_PLATFORM=offscreen`) : import complet du chemin « nouveau dossier » (analyse, titre, catégorie vide, confirmation, archivage) déclenché par script simulant les réponses utilisateur (`QInputDialog`/`QMessageBox` monkeypatchés, `resoudre_archive_locale` monkeypatché pour éviter une dépendance à un vrai partage SMB monté, même méthode que `specs/003` T033/`specs/006` T017/T026) — dossier obtenu (`AAAA-MM-JJ_Titre`, fichiers renommés `date_titre_nomOrigine.ext`) structurellement identique à ce que produit `regine import` (Scénario 8 de `quickstart.md`, SC-007). Chemins « nouveau sous-dossier »/« fusion »/collision de boîtier non exercés par ce script (aucune carte de test avec EXIF/voyage multi-étapes disponible dans cet environnement) : validés par lecture de code, chaque branche appelant exclusivement des fonctions `regine_core.import_carte`/`camera_profile` déjà testées unitairement par `specs/001`/`specs/002` | |
| 118 | +- [X] T038 [US3] Validé manuellement en mode headless (`QT_QPA_PLATFORM=offscreen`) : import complet du chemin « nouveau dossier » (analyse, titre, catégorie vide, confirmation, archivage en arrière-plan) déclenché par script simulant les réponses utilisateur (`QInputDialog`/`QMessageBox` monkeypatchés, `resoudre_archive_locale` monkeypatché pour éviter une dépendance à un vrai partage SMB monté, boucle d'événements Qt maintenue active jusqu'à la fin du thread d'archivage, même méthode que `specs/003` T033/`specs/006` T017/T026) — dossier obtenu (`AAAA-MM-JJ_Titre`, fichiers renommés `date_titre_nomOrigine.ext`) structurellement identique à ce que produit `regine import` (Scénario 8 de `quickstart.md`, SC-007). Complété par un test manuel réel (utilisateur, application non headless) sur une carte de test avec des fichiers plus volumineux, qui a mis en évidence les deux bugs de gel/threading documentés en T036 — tous deux corrigés et revalidés. Chemins « nouveau sous-dossier »/« fusion »/collision de boîtier non exercés par un script automatisé (aucune carte de test avec EXIF/voyage multi-étapes disponible dans cet environnement) : validés par lecture de code, chaque branche appelant exclusivement des fonctions `regine_core.import_carte`/`camera_profile` déjà testées unitairement par `specs/001`/`specs/002` | |
| 119 | 119 | |
| 120 | 120 | **Checkpoint** : User Story 3 fonctionnelle et testable indépendamment ; CLI (déjà livrée par `specs/001-import-photos`) et GUI cohérentes entre elles. |
| 121 | 121 | |
| @@ -127,6 +127,7 @@ description: "Task list for feature implementation" | ||
| 127 | 127 | - [X] T028 [P] `ruff check --fix` + `ruff format` sur `packages/regine-core`, `packages/regine-cli` et `packages/regine-gui` — 172/172 tests passent après coup |
| 128 | 128 | - [X] T029 Mettre à jour `contracts/regine-core-api.md`/`cli-dossier-selection.md`/`cli-archive-consultation.md` si l'implémentation révèle un écart avec les signatures documentées (patron déjà établi dans les specs précédentes) — tous mis à jour au fil de l'implémentation (signature `promouvoir`, renommage `browse`/`restore`, `resoudre_archive_locale`) |
| 129 | 129 | - [X] T039 [P] Exécuté le Scénario 8 de `quickstart.md` (cf. T038 ; Scénario 7 déjà couvert par `specs/001-import-photos`, sans duplication) et `ruff check --fix`/`ruff format` sur `packages/regine-gui`, `packages/regine-cli`, `packages/regine-core` (nouveau fichier `import_view.py`, nouvelle fonction publique `root_location_depuis_chemin`) — 172/172 tests `regine-core`/`regine-cli` toujours au vert, aucun nouveau test requis côté core (aucune nouvelle règle métier ajoutée par US3, seulement une fonction déjà testée indirectement via `import_cmd` rendue publique) |
| 130 | +- [X] T040 [US3] Révision 2026-09-22 (cf. Clarifications de `specs/001-import-photos/spec.md`) : `import_view.py` mis à jour pour suivre le nouveau pipeline `specs/001` — `classer_par_format`/`publier_localement` remplacent `archiver`/`publier_copie_locale` dans `_PublicationLocaleWorker` (ex-`_ArchivageWorker`) ; `_analyser` n'exige plus que le partage SMB soit monté (`config.smb.chemin_local_du_partage`, calcul pur, remplace `resoudre_archive_locale`) ; dialogues/journal reformulés pour ne plus jamais mentionner un « archivage » à l'import (« Confirmer la copie de travail locale », message final indiquant la commande `regine reconcile` à lancer plus tard). Revalidé manuellement en mode headless sans archive configurée/montée : structure `raw`/`jpeg` obtenue, aucune écriture sous `/Volumes/...` — 176/176 tests `regine-core`/`regine-cli` au vert (172 + 4 issus de la révision `specs/001`, cf. son `tasks.md` Phase 7) | |
| 130 | 131 | |
| 131 | 132 | --- |
| 132 | 133 | |
| @@ -113,9 +113,9 @@ description: "Task list for feature implementation" | |||
| 113 | - [X] T033 [US3] Implémenter le choix de destination et le titre dans `import_view.py` : appel à `destination.resoudre_destination`/`resoudre_fusion` et `nommage.construire_nom_dossier*` selon le type choisi, sans logique de nommage ou de collision propre à la GUI (US3 Acceptance Scenario 3) — **écart trouvé à l'implémentation** : le cas « nouveau sous-dossier » nécessitait la même reconstruction de `RootLocation` à partir d'un chemin relatif que `regine_cli.import_cmd._root_location_depuis_chemin` (privée) ; plutôt que de la dupliquer une seconde fois (même schéma que `smb.resoudre_archive_locale`, déjà rencontré pour US1/US2), elle a été rendue publique dans `regine_core.import_carte.destination.root_location_depuis_chemin` et `import_cmd.py` mis à jour pour l'appeler elle aussi (aucun changement de comportement CLI, 172/172 tests toujours au vert) — cf. `contracts/regine-core-api.md` | 113 | - [X] T033 [US3] Implémenter le choix de destination et le titre dans `import_view.py` : appel à `destination.resoudre_destination`/`resoudre_fusion` et `nommage.construire_nom_dossier*` selon le type choisi, sans logique de nommage ou de collision propre à la GUI (US3 Acceptance Scenario 3) — **écart trouvé à l'implémentation** : le cas « nouveau sous-dossier » nécessitait la même reconstruction de `RootLocation` à partir d'un chemin relatif que `regine_cli.import_cmd._root_location_depuis_chemin` (privée) ; plutôt que de la dupliquer une seconde fois (même schéma que `smb.resoudre_archive_locale`, déjà rencontré pour US1/US2), elle a été rendue publique dans `regine_core.import_carte.destination.root_location_depuis_chemin` et `import_cmd.py` mis à jour pour l'appeler elle aussi (aucun changement de comportement CLI, 172/172 tests toujours au vert) — cf. `contracts/regine-core-api.md` |
| 114 | - [X] T034 [US3] Implémenter le résumé de confirmation avant écriture dans `import_view.py` : appel à `push.preparer_resume`, attente d'une confirmation explicite (`QMessageBox`) avant tout appel à `push.archiver` (US3 Acceptance Scenario 4, FR-015) | 114 | - [X] T034 [US3] Implémenter le résumé de confirmation avant écriture dans `import_view.py` : appel à `push.preparer_resume`, attente d'une confirmation explicite (`QMessageBox`) avant tout appel à `push.archiver` (US3 Acceptance Scenario 4, FR-015) |
| 115 | - [X] T035 [US3] Implémenter la résolution de collision de boîtier dans `import_view.py` : appel à `copie.resoudre_collisions_boitiers`, présentation des mêmes options que la CLI (profil connu, numéro de série, étiquetage manuel) en cas de collision non résolue automatiquement (US3 Acceptance Scenario 5, FR-016, `specs/002-profil-boitiers-optionnel`) | 115 | - [X] T035 [US3] Implémenter la résolution de collision de boîtier dans `import_view.py` : appel à `copie.resoudre_collisions_boitiers`, présentation des mêmes options que la CLI (profil connu, numéro de série, étiquetage manuel) en cas de collision non résolue automatiquement (US3 Acceptance Scenario 5, FR-016, `specs/002-profil-boitiers-optionnel`) |
| 116 | -- [X] T036 [US3] Implémenter la progression et le résultat dans `import_view.py` : retour visuel (journal texte) pendant `push.archiver`/`push.publier_copie_locale`, résumé final (US3 Acceptance Scenario 6, FR-017) | 116 | +- [X] T036 [US3] Implémenter la progression et le résultat dans `import_view.py` : retour visuel (journal texte) pendant `push.archiver`/`push.publier_copie_locale`, résumé final (US3 Acceptance Scenario 6, FR-017) — **bug réel trouvé par test manuel (utilisateur)** : la première version appelait `archiver`/`publier_copie_locale` directement sur le thread principal Qt ; ces fonctions recalculent plusieurs sommes de contrôle par fichier (copie carte→local, local→archive, archive→copie locale, plus le hash image exiftool), ce qui gelait entièrement la fenêtre le temps de l'opération — violation de FR-017 malgré le journal déjà en place. Corrigé en déplaçant `archiver`/`publier_copie_locale` sur un `QThread` dédié (`_ArchivageWorker`), la fenêtre restant utilisable pendant l'archivage. **Second bug trouvé pendant cette correction** : les signaux du worker étaient connectés à des lambdas plutôt qu'à de vraies méthodes liées de `self` (`QMainWindow`) — Qt ne détectait alors pas l'affinité de thread du récepteur et exécutait les slots en connexion directe *dans le thread d'arrière-plan*, provoquant un `sqlite3.ProgrammingError` (connexion utilisée hors de son thread de création) dès la fermeture de la connexion de contexte. Corrigé en connectant les signaux à de vraies méthodes de `self` (`_sur_archivage_succes`/`_sur_archivage_echec`), permettant à Qt d'utiliser une connexion en file d'attente vers le thread principal |
| 117 | - [X] T037 [US3] Câbler l'onglet « Import » dans `packages/regine-gui/src/regine_gui/app.py` (`FenetrePrincipale`), aux côtés des onglets Tri/Consultation/Configuration déjà existants ; le dossier importé peut être ouvert directement dans l'onglet Tri via un signal Qt `dossier_importe` (US3 Acceptance Scenario 7) — `tri_view.EcranTri._charger_dossier` renommée en méthode publique `ouvrir_dossier` pour permettre cette connexion inter-onglets sans dupliquer le regroupement RAW+JPEG | 117 | - [X] T037 [US3] Câbler l'onglet « Import » dans `packages/regine-gui/src/regine_gui/app.py` (`FenetrePrincipale`), aux côtés des onglets Tri/Consultation/Configuration déjà existants ; le dossier importé peut être ouvert directement dans l'onglet Tri via un signal Qt `dossier_importe` (US3 Acceptance Scenario 7) — `tri_view.EcranTri._charger_dossier` renommée en méthode publique `ouvrir_dossier` pour permettre cette connexion inter-onglets sans dupliquer le regroupement RAW+JPEG |
| 118 | -- [X] T038 [US3] Validé manuellement en mode headless (`QT_QPA_PLATFORM=offscreen`) : import complet du chemin « nouveau dossier » (analyse, titre, catégorie vide, confirmation, archivage) déclenché par script simulant les réponses utilisateur (`QInputDialog`/`QMessageBox` monkeypatchés, `resoudre_archive_locale` monkeypatché pour éviter une dépendance à un vrai partage SMB monté, même méthode que `specs/003` T033/`specs/006` T017/T026) — dossier obtenu (`AAAA-MM-JJ_Titre`, fichiers renommés `date_titre_nomOrigine.ext`) structurellement identique à ce que produit `regine import` (Scénario 8 de `quickstart.md`, SC-007). Chemins « nouveau sous-dossier »/« fusion »/collision de boîtier non exercés par ce script (aucune carte de test avec EXIF/voyage multi-étapes disponible dans cet environnement) : validés par lecture de code, chaque branche appelant exclusivement des fonctions `regine_core.import_carte`/`camera_profile` déjà testées unitairement par `specs/001`/`specs/002` | 118 | +- [X] T038 [US3] Validé manuellement en mode headless (`QT_QPA_PLATFORM=offscreen`) : import complet du chemin « nouveau dossier » (analyse, titre, catégorie vide, confirmation, archivage en arrière-plan) déclenché par script simulant les réponses utilisateur (`QInputDialog`/`QMessageBox` monkeypatchés, `resoudre_archive_locale` monkeypatché pour éviter une dépendance à un vrai partage SMB monté, boucle d'événements Qt maintenue active jusqu'à la fin du thread d'archivage, même méthode que `specs/003` T033/`specs/006` T017/T026) — dossier obtenu (`AAAA-MM-JJ_Titre`, fichiers renommés `date_titre_nomOrigine.ext`) structurellement identique à ce que produit `regine import` (Scénario 8 de `quickstart.md`, SC-007). Complété par un test manuel réel (utilisateur, application non headless) sur une carte de test avec des fichiers plus volumineux, qui a mis en évidence les deux bugs de gel/threading documentés en T036 — tous deux corrigés et revalidés. Chemins « nouveau sous-dossier »/« fusion »/collision de boîtier non exercés par un script automatisé (aucune carte de test avec EXIF/voyage multi-étapes disponible dans cet environnement) : validés par lecture de code, chaque branche appelant exclusivement des fonctions `regine_core.import_carte`/`camera_profile` déjà testées unitairement par `specs/001`/`specs/002` |
| 119 | 119 | ||
| 120 | **Checkpoint** : User Story 3 fonctionnelle et testable indépendamment ; CLI (déjà livrée par `specs/001-import-photos`) et GUI cohérentes entre elles. | 120 | **Checkpoint** : User Story 3 fonctionnelle et testable indépendamment ; CLI (déjà livrée par `specs/001-import-photos`) et GUI cohérentes entre elles. |
| 121 | 121 | ||
| @@ -127,6 +127,7 @@ description: "Task list for feature implementation" | |||
| 127 | - [X] T028 [P] `ruff check --fix` + `ruff format` sur `packages/regine-core`, `packages/regine-cli` et `packages/regine-gui` — 172/172 tests passent après coup | 127 | - [X] T028 [P] `ruff check --fix` + `ruff format` sur `packages/regine-core`, `packages/regine-cli` et `packages/regine-gui` — 172/172 tests passent après coup |
| 128 | - [X] T029 Mettre à jour `contracts/regine-core-api.md`/`cli-dossier-selection.md`/`cli-archive-consultation.md` si l'implémentation révèle un écart avec les signatures documentées (patron déjà établi dans les specs précédentes) — tous mis à jour au fil de l'implémentation (signature `promouvoir`, renommage `browse`/`restore`, `resoudre_archive_locale`) | 128 | - [X] T029 Mettre à jour `contracts/regine-core-api.md`/`cli-dossier-selection.md`/`cli-archive-consultation.md` si l'implémentation révèle un écart avec les signatures documentées (patron déjà établi dans les specs précédentes) — tous mis à jour au fil de l'implémentation (signature `promouvoir`, renommage `browse`/`restore`, `resoudre_archive_locale`) |
| 129 | - [X] T039 [P] Exécuté le Scénario 8 de `quickstart.md` (cf. T038 ; Scénario 7 déjà couvert par `specs/001-import-photos`, sans duplication) et `ruff check --fix`/`ruff format` sur `packages/regine-gui`, `packages/regine-cli`, `packages/regine-core` (nouveau fichier `import_view.py`, nouvelle fonction publique `root_location_depuis_chemin`) — 172/172 tests `regine-core`/`regine-cli` toujours au vert, aucun nouveau test requis côté core (aucune nouvelle règle métier ajoutée par US3, seulement une fonction déjà testée indirectement via `import_cmd` rendue publique) | 129 | - [X] T039 [P] Exécuté le Scénario 8 de `quickstart.md` (cf. T038 ; Scénario 7 déjà couvert par `specs/001-import-photos`, sans duplication) et `ruff check --fix`/`ruff format` sur `packages/regine-gui`, `packages/regine-cli`, `packages/regine-core` (nouveau fichier `import_view.py`, nouvelle fonction publique `root_location_depuis_chemin`) — 172/172 tests `regine-core`/`regine-cli` toujours au vert, aucun nouveau test requis côté core (aucune nouvelle règle métier ajoutée par US3, seulement une fonction déjà testée indirectement via `import_cmd` rendue publique) |
| 130 | +- [X] T040 [US3] Révision 2026-09-22 (cf. Clarifications de `specs/001-import-photos/spec.md`) : `import_view.py` mis à jour pour suivre le nouveau pipeline `specs/001` — `classer_par_format`/`publier_localement` remplacent `archiver`/`publier_copie_locale` dans `_PublicationLocaleWorker` (ex-`_ArchivageWorker`) ; `_analyser` n'exige plus que le partage SMB soit monté (`config.smb.chemin_local_du_partage`, calcul pur, remplace `resoudre_archive_locale`) ; dialogues/journal reformulés pour ne plus jamais mentionner un « archivage » à l'import (« Confirmer la copie de travail locale », message final indiquant la commande `regine reconcile` à lancer plus tard). Revalidé manuellement en mode headless sans archive configurée/montée : structure `raw`/`jpeg` obtenue, aucune écriture sous `/Volumes/...` — 176/176 tests `regine-core`/`regine-cli` au vert (172 + 4 issus de la révision `specs/001`, cf. son `tasks.md` Phase 7) | ||
| 130 | 131 | ||
| 131 | --- | 132 | --- |
| 132 | 133 | ||