This commit is contained in:
Linrador 2026-06-23 15:22:08 +02:00
parent b55705c582
commit a49483f9d7
7 changed files with 222 additions and 15 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -3,6 +3,7 @@ package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
@ -116,3 +117,42 @@ func TestMigrateModelStoreDataSQLiteToSQLiteReplacesTarget(t *testing.T) {
t.Fatalf("unexpected migrated bio context ok=%v bio=%q", ok, bio)
}
}
func TestMoveSQLiteDatabaseFilesMovesSidecars(t *testing.T) {
tmp := t.TempDir()
sourcePath := filepath.Join(tmp, "old", "models.sqlite")
targetPath := filepath.Join(tmp, "new", "models.sqlite")
if err := os.MkdirAll(filepath.Dir(sourcePath), 0755); err != nil {
t.Fatalf("mkdir source: %v", err)
}
files := map[string]string{
sourcePath: "main",
sourcePath + "-wal": "wal",
sourcePath + "-shm": "shm",
sourcePath + "-journal": "journal",
}
for path, content := range files {
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
if err := moveSQLiteDatabaseFiles(sourcePath, targetPath); err != nil {
t.Fatalf("move sqlite files: %v", err)
}
for source, content := range files {
if _, err := os.Stat(source); !os.IsNotExist(err) {
t.Fatalf("expected source removed %s, err=%v", source, err)
}
target := strings.Replace(source, sourcePath, targetPath, 1)
b, err := os.ReadFile(target)
if err != nil {
t.Fatalf("read target %s: %v", target, err)
}
if string(b) != content {
t.Fatalf("unexpected target content for %s: %q", target, string(b))
}
}
}

View File

@ -562,14 +562,32 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
// DB erst prüfen, BEVOR gespeichert wird
if dbChanged {
sqliteMoved := false
if sqlitePathChanged(current, next) {
if err := moveSQLiteDatabaseForSettings(current, next); err != nil {
http.Error(w, "SQLite-Datei konnte nicht verschoben werden: "+err.Error(), http.StatusBadRequest)
return
}
sqliteMoved = true
}
storeConfig, err := buildModelStoreConfigFromRecorderSettings(next)
if err != nil {
if sqliteMoved {
_ = moveSQLiteDatabaseForSettings(next, current)
restoreModelStoreForSettings(current)
}
http.Error(w, "ungültige Datenbank-Konfiguration: "+err.Error(), http.StatusBadRequest)
return
}
newStore = NewModelStore(storeConfig)
if err := newStore.Load(); err != nil {
_ = newStore.Close()
if sqliteMoved {
_ = moveSQLiteDatabaseForSettings(next, current)
restoreModelStoreForSettings(current)
}
http.Error(w, "Datenbank-Verbindung fehlgeschlagen: "+err.Error(), http.StatusBadRequest)
return
}
@ -624,8 +642,8 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
func settingsBrowse(w http.ResponseWriter, r *http.Request) {
target := r.URL.Query().Get("target")
if target != "record" && target != "done" && target != "ffmpeg" {
http.Error(w, "target muss record, done oder ffmpeg sein", http.StatusBadRequest)
if target != "record" && target != "done" && target != "ffmpeg" && target != "sqlite" {
http.Error(w, "target muss record, done, ffmpeg oder sqlite sein", http.StatusBadRequest)
return
}
@ -639,6 +657,10 @@ func settingsBrowse(w http.ResponseWriter, r *http.Request) {
p, err = dialog.File().
Title("ffmpeg.exe auswählen").
Load()
} else if target == "sqlite" {
p, err = dialog.Directory().
Title("SQLite-Speicherort auswaehlen").
Browse()
} else {
// Ordnerauswahl für record/done
p, err = dialog.Directory().

View File

@ -0,0 +1,116 @@
package main
import (
"errors"
"os"
"path/filepath"
"strings"
)
func sqlitePathChanged(current, next RecorderSettings) bool {
return normalizeDatabaseType(current.DatabaseType) == databaseTypeSQLite &&
normalizeDatabaseType(next.DatabaseType) == databaseTypeSQLite &&
normalizeSQLitePath(current.SQLitePath) != normalizeSQLitePath(next.SQLitePath)
}
func moveSQLiteDatabaseForSettings(current, next RecorderSettings) error {
currentConfig, err := buildModelStoreConfigFromRecorderSettings(current)
if err != nil {
return err
}
nextConfig, err := buildModelStoreConfigFromRecorderSettings(next)
if err != nil {
return err
}
return moveSQLiteDatabaseFiles(currentConfig.SQLitePath, nextConfig.SQLitePath)
}
func restoreModelStoreForSettings(rec RecorderSettings) {
config, err := buildModelStoreConfigFromRecorderSettings(rec)
if err != nil {
return
}
store := NewModelStore(config)
if err := store.Load(); err != nil {
_ = store.Close()
return
}
setModelStore(store)
}
func moveSQLiteDatabaseFiles(sourcePath, targetPath string) error {
sourcePath = filepath.Clean(strings.TrimSpace(sourcePath))
targetPath = filepath.Clean(strings.TrimSpace(targetPath))
if sourcePath == "" || targetPath == "" {
return errors.New("sqlite source/target path fehlt")
}
if sourcePath == targetPath {
return nil
}
sourceInfo, err := os.Stat(sourcePath)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
if sourceInfo.IsDir() {
return appErrorf("sqlite source path ist ein ordner: %s", sourcePath)
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return err
}
for _, target := range append([]string{targetPath}, sqliteSidecarPaths(targetPath)...) {
if _, err := os.Stat(target); err == nil {
return appErrorf("Ziel-Datei existiert bereits: %s", target)
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
}
if store := getModelStore(); store != nil && store.isSQLite() && store.db != nil {
_, _ = store.db.Exec(`PRAGMA wal_checkpoint(TRUNCATE);`)
_ = store.Close()
}
if err := moveDatabaseFile(sourcePath, targetPath); err != nil {
return err
}
sourceSidecars := sqliteSidecarPaths(sourcePath)
targetSidecars := sqliteSidecarPaths(targetPath)
for i, source := range sourceSidecars {
if _, err := os.Stat(source); errors.Is(err, os.ErrNotExist) {
continue
} else if err != nil {
return err
}
if err := moveDatabaseFile(source, targetSidecars[i]); err != nil {
return err
}
}
return nil
}
func sqliteSidecarPaths(path string) []string {
return []string{
path + "-wal",
path + "-shm",
path + "-journal",
}
}
func moveDatabaseFile(sourcePath, targetPath string) error {
if err := renameWithRetry(sourcePath, targetPath); err == nil {
return nil
}
if _, err := copyDatabaseFile(sourcePath, targetPath); err != nil {
return err
}
return removeWithRetry(sourcePath)
}

View File

@ -547,6 +547,19 @@ function normalizeSqlitePath(value: unknown) {
return path || 'nsfwapp.sqlite'
}
function sqlitePathFilename(value: unknown) {
const path = normalizeSqlitePath(value).replace(/\\/g, '/')
return path.split('/').filter(Boolean).pop() || 'nsfwapp.sqlite'
}
function joinSettingsPath(dir: string, filename: string) {
const cleanDir = String(dir ?? '').trim().replace(/[\\/]+$/g, '')
const cleanFile = String(filename ?? '').trim().replace(/^[\\/]+/g, '') || 'nsfwapp.sqlite'
if (!cleanDir) return cleanFile
const sep = cleanDir.includes('\\') && !cleanDir.includes('/') ? '\\' : '/'
return `${cleanDir}${sep}${cleanFile}`
}
function databaseTypeLabel(value: 'sqlite' | 'postgres') {
return value === 'sqlite' ? 'SQLite' : 'Postgres'
}
@ -692,7 +705,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const saveSuccessTimerRef = useRef<number | null>(null)
const appLogPreRef = useRef<HTMLPreElement | null>(null)
const dbRestoreInputRef = useRef<HTMLInputElement | null>(null)
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null)
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | 'sqlite' | null>(null)
const [msg, setMsg] = useState<string | null>(null)
const [err, setErr] = useState<string | null>(null)
const [diskStatus, setDiskStatus] = useState<DiskStatus | null>(null)
@ -725,7 +738,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const savedDatabaseConfigured = loadedDatabaseType === 'sqlite' ? Boolean(loadedSqlitePath) : Boolean(loadedDatabaseUrl)
const targetDatabaseConfigured = currentDatabaseType === 'sqlite' ? Boolean(currentSqlitePath) : Boolean(currentDatabaseUrl)
const databaseConfigured = savedDatabaseConfigured
const canMigrateDatabase = savedDatabaseConfigured && targetDatabaseConfigured && databaseLocationDirty
const canMigrateDatabase =
savedDatabaseConfigured &&
targetDatabaseConfigured &&
databaseLocationDirty &&
currentDatabaseType !== loadedDatabaseType
const dbMaintenanceBusy = dbBackupBusy || dbRestoreBusy || dbMigrationBusy
const [authStatus, setAuthStatus] = useState<AuthSecurityStatus | null>(null)
@ -1237,7 +1254,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
scrollAppLogToBottom()
}, [appLogOpen, appLog?.log, appLogLoading])
async function browse(target: 'record' | 'done' | 'ffmpeg') {
async function browse(target: 'record' | 'done' | 'ffmpeg' | 'sqlite') {
setErr(null)
setMsg(null)
setBrowsing(target)
@ -1258,6 +1275,9 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
setValue((v) => {
if (target === 'record') return { ...v, recordDir: p }
if (target === 'done') return { ...v, doneDir: p }
if (target === 'sqlite') {
return { ...v, sqlitePath: joinSettingsPath(p, sqlitePathFilename((v as any).sqlitePath)) }
}
return { ...v, ffmpegPath: p }
})
} catch (e: any) {
@ -2292,16 +2312,21 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
</label>
<div className="sm:col-span-9">
<input
value={String((value as any).sqlitePath ?? DEFAULTS.sqlitePath ?? 'nsfwapp.sqlite')}
onChange={(e) => setValue((v) => ({ ...v, sqlitePath: e.target.value }))}
placeholder="nsfwapp.sqlite"
className="w-full rounded-lg px-3 py-2 text-sm bg-white text-gray-900 ring-1 ring-gray-200
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:bg-white/10 dark:text-white dark:ring-white/10"
/>
<div className="flex gap-2">
<input
value={String((value as any).sqlitePath ?? DEFAULTS.sqlitePath ?? 'nsfwapp.sqlite')}
onChange={(e) => setValue((v) => ({ ...v, sqlitePath: e.target.value }))}
placeholder="nsfwapp.sqlite"
className="min-w-0 flex-1 rounded-lg px-3 py-2 text-sm bg-white text-gray-900 ring-1 ring-gray-200
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:bg-white/10 dark:text-white dark:ring-white/10"
/>
<Button variant="secondary" onClick={() => browse('sqlite')} disabled={saving || browsing !== null}>
Speicherort...
</Button>
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Relativ zur App oder absolut, z.B. /home/chris/nsfwapp/nsfwapp.sqlite.
Beim Speichern wird eine bestehende SQLite-Datei samt -wal/-shm an den neuen Ort verschoben.
</div>
</div>
</div>
@ -2346,7 +2371,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
</div>
{databaseSettingsDirty ? (
<div className="mt-2 text-xs text-amber-700 dark:text-amber-200">
Backup/Restore nutzen die gespeicherte Datenbank. Migration nutzt diese geaenderten Werte als Ziel.
{currentDatabaseType !== loadedDatabaseType
? 'Backup/Restore nutzen die gespeicherte Datenbank. Migration nutzt diese geänderten Werte als Ziel.'
: currentDatabaseType === 'sqlite' && currentSqlitePath !== loadedSqlitePath
? 'Beim Speichern wird die bestehende SQLite-Datei an diesen Ort verschoben.'
: 'Die Datenbank-Einstellungen wurden geändert. Bitte erst speichern.'}
</div>
) : null}
</div>