All checks were successful
BotServer CI / build (push) Successful in 10m8s
1015 lines
24 KiB
Rust
1015 lines
24 KiB
Rust
use axum::{
|
|
async_trait,
|
|
extract::{FromRequestParts, Path, State},
|
|
http::{header::ACCEPT_LANGUAGE, request::Parts},
|
|
response::IntoResponse,
|
|
routing::get,
|
|
Json, Router,
|
|
};
|
|
use botlib::i18n::{self, Locale as BotlibLocale, MessageArgs as BotlibMessageArgs};
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
use crate::core::shared::state::AppState;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Locale {
|
|
language: String,
|
|
region: Option<String>,
|
|
}
|
|
|
|
impl Locale {
|
|
pub fn new(locale_str: &str) -> Option<Self> {
|
|
if locale_str.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let parts: Vec<&str> = locale_str.split(&['-', '_'][..]).collect();
|
|
|
|
let language = parts.first()?.to_lowercase();
|
|
if language.len() < 2 || language.len() > 3 {
|
|
return None;
|
|
}
|
|
|
|
let region = parts.get(1).map(|r| r.to_uppercase());
|
|
|
|
Some(Self { language, region })
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn language(&self) -> &str {
|
|
&self.language
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn region(&self) -> Option<&str> {
|
|
self.region.as_deref()
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn to_bcp47(&self) -> String {
|
|
match &self.region {
|
|
Some(r) => format!("{}-{r}", self.language),
|
|
None => self.language.clone(),
|
|
}
|
|
}
|
|
|
|
fn to_botlib_locale(&self) -> BotlibLocale {
|
|
BotlibLocale::new(&self.to_bcp47()).unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
impl Default for Locale {
|
|
fn default() -> Self {
|
|
Self {
|
|
language: "en".to_string(),
|
|
region: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for Locale {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{}", self.to_bcp47())
|
|
}
|
|
}
|
|
|
|
const AVAILABLE_LOCALES: &[&str] = &["en", "pt-BR", "es", "zh-CN"];
|
|
|
|
pub struct RequestLocale(pub Locale);
|
|
|
|
impl RequestLocale {
|
|
#[must_use]
|
|
pub fn locale(&self) -> &Locale {
|
|
&self.0
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn language(&self) -> &str {
|
|
self.0.language()
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl<S> FromRequestParts<S> for RequestLocale
|
|
where
|
|
S: Send + Sync,
|
|
{
|
|
type Rejection = std::convert::Infallible;
|
|
|
|
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
|
let locale = parts
|
|
.headers
|
|
.get(ACCEPT_LANGUAGE)
|
|
.and_then(|h| h.to_str().ok())
|
|
.map(parse_accept_language)
|
|
.and_then(|langs| negotiate_locale(&langs))
|
|
.unwrap_or_default();
|
|
|
|
Ok(Self(locale))
|
|
}
|
|
}
|
|
|
|
fn parse_accept_language(header: &str) -> Vec<(String, f32)> {
|
|
let mut langs: Vec<(String, f32)> = header
|
|
.split(',')
|
|
.filter_map(|part| {
|
|
let mut iter = part.trim().split(';');
|
|
let lang = iter.next()?.trim().to_string();
|
|
|
|
if lang.is_empty() || lang == "*" {
|
|
return None;
|
|
}
|
|
|
|
let quality = iter
|
|
.next()
|
|
.and_then(|q| q.trim().strip_prefix("q="))
|
|
.and_then(|q| q.parse().ok())
|
|
.unwrap_or(1.0);
|
|
|
|
Some((lang, quality))
|
|
})
|
|
.collect();
|
|
|
|
langs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
|
langs
|
|
}
|
|
|
|
fn negotiate_locale(requested: &[(String, f32)]) -> Option<Locale> {
|
|
for (lang, _) in requested {
|
|
let requested_locale = Locale::new(lang)?;
|
|
|
|
for available in AVAILABLE_LOCALES {
|
|
let avail_locale = Locale::new(available)?;
|
|
|
|
if requested_locale.language == avail_locale.language
|
|
&& requested_locale.region == avail_locale.region
|
|
{
|
|
return Some(avail_locale);
|
|
}
|
|
}
|
|
|
|
for available in AVAILABLE_LOCALES {
|
|
let avail_locale = Locale::new(available)?;
|
|
if requested_locale.language == avail_locale.language {
|
|
return Some(avail_locale);
|
|
}
|
|
}
|
|
}
|
|
|
|
Some(Locale::default())
|
|
}
|
|
|
|
pub type MessageArgs = HashMap<String, String>;
|
|
|
|
pub fn init_i18n(locales_path: &str) -> Result<(), String> {
|
|
i18n::init(locales_path).map_err(|e| format!("Failed to initialize i18n: {e}"))
|
|
}
|
|
|
|
pub fn is_i18n_initialized() -> bool {
|
|
i18n::is_initialized()
|
|
}
|
|
|
|
pub fn t(locale: &Locale, key: &str) -> String {
|
|
t_with_args(locale, key, None)
|
|
}
|
|
|
|
pub fn t_with_args(locale: &Locale, key: &str, args: Option<&MessageArgs>) -> String {
|
|
let botlib_locale = locale.to_botlib_locale();
|
|
let botlib_args: Option<BotlibMessageArgs> = args.map(|a| {
|
|
a.iter()
|
|
.map(|(k, v)| (k.clone(), v.clone()))
|
|
.collect()
|
|
});
|
|
i18n::get_with_args(&botlib_locale, key, botlib_args.as_ref())
|
|
}
|
|
|
|
pub fn available_locales() -> Vec<String> {
|
|
if is_i18n_initialized() {
|
|
i18n::available_locales()
|
|
} else {
|
|
AVAILABLE_LOCALES.iter().map(|s| (*s).to_string()).collect()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct LocalizedError {
|
|
pub code: String,
|
|
pub message: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub details: Option<serde_json::Value>,
|
|
}
|
|
|
|
impl LocalizedError {
|
|
pub fn new(locale: &Locale, code: &str) -> Self {
|
|
Self {
|
|
code: code.to_string(),
|
|
message: t(locale, code),
|
|
details: None,
|
|
}
|
|
}
|
|
|
|
pub fn with_args(locale: &Locale, code: &str, args: &MessageArgs) -> Self {
|
|
Self {
|
|
code: code.to_string(),
|
|
message: t_with_args(locale, code, Some(args)),
|
|
details: None,
|
|
}
|
|
}
|
|
|
|
pub fn not_found(locale: &Locale, entity: &str) -> Self {
|
|
let mut args = MessageArgs::new();
|
|
args.insert("entity".to_string(), entity.to_string());
|
|
Self::with_args(locale, "error-http-404", &args)
|
|
}
|
|
|
|
pub fn validation(locale: &Locale, field: &str, error_key: &str) -> Self {
|
|
let mut args = MessageArgs::new();
|
|
args.insert("field".to_string(), field.to_string());
|
|
Self::with_args(locale, error_key, &args)
|
|
}
|
|
|
|
pub fn internal(locale: &Locale) -> Self {
|
|
Self::new(locale, "error-http-500")
|
|
}
|
|
|
|
pub fn unauthorized(locale: &Locale) -> Self {
|
|
Self::new(locale, "error-http-401")
|
|
}
|
|
|
|
pub fn forbidden(locale: &Locale) -> Self {
|
|
Self::new(locale, "error-http-403")
|
|
}
|
|
|
|
pub fn rate_limited(locale: &Locale, seconds: u64) -> Self {
|
|
let mut args = MessageArgs::new();
|
|
args.insert("seconds".to_string(), seconds.to_string());
|
|
Self::with_args(locale, "error-http-429", &args)
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn with_details(mut self, details: serde_json::Value) -> Self {
|
|
self.details = Some(details);
|
|
self
|
|
}
|
|
}
|
|
|
|
const TRANSLATION_KEYS: &[&str] = &[
|
|
"app-name",
|
|
"app-tagline",
|
|
"action-save",
|
|
"action-cancel",
|
|
"action-delete",
|
|
"action-edit",
|
|
"action-close",
|
|
"action-confirm",
|
|
"action-retry",
|
|
"action-back",
|
|
"action-next",
|
|
"action-submit",
|
|
"action-search",
|
|
"action-refresh",
|
|
"action-copy",
|
|
"action-paste",
|
|
"action-undo",
|
|
"action-redo",
|
|
"action-select",
|
|
"action-select-all",
|
|
"action-clear",
|
|
"action-reset",
|
|
"action-apply",
|
|
"action-create",
|
|
"action-update",
|
|
"action-remove",
|
|
"action-add",
|
|
"action-upload",
|
|
"action-download",
|
|
"action-export",
|
|
"action-import",
|
|
"action-share",
|
|
"action-send",
|
|
"action-reply",
|
|
"action-forward",
|
|
"action-archive",
|
|
"action-restore",
|
|
"action-duplicate",
|
|
"action-rename",
|
|
"action-move",
|
|
"action-filter",
|
|
"action-sort",
|
|
"action-view",
|
|
"action-hide",
|
|
"action-show",
|
|
"action-expand",
|
|
"action-collapse",
|
|
"action-enable",
|
|
"action-disable",
|
|
"action-connect",
|
|
"action-disconnect",
|
|
"action-sync",
|
|
"action-start",
|
|
"action-stop",
|
|
"action-pause",
|
|
"action-resume",
|
|
"action-continue",
|
|
"action-finish",
|
|
"action-complete",
|
|
"action-approve",
|
|
"action-reject",
|
|
"action-accept",
|
|
"action-decline",
|
|
"action-login",
|
|
"action-logout",
|
|
"action-signup",
|
|
"action-forgot-password",
|
|
"label-loading",
|
|
"label-saving",
|
|
"label-processing",
|
|
"label-searching",
|
|
"label-uploading",
|
|
"label-downloading",
|
|
"label-no-results",
|
|
"label-no-data",
|
|
"label-empty",
|
|
"label-none",
|
|
"label-all",
|
|
"label-selected",
|
|
"label-required",
|
|
"label-optional",
|
|
"label-default",
|
|
"label-custom",
|
|
"label-new",
|
|
"label-draft",
|
|
"label-pending",
|
|
"label-active",
|
|
"label-inactive",
|
|
"label-enabled",
|
|
"label-disabled",
|
|
"label-public",
|
|
"label-private",
|
|
"label-shared",
|
|
"label-yes",
|
|
"label-no",
|
|
"label-on",
|
|
"label-off",
|
|
"label-true",
|
|
"label-false",
|
|
"label-unknown",
|
|
"label-other",
|
|
"label-more",
|
|
"label-less",
|
|
"label-details",
|
|
"label-summary",
|
|
"label-description",
|
|
"label-name",
|
|
"label-title",
|
|
"label-type",
|
|
"label-status",
|
|
"label-priority",
|
|
"label-date",
|
|
"label-time",
|
|
"label-size",
|
|
"label-count",
|
|
"label-total",
|
|
"label-average",
|
|
"label-minimum",
|
|
"label-maximum",
|
|
"label-version",
|
|
"label-id",
|
|
"label-created",
|
|
"label-updated",
|
|
"label-modified",
|
|
"label-deleted",
|
|
"label-by",
|
|
"label-from",
|
|
"label-to",
|
|
"label-at",
|
|
"label-in",
|
|
"label-of",
|
|
"status-success",
|
|
"status-error",
|
|
"status-warning",
|
|
"status-info",
|
|
"status-loading",
|
|
"status-complete",
|
|
"status-incomplete",
|
|
"status-failed",
|
|
"status-cancelled",
|
|
"status-pending",
|
|
"status-in-progress",
|
|
"status-done",
|
|
"status-ready",
|
|
"status-not-ready",
|
|
"status-connected",
|
|
"status-disconnected",
|
|
"status-online",
|
|
"status-offline",
|
|
"status-available",
|
|
"status-unavailable",
|
|
"status-busy",
|
|
"status-away",
|
|
"confirm-delete",
|
|
"confirm-delete-item",
|
|
"confirm-discard-changes",
|
|
"confirm-logout",
|
|
"confirm-cancel",
|
|
"time-now",
|
|
"time-today",
|
|
"time-yesterday",
|
|
"time-tomorrow",
|
|
"time-this-week",
|
|
"time-last-week",
|
|
"time-next-week",
|
|
"time-this-month",
|
|
"time-last-month",
|
|
"time-next-month",
|
|
"time-this-year",
|
|
"time-last-year",
|
|
"time-next-year",
|
|
"day-sunday",
|
|
"day-monday",
|
|
"day-tuesday",
|
|
"day-wednesday",
|
|
"day-thursday",
|
|
"day-friday",
|
|
"day-saturday",
|
|
"day-sun",
|
|
"day-mon",
|
|
"day-tue",
|
|
"day-wed",
|
|
"day-thu",
|
|
"day-fri",
|
|
"day-sat",
|
|
"month-january",
|
|
"month-february",
|
|
"month-march",
|
|
"month-april",
|
|
"month-may",
|
|
"month-june",
|
|
"month-july",
|
|
"month-august",
|
|
"month-september",
|
|
"month-october",
|
|
"month-november",
|
|
"month-december",
|
|
"month-jan",
|
|
"month-feb",
|
|
"month-mar",
|
|
"month-apr",
|
|
"month-may-short",
|
|
"month-jun",
|
|
"month-jul",
|
|
"month-aug",
|
|
"month-sep",
|
|
"month-oct",
|
|
"month-nov",
|
|
"month-dec",
|
|
"pagination-first",
|
|
"pagination-previous",
|
|
"pagination-next",
|
|
"pagination-last",
|
|
"pagination-items-per-page",
|
|
"pagination-go-to-page",
|
|
"validation-required",
|
|
"validation-email-invalid",
|
|
"validation-url-invalid",
|
|
"validation-number-invalid",
|
|
"validation-date-invalid",
|
|
"validation-pattern-mismatch",
|
|
"validation-passwords-mismatch",
|
|
"a11y-skip-to-content",
|
|
"a11y-loading",
|
|
"a11y-menu-open",
|
|
"a11y-menu-close",
|
|
"a11y-expand",
|
|
"a11y-collapse",
|
|
"a11y-selected",
|
|
"a11y-not-selected",
|
|
"a11y-required",
|
|
"a11y-error",
|
|
"a11y-success",
|
|
"a11y-warning",
|
|
"a11y-info",
|
|
"nav-home",
|
|
"nav-chat",
|
|
"nav-drive",
|
|
"nav-tasks",
|
|
"nav-mail",
|
|
"nav-calendar",
|
|
"nav-meet",
|
|
"nav-paper",
|
|
"nav-research",
|
|
"nav-analytics",
|
|
"nav-settings",
|
|
"nav-admin",
|
|
"nav-monitoring",
|
|
"nav-sources",
|
|
"nav-tools",
|
|
"nav-attendant",
|
|
"nav-learn",
|
|
"nav-crm",
|
|
"nav-billing",
|
|
"nav-products",
|
|
"nav-tickets",
|
|
"nav-docs",
|
|
"nav-sheet",
|
|
"nav-slides",
|
|
"nav-social",
|
|
"nav-all-apps",
|
|
"nav-people",
|
|
"nav-editor",
|
|
"nav-dashboards",
|
|
"nav-security",
|
|
"nav-designer",
|
|
"nav-project",
|
|
"nav-canvas",
|
|
"nav-goals",
|
|
"nav-player",
|
|
"nav-workspace",
|
|
"nav-video",
|
|
"dashboard-title",
|
|
"dashboard-welcome",
|
|
"dashboard-quick-actions",
|
|
"dashboard-recent-activity",
|
|
"chat-title",
|
|
"chat-placeholder",
|
|
"chat-send",
|
|
"chat-new-conversation",
|
|
"chat-history",
|
|
"chat-clear",
|
|
"chat-typing",
|
|
"chat-online",
|
|
"chat-offline",
|
|
"chat-connecting",
|
|
"drive-title",
|
|
"drive-upload",
|
|
"drive-new-folder",
|
|
"drive-download",
|
|
"drive-delete",
|
|
"drive-rename",
|
|
"drive-move",
|
|
"drive-copy",
|
|
"drive-share",
|
|
"drive-properties",
|
|
"drive-empty-folder",
|
|
"drive-search-placeholder",
|
|
"drive-sort-name",
|
|
"drive-sort-date",
|
|
"drive-sort-size",
|
|
"drive-sort-type",
|
|
"tasks-title",
|
|
"tasks-new",
|
|
"tasks-all",
|
|
"tasks-pending",
|
|
"tasks-completed",
|
|
"tasks-overdue",
|
|
"tasks-today",
|
|
"tasks-this-week",
|
|
"tasks-no-tasks",
|
|
"tasks-priority-low",
|
|
"tasks-priority-medium",
|
|
"tasks-priority-high",
|
|
"tasks-priority-urgent",
|
|
"tasks-assign",
|
|
"tasks-due-date",
|
|
"tasks-description",
|
|
"calendar-title",
|
|
"calendar-today",
|
|
"calendar-day",
|
|
"calendar-week",
|
|
"calendar-month",
|
|
"calendar-year",
|
|
"calendar-new-event",
|
|
"calendar-edit-event",
|
|
"calendar-delete-event",
|
|
"calendar-event-title",
|
|
"calendar-event-location",
|
|
"calendar-event-start",
|
|
"calendar-event-end",
|
|
"calendar-event-all-day",
|
|
"calendar-event-repeat",
|
|
"calendar-event-reminder",
|
|
"calendar-no-events",
|
|
"meet-title",
|
|
"meet-join",
|
|
"meet-leave",
|
|
"meet-mute",
|
|
"meet-unmute",
|
|
"meet-video-on",
|
|
"meet-video-off",
|
|
"meet-share-screen",
|
|
"meet-stop-sharing",
|
|
"meet-participants",
|
|
"meet-chat",
|
|
"meet-settings",
|
|
"meet-end-call",
|
|
"meet-invite",
|
|
"meet-copy-link",
|
|
"email-title",
|
|
"email-compose",
|
|
"email-inbox",
|
|
"email-sent",
|
|
"email-drafts",
|
|
"email-trash",
|
|
"email-spam",
|
|
"email-starred",
|
|
"email-archive",
|
|
"email-to",
|
|
"email-cc",
|
|
"email-bcc",
|
|
"email-subject",
|
|
"email-body",
|
|
"email-attachments",
|
|
"email-send",
|
|
"email-save-draft",
|
|
"email-discard",
|
|
"email-reply",
|
|
"email-reply-all",
|
|
"email-forward",
|
|
"email-mark-read",
|
|
"email-mark-unread",
|
|
"email-delete",
|
|
"email-no-messages",
|
|
"settings-title",
|
|
"settings-general",
|
|
"settings-account",
|
|
"settings-notifications",
|
|
"settings-privacy",
|
|
"settings-security",
|
|
"settings-appearance",
|
|
"settings-language",
|
|
"settings-timezone",
|
|
"settings-theme",
|
|
"settings-theme-light",
|
|
"settings-theme-dark",
|
|
"settings-theme-system",
|
|
"settings-save",
|
|
"settings-saved",
|
|
"admin-title",
|
|
"admin-users",
|
|
"admin-bots",
|
|
"admin-system",
|
|
"admin-logs",
|
|
"admin-backups",
|
|
"admin-settings",
|
|
"error-http-400",
|
|
"error-http-401",
|
|
"error-http-403",
|
|
"error-http-404",
|
|
"error-http-429",
|
|
"error-http-500",
|
|
"error-http-502",
|
|
"error-http-503",
|
|
"error-network",
|
|
"error-timeout",
|
|
"error-unknown",
|
|
"paper-title",
|
|
"paper-new-note",
|
|
"paper-search-notes",
|
|
"paper-quick-start",
|
|
"paper-template-blank",
|
|
"paper-template-meeting",
|
|
"paper-template-todo",
|
|
"paper-template-research",
|
|
"paper-untitled",
|
|
"paper-placeholder",
|
|
"paper-commands",
|
|
"paper-heading1",
|
|
"paper-heading1-desc",
|
|
"paper-heading2",
|
|
"paper-heading2-desc",
|
|
"paper-heading3",
|
|
"paper-heading3-desc",
|
|
"paper-paragraph",
|
|
"paper-paragraph-desc",
|
|
"paper-bullet-list",
|
|
"paper-bullet-list-desc",
|
|
"paper-numbered-list",
|
|
"paper-numbered-list-desc",
|
|
"paper-todo-list",
|
|
"paper-todo-list-desc",
|
|
"paper-quote",
|
|
"paper-quote-desc",
|
|
"paper-divider",
|
|
"paper-divider-desc",
|
|
"paper-code-block",
|
|
"paper-code-block-desc",
|
|
"paper-table",
|
|
"paper-table-desc",
|
|
"paper-image",
|
|
"paper-image-desc",
|
|
"paper-callout",
|
|
"paper-callout-desc",
|
|
"paper-ai-write",
|
|
"paper-ai-write-desc",
|
|
"paper-ai-summarize",
|
|
"paper-ai-summarize-desc",
|
|
"paper-ai-expand",
|
|
"paper-ai-expand-desc",
|
|
"paper-ai-improve",
|
|
"paper-ai-improve-desc",
|
|
"paper-ai-translate",
|
|
"paper-ai-translate-desc",
|
|
"paper-ai-assistant",
|
|
"paper-ai-quick-actions",
|
|
"paper-ai-rewrite",
|
|
"paper-ai-make-shorter",
|
|
"paper-ai-make-longer",
|
|
"paper-ai-fix-grammar",
|
|
"paper-ai-tone",
|
|
"paper-ai-tone-professional",
|
|
"paper-ai-tone-casual",
|
|
"paper-ai-tone-friendly",
|
|
"paper-ai-tone-formal",
|
|
"paper-ai-translate-to",
|
|
"paper-ai-custom-prompt",
|
|
"paper-ai-custom-placeholder",
|
|
"paper-ai-generate",
|
|
"paper-ai-response",
|
|
"paper-ai-apply",
|
|
"paper-ai-regenerate",
|
|
"paper-ai-copy",
|
|
"paper-word-count",
|
|
"paper-char-count",
|
|
"paper-saved",
|
|
"paper-saving",
|
|
"paper-last-edited",
|
|
"paper-last-edited-now",
|
|
"paper-export",
|
|
"paper-export-pdf",
|
|
"paper-export-docx",
|
|
"paper-export-markdown",
|
|
"paper-export-html",
|
|
"paper-export-txt",
|
|
"chat-voice",
|
|
"chat-message-placeholder",
|
|
"drive-my-drive",
|
|
"drive-shared",
|
|
"drive-recent",
|
|
"drive-starred",
|
|
"drive-trash",
|
|
"drive-loading-storage",
|
|
"drive-storage-used",
|
|
"drive-empty-folder",
|
|
"drive-drop-files",
|
|
"tasks-active",
|
|
"tasks-awaiting",
|
|
"tasks-paused",
|
|
"tasks-blocked",
|
|
"tasks-time-saved",
|
|
"tasks-input-placeholder",
|
|
"calendar-my-calendars",
|
|
"email-scheduled",
|
|
"email-tracking",
|
|
"email-inbox",
|
|
"email-starred",
|
|
"email-sent",
|
|
"email-drafts",
|
|
"email-spam",
|
|
"email-trash",
|
|
"email-compose",
|
|
"compliance-title",
|
|
"compliance-subtitle",
|
|
"compliance-export",
|
|
"compliance-run-scan",
|
|
"compliance-critical",
|
|
"compliance-critical-desc",
|
|
"compliance-high",
|
|
"compliance-high-desc",
|
|
"compliance-medium",
|
|
"compliance-medium-desc",
|
|
"compliance-low",
|
|
"compliance-low-desc",
|
|
"compliance-info",
|
|
"compliance-info-desc",
|
|
"compliance-filter-severity",
|
|
"compliance-filter-type",
|
|
"compliance-issues-found",
|
|
"sources-title",
|
|
"sources-subtitle",
|
|
"sources-prompts",
|
|
"sources-templates",
|
|
"sources-news",
|
|
"sources-mcp-servers",
|
|
"sources-llm-tools",
|
|
"sources-models",
|
|
"sources-repositories",
|
|
"sources-apps",
|
|
"attendant-title",
|
|
"attendant-subtitle",
|
|
"attendant-queue",
|
|
"attendant-active",
|
|
"attendant-resolved",
|
|
"attendant-assign",
|
|
"attendant-transfer",
|
|
"attendant-resolve",
|
|
"attendant-no-items",
|
|
"attendant-crm-disabled",
|
|
"attendant-status-online",
|
|
"attendant-select-conversation",
|
|
"sources-search",
|
|
"people-title",
|
|
"people-subtitle",
|
|
"people-search",
|
|
"people-add",
|
|
"people-tab-contacts",
|
|
"people-tab-groups",
|
|
"people-tab-directory",
|
|
"people-tab-recent",
|
|
"people-loading",
|
|
"people-add-contact",
|
|
"people-first-name",
|
|
"people-last-name",
|
|
"people-email",
|
|
"people-phone",
|
|
"people-company",
|
|
"people-job-title",
|
|
"people-notes",
|
|
"people-empty-title",
|
|
"people-empty-desc",
|
|
"crm-title",
|
|
"crm-subtitle",
|
|
"crm-leads",
|
|
"crm-opportunities",
|
|
"crm-contacts",
|
|
"crm-accounts",
|
|
"crm-pipeline",
|
|
"crm-new-lead",
|
|
"crm-new-opportunity",
|
|
"crm-new-contact",
|
|
"crm-new-account",
|
|
"crm-stage-lead",
|
|
"crm-stage-qualified",
|
|
"crm-stage-proposal",
|
|
"crm-stage-negotiation",
|
|
"crm-stage-won",
|
|
"crm-stage-lost",
|
|
"crm-conversion-rate",
|
|
"crm-pipeline-value",
|
|
"crm-avg-deal",
|
|
"crm-won-month",
|
|
"billing-title",
|
|
"billing-subtitle",
|
|
"billing-invoices",
|
|
"billing-payments",
|
|
"billing-quotes",
|
|
"billing-new-invoice",
|
|
"billing-new-payment",
|
|
"billing-new-quote",
|
|
"billing-pending",
|
|
"billing-paid",
|
|
"billing-overdue",
|
|
"billing-revenue-month",
|
|
"billing-total-revenue",
|
|
"products-title",
|
|
"products-subtitle",
|
|
"products-items",
|
|
"products-services",
|
|
"products-pricelists",
|
|
"products-new-product",
|
|
"products-new-service",
|
|
"products-new-pricelist",
|
|
"products-total-products",
|
|
"products-total-services",
|
|
"products-active",
|
|
"products-inactive",
|
|
"products-sku",
|
|
"products-price",
|
|
"products-category",
|
|
];
|
|
|
|
pub fn get_translations_json(locale: &Locale) -> serde_json::Value {
|
|
let mut translations = serde_json::Map::new();
|
|
|
|
for key in TRANSLATION_KEYS {
|
|
let value = t(locale, key);
|
|
if value.contains('[') && value == format!("[{key}]") {
|
|
log::warn!("i18n: Missing translation for key: {} in locale: {:?}", key, locale);
|
|
}
|
|
translations.insert((*key).to_string(), serde_json::Value::String(value));
|
|
}
|
|
|
|
log::info!("i18n: Returning {} translations for locale: {}", translations.len(), locale);
|
|
serde_json::Value::Object(translations)
|
|
}
|
|
|
|
pub fn configure_i18n_routes() -> Router<Arc<AppState>> {
|
|
Router::new()
|
|
.route("/api/i18n/locales", get(handle_get_locales))
|
|
.route("/api/i18n/:locale", get(handle_get_translations))
|
|
}
|
|
|
|
async fn handle_get_locales(
|
|
State(_state): State<Arc<AppState>>,
|
|
) -> impl IntoResponse {
|
|
let locales = available_locales();
|
|
Json(serde_json::json!({
|
|
"locales": locales,
|
|
"default": "en"
|
|
}))
|
|
}
|
|
|
|
async fn handle_get_translations(
|
|
State(_state): State<Arc<AppState>>,
|
|
Path(locale_str): Path<String>,
|
|
) -> impl IntoResponse {
|
|
let locale = Locale::new(&locale_str).unwrap_or_default();
|
|
|
|
let translations = get_translations_json(&locale);
|
|
|
|
Json(serde_json::json!({
|
|
"locale": locale.to_bcp47(),
|
|
"translations": translations
|
|
}))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_parse_accept_language_simple() {
|
|
let result = parse_accept_language("en-US");
|
|
assert_eq!(result.len(), 1);
|
|
assert_eq!(result[0].0, "en-US");
|
|
assert!((result[0].1 - 1.0).abs() < f32::EPSILON);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_accept_language_with_quality() {
|
|
let result = parse_accept_language("pt-BR,pt;q=0.9,en;q=0.8");
|
|
assert_eq!(result.len(), 3);
|
|
assert_eq!(result[0].0, "pt-BR");
|
|
assert_eq!(result[1].0, "pt");
|
|
assert_eq!(result[2].0, "en");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_accept_language_sorted_by_quality() {
|
|
let result = parse_accept_language("en;q=0.5,pt-BR;q=0.9,es;q=0.7");
|
|
assert_eq!(result[0].0, "pt-BR");
|
|
assert_eq!(result[1].0, "es");
|
|
assert_eq!(result[2].0, "en");
|
|
}
|
|
|
|
#[test]
|
|
fn test_negotiate_locale_exact_match() {
|
|
let requested = vec![("pt-BR".to_string(), 1.0)];
|
|
let result = negotiate_locale(&requested);
|
|
assert!(result.is_some());
|
|
assert_eq!(
|
|
result.as_ref().map(|l| l.to_bcp47()),
|
|
Some("pt-BR".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_negotiate_locale_language_match() {
|
|
let requested = vec![("pt-PT".to_string(), 1.0)];
|
|
let result = negotiate_locale(&requested);
|
|
assert!(result.is_some());
|
|
assert_eq!(result.as_ref().map(|l| l.language()), Some("pt"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_negotiate_locale_fallback() {
|
|
let requested = vec![("ja".to_string(), 1.0)];
|
|
let result = negotiate_locale(&requested);
|
|
assert!(result.is_some());
|
|
assert_eq!(result.as_ref().map(|l| l.language()), Some("en"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_locale_default() {
|
|
let locale = Locale::default();
|
|
assert_eq!(locale.language(), "en");
|
|
assert_eq!(locale.region(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_locale_display() {
|
|
let locale = Locale::new("pt-BR").unwrap();
|
|
assert_eq!(locale.to_string(), "pt-BR");
|
|
}
|
|
|
|
#[test]
|
|
fn test_localized_error_not_found() {
|
|
let locale = Locale::default();
|
|
let error = LocalizedError::not_found(&locale, "User");
|
|
assert_eq!(error.code, "error-http-404");
|
|
}
|
|
|
|
#[test]
|
|
fn test_localized_error_with_details() {
|
|
let locale = Locale::default();
|
|
let error =
|
|
LocalizedError::internal(&locale).with_details(serde_json::json!({"trace_id": "abc123"}));
|
|
assert!(error.details.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_available_locales_without_init() {
|
|
let locales = available_locales();
|
|
assert!(!locales.is_empty());
|
|
}
|
|
}
|