chargement plus rapide des photos, async
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 ( | ||
| 18 | 18 | suggest_categories, |
| 19 | 19 | ) |
| 20 | 20 | from regine_core.config.db import open_context_db |
| 21 | -from regine_core.dossier.root import RootLocation | |
| 22 | 21 | from regine_core.import_carte.copie import copier_carte, resoudre_collisions_boitiers |
| 23 | 22 | from regine_core.import_carte.destination import ( |
| 24 | 23 | lister_dossiers_candidats, |
| 25 | 24 | resoudre_destination, |
| 26 | 25 | resoudre_fusion, |
| 26 | + root_location_depuis_chemin, | |
| 27 | 27 | ) |
| 28 | 28 | from regine_core.import_carte.groupage import ( |
| 29 | 29 | decouper_en_groupes, |
| @@ -112,22 +112,6 @@ def _choisir_categorie(*, conn: sqlite3.Connection, interactif: bool) -> str | N | ||
| 112 | 112 | 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 | 115 | def _choisir_dossier_existant( |
| 132 | 116 | titre_partiel: str, *, archive_root: Path, local_root: Path, interactif: bool, arg: str | None |
| 133 | 117 | ) -> str: |
| @@ -235,7 +219,7 @@ def _traiter_groupe( | ||
| 235 | 219 | # sous-dossier d'étape doit lui être physiquement imbriqué, sous le |
| 236 | 220 | # dossier parent réel (ex. voyage/2026-08_Montenegro/2026-08-14_Kotor), |
| 237 | 221 | # pas simplement sous la racine catégorie. |
| 238 | - root_parent = _root_location_depuis_chemin( | |
| 222 | + root_parent = root_location_depuis_chemin( | |
| 239 | 223 | chemin_parent, archive_root=archive_root, local_root=local_root |
| 240 | 224 | ) |
| 241 | 225 | 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_db | 20 | from regine_core.config.db import open_context_db |
| 21 | -from regine_core.dossier.root import RootLocation | ||
| 22 | from regine_core.import_carte.copie import copier_carte, resoudre_collisions_boitiers | 21 | 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 saisie | 112 | 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 | None | 116 | 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 le | 219 | # 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_root | 223 | 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 None | 225 | 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 | ||
| 6 | 6 | from datetime import date as _date |
| 7 | 7 | from pathlib import Path |
| 8 | 8 | |
| 9 | -from regine_core.dossier.root import RootLocation, determine_root | |
| 9 | +from regine_core.dossier.root import RootLocation, RootType, determine_root | |
| 10 | 10 | 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 | 102 | def lister_dossiers_candidats( |
| 81 | 103 | titre_partiel: str, |
| 82 | 104 | racines: list[Path], |
| @@ -6,7 +6,7 @@ import difflib | |||
| 6 | from datetime import date as _date | 6 | from datetime import date as _date |
| 7 | from pathlib import Path | 7 | from pathlib import Path |
| 8 | 8 | ||
| 9 | -from regine_core.dossier.root import RootLocation, determine_root | 9 | +from regine_core.dossier.root import RootLocation, RootType, determine_root |
| 10 | from regine_core.import_carte.types import DestinationChoisie, GroupeImport, TypeDestination | 10 | 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 | ||
| 9 | 9 | |
| 10 | 10 | from regine_gui.config_view import EcranConfiguration |
| 11 | 11 | from regine_gui.consultation_view import EcranConsultation |
| 12 | +from regine_gui.import_view import EcranImport | |
| 12 | 13 | from regine_gui.tri_view import EcranTri |
| 13 | 14 | |
| 14 | 15 | |
| 15 | 16 | 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).""" | |
| 20 | 21 | |
| 21 | 22 | def __init__(self, contexte_db: Path | None = None) -> None: |
| 22 | 23 | super().__init__() |
| @@ -25,9 +26,14 @@ class FenetrePrincipale(QMainWindow): | ||
| 25 | 26 | |
| 26 | 27 | onglets = QTabWidget() |
| 27 | 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 | 34 | onglets.addTab(EcranConsultation(contexte_db=contexte_db), "Consultation") |
| 30 | 35 | onglets.addTab(EcranConfiguration(contexte_db=contexte_db), "Configuration") |
| 36 | + onglets.addTab(ecran_import, "Import") | |
| 31 | 37 | |
| 32 | 38 | |
| 33 | 39 | 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 EcranConfiguration | 10 | from regine_gui.config_view import EcranConfiguration |
| 11 | from regine_gui.consultation_view import EcranConsultation | 11 | from regine_gui.consultation_view import EcranConsultation |
| 12 | +from regine_gui.import_view import EcranImport | ||
| 12 | from regine_gui.tri_view import EcranTri | 13 | 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 de | 17 | + """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 entre | 19 | + 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): | ||
| 76 | 76 | def _ouvrir_dossier(self) -> None: |
| 77 | 77 | chemin = QFileDialog.getExistingDirectory(self, "Ouvrir un dossier de travail") |
| 78 | 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 | 85 | try: |
| 83 | 86 | entrees = regrouper(dossier) |
| 84 | 87 | 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` | |
| 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 | 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 | 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 | 51 | ## Notes d'implémentation |
| 34 | 52 | |
| 35 | 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. |
| @@ -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émentation | 51 | ## 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) | ||
| 66 | 66 | | `chemin_source` | chemin absolu (dans l'archive) | Fichier repéré via la Vue de consultation | |
| 67 | 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 | 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. | |
| @@ -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 @@ | ||
| 6 | 6 | |
| 7 | 7 | ## 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 | 11 | ## Technical Context |
| 12 | 12 | |
| @@ -40,12 +40,15 @@ Une façade graphique de bureau (`regine-gui`, PySide6) couvrant deux usages : l | ||
| 40 | 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 | 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 | 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 | 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 | 46 | | Formats ouverts et documentés | N/A | Aucun nouveau format de stockage introduit par cette fonctionnalité. | |
| 46 | 47 | |
| 47 | 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 | 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 | 54 | ## Project Structure |
| @@ -100,12 +103,16 @@ packages/ | ||
| 100 | 103 | ├── src/regine_gui/ |
| 101 | 104 | │ ├── app.py # point d'entrée, lance l'application PySide6 |
| 102 | 105 | │ ├── 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) | |
| 104 | 111 | └── (pas de suite de tests automatisée pour l'UI elle-même cette itération, cf. research.md § 2 — |
| 105 | 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 | 117 | ## Complexity Tracking |
| 111 | 118 | |
| @@ -6,7 +6,7 @@ | |||
| 6 | 6 | ||
| 7 | ## Summary | 7 | ## 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 Context | 11 | ## 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 Structure | 54 | ## 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 PySide6 | 104 | │ ├── app.py # point d'entrée, lance l'application PySide6 |
| 102 | │ ├── tri_view.py # écran de tri/culling (US1) — appelle regine_core.dossier.selection | 105 | │ ├── 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) | ||
| 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 Tracking | 117 | ## Complexity Tracking |
| 111 | 118 | ||
modified
specs/006-gui-tri-consultation/quickstart.md +19 -2 | @@ -1,6 +1,6 @@ | ||
| 1 | 1 | # 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 | 5 | ## Prérequis |
| 6 | 6 | |
| @@ -49,6 +49,23 @@ regine archive restore \ | ||
| 49 | 49 | |
| 50 | 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 | 69 | ## 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. | |
| @@ -1,6 +1,6 @@ | |||
| 1 | # Quickstart : validation de l'interface graphique de tri et de consultation | 1 | # 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érequis | 5 | ## 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 sortie | 69 | ## 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 @@ | ||
| 44 | 44 | |
| 45 | 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 | 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). | |
| @@ -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 @@ | ||
| 6 | 6 | |
| 7 | 7 | **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 | 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 | 71 | ### Edge Cases |
| 52 | 72 | |
| 53 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 104 | ### 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 125 | ## Assumptions |
| 96 | 126 | |
| @@ -100,3 +130,4 @@ Un photographe se souvient avoir pris une photo il y a plusieurs mois mais ne sa | ||
| 100 | 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 | 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 | 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). | |
| @@ -6,7 +6,7 @@ | |||
| 6 | 6 | ||
| 7 | **Status**: Draft | 7 | **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 Cases | 71 | ### 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 Entities | 104 | ### 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 | ## Assumptions | 125 | ## 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" | ||
| 99 | 99 | |
| 100 | 100 | --- |
| 101 | 101 | |
| 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 | |
| 103 | 125 | |
| 104 | 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 | 127 | - [X] T028 [P] `ruff check --fix` + `ruff format` sur `packages/regine-core`, `packages/regine-cli` et `packages/regine-gui` — 172/172 tests passent après coup |
| 106 | 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 | 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 | 139 | - **User Story 1 (Phase 3)** : dépend de Foundational. |
| 117 | 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 | 144 | ### 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 | 148 | ## 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. | |
| @@ -99,11 +99,34 @@ description: "Task list for feature implementation" | |||
| 99 | 99 | ||
| 100 | --- | 100 | --- |
| 101 | 101 | ||
| 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 | ||
| 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 coup | 127 | - [X] T028 [P] `ruff check --fix` + `ruff format` sur `packages/regine-core`, `packages/regine-cli` et `packages/regine-gui` — 172/172 tests passent après coup |
| 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 story | 144 | ### 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 Strategy | 148 | ## 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. |