From 337418afe0d2345ac0916a90e5de18d36edbf522 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 8 Oct 2026 07:08:09 +0500 Subject: [PATCH] Add a configurable backup folder and drop-down bot settings Backups can be stored in a chosen folder (new backups go there, older ones stay listed), and a backups folder that is a link to another drive no longer fails the update's safety backup check. SQUID settings that declare a fixed list of choices are edited as a drop-down and validated against it. Co-Authored-By: Claude Sonnet 5.5 --- .../20261008-backup-folder-and-bot-choices.md | 7 + .../20261008-bot-settings-drop-down.md | 7 + crates/coa-core/src/backup.rs | 123 +++++++++++++++++- crates/coa-core/src/squid.rs | 34 ++++- crates/coa-core/src/update.rs | 4 +- src-tauri/src/lib.rs | 14 ++ src/i18n/locales/de.ts | 6 + src/i18n/locales/en.ts | 6 + src/i18n/locales/es.ts | 6 + src/i18n/locales/fr.ts | 6 + src/i18n/locales/ru.ts | 6 + src/i18n/locales/zh.ts | 6 + src/lib/api.ts | 11 +- src/lib/moduleSettings.ts | 6 +- src/screens/BackupsPage.tsx | 22 +++- 15 files changed, 253 insertions(+), 11 deletions(-) create mode 100644 changelog.d/20261008-backup-folder-and-bot-choices.md create mode 100644 changelog.d/20261008-bot-settings-drop-down.md diff --git a/changelog.d/20261008-backup-folder-and-bot-choices.md b/changelog.d/20261008-backup-folder-and-bot-choices.md new file mode 100644 index 0000000..1cccc99 --- /dev/null +++ b/changelog.d/20261008-backup-folder-and-bot-choices.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: admins +title: Choose where backups are stored, for example on another drive +--- +Backups can now live in any folder outside the server folder. Existing backups stay listed. A backups folder that you moved and linked from the old place also works again for updates. diff --git a/changelog.d/20261008-bot-settings-drop-down.md b/changelog.d/20261008-bot-settings-drop-down.md new file mode 100644 index 0000000..898aaf3 --- /dev/null +++ b/changelog.d/20261008-bot-settings-drop-down.md @@ -0,0 +1,7 @@ +--- +area: manager +type: changed +audience: admins +title: Bot settings with a fixed list of values show a drop-down +--- +SQUID bots 1.9 describes the chat language as a list of languages. The Manager shows it as a drop-down with names instead of a bare number. diff --git a/crates/coa-core/src/backup.rs b/crates/coa-core/src/backup.rs index 41be4b6..3914f23 100644 --- a/crates/coa-core/src/backup.rs +++ b/crates/coa-core/src/backup.rs @@ -69,10 +69,82 @@ pub struct RecoveryPoint { pub realm: crate::realms::Mode, } -fn backups_dir(meta: &Path) -> PathBuf { +const LOCATION_FILE: &str = "backup-location.json"; + +#[derive(Serialize, Deserialize)] +struct StoredLocation { + path: String, +} + +fn default_dir(meta: &Path) -> PathBuf { meta.join("backups") } +/// The folder the owner chose for backups (for example on another drive), if any. +fn custom_dir(meta: &Path) -> Option { + let stored: StoredLocation = fsx::read_json(&meta.join(LOCATION_FILE)).ok()?; + let path = PathBuf::from(stored.path.trim()); + path.is_absolute().then_some(path) +} + +/// Where new backups go. +fn backups_dir(meta: &Path) -> PathBuf { + custom_dir(meta).unwrap_or_else(|| default_dir(meta)) +} + +/// Every folder that may hold backups: the chosen one first, then the default one, so backups made before the +/// folder was changed stay visible and restorable. +fn backup_roots(meta: &Path) -> Vec { + let mut roots: Vec = custom_dir(meta).into_iter().collect(); + let default = default_dir(meta); + if !roots.contains(&default) { roots.push(default); } + roots +} + +#[derive(Debug, Serialize)] +pub struct BackupLocation { + pub path: String, + pub default_path: String, + pub is_default: bool, +} + +pub fn location(meta: &Path) -> BackupLocation { + let default = default_dir(meta); + let current = backups_dir(meta); + BackupLocation { is_default: current == default, path: current.to_string_lossy().into_owned(), default_path: default.to_string_lossy().into_owned() } +} + +/// Choose where new backups are stored (`None` = the default folder next to the server). Existing backups stay where +/// they are and remain listed. +pub fn set_location(root: &Path, meta: &Path, path: Option<&str>) -> Result { + let _lock = crate::update::operation_lock(meta)?; + crate::update::ensure_recovered(meta)?; + match path.map(str::trim).filter(|p| !p.is_empty()) { + None => { + let file = meta.join(LOCATION_FILE); + if file.exists() { fs::remove_file(file)?; } + } + Some(chosen) => { + let dir = PathBuf::from(chosen); + if !dir.is_absolute() { return Err(Error::Invalid("Choose a full folder path, for example D:\\CoA backups.".into())); } + if fsx::ensure_within(root, &dir).is_ok() { + return Err(Error::Invalid("Backups cannot be stored inside the server folder: they would be part of what is being backed up.".into())); + } + fs::create_dir_all(&dir).map_err(|e| Error::Invalid(format!("The folder cannot be created: {e}")))?; + let probe = dir.join(format!(".coa-write-test-{}", uuid::Uuid::new_v4().simple())); + fs::write(&probe, b"x").map_err(|e| Error::Invalid(format!("The folder is not writable: {e}")))?; + let _ = fs::remove_file(&probe); + fsx::atomic_write_json(&meta.join(LOCATION_FILE), &StoredLocation { path: dir.to_string_lossy().into_owned() })?; + } + } + Ok(location(meta)) +} + +/// The `backup.json` of a recovery point, wherever its folder is. +pub(crate) fn point_json(meta: &Path, id: &str) -> Result { + Ok(point_dir(meta, id)?.join("backup.json")) +} + fn id_ok(id: &str) -> bool { !id.is_empty() && id.len() < 100 && id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) && !id.contains("..") } @@ -81,19 +153,32 @@ fn point_dir(meta: &Path, id: &str) -> Result { if !id_ok(id) { return Err(Error::PathRejected(format!("bad backup id {id:?}"))); } + if let Some(found) = backup_roots(meta).into_iter().map(|root| root.join(id)).find(|dir| dir.is_dir()) { + return Ok(found); + } Ok(backups_dir(meta).join(id)) } pub fn list(meta: &Path) -> Vec { - let mut out: Vec = fs::read_dir(backups_dir(meta)) - .map(|rd| rd.filter_map(|e| e.ok()).filter_map(|e| fsx::read_json(&e.path().join("backup.json")).ok()).collect()) - .unwrap_or_default(); + let mut out: Vec = Vec::new(); + for root in backup_roots(meta) { + let found: Vec = fs::read_dir(&root) + .map(|rd| rd.filter_map(|e| e.ok()).filter_map(|e| fsx::read_json(&e.path().join("backup.json")).ok()).collect()) + .unwrap_or_default(); + for point in found { + if !out.iter().any(|p| p.id == point.id) { out.push(point); } + } + } out.sort_by(|a, b| b.id.cmp(&a.id)); out } pub fn get(meta: &Path, id: &str) -> Result { - let point: RecoveryPoint = fsx::read_json(&fsx::ensure_within(meta, &point_dir(meta, id)?.join("backup.json"))?).map_err(|_| Error::Invalid(format!("backup {id} was not found or its metadata is damaged")))?; + let dir = point_dir(meta, id)?; + // The folder holding the backups may itself be a junction or symlink to another drive (the owner moved it there); + // what must hold is that the recovery point stays inside that folder. + let holder = dir.parent().ok_or_else(|| Error::PathRejected(format!("bad backup folder for {id}")))?; + let point: RecoveryPoint = fsx::read_json(&fsx::ensure_within(holder, &dir.join("backup.json"))?).map_err(|_| Error::Invalid(format!("backup {id} was not found or its metadata is damaged")))?; if point.id != id || point.schema != 1 { return Err(Error::Invalid(format!("Backup {id} has inconsistent identity or an unsupported schema."))); } Ok(point) } @@ -499,6 +584,34 @@ mod tests { assert!(list(&meta).len() >= 2); } + #[test] + fn backups_can_live_in_a_chosen_folder_and_old_ones_stay_visible() { + let (d, root, meta) = setup(); + let before = create(&root, &meta, Kind::Config, Trigger::Manual, Some("default folder".into()), &|_| {}).unwrap(); + assert!(location(&meta).is_default); + + // inside the server folder is refused, a relative path is refused + assert!(set_location(&root, &meta, Some(root.join("backups").to_str().unwrap())).is_err()); + assert!(set_location(&root, &meta, Some("backups")).is_err()); + + let elsewhere = d.path().join("other drive").join("CoA backups"); + let loc = set_location(&root, &meta, Some(elsewhere.to_str().unwrap())).unwrap(); + assert!(!loc.is_default && elsewhere.is_dir()); + let after = create(&root, &meta, Kind::Config, Trigger::Manual, Some("chosen folder".into()), &|_| {}).unwrap(); + assert!(elsewhere.join(&after.id).join("backup.json").is_file(), "new backups go to the chosen folder"); + assert!(!default_dir(&meta).join(&after.id).exists()); + let ids: Vec<_> = list(&meta).into_iter().map(|p| p.id).collect(); + assert!(ids.contains(&before.id) && ids.contains(&after.id), "the old backup is still listed"); + assert!(verify(&meta, &before.id).unwrap().ok && verify(&meta, &after.id).unwrap().ok); + assert_eq!(point_json(&meta, &after.id).unwrap(), elsewhere.join(&after.id).join("backup.json")); + restore_configs(&root, &meta, &after.id).unwrap(); + delete(&meta, &after.id).unwrap(); + assert!(!elsewhere.join(&after.id).exists()); + + assert!(set_location(&root, &meta, None).unwrap().is_default); + assert!(list(&meta).iter().any(|p| p.id == before.id)); + } + #[test] fn verify_detects_tampering_and_restore_refuses_damaged_backup() { let (_d, root, meta) = setup(); diff --git a/crates/coa-core/src/squid.rs b/crates/coa-core/src/squid.rs index 5ae6897..67222b0 100644 --- a/crates/coa-core/src/squid.rs +++ b/crates/coa-core/src/squid.rs @@ -44,6 +44,15 @@ pub struct Field { pub default_if_missing: Option, pub min: Option, pub max: Option, + /// A fixed list of allowed values with their labels (SQUID 1.9+); the editor shows it as a drop-down. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub choices: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +pub struct Choice { + pub value: serde_json::Value, + pub label: String, } #[derive(Deserialize)] @@ -71,12 +80,20 @@ pub fn fields(root: &Path) -> crate::Result max) || !default_type(&field.kind, &field.default) || field.default_if_missing.as_ref().is_some_and(|value| !default_type(&field.kind, value)) + || !valid_choices(&field) || fields.contains_key(&field.key) { continue; } fields.insert(field.key.clone(), field); } Ok(fields) } +/// A list of choices must be small, labelled, free of repeats and made of values of the field's own type. +fn valid_choices(field: &Field) -> bool { + field.choices.len() <= 64 + && field.choices.iter().all(|c| !c.label.trim().is_empty() && c.label.len() <= 80 && default_type(&field.kind, &c.value)) + && field.choices.iter().enumerate().all(|(i, c)| field.choices[..i].iter().all(|earlier| scalar(&earlier.value) != scalar(&c.value))) +} + fn default_type(kind: &str, value: &serde_json::Value) -> bool { match kind { "bool" => value.is_boolean(), @@ -104,13 +121,28 @@ pub fn validate(field: &Field, value: &str) -> crate::Result<()> { && field.min.is_none_or(|min| number >= min) && field.max.is_none_or(|max| number <= max)), "string" => true, _ => false, - }; + } && (field.choices.is_empty() || field.choices.iter().any(|c| scalar(&c.value).as_deref() == Some(value))); if valid { Ok(()) } else { Err(crate::Error::Invalid(format!("{} must match its documented SquidBots type and range.", field.key))) } } #[cfg(test)] mod tests { use super::*; + #[test] + fn a_setting_with_choices_only_accepts_one_of_them() { + let root = tempfile::tempdir().unwrap(); + crate::fsx::atomic_write(&root.path().join("Core/configs/modules/playerbots.conf.settings.json"), br#"{"format":1,"settings":[ + {"key":"AiPlayerbot.BotTextLocale","type":"int","group":"chat","title":"Language","description":"x","default":-1,"min":-1,"max":8, + "choices":[{"value":-1,"label":"Auto"},{"value":0,"label":"English"},{"value":8,"label":"Russian"}]}, + {"key":"AiPlayerbot.Broken","type":"int","group":"chat","title":"b","description":"x","default":0,"choices":[{"value":"a","label":"wrong type"}]}]}"#).unwrap(); + let fields = fields(root.path()).unwrap(); + let locale = &fields["AiPlayerbot.BotTextLocale"]; + assert_eq!(locale.choices.len(), 3); + for ok in ["-1", "0", "8"] { assert!(validate(locale, ok).is_ok(), "{ok}"); } + for bad in ["1", "7", "x"] { assert!(validate(locale, bad).is_err(), "{bad}"); } + assert!(!fields.contains_key("AiPlayerbot.Broken"), "a choice of the wrong type invalidates the setting"); + } + #[test] fn upstream_settings_preserve_types_ranges_and_distinct_defaults() { let root = tempfile::tempdir().unwrap(); diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index 26a9986..9c7b7bf 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -452,7 +452,7 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result { p.env.ensure_stopped()?; step(report, "Saving a recovery point", 52); txn.recovery_point = Some(p.env.snapshot().map_err(|e| Error::Invalid(format!("The update was not applied because the safety backup failed: {e}")))?); - let point_meta = meta_dir.join("backups").join(txn.recovery_point.as_deref().unwrap()).join("backup.json"); + let point_meta = crate::backup::point_json(meta_dir, txn.recovery_point.as_deref().unwrap())?; if point_meta.is_file() { txn.recovery_hashes.insert("@point".into(), fsx::sha256_file(&point_meta)?); } txn.ops = items @@ -595,7 +595,7 @@ fn restore_transaction(root: &Path, meta: &Path, before: &Path, txn: &mut Txn, e for (rel, expected) in &txn.recovery_hashes { let saved = match rel.as_str() { "@install" => before.join("manager-install.json"), - "@point" => meta.join("backups").join(txn.recovery_point.as_deref().ok_or_else(|| Error::Invalid("The recovery point identity is missing.".into()))?).join("backup.json"), + "@point" => crate::backup::point_json(meta, txn.recovery_point.as_deref().ok_or_else(|| Error::Invalid("The recovery point identity is missing.".into()))?)?, _ => fsx::safe_join(before, rel)?, }; if fsx::sha256_file(&saved)? != *expected { return Err(Error::Invalid(format!("Recovery copy {rel} is damaged; no restoration was started."))); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 08c3133..36a5bb8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -353,6 +353,18 @@ impl Drop for BusyGuard<'_> { } } +#[tauri::command] +fn backup_location(state: State<'_, AppState>, id: String) -> std::result::Result { + let root = path_of(&state, &id)?; + Ok(backup::location(&meta_dir(&root)?)) +} + +#[tauri::command] +async fn set_backup_location(state: State<'_, AppState>, id: String, path: Option) -> std::result::Result { + let root = path_of(&state, &id)?; + blocking(move || backup::set_location(&root, &meta_dir(&root)?, path.as_deref())).await +} + #[tauri::command] fn list_backups(state: State<'_, AppState>, id: String) -> std::result::Result, UiError> { let root = path_of(&state, &id)?; @@ -1559,6 +1571,8 @@ pub fn run() { list_config_snapshots, restore_config_snapshot, list_backups, + backup_location, + set_backup_location, create_backup, verify_backup, delete_backup, diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index fc9f56b..fa81270 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -827,6 +827,12 @@ const de: Partial> = { "set.searchAll": "Alle Einstellungen nach Name oder Beschreibung durchsuchen…", "set.search": "Einstellungen durchsuchen…", "upd.current": "Du bist auf dem neuesten Stand (Version {v}).", + "bk.folder": "Sicherungsordner", + "bk.folderDefault": "Standard", + "bk.folderNote": "Neue Sicherungen werden hier gespeichert, zum Beispiel auf einem anderen Laufwerk. Frühere Sicherungen bleiben, wo sie sind, und werden weiter aufgelistet. Der Ordner muss außerhalb des Serverordners liegen.", + "bk.folderChange": "Ordner ändern…", + "bk.folderReset": "Standardordner verwenden", + "bk.folderChanged": "Sicherungsordner geändert.", "remove.title": "Diesen Server aus dem Manager entfernen", "remove.text": "Der Manager zeigt diesen Server nicht mehr an. Es werden keine Dateien, Datenbanken oder Sicherungen gelöscht; den Ordner kannst du später über „Weiteren Server verwalten“ wieder hinzufügen.", "remove.button": "Aus dem Manager entfernen", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index c293a94..16677cd 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -848,6 +848,12 @@ const en = { "set.searchAll": "Search all settings by name or description…", "set.search": "Search settings…", "upd.current": "You are up to date (version {v}).", + "bk.folder": "Backup folder", + "bk.folderDefault": "default", + "bk.folderNote": "New backups are saved here, for example on another drive. Backups made earlier stay where they are and are still listed. The folder must be outside the server folder.", + "bk.folderChange": "Change folder…", + "bk.folderReset": "Use the default folder", + "bk.folderChanged": "Backup folder changed.", "remove.title": "Remove this server from the Manager", "remove.text": "The Manager stops listing this server. No files, databases or backups are deleted, and you can add the folder again later with “Manage another server”.", "remove.button": "Remove from Manager", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index d6432ba..656a55f 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -827,6 +827,12 @@ const es: Partial> = { "set.searchAll": "Buscar en todos los ajustes por nombre o descripción…", "set.search": "Buscar ajustes…", "upd.current": "Estás al día (versión {v}).", + "bk.folder": "Carpeta de copias de seguridad", + "bk.folderDefault": "predeterminada", + "bk.folderNote": "Las copias nuevas se guardan aquí, por ejemplo en otra unidad. Las anteriores se quedan donde están y siguen en la lista. La carpeta debe estar fuera de la carpeta del servidor.", + "bk.folderChange": "Cambiar carpeta…", + "bk.folderReset": "Usar la carpeta predeterminada", + "bk.folderChanged": "Carpeta de copias cambiada.", "remove.title": "Quitar este servidor del Manager", "remove.text": "El Manager deja de mostrar este servidor. No se borran archivos, bases de datos ni copias de seguridad, y puedes volver a añadir la carpeta con «Gestionar otro servidor».", "remove.button": "Quitar del Manager", diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 0aa1d4e..13e5d12 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -827,6 +827,12 @@ const fr: Partial> = { "set.searchAll": "Rechercher dans tous les réglages par nom ou description…", "set.search": "Rechercher un réglage…", "upd.current": "Vous êtes à jour (version {v}).", + "bk.folder": "Dossier des sauvegardes", + "bk.folderDefault": "par défaut", + "bk.folderNote": "Les nouvelles sauvegardes sont enregistrées ici, par exemple sur un autre disque. Les précédentes restent où elles sont et figurent toujours dans la liste. Le dossier doit se trouver hors du dossier du serveur.", + "bk.folderChange": "Changer de dossier…", + "bk.folderReset": "Utiliser le dossier par défaut", + "bk.folderChanged": "Dossier des sauvegardes modifié.", "remove.title": "Retirer ce serveur du Manager", "remove.text": "Le Manager n’affiche plus ce serveur. Aucun fichier, base de données ni sauvegarde n’est supprimé, et vous pourrez rajouter le dossier plus tard avec « Gérer un autre serveur ».", "remove.button": "Retirer du Manager", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 8a23d89..319f4b0 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -846,6 +846,12 @@ const ru: Partial> = { "set.searchAll": "Поиск по всем настройкам: название или описание…", "set.search": "Поиск по настройкам…", "upd.current": "У вас последняя версия ({v}).", + "bk.folder": "Папка резервных копий", + "bk.folderDefault": "по умолчанию", + "bk.folderNote": "Новые копии сохраняются сюда, например на другой диск. Прежние копии остаются на месте и по-прежнему в списке. Папка должна быть вне папки сервера.", + "bk.folderChange": "Выбрать папку…", + "bk.folderReset": "Папка по умолчанию", + "bk.folderChanged": "Папка резервных копий изменена.", "remove.title": "Убрать сервер из Manager", "remove.text": "Manager перестанет показывать этот сервер. Файлы, базы данных и резервные копии не удаляются, папку можно добавить снова через «Управлять другим сервером».", "remove.button": "Убрать из Manager", diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 5386709..9dc3888 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -827,6 +827,12 @@ const zh = { "set.searchAll": "按名称或描述搜索全部设置…", "set.search": "搜索设置…", "upd.current": "当前已是最新版本 ({v})。", + "bk.folder": "备份文件夹", + "bk.folderDefault": "默认", + "bk.folderNote": "新备份将保存在这里,例如另一个驱动器上。之前的备份保留在原处,仍会列出。该文件夹必须位于服务器文件夹之外。", + "bk.folderChange": "更改文件夹…", + "bk.folderReset": "使用默认文件夹", + "bk.folderChanged": "备份文件夹已更改。", "remove.title": "从 Manager 中移除此服务器", "remove.text": "Manager 将不再列出此服务器。不会删除任何文件、数据库或备份,之后可通过“管理其他服务器”重新添加该文件夹。", "remove.button": "从 Manager 中移除", diff --git a/src/lib/api.ts b/src/lib/api.ts index 0713057..4acf7b2 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -78,6 +78,12 @@ export interface DriverOutcome { output: string; } +export interface BackupLocation { + path: string; + default_path: string; + is_default: boolean; +} + export interface ServerSummary { id: string; name: string; @@ -217,7 +223,7 @@ export interface ModuleSetting { value: string; default: string | null; doc: string; - field?: { key: string; type: string; title: string; description: string; group: string; group_title?: string | null; min?: number | null; max?: number | null } | null; + field?: { key: string; type: string; title: string; description: string; group: string; group_title?: string | null; min?: number | null; max?: number | null; choices?: { value: string | number | boolean; label: string }[] | null } | null; } export interface AllSetting { @@ -425,6 +431,9 @@ export const api = { save: (id: string, scope: Scope, changes: Record) => invoke("save_settings", { id, scope, changes }), presets: (scope: Scope) => invoke("list_presets", { scope }), backups: (id: string) => invoke("list_backups", { id }), + backupLocation: (id: string) => invoke("backup_location", { id }), + /** `null` goes back to the default folder next to the server. */ + setBackupLocation: (id: string, path: string | null) => invoke("set_backup_location", { id, path }), createBackup: (id: string, kind: BackupKind, label?: string) => invoke("create_backup", { id, kind, label: label ?? null }), verifyBackup: (id: string, backupId: string) => invoke<{ ok: boolean; problems: string[] }>("verify_backup", { id, backupId }), deleteBackup: (id: string, backupId: string) => invoke("delete_backup", { id, backupId }), diff --git a/src/lib/moduleSettings.ts b/src/lib/moduleSettings.ts index b82b6b2..3a71d16 100644 --- a/src/lib/moduleSettings.ts +++ b/src/lib/moduleSettings.ts @@ -175,7 +175,11 @@ for (const [category, categoryNames] of Object.entries(auctionCategories)) { } export function moduleField(key: string, locale: Locale, metadata?: ModuleSetting["field"]): ModuleField | undefined { const translated = locale !== "en" ? fields.find(f => f.key === key) : undefined; - if (metadata) return { title: translated ? text(translated.title, locale) : metadata.title, description: translated ? text(translated.description, locale) : metadata.description, type: metadata.type, category: metadata.group, advanced: false, min: metadata.min ?? undefined, max: metadata.max ?? undefined }; + if (metadata) { + // A fixed list of values is edited as a drop-down; the values stay strings, as they are written to the config. + const options = metadata.choices?.length ? metadata.choices.map(c => ({ value: String(c.value), label: c.label })) : undefined; + return { title: translated ? text(translated.title, locale) : metadata.title, description: translated ? text(translated.description, locale) : metadata.description, type: options ? "enum" : metadata.type, category: metadata.group, advanced: false, min: metadata.min ?? undefined, max: metadata.max ?? undefined, ...(options ? { options } : {}) } as ModuleField; + } const field = fields.find(f => f.key === key); if (field) return { ...field, title: text(field.title, locale), description: text(field.description, locale), category: "general", advanced: field.advanced ?? false, step: field.type === "range" ? 0.05 : undefined, options: field.options?.map(o => ({ value: o.value, label: text(o.label, locale) })) }; const existing = [...bots.settings, ...server.settings].find(s => s.key === key); diff --git a/src/screens/BackupsPage.tsx b/src/screens/BackupsPage.tsx index 84144f5..35b5104 100644 --- a/src/screens/BackupsPage.tsx +++ b/src/screens/BackupsPage.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useState } from "react"; import { listen } from "@tauri-apps/api/event"; import { Loader2, ShieldCheck } from "lucide-react"; -import { api, asUiError, type BackupKind, type RecoveryPoint, type UiError } from "@/lib/api"; +import { open } from "@tauri-apps/plugin-dialog"; +import { api, asUiError, type BackupKind, type BackupLocation, type RecoveryPoint, type UiError } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { useHuman, useI18n, type Key, type Locale } from "@/i18n"; @@ -45,9 +46,11 @@ export function BackupsPage({ serverId }: { serverId: string }) { const [notice, setNotice] = useState(null); const [restoring, setRestoring] = useState(null); const [running, setRunning] = useState(false); + const [location, setLocation] = useState(null); const load = useCallback(async () => { setPoints(await api.backups(serverId)); + setLocation(await api.backupLocation(serverId)); const s = await api.status(serverId); setRunning(s.observed.world.state !== "stopped" || s.observed.auth.state !== "stopped"); }, [serverId]); @@ -113,6 +116,23 @@ export function BackupsPage({ serverId }: { serverId: string }) { + {location && ( + +

{t("bk.folder")}

+

{location.path}{location.is_default ? ` (${t("bk.folderDefault")})` : ""}

+

{t("bk.folderNote")}

+
+ + {!location.is_default && ( + + )} +
+
+ )} + {notice &&

{notice}

} {error && (