diff --git a/core/interfaces.go b/core/interfaces.go index 84dcdea..238879d 100644 --- a/core/interfaces.go +++ b/core/interfaces.go @@ -1,8 +1,9 @@ package core // UpdateParsers stores all the update parsers that packwiz can use. Add your own update systems to this map. -var UpdateParsers map[string]UpdateParser = make(map[string]UpdateParser) +var UpdateParsers = make(map[string]UpdateParser) -// UpdateParser takes an unparsed interface{}, and returns an Updater for a mod file +// UpdateParser takes an unparsed interface{} (as a map[string]interface{}), and returns an Updater for a mod file. +// This can be done using the mapstructure library or your own parsing methods. type UpdateParser interface { ParseUpdate(interface{}) (Updater, error) } diff --git a/curseforge/curseforge.go b/curseforge/curseforge.go index ef87b4d..8e9b9c5 100644 --- a/curseforge/curseforge.go +++ b/curseforge/curseforge.go @@ -1,6 +1,8 @@ package curseforge import ( "fmt" + "regexp" + "strconv" "github.com/comp500/packwiz/core" "github.com/mitchellh/mapstructure" @@ -29,11 +31,84 @@ func init() { }) core.UpdateParsers["curseforge"] = cfUpdateParser{} } + +var fileIDRegexes = [...]*regexp.Regexp{ + regexp.MustCompile("https?:\\/\\/minecraft\\.curseforge\\.com\\/projects\\/(.+)\\/files\\/(\\d+)"), + regexp.MustCompile("https?:\\/\\/(?:www\\.)?curseforge\\.com\\/minecraft\\/mc-mods\\/(.+)\\/download\\/(\\d+)"), +} + +func getFileIDsFromString(mod string) (bool, int, int, error) { + for _, v := range fileIDRegexes { + matches := v.FindStringSubmatch(mod) + if matches != nil && len(matches) == 3 { + modID, err := modIDFromSlug(matches[1]) + fileID, err := strconv.Atoi(matches[2]) + if err != nil { + return true, 0, 0, err + } + return true, modID, fileID, nil + } + } + return false, 0, 0, nil +} + +var modSlugRegexes = [...]*regexp.Regexp{ + regexp.MustCompile("https?:\\/\\/minecraft\\.curseforge\\.com\\/projects\\/([^\\/]+)"), + regexp.MustCompile("https?:\\/\\/(?:www\\.)?curseforge\\.com\\/minecraft\\/mc-mods\\/([^\\/]+)"), + // Exact slug matcher + regexp.MustCompile("[a-z][\\da-z\\-]{0,127}"), +} + +func getModIDFromString(mod string) (bool, int, error) { + // Check if it's just a number first + modID, err := strconv.Atoi(mod) + if err == nil && modID > 0 { + return true, modID, nil + } + + for _, v := range modSlugRegexes { + matches := v.FindStringSubmatch(mod) + if matches != nil { + var slug string + if len(matches) == 2 { + slug = matches[1] + } else if len(matches) == 1 { + slug = matches[0] + } else { + continue + } + modID, err := modIDFromSlug(slug) + if err != nil { + return true, 0, err + } + return true, modID, nil + } + } + return false, 0, nil +} + func cmdInstall(flags core.Flags, mod string) error { if len(mod) == 0 { return cli.NewExitError("You must specify a mod.", 1) } - fmt.Println("Not implemented yet!") + //fmt.Println("Not implemented yet!") + + done, modID, fileID, err := getFileIDsFromString(mod) + if err != nil { + fmt.Println(err) + } + + if !done { + done, modID, err = getModIDFromString(mod) + if err != nil { + fmt.Println(err) + } + } + + // TODO: fallback to CurseMeta search + // TODO: how to do interactive choices? automatically assume version? ask mod from list? choose first? + + fmt.Printf("ids: %d %d %v", modID, fileID, done) return nil } @@ -46,7 +121,9 @@ func (u cfUpdateParser) ParseUpdate(updateUnparsed interface{}) (core.Updater, e } type cfUpdater struct { - ProjectID int `mapstructure:"project-id"` + ProjectID int `mapstructure:"project-id"` + FileID int `mapstructure:"file-id"` + ReleaseChannel string `mapstructure:"release-channel"` } func (u cfUpdater) DoUpdate(mod core.Mod) (bool, error) { diff --git a/curseforge/request.go b/curseforge/request.go new file mode 100644 index 0000000..73d8abf --- /dev/null +++ b/curseforge/request.go @@ -0,0 +1,86 @@ +package curseforge +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" +) + +// AddonSlugRequest is sent to the CurseProxy GraphQL api to get the id from a slug +type AddonSlugRequest struct { + Query string `json:"query"` + Variables struct { + Slug string `json:"slug"` + } `json:"variables"` +} + +// AddonSlugResponse is received from the CurseProxy GraphQL api to get the id from a slug +type AddonSlugResponse struct { + Data struct { + Addons []struct { + ID int `json:"id"` + } `json:"addons"` + } `json:"data"` + Exception string `json:"exception"` + Message string `json:"message"` + Stacktrace []string `json:"stacktrace"` +} + +// Most of this is shamelessly copied from my previous attempt at modpack management: +// https://github.com/comp500/modpack-editor/blob/master/query.go +func modIDFromSlug(slug string) (int, error) { + request := AddonSlugRequest{ + Query: ` + query getIDFromSlug($slug: String) { + { + addons(slug: $slug) { + id + } + } + } + `, + } + request.Variables.Slug = slug + + // Uses the curse.nikky.moe GraphQL api + var response AddonSlugResponse + client := &http.Client{} + + requestBytes, err := json.Marshal(request) + if err != nil { + return 0, err + } + + req, err := http.NewRequest("POST", "https://curse.nikky.moe/graphql", bytes.NewBuffer(requestBytes)) + if err != nil { + return 0, err + } + + // TODO: make this configurable application-wide + req.Header.Set("User-Agent", "comp500/packwiz client") + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return 0, err + } + + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil && err != io.EOF { + return 0, err + } + + if len(response.Exception) > 0 || len(response.Message) > 0 { + return 0, fmt.Errorf("Error requesting id for slug: %s", response.Message) + } + + if len(response.Data.Addons) < 1 { + return 0, errors.New("Addon not found") + } + + return response.Data.Addons[0].ID, nil +} +