mirror of
https://github.com/packwiz/packwiz.git
synced 2025-04-19 21:16:30 +02:00
327 lines
8.2 KiB
Go
327 lines
8.2 KiB
Go
package curseforge
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/agnivade/levenshtein"
|
|
"github.com/comp500/packwiz/core"
|
|
"github.com/mitchellh/mapstructure"
|
|
"github.com/urfave/cli"
|
|
"gopkg.in/dixonwille/wmenu.v4"
|
|
)
|
|
|
|
func init() {
|
|
core.Commands = append(core.Commands, cli.Command{
|
|
Name: "curseforge",
|
|
Usage: "Manage curseforge-based mods",
|
|
Subcommands: []cli.Command{{
|
|
Name: "install",
|
|
Usage: "Install a mod from a curseforge URL, slug or ID",
|
|
Aliases: []string{"add", "get"},
|
|
Action: func(c *cli.Context) error {
|
|
return cmdInstall(core.FlagsFromContext(c), c.Args().Get(0), c.Args().Tail())
|
|
},
|
|
}, {
|
|
Name: "import",
|
|
Usage: "Import an installed curseforge modpack",
|
|
Action: func(c *cli.Context) error {
|
|
return cmdImport(core.FlagsFromContext(c), c.Args().Get(0))
|
|
},
|
|
}},
|
|
})
|
|
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 createModFile(flags core.Flags, modInfo modInfo, fileInfo modFileInfo, index *core.Index) error {
|
|
updateMap := make(map[string]map[string]interface{})
|
|
var err error
|
|
|
|
updateMap["curseforge"], err = cfUpdater{
|
|
ProjectID: modInfo.ID,
|
|
FileID: fileInfo.ID,
|
|
// TODO: determine update channel
|
|
ReleaseChannel: "beta",
|
|
}.ToMap()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
modMeta := core.Mod{
|
|
Name: modInfo.Name,
|
|
FileName: fileInfo.FileName,
|
|
Side: core.UniversalSide,
|
|
Download: core.ModDownload{
|
|
URL: fileInfo.DownloadURL,
|
|
// TODO: murmur2 hashing may be unstable in curse api, calculate the hash manually?
|
|
// TODO: check if the hash is invalid (e.g. 0)
|
|
HashFormat: "murmur2",
|
|
Hash: strconv.Itoa(fileInfo.Fingerprint),
|
|
},
|
|
Update: updateMap,
|
|
}
|
|
path := modMeta.SetMetaName(modInfo.Slug, flags)
|
|
|
|
// 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
|
|
}
|
|
|
|
// TODO: send written data directly to index, instead of write+read?
|
|
return index.RefreshFileWithHash(path, format, hash, true)
|
|
}
|
|
|
|
func cmdInstall(flags core.Flags, mod string, modArgsTail []string) error {
|
|
if len(mod) == 0 {
|
|
return cli.NewExitError("You must specify a mod.", 1)
|
|
}
|
|
pack, err := core.LoadPack(flags)
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
index, err := pack.LoadIndex()
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
mcVersion, err := pack.GetMCVersion()
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
|
|
var done bool
|
|
var modID, fileID int
|
|
// If modArgsTail has anything, go straight to searching - URLs/Slugs should not have spaces!
|
|
if len(modArgsTail) == 0 {
|
|
done, modID, fileID, err = getFileIDsFromString(mod)
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
|
|
if !done {
|
|
done, modID, err = getModIDFromString(mod)
|
|
// Ignore error, go to search instead (e.g. lowercase to search instead of as a slug)
|
|
if err != nil {
|
|
done = false
|
|
}
|
|
}
|
|
}
|
|
|
|
modInfoObtained := false
|
|
var modInfoData modInfo
|
|
|
|
if !done {
|
|
fmt.Println("Searching CurseForge...")
|
|
modArgs := append([]string{mod}, modArgsTail...)
|
|
searchTerm := strings.Join(modArgs, " ")
|
|
// TODO: Curse search
|
|
// TODO: how to do interactive choices? automatically assume version? ask mod from list? choose first?
|
|
results, err := getSearch(searchTerm, mcVersion)
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
|
|
if len(results) == 0 {
|
|
return cli.NewExitError(errors.New("no mods found"), 1)
|
|
} else if len(results) == 1 {
|
|
modInfoData = results[0]
|
|
modID = modInfoData.ID
|
|
modInfoObtained = true
|
|
done = true
|
|
} else {
|
|
// Find the closest value to the search term
|
|
sort.Slice(results, func(i, j int) bool {
|
|
return levenshtein.ComputeDistance(searchTerm, results[i].Name) < levenshtein.ComputeDistance(searchTerm, results[j].Name)
|
|
})
|
|
|
|
menu := wmenu.NewMenu("Choose a number:")
|
|
|
|
for i, v := range results {
|
|
menu.Option(v.Name, 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 {
|
|
fmt.Println("Cancelled!")
|
|
return nil
|
|
}
|
|
|
|
// Why is variable shadowing a thing!!!!
|
|
var ok bool
|
|
modInfoData, ok = menuRes[0].Value.(modInfo)
|
|
if !ok {
|
|
return errors.New("Error converting interface from wmenu")
|
|
}
|
|
modID = modInfoData.ID
|
|
modInfoObtained = true
|
|
done = true
|
|
return nil
|
|
})
|
|
err = menu.Run()
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
|
|
if !done {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
if !done {
|
|
if err == nil {
|
|
err = errors.New("Mod not found")
|
|
}
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
|
|
if !modInfoObtained {
|
|
modInfoData, err = getModInfo(modID)
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
}
|
|
|
|
fileInfoObtained := false
|
|
var fileInfoData modFileInfo
|
|
if fileID == 0 {
|
|
// TODO: how do we decide which version to use?
|
|
for _, v := range modInfoData.GameVersionLatestFiles {
|
|
// Choose "newest" version by largest ID
|
|
if v.GameVersion == mcVersion && v.ID > fileID {
|
|
fileID = v.ID
|
|
}
|
|
}
|
|
|
|
if fileID == 0 {
|
|
return cli.NewExitError(errors.New("no files available for current Minecraft version"), 1)
|
|
}
|
|
|
|
// The API also provides some files inline, because that's efficient!
|
|
for _, v := range modInfoData.LatestFiles {
|
|
if v.ID == fileID {
|
|
fileInfoObtained = true
|
|
fileInfoData = v
|
|
}
|
|
}
|
|
}
|
|
|
|
if !fileInfoObtained {
|
|
fileInfoData, err = getFileInfo(modID, fileID)
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
}
|
|
|
|
err = createModFile(flags, modInfoData, fileInfoData, &index)
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
|
|
err = index.Write()
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
err = pack.UpdateIndexHash()
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
err = pack.Write()
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
|
|
fmt.Printf("Mod \"%s\" successfully installed! (%s)\n", modInfoData.Name, fileInfoData.FileName)
|
|
|
|
return nil
|
|
}
|
|
|
|
type cfUpdateParser struct{}
|
|
|
|
func (u cfUpdateParser) ParseUpdate(updateUnparsed map[string]interface{}) (core.Updater, error) {
|
|
var updater cfUpdater
|
|
err := mapstructure.Decode(updateUnparsed, &updater)
|
|
return updater, err
|
|
}
|
|
|
|
type cfUpdater struct {
|
|
ProjectID int `mapstructure:"project-id"`
|
|
FileID int `mapstructure:"file-id"`
|
|
ReleaseChannel string `mapstructure:"release-channel"`
|
|
}
|
|
|
|
func (u cfUpdater) DoUpdate(mod core.Mod) (bool, error) {
|
|
// TODO: implement updating
|
|
return false, nil
|
|
}
|
|
|
|
func (u cfUpdater) ToMap() (map[string]interface{}, error) {
|
|
newMap := make(map[string]interface{})
|
|
err := mapstructure.Decode(u, &newMap)
|
|
return newMap, err
|
|
}
|