- Add isBasicFile() and openInDesigner() functions in drive.js - Remove .bas from inline editor, redirect to designer with bucket/path params - Update designer FileQuery to include bucket parameter - Add load_from_drive() function to fetch .bas content from MinIO - Fix designer initialization for HTMX dynamic loading - Parse URL params from both query string and hash fragment - Add parseBasicCodeToNodes() to convert BASIC code to visual nodes - Support TALK, HEAR, SET, IF, FOR, CALL, WAIT, GET, PARAM commands - Add saveToDrive() to save changes back to original location - Fix createNode() to return the created node - Add generateBasCode() for converting nodes back to BASIC
43 KiB
General Bots Internationalization (i18n) Strategy
Executive Summary
This document outlines a comprehensive internationalization strategy for the General Bots workspace, covering all projects: botserver, botui, botlib, botapp, botdevice, and bottest. The strategy leverages Rust's ecosystem with Fluent as the primary i18n framework, ensuring type-safe, maintainable translations across all components.
Table of Contents
- Architecture Overview
- Recommended Technology Stack
- Directory Structure
- Implementation Guide
- Translation Workflow
- Component-Specific Guidelines
- Best Practices
- Migration Plan
- Testing Strategy
- Appendix
Architecture Overview
Current State Analysis
The GB workspace contains hardcoded strings in multiple locations:
| Component | String Types | Priority |
|---|---|---|
botui |
UI labels, buttons, messages, tooltips | High |
botserver |
Error messages, API responses, bot templates | High |
botlib |
Error types, validation messages | Medium |
botapp |
Desktop app UI, settings, notifications | Medium |
botdevice |
Device messages, embedded UI | Low |
bottest |
Test assertions (keep in English) | Low |
Target Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Shared i18n Core (botlib) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ Fluent │ │ Locale │ │ Message Formatting │ │
│ │ Bundle │ │ Detection │ │ (dates, numbers, etc) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ botserver │ │ botui │ │ botapp │
│ (Backend) │ │ (Web UI) │ │ (Desktop) │
└─────────────┘ └─────────────┘ └─────────────┘
Recommended Technology Stack
Primary: Fluent (Project Fluent by Mozilla)
Why Fluent?
- Natural Language Support: Handles pluralization, gender, and complex grammatical rules
- Type Safety: Compile-time checks for message references
- Rust Native: First-class Rust support via
fluent-rs - Fallback Chain: Automatic fallback to base language
- Used in Production: Firefox, Thunderbird, and many large projects
Dependencies
Add to Cargo.toml files:
# botlib/Cargo.toml - Core i18n functionality
[dependencies]
fluent = "0.16"
fluent-bundle = "0.15"
fluent-syntax = "0.11"
fluent-langneg = "0.14"
intl-memoizer = "0.5"
unic-langid = "0.9"
sys-locale = "0.3" # For detecting system locale
[features]
i18n = ["fluent", "fluent-bundle", "fluent-syntax", "fluent-langneg", "intl-memoizer", "unic-langid", "sys-locale"]
# botserver/Cargo.toml - Add i18n feature
[dependencies.botlib]
path = "../botlib"
features = ["database", "i18n"]
# For Accept-Language header parsing
accept-language = "3.1"
# botui/Cargo.toml
[dependencies.botlib]
path = "../botlib"
features = ["i18n"]
Alternative Consideration: rust-i18n
For simpler use cases, rust-i18n provides a macro-based approach:
use rust_i18n::t;
rust_i18n::i18n!("locales");
fn main() {
let msg = t!("hello", name = "World");
}
Recommendation: Use Fluent for its superior handling of complex translations, especially important for a chatbot platform serving multiple regions.
Directory Structure
Centralized Locales (Recommended)
gb/
├── locales/ # Shared translation files
│ ├── en/ # English (base language)
│ │ ├── common.ftl # Shared strings
│ │ ├── errors.ftl # Error messages
│ │ ├── ui.ftl # UI labels
│ │ ├── notifications.ftl # Notifications
│ │ └── bot-templates.ftl # Bot dialog templates
│ ├── pt-BR/ # Brazilian Portuguese
│ │ ├── common.ftl
│ │ ├── errors.ftl
│ │ ├── ui.ftl
│ │ ├── notifications.ftl
│ │ └── bot-templates.ftl
│ ├── es/ # Spanish
│ │ └── ...
│ └── zh-CN/ # Simplified Chinese
│ └── ...
│
├── botlib/
│ └── src/
│ ├── i18n/ # i18n module
│ │ ├── mod.rs # Module exports
│ │ ├── bundle.rs # Fluent bundle management
│ │ ├── locale.rs # Locale detection/negotiation
│ │ ├── format.rs # Number/date formatting
│ │ └── macros.rs # Helper macros
│ └── lib.rs
│
├── botserver/
│ └── src/
│ └── core/
│ └── i18n.rs # Server-side i18n integration
│
└── botui/
├── src/
│ └── i18n.rs # UI i18n integration
└── ui/
└── suite/
└── js/
└── i18n.js # Client-side i18n for HTMX
Implementation Guide
Step 1: Create Core i18n Module in botlib
// botlib/src/i18n/mod.rs
mod bundle;
mod format;
mod locale;
pub use bundle::{FluentBundles, MessageId};
pub use format::{format_date, format_number, format_currency};
pub use locale::{Locale, detect_locale, negotiate_locale};
use std::sync::OnceLock;
use fluent_bundle::{FluentBundle, FluentResource, FluentArgs};
use unic_langid::LanguageIdentifier;
static BUNDLES: OnceLock<FluentBundles> = OnceLock::new();
pub fn init(locales_path: &str) -> Result<(), I18nError> {
let bundles = FluentBundles::load(locales_path)?;
BUNDLES.set(bundles).map_err(|_| I18nError::AlreadyInitialized)?;
Ok(())
}
pub fn get(locale: &Locale, message_id: &str) -> String {
get_with_args(locale, message_id, None)
}
pub fn get_with_args(locale: &Locale, message_id: &str, args: Option<&FluentArgs>) -> String {
BUNDLES
.get()
.expect("i18n not initialized")
.get_message(locale, message_id, args)
}
#[derive(Debug, thiserror::Error)]
pub enum I18nError {
#[error("i18n already initialized")]
AlreadyInitialized,
#[error("Failed to load locale {locale}: {reason}")]
LoadError { locale: String, reason: String },
#[error("Message not found: {0}")]
MessageNotFound(String),
}
// botlib/src/i18n/bundle.rs
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use fluent_bundle::{FluentBundle, FluentResource, FluentArgs, FluentValue};
use fluent_langneg::{negotiate_languages, NegotiationStrategy};
use unic_langid::LanguageIdentifier;
use super::{Locale, I18nError};
pub struct FluentBundles {
bundles: HashMap<LanguageIdentifier, FluentBundle<FluentResource>>,
available_locales: Vec<LanguageIdentifier>,
fallback: LanguageIdentifier,
}
impl FluentBundles {
pub fn load(base_path: &str) -> Result<Self, I18nError> {
let mut bundles = HashMap::new();
let mut available_locales = Vec::new();
let base = Path::new(base_path);
for entry in fs::read_dir(base).map_err(|e| I18nError::LoadError {
locale: "all".into(),
reason: e.to_string(),
})? {
let entry = entry.map_err(|e| I18nError::LoadError {
locale: "entry".into(),
reason: e.to_string(),
})?;
if entry.path().is_dir() {
let locale_name = entry.file_name().to_string_lossy().to_string();
let lang_id: LanguageIdentifier = locale_name.parse().map_err(|_| {
I18nError::LoadError {
locale: locale_name.clone(),
reason: "Invalid locale identifier".into(),
}
})?;
let bundle = Self::load_bundle(&entry.path(), &lang_id)?;
available_locales.push(lang_id.clone());
bundles.insert(lang_id, bundle);
}
}
let fallback: LanguageIdentifier = "en".parse().unwrap();
Ok(Self {
bundles,
available_locales,
fallback,
})
}
fn load_bundle(
locale_dir: &Path,
lang_id: &LanguageIdentifier,
) -> Result<FluentBundle<FluentResource>, I18nError> {
let mut bundle = FluentBundle::new(vec![lang_id.clone()]);
for entry in fs::read_dir(locale_dir).map_err(|e| I18nError::LoadError {
locale: lang_id.to_string(),
reason: e.to_string(),
})? {
let entry = entry.map_err(|e| I18nError::LoadError {
locale: lang_id.to_string(),
reason: e.to_string(),
})?;
let path = entry.path();
if path.extension().map_or(false, |ext| ext == "ftl") {
let source = fs::read_to_string(&path).map_err(|e| I18nError::LoadError {
locale: lang_id.to_string(),
reason: e.to_string(),
})?;
let resource = FluentResource::try_new(source).map_err(|(_, errors)| {
I18nError::LoadError {
locale: lang_id.to_string(),
reason: format!("Parse errors: {:?}", errors),
}
})?;
bundle.add_resource(resource).map_err(|errors| {
I18nError::LoadError {
locale: lang_id.to_string(),
reason: format!("Bundle errors: {:?}", errors),
}
})?;
}
}
Ok(bundle)
}
pub fn get_message(
&self,
locale: &Locale,
message_id: &str,
args: Option<&FluentArgs>,
) -> String {
let negotiated = negotiate_languages(
&[locale.as_langid()],
&self.available_locales,
Some(&self.fallback),
NegotiationStrategy::Filtering,
);
for lang_id in negotiated {
if let Some(bundle) = self.bundles.get(lang_id) {
if let Some(msg) = bundle.get_message(message_id) {
if let Some(pattern) = msg.value() {
let mut errors = vec![];
let result = bundle.format_pattern(pattern, args, &mut errors);
if errors.is_empty() {
return result.into_owned();
}
}
}
}
}
format!("[{}]", message_id)
}
pub fn available_locales(&self) -> &[LanguageIdentifier] {
&self.available_locales
}
}
// botlib/src/i18n/locale.rs
use unic_langid::LanguageIdentifier;
use sys_locale::get_locale;
#[derive(Debug, Clone)]
pub struct Locale {
lang_id: LanguageIdentifier,
}
impl Locale {
pub fn new(locale_str: &str) -> Option<Self> {
locale_str.parse().ok().map(|lang_id| Self { lang_id })
}
pub fn from_langid(lang_id: LanguageIdentifier) -> Self {
Self { lang_id }
}
pub fn as_langid(&self) -> &LanguageIdentifier {
&self.lang_id
}
pub fn language(&self) -> &str {
self.lang_id.language.as_str()
}
pub fn region(&self) -> Option<&str> {
self.lang_id.region.as_ref().map(|r| r.as_str())
}
}
impl Default for Locale {
fn default() -> Self {
detect_locale()
}
}
pub fn detect_locale() -> Locale {
get_locale()
.and_then(|l| Locale::new(&l))
.unwrap_or_else(|| Locale::new("en").unwrap())
}
pub fn negotiate_locale(
requested: &[&str],
available: &[&str],
) -> Locale {
use fluent_langneg::{negotiate_languages, NegotiationStrategy};
let requested: Vec<LanguageIdentifier> = requested
.iter()
.filter_map(|l| l.parse().ok())
.collect();
let available: Vec<LanguageIdentifier> = available
.iter()
.filter_map(|l| l.parse().ok())
.collect();
let fallback: LanguageIdentifier = "en".parse().unwrap();
let negotiated = negotiate_languages(
&requested.iter().collect::<Vec<_>>(),
&available.iter().collect::<Vec<_>>(),
Some(&fallback),
NegotiationStrategy::Filtering,
);
negotiated
.first()
.map(|l| Locale::from_langid((*l).clone()))
.unwrap_or_else(|| Locale::new("en").unwrap())
}
Step 2: Create Fluent Translation Files
# locales/en/common.ftl
# Brand
app-name = General Bots
app-tagline = Your AI-powered productivity workspace
# Common Actions
action-save = Save
action-cancel = Cancel
action-delete = Delete
action-edit = Edit
action-close = Close
action-confirm = Confirm
action-retry = Retry
action-back = Back
action-next = Next
action-submit = Submit
action-search = Search
action-refresh = Refresh
# Common Labels
label-loading = Loading...
label-no-results = No results found
label-error = Error
label-success = Success
label-warning = Warning
label-info = Information
# Dates and Times
time-now = Just now
time-minutes-ago = { $count ->
[one] { $count } minute ago
*[other] { $count } minutes ago
}
time-hours-ago = { $count ->
[one] { $count } hour ago
*[other] { $count } hours ago
}
time-days-ago = { $count ->
[one] { $count } day ago
*[other] { $count } days ago
}
# locales/en/errors.ftl
# HTTP Errors
error-http-400 = Bad request. Please check your input.
error-http-401 = Authentication required. Please log in.
error-http-403 = You don't have permission to access this resource.
error-http-404 = { $entity } not found.
error-http-409 = Conflict: { $message }
error-http-429 = Too many requests. Please wait { $seconds } seconds.
error-http-500 = Internal server error. Please try again later.
error-http-503 = Service temporarily unavailable.
error-http-504 = Request timed out after { $milliseconds }ms.
# Validation Errors
error-validation-required = { $field } is required.
error-validation-email = Please enter a valid email address.
error-validation-min-length = { $field } must be at least { $min } characters.
error-validation-max-length = { $field } must be no more than { $max } characters.
error-validation-pattern = { $field } format is invalid.
# Business Errors
error-config = Configuration error: { $message }
error-database = Database error: { $message }
error-auth = Authentication error: { $message }
error-rate-limit = Rate limited. Retry after { $seconds }s.
error-service-unavailable = Service unavailable: { $message }
error-internal = Internal error: { $message }
# locales/en/ui.ftl
# Navigation
nav-home = Home
nav-chat = Chat
nav-drive = Drive
nav-tasks = Tasks
nav-mail = Mail
nav-calendar = Calendar
nav-meet = Meet
nav-paper = Paper
nav-research = Research
nav-analytics = Analytics
nav-settings = Settings
# Dashboard
dashboard-title = Dashboard
dashboard-welcome = Welcome back, { $name }!
dashboard-quick-actions = Quick Actions
dashboard-recent-activity = Recent Activity
dashboard-no-activity = No recent activity yet. Start exploring!
# Chat
chat-title = Chat
chat-placeholder = Type your message...
chat-send = Send
chat-ai-thinking = AI is thinking...
chat-new-conversation = New Conversation
chat-history = Chat History
# Drive
drive-title = Drive
drive-upload = Upload Files
drive-new-folder = New Folder
drive-empty = No files yet. Upload something!
drive-file-size = { $size ->
[bytes] { $value } B
[kb] { $value } KB
[mb] { $value } MB
[gb] { $value } GB
*[other] { $value } bytes
}
# Tasks
tasks-title = Tasks
tasks-new = New Task
tasks-due-today = Due Today
tasks-overdue = Overdue
tasks-completed = Completed
tasks-priority-high = High Priority
tasks-priority-medium = Medium Priority
tasks-priority-low = Low Priority
# Calendar
calendar-title = Calendar
calendar-today = Today
calendar-new-event = New Event
calendar-all-day = All day
calendar-repeat = Repeat
calendar-reminder = Reminder
# Meet
meet-title = Meet
meet-join = Join Meeting
meet-start = Start Meeting
meet-mute = Mute
meet-unmute = Unmute
meet-video-on = Camera On
meet-video-off = Camera Off
meet-share-screen = Share Screen
meet-end-call = End Call
meet-participants = { $count ->
[one] { $count } participant
*[other] { $count } participants
}
# Mail
mail-title = Mail
mail-compose = Compose
mail-inbox = Inbox
mail-sent = Sent
mail-drafts = Drafts
mail-trash = Trash
mail-to = To
mail-subject = Subject
mail-reply = Reply
mail-forward = Forward
# Settings
settings-title = Settings
settings-general = General
settings-account = Account
settings-notifications = Notifications
settings-privacy = Privacy
settings-language = Language
settings-theme = Theme
settings-theme-light = Light
settings-theme-dark = Dark
settings-theme-system = System
# locales/en/bot-templates.ftl
# Default Bot Greetings
bot-greeting-default = Hello! How can I help you today?
bot-greeting-named = Hello, { $name }! How can I help you today?
bot-goodbye = Goodbye! Have a great day!
bot-help-prompt = I can help you with: { $topics }. What would you like to know?
bot-thank-you = Thank you for your message. How can I assist you today?
bot-echo-intro = Echo Bot: I will repeat everything you say. Type 'quit' to exit.
bot-you-said = You said: { $message }
# Lead Capture
bot-lead-welcome = Welcome! Let me help you get started.
bot-lead-ask-name = What's your name?
bot-lead-ask-email = And your email?
bot-lead-ask-company = What company are you from?
bot-lead-hot = Great! Our sales team will reach out shortly.
bot-lead-nurture = Thanks for your interest! We'll send you some resources.
# Scheduler
bot-schedule-created = Running scheduled task: { $name }
bot-monitor-alert = Alert: { $subject } has changed
# Order Management
bot-order-welcome = Welcome to our store! How can I help?
bot-order-track = Track my order
bot-order-browse = Browse products
bot-order-support = Contact support
bot-order-enter-id = Please enter your order number:
bot-order-status = Order status: { $status }
bot-order-ticket = Support ticket created: #{ $ticket }
# HR Assistant
bot-hr-welcome = HR Assistant here. How can I help?
bot-hr-request-leave = Request leave
bot-hr-check-balance = Check balance
bot-hr-view-policies = View policies
bot-hr-leave-type = What type of leave? (vacation/sick/personal)
bot-hr-start-date = Start date? (YYYY-MM-DD)
bot-hr-end-date = End date? (YYYY-MM-DD)
bot-hr-leave-submitted = Leave request submitted! Your manager will review it.
bot-hr-balance-title = Your leave balance:
bot-hr-vacation-days = Vacation: { $days } days
bot-hr-sick-days = Sick: { $days } days
# Healthcare Appointments
bot-health-welcome = Welcome to our healthcare center. How can I help?
bot-health-book = Book appointment
bot-health-cancel = Cancel appointment
bot-health-view = View my appointments
bot-health-type = What type of appointment? (general/specialist/lab)
# locales/pt-BR/common.ftl
# Brand
app-name = General Bots
app-tagline = Seu espaço de trabalho com IA
# Common Actions
action-save = Salvar
action-cancel = Cancelar
action-delete = Excluir
action-edit = Editar
action-close = Fechar
action-confirm = Confirmar
action-retry = Tentar novamente
action-back = Voltar
action-next = Próximo
action-submit = Enviar
action-search = Buscar
action-refresh = Atualizar
# Common Labels
label-loading = Carregando...
label-no-results = Nenhum resultado encontrado
label-error = Erro
label-success = Sucesso
label-warning = Atenção
label-info = Informação
# Dates and Times
time-now = Agora mesmo
time-minutes-ago = { $count ->
[one] { $count } minuto atrás
*[other] { $count } minutos atrás
}
time-hours-ago = { $count ->
[one] { $count } hora atrás
*[other] { $count } horas atrás
}
time-days-ago = { $count ->
[one] { $count } dia atrás
*[other] { $count } dias atrás
}
# locales/pt-BR/ui.ftl
# Navigation
nav-home = Início
nav-chat = Chat
nav-drive = Arquivos
nav-tasks = Tarefas
nav-mail = E-mail
nav-calendar = Calendário
nav-meet = Reuniões
nav-paper = Documentos
nav-research = Pesquisa
nav-analytics = Análises
nav-settings = Configurações
# Dashboard
dashboard-title = Painel
dashboard-welcome = Bem-vindo de volta, { $name }!
dashboard-quick-actions = Ações Rápidas
dashboard-recent-activity = Atividade Recente
dashboard-no-activity = Nenhuma atividade recente. Comece a explorar!
# AI Panel
ai-developer = Desenvolvedor IA
ai-developing = Desenvolvendo: { $project }
ai-quick-actions = Ações Rápidas
ai-add-field = Adicionar campo
ai-change-color = Mudar cor
ai-add-validation = Adicionar validação
ai-export-data = Exportar dados
ai-placeholder = Digite suas modificações...
# Quick Actions
quick-start-chat = Iniciar Chat
quick-upload-files = Enviar Arquivos
quick-new-task = Nova Tarefa
quick-compose-email = Escrever E-mail
quick-start-meeting = Iniciar Reunião
# App Descriptions
app-chat-desc = Conversas com IA. Faça perguntas, obtenha ajuda e automatize tarefas.
app-drive-desc = Armazenamento em nuvem para seus arquivos. Envie, organize e compartilhe.
app-tasks-desc = Mantenha-se organizado com listas de tarefas, prioridades e prazos.
app-mail-desc = Cliente de e-mail com escrita assistida por IA e organização inteligente.
app-calendar-desc = Agende reuniões, eventos e gerencie seu tempo efetivamente.
app-meet-desc = Videoconferência com compartilhamento de tela e transcrição ao vivo.
app-paper-desc = Escreva documentos com assistência de IA. Notas, relatórios e mais.
app-research-desc = Busca e descoberta com IA em todas as suas fontes.
app-analytics-desc = Painéis e relatórios para acompanhar uso e insights.
Step 3: Server-Side Integration
// botserver/src/core/i18n.rs
use axum::{
extract::FromRequestParts,
http::{header::ACCEPT_LANGUAGE, request::Parts},
};
use botlib::i18n::{Locale, negotiate_locale};
pub struct RequestLocale(pub Locale);
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)
.map(|langs| {
let available = ["en", "pt-BR", "es", "zh-CN"];
let requested: Vec<&str> = langs.iter().map(String::as_str).collect();
negotiate_locale(&requested, &available)
})
.unwrap_or_default();
Ok(RequestLocale(locale))
}
}
fn parse_accept_language(header: &str) -> Vec<String> {
let mut langs: Vec<(String, f32)> = header
.split(',')
.filter_map(|part| {
let mut iter = part.trim().split(';');
let lang = iter.next()?.trim().to_string();
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.into_iter().map(|(l, _)| l).collect()
}
// Usage in handlers
pub async fn example_handler(
RequestLocale(locale): RequestLocale,
) -> impl axum::response::IntoResponse {
use botlib::i18n::get;
let greeting = get(&locale, "dashboard-welcome");
// ...
}
Step 4: Template Integration (Askama)
// botui/src/i18n.rs
use askama::Template;
use botlib::i18n::{Locale, get, get_with_args};
use fluent_bundle::FluentArgs;
pub struct I18nContext {
locale: Locale,
}
impl I18nContext {
pub fn new(locale: Locale) -> Self {
Self { locale }
}
pub fn t(&self, key: &str) -> String {
get(&self.locale, key)
}
pub fn t_args(&self, key: &str, args: &FluentArgs) -> String {
get_with_args(&self.locale, key, Some(args))
}
}
// Custom Askama filter
pub mod filters {
use fluent_bundle::FluentArgs;
pub fn t(key: &str) -> askama::Result<String> {
// Uses thread-local or context locale
Ok(super::get_current_locale_message(key))
}
pub fn t_count(key: &str, count: i64) -> askama::Result<String> {
let mut args = FluentArgs::new();
args.set("count", count);
Ok(super::get_current_locale_message_with_args(key, &args))
}
}
Step 5: Client-Side JavaScript Integration
// botui/ui/suite/js/i18n.js
class I18n {
constructor() {
this.locale = document.documentElement.lang || 'en';
this.messages = {};
this.loaded = false;
}
async init() {
try {
const response = await fetch(`/api/i18n/${this.locale}`);
this.messages = await response.json();
this.loaded = true;
this.translatePage();
} catch (error) {
console.error('Failed to load translations:', error);
}
}
t(key, args = {}) {
let message = this.messages[key] || `[${key}]`;
// Simple interpolation
Object.entries(args).forEach(([k, v]) => {
message = message.replace(new RegExp(`\\{\\s*\\$${k}\\s*\\}`, 'g'), v);
});
return message;
}
translatePage() {
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.dataset.i18n;
const args = el.dataset.i18nArgs ? JSON.parse(el.dataset.i18nArgs) : {};
el.textContent = this.t(key, args);
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
el.placeholder = this.t(el.dataset.i18nPlaceholder);
});
document.querySelectorAll('[data-i18n-title]').forEach(el => {
el.title = this.t(el.dataset.i18nTitle);
});
}
setLocale(locale) {
this.locale = locale;
document.documentElement.lang = locale;
localStorage.setItem('gb-locale', locale);
return this.init();
}
}
// Global instance
window.i18n = new I18n();
document.addEventListener('DOMContentLoaded', () => window.i18n.init());
// HTMX integration - re-translate after content swap
document.body.addEventListener('htmx:afterSwap', () => {
if (window.i18n.loaded) {
window.i18n.translatePage();
}
});
Translation Workflow
1. String Extraction Process
# Use a custom script to extract strings from source
./scripts/extract-strings.sh
# Output structure:
# locales/
# en/
# extracted.ftl # New strings to translate
2. Translation Management
Option A: Self-hosted with Weblate
- Open-source translation management
- Git integration for syncing
.ftlfiles - Community translation support
Option B: Commercial (Crowdin, Lokalise)
- Better for teams with budget
- Professional translator access
- Quality assurance tools
3. CI/CD Integration
# .github/workflows/i18n.yml
name: i18n Checks
on: [push, pull_request]
jobs:
i18n-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check Fluent syntax
run: |
cargo install fluent-syntax
find locales -name "*.ftl" -exec fluent-syntax-check {} \;
- name: Check for missing translations
run: ./scripts/check-missing-translations.sh
- name: Check for unused translations
run: ./scripts/check-unused-translations.sh
Component-Specific Guidelines
botserver
| Area | Approach |
|---|---|
| API Error Responses | Use error codes + i18n lookup on client |
| Bot Templates | Store template keys, resolve at runtime |
| Log Messages | Keep in English (for debugging) |
| Validation | Return field names + error keys |
// Example: Localized API error response
#[derive(Serialize)]
pub struct ApiError {
pub code: String, // "error-http-404"
pub message: String, // Localized message
pub details: Option<Value>, // Additional context
}
impl ApiError {
pub fn not_found(locale: &Locale, entity: &str) -> Self {
let mut args = FluentArgs::new();
args.set("entity", entity);
Self {
code: "error-http-404".into(),
message: i18n::get_with_args(locale, "error-http-404", Some(&args)),
details: None,
}
}
}
botui
| Area | Approach |
|---|---|
| Static HTML | Use data-i18n attributes |
| Askama Templates | Use custom filters |
| JavaScript | Use window.i18n.t() |
| Dates/Numbers | Use Intl API |
<!-- Example: Translated HTML -->
<button data-i18n="action-save">Save</button>
<input placeholder="Search..." data-i18n-placeholder="action-search">
<span data-i18n="time-minutes-ago" data-i18n-args='{"count": 5}'>5 minutes ago</span>
botapp (Tauri)
// Use system locale detection
use sys_locale::get_locale;
fn main() {
let locale = get_locale().unwrap_or_else(|| "en".to_string());
botlib::i18n::init_with_locale(&locale);
}
botdevice
For embedded/IoT contexts with limited resources:
// Compile-time locale selection for embedded
#[cfg(feature = "locale-en")]
const MESSAGES: &[(&str, &str)] = include!("../locales/en/embedded.rs");
#[cfg(feature = "locale-pt-BR")]
const MESSAGES: &[(&str, &str)] = include!("../locales/pt-BR/embedded.rs");
Best Practices
1. Message ID Naming Convention
<category>-<subcategory>-<descriptor>
Examples:
- nav-home
- error-http-404
- action-save
- chat-placeholder
- bot-greeting-default
2. Avoid String Concatenation
// ❌ BAD: Concatenation breaks translation
format!("Hello, {}!", name)
// ✅ GOOD: Use placeholders
// Fluent: bot-greeting-named = Hello, { $name }!
i18n::get_with_args(locale, "bot-greeting-named", &args)
3. Handle Pluralization Properly
# ❌ BAD: Hardcoded plural
items-count = { $count } items
# ✅ GOOD: Proper pluralization
items-count = { $count ->
[zero] No items
[one] { $count } item
*[other] { $count } items
}
4. Context for Translators
# Provide context with comments
# This appears on the main navigation bar
nav-home = Home
# Button to save user settings (not documents)
action-save-settings = Save Settings
5. Date/Time Formatting
use chrono::{DateTime, Utc};
pub fn format_datetime(dt: &DateTime<Utc>, locale: &Locale) -> String {
// Use ICU-based formatting when available
match locale.language() {
"pt" => dt.format("%d/%m/%Y %H:%M").to_string(),
"en" => dt.format("%m/%d/%Y %I:%M %p").to_string(),
_ => dt.format("%Y-%m-%d %H:%M").to_string(),
}
}
6. Number Formatting
use num_format::{Locale as NumLocale, ToFormattedString};
pub fn format_number(n: i64, locale: &Locale) -> String {
let num_locale = match locale.language() {
"pt" => NumLocale::pt,
"es" => NumLocale::es,
"de" => NumLocale::de,
_ => NumLocale::en,
};
n.to_formatted_string(&num_locale)
}
Migration Plan
Phase 1: Foundation (Week 1-2) ✅ COMPLETE
- Add i18n dependencies to
botlib/Cargo.toml - Create core i18n module in
botlib(botlib/src/i18n/) - Set up
locales/directory structure (moved tobotlib/locales/) - Create base English translation files (700+ keys)
- Add i18n initialization to
botserver/main.rs
Phase 2: Server Integration (Week 3-4) ✅ COMPLETE
- Create
RequestLocaleextractor for Axum (botserver/src/core/i18n.rs) - Create
LocalizedErrorhelper for i18n error responses - Create
t()andt_with_args()translation functions - Add
/api/i18n/{locale}endpoint (serves .ftl translations as JSON)
Phase 3: UI Migration (Week 5-6) ✅ COMPLETE
- Add
data-i18nattributes to HTML templates (index.html, admin, analytics, meet, research) - Create JavaScript i18n client (
botui/ui/suite/js/i18n.js) with embedded fallbacks - Add app launcher icons for Admin, Sources, Tools, Attendant with i18n
- Migrate navigation and header strings
Phase 4: Bot Templates (Week 7-8) ✅ COMPLETE
- Create bot-templates.ftl with all bot messages (150 keys EN + PT-BR)
- Update BASIC interpreter to use i18n keys
- Add locale parameter to bot execution context
- Update template manager to resolve translations
Phase 5: Additional Languages (Week 9-10) 🔄 IN PROGRESS
- Complete Portuguese (pt-BR) translations (100% - 700/700 keys)
- Add Spanish (es) translations (10% - directory exists, common.ftl partial)
- Add Chinese (zh-CN) translations (0%)
- Create translation coverage script (
scripts/check-i18n.sh)
Phase 6: Polish & Documentation (Week 11-12) 🔄 IN PROGRESS
- Remove duplicate translations.js (consolidated to .ftl files)
- Create translator documentation
- Set up CI checks for translation coverage (
scripts/check-i18n.sh) - Performance optimization
Remaining Work - Detailed Checklist
HIGH PRIORITY - Complete UI i18n
-
Auth screens (
botui/ui/suite/auth/)- login.html - form labels, buttons, messages
- register.html - form labels, validation messages
- forgot-password.html - instructions, buttons
- reset-password.html - form labels, messages
-
Monitoring screens (
botui/ui/suite/monitoring/)- monitoring.html - dashboard title, metrics labels
- logs.html - filter labels, level names
- health.html - status labels, service names
- metrics.html - chart labels, time ranges
- alerts.html - alert types, severity levels
-
Sources screens (
botui/ui/suite/sources/)- index.html - page title, navigation
- accounts.html - account management labels
-
Tools screens (
botui/ui/suite/tools/)- compliance.html - all labels and buttons
-
Attendant screen (
botui/ui/suite/attendant/)- index.html - all UI elements
MEDIUM PRIORITY - Additional .ftl translations
-
Complete es (Spanish) locale
- es/ui.ftl - copy from en, translate ~700 keys
- es/common.ftl - copy from en, translate ~400 keys
- es/admin.ftl - copy from en, translate ~300 keys
- es/analytics.ftl - copy from en, translate ~170 keys
-
Add zh-CN (Chinese) locale
- Create zh-CN/ directory
- zh-CN/ui.ftl, common.ftl, admin.ftl, analytics.ftl, etc.
LOW PRIORITY - Bot Template Integration
- Update
botlib/src/basic/interpreter to use i18n - Add
localefield to bot execution context - Update template manager to resolve
t("key")in BASIC scripts
DOCUMENTATION
- Create
docs/i18n-guide.mdfor translators - Document Fluent syntax with examples
- Add translation contribution guidelines to CONTRIBUTING.md
Testing Strategy
Unit Tests
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_english_message() {
let locale = Locale::new("en").unwrap();
let msg = get(&locale, "action-save");
assert_eq!(msg, "Save");
}
#[test]
fn test_portuguese_message() {
let locale = Locale::new("pt-BR").unwrap();
let msg = get(&locale, "action-save");
assert_eq!(msg, "Salvar");
}
#[test]
fn test_fallback_to_english() {
let locale = Locale::new("xx").unwrap(); // Unknown locale
let msg = get(&locale, "action-save");
assert_eq!(msg, "Save"); // Falls back to English
}
#[test]
fn test_pluralization() {
let locale = Locale::new("en").unwrap();
let mut args = FluentArgs::new();
args.set("count", 1);
assert_eq!(get_with_args(&locale, "time-minutes-ago", Some(&args)), "1 minute ago");
args.set("count", 5);
assert_eq!(get_with_args(&locale, "time-minutes-ago", Some(&args)), "5 minutes ago");
}
#[test]
fn test_missing_message_returns_key() {
let locale = Locale::new("en").unwrap();
let msg = get(&locale, "non-existent-key");
assert_eq!(msg, "[non-existent-key]");
}
}
Integration Tests
#[tokio::test]
async fn test_localized_api_error() {
let app = create_test_app().await;
let response = app
.oneshot(
Request::builder()
.uri("/api/users/nonexistent")
.header("Accept-Language", "pt-BR")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), 404);
let body: ApiError = parse_body(response).await;
assert!(body.message.contains("não encontrado"));
}
Coverage Checks
#!/bin/bash
# scripts/check-missing-translations.sh
BASE_LOCALE="en"
LOCALES=("pt-BR" "es" "zh-CN")
for locale in "${LOCALES[@]}"; do
echo "Checking $locale..."
for file in locales/$BASE_LOCALE/*.ftl; do
filename=$(basename "$file")
target="locales/$locale/$filename"
if [ ! -f "$target" ]; then
echo " Missing file: $target"
continue
fi
# Extract message IDs
base_keys=$(grep -E "^[a-z]" "$file" | cut -d= -f1 | sort)
target_keys=$(grep -E "^[a-z]" "$target" | cut -d= -f1 | sort)
# Find missing keys
missing=$(comm -23 <(echo "$base_keys") <(echo "$target_keys"))
if [ -n "$missing" ]; then
echo " Missing in $target:"
echo "$missing" | sed 's/^/ /'
fi
done
done
Appendix
Supported Locales
| Code | Language | Region | Status |
|---|---|---|---|
en |
English | Default | ✅ Base |
pt-BR |
Portuguese | Brazil | 🔄 Priority |
es |
Spanish | General | 📋 Planned |
es-MX |
Spanish | Mexico | 📋 Planned |
zh-CN |
Chinese | Simplified | 📋 Planned |
zh-TW |
Chinese | Traditional | 📋 Planned |
fr |
French | General | 📋 Planned |
de |
German | General | 📋 Planned |
ja |
Japanese | 📋 Planned | |
ko |
Korean | 📋 Planned |
Fluent Syntax Quick Reference
# Simple message
hello = Hello World
# Message with variable
hello-name = Hello, { $name }!
# Pluralization
items = { $count ->
[zero] No items
[one] One item
*[other] { $count } items
}
# Selectors with gender
welcome = { $gender ->
[male] Welcome, Mr. { $name }
[female] Welcome, Ms. { $name }
*[other] Welcome, { $name }
}
# Nested placeholders
notification = { $user } sent you { $count ->
[one] a message
*[other] { $count } messages
}
# Terms (reusable)
-brand-name = General Bots
about = About { -brand-name }
# Attributes
login-button = Log In
.tooltip = Click to access your account
Resources
- Project Fluent
- fluent-rs Documentation
- Unicode CLDR - Locale data standards
- ICU Message Format
Changelog
| Version | Date | Changes |
|---|---|---|
| 1.0 | 2024-XX-XX | Initial strategy document |
| 1.1 | 2025-01-05 | Updated progress: Phase 1-4 complete, Phase 5-6 in progress |
| 1.2 | 2025-01-05 | Added detailed remaining work checklist |
| 1.3 | 2025-01-05 | Moved locales to botlib/locales/, removed duplicate translations.js |
Current File Structure
gb/
├── botlib/
│ ├── locales/ # ✅ Centralized translations
│ │ ├── en/
│ │ │ ├── admin.ftl # 326 keys
│ │ │ ├── analytics.ftl # 174 keys
│ │ │ ├── bot-templates.ftl # 150 keys
│ │ │ ├── channels.ftl # Channel-specific messages
│ │ │ ├── common.ftl # 400+ shared keys
│ │ │ ├── errors.ftl # Error messages
│ │ │ ├── notifications.ftl # Notification messages
│ │ │ └── ui.ftl # 680+ UI keys
│ │ ├── pt-BR/ # ✅ 100% translated
│ │ │ └── (same structure)
│ │ └── es/ # 🔄 10% translated
│ │ └── common.ftl (partial)
│ └── src/
│ └── i18n/
│ ├── mod.rs # ✅ Public API
│ ├── bundle.rs # ✅ FluentBundles implementation
│ └── locale.rs # ✅ Locale negotiation
│
├── botui/
│ └── ui/
│ └── suite/
│ ├── js/
│ │ └── i18n.js # ✅ Client-side i18n with fallbacks
│ ├── index.html # ✅ i18n for nav, apps dropdown
│ ├── admin/ # ✅ i18n added
│ ├── analytics/ # ✅ i18n added
│ ├── meet/ # ✅ i18n added
│ ├── research/ # ✅ i18n added
│ ├── auth/ # ❌ Needs i18n
│ ├── monitoring/ # ❌ Needs i18n
│ ├── sources/ # ❌ Needs i18n
│ ├── tools/ # ❌ Needs i18n
│ └── attendant/ # ❌ Needs i18n
│
└── botserver/
└── src/
└── core/
└── i18n.rs # ✅ RequestLocale extractor
Summary Statistics
| Metric | Value |
|---|---|
| Total translation keys (EN) | ~1,700 |
| PT-BR coverage | 100% |
| ES coverage | ~10% |
| UI screens with i18n | 10/18 (56%) |
| Remaining UI screens | 8 |