From ab056c342eda18b62f9d4b7890f40383773f3864 Mon Sep 17 00:00:00 2001 From: Sergio Date: Fri, 20 Feb 2026 17:37:12 +0100 Subject: [PATCH] normalizar-case --- .gitignore | 1 + core/constants.py | 2 +- core/pipeline.py | 47 +++++++++++++++++++++++++++++++++++ core/summary.py | 4 +++ main.py | 24 ++++++++++++++++++ processors/case_normalizer.py | 47 +++++++++++++++++++++++++++++++++++ processors/checks.py | 25 +++++++++++++++++++ 7 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 processors/case_normalizer.py diff --git a/.gitignore b/.gitignore index d258ce6..9be0f21 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ backup/ CLAUDE.md +.claude/ # ---> Python # Byte-compiled / optimized / DLL files diff --git a/core/constants.py b/core/constants.py index 8d9d070..be7cf11 100644 --- a/core/constants.py +++ b/core/constants.py @@ -1,6 +1,6 @@ # core/constants.py -IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} +IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif"} TRASH_FILES = { "thumbs.db", diff --git a/core/pipeline.py b/core/pipeline.py index 395d22f..32c20e9 100644 --- a/core/pipeline.py +++ b/core/pipeline.py @@ -20,6 +20,7 @@ from processors.checks import ( check_comicinfo, check_foreign, check_nested, + check_extension_case, ) from processors.page_normalizer import normalize_pages, preview_normalize_pages from processors.image_normalizer import ( @@ -28,6 +29,7 @@ from processors.image_normalizer import ( uniformize_images, preview_uniformize_images, ) +from processors.case_normalizer import normalize_case, preview_normalize_case class Pipeline: @@ -38,12 +40,14 @@ class Pipeline: desired_image_format: str = ".jpg", collision_policy: str = CollisionPolicy.ABORT, dry_run: bool = False, + case_mode: str = "lower", ): self.steps = steps self.desired_format = desired_format self.desired_image_format = desired_image_format self.collision_policy = collision_policy self.dry_run = dry_run + self.case_mode = case_mode def _compute_preview(self, step: str, temp_dir: str, step_results: list) -> dict: if step == "clean": @@ -82,6 +86,10 @@ class Pipeline: conversions = preview_normalize_images(temp_dir, self.desired_image_format) return {"conversions": conversions, "target_ext": self.desired_image_format} + elif step == "normalize_case": + renames = preview_normalize_case(temp_dir, self.case_mode) + return {"renames": renames, "mode": self.case_mode} + elif step == "convert": return {"target_format": self.desired_format.upper()} @@ -91,6 +99,10 @@ class Pipeline: for step in self.steps: if step in ("normalize_pages", "normalize_images", "convert_images"): return True + if step == "normalize_case": + case_r = next((r for r in step_results if r.step == "check_extension_case"), None) + if case_r and case_r.warnings: + return True if step == "convert": if needs_conversion(real_format, self.desired_format): return True @@ -134,8 +146,34 @@ class Pipeline: check_comicinfo(names), check_foreign(names), check_nested(names), + check_extension_case(names, self.case_mode), ] + # 3b. Corrección del case de la extensión exterior (renombrado en-sitio, sin repack) + if "normalize_case" in self.steps: + outer_ext = os.path.splitext(path)[1] + target_outer_ext = outer_ext.lower() if self.case_mode == "lower" else outer_ext.upper() + if outer_ext != target_outer_ext: + base_no_ext = os.path.splitext(path)[0] + new_outer_path = base_no_ext + target_outer_ext + try: + safe_outer = resolve_collision(new_outer_path, self.collision_policy) + if not self.dry_run: + os.rename(path, safe_outer) + path = safe_outer + step_results.append(StepResult( + step="normalize_case_outer", + changed=True, + details=[f"Extensión exterior corregida: {outer_ext} → {target_outer_ext}"], + )) + except (FileExistsError, OSError) as exc: + step_results.append(StepResult( + step="normalize_case_outer", + changed=False, + errors=[str(exc)], + )) + return ComicResult(original_path=path, final_path=None, steps=step_results) + # 4. Pre-flight: si ningún step necesita extracción, salir sin tocar el archivo if not self._needs_extraction(step_results, real_format, path): return ComicResult(original_path=path, final_path=path, steps=step_results) @@ -198,6 +236,15 @@ class Pipeline: if img_result.changed: any_changed = True + if "normalize_case" in self.steps: + preview = self._compute_preview("normalize_case", temp_dir, step_results) + if preview.get("renames"): + if confirm_fn is None or confirm_fn("normalize_case", preview): + case_result = normalize_case(temp_dir, self.case_mode) + step_results.append(case_result) + if case_result.changed: + any_changed = True + if "convert" in self.steps: preview = self._compute_preview("convert", temp_dir, step_results) if confirm_fn is None or confirm_fn("convert", preview): diff --git a/core/summary.py b/core/summary.py index e7a1fc0..307b4cb 100644 --- a/core/summary.py +++ b/core/summary.py @@ -58,6 +58,7 @@ class SummaryCollector: pages_normalized = count_step(["normalize_pages"]) images_converted = count_step(["normalize_images", "convert_images"]) format_converted = count_step(["convert"]) + case_normalized = count_step(["normalize_case", "normalize_case_outer"]) lines = [ _BORDER, @@ -76,6 +77,8 @@ class SummaryCollector: lines.append(f" · Imágenes convertidas : {images_converted:>3}") if format_converted: lines.append(f" · Formato convertido : {format_converted:>3}") + if case_normalized: + lines.append(f" · Case normalizado : {case_normalized:>3}") lines.append(f" Advertencias : {len(warnings_only):>3}") lines.append(f" Errores : {len(errors):>3}") if errors: @@ -137,6 +140,7 @@ class SummaryCollector: ("Numeración de páginas", "check_page_numbering", lambda w: True, ["normalize_pages"], "renumerado"), ("Imágenes mezcladas", "check_image_extensions", lambda w: True, ["normalize_images", "convert_images"], "normalizado"), ("Sin ComicInfo.xml", "check_comicinfo", lambda w: True, [], None), + ("Case de extensión", "check_extension_case", lambda w: True, ["normalize_case"], "normalizado"), ] output = [] for label, step_name, predicate, resolver_steps, fix_label in categories: diff --git a/main.py b/main.py index de513fe..8a9ec92 100644 --- a/main.py +++ b/main.py @@ -70,6 +70,20 @@ def _print_preview(step: str, preview: dict, formato: str) -> None: print(f" ... y {n - 10} más") print(f"Formato final del archivo: {fmt}") + elif step == "normalize_case": + renames = preview["renames"] + mode_label = "minúsculas" if preview["mode"] == "lower" else "mayúsculas" + n = len(renames) + print(f"Normalización de extensiones a {mode_label} ({n} ficheros):") + print(f" {'Nombre actual':<{_COL_W}} → Nombre final") + print(f" {_SEP}") + display = renames[:10] if n > 20 else renames + for orig, final in display: + print(f" {orig:<{_COL_W}} {final}") + if n > 20: + print(f" ... y {n - 10} más") + print(f"Formato final del archivo: {formato.upper()}") + elif step in ("normalize_images", "convert_images"): conversions = preview["conversions"] target_ext = preview["target_ext"].lstrip(".") @@ -117,6 +131,13 @@ def parse_args(): parser.add_argument("--uniformizar-imagenes", action="store_true") parser.add_argument("--convertir-imagenes", action="store_true") parser.add_argument("--formato-imagen", choices=["jpg", "png", "webp"], default="png") + parser.add_argument("--normalizar-case", action="store_true", dest="normalizar_case") + parser.add_argument( + "--modo-case", + choices=["lower", "upper"], + default="lower", + dest="modo_case", + ) parser.add_argument("--no-preguntar", action="store_true") parser.add_argument( @@ -171,6 +192,8 @@ def main(): steps.append("normalize_images") if args.convertir_imagenes: steps.append("convert_images") + if args.normalizar_case or args.estandarizar: + steps.append("normalize_case") if args.convertir or args.estandarizar: steps.append("convert") @@ -182,6 +205,7 @@ def main(): desired_format=args.formato, desired_image_format="." + args.formato_imagen, collision_policy=collision, + case_mode=args.modo_case, ) collector = SummaryCollector() try: diff --git a/processors/case_normalizer.py b/processors/case_normalizer.py new file mode 100644 index 0000000..537bfd4 --- /dev/null +++ b/processors/case_normalizer.py @@ -0,0 +1,47 @@ +# processors/case_normalizer.py + +import os + +from core.constants import IMAGE_EXTENSIONS +from core.result import StepResult + + +def preview_normalize_case(work_dir: str, mode: str = "lower") -> list[tuple[str, str]]: + """Devuelve lista de (nombre_actual, nombre_final) sin modificar nada.""" + result = [] + for root, _, files in os.walk(work_dir): + for f in files: + stem, ext = os.path.splitext(f) + if not ext or ext.lower() not in IMAGE_EXTENSIONS: + continue + target_ext = ext.lower() if mode == "lower" else ext.upper() + if ext != target_ext: + result.append((f, stem + target_ext)) + return sorted(result) + + +def normalize_case(work_dir: str, mode: str = "lower") -> StepResult: + """ + Renombra las extensiones de las imágenes en work_dir al case indicado. + Solo afecta a extensiones de imagen (IMAGE_EXTENSIONS); ComicInfo.xml y + otros ficheros no de imagen quedan intactos. + En Linux (FS sensible al case) el rename directo es seguro. + """ + changed = False + details = [] + + for root, _, files in os.walk(work_dir): + for f in files: + stem, ext = os.path.splitext(f) + if not ext or ext.lower() not in IMAGE_EXTENSIONS: + continue + target_ext = ext.lower() if mode == "lower" else ext.upper() + if ext == target_ext: + continue + src = os.path.join(root, f) + dst = os.path.join(root, stem + target_ext) + os.rename(src, dst) + details.append(f"{f} → {stem + target_ext}") + changed = True + + return StepResult(step="normalize_case", changed=changed, details=details) diff --git a/processors/checks.py b/processors/checks.py index 55b127f..d82ae9c 100644 --- a/processors/checks.py +++ b/processors/checks.py @@ -155,3 +155,28 @@ def check_comicinfo(names: list[str]) -> StepResult: ) warnings = [] if found else ["Falta ComicInfo.xml"] return StepResult(step="check_comicinfo", changed=False, warnings=warnings) + + +def check_extension_case(names: list[str], mode: str = "lower") -> StepResult: + """Detecta imágenes cuya extensión no está en el case esperado (lower/upper).""" + mismatches = [] + for name in names: + normalized = name.replace("\\", "/") + if normalized.endswith("/"): + continue + basename = normalized.rsplit("/", 1)[-1] + _, ext = os.path.splitext(basename) + if not ext or ext.lower() not in IMAGE_EXTENSIONS: + continue + target_ext = ext.lower() if mode == "lower" else ext.upper() + if ext != target_ext: + mismatches.append(basename) + + if not mismatches: + warnings = [] + elif len(mismatches) <= 3: + warnings = [f"Case incorrecto en extensión: {f}" for f in sorted(mismatches)] + else: + warnings = [f"Case incorrecto en extensión: {len(mismatches)} ficheros"] + + return StepResult(step="check_extension_case", changed=False, warnings=warnings)