fonzarely/regine-photos-archiverpublic⑂ Fork 0
⑂ 02d8847
Commits
⬇ Clone ▾
git clone https://git.rickub.com/fonzarely/regine-photos-archiver.git
git clone ssh://git@rickub.com/fonzarely/regine-photos-archiver.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

tri raw/jpeg

Fabien Champigny committed 2026-09-22T11:37:30+02:00 Browse files
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 (
1818 suggest_categories,
1919 )
2020 from regine_core.config.db import open_context_db
21+from regine_core.import_carte.classement import classer_par_format
2122 from regine_core.import_carte.copie import copier_carte, resoudre_collisions_boitiers
2223 from regine_core.import_carte.destination import (
2324 lister_dossiers_candidats,
@@ -38,7 +39,7 @@ from regine_core.import_carte.nommage import (
3839 renommer_fichiers,
3940 resoudre_collision_nom,
4041 )
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
4243 from regine_core.import_carte.types import DestinationChoisie, FichierCandidat, GroupeImport
4344
4445 _NOM_CONTEXTE_DB_PAR_DEFAUT = ".regine-contexte.sqlite3"
@@ -253,27 +254,30 @@ def _traiter_groupe(
253254 print(f"Checkout automatique depuis l'archive : {dossier_archive} -> {dossier_local}")
254255
255256 renommer_fichiers(groupe.fichiers, groupe.plage_dates[0], titre)
257+ classer_par_format(groupe.fichiers, local_tmp)
256258
257259 chemins_maitres = [f.chemin_source for f in groupe.fichiers if f.type == "maitre"]
258260 attribuer_identifiants(chemins_maitres)
259261
260262 chemins = [f.chemin_source for f in groupe.fichiers]
261- resume = preparer_resume(chemins, dossier_archive)
263+ resume = preparer_resume(chemins, dossier_local)
262264 print(
263265 f"Résumé : {resume.nombre_fichiers} fichier(s), {resume.taille_totale} octet(s) "
264266 f"-> {resume.dossier_destination}"
265267 )
266268
267269 if interactif:
268- confirmation = input("Confirmer l'archivage ? [o/n] ")
270+ confirmation = input("Confirmer la copie de travail locale ? [o/n] ")
269271 if confirmation.strip().lower() != "o":
270- print("Archivage annulé pour ce groupe.")
272+ print("Import annulé pour ce groupe.")
271273 return
272274
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)
276276 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+ )
277281
278282
279283 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_db20 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_boitiers22 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_locale42+from regine_core.import_carte.push import preparer_resume, publier_localement
42 from regine_core.import_carte.types import DestinationChoisie, FichierCandidat, GroupeImport43 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 return273 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
1414 class RestaurationCollisionError(Exception):
1515 """`chemin_destination` existe déjà avec un contenu différent (FR-012) — jamais
1616 d'écrasement silencieux, même principe que `regine_core.import_carte.push.
17- CollisionNomArchiveError`."""
17+ CollisionNomLocalError`."""
1818
1919
2020 @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) — jamais15 """`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
1010 from dataclasses import dataclass
1111 from pathlib import Path
1212
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
4219
4320
4421 class StructureDossierInvalideError(Exception):
@@ -10,35 +10,12 @@ from __future__ import annotations
10 from dataclasses import dataclass10 from dataclasses import dataclass
11 from pathlib import Path11 from pathlib import Path
12 12
13-from regine_core.import_carte.copie import EXTENSIONS_MAITRES13+# `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 extension15+# (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 = {
2929 #: Extensions reconnues comme fichiers associés (sidecars).
3030 EXTENSIONS_ASSOCIEES = {"xmp", "dop", "acr"}
3131
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+
3238 _TAILLE_BLOC = 1024 * 1024
3339
3440
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+
3568 class EchecVerificationError(Exception):
3669 """La vérification par somme de contrôle d'un fichier a échoué pendant la copie."""
3770
@@ -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 * 102438 _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.
913 """
1014
1115 from __future__ import annotations
@@ -14,25 +18,24 @@ import shutil
1418 from dataclasses import dataclass
1519 from pathlib import Path
1620
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
2022
2123
2224 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)."""
2426
2527
26-class CollisionNomArchiveError(Exception):
28+class CollisionNomLocalError(Exception):
2729 """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)."""
3134
3235
3336 @dataclass(frozen=True)
3437 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)."""
3639
3740 nombre_fichiers: int
3841 taille_totale: int
@@ -41,7 +44,7 @@ class ResumeConfirmation:
4144
4245 def preparer_resume(fichiers: list[Path], dossier_destination: Path) -> ResumeConfirmation:
4346 """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)."""
4548 taille_totale = sum(f.stat().st_size for f in fichiers)
4649 return ResumeConfirmation(
4750 nombre_fichiers=len(fichiers),
@@ -56,7 +59,7 @@ def _copier_verifie_sans_ecraser(source: Path, destination: Path) -> bool:
5659 Ne DOIT jamais écraser un fichier déjà présent de contenu différent
5760 (FR-012 appliqué au niveau fichier) : un doublon de contenu identique est
5861 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.
6063 """
6164 destination.parent.mkdir(parents=True, exist_ok=True)
6265 hash_source = hash_fichier_entier(source)
@@ -64,7 +67,7 @@ def _copier_verifie_sans_ecraser(source: Path, destination: Path) -> bool:
6467 if destination.exists():
6568 if hash_fichier_entier(destination) == hash_source:
6669 return False # doublon déjà présent (US3 scénario 3) — rien à refaire
67- raise CollisionNomArchiveError(
70+ raise CollisionNomLocalError(
6871 f"{destination} existe déjà avec un contenu différent — refus d'écraser "
6972 "silencieusement (probablement deux fichiers de boîtiers différents "
7073 "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:
7679 return True
7780
7881
79-def archiver(
82+def publier_localement(
8083 fichiers_locaux: list[Path],
8184 dossier_local_racine: Path,
8285 dossier_destination: Path,
8386 ) -> 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).
8589
8690 Ne DOIT être appelée qu'après confirmation explicite du résumé (FR-018) —
8791 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).
8995 """
9096 dossier_destination.mkdir(parents=True, exist_ok=True)
9197 for source in fichiers_locaux:
9298 relatif = source.relative_to(dossier_local_racine)
9399 _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 et4+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édiatement5+`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 persistant6+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 annotations15 from __future__ import annotations
@@ -14,25 +18,24 @@ import shutil
14 from dataclasses import dataclass18 from dataclasses import dataclass
15 from pathlib import Path19 from pathlib import Path
16 20
17-from regine_core.archive.manifest import ouvrir_ou_creer21+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 le29 """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ême31+ 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: int40 nombre_fichiers: int
38 taille_totale: int41 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érent59 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 est60 (FR-012 appliqué au niveau fichier) : un doublon de contenu identique est
58 ignoré silencieusement (no-op, retourne `False`), un contenu différent61 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 à refaire69 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 True79 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
88 from pathlib import Path
99
1010 import pytest
11+from regine_core.import_carte.classement import classer_par_format
1112 from regine_core.import_carte.copie import copier_carte
1213 from regine_core.import_carte.destination import resoudre_destination
1314 from regine_core.import_carte.groupage import decouper_en_groupes
1415 from regine_core.import_carte.identifiant import attribuer_identifiants
1516 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
1718 from regine_core.metadata.exif import close_session, read_document_id
1819
1920 _JPEG_1X1_BASE64 = (
@@ -73,23 +74,33 @@ def test_full_pipeline_card_to_archived_folder(tmp_path: Path) -> None:
7374 renommer_fichiers(groupe.fichiers, groupe.plage_dates[0], groupe.titre)
7475 assert groupe.fichiers[0].chemin_source.name == "2026-08-15_Sortie_parc_RD0001.JPG"
7576
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é.
7784 chemins_maitres = [f.chemin_source for f in groupe.fichiers if f.type == "maitre"]
7885 identifiants = attribuer_identifiants(chemins_maitres)
7986 assert read_document_id(chemins_maitres[0]) == identifiants[chemins_maitres[0]]
8087
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
8392 resume = preparer_resume([f.chemin_source for f in groupe.fichiers], dossier_final)
8493 assert resume.nombre_fichiers == 1
8594 assert resume.dossier_destination == dossier_final
8695
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).
8897 assert not dossier_final.exists()
8998
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)
91100
92101 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 Path8 from pathlib import Path
9 9
10 import pytest10 import pytest
11+from regine_core.import_carte.classement import classer_par_format
11 from regine_core.import_carte.copie import copier_carte12 from regine_core.import_carte.copie import copier_carte
12 from regine_core.import_carte.destination import resoudre_destination13 from regine_core.import_carte.destination import resoudre_destination
13 from regine_core.import_carte.groupage import decouper_en_groupes14 from regine_core.import_carte.groupage import decouper_en_groupes
14 from regine_core.import_carte.identifiant import attribuer_identifiants15 from regine_core.import_carte.identifiant import attribuer_identifiants
15 from regine_core.import_carte.nommage import construire_nom_dossier, renommer_fichiers16 from regine_core.import_carte.nommage import construire_nom_dossier, renommer_fichiers
16-from regine_core.import_carte.push import archiver, preparer_resume17+from regine_core.import_carte.push import preparer_resume, publier_localement
17 from regine_core.metadata.exif import close_session, read_document_id18 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_dossier89+ # 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 == 193 assert resume.nombre_fichiers == 1
85 assert resume.dossier_destination == dossier_final94 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_final102+ 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 @@
11 """Écran d'import (User Story 3, specs/006) — appelle exclusivement
22 `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).
67
78 Reprend à l'identique les étapes déjà définies et testées par
89 `specs/001-import-photos` : analyse jour par jour, découpage en groupes,
910 destination, titre, confirmation avant écriture, désambiguïsation de collision
1011 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.
1120 """
1221
1322 from __future__ import annotations
@@ -33,7 +42,8 @@ from regine_core.camera_profile.resolve import assign_manual_source
3342 from regine_core.config.categories import list_known_categories, register_category_usage
3443 from regine_core.config.context import charger_contexte
3544 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
3747 from regine_core.import_carte.copie import (
3848 EchecVerificationError,
3949 copier_carte,
@@ -58,8 +68,8 @@ from regine_core.import_carte.nommage import (
5868 renommer_fichiers,
5969 resoudre_collision_nom,
6070 )
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
6373
6474 _TYPES_DESTINATION = {
6575 "Nouveau dossier": "nouveau_dossier",
@@ -69,39 +79,38 @@ _TYPES_DESTINATION = {
6979 }
7080
7181
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.
7485
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)."""
8291
83- succes = Signal(object, object) # dossier_archive: Path, dossier_local: Path
92+ succes = Signal(object) # dossier_local: Path
8493 echec = Signal(str)
8594
8695 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
8897 ) -> None:
8998 super().__init__()
90- self._chemins = chemins
99+ self._fichiers = fichiers
91100 self._local_tmp = local_tmp
92101 self._dossier_local = dossier_local
93- self._dossier_archive = dossier_archive
94102
95103 def run(self) -> None:
96104 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)
101110 except Exception as exc: # rapporté au thread principal, jamais levé ici
102111 self.echec.emit(str(exc))
103112 return
104- self.succes.emit(self._dossier_archive, self._dossier_local)
113+ self.succes.emit(self._dossier_local)
105114
106115
107116 class EcranImport(QMainWindow):
@@ -125,7 +134,9 @@ class EcranImport(QMainWindow):
125134 self._local_tmp: Path | None = None
126135 self._groupes: list[GroupeImport] = []
127136 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
129140
130141 conteneur = QWidget()
131142 self.setCentralWidget(conteneur)
@@ -198,19 +209,22 @@ class EcranImport(QMainWindow):
198209 QMessageBox.warning(
199210 self,
200211 "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.",
202215 )
203216 return
204217
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."
212227 )
213- return
214228
215229 local_root = contexte.local_work_dir
216230 local_tmp = local_root / ".regine-import-tmp"
@@ -315,10 +329,11 @@ class EcranImport(QMainWindow):
315329 try:
316330 tache_lancee = self._traiter_groupe(groupe)
317331 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.
322337 QMessageBox.warning(self, "Échec de l'import", str(exc))
323338
324339 if not tache_lancee:
@@ -436,48 +451,61 @@ class EcranImport(QMainWindow):
436451 )
437452
438453 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)
441454
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)
444460
445461 confirmation = QMessageBox.question(
446462 self,
447- "Confirmer l'archivage",
463+ "Confirmer la copie de travail locale",
448464 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 ?",
450468 )
451469 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} ».")
453471 return False
454472
455473 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), "
457475 f"{resume.taille_totale} octet(s)) — la fenêtre reste utilisable pendant ce temps…"
458476 )
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)
460478 return True
461479
462- def _lancer_archivage(
480+ def _lancer_publication(
463481 self,
464- chemins: list[Path],
482+ fichiers: list[FichierCandidat],
465483 local_tmp: Path,
466484 dossier_local: Path,
467485 dossier_archive: Path,
468486 titre: str,
469487 ) -> 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+
475503 thread = QThread(self)
476- worker = _ArchivageWorker(chemins, local_tmp, dossier_local, dossier_archive)
504+ worker = _PublicationLocaleWorker(fichiers, local_tmp, dossier_local)
477505 worker.moveToThread(thread)
478506 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)
481509 worker.succes.connect(thread.quit)
482510 worker.echec.connect(thread.quit)
483511 thread.finished.connect(worker.deleteLater)
@@ -486,9 +514,13 @@ class EcranImport(QMainWindow):
486514 self._worker = worker
487515 thread.start()
488516
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
491519 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+ )
492524 self._thread = None
493525 self._worker = None
494526
@@ -500,10 +532,11 @@ class EcranImport(QMainWindow):
500532
501533 self._finaliser_groupe()
502534
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
504537 self._thread = None
505538 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}")
507540 self._finaliser_groupe()
508541
509542 def _choisir_dossier_existant(
@@ -1,13 +1,22 @@
1 """Écran d'import (User Story 3, specs/006) — appelle exclusivement1 """É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 via3+classement, push), jamais `regine_cli.import_cmd` : ce dernier orchestre ses
4-`input()`/`print()`, incompatibles avec une boucle d'événements Qt, et ne porte4+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 par8 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 collision10 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 annotations22 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_usage42 from regine_core.config.categories import list_known_categories, register_category_usage
34 from regine_core.config.context import charger_contexte43 from regine_core.config.context import charger_contexte
35 from regine_core.config.db import open_context_db44 from regine_core.config.db import open_context_db
36-from regine_core.config.smb import resoudre_archive_locale45+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_locale71+from regine_core.import_carte.push import preparer_resume, publier_localement
62-from regine_core.import_carte.types import GroupeImport72+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 fichier86+ `attribuer_identifiants` (appels exiftool) et la copie vérifiée par somme de
76- (copie carte→local, local→archive, archive→copie locale, plus le hash87+ contrôle peuvent prendre un temps notable sur de gros fichiers RAW nombreux —
77- image exiftool) — potentiellement long pour de gros fichiers RAW, et88+ 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 pour90+ 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: Path92+ 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: Path96+ self, fichiers: list[FichierCandidat], local_tmp: Path, dossier_local: Path
88 ) -> None:97 ) -> None:
89 super().__init__()98 super().__init__()
90- self._chemins = chemins99+ self._fichiers = fichiers
91 self._local_tmp = local_tmp100 self._local_tmp = local_tmp
92 self._dossier_local = dossier_local101 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_archive107+ 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é ici110 except Exception as exc: # rapporté au thread principal, jamais levé ici
102 self.echec.emit(str(exc))111 self.echec.emit(str(exc))
103 return112 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 = None134 self._local_tmp: Path | None = None
126 self._groupes: list[GroupeImport] = []135 self._groupes: list[GroupeImport] = []
127 self._thread: QThread | None = None136 self._thread: QThread | None = None
128- self._worker: _ArchivageWorker | None = None137+ 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 return216 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_dir229 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 étant334+ # `_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 False471 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 True478 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é — ces488+ """Exécute classement + attribution d'identifiants + copie finale sur un
471- fonctions recalculent plusieurs sommes de contrôle par fichier et489+ 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 lancer490+ fichiers RAW nombreux ; les lancer directement sur le thread principal
473- directement sur le thread principal gèlerait toute la fenêtre pendant491+ 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 = worker514 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 = None524 self._thread = None
493 self._worker = None525 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 = None537 self._thread = None
505 self._worker = None538 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 @@
5555 (répertoire racine affiché dans le résumé de confirmation), Key Entities (Dossier / Dossier
5656 parent), Acceptance Scenarios de User Story 3 (héritage du répertoire racine par les
5757 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 / Dossier55 (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 les56 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
1414 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.
1515 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).
1616 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.
2020
2121 **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.
2323 - É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.
2424 - Espace disque insuffisant (Edge Case) : message clair avant toute copie, code non-zéro.
2525 - 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`).
2728
2829 ## Notes de scriptabilité
2930
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).
3132 - `--categorie` et `--annee` sont mutuellement exclusifs ; en leur absence en mode interactif, la question est posée normalement.
3233
3334 ## Notes d'implémentation (post-US3)
@@ -35,10 +36,15 @@ regine import /Volumes/CARTE_SD [--titre TEXTE] [--destination nouveau|sous-doss
3536 - `--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).
3637 - 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).
3738 - `--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.
3940
4041 ## Notes d'implémentation (post-FR-010, 2026-09-19)
4142
4243 - **É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
4242
4343 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.
4444
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`
4846
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`.
5048
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`
5250
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.
5452
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`
5654
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.
5856
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).
6058
6159 ## Notes d'implémentation
6260
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.
6462
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`).
6766
6867 ## Dépendances résolues
6968
7069 - 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.
7170 - 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émentation59 ## 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ésolues67 ## 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é
3131 | `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) |
3232 | `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 |
3333
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+
3443 ## Renommage
3544
3645 | Champ | Type | Règles |
@@ -58,7 +67,14 @@ Carte mémoire ──▶ FichierCandidat (checksum, date) ──▶ Groupe d'imp
5867 Renommage (par fichier maître + fichiers liés)
5968 │
6069 ▼
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
6278 ```
6379
6480 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
7389 Découpé en sous-groupes (chacun redevient "Proposé")
7490 │ utilisateur saisit titre + destination (+ catégorie si nouveau_dossier/nouveau_parent)
7591 ▼
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)
7894 ▼
79- Prêt pour archivage (résumé affiché, FR-018)
95+ Prêt pour publication locale (résumé affiché, FR-018)
8096 │ confirmation explicite de l'utilisateur
8197 ▼
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)
83103 ```
@@ -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 ## Renommage43 ## 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 ──▶ Archive70+ 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'utilisateur96 │ 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`
1414 regine import ./fixtures/carte_journee_unique --titre "Sortie parc" --annee --yes
1515 ```
1616
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).
1824
1925 ## Scénario 2 — Découpage multi-jours (User Story 2, P2)
2026
@@ -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 --yes14 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 @@
88
99 **Input**: User description: "utilise les docs pour generer la specification du module d'importation des photos"
1010
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+
1118 ## User Scenarios & Testing *(mandatory)*
1219
1320 ### User Story 1 - Import simple d'une carte mémoire vers un nouveau dossier (Priority: P1)
1421
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`).
1623
1724 **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.
1825
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.
2027
2128 **Acceptance Scenarios**:
2229
2330 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.
2431 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.
2532 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.
2836
2937 ---
3038
@@ -41,7 +49,7 @@ Un photographe importe une carte couvrant plusieurs jours consécutifs (ex. une
4149 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.
4250 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.
4351 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.
4553
4654 ---
4755
@@ -72,6 +80,7 @@ Un photographe est en voyage sur plusieurs villes. Pour la deuxième étape, il
7280 - 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.
7381 - 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.
7482 - 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.
7584
7685 ## Requirements *(mandatory)*
7786
@@ -86,23 +95,26 @@ Un photographe est en voyage sur plusieurs villes. Pour la deuxième étape, il
8695 - **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`).
8796 - **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).
8897 - **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).
9099 - **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.
91100 - **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.
93102 - **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.
94103 - **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).
95104 - **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.
96105 - **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.
99109
100110 ### Key Entities
101111
102112 - **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é.
106118 - **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.
107119 - **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.
108120 - **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
114126 ### Measurable Outcomes
115127
116128 - **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).
118130 - **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.
119131 - **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.
120132 - **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).
122134 - **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.
124138
125139 ## Assumptions
126140
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é.
128142 - 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.
129143 - 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.
130144 - 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 Entities110 ### 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 Outcomes126 ### 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 ## Assumptions139 ## 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à
131131
132132 ---
133133
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+
134151 ## Dependencies & Execution Order
135152
136153 ### 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 Order151 ## Dependencies & Execution Order
135 152
136 ### Phase Dependencies153 ### 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
165165
166166 ## Assumptions
167167
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.
169169 - 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.
170170 - 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.
171171 - 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 ## Assumptions166 ## 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
4343 - `destination.resoudre_destination(...) -> DestinationChoisie` / `destination.resoudre_fusion(...) -> DestinationChoisie`
4444 - `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.
4545 - `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`.
4647 - `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`.
4849
4950 **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).
5051
@@ -53,3 +54,4 @@ Aucune fonction nouvelle : l'onglet d'import de la GUI (`import_view.py`) appell
5354 - **É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.
5455 - `regrouper` retourne des `EntreeRegroupee.fichiers: tuple[Path, ...]` (pas des champs fixes `fichier_raw`/`fichier_jpeg`) — cf. correction dans `data-model.md`.
5556 - **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)
7777 | `fichiers` | liste de `FichierCandidat` | Résultat de `copier_carte` (déjà copiés et vérifiés en local, cf. `specs/001` FR-001) |
7878 | `groupes` | liste de `GroupeImport` | Résultat de `decouper_en_groupes`, éventuellement ajusté par `detacher_jours` (US3 Acceptance Scenario 2) |
7979 | `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) |
8181
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 \
5656 --archive-root ~/regine/archive --local-root ~/regine/local
5757 ```
5858
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.
6060
6161 ## Scénario 8 — Importer une carte via la GUI (User Story 3, P3) — validation manuelle
6262
@@ -64,7 +64,7 @@ regine import ~/Volumes/CARTE_SD --titre "Sortie parc" --annee --yes \
6464 regine-gui
6565 ```
6666
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).
6868
6969 ## Critères de sortie
7070
@@ -56,7 +56,7 @@ regine import ~/Volumes/CARTE_SD --titre "Sortie parc" --annee --yes \
56 --archive-root ~/regine/archive --local-root ~/regine/local56 --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 manuelle61 ## 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-gui64 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 sortie69 ## Critères de sortie
70 70
modified specs/006-gui-tri-consultation/research.md +11 -1
@@ -46,7 +46,7 @@
4646
4747 ## 6. Onglet d'import (US3) : appel direct de `regine_core.import_carte`, jamais de `regine_cli.import_cmd`
4848
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.)
5050
5151 **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.
5252
@@ -54,6 +54,16 @@
5454 - 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`).
5555 - 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.
5656
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+
5767 ## Résumé
5868
5969 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
5454
5555 **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.
5656
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).
5858
5959 **Acceptance Scenarios**:
6060
6161 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).
6262 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`).
6363 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`).
6565 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`).
6666 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.
6767 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
7575 - 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`).
7676 - 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.
7777 - 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.
7980 - 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.
8081
8182 ## Requirements *(mandatory)*
@@ -96,7 +97,7 @@ Un photographe qui utilise déjà l'interface graphique de Régine pour trier se
9697 - **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é.
9798 - **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.
9899 - **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).
100101 - **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.
101102 - **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.
102103 - **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
119120 - **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.
120121 - **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.
121122 - **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.
124125
125126 ## Assumptions
126127
@@ -131,3 +132,4 @@ Un photographe qui utilise déjà l'interface graphique de Régine pour trier se
131132 - 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).
132133 - 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.
133134 - 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 ## Assumptions126 ## 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"
113113 - [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`
114114 - [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)
115115 - [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
117117 - [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`
119119
120120 **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.
121121
@@ -127,6 +127,7 @@ description: "Task list for feature implementation"
127127 - [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
128128 - [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`)
129129 - [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)
130131
131132 ---
132133
@@ -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+JPEG117 - [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 coup127 - [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