Add sync engine and ROM scanning

- sync/models.go: action types and sync plan models
- sync/flow.go: core sync flow with conflict detection
- sync/flow_test.go: tests for sync action determination
- sync/roms.go: rewrite to resolve local ROMs against cache
- cfw/roms.go: local ROM filesystem scanning (moved from sync)
- cfw/saves.go: add GetSaveDirectory helper
- tools/save-sync-dry-run/: dry-run testing tool
This commit is contained in:
Brandon T. Kowalski
2026-02-22 12:16:21 -05:00
parent ee2113faab
commit 12d387cfdd
7 changed files with 1570 additions and 186 deletions
+197
View File
@@ -0,0 +1,197 @@
package cfw
import (
"grout/internal/fileutil"
"grout/internal/stringutil"
"os"
"path/filepath"
"strings"
gosync "sync"
gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool"
)
// RomScanConfig provides configuration needed for ROM scanning.
// Implemented by internal.Config to avoid circular imports.
type RomScanConfig interface {
GetDirectoryMapping(fsSlug string) (relativePath string, ok bool)
ResolveRommFSSlug(cfwKey string) string
}
type LocalRomFile struct {
RomID int
RomName string
FSSlug string
FileName string
FilePath string
}
type LocalRomScan map[string][]LocalRomFile
func ScanRoms(config RomScanConfig) LocalRomScan {
logger := gaba.GetLogger()
result := make(map[string][]LocalRomFile)
currentCFW := GetCFW()
platformMap := GetPlatformMap(currentCFW)
if platformMap == nil {
logger.Warn("Unknown CFW, cannot scan ROMs")
return result
}
baseRomDir := GetRomDirectory()
logger.Debug("Starting ROM scan", "baseDir", baseRomDir)
result = scanRomsByPlatform(baseRomDir, platformMap, config, currentCFW)
totalRoms := 0
for _, roms := range result {
totalRoms += len(roms)
}
logger.Debug("Completed ROM scan", "platforms", len(result), "totalRoms", totalRoms)
return result
}
func scanRomsByPlatform(baseRomDir string, platformMap map[string][]string, config RomScanConfig, currentCFW CFW) map[string][]LocalRomFile {
logger := gaba.GetLogger()
result := make(map[string][]LocalRomFile)
if currentCFW == NextUI {
entries, err := os.ReadDir(baseRomDir)
if err != nil {
logger.Error("Failed to read ROM directory", "path", baseRomDir, "error", err)
return result
}
for _, entry := range entries {
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
dirName := entry.Name()
tag := stringutil.ParseTag(dirName)
if tag == "" {
logger.Debug("No tag found in directory", "dir", dirName)
continue
}
for fsSlug, cfwDirs := range platformMap {
matched := false
for _, cfwDir := range cfwDirs {
cfwTag := stringutil.ParseTag(cfwDir)
if cfwTag == tag {
matched = true
break
}
}
if !matched {
if config != nil {
if relPath, ok := config.GetDirectoryMapping(fsSlug); ok {
if stringutil.ParseTag(relPath) == tag {
matched = true
}
}
}
}
if matched {
romDir := filepath.Join(baseRomDir, dirName)
roms := scanRomDirectory(fsSlug, romDir)
if len(roms) > 0 {
result[fsSlug] = append(result[fsSlug], roms...)
logger.Debug("Found ROMs for platform", "fsSlug", fsSlug, "dir", dirName, "count", len(roms))
}
}
}
}
} else {
type platformResult struct {
fsSlug string
roms []LocalRomFile
}
resultChan := make(chan platformResult, len(platformMap))
var wg gosync.WaitGroup
for fsSlug := range platformMap {
wg.Add(1)
go func(s string) {
defer wg.Done()
rommFSSlug := s
if config != nil {
rommFSSlug = config.ResolveRommFSSlug(s)
}
romFolderName := ""
if config != nil {
if relPath, ok := config.GetDirectoryMapping(rommFSSlug); ok && relPath != "" {
romFolderName = relPath
}
}
if romFolderName == "" {
romFolderName = RomMFSSlugToCFW(s)
}
if romFolderName == "" {
logger.Debug("No ROM folder mapping for fsSlug", "fsSlug", rommFSSlug)
resultChan <- platformResult{fsSlug: rommFSSlug, roms: nil}
return
}
romDir := filepath.Join(baseRomDir, romFolderName)
if !fileutil.FileExists(romDir) {
resultChan <- platformResult{fsSlug: rommFSSlug, roms: nil}
return
}
roms := scanRomDirectory(rommFSSlug, romDir)
resultChan <- platformResult{fsSlug: rommFSSlug, roms: roms}
if len(roms) > 0 {
logger.Debug("Found ROMs for platform", "fsSlug", rommFSSlug, "count", len(roms))
}
}(fsSlug)
}
go func() {
wg.Wait()
close(resultChan)
}()
for pr := range resultChan {
if len(pr.roms) > 0 {
result[pr.fsSlug] = pr.roms
}
}
}
return result
}
func scanRomDirectory(fsSlug, romDir string) []LocalRomFile {
logger := gaba.GetLogger()
var roms []LocalRomFile
entries, err := os.ReadDir(romDir)
if err != nil {
logger.Error("Failed to read ROM directory", "path", romDir, "error", err)
return roms
}
visibleFiles := fileutil.FilterVisibleFiles(entries)
for _, entry := range visibleFiles {
rom := LocalRomFile{
FSSlug: fsSlug,
FileName: entry.Name(),
FilePath: filepath.Join(romDir, entry.Name()),
}
roms = append(roms, rom)
}
return roms
}
+17
View File
@@ -7,6 +7,7 @@ import (
"grout/cfw/nextui"
"grout/cfw/rocknix"
"grout/cfw/spruce"
"path/filepath"
)
// EmulatorFolderMap returns the emulator/save directory mapping for the given CFW.
@@ -37,3 +38,19 @@ func EmulatorFoldersForFSSlug(fsSlug string) []string {
}
return saveDirectoriesMap[fsSlug]
}
// GetSaveDirectory returns the full save directory path for a given filesystem slug.
// Falls back to the first emulator folder if no match is found.
func GetSaveDirectory(fsSlug string) string {
baseSavePath := BaseSavePath()
if baseSavePath == "" {
return ""
}
emulatorDirs := EmulatorFoldersForFSSlug(fsSlug)
if len(emulatorDirs) == 0 {
return ""
}
return filepath.Join(baseSavePath, emulatorDirs[0])
}
+586
View File
@@ -0,0 +1,586 @@
package sync
import (
"fmt"
"grout/cache"
"grout/cfw"
"grout/internal"
"grout/internal/fileutil"
"grout/romm"
"grout/version"
"os"
"path/filepath"
"strings"
gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool"
)
func ResolveSaveSync(client *romm.Client, config *internal.Config, deviceID string) ([]SyncItem, error) {
logger := gaba.GetLogger()
logger.Debug("Starting save sync resolve", "deviceID", deviceID)
localSaves := ScanSaves(config)
logger.Debug("Scanned local saves", "count", len(localSaves))
remoteSaves := FetchRemoteSaves(client, localSaves, deviceID)
logger.Debug("Fetched remote saves", "count", len(remoteSaves))
newSaves := LocalSavesWithoutRemote(localSaves, remoteSaves)
logger.Debug("Local saves without remote", "count", len(newSaves))
var allItems []SyncItem
allItems = append(allItems, NewSaveUploadActions(newSaves)...)
allItems = append(allItems, DetermineActions(localSaves, remoteSaves, deviceID, config)...)
remoteOnly := DiscoverRemoteSaves(client, config, localSaves, deviceID)
allItems = append(allItems, remoteOnly...)
logger.Debug("Total sync items resolved", "count", len(allItems))
return allItems, nil
}
func ExecuteSaveSync(client *romm.Client, config *internal.Config, deviceID string, items []SyncItem, progressFn func(current, total int)) SyncReport {
report := ExecuteActions(client, config, deviceID, items, progressFn)
cm := cache.GetCacheManager()
if cm != nil {
for _, item := range report.Items {
if item.Action == ActionSkip || item.Action == ActionConflict {
continue
}
fileName := item.LocalSave.FileName
if fileName == "" && item.RemoteSave != nil {
fileName = item.RemoteSave.FileName
}
record := cache.SaveSyncRecord{
RomID: item.LocalSave.RomID,
RomName: item.LocalSave.RomName,
Action: item.Action.String(),
DeviceID: deviceID,
FileName: fileName,
}
if item.RemoteSave != nil {
record.SaveID = item.RemoteSave.ID
}
cm.RecordSaveSync(record)
}
}
return report
}
func RegisterDevice(client *romm.Client, name string) (romm.Device, error) {
return client.RegisterDevice(romm.RegisterDeviceRequest{
Name: name,
Platform: string(cfw.GetCFW()),
Client: "grout",
ClientVersion: version.Get().Version,
})
}
func ScanSaves(config *internal.Config) []LocalSave {
logger := gaba.GetLogger()
currentCFW := cfw.GetCFW()
baseSavePath := cfw.BaseSavePath()
if baseSavePath == "" {
logger.Error("No save path for current CFW")
return nil
}
emulatorMap := cfw.EmulatorFolderMap(currentCFW)
if emulatorMap == nil {
logger.Error("No emulator folder map for current CFW")
return nil
}
cm := cache.GetCacheManager()
if cm == nil {
logger.Error("Cache manager not available for save scan")
return nil
}
var saves []LocalSave
logger.Debug("Starting save scan", "baseSavePath", baseSavePath, "platformCount", len(emulatorMap))
for fsSlug, emulatorDirs := range emulatorMap {
rommFSSlug := fsSlug
if config != nil {
rommFSSlug = config.ResolveRommFSSlug(fsSlug)
}
for _, emuDir := range emulatorDirs {
saveDir := filepath.Join(baseSavePath, emuDir)
if _, err := os.Stat(saveDir); os.IsNotExist(err) {
continue
}
entries, err := os.ReadDir(saveDir)
if err != nil {
logger.Error("Could not read save directory", "path", saveDir, "error", err)
continue
}
saveFileCount := 0
for _, entry := range entries {
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
ext := strings.ToLower(filepath.Ext(entry.Name()))
if ext != ".sav" && ext != ".srm" {
continue
}
saveFileCount++
nameNoExt := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name()))
rom, err := cm.GetRomByFSLookup(rommFSSlug, nameNoExt)
if err != nil {
logger.Debug("No cache match for save file", "file", entry.Name(), "fsSlug", rommFSSlug, "nameNoExt", nameNoExt)
continue
}
logger.Debug("Matched save to ROM", "file", entry.Name(), "romID", rom.ID, "romName", rom.Name)
saves = append(saves, LocalSave{
RomID: rom.ID,
RomName: rom.Name,
FSSlug: rommFSSlug,
FileName: entry.Name(),
FilePath: filepath.Join(saveDir, entry.Name()),
EmulatorDir: emuDir,
})
}
if saveFileCount > 0 {
logger.Debug("Scanned emulator directory", "path", saveDir, "saveFiles", saveFileCount)
}
}
}
logger.Debug("Completed save scan", "matched", len(saves))
return saves
}
// FetchRemoteSaves fetches saves with device_id for each ROM that has a local save.
// This returns full save data including device_syncs for conflict detection.
func FetchRemoteSaves(client *romm.Client, localSaves []LocalSave, deviceID string) map[int][]romm.Save {
logger := gaba.GetLogger()
result := make(map[int][]romm.Save)
seen := make(map[int]bool)
for _, ls := range localSaves {
seen[ls.RomID] = true
}
logger.Debug("Fetching remote saves", "romCount", len(seen))
for romID := range seen {
saves, err := client.GetSaves(romm.SaveQuery{RomID: romID, DeviceID: deviceID})
if err != nil {
logger.Error("Failed to get saves", "romID", romID, "error", err)
continue
}
if len(saves) > 0 {
result[romID] = saves
logger.Debug("Fetched remote saves", "romID", romID, "count", len(saves))
}
}
return result
}
func LocalSavesWithoutRemote(localSaves []LocalSave, remoteSaves map[int][]romm.Save) []LocalSave {
var filtered []LocalSave
for _, ls := range localSaves {
if _, ok := remoteSaves[ls.RomID]; !ok {
filtered = append(filtered, ls)
}
}
return filtered
}
func NewSaveUploadActions(saves []LocalSave) []SyncItem {
var items []SyncItem
for _, ls := range saves {
items = append(items, SyncItem{
LocalSave: ls,
Action: ActionUpload,
})
}
return items
}
func DetermineActions(localSaves []LocalSave, remoteSaves map[int][]romm.Save, deviceID string, config *internal.Config) []SyncItem {
logger := gaba.GetLogger()
var items []SyncItem
for _, ls := range localSaves {
saves, ok := remoteSaves[ls.RomID]
if !ok {
continue
}
preferredSlot := "default"
if config != nil {
preferredSlot = config.GetSlotPreference(ls.RomID)
}
remoteSave := selectSaveForSync(saves, preferredSlot)
action := determineAction(remoteSave, &ls, deviceID)
logger.Debug("Determined sync action",
"romID", ls.RomID,
"romName", ls.RomName,
"action", action.String(),
)
items = append(items, SyncItem{
LocalSave: ls,
RemoteSave: remoteSave,
Action: action,
})
}
return items
}
func determineAction(remoteSave *romm.Save, localSave *LocalSave, deviceID string) SyncAction {
logger := gaba.GetLogger()
if remoteSave == nil {
logger.Debug("No remote save found, will upload", "romID", localSave.RomID)
return ActionUpload
}
localInfo, err := os.Stat(localSave.FilePath)
if err != nil {
logger.Debug("Cannot stat local save, will download", "path", localSave.FilePath, "error", err)
return ActionDownload
}
localMtime := localInfo.ModTime()
for _, ds := range remoteSave.DeviceSyncs {
if ds.DeviceID == deviceID {
localChanged := localMtime.After(ds.LastSyncedAt)
remoteChanged := remoteSave.UpdatedAt.After(ds.LastSyncedAt)
if localChanged && remoteChanged {
logger.Debug("Both local and remote changed since last sync, conflict",
"romID", localSave.RomID, "localMtime", localMtime, "remoteUpdatedAt", remoteSave.UpdatedAt, "lastSyncedAt", ds.LastSyncedAt)
return ActionConflict
}
if ds.IsCurrent {
if localMtime.After(remoteSave.UpdatedAt) {
logger.Debug("Device current, local newer, will upload",
"romID", localSave.RomID, "localMtime", localMtime, "remoteUpdatedAt", remoteSave.UpdatedAt)
return ActionUpload
}
logger.Debug("Device current, local not newer, skipping",
"romID", localSave.RomID, "localMtime", localMtime, "remoteUpdatedAt", remoteSave.UpdatedAt)
return ActionSkip
}
logger.Debug("Device in sync list but not current, will download",
"romID", localSave.RomID, "deviceID", deviceID)
return ActionDownload
}
}
if localMtime.After(remoteSave.UpdatedAt) {
logger.Debug("Device not tracked, local newer, will upload",
"romID", localSave.RomID, "localMtime", localMtime, "remoteUpdatedAt", remoteSave.UpdatedAt)
return ActionUpload
}
if !localMtime.Before(remoteSave.UpdatedAt) {
logger.Debug("Device not tracked, mtime matches remote, skipping",
"romID", localSave.RomID, "localMtime", localMtime, "remoteUpdatedAt", remoteSave.UpdatedAt)
return ActionSkip
}
logger.Debug("Device not tracked, local older, will download",
"romID", localSave.RomID, "localMtime", localMtime, "remoteUpdatedAt", remoteSave.UpdatedAt)
return ActionDownload
}
// selectSaveForSync picks the latest save from the preferred slot.
// Falls back to the most recently updated save if the preferred slot has no saves.
func selectSaveForSync(saves []romm.Save, preferredSlot string) *romm.Save {
if len(saves) == 0 {
return nil
}
// Find the latest save in the preferred slot
var best *romm.Save
for i, s := range saves {
slotName := "default"
if s.Slot != nil {
slotName = *s.Slot
}
if slotName != preferredSlot {
continue
}
if best == nil || s.UpdatedAt.After(best.UpdatedAt) {
best = &saves[i]
}
}
if best != nil {
return best
}
// Fallback: latest save across all slots
best = &saves[0]
for i := 1; i < len(saves); i++ {
if saves[i].UpdatedAt.After(best.UpdatedAt) {
best = &saves[i]
}
}
return best
}
func ExecuteActions(client *romm.Client, config *internal.Config, deviceID string, items []SyncItem, progressFn func(current, total int)) SyncReport {
logger := gaba.GetLogger()
report := SyncReport{}
actionable := 0
for _, item := range items {
if item.Action != ActionSkip && item.Action != ActionConflict {
actionable++
}
}
logger.Debug("Executing sync actions", "total", len(items), "actionable", actionable)
current := 0
for i := range items {
item := &items[i]
switch item.Action {
case ActionUpload:
current++
if progressFn != nil {
progressFn(current, actionable)
}
if upload(client, deviceID, item) {
item.Success = true
report.Uploaded++
} else {
report.Errors++
}
case ActionDownload:
current++
if progressFn != nil {
progressFn(current, actionable)
}
if download(client, config, deviceID, item) {
item.Success = true
report.Downloaded++
} else {
report.Errors++
}
case ActionConflict:
report.Conflicts++
default:
report.Skipped++
}
}
report.Items = items
logger.Debug("Sync execution complete", "uploaded", report.Uploaded, "downloaded", report.Downloaded, "skipped", report.Skipped, "errors", report.Errors)
return report
}
func upload(client *romm.Client, deviceID string, item *SyncItem) bool {
logger := gaba.GetLogger()
logger.Debug("Uploading save", "romID", item.LocalSave.RomID, "romName", item.LocalSave.RomName, "file", item.LocalSave.FilePath)
slot := "default"
if item.RemoteSave != nil && item.RemoteSave.Slot != nil {
slot = *item.RemoteSave.Slot
}
emulator := filepath.Base(item.LocalSave.EmulatorDir)
query := romm.UploadSaveQuery{
RomID: item.LocalSave.RomID,
DeviceID: deviceID,
Emulator: emulator,
Slot: slot,
Overwrite: item.ForceOverwrite,
}
uploadedSave, err := client.UploadSaveWithQuery(query, item.LocalSave.FilePath)
if err != nil {
logger.Error("Failed to upload save", "romID", item.LocalSave.RomID, "romName", item.LocalSave.RomName, "error", err)
return false
}
if err := os.Chtimes(item.LocalSave.FilePath, uploadedSave.UpdatedAt, uploadedSave.UpdatedAt); err != nil {
logger.Warn("Failed to set save file mtime after upload", "path", item.LocalSave.FilePath, "error", err)
}
logger.Debug("Upload successful", "romID", item.LocalSave.RomID, "romName", item.LocalSave.RomName)
return true
}
func download(client *romm.Client, config *internal.Config, deviceID string, item *SyncItem) bool {
logger := gaba.GetLogger()
if item.RemoteSave == nil {
logger.Error("No remote save to download", "romID", item.LocalSave.RomID)
return false
}
logger.Debug("Downloading save", "romID", item.LocalSave.RomID, "romName", item.LocalSave.RomName, "saveID", item.RemoteSave.ID)
if item.LocalSave.FilePath != "" {
if info, err := os.Stat(item.LocalSave.FilePath); err == nil {
backupDir := filepath.Join(filepath.Dir(item.LocalSave.FilePath), ".backup")
ext := filepath.Ext(item.LocalSave.FileName)
base := strings.TrimSuffix(item.LocalSave.FileName, ext)
timestamp := info.ModTime().Format("2006-01-02 15-04-05")
backupPath := filepath.Join(backupDir, fmt.Sprintf("%s [%s]%s", base, timestamp, ext))
if err := os.MkdirAll(backupDir, 0755); err != nil {
logger.Warn("Failed to create backup directory", "path", backupDir, "error", err)
} else if err := fileutil.CopyFile(item.LocalSave.FilePath, backupPath); err != nil {
logger.Warn("Failed to backup save before download", "path", item.LocalSave.FilePath, "error", err)
} else {
logger.Debug("Backed up save before download", "backup", backupPath)
}
}
}
data, err := client.DownloadSaveByID(item.RemoteSave.ID, deviceID, true)
if err != nil {
logger.Error("Failed to download save", "romID", item.LocalSave.RomID, "saveID", item.RemoteSave.ID, "error", err)
return false
}
savePath := item.LocalSave.FilePath
if savePath == "" {
saveDir := ResolveSaveDirectory(item.LocalSave.FSSlug, config)
if saveDir != "" {
fileName := item.RemoteSave.FileName
if item.LocalSave.RomFileName != "" {
romNameNoExt := strings.TrimSuffix(item.LocalSave.RomFileName, filepath.Ext(item.LocalSave.RomFileName))
fileName = romNameNoExt + "." + item.RemoteSave.FileExtension
}
savePath = filepath.Join(saveDir, fileName)
}
}
if savePath == "" {
logger.Error("Could not determine save path", "romID", item.LocalSave.RomID, "fsSlug", item.LocalSave.FSSlug)
return false
}
if err := os.MkdirAll(filepath.Dir(savePath), 0755); err != nil {
logger.Error("Failed to create save directory", "path", filepath.Dir(savePath), "error", err)
return false
}
if err := os.WriteFile(savePath, data, 0644); err != nil {
logger.Error("Failed to write save file", "path", savePath, "error", err)
return false
}
if err := os.Chtimes(savePath, item.RemoteSave.UpdatedAt, item.RemoteSave.UpdatedAt); err != nil {
logger.Warn("Failed to set save file mtime", "path", savePath, "error", err)
}
if err := client.ConfirmSaveDownloaded(item.RemoteSave.ID, deviceID); err != nil {
logger.Warn("Failed to confirm save download", "saveID", item.RemoteSave.ID, "error", err)
}
logger.Debug("Download successful", "romID", item.LocalSave.RomID, "romName", item.LocalSave.RomName, "path", savePath)
return true
}
func DiscoverRemoteSaves(client *romm.Client, config *internal.Config, localSaves []LocalSave, deviceID string) []SyncItem {
logger := gaba.GetLogger()
scan := cfw.ScanRoms(config)
resolved := ResolveLocalRoms(scan)
if len(resolved) == 0 {
return nil
}
coveredRomIDs := make(map[int]bool)
for _, ls := range localSaves {
coveredRomIDs[ls.RomID] = true
}
var uncoveredRomIDs []int
for romID := range resolved {
if !coveredRomIDs[romID] {
uncoveredRomIDs = append(uncoveredRomIDs, romID)
}
}
if len(uncoveredRomIDs) == 0 {
logger.Debug("All local ROMs already have local saves")
return nil
}
logger.Debug("Checking remote saves for ROMs without local saves", "count", len(uncoveredRomIDs))
var items []SyncItem
for _, romID := range uncoveredRomIDs {
saves, err := client.GetSaves(romm.SaveQuery{RomID: romID, DeviceID: deviceID})
if err != nil {
logger.Debug("Failed to get saves", "romID", romID, "error", err)
continue
}
preferredSlot := "default"
if config != nil {
preferredSlot = config.GetSlotPreference(romID)
}
remoteSave := selectSaveForSync(saves, preferredSlot)
if remoteSave == nil {
continue
}
rom := resolved[romID]
logger.Debug("Found remote save for ROM without local save",
"romID", romID, "romName", rom.RomName, "saveFile", remoteSave.FileName)
items = append(items, SyncItem{
LocalSave: LocalSave{
RomID: romID,
RomName: rom.RomName,
FSSlug: rom.FSSlug,
RomFileName: rom.FileName,
},
RemoteSave: remoteSave,
Action: ActionDownload,
})
}
logger.Debug("Remote-only saves to download", "count", len(items))
return items
}
func ResolveSaveDirectory(fsSlug string, config *internal.Config) string {
if config != nil && config.SaveDirectoryMappings != nil {
if mapped, ok := config.SaveDirectoryMappings[fsSlug]; ok && mapped != "" {
baseSavePath := cfw.BaseSavePath()
if baseSavePath != "" {
return filepath.Join(baseSavePath, mapped)
}
}
}
effectiveFSSlug := fsSlug
if config != nil {
effectiveFSSlug = config.ResolveFSSlug(fsSlug)
}
return cfw.GetSaveDirectory(effectiveFSSlug)
}
+501
View File
@@ -0,0 +1,501 @@
package sync
import (
"grout/internal"
"grout/romm"
"os"
"path/filepath"
"testing"
"time"
)
func makeTempSave(t *testing.T, mtime time.Time) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "test.sav")
if err := os.WriteFile(path, []byte("save"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(path, mtime, mtime); err != nil {
t.Fatal(err)
}
return path
}
func ptrStr(s string) *string { return &s }
// --- determineAction tests ---
func TestDetermineAction_NoRemoteSave(t *testing.T) {
now := time.Now()
path := makeTempSave(t, now)
ls := &LocalSave{RomID: 1, FilePath: path}
action := determineAction(nil, ls, "device-1")
if action != ActionUpload {
t.Errorf("expected ActionUpload, got %s", action)
}
}
func TestDetermineAction_LocalFileUnreadable(t *testing.T) {
ls := &LocalSave{RomID: 1, FilePath: "/nonexistent/path/save.sav"}
remote := &romm.Save{ID: 10, UpdatedAt: time.Now()}
action := determineAction(remote, ls, "device-1")
if action != ActionDownload {
t.Errorf("expected ActionDownload, got %s", action)
}
}
func TestDetermineAction_DeviceCurrent_BothChanged(t *testing.T) {
lastSync := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
localMtime := lastSync.Add(1 * time.Hour)
remoteUpdated := lastSync.Add(2 * time.Hour)
path := makeTempSave(t, localMtime)
ls := &LocalSave{RomID: 1, FilePath: path}
remote := &romm.Save{
ID: 10,
UpdatedAt: remoteUpdated,
DeviceSyncs: []romm.DeviceSaveSync{
{DeviceID: "device-1", IsCurrent: true, LastSyncedAt: lastSync},
},
}
action := determineAction(remote, ls, "device-1")
if action != ActionConflict {
t.Errorf("expected ActionConflict, got %s", action)
}
}
func TestDetermineAction_DeviceCurrent_LocalNewer(t *testing.T) {
remoteUpdated := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
lastSync := remoteUpdated // remote hasn't changed since last sync
localMtime := remoteUpdated.Add(1 * time.Hour)
path := makeTempSave(t, localMtime)
ls := &LocalSave{RomID: 1, FilePath: path}
remote := &romm.Save{
ID: 10,
UpdatedAt: remoteUpdated,
DeviceSyncs: []romm.DeviceSaveSync{
{DeviceID: "device-1", IsCurrent: true, LastSyncedAt: lastSync},
},
}
action := determineAction(remote, ls, "device-1")
if action != ActionUpload {
t.Errorf("expected ActionUpload, got %s", action)
}
}
func TestDetermineAction_DeviceCurrent_RemoteNewerOrEqual(t *testing.T) {
lastSync := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
remoteUpdated := lastSync.Add(1 * time.Hour)
localMtime := lastSync.Add(-1 * time.Hour) // local older than both
path := makeTempSave(t, localMtime)
ls := &LocalSave{RomID: 1, FilePath: path}
remote := &romm.Save{
ID: 10,
UpdatedAt: remoteUpdated,
DeviceSyncs: []romm.DeviceSaveSync{
{DeviceID: "device-1", IsCurrent: true, LastSyncedAt: lastSync},
},
}
action := determineAction(remote, ls, "device-1")
if action != ActionSkip {
t.Errorf("expected ActionSkip, got %s", action)
}
}
func TestDetermineAction_DeviceTrackedNotCurrent_BothChanged(t *testing.T) {
lastSync := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
localMtime := lastSync.Add(1 * time.Hour)
remoteUpdated := lastSync.Add(2 * time.Hour)
path := makeTempSave(t, localMtime)
ls := &LocalSave{RomID: 1, FilePath: path}
remote := &romm.Save{
ID: 10,
UpdatedAt: remoteUpdated,
DeviceSyncs: []romm.DeviceSaveSync{
{DeviceID: "device-1", IsCurrent: false, LastSyncedAt: lastSync},
},
}
action := determineAction(remote, ls, "device-1")
if action != ActionConflict {
t.Errorf("expected ActionConflict, got %s", action)
}
}
func TestDetermineAction_DeviceTrackedNotCurrent_OnlyRemoteChanged(t *testing.T) {
lastSync := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
localMtime := lastSync.Add(-1 * time.Hour)
remoteUpdated := lastSync.Add(2 * time.Hour)
path := makeTempSave(t, localMtime)
ls := &LocalSave{RomID: 1, FilePath: path}
remote := &romm.Save{
ID: 10,
UpdatedAt: remoteUpdated,
DeviceSyncs: []romm.DeviceSaveSync{
{DeviceID: "device-1", IsCurrent: false, LastSyncedAt: lastSync},
},
}
action := determineAction(remote, ls, "device-1")
if action != ActionDownload {
t.Errorf("expected ActionDownload, got %s", action)
}
}
func TestDetermineAction_DeviceNotTracked_LocalNewer(t *testing.T) {
remoteUpdated := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
localMtime := remoteUpdated.Add(1 * time.Hour)
path := makeTempSave(t, localMtime)
ls := &LocalSave{RomID: 1, FilePath: path}
remote := &romm.Save{
ID: 10,
UpdatedAt: remoteUpdated,
DeviceSyncs: []romm.DeviceSaveSync{}, // empty - device not tracked
}
action := determineAction(remote, ls, "device-1")
if action != ActionUpload {
t.Errorf("expected ActionUpload, got %s", action)
}
}
func TestDetermineAction_DeviceNotTracked_SameMtime(t *testing.T) {
mtime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
path := makeTempSave(t, mtime)
ls := &LocalSave{RomID: 1, FilePath: path}
remote := &romm.Save{
ID: 10,
UpdatedAt: mtime,
DeviceSyncs: []romm.DeviceSaveSync{},
}
action := determineAction(remote, ls, "device-1")
if action != ActionSkip {
t.Errorf("expected ActionSkip, got %s", action)
}
}
func TestDetermineAction_DeviceNotTracked_LocalOlder(t *testing.T) {
remoteUpdated := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC)
localMtime := remoteUpdated.Add(-1 * time.Hour)
path := makeTempSave(t, localMtime)
ls := &LocalSave{RomID: 1, FilePath: path}
remote := &romm.Save{
ID: 10,
UpdatedAt: remoteUpdated,
DeviceSyncs: []romm.DeviceSaveSync{},
}
action := determineAction(remote, ls, "device-1")
if action != ActionDownload {
t.Errorf("expected ActionDownload, got %s", action)
}
}
func TestDetermineAction_OtherDeviceCurrent(t *testing.T) {
remoteUpdated := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC)
localMtime := remoteUpdated.Add(-1 * time.Hour)
path := makeTempSave(t, localMtime)
ls := &LocalSave{RomID: 1, FilePath: path}
remote := &romm.Save{
ID: 10,
UpdatedAt: remoteUpdated,
DeviceSyncs: []romm.DeviceSaveSync{
{DeviceID: "other-device", IsCurrent: true, LastSyncedAt: remoteUpdated},
},
}
action := determineAction(remote, ls, "device-1")
if action != ActionDownload {
t.Errorf("expected ActionDownload, got %s", action)
}
}
// --- DetermineActions tests ---
func TestDetermineActions_SkipsSavesWithoutRemote(t *testing.T) {
now := time.Now()
path := makeTempSave(t, now)
localSaves := []LocalSave{
{RomID: 1, FilePath: path},
{RomID: 2, FilePath: path},
}
remoteSaves := map[int][]romm.Save{} // no remote saves
items := DetermineActions(localSaves, remoteSaves, "device-1", nil)
if len(items) != 0 {
t.Errorf("expected 0 items, got %d", len(items))
}
}
func TestDetermineActions_EmptyInputs(t *testing.T) {
items := DetermineActions(nil, nil, "device-1", nil)
if len(items) != 0 {
t.Errorf("expected 0 items, got %d", len(items))
}
}
func TestDetermineActions_ReturnsCorrectActions(t *testing.T) {
remoteUpdated := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
// ROM 1: local newer, no device tracking → upload
uploadPath := makeTempSave(t, remoteUpdated.Add(1*time.Hour))
// ROM 2: local older, no device tracking → download
downloadPath := makeTempSave(t, remoteUpdated.Add(-1*time.Hour))
localSaves := []LocalSave{
{RomID: 1, RomName: "Mario", FilePath: uploadPath},
{RomID: 2, RomName: "Zelda", FilePath: downloadPath},
}
remoteSaves := map[int][]romm.Save{
1: {{ID: 10, RomID: 1, UpdatedAt: remoteUpdated, Slot: ptrStr("default")}},
2: {{ID: 20, RomID: 2, UpdatedAt: remoteUpdated, Slot: ptrStr("default")}},
}
items := DetermineActions(localSaves, remoteSaves, "device-1", nil)
if len(items) != 2 {
t.Fatalf("expected 2 items, got %d", len(items))
}
actionsByRom := map[int]SyncAction{}
for _, item := range items {
actionsByRom[item.LocalSave.RomID] = item.Action
}
if actionsByRom[1] != ActionUpload {
t.Errorf("ROM 1: expected ActionUpload, got %s", actionsByRom[1])
}
if actionsByRom[2] != ActionDownload {
t.Errorf("ROM 2: expected ActionDownload, got %s", actionsByRom[2])
}
}
func TestDetermineActions_PopulatesRemoteSave(t *testing.T) {
remoteUpdated := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
path := makeTempSave(t, remoteUpdated.Add(1*time.Hour))
localSaves := []LocalSave{
{RomID: 1, RomName: "Mario", FilePath: path},
}
remoteSaves := map[int][]romm.Save{
1: {{ID: 10, RomID: 1, UpdatedAt: remoteUpdated, Slot: ptrStr("default")}},
}
items := DetermineActions(localSaves, remoteSaves, "device-1", nil)
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
if items[0].RemoteSave == nil {
t.Fatal("expected RemoteSave to be set")
}
if items[0].RemoteSave.ID != 10 {
t.Errorf("expected RemoteSave.ID 10, got %d", items[0].RemoteSave.ID)
}
}
func TestDetermineActions_NoRemoteSavesForRom(t *testing.T) {
path := makeTempSave(t, time.Now())
localSaves := []LocalSave{
{RomID: 1, FilePath: path},
}
remoteSaves := map[int][]romm.Save{
1: {}, // empty saves list
}
items := DetermineActions(localSaves, remoteSaves, "device-1", nil)
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
// No remote save (empty list) → upload
if items[0].Action != ActionUpload {
t.Errorf("expected ActionUpload for empty saves, got %s", items[0].Action)
}
}
// --- selectSaveForSync tests ---
func TestSelectSaveForSync_EmptySaves(t *testing.T) {
result := selectSaveForSync(nil, "default")
if result != nil {
t.Errorf("expected nil for empty saves, got %+v", result)
}
}
func TestSelectSaveForSync_ReturnsDefaultSlot(t *testing.T) {
saves := []romm.Save{
{ID: 42, Slot: ptrStr("default"), UpdatedAt: time.Now()},
{ID: 99, Slot: ptrStr("slot2"), UpdatedAt: time.Now()},
}
result := selectSaveForSync(saves, "default")
if result == nil {
t.Fatal("expected non-nil result")
}
if result.ID != 42 {
t.Errorf("expected ID 42, got %d", result.ID)
}
}
func TestSelectSaveForSync_ReturnsPreferredSlot(t *testing.T) {
saves := []romm.Save{
{ID: 42, Slot: ptrStr("default"), UpdatedAt: time.Now()},
{ID: 99, Slot: ptrStr("quicksave"), UpdatedAt: time.Now()},
}
result := selectSaveForSync(saves, "quicksave")
if result == nil {
t.Fatal("expected non-nil result")
}
if result.ID != 99 {
t.Errorf("expected ID 99, got %d", result.ID)
}
}
func TestSelectSaveForSync_PicksLatestInSlot(t *testing.T) {
older := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
newer := older.Add(1 * time.Hour)
saves := []romm.Save{
{ID: 10, Slot: ptrStr("default"), UpdatedAt: older},
{ID: 20, Slot: ptrStr("default"), UpdatedAt: newer},
}
result := selectSaveForSync(saves, "default")
if result == nil {
t.Fatal("expected non-nil result")
}
if result.ID != 20 {
t.Errorf("expected ID 20 (latest), got %d", result.ID)
}
}
func TestSelectSaveForSync_FallsBackToLatest(t *testing.T) {
saves := []romm.Save{
{ID: 42, Slot: ptrStr("default"), UpdatedAt: time.Now()},
}
result := selectSaveForSync(saves, "nonexistent")
if result == nil {
t.Fatal("expected non-nil result")
}
if result.ID != 42 {
t.Errorf("expected ID 42 (fallback to latest), got %d", result.ID)
}
}
func TestSelectSaveForSync_NilSlotTreatedAsDefault(t *testing.T) {
saves := []romm.Save{
{ID: 42, Slot: nil, UpdatedAt: time.Now()},
}
result := selectSaveForSync(saves, "default")
if result == nil {
t.Fatal("expected non-nil result")
}
if result.ID != 42 {
t.Errorf("expected ID 42, got %d", result.ID)
}
}
func TestDetermineActions_WithSlotPreference(t *testing.T) {
remoteUpdated := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
path := makeTempSave(t, remoteUpdated.Add(1*time.Hour))
localSaves := []LocalSave{
{RomID: 1, RomName: "Mario", FilePath: path},
}
remoteSaves := map[int][]romm.Save{
1: {
{ID: 10, RomID: 1, UpdatedAt: remoteUpdated, Slot: ptrStr("default")},
{ID: 20, RomID: 1, UpdatedAt: remoteUpdated, Slot: ptrStr("quicksave")},
},
}
config := &internal.Config{
SlotPreferences: map[string]string{"1": "quicksave"},
}
items := DetermineActions(localSaves, remoteSaves, "device-1", config)
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
if items[0].RemoteSave == nil {
t.Fatal("expected RemoteSave to be set")
}
if items[0].RemoteSave.ID != 20 {
t.Errorf("expected RemoteSave.ID 20 (quicksave slot), got %d", items[0].RemoteSave.ID)
}
}
// --- Helper function tests ---
func TestLocalSavesWithoutRemote(t *testing.T) {
saves := []LocalSave{
{RomID: 1, RomName: "Mario"},
{RomID: 2, RomName: "Zelda"},
{RomID: 3, RomName: "Metroid"},
}
remoteSaves := map[int][]romm.Save{
1: {{ID: 10}},
3: {{ID: 30}},
}
result := LocalSavesWithoutRemote(saves, remoteSaves)
if len(result) != 1 {
t.Fatalf("expected 1 save without remote, got %d", len(result))
}
if result[0].RomID != 2 {
t.Errorf("expected RomID 2, got %d", result[0].RomID)
}
}
func TestNewSaveUploadActions(t *testing.T) {
saves := []LocalSave{
{RomID: 1, RomName: "Mario"},
{RomID: 2, RomName: "Zelda"},
}
items := NewSaveUploadActions(saves)
if len(items) != 2 {
t.Fatalf("expected 2 items, got %d", len(items))
}
for _, item := range items {
if item.Action != ActionUpload {
t.Errorf("expected ActionUpload, got %s", item.Action)
}
}
}
+58
View File
@@ -0,0 +1,58 @@
package sync
import "grout/romm"
type LocalSave struct {
RomID int
RomName string
FSSlug string
FileName string
FilePath string
EmulatorDir string
RomFileName string
}
type SyncAction int
const (
ActionUpload SyncAction = iota
ActionDownload
ActionConflict
ActionSkip
)
func (a SyncAction) String() string {
switch a {
case ActionUpload:
return "upload"
case ActionDownload:
return "download"
case ActionConflict:
return "conflict"
case ActionSkip:
return "skip"
default:
return "unknown"
}
}
type SyncItem struct {
LocalSave LocalSave
RemoteSave *romm.Save
Action SyncAction
Success bool
ForceOverwrite bool
}
func (item *SyncItem) Resolve(action SyncAction) {
item.Action = action
}
type SyncReport struct {
Uploaded int
Downloaded int
Conflicts int
Skipped int
Errors int
Items []SyncItem
}
+19 -186
View File
@@ -1,205 +1,38 @@
package sync
import (
"grout/cache"
"grout/cfw"
"grout/internal"
"grout/internal/fileutil"
"grout/internal/stringutil"
"os"
"path/filepath"
"strings"
gosync "sync"
gaba "github.com/BrandonKowalski/gabagool/v2/pkg/gabagool"
)
type LocalRomFile struct {
RomID int
RomName string
FSSlug string
FileName string
FilePath string
}
func (lrf LocalRomFile) baseName() string {
return strings.TrimSuffix(lrf.FileName, filepath.Ext(lrf.FileName))
}
type LocalRomScan map[string][]LocalRomFile
func ScanRoms(config *internal.Config) LocalRomScan {
// ResolveLocalRoms scans local ROM files and resolves them against the cache
// to get ROM IDs. Returns a map of ROM ID to LocalRomFile for matched ROMs.
func ResolveLocalRoms(scan cfw.LocalRomScan) map[int]cfw.LocalRomFile {
logger := gaba.GetLogger()
result := make(map[string][]LocalRomFile)
currentCFW := cfw.GetCFW()
platformMap := cfw.GetPlatformMap(currentCFW)
if platformMap == nil {
logger.Warn("Unknown CFW, cannot scan ROMs")
return result
cm := cache.GetCacheManager()
if cm == nil {
logger.Error("Cache manager not available for ROM resolution")
return nil
}
baseRomDir := cfw.GetRomDirectory()
logger.Debug("Starting ROM scan", "baseDir", baseRomDir)
if config == nil {
config, _ = internal.LoadConfig()
}
result = scanRomsByPlatform(baseRomDir, platformMap, config, currentCFW)
totalRoms := 0
for _, roms := range result {
totalRoms += len(roms)
}
logger.Debug("Completed ROM scan", "platforms", len(result), "totalRoms", totalRoms)
return result
}
func scanRomsByPlatform(baseRomDir string, platformMap map[string][]string, config *internal.Config, currentCFW cfw.CFW) map[string][]LocalRomFile {
logger := gaba.GetLogger()
result := make(map[string][]LocalRomFile)
if currentCFW == cfw.NextUI {
entries, err := os.ReadDir(baseRomDir)
if err != nil {
logger.Error("Failed to read ROM directory", "path", baseRomDir, "error", err)
return result
}
for _, entry := range entries {
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
resolved := make(map[int]cfw.LocalRomFile)
for fsSlug, files := range scan {
for _, f := range files {
nameNoExt := strings.TrimSuffix(f.FileName, filepath.Ext(f.FileName))
rom, err := cm.GetRomByFSLookup(fsSlug, nameNoExt)
if err != nil {
continue
}
dirName := entry.Name()
tag := stringutil.ParseTag(dirName)
if tag == "" {
logger.Debug("No tag found in directory", "dir", dirName)
continue
}
for fsSlug, cfwDirs := range platformMap {
matched := false
for _, cfwDir := range cfwDirs {
cfwTag := stringutil.ParseTag(cfwDir)
if cfwTag == tag {
matched = true
break
}
}
if !matched {
if config != nil {
if mapping, ok := config.DirectoryMappings[fsSlug]; ok {
if stringutil.ParseTag(mapping.RelativePath) == tag {
matched = true
}
}
}
}
if matched {
romDir := filepath.Join(baseRomDir, dirName)
roms := scanRomDirectory(fsSlug, romDir)
if len(roms) > 0 {
result[fsSlug] = append(result[fsSlug], roms...)
logger.Debug("Found ROMs for platform", "fsSlug", fsSlug, "dir", dirName, "count", len(roms))
}
}
}
}
} else {
// Parallelize platform scanning for MuOS and Knulli
type platformResult struct {
fsSlug string
roms []LocalRomFile
}
resultChan := make(chan platformResult, len(platformMap))
var wg gosync.WaitGroup
for fsSlug := range platformMap {
wg.Add(1)
go func(s string) {
defer wg.Done()
// Resolve CFW platform key to RomM fs_slug via inverse platform binding
// e.g., CFW "sms" -> RomM "ms" when binding is {"ms": "sms"}
rommFSSlug := s
if config != nil {
rommFSSlug = config.ResolveRommFSSlug(s)
}
romFolderName := ""
if config != nil {
if mapping, ok := config.DirectoryMappings[rommFSSlug]; ok && mapping.RelativePath != "" {
romFolderName = mapping.RelativePath
}
}
if romFolderName == "" {
romFolderName = cfw.RomMFSSlugToCFW(s)
}
if romFolderName == "" {
logger.Debug("No ROM folder mapping for fsSlug", "fsSlug", rommFSSlug)
resultChan <- platformResult{fsSlug: rommFSSlug, roms: nil}
return
}
romDir := filepath.Join(baseRomDir, romFolderName)
if !fileutil.FileExists(romDir) {
resultChan <- platformResult{fsSlug: rommFSSlug, roms: nil}
return
}
roms := scanRomDirectory(rommFSSlug, romDir)
resultChan <- platformResult{fsSlug: rommFSSlug, roms: roms}
if len(roms) > 0 {
logger.Debug("Found ROMs for platform", "fsSlug", rommFSSlug, "count", len(roms))
}
}(fsSlug)
}
// Close channel once all goroutines complete
go func() {
wg.Wait()
close(resultChan)
}()
// Collect results from all platforms
for pr := range resultChan {
if len(pr.roms) > 0 {
result[pr.fsSlug] = pr.roms
}
f.RomID = rom.ID
f.RomName = rom.Name
resolved[rom.ID] = f
}
}
return result
}
func scanRomDirectory(fsSlug, romDir string) []LocalRomFile {
logger := gaba.GetLogger()
var roms []LocalRomFile
entries, err := os.ReadDir(romDir)
if err != nil {
logger.Error("Failed to read ROM directory", "path", romDir, "error", err)
return roms
}
visibleFiles := fileutil.FilterVisibleFiles(entries)
for _, entry := range visibleFiles {
rom := LocalRomFile{
FSSlug: fsSlug,
FileName: entry.Name(),
FilePath: filepath.Join(romDir, entry.Name()),
}
roms = append(roms, rom)
}
return roms
logger.Debug("Resolved local ROMs against cache", "matched", len(resolved))
return resolved
}
+192
View File
@@ -0,0 +1,192 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"grout/cache"
"grout/internal"
"grout/romm"
"grout/sync"
"os"
"strings"
"time"
)
var debug bool
func main() {
flag.BoolVar(&debug, "debug", false, "dump raw API responses for each save")
flag.Parse()
config, err := internal.LoadConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err)
os.Exit(1)
}
if len(config.Hosts) == 0 {
fmt.Fprintln(os.Stderr, "No hosts configured in config.json")
os.Exit(1)
}
host := config.Hosts[0]
if host.DeviceID == "" {
fmt.Fprintln(os.Stderr, "No device_id set on host. Register a device first.")
os.Exit(1)
}
if err := cache.InitCacheManager(host, config); err != nil {
fmt.Fprintf(os.Stderr, "Failed to init cache: %v\n", err)
os.Exit(1)
}
defer cache.GetCacheManager().Close()
client := romm.NewClientFromHost(host, config.ApiTimeout)
fmt.Printf("Host: %s\n", host.URL())
fmt.Printf("Device: %s\n", host.DeviceID)
fmt.Println()
localSaves := sync.ScanSaves(config)
fmt.Printf("Local saves found: %d\n", len(localSaves))
remoteSaves := sync.FetchRemoteSaves(client, localSaves, host.DeviceID)
fmt.Printf("ROMs with remote saves: %d\n", len(remoteSaves))
if debug {
for romID, saves := range remoteSaves {
fmt.Printf("\n[DEBUG] Saves for rom_id=%d (%d saves):\n", romID, len(saves))
for _, s := range saves {
dumpJSON(s)
}
}
}
newSaves := sync.LocalSavesWithoutRemote(localSaves, remoteSaves)
var items []sync.SyncItem
items = append(items, sync.NewSaveUploadActions(newSaves)...)
items = append(items, sync.DetermineActions(localSaves, remoteSaves, host.DeviceID, config)...)
fmt.Println("Scanning for remote-only saves...")
remoteOnly := sync.DiscoverRemoteSaves(client, config, localSaves, host.DeviceID)
items = append(items, remoteOnly...)
fmt.Printf("Total sync items: %d\n\n", len(items))
if len(items) == 0 {
fmt.Println("Nothing to sync.")
return
}
printTable(items, host.DeviceID)
}
func dumpJSON(v any) {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent(" ", " ")
enc.Encode(v)
}
type row struct {
action string
rom string
localFile string
localMtime string
remoteID string
remoteUpdated string
isCurrent string
slot string
}
func printTable(items []sync.SyncItem, deviceID string) {
headers := row{"ACTION", "ROM", "LOCAL FILE", "LOCAL MTIME", "REMOTE ID", "REMOTE UPDATED", "CURRENT", "SLOT"}
var rows []row
for _, item := range items {
r := row{
action: strings.ToUpper(item.Action.String()),
rom: item.LocalSave.RomName,
localFile: item.LocalSave.FileName,
localMtime: "-",
remoteID: "-",
remoteUpdated: "-",
isCurrent: "-",
slot: "-",
}
if item.LocalSave.FilePath != "" {
if info, err := os.Stat(item.LocalSave.FilePath); err == nil {
r.localMtime = info.ModTime().Local().Format(time.DateTime)
}
}
if item.RemoteSave != nil {
r.remoteID = fmt.Sprintf("%d", item.RemoteSave.ID)
r.remoteUpdated = item.RemoteSave.UpdatedAt.Local().Format(time.DateTime)
if item.RemoteSave.Slot != nil {
r.slot = *item.RemoteSave.Slot
}
for _, ds := range item.RemoteSave.DeviceSyncs {
if ds.DeviceID == deviceID {
if ds.IsCurrent {
r.isCurrent = "yes"
} else {
r.isCurrent = "no"
}
break
}
}
}
if r.localFile == "" && item.RemoteSave != nil {
r.localFile = "(" + item.RemoteSave.FileName + ")"
}
rows = append(rows, r)
}
widths := [8]int{
len(headers.action), len(headers.rom), len(headers.localFile),
len(headers.localMtime), len(headers.remoteID), len(headers.remoteUpdated),
len(headers.isCurrent), len(headers.slot),
}
for _, r := range rows {
fields := [8]string{r.action, r.rom, r.localFile, r.localMtime, r.remoteID, r.remoteUpdated, r.isCurrent, r.slot}
for i, f := range fields {
if len(f) > widths[i] {
widths[i] = len(f)
}
}
}
fmtStr := fmt.Sprintf(" %%-%ds │ %%-%ds │ %%-%ds │ %%-%ds │ %%-%ds │ %%-%ds │ %%-%ds │ %%-%ds ",
widths[0], widths[1], widths[2], widths[3], widths[4], widths[5], widths[6], widths[7])
sep := "─" + strings.Repeat("─", widths[0]) + "─┼─" +
strings.Repeat("─", widths[1]) + "─┼─" +
strings.Repeat("─", widths[2]) + "─┼─" +
strings.Repeat("─", widths[3]) + "─┼─" +
strings.Repeat("─", widths[4]) + "─┼─" +
strings.Repeat("─", widths[5]) + "─┼─" +
strings.Repeat("─", widths[6]) + "─┼─" +
strings.Repeat("─", widths[7]) + "─"
h := fmt.Sprintf(fmtStr, headers.action, headers.rom, headers.localFile,
headers.localMtime, headers.remoteID, headers.remoteUpdated, headers.isCurrent, headers.slot)
fmt.Println(h)
fmt.Println(sep)
for _, r := range rows {
fmt.Printf(fmtStr+"\n", r.action, r.rom, r.localFile, r.localMtime, r.remoteID, r.remoteUpdated, r.isCurrent, r.slot)
}
counts := map[string]int{}
for _, item := range items {
counts[strings.ToUpper(item.Action.String())]++
}
fmt.Println()
for action, count := range counts {
fmt.Printf(" %s: %d\n", action, count)
}
}