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

chargement plus rapide des photos, async

Fabien Champigny committed 2026-09-22T09:55:52+02:00 Browse files
eca67cf parent: 9c96cb0
modified packages/regine-cli/src/regine_cli/import_cmd.py +2 -18
@@ -18,12 +18,12 @@ from regine_core.config.categories import (
1818 suggest_categories,
1919 )
2020 from regine_core.config.db import open_context_db
21-from regine_core.dossier.root import RootLocation
2221 from regine_core.import_carte.copie import copier_carte, resoudre_collisions_boitiers
2322 from regine_core.import_carte.destination import (
2423 lister_dossiers_candidats,
2524 resoudre_destination,
2625 resoudre_fusion,
26+ root_location_depuis_chemin,
2727 )
2828 from regine_core.import_carte.groupage import (
2929 decouper_en_groupes,
@@ -112,22 +112,6 @@ def _choisir_categorie(*, conn: sqlite3.Connection, interactif: bool) -> str | N
112112 return saisie
113113
114114
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-
131115 def _choisir_dossier_existant(
132116 titre_partiel: str, *, archive_root: Path, local_root: Path, interactif: bool, arg: str | None
133117 ) -> str:
@@ -235,7 +219,7 @@ def _traiter_groupe(
235219 # sous-dossier d'étape doit lui être physiquement imbriqué, sous le
236220 # dossier parent réel (ex. voyage/2026-08_Montenegro/2026-08-14_Kotor),
237221 # pas simplement sous la racine catégorie.
238- root_parent = _root_location_depuis_chemin(
222+ root_parent = root_location_depuis_chemin(
239223 chemin_parent, archive_root=archive_root, local_root=local_root
240224 )
241225 lieu = None if titre_impose else input("Lieu de cette étape (optionnel) : ").strip() or None
@@ -18,12 +18,12 @@ from regine_core.config.categories import (
18 suggest_categories,18 suggest_categories,
19 )19 )
20 from regine_core.config.db import open_context_db20 from regine_core.config.db import open_context_db
21-from regine_core.dossier.root import RootLocation
22 from regine_core.import_carte.copie import copier_carte, resoudre_collisions_boitiers21 from regine_core.import_carte.copie import copier_carte, resoudre_collisions_boitiers
23 from regine_core.import_carte.destination import (22 from regine_core.import_carte.destination import (
24 lister_dossiers_candidats,23 lister_dossiers_candidats,
25 resoudre_destination,24 resoudre_destination,
26 resoudre_fusion,25 resoudre_fusion,
26+ root_location_depuis_chemin,
27 )27 )
28 from regine_core.import_carte.groupage import (28 from regine_core.import_carte.groupage import (
29 decouper_en_groupes,29 decouper_en_groupes,
@@ -112,22 +112,6 @@ def _choisir_categorie(*, conn: sqlite3.Connection, interactif: bool) -> str | N
112 return saisie112 return saisie
113 113
114 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(115 def _choisir_dossier_existant(
132 titre_partiel: str, *, archive_root: Path, local_root: Path, interactif: bool, arg: str | None116 titre_partiel: str, *, archive_root: Path, local_root: Path, interactif: bool, arg: str | None
133 ) -> str:117 ) -> str:
@@ -235,7 +219,7 @@ def _traiter_groupe(
235 # sous-dossier d'étape doit lui être physiquement imbriqué, sous le219 # 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),220 # dossier parent réel (ex. voyage/2026-08_Montenegro/2026-08-14_Kotor),
237 # pas simplement sous la racine catégorie.221 # pas simplement sous la racine catégorie.
238- root_parent = _root_location_depuis_chemin(222+ root_parent = root_location_depuis_chemin(
239 chemin_parent, archive_root=archive_root, local_root=local_root223 chemin_parent, archive_root=archive_root, local_root=local_root
240 )224 )
241 lieu = None if titre_impose else input("Lieu de cette étape (optionnel) : ").strip() or None225 lieu = None if titre_impose else input("Lieu de cette étape (optionnel) : ").strip() or None
modified packages/regine-core/src/regine_core/import_carte/destination.py +23 -1
@@ -6,7 +6,7 @@ import difflib
66 from datetime import date as _date
77 from pathlib import Path
88
9-from regine_core.dossier.root import RootLocation, determine_root
9+from regine_core.dossier.root import RootLocation, RootType, determine_root
1010 from regine_core.import_carte.types import DestinationChoisie, GroupeImport, TypeDestination
1111
1212
@@ -77,6 +77,28 @@ def resoudre_fusion(
7777 )
7878
7979
80+def root_location_depuis_chemin(
81+ chemin_relatif: str, *, archive_root: Path, local_root: Path
82+) -> RootLocation:
83+ """Reconstruit le `RootLocation` d'un dossier parent déjà existant (créé lors
84+ d'un import précédent) à partir de son premier segment de chemin relatif — ce
85+ segment est soit une année, soit une catégorie (FR-006 de specs/004).
86+
87+ Partagée entre `regine-cli` (`import_cmd`) et `regine-gui` (onglet d'import,
88+ specs/006 User Story 3) — extraite ici après coup pour éviter la duplication
89+ déjà trouvée à l'implémentation de la GUI (même schéma que
90+ `config.smb.resoudre_archive_locale`, cf. `specs/006/contracts/regine-core-api.md`).
91+ """
92+ premier_segment = Path(chemin_relatif).parts[0]
93+ type_: RootType = "annee" if premier_segment.isdigit() else "categorie"
94+ return RootLocation(
95+ type=type_,
96+ nom=premier_segment,
97+ chemin_archive=archive_root / premier_segment,
98+ chemin_local=local_root / premier_segment,
99+ )
100+
101+
80102 def lister_dossiers_candidats(
81103 titre_partiel: str,
82104 racines: list[Path],
@@ -6,7 +6,7 @@ import difflib
6 from datetime import date as _date6 from datetime import date as _date
7 from pathlib import Path7 from pathlib import Path
8 8
9-from regine_core.dossier.root import RootLocation, determine_root9+from regine_core.dossier.root import RootLocation, RootType, determine_root
10 from regine_core.import_carte.types import DestinationChoisie, GroupeImport, TypeDestination10 from regine_core.import_carte.types import DestinationChoisie, GroupeImport, TypeDestination
11 11
12 12
@@ -77,6 +77,28 @@ def resoudre_fusion(
77 )77 )
78 78
79 79
80+def root_location_depuis_chemin(
81+ chemin_relatif: str, *, archive_root: Path, local_root: Path
82+) -> RootLocation:
83+ """Reconstruit le `RootLocation` d'un dossier parent déjà existant (créé lors
84+ d'un import précédent) à partir de son premier segment de chemin relatif — ce
85+ segment est soit une année, soit une catégorie (FR-006 de specs/004).
86+
87+ Partagée entre `regine-cli` (`import_cmd`) et `regine-gui` (onglet d'import,
88+ specs/006 User Story 3) — extraite ici après coup pour éviter la duplication
89+ déjà trouvée à l'implémentation de la GUI (même schéma que
90+ `config.smb.resoudre_archive_locale`, cf. `specs/006/contracts/regine-core-api.md`).
91+ """
92+ premier_segment = Path(chemin_relatif).parts[0]
93+ type_: RootType = "annee" if premier_segment.isdigit() else "categorie"
94+ return RootLocation(
95+ type=type_,
96+ nom=premier_segment,
97+ chemin_archive=archive_root / premier_segment,
98+ chemin_local=local_root / premier_segment,
99+ )
100+
101+
80 def lister_dossiers_candidats(102 def lister_dossiers_candidats(
81 titre_partiel: str,103 titre_partiel: str,
82 racines: list[Path],104 racines: list[Path],
modified packages/regine-gui/src/regine_gui/app.py +11 -5
@@ -9,14 +9,15 @@ from PySide6.QtWidgets import QApplication, QMainWindow, QTabWidget
99
1010 from regine_gui.config_view import EcranConfiguration
1111 from regine_gui.consultation_view import EcranConsultation
12+from regine_gui.import_view import EcranImport
1213 from regine_gui.tri_view import EcranTri
1314
1415
1516 class FenetrePrincipale(QMainWindow):
16- """Regroupe l'écran de tri (US1), l'écran de consultation (US2) et l'écran de
17- configuration (specs/003 User Story 4) sous trois onglets d'une même fenêtre —
18- chacun appelle sa propre partie de regine-core, aucune logique partagée entre
19- eux ici (Principe VI)."""
17+ """Regroupe l'écran de tri (US1), l'écran de consultation (US2), l'écran de
18+ configuration (specs/003 User Story 4) et l'écran d'import (specs/006 User
19+ Story 3) sous quatre onglets d'une même fenêtre — chacun appelle sa propre
20+ partie de regine-core, aucune logique partagée entre eux ici (Principe VI)."""
2021
2122 def __init__(self, contexte_db: Path | None = None) -> None:
2223 super().__init__()
@@ -25,9 +26,14 @@ class FenetrePrincipale(QMainWindow):
2526
2627 onglets = QTabWidget()
2728 self.setCentralWidget(onglets)
28- onglets.addTab(EcranTri(), "Tri")
29+ self._ecran_tri = EcranTri()
30+ ecran_import = EcranImport(contexte_db=contexte_db)
31+ ecran_import.dossier_importe.connect(self._ecran_tri.ouvrir_dossier)
32+
33+ onglets.addTab(self._ecran_tri, "Tri")
2934 onglets.addTab(EcranConsultation(contexte_db=contexte_db), "Consultation")
3035 onglets.addTab(EcranConfiguration(contexte_db=contexte_db), "Configuration")
36+ onglets.addTab(ecran_import, "Import")
3137
3238
3339 def main(argv: list[str] | None = None) -> int:
@@ -9,14 +9,15 @@ from PySide6.QtWidgets import QApplication, QMainWindow, QTabWidget
9 9
10 from regine_gui.config_view import EcranConfiguration10 from regine_gui.config_view import EcranConfiguration
11 from regine_gui.consultation_view import EcranConsultation11 from regine_gui.consultation_view import EcranConsultation
12+from regine_gui.import_view import EcranImport
12 from regine_gui.tri_view import EcranTri13 from regine_gui.tri_view import EcranTri
13 14
14 15
15 class FenetrePrincipale(QMainWindow):16 class FenetrePrincipale(QMainWindow):
16- """Regroupe l'écran de tri (US1), l'écran de consultation (US2) et l'écran de17+ """Regroupe l'écran de tri (US1), l'écran de consultation (US2), l'écran de
17- configuration (specs/003 User Story 4) sous trois onglets d'une même fenêtre —18+ configuration (specs/003 User Story 4) et l'écran d'import (specs/006 User
18- chacun appelle sa propre partie de regine-core, aucune logique partagée entre19+ Story 3) sous quatre onglets d'une même fenêtre — chacun appelle sa propre
19- eux ici (Principe VI)."""20+ partie de regine-core, aucune logique partagée entre eux ici (Principe VI)."""
20 21
21 def __init__(self, contexte_db: Path | None = None) -> None:22 def __init__(self, contexte_db: Path | None = None) -> None:
22 super().__init__()23 super().__init__()
@@ -25,9 +26,14 @@ class FenetrePrincipale(QMainWindow):
25 26
26 onglets = QTabWidget()27 onglets = QTabWidget()
27 self.setCentralWidget(onglets)28 self.setCentralWidget(onglets)
28- onglets.addTab(EcranTri(), "Tri")29+ self._ecran_tri = EcranTri()
30+ ecran_import = EcranImport(contexte_db=contexte_db)
31+ ecran_import.dossier_importe.connect(self._ecran_tri.ouvrir_dossier)
32+
33+ onglets.addTab(self._ecran_tri, "Tri")
29 onglets.addTab(EcranConsultation(contexte_db=contexte_db), "Consultation")34 onglets.addTab(EcranConsultation(contexte_db=contexte_db), "Consultation")
30 onglets.addTab(EcranConfiguration(contexte_db=contexte_db), "Configuration")35 onglets.addTab(EcranConfiguration(contexte_db=contexte_db), "Configuration")
36+ onglets.addTab(ecran_import, "Import")
31 37
32 38
33 def main(argv: list[str] | None = None) -> int:39 def main(argv: list[str] | None = None) -> int:
added packages/regine-gui/src/regine_gui/import_view.py +532 -0
new file mode 100644
@@ -0,0 +1,532 @@
1+"""Écran d'import (User Story 3, specs/006) — appelle exclusivement
2+`regine_core.import_carte` (copie, groupage, destination, nommage, identifiant,
3+push), jamais `regine_cli.import_cmd` : ce dernier orchestre ses décisions via
4+`input()`/`print()`, incompatibles avec une boucle d'événements Qt, et ne porte
5+aucune règle métier qui ne soit déjà dans `regine_core` (research.md § 6).
6+
7+Reprend à l'identique les étapes déjà définies et testées par
8+`specs/001-import-photos` : analyse jour par jour, découpage en groupes,
9+destination, titre, confirmation avant écriture, désambiguïsation de collision
10+de boîtier — jamais une logique propre à la GUI (Principe VI).
11+"""
12+
13+from __future__ import annotations
14+
15+from pathlib import Path
16+
17+from PySide6.QtCore import QObject, QThread, Signal
18+from PySide6.QtWidgets import (
19+ QFileDialog,
20+ QHBoxLayout,
21+ QInputDialog,
22+ QLabel,
23+ QLineEdit,
24+ QMainWindow,
25+ QMessageBox,
26+ QPushButton,
27+ QTextEdit,
28+ QVBoxLayout,
29+ QWidget,
30+)
31+from regine_core.camera_profile.db import list_boitiers
32+from regine_core.camera_profile.resolve import assign_manual_source
33+from regine_core.config.categories import list_known_categories, register_category_usage
34+from regine_core.config.context import charger_contexte
35+from regine_core.config.db import open_context_db
36+from regine_core.config.smb import resoudre_archive_locale
37+from regine_core.import_carte.copie import (
38+ EchecVerificationError,
39+ copier_carte,
40+ resoudre_collisions_boitiers,
41+)
42+from regine_core.import_carte.destination import (
43+ lister_dossiers_candidats,
44+ resoudre_destination,
45+ resoudre_fusion,
46+ root_location_depuis_chemin,
47+)
48+from regine_core.import_carte.groupage import (
49+ decouper_en_groupes,
50+ detacher_jours,
51+ jours_candidats_au_detachement,
52+)
53+from regine_core.import_carte.identifiant import attribuer_identifiants
54+from regine_core.import_carte.nommage import (
55+ construire_nom_dossier,
56+ construire_nom_dossier_parent,
57+ construire_nom_sous_dossier,
58+ renommer_fichiers,
59+ resoudre_collision_nom,
60+)
61+from regine_core.import_carte.push import archiver, preparer_resume, publier_copie_locale
62+from regine_core.import_carte.types import GroupeImport
63+
64+_TYPES_DESTINATION = {
65+ "Nouveau dossier": "nouveau_dossier",
66+ "Nouveau dossier parent (voyage)": "nouveau_parent",
67+ "Nouveau sous-dossier d'un parent existant": "nouveau_sous_dossier",
68+ "Fusion dans un dossier existant": "fusion",
69+}
70+
71+
72+class _ArchivageWorker(QObject):
73+ """Exécute `archiver`/`publier_copie_locale` hors du thread principal Qt.
74+
75+ Ces deux fonctions recalculent plusieurs sommes de contrôle par fichier
76+ (copie carte→local, local→archive, archive→copie locale, plus le hash
77+ image exiftool) — potentiellement long pour de gros fichiers RAW, et
78+ strictement identique à ce que fait déjà la CLI (`specs/001-import-photos`).
79+ Aucune logique métier ici : uniquement l'appel, déplacé sur un thread pour
80+ ne pas geler la fenêtre pendant ce temps (FR-017 : retour visuel pendant
81+ l'exécution, jamais une attente silencieuse)."""
82+
83+ succes = Signal(object, object) # dossier_archive: Path, dossier_local: Path
84+ echec = Signal(str)
85+
86+ def __init__(
87+ self, chemins: list[Path], local_tmp: Path, dossier_local: Path, dossier_archive: Path
88+ ) -> None:
89+ super().__init__()
90+ self._chemins = chemins
91+ self._local_tmp = local_tmp
92+ self._dossier_local = dossier_local
93+ self._dossier_archive = dossier_archive
94+
95+ def run(self) -> None:
96+ try:
97+ archiver(self._chemins, self._local_tmp, self._dossier_archive)
98+ publier_copie_locale(
99+ self._chemins, self._local_tmp, self._dossier_local, self._dossier_archive
100+ )
101+ except Exception as exc: # rapporté au thread principal, jamais levé ici
102+ self.echec.emit(str(exc))
103+ return
104+ self.succes.emit(self._dossier_archive, self._dossier_local)
105+
106+
107+class EcranImport(QMainWindow):
108+ """Fenêtre d'import : analyse une carte mémoire, découpe en groupes,
109+ résout destination/titre/collisions puis archive — même résultat que
110+ `regine import` (specs/001-import-photos), SC-007/SC-008."""
111+
112+ #: Émis après une publication réussie (US3 Acceptance Scenario 7) pour que
113+ #: `app.py` propose d'ouvrir le dossier importé dans l'onglet Tri.
114+ dossier_importe = Signal(Path)
115+
116+ def __init__(self, contexte_db: Path | None = None) -> None:
117+ super().__init__()
118+ self.setWindowTitle("Régine — Import")
119+ self.resize(720, 520)
120+ self._contexte_db = contexte_db
121+ self._conn = None
122+ self._carte: Path | None = None
123+ self._archive_root: Path | None = None
124+ self._local_root: Path | None = None
125+ self._local_tmp: Path | None = None
126+ self._groupes: list[GroupeImport] = []
127+ self._thread: QThread | None = None
128+ self._worker: _ArchivageWorker | None = None
129+
130+ conteneur = QWidget()
131+ self.setCentralWidget(conteneur)
132+ disposition = QVBoxLayout(conteneur)
133+
134+ barre_carte = QHBoxLayout()
135+ self._champ_carte = QLineEdit()
136+ self._champ_carte.setReadOnly(True)
137+ self._champ_carte.setPlaceholderText("Aucune carte sélectionnée")
138+ barre_carte.addWidget(self._champ_carte, stretch=1)
139+ bouton_choisir = QPushButton("Choisir une carte…")
140+ bouton_choisir.clicked.connect(self._choisir_carte)
141+ barre_carte.addWidget(bouton_choisir)
142+ disposition.addLayout(barre_carte)
143+
144+ self._bouton_analyser = QPushButton("Analyser la carte")
145+ self._bouton_analyser.clicked.connect(self._analyser)
146+ disposition.addWidget(self._bouton_analyser)
147+
148+ self._label_groupes = QLabel("Aucune analyse effectuée.")
149+ disposition.addWidget(self._label_groupes)
150+
151+ self._bouton_traiter = QPushButton("Traiter le prochain groupe…")
152+ self._bouton_traiter.setEnabled(False)
153+ self._bouton_traiter.clicked.connect(self._traiter_prochain_groupe)
154+ disposition.addWidget(self._bouton_traiter)
155+
156+ disposition.addWidget(QLabel("Journal :"))
157+ self._journal = QTextEdit()
158+ self._journal.setReadOnly(True)
159+ disposition.addWidget(self._journal, stretch=1)
160+
161+ def _log(self, message: str) -> None:
162+ self._journal.append(message)
163+
164+ def _ouvrir_conn(self):
165+ return open_context_db(self._contexte_db)
166+
167+ def _choisir_carte(self) -> None:
168+ chemin = QFileDialog.getExistingDirectory(self, "Choisir une carte mémoire")
169+ if chemin:
170+ self._carte = Path(chemin)
171+ self._champ_carte.setText(chemin)
172+
173+ # -- Analyse -----------------------------------------------------------
174+
175+ def _analyser(self) -> None:
176+ if self._carte is None:
177+ QMessageBox.warning(self, "Aucune carte", "Choisissez d'abord une carte mémoire.")
178+ return
179+ if not self._carte.is_dir():
180+ QMessageBox.warning(
181+ self, "Source invalide", f"{self._carte} n'est pas un support de carte valide."
182+ )
183+ return
184+
185+ conn = self._ouvrir_conn()
186+ contexte = charger_contexte(conn)
187+ if contexte.local_work_dir is None:
188+ conn.close()
189+ QMessageBox.warning(
190+ self,
191+ "Contexte non configuré",
192+ "Configurez d'abord le contexte de travail (onglet Configuration).",
193+ )
194+ return
195+
196+ if contexte.archive_smb_path is None:
197+ conn.close()
198+ QMessageBox.warning(
199+ self,
200+ "Archive non configurée",
201+ "Configurez d'abord l'emplacement de l'archive (onglet Configuration).",
202+ )
203+ return
204+
205+ archive_root = resoudre_archive_locale(conn)
206+ if archive_root is None:
207+ conn.close()
208+ QMessageBox.warning(
209+ self,
210+ "Archive inaccessible",
211+ f"{contexte.archive_smb_path} n'est pas accessible — montage déclenché, réessayez.",
212+ )
213+ return
214+
215+ local_root = contexte.local_work_dir
216+ local_tmp = local_root / ".regine-import-tmp"
217+
218+ try:
219+ fichiers = copier_carte(self._carte, local_tmp)
220+ except EchecVerificationError as exc:
221+ conn.close()
222+ QMessageBox.warning(self, "Échec de la copie", str(exc))
223+ return
224+
225+ if not fichiers:
226+ conn.close()
227+ self._log("Aucun fichier nouveau à importer.")
228+ return
229+
230+ a_etiqueter = resoudre_collisions_boitiers(fichiers, conn=conn)
231+ if not self._resoudre_etiquetage_manuel(a_etiqueter, conn=conn):
232+ conn.close()
233+ self._log("Import annulé : collision de boîtier non résolue.")
234+ return
235+
236+ groupes = decouper_en_groupes(fichiers)
237+ groupes = self._proposer_detachement(groupes)
238+
239+ self._conn = conn
240+ self._archive_root = archive_root
241+ self._local_root = local_root
242+ self._local_tmp = local_tmp
243+ self._groupes = groupes
244+
245+ resume = "\n".join(
246+ f" - {g.plage_dates[0]} → {g.plage_dates[1]} : {len(g.fichiers)} fichier(s)"
247+ for g in groupes
248+ )
249+ self._label_groupes.setText(f"{len(groupes)} groupe(s) à traiter :\n{resume}")
250+ self._bouton_traiter.setEnabled(True)
251+ self._log(
252+ f"Analyse terminée : {len(fichiers)} fichier(s) nouveau(x), {len(groupes)} groupe(s)."
253+ )
254+
255+ def _proposer_detachement(self, groupes: list[GroupeImport]) -> list[GroupeImport]:
256+ """Propose le détachement d'un jour candidat (FR-006) — jamais automatique."""
257+ if len(groupes) != 1:
258+ return groupes
259+ (groupe_initial,) = groupes
260+ candidats = jours_candidats_au_detachement(groupe_initial.fichiers)
261+ for jour in candidats:
262+ reponse = QMessageBox.question(
263+ self,
264+ "Jour à détacher ?",
265+ f"Le {jour} se distingue nettement du reste : le détacher en groupe séparé ?",
266+ )
267+ if reponse == QMessageBox.StandardButton.Yes:
268+ return detacher_jours(groupe_initial, [jour])
269+ return groupes
270+
271+ def _resoudre_etiquetage_manuel(self, a_etiqueter, *, conn) -> bool:
272+ """Résout les groupes de fichiers encore ambigus après résolution
273+ automatique (FR-005/FR-015) en demandant à l'utilisateur d'étiqueter
274+ chaque groupe — jamais résolu arbitrairement. Retourne `False` si
275+ l'utilisateur annule (import à interrompre)."""
276+ for groupe in a_etiqueter:
277+ noms = ", ".join(f.nom_origine or "?" for f in groupe)
278+ boitiers_connus = list_boitiers(conn)
279+ options = [
280+ f"{b.nom_lisible or b.modele or f'boîtier #{b.id}'}" for b in boitiers_connus
281+ ] + ["Nouveau boîtier"]
282+ choix, ok = QInputDialog.getItem(
283+ self,
284+ "Collision de boîtier",
285+ f"Fichiers en collision ({noms}) — choisir le boîtier source :",
286+ options,
287+ editable=False,
288+ )
289+ if not ok:
290+ return False
291+ if choix == "Nouveau boîtier":
292+ modele, _ = QInputDialog.getText(
293+ self, "Nouveau boîtier", "Modèle de ce boîtier (optionnel) :"
294+ )
295+ boitier_id = assign_manual_source(
296+ conn, [f.chemin_source for f in groupe], modele=modele.strip() or None
297+ )
298+ else:
299+ boitier_id = boitiers_connus[options.index(choix)].id
300+ for f in groupe:
301+ f.boitier_id = boitier_id
302+ return True
303+
304+ # -- Traitement d'un groupe ---------------------------------------------
305+
306+ def _traiter_prochain_groupe(self) -> None:
307+ if not self._groupes:
308+ return
309+ groupe = self._groupes.pop(0)
310+ # Désactivé pendant tout le traitement (dialogues puis, le cas échéant,
311+ # archivage en arrière-plan) — réactivé par `_finaliser_groupe`.
312+ self._bouton_traiter.setEnabled(False)
313+
314+ tache_lancee = False
315+ try:
316+ tache_lancee = self._traiter_groupe(groupe)
317+ except Exception as exc: # dernier filet, jamais une exception technique brute —
318+ # CollisionNomArchiveError/EchecTransfertError (archiver/publier_copie_locale)
319+ # ne peuvent plus remonter ici : elles sont capturées dans `_ArchivageWorker.run`
320+ # et rapportées via le signal `echec` (_sur_archivage_echec), le thread étant
321+ # lancé de façon non bloquante avant que cette méthode ne retourne.
322+ QMessageBox.warning(self, "Échec de l'import", str(exc))
323+
324+ if not tache_lancee:
325+ self._finaliser_groupe()
326+
327+ def _finaliser_groupe(self) -> None:
328+ """Rappelé une fois un groupe entièrement traité (skip/échec immédiat,
329+ ou fin de l'archivage en arrière-plan) : réactive le bouton s'il reste
330+ des groupes, sinon ferme la connexion à la base de contexte."""
331+ if self._groupes:
332+ self._bouton_traiter.setEnabled(True)
333+ elif self._conn is not None:
334+ self._conn.close()
335+ self._conn = None
336+ self._label_groupes.setText("Tous les groupes ont été traités.")
337+
338+ def _traiter_groupe(self, groupe: GroupeImport) -> bool:
339+ """Traite `groupe` jusqu'à la confirmation d'archivage. Retourne `True`
340+ si l'archivage a été lancé en arrière-plan (finalisation différée
341+ jusqu'au signal `succes`/`echec` du worker), `False` si le groupe a été
342+ ignoré/annulé (finalisation immédiate par l'appelant)."""
343+ assert self._conn is not None
344+ assert self._archive_root is not None
345+ assert self._local_root is not None
346+ assert self._local_tmp is not None
347+ conn = self._conn
348+ archive_root = self._archive_root
349+ local_root = self._local_root
350+ local_tmp = self._local_tmp
351+
352+ titre, ok = QInputDialog.getText(self, "Titre du groupe", "Titre :")
353+ if not ok or not titre.strip():
354+ self._log("Groupe ignoré (aucun titre saisi).")
355+ return False
356+ groupe.titre = titre
357+
358+ type_libelle, ok = QInputDialog.getItem(
359+ self,
360+ "Destination",
361+ "Destination de ce groupe :",
362+ list(_TYPES_DESTINATION),
363+ editable=False,
364+ )
365+ if not ok:
366+ self._log("Groupe ignoré (aucune destination choisie).")
367+ return False
368+ type_destination = _TYPES_DESTINATION[type_libelle]
369+
370+ if type_destination in ("nouveau_dossier", "nouveau_parent"):
371+ connues = list_known_categories(conn)
372+ label = "Catégorie (vide = année par défaut) :"
373+ if connues:
374+ label = f"Catégories connues : {', '.join(connues)}\n{label}"
375+ categorie, _ = QInputDialog.getText(self, "Catégorie", label)
376+ categorie = categorie.strip() or None
377+ if categorie:
378+ register_category_usage(conn, categorie)
379+
380+ destination = resoudre_destination(
381+ groupe,
382+ type_destination,
383+ archive_root=archive_root,
384+ local_root=local_root,
385+ categorie=categorie,
386+ )
387+ root = destination.root_location
388+ assert root is not None
389+ nom_dossier = (
390+ construire_nom_dossier_parent(groupe.plage_dates[0], titre)
391+ if type_destination == "nouveau_parent"
392+ else construire_nom_dossier(groupe, titre)
393+ )
394+ dossier_local = resoudre_collision_nom(root.chemin_local / nom_dossier)
395+ dossier_archive = root.chemin_archive / dossier_local.name
396+
397+ elif type_destination == "nouveau_sous_dossier":
398+ chemin_parent = self._choisir_dossier_existant(
399+ titre, archive_root=archive_root, local_root=local_root
400+ )
401+ if chemin_parent is None:
402+ self._log("Groupe ignoré (aucun dossier parent choisi).")
403+ return False
404+ root_parent = root_location_depuis_chemin(
405+ chemin_parent, archive_root=archive_root, local_root=local_root
406+ )
407+ lieu, _ = QInputDialog.getText(self, "Lieu", "Lieu de cette étape (optionnel) :")
408+ nom_dossier = construire_nom_sous_dossier(groupe, titre, lieu=lieu.strip() or None)
409+ dossier_local = resoudre_collision_nom(local_root / chemin_parent / nom_dossier)
410+ dossier_archive = archive_root / chemin_parent / dossier_local.name
411+ destination = resoudre_destination(
412+ groupe,
413+ "nouveau_sous_dossier",
414+ archive_root=archive_root,
415+ local_root=local_root,
416+ root_parent=root_parent,
417+ dossier_cible=dossier_local,
418+ )
419+
420+ else: # fusion
421+ chemin_cible = self._choisir_dossier_existant(
422+ titre, archive_root=archive_root, local_root=local_root
423+ )
424+ if chemin_cible is None:
425+ self._log("Groupe ignoré (aucun dossier de fusion choisi).")
426+ return False
427+ destination = resoudre_fusion(
428+ chemin_cible, archive_root=archive_root, local_root=local_root
429+ )
430+ assert destination.dossier_cible is not None
431+ dossier_local = destination.dossier_cible
432+ dossier_archive = archive_root / chemin_cible
433+ if destination.necessite_checkout_archive:
434+ self._log(
435+ f"Checkout automatique depuis l'archive : {dossier_archive} -> {dossier_local}"
436+ )
437+
438+ renommer_fichiers(groupe.fichiers, groupe.plage_dates[0], titre)
439+ chemins_maitres = [f.chemin_source for f in groupe.fichiers if f.type == "maitre"]
440+ attribuer_identifiants(chemins_maitres)
441+
442+ chemins = [f.chemin_source for f in groupe.fichiers]
443+ resume = preparer_resume(chemins, dossier_archive)
444+
445+ confirmation = QMessageBox.question(
446+ self,
447+ "Confirmer l'archivage",
448+ f"{resume.nombre_fichiers} fichier(s), {resume.taille_totale} octet(s)\n"
449+ f"→ {resume.dossier_destination}\n\nConfirmer l'archivage ?",
450+ )
451+ if confirmation != QMessageBox.StandardButton.Yes:
452+ self._log(f"Archivage annulé pour le groupe « {titre} ».")
453+ return False
454+
455+ self._log(
456+ f"Archivage en cours pour « {titre} » ({resume.nombre_fichiers} fichier(s), "
457+ f"{resume.taille_totale} octet(s)) — la fenêtre reste utilisable pendant ce temps…"
458+ )
459+ self._lancer_archivage(chemins, local_tmp, dossier_local, dossier_archive, titre)
460+ return True
461+
462+ def _lancer_archivage(
463+ self,
464+ chemins: list[Path],
465+ local_tmp: Path,
466+ dossier_local: Path,
467+ dossier_archive: Path,
468+ titre: str,
469+ ) -> None:
470+ """Exécute `archiver`/`publier_copie_locale` sur un thread séparé — ces
471+ fonctions recalculent plusieurs sommes de contrôle par fichier et
472+ peuvent prendre un temps notable sur de gros fichiers RAW ; les lancer
473+ directement sur le thread principal gèlerait toute la fenêtre pendant
474+ ce temps (FR-017)."""
475+ thread = QThread(self)
476+ worker = _ArchivageWorker(chemins, local_tmp, dossier_local, dossier_archive)
477+ worker.moveToThread(thread)
478+ thread.started.connect(worker.run)
479+ worker.succes.connect(lambda da, dl: self._sur_archivage_succes(da, dl, titre))
480+ worker.echec.connect(lambda msg: self._sur_archivage_echec(msg, titre))
481+ worker.succes.connect(thread.quit)
482+ worker.echec.connect(thread.quit)
483+ thread.finished.connect(worker.deleteLater)
484+ thread.finished.connect(thread.deleteLater)
485+ self._thread = thread
486+ self._worker = worker
487+ thread.start()
488+
489+ def _sur_archivage_succes(self, dossier_archive: Path, dossier_local: Path, titre: str) -> None:
490+ self._log(f"Archivé : {dossier_archive}")
491+ self._log(f"Copie de travail locale prête : {dossier_local}")
492+ self._thread = None
493+ self._worker = None
494+
495+ ouvrir = QMessageBox.question(
496+ self, "Import terminé", "Ouvrir ce dossier dans l'onglet Tri ?"
497+ )
498+ if ouvrir == QMessageBox.StandardButton.Yes:
499+ self.dossier_importe.emit(dossier_local)
500+
501+ self._finaliser_groupe()
502+
503+ def _sur_archivage_echec(self, message: str, titre: str) -> None:
504+ self._thread = None
505+ self._worker = None
506+ QMessageBox.warning(self, "Échec de l'archivage", f"Groupe « {titre} » : {message}")
507+ self._finaliser_groupe()
508+
509+ def _choisir_dossier_existant(
510+ self, titre_partiel: str, *, archive_root: Path, local_root: Path
511+ ) -> str | None:
512+ """Détermine le chemin relatif d'un dossier parent ou de fusion existant,
513+ choisi interactivement parmi les candidats (FR-008) ou saisi manuellement."""
514+ candidats = lister_dossiers_candidats(titre_partiel, [local_root, archive_root])
515+ options = [
516+ str(c.relative_to(local_root if local_root in c.parents else archive_root))
517+ for c in candidats
518+ ]
519+ options.append("Saisir un chemin manuellement…")
520+ choix, ok = QInputDialog.getItem(
521+ self, "Dossier existant", "Choisir un dossier :", options, editable=False
522+ )
523+ if not ok:
524+ return None
525+ if choix != "Saisir un chemin manuellement…":
526+ return choix
527+ manuel, ok = QInputDialog.getText(
528+ self, "Chemin du dossier", "Chemin relatif (ex. voyage/2026-08_Montenegro) :"
529+ )
530+ if not ok or not manuel.strip():
531+ return None
532+ return manuel.strip()
new file mode 100644
@@ -0,0 +1,532 @@
1+"""Écran d'import (User Story 3, specs/006) — appelle exclusivement
2+`regine_core.import_carte` (copie, groupage, destination, nommage, identifiant,
3+push), jamais `regine_cli.import_cmd` : ce dernier orchestre ses décisions via
4+`input()`/`print()`, incompatibles avec une boucle d'événements Qt, et ne porte
5+aucune règle métier qui ne soit déjà dans `regine_core` (research.md § 6).
6+
7+Reprend à l'identique les étapes déjà définies et testées par
8+`specs/001-import-photos` : analyse jour par jour, découpage en groupes,
9+destination, titre, confirmation avant écriture, désambiguïsation de collision
10+de boîtier — jamais une logique propre à la GUI (Principe VI).
11+"""
12+
13+from __future__ import annotations
14+
15+from pathlib import Path
16+
17+from PySide6.QtCore import QObject, QThread, Signal
18+from PySide6.QtWidgets import (
19+ QFileDialog,
20+ QHBoxLayout,
21+ QInputDialog,
22+ QLabel,
23+ QLineEdit,
24+ QMainWindow,
25+ QMessageBox,
26+ QPushButton,
27+ QTextEdit,
28+ QVBoxLayout,
29+ QWidget,
30+)
31+from regine_core.camera_profile.db import list_boitiers
32+from regine_core.camera_profile.resolve import assign_manual_source
33+from regine_core.config.categories import list_known_categories, register_category_usage
34+from regine_core.config.context import charger_contexte
35+from regine_core.config.db import open_context_db
36+from regine_core.config.smb import resoudre_archive_locale
37+from regine_core.import_carte.copie import (
38+ EchecVerificationError,
39+ copier_carte,
40+ resoudre_collisions_boitiers,
41+)
42+from regine_core.import_carte.destination import (
43+ lister_dossiers_candidats,
44+ resoudre_destination,
45+ resoudre_fusion,
46+ root_location_depuis_chemin,
47+)
48+from regine_core.import_carte.groupage import (
49+ decouper_en_groupes,
50+ detacher_jours,
51+ jours_candidats_au_detachement,
52+)
53+from regine_core.import_carte.identifiant import attribuer_identifiants
54+from regine_core.import_carte.nommage import (
55+ construire_nom_dossier,
56+ construire_nom_dossier_parent,
57+ construire_nom_sous_dossier,
58+ renommer_fichiers,
59+ resoudre_collision_nom,
60+)
61+from regine_core.import_carte.push import archiver, preparer_resume, publier_copie_locale
62+from regine_core.import_carte.types import GroupeImport
63+
64+_TYPES_DESTINATION = {
65+ "Nouveau dossier": "nouveau_dossier",
66+ "Nouveau dossier parent (voyage)": "nouveau_parent",
67+ "Nouveau sous-dossier d'un parent existant": "nouveau_sous_dossier",
68+ "Fusion dans un dossier existant": "fusion",
69+}
70+
71+
72+class _ArchivageWorker(QObject):
73+ """Exécute `archiver`/`publier_copie_locale` hors du thread principal Qt.
74+
75+ Ces deux fonctions recalculent plusieurs sommes de contrôle par fichier
76+ (copie carte→local, local→archive, archive→copie locale, plus le hash
77+ image exiftool) — potentiellement long pour de gros fichiers RAW, et
78+ strictement identique à ce que fait déjà la CLI (`specs/001-import-photos`).
79+ Aucune logique métier ici : uniquement l'appel, déplacé sur un thread pour
80+ ne pas geler la fenêtre pendant ce temps (FR-017 : retour visuel pendant
81+ l'exécution, jamais une attente silencieuse)."""
82+
83+ succes = Signal(object, object) # dossier_archive: Path, dossier_local: Path
84+ echec = Signal(str)
85+
86+ def __init__(
87+ self, chemins: list[Path], local_tmp: Path, dossier_local: Path, dossier_archive: Path
88+ ) -> None:
89+ super().__init__()
90+ self._chemins = chemins
91+ self._local_tmp = local_tmp
92+ self._dossier_local = dossier_local
93+ self._dossier_archive = dossier_archive
94+
95+ def run(self) -> None:
96+ try:
97+ archiver(self._chemins, self._local_tmp, self._dossier_archive)
98+ publier_copie_locale(
99+ self._chemins, self._local_tmp, self._dossier_local, self._dossier_archive
100+ )
101+ except Exception as exc: # rapporté au thread principal, jamais levé ici
102+ self.echec.emit(str(exc))
103+ return
104+ self.succes.emit(self._dossier_archive, self._dossier_local)
105+
106+
107+class EcranImport(QMainWindow):
108+ """Fenêtre d'import : analyse une carte mémoire, découpe en groupes,
109+ résout destination/titre/collisions puis archive — même résultat que
110+ `regine import` (specs/001-import-photos), SC-007/SC-008."""
111+
112+ #: Émis après une publication réussie (US3 Acceptance Scenario 7) pour que
113+ #: `app.py` propose d'ouvrir le dossier importé dans l'onglet Tri.
114+ dossier_importe = Signal(Path)
115+
116+ def __init__(self, contexte_db: Path | None = None) -> None:
117+ super().__init__()
118+ self.setWindowTitle("Régine — Import")
119+ self.resize(720, 520)
120+ self._contexte_db = contexte_db
121+ self._conn = None
122+ self._carte: Path | None = None
123+ self._archive_root: Path | None = None
124+ self._local_root: Path | None = None
125+ self._local_tmp: Path | None = None
126+ self._groupes: list[GroupeImport] = []
127+ self._thread: QThread | None = None
128+ self._worker: _ArchivageWorker | None = None
129+
130+ conteneur = QWidget()
131+ self.setCentralWidget(conteneur)
132+ disposition = QVBoxLayout(conteneur)
133+
134+ barre_carte = QHBoxLayout()
135+ self._champ_carte = QLineEdit()
136+ self._champ_carte.setReadOnly(True)
137+ self._champ_carte.setPlaceholderText("Aucune carte sélectionnée")
138+ barre_carte.addWidget(self._champ_carte, stretch=1)
139+ bouton_choisir = QPushButton("Choisir une carte…")
140+ bouton_choisir.clicked.connect(self._choisir_carte)
141+ barre_carte.addWidget(bouton_choisir)
142+ disposition.addLayout(barre_carte)
143+
144+ self._bouton_analyser = QPushButton("Analyser la carte")
145+ self._bouton_analyser.clicked.connect(self._analyser)
146+ disposition.addWidget(self._bouton_analyser)
147+
148+ self._label_groupes = QLabel("Aucune analyse effectuée.")
149+ disposition.addWidget(self._label_groupes)
150+
151+ self._bouton_traiter = QPushButton("Traiter le prochain groupe…")
152+ self._bouton_traiter.setEnabled(False)
153+ self._bouton_traiter.clicked.connect(self._traiter_prochain_groupe)
154+ disposition.addWidget(self._bouton_traiter)
155+
156+ disposition.addWidget(QLabel("Journal :"))
157+ self._journal = QTextEdit()
158+ self._journal.setReadOnly(True)
159+ disposition.addWidget(self._journal, stretch=1)
160+
161+ def _log(self, message: str) -> None:
162+ self._journal.append(message)
163+
164+ def _ouvrir_conn(self):
165+ return open_context_db(self._contexte_db)
166+
167+ def _choisir_carte(self) -> None:
168+ chemin = QFileDialog.getExistingDirectory(self, "Choisir une carte mémoire")
169+ if chemin:
170+ self._carte = Path(chemin)
171+ self._champ_carte.setText(chemin)
172+
173+ # -- Analyse -----------------------------------------------------------
174+
175+ def _analyser(self) -> None:
176+ if self._carte is None:
177+ QMessageBox.warning(self, "Aucune carte", "Choisissez d'abord une carte mémoire.")
178+ return
179+ if not self._carte.is_dir():
180+ QMessageBox.warning(
181+ self, "Source invalide", f"{self._carte} n'est pas un support de carte valide."
182+ )
183+ return
184+
185+ conn = self._ouvrir_conn()
186+ contexte = charger_contexte(conn)
187+ if contexte.local_work_dir is None:
188+ conn.close()
189+ QMessageBox.warning(
190+ self,
191+ "Contexte non configuré",
192+ "Configurez d'abord le contexte de travail (onglet Configuration).",
193+ )
194+ return
195+
196+ if contexte.archive_smb_path is None:
197+ conn.close()
198+ QMessageBox.warning(
199+ self,
200+ "Archive non configurée",
201+ "Configurez d'abord l'emplacement de l'archive (onglet Configuration).",
202+ )
203+ return
204+
205+ archive_root = resoudre_archive_locale(conn)
206+ if archive_root is None:
207+ conn.close()
208+ QMessageBox.warning(
209+ self,
210+ "Archive inaccessible",
211+ f"{contexte.archive_smb_path} n'est pas accessible — montage déclenché, réessayez.",
212+ )
213+ return
214+
215+ local_root = contexte.local_work_dir
216+ local_tmp = local_root / ".regine-import-tmp"
217+
218+ try:
219+ fichiers = copier_carte(self._carte, local_tmp)
220+ except EchecVerificationError as exc:
221+ conn.close()
222+ QMessageBox.warning(self, "Échec de la copie", str(exc))
223+ return
224+
225+ if not fichiers:
226+ conn.close()
227+ self._log("Aucun fichier nouveau à importer.")
228+ return
229+
230+ a_etiqueter = resoudre_collisions_boitiers(fichiers, conn=conn)
231+ if not self._resoudre_etiquetage_manuel(a_etiqueter, conn=conn):
232+ conn.close()
233+ self._log("Import annulé : collision de boîtier non résolue.")
234+ return
235+
236+ groupes = decouper_en_groupes(fichiers)
237+ groupes = self._proposer_detachement(groupes)
238+
239+ self._conn = conn
240+ self._archive_root = archive_root
241+ self._local_root = local_root
242+ self._local_tmp = local_tmp
243+ self._groupes = groupes
244+
245+ resume = "\n".join(
246+ f" - {g.plage_dates[0]} → {g.plage_dates[1]} : {len(g.fichiers)} fichier(s)"
247+ for g in groupes
248+ )
249+ self._label_groupes.setText(f"{len(groupes)} groupe(s) à traiter :\n{resume}")
250+ self._bouton_traiter.setEnabled(True)
251+ self._log(
252+ f"Analyse terminée : {len(fichiers)} fichier(s) nouveau(x), {len(groupes)} groupe(s)."
253+ )
254+
255+ def _proposer_detachement(self, groupes: list[GroupeImport]) -> list[GroupeImport]:
256+ """Propose le détachement d'un jour candidat (FR-006) — jamais automatique."""
257+ if len(groupes) != 1:
258+ return groupes
259+ (groupe_initial,) = groupes
260+ candidats = jours_candidats_au_detachement(groupe_initial.fichiers)
261+ for jour in candidats:
262+ reponse = QMessageBox.question(
263+ self,
264+ "Jour à détacher ?",
265+ f"Le {jour} se distingue nettement du reste : le détacher en groupe séparé ?",
266+ )
267+ if reponse == QMessageBox.StandardButton.Yes:
268+ return detacher_jours(groupe_initial, [jour])
269+ return groupes
270+
271+ def _resoudre_etiquetage_manuel(self, a_etiqueter, *, conn) -> bool:
272+ """Résout les groupes de fichiers encore ambigus après résolution
273+ automatique (FR-005/FR-015) en demandant à l'utilisateur d'étiqueter
274+ chaque groupe — jamais résolu arbitrairement. Retourne `False` si
275+ l'utilisateur annule (import à interrompre)."""
276+ for groupe in a_etiqueter:
277+ noms = ", ".join(f.nom_origine or "?" for f in groupe)
278+ boitiers_connus = list_boitiers(conn)
279+ options = [
280+ f"{b.nom_lisible or b.modele or f'boîtier #{b.id}'}" for b in boitiers_connus
281+ ] + ["Nouveau boîtier"]
282+ choix, ok = QInputDialog.getItem(
283+ self,
284+ "Collision de boîtier",
285+ f"Fichiers en collision ({noms}) — choisir le boîtier source :",
286+ options,
287+ editable=False,
288+ )
289+ if not ok:
290+ return False
291+ if choix == "Nouveau boîtier":
292+ modele, _ = QInputDialog.getText(
293+ self, "Nouveau boîtier", "Modèle de ce boîtier (optionnel) :"
294+ )
295+ boitier_id = assign_manual_source(
296+ conn, [f.chemin_source for f in groupe], modele=modele.strip() or None
297+ )
298+ else:
299+ boitier_id = boitiers_connus[options.index(choix)].id
300+ for f in groupe:
301+ f.boitier_id = boitier_id
302+ return True
303+
304+ # -- Traitement d'un groupe ---------------------------------------------
305+
306+ def _traiter_prochain_groupe(self) -> None:
307+ if not self._groupes:
308+ return
309+ groupe = self._groupes.pop(0)
310+ # Désactivé pendant tout le traitement (dialogues puis, le cas échéant,
311+ # archivage en arrière-plan) — réactivé par `_finaliser_groupe`.
312+ self._bouton_traiter.setEnabled(False)
313+
314+ tache_lancee = False
315+ try:
316+ tache_lancee = self._traiter_groupe(groupe)
317+ except Exception as exc: # dernier filet, jamais une exception technique brute —
318+ # CollisionNomArchiveError/EchecTransfertError (archiver/publier_copie_locale)
319+ # ne peuvent plus remonter ici : elles sont capturées dans `_ArchivageWorker.run`
320+ # et rapportées via le signal `echec` (_sur_archivage_echec), le thread étant
321+ # lancé de façon non bloquante avant que cette méthode ne retourne.
322+ QMessageBox.warning(self, "Échec de l'import", str(exc))
323+
324+ if not tache_lancee:
325+ self._finaliser_groupe()
326+
327+ def _finaliser_groupe(self) -> None:
328+ """Rappelé une fois un groupe entièrement traité (skip/échec immédiat,
329+ ou fin de l'archivage en arrière-plan) : réactive le bouton s'il reste
330+ des groupes, sinon ferme la connexion à la base de contexte."""
331+ if self._groupes:
332+ self._bouton_traiter.setEnabled(True)
333+ elif self._conn is not None:
334+ self._conn.close()
335+ self._conn = None
336+ self._label_groupes.setText("Tous les groupes ont été traités.")
337+
338+ def _traiter_groupe(self, groupe: GroupeImport) -> bool:
339+ """Traite `groupe` jusqu'à la confirmation d'archivage. Retourne `True`
340+ si l'archivage a été lancé en arrière-plan (finalisation différée
341+ jusqu'au signal `succes`/`echec` du worker), `False` si le groupe a été
342+ ignoré/annulé (finalisation immédiate par l'appelant)."""
343+ assert self._conn is not None
344+ assert self._archive_root is not None
345+ assert self._local_root is not None
346+ assert self._local_tmp is not None
347+ conn = self._conn
348+ archive_root = self._archive_root
349+ local_root = self._local_root
350+ local_tmp = self._local_tmp
351+
352+ titre, ok = QInputDialog.getText(self, "Titre du groupe", "Titre :")
353+ if not ok or not titre.strip():
354+ self._log("Groupe ignoré (aucun titre saisi).")
355+ return False
356+ groupe.titre = titre
357+
358+ type_libelle, ok = QInputDialog.getItem(
359+ self,
360+ "Destination",
361+ "Destination de ce groupe :",
362+ list(_TYPES_DESTINATION),
363+ editable=False,
364+ )
365+ if not ok:
366+ self._log("Groupe ignoré (aucune destination choisie).")
367+ return False
368+ type_destination = _TYPES_DESTINATION[type_libelle]
369+
370+ if type_destination in ("nouveau_dossier", "nouveau_parent"):
371+ connues = list_known_categories(conn)
372+ label = "Catégorie (vide = année par défaut) :"
373+ if connues:
374+ label = f"Catégories connues : {', '.join(connues)}\n{label}"
375+ categorie, _ = QInputDialog.getText(self, "Catégorie", label)
376+ categorie = categorie.strip() or None
377+ if categorie:
378+ register_category_usage(conn, categorie)
379+
380+ destination = resoudre_destination(
381+ groupe,
382+ type_destination,
383+ archive_root=archive_root,
384+ local_root=local_root,
385+ categorie=categorie,
386+ )
387+ root = destination.root_location
388+ assert root is not None
389+ nom_dossier = (
390+ construire_nom_dossier_parent(groupe.plage_dates[0], titre)
391+ if type_destination == "nouveau_parent"
392+ else construire_nom_dossier(groupe, titre)
393+ )
394+ dossier_local = resoudre_collision_nom(root.chemin_local / nom_dossier)
395+ dossier_archive = root.chemin_archive / dossier_local.name
396+
397+ elif type_destination == "nouveau_sous_dossier":
398+ chemin_parent = self._choisir_dossier_existant(
399+ titre, archive_root=archive_root, local_root=local_root
400+ )
401+ if chemin_parent is None:
402+ self._log("Groupe ignoré (aucun dossier parent choisi).")
403+ return False
404+ root_parent = root_location_depuis_chemin(
405+ chemin_parent, archive_root=archive_root, local_root=local_root
406+ )
407+ lieu, _ = QInputDialog.getText(self, "Lieu", "Lieu de cette étape (optionnel) :")
408+ nom_dossier = construire_nom_sous_dossier(groupe, titre, lieu=lieu.strip() or None)
409+ dossier_local = resoudre_collision_nom(local_root / chemin_parent / nom_dossier)
410+ dossier_archive = archive_root / chemin_parent / dossier_local.name
411+ destination = resoudre_destination(
412+ groupe,
413+ "nouveau_sous_dossier",
414+ archive_root=archive_root,
415+ local_root=local_root,
416+ root_parent=root_parent,
417+ dossier_cible=dossier_local,
418+ )
419+
420+ else: # fusion
421+ chemin_cible = self._choisir_dossier_existant(
422+ titre, archive_root=archive_root, local_root=local_root
423+ )
424+ if chemin_cible is None:
425+ self._log("Groupe ignoré (aucun dossier de fusion choisi).")
426+ return False
427+ destination = resoudre_fusion(
428+ chemin_cible, archive_root=archive_root, local_root=local_root
429+ )
430+ assert destination.dossier_cible is not None
431+ dossier_local = destination.dossier_cible
432+ dossier_archive = archive_root / chemin_cible
433+ if destination.necessite_checkout_archive:
434+ self._log(
435+ f"Checkout automatique depuis l'archive : {dossier_archive} -> {dossier_local}"
436+ )
437+
438+ renommer_fichiers(groupe.fichiers, groupe.plage_dates[0], titre)
439+ chemins_maitres = [f.chemin_source for f in groupe.fichiers if f.type == "maitre"]
440+ attribuer_identifiants(chemins_maitres)
441+
442+ chemins = [f.chemin_source for f in groupe.fichiers]
443+ resume = preparer_resume(chemins, dossier_archive)
444+
445+ confirmation = QMessageBox.question(
446+ self,
447+ "Confirmer l'archivage",
448+ f"{resume.nombre_fichiers} fichier(s), {resume.taille_totale} octet(s)\n"
449+ f"→ {resume.dossier_destination}\n\nConfirmer l'archivage ?",
450+ )
451+ if confirmation != QMessageBox.StandardButton.Yes:
452+ self._log(f"Archivage annulé pour le groupe « {titre} ».")
453+ return False
454+
455+ self._log(
456+ f"Archivage en cours pour « {titre} » ({resume.nombre_fichiers} fichier(s), "
457+ f"{resume.taille_totale} octet(s)) — la fenêtre reste utilisable pendant ce temps…"
458+ )
459+ self._lancer_archivage(chemins, local_tmp, dossier_local, dossier_archive, titre)
460+ return True
461+
462+ def _lancer_archivage(
463+ self,
464+ chemins: list[Path],
465+ local_tmp: Path,
466+ dossier_local: Path,
467+ dossier_archive: Path,
468+ titre: str,
469+ ) -> None:
470+ """Exécute `archiver`/`publier_copie_locale` sur un thread séparé — ces
471+ fonctions recalculent plusieurs sommes de contrôle par fichier et
472+ peuvent prendre un temps notable sur de gros fichiers RAW ; les lancer
473+ directement sur le thread principal gèlerait toute la fenêtre pendant
474+ ce temps (FR-017)."""
475+ thread = QThread(self)
476+ worker = _ArchivageWorker(chemins, local_tmp, dossier_local, dossier_archive)
477+ worker.moveToThread(thread)
478+ thread.started.connect(worker.run)
479+ worker.succes.connect(lambda da, dl: self._sur_archivage_succes(da, dl, titre))
480+ worker.echec.connect(lambda msg: self._sur_archivage_echec(msg, titre))
481+ worker.succes.connect(thread.quit)
482+ worker.echec.connect(thread.quit)
483+ thread.finished.connect(worker.deleteLater)
484+ thread.finished.connect(thread.deleteLater)
485+ self._thread = thread
486+ self._worker = worker
487+ thread.start()
488+
489+ def _sur_archivage_succes(self, dossier_archive: Path, dossier_local: Path, titre: str) -> None:
490+ self._log(f"Archivé : {dossier_archive}")
491+ self._log(f"Copie de travail locale prête : {dossier_local}")
492+ self._thread = None
493+ self._worker = None
494+
495+ ouvrir = QMessageBox.question(
496+ self, "Import terminé", "Ouvrir ce dossier dans l'onglet Tri ?"
497+ )
498+ if ouvrir == QMessageBox.StandardButton.Yes:
499+ self.dossier_importe.emit(dossier_local)
500+
501+ self._finaliser_groupe()
502+
503+ def _sur_archivage_echec(self, message: str, titre: str) -> None:
504+ self._thread = None
505+ self._worker = None
506+ QMessageBox.warning(self, "Échec de l'archivage", f"Groupe « {titre} » : {message}")
507+ self._finaliser_groupe()
508+
509+ def _choisir_dossier_existant(
510+ self, titre_partiel: str, *, archive_root: Path, local_root: Path
511+ ) -> str | None:
512+ """Détermine le chemin relatif d'un dossier parent ou de fusion existant,
513+ choisi interactivement parmi les candidats (FR-008) ou saisi manuellement."""
514+ candidats = lister_dossiers_candidats(titre_partiel, [local_root, archive_root])
515+ options = [
516+ str(c.relative_to(local_root if local_root in c.parents else archive_root))
517+ for c in candidats
518+ ]
519+ options.append("Saisir un chemin manuellement…")
520+ choix, ok = QInputDialog.getItem(
521+ self, "Dossier existant", "Choisir un dossier :", options, editable=False
522+ )
523+ if not ok:
524+ return None
525+ if choix != "Saisir un chemin manuellement…":
526+ return choix
527+ manuel, ok = QInputDialog.getText(
528+ self, "Chemin du dossier", "Chemin relatif (ex. voyage/2026-08_Montenegro) :"
529+ )
530+ if not ok or not manuel.strip():
531+ return None
532+ return manuel.strip()
modified packages/regine-gui/src/regine_gui/tri_view.py +5 -2
@@ -76,9 +76,12 @@ class EcranTri(QMainWindow):
7676 def _ouvrir_dossier(self) -> None:
7777 chemin = QFileDialog.getExistingDirectory(self, "Ouvrir un dossier de travail")
7878 if chemin:
79- self._charger_dossier(Path(chemin))
79+ self.ouvrir_dossier(Path(chemin))
8080
81- def _charger_dossier(self, dossier: Path) -> None:
81+ def ouvrir_dossier(self, dossier: Path) -> None:
82+ """Ouvre `dossier` dans cet écran — méthode publique pour permettre à un
83+ autre onglet (ex. Import, specs/006 US3) d'y ouvrir directement un
84+ dossier fraîchement importé, sans dupliquer la logique de regroupement."""
8285 try:
8386 entrees = regrouper(dossier)
8487 except StructureDossierInvalideError as exc:
@@ -76,9 +76,12 @@ class EcranTri(QMainWindow):
76 def _ouvrir_dossier(self) -> None:76 def _ouvrir_dossier(self) -> None:
77 chemin = QFileDialog.getExistingDirectory(self, "Ouvrir un dossier de travail")77 chemin = QFileDialog.getExistingDirectory(self, "Ouvrir un dossier de travail")
78 if chemin:78 if chemin:
79- self._charger_dossier(Path(chemin))79+ self.ouvrir_dossier(Path(chemin))
80 80
81- def _charger_dossier(self, dossier: Path) -> None:81+ def ouvrir_dossier(self, dossier: Path) -> None:
82+ """Ouvre `dossier` dans cet écran — méthode publique pour permettre à un
83+ autre onglet (ex. Import, specs/006 US3) d'y ouvrir directement un
84+ dossier fraîchement importé, sans dupliquer la logique de regroupement."""
82 try:85 try:
83 entrees = regrouper(dossier)86 entrees = regrouper(dossier)
84 except StructureDossierInvalideError as exc:87 except StructureDossierInvalideError as exc:
modified specs/006-gui-tri-consultation/contracts/regine-core-api.md +20 -2
@@ -1,6 +1,6 @@
1-# Contrat d'API interne : `regine_core.dossier.selection` / `regine_core.archive.consultation`
1+# Contrat d'API interne : `regine_core.dossier.selection` / `regine_core.archive.consultation` / `regine_core.import_carte`
22
3-Fonctions consommées à la fois par `regine-cli` (Principe CLI-first, cf. `contracts/cli-dossier-selection.md`/`cli-archive-consultation.md`) et par `regine-gui` — aucune logique métier propre dans les deux façades (Principe VI).
3+Fonctions consommées à la fois par `regine-cli` (Principe CLI-first, cf. `contracts/cli-dossier-selection.md`/`cli-archive-consultation.md`/`specs/001-import-photos/contracts/cli-import.md`) et par `regine-gui` — aucune logique métier propre dans les deux façades (Principe VI).
44
55 ## `selection.dossier_de_format(extension: str) -> str | None`
66
@@ -30,6 +30,24 @@ Parcourt `archive_root_local` (point de montage local du partage SMB, `regine_co
3030
3131 Copie vérifiée par somme de contrôle d'un fichier de l'archive vers un emplacement local (US2 FR-009). Refuse d'écraser silencieusement `chemin_destination` si elle existe déjà avec un contenu différent (FR-012, même principe que `regine_core.import_carte.push.CollisionNomArchiveError`, implémentation propre à ce module plutôt que partagée — cf. research.md § 5).
3232
33+## `import_carte.*` — réutilisées telles quelles par l'onglet d'import (US3)
34+
35+Aucune fonction nouvelle : l'onglet d'import de la GUI (`import_view.py`) appelle directement les fonctions déjà définies et testées par `specs/001-import-photos`, exactement comme le fait `regine_cli.import_cmd` — jamais cette dernière (cf. research.md § 6). Rappel des fonctions concernées, sans en changer la signature :
36+
37+- `copie.copier_carte(carte: Path, local_tmp: Path) -> list[FichierCandidat]`
38+- `copie.resoudre_collisions_boitiers(fichiers, *, conn) -> list[list[FichierCandidat]]`
39+- `groupage.decouper_en_groupes(fichiers) -> list[GroupeImport]`
40+- `groupage.jours_candidats_au_detachement(fichiers) -> list[date]`
41+- `groupage.detacher_jours(groupe, jours) -> list[GroupeImport]`
42+- `destination.lister_dossiers_candidats(titre_partiel, racines) -> list[Path]`
43+- `destination.resoudre_destination(...) -> DestinationChoisie` / `destination.resoudre_fusion(...) -> DestinationChoisie`
44+- `destination.root_location_depuis_chemin(chemin_relatif, *, archive_root, local_root) -> RootLocation` — **nouvelle fonction, extraite pendant l'implémentation de US3** : reconstruit le `RootLocation` d'un dossier parent déjà existant à partir de son premier segment de chemin. Existait déjà comme fonction privée (`_root_location_depuis_chemin`) dans `regine_cli.import_cmd` ; l'onglet d'import GUI en avait besoin à l'identique pour le cas « nouveau sous-dossier ». Plutôt que de la dupliquer une seconde fois (même schéma déjà rencontré et corrigé pour `smb.resoudre_archive_locale`, cf. Notes d'implémentation ci-dessous), elle a été rendue publique dans `regine_core.import_carte.destination`, et `import_cmd.py` a été mis à jour pour l'appeler elle aussi — aucun changement de comportement côté CLI.
45+- `nommage.construire_nom_dossier`/`construire_nom_dossier_parent`/`construire_nom_sous_dossier`/`renommer_fichiers`/`resoudre_collision_nom`
46+- `identifiant.attribuer_identifiants(chemins_maitres) -> None`
47+- `push.preparer_resume(chemins, dossier_archive) -> Resume` / `push.archiver(...)` / `push.publier_copie_locale(...)`
48+
49+**Différence GUI vs CLI** : uniquement la façon de recueillir chaque décision utilisateur (dialogues Qt/formulaires côté GUI, `input()` côté CLI) — jamais la fonction appelée ni le résultat produit sur le système de fichiers (US3 Acceptance Scenarios 1 à 5, SC-007).
50+
3351 ## Notes d'implémentation
3452
3553 - **Écart trouvé par un test dès l'implémentation** : la première version de `promouvoir` prenait seulement `fichier: Path` et déduisait la racine cible en vérifiant si `fichier.parent.name` correspondait à un dossier de format connu. Ce test a échoué sur le cas « promotion en deux temps » (racine d'une étape, puis racine du dossier parent, section 10 des notes d'archivage) : rien ne distingue, dans la seule position d'un fichier déjà hors de son dossier de format, « déjà promu à la racine d'une étape » de « déjà promu à la racine d'un dossier parent » — les deux se ressemblent structurellement. Corrigé en rendant `dossier` explicite : chaque promotion (étape ou dossier parent) est un geste distinct avec sa propre cible, jamais deviné. `retrograder(fichier)` n'a pas ce problème (un seul niveau possible, toujours vers le dossier de format immédiatement sous le parent courant du fichier) et garde sa signature à un seul argument.
@@ -1,6 +1,6 @@
1-# Contrat d'API interne : `regine_core.dossier.selection` / `regine_core.archive.consultation`1+# Contrat d'API interne : `regine_core.dossier.selection` / `regine_core.archive.consultation` / `regine_core.import_carte`
2 2
3-Fonctions consommées à la fois par `regine-cli` (Principe CLI-first, cf. `contracts/cli-dossier-selection.md`/`cli-archive-consultation.md`) et par `regine-gui` — aucune logique métier propre dans les deux façades (Principe VI).3+Fonctions consommées à la fois par `regine-cli` (Principe CLI-first, cf. `contracts/cli-dossier-selection.md`/`cli-archive-consultation.md`/`specs/001-import-photos/contracts/cli-import.md`) et par `regine-gui` — aucune logique métier propre dans les deux façades (Principe VI).
4 4
5 ## `selection.dossier_de_format(extension: str) -> str | None`5 ## `selection.dossier_de_format(extension: str) -> str | None`
6 6
@@ -30,6 +30,24 @@ Parcourt `archive_root_local` (point de montage local du partage SMB, `regine_co
30 30
31 Copie vérifiée par somme de contrôle d'un fichier de l'archive vers un emplacement local (US2 FR-009). Refuse d'écraser silencieusement `chemin_destination` si elle existe déjà avec un contenu différent (FR-012, même principe que `regine_core.import_carte.push.CollisionNomArchiveError`, implémentation propre à ce module plutôt que partagée — cf. research.md § 5).31 Copie vérifiée par somme de contrôle d'un fichier de l'archive vers un emplacement local (US2 FR-009). Refuse d'écraser silencieusement `chemin_destination` si elle existe déjà avec un contenu différent (FR-012, même principe que `regine_core.import_carte.push.CollisionNomArchiveError`, implémentation propre à ce module plutôt que partagée — cf. research.md § 5).
32 32
33+## `import_carte.*` — réutilisées telles quelles par l'onglet d'import (US3)
34+
35+Aucune fonction nouvelle : l'onglet d'import de la GUI (`import_view.py`) appelle directement les fonctions déjà définies et testées par `specs/001-import-photos`, exactement comme le fait `regine_cli.import_cmd` — jamais cette dernière (cf. research.md § 6). Rappel des fonctions concernées, sans en changer la signature :
36+
37+- `copie.copier_carte(carte: Path, local_tmp: Path) -> list[FichierCandidat]`
38+- `copie.resoudre_collisions_boitiers(fichiers, *, conn) -> list[list[FichierCandidat]]`
39+- `groupage.decouper_en_groupes(fichiers) -> list[GroupeImport]`
40+- `groupage.jours_candidats_au_detachement(fichiers) -> list[date]`
41+- `groupage.detacher_jours(groupe, jours) -> list[GroupeImport]`
42+- `destination.lister_dossiers_candidats(titre_partiel, racines) -> list[Path]`
43+- `destination.resoudre_destination(...) -> DestinationChoisie` / `destination.resoudre_fusion(...) -> DestinationChoisie`
44+- `destination.root_location_depuis_chemin(chemin_relatif, *, archive_root, local_root) -> RootLocation` — **nouvelle fonction, extraite pendant l'implémentation de US3** : reconstruit le `RootLocation` d'un dossier parent déjà existant à partir de son premier segment de chemin. Existait déjà comme fonction privée (`_root_location_depuis_chemin`) dans `regine_cli.import_cmd` ; l'onglet d'import GUI en avait besoin à l'identique pour le cas « nouveau sous-dossier ». Plutôt que de la dupliquer une seconde fois (même schéma déjà rencontré et corrigé pour `smb.resoudre_archive_locale`, cf. Notes d'implémentation ci-dessous), elle a été rendue publique dans `regine_core.import_carte.destination`, et `import_cmd.py` a été mis à jour pour l'appeler elle aussi — aucun changement de comportement côté CLI.
45+- `nommage.construire_nom_dossier`/`construire_nom_dossier_parent`/`construire_nom_sous_dossier`/`renommer_fichiers`/`resoudre_collision_nom`
46+- `identifiant.attribuer_identifiants(chemins_maitres) -> None`
47+- `push.preparer_resume(chemins, dossier_archive) -> Resume` / `push.archiver(...)` / `push.publier_copie_locale(...)`
48+
49+**Différence GUI vs CLI** : uniquement la façon de recueillir chaque décision utilisateur (dialogues Qt/formulaires côté GUI, `input()` côté CLI) — jamais la fonction appelée ni le résultat produit sur le système de fichiers (US3 Acceptance Scenarios 1 à 5, SC-007).
50+
33 ## Notes d'implémentation51 ## Notes d'implémentation
34 52
35 - **Écart trouvé par un test dès l'implémentation** : la première version de `promouvoir` prenait seulement `fichier: Path` et déduisait la racine cible en vérifiant si `fichier.parent.name` correspondait à un dossier de format connu. Ce test a échoué sur le cas « promotion en deux temps » (racine d'une étape, puis racine du dossier parent, section 10 des notes d'archivage) : rien ne distingue, dans la seule position d'un fichier déjà hors de son dossier de format, « déjà promu à la racine d'une étape » de « déjà promu à la racine d'un dossier parent » — les deux se ressemblent structurellement. Corrigé en rendant `dossier` explicite : chaque promotion (étape ou dossier parent) est un geste distinct avec sa propre cible, jamais deviné. `retrograder(fichier)` n'a pas ce problème (un seul niveau possible, toujours vers le dossier de format immédiatement sous le parent courant du fichier) et garde sa signature à un seul argument.53 - **Écart trouvé par un test dès l'implémentation** : la première version de `promouvoir` prenait seulement `fichier: Path` et déduisait la racine cible en vérifiant si `fichier.parent.name` correspondait à un dossier de format connu. Ce test a échoué sur le cas « promotion en deux temps » (racine d'une étape, puis racine du dossier parent, section 10 des notes d'archivage) : rien ne distingue, dans la seule position d'un fichier déjà hors de son dossier de format, « déjà promu à la racine d'une étape » de « déjà promu à la racine d'un dossier parent » — les deux se ressemblent structurellement. Corrigé en rendant `dossier` explicite : chaque promotion (étape ou dossier parent) est un geste distinct avec sa propre cible, jamais deviné. `retrograder(fichier)` n'a pas ce problème (un seul niveau possible, toujours vers le dossier de format immédiatement sous le parent courant du fichier) et garde sa signature à un seul argument.
modified specs/006-gui-tri-consultation/data-model.md +14 -0
@@ -66,3 +66,17 @@ Résultat d'une restauration d'un fichier repéré en consultation (US2, FR-009)
6666 | `chemin_source` | chemin absolu (dans l'archive) | Fichier repéré via la Vue de consultation |
6767 | `chemin_destination` | chemin absolu (local) | Choisi par l'utilisateur ; refuse d'écraser un fichier existant de contenu différent (FR-012, Edge Case) |
6868 | `hash_verifie` | booléen | DOIT être vrai avant de considérer la restauration réussie (SC-004) |
69+
70+## Assistant d'import (onglet, US3)
71+
72+État d'écran de l'onglet d'import — purement une vue sur les entités déjà définies par `specs/001-import-photos` (`FichierCandidat`, `GroupeImport`, `DestinationChoisie`, réutilisées telles quelles depuis `regine_core.import_carte.types`, jamais redéfinies ici) ; ne persiste rien de nouveau, aucune table supplémentaire.
73+
74+| Champ | Type | Règles |
75+|---|---|---|
76+| `carte` | chemin absolu | Support source sélectionné par l'utilisateur (US3 Acceptance Scenario 1) |
77+| `fichiers` | liste de `FichierCandidat` | Résultat de `copier_carte` (déjà copiés et vérifiés en local, cf. `specs/001` FR-001) |
78+| `groupes` | liste de `GroupeImport` | Résultat de `decouper_en_groupes`, éventuellement ajusté par `detacher_jours` (US3 Acceptance Scenario 2) |
79+| `groupe_courant` | `GroupeImport` \| `None` | Groupe en cours de traitement (destination, titre, confirmation) ; `None` une fois tous les groupes traités |
80+| `resume_avant_ecriture` | `preparer_resume(...)` (déjà défini par `specs/001`) | Affiché et confirmé explicitement avant tout appel à `archiver` (US3 Acceptance Scenario 4, FR-015) |
81+
82+**Effet d'un import confirmé** : identique à celui de la CLI — écriture sur l'archive (`archiver`) puis publication de la copie de travail locale (`publier_copie_locale`), sans étape supplémentaire propre à la GUI.
@@ -66,3 +66,17 @@ Résultat d'une restauration d'un fichier repéré en consultation (US2, FR-009)
66 | `chemin_source` | chemin absolu (dans l'archive) | Fichier repéré via la Vue de consultation |66 | `chemin_source` | chemin absolu (dans l'archive) | Fichier repéré via la Vue de consultation |
67 | `chemin_destination` | chemin absolu (local) | Choisi par l'utilisateur ; refuse d'écraser un fichier existant de contenu différent (FR-012, Edge Case) |67 | `chemin_destination` | chemin absolu (local) | Choisi par l'utilisateur ; refuse d'écraser un fichier existant de contenu différent (FR-012, Edge Case) |
68 | `hash_verifie` | booléen | DOIT être vrai avant de considérer la restauration réussie (SC-004) |68 | `hash_verifie` | booléen | DOIT être vrai avant de considérer la restauration réussie (SC-004) |
69+
70+## Assistant d'import (onglet, US3)
71+
72+État d'écran de l'onglet d'import — purement une vue sur les entités déjà définies par `specs/001-import-photos` (`FichierCandidat`, `GroupeImport`, `DestinationChoisie`, réutilisées telles quelles depuis `regine_core.import_carte.types`, jamais redéfinies ici) ; ne persiste rien de nouveau, aucune table supplémentaire.
73+
74+| Champ | Type | Règles |
75+|---|---|---|
76+| `carte` | chemin absolu | Support source sélectionné par l'utilisateur (US3 Acceptance Scenario 1) |
77+| `fichiers` | liste de `FichierCandidat` | Résultat de `copier_carte` (déjà copiés et vérifiés en local, cf. `specs/001` FR-001) |
78+| `groupes` | liste de `GroupeImport` | Résultat de `decouper_en_groupes`, éventuellement ajusté par `detacher_jours` (US3 Acceptance Scenario 2) |
79+| `groupe_courant` | `GroupeImport` \| `None` | Groupe en cours de traitement (destination, titre, confirmation) ; `None` une fois tous les groupes traités |
80+| `resume_avant_ecriture` | `preparer_resume(...)` (déjà défini par `specs/001`) | Affiché et confirmé explicitement avant tout appel à `archiver` (US3 Acceptance Scenario 4, FR-015) |
81+
82+**Effet d'un import confirmé** : identique à celui de la CLI — écriture sur l'archive (`archiver`) puis publication de la copie de travail locale (`publier_copie_locale`), sans étape supplémentaire propre à la GUI.
modified specs/006-gui-tri-consultation/plan.md +11 -4
@@ -6,7 +6,7 @@
66
77 ## Summary
88
9-Une façade graphique de bureau (`regine-gui`, PySide6) couvrant deux usages : le tri/culling d'un dossier de travail local déjà checkouté (regroupement visuel RAW+JPEG, promotion/rétrogradation individuelle à la racine de sélection, vue combinant plusieurs dossiers de format et, pour un dossier parent, ses sous-dossiers) et la consultation en lecture seule de l'archive NAS (recherche, restauration ciblée d'un fichier sans checkout complet). La promotion/rétrogradation à la racine de sélection est une capacité entièrement nouvelle de `regine-core` (aucun module existant ne la couvre) : par le Principe CLI-first de la constitution, elle est d'abord construite comme fonctions de bibliothèque + sous-commande CLI, la GUI n'en étant qu'une façade supplémentaire au même titre que la CLI (Principe VI) — jamais l'inverse.
9+Une façade graphique de bureau (`regine-gui`, PySide6) couvrant trois usages : le tri/culling d'un dossier de travail local déjà checkouté (regroupement visuel RAW+JPEG, promotion/rétrogradation individuelle à la racine de sélection, vue combinant plusieurs dossiers de format et, pour un dossier parent, ses sous-dossiers) ; la consultation en lecture seule de l'archive NAS (recherche, restauration ciblée d'un fichier sans checkout complet) ; et, depuis l'ajout de la User Story 3 (2026-09-21), l'import d'une carte mémoire (analyse jour par jour, découpage en groupes, destination, titre, confirmation), en appelant directement les fonctions déjà définies et testées par `specs/001-import-photos` (`regine_core.import_carte`), jamais l'orchestration interactive propre à `regine_cli.import_cmd` (cf. research.md § 6). La promotion/rétrogradation à la racine de sélection est une capacité entièrement nouvelle de `regine-core` (aucun module existant ne la couvre) : par le Principe CLI-first de la constitution, elle est d'abord construite comme fonctions de bibliothèque + sous-commande CLI, la GUI n'en étant qu'une façade supplémentaire au même titre que la CLI (Principe VI) — jamais l'inverse. L'onglet d'import (US3), lui, n'introduit aucune nouvelle fonction `regine_core` : la CLI (`regine import`) couvre déjà toutes les règles, la GUI n'en est qu'une seconde façade.
1010
1111 ## Technical Context
1212
@@ -40,12 +40,15 @@ Une façade graphique de bureau (`regine-gui`, PySide6) couvrant deux usages : l
4040 | IV. Métadonnées ouvertes et embarquées | N/A | Cette fonctionnalité ne lit ni n'écrit de métadonnées descriptives/de droits. |
4141 | V. L'utilisateur décide, Régine suggère | PASS | FR-003 : la promotion/rétrogradation reste toujours une action manuelle explicite, jamais automatique — y compris pour une paire RAW+JPEG (fichier par fichier, pas par paire). |
4242 | VI. Bibliothèque centrale, façades minces | PASS | Toute la logique (regroupement, promotion, recherche, restauration) vit dans `regine-core` ; `regine-cli` et `regine-gui` n'en sont que deux façades appelant les mêmes fonctions, sans logique dupliquée. |
43-| CLI-first | PASS (structurant) | FR-007 impose explicitement qu'aucune action de la GUI ne soit possible sans équivalent CLI — la sous-commande CLI fait partie des tâches Foundational de ce plan, avant l'implémentation de la GUI elle-même. |
43+| CLI-first | PASS (structurant) | FR-007 impose explicitement qu'aucune action de la GUI ne soit possible sans équivalent CLI — la sous-commande CLI fait partie des tâches Foundational de ce plan, avant l'implémentation de la GUI elle-même. FR-018 (US3) impose la même règle pour l'import : `regine import` existe déjà (`specs/001-import-photos`), livré avant la GUI — rien à construire côté CLI pour US3. |
44+| VI. Bibliothèque centrale, façades minces (US3) | PASS | L'onglet d'import appelle directement `regine_core.import_carte` (déjà utilisée par `regine_cli.import_cmd`), jamais l'orchestration interactive (`input()`) de ce dernier — aucune règle dupliquée, aucune fonction nouvelle (research.md § 6, contracts/regine-core-api.md). |
4445 | Exécution sans démon | PASS | PySide6 tourne dans le même processus Python que le reste, importe `regine-core` directement — pas de service ni de serveur local à superviser. |
4546 | Formats ouverts et documentés | N/A | Aucun nouveau format de stockage introduit par cette fonctionnalité. |
4647
4748 Aucune violation identifiée ; la section Complexity Tracking reste vide.
4849
50+**Re-check post ajout US3 (2026-09-21)** : `import_view.py` (nouveau, GUI) et `import_cmd.py` (déjà existant, `specs/001-import-photos`, inchangé) sont deux façades indépendantes de `regine_core.import_carte` — aucun nouveau module `regine_core`, aucune duplication de règle (cf. research.md § 6). Toutes les évaluations PASS ci-dessus restent valables.
51+
4952 **Re-check post Phase 1** (après génération de `data-model.md`, `contracts/`, `quickstart.md`) : le découpage en deux modules `regine_core.dossier.selection` (racine de sélection, US1) et `regine_core.archive.consultation` (lecture seule, US2) — distincts de `regine_core.dossier.root` (répertoire racine archive, specs/004, sémantique différente malgré le mot « racine » commun, cf. `docs/lexique.md` § Point de vigilance) — confirme chaque évaluation PASS ci-dessus. Aucune violation nouvelle introduite par la conception détaillée.
5053
5154 ## Project Structure
@@ -100,12 +103,16 @@ packages/
100103 ├── src/regine_gui/
101104 │ ├── app.py # point d'entrée, lance l'application PySide6
102105 │ ├── tri_view.py # écran de tri/culling (US1) — appelle regine_core.dossier.selection
103- │ └── consultation_view.py # écran de consultation (US2) — appelle regine_core.archive.consultation
106+ │ ├── consultation_view.py # écran de consultation (US2) — appelle regine_core.archive.consultation
107+ │ ├── config_view.py # écran de configuration (specs/003 US4) — appelle regine_core.config
108+ │ └── import_view.py # NOUVEAU (US3) — écran d'import, appelle regine_core.import_carte
109+ │ # directement (jamais regine_cli.import_cmd, cf. research.md § 6) ;
110+ │ # aucune nouvelle fonction regine_core requise (specs/001 déjà complet)
104111 └── (pas de suite de tests automatisée pour l'UI elle-même cette itération, cf. research.md § 2 —
105112 validée manuellement via quickstart.md ; toute la logique qu'elle appelle est testée côté regine-core)
106113 ```
107114
108-**Structure Decision**: Extension du monorepo à 4 paquets déjà en place (`specs/003` research.md § 5) : `regine-gui` passe de paquet réservé (scaffold vide) à première implémentation réelle. Deux nouveaux modules `regine-core` distincts pour ne pas mélanger deux sens différents du mot « racine » (`dossier.selection` pour la racine de sélection à l'intérieur d'un dossier vs `dossier.root` déjà existant pour le répertoire racine d'archive année/catégorie, cf. `docs/lexique.md` § Point de vigilance). La sous-commande CLI (`regine dossier ...`) est un prérequis Foundational de ce plan — construite avant la GUI elle-même, jamais après, conformément au Principe CLI-first et à FR-007.
115+**Structure Decision**: Extension du monorepo à 4 paquets déjà en place (`specs/003` research.md § 5) : `regine-gui` passe de paquet réservé (scaffold vide) à première implémentation réelle. Deux nouveaux modules `regine-core` distincts pour ne pas mélanger deux sens différents du mot « racine » (`dossier.selection` pour la racine de sélection à l'intérieur d'un dossier vs `dossier.root` déjà existant pour le répertoire racine d'archive année/catégorie, cf. `docs/lexique.md` § Point de vigilance). La sous-commande CLI (`regine dossier ...`) est un prérequis Foundational de ce plan — construite avant la GUI elle-même, jamais après, conformément au Principe CLI-first et à FR-007. Pour US3 (import), aucun prérequis Foundational CLI supplémentaire : `regine import` (`specs/001-import-photos`) existe déjà et n'est pas modifié — seul `import_view.py` est nouveau.
109116
110117 ## Complexity Tracking
111118
@@ -6,7 +6,7 @@
6 6
7 ## Summary7 ## Summary
8 8
9-Une façade graphique de bureau (`regine-gui`, PySide6) couvrant deux usages : le tri/culling d'un dossier de travail local déjà checkouté (regroupement visuel RAW+JPEG, promotion/rétrogradation individuelle à la racine de sélection, vue combinant plusieurs dossiers de format et, pour un dossier parent, ses sous-dossiers) et la consultation en lecture seule de l'archive NAS (recherche, restauration ciblée d'un fichier sans checkout complet). La promotion/rétrogradation à la racine de sélection est une capacité entièrement nouvelle de `regine-core` (aucun module existant ne la couvre) : par le Principe CLI-first de la constitution, elle est d'abord construite comme fonctions de bibliothèque + sous-commande CLI, la GUI n'en étant qu'une façade supplémentaire au même titre que la CLI (Principe VI) — jamais l'inverse.9+Une façade graphique de bureau (`regine-gui`, PySide6) couvrant trois usages : le tri/culling d'un dossier de travail local déjà checkouté (regroupement visuel RAW+JPEG, promotion/rétrogradation individuelle à la racine de sélection, vue combinant plusieurs dossiers de format et, pour un dossier parent, ses sous-dossiers) ; la consultation en lecture seule de l'archive NAS (recherche, restauration ciblée d'un fichier sans checkout complet) ; et, depuis l'ajout de la User Story 3 (2026-09-21), l'import d'une carte mémoire (analyse jour par jour, découpage en groupes, destination, titre, confirmation), en appelant directement les fonctions déjà définies et testées par `specs/001-import-photos` (`regine_core.import_carte`), jamais l'orchestration interactive propre à `regine_cli.import_cmd` (cf. research.md § 6). La promotion/rétrogradation à la racine de sélection est une capacité entièrement nouvelle de `regine-core` (aucun module existant ne la couvre) : par le Principe CLI-first de la constitution, elle est d'abord construite comme fonctions de bibliothèque + sous-commande CLI, la GUI n'en étant qu'une façade supplémentaire au même titre que la CLI (Principe VI) — jamais l'inverse. L'onglet d'import (US3), lui, n'introduit aucune nouvelle fonction `regine_core` : la CLI (`regine import`) couvre déjà toutes les règles, la GUI n'en est qu'une seconde façade.
10 10
11 ## Technical Context11 ## Technical Context
12 12
@@ -40,12 +40,15 @@ Une façade graphique de bureau (`regine-gui`, PySide6) couvrant deux usages : l
40 | IV. Métadonnées ouvertes et embarquées | N/A | Cette fonctionnalité ne lit ni n'écrit de métadonnées descriptives/de droits. |40 | IV. Métadonnées ouvertes et embarquées | N/A | Cette fonctionnalité ne lit ni n'écrit de métadonnées descriptives/de droits. |
41 | V. L'utilisateur décide, Régine suggère | PASS | FR-003 : la promotion/rétrogradation reste toujours une action manuelle explicite, jamais automatique — y compris pour une paire RAW+JPEG (fichier par fichier, pas par paire). |41 | V. L'utilisateur décide, Régine suggère | PASS | FR-003 : la promotion/rétrogradation reste toujours une action manuelle explicite, jamais automatique — y compris pour une paire RAW+JPEG (fichier par fichier, pas par paire). |
42 | VI. Bibliothèque centrale, façades minces | PASS | Toute la logique (regroupement, promotion, recherche, restauration) vit dans `regine-core` ; `regine-cli` et `regine-gui` n'en sont que deux façades appelant les mêmes fonctions, sans logique dupliquée. |42 | VI. Bibliothèque centrale, façades minces | PASS | Toute la logique (regroupement, promotion, recherche, restauration) vit dans `regine-core` ; `regine-cli` et `regine-gui` n'en sont que deux façades appelant les mêmes fonctions, sans logique dupliquée. |
43-| CLI-first | PASS (structurant) | FR-007 impose explicitement qu'aucune action de la GUI ne soit possible sans équivalent CLI — la sous-commande CLI fait partie des tâches Foundational de ce plan, avant l'implémentation de la GUI elle-même. |43+| CLI-first | PASS (structurant) | FR-007 impose explicitement qu'aucune action de la GUI ne soit possible sans équivalent CLI — la sous-commande CLI fait partie des tâches Foundational de ce plan, avant l'implémentation de la GUI elle-même. FR-018 (US3) impose la même règle pour l'import : `regine import` existe déjà (`specs/001-import-photos`), livré avant la GUI — rien à construire côté CLI pour US3. |
44+| VI. Bibliothèque centrale, façades minces (US3) | PASS | L'onglet d'import appelle directement `regine_core.import_carte` (déjà utilisée par `regine_cli.import_cmd`), jamais l'orchestration interactive (`input()`) de ce dernier — aucune règle dupliquée, aucune fonction nouvelle (research.md § 6, contracts/regine-core-api.md). |
44 | Exécution sans démon | PASS | PySide6 tourne dans le même processus Python que le reste, importe `regine-core` directement — pas de service ni de serveur local à superviser. |45 | Exécution sans démon | PASS | PySide6 tourne dans le même processus Python que le reste, importe `regine-core` directement — pas de service ni de serveur local à superviser. |
45 | Formats ouverts et documentés | N/A | Aucun nouveau format de stockage introduit par cette fonctionnalité. |46 | Formats ouverts et documentés | N/A | Aucun nouveau format de stockage introduit par cette fonctionnalité. |
46 47
47 Aucune violation identifiée ; la section Complexity Tracking reste vide.48 Aucune violation identifiée ; la section Complexity Tracking reste vide.
48 49
50+**Re-check post ajout US3 (2026-09-21)** : `import_view.py` (nouveau, GUI) et `import_cmd.py` (déjà existant, `specs/001-import-photos`, inchangé) sont deux façades indépendantes de `regine_core.import_carte` — aucun nouveau module `regine_core`, aucune duplication de règle (cf. research.md § 6). Toutes les évaluations PASS ci-dessus restent valables.
51+
49 **Re-check post Phase 1** (après génération de `data-model.md`, `contracts/`, `quickstart.md`) : le découpage en deux modules `regine_core.dossier.selection` (racine de sélection, US1) et `regine_core.archive.consultation` (lecture seule, US2) — distincts de `regine_core.dossier.root` (répertoire racine archive, specs/004, sémantique différente malgré le mot « racine » commun, cf. `docs/lexique.md` § Point de vigilance) — confirme chaque évaluation PASS ci-dessus. Aucune violation nouvelle introduite par la conception détaillée.52 **Re-check post Phase 1** (après génération de `data-model.md`, `contracts/`, `quickstart.md`) : le découpage en deux modules `regine_core.dossier.selection` (racine de sélection, US1) et `regine_core.archive.consultation` (lecture seule, US2) — distincts de `regine_core.dossier.root` (répertoire racine archive, specs/004, sémantique différente malgré le mot « racine » commun, cf. `docs/lexique.md` § Point de vigilance) — confirme chaque évaluation PASS ci-dessus. Aucune violation nouvelle introduite par la conception détaillée.
50 53
51 ## Project Structure54 ## Project Structure
@@ -100,12 +103,16 @@ packages/
100 ├── src/regine_gui/103 ├── src/regine_gui/
101 │ ├── app.py # point d'entrée, lance l'application PySide6104 │ ├── app.py # point d'entrée, lance l'application PySide6
102 │ ├── tri_view.py # écran de tri/culling (US1) — appelle regine_core.dossier.selection105 │ ├── tri_view.py # écran de tri/culling (US1) — appelle regine_core.dossier.selection
103- │ └── consultation_view.py # écran de consultation (US2) — appelle regine_core.archive.consultation106+ │ ├── consultation_view.py # écran de consultation (US2) — appelle regine_core.archive.consultation
107+ │ ├── config_view.py # écran de configuration (specs/003 US4) — appelle regine_core.config
108+ │ └── import_view.py # NOUVEAU (US3) — écran d'import, appelle regine_core.import_carte
109+ │ # directement (jamais regine_cli.import_cmd, cf. research.md § 6) ;
110+ │ # aucune nouvelle fonction regine_core requise (specs/001 déjà complet)
104 └── (pas de suite de tests automatisée pour l'UI elle-même cette itération, cf. research.md § 2 —111 └── (pas de suite de tests automatisée pour l'UI elle-même cette itération, cf. research.md § 2 —
105 validée manuellement via quickstart.md ; toute la logique qu'elle appelle est testée côté regine-core)112 validée manuellement via quickstart.md ; toute la logique qu'elle appelle est testée côté regine-core)
106 ```113 ```
107 114
108-**Structure Decision**: Extension du monorepo à 4 paquets déjà en place (`specs/003` research.md § 5) : `regine-gui` passe de paquet réservé (scaffold vide) à première implémentation réelle. Deux nouveaux modules `regine-core` distincts pour ne pas mélanger deux sens différents du mot « racine » (`dossier.selection` pour la racine de sélection à l'intérieur d'un dossier vs `dossier.root` déjà existant pour le répertoire racine d'archive année/catégorie, cf. `docs/lexique.md` § Point de vigilance). La sous-commande CLI (`regine dossier ...`) est un prérequis Foundational de ce plan — construite avant la GUI elle-même, jamais après, conformément au Principe CLI-first et à FR-007.115+**Structure Decision**: Extension du monorepo à 4 paquets déjà en place (`specs/003` research.md § 5) : `regine-gui` passe de paquet réservé (scaffold vide) à première implémentation réelle. Deux nouveaux modules `regine-core` distincts pour ne pas mélanger deux sens différents du mot « racine » (`dossier.selection` pour la racine de sélection à l'intérieur d'un dossier vs `dossier.root` déjà existant pour le répertoire racine d'archive année/catégorie, cf. `docs/lexique.md` § Point de vigilance). La sous-commande CLI (`regine dossier ...`) est un prérequis Foundational de ce plan — construite avant la GUI elle-même, jamais après, conformément au Principe CLI-first et à FR-007. Pour US3 (import), aucun prérequis Foundational CLI supplémentaire : `regine import` (`specs/001-import-photos`) existe déjà et n'est pas modifié — seul `import_view.py` est nouveau.
109 116
110 ## Complexity Tracking117 ## Complexity Tracking
111 118
modified specs/006-gui-tri-consultation/quickstart.md +19 -2
@@ -1,6 +1,6 @@
11 # Quickstart : validation de l'interface graphique de tri et de consultation
22
3-Ce guide valide les 2 user stories de `spec.md`. Les commandes CLI (Principe CLI-first) sont exécutables et automatisables ; les scénarios GUI nécessitent un poste avec affichage graphique et sont à valider manuellement (cf. `plan.md`/research.md § 2 : pas de suite de tests automatisée pour la GUI elle-même dans cette itération).
3+Ce guide valide les 3 user stories de `spec.md`. Les commandes CLI (Principe CLI-first) sont exécutables et automatisables ; les scénarios GUI nécessitent un poste avec affichage graphique et sont à valider manuellement (cf. `plan.md`/research.md § 2 : pas de suite de tests automatisée pour la GUI elle-même dans cette itération).
44
55 ## Prérequis
66
@@ -49,6 +49,23 @@ regine archive restore \
4949
5050 **Résultat attendu** : démonter le partage SMB configuré puis lancer `regine archive browse` (ou l'ouverture du mode consultation dans la GUI) déclenche l'assistance au montage déjà prévue par `specs/003`, plutôt qu'une erreur brute.
5151
52+## Scénario 7 — Importer une carte via la CLI (User Story 3, référence, `specs/001-import-photos`)
53+
54+```bash
55+regine import ~/Volumes/CARTE_SD --titre "Sortie parc" --annee --yes \
56+ --archive-root ~/regine/archive --local-root ~/regine/local
57+```
58+
59+**Résultat attendu** : un dossier `AAAA-MM-JJ_Sortie_parc` apparaît sous l'archive et sous le répertoire de travail local, fichiers renommés et vérifiés (comportement déjà couvert par `specs/001-import-photos`, servant ici de référence pour le scénario 8).
60+
61+## Scénario 8 — Importer une carte via la GUI (User Story 3, P3) — validation manuelle
62+
63+```bash
64+regine-gui
65+```
66+
67+**Résultat attendu** : dans l'onglet Import, sélectionner la même carte que le scénario 7, suivre les mêmes étapes (répartition jour par jour, destination, titre, confirmation) puis valider ; le dossier archivé obtenu est strictement identique (même nom, mêmes fichiers renommés, cf. SC-007) à celui du scénario 7 — vérifiable en comparant les deux dossiers ou en rejouant le scénario 7 sur une seconde carte identique. Une collision de boîtier déclenchée pendant cet import (cf. `specs/002-profil-boitiers-optionnel`) doit présenter les mêmes options de résolution que la CLI (US3 Acceptance Scenario 5). Depuis le résultat de cet import, ouvrir le dossier obtenu dans l'onglet Tri doit fonctionner sans étape supplémentaire (US3 Acceptance Scenario 7).
68+
5269 ## Critères de sortie
5370
54-Les 6 scénarios doivent produire le résultat attendu. Les scénarios CLI (1, 4, 6) sont vérifiables sans intervention manuelle ; les scénarios GUI (2, 3, 5) nécessitent une validation manuelle sur un poste avec affichage graphique, à consigner explicitement avant de considérer cette fonctionnalité prête.
71+Les 8 scénarios doivent produire le résultat attendu. Les scénarios CLI (1, 4, 6, 7) sont vérifiables sans intervention manuelle ; les scénarios GUI (2, 3, 5, 8) nécessitent une validation manuelle sur un poste avec affichage graphique, à consigner explicitement avant de considérer cette fonctionnalité prête.
@@ -1,6 +1,6 @@
1 # Quickstart : validation de l'interface graphique de tri et de consultation1 # Quickstart : validation de l'interface graphique de tri et de consultation
2 2
3-Ce guide valide les 2 user stories de `spec.md`. Les commandes CLI (Principe CLI-first) sont exécutables et automatisables ; les scénarios GUI nécessitent un poste avec affichage graphique et sont à valider manuellement (cf. `plan.md`/research.md § 2 : pas de suite de tests automatisée pour la GUI elle-même dans cette itération).3+Ce guide valide les 3 user stories de `spec.md`. Les commandes CLI (Principe CLI-first) sont exécutables et automatisables ; les scénarios GUI nécessitent un poste avec affichage graphique et sont à valider manuellement (cf. `plan.md`/research.md § 2 : pas de suite de tests automatisée pour la GUI elle-même dans cette itération).
4 4
5 ## Prérequis5 ## Prérequis
6 6
@@ -49,6 +49,23 @@ regine archive restore \
49 49
50 **Résultat attendu** : démonter le partage SMB configuré puis lancer `regine archive browse` (ou l'ouverture du mode consultation dans la GUI) déclenche l'assistance au montage déjà prévue par `specs/003`, plutôt qu'une erreur brute.50 **Résultat attendu** : démonter le partage SMB configuré puis lancer `regine archive browse` (ou l'ouverture du mode consultation dans la GUI) déclenche l'assistance au montage déjà prévue par `specs/003`, plutôt qu'une erreur brute.
51 51
52+## Scénario 7 — Importer une carte via la CLI (User Story 3, référence, `specs/001-import-photos`)
53+
54+```bash
55+regine import ~/Volumes/CARTE_SD --titre "Sortie parc" --annee --yes \
56+ --archive-root ~/regine/archive --local-root ~/regine/local
57+```
58+
59+**Résultat attendu** : un dossier `AAAA-MM-JJ_Sortie_parc` apparaît sous l'archive et sous le répertoire de travail local, fichiers renommés et vérifiés (comportement déjà couvert par `specs/001-import-photos`, servant ici de référence pour le scénario 8).
60+
61+## Scénario 8 — Importer une carte via la GUI (User Story 3, P3) — validation manuelle
62+
63+```bash
64+regine-gui
65+```
66+
67+**Résultat attendu** : dans l'onglet Import, sélectionner la même carte que le scénario 7, suivre les mêmes étapes (répartition jour par jour, destination, titre, confirmation) puis valider ; le dossier archivé obtenu est strictement identique (même nom, mêmes fichiers renommés, cf. SC-007) à celui du scénario 7 — vérifiable en comparant les deux dossiers ou en rejouant le scénario 7 sur une seconde carte identique. Une collision de boîtier déclenchée pendant cet import (cf. `specs/002-profil-boitiers-optionnel`) doit présenter les mêmes options de résolution que la CLI (US3 Acceptance Scenario 5). Depuis le résultat de cet import, ouvrir le dossier obtenu dans l'onglet Tri doit fonctionner sans étape supplémentaire (US3 Acceptance Scenario 7).
68+
52 ## Critères de sortie69 ## Critères de sortie
53 70
54-Les 6 scénarios doivent produire le résultat attendu. Les scénarios CLI (1, 4, 6) sont vérifiables sans intervention manuelle ; les scénarios GUI (2, 3, 5) nécessitent une validation manuelle sur un poste avec affichage graphique, à consigner explicitement avant de considérer cette fonctionnalité prête.71+Les 8 scénarios doivent produire le résultat attendu. Les scénarios CLI (1, 4, 6, 7) sont vérifiables sans intervention manuelle ; les scénarios GUI (2, 3, 5, 8) nécessitent une validation manuelle sur un poste avec affichage graphique, à consigner explicitement avant de considérer cette fonctionnalité prête.
modified specs/006-gui-tri-consultation/research.md +11 -1
@@ -44,6 +44,16 @@
4444
4545 **Alternatives considered**: Extraire un utilitaire commun `regine_core.integrity.copie_verifiee` — rejeté pour l'instant, complexité d'abstraction non encore justifiée par le nombre de cas (Principe VI, pas de complexité non justifiée).
4646
47+## 6. Onglet d'import (US3) : appel direct de `regine_core.import_carte`, jamais de `regine_cli.import_cmd`
48+
49+**Decision**: L'onglet d'import de la GUI (`import_view.py`) appelle directement les fonctions de bas niveau de `regine_core.import_carte` (`copier_carte`, `resoudre_collisions_boitiers`, `decouper_en_groupes`, `jours_candidats_au_detachement`, `detacher_jours`, `resoudre_destination`/`resoudre_fusion`, `construire_nom_dossier*`, `renommer_fichiers`, `attribuer_identifiants`, `preparer_resume`, `archiver`, `publier_copie_locale`) — jamais `regine_cli.import_cmd._cmd_import` ni les fonctions privées `_traiter_groupe`/`_proposer_detachement`/etc. de ce module.
50+
51+**Rationale**: `regine_cli/import_cmd.py` mélange déjà deux choses distinctes : la logique métier réelle (entièrement dans `regine_core.import_carte`, déjà testée) et l'orchestration interactive propre à la CLI (`input()` pour chaque décision — titre, détachement, destination, confirmation). Cette orchestration par `input()` est spécifique au terminal, pas une règle métier réutilisable : une GUI ne « répond » pas à `input()`, elle répond à des clics/formulaires. Appeler `regine_core.import_carte` directement, comme le fait déjà `import_cmd.py` lui-même, est donc la même conformité au Principe VI que pour les onglets Tri/Consultation/Configuration — pas un écart. Exactement le même choix que celui déjà documenté dans `contracts/regine-core-api.md` (« Notes d'implémentation ») pour `config.smb.resoudre_archive_locale`, ajoutée précisément pour éviter que deux façades dupliquent une même résolution.
52+
53+**Alternatives considered**:
54+- Faire appeler par la GUI la fonction `_cmd_import` de `regine_cli` (ou l'exécuter en sous-processus) — rejeté : cette fonction est bâtie autour de `input()`/`print()` bloquants, incompatible avec une boucle d'événements Qt, et son orchestration n'a aucune règle propre à en réutiliser (déjà entièrement dans `regine_core.import_carte`).
55+- Extraire un nouveau module d'orchestration neutre (« assistant d'import ») partagé entre CLI et GUI — rejeté pour cette itération : aucune règle métier supplémentaire n'apparaît en écrivant `import_view.py` (chaque étape appelle une fonction déjà testée de `import_carte`), donc aucune duplication réelle à éliminer, contrairement au cas SMB déjà corrigé. À reconsidérer si une troisième façade (ou une évolution de l'orchestration CLI) faisait apparaître une vraie règle dupliquée.
56+
4757 ## Résumé
4858
49-Tous les points ouverts du Technical Context du plan sont résolus par les décisions ci-dessus. Une seule nouvelle dépendance externe introduite : PySide6, exclusivement pour `regine-gui`.
59+Tous les points ouverts du Technical Context du plan sont résolus par les décisions ci-dessus. Une seule nouvelle dépendance externe introduite au total pour cette feature : PySide6, exclusivement pour `regine-gui` (déjà en place pour US1/US2, réutilisée telle quelle par US3).
@@ -44,6 +44,16 @@
44 44
45 **Alternatives considered**: Extraire un utilitaire commun `regine_core.integrity.copie_verifiee` — rejeté pour l'instant, complexité d'abstraction non encore justifiée par le nombre de cas (Principe VI, pas de complexité non justifiée).45 **Alternatives considered**: Extraire un utilitaire commun `regine_core.integrity.copie_verifiee` — rejeté pour l'instant, complexité d'abstraction non encore justifiée par le nombre de cas (Principe VI, pas de complexité non justifiée).
46 46
47+## 6. Onglet d'import (US3) : appel direct de `regine_core.import_carte`, jamais de `regine_cli.import_cmd`
48+
49+**Decision**: L'onglet d'import de la GUI (`import_view.py`) appelle directement les fonctions de bas niveau de `regine_core.import_carte` (`copier_carte`, `resoudre_collisions_boitiers`, `decouper_en_groupes`, `jours_candidats_au_detachement`, `detacher_jours`, `resoudre_destination`/`resoudre_fusion`, `construire_nom_dossier*`, `renommer_fichiers`, `attribuer_identifiants`, `preparer_resume`, `archiver`, `publier_copie_locale`) — jamais `regine_cli.import_cmd._cmd_import` ni les fonctions privées `_traiter_groupe`/`_proposer_detachement`/etc. de ce module.
50+
51+**Rationale**: `regine_cli/import_cmd.py` mélange déjà deux choses distinctes : la logique métier réelle (entièrement dans `regine_core.import_carte`, déjà testée) et l'orchestration interactive propre à la CLI (`input()` pour chaque décision — titre, détachement, destination, confirmation). Cette orchestration par `input()` est spécifique au terminal, pas une règle métier réutilisable : une GUI ne « répond » pas à `input()`, elle répond à des clics/formulaires. Appeler `regine_core.import_carte` directement, comme le fait déjà `import_cmd.py` lui-même, est donc la même conformité au Principe VI que pour les onglets Tri/Consultation/Configuration — pas un écart. Exactement le même choix que celui déjà documenté dans `contracts/regine-core-api.md` (« Notes d'implémentation ») pour `config.smb.resoudre_archive_locale`, ajoutée précisément pour éviter que deux façades dupliquent une même résolution.
52+
53+**Alternatives considered**:
54+- Faire appeler par la GUI la fonction `_cmd_import` de `regine_cli` (ou l'exécuter en sous-processus) — rejeté : cette fonction est bâtie autour de `input()`/`print()` bloquants, incompatible avec une boucle d'événements Qt, et son orchestration n'a aucune règle propre à en réutiliser (déjà entièrement dans `regine_core.import_carte`).
55+- Extraire un nouveau module d'orchestration neutre (« assistant d'import ») partagé entre CLI et GUI — rejeté pour cette itération : aucune règle métier supplémentaire n'apparaît en écrivant `import_view.py` (chaque étape appelle une fonction déjà testée de `import_carte`), donc aucune duplication réelle à éliminer, contrairement au cas SMB déjà corrigé. À reconsidérer si une troisième façade (ou une évolution de l'orchestration CLI) faisait apparaître une vraie règle dupliquée.
56+
47 ## Résumé57 ## Résumé
48 58
49-Tous les points ouverts du Technical Context du plan sont résolus par les décisions ci-dessus. Une seule nouvelle dépendance externe introduite : PySide6, exclusivement pour `regine-gui`.59+Tous les points ouverts du Technical Context du plan sont résolus par les décisions ci-dessus. Une seule nouvelle dépendance externe introduite au total pour cette feature : PySide6, exclusivement pour `regine-gui` (déjà en place pour US1/US2, réutilisée telle quelle par US3).
modified specs/006-gui-tri-consultation/spec.md +32 -1
@@ -6,7 +6,7 @@
66
77 **Status**: Draft
88
9-**Input**: User description: "Interface graphique de Régine (GUI), cadrée dans docs/interface-cli-gui-architecture.md et docs/archivage-photo-elements-cles.md section 10, jamais encore spécifiée. Façade fine au-dessus de regine-core (Principe VI), aucune logique métier propre — importe la bibliothèque dans le même processus, pas de daemon. Deux modes d'usage : (1) tri/culling pendant l'édition sur un dossier checké out en local, avec regroupement visuel RAW+JPEG jumeau et navigation combinant plusieurs dossiers de format/sous-dossiers d'un même dossier ; (2) consultation en lecture seule de l'archive NAS sans checkout complet, avec recherche et restauration ciblée. Hors périmètre : planche-contact JPEG annotée, import carte mémoire, configuration du contexte de travail, choix de la technologie d'interface (décision de planification)."
9+**Input**: User description: "Interface graphique de Régine (GUI), cadrée dans docs/interface-cli-gui-architecture.md et docs/archivage-photo-elements-cles.md section 10, jamais encore spécifiée. Façade fine au-dessus de regine-core (Principe VI), aucune logique métier propre — importe la bibliothèque dans le même processus, pas de daemon. Trois modes d'usage : (1) tri/culling pendant l'édition sur un dossier checké out en local, avec regroupement visuel RAW+JPEG jumeau et navigation combinant plusieurs dossiers de format/sous-dossiers d'un même dossier ; (2) consultation en lecture seule de l'archive NAS sans checkout complet, avec recherche et restauration ciblée ; (3) import d'une carte mémoire, reprenant à l'identique les étapes déjà définies par `specs/001-import-photos`. Hors périmètre : planche-contact JPEG annotée, configuration du contexte de travail, choix de la technologie d'interface (décision de planification)."
1010
1111 ## User Scenarios & Testing *(mandatory)*
1212
@@ -48,6 +48,26 @@ Un photographe se souvient avoir pris une photo il y a plusieurs mois mais ne sa
4848
4949 ---
5050
51+### User Story 3 - Importer une carte mémoire depuis l'interface graphique (Priority: P3)
52+
53+Un photographe qui utilise déjà l'interface graphique de Régine pour trier ses photos vient de débrancher une carte mémoire. Il veut lancer l'import directement depuis la GUI — analyse de la carte, découpage éventuel en groupes, choix de destination, titre, confirmation avant écriture — puis enchaîner sur le tri (User Story 1) du dossier fraîchement importé, sans repasser par la ligne de commande.
54+
55+**Why this priority**: Confort d'usage pur — la CLI (`regine import`) couvre déjà 100% de ces capacités et continue de fonctionner à l'identique ; un utilisateur à l'aise avec la ligne de commande peut se passer entièrement de cet onglet. Priorité identique à celle de l'onglet de configuration (`specs/003` User Story 4), pour la même raison.
56+
57+**Independent Test**: Peut être testé en ouvrant l'onglet d'import de la GUI, en y import et une carte mémoire à une seule journée jamais importée, puis en vérifiant que le dossier archivé obtenu (nom, fichiers renommés) est strictement identique à celui que produirait `regine import` sur la même carte.
58+
59+**Acceptance Scenarios**:
60+
61+1. **Given** l'onglet d'import ouvert, **When** l'utilisateur y sélectionne une carte mémoire, **Then** la GUI affiche la répartition jour par jour (nombre de photos, plage horaire) calculée par `regine_core.import`, sans recalculer elle-même cette analyse (Principe VI).
62+2. **Given** une répartition jour par jour affichée avec un jour mis en avant comme candidat au détachement, **When** l'utilisateur choisit de le détacher ou de conserver un seul groupe, **Then** la GUI applique exactement ce choix — jamais un détachement automatique sans validation (cf. FR-006 de `specs/001-import-photos`).
63+3. **Given** un groupe prêt à être traité, **When** l'utilisateur choisit sa destination (nouveau dossier, nouveau sous-dossier, fusion, nouveau dossier parent de voyage) et saisit un titre, **Then** la GUI applique les mêmes règles de nommage, de répertoire racine et de détection de collision que la CLI (`specs/001-import-photos` FR-007/FR-010/FR-012), sans logique de validation propre à la GUI.
64+4. **Given** un groupe prêt à être archivé, **When** l'utilisateur confirme, **Then** la GUI présente d'abord le même résumé que la CLI (nombre de fichiers, taille, dossier de destination) et attend une confirmation explicite avant toute écriture sur l'archive (cf. FR-018 de `specs/001-import-photos`).
65+5. **Given** une collision de nom d'origine entre deux boîtiers nécessitant une désambiguïsation manuelle, **When** cette collision survient pendant un import déclenché depuis la GUI, **Then** la GUI présente les mêmes options de résolution (profil, numéro de série, étiquetage manuel) que la CLI, jamais une désambiguïsation inventée par la GUI (cf. `specs/002-profil-boitiers-optionnel`).
66+6. **Given** un import en cours depuis cet onglet, **When** les fichiers sont copiés et vérifiés, **Then** l'utilisateur voit la progression, puis un résultat (fichiers importés, fichiers ignorés car déjà présents sur une carte précédemment importée, anomalies de date signalées) équivalent à celui que rapporterait la CLI.
67+7. **Given** un import venant d'aboutir depuis cet onglet, **When** l'utilisateur veut revoir le résultat, **Then** il peut ouvrir directement le dossier importé dans l'onglet Tri (User Story 1) sans quitter la GUI.
68+
69+---
70+
5171 ### Edge Cases
5272
5373 - Que se passe-t-il si l'utilisateur tente d'ouvrir en tri/culling (User Story 1) un dossier qui n'est pas réellement une copie de travail Régine (pas de structure par format reconnue) ? Le système DOIT le signaler clairement plutôt que d'afficher une liste vide ou une erreur technique.
@@ -55,6 +75,8 @@ Un photographe se souvient avoir pris une photo il y a plusieurs mois mais ne sa
5575 - Que se passe-t-il si l'utilisateur demande la restauration d'un fichier (User Story 2) vers un emplacement local qui contient déjà un fichier de même nom mais de contenu différent ? Le système DOIT refuser d'écraser silencieusement (cohérent avec le principe déjà appliqué à l'import et à l'archivage, `specs/001`).
5676 - Que se passe-t-il si l'utilisateur ferme la GUI avec des promotions/rétrogradations effectuées mais le dossier non encore réconcilié ? Rien ne doit être perdu : ces changements restent sur le système de fichiers local, à réconcilier plus tard (`specs/005`) comme n'importe quel autre changement local.
5777 - Que se passe-t-il si l'archive contient un très grand nombre de dossiers en mode consultation ? La recherche DOIT rester utilisable sans imposer de charger l'intégralité de l'archive en mémoire d'un coup.
78+- Que se passe-t-il si l'utilisateur ferme l'onglet d'import (ou la GUI) pendant qu'une écriture sur l'archive est en cours ? Le système DOIT empêcher la fermeture pendant l'écriture, ou garantir que celle-ci se termine ou échoue proprement avant de fermer, jamais laisser un dossier archivé partiellement écrit.
79+- Que se passe-t-il si l'emplacement sélectionné comme carte mémoire dans l'onglet d'import ne correspond pas à un support amovible réel (ex. sélection accidentelle d'un dossier de l'archive) ? La GUI DOIT signaler la même anomalie que la CLI le ferait, plutôt que de lancer un import sur une source invalide.
5880
5981 ## Requirements *(mandatory)*
6082
@@ -73,6 +95,11 @@ Un photographe se souvient avoir pris une photo il y a plusieurs mois mais ne sa
7395 - **FR-011**: Si l'archive est momentanément inaccessible au moment d'une consultation, le système DOIT réutiliser l'assistance au montage déjà prévue (`specs/003`) plutôt que d'échouer sans explication.
7496 - **FR-012**: Le système DOIT refuser d'écraser silencieusement un fichier local déjà présent lors d'une restauration ciblée (User Story 2) si son contenu diffère de celui restauré.
7597 - **FR-013**: Le système DOIT signaler clairement à l'utilisateur qu'un dossier ouvert en tri/culling n'a pas la structure attendue d'un dossier Régine, plutôt que d'afficher un résultat vide ou trompeur.
98+- **FR-014**: Le système DOIT exposer, dans un onglet dédié de la GUI, l'ensemble des étapes de l'import d'une carte mémoire (analyse jour par jour, découpage en groupes, choix de destination, titre, confirmation, résultat) en appelant directement les mêmes fonctions de `regine_core.import` que la CLI (`regine import`), sans dupliquer la moindre règle d'analyse, de nommage, de collision ou de désambiguïsation (Principe VI) — toute règle déjà vraie côté CLI (`specs/001-import-photos`) reste vraie à l'identique depuis la GUI.
99+- **FR-015**: Le système DOIT présenter, avant toute écriture sur l'archive déclenchée depuis l'onglet d'import, le même résumé de confirmation (nombre de fichiers, taille, dossier de destination) que celui déjà requis côté CLI (FR-018 de `specs/001-import-photos`), et attendre une confirmation explicite.
100+- **FR-016**: Lorsqu'une collision de nom d'origine entre boîtiers nécessite une désambiguïsation manuelle (`specs/002-profil-boitiers-optionnel`), le système DOIT présenter depuis l'onglet d'import les mêmes options de résolution que la CLI, jamais une logique de désambiguïsation propre à la GUI.
101+- **FR-017**: Le système DOIT afficher la progression et le résultat de l'import (fichiers copiés et vérifiés, fichiers ignorés car déjà importés, anomalies de date signalées) au fur et à mesure de son exécution depuis l'onglet d'import, sans obliger l'utilisateur à attendre la fin sans retour visuel.
102+- **FR-018**: Chaque action de l'onglet d'import DOIT rester accomplissable de façon identique par la commande CLI équivalente (`regine import`) (Principe CLI-first de la constitution) — la GUI ne peut pas être la seule façon de réaliser un import.
76103
77104 ### Key Entities
78105
@@ -80,6 +107,7 @@ Un photographe se souvient avoir pris une photo il y a plusieurs mois mais ne sa
80107 - **Racine de sélection** : niveau d'un dossier (ou d'un sous-dossier, ou de la racine d'un dossier parent) où vivent les fichiers promus, déjà défini par `docs/archivage-photo-elements-cles.md` section 10 — cette spécification n'en redéfinit pas la sémantique, seulement son exposition interactive.
81108 - **Vue de consultation** : navigation en lecture seule de l'archive, indépendante de toute copie de travail locale, sans manifeste ni verrou associés.
82109 - **Restauration ciblée** : copie vérifiée d'un ou plusieurs fichiers précis de l'archive vers un emplacement local, distincte d'un checkout complet de dossier (pas de manifeste, pas de verrou, pas de réconciliation attendue en retour).
110+- **Onglet d'import** : façade GUI de l'import d'une carte mémoire, reprenant à l'identique les étapes déjà définies par `specs/001-import-photos` (analyse, découpage, destination, titre, confirmation, désambiguïsation de collision), sans logique métier propre ni règle de validation additionnelle.
83111
84112 ## Success Criteria *(mandatory)*
85113
@@ -91,6 +119,8 @@ Un photographe se souvient avoir pris une photo il y a plusieurs mois mais ne sa
91119 - **SC-004**: 100% des restaurations ciblées depuis le mode consultation sont vérifiées par somme de contrôle avant d'être considérées réussies.
92120 - **SC-005**: 0% des suppressions déclenchées depuis l'écran de tri n'aboutissent à une perte de fichier sans confirmation explicite préalable à la réconciliation.
93121 - **SC-006**: Chaque action de promotion/rétrogradation disponible dans la GUI reste accomplissable via une commande CLI équivalente, sans exception.
122+- **SC-007**: Un import réalisé depuis l'onglet GUI produit un dossier archivé strictement identique (même nom, mêmes fichiers renommés, même répertoire racine) à celui qu'aurait produit le même import via la CLI.
123+- **SC-008**: 0% des écritures sur l'archive déclenchées depuis l'onglet d'import ne surviennent sans confirmation explicite préalable de l'utilisateur.
94124
95125 ## Assumptions
96126
@@ -100,3 +130,4 @@ Un photographe se souvient avoir pris une photo il y a plusieurs mois mais ne sa
100130 - Le mode consultation ne respecte ni ne vérifie le verrou applicatif d'un dossier (`specs/005`) : la lecture seule ne modifiant rien, elle n'entre jamais en conflit avec un checkout en cours ailleurs.
101131 - La promotion/rétrogradation à la racine de sélection est une capacité entièrement nouvelle de `regine-core` (aucun module existant ne la couvre) ; cette spécification suppose qu'elle sera conçue comme un module à part entière (probablement `regine_core.dossier`, qui porte déjà la notion de racine de sélection) plutôt que dupliquée entre la façade CLI et la façade GUI (Principe VI).
102132 - Le choix de la technologie d'interface graphique (PySide6 natif vs GUI web packagée, cf. `docs/interface-cli-gui-architecture.md`, non tranché) est une décision de planification, pas de cette spécification comportementale.
133+- L'onglet d'import (User Story 3) réutilise l'infrastructure à onglets déjà en place dans `regine-gui` (`app.py`, `QTabWidget`) plutôt que d'introduire une nouvelle application ; il n'ajoute aucune capacité que la CLI (`regine import`, `specs/001-import-photos`) n'a pas déjà — à l'image de l'onglet Configuration (`specs/003` User Story 4).
@@ -6,7 +6,7 @@
6 6
7 **Status**: Draft7 **Status**: Draft
8 8
9-**Input**: User description: "Interface graphique de Régine (GUI), cadrée dans docs/interface-cli-gui-architecture.md et docs/archivage-photo-elements-cles.md section 10, jamais encore spécifiée. Façade fine au-dessus de regine-core (Principe VI), aucune logique métier propre — importe la bibliothèque dans le même processus, pas de daemon. Deux modes d'usage : (1) tri/culling pendant l'édition sur un dossier checké out en local, avec regroupement visuel RAW+JPEG jumeau et navigation combinant plusieurs dossiers de format/sous-dossiers d'un même dossier ; (2) consultation en lecture seule de l'archive NAS sans checkout complet, avec recherche et restauration ciblée. Hors périmètre : planche-contact JPEG annotée, import carte mémoire, configuration du contexte de travail, choix de la technologie d'interface (décision de planification)."9+**Input**: User description: "Interface graphique de Régine (GUI), cadrée dans docs/interface-cli-gui-architecture.md et docs/archivage-photo-elements-cles.md section 10, jamais encore spécifiée. Façade fine au-dessus de regine-core (Principe VI), aucune logique métier propre — importe la bibliothèque dans le même processus, pas de daemon. Trois modes d'usage : (1) tri/culling pendant l'édition sur un dossier checké out en local, avec regroupement visuel RAW+JPEG jumeau et navigation combinant plusieurs dossiers de format/sous-dossiers d'un même dossier ; (2) consultation en lecture seule de l'archive NAS sans checkout complet, avec recherche et restauration ciblée ; (3) import d'une carte mémoire, reprenant à l'identique les étapes déjà définies par `specs/001-import-photos`. Hors périmètre : planche-contact JPEG annotée, configuration du contexte de travail, choix de la technologie d'interface (décision de planification)."
10 10
11 ## User Scenarios & Testing *(mandatory)*11 ## User Scenarios & Testing *(mandatory)*
12 12
@@ -48,6 +48,26 @@ Un photographe se souvient avoir pris une photo il y a plusieurs mois mais ne sa
48 48
49 ---49 ---
50 50
51+### User Story 3 - Importer une carte mémoire depuis l'interface graphique (Priority: P3)
52+
53+Un photographe qui utilise déjà l'interface graphique de Régine pour trier ses photos vient de débrancher une carte mémoire. Il veut lancer l'import directement depuis la GUI — analyse de la carte, découpage éventuel en groupes, choix de destination, titre, confirmation avant écriture — puis enchaîner sur le tri (User Story 1) du dossier fraîchement importé, sans repasser par la ligne de commande.
54+
55+**Why this priority**: Confort d'usage pur — la CLI (`regine import`) couvre déjà 100% de ces capacités et continue de fonctionner à l'identique ; un utilisateur à l'aise avec la ligne de commande peut se passer entièrement de cet onglet. Priorité identique à celle de l'onglet de configuration (`specs/003` User Story 4), pour la même raison.
56+
57+**Independent Test**: Peut être testé en ouvrant l'onglet d'import de la GUI, en y import et une carte mémoire à une seule journée jamais importée, puis en vérifiant que le dossier archivé obtenu (nom, fichiers renommés) est strictement identique à celui que produirait `regine import` sur la même carte.
58+
59+**Acceptance Scenarios**:
60+
61+1. **Given** l'onglet d'import ouvert, **When** l'utilisateur y sélectionne une carte mémoire, **Then** la GUI affiche la répartition jour par jour (nombre de photos, plage horaire) calculée par `regine_core.import`, sans recalculer elle-même cette analyse (Principe VI).
62+2. **Given** une répartition jour par jour affichée avec un jour mis en avant comme candidat au détachement, **When** l'utilisateur choisit de le détacher ou de conserver un seul groupe, **Then** la GUI applique exactement ce choix — jamais un détachement automatique sans validation (cf. FR-006 de `specs/001-import-photos`).
63+3. **Given** un groupe prêt à être traité, **When** l'utilisateur choisit sa destination (nouveau dossier, nouveau sous-dossier, fusion, nouveau dossier parent de voyage) et saisit un titre, **Then** la GUI applique les mêmes règles de nommage, de répertoire racine et de détection de collision que la CLI (`specs/001-import-photos` FR-007/FR-010/FR-012), sans logique de validation propre à la GUI.
64+4. **Given** un groupe prêt à être archivé, **When** l'utilisateur confirme, **Then** la GUI présente d'abord le même résumé que la CLI (nombre de fichiers, taille, dossier de destination) et attend une confirmation explicite avant toute écriture sur l'archive (cf. FR-018 de `specs/001-import-photos`).
65+5. **Given** une collision de nom d'origine entre deux boîtiers nécessitant une désambiguïsation manuelle, **When** cette collision survient pendant un import déclenché depuis la GUI, **Then** la GUI présente les mêmes options de résolution (profil, numéro de série, étiquetage manuel) que la CLI, jamais une désambiguïsation inventée par la GUI (cf. `specs/002-profil-boitiers-optionnel`).
66+6. **Given** un import en cours depuis cet onglet, **When** les fichiers sont copiés et vérifiés, **Then** l'utilisateur voit la progression, puis un résultat (fichiers importés, fichiers ignorés car déjà présents sur une carte précédemment importée, anomalies de date signalées) équivalent à celui que rapporterait la CLI.
67+7. **Given** un import venant d'aboutir depuis cet onglet, **When** l'utilisateur veut revoir le résultat, **Then** il peut ouvrir directement le dossier importé dans l'onglet Tri (User Story 1) sans quitter la GUI.
68+
69+---
70+
51 ### Edge Cases71 ### Edge Cases
52 72
53 - Que se passe-t-il si l'utilisateur tente d'ouvrir en tri/culling (User Story 1) un dossier qui n'est pas réellement une copie de travail Régine (pas de structure par format reconnue) ? Le système DOIT le signaler clairement plutôt que d'afficher une liste vide ou une erreur technique.73 - Que se passe-t-il si l'utilisateur tente d'ouvrir en tri/culling (User Story 1) un dossier qui n'est pas réellement une copie de travail Régine (pas de structure par format reconnue) ? Le système DOIT le signaler clairement plutôt que d'afficher une liste vide ou une erreur technique.
@@ -55,6 +75,8 @@ Un photographe se souvient avoir pris une photo il y a plusieurs mois mais ne sa
55 - Que se passe-t-il si l'utilisateur demande la restauration d'un fichier (User Story 2) vers un emplacement local qui contient déjà un fichier de même nom mais de contenu différent ? Le système DOIT refuser d'écraser silencieusement (cohérent avec le principe déjà appliqué à l'import et à l'archivage, `specs/001`).75 - Que se passe-t-il si l'utilisateur demande la restauration d'un fichier (User Story 2) vers un emplacement local qui contient déjà un fichier de même nom mais de contenu différent ? Le système DOIT refuser d'écraser silencieusement (cohérent avec le principe déjà appliqué à l'import et à l'archivage, `specs/001`).
56 - Que se passe-t-il si l'utilisateur ferme la GUI avec des promotions/rétrogradations effectuées mais le dossier non encore réconcilié ? Rien ne doit être perdu : ces changements restent sur le système de fichiers local, à réconcilier plus tard (`specs/005`) comme n'importe quel autre changement local.76 - Que se passe-t-il si l'utilisateur ferme la GUI avec des promotions/rétrogradations effectuées mais le dossier non encore réconcilié ? Rien ne doit être perdu : ces changements restent sur le système de fichiers local, à réconcilier plus tard (`specs/005`) comme n'importe quel autre changement local.
57 - Que se passe-t-il si l'archive contient un très grand nombre de dossiers en mode consultation ? La recherche DOIT rester utilisable sans imposer de charger l'intégralité de l'archive en mémoire d'un coup.77 - Que se passe-t-il si l'archive contient un très grand nombre de dossiers en mode consultation ? La recherche DOIT rester utilisable sans imposer de charger l'intégralité de l'archive en mémoire d'un coup.
78+- Que se passe-t-il si l'utilisateur ferme l'onglet d'import (ou la GUI) pendant qu'une écriture sur l'archive est en cours ? Le système DOIT empêcher la fermeture pendant l'écriture, ou garantir que celle-ci se termine ou échoue proprement avant de fermer, jamais laisser un dossier archivé partiellement écrit.
79+- Que se passe-t-il si l'emplacement sélectionné comme carte mémoire dans l'onglet d'import ne correspond pas à un support amovible réel (ex. sélection accidentelle d'un dossier de l'archive) ? La GUI DOIT signaler la même anomalie que la CLI le ferait, plutôt que de lancer un import sur une source invalide.
58 80
59 ## Requirements *(mandatory)*81 ## Requirements *(mandatory)*
60 82
@@ -73,6 +95,11 @@ Un photographe se souvient avoir pris une photo il y a plusieurs mois mais ne sa
73 - **FR-011**: Si l'archive est momentanément inaccessible au moment d'une consultation, le système DOIT réutiliser l'assistance au montage déjà prévue (`specs/003`) plutôt que d'échouer sans explication.95 - **FR-011**: Si l'archive est momentanément inaccessible au moment d'une consultation, le système DOIT réutiliser l'assistance au montage déjà prévue (`specs/003`) plutôt que d'échouer sans explication.
74 - **FR-012**: Le système DOIT refuser d'écraser silencieusement un fichier local déjà présent lors d'une restauration ciblée (User Story 2) si son contenu diffère de celui restauré.96 - **FR-012**: Le système DOIT refuser d'écraser silencieusement un fichier local déjà présent lors d'une restauration ciblée (User Story 2) si son contenu diffère de celui restauré.
75 - **FR-013**: Le système DOIT signaler clairement à l'utilisateur qu'un dossier ouvert en tri/culling n'a pas la structure attendue d'un dossier Régine, plutôt que d'afficher un résultat vide ou trompeur.97 - **FR-013**: Le système DOIT signaler clairement à l'utilisateur qu'un dossier ouvert en tri/culling n'a pas la structure attendue d'un dossier Régine, plutôt que d'afficher un résultat vide ou trompeur.
98+- **FR-014**: Le système DOIT exposer, dans un onglet dédié de la GUI, l'ensemble des étapes de l'import d'une carte mémoire (analyse jour par jour, découpage en groupes, choix de destination, titre, confirmation, résultat) en appelant directement les mêmes fonctions de `regine_core.import` que la CLI (`regine import`), sans dupliquer la moindre règle d'analyse, de nommage, de collision ou de désambiguïsation (Principe VI) — toute règle déjà vraie côté CLI (`specs/001-import-photos`) reste vraie à l'identique depuis la GUI.
99+- **FR-015**: Le système DOIT présenter, avant toute écriture sur l'archive déclenchée depuis l'onglet d'import, le même résumé de confirmation (nombre de fichiers, taille, dossier de destination) que celui déjà requis côté CLI (FR-018 de `specs/001-import-photos`), et attendre une confirmation explicite.
100+- **FR-016**: Lorsqu'une collision de nom d'origine entre boîtiers nécessite une désambiguïsation manuelle (`specs/002-profil-boitiers-optionnel`), le système DOIT présenter depuis l'onglet d'import les mêmes options de résolution que la CLI, jamais une logique de désambiguïsation propre à la GUI.
101+- **FR-017**: Le système DOIT afficher la progression et le résultat de l'import (fichiers copiés et vérifiés, fichiers ignorés car déjà importés, anomalies de date signalées) au fur et à mesure de son exécution depuis l'onglet d'import, sans obliger l'utilisateur à attendre la fin sans retour visuel.
102+- **FR-018**: Chaque action de l'onglet d'import DOIT rester accomplissable de façon identique par la commande CLI équivalente (`regine import`) (Principe CLI-first de la constitution) — la GUI ne peut pas être la seule façon de réaliser un import.
76 103
77 ### Key Entities104 ### Key Entities
78 105
@@ -80,6 +107,7 @@ Un photographe se souvient avoir pris une photo il y a plusieurs mois mais ne sa
80 - **Racine de sélection** : niveau d'un dossier (ou d'un sous-dossier, ou de la racine d'un dossier parent) où vivent les fichiers promus, déjà défini par `docs/archivage-photo-elements-cles.md` section 10 — cette spécification n'en redéfinit pas la sémantique, seulement son exposition interactive.107 - **Racine de sélection** : niveau d'un dossier (ou d'un sous-dossier, ou de la racine d'un dossier parent) où vivent les fichiers promus, déjà défini par `docs/archivage-photo-elements-cles.md` section 10 — cette spécification n'en redéfinit pas la sémantique, seulement son exposition interactive.
81 - **Vue de consultation** : navigation en lecture seule de l'archive, indépendante de toute copie de travail locale, sans manifeste ni verrou associés.108 - **Vue de consultation** : navigation en lecture seule de l'archive, indépendante de toute copie de travail locale, sans manifeste ni verrou associés.
82 - **Restauration ciblée** : copie vérifiée d'un ou plusieurs fichiers précis de l'archive vers un emplacement local, distincte d'un checkout complet de dossier (pas de manifeste, pas de verrou, pas de réconciliation attendue en retour).109 - **Restauration ciblée** : copie vérifiée d'un ou plusieurs fichiers précis de l'archive vers un emplacement local, distincte d'un checkout complet de dossier (pas de manifeste, pas de verrou, pas de réconciliation attendue en retour).
110+- **Onglet d'import** : façade GUI de l'import d'une carte mémoire, reprenant à l'identique les étapes déjà définies par `specs/001-import-photos` (analyse, découpage, destination, titre, confirmation, désambiguïsation de collision), sans logique métier propre ni règle de validation additionnelle.
83 111
84 ## Success Criteria *(mandatory)*112 ## Success Criteria *(mandatory)*
85 113
@@ -91,6 +119,8 @@ Un photographe se souvient avoir pris une photo il y a plusieurs mois mais ne sa
91 - **SC-004**: 100% des restaurations ciblées depuis le mode consultation sont vérifiées par somme de contrôle avant d'être considérées réussies.119 - **SC-004**: 100% des restaurations ciblées depuis le mode consultation sont vérifiées par somme de contrôle avant d'être considérées réussies.
92 - **SC-005**: 0% des suppressions déclenchées depuis l'écran de tri n'aboutissent à une perte de fichier sans confirmation explicite préalable à la réconciliation.120 - **SC-005**: 0% des suppressions déclenchées depuis l'écran de tri n'aboutissent à une perte de fichier sans confirmation explicite préalable à la réconciliation.
93 - **SC-006**: Chaque action de promotion/rétrogradation disponible dans la GUI reste accomplissable via une commande CLI équivalente, sans exception.121 - **SC-006**: Chaque action de promotion/rétrogradation disponible dans la GUI reste accomplissable via une commande CLI équivalente, sans exception.
122+- **SC-007**: Un import réalisé depuis l'onglet GUI produit un dossier archivé strictement identique (même nom, mêmes fichiers renommés, même répertoire racine) à celui qu'aurait produit le même import via la CLI.
123+- **SC-008**: 0% des écritures sur l'archive déclenchées depuis l'onglet d'import ne surviennent sans confirmation explicite préalable de l'utilisateur.
94 124
95 ## Assumptions125 ## Assumptions
96 126
@@ -100,3 +130,4 @@ Un photographe se souvient avoir pris une photo il y a plusieurs mois mais ne sa
100 - Le mode consultation ne respecte ni ne vérifie le verrou applicatif d'un dossier (`specs/005`) : la lecture seule ne modifiant rien, elle n'entre jamais en conflit avec un checkout en cours ailleurs.130 - Le mode consultation ne respecte ni ne vérifie le verrou applicatif d'un dossier (`specs/005`) : la lecture seule ne modifiant rien, elle n'entre jamais en conflit avec un checkout en cours ailleurs.
101 - La promotion/rétrogradation à la racine de sélection est une capacité entièrement nouvelle de `regine-core` (aucun module existant ne la couvre) ; cette spécification suppose qu'elle sera conçue comme un module à part entière (probablement `regine_core.dossier`, qui porte déjà la notion de racine de sélection) plutôt que dupliquée entre la façade CLI et la façade GUI (Principe VI).131 - La promotion/rétrogradation à la racine de sélection est une capacité entièrement nouvelle de `regine-core` (aucun module existant ne la couvre) ; cette spécification suppose qu'elle sera conçue comme un module à part entière (probablement `regine_core.dossier`, qui porte déjà la notion de racine de sélection) plutôt que dupliquée entre la façade CLI et la façade GUI (Principe VI).
102 - Le choix de la technologie d'interface graphique (PySide6 natif vs GUI web packagée, cf. `docs/interface-cli-gui-architecture.md`, non tranché) est une décision de planification, pas de cette spécification comportementale.132 - Le choix de la technologie d'interface graphique (PySide6 natif vs GUI web packagée, cf. `docs/interface-cli-gui-architecture.md`, non tranché) est une décision de planification, pas de cette spécification comportementale.
133+- L'onglet d'import (User Story 3) réutilise l'infrastructure à onglets déjà en place dans `regine-gui` (`app.py`, `QTabWidget`) plutôt que d'introduire une nouvelle application ; il n'ajoute aucune capacité que la CLI (`regine import`, `specs/001-import-photos`) n'a pas déjà — à l'image de l'onglet Configuration (`specs/003` User Story 4).
modified specs/006-gui-tri-consultation/tasks.md +28 -4
@@ -99,11 +99,34 @@ description: "Task list for feature implementation"
9999
100100 ---
101101
102-## Phase 5: Polish & Cross-Cutting Concerns
102+## Phase 5: User Story 3 - Importer une carte mémoire depuis l'interface graphique (Priority: P3)
103+
104+**Objectif** : onglet d'import GUI appelant directement `regine_core.import_carte` (research.md § 6) — jamais `regine_cli.import_cmd` — pour reprendre à l'identique les étapes déjà couvertes et testées par `specs/001-import-photos` (analyse, découpage, destination, titre, confirmation, désambiguïsation de collision). Aucune nouvelle fonction `regine_core` : `regine import` (CLI) existe déjà et n'est pas modifié.
105+
106+**Test indépendant** : ouvrir l'onglet d'import, importer une carte à une seule journée jamais importée, vérifier que le dossier archivé obtenu est identique à celui que produirait `regine import` sur la même carte (Scénario 8 de `quickstart.md`).
107+
108+### Implementation for User Story 3
109+
110+- [X] T030 [US3] Créer `packages/regine-gui/src/regine_gui/import_view.py` (squelette, docstring de module uniquement)
111+- [X] T031 [US3] Implémenter l'étape d'analyse dans `import_view.py` : sélection de la carte (`QFileDialog`), appel à `copie.copier_carte` puis `groupage.decouper_en_groupes`/`jours_candidats_au_detachement`, affichage de la répartition jour par jour (US3 Acceptance Scenario 1)
112+- [X] T032 [US3] Implémenter le détachement de jour dans `import_view.py` : appel à `groupage.detacher_jours` sur confirmation explicite de l'utilisateur, jamais automatique (US3 Acceptance Scenario 2, FR-006 de `specs/001-import-photos`)
113+- [X] T033 [US3] Implémenter le choix de destination et le titre dans `import_view.py` : appel à `destination.resoudre_destination`/`resoudre_fusion` et `nommage.construire_nom_dossier*` selon le type choisi, sans logique de nommage ou de collision propre à la GUI (US3 Acceptance Scenario 3) — **écart trouvé à l'implémentation** : le cas « nouveau sous-dossier » nécessitait la même reconstruction de `RootLocation` à partir d'un chemin relatif que `regine_cli.import_cmd._root_location_depuis_chemin` (privée) ; plutôt que de la dupliquer une seconde fois (même schéma que `smb.resoudre_archive_locale`, déjà rencontré pour US1/US2), elle a été rendue publique dans `regine_core.import_carte.destination.root_location_depuis_chemin` et `import_cmd.py` mis à jour pour l'appeler elle aussi (aucun changement de comportement CLI, 172/172 tests toujours au vert) — cf. `contracts/regine-core-api.md`
114+- [X] T034 [US3] Implémenter le résumé de confirmation avant écriture dans `import_view.py` : appel à `push.preparer_resume`, attente d'une confirmation explicite (`QMessageBox`) avant tout appel à `push.archiver` (US3 Acceptance Scenario 4, FR-015)
115+- [X] T035 [US3] Implémenter la résolution de collision de boîtier dans `import_view.py` : appel à `copie.resoudre_collisions_boitiers`, présentation des mêmes options que la CLI (profil connu, numéro de série, étiquetage manuel) en cas de collision non résolue automatiquement (US3 Acceptance Scenario 5, FR-016, `specs/002-profil-boitiers-optionnel`)
116+- [X] T036 [US3] Implémenter la progression et le résultat dans `import_view.py` : retour visuel (journal texte) pendant `push.archiver`/`push.publier_copie_locale`, résumé final (US3 Acceptance Scenario 6, FR-017)
117+- [X] T037 [US3] Câbler l'onglet « Import » dans `packages/regine-gui/src/regine_gui/app.py` (`FenetrePrincipale`), aux côtés des onglets Tri/Consultation/Configuration déjà existants ; le dossier importé peut être ouvert directement dans l'onglet Tri via un signal Qt `dossier_importe` (US3 Acceptance Scenario 7) — `tri_view.EcranTri._charger_dossier` renommée en méthode publique `ouvrir_dossier` pour permettre cette connexion inter-onglets sans dupliquer le regroupement RAW+JPEG
118+- [X] T038 [US3] Validé manuellement en mode headless (`QT_QPA_PLATFORM=offscreen`) : import complet du chemin « nouveau dossier » (analyse, titre, catégorie vide, confirmation, archivage) déclenché par script simulant les réponses utilisateur (`QInputDialog`/`QMessageBox` monkeypatchés, `resoudre_archive_locale` monkeypatché pour éviter une dépendance à un vrai partage SMB monté, même méthode que `specs/003` T033/`specs/006` T017/T026) — dossier obtenu (`AAAA-MM-JJ_Titre`, fichiers renommés `date_titre_nomOrigine.ext`) structurellement identique à ce que produit `regine import` (Scénario 8 de `quickstart.md`, SC-007). Chemins « nouveau sous-dossier »/« fusion »/collision de boîtier non exercés par ce script (aucune carte de test avec EXIF/voyage multi-étapes disponible dans cet environnement) : validés par lecture de code, chaque branche appelant exclusivement des fonctions `regine_core.import_carte`/`camera_profile` déjà testées unitairement par `specs/001`/`specs/002`
119+
120+**Checkpoint** : User Story 3 fonctionnelle et testable indépendamment ; CLI (déjà livrée par `specs/001-import-photos`) et GUI cohérentes entre elles.
121+
122+---
123+
124+## Phase 6: Polish & Cross-Cutting Concerns
103125
104126 - [X] T027 [P] Exécuter les 6 scénarios de `specs/006-gui-tri-consultation/quickstart.md` — Scénario 1 (trier via CLI) : exécuté via `uv run python -m regine_cli.dossier_cmd`, réussi. Scénario 4 (consulter/restaurer) : validé via les fonctions core directement (le monkeypatch SMB inter-process n'est pas possible en subprocess, déjà couvert par `test_cli_archive_consultation.py`). Scénario 6 (archive non configurée) : réussi. Scénarios 2, 3, 5 (GUI) : validés manuellement en mode headless (`QT_QPA_PLATFORM=offscreen`), ouverture/regroupement/promotion et recherche/restauration réelles, sans interaction visuelle humaine (aucun poste avec écran physique disponible dans cet environnement de développement — limitation déjà documentée pour `specs/003` scénario 4)
105127 - [X] T028 [P] `ruff check --fix` + `ruff format` sur `packages/regine-core`, `packages/regine-cli` et `packages/regine-gui` — 172/172 tests passent après coup
106128 - [X] T029 Mettre à jour `contracts/regine-core-api.md`/`cli-dossier-selection.md`/`cli-archive-consultation.md` si l'implémentation révèle un écart avec les signatures documentées (patron déjà établi dans les specs précédentes) — tous mis à jour au fil de l'implémentation (signature `promouvoir`, renommage `browse`/`restore`, `resoudre_archive_locale`)
129+- [X] T039 [P] Exécuté le Scénario 8 de `quickstart.md` (cf. T038 ; Scénario 7 déjà couvert par `specs/001-import-photos`, sans duplication) et `ruff check --fix`/`ruff format` sur `packages/regine-gui`, `packages/regine-cli`, `packages/regine-core` (nouveau fichier `import_view.py`, nouvelle fonction publique `root_location_depuis_chemin`) — 172/172 tests `regine-core`/`regine-cli` toujours au vert, aucun nouveau test requis côté core (aucune nouvelle règle métier ajoutée par US3, seulement une fonction déjà testée indirectement via `import_cmd` rendue publique)
107130
108131 ---
109132
@@ -115,12 +138,13 @@ description: "Task list for feature implementation"
115138 - **Foundational (Phase 2)** : dépend de Setup ; bloque User Story 1 uniquement (`dossier_de_format` n'est utilisée que par `selection.py`).
116139 - **User Story 1 (Phase 3)** : dépend de Foundational.
117140 - **User Story 2 (Phase 4)** : dépend de Setup uniquement — indépendante de Foundational et de User Story 1, peut être menée en parallèle de la Phase 3.
118-- **Polish (Phase 5)** : dépend des user stories livrées (au minimum US1).
141+- **User Story 3 (Phase 5)** : dépend de Setup (PySide6, `app.py`) et de `specs/001-import-photos` (déjà livrée, fournit `regine_core.import_carte` sans modification) ; indépendante de Foundational, User Story 1 et User Story 2 — aucune dépendance CLI nouvelle puisque `regine import` existe déjà.
142+- **Polish (Phase 6)** : dépend des user stories livrées (au minimum US1 ; T039 dépend spécifiquement de US3).
119143
120144 ### Au sein de chaque user story
121145
122-CLI avant GUI, toujours (Principe CLI-first — cf. contrainte structurante en tête de fichier) : T016 avant T017/T018 ; T025 avant T026.
146+CLI avant GUI, toujours (Principe CLI-first — cf. contrainte structurante en tête de fichier) : T016 avant T017/T018 ; T025 avant T026 ; pour US3, la CLI (`regine import`) est déjà livrée par `specs/001-import-photos`, donc aucune tâche CLI n'est requise avant T030-T037.
123147
124148 ## Implementation Strategy
125149
126-MVP = Phase 1 + 2 + 3 (US1, CLI puis GUI). US2 est un incrément indépendant, livrable séparément dans n'importe quel ordre relatif à US1 une fois Setup terminé.
150+MVP = Phase 1 + 2 + 3 (US1, CLI puis GUI). US2 est un incrément indépendant, livrable séparément dans n'importe quel ordre relatif à US1 une fois Setup terminé. US3 (ajoutée le 2026-09-21) est un incrément de confort pur, livrable à tout moment après Setup — sans lui, la CLI (`regine import`, `specs/001-import-photos`) reste pleinement fonctionnelle et suffisante pour importer une carte mémoire.
@@ -99,11 +99,34 @@ description: "Task list for feature implementation"
99 99
100 ---100 ---
101 101
102-## Phase 5: Polish & Cross-Cutting Concerns102+## Phase 5: User Story 3 - Importer une carte mémoire depuis l'interface graphique (Priority: P3)
103+
104+**Objectif** : onglet d'import GUI appelant directement `regine_core.import_carte` (research.md § 6) — jamais `regine_cli.import_cmd` — pour reprendre à l'identique les étapes déjà couvertes et testées par `specs/001-import-photos` (analyse, découpage, destination, titre, confirmation, désambiguïsation de collision). Aucune nouvelle fonction `regine_core` : `regine import` (CLI) existe déjà et n'est pas modifié.
105+
106+**Test indépendant** : ouvrir l'onglet d'import, importer une carte à une seule journée jamais importée, vérifier que le dossier archivé obtenu est identique à celui que produirait `regine import` sur la même carte (Scénario 8 de `quickstart.md`).
107+
108+### Implementation for User Story 3
109+
110+- [X] T030 [US3] Créer `packages/regine-gui/src/regine_gui/import_view.py` (squelette, docstring de module uniquement)
111+- [X] T031 [US3] Implémenter l'étape d'analyse dans `import_view.py` : sélection de la carte (`QFileDialog`), appel à `copie.copier_carte` puis `groupage.decouper_en_groupes`/`jours_candidats_au_detachement`, affichage de la répartition jour par jour (US3 Acceptance Scenario 1)
112+- [X] T032 [US3] Implémenter le détachement de jour dans `import_view.py` : appel à `groupage.detacher_jours` sur confirmation explicite de l'utilisateur, jamais automatique (US3 Acceptance Scenario 2, FR-006 de `specs/001-import-photos`)
113+- [X] T033 [US3] Implémenter le choix de destination et le titre dans `import_view.py` : appel à `destination.resoudre_destination`/`resoudre_fusion` et `nommage.construire_nom_dossier*` selon le type choisi, sans logique de nommage ou de collision propre à la GUI (US3 Acceptance Scenario 3) — **écart trouvé à l'implémentation** : le cas « nouveau sous-dossier » nécessitait la même reconstruction de `RootLocation` à partir d'un chemin relatif que `regine_cli.import_cmd._root_location_depuis_chemin` (privée) ; plutôt que de la dupliquer une seconde fois (même schéma que `smb.resoudre_archive_locale`, déjà rencontré pour US1/US2), elle a été rendue publique dans `regine_core.import_carte.destination.root_location_depuis_chemin` et `import_cmd.py` mis à jour pour l'appeler elle aussi (aucun changement de comportement CLI, 172/172 tests toujours au vert) — cf. `contracts/regine-core-api.md`
114+- [X] T034 [US3] Implémenter le résumé de confirmation avant écriture dans `import_view.py` : appel à `push.preparer_resume`, attente d'une confirmation explicite (`QMessageBox`) avant tout appel à `push.archiver` (US3 Acceptance Scenario 4, FR-015)
115+- [X] T035 [US3] Implémenter la résolution de collision de boîtier dans `import_view.py` : appel à `copie.resoudre_collisions_boitiers`, présentation des mêmes options que la CLI (profil connu, numéro de série, étiquetage manuel) en cas de collision non résolue automatiquement (US3 Acceptance Scenario 5, FR-016, `specs/002-profil-boitiers-optionnel`)
116+- [X] T036 [US3] Implémenter la progression et le résultat dans `import_view.py` : retour visuel (journal texte) pendant `push.archiver`/`push.publier_copie_locale`, résumé final (US3 Acceptance Scenario 6, FR-017)
117+- [X] T037 [US3] Câbler l'onglet « Import » dans `packages/regine-gui/src/regine_gui/app.py` (`FenetrePrincipale`), aux côtés des onglets Tri/Consultation/Configuration déjà existants ; le dossier importé peut être ouvert directement dans l'onglet Tri via un signal Qt `dossier_importe` (US3 Acceptance Scenario 7) — `tri_view.EcranTri._charger_dossier` renommée en méthode publique `ouvrir_dossier` pour permettre cette connexion inter-onglets sans dupliquer le regroupement RAW+JPEG
118+- [X] T038 [US3] Validé manuellement en mode headless (`QT_QPA_PLATFORM=offscreen`) : import complet du chemin « nouveau dossier » (analyse, titre, catégorie vide, confirmation, archivage) déclenché par script simulant les réponses utilisateur (`QInputDialog`/`QMessageBox` monkeypatchés, `resoudre_archive_locale` monkeypatché pour éviter une dépendance à un vrai partage SMB monté, même méthode que `specs/003` T033/`specs/006` T017/T026) — dossier obtenu (`AAAA-MM-JJ_Titre`, fichiers renommés `date_titre_nomOrigine.ext`) structurellement identique à ce que produit `regine import` (Scénario 8 de `quickstart.md`, SC-007). Chemins « nouveau sous-dossier »/« fusion »/collision de boîtier non exercés par ce script (aucune carte de test avec EXIF/voyage multi-étapes disponible dans cet environnement) : validés par lecture de code, chaque branche appelant exclusivement des fonctions `regine_core.import_carte`/`camera_profile` déjà testées unitairement par `specs/001`/`specs/002`
119+
120+**Checkpoint** : User Story 3 fonctionnelle et testable indépendamment ; CLI (déjà livrée par `specs/001-import-photos`) et GUI cohérentes entre elles.
121+
122+---
123+
124+## Phase 6: Polish & Cross-Cutting Concerns
103 125
104 - [X] T027 [P] Exécuter les 6 scénarios de `specs/006-gui-tri-consultation/quickstart.md` — Scénario 1 (trier via CLI) : exécuté via `uv run python -m regine_cli.dossier_cmd`, réussi. Scénario 4 (consulter/restaurer) : validé via les fonctions core directement (le monkeypatch SMB inter-process n'est pas possible en subprocess, déjà couvert par `test_cli_archive_consultation.py`). Scénario 6 (archive non configurée) : réussi. Scénarios 2, 3, 5 (GUI) : validés manuellement en mode headless (`QT_QPA_PLATFORM=offscreen`), ouverture/regroupement/promotion et recherche/restauration réelles, sans interaction visuelle humaine (aucun poste avec écran physique disponible dans cet environnement de développement — limitation déjà documentée pour `specs/003` scénario 4)126 - [X] T027 [P] Exécuter les 6 scénarios de `specs/006-gui-tri-consultation/quickstart.md` — Scénario 1 (trier via CLI) : exécuté via `uv run python -m regine_cli.dossier_cmd`, réussi. Scénario 4 (consulter/restaurer) : validé via les fonctions core directement (le monkeypatch SMB inter-process n'est pas possible en subprocess, déjà couvert par `test_cli_archive_consultation.py`). Scénario 6 (archive non configurée) : réussi. Scénarios 2, 3, 5 (GUI) : validés manuellement en mode headless (`QT_QPA_PLATFORM=offscreen`), ouverture/regroupement/promotion et recherche/restauration réelles, sans interaction visuelle humaine (aucun poste avec écran physique disponible dans cet environnement de développement — limitation déjà documentée pour `specs/003` scénario 4)
105 - [X] T028 [P] `ruff check --fix` + `ruff format` sur `packages/regine-core`, `packages/regine-cli` et `packages/regine-gui` — 172/172 tests passent après coup127 - [X] T028 [P] `ruff check --fix` + `ruff format` sur `packages/regine-core`, `packages/regine-cli` et `packages/regine-gui` — 172/172 tests passent après coup
106 - [X] T029 Mettre à jour `contracts/regine-core-api.md`/`cli-dossier-selection.md`/`cli-archive-consultation.md` si l'implémentation révèle un écart avec les signatures documentées (patron déjà établi dans les specs précédentes) — tous mis à jour au fil de l'implémentation (signature `promouvoir`, renommage `browse`/`restore`, `resoudre_archive_locale`)128 - [X] T029 Mettre à jour `contracts/regine-core-api.md`/`cli-dossier-selection.md`/`cli-archive-consultation.md` si l'implémentation révèle un écart avec les signatures documentées (patron déjà établi dans les specs précédentes) — tous mis à jour au fil de l'implémentation (signature `promouvoir`, renommage `browse`/`restore`, `resoudre_archive_locale`)
129+- [X] T039 [P] Exécuté le Scénario 8 de `quickstart.md` (cf. T038 ; Scénario 7 déjà couvert par `specs/001-import-photos`, sans duplication) et `ruff check --fix`/`ruff format` sur `packages/regine-gui`, `packages/regine-cli`, `packages/regine-core` (nouveau fichier `import_view.py`, nouvelle fonction publique `root_location_depuis_chemin`) — 172/172 tests `regine-core`/`regine-cli` toujours au vert, aucun nouveau test requis côté core (aucune nouvelle règle métier ajoutée par US3, seulement une fonction déjà testée indirectement via `import_cmd` rendue publique)
107 130
108 ---131 ---
109 132
@@ -115,12 +138,13 @@ description: "Task list for feature implementation"
115 - **Foundational (Phase 2)** : dépend de Setup ; bloque User Story 1 uniquement (`dossier_de_format` n'est utilisée que par `selection.py`).138 - **Foundational (Phase 2)** : dépend de Setup ; bloque User Story 1 uniquement (`dossier_de_format` n'est utilisée que par `selection.py`).
116 - **User Story 1 (Phase 3)** : dépend de Foundational.139 - **User Story 1 (Phase 3)** : dépend de Foundational.
117 - **User Story 2 (Phase 4)** : dépend de Setup uniquement — indépendante de Foundational et de User Story 1, peut être menée en parallèle de la Phase 3.140 - **User Story 2 (Phase 4)** : dépend de Setup uniquement — indépendante de Foundational et de User Story 1, peut être menée en parallèle de la Phase 3.
118-- **Polish (Phase 5)** : dépend des user stories livrées (au minimum US1).141+- **User Story 3 (Phase 5)** : dépend de Setup (PySide6, `app.py`) et de `specs/001-import-photos` (déjà livrée, fournit `regine_core.import_carte` sans modification) ; indépendante de Foundational, User Story 1 et User Story 2 — aucune dépendance CLI nouvelle puisque `regine import` existe déjà.
142+- **Polish (Phase 6)** : dépend des user stories livrées (au minimum US1 ; T039 dépend spécifiquement de US3).
119 143
120 ### Au sein de chaque user story144 ### Au sein de chaque user story
121 145
122-CLI avant GUI, toujours (Principe CLI-first — cf. contrainte structurante en tête de fichier) : T016 avant T017/T018 ; T025 avant T026.146+CLI avant GUI, toujours (Principe CLI-first — cf. contrainte structurante en tête de fichier) : T016 avant T017/T018 ; T025 avant T026 ; pour US3, la CLI (`regine import`) est déjà livrée par `specs/001-import-photos`, donc aucune tâche CLI n'est requise avant T030-T037.
123 147
124 ## Implementation Strategy148 ## Implementation Strategy
125 149
126-MVP = Phase 1 + 2 + 3 (US1, CLI puis GUI). US2 est un incrément indépendant, livrable séparément dans n'importe quel ordre relatif à US1 une fois Setup terminé.150+MVP = Phase 1 + 2 + 3 (US1, CLI puis GUI). US2 est un incrément indépendant, livrable séparément dans n'importe quel ordre relatif à US1 une fois Setup terminé. US3 (ajoutée le 2026-09-21) est un incrément de confort pur, livrable à tout moment après Setup — sans lui, la CLI (`regine import`, `specs/001-import-photos`) reste pleinement fonctionnelle et suffisante pour importer une carte mémoire.