publie une copie locale apres import
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
6a31918 parent: b8be229 modified
README.md +11 -6 | @@ -34,15 +34,16 @@ flowchart LR | ||
| 34 | 34 | |
| 35 | 35 | subgraph LOCAL["💻 Espace de travail local"] |
| 36 | 36 | direction TB |
| 37 | - OUT["Checkout<br/>specs/005"] | |
| 37 | + OUT["Copie de travail<br/>(laissée par l'import, ou par un checkout — specs/005)"] | |
| 38 | 38 | EDIT["Tri & retouche<br/>(DxO, Lightroom, votre outil…)"] |
| 39 | 39 | RECON["Réconciliation<br/>par hash, avant tout réarchivage"] |
| 40 | 40 | OUT --> EDIT --> RECON |
| 41 | 41 | end |
| 42 | 42 | |
| 43 | 43 | CARD --> COPY |
| 44 | - NAME -->|premier archivage| DOSSIER | |
| 45 | - DOSSIER -->|sort une copie de travail| OUT | |
| 44 | + NAME -->|archivage vérifié| DOSSIER | |
| 45 | + NAME -.->|copie locale laissée immédiatement| OUT | |
| 46 | + DOSSIER -->|checkout, à tout moment| OUT | |
| 46 | 47 | RECON -->|réarchivage vérifié, jamais silencieux| DOSSIER |
| 47 | 48 | ``` |
| 48 | 49 | |
| @@ -128,7 +129,7 @@ uv run python -m regine_cli.import_cmd import /Volumes/CARTE_SD \ | ||
| 128 | 129 | --archive-root /Volumes/NAS/photos --local-root ~/regine/local |
| 129 | 130 | ``` |
| 130 | 131 | |
| 131 | -Régine copie chaque fichier en une seule lecture de la carte (vérification par checksum), détecte la plage de dates, et archive sous `<archive>/2026/2026-08-15_Sortie_parc/`. | |
| 132 | +Régine copie chaque fichier en une seule lecture de la carte (vérification par checksum), détecte la plage de dates, et archive sous `<archive>/2026/2026-08-15_Sortie_parc/`. Une **copie de travail locale identique** est laissée sous `<local-root>/2026/2026-08-15_Sortie_parc/` — vous pouvez trier/retoucher tout de suite après l'import, sans étape de checkout séparée. Le dossier reste verrouillé côté archive tant que vous n'avez pas lancé un `reconcile` (même « à vide », cf. ci-dessous) pour le libérer. | |
| 132 | 133 | |
| 133 | 134 | ### Un voyage en plusieurs étapes |
| 134 | 135 | |
| @@ -146,10 +147,13 @@ uv run python -m regine_cli.import_cmd import /Volumes/CARTE_ETAPE2 \ | ||
| 146 | 147 | |
| 147 | 148 | Résultat : `voyage/2026-08_Montenegro/2026-08-12_Kotor/`, imbriqué sous le dossier parent — dont la racine sert de planche-contact sur l'ensemble du voyage. |
| 148 | 149 | |
| 149 | -### Éditer un dossier déjà archivé, en sécurité | |
| 150 | +### Reprendre un dossier déjà archivé, en sécurité | |
| 151 | + | |
| 152 | +Après un import, la copie locale existe déjà (ci-dessus) — inutile de la re-checkouter. Cette étape sert à **reprendre** un dossier plus tard (nouvelle session, nouvel ordinateur), ou simplement à conclure une session de tri en cours : | |
| 150 | 153 | |
| 151 | 154 | ```bash |
| 152 | 155 | # Checkout : sort une copie de travail locale, avec verrou côté archive |
| 156 | +# (uniquement si vous n'avez pas déjà de copie locale en cours pour ce dossier) | |
| 153 | 157 | uv run python -m regine_cli.archive_cmd checkout \ |
| 154 | 158 | /Volumes/NAS/photos/voyage/2026-08_Montenegro/2026-08-12_Kotor \ |
| 155 | 159 | --local-dest ~/regine/local/2026-08-12_Kotor |
| @@ -157,7 +161,8 @@ uv run python -m regine_cli.archive_cmd checkout \ | ||
| 157 | 161 | # ... tri et retouche libres dans DxO, Lightroom, ou l'outil de votre choix ... |
| 158 | 162 | |
| 159 | 163 | # Réconciliation : classe chaque changement par hash, affiche le "point avant archive" |
| 160 | -# et demande confirmation avant toute écriture sur le NAS | |
| 164 | +# et demande confirmation avant toute écriture sur le NAS — libère aussi le verrou, | |
| 165 | +# même sans aucun changement (à lancer après un import comme après un checkout) | |
| 161 | 166 | uv run python -m regine_cli.archive_cmd reconcile \ |
| 162 | 167 | /Volumes/NAS/photos/voyage/2026-08_Montenegro/2026-08-12_Kotor \ |
| 163 | 168 | --local-dest ~/regine/local/2026-08-12_Kotor |
| @@ -34,15 +34,16 @@ flowchart LR | |||
| 34 | 34 | ||
| 35 | subgraph LOCAL["💻 Espace de travail local"] | 35 | subgraph LOCAL["💻 Espace de travail local"] |
| 36 | direction TB | 36 | direction TB |
| 37 | - OUT["Checkout<br/>specs/005"] | 37 | + OUT["Copie de travail<br/>(laissée par l'import, ou par un checkout — specs/005)"] |
| 38 | EDIT["Tri & retouche<br/>(DxO, Lightroom, votre outil…)"] | 38 | EDIT["Tri & retouche<br/>(DxO, Lightroom, votre outil…)"] |
| 39 | RECON["Réconciliation<br/>par hash, avant tout réarchivage"] | 39 | RECON["Réconciliation<br/>par hash, avant tout réarchivage"] |
| 40 | OUT --> EDIT --> RECON | 40 | OUT --> EDIT --> RECON |
| 41 | end | 41 | end |
| 42 | 42 | ||
| 43 | CARD --> COPY | 43 | CARD --> COPY |
| 44 | - NAME -->|premier archivage| DOSSIER | 44 | + NAME -->|archivage vérifié| DOSSIER |
| 45 | - DOSSIER -->|sort une copie de travail| OUT | 45 | + NAME -.->|copie locale laissée immédiatement| OUT |
| 46 | + DOSSIER -->|checkout, à tout moment| OUT | ||
| 46 | RECON -->|réarchivage vérifié, jamais silencieux| DOSSIER | 47 | RECON -->|réarchivage vérifié, jamais silencieux| DOSSIER |
| 47 | ``` | 48 | ``` |
| 48 | 49 | ||
| @@ -128,7 +129,7 @@ uv run python -m regine_cli.import_cmd import /Volumes/CARTE_SD \ | |||
| 128 | --archive-root /Volumes/NAS/photos --local-root ~/regine/local | 129 | --archive-root /Volumes/NAS/photos --local-root ~/regine/local |
| 129 | ``` | 130 | ``` |
| 130 | 131 | ||
| 131 | -Régine copie chaque fichier en une seule lecture de la carte (vérification par checksum), détecte la plage de dates, et archive sous `<archive>/2026/2026-08-15_Sortie_parc/`. | 132 | +Régine copie chaque fichier en une seule lecture de la carte (vérification par checksum), détecte la plage de dates, et archive sous `<archive>/2026/2026-08-15_Sortie_parc/`. Une **copie de travail locale identique** est laissée sous `<local-root>/2026/2026-08-15_Sortie_parc/` — vous pouvez trier/retoucher tout de suite après l'import, sans étape de checkout séparée. Le dossier reste verrouillé côté archive tant que vous n'avez pas lancé un `reconcile` (même « à vide », cf. ci-dessous) pour le libérer. |
| 132 | 133 | ||
| 133 | ### Un voyage en plusieurs étapes | 134 | ### Un voyage en plusieurs étapes |
| 134 | 135 | ||
| @@ -146,10 +147,13 @@ uv run python -m regine_cli.import_cmd import /Volumes/CARTE_ETAPE2 \ | |||
| 146 | 147 | ||
| 147 | Résultat : `voyage/2026-08_Montenegro/2026-08-12_Kotor/`, imbriqué sous le dossier parent — dont la racine sert de planche-contact sur l'ensemble du voyage. | 148 | Résultat : `voyage/2026-08_Montenegro/2026-08-12_Kotor/`, imbriqué sous le dossier parent — dont la racine sert de planche-contact sur l'ensemble du voyage. |
| 148 | 149 | ||
| 149 | -### Éditer un dossier déjà archivé, en sécurité | 150 | +### Reprendre un dossier déjà archivé, en sécurité |
| 151 | + | ||
| 152 | +Après un import, la copie locale existe déjà (ci-dessus) — inutile de la re-checkouter. Cette étape sert à **reprendre** un dossier plus tard (nouvelle session, nouvel ordinateur), ou simplement à conclure une session de tri en cours : | ||
| 150 | 153 | ||
| 151 | ```bash | 154 | ```bash |
| 152 | # Checkout : sort une copie de travail locale, avec verrou côté archive | 155 | # Checkout : sort une copie de travail locale, avec verrou côté archive |
| 156 | +# (uniquement si vous n'avez pas déjà de copie locale en cours pour ce dossier) | ||
| 153 | uv run python -m regine_cli.archive_cmd checkout \ | 157 | uv run python -m regine_cli.archive_cmd checkout \ |
| 154 | /Volumes/NAS/photos/voyage/2026-08_Montenegro/2026-08-12_Kotor \ | 158 | /Volumes/NAS/photos/voyage/2026-08_Montenegro/2026-08-12_Kotor \ |
| 155 | --local-dest ~/regine/local/2026-08-12_Kotor | 159 | --local-dest ~/regine/local/2026-08-12_Kotor |
| @@ -157,7 +161,8 @@ uv run python -m regine_cli.archive_cmd checkout \ | |||
| 157 | # ... tri et retouche libres dans DxO, Lightroom, ou l'outil de votre choix ... | 161 | # ... tri et retouche libres dans DxO, Lightroom, ou l'outil de votre choix ... |
| 158 | 162 | ||
| 159 | # Réconciliation : classe chaque changement par hash, affiche le "point avant archive" | 163 | # Réconciliation : classe chaque changement par hash, affiche le "point avant archive" |
| 160 | -# et demande confirmation avant toute écriture sur le NAS | 164 | +# et demande confirmation avant toute écriture sur le NAS — libère aussi le verrou, |
| 165 | +# même sans aucun changement (à lancer après un import comme après un checkout) | ||
| 161 | uv run python -m regine_cli.archive_cmd reconcile \ | 166 | uv run python -m regine_cli.archive_cmd reconcile \ |
| 162 | /Volumes/NAS/photos/voyage/2026-08_Montenegro/2026-08-12_Kotor \ | 167 | /Volumes/NAS/photos/voyage/2026-08_Montenegro/2026-08-12_Kotor \ |
| 163 | --local-dest ~/regine/local/2026-08-12_Kotor | 168 | --local-dest ~/regine/local/2026-08-12_Kotor |
modified
packages/regine-cli/src/regine_cli/import_cmd.py +3 -1 | @@ -38,7 +38,7 @@ from regine_core.import_carte.nommage import ( | ||
| 38 | 38 | renommer_fichiers, |
| 39 | 39 | resoudre_collision_nom, |
| 40 | 40 | ) |
| 41 | -from regine_core.import_carte.push import archiver, preparer_resume | |
| 41 | +from regine_core.import_carte.push import archiver, preparer_resume, publier_copie_locale | |
| 42 | 42 | from regine_core.import_carte.types import DestinationChoisie, FichierCandidat, GroupeImport |
| 43 | 43 | |
| 44 | 44 | _NOM_CONTEXTE_DB_PAR_DEFAUT = ".regine-contexte.sqlite3" |
| @@ -287,7 +287,9 @@ def _traiter_groupe( | ||
| 287 | 287 | return |
| 288 | 288 | |
| 289 | 289 | archiver(chemins, local_tmp, dossier_archive) |
| 290 | + publier_copie_locale(chemins, local_tmp, dossier_local, dossier_archive) | |
| 290 | 291 | print(f"Archivé : {dossier_archive}") |
| 292 | + print(f"Copie de travail locale prête : {dossier_local}") | |
| 291 | 293 | |
| 292 | 294 | |
| 293 | 295 | def _cmd_import(args: argparse.Namespace) -> int: |
| @@ -38,7 +38,7 @@ from regine_core.import_carte.nommage import ( | |||
| 38 | renommer_fichiers, | 38 | renommer_fichiers, |
| 39 | resoudre_collision_nom, | 39 | resoudre_collision_nom, |
| 40 | ) | 40 | ) |
| 41 | -from regine_core.import_carte.push import archiver, preparer_resume | 41 | +from regine_core.import_carte.push import archiver, preparer_resume, publier_copie_locale |
| 42 | from regine_core.import_carte.types import DestinationChoisie, FichierCandidat, GroupeImport | 42 | from regine_core.import_carte.types import DestinationChoisie, FichierCandidat, GroupeImport |
| 43 | 43 | ||
| 44 | _NOM_CONTEXTE_DB_PAR_DEFAUT = ".regine-contexte.sqlite3" | 44 | _NOM_CONTEXTE_DB_PAR_DEFAUT = ".regine-contexte.sqlite3" |
| @@ -287,7 +287,9 @@ def _traiter_groupe( | |||
| 287 | return | 287 | return |
| 288 | 288 | ||
| 289 | archiver(chemins, local_tmp, dossier_archive) | 289 | archiver(chemins, local_tmp, dossier_archive) |
| 290 | + publier_copie_locale(chemins, local_tmp, dossier_local, dossier_archive) | ||
| 290 | print(f"Archivé : {dossier_archive}") | 291 | print(f"Archivé : {dossier_archive}") |
| 292 | + print(f"Copie de travail locale prête : {dossier_local}") | ||
| 291 | 293 | ||
| 292 | 294 | ||
| 293 | def _cmd_import(args: argparse.Namespace) -> int: | 295 | def _cmd_import(args: argparse.Namespace) -> int: |
modified
packages/regine-core/src/regine_core/import_carte/push.py +85 -18 | @@ -1,4 +1,12 @@ | ||
| 1 | -"""Résumé de confirmation et transfert final vers l'archive (FR-018/019).""" | |
| 1 | +"""Résumé de confirmation et transfert final vers l'archive (FR-018/019). | |
| 2 | + | |
| 3 | +Laisse aussi une copie de travail locale structurée de façon identique à | |
| 4 | +l'archive (FR-010 : « de façon identique dans la copie de travail locale et | |
| 5 | +dans l'archive ») pour permettre de continuer à trier/retoucher immédiatement | |
| 6 | +après l'import, sans checkout séparé — en réutilisant le manifeste persistant | |
| 7 | +et le verrouillage de `regine_core.archive` (`specs/005-checkout-reconciliation`) | |
| 8 | +plutôt qu'en inventant un mécanisme parallèle (Principe VI). | |
| 9 | +""" | |
| 2 | 10 | |
| 3 | 11 | from __future__ import annotations |
| 4 | 12 | |
| @@ -6,7 +14,9 @@ import shutil | ||
| 6 | 14 | from dataclasses import dataclass |
| 7 | 15 | from pathlib import Path |
| 8 | 16 | |
| 9 | -from regine_core.integrity.hash import hash_fichier_entier | |
| 17 | +from regine_core.archive.manifest import ouvrir_ou_creer | |
| 18 | +from regine_core.archive.verrou import poser, verifier | |
| 19 | +from regine_core.integrity.hash import empreinte, hash_fichier_entier | |
| 10 | 20 | |
| 11 | 21 | |
| 12 | 22 | class EchecTransfertError(Exception): |
| @@ -40,6 +50,32 @@ def preparer_resume(fichiers: list[Path], dossier_destination: Path) -> ResumeCo | ||
| 40 | 50 | ) |
| 41 | 51 | |
| 42 | 52 | |
| 53 | +def _copier_verifie_sans_ecraser(source: Path, destination: Path) -> bool: | |
| 54 | + """Copie `source` vers `destination` avec vérification d'intégrité. | |
| 55 | + | |
| 56 | + Ne DOIT jamais écraser un fichier déjà présent de contenu différent | |
| 57 | + (FR-012 appliqué au niveau fichier) : un doublon de contenu identique est | |
| 58 | + ignoré silencieusement (no-op, retourne `False`), un contenu différent | |
| 59 | + lève `CollisionNomArchiveError` plutôt que d'écraser. | |
| 60 | + """ | |
| 61 | + destination.parent.mkdir(parents=True, exist_ok=True) | |
| 62 | + hash_source = hash_fichier_entier(source) | |
| 63 | + | |
| 64 | + if destination.exists(): | |
| 65 | + if hash_fichier_entier(destination) == hash_source: | |
| 66 | + return False # doublon déjà présent (US3 scénario 3) — rien à refaire | |
| 67 | + raise CollisionNomArchiveError( | |
| 68 | + f"{destination} existe déjà avec un contenu différent — refus d'écraser " | |
| 69 | + "silencieusement (probablement deux fichiers de boîtiers différents " | |
| 70 | + "importés séparément aboutissant au même nom final)" | |
| 71 | + ) | |
| 72 | + | |
| 73 | + shutil.copy2(source, destination) | |
| 74 | + if hash_fichier_entier(destination) != hash_source: | |
| 75 | + raise EchecTransfertError(f"Échec de vérification d'intégrité pour {destination}") | |
| 76 | + return True | |
| 77 | + | |
| 78 | + | |
| 43 | 79 | def archiver( |
| 44 | 80 | fichiers_locaux: list[Path], |
| 45 | 81 | dossier_local_racine: Path, |
| @@ -54,20 +90,51 @@ def archiver( | ||
| 54 | 90 | dossier_destination.mkdir(parents=True, exist_ok=True) |
| 55 | 91 | for source in fichiers_locaux: |
| 56 | 92 | relatif = source.relative_to(dossier_local_racine) |
| 57 | - destination = dossier_destination / relatif | |
| 58 | - destination.parent.mkdir(parents=True, exist_ok=True) | |
| 59 | - | |
| 60 | - hash_source = hash_fichier_entier(source) | |
| 61 | - | |
| 62 | - if destination.exists(): | |
| 63 | - if hash_fichier_entier(destination) == hash_source: | |
| 64 | - continue # doublon déjà archivé (US3 scénario 3) — rien à refaire | |
| 65 | - raise CollisionNomArchiveError( | |
| 66 | - f"{destination} existe déjà avec un contenu différent — refus d'écraser " | |
| 67 | - "silencieusement (probablement deux fichiers de boîtiers différents " | |
| 68 | - "importés séparément aboutissant au même nom final)" | |
| 69 | - ) | |
| 93 | + _copier_verifie_sans_ecraser(source, dossier_destination / relatif) | |
| 94 | + | |
| 70 | 95 | |
| 71 | - shutil.copy2(source, destination) | |
| 72 | - if hash_fichier_entier(destination) != hash_source: | |
| 73 | - raise EchecTransfertError(f"Échec de vérification d'intégrité pour {destination}") | |
| 96 | +def publier_copie_locale( | |
| 97 | + fichiers_locaux: list[Path], | |
| 98 | + dossier_local_racine: Path, | |
| 99 | + dossier_local: Path, | |
| 100 | + dossier_archive: Path, | |
| 101 | +) -> None: | |
| 102 | + """Laisse une copie de travail locale des fichiers qui viennent d'être archivés, | |
| 103 | + et enregistre leur empreinte dans le manifeste persistant du dossier (FR-010). | |
| 104 | + | |
| 105 | + Ne DOIT être appelée qu'après un `archiver()` réussi vers `dossier_archive` — les | |
| 106 | + deux copies (archive et locale) sont alors identiques par construction. Ne copie | |
| 107 | + que les fichiers de cet import : contrairement à un checkout complet | |
| 108 | + (`regine_core.archive.checkout.checkout`), n'écrase jamais un fichier local déjà | |
| 109 | + présent (édition en cours d'un import précédent non encore réconcilié). Pose le | |
| 110 | + verrou du dossier s'il ne l'est pas déjà (cas d'une fusion ayant déclenché un | |
| 111 | + checkout juste avant, cf. `destination.resoudre_fusion`) — jamais un second | |
| 112 | + verrou. Laisse volontairement le dossier verrouillé : l'utilisateur travaille | |
| 113 | + maintenant sur une copie locale, exactement comme après un checkout explicite, et | |
| 114 | + devra passer par `regine_core.archive.reconciliation` pour le libérer. | |
| 115 | + """ | |
| 116 | + manifest = ouvrir_ou_creer(dossier_archive) | |
| 117 | + try: | |
| 118 | + if not verifier(manifest): | |
| 119 | + poser(manifest) | |
| 120 | + | |
| 121 | + for source in fichiers_locaux: | |
| 122 | + relatif = source.relative_to(dossier_local_racine) | |
| 123 | + _copier_verifie_sans_ecraser(source, dossier_local / relatif) | |
| 124 | + | |
| 125 | + emp = empreinte(source) | |
| 126 | + manifest.conn.execute( | |
| 127 | + "INSERT OR REPLACE INTO fichiers " | |
| 128 | + "(chemin_relatif, taille, hash_fichier_entier, hash_image_only, " | |
| 129 | + "identifiant_perenne) VALUES (?, ?, ?, ?, ?)", | |
| 130 | + ( | |
| 131 | + str(relatif), | |
| 132 | + source.stat().st_size, | |
| 133 | + emp.hash_fichier_entier, | |
| 134 | + emp.hash_image_only, | |
| 135 | + None, | |
| 136 | + ), | |
| 137 | + ) | |
| 138 | + manifest.conn.commit() | |
| 139 | + finally: | |
| 140 | + manifest.conn.close() | |
| @@ -1,4 +1,12 @@ | |||
| 1 | -"""Résumé de confirmation et transfert final vers l'archive (FR-018/019).""" | 1 | +"""Résumé de confirmation et transfert final vers l'archive (FR-018/019). |
| 2 | + | ||
| 3 | +Laisse aussi une copie de travail locale structurée de façon identique à | ||
| 4 | +l'archive (FR-010 : « de façon identique dans la copie de travail locale et | ||
| 5 | +dans l'archive ») pour permettre de continuer à trier/retoucher immédiatement | ||
| 6 | +après l'import, sans checkout séparé — en réutilisant le manifeste persistant | ||
| 7 | +et le verrouillage de `regine_core.archive` (`specs/005-checkout-reconciliation`) | ||
| 8 | +plutôt qu'en inventant un mécanisme parallèle (Principe VI). | ||
| 9 | +""" | ||
| 2 | 10 | ||
| 3 | from __future__ import annotations | 11 | from __future__ import annotations |
| 4 | 12 | ||
| @@ -6,7 +14,9 @@ import shutil | |||
| 6 | from dataclasses import dataclass | 14 | from dataclasses import dataclass |
| 7 | from pathlib import Path | 15 | from pathlib import Path |
| 8 | 16 | ||
| 9 | -from regine_core.integrity.hash import hash_fichier_entier | 17 | +from regine_core.archive.manifest import ouvrir_ou_creer |
| 18 | +from regine_core.archive.verrou import poser, verifier | ||
| 19 | +from regine_core.integrity.hash import empreinte, hash_fichier_entier | ||
| 10 | 20 | ||
| 11 | 21 | ||
| 12 | class EchecTransfertError(Exception): | 22 | class EchecTransfertError(Exception): |
| @@ -40,6 +50,32 @@ def preparer_resume(fichiers: list[Path], dossier_destination: Path) -> ResumeCo | |||
| 40 | ) | 50 | ) |
| 41 | 51 | ||
| 42 | 52 | ||
| 53 | +def _copier_verifie_sans_ecraser(source: Path, destination: Path) -> bool: | ||
| 54 | + """Copie `source` vers `destination` avec vérification d'intégrité. | ||
| 55 | + | ||
| 56 | + Ne DOIT jamais écraser un fichier déjà présent de contenu différent | ||
| 57 | + (FR-012 appliqué au niveau fichier) : un doublon de contenu identique est | ||
| 58 | + ignoré silencieusement (no-op, retourne `False`), un contenu différent | ||
| 59 | + lève `CollisionNomArchiveError` plutôt que d'écraser. | ||
| 60 | + """ | ||
| 61 | + destination.parent.mkdir(parents=True, exist_ok=True) | ||
| 62 | + hash_source = hash_fichier_entier(source) | ||
| 63 | + | ||
| 64 | + if destination.exists(): | ||
| 65 | + if hash_fichier_entier(destination) == hash_source: | ||
| 66 | + return False # doublon déjà présent (US3 scénario 3) — rien à refaire | ||
| 67 | + raise CollisionNomArchiveError( | ||
| 68 | + f"{destination} existe déjà avec un contenu différent — refus d'écraser " | ||
| 69 | + "silencieusement (probablement deux fichiers de boîtiers différents " | ||
| 70 | + "importés séparément aboutissant au même nom final)" | ||
| 71 | + ) | ||
| 72 | + | ||
| 73 | + shutil.copy2(source, destination) | ||
| 74 | + if hash_fichier_entier(destination) != hash_source: | ||
| 75 | + raise EchecTransfertError(f"Échec de vérification d'intégrité pour {destination}") | ||
| 76 | + return True | ||
| 77 | + | ||
| 78 | + | ||
| 43 | def archiver( | 79 | def archiver( |
| 44 | fichiers_locaux: list[Path], | 80 | fichiers_locaux: list[Path], |
| 45 | dossier_local_racine: Path, | 81 | dossier_local_racine: Path, |
| @@ -54,20 +90,51 @@ def archiver( | |||
| 54 | dossier_destination.mkdir(parents=True, exist_ok=True) | 90 | dossier_destination.mkdir(parents=True, exist_ok=True) |
| 55 | for source in fichiers_locaux: | 91 | for source in fichiers_locaux: |
| 56 | relatif = source.relative_to(dossier_local_racine) | 92 | relatif = source.relative_to(dossier_local_racine) |
| 57 | - destination = dossier_destination / relatif | 93 | + _copier_verifie_sans_ecraser(source, dossier_destination / relatif) |
| 58 | - destination.parent.mkdir(parents=True, exist_ok=True) | 94 | + |
| 59 | - | ||
| 60 | - hash_source = hash_fichier_entier(source) | ||
| 61 | - | ||
| 62 | - if destination.exists(): | ||
| 63 | - if hash_fichier_entier(destination) == hash_source: | ||
| 64 | - continue # doublon déjà archivé (US3 scénario 3) — rien à refaire | ||
| 65 | - raise CollisionNomArchiveError( | ||
| 66 | - f"{destination} existe déjà avec un contenu différent — refus d'écraser " | ||
| 67 | - "silencieusement (probablement deux fichiers de boîtiers différents " | ||
| 68 | - "importés séparément aboutissant au même nom final)" | ||
| 69 | - ) | ||
| 70 | 95 | ||
| 71 | - shutil.copy2(source, destination) | 96 | +def publier_copie_locale( |
| 72 | - if hash_fichier_entier(destination) != hash_source: | 97 | + fichiers_locaux: list[Path], |
| 73 | - raise EchecTransfertError(f"Échec de vérification d'intégrité pour {destination}") | 98 | + dossier_local_racine: Path, |
| 99 | + dossier_local: Path, | ||
| 100 | + dossier_archive: Path, | ||
| 101 | +) -> None: | ||
| 102 | + """Laisse une copie de travail locale des fichiers qui viennent d'être archivés, | ||
| 103 | + et enregistre leur empreinte dans le manifeste persistant du dossier (FR-010). | ||
| 104 | + | ||
| 105 | + Ne DOIT être appelée qu'après un `archiver()` réussi vers `dossier_archive` — les | ||
| 106 | + deux copies (archive et locale) sont alors identiques par construction. Ne copie | ||
| 107 | + que les fichiers de cet import : contrairement à un checkout complet | ||
| 108 | + (`regine_core.archive.checkout.checkout`), n'écrase jamais un fichier local déjà | ||
| 109 | + présent (édition en cours d'un import précédent non encore réconcilié). Pose le | ||
| 110 | + verrou du dossier s'il ne l'est pas déjà (cas d'une fusion ayant déclenché un | ||
| 111 | + checkout juste avant, cf. `destination.resoudre_fusion`) — jamais un second | ||
| 112 | + verrou. Laisse volontairement le dossier verrouillé : l'utilisateur travaille | ||
| 113 | + maintenant sur une copie locale, exactement comme après un checkout explicite, et | ||
| 114 | + devra passer par `regine_core.archive.reconciliation` pour le libérer. | ||
| 115 | + """ | ||
| 116 | + manifest = ouvrir_ou_creer(dossier_archive) | ||
| 117 | + try: | ||
| 118 | + if not verifier(manifest): | ||
| 119 | + poser(manifest) | ||
| 120 | + | ||
| 121 | + for source in fichiers_locaux: | ||
| 122 | + relatif = source.relative_to(dossier_local_racine) | ||
| 123 | + _copier_verifie_sans_ecraser(source, dossier_local / relatif) | ||
| 124 | + | ||
| 125 | + emp = empreinte(source) | ||
| 126 | + manifest.conn.execute( | ||
| 127 | + "INSERT OR REPLACE INTO fichiers " | ||
| 128 | + "(chemin_relatif, taille, hash_fichier_entier, hash_image_only, " | ||
| 129 | + "identifiant_perenne) VALUES (?, ?, ?, ?, ?)", | ||
| 130 | + ( | ||
| 131 | + str(relatif), | ||
| 132 | + source.stat().st_size, | ||
| 133 | + emp.hash_fichier_entier, | ||
| 134 | + emp.hash_image_only, | ||
| 135 | + None, | ||
| 136 | + ), | ||
| 137 | + ) | ||
| 138 | + manifest.conn.commit() | ||
| 139 | + finally: | ||
| 140 | + manifest.conn.close() | ||
modified
packages/regine-core/tests/unit/test_push_archiver.py +72 -2 | @@ -1,11 +1,18 @@ | ||
| 1 | -"""Tests de `archiver` : jamais d'écrasement silencieux (FR-012, US3 scénario 3).""" | |
| 1 | +"""Tests de `archiver`/`publier_copie_locale` : jamais d'écrasement silencieux | |
| 2 | +(FR-012, US3 scénario 3), copie de travail locale laissée après import (FR-010).""" | |
| 2 | 3 | |
| 3 | 4 | from __future__ import annotations |
| 4 | 5 | |
| 5 | 6 | from pathlib import Path |
| 6 | 7 | |
| 7 | 8 | import pytest |
| 8 | -from regine_core.import_carte.push import CollisionNomArchiveError, archiver | |
| 9 | +from regine_core.archive.manifest import ouvrir_ou_creer | |
| 10 | +from regine_core.archive.verrou import lever, verifier | |
| 11 | +from regine_core.import_carte.push import ( | |
| 12 | + CollisionNomArchiveError, | |
| 13 | + archiver, | |
| 14 | + publier_copie_locale, | |
| 15 | +) | |
| 9 | 16 | |
| 10 | 17 | |
| 11 | 18 | def _preparer_source(tmp_path: Path, contenu: bytes) -> tuple[Path, Path]: |
| @@ -48,3 +55,66 @@ def test_archiver_refuse_ecraser_un_fichier_existant_de_contenu_different(tmp_pa | ||
| 48 | 55 | archiver([fichier], local_racine, destination) |
| 49 | 56 | |
| 50 | 57 | assert (destination / fichier.name).read_bytes() == b"contenu-boitier-a-deja-archive" |
| 58 | + | |
| 59 | + | |
| 60 | +def test_publier_copie_locale_laisse_une_copie_de_travail_et_verrouille(tmp_path: Path) -> None: | |
| 61 | + """FR-010 : après import, une copie locale identique à l'archive doit exister, | |
| 62 | + prête à être triée/retouchée immédiatement, sans checkout séparé.""" | |
| 63 | + local_tmp, fichier = _preparer_source(tmp_path, b"contenu-a") | |
| 64 | + dossier_archive = tmp_path / "archive" | |
| 65 | + dossier_local = tmp_path / "local_travail" | |
| 66 | + archiver([fichier], local_tmp, dossier_archive) | |
| 67 | + | |
| 68 | + publier_copie_locale([fichier], local_tmp, dossier_local, dossier_archive) | |
| 69 | + | |
| 70 | + assert (dossier_local / fichier.name).read_bytes() == b"contenu-a" | |
| 71 | + | |
| 72 | + manifest = ouvrir_ou_creer(dossier_archive) | |
| 73 | + ligne = manifest.conn.execute( | |
| 74 | + "SELECT hash_fichier_entier FROM fichiers WHERE chemin_relatif = ?", (fichier.name,) | |
| 75 | + ).fetchone() | |
| 76 | + assert ligne is not None | |
| 77 | + assert verifier(manifest) # dossier laissé verrouillé, comme après un checkout | |
| 78 | + manifest.conn.close() | |
| 79 | + | |
| 80 | + | |
| 81 | +def test_publier_copie_locale_ne_pose_pas_un_second_verrou_si_deja_verrouille( | |
| 82 | + tmp_path: Path, | |
| 83 | +) -> None: | |
| 84 | + """Cas d'une fusion ayant déjà déclenché un checkout (donc déjà verrouillé) juste | |
| 85 | + avant l'archivage : ne DOIT jamais lever une erreur de double verrouillage.""" | |
| 86 | + local_tmp, fichier = _preparer_source(tmp_path, b"contenu-a") | |
| 87 | + dossier_archive = tmp_path / "archive" | |
| 88 | + dossier_local = tmp_path / "local_travail" | |
| 89 | + archiver([fichier], local_tmp, dossier_archive) | |
| 90 | + | |
| 91 | + manifest_prealable = ouvrir_ou_creer(dossier_archive) | |
| 92 | + from regine_core.archive.verrou import poser # noqa: PLC0415 | |
| 93 | + | |
| 94 | + poser(manifest_prealable) | |
| 95 | + manifest_prealable.conn.close() | |
| 96 | + | |
| 97 | + publier_copie_locale([fichier], local_tmp, dossier_local, dossier_archive) # ne doit pas lever | |
| 98 | + | |
| 99 | + assert (dossier_local / fichier.name).read_bytes() == b"contenu-a" | |
| 100 | + | |
| 101 | + | |
| 102 | +def test_publier_copie_locale_ne_touche_pas_un_fichier_local_deja_present_non_concerne( | |
| 103 | + tmp_path: Path, | |
| 104 | +) -> None: | |
| 105 | + """Contrairement à un checkout complet, ne doit jamais écraser un fichier local | |
| 106 | + déjà là (ex. édition en cours d'un import précédent non encore réconcilié).""" | |
| 107 | + local_tmp, fichier = _preparer_source(tmp_path, b"contenu-a") | |
| 108 | + dossier_archive = tmp_path / "archive" | |
| 109 | + dossier_local = tmp_path / "local_travail" | |
| 110 | + dossier_local.mkdir() | |
| 111 | + (dossier_local / "deja_present.xmp").write_bytes(b"reglages-utilisateur-en-cours") | |
| 112 | + archiver([fichier], local_tmp, dossier_archive) | |
| 113 | + | |
| 114 | + publier_copie_locale([fichier], local_tmp, dossier_local, dossier_archive) | |
| 115 | + | |
| 116 | + assert (dossier_local / "deja_present.xmp").read_bytes() == b"reglages-utilisateur-en-cours" | |
| 117 | + | |
| 118 | + manifest = ouvrir_ou_creer(dossier_archive) | |
| 119 | + lever(manifest) # nettoyage, pas d'assertion supplémentaire nécessaire ici | |
| 120 | + manifest.conn.close() | |
| @@ -1,11 +1,18 @@ | |||
| 1 | -"""Tests de `archiver` : jamais d'écrasement silencieux (FR-012, US3 scénario 3).""" | 1 | +"""Tests de `archiver`/`publier_copie_locale` : jamais d'écrasement silencieux |
| 2 | +(FR-012, US3 scénario 3), copie de travail locale laissée après import (FR-010).""" | ||
| 2 | 3 | ||
| 3 | from __future__ import annotations | 4 | from __future__ import annotations |
| 4 | 5 | ||
| 5 | from pathlib import Path | 6 | from pathlib import Path |
| 6 | 7 | ||
| 7 | import pytest | 8 | import pytest |
| 8 | -from regine_core.import_carte.push import CollisionNomArchiveError, archiver | 9 | +from regine_core.archive.manifest import ouvrir_ou_creer |
| 10 | +from regine_core.archive.verrou import lever, verifier | ||
| 11 | +from regine_core.import_carte.push import ( | ||
| 12 | + CollisionNomArchiveError, | ||
| 13 | + archiver, | ||
| 14 | + publier_copie_locale, | ||
| 15 | +) | ||
| 9 | 16 | ||
| 10 | 17 | ||
| 11 | def _preparer_source(tmp_path: Path, contenu: bytes) -> tuple[Path, Path]: | 18 | def _preparer_source(tmp_path: Path, contenu: bytes) -> tuple[Path, Path]: |
| @@ -48,3 +55,66 @@ def test_archiver_refuse_ecraser_un_fichier_existant_de_contenu_different(tmp_pa | |||
| 48 | archiver([fichier], local_racine, destination) | 55 | archiver([fichier], local_racine, destination) |
| 49 | 56 | ||
| 50 | assert (destination / fichier.name).read_bytes() == b"contenu-boitier-a-deja-archive" | 57 | assert (destination / fichier.name).read_bytes() == b"contenu-boitier-a-deja-archive" |
| 58 | + | ||
| 59 | + | ||
| 60 | +def test_publier_copie_locale_laisse_une_copie_de_travail_et_verrouille(tmp_path: Path) -> None: | ||
| 61 | + """FR-010 : après import, une copie locale identique à l'archive doit exister, | ||
| 62 | + prête à être triée/retouchée immédiatement, sans checkout séparé.""" | ||
| 63 | + local_tmp, fichier = _preparer_source(tmp_path, b"contenu-a") | ||
| 64 | + dossier_archive = tmp_path / "archive" | ||
| 65 | + dossier_local = tmp_path / "local_travail" | ||
| 66 | + archiver([fichier], local_tmp, dossier_archive) | ||
| 67 | + | ||
| 68 | + publier_copie_locale([fichier], local_tmp, dossier_local, dossier_archive) | ||
| 69 | + | ||
| 70 | + assert (dossier_local / fichier.name).read_bytes() == b"contenu-a" | ||
| 71 | + | ||
| 72 | + manifest = ouvrir_ou_creer(dossier_archive) | ||
| 73 | + ligne = manifest.conn.execute( | ||
| 74 | + "SELECT hash_fichier_entier FROM fichiers WHERE chemin_relatif = ?", (fichier.name,) | ||
| 75 | + ).fetchone() | ||
| 76 | + assert ligne is not None | ||
| 77 | + assert verifier(manifest) # dossier laissé verrouillé, comme après un checkout | ||
| 78 | + manifest.conn.close() | ||
| 79 | + | ||
| 80 | + | ||
| 81 | +def test_publier_copie_locale_ne_pose_pas_un_second_verrou_si_deja_verrouille( | ||
| 82 | + tmp_path: Path, | ||
| 83 | +) -> None: | ||
| 84 | + """Cas d'une fusion ayant déjà déclenché un checkout (donc déjà verrouillé) juste | ||
| 85 | + avant l'archivage : ne DOIT jamais lever une erreur de double verrouillage.""" | ||
| 86 | + local_tmp, fichier = _preparer_source(tmp_path, b"contenu-a") | ||
| 87 | + dossier_archive = tmp_path / "archive" | ||
| 88 | + dossier_local = tmp_path / "local_travail" | ||
| 89 | + archiver([fichier], local_tmp, dossier_archive) | ||
| 90 | + | ||
| 91 | + manifest_prealable = ouvrir_ou_creer(dossier_archive) | ||
| 92 | + from regine_core.archive.verrou import poser # noqa: PLC0415 | ||
| 93 | + | ||
| 94 | + poser(manifest_prealable) | ||
| 95 | + manifest_prealable.conn.close() | ||
| 96 | + | ||
| 97 | + publier_copie_locale([fichier], local_tmp, dossier_local, dossier_archive) # ne doit pas lever | ||
| 98 | + | ||
| 99 | + assert (dossier_local / fichier.name).read_bytes() == b"contenu-a" | ||
| 100 | + | ||
| 101 | + | ||
| 102 | +def test_publier_copie_locale_ne_touche_pas_un_fichier_local_deja_present_non_concerne( | ||
| 103 | + tmp_path: Path, | ||
| 104 | +) -> None: | ||
| 105 | + """Contrairement à un checkout complet, ne doit jamais écraser un fichier local | ||
| 106 | + déjà là (ex. édition en cours d'un import précédent non encore réconcilié).""" | ||
| 107 | + local_tmp, fichier = _preparer_source(tmp_path, b"contenu-a") | ||
| 108 | + dossier_archive = tmp_path / "archive" | ||
| 109 | + dossier_local = tmp_path / "local_travail" | ||
| 110 | + dossier_local.mkdir() | ||
| 111 | + (dossier_local / "deja_present.xmp").write_bytes(b"reglages-utilisateur-en-cours") | ||
| 112 | + archiver([fichier], local_tmp, dossier_archive) | ||
| 113 | + | ||
| 114 | + publier_copie_locale([fichier], local_tmp, dossier_local, dossier_archive) | ||
| 115 | + | ||
| 116 | + assert (dossier_local / "deja_present.xmp").read_bytes() == b"reglages-utilisateur-en-cours" | ||
| 117 | + | ||
| 118 | + manifest = ouvrir_ou_creer(dossier_archive) | ||
| 119 | + lever(manifest) # nettoyage, pas d'assertion supplémentaire nécessaire ici | ||
| 120 | + manifest.conn.close() | ||
modified
specs/001-import-photos/contracts/cli-import.md +8 -2 | @@ -16,10 +16,10 @@ regine import /Volumes/CARTE_SD [--titre TEXTE] [--destination nouveau|sous-doss | ||
| 16 | 16 | 4. Si une collision de nom d'origine est détectée (FR-015) : résolution automatique silencieuse, ou question d'étiquetage manuel uniquement si `regine_core.camera_profile.resolve_collision` renvoie un groupe non résolu. |
| 17 | 17 | 5. Renommage local (FR-013/014) et attribution de l'identifiant pérenne (FR-017). |
| 18 | 18 | 6. Résumé complet par groupe (fichiers, taille, dossier de destination avec répertoire racine) et confirmation explicite avant écriture sur l'archive (FR-018). |
| 19 | -7. Transfert final vérifié (FR-019). | |
| 19 | +7. Transfert final vérifié (FR-019), puis publication d'une copie de travail locale identique (FR-010, `push.publier_copie_locale`) — prête pour un tri/une retouche immédiate, sans `regine checkout` séparé. | |
| 20 | 20 | |
| 21 | 21 | **Sorties** : |
| 22 | -- Succès : récapitulatif des dossiers archivés sur stdout, code `0`. | |
| 22 | +- Succès : récapitulatif des dossiers archivés **et de la copie de travail locale correspondante** sur stdout, code `0`. Le dossier reste verrouillé côté archive à l'issue de l'import (comme après un checkout explicite) : un `regine reconcile` (même « à vide », sans changement) est nécessaire pour le libérer une fois le tri/la retouche locale terminée. | |
| 23 | 23 | - Échec de vérification d'un fichier (Edge Case lecture corrompue) : fichier signalé sur stderr, import interrompu pour ce fichier, carte non marquée sûre à effacer, code non-zéro. |
| 24 | 24 | - Espace disque insuffisant (Edge Case) : message clair avant toute copie, code non-zéro. |
| 25 | 25 | - Collision de nom de dossier (FR-012) : proposition de suffixe ou demande de confirmation sur stdout ; sans `--yes`, attend une réponse interactive. |
| @@ -36,3 +36,9 @@ regine import /Volumes/CARTE_SD [--titre TEXTE] [--destination nouveau|sous-doss | ||
| 36 | 36 | - Un `nouveau_sous_dossier` est physiquement imbriqué sous le dossier parent réel (`archive_root/CHEMIN_RELATIF/nom_etape/...`), pas seulement sous la racine catégorie/année héritée — `regine_core.import_carte.destination.resoudre_destination` attend un `dossier_cible` déjà construit par l'appelant pour ce type (elle ne le calcule pas elle-même). |
| 37 | 37 | - `--contexte-db` (optionnel, défaut `<local_root>/.regine-contexte.sqlite3`) ouvre la base de contexte centralisée (`regine_core.config.db`) utilisée pour la désambiguïsation de boîtiers (specs/002) et le cache de catégories (specs/004). |
| 38 | 38 | - **Limitation connue** : la désambiguïsation automatique de boîtiers par tag de modèle (FR-015) n'opère que sur les fichiers copiés au sein d'un même appel `copier_carte` (un seul import), conformément au scénario d'acceptation US3 #5 ("au sein d'un même import"). Une fusion (`fusion:CHEMIN`) ciblant un dossier déjà peuplé lors d'un import **précédent et séparé** ne redétecte pas les boîtiers déjà présents : si un fichier du nouvel import aboutit, après renommage, au même nom final qu'un fichier déjà archivé mais de contenu différent (deux boîtiers différents ayant produit un nom d'origine identique, importés carte par carte plutôt qu'en une seule session), `regine_core.import_carte.push.archiver` lève `CollisionNomArchiveError` plutôt que d'écraser silencieusement (FR-012 appliqué au niveau fichier). Un doublon de contenu identique (même somme de contrôle) est en revanche ignoré silencieusement, conformément à US3 scénario 3. |
| 39 | + | |
| 40 | +## Notes d'implémentation (post-FR-010, 2026-09-19) | |
| 41 | + | |
| 42 | +- **Écart trouvé après relecture du README par l'utilisateur** : jusqu'ici, `regine import` ne laissait aucune copie de travail locale réellement exploitable après l'import (seulement un dossier temporaire à plat) — contrairement à FR-010, qui exige explicitement un chemin final construit « de façon identique dans la copie de travail locale et dans l'archive ». Il fallait un `regine checkout` séparé pour pouvoir continuer à travailler localement. | |
| 43 | +- Corrigé : `regine import` publie désormais aussi une copie de travail locale (`push.publier_copie_locale`), en réutilisant le manifeste persistant et le verrouillage de `specs/005-checkout-reconciliation`. Le dossier archivé reste donc verrouillé à l'issue d'un import — la commande `regine reconcile` (cf. `specs/005-checkout-reconciliation/contracts/cli-checkout-reconcile.md`) devient l'étape normale pour le libérer, même sans aucun changement local (réconciliation « à vide »). | |
| 44 | +- Cas d'une `fusion:CHEMIN` vers un dossier déjà archivé (checkout automatique préalable, ligne ci-dessus) : la publication de la copie locale ne pose pas de second verrou (le checkout de `resoudre_fusion` l'a déjà posé) et n'écrase jamais un fichier local préexistant issu d'un import précédent non encore réconcilié — seuls les fichiers de l'import en cours sont copiés/ajoutés au manifeste. | |
| @@ -16,10 +16,10 @@ regine import /Volumes/CARTE_SD [--titre TEXTE] [--destination nouveau|sous-doss | |||
| 16 | 4. Si une collision de nom d'origine est détectée (FR-015) : résolution automatique silencieuse, ou question d'étiquetage manuel uniquement si `regine_core.camera_profile.resolve_collision` renvoie un groupe non résolu. | 16 | 4. Si une collision de nom d'origine est détectée (FR-015) : résolution automatique silencieuse, ou question d'étiquetage manuel uniquement si `regine_core.camera_profile.resolve_collision` renvoie un groupe non résolu. |
| 17 | 5. Renommage local (FR-013/014) et attribution de l'identifiant pérenne (FR-017). | 17 | 5. Renommage local (FR-013/014) et attribution de l'identifiant pérenne (FR-017). |
| 18 | 6. Résumé complet par groupe (fichiers, taille, dossier de destination avec répertoire racine) et confirmation explicite avant écriture sur l'archive (FR-018). | 18 | 6. Résumé complet par groupe (fichiers, taille, dossier de destination avec répertoire racine) et confirmation explicite avant écriture sur l'archive (FR-018). |
| 19 | -7. Transfert final vérifié (FR-019). | 19 | +7. Transfert final vérifié (FR-019), puis publication d'une copie de travail locale identique (FR-010, `push.publier_copie_locale`) — prête pour un tri/une retouche immédiate, sans `regine checkout` séparé. |
| 20 | 20 | ||
| 21 | **Sorties** : | 21 | **Sorties** : |
| 22 | -- Succès : récapitulatif des dossiers archivés sur stdout, code `0`. | 22 | +- Succès : récapitulatif des dossiers archivés **et de la copie de travail locale correspondante** sur stdout, code `0`. Le dossier reste verrouillé côté archive à l'issue de l'import (comme après un checkout explicite) : un `regine reconcile` (même « à vide », sans changement) est nécessaire pour le libérer une fois le tri/la retouche locale terminée. |
| 23 | - Échec de vérification d'un fichier (Edge Case lecture corrompue) : fichier signalé sur stderr, import interrompu pour ce fichier, carte non marquée sûre à effacer, code non-zéro. | 23 | - Échec de vérification d'un fichier (Edge Case lecture corrompue) : fichier signalé sur stderr, import interrompu pour ce fichier, carte non marquée sûre à effacer, code non-zéro. |
| 24 | - Espace disque insuffisant (Edge Case) : message clair avant toute copie, code non-zéro. | 24 | - Espace disque insuffisant (Edge Case) : message clair avant toute copie, code non-zéro. |
| 25 | - Collision de nom de dossier (FR-012) : proposition de suffixe ou demande de confirmation sur stdout ; sans `--yes`, attend une réponse interactive. | 25 | - Collision de nom de dossier (FR-012) : proposition de suffixe ou demande de confirmation sur stdout ; sans `--yes`, attend une réponse interactive. |
| @@ -36,3 +36,9 @@ regine import /Volumes/CARTE_SD [--titre TEXTE] [--destination nouveau|sous-doss | |||
| 36 | - Un `nouveau_sous_dossier` est physiquement imbriqué sous le dossier parent réel (`archive_root/CHEMIN_RELATIF/nom_etape/...`), pas seulement sous la racine catégorie/année héritée — `regine_core.import_carte.destination.resoudre_destination` attend un `dossier_cible` déjà construit par l'appelant pour ce type (elle ne le calcule pas elle-même). | 36 | - Un `nouveau_sous_dossier` est physiquement imbriqué sous le dossier parent réel (`archive_root/CHEMIN_RELATIF/nom_etape/...`), pas seulement sous la racine catégorie/année héritée — `regine_core.import_carte.destination.resoudre_destination` attend un `dossier_cible` déjà construit par l'appelant pour ce type (elle ne le calcule pas elle-même). |
| 37 | - `--contexte-db` (optionnel, défaut `<local_root>/.regine-contexte.sqlite3`) ouvre la base de contexte centralisée (`regine_core.config.db`) utilisée pour la désambiguïsation de boîtiers (specs/002) et le cache de catégories (specs/004). | 37 | - `--contexte-db` (optionnel, défaut `<local_root>/.regine-contexte.sqlite3`) ouvre la base de contexte centralisée (`regine_core.config.db`) utilisée pour la désambiguïsation de boîtiers (specs/002) et le cache de catégories (specs/004). |
| 38 | - **Limitation connue** : la désambiguïsation automatique de boîtiers par tag de modèle (FR-015) n'opère que sur les fichiers copiés au sein d'un même appel `copier_carte` (un seul import), conformément au scénario d'acceptation US3 #5 ("au sein d'un même import"). Une fusion (`fusion:CHEMIN`) ciblant un dossier déjà peuplé lors d'un import **précédent et séparé** ne redétecte pas les boîtiers déjà présents : si un fichier du nouvel import aboutit, après renommage, au même nom final qu'un fichier déjà archivé mais de contenu différent (deux boîtiers différents ayant produit un nom d'origine identique, importés carte par carte plutôt qu'en une seule session), `regine_core.import_carte.push.archiver` lève `CollisionNomArchiveError` plutôt que d'écraser silencieusement (FR-012 appliqué au niveau fichier). Un doublon de contenu identique (même somme de contrôle) est en revanche ignoré silencieusement, conformément à US3 scénario 3. | 38 | - **Limitation connue** : la désambiguïsation automatique de boîtiers par tag de modèle (FR-015) n'opère que sur les fichiers copiés au sein d'un même appel `copier_carte` (un seul import), conformément au scénario d'acceptation US3 #5 ("au sein d'un même import"). Une fusion (`fusion:CHEMIN`) ciblant un dossier déjà peuplé lors d'un import **précédent et séparé** ne redétecte pas les boîtiers déjà présents : si un fichier du nouvel import aboutit, après renommage, au même nom final qu'un fichier déjà archivé mais de contenu différent (deux boîtiers différents ayant produit un nom d'origine identique, importés carte par carte plutôt qu'en une seule session), `regine_core.import_carte.push.archiver` lève `CollisionNomArchiveError` plutôt que d'écraser silencieusement (FR-012 appliqué au niveau fichier). Un doublon de contenu identique (même somme de contrôle) est en revanche ignoré silencieusement, conformément à US3 scénario 3. |
| 39 | + | ||
| 40 | +## Notes d'implémentation (post-FR-010, 2026-09-19) | ||
| 41 | + | ||
| 42 | +- **Écart trouvé après relecture du README par l'utilisateur** : jusqu'ici, `regine import` ne laissait aucune copie de travail locale réellement exploitable après l'import (seulement un dossier temporaire à plat) — contrairement à FR-010, qui exige explicitement un chemin final construit « de façon identique dans la copie de travail locale et dans l'archive ». Il fallait un `regine checkout` séparé pour pouvoir continuer à travailler localement. | ||
| 43 | +- Corrigé : `regine import` publie désormais aussi une copie de travail locale (`push.publier_copie_locale`), en réutilisant le manifeste persistant et le verrouillage de `specs/005-checkout-reconciliation`. Le dossier archivé reste donc verrouillé à l'issue d'un import — la commande `regine reconcile` (cf. `specs/005-checkout-reconciliation/contracts/cli-checkout-reconcile.md`) devient l'étape normale pour le libérer, même sans aucun changement local (réconciliation « à vide »). | ||
| 44 | +- Cas d'une `fusion:CHEMIN` vers un dossier déjà archivé (checkout automatique préalable, ligne ci-dessus) : la publication de la copie locale ne pose pas de second verrou (le checkout de `resoudre_fusion` l'a déjà posé) et n'écrase jamais un fichier local préexistant issu d'un import précédent non encore réconcilié — seuls les fichiers de l'import en cours sont copiés/ajoutés au manifeste. | ||
modified
specs/001-import-photos/contracts/regine-core-api.md +10 -0 | @@ -50,8 +50,18 @@ Construit l'objet structuré (nombre de fichiers, taille totale, chemin de desti | ||
| 50 | 50 | |
| 51 | 51 | Transfert final vérifié depuis la copie locale déjà renommée (FR-019) — ne demande pas elle-même confirmation, c'est la responsabilité de l'appelant (façade CLI, après `preparer_resume`). Si le fichier de destination existe déjà avec le même contenu (même somme de contrôle), l'opération est un no-op silencieux (doublon, US3 scénario 3). S'il existe déjà avec un contenu **différent**, lève `CollisionNomArchiveError` plutôt que d'écraser silencieusement (FR-012 appliqué au niveau fichier, pas seulement dossier) — cf. « Notes d'implémentation » ci-dessous. Lève `EchecTransfertError` en cas d'échec de vérification d'intégrité après copie. |
| 52 | 52 | |
| 53 | +## `push.publier_copie_locale(fichiers_locaux: list[Path], dossier_local_racine: Path, dossier_local: Path, dossier_archive: Path) -> None` | |
| 54 | + | |
| 55 | +Laisse une copie de travail locale des fichiers qui viennent d'être archivés (FR-010 : « de façon identique dans la copie de travail locale et dans l'archive »), pour permettre de trier/retoucher immédiatement après l'import, sans `regine checkout` séparé. Ne DOIT être appelée qu'après un `archiver()` réussi vers `dossier_archive`. | |
| 56 | + | |
| 57 | +Réutilise le manifeste persistant et le verrouillage de `regine_core.archive` (`specs/005-checkout-reconciliation`) plutôt qu'un mécanisme parallèle (Principe VI) : ouvre/crée le manifeste de `dossier_archive`, y enregistre chaque fichier (même schéma que `checkout.checkout`), et pose le verrou s'il n'est pas déjà posé (cas d'une fusion ayant déclenché un checkout juste avant via `destination.resoudre_fusion` — jamais un second verrou). Laisse volontairement le dossier verrouillé en sortie : l'utilisateur travaille maintenant sur une copie locale, exactement comme après un checkout explicite, et devra passer par `regine_core.archive.reconciliation` (même une réconciliation « à vide ») pour le libérer. | |
| 58 | + | |
| 59 | +Contrairement à `checkout.checkout` (qui recopie l'intégralité du dossier depuis l'archive et écraserait donc un fichier local déjà en cours d'édition), ne copie que les fichiers de cet import et respecte la même protection anti-écrasement que `archiver` (`CollisionNomArchiveError`) — un fichier local déjà présent et non concerné par cet import n'est jamais touché. | |
| 60 | + | |
| 53 | 61 | ## Notes d'implémentation |
| 54 | 62 | |
| 63 | +- **Écart avec FR-010 trouvé après relecture du README par l'utilisateur (2026-09-19)** : `push.archiver` ne transférait les fichiers que vers l'archive, jamais vers la copie de travail locale — contrairement à FR-010, qui exige un chemin final construit « de façon identique dans la copie de travail locale et dans l'archive ». Après un import, aucune copie locale structurée n'existait réellement (seulement un dossier temporaire à plat, `local_tmp`, jamais nettoyé), obligeant à un `regine checkout` séparé pour retravailler localement. Corrigé par l'ajout de `push.publier_copie_locale`, appelée par `regine_cli.import_cmd` juste après `archiver`. | |
| 64 | + | |
| 55 | 65 | - **Bug trouvé par validation manuelle (2026-09-19)** : `push.archiver` écrasait silencieusement un fichier déjà archivé si un import ultérieur et séparé (ex. fusion d'une seconde carte, d'un boîtier différent, sur la même étape d'un voyage) produisait par coïncidence le même nom final avec un contenu différent — violation du principe « jamais d'écrasement silencieux » (FR-012). Corrigé en ajoutant une vérification de collision par somme de contrôle avant toute copie, avec la nouvelle exception `CollisionNomArchiveError`. Cf. `test_push_archiver.py`. |
| 56 | 66 | - **Limitation connue** : la désambiguïsation automatique de boîtiers (`copie.resoudre_collisions_boitiers`) n'opère que sur les fichiers d'un même appel à `copier_carte` (un seul import), conformément à l'US3 scénario d'acceptation #5 (« au sein d'un même import »). Une fusion ciblant un dossier peuplé par un import précédent et séparé ne redétecte pas les boîtiers déjà présents dans ce dossier ; seule la protection anti-écrasement de `push.archiver` (ci-dessus) couvre ce cas résiduel, en refusant explicitement plutôt qu'en tentant une désambiguïsation rétroactive (hors périmètre de cette itération). |
| 57 | 67 | |
| @@ -50,8 +50,18 @@ Construit l'objet structuré (nombre de fichiers, taille totale, chemin de desti | |||
| 50 | 50 | ||
| 51 | Transfert final vérifié depuis la copie locale déjà renommée (FR-019) — ne demande pas elle-même confirmation, c'est la responsabilité de l'appelant (façade CLI, après `preparer_resume`). Si le fichier de destination existe déjà avec le même contenu (même somme de contrôle), l'opération est un no-op silencieux (doublon, US3 scénario 3). S'il existe déjà avec un contenu **différent**, lève `CollisionNomArchiveError` plutôt que d'écraser silencieusement (FR-012 appliqué au niveau fichier, pas seulement dossier) — cf. « Notes d'implémentation » ci-dessous. Lève `EchecTransfertError` en cas d'échec de vérification d'intégrité après copie. | 51 | Transfert final vérifié depuis la copie locale déjà renommée (FR-019) — ne demande pas elle-même confirmation, c'est la responsabilité de l'appelant (façade CLI, après `preparer_resume`). Si le fichier de destination existe déjà avec le même contenu (même somme de contrôle), l'opération est un no-op silencieux (doublon, US3 scénario 3). S'il existe déjà avec un contenu **différent**, lève `CollisionNomArchiveError` plutôt que d'écraser silencieusement (FR-012 appliqué au niveau fichier, pas seulement dossier) — cf. « Notes d'implémentation » ci-dessous. Lève `EchecTransfertError` en cas d'échec de vérification d'intégrité après copie. |
| 52 | 52 | ||
| 53 | +## `push.publier_copie_locale(fichiers_locaux: list[Path], dossier_local_racine: Path, dossier_local: Path, dossier_archive: Path) -> None` | ||
| 54 | + | ||
| 55 | +Laisse une copie de travail locale des fichiers qui viennent d'être archivés (FR-010 : « de façon identique dans la copie de travail locale et dans l'archive »), pour permettre de trier/retoucher immédiatement après l'import, sans `regine checkout` séparé. Ne DOIT être appelée qu'après un `archiver()` réussi vers `dossier_archive`. | ||
| 56 | + | ||
| 57 | +Réutilise le manifeste persistant et le verrouillage de `regine_core.archive` (`specs/005-checkout-reconciliation`) plutôt qu'un mécanisme parallèle (Principe VI) : ouvre/crée le manifeste de `dossier_archive`, y enregistre chaque fichier (même schéma que `checkout.checkout`), et pose le verrou s'il n'est pas déjà posé (cas d'une fusion ayant déclenché un checkout juste avant via `destination.resoudre_fusion` — jamais un second verrou). Laisse volontairement le dossier verrouillé en sortie : l'utilisateur travaille maintenant sur une copie locale, exactement comme après un checkout explicite, et devra passer par `regine_core.archive.reconciliation` (même une réconciliation « à vide ») pour le libérer. | ||
| 58 | + | ||
| 59 | +Contrairement à `checkout.checkout` (qui recopie l'intégralité du dossier depuis l'archive et écraserait donc un fichier local déjà en cours d'édition), ne copie que les fichiers de cet import et respecte la même protection anti-écrasement que `archiver` (`CollisionNomArchiveError`) — un fichier local déjà présent et non concerné par cet import n'est jamais touché. | ||
| 60 | + | ||
| 53 | ## Notes d'implémentation | 61 | ## Notes d'implémentation |
| 54 | 62 | ||
| 63 | +- **Écart avec FR-010 trouvé après relecture du README par l'utilisateur (2026-09-19)** : `push.archiver` ne transférait les fichiers que vers l'archive, jamais vers la copie de travail locale — contrairement à FR-010, qui exige un chemin final construit « de façon identique dans la copie de travail locale et dans l'archive ». Après un import, aucune copie locale structurée n'existait réellement (seulement un dossier temporaire à plat, `local_tmp`, jamais nettoyé), obligeant à un `regine checkout` séparé pour retravailler localement. Corrigé par l'ajout de `push.publier_copie_locale`, appelée par `regine_cli.import_cmd` juste après `archiver`. | ||
| 64 | + | ||
| 55 | - **Bug trouvé par validation manuelle (2026-09-19)** : `push.archiver` écrasait silencieusement un fichier déjà archivé si un import ultérieur et séparé (ex. fusion d'une seconde carte, d'un boîtier différent, sur la même étape d'un voyage) produisait par coïncidence le même nom final avec un contenu différent — violation du principe « jamais d'écrasement silencieux » (FR-012). Corrigé en ajoutant une vérification de collision par somme de contrôle avant toute copie, avec la nouvelle exception `CollisionNomArchiveError`. Cf. `test_push_archiver.py`. | 65 | - **Bug trouvé par validation manuelle (2026-09-19)** : `push.archiver` écrasait silencieusement un fichier déjà archivé si un import ultérieur et séparé (ex. fusion d'une seconde carte, d'un boîtier différent, sur la même étape d'un voyage) produisait par coïncidence le même nom final avec un contenu différent — violation du principe « jamais d'écrasement silencieux » (FR-012). Corrigé en ajoutant une vérification de collision par somme de contrôle avant toute copie, avec la nouvelle exception `CollisionNomArchiveError`. Cf. `test_push_archiver.py`. |
| 56 | - **Limitation connue** : la désambiguïsation automatique de boîtiers (`copie.resoudre_collisions_boitiers`) n'opère que sur les fichiers d'un même appel à `copier_carte` (un seul import), conformément à l'US3 scénario d'acceptation #5 (« au sein d'un même import »). Une fusion ciblant un dossier peuplé par un import précédent et séparé ne redétecte pas les boîtiers déjà présents dans ce dossier ; seule la protection anti-écrasement de `push.archiver` (ci-dessus) couvre ce cas résiduel, en refusant explicitement plutôt qu'en tentant une désambiguïsation rétroactive (hors périmètre de cette itération). | 66 | - **Limitation connue** : la désambiguïsation automatique de boîtiers (`copie.resoudre_collisions_boitiers`) n'opère que sur les fichiers d'un même appel à `copier_carte` (un seul import), conformément à l'US3 scénario d'acceptation #5 (« au sein d'un même import »). Une fusion ciblant un dossier peuplé par un import précédent et séparé ne redétecte pas les boîtiers déjà présents dans ce dossier ; seule la protection anti-écrasement de `push.archiver` (ci-dessus) couvre ce cas résiduel, en refusant explicitement plutôt qu'en tentant une désambiguïsation rétroactive (hors périmètre de cette itération). |
| 57 | 67 | ||
modified
specs/001-import-photos/tasks.md +1 -1 | @@ -68,7 +68,7 @@ Ce module est le premier point d'intégration réel de trois autres specs déjà | ||
| 68 | 68 | - [X] T015 [US1] Implémenter la branche `nouveau_dossier` de `resoudre_destination` dans `packages/regine-core/src/regine_core/import_carte/destination.py` (FR-007 pour ce cas, appelle `regine_core.dossier.root.determine_root`) |
| 69 | 69 | - [X] T016 [US1] Implémenter `construire_nom_dossier`/`renommer_fichiers` dans `packages/regine-core/src/regine_core/import_carte/nommage.py` (FR-010/012/013/014) |
| 70 | 70 | - [X] T017 [US1] Implémenter `attribuer_identifiants` dans `packages/regine-core/src/regine_core/import_carte/identifiant.py` (FR-017, appelle `write_document_id`) |
| 71 | -- [X] T018 [US1] Implémenter `preparer_resume`/`archiver` dans `packages/regine-core/src/regine_core/import_carte/push.py` (FR-018/019) | |
| 71 | +- [X] T018 [US1] Implémenter `preparer_resume`/`archiver` dans `packages/regine-core/src/regine_core/import_carte/push.py` (FR-018/019) — **complété le 2026-09-19** par `publier_copie_locale`, suite à un écart avec FR-010 signalé par l'utilisateur après relecture du README (aucune copie de travail locale exploitable n'était laissée après import) ; réutilise le manifeste/verrou de specs/005 plutôt qu'un mécanisme parallèle — cf. `contracts/regine-core-api.md` | |
| 72 | 72 | - [X] T019 [US1] Orchestrer le pipeline dans `packages/regine-cli/src/regine_cli/import_cmd.py` pour `regine import <carte> --annee --titre TEXTE` (cas `nouveau_dossier` uniquement) — validé en conditions réelles (`python -m regine_cli.import_cmd import ...`) |
| 73 | 73 | |
| 74 | 74 | **Checkpoint**: User Story 1 fonctionnelle et testable indépendamment (MVP). |
| @@ -68,7 +68,7 @@ Ce module est le premier point d'intégration réel de trois autres specs déjà | |||
| 68 | - [X] T015 [US1] Implémenter la branche `nouveau_dossier` de `resoudre_destination` dans `packages/regine-core/src/regine_core/import_carte/destination.py` (FR-007 pour ce cas, appelle `regine_core.dossier.root.determine_root`) | 68 | - [X] T015 [US1] Implémenter la branche `nouveau_dossier` de `resoudre_destination` dans `packages/regine-core/src/regine_core/import_carte/destination.py` (FR-007 pour ce cas, appelle `regine_core.dossier.root.determine_root`) |
| 69 | - [X] T016 [US1] Implémenter `construire_nom_dossier`/`renommer_fichiers` dans `packages/regine-core/src/regine_core/import_carte/nommage.py` (FR-010/012/013/014) | 69 | - [X] T016 [US1] Implémenter `construire_nom_dossier`/`renommer_fichiers` dans `packages/regine-core/src/regine_core/import_carte/nommage.py` (FR-010/012/013/014) |
| 70 | - [X] T017 [US1] Implémenter `attribuer_identifiants` dans `packages/regine-core/src/regine_core/import_carte/identifiant.py` (FR-017, appelle `write_document_id`) | 70 | - [X] T017 [US1] Implémenter `attribuer_identifiants` dans `packages/regine-core/src/regine_core/import_carte/identifiant.py` (FR-017, appelle `write_document_id`) |
| 71 | -- [X] T018 [US1] Implémenter `preparer_resume`/`archiver` dans `packages/regine-core/src/regine_core/import_carte/push.py` (FR-018/019) | 71 | +- [X] T018 [US1] Implémenter `preparer_resume`/`archiver` dans `packages/regine-core/src/regine_core/import_carte/push.py` (FR-018/019) — **complété le 2026-09-19** par `publier_copie_locale`, suite à un écart avec FR-010 signalé par l'utilisateur après relecture du README (aucune copie de travail locale exploitable n'était laissée après import) ; réutilise le manifeste/verrou de specs/005 plutôt qu'un mécanisme parallèle — cf. `contracts/regine-core-api.md` |
| 72 | - [X] T019 [US1] Orchestrer le pipeline dans `packages/regine-cli/src/regine_cli/import_cmd.py` pour `regine import <carte> --annee --titre TEXTE` (cas `nouveau_dossier` uniquement) — validé en conditions réelles (`python -m regine_cli.import_cmd import ...`) | 72 | - [X] T019 [US1] Orchestrer le pipeline dans `packages/regine-cli/src/regine_cli/import_cmd.py` pour `regine import <carte> --annee --titre TEXTE` (cas `nouveau_dossier` uniquement) — validé en conditions réelles (`python -m regine_cli.import_cmd import ...`) |
| 73 | 73 | ||
| 74 | **Checkpoint**: User Story 1 fonctionnelle et testable indépendamment (MVP). | 74 | **Checkpoint**: User Story 1 fonctionnelle et testable indépendamment (MVP). |