diff --git a/backend/dist/nsfwapp-linux-amd64 b/backend/dist/nsfwapp-linux-amd64 index 4f4091e..e450f43 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 8ddbc5e..9fd5612 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 5fba590..35e0138 100644 Binary files a/backend/dist/nsfwapp_amd64.deb and b/backend/dist/nsfwapp_amd64.deb differ diff --git a/backend/embed.go b/backend/embed.go index 3f3ed74..4654ab1 100644 --- a/backend/embed.go +++ b/backend/embed.go @@ -117,10 +117,6 @@ func embeddedPoseModelPath() (string, error) { return dstPath, nil } -func embeddedDotEnvBytes() ([]byte, error) { - return nil, os.ErrNotExist -} - func embeddedTLSCertBytes() ([]byte, error) { return embeddedMLFiles.ReadFile("recorder-cert.pem") } diff --git a/backend/models_store.go b/backend/models_store.go index 0ed14cf..8ac28c3 100644 --- a/backend/models_store.go +++ b/backend/models_store.go @@ -303,6 +303,11 @@ func (s *ModelStore) init() error { _ = db.Close() return err } + } else { + if err := s.ensurePostgresSchema(); err != nil { + _ = db.Close() + return err + } } if err := s.normalizeNameOnlyChaturbate(); err != nil { @@ -383,6 +388,56 @@ func (s *ModelStore) ensureSQLiteSchema() error { return nil } +func (s *ModelStore) ensurePostgresSchema() error { + stmts := []string{ + `CREATE TABLE IF NOT EXISTS models ( + id TEXT PRIMARY KEY, + input TEXT NOT NULL DEFAULT '', + is_url BOOLEAN NOT NULL DEFAULT false, + host TEXT DEFAULT '', + path TEXT DEFAULT '', + model_key TEXT NOT NULL DEFAULT '', + tags TEXT DEFAULT '', + last_stream TIMESTAMPTZ NULL, + last_seen_online BOOLEAN NULL, + last_seen_online_at TIMESTAMPTZ NULL, + cb_online_json TEXT DEFAULT '', + cb_online_fetched_at TIMESTAMPTZ NULL, + cb_online_last_error TEXT DEFAULT '', + profile_image_url TEXT DEFAULT '', + profile_image_mime TEXT DEFAULT '', + profile_image_blob BYTEA NULL, + profile_image_updated_at TIMESTAMPTZ NULL, + room_status TEXT DEFAULT '', + is_online BOOLEAN NOT NULL DEFAULT false, + chat_room_url TEXT DEFAULT '', + image_url TEXT DEFAULT '', + last_online_at TIMESTAMPTZ NULL, + last_offline_at TIMESTAMPTZ NULL, + last_room_sync_at TIMESTAMPTZ NULL, + biocontext_json TEXT DEFAULT '', + biocontext_fetched_at TIMESTAMPTZ NULL, + watching BOOLEAN NOT NULL DEFAULT false, + favorite BOOLEAN NOT NULL DEFAULT false, + hot BOOLEAN NOT NULL DEFAULT false, + keep BOOLEAN NOT NULL DEFAULT false, + liked BOOLEAN NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + );`, + `CREATE INDEX IF NOT EXISTS idx_models_host_model_key ON models(host, model_key);`, + `CREATE INDEX IF NOT EXISTS idx_models_model_key ON models(model_key);`, + `CREATE INDEX IF NOT EXISTS idx_models_watching_host ON models(watching, host);`, + `CREATE INDEX IF NOT EXISTS idx_models_updated_at ON models(updated_at);`, + } + for _, stmt := range stmts { + if _, err := s.db.Exec(stmt); err != nil { + return err + } + } + return nil +} + func (s *ModelStore) SetChaturbateRoomState( host string, modelKey string, diff --git a/backend/models_store_sqlite_test.go b/backend/models_store_sqlite_test.go index 57d7119..39404e4 100644 --- a/backend/models_store_sqlite_test.go +++ b/backend/models_store_sqlite_test.go @@ -51,3 +51,68 @@ func TestModelStoreSQLiteCreatesSchemaAndPersists(t *testing.T) { t.Fatal("expected meta updatedAt") } } + +func TestMigrateModelStoreDataSQLiteToSQLiteReplacesTarget(t *testing.T) { + tmp := t.TempDir() + sourcePath := filepath.Join(tmp, "source.sqlite") + targetPath := filepath.Join(tmp, "target.sqlite") + + sourceConfig := ModelStoreConfig{Type: databaseTypeSQLite, DSN: sourcePath, SQLitePath: sourcePath} + targetConfig := ModelStoreConfig{Type: databaseTypeSQLite, DSN: targetPath, SQLitePath: targetPath} + + source := NewModelStore(sourceConfig) + defer source.Close() + if err := source.Load(); err != nil { + t.Fatalf("load source: %v", err) + } + if _, err := source.EnsureByHostModelKey("chaturbate.com", "alice"); err != nil { + t.Fatalf("ensure source model: %v", err) + } + if err := source.SetProfileImage("chaturbate.com", "alice", "https://example.test/a.jpg", "image/jpeg", []byte{1, 2, 3, 4}, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { + t.Fatalf("set profile image: %v", err) + } + if err := source.SetBioContext("chaturbate.com", "alice", `{"ok":true}`, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { + t.Fatalf("set bio context: %v", err) + } + + target := NewModelStore(targetConfig) + if err := target.Load(); err != nil { + t.Fatalf("load target: %v", err) + } + if _, err := target.EnsureByHostModelKey("chaturbate.com", "stale"); err != nil { + t.Fatalf("ensure stale target model: %v", err) + } + _ = target.Close() + _ = source.Close() + + count, err := migrateModelStoreData(sourceConfig, targetConfig) + if err != nil { + t.Fatalf("migrate sqlite to sqlite: %v", err) + } + if count != 1 { + t.Fatalf("expected 1 migrated row, got %d", count) + } + + migrated := NewModelStore(targetConfig) + defer migrated.Close() + if err := migrated.Load(); err != nil { + t.Fatalf("load migrated target: %v", err) + } + models := migrated.List() + if len(models) != 1 { + t.Fatalf("expected target replacement with 1 row, got %d", len(models)) + } + if models[0].ModelKey != "alice" { + t.Fatalf("unexpected migrated model: %q", models[0].ModelKey) + } + if models[0].ProfileImageCached == "" { + t.Fatal("expected migrated profile image blob marker") + } + bio, _, ok, err := migrated.GetBioContext("chaturbate.com", "alice") + if err != nil { + t.Fatalf("get migrated bio context: %v", err) + } + if !ok || bio != `{"ok":true}` { + t.Fatalf("unexpected migrated bio context ok=%v bio=%q", ok, bio) + } +} diff --git a/backend/routes.go b/backend/routes.go index 00b819e..f6e35e4 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -56,6 +56,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore { api.HandleFunc("/api/settings/cleanup", settingsCleanupHandler) api.HandleFunc("/api/settings/database/backup", settingsDatabaseBackupHandler) api.HandleFunc("/api/settings/database/restore", settingsDatabaseRestoreHandler) + api.HandleFunc("/api/settings/database/migrate", settingsDatabaseMigrateHandler) api.HandleFunc("/api/pending-autostart", handlePendingAutoStart(auth)) diff --git a/backend/server.go b/backend/server.go index c008e31..b09f014 100644 --- a/backend/server.go +++ b/backend/server.go @@ -2,7 +2,6 @@ package main import ( - "bytes" "context" "crypto/rand" "encoding/hex" @@ -807,26 +806,6 @@ func upsertEnv(env []string, key string, value string) []string { return out } -func loadEmbeddedDotEnv() { - b, err := embeddedDotEnvBytes() - if err != nil { - appLogln("ℹ️ Keine eingebettete .env gefunden.") - return - } - - envMap, err := godotenv.Parse(bytes.NewReader(b)) - if err != nil { - appLogln("⚠️ Eingebettete .env konnte nicht gelesen werden:", err) - return - } - - for k, v := range envMap { - setEnvIfMissing(k, v) - } - - appLogln("✅ Eingebettete .env geladen") -} - func loadExternalDotEnvNextToExe() { exePath, err := os.Executable() if err != nil { @@ -840,17 +819,16 @@ func loadExternalDotEnvNextToExe() { envPath := filepath.Join(exeDir, ".env") if err := godotenv.Overload(envPath); err == nil { - appLogln("✅ Externe .env geladen und überschreibt embedded/env:", envPath) + appLogln("✅ Externe .env geladen:", envPath) return } if err := godotenv.Overload(); err == nil { - appLogln("✅ Externe .env geladen aus aktuellem Arbeitsverzeichnis und überschreibt embedded/env") + appLogln("✅ Externe .env geladen aus aktuellem Arbeitsverzeichnis") } } func loadAppEnv() { - loadEmbeddedDotEnv() loadExternalDotEnvNextToExe() } diff --git a/backend/settings_database_migrate.go b/backend/settings_database_migrate.go new file mode 100644 index 0000000..6016e44 --- /dev/null +++ b/backend/settings_database_migrate.go @@ -0,0 +1,405 @@ +package main + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/http" + "path/filepath" + "strconv" + "strings" + "time" +) + +type databaseMigrationRequest struct { + DatabaseType string `json:"databaseType"` + SQLitePath string `json:"sqlitePath"` + DatabaseURL string `json:"databaseUrl"` + DBPassword string `json:"dbPassword,omitempty"` +} + +type modelMigrationColumn struct { + name string + kind string + notNull bool +} + +const ( + modelMigrationText = "text" + modelMigrationBool = "bool" + modelMigrationTime = "time" + modelMigrationBlob = "blob" +) + +var modelMigrationColumns = []modelMigrationColumn{ + {name: "id", kind: modelMigrationText, notNull: true}, + {name: "input", kind: modelMigrationText, notNull: true}, + {name: "is_url", kind: modelMigrationBool, notNull: true}, + {name: "host", kind: modelMigrationText}, + {name: "path", kind: modelMigrationText}, + {name: "model_key", kind: modelMigrationText, notNull: true}, + {name: "tags", kind: modelMigrationText}, + {name: "last_stream", kind: modelMigrationTime}, + {name: "last_seen_online", kind: modelMigrationBool}, + {name: "last_seen_online_at", kind: modelMigrationTime}, + {name: "cb_online_json", kind: modelMigrationText}, + {name: "cb_online_fetched_at", kind: modelMigrationTime}, + {name: "cb_online_last_error", kind: modelMigrationText}, + {name: "profile_image_url", kind: modelMigrationText}, + {name: "profile_image_mime", kind: modelMigrationText}, + {name: "profile_image_blob", kind: modelMigrationBlob}, + {name: "profile_image_updated_at", kind: modelMigrationTime}, + {name: "room_status", kind: modelMigrationText}, + {name: "is_online", kind: modelMigrationBool, notNull: true}, + {name: "chat_room_url", kind: modelMigrationText}, + {name: "image_url", kind: modelMigrationText}, + {name: "last_online_at", kind: modelMigrationTime}, + {name: "last_offline_at", kind: modelMigrationTime}, + {name: "last_room_sync_at", kind: modelMigrationTime}, + {name: "biocontext_json", kind: modelMigrationText}, + {name: "biocontext_fetched_at", kind: modelMigrationTime}, + {name: "watching", kind: modelMigrationBool, notNull: true}, + {name: "favorite", kind: modelMigrationBool, notNull: true}, + {name: "hot", kind: modelMigrationBool, notNull: true}, + {name: "keep", kind: modelMigrationBool, notNull: true}, + {name: "liked", kind: modelMigrationBool}, + {name: "created_at", kind: modelMigrationTime, notNull: true}, + {name: "updated_at", kind: modelMigrationTime, notNull: true}, +} + +type dbNullBool struct { + sql.NullBool +} + +func (b *dbNullBool) Scan(value any) error { + if value == nil { + b.Valid = false + b.Bool = false + return nil + } + + switch v := value.(type) { + case bool: + b.Valid = true + b.Bool = v + return nil + case int64: + b.Valid = true + b.Bool = v != 0 + return nil + case int: + b.Valid = true + b.Bool = v != 0 + return nil + case []byte: + return b.scanString(string(v)) + case string: + return b.scanString(v) + default: + var raw sql.NullBool + if err := raw.Scan(value); err != nil { + return err + } + b.NullBool = raw + return nil + } +} + +func (b *dbNullBool) scanString(value string) error { + value = strings.TrimSpace(strings.ToLower(value)) + if value == "" { + b.Valid = false + b.Bool = false + return nil + } + switch value { + case "1", "t", "true", "yes", "y", "on": + b.Valid = true + b.Bool = true + return nil + case "0", "f", "false", "no", "n", "off": + b.Valid = true + b.Bool = false + return nil + default: + parsed, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("invalid bool value %q", value) + } + b.Valid = true + b.Bool = parsed + return nil + } +} + +type modelMigrationValue struct { + column modelMigrationColumn + text sql.NullString + bool dbNullBool + tm dbNullTime + blob []byte +} + +func (v *modelMigrationValue) dest() any { + switch v.column.kind { + case modelMigrationBool: + return &v.bool + case modelMigrationTime: + return &v.tm + case modelMigrationBlob: + return &v.blob + default: + return &v.text + } +} + +func (v modelMigrationValue) arg(now time.Time) any { + switch v.column.kind { + case modelMigrationBool: + if !v.bool.Valid { + if v.column.notNull { + return false + } + return nil + } + return v.bool.Bool + case modelMigrationTime: + if !v.tm.Valid || v.tm.Time.IsZero() { + if v.column.notNull { + return now + } + return nil + } + return v.tm.Time.UTC() + case modelMigrationBlob: + if len(v.blob) == 0 { + return nil + } + return v.blob + default: + if !v.text.Valid { + if v.column.notNull { + return "" + } + return nil + } + return v.text.String + } +} + +func (v modelMigrationValue) textValue() string { + if !v.text.Valid { + return "" + } + return strings.TrimSpace(v.text.String) +} + +func settingsDatabaseMigrateHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed) + return + } + + var req databaseMigrationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid json: "+err.Error(), http.StatusBadRequest) + return + } + + current := getSettings() + target, err := databaseMigrationTargetSettings(current, req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + sourceConfig, err := buildModelStoreConfigFromRecorderSettings(current) + if err != nil { + http.Error(w, "Quell-Datenbank ungueltig: "+err.Error(), http.StatusBadRequest) + return + } + targetConfig, err := buildModelStoreConfigFromRecorderSettings(target) + if err != nil { + http.Error(w, "Ziel-Datenbank ungueltig: "+err.Error(), http.StatusBadRequest) + return + } + + count, err := migrateModelStoreData(sourceConfig, targetConfig) + if err != nil { + http.Error(w, "Migration fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) + return + } + + refreshedStore := NewModelStore(targetConfig) + if err := refreshedStore.Load(); err != nil { + http.Error(w, "Ziel-Datenbank konnte nach Migration nicht geoeffnet werden: "+err.Error(), http.StatusInternalServerError) + return + } + + settingsMu.Lock() + settings = target + settingsMu.Unlock() + saveSettingsToDisk() + setModelStore(refreshedStore) + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "rows": count, + "sourceType": sourceConfig.Type, + "targetType": targetConfig.Type, + "targetSQLite": targetConfig.SQLitePath, + "targetDatabase": sanitizeDSNForLog(targetConfig.DSN), + }) +} + +func databaseMigrationTargetSettings(current RecorderSettings, req databaseMigrationRequest) (RecorderSettings, error) { + next := current + next.DatabaseType = normalizeDatabaseType(req.DatabaseType) + next.SQLitePath = normalizeSQLitePath(req.SQLitePath) + next.DatabaseURL = strings.TrimSpace(req.DatabaseURL) + + sanitizedURL, pwFromURL := stripPasswordFromPostgresURL(next.DatabaseURL) + pwFromURL = strings.TrimSpace(pwFromURL) + if pwFromURL == "****" { + pwFromURL = "" + } + if sanitizedURL != "" { + next.DatabaseURL = sanitizedURL + } + + plainPW := strings.TrimSpace(req.DBPassword) + if plainPW == "" { + plainPW = pwFromURL + } + if plainPW != "" { + enc, err := encryptSettingString(plainPW) + if err != nil { + return RecorderSettings{}, appErrorf("konnte DB-Passwort nicht verschluesseln: %w", err) + } + next.EncryptedDBPassword = enc + } + + if next.DatabaseType == databaseTypePostgres && strings.TrimSpace(next.DatabaseURL) == "" { + return RecorderSettings{}, errors.New("Database URL fehlt") + } + if next.DatabaseType == databaseTypeSQLite && strings.TrimSpace(next.SQLitePath) == "" { + return RecorderSettings{}, errors.New("SQLite-Datei fehlt") + } + + return next, nil +} + +func migrateModelStoreData(sourceConfig, targetConfig ModelStoreConfig) (int64, error) { + if sameModelStoreConfig(sourceConfig, targetConfig) { + return 0, errors.New("Quelle und Ziel sind identisch") + } + + source := NewModelStore(sourceConfig) + if err := source.Load(); err != nil { + return 0, appErrorf("Quelle konnte nicht geoeffnet werden: %w", err) + } + defer source.Close() + + target := NewModelStore(targetConfig) + if err := target.Load(); err != nil { + return 0, appErrorf("Ziel konnte nicht geoeffnet werden: %w", err) + } + defer target.Close() + + return migrateModelsTable(source.db, target.db) +} + +func sameModelStoreConfig(a, b ModelStoreConfig) bool { + a.Type = normalizeDatabaseType(a.Type) + b.Type = normalizeDatabaseType(b.Type) + if a.Type != b.Type { + return false + } + if a.Type == databaseTypeSQLite { + return filepath.Clean(strings.TrimSpace(a.SQLitePath)) == filepath.Clean(strings.TrimSpace(b.SQLitePath)) + } + return strings.TrimSpace(a.DSN) == strings.TrimSpace(b.DSN) +} + +func migrateModelsTable(sourceDB, targetDB *sql.DB) (int64, error) { + columnNames := make([]string, 0, len(modelMigrationColumns)) + for _, column := range modelMigrationColumns { + columnNames = append(columnNames, column.name) + } + + rows, err := sourceDB.Query("SELECT " + strings.Join(columnNames, ",") + " FROM models ORDER BY updated_at ASC, id ASC;") + if err != nil { + return 0, err + } + defer rows.Close() + + tx, err := targetDB.Begin() + if err != nil { + return 0, err + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.Exec(`DELETE FROM models;`); err != nil { + return 0, err + } + + insertSQL := modelMigrationInsertSQL(columnNames) + stmt, err := tx.Prepare(insertSQL) + if err != nil { + return 0, err + } + defer stmt.Close() + + var count int64 + now := time.Now().UTC() + for rows.Next() { + values := make([]modelMigrationValue, len(modelMigrationColumns)) + dests := make([]any, len(modelMigrationColumns)) + for i, column := range modelMigrationColumns { + values[i] = modelMigrationValue{column: column} + dests[i] = values[i].dest() + } + if err := rows.Scan(dests...); err != nil { + return count, err + } + if values[0].textValue() == "" { + continue + } + + args := make([]any, len(values)) + for i, value := range values { + args[i] = value.arg(now) + } + if _, err := stmt.Exec(args...); err != nil { + return count, err + } + count++ + } + if err := rows.Err(); err != nil { + return count, err + } + if err := tx.Commit(); err != nil { + return count, err + } + + return count, nil +} + +func modelMigrationInsertSQL(columnNames []string) string { + placeholders := make([]string, 0, len(columnNames)) + updates := make([]string, 0, len(columnNames)-1) + for i, column := range columnNames { + placeholders = append(placeholders, fmt.Sprintf("$%d", i+1)) + if column != "id" { + updates = append(updates, column+"=EXCLUDED."+column) + } + } + + return fmt.Sprintf( + "INSERT INTO models (%s) VALUES (%s) ON CONFLICT(id) DO UPDATE SET %s;", + strings.Join(columnNames, ","), + strings.Join(placeholders, ","), + strings.Join(updates, ","), + ) +} diff --git a/frontend/src/components/ui/RecorderSettings.tsx b/frontend/src/components/ui/RecorderSettings.tsx index 4150980..f833fd0 100644 --- a/frontend/src/components/ui/RecorderSettings.tsx +++ b/frontend/src/components/ui/RecorderSettings.tsx @@ -547,6 +547,10 @@ function normalizeSqlitePath(value: unknown) { return path || 'nsfwapp.sqlite' } +function databaseTypeLabel(value: 'sqlite' | 'postgres') { + return value === 'sqlite' ? 'SQLite' : 'Postgres' +} + function formatDatabaseBackupBytes(bytes: unknown) { const n = Number(bytes) if (!Number.isFinite(n) || n <= 0) return '' @@ -703,6 +707,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { const [pendingDbPassword, setPendingDbPassword] = useState('') // wird nur beim Speichern gesendet const [dbBackupBusy, setDbBackupBusy] = useState(false) const [dbRestoreBusy, setDbRestoreBusy] = useState(false) + const [dbMigrationBusy, setDbMigrationBusy] = useState(false) const [dbMaintenanceMsg, setDbMaintenanceMsg] = useState(null) const [dbMaintenanceErr, setDbMaintenanceErr] = useState(null) const now = Date.now() @@ -711,13 +716,17 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { const currentDatabaseType = normalizeDatabaseType((value as any).databaseType) const currentSqlitePath = normalizeSqlitePath((value as any).sqlitePath) const currentDatabaseUrl = String((value as any).databaseUrl ?? '').trim() - const databaseSettingsDirty = + const databaseLocationDirty = currentDatabaseType !== loadedDatabaseType || currentSqlitePath !== loadedSqlitePath || - currentDatabaseUrl !== loadedDatabaseUrl || - Boolean((pendingDbPassword || '').trim()) - const databaseConfigured = currentDatabaseType === 'sqlite' ? Boolean(loadedSqlitePath) : Boolean(loadedDatabaseUrl) - const dbMaintenanceBusy = dbBackupBusy || dbRestoreBusy + currentDatabaseUrl !== loadedDatabaseUrl + const databaseSettingsDirty = + databaseLocationDirty || Boolean((pendingDbPassword || '').trim()) + 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 dbMaintenanceBusy = dbBackupBusy || dbRestoreBusy || dbMigrationBusy const [authStatus, setAuthStatus] = useState(null) const [securityBusy, setSecurityBusy] = useState(false) @@ -1362,6 +1371,74 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { } } + async function migrateDatabase() { + setDbMaintenanceMsg(null) + setDbMaintenanceErr(null) + + const databaseType = currentDatabaseType + const sqlitePath = currentSqlitePath + const databaseUrl = currentDatabaseUrl + + if (!savedDatabaseConfigured) { + setDbMaintenanceErr('Bitte zuerst eine Quell-Datenbank speichern.') + return + } + if (!targetDatabaseConfigured) { + setDbMaintenanceErr('Bitte zuerst die Ziel-Datenbank angeben.') + return + } + if (!databaseLocationDirty) { + setDbMaintenanceErr('Quelle und Ziel sind identisch.') + return + } + + const ok = window.confirm( + `Aktive Datenbank von ${databaseTypeLabel(loadedDatabaseType)} nach ${databaseTypeLabel(databaseType)} migrieren?\n\n` + + 'Die Ziel-Datenbank wird mit den aktuellen Model-Daten ueberschrieben. Danach wird die App auf die Ziel-Datenbank umgestellt.' + ) + if (!ok) return + + setDbMigrationBusy(true) + try { + const res = await fetch('/api/settings/database/migrate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + cache: 'no-store', + body: JSON.stringify({ + databaseType, + sqlitePath, + databaseUrl, + dbPassword: pendingDbPassword || undefined, + }), + }) + if (!res.ok) { + const t = await res.text().catch(() => '') + throw new Error(t || `HTTP ${res.status}`) + } + + const data = await res.json().catch(() => null) + const rows = Number(data?.rows ?? 0) + setDbMaintenanceMsg(`Migration abgeschlossen (${rows} Model${rows === 1 ? '' : 's'}).`) + + setLoadedDatabaseType(databaseType) + setLoadedSqlitePath(sqlitePath) + setLoadedDatabaseUrl(databaseUrl) + setPendingDbPassword('') + + window.dispatchEvent(new CustomEvent('recorder-settings-updated')) + window.dispatchEvent(new Event('models-changed')) + window.dispatchEvent( + new CustomEvent('models-db-changed', { + detail: { databaseType, sqlitePath, databaseUrl, migrated: true }, + }) + ) + } catch (e: any) { + setDbMaintenanceErr(e?.message ?? String(e)) + } finally { + setDbMigrationBusy(false) + } + } + async function save() { setErr(null) setMsg(null) @@ -2269,12 +2346,22 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { {databaseSettingsDirty ? (
- Die Datenbank-Einstellungen wurden geaendert. Bitte erst speichern. + Backup/Restore nutzen die gespeicherte Datenbank. Migration nutzt diese geaenderten Werte als Ziel.
) : null}
+ +