From 352fbeb32dabc9fd9fcbc8ee50215924773e3d84 Mon Sep 17 00:00:00 2001 From: Alex Yatskov Date: Sun, 22 Oct 2023 20:57:43 -0700 Subject: [PATCH] Initially working version --- arch.go | 99 ++++++++++++++++++ cmd/mex.go | 91 +++++++++++++++++ file.go | 110 ++++++++++++++++++++ main.go | 7 -- media.go | 291 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 591 insertions(+), 7 deletions(-) create mode 100644 arch.go create mode 100644 cmd/mex.go create mode 100644 file.go delete mode 100644 main.go create mode 100644 media.go diff --git a/arch.go b/arch.go new file mode 100644 index 0000000..a0a2246 --- /dev/null +++ b/arch.go @@ -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 +} diff --git a/cmd/mex.go b/cmd/mex.go new file mode 100644 index 0000000..1f58ca2 --- /dev/null +++ b/cmd/mex.go @@ -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 []") + 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) + } +} diff --git a/file.go b/file.go new file mode 100644 index 0000000..02d4fce --- /dev/null +++ b/file.go @@ -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 +} diff --git a/main.go b/main.go deleted file mode 100644 index 50e8d8d..0000000 --- a/main.go +++ /dev/null @@ -1,7 +0,0 @@ -package main - -import "fmt" - -func main() { - fmt.Println("vim-go") -} diff --git a/media.go b/media.go new file mode 100644 index 0000000..b83080d --- /dev/null +++ b/media.go @@ -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 +}