implemente gui tri et consultation
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
780946f parent: 94fd0a0 modified
README.md +37 -8 | @@ -101,9 +101,9 @@ Glossaire complet : [`docs/lexique.md`](docs/lexique.md). | ||
| 101 | 101 | | **Profil de boîtiers** | Désambiguïsation multi-appareils par tag EXIF `Model`, en repli sur `BodySerialNumber` ou étiquetage manuel — optionnel, jamais un prérequis | [`specs/002`](specs/002-profil-boitiers-optionnel) | ✅ Implémenté | |
| 102 | 102 | | **Catégorisation des dossiers** | Placement à la racine par année ou par catégorie thématique extensible (voyage, mariage, vacances...) | [`specs/004`](specs/004-categorisation-dossiers) | ✅ Implémenté | |
| 103 | 103 | | **Checkout / réconciliation** | Aller-retour sécurisé archive ↔ espace de travail local, manifeste persistant par dossier, classification des changements par hash | [`specs/005`](specs/005-checkout-reconciliation) | ✅ Implémenté | |
| 104 | -| **Configuration du contexte de travail** | Chemins (temp, local, NAS/SMB), aide au montage, base de travail centralisée | [`specs/003`](specs/003-config-contexte-travail) | 🚧 Spécifiée, implémentation à venir | | |
| 104 | +| **Configuration du contexte de travail** | Chemins (temp, local, NAS/SMB), aide au montage, base de travail centralisée, nommage des boîtiers | [`specs/003`](specs/003-config-contexte-travail) | ✅ Implémenté | | |
| 105 | +| **Interface graphique** | Tri/culling (regroupement RAW+JPEG, promotion à la racine) et consultation en lecture seule de l'archive avec restauration ciblée | [`specs/006`](specs/006-gui-tri-consultation) | ✅ Implémenté | | |
| 105 | 106 | | **Vérification périodique (scrub)** | Détection de corruption silencieuse (bit rot) indépendamment de tout checkout | [constitution](.specify/memory/constitution.md) | 📝 Prévue | |
| 106 | -| **Interface graphique** | Tri/culling sur un dossier checké out, consultation en lecture seule de l'archive | [`docs/interface-cli-gui-architecture.md`](docs/interface-cli-gui-architecture.md) | 📝 Prévue | | |
| 107 | 107 | | **Agent IA** | Façade conversationnelle au-dessus de la même bibliothèque centrale | [`docs/interface-agent-ia.md`](docs/interface-agent-ia.md) | 📝 Prévue | |
| 108 | 108 | |
| 109 | 109 | ## Installation |
| @@ -117,9 +117,11 @@ uv sync | ||
| 117 | 117 | uv run pytest packages/regine-core/tests/ |
| 118 | 118 | ``` |
| 119 | 119 | |
| 120 | +`uv sync` installe aussi PySide6 (interface graphique, ~400 Mo au premier téléchargement) — nécessaire uniquement pour [lancer la GUI](#lancer-linterface-graphique), pas pour la CLI seule. | |
| 121 | + | |
| 120 | 122 | ## Utiliser Régine |
| 121 | 123 | |
| 122 | -> La bibliothèque (`regine-core`) est stable et testée ; la façade CLI (`regine-cli`) s'invoque aujourd'hui module par module (`python -m`), en attendant une commande unifiée `regine` (cf. [Feuille de route](#-feuille-de-route)). Tous les exemples ci-dessous sont exécutables tels quels depuis la racine du dépôt. | |
| 124 | +> La bibliothèque (`regine-core`) est stable et testée ; les façades CLI (`regine-cli`) et GUI (`regine-gui`) s'invoquent aujourd'hui module par module (`python -m`), en attendant une commande unifiée `regine` (cf. [Feuille de route](#-feuille-de-route)). Tous les exemples ci-dessous sont exécutables tels quels depuis la racine du dépôt. | |
| 123 | 125 | |
| 124 | 126 | ### Import simple d'une carte mémoire |
| 125 | 127 | |
| @@ -168,6 +170,33 @@ uv run python -m regine_cli.archive_cmd reconcile \ | ||
| 168 | 170 | --local-dest ~/regine/local/2026-08-12_Kotor |
| 169 | 171 | ``` |
| 170 | 172 | |
| 173 | +### Lancer l'interface graphique | |
| 174 | + | |
| 175 | +```bash | |
| 176 | +uv run python -m regine_gui.app | |
| 177 | +``` | |
| 178 | + | |
| 179 | +Ouvre une fenêtre à deux onglets : | |
| 180 | + | |
| 181 | +- **Tri** — bouton « Ouvrir un dossier… » pour sélectionner une copie de travail locale (celle laissée par un import, ou par un checkout) ; les paires RAW+JPEG jumelles apparaissent comme une seule entrée, promotion/rétrogradation à la racine en un clic. Chaque action a un équivalent CLI direct (`regine dossier list/promote/demote`, ci-dessous), garanti par le Principe CLI-first de la constitution — la GUI n'est qu'une façade de plus sur `regine-core`, jamais une capacité qui lui serait propre. | |
| 182 | +- **Consultation** — recherche en lecture seule dans l'archive configurée (`regine config set-paths`, cf. ci-dessus) par nom de dossier, date ou titre, avec restauration ciblée d'un fichier précis vers un emplacement local sans checkout complet ni verrou. | |
| 183 | + | |
| 184 | +Équivalents CLI (utiles pour scripter, ou si aucun environnement graphique n'est disponible) : | |
| 185 | + | |
| 186 | +```bash | |
| 187 | +# Tri : lister, promouvoir, rétrograder | |
| 188 | +uv run python -m regine_cli.dossier_cmd dossier list ~/regine/local/2026-08-15_Sortie_parc | |
| 189 | +uv run python -m regine_cli.dossier_cmd dossier promote \ | |
| 190 | + ~/regine/local/2026-08-15_Sortie_parc \ | |
| 191 | + ~/regine/local/2026-08-15_Sortie_parc/raw/2026-08-15_Sortie_parc_R0018279.RAF | |
| 192 | + | |
| 193 | +# Consultation : rechercher puis restaurer un fichier précis de l'archive configurée | |
| 194 | +uv run python -m regine_cli.archive_cmd browse --search "Montenegro" | |
| 195 | +uv run python -m regine_cli.archive_cmd restore \ | |
| 196 | + --chemin voyage/2026-08_Montenegro/2026-08-12_Kotor/raw/2026-08-12_Kotor_RD0001.RAF \ | |
| 197 | + --destination ~/Desktop/RD0001.RAF | |
| 198 | +``` | |
| 199 | + | |
| 171 | 200 | ## Architecture du monorepo |
| 172 | 201 | |
| 173 | 202 | Une bibliothèque centrale porte toute la logique métier ; chaque interface (CLI aujourd'hui, GUI et agent IA demain) n'est qu'une façade fine au-dessus, sans logique dupliquée — Principe VI de la constitution. |
| @@ -177,7 +206,7 @@ flowchart TB | ||
| 177 | 206 | subgraph FACADES [" Façades minces — aucune logique métier "] |
| 178 | 207 | direction LR |
| 179 | 208 | CLI["📟 regine-cli<br/>✅ implémentée"] |
| 180 | - GUI["🖥️ regine-gui<br/>📝 réservée"] | |
| 209 | + GUI["🖥️ regine-gui (PySide6)<br/>✅ implémentée"] | |
| 181 | 210 | AGENT["🤖 regine-agent<br/>📝 réservée"] |
| 182 | 211 | end |
| 183 | 212 | |
| @@ -192,8 +221,8 @@ flowchart TB | ||
| 192 | 221 | regine-photos-archiver/ |
| 193 | 222 | ├── packages/ |
| 194 | 223 | │ ├── regine-core/ # toute la logique métier — testée, sans dépendance aux façades |
| 195 | -│ ├── regine-cli/ # façade CLI (regine import, regine checkout, regine reconcile) | |
| 196 | -│ ├── regine-gui/ # façade graphique — réservée | |
| 224 | +│ ├── regine-cli/ # façade CLI (regine import, regine checkout, regine reconcile, regine dossier, regine browse...) | |
| 225 | +│ ├── regine-gui/ # façade graphique (PySide6) : tri/culling + consultation | |
| 197 | 226 | │ └── regine-agent/ # façade agent IA — réservée |
| 198 | 227 | ├── docs/ # notes de conception (source de vérité du domaine) |
| 199 | 228 | └── specs/ # spécifications Spec-Kit, une par fonctionnalité |
| @@ -201,11 +230,11 @@ regine-photos-archiver/ | ||
| 201 | 230 | |
| 202 | 231 | ## 🗺 Feuille de route |
| 203 | 232 | |
| 204 | -- [ ] Module de configuration complet (`specs/003`) : montage SMB, base de travail centralisée | |
| 205 | 233 | - [ ] Commande unifiée `regine` (un seul point d'entrée CLI packagé) |
| 234 | +- [ ] Intégration complète config ↔ import/checkout (`regine import`/`checkout` lisent encore `--archive-root`/`--local-root` en flags plutôt que le contexte configuré par `regine config`) | |
| 206 | 235 | - [ ] Vérification périodique de l'archive (scrub, indépendante du checkout) |
| 207 | -- [ ] Interface graphique (tri/culling + consultation en lecture seule) | |
| 208 | 236 | - [ ] Planche-contact JPEG annotée (export statique, indépendant de la GUI) |
| 237 | +- [ ] Vignettes/aperçus dans l'écran de tri de la GUI (actuellement une liste de noms de fichiers) | |
| 209 | 238 | - [ ] Agent IA conversationnel |
| 210 | 239 | |
| 211 | 240 | ## Développement piloté par les specs |
| @@ -101,9 +101,9 @@ Glossaire complet : [`docs/lexique.md`](docs/lexique.md). | |||
| 101 | | **Profil de boîtiers** | Désambiguïsation multi-appareils par tag EXIF `Model`, en repli sur `BodySerialNumber` ou étiquetage manuel — optionnel, jamais un prérequis | [`specs/002`](specs/002-profil-boitiers-optionnel) | ✅ Implémenté | | 101 | | **Profil de boîtiers** | Désambiguïsation multi-appareils par tag EXIF `Model`, en repli sur `BodySerialNumber` ou étiquetage manuel — optionnel, jamais un prérequis | [`specs/002`](specs/002-profil-boitiers-optionnel) | ✅ Implémenté | |
| 102 | | **Catégorisation des dossiers** | Placement à la racine par année ou par catégorie thématique extensible (voyage, mariage, vacances...) | [`specs/004`](specs/004-categorisation-dossiers) | ✅ Implémenté | | 102 | | **Catégorisation des dossiers** | Placement à la racine par année ou par catégorie thématique extensible (voyage, mariage, vacances...) | [`specs/004`](specs/004-categorisation-dossiers) | ✅ Implémenté | |
| 103 | | **Checkout / réconciliation** | Aller-retour sécurisé archive ↔ espace de travail local, manifeste persistant par dossier, classification des changements par hash | [`specs/005`](specs/005-checkout-reconciliation) | ✅ Implémenté | | 103 | | **Checkout / réconciliation** | Aller-retour sécurisé archive ↔ espace de travail local, manifeste persistant par dossier, classification des changements par hash | [`specs/005`](specs/005-checkout-reconciliation) | ✅ Implémenté | |
| 104 | -| **Configuration du contexte de travail** | Chemins (temp, local, NAS/SMB), aide au montage, base de travail centralisée | [`specs/003`](specs/003-config-contexte-travail) | 🚧 Spécifiée, implémentation à venir | | 104 | +| **Configuration du contexte de travail** | Chemins (temp, local, NAS/SMB), aide au montage, base de travail centralisée, nommage des boîtiers | [`specs/003`](specs/003-config-contexte-travail) | ✅ Implémenté | |
| 105 | +| **Interface graphique** | Tri/culling (regroupement RAW+JPEG, promotion à la racine) et consultation en lecture seule de l'archive avec restauration ciblée | [`specs/006`](specs/006-gui-tri-consultation) | ✅ Implémenté | | ||
| 105 | | **Vérification périodique (scrub)** | Détection de corruption silencieuse (bit rot) indépendamment de tout checkout | [constitution](.specify/memory/constitution.md) | 📝 Prévue | | 106 | | **Vérification périodique (scrub)** | Détection de corruption silencieuse (bit rot) indépendamment de tout checkout | [constitution](.specify/memory/constitution.md) | 📝 Prévue | |
| 106 | -| **Interface graphique** | Tri/culling sur un dossier checké out, consultation en lecture seule de l'archive | [`docs/interface-cli-gui-architecture.md`](docs/interface-cli-gui-architecture.md) | 📝 Prévue | | ||
| 107 | | **Agent IA** | Façade conversationnelle au-dessus de la même bibliothèque centrale | [`docs/interface-agent-ia.md`](docs/interface-agent-ia.md) | 📝 Prévue | | 107 | | **Agent IA** | Façade conversationnelle au-dessus de la même bibliothèque centrale | [`docs/interface-agent-ia.md`](docs/interface-agent-ia.md) | 📝 Prévue | |
| 108 | 108 | ||
| 109 | ## Installation | 109 | ## Installation |
| @@ -117,9 +117,11 @@ uv sync | |||
| 117 | uv run pytest packages/regine-core/tests/ | 117 | uv run pytest packages/regine-core/tests/ |
| 118 | ``` | 118 | ``` |
| 119 | 119 | ||
| 120 | +`uv sync` installe aussi PySide6 (interface graphique, ~400 Mo au premier téléchargement) — nécessaire uniquement pour [lancer la GUI](#lancer-linterface-graphique), pas pour la CLI seule. | ||
| 121 | + | ||
| 120 | ## Utiliser Régine | 122 | ## Utiliser Régine |
| 121 | 123 | ||
| 122 | -> La bibliothèque (`regine-core`) est stable et testée ; la façade CLI (`regine-cli`) s'invoque aujourd'hui module par module (`python -m`), en attendant une commande unifiée `regine` (cf. [Feuille de route](#-feuille-de-route)). Tous les exemples ci-dessous sont exécutables tels quels depuis la racine du dépôt. | 124 | +> La bibliothèque (`regine-core`) est stable et testée ; les façades CLI (`regine-cli`) et GUI (`regine-gui`) s'invoquent aujourd'hui module par module (`python -m`), en attendant une commande unifiée `regine` (cf. [Feuille de route](#-feuille-de-route)). Tous les exemples ci-dessous sont exécutables tels quels depuis la racine du dépôt. |
| 123 | 125 | ||
| 124 | ### Import simple d'une carte mémoire | 126 | ### Import simple d'une carte mémoire |
| 125 | 127 | ||
| @@ -168,6 +170,33 @@ uv run python -m regine_cli.archive_cmd reconcile \ | |||
| 168 | --local-dest ~/regine/local/2026-08-12_Kotor | 170 | --local-dest ~/regine/local/2026-08-12_Kotor |
| 169 | ``` | 171 | ``` |
| 170 | 172 | ||
| 173 | +### Lancer l'interface graphique | ||
| 174 | + | ||
| 175 | +```bash | ||
| 176 | +uv run python -m regine_gui.app | ||
| 177 | +``` | ||
| 178 | + | ||
| 179 | +Ouvre une fenêtre à deux onglets : | ||
| 180 | + | ||
| 181 | +- **Tri** — bouton « Ouvrir un dossier… » pour sélectionner une copie de travail locale (celle laissée par un import, ou par un checkout) ; les paires RAW+JPEG jumelles apparaissent comme une seule entrée, promotion/rétrogradation à la racine en un clic. Chaque action a un équivalent CLI direct (`regine dossier list/promote/demote`, ci-dessous), garanti par le Principe CLI-first de la constitution — la GUI n'est qu'une façade de plus sur `regine-core`, jamais une capacité qui lui serait propre. | ||
| 182 | +- **Consultation** — recherche en lecture seule dans l'archive configurée (`regine config set-paths`, cf. ci-dessus) par nom de dossier, date ou titre, avec restauration ciblée d'un fichier précis vers un emplacement local sans checkout complet ni verrou. | ||
| 183 | + | ||
| 184 | +Équivalents CLI (utiles pour scripter, ou si aucun environnement graphique n'est disponible) : | ||
| 185 | + | ||
| 186 | +```bash | ||
| 187 | +# Tri : lister, promouvoir, rétrograder | ||
| 188 | +uv run python -m regine_cli.dossier_cmd dossier list ~/regine/local/2026-08-15_Sortie_parc | ||
| 189 | +uv run python -m regine_cli.dossier_cmd dossier promote \ | ||
| 190 | + ~/regine/local/2026-08-15_Sortie_parc \ | ||
| 191 | + ~/regine/local/2026-08-15_Sortie_parc/raw/2026-08-15_Sortie_parc_R0018279.RAF | ||
| 192 | + | ||
| 193 | +# Consultation : rechercher puis restaurer un fichier précis de l'archive configurée | ||
| 194 | +uv run python -m regine_cli.archive_cmd browse --search "Montenegro" | ||
| 195 | +uv run python -m regine_cli.archive_cmd restore \ | ||
| 196 | + --chemin voyage/2026-08_Montenegro/2026-08-12_Kotor/raw/2026-08-12_Kotor_RD0001.RAF \ | ||
| 197 | + --destination ~/Desktop/RD0001.RAF | ||
| 198 | +``` | ||
| 199 | + | ||
| 171 | ## Architecture du monorepo | 200 | ## Architecture du monorepo |
| 172 | 201 | ||
| 173 | Une bibliothèque centrale porte toute la logique métier ; chaque interface (CLI aujourd'hui, GUI et agent IA demain) n'est qu'une façade fine au-dessus, sans logique dupliquée — Principe VI de la constitution. | 202 | Une bibliothèque centrale porte toute la logique métier ; chaque interface (CLI aujourd'hui, GUI et agent IA demain) n'est qu'une façade fine au-dessus, sans logique dupliquée — Principe VI de la constitution. |
| @@ -177,7 +206,7 @@ flowchart TB | |||
| 177 | subgraph FACADES [" Façades minces — aucune logique métier "] | 206 | subgraph FACADES [" Façades minces — aucune logique métier "] |
| 178 | direction LR | 207 | direction LR |
| 179 | CLI["📟 regine-cli<br/>✅ implémentée"] | 208 | CLI["📟 regine-cli<br/>✅ implémentée"] |
| 180 | - GUI["🖥️ regine-gui<br/>📝 réservée"] | 209 | + GUI["🖥️ regine-gui (PySide6)<br/>✅ implémentée"] |
| 181 | AGENT["🤖 regine-agent<br/>📝 réservée"] | 210 | AGENT["🤖 regine-agent<br/>📝 réservée"] |
| 182 | end | 211 | end |
| 183 | 212 | ||
| @@ -192,8 +221,8 @@ flowchart TB | |||
| 192 | regine-photos-archiver/ | 221 | regine-photos-archiver/ |
| 193 | ├── packages/ | 222 | ├── packages/ |
| 194 | │ ├── regine-core/ # toute la logique métier — testée, sans dépendance aux façades | 223 | │ ├── regine-core/ # toute la logique métier — testée, sans dépendance aux façades |
| 195 | -│ ├── regine-cli/ # façade CLI (regine import, regine checkout, regine reconcile) | 224 | +│ ├── regine-cli/ # façade CLI (regine import, regine checkout, regine reconcile, regine dossier, regine browse...) |
| 196 | -│ ├── regine-gui/ # façade graphique — réservée | 225 | +│ ├── regine-gui/ # façade graphique (PySide6) : tri/culling + consultation |
| 197 | │ └── regine-agent/ # façade agent IA — réservée | 226 | │ └── regine-agent/ # façade agent IA — réservée |
| 198 | ├── docs/ # notes de conception (source de vérité du domaine) | 227 | ├── docs/ # notes de conception (source de vérité du domaine) |
| 199 | └── specs/ # spécifications Spec-Kit, une par fonctionnalité | 228 | └── specs/ # spécifications Spec-Kit, une par fonctionnalité |
| @@ -201,11 +230,11 @@ regine-photos-archiver/ | |||
| 201 | 230 | ||
| 202 | ## 🗺 Feuille de route | 231 | ## 🗺 Feuille de route |
| 203 | 232 | ||
| 204 | -- [ ] Module de configuration complet (`specs/003`) : montage SMB, base de travail centralisée | ||
| 205 | - [ ] Commande unifiée `regine` (un seul point d'entrée CLI packagé) | 233 | - [ ] Commande unifiée `regine` (un seul point d'entrée CLI packagé) |
| 234 | +- [ ] Intégration complète config ↔ import/checkout (`regine import`/`checkout` lisent encore `--archive-root`/`--local-root` en flags plutôt que le contexte configuré par `regine config`) | ||
| 206 | - [ ] Vérification périodique de l'archive (scrub, indépendante du checkout) | 235 | - [ ] Vérification périodique de l'archive (scrub, indépendante du checkout) |
| 207 | -- [ ] Interface graphique (tri/culling + consultation en lecture seule) | ||
| 208 | - [ ] Planche-contact JPEG annotée (export statique, indépendant de la GUI) | 236 | - [ ] Planche-contact JPEG annotée (export statique, indépendant de la GUI) |
| 237 | +- [ ] Vignettes/aperçus dans l'écran de tri de la GUI (actuellement une liste de noms de fichiers) | ||
| 209 | - [ ] Agent IA conversationnel | 238 | - [ ] Agent IA conversationnel |
| 210 | 239 | ||
| 211 | ## Développement piloté par les specs | 240 | ## Développement piloté par les specs |
modified
packages/regine-cli/src/regine_cli/archive_cmd.py +99 -0 | @@ -7,6 +7,7 @@ aucune logique métier propre (Principe VI de la constitution). | ||
| 7 | 7 | from __future__ import annotations |
| 8 | 8 | |
| 9 | 9 | import argparse |
| 10 | +import json | |
| 10 | 11 | import sys |
| 11 | 12 | from pathlib import Path |
| 12 | 13 | |
| @@ -117,6 +118,88 @@ def _cmd_reconcile(args: argparse.Namespace) -> int: | ||
| 117 | 118 | return 0 |
| 118 | 119 | |
| 119 | 120 | |
| 121 | +def _charger_archive_root_local(args: argparse.Namespace) -> Path | None: | |
| 122 | + """Charge le contexte de travail (`specs/003`) et résout le point de montage | |
| 123 | + local de l'archive configurée — ou `None` (message déjà affiché sur stderr) si | |
| 124 | + aucune archive n'est configurée ou inaccessible. Délègue la résolution/ | |
| 125 | + vérification elle-même à `regine_core.config.smb.resoudre_archive_locale`, | |
| 126 | + partagée avec `regine-gui` (Principe VI — cf. bug trouvé par test manuel de la | |
| 127 | + GUI, specs/006 : deux résolutions dupliquées et non cohérentes entre elles).""" | |
| 128 | + from regine_core.config.context import charger_contexte # noqa: PLC0415 | |
| 129 | + from regine_core.config.db import open_context_db # noqa: PLC0415 | |
| 130 | + from regine_core.config.smb import resoudre_archive_locale # noqa: PLC0415 | |
| 131 | + | |
| 132 | + conn = open_context_db(Path(args.contexte_db) if args.contexte_db else None) | |
| 133 | + try: | |
| 134 | + contexte = charger_contexte(conn) | |
| 135 | + if contexte.archive_smb_path is None: | |
| 136 | + print("Aucune archive configurée (regine config set-paths).", file=sys.stderr) | |
| 137 | + return None | |
| 138 | + | |
| 139 | + archive_root_local = resoudre_archive_locale(conn) | |
| 140 | + if archive_root_local is None: | |
| 141 | + print( | |
| 142 | + f"{contexte.archive_smb_path} n'est pas accessible — montage natif " | |
| 143 | + "déclenché, réessayer une fois l'accès rétabli.", | |
| 144 | + file=sys.stderr, | |
| 145 | + ) | |
| 146 | + return archive_root_local | |
| 147 | + finally: | |
| 148 | + conn.close() | |
| 149 | + | |
| 150 | + | |
| 151 | +def _cmd_browse(args: argparse.Namespace) -> int: | |
| 152 | + from regine_core.archive.consultation import rechercher # noqa: PLC0415 | |
| 153 | + | |
| 154 | + archive_root_local = _charger_archive_root_local(args) | |
| 155 | + if archive_root_local is None: | |
| 156 | + return 1 | |
| 157 | + | |
| 158 | + resultats = rechercher(archive_root_local, args.search) | |
| 159 | + | |
| 160 | + if args.json: | |
| 161 | + print( | |
| 162 | + json.dumps( | |
| 163 | + [ | |
| 164 | + {"chemin_relatif": str(r.chemin_relatif), "est_dossier": r.est_dossier} | |
| 165 | + for r in resultats | |
| 166 | + ] | |
| 167 | + ) | |
| 168 | + ) | |
| 169 | + return 0 | |
| 170 | + | |
| 171 | + if not resultats: | |
| 172 | + print("Aucun résultat.") | |
| 173 | + return 0 | |
| 174 | + for resultat in resultats: | |
| 175 | + marque = "[dossier]" if resultat.est_dossier else "[fichier]" | |
| 176 | + print(f" {marque} {resultat.chemin_relatif}") | |
| 177 | + return 0 | |
| 178 | + | |
| 179 | + | |
| 180 | +def _cmd_restore(args: argparse.Namespace) -> int: | |
| 181 | + from regine_core.archive.consultation import ( # noqa: PLC0415 | |
| 182 | + RestaurationCollisionError, | |
| 183 | + restaurer, | |
| 184 | + ) | |
| 185 | + | |
| 186 | + archive_root_local = _charger_archive_root_local(args) | |
| 187 | + if archive_root_local is None: | |
| 188 | + return 1 | |
| 189 | + | |
| 190 | + chemin_source = archive_root_local / args.chemin | |
| 191 | + chemin_destination = Path(args.destination) | |
| 192 | + | |
| 193 | + try: | |
| 194 | + restaurer(chemin_source, chemin_destination) | |
| 195 | + except (FileNotFoundError, RestaurationCollisionError) as exc: | |
| 196 | + print(str(exc), file=sys.stderr) | |
| 197 | + return 1 | |
| 198 | + | |
| 199 | + print(f"Restauré : {chemin_destination}") | |
| 200 | + return 0 | |
| 201 | + | |
| 202 | + | |
| 120 | 203 | def construire_analyseur() -> argparse.ArgumentParser: |
| 121 | 204 | analyseur = argparse.ArgumentParser(prog="regine") |
| 122 | 205 | sous_commandes = analyseur.add_subparsers(dest="commande", required=True) |
| @@ -134,6 +217,22 @@ def construire_analyseur() -> argparse.ArgumentParser: | ||
| 134 | 217 | reconcile_parser.add_argument("--local-dest", default=None) |
| 135 | 218 | reconcile_parser.set_defaults(func=_cmd_reconcile) |
| 136 | 219 | |
| 220 | + browse_parser = sous_commandes.add_parser( | |
| 221 | + "browse", help="Parcourt l'archive configurée en lecture seule" | |
| 222 | + ) | |
| 223 | + browse_parser.add_argument("--search", default=None) | |
| 224 | + browse_parser.add_argument("--json", action="store_true") | |
| 225 | + browse_parser.add_argument("--contexte-db", default=None) | |
| 226 | + browse_parser.set_defaults(func=_cmd_browse) | |
| 227 | + | |
| 228 | + restore_parser = sous_commandes.add_parser( | |
| 229 | + "restore", help="Restaure une copie vérifiée d'un fichier de l'archive" | |
| 230 | + ) | |
| 231 | + restore_parser.add_argument("--chemin", required=True) | |
| 232 | + restore_parser.add_argument("--destination", required=True) | |
| 233 | + restore_parser.add_argument("--contexte-db", default=None) | |
| 234 | + restore_parser.set_defaults(func=_cmd_restore) | |
| 235 | + | |
| 137 | 236 | return analyseur |
| 138 | 237 | |
| 139 | 238 | |
| @@ -7,6 +7,7 @@ aucune logique métier propre (Principe VI de la constitution). | |||
| 7 | from __future__ import annotations | 7 | from __future__ import annotations |
| 8 | 8 | ||
| 9 | import argparse | 9 | import argparse |
| 10 | +import json | ||
| 10 | import sys | 11 | import sys |
| 11 | from pathlib import Path | 12 | from pathlib import Path |
| 12 | 13 | ||
| @@ -117,6 +118,88 @@ def _cmd_reconcile(args: argparse.Namespace) -> int: | |||
| 117 | return 0 | 118 | return 0 |
| 118 | 119 | ||
| 119 | 120 | ||
| 121 | +def _charger_archive_root_local(args: argparse.Namespace) -> Path | None: | ||
| 122 | + """Charge le contexte de travail (`specs/003`) et résout le point de montage | ||
| 123 | + local de l'archive configurée — ou `None` (message déjà affiché sur stderr) si | ||
| 124 | + aucune archive n'est configurée ou inaccessible. Délègue la résolution/ | ||
| 125 | + vérification elle-même à `regine_core.config.smb.resoudre_archive_locale`, | ||
| 126 | + partagée avec `regine-gui` (Principe VI — cf. bug trouvé par test manuel de la | ||
| 127 | + GUI, specs/006 : deux résolutions dupliquées et non cohérentes entre elles).""" | ||
| 128 | + from regine_core.config.context import charger_contexte # noqa: PLC0415 | ||
| 129 | + from regine_core.config.db import open_context_db # noqa: PLC0415 | ||
| 130 | + from regine_core.config.smb import resoudre_archive_locale # noqa: PLC0415 | ||
| 131 | + | ||
| 132 | + conn = open_context_db(Path(args.contexte_db) if args.contexte_db else None) | ||
| 133 | + try: | ||
| 134 | + contexte = charger_contexte(conn) | ||
| 135 | + if contexte.archive_smb_path is None: | ||
| 136 | + print("Aucune archive configurée (regine config set-paths).", file=sys.stderr) | ||
| 137 | + return None | ||
| 138 | + | ||
| 139 | + archive_root_local = resoudre_archive_locale(conn) | ||
| 140 | + if archive_root_local is None: | ||
| 141 | + print( | ||
| 142 | + f"{contexte.archive_smb_path} n'est pas accessible — montage natif " | ||
| 143 | + "déclenché, réessayer une fois l'accès rétabli.", | ||
| 144 | + file=sys.stderr, | ||
| 145 | + ) | ||
| 146 | + return archive_root_local | ||
| 147 | + finally: | ||
| 148 | + conn.close() | ||
| 149 | + | ||
| 150 | + | ||
| 151 | +def _cmd_browse(args: argparse.Namespace) -> int: | ||
| 152 | + from regine_core.archive.consultation import rechercher # noqa: PLC0415 | ||
| 153 | + | ||
| 154 | + archive_root_local = _charger_archive_root_local(args) | ||
| 155 | + if archive_root_local is None: | ||
| 156 | + return 1 | ||
| 157 | + | ||
| 158 | + resultats = rechercher(archive_root_local, args.search) | ||
| 159 | + | ||
| 160 | + if args.json: | ||
| 161 | + print( | ||
| 162 | + json.dumps( | ||
| 163 | + [ | ||
| 164 | + {"chemin_relatif": str(r.chemin_relatif), "est_dossier": r.est_dossier} | ||
| 165 | + for r in resultats | ||
| 166 | + ] | ||
| 167 | + ) | ||
| 168 | + ) | ||
| 169 | + return 0 | ||
| 170 | + | ||
| 171 | + if not resultats: | ||
| 172 | + print("Aucun résultat.") | ||
| 173 | + return 0 | ||
| 174 | + for resultat in resultats: | ||
| 175 | + marque = "[dossier]" if resultat.est_dossier else "[fichier]" | ||
| 176 | + print(f" {marque} {resultat.chemin_relatif}") | ||
| 177 | + return 0 | ||
| 178 | + | ||
| 179 | + | ||
| 180 | +def _cmd_restore(args: argparse.Namespace) -> int: | ||
| 181 | + from regine_core.archive.consultation import ( # noqa: PLC0415 | ||
| 182 | + RestaurationCollisionError, | ||
| 183 | + restaurer, | ||
| 184 | + ) | ||
| 185 | + | ||
| 186 | + archive_root_local = _charger_archive_root_local(args) | ||
| 187 | + if archive_root_local is None: | ||
| 188 | + return 1 | ||
| 189 | + | ||
| 190 | + chemin_source = archive_root_local / args.chemin | ||
| 191 | + chemin_destination = Path(args.destination) | ||
| 192 | + | ||
| 193 | + try: | ||
| 194 | + restaurer(chemin_source, chemin_destination) | ||
| 195 | + except (FileNotFoundError, RestaurationCollisionError) as exc: | ||
| 196 | + print(str(exc), file=sys.stderr) | ||
| 197 | + return 1 | ||
| 198 | + | ||
| 199 | + print(f"Restauré : {chemin_destination}") | ||
| 200 | + return 0 | ||
| 201 | + | ||
| 202 | + | ||
| 120 | def construire_analyseur() -> argparse.ArgumentParser: | 203 | def construire_analyseur() -> argparse.ArgumentParser: |
| 121 | analyseur = argparse.ArgumentParser(prog="regine") | 204 | analyseur = argparse.ArgumentParser(prog="regine") |
| 122 | sous_commandes = analyseur.add_subparsers(dest="commande", required=True) | 205 | sous_commandes = analyseur.add_subparsers(dest="commande", required=True) |
| @@ -134,6 +217,22 @@ def construire_analyseur() -> argparse.ArgumentParser: | |||
| 134 | reconcile_parser.add_argument("--local-dest", default=None) | 217 | reconcile_parser.add_argument("--local-dest", default=None) |
| 135 | reconcile_parser.set_defaults(func=_cmd_reconcile) | 218 | reconcile_parser.set_defaults(func=_cmd_reconcile) |
| 136 | 219 | ||
| 220 | + browse_parser = sous_commandes.add_parser( | ||
| 221 | + "browse", help="Parcourt l'archive configurée en lecture seule" | ||
| 222 | + ) | ||
| 223 | + browse_parser.add_argument("--search", default=None) | ||
| 224 | + browse_parser.add_argument("--json", action="store_true") | ||
| 225 | + browse_parser.add_argument("--contexte-db", default=None) | ||
| 226 | + browse_parser.set_defaults(func=_cmd_browse) | ||
| 227 | + | ||
| 228 | + restore_parser = sous_commandes.add_parser( | ||
| 229 | + "restore", help="Restaure une copie vérifiée d'un fichier de l'archive" | ||
| 230 | + ) | ||
| 231 | + restore_parser.add_argument("--chemin", required=True) | ||
| 232 | + restore_parser.add_argument("--destination", required=True) | ||
| 233 | + restore_parser.add_argument("--contexte-db", default=None) | ||
| 234 | + restore_parser.set_defaults(func=_cmd_restore) | ||
| 235 | + | ||
| 137 | return analyseur | 236 | return analyseur |
| 138 | 237 | ||
| 139 | 238 | ||
added
packages/regine-cli/src/regine_cli/dossier_cmd.py +119 -0 | new file mode 100644 | ||
| @@ -0,0 +1,119 @@ | ||
| 1 | +"""Commandes `regine dossier ...` (cf. contracts/cli-dossier-selection.md, specs/006). | |
| 2 | + | |
| 3 | +Façade fine : orchestre `regine_core.dossier.selection`, formatte le résultat, ne | |
| 4 | +contient aucune logique métier propre (Principe VI de la constitution). Cette | |
| 5 | +surface DOIT exister avant tout équivalent GUI (Principe CLI-first, FR-007). | |
| 6 | +""" | |
| 7 | + | |
| 8 | +from __future__ import annotations | |
| 9 | + | |
| 10 | +import argparse | |
| 11 | +import json | |
| 12 | +import sys | |
| 13 | +from pathlib import Path | |
| 14 | + | |
| 15 | +from regine_core.dossier.selection import ( | |
| 16 | + FichierDejaDansSonDossierDeFormatError, | |
| 17 | + StructureDossierInvalideError, | |
| 18 | + promouvoir, | |
| 19 | + regrouper, | |
| 20 | + retrograder, | |
| 21 | +) | |
| 22 | + | |
| 23 | + | |
| 24 | +def _cmd_list(args: argparse.Namespace) -> int: | |
| 25 | + dossier = Path(args.chemin_dossier) | |
| 26 | + try: | |
| 27 | + entrees = regrouper(dossier) | |
| 28 | + except StructureDossierInvalideError as exc: | |
| 29 | + print(str(exc), file=sys.stderr) | |
| 30 | + return 1 | |
| 31 | + | |
| 32 | + if args.json: | |
| 33 | + print( | |
| 34 | + json.dumps( | |
| 35 | + [ | |
| 36 | + { | |
| 37 | + "nom_base": entree.nom_base, | |
| 38 | + "fichiers": [ | |
| 39 | + {"chemin": str(f), "promu": f.parent == dossier} | |
| 40 | + for f in entree.fichiers | |
| 41 | + ], | |
| 42 | + } | |
| 43 | + for entree in entrees | |
| 44 | + ] | |
| 45 | + ) | |
| 46 | + ) | |
| 47 | + return 0 | |
| 48 | + | |
| 49 | + for entree in entrees: | |
| 50 | + details = ", ".join( | |
| 51 | + f"{f.name} ({'promu' if f.parent == dossier else 'non promu'})" for f in entree.fichiers | |
| 52 | + ) | |
| 53 | + print(f" {entree.nom_base} : {details}") | |
| 54 | + return 0 | |
| 55 | + | |
| 56 | + | |
| 57 | +def _cmd_promote(args: argparse.Namespace) -> int: | |
| 58 | + dossier = Path(args.chemin_dossier) | |
| 59 | + fichier = Path(args.chemin_fichier) | |
| 60 | + if not fichier.is_relative_to(dossier): | |
| 61 | + print(f"{fichier} n'est pas sous {dossier}", file=sys.stderr) | |
| 62 | + return 1 | |
| 63 | + | |
| 64 | + resultat = promouvoir(dossier, fichier) | |
| 65 | + if resultat == fichier: | |
| 66 | + print(f"Déjà à la racine : {resultat}") | |
| 67 | + else: | |
| 68 | + print(f"Promu : {resultat}") | |
| 69 | + return 0 | |
| 70 | + | |
| 71 | + | |
| 72 | +def _cmd_demote(args: argparse.Namespace) -> int: | |
| 73 | + fichier = Path(args.chemin_fichier) | |
| 74 | + try: | |
| 75 | + resultat = retrograder(fichier) | |
| 76 | + except (FichierDejaDansSonDossierDeFormatError, ValueError) as exc: | |
| 77 | + print(str(exc), file=sys.stderr) | |
| 78 | + return 1 | |
| 79 | + | |
| 80 | + print(f"Rétrogradé : {resultat}") | |
| 81 | + return 0 | |
| 82 | + | |
| 83 | + | |
| 84 | +def construire_analyseur() -> argparse.ArgumentParser: | |
| 85 | + analyseur = argparse.ArgumentParser(prog="regine") | |
| 86 | + sous_commandes = analyseur.add_subparsers(dest="commande", required=True) | |
| 87 | + | |
| 88 | + dossier_parser = sous_commandes.add_parser( | |
| 89 | + "dossier", help="Tri/culling d'un dossier de travail local" | |
| 90 | + ) | |
| 91 | + dossier_sous = dossier_parser.add_subparsers(dest="sous_commande", required=True) | |
| 92 | + | |
| 93 | + list_parser = dossier_sous.add_parser("list", help="Liste les entrées regroupées") | |
| 94 | + list_parser.add_argument("chemin_dossier") | |
| 95 | + list_parser.add_argument("--json", action="store_true") | |
| 96 | + list_parser.set_defaults(func=_cmd_list) | |
| 97 | + | |
| 98 | + promote_parser = dossier_sous.add_parser("promote", help="Promeut un fichier à la racine") | |
| 99 | + promote_parser.add_argument("chemin_dossier") | |
| 100 | + promote_parser.add_argument("chemin_fichier") | |
| 101 | + promote_parser.set_defaults(func=_cmd_promote) | |
| 102 | + | |
| 103 | + demote_parser = dossier_sous.add_parser( | |
| 104 | + "demote", help="Rétrograde un fichier vers son dossier de format" | |
| 105 | + ) | |
| 106 | + demote_parser.add_argument("chemin_fichier") | |
| 107 | + demote_parser.set_defaults(func=_cmd_demote) | |
| 108 | + | |
| 109 | + return analyseur | |
| 110 | + | |
| 111 | + | |
| 112 | +def main(argv: list[str] | None = None) -> int: | |
| 113 | + analyseur = construire_analyseur() | |
| 114 | + args = analyseur.parse_args(argv) | |
| 115 | + return args.func(args) | |
| 116 | + | |
| 117 | + | |
| 118 | +if __name__ == "__main__": | |
| 119 | + raise SystemExit(main()) | |
| new file mode 100644 | |||
| @@ -0,0 +1,119 @@ | |||
| 1 | +"""Commandes `regine dossier ...` (cf. contracts/cli-dossier-selection.md, specs/006). | ||
| 2 | + | ||
| 3 | +Façade fine : orchestre `regine_core.dossier.selection`, formatte le résultat, ne | ||
| 4 | +contient aucune logique métier propre (Principe VI de la constitution). Cette | ||
| 5 | +surface DOIT exister avant tout équivalent GUI (Principe CLI-first, FR-007). | ||
| 6 | +""" | ||
| 7 | + | ||
| 8 | +from __future__ import annotations | ||
| 9 | + | ||
| 10 | +import argparse | ||
| 11 | +import json | ||
| 12 | +import sys | ||
| 13 | +from pathlib import Path | ||
| 14 | + | ||
| 15 | +from regine_core.dossier.selection import ( | ||
| 16 | + FichierDejaDansSonDossierDeFormatError, | ||
| 17 | + StructureDossierInvalideError, | ||
| 18 | + promouvoir, | ||
| 19 | + regrouper, | ||
| 20 | + retrograder, | ||
| 21 | +) | ||
| 22 | + | ||
| 23 | + | ||
| 24 | +def _cmd_list(args: argparse.Namespace) -> int: | ||
| 25 | + dossier = Path(args.chemin_dossier) | ||
| 26 | + try: | ||
| 27 | + entrees = regrouper(dossier) | ||
| 28 | + except StructureDossierInvalideError as exc: | ||
| 29 | + print(str(exc), file=sys.stderr) | ||
| 30 | + return 1 | ||
| 31 | + | ||
| 32 | + if args.json: | ||
| 33 | + print( | ||
| 34 | + json.dumps( | ||
| 35 | + [ | ||
| 36 | + { | ||
| 37 | + "nom_base": entree.nom_base, | ||
| 38 | + "fichiers": [ | ||
| 39 | + {"chemin": str(f), "promu": f.parent == dossier} | ||
| 40 | + for f in entree.fichiers | ||
| 41 | + ], | ||
| 42 | + } | ||
| 43 | + for entree in entrees | ||
| 44 | + ] | ||
| 45 | + ) | ||
| 46 | + ) | ||
| 47 | + return 0 | ||
| 48 | + | ||
| 49 | + for entree in entrees: | ||
| 50 | + details = ", ".join( | ||
| 51 | + f"{f.name} ({'promu' if f.parent == dossier else 'non promu'})" for f in entree.fichiers | ||
| 52 | + ) | ||
| 53 | + print(f" {entree.nom_base} : {details}") | ||
| 54 | + return 0 | ||
| 55 | + | ||
| 56 | + | ||
| 57 | +def _cmd_promote(args: argparse.Namespace) -> int: | ||
| 58 | + dossier = Path(args.chemin_dossier) | ||
| 59 | + fichier = Path(args.chemin_fichier) | ||
| 60 | + if not fichier.is_relative_to(dossier): | ||
| 61 | + print(f"{fichier} n'est pas sous {dossier}", file=sys.stderr) | ||
| 62 | + return 1 | ||
| 63 | + | ||
| 64 | + resultat = promouvoir(dossier, fichier) | ||
| 65 | + if resultat == fichier: | ||
| 66 | + print(f"Déjà à la racine : {resultat}") | ||
| 67 | + else: | ||
| 68 | + print(f"Promu : {resultat}") | ||
| 69 | + return 0 | ||
| 70 | + | ||
| 71 | + | ||
| 72 | +def _cmd_demote(args: argparse.Namespace) -> int: | ||
| 73 | + fichier = Path(args.chemin_fichier) | ||
| 74 | + try: | ||
| 75 | + resultat = retrograder(fichier) | ||
| 76 | + except (FichierDejaDansSonDossierDeFormatError, ValueError) as exc: | ||
| 77 | + print(str(exc), file=sys.stderr) | ||
| 78 | + return 1 | ||
| 79 | + | ||
| 80 | + print(f"Rétrogradé : {resultat}") | ||
| 81 | + return 0 | ||
| 82 | + | ||
| 83 | + | ||
| 84 | +def construire_analyseur() -> argparse.ArgumentParser: | ||
| 85 | + analyseur = argparse.ArgumentParser(prog="regine") | ||
| 86 | + sous_commandes = analyseur.add_subparsers(dest="commande", required=True) | ||
| 87 | + | ||
| 88 | + dossier_parser = sous_commandes.add_parser( | ||
| 89 | + "dossier", help="Tri/culling d'un dossier de travail local" | ||
| 90 | + ) | ||
| 91 | + dossier_sous = dossier_parser.add_subparsers(dest="sous_commande", required=True) | ||
| 92 | + | ||
| 93 | + list_parser = dossier_sous.add_parser("list", help="Liste les entrées regroupées") | ||
| 94 | + list_parser.add_argument("chemin_dossier") | ||
| 95 | + list_parser.add_argument("--json", action="store_true") | ||
| 96 | + list_parser.set_defaults(func=_cmd_list) | ||
| 97 | + | ||
| 98 | + promote_parser = dossier_sous.add_parser("promote", help="Promeut un fichier à la racine") | ||
| 99 | + promote_parser.add_argument("chemin_dossier") | ||
| 100 | + promote_parser.add_argument("chemin_fichier") | ||
| 101 | + promote_parser.set_defaults(func=_cmd_promote) | ||
| 102 | + | ||
| 103 | + demote_parser = dossier_sous.add_parser( | ||
| 104 | + "demote", help="Rétrograde un fichier vers son dossier de format" | ||
| 105 | + ) | ||
| 106 | + demote_parser.add_argument("chemin_fichier") | ||
| 107 | + demote_parser.set_defaults(func=_cmd_demote) | ||
| 108 | + | ||
| 109 | + return analyseur | ||
| 110 | + | ||
| 111 | + | ||
| 112 | +def main(argv: list[str] | None = None) -> int: | ||
| 113 | + analyseur = construire_analyseur() | ||
| 114 | + args = analyseur.parse_args(argv) | ||
| 115 | + return args.func(args) | ||
| 116 | + | ||
| 117 | + | ||
| 118 | +if __name__ == "__main__": | ||
| 119 | + raise SystemExit(main()) | ||
added
packages/regine-cli/tests/contract/test_cli_archive_consultation.py +79 -0 | new file mode 100644 | ||
| @@ -0,0 +1,79 @@ | ||
| 1 | +"""Contrat de la surface CLI `regine browse`/`regine restore` (T021, | |
| 2 | +cf. contracts/cli-archive-consultation.md).""" | |
| 3 | + | |
| 4 | +from __future__ import annotations | |
| 5 | + | |
| 6 | +import json | |
| 7 | +from pathlib import Path | |
| 8 | + | |
| 9 | +from regine_cli.archive_cmd import main | |
| 10 | +from regine_core.config.context import enregistrer_chemins | |
| 11 | +from regine_core.config.db import open_context_db | |
| 12 | + | |
| 13 | + | |
| 14 | +def _configurer_archive(tmp_path: Path, chemin_archive_local: Path) -> list[str]: | |
| 15 | + """Configure un contexte de travail dont `archive_smb_path` résout, via un | |
| 16 | + `chemin_local_du_partage` monkeypatché côté module, vers `chemin_archive_local`.""" | |
| 17 | + contexte_db = tmp_path / "contexte.sqlite3" | |
| 18 | + conn = open_context_db(contexte_db) | |
| 19 | + enregistrer_chemins(conn, archive_smb_path="smb://nas.local/regine-archive") | |
| 20 | + conn.close() | |
| 21 | + return ["--contexte-db", str(contexte_db)] | |
| 22 | + | |
| 23 | + | |
| 24 | +def test_browse_sans_archive_configuree_echoue(tmp_path: Path, capsys) -> None: | |
| 25 | + code = main(["browse", "--contexte-db", str(tmp_path / "contexte.sqlite3")]) | |
| 26 | + | |
| 27 | + assert code != 0 | |
| 28 | + assert "Aucune archive configurée" in capsys.readouterr().err | |
| 29 | + | |
| 30 | + | |
| 31 | +def test_browse_et_restore_via_archive_configuree(tmp_path: Path, monkeypatch, capsys) -> None: | |
| 32 | + archive_root_local = tmp_path / "archive" | |
| 33 | + (archive_root_local / "voyage" / "2026-08_Montenegro").mkdir(parents=True) | |
| 34 | + (archive_root_local / "voyage" / "2026-08_Montenegro" / "photo.jpg").write_bytes(b"contenu") | |
| 35 | + | |
| 36 | + import regine_core.config.smb as smb_module | |
| 37 | + | |
| 38 | + monkeypatch.setattr(smb_module, "chemin_local_du_partage", lambda _c: archive_root_local) | |
| 39 | + | |
| 40 | + flags = _configurer_archive(tmp_path, archive_root_local) | |
| 41 | + | |
| 42 | + code = main(["browse", "--search", "Montenegro", "--json", *flags]) | |
| 43 | + assert code == 0 | |
| 44 | + resultats = json.loads(capsys.readouterr().out) | |
| 45 | + assert any(r["chemin_relatif"] == "voyage/2026-08_Montenegro" for r in resultats) | |
| 46 | + | |
| 47 | + destination = tmp_path / "Desktop" / "photo.jpg" | |
| 48 | + code = main( | |
| 49 | + [ | |
| 50 | + "restore", | |
| 51 | + "--chemin", | |
| 52 | + "voyage/2026-08_Montenegro/photo.jpg", | |
| 53 | + "--destination", | |
| 54 | + str(destination), | |
| 55 | + *flags, | |
| 56 | + ] | |
| 57 | + ) | |
| 58 | + assert code == 0 | |
| 59 | + assert destination.read_bytes() == b"contenu" | |
| 60 | + | |
| 61 | + | |
| 62 | +def test_restore_refuse_ecraser_contenu_different(tmp_path: Path, monkeypatch, capsys) -> None: | |
| 63 | + archive_root_local = tmp_path / "archive" | |
| 64 | + archive_root_local.mkdir() | |
| 65 | + (archive_root_local / "photo.jpg").write_bytes(b"contenu-archive") | |
| 66 | + | |
| 67 | + import regine_core.config.smb as smb_module | |
| 68 | + | |
| 69 | + monkeypatch.setattr(smb_module, "chemin_local_du_partage", lambda _c: archive_root_local) | |
| 70 | + flags = _configurer_archive(tmp_path, archive_root_local) | |
| 71 | + | |
| 72 | + destination = tmp_path / "Desktop" / "photo.jpg" | |
| 73 | + destination.parent.mkdir(parents=True) | |
| 74 | + destination.write_bytes(b"contenu-local-different") | |
| 75 | + | |
| 76 | + code = main(["restore", "--chemin", "photo.jpg", "--destination", str(destination), *flags]) | |
| 77 | + | |
| 78 | + assert code != 0 | |
| 79 | + assert destination.read_bytes() == b"contenu-local-different" | |
| new file mode 100644 | |||
| @@ -0,0 +1,79 @@ | |||
| 1 | +"""Contrat de la surface CLI `regine browse`/`regine restore` (T021, | ||
| 2 | +cf. contracts/cli-archive-consultation.md).""" | ||
| 3 | + | ||
| 4 | +from __future__ import annotations | ||
| 5 | + | ||
| 6 | +import json | ||
| 7 | +from pathlib import Path | ||
| 8 | + | ||
| 9 | +from regine_cli.archive_cmd import main | ||
| 10 | +from regine_core.config.context import enregistrer_chemins | ||
| 11 | +from regine_core.config.db import open_context_db | ||
| 12 | + | ||
| 13 | + | ||
| 14 | +def _configurer_archive(tmp_path: Path, chemin_archive_local: Path) -> list[str]: | ||
| 15 | + """Configure un contexte de travail dont `archive_smb_path` résout, via un | ||
| 16 | + `chemin_local_du_partage` monkeypatché côté module, vers `chemin_archive_local`.""" | ||
| 17 | + contexte_db = tmp_path / "contexte.sqlite3" | ||
| 18 | + conn = open_context_db(contexte_db) | ||
| 19 | + enregistrer_chemins(conn, archive_smb_path="smb://nas.local/regine-archive") | ||
| 20 | + conn.close() | ||
| 21 | + return ["--contexte-db", str(contexte_db)] | ||
| 22 | + | ||
| 23 | + | ||
| 24 | +def test_browse_sans_archive_configuree_echoue(tmp_path: Path, capsys) -> None: | ||
| 25 | + code = main(["browse", "--contexte-db", str(tmp_path / "contexte.sqlite3")]) | ||
| 26 | + | ||
| 27 | + assert code != 0 | ||
| 28 | + assert "Aucune archive configurée" in capsys.readouterr().err | ||
| 29 | + | ||
| 30 | + | ||
| 31 | +def test_browse_et_restore_via_archive_configuree(tmp_path: Path, monkeypatch, capsys) -> None: | ||
| 32 | + archive_root_local = tmp_path / "archive" | ||
| 33 | + (archive_root_local / "voyage" / "2026-08_Montenegro").mkdir(parents=True) | ||
| 34 | + (archive_root_local / "voyage" / "2026-08_Montenegro" / "photo.jpg").write_bytes(b"contenu") | ||
| 35 | + | ||
| 36 | + import regine_core.config.smb as smb_module | ||
| 37 | + | ||
| 38 | + monkeypatch.setattr(smb_module, "chemin_local_du_partage", lambda _c: archive_root_local) | ||
| 39 | + | ||
| 40 | + flags = _configurer_archive(tmp_path, archive_root_local) | ||
| 41 | + | ||
| 42 | + code = main(["browse", "--search", "Montenegro", "--json", *flags]) | ||
| 43 | + assert code == 0 | ||
| 44 | + resultats = json.loads(capsys.readouterr().out) | ||
| 45 | + assert any(r["chemin_relatif"] == "voyage/2026-08_Montenegro" for r in resultats) | ||
| 46 | + | ||
| 47 | + destination = tmp_path / "Desktop" / "photo.jpg" | ||
| 48 | + code = main( | ||
| 49 | + [ | ||
| 50 | + "restore", | ||
| 51 | + "--chemin", | ||
| 52 | + "voyage/2026-08_Montenegro/photo.jpg", | ||
| 53 | + "--destination", | ||
| 54 | + str(destination), | ||
| 55 | + *flags, | ||
| 56 | + ] | ||
| 57 | + ) | ||
| 58 | + assert code == 0 | ||
| 59 | + assert destination.read_bytes() == b"contenu" | ||
| 60 | + | ||
| 61 | + | ||
| 62 | +def test_restore_refuse_ecraser_contenu_different(tmp_path: Path, monkeypatch, capsys) -> None: | ||
| 63 | + archive_root_local = tmp_path / "archive" | ||
| 64 | + archive_root_local.mkdir() | ||
| 65 | + (archive_root_local / "photo.jpg").write_bytes(b"contenu-archive") | ||
| 66 | + | ||
| 67 | + import regine_core.config.smb as smb_module | ||
| 68 | + | ||
| 69 | + monkeypatch.setattr(smb_module, "chemin_local_du_partage", lambda _c: archive_root_local) | ||
| 70 | + flags = _configurer_archive(tmp_path, archive_root_local) | ||
| 71 | + | ||
| 72 | + destination = tmp_path / "Desktop" / "photo.jpg" | ||
| 73 | + destination.parent.mkdir(parents=True) | ||
| 74 | + destination.write_bytes(b"contenu-local-different") | ||
| 75 | + | ||
| 76 | + code = main(["restore", "--chemin", "photo.jpg", "--destination", str(destination), *flags]) | ||
| 77 | + | ||
| 78 | + assert code != 0 | ||
| 79 | + assert destination.read_bytes() == b"contenu-local-different" | ||
added
packages/regine-cli/tests/contract/test_cli_dossier.py +93 -0 | new file mode 100644 | ||
| @@ -0,0 +1,93 @@ | ||
| 1 | +"""Contrat de la surface CLI `regine dossier` (cf. contracts/cli-dossier-selection.md, T012).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import json | |
| 6 | +from pathlib import Path | |
| 7 | + | |
| 8 | +from regine_cli.dossier_cmd import main | |
| 9 | + | |
| 10 | + | |
| 11 | +def test_list_regroupe_une_paire_raw_jpeg(tmp_path: Path, capsys) -> None: | |
| 12 | + (tmp_path / "raw").mkdir() | |
| 13 | + (tmp_path / "jpeg").mkdir() | |
| 14 | + (tmp_path / "raw" / "photo.RAF").write_bytes(b"raw") | |
| 15 | + (tmp_path / "jpeg" / "photo.JPG").write_bytes(b"jpeg") | |
| 16 | + | |
| 17 | + code = main(["dossier", "list", str(tmp_path), "--json"]) | |
| 18 | + | |
| 19 | + assert code == 0 | |
| 20 | + entrees = json.loads(capsys.readouterr().out) | |
| 21 | + assert len(entrees) == 1 | |
| 22 | + assert len(entrees[0]["fichiers"]) == 2 | |
| 23 | + assert all(f["promu"] is False for f in entrees[0]["fichiers"]) | |
| 24 | + | |
| 25 | + | |
| 26 | +def test_list_structure_invalide_echoue(tmp_path: Path, capsys) -> None: | |
| 27 | + (tmp_path / "notes.txt").write_bytes(b"rien") | |
| 28 | + | |
| 29 | + code = main(["dossier", "list", str(tmp_path)]) | |
| 30 | + | |
| 31 | + assert code != 0 | |
| 32 | + assert capsys.readouterr().err | |
| 33 | + | |
| 34 | + | |
| 35 | +def test_promote_puis_list_reflete_la_promotion(tmp_path: Path, capsys) -> None: | |
| 36 | + (tmp_path / "raw").mkdir() | |
| 37 | + fichier = tmp_path / "raw" / "photo.RAF" | |
| 38 | + fichier.write_bytes(b"raw") | |
| 39 | + | |
| 40 | + code = main(["dossier", "promote", str(tmp_path), str(fichier)]) | |
| 41 | + assert code == 0 | |
| 42 | + assert "Promu" in capsys.readouterr().out | |
| 43 | + assert (tmp_path / "photo.RAF").exists() | |
| 44 | + | |
| 45 | + code = main(["dossier", "list", str(tmp_path), "--json"]) | |
| 46 | + entrees = json.loads(capsys.readouterr().out) | |
| 47 | + assert entrees[0]["fichiers"][0]["promu"] is True | |
| 48 | + | |
| 49 | + | |
| 50 | +def test_promote_idempotent_si_deja_a_la_racine(tmp_path: Path, capsys) -> None: | |
| 51 | + fichier = tmp_path / "photo.RAF" | |
| 52 | + fichier.write_bytes(b"raw") | |
| 53 | + | |
| 54 | + code = main(["dossier", "promote", str(tmp_path), str(fichier)]) | |
| 55 | + | |
| 56 | + assert code == 0 | |
| 57 | + assert "Déjà à la racine" in capsys.readouterr().out | |
| 58 | + | |
| 59 | + | |
| 60 | +def test_promote_fichier_hors_du_dossier_echoue(tmp_path: Path, capsys) -> None: | |
| 61 | + autre = tmp_path / "ailleurs" | |
| 62 | + autre.mkdir() | |
| 63 | + fichier = autre / "photo.RAF" | |
| 64 | + fichier.write_bytes(b"raw") | |
| 65 | + dossier = tmp_path / "dossier" | |
| 66 | + dossier.mkdir() | |
| 67 | + | |
| 68 | + code = main(["dossier", "promote", str(dossier), str(fichier)]) | |
| 69 | + | |
| 70 | + assert code != 0 | |
| 71 | + assert "n'est pas sous" in capsys.readouterr().err | |
| 72 | + | |
| 73 | + | |
| 74 | +def test_demote_deplace_vers_le_dossier_de_format(tmp_path: Path, capsys) -> None: | |
| 75 | + fichier = tmp_path / "photo.RAF" | |
| 76 | + fichier.write_bytes(b"raw") | |
| 77 | + | |
| 78 | + code = main(["dossier", "demote", str(fichier)]) | |
| 79 | + | |
| 80 | + assert code == 0 | |
| 81 | + assert "Rétrogradé" in capsys.readouterr().out | |
| 82 | + assert (tmp_path / "raw" / "photo.RAF").exists() | |
| 83 | + | |
| 84 | + | |
| 85 | +def test_demote_deja_dans_son_dossier_de_format_echoue(tmp_path: Path, capsys) -> None: | |
| 86 | + (tmp_path / "raw").mkdir() | |
| 87 | + fichier = tmp_path / "raw" / "photo.RAF" | |
| 88 | + fichier.write_bytes(b"raw") | |
| 89 | + | |
| 90 | + code = main(["dossier", "demote", str(fichier)]) | |
| 91 | + | |
| 92 | + assert code != 0 | |
| 93 | + assert capsys.readouterr().err | |
| new file mode 100644 | |||
| @@ -0,0 +1,93 @@ | |||
| 1 | +"""Contrat de la surface CLI `regine dossier` (cf. contracts/cli-dossier-selection.md, T012).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import json | ||
| 6 | +from pathlib import Path | ||
| 7 | + | ||
| 8 | +from regine_cli.dossier_cmd import main | ||
| 9 | + | ||
| 10 | + | ||
| 11 | +def test_list_regroupe_une_paire_raw_jpeg(tmp_path: Path, capsys) -> None: | ||
| 12 | + (tmp_path / "raw").mkdir() | ||
| 13 | + (tmp_path / "jpeg").mkdir() | ||
| 14 | + (tmp_path / "raw" / "photo.RAF").write_bytes(b"raw") | ||
| 15 | + (tmp_path / "jpeg" / "photo.JPG").write_bytes(b"jpeg") | ||
| 16 | + | ||
| 17 | + code = main(["dossier", "list", str(tmp_path), "--json"]) | ||
| 18 | + | ||
| 19 | + assert code == 0 | ||
| 20 | + entrees = json.loads(capsys.readouterr().out) | ||
| 21 | + assert len(entrees) == 1 | ||
| 22 | + assert len(entrees[0]["fichiers"]) == 2 | ||
| 23 | + assert all(f["promu"] is False for f in entrees[0]["fichiers"]) | ||
| 24 | + | ||
| 25 | + | ||
| 26 | +def test_list_structure_invalide_echoue(tmp_path: Path, capsys) -> None: | ||
| 27 | + (tmp_path / "notes.txt").write_bytes(b"rien") | ||
| 28 | + | ||
| 29 | + code = main(["dossier", "list", str(tmp_path)]) | ||
| 30 | + | ||
| 31 | + assert code != 0 | ||
| 32 | + assert capsys.readouterr().err | ||
| 33 | + | ||
| 34 | + | ||
| 35 | +def test_promote_puis_list_reflete_la_promotion(tmp_path: Path, capsys) -> None: | ||
| 36 | + (tmp_path / "raw").mkdir() | ||
| 37 | + fichier = tmp_path / "raw" / "photo.RAF" | ||
| 38 | + fichier.write_bytes(b"raw") | ||
| 39 | + | ||
| 40 | + code = main(["dossier", "promote", str(tmp_path), str(fichier)]) | ||
| 41 | + assert code == 0 | ||
| 42 | + assert "Promu" in capsys.readouterr().out | ||
| 43 | + assert (tmp_path / "photo.RAF").exists() | ||
| 44 | + | ||
| 45 | + code = main(["dossier", "list", str(tmp_path), "--json"]) | ||
| 46 | + entrees = json.loads(capsys.readouterr().out) | ||
| 47 | + assert entrees[0]["fichiers"][0]["promu"] is True | ||
| 48 | + | ||
| 49 | + | ||
| 50 | +def test_promote_idempotent_si_deja_a_la_racine(tmp_path: Path, capsys) -> None: | ||
| 51 | + fichier = tmp_path / "photo.RAF" | ||
| 52 | + fichier.write_bytes(b"raw") | ||
| 53 | + | ||
| 54 | + code = main(["dossier", "promote", str(tmp_path), str(fichier)]) | ||
| 55 | + | ||
| 56 | + assert code == 0 | ||
| 57 | + assert "Déjà à la racine" in capsys.readouterr().out | ||
| 58 | + | ||
| 59 | + | ||
| 60 | +def test_promote_fichier_hors_du_dossier_echoue(tmp_path: Path, capsys) -> None: | ||
| 61 | + autre = tmp_path / "ailleurs" | ||
| 62 | + autre.mkdir() | ||
| 63 | + fichier = autre / "photo.RAF" | ||
| 64 | + fichier.write_bytes(b"raw") | ||
| 65 | + dossier = tmp_path / "dossier" | ||
| 66 | + dossier.mkdir() | ||
| 67 | + | ||
| 68 | + code = main(["dossier", "promote", str(dossier), str(fichier)]) | ||
| 69 | + | ||
| 70 | + assert code != 0 | ||
| 71 | + assert "n'est pas sous" in capsys.readouterr().err | ||
| 72 | + | ||
| 73 | + | ||
| 74 | +def test_demote_deplace_vers_le_dossier_de_format(tmp_path: Path, capsys) -> None: | ||
| 75 | + fichier = tmp_path / "photo.RAF" | ||
| 76 | + fichier.write_bytes(b"raw") | ||
| 77 | + | ||
| 78 | + code = main(["dossier", "demote", str(fichier)]) | ||
| 79 | + | ||
| 80 | + assert code == 0 | ||
| 81 | + assert "Rétrogradé" in capsys.readouterr().out | ||
| 82 | + assert (tmp_path / "raw" / "photo.RAF").exists() | ||
| 83 | + | ||
| 84 | + | ||
| 85 | +def test_demote_deja_dans_son_dossier_de_format_echoue(tmp_path: Path, capsys) -> None: | ||
| 86 | + (tmp_path / "raw").mkdir() | ||
| 87 | + fichier = tmp_path / "raw" / "photo.RAF" | ||
| 88 | + fichier.write_bytes(b"raw") | ||
| 89 | + | ||
| 90 | + code = main(["dossier", "demote", str(fichier)]) | ||
| 91 | + | ||
| 92 | + assert code != 0 | ||
| 93 | + assert capsys.readouterr().err | ||
added
packages/regine-core/src/regine_core/archive/consultation.py +75 -0 | new file mode 100644 | ||
| @@ -0,0 +1,75 @@ | ||
| 1 | +"""Consultation en lecture seule de l'archive et restauration ciblée (specs/006).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import shutil | |
| 6 | +from dataclasses import dataclass | |
| 7 | +from pathlib import Path | |
| 8 | + | |
| 9 | +from regine_core.archive.checkout import IntegriteCopieError | |
| 10 | +from regine_core.dossier.selection import dossier_de_format | |
| 11 | +from regine_core.integrity.hash import hash_fichier_entier | |
| 12 | + | |
| 13 | + | |
| 14 | +class RestaurationCollisionError(Exception): | |
| 15 | + """`chemin_destination` existe déjà avec un contenu différent (FR-012) — jamais | |
| 16 | + d'écrasement silencieux, même principe que `regine_core.import_carte.push. | |
| 17 | + CollisionNomArchiveError`.""" | |
| 18 | + | |
| 19 | + | |
| 20 | +@dataclass(frozen=True) | |
| 21 | +class ResultatRecherche: | |
| 22 | + """Un dossier ou fichier maître trouvé dans l'archive (US2, lecture seule).""" | |
| 23 | + | |
| 24 | + chemin_relatif: Path | |
| 25 | + est_dossier: bool | |
| 26 | + | |
| 27 | + | |
| 28 | +def rechercher(archive_root_local: Path, requete: str | None) -> list[ResultatRecherche]: | |
| 29 | + """Parcourt `archive_root_local` en lecture seule (FR-008) et retourne les | |
| 30 | + dossiers/fichiers maîtres dont le nom correspond à `requete` (nom de dossier, | |
| 31 | + date, ou texte de titre — tous déjà encodés dans le nom, cf. `specs/001` | |
| 32 | + convention `AAAA-MM-JJ_Titre`) ; `requete=None` liste tout. Ne modifie rien, | |
| 33 | + n'ouvre aucun manifeste, ne pose aucun verrou. | |
| 34 | + """ | |
| 35 | + resultats: list[ResultatRecherche] = [] | |
| 36 | + for chemin in sorted(archive_root_local.rglob("*")): | |
| 37 | + if chemin.name.startswith(".regine-manifest"): | |
| 38 | + continue | |
| 39 | + if requete and requete.lower() not in chemin.name.lower(): | |
| 40 | + continue | |
| 41 | + if chemin.is_dir() or dossier_de_format(chemin.suffix) is not None: | |
| 42 | + resultats.append( | |
| 43 | + ResultatRecherche( | |
| 44 | + chemin_relatif=chemin.relative_to(archive_root_local), | |
| 45 | + est_dossier=chemin.is_dir(), | |
| 46 | + ) | |
| 47 | + ) | |
| 48 | + return resultats | |
| 49 | + | |
| 50 | + | |
| 51 | +def restaurer(chemin_source: Path, chemin_destination: Path) -> None: | |
| 52 | + """Copie vérifiée par somme de contrôle d'un fichier de l'archive vers un | |
| 53 | + emplacement local (US2 FR-009), sans checkout ni verrou du dossier entier. | |
| 54 | + | |
| 55 | + Un doublon de contenu identique déjà présent à `chemin_destination` est ignoré | |
| 56 | + silencieusement ; un contenu différent lève `RestaurationCollisionError` plutôt | |
| 57 | + que d'écraser (FR-012). | |
| 58 | + """ | |
| 59 | + if not chemin_source.is_file(): | |
| 60 | + raise FileNotFoundError(f"{chemin_source} introuvable dans l'archive") | |
| 61 | + | |
| 62 | + hash_source = hash_fichier_entier(chemin_source) | |
| 63 | + | |
| 64 | + if chemin_destination.exists(): | |
| 65 | + if hash_fichier_entier(chemin_destination) == hash_source: | |
| 66 | + return | |
| 67 | + raise RestaurationCollisionError( | |
| 68 | + f"{chemin_destination} existe déjà avec un contenu différent — " | |
| 69 | + "refus d'écraser silencieusement" | |
| 70 | + ) | |
| 71 | + | |
| 72 | + chemin_destination.parent.mkdir(parents=True, exist_ok=True) | |
| 73 | + shutil.copy2(chemin_source, chemin_destination) | |
| 74 | + if hash_fichier_entier(chemin_destination) != hash_source: | |
| 75 | + raise IntegriteCopieError(f"Échec de vérification d'intégrité pour {chemin_destination}") | |
| new file mode 100644 | |||
| @@ -0,0 +1,75 @@ | |||
| 1 | +"""Consultation en lecture seule de l'archive et restauration ciblée (specs/006).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import shutil | ||
| 6 | +from dataclasses import dataclass | ||
| 7 | +from pathlib import Path | ||
| 8 | + | ||
| 9 | +from regine_core.archive.checkout import IntegriteCopieError | ||
| 10 | +from regine_core.dossier.selection import dossier_de_format | ||
| 11 | +from regine_core.integrity.hash import hash_fichier_entier | ||
| 12 | + | ||
| 13 | + | ||
| 14 | +class RestaurationCollisionError(Exception): | ||
| 15 | + """`chemin_destination` existe déjà avec un contenu différent (FR-012) — jamais | ||
| 16 | + d'écrasement silencieux, même principe que `regine_core.import_carte.push. | ||
| 17 | + CollisionNomArchiveError`.""" | ||
| 18 | + | ||
| 19 | + | ||
| 20 | +@dataclass(frozen=True) | ||
| 21 | +class ResultatRecherche: | ||
| 22 | + """Un dossier ou fichier maître trouvé dans l'archive (US2, lecture seule).""" | ||
| 23 | + | ||
| 24 | + chemin_relatif: Path | ||
| 25 | + est_dossier: bool | ||
| 26 | + | ||
| 27 | + | ||
| 28 | +def rechercher(archive_root_local: Path, requete: str | None) -> list[ResultatRecherche]: | ||
| 29 | + """Parcourt `archive_root_local` en lecture seule (FR-008) et retourne les | ||
| 30 | + dossiers/fichiers maîtres dont le nom correspond à `requete` (nom de dossier, | ||
| 31 | + date, ou texte de titre — tous déjà encodés dans le nom, cf. `specs/001` | ||
| 32 | + convention `AAAA-MM-JJ_Titre`) ; `requete=None` liste tout. Ne modifie rien, | ||
| 33 | + n'ouvre aucun manifeste, ne pose aucun verrou. | ||
| 34 | + """ | ||
| 35 | + resultats: list[ResultatRecherche] = [] | ||
| 36 | + for chemin in sorted(archive_root_local.rglob("*")): | ||
| 37 | + if chemin.name.startswith(".regine-manifest"): | ||
| 38 | + continue | ||
| 39 | + if requete and requete.lower() not in chemin.name.lower(): | ||
| 40 | + continue | ||
| 41 | + if chemin.is_dir() or dossier_de_format(chemin.suffix) is not None: | ||
| 42 | + resultats.append( | ||
| 43 | + ResultatRecherche( | ||
| 44 | + chemin_relatif=chemin.relative_to(archive_root_local), | ||
| 45 | + est_dossier=chemin.is_dir(), | ||
| 46 | + ) | ||
| 47 | + ) | ||
| 48 | + return resultats | ||
| 49 | + | ||
| 50 | + | ||
| 51 | +def restaurer(chemin_source: Path, chemin_destination: Path) -> None: | ||
| 52 | + """Copie vérifiée par somme de contrôle d'un fichier de l'archive vers un | ||
| 53 | + emplacement local (US2 FR-009), sans checkout ni verrou du dossier entier. | ||
| 54 | + | ||
| 55 | + Un doublon de contenu identique déjà présent à `chemin_destination` est ignoré | ||
| 56 | + silencieusement ; un contenu différent lève `RestaurationCollisionError` plutôt | ||
| 57 | + que d'écraser (FR-012). | ||
| 58 | + """ | ||
| 59 | + if not chemin_source.is_file(): | ||
| 60 | + raise FileNotFoundError(f"{chemin_source} introuvable dans l'archive") | ||
| 61 | + | ||
| 62 | + hash_source = hash_fichier_entier(chemin_source) | ||
| 63 | + | ||
| 64 | + if chemin_destination.exists(): | ||
| 65 | + if hash_fichier_entier(chemin_destination) == hash_source: | ||
| 66 | + return | ||
| 67 | + raise RestaurationCollisionError( | ||
| 68 | + f"{chemin_destination} existe déjà avec un contenu différent — " | ||
| 69 | + "refus d'écraser silencieusement" | ||
| 70 | + ) | ||
| 71 | + | ||
| 72 | + chemin_destination.parent.mkdir(parents=True, exist_ok=True) | ||
| 73 | + shutil.copy2(chemin_source, chemin_destination) | ||
| 74 | + if hash_fichier_entier(chemin_destination) != hash_source: | ||
| 75 | + raise IntegriteCopieError(f"Échec de vérification d'intégrité pour {chemin_destination}") | ||
modified
packages/regine-core/src/regine_core/config/smb.py +19 -0 | @@ -51,3 +51,22 @@ def assurer_disponibilite( | ||
| 51 | 51 | if declencher: |
| 52 | 52 | declencher_montage(archive_smb_path) |
| 53 | 53 | return False |
| 54 | + | |
| 55 | + | |
| 56 | +def resoudre_archive_locale(conn: sqlite3.Connection, *, declencher: bool = True) -> Path | None: | |
| 57 | + """Résout et vérifie l'accès au point de montage local de l'archive configurée | |
| 58 | + (`specs/003`), en un seul appel réutilisable par toute façade (CLI, GUI — | |
| 59 | + Principe VI, pas de logique dupliquée entre elles, cf. specs/006). | |
| 60 | + | |
| 61 | + Retourne `None` (jamais d'exception) si aucune archive n'est configurée ou si | |
| 62 | + elle est inaccessible (montage natif déclenché le cas échéant, FR-011) — | |
| 63 | + l'appelant décide comment l'annoncer (message CLI, boîte de dialogue GUI...). | |
| 64 | + """ | |
| 65 | + from regine_core.config.context import charger_contexte # noqa: PLC0415 | |
| 66 | + | |
| 67 | + contexte = charger_contexte(conn) | |
| 68 | + if contexte.archive_smb_path is None: | |
| 69 | + return None | |
| 70 | + if not assurer_disponibilite(conn, contexte.archive_smb_path, declencher=declencher): | |
| 71 | + return None | |
| 72 | + return chemin_local_du_partage(contexte.archive_smb_path) | |
| @@ -51,3 +51,22 @@ def assurer_disponibilite( | |||
| 51 | if declencher: | 51 | if declencher: |
| 52 | declencher_montage(archive_smb_path) | 52 | declencher_montage(archive_smb_path) |
| 53 | return False | 53 | return False |
| 54 | + | ||
| 55 | + | ||
| 56 | +def resoudre_archive_locale(conn: sqlite3.Connection, *, declencher: bool = True) -> Path | None: | ||
| 57 | + """Résout et vérifie l'accès au point de montage local de l'archive configurée | ||
| 58 | + (`specs/003`), en un seul appel réutilisable par toute façade (CLI, GUI — | ||
| 59 | + Principe VI, pas de logique dupliquée entre elles, cf. specs/006). | ||
| 60 | + | ||
| 61 | + Retourne `None` (jamais d'exception) si aucune archive n'est configurée ou si | ||
| 62 | + elle est inaccessible (montage natif déclenché le cas échéant, FR-011) — | ||
| 63 | + l'appelant décide comment l'annoncer (message CLI, boîte de dialogue GUI...). | ||
| 64 | + """ | ||
| 65 | + from regine_core.config.context import charger_contexte # noqa: PLC0415 | ||
| 66 | + | ||
| 67 | + contexte = charger_contexte(conn) | ||
| 68 | + if contexte.archive_smb_path is None: | ||
| 69 | + return None | ||
| 70 | + if not assurer_disponibilite(conn, contexte.archive_smb_path, declencher=declencher): | ||
| 71 | + return None | ||
| 72 | + return chemin_local_du_partage(contexte.archive_smb_path) | ||
added
packages/regine-core/src/regine_core/dossier/selection.py +138 -0 | new file mode 100644 | ||
| @@ -0,0 +1,138 @@ | ||
| 1 | +"""Racine de sélection d'un dossier : regroupement RAW+JPEG, promotion/rétrogradation (specs/006). | |
| 2 | + | |
| 3 | +À ne pas confondre avec `regine_core.dossier.root` (répertoire racine d'archive | |
| 4 | +année/catégorie, specs/004) — deux sens différents du mot « racine », cf. | |
| 5 | +`docs/lexique.md` § Point de vigilance. | |
| 6 | +""" | |
| 7 | + | |
| 8 | +from __future__ import annotations | |
| 9 | + | |
| 10 | +from dataclasses import dataclass | |
| 11 | +from pathlib import Path | |
| 12 | + | |
| 13 | +from regine_core.import_carte.copie import EXTENSIONS_MAITRES | |
| 14 | + | |
| 15 | +#: RAW propriétaires + DNG : catégorie fonctionnelle unique `raw/`, pas une extension | |
| 16 | +#: par dossier (cf. docs/archivage-photo-elements-cles.md section 10). | |
| 17 | +_EXTENSIONS_RAW = {"raf", "cr2", "cr3", "nef", "arw", "orf", "rw2", "pef", "srw", "dng"} | |
| 18 | +_EXTENSIONS_TIFF = {"tiff", "tif"} | |
| 19 | +_EXTENSIONS_JPEG = {"jpg", "jpeg"} | |
| 20 | + | |
| 21 | + | |
| 22 | +def dossier_de_format(extension: str) -> str | None: | |
| 23 | + """Dossier de format attendu pour `extension` (sans le point, insensible à la casse). | |
| 24 | + | |
| 25 | + `raw/` regroupe toutes les extensions RAW propriétaires et le DNG ; `tiff/` et | |
| 26 | + `jpeg/` correspondent chacun à une extension précise. Un format maître plus | |
| 27 | + marginal (ex. `bmp`) obtient son propre dossier nommé d'après lui, créé à la | |
| 28 | + demande — jamais une liste figée à l'avance (section 10 des notes d'archivage). | |
| 29 | + Retourne `None` pour une extension associée (sidecar) ou non reconnue comme | |
| 30 | + fichier maître. | |
| 31 | + """ | |
| 32 | + ext = extension.lstrip(".").lower() | |
| 33 | + if ext in _EXTENSIONS_RAW: | |
| 34 | + return "raw" | |
| 35 | + if ext in _EXTENSIONS_TIFF: | |
| 36 | + return "tiff" | |
| 37 | + if ext in _EXTENSIONS_JPEG: | |
| 38 | + return "jpeg" | |
| 39 | + if ext in EXTENSIONS_MAITRES: | |
| 40 | + return ext | |
| 41 | + return None | |
| 42 | + | |
| 43 | + | |
| 44 | +class StructureDossierInvalideError(Exception): | |
| 45 | + """`dossier` n'a pas la structure attendue d'un dossier Régine (FR-013) : | |
| 46 | + aucun fichier maître reconnu, ni dans un dossier de format ni à sa racine.""" | |
| 47 | + | |
| 48 | + | |
| 49 | +class FichierDejaDansSonDossierDeFormatError(Exception): | |
| 50 | + """`fichier` n'est actuellement à aucune racine (rien à rétrograder).""" | |
| 51 | + | |
| 52 | + | |
| 53 | +@dataclass(frozen=True) | |
| 54 | +class EntreeRegroupee: | |
| 55 | + """Représentation à l'écran d'une capture (cf. data-model.md § Entrée regroupée). | |
| 56 | + | |
| 57 | + Regroupe tous les fichiers maîtres partageant le même nom de base — le cas | |
| 58 | + RAW+JPEG jumeau (FR-002) n'est qu'un cas particulier d'un principe plus général : | |
| 59 | + un fichier maître sans jumeau (RAW seul, JPEG seul, scan TIFF) forme une entrée | |
| 60 | + à un seul membre, pas un cas à part. | |
| 61 | + """ | |
| 62 | + | |
| 63 | + nom_base: str | |
| 64 | + fichiers: tuple[Path, ...] | |
| 65 | + | |
| 66 | + | |
| 67 | +def _lister_fichiers_maitres(dossier: Path) -> list[Path]: | |
| 68 | + return [ | |
| 69 | + chemin | |
| 70 | + for chemin in dossier.rglob("*") | |
| 71 | + if chemin.is_file() | |
| 72 | + and not chemin.name.startswith(".regine-manifest") | |
| 73 | + and dossier_de_format(chemin.suffix) is not None | |
| 74 | + ] | |
| 75 | + | |
| 76 | + | |
| 77 | +def regrouper(dossier: Path) -> list[EntreeRegroupee]: | |
| 78 | + """Regroupe les fichiers maîtres d'un dossier de travail local par capture (FR-002). | |
| 79 | + | |
| 80 | + Lève `StructureDossierInvalideError` si `dossier` ne contient aucun fichier | |
| 81 | + maître reconnu (FR-013) — jamais une liste vide silencieuse dans ce cas. | |
| 82 | + """ | |
| 83 | + fichiers = _lister_fichiers_maitres(dossier) | |
| 84 | + if not fichiers: | |
| 85 | + raise StructureDossierInvalideError( | |
| 86 | + f"{dossier} ne contient aucun fichier maître reconnu " | |
| 87 | + "(structure de dossier Régine attendue : raw/, jpeg/, tiff/... ou racine)" | |
| 88 | + ) | |
| 89 | + | |
| 90 | + groupes: dict[str, list[Path]] = {} | |
| 91 | + for chemin in sorted(fichiers): | |
| 92 | + nom_base = chemin.name.split(".")[0] | |
| 93 | + groupes.setdefault(nom_base, []).append(chemin) | |
| 94 | + | |
| 95 | + return [ | |
| 96 | + EntreeRegroupee(nom_base=nom_base, fichiers=tuple(membres)) | |
| 97 | + for nom_base, membres in sorted(groupes.items()) | |
| 98 | + ] | |
| 99 | + | |
| 100 | + | |
| 101 | +def promouvoir(dossier: Path, fichier: Path) -> Path: | |
| 102 | + """Promeut `fichier` à la racine de `dossier` (FR-003). | |
| 103 | + | |
| 104 | + `dossier` est un argument explicite plutôt qu'inféré du chemin de `fichier` : | |
| 105 | + rien dans la seule position d'un fichier (« hors de son dossier de format ») ne | |
| 106 | + permet de distinguer « déjà promu à la racine d'une étape » de « déjà promu à la | |
| 107 | + racine d'un dossier parent » — ce sont deux niveaux réels et distincts (section | |
| 108 | + 10 des notes d'archivage, « promotion en deux temps »), donc deux gestes | |
| 109 | + explicites, chacun avec sa propre cible. Idempotent : si `fichier` est déjà à la | |
| 110 | + racine de `dossier`, ne fait rien et retourne son chemin actuel. | |
| 111 | + """ | |
| 112 | + if fichier.parent == dossier: | |
| 113 | + return fichier | |
| 114 | + | |
| 115 | + destination = dossier / fichier.name | |
| 116 | + fichier.rename(destination) | |
| 117 | + return destination | |
| 118 | + | |
| 119 | + | |
| 120 | +def retrograder(fichier: Path) -> Path: | |
| 121 | + """Rétrograde `fichier` de sa racine vers son dossier de format d'origine (FR-003). | |
| 122 | + | |
| 123 | + Lève `FichierDejaDansSonDossierDeFormatError` si `fichier` est déjà dans son | |
| 124 | + dossier de format (rien à rétrograder). | |
| 125 | + """ | |
| 126 | + format_attendu = dossier_de_format(fichier.suffix) | |
| 127 | + if format_attendu is None: | |
| 128 | + raise ValueError(f"{fichier} n'est pas un fichier maître reconnu") | |
| 129 | + if fichier.parent.name == format_attendu: | |
| 130 | + raise FichierDejaDansSonDossierDeFormatError( | |
| 131 | + f"{fichier} est déjà dans son dossier de format ({format_attendu}/)" | |
| 132 | + ) | |
| 133 | + | |
| 134 | + dossier_format = fichier.parent / format_attendu | |
| 135 | + dossier_format.mkdir(exist_ok=True) | |
| 136 | + destination = dossier_format / fichier.name | |
| 137 | + fichier.rename(destination) | |
| 138 | + return destination | |
| new file mode 100644 | |||
| @@ -0,0 +1,138 @@ | |||
| 1 | +"""Racine de sélection d'un dossier : regroupement RAW+JPEG, promotion/rétrogradation (specs/006). | ||
| 2 | + | ||
| 3 | +À ne pas confondre avec `regine_core.dossier.root` (répertoire racine d'archive | ||
| 4 | +année/catégorie, specs/004) — deux sens différents du mot « racine », cf. | ||
| 5 | +`docs/lexique.md` § Point de vigilance. | ||
| 6 | +""" | ||
| 7 | + | ||
| 8 | +from __future__ import annotations | ||
| 9 | + | ||
| 10 | +from dataclasses import dataclass | ||
| 11 | +from pathlib import Path | ||
| 12 | + | ||
| 13 | +from regine_core.import_carte.copie import EXTENSIONS_MAITRES | ||
| 14 | + | ||
| 15 | +#: RAW propriétaires + DNG : catégorie fonctionnelle unique `raw/`, pas une extension | ||
| 16 | +#: par dossier (cf. docs/archivage-photo-elements-cles.md section 10). | ||
| 17 | +_EXTENSIONS_RAW = {"raf", "cr2", "cr3", "nef", "arw", "orf", "rw2", "pef", "srw", "dng"} | ||
| 18 | +_EXTENSIONS_TIFF = {"tiff", "tif"} | ||
| 19 | +_EXTENSIONS_JPEG = {"jpg", "jpeg"} | ||
| 20 | + | ||
| 21 | + | ||
| 22 | +def dossier_de_format(extension: str) -> str | None: | ||
| 23 | + """Dossier de format attendu pour `extension` (sans le point, insensible à la casse). | ||
| 24 | + | ||
| 25 | + `raw/` regroupe toutes les extensions RAW propriétaires et le DNG ; `tiff/` et | ||
| 26 | + `jpeg/` correspondent chacun à une extension précise. Un format maître plus | ||
| 27 | + marginal (ex. `bmp`) obtient son propre dossier nommé d'après lui, créé à la | ||
| 28 | + demande — jamais une liste figée à l'avance (section 10 des notes d'archivage). | ||
| 29 | + Retourne `None` pour une extension associée (sidecar) ou non reconnue comme | ||
| 30 | + fichier maître. | ||
| 31 | + """ | ||
| 32 | + ext = extension.lstrip(".").lower() | ||
| 33 | + if ext in _EXTENSIONS_RAW: | ||
| 34 | + return "raw" | ||
| 35 | + if ext in _EXTENSIONS_TIFF: | ||
| 36 | + return "tiff" | ||
| 37 | + if ext in _EXTENSIONS_JPEG: | ||
| 38 | + return "jpeg" | ||
| 39 | + if ext in EXTENSIONS_MAITRES: | ||
| 40 | + return ext | ||
| 41 | + return None | ||
| 42 | + | ||
| 43 | + | ||
| 44 | +class StructureDossierInvalideError(Exception): | ||
| 45 | + """`dossier` n'a pas la structure attendue d'un dossier Régine (FR-013) : | ||
| 46 | + aucun fichier maître reconnu, ni dans un dossier de format ni à sa racine.""" | ||
| 47 | + | ||
| 48 | + | ||
| 49 | +class FichierDejaDansSonDossierDeFormatError(Exception): | ||
| 50 | + """`fichier` n'est actuellement à aucune racine (rien à rétrograder).""" | ||
| 51 | + | ||
| 52 | + | ||
| 53 | +@dataclass(frozen=True) | ||
| 54 | +class EntreeRegroupee: | ||
| 55 | + """Représentation à l'écran d'une capture (cf. data-model.md § Entrée regroupée). | ||
| 56 | + | ||
| 57 | + Regroupe tous les fichiers maîtres partageant le même nom de base — le cas | ||
| 58 | + RAW+JPEG jumeau (FR-002) n'est qu'un cas particulier d'un principe plus général : | ||
| 59 | + un fichier maître sans jumeau (RAW seul, JPEG seul, scan TIFF) forme une entrée | ||
| 60 | + à un seul membre, pas un cas à part. | ||
| 61 | + """ | ||
| 62 | + | ||
| 63 | + nom_base: str | ||
| 64 | + fichiers: tuple[Path, ...] | ||
| 65 | + | ||
| 66 | + | ||
| 67 | +def _lister_fichiers_maitres(dossier: Path) -> list[Path]: | ||
| 68 | + return [ | ||
| 69 | + chemin | ||
| 70 | + for chemin in dossier.rglob("*") | ||
| 71 | + if chemin.is_file() | ||
| 72 | + and not chemin.name.startswith(".regine-manifest") | ||
| 73 | + and dossier_de_format(chemin.suffix) is not None | ||
| 74 | + ] | ||
| 75 | + | ||
| 76 | + | ||
| 77 | +def regrouper(dossier: Path) -> list[EntreeRegroupee]: | ||
| 78 | + """Regroupe les fichiers maîtres d'un dossier de travail local par capture (FR-002). | ||
| 79 | + | ||
| 80 | + Lève `StructureDossierInvalideError` si `dossier` ne contient aucun fichier | ||
| 81 | + maître reconnu (FR-013) — jamais une liste vide silencieuse dans ce cas. | ||
| 82 | + """ | ||
| 83 | + fichiers = _lister_fichiers_maitres(dossier) | ||
| 84 | + if not fichiers: | ||
| 85 | + raise StructureDossierInvalideError( | ||
| 86 | + f"{dossier} ne contient aucun fichier maître reconnu " | ||
| 87 | + "(structure de dossier Régine attendue : raw/, jpeg/, tiff/... ou racine)" | ||
| 88 | + ) | ||
| 89 | + | ||
| 90 | + groupes: dict[str, list[Path]] = {} | ||
| 91 | + for chemin in sorted(fichiers): | ||
| 92 | + nom_base = chemin.name.split(".")[0] | ||
| 93 | + groupes.setdefault(nom_base, []).append(chemin) | ||
| 94 | + | ||
| 95 | + return [ | ||
| 96 | + EntreeRegroupee(nom_base=nom_base, fichiers=tuple(membres)) | ||
| 97 | + for nom_base, membres in sorted(groupes.items()) | ||
| 98 | + ] | ||
| 99 | + | ||
| 100 | + | ||
| 101 | +def promouvoir(dossier: Path, fichier: Path) -> Path: | ||
| 102 | + """Promeut `fichier` à la racine de `dossier` (FR-003). | ||
| 103 | + | ||
| 104 | + `dossier` est un argument explicite plutôt qu'inféré du chemin de `fichier` : | ||
| 105 | + rien dans la seule position d'un fichier (« hors de son dossier de format ») ne | ||
| 106 | + permet de distinguer « déjà promu à la racine d'une étape » de « déjà promu à la | ||
| 107 | + racine d'un dossier parent » — ce sont deux niveaux réels et distincts (section | ||
| 108 | + 10 des notes d'archivage, « promotion en deux temps »), donc deux gestes | ||
| 109 | + explicites, chacun avec sa propre cible. Idempotent : si `fichier` est déjà à la | ||
| 110 | + racine de `dossier`, ne fait rien et retourne son chemin actuel. | ||
| 111 | + """ | ||
| 112 | + if fichier.parent == dossier: | ||
| 113 | + return fichier | ||
| 114 | + | ||
| 115 | + destination = dossier / fichier.name | ||
| 116 | + fichier.rename(destination) | ||
| 117 | + return destination | ||
| 118 | + | ||
| 119 | + | ||
| 120 | +def retrograder(fichier: Path) -> Path: | ||
| 121 | + """Rétrograde `fichier` de sa racine vers son dossier de format d'origine (FR-003). | ||
| 122 | + | ||
| 123 | + Lève `FichierDejaDansSonDossierDeFormatError` si `fichier` est déjà dans son | ||
| 124 | + dossier de format (rien à rétrograder). | ||
| 125 | + """ | ||
| 126 | + format_attendu = dossier_de_format(fichier.suffix) | ||
| 127 | + if format_attendu is None: | ||
| 128 | + raise ValueError(f"{fichier} n'est pas un fichier maître reconnu") | ||
| 129 | + if fichier.parent.name == format_attendu: | ||
| 130 | + raise FichierDejaDansSonDossierDeFormatError( | ||
| 131 | + f"{fichier} est déjà dans son dossier de format ({format_attendu}/)" | ||
| 132 | + ) | ||
| 133 | + | ||
| 134 | + dossier_format = fichier.parent / format_attendu | ||
| 135 | + dossier_format.mkdir(exist_ok=True) | ||
| 136 | + destination = dossier_format / fichier.name | ||
| 137 | + fichier.rename(destination) | ||
| 138 | + return destination | ||
added
packages/regine-core/tests/integration/test_consultation_restore_verrou.py +28 -0 | new file mode 100644 | ||
| @@ -0,0 +1,28 @@ | ||
| 1 | +"""Test d'intégration : une restauration ciblée ne pose ni ne modifie aucun | |
| 2 | +verrou du dossier d'origine (T022, Assumptions de spec.md).""" | |
| 3 | + | |
| 4 | +from __future__ import annotations | |
| 5 | + | |
| 6 | +from pathlib import Path | |
| 7 | + | |
| 8 | +from regine_core.archive.consultation import restaurer | |
| 9 | +from regine_core.archive.manifest import ouvrir_ou_creer | |
| 10 | +from regine_core.archive.verrou import verifier | |
| 11 | + | |
| 12 | + | |
| 13 | +def test_restaurer_ne_touche_pas_au_verrou_du_dossier_d_origine(tmp_path: Path) -> None: | |
| 14 | + dossier_archive = tmp_path / "archive" / "2026-08-15_Sortie" | |
| 15 | + dossier_archive.mkdir(parents=True) | |
| 16 | + source = dossier_archive / "photo.RAF" | |
| 17 | + source.write_bytes(b"contenu-raw") | |
| 18 | + | |
| 19 | + manifest_avant = ouvrir_ou_creer(dossier_archive) | |
| 20 | + verrouille_avant = verifier(manifest_avant) | |
| 21 | + manifest_avant.conn.close() | |
| 22 | + assert verrouille_avant is False # jamais checkouté, jamais verrouillé | |
| 23 | + | |
| 24 | + restaurer(source, tmp_path / "Desktop" / "photo.RAF") | |
| 25 | + | |
| 26 | + manifest_apres = ouvrir_ou_creer(dossier_archive) | |
| 27 | + assert verifier(manifest_apres) is False # toujours pas verrouillé après restauration | |
| 28 | + manifest_apres.conn.close() | |
| new file mode 100644 | |||
| @@ -0,0 +1,28 @@ | |||
| 1 | +"""Test d'intégration : une restauration ciblée ne pose ni ne modifie aucun | ||
| 2 | +verrou du dossier d'origine (T022, Assumptions de spec.md).""" | ||
| 3 | + | ||
| 4 | +from __future__ import annotations | ||
| 5 | + | ||
| 6 | +from pathlib import Path | ||
| 7 | + | ||
| 8 | +from regine_core.archive.consultation import restaurer | ||
| 9 | +from regine_core.archive.manifest import ouvrir_ou_creer | ||
| 10 | +from regine_core.archive.verrou import verifier | ||
| 11 | + | ||
| 12 | + | ||
| 13 | +def test_restaurer_ne_touche_pas_au_verrou_du_dossier_d_origine(tmp_path: Path) -> None: | ||
| 14 | + dossier_archive = tmp_path / "archive" / "2026-08-15_Sortie" | ||
| 15 | + dossier_archive.mkdir(parents=True) | ||
| 16 | + source = dossier_archive / "photo.RAF" | ||
| 17 | + source.write_bytes(b"contenu-raw") | ||
| 18 | + | ||
| 19 | + manifest_avant = ouvrir_ou_creer(dossier_archive) | ||
| 20 | + verrouille_avant = verifier(manifest_avant) | ||
| 21 | + manifest_avant.conn.close() | ||
| 22 | + assert verrouille_avant is False # jamais checkouté, jamais verrouillé | ||
| 23 | + | ||
| 24 | + restaurer(source, tmp_path / "Desktop" / "photo.RAF") | ||
| 25 | + | ||
| 26 | + manifest_apres = ouvrir_ou_creer(dossier_archive) | ||
| 27 | + assert verifier(manifest_apres) is False # toujours pas verrouillé après restauration | ||
| 28 | + manifest_apres.conn.close() | ||
added
packages/regine-core/tests/integration/test_selection_combined_view.py +40 -0 | new file mode 100644 | ||
| @@ -0,0 +1,40 @@ | ||
| 1 | +"""Test d'intégration de la vue combinée dossier parent + sous-dossiers (T011, FR-005).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from pathlib import Path | |
| 6 | + | |
| 7 | +from regine_core.dossier.selection import regrouper | |
| 8 | + | |
| 9 | + | |
| 10 | +def test_regrouper_dossier_parent_puis_ses_sous_dossiers(tmp_path: Path) -> None: | |
| 11 | + """Un dossier parent de voyage (specs/004) a sa propre structure complète en | |
| 12 | + plus de celle de ses étapes — regrouper() couvre chaque niveau indépendamment, | |
| 13 | + la vue combinée (FR-005) consiste à appeler regrouper() sur chacun et à | |
| 14 | + présenter les résultats ensemble côté GUI (pas de logique combinée ici).""" | |
| 15 | + dossier_parent = tmp_path / "2026-08_Montenegro" | |
| 16 | + (dossier_parent / "jpeg").mkdir(parents=True) | |
| 17 | + (dossier_parent / "jpeg" / "2026-08_Montenegro_meilleure.JPG").write_bytes(b"planche-contact") | |
| 18 | + | |
| 19 | + etape1 = dossier_parent / "2026-08-12_Kotor" | |
| 20 | + (etape1 / "raw").mkdir(parents=True) | |
| 21 | + (etape1 / "raw" / "2026-08-12_Kotor_RD0001.RAF").write_bytes(b"raw-1") | |
| 22 | + | |
| 23 | + etape2 = dossier_parent / "2026-08-14_Budva" | |
| 24 | + (etape2 / "raw").mkdir(parents=True) | |
| 25 | + (etape2 / "raw" / "2026-08-14_Budva_RD0050.RAF").write_bytes(b"raw-2") | |
| 26 | + | |
| 27 | + entrees_parent = regrouper(dossier_parent) | |
| 28 | + entrees_etape1 = regrouper(etape1) | |
| 29 | + entrees_etape2 = regrouper(etape2) | |
| 30 | + | |
| 31 | + # Le regroupement du dossier parent est récursif (rglob) : il voit aussi les | |
| 32 | + # fichiers de ses sous-dossiers, cohérent avec « planche-contact globale » du | |
| 33 | + # dossier parent (section 10 des notes d'archivage). | |
| 34 | + noms_parent = {e.nom_base for e in entrees_parent} | |
| 35 | + assert "2026-08_Montenegro_meilleure" in noms_parent | |
| 36 | + assert "2026-08-12_Kotor_RD0001" in noms_parent | |
| 37 | + assert "2026-08-14_Budva_RD0050" in noms_parent | |
| 38 | + | |
| 39 | + assert [e.nom_base for e in entrees_etape1] == ["2026-08-12_Kotor_RD0001"] | |
| 40 | + assert [e.nom_base for e in entrees_etape2] == ["2026-08-14_Budva_RD0050"] | |
| new file mode 100644 | |||
| @@ -0,0 +1,40 @@ | |||
| 1 | +"""Test d'intégration de la vue combinée dossier parent + sous-dossiers (T011, FR-005).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from pathlib import Path | ||
| 6 | + | ||
| 7 | +from regine_core.dossier.selection import regrouper | ||
| 8 | + | ||
| 9 | + | ||
| 10 | +def test_regrouper_dossier_parent_puis_ses_sous_dossiers(tmp_path: Path) -> None: | ||
| 11 | + """Un dossier parent de voyage (specs/004) a sa propre structure complète en | ||
| 12 | + plus de celle de ses étapes — regrouper() couvre chaque niveau indépendamment, | ||
| 13 | + la vue combinée (FR-005) consiste à appeler regrouper() sur chacun et à | ||
| 14 | + présenter les résultats ensemble côté GUI (pas de logique combinée ici).""" | ||
| 15 | + dossier_parent = tmp_path / "2026-08_Montenegro" | ||
| 16 | + (dossier_parent / "jpeg").mkdir(parents=True) | ||
| 17 | + (dossier_parent / "jpeg" / "2026-08_Montenegro_meilleure.JPG").write_bytes(b"planche-contact") | ||
| 18 | + | ||
| 19 | + etape1 = dossier_parent / "2026-08-12_Kotor" | ||
| 20 | + (etape1 / "raw").mkdir(parents=True) | ||
| 21 | + (etape1 / "raw" / "2026-08-12_Kotor_RD0001.RAF").write_bytes(b"raw-1") | ||
| 22 | + | ||
| 23 | + etape2 = dossier_parent / "2026-08-14_Budva" | ||
| 24 | + (etape2 / "raw").mkdir(parents=True) | ||
| 25 | + (etape2 / "raw" / "2026-08-14_Budva_RD0050.RAF").write_bytes(b"raw-2") | ||
| 26 | + | ||
| 27 | + entrees_parent = regrouper(dossier_parent) | ||
| 28 | + entrees_etape1 = regrouper(etape1) | ||
| 29 | + entrees_etape2 = regrouper(etape2) | ||
| 30 | + | ||
| 31 | + # Le regroupement du dossier parent est récursif (rglob) : il voit aussi les | ||
| 32 | + # fichiers de ses sous-dossiers, cohérent avec « planche-contact globale » du | ||
| 33 | + # dossier parent (section 10 des notes d'archivage). | ||
| 34 | + noms_parent = {e.nom_base for e in entrees_parent} | ||
| 35 | + assert "2026-08_Montenegro_meilleure" in noms_parent | ||
| 36 | + assert "2026-08-12_Kotor_RD0001" in noms_parent | ||
| 37 | + assert "2026-08-14_Budva_RD0050" in noms_parent | ||
| 38 | + | ||
| 39 | + assert [e.nom_base for e in entrees_etape1] == ["2026-08-12_Kotor_RD0001"] | ||
| 40 | + assert [e.nom_base for e in entrees_etape2] == ["2026-08-14_Budva_RD0050"] | ||
added
packages/regine-core/tests/integration/test_selection_reconciliation.py +32 -0 | new file mode 100644 | ||
| @@ -0,0 +1,32 @@ | ||
| 1 | +"""Test d'intégration bout-en-bout : une promotion est un déplacement pour | |
| 2 | +`comparer` (T013), sans code dédié côté specs/005 (Principe III).""" | |
| 3 | + | |
| 4 | +from __future__ import annotations | |
| 5 | + | |
| 6 | +from pathlib import Path | |
| 7 | + | |
| 8 | +from regine_core.archive.checkout import checkout | |
| 9 | +from regine_core.archive.reconciliation import comparer | |
| 10 | +from regine_core.dossier.selection import promouvoir | |
| 11 | + | |
| 12 | + | |
| 13 | +def test_promotion_est_classee_deplacement_pas_anomalie(tmp_path: Path) -> None: | |
| 14 | + dossier_archive = tmp_path / "archive" / "2026-08-15_Sortie" | |
| 15 | + (dossier_archive / "raw").mkdir(parents=True) | |
| 16 | + (dossier_archive / "raw" / "photo.raf").write_bytes(b"contenu-raw") | |
| 17 | + | |
| 18 | + dossier_local = tmp_path / "local" / "2026-08-15_Sortie" | |
| 19 | + snapshot = checkout(dossier_archive, dossier_local) | |
| 20 | + | |
| 21 | + fichier = dossier_local / "raw" / "photo.raf" | |
| 22 | + resultat = promouvoir(dossier_local, fichier) | |
| 23 | + assert resultat == dossier_local / "photo.raf" | |
| 24 | + | |
| 25 | + rapport = comparer(snapshot, dossier_local) | |
| 26 | + | |
| 27 | + assert rapport.anomalies == [] | |
| 28 | + deplacements = { | |
| 29 | + c.chemin_ancien: c.chemin for c in rapport.changements if c.categorie == "deplacement" | |
| 30 | + } | |
| 31 | + assert deplacements == {Path("raw/photo.raf"): Path("photo.raf")} | |
| 32 | + snapshot.manifest.conn.close() | |
| new file mode 100644 | |||
| @@ -0,0 +1,32 @@ | |||
| 1 | +"""Test d'intégration bout-en-bout : une promotion est un déplacement pour | ||
| 2 | +`comparer` (T013), sans code dédié côté specs/005 (Principe III).""" | ||
| 3 | + | ||
| 4 | +from __future__ import annotations | ||
| 5 | + | ||
| 6 | +from pathlib import Path | ||
| 7 | + | ||
| 8 | +from regine_core.archive.checkout import checkout | ||
| 9 | +from regine_core.archive.reconciliation import comparer | ||
| 10 | +from regine_core.dossier.selection import promouvoir | ||
| 11 | + | ||
| 12 | + | ||
| 13 | +def test_promotion_est_classee_deplacement_pas_anomalie(tmp_path: Path) -> None: | ||
| 14 | + dossier_archive = tmp_path / "archive" / "2026-08-15_Sortie" | ||
| 15 | + (dossier_archive / "raw").mkdir(parents=True) | ||
| 16 | + (dossier_archive / "raw" / "photo.raf").write_bytes(b"contenu-raw") | ||
| 17 | + | ||
| 18 | + dossier_local = tmp_path / "local" / "2026-08-15_Sortie" | ||
| 19 | + snapshot = checkout(dossier_archive, dossier_local) | ||
| 20 | + | ||
| 21 | + fichier = dossier_local / "raw" / "photo.raf" | ||
| 22 | + resultat = promouvoir(dossier_local, fichier) | ||
| 23 | + assert resultat == dossier_local / "photo.raf" | ||
| 24 | + | ||
| 25 | + rapport = comparer(snapshot, dossier_local) | ||
| 26 | + | ||
| 27 | + assert rapport.anomalies == [] | ||
| 28 | + deplacements = { | ||
| 29 | + c.chemin_ancien: c.chemin for c in rapport.changements if c.categorie == "deplacement" | ||
| 30 | + } | ||
| 31 | + assert deplacements == {Path("raw/photo.raf"): Path("photo.raf")} | ||
| 32 | + snapshot.manifest.conn.close() | ||
added
packages/regine-core/tests/unit/test_consultation_restore.py +51 -0 | new file mode 100644 | ||
| @@ -0,0 +1,51 @@ | ||
| 1 | +"""Tests de `regine_core.archive.consultation.restaurer` (T020).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from pathlib import Path | |
| 6 | + | |
| 7 | +import pytest | |
| 8 | +from regine_core.archive.consultation import RestaurationCollisionError, restaurer | |
| 9 | + | |
| 10 | + | |
| 11 | +def test_restaurer_copie_verifiee(tmp_path: Path) -> None: | |
| 12 | + source = tmp_path / "archive" / "photo.RAF" | |
| 13 | + source.parent.mkdir(parents=True) | |
| 14 | + source.write_bytes(b"contenu-raw") | |
| 15 | + destination = tmp_path / "Desktop" / "photo.RAF" | |
| 16 | + | |
| 17 | + restaurer(source, destination) | |
| 18 | + | |
| 19 | + assert destination.read_bytes() == b"contenu-raw" | |
| 20 | + | |
| 21 | + | |
| 22 | +def test_restaurer_doublon_identique_est_un_no_op(tmp_path: Path) -> None: | |
| 23 | + source = tmp_path / "archive" / "photo.RAF" | |
| 24 | + source.parent.mkdir(parents=True) | |
| 25 | + source.write_bytes(b"contenu-raw") | |
| 26 | + destination = tmp_path / "Desktop" / "photo.RAF" | |
| 27 | + destination.parent.mkdir(parents=True) | |
| 28 | + destination.write_bytes(b"contenu-raw") | |
| 29 | + | |
| 30 | + restaurer(source, destination) # ne doit pas lever | |
| 31 | + | |
| 32 | + assert destination.read_bytes() == b"contenu-raw" | |
| 33 | + | |
| 34 | + | |
| 35 | +def test_restaurer_refuse_ecraser_contenu_different(tmp_path: Path) -> None: | |
| 36 | + source = tmp_path / "archive" / "photo.RAF" | |
| 37 | + source.parent.mkdir(parents=True) | |
| 38 | + source.write_bytes(b"contenu-nouveau") | |
| 39 | + destination = tmp_path / "Desktop" / "photo.RAF" | |
| 40 | + destination.parent.mkdir(parents=True) | |
| 41 | + destination.write_bytes(b"contenu-different-deja-present") | |
| 42 | + | |
| 43 | + with pytest.raises(RestaurationCollisionError): | |
| 44 | + restaurer(source, destination) | |
| 45 | + | |
| 46 | + assert destination.read_bytes() == b"contenu-different-deja-present" | |
| 47 | + | |
| 48 | + | |
| 49 | +def test_restaurer_source_introuvable_leve(tmp_path: Path) -> None: | |
| 50 | + with pytest.raises(FileNotFoundError): | |
| 51 | + restaurer(tmp_path / "archive" / "absent.RAF", tmp_path / "Desktop" / "absent.RAF") | |
| new file mode 100644 | |||
| @@ -0,0 +1,51 @@ | |||
| 1 | +"""Tests de `regine_core.archive.consultation.restaurer` (T020).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from pathlib import Path | ||
| 6 | + | ||
| 7 | +import pytest | ||
| 8 | +from regine_core.archive.consultation import RestaurationCollisionError, restaurer | ||
| 9 | + | ||
| 10 | + | ||
| 11 | +def test_restaurer_copie_verifiee(tmp_path: Path) -> None: | ||
| 12 | + source = tmp_path / "archive" / "photo.RAF" | ||
| 13 | + source.parent.mkdir(parents=True) | ||
| 14 | + source.write_bytes(b"contenu-raw") | ||
| 15 | + destination = tmp_path / "Desktop" / "photo.RAF" | ||
| 16 | + | ||
| 17 | + restaurer(source, destination) | ||
| 18 | + | ||
| 19 | + assert destination.read_bytes() == b"contenu-raw" | ||
| 20 | + | ||
| 21 | + | ||
| 22 | +def test_restaurer_doublon_identique_est_un_no_op(tmp_path: Path) -> None: | ||
| 23 | + source = tmp_path / "archive" / "photo.RAF" | ||
| 24 | + source.parent.mkdir(parents=True) | ||
| 25 | + source.write_bytes(b"contenu-raw") | ||
| 26 | + destination = tmp_path / "Desktop" / "photo.RAF" | ||
| 27 | + destination.parent.mkdir(parents=True) | ||
| 28 | + destination.write_bytes(b"contenu-raw") | ||
| 29 | + | ||
| 30 | + restaurer(source, destination) # ne doit pas lever | ||
| 31 | + | ||
| 32 | + assert destination.read_bytes() == b"contenu-raw" | ||
| 33 | + | ||
| 34 | + | ||
| 35 | +def test_restaurer_refuse_ecraser_contenu_different(tmp_path: Path) -> None: | ||
| 36 | + source = tmp_path / "archive" / "photo.RAF" | ||
| 37 | + source.parent.mkdir(parents=True) | ||
| 38 | + source.write_bytes(b"contenu-nouveau") | ||
| 39 | + destination = tmp_path / "Desktop" / "photo.RAF" | ||
| 40 | + destination.parent.mkdir(parents=True) | ||
| 41 | + destination.write_bytes(b"contenu-different-deja-present") | ||
| 42 | + | ||
| 43 | + with pytest.raises(RestaurationCollisionError): | ||
| 44 | + restaurer(source, destination) | ||
| 45 | + | ||
| 46 | + assert destination.read_bytes() == b"contenu-different-deja-present" | ||
| 47 | + | ||
| 48 | + | ||
| 49 | +def test_restaurer_source_introuvable_leve(tmp_path: Path) -> None: | ||
| 50 | + with pytest.raises(FileNotFoundError): | ||
| 51 | + restaurer(tmp_path / "archive" / "absent.RAF", tmp_path / "Desktop" / "absent.RAF") | ||
added
packages/regine-core/tests/unit/test_consultation_search.py +61 -0 | new file mode 100644 | ||
| @@ -0,0 +1,61 @@ | ||
| 1 | +"""Tests de `regine_core.archive.consultation.rechercher` (T019).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from pathlib import Path | |
| 6 | + | |
| 7 | +from regine_core.archive.consultation import rechercher | |
| 8 | + | |
| 9 | + | |
| 10 | +def _archive(tmp_path: Path) -> Path: | |
| 11 | + archive = tmp_path / "archive" | |
| 12 | + (archive / "2026" / "2026-06-01_Anniversaire").mkdir(parents=True) | |
| 13 | + (archive / "2026" / "2026-06-01_Anniversaire" / "raw").mkdir() | |
| 14 | + (archive / "2026" / "2026-06-01_Anniversaire" / "raw" / "photo.RAF").write_bytes(b"x") | |
| 15 | + (archive / "voyage" / "2026-08_Montenegro").mkdir(parents=True) | |
| 16 | + return archive | |
| 17 | + | |
| 18 | + | |
| 19 | +def test_rechercher_sans_requete_liste_tout(tmp_path: Path) -> None: | |
| 20 | + archive = _archive(tmp_path) | |
| 21 | + | |
| 22 | + resultats = rechercher(archive, None) | |
| 23 | + | |
| 24 | + noms = {r.chemin_relatif.name for r in resultats} | |
| 25 | + assert "2026-06-01_Anniversaire" in noms | |
| 26 | + assert "2026-08_Montenegro" in noms | |
| 27 | + assert "photo.RAF" in noms | |
| 28 | + | |
| 29 | + | |
| 30 | +def test_rechercher_filtre_par_texte_de_titre(tmp_path: Path) -> None: | |
| 31 | + archive = _archive(tmp_path) | |
| 32 | + | |
| 33 | + resultats = rechercher(archive, "Montenegro") | |
| 34 | + | |
| 35 | + assert len(resultats) == 1 | |
| 36 | + assert resultats[0].chemin_relatif.name == "2026-08_Montenegro" | |
| 37 | + assert resultats[0].est_dossier is True | |
| 38 | + | |
| 39 | + | |
| 40 | +def test_rechercher_filtre_par_date(tmp_path: Path) -> None: | |
| 41 | + archive = _archive(tmp_path) | |
| 42 | + | |
| 43 | + resultats = rechercher(archive, "2026-06-01") | |
| 44 | + | |
| 45 | + assert len(resultats) == 1 | |
| 46 | + assert resultats[0].chemin_relatif.name == "2026-06-01_Anniversaire" | |
| 47 | + | |
| 48 | + | |
| 49 | +def test_rechercher_aucun_resultat_retourne_liste_vide(tmp_path: Path) -> None: | |
| 50 | + archive = _archive(tmp_path) | |
| 51 | + | |
| 52 | + assert rechercher(archive, "Introuvable") == [] | |
| 53 | + | |
| 54 | + | |
| 55 | +def test_rechercher_ignore_le_manifeste(tmp_path: Path) -> None: | |
| 56 | + archive = _archive(tmp_path) | |
| 57 | + (archive / "2026" / "2026-06-01_Anniversaire" / ".regine-manifest.sqlite3").write_bytes(b"") | |
| 58 | + | |
| 59 | + resultats = rechercher(archive, None) | |
| 60 | + | |
| 61 | + assert all(".regine-manifest" not in str(r.chemin_relatif) for r in resultats) | |
| new file mode 100644 | |||
| @@ -0,0 +1,61 @@ | |||
| 1 | +"""Tests de `regine_core.archive.consultation.rechercher` (T019).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from pathlib import Path | ||
| 6 | + | ||
| 7 | +from regine_core.archive.consultation import rechercher | ||
| 8 | + | ||
| 9 | + | ||
| 10 | +def _archive(tmp_path: Path) -> Path: | ||
| 11 | + archive = tmp_path / "archive" | ||
| 12 | + (archive / "2026" / "2026-06-01_Anniversaire").mkdir(parents=True) | ||
| 13 | + (archive / "2026" / "2026-06-01_Anniversaire" / "raw").mkdir() | ||
| 14 | + (archive / "2026" / "2026-06-01_Anniversaire" / "raw" / "photo.RAF").write_bytes(b"x") | ||
| 15 | + (archive / "voyage" / "2026-08_Montenegro").mkdir(parents=True) | ||
| 16 | + return archive | ||
| 17 | + | ||
| 18 | + | ||
| 19 | +def test_rechercher_sans_requete_liste_tout(tmp_path: Path) -> None: | ||
| 20 | + archive = _archive(tmp_path) | ||
| 21 | + | ||
| 22 | + resultats = rechercher(archive, None) | ||
| 23 | + | ||
| 24 | + noms = {r.chemin_relatif.name for r in resultats} | ||
| 25 | + assert "2026-06-01_Anniversaire" in noms | ||
| 26 | + assert "2026-08_Montenegro" in noms | ||
| 27 | + assert "photo.RAF" in noms | ||
| 28 | + | ||
| 29 | + | ||
| 30 | +def test_rechercher_filtre_par_texte_de_titre(tmp_path: Path) -> None: | ||
| 31 | + archive = _archive(tmp_path) | ||
| 32 | + | ||
| 33 | + resultats = rechercher(archive, "Montenegro") | ||
| 34 | + | ||
| 35 | + assert len(resultats) == 1 | ||
| 36 | + assert resultats[0].chemin_relatif.name == "2026-08_Montenegro" | ||
| 37 | + assert resultats[0].est_dossier is True | ||
| 38 | + | ||
| 39 | + | ||
| 40 | +def test_rechercher_filtre_par_date(tmp_path: Path) -> None: | ||
| 41 | + archive = _archive(tmp_path) | ||
| 42 | + | ||
| 43 | + resultats = rechercher(archive, "2026-06-01") | ||
| 44 | + | ||
| 45 | + assert len(resultats) == 1 | ||
| 46 | + assert resultats[0].chemin_relatif.name == "2026-06-01_Anniversaire" | ||
| 47 | + | ||
| 48 | + | ||
| 49 | +def test_rechercher_aucun_resultat_retourne_liste_vide(tmp_path: Path) -> None: | ||
| 50 | + archive = _archive(tmp_path) | ||
| 51 | + | ||
| 52 | + assert rechercher(archive, "Introuvable") == [] | ||
| 53 | + | ||
| 54 | + | ||
| 55 | +def test_rechercher_ignore_le_manifeste(tmp_path: Path) -> None: | ||
| 56 | + archive = _archive(tmp_path) | ||
| 57 | + (archive / "2026" / "2026-06-01_Anniversaire" / ".regine-manifest.sqlite3").write_bytes(b"") | ||
| 58 | + | ||
| 59 | + resultats = rechercher(archive, None) | ||
| 60 | + | ||
| 61 | + assert all(".regine-manifest" not in str(r.chemin_relatif) for r in resultats) | ||
added
packages/regine-core/tests/unit/test_selection_grouping.py +84 -0 | new file mode 100644 | ||
| @@ -0,0 +1,84 @@ | ||
| 1 | +"""Tests de `regine_core.dossier.selection` : correspondance de format et regroupement.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from pathlib import Path | |
| 6 | + | |
| 7 | +import pytest | |
| 8 | +from regine_core.dossier.selection import ( | |
| 9 | + StructureDossierInvalideError, | |
| 10 | + dossier_de_format, | |
| 11 | + regrouper, | |
| 12 | +) | |
| 13 | + | |
| 14 | + | |
| 15 | +def test_dossier_de_format_raw_proprietaires() -> None: | |
| 16 | + for ext in ("raf", "cr2", "cr3", "nef", "arw", "orf", "rw2", "pef", "srw", "dng"): | |
| 17 | + assert dossier_de_format(ext) == "raw" | |
| 18 | + | |
| 19 | + | |
| 20 | +def test_dossier_de_format_tiff() -> None: | |
| 21 | + assert dossier_de_format("tiff") == "tiff" | |
| 22 | + assert dossier_de_format("tif") == "tiff" | |
| 23 | + | |
| 24 | + | |
| 25 | +def test_dossier_de_format_jpeg() -> None: | |
| 26 | + assert dossier_de_format("jpg") == "jpeg" | |
| 27 | + assert dossier_de_format("jpeg") == "jpeg" | |
| 28 | + | |
| 29 | + | |
| 30 | +def test_dossier_de_format_insensible_a_la_casse_et_au_point() -> None: | |
| 31 | + assert dossier_de_format("RAF") == "raw" | |
| 32 | + assert dossier_de_format(".RAF") == "raw" | |
| 33 | + | |
| 34 | + | |
| 35 | +def test_dossier_de_format_maitre_marginal_obtient_son_propre_dossier() -> None: | |
| 36 | + assert dossier_de_format("bmp") == "bmp" | |
| 37 | + | |
| 38 | + | |
| 39 | +def test_dossier_de_format_sidecar_ou_inconnu_retourne_none() -> None: | |
| 40 | + assert dossier_de_format("xmp") is None | |
| 41 | + assert dossier_de_format("dop") is None | |
| 42 | + assert dossier_de_format("txt") is None | |
| 43 | + | |
| 44 | + | |
| 45 | +def test_regrouper_paire_raw_jpeg_jumelle_en_une_seule_entree(tmp_path: Path) -> None: | |
| 46 | + (tmp_path / "raw").mkdir() | |
| 47 | + (tmp_path / "jpeg").mkdir() | |
| 48 | + (tmp_path / "raw" / "2026-08-15_Sortie_RD0001.RAF").write_bytes(b"raw") | |
| 49 | + (tmp_path / "jpeg" / "2026-08-15_Sortie_RD0001.JPG").write_bytes(b"jpeg") | |
| 50 | + | |
| 51 | + entrees = regrouper(tmp_path) | |
| 52 | + | |
| 53 | + assert len(entrees) == 1 | |
| 54 | + assert entrees[0].nom_base == "2026-08-15_Sortie_RD0001" | |
| 55 | + assert len(entrees[0].fichiers) == 2 | |
| 56 | + | |
| 57 | + | |
| 58 | +def test_regrouper_fichier_seul_forme_une_entree_a_un_membre(tmp_path: Path) -> None: | |
| 59 | + (tmp_path / "raw").mkdir() | |
| 60 | + (tmp_path / "raw" / "2026-08-15_Sortie_RD0002.RAF").write_bytes(b"raw") | |
| 61 | + | |
| 62 | + entrees = regrouper(tmp_path) | |
| 63 | + | |
| 64 | + assert len(entrees) == 1 | |
| 65 | + assert len(entrees[0].fichiers) == 1 | |
| 66 | + | |
| 67 | + | |
| 68 | +def test_regrouper_scan_tiff_isole_forme_sa_propre_entree(tmp_path: Path) -> None: | |
| 69 | + """Non-régression : un scan TIFF n'est ni RAW ni JPEG, doit tout de même | |
| 70 | + apparaître comme sa propre entrée (cf. data-model.md, correction post-conception).""" | |
| 71 | + (tmp_path / "tiff").mkdir() | |
| 72 | + (tmp_path / "tiff" / "scan_0001.TIF").write_bytes(b"tiff") | |
| 73 | + | |
| 74 | + entrees = regrouper(tmp_path) | |
| 75 | + | |
| 76 | + assert len(entrees) == 1 | |
| 77 | + assert entrees[0].nom_base == "scan_0001" | |
| 78 | + | |
| 79 | + | |
| 80 | +def test_regrouper_dossier_sans_structure_reconnue_leve(tmp_path: Path) -> None: | |
| 81 | + (tmp_path / "notes.txt").write_bytes(b"pas un fichier maitre") | |
| 82 | + | |
| 83 | + with pytest.raises(StructureDossierInvalideError): | |
| 84 | + regrouper(tmp_path) | |
| new file mode 100644 | |||
| @@ -0,0 +1,84 @@ | |||
| 1 | +"""Tests de `regine_core.dossier.selection` : correspondance de format et regroupement.""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from pathlib import Path | ||
| 6 | + | ||
| 7 | +import pytest | ||
| 8 | +from regine_core.dossier.selection import ( | ||
| 9 | + StructureDossierInvalideError, | ||
| 10 | + dossier_de_format, | ||
| 11 | + regrouper, | ||
| 12 | +) | ||
| 13 | + | ||
| 14 | + | ||
| 15 | +def test_dossier_de_format_raw_proprietaires() -> None: | ||
| 16 | + for ext in ("raf", "cr2", "cr3", "nef", "arw", "orf", "rw2", "pef", "srw", "dng"): | ||
| 17 | + assert dossier_de_format(ext) == "raw" | ||
| 18 | + | ||
| 19 | + | ||
| 20 | +def test_dossier_de_format_tiff() -> None: | ||
| 21 | + assert dossier_de_format("tiff") == "tiff" | ||
| 22 | + assert dossier_de_format("tif") == "tiff" | ||
| 23 | + | ||
| 24 | + | ||
| 25 | +def test_dossier_de_format_jpeg() -> None: | ||
| 26 | + assert dossier_de_format("jpg") == "jpeg" | ||
| 27 | + assert dossier_de_format("jpeg") == "jpeg" | ||
| 28 | + | ||
| 29 | + | ||
| 30 | +def test_dossier_de_format_insensible_a_la_casse_et_au_point() -> None: | ||
| 31 | + assert dossier_de_format("RAF") == "raw" | ||
| 32 | + assert dossier_de_format(".RAF") == "raw" | ||
| 33 | + | ||
| 34 | + | ||
| 35 | +def test_dossier_de_format_maitre_marginal_obtient_son_propre_dossier() -> None: | ||
| 36 | + assert dossier_de_format("bmp") == "bmp" | ||
| 37 | + | ||
| 38 | + | ||
| 39 | +def test_dossier_de_format_sidecar_ou_inconnu_retourne_none() -> None: | ||
| 40 | + assert dossier_de_format("xmp") is None | ||
| 41 | + assert dossier_de_format("dop") is None | ||
| 42 | + assert dossier_de_format("txt") is None | ||
| 43 | + | ||
| 44 | + | ||
| 45 | +def test_regrouper_paire_raw_jpeg_jumelle_en_une_seule_entree(tmp_path: Path) -> None: | ||
| 46 | + (tmp_path / "raw").mkdir() | ||
| 47 | + (tmp_path / "jpeg").mkdir() | ||
| 48 | + (tmp_path / "raw" / "2026-08-15_Sortie_RD0001.RAF").write_bytes(b"raw") | ||
| 49 | + (tmp_path / "jpeg" / "2026-08-15_Sortie_RD0001.JPG").write_bytes(b"jpeg") | ||
| 50 | + | ||
| 51 | + entrees = regrouper(tmp_path) | ||
| 52 | + | ||
| 53 | + assert len(entrees) == 1 | ||
| 54 | + assert entrees[0].nom_base == "2026-08-15_Sortie_RD0001" | ||
| 55 | + assert len(entrees[0].fichiers) == 2 | ||
| 56 | + | ||
| 57 | + | ||
| 58 | +def test_regrouper_fichier_seul_forme_une_entree_a_un_membre(tmp_path: Path) -> None: | ||
| 59 | + (tmp_path / "raw").mkdir() | ||
| 60 | + (tmp_path / "raw" / "2026-08-15_Sortie_RD0002.RAF").write_bytes(b"raw") | ||
| 61 | + | ||
| 62 | + entrees = regrouper(tmp_path) | ||
| 63 | + | ||
| 64 | + assert len(entrees) == 1 | ||
| 65 | + assert len(entrees[0].fichiers) == 1 | ||
| 66 | + | ||
| 67 | + | ||
| 68 | +def test_regrouper_scan_tiff_isole_forme_sa_propre_entree(tmp_path: Path) -> None: | ||
| 69 | + """Non-régression : un scan TIFF n'est ni RAW ni JPEG, doit tout de même | ||
| 70 | + apparaître comme sa propre entrée (cf. data-model.md, correction post-conception).""" | ||
| 71 | + (tmp_path / "tiff").mkdir() | ||
| 72 | + (tmp_path / "tiff" / "scan_0001.TIF").write_bytes(b"tiff") | ||
| 73 | + | ||
| 74 | + entrees = regrouper(tmp_path) | ||
| 75 | + | ||
| 76 | + assert len(entrees) == 1 | ||
| 77 | + assert entrees[0].nom_base == "scan_0001" | ||
| 78 | + | ||
| 79 | + | ||
| 80 | +def test_regrouper_dossier_sans_structure_reconnue_leve(tmp_path: Path) -> None: | ||
| 81 | + (tmp_path / "notes.txt").write_bytes(b"pas un fichier maitre") | ||
| 82 | + | ||
| 83 | + with pytest.raises(StructureDossierInvalideError): | ||
| 84 | + regrouper(tmp_path) | ||
added
packages/regine-core/tests/unit/test_selection_promotion.py +79 -0 | new file mode 100644 | ||
| @@ -0,0 +1,79 @@ | ||
| 1 | +"""Tests de `promouvoir`/`retrograder` (T010).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import hashlib | |
| 6 | +from pathlib import Path | |
| 7 | + | |
| 8 | +import pytest | |
| 9 | +from regine_core.dossier.selection import ( | |
| 10 | + FichierDejaDansSonDossierDeFormatError, | |
| 11 | + promouvoir, | |
| 12 | + retrograder, | |
| 13 | +) | |
| 14 | + | |
| 15 | + | |
| 16 | +def _hash(chemin: Path) -> str: | |
| 17 | + return hashlib.sha256(chemin.read_bytes()).hexdigest() | |
| 18 | + | |
| 19 | + | |
| 20 | +def test_promouvoir_deplace_vers_la_racine_sans_changer_le_contenu(tmp_path: Path) -> None: | |
| 21 | + (tmp_path / "raw").mkdir() | |
| 22 | + fichier = tmp_path / "raw" / "photo.RAF" | |
| 23 | + fichier.write_bytes(b"contenu-raw") | |
| 24 | + empreinte_avant = _hash(fichier) | |
| 25 | + | |
| 26 | + resultat = promouvoir(tmp_path, fichier) | |
| 27 | + | |
| 28 | + assert resultat == tmp_path / "photo.RAF" | |
| 29 | + assert resultat.exists() | |
| 30 | + assert not fichier.exists() | |
| 31 | + assert _hash(resultat) == empreinte_avant | |
| 32 | + | |
| 33 | + | |
| 34 | +def test_promouvoir_est_idempotent_si_deja_a_la_racine(tmp_path: Path) -> None: | |
| 35 | + fichier = tmp_path / "photo.RAF" | |
| 36 | + fichier.write_bytes(b"contenu-raw") | |
| 37 | + | |
| 38 | + resultat = promouvoir(tmp_path, fichier) | |
| 39 | + | |
| 40 | + assert resultat == fichier | |
| 41 | + assert fichier.exists() | |
| 42 | + | |
| 43 | + | |
| 44 | +def test_promouvoir_fonctionne_a_deux_niveaux_etape_puis_parent(tmp_path: Path) -> None: | |
| 45 | + """Section 10 des notes d'archivage : promotion en deux temps (racine de | |
| 46 | + l'étape, puis racine du dossier parent) — deux gestes explicites, chacun avec | |
| 47 | + sa propre cible `dossier` (la fonction ne peut pas deviner le niveau visé).""" | |
| 48 | + dossier_parent = tmp_path | |
| 49 | + etape = dossier_parent / "2026-08-12_Kotor" | |
| 50 | + (etape / "raw").mkdir(parents=True) | |
| 51 | + fichier = etape / "raw" / "photo.RAF" | |
| 52 | + fichier.write_bytes(b"contenu-raw") | |
| 53 | + | |
| 54 | + a_la_racine_etape = promouvoir(etape, fichier) | |
| 55 | + assert a_la_racine_etape == etape / "photo.RAF" | |
| 56 | + | |
| 57 | + a_la_racine_parent = promouvoir(dossier_parent, a_la_racine_etape) | |
| 58 | + assert a_la_racine_parent == dossier_parent / "photo.RAF" | |
| 59 | + assert a_la_racine_parent.exists() | |
| 60 | + | |
| 61 | + | |
| 62 | +def test_retrograder_deplace_vers_le_dossier_de_format(tmp_path: Path) -> None: | |
| 63 | + fichier = tmp_path / "photo.RAF" | |
| 64 | + fichier.write_bytes(b"contenu-raw") | |
| 65 | + | |
| 66 | + resultat = retrograder(fichier) | |
| 67 | + | |
| 68 | + assert resultat == tmp_path / "raw" / "photo.RAF" | |
| 69 | + assert resultat.exists() | |
| 70 | + assert not fichier.exists() | |
| 71 | + | |
| 72 | + | |
| 73 | +def test_retrograder_leve_si_deja_dans_son_dossier_de_format(tmp_path: Path) -> None: | |
| 74 | + (tmp_path / "raw").mkdir() | |
| 75 | + fichier = tmp_path / "raw" / "photo.RAF" | |
| 76 | + fichier.write_bytes(b"contenu-raw") | |
| 77 | + | |
| 78 | + with pytest.raises(FichierDejaDansSonDossierDeFormatError): | |
| 79 | + retrograder(fichier) | |
| new file mode 100644 | |||
| @@ -0,0 +1,79 @@ | |||
| 1 | +"""Tests de `promouvoir`/`retrograder` (T010).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import hashlib | ||
| 6 | +from pathlib import Path | ||
| 7 | + | ||
| 8 | +import pytest | ||
| 9 | +from regine_core.dossier.selection import ( | ||
| 10 | + FichierDejaDansSonDossierDeFormatError, | ||
| 11 | + promouvoir, | ||
| 12 | + retrograder, | ||
| 13 | +) | ||
| 14 | + | ||
| 15 | + | ||
| 16 | +def _hash(chemin: Path) -> str: | ||
| 17 | + return hashlib.sha256(chemin.read_bytes()).hexdigest() | ||
| 18 | + | ||
| 19 | + | ||
| 20 | +def test_promouvoir_deplace_vers_la_racine_sans_changer_le_contenu(tmp_path: Path) -> None: | ||
| 21 | + (tmp_path / "raw").mkdir() | ||
| 22 | + fichier = tmp_path / "raw" / "photo.RAF" | ||
| 23 | + fichier.write_bytes(b"contenu-raw") | ||
| 24 | + empreinte_avant = _hash(fichier) | ||
| 25 | + | ||
| 26 | + resultat = promouvoir(tmp_path, fichier) | ||
| 27 | + | ||
| 28 | + assert resultat == tmp_path / "photo.RAF" | ||
| 29 | + assert resultat.exists() | ||
| 30 | + assert not fichier.exists() | ||
| 31 | + assert _hash(resultat) == empreinte_avant | ||
| 32 | + | ||
| 33 | + | ||
| 34 | +def test_promouvoir_est_idempotent_si_deja_a_la_racine(tmp_path: Path) -> None: | ||
| 35 | + fichier = tmp_path / "photo.RAF" | ||
| 36 | + fichier.write_bytes(b"contenu-raw") | ||
| 37 | + | ||
| 38 | + resultat = promouvoir(tmp_path, fichier) | ||
| 39 | + | ||
| 40 | + assert resultat == fichier | ||
| 41 | + assert fichier.exists() | ||
| 42 | + | ||
| 43 | + | ||
| 44 | +def test_promouvoir_fonctionne_a_deux_niveaux_etape_puis_parent(tmp_path: Path) -> None: | ||
| 45 | + """Section 10 des notes d'archivage : promotion en deux temps (racine de | ||
| 46 | + l'étape, puis racine du dossier parent) — deux gestes explicites, chacun avec | ||
| 47 | + sa propre cible `dossier` (la fonction ne peut pas deviner le niveau visé).""" | ||
| 48 | + dossier_parent = tmp_path | ||
| 49 | + etape = dossier_parent / "2026-08-12_Kotor" | ||
| 50 | + (etape / "raw").mkdir(parents=True) | ||
| 51 | + fichier = etape / "raw" / "photo.RAF" | ||
| 52 | + fichier.write_bytes(b"contenu-raw") | ||
| 53 | + | ||
| 54 | + a_la_racine_etape = promouvoir(etape, fichier) | ||
| 55 | + assert a_la_racine_etape == etape / "photo.RAF" | ||
| 56 | + | ||
| 57 | + a_la_racine_parent = promouvoir(dossier_parent, a_la_racine_etape) | ||
| 58 | + assert a_la_racine_parent == dossier_parent / "photo.RAF" | ||
| 59 | + assert a_la_racine_parent.exists() | ||
| 60 | + | ||
| 61 | + | ||
| 62 | +def test_retrograder_deplace_vers_le_dossier_de_format(tmp_path: Path) -> None: | ||
| 63 | + fichier = tmp_path / "photo.RAF" | ||
| 64 | + fichier.write_bytes(b"contenu-raw") | ||
| 65 | + | ||
| 66 | + resultat = retrograder(fichier) | ||
| 67 | + | ||
| 68 | + assert resultat == tmp_path / "raw" / "photo.RAF" | ||
| 69 | + assert resultat.exists() | ||
| 70 | + assert not fichier.exists() | ||
| 71 | + | ||
| 72 | + | ||
| 73 | +def test_retrograder_leve_si_deja_dans_son_dossier_de_format(tmp_path: Path) -> None: | ||
| 74 | + (tmp_path / "raw").mkdir() | ||
| 75 | + fichier = tmp_path / "raw" / "photo.RAF" | ||
| 76 | + fichier.write_bytes(b"contenu-raw") | ||
| 77 | + | ||
| 78 | + with pytest.raises(FichierDejaDansSonDossierDeFormatError): | ||
| 79 | + retrograder(fichier) | ||
modified
packages/regine-gui/pyproject.toml +5 -2 | @@ -1,9 +1,12 @@ | ||
| 1 | 1 | [project] |
| 2 | 2 | name = "regine-gui" |
| 3 | 3 | version = "0.1.0" |
| 4 | -description = "Façade GUI de Régine — réservée, non implémentée (cf. specs/003-config-contexte-travail)." | |
| 4 | +description = "Façade GUI de Régine (PySide6) — appelle regine-core, aucune logique métier propre." | |
| 5 | 5 | requires-python = ">=3.11" |
| 6 | -dependencies = ["regine-core"] | |
| 6 | +dependencies = [ | |
| 7 | + "pyside6>=6.11.2", | |
| 8 | + "regine-core", | |
| 9 | +] | |
| 7 | 10 | |
| 8 | 11 | [tool.uv.sources] |
| 9 | 12 | regine-core = { workspace = true } |
| @@ -1,9 +1,12 @@ | |||
| 1 | [project] | 1 | [project] |
| 2 | name = "regine-gui" | 2 | name = "regine-gui" |
| 3 | version = "0.1.0" | 3 | version = "0.1.0" |
| 4 | -description = "Façade GUI de Régine — réservée, non implémentée (cf. specs/003-config-contexte-travail)." | 4 | +description = "Façade GUI de Régine (PySide6) — appelle regine-core, aucune logique métier propre." |
| 5 | requires-python = ">=3.11" | 5 | requires-python = ">=3.11" |
| 6 | -dependencies = ["regine-core"] | 6 | +dependencies = [ |
| 7 | + "pyside6>=6.11.2", | ||
| 8 | + "regine-core", | ||
| 9 | +] | ||
| 7 | 10 | ||
| 8 | [tool.uv.sources] | 11 | [tool.uv.sources] |
| 9 | regine-core = { workspace = true } | 12 | regine-core = { workspace = true } |
modified
packages/regine-gui/src/regine_gui/__init__.py +1 -1 | @@ -1 +1 @@ | ||
| 1 | -"""Façade GUI de Régine — paquet réservé, non implémenté (cf. specs/003-config-contexte-travail).""" | |
| 1 | +"""Façade GUI de Régine (PySide6) — appelle regine-core, aucune logique métier propre.""" | |
| @@ -1 +1 @@ | |||
| 1 | -"""Façade GUI de Régine — paquet réservé, non implémenté (cf. specs/003-config-contexte-travail).""" | 1 | +"""Façade GUI de Régine (PySide6) — appelle regine-core, aucune logique métier propre.""" |
added
packages/regine-gui/src/regine_gui/app.py +38 -0 | new file mode 100644 | ||
| @@ -0,0 +1,38 @@ | ||
| 1 | +"""Point d'entrée de l'application graphique de Régine (cf. specs/006).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import sys | |
| 6 | +from pathlib import Path | |
| 7 | + | |
| 8 | +from PySide6.QtWidgets import QApplication, QMainWindow, QTabWidget | |
| 9 | + | |
| 10 | +from regine_gui.consultation_view import EcranConsultation | |
| 11 | +from regine_gui.tri_view import EcranTri | |
| 12 | + | |
| 13 | + | |
| 14 | +class FenetrePrincipale(QMainWindow): | |
| 15 | + """Regroupe l'écran de tri (US1) et l'écran de consultation (US2) sous deux | |
| 16 | + onglets d'une même fenêtre — chacun appelle sa propre partie de regine-core, | |
| 17 | + aucune logique partagée entre les deux ici (Principe VI).""" | |
| 18 | + | |
| 19 | + def __init__(self, contexte_db: Path | None = None) -> None: | |
| 20 | + super().__init__() | |
| 21 | + self.setWindowTitle("Régine") | |
| 22 | + self.resize(760, 520) | |
| 23 | + | |
| 24 | + onglets = QTabWidget() | |
| 25 | + self.setCentralWidget(onglets) | |
| 26 | + onglets.addTab(EcranTri(), "Tri") | |
| 27 | + onglets.addTab(EcranConsultation(contexte_db=contexte_db), "Consultation") | |
| 28 | + | |
| 29 | + | |
| 30 | +def main(argv: list[str] | None = None) -> int: | |
| 31 | + app = QApplication(argv if argv is not None else sys.argv) | |
| 32 | + fenetre = FenetrePrincipale() | |
| 33 | + fenetre.show() | |
| 34 | + return app.exec() | |
| 35 | + | |
| 36 | + | |
| 37 | +if __name__ == "__main__": | |
| 38 | + raise SystemExit(main()) | |
| new file mode 100644 | |||
| @@ -0,0 +1,38 @@ | |||
| 1 | +"""Point d'entrée de l'application graphique de Régine (cf. specs/006).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import sys | ||
| 6 | +from pathlib import Path | ||
| 7 | + | ||
| 8 | +from PySide6.QtWidgets import QApplication, QMainWindow, QTabWidget | ||
| 9 | + | ||
| 10 | +from regine_gui.consultation_view import EcranConsultation | ||
| 11 | +from regine_gui.tri_view import EcranTri | ||
| 12 | + | ||
| 13 | + | ||
| 14 | +class FenetrePrincipale(QMainWindow): | ||
| 15 | + """Regroupe l'écran de tri (US1) et l'écran de consultation (US2) sous deux | ||
| 16 | + onglets d'une même fenêtre — chacun appelle sa propre partie de regine-core, | ||
| 17 | + aucune logique partagée entre les deux ici (Principe VI).""" | ||
| 18 | + | ||
| 19 | + def __init__(self, contexte_db: Path | None = None) -> None: | ||
| 20 | + super().__init__() | ||
| 21 | + self.setWindowTitle("Régine") | ||
| 22 | + self.resize(760, 520) | ||
| 23 | + | ||
| 24 | + onglets = QTabWidget() | ||
| 25 | + self.setCentralWidget(onglets) | ||
| 26 | + onglets.addTab(EcranTri(), "Tri") | ||
| 27 | + onglets.addTab(EcranConsultation(contexte_db=contexte_db), "Consultation") | ||
| 28 | + | ||
| 29 | + | ||
| 30 | +def main(argv: list[str] | None = None) -> int: | ||
| 31 | + app = QApplication(argv if argv is not None else sys.argv) | ||
| 32 | + fenetre = FenetrePrincipale() | ||
| 33 | + fenetre.show() | ||
| 34 | + return app.exec() | ||
| 35 | + | ||
| 36 | + | ||
| 37 | +if __name__ == "__main__": | ||
| 38 | + raise SystemExit(main()) | ||
added
packages/regine-gui/src/regine_gui/consultation_view.py +133 -0 | new file mode 100644 | ||
| @@ -0,0 +1,133 @@ | ||
| 1 | +"""Écran de consultation (US2) — appelle exclusivement `regine_core.archive.consultation` | |
| 2 | +et `regine_core.config`, aucune logique métier propre (Principe VI). Lecture seule | |
| 3 | +stricte : aucune action d'écriture (promotion, suppression) n'est proposée ici (FR-010). | |
| 4 | +""" | |
| 5 | + | |
| 6 | +from __future__ import annotations | |
| 7 | + | |
| 8 | +from pathlib import Path | |
| 9 | + | |
| 10 | +from PySide6.QtCore import Qt | |
| 11 | +from PySide6.QtWidgets import ( | |
| 12 | + QFileDialog, | |
| 13 | + QHBoxLayout, | |
| 14 | + QLabel, | |
| 15 | + QLineEdit, | |
| 16 | + QListWidget, | |
| 17 | + QListWidgetItem, | |
| 18 | + QMainWindow, | |
| 19 | + QMessageBox, | |
| 20 | + QPushButton, | |
| 21 | + QVBoxLayout, | |
| 22 | + QWidget, | |
| 23 | +) | |
| 24 | +from regine_core.archive.consultation import RestaurationCollisionError, rechercher, restaurer | |
| 25 | +from regine_core.config.context import charger_contexte | |
| 26 | +from regine_core.config.db import open_context_db | |
| 27 | +from regine_core.config.smb import resoudre_archive_locale | |
| 28 | + | |
| 29 | +_ROLE_CHEMIN = Qt.ItemDataRole.UserRole | |
| 30 | + | |
| 31 | + | |
| 32 | +class EcranConsultation(QMainWindow): | |
| 33 | + """Fenêtre de consultation en lecture seule de l'archive configurée | |
| 34 | + (`specs/003`) : recherche (nom de dossier, date, titre) et restauration ciblée | |
| 35 | + d'un fichier précis, sans checkout ni verrou du dossier entier (FR-008/009/010).""" | |
| 36 | + | |
| 37 | + def __init__(self, contexte_db: Path | None = None) -> None: | |
| 38 | + super().__init__() | |
| 39 | + self.setWindowTitle("Régine — Consultation de l'archive") | |
| 40 | + self.resize(720, 480) | |
| 41 | + self._contexte_db = contexte_db | |
| 42 | + self._archive_root_local: Path | None = None | |
| 43 | + | |
| 44 | + conteneur = QWidget() | |
| 45 | + self.setCentralWidget(conteneur) | |
| 46 | + disposition = QVBoxLayout(conteneur) | |
| 47 | + | |
| 48 | + barre = QHBoxLayout() | |
| 49 | + self._champ_recherche = QLineEdit() | |
| 50 | + self._champ_recherche.setPlaceholderText("Rechercher (nom de dossier, date, titre)…") | |
| 51 | + self._champ_recherche.returnPressed.connect(self._rechercher) | |
| 52 | + barre.addWidget(self._champ_recherche, stretch=1) | |
| 53 | + bouton_rechercher = QPushButton("Rechercher") | |
| 54 | + bouton_rechercher.clicked.connect(self._rechercher) | |
| 55 | + barre.addWidget(bouton_rechercher) | |
| 56 | + disposition.addLayout(barre) | |
| 57 | + | |
| 58 | + self._label_etat = QLabel("") | |
| 59 | + disposition.addWidget(self._label_etat) | |
| 60 | + | |
| 61 | + self._liste = QListWidget() | |
| 62 | + disposition.addWidget(self._liste) | |
| 63 | + | |
| 64 | + bouton_restaurer = QPushButton("Restaurer la sélection…") | |
| 65 | + bouton_restaurer.clicked.connect(self._restaurer_selection) | |
| 66 | + disposition.addWidget(bouton_restaurer) | |
| 67 | + | |
| 68 | + def _assurer_archive(self) -> Path | None: | |
| 69 | + if self._archive_root_local is not None: | |
| 70 | + return self._archive_root_local | |
| 71 | + | |
| 72 | + conn = open_context_db(self._contexte_db) | |
| 73 | + try: | |
| 74 | + contexte = charger_contexte(conn) | |
| 75 | + if contexte.archive_smb_path is None: | |
| 76 | + self._label_etat.setText("Aucune archive configurée (regine config set-paths).") | |
| 77 | + return None | |
| 78 | + | |
| 79 | + archive_root_local = resoudre_archive_locale(conn) | |
| 80 | + if archive_root_local is None: | |
| 81 | + self._label_etat.setText( | |
| 82 | + f"{contexte.archive_smb_path} n'est pas accessible — montage déclenché." | |
| 83 | + ) | |
| 84 | + return None | |
| 85 | + | |
| 86 | + self._archive_root_local = archive_root_local | |
| 87 | + self._label_etat.setText(f"Archive : {archive_root_local}") | |
| 88 | + return archive_root_local | |
| 89 | + finally: | |
| 90 | + conn.close() | |
| 91 | + | |
| 92 | + def _rechercher(self) -> None: | |
| 93 | + archive_root_local = self._assurer_archive() | |
| 94 | + if archive_root_local is None: | |
| 95 | + return | |
| 96 | + | |
| 97 | + requete = self._champ_recherche.text().strip() or None | |
| 98 | + resultats = rechercher(archive_root_local, requete) | |
| 99 | + | |
| 100 | + self._liste.clear() | |
| 101 | + for resultat in resultats: | |
| 102 | + marque = "[dossier]" if resultat.est_dossier else "[fichier]" | |
| 103 | + item = QListWidgetItem(f"{marque} {resultat.chemin_relatif}") | |
| 104 | + item.setData(_ROLE_CHEMIN, str(archive_root_local / resultat.chemin_relatif)) | |
| 105 | + self._liste.addItem(item) | |
| 106 | + | |
| 107 | + def _restaurer_selection(self) -> None: | |
| 108 | + item = self._liste.currentItem() | |
| 109 | + if item is None: | |
| 110 | + return | |
| 111 | + | |
| 112 | + chemin_source = Path(item.data(_ROLE_CHEMIN)) | |
| 113 | + if chemin_source.is_dir(): | |
| 114 | + QMessageBox.information( | |
| 115 | + self, "Sélection invalide", "Choisissez un fichier, pas un dossier." | |
| 116 | + ) | |
| 117 | + return | |
| 118 | + | |
| 119 | + chemin_destination_str, _ = QFileDialog.getSaveFileName( | |
| 120 | + self, "Restaurer vers…", chemin_source.name | |
| 121 | + ) | |
| 122 | + if not chemin_destination_str: | |
| 123 | + return | |
| 124 | + | |
| 125 | + try: | |
| 126 | + restaurer(chemin_source, Path(chemin_destination_str)) | |
| 127 | + except (FileNotFoundError, RestaurationCollisionError) as exc: | |
| 128 | + QMessageBox.warning(self, "Échec de la restauration", str(exc)) | |
| 129 | + return | |
| 130 | + | |
| 131 | + QMessageBox.information( | |
| 132 | + self, "Restauration réussie", f"Copié vers {chemin_destination_str}" | |
| 133 | + ) | |
| new file mode 100644 | |||
| @@ -0,0 +1,133 @@ | |||
| 1 | +"""Écran de consultation (US2) — appelle exclusivement `regine_core.archive.consultation` | ||
| 2 | +et `regine_core.config`, aucune logique métier propre (Principe VI). Lecture seule | ||
| 3 | +stricte : aucune action d'écriture (promotion, suppression) n'est proposée ici (FR-010). | ||
| 4 | +""" | ||
| 5 | + | ||
| 6 | +from __future__ import annotations | ||
| 7 | + | ||
| 8 | +from pathlib import Path | ||
| 9 | + | ||
| 10 | +from PySide6.QtCore import Qt | ||
| 11 | +from PySide6.QtWidgets import ( | ||
| 12 | + QFileDialog, | ||
| 13 | + QHBoxLayout, | ||
| 14 | + QLabel, | ||
| 15 | + QLineEdit, | ||
| 16 | + QListWidget, | ||
| 17 | + QListWidgetItem, | ||
| 18 | + QMainWindow, | ||
| 19 | + QMessageBox, | ||
| 20 | + QPushButton, | ||
| 21 | + QVBoxLayout, | ||
| 22 | + QWidget, | ||
| 23 | +) | ||
| 24 | +from regine_core.archive.consultation import RestaurationCollisionError, rechercher, restaurer | ||
| 25 | +from regine_core.config.context import charger_contexte | ||
| 26 | +from regine_core.config.db import open_context_db | ||
| 27 | +from regine_core.config.smb import resoudre_archive_locale | ||
| 28 | + | ||
| 29 | +_ROLE_CHEMIN = Qt.ItemDataRole.UserRole | ||
| 30 | + | ||
| 31 | + | ||
| 32 | +class EcranConsultation(QMainWindow): | ||
| 33 | + """Fenêtre de consultation en lecture seule de l'archive configurée | ||
| 34 | + (`specs/003`) : recherche (nom de dossier, date, titre) et restauration ciblée | ||
| 35 | + d'un fichier précis, sans checkout ni verrou du dossier entier (FR-008/009/010).""" | ||
| 36 | + | ||
| 37 | + def __init__(self, contexte_db: Path | None = None) -> None: | ||
| 38 | + super().__init__() | ||
| 39 | + self.setWindowTitle("Régine — Consultation de l'archive") | ||
| 40 | + self.resize(720, 480) | ||
| 41 | + self._contexte_db = contexte_db | ||
| 42 | + self._archive_root_local: Path | None = None | ||
| 43 | + | ||
| 44 | + conteneur = QWidget() | ||
| 45 | + self.setCentralWidget(conteneur) | ||
| 46 | + disposition = QVBoxLayout(conteneur) | ||
| 47 | + | ||
| 48 | + barre = QHBoxLayout() | ||
| 49 | + self._champ_recherche = QLineEdit() | ||
| 50 | + self._champ_recherche.setPlaceholderText("Rechercher (nom de dossier, date, titre)…") | ||
| 51 | + self._champ_recherche.returnPressed.connect(self._rechercher) | ||
| 52 | + barre.addWidget(self._champ_recherche, stretch=1) | ||
| 53 | + bouton_rechercher = QPushButton("Rechercher") | ||
| 54 | + bouton_rechercher.clicked.connect(self._rechercher) | ||
| 55 | + barre.addWidget(bouton_rechercher) | ||
| 56 | + disposition.addLayout(barre) | ||
| 57 | + | ||
| 58 | + self._label_etat = QLabel("") | ||
| 59 | + disposition.addWidget(self._label_etat) | ||
| 60 | + | ||
| 61 | + self._liste = QListWidget() | ||
| 62 | + disposition.addWidget(self._liste) | ||
| 63 | + | ||
| 64 | + bouton_restaurer = QPushButton("Restaurer la sélection…") | ||
| 65 | + bouton_restaurer.clicked.connect(self._restaurer_selection) | ||
| 66 | + disposition.addWidget(bouton_restaurer) | ||
| 67 | + | ||
| 68 | + def _assurer_archive(self) -> Path | None: | ||
| 69 | + if self._archive_root_local is not None: | ||
| 70 | + return self._archive_root_local | ||
| 71 | + | ||
| 72 | + conn = open_context_db(self._contexte_db) | ||
| 73 | + try: | ||
| 74 | + contexte = charger_contexte(conn) | ||
| 75 | + if contexte.archive_smb_path is None: | ||
| 76 | + self._label_etat.setText("Aucune archive configurée (regine config set-paths).") | ||
| 77 | + return None | ||
| 78 | + | ||
| 79 | + archive_root_local = resoudre_archive_locale(conn) | ||
| 80 | + if archive_root_local is None: | ||
| 81 | + self._label_etat.setText( | ||
| 82 | + f"{contexte.archive_smb_path} n'est pas accessible — montage déclenché." | ||
| 83 | + ) | ||
| 84 | + return None | ||
| 85 | + | ||
| 86 | + self._archive_root_local = archive_root_local | ||
| 87 | + self._label_etat.setText(f"Archive : {archive_root_local}") | ||
| 88 | + return archive_root_local | ||
| 89 | + finally: | ||
| 90 | + conn.close() | ||
| 91 | + | ||
| 92 | + def _rechercher(self) -> None: | ||
| 93 | + archive_root_local = self._assurer_archive() | ||
| 94 | + if archive_root_local is None: | ||
| 95 | + return | ||
| 96 | + | ||
| 97 | + requete = self._champ_recherche.text().strip() or None | ||
| 98 | + resultats = rechercher(archive_root_local, requete) | ||
| 99 | + | ||
| 100 | + self._liste.clear() | ||
| 101 | + for resultat in resultats: | ||
| 102 | + marque = "[dossier]" if resultat.est_dossier else "[fichier]" | ||
| 103 | + item = QListWidgetItem(f"{marque} {resultat.chemin_relatif}") | ||
| 104 | + item.setData(_ROLE_CHEMIN, str(archive_root_local / resultat.chemin_relatif)) | ||
| 105 | + self._liste.addItem(item) | ||
| 106 | + | ||
| 107 | + def _restaurer_selection(self) -> None: | ||
| 108 | + item = self._liste.currentItem() | ||
| 109 | + if item is None: | ||
| 110 | + return | ||
| 111 | + | ||
| 112 | + chemin_source = Path(item.data(_ROLE_CHEMIN)) | ||
| 113 | + if chemin_source.is_dir(): | ||
| 114 | + QMessageBox.information( | ||
| 115 | + self, "Sélection invalide", "Choisissez un fichier, pas un dossier." | ||
| 116 | + ) | ||
| 117 | + return | ||
| 118 | + | ||
| 119 | + chemin_destination_str, _ = QFileDialog.getSaveFileName( | ||
| 120 | + self, "Restaurer vers…", chemin_source.name | ||
| 121 | + ) | ||
| 122 | + if not chemin_destination_str: | ||
| 123 | + return | ||
| 124 | + | ||
| 125 | + try: | ||
| 126 | + restaurer(chemin_source, Path(chemin_destination_str)) | ||
| 127 | + except (FileNotFoundError, RestaurationCollisionError) as exc: | ||
| 128 | + QMessageBox.warning(self, "Échec de la restauration", str(exc)) | ||
| 129 | + return | ||
| 130 | + | ||
| 131 | + QMessageBox.information( | ||
| 132 | + self, "Restauration réussie", f"Copié vers {chemin_destination_str}" | ||
| 133 | + ) | ||
added
packages/regine-gui/src/regine_gui/tri_view.py +120 -0 | new file mode 100644 | ||
| @@ -0,0 +1,120 @@ | ||
| 1 | +"""Écran de tri/culling (US1) — appelle exclusivement `regine_core.dossier.selection`, | |
| 2 | +aucune logique de regroupement/promotion propre à la GUI (Principe VI). | |
| 3 | + | |
| 4 | +La promotion/rétrogradation cible toujours le dossier explicitement ouvert dans cet | |
| 5 | +écran (`self._dossier`) : pour la « promotion en deux temps » d'un dossier parent de | |
| 6 | +voyage (racine de l'étape, puis racine du dossier parent — section 10 des notes | |
| 7 | +d'archivage), l'utilisateur ouvre chaque niveau séparément comme deux gestes | |
| 8 | +explicites et distincts, jamais un raccourci automatique d'un niveau à l'autre. | |
| 9 | +""" | |
| 10 | + | |
| 11 | +from __future__ import annotations | |
| 12 | + | |
| 13 | +from pathlib import Path | |
| 14 | + | |
| 15 | +from PySide6.QtCore import Qt | |
| 16 | +from PySide6.QtWidgets import ( | |
| 17 | + QFileDialog, | |
| 18 | + QHBoxLayout, | |
| 19 | + QLabel, | |
| 20 | + QListWidget, | |
| 21 | + QListWidgetItem, | |
| 22 | + QMainWindow, | |
| 23 | + QMessageBox, | |
| 24 | + QPushButton, | |
| 25 | + QVBoxLayout, | |
| 26 | + QWidget, | |
| 27 | +) | |
| 28 | +from regine_core.dossier.selection import ( | |
| 29 | + EntreeRegroupee, | |
| 30 | + FichierDejaDansSonDossierDeFormatError, | |
| 31 | + StructureDossierInvalideError, | |
| 32 | + promouvoir, | |
| 33 | + regrouper, | |
| 34 | + retrograder, | |
| 35 | +) | |
| 36 | + | |
| 37 | +_ROLE_CHEMIN = Qt.ItemDataRole.UserRole | |
| 38 | + | |
| 39 | + | |
| 40 | +class EcranTri(QMainWindow): | |
| 41 | + """Fenêtre de tri : ouvre un dossier de travail local, affiche ses entrées | |
| 42 | + regroupées (RAW+JPEG jumeaux fusionnés, FR-002), promeut/rétrograde un fichier | |
| 43 | + à la fois (FR-003) — jamais de suppression directe (FR-006).""" | |
| 44 | + | |
| 45 | + def __init__(self) -> None: | |
| 46 | + super().__init__() | |
| 47 | + self.setWindowTitle("Régine — Tri") | |
| 48 | + self.resize(720, 480) | |
| 49 | + self._dossier: Path | None = None | |
| 50 | + | |
| 51 | + conteneur = QWidget() | |
| 52 | + self.setCentralWidget(conteneur) | |
| 53 | + disposition = QVBoxLayout(conteneur) | |
| 54 | + | |
| 55 | + barre = QHBoxLayout() | |
| 56 | + bouton_ouvrir = QPushButton("Ouvrir un dossier…") | |
| 57 | + bouton_ouvrir.clicked.connect(self._ouvrir_dossier) | |
| 58 | + barre.addWidget(bouton_ouvrir) | |
| 59 | + self._label_dossier = QLabel("Aucun dossier ouvert") | |
| 60 | + barre.addWidget(self._label_dossier, stretch=1) | |
| 61 | + disposition.addLayout(barre) | |
| 62 | + | |
| 63 | + self._liste = QListWidget() | |
| 64 | + self._liste.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) | |
| 65 | + disposition.addWidget(self._liste) | |
| 66 | + | |
| 67 | + actions = QHBoxLayout() | |
| 68 | + bouton_promouvoir = QPushButton("Promouvoir la sélection") | |
| 69 | + bouton_promouvoir.clicked.connect(self._promouvoir_selection) | |
| 70 | + actions.addWidget(bouton_promouvoir) | |
| 71 | + bouton_retrograder = QPushButton("Rétrograder la sélection") | |
| 72 | + bouton_retrograder.clicked.connect(self._retrograder_selection) | |
| 73 | + actions.addWidget(bouton_retrograder) | |
| 74 | + disposition.addLayout(actions) | |
| 75 | + | |
| 76 | + def _ouvrir_dossier(self) -> None: | |
| 77 | + chemin = QFileDialog.getExistingDirectory(self, "Ouvrir un dossier de travail") | |
| 78 | + if chemin: | |
| 79 | + self._charger_dossier(Path(chemin)) | |
| 80 | + | |
| 81 | + def _charger_dossier(self, dossier: Path) -> None: | |
| 82 | + try: | |
| 83 | + entrees = regrouper(dossier) | |
| 84 | + except StructureDossierInvalideError as exc: | |
| 85 | + QMessageBox.warning(self, "Dossier non reconnu", str(exc)) | |
| 86 | + return | |
| 87 | + | |
| 88 | + self._dossier = dossier | |
| 89 | + self._label_dossier.setText(str(dossier)) | |
| 90 | + self._remplir_liste(entrees, dossier) | |
| 91 | + | |
| 92 | + def _remplir_liste(self, entrees: list[EntreeRegroupee], dossier: Path) -> None: | |
| 93 | + self._liste.clear() | |
| 94 | + for entree in entrees: | |
| 95 | + for fichier in entree.fichiers: | |
| 96 | + promu = fichier.parent == dossier | |
| 97 | + statut = "★ promu" if promu else "non promu" | |
| 98 | + item = QListWidgetItem(f"{entree.nom_base} — {fichier.name} ({statut})") | |
| 99 | + item.setData(_ROLE_CHEMIN, str(fichier)) | |
| 100 | + self._liste.addItem(item) | |
| 101 | + | |
| 102 | + def _fichiers_selectionnes(self) -> list[Path]: | |
| 103 | + return [Path(item.data(_ROLE_CHEMIN)) for item in self._liste.selectedItems()] | |
| 104 | + | |
| 105 | + def _promouvoir_selection(self) -> None: | |
| 106 | + if self._dossier is None: | |
| 107 | + return | |
| 108 | + for fichier in self._fichiers_selectionnes(): | |
| 109 | + promouvoir(self._dossier, fichier) | |
| 110 | + self._charger_dossier(self._dossier) | |
| 111 | + | |
| 112 | + def _retrograder_selection(self) -> None: | |
| 113 | + if self._dossier is None: | |
| 114 | + return | |
| 115 | + for fichier in self._fichiers_selectionnes(): | |
| 116 | + try: | |
| 117 | + retrograder(fichier) | |
| 118 | + except (FichierDejaDansSonDossierDeFormatError, ValueError): | |
| 119 | + continue | |
| 120 | + self._charger_dossier(self._dossier) | |
| new file mode 100644 | |||
| @@ -0,0 +1,120 @@ | |||
| 1 | +"""Écran de tri/culling (US1) — appelle exclusivement `regine_core.dossier.selection`, | ||
| 2 | +aucune logique de regroupement/promotion propre à la GUI (Principe VI). | ||
| 3 | + | ||
| 4 | +La promotion/rétrogradation cible toujours le dossier explicitement ouvert dans cet | ||
| 5 | +écran (`self._dossier`) : pour la « promotion en deux temps » d'un dossier parent de | ||
| 6 | +voyage (racine de l'étape, puis racine du dossier parent — section 10 des notes | ||
| 7 | +d'archivage), l'utilisateur ouvre chaque niveau séparément comme deux gestes | ||
| 8 | +explicites et distincts, jamais un raccourci automatique d'un niveau à l'autre. | ||
| 9 | +""" | ||
| 10 | + | ||
| 11 | +from __future__ import annotations | ||
| 12 | + | ||
| 13 | +from pathlib import Path | ||
| 14 | + | ||
| 15 | +from PySide6.QtCore import Qt | ||
| 16 | +from PySide6.QtWidgets import ( | ||
| 17 | + QFileDialog, | ||
| 18 | + QHBoxLayout, | ||
| 19 | + QLabel, | ||
| 20 | + QListWidget, | ||
| 21 | + QListWidgetItem, | ||
| 22 | + QMainWindow, | ||
| 23 | + QMessageBox, | ||
| 24 | + QPushButton, | ||
| 25 | + QVBoxLayout, | ||
| 26 | + QWidget, | ||
| 27 | +) | ||
| 28 | +from regine_core.dossier.selection import ( | ||
| 29 | + EntreeRegroupee, | ||
| 30 | + FichierDejaDansSonDossierDeFormatError, | ||
| 31 | + StructureDossierInvalideError, | ||
| 32 | + promouvoir, | ||
| 33 | + regrouper, | ||
| 34 | + retrograder, | ||
| 35 | +) | ||
| 36 | + | ||
| 37 | +_ROLE_CHEMIN = Qt.ItemDataRole.UserRole | ||
| 38 | + | ||
| 39 | + | ||
| 40 | +class EcranTri(QMainWindow): | ||
| 41 | + """Fenêtre de tri : ouvre un dossier de travail local, affiche ses entrées | ||
| 42 | + regroupées (RAW+JPEG jumeaux fusionnés, FR-002), promeut/rétrograde un fichier | ||
| 43 | + à la fois (FR-003) — jamais de suppression directe (FR-006).""" | ||
| 44 | + | ||
| 45 | + def __init__(self) -> None: | ||
| 46 | + super().__init__() | ||
| 47 | + self.setWindowTitle("Régine — Tri") | ||
| 48 | + self.resize(720, 480) | ||
| 49 | + self._dossier: Path | None = None | ||
| 50 | + | ||
| 51 | + conteneur = QWidget() | ||
| 52 | + self.setCentralWidget(conteneur) | ||
| 53 | + disposition = QVBoxLayout(conteneur) | ||
| 54 | + | ||
| 55 | + barre = QHBoxLayout() | ||
| 56 | + bouton_ouvrir = QPushButton("Ouvrir un dossier…") | ||
| 57 | + bouton_ouvrir.clicked.connect(self._ouvrir_dossier) | ||
| 58 | + barre.addWidget(bouton_ouvrir) | ||
| 59 | + self._label_dossier = QLabel("Aucun dossier ouvert") | ||
| 60 | + barre.addWidget(self._label_dossier, stretch=1) | ||
| 61 | + disposition.addLayout(barre) | ||
| 62 | + | ||
| 63 | + self._liste = QListWidget() | ||
| 64 | + self._liste.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) | ||
| 65 | + disposition.addWidget(self._liste) | ||
| 66 | + | ||
| 67 | + actions = QHBoxLayout() | ||
| 68 | + bouton_promouvoir = QPushButton("Promouvoir la sélection") | ||
| 69 | + bouton_promouvoir.clicked.connect(self._promouvoir_selection) | ||
| 70 | + actions.addWidget(bouton_promouvoir) | ||
| 71 | + bouton_retrograder = QPushButton("Rétrograder la sélection") | ||
| 72 | + bouton_retrograder.clicked.connect(self._retrograder_selection) | ||
| 73 | + actions.addWidget(bouton_retrograder) | ||
| 74 | + disposition.addLayout(actions) | ||
| 75 | + | ||
| 76 | + def _ouvrir_dossier(self) -> None: | ||
| 77 | + chemin = QFileDialog.getExistingDirectory(self, "Ouvrir un dossier de travail") | ||
| 78 | + if chemin: | ||
| 79 | + self._charger_dossier(Path(chemin)) | ||
| 80 | + | ||
| 81 | + def _charger_dossier(self, dossier: Path) -> None: | ||
| 82 | + try: | ||
| 83 | + entrees = regrouper(dossier) | ||
| 84 | + except StructureDossierInvalideError as exc: | ||
| 85 | + QMessageBox.warning(self, "Dossier non reconnu", str(exc)) | ||
| 86 | + return | ||
| 87 | + | ||
| 88 | + self._dossier = dossier | ||
| 89 | + self._label_dossier.setText(str(dossier)) | ||
| 90 | + self._remplir_liste(entrees, dossier) | ||
| 91 | + | ||
| 92 | + def _remplir_liste(self, entrees: list[EntreeRegroupee], dossier: Path) -> None: | ||
| 93 | + self._liste.clear() | ||
| 94 | + for entree in entrees: | ||
| 95 | + for fichier in entree.fichiers: | ||
| 96 | + promu = fichier.parent == dossier | ||
| 97 | + statut = "★ promu" if promu else "non promu" | ||
| 98 | + item = QListWidgetItem(f"{entree.nom_base} — {fichier.name} ({statut})") | ||
| 99 | + item.setData(_ROLE_CHEMIN, str(fichier)) | ||
| 100 | + self._liste.addItem(item) | ||
| 101 | + | ||
| 102 | + def _fichiers_selectionnes(self) -> list[Path]: | ||
| 103 | + return [Path(item.data(_ROLE_CHEMIN)) for item in self._liste.selectedItems()] | ||
| 104 | + | ||
| 105 | + def _promouvoir_selection(self) -> None: | ||
| 106 | + if self._dossier is None: | ||
| 107 | + return | ||
| 108 | + for fichier in self._fichiers_selectionnes(): | ||
| 109 | + promouvoir(self._dossier, fichier) | ||
| 110 | + self._charger_dossier(self._dossier) | ||
| 111 | + | ||
| 112 | + def _retrograder_selection(self) -> None: | ||
| 113 | + if self._dossier is None: | ||
| 114 | + return | ||
| 115 | + for fichier in self._fichiers_selectionnes(): | ||
| 116 | + try: | ||
| 117 | + retrograder(fichier) | ||
| 118 | + except (FichierDejaDansSonDossierDeFormatError, ValueError): | ||
| 119 | + continue | ||
| 120 | + self._charger_dossier(self._dossier) | ||
added
specs/006-gui-tri-consultation/checklists/requirements.md +35 -0 | new file mode 100644 | ||
| @@ -0,0 +1,35 @@ | ||
| 1 | +# Specification Quality Checklist: Interface graphique de tri et de consultation | |
| 2 | + | |
| 3 | +**Purpose**: Validate specification completeness and quality before proceeding to planning | |
| 4 | +**Created**: 2026-09-20 | |
| 5 | +**Feature**: [spec.md](../spec.md) | |
| 6 | + | |
| 7 | +## Content Quality | |
| 8 | + | |
| 9 | +- [X] No implementation details (languages, frameworks, APIs) | |
| 10 | +- [X] Focused on user value and business needs | |
| 11 | +- [X] Written for non-technical stakeholders | |
| 12 | +- [X] All mandatory sections completed | |
| 13 | + | |
| 14 | +## Requirement Completeness | |
| 15 | + | |
| 16 | +- [X] No [NEEDS CLARIFICATION] markers remain | |
| 17 | +- [X] Requirements are testable and unambiguous | |
| 18 | +- [X] Success criteria are measurable | |
| 19 | +- [X] Success criteria are technology-agnostic (no implementation details) | |
| 20 | +- [X] All acceptance scenarios are defined | |
| 21 | +- [X] Edge cases are identified | |
| 22 | +- [X] Scope is clearly bounded | |
| 23 | +- [X] Dependencies and assumptions identified | |
| 24 | + | |
| 25 | +## Feature Readiness | |
| 26 | + | |
| 27 | +- [X] All functional requirements have clear acceptance criteria | |
| 28 | +- [X] User scenarios cover primary flows | |
| 29 | +- [X] Feature meets measurable outcomes defined in Success Criteria | |
| 30 | +- [X] No implementation details leak into specification | |
| 31 | + | |
| 32 | +## Notes | |
| 33 | + | |
| 34 | +- Aucune ambiguïté n'a nécessité de marqueur `[NEEDS CLARIFICATION]` : la description fournie s'appuyait déjà sur des décisions actées dans `docs/interface-cli-gui-architecture.md` et `docs/archivage-photo-elements-cles.md` section 10. | |
| 35 | +- Écart de conception identifié et intégré directement dans la spec (FR-007, Assumptions) : la promotion/rétrogradation à la racine de sélection est une capacité entièrement nouvelle, absente de tout module existant de `regine-core` — le Principe CLI-first de la constitution impose qu'elle soit exposée en CLI, pas seulement dans la GUI. | |
| new file mode 100644 | |||
| @@ -0,0 +1,35 @@ | |||
| 1 | +# Specification Quality Checklist: Interface graphique de tri et de consultation | ||
| 2 | + | ||
| 3 | +**Purpose**: Validate specification completeness and quality before proceeding to planning | ||
| 4 | +**Created**: 2026-09-20 | ||
| 5 | +**Feature**: [spec.md](../spec.md) | ||
| 6 | + | ||
| 7 | +## Content Quality | ||
| 8 | + | ||
| 9 | +- [X] No implementation details (languages, frameworks, APIs) | ||
| 10 | +- [X] Focused on user value and business needs | ||
| 11 | +- [X] Written for non-technical stakeholders | ||
| 12 | +- [X] All mandatory sections completed | ||
| 13 | + | ||
| 14 | +## Requirement Completeness | ||
| 15 | + | ||
| 16 | +- [X] No [NEEDS CLARIFICATION] markers remain | ||
| 17 | +- [X] Requirements are testable and unambiguous | ||
| 18 | +- [X] Success criteria are measurable | ||
| 19 | +- [X] Success criteria are technology-agnostic (no implementation details) | ||
| 20 | +- [X] All acceptance scenarios are defined | ||
| 21 | +- [X] Edge cases are identified | ||
| 22 | +- [X] Scope is clearly bounded | ||
| 23 | +- [X] Dependencies and assumptions identified | ||
| 24 | + | ||
| 25 | +## Feature Readiness | ||
| 26 | + | ||
| 27 | +- [X] All functional requirements have clear acceptance criteria | ||
| 28 | +- [X] User scenarios cover primary flows | ||
| 29 | +- [X] Feature meets measurable outcomes defined in Success Criteria | ||
| 30 | +- [X] No implementation details leak into specification | ||
| 31 | + | ||
| 32 | +## Notes | ||
| 33 | + | ||
| 34 | +- Aucune ambiguïté n'a nécessité de marqueur `[NEEDS CLARIFICATION]` : la description fournie s'appuyait déjà sur des décisions actées dans `docs/interface-cli-gui-architecture.md` et `docs/archivage-photo-elements-cles.md` section 10. | ||
| 35 | +- Écart de conception identifié et intégré directement dans la spec (FR-007, Assumptions) : la promotion/rétrogradation à la racine de sélection est une capacité entièrement nouvelle, absente de tout module existant de `regine-core` — le Principe CLI-first de la constitution impose qu'elle soit exposée en CLI, pas seulement dans la GUI. | ||
added
specs/006-gui-tri-consultation/contracts/cli-archive-consultation.md +37 -0 | new file mode 100644 | ||
| @@ -0,0 +1,37 @@ | ||
| 1 | +# Contrat CLI : `regine browse` / `regine restore` (US2) | |
| 2 | + | |
| 3 | +Étend `regine-cli`'s `archive_cmd.py` existant (`specs/005-checkout-reconciliation`, déjà porteur de `checkout`/`reconcile`) plutôt que de créer une façade séparée — même thème (« l'archive »), pas de logique métier propre (Principe VI). **Écart trouvé à l'implémentation par rapport à la première version de ce contrat** : `checkout`/`reconcile` sont déjà des sous-commandes de premier niveau (`regine checkout ...`, pas `regine archive checkout ...`) — `browse`/`restore` suivent donc la même convention déjà établie, sans introduire un niveau `archive` intermédiaire qui n'existe nulle part ailleurs dans la CLI. Lit l'archive configurée via `specs/003-config-contexte-travail` plutôt qu'un chemin passé à chaque appel : consulter « l'archive » sous-entend l'archive déjà configurée. | |
| 4 | + | |
| 5 | +## `regine browse [--search TEXTE] [--json] [--contexte-db CHEMIN]` | |
| 6 | + | |
| 7 | +Parcourt l'archive configurée en lecture seule (US2 FR-008). | |
| 8 | + | |
| 9 | +**Entrée** : | |
| 10 | +``` | |
| 11 | +regine browse --search "Montenegro" | |
| 12 | +``` | |
| 13 | + | |
| 14 | +**Déroulé** : | |
| 15 | +1. Charge le contexte de travail (`regine_core.config.context.charger_contexte`) ; si aucune archive n'est configurée, erreur claire sur stderr, code non-zéro. | |
| 16 | +2. Vérifie la disponibilité du partage (`regine_core.config.smb.assurer_disponibilite`) ; si indisponible, déclenche le montage natif et l'indique (comportement déjà établi par `specs/003`, cf. FR-011), code non-zéro (rien à parcourir tant que l'accès n'est pas rétabli). | |
| 17 | +3. Parcourt les dossiers/photos correspondant à `--search` (nom de dossier, date, ou texte de titre — sur le nom du dossier, pas de recherche plein texte dans les métadonnées). | |
| 18 | + | |
| 19 | +**Sorties** : | |
| 20 | +- Succès : liste des dossiers/photos correspondants sur stdout (texte ou JSON selon `--json`), code `0`. Aucun fichier n'est copié localement à cette étape (FR-010, lecture seule). | |
| 21 | +- Aucun résultat : message explicite, pas une erreur, code `0`. | |
| 22 | + | |
| 23 | +## `regine restore --chemin CHEMIN_RELATIF --destination CHEMIN_LOCAL [--contexte-db CHEMIN]` | |
| 24 | + | |
| 25 | +Restaure une copie vérifiée d'un fichier précis de l'archive vers un emplacement local (US2 FR-009), sans checkout ni verrou du dossier entier. | |
| 26 | + | |
| 27 | +**Entrée** : | |
| 28 | +``` | |
| 29 | +regine restore --chemin voyage/2026-08_Montenegro/2026-08-12_Kotor/raw/2026-08-12_Kotor_RD0001.RAF \ | |
| 30 | + --destination ~/Desktop/RD0001.RAF | |
| 31 | +``` | |
| 32 | + | |
| 33 | +**Sorties** : | |
| 34 | +- Succès : somme de contrôle vérifiée, confirmation sur stdout, code `0` (SC-004). | |
| 35 | +- `--destination` existe déjà avec un contenu différent (Edge Case, FR-012) : erreur sur stderr, rien n'est écrasé, code non-zéro — même principe que `regine_core.import_carte.push.CollisionNomArchiveError`. | |
| 36 | +- `--chemin` introuvable dans l'archive : erreur sur stderr, code non-zéro. | |
| 37 | +- Archive inaccessible : même comportement que `browse` (assistance au montage, `specs/003`). | |
| new file mode 100644 | |||
| @@ -0,0 +1,37 @@ | |||
| 1 | +# Contrat CLI : `regine browse` / `regine restore` (US2) | ||
| 2 | + | ||
| 3 | +Étend `regine-cli`'s `archive_cmd.py` existant (`specs/005-checkout-reconciliation`, déjà porteur de `checkout`/`reconcile`) plutôt que de créer une façade séparée — même thème (« l'archive »), pas de logique métier propre (Principe VI). **Écart trouvé à l'implémentation par rapport à la première version de ce contrat** : `checkout`/`reconcile` sont déjà des sous-commandes de premier niveau (`regine checkout ...`, pas `regine archive checkout ...`) — `browse`/`restore` suivent donc la même convention déjà établie, sans introduire un niveau `archive` intermédiaire qui n'existe nulle part ailleurs dans la CLI. Lit l'archive configurée via `specs/003-config-contexte-travail` plutôt qu'un chemin passé à chaque appel : consulter « l'archive » sous-entend l'archive déjà configurée. | ||
| 4 | + | ||
| 5 | +## `regine browse [--search TEXTE] [--json] [--contexte-db CHEMIN]` | ||
| 6 | + | ||
| 7 | +Parcourt l'archive configurée en lecture seule (US2 FR-008). | ||
| 8 | + | ||
| 9 | +**Entrée** : | ||
| 10 | +``` | ||
| 11 | +regine browse --search "Montenegro" | ||
| 12 | +``` | ||
| 13 | + | ||
| 14 | +**Déroulé** : | ||
| 15 | +1. Charge le contexte de travail (`regine_core.config.context.charger_contexte`) ; si aucune archive n'est configurée, erreur claire sur stderr, code non-zéro. | ||
| 16 | +2. Vérifie la disponibilité du partage (`regine_core.config.smb.assurer_disponibilite`) ; si indisponible, déclenche le montage natif et l'indique (comportement déjà établi par `specs/003`, cf. FR-011), code non-zéro (rien à parcourir tant que l'accès n'est pas rétabli). | ||
| 17 | +3. Parcourt les dossiers/photos correspondant à `--search` (nom de dossier, date, ou texte de titre — sur le nom du dossier, pas de recherche plein texte dans les métadonnées). | ||
| 18 | + | ||
| 19 | +**Sorties** : | ||
| 20 | +- Succès : liste des dossiers/photos correspondants sur stdout (texte ou JSON selon `--json`), code `0`. Aucun fichier n'est copié localement à cette étape (FR-010, lecture seule). | ||
| 21 | +- Aucun résultat : message explicite, pas une erreur, code `0`. | ||
| 22 | + | ||
| 23 | +## `regine restore --chemin CHEMIN_RELATIF --destination CHEMIN_LOCAL [--contexte-db CHEMIN]` | ||
| 24 | + | ||
| 25 | +Restaure une copie vérifiée d'un fichier précis de l'archive vers un emplacement local (US2 FR-009), sans checkout ni verrou du dossier entier. | ||
| 26 | + | ||
| 27 | +**Entrée** : | ||
| 28 | +``` | ||
| 29 | +regine restore --chemin voyage/2026-08_Montenegro/2026-08-12_Kotor/raw/2026-08-12_Kotor_RD0001.RAF \ | ||
| 30 | + --destination ~/Desktop/RD0001.RAF | ||
| 31 | +``` | ||
| 32 | + | ||
| 33 | +**Sorties** : | ||
| 34 | +- Succès : somme de contrôle vérifiée, confirmation sur stdout, code `0` (SC-004). | ||
| 35 | +- `--destination` existe déjà avec un contenu différent (Edge Case, FR-012) : erreur sur stderr, rien n'est écrasé, code non-zéro — même principe que `regine_core.import_carte.push.CollisionNomArchiveError`. | ||
| 36 | +- `--chemin` introuvable dans l'archive : erreur sur stderr, code non-zéro. | ||
| 37 | +- Archive inaccessible : même comportement que `browse` (assistance au montage, `specs/003`). | ||
added
specs/006-gui-tri-consultation/contracts/cli-dossier-selection.md +48 -0 | new file mode 100644 | ||
| @@ -0,0 +1,48 @@ | ||
| 1 | +# Contrat CLI : `regine dossier` (US1, réponse au Principe CLI-first — FR-007) | |
| 2 | + | |
| 3 | +Façade fine de `regine_core.dossier.selection` (cf. Principe CLI-first). Protocole texte : arguments en entrée, résultat sur stdout, erreurs sur stderr, code de sortie non-zéro en cas d'échec. Cette surface DOIT exister et fonctionner avant que l'équivalent GUI ne soit considéré livré (FR-007). | |
| 4 | + | |
| 5 | +## `regine dossier list <chemin_dossier>` | |
| 6 | + | |
| 7 | +Liste les entrées regroupées (RAW+JPEG jumeaux fusionnés) d'un dossier de travail local, avec leur statut de promotion. | |
| 8 | + | |
| 9 | +**Entrée** : | |
| 10 | +``` | |
| 11 | +regine dossier list /chemin/local/2026-08-15_Sortie [--json] | |
| 12 | +``` | |
| 13 | + | |
| 14 | +**Sorties** : | |
| 15 | +- Succès : une ligne par entrée regroupée (`nom_base`, fichiers présents, promu oui/non pour chacun), code `0`. | |
| 16 | +- `chemin_dossier` n'a pas la structure attendue d'un dossier Régine (Edge Case, FR-013) : message clair sur stderr, code non-zéro. | |
| 17 | + | |
| 18 | +## `regine dossier promote <chemin_dossier> <chemin_fichier>` | |
| 19 | + | |
| 20 | +Promeut un fichier individuel à la racine de `chemin_dossier` (FR-003). `chemin_dossier` est explicite plutôt que déduit de `chemin_fichier` : pour un dossier parent de voyage, la même commande sert aux deux gestes distincts de la « promotion en deux temps » (section 10 des notes d'archivage) — une fois avec le chemin de l'étape, une seconde fois avec le chemin du dossier parent — jamais deviné automatiquement (cf. `contracts/regine-core-api.md`, écart trouvé par un test à l'implémentation). | |
| 21 | + | |
| 22 | +**Entrée** : | |
| 23 | +``` | |
| 24 | +regine dossier promote /chemin/local/2026-08-15_Sortie /chemin/local/2026-08-15_Sortie/raw/2026-08-15_Sortie_RD0001.RAF | |
| 25 | +``` | |
| 26 | + | |
| 27 | +**Sorties** : | |
| 28 | +- Succès : chemin final affiché sur stdout, code `0`. | |
| 29 | +- `chemin_fichier` déjà à la racine de `chemin_dossier` (rien à faire) : message informatif sur stdout, code `0` (idempotent, pas une erreur). | |
| 30 | +- `chemin_fichier` n'est pas sous `chemin_dossier` : erreur sur stderr, code non-zéro. | |
| 31 | + | |
| 32 | +## `regine dossier demote <chemin_fichier>` | |
| 33 | + | |
| 34 | +Rétrograde un fichier de la racine vers son dossier de format d'origine, déduit de son extension (FR-003). | |
| 35 | + | |
| 36 | +**Entrée** : | |
| 37 | +``` | |
| 38 | +regine dossier demote /chemin/local/2026-08-15_Sortie/2026-08-15_Sortie_RD0001.RAF | |
| 39 | +``` | |
| 40 | + | |
| 41 | +**Sorties** : | |
| 42 | +- Succès : chemin final affiché sur stdout, code `0`. | |
| 43 | +- `chemin_fichier` n'est pas actuellement à la racine : erreur sur stderr, code non-zéro. | |
| 44 | + | |
| 45 | +## Notes de scriptabilité | |
| 46 | + | |
| 47 | +- Ces trois commandes ne demandent aucune confirmation interactive : un déplacement au sein d'un même dossier local (hors archive) n'est pas une action à risque au sens du Principe II — l'archive elle-même n'est jamais touchée directement (la réconciliation, `specs/005`, reste l'étape où toute écriture sur le NAS est confirmée explicitement). | |
| 48 | +- `promote`/`demote` n'acceptent qu'un seul fichier à la fois, jamais une paire RAW+JPEG groupée automatiquement (FR-003) — un script appelant ces commandes pour les deux membres d'une paire le fait par deux appels explicites et distincts. | |
| new file mode 100644 | |||
| @@ -0,0 +1,48 @@ | |||
| 1 | +# Contrat CLI : `regine dossier` (US1, réponse au Principe CLI-first — FR-007) | ||
| 2 | + | ||
| 3 | +Façade fine de `regine_core.dossier.selection` (cf. Principe CLI-first). Protocole texte : arguments en entrée, résultat sur stdout, erreurs sur stderr, code de sortie non-zéro en cas d'échec. Cette surface DOIT exister et fonctionner avant que l'équivalent GUI ne soit considéré livré (FR-007). | ||
| 4 | + | ||
| 5 | +## `regine dossier list <chemin_dossier>` | ||
| 6 | + | ||
| 7 | +Liste les entrées regroupées (RAW+JPEG jumeaux fusionnés) d'un dossier de travail local, avec leur statut de promotion. | ||
| 8 | + | ||
| 9 | +**Entrée** : | ||
| 10 | +``` | ||
| 11 | +regine dossier list /chemin/local/2026-08-15_Sortie [--json] | ||
| 12 | +``` | ||
| 13 | + | ||
| 14 | +**Sorties** : | ||
| 15 | +- Succès : une ligne par entrée regroupée (`nom_base`, fichiers présents, promu oui/non pour chacun), code `0`. | ||
| 16 | +- `chemin_dossier` n'a pas la structure attendue d'un dossier Régine (Edge Case, FR-013) : message clair sur stderr, code non-zéro. | ||
| 17 | + | ||
| 18 | +## `regine dossier promote <chemin_dossier> <chemin_fichier>` | ||
| 19 | + | ||
| 20 | +Promeut un fichier individuel à la racine de `chemin_dossier` (FR-003). `chemin_dossier` est explicite plutôt que déduit de `chemin_fichier` : pour un dossier parent de voyage, la même commande sert aux deux gestes distincts de la « promotion en deux temps » (section 10 des notes d'archivage) — une fois avec le chemin de l'étape, une seconde fois avec le chemin du dossier parent — jamais deviné automatiquement (cf. `contracts/regine-core-api.md`, écart trouvé par un test à l'implémentation). | ||
| 21 | + | ||
| 22 | +**Entrée** : | ||
| 23 | +``` | ||
| 24 | +regine dossier promote /chemin/local/2026-08-15_Sortie /chemin/local/2026-08-15_Sortie/raw/2026-08-15_Sortie_RD0001.RAF | ||
| 25 | +``` | ||
| 26 | + | ||
| 27 | +**Sorties** : | ||
| 28 | +- Succès : chemin final affiché sur stdout, code `0`. | ||
| 29 | +- `chemin_fichier` déjà à la racine de `chemin_dossier` (rien à faire) : message informatif sur stdout, code `0` (idempotent, pas une erreur). | ||
| 30 | +- `chemin_fichier` n'est pas sous `chemin_dossier` : erreur sur stderr, code non-zéro. | ||
| 31 | + | ||
| 32 | +## `regine dossier demote <chemin_fichier>` | ||
| 33 | + | ||
| 34 | +Rétrograde un fichier de la racine vers son dossier de format d'origine, déduit de son extension (FR-003). | ||
| 35 | + | ||
| 36 | +**Entrée** : | ||
| 37 | +``` | ||
| 38 | +regine dossier demote /chemin/local/2026-08-15_Sortie/2026-08-15_Sortie_RD0001.RAF | ||
| 39 | +``` | ||
| 40 | + | ||
| 41 | +**Sorties** : | ||
| 42 | +- Succès : chemin final affiché sur stdout, code `0`. | ||
| 43 | +- `chemin_fichier` n'est pas actuellement à la racine : erreur sur stderr, code non-zéro. | ||
| 44 | + | ||
| 45 | +## Notes de scriptabilité | ||
| 46 | + | ||
| 47 | +- Ces trois commandes ne demandent aucune confirmation interactive : un déplacement au sein d'un même dossier local (hors archive) n'est pas une action à risque au sens du Principe II — l'archive elle-même n'est jamais touchée directement (la réconciliation, `specs/005`, reste l'étape où toute écriture sur le NAS est confirmée explicitement). | ||
| 48 | +- `promote`/`demote` n'acceptent qu'un seul fichier à la fois, jamais une paire RAW+JPEG groupée automatiquement (FR-003) — un script appelant ces commandes pour les deux membres d'une paire le fait par deux appels explicites et distincts. | ||
added
specs/006-gui-tri-consultation/contracts/regine-core-api.md +37 -0 | new file mode 100644 | ||
| @@ -0,0 +1,37 @@ | ||
| 1 | +# Contrat d'API interne : `regine_core.dossier.selection` / `regine_core.archive.consultation` | |
| 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). | |
| 4 | + | |
| 5 | +## `selection.dossier_de_format(extension: str) -> str | None` | |
| 6 | + | |
| 7 | +Correspondance extension → nom de dossier de format (`raw`/`jpeg`/`tiff`), réutilisant `regine_core.import_carte.copie.EXTENSIONS_MAITRES` pour la liste des extensions RAW plutôt que de la dupliquer (cf. research.md § 4). Retourne `None` pour une extension associée (sidecar) ou non reconnue. | |
| 8 | + | |
| 9 | +## `selection.regrouper(dossier: Path) -> list[EntreeRegroupee]` | |
| 10 | + | |
| 11 | +Parcourt un dossier de travail local (racine + dossiers de format) et regroupe les fichiers maîtres par nom de base (FR-002). Lève une erreur dédiée si `dossier` n'a pas la structure attendue d'un dossier Régine (aucun dossier de format reconnu et rien à la racine — FR-013), plutôt que de retourner une liste vide silencieuse. | |
| 12 | + | |
| 13 | +## `selection.promouvoir(dossier: Path, fichier: Path) -> Path` | |
| 14 | + | |
| 15 | +Déplace un fichier individuel vers la racine de `dossier` (FR-003). `dossier` est un argument explicite, pas inféré du chemin de `fichier` — cf. « Notes d'implémentation » ci-dessous. Idempotent : si `fichier` est déjà à la racine de `dossier`, ne fait rien et retourne son chemin actuel. Ne déplace jamais un second fichier (jamais une paire entière) — c'est à l'appelant (CLI ou GUI) d'invoquer cette fonction une fois par fichier à promouvoir. | |
| 16 | + | |
| 17 | +## `selection.retrograder(fichier: Path) -> Path` | |
| 18 | + | |
| 19 | +Déplace un fichier de la racine vers son dossier de format d'origine, déduit de `dossier_de_format` (FR-003). Lève une erreur si `fichier` n'est pas actuellement à la racine du dossier. | |
| 20 | + | |
| 21 | +## `config.smb.resoudre_archive_locale(conn: sqlite3.Connection, *, declencher: bool = True) -> Path | None` | |
| 22 | + | |
| 23 | +Résout et vérifie en un seul appel le point de montage local de l'archive configurée (`specs/003`) : charge le contexte, vérifie la disponibilité du partage, déclenche le montage natif si nécessaire (FR-011). Retourne `None` (jamais d'exception) si aucune archive n'est configurée ou si elle reste inaccessible — l'appelant (CLI ou GUI) décide comment l'annoncer. Ajoutée après coup (cf. Notes d'implémentation) pour éliminer une duplication entre `regine-cli` et `regine-gui`. | |
| 24 | + | |
| 25 | +## `consultation.rechercher(archive_root_local: Path, requete: str | None) -> list[ResultatRecherche]` | |
| 26 | + | |
| 27 | +Parcourt `archive_root_local` (point de montage local du partage SMB, `regine_core.config.smb.chemin_local_du_partage`) en lecture seule et retourne les dossiers/fichiers dont le nom correspond à `requete` (nom de dossier, date, ou texte de titre) — `requete=None` liste tout (US2 FR-008). Ne modifie jamais rien, n'ouvre aucun manifeste, ne pose aucun verrou (Assumptions de spec.md). | |
| 28 | + | |
| 29 | +## `consultation.restaurer(chemin_source: Path, chemin_destination: Path) -> None` | |
| 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). | |
| 32 | + | |
| 33 | +## Notes d'implémentation | |
| 34 | + | |
| 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. | |
| 36 | +- `regrouper` retourne des `EntreeRegroupee.fichiers: tuple[Path, ...]` (pas des champs fixes `fichier_raw`/`fichier_jpeg`) — cf. correction dans `data-model.md`. | |
| 37 | +- **Bug réel trouvé par test manuel headless de la GUI de consultation** : `archive_cmd.py` (CLI, écrit en premier par contrainte CLI-first) et `consultation_view.py` (GUI, écrit ensuite) dupliquaient chacun la même logique de résolution du point de montage de l'archive configurée, avec des styles d'import différents (import différé dans un cas, import de module dans l'autre) — un test qui monkeypatchait `smb.chemin_local_du_partage` pour l'un des deux appelants n'affectait pas l'autre, révélant une incohérence réelle entre CLI et GUI, pas seulement un artefact de test. Corrigé en extrayant `config.smb.resoudre_archive_locale`, désormais seule source de vérité pour les deux façades (Principe VI). | |
| new file mode 100644 | |||
| @@ -0,0 +1,37 @@ | |||
| 1 | +# Contrat d'API interne : `regine_core.dossier.selection` / `regine_core.archive.consultation` | ||
| 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). | ||
| 4 | + | ||
| 5 | +## `selection.dossier_de_format(extension: str) -> str | None` | ||
| 6 | + | ||
| 7 | +Correspondance extension → nom de dossier de format (`raw`/`jpeg`/`tiff`), réutilisant `regine_core.import_carte.copie.EXTENSIONS_MAITRES` pour la liste des extensions RAW plutôt que de la dupliquer (cf. research.md § 4). Retourne `None` pour une extension associée (sidecar) ou non reconnue. | ||
| 8 | + | ||
| 9 | +## `selection.regrouper(dossier: Path) -> list[EntreeRegroupee]` | ||
| 10 | + | ||
| 11 | +Parcourt un dossier de travail local (racine + dossiers de format) et regroupe les fichiers maîtres par nom de base (FR-002). Lève une erreur dédiée si `dossier` n'a pas la structure attendue d'un dossier Régine (aucun dossier de format reconnu et rien à la racine — FR-013), plutôt que de retourner une liste vide silencieuse. | ||
| 12 | + | ||
| 13 | +## `selection.promouvoir(dossier: Path, fichier: Path) -> Path` | ||
| 14 | + | ||
| 15 | +Déplace un fichier individuel vers la racine de `dossier` (FR-003). `dossier` est un argument explicite, pas inféré du chemin de `fichier` — cf. « Notes d'implémentation » ci-dessous. Idempotent : si `fichier` est déjà à la racine de `dossier`, ne fait rien et retourne son chemin actuel. Ne déplace jamais un second fichier (jamais une paire entière) — c'est à l'appelant (CLI ou GUI) d'invoquer cette fonction une fois par fichier à promouvoir. | ||
| 16 | + | ||
| 17 | +## `selection.retrograder(fichier: Path) -> Path` | ||
| 18 | + | ||
| 19 | +Déplace un fichier de la racine vers son dossier de format d'origine, déduit de `dossier_de_format` (FR-003). Lève une erreur si `fichier` n'est pas actuellement à la racine du dossier. | ||
| 20 | + | ||
| 21 | +## `config.smb.resoudre_archive_locale(conn: sqlite3.Connection, *, declencher: bool = True) -> Path | None` | ||
| 22 | + | ||
| 23 | +Résout et vérifie en un seul appel le point de montage local de l'archive configurée (`specs/003`) : charge le contexte, vérifie la disponibilité du partage, déclenche le montage natif si nécessaire (FR-011). Retourne `None` (jamais d'exception) si aucune archive n'est configurée ou si elle reste inaccessible — l'appelant (CLI ou GUI) décide comment l'annoncer. Ajoutée après coup (cf. Notes d'implémentation) pour éliminer une duplication entre `regine-cli` et `regine-gui`. | ||
| 24 | + | ||
| 25 | +## `consultation.rechercher(archive_root_local: Path, requete: str | None) -> list[ResultatRecherche]` | ||
| 26 | + | ||
| 27 | +Parcourt `archive_root_local` (point de montage local du partage SMB, `regine_core.config.smb.chemin_local_du_partage`) en lecture seule et retourne les dossiers/fichiers dont le nom correspond à `requete` (nom de dossier, date, ou texte de titre) — `requete=None` liste tout (US2 FR-008). Ne modifie jamais rien, n'ouvre aucun manifeste, ne pose aucun verrou (Assumptions de spec.md). | ||
| 28 | + | ||
| 29 | +## `consultation.restaurer(chemin_source: Path, chemin_destination: Path) -> None` | ||
| 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). | ||
| 32 | + | ||
| 33 | +## Notes d'implémentation | ||
| 34 | + | ||
| 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. | ||
| 36 | +- `regrouper` retourne des `EntreeRegroupee.fichiers: tuple[Path, ...]` (pas des champs fixes `fichier_raw`/`fichier_jpeg`) — cf. correction dans `data-model.md`. | ||
| 37 | +- **Bug réel trouvé par test manuel headless de la GUI de consultation** : `archive_cmd.py` (CLI, écrit en premier par contrainte CLI-first) et `consultation_view.py` (GUI, écrit ensuite) dupliquaient chacun la même logique de résolution du point de montage de l'archive configurée, avec des styles d'import différents (import différé dans un cas, import de module dans l'autre) — un test qui monkeypatchait `smb.chemin_local_du_partage` pour l'un des deux appelants n'affectait pas l'autre, révélant une incohérence réelle entre CLI et GUI, pas seulement un artefact de test. Corrigé en extrayant `config.smb.resoudre_archive_locale`, désormais seule source de vérité pour les deux façades (Principe VI). | ||
added
specs/006-gui-tri-consultation/data-model.md +68 -0 | new file mode 100644 | ||
| @@ -0,0 +1,68 @@ | ||
| 1 | +# Data Model: Interface graphique de tri et de consultation | |
| 2 | + | |
| 3 | +Entités dérivées de `spec.md` § Key Entities et Functional Requirements. | |
| 4 | + | |
| 5 | +## Fichier de dossier | |
| 6 | + | |
| 7 | +Un fichier individuel au sein d'un dossier de travail local (US1). | |
| 8 | + | |
| 9 | +| Champ | Type | Règles | | |
| 10 | +|---|---|---| | |
| 11 | +| `chemin` | chemin absolu | Toujours à l'intérieur du dossier ouvert (dossier de format ou racine) | | |
| 12 | +| `chemin_relatif` | chemin relatif au dossier | Sert à déterminer s'il est promu (racine, un seul segment) ou non (dans un sous-dossier de format) | | |
| 13 | +| `extension` | texte | Détermine le dossier de format attendu (raw/jpeg/tiff, cf. research.md § 4) | | |
| 14 | +| `nom_base` | texte | Nom hors extension, sert à apparier RAW et JPEG jumeaux (même valeur) | | |
| 15 | +| `est_promu` | booléen | Dérivé de `chemin_relatif` (un seul segment = à la racine du dossier), jamais stocké séparément | | |
| 16 | + | |
| 17 | +## Entrée regroupée | |
| 18 | + | |
| 19 | +Représentation à l'écran d'une capture (spec.md § Key Entities). Purement une vue construite à la volée à partir des Fichiers de dossier partageant le même `nom_base` — ne modifie jamais leur relation sur le système de fichiers, jamais persistée. | |
| 20 | + | |
| 21 | +| Champ | Type | Règles | | |
| 22 | +|---|---|---| | |
| 23 | +| `nom_base` | texte | Clé de regroupement | | |
| 24 | +| `fichiers` | liste de Fichier de dossier | Tous les fichiers maîtres partageant `nom_base` — pas limité à deux champs figés `raw`/`jpeg` | | |
| 25 | + | |
| 26 | +**Règle** : le cas RAW+JPEG jumeau (FR-002) n'est qu'un cas particulier d'un principe plus général — un fichier maître sans jumeau (RAW seul, JPEG seul, scan TIFF isolé) forme une entrée regroupée à un seul membre, pas un cas à part. **Correction par rapport à la conception initiale** : un modèle à deux champs fixes `fichier_raw`/`fichier_jpeg` ne représentait pas correctement un scan TIFF ou un format maître marginal (ex. BMP) isolé, qui n'est ni un RAW ni un JPEG — généralisé en une liste dès l'implémentation. | |
| 27 | + | |
| 28 | +## Dossier de travail (vue combinée) | |
| 29 | + | |
| 30 | +Racine locale ouverte en tri/culling (US1), avec ses dossiers de format et, le cas échéant, ses sous-dossiers (dossier parent de voyage, `specs/004`). | |
| 31 | + | |
| 32 | +| Champ | Type | Règles | | |
| 33 | +|---|---|---| | |
| 34 | +| `chemin_racine` | chemin absolu | Racine du dossier (ou du dossier parent) ouvert | | |
| 35 | +| `dossiers_format_presents` | liste de texte | Détectés à l'ouverture (créés à la demande, jamais une liste figée — `docs/archivage-photo-elements-cles.md` section 10) | | |
| 36 | +| `sous_dossiers` | liste de « Dossier de travail », vide si dossier simple | Un sous-dossier a sa propre structure complète (Edge Case : structure non reconnue si absente) | | |
| 37 | + | |
| 38 | +## Action de promotion/rétrogradation | |
| 39 | + | |
| 40 | +Commande appliquée à un Fichier de dossier (US1, FR-003). N'existe que comme événement, pas comme entité stockée. | |
| 41 | + | |
| 42 | +| Champ | Type | Règles | | |
| 43 | +|---|---|---| | |
| 44 | +| `fichier` | Fichier de dossier | Cible unique, jamais une paire entière d'un coup (FR-003) | | |
| 45 | +| `sens` | énumération : `promotion` \| `retrogradation` | Détermine le chemin final (racine, ou dossier de format d'origine déduit de l'extension) | | |
| 46 | + | |
| 47 | +**Effet** : un déplacement physique (`rename`) au sein du même dossier, hash inchangé (Principe I/III) — aucune trace supplémentaire à écrire ; retrouvé nativement par `regine_core.archive.reconciliation.comparer` à la prochaine réconciliation (specs/005), sans code dédié ici. | |
| 48 | + | |
| 49 | +## Vue de consultation | |
| 50 | + | |
| 51 | +Navigation en lecture seule de l'archive (US2, spec.md § Key Entities). | |
| 52 | + | |
| 53 | +| Champ | Type | Règles | | |
| 54 | +|---|---|---| | |
| 55 | +| `archive_root_local` | chemin absolu | Point de montage local du partage SMB configuré (réutilise `regine_core.config.smb.chemin_local_du_partage`, `specs/003`) | | |
| 56 | +| `requete` | texte, optionnel | Filtre par nom de dossier, date, ou texte de titre (US2 FR-008) | | |
| 57 | + | |
| 58 | +**Aucune écriture** : cette vue ne crée ni manifeste ni verrou (Assumptions de spec.md) — une simple lecture du système de fichiers de l'archive. | |
| 59 | + | |
| 60 | +## Restauration ciblée | |
| 61 | + | |
| 62 | +Résultat d'une restauration d'un fichier repéré en consultation (US2, FR-009). | |
| 63 | + | |
| 64 | +| Champ | Type | Règles | | |
| 65 | +|---|---|---| | |
| 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) | | |
| 68 | +| `hash_verifie` | booléen | DOIT être vrai avant de considérer la restauration réussie (SC-004) | | |
| new file mode 100644 | |||
| @@ -0,0 +1,68 @@ | |||
| 1 | +# Data Model: Interface graphique de tri et de consultation | ||
| 2 | + | ||
| 3 | +Entités dérivées de `spec.md` § Key Entities et Functional Requirements. | ||
| 4 | + | ||
| 5 | +## Fichier de dossier | ||
| 6 | + | ||
| 7 | +Un fichier individuel au sein d'un dossier de travail local (US1). | ||
| 8 | + | ||
| 9 | +| Champ | Type | Règles | | ||
| 10 | +|---|---|---| | ||
| 11 | +| `chemin` | chemin absolu | Toujours à l'intérieur du dossier ouvert (dossier de format ou racine) | | ||
| 12 | +| `chemin_relatif` | chemin relatif au dossier | Sert à déterminer s'il est promu (racine, un seul segment) ou non (dans un sous-dossier de format) | | ||
| 13 | +| `extension` | texte | Détermine le dossier de format attendu (raw/jpeg/tiff, cf. research.md § 4) | | ||
| 14 | +| `nom_base` | texte | Nom hors extension, sert à apparier RAW et JPEG jumeaux (même valeur) | | ||
| 15 | +| `est_promu` | booléen | Dérivé de `chemin_relatif` (un seul segment = à la racine du dossier), jamais stocké séparément | | ||
| 16 | + | ||
| 17 | +## Entrée regroupée | ||
| 18 | + | ||
| 19 | +Représentation à l'écran d'une capture (spec.md § Key Entities). Purement une vue construite à la volée à partir des Fichiers de dossier partageant le même `nom_base` — ne modifie jamais leur relation sur le système de fichiers, jamais persistée. | ||
| 20 | + | ||
| 21 | +| Champ | Type | Règles | | ||
| 22 | +|---|---|---| | ||
| 23 | +| `nom_base` | texte | Clé de regroupement | | ||
| 24 | +| `fichiers` | liste de Fichier de dossier | Tous les fichiers maîtres partageant `nom_base` — pas limité à deux champs figés `raw`/`jpeg` | | ||
| 25 | + | ||
| 26 | +**Règle** : le cas RAW+JPEG jumeau (FR-002) n'est qu'un cas particulier d'un principe plus général — un fichier maître sans jumeau (RAW seul, JPEG seul, scan TIFF isolé) forme une entrée regroupée à un seul membre, pas un cas à part. **Correction par rapport à la conception initiale** : un modèle à deux champs fixes `fichier_raw`/`fichier_jpeg` ne représentait pas correctement un scan TIFF ou un format maître marginal (ex. BMP) isolé, qui n'est ni un RAW ni un JPEG — généralisé en une liste dès l'implémentation. | ||
| 27 | + | ||
| 28 | +## Dossier de travail (vue combinée) | ||
| 29 | + | ||
| 30 | +Racine locale ouverte en tri/culling (US1), avec ses dossiers de format et, le cas échéant, ses sous-dossiers (dossier parent de voyage, `specs/004`). | ||
| 31 | + | ||
| 32 | +| Champ | Type | Règles | | ||
| 33 | +|---|---|---| | ||
| 34 | +| `chemin_racine` | chemin absolu | Racine du dossier (ou du dossier parent) ouvert | | ||
| 35 | +| `dossiers_format_presents` | liste de texte | Détectés à l'ouverture (créés à la demande, jamais une liste figée — `docs/archivage-photo-elements-cles.md` section 10) | | ||
| 36 | +| `sous_dossiers` | liste de « Dossier de travail », vide si dossier simple | Un sous-dossier a sa propre structure complète (Edge Case : structure non reconnue si absente) | | ||
| 37 | + | ||
| 38 | +## Action de promotion/rétrogradation | ||
| 39 | + | ||
| 40 | +Commande appliquée à un Fichier de dossier (US1, FR-003). N'existe que comme événement, pas comme entité stockée. | ||
| 41 | + | ||
| 42 | +| Champ | Type | Règles | | ||
| 43 | +|---|---|---| | ||
| 44 | +| `fichier` | Fichier de dossier | Cible unique, jamais une paire entière d'un coup (FR-003) | | ||
| 45 | +| `sens` | énumération : `promotion` \| `retrogradation` | Détermine le chemin final (racine, ou dossier de format d'origine déduit de l'extension) | | ||
| 46 | + | ||
| 47 | +**Effet** : un déplacement physique (`rename`) au sein du même dossier, hash inchangé (Principe I/III) — aucune trace supplémentaire à écrire ; retrouvé nativement par `regine_core.archive.reconciliation.comparer` à la prochaine réconciliation (specs/005), sans code dédié ici. | ||
| 48 | + | ||
| 49 | +## Vue de consultation | ||
| 50 | + | ||
| 51 | +Navigation en lecture seule de l'archive (US2, spec.md § Key Entities). | ||
| 52 | + | ||
| 53 | +| Champ | Type | Règles | | ||
| 54 | +|---|---|---| | ||
| 55 | +| `archive_root_local` | chemin absolu | Point de montage local du partage SMB configuré (réutilise `regine_core.config.smb.chemin_local_du_partage`, `specs/003`) | | ||
| 56 | +| `requete` | texte, optionnel | Filtre par nom de dossier, date, ou texte de titre (US2 FR-008) | | ||
| 57 | + | ||
| 58 | +**Aucune écriture** : cette vue ne crée ni manifeste ni verrou (Assumptions de spec.md) — une simple lecture du système de fichiers de l'archive. | ||
| 59 | + | ||
| 60 | +## Restauration ciblée | ||
| 61 | + | ||
| 62 | +Résultat d'une restauration d'un fichier repéré en consultation (US2, FR-009). | ||
| 63 | + | ||
| 64 | +| Champ | Type | Règles | | ||
| 65 | +|---|---|---| | ||
| 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) | | ||
| 68 | +| `hash_verifie` | booléen | DOIT être vrai avant de considérer la restauration réussie (SC-004) | | ||
added
specs/006-gui-tri-consultation/plan.md +112 -0 | new file mode 100644 | ||
| @@ -0,0 +1,112 @@ | ||
| 1 | +# Implementation Plan: Interface graphique de tri et de consultation | |
| 2 | + | |
| 3 | +**Branch**: `006-gui-tri-consultation` | **Date**: 2026-09-20 | **Spec**: [spec.md](./spec.md) | |
| 4 | + | |
| 5 | +**Input**: Feature specification from `/specs/006-gui-tri-consultation/spec.md` | |
| 6 | + | |
| 7 | +## Summary | |
| 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. | |
| 10 | + | |
| 11 | +## Technical Context | |
| 12 | + | |
| 13 | +**Language/Version**: Python 3.11+ (cohérent avec le reste de `regine-core`/`regine-cli`) | |
| 14 | + | |
| 15 | +**Primary Dependencies**: PySide6 (interface graphique native Qt, cf. research.md § 1) pour `regine-gui` uniquement. Aucune nouvelle dépendance pour `regine-core`/`regine-cli` (bibliothèque standard, `pathlib`/`shutil`, cohérent avec le reste du projet). | |
| 16 | + | |
| 17 | +**Storage**: Aucune nouvelle base. Le tri/culling (US1) opère sur une copie de travail déjà checkoutée (manifeste/verrou existants, `specs/005`) ; la consultation (US2) est purement en lecture, sans manifeste ni verrou (cf. Assumptions de spec.md). | |
| 18 | + | |
| 19 | +**Testing**: `pytest` pour `regine-core`/`regine-cli` (logique de sélection, recherche, restauration, contrat CLI) — couverture exhaustive comme pour le reste du projet. La GUI elle-même n'est pas couverte par des tests automatisés dans cette itération (cf. research.md § 2) : validée manuellement via `quickstart.md`, la logique métier qu'elle appelle étant, elle, entièrement testée côté `regine-core`. | |
| 20 | + | |
| 21 | +**Target Platform**: Poste de travail de bureau, macOS en priorité (cohérent avec le reste du projet, cf. `specs/003` research.md § 1) | |
| 22 | + | |
| 23 | +**Project Type**: Application de bureau (façade `regine-gui`) + extension CLI, dans le monorepo existant (`regine-core`, `regine-cli`, `regine-gui`) | |
| 24 | + | |
| 25 | +**Performance Goals**: Pas de cible chiffrée spécifique ; la vue combinée (US1 FR-004/005) doit rester fluide sur l'ordre de grandeur d'une sortie photo (quelques centaines à low-thousands de fichiers), sans imposer de charger l'archive entière en mémoire pour la recherche (US2 Edge Case) | |
| 26 | + | |
| 27 | +**Constraints**: Un seul processus par lancement, pas de démon (contrainte technique de la constitution) ; aucune écriture depuis le mode consultation (FR-010) ; chaque action de promotion/rétrogradation DOIT être accomplissable en CLI, pas seulement en GUI (FR-007, Principe CLI-first) | |
| 28 | + | |
| 29 | +**Scale/Scope**: Usage mono-utilisateur, mono-poste, cohérent avec le reste du projet (cf. `specs/003` Assumptions) | |
| 30 | + | |
| 31 | +## Constitution Check | |
| 32 | + | |
| 33 | +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* | |
| 34 | + | |
| 35 | +| Principe / contrainte | Évaluation | Justification | | |
| 36 | +|---|---|---| | |
| 37 | +| I. Fichier maître intouchable | PASS | La promotion/rétrogradation est un déplacement (`rename`/`move`) au sein d'un même dossier, jamais une modification de contenu — le hash reste stable (cohérent avec `docs/archivage-photo-elements-cles.md` section 10 : « ce deuxième déplacement est un cas de plus couvert par la détection de déplacement par hash »). | | |
| 38 | +| II. Confirmation explicite avant toute action destructive | PASS | FR-006 : aucune suppression directe depuis l'écran de tri, toujours différée à la réconciliation (`specs/005`) avec confirmation explicite. | | |
| 39 | +| III. Identité par contenu, jamais par nom de fichier seul | PASS | Le déplacement physique d'une promotion est retrouvé par `regine_core.archive.reconciliation.comparer` via le hash, sans code dédié à construire ici (déjà prouvé par `test_whole_folder_move_detected_as_move_for_every_file`, specs/005). | | |
| 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). | | |
| 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. | | |
| 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 | +| Formats ouverts et documentés | N/A | Aucun nouveau format de stockage introduit par cette fonctionnalité. | | |
| 46 | + | |
| 47 | +Aucune violation identifiée ; la section Complexity Tracking reste vide. | |
| 48 | + | |
| 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. | |
| 50 | + | |
| 51 | +## Project Structure | |
| 52 | + | |
| 53 | +### Documentation (this feature) | |
| 54 | + | |
| 55 | +```text | |
| 56 | +specs/006-gui-tri-consultation/ | |
| 57 | +├── plan.md # This file (/speckit-plan command output) | |
| 58 | +├── research.md # Phase 0 output (/speckit-plan command) | |
| 59 | +├── data-model.md # Phase 1 output (/speckit-plan command) | |
| 60 | +├── quickstart.md # Phase 1 output (/speckit-plan command) | |
| 61 | +├── contracts/ # Phase 1 output (/speckit-plan command) | |
| 62 | +│ ├── cli-dossier-selection.md | |
| 63 | +│ ├── cli-archive-consultation.md | |
| 64 | +│ └── regine-core-api.md | |
| 65 | +└── tasks.md # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan) | |
| 66 | +``` | |
| 67 | + | |
| 68 | +### Source Code (repository root) — monorepo existant | |
| 69 | + | |
| 70 | +```text | |
| 71 | +packages/ | |
| 72 | +├── regine-core/ | |
| 73 | +│ ├── src/regine_core/ | |
| 74 | +│ │ ├── dossier/ | |
| 75 | +│ │ │ ├── root.py # existant (specs/004) — répertoire racine archive, INCHANGÉ | |
| 76 | +│ │ │ └── selection.py # NOUVEAU — racine de sélection : regroupement RAW+JPEG, | |
| 77 | +│ │ │ promotion/rétrogradation individuelle (US1) | |
| 78 | +│ │ └── archive/ | |
| 79 | +│ │ └── consultation.py # NOUVEAU — parcours lecture seule + restauration ciblée (US2) | |
| 80 | +│ └── tests/ | |
| 81 | +│ ├── unit/ | |
| 82 | +│ │ ├── test_selection_grouping.py | |
| 83 | +│ │ ├── test_selection_promotion.py | |
| 84 | +│ │ └── test_consultation_search.py | |
| 85 | +│ └── integration/ | |
| 86 | +│ ├── test_selection_combined_view.py # dossier parent + sous-dossiers, US1 | |
| 87 | +│ └── test_consultation_restore.py # restauration ciblée vérifiée, US2 | |
| 88 | +│ | |
| 89 | +├── regine-cli/ | |
| 90 | +│ ├── src/regine_cli/ | |
| 91 | +│ │ └── dossier_cmd.py # NOUVEAU — `regine dossier promote/demote/list`, | |
| 92 | +│ │ réponse CLI-first à FR-007 ; l'archive_cmd.py existant | |
| 93 | +│ │ (specs/005) est étendu pour `regine archive browse/restore` (US2) | |
| 94 | +│ └── tests/contract/ | |
| 95 | +│ ├── test_cli_dossier.py | |
| 96 | +│ └── test_cli_archive_consultation.py | |
| 97 | +│ | |
| 98 | +└── regine-gui/ # première implémentation réelle de cette façade réservée | |
| 99 | + ├── pyproject.toml # dépendance PySide6 ajoutée ici | |
| 100 | + ├── src/regine_gui/ | |
| 101 | + │ ├── app.py # point d'entrée, lance l'application PySide6 | |
| 102 | + │ ├── 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 | |
| 104 | + └── (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) | |
| 106 | +``` | |
| 107 | + | |
| 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. | |
| 109 | + | |
| 110 | +## Complexity Tracking | |
| 111 | + | |
| 112 | +*Aucune violation de gate à justifier — section laissée vide intentionnellement.* | |
| new file mode 100644 | |||
| @@ -0,0 +1,112 @@ | |||
| 1 | +# Implementation Plan: Interface graphique de tri et de consultation | ||
| 2 | + | ||
| 3 | +**Branch**: `006-gui-tri-consultation` | **Date**: 2026-09-20 | **Spec**: [spec.md](./spec.md) | ||
| 4 | + | ||
| 5 | +**Input**: Feature specification from `/specs/006-gui-tri-consultation/spec.md` | ||
| 6 | + | ||
| 7 | +## Summary | ||
| 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. | ||
| 10 | + | ||
| 11 | +## Technical Context | ||
| 12 | + | ||
| 13 | +**Language/Version**: Python 3.11+ (cohérent avec le reste de `regine-core`/`regine-cli`) | ||
| 14 | + | ||
| 15 | +**Primary Dependencies**: PySide6 (interface graphique native Qt, cf. research.md § 1) pour `regine-gui` uniquement. Aucune nouvelle dépendance pour `regine-core`/`regine-cli` (bibliothèque standard, `pathlib`/`shutil`, cohérent avec le reste du projet). | ||
| 16 | + | ||
| 17 | +**Storage**: Aucune nouvelle base. Le tri/culling (US1) opère sur une copie de travail déjà checkoutée (manifeste/verrou existants, `specs/005`) ; la consultation (US2) est purement en lecture, sans manifeste ni verrou (cf. Assumptions de spec.md). | ||
| 18 | + | ||
| 19 | +**Testing**: `pytest` pour `regine-core`/`regine-cli` (logique de sélection, recherche, restauration, contrat CLI) — couverture exhaustive comme pour le reste du projet. La GUI elle-même n'est pas couverte par des tests automatisés dans cette itération (cf. research.md § 2) : validée manuellement via `quickstart.md`, la logique métier qu'elle appelle étant, elle, entièrement testée côté `regine-core`. | ||
| 20 | + | ||
| 21 | +**Target Platform**: Poste de travail de bureau, macOS en priorité (cohérent avec le reste du projet, cf. `specs/003` research.md § 1) | ||
| 22 | + | ||
| 23 | +**Project Type**: Application de bureau (façade `regine-gui`) + extension CLI, dans le monorepo existant (`regine-core`, `regine-cli`, `regine-gui`) | ||
| 24 | + | ||
| 25 | +**Performance Goals**: Pas de cible chiffrée spécifique ; la vue combinée (US1 FR-004/005) doit rester fluide sur l'ordre de grandeur d'une sortie photo (quelques centaines à low-thousands de fichiers), sans imposer de charger l'archive entière en mémoire pour la recherche (US2 Edge Case) | ||
| 26 | + | ||
| 27 | +**Constraints**: Un seul processus par lancement, pas de démon (contrainte technique de la constitution) ; aucune écriture depuis le mode consultation (FR-010) ; chaque action de promotion/rétrogradation DOIT être accomplissable en CLI, pas seulement en GUI (FR-007, Principe CLI-first) | ||
| 28 | + | ||
| 29 | +**Scale/Scope**: Usage mono-utilisateur, mono-poste, cohérent avec le reste du projet (cf. `specs/003` Assumptions) | ||
| 30 | + | ||
| 31 | +## Constitution Check | ||
| 32 | + | ||
| 33 | +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* | ||
| 34 | + | ||
| 35 | +| Principe / contrainte | Évaluation | Justification | | ||
| 36 | +|---|---|---| | ||
| 37 | +| I. Fichier maître intouchable | PASS | La promotion/rétrogradation est un déplacement (`rename`/`move`) au sein d'un même dossier, jamais une modification de contenu — le hash reste stable (cohérent avec `docs/archivage-photo-elements-cles.md` section 10 : « ce deuxième déplacement est un cas de plus couvert par la détection de déplacement par hash »). | | ||
| 38 | +| II. Confirmation explicite avant toute action destructive | PASS | FR-006 : aucune suppression directe depuis l'écran de tri, toujours différée à la réconciliation (`specs/005`) avec confirmation explicite. | | ||
| 39 | +| III. Identité par contenu, jamais par nom de fichier seul | PASS | Le déplacement physique d'une promotion est retrouvé par `regine_core.archive.reconciliation.comparer` via le hash, sans code dédié à construire ici (déjà prouvé par `test_whole_folder_move_detected_as_move_for_every_file`, specs/005). | | ||
| 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). | | ||
| 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. | | ||
| 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 | +| Formats ouverts et documentés | N/A | Aucun nouveau format de stockage introduit par cette fonctionnalité. | | ||
| 46 | + | ||
| 47 | +Aucune violation identifiée ; la section Complexity Tracking reste vide. | ||
| 48 | + | ||
| 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. | ||
| 50 | + | ||
| 51 | +## Project Structure | ||
| 52 | + | ||
| 53 | +### Documentation (this feature) | ||
| 54 | + | ||
| 55 | +```text | ||
| 56 | +specs/006-gui-tri-consultation/ | ||
| 57 | +├── plan.md # This file (/speckit-plan command output) | ||
| 58 | +├── research.md # Phase 0 output (/speckit-plan command) | ||
| 59 | +├── data-model.md # Phase 1 output (/speckit-plan command) | ||
| 60 | +├── quickstart.md # Phase 1 output (/speckit-plan command) | ||
| 61 | +├── contracts/ # Phase 1 output (/speckit-plan command) | ||
| 62 | +│ ├── cli-dossier-selection.md | ||
| 63 | +│ ├── cli-archive-consultation.md | ||
| 64 | +│ └── regine-core-api.md | ||
| 65 | +└── tasks.md # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan) | ||
| 66 | +``` | ||
| 67 | + | ||
| 68 | +### Source Code (repository root) — monorepo existant | ||
| 69 | + | ||
| 70 | +```text | ||
| 71 | +packages/ | ||
| 72 | +├── regine-core/ | ||
| 73 | +│ ├── src/regine_core/ | ||
| 74 | +│ │ ├── dossier/ | ||
| 75 | +│ │ │ ├── root.py # existant (specs/004) — répertoire racine archive, INCHANGÉ | ||
| 76 | +│ │ │ └── selection.py # NOUVEAU — racine de sélection : regroupement RAW+JPEG, | ||
| 77 | +│ │ │ promotion/rétrogradation individuelle (US1) | ||
| 78 | +│ │ └── archive/ | ||
| 79 | +│ │ └── consultation.py # NOUVEAU — parcours lecture seule + restauration ciblée (US2) | ||
| 80 | +│ └── tests/ | ||
| 81 | +│ ├── unit/ | ||
| 82 | +│ │ ├── test_selection_grouping.py | ||
| 83 | +│ │ ├── test_selection_promotion.py | ||
| 84 | +│ │ └── test_consultation_search.py | ||
| 85 | +│ └── integration/ | ||
| 86 | +│ ├── test_selection_combined_view.py # dossier parent + sous-dossiers, US1 | ||
| 87 | +│ └── test_consultation_restore.py # restauration ciblée vérifiée, US2 | ||
| 88 | +│ | ||
| 89 | +├── regine-cli/ | ||
| 90 | +│ ├── src/regine_cli/ | ||
| 91 | +│ │ └── dossier_cmd.py # NOUVEAU — `regine dossier promote/demote/list`, | ||
| 92 | +│ │ réponse CLI-first à FR-007 ; l'archive_cmd.py existant | ||
| 93 | +│ │ (specs/005) est étendu pour `regine archive browse/restore` (US2) | ||
| 94 | +│ └── tests/contract/ | ||
| 95 | +│ ├── test_cli_dossier.py | ||
| 96 | +│ └── test_cli_archive_consultation.py | ||
| 97 | +│ | ||
| 98 | +└── regine-gui/ # première implémentation réelle de cette façade réservée | ||
| 99 | + ├── pyproject.toml # dépendance PySide6 ajoutée ici | ||
| 100 | + ├── src/regine_gui/ | ||
| 101 | + │ ├── app.py # point d'entrée, lance l'application PySide6 | ||
| 102 | + │ ├── 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 | ||
| 104 | + └── (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) | ||
| 106 | +``` | ||
| 107 | + | ||
| 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. | ||
| 109 | + | ||
| 110 | +## Complexity Tracking | ||
| 111 | + | ||
| 112 | +*Aucune violation de gate à justifier — section laissée vide intentionnellement.* | ||
added
specs/006-gui-tri-consultation/quickstart.md +54 -0 | new file mode 100644 | ||
| @@ -0,0 +1,54 @@ | ||
| 1 | +# Quickstart : validation de l'interface graphique de tri et de consultation | |
| 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). | |
| 4 | + | |
| 5 | +## Prérequis | |
| 6 | + | |
| 7 | +- `regine-core`/`regine-cli` installés ; pour la GUI, `regine-gui` installé avec sa dépendance PySide6. | |
| 8 | +- Un dossier de travail local déjà checkouté (`specs/005`) ou fraîchement importé (`specs/001`), contenant au moins une paire RAW+JPEG et un fichier RAW seul, répartis dans `raw/`/`jpeg/`. | |
| 9 | +- Un contexte de travail configuré (`specs/003`) avec un `archive_smb_path` accessible, pour les scénarios de consultation. | |
| 10 | + | |
| 11 | +## Scénario 1 — Trier via la CLI (User Story 1, P1, Principe CLI-first) | |
| 12 | + | |
| 13 | +```bash | |
| 14 | +regine dossier list ~/regine/local/2026-08-15_Sortie | |
| 15 | +regine dossier promote ~/regine/local/2026-08-15_Sortie/raw/2026-08-15_Sortie_RD0001.RAF | |
| 16 | +regine dossier list ~/regine/local/2026-08-15_Sortie | |
| 17 | +``` | |
| 18 | + | |
| 19 | +**Résultat attendu** : la première liste montre la paire RAW+JPEG `RD0001` comme une seule entrée regroupée, non promue ; après `promote`, le RAW apparaît à la racine du dossier sur le système de fichiers et la seconde liste reflète son nouveau statut, sans toucher au JPEG jumeau. | |
| 20 | + | |
| 21 | +## Scénario 2 — Trier via la GUI (User Story 1, P1) — validation manuelle | |
| 22 | + | |
| 23 | +```bash | |
| 24 | +regine-gui # ou uv run python -m regine_gui.app, selon l'empaquetage retenu à l'implémentation | |
| 25 | +``` | |
| 26 | + | |
| 27 | +**Résultat attendu** : ouvrir le même dossier de travail affiche la même paire regroupée qu'au scénario 1 ; promouvoir/rétrograder depuis la GUI produit exactement le même effet sur le système de fichiers que les commandes CLI équivalentes (vérifiable par `regine dossier list` après coup) ; aucune suppression n'est possible directement depuis cet écran (FR-006). | |
| 28 | + | |
| 29 | +## Scénario 3 — Vue combinée d'un dossier parent de voyage (User Story 1, FR-004/005) | |
| 30 | + | |
| 31 | +**Résultat attendu** : ouvrir un dossier parent (`specs/004`) dans la GUI affiche une vue combinant sa propre racine et celles de ses sous-dossiers/étapes, sans changement manuel de répertoire. | |
| 32 | + | |
| 33 | +## Scénario 4 — Consulter et restaurer via la CLI (User Story 2, P2) | |
| 34 | + | |
| 35 | +```bash | |
| 36 | +regine archive browse --search "Montenegro" | |
| 37 | +regine archive restore \ | |
| 38 | + --chemin voyage/2026-08_Montenegro/2026-08-12_Kotor/raw/2026-08-12_Kotor_RD0001.RAF \ | |
| 39 | + --destination ~/Desktop/RD0001.RAF | |
| 40 | +``` | |
| 41 | + | |
| 42 | +**Résultat attendu** : `browse` liste les dossiers/photos correspondants sans rien copier localement ; `restore` copie une version vérifiée par somme de contrôle du fichier ciblé vers `~/Desktop/`, sans checkout ni verrou du dossier entier (vérifiable : le dossier reste disponible pour un checkout normal ensuite, aucun verrou posé). | |
| 43 | + | |
| 44 | +## Scénario 5 — Consulter via la GUI (User Story 2, P2) — validation manuelle | |
| 45 | + | |
| 46 | +**Résultat attendu** : le mode consultation de la GUI reproduit le même résultat que le scénario 4 ; aucune action d'écriture (promotion, suppression) n'est proposée dans cet écran (FR-010). | |
| 47 | + | |
| 48 | +## Scénario 6 — Archive momentanément inaccessible (User Story 2, Edge Case) | |
| 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. | |
| 51 | + | |
| 52 | +## Critères de sortie | |
| 53 | + | |
| 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. | |
| new file mode 100644 | |||
| @@ -0,0 +1,54 @@ | |||
| 1 | +# Quickstart : validation de l'interface graphique de tri et de consultation | ||
| 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). | ||
| 4 | + | ||
| 5 | +## Prérequis | ||
| 6 | + | ||
| 7 | +- `regine-core`/`regine-cli` installés ; pour la GUI, `regine-gui` installé avec sa dépendance PySide6. | ||
| 8 | +- Un dossier de travail local déjà checkouté (`specs/005`) ou fraîchement importé (`specs/001`), contenant au moins une paire RAW+JPEG et un fichier RAW seul, répartis dans `raw/`/`jpeg/`. | ||
| 9 | +- Un contexte de travail configuré (`specs/003`) avec un `archive_smb_path` accessible, pour les scénarios de consultation. | ||
| 10 | + | ||
| 11 | +## Scénario 1 — Trier via la CLI (User Story 1, P1, Principe CLI-first) | ||
| 12 | + | ||
| 13 | +```bash | ||
| 14 | +regine dossier list ~/regine/local/2026-08-15_Sortie | ||
| 15 | +regine dossier promote ~/regine/local/2026-08-15_Sortie/raw/2026-08-15_Sortie_RD0001.RAF | ||
| 16 | +regine dossier list ~/regine/local/2026-08-15_Sortie | ||
| 17 | +``` | ||
| 18 | + | ||
| 19 | +**Résultat attendu** : la première liste montre la paire RAW+JPEG `RD0001` comme une seule entrée regroupée, non promue ; après `promote`, le RAW apparaît à la racine du dossier sur le système de fichiers et la seconde liste reflète son nouveau statut, sans toucher au JPEG jumeau. | ||
| 20 | + | ||
| 21 | +## Scénario 2 — Trier via la GUI (User Story 1, P1) — validation manuelle | ||
| 22 | + | ||
| 23 | +```bash | ||
| 24 | +regine-gui # ou uv run python -m regine_gui.app, selon l'empaquetage retenu à l'implémentation | ||
| 25 | +``` | ||
| 26 | + | ||
| 27 | +**Résultat attendu** : ouvrir le même dossier de travail affiche la même paire regroupée qu'au scénario 1 ; promouvoir/rétrograder depuis la GUI produit exactement le même effet sur le système de fichiers que les commandes CLI équivalentes (vérifiable par `regine dossier list` après coup) ; aucune suppression n'est possible directement depuis cet écran (FR-006). | ||
| 28 | + | ||
| 29 | +## Scénario 3 — Vue combinée d'un dossier parent de voyage (User Story 1, FR-004/005) | ||
| 30 | + | ||
| 31 | +**Résultat attendu** : ouvrir un dossier parent (`specs/004`) dans la GUI affiche une vue combinant sa propre racine et celles de ses sous-dossiers/étapes, sans changement manuel de répertoire. | ||
| 32 | + | ||
| 33 | +## Scénario 4 — Consulter et restaurer via la CLI (User Story 2, P2) | ||
| 34 | + | ||
| 35 | +```bash | ||
| 36 | +regine archive browse --search "Montenegro" | ||
| 37 | +regine archive restore \ | ||
| 38 | + --chemin voyage/2026-08_Montenegro/2026-08-12_Kotor/raw/2026-08-12_Kotor_RD0001.RAF \ | ||
| 39 | + --destination ~/Desktop/RD0001.RAF | ||
| 40 | +``` | ||
| 41 | + | ||
| 42 | +**Résultat attendu** : `browse` liste les dossiers/photos correspondants sans rien copier localement ; `restore` copie une version vérifiée par somme de contrôle du fichier ciblé vers `~/Desktop/`, sans checkout ni verrou du dossier entier (vérifiable : le dossier reste disponible pour un checkout normal ensuite, aucun verrou posé). | ||
| 43 | + | ||
| 44 | +## Scénario 5 — Consulter via la GUI (User Story 2, P2) — validation manuelle | ||
| 45 | + | ||
| 46 | +**Résultat attendu** : le mode consultation de la GUI reproduit le même résultat que le scénario 4 ; aucune action d'écriture (promotion, suppression) n'est proposée dans cet écran (FR-010). | ||
| 47 | + | ||
| 48 | +## Scénario 6 — Archive momentanément inaccessible (User Story 2, Edge Case) | ||
| 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. | ||
| 51 | + | ||
| 52 | +## Critères de sortie | ||
| 53 | + | ||
| 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. | ||
added
specs/006-gui-tri-consultation/research.md +49 -0 | new file mode 100644 | ||
| @@ -0,0 +1,49 @@ | ||
| 1 | +# Research: Interface graphique de tri et de consultation | |
| 2 | + | |
| 3 | +## 1. Technologie d'interface graphique : PySide6 natif | |
| 4 | + | |
| 5 | +**Decision**: PySide6 (Qt natif Python), importé directement dans le même processus que `regine-core` — pas de serveur local, pas de pont technique supplémentaire. | |
| 6 | + | |
| 7 | +**Rationale**: `docs/interface-cli-gui-architecture.md` avait déjà exploré la question sans trancher, avec une « inclination de départ vers PySide6 » pour deux raisons qui restent valables aujourd'hui : (1) cohérence avec la décision déjà actée d'une exécution « un seul processus par lancement, pas de démon » — une GUI web, même packagée en application de bureau, nécessiterait un mini-serveur local ou un pont plus lourd (Tauri + sidecar), ce qui casse partiellement ce principe ; (2) précédent solide dans le domaine (digiKam, RawTherapee construits sur Qt) pour une grille de miniatures performante et une sélection clavier/souris, exactement le cas d'usage du tri/culling (US1). | |
| 8 | + | |
| 9 | +**Alternatives considered**: | |
| 10 | +- GUI web (HTML/CSS/JS) packagée en application de bureau — rejetée pour cette itération : flexibilité d'interface supérieure, mais nécessite un processus ou un pont supplémentaire, contredisant la contrainte technique déjà actée. Reste une option révisable plus tard si l'itération visuelle rapide devient prioritaire (cf. discussion déjà actée dans `docs/interface-cli-gui-architecture.md`, non fermée définitivement). | |
| 11 | + | |
| 12 | +## 2. Stratégie de test de la GUI | |
| 13 | + | |
| 14 | +**Decision**: Aucune suite de tests automatisée pour l'interface graphique elle-même dans cette itération. Toute la logique métier qu'elle appelle (regroupement RAW+JPEG, promotion/rétrogradation, recherche, restauration) vit dans `regine-core` et y est testée exhaustivement (unitaire + intégration, comme pour tous les modules précédents du projet). La GUI est validée manuellement via les scénarios de `quickstart.md`, exactement comme les façades CLI précédentes l'ont été (specs/001 à 005) avant qu'aucune suite automatisée `regine-cli` n'existe (la première, `test_cli_config.py`, n'a été ajoutée qu'avec `specs/003`). | |
| 15 | + | |
| 16 | +**Rationale**: `pytest-qt` existe et permettrait de piloter des widgets Qt en test, mais ajouterait une dépendance de test et une complexité d'installation (environnement graphique/headless) non justifiées tant que la façade GUI n'a pas encore de première version fonctionnelle à couvrir. Cohérent avec le principe déjà appliqué dans ce projet : pas de complexité non justifiée pour un besoin hypothétique (Principe VI). Cette décision est révisable dès qu'un premier écran existe et que des régressions d'interface deviennent un risque réel. | |
| 17 | + | |
| 18 | +**Alternatives considered**: | |
| 19 | +- `pytest-qt` dès cette itération — rejeté pour l'instant, cf. rationale ci-dessus. | |
| 20 | +- Tests d'acceptation end-to-end pilotés par capture d'écran — rejeté, complexité largement disproportionnée par rapport à la taille de cette première itération GUI. | |
| 21 | + | |
| 22 | +## 3. Frontière entre `dossier.root` et `dossier.selection` | |
| 23 | + | |
| 24 | +**Decision**: Nouveau module `regine_core.dossier.selection`, distinct de `regine_core.dossier.root` (specs/004, inchangé). `root.py` porte le **répertoire racine d'archive** (premier niveau année/catégorie sous l'archive) ; `selection.py` porte la **racine de sélection** (niveau, à l'intérieur d'un dossier, où vivent les fichiers promus) — deux concepts que `docs/lexique.md` distingue déjà explicitement comme un « point de vigilance » (les deux emploient le mot « racine » mais désignent des niveaux différents de la hiérarchie). | |
| 25 | + | |
| 26 | +**Rationale**: Mélanger les deux dans un même fichier créerait exactement la confusion que le lexique met déjà en garde d'éviter. Garder `root.py` inchangé évite aussi de re-tester une logique déjà stable (specs/004, 28/28 tâches, testée) pour une fonctionnalité qui n'a aucune dépendance réelle dessus. | |
| 27 | + | |
| 28 | +**Alternatives considered**: | |
| 29 | +- Étendre `root.py` avec la logique de promotion — rejeté, cf. rationale (confusion de nommage, couplage non justifié entre deux concepts indépendants). | |
| 30 | + | |
| 31 | +## 4. Correspondance extension → dossier de format (`raw/`, `jpeg/`, `tiff/`) | |
| 32 | + | |
| 33 | +**Decision**: `selection.py` introduit une petite fonction de correspondance extension → nom de dossier de format, réutilisant l'ensemble d'extensions RAW déjà défini dans `regine_core.import_carte.copie.EXTENSIONS_MAITRES` plutôt que de le dupliquer (`raf`/`cr2`/`cr3`/`nef`/`arw`/`orf`/`rw2`/`pef`/`srw`/`dng` → `raw/`, à l'exception du DNG qui reste un cas à trancher à l'implémentation puisque `docs/archivage-photo-elements-cles.md` section 11 le traite à part pour le hash mais pas pour le rangement par dossier de format ; `tiff`/`tif` → `tiff/` ; `jpg`/`jpeg` → `jpeg/`). | |
| 34 | + | |
| 35 | +**Rationale**: Cette correspondance n'existe dans aucun module existant (vérifié : ni `import_carte`, ni `dossier`, ni `archive` n'en portent une). `docs/archivage-photo-elements-cles.md` section 10 la décrit précisément (« `raw/` est une catégorie fonctionnelle qui regroupe toutes les extensions RAW... contrairement à `jpeg/` et `tiff/`, qui correspondent chacun à une extension précise »), donc reste conforme à une conception déjà actée, pas une invention pour ce plan. | |
| 36 | + | |
| 37 | +**Alternatives considered**: Aucune — la logique est déjà spécifiée par les notes de conception existantes, seule son implémentation manquait. | |
| 38 | + | |
| 39 | +## 5. Restauration ciblée (US2) : copie vérifiée, sans mécanisme partagé dédié | |
| 40 | + | |
| 41 | +**Decision**: `regine_core.archive.consultation.restaurer` implémente sa propre copie vérifiée par somme de contrôle (source → destination, avec le même refus d'écrasement silencieux qu'ailleurs dans le projet, cf. `push.CollisionNomArchiveError`), plutôt que d'extraire un utilitaire partagé avec les fonctions de copie déjà privées de `push.py`/`checkout.py`. | |
| 42 | + | |
| 43 | +**Rationale**: Le projet a déjà deux copies vérifiées quasi identiques mais privées et non partagées (`import_carte.push._copier_verifie_sans_ecraser`, `archive.checkout._copier_verifie`) sans que cela ait posé de problème de maintenance à ce stade (~10 lignes chacune). Une troisième variante minimaliste (pas de vérification de collision requise ici, la restauration cible un fichier choisi par l'utilisateur) reste plus simple qu'une abstraction partagée prématurée. À reconsidérer si une quatrième variante apparaissait. | |
| 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). | |
| 46 | + | |
| 47 | +## Résumé | |
| 48 | + | |
| 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`. | |
| new file mode 100644 | |||
| @@ -0,0 +1,49 @@ | |||
| 1 | +# Research: Interface graphique de tri et de consultation | ||
| 2 | + | ||
| 3 | +## 1. Technologie d'interface graphique : PySide6 natif | ||
| 4 | + | ||
| 5 | +**Decision**: PySide6 (Qt natif Python), importé directement dans le même processus que `regine-core` — pas de serveur local, pas de pont technique supplémentaire. | ||
| 6 | + | ||
| 7 | +**Rationale**: `docs/interface-cli-gui-architecture.md` avait déjà exploré la question sans trancher, avec une « inclination de départ vers PySide6 » pour deux raisons qui restent valables aujourd'hui : (1) cohérence avec la décision déjà actée d'une exécution « un seul processus par lancement, pas de démon » — une GUI web, même packagée en application de bureau, nécessiterait un mini-serveur local ou un pont plus lourd (Tauri + sidecar), ce qui casse partiellement ce principe ; (2) précédent solide dans le domaine (digiKam, RawTherapee construits sur Qt) pour une grille de miniatures performante et une sélection clavier/souris, exactement le cas d'usage du tri/culling (US1). | ||
| 8 | + | ||
| 9 | +**Alternatives considered**: | ||
| 10 | +- GUI web (HTML/CSS/JS) packagée en application de bureau — rejetée pour cette itération : flexibilité d'interface supérieure, mais nécessite un processus ou un pont supplémentaire, contredisant la contrainte technique déjà actée. Reste une option révisable plus tard si l'itération visuelle rapide devient prioritaire (cf. discussion déjà actée dans `docs/interface-cli-gui-architecture.md`, non fermée définitivement). | ||
| 11 | + | ||
| 12 | +## 2. Stratégie de test de la GUI | ||
| 13 | + | ||
| 14 | +**Decision**: Aucune suite de tests automatisée pour l'interface graphique elle-même dans cette itération. Toute la logique métier qu'elle appelle (regroupement RAW+JPEG, promotion/rétrogradation, recherche, restauration) vit dans `regine-core` et y est testée exhaustivement (unitaire + intégration, comme pour tous les modules précédents du projet). La GUI est validée manuellement via les scénarios de `quickstart.md`, exactement comme les façades CLI précédentes l'ont été (specs/001 à 005) avant qu'aucune suite automatisée `regine-cli` n'existe (la première, `test_cli_config.py`, n'a été ajoutée qu'avec `specs/003`). | ||
| 15 | + | ||
| 16 | +**Rationale**: `pytest-qt` existe et permettrait de piloter des widgets Qt en test, mais ajouterait une dépendance de test et une complexité d'installation (environnement graphique/headless) non justifiées tant que la façade GUI n'a pas encore de première version fonctionnelle à couvrir. Cohérent avec le principe déjà appliqué dans ce projet : pas de complexité non justifiée pour un besoin hypothétique (Principe VI). Cette décision est révisable dès qu'un premier écran existe et que des régressions d'interface deviennent un risque réel. | ||
| 17 | + | ||
| 18 | +**Alternatives considered**: | ||
| 19 | +- `pytest-qt` dès cette itération — rejeté pour l'instant, cf. rationale ci-dessus. | ||
| 20 | +- Tests d'acceptation end-to-end pilotés par capture d'écran — rejeté, complexité largement disproportionnée par rapport à la taille de cette première itération GUI. | ||
| 21 | + | ||
| 22 | +## 3. Frontière entre `dossier.root` et `dossier.selection` | ||
| 23 | + | ||
| 24 | +**Decision**: Nouveau module `regine_core.dossier.selection`, distinct de `regine_core.dossier.root` (specs/004, inchangé). `root.py` porte le **répertoire racine d'archive** (premier niveau année/catégorie sous l'archive) ; `selection.py` porte la **racine de sélection** (niveau, à l'intérieur d'un dossier, où vivent les fichiers promus) — deux concepts que `docs/lexique.md` distingue déjà explicitement comme un « point de vigilance » (les deux emploient le mot « racine » mais désignent des niveaux différents de la hiérarchie). | ||
| 25 | + | ||
| 26 | +**Rationale**: Mélanger les deux dans un même fichier créerait exactement la confusion que le lexique met déjà en garde d'éviter. Garder `root.py` inchangé évite aussi de re-tester une logique déjà stable (specs/004, 28/28 tâches, testée) pour une fonctionnalité qui n'a aucune dépendance réelle dessus. | ||
| 27 | + | ||
| 28 | +**Alternatives considered**: | ||
| 29 | +- Étendre `root.py` avec la logique de promotion — rejeté, cf. rationale (confusion de nommage, couplage non justifié entre deux concepts indépendants). | ||
| 30 | + | ||
| 31 | +## 4. Correspondance extension → dossier de format (`raw/`, `jpeg/`, `tiff/`) | ||
| 32 | + | ||
| 33 | +**Decision**: `selection.py` introduit une petite fonction de correspondance extension → nom de dossier de format, réutilisant l'ensemble d'extensions RAW déjà défini dans `regine_core.import_carte.copie.EXTENSIONS_MAITRES` plutôt que de le dupliquer (`raf`/`cr2`/`cr3`/`nef`/`arw`/`orf`/`rw2`/`pef`/`srw`/`dng` → `raw/`, à l'exception du DNG qui reste un cas à trancher à l'implémentation puisque `docs/archivage-photo-elements-cles.md` section 11 le traite à part pour le hash mais pas pour le rangement par dossier de format ; `tiff`/`tif` → `tiff/` ; `jpg`/`jpeg` → `jpeg/`). | ||
| 34 | + | ||
| 35 | +**Rationale**: Cette correspondance n'existe dans aucun module existant (vérifié : ni `import_carte`, ni `dossier`, ni `archive` n'en portent une). `docs/archivage-photo-elements-cles.md` section 10 la décrit précisément (« `raw/` est une catégorie fonctionnelle qui regroupe toutes les extensions RAW... contrairement à `jpeg/` et `tiff/`, qui correspondent chacun à une extension précise »), donc reste conforme à une conception déjà actée, pas une invention pour ce plan. | ||
| 36 | + | ||
| 37 | +**Alternatives considered**: Aucune — la logique est déjà spécifiée par les notes de conception existantes, seule son implémentation manquait. | ||
| 38 | + | ||
| 39 | +## 5. Restauration ciblée (US2) : copie vérifiée, sans mécanisme partagé dédié | ||
| 40 | + | ||
| 41 | +**Decision**: `regine_core.archive.consultation.restaurer` implémente sa propre copie vérifiée par somme de contrôle (source → destination, avec le même refus d'écrasement silencieux qu'ailleurs dans le projet, cf. `push.CollisionNomArchiveError`), plutôt que d'extraire un utilitaire partagé avec les fonctions de copie déjà privées de `push.py`/`checkout.py`. | ||
| 42 | + | ||
| 43 | +**Rationale**: Le projet a déjà deux copies vérifiées quasi identiques mais privées et non partagées (`import_carte.push._copier_verifie_sans_ecraser`, `archive.checkout._copier_verifie`) sans que cela ait posé de problème de maintenance à ce stade (~10 lignes chacune). Une troisième variante minimaliste (pas de vérification de collision requise ici, la restauration cible un fichier choisi par l'utilisateur) reste plus simple qu'une abstraction partagée prématurée. À reconsidérer si une quatrième variante apparaissait. | ||
| 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). | ||
| 46 | + | ||
| 47 | +## Résumé | ||
| 48 | + | ||
| 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`. | ||
added
specs/006-gui-tri-consultation/spec.md +102 -0 | new file mode 100644 | ||
| @@ -0,0 +1,102 @@ | ||
| 1 | +# Feature Specification: Interface graphique de tri et de consultation | |
| 2 | + | |
| 3 | +**Feature Branch**: `[006-gui-tri-consultation]` | |
| 4 | + | |
| 5 | +**Created**: 2026-09-20 | |
| 6 | + | |
| 7 | +**Status**: Draft | |
| 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)." | |
| 10 | + | |
| 11 | +## User Scenarios & Testing *(mandatory)* | |
| 12 | + | |
| 13 | +### User Story 1 - Trier une sortie photo fraîchement importée (Priority: P1) | |
| 14 | + | |
| 15 | +Un photographe vient d'importer une carte mémoire et d'obtenir une copie de travail locale (import, cf. `specs/001`, ou checkout explicite, cf. `specs/005`). Il ouvre ce dossier dans l'interface graphique de Régine pour repérer ses meilleures photos et les promouvoir à la racine du dossier (planche-contact directement consultable), sans que les paires RAW+JPEG d'une même capture ne l'obligent à jongler entre deux entrées séparées, et sans être limité à un seul sous-répertoire à la fois. | |
| 16 | + | |
| 17 | +**Why this priority**: C'est l'usage le plus fréquent et le plus concret de la GUI — le geste que l'utilisateur répète après chaque sortie photo. Sans lui, la GUI n'apporte aucune valeur par rapport à un simple explorateur de fichiers. | |
| 18 | + | |
| 19 | +**Independent Test**: Peut être testé en ouvrant, dans la GUI, un dossier local contenant des paires RAW+JPEG et des fichiers RAW seuls répartis entre `raw/` et `jpeg/`, en promouvant certains fichiers à la racine, puis en vérifiant sur le système de fichiers que les fichiers promus sont bien à la racine du dossier et que les autres n'ont pas bougé — sans dépendre du mode consultation (User Story 2). | |
| 20 | + | |
| 21 | +**Acceptance Scenarios**: | |
| 22 | + | |
| 23 | +1. **Given** un dossier local ouvert contenant une capture en RAW+JPEG (même nom hors extension, dans `raw/` et `jpeg/` respectivement), **When** l'utilisateur consulte la liste des photos, **Then** cette capture apparaît comme une seule entrée regroupée, pas comme deux entrées séparées. | |
| 24 | +2. **Given** une entrée regroupée RAW+JPEG affichée, **When** l'utilisateur promeut cette entrée à la racine, **Then** le système lui permet de choisir de promouvoir le RAW, le JPEG, ou les deux — jamais une promotion automatique des deux fichiers sans confirmation individuelle (cf. `docs/archivage-photo-elements-cles.md` section 10 : le choix se fait fichier par fichier, pas par paire). | |
| 25 | +3. **Given** un dossier local ouvert, **When** l'utilisateur navigue, **Then** il peut voir et parcourir ensemble les photos de plusieurs dossiers de format (`raw/`, `jpeg/`, `tiff/`) du même dossier, sans changer manuellement de répertoire à chaque fois. | |
| 26 | +4. **Given** un dossier parent de voyage (avec ses sous-dossiers/étapes, cf. `specs/004`), **When** l'utilisateur l'ouvre dans la GUI, **Then** il peut voir la racine du dossier parent et les racines de ses sous-dossiers dans une même vue combinée. | |
| 27 | +5. **Given** une photo déjà promue à la racine, **When** l'utilisateur la rétrograde, **Then** elle retourne à son dossier de format d'origine, sans être supprimée. | |
| 28 | +6. **Given** une photo identifiée comme déchet évident (test d'exposition, déclenchement accidentel), **When** l'utilisateur demande sa suppression depuis la GUI, **Then** le système ne supprime rien immédiatement : la suppression est présentée comme un changement à valider explicitement au moment de la réconciliation (`specs/005`), jamais une suppression directe et silencieuse depuis l'écran de tri (Principe II). | |
| 29 | +7. **Given** une action de promotion ou de rétrogradation, **When** l'utilisateur la déclenche, **Then** la même action est également exposée par une commande en ligne de commande équivalente (Principe CLI-first de la constitution) — la GUI ne peut pas être la seule façon d'accomplir cette action. | |
| 30 | + | |
| 31 | +--- | |
| 32 | + | |
| 33 | +### User Story 2 - Retrouver une photo dans l'archive sans tout rapatrier (Priority: P2) | |
| 34 | + | |
| 35 | +Un photographe se souvient avoir pris une photo il y a plusieurs mois mais ne sait plus dans quel dossier, ou a supprimé par erreur un RAW de sa copie de travail locale. Plutôt que de faire un checkout complet du dossier concerné (potentiellement plusieurs dizaines de Go, cf. `specs/005`), il consulte l'archive directement en lecture seule depuis la GUI, retrouve la photo par une recherche, et en restaure une copie ciblée vers un emplacement local de son choix. | |
| 36 | + | |
| 37 | +**Why this priority**: Un raffinement précieux mais moins fréquent que le tri (User Story 1) — un utilisateur qui ne perd jamais de fichier ou qui se souvient toujours de l'emplacement de ses dossiers peut se passer de cette fonctionnalité sans perdre l'essentiel de la valeur de la GUI. | |
| 38 | + | |
| 39 | +**Independent Test**: Peut être testé en ouvrant la GUI en mode consultation sur une archive contenant plusieurs dossiers, en recherchant un dossier ou une photo par nom/date/texte, et en restaurant un fichier trouvé vers un dossier local vide, sans avoir réalisé de checkout complet au préalable. | |
| 40 | + | |
| 41 | +**Acceptance Scenarios**: | |
| 42 | + | |
| 43 | +1. **Given** une archive accessible, **When** l'utilisateur ouvre le mode consultation, **Then** il peut parcourir les dossiers de l'archive sans qu'aucun fichier ne soit copié localement tant qu'il ne le demande pas explicitement. | |
| 44 | +2. **Given** le mode consultation ouvert, **When** l'utilisateur recherche par nom de dossier, par date, ou par texte de titre, **Then** les dossiers et photos correspondants sont présentés, y compris à l'intérieur d'un dossier parent de voyage (dossier parent et ses sous-dossiers/étapes). | |
| 45 | +3. **Given** une photo repérée en consultation, **When** l'utilisateur demande sa restauration, **Then** le système en copie une version vérifiée (somme de contrôle) vers un emplacement local choisi par l'utilisateur, sans verrouiller ni checkouter le dossier entier. | |
| 46 | +4. **Given** le mode consultation, **When** l'utilisateur y navigue, **Then** aucune action d'écriture (promotion, suppression, modification) n'y est possible — ces actions restent réservées à une copie de travail réellement checkoutée (User Story 1). | |
| 47 | +5. **Given** l'archive momentanément inaccessible (partage réseau non monté), **When** l'utilisateur ouvre le mode consultation, **Then** le système réutilise l'assistance au montage déjà prévue (`specs/003`) plutôt que d'échouer sans explication. | |
| 48 | + | |
| 49 | +--- | |
| 50 | + | |
| 51 | +### Edge Cases | |
| 52 | + | |
| 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. | |
| 54 | +- Que se passe-t-il si deux fichiers de dossiers de format différents partagent le même nom hors extension mais ne sont *pas* une paire RAW+JPEG légitime (coïncidence de nommage) ? Le regroupement visuel reste purement indicatif ; aucune action destructive ne doit dépendre de cette supposition d'appariement. | |
| 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`). | |
| 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. | |
| 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. | |
| 58 | + | |
| 59 | +## Requirements *(mandatory)* | |
| 60 | + | |
| 61 | +### Functional Requirements | |
| 62 | + | |
| 63 | +- **FR-001**: Le système DOIT afficher, pour un dossier de travail local ouvert, l'ensemble des photos réparties dans ses dossiers de format (`raw/`, `jpeg/`, `tiff/`...) et à sa racine, en une seule vue navigable. | |
| 64 | +- **FR-002**: Le système DOIT regrouper visuellement, comme une seule entrée, un fichier RAW et son JPEG jumeau (même nom hors extension, capture en mode RAW+JPEG), même lorsqu'ils résident dans des dossiers de format différents. | |
| 65 | +- **FR-003**: Le système DOIT permettre de promouvoir un fichier individuel (RAW ou JPEG, indépendamment de son jumeau éventuel) à la racine du dossier, et de le rétrograder — décision toujours prise fichier par fichier, jamais automatiquement par paire. | |
| 66 | +- **FR-004**: Le système DOIT permettre de naviguer et visualiser ensemble plusieurs dossiers de format d'un même dossier, sans obliger l'utilisateur à changer manuellement de répertoire. | |
| 67 | +- **FR-005**: Pour un dossier parent avec des sous-dossiers (voyage en plusieurs étapes, `specs/004`), le système DOIT permettre une vue combinant la racine du dossier parent et celles de ses sous-dossiers. | |
| 68 | +- **FR-006**: Le système NE DOIT PAS supprimer un fichier directement depuis l'écran de tri : toute suppression proposée DOIT être présentée comme un changement à valider explicitement lors de la réconciliation (`specs/005`), jamais exécutée immédiatement (Principe II). | |
| 69 | +- **FR-007**: Chaque action de promotion/rétrogradation exposée par la GUI DOIT être également accomplissable par une commande en ligne de commande équivalente (Principe CLI-first de la constitution) ; l'action sous-jacente (promotion à la racine de sélection) n'existe dans aucun module existant de `regine-core` et DOIT être construite comme telle, pas comme une logique propre à la GUI (Principe VI). | |
| 70 | +- **FR-008**: Le système DOIT permettre de parcourir l'archive en lecture seule, par dossier, par date, ou par texte de titre, sans nécessiter de checkout complet préalable. | |
| 71 | +- **FR-009**: Le système DOIT permettre de restaurer une copie vérifiée (somme de contrôle) d'un fichier repéré dans l'archive vers un emplacement local choisi par l'utilisateur, sans verrouiller ni checkouter le dossier entier dont il provient. | |
| 72 | +- **FR-010**: Le système NE DOIT PAS permettre d'action d'écriture (promotion, suppression, modification) depuis le mode consultation en lecture seule — ces actions restent réservées à une copie de travail réellement checkoutée. | |
| 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. | |
| 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é. | |
| 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. | |
| 76 | + | |
| 77 | +### Key Entities | |
| 78 | + | |
| 79 | +- **Entrée regroupée** : représentation à l'écran d'une capture, combinant son RAW et son JPEG jumeau quand les deux existent (même nom hors extension) ; purement visuelle, ne modifie jamais la relation entre les deux fichiers sur le système de fichiers. | |
| 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. | |
| 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. | |
| 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). | |
| 83 | + | |
| 84 | +## Success Criteria *(mandatory)* | |
| 85 | + | |
| 86 | +### Measurable Outcomes | |
| 87 | + | |
| 88 | +- **SC-001**: Un utilisateur peut trier un dossier de 200 photos (avec paires RAW+JPEG) et promouvoir sa sélection à la racine sans jamais supprimer par erreur un fichier de la paire au seul motif de désencombrer l'affichage. | |
| 89 | +- **SC-002**: 100% des paires RAW+JPEG d'un dossier ouvert en tri/culling apparaissent comme une entrée unique, jamais comme deux entrées séparées. | |
| 90 | +- **SC-003**: Un utilisateur retrouve une photo précise dans une archive de plusieurs centaines de dossiers, par recherche, sans avoir à se souvenir du chemin exact ni à réaliser de checkout complet. | |
| 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. | |
| 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. | |
| 93 | +- **SC-006**: Chaque action de promotion/rétrogradation disponible dans la GUI reste accomplissable via une commande CLI équivalente, sans exception. | |
| 94 | + | |
| 95 | +## Assumptions | |
| 96 | + | |
| 97 | +- La GUI ne lance pas et ne remplace pas l'outil d'édition RAW (DxO, Lightroom...) : elle offre son propre écran de tri/sélection, que l'utilisateur peut utiliser en complément ou à la place de DxO pour la seule promotion à la racine — le développement RAW (recadrage, étalonnage) continue de se faire dans l'outil du choix de l'utilisateur. | |
| 98 | +- Cette version ne couvre pas la notation en étoiles ni les annotations visuelles (réservées à la planche-contact JPEG annotée, hors périmètre, `docs/interface-cli-gui-architecture.md`) : seule la promotion binaire à la racine est couverte ici. | |
| 99 | +- La restauration ciblée (User Story 2) porte sur un fichier ou une petite sélection de fichiers, pas sur un dossier entier — restaurer un dossier entier reste le rôle du checkout complet déjà spécifié (`specs/005`). | |
| 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. | |
| 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). | |
| 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. | |
| new file mode 100644 | |||
| @@ -0,0 +1,102 @@ | |||
| 1 | +# Feature Specification: Interface graphique de tri et de consultation | ||
| 2 | + | ||
| 3 | +**Feature Branch**: `[006-gui-tri-consultation]` | ||
| 4 | + | ||
| 5 | +**Created**: 2026-09-20 | ||
| 6 | + | ||
| 7 | +**Status**: Draft | ||
| 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)." | ||
| 10 | + | ||
| 11 | +## User Scenarios & Testing *(mandatory)* | ||
| 12 | + | ||
| 13 | +### User Story 1 - Trier une sortie photo fraîchement importée (Priority: P1) | ||
| 14 | + | ||
| 15 | +Un photographe vient d'importer une carte mémoire et d'obtenir une copie de travail locale (import, cf. `specs/001`, ou checkout explicite, cf. `specs/005`). Il ouvre ce dossier dans l'interface graphique de Régine pour repérer ses meilleures photos et les promouvoir à la racine du dossier (planche-contact directement consultable), sans que les paires RAW+JPEG d'une même capture ne l'obligent à jongler entre deux entrées séparées, et sans être limité à un seul sous-répertoire à la fois. | ||
| 16 | + | ||
| 17 | +**Why this priority**: C'est l'usage le plus fréquent et le plus concret de la GUI — le geste que l'utilisateur répète après chaque sortie photo. Sans lui, la GUI n'apporte aucune valeur par rapport à un simple explorateur de fichiers. | ||
| 18 | + | ||
| 19 | +**Independent Test**: Peut être testé en ouvrant, dans la GUI, un dossier local contenant des paires RAW+JPEG et des fichiers RAW seuls répartis entre `raw/` et `jpeg/`, en promouvant certains fichiers à la racine, puis en vérifiant sur le système de fichiers que les fichiers promus sont bien à la racine du dossier et que les autres n'ont pas bougé — sans dépendre du mode consultation (User Story 2). | ||
| 20 | + | ||
| 21 | +**Acceptance Scenarios**: | ||
| 22 | + | ||
| 23 | +1. **Given** un dossier local ouvert contenant une capture en RAW+JPEG (même nom hors extension, dans `raw/` et `jpeg/` respectivement), **When** l'utilisateur consulte la liste des photos, **Then** cette capture apparaît comme une seule entrée regroupée, pas comme deux entrées séparées. | ||
| 24 | +2. **Given** une entrée regroupée RAW+JPEG affichée, **When** l'utilisateur promeut cette entrée à la racine, **Then** le système lui permet de choisir de promouvoir le RAW, le JPEG, ou les deux — jamais une promotion automatique des deux fichiers sans confirmation individuelle (cf. `docs/archivage-photo-elements-cles.md` section 10 : le choix se fait fichier par fichier, pas par paire). | ||
| 25 | +3. **Given** un dossier local ouvert, **When** l'utilisateur navigue, **Then** il peut voir et parcourir ensemble les photos de plusieurs dossiers de format (`raw/`, `jpeg/`, `tiff/`) du même dossier, sans changer manuellement de répertoire à chaque fois. | ||
| 26 | +4. **Given** un dossier parent de voyage (avec ses sous-dossiers/étapes, cf. `specs/004`), **When** l'utilisateur l'ouvre dans la GUI, **Then** il peut voir la racine du dossier parent et les racines de ses sous-dossiers dans une même vue combinée. | ||
| 27 | +5. **Given** une photo déjà promue à la racine, **When** l'utilisateur la rétrograde, **Then** elle retourne à son dossier de format d'origine, sans être supprimée. | ||
| 28 | +6. **Given** une photo identifiée comme déchet évident (test d'exposition, déclenchement accidentel), **When** l'utilisateur demande sa suppression depuis la GUI, **Then** le système ne supprime rien immédiatement : la suppression est présentée comme un changement à valider explicitement au moment de la réconciliation (`specs/005`), jamais une suppression directe et silencieuse depuis l'écran de tri (Principe II). | ||
| 29 | +7. **Given** une action de promotion ou de rétrogradation, **When** l'utilisateur la déclenche, **Then** la même action est également exposée par une commande en ligne de commande équivalente (Principe CLI-first de la constitution) — la GUI ne peut pas être la seule façon d'accomplir cette action. | ||
| 30 | + | ||
| 31 | +--- | ||
| 32 | + | ||
| 33 | +### User Story 2 - Retrouver une photo dans l'archive sans tout rapatrier (Priority: P2) | ||
| 34 | + | ||
| 35 | +Un photographe se souvient avoir pris une photo il y a plusieurs mois mais ne sait plus dans quel dossier, ou a supprimé par erreur un RAW de sa copie de travail locale. Plutôt que de faire un checkout complet du dossier concerné (potentiellement plusieurs dizaines de Go, cf. `specs/005`), il consulte l'archive directement en lecture seule depuis la GUI, retrouve la photo par une recherche, et en restaure une copie ciblée vers un emplacement local de son choix. | ||
| 36 | + | ||
| 37 | +**Why this priority**: Un raffinement précieux mais moins fréquent que le tri (User Story 1) — un utilisateur qui ne perd jamais de fichier ou qui se souvient toujours de l'emplacement de ses dossiers peut se passer de cette fonctionnalité sans perdre l'essentiel de la valeur de la GUI. | ||
| 38 | + | ||
| 39 | +**Independent Test**: Peut être testé en ouvrant la GUI en mode consultation sur une archive contenant plusieurs dossiers, en recherchant un dossier ou une photo par nom/date/texte, et en restaurant un fichier trouvé vers un dossier local vide, sans avoir réalisé de checkout complet au préalable. | ||
| 40 | + | ||
| 41 | +**Acceptance Scenarios**: | ||
| 42 | + | ||
| 43 | +1. **Given** une archive accessible, **When** l'utilisateur ouvre le mode consultation, **Then** il peut parcourir les dossiers de l'archive sans qu'aucun fichier ne soit copié localement tant qu'il ne le demande pas explicitement. | ||
| 44 | +2. **Given** le mode consultation ouvert, **When** l'utilisateur recherche par nom de dossier, par date, ou par texte de titre, **Then** les dossiers et photos correspondants sont présentés, y compris à l'intérieur d'un dossier parent de voyage (dossier parent et ses sous-dossiers/étapes). | ||
| 45 | +3. **Given** une photo repérée en consultation, **When** l'utilisateur demande sa restauration, **Then** le système en copie une version vérifiée (somme de contrôle) vers un emplacement local choisi par l'utilisateur, sans verrouiller ni checkouter le dossier entier. | ||
| 46 | +4. **Given** le mode consultation, **When** l'utilisateur y navigue, **Then** aucune action d'écriture (promotion, suppression, modification) n'y est possible — ces actions restent réservées à une copie de travail réellement checkoutée (User Story 1). | ||
| 47 | +5. **Given** l'archive momentanément inaccessible (partage réseau non monté), **When** l'utilisateur ouvre le mode consultation, **Then** le système réutilise l'assistance au montage déjà prévue (`specs/003`) plutôt que d'échouer sans explication. | ||
| 48 | + | ||
| 49 | +--- | ||
| 50 | + | ||
| 51 | +### Edge Cases | ||
| 52 | + | ||
| 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. | ||
| 54 | +- Que se passe-t-il si deux fichiers de dossiers de format différents partagent le même nom hors extension mais ne sont *pas* une paire RAW+JPEG légitime (coïncidence de nommage) ? Le regroupement visuel reste purement indicatif ; aucune action destructive ne doit dépendre de cette supposition d'appariement. | ||
| 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`). | ||
| 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. | ||
| 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. | ||
| 58 | + | ||
| 59 | +## Requirements *(mandatory)* | ||
| 60 | + | ||
| 61 | +### Functional Requirements | ||
| 62 | + | ||
| 63 | +- **FR-001**: Le système DOIT afficher, pour un dossier de travail local ouvert, l'ensemble des photos réparties dans ses dossiers de format (`raw/`, `jpeg/`, `tiff/`...) et à sa racine, en une seule vue navigable. | ||
| 64 | +- **FR-002**: Le système DOIT regrouper visuellement, comme une seule entrée, un fichier RAW et son JPEG jumeau (même nom hors extension, capture en mode RAW+JPEG), même lorsqu'ils résident dans des dossiers de format différents. | ||
| 65 | +- **FR-003**: Le système DOIT permettre de promouvoir un fichier individuel (RAW ou JPEG, indépendamment de son jumeau éventuel) à la racine du dossier, et de le rétrograder — décision toujours prise fichier par fichier, jamais automatiquement par paire. | ||
| 66 | +- **FR-004**: Le système DOIT permettre de naviguer et visualiser ensemble plusieurs dossiers de format d'un même dossier, sans obliger l'utilisateur à changer manuellement de répertoire. | ||
| 67 | +- **FR-005**: Pour un dossier parent avec des sous-dossiers (voyage en plusieurs étapes, `specs/004`), le système DOIT permettre une vue combinant la racine du dossier parent et celles de ses sous-dossiers. | ||
| 68 | +- **FR-006**: Le système NE DOIT PAS supprimer un fichier directement depuis l'écran de tri : toute suppression proposée DOIT être présentée comme un changement à valider explicitement lors de la réconciliation (`specs/005`), jamais exécutée immédiatement (Principe II). | ||
| 69 | +- **FR-007**: Chaque action de promotion/rétrogradation exposée par la GUI DOIT être également accomplissable par une commande en ligne de commande équivalente (Principe CLI-first de la constitution) ; l'action sous-jacente (promotion à la racine de sélection) n'existe dans aucun module existant de `regine-core` et DOIT être construite comme telle, pas comme une logique propre à la GUI (Principe VI). | ||
| 70 | +- **FR-008**: Le système DOIT permettre de parcourir l'archive en lecture seule, par dossier, par date, ou par texte de titre, sans nécessiter de checkout complet préalable. | ||
| 71 | +- **FR-009**: Le système DOIT permettre de restaurer une copie vérifiée (somme de contrôle) d'un fichier repéré dans l'archive vers un emplacement local choisi par l'utilisateur, sans verrouiller ni checkouter le dossier entier dont il provient. | ||
| 72 | +- **FR-010**: Le système NE DOIT PAS permettre d'action d'écriture (promotion, suppression, modification) depuis le mode consultation en lecture seule — ces actions restent réservées à une copie de travail réellement checkoutée. | ||
| 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. | ||
| 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é. | ||
| 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. | ||
| 76 | + | ||
| 77 | +### Key Entities | ||
| 78 | + | ||
| 79 | +- **Entrée regroupée** : représentation à l'écran d'une capture, combinant son RAW et son JPEG jumeau quand les deux existent (même nom hors extension) ; purement visuelle, ne modifie jamais la relation entre les deux fichiers sur le système de fichiers. | ||
| 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. | ||
| 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. | ||
| 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). | ||
| 83 | + | ||
| 84 | +## Success Criteria *(mandatory)* | ||
| 85 | + | ||
| 86 | +### Measurable Outcomes | ||
| 87 | + | ||
| 88 | +- **SC-001**: Un utilisateur peut trier un dossier de 200 photos (avec paires RAW+JPEG) et promouvoir sa sélection à la racine sans jamais supprimer par erreur un fichier de la paire au seul motif de désencombrer l'affichage. | ||
| 89 | +- **SC-002**: 100% des paires RAW+JPEG d'un dossier ouvert en tri/culling apparaissent comme une entrée unique, jamais comme deux entrées séparées. | ||
| 90 | +- **SC-003**: Un utilisateur retrouve une photo précise dans une archive de plusieurs centaines de dossiers, par recherche, sans avoir à se souvenir du chemin exact ni à réaliser de checkout complet. | ||
| 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. | ||
| 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. | ||
| 93 | +- **SC-006**: Chaque action de promotion/rétrogradation disponible dans la GUI reste accomplissable via une commande CLI équivalente, sans exception. | ||
| 94 | + | ||
| 95 | +## Assumptions | ||
| 96 | + | ||
| 97 | +- La GUI ne lance pas et ne remplace pas l'outil d'édition RAW (DxO, Lightroom...) : elle offre son propre écran de tri/sélection, que l'utilisateur peut utiliser en complément ou à la place de DxO pour la seule promotion à la racine — le développement RAW (recadrage, étalonnage) continue de se faire dans l'outil du choix de l'utilisateur. | ||
| 98 | +- Cette version ne couvre pas la notation en étoiles ni les annotations visuelles (réservées à la planche-contact JPEG annotée, hors périmètre, `docs/interface-cli-gui-architecture.md`) : seule la promotion binaire à la racine est couverte ici. | ||
| 99 | +- La restauration ciblée (User Story 2) porte sur un fichier ou une petite sélection de fichiers, pas sur un dossier entier — restaurer un dossier entier reste le rôle du checkout complet déjà spécifié (`specs/005`). | ||
| 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. | ||
| 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). | ||
| 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. | ||
added
specs/006-gui-tri-consultation/tasks.md +126 -0 | new file mode 100644 | ||
| @@ -0,0 +1,126 @@ | ||
| 1 | +--- | |
| 2 | + | |
| 3 | +description: "Task list for feature implementation" | |
| 4 | +--- | |
| 5 | + | |
| 6 | +# Tasks: Interface graphique de tri et de consultation | |
| 7 | + | |
| 8 | +**Input**: Design documents from `/specs/006-gui-tri-consultation/` | |
| 9 | +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md | |
| 10 | + | |
| 11 | +**Tests**: Incluses — `plan.md` § Project Structure nomme explicitement les fichiers de test attendus, ce qui vaut demande explicite au sens des règles de génération. | |
| 12 | + | |
| 13 | +**Organisation**: Tâches groupées par user story (spec.md) pour une implémentation et une validation indépendantes de chacune. | |
| 14 | + | |
| 15 | +## Contrainte structurante à respecter tout au long de l'implémentation | |
| 16 | + | |
| 17 | +**Principe CLI-first (constitution)** : pour chaque user story, les tâches CLI (`regine dossier ...`, `regine archive browse/restore`) DOIVENT être implémentées et testées avant les écrans GUI équivalents — jamais l'inverse. L'ordre des tâches ci-dessous respecte déjà cette contrainte ; ne pas le réorganiser à l'implémentation. | |
| 18 | + | |
| 19 | +## Dépendances inter-specs (à lire avant de commencer) | |
| 20 | + | |
| 21 | +| Dépendance | Fournit | État | | |
| 22 | +|---|---|---| | |
| 23 | +| `specs/001-import-photos` | `regine_core.import_carte.copie.EXTENSIONS_MAITRES` (réutilisée par `selection.dossier_de_format`, research.md § 4) | Implémentée | | |
| 24 | +| `specs/003-config-contexte-travail` | `regine_core.config.context`/`smb` (contexte de travail, disponibilité et point de montage SMB, réutilisés par `regine archive browse/restore`) | Implémentée | | |
| 25 | +| `specs/004-categorisation-dossiers` | `regine_core.dossier.root` — sémantique différente de `regine_core.dossier.selection` (cette spec), cf. research.md § 3, aucune modification requise | Implémentée | | |
| 26 | +| `specs/005-checkout-reconciliation` | `regine_core.archive.manifest`/`verrou`, et `comparer()` qui détecte nativement un déplacement de promotion par hash (aucun code dédié requis ici) | Implémentée | | |
| 27 | + | |
| 28 | +## Phase 1: Setup | |
| 29 | + | |
| 30 | +- [X] T001 [P] Ajouter la dépendance PySide6 à `packages/regine-gui/pyproject.toml` (cf. research.md § 1) — `uv add pyside6` (6.11.2), description du paquet mise à jour (n'est plus « réservé ») | |
| 31 | +- [X] T002 [P] Créer `packages/regine-core/src/regine_core/dossier/selection.py` (squelette, docstring de module uniquement — distinct de `root.py`, cf. research.md § 3) | |
| 32 | +- [X] T003 [P] Créer `packages/regine-core/src/regine_core/archive/consultation.py` (squelette, docstring de module uniquement) | |
| 33 | +- [X] T004 [P] Créer `packages/regine-cli/src/regine_cli/dossier_cmd.py` (squelette, docstring de module uniquement) | |
| 34 | +- [X] T005 [P] Créer `packages/regine-gui/src/regine_gui/app.py` (squelette, point d'entrée PySide6) | |
| 35 | +- [X] T006 [P] Vérifier que PySide6 s'installe et s'importe sur le poste de développement (`uv sync`, `python -c "import PySide6"`) — installé et importable (`from PySide6.QtWidgets import QApplication` fonctionne), environnement graphique macOS disponible | |
| 36 | + | |
| 37 | +--- | |
| 38 | + | |
| 39 | +## Phase 2: Foundational (Blocking Prerequisites) | |
| 40 | + | |
| 41 | +**Objectif** : la correspondance extension → dossier de format, prérequis de toute la logique de `selection.py` (US1). | |
| 42 | + | |
| 43 | +**⚠️ CRITIQUE** : User Story 1 ne peut pas commencer avant la fin de cette phase. User Story 2 en est indépendante (cf. Independent Test de spec.md) et peut être menée en parallèle. | |
| 44 | + | |
| 45 | +- [X] T007 Implémenter `dossier_de_format(extension: str) -> str | None` dans `selection.py`, en réutilisant `regine_core.import_carte.copie.EXTENSIONS_MAITRES` (research.md § 4) — jamais une nouvelle liste d'extensions dupliquée | |
| 46 | +- [X] T008 [P] Test unitaire de `dossier_de_format` dans `packages/regine-core/tests/unit/test_selection_grouping.py` : extensions RAW → `raw`, `tiff`/`tif` → `tiff`, `jpg`/`jpeg` → `jpeg`, extension associée (sidecar) ou inconnue → `None`, format maître marginal (`bmp`) → son propre nom de dossier | |
| 47 | + | |
| 48 | +**Checkpoint** : la correspondance de format est prête ; User Story 1 peut commencer. | |
| 49 | + | |
| 50 | +--- | |
| 51 | + | |
| 52 | +## Phase 3: User Story 1 - Trier une sortie photo fraîchement importée (Priority: P1) 🎯 MVP | |
| 53 | + | |
| 54 | +**Objectif** : regroupement visuel RAW+JPEG, promotion/rétrogradation individuelle à la racine de sélection, vue combinant plusieurs dossiers de format et sous-dossiers — d'abord en CLI (Principe CLI-first), puis dans la GUI. | |
| 55 | + | |
| 56 | +**Test indépendant** : ouvrir un dossier local avec des paires RAW+JPEG via `regine dossier list`, promouvoir un fichier, vérifier sur le système de fichiers. | |
| 57 | + | |
| 58 | +### Tests for User Story 1 | |
| 59 | + | |
| 60 | +- [X] T009 [P] [US1] Test unitaire de `regrouper()` dans `packages/regine-core/tests/unit/test_selection_grouping.py` : paire RAW+JPEG regroupée en une entrée, RAW seul, JPEG seul (mode JPEG seul), dossier sans structure reconnue → erreur dédiée (FR-013) — étendu pour couvrir un scan TIFF isolé (généralisation `EntreeRegroupee.fichiers`, cf. data-model.md) | |
| 61 | +- [X] T010 [P] [US1] Test unitaire de `promouvoir()`/`retrograder()` dans `packages/regine-core/tests/unit/test_selection_promotion.py` : promotion idempotente (déjà à la racine → no-op), rétrogradation refuse si le fichier n'est pas à la racine, hash de contenu inchangé avant/après (Principe I/III) — **écart de conception trouvé par ce test** : la promotion « en deux temps » (étape puis dossier parent) ne peut pas être devinée à partir du seul chemin du fichier ; `promouvoir` a été corrigée pour prendre `dossier` en argument explicite, cf. `contracts/regine-core-api.md` | |
| 62 | +- [X] T011 [P] [US1] Test d'intégration de la vue combinée (dossier parent + sous-dossiers) dans `packages/regine-core/tests/integration/test_selection_combined_view.py` — `regrouper` étant récursif (`rglob`), la vue du dossier parent inclut nativement les fichiers de ses étapes (planche-contact globale, section 10) ; aucune logique combinée dédiée nécessaire côté `regine-core` | |
| 63 | +- [X] T012 [P] [US1] Test de contrat CLI dans `packages/regine-cli/tests/contract/test_cli_dossier.py` : `regine dossier list/promote/demote`, codes de sortie, cf. `contracts/cli-dossier-selection.md` — 7/7 tests passent | |
| 64 | +- [X] T013 [US1] Test d'intégration bout-en-bout : une promotion via `regine dossier promote` est ensuite classée `deplacement` (et non `anomalie`) par `regine_core.archive.reconciliation.comparer` au moment de la réconciliation — preuve qu'aucun code dédié n'est nécessaire côté specs/005 (Principe III), dans `packages/regine-core/tests/integration/test_selection_reconciliation.py`, passe du premier coup | |
| 65 | + | |
| 66 | +### Implementation for User Story 1 | |
| 67 | + | |
| 68 | +- [X] T014 [US1] Implémenter `regrouper(dossier: Path) -> list[EntreeRegroupee]` dans `selection.py` | |
| 69 | +- [X] T015 [US1] Implémenter `promouvoir(dossier: Path, fichier: Path) -> Path` / `retrograder(fichier: Path) -> Path` dans `selection.py` — signature de `promouvoir` corrigée par rapport au contrat initial (cf. T010) | |
| 70 | +- [X] T016 [US1] Implémenter `regine dossier list/promote/demote` dans `dossier_cmd.py` (cf. `contracts/cli-dossier-selection.md`) — **CLI-first : cette tâche précède T017/T018, jamais l'inverse** | |
| 71 | +- [X] T017 [US1] Implémenter l'écran de tri (`tri_view.py`) dans `regine-gui` : appelle exclusivement `regine_core.dossier.selection`, aucune logique de regroupement/promotion propre à la GUI (Principe VI) — validé manuellement en mode headless (`QT_QPA_PLATFORM=offscreen`) : ouverture d'un dossier, regroupement, promotion via l'écran, fichier réellement déplacé sur le système de fichiers | |
| 72 | +- [X] T018 [US1] Implémenter la vue combinée (dossier parent + sous-dossiers) dans `tri_view.py` — **aucun code combiné dédié nécessaire** : `regrouper()` étant récursif (T011), ouvrir un dossier parent affiche déjà ses fichiers et ceux de ses sous-dossiers dans la même liste. Décision de portée documentée : la promotion depuis cet écran cible toujours le dossier explicitement ouvert (`self._dossier`) — pour la promotion en deux temps d'un voyage, l'utilisateur ouvre l'étape puis, séparément, le dossier parent (deux gestes explicites distincts, cf. docstring de `tri_view.py`), plutôt qu'un raccourci automatique | |
| 73 | + | |
| 74 | +**Checkpoint** : User Story 1 fonctionnelle et testable indépendamment (MVP), CLI et GUI cohérentes entre elles. | |
| 75 | + | |
| 76 | +--- | |
| 77 | + | |
| 78 | +## Phase 4: User Story 2 - Retrouver une photo dans l'archive sans tout rapatrier (Priority: P2) | |
| 79 | + | |
| 80 | +**Objectif** : consultation en lecture seule de l'archive avec recherche, restauration ciblée d'un fichier sans checkout complet — d'abord en CLI, puis dans la GUI. Indépendante de User Story 1 (aucune dépendance sur `selection.py`). | |
| 81 | + | |
| 82 | +**Test indépendant** : `regine archive browse --search ...` puis `regine archive restore` vers un dossier local vide, sans checkout préalable. | |
| 83 | + | |
| 84 | +### Tests for User Story 2 | |
| 85 | + | |
| 86 | +- [X] T019 [P] [US2] Test unitaire de `rechercher()` dans `packages/regine-core/tests/unit/test_consultation_search.py` : filtre par nom de dossier/date/texte, aucun résultat → liste vide (pas une erreur) | |
| 87 | +- [X] T020 [P] [US2] Test unitaire de `restaurer()` dans `packages/regine-core/tests/unit/test_consultation_restore.py` : copie vérifiée par somme de contrôle, refuse d'écraser un fichier local de contenu différent (FR-012), lève si la source est introuvable dans l'archive | |
| 88 | +- [X] T021 [P] [US2] Test de contrat CLI dans `packages/regine-cli/tests/contract/test_cli_archive_consultation.py` : `regine browse`/`regine restore` (renommé, cf. écart documenté dans `contracts/cli-archive-consultation.md`), y compris le cas aucune archive configurée — 3/3 tests passent | |
| 89 | +- [X] T022 [US2] Test d'intégration : une restauration ciblée ne pose ni ne modifie aucun verrou du dossier d'origine (`regine_core.archive.verrou.verifier` reste inchangé avant/après), dans `packages/regine-core/tests/integration/test_consultation_restore_verrou.py` (renommé : collision de nom de module avec le test unitaire T020, pytest exige des noms de fichiers de test uniques sans `__init__.py`) | |
| 90 | + | |
| 91 | +### Implementation for User Story 2 | |
| 92 | + | |
| 93 | +- [X] T023 [US2] Implémenter `rechercher(archive_root_local: Path, requete: str | None) -> list[ResultatRecherche]` dans `consultation.py` | |
| 94 | +- [X] T024 [US2] Implémenter `restaurer(chemin_source: Path, chemin_destination: Path) -> None` dans `consultation.py` — réutilise `regine_core.archive.checkout.IntegriteCopieError` plutôt qu'une exception dupliquée | |
| 95 | +- [X] T025 [US2] Étendre `packages/regine-cli/src/regine_cli/archive_cmd.py` avec `regine browse`/`regine restore` (charge le contexte, `smb.assurer_disponibilite` avant tout parcours, cf. `contracts/cli-archive-consultation.md`) — **écart trouvé à l'implémentation** : `checkout`/`reconcile` sont déjà des sous-commandes de premier niveau (pas `regine archive checkout`), `browse`/`restore` suivent la même convention plutôt que d'introduire un niveau `archive` inédit — **CLI-first : précède T026** | |
| 96 | +- [X] T026 [US2] Implémenter l'écran de consultation (`consultation_view.py`) dans `regine-gui` : lecture seule stricte, aucune action d'écriture proposée (FR-010) — **bug réel trouvé par test manuel headless** : la résolution du point de montage de l'archive était dupliquée entre `archive_cmd.py` et `consultation_view.py` avec un style d'import différent, rendant l'une des deux résolutions impossible à patcher en test et incohérente avec l'autre (violation du Principe VI). Corrigé en extrayant `regine_core.config.smb.resoudre_archive_locale(conn)`, désormais seule source de vérité partagée par la CLI et la GUI ; validé de bout en bout en mode headless (`QT_QPA_PLATFORM=offscreen`) : recherche puis restauration réelles | |
| 97 | + | |
| 98 | +**Checkpoint** : les deux user stories sont fonctionnelles indépendamment. | |
| 99 | + | |
| 100 | +--- | |
| 101 | + | |
| 102 | +## Phase 5: Polish & Cross-Cutting Concerns | |
| 103 | + | |
| 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) | |
| 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 | |
| 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`) | |
| 107 | + | |
| 108 | +--- | |
| 109 | + | |
| 110 | +## Dependencies & Execution Order | |
| 111 | + | |
| 112 | +### Phase Dependencies | |
| 113 | + | |
| 114 | +- **Setup (Phase 1)** : aucune dépendance, tâches `[P]` en parallèle. | |
| 115 | +- **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. | |
| 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. | |
| 118 | +- **Polish (Phase 5)** : dépend des user stories livrées (au minimum US1). | |
| 119 | + | |
| 120 | +### Au sein de chaque user story | |
| 121 | + | |
| 122 | +CLI avant GUI, toujours (Principe CLI-first — cf. contrainte structurante en tête de fichier) : T016 avant T017/T018 ; T025 avant T026. | |
| 123 | + | |
| 124 | +## Implementation Strategy | |
| 125 | + | |
| 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é. | |
| new file mode 100644 | |||
| @@ -0,0 +1,126 @@ | |||
| 1 | +--- | ||
| 2 | + | ||
| 3 | +description: "Task list for feature implementation" | ||
| 4 | +--- | ||
| 5 | + | ||
| 6 | +# Tasks: Interface graphique de tri et de consultation | ||
| 7 | + | ||
| 8 | +**Input**: Design documents from `/specs/006-gui-tri-consultation/` | ||
| 9 | +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md | ||
| 10 | + | ||
| 11 | +**Tests**: Incluses — `plan.md` § Project Structure nomme explicitement les fichiers de test attendus, ce qui vaut demande explicite au sens des règles de génération. | ||
| 12 | + | ||
| 13 | +**Organisation**: Tâches groupées par user story (spec.md) pour une implémentation et une validation indépendantes de chacune. | ||
| 14 | + | ||
| 15 | +## Contrainte structurante à respecter tout au long de l'implémentation | ||
| 16 | + | ||
| 17 | +**Principe CLI-first (constitution)** : pour chaque user story, les tâches CLI (`regine dossier ...`, `regine archive browse/restore`) DOIVENT être implémentées et testées avant les écrans GUI équivalents — jamais l'inverse. L'ordre des tâches ci-dessous respecte déjà cette contrainte ; ne pas le réorganiser à l'implémentation. | ||
| 18 | + | ||
| 19 | +## Dépendances inter-specs (à lire avant de commencer) | ||
| 20 | + | ||
| 21 | +| Dépendance | Fournit | État | | ||
| 22 | +|---|---|---| | ||
| 23 | +| `specs/001-import-photos` | `regine_core.import_carte.copie.EXTENSIONS_MAITRES` (réutilisée par `selection.dossier_de_format`, research.md § 4) | Implémentée | | ||
| 24 | +| `specs/003-config-contexte-travail` | `regine_core.config.context`/`smb` (contexte de travail, disponibilité et point de montage SMB, réutilisés par `regine archive browse/restore`) | Implémentée | | ||
| 25 | +| `specs/004-categorisation-dossiers` | `regine_core.dossier.root` — sémantique différente de `regine_core.dossier.selection` (cette spec), cf. research.md § 3, aucune modification requise | Implémentée | | ||
| 26 | +| `specs/005-checkout-reconciliation` | `regine_core.archive.manifest`/`verrou`, et `comparer()` qui détecte nativement un déplacement de promotion par hash (aucun code dédié requis ici) | Implémentée | | ||
| 27 | + | ||
| 28 | +## Phase 1: Setup | ||
| 29 | + | ||
| 30 | +- [X] T001 [P] Ajouter la dépendance PySide6 à `packages/regine-gui/pyproject.toml` (cf. research.md § 1) — `uv add pyside6` (6.11.2), description du paquet mise à jour (n'est plus « réservé ») | ||
| 31 | +- [X] T002 [P] Créer `packages/regine-core/src/regine_core/dossier/selection.py` (squelette, docstring de module uniquement — distinct de `root.py`, cf. research.md § 3) | ||
| 32 | +- [X] T003 [P] Créer `packages/regine-core/src/regine_core/archive/consultation.py` (squelette, docstring de module uniquement) | ||
| 33 | +- [X] T004 [P] Créer `packages/regine-cli/src/regine_cli/dossier_cmd.py` (squelette, docstring de module uniquement) | ||
| 34 | +- [X] T005 [P] Créer `packages/regine-gui/src/regine_gui/app.py` (squelette, point d'entrée PySide6) | ||
| 35 | +- [X] T006 [P] Vérifier que PySide6 s'installe et s'importe sur le poste de développement (`uv sync`, `python -c "import PySide6"`) — installé et importable (`from PySide6.QtWidgets import QApplication` fonctionne), environnement graphique macOS disponible | ||
| 36 | + | ||
| 37 | +--- | ||
| 38 | + | ||
| 39 | +## Phase 2: Foundational (Blocking Prerequisites) | ||
| 40 | + | ||
| 41 | +**Objectif** : la correspondance extension → dossier de format, prérequis de toute la logique de `selection.py` (US1). | ||
| 42 | + | ||
| 43 | +**⚠️ CRITIQUE** : User Story 1 ne peut pas commencer avant la fin de cette phase. User Story 2 en est indépendante (cf. Independent Test de spec.md) et peut être menée en parallèle. | ||
| 44 | + | ||
| 45 | +- [X] T007 Implémenter `dossier_de_format(extension: str) -> str | None` dans `selection.py`, en réutilisant `regine_core.import_carte.copie.EXTENSIONS_MAITRES` (research.md § 4) — jamais une nouvelle liste d'extensions dupliquée | ||
| 46 | +- [X] T008 [P] Test unitaire de `dossier_de_format` dans `packages/regine-core/tests/unit/test_selection_grouping.py` : extensions RAW → `raw`, `tiff`/`tif` → `tiff`, `jpg`/`jpeg` → `jpeg`, extension associée (sidecar) ou inconnue → `None`, format maître marginal (`bmp`) → son propre nom de dossier | ||
| 47 | + | ||
| 48 | +**Checkpoint** : la correspondance de format est prête ; User Story 1 peut commencer. | ||
| 49 | + | ||
| 50 | +--- | ||
| 51 | + | ||
| 52 | +## Phase 3: User Story 1 - Trier une sortie photo fraîchement importée (Priority: P1) 🎯 MVP | ||
| 53 | + | ||
| 54 | +**Objectif** : regroupement visuel RAW+JPEG, promotion/rétrogradation individuelle à la racine de sélection, vue combinant plusieurs dossiers de format et sous-dossiers — d'abord en CLI (Principe CLI-first), puis dans la GUI. | ||
| 55 | + | ||
| 56 | +**Test indépendant** : ouvrir un dossier local avec des paires RAW+JPEG via `regine dossier list`, promouvoir un fichier, vérifier sur le système de fichiers. | ||
| 57 | + | ||
| 58 | +### Tests for User Story 1 | ||
| 59 | + | ||
| 60 | +- [X] T009 [P] [US1] Test unitaire de `regrouper()` dans `packages/regine-core/tests/unit/test_selection_grouping.py` : paire RAW+JPEG regroupée en une entrée, RAW seul, JPEG seul (mode JPEG seul), dossier sans structure reconnue → erreur dédiée (FR-013) — étendu pour couvrir un scan TIFF isolé (généralisation `EntreeRegroupee.fichiers`, cf. data-model.md) | ||
| 61 | +- [X] T010 [P] [US1] Test unitaire de `promouvoir()`/`retrograder()` dans `packages/regine-core/tests/unit/test_selection_promotion.py` : promotion idempotente (déjà à la racine → no-op), rétrogradation refuse si le fichier n'est pas à la racine, hash de contenu inchangé avant/après (Principe I/III) — **écart de conception trouvé par ce test** : la promotion « en deux temps » (étape puis dossier parent) ne peut pas être devinée à partir du seul chemin du fichier ; `promouvoir` a été corrigée pour prendre `dossier` en argument explicite, cf. `contracts/regine-core-api.md` | ||
| 62 | +- [X] T011 [P] [US1] Test d'intégration de la vue combinée (dossier parent + sous-dossiers) dans `packages/regine-core/tests/integration/test_selection_combined_view.py` — `regrouper` étant récursif (`rglob`), la vue du dossier parent inclut nativement les fichiers de ses étapes (planche-contact globale, section 10) ; aucune logique combinée dédiée nécessaire côté `regine-core` | ||
| 63 | +- [X] T012 [P] [US1] Test de contrat CLI dans `packages/regine-cli/tests/contract/test_cli_dossier.py` : `regine dossier list/promote/demote`, codes de sortie, cf. `contracts/cli-dossier-selection.md` — 7/7 tests passent | ||
| 64 | +- [X] T013 [US1] Test d'intégration bout-en-bout : une promotion via `regine dossier promote` est ensuite classée `deplacement` (et non `anomalie`) par `regine_core.archive.reconciliation.comparer` au moment de la réconciliation — preuve qu'aucun code dédié n'est nécessaire côté specs/005 (Principe III), dans `packages/regine-core/tests/integration/test_selection_reconciliation.py`, passe du premier coup | ||
| 65 | + | ||
| 66 | +### Implementation for User Story 1 | ||
| 67 | + | ||
| 68 | +- [X] T014 [US1] Implémenter `regrouper(dossier: Path) -> list[EntreeRegroupee]` dans `selection.py` | ||
| 69 | +- [X] T015 [US1] Implémenter `promouvoir(dossier: Path, fichier: Path) -> Path` / `retrograder(fichier: Path) -> Path` dans `selection.py` — signature de `promouvoir` corrigée par rapport au contrat initial (cf. T010) | ||
| 70 | +- [X] T016 [US1] Implémenter `regine dossier list/promote/demote` dans `dossier_cmd.py` (cf. `contracts/cli-dossier-selection.md`) — **CLI-first : cette tâche précède T017/T018, jamais l'inverse** | ||
| 71 | +- [X] T017 [US1] Implémenter l'écran de tri (`tri_view.py`) dans `regine-gui` : appelle exclusivement `regine_core.dossier.selection`, aucune logique de regroupement/promotion propre à la GUI (Principe VI) — validé manuellement en mode headless (`QT_QPA_PLATFORM=offscreen`) : ouverture d'un dossier, regroupement, promotion via l'écran, fichier réellement déplacé sur le système de fichiers | ||
| 72 | +- [X] T018 [US1] Implémenter la vue combinée (dossier parent + sous-dossiers) dans `tri_view.py` — **aucun code combiné dédié nécessaire** : `regrouper()` étant récursif (T011), ouvrir un dossier parent affiche déjà ses fichiers et ceux de ses sous-dossiers dans la même liste. Décision de portée documentée : la promotion depuis cet écran cible toujours le dossier explicitement ouvert (`self._dossier`) — pour la promotion en deux temps d'un voyage, l'utilisateur ouvre l'étape puis, séparément, le dossier parent (deux gestes explicites distincts, cf. docstring de `tri_view.py`), plutôt qu'un raccourci automatique | ||
| 73 | + | ||
| 74 | +**Checkpoint** : User Story 1 fonctionnelle et testable indépendamment (MVP), CLI et GUI cohérentes entre elles. | ||
| 75 | + | ||
| 76 | +--- | ||
| 77 | + | ||
| 78 | +## Phase 4: User Story 2 - Retrouver une photo dans l'archive sans tout rapatrier (Priority: P2) | ||
| 79 | + | ||
| 80 | +**Objectif** : consultation en lecture seule de l'archive avec recherche, restauration ciblée d'un fichier sans checkout complet — d'abord en CLI, puis dans la GUI. Indépendante de User Story 1 (aucune dépendance sur `selection.py`). | ||
| 81 | + | ||
| 82 | +**Test indépendant** : `regine archive browse --search ...` puis `regine archive restore` vers un dossier local vide, sans checkout préalable. | ||
| 83 | + | ||
| 84 | +### Tests for User Story 2 | ||
| 85 | + | ||
| 86 | +- [X] T019 [P] [US2] Test unitaire de `rechercher()` dans `packages/regine-core/tests/unit/test_consultation_search.py` : filtre par nom de dossier/date/texte, aucun résultat → liste vide (pas une erreur) | ||
| 87 | +- [X] T020 [P] [US2] Test unitaire de `restaurer()` dans `packages/regine-core/tests/unit/test_consultation_restore.py` : copie vérifiée par somme de contrôle, refuse d'écraser un fichier local de contenu différent (FR-012), lève si la source est introuvable dans l'archive | ||
| 88 | +- [X] T021 [P] [US2] Test de contrat CLI dans `packages/regine-cli/tests/contract/test_cli_archive_consultation.py` : `regine browse`/`regine restore` (renommé, cf. écart documenté dans `contracts/cli-archive-consultation.md`), y compris le cas aucune archive configurée — 3/3 tests passent | ||
| 89 | +- [X] T022 [US2] Test d'intégration : une restauration ciblée ne pose ni ne modifie aucun verrou du dossier d'origine (`regine_core.archive.verrou.verifier` reste inchangé avant/après), dans `packages/regine-core/tests/integration/test_consultation_restore_verrou.py` (renommé : collision de nom de module avec le test unitaire T020, pytest exige des noms de fichiers de test uniques sans `__init__.py`) | ||
| 90 | + | ||
| 91 | +### Implementation for User Story 2 | ||
| 92 | + | ||
| 93 | +- [X] T023 [US2] Implémenter `rechercher(archive_root_local: Path, requete: str | None) -> list[ResultatRecherche]` dans `consultation.py` | ||
| 94 | +- [X] T024 [US2] Implémenter `restaurer(chemin_source: Path, chemin_destination: Path) -> None` dans `consultation.py` — réutilise `regine_core.archive.checkout.IntegriteCopieError` plutôt qu'une exception dupliquée | ||
| 95 | +- [X] T025 [US2] Étendre `packages/regine-cli/src/regine_cli/archive_cmd.py` avec `regine browse`/`regine restore` (charge le contexte, `smb.assurer_disponibilite` avant tout parcours, cf. `contracts/cli-archive-consultation.md`) — **écart trouvé à l'implémentation** : `checkout`/`reconcile` sont déjà des sous-commandes de premier niveau (pas `regine archive checkout`), `browse`/`restore` suivent la même convention plutôt que d'introduire un niveau `archive` inédit — **CLI-first : précède T026** | ||
| 96 | +- [X] T026 [US2] Implémenter l'écran de consultation (`consultation_view.py`) dans `regine-gui` : lecture seule stricte, aucune action d'écriture proposée (FR-010) — **bug réel trouvé par test manuel headless** : la résolution du point de montage de l'archive était dupliquée entre `archive_cmd.py` et `consultation_view.py` avec un style d'import différent, rendant l'une des deux résolutions impossible à patcher en test et incohérente avec l'autre (violation du Principe VI). Corrigé en extrayant `regine_core.config.smb.resoudre_archive_locale(conn)`, désormais seule source de vérité partagée par la CLI et la GUI ; validé de bout en bout en mode headless (`QT_QPA_PLATFORM=offscreen`) : recherche puis restauration réelles | ||
| 97 | + | ||
| 98 | +**Checkpoint** : les deux user stories sont fonctionnelles indépendamment. | ||
| 99 | + | ||
| 100 | +--- | ||
| 101 | + | ||
| 102 | +## Phase 5: Polish & Cross-Cutting Concerns | ||
| 103 | + | ||
| 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) | ||
| 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 | ||
| 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`) | ||
| 107 | + | ||
| 108 | +--- | ||
| 109 | + | ||
| 110 | +## Dependencies & Execution Order | ||
| 111 | + | ||
| 112 | +### Phase Dependencies | ||
| 113 | + | ||
| 114 | +- **Setup (Phase 1)** : aucune dépendance, tâches `[P]` en parallèle. | ||
| 115 | +- **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. | ||
| 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. | ||
| 118 | +- **Polish (Phase 5)** : dépend des user stories livrées (au minimum US1). | ||
| 119 | + | ||
| 120 | +### Au sein de chaque user story | ||
| 121 | + | ||
| 122 | +CLI avant GUI, toujours (Principe CLI-first — cf. contrainte structurante en tête de fichier) : T016 avant T017/T018 ; T025 avant T026. | ||
| 123 | + | ||
| 124 | +## Implementation Strategy | ||
| 125 | + | ||
| 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é. | ||
modified
uv.lock +65 -1 | @@ -61,6 +61,54 @@ wheels = [ | ||
| 61 | 61 | { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, |
| 62 | 62 | ] |
| 63 | 63 | |
| 64 | +[[package]] | |
| 65 | +name = "pyside6" | |
| 66 | +version = "6.11.2" | |
| 67 | +source = { registry = "https://pypi.org/simple" } | |
| 68 | +dependencies = [ | |
| 69 | + { name = "pyside6-addons" }, | |
| 70 | + { name = "pyside6-essentials" }, | |
| 71 | + { name = "shiboken6" }, | |
| 72 | +] | |
| 73 | +wheels = [ | |
| 74 | + { url = "https://files.pythonhosted.org/packages/ae/b5/99ff75f604d69eda1c5d1fe85cc43fb697d95eabc30ba86af099c2ddcd96/pyside6-6.11.2-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:13a3c79816879d9743672a669f1988f74459a78f89a97a2624fae4fe059ffa3a", size = 571875, upload-time = "2026-08-18T06:37:27.794Z" }, | |
| 75 | + { url = "https://files.pythonhosted.org/packages/b2/fe/968844def58150a41568b505edca0d68df6901b20ea4222ccb0c069cc703/pyside6-6.11.2-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:dc6d03990489a5085842770718392ef5de36352d8f608d9a9fe03e60af4d7b66", size = 572120, upload-time = "2026-08-18T06:37:30.298Z" }, | |
| 76 | + { url = "https://files.pythonhosted.org/packages/5e/5c/f4ecc6defaec645e5ac3ca2f99e3da286a28d1c647c2d10b584e0392b94c/pyside6-6.11.2-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:57fe867a6c93821a085d74e8a26f863fc2363516848181c466032595b33b1729", size = 572121, upload-time = "2026-08-18T06:37:32.383Z" }, | |
| 77 | + { url = "https://files.pythonhosted.org/packages/94/fe/4aea9d30300fff4cda9bb729c1363b9e1095a8544e0d6b57065465e16599/pyside6-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:3201d67e3c10be2eaedd3910ff0f02351eca7e88c95a291cde5e7f2f55ef207f", size = 578382, upload-time = "2026-08-18T06:37:34.385Z" }, | |
| 78 | + { url = "https://files.pythonhosted.org/packages/26/b0/8e2fb01e9ee15ad29fe10d0dd2d37f503e4aa3714c9fbb07aca380618de7/pyside6-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:0444ac71d0791a19bded35f6d9a94515941f23a6eb7098ca45a1b64a338be697", size = 561768, upload-time = "2026-08-18T06:37:36.437Z" }, | |
| 79 | +] | |
| 80 | + | |
| 81 | +[[package]] | |
| 82 | +name = "pyside6-addons" | |
| 83 | +version = "6.11.2" | |
| 84 | +source = { registry = "https://pypi.org/simple" } | |
| 85 | +dependencies = [ | |
| 86 | + { name = "pyside6-essentials" }, | |
| 87 | + { name = "shiboken6" }, | |
| 88 | +] | |
| 89 | +wheels = [ | |
| 90 | + { url = "https://files.pythonhosted.org/packages/e3/91/339fe9f7c94163815004ccd6b5c58b926b2dfb4e51c3b5563d412eab151f/pyside6_addons-6.11.2-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:354c574a839f7d0751960ba03f33bec87c95e0f2796dca3287f54d03abe97dac", size = 332047075, upload-time = "2026-08-18T06:35:33.037Z" }, | |
| 91 | + { url = "https://files.pythonhosted.org/packages/28/cd/ce470e20f68901f4dfa5f742d6a3a58ba865684a232e4b87d5a1a2179cc0/pyside6_addons-6.11.2-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a8b00956925fbdeffc7052cf933506391289f35ac4f3f71a0a167ec195cd47b4", size = 175062576, upload-time = "2026-08-18T06:35:45.583Z" }, | |
| 92 | + { url = "https://files.pythonhosted.org/packages/11/bd/f725a8984703612c72cd17d0871d047132bacaccc13fc0d288a5e8a37dad/pyside6_addons-6.11.2-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a2ca3c73e5f060d4aca49451abd9fba68dd0e1c66ff40cea3a1fe0415def579a", size = 170827254, upload-time = "2026-08-18T06:35:56.675Z" }, | |
| 93 | + { url = "https://files.pythonhosted.org/packages/fb/c1/08bf6594101b42fc88834009f1dac59a983c5b6805c62c5081f077d09b4a/pyside6_addons-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:f449ea4431da20e7b86752cca8d166f93434516fe417f981c27e5f8e1b554407", size = 168208836, upload-time = "2026-08-18T06:36:07.295Z" }, | |
| 94 | + { url = "https://files.pythonhosted.org/packages/26/60/e4b7f09343e9f29e565f488fb44c4f1fe260db481ba2c08ad8a1a7d48216/pyside6_addons-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:d5606af369484b862b4d5741cfc179e19d2b8819c3e04d210b8188e0c9f85988", size = 35807245, upload-time = "2026-08-18T06:36:12.485Z" }, | |
| 95 | +] | |
| 96 | + | |
| 97 | +[[package]] | |
| 98 | +name = "pyside6-essentials" | |
| 99 | +version = "6.11.2" | |
| 100 | +source = { registry = "https://pypi.org/simple" } | |
| 101 | +dependencies = [ | |
| 102 | + { name = "shiboken6" }, | |
| 103 | +] | |
| 104 | +wheels = [ | |
| 105 | + { url = "https://files.pythonhosted.org/packages/6f/16/0b7ecf89ebada82ed0430be33809ff325761ece83104f8635b1bd101fcd0/pyside6_essentials-6.11.2-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:77795c145202e65a78d88f7cd409d186e3ba23d159bdb3ba2dcd159ae5e5f0d9", size = 110800753, upload-time = "2026-08-18T06:36:33.674Z" }, | |
| 106 | + { url = "https://files.pythonhosted.org/packages/0e/1a/84a84bbc75835ee88abdcb66eb875b8e1a21834af9b833bc1893f2c1bfaf/pyside6_essentials-6.11.2-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:aaf9f25f0f324874085fa5b26a610318db8a8e243cf85bb3e5400595191c7778", size = 80108947, upload-time = "2026-08-18T06:36:40.637Z" }, | |
| 107 | + { url = "https://files.pythonhosted.org/packages/60/4c/d92b08a2151d095b9a3725fa0e71b215ec686d40bbd5d8b702d21a9b5eaa/pyside6_essentials-6.11.2-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:d3ec6e1885c46e57f16364f38ea8040ffc5dc0b18341058f608fede3ba930567", size = 79575497, upload-time = "2026-08-18T06:36:46.97Z" }, | |
| 108 | + { url = "https://files.pythonhosted.org/packages/dc/09/d2662684aee4d3f027ff9b1e965f22301bc28ffc5b7f167658508e4475dc/pyside6_essentials-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:c8a29def77032773a30879f7f24415b5395ad08592d147c170824ef4c735dfc1", size = 76913043, upload-time = "2026-08-18T06:36:54.038Z" }, | |
| 109 | + { url = "https://files.pythonhosted.org/packages/cd/87/4dd2a6468cea600da1fcf9c7f02bc248eeaa98cdc75658e48e669d95326a/pyside6_essentials-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:fadd75c5c20800d64dd0a586ea8cb337c5e630aa2246df0e5105738afa46e02c", size = 58125520, upload-time = "2026-08-18T06:36:59.073Z" }, | |
| 110 | +] | |
| 111 | + | |
| 64 | 112 | [[package]] |
| 65 | 113 | name = "pytest" |
| 66 | 114 | version = "9.1.1" |
| @@ -109,11 +157,15 @@ name = "regine-gui" | ||
| 109 | 157 | version = "0.1.0" |
| 110 | 158 | source = { editable = "packages/regine-gui" } |
| 111 | 159 | dependencies = [ |
| 160 | + { name = "pyside6" }, | |
| 112 | 161 | { name = "regine-core" }, |
| 113 | 162 | ] |
| 114 | 163 | |
| 115 | 164 | [package.metadata] |
| 116 | -requires-dist = [{ name = "regine-core", editable = "packages/regine-core" }] | |
| 165 | +requires-dist = [ | |
| 166 | + { name = "pyside6", specifier = ">=6.11.2" }, | |
| 167 | + { name = "regine-core", editable = "packages/regine-core" }, | |
| 168 | +] | |
| 117 | 169 | |
| 118 | 170 | [[package]] |
| 119 | 171 | name = "ruff" |
| @@ -139,3 +191,15 @@ wheels = [ | ||
| 139 | 191 | { url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" }, |
| 140 | 192 | { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" }, |
| 141 | 193 | ] |
| 194 | + | |
| 195 | +[[package]] | |
| 196 | +name = "shiboken6" | |
| 197 | +version = "6.11.2" | |
| 198 | +source = { registry = "https://pypi.org/simple" } | |
| 199 | +wheels = [ | |
| 200 | + { url = "https://files.pythonhosted.org/packages/47/44/11bf71c36e71936ab4e923e5dd23599dab527c4488b01860b0f689ce9947/shiboken6-6.11.2-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:53659683b1f7a08e9f87eff9b1065f1ceb7110cd7a4bc09fdf5efe43d286604d", size = 476674, upload-time = "2026-08-18T06:37:11.526Z" }, | |
| 201 | + { url = "https://files.pythonhosted.org/packages/a5/9a/f41bea4316d0c1bec4800842dcb4bd8fd3e29d6a79c496bc32e1aec42583/shiboken6-6.11.2-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7a7a0a72a9ed26c9bf77d42246b1c736486befb8f31aa2fb29957ea4cdd1c1c2", size = 272435, upload-time = "2026-08-18T06:37:13.397Z" }, | |
| 202 | + { url = "https://files.pythonhosted.org/packages/56/05/79ed0697515d4fc7435459e4ed4c4a3b5621a55b7c91d0f3f2cc3d617aa0/shiboken6-6.11.2-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:4bbbd6fa4d7cff5ec5e12bc4c10e1d845fa30c89457ecb13cc64f7bddb77f6f9", size = 270668, upload-time = "2026-08-18T06:37:15.025Z" }, | |
| 203 | + { url = "https://files.pythonhosted.org/packages/ce/6b/fee1b4814317e4664e318644c9c007676c65a71e5b2b9d3ac4121a94dc0a/shiboken6-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:6ab0eba1c904455df621f9a6df3ca2bb896bab8670572d2bc4e37804ae91f19a", size = 1226578, upload-time = "2026-08-18T06:37:18.002Z" }, | |
| 204 | + { url = "https://files.pythonhosted.org/packages/4d/b5/80c4e5aafeb8ac777cf5a1becfe87ac7197c35051df00b5f7819787e09d5/shiboken6-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:97c49432488df958f0308736f3d15b6915d8826fc380af17bb4b9ec5c4a5e81b", size = 1784669, upload-time = "2026-08-18T06:37:21.046Z" }, | |
| 205 | +] | |
| @@ -61,6 +61,54 @@ wheels = [ | |||
| 61 | { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, | 61 | { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, |
| 62 | ] | 62 | ] |
| 63 | 63 | ||
| 64 | +[[package]] | ||
| 65 | +name = "pyside6" | ||
| 66 | +version = "6.11.2" | ||
| 67 | +source = { registry = "https://pypi.org/simple" } | ||
| 68 | +dependencies = [ | ||
| 69 | + { name = "pyside6-addons" }, | ||
| 70 | + { name = "pyside6-essentials" }, | ||
| 71 | + { name = "shiboken6" }, | ||
| 72 | +] | ||
| 73 | +wheels = [ | ||
| 74 | + { url = "https://files.pythonhosted.org/packages/ae/b5/99ff75f604d69eda1c5d1fe85cc43fb697d95eabc30ba86af099c2ddcd96/pyside6-6.11.2-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:13a3c79816879d9743672a669f1988f74459a78f89a97a2624fae4fe059ffa3a", size = 571875, upload-time = "2026-08-18T06:37:27.794Z" }, | ||
| 75 | + { url = "https://files.pythonhosted.org/packages/b2/fe/968844def58150a41568b505edca0d68df6901b20ea4222ccb0c069cc703/pyside6-6.11.2-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:dc6d03990489a5085842770718392ef5de36352d8f608d9a9fe03e60af4d7b66", size = 572120, upload-time = "2026-08-18T06:37:30.298Z" }, | ||
| 76 | + { url = "https://files.pythonhosted.org/packages/5e/5c/f4ecc6defaec645e5ac3ca2f99e3da286a28d1c647c2d10b584e0392b94c/pyside6-6.11.2-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:57fe867a6c93821a085d74e8a26f863fc2363516848181c466032595b33b1729", size = 572121, upload-time = "2026-08-18T06:37:32.383Z" }, | ||
| 77 | + { url = "https://files.pythonhosted.org/packages/94/fe/4aea9d30300fff4cda9bb729c1363b9e1095a8544e0d6b57065465e16599/pyside6-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:3201d67e3c10be2eaedd3910ff0f02351eca7e88c95a291cde5e7f2f55ef207f", size = 578382, upload-time = "2026-08-18T06:37:34.385Z" }, | ||
| 78 | + { url = "https://files.pythonhosted.org/packages/26/b0/8e2fb01e9ee15ad29fe10d0dd2d37f503e4aa3714c9fbb07aca380618de7/pyside6-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:0444ac71d0791a19bded35f6d9a94515941f23a6eb7098ca45a1b64a338be697", size = 561768, upload-time = "2026-08-18T06:37:36.437Z" }, | ||
| 79 | +] | ||
| 80 | + | ||
| 81 | +[[package]] | ||
| 82 | +name = "pyside6-addons" | ||
| 83 | +version = "6.11.2" | ||
| 84 | +source = { registry = "https://pypi.org/simple" } | ||
| 85 | +dependencies = [ | ||
| 86 | + { name = "pyside6-essentials" }, | ||
| 87 | + { name = "shiboken6" }, | ||
| 88 | +] | ||
| 89 | +wheels = [ | ||
| 90 | + { url = "https://files.pythonhosted.org/packages/e3/91/339fe9f7c94163815004ccd6b5c58b926b2dfb4e51c3b5563d412eab151f/pyside6_addons-6.11.2-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:354c574a839f7d0751960ba03f33bec87c95e0f2796dca3287f54d03abe97dac", size = 332047075, upload-time = "2026-08-18T06:35:33.037Z" }, | ||
| 91 | + { url = "https://files.pythonhosted.org/packages/28/cd/ce470e20f68901f4dfa5f742d6a3a58ba865684a232e4b87d5a1a2179cc0/pyside6_addons-6.11.2-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a8b00956925fbdeffc7052cf933506391289f35ac4f3f71a0a167ec195cd47b4", size = 175062576, upload-time = "2026-08-18T06:35:45.583Z" }, | ||
| 92 | + { url = "https://files.pythonhosted.org/packages/11/bd/f725a8984703612c72cd17d0871d047132bacaccc13fc0d288a5e8a37dad/pyside6_addons-6.11.2-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a2ca3c73e5f060d4aca49451abd9fba68dd0e1c66ff40cea3a1fe0415def579a", size = 170827254, upload-time = "2026-08-18T06:35:56.675Z" }, | ||
| 93 | + { url = "https://files.pythonhosted.org/packages/fb/c1/08bf6594101b42fc88834009f1dac59a983c5b6805c62c5081f077d09b4a/pyside6_addons-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:f449ea4431da20e7b86752cca8d166f93434516fe417f981c27e5f8e1b554407", size = 168208836, upload-time = "2026-08-18T06:36:07.295Z" }, | ||
| 94 | + { url = "https://files.pythonhosted.org/packages/26/60/e4b7f09343e9f29e565f488fb44c4f1fe260db481ba2c08ad8a1a7d48216/pyside6_addons-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:d5606af369484b862b4d5741cfc179e19d2b8819c3e04d210b8188e0c9f85988", size = 35807245, upload-time = "2026-08-18T06:36:12.485Z" }, | ||
| 95 | +] | ||
| 96 | + | ||
| 97 | +[[package]] | ||
| 98 | +name = "pyside6-essentials" | ||
| 99 | +version = "6.11.2" | ||
| 100 | +source = { registry = "https://pypi.org/simple" } | ||
| 101 | +dependencies = [ | ||
| 102 | + { name = "shiboken6" }, | ||
| 103 | +] | ||
| 104 | +wheels = [ | ||
| 105 | + { url = "https://files.pythonhosted.org/packages/6f/16/0b7ecf89ebada82ed0430be33809ff325761ece83104f8635b1bd101fcd0/pyside6_essentials-6.11.2-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:77795c145202e65a78d88f7cd409d186e3ba23d159bdb3ba2dcd159ae5e5f0d9", size = 110800753, upload-time = "2026-08-18T06:36:33.674Z" }, | ||
| 106 | + { url = "https://files.pythonhosted.org/packages/0e/1a/84a84bbc75835ee88abdcb66eb875b8e1a21834af9b833bc1893f2c1bfaf/pyside6_essentials-6.11.2-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:aaf9f25f0f324874085fa5b26a610318db8a8e243cf85bb3e5400595191c7778", size = 80108947, upload-time = "2026-08-18T06:36:40.637Z" }, | ||
| 107 | + { url = "https://files.pythonhosted.org/packages/60/4c/d92b08a2151d095b9a3725fa0e71b215ec686d40bbd5d8b702d21a9b5eaa/pyside6_essentials-6.11.2-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:d3ec6e1885c46e57f16364f38ea8040ffc5dc0b18341058f608fede3ba930567", size = 79575497, upload-time = "2026-08-18T06:36:46.97Z" }, | ||
| 108 | + { url = "https://files.pythonhosted.org/packages/dc/09/d2662684aee4d3f027ff9b1e965f22301bc28ffc5b7f167658508e4475dc/pyside6_essentials-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:c8a29def77032773a30879f7f24415b5395ad08592d147c170824ef4c735dfc1", size = 76913043, upload-time = "2026-08-18T06:36:54.038Z" }, | ||
| 109 | + { url = "https://files.pythonhosted.org/packages/cd/87/4dd2a6468cea600da1fcf9c7f02bc248eeaa98cdc75658e48e669d95326a/pyside6_essentials-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:fadd75c5c20800d64dd0a586ea8cb337c5e630aa2246df0e5105738afa46e02c", size = 58125520, upload-time = "2026-08-18T06:36:59.073Z" }, | ||
| 110 | +] | ||
| 111 | + | ||
| 64 | [[package]] | 112 | [[package]] |
| 65 | name = "pytest" | 113 | name = "pytest" |
| 66 | version = "9.1.1" | 114 | version = "9.1.1" |
| @@ -109,11 +157,15 @@ name = "regine-gui" | |||
| 109 | version = "0.1.0" | 157 | version = "0.1.0" |
| 110 | source = { editable = "packages/regine-gui" } | 158 | source = { editable = "packages/regine-gui" } |
| 111 | dependencies = [ | 159 | dependencies = [ |
| 160 | + { name = "pyside6" }, | ||
| 112 | { name = "regine-core" }, | 161 | { name = "regine-core" }, |
| 113 | ] | 162 | ] |
| 114 | 163 | ||
| 115 | [package.metadata] | 164 | [package.metadata] |
| 116 | -requires-dist = [{ name = "regine-core", editable = "packages/regine-core" }] | 165 | +requires-dist = [ |
| 166 | + { name = "pyside6", specifier = ">=6.11.2" }, | ||
| 167 | + { name = "regine-core", editable = "packages/regine-core" }, | ||
| 168 | +] | ||
| 117 | 169 | ||
| 118 | [[package]] | 170 | [[package]] |
| 119 | name = "ruff" | 171 | name = "ruff" |
| @@ -139,3 +191,15 @@ wheels = [ | |||
| 139 | { url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" }, | 191 | { url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" }, |
| 140 | { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" }, | 192 | { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" }, |
| 141 | ] | 193 | ] |
| 194 | + | ||
| 195 | +[[package]] | ||
| 196 | +name = "shiboken6" | ||
| 197 | +version = "6.11.2" | ||
| 198 | +source = { registry = "https://pypi.org/simple" } | ||
| 199 | +wheels = [ | ||
| 200 | + { url = "https://files.pythonhosted.org/packages/47/44/11bf71c36e71936ab4e923e5dd23599dab527c4488b01860b0f689ce9947/shiboken6-6.11.2-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:53659683b1f7a08e9f87eff9b1065f1ceb7110cd7a4bc09fdf5efe43d286604d", size = 476674, upload-time = "2026-08-18T06:37:11.526Z" }, | ||
| 201 | + { url = "https://files.pythonhosted.org/packages/a5/9a/f41bea4316d0c1bec4800842dcb4bd8fd3e29d6a79c496bc32e1aec42583/shiboken6-6.11.2-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7a7a0a72a9ed26c9bf77d42246b1c736486befb8f31aa2fb29957ea4cdd1c1c2", size = 272435, upload-time = "2026-08-18T06:37:13.397Z" }, | ||
| 202 | + { url = "https://files.pythonhosted.org/packages/56/05/79ed0697515d4fc7435459e4ed4c4a3b5621a55b7c91d0f3f2cc3d617aa0/shiboken6-6.11.2-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:4bbbd6fa4d7cff5ec5e12bc4c10e1d845fa30c89457ecb13cc64f7bddb77f6f9", size = 270668, upload-time = "2026-08-18T06:37:15.025Z" }, | ||
| 203 | + { url = "https://files.pythonhosted.org/packages/ce/6b/fee1b4814317e4664e318644c9c007676c65a71e5b2b9d3ac4121a94dc0a/shiboken6-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:6ab0eba1c904455df621f9a6df3ca2bb896bab8670572d2bc4e37804ae91f19a", size = 1226578, upload-time = "2026-08-18T06:37:18.002Z" }, | ||
| 204 | + { url = "https://files.pythonhosted.org/packages/4d/b5/80c4e5aafeb8ac777cf5a1becfe87ac7197c35051df00b5f7819787e09d5/shiboken6-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:97c49432488df958f0308736f3d15b6915d8826fc380af17bb4b9ec5c4a5e81b", size = 1784669, upload-time = "2026-08-18T06:37:21.046Z" }, | ||
| 205 | +] | ||