From 0df7b828cceb124293f4f4cac0452e870e403dcb Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:32:57 +0200 Subject: [PATCH] Track a game from the game database; one folder, one game A folder tracked by hand got only the name typed for it: no App ID, so no cover art and no launching. And nothing stopped a second entry for a folder inside one already tracked: tracking Escape From Duckov from the game database put a second game on ...\TeamSoda\Duckov\Saves, inside the folder an entry named "Duckov" tracked, and the same files were watched, snapshotted and synced twice. - "Track game" beside "Track folder" on Home finds a game in the game database (the Ludusavi manifest index) by name, by its program (the Steam install folder it is in, or a folder above it named as the game's install folder, compared without spaces), or by a save folder (the game whose save location it is, holds, or is held by). It is tracked with the database's name, App ID, its program and a save location known on this device (presets/gamedb.go; GET /api/gamedb/search and /api/gamedb/identify). - "Track folder" recognises the folder the same way ("Recognised as ..."). - TrackGame refuses a folder inside a tracked game's folder, or holding one, and names that game (store.PathsNest). The very same folder keeps its existing refusal. - A game picked that is tracked already here, by App ID or by an overlapping folder, is offered to link that entry instead (the existing PATCH /api/games/{id}), not to track the same files twice. New text goes through i18n, in en and zh-CN. Co-Authored-By: Claude Opus 5.5 --- cmd/opensave-app/frontend/src/locales/en.json | 27 +- .../frontend/src/locales/zh-CN.json | 27 +- .../frontend/src/views/Home.svelte | 8 + .../src/views/home/AddGameCard.svelte | 25 +- .../src/views/home/TrackGameDialog.svelte | 345 ++++++++++++++++++ internal/api/routes.go | 7 +- internal/api/routes_gamedb.go | 26 ++ internal/daemon/daemon.go | 6 + internal/daemon/gamedb.go | 90 +++++ internal/daemon/gamedb_test.go | 78 ++++ internal/presets/gamedb.go | 294 +++++++++++++++ internal/presets/gamedb_test.go | 69 ++++ internal/store/known_saves.go | 6 + 13 files changed, 1002 insertions(+), 6 deletions(-) create mode 100644 cmd/opensave-app/frontend/src/views/home/TrackGameDialog.svelte create mode 100644 internal/api/routes_gamedb.go create mode 100644 internal/daemon/gamedb.go create mode 100644 internal/daemon/gamedb_test.go create mode 100644 internal/presets/gamedb.go create mode 100644 internal/presets/gamedb_test.go diff --git a/cmd/opensave-app/frontend/src/locales/en.json b/cmd/opensave-app/frontend/src/locales/en.json index 7da165a7..2157edb9 100644 --- a/cmd/opensave-app/frontend/src/locales/en.json +++ b/cmd/opensave-app/frontend/src/locales/en.json @@ -186,5 +186,30 @@ "home.welcome.intro": "Keep your game saves in sync across every device — no accounts, no cloud lock-in. Start by finding your saves:", "home.welcome.scanForSaves": "Scan for saves", "home.welcome.title": "Welcome to OpenSave", - "home.welcome.trackManually": "Track a folder manually" + "home.welcome.trackManually": "Track a folder manually", + "home.trackGame": "Track game", + "home.trackGameTitle": "Find a game by name, or choose its program or save folder", + "home.addGame.recognisedAs": "Recognised as \"{name}\" — its cover art comes from the game database.", + "home.findGame.title": "Track a game from the game database", + "home.findGame.findByName": "Find by name", + "home.findGame.searchPlaceholder": "e.g. Crimson Desert", + "home.findGame.searching": "Searching…", + "home.findGame.noSavesHere": "no saves found on this PC", + "home.findGame.trackedAs": "tracked as \"{name}\"", + "home.findGame.nothingFound": "Nothing by that name in the game database.", + "home.findGame.chooseProgram": "Choose the game’s program…", + "home.findGame.chooseSaveFolder": "Choose its save folder…", + "home.findGame.programDialog": "Choose the game’s program", + "home.findGame.saveFolderDialog": "Choose the game’s save folder", + "home.findGame.saveFolderOf": "Save folder of \"{name}\"", + "home.findGame.theGame": "the game", + "home.findGame.notKnown": "Not in the game database — give it a name, and pick its save folder if it is not set.", + "home.findGame.saveFolder": "Save folder", + "home.findGame.noSavesYet": "No saves of it found on this PC yet. Choose where it saves, or play it once and track it then.", + "home.findGame.otherSaveFolder": "Choose another save folder…", + "home.findGame.program": "Program", + "home.findGame.pickAnother": "← Pick another game", + "home.findGame.already": "Already tracked here as \"{tracked}\". Tracking it again would sync the same files twice; link that entry to {name} instead, so it gets the game database’s name, App ID and cover art.", + "home.findGame.link": "Link \"{tracked}\" to {name}", + "home.findGame.linked": "\"{tracked}\" is now {name}" } diff --git a/cmd/opensave-app/frontend/src/locales/zh-CN.json b/cmd/opensave-app/frontend/src/locales/zh-CN.json index 12fbb543..b7399448 100644 --- a/cmd/opensave-app/frontend/src/locales/zh-CN.json +++ b/cmd/opensave-app/frontend/src/locales/zh-CN.json @@ -165,5 +165,30 @@ "home.welcome.intro": "让你的游戏存档在所有设备间保持同步——无需账号,不被云端绑架。先找到你的存档:", "home.welcome.scanForSaves": "扫描存档", "home.welcome.title": "欢迎使用 OpenSave", - "home.welcome.trackManually": "手动跟踪文件夹" + "home.welcome.trackManually": "手动跟踪文件夹", + "home.trackGame": "跟踪游戏", + "home.trackGameTitle": "按名称查找游戏,或选择其程序或存档文件夹", + "home.addGame.recognisedAs": "已识别为“{name}”——封面来自游戏数据库。", + "home.findGame.title": "从游戏数据库跟踪游戏", + "home.findGame.findByName": "按名称查找", + "home.findGame.searchPlaceholder": "例如:Crimson Desert", + "home.findGame.searching": "搜索中…", + "home.findGame.noSavesHere": "此电脑上未找到存档", + "home.findGame.trackedAs": "已作为“{name}”跟踪", + "home.findGame.nothingFound": "游戏数据库中没有这个名称的游戏。", + "home.findGame.chooseProgram": "选择游戏程序…", + "home.findGame.chooseSaveFolder": "选择其存档文件夹…", + "home.findGame.programDialog": "选择游戏程序", + "home.findGame.saveFolderDialog": "选择游戏的存档文件夹", + "home.findGame.saveFolderOf": "“{name}”的存档文件夹", + "home.findGame.theGame": "该游戏", + "home.findGame.notKnown": "不在游戏数据库中——请为它命名;如果尚未设置存档文件夹,请选择一个。", + "home.findGame.saveFolder": "存档文件夹", + "home.findGame.noSavesYet": "此电脑上尚未找到它的存档。请选择它的存档位置,或先玩一次再跟踪。", + "home.findGame.otherSaveFolder": "选择其他存档文件夹…", + "home.findGame.program": "程序", + "home.findGame.pickAnother": "← 选择其他游戏", + "home.findGame.already": "此处已作为“{tracked}”跟踪。再次跟踪会将相同的文件同步两次;请改为将该条目关联到 {name},使其获得游戏数据库中的名称、App ID 和封面。", + "home.findGame.link": "将“{tracked}”关联到 {name}", + "home.findGame.linked": "“{tracked}”现在是 {name}" } diff --git a/cmd/opensave-app/frontend/src/views/Home.svelte b/cmd/opensave-app/frontend/src/views/Home.svelte index de8ecef4..0287353d 100644 --- a/cmd/opensave-app/frontend/src/views/Home.svelte +++ b/cmd/opensave-app/frontend/src/views/Home.svelte @@ -5,6 +5,7 @@ import { gameStatus, conflictedIds } from '../lib/gamestatus.js'; import OfferedGames from './home/OfferedGames.svelte'; import AddGameCard from './home/AddGameCard.svelte'; + import TrackGameDialog from './home/TrackGameDialog.svelte'; import ScanDialog from './home/ScanDialog.svelte'; import HomeSummary from './home/HomeSummary.svelte'; import LibraryGrid from './home/LibraryGrid.svelte'; @@ -31,6 +32,8 @@ // on the params object, so each request — the sidebar's +, Ctrl+K — opens // it once, and not again on every re-render. let showAdd = false; + // Tracking a game the game database knows (TrackGameDialog). + let showTrackGame = false; let addPath = ''; let handledAdd = null; $: if (params.add && params !== handledAdd) { @@ -109,6 +112,7 @@ + @@ -119,6 +123,10 @@ (showAdd = false)} /> {/if} +{#if showTrackGame} + (showTrackGame = false)} /> +{/if} + {#if !$stateLoaded} diff --git a/cmd/opensave-app/frontend/src/views/home/AddGameCard.svelte b/cmd/opensave-app/frontend/src/views/home/AddGameCard.svelte index 8c95beed..963f991d 100644 --- a/cmd/opensave-app/frontend/src/views/home/AddGameCard.svelte +++ b/cmd/opensave-app/frontend/src/views/home/AddGameCard.svelte @@ -21,6 +21,10 @@ export let path = ''; let name = ''; + // The game the folder is a save location of, in the game database: its + // name and App ID (cover art, launching). + let appId = ''; + let knownAs = ''; let typed = false; // once typed in, a late suggestion must not replace it let adding = false; let nameInput; @@ -32,10 +36,24 @@ suggestedFor = p; if (!p) return; let guess = folderName(p); + appId = ''; + knownAs = ''; try { - guess = (await api.get(`/api/suggest-name?path=${encodeURIComponent(p)}`)).name || guess; + const id = await api.get(`/api/gamedb/identify?path=${encodeURIComponent(p)}`); + if (id.found && p === path) { + appId = id.game.appId ?? ''; + knownAs = id.game.name; + guess = id.game.name; + } } catch { - // The folder's own name will do. + // Named from the path instead. + } + if (!knownAs) { + try { + guess = (await api.get(`/api/suggest-name?path=${encodeURIComponent(p)}`)).name || guess; + } catch { + // The folder's own name will do. + } } if (p !== path || typed) return; name = guess; @@ -65,7 +83,7 @@ if (!name.trim() || !path || adding) return; adding = true; try { - const game = await api.post('/api/games', { name: name.trim(), savePath: path }); + const game = await api.post('/api/games', { name: name.trim(), savePath: path, appId }); toast($t('common.nowTracking', { name: game.name ?? name }), 'success'); dispatch('close'); navigate('game', { gameId: game.id }); @@ -102,6 +120,7 @@ on:keydown={(e) => e.key === 'Enter' && add()} /> {$t('home.addGame.nameHint')} + {#if knownAs}{$t('home.addGame.recognisedAs', { name: knownAs })}{/if} diff --git a/cmd/opensave-app/frontend/src/views/home/TrackGameDialog.svelte b/cmd/opensave-app/frontend/src/views/home/TrackGameDialog.svelte new file mode 100644 index 00000000..127ffef8 --- /dev/null +++ b/cmd/opensave-app/frontend/src/views/home/TrackGameDialog.svelte @@ -0,0 +1,345 @@ + + + dispatch('close')}> +
+ {#if !picked} +
+ + + {#if searching} +

{$t('home.findGame.searching')}

+ {:else if results.length > 0} +
+ {#each results as g (g.name)} + + {/each} +
+ {:else if query.trim().length >= 2} +

{$t('home.findGame.nothingFound')}

+ {/if} +
+ + {#if canPick} +
+ + +
+ {/if} + {:else} +
+ {#if picked.appId} + (e.currentTarget.style.display = 'none')} /> + {/if} +
+ + +
+
+ {#if note}

{note}

{/if} + {#if picked.trackedId} +
+

{$t('home.findGame.already', { tracked: picked.trackedName, name: picked.name || $t('home.findGame.theGame') })}

+ +
+ {/if} + +
+ {$t('home.findGame.saveFolder')} + {#if picked.savePaths?.length > 1} + {#each picked.savePaths as p} + + {/each} + {:else if savePath} +
{savePath}
+ {:else} +

{$t('home.findGame.noSavesYet')}

+ {/if} + {#if canPick} +
+ {/if} +
+ {#if exePath} +
+ {$t('home.findGame.program')} +
{exePath}
+
+ {/if} +
+ {/if} +
+ + +
+ + +
+
+
+ + diff --git a/internal/api/routes.go b/internal/api/routes.go index 920b1a33..f943a131 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -29,6 +29,9 @@ func (s *Server) routes(r chi.Router) { r.Get("/api/games", s.handleListGames) r.Post("/api/games", s.handleTrackGame) + // The game database, for tracking a game by hand (daemon/gamedb.go). + r.Get("/api/gamedb/search", s.handleGameDBSearch) + r.Get("/api/gamedb/identify", s.handleGameDBIdentify) r.Get("/api/suggest-name", s.handleSuggestName) r.Post("/api/games/untrack-bulk", s.handleBulkUntrack) @@ -344,6 +347,8 @@ func (s *Server) handleTrackGame(w http.ResponseWriter, r *http.Request) { Name string `json:"name"` SavePath string `json:"savePath"` AppID string `json:"appId"` + // ExePath: the game's program, when it was picked by it. + ExePath string `json:"exePath"` } if err := readJSON(r, &body); err != nil { writeError(w, http.StatusBadRequest, err.Error()) @@ -354,7 +359,7 @@ func (s *Server) handleTrackGame(w http.ResponseWriter, r *http.Request) { return } - game, err := s.Daemon.TrackGame(store.Game{Name: body.Name, SavePath: body.SavePath, AppID: body.AppID}) + game, err := s.Daemon.TrackGame(store.Game{Name: body.Name, SavePath: body.SavePath, AppID: body.AppID, ExePath: body.ExePath}) if err != nil { // Duplicates (id or path) are conflicts; anything else the daemon // rejects is bad input. diff --git a/internal/api/routes_gamedb.go b/internal/api/routes_gamedb.go new file mode 100644 index 00000000..b1d9024c --- /dev/null +++ b/internal/api/routes_gamedb.go @@ -0,0 +1,26 @@ +package api + +import ( + "net/http" + + "github.com/opensave/opensave/internal/presets" +) + +// GET /api/gamedb/search?q= — games in the game database by name. +func (s *Server) handleGameDBSearch(w http.ResponseWriter, r *http.Request) { + out := s.Daemon.SearchGames(r.URL.Query().Get("q")) + if out == nil { + out = []presets.GameMatch{} + } + writeJSON(w, http.StatusOK, map[string]any{"games": out}) +} + +// GET /api/gamedb/identify?path= — the game a program or folder belongs to. +func (s *Server) handleGameDBIdentify(w http.ResponseWriter, r *http.Request) { + m, ok := s.Daemon.IdentifyGame(r.URL.Query().Get("path")) + if !ok { + writeJSON(w, http.StatusOK, map[string]any{"found": false}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"found": true, "game": m}) +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 3d732f86..cd87c0e7 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -703,6 +703,12 @@ func (d *Daemon) TrackGame(game store.Game) (store.Game, error) { return store.Game{}, err } game.SavePath = abs + // Nor a folder inside another game's, or holding one: the same files + // would be watched, snapshotted and synced twice, under two games that + // each think them theirs. + if other, ok := d.overlappingGame(abs); ok { + return store.Game{}, fmt.Errorf("%q already tracks %s, which this folder is inside or holds — track one or the other", other.Name, other.SavePath) + } // Fresh track from the UI (no id supplied): derive the id from the name, // but disambiguate collisions so a second save location for a same-named diff --git a/internal/daemon/gamedb.go b/internal/daemon/gamedb.go new file mode 100644 index 00000000..302ef470 --- /dev/null +++ b/internal/daemon/gamedb.go @@ -0,0 +1,90 @@ +package daemon + +import ( + "os" + "path/filepath" + "strings" + + "github.com/opensave/opensave/internal/presets" + "github.com/opensave/opensave/internal/store" +) + +// Tracking a game by hand, with what the game database knows about it: a +// game found by name, or recognised from a program or folder chosen. See +// presets/gamedb.go. + +func (d *Daemon) gameDB() *presets.GameDB { + if d.Scanner == nil { + return nil + } + return d.Scanner.GameDB() +} + +// SearchGames finds games in the game database by name. +func (d *Daemon) SearchGames(query string) []presets.GameMatch { + out := d.gameDB().Search(query, 20) + for i := range out { + d.markTracked(&out[i]) + } + return out +} + +// IdentifyGame says which game a program or folder belongs to: a program by +// where it is installed, a folder by the game whose save location it is. +func (d *Daemon) IdentifyGame(path string) (presets.GameMatch, bool) { + db := d.gameDB() + info, err := os.Stat(path) + if err != nil { + return presets.GameMatch{}, false + } + var m presets.GameMatch + var ok bool + switch { + case !info.IsDir() && strings.EqualFold(filepath.Ext(path), ".exe"): + m, ok = db.IdentifyProgram(path) + case !info.IsDir(): + m, ok = db.IdentifyFolder(filepath.Dir(path)) // a save file: its folder + default: + m, ok = db.IdentifyFolder(path) + } + if ok { + d.markTracked(&m) + } + return m, ok +} + +// overlappingGame is the tracked game whose folder holds path, or is inside +// it, if there is one. The very same folder is not counted: TrackGame says +// so in its own words. +func (d *Daemon) overlappingGame(path string) (store.Game, bool) { + games, err := d.Store.ListGames() + if err != nil { + return store.Game{}, false + } + for _, g := range games { + if g.SavePath != "" && store.PathsNest(path, g.SavePath) { + return g, true + } + } + return store.Game{}, false +} + +// markTracked fills in which tracked game, if any, a match already is. +func (d *Daemon) markTracked(m *presets.GameMatch) { + games, err := d.Store.ListGames() + if err != nil { + return + } + for _, g := range games { + same := m.AppID != "" && g.AppID == m.AppID + for _, p := range m.SavePaths { + if g.SavePath != "" && store.PathsOverlap(p, g.SavePath) { + same = true + } + } + if same { + m.TrackedID, m.TrackedName = g.ID, g.Name + return + } + } +} diff --git a/internal/daemon/gamedb_test.go b/internal/daemon/gamedb_test.go new file mode 100644 index 00000000..8a5fcde7 --- /dev/null +++ b/internal/daemon/gamedb_test.go @@ -0,0 +1,78 @@ +package daemon + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/opensave/opensave/internal/presets" + "github.com/opensave/opensave/internal/store" +) + +// A folder inside a tracked game's folder, or holding it, is not tracked as +// another game: the same files would be watched, snapshotted and synced twice. +// The very same folder keeps its own refusal. +func TestTrackGame_RefusesAFolderInsideAnother(t *testing.T) { + d := newTestDaemon(t) + outer := t.TempDir() + inner := filepath.Join(outer, "Saves") + if err := os.MkdirAll(inner, 0o777); err != nil { + t.Fatal(err) + } + if _, err := d.TrackGame(store.Game{Name: "Duckov", SavePath: inner}); err != nil { + t.Fatal(err) + } + if _, err := d.TrackGame(store.Game{Name: "Escape From Duckov", SavePath: outer}); err == nil { + t.Error("a folder holding a tracked game's was tracked as another game") + } else if !strings.Contains(err.Error(), "Duckov") { + t.Errorf("the refusal does not name the game: %v", err) + } + deeper := filepath.Join(inner, "slot1") + if err := os.MkdirAll(deeper, 0o777); err != nil { + t.Fatal(err) + } + if _, err := d.TrackGame(store.Game{Name: "Duckov Slot", SavePath: deeper}); err == nil { + t.Error("a folder inside a tracked game's was tracked as another game") + } + if _, err := d.TrackGame(store.Game{Name: "Duckov Again", SavePath: inner}); err == nil || !strings.Contains(err.Error(), "already tracks this folder") { + t.Errorf("the same folder again: %v, want the usual \"already tracks this folder\"", err) + } + if _, err := d.TrackGame(store.Game{Name: "Unrelated", SavePath: t.TempDir()}); err != nil { + t.Errorf("an unrelated folder was refused: %v", err) + } +} + +// A game picked from the game database says which entry here it already is: +// one with its App ID, or one whose folder is, holds or is held by a save +// location the database knows for it. +func TestMarkTracked_NamesTheEntryAGameIsAlready(t *testing.T) { + d := newTestDaemon(t) + saves := t.TempDir() + g, err := d.TrackGame(store.Game{Name: "Duckov", SavePath: saves}) + if err != nil { + t.Fatal(err) + } + + byFolder := presets.GameMatch{Name: "Escape From Duckov", SavePaths: []string{filepath.Join(saves, "Saves")}} + d.markTracked(&byFolder) + if byFolder.TrackedID != g.ID || byFolder.TrackedName != "Duckov" { + t.Errorf("by folder: tracked as %q (%q), want %q", byFolder.TrackedID, byFolder.TrackedName, g.ID) + } + + g.AppID = "3167020" + if err := d.Store.UpdateGame(g); err != nil { + t.Fatal(err) + } + byID := presets.GameMatch{Name: "Escape From Duckov", AppID: "3167020"} + d.markTracked(&byID) + if byID.TrackedID != g.ID { + t.Errorf("by App ID: tracked as %q, want %q", byID.TrackedID, g.ID) + } + + other := presets.GameMatch{Name: "Something Else", AppID: "1", SavePaths: []string{t.TempDir()}} + d.markTracked(&other) + if other.TrackedID != "" { + t.Errorf("an unrelated game was marked as tracked: %q", other.TrackedID) + } +} diff --git a/internal/presets/gamedb.go b/internal/presets/gamedb.go new file mode 100644 index 00000000..011c77d9 --- /dev/null +++ b/internal/presets/gamedb.go @@ -0,0 +1,294 @@ +package presets + +import ( + "path" + "path/filepath" + "sort" + "strings" +) + +// Finding a game in the game database by what is on this device: a program +// or a folder that was picked, or a name typed. +// What it answers is everything tracking needs and a folder picked by hand +// lacked: the game's name, its Steam App ID (cover art, launching, sessions), +// and where its saves are on this device. + +// GameDB is the game database loaded once for any number of lookups. Nil +// when there is no index; every method answers nothing on a nil GameDB. +type GameDB struct { + sc *Scanner + all []indexedGame // every game, for looking one up by what is on disk + byID map[string]indexedGame + protonIx map[string][]string + bases func([]string) []string + blocked map[string]bool // folders no save location may be + // byInstall: games by install folder name, built when first asked. + byInstall map[string][]indexedGame +} + +// GameDB loads the game database (the manifest index) for lookups. +func (sc *Scanner) GameDB() *GameDB { + if sc == nil || sc.CacheFile == "" { + return nil + } + idx := sc.loadManifestIndex() + if len(idx) == 0 { + return nil + } + ds := &GameDB{sc: sc, all: idx, byID: map[string]indexedGame{}} + for _, g := range idx { + if g.SteamID != "" && len(g.Paths) > 0 { + ds.byID[g.SteamID] = g + } + } + if sc.goos() == "linux" { + ds.protonIx = sc.protonPrefixIndex() + } + if sc.goos() == "windows" { + ds.blocked = blockedRoots(windowsPathVars()) + } else { + ds.blocked = linuxBlockedRoots(sc.linuxHome()) + } + ds.bases = sc.installBaseCandidates() + return ds +} + +// GameMatch is a game from the game database, as found on this device. +type GameMatch struct { + Name string `json:"name"` + AppID string `json:"appId,omitempty"` + // SavePaths are the game's save locations that exist on this device, + // most likely first. + SavePaths []string `json:"savePaths"` + // InstallDir is where it is installed, when that is known. + InstallDir string `json:"installDir,omitempty"` + // TrackedID and TrackedName name the game already tracked here that + // this one is — its folder is, holds or is held by one of SavePaths, or it + // has the App ID. Filled in by the daemon. + TrackedID string `json:"trackedId,omitempty"` + TrackedName string `json:"trackedName,omitempty"` +} + +// savePathsHere lists a game's save locations that exist on this device. +func (ds *GameDB) savePathsHere(g indexedGame) []string { + if ds == nil || len(g.Paths) == 0 { + return nil + } + return expandGamePaths(g, ds.sc.ludusaviVarSets(g, ds.protonIx), ds.bases, ds.blocked) +} + +func (ds *GameDB) matchOf(g indexedGame, installDir string) GameMatch { + return GameMatch{Name: g.Name, AppID: g.SteamID, SavePaths: ds.savePathsHere(g), InstallDir: installDir} +} + +// Search finds games by name: the whole name first, then names that begin +// with what was typed, then names that contain it. +func (ds *GameDB) Search(query string, limit int) []GameMatch { + q := normalizeGameName(query) + if ds == nil || len(q) < 2 { + return nil + } + type hit struct { + g indexedGame + rank int + } + var hits []hit + for _, g := range ds.all { + n := normalizeGameName(g.Name) + switch { + case n == q: + hits = append(hits, hit{g, 0}) + case strings.HasPrefix(n, q): + hits = append(hits, hit{g, 1}) + case strings.Contains(n, q): + hits = append(hits, hit{g, 2}) + } + } + sort.SliceStable(hits, func(i, j int) bool { + if hits[i].rank != hits[j].rank { + return hits[i].rank < hits[j].rank + } + return len(hits[i].g.Name) < len(hits[j].g.Name) + }) + if len(hits) > limit { + hits = hits[:limit] + } + out := make([]GameMatch, 0, len(hits)) + for _, h := range hits { + out = append(out, ds.matchOf(h.g, "")) + } + return out +} + +// IdentifyProgram finds the game a program belongs to: by the Steam install +// folder it is in, or by the name of a folder above it that is the game's +// install folder in the game database ("G:\Games\Crimson Desert"). +func (ds *GameDB) IdentifyProgram(exe string) (GameMatch, bool) { + if ds == nil || exe == "" { + return GameMatch{}, false + } + exe = filepath.Clean(exe) + byAppID, _ := ds.sc.InstallDirs() + for appID, dir := range byAppID { + if dir != "" && isSubPath(dir, exe) { + if g, ok := ds.gameByID(appID); ok { + return ds.matchOf(g, dir), true + } + } + } + byFolder := ds.installNameIndex() + for dir := filepath.Dir(exe); dir != filepath.Dir(dir); dir = filepath.Dir(dir) { + if gs := byFolder[installKey(filepath.Base(dir))]; len(gs) > 0 { + return ds.matchOf(pickGame(gs), dir), true + } + } + return GameMatch{}, false +} + +// IdentifyFolder finds the game a save folder belongs to: the game whose save +// location in the game database is this folder, or holds it, or is held by it +// (a folder picked one level too high). +func (ds *GameDB) IdentifyFolder(folder string) (GameMatch, bool) { + if ds == nil || folder == "" { + return GameMatch{}, false + } + folder = filepath.Clean(folder) + low := strings.ToLower(folder) + best, bestScore := indexedGame{}, 0 + for _, g := range ds.all { + if len(g.Paths) == 0 { + continue + } + // An install folder is only worked out for a game whose install + // folder name is in the path at all: doing it for every game in the + // database would look on disk tens of thousands of times. + bases := func([]string) []string { return nil } + for _, inst := range g.Installs { + if inst != "" && strings.Contains(low, strings.ToLower(inst)) { + bases = ds.bases + break + } + } + for _, vars := range ds.sc.ludusaviVarSets(g, ds.protonIx) { + for _, tpl := range g.Paths { + for _, expanded := range expandTemplate(tpl, vars, bases(g.Installs), g.SteamID) { + score := 0 + switch { + case sameUnder(folder, expanded): + score = 3 + case under(expanded, folder): + score = 2 // a folder inside the save location + case under(folder, expanded): + score = 1 // the save location inside the folder picked + } + if score > bestScore || (score == bestScore && score > 0 && better(g, best)) { + best, bestScore = g, score + } + } + } + } + } + if bestScore == 0 { + return GameMatch{}, false + } + return ds.matchOf(best, ""), true +} + +// sameUnder reports whether path is pattern (a template expanded, possibly +// with wildcards), compared component by component. +func sameUnder(path, pattern string) bool { + rel, ok := relativeUnder(path, pattern+string(filepath.Separator)+"x") + return ok && rel == "x" +} + +// relativeUnder reports whether pattern (a path, possibly with wildcards) +// lies strictly inside root, and the rest of it below root. Compared +// component by component, case-insensitively, with either separator: the +// game database writes Windows paths, which a Proton prefix holds on Linux. +func relativeUnder(root, pattern string) (string, bool) { + split := func(p string) []string { + p = strings.ToLower(path.Clean(strings.ReplaceAll(p, `\`, "/"))) + var parts []string + for _, c := range strings.Split(p, "/") { + if c != "" { + parts = append(parts, c) + } + } + return parts + } + r, p := split(root), split(pattern) + if len(r) == 0 || len(p) <= len(r) { + return "", false + } + for i, rc := range r { + if ok, err := path.Match(p[i], rc); err != nil || !ok { + return "", false + } + } + return strings.Join(p[len(r):], "/"), true +} + +// under reports whether path lies strictly inside pattern. +func under(pattern, path string) bool { + _, ok := relativeUnder(pattern, path) + return ok +} + +// better prefers a game with a Steam App ID — cover art, launching — when two +// match equally. +func better(g, than indexedGame) bool { + return g.SteamID != "" && than.SteamID == "" +} + +func pickGame(gs []indexedGame) indexedGame { + best := gs[0] + for _, g := range gs[1:] { + if better(g, best) { + best = g + } + } + return best +} + +func (ds *GameDB) gameByID(appID string) (indexedGame, bool) { + if g, ok := ds.byID[appID]; ok { + return g, true + } + for _, g := range ds.all { + if g.SteamID == appID { + return g, true + } + } + return indexedGame{}, false +} + +// installNameIndex maps install folder names, normalised, to the games that +// use them, for names specific enough to say which game a folder is. +func (ds *GameDB) installNameIndex() map[string][]indexedGame { + if ds.byInstall != nil { + return ds.byInstall + } + ds.byInstall = map[string][]indexedGame{} + for _, g := range ds.all { + for _, inst := range g.Installs { + n := installKey(inst) + if len(n) < 4 || genericInstallNames[n] { + continue + } + ds.byInstall[n] = append(ds.byInstall[n], g) + } + } + return ds.byInstall +} + +var genericInstallNames = map[string]bool{ + "game": true, "games": true, "data": true, "bin": true, "binaries": true, "launcher": true, + "client": true, "common": true, "steam": true, "app": true, +} + +// installKey is a folder name as compared with the game database's install +// folder names: normalised, and without spaces, so "Project Zomboid" is the +// "ProjectZomboid" the database knows. +func installKey(name string) string { + return strings.ReplaceAll(normalizeGameName(name), " ", "") +} diff --git a/internal/presets/gamedb_test.go b/internal/presets/gamedb_test.go new file mode 100644 index 00000000..db9addd3 --- /dev/null +++ b/internal/presets/gamedb_test.go @@ -0,0 +1,69 @@ +package presets + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestGameDB_FindsGamesByNameFolderAndProgram(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("templates below use Windows placeholders") + } + home := t.TempDir() + t.Setenv("USERPROFILE", home) + t.Setenv("HOME", home) + local := filepath.Join(home, "AppData", "Local") + t.Setenv("LOCALAPPDATA", local) + save := filepath.Join(local, "Pearl", "CD", "save") + if err := os.MkdirAll(filepath.Join(save, "123"), 0o777); err != nil { + t.Fatal(err) + } + + sc := manifestScanner(t, ` +Pearl Game: + files: + "/Pearl/CD/save": + tags: [save] + installDir: + Pearl Game: {} + steam: + id: 3321460 +Project Things: + files: + "/Things/Saves": + tags: [save] + installDir: + ProjectThings: {} +`) + ds := sc.GameDB() + if ds == nil { + t.Fatal("no index") + } + + got := ds.Search("pearl", 5) + if len(got) != 1 || got[0].AppID != "3321460" || len(got[0].SavePaths) != 1 || got[0].SavePaths[0] != save { + t.Errorf("search = %+v", got) + } + + for _, folder := range []string{save, filepath.Join(save, "123"), filepath.Join(local, "Pearl", "CD")} { + if m, ok := ds.IdentifyFolder(folder); !ok || m.Name != "Pearl Game" { + t.Errorf("folder %s: %+v %v", folder, m, ok) + } + } + if _, ok := ds.IdentifyFolder(filepath.Join(home, "Elsewhere")); ok { + t.Error("an unrelated folder was taken for a game") + } + + m, ok := ds.IdentifyProgram(`G:\Games\Pearl Game\bin64\game.exe`) + if !ok || m.Name != "Pearl Game" || m.InstallDir != `G:\Games\Pearl Game` { + t.Errorf("program in a folder of its own = %+v %v", m, ok) + } + if m, ok := ds.IdentifyProgram(`D:\Stuff\Project Things\run.exe`); !ok || m.Name != "Project Things" { + t.Errorf("install folder written with a space the database leaves out = %+v %v", m, ok) + } + if _, ok := ds.IdentifyProgram(`C:\Tools\editor.exe`); ok { + t.Error("an unrelated program was taken for a game") + } +} diff --git a/internal/store/known_saves.go b/internal/store/known_saves.go index 55f4a739..afdc8459 100644 --- a/internal/store/known_saves.go +++ b/internal/store/known_saves.go @@ -91,3 +91,9 @@ func (s *Store) ClearPendingSaves() error { // sits inside the other, compared the way the store compares a game's // locations — the same answer on every operating system. func PathsOverlap(a, b string) bool { return overlaps(a, b) } + +// PathsNest reports whether one save folder sits inside the other, the two +// not being the same folder. +func PathsNest(a, b string) bool { + return overlaps(a, b) && normalizeLocationPath(a) != normalizeLocationPath(b) +}