1
0

main.go 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. // Command rekopub converts a web article into an EPUB for e-ink readers.
  2. //
  3. // rekopub -URL <url> -SAVE <path> [-FORCE] [-KEPUB]
  4. package main
  5. import (
  6. "context"
  7. "errors"
  8. "flag"
  9. "fmt"
  10. "io"
  11. "net/url"
  12. "os"
  13. "os/signal"
  14. "git.monoexp.com/bajdev/rekopub/internal/pipeline"
  15. "git.monoexp.com/bajdev/rekopub/internal/save"
  16. )
  17. // Exit codes, as documented in the README.
  18. const (
  19. exitOK = 0
  20. exitFetch = 1 // page couldn't be fetched
  21. exitUsage = 2 // invalid arguments
  22. exitNoContent = 3 // no readable content found
  23. exitBuild = 4 // book couldn't be built or written
  24. exitExists = 5 // output exists and -FORCE wasn't given
  25. // exitInterrupted is the conventional code for a program stopped
  26. // with Ctrl-C (128 + SIGINT).
  27. exitInterrupted = 130
  28. )
  29. const usage = `Usage: rekopub -URL <url> -SAVE <path> [-FORCE] [-KEPUB]
  30. Convert a web article into an EPUB for e-ink readers.
  31. -URL string
  32. Web page to convert (required). Must be http:// or https://.
  33. -SAVE string
  34. Where to write the book (required). Missing folders are created.
  35. - Ends in "/" → a folder: <folder>/<article-title>.epub
  36. - An existing folder → same as above
  37. - Ends in .epub → written to exactly that file
  38. - Anything else → ".epub" is appended
  39. With -KEPUB the extension is ".kepub.epub" instead.
  40. -FORCE
  41. Overwrite the output file if it already exists.
  42. -KEPUB
  43. Write a Kobo KEPUB (.kepub.epub) instead of a plain EPUB.
  44. -VERSION
  45. Print the version and exit.
  46. `
  47. // convertFunc turns a URL into a book. It is pipeline.Run in production and
  48. // a fake in tests.
  49. type convertFunc func(ctx context.Context, u *url.URL, opts pipeline.Options) (*pipeline.Book, error)
  50. func main() {
  51. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
  52. code := run(ctx, os.Args[1:], os.Stdout, os.Stderr, pipeline.Run)
  53. stop()
  54. os.Exit(code)
  55. }
  56. func run(ctx context.Context, args []string, stdout, stderr io.Writer, convert convertFunc) int {
  57. fs := flag.NewFlagSet("rekopub", flag.ContinueOnError)
  58. fs.SetOutput(stderr)
  59. fs.Usage = func() { fmt.Fprint(stderr, usage) }
  60. urlArg := fs.String("URL", "", "web page to convert (required)")
  61. saveArg := fs.String("SAVE", "", "file or folder to write the book to (required)")
  62. force := fs.Bool("FORCE", false, "overwrite the output file if it already exists")
  63. kepub := fs.Bool("KEPUB", false, "write a Kobo KEPUB (.kepub.epub) instead of a plain EPUB")
  64. showVersion := fs.Bool("VERSION", false, "print the version and exit")
  65. if err := fs.Parse(args); err != nil {
  66. if errors.Is(err, flag.ErrHelp) {
  67. return exitOK
  68. }
  69. return exitUsage // flag has already printed the error and usage
  70. }
  71. fail := func(code int, format string, a ...any) int {
  72. fmt.Fprintf(stderr, "error: "+format+"\n", a...)
  73. return code
  74. }
  75. usageError := func(format string, a ...any) int {
  76. fail(exitUsage, format, a...)
  77. fmt.Fprint(stderr, "\n"+usage)
  78. return exitUsage
  79. }
  80. if *showVersion {
  81. fmt.Fprintln(stdout, "rekopub", versionString())
  82. return exitOK
  83. }
  84. if fs.NArg() > 0 {
  85. return usageError("unexpected argument %q", fs.Arg(0))
  86. }
  87. if *urlArg == "" {
  88. return usageError("-URL is required")
  89. }
  90. if *saveArg == "" {
  91. return usageError("-SAVE is required")
  92. }
  93. pageURL, err := parseURL(*urlArg)
  94. if err != nil {
  95. return usageError("-URL %q: %v", *urlArg, err)
  96. }
  97. target, err := save.Resolve(*saveArg, *kepub)
  98. if err != nil {
  99. return usageError("-SAVE: %v", err)
  100. }
  101. if target.Note != "" {
  102. fmt.Fprintf(stderr, "note: %s\n", target.Note)
  103. }
  104. // Fail before downloading anything when the filename is already known.
  105. if target.Known() && !*force {
  106. if err := save.CheckExists(target.Path("")); err != nil {
  107. return saveFailure(fail, err)
  108. }
  109. }
  110. opts := pipeline.Options{
  111. Warn: func(msg string) { fmt.Fprintf(stderr, "warning: %s\n", msg) },
  112. KEPUB: *kepub,
  113. }
  114. if !target.Known() && !*force {
  115. // For folder targets the filename comes from the title; check it
  116. // before downloading images.
  117. opts.BeforeImages = func(title string) error { return save.CheckExists(target.Path(title)) }
  118. }
  119. book, err := convert(ctx, pageURL, opts)
  120. switch {
  121. case errors.Is(err, context.Canceled):
  122. return fail(exitInterrupted, "interrupted")
  123. case errors.Is(err, save.ErrExists):
  124. return saveFailure(fail, err)
  125. case errors.Is(err, pipeline.ErrFetch):
  126. return fail(exitFetch, "%v", err)
  127. case errors.Is(err, pipeline.ErrNoContent):
  128. return fail(exitNoContent, "%v", err)
  129. case err != nil:
  130. return fail(exitBuild, "%v", err)
  131. }
  132. path := target.Path(book.Title)
  133. if !*force {
  134. if err := save.CheckExists(path); err != nil {
  135. return saveFailure(fail, err)
  136. }
  137. }
  138. if err := save.Write(path, book.Content, *force); err != nil {
  139. return saveFailure(fail, err)
  140. }
  141. fmt.Fprintln(stdout, path)
  142. return exitOK
  143. }
  144. // saveFailure reports an error from the save package with its exit code.
  145. func saveFailure(fail func(int, string, ...any) int, err error) int {
  146. if errors.Is(err, save.ErrExists) {
  147. return fail(exitExists, "%v (use -FORCE to overwrite)", err)
  148. }
  149. return fail(exitBuild, "saving: %v", err)
  150. }
  151. // parseURL accepts absolute http and https URLs only.
  152. func parseURL(raw string) (*url.URL, error) {
  153. u, err := url.Parse(raw)
  154. if err != nil {
  155. return nil, errors.New("not a valid URL")
  156. }
  157. if u.Scheme != "http" && u.Scheme != "https" {
  158. return nil, errors.New("must start with http:// or https://")
  159. }
  160. if u.Host == "" {
  161. return nil, errors.New("missing host name")
  162. }
  163. return u, nil
  164. }