Reworked install command to use new slug lookup API, and support any game/category

New --category and --game flags allow using categories other than Minecraft mods (also parsed from URLs)
Fixed loader checks to allow a project with no loaders in the version list
Improved error messages and docs
Fixed sending empty mod requests when dependencies were already installed
Slug lookup now defaults to no category, forcing a user to interactively select a project (--category should guarantee no interactivity)
Added project summaries to search results
Fixes #112
This commit is contained in:
comp500 2022-05-16 17:09:28 +01:00
parent 640d4ac046
commit d73c7e809b
3 changed files with 256 additions and 196 deletions

View File

@ -3,6 +3,7 @@ package curseforge
import ( import (
"errors" "errors"
"github.com/spf13/viper" "github.com/spf13/viper"
"golang.org/x/exp/slices"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@ -24,12 +25,6 @@ func init() {
core.Updaters["curseforge"] = cfUpdater{} 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 snapshotVersionRegex = regexp.MustCompile("(?:Snapshot )?(\\d+)w0?(0|[1-9]\\d*)([a-z])")
var snapshotNames = [...]string{"-pre", " Pre-Release ", " Pre-release ", "-rc"} var snapshotNames = [...]string{"-pre", " Pre-Release ", " Pre-release ", "-rc"}
@ -111,57 +106,44 @@ func getCurseforgeVersion(mcVersion string) string {
return mcVersion return mcVersion
} }
func getFileIDsFromString(mod string) (bool, int, int, error) { var urlRegexes = [...]*regexp.Regexp{
for _, v := range fileIDRegexes { regexp.MustCompile("^https?://(?P<game>minecraft)\\.curseforge\\.com/projects/(?P<slug>[^/]+)(?:/(?:files|download)/(?P<fileID>\\d+))?"),
matches := v.FindStringSubmatch(mod) regexp.MustCompile("^https?://(?:www\\.)?curseforge\\.com/(?P<game>[^/]+)/(?P<category>[^/]+)/(?P<slug>[^/]+)(?:/(?:files|download)/(?P<fileID>\\d+))?"),
if matches != nil && len(matches) == 3 { regexp.MustCompile("^(?P<slug>[a-z][\\da-z\\-_]{0,127})$"),
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 modSlugRegexes = [...]*regexp.Regexp{ func parseSlugOrUrl(url string) (game string, category string, slug string, fileID int, err error) {
regexp.MustCompile("^https?://minecraft\\.curseforge\\.com/projects/([^/]+)"), for _, r := range urlRegexes {
regexp.MustCompile("^https?://(?:www\\.)?curseforge\\.com/minecraft/mc-mods/([^/]+)"), matches := r.FindStringSubmatch(url)
// 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 { if matches != nil {
var slug string if i := r.SubexpIndex("game"); i >= 0 {
if len(matches) == 2 { game = matches[i]
slug = matches[1]
} else if len(matches) == 1 {
slug = matches[0]
} else {
continue
} }
modID, err := modIDFromSlug(slug) if i := r.SubexpIndex("category"); i >= 0 {
if err != nil { category = matches[i]
return true, 0, err
} }
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 false, 0, nil return
}
}
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 { 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 { if packLoaderType == modloaderTypeAny {
return true return true
} else { } else {
if packLoaderType == modloaderTypeFabric { containsLoader := false
for _, v := range fileInfoData.GameVersions { for i, name := range modloaderNames {
if v == "Fabric" { if slices.Contains(fileInfoData.GameVersions, name) {
containsLoader = true
if i == packLoaderType {
return true return true
} }
} }
} else if packLoaderType == modloaderTypeForge {
for _, v := range fileInfoData.GameVersions {
if v == "Forge" {
return true
} }
} // If a file doesn't contain any loaders, it matches all!
} else { return !containsLoader
return true
}
return false
} }
} }

View File

@ -23,8 +23,8 @@ type installableDep struct {
// installCmd represents the install command // installCmd represents the install command
var installCmd = &cobra.Command{ var installCmd = &cobra.Command{
Use: "install [mod]", Use: "install [URL|slug|search]",
Short: "Install a mod from a curseforge URL, slug, ID or search", Short: "Install a project from a CurseForge URL, slug, ID or search",
Aliases: []string{"add", "get"}, Aliases: []string{"add", "get"},
Args: cobra.ArbitraryArgs, Args: cobra.ArbitraryArgs,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
@ -44,64 +44,71 @@ var installCmd = &cobra.Command{
os.Exit(1) os.Exit(1)
} }
var done bool game := gameFlag
category := categoryFlag
var modID, fileID int var modID, fileID int
var slug string
// If mod/file IDs are provided in command line, use those // If mod/file IDs are provided in command line, use those
if fileIDFlag != 0 { if fileIDFlag != 0 {
fileID = fileIDFlag fileID = fileIDFlag
} }
if addonIDFlag != 0 { if addonIDFlag != 0 {
modID = addonIDFlag modID = addonIDFlag
done = true
} }
if (len(args) == 0 || len(args[0]) == 0) && !done { if (len(args) == 0 || len(args[0]) == 0) && modID == 0 {
fmt.Println("You must specify a mod.") fmt.Println("You must specify a project; with the ID flags, or by passing a URL, slug or search term directly.")
os.Exit(1) os.Exit(1)
} }
// If there are more than 1 argument, go straight to searching - URLs/Slugs should not have spaces! // If there are more than 1 argument, go straight to searching - URLs/Slugs should not have spaces!
if !done && len(args) == 1 { if modID == 0 && len(args) == 1 {
done, modID, fileID, err = getFileIDsFromString(args[0]) parsedGame, parsedCategory, parsedSlug, parsedFileID, err := parseSlugOrUrl(args[0])
if err != nil { if err != nil {
fmt.Println(err) fmt.Printf("Failed to parse URL: %v\n", err)
os.Exit(1) os.Exit(1)
} }
if !done { if parsedGame != "" {
done, modID, err = getModIDFromString(args[0]) game = parsedGame
// Ignore error, go to search instead (e.g. lowercase to search instead of as a slug)
if err != nil {
done = false
} }
if parsedCategory != "" {
category = parsedCategory
}
if parsedSlug != "" {
slug = parsedSlug
}
if parsedFileID != 0 {
fileID = parsedFileID
} }
} }
modInfoObtained := false modInfoObtained := false
var modInfoData modInfo var modInfoData modInfo
if !done { if modID == 0 {
var cancelled bool 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 { if cancelled {
return return
} }
done = true
modID = modInfoData.ID modID = modInfoData.ID
modInfoObtained = true modInfoObtained = true
} }
if !done { if modID == 0 {
if err == nil { fmt.Println("No projects found!")
fmt.Println("No mods found!")
os.Exit(1)
}
fmt.Println(err)
os.Exit(1) os.Exit(1)
} }
if !modInfoObtained { if !modInfoObtained {
modInfoData, err = cfDefaultClient.getModInfo(modID) modInfoData, err = cfDefaultClient.getModInfo(modID)
if err != nil { if err != nil {
fmt.Println(err) fmt.Printf("Failed to get project info: %v\n", err)
os.Exit(1) os.Exit(1)
} }
} }
@ -109,7 +116,7 @@ var installCmd = &cobra.Command{
var fileInfoData modFileInfo var fileInfoData modFileInfo
fileInfoData, err = getLatestFile(modInfoData, mcVersion, fileID, getLoader(pack)) fileInfoData, err = getLatestFile(modInfoData, mcVersion, fileID, getLoader(pack))
if err != nil { if err != nil {
fmt.Println(err) fmt.Printf("Failed to get file for project: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@ -169,6 +176,10 @@ var installCmd = &cobra.Command{
} }
depIDPendingQueue = depIDPendingQueue[:i] depIDPendingQueue = depIDPendingQueue[:i]
if len(depIDPendingQueue) == 0 {
break
}
depInfoData, err := cfDefaultClient.getModInfoMultiple(depIDPendingQueue) depInfoData, err := cfDefaultClient.getModInfoMultiple(depIDPendingQueue)
if err != nil { if err != nil {
fmt.Printf("Error retrieving dependency data: %s\n", err.Error()) fmt.Printf("Error retrieving dependency data: %s\n", err.Error())
@ -221,11 +232,11 @@ var installCmd = &cobra.Command{
fmt.Println(err) fmt.Println(err)
os.Exit(1) 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 { } 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) 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) return len(r)
} }
func searchCurseforgeInternal(args []string, mcVersion string, packLoaderType int) (bool, modInfo) { 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...") fmt.Println("Searching CurseForge...")
searchTerm := strings.Join(args, " ") }
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) // 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) filterGameVersion := getCurseforgeVersion(mcVersion)
if len(viper.GetStringSlice("acceptable-game-versions")) > 0 { if len(viper.GetStringSlice("acceptable-game-versions")) > 0 {
filterGameVersion = "" 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 { if err != nil {
fmt.Println(err) fmt.Printf("Failed to search for project: %v\n", err)
os.Exit(1) os.Exit(1)
} }
if len(results) == 0 { if len(results) == 0 {
fmt.Println("No mods found!") fmt.Println("No projects found!")
os.Exit(1) os.Exit(1)
return false, modInfo{} return false, modInfo{}
} else if len(results) == 1 { } else if len(results) == 1 {
@ -296,11 +371,11 @@ func searchCurseforgeInternal(args []string, mcVersion string, packLoaderType in
menu.Option("Cancel", nil, false, nil) menu.Option("Cancel", nil, false, nil)
if len(fuzzySearchResults) == 0 { if len(fuzzySearchResults) == 0 {
for i, v := range results { for i, v := range results {
menu.Option(v.Name, v, i == 0, nil) menu.Option(v.Name+" ("+v.Summary+")", v, i == 0, nil)
} }
} else { } else {
for i, v := range fuzzySearchResults { 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 addonIDFlag int
var fileIDFlag int var fileIDFlag int
var gameFlag string
var categoryFlag string
func init() { func init() {
curseforgeCmd.AddCommand(installCmd) curseforgeCmd.AddCommand(installCmd)
installCmd.Flags().IntVar(&addonIDFlag, "addon-id", 0, "The curseforge addon 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().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")
} }

View File

@ -4,7 +4,6 @@ import (
"bytes" "bytes"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -88,91 +87,6 @@ func (c *cfApiClient) makePost(endpoint string, body io.Reader) (*http.Response,
return resp, nil 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 //noinspection GoUnusedConst
const ( const (
fileTypeRelease int = iota + 1 fileTypeRelease int = iota + 1
@ -200,6 +114,14 @@ const (
modloaderTypeFabric modloaderTypeFabric
) )
var modloaderNames = [...]string{
"",
"Forge",
"Cauldron",
"Liteloader",
"Fabric",
}
//noinspection GoUnusedConst //noinspection GoUnusedConst
const ( const (
hashAlgoSHA1 int = iota + 1 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) // modInfo is a subset of the deserialised JSON response from the Curse API for mods (addons)
type modInfo struct { type modInfo struct {
Name string `json:"name"` Name string `json:"name"`
Summary string `json:"summary"`
Slug string `json:"slug"` Slug string `json:"slug"`
ID int `json:"id"` ID int `json:"id"`
LatestFiles []modFileInfo `json:"latestFiles"` LatestFiles []modFileInfo `json:"latestFiles"`
@ -376,22 +299,35 @@ func (c *cfApiClient) getFileInfoMultiple(fileIDs []int) ([]modFileInfo, error)
return infoRes.Data, nil 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 { var infoRes struct {
Data []modInfo `json:"data"` Data []modInfo `json:"data"`
} }
q := url.Values{} q := url.Values{}
q.Set("gameId", "432") // Minecraft q.Set("gameId", strconv.Itoa(gameID))
q.Set("pageSize", "10") q.Set("pageSize", "10")
q.Set("classId", "6") // Mods if classID != 0 {
q.Set("searchFilter", searchText) q.Set("classId", strconv.Itoa(classID))
if len(gameVersion) > 0 { }
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) q.Set("gameVersion", gameVersion)
} }
if modloaderType != modloaderTypeAny { if modloaderType != modloaderTypeAny {
q.Set("modLoaderType", strconv.Itoa(modloaderType)) q.Set("modLoaderType", strconv.Itoa(modloaderType))
} }
}
resp, err := c.makeGet("/v1/mods/search?" + q.Encode()) resp, err := c.makeGet("/v1/mods/search?" + q.Encode())
if err != nil { if err != nil {
@ -400,7 +336,74 @@ func (c *cfApiClient) getSearch(searchText string, gameVersion string, modloader
err = json.NewDecoder(resp.Body).Decode(&infoRes) err = json.NewDecoder(resp.Body).Decode(&infoRes)
if err != nil && err != io.EOF { 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 return infoRes.Data, nil