feat(converter): add --keep-filenames flag to preserve original page filenames

Add a new `--keep-filenames` boolean flag (no shorthand, default false) on
both `optimize` and `watch` commands. When enabled, page entries in the
output CBZ preserve their original base filenames instead of being
renumbered to `%04d` sequential indices. Extension is swapped on format
conversion (e.g. `001.png` → `001.webp`), split pages get `-NN` suffixes
on the original stem, and duplicate base names fall back to
`<stem>_<index>.<ext>`. Watch mode supports the flag via the
`keep-filenames` viper config key.

Design: data-driven `OriginalName` field on `manga.PageFile`, set at
extraction in `cbz_loader.ExtractChapter`. The `Converter` interface is
unchanged. Flag off → behavior identical to before.

Closes #216
This commit is contained in:
Antoine Aflalo
2026-07-21 19:38:17 -04:00
parent 650623b340
commit f3ba585c13
12 changed files with 245 additions and 37 deletions
+52 -7
View File
@@ -5,6 +5,8 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/belphemur/CBZOptimizer/v2/internal/manga"
@@ -12,6 +14,49 @@ import (
"github.com/rs/zerolog/log"
)
// resolvePageName picks the final archive entry name for a page.
//
// Order of resolution:
// 1. When page.OriginalName is set (keep-filenames mode), use its stem with
// the current page.Extension. Split pages append the -NN suffix so all
// parts of the same source still land in the archive.
// 2. If that final name has already been used in this archive, fall back to
// the indexed form (stem + "_%04d" + extension) so the zip stays valid.
// 3. Otherwise (OriginalName is empty), use the historical %04d / %04d-NN
// sequential naming.
//
// usedNames tracks every name already chosen for this archive and is mutated
// in place so the caller can keep a single map across the whole chapter.
func resolvePageName(page *manga.PageFile, usedNames map[string]struct{}) string {
if page.OriginalName != "" {
stem := strings.TrimSuffix(page.OriginalName, filepath.Ext(page.OriginalName))
var candidate string
if page.IsSplitted {
candidate = fmt.Sprintf("%s-%02d%s", stem, page.SplitPartIndex, page.Extension)
} else {
candidate = stem + page.Extension
}
if _, taken := usedNames[candidate]; !taken {
usedNames[candidate] = struct{}{}
return candidate
}
// Collision: fall back to the indexed form so the entry still gets
// written, but make it visibly distinct from the preserved one.
fallback := fmt.Sprintf("%s_%04d%s", stem, page.Index, page.Extension)
usedNames[fallback] = struct{}{}
return fallback
}
if page.IsSplitted {
name := fmt.Sprintf("%04d-%02d%s", page.Index, page.SplitPartIndex, page.Extension)
usedNames[name] = struct{}{}
return name
}
name := fmt.Sprintf("%04d%s", page.Index, page.Extension)
usedNames[name] = struct{}{}
return name
}
// WriteChapterToCBZ creates a CBZ file from a Chapter by streaming page files
// from disk directly into the zip archive. No image data is held in memory.
func WriteChapterToCBZ(chapter *manga.Chapter, outputFilePath string) (err error) {
@@ -34,14 +79,14 @@ func WriteChapterToCBZ(chapter *manga.Chapter, outputFilePath string) (err error
zipWriter := zip.NewWriter(zipFile)
defer errs.Capture(&err, zipWriter.Close, "failed to close .cbz writer")
// Write each page to the archive by streaming from disk
// Write each page to the archive by streaming from disk.
// Final name resolution: when a page carries an OriginalName (recorded by
// ExtractChapter when --keep-filenames is on), preserve its stem and only
// swap the extension to the current page.Extension. Duplicates in the
// archive fall back to the indexed naming so the output stays a valid zip.
usedNames := make(map[string]struct{}, len(chapter.Pages))
for _, page := range chapter.Pages {
var fileName string
if page.IsSplitted {
fileName = fmt.Sprintf("%04d-%02d%s", page.Index, page.SplitPartIndex, page.Extension)
} else {
fileName = fmt.Sprintf("%04d%s", page.Index, page.Extension)
}
fileName := resolvePageName(page, usedNames)
log.Debug().
Str("output_path", outputFilePath).