├── examples ├── anilist-search │ ├── run.sh │ ├── README.md │ └── run.lua ├── fzf-native │ ├── run.sh │ ├── README.md │ └── run.lua └── fzf │ ├── README.md │ └── run.sh ├── fs └── fs.go ├── meta └── meta.go ├── main.go ├── .idea ├── vcs.xml ├── .gitignore ├── modules.xml └── mangalcli.iml ├── justfile ├── path └── path.go ├── cmd ├── cache.go ├── cmd.go ├── run.go └── download.go ├── cache └── cache.go ├── LICENSE ├── lua ├── helper.go ├── modules.go └── exec.go ├── go.mod ├── .gitignore ├── README.md └── go.sum /examples/anilist-search/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | mangalcli run "$(cat run.lua)" --vars="search=$1" -------------------------------------------------------------------------------- /fs/fs.go: -------------------------------------------------------------------------------- 1 | package fs 2 | 3 | import "github.com/spf13/afero" 4 | 5 | var FS = afero.NewOsFs() 6 | -------------------------------------------------------------------------------- /meta/meta.go: -------------------------------------------------------------------------------- 1 | package meta 2 | 3 | const ( 4 | AppName = "mangalcli" 5 | Version = "0.1.0" 6 | ) 7 | -------------------------------------------------------------------------------- /examples/fzf-native/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | mangalcli run "$(cat ./run.lua)" --provider "$1" --vars="title=$2" -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/mangalorg/mangalcli/cmd" 5 | ) 6 | 7 | func main() { 8 | cmd.Run() 9 | } 10 | -------------------------------------------------------------------------------- /examples/fzf-native/README.md: -------------------------------------------------------------------------------- 1 | # MangalCLI with native FZF 2 | 3 | ## Usage 4 | 5 | ```bash 6 | # download providers 7 | mangalcli download 8 | 9 | # run 10 | ./run.sh 11 | ``` -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env just --justfile 2 | 3 | go-mod := `go list` 4 | 5 | test: 6 | go test ./... 7 | 8 | generate: 9 | go generate ./... 10 | 11 | update: 12 | go get -u 13 | go mod tidy -v 14 | 15 | publish tag: 16 | GOPROXY=proxy.golang.org go list -m {{go-mod}}@{{tag}} 17 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /examples/anilist-search/README.md: -------------------------------------------------------------------------------- 1 | # Anilist search 2 | 3 | Searches for mangas on Anilist and returns selected manga (with fzf) as JSON 4 | 5 | ## Usage 6 | 7 | ``` 8 | ./run.sh 9 | ``` 10 | 11 | [![asciicast](https://asciinema.org/a/5PSpwMRPPKtjL0274ytChlrvB.svg)](https://asciinema.org/a/5PSpwMRPPKtjL0274ytChlrvB) 12 | -------------------------------------------------------------------------------- /path/path.go: -------------------------------------------------------------------------------- 1 | package path 2 | 3 | import ( 4 | "github.com/mangalorg/mangalcli/meta" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | func Cache() string { 10 | cacheDir, err := os.UserCacheDir() 11 | if err != nil { 12 | return filepath.Join("."+meta.AppName, "cache") 13 | } 14 | 15 | return filepath.Join(cacheDir, meta.AppName) 16 | } 17 | -------------------------------------------------------------------------------- /.idea/mangalcli.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /examples/anilist-search/run.lua: -------------------------------------------------------------------------------- 1 | local fzf = require('fzf') 2 | local json = require('json') 3 | local anilist = require('anilist') 4 | 5 | local mangas = anilist.search_mangas(Vars.search) 6 | 7 | local manga = fzf.select_one(mangas, function(manga) 8 | for _, title in ipairs({ 9 | manga.title.english, 10 | manga.title.romaji, 11 | manga.title.native, 12 | }) do 13 | if title ~= "" then 14 | return title 15 | end 16 | end 17 | end) 18 | 19 | json.print(manga) 20 | -------------------------------------------------------------------------------- /cmd/cache.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/mangalorg/mangalcli/fs" 6 | "github.com/mangalorg/mangalcli/path" 7 | ) 8 | 9 | type cacheCmd struct { 10 | Path cachePathCmd `cmd:"" help:"Show cache directory path"` 11 | Clear cacheClearCmd `cmd:"" help:"Remove cache directory"` 12 | } 13 | 14 | type cachePathCmd struct{} 15 | 16 | func (c *cachePathCmd) Run() error { 17 | fmt.Println(path.Cache()) 18 | return nil 19 | } 20 | 21 | type cacheClearCmd struct{} 22 | 23 | func (c *cacheClearCmd) Run() error { 24 | return fs.FS.RemoveAll(path.Cache()) 25 | } 26 | -------------------------------------------------------------------------------- /examples/fzf-native/run.lua: -------------------------------------------------------------------------------- 1 | local fzf = require('fzf') 2 | 3 | local manga = fzf.select_one(SearchMangas(Vars.title), function(item) 4 | return item:info().title 5 | end) 6 | 7 | local volume = fzf.select_one(MangaVolumes(manga), function(item) 8 | return "Volume " .. item:info().number 9 | end) 10 | 11 | local chapters = fzf.select_multi(VolumeChapters(volume), function(item) 12 | return item:info().title 13 | end) 14 | 15 | for _, chapter in ipairs(chapters) do 16 | print("Downloading " .. chapter:info().title) 17 | DownloadChapter(chapter, { 18 | format = "pdf" 19 | }) 20 | end 21 | -------------------------------------------------------------------------------- /examples/fzf/README.md: -------------------------------------------------------------------------------- 1 | # mangalcli-fzf 2 | 3 | See also [fzf-native](../fzf-native) 4 | 5 | In this example, `mangalcli` is wrapped in a shell script that 6 | uses `fzf` to interactively select manga/volume/chapter which it will 7 | open for reading in the end 8 | 9 | ## Usage 10 | 11 | ```bash 12 | run.sh 13 | ``` 14 | 15 | [![asciicast](https://asciinema.org/a/591780.svg)](https://asciinema.org/a/591780) 16 | 17 | You can get mangapill provider used in the video [from here](https://github.com/mangalorg/saturno/blob/261c5739eacb73525fbe52705b8862a11c14040f/luas/mangapill.lua) 18 | 19 | Or use this command: 20 | 21 | ```bash 22 | mangalcli download --all 23 | ``` -------------------------------------------------------------------------------- /cache/cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "github.com/mangalorg/mangalcli/fs" 5 | "github.com/mangalorg/mangalcli/path" 6 | "github.com/philippgille/gokv" 7 | "github.com/philippgille/gokv/bbolt" 8 | "github.com/philippgille/gokv/encoding" 9 | "log" 10 | "path/filepath" 11 | ) 12 | 13 | func New(name string) gokv.Store { 14 | cacheDir := path.Cache() 15 | err := fs.FS.MkdirAll(cacheDir, 0755) 16 | if err != nil { 17 | log.Fatal(err) 18 | } 19 | 20 | store, err := bbolt.NewStore(bbolt.Options{ 21 | BucketName: name, 22 | Path: filepath.Join(cacheDir, name+".db"), 23 | Codec: encoding.Gob, 24 | }) 25 | 26 | if err != nil { 27 | log.Fatal(err) 28 | } 29 | 30 | return store 31 | } 32 | -------------------------------------------------------------------------------- /cmd/cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/alecthomas/kong" 5 | "github.com/charmbracelet/log" 6 | "io" 7 | "os" 8 | ) 9 | 10 | var cmd struct { 11 | Run runCmd `cmd:"" help:"Run given script as string"` 12 | Cache cacheCmd `cmd:"" help:"Cache manipulation"` 13 | Download downloadCmd `cmd:"" help:"Download lua providers from GitHub"` 14 | Log string `enum:"stdout,stderr,none" default:"stderr" help:"Logging output. Possible values: stdout, stderr, none"` 15 | } 16 | 17 | func Run() { 18 | ctx := kong.Parse(&cmd) 19 | 20 | var logWriter io.Writer 21 | 22 | switch cmd.Log { 23 | case "none": 24 | logWriter = io.Discard 25 | case "stdout": 26 | logWriter = os.Stdout 27 | case "stderr": 28 | logWriter = os.Stderr 29 | default: 30 | panic("unknown log") 31 | } 32 | 33 | log.SetDefault(log.New(logWriter)) 34 | 35 | err := ctx.Run() 36 | ctx.FatalIfErrorf(err) 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /examples/fzf/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -euo pipefail 4 | 5 | 6 | SCRIPT=$(cat <<-EOF 7 | local mangas = SearchMangas(Vars.search) 8 | 9 | if not Vars.manga then 10 | for i, manga in ipairs(mangas) do 11 | local title = manga:info().title 12 | print(i, title) 13 | end 14 | 15 | return 16 | end 17 | 18 | local manga = mangas[tonumber(Vars.manga)] 19 | local volumes = MangaVolumes(manga) 20 | 21 | if not Vars.volume then 22 | for i, volume in ipairs(volumes) do 23 | local number = volume:info().number 24 | print(i, number) 25 | end 26 | 27 | return 28 | end 29 | 30 | local volume = volumes[tonumber(Vars.volume)] 31 | local chapters = VolumeChapters(volume) 32 | 33 | if not Vars.chapter then 34 | for i, chapter in ipairs(chapters) do 35 | local title = chapter:info().title 36 | print(i, title) 37 | end 38 | 39 | return 40 | end 41 | 42 | local chapter = chapters[tonumber(Vars.chapter)] 43 | 44 | DownloadChapter(chapter, { 45 | directory = Vars.dir, 46 | read_after = true 47 | }) 48 | 49 | EOF 50 | 51 | ) 52 | 53 | PROVIDER="$1" 54 | SEARCH="$2" 55 | 56 | select_one() { 57 | fzf --delimiter="\t" --with-nth=2.. | awk '{print $1}' 58 | } 59 | 60 | M() { 61 | mangalcli run "$SCRIPT" --provider "$PROVIDER" --vars="$1" 62 | } 63 | 64 | VARS="search=$SEARCH" 65 | 66 | echo "Searching for '$SEARCH'" 67 | MANGA=$(M "$VARS" | select_one) 68 | VARS="$VARS;manga=$MANGA" 69 | 70 | echo "Getting volumes" 71 | VOLUME=$(M "$VARS" | select_one) 72 | VARS="$VARS;volume=$VOLUME" 73 | 74 | echo "Getting chapters" 75 | CHAPTER=$(M "$VARS" | select_one) 76 | VARS="$VARS;chapter=$CHAPTER" 77 | 78 | 79 | echo "Downloading chapter for reading..." 80 | M "$VARS;dir=$(mktemp -d)" 81 | 82 | -------------------------------------------------------------------------------- /lua/helper.go: -------------------------------------------------------------------------------- 1 | package lua 2 | 3 | import ( 4 | "github.com/fatih/camelcase" 5 | json "github.com/json-iterator/go" 6 | orderedmap "github.com/wk8/go-ordered-map/v2" 7 | lua "github.com/yuin/gopher-lua" 8 | "strings" 9 | ) 10 | 11 | func marshal(value any) (string, error) { 12 | bytes, err := json.Marshal(value) 13 | if err != nil { 14 | return "", err 15 | } 16 | 17 | return string(bytes), nil 18 | } 19 | 20 | func luaValueToGo(value lua.LValue) any { 21 | switch value.Type() { 22 | case lua.LTNil: 23 | return nil 24 | case lua.LTBool: 25 | return bool(value.(lua.LBool)) 26 | case lua.LTNumber: 27 | return float64(value.(lua.LNumber)) 28 | case lua.LTString: 29 | return string(value.(lua.LString)) 30 | case lua.LTTable: 31 | table := value.(*lua.LTable) 32 | om := orderedmap.New[any, any]() 33 | 34 | var asMap = make(map[any]any) 35 | 36 | table.ForEach(func(key lua.LValue, value lua.LValue) { 37 | k, v := luaValueToGo(key), luaValueToGo(value) 38 | asMap[k] = v 39 | om.Set(k, v) 40 | }) 41 | 42 | // check if we can convert table to slice. 43 | // if not, return as map. 44 | var ( 45 | prev float64 = 0 46 | asSlice []any 47 | ) 48 | for pair := om.Oldest(); pair != nil; pair = pair.Next() { 49 | asNum, ok := pair.Key.(float64) 50 | if !ok || asNum != prev+1 { 51 | return asMap 52 | } 53 | 54 | prev = asNum 55 | asSlice = append(asSlice, pair.Value) 56 | } 57 | 58 | return asSlice 59 | case lua.LTUserData: 60 | return value.(*lua.LUserData).Value 61 | default: 62 | return nil 63 | } 64 | } 65 | 66 | func caseCamelToSnake(s string) string { 67 | words := camelcase.Split(s) 68 | for i, word := range words { 69 | words[i] = strings.ToLower(word) 70 | } 71 | 72 | return strings.Join(words, "_") 73 | } 74 | -------------------------------------------------------------------------------- /lua/modules.go: -------------------------------------------------------------------------------- 1 | package lua 2 | 3 | import ( 4 | "fmt" 5 | "github.com/ktr0731/go-fuzzyfinder" 6 | lua "github.com/yuin/gopher-lua" 7 | ) 8 | 9 | var moduleJSON = map[string]lua.LGFunction{ 10 | "print": func(state *lua.LState) int { 11 | value := state.CheckAny(1) 12 | json, err := marshal(luaValueToGo(value)) 13 | if err != nil { 14 | state.RaiseError(err.Error()) 15 | } 16 | 17 | fmt.Println(json) 18 | return 0 19 | }, 20 | } 21 | 22 | var moduleFZF = map[string]lua.LGFunction{ 23 | "select_one": func(state *lua.LState) int { 24 | var values []lua.LValue 25 | state.CheckTable(1).ForEach(func(_, value lua.LValue) { 26 | values = append(values, value) 27 | }) 28 | 29 | showFn := state.CheckFunction(2) 30 | 31 | index, err := fuzzyfinder.Find(values, func(i int) string { 32 | state.Push(showFn) 33 | state.Push(values[i]) 34 | 35 | if err := state.PCall(1, 1, nil); err != nil { 36 | state.RaiseError(err.Error()) 37 | } 38 | 39 | return state.Get(-1).String() 40 | }) 41 | 42 | if err != nil { 43 | state.RaiseError(err.Error()) 44 | } 45 | 46 | state.Push(values[index]) 47 | return 1 48 | }, 49 | "select_multi": func(state *lua.LState) int { 50 | var values []lua.LValue 51 | state.CheckTable(1).ForEach(func(_, value lua.LValue) { 52 | values = append(values, value) 53 | }) 54 | showFn := state.CheckFunction(2) 55 | 56 | indexes, err := fuzzyfinder.FindMulti(values, func(i int) string { 57 | state.Push(showFn) 58 | state.Push(values[i]) 59 | 60 | if err := state.PCall(1, 1, nil); err != nil { 61 | state.RaiseError(err.Error()) 62 | } 63 | 64 | return state.Get(-1).String() 65 | }) 66 | 67 | if err != nil { 68 | state.RaiseError(err.Error()) 69 | } 70 | 71 | table := state.NewTable() 72 | for _, index := range indexes { 73 | table.Append(values[index]) 74 | } 75 | 76 | state.Push(table) 77 | return 1 78 | }, 79 | } 80 | -------------------------------------------------------------------------------- /cmd/run.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "github.com/charmbracelet/log" 6 | "github.com/mangalorg/libmangal" 7 | "github.com/mangalorg/luaprovider" 8 | "github.com/mangalorg/mangalcli/cache" 9 | "github.com/mangalorg/mangalcli/fs" 10 | "github.com/mangalorg/mangalcli/lua" 11 | "net/http" 12 | "os" 13 | ) 14 | 15 | func newAnilist() libmangal.Anilist { 16 | options := libmangal.DefaultAnilistOptions() 17 | options.Log = func(msg string) { 18 | log.Info(msg) 19 | } 20 | 21 | options.QueryToIDsStore = cache.New("query-to-id") 22 | options.TitleToIDStore = cache.New("title-to-id") 23 | options.IDToMangaStore = cache.New("id-to-manga") 24 | options.AccessTokenStore = cache.New("access-token") 25 | 26 | return libmangal.NewAnilist(options) 27 | } 28 | 29 | type runCmd struct { 30 | Script string `arg:"" help:"Lua script string to execute. See wiki for more" required:""` 31 | Vars map[string]string `help:"Variables to pass to the exec script"` 32 | Provider string `help:"Path to the lua provider" optional:"" type:"existingfile"` 33 | } 34 | 35 | func (r *runCmd) Run() error { 36 | anilist := newAnilist() 37 | 38 | var client *libmangal.Client 39 | 40 | if r.Provider != "" { 41 | contents, err := os.ReadFile(r.Provider) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | loader, err := luaprovider.NewLoader(contents, luaprovider.Options{ 47 | HTTPClient: &http.Client{}, 48 | HTTPStore: cache.New("lua-http"), 49 | }) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | clientOptions := libmangal.DefaultClientOptions() 55 | clientOptions.Anilist = &anilist 56 | clientOptions.FS = fs.FS 57 | clientOptions.Log = func(msg string) { 58 | log.Info(msg) 59 | } 60 | 61 | c, err := libmangal.NewClient(context.Background(), loader, clientOptions) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | client = &c 67 | } 68 | 69 | return lua.Exec(context.Background(), r.Script, lua.ExecOptions{ 70 | Client: client, 71 | Anilist: &anilist, 72 | Variables: r.Vars, 73 | }) 74 | } 75 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mangalorg/mangalcli 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/alecthomas/kong v0.7.1 7 | github.com/charmbracelet/log v0.2.2 8 | github.com/fatih/camelcase v1.0.0 9 | github.com/gobwas/glob v0.2.3 10 | github.com/json-iterator/go v1.1.12 11 | github.com/ktr0731/go-fuzzyfinder v0.7.0 12 | github.com/mangalorg/libmangal v0.3.2 13 | github.com/mangalorg/luaprovider v0.3.6 14 | github.com/philippgille/gokv v0.6.0 15 | github.com/philippgille/gokv/bbolt v0.6.0 16 | github.com/philippgille/gokv/encoding v0.6.0 17 | github.com/spf13/afero v1.9.5 18 | github.com/wk8/go-ordered-map/v2 v2.1.7 19 | github.com/yuin/gluamapper v0.0.0-20150323120927-d836955830e7 20 | github.com/yuin/gopher-lua v1.1.0 21 | layeh.com/gopher-luar v1.0.11 22 | ) 23 | 24 | require ( 25 | github.com/JohannesKaufmann/html-to-markdown v1.4.0 // indirect 26 | github.com/PuerkitoBio/goquery v1.8.1 // indirect 27 | github.com/andybalholm/cascadia v1.3.2 // indirect 28 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 29 | github.com/bahlo/generic-list-go v0.2.0 // indirect 30 | github.com/buger/jsonparser v1.1.1 // indirect 31 | github.com/charmbracelet/lipgloss v0.7.1 // indirect 32 | github.com/cixtor/readability v1.0.0 // indirect 33 | github.com/gdamore/encoding v1.0.0 // indirect 34 | github.com/gdamore/tcell/v2 v2.6.0 // indirect 35 | github.com/go-logfmt/logfmt v0.6.0 // indirect 36 | github.com/hhrutter/lzw v1.0.0 // indirect 37 | github.com/hhrutter/tiff v1.0.0 // indirect 38 | github.com/ka-weihe/fast-levenshtein v0.0.0-20201227151214-4c99ee36a1ba // indirect 39 | github.com/ktr0731/go-ansisgr v0.1.0 // indirect 40 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 41 | github.com/mailru/easyjson v0.7.7 // indirect 42 | github.com/mattn/go-isatty v0.0.19 // indirect 43 | github.com/mattn/go-runewidth v0.0.14 // indirect 44 | github.com/mitchellh/mapstructure v1.5.0 // indirect 45 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 46 | github.com/modern-go/reflect2 v1.0.2 // indirect 47 | github.com/muesli/reflow v0.3.0 // indirect 48 | github.com/muesli/termenv v0.15.1 // indirect 49 | github.com/mvdan/xurls v1.1.0 // indirect 50 | github.com/nsf/termbox-go v1.1.1 // indirect 51 | github.com/pdfcpu/pdfcpu v0.4.1 // indirect 52 | github.com/philippgille/gokv/syncmap v0.6.0 // indirect 53 | github.com/philippgille/gokv/util v0.6.0 // indirect 54 | github.com/pkg/errors v0.9.1 // indirect 55 | github.com/rivo/uniseg v0.4.4 // indirect 56 | github.com/robertkrimen/otto v0.2.1 // indirect 57 | github.com/samber/lo v1.38.1 // indirect 58 | github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect 59 | go.etcd.io/bbolt v1.3.7 // indirect 60 | golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 // indirect 61 | golang.org/x/image v0.8.0 // indirect 62 | golang.org/x/mod v0.11.0 // indirect 63 | golang.org/x/net v0.11.0 // indirect 64 | golang.org/x/sync v0.3.0 // indirect 65 | golang.org/x/sys v0.9.0 // indirect 66 | golang.org/x/term v0.9.0 // indirect 67 | golang.org/x/text v0.10.0 // indirect 68 | gopkg.in/sourcemap.v1 v1.0.5 // indirect 69 | gopkg.in/yaml.v2 v2.4.0 // indirect 70 | gopkg.in/yaml.v3 v3.0.1 // indirect 71 | ) 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | ######### 3 | # macOS # 4 | ######### 5 | 6 | # General 7 | .DS_Store 8 | .AppleDouble 9 | .LSOverride 10 | 11 | # Icon must end with two \r 12 | Icon 13 | 14 | # Thumbnails 15 | ._* 16 | 17 | # Files that might appear in the root of a volume 18 | .DocumentRevisions-V100 19 | .fseventsd 20 | .Spotlight-V100 21 | .TemporaryItems 22 | .Trashes 23 | .VolumeIcon.icns 24 | .com.apple.timemachine.donotpresent 25 | 26 | # Directories potentially created on remote AFP share 27 | .AppleDB 28 | .AppleDesktop 29 | Network Trash Folder 30 | Temporary Items 31 | .apdisk 32 | 33 | 34 | 35 | 36 | 37 | ###### 38 | # Go # 39 | ###### 40 | 41 | # If you prefer the allow list template instead of the deny list, see community template: 42 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 43 | # 44 | # Binaries for programs and plugins 45 | *.exe 46 | *.exe~ 47 | *.dll 48 | *.so 49 | *.dylib 50 | 51 | # Test binary, built with `go test -c` 52 | *.test 53 | 54 | # Output of the go coverage tool, specifically when used with LiteIDE 55 | *.out 56 | 57 | # Dependency directories (remove the comment below to include it) 58 | # vendor/ 59 | 60 | # Go workspace file 61 | go.work 62 | 63 | 64 | 65 | 66 | 67 | ############# 68 | # JetBrains # 69 | ############# 70 | 71 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 72 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 73 | 74 | # User-specific stuff 75 | .idea/**/workspace.xml 76 | .idea/**/tasks.xml 77 | .idea/**/usage.statistics.xml 78 | .idea/**/dictionaries 79 | .idea/**/shelf 80 | 81 | # AWS User-specific 82 | .idea/**/aws.xml 83 | 84 | # Generated files 85 | .idea/**/contentModel.xml 86 | 87 | # Sensitive or high-churn files 88 | .idea/**/dataSources/ 89 | .idea/**/dataSources.ids 90 | .idea/**/dataSources.local.xml 91 | .idea/**/sqlDataSources.xml 92 | .idea/**/dynamic.xml 93 | .idea/**/uiDesigner.xml 94 | .idea/**/dbnavigator.xml 95 | 96 | # Gradle 97 | .idea/**/gradle.xml 98 | .idea/**/libraries 99 | 100 | # Gradle and Maven with auto-import 101 | # When using Gradle or Maven with auto-import, you should exclude module files, 102 | # since they will be recreated, and may cause churn. Uncomment if using 103 | # auto-import. 104 | # .idea/artifacts 105 | # .idea/compiler.xml 106 | # .idea/jarRepositories.xml 107 | # .idea/modules.xml 108 | # .idea/*.iml 109 | # .idea/modules 110 | # *.iml 111 | # *.ipr 112 | 113 | # CMake 114 | cmake-build-*/ 115 | 116 | # Mongo Explorer plugin 117 | .idea/**/mongoSettings.xml 118 | 119 | # File-based project format 120 | *.iws 121 | 122 | # IntelliJ 123 | out/ 124 | 125 | # mpeltonen/sbt-idea plugin 126 | .idea_modules/ 127 | 128 | # JIRA plugin 129 | atlassian-ide-plugin.xml 130 | 131 | # Cursive Clojure plugin 132 | .idea/replstate.xml 133 | 134 | # SonarLint plugin 135 | .idea/sonarlint/ 136 | 137 | # Crashlytics plugin (for Android Studio and IntelliJ) 138 | com_crashlytics_export_strings.xml 139 | crashlytics.properties 140 | crashlytics-build.properties 141 | fabric.properties 142 | 143 | # Editor-based Rest Client 144 | .idea/httpRequests 145 | 146 | # Android studio 3.1+ serialized cache file 147 | .idea/caches/build_file_checksums.ser 148 | -------------------------------------------------------------------------------- /cmd/download.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/base64" 5 | "errors" 6 | "fmt" 7 | "github.com/charmbracelet/log" 8 | "github.com/gobwas/glob" 9 | json "github.com/json-iterator/go" 10 | "github.com/ktr0731/go-fuzzyfinder" 11 | "github.com/mangalorg/mangalcli/fs" 12 | "github.com/spf13/afero" 13 | "io" 14 | "net/http" 15 | "net/url" 16 | "os" 17 | "path/filepath" 18 | "sync" 19 | ) 20 | 21 | var ( 22 | gitHubAPIURL, _ = url.Parse("https://api.github.com") 23 | ) 24 | 25 | type downloadCmd struct { 26 | Owner string `help:"Owner of the GitHub repo" default:"mangalorg"` 27 | Branch string `help:"Repo branch" default:"main"` 28 | Repo string `help:"GitHub repo that contains lua providers" default:"saturno"` 29 | Glob string `help:"Glob pattern of the files to download" default:"*.lua"` 30 | Dir string `help:"Output directory" default:"." type:"existingdir"` 31 | All bool `help:"Download all files that match the glob pattern"` 32 | } 33 | 34 | type gitHubTreeItem struct { 35 | Path string `json:"path"` 36 | URL string `json:"url"` 37 | 38 | // Type of the tree item. 39 | // 40 | // `blob` is a file. 41 | // `tree` is a directory. 42 | Type string `json:"type"` 43 | } 44 | 45 | func (g gitHubTreeItem) downloadAndDecodeContents() ([]byte, error) { 46 | request, err := newGitHubApiRequest(g.URL) 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | response, err := http.DefaultClient.Do(request) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | defer response.Body.Close() 57 | 58 | if response.StatusCode != http.StatusOK { 59 | return nil, errors.New(response.Status) 60 | } 61 | 62 | buffer, err := io.ReadAll(response.Body) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | var blob struct { 68 | Content string `json:"content"` 69 | Encoding string `json:"encoding"` 70 | } 71 | 72 | err = json.Unmarshal(buffer, &blob) 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | switch blob.Encoding { 78 | case "base64": 79 | encoding := base64.RawStdEncoding 80 | return encoding.DecodeString(blob.Content) 81 | default: 82 | return nil, fmt.Errorf("unsupported encoding: %s", blob.Encoding) 83 | } 84 | } 85 | 86 | type gitHubTree struct { 87 | Tree []gitHubTreeItem `json:"tree"` 88 | } 89 | 90 | func newGitHubApiRequest(URL string) (*http.Request, error) { 91 | request, err := http.NewRequest(http.MethodGet, URL, nil) 92 | if err != nil { 93 | return nil, err 94 | } 95 | 96 | request.Header.Set("X-GitHub-Api-Version", "2022-11-28") 97 | request.Header.Set("Accept", "application/vnd.github+json") 98 | 99 | if token, ok := os.LookupEnv("GITHUB_TOKEN"); ok { 100 | request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) 101 | } 102 | 103 | return request, nil 104 | } 105 | 106 | func (d *downloadCmd) getTreeSHA() (string, error) { 107 | URL := gitHubAPIURL.JoinPath("repos", d.Owner, d.Repo, "branches", d.Branch) 108 | request, err := newGitHubApiRequest(URL.String()) 109 | if err != nil { 110 | return "", err 111 | } 112 | 113 | response, err := http.DefaultClient.Do(request) 114 | if err != nil { 115 | return "", err 116 | } 117 | 118 | defer response.Body.Close() 119 | 120 | if response.StatusCode != http.StatusOK { 121 | return "", errors.New(response.Status) 122 | } 123 | 124 | buffer, err := io.ReadAll(response.Body) 125 | if err != nil { 126 | return "", err 127 | } 128 | 129 | var r struct { 130 | Commit struct { 131 | SHA string `json:"sha"` 132 | } `json:"commit"` 133 | } 134 | 135 | err = json.Unmarshal(buffer, &r) 136 | if err != nil { 137 | return "", err 138 | } 139 | 140 | return r.Commit.SHA, nil 141 | } 142 | 143 | func (d *downloadCmd) getTree() (gitHubTree, error) { 144 | SHA, err := d.getTreeSHA() 145 | if err != nil { 146 | return gitHubTree{}, err 147 | } 148 | 149 | URL := gitHubAPIURL.JoinPath("repos", d.Owner, d.Repo, "git", "trees", SHA) 150 | 151 | params := url.Values{} 152 | params.Set("recursive", "1") 153 | 154 | URL.RawQuery = params.Encode() 155 | 156 | request, err := newGitHubApiRequest(URL.String()) 157 | if err != nil { 158 | return gitHubTree{}, err 159 | } 160 | 161 | response, err := http.DefaultClient.Do(request) 162 | if err != nil { 163 | return gitHubTree{}, err 164 | } 165 | 166 | defer response.Body.Close() 167 | 168 | if response.StatusCode != http.StatusOK { 169 | return gitHubTree{}, errors.New(response.Status) 170 | } 171 | 172 | buffer, err := io.ReadAll(response.Body) 173 | if err != nil { 174 | return gitHubTree{}, err 175 | } 176 | 177 | var tree gitHubTree 178 | 179 | err = json.Unmarshal(buffer, &tree) 180 | if err != nil { 181 | return gitHubTree{}, err 182 | } 183 | 184 | return tree, nil 185 | } 186 | 187 | func (d *downloadCmd) getFilteredFiles() ([]*gitHubTreeItem, error) { 188 | tree, err := d.getTree() 189 | if err != nil { 190 | return nil, err 191 | } 192 | 193 | var items []*gitHubTreeItem 194 | 195 | pattern, err := glob.Compile(d.Glob) 196 | if err != nil { 197 | return nil, err 198 | } 199 | 200 | for _, item := range tree.Tree { 201 | if item.Type == "blob" && pattern.Match(item.Path) { 202 | items = append(items, &item) 203 | } 204 | } 205 | 206 | return items, nil 207 | } 208 | 209 | func (d *downloadCmd) Run() error { 210 | files, err := d.getFilteredFiles() 211 | if err != nil { 212 | return err 213 | } 214 | 215 | var filesToDownload []*gitHubTreeItem 216 | 217 | if d.All { 218 | filesToDownload = files 219 | } else { 220 | indexes, err := fuzzyfinder.FindMulti(files, func(i int) string { 221 | return filepath.Base(files[i].Path) 222 | }, fuzzyfinder.WithHeader("Select providers to download. TAB to select multiple")) 223 | 224 | if err != nil { 225 | if errors.Is(err, fuzzyfinder.ErrAbort) { 226 | os.Exit(1) 227 | } 228 | 229 | return err 230 | } 231 | 232 | filesToDownload = make([]*gitHubTreeItem, len(indexes)) 233 | for i, index := range indexes { 234 | filesToDownload[i] = files[index] 235 | } 236 | } 237 | 238 | var wg sync.WaitGroup 239 | 240 | wg.Add(len(filesToDownload)) 241 | 242 | for _, file := range filesToDownload { 243 | go func(file *gitHubTreeItem) { 244 | defer wg.Done() 245 | 246 | log.Info("downloading", "file", file.Path) 247 | 248 | reader, err := file.downloadAndDecodeContents() 249 | if err != nil { 250 | log.Error(err, "file", file.Path) 251 | return 252 | } 253 | 254 | filename := filepath.Base(file.Path) 255 | 256 | err = afero.WriteFile(fs.FS, filepath.Join(d.Dir, filename), reader, 0755) 257 | if err != nil { 258 | log.Error(err, "file", file.Path) 259 | return 260 | } 261 | 262 | log.Info("done", "file", file.Path) 263 | }(file) 264 | } 265 | 266 | wg.Wait() 267 | 268 | return nil 269 | } 270 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | robot hand 3 |

MangalCLI

4 |
5 | 6 | Frontend for the [libmangal](https://github.com/mangalorg/libmangal) and 7 | [luaprovider](https://github.com/mangalorg/luaprovider) wrapped 8 | as a CLI app. 9 | 10 | [See also how to create lua providers](https://github.com/mangalorg/luaprovider) 11 | 12 | ## Example 13 | 14 | [See more examples here](./examples) 15 | 16 | > Documentation is still work in progress... 😪 17 | 18 | ```bash 19 | mangalcli run "$(cat run.lua)" --vars="title=chainsaw man" --provider mangapill.lua 20 | ``` 21 | 22 | **...where**: 23 | 24 | `mangapill.lua` looks like [this](https://github.com/mangalorg/saturno/blob/261c5739eacb73525fbe52705b8862a11c14040f/luas/mangapill.lua); 25 | 26 | > You can download it by using `mangalcli download` command 27 | 28 | `run.lua` looks like this: 29 | 30 | ```lua 31 | local json = require('json') 32 | local fzf = require('fzf') 33 | 34 | local mangas = SearchMangas(Vars.title) -- search with the given title 35 | local volumes = MangaVolumes(fzf.select(mangas, function(manga) 36 | return manga:info().title 37 | end)) -- select the first manga 38 | 39 | local chapters = {} 40 | 41 | -- get all chapters of the manga 42 | for _, volume in ipairs(volumes) do 43 | for _, chapter in ipairs(VolumeChapters(volume)) do 44 | table.insert(chapters, chapter) 45 | end 46 | end 47 | 48 | -- chapters encoded in json format for later use, e.g. pipe to jq 49 | json.print(chapters) 50 | ``` 51 | 52 | and the output would be like the following 53 | 54 | ```json 55 | [ 56 | { 57 | "title": "Chapter 1", 58 | "url": "https://mangapill.com/chapters/723-10001000/chainsaw-man-chapter-1", 59 | "number": 1 60 | }, 61 | { 62 | "title": "Chapter 2", 63 | "url": "https://mangapill.com/chapters/723-10002000/chainsaw-man-chapter-2", 64 | "number": 2 65 | }, 66 | // etc 67 | ] 68 | ``` 69 | 70 | ## Install 71 | 72 | It's not packaged for any package manager *yet* 73 | so the only option is to build from source. 74 | 75 | Either like this: 76 | 77 | ```bash 78 | go install github.com/mangalorg/mangalcli@latest 79 | ``` 80 | 81 | Or like this: 82 | 83 | ```bash 84 | git clone github.com/mangalorg/mangalcli 85 | cd mangalcli 86 | go install . 87 | ``` 88 | 89 | To download lua providers (e.g. mangapill) use this command 90 | 91 | ```bash 92 | mangalcli download 93 | ``` 94 | 95 | ## Scripts 96 | 97 | Scripts use Lua5.1(+ goto statement from Lua5.2) 98 | 99 | `sdk` package from [luaprovider](https://github.com/mangalorg/luaprovider) 100 | isn't available (subject of change). 101 | 102 | Available functions: 103 | 104 | ```lua 105 | --- @alias Manga userdata 106 | --- @alias Volume userdata 107 | --- @alias Chapter userdata 108 | --- @alias Page userdata 109 | 110 | --- @alias MangaInfo { title: string, id: number, url: string, cover: string, banner: string } 111 | --- @alias VolumeInfo { number: number } 112 | --- @alias ChapterInfo { title: string, url: string, number: number } 113 | 114 | --- @alias Format "pdf" | "cbz" | "images" 115 | 116 | --- @alias DownloadOptions { format: Format, directory: string, create_manga_dir: boolean, create_volume_dir: boolean, strict: boolean, skip_if_exists: boolean, download_manga_cover: boolean, download_manga_banner: boolean, write_series_json: boolean, write_comic_info_xml: boolean, read_after: boolean, read_incognito: boolean } 117 | 118 | --- Searches mangas by the given query. 119 | --- @param query string 120 | --- @return []MangaInfo 121 | function SearchMangas(query) end 122 | 123 | --- Gets all volumes of the given manga. 124 | --- @param manga Manga 125 | --- @return []Volume 126 | function MangaVolumes(manga) end 127 | 128 | --- Gets all chapters of the given volume. 129 | --- @param volume Volume 130 | --- @return []Chapter 131 | function VolumeChapters(volume) end 132 | 133 | --- Gets all pages of the given chapter. 134 | --- Note, that if you want to download the chapter, you should use DownloadChapter() instead. 135 | --- @param chapter Chapter 136 | --- @return []Page 137 | function ChapterPages(chapter) end 138 | 139 | 140 | --- Downloads the given chapter 141 | --- @param chapter Chapter 142 | --- @param options DownloadOptions? 143 | function DownloadChapter(chapter, options) end 144 | 145 | --- Manga, Volume and Chapter has :info() method that would 146 | --- return appropriate info table as defined above 147 | --- 148 | --- e.g. manga:info().title 149 | ``` 150 | 151 | Comes with `anilist` package that has the following functions available: 152 | 153 | ```lua 154 | local anilist = {} 155 | 156 | --- @alias AnilistManga TODO, see https://pkg.go.dev/github.com/mangalorg/libmangal#AnilistManga 157 | 158 | --- Find closest anilist manga by title. 159 | --- @param title string 160 | --- @return AnilistManga? 161 | function anilist.find_closest_manga(title) end 162 | 163 | --- Search anilist mangas by title 164 | --- @param title string 165 | --- @return []AnilistManga 166 | function anilist.search_mangas(title) end 167 | 168 | --- Get anilist manga by its id 169 | --- @param id number 170 | --- @return AnilistManga? 171 | function anilist.get_manga_by_id(id) end 172 | 173 | --- Binds title to anilist manga id. 174 | --- Will be used by anilist.find_closest_manga 175 | --- @param title string 176 | --- @param id number 177 | function anilist.bind_title_to_id(title, id) end 178 | 179 | return anilist 180 | ``` 181 | 182 | And `json` package 183 | 184 | ```lua 185 | local json = {} 186 | 187 | --- Prints data in json format to stdout 188 | --- @param data any 189 | function json.print(data) end 190 | 191 | return json 192 | ``` 193 | 194 | And `fzf` package 195 | 196 | ```lua 197 | local fzf = {} 198 | 199 | --- Will open fzf window with the given items and show function. 200 | --- Selected item will be returned. 201 | --- @generic T 202 | --- @param items T[] 203 | --- @param show function(T): string 204 | --- @return T 205 | function fzf.select_one(items, show) end 206 | 207 | --- Similar to fzf.select_one but allows to select multiple items using tab key. 208 | --- Selected items will be returned. 209 | --- @generic T 210 | --- @param items T[] 211 | --- @param show function(T): string 212 | --- @return T[] 213 | function fzf.select_multi(items, show) end 214 | 215 | return fzf 216 | ``` 217 | 218 | Example: 219 | 220 | ```lua 221 | local anilist = require("anilist") 222 | local json = require("json") 223 | 224 | json.print(anilist.search_mangas("one piece")) 225 | ``` 226 | 227 | And `Vars` global table with variables passed from `--vars` flag. 228 | 229 | Used like this: 230 | 231 | ```bash 232 | --vars="key1=value1;key2=value2;key3=18.2" 233 | ``` 234 | 235 | ```lua 236 | print(Vars.key1) -- value1 237 | print(Vars.key2) -- value2 238 | 239 | -- Note, that all values are passed as strings. 240 | -- If you want to get number value use 241 | 242 | print(tonumber(Vars.key3)) -- 18.2 243 | ``` 244 | 245 | ## Usage 246 | 247 | ``` 248 | Usage: mangalcli 249 | 250 | Flags: 251 | -h, --help Show context-sensitive help. 252 | --log="none" Logging output. Possible values: stdout, stderr, none 253 | 254 | Commands: 255 | run