diff --git a/main.go b/main.go index 4f1b94f..487c512 100644 --- a/main.go +++ b/main.go @@ -12,11 +12,13 @@ import ( type Command struct { Name string Description string - Action func([]string) (string, error) + Action CommandAction Arguments []string IsVarargs bool } +type CommandAction func([]string, []string) (string, error) + var commands = []Command{ { Name: "install", @@ -24,38 +26,35 @@ var commands = []Command{ Arguments: []string{"game name", "archive path"}, IsVarargs: true, - Action: func(arguments []string) (string, error) { - var argumentCount = len(arguments) + Action: func(requiredArguments []string, providedArguments []string) (string, error) { + var argumentCount = len(providedArguments) - if argumentCount == 0 { - return "", fmt.Errorf("expected game name") + if argumentCount < len(requiredArguments) { + return "", fmt.Errorf("expected game name folowed by at least one archive path") } - var archivePaths = arguments[1:] - var processed = 0 + var archivePaths = providedArguments[1:] - if gameError := WithGame(arguments[0], func(game *Game) error { + if gameError := WithGame(providedArguments[0], func(game *Game) error { for i := range archivePaths { var archivePath = archivePaths[i] var extension = filepath.Ext(archivePath) if len(extension) == 0 { - continue - } else { - extension = extension[1:] + return fmt.Errorf("missing file extension: `%s`", archivePath) } - var extractor, extractorExists = extractors[extension] + var extractor, extractorExists = extractors[extension[1:]] if !(extractorExists) { - continue + return fmt.Errorf("unsupported file format: `%s`", archivePath) } - if game.InstallMod(extractor, archivePath) != nil { - continue - } + if installError := game.InstallMod( + extractor, archivePath); installError != nil { - processed += 1 + return installError + } } return nil @@ -63,7 +62,7 @@ var commands = []Command{ return "", gameError } - return fmt.Sprint(processed, " of ", len(archivePaths), " installed"), nil + return "mods installed", nil }, }, @@ -73,31 +72,19 @@ var commands = []Command{ Arguments: []string{"game name", "mod name"}, IsVarargs: true, - Action: func(arguments []string) (string, error) { - var argumentCount = len(arguments) - - if argumentCount == 0 { - return "", fmt.Errorf("expected game name") + Action: func(requiredArguments []string, providedArguments []string) (string, error) { + if len(providedArguments) < len(requiredArguments) { + return "", fmt.Errorf("expected %s followed by one or more %ss", + requiredArguments[0], requiredArguments[1]) } - var modsToRemove = arguments[1:] - var removedMods = []string{} - - if gameError := WithGame(arguments[0], func(game *Game) error { - var removed, removeError = game.RemoveMods(modsToRemove) - - if removeError != nil { - return removeError - } - - removedMods = removed - - return nil + if gameError := WithGame(providedArguments[0], func(game *Game) error { + return game.RemoveMods(providedArguments[1:]) }); gameError != nil { return "", gameError } - return fmt.Sprint(len(removedMods), " of ", len(removedMods), " removed"), nil + return "removed mods", nil }, }, @@ -107,13 +94,16 @@ var commands = []Command{ Arguments: []string{"game name", "mod name", "new name"}, IsVarargs: false, - Action: func(arguments []string) (string, error) { - if len(arguments) != 3 { - return "", fmt.Errorf("expected game name followed by mod name and new name") + Action: func(requiredArguments []string, providedArguments []string) (string, error) { + if len(providedArguments) != len(requiredArguments) { + return "", fmt.Errorf("expected %s followed by %s and %s", + requiredArguments[0], requiredArguments[1], requiredArguments[2]) } - if gameError := WithGame(arguments[0], func(game *Game) error { - if removeError := game.RenameMod(arguments[1], arguments[2]); removeError != nil { + if gameError := WithGame(providedArguments[0], func(game *Game) error { + if removeError := game.RenameMod(providedArguments[1], + providedArguments[2]); removeError != nil { + return removeError } @@ -132,22 +122,23 @@ var commands = []Command{ Arguments: []string{"game name", "format"}, IsVarargs: false, - Action: func(arguments []string) (string, error) { - if len(arguments) != 2 { - return "", fmt.Errorf("expected game name followed by format") + Action: func(requiredArguments []string, providedArguments []string) (string, error) { + if len(providedArguments) != len(requiredArguments) { + return "", fmt.Errorf("expected %s followed by %s", + requiredArguments[0], requiredArguments[1]) } var modManifest = "" - if gameError := WithGame(arguments[0], func(game *Game) error { - var format = arguments[1] + if gameError := WithGame(providedArguments[0], func(game *Game) error { + var format = providedArguments[1] var formatter, formatterExists = formatters[format] if !(formatterExists) { return fmt.Errorf("unsupported format: `%s`", format) } - var formattedManifest, formatError = formatter(game.Mods) + var formattedManifest, formatError = formatter(game.ModOrder) if formatError != nil { return formatError @@ -172,9 +163,76 @@ var commands = []Command{ Arguments: []string{"game name"}, IsVarargs: false, - Action: func(arguments []string) (string, error) { - // TODO: Implement. - return "", fmt.Errorf("not implemented") + Action: func(requiredArguments []string, arguments []string) (string, error) { + if len(arguments) != len(requiredArguments) { + return "", fmt.Errorf("expected %s", requiredArguments[0]) + } + + if gameError := WithGame(arguments[0], func(game *Game) error { + var deployError = game.Deploy() + + if deployError != nil { + return deployError + } + + return nil + }); gameError != nil { + return "", gameError + } + + return "deployed", nil + }, + }, + + { + Name: "disable", + Description: "Disable one or more installed mods", + Arguments: []string{"game name", "mod name"}, + IsVarargs: true, + + Action: func(requiredArguments []string, arguments []string) (string, error) { + if len(arguments) < len(requiredArguments) { + return "", fmt.Errorf("expected %s followed by one or more %ss", + requiredArguments[0], requiredArguments[1]) + } + + if gameError := WithGame(arguments[0], func(game *Game) error { + if enableError := game.SwitchMods(false, arguments[1:]); enableError != nil { + return enableError + } + + return nil + }); gameError != nil { + return "", gameError + } + + return "enabled", nil + }, + }, + + { + Name: "enable", + Description: "Enable one or more installed mods", + Arguments: []string{"game name", "mod name"}, + IsVarargs: true, + + Action: func(requiredArguments []string, arguments []string) (string, error) { + if len(arguments) < len(requiredArguments) { + return "", fmt.Errorf("expected %s followed by one or more %ss", + requiredArguments[0], requiredArguments[1]) + } + + if gameError := WithGame(arguments[0], func(game *Game) error { + if enableError := game.SwitchMods(true, arguments[1:]); enableError != nil { + return enableError + } + + return nil + }); gameError != nil { + return "", gameError + } + + return "enabled", nil }, }, } @@ -291,7 +349,7 @@ func main() { var command = commands[i] if command.Name == commandName { - var response, actionError = command.Action(os.Args[2:]) + var response, actionError = command.Action(command.Arguments, os.Args[2:]) if actionError != nil { fmt.Fprintln(os.Stderr, actionError.Error()) diff --git a/manager.go b/manager.go index c3ad695..85931ac 100644 --- a/manager.go +++ b/manager.go @@ -1,8 +1,11 @@ package main import ( + "bufio" "encoding/csv" "fmt" + "io" + "io/fs" "os" "path/filepath" "strings" @@ -12,33 +15,193 @@ type App struct { ConfigDirPath string } +func (game *Game) CachePath() (string, error) { + var path, pathError = os.UserCacheDir() + + if pathError != nil { + return "", pathError + } + + path = filepath.Join(path, "modman", game.ID) + + if mkdirError := os.MkdirAll(path, 0755); mkdirError != nil { + return "", mkdirError + } + + return path, nil +} + +func (game *Game) Clean() error { + var cachePath, cachePathError = game.CachePath() + + if cachePathError != nil { + return cachePathError + } + + var deployedListPath = filepath.Join(cachePath, "backup", "deployed.txt") + + if backupFile, openError := os.Open(deployedListPath); !(os.IsNotExist(openError)) { + defer backupFile.Close() + + var scanner = bufio.NewScanner(backupFile) + + for scanner.Scan() { + var + } + } + + return nil +} + +func (game *Game) ConfigPath() (string, error) { + var path, pathError = os.UserConfigDir() + + if pathError != nil { + return "", pathError + } + + path = filepath.Join(path, "modman", game.ID) + + if mkdirError := os.MkdirAll(path, 0755); mkdirError != nil { + return "", mkdirError + } + + return path, nil +} + +func (game *Game) Deploy() error { + var cachePath, cachePathError = game.CachePath() + + if cachePathError != nil { + return cachePathError + } + + var configPath, configPathError = game.ConfigPath() + + if configPathError != nil { + return configPathError + } + + var backupPath = filepath.Join(cachePath, "backup") + var deployedListPath = filepath.Join(backupPath, "deployed.txt") + + if backupFile, openError := os.Open(deployedListPath); !(os.IsNotExist(openError)) { + defer backupFile.Close() + + var scanner = bufio.NewScanner(backupFile) + + for scanner.Scan() { + var + } + } + + var modsPath = filepath.Join(configPath, "mods") + + for i := range game.ModOrder { + var mod = game.ModOrder[i] + + if mod.IsEnabled { + var modPath = filepath.Join(modsPath, mod.Name) + + if walkError := filepath.WalkDir(modPath, func( + path string, dirEntry fs.DirEntry, dirError error) error { + + if dirError != nil { + return dirError + } + + var fileMode = dirEntry.Type() + + if !(fileMode.IsDir()) { + var localPath, relativeError = filepath.Rel(modPath, path) + + if relativeError != nil { + return relativeError + } + + var linkPath = filepath.Join(game.Path, localPath) + + if pathError := os.MkdirAll(filepath.Dir(linkPath), + fileMode.Perm()); pathError != nil { + + return pathError + } + + if _, statError := os.Stat(linkPath); !(os.IsNotExist(statError)) { + var sourceFile, sourceOpenError = os.Open(linkPath) + + if sourceOpenError != nil { + return sourceOpenError + } + + defer sourceFile.Close() + + var targetFile, targetOpenError = os.Create(backupPath) + + if targetOpenError != nil { + return targetOpenError + } + + defer targetFile.Close() + + var _, copyError = io.Copy(targetFile, sourceFile) + + if copyError != nil { + return copyError + } + } + + if linkError := os.Link(path, linkPath); linkError != nil { + return linkError + } + } + + return nil + }); walkError != nil { + return walkError + } + } + } + + return nil +} + type Extractor func(string, string) error type Game struct { ID string - Mods map[string]Mod + ModOrder []Mod + ModNames map[string]int + Path string HasUpdated bool } func (game *Game) InstallMod(extractor Extractor, archivePath string) error { - var gamePath, pathError = game.Path() + var baseName = filepath.Base(archivePath) + var modName = strings.TrimSuffix(baseName, filepath.Ext(baseName)) + + if _, exists := game.ModNames[modName]; exists { + return fmt.Errorf("mod with name already exists: `%s`", modName) + } + + var configPath, pathError = game.ConfigPath() if pathError != nil { return pathError } - var baseName = filepath.Base(archivePath) - var modName = strings.TrimSuffix(baseName, filepath.Ext(baseName)) - if extractError := extractor(archivePath, filepath.Join( - gamePath, "mods", modName)); extractError != nil { + configPath, "mods", modName)); extractError != nil { return extractError } - game.Mods[modName] = Mod{ + game.ModNames[modName] = len(game.ModOrder) + + game.ModOrder = append(game.ModOrder, Mod{ IsEnabled: false, - } + Name: modName, + }) game.HasUpdated = true @@ -47,6 +210,30 @@ func (game *Game) InstallMod(extractor Extractor, archivePath string) error { type Mod struct { IsEnabled bool + Name string + Source string +} + +func (game *Game) SwitchMods(isEnabled bool, names []string) error { + if len(names) != 0 { + for i := range names { + var name = names[i] + var index, exists = game.ModNames[name] + + if !(exists) { + return fmt.Errorf("mod does not exist: `%s`", name) + } + + var mod = game.ModOrder[index] + + mod.IsEnabled = isEnabled + game.ModOrder[index] = mod + } + + game.HasUpdated = true + } + + return nil } func WithGame(gameName string, action func(*Game) error) error { @@ -58,17 +245,19 @@ func WithGame(gameName string, action func(*Game) error) error { if gameName == supportedGame { var game = Game{ ID: supportedGame, - Mods: make(map[string]Mod), + ModOrder: make([]Mod, 0, 512), + ModNames: make(map[string]int), HasUpdated: false, + Path: "/home/kayomn/.steam/steam/steamapps/common/Fallout 4/Data", } - var gamePath, pathError = game.Path() + var configPath, pathError = game.ConfigPath() if pathError != nil { return pathError } - var manifestPath = filepath.Join(gamePath, "mods.csv") + var manifestPath = filepath.Join(configPath, "mods.csv") // Load manifest from disk. { @@ -92,9 +281,13 @@ func WithGame(gameName string, action func(*Game) error) error { var status = recordValues[0] var name = recordValues[1] - game.Mods[name] = Mod{ + game.ModNames[name] = len(game.ModOrder) + + game.ModOrder = append(game.ModOrder, Mod{ IsEnabled: status == "*", - } + Name: name, + Source: "", + }) recordValues, recordError = manifestReader.Read() } @@ -107,8 +300,7 @@ func WithGame(gameName string, action func(*Game) error) error { // Save manifest back to disk. if game.HasUpdated { - var manifestFile, openError = os.OpenFile(manifestPath, - os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + var manifestFile, openError = os.Create(manifestPath) if openError != nil { return openError @@ -118,14 +310,15 @@ func WithGame(gameName string, action func(*Game) error) error { var manifestWriter = csv.NewWriter(manifestFile) - for name, mod := range game.Mods { + for i := range game.ModOrder { + var mod = game.ModOrder[i] var status = "" if mod.IsEnabled { status = "*" } - manifestWriter.Write([]string{status, name}) + manifestWriter.Write([]string{status, mod.Name, mod.Source}) } manifestWriter.Flush() @@ -138,65 +331,63 @@ func WithGame(gameName string, action func(*Game) error) error { return fmt.Errorf("%s: game not supported", gameName) } -func (game Game) Path() (string, error) { - var configDirPath, configError = os.UserConfigDir() - - if configError != nil { - return "", configError - } - - return filepath.Join(configDirPath, "modman", game.ID), nil -} - -func (game *Game) RemoveMods(modNames []string) ([]string, error) { - var gamePath, pathError = game.Path() - - if pathError != nil { - return nil, pathError - } - - var processed = 0 - - for i := range modNames { - var modName = modNames[i] - - if removeError := os.RemoveAll(filepath.Join(gamePath, modName)); removeError != nil { - return nil, removeError - } - - delete(game.Mods, modName) - - game.HasUpdated = true - processed += 1 - } - - return modNames[0:processed], nil -} - -func (game *Game) RenameMod(modName string, newName string) error { - var gamePath, pathError = game.Path() +func (game *Game) RemoveMods(names []string) error { + var configPath, pathError = game.ConfigPath() if pathError != nil { return pathError } - if _, exists := game.Mods[modName]; !(exists) { + if len(names) != 0 { + game.HasUpdated = true + + for i := range names { + var name = names[i] + var index, exists = game.ModNames[name] + + if !(exists) { + return fmt.Errorf("unknown mod: `%s`", name) + } + + if removeError := os.RemoveAll(filepath.Join(configPath, name)); removeError != nil { + return removeError + } + + game.ModOrder = append(game.ModOrder[:index], game.ModOrder[index+1:]...) + + delete(game.ModNames, name) + } + } + + return nil +} + +func (game *Game) RenameMod(modName string, newName string) error { + var configPath, pathError = game.ConfigPath() + + if pathError != nil { + return pathError + } + + if _, exists := game.ModNames[modName]; !(exists) { return fmt.Errorf("no mod with that name exists") } - if _, exists := game.Mods[newName]; exists { + if _, nameTaken := game.ModNames[newName]; nameTaken { return fmt.Errorf("a mod with the new name already exists") } - if renameError := os.Rename(filepath.Join(gamePath, modName), - filepath.Join(gamePath, newName)); renameError != nil { + var modsPath = filepath.Join(configPath, "mods") + + if renameError := os.Rename(filepath.Join(modsPath, modName), + filepath.Join(modsPath, newName)); renameError != nil { return renameError } - game.Mods[newName] = game.Mods[modName] + game.ModNames[newName] = game.ModNames[modName] - delete(game.Mods, modName) + delete(game.ModNames, modName) game.HasUpdated = true