diff --git a/backend/dist/nsfwapp-linux-amd64 b/backend/dist/nsfwapp-linux-amd64 index e450f43..44c29a4 100644 Binary files a/backend/dist/nsfwapp-linux-amd64 and b/backend/dist/nsfwapp-linux-amd64 differ diff --git a/backend/dist/nsfwapp.exe b/backend/dist/nsfwapp.exe index 9fd5612..032b1ab 100644 Binary files a/backend/dist/nsfwapp.exe and b/backend/dist/nsfwapp.exe differ diff --git a/backend/dist/nsfwapp_amd64.deb b/backend/dist/nsfwapp_amd64.deb index 35e0138..72f184e 100644 Binary files a/backend/dist/nsfwapp_amd64.deb and b/backend/dist/nsfwapp_amd64.deb differ diff --git a/backend/models_store_sqlite_test.go b/backend/models_store_sqlite_test.go index 39404e4..b748580 100644 --- a/backend/models_store_sqlite_test.go +++ b/backend/models_store_sqlite_test.go @@ -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)) + } + } +} diff --git a/backend/settings.go b/backend/settings.go index d197c07..b73800c 100644 --- a/backend/settings.go +++ b/backend/settings.go @@ -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(). diff --git a/backend/settings_sqlite_move.go b/backend/settings_sqlite_move.go new file mode 100644 index 0000000..5dd507e --- /dev/null +++ b/backend/settings_sqlite_move.go @@ -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) +} diff --git a/frontend/src/components/ui/RecorderSettings.tsx b/frontend/src/components/ui/RecorderSettings.tsx index f833fd0..c64a20d 100644 --- a/frontend/src/components/ui/RecorderSettings.tsx +++ b/frontend/src/components/ui/RecorderSettings.tsx @@ -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(null) const appLogPreRef = useRef(null) const dbRestoreInputRef = useRef(null) - const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null) + const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | 'sqlite' | null>(null) const [msg, setMsg] = useState(null) const [err, setErr] = useState(null) const [diskStatus, setDiskStatus] = useState(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(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) {
- 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" - /> +
+ 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" + /> + +
- 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.
@@ -2346,7 +2371,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { {databaseSettingsDirty ? (
- 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.'}
) : null}