From 776664f3f58fe6323aebb3f3f70feb39d5701b25 Mon Sep 17 00:00:00 2001 From: Angel Garcia Date: Mon, 15 Jun 2026 10:56:17 -0600 Subject: [PATCH 1/2] Rewrite maifetch in Fortran --- .github/workflows/fortran.yml | 15 + .gitignore | 4 +- README.md | 15 +- cmd/maifetch/config.go | 85 ---- cmd/maifetch/main.go | 121 ----- cmd/maifetch/utils.go | 48 -- go.mod | 22 - go.sum | 42 -- pkg/maitea/game_information.go | 36 -- pkg/maitea/helpers.go | 53 --- pkg/maitea/maitea.go | 35 -- pkg/maitea/pagination.go | 108 ----- pkg/maitea/plays.go | 111 ----- pkg/maitea/profile.go | 65 --- pkg/maitea/website_information.go | 36 -- src/maifetch.f90 | 720 ++++++++++++++++++++++++++++++ test/fixtures/plays.json | 36 ++ test/fixtures/profile.json | 21 + test/run-fixture.sh | 26 ++ 19 files changed, 831 insertions(+), 768 deletions(-) create mode 100644 .github/workflows/fortran.yml delete mode 100644 cmd/maifetch/config.go delete mode 100644 cmd/maifetch/main.go delete mode 100644 cmd/maifetch/utils.go delete mode 100644 go.mod delete mode 100644 go.sum delete mode 100644 pkg/maitea/game_information.go delete mode 100644 pkg/maitea/helpers.go delete mode 100644 pkg/maitea/maitea.go delete mode 100644 pkg/maitea/pagination.go delete mode 100644 pkg/maitea/plays.go delete mode 100644 pkg/maitea/profile.go delete mode 100644 pkg/maitea/website_information.go create mode 100644 src/maifetch.f90 create mode 100644 test/fixtures/plays.json create mode 100644 test/fixtures/profile.json create mode 100755 test/run-fixture.sh diff --git a/.github/workflows/fortran.yml b/.github/workflows/fortran.yml new file mode 100644 index 0000000..9437490 --- /dev/null +++ b/.github/workflows/fortran.yml @@ -0,0 +1,15 @@ +name: Fortran + +on: + push: + pull_request: + +jobs: + fixture: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install gfortran + run: sudo apt-get update && sudo apt-get install -y gfortran + - name: Run fixture test + run: ./test/run-fixture.sh diff --git a/.gitignore b/.gitignore index 3b0cb0d..4a9b742 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .idea/ -*.exe \ No newline at end of file +build/ +*.mod +*.exe diff --git a/README.md b/README.md index f1bce25..7fee65b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ # maifetch -a really lazy fetch tool for [maitea](https://maitea.app) written in go\ -also contains a little api wrapper for maitea too :D +a really lazy fetch tool for [maitea](https://maitea.app) rewritten in Fortran. ![image](https://github.com/user-attachments/assets/96cd7018-8a00-4785-a1a8-9fe503263662) @@ -23,13 +22,19 @@ obtained from `os.UserConfigDir` ## how to build 1. clone the project with `git clone https://github.com/HutchyBen/maifetch` -2. build with `go build maifetch/cmd/maifetch` -3. run outputted executable ensuring access token is either +2. install a Fortran compiler such as `gfortran` +3. build with `gfortran -std=f2008 -ffree-line-length-none src/maifetch.f90 -o maifetch` +4. run outputted executable ensuring access token is either - in config file - in environment variables - in command line options +## testing +Run the fixture test without a real MaiTea token: + +```sh +./test/run-fixture.sh +``` ## todo -- test it properly - add friendly errors diff --git a/cmd/maifetch/config.go b/cmd/maifetch/config.go deleted file mode 100644 index 23480b0..0000000 --- a/cmd/maifetch/config.go +++ /dev/null @@ -1,85 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - - "github.com/alecthomas/kong" - "github.com/kelseyhightower/envconfig" -) - -type MaifetchConfig struct { - AccessToken string `envconfig:"token" json:"accessToken" help:"Access Token for the MaiTea account" short:"t"` - ConfigFile *os.File `help:"Config file to use" short:"c" json:"-" envconfig:"CONFIG_FILE"` - ScoreCount uint `help:"Amount of recent scores to view (max 12)" json:"scoreCount" envconfig:"SCORE_COUNT" short:"s"` - LogoSize int `envconfig:"logo_size" json:"logoSize" help:"Size of the ASCII logo (<1 to disable)" short:"l"` -} - -// LoadConfig will load config in order of priority CLI > ENV_VARS > CONFIG_FILE -func LoadConfig() (*MaifetchConfig, error) { - var cliOptions MaifetchConfig // this is seperate as needs to be parsed first as generally want config file from here but takes priority later - var config MaifetchConfig - - defaultPath, err := os.UserConfigDir() - if err != nil { - return nil, err - } - - // default. I would do this as a kong default but I don't want to deal with what if user explicitly says 20 later on - config.LogoSize = 20 - config.ScoreCount = 4 // typical cab is 4 songs a credit from my small sample size of like 4 maimai places ive been - - kong.Parse(&cliOptions, kong.Vars{ - "config": defaultPath, - }) - - // Load JSON - if cliOptions.ConfigFile == nil { - // TODO: add a verbose error logging option - cliOptions.ConfigFile, _ = os.Open(defaultPath + "/maifetch.json") - } - - // if config file exists load it - if cliOptions.ConfigFile != nil { - defer func(jsonFile *os.File) { - err = jsonFile.Close() - if err != nil { - panic("could not close json file") - } - }(cliOptions.ConfigFile) - err = json.NewDecoder(cliOptions.ConfigFile).Decode(&config) - if err != nil { - return nil, err - } - } - - // Override JSON with environment - err = envconfig.Process("maifetch", &config) - if err != nil { - return nil, err - } - - // Override with cliOptions - if cliOptions.AccessToken != "" { - config.AccessToken = cliOptions.AccessToken - } - - if cliOptions.LogoSize != 0 { - config.LogoSize = cliOptions.LogoSize - } - - if cliOptions.ScoreCount > 0 { - config.ScoreCount = cliOptions.ScoreCount - } - - if config.AccessToken == "" { - return nil, fmt.Errorf("access token is required") - } - - if config.ScoreCount > 12 { - return nil, fmt.Errorf("score count cannot be higher than 12") - } - - return &config, nil -} diff --git a/cmd/maifetch/main.go b/cmd/maifetch/main.go deleted file mode 100644 index 2f75668..0000000 --- a/cmd/maifetch/main.go +++ /dev/null @@ -1,121 +0,0 @@ -package main - -import ( - "fmt" - _ "image/jpeg" - _ "image/png" - "maifetch/pkg/maitea" - "os" - "strings" - "time" - - "github.com/briandowns/spinner" -) - -func createInfoStrings(profile maitea.Profile, plays []maitea.Play, scoreCount uint) []string { - name := WideToNormal(profile.Name) - - scoreStrings := make([]string, scoreCount*3) // this is really lazy coding line1. name and diff, line2. score and achivement and fc label. line3. padding - - // get 5 latest plays - for i := uint(0); i < scoreCount; i++ { - play := plays[i] - fcLabel := "" - if play.FullComboLabel != nil { - fcLabel = *play.FullComboLabel - } - scoreStrings[i*3] = fmt.Sprintf(" %s %s", play.Song.Name.En, maitea.DifficultyString(play.DifficultyLevel.Value)) - scoreStrings[i*3+1] = fmt.Sprintf(" %s %s%% %s %s", play.ScoreFormatted, play.AchievementFormatted, maitea.RankString(play.Rank), fcLabel) - } - - return append([]string{ - Colour(name), - strings.Repeat("-", len(name)), - fmt.Sprintf("%s: %d", Colour("ID"), profile.Id), - fmt.Sprintf("%s: %.2f / %.2f", Colour("Rating"), float32(profile.Rating)/100.0, float32(profile.RatingHighest)/100), - fmt.Sprintf("%s: %d", Colour("Level"), profile.Level), - fmt.Sprintf("%s: %d", Colour("Total Credits"), profile.PlayStats.Total), - fmt.Sprintf("%s:", Colour("Recent Scores")), - }, scoreStrings...) -} - -func printCombined(infoLines []string, logoLines []string, logoSize int) { - maxLength := len(logoLines) - if len(infoLines) > maxLength { - maxLength = len(infoLines) - } - padding := strings.Repeat(" ", 2) - // Iterate up to the maximum length - for i := 0; i < maxLength; i++ { - logoStr := strings.Repeat(" ", logoSize*2) - infoStr := "" - if i < len(logoLines)-1 { - logoStr = logoLines[i] - } - if i < len(infoLines)-1 { - infoStr = infoLines[i] - } - fmt.Println(logoStr, padding, infoStr) - } -} - -func Output(page []maitea.Play, profile maitea.Profile, logoSize int, scoreCount uint) { - infoLines := createInfoStrings(profile, page, scoreCount) - - if logoSize > 0 { - logo, err := UrlToAscii(profile.Options.Icon.Png, logoSize) - if err != nil { - fmt.Println(err) - return - } - logoLines := strings.Split(logo, "\n") - printCombined(infoLines, logoLines, logoSize) - - } else { - fmt.Println(strings.Join(infoLines, "\n")) - } -} - -func main() { - config, err := LoadConfig() - if err != nil { - fmt.Println(err) - return - } - - client := maitea.NewAPIClient(config.AccessToken) - profiles, err := client.GetProfiles() - if err != nil { - fmt.Println(err) - return - } - - if len(profiles) == 0 { - fmt.Println("No profiles found") - return - } - - // get users recent plays - apiLoading := make(chan []maitea.Play) - go func() { - - plays, err := client.GetPlays() - if err != nil { - fmt.Println(err) - os.Exit(1) - } - apiLoading <- plays.CurrentPage() - }() - s := spinner.New(spinner.CharSets[9], 100*time.Millisecond) - s.Suffix = " Loading..." - s.Start() - select { - case plays := <-apiLoading: - s.Stop() - Output(plays, profiles[0], config.LogoSize, config.ScoreCount) - return - case <-time.After(30 * time.Second): - fmt.Println("API timed out") - return - } -} diff --git a/cmd/maifetch/utils.go b/cmd/maifetch/utils.go deleted file mode 100644 index 7852c94..0000000 --- a/cmd/maifetch/utils.go +++ /dev/null @@ -1,48 +0,0 @@ -package main - -import ( - "image" - "net/http" - "strings" - "unicode/utf8" - - "github.com/aybabtme/rgbterm" - "github.com/qeesung/image2ascii/convert" -) - -func WideToNormal(str string) string { - out := make([]rune, utf8.RuneCountInString(str)) - - i := 0 - for _, chr := range str { - out[i] = chr - 0xFEE0 - i++ - } - return string(out) -} - -func Colour(str string) string { - return rgbterm.FgString(str, 72, 184, 200) -} - -func UrlToAscii(url string, size int) (string, error) { - convertOptions := convert.DefaultOptions - convertOptions.FixedWidth = size * 2 - convertOptions.FixedHeight = size - // Create the image converter - converter := convert.NewImageConverter() - res, err := http.Get(url) - if err != nil { - return "", err - } - - img, _, err := image.Decode(res.Body) - if err != nil { - return "", err - } - - // make black black!!! - logo := converter.Image2ASCIIString(img, &convertOptions) - blankChar := rgbterm.FgString("#", 0, 0, 0) - return strings.ReplaceAll(logo, " ", blankChar), nil -} diff --git a/go.mod b/go.mod deleted file mode 100644 index e6e63a5..0000000 --- a/go.mod +++ /dev/null @@ -1,22 +0,0 @@ -module maifetch - -go 1.23.0 - -require ( - github.com/alecthomas/kong v0.9.0 - github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59 - github.com/briandowns/spinner v1.23.1 - github.com/kelseyhightower/envconfig v1.4.0 - github.com/qeesung/image2ascii v1.0.1 -) - -require ( - github.com/fatih/color v1.7.0 // indirect - github.com/mattn/go-colorable v0.1.2 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect - github.com/stretchr/testify v1.9.0 // indirect - github.com/wayneashleyberry/terminal-dimensions v1.1.0 // indirect - golang.org/x/sys v0.6.0 // indirect - golang.org/x/term v0.1.0 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 8e5ccac..0000000 --- a/go.sum +++ /dev/null @@ -1,42 +0,0 @@ -github.com/alecthomas/assert/v2 v2.6.0 h1:o3WJwILtexrEUk3cUVal3oiQY2tfgr/FHWiz/v2n4FU= -github.com/alecthomas/assert/v2 v2.6.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/kong v0.9.0 h1:G5diXxc85KvoV2f0ZRVuMsi45IrBgx9zDNGNj165aPA= -github.com/alecthomas/kong v0.9.0/go.mod h1:Y47y5gKfHp1hDc7CH7OeXgLIpp+Q2m1Ni0L5s3bI8Os= -github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= -github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= -github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59 h1:WWB576BN5zNSZc/M9d/10pqEx5VHNhaQ/yOVAkmj5Yo= -github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I= -github.com/briandowns/spinner v1.23.1 h1:t5fDPmScwUjozhDj4FA46p5acZWIPXYE30qW2Ptu650= -github.com/briandowns/spinner v1.23.1/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= -github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= -github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= -github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= -github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= -github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= -github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/qeesung/image2ascii v1.0.1 h1:Fe5zTnX/v/qNC3OC4P/cfASOXS501Xyw2UUcgrLgtp4= -github.com/qeesung/image2ascii v1.0.1/go.mod h1:kZKhyX0h2g/YXa/zdJR3JnLnJ8avHjZ3LrvEKSYyAyU= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/wayneashleyberry/terminal-dimensions v1.1.0 h1:EB7cIzBdsOzAgmhTUtTTQXBByuPheP/Zv1zL2BRPY6g= -github.com/wayneashleyberry/terminal-dimensions v1.1.0/go.mod h1:2lc/0eWCObmhRczn2SdGSQtgBooLUzIotkkEGXqghyg= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw= -golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/maitea/game_information.go b/pkg/maitea/game_information.go deleted file mode 100644 index 6cdf299..0000000 --- a/pkg/maitea/game_information.go +++ /dev/null @@ -1,36 +0,0 @@ -package maitea - -import ( - "encoding/json" -) - -type TrackInfo struct { - Id int `json:"id"` - Code string `json:"code"` - Name struct { - En string `json:"en"` - Jp string `json:"jp"` - } `json:"name"` - Artist struct { - En string `json:"en"` - Jp string `json:"jp"` - } `json:"artist"` -} - -// GetTracks will return every track available in MaiMai -func (api *APIClient) GetTracks() ([]TrackInfo, error) { - res, err := api.Get("/api/v1/tracks") - if err != nil { - return nil, err - } - - jsonRes := struct { - Data []TrackInfo `json:"data"` - }{} - - err = json.NewDecoder(res.Body).Decode(&jsonRes) - if err != nil { - return nil, err - } - return jsonRes.Data, nil -} diff --git a/pkg/maitea/helpers.go b/pkg/maitea/helpers.go deleted file mode 100644 index ae8e456..0000000 --- a/pkg/maitea/helpers.go +++ /dev/null @@ -1,53 +0,0 @@ -package maitea - -import "github.com/aybabtme/rgbterm" - -// this is absolute slop lolol - -func DifficultyString(diff string) string { - switch diff { - case "easy": - return rgbterm.String("Easy", 255, 255, 255, 69, 174, 255) - case "basic": - return rgbterm.String("Basic", 255, 255, 255, 111, 212, 61) - case "advanced": - return rgbterm.String("Advanced", 255, 255, 255, 248, 183, 9) - case "expert": - return rgbterm.String("Expert", 255, 255, 255, 255, 46, 66) - case "master": - return rgbterm.String("Master", 255, 255, 255, 171, 140, 233) - case "remaster": - case "re:master": - return rgbterm.String("Re:Master", 255, 255, 255, 207, 114, 237) - case "utage": - return rgbterm.String("Utage", 255, 255, 255, 255, 68, 1) - default: - return diff - } - return diff // why is this throwing an error? -} - -func RankString(rank string) string { - switch rank { - case "SSS+": - return rgbterm.FgString("S", 255, 200, 54) + rgbterm.FgString("S", 225, 38, 165) + rgbterm.FgString("S", 73, 64, 233) + rgbterm.FgString("+", 21, 203, 148) - case "SSS": - return rgbterm.FgString("S", 255, 200, 54) + rgbterm.FgString("S", 232, 39, 148) + rgbterm.FgString("S", 18, 195, 144) - case "SS+": - return rgbterm.String("SS+", 248, 200, 75, 143, 71, 33) - case "SS": - return rgbterm.String("SS", 248, 200, 75, 143, 71, 33) - case "S+": - return rgbterm.String("S+", 248, 200, 75, 75, 82, 82) - case "S": - return rgbterm.String("S", 248, 200, 75, 75, 82, 82) - case "AAA": - return rgbterm.FgString("AAA", 23, 163, 255) - case "AA": - return rgbterm.FgString("AA", 23, 163, 255) - case "A": - return rgbterm.FgString("A", 23, 163, 255) - default: - return rank - } -} diff --git a/pkg/maitea/maitea.go b/pkg/maitea/maitea.go deleted file mode 100644 index 5bdf6c7..0000000 --- a/pkg/maitea/maitea.go +++ /dev/null @@ -1,35 +0,0 @@ -package maitea - -import "net/http" - -var baseURL string = "https://maitea.app" - -type Image struct { - Id int `json:"id"` - Png string `json:"png"` - Webp string `json:"webp"` -} - -type APIClient struct { - accessToken string - client http.Client -} - -// Creates a new APIClient with the access token and a default http.Client -func NewAPIClient(accessToken string) *APIClient { - return &APIClient{accessToken, http.Client{}} -} - -// Get is used when making requests to MaiTea and ensures all requests are authenticated -func (api *APIClient) Get(url string) (*http.Response, error) { - req, err := http.NewRequest("GET", baseURL+url, nil) - if err != nil { - return nil, err - } - - req.Header.Add("Authorization", "Bearer "+api.accessToken) - req.Header.Add("Content-Type", "application/json") - req.Header.Add("Accept", "application/json") - - return api.client.Do(req) -} diff --git a/pkg/maitea/pagination.go b/pkg/maitea/pagination.go deleted file mode 100644 index 8173cff..0000000 --- a/pkg/maitea/pagination.go +++ /dev/null @@ -1,108 +0,0 @@ -package maitea - -import ( - "encoding/json" - "strings" -) - -type PageNonExistant struct{} - -func (m *PageNonExistant) Error() string { - return "Page does not exist" -} - -type Pager[T any] struct { - currentPage PagerPage[T] -} - -type PagerPage[T any] struct { - apiClient *APIClient // TODO: find better way please i beg - Data T `json:"data"` - Links struct { - First string `json:"first"` - Last string `json:"last"` - Prev *string `json:"prev"` - Next *string `json:"next"` - } `json:"links"` - Meta struct { - CurrentPage int `json:"current_page"` - From int `json:"from"` - LastPage int `json:"last_page"` - Links []struct { - Url *string `json:"url"` - Label string `json:"label"` - Active bool `json:"active"` - } `json:"links"` - Path string `json:"path"` - PerPage int `json:"per_page"` - To int `json:"to"` - Total int `json:"total"` - } `json:"meta"` -} - -// I am depressed I have to pass apiClient as a parameter even though its not user facing. -func getPage[T any](apiClient *APIClient, pageURL string) (PagerPage[T], error) { - res, err := apiClient.Get(strings.TrimPrefix(pageURL, "https://maitea.app")) // this is not elegant - if err != nil { - return PagerPage[T]{}, err - } - var page PagerPage[T] - err = json.NewDecoder(res.Body).Decode(&page) - if err != nil { - return PagerPage[T]{}, err - } - page.apiClient = apiClient //😭 - return page, nil -} - -func (p *Pager[T]) CurrentPage() T { - return p.currentPage.Data -} - -func (p *Pager[T]) Next() (T, error) { - if p.currentPage.Links.Next == nil { - return p.currentPage.Data, &PageNonExistant{} - } - - page, err := getPage[T](p.currentPage.apiClient, *p.currentPage.Links.Next) //😭 - if err != nil { - return p.currentPage.Data, err - } - - p.currentPage = page - return page.Data, nil -} - -func (p *Pager[T]) Prev() (T, error) { - if p.currentPage.Links.Prev == nil { - return p.currentPage.Data, &PageNonExistant{} - } - - page, err := getPage[T](p.currentPage.apiClient, *p.currentPage.Links.Prev) //😭 - if err != nil { - return p.currentPage.Data, err - } - - p.currentPage = page - return page.Data, nil -} - -func (p *Pager[T]) Last() (T, error) { - page, err := getPage[T](p.currentPage.apiClient, p.currentPage.Links.Last) //😭 - if err != nil { - return p.currentPage.Data, err - } - - p.currentPage = page - return page.Data, nil -} - -func (p *Pager[T]) First() (T, error) { - page, err := getPage[T](p.currentPage.apiClient, p.currentPage.Links.First) //😭 - if err != nil { - return p.currentPage.Data, err - } - - p.currentPage = page - return page.Data, nil -} diff --git a/pkg/maitea/plays.go b/pkg/maitea/plays.go deleted file mode 100644 index 779ae99..0000000 --- a/pkg/maitea/plays.go +++ /dev/null @@ -1,111 +0,0 @@ -package maitea - -import ( - "time" -) - -type Notes struct { - Perfect int `json:"perfect"` - Great int `json:"great"` - Good int `json:"good"` - Bad int `json:"bad"` -} - -type Score struct { - Id int `json:"id"` - Achievement int `json:"achievement"` - AchievementFormatted string `json:"achievement_formatted"` - Score int `json:"score"` - ScoreFormatted string `json:"score_formatted"` - Rank string `json:"rank"` - FullCombo int `json:"full_combo"` - FullComboLabel *string `json:"full_combo_label"` - IsAllPerfect bool `json:"is_all_perfect"` - IsAllPerfectPlus bool `json:"is_all_perfect_plus"` - DifficultyLevel struct { - Key int `json:"key"` - Value string `json:"value"` - Label string `json:"label"` - } `json:"difficulty_level"` - Song TrackInfo `json:"song"` - Player Profile `json:"player"` -} - -// ughhh if only i could do a cheeky clean embedding of score into play but i dont wanna deal with the fact all perfect plus isnt in play lol - -type Play struct { - Id int `json:"id"` - Achievement int `json:"achievement"` - AchievementFormatted string `json:"achievement_formatted"` - Track int `json:"track"` - Score int `json:"score"` - ScoreFormatted string `json:"score_formatted"` - ScoreDetail struct { - Hits Notes `json:"hits"` - Tap Notes `json:"tap"` - Hold Notes `json:"hold"` - Slide Notes `json:"slide"` - Break Notes `json:"break"` - } `json:"score_detail"` - Rank string `json:"rank"` - FullCombo int `json:"full_combo"` - FullComboLabel *string `json:"full_combo_label"` - IsHighScore bool `json:"is_high_score"` - IsAllPerfect bool `json:"is_all_perfect"` - IsTrackSkip bool `json:"is_track_skip"` - DifficultyLevel struct { - Key int `json:"key"` - Value string `json:"value"` - Label string `json:"label"` - } `json:"difficulty_level"` - PlayDate time.Time `json:"play_date"` - PlayDateUnix int `json:"play_date_unix"` - Song TrackInfo `json:"song"` - Player Profile `json:"player"` -} - -// GetPlays will get recent plays done by the authenticated user -// It is sorted by recently played and is paginated -func (api *APIClient) GetPlays() (Pager[[]Play], error) { - page, err := getPage[[]Play](api, baseURL+"/api/v1/plays") - if err != nil { - return Pager[[]Play]{}, err - } - - return Pager[[]Play]{page}, nil -} - -// GetAllPlays will get all the plays done by the authenticated user -// It is sorted by recently played and is NOT paginated -// This is very slow. GetPlays is recommended -func (api *APIClient) GetAllPlays() (Pager[[]Play], error) { - page, err := getPage[[]Play](api, baseURL+"/api/v1/plays/all") - if err != nil { - return Pager[[]Play]{}, err - } - - return Pager[[]Play]{page}, nil -} - -// GetBestScores will get recent plays done by the authenticated user -// It is sorted by internal ID and is paginated -func (api *APIClient) GetBestScores() (Pager[[]Score], error) { - page, err := getPage[[]Score](api, baseURL+"/api/v1/scores") - if err != nil { - return Pager[[]Score]{}, err - } - - return Pager[[]Score]{page}, nil -} - -// GetAllBestScores will get all the best scores done by the authenticated user -// It is sorted by internal ID and is NOT paginated -// This is very slow. GetBestScores is recommended -func (api *APIClient) GetAllBestScores() (Pager[[]Score], error) { - page, err := getPage[[]Score](api, baseURL+"/api/v1/scores/all") - if err != nil { - return Pager[[]Score]{}, err - } - - return Pager[[]Score]{page}, nil -} diff --git a/pkg/maitea/profile.go b/pkg/maitea/profile.go deleted file mode 100644 index 590a904..0000000 --- a/pkg/maitea/profile.go +++ /dev/null @@ -1,65 +0,0 @@ -package maitea - -import ( - "encoding/json" - "time" -) - -type Profile struct { - Id int `json:"id"` - Name string `json:"name"` - Rating int `json:"rating"` - RatingHighest int `json:"rating_highest"` - Level int `json:"level"` - PlayStats struct { - Total int `json:"total"` - Wins int `json:"wins"` - Vs int `json:"vs"` - Sync int `json:"sync"` - First struct { - Id int `json:"id"` - Date time.Time `json:"date"` - DateUnix int `json:"date_unix"` - ApiRoute string `json:"api_route"` - } `json:"first"` - Latest struct { - Id int `json:"id"` - Date time.Time `json:"date"` - DateUnix int `json:"date_unix"` - ApiRoute string `json:"api_route"` - } `json:"latest"` - } `json:"play_stats"` - Options struct { - Icon Image `json:"icon"` - IconDeka Image `json:"icon_deka"` - Nameplate struct { - Id int `json:"id"` - Png string `json:"png"` - Webp string `json:"webp"` - } `json:"nameplate"` - Frame struct { - Id int `json:"id"` - Png string `json:"png"` - Webp string `json:"webp"` - } `json:"frame"` - } `json:"options"` -} - -// GetProfiles will return all profiles attached to the access token given -func (api *APIClient) GetProfiles() ([]Profile, error) { - res, err := api.Get("/api/v1/profiles") - if err != nil { - return nil, err - } - - profiles := struct { - Data []Profile `json:"data"` - }{} - - err = json.NewDecoder(res.Body).Decode(&profiles) - if err != nil { - return nil, err - } - - return profiles.Data, nil -} diff --git a/pkg/maitea/website_information.go b/pkg/maitea/website_information.go deleted file mode 100644 index a67b233..0000000 --- a/pkg/maitea/website_information.go +++ /dev/null @@ -1,36 +0,0 @@ -package maitea - -import "encoding/json" - -type Status struct { - Webui struct { - Api string `json:"api"` - DbRead struct { - Status string `json:"status"` - QueryTime string `json:"query_time"` - } `json:"db_read"` - DbWrite struct { - Status string `json:"status"` - QueryTime string `json:"query_time"` - } `json:"db_write"` - } `json:"webui"` - Game struct { - Status string `json:"status"` - } `json:"game"` - LastUpdated int64 `json:"last_updated"` -} - -// Status will return the server status of MaiTea -func (api *APIClient) Status() (Status, error) { - res, err := api.Get("/api/status") - if err != nil { - return Status{}, err - } - - var status Status - err = json.NewDecoder(res.Body).Decode(&status) - if err != nil { - return Status{}, err - } - return status, nil -} diff --git a/src/maifetch.f90 b/src/maifetch.f90 new file mode 100644 index 0000000..b8b5ed6 --- /dev/null +++ b/src/maifetch.f90 @@ -0,0 +1,720 @@ +module maifetch_app + use iso_fortran_env, only: error_unit + implicit none + + character(len=*), parameter :: base_url = "https://maitea.app" + + type :: config + character(:), allocatable :: access_token + character(:), allocatable :: config_file + character(:), allocatable :: profiles_fixture + character(:), allocatable :: plays_fixture + integer :: logo_size = 20 + integer :: score_count = 4 + logical :: no_color = .false. + end type config + + type :: json_object + character(:), allocatable :: text + end type json_object + + type :: line_item + character(:), allocatable :: text + end type line_item + +contains + subroutine die(message) + character(len=*), intent(in) :: message + write(error_unit, '(A)') trim(message) + error stop 1 + end subroutine die + + function env_value(name) result(value) + character(len=*), intent(in) :: name + character(:), allocatable :: value + integer :: needed, status + + call get_environment_variable(name, length=needed, status=status) + if (status /= 0 .or. needed <= 0) then + value = "" + return + end if + + allocate(character(len=needed) :: value) + call get_environment_variable(name, value=value, status=status) + if (status /= 0) value = "" + end function env_value + + function file_exists(path) result(exists) + character(len=*), intent(in) :: path + logical :: exists + inquire(file=trim(path), exist=exists) + end function file_exists + + function default_config_path() result(path) + character(:), allocatable :: path + character(:), allocatable :: home, appdata, xdg + + home = env_value("HOME") + appdata = env_value("APPDATA") + xdg = env_value("XDG_CONFIG_HOME") + + if (len_trim(appdata) > 0) then + path = trim(appdata) // "/maifetch.json" + else if (len_trim(xdg) > 0) then + path = trim(xdg) // "/maifetch.json" + else if (len_trim(home) > 0 .and. & + file_exists(trim(home) // "/Library/Application Support")) then + path = trim(home) // "/Library/Application Support/maifetch.json" + else if (len_trim(home) > 0) then + path = trim(home) // "/.config/maifetch.json" + else + path = ".config/maifetch.json" + end if + end function default_config_path + + function starts_with(text, prefix) result(matches) + character(len=*), intent(in) :: text, prefix + logical :: matches + matches = len(text) >= len(prefix) + if (matches) matches = text(1:len(prefix)) == prefix + end function starts_with + + function after_equal(arg) result(value) + character(len=*), intent(in) :: arg + character(:), allocatable :: value + integer :: pos + + pos = index(arg, "=") + if (pos == 0) then + value = "" + else + value = arg(pos + 1:) + end if + end function after_equal + + function parse_int(text, fallback) result(value) + character(len=*), intent(in) :: text + integer, intent(in) :: fallback + integer :: value, ios + + if (len_trim(text) == 0) then + value = fallback + return + end if + + read(text, *, iostat=ios) value + if (ios /= 0) value = fallback + end function parse_int + + subroutine print_help() + print '(A)', "Usage: maifetch [options]" + print '(A)', "" + print '(A)', "Options:" + print '(A)', " -a, --access-token TOKEN Access token for the MaiTea account" + print '(A)', " -t TOKEN Access token for the MaiTea account" + print '(A)', " -l, --logo-size SIZE Size of the ASCII logo (<1 disables)" + print '(A)', " -s, --score-count COUNT Amount of recent scores to show (max 12)" + print '(A)', " -c, --config-file FILE Config file to use" + print '(A)', " --profiles-fixture FILE Read profiles JSON from a fixture file" + print '(A)', " --plays-fixture FILE Read plays JSON from a fixture file" + print '(A)', " --no-color Disable ANSI colors" + print '(A)', " -h, --help Show this help" + end subroutine print_help + + subroutine read_json_config(path, token, logo_size, score_count) + character(len=*), intent(in) :: path + character(:), allocatable, intent(inout) :: token + integer, intent(inout) :: logo_size, score_count + character(:), allocatable :: json, json_token + + if (.not. file_exists(path)) return + + json = read_file(path) + json_token = json_string(json, "accessToken") + if (len_trim(json_token) > 0) token = json_token + logo_size = json_int(json, "logoSize", logo_size) + score_count = json_int(json, "scoreCount", score_count) + end subroutine read_json_config + + subroutine load_config(cfg) + type(config), intent(out) :: cfg + character(:), allocatable :: arg, next, cli_token, cli_config + character(:), allocatable :: env_token, env_logo, env_scores + integer :: i, argc, cli_logo, cli_scores + logical :: has_cli_logo, has_cli_scores + + cfg%config_file = default_config_path() + cfg%access_token = "" + cfg%profiles_fixture = "" + cfg%plays_fixture = "" + cli_token = "" + cli_config = "" + cli_logo = cfg%logo_size + cli_scores = cfg%score_count + has_cli_logo = .false. + has_cli_scores = .false. + + argc = command_argument_count() + i = 1 + do while (i <= argc) + call get_command_argument_alloc(i, arg) + select case (trim(arg)) + case ("-h", "--help") + call print_help() + stop + case ("--no-color") + cfg%no_color = .true. + case ("-a", "-t", "--access-token") + call require_next(i, argc, trim(arg), next) + cli_token = next + i = i + 1 + case ("-l", "--logo-size") + call require_next(i, argc, trim(arg), next) + cli_logo = parse_int(next, cfg%logo_size) + has_cli_logo = .true. + i = i + 1 + case ("-s", "--score-count") + call require_next(i, argc, trim(arg), next) + cli_scores = parse_int(next, cfg%score_count) + has_cli_scores = .true. + i = i + 1 + case ("-c", "--config-file") + call require_next(i, argc, trim(arg), next) + cli_config = next + i = i + 1 + case ("--profiles-fixture") + call require_next(i, argc, trim(arg), next) + cfg%profiles_fixture = next + i = i + 1 + case ("--plays-fixture") + call require_next(i, argc, trim(arg), next) + cfg%plays_fixture = next + i = i + 1 + case default + if (starts_with(arg, "--access-token=")) then + cli_token = after_equal(arg) + else if (starts_with(arg, "--logo-size=")) then + cli_logo = parse_int(after_equal(arg), cfg%logo_size) + has_cli_logo = .true. + else if (starts_with(arg, "--score-count=")) then + cli_scores = parse_int(after_equal(arg), cfg%score_count) + has_cli_scores = .true. + else if (starts_with(arg, "--config-file=")) then + cli_config = after_equal(arg) + else if (starts_with(arg, "--profiles-fixture=")) then + cfg%profiles_fixture = after_equal(arg) + else if (starts_with(arg, "--plays-fixture=")) then + cfg%plays_fixture = after_equal(arg) + else + call die("unknown argument: " // trim(arg)) + end if + end select + i = i + 1 + end do + + if (len_trim(cli_config) > 0) cfg%config_file = cli_config + cfg%access_token = "" + call read_json_config(cfg%config_file, cfg%access_token, & + cfg%logo_size, cfg%score_count) + + env_token = env_value("MAITEA_TOKEN") + env_logo = env_value("MAITEA_LOGO_SIZE") + env_scores = env_value("MAITEA_SCORE_COUNT") + + if (len_trim(env_token) > 0) cfg%access_token = env_token + if (len_trim(env_logo) > 0) cfg%logo_size = parse_int(env_logo, cfg%logo_size) + if (len_trim(env_scores) > 0) then + cfg%score_count = parse_int(env_scores, cfg%score_count) + end if + + if (len_trim(cli_token) > 0) cfg%access_token = cli_token + if (has_cli_logo) cfg%logo_size = cli_logo + if (has_cli_scores) cfg%score_count = cli_scores + + if (cfg%score_count > 12) call die("score count cannot be higher than 12") + if ((len_trim(cfg%profiles_fixture) == 0 .or. & + len_trim(cfg%plays_fixture) == 0) .and. & + len_trim(cfg%access_token) == 0) then + call die("access token is required") + end if + end subroutine load_config + + subroutine require_next(i, argc, flag, value) + integer, intent(in) :: i, argc + character(len=*), intent(in) :: flag + character(:), allocatable, intent(out) :: value + + if (i >= argc) call die("missing value for " // trim(flag)) + call get_command_argument_alloc(i + 1, value) + end subroutine require_next + + subroutine get_command_argument_alloc(index, value) + integer, intent(in) :: index + character(:), allocatable, intent(out) :: value + integer :: needed + + call get_command_argument(index, length=needed) + allocate(character(len=needed) :: value) + call get_command_argument(index, value=value) + end subroutine get_command_argument_alloc + + function read_file(path) result(contents) + character(len=*), intent(in) :: path + character(:), allocatable :: contents + integer :: unit, ios, file_size + + inquire(file=trim(path), size=file_size) + if (file_size < 0) call die("could not stat file: " // trim(path)) + allocate(character(len=file_size) :: contents) + + open(newunit=unit, file=trim(path), access="stream", form="unformatted", & + action="read", iostat=ios) + if (ios /= 0) call die("could not open file: " // trim(path)) + read(unit, iostat=ios) contents + close(unit) + if (ios /= 0) call die("could not read file: " // trim(path)) + end function read_file + + function json_string(json, key) result(value) + character(len=*), intent(in) :: json, key + character(:), allocatable :: value + character(:), allocatable :: pattern + integer :: key_pos, pos, start_pos, stop_pos + logical :: escaped + + value = "" + pattern = '"' // key // '"' + key_pos = index(json, pattern) + if (key_pos == 0) return + + pos = key_pos + len(pattern) + pos = pos + index(json(pos:), ":") + if (pos <= key_pos + len(pattern)) return + call skip_ws(json, pos) + + if (starts_with(json(pos:), "null")) return + if (json(pos:pos) /= '"') return + + start_pos = pos + 1 + stop_pos = start_pos + escaped = .false. + do while (stop_pos <= len(json)) + if (escaped) then + escaped = .false. + else if (json(stop_pos:stop_pos) == "\") then + escaped = .true. + else if (json(stop_pos:stop_pos) == '"') then + value = json(start_pos:stop_pos - 1) + return + end if + stop_pos = stop_pos + 1 + end do + end function json_string + + function json_int(json, key, fallback) result(value) + character(len=*), intent(in) :: json, key + integer, intent(in) :: fallback + integer :: value, key_pos, pos, end_pos, ios + character(:), allocatable :: pattern, raw + + value = fallback + pattern = '"' // key // '"' + key_pos = index(json, pattern) + if (key_pos == 0) return + + pos = key_pos + len(pattern) + pos = pos + index(json(pos:), ":") + if (pos <= key_pos + len(pattern)) return + call skip_ws(json, pos) + + end_pos = pos + do while (end_pos <= len(json)) + if (index("-+0123456789", json(end_pos:end_pos)) == 0) exit + end_pos = end_pos + 1 + end do + + if (end_pos <= pos) return + raw = json(pos:end_pos - 1) + read(raw, *, iostat=ios) value + if (ios /= 0) value = fallback + end function json_int + + subroutine skip_ws(text, pos) + character(len=*), intent(in) :: text + integer, intent(inout) :: pos + + do while (pos <= len(text)) + if (index(" " // achar(9) // achar(10) // achar(13), text(pos:pos)) == 0) exit + pos = pos + 1 + end do + end subroutine skip_ws + + subroutine parse_data_objects(json, objects) + character(len=*), intent(in) :: json + type(json_object), allocatable, intent(out) :: objects(:) + integer :: data_pos, bracket_rel, i, start_pos, depth + logical :: in_string, escaped + + allocate(objects(0)) + data_pos = index(json, '"data"') + if (data_pos == 0) call die("response did not include a data array") + bracket_rel = index(json(data_pos:), "[") + if (bracket_rel == 0) call die("response data was not an array") + + i = data_pos + bracket_rel + depth = 0 + start_pos = 0 + in_string = .false. + escaped = .false. + + do while (i <= len(json)) + if (in_string) then + if (escaped) then + escaped = .false. + else if (json(i:i) == "\") then + escaped = .true. + else if (json(i:i) == '"') then + in_string = .false. + end if + else + select case (json(i:i)) + case ('"') + in_string = .true. + case ("{") + if (depth == 0) start_pos = i + depth = depth + 1 + case ("}") + depth = depth - 1 + if (depth == 0 .and. start_pos > 0) then + call append_object(objects, json(start_pos:i)) + start_pos = 0 + end if + case ("]") + if (depth == 0) exit + end select + end if + i = i + 1 + end do + end subroutine parse_data_objects + + subroutine append_object(objects, text) + type(json_object), allocatable, intent(inout) :: objects(:) + character(len=*), intent(in) :: text + type(json_object), allocatable :: next_objects(:) + integer :: count + + count = size(objects) + allocate(next_objects(count + 1)) + if (count > 0) next_objects(1:count) = objects + next_objects(count + 1)%text = text + call move_alloc(next_objects, objects) + end subroutine append_object + + function load_json(path, api_path, token) result(json) + character(len=*), intent(in) :: path, api_path, token + character(:), allocatable :: json, temp_path, command + + if (len_trim(path) > 0) then + json = read_file(path) + return + end if + + temp_path = make_temp_path() + command = "curl -fsSL -H " // shell_quote("Authorization: Bearer " // token) // & + " -H " // shell_quote("Accept: application/json") // & + " -H " // shell_quote("Content-Type: application/json") // & + " " // shell_quote(base_url // api_path) // " -o " // shell_quote(temp_path) + call run_command(command, "MaiTea API request failed for " // api_path) + json = read_file(temp_path) + call execute_command_line("rm -f " // shell_quote(temp_path)) + end function load_json + + function make_temp_path() result(path) + character(:), allocatable :: path + character(:), allocatable :: tmpdir + character(len=16) :: suffix + real :: random_value + + tmpdir = env_value("TMPDIR") + if (len_trim(tmpdir) == 0) tmpdir = "/tmp" + call random_seed() + call random_number(random_value) + write(suffix, '(I0)') int(random_value * 100000000.0) + path = trim(tmpdir) // "/maifetch_" // trim(suffix) // ".json" + end function make_temp_path + + function shell_quote(text) result(quoted) + character(len=*), intent(in) :: text + character(:), allocatable :: quoted + integer :: i + + quoted = "'" + do i = 1, len_trim(text) + if (text(i:i) == "'") then + quoted = quoted // "'\''" + else + quoted = quoted // text(i:i) + end if + end do + quoted = quoted // "'" + end function shell_quote + + subroutine run_command(command, message) + character(len=*), intent(in) :: command, message + integer :: exit_status + + call execute_command_line(command, exitstat=exit_status) + if (exit_status /= 0) call die(message) + end subroutine run_command + + function ansi_fg(text, r, g, b, no_color) result(out) + character(len=*), intent(in) :: text + integer, intent(in) :: r, g, b + logical, intent(in) :: no_color + character(:), allocatable :: out + + if (no_color) then + out = text + else + out = achar(27) // "[38;2;" // int_text(r) // ";" // int_text(g) // & + ";" // int_text(b) // "m" // text // achar(27) // "[0m" + end if + end function ansi_fg + + function ansi_bg(text, r, g, b, no_color) result(out) + character(len=*), intent(in) :: text + integer, intent(in) :: r, g, b + logical, intent(in) :: no_color + character(:), allocatable :: out + + if (no_color) then + out = text + else + out = achar(27) // "[38;2;255;255;255m" // achar(27) // & + "[48;2;" // int_text(r) // ";" // int_text(g) // ";" // & + int_text(b) // "m" // text // achar(27) // "[0m" + end if + end function ansi_bg + + function cyan(text, no_color) result(out) + character(len=*), intent(in) :: text + logical, intent(in) :: no_color + character(:), allocatable :: out + out = ansi_fg(text, 72, 184, 200, no_color) + end function cyan + + function int_text(value) result(text) + integer, intent(in) :: value + character(:), allocatable :: text + character(len=32) :: buffer + write(buffer, '(I0)') value + text = trim(buffer) + end function int_text + + function rating_text(value) result(text) + integer, intent(in) :: value + character(:), allocatable :: text + character(len=32) :: buffer + write(buffer, '(F0.2)') real(value) / 100.0 + text = trim(buffer) + end function rating_text + + function difficulty_label(value, no_color) result(label) + character(len=*), intent(in) :: value + logical, intent(in) :: no_color + character(:), allocatable :: label + + select case (trim(value)) + case ("easy") + label = ansi_bg("Easy", 69, 174, 255, no_color) + case ("basic") + label = ansi_bg("Basic", 111, 212, 61, no_color) + case ("advanced") + label = ansi_bg("Advanced", 248, 183, 9, no_color) + case ("expert") + label = ansi_bg("Expert", 255, 46, 66, no_color) + case ("master") + label = ansi_bg("Master", 171, 140, 233, no_color) + case ("remaster", "re:master") + label = ansi_bg("Re:Master", 207, 114, 237, no_color) + case ("utage") + label = ansi_bg("Utage", 255, 68, 1, no_color) + case default + label = trim(value) + end select + end function difficulty_label + + function rank_label(rank, no_color) result(label) + character(len=*), intent(in) :: rank + logical, intent(in) :: no_color + character(:), allocatable :: label + + if (no_color) then + label = trim(rank) + return + end if + + select case (trim(rank)) + case ("SSS+") + label = ansi_fg("S", 255, 200, 54, .false.) // & + ansi_fg("S", 225, 38, 165, .false.) // & + ansi_fg("S", 73, 64, 233, .false.) // & + ansi_fg("+", 21, 203, 148, .false.) + case ("SSS") + label = ansi_fg("S", 255, 200, 54, .false.) // & + ansi_fg("S", 232, 39, 148, .false.) // & + ansi_fg("S", 18, 195, 144, .false.) + case ("SS+", "SS") + label = ansi_bg(trim(rank), 143, 71, 33, .false.) + case ("S+", "S") + label = ansi_bg(trim(rank), 75, 82, 82, .false.) + case ("AAA", "AA", "A") + label = ansi_fg(trim(rank), 23, 163, 255, .false.) + case default + label = trim(rank) + end select + end function rank_label + + subroutine append_line(lines, text) + type(line_item), allocatable, intent(inout) :: lines(:) + character(len=*), intent(in) :: text + type(line_item), allocatable :: next_lines(:) + integer :: count + + count = size(lines) + allocate(next_lines(count + 1)) + if (count > 0) next_lines(1:count) = lines + next_lines(count + 1)%text = text + call move_alloc(next_lines, lines) + end subroutine append_line + + subroutine build_info_lines(profile, plays, score_count, no_color, lines) + type(json_object), intent(in) :: profile + type(json_object), intent(in) :: plays(:) + integer, intent(in) :: score_count + logical, intent(in) :: no_color + type(line_item), allocatable, intent(inout) :: lines(:) + character(:), allocatable :: name, song, difficulty, rank, combo + character(:), allocatable :: score, achievement + integer :: i, limit + + if (allocated(lines)) deallocate(lines) + allocate(lines(0)) + name = json_string(profile%text, "name") + if (len_trim(name) == 0) name = "MaiTea" + + call append_line(lines, cyan(name, no_color)) + call append_line(lines, repeat("-", len_trim(name))) + call append_line(lines, cyan("ID", no_color) // ": " // & + int_text(json_int(profile%text, "id", 0))) + call append_line(lines, cyan("Rating", no_color) // ": " // & + rating_text(json_int(profile%text, "rating", 0)) // " / " // & + rating_text(json_int(profile%text, "rating_highest", 0))) + call append_line(lines, cyan("Level", no_color) // ": " // & + int_text(json_int(profile%text, "level", 0))) + call append_line(lines, cyan("Total Credits", no_color) // ": " // & + int_text(json_int(profile%text, "total", 0))) + call append_line(lines, cyan("Recent Scores", no_color) // ":") + + limit = min(score_count, size(plays)) + do i = 1, limit + song = json_string(plays(i)%text, "en") + difficulty = json_string(plays(i)%text, "value") + score = json_string(plays(i)%text, "score_formatted") + achievement = json_string(plays(i)%text, "achievement_formatted") + rank = json_string(plays(i)%text, "rank") + combo = json_string(plays(i)%text, "full_combo_label") + + call append_line(lines, " " // song // " " // & + difficulty_label(difficulty, no_color)) + call append_line(lines, trim(" " // score // " " // achievement // & + "% " // rank_label(rank, no_color) // " " // combo)) + call append_line(lines, "") + end do + end subroutine build_info_lines + + function logo_line(icon_url, row, width, no_color) result(line) + character(len=*), intent(in) :: icon_url + integer, intent(in) :: row, width + logical, intent(in) :: no_color + character(:), allocatable :: line, raw + character(len=*), parameter :: chars = " .:-=+*#%@" + integer :: col, seed, idx, code + + seed = 0 + do idx = 1, len_trim(icon_url) + code = iachar(icon_url(idx:idx)) + seed = seed + code + end do + + raw = "" + do col = 1, width + idx = modulo(row * row + col * 3 + seed, len(chars)) + 1 + raw = raw // chars(idx:idx) + end do + + line = ansi_fg(raw, 72, 184, 200, no_color) + end function logo_line + + subroutine print_output(profile, plays, cfg) + type(json_object), intent(in) :: profile + type(json_object), intent(in) :: plays(:) + type(config), intent(in) :: cfg + type(line_item), allocatable :: lines(:) + character(:), allocatable :: icon_url, logo, blank_logo + integer :: i, max_lines, width + + allocate(lines(0)) + call build_info_lines(profile, plays, cfg%score_count, cfg%no_color, lines) + if (cfg%logo_size <= 0) then + do i = 1, size(lines) + print '(A)', lines(i)%text + end do + return + end if + + icon_url = json_string(profile%text, "png") + width = max(cfg%logo_size * 2, 8) + blank_logo = repeat(" ", width) + max_lines = max(size(lines), cfg%logo_size) + + do i = 1, max_lines + if (i <= cfg%logo_size) then + logo = logo_line(icon_url, i, width, cfg%no_color) + else + logo = blank_logo + end if + + if (i <= size(lines)) then + print '(A)', logo // " " // lines(i)%text + else + print '(A)', logo + end if + end do + end subroutine print_output + + subroutine run() + type(config) :: cfg + character(:), allocatable :: profiles_json, plays_json + type(json_object), allocatable :: profiles(:), plays(:) + + call load_config(cfg) + profiles_json = load_json(cfg%profiles_fixture, "/api/v1/profiles", & + cfg%access_token) + plays_json = load_json(cfg%plays_fixture, "/api/v1/plays", & + cfg%access_token) + + call parse_data_objects(profiles_json, profiles) + call parse_data_objects(plays_json, plays) + if (size(profiles) == 0) call die("No profiles found") + + call print_output(profiles(1), plays, cfg) + end subroutine run +end module maifetch_app + +program maifetch + use maifetch_app + implicit none + call run() +end program maifetch diff --git a/test/fixtures/plays.json b/test/fixtures/plays.json new file mode 100644 index 0000000..3d3369d --- /dev/null +++ b/test/fixtures/plays.json @@ -0,0 +1,36 @@ +{ + "data": [ + { + "id": 1, + "achievement_formatted": "100.5000", + "score_formatted": "1,009,000", + "rank": "SSS+", + "full_combo_label": "FC", + "difficulty_level": { + "value": "master" + }, + "song": { + "name": { + "en": "Latent Kingdom", + "jp": "Latent Kingdom" + } + } + }, + { + "id": 2, + "achievement_formatted": "99.7500", + "score_formatted": "995,000", + "rank": "SS", + "full_combo_label": null, + "difficulty_level": { + "value": "expert" + }, + "song": { + "name": { + "en": "Citrus City", + "jp": "Citrus City" + } + } + } + ] +} diff --git a/test/fixtures/profile.json b/test/fixtures/profile.json new file mode 100644 index 0000000..1e4a700 --- /dev/null +++ b/test/fixtures/profile.json @@ -0,0 +1,21 @@ +{ + "data": [ + { + "id": 42, + "name": "MaiTea", + "rating": 12345, + "rating_highest": 13001, + "level": 25, + "play_stats": { + "total": 321 + }, + "options": { + "icon": { + "id": 7, + "png": "https://example.test/icon.png", + "webp": "https://example.test/icon.webp" + } + } + } + ] +} diff --git a/test/run-fixture.sh b/test/run-fixture.sh new file mode 100755 index 0000000..fbca38e --- /dev/null +++ b/test/run-fixture.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +mkdir -p build +gfortran -std=f2008 -Wall -Wextra -ffree-line-length-none \ + src/maifetch.f90 \ + -o build/maifetch + +output="$( + ./build/maifetch \ + --profiles-fixture test/fixtures/profile.json \ + --plays-fixture test/fixtures/plays.json \ + --logo-size 0 \ + --score-count 2 \ + --no-color +)" + +printf '%s\n' "$output" + +grep -q 'MaiTea' <<< "$output" +grep -q 'ID: 42' <<< "$output" +grep -q 'Rating: 123.45 / 130.01' <<< "$output" +grep -q 'Total Credits: 321' <<< "$output" +grep -q 'Latent Kingdom' <<< "$output" +grep -q 'Citrus City' <<< "$output" +grep -q 'SSS+' <<< "$output" From a2f84dee84bcb4eba318e614cb3c84154f5ed5cd Mon Sep 17 00:00:00 2001 From: Angel Garcia Date: Mon, 15 Jun 2026 11:47:02 -0600 Subject: [PATCH 2/2] Honor config file environment override --- README.md | 2 +- src/maifetch.f90 | 4 +++- test/fixtures/config.json | 5 +++++ test/run-fixture.sh | 13 +++++++++++++ 4 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/config.json diff --git a/README.md b/README.md index 7fee65b..abd30e4 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ obtained from `os.UserConfigDir` ## how to build 1. clone the project with `git clone https://github.com/HutchyBen/maifetch` -2. install a Fortran compiler such as `gfortran` +2. install a Fortran compiler such as `gfortran` and make sure `curl` is available for API requests 3. build with `gfortran -std=f2008 -ffree-line-length-none src/maifetch.f90 -o maifetch` 4. run outputted executable ensuring access token is either - in config file diff --git a/src/maifetch.f90 b/src/maifetch.f90 index b8b5ed6..5a91c8c 100644 --- a/src/maifetch.f90 +++ b/src/maifetch.f90 @@ -140,7 +140,7 @@ end subroutine read_json_config subroutine load_config(cfg) type(config), intent(out) :: cfg character(:), allocatable :: arg, next, cli_token, cli_config - character(:), allocatable :: env_token, env_logo, env_scores + character(:), allocatable :: env_config, env_token, env_logo, env_scores integer :: i, argc, cli_logo, cli_scores logical :: has_cli_logo, has_cli_scores @@ -213,6 +213,8 @@ subroutine load_config(cfg) i = i + 1 end do + env_config = env_value("MAITEA_CONFIG_FILE") + if (len_trim(env_config) > 0) cfg%config_file = env_config if (len_trim(cli_config) > 0) cfg%config_file = cli_config cfg%access_token = "" call read_json_config(cfg%config_file, cfg%access_token, & diff --git a/test/fixtures/config.json b/test/fixtures/config.json new file mode 100644 index 0000000..0749351 --- /dev/null +++ b/test/fixtures/config.json @@ -0,0 +1,5 @@ +{ + "accessToken": "fixture-token", + "logoSize": 0, + "scoreCount": 1 +} diff --git a/test/run-fixture.sh b/test/run-fixture.sh index fbca38e..f4319d3 100755 --- a/test/run-fixture.sh +++ b/test/run-fixture.sh @@ -24,3 +24,16 @@ grep -q 'Total Credits: 321' <<< "$output" grep -q 'Latent Kingdom' <<< "$output" grep -q 'Citrus City' <<< "$output" grep -q 'SSS+' <<< "$output" + +config_output="$( + MAITEA_CONFIG_FILE=test/fixtures/config.json \ + ./build/maifetch \ + --profiles-fixture test/fixtures/profile.json \ + --plays-fixture test/fixtures/plays.json \ + --no-color +)" + +printf '%s\n' "$config_output" + +grep -q 'Latent Kingdom' <<< "$config_output" +! grep -q 'Citrus City' <<< "$config_output"