diff --git a/README.md b/README.md index de75340..47196a9 100644 --- a/README.md +++ b/README.md @@ -55,43 +55,74 @@ A typical setup could look like this: │ │ ├── control.json │ │ └── state.json │ └── chatterbox@botsin.space -│ ├── config.json -│ ├── control.json -│ └── state.json +│ ├── config.json ... fixed config edited manually +│ ├── messages.json ... custom locale overrides (optional) +│ ├── control.json ... mutable config updated by the group service +│ └── state.json ... group state data +├── locales +│ ├── ... custom locale files, same format like en.json +│ └── ru.json └── groups.json ``` +#### Locales + +English locale ("en") is bundled in the binary. Additional locales can be placed in the `locales` folder. +If an entry is missing, the English version will be used. + +The locale file looks like this (excerpt): + +```json +{ + "group_announcement": "**📢Group announcement**\n{message}", + "ping_response": "pong, this is fedigroups service v{version}" +} +``` + +- Words in curly braces (`{}`) are substitution tokens. These must be preserved in all translations. +- Pay attention to line endings and blank lines (`\n`). Some messages from the locale file are combined to form the + final post, leaving out newlines can result in a mangled output. + +The locale to use is chosen in each group's `config.json`, "en" by default (if not specified). + +Group-specific overrides are also possible: create a file `messages.json` in the group folder +and define messages you wish to change, e.g. the greeting or announcement templates. + #### Common config There is one shared config file: `groups.json` -- If the file does not exist, default settings are used. This is usually good enough. -- This file applies to all groups -- Prior to 0.3, the groups were also configured here. -- Running 0.3+ with the old file will produce an error, you need to update the config before continuing - move groups to subfolders +- If the file does not exist, default settings are used. This is usually sufficient. +- This file applies to all groups and serves as the default config. ``` { + // name of the directory with groups + "groups_dir": "groups", + // name of the directory with locales + "locales_dir": "locales", + // Show warning if locales are missing keys + "validate_locales": true, // Max number of missed notifs to process after connect - max_catchup_notifs: 30, + "max_catchup_notifs": 30, // Max number of missed statuses to process after connect - max_catchup_statuses: 50, + "max_catchup_statuses": 50, // Delay between fetched pages when catching up - delay_fetch_page_s: 0.25, + "delay_fetch_page_s": 0.25, // Delay after sending a status, making a follow or some other action. // Set if there are Throttled errors and you need to slow the service down. - delay_after_post_s: 0.0, + "delay_after_post_s": 0.0, // Delay before trying to re-connect after the server closed the socket - delay_reopen_closed_s: 0.5, + "delay_reopen_closed_s": 0.5, // Delay before trying to re-connect after an error - delay_reopen_error_s: 5.0, + "delay_reopen_error_s": 5.0, // Timeout for a notification/timeline socket to be considered alive. // If nothing arrives in this interval, reopen it. Some servers have a buggy socket // implementation where it stays open but no longer works. - socket_alive_timeout_s: 30.0, + "socket_alive_timeout_s": 30.0, // Time after which a socket is always closed, even if seemingly alive. // This is a work-around for servers that stop sending notifs after a while. - socket_retire_time_s: 120.0, + "socket_retire_time_s": 120.0 } ``` @@ -111,6 +142,7 @@ Only the `config.json` file with credentials is required; the others will be cre - `state.json` - frequently changing state data. The last-seen status/notification timestamps are kept here. State is split from Control to limit the write frequency of the control file. Timestamps can be updated multiple times per minute. +- `messages.json` - optional per-group locale overrides **Do not edit the control and state files while the group service is running, it may overwrite your changes!** @@ -127,9 +159,11 @@ The file formats are quite self-explanatory (again, remove comments before copyi { // Enable or disable the group service "enabled": true, + // Group locale (optional, defaults to "en") + "locale": "en", // Group account name "acct": "group@myserver.xyz", - // Saved mastodon API credentials + // Saved mastodon API credentials, this is created when authenticating the group. "appdata": { "base": "https://myserver.xyz", "client_id": "...", diff --git a/locales/cs.json b/locales/cs.json index 09393fe..f24d6f9 100644 --- a/locales/cs.json +++ b/locales/cs.json @@ -1,4 +1,3 @@ { - "welcome_public": "Ahoj", "ping_response": "pong, toto je fedigroups verze {version}" } diff --git a/src/group_handler/handle_mention.rs b/src/group_handler/handle_mention.rs index d608345..218dae5 100644 --- a/src/group_handler/handle_mention.rs +++ b/src/group_handler/handle_mention.rs @@ -35,7 +35,7 @@ pub struct ProcessMention<'a> { impl<'a> ProcessMention<'a> { fn tr(&self) -> &TranslationTable { - self.cc.tr(self.config.get_locale()) + self.config.tr() } async fn lookup_acct_id(&self, acct: &str, followed: bool) -> Result, GroupError> { diff --git a/src/group_handler/mod.rs b/src/group_handler/mod.rs index 01224a8..662a0cf 100644 --- a/src/group_handler/mod.rs +++ b/src/group_handler/mod.rs @@ -529,7 +529,7 @@ impl GroupHandle { } fn tr(&self) -> &TranslationTable { - self.cc.tr(self.config.get_locale()) + self.config.tr() } async fn handle_new_follow(&mut self, notif_acct: &str, notif_user_id: &str) { diff --git a/src/store/common_config.rs b/src/store/common_config.rs index 8732922..a3cddf8 100644 --- a/src/store/common_config.rs +++ b/src/store/common_config.rs @@ -7,6 +7,7 @@ use crate::tr::TranslationTable; pub struct CommonConfig { pub groups_dir: String, pub locales_dir: String, + pub validate_locales: bool, /// Max number of missed notifs to process after connect pub max_catchup_notifs: usize, /// Max number of missed statuses to process after connect @@ -36,6 +37,7 @@ impl Default for CommonConfig { Self { groups_dir: "groups".to_string(), locales_dir: "locales".to_string(), + validate_locales: true, max_catchup_notifs: 30, max_catchup_statuses: 50, delay_fetch_page_s: 0.25, diff --git a/src/store/group_config.rs b/src/store/group_config.rs index 7846bbe..60dc934 100644 --- a/src/store/group_config.rs +++ b/src/store/group_config.rs @@ -4,7 +4,8 @@ use std::path::{Path, PathBuf}; use elefren::AppData; use crate::error::GroupError; -use crate::store::DEFAULT_LOCALE_NAME; +use crate::store::{DEFAULT_LOCALE_NAME, CommonConfig}; +use crate::tr::TranslationTable; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] @@ -69,6 +70,8 @@ pub struct GroupConfig { control: MutableConfig, /// State config with timestamps and transient data that is changed frequently state: StateConfig, + /// Group-specific translation table; this is a clone of the global table with group-specific overrides applied. + _group_tr: TranslationTable, } impl Default for FixedConfig { @@ -155,16 +158,6 @@ impl_change_tracking!(FixedConfig); impl_change_tracking!(MutableConfig); impl_change_tracking!(StateConfig); -impl Default for GroupConfig { - fn default() -> Self { - Self { - config: Default::default(), - control: Default::default(), - state: Default::default(), - } - } -} - async fn load_or_create_control_file(control_path: impl AsRef) -> Result { let control_path = control_path.as_ref(); let mut dirty = false; @@ -209,7 +202,22 @@ async fn load_or_create_state_file(state_path: impl AsRef) -> Result) -> Result, GroupError> { + let locale_path = locale_path.as_ref(); + if locale_path.is_file() { + let f = tokio::fs::read(&locale_path).await?; + let opt : TranslationTable = serde_json::from_slice(&f)?; + Ok(Some(opt)) + } else { + Ok(None) + } +} + impl GroupConfig { + pub fn tr(&self) -> &TranslationTable { + &self._group_tr + } + pub(crate) fn is_dirty(&self) -> bool { self.config.is_dirty() || self.control.is_dirty() || self.state.is_dirty() } @@ -251,7 +259,7 @@ impl GroupConfig { } /// (re)init using new authorization - pub(crate) async fn from_appdata(acct: String, appdata: AppData, group_dir: PathBuf) -> Result { + pub(crate) async fn initialize_by_appdata(acct: String, appdata: AppData, group_dir: PathBuf) -> Result<(), GroupError> { if !group_dir.is_dir() { debug!("Creating group directory"); tokio::fs::create_dir_all(&group_dir).await?; @@ -298,15 +306,16 @@ impl GroupConfig { /* state */ let state = load_or_create_state_file(state_path).await?; - let g = GroupConfig { config, control, state }; + let g = GroupConfig { config, control, state, _group_tr: TranslationTable::new() }; g.warn_of_bad_config(); - Ok(g) + Ok(()) } - pub(crate) async fn from_dir(group_dir: PathBuf) -> Result { + pub(crate) async fn from_dir(group_dir: PathBuf, cc : &CommonConfig) -> Result { let config_path = group_dir.join("config.json"); let control_path = group_dir.join("control.json"); let state_path = group_dir.join("state.json"); + let locale_path = group_dir.join("messages.json"); // try to reuse content of the files, if present @@ -321,7 +330,15 @@ impl GroupConfig { /* state */ let state = load_or_create_state_file(state_path).await?; - let g = GroupConfig { config, control, state }; + /* translation table */ + let mut tr = cc.tr(&config.locale).clone(); + if let Some(locale_overrides) = load_locale_override_file(locale_path).await? { + for (k, v) in locale_overrides.entries() { + tr.add_translation(k, v); + } + } + + let g = GroupConfig { config, control, state, _group_tr: tr }; g.warn_of_bad_config(); Ok(g) } @@ -369,10 +386,6 @@ impl GroupConfig { &self.config.appdata } - pub(crate) fn get_locale(&self) -> &str { - &self.config.locale - } - pub(crate) fn set_appdata(&mut self, appdata: AppData) { if self.config.appdata != appdata { self.config.mark_dirty(); diff --git a/src/store/mod.rs b/src/store/mod.rs index f087b9f..50de2d0 100644 --- a/src/store/mod.rs +++ b/src/store/mod.rs @@ -127,7 +127,7 @@ impl ConfigStore { let group_dir = self.groups_path.join(&opts.acct); - let _data = GroupConfig::from_appdata(opts.acct.clone(), appdata, group_dir).await?; + GroupConfig::initialize_by_appdata(opts.acct.clone(), appdata, group_dir).await?; // save & persist @@ -151,7 +151,7 @@ impl ConfigStore { pub async fn reauth_group(&self, acct: &str) -> Result<(), GroupError> { let group_dir = self.groups_path.join(&acct); - let mut config = GroupConfig::from_dir(group_dir).await?; + let mut config = GroupConfig::from_dir(group_dir, &self.config).await?; println!("--- Re-authenticating bot user @{} ---", acct); let registration = Registration::new(config.get_appdata().base.to_string()) @@ -233,11 +233,12 @@ impl ConfigStore { for (k, v) in def_tr.entries() { if !tr.translation_exists(k) { - warn!("locale \"{}\" is missing \"{}\", default: {:?}", - locale_name, - k, - def_tr.get_translation_raw(k).unwrap()); - + if self.config.validate_locales { + warn!("locale \"{}\" is missing \"{}\", default: {:?}", + locale_name, + k, + def_tr.get_translation_raw(k).unwrap()); + } tr.add_translation(k, v); } } @@ -261,7 +262,7 @@ impl ConfigStore { .map(|entry_maybe: Result| async { match entry_maybe { Ok(entry) => { - let gc = GroupConfig::from_dir(entry.path()).await.ok()?; + let gc = GroupConfig::from_dir(entry.path(), &config).await.ok()?; if !gc.is_enabled() { debug!("Group @{} is DISABLED", gc.get_acct()); diff --git a/src/tr.rs b/src/tr.rs index b4c6d55..53355d1 100644 --- a/src/tr.rs +++ b/src/tr.rs @@ -23,6 +23,7 @@ impl TranslationTable { self.entries.get(key).map(|s| s.as_str()) } + /// Add or update a translation pub fn add_translation(&mut self, key : impl ToString, subs : impl ToString) { self.entries.insert(key.to_string(), subs.to_string()); }