mirror of
https://github.com/rommapp/grout.git
synced 2026-04-23 06:54:36 +00:00
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:
+197
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user