Skip to content
Draft
15 changes: 15 additions & 0 deletions cmd/opensave-app/frontend/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,21 @@
"game.config.deviceSettings": "Device settings",
"game.config.syncDeviceSettings": "Sync these settings between devices too",
"game.config.deviceSettingsHint": "The game database lists these as this game's settings, not its save, so each device keeps its own (graphics, resolution). They are still in every snapshot.",
"game.config.deviceSettingsDetectedHint": "Settings files you confirm from the suggestions are kept on each device too. A file that turns out to change along with the save syncs again by itself; to sync one yourself, untick it under “Pick from your save folder” below.",
"game.config.sourceDatabase": "game database",
"game.config.sourceDetected": "kept here by you — {reason}",
"game.config.sourceSave": "syncs again — {reason}",
"game.config.dbSuggestion": "This looks like {name} in the game database. Linked, it gets cover art, launching, seeing it played, and the game database’s settings files kept on each device.",
"game.config.dbLink": "Link",
"game.config.dbNotThis": "Not this game",
"game.config.dbLinked": "Linked to {name}",
"game.config.dbNotLinked": "Left as it is",
"game.config.suggestedSettings": "Suggested settings files",
"game.config.suggestedHint": "These look like this device’s settings rather than the save, by what they hold. They keep syncing until you decide: keep one on this device, and each device keeps its own.",
"game.config.keepHere": "Keep on this device",
"game.config.keepSyncing": "Keep syncing",
"game.config.keptHere": "{file} is kept on this device now",
"game.config.keptSyncing": "{file} keeps syncing",
"notifications.clearAll": "Clear all",
"notifications.clearAllTitle": "Take these out of the bell. Activity still has them.",
"palette.renameGame": "Rename {game}"
Expand Down
15 changes: 15 additions & 0 deletions cmd/opensave-app/frontend/src/locales/zh-Hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,21 @@
"game.config.deviceSettings": "设备设置",
"game.config.syncDeviceSettings": "也在设备之间同步这些设置",
"game.config.deviceSettingsHint": "游戏数据库将这些文件列为该游戏的设置而非存档,因此每台设备保留各自的设置(画质、分辨率)。它们仍包含在每个快照中。",
"game.config.deviceSettingsDetectedHint": "你从建议中确认的设置文件也会在每台设备上各自保留。若某个文件被发现会随存档一起变化,它会自动恢复同步;如需手动同步某个文件,请在下方“Pick from your save folder”中取消勾选。",
"game.config.sourceDatabase": "游戏数据库",
"game.config.sourceDetected": "由你保留在本机 — {reason}",
"game.config.sourceSave": "恢复同步 — {reason}",
"game.config.dbSuggestion": "这看起来是游戏数据库中的 {name}。关联后将获得封面、启动、游玩检测,并且游戏数据库列出的设置文件会在每台设备上各自保留。",
"game.config.dbLink": "关联",
"game.config.dbNotThis": "不是这个游戏",
"game.config.dbLinked": "已关联到 {name}",
"game.config.dbNotLinked": "保持不变",
"game.config.suggestedSettings": "建议的设置文件",
"game.config.suggestedHint": "根据内容判断,这些文件看起来是本设备的设置而非存档。在你决定之前它们会继续同步:选择保留在本设备后,每台设备将各自保留自己的版本。",
"game.config.keepHere": "保留在本设备",
"game.config.keepSyncing": "继续同步",
"game.config.keptHere": "{file} 现在保留在本设备",
"game.config.keptSyncing": "{file} 将继续同步",
"notifications.clearAll": "清除全部",
"notifications.clearAllTitle": "将这些从铃铛移除,活动页仍保留",
"palette.renameGame": "重命名 {game}"
Expand Down
15 changes: 15 additions & 0 deletions cmd/opensave-app/frontend/src/locales/zh-Hant.json
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,21 @@
"game.config.deviceSettings": "裝置設定",
"game.config.syncDeviceSettings": "也在裝置之間同步這些設定",
"game.config.deviceSettingsHint": "遊戲資料庫將這些檔案列為該遊戲的設定而非存檔,因此每臺裝置保留各自的設定(畫質、解析度)。它們仍包含在每個快照中。",
"game.config.deviceSettingsDetectedHint": "你從建議中確認的設定檔案也會在每臺裝置上各自保留。若某個檔案被發現會隨存檔一起變化,它會自動恢復同步;如需手動同步某個檔案,請在下方「Pick from your save folder」中取消勾選。",
"game.config.sourceDatabase": "遊戲資料庫",
"game.config.sourceDetected": "由你保留在本機 — {reason}",
"game.config.sourceSave": "恢復同步 — {reason}",
"game.config.dbSuggestion": "這看起來是遊戲資料庫中的 {name}。連結後將取得封面、啟動、遊玩偵測,並且遊戲資料庫列出的設定檔案會在每臺裝置上各自保留。",
"game.config.dbLink": "連結",
"game.config.dbNotThis": "不是這個遊戲",
"game.config.dbLinked": "已連結到 {name}",
"game.config.dbNotLinked": "保持不變",
"game.config.suggestedSettings": "建議的設定檔案",
"game.config.suggestedHint": "根據內容判斷,這些檔案看起來是本裝置的設定而非存檔。在你決定之前它們會繼續同步:選擇保留在本裝置後,每臺裝置將各自保留自己的版本。",
"game.config.keepHere": "保留在本裝置",
"game.config.keepSyncing": "繼續同步",
"game.config.keptHere": "{file} 現在保留在本裝置",
"game.config.keptSyncing": "{file} 將繼續同步",
"notifications.clearAll": "清除全部",
"notifications.clearAllTitle": "將這些從鈴鐺移除,活動頁仍保留",
"palette.renameGame": "重新命名 {game}"
Expand Down
99 changes: 97 additions & 2 deletions cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,32 @@
// The files the game database names as this game's settings rather than its
// save (graphics, resolution): left out of syncing unless asked for.
$: deviceSettings = game.deviceSettings ?? [];
// Files found by what they hold: suggested, and still syncing, until
// answered here. A wrong guess must not quietly stop part of a save syncing.
$: suggested = game.suggestedSettings ?? [];
let locations = [];

function decide(s, keepHere) {
const path = s.pattern.replace(/^\//, '');
const done = $t(keepHere ? 'game.config.keptHere' : 'game.config.keptSyncing', { file: path });
return run(done, () => api.post(`/api/games/${game.id}/suggested-settings`, { path, keepHere }));
}

// The game in the game database this one looks like, for a game tracked
// without an App ID: linked only when confirmed here, as the App ID brings
// its settings files with it, left out of syncing.
$: dbSuggestion = game.gameDBSuggestion ?? null;
function answerLink(accept) {
const s = dbSuggestion;
if (!s) return;
const done = accept ? $t('game.config.dbLinked', { name: s.name }) : $t('game.config.dbNotLinked');
return run(done, async () => {
await api.post(`/api/games/${game.id}/gamedb-link`, { accept });
// The form holds the App ID as the tab opened; saving it would undo this.
if (accept) cfg.appId = s.appId;
});
}

// Cover preview: a custom URL wins, else the proxied Steam art for the App
// ID being edited.
$: cover =
Expand Down Expand Up @@ -131,6 +155,19 @@
</span>
{/if}
</div>
{#if dbSuggestion}
<div class="db-suggestion">
<span class="hint">{$t('game.config.dbSuggestion', { name: dbSuggestion.name })}</span>
<span class="decide">
<button class="btn small" disabled={$busy} on:click={() => answerLink(true)}>
{$t('game.config.dbLink')}
</button>
<button class="btn small ghost" disabled={$busy} on:click={() => answerLink(false)}>
{$t('game.config.dbNotThis')}
</button>
</span>
</div>
{/if}
<AppIdField bind:value={cfg.appId} error={appIdError} />
<div class="field">
<label for="c-exe">Executable path</label>
Expand Down Expand Up @@ -166,12 +203,39 @@
can't push them out. <strong>0 = keep forever</strong> (the default).
</span>
</div>
{#if suggested.length > 0}
<div class="field device-block">
<span class="label">{$t('game.config.suggestedSettings')}</span>
<ul class="device-settings">
{#each suggested as s (s.pattern)}
<li>
<code>{s.pattern.replace(/^\//, '')}</code>
<span class="source">{s.reason}</span>
<span class="decide">
<button class="btn small" disabled={$busy} on:click={() => decide(s, true)}>
{$t('game.config.keepHere')}
</button>
<button class="btn small ghost" disabled={$busy} on:click={() => decide(s, false)}>
{$t('game.config.keepSyncing')}
</button>
</span>
</li>
{/each}
</ul>
</div>
<span class="hint device-hint">{$t('game.config.suggestedHint')}</span>
{/if}
{#if deviceSettings.length > 0}
<div class="field device-block">
<label for="c-devsettings">{$t('game.config.deviceSettings')}</label>
<ul class="device-settings">
{#each deviceSettings as p}
<li><code>{p.replace(/^\//, '')}</code></li>
{#each deviceSettings as s}
<li class:given-back={s.source === 'save'}>
<code>{s.pattern.replace(/^!?\//, '')}</code>
<span class="source">
{#if s.source === 'database'}{$t('game.config.sourceDatabase')}{:else if s.source === 'detected'}{$t('game.config.sourceDetected', { reason: s.reason })}{:else}{$t('game.config.sourceSave', { reason: s.reason })}{/if}
</span>
</li>
{/each}
</ul>
</div>
Expand All @@ -180,6 +244,7 @@
{$t('game.config.syncDeviceSettings')}
</label>
<span class="hint device-hint">{$t('game.config.deviceSettingsHint')}</span>
<span class="hint device-hint">{$t('game.config.deviceSettingsDetectedHint')}</span>
{/if}
<SyncIgnoreEditor {game} bind:value={cfg.syncIgnore} severalFolders={locations.length > 0} />
<SaveLocations {game} {runner} bind:locations />
Expand Down Expand Up @@ -235,6 +300,36 @@
.device-settings code {
font-size: 0.9em;
}
.device-settings .source {
font-size: 0.82em;
color: var(--text-faint);
margin-left: 6px;
}
.device-settings .given-back code {
text-decoration: line-through;
}
/* A heading over a list, not a control: styled as the field labels are. */
.device-block .label {
font-size: 0.85rem;
color: var(--text-dim);
font-weight: 500;
}
.db-suggestion {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
.db-suggestion .decide {
display: inline-flex;
gap: 6px;
}
.device-settings .decide {
display: inline-flex;
gap: 6px;
margin-left: 8px;
}
.device-block {
margin-bottom: 4px !important;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,14 +133,19 @@
<span class="file-name">
{#if f.location}<span class="file-loc">{f.location} ›</span>{/if}{f.path}
</span>
<span class="verdict">{f.excluded ? "won't sync" : 'syncs'}</span>
<span class="verdict" title={f.reason ?? ''}>
{#if !f.excluded}syncs{:else if f.source === 'database'}won't sync · settings (game database){:else if f.source === 'detected'}won't
sync · settings (detected){:else}won't sync{/if}
</span>
</label>
{/each}
</div>
<span class="hint">
Ticking a file writes the pattern for you, anchored so it can only ever mean that
one file. Unticking a file caught by a wildcard adds a <code>!</code> exception
rather than deleting the wildcard.
Files marked <em>settings</em> are left out as this game's device settings; unticking
one syncs it after all.
{#if severalFolders}
A pattern applies to <strong>every one of this game's folders</strong>, so a
name that appears in two of them is excluded in both.
Expand Down
2 changes: 2 additions & 0 deletions internal/api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ func (s *Server) routes(r chi.Router) {
r.Post("/api/games/{gameId}/link", s.handleLinkGame)
r.Get("/api/games/{gameId}/roots", s.handleListGameRoots)
r.Post("/api/games/{gameId}/roots", s.handleAddGameRoot)
r.Post("/api/games/{gameId}/suggested-settings", s.handleDecideSuggestedSettings)
r.Post("/api/games/{gameId}/gamedb-link", s.handleAnswerGameDBLink)
r.Delete("/api/games/{gameId}/roots/{root}", s.handleRemoveGameRoot)
r.Delete("/api/games/{gameId}/alias/{aliasId}", s.handleUnlinkGame)

Expand Down
30 changes: 30 additions & 0 deletions internal/api/routes_gamedb.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package api

import (
"errors"
"net/http"

"github.com/go-chi/chi/v5"
"github.com/opensave/opensave/internal/daemon"
"github.com/opensave/opensave/internal/presets"
)

Expand All @@ -24,3 +27,30 @@ func (s *Server) handleGameDBIdentify(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]any{"found": true, "game": m})
}

// POST /api/games/{gameId}/gamedb-link {"accept": bool} — the answer to the
// game in the game database a game tracked without an App ID looks like.
func (s *Server) handleAnswerGameDBLink(w http.ResponseWriter, r *http.Request) {
game, err := s.Daemon.Store.GetGame(chi.URLParam(r, "gameId"))
if err != nil {
writeError(w, http.StatusNotFound, "Game not found.")
return
}
var body struct {
Accept bool `json:"accept"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "accept is required")
return
}
if err := s.Daemon.AnswerGameDBLink(game, body.Accept); err != nil {
if errors.Is(err, daemon.ErrNoGameDBSuggestion) {
writeError(w, http.StatusConflict, err.Error())
return
}
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.BroadcastGamesUpdate()
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
61 changes: 61 additions & 0 deletions internal/api/routes_savefiles.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package api

import (
"errors"
"net/http"
"os"
"path/filepath"
"sort"
"strings"

"github.com/go-chi/chi/v5"
"github.com/opensave/opensave/internal/daemon"
"github.com/opensave/opensave/internal/delta"
"github.com/opensave/opensave/internal/ignore"
)
Expand Down Expand Up @@ -38,6 +40,11 @@ type saveFile struct {
MtimeMs int64 `json:"mtimeMs"`
// Excluded reports whether the current rules stop this file syncing.
Excluded bool `json:"excluded"`
// Why: "rule" (the game's own exclusion list), "database" (the game
// database names it as the game's settings) or "detected" (found to be
// its settings by what it holds). Empty when it syncs.
Source string `json:"source,omitempty"`
Reason string `json:"reason,omitempty"`
}

// saveFileListCap bounds the response. A save folder with more files than
Expand Down Expand Up @@ -67,6 +74,25 @@ func (s *Server) handleGameSaveFiles(w http.ResponseWriter, r *http.Request) {
// With the game's device settings, which are left out the same way.
text := s.Daemon.IgnoreText(game)
rules := ignore.Parse(text)
ownRules := ignore.Parse(game.SyncIgnore)
var settings []daemon.DeviceSetting
if !game.SyncDeviceSettings {
settings = s.Daemon.DeviceSettings(game)
}
// which says what excludes a file the rules exclude: the game's own
// rules first, as they apply last; then the last device-settings entry
// that names it.
which := func(path string) (string, string) {
if !ownRules.Empty() && ownRules.Match(path) {
return "rule", ""
}
for i := len(settings) - 1; i >= 0; i-- {
if settings[i].Source != "save" && ignore.Parse(settings[i].Pattern).Match(path) {
return settings[i].Source, settings[i].Reason
}
}
return "rule", ""
}

roots := []struct{ name, path string }{{"", game.SavePath}}
if extra, rootsErr := s.Daemon.Store.ListGameRoots(gameID); rootsErr == nil {
Expand Down Expand Up @@ -96,6 +122,11 @@ func (s *Server) handleGameSaveFiles(w http.ResponseWriter, r *http.Request) {
for _, f := range files {
f.Location = root.name
f.Excluded = rules.Match(f.Path)
if f.Excluded && root.name == "" {
f.Source, f.Reason = which(f.Path)
} else if f.Excluded {
f.Source = "rule"
}
out = append(out, f)
}
}
Expand Down Expand Up @@ -204,3 +235,33 @@ func listSaveFiles(root string, limit int) ([]saveFile, bool) {
}
return out, truncated
}

// handleDecideSuggestedSettings answers a suggested settings file from the
// game's configuration tab: {"path": "...", "keepHere": true} leaves it out of
// syncing as this device's settings, false keeps it syncing for good.
func (s *Server) handleDecideSuggestedSettings(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "gameId")
game, err := s.Daemon.Store.GetGame(gameID)
if err != nil {
writeError(w, http.StatusNotFound, "Game not found.")
return
}
var body struct {
Path string `json:"path"`
KeepHere bool `json:"keepHere"`
}
if err := readJSON(r, &body); err != nil || body.Path == "" {
writeError(w, http.StatusBadRequest, "path is required")
return
}
if err := s.Daemon.DecideSuggestedSettings(game, body.Path, body.KeepHere); err != nil {
if errors.Is(err, daemon.ErrNotSuggested) {
writeError(w, http.StatusConflict, err.Error())
return
}
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.BroadcastGamesUpdate()
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
8 changes: 7 additions & 1 deletion internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,13 @@ func (s *Server) gamePayload(g store.Game) map[string]any {
"syncIgnore": g.SyncIgnore,
// The files the game database names as this game's settings, left
// out of syncing unless syncDeviceSettings (daemon/devicesettings.go).
"deviceSettings": s.Daemon.DeviceSettings(g),
"deviceSettings": s.Daemon.DeviceSettings(g),
// Found by what they hold, and waiting to be confirmed; they keep
// syncing meanwhile (daemon.SuggestedSettings).
"suggestedSettings": s.Daemon.SuggestedSettings(g),
// The game in the game database a game tracked without an App ID
// looks like, to be confirmed (daemon.SuggestedGameDBLink).
"gameDBSuggestion": s.Daemon.SuggestedGameDBLink(g),
"syncDeviceSettings": g.SyncDeviceSettings,
"branches": branches,
"createdAt": g.CreatedAt,
Expand Down
Loading
Loading