i18n: make localization context non-global
- Simplify Localization{Context,Manager} to just Localization
- Fixed a bunch of lifetime issueo
- Removed all Arcs and Locks
- Removed globals
* widgets now need access to &mut Localization for i18n
Signed-off-by: William Casarin <jb55@jb55.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
use crate::account::FALLBACK_PUBKEY;
|
||||
use crate::i18n::{LocalizationContext, LocalizationManager};
|
||||
use crate::i18n::Localization;
|
||||
use crate::persist::{AppSizeHandler, ZoomHandler};
|
||||
use crate::wallet::GlobalWallet;
|
||||
use crate::zaps::Zaps;
|
||||
@@ -18,7 +18,6 @@ use std::cell::RefCell;
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info};
|
||||
|
||||
pub enum AppAction {
|
||||
@@ -50,7 +49,7 @@ pub struct Notedeck {
|
||||
zaps: Zaps,
|
||||
frame_history: FrameHistory,
|
||||
job_pool: JobPool,
|
||||
i18n: LocalizationContext,
|
||||
i18n: Localization,
|
||||
}
|
||||
|
||||
/// Our chrome, which is basically nothing
|
||||
@@ -231,19 +230,10 @@ impl Notedeck {
|
||||
let job_pool = JobPool::default();
|
||||
|
||||
// Initialize localization
|
||||
let i18n_resource_dir = Path::new("assets/translations");
|
||||
let localization_manager = Arc::new(
|
||||
LocalizationManager::new(i18n_resource_dir).unwrap_or_else(|e| {
|
||||
error!("Failed to initialize localization manager: {}", e);
|
||||
// Create a fallback manager with a temporary directory
|
||||
LocalizationManager::new(&std::env::temp_dir().join("notedeck_i18n_fallback"))
|
||||
.expect("Failed to create fallback localization manager")
|
||||
}),
|
||||
);
|
||||
let i18n = LocalizationContext::new(localization_manager);
|
||||
let i18n = Localization::new();
|
||||
|
||||
// Initialize global i18n context
|
||||
crate::i18n::init_global_i18n(i18n.clone());
|
||||
//crate::i18n::init_global_i18n(i18n.clone());
|
||||
|
||||
Self {
|
||||
ndb,
|
||||
@@ -289,7 +279,7 @@ impl Notedeck {
|
||||
zaps: &mut self.zaps,
|
||||
frame_history: &mut self.frame_history,
|
||||
job_pool: &mut self.job_pool,
|
||||
i18n: &self.i18n,
|
||||
i18n: &mut self.i18n,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::{
|
||||
account::accounts::Accounts, frame_history::FrameHistory, i18n::LocalizationContext,
|
||||
account::accounts::Accounts, frame_history::FrameHistory, i18n::Localization,
|
||||
wallet::GlobalWallet, zaps::Zaps, Args, DataPath, Images, JobPool, NoteCache, ThemeHandler,
|
||||
UnknownIds,
|
||||
};
|
||||
@@ -25,5 +25,5 @@ pub struct AppContext<'a> {
|
||||
pub zaps: &'a mut Zaps,
|
||||
pub frame_history: &'a mut FrameHistory,
|
||||
pub job_pool: &'a mut JobPool,
|
||||
pub i18n: &'a LocalizationContext,
|
||||
pub i18n: &'a mut Localization,
|
||||
}
|
||||
|
||||
24
crates/notedeck/src/i18n/error.rs
Normal file
24
crates/notedeck/src/i18n/error.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use super::IntlKeyBuf;
|
||||
use unic_langid::LanguageIdentifier;
|
||||
|
||||
/// App related errors
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum IntlError {
|
||||
#[error("message not found: {0}")]
|
||||
NotFound(IntlKeyBuf),
|
||||
|
||||
#[error("message has no value: {0}")]
|
||||
NoValue(IntlKeyBuf),
|
||||
|
||||
#[error("Locale({0}) parse error: {1}")]
|
||||
LocaleParse(LanguageIdentifier, String),
|
||||
|
||||
#[error("locale not available: {0}")]
|
||||
LocaleNotAvailable(LanguageIdentifier),
|
||||
|
||||
#[error("FTL for '{0}' is not available")]
|
||||
NoFtl(LanguageIdentifier),
|
||||
|
||||
#[error("Bundle for '{0}' is not available")]
|
||||
NoBundle(LanguageIdentifier),
|
||||
}
|
||||
47
crates/notedeck/src/i18n/key.rs
Normal file
47
crates/notedeck/src/i18n/key.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use std::fmt;
|
||||
|
||||
/// An owned key used to lookup i18n translations. Mostly used for errors
|
||||
#[derive(Eq, PartialEq, Clone, Debug)]
|
||||
pub struct IntlKeyBuf(String);
|
||||
|
||||
/// A key used to lookup i18n translations
|
||||
#[derive(Eq, PartialEq, Clone, Copy, Debug)]
|
||||
pub struct IntlKey<'a>(&'a str);
|
||||
|
||||
impl fmt::Display for IntlKey<'_> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
// Use `self.number` to refer to each positional data point.
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for IntlKeyBuf {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
// Use `self.number` to refer to each positional data point.
|
||||
write!(f, "{}", &self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntlKeyBuf {
|
||||
pub fn new(string: impl Into<String>) -> Self {
|
||||
IntlKeyBuf(string.into())
|
||||
}
|
||||
|
||||
pub fn borrow<'a>(&'a self) -> IntlKey<'a> {
|
||||
IntlKey::new(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> IntlKey<'a> {
|
||||
pub fn new(string: &'a str) -> IntlKey<'a> {
|
||||
IntlKey(string)
|
||||
}
|
||||
|
||||
pub fn to_owned(&self) -> IntlKeyBuf {
|
||||
IntlKeyBuf::new(self.0)
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'a str {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,104 +4,22 @@
|
||||
//! It handles loading translation files, managing locales, and providing
|
||||
//! localized strings throughout the application.
|
||||
|
||||
mod error;
|
||||
mod key;
|
||||
pub mod manager;
|
||||
|
||||
pub use error::IntlError;
|
||||
pub use key::{IntlKey, IntlKeyBuf};
|
||||
|
||||
pub use manager::CacheStats;
|
||||
pub use manager::LocalizationContext;
|
||||
pub use manager::LocalizationManager;
|
||||
pub use manager::Localization;
|
||||
pub use manager::StringCacheResult;
|
||||
|
||||
/// Re-export commonly used types for convenience
|
||||
pub use fluent::FluentArgs;
|
||||
pub use fluent::FluentValue;
|
||||
pub use unic_langid::LanguageIdentifier;
|
||||
|
||||
use md5;
|
||||
use once_cell::sync::OnceCell;
|
||||
use regex::Regex;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use tracing::info;
|
||||
|
||||
/// Global localization manager for easy access from anywhere
|
||||
static GLOBAL_I18N: OnceCell<Arc<LocalizationManager>> = OnceCell::new();
|
||||
|
||||
/// Cache for normalized FTL keys to avoid repeated normalization
|
||||
static NORMALIZED_KEY_CACHE: OnceCell<Mutex<HashMap<String, String>>> = OnceCell::new();
|
||||
|
||||
/// Initialize the global localization context
|
||||
pub fn init_global_i18n(context: LocalizationContext) {
|
||||
info!("Initializing global i18n context");
|
||||
let _ = GLOBAL_I18N.set(context.manager().clone());
|
||||
|
||||
// Initialize the normalized key cache
|
||||
let _ = NORMALIZED_KEY_CACHE.set(Mutex::new(HashMap::new()));
|
||||
|
||||
info!("Global i18n context initialized successfully");
|
||||
}
|
||||
|
||||
/// Get the global localization manager
|
||||
pub fn get_global_i18n() -> Option<Arc<LocalizationManager>> {
|
||||
GLOBAL_I18N.get().cloned()
|
||||
}
|
||||
|
||||
fn simple_hash(s: &str) -> String {
|
||||
let digest = md5::compute(s.as_bytes());
|
||||
// Take the first 2 bytes and convert to 4 hex characters
|
||||
format!("{:02x}{:02x}", digest[0], digest[1])
|
||||
}
|
||||
|
||||
pub fn normalize_ftl_key(key: &str, comment: Option<&str>) -> String {
|
||||
// Try to get from cache first
|
||||
let cache_key = if let Some(comment) = comment {
|
||||
format!("{key}:{comment}")
|
||||
} else {
|
||||
key.to_string()
|
||||
};
|
||||
|
||||
if let Some(cache) = NORMALIZED_KEY_CACHE.get() {
|
||||
if let Ok(cache) = cache.lock() {
|
||||
if let Some(cached) = cache.get(&cache_key) {
|
||||
return cached.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Replace each invalid character with exactly one underscore
|
||||
// This matches the behavior of the Python extraction script
|
||||
let re = Regex::new(r"[^a-zA-Z0-9_-]").unwrap();
|
||||
let mut result = re.replace_all(key, "_").to_string();
|
||||
|
||||
// Remove leading/trailing underscores
|
||||
result = result.trim_matches('_').to_string();
|
||||
|
||||
// Ensure the key starts with a letter (Fluent requirement)
|
||||
if result.is_empty() || !result.chars().next().unwrap().is_ascii_alphabetic() {
|
||||
result = format!("k_{result}");
|
||||
}
|
||||
|
||||
// If we have a comment, append a hash of it to reduce collisions
|
||||
if let Some(comment) = comment {
|
||||
let hash_str = format!("_{}", simple_hash(comment));
|
||||
result.push_str(&hash_str);
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
if let Some(cache) = NORMALIZED_KEY_CACHE.get() {
|
||||
if let Ok(mut cache) = cache.lock() {
|
||||
cache.insert(cache_key, result.clone());
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
"normalize_ftl_key: original='{}', comment='{:?}', final='{}'",
|
||||
key,
|
||||
comment,
|
||||
result
|
||||
);
|
||||
result
|
||||
}
|
||||
|
||||
/// Macro for getting localized strings with format-like syntax
|
||||
///
|
||||
/// Syntax: tr!("message", comment)
|
||||
@@ -114,53 +32,36 @@ pub fn normalize_ftl_key(key: &str, comment: Option<&str>) -> String {
|
||||
/// All placeholders must be named and start with a letter (a-zA-Z).
|
||||
#[macro_export]
|
||||
macro_rules! tr {
|
||||
// Simple case: just message and comment
|
||||
($message:expr, $comment:expr) => {
|
||||
($i18n:expr, $message:expr, $comment:expr) => {
|
||||
{
|
||||
let norm_key = $crate::i18n::normalize_ftl_key($message, Some($comment));
|
||||
if let Some(i18n) = $crate::i18n::get_global_i18n() {
|
||||
let result = i18n.get_string(&norm_key);
|
||||
match result {
|
||||
Ok(ref s) if s != $message => s.clone(),
|
||||
_ => {
|
||||
tracing::warn!("FALLBACK: Using key '{}' as string (not found in FTL)", $message);
|
||||
$message.to_string()
|
||||
}
|
||||
let key = $i18n.normalized_ftl_key($message, $comment);
|
||||
match $i18n.get_string(key.borrow()) {
|
||||
Ok(r) => r,
|
||||
Err(_err) => {
|
||||
$message.to_string()
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("FALLBACK: Global i18n not initialized, using key '{}' as string", $message);
|
||||
$message.to_string()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Case with named parameters: message, comment, param=value, ...
|
||||
($message:expr, $comment:expr, $($param:ident = $value:expr),*) => {
|
||||
($i18n:expr, $message:expr, $comment:expr, $($param:ident = $value:expr),*) => {
|
||||
{
|
||||
let norm_key = $crate::i18n::normalize_ftl_key($message, Some($comment));
|
||||
if let Some(i18n) = $crate::i18n::get_global_i18n() {
|
||||
let mut args = $crate::i18n::FluentArgs::new();
|
||||
$(
|
||||
args.set(stringify!($param), $value);
|
||||
)*
|
||||
match i18n.get_string_with_args(&norm_key, Some(&args)) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
// Fallback: replace placeholders with values
|
||||
let mut result = $message.to_string();
|
||||
$(
|
||||
result = result.replace(&format!("{{{}}}", stringify!($param)), &$value.to_string());
|
||||
)*
|
||||
result
|
||||
}
|
||||
let key = $i18n.normalized_ftl_key($message, $comment);
|
||||
let mut args = $crate::i18n::FluentArgs::new();
|
||||
$(
|
||||
args.set(stringify!($param), $value);
|
||||
)*
|
||||
match $i18n.get_cached_string(key.borrow(), Some(&args)) {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
// Fallback: replace placeholders with values
|
||||
let mut result = $message.to_string();
|
||||
$(
|
||||
result = result.replace(&format!("{{{}}}", stringify!($param)), &$value.to_string());
|
||||
)*
|
||||
result
|
||||
}
|
||||
} else {
|
||||
// Fallback: replace placeholders with values
|
||||
let mut result = $message.to_string();
|
||||
$(
|
||||
result = result.replace(&format!("{{{}}}", stringify!($param)), &$value.to_string());
|
||||
)*
|
||||
result
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -177,42 +78,27 @@ macro_rules! tr {
|
||||
#[macro_export]
|
||||
macro_rules! tr_plural {
|
||||
// With named parameters
|
||||
($one:expr, $other:expr, $comment:expr, $count:expr, $($param:ident = $value:expr),*) => {{
|
||||
let norm_key = $crate::i18n::normalize_ftl_key($other, Some($comment));
|
||||
if let Some(i18n) = $crate::i18n::get_global_i18n() {
|
||||
let mut args = $crate::i18n::FluentArgs::new();
|
||||
args.set("count", $count);
|
||||
$(args.set(stringify!($param), $value);)*
|
||||
match i18n.get_string_with_args(&norm_key, Some(&args)) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
// Fallback: use simple pluralization
|
||||
if $count == 1 {
|
||||
let mut result = $one.to_string();
|
||||
$(result = result.replace(&format!("{{{}}}", stringify!($param)), &$value.to_string());)*
|
||||
result = result.replace("{count}", &$count.to_string());
|
||||
result
|
||||
} else {
|
||||
let mut result = $other.to_string();
|
||||
$(result = result.replace(&format!("{{{}}}", stringify!($param)), &$value.to_string());)*
|
||||
result = result.replace("{count}", &$count.to_string());
|
||||
result
|
||||
}
|
||||
($i18n:expr, $one:expr, $other:expr, $comment:expr, $count:expr, $($param:ident = $value:expr),*) => {{
|
||||
let norm_key = $i18n.normalized_ftl_key($other, $comment);
|
||||
let mut args = $crate::i18n::FluentArgs::new();
|
||||
args.set("count", $count);
|
||||
$(args.set(stringify!($param), $value);)*
|
||||
match $i18n.get_cached_string(norm_key.borrow(), Some(&args)) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
// Fallback: use simple pluralization
|
||||
if $count == 1 {
|
||||
let mut result = $one.to_string();
|
||||
$(result = result.replace(&format!("{{{}}}", stringify!($param)), &$value.to_string());)*
|
||||
result = result.replace("{count}", &$count.to_string());
|
||||
result
|
||||
} else {
|
||||
let mut result = $other.to_string();
|
||||
$(result = result.replace(&format!("{{{}}}", stringify!($param)), &$value.to_string());)*
|
||||
result = result.replace("{count}", &$count.to_string());
|
||||
result
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: use simple pluralization
|
||||
if $count == 1 {
|
||||
let mut result = $one.to_string();
|
||||
$(result = result.replace(&format!("{{{}}}", stringify!($param)), &$value.to_string());)*
|
||||
result = result.replace("{count}", &$count.to_string());
|
||||
result
|
||||
} else {
|
||||
let mut result = $other.to_string();
|
||||
$(result = result.replace(&format!("{{{}}}", stringify!($param)), &$value.to_string());)*
|
||||
result = result.replace("{count}", &$count.to_string());
|
||||
result
|
||||
}
|
||||
}
|
||||
}};
|
||||
// Without named parameters
|
||||
|
||||
@@ -45,11 +45,7 @@ pub use context::AppContext;
|
||||
pub use error::{show_one_error_message, Error, FilterError, ZapError};
|
||||
pub use filter::{FilterState, FilterStates, UnifiedSubscription};
|
||||
pub use fonts::NamedFontFamily;
|
||||
pub use i18n::manager::Localizable;
|
||||
pub use i18n::{
|
||||
CacheStats, FluentArgs, FluentValue, LanguageIdentifier, LocalizationContext,
|
||||
LocalizationManager,
|
||||
};
|
||||
pub use i18n::{CacheStats, FluentArgs, FluentValue, LanguageIdentifier, Localization};
|
||||
pub use imgcache::{
|
||||
Animation, GifState, GifStateMap, ImageFrame, Images, LoadableTextureState, MediaCache,
|
||||
MediaCacheType, TextureFrame, TextureState, TexturedImage, TexturesCache,
|
||||
@@ -89,5 +85,3 @@ pub use enostr;
|
||||
pub use nostrdb;
|
||||
|
||||
pub use zaps::Zaps;
|
||||
|
||||
pub use crate::i18n::{get_global_i18n, init_global_i18n};
|
||||
|
||||
@@ -6,6 +6,7 @@ pub use context::{BroadcastContext, ContextSelection, NoteContextSelection};
|
||||
|
||||
use crate::Accounts;
|
||||
use crate::JobPool;
|
||||
use crate::Localization;
|
||||
use crate::UnknownIds;
|
||||
use crate::{notecache::NoteCache, zaps::Zaps, Images};
|
||||
use enostr::{NoteId, RelayPool};
|
||||
@@ -19,6 +20,7 @@ use std::fmt;
|
||||
pub struct NoteContext<'d> {
|
||||
pub ndb: &'d Ndb,
|
||||
pub accounts: &'d Accounts,
|
||||
pub i18n: &'d mut Localization,
|
||||
pub img_cache: &'d mut Images,
|
||||
pub note_cache: &'d mut NoteCache,
|
||||
pub zaps: &'d mut Zaps,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use crate::{time_ago_since, TimeCached};
|
||||
use nostrdb::{Note, NoteKey, NoteReply, NoteReplyBuf};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct NoteCache {
|
||||
@@ -32,7 +30,7 @@ impl NoteCache {
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CachedNote {
|
||||
reltime: TimeCached<String>,
|
||||
//reltime: TimeCached<String>,
|
||||
pub client: Option<String>,
|
||||
pub reply: NoteReplyBuf,
|
||||
}
|
||||
@@ -41,22 +39,25 @@ impl CachedNote {
|
||||
pub fn new(note: &Note) -> Self {
|
||||
use crate::note::event_tag;
|
||||
|
||||
/*
|
||||
let created_at = note.created_at();
|
||||
let reltime = TimeCached::new(
|
||||
Duration::from_secs(1),
|
||||
Box::new(move || time_ago_since(created_at)),
|
||||
Box::new(move || time_ago_since(i18n, created_at)),
|
||||
);
|
||||
*/
|
||||
let reply = NoteReply::new(note.tags()).to_owned();
|
||||
|
||||
let client = event_tag(note, "client");
|
||||
|
||||
CachedNote {
|
||||
client: client.map(|c| c.to_string()),
|
||||
reltime,
|
||||
// reltime,
|
||||
reply,
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
pub fn reltime_str_mut(&mut self) -> &str {
|
||||
self.reltime.get_mut()
|
||||
}
|
||||
@@ -64,4 +65,5 @@ impl CachedNote {
|
||||
pub fn reltime_str(&self) -> Option<&str> {
|
||||
self.reltime.get().map(|x| x.as_str())
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::tr;
|
||||
use crate::{tr, Localization};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
// Time duration constants in seconds
|
||||
@@ -18,7 +18,7 @@ const MAX_SECONDS_FOR_WEEKS: u64 = ONE_MONTH_IN_SECONDS - 1;
|
||||
const MAX_SECONDS_FOR_MONTHS: u64 = ONE_YEAR_IN_SECONDS - 1;
|
||||
|
||||
/// Calculate relative time between two timestamps
|
||||
fn time_ago_between(timestamp: u64, now: u64) -> String {
|
||||
fn time_ago_between(i18n: &mut Localization, timestamp: u64, now: u64) -> String {
|
||||
// Determine if the timestamp is in the future or the past
|
||||
let duration = if now >= timestamp {
|
||||
now.saturating_sub(timestamp)
|
||||
@@ -28,36 +28,48 @@ fn time_ago_between(timestamp: u64, now: u64) -> String {
|
||||
|
||||
let time_str = match duration {
|
||||
0..=2 => tr!(
|
||||
i18n,
|
||||
"now",
|
||||
"Relative time for very recent events (less than 3 seconds)"
|
||||
),
|
||||
3..=MAX_SECONDS => tr!("{count}s", "Relative time in seconds", count = duration),
|
||||
3..=MAX_SECONDS => tr!(
|
||||
i18n,
|
||||
"{count}s",
|
||||
"Relative time in seconds",
|
||||
count = duration
|
||||
),
|
||||
ONE_MINUTE_IN_SECONDS..=MAX_SECONDS_FOR_MINUTES => tr!(
|
||||
i18n,
|
||||
"{count}m",
|
||||
"Relative time in minutes",
|
||||
count = duration / ONE_MINUTE_IN_SECONDS
|
||||
),
|
||||
ONE_HOUR_IN_SECONDS..=MAX_SECONDS_FOR_HOURS => tr!(
|
||||
i18n,
|
||||
"{count}h",
|
||||
"Relative time in hours",
|
||||
count = duration / ONE_HOUR_IN_SECONDS
|
||||
),
|
||||
ONE_DAY_IN_SECONDS..=MAX_SECONDS_FOR_DAYS => tr!(
|
||||
i18n,
|
||||
"{count}d",
|
||||
"Relative time in days",
|
||||
count = duration / ONE_DAY_IN_SECONDS
|
||||
),
|
||||
ONE_WEEK_IN_SECONDS..=MAX_SECONDS_FOR_WEEKS => tr!(
|
||||
i18n,
|
||||
"{count}w",
|
||||
"Relative time in weeks",
|
||||
count = duration / ONE_WEEK_IN_SECONDS
|
||||
),
|
||||
ONE_MONTH_IN_SECONDS..=MAX_SECONDS_FOR_MONTHS => tr!(
|
||||
i18n,
|
||||
"{count}mo",
|
||||
"Relative time in months",
|
||||
count = duration / ONE_MONTH_IN_SECONDS
|
||||
),
|
||||
_ => tr!(
|
||||
i18n,
|
||||
"{count}y",
|
||||
"Relative time in years",
|
||||
count = duration / ONE_YEAR_IN_SECONDS
|
||||
@@ -65,19 +77,19 @@ fn time_ago_between(timestamp: u64, now: u64) -> String {
|
||||
};
|
||||
|
||||
if timestamp > now {
|
||||
format!("+{}", time_str)
|
||||
format!("+{time_str}")
|
||||
} else {
|
||||
time_str
|
||||
}
|
||||
}
|
||||
|
||||
pub fn time_ago_since(timestamp: u64) -> String {
|
||||
pub fn time_ago_since(i18n: &mut Localization, timestamp: u64) -> String {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("Time went backwards")
|
||||
.as_secs();
|
||||
|
||||
time_ago_between(timestamp, now)
|
||||
time_ago_between(i18n, timestamp, now)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -95,9 +107,10 @@ mod tests {
|
||||
#[test]
|
||||
fn test_now_condition() {
|
||||
let now = get_current_timestamp();
|
||||
let mut intl = Localization::default();
|
||||
|
||||
// Test 0 seconds ago
|
||||
let result = time_ago_between(now, now);
|
||||
let result = time_ago_between(&mut intl, now, now);
|
||||
assert_eq!(
|
||||
result, "now",
|
||||
"Expected 'now' for 0 seconds, got: {}",
|
||||
@@ -105,7 +118,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// Test 1 second ago
|
||||
let result = time_ago_between(now - 1, now);
|
||||
let result = time_ago_between(&mut intl, now - 1, now);
|
||||
assert_eq!(
|
||||
result, "now",
|
||||
"Expected 'now' for 1 second, got: {}",
|
||||
@@ -113,7 +126,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// Test 2 seconds ago
|
||||
let result = time_ago_between(now - 2, now);
|
||||
let result = time_ago_between(&mut intl, now - 2, now);
|
||||
assert_eq!(
|
||||
result, "now",
|
||||
"Expected 'now' for 2 seconds, got: {}",
|
||||
@@ -124,13 +137,14 @@ mod tests {
|
||||
#[test]
|
||||
fn test_seconds_condition() {
|
||||
let now = get_current_timestamp();
|
||||
let mut i18n = Localization::default();
|
||||
|
||||
// Test 3 seconds ago
|
||||
let result = time_ago_between(now - 3, now);
|
||||
let result = time_ago_between(&mut i18n, now - 3, now);
|
||||
assert_eq!(result, "3s", "Expected '3s' for 3 seconds, got: {}", result);
|
||||
|
||||
// Test 30 seconds ago
|
||||
let result = time_ago_between(now - 30, now);
|
||||
let result = time_ago_between(&mut i18n, now - 30, now);
|
||||
assert_eq!(
|
||||
result, "30s",
|
||||
"Expected '30s' for 30 seconds, got: {}",
|
||||
@@ -138,7 +152,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// Test 59 seconds ago (max for seconds)
|
||||
let result = time_ago_between(now - 59, now);
|
||||
let result = time_ago_between(&mut i18n, now - 59, now);
|
||||
assert_eq!(
|
||||
result, "59s",
|
||||
"Expected '59s' for 59 seconds, got: {}",
|
||||
@@ -149,13 +163,14 @@ mod tests {
|
||||
#[test]
|
||||
fn test_minutes_condition() {
|
||||
let now = get_current_timestamp();
|
||||
let mut i18n = Localization::default();
|
||||
|
||||
// Test 1 minute ago
|
||||
let result = time_ago_between(now - ONE_MINUTE_IN_SECONDS, now);
|
||||
let result = time_ago_between(&mut i18n, now - ONE_MINUTE_IN_SECONDS, now);
|
||||
assert_eq!(result, "1m", "Expected '1m' for 1 minute, got: {}", result);
|
||||
|
||||
// Test 30 minutes ago
|
||||
let result = time_ago_between(now - 30 * ONE_MINUTE_IN_SECONDS, now);
|
||||
let result = time_ago_between(&mut i18n, now - 30 * ONE_MINUTE_IN_SECONDS, now);
|
||||
assert_eq!(
|
||||
result, "30m",
|
||||
"Expected '30m' for 30 minutes, got: {}",
|
||||
@@ -163,7 +178,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// Test 59 minutes ago (max for minutes)
|
||||
let result = time_ago_between(now - 59 * ONE_MINUTE_IN_SECONDS, now);
|
||||
let result = time_ago_between(&mut i18n, now - 59 * ONE_MINUTE_IN_SECONDS, now);
|
||||
assert_eq!(
|
||||
result, "59m",
|
||||
"Expected '59m' for 59 minutes, got: {}",
|
||||
@@ -174,13 +189,14 @@ mod tests {
|
||||
#[test]
|
||||
fn test_hours_condition() {
|
||||
let now = get_current_timestamp();
|
||||
let mut i18n = Localization::default();
|
||||
|
||||
// Test 1 hour ago
|
||||
let result = time_ago_between(now - ONE_HOUR_IN_SECONDS, now);
|
||||
let result = time_ago_between(&mut i18n, now - ONE_HOUR_IN_SECONDS, now);
|
||||
assert_eq!(result, "1h", "Expected '1h' for 1 hour, got: {}", result);
|
||||
|
||||
// Test 12 hours ago
|
||||
let result = time_ago_between(now - 12 * ONE_HOUR_IN_SECONDS, now);
|
||||
let result = time_ago_between(&mut i18n, now - 12 * ONE_HOUR_IN_SECONDS, now);
|
||||
assert_eq!(
|
||||
result, "12h",
|
||||
"Expected '12h' for 12 hours, got: {}",
|
||||
@@ -188,7 +204,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// Test 23 hours ago (max for hours)
|
||||
let result = time_ago_between(now - 23 * ONE_HOUR_IN_SECONDS, now);
|
||||
let result = time_ago_between(&mut i18n, now - 23 * ONE_HOUR_IN_SECONDS, now);
|
||||
assert_eq!(
|
||||
result, "23h",
|
||||
"Expected '23h' for 23 hours, got: {}",
|
||||
@@ -199,43 +215,46 @@ mod tests {
|
||||
#[test]
|
||||
fn test_days_condition() {
|
||||
let now = get_current_timestamp();
|
||||
let mut i18n = Localization::default();
|
||||
|
||||
// Test 1 day ago
|
||||
let result = time_ago_between(now - ONE_DAY_IN_SECONDS, now);
|
||||
let result = time_ago_between(&mut i18n, now - ONE_DAY_IN_SECONDS, now);
|
||||
assert_eq!(result, "1d", "Expected '1d' for 1 day, got: {}", result);
|
||||
|
||||
// Test 3 days ago
|
||||
let result = time_ago_between(now - 3 * ONE_DAY_IN_SECONDS, now);
|
||||
let result = time_ago_between(&mut i18n, now - 3 * ONE_DAY_IN_SECONDS, now);
|
||||
assert_eq!(result, "3d", "Expected '3d' for 3 days, got: {}", result);
|
||||
|
||||
// Test 6 days ago (max for days, before weeks)
|
||||
let result = time_ago_between(now - 6 * ONE_DAY_IN_SECONDS, now);
|
||||
let result = time_ago_between(&mut i18n, now - 6 * ONE_DAY_IN_SECONDS, now);
|
||||
assert_eq!(result, "6d", "Expected '6d' for 6 days, got: {}", result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_weeks_condition() {
|
||||
let now = get_current_timestamp();
|
||||
let mut i18n = Localization::default();
|
||||
|
||||
// Test 1 week ago
|
||||
let result = time_ago_between(now - ONE_WEEK_IN_SECONDS, now);
|
||||
let result = time_ago_between(&mut i18n, now - ONE_WEEK_IN_SECONDS, now);
|
||||
assert_eq!(result, "1w", "Expected '1w' for 1 week, got: {}", result);
|
||||
|
||||
// Test 4 weeks ago
|
||||
let result = time_ago_between(now - 4 * ONE_WEEK_IN_SECONDS, now);
|
||||
let result = time_ago_between(&mut i18n, now - 4 * ONE_WEEK_IN_SECONDS, now);
|
||||
assert_eq!(result, "4w", "Expected '4w' for 4 weeks, got: {}", result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_months_condition() {
|
||||
let now = get_current_timestamp();
|
||||
let mut i18n = Localization::default();
|
||||
|
||||
// Test 1 month ago
|
||||
let result = time_ago_between(now - ONE_MONTH_IN_SECONDS, now);
|
||||
let result = time_ago_between(&mut i18n, now - ONE_MONTH_IN_SECONDS, now);
|
||||
assert_eq!(result, "1mo", "Expected '1mo' for 1 month, got: {}", result);
|
||||
|
||||
// Test 11 months ago (max for months, before years)
|
||||
let result = time_ago_between(now - 11 * ONE_MONTH_IN_SECONDS, now);
|
||||
let result = time_ago_between(&mut i18n, now - 11 * ONE_MONTH_IN_SECONDS, now);
|
||||
assert_eq!(
|
||||
result, "11mo",
|
||||
"Expected '11mo' for 11 months, got: {}",
|
||||
@@ -246,17 +265,18 @@ mod tests {
|
||||
#[test]
|
||||
fn test_years_condition() {
|
||||
let now = get_current_timestamp();
|
||||
let mut i18n = Localization::default();
|
||||
|
||||
// Test 1 year ago
|
||||
let result = time_ago_between(now - ONE_YEAR_IN_SECONDS, now);
|
||||
let result = time_ago_between(&mut i18n, now - ONE_YEAR_IN_SECONDS, now);
|
||||
assert_eq!(result, "1y", "Expected '1y' for 1 year, got: {}", result);
|
||||
|
||||
// Test 5 years ago
|
||||
let result = time_ago_between(now - 5 * ONE_YEAR_IN_SECONDS, now);
|
||||
let result = time_ago_between(&mut i18n, now - 5 * ONE_YEAR_IN_SECONDS, now);
|
||||
assert_eq!(result, "5y", "Expected '5y' for 5 years, got: {}", result);
|
||||
|
||||
// Test 10 years ago (reduced from 100 to avoid overflow)
|
||||
let result = time_ago_between(now - 10 * ONE_YEAR_IN_SECONDS, now);
|
||||
let result = time_ago_between(&mut i18n, now - 10 * ONE_YEAR_IN_SECONDS, now);
|
||||
assert_eq!(
|
||||
result, "10y",
|
||||
"Expected '10y' for 10 years, got: {}",
|
||||
@@ -267,9 +287,10 @@ mod tests {
|
||||
#[test]
|
||||
fn test_future_timestamps() {
|
||||
let now = get_current_timestamp();
|
||||
let mut i18n = Localization::default();
|
||||
|
||||
// Test 1 minute in the future
|
||||
let result = time_ago_between(now + ONE_MINUTE_IN_SECONDS, now);
|
||||
let result = time_ago_between(&mut i18n, now + ONE_MINUTE_IN_SECONDS, now);
|
||||
assert_eq!(
|
||||
result, "+1m",
|
||||
"Expected '+1m' for 1 minute in future, got: {}",
|
||||
@@ -277,7 +298,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// Test 1 hour in the future
|
||||
let result = time_ago_between(now + ONE_HOUR_IN_SECONDS, now);
|
||||
let result = time_ago_between(&mut i18n, now + ONE_HOUR_IN_SECONDS, now);
|
||||
assert_eq!(
|
||||
result, "+1h",
|
||||
"Expected '+1h' for 1 hour in future, got: {}",
|
||||
@@ -285,7 +306,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// Test 1 day in the future
|
||||
let result = time_ago_between(now + ONE_DAY_IN_SECONDS, now);
|
||||
let result = time_ago_between(&mut i18n, now + ONE_DAY_IN_SECONDS, now);
|
||||
assert_eq!(
|
||||
result, "+1d",
|
||||
"Expected '+1d' for 1 day in future, got: {}",
|
||||
@@ -296,9 +317,10 @@ mod tests {
|
||||
#[test]
|
||||
fn test_boundary_conditions() {
|
||||
let now = get_current_timestamp();
|
||||
let mut i18n = Localization::default();
|
||||
|
||||
// Test boundary between seconds and minutes
|
||||
let result = time_ago_between(now - 60, now);
|
||||
let result = time_ago_between(&mut i18n, now - 60, now);
|
||||
assert_eq!(
|
||||
result, "1m",
|
||||
"Expected '1m' for exactly 60 seconds, got: {}",
|
||||
@@ -306,7 +328,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// Test boundary between minutes and hours
|
||||
let result = time_ago_between(now - 3600, now);
|
||||
let result = time_ago_between(&mut i18n, now - 3600, now);
|
||||
assert_eq!(
|
||||
result, "1h",
|
||||
"Expected '1h' for exactly 3600 seconds, got: {}",
|
||||
@@ -314,7 +336,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// Test boundary between hours and days
|
||||
let result = time_ago_between(now - 86400, now);
|
||||
let result = time_ago_between(&mut i18n, now - 86400, now);
|
||||
assert_eq!(
|
||||
result, "1d",
|
||||
"Expected '1d' for exactly 86400 seconds, got: {}",
|
||||
|
||||
Reference in New Issue
Block a user