mirror of
https://github.com/packwiz/packwiz.git
synced 2025-04-19 21:16:30 +02:00
The acceptable versions list should now be specified in order of preference, where the last version is the most preferable Minecraft version
458 lines
13 KiB
Go
458 lines
13 KiB
Go
package modrinth
|
|
|
|
import (
|
|
modrinthApi "codeberg.org/jmansfield/go-modrinth/modrinth"
|
|
"errors"
|
|
"fmt"
|
|
"github.com/packwiz/packwiz/cmdshared"
|
|
"github.com/spf13/viper"
|
|
"golang.org/x/exp/slices"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/packwiz/packwiz/core"
|
|
"github.com/spf13/cobra"
|
|
"gopkg.in/dixonwille/wmenu.v4"
|
|
)
|
|
|
|
// installCmd represents the install command
|
|
var installCmd = &cobra.Command{
|
|
Use: "add [URL|slug|search]",
|
|
Short: "Add a project from a Modrinth URL, slug/project ID or search",
|
|
Aliases: []string{"install", "get"},
|
|
Args: cobra.ArbitraryArgs,
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
pack, err := core.LoadPack()
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
index, err := pack.LoadIndex()
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// If project/version IDs/version file name is provided in command line, use those
|
|
var projectID, versionID, versionFilename string
|
|
if projectIDFlag != "" {
|
|
projectID = projectIDFlag
|
|
if len(args) != 0 {
|
|
fmt.Println("--project-id cannot be used with a separately specified URL/slug/search term")
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
if versionIDFlag != "" {
|
|
versionID = versionIDFlag
|
|
if len(args) != 0 {
|
|
fmt.Println("--version-id cannot be used with a separately specified URL/slug/search term")
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
if versionFilenameFlag != "" {
|
|
versionFilename = versionFilenameFlag
|
|
}
|
|
|
|
if (len(args) == 0 || len(args[0]) == 0) && projectID == "" {
|
|
fmt.Println("You must specify a project; with the ID flags, or by passing a URL, slug or search term directly.")
|
|
os.Exit(1)
|
|
}
|
|
|
|
var version string
|
|
var parsedSlug bool
|
|
if projectID == "" && versionID == "" {
|
|
// Try interpreting the argument as a slug/project ID, or project/version/CDN URL
|
|
parsedSlug, err = parseSlugOrUrl(args[0], &projectID, &version, &versionID, &versionFilename)
|
|
if err != nil {
|
|
fmt.Printf("Failed to parse URL: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// Got version ID; install using this ID
|
|
if versionID != "" {
|
|
err = installVersionById(versionID, versionFilename, pack, &index)
|
|
if err != nil {
|
|
fmt.Printf("Failed to add project: %s\n", err)
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Look up project ID
|
|
if projectID != "" {
|
|
// Modrinth transparently handles slugs/project IDs in their API; we don't have to detect which one it is.
|
|
var project *modrinthApi.Project
|
|
project, err = mrDefaultClient.Projects.Get(projectID)
|
|
if err == nil {
|
|
// We found a project with that id/slug
|
|
if version != "" {
|
|
// Try to look up version number
|
|
versionData, err := resolveVersion(project, version)
|
|
if err != nil {
|
|
fmt.Printf("Failed to add project: %s\n", err)
|
|
os.Exit(1)
|
|
}
|
|
err = installVersion(project, versionData, versionFilename, pack, &index)
|
|
if err != nil {
|
|
fmt.Printf("Failed to add project: %s\n", err)
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
}
|
|
|
|
// No version specified; find latest
|
|
err = installProject(project, versionFilename, pack, &index)
|
|
if err != nil {
|
|
fmt.Printf("Failed to add project: %s\n", err)
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
// Arguments weren't a valid slug/project ID, try to search for it instead (if it was not parsed as a URL)
|
|
if projectID == "" || parsedSlug {
|
|
err = installViaSearch(strings.Join(args, " "), versionFilename, !parsedSlug, pack, &index)
|
|
if err != nil {
|
|
fmt.Printf("Failed to add project: %s\n", err)
|
|
os.Exit(1)
|
|
}
|
|
} else {
|
|
fmt.Printf("Failed to add project: %s\n", err)
|
|
os.Exit(1)
|
|
}
|
|
},
|
|
}
|
|
|
|
func installVersionById(versionId string, versionFilename string, pack core.Pack, index *core.Index) error {
|
|
version, err := mrDefaultClient.Versions.Get(versionId)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fetch version %s: %v", versionId, err)
|
|
}
|
|
|
|
project, err := mrDefaultClient.Projects.Get(*version.ProjectID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fetch project %s: %v", *version.ProjectID, err)
|
|
}
|
|
|
|
return installVersion(project, version, versionFilename, pack, index)
|
|
}
|
|
|
|
func installViaSearch(query string, versionFilename string, autoAcceptFirst bool, pack core.Pack, index *core.Index) error {
|
|
mcVersions, err := pack.GetSupportedMCVersions()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Println("Searching Modrinth...")
|
|
|
|
results, err := getProjectIdsViaSearch(query, mcVersions)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(results) == 0 {
|
|
return errors.New("no projects found")
|
|
}
|
|
|
|
if viper.GetBool("non-interactive") || (len(results) == 1 && autoAcceptFirst) {
|
|
// Install the first project found
|
|
project, err := mrDefaultClient.Projects.Get(*results[0].ProjectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return installProject(project, versionFilename, pack, index)
|
|
}
|
|
|
|
// Create menu for the user to choose the correct project
|
|
menu := wmenu.NewMenu("Choose a number:")
|
|
menu.Option("Cancel", nil, false, nil)
|
|
for i, v := range results {
|
|
// Should be non-nil (Title is a required field)
|
|
menu.Option(*v.Title, v, i == 0, nil)
|
|
}
|
|
|
|
menu.Action(func(menuRes []wmenu.Opt) error {
|
|
if len(menuRes) != 1 || menuRes[0].Value == nil {
|
|
return errors.New("project selection cancelled")
|
|
}
|
|
|
|
// Get the selected project
|
|
selectedProject, ok := menuRes[0].Value.(*modrinthApi.SearchResult)
|
|
if !ok {
|
|
return errors.New("error converting interface from wmenu")
|
|
}
|
|
|
|
// Install the selected project
|
|
project, err := mrDefaultClient.Projects.Get(*selectedProject.ProjectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return installProject(project, versionFilename, pack, index)
|
|
})
|
|
|
|
return menu.Run()
|
|
}
|
|
|
|
func installProject(project *modrinthApi.Project, versionFilename string, pack core.Pack, index *core.Index) error {
|
|
latestVersion, err := getLatestVersion(*project.ID, *project.Title, pack)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get latest version: %v", err)
|
|
}
|
|
if latestVersion.ID == nil {
|
|
return errors.New("mod not available for the configured Minecraft version(s) (use the acceptable-game-versions option to accept more) or loader")
|
|
}
|
|
|
|
return installVersion(project, latestVersion, versionFilename, pack, index)
|
|
}
|
|
|
|
const maxCycles = 20
|
|
|
|
type depMetadataStore struct {
|
|
projectInfo *modrinthApi.Project
|
|
versionInfo *modrinthApi.Version
|
|
fileInfo *modrinthApi.File
|
|
}
|
|
|
|
func installVersion(project *modrinthApi.Project, version *modrinthApi.Version, versionFilename string, pack core.Pack, index *core.Index) error {
|
|
if len(version.Files) == 0 {
|
|
return errors.New("version doesn't have any files attached")
|
|
}
|
|
|
|
if len(version.Dependencies) > 0 {
|
|
// TODO: could get installed version IDs, and compare to install the newest - i.e. preferring pinned versions over getting absolute latest?
|
|
installedProjects := getInstalledProjectIDs(index)
|
|
|
|
var depMetadata []depMetadataStore
|
|
var depProjectIDPendingQueue []string
|
|
var depVersionIDPendingQueue []string
|
|
|
|
for _, dep := range version.Dependencies {
|
|
// TODO: recommend optional dependencies?
|
|
if dep.DependencyType != nil && *dep.DependencyType == "required" {
|
|
if dep.ProjectID != nil {
|
|
depProjectIDPendingQueue = append(depProjectIDPendingQueue, *dep.ProjectID)
|
|
}
|
|
if dep.VersionID != nil {
|
|
depVersionIDPendingQueue = append(depVersionIDPendingQueue, *dep.VersionID)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(depProjectIDPendingQueue)+len(depVersionIDPendingQueue) > 0 {
|
|
fmt.Println("Finding dependencies...")
|
|
|
|
cycles := 0
|
|
for len(depProjectIDPendingQueue)+len(depVersionIDPendingQueue) > 0 && cycles < maxCycles {
|
|
// Look up version IDs
|
|
if len(depVersionIDPendingQueue) > 0 {
|
|
depVersions, err := mrDefaultClient.Versions.GetMultiple(depVersionIDPendingQueue)
|
|
if err == nil {
|
|
for _, v := range depVersions {
|
|
// Add project ID to queue
|
|
depProjectIDPendingQueue = append(depProjectIDPendingQueue, *v.ProjectID)
|
|
}
|
|
} else {
|
|
fmt.Printf("Error retrieving dependency data: %s\n", err.Error())
|
|
}
|
|
depVersionIDPendingQueue = depVersionIDPendingQueue[:0]
|
|
}
|
|
|
|
// Remove installed project IDs from dep queue
|
|
i := 0
|
|
for _, id := range depProjectIDPendingQueue {
|
|
contains := slices.Contains(installedProjects, id)
|
|
for _, dep := range depMetadata {
|
|
if *dep.projectInfo.ID == id {
|
|
contains = true
|
|
break
|
|
}
|
|
}
|
|
if !contains {
|
|
depProjectIDPendingQueue[i] = id
|
|
i++
|
|
}
|
|
}
|
|
depProjectIDPendingQueue = depProjectIDPendingQueue[:i]
|
|
|
|
if len(depProjectIDPendingQueue) == 0 {
|
|
break
|
|
}
|
|
depProjects, err := mrDefaultClient.Projects.GetMultiple(depProjectIDPendingQueue)
|
|
if err != nil {
|
|
fmt.Printf("Error retrieving dependency data: %s\n", err.Error())
|
|
}
|
|
depProjectIDPendingQueue = depProjectIDPendingQueue[:0]
|
|
|
|
for _, project := range depProjects {
|
|
if project.ID == nil {
|
|
return errors.New("failed to get dependency data: invalid response")
|
|
}
|
|
// Get latest version - could reuse version lookup data but it's not as easy (particularly since the version won't necessarily be the latest)
|
|
latestVersion, err := getLatestVersion(*project.ID, *project.Title, pack)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get latest version of dependency %v: %v", *project.ID, err)
|
|
}
|
|
|
|
for _, dep := range version.Dependencies {
|
|
// TODO: recommend optional dependencies?
|
|
if dep.DependencyType != nil && *dep.DependencyType == "required" {
|
|
if dep.ProjectID != nil {
|
|
depProjectIDPendingQueue = append(depProjectIDPendingQueue, *dep.ProjectID)
|
|
}
|
|
if dep.VersionID != nil {
|
|
depVersionIDPendingQueue = append(depVersionIDPendingQueue, *dep.VersionID)
|
|
}
|
|
}
|
|
}
|
|
|
|
var file = latestVersion.Files[0]
|
|
// Prefer the primary file
|
|
for _, v := range latestVersion.Files {
|
|
if *v.Primary {
|
|
file = v
|
|
}
|
|
}
|
|
|
|
depMetadata = append(depMetadata, depMetadataStore{
|
|
projectInfo: project,
|
|
versionInfo: latestVersion,
|
|
fileInfo: file,
|
|
})
|
|
}
|
|
|
|
cycles++
|
|
}
|
|
if cycles >= maxCycles {
|
|
return errors.New("dependencies recurse too deeply, try increasing maxCycles")
|
|
}
|
|
|
|
if len(depMetadata) > 0 {
|
|
fmt.Println("Dependencies found:")
|
|
for _, v := range depMetadata {
|
|
fmt.Println(*v.projectInfo.Title)
|
|
}
|
|
|
|
if cmdshared.PromptYesNo("Would you like to add them? [Y/n]: ") {
|
|
for _, v := range depMetadata {
|
|
err := createFileMeta(v.projectInfo, v.versionInfo, v.fileInfo, pack, index)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("Dependency \"%s\" successfully added! (%s)\n", *v.projectInfo.Title, *v.fileInfo.Filename)
|
|
}
|
|
}
|
|
} else {
|
|
fmt.Println("All dependencies are already added!")
|
|
}
|
|
}
|
|
}
|
|
|
|
var file = version.Files[0]
|
|
// Prefer the primary file
|
|
for _, v := range version.Files {
|
|
if (*v.Primary) || (versionFilename != "" && versionFilename == *v.Filename) {
|
|
file = v
|
|
}
|
|
}
|
|
// TODO: handle optional/required resource pack files
|
|
|
|
// Create the metadata file
|
|
err := createFileMeta(project, version, file, pack, index)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = index.Write()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = pack.UpdateIndexHash()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = pack.Write()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Printf("Project \"%s\" successfully added! (%s)\n", *project.Title, *file.Filename)
|
|
return nil
|
|
}
|
|
|
|
func createFileMeta(project *modrinthApi.Project, version *modrinthApi.Version, file *modrinthApi.File, pack core.Pack, index *core.Index) error {
|
|
updateMap := make(map[string]map[string]interface{})
|
|
|
|
var err error
|
|
updateMap["modrinth"], err = mrUpdateData{
|
|
ProjectID: *project.ID,
|
|
InstalledVersion: *version.ID,
|
|
}.ToMap()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
side := getSide(project)
|
|
if side == "" {
|
|
return errors.New("version doesn't have a side that's supported. Server: " + *project.ServerSide + " Client: " + *project.ClientSide)
|
|
}
|
|
|
|
algorithm, hash := getBestHash(file)
|
|
if algorithm == "" {
|
|
return errors.New("file doesn't have a hash")
|
|
}
|
|
|
|
modMeta := core.Mod{
|
|
Name: *project.Title,
|
|
FileName: *file.Filename,
|
|
Side: side,
|
|
Download: core.ModDownload{
|
|
URL: *file.URL,
|
|
HashFormat: algorithm,
|
|
Hash: hash,
|
|
},
|
|
Update: updateMap,
|
|
}
|
|
var path string
|
|
folder := viper.GetString("meta-folder")
|
|
if folder == "" {
|
|
folder, err = getProjectTypeFolder(*project.ProjectType, version.Loaders, pack.GetLoaders())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if project.Slug != nil {
|
|
path = modMeta.SetMetaPath(filepath.Join(viper.GetString("meta-folder-base"), folder, *project.Slug+core.MetaExtension))
|
|
} else {
|
|
path = modMeta.SetMetaPath(filepath.Join(viper.GetString("meta-folder-base"), folder, core.SlugifyName(*project.Title)+core.MetaExtension))
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
var projectIDFlag string
|
|
var versionIDFlag string
|
|
var versionFilenameFlag string
|
|
|
|
func init() {
|
|
modrinthCmd.AddCommand(installCmd)
|
|
|
|
installCmd.Flags().StringVar(&projectIDFlag, "project-id", "", "The Modrinth project ID to use")
|
|
installCmd.Flags().StringVar(&versionIDFlag, "version-id", "", "The Modrinth version ID to use")
|
|
installCmd.Flags().StringVar(&versionFilenameFlag, "version-filename", "", "The Modrinth version filename to use")
|
|
}
|