| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247 |
- // Package save decides where a book is written and writes it there safely.
- //
- // It implements the -SAVE rules from the README: folder versus file targets,
- // the .epub / .kepub.epub extension, creation of missing folders, and
- // overwrite protection unless -FORCE is given. Writes are atomic, so a failed
- // run never leaves a partial file behind.
- package save
- import (
- "errors"
- "fmt"
- "io"
- "io/fs"
- "os"
- "path/filepath"
- "strings"
- "unicode"
- "golang.org/x/text/unicode/norm"
- )
- const (
- extEPUB = ".epub"
- extKEPUB = ".kepub.epub"
- // maxSlugLen caps generated filenames well below filesystem limits.
- maxSlugLen = 80
- // fallbackName is used when a title produces an empty slug, for example
- // a title written entirely in a non-Latin script.
- fallbackName = "article"
- )
- // ErrExists reports that the output file already exists and overwriting was
- // not allowed.
- var ErrExists = errors.New("already exists")
- // ErrKEPUBName reports a .kepub.epub output path without KEPUB output
- // enabled. Kobo would open such a plain EPUB in its KEPUB renderer.
- var ErrKEPUBName = errors.New("a .kepub.epub file needs -KEPUB (or use a .epub filename)")
- // Target is a resolved -SAVE value. The final filename is known up front for
- // file targets; for folder targets it depends on the article title.
- type Target struct {
- file string // absolute file path, set for file targets
- dir string // absolute folder, set for folder targets
- ext string // extension used for folder targets
- // Note is a message for the user about an adjustment made to the path,
- // or empty.
- Note string
- }
- // Resolve interprets the -SAVE argument. kepub selects the .kepub.epub
- // extension.
- func Resolve(arg string, kepub bool) (Target, error) {
- if arg == "" {
- return Target{}, errors.New("empty path")
- }
- arg, err := expandHome(arg)
- if err != nil {
- return Target{}, err
- }
- ext := extEPUB
- if kepub {
- ext = extKEPUB
- }
- if isFolder(arg) {
- dir, err := filepath.Abs(arg)
- if err != nil {
- return Target{}, err
- }
- return Target{dir: dir, ext: ext}, nil
- }
- var note string
- lower := strings.ToLower(arg)
- switch {
- case strings.HasSuffix(lower, extKEPUB):
- if !kepub {
- return Target{}, fmt.Errorf("%s: %w", arg, ErrKEPUBName)
- }
- case strings.HasSuffix(lower, extEPUB):
- if kepub {
- renamed := arg[:len(arg)-len(extEPUB)] + extKEPUB
- note = fmt.Sprintf("saving as %s so Kobo opens it with the KEPUB renderer", filepath.Base(renamed))
- arg = renamed
- }
- default:
- arg += ext
- }
- file, err := filepath.Abs(arg)
- if err != nil {
- return Target{}, err
- }
- return Target{file: file, Note: note}, nil
- }
- // Known reports whether the output filename is known before the article
- // title is, which allows checking for an existing file before fetching.
- func (t Target) Known() bool {
- return t.file != ""
- }
- // Path returns the output file path. title is only used for folder targets.
- func (t Target) Path(title string) string {
- if t.file != "" {
- return t.file
- }
- return filepath.Join(t.dir, Slug(title)+t.ext)
- }
- // isFolder reports whether arg names a folder: it ends in a path separator
- // or is an existing directory.
- func isFolder(arg string) bool {
- if os.IsPathSeparator(arg[len(arg)-1]) {
- return true
- }
- info, err := os.Stat(arg)
- return err == nil && info.IsDir()
- }
- // expandHome replaces a leading "~" with the user's home directory, for
- // paths the shell did not expand (for example, quoted ones).
- func expandHome(arg string) (string, error) {
- if arg != "~" && !(len(arg) > 1 && arg[0] == '~' && os.IsPathSeparator(arg[1])) {
- return arg, nil
- }
- home, err := os.UserHomeDir()
- if err != nil {
- return "", fmt.Errorf("expanding ~: %w", err)
- }
- return home + arg[1:], nil
- }
- // Slug turns a title into a safe filename stem: lowercase ASCII letters and
- // digits separated by single dashes, at most 80 characters. Accents are
- // removed ("Café" → "cafe"). It never contains path separators, so a title
- // cannot move the file out of the chosen folder.
- func Slug(title string) string {
- var b strings.Builder
- dash := false
- for _, r := range norm.NFD.String(title) {
- switch {
- case unicode.Is(unicode.Mn, r):
- // Combining accent left over from NFD; drop it.
- case r < unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)):
- if dash && b.Len() > 0 {
- b.WriteByte('-')
- }
- dash = false
- b.WriteRune(unicode.ToLower(r))
- default:
- dash = true
- }
- }
- s := b.String()
- if len(s) > maxSlugLen {
- s = strings.TrimRight(s[:maxSlugLen], "-")
- }
- if s == "" {
- return fallbackName
- }
- return s
- }
- // CheckExists returns an error wrapping ErrExists if path already exists.
- // It is the early check made before work is done; Write repeats the check
- // atomically.
- func CheckExists(path string) error {
- info, err := os.Lstat(path)
- switch {
- case errors.Is(err, fs.ErrNotExist):
- return nil
- case err != nil:
- return err
- case info.IsDir():
- return fmt.Errorf("%s is a folder", path)
- default:
- return fmt.Errorf("%s %w", path, ErrExists)
- }
- }
- // Write writes src to path, creating missing folders. Unless force is set,
- // an existing file is never modified and an error wrapping ErrExists is
- // returned. The content is written to a temporary file and renamed into
- // place, so path never holds a partial book; on failure, nothing the run
- // created is left behind.
- func Write(path string, src io.WriterTo, force bool) (err error) {
- dir := filepath.Dir(path)
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return fmt.Errorf("creating folder: %w", err)
- }
- // Without force, claim the name first. O_EXCL fails if the file exists,
- // including one created by another program since CheckExists ran.
- reserved := false
- if !force {
- f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
- if errors.Is(err, fs.ErrExist) {
- return fmt.Errorf("%s %w", path, ErrExists)
- }
- if err != nil {
- return err
- }
- if err := f.Close(); err != nil {
- os.Remove(path)
- return err
- }
- reserved = true
- }
- tmp, err := os.CreateTemp(dir, ".rekopub-*.tmp")
- if err != nil {
- if reserved {
- os.Remove(path)
- }
- return err
- }
- defer func() {
- if err != nil {
- tmp.Close()
- os.Remove(tmp.Name())
- if reserved {
- os.Remove(path)
- }
- }
- }()
- if _, err := src.WriteTo(tmp); err != nil {
- return err
- }
- if err := tmp.Sync(); err != nil {
- return err
- }
- if err := tmp.Close(); err != nil {
- return err
- }
- // CreateTemp uses mode 0600; books should be readable like any other
- // file. Best effort: FAT32 (a Kobo over USB) has no permissions.
- _ = os.Chmod(tmp.Name(), 0o644)
- return os.Rename(tmp.Name(), path)
- }
|