fonzarely/regine-photos-archiverpublic⑂ Fork 0
⑂ 6a31918
Commits
⬇ Clone ▾
git clone https://git.rickub.com/fonzarely/regine-photos-archiver.git
git clone ssh://git@rickub.com/fonzarely/regine-photos-archiver.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

publie une copie locale apres import

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fabien Champigny committed 2026-09-19T21:31:44+02:00 Browse files
6a31918 parent: b8be229
modified README.md +11 -6
@@ -34,15 +34,16 @@ flowchart LR
3434
3535 subgraph LOCAL["💻 Espace de travail local"]
3636 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)"]
3838 EDIT["Tri &amp; retouche<br/>(DxO, Lightroom, votre outil…)"]
3939 RECON["Réconciliation<br/>par hash, avant tout réarchivage"]
4040 OUT --> EDIT --> RECON
4141 end
4242
4343 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
4647 RECON -->|réarchivage vérifié, jamais silencieux| DOSSIER
4748 ```
4849
@@ -128,7 +129,7 @@ uv run python -m regine_cli.import_cmd import /Volumes/CARTE_SD \
128129 --archive-root /Volumes/NAS/photos --local-root ~/regine/local
129130 ```
130131
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.
132133
133134 ### Un voyage en plusieurs étapes
134135
@@ -146,10 +147,13 @@ uv run python -m regine_cli.import_cmd import /Volumes/CARTE_ETAPE2 \
146147
147148 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.
148149
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 :
150153
151154 ```bash
152155 # 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)
153157 uv run python -m regine_cli.archive_cmd checkout \
154158 /Volumes/NAS/photos/voyage/2026-08_Montenegro/2026-08-12_Kotor \
155159 --local-dest ~/regine/local/2026-08-12_Kotor
@@ -157,7 +161,8 @@ uv run python -m regine_cli.archive_cmd checkout \
157161 # ... tri et retouche libres dans DxO, Lightroom, ou l'outil de votre choix ...
158162
159163 # 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)
161166 uv run python -m regine_cli.archive_cmd reconcile \
162167 /Volumes/NAS/photos/voyage/2026-08_Montenegro/2026-08-12_Kotor \
163168 --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 TB36 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 &amp; retouche<br/>(DxO, Lightroom, votre outil…)"]38 EDIT["Tri &amp; 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 --> RECON40 OUT --> EDIT --> RECON
41 end41 end
42 42
43 CARD --> COPY43 CARD --> COPY
44- NAME -->|premier archivage| DOSSIER44+ NAME -->|archivage vérifié| DOSSIER
45- DOSSIER -->|sort une copie de travail| OUT45+ NAME -.->|copie locale laissée immédiatement| OUT
46+ DOSSIER -->|checkout, à tout moment| OUT
46 RECON -->|réarchivage vérifié, jamais silencieux| DOSSIER47 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/local129 --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 étapes134 ### 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 ```bash154 ```bash
152 # Checkout : sort une copie de travail locale, avec verrou côté archive155 # 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_Kotor159 --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 NAS164+# 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_Kotor168 --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 (
3838 renommer_fichiers,
3939 resoudre_collision_nom,
4040 )
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
4242 from regine_core.import_carte.types import DestinationChoisie, FichierCandidat, GroupeImport
4343
4444 _NOM_CONTEXTE_DB_PAR_DEFAUT = ".regine-contexte.sqlite3"
@@ -287,7 +287,9 @@ def _traiter_groupe(
287287 return
288288
289289 archiver(chemins, local_tmp, dossier_archive)
290+ publier_copie_locale(chemins, local_tmp, dossier_local, dossier_archive)
290291 print(f"Archivé : {dossier_archive}")
292+ print(f"Copie de travail locale prête : {dossier_local}")
291293
292294
293295 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_resume41+from regine_core.import_carte.push import archiver, preparer_resume, publier_copie_locale
42 from regine_core.import_carte.types import DestinationChoisie, FichierCandidat, GroupeImport42 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 return287 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+"""
210
311 from __future__ import annotations
412
@@ -6,7 +14,9 @@ import shutil
614 from dataclasses import dataclass
715 from pathlib import Path
816
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
1020
1121
1222 class EchecTransfertError(Exception):
@@ -40,6 +50,32 @@ def preparer_resume(fichiers: list[Path], dossier_destination: Path) -> ResumeCo
4050 )
4151
4252
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+
4379 def archiver(
4480 fichiers_locaux: list[Path],
4581 dossier_local_racine: Path,
@@ -54,20 +90,51 @@ def archiver(
5490 dossier_destination.mkdir(parents=True, exist_ok=True)
5591 for source in fichiers_locaux:
5692 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+
7095
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 annotations11 from __future__ import annotations
4 12
@@ -6,7 +14,9 @@ import shutil
6 from dataclasses import dataclass14 from dataclasses import dataclass
7 from pathlib import Path15 from pathlib import Path
8 16
9-from regine_core.integrity.hash import hash_fichier_entier17+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 / relatif93+ _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)."""
23
34 from __future__ import annotations
45
56 from pathlib import Path
67
78 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+)
916
1017
1118 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
4855 archiver([fichier], local_racine, destination)
4956
5057 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 annotations4 from __future__ import annotations
4 5
5 from pathlib import Path6 from pathlib import Path
6 7
7 import pytest8 import pytest
8-from regine_core.import_carte.push import CollisionNomArchiveError, archiver9+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
1616 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.
1717 5. Renommage local (FR-013/014) et attribution de l'identifiant pérenne (FR-017).
1818 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é.
2020
2121 **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.
2323 - É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.
2424 - Espace disque insuffisant (Edge Case) : message clair avant toute copie, code non-zéro.
2525 - 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
3636 - 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).
3737 - `--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).
3838 - **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
5050
5151 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.
5252
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+
5361 ## Notes d'implémentation
5462
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+
5565 - **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`.
5666 - **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).
5767
@@ -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émentation61 ## 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à
6868 - [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`)
6969 - [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)
7070 - [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`
7272 - [X] T019 [US1] Orchestrer le pipeline dans `packages/regine-cli/src/regine_cli/import_cmd.py` pour `regine import <carte> --annee --titre TEXTE` (cas `nouveau_dossier` uniquement) — validé en conditions réelles (`python -m regine_cli.import_cmd import ...`)
7373
7474 **Checkpoint**: User Story 1 fonctionnelle et testable indépendamment (MVP).
@@ -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).