diff --git a/.deployment/templates/config.toml b/.deployment/templates/config.toml index 43c83a895..df167dc3f 100644 --- a/.deployment/templates/config.toml +++ b/.deployment/templates/config.toml @@ -1,5 +1,5 @@ [general] -site_title.en = "Tobira Test Deployment" +site_title.default = "Tobira Test Deployment" tobira_url = "https://{% if id != 'main' %}{{id}}.{% endif %}tobira.opencast.org" users_searchable = true @@ -25,7 +25,7 @@ unix_socket_permissions = 0o777 [auth] source = "tobira-session" session.from_login_credentials = "login-callback:http+unix://[/opt/tobira/{{ id }}/socket/auth.sock]/" -login_page.note.en = 'Dummy users: "jose", "morgan", "björk" and "sabine". Password for all: "tobira".' +login_page.note.default = 'Dummy users: "jose", "morgan", "björk" and "sabine". Password for all: "tobira".' login_page.note.de = 'Testnutzer: "jose", "morgan", "björk" und "sabine". Passwort für alle: "tobira".' trusted_external_key = "tobira" diff --git a/backend/src/config/mod.rs b/backend/src/config/mod.rs index 5e4fdf075..086d186e4 100644 --- a/backend/src/config/mod.rs +++ b/backend/src/config/mod.rs @@ -50,11 +50,12 @@ const TOBIRA_CONFIG_PATH_ENV: &str = "TOBIRA_CONFIG_PATH"; /// units: 'ms', 's', 'min', 'h' and 'd'. /// /// All user-facing texts you can configure here have to be specified per -/// language, with two letter language key. Only English ('en') is required. -/// Take `general.site_title` for example: +/// language, with two letter language key. The special key 'default' is +/// required and used as fallback for languages that are not specified +/// explicitly. Take `general.site_title` for example: /// /// [general] -/// site_title.en = "My university" +/// site_title.default = "My university" /// site_title.de = "Meine Universität" /// #[derive(Debug, confique::Config)] diff --git a/backend/src/config/theme.rs b/backend/src/config/theme.rs index 508e37a0b..fd62fa01b 100644 --- a/backend/src/config/theme.rs +++ b/backend/src/config/theme.rs @@ -1,4 +1,4 @@ -use std::{fmt, path::PathBuf}; +use std::{collections::HashMap, fmt, path::PathBuf}; use serde::{Deserialize, Serialize}; use super::{color::ColorConfig, translated_string::LangKey}; @@ -20,14 +20,14 @@ pub(crate) struct ThemeConfig { /// /// ``` /// logos = [ - /// { path = "logo-large.svg", resolution = [425, 182] }, - /// { path = "logo-large-en.svg", lang = "en", resolution = [425, 182] }, - /// { path = "logo-large-dark.svg", mode = "dark", resolution = [425, 182] }, + /// { path = "logo-wide-light.svg", mode = "light", size = "wide", resolution = [425, 182] }, + /// { path = "logo-wide-dark.svg", mode = "dark", size = "wide", resolution = [425, 182] }, /// { path = "logo-small.svg", size = "narrow", resolution = [212, 182] }, /// ] /// ``` /// /// See the documentation on theming/logos for more info and additional examples! + #[config(validate = validate_logos)] pub(crate) logos: Vec, /// Colors used in the UI. Specified in sRGB. @@ -47,7 +47,7 @@ pub(crate) struct LogoDef { pub(crate) resolution: LogoResolution, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] #[serde(rename_all = "lowercase")] pub(crate) enum LogoSize { Wide, @@ -60,7 +60,7 @@ impl fmt::Display for LogoSize { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] #[serde(rename_all = "lowercase")] pub(crate) enum LogoMode { Light, @@ -146,3 +146,74 @@ impl ThemeConfig { out } } + +fn validate_logos(logos: &Vec) -> Result<(), String> { + #[derive()] + enum LangLogo { + Universal(usize), + LangSpecific(HashMap), + } + + let all_modes = [LogoMode::Light, LogoMode::Dark]; + let all_sizes = [LogoSize::Wide, LogoSize::Narrow]; + + let mut cases = HashMap::new(); + for (i, logo) in logos.iter().enumerate() { + let modes = logo.mode.map(|m| vec![m]).unwrap_or(all_modes.to_vec()); + let sizes = logo.size.map(|s| vec![s]).unwrap_or(all_sizes.to_vec()); + + for &mode in &modes { + for &size in &sizes { + let key = (mode, size); + + if let Some(entry) = cases.get_mut(&key) { + let conflicting = match (entry, &logo.lang) { + (LangLogo::LangSpecific(m), Some(lang)) => m.insert(lang.clone(), i), + (LangLogo::LangSpecific(m), None) => m.values().next().copied(), + (LangLogo::Universal(c), _) => Some(*c), + }; + + if let Some(conflicting) = conflicting { + return Err(format!( + "ambiguous logo definition: \ + entry {i} (path: '{curr_path}') conflicts with \ + entry {prev_index} (path: '{prev_path}'). \ + Both define a {mode} {size} logo, which is only allowed \ + if both have different 'lang' keys! Consider adding 'mode' \ + or 'size' fields to make entries more specific.", + i = i + 1, + prev_index = conflicting + 1, + curr_path = logo.path.display(), + prev_path = logos[conflicting].path.display(), + )); + } + } else { + cases.insert(key, match &logo.lang { + Some(lang) => LangLogo::LangSpecific(HashMap::from([(lang.clone(), i)])), + None => LangLogo::Universal(i), + }); + } + } + } + } + + // Check that all cases are defined + for mode in all_modes { + for size in all_sizes { + match cases.get(&(mode, size)) { + None => return Err(format!( + "incomplete logo configuration: no {mode} {size} logo defined", + )), + Some(LangLogo::LangSpecific(m)) if !m.contains_key(&LangKey::Default) => { + return Err(format!( + "incomplete logo configuration: {mode} {size} logo is \ + missing `lang = '*'` entry", + )); + } + _ => {} + } + } + } + + Ok(()) +} diff --git a/backend/src/config/translated_string.rs b/backend/src/config/translated_string.rs index e9c6513f5..6fcc56c5a 100644 --- a/backend/src/config/translated_string.rs +++ b/backend/src/config/translated_string.rs @@ -9,8 +9,8 @@ use anyhow::{anyhow, Error}; pub(crate) struct TranslatedString(HashMap); impl TranslatedString { - pub(crate) fn en(&self) -> &str { - &self.0[&LangKey::En] + pub(crate) fn default(&self) -> &str { + &self.0[&LangKey::Default] } } @@ -18,8 +18,8 @@ impl TryFrom> for TranslatedString { type Error = Error; fn try_from(map: HashMap) -> Result { - if !map.contains_key(&LangKey::En) { - return Err(anyhow!("Translated string must include 'en' as a language.")); + if !map.contains_key(&LangKey::Default) { + return Err(anyhow!("Translated string must include 'default' entry.")); } Ok(Self(map)) @@ -36,6 +36,8 @@ impl fmt::Debug for TranslatedString { #[derive(Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Debug)] #[serde(rename_all = "lowercase")] pub(crate) enum LangKey { + #[serde(alias = "*")] + Default, En, De, } diff --git a/backend/src/http/assets.rs b/backend/src/http/assets.rs index 7fc87e48d..87d845d92 100644 --- a/backend/src/http/assets.rs +++ b/backend/src/http/assets.rs @@ -73,7 +73,7 @@ impl Assets { builder.add_embedded(INDEX_FILE, &EMBEDS[INDEX_FILE]).with_modifier(deps, { let frontend_config = frontend_config(config); - let html_title = config.general.site_title.en().to_owned(); + let html_title = config.general.site_title.default().to_owned(); let global_style = config.theme.to_css(); let matomo_code = config.matomo.js_code().unwrap_or_default(); diff --git a/docs/docs/setup/config.toml b/docs/docs/setup/config.toml index c4ef247c7..cd071d75b 100644 --- a/docs/docs/setup/config.toml +++ b/docs/docs/setup/config.toml @@ -5,11 +5,12 @@ # units: 'ms', 's', 'min', 'h' and 'd'. # # All user-facing texts you can configure here have to be specified per -# language, with two letter language key. Only English ('en') is required. -# Take `general.site_title` for example: +# language, with two letter language key. The special key 'default' is +# required and used as fallback for languages that are not specified +# explicitly. Take `general.site_title` for example: # # [general] -# site_title.en = "My university" +# site_title.default = "My university" # site_title.de = "Meine Universität" # @@ -539,9 +540,8 @@ # # ``` # logos = [ -# { path = "logo-large.svg", resolution = [425, 182] }, -# { path = "logo-large-en.svg", lang = "en", resolution = [425, 182] }, -# { path = "logo-large-dark.svg", mode = "dark", resolution = [425, 182] }, +# { path = "logo-wide-light.svg", mode = "light", size = "wide", resolution = [425, 182] }, +# { path = "logo-wide-dark.svg", mode = "dark", size = "wide", resolution = [425, 182] }, # { path = "logo-small.svg", size = "narrow", resolution = [212, 182] }, # ] # ``` diff --git a/frontend/src/config.ts b/frontend/src/config.ts index 6e88a757d..45a6106aa 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -101,7 +101,7 @@ type SyncConfig = { type MetadataLabel = "builtin:license" | "builtin:source" | TranslatedString; -export type TranslatedString = { en: string } & Record<"de", string | undefined>; +export type TranslatedString = { default: string } & Record<"en" | "de", string | undefined>; const CONFIG: Config = parseConfig(); export default CONFIG; diff --git a/frontend/src/ui/InitialConsent.tsx b/frontend/src/ui/InitialConsent.tsx index 144c0bd72..0cb65736f 100644 --- a/frontend/src/ui/InitialConsent.tsx +++ b/frontend/src/ui/InitialConsent.tsx @@ -26,7 +26,7 @@ export const InitialConsent: React.FC = ({ consentGiven: initialConsentGi const currentLanguage = i18n.resolvedLanguage ?? "en"; const usedLang = currentLanguage in notNullish(CONFIG.initialConsent).text ? currentLanguage - : "en"; + : "default"; const hash = await calcHash(usedLang); localStorage.setItem(LOCAL_STORAGE_KEY, `${usedLang}:${hash}`); diff --git a/frontend/src/util/index.ts b/frontend/src/util/index.ts index a1b3ea828..579622677 100644 --- a/frontend/src/util/index.ts +++ b/frontend/src/util/index.ts @@ -85,8 +85,8 @@ export const translatedConfig = (s: TranslatedString, i18n: i18n): string => getTranslatedString(s, i18n.resolvedLanguage); export const getTranslatedString = (s: TranslatedString, lang: string | undefined): string => { - const l = lang ?? "en"; - return (l in s ? s[l as keyof TranslatedString] : undefined) ?? s.en; + const l = lang ?? "default"; + return (l in s ? s[l as keyof TranslatedString] : undefined) ?? s.default; }; export const useOnOutsideClick = ( diff --git a/frontend/tests/util/isolation.ts b/frontend/tests/util/isolation.ts index 522d5e71b..e77d8c893 100644 --- a/frontend/tests/util/isolation.ts +++ b/frontend/tests/util/isolation.ts @@ -126,7 +126,7 @@ const tobiraConfig = ({ index, port, dbName, rootPath }: { rootPath: string; }) => ` [general] - site_title.en = "Tobira Videoportal" + site_title.default = "Tobira Videoportal" tobira_url = "http://localhost:${port}" users_searchable = true diff --git a/util/dev-config/config.toml b/util/dev-config/config.toml index 60432a858..73b979296 100644 --- a/util/dev-config/config.toml +++ b/util/dev-config/config.toml @@ -2,7 +2,7 @@ # developer, you are not interested in this file. [general] -site_title.en = "Tobira Videoportal" +site_title.default = "Tobira Videoportal" tobira_url = "http://localhost:8030" users_searchable = true