From b5b9fd68103804bba9a60cf35016116a58866205 Mon Sep 17 00:00:00 2001 From: TheEpicBlock <61842090+TheEpicBlock@users.noreply.github.com> Date: Tue, 16 Feb 2021 17:34:52 +0100 Subject: [PATCH] Modrinth Support (#11) Co-authored-by: comp500 --- core/hash.go | 5 +- core/mod.go | 2 +- go.mod | 1 + go.sum | 9 ++ main.go | 1 + modrinth/install.go | 256 +++++++++++++++++++++++++++++++++ modrinth/modrinth.go | 331 +++++++++++++++++++++++++++++++++++++++++++ modrinth/updater.go | 110 ++++++++++++++ 8 files changed, 713 insertions(+), 2 deletions(-) create mode 100644 modrinth/install.go create mode 100644 modrinth/modrinth.go create mode 100644 modrinth/updater.go diff --git a/core/hash.go b/core/hash.go index 62487a8..46ce5d9 100644 --- a/core/hash.go +++ b/core/hash.go @@ -2,16 +2,19 @@ package core import ( "crypto/md5" + "crypto/sha1" "crypto/sha256" "crypto/sha512" - "strings" "errors" "hash" + "strings" ) // GetHashImpl gets an implementation of hash.Hash for the given hash type string func GetHashImpl(hashType string) (hash.Hash, error) { switch strings.ToLower(hashType) { + case "sha1": + return sha1.New(), nil case "sha256": return sha256.New(), nil case "sha512": diff --git a/core/mod.go b/core/mod.go index 4a15943..d32bdf6 100644 --- a/core/mod.go +++ b/core/mod.go @@ -3,12 +3,12 @@ package core import ( "encoding/hex" "errors" + "fmt" "io" "net/http" "os" "path/filepath" "strconv" - "fmt" "github.com/BurntSushi/toml" ) diff --git a/go.mod b/go.mod index 2354758..1e1ae2e 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/spf13/cobra v0.0.5 github.com/spf13/viper v1.4.0 github.com/vbauerster/mpb/v4 v4.7.0 + golang.org/x/mod v0.4.1 golang.org/x/sys v0.0.0-20190919044723-0c1ff786ef13 gopkg.in/dixonwille/wlog.v2 v2.0.0 // indirect gopkg.in/dixonwille/wmenu.v4 v4.0.2 diff --git a/go.sum b/go.sum index fecf2e3..1def4e9 100644 --- a/go.sum +++ b/go.sum @@ -145,18 +145,24 @@ golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnf golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734 h1:p/H982KKEjUnLJkM3tt/LemDnOc1GiZL5FCVlORJ5zo= golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.4.1 h1:Kvvh58BN8Y9/lBi7hTekvtMpm07eUZ0ck5pRHpsMWrY= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -174,6 +180,9 @@ golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxb golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= diff --git a/main.go b/main.go index 7638a14..0edf215 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( // Modules of packwiz "github.com/comp500/packwiz/cmd" _ "github.com/comp500/packwiz/curseforge" + _ "github.com/comp500/packwiz/modrinth" _ "github.com/comp500/packwiz/utils" ) diff --git a/modrinth/install.go b/modrinth/install.go new file mode 100644 index 0000000..d096fb8 --- /dev/null +++ b/modrinth/install.go @@ -0,0 +1,256 @@ +package modrinth + +import ( + "errors" + "fmt" + "os" + "regexp" + "strings" + + "github.com/comp500/packwiz/core" + "github.com/spf13/cobra" + "gopkg.in/dixonwille/wmenu.v4" +) + +var modSiteRegex = regexp.MustCompile("modrinth\\.com/mod/([^/]+)/?$") +var versionSiteRegex = regexp.MustCompile("modrinth\\.com/mod/([^/]+)/version/([^/]+)/?$") + +// installCmd represents the install command +var installCmd = &cobra.Command{ + Use: "install [mod]", + Short: "Install a mod from a modrinth URL, slug, ID or search", + Aliases: []string{"add", "get"}, + Args: cobra.ArbitraryArgs, + Run: func(cmd *cobra.Command, args []string) { + pack, err := core.LoadPack() + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + if len(args) == 0 || len(args[0]) == 0 { + fmt.Println("You must specify a mod.") + os.Exit(1) + } + + // If there are more than 1 argument, go straight to searching - URLs/Slugs should not have spaces! + if len(args) > 1 { + err = installViaSearch(strings.Join(args, " "), pack) + if err != nil { + fmt.Printf("Failed installing mod: %s\n", err) + os.Exit(1) + } + return + } + + //Try interpreting the arg as a version url + matches := versionSiteRegex.FindStringSubmatch(args[0]) + if matches != nil && len(matches) == 3 { + err = installVersionById(matches[2], pack) + if err != nil { + fmt.Printf("Failed installing mod: %s\n", err) + os.Exit(1) + } + return + } + + //Try interpreting the arg as a modId or slug. + //Modrinth transparently handles slugs/mod ids in their api; we don't have to detect which one it is. + var modStr string + + //Try to see if it's a site, if extract the id/slug from the url. + //Otherwise, interpret the arg as a id/slug straight up + matches = modSiteRegex.FindStringSubmatch(args[0]) + if matches != nil && len(matches) == 2 { + modStr = matches[1] + } else { + modStr = args[0] + } + + mod, err := fetchMod(modStr) + + if err == nil { + //We found a mod with that id/slug + err = installMod(mod, pack) + if err != nil { + fmt.Printf("Failed installing mod: %s\n", err) + os.Exit(1) + } + return + } else { + //This wasn't a valid modid/slug, try to search for it instead: + //Don't bother to search if it looks like a url though + if !strings.Contains(args[0], "modrinth.com") { + err = installViaSearch(args[0], pack) + if err != nil { + fmt.Printf("Failed installing mod: %s\n", err) + os.Exit(1) + } + } + } + }, +} + +func installViaSearch(query string, pack core.Pack) error { + mcVersion, err := pack.GetMCVersion() + if err != nil { + return err + } + + results, err := getModIdsViaSearch(query, mcVersion) + if err != nil { + return err + } + + //Create menu for the user to choose the correct mod + menu := wmenu.NewMenu("Choose a number:") + for i, v := range results { + menu.Option(v.Title, v, i == 0, nil) + } + menu.Option("Cancel", nil, false, nil) + + menu.Action(func(menuRes []wmenu.Opt) error { + if len(menuRes) != 1 || menuRes[0].Value == nil { + return errors.New("Cancelled!") + } + + //Get the selected mod + selectedMod, ok := menuRes[0].Value.(ModResult) + if !ok { + return errors.New("error converting interface from wmenu") + } + + //Install the selected mod + modId := strings.TrimPrefix(selectedMod.ModID, "local-") + + mod, err := fetchMod(modId) + if err != nil { + return err + } + + return installMod(mod, pack) + }) + + return menu.Run() +} + +func installMod(mod Mod, pack core.Pack) error { + fmt.Printf("Found mod %s: '%s'.\n", mod.Title, mod.Description) + + latestVersion, err := getLatestVersion(mod.ID, pack) + if err != nil { + return err + } + if latestVersion.ID == "" { + return errors.New("mod is not available for this minecraft version or mod loader") + } + + return installVersion(mod, latestVersion, pack) +} + +func installVersion(mod Mod, version Version, pack core.Pack) error { + var files = version.Files + + if len(files) == 0 { + return errors.New("version doesn't have any files attached") + } + + // TODO: add some way to allow users to pick which file to install? + var file = files[0] + // Prefer the primary file + for _, v := range files { + if v.Primary { + file = v + } + } + + //Install the file + fmt.Printf("Installing %s from version %s\n", file.Filename, version.VersionNumber) + index, err := pack.LoadIndex() + if err != nil { + return err + } + + updateMap := make(map[string]map[string]interface{}) + + updateMap["modrinth"], err = mrUpdateData{ + ModID: mod.ID, + InstalledVersion: version.ID, + }.ToMap() + if err != nil { + return err + } + + side := mod.getSide() + if side == "" { + return errors.New("version doesn't have a side that's supported. Server: " + mod.ServerSide + " Client: " + mod.ClientSide) + } + + algorithm, hash := file.getBestHash() + if algorithm == "" { + return errors.New("file doesn't have a hash") + } + + modMeta := core.Mod{ + Name: mod.Title, + FileName: file.Filename, + Side: side, + Download: core.ModDownload{ + URL: file.Url, + HashFormat: algorithm, + Hash: hash, + }, + Update: updateMap, + } + var path string + if mod.Slug != "" { + path = modMeta.SetMetaName(mod.Slug) + } else { + path = modMeta.SetMetaName(mod.Title) + } + + // If the file already exists, this will overwrite it!!! + // TODO: Should this be improved? + // Current strategy is to go ahead and do stuff without asking, with the assumption that you are using + // VCS anyway. + + format, hash, err := modMeta.Write() + if err != nil { + return err + } + err = index.RefreshFileWithHash(path, format, hash, true) + if err != nil { + return err + } + err = index.Write() + if err != nil { + return err + } + err = pack.UpdateIndexHash() + if err != nil { + return err + } + err = pack.Write() + if err != nil { + return err + } + return nil +} + +func installVersionById(versionId string, pack core.Pack) error { + version, err := fetchVersion(versionId) + if err != nil { + return err + } + + mod, err := fetchMod(version.ModID) + if err != nil { + return err + } + + return installVersion(mod, version, pack) +} + +func init() { + modrinthCmd.AddCommand(installCmd) +} diff --git a/modrinth/modrinth.go b/modrinth/modrinth.go new file mode 100644 index 0000000..f65987f --- /dev/null +++ b/modrinth/modrinth.go @@ -0,0 +1,331 @@ +package modrinth + +import ( + "encoding/json" + "errors" + "io/ioutil" + "net/http" + "net/url" + "time" + + "github.com/comp500/packwiz/cmd" + "github.com/comp500/packwiz/core" + "github.com/spf13/cobra" + "golang.org/x/mod/semver" +) + +const modrinthApiUrl = "https://api.modrinth.com/api/v1/" + +var modrinthApiUrlParsed, _ = url.Parse(modrinthApiUrl) + +var modrinthCmd = &cobra.Command{ + Use: "modrinth", + Aliases: []string{"mr"}, + Short: "Manage modrinth-based mods", +} + +func init() { + cmd.Add(modrinthCmd) + core.Updaters["modrinth"] = mrUpdater{} +} + +type License struct { + Id string `json:"id"` //The license id of a mod, retrieved from the licenses get route + Name string `json:"name"` //The long for name of a license + Url string `json:"url"` //The URL to this license +} + +type Mod struct { + ID string `json:"id"` //The ID of the mod, encoded as a base62 string + Slug string `json:"slug"` //The slug of a mod, used for vanity URLs + Team string `json:"team"` //The id of the team that has ownership of this mod + Title string `json:"title"` //The title or name of the mod + Description string `json:"description"` //A short description of the mod + Body string `json:"body"` //A long form description of the mod. + BodyUrl string `json:"body_url"` //DEPRECATED The link to the long description of the mod (Optional) + Published string `json:"published"` //The date at which the mod was first published + Updated string `json:"updated"` //The date at which the mod was updated + Status string `json:"status"` //The status of the mod - approved, rejected, draft, unlisted, processing, or unknown + License struct { //The license of the mod + ID string `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + } `json:"license"` + ClientSide string `json:"client_side"` //The support range for the client mod - required, optional, unsupported, or unknown + ServerSide string `json:"server_side"` //The support range for the server mod - required, optional, unsupported, or unknown + Downloads int `json:"downloads"` //The total number of downloads the mod has + Categories []string `json:"categories"` //A list of the categories that the mod is in + Versions []string `json:"versions"` //A list of ids for versions of the mod + IconUrl string `json:"icon_url"` //The URL of the icon of the mod (Optional) + IssuesUrl string `json:"issues_url"` //An optional link to where to submit bugs or issues with the mod (Optional) + SourceUrl string `json:"source_url"` //An optional link to the source code for the mod (Optional) + WikiUrl string `json:"wiki_url"` //An optional link to the mod's wiki page or other relevant information (Optional) + DiscordUrl string `json:"discord_url"` //An optional link to the mod's discord (Optional) +} + +type ModResult struct { + ModID string `json:"mod_id"` //The id of the mod; prefixed with local- + ProjectType string `json:"project_id"` //The project type of the mod + Author string `json:"author"` //The username of the author of the mod + Title string `json:"title"` //The name of the mod + Description string `json:"description"` //A short description of the mod + Categories []string `json:"categories"` //A list of the categories the mod is in + Versions []string `json:"versions"` //A list of the minecraft versions supported by the mod + Downloads int `json:"downloads"` //The total number of downloads for the mod + PageUrl string `json:"page_url"` //A link to the mod's main page; + IconUrl string `json:"icon_url"` //The url of the mod's icon + AuthorUrl string `json:"author_url"` //The url of the mod's author + DateCreated string `json:"date_created"` //The date that the mod was originally created + DateModified string `json:"date_modified"` //The date that the mod was last modified + LatestVersion string `json:"latest_version"` //The latest version of minecraft that this mod supports + License string `json:"license"` //The id of the license this mod follows + ClientSide string `json:"client_side"` //The side type id that this mod is on the client + ServerSide string `json:"server_side"` //The side type id that this mod is on the server + Host string `json:"host"` //The host that this mod is from, always modrinth +} + +type ModSearchResult struct { + Hits []ModResult `json:"hits"` //The list of results + Offset int `json:"offset"` //The number of results that were skipped by the query + Limit int `json:"limit"` //The number of mods returned by the query + TotalHits int `json:"total_hits"` //The total number of mods that the query found +} + +type Version struct { + ID string `json:"id"` //The ID of the version, encoded as a base62 string + ModID string `json:"mod_id"` //The ID of the mod this version is for + AuthorId string `json:"author_id"` //The ID of the author who published this version + Featured bool `json:"featured"` //Whether the version is featured or not + Name string `json:"name"` //The name of this version + VersionNumber string `json:"version_number"` //The version number. Ideally will follow semantic versioning + Changelog string `json:"changelog"` //The changelog for this version of the mod. (Optional) + DatePublished string `json:"date_published"` //The date that this version was published + Downloads int `json:"downloads"` //The number of downloads this specific version has + VersionType string `json:"version_type"` //The type of the release - alpha, beta, or release + Files []VersionFile `json:"files"` //A list of files available for download for this version + Dependencies []string `json:"dependencies"` //A list of specific versions of mods that this version depends on + GameVersions []string `json:"game_versions"` //A list of versions of Minecraft that this version of the mod supports + Loaders []string `json:"loaders"` //The mod loaders that this version supports +} + +type VersionFile struct { + Hashes map[string]string //A map of hashes of the file. The key is the hashing algorithm and the value is the string version of the hash. + Url string //A direct link to the file + Filename string //The name of the file + Primary bool // Is the file the primary file? +} + +func getModIdsViaSearch(query string, version string) ([]ModResult, error) { + baseUrl := *modrinthApiUrlParsed + baseUrl.Path += "mod" + + params := url.Values{} + params.Add("limit", "5") + params.Add("index", "relevance") + params.Add("facets", "[[\"versions:"+version+"\"]]") + params.Add("query", query) + + baseUrl.RawQuery = params.Encode() + + resp, err := http.Get(baseUrl.String()) + if err != nil { + return []ModResult{}, err + } + + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return []ModResult{}, err + } + + var result ModSearchResult + err = json.Unmarshal(body, &result) + if err != nil { + return []ModResult{}, err + } + + if result.TotalHits <= 0 { + return []ModResult{}, errors.New("Couldn't find that mod. Is it available for this version?") + } + + return result.Hits, nil +} + +func getLatestVersion(modID string, pack core.Pack) (Version, error) { + mcVersion, err := pack.GetMCVersion() + if err != nil { + return Version{}, err + } + + loader := getLoader(pack) + + baseUrl := modrinthApiUrlParsed + baseUrl.Path += "mod/" + baseUrl.Path += modID + baseUrl.Path += "/version" + + params := url.Values{} + params.Add("game_versions", "[\""+mcVersion+"\"]") + if loader != "any" { + params.Add("loaders", "[\""+loader+"\"]") + } + + baseUrl.RawQuery = params.Encode() + + resp, err := http.Get(baseUrl.String()) + if err != nil { + return Version{}, err + } + + if resp.StatusCode == 404 { + return Version{}, errors.New("couldn't find mod: " + modID) + } + + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return Version{}, err + } + + var result []Version + err = json.Unmarshal(body, &result) + if err != nil { + return Version{}, err + } + + var latestValidVersion Version + for _, v := range result { + var semverCompare = semver.Compare(v.VersionNumber, latestValidVersion.VersionNumber) + if semverCompare == 0 { + //Semver is equal, compare date instead + vDate, _ := time.Parse(time.RFC3339Nano, v.DatePublished) + latestDate, _ := time.Parse(time.RFC3339Nano, latestValidVersion.DatePublished) + if vDate.After(latestDate) { + latestValidVersion = v + } + } else if semverCompare == 1 { + latestValidVersion = v + } + } + + return latestValidVersion, nil +} + +func fetchMod(modID string) (Mod, error) { + var mod Mod + + resp, err := http.Get(modrinthApiUrl + "mod/" + modID) + if err != nil { + return mod, err + } + + if resp.StatusCode == 404 { + return mod, errors.New("couldn't find mod: " + modID) + } + + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return mod, err + } + + err = json.Unmarshal(body, &mod) + if err != nil { + return mod, err + } + + if mod.ID == "" { + return mod, errors.New("invalid json whilst fetching mod: " + modID) + } + + return mod, nil +} + +func fetchVersion(versionId string) (Version, error) { + var version Version + + resp, err := http.Get(modrinthApiUrl + "version/" + versionId) + if err != nil { + return version, err + } + + if resp.StatusCode == 404 { + return version, errors.New("couldn't find version: " + versionId) + } + + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return version, err + } + + err = json.Unmarshal(body, &version) + if err != nil { + return version, err + } + + if version.ID == "" { + return version, errors.New("invalid json whilst fetching version: " + versionId) + } + + return version, nil +} + +func (mod Mod) getSide() string { + server := shouldDownloadOnSide(mod.ServerSide) + client := shouldDownloadOnSide(mod.ClientSide) + + if server && client { + return core.UniversalSide + } else if server { + return core.ServerSide + } else if client { + return core.ClientSide + } else { + return "" + } +} + +func shouldDownloadOnSide(side string) bool { + return side == "required" || side == "optional" +} + +func (v VersionFile) getBestHash() (string, string) { + //try preferred hashes first + val, exists := v.Hashes["sha256"] + if exists { + return "sha256", val + } + val, exists = v.Hashes["murmur2"] + if exists { + return "murmur2", val + } + val, exists = v.Hashes["sha512"] + if exists { + return "sha512", val + } + + //none of the preferred hashes are present, just get the first one + for key, val := range v.Hashes { + return key, val + } + + //No hashes were present + return "", "" +} + +func getLoader(pack core.Pack) string { + dependencies := pack.Versions + + _, hasFabric := dependencies["fabric"] + _, hasForge := dependencies["forge"] + if hasFabric && hasForge { + return "any" + } else if hasFabric { + return "fabric" + } else if hasForge { + return "forge" + } else { + return "any" + } +} diff --git a/modrinth/updater.go b/modrinth/updater.go new file mode 100644 index 0000000..a1cd242 --- /dev/null +++ b/modrinth/updater.go @@ -0,0 +1,110 @@ +package modrinth + +import ( + "errors" + + "github.com/comp500/packwiz/core" + "github.com/mitchellh/mapstructure" +) + +type mrUpdateData struct { + ModID string `mapstructure:"mod-id"` + InstalledVersion string `mapstructure:"version"` +} + +func (u mrUpdateData) ToMap() (map[string]interface{}, error) { + newMap := make(map[string]interface{}) + err := mapstructure.Decode(u, &newMap) + return newMap, err +} + +type mrUpdater struct{} + +func (u mrUpdater) ParseUpdate(updateUnparsed map[string]interface{}) (interface{}, error) { + var updateData mrUpdateData + err := mapstructure.Decode(updateUnparsed, &updateData) + return updateData, err +} + +type cachedStateStore struct { + ModID string + Version Version +} + +func (u mrUpdater) CheckUpdate(mods []core.Mod, mcVersion string) ([]core.UpdateCheck, error) { + results := make([]core.UpdateCheck, len(mods)) + + pack, err := core.LoadPack() + if err != nil { + return results, err + } + + for i, mod := range mods { + rawData, ok := mod.GetParsedUpdateData("modrinth") + if !ok { + results[i] = core.UpdateCheck{Error: errors.New("couldn't parse mod data")} + continue + } + + data := rawData.(mrUpdateData) + + newVersion, err := getLatestVersion(data.ModID, pack) + if err != nil { + results[i] = core.UpdateCheck{Error: err} + continue + } + + if newVersion.ID == "" { //There is no version available for this minecraft version or loader. + results[i] = core.UpdateCheck{UpdateAvailable: false} + continue + } + + if newVersion.ID == data.InstalledVersion { //The latest version from the site is the same as the installed one + results[i] = core.UpdateCheck{UpdateAvailable: false} + continue + } + + if len(newVersion.Files) == 0 { + results[i] = core.UpdateCheck{Error: errors.New("new version doesn't have any files")} + continue + } + + results[i] = core.UpdateCheck{ + UpdateAvailable: true, + UpdateString: mod.FileName + " -> " + newVersion.Files[0].Filename, + CachedState: cachedStateStore{data.ModID, newVersion}, + } + } + + return results, nil +} + +func (u mrUpdater) DoUpdate(mods []*core.Mod, cachedState []interface{}) error { + for i, mod := range mods { + modState := cachedState[i].(cachedStateStore) + var version = modState.Version + + var file = version.Files[0] + // Prefer the primary file + for _, v := range version.Files { + if v.Primary { + file = v + } + } + + algorithm, hash := file.getBestHash() + if algorithm == "" { + return errors.New("file for mod " + mod.Name + " doesn't have a hash") + } + + mod.FileName = file.Filename + mod.Download = core.ModDownload{ + URL: file.Url, + HashFormat: algorithm, + Hash: hash, + } + mod.Update["modrinth"]["version"] = version.ID + } + + return nil +}