1
0

main.go 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. // Command rekopub converts a web article into an EPUB for e-ink readers.
  2. //
  3. // rekopub -URL <url> -SAVE <path> [-FORCE] [-KEPUB] [-LOCAL]
  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] [-LOCAL]
  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. -LOCAL
  45. Allow -URL to be on this computer or a private network, such as
  46. a wiki at home. Without it, local and private addresses are
  47. refused.
  48. -VERSION
  49. Print the version and exit.
  50. `
  51. // convertFunc turns a URL into a book. It is pipeline.Run in production and
  52. // a fake in tests.
  53. type convertFunc func(ctx context.Context, u *url.URL, opts pipeline.Options) (*pipeline.Book, error)
  54. func main() {
  55. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
  56. code := run(ctx, os.Args[1:], os.Stdout, os.Stderr, pipeline.Run)
  57. stop()
  58. os.Exit(code)
  59. }
  60. func run(ctx context.Context, args []string, stdout, stderr io.Writer, convert convertFunc) int {
  61. fs := flag.NewFlagSet("rekopub", flag.ContinueOnError)
  62. fs.SetOutput(stderr)
  63. fs.Usage = func() { fmt.Fprint(stderr, usage) }
  64. urlArg := fs.String("URL", "", "web page to convert (required)")
  65. saveArg := fs.String("SAVE", "", "file or folder to write the book to (required)")
  66. force := fs.Bool("FORCE", false, "overwrite the output file if it already exists")
  67. kepub := fs.Bool("KEPUB", false, "write a Kobo KEPUB (.kepub.epub) instead of a plain EPUB")
  68. local := fs.Bool("LOCAL", false, "allow -URL to be a local or private address")
  69. showVersion := fs.Bool("VERSION", false, "print the version and exit")
  70. if err := fs.Parse(args); err != nil {
  71. if errors.Is(err, flag.ErrHelp) {
  72. return exitOK
  73. }
  74. return exitUsage // flag has already printed the error and usage
  75. }
  76. fail := func(code int, format string, a ...any) int {
  77. fmt.Fprintf(stderr, "error: "+format+"\n", a...)
  78. return code
  79. }
  80. usageError := func(format string, a ...any) int {
  81. fail(exitUsage, format, a...)
  82. fmt.Fprint(stderr, "\n"+usage)
  83. return exitUsage
  84. }
  85. if *showVersion {
  86. fmt.Fprintln(stdout, "rekopub", versionString())
  87. return exitOK
  88. }
  89. if fs.NArg() > 0 {
  90. return usageError("unexpected argument %q", fs.Arg(0))
  91. }
  92. if *urlArg == "" {
  93. return usageError("-URL is required")
  94. }
  95. if *saveArg == "" {
  96. return usageError("-SAVE is required")
  97. }
  98. pageURL, err := parseURL(*urlArg)
  99. if err != nil {
  100. return usageError("-URL %q: %v", *urlArg, err)
  101. }
  102. target, err := save.Resolve(*saveArg, *kepub)
  103. if err != nil {
  104. return usageError("-SAVE: %v", err)
  105. }
  106. if target.Note != "" {
  107. fmt.Fprintf(stderr, "note: %s\n", target.Note)
  108. }
  109. // Fail before downloading anything when the filename is already known.
  110. if target.Known() && !*force {
  111. if err := save.CheckExists(target.Path("")); err != nil {
  112. return saveFailure(fail, err)
  113. }
  114. }
  115. opts := pipeline.Options{
  116. Warn: func(msg string) { fmt.Fprintf(stderr, "warning: %s\n", msg) },
  117. KEPUB: *kepub,
  118. Local: *local,
  119. }
  120. if !target.Known() && !*force {
  121. // For folder targets the filename comes from the title; check it
  122. // before downloading images.
  123. opts.BeforeImages = func(title string) error { return save.CheckExists(target.Path(title)) }
  124. }
  125. book, err := convert(ctx, pageURL, opts)
  126. switch {
  127. case errors.Is(err, context.Canceled):
  128. return fail(exitInterrupted, "interrupted")
  129. case errors.Is(err, save.ErrExists):
  130. return saveFailure(fail, err)
  131. case errors.Is(err, pipeline.ErrLocal):
  132. return fail(exitUsage, "-URL %s is on this computer or a private network; add -LOCAL to convert it", pageURL.Hostname())
  133. case errors.Is(err, pipeline.ErrFetch):
  134. return fail(exitFetch, "%v", err)
  135. case errors.Is(err, pipeline.ErrNoContent):
  136. return fail(exitNoContent, "%v", err)
  137. case err != nil:
  138. return fail(exitBuild, "%v", err)
  139. }
  140. path := target.Path(book.Title)
  141. if !*force {
  142. if err := save.CheckExists(path); err != nil {
  143. return saveFailure(fail, err)
  144. }
  145. }
  146. if err := save.Write(path, book.Content, *force); err != nil {
  147. return saveFailure(fail, err)
  148. }
  149. fmt.Fprintln(stdout, path)
  150. return exitOK
  151. }
  152. // saveFailure reports an error from the save package with its exit code.
  153. func saveFailure(fail func(int, string, ...any) int, err error) int {
  154. if errors.Is(err, save.ErrExists) {
  155. return fail(exitExists, "%v (use -FORCE to overwrite)", err)
  156. }
  157. return fail(exitBuild, "saving: %v", err)
  158. }
  159. // parseURL accepts absolute http and https URLs only.
  160. func parseURL(raw string) (*url.URL, error) {
  161. u, err := url.Parse(raw)
  162. if err != nil {
  163. return nil, errors.New("not a valid URL")
  164. }
  165. if u.Scheme != "http" && u.Scheme != "https" {
  166. return nil, errors.New("must start with http:// or https://")
  167. }
  168. if u.Host == "" {
  169. return nil, errors.New("missing host name")
  170. }
  171. return u, nil
  172. }