fonzarely/regine-photos-archiverpublic⑂ Fork 0
⑂ 1da9bb6
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.

implemente import

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