From e3d39d12bf3794a3d872cb2eaffef2530aef9c0f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Jan 2026 10:38:37 +0000 Subject: [PATCH 1/4] Add epub rename tool New Python script to rename epub files to 'Author - Title.epub' format using metadata extracted from the epub file. --- README.md | 8 ++++ python/rename_epubs.py | 87 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 python/rename_epubs.py diff --git a/README.md b/README.md index 30a5768..946c414 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,14 @@ Rename PDF invoice files from "YYYY-MM-DD - name - invoice_no.pdf" to "YYYY-MM-D uv run https://kzuraw.github.io/tools/python/rename_invoices.py [--dry-run] ``` +### rename_epubs.py + +Rename epub files to "Author - Title.epub" format using metadata from the epub file + +```bash +uv run https://kzuraw.github.io/tools/python/rename_epubs.py [--dry-run] +``` + ## Deployment This project is automatically deployed to GitHub Pages. Any changes pushed to the main branch will be reflected at [https://kzuraw.github.io/tools/](https://kzuraw.github.io/tools/). diff --git a/python/rename_epubs.py b/python/rename_epubs.py new file mode 100644 index 0000000..787166d --- /dev/null +++ b/python/rename_epubs.py @@ -0,0 +1,87 @@ +# /// script +# requires-python = ">=3.14" +# dependencies = [ +# "click", +# ] +# /// + +"""Rename epub files to 'Author - Title.epub' format.""" + +import xml.etree.ElementTree as ET +import zipfile +from pathlib import Path + +import click + + +def extract_metadata(epub_path: Path) -> tuple[str | None, str | None]: + """Extract author and title from epub metadata.""" + try: + with zipfile.ZipFile(epub_path, "r") as zf: + # Find the OPF file (contains metadata) + container = zf.read("META-INF/container.xml") + container_root = ET.fromstring(container) + ns = {"c": "urn:oasis:names:tc:opendocument:xmlns:container"} + rootfile = container_root.find(".//c:rootfile", ns) + if rootfile is None: + return None, None + opf_path = rootfile.get("full-path") + + # Parse OPF for metadata + opf_content = zf.read(opf_path) + opf_root = ET.fromstring(opf_content) + dc = {"dc": "http://purl.org/dc/elements/1.1/"} + + title_el = opf_root.find(".//dc:title", dc) + creator_el = opf_root.find(".//dc:creator", dc) + + title = title_el.text.strip() if title_el is not None and title_el.text else None + author = creator_el.text.strip() if creator_el is not None and creator_el.text else None + + return author, title + except Exception: + return None, None + + +def sanitize_filename(name: str) -> str: + """Remove characters that are invalid in filenames.""" + invalid_chars = '<>:"/\\|?*' + for char in invalid_chars: + name = name.replace(char, "") + return name.strip() + + +@click.command() +@click.argument("folder", type=click.Path(exists=True, file_okay=False, path_type=Path)) +@click.option("--dry-run", is_flag=True, help="Preview changes without renaming") +def main(folder: Path, dry_run: bool): + """Rename epub files in FOLDER to 'Author - Title.epub' format.""" + epubs = list(folder.glob("*.epub")) + + if not epubs: + click.echo("No epub files found.") + return + + for epub_path in epubs: + author, title = extract_metadata(epub_path) + + if not author or not title: + click.echo(f"Skipping {epub_path.name}: missing metadata") + continue + + new_name = sanitize_filename(f"{author} - {title}.epub") + new_path = folder / new_name + + if new_path == epub_path: + click.echo(f"Already named correctly: {epub_path.name}") + continue + + if dry_run: + click.echo(f"Would rename: {epub_path.name} -> {new_name}") + else: + epub_path.rename(new_path) + click.echo(f"Renamed: {epub_path.name} -> {new_name}") + + +if __name__ == "__main__": + main() From db1c7aecb692279dd9f3077e2cf8d258357158ba Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Jan 2026 10:48:58 +0000 Subject: [PATCH 2/4] Improve error handling in epub rename tool - Add specific exception handling for BadZipFile, ParseError, KeyError - Log errors to stderr for better debugging - Validate opf_path before using it --- python/rename_epubs.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/python/rename_epubs.py b/python/rename_epubs.py index 787166d..9a858f9 100644 --- a/python/rename_epubs.py +++ b/python/rename_epubs.py @@ -26,6 +26,8 @@ def extract_metadata(epub_path: Path) -> tuple[str | None, str | None]: if rootfile is None: return None, None opf_path = rootfile.get("full-path") + if not opf_path: + return None, None # Parse OPF for metadata opf_content = zf.read(opf_path) @@ -39,7 +41,11 @@ def extract_metadata(epub_path: Path) -> tuple[str | None, str | None]: author = creator_el.text.strip() if creator_el is not None and creator_el.text else None return author, title - except Exception: + except (zipfile.BadZipFile, ET.ParseError, KeyError) as e: + click.echo(f"Error reading {epub_path.name}: {e}", err=True) + return None, None + except Exception as e: + click.echo(f"Unexpected error reading {epub_path.name}: {e}", err=True) return None, None From 742d80fde9e24ffd320ebb55bb8680bd68307eaa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Jan 2026 10:55:12 +0000 Subject: [PATCH 3/4] Add consistent output style and summary statistics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use → arrow for consistency with rename_invoices.py - Add renamed/skipped counters - Display summary at end of execution --- python/rename_epubs.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/python/rename_epubs.py b/python/rename_epubs.py index 9a858f9..f02cce4 100644 --- a/python/rename_epubs.py +++ b/python/rename_epubs.py @@ -68,11 +68,15 @@ def main(folder: Path, dry_run: bool): click.echo("No epub files found.") return + renamed_count = 0 + skipped_count = 0 + for epub_path in epubs: author, title = extract_metadata(epub_path) if not author or not title: click.echo(f"Skipping {epub_path.name}: missing metadata") + skipped_count += 1 continue new_name = sanitize_filename(f"{author} - {title}.epub") @@ -83,10 +87,18 @@ def main(folder: Path, dry_run: bool): continue if dry_run: - click.echo(f"Would rename: {epub_path.name} -> {new_name}") + click.echo(f"Would rename: {epub_path.name} → {new_name}") else: epub_path.rename(new_path) - click.echo(f"Renamed: {epub_path.name} -> {new_name}") + click.echo(f"Renamed: {epub_path.name} → {new_name}") + renamed_count += 1 + + if renamed_count == 0 and skipped_count == 0: + click.echo("\nNo files needed renaming.") + elif dry_run: + click.echo(f"\nDry run complete. {renamed_count} file(s) would be renamed, {skipped_count} skipped.") + else: + click.echo(f"\nRenamed {renamed_count} file(s), {skipped_count} skipped.") if __name__ == "__main__": From 850cc84dd5f436b2531b46908d03a0c0b372d51f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Jan 2026 11:03:55 +0000 Subject: [PATCH 4/4] Fix whitespace handling and quote consistency - Normalize multiple consecutive spaces to single space in filenames - Use double quotes consistently throughout the file --- python/rename_epubs.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/python/rename_epubs.py b/python/rename_epubs.py index f02cce4..dafd105 100644 --- a/python/rename_epubs.py +++ b/python/rename_epubs.py @@ -7,6 +7,7 @@ """Rename epub files to 'Author - Title.epub' format.""" +import re import xml.etree.ElementTree as ET import zipfile from pathlib import Path @@ -51,9 +52,10 @@ def extract_metadata(epub_path: Path) -> tuple[str | None, str | None]: def sanitize_filename(name: str) -> str: """Remove characters that are invalid in filenames.""" - invalid_chars = '<>:"/\\|?*' + invalid_chars = "<>:\"/\\|?*" for char in invalid_chars: name = name.replace(char, "") + name = re.sub(r"\s+", " ", name) return name.strip()