From bd47a004bf450b6f5fb4453e103606209e315fd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Hru=C5=A1ka?= Date: Wed, 6 Oct 2021 01:09:20 +0200 Subject: [PATCH 1/3] wip translation system --- README.md | 10 ++-- locales/en.json | 18 ++++++ src/group_handler/handle_mention.rs | 62 +++++++-------------- src/group_handler/mod.rs | 29 ++++------ src/main.rs | 19 +++++-- src/store/common_config.rs | 6 ++ src/store/group_config.rs | 7 +++ src/store/mod.rs | 86 ++++++++++++++++++++--------- src/tr.rs | 81 +++++++++++++++++++++++++++ 9 files changed, 221 insertions(+), 97 deletions(-) create mode 100644 locales/en.json create mode 100644 src/tr.rs diff --git a/README.md b/README.md index be3acc7..de75340 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ You can also run the program using Cargo, that is handy for development: `cargo 3. **Make sure you auth as the correct user!** 4. Paste the Oauth2 token you got into the terminal, hit enter. -The program now ends. The credentials are saved in the directory `groups.d/account@server/`, which is created if missing. +The program now ends. The credentials are saved in the directory `groups/account@server/`, which is created if missing. You can repeat this for any number of groups. @@ -49,7 +49,7 @@ In case you need to re-authenticate an existing group, do the same but use `-A` A typical setup could look like this: ``` -├── groups.d +├── groups │ ├── betty@piggo.space │ │ ├── config.json │ │ ├── control.json @@ -97,8 +97,8 @@ There is one shared config file: `groups.json` #### Per-group config -Each group is stored as a sub-directory of `groups.d/`. The sub-directories are normally named after their accounts, -but this is not required. For example, `groups.d/betty@piggo.space/`. +Each group is stored as a sub-directory of `groups/`. The sub-directories are normally named after their accounts, +but this is not required. For example, `groups/betty@piggo.space/`. The group's config and state is split into three files in a way that minimizes the risk of data loss. @@ -180,7 +180,7 @@ Internal use, millisecond timestamps of the last-seen status and notification. ### Running To run the group service, simply run it with no arguments. -It will read the `groups.json` file (if present), find groups in `groups.d/` and start the services for you. +It will read the `groups.json` file (if present), find groups in `groups/` and start the services for you. Note that the control and status files must be writable, they are updated at run-time. Config files can have limited permissions to avoid accidental overwrite. diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 0000000..5f0a3f7 --- /dev/null +++ b/locales/en.json @@ -0,0 +1,18 @@ +{ + "welcome_public": "@{user} Welcome to the group! The group user will now follow you back to complete the sign-up.\nTo share a post, @ the group user or use a group hashtag.\n\nUse /help for more info.", + "welcome_member_only": "@{user} Welcome to the group! This group has posting restricted to members.\nIf you'd like to join, please ask one of the group admins:\n{admins}", + "welcome_join_cmd": "Welcome to the group! The group user will now follow you to complete the sign-up. Make sure you follow back to receive shared posts!\n\nUse /help for more info.", + "welcome_closed": "Sorry, this group is closed to new sign-ups.\nPlease ask one of the group admins to add you:", + "user_list_member": "- {user}", + "user_list_admin": "- {user} [admin]", + "help_admin_commands": "\n**Admin commands:**\n`/ping` - check the group works\n`/add user` - add a member (user@domain)\n`/remove user` - remove a member\n`/add #hashtag` - add a group hashtag\n`/remove #hashtag` - remove a group hashtag\n`/undo` - un-boost a replied-to post, delete an announcement\n`/ban x` - ban a user or server\n`/unban x` - lift a ban\n`/admin user` - grant admin rights\n`/deadmin user` - revoke admin rights\n`/closegroup` - make member-only\n`/opengroup` - make public-access\n`/announce x` - make a public announcement", + "cmd_leave_resp": "You're no longer a group member. Unfollow the group user to stop receiving group messages.", + "member_list_heading": "Group members:", + "admin_list_heading": "Group admins:", + "tag_list_heading": "Group tags:", + "tag_list_entry": "- {tag}", + "cmd_close_resp": "Group changed to member-only", + "cmd_close_resp_noaction": "No action, group is member-only already", + "cmd_open_resp": "Group changed to open-access", + "cmd_open_resp_noaction": "No action, group is open-access already", +} diff --git a/src/group_handler/handle_mention.rs b/src/group_handler/handle_mention.rs index c560f61..b16e0a2 100644 --- a/src/group_handler/handle_mention.rs +++ b/src/group_handler/handle_mention.rs @@ -12,6 +12,7 @@ use crate::error::GroupError; use crate::group_handler::GroupHandle; use crate::store::group_config::GroupConfig; use crate::store::CommonConfig; +use crate::tr::{EMPTY_TRANSLATION_TABLE, TranslationTable}; use crate::utils; use crate::utils::{normalize_acct, LogError}; @@ -33,6 +34,11 @@ pub struct ProcessMention<'a> { } impl<'a> ProcessMention<'a> { + fn tr(&self) -> &TranslationTable { + self.cc.tr.get(self.config.get_locale()) + .unwrap_or(&EMPTY_TRANSLATION_TABLE) + } + async fn lookup_acct_id(&self, acct: &str, followed: bool) -> Result, GroupError> { debug!("Looking up user ID by acct: {}", acct); @@ -85,9 +91,9 @@ impl<'a> ProcessMention<'a> { members.dedup(); for m in members { self.replies.push(if admins.contains(&m) { - format!("- {} [admin]", m) + crate::tr!(self, "user_list_admin", user=m) } else { - format!("- {}", m) + crate::tr!(self, "user_list_member", user=m) }); } } @@ -610,9 +616,9 @@ impl<'a> ProcessMention<'a> { if self.is_admin { if self.config.is_member_only() { self.config.set_member_only(false); - self.add_reply("Group changed to open-access"); + self.add_reply(crate::tr!(self, "cmd_open_resp")); } else { - self.add_reply("No action, group is open-access already"); + self.add_reply(crate::tr!(self, "cmd_open_resp_noaction")); } } else { warn!("Ignore cmd, user not admin"); @@ -623,9 +629,9 @@ impl<'a> ProcessMention<'a> { if self.is_admin { if !self.config.is_member_only() { self.config.set_member_only(true); - self.add_reply("Group changed to member-only"); + self.add_reply(crate::tr!(self, "cmd_close_resp")); } else { - self.add_reply("No action, group is member-only already"); + self.add_reply(crate::tr!(self, "cmd_close_resp_noaction")); } } else { warn!("Ignore cmd, user not admin"); @@ -675,44 +681,28 @@ impl<'a> ProcessMention<'a> { // XXX when used on instance with small character limit, this won't fit! if self.is_admin { - self.add_reply( - "\n\ - **Admin commands:**\n\ - `/ping` - check the group works\n\ - `/add user` - add a member (user@domain)\n\ - `/remove user` - remove a member\n\ - `/add #hashtag` - add a group hashtag\n\ - `/remove #hashtag` - remove a group hashtag\n\ - `/undo` - un-boost a replied-to post, delete an announcement\n\ - `/ban x` - ban a user or server\n\ - `/unban x` - lift a ban\n\ - `/admin user` - grant admin rights\n\ - `/deadmin user` - revoke admin rights\n\ - `/closegroup` - make member-only\n\ - `/opengroup` - make public-access\n\ - `/announce x` - make a public announcement", - ); + self.add_reply(crate::tr!(self, "help_admin_commands")); } } async fn cmd_list_members(&mut self) { self.want_markdown = true; if self.is_admin { - self.add_reply("Group members:"); + self.add_reply(crate::tr!(self, "member_list_heading")); self.append_member_list_to_reply(); } else { - self.add_reply("Group admins:"); + self.add_reply(crate::tr!(self, "admin_list_heading")); self.append_admin_list_to_reply(); } } async fn cmd_list_tags(&mut self) { - self.add_reply("Group tags:"); + self.add_reply(crate::tr!(self, "tag_list_heading")); self.want_markdown = true; let mut tags = self.config.get_tags().collect::>(); tags.sort(); for t in tags { - self.replies.push(format!("- {}", t).to_string()); + self.replies.push(crate::tr!(self, "tag_list_entry", tag=t)); } } @@ -720,9 +710,7 @@ impl<'a> ProcessMention<'a> { if self.config.is_member_or_admin(&self.status_acct) { // admin can leave but that's a bad idea let _ = self.config.set_member(&self.status_acct, false); - self.add_reply( - "You're no longer a group member. Unfollow the group user to stop receiving group messages.", - ); + self.add_reply(crate::tr!(self, "cmd_leave_resp")); } self.unfollow_user_by_id(&self.status_user_id) @@ -740,12 +728,7 @@ impl<'a> ProcessMention<'a> { // Not a member yet if self.config.is_member_only() { // No you can't - self.add_reply( - "\ - Sorry, this group is closed to new sign-ups.\n\ - Please ask one of the group admins to add you:", - ); - + self.add_reply(crate::tr!(self, "welcome_closed")); self.append_admin_list_to_reply(); } else { // Open access, try to follow back @@ -753,12 +736,7 @@ impl<'a> ProcessMention<'a> { // This only fails if the user is banned, but that is filtered above let _ = self.config.set_member(&self.status_acct, true); - self.add_reply( - "\ - Welcome to the group! The group user will now follow you to complete the sign-up. \ - Make sure you follow back to receive shared posts!\n\n\ - Use /help for more info.", - ); + self.add_reply(crate::tr!(self, "welcome_join_cmd")); } } } diff --git a/src/group_handler/mod.rs b/src/group_handler/mod.rs index dd06e97..c80469e 100644 --- a/src/group_handler/mod.rs +++ b/src/group_handler/mod.rs @@ -1,4 +1,5 @@ use std::collections::VecDeque; +use std::ops::Deref; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -19,6 +20,7 @@ use crate::command::StatusCommand; use crate::error::GroupError; use crate::store::CommonConfig; use crate::store::GroupConfig; +use crate::tr::{EMPTY_TRANSLATION_TABLE, TranslationTable}; use crate::utils::{normalize_acct, LogError, VisExt}; mod handle_mention; @@ -46,14 +48,6 @@ impl Default for GroupInternal { } } -// TODO move other options to common_config! - -// // const DELAY_BEFORE_ACTION: Duration = Duration::from_millis(250); -// const DELAY_REOPEN_STREAM: Duration = Duration::from_millis(500); -// // higher because we can expect a lot of non-hashtag statuses here -// const SOCKET_ALIVE_TIMEOUT: Duration = Duration::from_secs(30); -// const SOCKET_RETIRE_TIME: Duration = Duration::from_secs(120); - macro_rules! grp_debug { ($self:ident, $f:expr) => { ::log::debug!(concat!("(@{}) ", $f), $self.config.get_acct()); @@ -531,6 +525,11 @@ impl GroupHandle { res } + fn tr(&self) -> &TranslationTable { + self.cc.tr.get(self.config.get_locale()) + .unwrap_or(&EMPTY_TRANSLATION_TABLE) + } + async fn handle_new_follow(&mut self, notif_acct: &str, notif_user_id: &str) { let mut follow_back = false; let text = if self.config.is_member_only() { @@ -539,24 +538,16 @@ impl GroupHandle { let mut admins = self.config.get_admins().cloned().collect::>(); admins.sort(); - format!( - "\ - @{user} Welcome to the group! This group has posting restricted to members. \ - If you'd like to join, please ask one of the group admins:\n\ - {admins}", + crate::tr!(self, "welcome_member_only", user = notif_acct, - admins = admins.join(", ") + admins = &admins.join(", ") ) } else { follow_back = true; self.config.set_member(notif_acct, true).log_error("Fail add a member"); - format!( - "\ - @{user} Welcome to the group! The group user will now follow you back to complete the sign-up. \ - To share a post, @ the group user or use a group hashtag.\n\n\ - Use /help for more info.", + crate::tr!(self, "welcome_public", user = notif_acct ) }; diff --git a/src/main.rs b/src/main.rs index fb0b457..c3f9541 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,10 +10,12 @@ extern crate serde; #[macro_use] extern crate thiserror; +use std::sync::Arc; use clap::Arg; use log::LevelFilter; use crate::store::{NewGroupOptions, StoreOptions}; +use crate::tr::TranslationTable; use crate::utils::acct_to_server; mod command; @@ -22,6 +24,9 @@ mod group_handler; mod store; mod utils; +#[macro_use] +mod tr; + #[tokio::main] async fn main() -> anyhow::Result<()> { let args = clap::App::new("groups") @@ -90,7 +95,7 @@ async fn main() -> anyhow::Result<()> { .filter_module("mio", LevelFilter::Warn) .init(); - let store = store::ConfigStore::load_from_fs(StoreOptions { + let mut store = store::ConfigStore::load_from_fs(StoreOptions { store_dir: args.value_of("config").unwrap_or(".").to_string(), }) .await?; @@ -104,14 +109,14 @@ async fn main() -> anyhow::Result<()> { } if let Some(server) = acct_to_server(acct) { - let g = store + store .auth_new_group(NewGroupOptions { server: format!("https://{}", server), acct: acct.to_string(), }) .await?; - eprintln!("New group @{} added to config!", g.config.get_acct()); + eprintln!("New group added to config!"); return Ok(()); } else { anyhow::bail!("--auth handle must be username@server, got: \"{}\"", handle); @@ -120,13 +125,17 @@ async fn main() -> anyhow::Result<()> { if let Some(acct) = args.value_of("reauth") { let acct = acct.trim_start_matches('@'); - let _ = store.reauth_group(acct).await?; + store.reauth_group(acct).await?; eprintln!("Group @{} re-authed!", acct); return Ok(()); } + store.find_locales().await; + + return Ok(()); + // Start - let groups = store.spawn_groups().await?; + let groups = Arc::new(store).spawn_groups().await?; let mut handles = vec![]; for mut g in groups { diff --git a/src/store/common_config.rs b/src/store/common_config.rs index a40b28a..26f28a7 100644 --- a/src/store/common_config.rs +++ b/src/store/common_config.rs @@ -1,3 +1,6 @@ +use std::collections::HashMap; +use crate::tr::TranslationTable; + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct CommonConfig { @@ -21,6 +24,8 @@ pub struct CommonConfig { /// 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. pub socket_retire_time_s: f64, + #[serde(skip)] + pub tr : HashMap, } impl Default for CommonConfig { @@ -34,6 +39,7 @@ impl Default for CommonConfig { delay_reopen_error_s: 5.0, socket_alive_timeout_s: 30.0, socket_retire_time_s: 120.0, + tr: Default::default(), } } } diff --git a/src/store/group_config.rs b/src/store/group_config.rs index 4e56968..ec769f7 100644 --- a/src/store/group_config.rs +++ b/src/store/group_config.rs @@ -13,6 +13,8 @@ struct FixedConfig { acct: String, /// elefren data appdata: AppData, + /// configured locale to use + locale: String, /// Server's character limit character_limit: usize, #[serde(skip)] @@ -73,6 +75,7 @@ impl Default for FixedConfig { Self { enabled: true, acct: "".to_string(), + locale: "en".to_string(), appdata: AppData { base: Default::default(), client_id: Default::default(), @@ -365,6 +368,10 @@ 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 71c7ce6..80062a2 100644 --- a/src/store/mod.rs +++ b/src/store/mod.rs @@ -11,12 +11,14 @@ pub mod common_config; pub mod group_config; pub use common_config::CommonConfig; pub use group_config::GroupConfig; +use crate::tr::TranslationTable; #[derive(Debug, Default)] pub struct ConfigStore { store_path: PathBuf, groups_path: PathBuf, - config: Arc, + locales_path: PathBuf, + config: CommonConfig, } #[derive(Debug)] @@ -33,7 +35,7 @@ pub struct StoreOptions { impl ConfigStore { /// Create a new instance of the store. /// If a path is given, it will try to load the content from a file. - pub async fn load_from_fs(options: StoreOptions) -> Result, GroupError> { + pub async fn load_from_fs(options: StoreOptions) -> Result { let given_path: &Path = options.store_dir.as_ref(); let mut common_file: Option = None; @@ -74,21 +76,24 @@ impl ConfigStore { debug!("Using common config:\n{:#?}", config); - let groups_path = base_dir.join("groups.d"); + let groups_path = base_dir.join("groups"); if !groups_path.exists() { debug!("Creating groups directory"); tokio::fs::create_dir_all(&groups_path).await?; } - Ok(Arc::new(Self { + let locales_path = base_dir.join("locales"); + + Ok(Self { store_path: base_dir.to_owned(), groups_path, - config: Arc::new(config), - })) + locales_path, + config, + }) } /// Spawn a new group - pub async fn auth_new_group(self: &Arc, opts: NewGroupOptions) -> Result { + pub async fn auth_new_group(&self, opts: NewGroupOptions) -> Result<(), GroupError> { let registration = Registration::new(&opts.server) .client_name("group-actor") .force_login(true) @@ -106,13 +111,12 @@ impl ConfigStore { // save & persist - let group_account = match client.verify_credentials().await { + match client.verify_credentials().await { Ok(account) => { info!( "Group account verified: @{}, \"{}\"", account.acct, account.display_name ); - account } Err(e) => { error!("Group @{} auth error: {}", opts.acct, e); @@ -120,17 +124,11 @@ impl ConfigStore { } }; - Ok(GroupHandle { - group_account, - client, - config: data, - cc: self.config.clone(), - internal: GroupInternal::default(), - }) + Ok(()) } /// Re-auth an existing group - pub async fn reauth_group(self: &Arc, acct: &str) -> Result { + 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?; @@ -165,20 +163,56 @@ impl ConfigStore { } }; - Ok(GroupHandle { - group_account, - client, - config, - cc: self.config.clone(), - internal: GroupInternal::default(), - }) + Ok(()) + } + + pub async fn find_locales(&mut self) { + if !self.locales_path.is_dir() { + debug!("No locales path set!"); + return; + } + + let entries = match std::fs::read_dir(&self.locales_path) { + Ok(ee) => ee, + Err(e) => { + warn!("Error listing locales"); + return; + } + }; + + for e in entries { + if let Ok(e) = e { + let path = e.path(); + if path.is_file() && path.extension().unwrap_or_default().to_string_lossy() == "json" { + let filename = path.file_name().unwrap_or_default().to_string_lossy(); + debug!("Loading locale {}", filename); + + match tokio::fs::read(&path).await { + Ok(f) => { + if let Ok(tr) = serde_json::from_slice::(&f) { + let locale_name = path.file_stem().unwrap_or_default().to_string_lossy().to_string(); + debug!("Loaded locale: {}", locale_name); + self.config.tr.insert(locale_name, tr); + } else { + error!("Failed to parse locale file {}", path.display()); + } + }, + Err(e) => { + error!("Failed to read locale file {}: {}", path.display(), e); + } + } + } + } + } } /// Spawn existing group using saved creds - pub async fn spawn_groups(self: Arc) -> Result, GroupError> { + pub async fn spawn_groups(self) -> Result, GroupError> { info!("Starting group services for groups in {}", self.groups_path.display()); let dirs = std::fs::read_dir(&self.groups_path)?; + let config = Arc::new(self.config); + // Connect in parallel Ok(futures::stream::iter(dirs) .map(|entry_maybe: Result| async { @@ -213,7 +247,7 @@ impl ConfigStore { group_account: my_account, client, config: gc, - cc: self.config.clone(), + cc: config.clone(), internal: GroupInternal::default(), }) } diff --git a/src/tr.rs b/src/tr.rs new file mode 100644 index 0000000..3313303 --- /dev/null +++ b/src/tr.rs @@ -0,0 +1,81 @@ +//! magic for custom translations and strings + +use std::collections::HashMap; +use once_cell::sync::Lazy; + +#[derive(Debug,Clone,Serialize,Deserialize,Default)] +pub struct TranslationTable { + #[serde(flatten)] + entries: Option>, +} + +pub const EMPTY_TRANSLATION_TABLE : TranslationTable = TranslationTable { + entries: None, +}; + +impl TranslationTable { + pub fn new() -> Self { + Self::default() + } + + pub fn add_translation(&mut self, key : impl ToString, subs : impl ToString) { + if self.entries.is_none() { + self.entries = Some(Default::default()); + } + self.entries.as_mut().unwrap().insert(key.to_string(), subs.to_string()); + } + + pub fn subs(&self, key : &str, substitutions: &[&str]) -> String { + if let Some(ee) = &self.entries { + match ee.get(key) { + Some(s) => { + // TODO optimize + let mut s = s.clone(); + for pair in substitutions.chunks(2) { + if pair.len() != 2 { + continue; + } + s = s.replace(&format!("{{{}}}", pair[0]), pair[1]); + } + s + } + None => key.to_owned() + } + } else { + key.to_owned() + } + } +} + +#[cfg(test)] +mod tests { + use crate::tr::TranslationTable; + + #[test] + fn deser_tr_table() { + let tr : TranslationTable = serde_json::from_str(r#"{"foo":"bar"}"#).unwrap(); + assert_eq!("bar", tr.subs("foo", &[])); + assert_eq!("xxx", tr.subs("xxx", &[])); + } + + + #[test] + fn subs() { + let mut tr = TranslationTable::new(); + tr.add_translation("hello_user", "Hello, {user}!"); + assert_eq!("Hello, James!", tr.subs("hello_user", &["user", "James"])); + } +} + +#[macro_export] +macro_rules! tr { + ($tr_haver:expr, $key:literal) => { + $tr_haver.tr().subs($key, &[]) + }; + + ($tr_haver:expr, $key:literal, $($k:tt=$value:expr),*) => { + $tr_haver.tr().subs($key, &[ + $(stringify!($k), $value),* + ]) + }; +} From 239e15afdd44f5bfe6d56e0a10b86ef71780904c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Hru=C5=A1ka?= Date: Sat, 9 Oct 2021 21:49:26 +0200 Subject: [PATCH 2/3] add missing trans, untested! --- locales/en.json | 68 ++++++++--- src/group_handler/handle_mention.rs | 175 +++++++++++++--------------- src/group_handler/mod.rs | 11 +- 3 files changed, 141 insertions(+), 113 deletions(-) diff --git a/locales/en.json b/locales/en.json index 5f0a3f7..f04253e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1,18 +1,56 @@ { - "welcome_public": "@{user} Welcome to the group! The group user will now follow you back to complete the sign-up.\nTo share a post, @ the group user or use a group hashtag.\n\nUse /help for more info.", - "welcome_member_only": "@{user} Welcome to the group! This group has posting restricted to members.\nIf you'd like to join, please ask one of the group admins:\n{admins}", + "welcome_public": "Welcome to the group! The group user will now follow you back to complete the sign-up.\nTo share a post, @ the group user or use a group hashtag.\n\nUse /help for more info.", + "welcome_member_only": "Welcome to the group! This group has posting restricted to members.\nIf you'd like to join, please ask one of the group admins:\n{admins}", "welcome_join_cmd": "Welcome to the group! The group user will now follow you to complete the sign-up. Make sure you follow back to receive shared posts!\n\nUse /help for more info.", - "welcome_closed": "Sorry, this group is closed to new sign-ups.\nPlease ask one of the group admins to add you:", - "user_list_member": "- {user}", - "user_list_admin": "- {user} [admin]", - "help_admin_commands": "\n**Admin commands:**\n`/ping` - check the group works\n`/add user` - add a member (user@domain)\n`/remove user` - remove a member\n`/add #hashtag` - add a group hashtag\n`/remove #hashtag` - remove a group hashtag\n`/undo` - un-boost a replied-to post, delete an announcement\n`/ban x` - ban a user or server\n`/unban x` - lift a ban\n`/admin user` - grant admin rights\n`/deadmin user` - revoke admin rights\n`/closegroup` - make member-only\n`/opengroup` - make public-access\n`/announce x` - make a public announcement", - "cmd_leave_resp": "You're no longer a group member. Unfollow the group user to stop receiving group messages.", - "member_list_heading": "Group members:", - "admin_list_heading": "Group admins:", - "tag_list_heading": "Group tags:", - "tag_list_entry": "- {tag}", - "cmd_close_resp": "Group changed to member-only", - "cmd_close_resp_noaction": "No action, group is member-only already", - "cmd_open_resp": "Group changed to open-access", - "cmd_open_resp_noaction": "No action, group is open-access already", + "welcome_closed": "Sorry, this group is closed to new sign-ups.\nPlease ask one of the group admins to add you:\n", + "user_list_entry": "- {user}\n", + "user_list_entry_admin": "- {user} [admin]\n", + "help_group_info_closed": "This is a member-only group. {membership}\n", + "help_group_info_open": "This is a public-access group. {membership}\n", + "help_membership_admin": "*You are an admin.*", + "help_membership_member": "*You are a member.*", + "help_membership_guest_closed": "*You are not a member, ask one of the admins to add you.*", + "help_membership_guest_open": "*You are not a member, follow or use /join to join the group.*", + "help_admin_commands": "\n\n**Admin commands:**\n`/ping` - check the group works\n`/members - show group members / admins\n`/add user` - add a member (user@domain)\n`/remove user` - remove a member\n`/add #hashtag` - add a group hashtag\n`/remove #hashtag` - remove a group hashtag\n`/undo` - un-boost a replied-to post, delete an announcement\n`/ban x` - ban a user or server\n`/unban x` - lift a ban\n`/admin user` - grant admin rights\n`/deadmin user` - revoke admin rights\n`/closegroup` - make member-only\n`/opengroup` - make public-access\n`/announce x` - make a public announcement", + "help_basic_commands": "To share a post, @ the group user or use a group hashtag.\n\n**Supported commands:**\n`/boost`, `/b` - boost the replied-to post into the group\n`/ignore`, `/i` - make the group ignore the post\n`/tags` - show group hashtags\n`/join` - (re-)join the group\n`/leave` - leave the group\n`/optout` - forbid sharing of your posts", + "help_member_commands": "\n`/admins` - show group admins\n`/undo` - un-boost your post (use in a reply)", + "cmd_leave_ok": "You're no longer a group member. Unfollow the group user to stop receiving group messages.", + "member_list_heading": "Group members:\n", + "admin_list_heading": "Group admins:\n", + "tag_list_heading": "Group tags:\n", + "tag_list_entry": "- {tag}\n", + "cmd_error": "Command failed: {cause}", + "cmd_close_ok": "Group changed to member-only", + "cmd_close_fail_already": "No action, group is member-only already", + "cmd_open_ok": "Group changed to open-access", + "cmd_open_fail_already": "No action, group is open-access already", + "cmd_optout_fail_admin_cant": "Group admins can't opt-out.", + "cmd_optout_fail_member_cant": "Group members can't opt-out. You have to leave first.", + "cmd_optout_ok": "Your posts will no longer be shared to the group.", + "cmd_optin_fail_admin_cant": "Opt-in has no effect for admins.", + "cmd_optin_fail_member_cant": "Opt-in has no effect for members.", + "cmd_optin_ok": "Your posts can now be shared to the group.", + "cmd_ban_user_ok": "User {user} banned from group!", + "cmd_ban_user_fail_already": "No action, user {user} is already banned", + "cmd_unban_user_ok": "User {user} un-banned!", + "cmd_unban_user_fail_already": "No action, user {user} is not banned", + "cmd_ban_server_ok": "Server {server} banned from group!", + "cmd_ban_server_fail_already": "No action, server {server} already banned", + "cmd_unban_server_ok": "Server {server} un-banned!", + "cmd_unban_server_fail_already": "No action, server {server} is not banned", + "cmd_add_user_ok": "User {user} added to the group!", + "cmd_remove_user_ok": "User {user} removed from the group.", + "cmd_add_tag_ok": "Tag #{tag} added to the group!", + "cmd_add_tag_fail_already": "No action, #{tag} is already a group tag", + "cmd_remove_tag_ok": "Tag #{tag} removed from the group!", + "cmd_remove_tag_fail_already": "No action, #{tag} is not a group tag", + "cmd_admin_ok": "User {user} is now a group admin!", + "cmd_admin_fail_already": "No action, user {user} is a group admin already", + "cmd_unadmin_ok": "User {user} is no longer a group admin!", + "cmd_unadmin_fail_already": "No action, user {user} is not a group admin", + + + "mention_prefix": "@{user} ", + "group_announcement": "**📢Group announcement**\n{message}", + "ping_response": "pong, this is fedigroups service v{version}" } diff --git a/src/group_handler/handle_mention.rs b/src/group_handler/handle_mention.rs index b16e0a2..e5edfdc 100644 --- a/src/group_handler/handle_mention.rs +++ b/src/group_handler/handle_mention.rs @@ -27,8 +27,8 @@ pub struct ProcessMention<'a> { status_user_id: String, can_write: bool, is_admin: bool, - replies: Vec, - announcements: Vec, + replies: String, + announcements: String, do_boost_prev_post: bool, want_markdown: bool, } @@ -47,7 +47,7 @@ impl<'a> ProcessMention<'a> { self.client .search_v2(acct, !followed, Some(SearchType::Accounts), Some(1), followed), ) - .await + .await { Err(_) => { warn!("Account lookup timeout!"); @@ -78,9 +78,11 @@ impl<'a> ProcessMention<'a> { fn append_admin_list_to_reply(&mut self) { let mut admins = self.config.get_admins().collect::>(); admins.sort(); + let mut to_add = String::new(); for a in admins { - self.replies.push(format!("- {}", a)); + to_add.push_str(&crate::tr!(self, "user_list_entry", user = a)); } + self.add_reply(&to_add); } fn append_member_list_to_reply(&mut self) { @@ -89,13 +91,15 @@ impl<'a> ProcessMention<'a> { members.extend(admins.iter()); members.sort(); members.dedup(); + let mut to_add = String::new(); for m in members { - self.replies.push(if admins.contains(&m) { - crate::tr!(self, "user_list_admin", user=m) + to_add.push_str(&if admins.contains(&m) { + crate::tr!(self, "user_list_entry_admin", user=m) } else { - crate::tr!(self, "user_list_member", user=m) + crate::tr!(self, "user_list_entry", user=m) }); } + self.add_reply(&to_add); } async fn follow_user_by_id(&self, id: &str) -> Result<(), GroupError> { @@ -128,8 +132,8 @@ impl<'a> ProcessMention<'a> { cc: &gh.cc, can_write: gh.config.can_write(&status_acct), is_admin: gh.config.is_admin(&status_acct), - replies: vec![], - announcements: vec![], + replies: String::new(), + announcements: String::new(), do_boost_prev_post: false, want_markdown: false, group_acct, @@ -142,16 +146,17 @@ impl<'a> ProcessMention<'a> { } async fn reblog_status(&self) { - self.client.reblog(&self.status.id).await.log_error("Failed to reblog status"); + self.client.reblog(&self.status.id) + .await.log_error("Failed to reblog status"); self.delay_after_post().await; } fn add_reply(&mut self, line: impl AsRef) { - self.replies.push(line.as_ref().trim_matches(' ').to_string()) + self.replies.push_str(line.as_ref()) } fn add_announcement(&mut self, line: impl AsRef) { - self.announcements.push(line.as_ref().trim_matches(' ').to_string()) + self.announcements.push_str(line.as_ref()) } async fn handle(mut self) -> Result<(), GroupError> { @@ -166,6 +171,9 @@ impl<'a> ProcessMention<'a> { } for cmd in commands { + if !self.replies.is_empty() { + self.replies.push('\n'); // make sure there's a newline between batched commands. + } match cmd { StatusCommand::Undo => { self.cmd_undo().await.log_error("Error handling undo cmd"); @@ -213,7 +221,7 @@ impl<'a> ProcessMention<'a> { self.cmd_grant_admin(&u).await.log_error("Error handling grant-admin cmd"); } StatusCommand::RemoveAdmin(u) => { - self.cmd_revoke_member(&u).await.log_error("Error handling grant-admin cmd"); + self.cmd_revoke_admin(&u).await.log_error("Error handling grant-admin cmd"); } StatusCommand::OpenGroup => { self.cmd_open_group().await; @@ -261,26 +269,26 @@ impl<'a> ProcessMention<'a> { } if !self.replies.is_empty() { - let mut msg = self.replies.join("\n"); + let mut msg = std::mem::take(&mut self.replies); debug!("r={}", msg); if self.want_markdown { apply_trailing_hashtag_pleroma_bug_workaround(&mut msg); } - let mention = format!("@{user} ", user = self.status_acct); + let mention = crate::tr!(self, "mention_prefix", user = &self.status_acct); self.send_reply_multipart(mention, msg).await?; } if !self.announcements.is_empty() { - let mut msg = self.announcements.join("\n"); + let mut msg = std::mem::take(&mut self.announcements); debug!("a={}", msg); if self.want_markdown { apply_trailing_hashtag_pleroma_bug_workaround(&mut msg); } - let msg = format!("**📢 Group announcement**\n{msg}", msg = msg); + let msg = crate::tr!(self, "group_announcement", message = &msg); self.send_announcement_multipart(&msg).await?; } @@ -373,29 +381,29 @@ impl<'a> ProcessMention<'a> { async fn cmd_optout(&mut self) { if self.is_admin { - self.add_reply("Group admins can't opt-out."); + self.add_reply(crate::tr!(self, "cmd_optout_fail_admin_cant")); } else if self.config.is_member(&self.status_acct) { - self.add_reply("Group members can't opt-out. You have to leave first."); + self.add_reply(crate::tr!(self, "cmd_optout_fail_member_cant")); } else { self.config.set_optout(&self.status_acct, true); - self.add_reply("Your posts will no longer be shared to the group."); + self.add_reply(crate::tr!(self, "cmd_optout_ok")); } } async fn cmd_optin(&mut self) { if self.is_admin { - self.add_reply("Opt-in has no effect for admins."); + self.add_reply(crate::tr!(self, "cmd_optin_fail_admin_cant")); } else if self.config.is_member(&self.status_acct) { - self.add_reply("Opt-in has no effect for members."); + self.add_reply(crate::tr!(self, "cmd_optin_fail_member_cant")); } else { self.config.set_optout(&self.status_acct, false); - self.add_reply("Your posts can now be shared to the group."); + self.add_reply(crate::tr!(self, "cmd_optin_ok")); } } async fn cmd_undo(&mut self) -> Result<(), GroupError> { if let (Some(ref parent_account_id), Some(ref parent_status_id)) = - (&self.status.in_reply_to_account_id, &self.status.in_reply_to_id) + (&self.status.in_reply_to_account_id, &self.status.in_reply_to_id) { if parent_account_id == &self.group_account.id { // This is a post sent by the group user, likely an announcement. @@ -428,15 +436,15 @@ impl<'a> ProcessMention<'a> { if !self.config.is_banned(&u) { match self.config.ban_user(&u, true) { Ok(_) => { - self.add_reply(format!("User {} banned from group!", u)); + self.add_reply(crate::tr!(self, "cmd_ban_user_ok", user = &u)); self.unfollow_by_acct(&u).await.log_error("Failed to unfollow banned user"); } Err(e) => { - self.add_reply(format!("Failed to ban user {}: {}", u, e)); + self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string())); } } } else { - self.add_reply(format!("No action, user {} is already banned", u)); + self.add_reply(crate::tr!(self, "cmd_ban_user_fail_already", user = &u)); } } else { warn!("Ignore cmd, user not admin"); @@ -450,15 +458,15 @@ impl<'a> ProcessMention<'a> { if self.config.is_banned(&u) { match self.config.ban_user(&u, false) { Ok(_) => { - self.add_reply(format!("User {} un-banned!", u)); + self.add_reply(crate::tr!(self, "cmd_unban_user_ok", user = &u)); // no announcement here } - Err(_) => { - unreachable!() + Err(e) => { + self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string())); } } } else { - self.add_reply(format!("No action, user {} is not banned", u)); + self.add_reply(crate::tr!(self, "cmd_unban_user_fail_already", user = &u)); } } else { warn!("Ignore cmd, user not admin"); @@ -471,14 +479,14 @@ impl<'a> ProcessMention<'a> { if !self.config.is_server_banned(s) { match self.config.ban_server(s, true) { Ok(_) => { - self.add_reply(format!("Server {} banned from group!", s)); + self.add_reply(crate::tr!(self, "cmd_ban_server_ok", server = s)); } Err(e) => { - self.add_reply(format!("Failed to ban server {}: {}", s, e)); + self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string())); } } } else { - self.add_reply(format!("No action, server {} already banned", s)); + self.add_reply(crate::tr!(self, "cmd_ban_server_fail_already", server = s)); } } else { warn!("Ignore cmd, user not admin"); @@ -490,14 +498,14 @@ impl<'a> ProcessMention<'a> { if self.config.is_server_banned(s) { match self.config.ban_server(s, false) { Ok(_) => { - self.add_reply(format!("Server {} un-banned!", s)); + self.add_reply(crate::tr!(self, "cmd_unban_server_ok", server = s)); } Err(e) => { - self.add_reply(format!("Unexpected error occured: {}", e)); + self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string())); } } } else { - self.add_reply(format!("No action, server {} is not banned", s)); + self.add_reply(crate::tr!(self, "cmd_unban_server_fail_already", server = s)); } } else { warn!("Ignore cmd, user not admin"); @@ -510,11 +518,12 @@ impl<'a> ProcessMention<'a> { // Allow even if the user is already a member - that will trigger re-follow match self.config.set_member(&u, true) { Ok(_) => { - self.add_reply(format!("User {} added to the group!", u)); + self.add_reply(crate::tr!(self, "cmd_add_user_ok", user = &u)); + // marked as member, now also follow the user self.follow_by_acct(&u).await.log_error("Failed to follow"); } Err(e) => { - self.add_reply(format!("Failed to add user {} to group: {}", u, e)); + self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string())); } } } else { @@ -528,11 +537,11 @@ impl<'a> ProcessMention<'a> { if self.is_admin { match self.config.set_member(&u, false) { Ok(_) => { - self.add_reply(format!("User {} removed from the group.", u)); + self.add_reply(crate::tr!(self, "cmd_remove_user_ok", user = &u)); self.unfollow_by_acct(&u).await.log_error("Failed to unfollow removed user"); } Err(e) => { - self.add_reply(format!("Unexpected error occured: {}", e)); + self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string())); } } } else { @@ -545,9 +554,9 @@ impl<'a> ProcessMention<'a> { if self.is_admin { if !self.config.is_tag_followed(&tag) { self.config.add_tag(&tag); - self.add_reply(format!("Tag \"{}\" added to the group!", tag)); + self.add_reply(crate::tr!(self, "cmd_add_tag_ok", tag = &tag)); } else { - self.add_reply(format!("No action, \"{}\" is already a group tag", tag)); + self.add_reply(crate::tr!(self, "cmd_add_tag_fail_already", tag = &tag)); } } else { warn!("Ignore cmd, user not admin"); @@ -558,9 +567,9 @@ impl<'a> ProcessMention<'a> { if self.is_admin { if self.config.is_tag_followed(&tag) { self.config.remove_tag(&tag); - self.add_reply(format!("Tag \"{}\" removed from the group!", tag)); + self.add_reply(crate::tr!(self, "cmd_remove_tag_ok", tag = &tag)); } else { - self.add_reply(format!("No action, \"{}\" is not a group tag", tag)); + self.add_reply(crate::tr!(self, "cmd_remove_tag_fail_already", tag = &tag)); } } else { warn!("Ignore cmd, user not admin"); @@ -576,14 +585,14 @@ impl<'a> ProcessMention<'a> { // try to make the config a little more sane, admins should be members let _ = self.config.set_member(&u, true); - self.add_reply(format!("User {} is now a group admin!", u)); + self.add_reply(crate::tr!(self, "cmd_admin_ok", user = &u)); } Err(e) => { - self.add_reply(format!("Failed to make user {} a group admin: {}", u, e)); + self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string())); } } } else { - self.add_reply(format!("No action, \"{}\" is admin already", u)); + self.add_reply(crate::tr!(self, "cmd_admin_fail_already", user = &u)); } } else { warn!("Ignore cmd, user not admin"); @@ -591,20 +600,20 @@ impl<'a> ProcessMention<'a> { Ok(()) } - async fn cmd_revoke_member(&mut self, user: &str) -> Result<(), GroupError> { + async fn cmd_revoke_admin(&mut self, user: &str) -> Result<(), GroupError> { let u = normalize_acct(user, &self.group_acct)?; if self.is_admin { if self.config.is_admin(&u) { match self.config.set_admin(&u, false) { Ok(_) => { - self.add_reply(format!("User {} is no longer a group admin!", u)); + self.add_reply(crate::tr!(self, "cmd_unadmin_ok", user = &u)); } Err(e) => { - self.add_reply(format!("Failed to revoke {}'s group admin: {}", u, e)); + self.add_reply(crate::tr!(self, "cmd_error", cause = &e.to_string())); } } } else { - self.add_reply(format!("No action, user {} is not admin", u)); + self.add_reply(crate::tr!(self, "cmd_unadmin_fail_already", user = &u)); } } else { warn!("Ignore cmd, user not admin"); @@ -618,7 +627,7 @@ impl<'a> ProcessMention<'a> { self.config.set_member_only(false); self.add_reply(crate::tr!(self, "cmd_open_resp")); } else { - self.add_reply(crate::tr!(self, "cmd_open_resp_noaction")); + self.add_reply(crate::tr!(self, "cmd_open_resp_already")); } } else { warn!("Ignore cmd, user not admin"); @@ -631,7 +640,7 @@ impl<'a> ProcessMention<'a> { self.config.set_member_only(true); self.add_reply(crate::tr!(self, "cmd_close_resp")); } else { - self.add_reply(crate::tr!(self, "cmd_close_resp_noaction")); + self.add_reply(crate::tr!(self, "cmd_close_resp_already")); } } else { warn!("Ignore cmd, user not admin"); @@ -642,43 +651,26 @@ impl<'a> ProcessMention<'a> { self.want_markdown = true; let membership_line = if self.is_admin { - "*You are an admin.*" + crate::tr!(self, "help_membership_admin") } else if self.config.is_member(&self.status_acct) { - "*You are a member.*" + crate::tr!(self, "help_membership_member") } else if self.config.is_member_only() { - "*You are not a member, ask one of the admins to add you.*" + crate::tr!(self, "help_membership_guest_closed") } else { - "*You are not a member, follow or use /join to join the group.*" + crate::tr!(self, "help_membership_guest_open") }; if self.config.is_member_only() { - self.add_reply(format!("This is a member-only group. {}", membership_line)); + self.add_reply(crate::tr!(self, "help_group_info_closed", membership = &membership_line)); } else { - self.add_reply(format!("This is a public-access group. {}", membership_line)); - } - - self.add_reply( - "\ - To share a post, @ the group user or use a group hashtag.\n\ - \n\ - **Supported commands:**\n\ - `/boost`, `/b` - boost the replied-to post into the group\n\ - `/ignore`, `/i` - make the group ignore the post\n\ - `/tags` - show group hashtags\n\ - `/join` - (re-)join the group\n\ - `/leave` - leave the group\n\ - `/optout` - forbid sharing of your posts", - ); - - if self.is_admin { - self.add_reply("`/members`, `/who` - show group members / admins"); - // undo is listed as an admin command - } else { - self.add_reply("`/admins` - show group admins"); - self.add_reply("`/undo` - un-boost your post (use in a reply)"); + self.add_reply(crate::tr!(self, "help_group_info_open", membership = &membership_line)); } - // XXX when used on instance with small character limit, this won't fit! + self.add_reply(crate::tr!(self, "help_basic_commands")); + + if !self.is_admin { + self.add_reply(crate::tr!(self, "help_member_commands")); + } if self.is_admin { self.add_reply(crate::tr!(self, "help_admin_commands")); @@ -701,9 +693,13 @@ impl<'a> ProcessMention<'a> { self.want_markdown = true; let mut tags = self.config.get_tags().collect::>(); tags.sort(); + + let mut to_add = String::new(); + for t in tags { - self.replies.push(crate::tr!(self, "tag_list_entry", tag=t)); + to_add.push_str(&crate::tr!(self, "tag_list_entry", tag=t)); } + self.add_reply(to_add); } async fn cmd_leave(&mut self) { @@ -742,10 +738,7 @@ impl<'a> ProcessMention<'a> { } async fn cmd_ping(&mut self) { - self.add_reply(format!( - "pong, this is fedigroups service v{}", - env!("CARGO_PKG_VERSION") - )); + self.add_reply(crate::tr!(self, "ping_response", version = env!("CARGO_PKG_VERSION"))); } async fn unfollow_by_acct(&self, acct: &str) -> Result<(), GroupError> { @@ -895,21 +888,21 @@ mod test { let to_split = "foo\nbar\nbaz"; let parts = super::smart_split(to_split, None, 1000); - assert_eq!(vec!["foo\nbar\nbaz".to_string(),], parts); + assert_eq!(vec!["foo\nbar\nbaz".to_string()], parts); } #[test] fn test_smart_split_nosplit_prefix() { let to_split = "foo\nbar\nbaz"; let parts = super::smart_split(to_split, Some("PREFIX".to_string()), 1000); - assert_eq!(vec!["PREFIXfoo\nbar\nbaz".to_string(),], parts); + assert_eq!(vec!["PREFIXfoo\nbar\nbaz".to_string()], parts); } #[test] fn test_smart_split_prefix_each() { let to_split = "1234\n56\n7"; let parts = super::smart_split(to_split, Some("PREFIX".to_string()), 10); - assert_eq!(vec!["PREFIX1234".to_string(), "PREFIX56\n7".to_string(),], parts); + assert_eq!(vec!["PREFIX1234".to_string(), "PREFIX56\n7".to_string()], parts); } #[test] @@ -951,7 +944,7 @@ mod test { let to_split = "one two threefourfive six"; let parts = super::smart_split(to_split, None, 10); assert_eq!( - vec!["one two".to_string(), "threefourf".to_string(), "ive six".to_string(),], + vec!["one two".to_string(), "threefourf".to_string(), "ive six".to_string()], parts ); } diff --git a/src/group_handler/mod.rs b/src/group_handler/mod.rs index c80469e..f9ea4e3 100644 --- a/src/group_handler/mod.rs +++ b/src/group_handler/mod.rs @@ -538,18 +538,15 @@ impl GroupHandle { let mut admins = self.config.get_admins().cloned().collect::>(); admins.sort(); - crate::tr!(self, "welcome_member_only", - user = notif_acct, - admins = &admins.join(", ") - ) + crate::tr!(self, "mention_prefix", user = notif_acct) + + &crate::tr!(self, "welcome_member_only", admins = &admins.join(", ")) } else { follow_back = true; self.config.set_member(notif_acct, true).log_error("Fail add a member"); - crate::tr!(self, "welcome_public", - user = notif_acct - ) + crate::tr!(self, "mention_prefix", user = notif_acct) + + &crate::tr!(self, "welcome_public") }; let post = StatusBuilder::new() From e76da157b3895a8928671651d7d8cfe6ffbdae2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Hru=C5=A1ka?= Date: Sun, 10 Oct 2021 15:38:56 +0200 Subject: [PATCH 3/3] fixes for locales, improvements, more logging, stub cs translation --- Cargo.lock | 2 +- Cargo.toml | 2 +- locales/cs.json | 4 ++ locales/en.json | 1 - src/group_handler/handle_mention.rs | 5 +-- src/group_handler/mod.rs | 46 ++++++++++---------- src/main.rs | 6 +-- src/store/common_config.rs | 14 ++++++ src/store/group_config.rs | 3 +- src/store/mod.rs | 67 +++++++++++++++++++++++------ src/tr.rs | 50 ++++++++++----------- 11 files changed, 130 insertions(+), 70 deletions(-) create mode 100644 locales/cs.json diff --git a/Cargo.lock b/Cargo.lock index a7d22d5..8e8767c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -328,7 +328,7 @@ checksum = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed" [[package]] name = "fedigroups" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 1444b91..33aeffb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fedigroups" -version = "0.3.0" +version = "0.4.0" authors = ["Ondřej Hruška "] edition = "2018" publish = false diff --git a/locales/cs.json b/locales/cs.json new file mode 100644 index 0000000..09393fe --- /dev/null +++ b/locales/cs.json @@ -0,0 +1,4 @@ +{ + "welcome_public": "Ahoj", + "ping_response": "pong, toto je fedigroups verze {version}" +} diff --git a/locales/en.json b/locales/en.json index f04253e..5ed832e 100644 --- a/locales/en.json +++ b/locales/en.json @@ -49,7 +49,6 @@ "cmd_unadmin_ok": "User {user} is no longer a group admin!", "cmd_unadmin_fail_already": "No action, user {user} is not a group admin", - "mention_prefix": "@{user} ", "group_announcement": "**📢Group announcement**\n{message}", "ping_response": "pong, this is fedigroups service v{version}" diff --git a/src/group_handler/handle_mention.rs b/src/group_handler/handle_mention.rs index e5edfdc..d608345 100644 --- a/src/group_handler/handle_mention.rs +++ b/src/group_handler/handle_mention.rs @@ -12,7 +12,7 @@ use crate::error::GroupError; use crate::group_handler::GroupHandle; use crate::store::group_config::GroupConfig; use crate::store::CommonConfig; -use crate::tr::{EMPTY_TRANSLATION_TABLE, TranslationTable}; +use crate::tr::TranslationTable; use crate::utils; use crate::utils::{normalize_acct, LogError}; @@ -35,8 +35,7 @@ pub struct ProcessMention<'a> { impl<'a> ProcessMention<'a> { fn tr(&self) -> &TranslationTable { - self.cc.tr.get(self.config.get_locale()) - .unwrap_or(&EMPTY_TRANSLATION_TABLE) + self.cc.tr(self.config.get_locale()) } 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 f9ea4e3..01224a8 100644 --- a/src/group_handler/mod.rs +++ b/src/group_handler/mod.rs @@ -1,5 +1,4 @@ use std::collections::VecDeque; -use std::ops::Deref; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -20,7 +19,7 @@ use crate::command::StatusCommand; use crate::error::GroupError; use crate::store::CommonConfig; use crate::store::GroupConfig; -use crate::tr::{EMPTY_TRANSLATION_TABLE, TranslationTable}; +use crate::tr::TranslationTable; use crate::utils::{normalize_acct, LogError, VisExt}; mod handle_mention; @@ -162,27 +161,31 @@ impl GroupHandle { let socket_open_time = Instant::now(); let mut last_rx = Instant::now(); - match self.catch_up_with_missed_notifications().await { - Ok(true) => { - grp_debug!(self, "Some missed notifs handled"); - } - Ok(false) => { - grp_debug!(self, "No notifs missed"); - } - Err(e) => { - grp_error!(self, "Failed to handle missed notifs: {}", e); + if self.cc.max_catchup_notifs > 0 { + match self.catch_up_with_missed_notifications().await { + Ok(true) => { + grp_debug!(self, "Some missed notifs handled"); + } + Ok(false) => { + grp_debug!(self, "No notifs missed"); + } + Err(e) => { + grp_error!(self, "Failed to handle missed notifs: {}", e); + } } } - match self.catch_up_with_missed_statuses().await { - Ok(true) => { - grp_debug!(self, "Some missed statuses handled"); - } - Ok(false) => { - grp_debug!(self, "No statuses missed"); - } - Err(e) => { - grp_error!(self, "Failed to handle missed statuses: {}", e); + if self.cc.max_catchup_statuses > 0 { + match self.catch_up_with_missed_statuses().await { + Ok(true) => { + grp_debug!(self, "Some missed statuses handled"); + } + Ok(false) => { + grp_debug!(self, "No statuses missed"); + } + Err(e) => { + grp_error!(self, "Failed to handle missed statuses: {}", e); + } } } @@ -526,8 +529,7 @@ impl GroupHandle { } fn tr(&self) -> &TranslationTable { - self.cc.tr.get(self.config.get_locale()) - .unwrap_or(&EMPTY_TRANSLATION_TABLE) + self.cc.tr(self.config.get_locale()) } async fn handle_new_follow(&mut self, notif_acct: &str, notif_user_id: &str) { diff --git a/src/main.rs b/src/main.rs index c3f9541..f139a12 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,12 +10,10 @@ extern crate serde; #[macro_use] extern crate thiserror; -use std::sync::Arc; use clap::Arg; use log::LevelFilter; use crate::store::{NewGroupOptions, StoreOptions}; -use crate::tr::TranslationTable; use crate::utils::acct_to_server; mod command; @@ -132,10 +130,8 @@ async fn main() -> anyhow::Result<()> { store.find_locales().await; - return Ok(()); - // Start - let groups = Arc::new(store).spawn_groups().await?; + let groups = store.spawn_groups().await?; let mut handles = vec![]; for mut g in groups { diff --git a/src/store/common_config.rs b/src/store/common_config.rs index 26f28a7..8732922 100644 --- a/src/store/common_config.rs +++ b/src/store/common_config.rs @@ -1,9 +1,12 @@ use std::collections::HashMap; +use crate::store::DEFAULT_LOCALE_NAME; use crate::tr::TranslationTable; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct CommonConfig { + pub groups_dir: String, + pub locales_dir: String, /// Max number of missed notifs to process after connect pub max_catchup_notifs: usize, /// Max number of missed statuses to process after connect @@ -31,6 +34,8 @@ pub struct CommonConfig { impl Default for CommonConfig { fn default() -> Self { Self { + groups_dir: "groups".to_string(), + locales_dir: "locales".to_string(), max_catchup_notifs: 30, max_catchup_statuses: 50, delay_fetch_page_s: 0.25, @@ -43,3 +48,12 @@ impl Default for CommonConfig { } } } + +impl CommonConfig { + pub fn tr(&self, lang : &str) -> &TranslationTable { + match self.tr.get(lang) { + Some(tr) => tr, + None => self.tr.get(DEFAULT_LOCALE_NAME).expect("default locale is not loaded") + } + } +} diff --git a/src/store/group_config.rs b/src/store/group_config.rs index ec769f7..7846bbe 100644 --- a/src/store/group_config.rs +++ b/src/store/group_config.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use elefren::AppData; use crate::error::GroupError; +use crate::store::DEFAULT_LOCALE_NAME; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] @@ -75,7 +76,7 @@ impl Default for FixedConfig { Self { enabled: true, acct: "".to_string(), - locale: "en".to_string(), + locale: DEFAULT_LOCALE_NAME.to_string(), appdata: AppData { base: Default::default(), client_id: Default::default(), diff --git a/src/store/mod.rs b/src/store/mod.rs index 80062a2..f087b9f 100644 --- a/src/store/mod.rs +++ b/src/store/mod.rs @@ -32,6 +32,9 @@ pub struct StoreOptions { pub store_dir: String, } +const DEFAULT_LOCALE_NAME : &str = "en"; +const DEFAULT_LOCALE_JSON : &str = include_str!("../../locales/en.json"); + impl ConfigStore { /// Create a new instance of the store. /// If a path is given, it will try to load the content from a file. @@ -76,13 +79,30 @@ impl ConfigStore { debug!("Using common config:\n{:#?}", config); - let groups_path = base_dir.join("groups"); + let groups_path = if config.groups_dir.starts_with('/') { + PathBuf::from(&config.groups_dir) + } else { + base_dir.join(&config.groups_dir) + }; + if !groups_path.exists() { debug!("Creating groups directory"); tokio::fs::create_dir_all(&groups_path).await?; } - let locales_path = base_dir.join("locales"); + let locales_path = if config.locales_dir.starts_with('/') { + PathBuf::from(&config.locales_dir) + } else { + base_dir.join(&config.locales_dir) + }; + + // warn, this is usually not a good idea beside for testing + if config.max_catchup_notifs == 0 { + warn!("Missed notifications catch-up is disabled!"); + } + if config.max_catchup_statuses == 0 { + warn!("Missed statuses catch-up is disabled!"); + } Ok(Self { store_path: base_dir.to_owned(), @@ -107,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?; + let _data = GroupConfig::from_appdata(opts.acct.clone(), appdata, group_dir).await?; // save & persist @@ -149,7 +169,7 @@ impl ConfigStore { config.set_appdata(appdata); config.save_if_needed(true).await?; - let group_account = match client.verify_credentials().await { + let _group_account = match client.verify_credentials().await { Ok(account) => { info!( "Group account verified: @{}, \"{}\"", @@ -167,6 +187,9 @@ impl ConfigStore { } pub async fn find_locales(&mut self) { + // Load the default locale, it will be used as fallback to fill-in missing keys + self.load_locale(DEFAULT_LOCALE_NAME, DEFAULT_LOCALE_JSON, true); + if !self.locales_path.is_dir() { debug!("No locales path set!"); return; @@ -175,7 +198,7 @@ impl ConfigStore { let entries = match std::fs::read_dir(&self.locales_path) { Ok(ee) => ee, Err(e) => { - warn!("Error listing locales"); + warn!("Error listing locales: {}", e); return; } }; @@ -189,13 +212,8 @@ impl ConfigStore { match tokio::fs::read(&path).await { Ok(f) => { - if let Ok(tr) = serde_json::from_slice::(&f) { - let locale_name = path.file_stem().unwrap_or_default().to_string_lossy().to_string(); - debug!("Loaded locale: {}", locale_name); - self.config.tr.insert(locale_name, tr); - } else { - error!("Failed to parse locale file {}", path.display()); - } + let locale_name = path.file_stem().unwrap_or_default().to_string_lossy(); + self.load_locale(&locale_name, &String::from_utf8_lossy(&f), false); }, Err(e) => { error!("Failed to read locale file {}: {}", path.display(), e); @@ -206,6 +224,31 @@ impl ConfigStore { } } + fn load_locale(&mut self, locale_name: &str, locale_json: &str, is_default: bool) { + if let Ok(mut tr) = serde_json::from_str::(locale_json) { + debug!("Loaded locale: {}", locale_name); + + if !is_default { + let def_tr = self.config.tr.get(DEFAULT_LOCALE_NAME).expect("Default locale not loaded!"); + + 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()); + + tr.add_translation(k, v); + } + } + } + + self.config.tr.insert(locale_name.to_owned(), tr); + } else { + error!("Failed to parse locale {}", locale_name); + } + } + /// Spawn existing group using saved creds pub async fn spawn_groups(self) -> Result, GroupError> { info!("Starting group services for groups in {}", self.groups_path.display()); diff --git a/src/tr.rs b/src/tr.rs index 3313303..b4c6d55 100644 --- a/src/tr.rs +++ b/src/tr.rs @@ -1,48 +1,50 @@ //! magic for custom translations and strings use std::collections::HashMap; -use once_cell::sync::Lazy; #[derive(Debug,Clone,Serialize,Deserialize,Default)] pub struct TranslationTable { #[serde(flatten)] - entries: Option>, + entries: HashMap, } -pub const EMPTY_TRANSLATION_TABLE : TranslationTable = TranslationTable { - entries: None, -}; - impl TranslationTable { + #[allow(unused)] pub fn new() -> Self { Self::default() } + /// Iterate all entries + pub fn entries(&self) -> impl Iterator { + self.entries.iter() + } + + pub fn get_translation_raw(&self, key : &str) -> Option<&str> { + self.entries.get(key).map(|s| s.as_str()) + } + pub fn add_translation(&mut self, key : impl ToString, subs : impl ToString) { - if self.entries.is_none() { - self.entries = Some(Default::default()); - } - self.entries.as_mut().unwrap().insert(key.to_string(), subs.to_string()); + self.entries.insert(key.to_string(), subs.to_string()); + } + + pub fn translation_exists(&self, key : &str) -> bool { + self.entries.contains_key(key) } pub fn subs(&self, key : &str, substitutions: &[&str]) -> String { - if let Some(ee) = &self.entries { - match ee.get(key) { - Some(s) => { - // TODO optimize - let mut s = s.clone(); - for pair in substitutions.chunks(2) { - if pair.len() != 2 { - continue; - } - s = s.replace(&format!("{{{}}}", pair[0]), pair[1]); + match self.entries.get(key) { + Some(s) => { + // TODO optimize + let mut s = s.clone(); + for pair in substitutions.chunks(2) { + if pair.len() != 2 { + continue; } - s + s = s.replace(&format!("{{{}}}", pair[0]), pair[1]); } - None => key.to_owned() + s } - } else { - key.to_owned() + None => key.to_owned() } } }