piximport 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- piximport-1.0.0/PKG-INFO +93 -0
- piximport-1.0.0/README.md +77 -0
- piximport-1.0.0/pyproject.toml +33 -0
- piximport-1.0.0/setup.cfg +4 -0
- piximport-1.0.0/src/piximport/__init__.py +858 -0
- piximport-1.0.0/src/piximport/__main__.py +6 -0
- piximport-1.0.0/src/piximport.egg-info/PKG-INFO +93 -0
- piximport-1.0.0/src/piximport.egg-info/SOURCES.txt +10 -0
- piximport-1.0.0/src/piximport.egg-info/dependency_links.txt +1 -0
- piximport-1.0.0/src/piximport.egg-info/entry_points.txt +2 -0
- piximport-1.0.0/src/piximport.egg-info/requires.txt +1 -0
- piximport-1.0.0/src/piximport.egg-info/top_level.txt +1 -0
piximport-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: piximport
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: CLI to import photos from SD cards into ~/Pictures, organised by date and camera make
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Keywords: photography,import,sd-card,exif,cli
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
11
|
+
Classifier: Operating System :: MacOS
|
|
12
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: questionary>=2.1.1
|
|
16
|
+
|
|
17
|
+
# piximport
|
|
18
|
+
|
|
19
|
+
CLI para importar fotos desde tarjetas SD a `~/Pictures`, organizadas
|
|
20
|
+
automáticamente por fecha EXIF y fabricante de cámara.
|
|
21
|
+
|
|
22
|
+
## Estructura de destino
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
~/Pictures/
|
|
26
|
+
└── 2026/
|
|
27
|
+
└── 01-15/
|
|
28
|
+
└── SONY/
|
|
29
|
+
├── SOOC/ ← JPEG, HEIF, HIF
|
|
30
|
+
├── RAW/ ← ARW, RAF, NEF, CR2, CR3, DNG, ORF, RW2
|
|
31
|
+
└── EDITED/ ← vacío, listo para tu flujo de edición
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Instalación
|
|
35
|
+
|
|
36
|
+
### Con pipx (recomendado — aislado, sin tocar el entorno global)
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pipx install piximport
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Directamente desde GitHub
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pipx install git+https://github.com/suarez605/piximport.git
|
|
46
|
+
# o una versión concreta:
|
|
47
|
+
pipx install git+https://github.com/suarez605/piximport.git@v1.0.0
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Con pip
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install piximport
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Con Homebrew (macOS)
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
brew install suarez605/tap/piximport
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Uso
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
piximport
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
El CLI detecta automáticamente las tarjetas SD conectadas, muestra un
|
|
69
|
+
selector interactivo de días a importar y copia las fotos preservando
|
|
70
|
+
los metadatos del sistema de ficheros.
|
|
71
|
+
|
|
72
|
+
## Formatos soportados
|
|
73
|
+
|
|
74
|
+
| Tipo | Extensiones |
|
|
75
|
+
|------|-------------|
|
|
76
|
+
| SOOC | `.jpg` `.jpeg` `.heif` `.heic` `.hif` |
|
|
77
|
+
| RAW | `.arw` `.raf` `.nef` `.cr2` `.cr3` `.dng` `.orf` `.rw2` |
|
|
78
|
+
|
|
79
|
+
## Requisitos
|
|
80
|
+
|
|
81
|
+
- macOS (usa `/Volumes` y `diskutil`)
|
|
82
|
+
- Python 3.11+
|
|
83
|
+
|
|
84
|
+
## Desarrollo
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
git clone https://github.com/suarez605/piximport
|
|
88
|
+
cd piximport
|
|
89
|
+
python3.11 -m venv env
|
|
90
|
+
source env/bin/activate
|
|
91
|
+
pip install -e .
|
|
92
|
+
python -m unittest tests -v
|
|
93
|
+
```
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# piximport
|
|
2
|
+
|
|
3
|
+
CLI para importar fotos desde tarjetas SD a `~/Pictures`, organizadas
|
|
4
|
+
automáticamente por fecha EXIF y fabricante de cámara.
|
|
5
|
+
|
|
6
|
+
## Estructura de destino
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
~/Pictures/
|
|
10
|
+
└── 2026/
|
|
11
|
+
└── 01-15/
|
|
12
|
+
└── SONY/
|
|
13
|
+
├── SOOC/ ← JPEG, HEIF, HIF
|
|
14
|
+
├── RAW/ ← ARW, RAF, NEF, CR2, CR3, DNG, ORF, RW2
|
|
15
|
+
└── EDITED/ ← vacío, listo para tu flujo de edición
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Instalación
|
|
19
|
+
|
|
20
|
+
### Con pipx (recomendado — aislado, sin tocar el entorno global)
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pipx install piximport
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Directamente desde GitHub
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pipx install git+https://github.com/suarez605/piximport.git
|
|
30
|
+
# o una versión concreta:
|
|
31
|
+
pipx install git+https://github.com/suarez605/piximport.git@v1.0.0
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Con pip
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install piximport
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Con Homebrew (macOS)
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
brew install suarez605/tap/piximport
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Uso
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
piximport
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
El CLI detecta automáticamente las tarjetas SD conectadas, muestra un
|
|
53
|
+
selector interactivo de días a importar y copia las fotos preservando
|
|
54
|
+
los metadatos del sistema de ficheros.
|
|
55
|
+
|
|
56
|
+
## Formatos soportados
|
|
57
|
+
|
|
58
|
+
| Tipo | Extensiones |
|
|
59
|
+
|------|-------------|
|
|
60
|
+
| SOOC | `.jpg` `.jpeg` `.heif` `.heic` `.hif` |
|
|
61
|
+
| RAW | `.arw` `.raf` `.nef` `.cr2` `.cr3` `.dng` `.orf` `.rw2` |
|
|
62
|
+
|
|
63
|
+
## Requisitos
|
|
64
|
+
|
|
65
|
+
- macOS (usa `/Volumes` y `diskutil`)
|
|
66
|
+
- Python 3.11+
|
|
67
|
+
|
|
68
|
+
## Desarrollo
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
git clone https://github.com/suarez605/piximport
|
|
72
|
+
cd piximport
|
|
73
|
+
python3.11 -m venv env
|
|
74
|
+
source env/bin/activate
|
|
75
|
+
pip install -e .
|
|
76
|
+
python -m unittest tests -v
|
|
77
|
+
```
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "piximport"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "CLI to import photos from SD cards into ~/Pictures, organised by date and camera make"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
keywords = ["photography", "import", "sd-card", "exif", "cli"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3.11",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: End Users/Desktop",
|
|
18
|
+
"Operating System :: MacOS",
|
|
19
|
+
"Topic :: Multimedia :: Graphics",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"questionary>=2.1.1",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
# After `pip install .` or `brew install`, this command becomes available globally
|
|
27
|
+
piximport = "piximport:main"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
where = ["src"]
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.package-dir]
|
|
33
|
+
"" = "src"
|
|
@@ -0,0 +1,858 @@
|
|
|
1
|
+
#!/usr/bin/env python3.11
|
|
2
|
+
"""
|
|
3
|
+
piximport — CLI para importar fotos desde tarjetas SD a ~/Pictures.
|
|
4
|
+
|
|
5
|
+
Estructura de destino:
|
|
6
|
+
~/Pictures/<AÑO>/<MM-DD>/<FABRICANTE>/<SOOC|RAW|EDITED>/
|
|
7
|
+
|
|
8
|
+
El año y la fecha provienen de los metadatos EXIF de cada foto
|
|
9
|
+
(DateTimeOriginal). Si no hay EXIF, se usa la fecha de modificación
|
|
10
|
+
del archivo como fallback.
|
|
11
|
+
|
|
12
|
+
Tipos de archivo soportados:
|
|
13
|
+
SOOC : .jpg .jpeg .heif .heic .hif
|
|
14
|
+
RAW : .arw .raf .nef .cr2 .cr3 .dng .orf .rw2
|
|
15
|
+
|
|
16
|
+
Uso:
|
|
17
|
+
python3.11 -m piximport
|
|
18
|
+
|
|
19
|
+
Requisitos:
|
|
20
|
+
Python 3.11+
|
|
21
|
+
questionary (pip install questionary)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import shutil
|
|
28
|
+
import struct
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
from collections import defaultdict
|
|
32
|
+
from datetime import datetime
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import NamedTuple
|
|
35
|
+
|
|
36
|
+
import questionary
|
|
37
|
+
from questionary import Choice
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Constantes globales
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
PICTURES_ROOT = Path.home() / "Pictures"
|
|
44
|
+
|
|
45
|
+
# Extensiones clasificadas (en minúsculas)
|
|
46
|
+
SOOC_EXTENSIONS: frozenset[str] = frozenset({".jpg", ".jpeg", ".heif", ".heic", ".hif"})
|
|
47
|
+
RAW_EXTENSIONS: frozenset[str] = frozenset(
|
|
48
|
+
{".arw", ".raf", ".nef", ".cr2", ".cr3", ".dng", ".orf", ".rw2"}
|
|
49
|
+
)
|
|
50
|
+
ALL_EXTENSIONS: frozenset[str] = SOOC_EXTENSIONS | RAW_EXTENSIONS
|
|
51
|
+
|
|
52
|
+
# Nombre de la subcarpeta cuando no se puede determinar el fabricante
|
|
53
|
+
UNKNOWN_CAMERA = "NO_CAMERA"
|
|
54
|
+
|
|
55
|
+
# Subcarpetas que se crean siempre dentro de cada directorio de fabricante
|
|
56
|
+
CAMERA_SUBDIRS = ("SOOC", "RAW", "EDITED")
|
|
57
|
+
|
|
58
|
+
# Volúmenes del sistema que se excluyen del menú de selección
|
|
59
|
+
SYSTEM_VOLUMES: frozenset[str] = frozenset(
|
|
60
|
+
{"Macintosh HD", "Data", "Preboot", "Recovery", "VM"}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Tipos de datos
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PhotoInfo(NamedTuple):
|
|
70
|
+
"""Metadatos extraídos de una foto durante el escaneo."""
|
|
71
|
+
|
|
72
|
+
path: Path # Ruta absoluta al archivo original en la SD
|
|
73
|
+
date: datetime # Fecha de captura (EXIF) o de modificación (fallback)
|
|
74
|
+
make: str # Fabricante de la cámara, ej: "SONY", "FUJIFILM"
|
|
75
|
+
category: str # "SOOC" o "RAW"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class CopyResult(NamedTuple):
|
|
79
|
+
"""Estadísticas del proceso de copia."""
|
|
80
|
+
|
|
81
|
+
copied: int
|
|
82
|
+
skipped: int
|
|
83
|
+
errors: int
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Detección de medios externos (macOS)
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def list_external_volumes() -> list[Path]:
|
|
92
|
+
"""
|
|
93
|
+
Devuelve una lista de rutas a volúmenes montados en /Volumes que no
|
|
94
|
+
pertenecen al sistema interno del Mac.
|
|
95
|
+
|
|
96
|
+
Usa `diskutil info` para identificar volúmenes internos/sistema y
|
|
97
|
+
excluirlos. Como fallback, filtra por nombres conocidos del sistema.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Lista de Path a los puntos de montaje de volúmenes externos,
|
|
101
|
+
ordenados alfabéticamente.
|
|
102
|
+
"""
|
|
103
|
+
volumes_root = Path("/Volumes")
|
|
104
|
+
if not volumes_root.exists():
|
|
105
|
+
return []
|
|
106
|
+
|
|
107
|
+
external: list[Path] = []
|
|
108
|
+
for entry in sorted(volumes_root.iterdir()):
|
|
109
|
+
if not entry.is_dir() or entry.is_symlink():
|
|
110
|
+
continue
|
|
111
|
+
if entry.name in SYSTEM_VOLUMES:
|
|
112
|
+
continue
|
|
113
|
+
if _is_internal_volume(entry):
|
|
114
|
+
continue
|
|
115
|
+
external.append(entry)
|
|
116
|
+
|
|
117
|
+
return external
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _is_internal_volume(mount_point: Path) -> bool:
|
|
121
|
+
"""
|
|
122
|
+
Consulta `diskutil info` para determinar si un volumen es interno.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
mount_point: Ruta al punto de montaje del volumen.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
True si el volumen es interno o del sistema, False si es externo.
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
result = subprocess.run(
|
|
132
|
+
["diskutil", "info", str(mount_point)],
|
|
133
|
+
capture_output=True,
|
|
134
|
+
text=True,
|
|
135
|
+
timeout=5,
|
|
136
|
+
)
|
|
137
|
+
output = result.stdout.lower()
|
|
138
|
+
if (
|
|
139
|
+
"internal: yes" in output
|
|
140
|
+
or "internal: yes" in output
|
|
141
|
+
):
|
|
142
|
+
return True
|
|
143
|
+
if "protocol: apple fabric" in output:
|
|
144
|
+
return True
|
|
145
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
146
|
+
pass
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def display_volume_menu(volumes: list[Path]) -> Path | None:
|
|
151
|
+
"""
|
|
152
|
+
Muestra un menú interactivo con los volúmenes externos disponibles y
|
|
153
|
+
devuelve el elegido por el usuario.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
volumes: Lista de rutas a volúmenes externos.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
Path al volumen seleccionado, o None si el usuario cancela.
|
|
160
|
+
"""
|
|
161
|
+
print("\n╔══════════════════════════════════════════╗")
|
|
162
|
+
print("║ PHOTO IMPORTER — SD Selector ║")
|
|
163
|
+
print("╚══════════════════════════════════════════╝\n")
|
|
164
|
+
|
|
165
|
+
if not volumes:
|
|
166
|
+
print(" No se encontraron tarjetas SD ni volúmenes externos.")
|
|
167
|
+
print(" Conecta una tarjeta SD e intenta de nuevo.\n")
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
choices = [
|
|
171
|
+
Choice(
|
|
172
|
+
title=f"{vol.name:<22} {_get_volume_size(vol):>9} {vol}",
|
|
173
|
+
value=vol,
|
|
174
|
+
)
|
|
175
|
+
for vol in volumes
|
|
176
|
+
]
|
|
177
|
+
choices.append(Choice(title="Salir", value=None))
|
|
178
|
+
|
|
179
|
+
selected = questionary.select(
|
|
180
|
+
"Selecciona el volumen a importar:",
|
|
181
|
+
choices=choices,
|
|
182
|
+
).ask()
|
|
183
|
+
|
|
184
|
+
if selected is not None:
|
|
185
|
+
print(f"\n Volumen seleccionado: {selected.name} ({selected})\n")
|
|
186
|
+
return selected
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _get_volume_size(path: Path) -> str:
|
|
190
|
+
"""
|
|
191
|
+
Devuelve el espacio total del volumen como cadena legible (ej: "64.0 GB").
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
path: Ruta al punto de montaje.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
Cadena con el tamaño formateado, o "" si no se puede obtener.
|
|
198
|
+
"""
|
|
199
|
+
try:
|
|
200
|
+
stat = os.statvfs(path)
|
|
201
|
+
return _format_bytes(stat.f_blocks * stat.f_frsize)
|
|
202
|
+
except OSError:
|
|
203
|
+
return ""
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _format_bytes(n: int) -> str:
|
|
207
|
+
"""Convierte bytes a cadena legible con la unidad apropiada (B → TB)."""
|
|
208
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
209
|
+
if n < 1024:
|
|
210
|
+
return f"{n:.1f} {unit}"
|
|
211
|
+
n /= 1024
|
|
212
|
+
return f"{n:.1f} PB"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
# Parser EXIF (sin dependencias externas)
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
# Tags EXIF relevantes
|
|
220
|
+
_TAG_MAKE = 0x010F # IFD0: fabricante de la cámara
|
|
221
|
+
_TAG_EXIF_IFD = 0x8769 # IFD0: puntero al IFD EXIF subyacente
|
|
222
|
+
_TAG_DATE_ORIGINAL = 0x9003 # EXIF IFD: fecha y hora de captura original
|
|
223
|
+
|
|
224
|
+
# Formato estándar de fecha en EXIF: "YYYY:MM:DD HH:MM:SS"
|
|
225
|
+
_EXIF_DATE_FORMAT = "%Y:%m:%d %H:%M:%S"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def read_exif(file_path: Path) -> tuple[str, datetime | None]:
|
|
229
|
+
"""
|
|
230
|
+
Extrae el fabricante de la cámara y la fecha de captura de un archivo
|
|
231
|
+
de imagen usando únicamente la biblioteca estándar de Python.
|
|
232
|
+
|
|
233
|
+
Soporta:
|
|
234
|
+
- JPEG / HEIF / HIF → busca segmento APP1 con bloque EXIF
|
|
235
|
+
- TIFF-based RAW → ARW, NEF, CR2, CR3, DNG, ORF, RW2
|
|
236
|
+
- RAF (Fujifilm) → lee el JPEG embebido en la cabecera RAF
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
file_path: Ruta al archivo de imagen.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
Tupla (fabricante, fecha).
|
|
243
|
+
- fabricante es UNKNOWN_CAMERA si no se puede determinar.
|
|
244
|
+
- fecha es None si no hay EXIF (usar mtime como fallback externo).
|
|
245
|
+
"""
|
|
246
|
+
suffix = file_path.suffix.lower()
|
|
247
|
+
try:
|
|
248
|
+
with open(file_path, "rb") as fh:
|
|
249
|
+
if suffix == ".raf":
|
|
250
|
+
return _parse_raf(fh)
|
|
251
|
+
elif suffix in (".jpg", ".jpeg", ".heif", ".heic", ".hif"):
|
|
252
|
+
return _parse_jpeg_exif(fh)
|
|
253
|
+
else:
|
|
254
|
+
# ARW, NEF, CR2, CR3, DNG, ORF, RW2 — todos son TIFF-based
|
|
255
|
+
return _parse_tiff_exif(fh)
|
|
256
|
+
except (OSError, struct.error, ValueError, UnicodeDecodeError):
|
|
257
|
+
return UNKNOWN_CAMERA, None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# — JPEG ——————————————————————————————————————————————————————————————————
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _parse_jpeg_exif(fh) -> tuple[str, datetime | None]:
|
|
264
|
+
"""
|
|
265
|
+
Localiza el segmento APP1 con magic "Exif\\x00\\x00" en un stream JPEG
|
|
266
|
+
y delega el parsing al lector de bloques TIFF.
|
|
267
|
+
|
|
268
|
+
Ignora otros segmentos APP1 (p.ej. XMP) y continúa buscando.
|
|
269
|
+
"""
|
|
270
|
+
if fh.read(2) != b"\xff\xd8":
|
|
271
|
+
return UNKNOWN_CAMERA, None
|
|
272
|
+
|
|
273
|
+
while True:
|
|
274
|
+
marker = fh.read(2)
|
|
275
|
+
if len(marker) < 2 or marker[0] != 0xFF:
|
|
276
|
+
break
|
|
277
|
+
|
|
278
|
+
raw_len = fh.read(2)
|
|
279
|
+
if len(raw_len) < 2:
|
|
280
|
+
break
|
|
281
|
+
seg_len = (
|
|
282
|
+
struct.unpack(">H", raw_len)[0] - 2
|
|
283
|
+
) # longitud excluye los 2 bytes propios
|
|
284
|
+
|
|
285
|
+
if marker[1] == 0xE1: # APP1
|
|
286
|
+
data = fh.read(seg_len)
|
|
287
|
+
if data[:6] == b"Exif\x00\x00":
|
|
288
|
+
return _parse_tiff_block(data[6:])
|
|
289
|
+
# Otro APP1 (XMP, etc.) → seguir buscando
|
|
290
|
+
else:
|
|
291
|
+
fh.seek(seg_len, 1)
|
|
292
|
+
|
|
293
|
+
return UNKNOWN_CAMERA, None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# — TIFF / RAW ————————————————————————————————————————————————————————————
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _parse_tiff_exif(fh) -> tuple[str, datetime | None]:
|
|
300
|
+
"""Lee el archivo completo como bloque TIFF (para RAW TIFF-based)."""
|
|
301
|
+
return _parse_tiff_block(fh.read())
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _parse_tiff_block(data: bytes) -> tuple[str, datetime | None]:
|
|
305
|
+
"""
|
|
306
|
+
Parsea un bloque TIFF e extrae Make y DateTimeOriginal.
|
|
307
|
+
|
|
308
|
+
Navega por IFD0 para encontrar el tag Make (0x010F) y el puntero al
|
|
309
|
+
EXIF IFD (0x8769), luego lee DateTimeOriginal (0x9003) del EXIF IFD.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
data: Bytes del bloque TIFF (debe comenzar con "II" o "MM").
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
Tupla (fabricante, fecha).
|
|
316
|
+
"""
|
|
317
|
+
if len(data) < 8:
|
|
318
|
+
return UNKNOWN_CAMERA, None
|
|
319
|
+
|
|
320
|
+
byte_order = data[:2]
|
|
321
|
+
if byte_order == b"II":
|
|
322
|
+
endian = "<"
|
|
323
|
+
elif byte_order == b"MM":
|
|
324
|
+
endian = ">"
|
|
325
|
+
else:
|
|
326
|
+
return UNKNOWN_CAMERA, None
|
|
327
|
+
|
|
328
|
+
if struct.unpack_from(f"{endian}H", data, 2)[0] != 42:
|
|
329
|
+
return UNKNOWN_CAMERA, None
|
|
330
|
+
|
|
331
|
+
ifd0_offset = struct.unpack_from(f"{endian}I", data, 4)[0]
|
|
332
|
+
make_raw, exif_ifd_offset = _read_ifd(
|
|
333
|
+
data, ifd0_offset, endian, {_TAG_MAKE, _TAG_EXIF_IFD}
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
date_raw: str | None = None
|
|
337
|
+
if exif_ifd_offset:
|
|
338
|
+
date_raw, _ = _read_ifd(data, exif_ifd_offset, endian, {_TAG_DATE_ORIGINAL})
|
|
339
|
+
|
|
340
|
+
return _build_result(make_raw, date_raw)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _read_ifd(
|
|
344
|
+
data: bytes,
|
|
345
|
+
offset: int,
|
|
346
|
+
endian: str,
|
|
347
|
+
tags_wanted: set[int],
|
|
348
|
+
) -> tuple[str | None, int | None]:
|
|
349
|
+
"""
|
|
350
|
+
Lee un IFD TIFF y extrae los valores de los tags solicitados.
|
|
351
|
+
|
|
352
|
+
Soporta tipos ASCII (2) y LONG (4). Solo extrae el primer tag ASCII
|
|
353
|
+
y el primer tag LONG encontrados entre los tags solicitados.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
data: Bytes del bloque TIFF completo.
|
|
357
|
+
offset: Posición de inicio del IFD dentro de data.
|
|
358
|
+
endian: "<" little-endian o ">" big-endian.
|
|
359
|
+
tags_wanted: Conjunto de IDs de tags a buscar.
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
Tupla (valor_ascii_o_None, valor_long_o_None).
|
|
363
|
+
"""
|
|
364
|
+
if offset + 2 > len(data):
|
|
365
|
+
return None, None
|
|
366
|
+
|
|
367
|
+
num_entries = struct.unpack_from(f"{endian}H", data, offset)[0]
|
|
368
|
+
if num_entries > 1000: # sanity check contra datos corruptos
|
|
369
|
+
return None, None
|
|
370
|
+
|
|
371
|
+
ascii_val: str | None = None
|
|
372
|
+
long_val: int | None = None
|
|
373
|
+
pos = offset + 2
|
|
374
|
+
|
|
375
|
+
for _ in range(num_entries):
|
|
376
|
+
if pos + 12 > len(data):
|
|
377
|
+
break
|
|
378
|
+
|
|
379
|
+
tag, dtype, count = struct.unpack_from(f"{endian}HHI", data, pos)
|
|
380
|
+
value_or_offset = struct.unpack_from(f"{endian}I", data, pos + 8)[0]
|
|
381
|
+
|
|
382
|
+
if tag in tags_wanted:
|
|
383
|
+
if dtype == 2: # ASCII
|
|
384
|
+
if count <= 4:
|
|
385
|
+
raw = data[pos + 8 : pos + 8 + count]
|
|
386
|
+
else:
|
|
387
|
+
raw = data[value_or_offset : value_or_offset + count]
|
|
388
|
+
ascii_val = (
|
|
389
|
+
raw.rstrip(b"\x00").decode("ascii", errors="replace").strip()
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
elif dtype == 4: # LONG — puntero a sub-IFD
|
|
393
|
+
long_val = value_or_offset
|
|
394
|
+
|
|
395
|
+
pos += 12
|
|
396
|
+
|
|
397
|
+
return ascii_val, long_val
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _build_result(
|
|
401
|
+
make_raw: str | None, date_raw: str | None
|
|
402
|
+
) -> tuple[str, datetime | None]:
|
|
403
|
+
"""
|
|
404
|
+
Normaliza el fabricante y parsea la fecha extraídos del IFD.
|
|
405
|
+
|
|
406
|
+
La normalización toma la primera palabra en mayúsculas y elimina
|
|
407
|
+
comas/puntos finales (algunos fabricantes los incluyen en el tag Make).
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
make_raw: Valor bruto del tag Make, puede ser None.
|
|
411
|
+
date_raw: Valor bruto del tag DateTimeOriginal, puede ser None.
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
Tupla (fabricante_normalizado, fecha_o_None).
|
|
415
|
+
"""
|
|
416
|
+
if make_raw:
|
|
417
|
+
make = make_raw.split()[0].upper().rstrip(",.")
|
|
418
|
+
else:
|
|
419
|
+
make = UNKNOWN_CAMERA
|
|
420
|
+
|
|
421
|
+
date: datetime | None = None
|
|
422
|
+
if date_raw:
|
|
423
|
+
try:
|
|
424
|
+
date = datetime.strptime(date_raw.strip(), _EXIF_DATE_FORMAT)
|
|
425
|
+
except ValueError:
|
|
426
|
+
pass
|
|
427
|
+
|
|
428
|
+
return make, date
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
# — RAF (Fujifilm) ————————————————————————————————————————————————————————
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _parse_raf(fh) -> tuple[str, datetime | None]:
|
|
435
|
+
"""
|
|
436
|
+
Parsea archivos RAF de Fujifilm extrayendo el JPEG embebido.
|
|
437
|
+
|
|
438
|
+
Estructura de la cabecera RAF (offsets big-endian):
|
|
439
|
+
0x00 16 bytes magic "FUJIFILMCCD-RAW "
|
|
440
|
+
0x10 4 bytes versión del formato
|
|
441
|
+
0x14 8 bytes número de cámara
|
|
442
|
+
0x1C 32 bytes nombre del modelo
|
|
443
|
+
0x3C 4 bytes versión de directorio
|
|
444
|
+
0x40 20 bytes reservado
|
|
445
|
+
0x54 4 bytes offset al JPEG embebido
|
|
446
|
+
0x58 4 bytes longitud del JPEG embebido
|
|
447
|
+
|
|
448
|
+
El EXIF se encuentra dentro del JPEG embebido, por lo que se redirige
|
|
449
|
+
el parsing a _parse_jpeg_exif().
|
|
450
|
+
"""
|
|
451
|
+
# 0x58 + 4 = 92 bytes mínimos para leer ambos campos
|
|
452
|
+
header = fh.read(92)
|
|
453
|
+
if len(header) < 92:
|
|
454
|
+
return UNKNOWN_CAMERA, None
|
|
455
|
+
|
|
456
|
+
if header[:16] != b"FUJIFILMCCD-RAW ":
|
|
457
|
+
return UNKNOWN_CAMERA, None
|
|
458
|
+
|
|
459
|
+
jpeg_offset = struct.unpack_from(">I", header, 0x54)[0]
|
|
460
|
+
jpeg_length = struct.unpack_from(">I", header, 0x58)[0]
|
|
461
|
+
|
|
462
|
+
if jpeg_offset == 0 or jpeg_length == 0:
|
|
463
|
+
return UNKNOWN_CAMERA, None
|
|
464
|
+
|
|
465
|
+
fh.seek(jpeg_offset)
|
|
466
|
+
jpeg_data = fh.read(jpeg_length)
|
|
467
|
+
|
|
468
|
+
import io
|
|
469
|
+
|
|
470
|
+
return _parse_jpeg_exif(io.BytesIO(jpeg_data))
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
# ---------------------------------------------------------------------------
|
|
474
|
+
# Clasificación y escaneo de archivos
|
|
475
|
+
# ---------------------------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def classify_file(path: Path) -> str | None:
|
|
479
|
+
"""
|
|
480
|
+
Determina la categoría de importación de un archivo según su extensión.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
path: Ruta al archivo (solo se examina la extensión).
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
"SOOC" para JPEG/HEIF/HIF, "RAW" para formatos RAW, None si se ignora.
|
|
487
|
+
"""
|
|
488
|
+
suffix = path.suffix.lower()
|
|
489
|
+
if suffix in SOOC_EXTENSIONS:
|
|
490
|
+
return "SOOC"
|
|
491
|
+
if suffix in RAW_EXTENSIONS:
|
|
492
|
+
return "RAW"
|
|
493
|
+
return None
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def scan_volume(volume: Path) -> list[PhotoInfo]:
|
|
497
|
+
"""
|
|
498
|
+
Escanea recursivamente un volumen y recopila los metadatos de todas
|
|
499
|
+
las fotos importables encontradas.
|
|
500
|
+
|
|
501
|
+
Usa os.scandir en lugar de pathlib.rglob para mayor rendimiento en
|
|
502
|
+
volúmenes grandes con miles de archivos.
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
volume: Ruta raíz del volumen a escanear.
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
Lista de PhotoInfo ordenada por fecha de captura (más antigua primero).
|
|
509
|
+
"""
|
|
510
|
+
photos: list[PhotoInfo] = []
|
|
511
|
+
print(f" Escaneando {volume} ...")
|
|
512
|
+
_scan_dir(volume, photos)
|
|
513
|
+
photos.sort(key=lambda p: p.date)
|
|
514
|
+
print(f" {len(photos)} foto(s) encontrada(s).\n")
|
|
515
|
+
return photos
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _scan_dir(directory: Path, results: list[PhotoInfo]) -> None:
|
|
519
|
+
"""
|
|
520
|
+
Escanea recursivamente un directorio acumulando fotos en results.
|
|
521
|
+
|
|
522
|
+
Ignora archivos y directorios que comiencen por "." (sistema/ocultos).
|
|
523
|
+
|
|
524
|
+
Args:
|
|
525
|
+
directory: Directorio a escanear.
|
|
526
|
+
results: Lista donde se acumulan los PhotoInfo encontrados.
|
|
527
|
+
"""
|
|
528
|
+
try:
|
|
529
|
+
with os.scandir(directory) as it:
|
|
530
|
+
for entry in it:
|
|
531
|
+
if entry.name.startswith("."):
|
|
532
|
+
continue
|
|
533
|
+
if entry.is_dir(follow_symlinks=False):
|
|
534
|
+
_scan_dir(Path(entry.path), results)
|
|
535
|
+
elif entry.is_file(follow_symlinks=False):
|
|
536
|
+
path = Path(entry.path)
|
|
537
|
+
category = classify_file(path)
|
|
538
|
+
if category is None:
|
|
539
|
+
continue
|
|
540
|
+
|
|
541
|
+
make, date = read_exif(path)
|
|
542
|
+
|
|
543
|
+
# Fallback de fecha: mtime del archivo en el sistema de ficheros
|
|
544
|
+
if date is None:
|
|
545
|
+
date = datetime.fromtimestamp(entry.stat().st_mtime)
|
|
546
|
+
|
|
547
|
+
results.append(
|
|
548
|
+
PhotoInfo(path=path, date=date, make=make, category=category)
|
|
549
|
+
)
|
|
550
|
+
except PermissionError:
|
|
551
|
+
pass
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
# ---------------------------------------------------------------------------
|
|
555
|
+
# Selector interactivo de fotos
|
|
556
|
+
# ---------------------------------------------------------------------------
|
|
557
|
+
|
|
558
|
+
# Tipo alias para el agrupamiento: año → {MM-DD → [PhotoInfo]}
|
|
559
|
+
_Groups = dict[int, dict[str, list[PhotoInfo]]]
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _group_by_date(photos: list[PhotoInfo]) -> _Groups:
|
|
563
|
+
"""
|
|
564
|
+
Agrupa una lista de fotos por año y por fecha (MM-DD).
|
|
565
|
+
|
|
566
|
+
Args:
|
|
567
|
+
photos: Lista de PhotoInfo a agrupar.
|
|
568
|
+
|
|
569
|
+
Returns:
|
|
570
|
+
Diccionario {año: {MM-DD: [PhotoInfo, ...]}} ordenado cronológicamente.
|
|
571
|
+
"""
|
|
572
|
+
groups: _Groups = defaultdict(lambda: defaultdict(list))
|
|
573
|
+
for photo in photos:
|
|
574
|
+
day_key = photo.date.strftime("%m-%d")
|
|
575
|
+
groups[photo.date.year][day_key].append(photo)
|
|
576
|
+
# Convertir a dicts ordenados para iteración predecible
|
|
577
|
+
return {year: dict(sorted(days.items())) for year, days in sorted(groups.items())}
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def select_photos(photos: list[PhotoInfo]) -> list[PhotoInfo]:
|
|
581
|
+
"""
|
|
582
|
+
Muestra un selector interactivo de checkbox que permite al usuario
|
|
583
|
+
elegir qué días importar, agrupados por año.
|
|
584
|
+
|
|
585
|
+
El selector muestra:
|
|
586
|
+
- Una entrada por cada año (como separador visual no seleccionable)
|
|
587
|
+
- Una entrada por cada día dentro de cada año, con conteo de fotos
|
|
588
|
+
y fabricantes presentes
|
|
589
|
+
|
|
590
|
+
El usuario puede marcar/desmarcar días individuales con Espacio, y
|
|
591
|
+
confirmar con Enter. Para desmarcar un año completo basta con
|
|
592
|
+
desmarcar todos sus días.
|
|
593
|
+
|
|
594
|
+
Args:
|
|
595
|
+
photos: Lista completa de fotos escaneadas.
|
|
596
|
+
|
|
597
|
+
Returns:
|
|
598
|
+
Lista filtrada de PhotoInfo correspondiente a los días seleccionados.
|
|
599
|
+
Devuelve lista vacía si el usuario cancela (Ctrl+C).
|
|
600
|
+
"""
|
|
601
|
+
groups = _group_by_date(photos)
|
|
602
|
+
|
|
603
|
+
choices: list[Choice | questionary.Separator] = []
|
|
604
|
+
|
|
605
|
+
for year, days in groups.items():
|
|
606
|
+
year_total = sum(len(v) for v in days.values())
|
|
607
|
+
choices.append(questionary.Separator(f"\n ── {year} ({year_total} fotos) ──"))
|
|
608
|
+
|
|
609
|
+
for day_key, day_photos in days.items():
|
|
610
|
+
makes = sorted({p.make for p in day_photos})
|
|
611
|
+
makes_str = ", ".join(makes)
|
|
612
|
+
n = len(day_photos)
|
|
613
|
+
label = f"{year} / {day_key} — {n} foto{'s' if n != 1 else ''} [{makes_str}]"
|
|
614
|
+
choices.append(Choice(title=label, value=day_key, checked=True))
|
|
615
|
+
|
|
616
|
+
print()
|
|
617
|
+
selected_keys: list[str] | None = questionary.checkbox(
|
|
618
|
+
"Selecciona los días a importar (Espacio = marcar/desmarcar, Enter = confirmar):",
|
|
619
|
+
choices=choices,
|
|
620
|
+
).ask()
|
|
621
|
+
|
|
622
|
+
# ask() devuelve None si el usuario pulsa Ctrl+C
|
|
623
|
+
if selected_keys is None:
|
|
624
|
+
return []
|
|
625
|
+
|
|
626
|
+
# El valor de cada Choice es solo "MM-DD"; puede haber el mismo MM-DD en
|
|
627
|
+
# distintos años, así que filtramos por (año, MM-DD) combinados
|
|
628
|
+
selected_set: set[tuple[int, str]] = set()
|
|
629
|
+
for year, days in groups.items():
|
|
630
|
+
for day_key in days:
|
|
631
|
+
if day_key in selected_keys:
|
|
632
|
+
selected_set.add((year, day_key))
|
|
633
|
+
|
|
634
|
+
return [
|
|
635
|
+
p for p in photos if (p.date.year, p.date.strftime("%m-%d")) in selected_set
|
|
636
|
+
]
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
# ---------------------------------------------------------------------------
|
|
640
|
+
# Construcción de rutas de destino
|
|
641
|
+
# ---------------------------------------------------------------------------
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def build_dest_path(photo: PhotoInfo, dest_root: Path) -> Path:
|
|
645
|
+
"""
|
|
646
|
+
Construye la ruta de destino para una foto y crea los directorios
|
|
647
|
+
necesarios si no existen.
|
|
648
|
+
|
|
649
|
+
La estructura sigue el esquema:
|
|
650
|
+
<dest_root>/<AÑO>/<MM-DD>/<FABRICANTE>/<SOOC|RAW|EDITED>/
|
|
651
|
+
|
|
652
|
+
El año y la fecha derivan exclusivamente de photo.date (fecha EXIF o
|
|
653
|
+
mtime), nunca de la fecha actual del sistema.
|
|
654
|
+
|
|
655
|
+
Args:
|
|
656
|
+
photo: Metadatos de la foto.
|
|
657
|
+
dest_root: Carpeta raíz de destino (ej: ~/Pictures).
|
|
658
|
+
|
|
659
|
+
Returns:
|
|
660
|
+
Path completo al archivo de destino (sin crear el archivo).
|
|
661
|
+
"""
|
|
662
|
+
year_dir = str(photo.date.year)
|
|
663
|
+
date_dir = photo.date.strftime("%m-%d")
|
|
664
|
+
make_dir = photo.make.upper()
|
|
665
|
+
|
|
666
|
+
dest_dir = dest_root / year_dir / date_dir / make_dir / photo.category
|
|
667
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
668
|
+
|
|
669
|
+
# Garantizar que SOOC, RAW y EDITED siempre existen como hermanos
|
|
670
|
+
_ensure_camera_subdirs(dest_root / year_dir / date_dir / make_dir)
|
|
671
|
+
|
|
672
|
+
return dest_dir / photo.path.name
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _ensure_camera_subdirs(camera_dir: Path) -> None:
|
|
676
|
+
"""
|
|
677
|
+
Crea las subcarpetas SOOC, RAW y EDITED dentro del directorio del
|
|
678
|
+
fabricante si aún no existen.
|
|
679
|
+
|
|
680
|
+
Args:
|
|
681
|
+
camera_dir: Directorio del fabricante (ej: ~/Pictures/2026/01-15/SONY).
|
|
682
|
+
"""
|
|
683
|
+
for subdir in CAMERA_SUBDIRS:
|
|
684
|
+
(camera_dir / subdir).mkdir(exist_ok=True)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
# ---------------------------------------------------------------------------
|
|
688
|
+
# Manejo de colisiones de nombres
|
|
689
|
+
# ---------------------------------------------------------------------------
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def resolve_collision(dest_path: Path, source_path: Path) -> Path:
|
|
693
|
+
"""
|
|
694
|
+
Resuelve colisiones de nombre de archivo en el destino.
|
|
695
|
+
|
|
696
|
+
Estrategia:
|
|
697
|
+
1. Si el destino no existe → devolver dest_path tal cual.
|
|
698
|
+
2. Si el destino existe y tiene el mismo tamaño que la fuente →
|
|
699
|
+
asumir duplicado, devolver la misma ruta (el llamador lo saltará).
|
|
700
|
+
3. Si el destino existe con distinto tamaño → añadir sufijo numérico
|
|
701
|
+
(_1, _2, ...) hasta encontrar un nombre libre.
|
|
702
|
+
|
|
703
|
+
Args:
|
|
704
|
+
dest_path: Ruta de destino propuesta.
|
|
705
|
+
source_path: Ruta al archivo fuente.
|
|
706
|
+
|
|
707
|
+
Returns:
|
|
708
|
+
Ruta definitiva donde debe copiarse el archivo.
|
|
709
|
+
"""
|
|
710
|
+
if not dest_path.exists():
|
|
711
|
+
return dest_path
|
|
712
|
+
|
|
713
|
+
if dest_path.stat().st_size == source_path.stat().st_size:
|
|
714
|
+
return dest_path # Señal de duplicado para el llamador
|
|
715
|
+
|
|
716
|
+
stem, suffix, parent = dest_path.stem, dest_path.suffix, dest_path.parent
|
|
717
|
+
counter = 1
|
|
718
|
+
while True:
|
|
719
|
+
candidate = parent / f"{stem}_{counter}{suffix}"
|
|
720
|
+
if not candidate.exists():
|
|
721
|
+
return candidate
|
|
722
|
+
counter += 1
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
# ---------------------------------------------------------------------------
|
|
726
|
+
# Motor de copia
|
|
727
|
+
# ---------------------------------------------------------------------------
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def copy_photos(photos: list[PhotoInfo], dest_root: Path) -> CopyResult:
|
|
731
|
+
"""
|
|
732
|
+
Copia todas las fotos al árbol de destino mostrando progreso en tiempo real.
|
|
733
|
+
|
|
734
|
+
Para cada foto:
|
|
735
|
+
- Construye la ruta de destino con build_dest_path()
|
|
736
|
+
- Resuelve colisiones de nombre con resolve_collision()
|
|
737
|
+
- Si el archivo ya existe con el mismo tamaño, lo salta
|
|
738
|
+
- Si no, lo copia preservando metadatos del FS con shutil.copy2()
|
|
739
|
+
|
|
740
|
+
Args:
|
|
741
|
+
photos: Lista de fotos a copiar (ya filtrada por el selector).
|
|
742
|
+
dest_root: Carpeta raíz de destino (ej: ~/Pictures).
|
|
743
|
+
|
|
744
|
+
Returns:
|
|
745
|
+
CopyResult con el conteo de archivos copiados, saltados y errores.
|
|
746
|
+
"""
|
|
747
|
+
total = len(photos)
|
|
748
|
+
copied = skipped = errors = 0
|
|
749
|
+
width = len(str(total))
|
|
750
|
+
|
|
751
|
+
for idx, photo in enumerate(photos, start=1):
|
|
752
|
+
prefix = f" [{idx:>{width}}/{total}]"
|
|
753
|
+
try:
|
|
754
|
+
proposed = build_dest_path(photo, dest_root)
|
|
755
|
+
final = resolve_collision(proposed, photo.path)
|
|
756
|
+
|
|
757
|
+
if final == proposed and proposed.exists():
|
|
758
|
+
print(f"{prefix} SALTAR {photo.path.name} (ya existe)")
|
|
759
|
+
skipped += 1
|
|
760
|
+
continue
|
|
761
|
+
|
|
762
|
+
shutil.copy2(photo.path, final)
|
|
763
|
+
print(
|
|
764
|
+
f"{prefix} COPIAR {photo.path.name} → {final.relative_to(dest_root)}"
|
|
765
|
+
)
|
|
766
|
+
copied += 1
|
|
767
|
+
|
|
768
|
+
except (OSError, shutil.Error) as exc:
|
|
769
|
+
print(f"{prefix} ERROR {photo.path.name}: {exc}")
|
|
770
|
+
errors += 1
|
|
771
|
+
|
|
772
|
+
return CopyResult(copied=copied, skipped=skipped, errors=errors)
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
# ---------------------------------------------------------------------------
|
|
776
|
+
# Resumen final
|
|
777
|
+
# ---------------------------------------------------------------------------
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def print_summary(result: CopyResult, dest_root: Path) -> None:
|
|
781
|
+
"""
|
|
782
|
+
Imprime una tabla con las estadísticas del proceso de importación.
|
|
783
|
+
|
|
784
|
+
Args:
|
|
785
|
+
result: Estadísticas devueltas por copy_photos().
|
|
786
|
+
dest_root: Carpeta raíz de destino usada (para informar al usuario).
|
|
787
|
+
"""
|
|
788
|
+
total = result.copied + result.skipped + result.errors
|
|
789
|
+
print("\n╔══════════════════════════════════════════╗")
|
|
790
|
+
print("║ Importación completada ║")
|
|
791
|
+
print("╠══════════════════════════════════════════╣")
|
|
792
|
+
print(f"║ Total procesadas : {total:<22}║")
|
|
793
|
+
print(f"║ Copiadas : {result.copied:<22}║")
|
|
794
|
+
print(f"║ Saltadas (dup.) : {result.skipped:<22}║")
|
|
795
|
+
print(f"║ Errores : {result.errors:<22}║")
|
|
796
|
+
print(f"║ Destino : {str(dest_root):<22}║")
|
|
797
|
+
print("╚══════════════════════════════════════════╝\n")
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
# ---------------------------------------------------------------------------
|
|
801
|
+
# Punto de entrada
|
|
802
|
+
# ---------------------------------------------------------------------------
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def main() -> int:
|
|
806
|
+
"""
|
|
807
|
+
Función principal del CLI. Orquesta el flujo completo:
|
|
808
|
+
detección → selección de volumen → escaneo → filtro de fechas → copia.
|
|
809
|
+
|
|
810
|
+
Returns:
|
|
811
|
+
Código de salida: 0 éxito, 1 sin fotos/cancelado, 2 errores de copia.
|
|
812
|
+
"""
|
|
813
|
+
# 1. Detectar volúmenes externos
|
|
814
|
+
volumes = list_external_volumes()
|
|
815
|
+
|
|
816
|
+
# 2. Menú de selección de volumen
|
|
817
|
+
selected_volume = display_volume_menu(volumes)
|
|
818
|
+
if selected_volume is None:
|
|
819
|
+
print(" Sin cambios. Hasta luego.\n")
|
|
820
|
+
return 0
|
|
821
|
+
|
|
822
|
+
# 3. Escanear el volumen (las fotos se ordenan por fecha EXIF)
|
|
823
|
+
photos = scan_volume(selected_volume)
|
|
824
|
+
if not photos:
|
|
825
|
+
print(" No se encontraron fotos con formatos soportados en el volumen.\n")
|
|
826
|
+
return 1
|
|
827
|
+
|
|
828
|
+
# 4. Selector interactivo: el usuario elige qué días importar
|
|
829
|
+
photos = select_photos(photos)
|
|
830
|
+
if not photos:
|
|
831
|
+
print("\n Sin selección. No se copiará nada.\n")
|
|
832
|
+
return 1
|
|
833
|
+
|
|
834
|
+
# 5. Confirmación final con conteo real de la selección
|
|
835
|
+
print(f"\n {len(photos)} foto(s) seleccionadas → destino: {PICTURES_ROOT}\n")
|
|
836
|
+
try:
|
|
837
|
+
confirm = input(" ¿Continuar con la importación? [S/n]: ").strip().lower()
|
|
838
|
+
except (EOFError, KeyboardInterrupt):
|
|
839
|
+
print("\n Cancelado.")
|
|
840
|
+
return 0
|
|
841
|
+
|
|
842
|
+
if confirm not in ("", "s", "si", "sí", "y", "yes"):
|
|
843
|
+
print(" Operación cancelada.\n")
|
|
844
|
+
return 0
|
|
845
|
+
|
|
846
|
+
print()
|
|
847
|
+
|
|
848
|
+
# 6. Copiar fotos
|
|
849
|
+
result = copy_photos(photos, PICTURES_ROOT)
|
|
850
|
+
|
|
851
|
+
# 7. Mostrar resumen
|
|
852
|
+
print_summary(result, PICTURES_ROOT)
|
|
853
|
+
|
|
854
|
+
return 0 if result.errors == 0 else 2
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
if __name__ == "__main__":
|
|
858
|
+
sys.exit(main())
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: piximport
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: CLI to import photos from SD cards into ~/Pictures, organised by date and camera make
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Keywords: photography,import,sd-card,exif,cli
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
11
|
+
Classifier: Operating System :: MacOS
|
|
12
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: questionary>=2.1.1
|
|
16
|
+
|
|
17
|
+
# piximport
|
|
18
|
+
|
|
19
|
+
CLI para importar fotos desde tarjetas SD a `~/Pictures`, organizadas
|
|
20
|
+
automáticamente por fecha EXIF y fabricante de cámara.
|
|
21
|
+
|
|
22
|
+
## Estructura de destino
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
~/Pictures/
|
|
26
|
+
└── 2026/
|
|
27
|
+
└── 01-15/
|
|
28
|
+
└── SONY/
|
|
29
|
+
├── SOOC/ ← JPEG, HEIF, HIF
|
|
30
|
+
├── RAW/ ← ARW, RAF, NEF, CR2, CR3, DNG, ORF, RW2
|
|
31
|
+
└── EDITED/ ← vacío, listo para tu flujo de edición
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Instalación
|
|
35
|
+
|
|
36
|
+
### Con pipx (recomendado — aislado, sin tocar el entorno global)
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pipx install piximport
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Directamente desde GitHub
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pipx install git+https://github.com/suarez605/piximport.git
|
|
46
|
+
# o una versión concreta:
|
|
47
|
+
pipx install git+https://github.com/suarez605/piximport.git@v1.0.0
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Con pip
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install piximport
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Con Homebrew (macOS)
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
brew install suarez605/tap/piximport
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Uso
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
piximport
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
El CLI detecta automáticamente las tarjetas SD conectadas, muestra un
|
|
69
|
+
selector interactivo de días a importar y copia las fotos preservando
|
|
70
|
+
los metadatos del sistema de ficheros.
|
|
71
|
+
|
|
72
|
+
## Formatos soportados
|
|
73
|
+
|
|
74
|
+
| Tipo | Extensiones |
|
|
75
|
+
|------|-------------|
|
|
76
|
+
| SOOC | `.jpg` `.jpeg` `.heif` `.heic` `.hif` |
|
|
77
|
+
| RAW | `.arw` `.raf` `.nef` `.cr2` `.cr3` `.dng` `.orf` `.rw2` |
|
|
78
|
+
|
|
79
|
+
## Requisitos
|
|
80
|
+
|
|
81
|
+
- macOS (usa `/Volumes` y `diskutil`)
|
|
82
|
+
- Python 3.11+
|
|
83
|
+
|
|
84
|
+
## Desarrollo
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
git clone https://github.com/suarez605/piximport
|
|
88
|
+
cd piximport
|
|
89
|
+
python3.11 -m venv env
|
|
90
|
+
source env/bin/activate
|
|
91
|
+
pip install -e .
|
|
92
|
+
python -m unittest tests -v
|
|
93
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/piximport/__init__.py
|
|
4
|
+
src/piximport/__main__.py
|
|
5
|
+
src/piximport.egg-info/PKG-INFO
|
|
6
|
+
src/piximport.egg-info/SOURCES.txt
|
|
7
|
+
src/piximport.egg-info/dependency_links.txt
|
|
8
|
+
src/piximport.egg-info/entry_points.txt
|
|
9
|
+
src/piximport.egg-info/requires.txt
|
|
10
|
+
src/piximport.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
questionary>=2.1.1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
piximport
|