| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187 |
- // Command rekopub converts a web article into an EPUB for e-ink readers.
- //
- // rekopub -URL <url> -SAVE <path> [-FORCE] [-KEPUB] [-NOLOCAL]
- package main
- import (
- "context"
- "errors"
- "flag"
- "fmt"
- "io"
- "net/url"
- "os"
- "os/signal"
- "git.monoexp.com/bajdev/rekopub/internal/pipeline"
- "git.monoexp.com/bajdev/rekopub/internal/save"
- )
- // Exit codes, as documented in the README.
- const (
- exitOK = 0
- exitFetch = 1 // page couldn't be fetched
- exitUsage = 2 // invalid arguments
- exitNoContent = 3 // no readable content found
- exitBuild = 4 // book couldn't be built or written
- exitExists = 5 // output exists and -FORCE wasn't given
- // exitInterrupted is the conventional code for a program stopped
- // with Ctrl-C (128 + SIGINT).
- exitInterrupted = 130
- )
- const usage = `Usage: rekopub -URL <url> -SAVE <path> [-FORCE] [-KEPUB] [-NOLOCAL]
- Convert a web article into an EPUB for e-ink readers.
- -URL string
- Web page to convert (required). Must be http:// or https://.
- -SAVE string
- Where to write the book (required). Missing folders are created.
- - Ends in "/" → a folder: <folder>/<article-title>.epub
- - An existing folder → same as above
- - Ends in .epub → written to exactly that file
- - Anything else → ".epub" is appended
- With -KEPUB the extension is ".kepub.epub" instead.
- -FORCE
- Overwrite the output file if it already exists.
- -KEPUB
- Write a Kobo KEPUB (.kepub.epub) instead of a plain EPUB.
- -NOLOCAL
- Never connect to local or private addresses, even when -URL is
- one. For servers that run rekopub on behalf of other people.
- -VERSION
- Print the version and exit.
- `
- // convertFunc turns a URL into a book. It is pipeline.Run in production and
- // a fake in tests.
- type convertFunc func(ctx context.Context, u *url.URL, opts pipeline.Options) (*pipeline.Book, error)
- func main() {
- ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
- code := run(ctx, os.Args[1:], os.Stdout, os.Stderr, pipeline.Run)
- stop()
- os.Exit(code)
- }
- func run(ctx context.Context, args []string, stdout, stderr io.Writer, convert convertFunc) int {
- fs := flag.NewFlagSet("rekopub", flag.ContinueOnError)
- fs.SetOutput(stderr)
- fs.Usage = func() { fmt.Fprint(stderr, usage) }
- urlArg := fs.String("URL", "", "web page to convert (required)")
- saveArg := fs.String("SAVE", "", "file or folder to write the book to (required)")
- force := fs.Bool("FORCE", false, "overwrite the output file if it already exists")
- kepub := fs.Bool("KEPUB", false, "write a Kobo KEPUB (.kepub.epub) instead of a plain EPUB")
- noLocal := fs.Bool("NOLOCAL", false, "never connect to local or private addresses")
- showVersion := fs.Bool("VERSION", false, "print the version and exit")
- if err := fs.Parse(args); err != nil {
- if errors.Is(err, flag.ErrHelp) {
- return exitOK
- }
- return exitUsage // flag has already printed the error and usage
- }
- fail := func(code int, format string, a ...any) int {
- fmt.Fprintf(stderr, "error: "+format+"\n", a...)
- return code
- }
- usageError := func(format string, a ...any) int {
- fail(exitUsage, format, a...)
- fmt.Fprint(stderr, "\n"+usage)
- return exitUsage
- }
- if *showVersion {
- fmt.Fprintln(stdout, "rekopub", versionString())
- return exitOK
- }
- if fs.NArg() > 0 {
- return usageError("unexpected argument %q", fs.Arg(0))
- }
- if *urlArg == "" {
- return usageError("-URL is required")
- }
- if *saveArg == "" {
- return usageError("-SAVE is required")
- }
- pageURL, err := parseURL(*urlArg)
- if err != nil {
- return usageError("-URL %q: %v", *urlArg, err)
- }
- target, err := save.Resolve(*saveArg, *kepub)
- if err != nil {
- return usageError("-SAVE: %v", err)
- }
- if target.Note != "" {
- fmt.Fprintf(stderr, "note: %s\n", target.Note)
- }
- // Fail before downloading anything when the filename is already known.
- if target.Known() && !*force {
- if err := save.CheckExists(target.Path("")); err != nil {
- return saveFailure(fail, err)
- }
- }
- opts := pipeline.Options{
- Warn: func(msg string) { fmt.Fprintf(stderr, "warning: %s\n", msg) },
- KEPUB: *kepub,
- NoLocal: *noLocal,
- }
- if !target.Known() && !*force {
- // For folder targets the filename comes from the title; check it
- // before downloading images.
- opts.BeforeImages = func(title string) error { return save.CheckExists(target.Path(title)) }
- }
- book, err := convert(ctx, pageURL, opts)
- switch {
- case errors.Is(err, context.Canceled):
- return fail(exitInterrupted, "interrupted")
- case errors.Is(err, save.ErrExists):
- return saveFailure(fail, err)
- case errors.Is(err, pipeline.ErrFetch):
- return fail(exitFetch, "%v", err)
- case errors.Is(err, pipeline.ErrNoContent):
- return fail(exitNoContent, "%v", err)
- case err != nil:
- return fail(exitBuild, "%v", err)
- }
- path := target.Path(book.Title)
- if !*force {
- if err := save.CheckExists(path); err != nil {
- return saveFailure(fail, err)
- }
- }
- if err := save.Write(path, book.Content, *force); err != nil {
- return saveFailure(fail, err)
- }
- fmt.Fprintln(stdout, path)
- return exitOK
- }
- // saveFailure reports an error from the save package with its exit code.
- func saveFailure(fail func(int, string, ...any) int, err error) int {
- if errors.Is(err, save.ErrExists) {
- return fail(exitExists, "%v (use -FORCE to overwrite)", err)
- }
- return fail(exitBuild, "saving: %v", err)
- }
- // parseURL accepts absolute http and https URLs only.
- func parseURL(raw string) (*url.URL, error) {
- u, err := url.Parse(raw)
- if err != nil {
- return nil, errors.New("not a valid URL")
- }
- if u.Scheme != "http" && u.Scheme != "https" {
- return nil, errors.New("must start with http:// or https://")
- }
- if u.Host == "" {
- return nil, errors.New("missing host name")
- }
- return u, nil
- }
|