diff --git a/curseforge/curseforge.go b/curseforge/curseforge.go index c20871c..4843275 100644 --- a/curseforge/curseforge.go +++ b/curseforge/curseforge.go @@ -3,6 +3,7 @@ package curseforge import ( "errors" "github.com/spf13/viper" + "golang.org/x/exp/slices" "regexp" "strconv" "strings" @@ -24,12 +25,6 @@ func init() { core.Updaters["curseforge"] = cfUpdater{} } -var fileIDRegexes = [...]*regexp.Regexp{ - regexp.MustCompile("^https?://minecraft\\.curseforge\\.com/projects/(.+)/files/(\\d+)"), - regexp.MustCompile("^https?://(?:www\\.)?curseforge\\.com/minecraft/mc-mods/(.+)/files/(\\d+)"), - regexp.MustCompile("^https?://(?:www\\.)?curseforge\\.com/minecraft/mc-mods/(.+)/download/(\\d+)"), -} - var snapshotVersionRegex = regexp.MustCompile("(?:Snapshot )?(\\d+)w0?(0|[1-9]\\d*)([a-z])") var snapshotNames = [...]string{"-pre", " Pre-Release ", " Pre-release ", "-rc"} @@ -111,57 +106,44 @@ func getCurseforgeVersion(mcVersion string) string { return mcVersion } -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]) - if err != nil { - return true, 0, 0, err - } - 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 urlRegexes = [...]*regexp.Regexp{ + regexp.MustCompile("^https?://(?Pminecraft)\\.curseforge\\.com/projects/(?P[^/]+)(?:/(?:files|download)/(?P\\d+))?"), + regexp.MustCompile("^https?://(?:www\\.)?curseforge\\.com/(?P[^/]+)/(?P[^/]+)/(?P[^/]+)(?:/(?:files|download)/(?P\\d+))?"), + regexp.MustCompile("^(?P[a-z][\\da-z\\-_]{0,127})$"), } -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) +func parseSlugOrUrl(url string) (game string, category string, slug string, fileID int, err error) { + for _, r := range urlRegexes { + matches := r.FindStringSubmatch(url) if matches != nil { - var slug string - if len(matches) == 2 { - slug = matches[1] - } else if len(matches) == 1 { - slug = matches[0] - } else { - continue + if i := r.SubexpIndex("game"); i >= 0 { + game = matches[i] } - modID, err := modIDFromSlug(slug) - if err != nil { - return true, 0, err + if i := r.SubexpIndex("category"); i >= 0 { + category = matches[i] } - return true, modID, nil + if i := r.SubexpIndex("slug"); i >= 0 { + slug = matches[i] + } + if i := r.SubexpIndex("fileID"); i >= 0 { + if matches[i] != "" { + fileID, err = strconv.Atoi(matches[i]) + } + } + return } } - return false, 0, nil + return +} + +// TODO: put projects into folders based on these +var defaultFolders = map[int]map[int]string{ + 432: { // Minecraft + 5: "plugins", // Bukkit Plugins + 12: "resourcepacks", + 6: "mods", + 17: "saves", + }, } func createModFile(modInfo modInfo, fileInfo modFileInfo, index *core.Index, optionalDisabled bool) error { @@ -241,22 +223,17 @@ func matchLoaderTypeFileInfo(packLoaderType int, fileInfoData modFileInfo) bool if packLoaderType == modloaderTypeAny { return true } else { - if packLoaderType == modloaderTypeFabric { - for _, v := range fileInfoData.GameVersions { - if v == "Fabric" { + containsLoader := false + for i, name := range modloaderNames { + if slices.Contains(fileInfoData.GameVersions, name) { + containsLoader = true + if i == packLoaderType { return true } } - } else if packLoaderType == modloaderTypeForge { - for _, v := range fileInfoData.GameVersions { - if v == "Forge" { - return true - } - } - } else { - return true } - return false + // If a file doesn't contain any loaders, it matches all! + return !containsLoader } } diff --git a/curseforge/install.go b/curseforge/install.go index f291dbc..dc2ce22 100644 --- a/curseforge/install.go +++ b/curseforge/install.go @@ -23,8 +23,8 @@ type installableDep struct { // installCmd represents the install command var installCmd = &cobra.Command{ - Use: "install [mod]", - Short: "Install a mod from a curseforge URL, slug, ID or search", + Use: "install [URL|slug|search]", + Short: "Install a project from a CurseForge URL, slug, ID or search", Aliases: []string{"add", "get"}, Args: cobra.ArbitraryArgs, Run: func(cmd *cobra.Command, args []string) { @@ -44,64 +44,71 @@ var installCmd = &cobra.Command{ os.Exit(1) } - var done bool + game := gameFlag + category := categoryFlag var modID, fileID int + var slug string + // If mod/file IDs are provided in command line, use those if fileIDFlag != 0 { fileID = fileIDFlag } if addonIDFlag != 0 { modID = addonIDFlag - done = true } - if (len(args) == 0 || len(args[0]) == 0) && !done { - fmt.Println("You must specify a mod.") + if (len(args) == 0 || len(args[0]) == 0) && modID == 0 { + fmt.Println("You must specify a project; with the ID flags, or by passing a URL, slug or search term directly.") os.Exit(1) } // If there are more than 1 argument, go straight to searching - URLs/Slugs should not have spaces! - if !done && len(args) == 1 { - done, modID, fileID, err = getFileIDsFromString(args[0]) + if modID == 0 && len(args) == 1 { + parsedGame, parsedCategory, parsedSlug, parsedFileID, err := parseSlugOrUrl(args[0]) if err != nil { - fmt.Println(err) + fmt.Printf("Failed to parse URL: %v\n", err) os.Exit(1) } - if !done { - done, modID, err = getModIDFromString(args[0]) - // Ignore error, go to search instead (e.g. lowercase to search instead of as a slug) - if err != nil { - done = false - } + if parsedGame != "" { + game = parsedGame + } + if parsedCategory != "" { + category = parsedCategory + } + if parsedSlug != "" { + slug = parsedSlug + } + if parsedFileID != 0 { + fileID = parsedFileID } } modInfoObtained := false var modInfoData modInfo - if !done { + if modID == 0 { var cancelled bool - cancelled, modInfoData = searchCurseforgeInternal(args, mcVersion, getLoader(pack)) + if slug == "" { + searchTerm := strings.Join(args, " ") + cancelled, modInfoData = searchCurseforgeInternal(searchTerm, false, game, category, mcVersion, getLoader(pack)) + } else { + cancelled, modInfoData = searchCurseforgeInternal(slug, true, game, category, mcVersion, getLoader(pack)) + } if cancelled { return } - done = true modID = modInfoData.ID modInfoObtained = true } - if !done { - if err == nil { - fmt.Println("No mods found!") - os.Exit(1) - } - fmt.Println(err) + if modID == 0 { + fmt.Println("No projects found!") os.Exit(1) } if !modInfoObtained { modInfoData, err = cfDefaultClient.getModInfo(modID) if err != nil { - fmt.Println(err) + fmt.Printf("Failed to get project info: %v\n", err) os.Exit(1) } } @@ -109,7 +116,7 @@ var installCmd = &cobra.Command{ var fileInfoData modFileInfo fileInfoData, err = getLatestFile(modInfoData, mcVersion, fileID, getLoader(pack)) if err != nil { - fmt.Println(err) + fmt.Printf("Failed to get file for project: %v\n", err) os.Exit(1) } @@ -169,6 +176,10 @@ var installCmd = &cobra.Command{ } depIDPendingQueue = depIDPendingQueue[:i] + if len(depIDPendingQueue) == 0 { + break + } + depInfoData, err := cfDefaultClient.getModInfoMultiple(depIDPendingQueue) if err != nil { fmt.Printf("Error retrieving dependency data: %s\n", err.Error()) @@ -221,11 +232,11 @@ var installCmd = &cobra.Command{ fmt.Println(err) os.Exit(1) } - fmt.Printf("Dependency \"%s\" successfully installed! (%s)\n", v.modInfo.Name, v.fileInfo.FileName) + fmt.Printf("Dependency \"%s\" successfully added! (%s)\n", v.modInfo.Name, v.fileInfo.FileName) } } } else { - fmt.Println("All dependencies are already installed!") + fmt.Println("All dependencies are already added!") } } } @@ -252,7 +263,7 @@ var installCmd = &cobra.Command{ os.Exit(1) } - fmt.Printf("Mod \"%s\" successfully installed! (%s)\n", modInfoData.Name, fileInfoData.FileName) + fmt.Printf("Project \"%s\" successfully added! (%s)\n", modInfoData.Name, fileInfoData.FileName) }, } @@ -267,22 +278,86 @@ func (r modResultsList) Len() int { return len(r) } -func searchCurseforgeInternal(args []string, mcVersion string, packLoaderType int) (bool, modInfo) { - fmt.Println("Searching CurseForge...") - searchTerm := strings.Join(args, " ") +func searchCurseforgeInternal(searchTerm string, isSlug bool, game string, category string, mcVersion string, packLoaderType int) (bool, modInfo) { + if isSlug { + fmt.Println("Looking up CurseForge slug...") + } else { + fmt.Println("Searching CurseForge...") + } + + var gameID, categoryID, classID int + if game == "minecraft" { + gameID = 432 + } + if category == "mc-mods" { + classID = 6 + } + if gameID == 0 { + games, err := cfDefaultClient.getGames() + if err != nil { + fmt.Printf("Failed to lookup game %s: %v\n", game, err) + os.Exit(1) + } + for _, v := range games { + if v.Slug == game { + if v.Status != gameStatusLive { + fmt.Printf("Failed to lookup game %s: selected game is not live!\n", game) + os.Exit(1) + } + if v.APIStatus != gameApiStatusPublic { + fmt.Printf("Failed to lookup game %s: selected game does not have a public API!\n", game) + os.Exit(1) + } + gameID = int(v.ID) + break + } + } + if gameID == 0 { + fmt.Printf("Failed to lookup: game %s could not be found!\n", game) + os.Exit(1) + } + } + if categoryID == 0 && classID == 0 && category != "" { + categories, err := cfDefaultClient.getCategories(gameID) + if err != nil { + fmt.Printf("Failed to lookup categories: %v\n", err) + os.Exit(1) + } + for _, v := range categories { + if v.Slug == category { + if v.IsClass { + classID = v.ID + } else { + classID = v.ClassID + categoryID = v.ID + } + break + } + } + if categoryID == 0 && classID == 0 { + fmt.Printf("Failed to lookup: category %s could not be found!\n", category) + os.Exit(1) + } + } // If there are more than one acceptable version, we shouldn't filter by game version at all (as we can't filter by multiple) filterGameVersion := getCurseforgeVersion(mcVersion) if len(viper.GetStringSlice("acceptable-game-versions")) > 0 { filterGameVersion = "" } - results, err := cfDefaultClient.getSearch(searchTerm, filterGameVersion, packLoaderType) + var search, slug string + if isSlug { + slug = searchTerm + } else { + search = searchTerm + } + results, err := cfDefaultClient.getSearch(search, slug, gameID, classID, categoryID, filterGameVersion, packLoaderType) if err != nil { - fmt.Println(err) + fmt.Printf("Failed to search for project: %v\n", err) os.Exit(1) } if len(results) == 0 { - fmt.Println("No mods found!") + fmt.Println("No projects found!") os.Exit(1) return false, modInfo{} } else if len(results) == 1 { @@ -296,11 +371,11 @@ func searchCurseforgeInternal(args []string, mcVersion string, packLoaderType in menu.Option("Cancel", nil, false, nil) if len(fuzzySearchResults) == 0 { for i, v := range results { - menu.Option(v.Name, v, i == 0, nil) + menu.Option(v.Name+" ("+v.Summary+")", v, i == 0, nil) } } else { for i, v := range fuzzySearchResults { - menu.Option(results[v.Index].Name, results[v.Index], i == 0, nil) + menu.Option(results[v.Index].Name+" ("+results[v.Index].Summary+")", results[v.Index], i == 0, nil) } } @@ -383,9 +458,14 @@ func getLatestFile(modInfoData modInfo, mcVersion string, fileID int, packLoader var addonIDFlag int var fileIDFlag int +var gameFlag string +var categoryFlag string + func init() { curseforgeCmd.AddCommand(installCmd) - installCmd.Flags().IntVar(&addonIDFlag, "addon-id", 0, "The curseforge addon ID to use") - installCmd.Flags().IntVar(&fileIDFlag, "file-id", 0, "The curseforge file ID to use") + installCmd.Flags().IntVar(&addonIDFlag, "addon-id", 0, "The CurseForge project ID to use") + installCmd.Flags().IntVar(&fileIDFlag, "file-id", 0, "The CurseForge file ID to use") + installCmd.Flags().StringVar(&gameFlag, "game", "minecraft", "The game to add files from (slug, as stored in URLs); the game in the URL takes precedence") + installCmd.Flags().StringVar(&categoryFlag, "category", "", "The category to add files from (slug, as stored in URLs); the category in the URL takes precedence") } diff --git a/curseforge/request.go b/curseforge/request.go index e2fe9e5..5154f95 100644 --- a/curseforge/request.go +++ b/curseforge/request.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/base64" "encoding/json" - "errors" "fmt" "io" "net/http" @@ -88,91 +87,6 @@ func (c *cfApiClient) makePost(endpoint string, body io.Reader) (*http.Response, return resp, nil } -// 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"` - OperationName string `json:"operationName"` -} - -// 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"` - CategorySection struct { - ID int `json:"id"` - } `json:"categorySection"` - } `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 - categorySection { - id - } - } - } - `, - OperationName: "getIDFromSlug", - } - 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 - } - - // TODO: move to new slug API - 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", "packwiz/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) - } - - for _, addonData := range response.Data.Addons { - // Only use mods, not resource packs/modpacks - if addonData.CategorySection.ID == 8 { - return addonData.ID, nil - } - } - return 0, errors.New("addon not found") -} - //noinspection GoUnusedConst const ( fileTypeRelease int = iota + 1 @@ -200,6 +114,14 @@ const ( modloaderTypeFabric ) +var modloaderNames = [...]string{ + "", + "Forge", + "Cauldron", + "Liteloader", + "Fabric", +} + //noinspection GoUnusedConst const ( hashAlgoSHA1 int = iota + 1 @@ -209,6 +131,7 @@ const ( // modInfo is a subset of the deserialised JSON response from the Curse API for mods (addons) type modInfo struct { Name string `json:"name"` + Summary string `json:"summary"` Slug string `json:"slug"` ID int `json:"id"` LatestFiles []modFileInfo `json:"latestFiles"` @@ -376,21 +299,34 @@ func (c *cfApiClient) getFileInfoMultiple(fileIDs []int) ([]modFileInfo, error) return infoRes.Data, nil } -func (c *cfApiClient) getSearch(searchText string, gameVersion string, modloaderType int) ([]modInfo, error) { +func (c *cfApiClient) getSearch(searchTerm string, slug string, gameID int, classID int, categoryID int, gameVersion string, modloaderType int) ([]modInfo, error) { var infoRes struct { Data []modInfo `json:"data"` } q := url.Values{} - q.Set("gameId", "432") // Minecraft + q.Set("gameId", strconv.Itoa(gameID)) q.Set("pageSize", "10") - q.Set("classId", "6") // Mods - q.Set("searchFilter", searchText) - if len(gameVersion) > 0 { - q.Set("gameVersion", gameVersion) + if classID != 0 { + q.Set("classId", strconv.Itoa(classID)) } - if modloaderType != modloaderTypeAny { - q.Set("modLoaderType", strconv.Itoa(modloaderType)) + if slug != "" { + q.Set("slug", slug) + } + // If classID and slug are provided, don't bother filtering by anything else (should be unique) + if classID == 0 && slug == "" { + if categoryID != 0 { + q.Set("categoryId", strconv.Itoa(categoryID)) + } + if searchTerm != "" { + q.Set("searchFilter", searchTerm) + } + if gameVersion != "" { + q.Set("gameVersion", gameVersion) + } + if modloaderType != modloaderTypeAny { + q.Set("modLoaderType", strconv.Itoa(modloaderType)) + } } resp, err := c.makeGet("/v1/mods/search?" + q.Encode()) @@ -400,7 +336,74 @@ func (c *cfApiClient) getSearch(searchText string, gameVersion string, modloader err = json.NewDecoder(resp.Body).Decode(&infoRes) if err != nil && err != io.EOF { - return []modInfo{}, err + return []modInfo{}, fmt.Errorf("failed to parse search results: %w", err) + } + + return infoRes.Data, nil +} + +//noinspection GoUnusedConst +const ( + gameStatusDraft int = iota + 1 + gameStatusTest + gameStatusPendingReview + gameStatusRejected + gameStatusApproved + gameStatusLive +) + +//noinspection GoUnusedConst +const ( + gameApiStatusPrivate int = iota + 1 + gameApiStatusPublic +) + +type cfGame struct { + ID uint32 `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Status int `json:"status"` + APIStatus int `json:"apiStatus"` +} + +func (c *cfApiClient) getGames() ([]cfGame, error) { + var infoRes struct { + Data []cfGame `json:"data"` + } + + resp, err := c.makeGet("/v1/games") + if err != nil { + return []cfGame{}, fmt.Errorf("failed to retrieve game list: %w", err) + } + + err = json.NewDecoder(resp.Body).Decode(&infoRes) + if err != nil && err != io.EOF { + return []cfGame{}, fmt.Errorf("failed to parse game list: %w", err) + } + + return infoRes.Data, nil +} + +type cfCategory struct { + ID int `json:"id"` + Slug string `json:"slug"` + IsClass bool `json:"isClass"` + ClassID int `json:"classId"` +} + +func (c *cfApiClient) getCategories(gameID int) ([]cfCategory, error) { + var infoRes struct { + Data []cfCategory `json:"data"` + } + + resp, err := c.makeGet("/v1/categories?gameId=" + strconv.Itoa(gameID)) + if err != nil { + return []cfCategory{}, fmt.Errorf("failed to retrieve category list for game %v: %w", gameID, err) + } + + err = json.NewDecoder(resp.Body).Decode(&infoRes) + if err != nil && err != io.EOF { + return []cfCategory{}, fmt.Errorf("failed to parse category list for game %v: %w", gameID, err) } return infoRes.Data, nil