packwiz/curseforge/curseforge.go
comp500 d73c7e809b 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
2022-05-16 17:09:28 +01:00

430 lines
11 KiB
Go

package curseforge
import (
"errors"
"github.com/spf13/viper"
"golang.org/x/exp/slices"
"regexp"
"strconv"
"strings"
"github.com/mitchellh/mapstructure"
"github.com/packwiz/packwiz/cmd"
"github.com/packwiz/packwiz/core"
"github.com/spf13/cobra"
)
var curseforgeCmd = &cobra.Command{
Use: "curseforge",
Aliases: []string{"cf", "curse"},
Short: "Manage curseforge-based mods",
}
func init() {
cmd.Add(curseforgeCmd)
core.Updaters["curseforge"] = cfUpdater{}
}
var snapshotVersionRegex = regexp.MustCompile("(?:Snapshot )?(\\d+)w0?(0|[1-9]\\d*)([a-z])")
var snapshotNames = [...]string{"-pre", " Pre-Release ", " Pre-release ", "-rc"}
func getCurseforgeVersion(mcVersion string) string {
for _, name := range snapshotNames {
index := strings.Index(mcVersion, name)
if index > -1 {
return mcVersion[:index] + "-Snapshot"
}
}
matches := snapshotVersionRegex.FindStringSubmatch(mcVersion)
if matches == nil {
return mcVersion
}
year, err := strconv.Atoi(matches[1])
if err != nil {
return mcVersion
}
week, err := strconv.Atoi(matches[2])
if err != nil {
return mcVersion
}
if year >= 22 && week >= 11 {
return "1.19-Snapshot"
} else if year == 21 && week >= 37 || year >= 22 {
return "1.18-Snapshot"
} else if year == 20 && week >= 45 || year == 21 && week <= 20 {
return "1.17-Snapshot"
} else if year == 20 && week >= 6 {
return "1.16-Snapshot"
} else if year == 19 && week >= 34 {
return "1.15-Snapshot"
} else if year == 18 && week >= 43 || year == 19 && week <= 14 {
return "1.14-Snapshot"
} else if year == 18 && week >= 30 && week <= 33 {
return "1.13.1-Snapshot"
} else if year == 17 && week >= 43 || year == 18 && week <= 22 {
return "1.13-Snapshot"
} else if year == 17 && week == 31 {
return "1.12.1-Snapshot"
} else if year == 17 && week >= 6 && week <= 18 {
return "1.12-Snapshot"
} else if year == 16 && week == 50 {
return "1.11.1-Snapshot"
} else if year == 16 && week >= 32 && week <= 44 {
return "1.11-Snapshot"
} else if year == 16 && week >= 20 && week <= 21 {
return "1.10-Snapshot"
} else if year == 16 && week >= 14 && week <= 15 {
return "1.9.3-Snapshot"
} else if year == 15 && week >= 31 || year == 16 && week <= 7 {
return "1.9-Snapshot"
} else if year == 14 && week >= 2 && week <= 34 {
return "1.8-Snapshot"
} else if year == 13 && week >= 47 && week <= 49 {
return "1.7.4-Snapshot"
} else if year == 13 && week >= 36 && week <= 43 {
return "1.7.2-Snapshot"
} else if year == 13 && week >= 16 && week <= 26 {
return "1.6-Snapshot"
} else if year == 13 && week >= 11 && week <= 12 {
return "1.5.1-Snapshot"
} else if year == 13 && week >= 1 && week <= 10 {
return "1.5-Snapshot"
} else if year == 12 && week >= 49 && week <= 50 {
return "1.4.6-Snapshot"
} else if year == 12 && week >= 32 && week <= 42 {
return "1.4.2-Snapshot"
} else if year == 12 && week >= 15 && week <= 30 {
return "1.3.1-Snapshot"
} else if year == 12 && week >= 3 && week <= 8 {
return "1.2.1-Snapshot"
} else if year == 11 && week >= 47 || year == 12 && week <= 1 {
return "1.1-Snapshot"
}
return mcVersion
}
var urlRegexes = [...]*regexp.Regexp{
regexp.MustCompile("^https?://(?P<game>minecraft)\\.curseforge\\.com/projects/(?P<slug>[^/]+)(?:/(?:files|download)/(?P<fileID>\\d+))?"),
regexp.MustCompile("^https?://(?:www\\.)?curseforge\\.com/(?P<game>[^/]+)/(?P<category>[^/]+)/(?P<slug>[^/]+)(?:/(?:files|download)/(?P<fileID>\\d+))?"),
regexp.MustCompile("^(?P<slug>[a-z][\\da-z\\-_]{0,127})$"),
}
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 {
if i := r.SubexpIndex("game"); i >= 0 {
game = matches[i]
}
if i := r.SubexpIndex("category"); i >= 0 {
category = matches[i]
}
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
}
// 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 {
updateMap := make(map[string]map[string]interface{})
var err error
updateMap["curseforge"], err = cfUpdateData{
ProjectID: modInfo.ID,
FileID: fileInfo.ID,
}.ToMap()
if err != nil {
return err
}
hash, hashFormat := fileInfo.getBestHash()
var optional *core.ModOption
if optionalDisabled {
optional = &core.ModOption{
Optional: true,
Default: false,
}
}
modMeta := core.Mod{
Name: modInfo.Name,
FileName: fileInfo.FileName,
Side: core.UniversalSide,
Download: core.ModDownload{
HashFormat: hashFormat,
Hash: hash,
},
Option: optional,
Update: updateMap,
}
path := modMeta.SetMetaName(modInfo.Slug, *index)
// 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
}
return index.RefreshFileWithHash(path, format, hash, true)
}
func getLoader(pack core.Pack) int {
dependencies := pack.Versions
_, hasFabric := dependencies["fabric"]
_, hasQuilt := dependencies["quilt"]
_, hasForge := dependencies["forge"]
if (hasFabric || hasQuilt) && hasForge {
return modloaderTypeAny
} else if hasFabric || hasQuilt { // Backwards-compatible; for now (could be configurable later)
return modloaderTypeFabric
} else if hasForge {
return modloaderTypeForge
} else {
return modloaderTypeAny
}
}
func matchLoaderType(packLoaderType int, modLoaderType int) bool {
if packLoaderType == modloaderTypeAny || modLoaderType == modloaderTypeAny {
return true
} else {
return packLoaderType == modLoaderType
}
}
func matchLoaderTypeFileInfo(packLoaderType int, fileInfoData modFileInfo) bool {
if packLoaderType == modloaderTypeAny {
return true
} else {
containsLoader := false
for i, name := range modloaderNames {
if slices.Contains(fileInfoData.GameVersions, name) {
containsLoader = true
if i == packLoaderType {
return true
}
}
}
// If a file doesn't contain any loaders, it matches all!
return !containsLoader
}
}
func matchGameVersion(mcVersion string, modMcVersion string) bool {
if getCurseforgeVersion(mcVersion) == modMcVersion {
return true
} else {
for _, v := range viper.GetStringSlice("acceptable-game-versions") {
if getCurseforgeVersion(v) == modMcVersion {
return true
}
}
return false
}
}
func matchGameVersions(mcVersion string, modMcVersions []string) bool {
for _, modMcVersion := range modMcVersions {
if getCurseforgeVersion(mcVersion) == modMcVersion {
return true
} else {
for _, v := range viper.GetStringSlice("acceptable-game-versions") {
if getCurseforgeVersion(v) == modMcVersion {
return true
}
}
}
}
return false
}
type cfUpdateData struct {
ProjectID int `mapstructure:"project-id"`
FileID int `mapstructure:"file-id"`
}
func (u cfUpdateData) ToMap() (map[string]interface{}, error) {
newMap := make(map[string]interface{})
err := mapstructure.Decode(u, &newMap)
return newMap, err
}
type cfUpdater struct{}
func (u cfUpdater) ParseUpdate(updateUnparsed map[string]interface{}) (interface{}, error) {
var updateData cfUpdateData
err := mapstructure.Decode(updateUnparsed, &updateData)
return updateData, err
}
type cachedStateStore struct {
modInfo
hasFileInfo bool
fileID int
fileInfo modFileInfo
}
func (u cfUpdater) CheckUpdate(mods []core.Mod, mcVersion string, pack core.Pack) ([]core.UpdateCheck, error) {
results := make([]core.UpdateCheck, len(mods))
modIDs := make([]int, len(mods))
modInfos := make([]modInfo, len(mods))
for i, v := range mods {
projectRaw, ok := v.GetParsedUpdateData("curseforge")
if !ok {
results[i] = core.UpdateCheck{Error: errors.New("couldn't parse mod data")}
continue
}
project := projectRaw.(cfUpdateData)
modIDs[i] = project.ProjectID
}
modInfosUnsorted, err := cfDefaultClient.getModInfoMultiple(modIDs)
if err != nil {
return nil, err
}
for _, v := range modInfosUnsorted {
for i, id := range modIDs {
if id == v.ID {
modInfos[i] = v
break
}
}
}
packLoaderType := getLoader(pack)
for i, v := range mods {
projectRaw, ok := v.GetParsedUpdateData("curseforge")
if !ok {
results[i] = core.UpdateCheck{Error: errors.New("couldn't parse mod data")}
continue
}
project := projectRaw.(cfUpdateData)
updateAvailable := false
fileID := project.FileID
fileInfoObtained := false
var fileInfoData modFileInfo
var fileName string
// For snapshots, curseforge doesn't put them in GameVersionLatestFiles
for _, v := range modInfos[i].LatestFiles {
// Choose "newest" version by largest ID
if matchGameVersions(mcVersion, v.GameVersions) && v.ID > fileID && matchLoaderTypeFileInfo(packLoaderType, v) {
updateAvailable = true
fileID = v.ID
fileInfoData = v
fileInfoObtained = true
fileName = v.FileName
}
}
for _, file := range modInfos[i].GameVersionLatestFiles {
// TODO: change to timestamp-based comparison??
// TODO: manage alpha/beta/release correctly, check update channel?
// Choose "newest" version by largest ID
if matchGameVersion(mcVersion, file.GameVersion) && file.ID > fileID && matchLoaderType(packLoaderType, file.Modloader) {
updateAvailable = true
fileID = file.ID
fileName = file.Name
fileInfoObtained = false // Make sure we get the file info again
}
}
if !updateAvailable {
results[i] = core.UpdateCheck{UpdateAvailable: false}
continue
}
// The API also provides some files inline, because that's efficient!
if !fileInfoObtained {
for _, file := range modInfos[i].LatestFiles {
if file.ID == fileID {
fileInfoObtained = true
fileInfoData = file
}
}
}
results[i] = core.UpdateCheck{
UpdateAvailable: true,
UpdateString: v.FileName + " -> " + fileName,
CachedState: cachedStateStore{modInfos[i], fileInfoObtained, fileID, fileInfoData},
}
}
return results, nil
}
func (u cfUpdater) DoUpdate(mods []*core.Mod, cachedState []interface{}) error {
// "Do" isn't really that accurate, more like "Apply", because all the work is done in CheckUpdate!
for i, v := range mods {
modState := cachedState[i].(cachedStateStore)
fileInfoData := modState.fileInfo
if !modState.hasFileInfo {
var err error
fileInfoData, err = cfDefaultClient.getFileInfo(modState.ID, modState.fileID)
if err != nil {
return err
}
}
v.FileName = fileInfoData.FileName
v.Name = modState.Name
hash, hashFormat := fileInfoData.getBestHash()
v.Download = core.ModDownload{
HashFormat: hashFormat,
Hash: hash,
}
v.Update["curseforge"]["project-id"] = modState.ID
v.Update["curseforge"]["file-id"] = fileInfoData.ID
}
return nil
}
type cfExportData struct {
ProjectID int `mapstructure:"project-id"`
}
func (e cfExportData) ToMap() (map[string]interface{}, error) {
newMap := make(map[string]interface{})
err := mapstructure.Decode(e, &newMap)
return newMap, err
}
func parseExportData(from map[string]interface{}) (cfExportData, error) {
var exportData cfExportData
err := mapstructure.Decode(from, &exportData)
return exportData, err
}