diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 97c1405370..b437388bf6 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -24,7 +24,7 @@ dependencies = [ "agama-lib", "anyhow", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.16", "tokio", "url", ] @@ -47,15 +47,33 @@ dependencies = [ "reqwest", "serde_json", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.16", "tokio", "url", ] +[[package]] +name = "agama-l10n" +version = "0.1.0" +dependencies = [ + "agama-locale-data", + "anyhow", + "gettext-rs", + "merge-struct", + "regex", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.16", + "tracing", + "utoipa", +] + [[package]] name = "agama-lib" version = "1.0.0" dependencies = [ + "agama-l10n", "agama-locale-data", "agama-network", "agama-utils", @@ -81,7 +99,7 @@ dependencies = [ "serde_with", "strum", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.16", "tokio", "tokio-native-tls", "tokio-stream", @@ -102,7 +120,7 @@ dependencies = [ "quick-xml", "regex", "serde", - "thiserror 2.0.12", + "thiserror 2.0.16", "utoipa", ] @@ -121,7 +139,7 @@ dependencies = [ "serde", "serde_with", "strum", - "thiserror 2.0.12", + "thiserror 2.0.16", "tokio", "tokio-stream", "tokio-test", @@ -135,6 +153,7 @@ dependencies = [ name = "agama-server" version = "0.1.0" dependencies = [ + "agama-l10n", "agama-lib", "agama-locale-data", "agama-utils", @@ -151,6 +170,7 @@ dependencies = [ "hyper 1.6.0", "hyper-util", "libsystemd", + "merge-struct", "openssl", "pam", "pin-project", @@ -160,9 +180,10 @@ dependencies = [ "serde", "serde_json", "serde_with", + "strum", "subprocess", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.16", "tokio", "tokio-openssl", "tokio-stream", @@ -294,9 +315,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.98" +version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" [[package]] name = "arraydeque" @@ -584,6 +605,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", "axum-core", + "axum-macros", "base64 0.22.1", "bytes", "futures-util", @@ -659,6 +681,17 @@ dependencies = [ "tower-service", ] +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "backtrace" version = "0.3.74" @@ -2476,7 +2509,7 @@ dependencies = [ "once_cell", "serde", "sha2", - "thiserror 2.0.12", + "thiserror 2.0.16", "uuid", ] @@ -2596,6 +2629,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "merge-struct" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d82012d21e24135b839b6b9bebd622b7ff0cb40071498bc2d066d3a6d04dd4a" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "mime" version = "0.3.17" @@ -3065,7 +3108,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6" dependencies = [ "memchr", - "thiserror 2.0.12", + "thiserror 2.0.16", "ucd-trie", ] @@ -3431,9 +3474,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.1" +version = "1.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" dependencies = [ "aho-corasick", "memchr", @@ -3669,6 +3712,30 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -3735,9 +3802,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.143" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" dependencies = [ "itoa", "memchr", @@ -3799,15 +3866,17 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.12.0" +version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" +checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5" dependencies = [ "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", "indexmap 2.9.0", + "schemars 0.9.0", + "schemars 1.0.4", "serde", "serde_derive", "serde_json", @@ -3817,9 +3886,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.12.0" +version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" +checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" dependencies = [ "darling", "proc-macro2", @@ -3908,7 +3977,7 @@ checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.12", + "thiserror 2.0.16", "time", ] @@ -3981,9 +4050,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ "strum_macros", ] @@ -4131,11 +4200,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl 2.0.16", ] [[package]] @@ -4151,9 +4220,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" dependencies = [ "proc-macro2", "quote", @@ -4551,7 +4620,7 @@ dependencies = [ "native-tls", "rand 0.9.1", "sha1", - "thiserror 2.0.12", + "thiserror 2.0.16", "utf-8", ] @@ -4658,9 +4727,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "utoipa" -version = "5.3.1" +version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435c6f69ef38c9017b4b4eea965dfb91e71e53d869e896db40d1cf2441dd75c0" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" dependencies = [ "indexmap 2.9.0", "serde", @@ -4670,9 +4739,9 @@ dependencies = [ [[package]] name = "utoipa-gen" -version = "5.3.1" +version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77d306bc75294fd52f3e99b13ece67c02c1a2789190a6f31d32f736624326f7" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" dependencies = [ "proc-macro2", "quote", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 4b93ca5f00..b16499414d 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -2,10 +2,11 @@ members = [ "agama-autoinstall", "agama-cli", - "agama-server", + "agama-l10n", "agama-lib", "agama-locale-data", "agama-network", + "agama-server", "agama-utils", "xtask", ] diff --git a/rust/agama-l10n/Cargo.toml b/rust/agama-l10n/Cargo.toml new file mode 100644 index 0000000000..15beeb643e --- /dev/null +++ b/rust/agama-l10n/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "agama-l10n" +version = "0.1.0" +rust-version.workspace = true +edition.workspace = true + +[dependencies] +anyhow = "1.0.99" +merge-struct = "0.1.0" +serde = { version = "1.0.219", features = ["derive"] } +thiserror = "2.0.16" +agama-locale-data = { path = "../agama-locale-data" } +regex = "1.11.2" +tracing = "0.1.41" +serde_with = "3.14.0" +utoipa = "5.4.0" +gettext-rs = { version = "0.7.2", features = ["gettext-system"] } +serde_json = "1.0.143" diff --git a/rust/agama-l10n/src/actions.rs b/rust/agama-l10n/src/actions.rs new file mode 100644 index 0000000000..a8e33de4ba --- /dev/null +++ b/rust/agama-l10n/src/actions.rs @@ -0,0 +1,49 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::{L10n}; +use serde::{Deserialize}; + +#[derive(Debug, Deserialize)] +pub struct ConfigureSystemAction { + pub language: Option, + pub keyboard: Option, +} + +impl ConfigureSystemAction { + // FIXME: return an action error instead of using anyhow. + pub fn run(self, l10n: &mut L10n) -> anyhow::Result<()> { + // TODO: redesign actions + + // if let Some(language) = self.language { + // let locale = &language.as_str().try_into()?; + // l10n.model.translate(locale)?; + // } + + // if let Some(keyboard) = self.keyboard { + // let keymap = (&keyboard).parse().map_err(LocaleError::InvalidKeymap)?; + // l10n.model.set_ui_keymap(keymap)?; + // }; + + // TODO: update state (system). + + Ok(()) + } +} diff --git a/rust/agama-lib/src/localization/settings.rs b/rust/agama-l10n/src/config.rs similarity index 93% rename from rust/agama-lib/src/localization/settings.rs rename to rust/agama-l10n/src/config.rs index 5548bf0014..de99808967 100644 --- a/rust/agama-lib/src/localization/settings.rs +++ b/rust/agama-l10n/src/config.rs @@ -24,9 +24,9 @@ use serde::{Deserialize, Serialize}; /// Localization settings for the system being installed (not the UI) /// FIXME: this one is close to CLI. A possible duplicate close to HTTP is LocaleConfig -#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] -pub struct LocalizationSettings { +pub struct L10nConfig { /// like "en_US.UTF-8" #[serde(skip_serializing_if = "Option::is_none")] pub language: Option, diff --git a/rust/agama-server/src/l10n/error.rs b/rust/agama-l10n/src/error.rs similarity index 76% rename from rust/agama-server/src/l10n/error.rs rename to rust/agama-l10n/src/error.rs index deb350dc08..b16b4cd2bd 100644 --- a/rust/agama-server/src/l10n/error.rs +++ b/rust/agama-l10n/src/error.rs @@ -18,20 +18,24 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_locale_data::{InvalidKeymap, InvalidLocaleCode, KeymapId, LocaleId}; +use agama_locale_data::{InvalidKeymapId, InvalidLocaleId, InvalidTimezoneId, KeymapId, LocaleId}; #[derive(thiserror::Error, Debug)] pub enum LocaleError { #[error("Unknown locale code: {0}")] UnknownLocale(LocaleId), #[error("Invalid locale: {0}")] - InvalidLocale(#[from] InvalidLocaleCode), + InvalidLocale(#[from] InvalidLocaleId), #[error("Unknown timezone: {0}")] UnknownTimezone(String), + #[error("Invalid timezone")] + InvalidTimezone(#[from] InvalidTimezoneId), #[error("Unknown keymap: {0}")] UnknownKeymap(KeymapId), #[error("Invalid keymap: {0}")] - InvalidKeymap(#[from] InvalidKeymap), + InvalidKeymap(#[from] InvalidKeymapId), #[error("Could not apply the l10n settings: {0}")] Commit(#[from] std::io::Error), + #[error("Could not merge the current and the new configuration")] + Merge(#[from] serde_json::error::Error), } diff --git a/rust/agama-server/src/l10n/helpers.rs b/rust/agama-l10n/src/helpers.rs similarity index 95% rename from rust/agama-server/src/l10n/helpers.rs rename to rust/agama-l10n/src/helpers.rs index e39229529a..bb7f95d0dd 100644 --- a/rust/agama-server/src/l10n/helpers.rs +++ b/rust/agama-l10n/src/helpers.rs @@ -31,7 +31,7 @@ use std::env; /// It returns the used locale. Defaults to `en_US.UTF-8`. pub fn init_locale() -> Result> { let lang = env::var("LANG").unwrap_or("en_US.UTF-8".to_string()); - let locale: LocaleId = lang.as_str().try_into().unwrap_or_default(); + let locale = lang.parse().unwrap_or_default(); set_service_locale(&locale); textdomain("xkeyboard-config")?; diff --git a/rust/agama-l10n/src/l10n.rs b/rust/agama-l10n/src/l10n.rs new file mode 100644 index 0000000000..ebaf4a4765 --- /dev/null +++ b/rust/agama-l10n/src/l10n.rs @@ -0,0 +1,127 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::{actions, L10nConfig, L10nModel, L10nProposal, L10nSystemInfo, LocaleError}; +use agama_locale_data::{KeymapId, LocaleId, TimezoneId}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub enum L10nAction { + #[serde(rename = "configureL10n")] + ConfigureSystem(actions::ConfigureSystemAction), +} + +pub struct L10n { + state: State, + model: L10nModel, +} + +struct State { + system: L10nSystemInfo, + config: Config, +} + +impl L10n { + pub fn new() -> Self { + let model = L10nModel::new_with_locale(&LocaleId::default()).unwrap(); + let system = L10nSystemInfo::read_from(&model); + let config = Config::new_from(&system); + + let state = State { + system, + config, + }; + + Self { + state, + model, + } + } + + pub fn get_config(&self) -> L10nConfig { + (&self.state.config).into() + } + + pub fn set_config(&mut self, user_config: &L10nConfig) -> Result<(), LocaleError> { + self.state.config.merge(user_config) + } + + pub fn get_proposal(&self) -> L10nProposal { + (&self.state.config).into() + } + + pub fn dispatch(&mut self, action: L10nAction) -> anyhow::Result<()> { + match action { + L10nAction::ConfigureSystem(action) => action.run(self), + } + } +} + +struct Config { + locale: LocaleId, + keymap: KeymapId, + timezone: TimezoneId, +} + +impl Config { + fn new_from(system: &L10nSystemInfo) -> Self { + Self { + locale: system.locale.clone(), + keymap: system.keymap.clone(), + timezone: system.timezone.clone(), + } + } + + fn merge(&mut self, config: &L10nConfig) -> Result<(), LocaleError> { + if let Some(language) = &config.language { + self.locale = language.parse().map_err(LocaleError::InvalidLocale)? + } + + if let Some(keyboard) = &config.keyboard { + self.keymap = keyboard.parse().map_err(LocaleError::InvalidKeymap)? + } + + if let Some(timezone) = &config.timezone { + self.timezone = timezone.parse().map_err(LocaleError::InvalidTimezone)?; + } + + Ok(()) + } +} + +impl From<&Config> for L10nConfig { + fn from(config: &Config) -> Self { + L10nConfig { + language: Some(config.locale.to_string()), + keyboard: Some(config.keymap.to_string()), + timezone: Some(config.timezone.to_string()), + } + } +} + +impl From<&Config> for L10nProposal { + fn from(config: &Config) -> Self { + L10nProposal { + keymap: config.keymap.clone(), + locale: config.locale.clone(), + timezone: config.timezone.clone(), + } + } +} diff --git a/rust/agama-l10n/src/lib.rs b/rust/agama-l10n/src/lib.rs new file mode 100644 index 0000000000..3781c93cc0 --- /dev/null +++ b/rust/agama-l10n/src/lib.rs @@ -0,0 +1,35 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +pub mod actions; +mod l10n; +mod config; +mod error; +pub mod helpers; +mod model; +mod proposal; +mod system_info; + +pub use l10n::{L10n, L10nAction}; +pub use config::L10nConfig; +pub use error::LocaleError; +pub use model::{Keymap, L10nModel, LocaleEntry, TimezoneEntry}; +pub use proposal::L10nProposal; +pub use system_info::L10nSystemInfo; diff --git a/rust/agama-server/src/l10n/model.rs b/rust/agama-l10n/src/model.rs similarity index 93% rename from rust/agama-server/src/l10n/model.rs rename to rust/agama-l10n/src/model.rs index a55f20d163..08089d4eec 100644 --- a/rust/agama-server/src/l10n/model.rs +++ b/rust/agama-l10n/src/model.rs @@ -22,9 +22,7 @@ use std::fs::OpenOptions; use std::io::Write; use std::process::Command; -use crate::error::Error; -use agama_locale_data::InvalidLocaleCode; -use agama_locale_data::{KeymapId, LocaleId}; +use agama_locale_data::{InvalidLocaleId, KeymapId, LocaleId}; use regex::Regex; pub mod keyboard; @@ -40,7 +38,7 @@ use keyboard::KeymapsDatabase; use locale::LocalesDatabase; use timezone::TimezonesDatabase; -pub struct L10n { +pub struct L10nModel { pub timezone: String, pub timezones_db: TimezonesDatabase, pub locales: Vec, @@ -51,8 +49,9 @@ pub struct L10n { pub ui_keymap: KeymapId, } -impl L10n { - pub fn new_with_locale(ui_locale: &LocaleId) -> Result { +impl L10nModel { + // pub fn new_with_locale(ui_locale: &LocaleId) -> Result { + pub fn new_with_locale(ui_locale: &LocaleId) -> anyhow::Result { const DEFAULT_TIMEZONE: &str = "Europe/Berlin"; let locale = ui_locale.to_string(); @@ -91,10 +90,10 @@ impl L10n { } pub fn set_locales(&mut self, locales: &Vec) -> Result<(), LocaleError> { - let locale_ids: Result, InvalidLocaleCode> = locales + let locale_ids: Result, InvalidLocaleId> = locales .iter() .cloned() - .map(|l| l.as_str().try_into()) + .map(|l| l.parse::()) .collect(); let locale_ids = locale_ids?; @@ -127,7 +126,7 @@ impl L10n { } // TODO: use LocaleError - pub fn translate(&mut self, locale: &LocaleId) -> Result<(), Error> { + pub fn translate(&mut self, locale: &LocaleId) -> anyhow::Result<()> { helpers::set_service_locale(locale); self.timezones_db.read(&locale.language)?; self.locales_db.read(&locale.language)?; diff --git a/rust/agama-server/src/l10n/model/keyboard.rs b/rust/agama-l10n/src/model/keyboard.rs similarity index 100% rename from rust/agama-server/src/l10n/model/keyboard.rs rename to rust/agama-l10n/src/model/keyboard.rs diff --git a/rust/agama-server/src/l10n/model/locale.rs b/rust/agama-l10n/src/model/locale.rs similarity index 90% rename from rust/agama-server/src/l10n/model/locale.rs rename to rust/agama-l10n/src/model/locale.rs index 935e883a23..6fc3486216 100644 --- a/rust/agama-server/src/l10n/model/locale.rs +++ b/rust/agama-l10n/src/model/locale.rs @@ -20,7 +20,6 @@ //! This module provides support for reading the locales database. -use crate::error::Error; use agama_locale_data::LocaleId; use anyhow::Context; use serde::Serialize; @@ -63,7 +62,7 @@ impl LocalesDatabase { /// It it does not exists, calls `localectl list-locales`. /// /// * `ui_language`: language to translate the descriptions (e.g., "en"). - pub fn read(&mut self, ui_language: &str) -> Result<(), Error> { + pub fn read(&mut self, ui_language: &str) -> anyhow::Result<()> { self.known_locales = Self::get_locales_list()?; self.locales = self.get_locales(ui_language)?; Ok(()) @@ -89,7 +88,7 @@ impl LocalesDatabase { /// Gets the supported locales information. /// /// * `ui_language`: language to use in the translations. - fn get_locales(&self, ui_language: &str) -> Result, Error> { + fn get_locales(&self, ui_language: &str) -> anyhow::Result> { const DEFAULT_LANG: &str = "en"; let mut result = Vec::with_capacity(self.known_locales.len()); let languages = agama_locale_data::get_languages()?; @@ -135,7 +134,7 @@ impl LocalesDatabase { Ok(result) } - fn get_locales_list() -> Result, Error> { + fn get_locales_list() -> anyhow::Result> { const LOCALES_LIST_PATH: &str = "/etc/agama.d/locales"; let locales = fs::read_to_string(LOCALES_LIST_PATH).map(Self::get_locales_from_string); @@ -159,10 +158,7 @@ impl LocalesDatabase { } fn get_locales_from_string(locales: String) -> Vec { - locales - .lines() - .filter_map(|line| TryInto::::try_into(line).ok()) - .collect() + locales.lines().filter_map(|l| l.parse().ok()).collect() } } @@ -178,7 +174,7 @@ mod tests { let mut db = LocalesDatabase::new(); db.read("de").unwrap(); let found_locales = db.entries(); - let spanish: LocaleId = "es_ES".try_into().unwrap(); + let spanish = "es_ES".parse::().unwrap(); let found = found_locales .iter() .find(|l| l.id == spanish) @@ -189,14 +185,14 @@ mod tests { #[test] fn test_try_into_locale() { - let locale = LocaleId::try_from("es_ES.UTF-16").unwrap(); + let locale = "es_ES.UTF-16".parse::().unwrap(); assert_eq!(&locale.language, "es"); assert_eq!(&locale.territory, "ES"); assert_eq!(&locale.encoding, "UTF-16"); assert_eq!(locale.to_string(), String::from("es_ES.UTF-16")); - let invalid = LocaleId::try_from("."); + let invalid = ".".parse::(); assert!(invalid.is_err()); } @@ -206,8 +202,8 @@ mod tests { fn test_locale_exists() { let mut db = LocalesDatabase::new(); db.read("en").unwrap(); - let en_us = LocaleId::try_from("en_US").unwrap(); - let unknown = LocaleId::try_from("unknown_UNKNOWN").unwrap(); + let en_us = "en_US".parse::().unwrap(); + let unknown = "unknown_UNKNOWN".parse::().unwrap(); assert!(db.exists(&en_us)); assert!(!db.exists(&unknown)); } diff --git a/rust/agama-server/src/l10n/model/timezone.rs b/rust/agama-l10n/src/model/timezone.rs similarity index 96% rename from rust/agama-server/src/l10n/model/timezone.rs rename to rust/agama-l10n/src/model/timezone.rs index 192c240cb1..b6c8c51ae9 100644 --- a/rust/agama-server/src/l10n/model/timezone.rs +++ b/rust/agama-l10n/src/model/timezone.rs @@ -20,7 +20,6 @@ //! This module provides support for reading the timezones database. -use crate::error::Error; use agama_locale_data::territory::Territories; use agama_locale_data::timezone_part::TimezoneIdParts; use serde::Serialize; @@ -50,7 +49,7 @@ impl TimezonesDatabase { /// Initializes the list of known timezones. /// /// * `ui_language`: language to translate the descriptions (e.g., "en"). - pub fn read(&mut self, ui_language: &str) -> Result<(), Error> { + pub fn read(&mut self, ui_language: &str) -> anyhow::Result<()> { self.timezones = self.get_timezones(ui_language)?; Ok(()) } @@ -71,7 +70,7 @@ impl TimezonesDatabase { /// containing the translation of each part of the language. /// /// * `ui_language`: language to translate the descriptions (e.g., "en"). - fn get_timezones(&self, ui_language: &str) -> Result, Error> { + fn get_timezones(&self, ui_language: &str) -> anyhow::Result> { let timezones = agama_locale_data::get_timezones(); let tz_parts = agama_locale_data::get_timezone_parts()?; let territories = agama_locale_data::get_territories()?; diff --git a/rust/agama-l10n/src/proposal.rs b/rust/agama-l10n/src/proposal.rs new file mode 100644 index 0000000000..5bf54e396f --- /dev/null +++ b/rust/agama-l10n/src/proposal.rs @@ -0,0 +1,34 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use agama_locale_data::{KeymapId, LocaleId, TimezoneId}; +use serde::Serialize; +use serde_with::{serde_as, DisplayFromStr}; + +#[serde_as] +#[derive(Clone, Debug, Serialize)] +pub struct L10nProposal { + #[serde_as(as = "DisplayFromStr")] + pub keymap: KeymapId, + #[serde_as(as = "DisplayFromStr")] + pub locale: LocaleId, + #[serde_as(as = "DisplayFromStr")] + pub timezone: TimezoneId, +} diff --git a/rust/agama-l10n/src/system_info.rs b/rust/agama-l10n/src/system_info.rs new file mode 100644 index 0000000000..b205482d03 --- /dev/null +++ b/rust/agama-l10n/src/system_info.rs @@ -0,0 +1,51 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use agama_locale_data::{KeymapId, LocaleId, TimezoneId}; +use serde::Serialize; +use crate::L10nModel; +use super::{Keymap, LocaleEntry, TimezoneEntry}; + +#[derive(Debug, Serialize)] +pub struct L10nSystemInfo { + pub locales: Vec, + pub timezones: Vec, + pub keymaps: Vec, + pub locale: LocaleId, + pub keymap: KeymapId, + pub timezone: TimezoneId, +} + +impl L10nSystemInfo { + pub fn read_from(model: &L10nModel) -> Self { + let locales = model.locales_db.entries().clone(); + let keymaps = model.keymaps_db.entries().clone(); + let timezones = model.timezones_db.entries().clone(); + + Self { + locales, + keymaps, + timezones, + locale: model.ui_locale.clone(), + keymap: model.ui_keymap.clone(), + timezone: Default::default(), + } + } +} \ No newline at end of file diff --git a/rust/agama-lib/Cargo.toml b/rust/agama-lib/Cargo.toml index 86717c2b40..3303376ce4 100644 --- a/rust/agama-lib/Cargo.toml +++ b/rust/agama-lib/Cargo.toml @@ -10,6 +10,7 @@ anyhow = "1.0" agama-utils = { path = "../agama-utils" } agama-network = { path = "../agama-network" } agama-locale-data = { path = "../agama-locale-data" } +agama-l10n = { path = "../agama-l10n" } async-trait = "0.1.83" futures-util = "0.3.30" jsonschema = { version = "0.30.0", default-features = false, features = [ diff --git a/rust/agama-lib/src/config.rs b/rust/agama-lib/src/config.rs new file mode 100644 index 0000000000..8eb74b74db --- /dev/null +++ b/rust/agama-lib/src/config.rs @@ -0,0 +1,21 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +pub use agama_l10n::L10nConfig; diff --git a/rust/agama-lib/src/http/event.rs b/rust/agama-lib/src/http/event.rs index fb4e92ae21..6a2f92081c 100644 --- a/rust/agama-lib/src/http/event.rs +++ b/rust/agama-lib/src/http/event.rs @@ -21,7 +21,6 @@ use crate::{ auth::ClientId, jobs::Job, - localization::model::LocaleConfig, manager::InstallationPhase, network::model::NetworkChange, progress::Progress, @@ -81,7 +80,6 @@ impl Event { #[serde(tag = "type")] pub enum EventPayload { ClientConnected, - L10nConfigChanged(LocaleConfig), LocaleChanged { locale: String, }, diff --git a/rust/agama-lib/src/install_settings.rs b/rust/agama-lib/src/install_settings.rs index bda21d4b34..b7867784ef 100644 --- a/rust/agama-lib/src/install_settings.rs +++ b/rust/agama-lib/src/install_settings.rs @@ -22,6 +22,7 @@ //! //! This module implements the mechanisms to load and store the installation settings. use crate::bootloader::model::BootloaderSettings; +use crate::config::L10nConfig; use crate::context::InstallationContext; use crate::file_source::{FileSourceError, WithFileSource}; use crate::files::model::UserFile; @@ -30,9 +31,8 @@ use crate::questions::config::QuestionsConfig; use crate::security::settings::SecuritySettings; use crate::storage::settings::zfcp::ZFCPConfig; use crate::{ - localization::LocalizationSettings, network::NetworkSettings, product::ProductSettings, - scripts::ScriptsConfig, software::SoftwareSettings, storage::settings::dasd::DASDConfig, - users::UserSettings, + network::NetworkSettings, product::ProductSettings, scripts::ScriptsConfig, + software::SoftwareSettings, storage::settings::dasd::DASDConfig, users::UserSettings, }; use fluent_uri::Uri; use serde::{Deserialize, Serialize}; @@ -54,7 +54,7 @@ pub enum InstallSettingsError { /// /// This struct represents installation settings. It serves as an entry point and it is composed of /// other structs which hold the settings for each area ("users", "software", etc.). -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct InstallSettings { #[serde(skip_serializing_if = "Option::is_none")] @@ -83,7 +83,7 @@ pub struct InstallSettings { #[serde(skip_serializing_if = "Option::is_none")] pub network: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub localization: Option, + pub localization: Option, #[serde(skip_serializing_if = "Option::is_none")] pub scripts: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/rust/agama-lib/src/lib.rs b/rust/agama-lib/src/lib.rs index 02fa38d1a7..7c9b9ecfeb 100644 --- a/rust/agama-lib/src/lib.rs +++ b/rust/agama-lib/src/lib.rs @@ -45,6 +45,7 @@ pub mod auth; pub mod bootloader; +pub mod config; pub mod context; pub mod error; pub mod file_source; @@ -54,7 +55,6 @@ pub mod http; pub mod install_settings; pub mod issue; pub mod jobs; -pub mod localization; pub mod logs; pub mod manager; pub mod monitor; diff --git a/rust/agama-lib/src/localization/store.rs b/rust/agama-lib/src/localization/store.rs deleted file mode 100644 index 9bda06fb48..0000000000 --- a/rust/agama-lib/src/localization/store.rs +++ /dev/null @@ -1,175 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! Implements the store for the localization settings. -// TODO: for an overview see crate::store (?) - -use super::{ - http_client::LocalizationHTTPClientError, LocalizationHTTPClient, LocalizationSettings, -}; -use crate::{http::BaseHTTPClient, localization::model::LocaleConfig}; - -#[derive(Debug, thiserror::Error)] -#[error("Error processing localization settings: {0}")] -pub struct LocalizationStoreError(#[from] LocalizationHTTPClientError); - -type LocalizationStoreResult = Result; - -/// Loads and stores the storage settings from/to the D-Bus service. -pub struct LocalizationStore { - localization_client: LocalizationHTTPClient, -} - -impl LocalizationStore { - pub fn new(client: BaseHTTPClient) -> Self { - Self { - localization_client: LocalizationHTTPClient::new(client), - } - } - - pub fn new_with_client(client: LocalizationHTTPClient) -> Self { - Self { - localization_client: client, - } - } - - /// Consume *v* and return its first element, or None. - /// This is similar to VecDeque::pop_front but it consumes the whole Vec. - fn chestburster(mut v: Vec) -> Option { - if v.is_empty() { - None - } else { - Some(v.swap_remove(0)) - } - } - - pub async fn load(&self) -> LocalizationStoreResult { - let config = self.localization_client.get_config().await?; - - let opt_language = config.locales.and_then(Self::chestburster); - let opt_keyboard = config.keymap; - let opt_timezone = config.timezone; - - Ok(LocalizationSettings { - language: opt_language, - keyboard: opt_keyboard, - timezone: opt_timezone, - }) - } - - pub async fn store(&self, settings: &LocalizationSettings) -> LocalizationStoreResult<()> { - // clones are necessary as we have different structs owning their data - let opt_language = settings.language.clone(); - let opt_keymap = settings.keyboard.clone(); - let opt_timezone = settings.timezone.clone(); - - let config = LocaleConfig { - locales: opt_language.map(|s| vec![s]), - keymap: opt_keymap, - timezone: opt_timezone, - ui_locale: None, - ui_keymap: None, - }; - Ok(self.localization_client.set_config(&config).await?) - } -} - -#[cfg(test)] -mod test { - use super::*; - use crate::http::BaseHTTPClient; - use httpmock::prelude::*; - use httpmock::Method::PATCH; - use std::error::Error; - use tokio::test; // without this, "error: async functions cannot be used for tests" - - async fn localization_store( - mock_server_url: String, - ) -> Result> { - let bhc = - BaseHTTPClient::new(mock_server_url).map_err(LocalizationHTTPClientError::HTTP)?; - let client = LocalizationHTTPClient::new(bhc); - Ok(LocalizationStore::new_with_client(client)) - } - - #[test] - async fn test_getting_l10n() -> Result<(), Box> { - let server = MockServer::start(); - let l10n_mock = server.mock(|when, then| { - when.method(GET).path("/api/l10n/config"); - then.status(200) - .header("content-type", "application/json") - .body( - r#"{ - "locales": ["fr_FR.UTF-8"], - "keymap": "fr(dvorak)", - "timezone": "Europe/Paris" - }"#, - ); - }); - let url = server.url("/api"); - - let store = localization_store(url).await?; - let settings = store.load().await?; - - let expected = LocalizationSettings { - language: Some("fr_FR.UTF-8".to_owned()), - keyboard: Some("fr(dvorak)".to_owned()), - timezone: Some("Europe/Paris".to_owned()), - }; - // main assertion - assert_eq!(settings, expected); - - // Ensure the specified mock was called exactly one time (or fail with a detailed error description). - l10n_mock.assert(); - Ok(()) - } - - #[test] - async fn test_setting_l10n() -> Result<(), Box> { - let server = MockServer::start(); - let l10n_mock = server.mock(|when, then| { - when.method(PATCH) - .path("/api/l10n/config") - .header("content-type", "application/json") - .body( - r#"{"locales":["fr_FR.UTF-8"],"keymap":"fr(dvorak)","timezone":"Europe/Paris","uiLocale":null,"uiKeymap":null}"# - ); - then.status(204); - }); - let url = server.url("/api"); - - let store = localization_store(url).await?; - - let settings = LocalizationSettings { - language: Some("fr_FR.UTF-8".to_owned()), - keyboard: Some("fr(dvorak)".to_owned()), - timezone: Some("Europe/Paris".to_owned()), - }; - let result = store.store(&settings).await; - - // main assertion - result?; - - // Ensure the specified mock was called exactly one time (or fail with a detailed error description). - l10n_mock.assert(); - Ok(()) - } -} diff --git a/rust/agama-lib/src/product/settings.rs b/rust/agama-lib/src/product/settings.rs index a1348b707e..d40ed37db4 100644 --- a/rust/agama-lib/src/product/settings.rs +++ b/rust/agama-lib/src/product/settings.rs @@ -23,7 +23,7 @@ use serde::{Deserialize, Serialize}; /// Addon settings for registration -#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct AddonSettings { pub id: String, @@ -37,7 +37,7 @@ pub struct AddonSettings { } /// Software settings for installation -#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProductSettings { /// ID of the product to install (e.g., "ALP", "Tumbleweed", etc.) diff --git a/rust/agama-lib/src/scripts/settings.rs b/rust/agama-lib/src/scripts/settings.rs index dbb32d0ed7..4792a8e9aa 100644 --- a/rust/agama-lib/src/scripts/settings.rs +++ b/rust/agama-lib/src/scripts/settings.rs @@ -25,7 +25,7 @@ use crate::file_source::{FileSourceError, WithFileSource}; use super::{InitScript, PostPartitioningScript, PostScript, PreScript}; -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScriptsConfig { /// User-defined pre-installation scripts diff --git a/rust/agama-lib/src/security/settings.rs b/rust/agama-lib/src/security/settings.rs index c67209c6b0..26faa770e8 100644 --- a/rust/agama-lib/src/security/settings.rs +++ b/rust/agama-lib/src/security/settings.rs @@ -25,7 +25,7 @@ use serde::{Deserialize, Serialize}; use super::model::SSLFingerprint; /// Security settings for installation -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SecuritySettings { /// List of user selected patterns to install. diff --git a/rust/agama-lib/src/software/settings.rs b/rust/agama-lib/src/software/settings.rs index bf8d9b2dbb..d43579623a 100644 --- a/rust/agama-lib/src/software/settings.rs +++ b/rust/agama-lib/src/software/settings.rs @@ -27,7 +27,7 @@ use serde::{Deserialize, Serialize}; use super::model::RepositoryParams; /// Software settings for installation -#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct SoftwareSettings { /// List of user selected patterns to install. diff --git a/rust/agama-lib/src/storage/settings/dasd.rs b/rust/agama-lib/src/storage/settings/dasd.rs index a310220e8e..f2e423cd4c 100644 --- a/rust/agama-lib/src/storage/settings/dasd.rs +++ b/rust/agama-lib/src/storage/settings/dasd.rs @@ -22,7 +22,7 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct DASDConfig { pub devices: Vec, diff --git a/rust/agama-lib/src/storage/settings/zfcp.rs b/rust/agama-lib/src/storage/settings/zfcp.rs index 79c227e534..222c69b167 100644 --- a/rust/agama-lib/src/storage/settings/zfcp.rs +++ b/rust/agama-lib/src/storage/settings/zfcp.rs @@ -22,7 +22,7 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct ZFCPConfig { pub devices: Vec, diff --git a/rust/agama-lib/src/store.rs b/rust/agama-lib/src/store.rs index 24c17559db..ed70b32b97 100644 --- a/rust/agama-lib/src/store.rs +++ b/rust/agama-lib/src/store.rs @@ -27,7 +27,6 @@ use crate::{ hostname::store::{HostnameStore, HostnameStoreError}, http::BaseHTTPClient, install_settings::InstallSettings, - localization::{LocalizationStore, LocalizationStoreError}, manager::{http_client::ManagerHTTPClientError, InstallationPhase, ManagerHTTPClient}, network::{NetworkStore, NetworkStoreError}, product::{ProductHTTPClient, ProductStore, ProductStoreError}, @@ -76,8 +75,6 @@ pub enum StoreError { #[error(transparent)] ISCSI(#[from] ISCSIHTTPClientError), #[error(transparent)] - Localization(#[from] LocalizationStoreError), - #[error(transparent)] Scripts(#[from] ScriptsStoreError), // FIXME: it uses the client instead of the store. #[error(transparent)] @@ -110,7 +107,6 @@ pub struct Store { security: SecurityStore, software: SoftwareStore, storage: StorageStore, - localization: LocalizationStore, scripts: ScriptsStore, iscsi_client: ISCSIHTTPClient, manager_client: ManagerHTTPClient, @@ -125,7 +121,6 @@ impl Store { dasd: DASDStore::new(http_client.clone()), files: FilesStore::new(http_client.clone()), hostname: HostnameStore::new(http_client.clone()), - localization: LocalizationStore::new(http_client.clone()), users: UsersStore::new(http_client.clone()), network: NetworkStore::new(http_client.clone()), questions: QuestionsStore::new(http_client.clone()), @@ -155,7 +150,6 @@ impl Store { software: self.software.load().await?.to_option(), user: Some(self.users.load().await?), product: Some(self.product.load().await?), - localization: Some(self.localization.load().await?), scripts: self.scripts.load().await?.to_option(), zfcp: self.zfcp.load().await?, ..Default::default() @@ -208,11 +202,6 @@ impl Store { } // here detect if product is properly selected, so later it can be checked let is_product_selected = self.detect_selected_product().await?; - // ordering: localization after product as some product may miss some locales - if let Some(localization) = &settings.localization { - Store::ensure_selected_product(is_product_selected)?; - self.localization.store(localization).await?; - } if let Some(software) = &settings.software { Store::ensure_selected_product(is_product_selected)?; self.software.store(software).await?; diff --git a/rust/agama-lib/src/users/settings.rs b/rust/agama-lib/src/users/settings.rs index 6ee63906ab..6361ca347a 100644 --- a/rust/agama-lib/src/users/settings.rs +++ b/rust/agama-lib/src/users/settings.rs @@ -25,7 +25,7 @@ use super::{FirstUser, RootUser}; /// User settings /// /// Holds the user settings for the installation. -#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct UserSettings { #[serde(rename = "user")] @@ -105,7 +105,7 @@ pub struct UserPassword { /// Root user settings /// /// Holds the settings for the root user. -#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RootUserSettings { /// Root user password diff --git a/rust/agama-locale-data/src/lib.rs b/rust/agama-locale-data/src/lib.rs index 7475d939a5..90754520be 100644 --- a/rust/agama-locale-data/src/lib.rs +++ b/rust/agama-locale-data/src/lib.rs @@ -39,7 +39,9 @@ pub mod timezone_part; use keyboard::xkeyboard; -pub use locale::{InvalidKeymap, InvalidLocaleCode, KeymapId, LocaleId}; +pub use locale::{ + InvalidKeymapId, InvalidLocaleId, InvalidTimezoneId, KeymapId, LocaleId, TimezoneId, +}; fn file_reader(file_path: &str) -> anyhow::Result { let file = File::open(file_path) diff --git a/rust/agama-locale-data/src/locale.rs b/rust/agama-locale-data/src/locale.rs index 65bb72b91a..866ce3b8fb 100644 --- a/rust/agama-locale-data/src/locale.rs +++ b/rust/agama-locale-data/src/locale.rs @@ -26,6 +26,34 @@ use std::sync::OnceLock; use std::{fmt::Display, str::FromStr}; use thiserror::Error; +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct TimezoneId(String); + +impl Default for TimezoneId { + fn default() -> Self { + Self("Europe/Berlin".to_string()) + } +} + +impl Display for TimezoneId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Error, Debug)] +#[error("Not a valid timezone: {0}")] +pub struct InvalidTimezoneId(String); + +impl FromStr for TimezoneId { + type Err = InvalidTimezoneId; + + // TODO: implement real parsing of the string. + fn from_str(s: &str) -> Result { + Ok(Self(s.to_string())) + } +} + #[derive(Clone, Debug, PartialEq, Serialize, utoipa::ToSchema)] pub struct LocaleId { // ISO-639 @@ -57,18 +85,18 @@ impl Default for LocaleId { #[derive(Error, Debug)] #[error("Not a valid locale string: {0}")] -pub struct InvalidLocaleCode(String); +pub struct InvalidLocaleId(String); -impl TryFrom<&str> for LocaleId { - type Error = InvalidLocaleCode; +impl FromStr for LocaleId { + type Err = InvalidLocaleId; - fn try_from(value: &str) -> Result { + fn from_str(s: &str) -> Result { let locale_regexp: Regex = Regex::new(r"^([[:alpha:]]+)_([[:alpha:]]+)(?:\.(.+))?").unwrap(); let captures = locale_regexp - .captures(value) - .ok_or_else(|| InvalidLocaleCode(value.to_string()))?; + .captures(s) + .ok_or_else(|| InvalidLocaleId(s.to_string()))?; let encoding = captures .get(3) @@ -119,7 +147,7 @@ impl Default for KeymapId { #[derive(Error, Debug, PartialEq)] #[error("Invalid keymap ID: {0}")] -pub struct InvalidKeymap(String); +pub struct InvalidKeymapId(String); impl KeymapId { pub fn dashed(&self) -> String { @@ -142,7 +170,7 @@ impl Display for KeymapId { } impl FromStr for KeymapId { - type Err = InvalidKeymap; + type Err = InvalidKeymapId; fn from_str(s: &str) -> Result { let re = KEYMAP_ID_REGEX @@ -176,7 +204,7 @@ impl FromStr for KeymapId { variant, }) } else { - Err(InvalidKeymap(s.to_string())) + Err(InvalidKeymapId(s.to_string())) } } } diff --git a/rust/agama-network/src/settings.rs b/rust/agama-network/src/settings.rs index 2f1b2724da..db9a4f6120 100644 --- a/rust/agama-network/src/settings.rs +++ b/rust/agama-network/src/settings.rs @@ -28,7 +28,7 @@ use std::default::Default; use std::net::IpAddr; /// Network settings for installation -#[derive(Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct NetworkSettings { /// Connections to use in the installation diff --git a/rust/agama-server/Cargo.toml b/rust/agama-server/Cargo.toml index cde9f6c8a6..87b5629ecd 100644 --- a/rust/agama-server/Cargo.toml +++ b/rust/agama-server/Cargo.toml @@ -11,6 +11,7 @@ anyhow = "1.0" agama-locale-data = { path = "../agama-locale-data" } agama-lib = { path = "../agama-lib" } agama-utils = { path = "../agama-utils" } +agama-l10n = { path = "../agama-l10n" } zbus = { version = "5", default-features = false, features = ["tokio"] } uuid = { version = "1.10.0", features = ["v4"] } thiserror = "2.0.12" @@ -20,7 +21,7 @@ tokio-stream = "0.1.16" gettext-rs = { version = "0.7.1", features = ["gettext-system"] } regex = "1.11.0" async-trait = "0.1.83" -axum = { version = "0.7.7", features = ["ws"] } +axum = { version = "0.7.7", features = ["ws", "macros"] } serde_json = "1.0.128" tower-http = { version = "0.6.2", features = [ "compression-br", @@ -56,6 +57,8 @@ gethostname = "1.0.0" tokio-util = "0.7.12" tempfile = "3.13.0" url = "2.5.2" +merge-struct = "0.1.0" +strum = { version = "0.27.2", features = ["derive"] } [[bin]] name = "agama-dbus-server" diff --git a/rust/agama-server/src/agama-dbus-server.rs b/rust/agama-server/src/agama-dbus-server.rs index 8cb57f4b47..879d1ab86a 100644 --- a/rust/agama-server/src/agama-dbus-server.rs +++ b/rust/agama-server/src/agama-dbus-server.rs @@ -18,11 +18,8 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use agama_server::{ - l10n::{self, helpers}, - logs::init_logging, - questions, -}; +use agama_l10n::helpers as l10n_helpers; +use agama_server::{logs::init_logging, questions}; use agama_lib::connection_to; use anyhow::Context; @@ -33,7 +30,8 @@ const SERVICE_NAME: &str = "org.opensuse.Agama1"; #[tokio::main] async fn main() -> Result<(), Box> { - let locale = helpers::init_locale()?; + let locale = l10n_helpers::init_locale()?; + tracing::info!("Using locale {}", locale); init_logging().context("Could not initialize the logger")?; let connection = connection_to(ADDRESS) diff --git a/rust/agama-server/src/agama-web-server.rs b/rust/agama-server/src/agama-web-server.rs index d64d3c3ff8..82d760c8af 100644 --- a/rust/agama-server/src/agama-web-server.rs +++ b/rust/agama-server/src/agama-web-server.rs @@ -24,10 +24,10 @@ use std::{ process::{ExitCode, Termination}, }; +use agama_l10n::helpers as l10n_helpers; use agama_lib::{auth::AuthToken, connection_to}; use agama_server::{ cert::Certificate, - l10n::helpers, logs::init_logging, web::{self, run_monitor}, }; @@ -316,7 +316,7 @@ async fn start_server(address: String, service: Router, ssl_acceptor: SslAccepto /// Start serving the API. /// `options`: command-line arguments. async fn serve_command(args: ServeArgs) -> anyhow::Result<()> { - _ = helpers::init_locale(); + _ = l10n_helpers::init_locale(); init_logging().context("Could not initialize the logger")?; let (tx, _) = channel(16); diff --git a/rust/agama-server/src/error.rs b/rust/agama-server/src/error.rs index b632342b62..1466882fda 100644 --- a/rust/agama-server/src/error.rs +++ b/rust/agama-server/src/error.rs @@ -27,7 +27,6 @@ use axum::{ use serde_json::json; use crate::{ - l10n::LocaleError, users::password::PasswordCheckerError, web::common::{IssuesServiceError, ProgressServiceError}, }; @@ -42,8 +41,6 @@ pub enum Error { Service(#[from] ServiceError), #[error("Questions service error: {0}")] Questions(QuestionsError), - #[error("Software service error: {0}")] - Locale(#[from] LocaleError), #[error("Issues service error: {0}")] Issues(#[from] IssuesServiceError), #[error("Progress service error: {0}")] diff --git a/rust/agama-server/src/l10n.rs b/rust/agama-server/src/l10n.rs index 401062f3fa..a8741896cf 100644 --- a/rust/agama-server/src/l10n.rs +++ b/rust/agama-server/src/l10n.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2025] SUSE LLC // // All Rights Reserved. // @@ -18,11 +18,4 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -pub mod error; -pub mod helpers; -mod model; -pub mod web; - -pub use agama_lib::localization::model::LocaleConfig; -pub use error::LocaleError; -pub use model::{Keymap, L10n, LocaleEntry, TimezoneEntry}; +pub use agama_l10n::L10n; diff --git a/rust/agama-server/src/l10n/web.rs b/rust/agama-server/src/l10n/web.rs deleted file mode 100644 index 9a430df27d..0000000000 --- a/rust/agama-server/src/l10n/web.rs +++ /dev/null @@ -1,213 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! This module implements the web API for the localization module. - -use super::{ - error::LocaleError, - model::{keyboard::Keymap, locale::LocaleEntry, timezone::TimezoneEntry, L10n}, -}; -use crate::{error::Error, web::EventsSender}; -use agama_lib::{ - auth::ClientId, error::ServiceError, event, localization::model::LocaleConfig, - proxies::LocaleMixinProxy as ManagerLocaleProxy, -}; -use agama_locale_data::LocaleId; -use axum::{ - extract::State, - http::StatusCode, - response::IntoResponse, - routing::{get, patch, post}, - Extension, Json, Router, -}; -use std::sync::Arc; -use tokio::sync::RwLock; - -#[derive(Clone)] -struct LocaleState<'a> { - locale: Arc>, - manager_proxy: ManagerLocaleProxy<'a>, - events: EventsSender, -} - -/// Sets up and returns the axum service for the localization module. -/// -/// * `events`: channel to send the events to the main service. -pub async fn l10n_service( - dbus: zbus::Connection, - events: EventsSender, -) -> Result { - let id = LocaleId::default(); - let locale = L10n::new_with_locale(&id).unwrap(); - let manager_proxy = ManagerLocaleProxy::new(&dbus).await?; - let state = LocaleState { - locale: Arc::new(RwLock::new(locale)), - manager_proxy, - events, - }; - - let router = Router::new() - .route("/keymaps", get(keymaps)) - .route("/locales", get(locales)) - .route("/timezones", get(timezones)) - .route("/config", patch(set_config).get(get_config)) - .route("/finish", post(finish)) - .with_state(state); - Ok(router) -} - -#[utoipa::path( - get, - path = "/locales", - context_path = "/api/l10n", - responses( - (status = 200, description = "List of known locales", body = Vec) - ) -)] -async fn locales(State(state): State>) -> Json> { - let data = state.locale.read().await; - let locales = data.locales_db.entries().to_vec(); - Json(locales) -} - -#[utoipa::path( - get, - path = "/timezones", - context_path = "/api/l10n", - responses( - (status = 200, description = "List of known timezones", body = Vec) - ) -)] -async fn timezones(State(state): State>) -> Json> { - let data = state.locale.read().await; - let timezones = data.timezones_db.entries().to_vec(); - Json(timezones) -} - -#[utoipa::path( - get, - path = "/keymaps", - context_path = "/api/l10n", - responses( - (status = 200, description = "List of known keymaps", body = Vec) - ) -)] -async fn keymaps(State(state): State>) -> Json> { - let data = state.locale.read().await; - let keymaps = data.keymaps_db.entries().to_vec(); - Json(keymaps) -} - -// TODO: update all or nothing -// TODO: send only the attributes that have changed -#[utoipa::path( - patch, - path = "/config", - context_path = "/api/l10n", - operation_id = "set_l10n_config", - responses( - (status = 204, description = "Set the locale configuration", body = LocaleConfig) - ) -)] -async fn set_config( - State(state): State>, - Extension(client_id): Extension>, - Json(value): Json, -) -> Result { - let mut data = state.locale.write().await; - let mut changes = LocaleConfig::default(); - - if let Some(locales) = &value.locales { - data.set_locales(locales)?; - changes.locales.clone_from(&value.locales); - } - - if let Some(timezone) = &value.timezone { - data.set_timezone(timezone)?; - changes.timezone.clone_from(&value.timezone); - } - - if let Some(keymap_id) = &value.keymap { - let keymap_id = keymap_id.parse().map_err(LocaleError::InvalidKeymap)?; - data.set_keymap(keymap_id)?; - changes.keymap.clone_from(&value.keymap); - } - - if let Some(ui_locale) = &value.ui_locale { - let locale = ui_locale - .as_str() - .try_into() - .map_err(LocaleError::InvalidLocale)?; - data.translate(&locale)?; - let locale_string = locale.to_string(); - state.manager_proxy.set_locale(&locale_string).await?; - changes.ui_locale = Some(locale_string); - _ = state.events.send(event!(LocaleChanged { - locale: locale.to_string(), - })); - } - - if let Some(ui_keymap) = &value.ui_keymap { - let ui_keymap = ui_keymap.parse().map_err(LocaleError::InvalidKeymap)?; - data.set_ui_keymap(ui_keymap)?; - } - - _ = state - .events - .send(event!(L10nConfigChanged(changes), client_id.as_ref())); - - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path( - get, - path = "/config", - context_path = "/api/l10n", - operation_id = "get_l10n_config", - responses( - (status = 200, description = "Localization configuration", body = LocaleConfig) - ) -)] -async fn get_config(State(state): State>) -> Json { - let data = state.locale.read().await; - let locales = data.locales.iter().map(ToString::to_string).collect(); - Json(LocaleConfig { - locales: Some(locales), - keymap: Some(data.keymap.to_string()), - timezone: Some(data.timezone.to_string()), - ui_locale: Some(data.ui_locale.to_string()), - ui_keymap: Some(data.ui_keymap.to_string()), - }) -} - -#[utoipa::path( - get, - path = "/finish", - context_path = "/api/l10n", - operation_id = "l10n_finish", - responses( - (status = 200, description = "Finish the l10n configuration") - ) -)] -async fn finish(State(state): State>) -> Result { - let data = state.locale.read().await; - data.commit()?; - Ok(StatusCode::NO_CONTENT) -} diff --git a/rust/agama-server/src/lib.rs b/rust/agama-server/src/lib.rs index bf7a8c5889..5bddb194cd 100644 --- a/rust/agama-server/src/lib.rs +++ b/rust/agama-server/src/lib.rs @@ -37,3 +37,5 @@ pub mod storage; pub mod users; pub mod web; pub use web::service; +pub mod server; +pub mod supervisor; diff --git a/rust/agama-server/src/server.rs b/rust/agama-server/src/server.rs new file mode 100644 index 0000000000..296ff35faa --- /dev/null +++ b/rust/agama-server/src/server.rs @@ -0,0 +1,30 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +pub mod web; +pub use web::server_service; +pub mod proposal; +pub use proposal::Proposal; +pub mod info; +pub use info::SystemInfo; +pub mod scope; +pub use scope::{Scope, ScopeConfig}; +pub mod error; +pub use error::ServerError; diff --git a/rust/agama-lib/src/localization/http_client.rs b/rust/agama-server/src/server/error.rs similarity index 54% rename from rust/agama-lib/src/localization/http_client.rs rename to rust/agama-server/src/server/error.rs index 57bfcba383..8a11f0d8c7 100644 --- a/rust/agama-lib/src/localization/http_client.rs +++ b/rust/agama-server/src/server/error.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2025] SUSE LLC // // All Rights Reserved. // @@ -18,32 +18,32 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use super::model::LocaleConfig; -use crate::http::{BaseHTTPClient, BaseHTTPClientError}; +use agama_l10n::LocaleError; +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::json; + +use super::Scope; + +pub type ServerResult = Result; #[derive(Debug, thiserror::Error)] -pub enum LocalizationHTTPClientError { +pub enum ServerError { + #[error("The given configuration does not belong to the '{0}' scope.")] + NoMatchingScope(Scope), #[error(transparent)] - HTTP(#[from] BaseHTTPClientError), -} - -pub struct LocalizationHTTPClient { - client: BaseHTTPClient, + L10n(#[from] LocaleError), } -impl LocalizationHTTPClient { - pub fn new(base: BaseHTTPClient) -> Self { - Self { client: base } - } - - pub async fn get_config(&self) -> Result { - Ok(self.client.get("/l10n/config").await?) - } - - pub async fn set_config( - &self, - config: &LocaleConfig, - ) -> Result<(), LocalizationHTTPClientError> { - Ok(self.client.patch_void("/l10n/config", config).await?) +impl IntoResponse for ServerError { + fn into_response(self) -> Response { + tracing::warn!("Server return error {}", self); + let body = json!({ + "error": self.to_string() + }); + (StatusCode::BAD_REQUEST, Json(body)).into_response() } } diff --git a/rust/agama-lib/src/localization.rs b/rust/agama-server/src/server/info.rs similarity index 72% rename from rust/agama-lib/src/localization.rs rename to rust/agama-server/src/server/info.rs index 6d3ae18db3..7bcfc7c637 100644 --- a/rust/agama-lib/src/localization.rs +++ b/rust/agama-server/src/server/info.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2025] SUSE LLC // // All Rights Reserved. // @@ -18,13 +18,10 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -//! Implements support for handling the localization settings +use agama_l10n::L10nSystemInfo; +use serde::Serialize; -mod http_client; -pub mod model; -mod settings; -mod store; - -pub use http_client::LocalizationHTTPClient; -pub use settings::LocalizationSettings; -pub use store::{LocalizationStore, LocalizationStoreError}; +#[derive(Serialize)] +pub struct SystemInfo { + pub localization: L10nSystemInfo, +} diff --git a/rust/agama-server/src/server/proposal.rs b/rust/agama-server/src/server/proposal.rs new file mode 100644 index 0000000000..c7b9b24f24 --- /dev/null +++ b/rust/agama-server/src/server/proposal.rs @@ -0,0 +1,27 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use agama_l10n::L10nProposal; +use serde::Serialize; + +#[derive(Clone, Serialize)] +pub struct Proposal { + pub localization: L10nProposal, +} diff --git a/rust/agama-lib/src/localization/model.rs b/rust/agama-server/src/server/scope.rs similarity index 57% rename from rust/agama-lib/src/localization/model.rs rename to rust/agama-server/src/server/scope.rs index 20a565bc4b..bf0c4efd51 100644 --- a/rust/agama-lib/src/localization/model.rs +++ b/rust/agama-server/src/server/scope.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2025] SUSE LLC // // All Rights Reserved. // @@ -18,19 +18,26 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use agama_l10n::L10nConfig; use serde::{Deserialize, Serialize}; -#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct LocaleConfig { - /// Locales to install in the target system - pub locales: Option>, - /// Keymap for the target system - pub keymap: Option, - /// Timezone for the target system - pub timezone: Option, - /// User-interface locale. It is actually not related to the `locales` property. - pub ui_locale: Option, - /// User-interface locale. It is relevant only on local installations. - pub ui_keymap: Option, +#[derive(Copy, Clone, Debug, strum::EnumString, strum::Display, Deserialize, PartialEq)] +#[strum(serialize_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum Scope { + L10n, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ScopeConfig { + L10n(L10nConfig), +} + +impl ScopeConfig { + pub fn to_scope(&self) -> Scope { + match &self { + Self::L10n(_) => Scope::L10n, + } + } } diff --git a/rust/agama-server/src/server/web.rs b/rust/agama-server/src/server/web.rs new file mode 100644 index 0000000000..b5cc51c7f7 --- /dev/null +++ b/rust/agama-server/src/server/web.rs @@ -0,0 +1,169 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! This module implements Agama's HTTP API. + +// use agama_l10n::L10nModel; +use agama_lib::{error::ServiceError, install_settings::InstallSettings}; +// use agama_locale_data::LocaleId; +use axum::{ + extract::{Path, State}, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use hyper::StatusCode; +use serde::Serialize; +use std::sync::Arc; +use tokio::sync::Mutex; + +use crate::{supervisor::Action, supervisor::Supervisor}; + +use super::{Scope, ScopeConfig, ServerError, SystemInfo}; + +#[derive(Clone)] +pub struct ServerState { + supervisor: Arc>, +} + +/// Sets up and returns the axum service for the manager module +pub async fn server_service() -> Result { + // let l10n = L10nModel::new_with_locale(&LocaleId::default()).unwrap(); + // let l10n = L10nAgent::new(l10n); + let supervisor = Supervisor::new(); + let state = ServerState { + supervisor: Arc::new(Mutex::new(supervisor)), + }; + + Ok(Router::new() + .route( + "/config/user/:scope", + get(get_scope_user_config) + .put(set_scope_config) + .patch(set_scope_config), + ) + .route( + "/config/user", + get(get_user_config).patch(set_config).put(set_config), + ) + .route("/config/:scope", get(get_scope_full_config)) + .route("/config", get(get_full_config)) + .route("/system", get(get_system)) + .route("/proposal", get(get_proposal)) + .route("/actions", post(run_action)) + .with_state(state)) +} + +async fn get_full_config(State(state): State) -> Json { + let state = state.supervisor.lock().await; + Json(state.get_config().await.clone()) +} + +async fn get_scope_full_config( + State(state): State, + Path(scope): Path, +) -> Result { + let state = state.supervisor.lock().await; + let config = state.get_scope_config(scope).await; + Ok(to_option_response(config)) +} + +async fn get_user_config(State(state): State) -> Json { + let state = state.supervisor.lock().await; + Json(state.get_user_config().await.clone()) +} + +async fn get_scope_user_config( + State(state): State, + Path(scope): Path, +) -> Response { + let state = state.supervisor.lock().await; + let user_config = state.get_user_config().await; + + let result = match scope { + Scope::L10n => &user_config.localization, + }; + + to_option_response(result.clone()) +} + +async fn set_config( + State(state): State, + method: axum::http::Method, + Json(config): Json, +) -> Result<(), ServerError> { + let mut state = state.supervisor.lock().await; + if method.as_str() == "PATCH" { + state.patch_config(config).await?; + } else { + state.update_config(config).await?; + } + + Ok(()) +} + +async fn set_scope_config( + State(state): State, + method: axum::http::Method, + Path(scope): Path, + Json(user_config): Json, +) -> Result<(), ServerError> { + if user_config.to_scope() != scope { + return Err(ServerError::NoMatchingScope(scope)); + } + + let mut state = state.supervisor.lock().await; + if method.as_str() == "PATCH" { + state.patch_scope_config(user_config).await?; + } else { + state.update_scope_config(user_config).await?; + } + Ok(()) +} + +async fn run_action( + State(state): State, + Json(action): Json, +) -> Result<(), ServerError> { + let mut state = state.supervisor.lock().await; + state.dispatch_action(action).await; + Ok(()) +} + +async fn get_proposal(State(state): State) -> Response { + let state = state.supervisor.lock().await; + if let Some(proposal) = state.get_proposal().await { + Json(proposal).into_response() + } else { + StatusCode::NOT_FOUND.into_response() + } +} + +async fn get_system(State(state): State) -> Json { + let state = state.supervisor.lock().await; + Json(state.get_system().await) +} + +fn to_option_response(value: Option) -> Response { + match value { + Some(inner) => Json(inner).into_response(), + None => StatusCode::NOT_FOUND.into_response(), + } +} diff --git a/rust/agama-server/src/supervisor.rs b/rust/agama-server/src/supervisor.rs new file mode 100644 index 0000000000..5e713a2a67 --- /dev/null +++ b/rust/agama-server/src/supervisor.rs @@ -0,0 +1,156 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::server::{error::ServerResult, Proposal, Scope, ScopeConfig, SystemInfo}; +use agama_l10n::{L10n, L10nAction}; +use agama_lib::install_settings::InstallSettings; +use merge_struct::merge; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum Action { + L10n(L10nAction), +} + +pub struct Supervisor { + l10n: L10n, + user_config: InstallSettings, + config: InstallSettings, + proposal: Option, +} + +impl Supervisor { + pub fn new() -> Self { + Self { + l10n: L10n::new(), + config: InstallSettings::default(), + user_config: InstallSettings::default(), + proposal: None, + } + } + + /// Gets the current configuration. + /// + /// It includes user and default values. + pub async fn get_config(&self) -> InstallSettings { + InstallSettings { + localization: Some(self.l10n.get_config().clone()), + ..Default::default() + } + } + + /// Gets the current configuration set by the user. + /// + /// It includes only the values that were set by the user. + pub async fn get_user_config(&self) -> &InstallSettings { + &self.user_config + } + + /// It returns the configuration for the given scope. + /// + /// * scope: scope to get the configuration for. + pub async fn get_scope_config(&self, scope: Scope) -> Option { + // FIXME: implement this logic at InstallSettings level: self.get_config().by_scope(...) + // It would allow us to drop this method. + match scope { + Scope::L10n => self + .config + .localization + .clone() + .map(|c| ScopeConfig::L10n(c)), + } + } + + /// Patches the user configuration with the given values. + /// + /// It merges the current configuration with the given one. + pub async fn patch_config(&mut self, user_config: InstallSettings) -> ServerResult<()> { + let config = merge(&self.user_config, &user_config).unwrap(); + self.update_config(config).await + } + + /// Sets the user configuration with the given values. + /// + /// It merges the values in the top-level. Therefore, if the configuration + /// for a scope is not given, it keeps the previous one. + /// + /// FIXME: We should replace not given sections with the default ones. + /// After all, now we have config/user/:scope URLs. + pub async fn update_config(&mut self, user_config: InstallSettings) -> ServerResult<()> { + if let Some(l10n_user_config) = &user_config.localization { + self.l10n.set_config(l10n_user_config)?; + } + self.user_config = user_config; + Ok(()) + } + + /// Patches the user configuration within the given scope. + /// + /// It merges the current configuration with the given one. + pub async fn patch_scope_config(&mut self, user_config: ScopeConfig) -> ServerResult<()> { + match user_config { + ScopeConfig::L10n(new_config) => { + let base_config = self.user_config.localization.clone().unwrap_or_default(); + let config = merge(&base_config, &new_config).unwrap(); + // FIXME: we are doing pattern matching twice. Is it ok? + // Implementing a "merge" for ScopeConfig would allow to simplify this function. + self.update_scope_config(ScopeConfig::L10n(config)).await?; + } + } + + Ok(()) + } + + /// Sets the user configuration within the given scope. + /// + /// It replaces the current configuration with the given one and calculates a + /// new proposal. Only the configuration in the given scope is affected. + pub async fn update_scope_config(&mut self, user_config: ScopeConfig) -> ServerResult<()> { + match user_config { + ScopeConfig::L10n(new_config) => { + self.l10n.set_config(&new_config)?; + self.user_config.localization = Some(new_config); + } + } + + Ok(()) + } + + // TODO: report error if the action fails. + pub async fn dispatch_action(&mut self, action: Action) { + match action { + Action::L10n(l10n_action) => self.l10n.dispatch(l10n_action).unwrap(), + } + } + + /// It returns the current proposal, if any. + pub async fn get_proposal(&self) -> Option<&Proposal> { + self.proposal.as_ref() + } + + /// It returns the information of the underlying system. + pub async fn get_system(&self) -> SystemInfo { + // SystemInfo { + // localization: self.l10n.get_system(), + // } + unimplemented!("TODO") + } +} diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index a0e2e5851d..edce7b8538 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -29,13 +29,13 @@ use crate::{ error::Error, files::web::files_service, hostname::web::hostname_service, - l10n::web::l10n_service, manager::web::{manager_service, manager_stream}, network::{web::network_service, NetworkManagerAdapter}, profile::web::profile_service, questions::web::{questions_service, questions_stream}, scripts::web::scripts_service, security::security_service, + server::server_service, software::web::{software_service, software_streams}, storage::web::{iscsi::iscsi_service, storage_service, storage_streams}, users::web::{users_service, users_streams}, @@ -85,11 +85,11 @@ where let progress = ProgressService::start(dbus.clone(), events.clone()).await; let router = MainServiceBuilder::new(events.clone(), web_ui_dir) - .add_service("/l10n", l10n_service(dbus.clone(), events.clone()).await?) .add_service( "/manager", manager_service(dbus.clone(), progress.clone()).await?, ) + .add_service("/server", server_service().await?) .add_service("/security", security_service(dbus.clone()).await?) .add_service( "/software", diff --git a/rust/agama-server/src/web/docs.rs b/rust/agama-server/src/web/docs.rs index 0dec7d64e7..15786b51a5 100644 --- a/rust/agama-server/src/web/docs.rs +++ b/rust/agama-server/src/web/docs.rs @@ -30,8 +30,6 @@ mod bootloader; pub use bootloader::BootloaderApiDocBuilder; mod software; pub use software::SoftwareApiDocBuilder; -mod l10n; -pub use l10n::L10nApiDocBuilder; mod questions; pub use questions::QuestionsApiDocBuilder; mod profile; diff --git a/rust/agama-server/src/web/docs/l10n.rs b/rust/agama-server/src/web/docs/l10n.rs deleted file mode 100644 index c717edbbaf..0000000000 --- a/rust/agama-server/src/web/docs/l10n.rs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use utoipa::openapi::{Components, ComponentsBuilder, Paths, PathsBuilder}; - -use super::ApiDocBuilder; - -pub struct L10nApiDocBuilder; - -impl ApiDocBuilder for L10nApiDocBuilder { - fn title(&self) -> String { - "Localization HTTP API".to_string() - } - - fn paths(&self) -> Paths { - PathsBuilder::new() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .build() - } - - fn components(&self) -> Components { - ComponentsBuilder::new() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .build() - } -} diff --git a/rust/agama-server/tests/l10n.rs b/rust/agama-server/tests/l10n.rs deleted file mode 100644 index 2d4baa0ec1..0000000000 --- a/rust/agama-server/tests/l10n.rs +++ /dev/null @@ -1,137 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -pub mod common; - -use std::error::Error; - -use agama_server::l10n::web::l10n_service; -use axum::{ - body::Body, - http::{Request, StatusCode}, - Router, -}; -use common::{body_to_string, DBusServer}; -use tokio::{sync::broadcast::channel, test}; -use tower::ServiceExt; - -async fn build_service(dbus: zbus::Connection) -> Router { - let (tx, _) = channel(16); - l10n_service(dbus, tx).await.unwrap() -} - -#[test] -// FIXME: temporarily skip the test in CI -#[cfg(not(ci))] -async fn test_get_config() -> Result<(), Box> { - let dbus_server = DBusServer::new().start().await?; - let service = build_service(dbus_server.connection()).await; - let request = Request::builder() - .uri("/config") - .body(Body::empty()) - .unwrap(); - let response = service.oneshot(request).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - Ok(()) -} - -#[test] -// FIXME: temporarily skip the test in CI -#[cfg(not(ci))] -async fn test_locales() -> Result<(), Box> { - let dbus_server = DBusServer::new().start().await?; - let service = build_service(dbus_server.connection()).await; - let request = Request::builder() - .uri("/locales") - .body(Body::empty()) - .unwrap(); - let response = service.oneshot(request).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_to_string(response.into_body()).await; - assert!(body.contains(r#""language":"English""#)); - Ok(()) -} - -#[test] -// FIXME: temporarily skip the test in CI -#[cfg(not(ci))] -async fn test_keymaps() -> Result<(), Box> { - let dbus_server = DBusServer::new().start().await?; - let service = build_service(dbus_server.connection()).await; - let request = Request::builder() - .uri("/keymaps") - .body(Body::empty()) - .unwrap(); - let response = service.oneshot(request).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_to_string(response.into_body()).await; - assert!(body.contains(r#""id":"us""#)); - Ok(()) -} - -#[test] -// FIXME: temporarily skip the test in CI -#[cfg(not(ci))] -async fn test_timezones() -> Result<(), Box> { - let dbus_server = DBusServer::new().start().await?; - let service = build_service(dbus_server.connection()).await; - let request = Request::builder() - .uri("/timezones") - .body(Body::empty()) - .unwrap(); - let response = service.oneshot(request).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_to_string(response.into_body()).await; - assert!(body.contains(r#""code":"Atlantic/Canary""#)); - Ok(()) -} - -#[test] -// FIXME: temporarily skip the test in CI -#[cfg(not(ci))] -async fn test_set_config_locales() -> Result<(), Box> { - use agama_lib::auth::ClientId; - use std::sync::Arc; - - let dbus_server = DBusServer::new().start().await?; - let service = build_service(dbus_server.connection()).await; - - let content = "{\"locales\":[\"es_ES.UTF-8\"]}"; - let body = Body::from(content); - let request = Request::patch("/config") - .header("Content-Type", "application/json") - .extension(Arc::new(ClientId::new())) - .body(body)?; - let response = service.clone().oneshot(request).await?; - assert_eq!(response.status(), StatusCode::NO_CONTENT); - - // check whether the value changed - let request = Request::get("/config") - .header("Content-Type", "application/json") - .body(Body::empty())?; - let response = service.oneshot(request).await?; - assert_eq!(response.status(), StatusCode::OK); - let body = body_to_string(response.into_body()).await; - assert!(body.contains(r#""locales":["es_ES.UTF-8"]"#)); - - // TODO: check whether the D-Bus value was synchronized - - Ok(()) -} diff --git a/rust/xtask/src/main.rs b/rust/xtask/src/main.rs index 0da8e5d8d8..2d23d2be8c 100644 --- a/rust/xtask/src/main.rs +++ b/rust/xtask/src/main.rs @@ -5,9 +5,9 @@ mod tasks { use agama_cli::Cli; use agama_server::web::docs::{ - ApiDocBuilder, HostnameApiDocBuilder, L10nApiDocBuilder, ManagerApiDocBuilder, - MiscApiDocBuilder, NetworkApiDocBuilder, ProfileApiDocBuilder, QuestionsApiDocBuilder, - ScriptsApiDocBuilder, SoftwareApiDocBuilder, StorageApiDocBuilder, UsersApiDocBuilder, + ApiDocBuilder, HostnameApiDocBuilder, ManagerApiDocBuilder, MiscApiDocBuilder, + NetworkApiDocBuilder, ProfileApiDocBuilder, QuestionsApiDocBuilder, ScriptsApiDocBuilder, + SoftwareApiDocBuilder, StorageApiDocBuilder, UsersApiDocBuilder, }; use clap::CommandFactory; use clap_complete::aot; @@ -65,7 +65,6 @@ mod tasks { let out_dir = create_output_dir("openapi")?; write_openapi(HostnameApiDocBuilder {}, out_dir.join("hostname.json"))?; - write_openapi(L10nApiDocBuilder {}, out_dir.join("l10n.json"))?; write_openapi(ManagerApiDocBuilder {}, out_dir.join("manager.json"))?; write_openapi(MiscApiDocBuilder {}, out_dir.join("misc.json"))?; write_openapi(NetworkApiDocBuilder {}, out_dir.join("network.json"))?; diff --git a/web/package-lock.json b/web/package-lock.json index edca841e6d..b2812df61a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -16,10 +16,14 @@ "@tanstack/react-query": "^5.85.5", "axios": "^1.11.0", "fast-sort": "^3.4.1", + "i18next": "^25.5.2", + "i18next-browser-languagedetector": "^8.2.0", + "i18next-http-backend": "^3.0.2", "ipaddr.js": "^2.2.0", "radashi": "^12.6.2", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-i18next": "^15.7.3", "react-router-dom": "^6.30.1", "sprintf-js": "^1.1.3", "xbytes": "^1.9.1" @@ -1979,14 +1983,10 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", - "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", - "dev": true, + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, "engines": { "node": ">=6.9.0" } @@ -7574,6 +7574,15 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -10746,6 +10755,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/html-tags": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", @@ -10879,6 +10897,55 @@ "node": ">=10.18" } }, + "node_modules/i18next": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.5.2.tgz", + "integrity": "sha512-lW8Zeh37i/o0zVr+NoCHfNnfvVw+M6FQbRp36ZZ/NyHDJ3NJVpp2HhAUyU9WafL5AssymNoOjMRB48mmx2P6Hw==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", + "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-http-backend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz", + "integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==", + "license": "MIT", + "dependencies": { + "cross-fetch": "4.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -14789,6 +14856,48 @@ "license": "MIT", "optional": true }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-forge": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", @@ -16382,6 +16491,32 @@ "react": ">= 16.8 || 18.0.0" } }, + "node_modules/react-i18next": { + "version": "15.7.3", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.3.tgz", + "integrity": "sha512-AANws4tOE+QSq/IeMF/ncoHlMNZaVLxpa5uUGW1wjike68elVYr0018L9xYoqBr1OFO7G7boDPrbn0HpMCJxTw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 25.4.1", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -16541,13 +16676,6 @@ "node": ">=4" } }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true, - "license": "MIT" - }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -19020,7 +19148,7 @@ "version": "5.9.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -19297,6 +19425,15 @@ "node": ">= 0.8" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-xmlserializer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", diff --git a/web/package.json b/web/package.json index 26991b3891..b9aab698a8 100644 --- a/web/package.json +++ b/web/package.json @@ -97,10 +97,14 @@ "@tanstack/react-query": "^5.85.5", "axios": "^1.11.0", "fast-sort": "^3.4.1", + "i18next": "^25.5.2", + "i18next-browser-languagedetector": "^8.2.0", + "i18next-http-backend": "^3.0.2", "ipaddr.js": "^2.2.0", "radashi": "^12.6.2", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-i18next": "^15.7.3", "react-router-dom": "^6.30.1", "sprintf-js": "^1.1.3", "xbytes": "^1.9.1" diff --git a/web/public/po-en.json b/web/public/po-en.json new file mode 100644 index 0000000000..1f1d5c75e8 --- /dev/null +++ b/web/public/po-en.json @@ -0,0 +1,810 @@ +{ + "Configuration out of sync": "Configuration out of sync", + "The configuration has been updated externally.": "The configuration has been updated externally.", + "Reloading is required to get the latest data and avoid issues or data loss.": "Reloading is required to get the latest data and avoid issues or data loss.", + "Reload now": "Reload now", + "Change product": "Change product", + "Confirm Installation": "Confirm Installation", + "If you continue, partitions on your hard disk will be modified according to the provided installation settings.": "If you continue, partitions on your hard disk will be modified according to the provided installation settings.", + "Please, cancel and check the settings if you are unsure.": "Please, cancel and check the settings if you are unsure.", + "Continue": "Continue", + "Cancel": "Cancel", + "Install": "Install", + "Not possible with the current setup. Click to know more.": "Not possible with the current setup. Click to know more.", + "Your system is rebooting": "Your system is rebooting", + "The installer interface is no longer available, so you can safely close this window.": "The installer interface is no longer available, so you can safely close this window.", + "TPM sealing requires the new system to be booted directly.": "TPM sealing requires the new system to be booted directly.", + "If a local media was used to run this installer, remove it before the next boot.": "If a local media was used to run this installer, remove it before the next boot.", + "Hide details": "Hide details", + "See more details": "See more details", + "The final step to configure the Trusted Platform Module (TPM) to automatically open encrypted devices will take place during the first boot of the new system. For that to work, the machine needs to boot directly to the new boot loader.": "The final step to configure the Trusted Platform Module (TPM) to automatically open encrypted devices will take place during the first boot of the new system. For that to work, the machine needs to boot directly to the new boot loader.", + "Congratulations!": "Congratulations!", + "The installation on your machine is complete.": "The installation on your machine is complete.", + "At this point you can power off the machine.": "At this point you can power off the machine.", + "At this point you can reboot the machine to log in to the new system.": "At this point you can reboot the machine to log in to the new system.", + "Finish": "Finish", + "Reboot": "Reboot", + "Installing the system, please wait...": "Installing the system, please wait...", + "Language": "Language", + "Keyboard layout": "Keyboard layout", + "Cannot be changed in remote installation": "Cannot be changed in remote installation", + "This will affect only the installer interface, not the product to be installed. You can adjust the product’s localization later in the Localization settings page.": "This will affect only the installer interface, not the product to be installed. You can adjust the product’s localization later in the Localization settings page.", + "More language and keyboard layout options for the selected product may be available in [Localization] page.": "More language and keyboard layout options for the selected product may be available in [Localization] page.", + "Language and keyboard": "Language and keyboard", + "Use these same settings for the selected product": "Use these same settings for the selected product", + "Accept": "Accept", + "More languages might be available for the selected product at [Localization] page": "More languages might be available for the selected product at [Localization] page", + "Change Language": "Change Language", + "Use for the selected product too": "Use for the selected product too", + "Change keyboard": "Change keyboard", + "More keymap layout might be available for the selected product at [Localization] page": "More keymap layout might be available for the selected product at [Localization] page", + "Change display language": "Change display language", + "Change keyboard layout": "Change keyboard layout", + "Change display language and keyboard layout": "Change display language and keyboard layout", + "Before starting the installation, you need to address the following problems:": "Before starting the installation, you need to address the following problems:", + "Review and fix": "Review and fix", + "Authentication": "Authentication", + "Storage": "Storage", + "Software": "Software", + "Registration": "Registration", + "Pre-installation checks": "Pre-installation checks", + "Before installing, you have to make some decisions. Click on each section to review the settings.": "Before installing, you have to make some decisions. Click on each section to review the settings.", + "Search": "Search", + "Could not log in. Please, make sure that the password is correct.": "Could not log in. Please, make sure that the password is correct.", + "Could not authenticate against the server, please check it.": "Could not authenticate against the server, please check it.", + "Log in as %s": "Log in as %s", + "The installer requires [root] user privileges.": "The installer requires [root] user privileges.", + "Login form": "Login form", + "Password": "Password", + "Password input": "Password input", + "Please, provide its password to log in to the system.": "Please, provide its password to log in to the system.", + "Log in": "Log in", + "Back": "Back", + "Passwords do not match": "Passwords do not match", + "Password confirmation": "Password confirmation", + "Using [%s] keyboard": "Using [%s] keyboard", + "[CAPS LOCK] is on": "[CAPS LOCK] is on", + "Password visibility button": "Password visibility button", + "Confirm": "Confirm", + "Loading data...": "Loading data...", + "Pending": "Pending", + "In progress": "In progress", + "Finished": "Finished", + "Resource not found or lost": "Resource not found or lost", + "It doesn't exist or can't be reached.": "It doesn't exist or can't be reached.", + "Actions": "Actions", + "Add": "Add", + "Row expansion": "Row expansion", + "Row selection": "Row selection", + "Row actions": "Row actions", + "Cannot connect to Agama server": "Cannot connect to Agama server", + "Please, check whether it is running.": "Please, check whether it is running.", + "Reload": "Reload", + "Skip to content": "Skip to content", + "More actions": "More actions", + "Filter by description or keymap code": "Filter by description or keymap code", + "None of the keymaps match the filter.": "None of the keymaps match the filter.", + "Keyboard selection": "Keyboard selection", + "Select": "Select", + "These are the settings for the product to install. The installer language and keyboard layout can be adjusted via the [settings panel] accessible from the top bar.": "These are the settings for the product to install. The installer language and keyboard layout can be adjusted via the [settings panel] accessible from the top bar.", + "These are the settings for the product to install. The installer language can be adjusted via the [settings panel] accessible from the top bar.": "These are the settings for the product to install. The installer language can be adjusted via the [settings panel] accessible from the top bar.", + "Localization": "Localization", + "Change": "Change", + "Not selected yet": "Not selected yet", + "Keyboard": "Keyboard", + "Time zone": "Time zone", + "Filter by language, territory or locale code": "Filter by language, territory or locale code", + "None of the locales match the filter.": "None of the locales match the filter.", + "Locale selection": "Locale selection", + "Filter by territory, time zone code or UTC offset": "Filter by territory, time zone code or UTC offset", + "None of the time zones match the filter.": "None of the time zones match the filter.", + " Timezone selection": " Timezone selection", + "Options toggle": "Options toggle", + "Download logs": "Download logs", + "Main navigation": "Main navigation", + "Loading": "Loading", + "Remove": "Remove", + "IP Address": "IP Address", + "Prefix length or netmask": "Prefix length or netmask", + "Add an address": "Add an address", + "Add another address": "Add another address", + "Addresses": "Addresses", + "Addresses data list": "Addresses data list", + "%s - %s": "%s - %s", + "Binding settings for '%s'": "Binding settings for '%s'", + "Choose how the connection should be associated with a network device. This helps control which device the connection uses.": "Choose how the connection should be associated with a network device. This helps control which device the connection uses.", + "Unbound": "Unbound", + "The connection can be used by any available device.": "The connection can be used by any available device.", + "Bind to device name": "Bind to device name", + "Choose device to bind by name": "Choose device to bind by name", + "Bind to MAC address": "Bind to MAC address", + "Choose device to bind by MAC": "Choose device to bind by MAC", + "Server IP": "Server IP", + "Add DNS": "Add DNS", + "Add another DNS": "Add another DNS", + "DNS": "DNS", + "Use for installation only": "Use for installation only", + "The connection will be used only during installation and not available in the installed system.": "The connection will be used only during installation and not available in the installed system.", + "Ip prefix or netmask": "Ip prefix or netmask", + "At least one address must be provided for selected mode": "At least one address must be provided for selected mode", + "Edit connection %s": "Edit connection %s", + "Something went wrong": "Something went wrong", + "Mode": "Mode", + "Automatic (DHCP)": "Automatic (DHCP)", + "Manual": "Manual", + "Gateway": "Gateway", + "Gateway can be defined only in 'Manual' mode": "Gateway can be defined only in 'Manual' mode", + "Wi-Fi not supported": "Wi-Fi not supported", + "The system does not support Wi-Fi connections, probably because of missing or disabled hardware.": "The system does not support Wi-Fi connections, probably because of missing or disabled hardware.", + "Network": "Network", + "Wired connections": "Wired connections", + "Wi-Fi networks": "Wi-Fi networks", + "Installed system may not have network connections": "Installed system may not have network connections", + "All network connections managed through this interface are currently set to be used only during installation and will not be copied to the installed system": "All network connections managed through this interface are currently set to be used only during installation and will not be copied to the installed system", + "Network details": "Network details", + "SSID": "SSID", + "Signal strength": "Signal strength", + "Status": "Status", + "Security": "Security", + "Device": "Device", + "Connection details": "Connection details", + "Interface": "Interface", + "MAC": "MAC", + "IP settings": "IP settings", + "Edit": "Edit", + "IPv4": "IPv4", + "IPv6": "IPv6", + "Routes": "Routes", + "None": "None", + "WPA & WPA2 Personal": "WPA & WPA2 Personal", + "Not protected network": "Not protected network", + "You will connect to a public network without encryption. Your data may not be secure.": "You will connect to a public network without encryption. Your data may not be secure.", + "Setting up connection": "Setting up connection", + "It may take some time.": "It may take some time.", + "Details will appear after the connection is successfully established.": "Details will appear after the connection is successfully established.", + "Could not connect to %s": "Could not connect to %s", + "Check the authentication parameters.": "Check the authentication parameters.", + "Wi-Fi connection form": "Wi-Fi connection form", + "WPA Password": "WPA Password", + "Connect": "Connect", + "Network not found or lost": "Network not found or lost", + "Go to network page": "Go to network page", + "Connect to %s": "Connect to %s", + "Excellent signal": "Excellent signal", + "Good signal": "Good signal", + "Weak signal": "Weak signal", + "Secured network": "Secured network", + "Public network": "Public network", + "Connecting to %s": "Connecting to %s", + "Connected": "Connected", + "IP addresses": "IP addresses", + "Configured for installation only": "Configured for installation only", + "No Wi-Fi networks were found": "No Wi-Fi networks were found", + "Connection is available to all devices.": "Connection is available to all devices.", + "Connection is bound to device %s.": "Connection is bound to device %s.", + "Connection is bound to MAC address %s.": "Connection is bound to MAC address %s.", + "Binding": "Binding", + "Edit binding settings": "Edit binding settings", + "Device details": "Device details", + "IP Addresses": "IP Addresses", + "Connected device": "Connected device", + "Connected devices": "Connected devices", + "No device is currently using this connection.": "No device is currently using this connection.", + "Connected devices tabs": "Connected devices tabs", + "Settings": "Settings", + "Edit connection settings": "Edit connection settings", + "None set": "None set", + "Connection not found or lost": "Connection not found or lost", + "No wired connections were found": "No wired connections were found", + "The system will use %s as its default language.": "The system will use %s as its default language.", + "Overview": "Overview", + "These are the most relevant installation settings. Feel free to browse the sections in the menu for further details.": "These are the most relevant installation settings. Feel free to browse the sections in the menu for further details.", + "The installation will take": "The installation will take", + "The installation will take %s including:": "The installation will take %s including:", + "No device selected yet": "No device selected yet", + "Install using device %s shrinking existing partitions as needed.": "Install using device %s shrinking existing partitions as needed.", + "Install using device %s without modifying existing partitions.": "Install using device %s without modifying existing partitions.", + "Install using device %s and deleting all its content.": "Install using device %s and deleting all its content.", + "Install using device %s with a custom strategy to find the needed space.": "Install using device %s with a custom strategy to find the needed space.", + "Install using several devices shrinking existing partitions as needed.": "Install using several devices shrinking existing partitions as needed.", + "Install using several devices without modifying existing partitions.": "Install using several devices without modifying existing partitions.", + "Install using several devices and deleting all its content.": "Install using several devices and deleting all its content.", + "Install using several devices with a custom strategy to find the needed space.": "Install using several devices with a custom strategy to find the needed space.", + "There are no disks available for the installation.": "There are no disks available for the installation.", + "Install using an advanced configuration.": "Install using an advanced configuration.", + "This license is not available in %s.": "This license is not available in %s.", + "Close": "Close", + "%s [must be registered].": "%s [must be registered].", + "Registration server": "Registration server", + "Email": "Email", + "SUSE Customer Center (SCC)": "SUSE Customer Center (SCC)", + "Custom": "Custom", + "%s has been registered with below information.": "%s has been registered with below information.", + "Registration code": "Registration code", + "Hide": "Hide", + "Show": "Show", + "Server options": "Server options", + "Register using SUSE server": "Register using SUSE server", + "Register using a custom registration server": "Register using a custom registration server", + "Server URL": "Server URL", + "Example: https://myserver.com": "Example: https://myserver.com", + "Provide registration code": "Provide registration code", + "Provide email address": "Provide email address", + "Check the following before continuing": "Check the following before continuing", + "Register": "Register", + "You cannot change it later. Go to the %s section if you want to modify it before proceeding with registration.": "You cannot change it later. Go to the %s section if you want to modify it before proceeding with registration.", + "hostname": "hostname", + "Extensions": "Extensions", + "%s logo": "%s logo", + "I have read and accept the [license] for %s": "I have read and accept the [license] for %s", + "Select a product": "Select a product", + "Available products": "Available products", + "Configuring the product, please wait ...": "Configuring the product, please wait ...", + "The extension has been registered with key %s.": "The extension has been registered with key %s.", + "The extension was registered without any registration code.": "The extension was registered without any registration code.", + "Beta": "Beta", + "Recommended": "Recommended", + "Not available": "Not available", + "This extension is not available on the server. Ask the server administrator to mirror the extension.": "This extension is not available on the server. Ask the server administrator to mirror the extension.", + "Question": "Question", + "Configuration unreachable or invalid": "Configuration unreachable or invalid", + "The encryption password did not work": "The encryption password did not work", + "Encrypted Device": "Encrypted Device", + "Encryption Password": "Encryption Password", + "Installing a broken package affects system stability and is a big security risk!": "Installing a broken package affects system stability and is a big security risk!", + "Continuing without installing the package can result in a broken system. In some cases the system might not even boot.": "Continuing without installing the package can result in a broken system. In some cases the system might not even boot.", + "Package installation failed": "Package installation failed", + "Password Required": "Password Required", + "Registration certificate": "Registration certificate", + "URL": "URL", + "Issuer": "Issuer", + "Issue date": "Issue date", + "Expiration date": "Expiration date", + "SHA1 fingerprint": "SHA1 fingerprint", + "SHA256 fingerprint": "SHA256 fingerprint", + "Unsupported AutoYaST elements": "Unsupported AutoYaST elements", + "Some of the elements in your AutoYaST profile are not supported.": "Some of the elements in your AutoYaST profile are not supported.", + "Not implemented yet (%s)": "Not implemented yet (%s)", + "Will be supported in a future version.": "Will be supported in a future version.", + "Not supported (%s)": "Not supported (%s)", + "No support is planned.": "No support is planned.", + "Show less actions": "Show less actions", + "Show more actions": "Show more actions", + "Select a solution to continue": "Select a solution to continue", + "Apply selected solution": "Apply selected solution", + "Multiple conflicts found. You can address them in any order, and resolving one may resolve others.": "Multiple conflicts found. You can address them in any order, and resolving one may resolve others.", + "Skip to previous": "Skip to previous", + "%d of %d": "%d of %d", + "Skip to next": "Skip to next", + "No conflicts to address": "No conflicts to address", + "All conflicts have been resolved, or none were detected. You can safely continue with your setup.": "All conflicts have been resolved, or none were detected. You can safely continue with your setup.", + "Software conflicts resolution": "Software conflicts resolution", + "No additional software was selected.": "No additional software was selected.", + "The following software patterns are selected for installation:": "The following software patterns are selected for installation:", + "Selected patterns": "Selected patterns", + "Change selection": "Change selection", + "This product does not allow to select software patterns during installation. However, you can add additional software once the installation is finished.": "This product does not allow to select software patterns during installation. However, you can add additional software once the installation is finished.", + "Some installation repositories could not be loaded. The system cannot be installed without them.": "Some installation repositories could not be loaded. The system cannot be installed without them.", + "Repository load failed": "Repository load failed", + "Loading the installation repositories...": "Loading the installation repositories...", + "Try again": "Try again", + "Used space": "Used space", + "None of the patterns match the filter.": "None of the patterns match the filter.", + "auto selected": "auto selected", + "Unselect": "Unselect", + "Software selection": "Software selection", + "Filter by pattern title or description": "Filter by pattern title or description", + "Installation will take %s.": "Installation will take %s.", + "This space includes the base system and the selected software patterns, if any.": "This space includes the base system and the selected software patterns, if any.", + "logical volume": "logical volume", + "partition": "partition", + "A generic size of %1$s will be used for the new %2$s": "A generic size of %1$s will be used for the new %2$s", + "A generic size range between %1$s and %2$s will be used for the new %3$s": "A generic size range between %1$s and %2$s will be used for the new %3$s", + "A generic minimum size of %1$s will be used for the new %2$s": "A generic minimum size of %1$s will be used for the new %2$s", + "A %1$s of %2$s will be created for %3$s if possible": "A %1$s of %2$s will be created for %3$s if possible", + "A %1$s with a size between %2$s and %3$s will be created for %4$s if possible": "A %1$s with a size between %2$s and %3$s will be created for %4$s if possible", + "A %1$s of at least %2$s will be created for %3$s if possible": "A %1$s of at least %2$s will be created for %3$s if possible", + "Based on the amount of RAM in the system, a %1$s of %2$s will be planned for %3$s": "Based on the amount of RAM in the system, a %1$s of %2$s will be planned for %3$s", + "Based on the amount of RAM in the system, a %1$s with a size between %2$s and %3$s will be planned for %4$s": "Based on the amount of RAM in the system, a %1$s with a size between %2$s and %3$s will be planned for %4$s", + "Based on the amount of RAM in the system, a %1$s of at least %2$s will be planned for %3$s": "Based on the amount of RAM in the system, a %1$s of at least %2$s will be planned for %3$s", + "The size for %1$s will be dynamically adjusted based on the amount of RAM in the system, the usage of Btrfs snapshots and the presence of a separate file system for %2$s.": "The size for %1$s will be dynamically adjusted based on the amount of RAM in the system, the usage of Btrfs snapshots and the presence of a separate file system for %2$s.", + "The size for %1$s will be dynamically adjusted based on the amount of RAM in the system, the usage of Btrfs snapshots and the presence of separate file systems for %2$s.": "The size for %1$s will be dynamically adjusted based on the amount of RAM in the system, the usage of Btrfs snapshots and the presence of separate file systems for %2$s.", + "The size for %s will be dynamically adjusted based on the amount of RAM in the system and the usage of Btrfs snapshots.": "The size for %s will be dynamically adjusted based on the amount of RAM in the system and the usage of Btrfs snapshots.", + "The size for %1$s will be dynamically adjusted based on the amount of RAM in the system and the presence of a separate file system for %2$s.": "The size for %1$s will be dynamically adjusted based on the amount of RAM in the system and the presence of a separate file system for %2$s.", + "The size for %1$s will be dynamically adjusted based on the amount of RAM in the system and the presence of separate file systems for %2$s.": "The size for %1$s will be dynamically adjusted based on the amount of RAM in the system and the presence of separate file systems for %2$s.", + "The size for %1$s will be dynamically adjusted based on the usage of Btrfs snapshots and the presence of a separate file system for %2$s.": "The size for %1$s will be dynamically adjusted based on the usage of Btrfs snapshots and the presence of a separate file system for %2$s.", + "The size for %1$s will be dynamically adjusted based on the usage of Btrfs snapshots and the presence of separate file systems for %2$s.": "The size for %1$s will be dynamically adjusted based on the usage of Btrfs snapshots and the presence of separate file systems for %2$s.", + "The size for %s will be dynamically adjusted based on the usage of Btrfs snapshots.": "The size for %s will be dynamically adjusted based on the usage of Btrfs snapshots.", + "The size for %1$s will be dynamically adjusted based on the presence of a separate file system for %2$s.": "The size for %1$s will be dynamically adjusted based on the presence of a separate file system for %2$s.", + "The size for %1$s will be dynamically adjusted based on the presence of separate file systems for %2$s.": "The size for %1$s will be dynamically adjusted based on the presence of separate file systems for %2$s.", + "The current configuration will result in an attempt to create a %1$s of %2$s.": "The current configuration will result in an attempt to create a %1$s of %2$s.", + "The current configuration will result in an attempt to create a %1$s with a size between %2$s and %3$s.": "The current configuration will result in an attempt to create a %1$s with a size between %2$s and %3$s.", + "The current configuration will result in an attempt to create a %1$s of at least %2$s.": "The current configuration will result in an attempt to create a %1$s of at least %2$s.", + "To ensure the new system is able to boot, the installer may need to create or configure some partitions in the appropriate disk.": "To ensure the new system is able to boot, the installer may need to create or configure some partitions in the appropriate disk.", + "Partitions to boot will be allocated at the installation disk.": "Partitions to boot will be allocated at the installation disk.", + "Partitions to boot will be allocated at the installation disk %s.": "Partitions to boot will be allocated at the installation disk %s.", + "Boot options": "Boot options", + "Automatic": "Automatic", + "Select a disk": "Select a disk", + "Partitions to boot will be allocated at the following device.": "Partitions to boot will be allocated at the following device.", + "Choose a disk for placing the boot loader": "Choose a disk for placing the boot loader", + "Do not configure": "Do not configure", + "No partitions will be automatically configured for booting. Use with caution.": "No partitions will be automatically configured for booting. Use with caution.", + "No devices configured yet": "No devices configured yet", + "Use actions below to set up your devices or click %s to start from scratch with the default configuration.": "Use actions below to set up your devices or click %s to start from scratch with the default configuration.", + "reset to defaults": "reset to defaults", + "[FIXME]": "[FIXME]", + "Other options toggle": "Other options toggle", + "Other options": "Other options", + "Select the disk to configure partitions for booting": "Select the disk to configure partitions for booting", + "Change boot options": "Change boot options", + "Start from scratch with the default configuration": "Start from scratch with the default configuration", + "Reset to defaults": "Reset to defaults", + "Discover and connect to iSCSI targets": "Discover and connect to iSCSI targets", + "Configure iSCSI": "Configure iSCSI", + "Activate zFCP disks": "Activate zFCP disks", + "Configure zFCP": "Configure zFCP", + "Activate and format DASD devices": "Activate and format DASD devices", + "Configure DASD": "Configure DASD", + "Update available disks and activate crypt devices": "Update available disks and activate crypt devices", + "Rescan devices": "Rescan devices", + "Select a device to define partitions or to mount": "Select a device to define partitions or to mount", + "Select another device to define partitions or to mount": "Select another device to define partitions or to mount", + "Select a disk to define partitions or to mount": "Select a disk to define partitions or to mount", + "Select another disk to define partitions or to mount": "Select another disk to define partitions or to mount", + "Already using all available devices": "Already using all available devices", + "Already using all available disks": "Already using all available disks", + "Extend the installation beyond the currently selected device": "Extend the installation beyond the currently selected device", + "Extend the installation beyond the currently selected device_plural": "Extend the installation beyond the current %d devices", + "Extend the installation beyond the currently selected disk": "Extend the installation beyond the currently selected disk", + "Extend the installation beyond the currently selected disk_plural": "Extend the installation beyond the current %d disks", + "Start configuring a basic installation": "Start configuring a basic installation", + "Add device menu": "Add device menu", + "Define a new LVM on top of one or several disks": "Define a new LVM on top of one or several disks", + "Define a new LVM on the disk": "Define a new LVM on the disk", + "Configure device menu": "Configure device menu", + "Add LVM volume group": "Add LVM volume group", + "More devices": "More devices", + "Size": "Size", + "Description": "Description", + "Current content": "Current content", + "Type": "Type", + "Name": "Name", + "Content": "Content", + "Filesystems": "Filesystems", + "Device Selection": "Device Selection", + "The modal selector offers a simplified interface designed for quick and straightforward use, without overwhelming the user.": "The modal selector offers a simplified interface designed for quick and straightforward use, without overwhelming the user.", + "For more advanced needs, users can switch to this full-page, dedicated path version that provides more space for detailed views, additional columns, filters, and extended functionality.": "For more advanced needs, users can switch to this full-page, dedicated path version that provides more space for detailed views, additional columns, filters, and extended functionality.", + "This pattern strikes a balance between clarity and efficiency: the modal keeps things lightweight for simple selections, while the full view supports deeper exploration and more complex actions, specially for users with tons of devices.": "This pattern strikes a balance between clarity and efficiency: the modal keeps things lightweight for simple selections, while the full view supports deeper exploration and more complex actions, specially for users with tons of devices.", + "Mount disk %s": "Mount disk %s", + "Format disk %s": "Format disk %s", + "Use disk %s to install, host LVM and boot": "Use disk %s to install, host LVM and boot", + "Use disk %s to install and host LVM": "Use disk %s to install and host LVM", + "Use disk %s to install and boot": "Use disk %s to install and boot", + "Use disk %s to install": "Use disk %s to install", + "Use disk %s for LVM, additional partitions and booting": "Use disk %s for LVM, additional partitions and booting", + "Use disk %s for LVM and additional partitions": "Use disk %s for LVM and additional partitions", + "Use disk %s for additional partitions and booting": "Use disk %s for additional partitions and booting", + "Use disk %s for additional partitions": "Use disk %s for additional partitions", + "Use disk %s to host LVM and boot": "Use disk %s to host LVM and boot", + "Use disk %s to host LVM": "Use disk %s to host LVM", + "Use disk %s to configure boot partitions": "Use disk %s to configure boot partitions", + "Use disk %s": "Use disk %s", + "Encryption is disabled": "Encryption is disabled", + "Encryption is enabled using TPM unlocking": "Encryption is enabled using TPM unlocking", + "Encryption is enabled": "Encryption is enabled", + "Encryption": "Encryption", + "Protection for the information stored at the new file systems, including data, programs, and system files.": "Protection for the information stored at the new file systems, including data, programs, and system files.", + "Password is empty.": "Password is empty.", + "Use the Trusted Platform Module (TPM) to decrypt automatically on each boot": "Use the Trusted Platform Module (TPM) to decrypt automatically on each boot", + "The password will not be needed to boot and access the data if the TPM can verify the integrity of the system. TPM sealing requires the new system to be booted directly on its first run.": "The password will not be needed to boot and access the data if the TPM can verify the integrity of the system. TPM sealing requires the new system to be booted directly on its first run.", + "Encryption settings": "Encryption settings", + "Encrypt the system": "Encrypt the system", + "Full Disk Encryption (FDE) allows to protect the information stored at the new file systems, including data, programs, and system files.": "Full Disk Encryption (FDE) allows to protect the information stored at the new file systems, including data, programs, and system files.", + "The device will be mounted": "The device will be mounted", + "The device will be formatted": "The device will be formatted", + "The current file system will be mounted at %s": "The current file system will be mounted at %s", + "The device will be formatted as %1$s and mounted at %2$s": "The device will be formatted as %1$s and mounted at %2$s", + "Details for %s": "Details for %s", + "Details": "Details", + "Change the file system or mount point": "Change the file system or mount point", + "The configuration must be adapted to address the following issue:": "The configuration must be adapted to address the following issue:", + "The configuration must be adapted to address the following issue:_plural": "The configuration must be adapted to address the following issues:", + "Select or enter a valid mount point": "Select or enter a valid mount point", + "Select or enter a mount point that is not already assigned to another device": "Select or enter a mount point that is not already assigned to another device", + "Waiting for a mount point": "Waiting for a mount point", + "Current %s": "Current %s", + "Btrfs with snapshots": "Btrfs with snapshots", + "Default file system for %s": "Default file system for %s", + "Default file system for generic mount paths": "Default file system for generic mount paths", + "Destroy current data and format device as": "Destroy current data and format device as", + "Format device as": "Format device as", + "Do not format %s and keep the data": "Do not format %s and keep the data", + "File system label": "File system label", + "Configure device %s": "Configure device %s", + "Mount point": "Mount point", + "Mount point toggle": "Mount point toggle", + "Suggested mount points": "Suggested mount points", + "Clear selected mount point": "Clear selected mount point", + "Use": "Use", + "Select or enter a mount point": "Select or enter a mount point", + "File system": "File system", + "Label": "Label", + "iSCSI": "iSCSI", + "Enter a name": "Enter a name", + "The minimum cannot be greater than the maximum": "The minimum cannot be greater than the maximum", + "The maximum must be a number optionally followed by a unit like GiB or GB": "The maximum must be a number optionally followed by a unit like GiB or GB", + "The minimum must be a number optionally followed by a unit like GiB or GB": "The minimum must be a number optionally followed by a unit like GiB or GB", + "Size limits must be numbers optionally followed by a unit like GiB or GB": "Size limits must be numbers optionally followed by a unit like GiB or GB", + "Logical volume name": "Logical volume name", + "Default file system for generic logical volumes": "Default file system for generic logical volumes", + "Format logical volume as": "Format logical volume as", + "Configure LVM logical volume at %s volume group": "Configure LVM logical volume at %s volume group", + "Size mode": "Size mode", + "Enter a name for the volume group.": "Enter a name for the volume group.", + "Volume group '%s' already exists. Enter a different name.": "Volume group '%s' already exists. Enter a different name.", + "Select at least one disk.": "Select at least one disk.", + "Configure LVM Volume Group": "Configure LVM Volume Group", + "Disks": "Disks", + "The needed LVM physical volumes will be added as partitions on the chosen disks, based on the sizes of the logical volumes. If you select more than one disk, the physical volumes may be distributed along several disks.": "The needed LVM physical volumes will be added as partitions on the chosen disks, based on the sizes of the logical volumes. If you select more than one disk, the physical volumes may be distributed along several disks.", + "Move mount points": "Move mount points", + "Move the mount points currently configured at the selected disks to logical volumes of this volume group.": "Move the mount points currently configured at the selected disks to logical volumes of this volume group.", + "Mount RAID %s": "Mount RAID %s", + "Format RAID %s": "Format RAID %s", + "Use RAID %s to install, host LVM and boot": "Use RAID %s to install, host LVM and boot", + "Use RAID %s to install and host LVM": "Use RAID %s to install and host LVM", + "Use RAID %s to install and boot": "Use RAID %s to install and boot", + "Use RAID %s to install": "Use RAID %s to install", + "Use RAID %s for LVM, additional partitions and booting": "Use RAID %s for LVM, additional partitions and booting", + "Use RAID %s for LVM and additional partitions": "Use RAID %s for LVM and additional partitions", + "Use RAID %s for additional partitions and booting": "Use RAID %s for additional partitions and booting", + "Use RAID %s for additional partitions": "Use RAID %s for additional partitions", + "Use RAID %s to host LVM and boot": "Use RAID %s to host LVM and boot", + "Use RAID %s to host LVM": "Use RAID %s to host LVM", + "Use RAID %s to configure boot partitions": "Use RAID %s to configure boot partitions", + "Use RAID %s": "Use RAID %s", + "Create another LVM volume group on %s": "Create another LVM volume group on %s", + "Create LVM volume group on %s": "Create LVM volume group on %s", + "%s will be created as a logical volume": "%s will be created as a logical volume", + "%s will be created as a logical volume_plural": "%s will be created as logical volumes", + "The maximum must be a number followed by a unit like GiB or GB": "The maximum must be a number followed by a unit like GiB or GB", + "The minimum must be a number followed by a unit like GiB or GB": "The minimum must be a number followed by a unit like GiB or GB", + "Size limits must be numbers followed by a unit like GiB or GB": "Size limits must be numbers followed by a unit like GiB or GB", + "As a new partition on %s": "As a new partition on %s", + "Using partition %s": "Using partition %s", + "Mount point options": "Mount point options", + "Using an existing partition": "Using an existing partition", + "There are not usable partitions": "There are not usable partitions", + "Default file system for generic partitions": "Default file system for generic partitions", + "Destroy current data and format partition as": "Destroy current data and format partition as", + "Format partition as": "Format partition as", + "Configure partition at %s": "Configure partition at %s", + "Mount point mode": "Mount point mode", + "Go to storage page": "Go to storage page", + "Moreover, the following partitions will be created or mounted": "Moreover, the following partitions will be created or mounted", + "Moreover, the following partition will be created.": "Moreover, the following partition will be created.", + "Moreover, the following partition will be created._plural": "Moreover, the following partitions will be created.", + "Moreover, the following partition will be mounted.": "Moreover, the following partition will be mounted.", + "Moreover, the following partition will be mounted._plural": "Moreover, the following partitions will be mounted.", + "The following partitions will be created or mounted": "The following partitions will be created or mounted", + "The following partition will be created.": "The following partition will be created.", + "The following partition will be created._plural": "The following partitions will be created.", + "The following partition will be mounted.": "The following partition will be mounted.", + "The following partition will be mounted._plural": "The following partitions will be mounted.", + "Any partition needed to boot will be configured.": "Any partition needed to boot will be configured.", + "Add another partition or mount an existing one": "Add another partition or mount an existing one", + "Add or use partition": "Add or use partition", + "Loading storage": "Loading storage", + "Hide %d subvolume action": "Hide %d subvolume action", + "Hide %d subvolume action_plural": "Hide %d subvolume actions", + "Show %d subvolume action": "Show %d subvolume action", + "Show %d subvolume action_plural": "Show %d subvolume actions", + "It is not possible to install the system with the current configuration. Adjust the settings below.": "It is not possible to install the system with the current configuration. Adjust the settings below.", + "It is not possible to allocate space for the boot partition and for %s.": "It is not possible to allocate space for the boot partition and for %s.", + "It is not possible to allocate space for %s.": "It is not possible to allocate space for %s.", + "Adjust the settings below to make the new system fit into the available space.": "Adjust the settings below to make the new system fit into the available space.", + "Failed to calculate a storage layout": "Failed to calculate a storage layout", + "Invalid storage settings": "Invalid storage settings", + "The current storage configuration has the following issue:": "The current storage configuration has the following issue:", + "The current storage configuration has the following issue:_plural": "The current storage configuration has the following issues:", + "You may want to discard those settings and start from scratch with a simple configuration.": "You may want to discard those settings and start from scratch with a simple configuration.", + "Reset to the default configuration": "Reset to the default configuration", + "Unable to modify the settings": "Unable to modify the settings", + "The storage configuration uses elements not supported by this interface.": "The storage configuration uses elements not supported by this interface.", + "You may want to discard the current settings and start from scratch with a simple configuration.": "You may want to discard the current settings and start from scratch with a simple configuration.", + "There are not disks available for the installation. You may need to configure some device.": "There are not disks available for the installation. You may need to configure some device.", + "No devices found": "No devices found", + "Connect to iSCSI targets": "Connect to iSCSI targets", + "Manage DASD devices": "Manage DASD devices", + "Installation Devices": "Installation Devices", + "Structure of the new system, including disks to use and additional devices like LVM volume groups.": "Structure of the new system, including disks to use and additional devices like LVM volume groups.", + "Reloading data, please wait...": "Reloading data, please wait...", + "Waiting for information about storage configuration": "Waiting for information about storage configuration", + "There is %d destructive action planned affecting %s": "There is %d destructive action planned affecting %s", + "There is %d destructive action planned affecting %s_plural": "There are %d destructive actions planned affecting %s", + "There is %d destructive action planned": "There is %d destructive action planned", + "There is %d destructive action planned_plural": "There are %d destructive actions planned", + "Collapse the list of planned actions": "Collapse the list of planned actions", + "Check the %d planned actions": "Check the %d planned actions", + "Result": "Result", + "During installation, several actions will be performed to setup the layout shown at the table below.": "During installation, several actions will be performed to setup the layout shown at the table below.", + "New": "New", + "Before %s": "Before %s", + "Mount Point": "Mount Point", + "Transactional root file system": "Transactional root file system", + "%s is an immutable system with atomic updates. It uses a read-only Btrfs file system updated via snapshots.": "%s is an immutable system with atomic updates. It uses a read-only Btrfs file system updated via snapshots.", + "Selected disk cannot be changed": "Selected disk cannot be changed", + "Select a disk to format as %s": "Select a disk to format as %s", + "Select a disk to configure": "Select a disk to configure", + "Select a disk to install the system": "Select a disk to install the system", + "Select a disk to create %s": "Select a disk to create %s", + "This uses the existing file system at the disk": "This uses the existing file system at the disk", + "This uses existing partitions at the disk": "This uses existing partitions at the disk", + "It is chosen for booting and for some LVM groups": "It is chosen for booting and for some LVM groups", + "It is chosen for some LVM groups": "It is chosen for some LVM groups", + "It is chosen for booting and for the LVM group '%s'": "It is chosen for booting and for the LVM group '%s'", + "It is chosen for the LVM group '%s'": "It is chosen for the LVM group '%s'", + "It is chosen for booting": "It is chosen for booting", + "%s will still contain the configured LVM groups and any partition needed to boot": "%s will still contain the configured LVM groups and any partition needed to boot", + "The configured LVM groups will remain at %s": "The configured LVM groups will remain at %s", + "%1$s will still contain the LVM group '%2$s' and any partition needed to boot": "%1$s will still contain the LVM group '%2$s' and any partition needed to boot", + "The LVM group '%1$s' will remain at %2$s": "The LVM group '%1$s' will remain at %2$s", + "Partitions needed for booting will remain at %s": "Partitions needed for booting will remain at %s", + "Partitions needed for booting will also be adapted": "Partitions needed for booting will also be adapted", + "Change device menu": "Change device menu", + "The disk is used for LVM and boot": "The disk is used for LVM and boot", + "The disk is used for booting": "The disk is used for booting", + "The disk is used for LVM": "The disk is used for LVM", + "Remove the configuration for this disk": "Remove the configuration for this disk", + "Do not use": "Do not use", + "Device %s menu": "Device %s menu", + "The size is configured as a range between %s and %s, but this interface cannot handle ranges with a given max size.": "The size is configured as a range between %s and %s, but this interface cannot handle ranges with a given max size.", + "Discard the maximum size and continue with simplified configuration": "Discard the maximum size and continue with simplified configuration", + "The size must be a number followed by a unit of the form GiB (power of 2) or GB (power of 10).": "The size must be a number followed by a unit of the form GiB (power of 2) or GB (power of 10).", + "approx. %s": "approx. %s", + "Allow growing": "Allow growing", + "The final size can be bigger in order to fill the extra free space.": "The final size can be bigger in order to fill the extra free space.", + "Size modes": "Size modes", + "Let the installer propose a sensible size": "Let the installer propose a sensible size", + "Define a custom size": "Define a custom size", + "The device will be used by the new system.": "The device will be used by the new system.", + "The device will be mounted at %s.": "The device will be mounted at %s.", + "Up to %s can be recovered by shrinking the device.": "Up to %s can be recovered by shrinking the device.", + "The device cannot be shrunk:": "The device cannot be shrunk:", + "Show information about %s": "Show information about %s", + "The content may be deleted": "The content may be deleted", + "No content found": "No content found", + "Action": "Action", + "Find space": "Find space", + "Select what to do with each partition in order to find space for allocating the new system.": "Select what to do with each partition in order to find space for allocating the new system.", + "Find space in %s": "Find space in %s", + "The storage configuration is valid (see result below) but uses elements not supported by this interface.": "The storage configuration is valid (see result below) but uses elements not supported by this interface.", + "You can proceed to install with the current settings or you may want to discard the configuration and start from scratch with a simple one.": "You can proceed to install with the current settings or you may want to discard the configuration and start from scratch with a simple one.", + "Not configured yet": "Not configured yet", + "Use the RAID without partitions": "Use the RAID without partitions", + "Use the disk without partitions": "Use the disk without partitions", + "Add a partition or mount an existing one": "Add a partition or mount an existing one", + "Format the whole device or mount an existing file system": "Format the whole device or mount an existing file system", + "%1$s will be created as a partition at %2$s": "%1$s will be created as a partition at %2$s", + "%1$s will be created as a partition at %2$s_plural": "%1$s will be created as partitions at %2$s", + "The logical volume will also be deleted": "The logical volume will also be deleted", + "The logical volume will also be deleted_plural": "The logical volumes will also be deleted", + "Delete volume group": "Delete volume group", + "Modify settings and physical volumes": "Modify settings and physical volumes", + "Edit volume group": "Edit volume group", + "Create LVM volume group %s": "Create LVM volume group %s", + "Empty LVM volume group %s": "Empty LVM volume group %s", + "Logical volumes for %s": "Logical volumes for %s", + "Add logical volume": "Add logical volume", + "The following logical volume will be created": "The following logical volume will be created", + "The following logical volume will be created_plural": "The following logical volumes will be created", + "Formatting DASD devices": "Formatting DASD devices", + "DASD": "DASD", + "Activate": "Activate", + "Deactivate": "Deactivate", + "Set DIAG on": "Set DIAG on", + "Set DIAG off": "Set DIAG off", + "Format": "Format", + "Min channel": "Min channel", + "Max channel": "Max channel", + "Apply to the selected device": "Apply to the selected device", + "Apply to the selected device_plural": "Apply to the %s selected devices", + "Select devices to enable bulk actions.": "Select devices to enable bulk actions.", + "No devices available": "No devices available", + "No DASD devices were found in this machine.": "No DASD devices were found in this machine.", + "Change filters and try again.": "Change filters and try again.", + "Clear all filters": "Clear all filters", + "Channel ID": "Channel ID", + "DIAG": "DIAG", + "No": "No", + "Yes": "Yes", + "Formatted": "Formatted", + "Partition Info": "Partition Info", + "Applying changes": "Applying changes", + "This may take a moment while updates complete.": "This may take a moment while updates complete.", + "This message will close automatically when everything is done.": "This message will close automatically when everything is done.", + "Cannot format %s": "Cannot format %s", + "It is offline and must be activated before formatting it.": "It is offline and must be activated before formatting it.", + "Cannot format all the selected devices": "Cannot format all the selected devices", + "Below %s devices are offline and cannot be formatted.": "Below %s devices are offline and cannot be formatted.", + "Unselect or activate them and try it again.": "Unselect or activate them and try it again.", + "Format device %s": "Format device %s", + "This action could destroy any data stored on the device.": "This action could destroy any data stored on the device.", + "Confirm that you really want to continue.": "Confirm that you really want to continue.", + "Format now": "Format now", + "Format selected devices?": "Format selected devices?", + "This action could destroy any data stored on the devices listed below.": "This action could destroy any data stored on the devices listed below.", + "all": "all", + "yes": "yes", + "no": "no", + "active": "active", + "read_only": "read_only", + "offline": "offline", + "Clear input": "Clear input", + "Unused space": "Unused space", + "%1$s (%2$s)": "%1$s (%2$s)", + "Only available if authentication by target is provided": "Only available if authentication by target is provided", + "Authentication by target": "Authentication by target", + "User name": "User name", + "Incorrect user name": "Incorrect user name", + "Incorrect password": "Incorrect password", + "Authentication by initiator": "Authentication by initiator", + "Target Password": "Target Password", + "Discover iSCSI Targets": "Discover iSCSI Targets", + "Make sure you provide the correct values": "Make sure you provide the correct values", + "IP address": "IP address", + "Address": "Address", + "Incorrect IP address": "Incorrect IP address", + "Port": "Port", + "Incorrect port": "Incorrect port", + "Edit %s": "Edit %s", + "Initiator name successfully updated": "Initiator name successfully updated", + "Initiator name could not be updated": "Initiator name could not be updated", + "The initiator name cannot be blank": "The initiator name cannot be blank", + "Updating the initiator name": "Updating the initiator name", + "Initiator name": "Initiator name", + "Initiator details": "Initiator details", + "Configuration read from the iSCSI Boot Firmware Table (iBFT).": "Configuration read from the iSCSI Boot Firmware Table (iBFT).", + "No iSCSI Boot Firmware Table (iBFT) found. The initiator can be configured manually.": "No iSCSI Boot Firmware Table (iBFT) found. The initiator can be configured manually.", + "Initiator": "Initiator", + "Login %s": "Login %s", + "Startup": "Startup", + "On boot": "On boot", + "Disconnected": "Disconnected", + "Connected (%s)": "Connected (%s)", + "Delete": "Delete", + "Login": "Login", + "Logout": "Logout", + "Portal": "Portal", + "iBFT": "iBFT", + "No iSCSI targets found.": "No iSCSI targets found.", + "Please, perform an iSCSI discovery in order to find available iSCSI targets.": "Please, perform an iSCSI discovery in order to find available iSCSI targets.", + "Discover iSCSI targets": "Discover iSCSI targets", + "Discover": "Discover", + "Targets": "Targets", + "KiB": "KiB", + "MiB": "MiB", + "GiB": "GiB", + "TiB": "TiB", + "PiB": "PiB", + "Bcachefs": "Bcachefs", + "BitLocker": "BitLocker", + "Btrfs": "Btrfs", + "ExFAT": "ExFAT", + "Ext2": "Ext2", + "Ext3": "Ext3", + "Ext4": "Ext4", + "F2FS": "F2FS", + "JFS": "JFS", + "NFS": "NFS", + "NILFS2": "NILFS2", + "NTFS": "NTFS", + "ReiserFS": "ReiserFS", + "Swap": "Swap", + "Tmpfs": "Tmpfs", + "FAT": "FAT", + "XFS": "XFS", + "Delete current content": "Delete current content", + "Shrink existing partitions": "Shrink existing partitions", + "Use available space": "Use available space", + "%1$s - %2$s": "%1$s - %2$s", + "at least %s": "at least %s", + "Multipath": "Multipath", + "DASD %s": "DASD %s", + "Software %s": "Software %s", + "SD Card": "SD Card", + "%s disk": "%s disk", + "Disk": "Disk", + "%s with %d partitions": "%s with %d partitions", + "A partition will be deleted": "A partition will be deleted", + "At least one partition will be deleted": "At least one partition will be deleted", + "Several partitions will be deleted": "Several partitions will be deleted", + "A partition may be deleted": "A partition may be deleted", + "Some partitions may be deleted": "Some partitions may be deleted", + "A partition may be shrunk": "A partition may be shrunk", + "Some partitions may be shrunk": "Some partitions may be shrunk", + "All content not configured to be mounted will be deleted": "All content not configured to be mounted will be deleted", + "All content will be deleted": "All content will be deleted", + "Reused partitions will not be shrunk": "Reused partitions will not be shrunk", + "Some existing partitions may be shrunk": "Some existing partitions may be shrunk", + "Current partitions will be kept": "Current partitions will be kept", + "Partitions that are not reused will be removed and that data will be lost.": "Partitions that are not reused will be removed and that data will be lost.", + "Any existing partition will be removed and all data in the disk will be lost.": "Any existing partition will be removed and all data in the disk will be lost.", + "Partitions that are not reused will be resized as needed.": "Partitions that are not reused will be resized as needed.", + "Partitions that are not reused would be resized if needed.": "Partitions that are not reused would be resized if needed.", + "The data is kept, but the current partitions will be resized as needed.": "The data is kept, but the current partitions will be resized as needed.", + "Only reused partitions and space not assigned to any partition will be used.": "Only reused partitions and space not assigned to any partition will be used.", + "Only reused partitions will be used.": "Only reused partitions will be used.", + "The data is kept. Only the space not assigned to any partition will be used.": "The data is kept. Only the space not assigned to any partition will be used.", + "Select what to do with each partition.": "Select what to do with each partition.", + "The whole device will be used for %s": "The whole device will be used for %s", + "A file system will be used for the whole device": "A file system will be used for the whole device", + "No additional partitions will be created": "No additional partitions will be created", + "An existing partition will be used for %s": "An existing partition will be used for %s", + "An existing partition will be used for %s_plural": "Existing partitions will be used for %s", + "A new partition will be created for %s": "A new partition will be created for %s", + "A new partition will be created for %s_plural": "New partitions will be created for %s", + "Partitions will be used and created for %s": "Partitions will be used and created for %s", + "Current %1$s at %2$s": "Current %1$s at %2$s", + "%1$s at %2$s": "%1$s at %2$s", + "No logical volumes are defined yet": "No logical volumes are defined yet", + "A new volume will be created for %s": "A new volume will be created for %s", + "A new volume will be created for %s_plural": "New volumes will be created for %s", + "Auto LUNs Scan": "Auto LUNs Scan", + "Activated": "Activated", + "Deactivated": "Deactivated", + "zFCP Disk Activation": "zFCP Disk Activation", + "zFCP Disk activation form": "zFCP Disk activation form", + "The zFCP disk was not activated.": "The zFCP disk was not activated.", + "WWPN": "WWPN", + "LUN": "LUN", + "Automatic LUN scan is [enabled]. Activating a controller which is running in NPIV mode will automatically configures all its LUNs.": "Automatic LUN scan is [enabled]. Activating a controller which is running in NPIV mode will automatically configures all its LUNs.", + "Automatic LUN scan is [disabled]. LUNs have to be manually configured after activating a controller.": "Automatic LUN scan is [disabled]. LUNs have to be manually configured after activating a controller.", + "Please, try to activate a zFCP disk.": "Please, try to activate a zFCP disk.", + "Please, try to activate a zFCP controller.": "Please, try to activate a zFCP controller.", + "No zFCP disks found.": "No zFCP disks found.", + "Activate zFCP disk": "Activate zFCP disk", + "Activate new disk": "Activate new disk", + "Controllers": "Controllers", + "No zFCP controllers found.": "No zFCP controllers found.", + "Read zFCP devices": "Read zFCP devices", + "zFCP": "zFCP", + "Enter a hostname.": "Enter a hostname.", + "Hostname successfully updated": "Hostname successfully updated", + "Hostname could not be updated": "Hostname could not be updated", + "Using transient hostname: %s": "Using transient hostname: %s", + "Hostname": "Hostname", + "Product is already registered": "Product is already registered", + "Updating the hostname now or later will not change the currently registered hostname.": "Updating the hostname now or later will not change the currently registered hostname.", + "This hostname is dynamic and may change after a reboot or network update, as configured by the local network administrator.": "This hostname is dynamic and may change after a reboot or network update, as configured by the local network administrator.", + "Use static hostname": "Use static hostname", + "Set a permanent hostname that won’t change with network updates.": "Set a permanent hostname that won’t change with network updates.", + "Static hostname": "Static hostname", + "Define a user now": "Define a user now", + "Discard": "Discard", + "No user defined yet.": "No user defined yet.", + "Full name": "Full name", + "Username": "Username", + "First user": "First user", + "Define the first user with admin (sudo) privileges for system management.": "Define the first user with admin (sudo) privileges for system management.", + "Username suggestion dropdown": "Username suggestion dropdown", + "Use suggested username": "Use suggested username", + "All fields are required": "All fields are required", + "Create user": "Create user", + "Edit user": "Edit user", + "Using a hashed password.": "Using a hashed password.", + "The password is weak": "The password is weak", + "Root user": "Root user", + "Alongside defining the first user, authentication methods for the root user can be configured.": "Alongside defining the first user, authentication methods for the root user can be configured.", + "Defined (hidden)": "Defined (hidden)", + "Not defined": "Not defined", + "Public SSH Key": "Public SSH Key", + "Upload, paste, or drop an SSH public key": "Upload, paste, or drop an SSH public key", + "Upload": "Upload", + "Clear": "Clear", + "Public SSH Key is empty.": "Public SSH Key is empty.", + "Root authentication methods": "Root authentication methods", + "Use password": "Use password", + "Use public SSH Key": "Use public SSH Key", + "ZFCP": "ZFCP" +} \ No newline at end of file diff --git a/web/public/po-es.json b/web/public/po-es.json new file mode 100644 index 0000000000..e5ec1775f6 --- /dev/null +++ b/web/public/po-es.json @@ -0,0 +1,810 @@ +{ + "Configuration out of sync": "Configuración desincronizada", + "The configuration has been updated externally.": "La configuración se ha actualizado externamente.", + "Reloading is required to get the latest data and avoid issues or data loss.": "Es necesario volver a cargar para obtener los datos más recientes y evitar problemas o pérdida de datos.", + "Reload now": "Recargar ahora", + "Change product": "Cambiar de producto", + "Confirm Installation": "Confirmar instalación", + "If you continue, partitions on your hard disk will be modified according to the provided installation settings.": "Si continúa, las particiones del disco duro se modificarán de acuerdo con la configuración de instalación proporcionada.", + "Please, cancel and check the settings if you are unsure.": "Si no está seguro, cancele y revise la configuración.", + "Continue": "Continuar", + "Cancel": "Cancelar", + "Install": "Instalar", + "Not possible with the current setup. Click to know more.": "No es posible con la configuración actual. Haga clic para obtener más información.", + "Your system is rebooting": "El sistema se está reiniciando", + "The installer interface is no longer available, so you can safely close this window.": "La interfaz del instalador ya no está disponible, por lo que puede cerrar esta ventana sin problemas.", + "TPM sealing requires the new system to be booted directly.": "El sellado TPM requiere que el nuevo sistema se inicie directamente.", + "If a local media was used to run this installer, remove it before the next boot.": "Si se ha utilizado un medio local para ejecutar este instalador, expúlselo antes del próximo inicio.", + "Hide details": "Ocultar detalles", + "See more details": "Ver más detalles", + "The final step to configure the Trusted Platform Module (TPM) to automatically open encrypted devices will take place during the first boot of the new system. For that to work, the machine needs to boot directly to the new boot loader.": "El último paso para configurar Trusted Platform Module (TPM) para abrir automáticamente dispositivos cifrados se llevará a cabo durante el primer inicio del nuevo sistema. Para que eso funcione, la máquina necesita iniciarse directamente con el nuevo cargador de arranque.", + "Congratulations!": "¡Enhorabuena!", + "The installation on your machine is complete.": "La instalación en el equipo ha finalizado.", + "At this point you can power off the machine.": "Ya puede apagar el equipo.", + "At this point you can reboot the machine to log in to the new system.": "Ya puede reiniciar el equipo para iniciar sesión en el nuevo sistema.", + "Finish": "Finalizar", + "Reboot": "Reiniciar", + "Installing the system, please wait...": "Instalando el sistema, por favor espere...", + "Language": "Idioma", + "Keyboard layout": "Distribución del teclado", + "Cannot be changed in remote installation": "No se puede cambiar en instalación remota", + "This will affect only the installer interface, not the product to be installed. You can adjust the product’s localization later in the Localization settings page.": "Esto sólo afectará a la interfaz del instalador, no el producto a instalar. Puede ajustar la ubicación del producto más adelante en la página de configuración de ubicación.", + "More language and keyboard layout options for the selected product may be available in [Localization] page.": "Es posible que haya más idiomas y opciones de distribución de teclado para el producto seleccionado en la página de [Ubicación].", + "Language and keyboard": "Idioma y teclado", + "Use these same settings for the selected product": "Utilizar los mismos ajustes para el producto seleccionado", + "Accept": "Aceptar", + "More languages might be available for the selected product at [Localization] page": "Es posible que haya más idiomas disponibles para el producto seleccionado en la página de [Ubicación]", + "Change Language": "Cambiar idioma", + "Use for the selected product too": "Utilizar también para el producto seleccionado", + "Change keyboard": "Cambiar teclado", + "More keymap layout might be available for the selected product at [Localization] page": "Puede haber más distribuciones de teclado disponibles para el producto seleccionado en la página de [Ubicación]", + "Change display language": "Cambiar el idioma de visualización", + "Change keyboard layout": "Cambiar distribución del teclado", + "Change display language and keyboard layout": "Cambiar idioma de visualización y distribución del teclado", + "Before starting the installation, you need to address the following problems:": "Antes de comenzar la instalación, debe solucionar los siguientes problemas:", + "Review and fix": "Revisar y corregir", + "Authentication": "Autenticación", + "Storage": "Almacenamiento", + "Software": "Software", + "Registration": "Registro", + "Pre-installation checks": "Comprobaciones previas a la instalación", + "Before installing, you have to make some decisions. Click on each section to review the settings.": "Antes de instalar, tiene que tomar algunas decisiones. Haga clic en cada sección para revisar la configuración.", + "Search": "Buscar", + "Could not log in. Please, make sure that the password is correct.": "No se ha podido iniciar sesión. Por favor, asegúrese de que la contraseña es correcta.", + "Could not authenticate against the server, please check it.": "No se pudo autenticar en el servidor, por favor verifíquelo.", + "Log in as %s": "Iniciar sesión como %s", + "The installer requires [root] user privileges.": "El instalador requiere privilegios de usuario [root].", + "Login form": "Formulario de inicio de sesión", + "Password": "Contraseña", + "Password input": "Entrada de contraseña", + "Please, provide its password to log in to the system.": "Por favor, proporcione su contraseña para iniciar sesión en el sistema.", + "Log in": "Iniciar sesión", + "Back": "Retroceder", + "Passwords do not match": "Las contraseñas no coinciden", + "Password confirmation": "Confirmación de contraseña", + "Using [%s] keyboard": "Se está utilizando el teclado [%s]", + "[CAPS LOCK] is on": "[BLOQ MAYÚS] está activado", + "Password visibility button": "Botón de visibilidad de contraseña", + "Confirm": "Confirmar", + "Loading data...": "Cargando los datos...", + "Pending": "Pendiente", + "In progress": "En curso", + "Finished": "Finalizado", + "Resource not found or lost": "Recurso no encontrado o perdido", + "It doesn't exist or can't be reached.": "No existe o no se puede alcanzar.", + "Actions": "Acciones", + "Add": "Añadir", + "Row expansion": "Expansión de fila", + "Row selection": "Selección de fila", + "Row actions": "Acciones de la fila", + "Cannot connect to Agama server": "No se pud conectar al servidor de Agama", + "Please, check whether it is running.": "Por favor, compruebe si está funcionando.", + "Reload": "Recargar", + "Skip to content": "Saltar a contenido", + "More actions": "Más acciones", + "Filter by description or keymap code": "Filtrar por descripción o código de mapa de teclas", + "None of the keymaps match the filter.": "Ninguno de los mapas de teclas coincide con el filtro.", + "Keyboard selection": "Selección de teclado", + "Select": "Seleccionar", + "These are the settings for the product to install. The installer language and keyboard layout can be adjusted via the [settings panel] accessible from the top bar.": "Parámetros de instalación del producto. Tanto el idioma del instalador como la distribución del teclado pueden configurarse mediante el [panel de ajustes], accesible en la barra superior.", + "These are the settings for the product to install. The installer language can be adjusted via the [settings panel] accessible from the top bar.": "Parámetros de instalación del producto. El idioma del instalador puede ser ajustado mediante el [panel de configuraciones], accesible en la barra superior.", + "Localization": "Ubicación", + "Change": "Cambiar", + "Not selected yet": "Aún no seleccionado", + "Keyboard": "Teclado", + "Time zone": "Zona horaria", + "Filter by language, territory or locale code": "Filtrar por idioma, territorio o código de configuración regional", + "None of the locales match the filter.": "Ninguna de las configuraciones regionales coincide con el filtro.", + "Locale selection": "Selección de configuración regional", + "Filter by territory, time zone code or UTC offset": "Filtrar por territorio, código de zona horaria o compensación UTC", + "None of the time zones match the filter.": "Ninguna de las zonas horarias coincide con el filtro.", + " Timezone selection": " · Selección de zona horaria", + "Options toggle": "Conmutador de opciones", + "Download logs": "Descargar registros", + "Main navigation": "Navegación principal", + "Loading": "Cargando", + "Remove": "Retirar", + "IP Address": "Dirección IP", + "Prefix length or netmask": "Longitud del prefijo o máscara de red", + "Add an address": "Añadir una dirección", + "Add another address": "Añadir otras direcciones", + "Addresses": "Direcciones", + "Addresses data list": "Lista de datos de direcciones", + "%s - %s": "%s - %s", + "Binding settings for '%s'": "Configuración de conexión para '%s'", + "Choose how the connection should be associated with a network device. This helps control which device the connection uses.": "Elija cómo se debe asociar la conexión con un dispositivo de red. Esto ayuda a controlar qué dispositivo usa la conexión.", + "Unbound": "No asociado", + "The connection can be used by any available device.": "La conexión puede ser utilizada por cualquier dispositivo disponible.", + "Bind to device name": "Asociar al nombre del dispositivo", + "Choose device to bind by name": "Escoger dispositivo para vincular por nombre", + "Bind to MAC address": "Vincular a dirección MAC", + "Choose device to bind by MAC": "Escoger dispositivo para vincular por dirección MAC", + "Server IP": "Servidor IP", + "Add DNS": "Añadir DNS", + "Add another DNS": "Añadir otro DNS", + "DNS": "DNS", + "Use for installation only": "Utilizar únicamente para la instalación", + "The connection will be used only during installation and not available in the installed system.": "La conexión sólo se usará durante la instalación y no estará disponible en el sistema instalado.", + "Ip prefix or netmask": "Prefijo IP o máscara de red", + "At least one address must be provided for selected mode": "Se debe proporcionar al menos una dirección para el modo seleccionado", + "Edit connection %s": "Editar conexión %s", + "Something went wrong": "Algo ha fallado", + "Mode": "Modo", + "Automatic (DHCP)": "Automático (DHCP)", + "Manual": "Manual", + "Gateway": "Puerta de enlace", + "Gateway can be defined only in 'Manual' mode": "La puerta de enlace sólo se puede definir en modo 'Manual'", + "Wi-Fi not supported": "Wi-Fi no compatible", + "The system does not support Wi-Fi connections, probably because of missing or disabled hardware.": "El sistema no admite conexiones Wi-Fi, probablemente porque falta hardware o está deshabilitado.", + "Network": "Red", + "Wired connections": "Redes cableadas", + "Wi-Fi networks": "Redes Wi-Fi", + "Installed system may not have network connections": "Puede que el sistema instalado no tenga conexiones de red", + "All network connections managed through this interface are currently set to be used only during installation and will not be copied to the installed system": "Todas las conexiones de red gestionadas mediante esta interfaz están configuradas para usarse solo durante la instalación y no se copiarán al sistema instalado", + "Network details": "Detalles de la red", + "SSID": "SSID", + "Signal strength": "Intensidad de la señal", + "Status": "Estado", + "Security": "Seguridad", + "Device": "Dispositivo", + "Connection details": "Detalles de la conexión", + "Interface": "Interfaz", + "MAC": "MAC", + "IP settings": "Ajustes de IP", + "Edit": "Editar", + "IPv4": "IPv4", + "IPv6": "IPv6", + "Routes": "Rutas", + "None": "Ninguno", + "WPA & WPA2 Personal": "WPA y WPA2 personales", + "Not protected network": "Red no protegida", + "You will connect to a public network without encryption. Your data may not be secure.": "Se conectará a una red pública sin cifrado. Es posible que sus datos no estén seguros.", + "Setting up connection": "Configurando conexión", + "It may take some time.": "Puede tardar un poco.", + "Details will appear after the connection is successfully established.": "Los detalles aparecerán después de que la conexión se haya establecido con éxito.", + "Could not connect to %s": "No se ha podido conectar a %s", + "Check the authentication parameters.": "Verifique los parámetros de autenticación.", + "Wi-Fi connection form": "Formulario de conexión Wi-Fi", + "WPA Password": "Contraseña WPA", + "Connect": "Conectarse", + "Network not found or lost": "Red no encontrada o perdida", + "Go to network page": "Ir a la página de red", + "Connect to %s": "Conectarse a %s", + "Excellent signal": "Señal excelente", + "Good signal": "Señal buena", + "Weak signal": "Señal débil", + "Secured network": "Red segura", + "Public network": "Red pública", + "Connecting to %s": "Conectándose a %s", + "Connected": "Conectado", + "IP addresses": "Direcciones IP", + "Configured for installation only": "Configurado solo para la instalación", + "No Wi-Fi networks were found": "No se han encontrado redes Wi-Fi", + "Connection is available to all devices.": "La conexión está disponible para todos los dispositivos.", + "Connection is bound to device %s.": "La conexión está asociada al dispositivo %s.", + "Connection is bound to MAC address %s.": "La conexión está asociada a la dirección MAC %s.", + "Binding": "Asociar", + "Edit binding settings": "Editar configuración de vinculación", + "Device details": "Detalles del dispositivo", + "IP Addresses": "Direcciones IP", + "Connected device": "Dispositivo conectado", + "Connected devices": "Dispositivos conectados", + "No device is currently using this connection.": "Ningún dispositivo está utilizando actualmente esta conexión.", + "Connected devices tabs": "Pestañas de dispositivos conectados", + "Settings": "Ajustes", + "Edit connection settings": "Editar la configuración de conexión", + "None set": "Ninguna establecida", + "Connection not found or lost": "Conexión no encontrada o perdida", + "No wired connections were found": "No se han encontrado conexiones por cable", + "The system will use %s as its default language.": "El sistema utilizará %s como idioma predeterminado.", + "Overview": "Vista general", + "These are the most relevant installation settings. Feel free to browse the sections in the menu for further details.": "Ajustes de instalación más relevantes. Consulte las diferentes secciones del menú para información adicional.", + "The installation will take": "La instalación utilizará", + "The installation will take %s including:": "La instalación utilizará %s, la cual incluye:", + "No device selected yet": "Aún no se ha seleccionado ningún dispositivo", + "Install using device %s shrinking existing partitions as needed.": "Instalar utilizando el dispositivo %s reduciendo las particiones existentes según sea necesario.", + "Install using device %s without modifying existing partitions.": "Instalar utilizando el dispositivo %s sin modificar las particiones existentes.", + "Install using device %s and deleting all its content.": "Instalar utilizando el dispositivo %s y eliminando todo su contenido.", + "Install using device %s with a custom strategy to find the needed space.": "Instalar utilizando el dispositivo %s con una estrategia personalizada para localizar el espacio necesario.", + "Install using several devices shrinking existing partitions as needed.": "Instalar utilizando varios dispositivos reduciendo las particiones existentes según sea necesario.", + "Install using several devices without modifying existing partitions.": "Instalar utilizando varios dispositivos sin modificar las particiones existentes.", + "Install using several devices and deleting all its content.": "Instalar utilizando varios dispositivos y eliminando todo su contenido.", + "Install using several devices with a custom strategy to find the needed space.": "Instalar utilizando varios dispositivos con una estrategia personalizada para localizar el espacio necesario.", + "There are no disks available for the installation.": "No hay discos disponibles para la instalación.", + "Install using an advanced configuration.": "Instalar utilizando una configuración avanzada.", + "This license is not available in %s.": "Esta licencia no está disponible en %s.", + "Close": "Cerrar", + "%s [must be registered].": "%s [debe estar registrado].", + "Registration server": "Servidor de registro", + "Email": "Correo electrónico", + "SUSE Customer Center (SCC)": "Centro de servicios al cliente de SUSE (SCC)", + "Custom": "Personalizado", + "%s has been registered with below information.": "%s se ha registrado con la siguiente información.", + "Registration code": "Código de registro", + "Hide": "Ocultar", + "Show": "Mostrar", + "Server options": "Opciones del servidor", + "Register using SUSE server": "Registrarse usando el servidor SUSE", + "Register using a custom registration server": "Registrarse usando un servidor de registro personalizado", + "Server URL": "URL del servidor", + "Example: https://myserver.com": "Ejemplo: https://miservidor.com", + "Provide registration code": "Proporcione el código de registro", + "Provide email address": "Proporcione una dirección de correo electrónico", + "Check the following before continuing": "Compruebe lo siguiente antes de continuar", + "Register": "Registrar", + "You cannot change it later. Go to the %s section if you want to modify it before proceeding with registration.": "No puede cambiarlo más tarde. Vaya a la sección %s si desea modificarlo antes de proceder con el registro.", + "hostname": "nombre del equipo", + "Extensions": "Extensiones", + "%s logo": "logo de %s", + "I have read and accept the [license] for %s": "He leído y acepto la [licencia] para %s", + "Select a product": "Seleccione un producto", + "Available products": "Productos disponibles", + "Configuring the product, please wait ...": "Configurando el producto, por favor espere...", + "The extension has been registered with key %s.": "La extensión se ha registrado con la clave %s.", + "The extension was registered without any registration code.": "La extensión se ha registrado sin ningún código de registro.", + "Beta": "Beta", + "Recommended": "Recomendado", + "Not available": "No disponible", + "This extension is not available on the server. Ask the server administrator to mirror the extension.": "Esta extensión no está disponible en el servidor. Pida al administrador del servidor que replique la extensión.", + "Question": "Pregunta", + "Configuration unreachable or invalid": "No se puede acceder a la configuración o la configuración no es válida", + "The encryption password did not work": "La contraseña de cifrado no ha funcionado", + "Encrypted Device": "Dispositivo cifrado", + "Encryption Password": "Contraseña de cifrado", + "Installing a broken package affects system stability and is a big security risk!": "¡Instalar un paquete roto afecta a la estabilidad del sistema y supone un gran riesgo para la seguridad!", + "Continuing without installing the package can result in a broken system. In some cases the system might not even boot.": "Continuar sin instalar el paquete puede resultar en un sistema inestable. En algunos casos, es posible que el sistema ni siquiera arranque.", + "Package installation failed": "Error en la instalación del paquete", + "Password Required": "Se requiere contraseña", + "Registration certificate": "Certificado de registro", + "URL": "URL", + "Issuer": "Emisor", + "Issue date": "Fecha de emisión", + "Expiration date": "Fecha de vencimiento", + "SHA1 fingerprint": "Huella digital SHA1", + "SHA256 fingerprint": "Huella digital SHA256", + "Unsupported AutoYaST elements": "Elementos de AutoYaST no compatibles", + "Some of the elements in your AutoYaST profile are not supported.": "Algunos de los elementos de su perfil de AutoYaST no son compatibles.", + "Not implemented yet (%s)": "Aún no implementado (%s)", + "Will be supported in a future version.": "Será compatible en una versión futura.", + "Not supported (%s)": "No compatible (%s)", + "No support is planned.": "No se prevé ninguna planificación.", + "Show less actions": "Mostrar menos acciones", + "Show more actions": "Mostrar más acciones", + "Select a solution to continue": "Seleccione una solución para continuar", + "Apply selected solution": "Aplicar solución seleccionada", + "Multiple conflicts found. You can address them in any order, and resolving one may resolve others.": "Se han detectado varios conflictos. Puede abordarlos en cualquier orden y resolver uno podría resolver el resto.", + "Skip to previous": "Saltar al anterior", + "%d of %d": "%d de %d", + "Skip to next": "Saltar al siguiente", + "No conflicts to address": "No hay ningún conflicto que abordar", + "All conflicts have been resolved, or none were detected. You can safely continue with your setup.": "Todos los conflictos se han resuelto o no se ha detectado ninguno. Puede continuar con la instalación sin problemas.", + "Software conflicts resolution": "Resolución de conflictos de software", + "No additional software was selected.": "No se ha seleccionado software adicional.", + "The following software patterns are selected for installation:": "Los siguientes patrones de software están seleccionados para su instalación:", + "Selected patterns": "Seleccione los patrones", + "Change selection": "Cambiar selección", + "This product does not allow to select software patterns during installation. However, you can add additional software once the installation is finished.": "Este producto no permite seleccionar patrones de software durante la instalación. Sin embargo, puede añadir software adicional una vez finalizada la instalación.", + "Some installation repositories could not be loaded. The system cannot be installed without them.": "No se han podido cargar algunos repositorios de instalación. El sistema no se puede instalar sin ellos.", + "Repository load failed": "Ha fallado la carga del repositorio", + "Loading the installation repositories...": "Cargando los repositorios de instalación...", + "Try again": "Volver a intentar", + "Used space": "Espacio utilizado", + "None of the patterns match the filter.": "Ninguno de los patrones coincide con el filtro.", + "auto selected": "seleccionado automáticamente", + "Unselect": "Deseleccionar", + "Software selection": "Selección de software", + "Filter by pattern title or description": "Filtrar por título o descripción del patrón", + "Installation will take %s.": "La instalación utilizará %s.", + "This space includes the base system and the selected software patterns, if any.": "Este espacio incluye el sistema base y los patrones de software seleccionados, si los hubiera.", + "logical volume": "volúmen lógico", + "partition": "partición", + "A generic size of %1$s will be used for the new %2$s": "Se utilizará un tamaño genérico de %1$s para el(la) nuevo(a) %2$s", + "A generic size range between %1$s and %2$s will be used for the new %3$s": "Un intervalo de tamaño genérico entre %1$s y %2$s se utilizará para el nuevo %3$s", + "A generic minimum size of %1$s will be used for the new %2$s": "Un tamaño mínimo genérico de %1$s se utilizará para el %2$s nuevo", + "A %1$s of %2$s will be created for %3$s if possible": "Si fuera posible, se creará una %1$s de %2$s para %3$s", + "A %1$s with a size between %2$s and %3$s will be created for %4$s if possible": "Si fuera posible, se creará una %1$s de entre %2$s y %3$s para %4$s", + "A %1$s of at least %2$s will be created for %3$s if possible": "Si fuera posible, se creará una %1$s de al menos %2$s para %3$s", + "Based on the amount of RAM in the system, a %1$s of %2$s will be planned for %3$s": "Según la cantidad de RAM en el sistema, se planificará un %1$s de %2$s para %3$s", + "Based on the amount of RAM in the system, a %1$s with a size between %2$s and %3$s will be planned for %4$s": "En función de la cantidad de RAM en el sistema, se planificará un %1$s con un tamaño entre %2$s y %3$s para %4$s", + "Based on the amount of RAM in the system, a %1$s of at least %2$s will be planned for %3$s": "En función de la cantidad de RAM en el sistema, se planificará un %1$s de al menos %2$s para %3$s", + "The size for %1$s will be dynamically adjusted based on the amount of RAM in the system, the usage of Btrfs snapshots and the presence of a separate file system for %2$s.": "El tamaño para %1$s se ajustará dinámicamente según el total de RAM del sistema, el uso de instantáneas Btrfs y la presencia de un sistema de archivos independiente para %2$s.", + "The size for %1$s will be dynamically adjusted based on the amount of RAM in the system, the usage of Btrfs snapshots and the presence of separate file systems for %2$s.": "El tamaño para %1$s se ajustará dinámicamente según el total de RAM del sistema, el uso de instantáneas Btrfs y la presencia de sistemas de archivos independientes para %2$s.", + "The size for %s will be dynamically adjusted based on the amount of RAM in the system and the usage of Btrfs snapshots.": "El tamaño para %s se ajustará dinámicamente según el total de RAM del sistema y el uso de instantáneas Btrfs.", + "The size for %1$s will be dynamically adjusted based on the amount of RAM in the system and the presence of a separate file system for %2$s.": "El tamaño para %1$s se ajustará dinámicamente según el total de RAM del sistema y la presencia de un sistema de archivos independiente para %2$s.", + "The size for %1$s will be dynamically adjusted based on the amount of RAM in the system and the presence of separate file systems for %2$s.": "El tamaño para %1$s se ajustará dinámicamente según el total de RAM del sistema y la presencia de sistemas de archivos independientes para %2$s.", + "The size for %1$s will be dynamically adjusted based on the usage of Btrfs snapshots and the presence of a separate file system for %2$s.": "El tamaño para %1$s se ajustará dinámicamente según el uso de instantáneas Btrfs y la presencia de un sistema de archivos independiente para %2$s.", + "The size for %1$s will be dynamically adjusted based on the usage of Btrfs snapshots and the presence of separate file systems for %2$s.": "El tamaño para %1$s se ajustará dinámicamente según el uso de instantáneas Btrfs y la presencia de sistemas de archivos independientes para %2$s.", + "The size for %s will be dynamically adjusted based on the usage of Btrfs snapshots.": "El tamaño de %s se ajustará dinámicamente según el uso de instantáneas Btrfs.", + "The size for %1$s will be dynamically adjusted based on the presence of a separate file system for %2$s.": "El tamaño de %1$s se ajustará dinámicamente según la presencia de un sistema de archivos independiente para %2$s.", + "The size for %1$s will be dynamically adjusted based on the presence of separate file systems for %2$s.": "El tamaño de %1$s se ajustará dinámicamente según la presencia de sistemas de archivos independientes para %2$s.", + "The current configuration will result in an attempt to create a %1$s of %2$s.": "La configuración actual dará lugar a un intento de crear una %1$s de %2$s.", + "The current configuration will result in an attempt to create a %1$s with a size between %2$s and %3$s.": "La configuración actual dará lugar a un intento de crear una %1$s de entre %2$s y %3$s.", + "The current configuration will result in an attempt to create a %1$s of at least %2$s.": "La configuración actual dará lugar a un intento de crear una %1$s de al menos %2$s.", + "To ensure the new system is able to boot, the installer may need to create or configure some partitions in the appropriate disk.": "Para garantizar que el nuevo sistema pueda arrancar, es posible que el instalador tenga que crear o configurar algunas particiones en el disco correspondiente.", + "Partitions to boot will be allocated at the installation disk.": "Las particiones para arrancar se asignarán en el disco de instalación.", + "Partitions to boot will be allocated at the installation disk %s.": "Las particiones de arranque se asignarán en el disco de instalación %s.", + "Boot options": "Opciones de arranque", + "Automatic": "Automático", + "Select a disk": "Seleccione un disco", + "Partitions to boot will be allocated at the following device.": "Las particiones de arranque se asignarán en el siguiente dispositivo.", + "Choose a disk for placing the boot loader": "Escoge un disco para poner el cargador de arranque", + "Do not configure": "No configurar", + "No partitions will be automatically configured for booting. Use with caution.": "No se configurarán automáticamente particiones para el arranque. Úselo con precaución.", + "No devices configured yet": "Aún no hay dispositivos configurados", + "Use actions below to set up your devices or click %s to start from scratch with the default configuration.": "Utilice las operaciones siguientes para configurar sus dispositivos o haga clic en %s para empezar desde cero con la configuración predeterminada.", + "reset to defaults": "restablecer valores predeterminados", + "[FIXME]": "[FIXME]", + "Other options toggle": "Mostrar/ocultar otras opciones", + "Other options": "Otras opciones", + "Select the disk to configure partitions for booting": "Seleccione el disco para configurar particiones de arranque", + "Change boot options": "Cambiar opciones de arranque", + "Start from scratch with the default configuration": "Empiece desde cero con la configuración predeterminada", + "Reset to defaults": "Restablecer los valores predeterminados", + "Discover and connect to iSCSI targets": "Descubra y conéctese a destinos iSCSI", + "Configure iSCSI": "Configurar iSCSI", + "Activate zFCP disks": "Activar discos zFCP", + "Configure zFCP": "Configurar zFCP", + "Activate and format DASD devices": "Active y dé formato a dispositivos DASD", + "Configure DASD": "Configurar DASD", + "Update available disks and activate crypt devices": "Actualizar los discos disponibles y activar los dispositivos de cifrado", + "Rescan devices": "Volver a buscar dispositivos", + "Select a device to define partitions or to mount": "Seleccione un dispositivo para definir particiones o para montarlo", + "Select another device to define partitions or to mount": "Seleccione otro dispositivo para definir particiones o para montarlo", + "Select a disk to define partitions or to mount": "Seleccione un disco para definir particiones o para montarlo", + "Select another disk to define partitions or to mount": "Seleccione otro disco para definir particiones o para montarlo", + "Already using all available devices": "Ya se están utilizando todos los dispositivos disponibles", + "Already using all available disks": "Ya se están utilizando todos los discos disponibles", + "Extend the installation beyond the currently selected device": "Ampliar la instalación más allá del dispositivo seleccionado actualmente", + "Extend the installation beyond the currently selected device_plural": "Ampliar la instalación más allá de los %d dispositivos actuales", + "Extend the installation beyond the currently selected disk": "Ampliar la instalación más allá del disco seleccionado actualmente", + "Extend the installation beyond the currently selected disk_plural": "Ampliar la instalación más allá de los %d discos actuales", + "Start configuring a basic installation": "Comience configurando una instalación básica", + "Add device menu": "Añadir menú de dispositivo", + "Define a new LVM on top of one or several disks": "Definir un nuevo LVM sobre uno o varios discos", + "Define a new LVM on the disk": "Defina un LVM nuevo en el disco", + "Configure device menu": "Configurar menú de dispositivo", + "Add LVM volume group": "Añadir grupo de volúmenes LVM", + "More devices": "Más dispositivos", + "Size": "Tamaño", + "Description": "Descripción", + "Current content": "Contenido actual", + "Type": "Tipo", + "Name": "Nombre", + "Content": "Contenido", + "Filesystems": "Sistemas de archivos", + "Device Selection": "Selección de dispositivo", + "The modal selector offers a simplified interface designed for quick and straightforward use, without overwhelming the user.": "El selector modal ofrece una interfaz simplificada y diseñada para un uso rápido y directo, sin agobiar al usuario.", + "For more advanced needs, users can switch to this full-page, dedicated path version that provides more space for detailed views, additional columns, filters, and extended functionality.": "Para necesidades más avanzadas, los usuarios pueden cambiar a esta versión de página completa y ruta dedicada que proporciona más espacio para vistas detalladas, columnas adicionales y funcionalidad ampliada.", + "This pattern strikes a balance between clarity and efficiency: the modal keeps things lightweight for simple selections, while the full view supports deeper exploration and more complex actions, specially for users with tons of devices.": "Este patrón consigue un equilibrio entre claridad y eficiencia: el modal es ligero y sirve para selecciones sencillas, mientras que la vista completa admite una exploración más profunda y acciones más complejas, especialmente para usuarios con muchos dispositivos.", + "Mount disk %s": "Montar disco %s", + "Format disk %s": "Formatear el disco %s", + "Use disk %s to install, host LVM and boot": "Utilizar %s para la instalación, alojar LVM y arrancar", + "Use disk %s to install and host LVM": "Utilizar %s para la instalación y alojar LVM", + "Use disk %s to install and boot": "Utilizar %s para la instalación y arrancar", + "Use disk %s to install": "Utilizar el disco %s para la instalación", + "Use disk %s for LVM, additional partitions and booting": "Utilizar el disco %s para LVM, particiones adicionales y arranque", + "Use disk %s for LVM and additional partitions": "Utilizar el disco %s para LVM y particiones adicionales", + "Use disk %s for additional partitions and booting": "Utilizar el disco %s para particiones adicionales y arranque", + "Use disk %s for additional partitions": "Utilizar el disco %s para particiones adicionales", + "Use disk %s to host LVM and boot": "Utilizar disco %s para hospedar LVM y arrancar", + "Use disk %s to host LVM": "Utilizar el disco %s para hospedar LVM", + "Use disk %s to configure boot partitions": "Utilizar el disco %s para configurar particiones de arranque", + "Use disk %s": "Utilizar el disco %s", + "Encryption is disabled": "El cifrado está deshabilitado", + "Encryption is enabled using TPM unlocking": "El cifrado se activa utilizando el desbloqueo TPM", + "Encryption is enabled": "El cifrado está habilitado", + "Encryption": "Cifrado", + "Protection for the information stored at the new file systems, including data, programs, and system files.": "Protección de la información almacenada en los nuevos sistemas de archivo, incluyendo datos, programas y archivos del sistema.", + "Password is empty.": "La contraseña está vacía.", + "Use the Trusted Platform Module (TPM) to decrypt automatically on each boot": "Utilizar Trusted Platform Module (TPM) para descifrar automáticamente en cada arranque", + "The password will not be needed to boot and access the data if the TPM can verify the integrity of the system. TPM sealing requires the new system to be booted directly on its first run.": "La contraseña no será necesaria para arrancar y acceder a los datos si TPM puede verificar la integridad del sistema. El sellado TPM requiere que el nuevo sistema se arranque directamente en su primera ejecución.", + "Encryption settings": "Ajustes de cifrado", + "Encrypt the system": "Cifrar el sistema", + "Full Disk Encryption (FDE) allows to protect the information stored at the new file systems, including data, programs, and system files.": "El cifrado completo del disco (FDE) permite proteger la información almacenada en los nuevos sistemas de archivos, incluidos datos, programas y archivos del sistema.", + "The device will be mounted": "El dispositivo se montará", + "The device will be formatted": "El dispositivo se formateará", + "The current file system will be mounted at %s": "El sistema de archivos actual se montará en %s", + "The device will be formatted as %1$s and mounted at %2$s": "El dispositivo se formateará como %1$s y se montará en %2$s", + "Details for %s": "Detalles de %s", + "Details": "Detalles", + "Change the file system or mount point": "Cambiar el sistema de archivos o el punto de montaje", + "The configuration must be adapted to address the following issue:": "La configuración debe adaptarse para solucionar el problema siguiente:", + "The configuration must be adapted to address the following issue:_plural": "La configuración debe adaptarse para solucionar los problemas siguientes:", + "Select or enter a valid mount point": "Seleccione o introduzca un punto de montaje válido", + "Select or enter a mount point that is not already assigned to another device": "Seleccione o introduzca un punto de montaje que no esté ya asignado a otro dispositivo", + "Waiting for a mount point": "Esperando un punto de montaje", + "Current %s": "Actual %s", + "Btrfs with snapshots": "Brtfs con instantáneas", + "Default file system for %s": "Sistema de archivos por defecto para %s", + "Default file system for generic mount paths": "Sistema de archivos predeterminado para rutas de montaje genéricas", + "Destroy current data and format device as": "Destruir datos actuales y formatear el dispositivo como", + "Format device as": "Formatear dispositivo como", + "Do not format %s and keep the data": "No formatear %s y conservar los datos", + "File system label": "Etiqueta del sistema de archivos", + "Configure device %s": "Configurar dispositivo %s", + "Mount point": "Punto de montaje", + "Mount point toggle": "Activar/desactivar punto de montaje", + "Suggested mount points": "Puntos de montaje sugeridos", + "Clear selected mount point": "Quitar punto de montaje seleccionado", + "Use": "Emplear", + "Select or enter a mount point": "Seleccionar o introducir un punto de montaje", + "File system": "Sistema de archivos", + "Label": "Etiqueta", + "iSCSI": "iSCSI", + "Enter a name": "Introduzca un nombre", + "The minimum cannot be greater than the maximum": "El mínimo no puede ser mayor que el máximo", + "The maximum must be a number optionally followed by a unit like GiB or GB": "El máximo debe ser un número, opcionalmente seguido de una unidad como GiB o GB", + "The minimum must be a number optionally followed by a unit like GiB or GB": "El mínimo debe ser un número, opcionalmente seguido de una unidad como GiB o GB", + "Size limits must be numbers optionally followed by a unit like GiB or GB": "Los límites de tamaño deben ser números, opcionalmente seguidos de una unidad como Gib o GB", + "Logical volume name": "Nombre de volumen lógico", + "Default file system for generic logical volumes": "Sistema de archivos por defecto para volúmenes lógicos genéricos", + "Format logical volume as": "Dar formato lógico al volumen como", + "Configure LVM logical volume at %s volume group": "Configurar volumen lógico LVM en grupo de volúmenes %s", + "Size mode": "Modo de tamaño", + "Enter a name for the volume group.": "Introduzca un nombre para el grupo del volúmenes.", + "Volume group '%s' already exists. Enter a different name.": "Ya existe el grupo '%s' de volúmenes. Introduzca un nombre diferente.", + "Select at least one disk.": "Seleccione al menos un disco.", + "Configure LVM Volume Group": "Configure un grupo de volúmenes LVM", + "Disks": "Discos", + "The needed LVM physical volumes will be added as partitions on the chosen disks, based on the sizes of the logical volumes. If you select more than one disk, the physical volumes may be distributed along several disks.": "Los volúmenes físicos de LVM necesarios se añadirán como particiones en los discos elegidos, basándose en los tamaños de los volúmenes lógicos. Si selecciona más de un disco, los volúmenes físicos pueden distribuirse a lo largo de varios discos.", + "Move mount points": "Mover puntos de montaje", + "Move the mount points currently configured at the selected disks to logical volumes of this volume group.": "Mueve los puntos de montaje actualmente configurados en los discos seleccionados a volúmenes lógicos de este grupo de volúmenes.", + "Mount RAID %s": "Montar RAID %s", + "Format RAID %s": "Formatear RAID %s", + "Use RAID %s to install, host LVM and boot": "Usar RAID %s para instalar, alojar la LVM y arrancar", + "Use RAID %s to install and host LVM": "Usar RAID %s para instalar y alojar la LVM", + "Use RAID %s to install and boot": "Usar RAID %s para instalar y arrancar", + "Use RAID %s to install": "Usar RAID %s para instalar", + "Use RAID %s for LVM, additional partitions and booting": "Usar RAID %s para la LVM, particiones adicionales y arrancar", + "Use RAID %s for LVM and additional partitions": "Usar RAID %s para la LVM y particiones adicionales", + "Use RAID %s for additional partitions and booting": "Usar RAID %s para particiones adicionales y arrancar", + "Use RAID %s for additional partitions": "Usar RAID %s para particiones adicionales", + "Use RAID %s to host LVM and boot": "Usar RAID %s para alojar la LVM y arrancar", + "Use RAID %s to host LVM": "Usar RAID %s para alojar la LVM", + "Use RAID %s to configure boot partitions": "Usar RAID %s para configurar particiones de arranque", + "Use RAID %s": "Usar RAID %s", + "Create another LVM volume group on %s": "Crear otro grupo de volúmenes LVM en %s", + "Create LVM volume group on %s": "Crear grupo de volúmenes LVM en %s", + "%s will be created as a logical volume": "%s se creará como volumen lógico", + "%s will be created as a logical volume_plural": "%s se crearán como volúmenes lógicos", + "The maximum must be a number followed by a unit like GiB or GB": "El máximo debe ser un número seguido por una unidad como GiB o GB", + "The minimum must be a number followed by a unit like GiB or GB": "El mínimo debe ser un número seguido por una unidad como GiB o GB", + "Size limits must be numbers followed by a unit like GiB or GB": "Los límites de tamaño deben ser números seguidos de una unidad como GiB o GB", + "As a new partition on %s": "Como una partición nueva en %s", + "Using partition %s": "Utilizando partición %s", + "Mount point options": "Opciones de punto de montaje", + "Using an existing partition": "Utilizando una partición existente", + "There are not usable partitions": "No hay particiones utilizables", + "Default file system for generic partitions": "Sistemas de archivos predeterminado para particiones genéricas", + "Destroy current data and format partition as": "Destruye datos actuales y da formato de partición como", + "Format partition as": "Formatear partición como", + "Configure partition at %s": "Configurar partición en %s", + "Mount point mode": "Modo de punto de montaje", + "Go to storage page": "Ir a la página de almacenamiento", + "Moreover, the following partitions will be created or mounted": "Además, se crearán o se montarán las siguientes particiones", + "Moreover, the following partition will be created.": "Además, se creará la partición siguiente.", + "Moreover, the following partition will be created._plural": "Además, se crearán las particiones siguientes.", + "Moreover, the following partition will be mounted.": "Además, se montará la partición siguiente.", + "Moreover, the following partition will be mounted._plural": "Además, se montarán las particiones siguientes.", + "The following partitions will be created or mounted": "Las siguientes particiones se crearán o se montarán", + "The following partition will be created.": "Se creará la partición siguiente.", + "The following partition will be created._plural": "Se crearán las particiones siguientes.", + "The following partition will be mounted.": "Se montará la partición siguiente.", + "The following partition will be mounted._plural": "Se montarán las particiones siguientes.", + "Any partition needed to boot will be configured.": "Se configurará cualquier partición necesaria para el arranque.", + "Add another partition or mount an existing one": "Añada otra partición o monte una existente", + "Add or use partition": "Añadir o utilizar partición", + "Loading storage": "Cargando almacenamiento", + "Hide %d subvolume action": "Ocultar %d acción de subvolumen", + "Hide %d subvolume action_plural": "Ocultar %d acciones de subvolumen", + "Show %d subvolume action": "Mostrar %d acción de subvolumen", + "Show %d subvolume action_plural": "Mostrar %d acciones de subvolumen", + "It is not possible to install the system with the current configuration. Adjust the settings below.": "No es posible instalar el sistema con la configuración actual. Ajuste las opciones a continuación.", + "It is not possible to allocate space for the boot partition and for %s.": "No es posible asignar espacio para la partición de arranque y para %s.", + "It is not possible to allocate space for %s.": "No es posible asignar espacio para %s.", + "Adjust the settings below to make the new system fit into the available space.": "Ajuste la configuración a continuación para hacer que el nuevo sistema quepa dentro del espacio disponible.", + "Failed to calculate a storage layout": "Error al calcular una disposición de almacenamiento", + "Invalid storage settings": "Configuración de almacenamiento no válida", + "The current storage configuration has the following issue:": "La configuración de almacenamiento actual tiene el problema siguiente:", + "The current storage configuration has the following issue:_plural": "La configuración de almacenamiento actual tiene los problemas siguientes:", + "You may want to discard those settings and start from scratch with a simple configuration.": "Quizá quiera descartar tales ajustes y empezar desde cero con una configuración sencilla.", + "Reset to the default configuration": "Restablecer la configuración predeterminada", + "Unable to modify the settings": "No es posible modificar los ajustes", + "The storage configuration uses elements not supported by this interface.": "La configuración de almacenamiento utiliza elementos que no son compatibles con esta interfaz.", + "You may want to discard the current settings and start from scratch with a simple configuration.": "Quizá quiera descartar los ajustes actuales y empezar desde cero con una configuración sencilla.", + "There are not disks available for the installation. You may need to configure some device.": "No hay discos disponibles para la instalación. Es posible que necesite configurar algún dispositivo.", + "No devices found": "No se ha encontrado ningún dispositivo", + "Connect to iSCSI targets": "Conectarse a destinos iSCSI", + "Manage DASD devices": "Gestionar dispositivos DASD", + "Installation Devices": "Dispositivos de instalación", + "Structure of the new system, including disks to use and additional devices like LVM volume groups.": "Estructura del nuevo sistema, incluyendo discos a utilizar y dispositivos adicionales como los grupos de volúmenes LVM.", + "Reloading data, please wait...": "Cargando datos, por favor espere…", + "Waiting for information about storage configuration": "Esperando información sobre la configuración de almacenamiento", + "There is %d destructive action planned affecting %s": "Hay %d acción destructiva planeada que afecta a %s", + "There is %d destructive action planned affecting %s_plural": "Hay %d acciones destructivas planeadas que afectan a %s", + "There is %d destructive action planned": "Hay %d acción destructiva planeada", + "There is %d destructive action planned_plural": "Hay %d acciones destructivas planeadas", + "Collapse the list of planned actions": "Contraer la lista de acciones planificadas", + "Check the %d planned actions": "Revise las %d acciones planificadas", + "Result": "Resultado", + "During installation, several actions will be performed to setup the layout shown at the table below.": "Durante la instalación, se realizarán varias acciones para establecer la estructura de particiones que se muestra en la siguiente tabla.", + "New": "Nuevo", + "Before %s": "Antes de %s", + "Mount Point": "Punto de montaje", + "Transactional root file system": "Sistema transaccional de archivos de raíz", + "%s is an immutable system with atomic updates. It uses a read-only Btrfs file system updated via snapshots.": "%s es un sistema inmutable con actualizaciones atómicas. Utiliza un sistema de archivos Btrfs de solo lectura actualizado mediante instantáneas.", + "Selected disk cannot be changed": "El disco seleccionado no se puede cambiar", + "Select a disk to format as %s": "Seleccione un disco para formatear como %s", + "Select a disk to configure": "Seleccione un disco para configurarlo", + "Select a disk to install the system": "Seleccione un disco para instalar el sistema", + "Select a disk to create %s": "Seleccione un disco para crear %s", + "This uses the existing file system at the disk": "Esto usa el sistema de archivos existente en el disco", + "This uses existing partitions at the disk": "Esto utiliza particiones existentes en el disco", + "It is chosen for booting and for some LVM groups": "Se ha elegido para el arranque y para algunos grupos de LVM", + "It is chosen for some LVM groups": "Se ha elegido para algunos grupos de LVM", + "It is chosen for booting and for the LVM group '%s'": "Se ha elegido para arrancar y para algunos grupos de LVM %s", + "It is chosen for the LVM group '%s'": "Se ha elegido para el grupo de LVM %s", + "It is chosen for booting": "Se ha elegido para arrancar", + "%s will still contain the configured LVM groups and any partition needed to boot": "%s aún contendrá los grupos de LVM configurados y cualquier partición necesaria para arrancar", + "The configured LVM groups will remain at %s": "Los grupos de LVM configurados permanecerán en %s", + "%1$s will still contain the LVM group '%2$s' and any partition needed to boot": "%1$s aún contendrán el grupo de LVM '%2$s' y cualquier partición necesaria para arrancar", + "The LVM group '%1$s' will remain at %2$s": "El grupo de LVM '%1$s' permanecerá en %2$s", + "Partitions needed for booting will remain at %s": "Las particiones necesarias para el arranque permanecerán en %s", + "Partitions needed for booting will also be adapted": "Las particiones necesarias para el arranque también se adaptarán", + "Change device menu": "Cambiar el menú del dispositivos", + "The disk is used for LVM and boot": "El disco es utilizado para LVM y para arrancar", + "The disk is used for booting": "Este disco se utiliza para arrancar", + "The disk is used for LVM": "Este disco se utiliza para LVM", + "Remove the configuration for this disk": "Eliminar la configuración de este disco", + "Do not use": "No utilizar", + "Device %s menu": "Menú del dispositivo %s", + "The size is configured as a range between %s and %s, but this interface cannot handle ranges with a given max size.": "El tamaño se configura como un intervalo entre %s y %s, pero esta interfaz no puede gestionar intervalos con un tamaño máximo determinado.", + "Discard the maximum size and continue with simplified configuration": "Descartar el tamaño máximo y continuar con la configuración simplificada", + "The size must be a number followed by a unit of the form GiB (power of 2) or GB (power of 10).": "El tamaño debe ser un número seguido de una unidad en formato GiB (potencia de 2) o GB (potencia de 10).", + "approx. %s": "aprox. %s", + "Allow growing": "Permitir que crezca", + "The final size can be bigger in order to fill the extra free space.": "El tamaño final puede ser más grande para aprovechar el espacio libre adicional.", + "Size modes": "Modos de tamaño", + "Let the installer propose a sensible size": "Deje al instalador proponer un tamaño sensato", + "Define a custom size": "Definir un tamaño personalizado", + "The device will be used by the new system.": "El dispositivo será utilizado por el sistema nuevo.", + "The device will be mounted at %s.": "El dispositivo será montado en %s.", + "Up to %s can be recovered by shrinking the device.": "Se pueden recuperar hasta %s reduciendo el dispositivo.", + "The device cannot be shrunk:": "El dispositivo no puede ser reducido:", + "Show information about %s": "Mostrar información sobre %s", + "The content may be deleted": "El contenido puede ser eliminado", + "No content found": "No se encontró contenido", + "Action": "Acción", + "Find space": "Buscar espacio", + "Select what to do with each partition in order to find space for allocating the new system.": "Selecciona lo que quiere hacer con cada partición con el fin de encontrar espacio para asignar el sistema nuevo.", + "Find space in %s": "Encontrar espacio en %s", + "The storage configuration is valid (see result below) but uses elements not supported by this interface.": "La configuración de almacenaje es válida (consulte los resultados debajo) pero utiliza elementos no admitidos por este interfaz.", + "You can proceed to install with the current settings or you may want to discard the configuration and start from scratch with a simple one.": "Puede continuar con la instalación usando la configuración actual o, si lo prefiere, descartar la configuración y empezar desde cero con una configuración sencilla.", + "Not configured yet": "Aún no está configurado", + "Use the RAID without partitions": "Usar RAID sin particiones", + "Use the disk without partitions": "Usar el disco sin particiones", + "Add a partition or mount an existing one": "Añada una partición o monte una existente", + "Format the whole device or mount an existing file system": "Formatee todo el dispositivo o monte un sistema de archivos existente", + "%1$s will be created as a partition at %2$s": "%1$s se creará como partición en %2$s", + "%1$s will be created as a partition at %2$s_plural": "%1$s se crearán como particiones en %2$s", + "The logical volume will also be deleted": "El volumen lógico también se eliminará", + "The logical volume will also be deleted_plural": "Los volúmenes lógicos también se eliminarán", + "Delete volume group": "Eliminar grupo de volúmenes", + "Modify settings and physical volumes": "Modificar la configuración y los volúmenes físicos", + "Edit volume group": "Editar grupo de volúmenes", + "Create LVM volume group %s": "Crear grupo de volúmenes LVM %s", + "Empty LVM volume group %s": "Grupo de volúmenes LVM %s vacío", + "Logical volumes for %s": "Volúmenes lógicos para %s", + "Add logical volume": "Añadir volumen lógico", + "The following logical volume will be created": "Se creará el volumen lógico siguiente", + "The following logical volume will be created_plural": "Se crearán los volúmenes lógicos siguientes", + "Formatting DASD devices": "Formatear dispositivos DASD", + "DASD": "DASD", + "Activate": "Activar", + "Deactivate": "Desactivar", + "Set DIAG on": "Activar DIAG", + "Set DIAG off": "Desactivar DIAG", + "Format": "Formatear", + "Min channel": "Canal mínimo", + "Max channel": "Canal máximo", + "Apply to the selected device": "Aplicar al dispositivo seleccionado", + "Apply to the selected device_plural": "Aplicar a los %s dispositivos seleccionados", + "Select devices to enable bulk actions.": "Seleccione dispositivos para habilitar acciones en bloque.", + "No devices available": "No hay ningún dispositivo disponible", + "No DASD devices were found in this machine.": "No se encuentra ningún dispositivo DASD en este equipo.", + "Change filters and try again.": "Cambie los filtros y vuelva a intentarlo.", + "Clear all filters": "Borrar todos los filtros", + "Channel ID": "ID de canal", + "DIAG": "DIAG", + "No": "No", + "Yes": "Sí", + "Formatted": "Formateado", + "Partition Info": "Información de la partición", + "Applying changes": "Aplicando cambios", + "This may take a moment while updates complete.": "Las actualizaciones pueden tardar un momento.", + "This message will close automatically when everything is done.": "Este mensaje se cerrará automáticamente cuando todo se complete.", + "Cannot format %s": "No se puede formatear %s", + "It is offline and must be activated before formatting it.": "No tiene conexión y debe activarse antes de poder formatearse.", + "Cannot format all the selected devices": "No se pueden formatear todos los dispositivos seleccionados", + "Below %s devices are offline and cannot be formatted.": "Los %s dispositivos mostrados a continuación no tienen conexión y no se pueden formatear.", + "Unselect or activate them and try it again.": "Deselecciónelos o actívelos y vuelva a intentarlo.", + "Format device %s": "Formatear dispositivo %s", + "This action could destroy any data stored on the device.": "Esta acción podría destruir todos los datos almacenados en el dispositivo.", + "Confirm that you really want to continue.": "Confirme que desea continuar.", + "Format now": "Formatear ahora", + "Format selected devices?": "¿Formatear los dispositivos seleccionados?", + "This action could destroy any data stored on the devices listed below.": "Esta acción podría destruir todos los datos almacenados en los dispositivos que aparecen a continuación.", + "all": "todo", + "yes": "sí", + "no": "no", + "active": "activo", + "read_only": "solo_lectura", + "offline": "sin_conexión", + "Clear input": "Borrar entrada", + "Unused space": "Espacio no utilizado", + "%1$s (%2$s)": "%1$s (%2$s)", + "Only available if authentication by target is provided": "Solo disponible si se proporciona autenticación por destino", + "Authentication by target": "Autenticación por destino", + "User name": "Nombre de usuario", + "Incorrect user name": "Nombre de usuario incorrecto", + "Incorrect password": "Contraseña incorrecta", + "Authentication by initiator": "Autenticación por iniciador", + "Target Password": "Contraseña de destino", + "Discover iSCSI Targets": "Descubrir los destinos iSCSI", + "Make sure you provide the correct values": "Asegúrese de proporcionar los valores correctos", + "IP address": "Dirección IP", + "Address": "Dirección", + "Incorrect IP address": "Dirección IP incorrecta", + "Port": "Puerto", + "Incorrect port": "Puerto incorrecto", + "Edit %s": "Editar %s", + "Initiator name successfully updated": "Nombre del iniciador actualizado con éxito", + "Initiator name could not be updated": "No se ha podido actualizar el nombre del iniciador", + "The initiator name cannot be blank": "El nombre del iniciador no puede estar en blanco", + "Updating the initiator name": "Actualizando el nombre del iniciador", + "Initiator name": "Nombre del iniciador", + "Initiator details": "Detalles del iniciador", + "Configuration read from the iSCSI Boot Firmware Table (iBFT).": "Configuración leída de la tabla de firmware de arranque de iSCSI (iBFT).", + "No iSCSI Boot Firmware Table (iBFT) found. The initiator can be configured manually.": "No se ha encontrado la tabla de firmware de arranque de iSCSI (iBFT). El iniciador se puede configurar manualmente.", + "Initiator": "Iniciador", + "Login %s": "Iniciar sesión en %s", + "Startup": "Puesta en marcha", + "On boot": "En arranque", + "Disconnected": "Desconectado", + "Connected (%s)": "Conectado (%s)", + "Delete": "Eliminar", + "Login": "Acceder", + "Logout": "Cerrar sesión", + "Portal": "Portal", + "iBFT": "iBFT", + "No iSCSI targets found.": "No se han encontrado destinos iSCSI.", + "Please, perform an iSCSI discovery in order to find available iSCSI targets.": "Realice una búsqueda para encontrar destinos disponibles iSCSI.", + "Discover iSCSI targets": "Buscar destinos iSCSI", + "Discover": "Buscar", + "Targets": "Destinos", + "KiB": "KiB", + "MiB": "MiB", + "GiB": "GiB", + "TiB": "TiB", + "PiB": "PiB", + "Bcachefs": "Bcachefs", + "BitLocker": "BitLocker", + "Btrfs": "Btrfs", + "ExFAT": "ExFAT", + "Ext2": "Ext2", + "Ext3": "Ext3", + "Ext4": "Ext4", + "F2FS": "F2FS", + "JFS": "JFS", + "NFS": "NFS", + "NILFS2": "NILFS2", + "NTFS": "NTFS", + "ReiserFS": "ReiserFS", + "Swap": "Intercambio", + "Tmpfs": "Tmpfs", + "FAT": "FAT", + "XFS": "XFS", + "Delete current content": "Eliminar el contenido actual", + "Shrink existing partitions": "Reducir las particiones existentes", + "Use available space": "Utilizar el espacio disponible", + "%1$s - %2$s": "%1$s – %2$s", + "at least %s": "al menos %s", + "Multipath": "Ruta múltiple", + "DASD %s": "DASD %s", + "Software %s": "Software %s", + "SD Card": "Tarjeta SD", + "%s disk": "disco %s", + "Disk": "Disco", + "%s with %d partitions": "%s con %d particiones", + "A partition will be deleted": "Se eliminará una partición", + "At least one partition will be deleted": "Se eliminará al menos una partición", + "Several partitions will be deleted": "Se eliminarán varias particiones", + "A partition may be deleted": "Una partición se puede eliminar", + "Some partitions may be deleted": "Algunas particiones se pueden eliminar", + "A partition may be shrunk": "Una partición se puede reducir", + "Some partitions may be shrunk": "Algunas particiones se pueden reducir", + "All content not configured to be mounted will be deleted": "Todo el contenido que no se haya configurado para montarse se borrará", + "All content will be deleted": "Todo el contenido se borrará", + "Reused partitions will not be shrunk": "Las particiones reutilizadas no se reducirán", + "Some existing partitions may be shrunk": "Algunas particiones existentes se pueden reducir", + "Current partitions will be kept": "Se mantendrán las particiones actuales", + "Partitions that are not reused will be removed and that data will be lost.": "Las particiones que no se reutilicen se eliminarán y sus datos se perderán.", + "Any existing partition will be removed and all data in the disk will be lost.": "Se eliminará cualquier partición existente y se perderán todos los datos del disco.", + "Partitions that are not reused will be resized as needed.": "Las particiones que no se reutilicen se redimensionarán según sea necesario.", + "Partitions that are not reused would be resized if needed.": "Las particiones que no se reutilizan se redimensionarían si fuese necesario.", + "The data is kept, but the current partitions will be resized as needed.": "Los datos se conservan, pero las particiones actuales se redimensionarán según sea necesario.", + "Only reused partitions and space not assigned to any partition will be used.": "Solo se usarán las particiones reutilizadas y el espacio no asignado a ninguna partición.", + "Only reused partitions will be used.": "Solo se usarán las particiones reutilizadas.", + "The data is kept. Only the space not assigned to any partition will be used.": "Los datos se conservan. Solo se utilizará el espacio que no esté asignado a ninguna partición.", + "Select what to do with each partition.": "Seleccione qué hacer con cada partición.", + "The whole device will be used for %s": "Todo el dispositivo se usará para %s", + "A file system will be used for the whole device": "Se usará un sistema de archivos para todo el dispositivo", + "No additional partitions will be created": "No se creará ninguna partición adicional", + "An existing partition will be used for %s": "Se usará una partición existente para %s", + "An existing partition will be used for %s_plural": "Se usarán particiones existentes para %s", + "A new partition will be created for %s": "Se creará una partición nueva para %s", + "A new partition will be created for %s_plural": "Se crearán particiones nuevas para %s", + "Partitions will be used and created for %s": "Las particiones se utilizarán y crearán para %s", + "Current %1$s at %2$s": "Actual %1$s en %2$s", + "%1$s at %2$s": "%1$s en %2$s", + "No logical volumes are defined yet": "Aún no se han definido volúmenes lógicos", + "A new volume will be created for %s": "Se creará un volumen nuevo para %s", + "A new volume will be created for %s_plural": "Se crearán volúmenes nuevos para %s", + "Auto LUNs Scan": "Exploración automática de LUNs", + "Activated": "Activado", + "Deactivated": "Desactivado", + "zFCP Disk Activation": "Activación del disco zFCP", + "zFCP Disk activation form": "Formulario de activación del disco zFCP", + "The zFCP disk was not activated.": "El disco zFCP no estaba activado.", + "WWPN": "WWPN", + "LUN": "LUN", + "Automatic LUN scan is [enabled]. Activating a controller which is running in NPIV mode will automatically configures all its LUNs.": "La exploración automática de LUNs está [habilitada]. La activación de un controlador que se esté ejecutando en modo NPIV configurará automáticamente todos sus LUNs.", + "Automatic LUN scan is [disabled]. LUNs have to be manually configured after activating a controller.": "La exploración automática de LUNs está [deshabilitada]. Los LUNs deben configurarse manualmente después de activar un controlador.", + "Please, try to activate a zFCP disk.": "Por favor, intente activar un disco zFCP.", + "Please, try to activate a zFCP controller.": "Por favor, intente activar un controlador zFCP.", + "No zFCP disks found.": "No se han encontrado discos zFCP.", + "Activate zFCP disk": "Activar disco zFCP", + "Activate new disk": "Activar nuevo disco", + "Controllers": "Controladores", + "No zFCP controllers found.": "No se han encontrado controladores zFCP.", + "Read zFCP devices": "Leer dispositivos zFCP", + "zFCP": "zFCP", + "Enter a hostname.": "Introduce el nombre del equipo.", + "Hostname successfully updated": "El nombre del equipo se ha actualizado con éxito", + "Hostname could not be updated": "El nombre del equipo no se ha podido actualizar", + "Using transient hostname: %s": "Se está utilizando un nombre temporal de equipo: %s", + "Hostname": "Nombre del equipo", + "Product is already registered": "El producto ya está registrado", + "Updating the hostname now or later will not change the currently registered hostname.": "Actualizar el nombre del equipo ahora o más adelante no cambiará el nombre del equipo registrado actualmente.", + "This hostname is dynamic and may change after a reboot or network update, as configured by the local network administrator.": "El nombre del equipo es dinámico y puede cambiar tras un reinicio o actualización de red, según lo configure el administrador de la red local.", + "Use static hostname": "Utilizar un nombre estático del equipo", + "Set a permanent hostname that won’t change with network updates.": "Establece un nombre permanente del equipo que no cambie con las actualizaciones de la red.", + "Static hostname": "Nombre estático del equipo", + "Define a user now": "Defina un usuario ahora", + "Discard": "Descartar", + "No user defined yet.": "Aún no se ha definido ningún usuario.", + "Full name": "Nombre completo", + "Username": "Nombre de usuario", + "First user": "Primer usuario", + "Define the first user with admin (sudo) privileges for system management.": "Defina un primer usuario con privilegios de admin (sudo) para la gestión del sistema.", + "Username suggestion dropdown": "Menú desplegable de sugerencias de nombre de usuario", + "Use suggested username": "Usar nombre de usuario sugerido", + "All fields are required": "Todos los campos son obligatorios", + "Create user": "Crear usuario", + "Edit user": "Editar usuario", + "Using a hashed password.": "Utiliza una contraseña con hash.", + "The password is weak": "La contraseña es débil", + "Root user": "Usuario root", + "Alongside defining the first user, authentication methods for the root user can be configured.": "Además de definir el primer usuario, se pueden configurar los métodos de autenticación del usuario root.", + "Defined (hidden)": "Definido (oculto)", + "Not defined": "No definido", + "Public SSH Key": "Clave SSH pública", + "Upload, paste, or drop an SSH public key": "Subir, pegar o arrastrar una clave pública SSH", + "Upload": "Subir", + "Clear": "Limpiar", + "Public SSH Key is empty.": "La clave pública SSH está vacía.", + "Root authentication methods": "Métodos de autenticación de root", + "Use password": "Utilizar contraseña", + "Use public SSH Key": "Utilizar clave pública SSH", + "ZFCP": "ZFCP" +} diff --git a/web/src/api/api.ts b/web/src/api/api.ts new file mode 100644 index 0000000000..3fd7e8deee --- /dev/null +++ b/web/src/api/api.ts @@ -0,0 +1,40 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { get, patch } from "~/api/http"; + +/** + * Returns the system config + */ +const fetchSystem = (): Promise => get("/api/server/system"); + +/** + * Returns the proposal + */ +const fetchProposal = (): Promise => get("/api/server/proposal"); + +/** + * Updates configuration + */ +const updateConfig = (config) => patch("/api/server/config/user", config); + +export { fetchSystem, fetchProposal, updateConfig }; diff --git a/web/src/api/system.ts b/web/src/api/hostname.ts similarity index 96% rename from web/src/api/system.ts rename to web/src/api/hostname.ts index 7589dd0c10..dfac34b69e 100644 --- a/web/src/api/system.ts +++ b/web/src/api/hostname.ts @@ -21,7 +21,7 @@ */ import { get, put } from "~/api/http"; -import { Hostname } from "~/types/system"; +import { Hostname } from "~/types/hostname"; /** * Returns the hostname configuration diff --git a/web/src/api/l10n.ts b/web/src/api/l10n.ts deleted file mode 100644 index 224cae170b..0000000000 --- a/web/src/api/l10n.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright (c) [2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import { tzOffset } from "@date-fns/tz/tzOffset"; -import { get, patch } from "~/api/http"; -import { Keymap, Locale, LocaleConfig, Timezone } from "~/types/l10n"; - -/** - * Returns the l10n configuration - */ -const fetchConfig = (): Promise => get("/api/l10n/config"); - -/** - * Returns the list of known locales for installation - */ -const fetchLocales = async (): Promise => { - const json = await get("/api/l10n/locales"); - return json.map(({ id, language, territory }): Locale => { - return { id, name: language, territory }; - }); -}; - -/** - * Returns the list of known timezones - */ -const fetchTimezones = async (): Promise => { - const json = await get("/api/l10n/timezones"); - return json.map(({ code, parts, country }): Timezone => { - const offset = tzOffset(code, new Date()); - return { id: code, parts, country, utcOffset: offset }; - }); -}; - -/** - * Returns the list of known keymaps - */ -const fetchKeymaps = async (): Promise => { - const json = await get("/api/l10n/keymaps"); - const keymaps: Keymap[] = json.map(({ id, description }): Keymap => { - return { id, name: description }; - }); - return keymaps.sort((a, b) => (a.name < b.name ? -1 : 1)); -}; - -/** - * Updates the l10n configuration for the system to install - * - * @param config - Localization configuration - */ -const updateConfig = (config: LocaleConfig) => patch("/api/l10n/config", config); - -export { fetchConfig, fetchKeymaps, fetchLocales, fetchTimezones, updateConfig }; diff --git a/web/src/components/core/InstallerOptions.test.tsx b/web/src/components/core/InstallerOptions.test.tsx index 621bded93a..aeb04ecb2a 100644 --- a/web/src/components/core/InstallerOptions.test.tsx +++ b/web/src/components/core/InstallerOptions.test.tsx @@ -28,16 +28,17 @@ import * as utils from "~/utils"; import { PRODUCT, ROOT } from "~/routes/paths"; import InstallerOptions, { InstallerOptionsProps } from "./InstallerOptions"; import { Product } from "~/types/software"; +import { Keymap, Locale } from "~/types/l10n"; let phase: InstallationPhase; let isBusy: boolean; -const locales = [ +const locales: Locale[] = [ { id: "en_US.UTF-8", name: "English", territory: "United States" }, { id: "es_ES.UTF-8", name: "Spanish", territory: "Spain" }, ]; -const keymaps = [ +const keymaps: Keymap[] = [ { id: "us", name: "English (US)" }, { id: "gb", name: "English (UK)" }, ]; @@ -59,14 +60,9 @@ const mockL10nConfigMutation = { const mockChangeUIKeymap = jest.fn(); const mockChangeUILanguage = jest.fn(); -jest.mock("~/queries/l10n", () => ({ - ...jest.requireActual("~/queries/l10n"), - useL10n: () => ({ locales, selectedLocale: locales[0] }), - useConfigMutation: () => mockL10nConfigMutation, - keymapsQuery: () => ({ - queryKey: ["keymaps"], - queryFn: () => keymaps, - }), +jest.mock("~/queries/system", () => ({ + ...jest.requireActual("~/queries/system"), + useSystem: () => ({ locale: { locales, keymaps } }), })); jest.mock("~/queries/status", () => ({ diff --git a/web/src/components/core/InstallerOptions.tsx b/web/src/components/core/InstallerOptions.tsx index 446ed0a377..f55d73f9b4 100644 --- a/web/src/components/core/InstallerOptions.tsx +++ b/web/src/components/core/InstallerOptions.tsx @@ -33,7 +33,6 @@ import React, { useReducer } from "react"; import { useHref, useLocation } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; import { Button, ButtonProps, @@ -48,16 +47,18 @@ import { } from "@patternfly/react-core"; import { Popup } from "~/components/core"; import { Icon } from "~/components/layout"; -import { LocaleConfig } from "~/types/l10n"; +import { Keymap, Locale } from "~/types/l10n"; import { InstallationPhase } from "~/types/status"; import { useInstallerL10n } from "~/context/installerL10n"; -import { keymapsQuery, useConfigMutation, useL10n } from "~/queries/l10n"; import { useInstallerStatus } from "~/queries/status"; import { localConnection } from "~/utils"; import { _ } from "~/i18n"; import supportedLanguages from "~/languages.json"; import { PRODUCT, ROOT, L10N } from "~/routes/paths"; import { useProduct } from "~/queries/software"; +import { useSystem } from "~/queries/system"; +import { updateConfig } from "~/api/api"; +import { useTranslation } from "react-i18next"; /** * Props for select inputs @@ -70,17 +71,28 @@ type SelectProps = { /** * Renders a dropdown for language selection. */ -const LangaugeFormInput = ({ value, onChange }: SelectProps) => ( - - - {Object.keys(supportedLanguages) - .sort() - .map((id, index) => ( - - ))} - - -); +const LangaugeFormInput = ({ value, onChange }: SelectProps) => { + const { t, i18n } = useTranslation(); + return ( + + { + i18n.changeLanguage(value); + onChange(e, value); + }} + > + {Object.keys(supportedLanguages) + .sort() + .map((id, index) => ( + + ))} + + + ); +}; /** * Renders a dropdown for keyboard layout selection. @@ -88,23 +100,26 @@ const LangaugeFormInput = ({ value, onChange }: SelectProps) => ( * Not available in remote installations. */ const KeyboardFormInput = ({ value, onChange }: SelectProps) => { - const { isPending, data: keymaps } = useQuery(keymapsQuery()); - if (isPending) return; + const { t } = useTranslation(); + + const { + localization: { keymaps }, + } = useSystem(); if (!localConnection()) { return ( - - {_("Cannot be changed in remote installation")} + + {t("Cannot be changed in remote installation")} ); } return ( - + @@ -304,7 +319,9 @@ const TextWithLinkToL10n = ({ text, onClick }: TextWithLinkToL10nProps) => { }; const AllSettingsDialog = ({ state, formState, actions }: DialogProps) => { - const checkboxDescription = _( + const { t } = useTranslation(); + + const checkboxDescription = t( // TRANSLATORS: Explains where users can find more language and keymap // options for the product to install. Keep the text in square brackets [] // as it will be replaced with a clickable link. @@ -312,7 +329,7 @@ const AllSettingsDialog = ({ state, formState, actions }: DialogProps) => { ); return ( - +
@@ -320,7 +337,7 @@ const AllSettingsDialog = ({ state, formState, actions }: DialogProps) => { { isDisabled={state.isBusy} isLoading={state.isBusy} > - {_("Accept")} + {t("Accept")} @@ -550,9 +567,11 @@ export default function InstallerOptions({ toggle, onClose, }: InstallerOptionsProps) { + const { i18n } = useTranslation(); const location = useLocation(); - const { locales } = useL10n(); - const { mutate: updateSystemL10n } = useConfigMutation(); + const { + localization: { locales }, + } = useSystem(); const { language, keymap, changeLanguage, changeKeymap } = useInstallerL10n(); const { phase } = useInstallerStatus({ suspense: true }); const { selectedProduct } = useProduct({ suspense: true }); @@ -586,12 +605,12 @@ export default function InstallerOptions({ const reuseSettings = () => { // FIXME: export and use languageToLocale from context/installerL10n const systemLocale = locales.find((l) => l.id.startsWith(formState.language.replace("-", "_"))); - const systemL10n: Partial = {}; + const systemL10n: { language?: Locale["id"]; keyboard?: Keymap["id"] } = {}; // FIXME: use a fallback if no system locale was found ? - if (variant !== "keyboard") systemL10n.locales = [systemLocale?.id]; - if (variant !== "language" && localConnection()) systemL10n.keymap = formState.keymap; + if (variant !== "keyboard") systemL10n.language = systemLocale?.id; + if (variant !== "language" && localConnection()) systemL10n.keyboard = formState.keymap; - updateSystemL10n(systemL10n); + updateConfig({ localization: systemL10n }); }; const close = () => { @@ -605,15 +624,17 @@ export default function InstallerOptions({ dispatchDialogAction({ type: "SET_BUSY" }); try { - if (variant !== "language" && localConnection()) { - await changeKeymap(formState.keymap); - } - - if (variant !== "keyboard") { - await changeLanguage(formState.language); - } - - formState.allowReusingSettings && formState.reuseSettings && reuseSettings(); + i18n.changeLanguage(formState.language); + console.log(changeKeymap, changeLanguage, reuseSettings); + // if (variant !== "language" && localConnection()) { + // await changeKeymap(formState.keymap); + // } + // + // if (variant !== "keyboard") { + // await changeLanguage(formState.language); + // } + // + // formState.allowReusingSettings && formState.reuseSettings && reuseSettings(); } catch (e) { console.error(e); dispatchDialogAction({ type: "SET_IDLE" }); diff --git a/web/src/components/core/Popup.tsx b/web/src/components/core/Popup.tsx index 83c2584d76..77989d6cee 100644 --- a/web/src/components/core/Popup.tsx +++ b/web/src/components/core/Popup.tsx @@ -34,6 +34,7 @@ import { import { Loading } from "~/components/layout"; import { fork } from "radashi"; import { _ } from "~/i18n"; +import { useTranslation } from "react-i18next"; type ButtonWithoutVariantProps = Omit; type PredefinedAction = React.PropsWithChildren; @@ -137,11 +138,15 @@ const SecondaryAction = ({ children, ...actionProps }: PredefinedAction) => ( * @example Using it with a custom text * Dismiss */ -const Cancel = ({ children = _("Cancel"), ...actionProps }: PredefinedAction) => ( - - {children} - -); +const Cancel = ({ children, ...actionProps }: PredefinedAction) => { + const { t } = useTranslation(); + + return ( + + {children || t("Cancel")} + + ); +}; /** * A Popup additional action, rendered as a link diff --git a/web/src/components/l10n/KeyboardSelection.test.tsx b/web/src/components/l10n/KeyboardSelection.test.tsx index c22c3c5894..ee6095c8e7 100644 --- a/web/src/components/l10n/KeyboardSelection.test.tsx +++ b/web/src/components/l10n/KeyboardSelection.test.tsx @@ -25,8 +25,9 @@ import KeyboardSelection from "./KeyboardSelection"; import userEvent from "@testing-library/user-event"; import { screen } from "@testing-library/react"; import { mockNavigateFn, installerRender } from "~/test-utils"; +import { Keymap } from "~/types/l10n"; -const keymaps = [ +const keymaps: Keymap[] = [ { id: "us", name: "English" }, { id: "es", name: "Spanish" }, ]; @@ -39,10 +40,9 @@ jest.mock("~/components/product/ProductRegistrationAlert", () => () => (
ProductRegistrationAlert Mock
)); -jest.mock("~/queries/l10n", () => ({ - ...jest.requireActual("~/queries/l10n"), - useConfigMutation: () => mockConfigMutation, - useL10n: () => ({ keymaps, selectedKeymap: keymaps[0] }), +jest.mock("~/queries/system", () => ({ + ...jest.requireActual("~/queries/system"), + useSystem: () => ({ locale: { keymaps } }), })); jest.mock("react-router-dom", () => ({ diff --git a/web/src/components/l10n/KeyboardSelection.tsx b/web/src/components/l10n/KeyboardSelection.tsx index febcd26890..436fa7e4e7 100644 --- a/web/src/components/l10n/KeyboardSelection.tsx +++ b/web/src/components/l10n/KeyboardSelection.tsx @@ -24,16 +24,23 @@ import React, { useState } from "react"; import { Content, Flex, Form, FormGroup, Radio } from "@patternfly/react-core"; import { useNavigate } from "react-router-dom"; import { ListSearch, Page } from "~/components/core"; +import { updateConfig } from "~/api/api"; +import { useSystem } from "~/queries/system"; +import { useProposal } from "~/queries/proposal"; import { _ } from "~/i18n"; -import { useConfigMutation, useL10n } from "~/queries/l10n"; // TODO: Add documentation // TODO: Evaluate if worth it extracting the selector export default function KeyboardSelection() { const navigate = useNavigate(); - const setConfig = useConfigMutation(); - const { keymaps, selectedKeymap: currentKeymap } = useL10n(); - const [selected, setSelected] = useState(currentKeymap.id); + const { + localization: { keymaps }, + } = useSystem(); + const { + localization: { keymap: currentKeymap }, + } = useProposal(); + // FIXME: get current keymap from either, proposal or config + const [selected, setSelected] = useState(currentKeymap); const [filteredKeymaps, setFilteredKeymaps] = useState( keymaps.sort((k1, k2) => (k1.name > k2.name ? 1 : -1)), ); @@ -41,8 +48,10 @@ export default function KeyboardSelection() { const searchHelp = _("Filter by description or keymap code"); const onSubmit = async (e: React.SyntheticEvent) => { + console.log("selected", selected); e.preventDefault(); - setConfig.mutate({ keymap: selected }); + // FIXME: udpate when new API is ready + updateConfig({ localization: { keyboard: selected } }); navigate(-1); }; diff --git a/web/src/components/l10n/L10nPage.test.tsx b/web/src/components/l10n/L10nPage.test.tsx index ae84c15dfd..29dc36b080 100644 --- a/web/src/components/l10n/L10nPage.test.tsx +++ b/web/src/components/l10n/L10nPage.test.tsx @@ -24,22 +24,23 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import L10nPage from "~/components/l10n/L10nPage"; +import { Keymap, Locale, Timezone } from "~/types/l10n"; let mockLoadedData; -const locales = [ +const locales: Locale[] = [ { id: "en_US.UTF-8", name: "English", territory: "United States" }, { id: "es_ES.UTF-8", name: "Spanish", territory: "Spain" }, ]; -const keymaps = [ +const keymaps: Keymap[] = [ { id: "us", name: "English" }, { id: "es", name: "Spanish" }, ]; -const timezones = [ - { id: "Europe/Berlin", parts: ["Europe", "Berlin"] }, - { id: "Europe/Madrid", parts: ["Europe", "Madrid"] }, +const timezones: Timezone[] = [ + { id: "Europe/Berlin", parts: ["Europe", "Berlin"], country: "Germany", utcOffset: 120 }, + { id: "Europe/Madrid", parts: ["Europe", "Madrid"], country: "Spain", utcOffset: 120 }, ]; jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( @@ -48,19 +49,17 @@ jest.mock("~/components/product/ProductRegistrationAlert", () => () => ( jest.mock("~/components/core/InstallerOptions", () => () =>
InstallerOptions Mock
); -jest.mock("~/queries/l10n", () => ({ - ...jest.requireActual("~/queries/l10n"), - useL10n: () => mockLoadedData, +jest.mock("~/queries/system", () => ({ + useSystem: () => mockLoadedData, })); beforeEach(() => { mockLoadedData = { - locales, - keymaps, - timezones, - selectedLocale: locales[0], - selectedKeymap: keymaps[0], - selectedTimezone: timezones[0], + locale: { + locales, + keymaps, + timezones, + }, }; }); @@ -79,10 +78,6 @@ it("renders a section for configuring the language", () => { }); describe("if there is no selected language", () => { - beforeEach(() => { - mockLoadedData.selectedLocale = undefined; - }); - it("renders a button for selecting a language", () => { installerRender(); const region = screen.getByRole("region", { name: "Language" }); @@ -99,10 +94,6 @@ it("renders a section for configuring the keyboard", () => { }); describe("if there is no selected keyboard", () => { - beforeEach(() => { - mockLoadedData.selectedKeymap = undefined; - }); - it("renders a button for selecting a keyboard", () => { installerRender(); const region = screen.getByRole("region", { name: "Keyboard" }); @@ -119,10 +110,6 @@ it("renders a section for configuring the time zone", () => { }); describe("if there is no selected time zone", () => { - beforeEach(() => { - mockLoadedData.selectedTimezone = undefined; - }); - it("renders a button for selecting a time zone", () => { installerRender(); const region = screen.getByRole("region", { name: "Time zone" }); diff --git a/web/src/components/l10n/L10nPage.tsx b/web/src/components/l10n/L10nPage.tsx index d82a901e86..10961cac2f 100644 --- a/web/src/components/l10n/L10nPage.tsx +++ b/web/src/components/l10n/L10nPage.tsx @@ -24,9 +24,10 @@ import React from "react"; import { Button, Content, Grid, GridItem } from "@patternfly/react-core"; import { InstallerOptions, Link, Page } from "~/components/core"; import { L10N as PATHS } from "~/routes/paths"; -import { useL10n } from "~/queries/l10n"; -import { _ } from "~/i18n"; import { localConnection } from "~/utils"; +import { useProposal } from "~/queries/proposal"; +import { useSystem } from "~/queries/system"; +import { _ } from "~/i18n"; const InstallerL10nSettingsInfo = () => { const info = localConnection() @@ -66,7 +67,16 @@ const InstallerL10nSettingsInfo = () => { * Page for configuring localization. */ export default function L10nPage() { - const { selectedLocale: locale, selectedTimezone: timezone, selectedKeymap: keymap } = useL10n(); + // FIXME: retrieve selection from config when ready + const { localization: l10nProposal } = useProposal(); + const { localization: l10n } = useSystem(); + + const locale = l10nProposal.locale && l10n.locales.find((l) => l.id === l10nProposal.locale); + const keymap = l10nProposal.keymap && l10n.keymaps.find((k) => k.id === l10nProposal.keymap); + const timezone = + l10nProposal.timezone && l10n.timezones.find((t) => t.id === l10nProposal.timezone); + + console.log("locale", locale); return ( diff --git a/web/src/components/l10n/LocaleSelection.test.tsx b/web/src/components/l10n/LocaleSelection.test.tsx index b2d4a98aa5..0e1c69a843 100644 --- a/web/src/components/l10n/LocaleSelection.test.tsx +++ b/web/src/components/l10n/LocaleSelection.test.tsx @@ -25,8 +25,9 @@ import LocaleSelection from "./LocaleSelection"; import userEvent from "@testing-library/user-event"; import { screen } from "@testing-library/react"; import { mockNavigateFn, installerRender } from "~/test-utils"; +import { Locale } from "~/types/l10n"; -const locales = [ +const locales: Locale[] = [ { id: "en_US.UTF-8", name: "English", territory: "United States" }, { id: "es_ES.UTF-8", name: "Spanish", territory: "Spain" }, ]; @@ -39,10 +40,9 @@ jest.mock("~/components/product/ProductRegistrationAlert", () => () => (
ProductRegistrationAlert Mock
)); -jest.mock("~/queries/l10n", () => ({ - ...jest.requireActual("~/queries/l10n"), - useL10n: () => ({ locales, selectedLocale: locales[0] }), - useConfigMutation: () => mockConfigMutation, +jest.mock("~/queries/system", () => ({ + ...jest.requireActual("~/queries/system"), + useSystem: () => ({ locale: { locales } }), })); jest.mock("react-router-dom", () => ({ diff --git a/web/src/components/l10n/LocaleSelection.tsx b/web/src/components/l10n/LocaleSelection.tsx index 08e1219984..446d0b54a4 100644 --- a/web/src/components/l10n/LocaleSelection.tsx +++ b/web/src/components/l10n/LocaleSelection.tsx @@ -24,24 +24,30 @@ import React, { useState } from "react"; import { Content, Flex, Form, FormGroup, Radio } from "@patternfly/react-core"; import { useNavigate } from "react-router-dom"; import { ListSearch, Page } from "~/components/core"; -import { _ } from "~/i18n"; -import { useConfigMutation, useL10n } from "~/queries/l10n"; +import { updateConfig } from "~/api/api"; +import { useSystem } from "~/queries/system"; +import { useProposal } from "~/queries/proposal"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; +import { _ } from "~/i18n"; // TODO: Add documentation // TODO: Evaluate if worth it extracting the selector export default function LocaleSelection() { const navigate = useNavigate(); - const setConfig = useConfigMutation(); - const { locales, selectedLocale: currentLocale } = useL10n(); - const [selected, setSelected] = useState(currentLocale.id); + const { + localization: { locales }, + } = useSystem(); + const { + localization: { locale: currentLocale }, + } = useProposal(); + const [selected, setSelected] = useState(currentLocale); const [filteredLocales, setFilteredLocales] = useState(locales); const searchHelp = _("Filter by language, territory or locale code"); const onSubmit = async (e: React.SyntheticEvent) => { e.preventDefault(); - setConfig.mutate({ locales: [selected] }); + updateConfig({ localization: { language: selected } }); navigate(-1); }; diff --git a/web/src/components/l10n/TimezoneSelection.test.tsx b/web/src/components/l10n/TimezoneSelection.test.tsx index 1077a34cef..4d94a03e06 100644 --- a/web/src/components/l10n/TimezoneSelection.test.tsx +++ b/web/src/components/l10n/TimezoneSelection.test.tsx @@ -25,12 +25,13 @@ import TimezoneSelection from "./TimezoneSelection"; import userEvent from "@testing-library/user-event"; import { screen } from "@testing-library/react"; import { mockNavigateFn, installerRender } from "~/test-utils"; +import { Timezone } from "~/types/l10n"; jest.mock("~/components/product/ProductRegistrationAlert", () => () => (
ProductRegistrationAlert Mock
)); -const timezones = [ +const timezones: Timezone[] = [ { id: "Europe/Berlin", parts: ["Europe", "Berlin"], country: "Germany", utcOffset: 120 }, { id: "Europe/Madrid", parts: ["Europe", "Madrid"], country: "Spain", utcOffset: 120 }, { @@ -51,10 +52,9 @@ const mockConfigMutation = { mutate: jest.fn(), }; -jest.mock("~/queries/l10n", () => ({ - ...jest.requireActual("~/queries/l10n"), - useConfigMutation: () => mockConfigMutation, - useL10n: () => ({ timezones, selectedTimezone: timezones[0] }), +jest.mock("~/queries/system", () => ({ + ...jest.requireActual("~/queries/system"), + useSystem: () => ({ locale: { timezones } }), })); jest.mock("react-router-dom", () => ({ diff --git a/web/src/components/l10n/TimezoneSelection.tsx b/web/src/components/l10n/TimezoneSelection.tsx index 61db7b4414..b153e625ed 100644 --- a/web/src/components/l10n/TimezoneSelection.tsx +++ b/web/src/components/l10n/TimezoneSelection.tsx @@ -24,9 +24,11 @@ import React, { useState } from "react"; import { Content, Flex, Form, FormGroup, Radio } from "@patternfly/react-core"; import { useNavigate } from "react-router-dom"; import { ListSearch, Page } from "~/components/core"; -import { timezoneTime } from "~/utils"; -import { useConfigMutation, useL10n } from "~/queries/l10n"; import { Timezone } from "~/types/l10n"; +import { updateConfig } from "~/api/api"; +import { useSystem } from "~/queries/system"; +import { useProposal } from "~/queries/proposal"; +import { timezoneTime } from "~/utils"; import spacingStyles from "@patternfly/react-styles/css/utilities/Spacing/spacing"; import { _ } from "~/i18n"; @@ -66,17 +68,22 @@ const sortedTimezones = (timezones: Timezone[]) => { export default function TimezoneSelection() { date = new Date(); const navigate = useNavigate(); - const setConfig = useConfigMutation(); - const { timezones, selectedTimezone: currentTimezone } = useL10n(); + const { + localization: { timezones }, + } = useSystem(); + const { + localization: { timezone: currentTimezone }, + } = useProposal(); + const displayTimezones = timezones.map(timezoneWithDetails); - const [selected, setSelected] = useState(currentTimezone.id); + const [selected, setSelected] = useState(currentTimezone); const [filteredTimezones, setFilteredTimezones] = useState(sortedTimezones(displayTimezones)); const searchHelp = _("Filter by territory, time zone code or UTC offset"); const onSubmit = async (e: React.SyntheticEvent) => { e.preventDefault(); - setConfig.mutate({ timezone: selected }); + updateConfig({ localization: { timezone: selected } }); navigate(-1); }; diff --git a/web/src/components/overview/L10nSection.test.tsx b/web/src/components/overview/L10nSection.test.tsx index efb9a41ff0..93539f678f 100644 --- a/web/src/components/overview/L10nSection.test.tsx +++ b/web/src/components/overview/L10nSection.test.tsx @@ -24,14 +24,16 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import { L10nSection } from "~/components/overview"; +import { Locale } from "~/types/l10n"; -const locales = [ +const locales: Locale[] = [ { id: "en_US.UTF-8", name: "English", territory: "United States" }, { id: "de_DE.UTF-8", name: "German", territory: "Germany" }, ]; -jest.mock("~/queries/l10n", () => ({ - useL10n: () => ({ locales, selectedLocale: locales[0] }), +jest.mock("~/queries/system", () => ({ + ...jest.requireActual("~/queries/system"), + useSystem: () => ({ locale: { locales } }), })); it("displays the selected locale", async () => { diff --git a/web/src/components/overview/L10nSection.tsx b/web/src/components/overview/L10nSection.tsx index 3c5c4fe8be..d4bf24b4c3 100644 --- a/web/src/components/overview/L10nSection.tsx +++ b/web/src/components/overview/L10nSection.tsx @@ -21,25 +21,12 @@ */ import React from "react"; -import { Content } from "@patternfly/react-core"; -import { useL10n } from "~/queries/l10n"; -import { _ } from "~/i18n"; export default function L10nSection() { - const { selectedLocale: locale } = useL10n(); - - // TRANSLATORS: %s will be replaced by a language name and territory, example: - // "English (United States)". - const [msg1, msg2] = _("The system will use %s as its default language.").split("%s"); - + /* eslint-disable i18next/no-literal-string */ return ( - - {_("Localization")} - - {msg1} - {`${locale.name} (${locale.territory})`} - {msg2} - - +
+ Overview/L10nSection: pending to either, removal or adaptation +
); } diff --git a/web/src/components/overview/OverviewPage.tsx b/web/src/components/overview/OverviewPage.tsx index 0852b55ac6..f4df463720 100644 --- a/web/src/components/overview/OverviewPage.tsx +++ b/web/src/components/overview/OverviewPage.tsx @@ -26,9 +26,12 @@ import { Page } from "~/components/core"; import L10nSection from "./L10nSection"; import StorageSection from "./StorageSection"; import SoftwareSection from "./SoftwareSection"; -import { _ } from "~/i18n"; +import { _, n_ } from "~/i18n"; +import { useTranslation } from "react-i18next"; export default function OverviewPage() { + const { t } = useTranslation(); + return ( @@ -40,7 +43,7 @@ export default function OverviewPage() { - {_( + {t( "These are the most relevant installation settings. Feel free to browse the sections in the menu for further details.", )} diff --git a/web/src/components/product/ProductRegistrationPage.tsx b/web/src/components/product/ProductRegistrationPage.tsx index 8a6a80935f..703eceda97 100644 --- a/web/src/components/product/ProductRegistrationPage.tsx +++ b/web/src/components/product/ProductRegistrationPage.tsx @@ -54,7 +54,7 @@ import RegistrationCodeInput from "./RegistrationCodeInput"; import { RegistrationParams } from "~/types/software"; import { HOSTNAME } from "~/routes/paths"; import { useProduct, useRegistration, useRegisterMutation, useAddons } from "~/queries/software"; -import { useHostname } from "~/queries/system"; +import { useHostname } from "~/queries/hostname"; import { isEmpty } from "radashi"; import { mask } from "~/utils"; import { sprintf } from "sprintf-js"; diff --git a/web/src/components/product/ProductSelectionPage.tsx b/web/src/components/product/ProductSelectionPage.tsx index 0568882e9b..01ab695c40 100644 --- a/web/src/components/product/ProductSelectionPage.tsx +++ b/web/src/components/product/ProductSelectionPage.tsx @@ -48,6 +48,7 @@ import { isEmpty } from "radashi"; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; import LicenseDialog from "./LicenseDialog"; +import { useTranslation } from "react-i18next"; const ResponsiveGridItem = ({ children }) => ( @@ -102,6 +103,7 @@ const BackLink = () => { }; function ProductSelectionPage() { + const { t } = useTranslation(); const setConfig = useConfigMutation(); const registration = useRegistration(); const { products, selectedProduct } = useProduct({ suspense: true }); @@ -136,7 +138,7 @@ function ProductSelectionPage() { // TRANSLATORS: Text used for the license acceptance checkbox. %s will be // replaced with the product name and the text in the square brackets [] is // used for the link to show the license, please keep the brackets. - _("I have read and accept the [license] for %s"), + t("I have read and accept the [license] for {{product.name}}", { product: nextProduct }), nextProduct?.name || selectedProduct?.name, ).split(/[[\]]/); diff --git a/web/src/components/questions/LuksActivationQuestion.test.tsx b/web/src/components/questions/LuksActivationQuestion.test.tsx index 90023e785e..cc35186b60 100644 --- a/web/src/components/questions/LuksActivationQuestion.test.tsx +++ b/web/src/components/questions/LuksActivationQuestion.test.tsx @@ -46,10 +46,6 @@ const tumbleweed: Product = { }; const answerFn: AnswerCallback = jest.fn(); -const locales = [ - { id: "en_US.UTF-8", name: "English", territory: "United States" }, - { id: "es_ES.UTF-8", name: "Spanish", territory: "Spain" }, -]; jest.mock("~/queries/status", () => ({ useInstallerStatus: () => ({ @@ -58,11 +54,6 @@ jest.mock("~/queries/status", () => ({ }), })); -jest.mock("~/queries/l10n", () => ({ - ...jest.requireActual("~/queries/l10n"), - useL10n: () => ({ locales, selectedLocale: locales[0] }), -})); - jest.mock("~/queries/software", () => ({ ...jest.requireActual("~/queries/software"), useProduct: () => { diff --git a/web/src/components/questions/QuestionWithPassword.test.tsx b/web/src/components/questions/QuestionWithPassword.test.tsx index 67a8f5f0be..3a4a99dae5 100644 --- a/web/src/components/questions/QuestionWithPassword.test.tsx +++ b/web/src/components/questions/QuestionWithPassword.test.tsx @@ -27,6 +27,7 @@ import { Question } from "~/types/questions"; import { Product } from "~/types/software"; import { InstallationPhase } from "~/types/status"; import QuestionWithPassword from "~/components/questions/QuestionWithPassword"; +import { Locale } from "~/types/l10n"; const answerFn = jest.fn(); const question: Question = { @@ -45,7 +46,7 @@ const tumbleweed: Product = { registration: false, }; -const locales = [ +const locales: Locale[] = [ { id: "en_US.UTF-8", name: "English", territory: "United States" }, { id: "es_ES.UTF-8", name: "Spanish", territory: "Spain" }, ]; @@ -57,9 +58,9 @@ jest.mock("~/queries/status", () => ({ }), })); -jest.mock("~/queries/l10n", () => ({ +jest.mock("~/queries/system", () => ({ ...jest.requireActual("~/queries/l10n"), - useL10n: () => ({ locales, selectedLocale: locales[0] }), + useSystem: () => ({ locale: { locales } }), })); jest.mock("~/queries/software", () => ({ @@ -78,7 +79,6 @@ jest.mock("~/context/installerL10n", () => ({ keymap: "us", language: "de-DE", }), - useL10n: jest.fn(), })); const renderQuestion = () => diff --git a/web/src/components/system/HostnamePage.tsx b/web/src/components/system/HostnamePage.tsx index a406d34397..40c078e51a 100644 --- a/web/src/components/system/HostnamePage.tsx +++ b/web/src/components/system/HostnamePage.tsx @@ -33,7 +33,7 @@ import { } from "@patternfly/react-core"; import { NestedContent, Page } from "~/components/core"; import { useProduct, useRegistration } from "~/queries/software"; -import { useHostname, useHostnameMutation } from "~/queries/system"; +import { useHostname, useHostnameMutation } from "~/queries/hostname"; import { isEmpty } from "radashi"; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; diff --git a/web/src/context/installerL10n.tsx b/web/src/context/installerL10n.tsx index 68f0f289ae..ea5c6c7197 100644 --- a/web/src/context/installerL10n.tsx +++ b/web/src/context/installerL10n.tsx @@ -24,7 +24,6 @@ import React, { useCallback, useEffect, useState } from "react"; import { locationReload, setLocationSearch } from "~/utils"; import agama from "~/agama"; import supportedLanguages from "~/languages.json"; -import { fetchConfig as defaultFetchConfig, updateConfig } from "~/api/l10n"; import { LocaleConfig } from "~/types/l10n"; const L10nContext = React.createContext(null); @@ -99,48 +98,51 @@ function languageFromQuery(): string | undefined { return country ? `${language.toLowerCase()}-${country.toUpperCase()}` : language; } -/** - * Generates a RFC 5646 (or BCP 78) language tag from a locale. - * - * @param locale - * @return RFC 5646 language tag (e.g., "en-US") - * - * @private - * @see https://datatracker.ietf.org/doc/html/rfc5646 - * @see https://www.rfc-editor.org/info/bcp78 - */ -function languageFromLocale(locale: string): string { - const [language] = locale.split("."); - return language.replace("_", "-"); -} - -/** - * Converts a RFC 5646 language tag to a locale. - * - * It forces the encoding to "UTF-8". - * - * @param language as a RFC 5646 language tag (e.g., "en-US") - * @return locale (e.g., "en_US.UTF-8") - * - * @private - * @see https://datatracker.ietf.org/doc/html/rfc5646 - * @see https://www.rfc-editor.org/info/bcp78 - */ -function languageToLocale(language: string): string { - const [lang, country] = language.split("-"); - const locale = country ? `${lang}_${country.toUpperCase()}` : lang; - return `${locale}.UTF-8`; -} - -/** - * Returns the language tag from the backend. - * - * @return Language tag from the backend locale. - */ -async function languageFromBackend(fetchConfig: () => Promise): Promise { - const config = await fetchConfig(); - return languageFromLocale(config.uiLocale); -} +// FIXME: NEW-API: uncommnet when new api is ready +// /** +// * Generates a RFC 5646 (or BCP 78) language tag from a locale. +// * +// * @param locale +// * @return RFC 5646 language tag (e.g., "en-US") +// * +// * @private +// * @see https://datatracker.ietf.org/doc/html/rfc5646 +// * @see https://www.rfc-editor.org/info/bcp78 +// */ +// function languageFromLocale(locale: string): string { +// const [language] = locale.split("."); +// return language.replace("_", "-"); +// } + +// FIXME: NEW-API: uncomment when in use again +// /** +// * Converts a RFC 5646 language tag to a locale. +// * +// * It forces the encoding to "UTF-8". +// * +// * @param language as a RFC 5646 language tag (e.g., "en-US") +// * @return locale (e.g., "en_US.UTF-8") +// * +// * @private +// * @see https://datatracker.ietf.org/doc/html/rfc5646 +// * @see https://www.rfc-editor.org/info/bcp78 +// */ +// function languageToLocale(language: string): string { +// const [lang, country] = language.split("-"); +// const locale = country ? `${lang}_${country.toUpperCase()}` : lang; +// return `${locale}.UTF-8`; +// } + +// FIXME: NEW-API: uncommnet when new api is ready +// /** +// * Returns the language tag from the backend. +// * +// * @return Language tag from the backend locale. +// */ +// async function languageFromBackend(): Promise { +// // FIXME: NEW-API: return something meaningful once new API is ready +// return languageFromLocale("en"); +// } /** * Returns the first supported language from the given list. @@ -216,6 +218,10 @@ async function loadTranslations(locale: string) { }); } +const defaultFetchConfig = () => { + console.log("FIXME: NEW-API: make defaultFetchConfig work again"); +}; + /** * This provider sets the installer locale. By default, it uses the URL "lang" query parameter or * the preferred locale from the browser and synchronizes the UI and the backend locales. To @@ -242,16 +248,18 @@ function InstallerL10nProvider({ children?: React.ReactNode; }) { const fetchConfig = fetchConfigFn || defaultFetchConfig; + console.log("FIXME: NEW-API: reintroduce fetchConfig", fetchConfig()); const [language, setLanguage] = useState(initialLanguage); const [keymap, setKeymap] = useState(undefined); - const syncBackendLanguage = useCallback(async () => { - const backendLanguage = await languageFromBackend(fetchConfig); - if (backendLanguage === language) return; - - // FIXME: fallback to en-US if the language is not supported. - await updateConfig({ uiLocale: languageToLocale(language) }); - }, [fetchConfig, language]); + // FIXME: NEW-API: sync and updateConfig with new API once it's ready. + // const syncBackendLanguage = useCallback(async () => { + // const backendLanguage = await languageFromBackend(fetchConfig); + // if (backendLanguage === language) return; + // + // // FIXME: fallback to en-US if the language is not supported. + // await updateConfig({ uiLocale: languageToLocale(language) }); + // }, [fetchConfig, language]); const changeLanguage = useCallback( async (lang?: string) => { @@ -268,7 +276,7 @@ function InstallerL10nProvider({ wanted, wanted?.split("-")[0], // fallback to the language (e.g., "es" for "es-AR") agamaLanguage(), - await languageFromBackend(fetchConfig), + // await languageFromBackend(fetchConfig), ].filter((l) => l); const newLanguage = findSupportedLanguage(candidateLanguages) || "en-US"; const mustReload = storeAgamaLanguage(newLanguage); @@ -283,13 +291,15 @@ function InstallerL10nProvider({ await loadTranslations(newLanguage); } }, - [fetchConfig, setLanguage], + // [fetchConfig, setLanguage], + [setLanguage], ); const changeKeymap = useCallback( async (id: string) => { setKeymap(id); - await updateConfig({ uiKeymap: id }); + // FIXME: NEW-API: uncomment when new API is ready + // await updateConfig({ uiKeymap: id }); }, [setKeymap], ); @@ -298,15 +308,17 @@ function InstallerL10nProvider({ if (!language) changeLanguage(); }, [changeLanguage, language]); - useEffect(() => { - if (!language) return; - - syncBackendLanguage(); - }, [language, syncBackendLanguage]); - - useEffect(() => { - fetchConfig().then((c) => setKeymap(c.uiKeymap)); - }, [setKeymap, fetchConfig]); + // FIXME: NEW-API: uncomment when in use again + // useEffect(() => { + // if (!language) return; + // + // syncBackendLanguage(); + // }, [language, syncBackendLanguage]); + + // FIXME: NEW-API: uncomment when system reports keymap for insterface + // useEffect(() => { + // fetchConfig().then((c) => setKeymap(c.uiKeymap)); + // }, [setKeymap, fetchConfig]); const value = { language, changeLanguage, keymap, changeKeymap }; diff --git a/web/src/i18next.ts b/web/src/i18next.ts new file mode 100644 index 0000000000..b1f63af605 --- /dev/null +++ b/web/src/i18next.ts @@ -0,0 +1,25 @@ +import i18next from "i18next"; +import { initReactI18next } from "react-i18next"; +import LanguageDetector from "i18next-browser-languagedetector"; +import HTTPApi from "i18next-http-backend"; + +i18next + .use(initReactI18next) + .use(LanguageDetector) + .use(HTTPApi) + .init({ + debug: true, + fallbackLng: false, // We're using English keys as fallback. + lng: "es", + interpolation: { + escapeValue: false, + }, + load: "languageOnly", + nsSeparator: false, + keySeparator: false, + backend: { + loadPath: "/po-{{lng}}.json", + }, + }); + +export default i18next; diff --git a/web/src/index.tsx b/web/src/index.tsx index e678bef2a8..db7e908697 100644 --- a/web/src/index.tsx +++ b/web/src/index.tsx @@ -45,6 +45,11 @@ import "@patternfly/patternfly/patternfly-addons.scss"; */ import "~/assets/styles/index.scss"; +/** + * Initialize i18next + */ +import "~/i18next"; + const container = document.getElementById("root"); const root = createRoot(container); diff --git a/web/src/queries/hostname.ts b/web/src/queries/hostname.ts new file mode 100644 index 0000000000..128e6be1c0 --- /dev/null +++ b/web/src/queries/hostname.ts @@ -0,0 +1,54 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import { fetchHostname, updateHostname } from "~/api/hostname"; + +/** + * Returns a query for retrieving the hostname configuration + */ +const hostnameQuery = () => ({ + queryKey: ["system", "hostname"], + queryFn: fetchHostname, +}); + +/** + * Hook that returns the hostname configuration + */ +const useHostname = () => { + const { data: hostname } = useSuspenseQuery(hostnameQuery()); + return hostname; +}; + +/* + * Hook that returns a mutation to change the hostname + */ +const useHostnameMutation = () => { + const queryClient = useQueryClient(); + const query = { + mutationFn: updateHostname, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["system", "hostname"] }), + }; + return useMutation(query); +}; + +export { useHostname, useHostnameMutation }; diff --git a/web/src/queries/l10n.ts b/web/src/queries/l10n.ts index 4e48ada107..ee39a05bda 100644 --- a/web/src/queries/l10n.ts +++ b/web/src/queries/l10n.ts @@ -21,57 +21,8 @@ */ import React from "react"; -import { useQueryClient, useMutation, useSuspenseQueries } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; import { useInstallerClient } from "~/context/installer"; -import { fetchConfig, fetchKeymaps, fetchLocales, fetchTimezones, updateConfig } from "~/api/l10n"; - -/** - * Returns a query for retrieving the localization configuration - */ -const configQuery = () => { - return { - queryKey: ["l10n", "config"], - queryFn: fetchConfig, - }; -}; - -/** - * Returns a query for retrieving the list of known locales - */ -const localesQuery = () => ({ - queryKey: ["l10n", "locales"], - queryFn: fetchLocales, - staleTime: Infinity, -}); - -/** - * Returns a query for retrieving the list of known timezones - */ -const timezonesQuery = () => ({ - queryKey: ["l10n", "timezones"], - queryFn: fetchTimezones, - staleTime: Infinity, -}); - -/** - * Returns a query for retrieving the list of known keymaps - */ -const keymapsQuery = () => ({ - queryKey: ["l10n", "keymaps"], - queryFn: fetchKeymaps, - staleTime: Infinity, -}); - -/** - * Hook that builds a mutation to update the l10n configuration - * - * It does not require to call `useMutation`. - */ -const useConfigMutation = () => { - return useMutation({ - mutationFn: updateConfig, - }); -}; /** * Hook that returns a useEffect to listen for L10nConfigChanged events @@ -94,37 +45,4 @@ const useL10nConfigChanges = () => { }, [client, queryClient]); }; -/// Returns the l10n data. -const useL10n = () => { - const [{ data: config }, { data: locales }, { data: keymaps }, { data: timezones }] = - useSuspenseQueries({ - queries: [configQuery(), localesQuery(), keymapsQuery(), timezonesQuery()], - }); - - const selectedLocale = locales.find((l) => l.id === config.locales[0]); - const selectedKeymap = keymaps.find((k) => k.id === config.keymap); - const selectedTimezone = timezones.find((t) => t.id === config.timezone); - const uiLocale = locales.find((l) => l.id === config.uiLocale); - const uiKeymap = keymaps.find((k) => k.id === config.uiKeymap); - - return { - locales, - keymaps, - timezones, - selectedLocale, - selectedKeymap, - selectedTimezone, - uiLocale, - uiKeymap, - }; -}; - -export { - configQuery, - keymapsQuery, - localesQuery, - timezonesQuery, - useConfigMutation, - useL10n, - useL10nConfigChanges, -}; +export { useL10nConfigChanges }; diff --git a/web/src/queries/proposal.ts b/web/src/queries/proposal.ts new file mode 100644 index 0000000000..259f034443 --- /dev/null +++ b/web/src/queries/proposal.ts @@ -0,0 +1,41 @@ +/* + * Copyright (c) [2025] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import { useSuspenseQuery } from "@tanstack/react-query"; +import { fetchProposal } from "~/api/api"; + +/** + * Returns a query for retrieving the proposal + */ +const proposalQuery = () => { + return { + queryKey: ["proposal"], + queryFn: fetchProposal, + }; +}; + +const useProposal = () => { + const { data: config } = useSuspenseQuery(proposalQuery()); + return config; +}; + +export { useProposal }; diff --git a/web/src/queries/system.ts b/web/src/queries/system.ts index c21e2ee28a..57af2b0d6e 100644 --- a/web/src/queries/system.ts +++ b/web/src/queries/system.ts @@ -20,35 +20,55 @@ * find current contact information at www.suse.com. */ -import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; -import { fetchHostname, updateHostname } from "~/api/system"; +import { tzOffset } from "@date-fns/tz/tzOffset"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { fetchSystem } from "~/api/api"; -/** - * Returns a query for retrieving the hostname configuration - */ -const hostnameQuery = () => ({ - queryKey: ["system", "hostname"], - queryFn: fetchHostname, -}); +const transformLocales = (locales) => + locales.map(({ id, language: name, territory }) => ({ id, name, territory })); + +const tranformKeymaps = (keymaps) => keymaps.map(({ id, description: name }) => ({ id, name })); + +const transformTimezones = (timezones) => + timezones.map(({ code: id, parts, country }) => { + const utcOffset = tzOffset(id, new Date()); + return { id, parts, country, utcOffset }; + }); /** - * Hook that returns the hostname configuration + * Returns a query for retrieving the localization configuration */ -const useHostname = () => { - const { data: hostname } = useSuspenseQuery(hostnameQuery()); - return hostname; -}; +const systemQuery = () => { + return { + queryKey: ["system"], + queryFn: fetchSystem, -/* - * Hook that returns a mutation to change the hostname - */ -const useHostnameMutation = () => { - const queryClient = useQueryClient(); - const query = { - mutationFn: updateHostname, - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["system", "hostname"] }), + // FIXME: We previously had separate fetch functions (fetchLocales, + // fetchKeymaps, fetchTimezones) that each applied specific transformations to + // the raw API data, for example, adding `utcOffset` to timezones or + // changing keys to follow a consistent structure (e.g. `id` vs `code`). + // + // Now that we've consolidated these into a single "system" cache, instead of + // individual caches, those transformations are currently missing. While it's + // more efficient to fetch everything in one request, we may still want to apply + // those transformations only once. Ideally, this logic should live outside the + // React Query layer, in a dedicated "state layer" or transformation step, so + // that data remains normalized and consistently shaped for the rest of the app. + + select: (data) => ({ + ...data, + localization: { + locales: transformLocales(data.localization.locales), + keymaps: tranformKeymaps(data.localization.keymaps), + timezones: transformTimezones(data.localization.timezones), + }, + }), }; - return useMutation(query); }; -export { useHostname, useHostnameMutation }; +const useSystem = () => { + const { data: config } = useSuspenseQuery(systemQuery()); + return config; +}; + +export { useSystem }; diff --git a/web/src/types/system.ts b/web/src/types/hostname.ts similarity index 100% rename from web/src/types/system.ts rename to web/src/types/hostname.ts