From 21e52edd00f1d581c581d612dd8dd171ee25d619 Mon Sep 17 00:00:00 2001 From: "Brandon T. Kowalski" Date: Sat, 10 Jan 2026 01:52:03 -0500 Subject: [PATCH 1/3] Start of work for #57 --- app/states.go | 20 ++++++------ cache/games.go | 7 +---- go.mod | 2 ++ internal/constants/exit_codes.go | 1 + internal/constants/icons.go | 4 +++ internal/fileutil/fileutil.go | 19 ++++++++++++ romm/roms.go | 42 ++++++++++++++++++++++++-- sync/roms.go | 2 ++ sync/save_sync.go | 38 +++++++++++++++++++++++ ui/download.go | 46 +++++++++++++++++----------- ui/game_details.go | 52 +++++++++++++++++++++++++++++--- ui/games_list.go | 35 ++++++++++++++++++--- 12 files changed, 224 insertions(+), 44 deletions(-) create mode 100644 internal/constants/icons.go diff --git a/app/states.go b/app/states.go index 0b808cb..a84bbfd 100644 --- a/app/states.go +++ b/app/states.go @@ -393,7 +393,7 @@ func buildFSM(config *internal.Config, c cfw.CFW, platforms []romm.Platform, qui // If multiple games selected, skip details and go straight to download if len(gameListOutput.SelectedGames) != 1 { downloadScreen := ui.NewDownloadScreen() - downloadOutput := downloadScreen.Execute(*config, host, gameListOutput.Platform, gameListOutput.SelectedGames, gameListOutput.AllGames, nav.SearchFilter) + downloadOutput := downloadScreen.Execute(*config, host, gameListOutput.Platform, gameListOutput.SelectedGames, gameListOutput.AllGames, nav.SearchFilter, 0) nav.CurrentGames = downloadOutput.AllGames nav.SearchFilter = downloadOutput.SearchFilter triggerAutoSync() @@ -414,23 +414,22 @@ func buildFSM(config *internal.Config, c cfw.CFW, platforms []romm.Platform, qui return result.Value, result.ExitCode }). - OnWithHook(gaba.ExitCodeSuccess, gameList, func(ctx *gaba.Context) error { + OnWithHook(constants.ExitCodeDownloadRequested, gameDetails, func(ctx *gaba.Context) error { detailsOutput, _ := gaba.Get[ui.GameDetailsOutput](ctx) config, _ := gaba.Get[*internal.Config](ctx) host, _ := gaba.Get[romm.Host](ctx) gameListOutput, _ := gaba.Get[ui.GameListOutput](ctx) nav, _ := gaba.Get[*NavState](ctx) - if detailsOutput.DownloadRequested { - downloadScreen := ui.NewDownloadScreen() - downloadOutput := downloadScreen.Execute(*config, host, detailsOutput.Platform, []romm.Rom{detailsOutput.Game}, gameListOutput.AllGames, nav.SearchFilter) - nav.CurrentGames = downloadOutput.AllGames - nav.SearchFilter = downloadOutput.SearchFilter - triggerAutoSync() - } + downloadScreen := ui.NewDownloadScreen() + downloadOutput := downloadScreen.Execute(*config, host, detailsOutput.Platform, []romm.Rom{detailsOutput.Game}, gameListOutput.AllGames, nav.SearchFilter, detailsOutput.SelectedFileID) + nav.CurrentGames = downloadOutput.AllGames + nav.SearchFilter = downloadOutput.SearchFilter + triggerAutoSync() return nil }). + On(gaba.ExitCodeSuccess, gameList). On(gaba.ExitCodeBack, gameList). On(constants.ExitCodeGameOptions, gameOptions) @@ -876,7 +875,8 @@ func buildFSM(config *internal.Config, c cfw.CFW, platforms []romm.Platform, qui return nil } - // Update platforms in context + // Apply custom platform order and update in context + platforms = internal.SortPlatformsByOrder(platforms, config.PlatformOrder) gaba.Set(ctx, platforms) // Re-populate cache with progress diff --git a/cache/games.go b/cache/games.go index 969089f..3a218dc 100644 --- a/cache/games.go +++ b/cache/games.go @@ -102,13 +102,8 @@ func (cm *Manager) SavePlatformGames(platformID int, games []romm.Rom) error { } defer tx.Rollback() - _, err = tx.Exec(`DELETE FROM games WHERE platform_id = ?`, platformID) - if err != nil { - return newCacheError("save", "games", GetPlatformCacheKey(platformID), err) - } - stmt, err := tx.Prepare(` - INSERT INTO games (id, platform_id, platform_fs_slug, name, fs_name, fs_name_no_ext, crc_hash, md5_hash, sha1_hash, data_json, updated_at, cached_at) + INSERT OR REPLACE INTO games (id, platform_id, platform_fs_slug, name, fs_name, fs_name_no_ext, crc_hash, md5_hash, sha1_hash, data_json, updated_at, cached_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) if err != nil { diff --git a/go.mod b/go.mod index ee9b468..64c7f63 100644 --- a/go.mod +++ b/go.mod @@ -33,3 +33,5 @@ require ( modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) + +replace github.com/BrandonKowalski/gabagool/v2 => ../gabagool diff --git a/internal/constants/exit_codes.go b/internal/constants/exit_codes.go index 34c2555..3e193c3 100644 --- a/internal/constants/exit_codes.go +++ b/internal/constants/exit_codes.go @@ -19,6 +19,7 @@ const ( ExitCodeGameOptions gaba.ExitCode = 113 ExitCodeGeneralSettings gaba.ExitCode = 114 ExitCodeCheckUpdate gaba.ExitCode = 115 + ExitCodeDownloadRequested gaba.ExitCode = 116 ExitCodeSearch gaba.ExitCode = 200 ExitCodeClearSearch gaba.ExitCode = 201 ExitCodeCollections gaba.ExitCode = 300 diff --git a/internal/constants/icons.go b/internal/constants/icons.go new file mode 100644 index 0000000..1c8be3f --- /dev/null +++ b/internal/constants/icons.go @@ -0,0 +1,4 @@ +package constants + +const MultipleFiles = "\U000F0222" +const MultipleDownloaded = "\U000F09E9" diff --git a/internal/fileutil/fileutil.go b/internal/fileutil/fileutil.go index 304c9fc..61f3ce0 100644 --- a/internal/fileutil/fileutil.go +++ b/internal/fileutil/fileutil.go @@ -4,6 +4,7 @@ import ( "archive/zip" "bufio" "fmt" + "hash/crc32" "io" "os" "path/filepath" @@ -184,3 +185,21 @@ func FilterHiddenDirectories(entries []os.DirEntry) []os.DirEntry { } return result } + +// ComputeCRC32 computes the CRC32 hash of a file and returns it as an uppercase hex string +func ComputeCRC32(filePath string) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + hash := crc32.NewIEEE() + buffer := make([]byte, DefaultBufferSize) + + if _, err := io.CopyBuffer(hash, file, buffer); err != nil { + return "", fmt.Errorf("failed to compute hash: %w", err) + } + + return fmt.Sprintf("%08X", hash.Sum32()), nil +} diff --git a/romm/roms.go b/romm/roms.go index 37914eb..70ca388 100644 --- a/romm/roms.go +++ b/romm/roms.go @@ -210,9 +210,45 @@ func (r Rom) GetLocalPath(resolver PlatformDirResolver) string { } func (r Rom) IsDownloaded(resolver PlatformDirResolver) bool { - path := r.GetLocalPath(resolver) - if path == "" { + if r.PlatformFSSlug == "" { return false } - return fileutil.FileExists(path) + + platform := Platform{ + ID: r.PlatformID, + FSSlug: r.PlatformFSSlug, + Name: r.PlatformDisplayName, + } + romDirectory := resolver.GetPlatformRomDirectory(platform) + + // For multi-disk games, check the m3u file + if r.HasMultipleFiles { + m3uPath := filepath.Join(romDirectory, r.FsNameNoExt+".m3u") + return fileutil.FileExists(m3uPath) + } + + // Check if any of the associated files exist + for _, file := range r.Files { + filePath := filepath.Join(romDirectory, file.FileName) + if fileutil.FileExists(filePath) { + return true + } + } + + return false +} + +func (r Rom) IsFileDownloaded(resolver PlatformDirResolver, fileName string) bool { + if r.PlatformFSSlug == "" { + return false + } + + platform := Platform{ + ID: r.PlatformID, + FSSlug: r.PlatformFSSlug, + Name: r.PlatformDisplayName, + } + romDirectory := resolver.GetPlatformRomDirectory(platform) + filePath := filepath.Join(romDirectory, fileName) + return fileutil.FileExists(filePath) } diff --git a/sync/roms.go b/sync/roms.go index bd03845..dcfb814 100644 --- a/sync/roms.go +++ b/sync/roms.go @@ -21,6 +21,7 @@ type LocalRomFile struct { RomName string FSSlug string FileName string + FilePath string RemoteSaves []romm.Save SaveFile *LocalSave } @@ -247,6 +248,7 @@ func scanRomDirectory(fsSlug, romDir string, saveFileMap map[string]*LocalSave) rom := LocalRomFile{ FSSlug: fsSlug, FileName: entry.Name(), + FilePath: filepath.Join(romDir, entry.Name()), SaveFile: saveFile, } diff --git a/sync/save_sync.go b/sync/save_sync.go index 068bde7..61249e1 100644 --- a/sync/save_sync.go +++ b/sync/save_sync.go @@ -205,6 +205,39 @@ func lookupRomID(romFile *LocalRomFile) (int, string) { return 0, "" } +func lookupRomByHash(rc *romm.Client, romFile *LocalRomFile) (int, string) { + logger := gaba.GetLogger() + + if romFile.FilePath == "" { + return 0, "" + } + + crcHash, err := fileutil.ComputeCRC32(romFile.FilePath) + if err != nil { + logger.Debug("Failed to compute CRC32 hash", "file", romFile.FileName, "error", err) + return 0, "" + } + + logger.Debug("Looking up ROM by CRC32 hash", "file", romFile.FileName, "crc", crcHash) + + rom, err := rc.GetRomByHash(romm.GetRomByHashQuery{CrcHash: crcHash}) + if err != nil { + logger.Debug("ROM not found by hash", "file", romFile.FileName, "crc", crcHash, "error", err) + return 0, "" + } + + if rom.ID > 0 { + logger.Info("Found ROM by CRC32 hash", + "file", romFile.FileName, + "crc", crcHash, + "romID", rom.ID, + "romName", rom.Name) + return rom.ID, rom.Name + } + + return 0, "" +} + func FindSaveSyncs(host romm.Host, config *internal.Config) ([]SaveSync, []UnmatchedSave, error) { return FindSaveSyncsFromScan(host, config, ScanRoms()) } @@ -311,6 +344,11 @@ func FindSaveSyncsFromScan(host romm.Host, config *internal.Config, scanLocal Lo // Look up ROM ID from the games cache romID, romName := lookupRomID(romFile) + if romID == 0 && romFile.SaveFile != nil { + // Try to find ROM by CRC32 hash as fallback + romID, romName = lookupRomByHash(rc, romFile) + } + if romID == 0 { if romFile.SaveFile != nil { unmatched = append(unmatched, UnmatchedSave{ diff --git a/ui/download.go b/ui/download.go index 06448ed..d699f6a 100644 --- a/ui/download.go +++ b/ui/download.go @@ -27,12 +27,13 @@ import ( ) type downloadInput struct { - Config internal.Config - Host romm.Host - Platform romm.Platform - SelectedGames []romm.Rom - AllGames []romm.Rom - SearchFilter string + Config internal.Config + Host romm.Host + Platform romm.Platform + SelectedGames []romm.Rom + AllGames []romm.Rom + SearchFilter string + SelectedFileID int } type downloadOutput struct { @@ -54,14 +55,15 @@ func NewDownloadScreen() *DownloadScreen { return &DownloadScreen{} } -func (s *DownloadScreen) Execute(config internal.Config, host romm.Host, platform romm.Platform, selectedGames []romm.Rom, allGames []romm.Rom, searchFilter string) downloadOutput { +func (s *DownloadScreen) Execute(config internal.Config, host romm.Host, platform romm.Platform, selectedGames []romm.Rom, allGames []romm.Rom, searchFilter string, selectedFileID int) downloadOutput { result, err := s.draw(downloadInput{ - Config: config, - Host: host, - Platform: platform, - SelectedGames: selectedGames, - AllGames: allGames, - SearchFilter: searchFilter, + Config: config, + Host: host, + Platform: platform, + SelectedGames: selectedGames, + AllGames: allGames, + SelectedFileID: selectedFileID, + SearchFilter: searchFilter, }) if err != nil { @@ -89,7 +91,7 @@ func (s *DownloadScreen) draw(input downloadInput) (ScreenResult[downloadOutput] SearchFilter: input.SearchFilter, } - downloads, artDownloads := s.buildDownloads(input.Config, input.Host, input.Platform, input.SelectedGames) + downloads, artDownloads := s.buildDownloads(input.Config, input.Host, input.Platform, input.SelectedGames, input.SelectedFileID) headers := make(map[string]string) headers["Authorization"] = input.Host.BasicAuthHeader() @@ -294,7 +296,7 @@ func (s *DownloadScreen) draw(input downloadInput) (ScreenResult[downloadOutput] return success(output), nil } -func (s *DownloadScreen) buildDownloads(config internal.Config, host romm.Host, platform romm.Platform, games []romm.Rom) ([]gaba.Download, []artDownload) { +func (s *DownloadScreen) buildDownloads(config internal.Config, host romm.Host, platform romm.Platform, games []romm.Rom, selectedFileID int) ([]gaba.Download, []artDownload) { downloads := make([]gaba.Download, 0, len(games)) artDownloads := make([]artDownload, 0, len(games)) @@ -318,8 +320,18 @@ func (s *DownloadScreen) buildDownloads(config internal.Config, host romm.Host, downloadLocation = filepath.Join(tmpDir, fmt.Sprintf("grout_multirom_%d.zip", g.ID)) sourceURL, _ = url.JoinPath(host.URL(), "/api/roms/", strconv.Itoa(g.ID), "content", g.FsName) } else { - downloadLocation = filepath.Join(romDirectory, g.Files[0].FileName) - sourceURL, _ = url.JoinPath(host.URL(), "/api/roms/", strconv.Itoa(g.ID), "content", g.Files[0].FileName) + // Find the file to download - use selected file if specified, otherwise first file + fileToDownload := g.Files[0] + if selectedFileID > 0 { + for _, f := range g.Files { + if f.ID == selectedFileID { + fileToDownload = f + break + } + } + } + downloadLocation = filepath.Join(romDirectory, fileToDownload.FileName) + sourceURL, _ = url.JoinPath(host.URL(), "/api/roms/", strconv.Itoa(g.ID), "content", fileToDownload.FileName) } downloads = append(downloads, gaba.Download{ diff --git a/ui/game_details.go b/ui/game_details.go index 91df410..bd6b047 100644 --- a/ui/game_details.go +++ b/ui/game_details.go @@ -6,11 +6,14 @@ import ( "grout/cache" "grout/internal" constants2 "grout/internal/constants" + "grout/internal/fileutil" "grout/internal/imageutil" "grout/internal/stringutil" "io" "net/http" "os" + "path/filepath" + "strconv" "strings" "time" @@ -31,6 +34,7 @@ type GameDetailsInput struct { type GameDetailsOutput struct { DownloadRequested bool + SelectedFileID int Game romm.Rom Platform romm.Platform } @@ -54,8 +58,12 @@ func (s *GameDetailsScreen) Draw(input GameDetailsInput) (ScreenResult[GameDetai options.Sections = sections options.ShowThemeBackground = false options.ShowScrollbar = true + hasMultipleFiles := input.Game.HasNestedSingleFile && len(input.Game.Files) > 1 + if hasMultipleFiles { + options.ConfirmButton = constants.VirtualButtonX + } if !internal.IsKidModeEnabled() { - options.ActionButton = constants.VirtualButtonX + options.ActionButton = constants.VirtualButtonY options.EnableAction = true } @@ -63,9 +71,13 @@ func (s *GameDetailsScreen) Draw(input GameDetailsInput) (ScreenResult[GameDetai {ButtonName: "B", HelpText: i18n.Localize(&goi18n.Message{ID: "button_back", Other: "Back"}, nil)}, } if !internal.IsKidModeEnabled() { - footerItems = append(footerItems, gaba.FooterHelpItem{ButtonName: "X", HelpText: i18n.Localize(&goi18n.Message{ID: "button_options", Other: "Options"}, nil)}) + footerItems = append(footerItems, gaba.FooterHelpItem{ButtonName: "Y", HelpText: i18n.Localize(&goi18n.Message{ID: "button_options", Other: "Options"}, nil)}) + } + downloadButton := "A" + if hasMultipleFiles { + downloadButton = "X" } - footerItems = append(footerItems, gaba.FooterHelpItem{ButtonName: "A", HelpText: i18n.Localize(&goi18n.Message{ID: "button_download", Other: "Download"}, nil)}) + footerItems = append(footerItems, gaba.FooterHelpItem{ButtonName: downloadButton, HelpText: i18n.Localize(&goi18n.Message{ID: "button_download", Other: "Download"}, nil)}) result, err := gaba.DetailScreen(input.Game.Name, options, footerItems) @@ -79,7 +91,16 @@ func (s *GameDetailsScreen) Draw(input GameDetailsInput) (ScreenResult[GameDetai if result.Action == gaba.DetailActionConfirmed { output.DownloadRequested = true - return success(output), nil + // Check if a specific file was selected from the dropdown + for _, selection := range result.DropdownSelections { + if selection.ID == "file_version" { + if fileID, err := strconv.Atoi(selection.Option.Value); err == nil { + output.SelectedFileID = fileID + } + break + } + } + return withCode(output, constants2.ExitCodeDownloadRequested), nil } if result.Action == gaba.DetailActionTriggered { @@ -101,6 +122,29 @@ func (s *GameDetailsScreen) buildSections(input GameDetailsInput) []gaba.Section logger.Debug("No cover image available", "game", game.Name) } + // Show file selection dropdown for games with nested single file (multiple versions) + if game.HasNestedSingleFile && len(game.Files) > 1 { + fileOptions := make([]gaba.DropdownOption, len(game.Files)) + romDirectory := input.Config.GetPlatformRomDirectory(input.Platform) + for i, file := range game.Files { + label := file.FileName + filePath := filepath.Join(romDirectory, file.FileName) + if fileutil.FileExists(filePath) { + label = constants.Download + " " + label + } + fileOptions[i] = gaba.DropdownOption{ + Label: label, + Value: fmt.Sprintf("%d", file.ID), + } + } + sections = append(sections, gaba.NewDropdownSection( + i18n.Localize(&goi18n.Message{ID: "game_details_file_version", Other: "File Version"}, nil), + "file_version", + fileOptions, + 0, + )) + } + if game.Summary != "" { sections = append(sections, gaba.NewDescriptionSection("", game.Summary)) } diff --git a/ui/games_list.go b/ui/games_list.go index 7662c51..f430b98 100644 --- a/ui/games_list.go +++ b/ui/games_list.go @@ -134,12 +134,39 @@ func (s *GameListScreen) Draw(input GameListInput) (ScreenResult[GameListOutput] } } } else { - if input.Config.DownloadedGames == "mark" { - for i := range displayGames { - if displayGames[i].IsDownloaded(*input.Config) { - displayGames[i].DisplayName = fmt.Sprintf("%s %s", gabaconst.Download, displayGames[i].DisplayName) + for i := range displayGames { + prefix := "" + game := &displayGames[i] + + if game.HasNestedSingleFile { + // For multi-file games, check if all files are downloaded + allDownloaded := len(game.Files) > 0 + anyDownloaded := false + for _, file := range game.Files { + if game.IsFileDownloaded(*input.Config, file.FileName) { + anyDownloaded = true + } else { + allDownloaded = false + } + } + + if input.Config.DownloadedGames == "mark" { + if allDownloaded { + prefix = constants.MultipleDownloaded + " " + } else if anyDownloaded { + prefix = gabaconst.Download + " " + } + } + prefix += constants.MultipleFiles + " " + } else { + if input.Config.DownloadedGames == "mark" && game.IsDownloaded(*input.Config) { + prefix = gabaconst.Download + " " } } + + if prefix != "" { + game.DisplayName = prefix + game.DisplayName + } } } From 29abba1a710048f505f346dbb7c90a04cd38a6eb Mon Sep 17 00:00:00 2001 From: "Brandon T. Kowalski" Date: Sat, 10 Jan 2026 17:02:09 -0500 Subject: [PATCH 2/3] Grout now properly detected "orphaned" ROMS / Saves and "reattaches" them with a CRC32 lookup. This will allow for duplicate roms on a local device (e.g. Pokemon Red.gb and Pokemon Red Nuzlocke.gb) and for the saves to be synced and managed separately --- go.mod | 12 ++++------ go.sum | 6 +++++ sync/roms.go | 58 +++++++++++++++++++++++++++++++++++++++++++---- sync/save_sync.go | 7 +++--- ui/status_bar.go | 7 +----- 5 files changed, 69 insertions(+), 21 deletions(-) diff --git a/go.mod b/go.mod index 64c7f63..9a9481a 100644 --- a/go.mod +++ b/go.mod @@ -3,14 +3,14 @@ module grout go 1.24.2 require ( - github.com/BrandonKowalski/gabagool/v2 v2.5.3 + github.com/BrandonKowalski/gabagool/v2 v2.6.0 github.com/UncleJunVIP/certifiable v1.2.0 github.com/nicksnyder/go-i18n/v2 v2.6.1 github.com/piglig/go-qr v0.2.6 github.com/sonh/qs v0.6.4 go.uber.org/atomic v1.11.0 golang.org/x/image v0.34.0 - modernc.org/sqlite v1.42.2 + modernc.org/sqlite v1.43.0 ) require ( @@ -27,11 +27,9 @@ require ( github.com/veandco/go-sdl2 v0.4.40 // indirect golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect golang.org/x/net v0.48.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.32.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect modernc.org/libc v1.67.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect -) - -replace github.com/BrandonKowalski/gabagool/v2 => ../gabagool +) \ No newline at end of file diff --git a/go.sum b/go.sum index b7f0a20..7c74f0d 100644 --- a/go.sum +++ b/go.sum @@ -59,8 +59,12 @@ golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= @@ -89,6 +93,8 @@ modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74= modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8= +modernc.org/sqlite v1.43.0 h1:8YqiFx3G1VhHTXO2Q00bl1Wz9KhS9Q5okwfp9Y97VnA= +modernc.org/sqlite v1.43.0/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/sync/roms.go b/sync/roms.go index dcfb814..67ca49a 100644 --- a/sync/roms.go +++ b/sync/roms.go @@ -8,6 +8,7 @@ import ( "grout/romm" "os" "path/filepath" + "regexp" "slices" "strings" gosync "sync" @@ -16,6 +17,17 @@ import ( gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool" ) +// timestampPattern matches the timestamp suffix appended to save files +// Format: " [YYYY-MM-DD HH-MM-SS-mmm]" e.g., " [2024-01-02 15-04-05-000]" +var timestampPattern = regexp.MustCompile(` \[\d{4}-\d{2}-\d{2} \d{2}-\d{2}-\d{2}-\d{3}\]$`) + +// extractSaveBaseName strips the timestamp suffix from a remote save's filename +// to get the original base name for comparison with local saves. +// e.g., "Pokemon Red [2024-01-02 15-04-05-000]" -> "Pokemon Red" +func extractSaveBaseName(fileNameNoExt string) string { + return timestampPattern.ReplaceAllString(fileNameNoExt, "") +} + type LocalRomFile struct { RomID int RomName string @@ -26,9 +38,17 @@ type LocalRomFile struct { SaveFile *LocalSave } +// baseName returns the ROM filename without extension, used for matching saves +func (lrf LocalRomFile) baseName() string { + return strings.TrimSuffix(lrf.FileName, filepath.Ext(lrf.FileName)) +} + func (lrf LocalRomFile) syncAction() SyncAction { hasLocal := lrf.SaveFile != nil - hasRemote := len(lrf.RemoteSaves) > 0 + baseName := lrf.baseName() + + // Check for remote saves that match this ROM's base name + hasRemote := lrf.hasRemoteSaveForBaseName(baseName) switch { case !hasLocal && !hasRemote: @@ -43,7 +63,8 @@ func (lrf LocalRomFile) syncAction() SyncAction { // Truncate to second precision to avoid timestamp precision issues // API timestamps are typically second/millisecond precision, but filesystem is nanosecond localTime := lrf.SaveFile.LastModified.Truncate(time.Second) - remoteTime := lrf.lastRemoteSave().UpdatedAt.Truncate(time.Second) + remoteSave := lrf.lastRemoteSaveForBaseName(baseName) + remoteTime := remoteSave.UpdatedAt.Truncate(time.Second) switch localTime.Compare(remoteTime) { case -1: @@ -55,16 +76,43 @@ func (lrf LocalRomFile) syncAction() SyncAction { } } -func (lrf LocalRomFile) lastRemoteSave() romm.Save { +// lastRemoteSaveForBaseName returns the most recent remote save that matches +// the given base name (after stripping timestamps from remote save filenames). +// This allows multiple local ROM files with different names but the same CRC32 +// to each sync with their own set of remote saves. +func (lrf LocalRomFile) lastRemoteSaveForBaseName(baseName string) romm.Save { if len(lrf.RemoteSaves) == 0 { return romm.Save{} } - slices.SortFunc(lrf.RemoteSaves, func(s1 romm.Save, s2 romm.Save) int { + // Filter saves to only those matching the base name + var matching []romm.Save + for _, s := range lrf.RemoteSaves { + remoteBaseName := extractSaveBaseName(s.FileNameNoExt) + if remoteBaseName == baseName { + matching = append(matching, s) + } + } + + if len(matching) == 0 { + return romm.Save{} + } + + slices.SortFunc(matching, func(s1 romm.Save, s2 romm.Save) int { return s2.UpdatedAt.Compare(s1.UpdatedAt) }) - return lrf.RemoteSaves[0] + return matching[0] +} + +// hasRemoteSaveForBaseName checks if there's any remote save matching the given base name +func (lrf LocalRomFile) hasRemoteSaveForBaseName(baseName string) bool { + for _, s := range lrf.RemoteSaves { + if extractSaveBaseName(s.FileNameNoExt) == baseName { + return true + } + } + return false } // LocalRomScan holds the results of scanning local ROMs, keyed by platform fs_slug diff --git a/sync/save_sync.go b/sync/save_sync.go index 61249e1..e5ae9ef 100644 --- a/sync/save_sync.go +++ b/sync/save_sync.go @@ -395,8 +395,9 @@ func FindSaveSyncsFromScan(host romm.Host, config *internal.Config, scanLocal Lo // For uploads, key by local save path to avoid duplicates key = r.SaveFile.Path } else { - // For downloads, key by romID to avoid duplicate downloads - key = fmt.Sprintf("download_%d", r.RomID) + // For downloads, key by romID and baseName to allow different + // ROM files (same CRC32, different names) to download their own saves + key = fmt.Sprintf("download_%d_%s", r.RomID, baseName) } // Skip if already added (happens when multiple fs_slugs share same save dir) @@ -410,7 +411,7 @@ func FindSaveSyncsFromScan(host romm.Host, config *internal.Config, scanLocal Lo FSSlug: fsSlug, GameBase: baseName, Local: r.SaveFile, - Remote: r.lastRemoteSave(), + Remote: r.lastRemoteSaveForBaseName(baseName), Action: action, } } diff --git a/ui/status_bar.go b/ui/status_bar.go index b9f0fc0..9612b56 100644 --- a/ui/status_bar.go +++ b/ui/status_bar.go @@ -2,18 +2,13 @@ package ui import ( gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool" - icons "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool/constants" ) var defaultStatusBar = gaba.StatusBarOptions{ Enabled: true, ShowTime: true, TimeFormat: gaba.TimeFormat24Hour, - Icons: []gaba.StatusBarIcon{ - { - Text: icons.WiFi, - }, - }, + Icons: []gaba.StatusBarIcon{}, } func StatusBar() gaba.StatusBarOptions { From fb5c028784d1b5b3eeb554abefcd19cb73b2ab6c Mon Sep 17 00:00:00 2001 From: "Brandon T. Kowalski" Date: Sat, 10 Jan 2026 17:08:33 -0500 Subject: [PATCH 3/3] Missing go sum... --- go.sum | 2 ++ 1 file changed, 2 insertions(+) diff --git a/go.sum b/go.sum index 7c74f0d..562c334 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/BrandonKowalski/gabagool/v2 v2.5.2 h1:w835LTPQ/EscGDdCgrfTDjoGTQkIkhb github.com/BrandonKowalski/gabagool/v2 v2.5.2/go.mod h1:GMra4TXqVze081BlRc4PYCwEk+kP7vh3h9X5PHOAjPg= github.com/BrandonKowalski/gabagool/v2 v2.5.3 h1:pQ7oVvKYbD1P9eIKXao/3A2YnjvB/8PVgby9ViPETkM= github.com/BrandonKowalski/gabagool/v2 v2.5.3/go.mod h1:GMra4TXqVze081BlRc4PYCwEk+kP7vh3h9X5PHOAjPg= +github.com/BrandonKowalski/gabagool/v2 v2.6.0 h1:ciimVltlZR2y5B03FmQZUdWSj4NL5rGUwDZrnasa2zk= +github.com/BrandonKowalski/gabagool/v2 v2.6.0/go.mod h1:GMra4TXqVze081BlRc4PYCwEk+kP7vh3h9X5PHOAjPg= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/UncleJunVIP/certifiable v1.2.0 h1:xGqpc7U9FafWPqJ/sQreOQRPj8fv0Y4x9EBdfa2ic3Q=