updated
This commit is contained in:
parent
b55705c582
commit
a49483f9d7
BIN
backend/dist/nsfwapp-linux-amd64
vendored
BIN
backend/dist/nsfwapp-linux-amd64
vendored
Binary file not shown.
BIN
backend/dist/nsfwapp.exe
vendored
BIN
backend/dist/nsfwapp.exe
vendored
Binary file not shown.
BIN
backend/dist/nsfwapp_amd64.deb
vendored
BIN
backend/dist/nsfwapp_amd64.deb
vendored
Binary file not shown.
@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@ -116,3 +117,42 @@ func TestMigrateModelStoreDataSQLiteToSQLiteReplacesTarget(t *testing.T) {
|
|||||||
t.Fatalf("unexpected migrated bio context ok=%v bio=%q", ok, bio)
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -562,14 +562,32 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// DB erst prüfen, BEVOR gespeichert wird
|
// DB erst prüfen, BEVOR gespeichert wird
|
||||||
if dbChanged {
|
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)
|
storeConfig, err := buildModelStoreConfigFromRecorderSettings(next)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if sqliteMoved {
|
||||||
|
_ = moveSQLiteDatabaseForSettings(next, current)
|
||||||
|
restoreModelStoreForSettings(current)
|
||||||
|
}
|
||||||
http.Error(w, "ungültige Datenbank-Konfiguration: "+err.Error(), http.StatusBadRequest)
|
http.Error(w, "ungültige Datenbank-Konfiguration: "+err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
newStore = NewModelStore(storeConfig)
|
newStore = NewModelStore(storeConfig)
|
||||||
if err := newStore.Load(); err != nil {
|
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)
|
http.Error(w, "Datenbank-Verbindung fehlgeschlagen: "+err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -624,8 +642,8 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func settingsBrowse(w http.ResponseWriter, r *http.Request) {
|
func settingsBrowse(w http.ResponseWriter, r *http.Request) {
|
||||||
target := r.URL.Query().Get("target")
|
target := r.URL.Query().Get("target")
|
||||||
if target != "record" && target != "done" && target != "ffmpeg" {
|
if target != "record" && target != "done" && target != "ffmpeg" && target != "sqlite" {
|
||||||
http.Error(w, "target muss record, done oder ffmpeg sein", http.StatusBadRequest)
|
http.Error(w, "target muss record, done, ffmpeg oder sqlite sein", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -639,6 +657,10 @@ func settingsBrowse(w http.ResponseWriter, r *http.Request) {
|
|||||||
p, err = dialog.File().
|
p, err = dialog.File().
|
||||||
Title("ffmpeg.exe auswählen").
|
Title("ffmpeg.exe auswählen").
|
||||||
Load()
|
Load()
|
||||||
|
} else if target == "sqlite" {
|
||||||
|
p, err = dialog.Directory().
|
||||||
|
Title("SQLite-Speicherort auswaehlen").
|
||||||
|
Browse()
|
||||||
} else {
|
} else {
|
||||||
// Ordnerauswahl für record/done
|
// Ordnerauswahl für record/done
|
||||||
p, err = dialog.Directory().
|
p, err = dialog.Directory().
|
||||||
|
|||||||
116
backend/settings_sqlite_move.go
Normal file
116
backend/settings_sqlite_move.go
Normal 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)
|
||||||
|
}
|
||||||
@ -547,6 +547,19 @@ function normalizeSqlitePath(value: unknown) {
|
|||||||
return path || 'nsfwapp.sqlite'
|
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') {
|
function databaseTypeLabel(value: 'sqlite' | 'postgres') {
|
||||||
return value === 'sqlite' ? 'SQLite' : 'Postgres'
|
return value === 'sqlite' ? 'SQLite' : 'Postgres'
|
||||||
}
|
}
|
||||||
@ -692,7 +705,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
const saveSuccessTimerRef = useRef<number | null>(null)
|
const saveSuccessTimerRef = useRef<number | null>(null)
|
||||||
const appLogPreRef = useRef<HTMLPreElement | null>(null)
|
const appLogPreRef = useRef<HTMLPreElement | null>(null)
|
||||||
const dbRestoreInputRef = useRef<HTMLInputElement | 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 [msg, setMsg] = useState<string | null>(null)
|
||||||
const [err, setErr] = useState<string | null>(null)
|
const [err, setErr] = useState<string | null>(null)
|
||||||
const [diskStatus, setDiskStatus] = useState<DiskStatus | 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 savedDatabaseConfigured = loadedDatabaseType === 'sqlite' ? Boolean(loadedSqlitePath) : Boolean(loadedDatabaseUrl)
|
||||||
const targetDatabaseConfigured = currentDatabaseType === 'sqlite' ? Boolean(currentSqlitePath) : Boolean(currentDatabaseUrl)
|
const targetDatabaseConfigured = currentDatabaseType === 'sqlite' ? Boolean(currentSqlitePath) : Boolean(currentDatabaseUrl)
|
||||||
const databaseConfigured = savedDatabaseConfigured
|
const databaseConfigured = savedDatabaseConfigured
|
||||||
const canMigrateDatabase = savedDatabaseConfigured && targetDatabaseConfigured && databaseLocationDirty
|
const canMigrateDatabase =
|
||||||
|
savedDatabaseConfigured &&
|
||||||
|
targetDatabaseConfigured &&
|
||||||
|
databaseLocationDirty &&
|
||||||
|
currentDatabaseType !== loadedDatabaseType
|
||||||
const dbMaintenanceBusy = dbBackupBusy || dbRestoreBusy || dbMigrationBusy
|
const dbMaintenanceBusy = dbBackupBusy || dbRestoreBusy || dbMigrationBusy
|
||||||
|
|
||||||
const [authStatus, setAuthStatus] = useState<AuthSecurityStatus | null>(null)
|
const [authStatus, setAuthStatus] = useState<AuthSecurityStatus | null>(null)
|
||||||
@ -1237,7 +1254,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
scrollAppLogToBottom()
|
scrollAppLogToBottom()
|
||||||
}, [appLogOpen, appLog?.log, appLogLoading])
|
}, [appLogOpen, appLog?.log, appLogLoading])
|
||||||
|
|
||||||
async function browse(target: 'record' | 'done' | 'ffmpeg') {
|
async function browse(target: 'record' | 'done' | 'ffmpeg' | 'sqlite') {
|
||||||
setErr(null)
|
setErr(null)
|
||||||
setMsg(null)
|
setMsg(null)
|
||||||
setBrowsing(target)
|
setBrowsing(target)
|
||||||
@ -1258,6 +1275,9 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
setValue((v) => {
|
setValue((v) => {
|
||||||
if (target === 'record') return { ...v, recordDir: p }
|
if (target === 'record') return { ...v, recordDir: p }
|
||||||
if (target === 'done') return { ...v, doneDir: 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 }
|
return { ...v, ffmpegPath: p }
|
||||||
})
|
})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@ -2292,16 +2312,21 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="sm:col-span-9">
|
<div className="sm:col-span-9">
|
||||||
<input
|
<div className="flex gap-2">
|
||||||
value={String((value as any).sqlitePath ?? DEFAULTS.sqlitePath ?? 'nsfwapp.sqlite')}
|
<input
|
||||||
onChange={(e) => setValue((v) => ({ ...v, sqlitePath: e.target.value }))}
|
value={String((value as any).sqlitePath ?? DEFAULTS.sqlitePath ?? 'nsfwapp.sqlite')}
|
||||||
placeholder="nsfwapp.sqlite"
|
onChange={(e) => setValue((v) => ({ ...v, sqlitePath: e.target.value }))}
|
||||||
className="w-full rounded-lg px-3 py-2 text-sm bg-white text-gray-900 ring-1 ring-gray-200
|
placeholder="nsfwapp.sqlite"
|
||||||
focus:outline-none focus:ring-2 focus:ring-indigo-500
|
className="min-w-0 flex-1 rounded-lg px-3 py-2 text-sm bg-white text-gray-900 ring-1 ring-gray-200
|
||||||
dark:bg-white/10 dark:text-white dark:ring-white/10"
|
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">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -2346,7 +2371,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
{databaseSettingsDirty ? (
|
{databaseSettingsDirty ? (
|
||||||
<div className="mt-2 text-xs text-amber-700 dark:text-amber-200">
|
<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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user