diff --git a/cmd/mc/modrinth/modrinth.go b/cmd/mc/modrinth/modrinth.go new file mode 100644 index 0000000..d34c708 --- /dev/null +++ b/cmd/mc/modrinth/modrinth.go @@ -0,0 +1,17 @@ +package modrinth + +import ( + "github.com/mworzala/mc/internal/pkg/cli" + "github.com/spf13/cobra" +) + +func NewModrinthCmd(app *cli.App) *cobra.Command { + cmd := &cobra.Command{ + Use: "modrinth", + Short: "Query the modrinth API directly", + } + + cmd.AddCommand(newSearchCmd(app)) + + return cmd +} diff --git a/cmd/mc/modrinth/search.go b/cmd/mc/modrinth/search.go new file mode 100644 index 0000000..8741e99 --- /dev/null +++ b/cmd/mc/modrinth/search.go @@ -0,0 +1,101 @@ +package modrinth + +import ( + "context" + "os" + "os/signal" + "strings" + + "github.com/mworzala/mc/internal/pkg/cli" + appModel "github.com/mworzala/mc/internal/pkg/cli/model" + "github.com/mworzala/mc/internal/pkg/modrinth" + "github.com/mworzala/mc/internal/pkg/modrinth/facet" + "github.com/spf13/cobra" +) + +type searchOpts struct { + app *cli.App + + // Project type + mod bool + modPack bool + resourcePack bool + shader bool + + // Sort + //todo +} + +func newSearchCmd(app *cli.App) *cobra.Command { + var o searchOpts + + cmd := &cobra.Command{ + Use: "search", + Short: "Search for projects on modrinth", + Args: func(cmd *cobra.Command, args []string) error { + o.app = app + return o.validateArgs(cmd, args) + }, + RunE: func(_ *cobra.Command, args []string) error { + o.app = app + return o.execute(args) + }, + } + + cmd.Flags().BoolVar(&o.mod, "mod", false, "Show only mods") + cmd.Flags().BoolVar(&o.modPack, "modpack", false, "Show only modpacks") + cmd.Flags().BoolVar(&o.resourcePack, "resourcepack", false, "Show only resource packs") + cmd.Flags().BoolVar(&o.resourcePack, "rp", false, "Show only resource packs") + cmd.Flags().BoolVar(&o.shader, "shader", false, "Show only shaders") + + cmd.Flags().FlagUsages() + + return cmd +} + +func (o *searchOpts) validateArgs(cmd *cobra.Command, args []string) (err error) { + if err := cobra.MinimumNArgs(1)(cmd, args); err != nil { + return err + } + + // todo + return nil +} + +func (o *searchOpts) execute(args []string) error { + // Validation function has done arg validation and option population + + client := modrinth.NewClient(o.app.Build.Version) + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + query := strings.Join(args, " ") + var facets facet.And + if o.mod || o.modPack || o.resourcePack || o.shader { + var filter facet.Or + if o.mod { + filter = append(filter, facet.Eq{facet.ProjectType, "mod"}) + } + if o.modPack { + filter = append(filter, facet.Eq{facet.ProjectType, "modpack"}) + } + if o.resourcePack { + filter = append(filter, facet.Eq{facet.ProjectType, "resourcepack"}) + } + if o.shader { + filter = append(filter, facet.Eq{facet.ProjectType, "shader"}) + } + facets = append(facets, filter) + } + + res, err := client.Search(ctx, modrinth.SearchRequest{ + Query: query, + Facets: facets, + }) + if err != nil { + return err + } + + presentable := appModel.ModrinthSearchResult(*res) + return o.app.Present(&presentable) +} diff --git a/cmd/mc/root.go b/cmd/mc/root.go index 116d3ae..9177d34 100644 --- a/cmd/mc/root.go +++ b/cmd/mc/root.go @@ -2,6 +2,7 @@ package mc import ( "github.com/MakeNowJust/heredoc" + "github.com/mworzala/mc/cmd/mc/modrinth" "github.com/mworzala/mc/cmd/mc/profile" @@ -37,6 +38,7 @@ func NewRootCmd(app *cli.App) *cobra.Command { cmd.AddCommand(profile.NewProfileCmd(app)) cmd.AddCommand(newLaunchCmd(app)) cmd.AddCommand(newInstallCmd(app)) + cmd.AddCommand(modrinth.NewModrinthCmd(app)) cmd.AddCommand(newVersionCmd(app)) cmd.AddCommand(newDebugCmd(app)) diff --git a/internal/pkg/cli/model/modrinth.go b/internal/pkg/cli/model/modrinth.go new file mode 100644 index 0000000..c7dc630 --- /dev/null +++ b/internal/pkg/cli/model/modrinth.go @@ -0,0 +1,24 @@ +package model + +import ( + "fmt" + + "github.com/gosuri/uitable" + "github.com/mworzala/mc/internal/pkg/modrinth" + "github.com/mworzala/mc/internal/pkg/util" +) + +type ModrinthSearchResult modrinth.SearchResponse + +func (result *ModrinthSearchResult) String() string { + table := uitable.New() + table.AddRow("ID", "TYPE", "NAME", "DOWNLOADS") + for _, project := range result.Hits { + table.AddRow(project.ProjectID, project.ProjectType, project.Title, util.FormatCount(project.Downloads)) + } + res := table.String() + if result.TotalHits-len(result.Hits) > 0 { + res += fmt.Sprintf("\n...and %d more", result.TotalHits-len(result.Hits)) + } + return res +} diff --git a/internal/pkg/modrinth/facet/facet.go b/internal/pkg/modrinth/facet/facet.go new file mode 100644 index 0000000..68782eb --- /dev/null +++ b/internal/pkg/modrinth/facet/facet.go @@ -0,0 +1,215 @@ +package facet + +import ( + "fmt" + "strconv" + "time" +) + +func ToString(facet Root) (string, error) { + if facet == nil { + return "", nil + } + return facet.asString(false, false) +} + +type And []OrFilter + +type Or []Filter + +type Eq struct { + Type Type + Value interface{} +} + +type NEq struct { + Type Type + Value interface{} +} + +type Gt struct { + Type Type + Value interface{} +} + +type GtEq struct { + Type Type + Value interface{} +} + +type Lt struct { + Type Type + Value interface{} +} + +type LtEq struct { + Type Type + Value interface{} +} + +type Type string + +const ( + ProjectType Type = "project_type" + Categories Type = "categories" + Versions Type = "versions" + ClientSide Type = "client_side" + ServerSide Type = "server_side" + OpenSource Type = "open_source" + Title Type = "title" + Author Type = "author" + Follows Type = "follows" + ProjectID Type = "project_id" + License Type = "license" + Downloads Type = "downloads" + Color Type = "color" + CreatedTimestamp Type = "created_timestamp" + ModifiedTimestamp Type = "modified_timestamp" +) + +type ( + anyFilter interface { + asString(hasAnd, hasOr bool) (string, error) + } + Root interface { + anyFilter + facetRoot() + } + OrFilter interface { + anyFilter + orFilter() + } + Filter interface { + anyFilter + facet() + } +) + +func (f And) asString(_, _ bool) (string, error) { + if len(f) == 0 { + return "", nil + } + + out := "[" + for i, child := range f { + childStr, err := child.asString(true, false) + if err != nil { + return "", err + } + out += childStr + if i != len(f)-1 { + out += "," + } + } + + return out + "]", nil +} + +func (f Or) asString(hasAnd, _ bool) (string, error) { + if len(f) == 0 { + return "", nil + } + + out := "[" + for i, child := range f { + childStr, err := child.asString(true, true) + if err != nil { + return "", err + } + out += childStr + if i != len(f)-1 { + out += "," + } + } + out += "]" + + if !hasAnd { + out = "[" + out + "]" + } + + return out, nil +} + +func (f Eq) asString(hasAnd, hasOr bool) (string, error) { + return serializeOperation(hasAnd, hasOr, "=", f.Type, f.Value) +} +func (f NEq) asString(hasAnd, hasOr bool) (string, error) { + return serializeOperation(hasAnd, hasOr, "!=", f.Type, f.Value) +} +func (f Gt) asString(hasAnd, hasOr bool) (string, error) { + return serializeOperation(hasAnd, hasOr, ">", f.Type, f.Value) +} +func (f GtEq) asString(hasAnd, hasOr bool) (string, error) { + return serializeOperation(hasAnd, hasOr, ">=", f.Type, f.Value) +} +func (f Lt) asString(hasAnd, hasOr bool) (string, error) { + return serializeOperation(hasAnd, hasOr, "<", f.Type, f.Value) +} +func (f LtEq) asString(hasAnd, hasOr bool) (string, error) { + return serializeOperation(hasAnd, hasOr, "<=", f.Type, f.Value) +} + +func (And) facetRoot() {} +func (Or) facetRoot() {} +func (Eq) facetRoot() {} +func (NEq) facetRoot() {} +func (Gt) facetRoot() {} +func (GtEq) facetRoot() {} +func (Lt) facetRoot() {} +func (LtEq) facetRoot() {} + +func (Or) orFilter() {} +func (Eq) orFilter() {} +func (NEq) orFilter() {} +func (Gt) orFilter() {} +func (GtEq) orFilter() {} +func (Lt) orFilter() {} +func (LtEq) orFilter() {} + +func (Or) facet() {} +func (Eq) facet() {} +func (NEq) facet() {} +func (Gt) facet() {} +func (GtEq) facet() {} +func (Lt) facet() {} +func (LtEq) facet() {} + +func serializeOperation(hasAnd, hasOr bool, op string, _type Type, value interface{}) (string, error) { + valueStr, err := serializeValue(value) + if err != nil { + return "", err + } + + out := `"` + string(_type) + op + valueStr + `"` + + if !hasAnd { + out = "[" + out + "]" + } + if !hasOr { + out = "[" + out + "]" + } + return out, nil +} + +func serializeValue(value interface{}) (string, error) { + switch v := value.(type) { + case bool: + if v { + return "true", nil + } else { + return "false", nil + } + case string: + return v, nil + case int: + return strconv.Itoa(v), nil + case int64: + return strconv.FormatInt(v, 10), nil + case float64: + return strconv.FormatFloat(v, 'g', -1, 64), nil + case time.Time: + return v.Format(time.RFC3339), nil + default: + return "", fmt.Errorf("unexpected facet value type %T", v) + } +} diff --git a/internal/pkg/modrinth/facet/facet_test.go b/internal/pkg/modrinth/facet/facet_test.go new file mode 100644 index 0000000..4c9f6b0 --- /dev/null +++ b/internal/pkg/modrinth/facet/facet_test.go @@ -0,0 +1,39 @@ +package facet + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSerialize(t *testing.T) { + tests := []struct { + name string + in Root + out string + }{ + {"empty and", And{}, ""}, + {"empty or", Or{}, ""}, + {"or no and", Or{Eq{ProjectType, "mod"}}, `[["project_type=mod"]]`}, + {"and no or", And{Eq{ProjectType, "mod"}}, `[["project_type=mod"]]`}, + {"no and or", Eq{ProjectType, "mod"}, `[["project_type=mod"]]`}, + {"and or", And{Or{Eq{ProjectType, "mod"}}}, `[["project_type=mod"]]`}, + {"and multi", And{Eq{ProjectType, "mod"}, NEq{ProjectType, "modpack"}}, `[["project_type=mod"],["project_type!=modpack"]]`}, + {"or multi", Or{Eq{ProjectType, "mod"}, NEq{ProjectType, "modpack"}}, `[["project_type=mod","project_type!=modpack"]]`}, + + {"eq", Eq{ProjectType, "mod"}, `[["project_type=mod"]]`}, + {"neq", NEq{ProjectType, "mod"}, `[["project_type!=mod"]]`}, + {"gt", Gt{ProjectType, "mod"}, `[["project_type>mod"]]`}, + {"gteq", GtEq{ProjectType, "mod"}, `[["project_type>=mod"]]`}, + {"lt", Lt{ProjectType, "mod"}, `[["project_type 100 { + return nil, errors.New("limit must be less than or equal to 100") + } + facets, err := facet.ToString(req.Facets) + if err != nil { + return nil, err + } + + params := url.Values{} + if req.Query != "" { + params.Add("query", req.Query) + } + if facets != "" { + params.Add("facets", facets) + } + params.Add("index", string(req.Index)) + params.Add("offset", strconv.Itoa(req.Offset)) + params.Add("limit", strconv.Itoa(req.Limit)) + + return get[SearchResponse](c, ctx, "/search", params) +} + +var searchIndexValidationMap = map[SearchIndex]bool{ + Relevance: true, + Downloads: true, + Follows: true, + Newest: true, + Updated: true, +} diff --git a/internal/pkg/modrinth/types.go b/internal/pkg/modrinth/types.go index b200b47..a7babfa 100644 --- a/internal/pkg/modrinth/types.go +++ b/internal/pkg/modrinth/types.go @@ -1,25 +1,27 @@ package modrinth -import "time" +type ProjectType string -type VersionType string +const ( + Mod ProjectType = "mod" + ModPack ProjectType = "modpack" + ResourcePack ProjectType = "resourcepack" + Shader ProjectType = "shader" +) + +type SupportStatus string const ( - Alpha VersionType = "alpha" - Beta VersionType = "beta" - Release VersionType = "release" + Required SupportStatus = "required" + Optional SupportStatus = "optional" + Unsupported SupportStatus = "unsupported" + Unknown SupportStatus = "unknown" ) -type Version struct { - VersionType VersionType `json:"version_type"` - DatePublished time.Time `json:"date_published"` - Files []*struct { - Hashes struct { - Sha1 string `json:"sha1"` - } - Url string `json:"url"` - Filename string `json:"filename"` - Primary bool `json:"primary"` - Size int64 `json:"size"` - } `json:"files"` -} +type MonetizationStatus string + +const ( + Monetized MonetizationStatus = "monetized" + Demonetized MonetizationStatus = "demonetized" + ForceDemonetized MonetizationStatus = "force-demonetized" +) diff --git a/internal/pkg/util/numbers.go b/internal/pkg/util/numbers.go new file mode 100644 index 0000000..c6f3ebf --- /dev/null +++ b/internal/pkg/util/numbers.go @@ -0,0 +1,23 @@ +package util + +import "fmt" + +func FormatCount(num int) string { + if num >= 1_000_000_000 { + return fmt.Sprintf("%.2fB", float64(num)/1_000_000_000) + } else if num >= 100_000_000 { + return fmt.Sprintf("%.0fM", float64(num)/1_000_000) + } else if num >= 10_000_000 { + return fmt.Sprintf("%.1fM", float64(num)/1_000_000) + } else if num >= 1_000_000 { + return fmt.Sprintf("%.2fM", float64(num)/1_000_000) + } else if num >= 100_000 { + return fmt.Sprintf("%.0fk", float64(num)/1_000) + } else if num >= 10_000 { + return fmt.Sprintf("%.1fk", float64(num)/1_000) + } else if num >= 1_000 { + return fmt.Sprintf("%.2fk", float64(num)/1_000) + } else { + return fmt.Sprintf("%d", num) + } +}