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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,6 @@
1
+ """Allows running the package directly: python -m piximport"""
2
+
3
+ from piximport import main
4
+ import sys
5
+
6
+ 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,2 @@
1
+ [console_scripts]
2
+ piximport = piximport:main
@@ -0,0 +1 @@
1
+ questionary>=2.1.1
@@ -0,0 +1 @@
1
+ piximport