diff --git a/.deployment/files/known-groups.json b/.deployment/files/known-groups.json index cc90e9e7b..91c22fa98 100644 --- a/.deployment/files/known-groups.json +++ b/.deployment/files/known-groups.json @@ -1,6 +1,6 @@ { - "ROLE_STUDENT": { "label": { "en": "Students", "de": "Studierende" }, "implies": [], "large": true }, - "ROLE_STAFF": { "label": { "en": "Staff", "de": "Angestellte" }, "implies": [], "large": true }, - "ROLE_INSTRUCTOR": { "label": { "en": "Lecturers", "de": "Vortragende" }, "implies": ["ROLE_STAFF"], "large": true }, - "ROLE_TOBIRA_MODERATOR": { "label": { "en": "Moderators", "de": "Moderierende" }, "implies": ["ROLE_STAFF"], "large": false } + "ROLE_STUDENT": { "label": { "default": "Students", "de": "Studierende" }, "implies": [], "large": true }, + "ROLE_STAFF": { "label": { "default": "Staff", "de": "Angestellte" }, "implies": [], "large": true }, + "ROLE_INSTRUCTOR": { "label": { "default": "Lecturers", "de": "Vortragende" }, "implies": ["ROLE_STAFF"], "large": true }, + "ROLE_TOBIRA_MODERATOR": { "label": { "default": "Moderators", "de": "Moderierende" }, "implies": ["ROLE_STAFF"], "large": false } } diff --git a/backend/src/api/model/acl.rs b/backend/src/api/model/acl.rs index e5c9b200d..530d2e0a0 100644 --- a/backend/src/api/model/acl.rs +++ b/backend/src/api/model/acl.rs @@ -2,7 +2,7 @@ use juniper::{GraphQLInputObject, GraphQLObject}; use postgres_types::BorrowToSql; use serde::Serialize; -use crate::{api::{util::TranslatedString, Context, err::ApiResult}, db::util::select}; +use crate::{api::{err::ApiResult, Context}, config::TranslatedString, db::util::select}; @@ -31,9 +31,9 @@ pub(crate) struct AclItem { #[graphql(context = Context)] pub(crate) struct RoleInfo { /// A user-facing label for this role (group or person). If the label does - /// not depend on the language (e.g. a name), `{ "_": "Peter" }` is + /// not depend on the language (e.g. a name), `{ "default": "Peter" }` is /// returned. - pub label: TranslatedString, + pub label: TranslatedString, /// For user roles this is `null`. For groups, it defines a list of other /// group roles that this role implies. I.e. a user with this role always @@ -66,7 +66,7 @@ where known_groups.label, case when users.display_name is null then null - else hstore('_', users.display_name) + else hstore('default', users.display_name) end )", ); diff --git a/backend/src/api/model/known_roles.rs b/backend/src/api/model/known_roles.rs index 314c4ae09..d6ea6732d 100644 --- a/backend/src/api/model/known_roles.rs +++ b/backend/src/api/model/known_roles.rs @@ -2,9 +2,10 @@ use meilisearch_sdk::search::{Selectors, MatchingStrategies}; use serde::Deserialize; use crate::{ - api::{Context, err::ApiResult, util::TranslatedString}, - prelude::*, + api::{err::ApiResult, Context}, + config::TranslatedString, db::util::{impl_from_db, select}, + prelude::*, }; use super::search::{handle_search_result, measure_search_duration, SearchResults, SearchUnavailable}; @@ -16,7 +17,7 @@ use super::search::{handle_search_result, measure_search_duration, SearchResults #[derive(juniper::GraphQLObject)] pub struct KnownGroup { pub(crate) role: String, - pub(crate) label: TranslatedString, + pub(crate) label: TranslatedString, pub(crate) implies: Vec, pub(crate) large: bool, } diff --git a/backend/src/api/util.rs b/backend/src/api/util.rs index e4accd2ab..3e945a1ba 100644 --- a/backend/src/api/util.rs +++ b/backend/src/api/util.rs @@ -1,13 +1,3 @@ -use std::{collections::HashMap, fmt}; - -use bytes::BytesMut; -use fallible_iterator::FallibleIterator; -use juniper::{GraphQLScalar, InputValue, ScalarValue}; -use postgres_types::{FromSql, ToSql}; - -use crate::prelude::*; - - macro_rules! impl_object_with_dummy_field { ($ty:ident) => { @@ -23,67 +13,3 @@ macro_rules! impl_object_with_dummy_field { } pub(crate) use impl_object_with_dummy_field; - - - - -/// A string in different languages. -#[derive(Debug, GraphQLScalar)] -#[graphql( - where(T: AsRef), - parse_token(String), -)] -pub struct TranslatedString(pub(crate) HashMap); - -impl + fmt::Debug> ToSql for TranslatedString { - fn to_sql( - &self, - _: &postgres_types::Type, - out: &mut BytesMut, - ) -> Result> { - let values = self.0.iter().map(|(k, v)| (k.as_ref(), Some(&**v))); - postgres_protocol::types::hstore_to_sql(values, out)?; - Ok(postgres_types::IsNull::No) - } - - fn accepts(ty: &postgres_types::Type) -> bool { - ty.name() == "hstore" - } - - postgres_types::to_sql_checked!(); -} - -impl<'a> FromSql<'a> for TranslatedString { - fn from_sql( - _: &postgres_types::Type, - raw: &'a [u8], - ) -> Result> { - postgres_protocol::types::hstore_from_sql(raw)? - .map(|(k, v)| { - v.map(|v| (k.to_owned(), v.to_owned())) - .ok_or("translated label contained null value in hstore".into()) - }) - .collect() - .map(Self) - } - - fn accepts(ty: &postgres_types::Type) -> bool { - ty.name() == "hstore" - } -} - -impl> TranslatedString { - fn to_output(&self) -> juniper::Value { - self.0.iter() - .map(|(k, v)| (k.as_ref(), juniper::Value::scalar(v.to_owned()))) - .collect::>() - .pipe(juniper::Value::Object) - } - - fn from_input(input: &InputValue) -> Result { - // I did not want to waste time implementing this now, given that we - // likely never use it. - let _ = input; - todo!("TranslatedString cannot be used as input value yet") - } -} diff --git a/backend/src/cmd/known_groups.rs b/backend/src/cmd/known_groups.rs index c4e350528..69cb1074b 100644 --- a/backend/src/cmd/known_groups.rs +++ b/backend/src/cmd/known_groups.rs @@ -5,10 +5,10 @@ use postgres_types::ToSql; use serde_json::json; use crate::{ - prelude::*, + api::model::known_roles::KnownGroup, + config::{Config, TranslatedString}, db, - api::{util::TranslatedString, model::known_roles::KnownGroup}, - config::Config, + prelude::*, }; use super::prompt_for_yes; @@ -25,7 +25,7 @@ pub(crate) enum Args { /// /// { /// "ROLE_LECTURER": { - /// "label": { "en": "Lecturer", "de": "Vortragende" }, + /// "label": { "default": "Lecturer", "de": "Vortragende" }, /// "implies": ["ROLE_STAFF"], /// "large": true /// } @@ -112,10 +112,7 @@ async fn upsert(file: &str, config: &Config, tx: Transaction<'_>) -> Result<()> .context("failed to deserialize")?; // Validate - for (role, info) in &groups { - if info.label.is_empty() { - bail!("No label given for {}", role.0); - } + for role in groups.keys() { if config.auth.is_user_role(&role.0) { bail!("Role '{}' is a user role according to 'auth.user_role_prefixes'. \ This should be added as user, not as group.", role.0); @@ -131,7 +128,7 @@ async fn upsert(file: &str, config: &Config, tx: Transaction<'_>) -> Result<()> label = excluded.label, \ implies = excluded.implies, \ large = excluded.large"; - tx.execute(sql, &[&role, &TranslatedString(info.label), &info.implies, &info.large]).await?; + tx.execute(sql, &[&role, &info.label, &info.implies, &info.large]).await?; } tx.commit().await?; @@ -185,7 +182,7 @@ async fn clear(tx: Transaction<'_>) -> Result<()> { #[derive(serde::Deserialize)] struct GroupData { - label: HashMap, + label: TranslatedString, #[serde(default)] implies: Vec, @@ -193,29 +190,6 @@ struct GroupData { large: bool, } -#[derive(Debug, serde::Deserialize, PartialEq, Eq, Hash)] -#[serde(try_from = "&str")] -struct LangCode([u8; 2]); - -impl<'a> TryFrom<&'a str> for LangCode { - type Error = &'static str; - - fn try_from(v: &'a str) -> std::result::Result { - if !(v.len() == 2 && v.chars().all(|c| c.is_ascii_alphabetic())) { - return Err("invalid language code: two ASCII letters expected"); - } - - let bytes = v.as_bytes(); - Ok(Self([bytes[0], bytes[1]])) - } -} - -impl AsRef for LangCode { - fn as_ref(&self) -> &str { - std::str::from_utf8(&self.0).unwrap() - } -} - #[derive(Debug, serde::Deserialize, PartialEq, Eq, Hash, ToSql)] #[serde(try_from = "String")] #[postgres(transparent)] diff --git a/backend/src/config/general.rs b/backend/src/config/general.rs index 5c5a0b4a1..51e4dc6fe 100644 --- a/backend/src/config/general.rs +++ b/backend/src/config/general.rs @@ -29,9 +29,9 @@ pub(crate) struct GeneralConfig { /// Example: /// /// ``` - /// initial_consent.title.en = "Terms & Conditions" - /// initial_consent.button.en = "Agree" - /// initial_consent.text.en = """ + /// initial_consent.title.default = "Terms & Conditions" + /// initial_consent.button.default = "Agree" + /// initial_consent.text.default = """ /// To use Tobira, you need to agree to our terms and conditions: /// - [Terms](https://www.our-terms.de) /// - [Conditions](https://www.our-conditions.de) @@ -54,8 +54,8 @@ pub(crate) struct GeneralConfig { /// /// ``` /// footer_links = [ - /// { label = { en = "Example 1" }, link = "https://example.com" }, - /// { label = { en = "Example 2" }, link = { en = "https://example.com/en" } }, + /// { label = { default = "Example 1" }, link = "https://example.com" }, + /// { label = { default = "Example 2" }, link = { default = "https://example.com/en" } }, /// "about", /// ] /// ``` @@ -65,8 +65,8 @@ pub(crate) struct GeneralConfig { /// Additional metadata that is shown below a video. Example: /// /// [general.metadata] - /// dcterms.spatial = { en = "Location", de = "Ort" } - /// "http://my.domain/xml/namespace".courseLink = { en = "Course", de = "Kurs"} + /// dcterms.spatial = { default = "Location", de = "Ort" } + /// "http://my.domain/xml/namespace".courseLink = { default = "Course", de = "Kurs"} /// /// As you can see, this is a mapping of a metadata location (the XML /// namespace and the name) to a translated label. For the XML namespace diff --git a/backend/src/config/translated_string.rs b/backend/src/config/translated_string.rs index 6fcc56c5a..2bfc63daf 100644 --- a/backend/src/config/translated_string.rs +++ b/backend/src/config/translated_string.rs @@ -1,17 +1,38 @@ -use std::{collections::HashMap, fmt}; +use std::{collections::HashMap, fmt, str::FromStr}; +use bytes::BytesMut; +use fallible_iterator::FallibleIterator; +use juniper::{GraphQLScalar, InputValue, ScalarValue}; +use postgres_types::{FromSql, ToSql}; use serde::{Deserialize, Serialize}; use anyhow::{anyhow, Error}; -/// A configurable string specified in different languages. Language 'en' always -/// has to be specified. -#[derive(Serialize, Deserialize, Clone)] +use crate::prelude::*; + + +/// A string specified in different languages. Entry 'default' is required. +#[derive(Serialize, Deserialize, Clone, GraphQLScalar)] #[serde(try_from = "HashMap")] -pub(crate) struct TranslatedString(HashMap); +#[graphql(parse_token(String))] +pub(crate) struct TranslatedString(pub(crate) HashMap); impl TranslatedString { pub(crate) fn default(&self) -> &str { &self.0[&LangKey::Default] } + + fn to_output(&self) -> juniper::Value { + self.0.iter() + .map(|(k, v)| (k.as_ref(), juniper::Value::scalar(v.to_owned()))) + .collect::>() + .pipe(juniper::Value::Object) + } + + fn from_input(input: &InputValue) -> Result { + // I did not want to waste time implementing this now, given that we + // likely never use it. + let _ = input; + todo!("TranslatedString cannot be used as input value yet") + } } impl TryFrom> for TranslatedString { @@ -33,7 +54,7 @@ impl fmt::Debug for TranslatedString { } } -#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Debug)] +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Debug, PartialOrd, Ord)] #[serde(rename_all = "lowercase")] pub(crate) enum LangKey { #[serde(alias = "*")] @@ -47,3 +68,61 @@ impl fmt::Display for LangKey { self.serialize(f) } } + +impl AsRef for LangKey { + fn as_ref(&self) -> &str { + match self { + LangKey::Default => "default", + LangKey::En => "en", + LangKey::De => "de", + } + } +} + +impl FromStr for LangKey { + type Err = serde::de::value::Error; + + fn from_str(s: &str) -> std::result::Result { + Self::deserialize(serde::de::value::BorrowedStrDeserializer::new(s)) + } +} + +impl ToSql for TranslatedString { + fn to_sql( + &self, + _: &postgres_types::Type, + out: &mut BytesMut, + ) -> Result> { + let values = self.0.iter().map(|(k, v)| (k.as_ref(), Some(v.as_str()))); + postgres_protocol::types::hstore_to_sql(values, out)?; + Ok(postgres_types::IsNull::No) + } + + fn accepts(ty: &postgres_types::Type) -> bool { + ty.name() == "hstore" + } + + postgres_types::to_sql_checked!(); +} + + + +impl<'a> FromSql<'a> for TranslatedString { + fn from_sql( + _: &postgres_types::Type, + raw: &'a [u8], + ) -> Result> { + postgres_protocol::types::hstore_from_sql(raw)? + .map(|(k, v)| { + let v = v.ok_or("translated label contained null value in hstore")?; + let k = k.parse()?; + Ok((k, v.to_owned())) + }) + .collect() + .map(Self) + } + + fn accepts(ty: &postgres_types::Type) -> bool { + ty.name() == "hstore" + } +} diff --git a/docs/docs/setup/config.toml b/docs/docs/setup/config.toml index be2c4e8e0..c3e0458ea 100644 --- a/docs/docs/setup/config.toml +++ b/docs/docs/setup/config.toml @@ -42,9 +42,9 @@ # Example: # # ``` -# initial_consent.title.en = "Terms & Conditions" -# initial_consent.button.en = "Agree" -# initial_consent.text.en = """ +# initial_consent.title.default = "Terms & Conditions" +# initial_consent.button.default = "Agree" +# initial_consent.text.default = """ # To use Tobira, you need to agree to our terms and conditions: # - [Terms](https://www.our-terms.de) # - [Conditions](https://www.our-conditions.de) @@ -68,8 +68,8 @@ # # ``` # footer_links = [ -# { label = { en = "Example 1" }, link = "https://example.com" }, -# { label = { en = "Example 2" }, link = { en = "https://example.com/en" } }, +# { label = { default = "Example 1" }, link = "https://example.com" }, +# { label = { default = "Example 2" }, link = { default = "https://example.com/en" } }, # "about", # ] # ``` @@ -80,8 +80,8 @@ # Additional metadata that is shown below a video. Example: # # [general.metadata] -# dcterms.spatial = { en = "Location", de = "Ort" } -# "http://my.domain/xml/namespace".courseLink = { en = "Course", de = "Kurs"} +# dcterms.spatial = { default = "Location", de = "Ort" } +# "http://my.domain/xml/namespace".courseLink = { default = "Course", de = "Kurs"} # # As you can see, this is a mapping of a metadata location (the XML # namespace and the name) to a translated label. For the XML namespace diff --git a/frontend/src/schema.graphql b/frontend/src/schema.graphql index c8e79364f..792bacee8 100644 --- a/frontend/src/schema.graphql +++ b/frontend/src/schema.graphql @@ -210,7 +210,7 @@ scalar DateTime "Arbitrary metadata for events/series. Serialized as JSON object." scalar ExtraMetadata -"A string in different languages." +"A string specified in different languages. Entry 'default' is required." scalar TranslatedString "A role being granted permission to perform certain actions." @@ -698,7 +698,7 @@ type RemovedRealm { type RoleInfo { """ A user-facing label for this role (group or person). If the label does - not depend on the language (e.g. a name), `{ "_": "Peter" }` is + not depend on the language (e.g. a name), `{ "default": "Peter" }` is returned. """ label: TranslatedString! diff --git a/frontend/src/ui/Access.tsx b/frontend/src/ui/Access.tsx index 1fe6c1be0..03e8e37c8 100644 --- a/frontend/src/ui/Access.tsx +++ b/frontend/src/ui/Access.tsx @@ -891,7 +891,7 @@ const getLabel = (role: string, label: TranslatedLabel | undefined, i18n: i18n) return i18n.t("acl.admin-user"); } if (label) { - return label[i18n.language] ?? label.en ?? label._; + return label[i18n.language] ?? label.default; } return role; };