Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,19 @@ epubkit runs a 20-step pipeline on every EPUB:
| 17 | **Fix TOC** — validates the Table of Contents, generates one from chapter headings if missing |
| 18 | **Clean OS artifacts** — removes .DS_Store, Thumbs.db, __MACOSX, desktop.ini, etc. |
| 19 | **Repackage** — rebuilds the EPUB ZIP with correct mimetype entry and deflate compression |
| 20 | **Output filename** — generates a clean `Author - Title.epub` filename from metadata |
| 20 | **Output filename** — applies the selected filename preset or custom metadata template |

## Usage

1. **Drop** one or more EPUB files onto the upload zone
2. **Edit** title/author if needed (auto-detected from metadata)
3. **Pick your device**: X4 or X3 (sets screen size and grayscale depth)
4. **Pick a preset**: Quick (images + text), Full (device-optimized), or Custom
5. **Click Optimize** and watch real-time progress via SSE streaming
6. **Download** the optimized EPUB — ready to transfer to your reader
5. **Choose an output filename**: original, title/author presets, title only, or a custom template
6. **Click Optimize** and watch real-time progress via SSE streaming
7. **Download** the optimized EPUB — ready to transfer to your reader

Custom filename templates support `{title}`, `{author}`, `{year}`, `{series}`, `{series_index}`, `{language}`, and `{original}` placeholders. Invalid filename characters are sanitized automatically.

## Processing presets

Expand Down
24 changes: 24 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

from epub_processor import process_epub, extract_epub_metadata, ProcessingOptions, ProcessingReport
from image_processor import DEVICE_PROFILES
from metadata_handler import FILENAME_FORMATS, format_filename

app = FastAPI(title="epubkit")

Expand Down Expand Up @@ -146,6 +147,8 @@ async def process_sse(
text_cleanup: bool = True,
edit_title: str = "",
edit_author: str = "",
filename_format: str = "author-title",
filename_template: str = "",
):
"""SSE endpoint that streams processing progress."""
if task_id not in tasks:
Expand All @@ -158,6 +161,24 @@ async def process_sse(
if device not in DEVICE_PROFILES:
allowed = ", ".join(f"'{d}'" for d in DEVICE_PROFILES)
raise HTTPException(status_code=400, detail=f"Unknown device (expected {allowed})")
if filename_format not in FILENAME_FORMATS:
raise HTTPException(status_code=400, detail="Unknown filename format")
if filename_format == "custom" and not filename_template.strip():
raise HTTPException(status_code=400, detail="Custom filename template cannot be empty")
if len(filename_template) > 200:
raise HTTPException(status_code=400, detail="Filename template is too long")
if filename_format == "custom":
try:
format_filename(
"title",
"author",
filename_format="custom",
template=filename_template,
original_filename="original.epub",
year="2026",
)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error

input_path = task["file_path"]
out_dir = OUTPUT_DIR / task_id
Expand All @@ -175,6 +196,9 @@ async def process_sse(
generate_missing_cover=generate_cover,
clean_metadata=clean_metadata,
text_cleanup=text_cleanup,
filename_format=filename_format,
filename_template=filename_template,
original_filename=task["filename"],
)

if edit_title or edit_author:
Expand Down
15 changes: 14 additions & 1 deletion epub_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ class ProcessingOptions:
clean_metadata: bool = True
text_cleanup: bool = True
normalize_quotes: bool = True
filename_format: str = 'author-title'
filename_template: str = ''
original_filename: str = ''
# Metadata edits (applied if non-empty)
metadata_edits: dict = field(default_factory=dict)

Expand Down Expand Up @@ -410,7 +413,17 @@ def _progress(pct: int, msg: str):
final_metadata = extract_metadata(opf_tree)
title = options.metadata_edits.get('title', final_metadata['title']) or final_metadata['title']
author = options.metadata_edits.get('author', final_metadata['author']) or final_metadata['author']
report.output_filename = format_filename(title, author)
report.output_filename = format_filename(
title,
author,
filename_format=options.filename_format,
template=options.filename_template,
original_filename=options.original_filename,
year=final_metadata['year'],
series=final_metadata['series'],
series_index=final_metadata['series_index'],
language=final_metadata['language'],
)

# Done
report.optimized_size = os.path.getsize(output_path)
Expand Down
103 changes: 88 additions & 15 deletions metadata_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"""

import re
import string
import unicodedata
from pathlib import Path
from typing import Optional
Expand All @@ -28,6 +29,23 @@
# Prefixes for metadata we want to strip
STORE_META_PREFIXES = ('calibre:', 'ibooks:', 'amazon:', 'kindle:')

FILENAME_FORMATS = {
'original',
'title-author',
'author-title',
'title',
'custom',
}
FILENAME_TEMPLATE_FIELDS = {
'title',
'author',
'year',
'series',
'series_index',
'language',
'original',
}


def _find_dc(root, local_name, nsmap):
"""Find a Dublin Core element, trying multiple namespace strategies."""
Expand Down Expand Up @@ -67,14 +85,15 @@ def _find_manifest(root):
def extract_metadata(opf_tree: etree._ElementTree) -> dict:
"""
Extract metadata from OPF document.
Returns dict with: title, author, series, series_index, language, cover_id, cover_href
Returns dict with: title, author, year, series, series_index, language, cover_id, cover_href
"""
root = opf_tree.getroot()
nsmap = _build_nsmap(root)

metadata = {
'title': '',
'author': '',
'year': '',
'series': '',
'series_index': '',
'language': '',
Expand All @@ -92,6 +111,13 @@ def extract_metadata(opf_tree: etree._ElementTree) -> dict:
if creator_el is not None and creator_el.text:
metadata['author'] = creator_el.text.strip()

# Publication year
date_el = _find_dc(root, 'date', nsmap)
if date_el is not None and date_el.text:
year_match = re.search(r'\b(\d{4})\b', date_el.text)
if year_match:
metadata['year'] = year_match.group(1)

# Language
lang_el = _find_dc(root, 'language', nsmap)
if lang_el is not None and lang_el.text:
Expand Down Expand Up @@ -273,33 +299,80 @@ def strip_store_metadata(opf_tree: etree._ElementTree) -> int:
return removed


def format_filename(title: str, author: str) -> str:
def format_filename(
title: str,
author: str,
filename_format: str = 'author-title',
template: str = '',
original_filename: str = '',
year: str = '',
series: str = '',
series_index: str = '',
language: str = '',
) -> str:
"""
Create a sanitized filename in 'Author - Title.epub' format.
Falls back gracefully if either field is missing.
Create a sanitized EPUB filename using a preset or custom template.
"""
title = (title or '').strip()
author = (author or '').strip()

if author and title:
name = f"{author} - {title}"
elif title:
year = (year or '').strip()
series = (series or '').strip()
series_index = (series_index or '').strip()
language = (language or '').strip()
original = Path(original_filename or '').name
if original.lower().endswith('.epub'):
original = original[:-5]

if filename_format not in FILENAME_FORMATS:
raise ValueError(f"Unknown filename format: {filename_format}")

if filename_format == 'original':
name = original
elif filename_format == 'title-author':
name = _join_filename_parts(title, author)
elif filename_format == 'author-title':
name = _join_filename_parts(author, title)
elif filename_format == 'title':
name = title
elif author:
name = author
else:
name = "optimized"
if not template.strip():
raise ValueError("Custom filename template cannot be empty")
parsed_template = list(string.Formatter().parse(template))
if any(format_spec or conversion for _, _, format_spec, conversion in parsed_template):
raise ValueError("Filename template fields do not support formatting options")
fields = {field_name for _, field_name, _, _ in parsed_template if field_name}
unsupported = fields - FILENAME_TEMPLATE_FIELDS
if unsupported:
names = ', '.join(sorted(unsupported))
raise ValueError(f"Unknown filename template field: {names}")
name = template.format(
title=title,
author=author,
year=year,
series=series,
series_index=series_index,
language=language,
original=original,
)
Comment on lines +338 to +356

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Repo files matching metadata_handler.py / app.py / epub_processor.py:"
fd -a 'metadata_handler\.py|app\.py|epub_processor\.py' . | sed 's#^\./##'

echo
echo "Python version and string.Formatter().parse behavior:"
python3 --version
python3 - <<'PY'
import string
for template in ["{}", "{ }", "{title}", "{0}", "{title[foo]}"]:
    parsed = list(string.Formatter().parse(template))
    print(template, parsed)
    fields = {field_name for _, field_name, _, _ in parsed if field_name}
    print("  filtered fields={}", fields)
PY

Repository: b1rdmania/epubkit

Length of output: 742


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "metadata_handler.py relevant section:"
sed -n '300,380p' metadata_handler.py | nl -ba -v300

echo
echo "app.py relevant sections:"
sed -n '150,190p' app.py | nl -ba -v150
rg -n "custom|filename|ValueError|IndexError|except" app.py

echo
echo "epub_processor.py relevant sections:"
sed -n '410,450p' epub_processor.py | nl -ba -v410
rg -n "custom|filename|ValueError|except Exception|Metadata" epub_processor.py

echo
echo "All occurrences of filename template logic:"
rg -n "FILENAME_TEMPLATE_FIELDS|filename template|custom filename|format\\(" metadata_handler.py app.py epub_processor.py test_metadata_handler.py

Repository: b1rdmania/epubkit

Length of output: 233


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "metadata_handler.py relevant section:"
sed -n '300,380p' metadata_handler.py | awk '{printf "%5d  %s\n", NR+299, $0}'

echo
echo "app.py relevant sections and occurrences:"
sed -n '150,190p' app.py | awk '{printf "%5d  %s\n", NR+149, $0}'
rg -n "custom|filename|ValueError|IndexError|except" app.py

echo
echo "epub_processor.py relevant sections and occurrences:"
sed -n '410,450p' epub_processor.py | awk '{printf "%5d  %s\n", NR+409, $0}'
rg -n "custom|filename|ValueError|except Exception|Metadata" epub_processor.py

echo
echo "All occurrences of filename template logic:"
rg -n "FILENAME_TEMPLATE_FIELDS|filename template|custom filename|format\\(" metadata_handler.py app.py epub_processor.py test_metadata_handler.py

Repository: b1rdmania/epubkit

Length of output: 10658


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Python formatting behavior for bare placeholder templates during validation and formatting:"
python3 - <<'PY'
import string
from pathlib import Path

FILENAME_TEMPLATE_FIELDS = {'title', 'author', 'year', 'series', 'series_index', 'language', 'original'}

for template in ["{}", "{ }", "{title}"]:
    parsed = list(string.Formatter().parse(template))
    fields = {field_name for _, field_name, _, _ in parsed if field_name}
    unsupported = fields - FILENAME_TEMPLATE_FIELDS
    try:
        name = template.format(
            title="title",
            author="author",
            year="2026",
            series="series",
            series_index="1",
            language="en",
            original=Path("original.epub").name,
        )
        print(template, "parsed=", parsed, "fields=", fields, "unsupported=", unsupported, "name=", name)
    except Exception as e:
        print(template, "parsed=", parsed, "fields=", fields, "unsupported=", unsupported, "raised", type(e).__name__, str(e))

print()
print("Literal blank field as {}")
try:
    "{} {}".format(title="t", author="a")
except Exception as e:
    print(type(e).__name__, str(e))
PY

Repository: b1rdmania/epubkit

Length of output: 664


Reject bare field placeholders in custom filename templates.

string.Formatter().parse() treats {} and { } as fields with an empty or whitespace field name. The current if field_name, filter excludes empty names, so unsupported stays empty; template.format(title=...) then raises IndexError because format_filename() only supplies keyword arguments. Treat None field names as unknown fields and raise ValueError, so malformed templates are rejected before processing instead of an unhandled IndexError.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@metadata_handler.py` around lines 338 - 356, Update the custom filename
validation around parsed_template and fields so bare or whitespace-only
placeholders are treated as unsupported fields rather than filtered out. Ensure
format_filename() raises the existing ValueError for these templates before
template.format runs, while preserving validation of named fields against
FILENAME_TEMPLATE_FIELDS.


if name.lower().endswith('.epub'):
name = name[:-5]

# Sanitize: remove/replace problematic characters
name = _sanitize_filename(name)
if not name or name in {'.', '..'}:
name = "optimized"

# Limit length (leave room for .epub extension)
if len(name) > 200:
name = name[:200].rstrip()
name = name[:200].rstrip(' .-')

return f"{name}.epub"


def _join_filename_parts(first: str, second: str) -> str:
"""Join available metadata fields without leaving an empty separator."""
return ' - '.join(part for part in (first, second) if part)


def _sanitize_filename(name: str) -> str:
"""Remove characters that are problematic in filenames."""
# Replace common problematic chars
Expand All @@ -320,4 +393,4 @@ def _sanitize_filename(name: str) -> str:
name = re.sub(r'\s+', ' ', name)
name = re.sub(r'-{2,}', '-', name)

return name.strip()
return name.strip(' .-')
43 changes: 43 additions & 0 deletions static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ const processBtn = document.getElementById('process-btn');
const qualitySlider = document.getElementById('opt-quality');
const qualityValue = document.getElementById('quality-value');
const downloadAllBtn = document.getElementById('download-all-btn');
const filenameTemplateRow = document.getElementById('filename-template-row');
const filenameTemplate = document.getElementById('opt-filename-template');

let uploadedFiles = []; // {task_id, filename, metadata, file_size}
let selectedDevice = 'x4'; // 'x4' (480x800) or 'x3' (528x792), both 4-level gray
let selectedFilenameFormat = 'author-title';

// ==================== Upload ====================

Expand Down Expand Up @@ -147,6 +150,16 @@ function removeFile(taskId, btn) {

// ==================== Options ====================

document.querySelectorAll('.filename-format-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.filename-format-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
selectedFilenameFormat = btn.dataset.filenameFormat;
filenameTemplateRow.hidden = selectedFilenameFormat !== 'custom';
if (selectedFilenameFormat === 'custom') filenameTemplate.focus();
});
});

// Device toggle
document.querySelectorAll('.device-btn').forEach(btn => {
btn.addEventListener('click', () => {
Expand Down Expand Up @@ -221,6 +234,7 @@ processBtn.addEventListener('click', startProcessing);
async function startProcessing() {
const validFiles = uploadedFiles.filter(f => f.task_id && !f.error);
if (validFiles.length === 0) return;
if (!validateFilenameTemplate()) return;

processBtn.disabled = true;
processBtn.innerHTML = `
Expand Down Expand Up @@ -279,6 +293,31 @@ async function startProcessing() {
Optimize EPUBs`;
}

function validateFilenameTemplate() {
filenameTemplate.setCustomValidity('');
if (selectedFilenameFormat !== 'custom') return true;

if (!filenameTemplate.value.trim()) {
filenameTemplate.setCustomValidity('Enter a custom filename template.');
} else {
const remainder = filenameTemplate.value
.replace(/\{\{|\}\}/g, '')
.replace(/\{(?:title|author|year|series|series_index|language|original)\}/g, '');
if (/[{}]/.test(remainder)) {
filenameTemplate.setCustomValidity(
'Use only the placeholders listed below the template.'
);
}
}

if (!filenameTemplate.checkValidity()) {
filenameTemplate.reportValidity();
filenameTemplate.focus();
return false;
}
return true;
}

function getOptions() {
return {
device: selectedDevice,
Expand All @@ -291,6 +330,8 @@ function getOptions() {
generate_cover: document.getElementById('opt-cover').checked,
clean_metadata: document.getElementById('opt-metadata').checked,
text_cleanup: document.getElementById('opt-textcleanup').checked,
filename_format: selectedFilenameFormat,
filename_template: filenameTemplate.value,
};
}

Expand All @@ -309,6 +350,8 @@ function processFile(taskId, options, editTitle, editAuthor) {
text_cleanup: options.text_cleanup,
edit_title: editTitle,
edit_author: editAuthor,
filename_format: options.filename_format,
filename_template: options.filename_template,
});

const eventSource = new EventSource(`/process/${taskId}?${params}`);
Expand Down
Loading