diff --git a/main.go b/main.go index 1f3b133..cc86dab 100644 --- a/main.go +++ b/main.go @@ -1,118 +1,178 @@ package main import ( - "archive/zip" + "encoding/json" "fmt" "os" - "strings" ) type Command struct { Name string Description string - Action func([]string) Result + Action func([]string) (string, error) Arguments []string IsVarargs bool } -func Failed(message string) Result { - return Result{ - HasFailed: true, - Message: message, - } -} - -func Ok(message string) Result { - return Result{ - HasFailed: false, - Message: message, - } -} - -type Result struct { - Message string - HasFailed bool -} - var commands = []Command{ { Name: "install", Description: "Install one or more mod archives into modman", - Arguments: []string{"archive path"}, + Arguments: []string{"game name", "archive path"}, IsVarargs: true, - Action: func(arguments []string) Result { - var processed = 0 + Action: func(arguments []string) (string, error) { + var argumentCount = len(arguments) - for i := range arguments { - var archivePath = arguments[i] - - if strings.HasSuffix(archivePath, ".zip") { - var reader, err = zip.OpenReader(archivePath) - - if err == nil { - defer reader.Close() - - processed += 1 - } - - continue - } + if argumentCount == 0 { + return "", fmt.Errorf("expected game name") } - return Ok(fmt.Sprint(processed, "of", len(arguments), "installed")) + var modsToInstall = arguments[1:] + var installedMods = []string{} + + if gameError := WithGame(arguments[0], func(game *Game) error { + var installed, installError = game.InstallMods(modsToInstall) + + if installError != nil { + return installError + } + + installedMods = installed + + return nil + }); gameError != nil { + return "", gameError + } + + return fmt.Sprint(len(installedMods), " of ", len(modsToInstall), " installed"), nil }, }, { Name: "remove", Description: "Remove one or more mods from modman", - Arguments: []string{"mod name"}, + Arguments: []string{"game name", "mod name"}, IsVarargs: true, - Action: func(arguments []string) Result { - // TODO: Implement. - return Failed("Not implemented") + Action: func(arguments []string) (string, error) { + var argumentCount = len(arguments) + + if argumentCount == 0 { + return "", fmt.Errorf("expected game name") + } + + 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 + }); gameError != nil { + return "", gameError + } + + return fmt.Sprint(len(removedMods), " of ", len(removedMods), " removed"), nil }, }, { Name: "rename", Description: "Rename a mod within modman", - Arguments: []string{"mod name", "new name"}, + Arguments: []string{"game name", "mod name", "new name"}, IsVarargs: false, - Action: func(arguments []string) Result { - // TODO: Implement. - return Failed("Not implemented") + Action: func(arguments []string) (string, error) { + if len(arguments) != 3 { + return "", fmt.Errorf("expected game name followed by mod name and new name") + } + + if gameError := WithGame(arguments[0], func(game *Game) error { + if removeError := game.RenameMod(arguments[1], arguments[2]); removeError != nil { + return removeError + } + + return nil + }); gameError != nil { + return "", gameError + } + + return "", nil }, }, { - Name: "list", - Description: "List all installed mods", - Arguments: []string{}, + Name: "manifest", + Description: "Retrieve a manifest of all installed mods", + Arguments: []string{"game name", "format"}, IsVarargs: false, - Action: func(arguments []string) Result { - // TODO: Implement. - return Failed("Not implemented") + Action: func(arguments []string) (string, error) { + if len(arguments) != 2 { + return "", fmt.Errorf("expected game name followed by format") + } + + var modManifest = "" + + if gameError := WithGame(arguments[0], func(game *Game) error { + var format = arguments[1] + var formatter, formatterExists = formatters[format] + + if !(formatterExists) { + return fmt.Errorf("unsupported format: `%s`", format) + } + + var formattedManifest, formatError = formatter(game.Mods) + + if formatError != nil { + return formatError + } + + // TODO: Reconsider if always casting formatted data to string for output is a good + // idea. + modManifest = string(formattedManifest) + + return nil + }); gameError != nil { + return "", gameError + } + + return modManifest, nil }, }, { Name: "deploy", Description: "Deploy all installed and enabled mods", - Arguments: []string{}, + Arguments: []string{"game name"}, IsVarargs: false, - Action: func(arguments []string) Result { + Action: func(arguments []string) (string, error) { // TODO: Implement. - return Failed("Not implemented") + return "", fmt.Errorf("not implemented") }, }, } +var formatters = map[string]func(any) ([]byte, error){ + "json": func(data any) ([]byte, error) { + var marshalledJson, marshalError = json.Marshal(data) + + if marshalError != nil { + return nil, marshalError + } + + return marshalledJson, nil + }, +} + func main() { var argCount = len(os.Args) @@ -145,16 +205,20 @@ func main() { var command = commands[i] if command.Name == commandName { - var result = command.Action(os.Args[2:]) + var response, actionError = command.Action(os.Args[2:]) - if result.HasFailed { - fmt.Fprint(os.Stderr, result.Message) + if actionError != nil { + fmt.Fprintln(os.Stderr, actionError.Error()) os.Exit(1) } - fmt.Print(result.Message) + if len(response) != 0 { + fmt.Println(response) + } return } } + + fmt.Fprintf(os.Stderr, "unknown command: `%s`\n", commandName) } diff --git a/manager.go b/manager.go new file mode 100644 index 0000000..f6fba97 --- /dev/null +++ b/manager.go @@ -0,0 +1,290 @@ +package main + +import ( + "archive/zip" + "encoding/csv" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +type App struct { + ConfigDirPath string +} + +type Game struct { + ID string + Mods map[string]Mod + HasUpdated bool +} + +func (game *Game) InstallMods(archivePaths []string) ([]string, error) { + var processed = 0 + + for i := range archivePaths { + var archivePath = archivePaths[i] + var suffixIndex = strings.LastIndex(archivePath, ".") + + if suffixIndex < 0 { + return nil, fmt.Errorf("cannot determine file type of `%s`", archivePath) + } + + var extension = archivePath[suffixIndex+1:] + var extractor = extractors[extension] + + if extractor == nil { + return nil, fmt.Errorf("unsupported file type `%s`", extension) + } + + var gamePath, pathError = game.Path() + + if pathError != nil { + return nil, pathError + } + + var modName = filepath.Base(archivePath[:suffixIndex]) + + if extractError := extractor(archivePath, filepath.Join( + gamePath, "mods", modName)); extractError != nil { + + return nil, extractError + } + + game.Mods[modName] = Mod{ + IsEnabled: false, + } + + game.HasUpdated = true + processed += 1 + } + + return archivePaths[0:processed], nil +} + +type Mod struct { + IsEnabled bool +} + +func WithGame(gameName string, action func(*Game) error) error { + var supportedGames = []string{"fallout4", "falloutnv", "skyrim"} + + for i := range supportedGames { + var supportedGame = supportedGames[i] + + if gameName == supportedGame { + var game = Game{ + ID: supportedGame, + Mods: make(map[string]Mod), + HasUpdated: false, + } + + var gamePath, pathError = game.Path() + + if pathError != nil { + return pathError + } + + var manifestPath = filepath.Join(gamePath, "mods.csv") + + // Load manifest from disk. + { + var manifestFile, openError = os.Open(manifestPath) + + if openError == nil { + defer manifestFile.Close() + + var manifestReader = csv.NewReader(manifestFile) + var recordValues, recordError = manifestReader.Read() + + for recordValues != nil { + if recordError != nil { + return recordError + } + + if len(recordValues) < 2 { + return fmt.Errorf("could not read mod manifest data - may be corrupt") + } + + var status = recordValues[0] + var name = recordValues[1] + + game.Mods[name] = Mod{ + IsEnabled: status == "*", + } + + recordValues, recordError = manifestReader.Read() + } + } + } + + if actionError := action(&game); actionError != nil { + return actionError + } + + // Save manifest back to disk. + if game.HasUpdated { + var manifestFile, openError = os.OpenFile(manifestPath, + os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + + if openError != nil { + return openError + } + + defer manifestFile.Close() + + var manifestWriter = csv.NewWriter(manifestFile) + + for name, mod := range game.Mods { + var status = "" + + if mod.IsEnabled { + status = "*" + } + + manifestWriter.Write([]string{status, name}) + } + + manifestWriter.Flush() + } + + return nil + } + } + + 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() + + if pathError != nil { + return pathError + } + + if _, exists := game.Mods[modName]; !(exists) { + return fmt.Errorf("no mod with that name exists") + } + + if _, exists := game.Mods[newName]; exists { + 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 { + + return renameError + } + + game.Mods[newName] = game.Mods[modName] + + delete(game.Mods, modName) + + game.HasUpdated = true + + return nil +} + +var extractors = map[string]func(string, string) error{ + "zip": func(archivePath string, destinationPath string) error { + var zipReader, openReaderError = zip.OpenReader(archivePath) + + if openReaderError != nil { + return openReaderError + } + + defer func() { + if closeError := zipReader.Close(); closeError != nil { + panic(closeError.Error()) + } + }() + + if mkdirError := os.MkdirAll(destinationPath, 0755); mkdirError != nil { + return mkdirError + } + + for i := range zipReader.File { + var file = zipReader.File[i] + var fileReader, fileOpenError = file.Open() + + if fileOpenError != nil { + return fileOpenError + } + + defer func() { + if closeError := fileReader.Close(); closeError != nil { + panic(closeError.Error()) + } + }() + + var path = filepath.Join(destinationPath, file.Name) + + if file.FileInfo().IsDir() { + if mkdirError := os.MkdirAll(path, file.Mode()); mkdirError != nil { + return mkdirError + } + } else { + if mkdirError := os.MkdirAll(filepath.Dir(path), file.Mode()); mkdirError != nil { + return mkdirError + } + + var extractedFile, fileOpenError = os.OpenFile(path, + os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode()) + + if fileOpenError != nil { + return fileOpenError + } + + defer func() { + if fileOpenError := extractedFile.Close(); fileOpenError != nil { + panic(fileOpenError) + } + }() + + var _, copyError = io.Copy(extractedFile, fileReader) + + if copyError != nil { + return copyError + } + } + } + + return nil + }, +}