Initially working version
This commit is contained in:
parent
286e79ba0b
commit
352fbeb32d
99
arch.go
Normal file
99
arch.go
Normal file
@ -0,0 +1,99 @@
|
||||
package mex
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
UnsupportedArchiveFormatError = errors.New("unsupported archive format")
|
||||
RequiredToolNotInstalled = errors.New("required tool not installed")
|
||||
)
|
||||
|
||||
var (
|
||||
archExt = ".cbz"
|
||||
|
||||
rarToolNames = []string{"unrar"}
|
||||
zipToolNames = []string{"7za", "7z"}
|
||||
)
|
||||
|
||||
func findToolByName(names ...string) (string, error) {
|
||||
for _, name := range names {
|
||||
if path, err := exec.LookPath(name); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", RequiredToolNotInstalled
|
||||
}
|
||||
|
||||
func Compress(archPath, contentDir string) error {
|
||||
if filepath.Ext(archPath) != archExt {
|
||||
archPath += archExt
|
||||
}
|
||||
|
||||
toolPath, err := findToolByName(zipToolNames...)
|
||||
if err != nil {
|
||||
return RequiredToolNotInstalled
|
||||
}
|
||||
|
||||
toolCmd := exec.Command(
|
||||
toolPath,
|
||||
"a", archPath,
|
||||
contentDir+string(filepath.Separator)+"*",
|
||||
)
|
||||
|
||||
log.Printf("compressing %s...", archPath)
|
||||
if err := toolCmd.Run(); err != nil {
|
||||
return errors.Join(fmt.Errorf("compression of %s failed", archPath), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Decompress(archPath string, allocator *TempDirAllocator) (string, error) {
|
||||
archPathAbs, err := filepath.Abs(archPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var (
|
||||
toolNames []string
|
||||
toolArgs []string
|
||||
)
|
||||
|
||||
switch strings.ToLower(filepath.Ext(archPathAbs)) {
|
||||
case ".rar", ".cbr":
|
||||
toolNames = rarToolNames
|
||||
toolArgs = []string{"x", archPathAbs}
|
||||
case ".zip", ".cbz", ".7z":
|
||||
toolNames = zipToolNames
|
||||
toolArgs = []string{"x", archPathAbs}
|
||||
default:
|
||||
return "", UnsupportedArchiveFormatError
|
||||
}
|
||||
|
||||
toolPath, err := findToolByName(toolNames...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
contentDir, err := allocator.TempDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
toolCmd := exec.Command(toolPath, toolArgs...)
|
||||
toolCmd.Dir = contentDir
|
||||
|
||||
log.Printf("decompressing %s...", archPath)
|
||||
if err := toolCmd.Run(); err != nil {
|
||||
return "", errors.Join(fmt.Errorf("decompression of %s failed", archPath), err)
|
||||
}
|
||||
|
||||
return contentDir, nil
|
||||
}
|
91
cmd/mex.go
Normal file
91
cmd/mex.go
Normal file
@ -0,0 +1,91 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"foosoft.net/projects/mex"
|
||||
)
|
||||
|
||||
func processPath(inputPath, outputDir string, config mex.ExportConfig) error {
|
||||
var allocator mex.TempDirAllocator
|
||||
defer allocator.Cleanup()
|
||||
|
||||
if len(outputDir) == 0 {
|
||||
var err error
|
||||
outputDir, err = os.Getwd()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
rootNode, err := mex.Walk(inputPath, &allocator)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
book, err := mex.ParseBook(rootNode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := book.Export(outputDir, config, &allocator); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
var (
|
||||
compressBook = flag.Bool("zip-book", false, "compress book as a cbz archive")
|
||||
compressVolumes = flag.Bool("zip-volume", true, "compress volumes as cbz archives")
|
||||
pageTemplate = flag.String("label-page", "page_{{.Index}}{{.Ext}}", "page name template")
|
||||
volumeTemplate = flag.String("label-volume", "vol_{{.Index}}", "volume name template")
|
||||
bookTemplate = flag.String("label-book", "{{.Name}}", "book name template")
|
||||
workers = flag.Int("workers", 4, "number of simultaneous workers")
|
||||
)
|
||||
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintln(os.Stderr, "Usage: mex <input_path> [<output_dir>]")
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprintln(os.Stderr, "Templates:")
|
||||
fmt.Fprintln(os.Stderr, " {{.Index}} - index of current volume or page")
|
||||
fmt.Fprintln(os.Stderr, " {{.Name}} - original filename and extension")
|
||||
fmt.Fprintln(os.Stderr, " {{.Ext}} - original extension only")
|
||||
}
|
||||
|
||||
flag.Parse()
|
||||
|
||||
config := mex.ExportConfig{
|
||||
PageTemplate: *pageTemplate,
|
||||
VolumeTemplate: *volumeTemplate,
|
||||
BookTemplate: *bookTemplate,
|
||||
Workers: *workers,
|
||||
}
|
||||
|
||||
if *compressBook {
|
||||
config.Flags |= mex.ExportFlag_CompressBook
|
||||
}
|
||||
if *compressVolumes {
|
||||
config.Flags |= mex.ExportFlag_CompressVolumes
|
||||
}
|
||||
|
||||
if argc := flag.NArg(); argc >= 1 && argc <= 2 {
|
||||
inputPath := flag.Arg(0)
|
||||
|
||||
var outputDir string
|
||||
if argc == 2 {
|
||||
outputDir = flag.Arg(1)
|
||||
}
|
||||
|
||||
if err := processPath(inputPath, outputDir, config); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
flag.Usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
110
file.go
Normal file
110
file.go
Normal file
@ -0,0 +1,110 @@
|
||||
package mex
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func copyFile(targetPath, sourcePath string) error {
|
||||
sourceFile, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sourceFile.Close()
|
||||
|
||||
targetFile, err := os.Create(targetPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer targetFile.Close()
|
||||
|
||||
if _, err := io.Copy(targetFile, sourceFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func stripExt(path string) string {
|
||||
name := filepath.Base(path)
|
||||
return name[:len(name)-len(filepath.Ext(name))]
|
||||
}
|
||||
|
||||
type TempDirAllocator struct {
|
||||
mutex sync.Mutex
|
||||
dirs []string
|
||||
}
|
||||
|
||||
func (self *TempDirAllocator) TempDir() (string, error) {
|
||||
dir, err := os.MkdirTemp("", "mex_")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
self.mutex.Lock()
|
||||
defer self.mutex.Unlock()
|
||||
self.dirs = append(self.dirs, dir)
|
||||
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func (self *TempDirAllocator) Cleanup() {
|
||||
for _, dir := range self.dirs {
|
||||
os.RemoveAll(dir)
|
||||
}
|
||||
|
||||
self.mutex.Lock()
|
||||
defer self.mutex.Unlock()
|
||||
self.dirs = nil
|
||||
}
|
||||
|
||||
type Node struct {
|
||||
Name string
|
||||
Path string
|
||||
Info os.FileInfo
|
||||
Children []*Node
|
||||
}
|
||||
|
||||
func Walk(path string, allocator *TempDirAllocator) (*Node, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
currNode := &Node{
|
||||
Name: filepath.Base(path),
|
||||
Path: path,
|
||||
Info: info,
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
child, err := Walk(filepath.Join(path, entry.Name()), allocator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
currNode.Children = append(currNode.Children, child)
|
||||
}
|
||||
} else {
|
||||
if contentDir, err := Decompress(path, allocator); err == nil {
|
||||
archName := currNode.Name
|
||||
if currNode, err = Walk(contentDir, allocator); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
currNode.Name = archName
|
||||
} else if err != UnsupportedArchiveFormatError {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return currNode, nil
|
||||
}
|
291
media.go
Normal file
291
media.go
Normal file
@ -0,0 +1,291 @@
|
||||
package mex
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func isImagePath(path string) bool {
|
||||
switch strings.ToLower(filepath.Ext(path)) {
|
||||
case ".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func buildTemplatedName(pattern, path string, index, count int) (string, error) {
|
||||
var (
|
||||
paddingCount = math.Log10(float64(count))
|
||||
paddingFmt = fmt.Sprintf("%%0.%dd", int(paddingCount+1))
|
||||
)
|
||||
|
||||
context := struct {
|
||||
Index string
|
||||
Name string
|
||||
Ext string
|
||||
}{
|
||||
Index: fmt.Sprintf(paddingFmt, index),
|
||||
Name: filepath.Base(path),
|
||||
Ext: strings.ToLower(filepath.Ext(path)),
|
||||
}
|
||||
|
||||
tmpl, err := template.New("name").Parse(pattern)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var buff bytes.Buffer
|
||||
if err := tmpl.Execute(&buff, context); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return buff.String(), nil
|
||||
}
|
||||
|
||||
type ExportFlags int
|
||||
|
||||
const (
|
||||
ExportFlag_CompressBook = 1 << iota
|
||||
ExportFlag_CompressVolumes
|
||||
)
|
||||
|
||||
type ExportConfig struct {
|
||||
Flags ExportFlags
|
||||
PageTemplate string
|
||||
VolumeTemplate string
|
||||
BookTemplate string
|
||||
Workers int
|
||||
}
|
||||
|
||||
type Page struct {
|
||||
Node *Node
|
||||
Volume *Volume
|
||||
Index int
|
||||
}
|
||||
|
||||
func (self *Page) export(dir string, config ExportConfig) error {
|
||||
name, err := buildTemplatedName(config.PageTemplate, self.Node.Name, self.Index+1, len(self.Volume.Pages))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := copyFile(filepath.Join(dir, name), self.Node.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type Volume struct {
|
||||
Node *Node
|
||||
Book *Book
|
||||
Pages []*Page
|
||||
Index int
|
||||
}
|
||||
|
||||
func (self *Volume) AveragePageSize() int {
|
||||
if len(self.Pages) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
var totalSize int
|
||||
for _, page := range self.Pages {
|
||||
totalSize += int(page.Node.Info.Size())
|
||||
}
|
||||
|
||||
return totalSize / len(self.Pages)
|
||||
}
|
||||
|
||||
func (self *Volume) export(path string, config ExportConfig, allocator *TempDirAllocator) error {
|
||||
name, err := buildTemplatedName(config.VolumeTemplate, stripExt(self.Node.Name), self.Index, self.Book.MaxVolume)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
compress = config.Flags&ExportFlag_CompressVolumes != 0
|
||||
outputDir = path
|
||||
)
|
||||
|
||||
if compress {
|
||||
if outputDir, err = allocator.TempDir(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
outputDir = filepath.Join(outputDir, name)
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, page := range self.Pages {
|
||||
if err := page.export(outputDir, config); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if compress {
|
||||
archivePath := filepath.Join(path, name)
|
||||
if err := Compress(archivePath, outputDir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *Volume) supercedes(other *Volume) bool {
|
||||
if len(self.Pages) > len(other.Pages) {
|
||||
log.Printf("picking %s over %s because it has more pages", self.Node.Name, other.Node.Name)
|
||||
return true
|
||||
}
|
||||
|
||||
if self.AveragePageSize() > other.AveragePageSize() {
|
||||
log.Printf("picking %s over %s because it has larger pages", self.Node.Name, other.Node.Name)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
type Book struct {
|
||||
Node *Node
|
||||
Volumes map[int]*Volume
|
||||
MaxVolume int
|
||||
}
|
||||
|
||||
func (self *Book) Export(path string, config ExportConfig, allocator *TempDirAllocator) error {
|
||||
name, err := buildTemplatedName(config.BookTemplate, stripExt(self.Node.Name), 0, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
compress = config.Flags&ExportFlag_CompressBook != 0
|
||||
outputDir = path
|
||||
)
|
||||
|
||||
if compress {
|
||||
if outputDir, err = allocator.TempDir(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
outputDir = filepath.Join(outputDir, name)
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
volumeChan = make(chan *Volume, 4)
|
||||
volumeErr error
|
||||
volumeErrLock sync.Mutex
|
||||
volumeWg sync.WaitGroup
|
||||
)
|
||||
|
||||
for i := 0; i < cap(volumeChan); i++ {
|
||||
volumeWg.Add(1)
|
||||
go func() {
|
||||
defer volumeWg.Done()
|
||||
for volume := range volumeChan {
|
||||
if err := volume.export(outputDir, config, allocator); err != nil {
|
||||
volumeErrLock.Lock()
|
||||
volumeErr = err
|
||||
volumeErrLock.Unlock()
|
||||
break
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for _, volume := range self.Volumes {
|
||||
volumeChan <- volume
|
||||
}
|
||||
|
||||
close(volumeChan)
|
||||
volumeWg.Wait()
|
||||
|
||||
if volumeErr != nil {
|
||||
return volumeErr
|
||||
}
|
||||
|
||||
if compress {
|
||||
archivePath := filepath.Join(path, name)
|
||||
if err := Compress(archivePath, outputDir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *Book) addVolume(volume *Volume) {
|
||||
currVolume, _ := self.Volumes[volume.Index]
|
||||
if currVolume == nil || volume.supercedes(currVolume) {
|
||||
self.Volumes[volume.Index] = volume
|
||||
}
|
||||
}
|
||||
|
||||
func (self *Book) parseVolumes(node *Node) {
|
||||
if !node.Info.IsDir() {
|
||||
return
|
||||
}
|
||||
|
||||
volume := &Volume{
|
||||
Node: node,
|
||||
Book: self,
|
||||
}
|
||||
|
||||
var pageIndex int
|
||||
for _, child := range node.Children {
|
||||
if child.Info.IsDir() {
|
||||
self.parseVolumes(child)
|
||||
} else if isImagePath(child.Name) {
|
||||
volume.Pages = append(volume.Pages, &Page{child, volume, pageIndex})
|
||||
pageIndex++
|
||||
}
|
||||
}
|
||||
|
||||
if len(volume.Pages) > 0 {
|
||||
exp := regexp.MustCompile(`(\d+)\D*$`)
|
||||
if matches := exp.FindStringSubmatch(node.Name); len(matches) >= 2 {
|
||||
index, err := strconv.ParseInt(matches[1], 10, 32)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
volume.Index = int(index)
|
||||
if volume.Index > self.MaxVolume {
|
||||
self.MaxVolume = volume.Index
|
||||
}
|
||||
|
||||
self.addVolume(volume)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ParseBook(node *Node) (*Book, error) {
|
||||
book := Book{
|
||||
Node: node,
|
||||
Volumes: make(map[int]*Volume),
|
||||
}
|
||||
|
||||
book.parseVolumes(node)
|
||||
|
||||
if len(book.Volumes) == 0 {
|
||||
return nil, errors.New("no volumes found")
|
||||
}
|
||||
|
||||
return &book, nil
|
||||
}
|
Loading…
Reference in New Issue
Block a user