// 語言: the menus' language and the language bots answer in (「選擇選單和訊息使用的語言」, // 37 languages + automatic). // // The window is written in Traditional Chinese. 简体中文 is converted from it by macOS // (ICU "Hant-Hans"), so every string is covered. English and 日本語 use the tables below for the // window's chrome (sidebar, header, composer, side panel, settings); the rest stays in Chinese. // Other languages show the menus in English and make the bots answer in that language. use std::collections::HashMap; use std::sync::{Mutex, OnceLock, RwLock}; /// The original's 37 languages: (tag, endonym, English name). pub const LANGS: [(&str, &str, &str); 37] = [ ("af", "Afrikaans", "Afrikaans"), ("ar", "العربية", "Arabic"), ("bn", "বাংলা", "Bengali"), ("cs", "Čeština", "Czech"), ("da", "Dansk", "Danish"), ("de", "Deutsch", "German"), ("el", "Ελληνικά", "Greek"), ("en", "English", "English"), ("es", "Español", "Spanish"), ("fi", "Suomi", "Finnish"), ("fr", "Français", "French"), ("ha", "Hausa", "Hausa"), ("he", "עברית", "Hebrew"), ("hi", "हिन्दी", "Hindi"), ("hu", "Magyar", "Hungarian"), ("id", "Bahasa Indonesia", "Indonesian"), ("it", "Italiano", "Italian"), ("ja", "日本語", "Japanese"), ("ko", "한국어", "Korean"), ("mr", "मराठी", "Marathi"), ("nb", "Norsk bokmål", "Norwegian Bokmål"), ("nl", "Nederlands", "Dutch"), ("pl", "Polski", "Polish"), ("pt-PT", "Português (Portugal)", "Portuguese (Portugal)"), ("pt", "Português (Brasil)", "Portuguese (Brazil)"), ("ru", "Русский", "Russian"), ("sv", "Svenska", "Swedish"), ("sw", "Kiswahili", "Swahili"), ("ta", "தமிழ்", "Tamil"), ("te", "తెలుగు", "Telugu"), ("th", "ไทย", "Thai"), ("tr", "Türkçe", "Turkish"), ("uk", "Українська", "Ukrainian"), ("ur", "اردو", "Urdu"), ("vi", "Tiếng Việt", "Vietnamese"), ("zh-CN", "简体中文", "Simplified Chinese"), ("zh-TW", "繁體中文", "Traditional Chinese"), ]; fn current() -> &'static RwLock { static L: OnceLock> = OnceLock::new(); L.get_or_init(|| RwLock::new("zh-TW".into())) } /// Replies are wanted in Traditional Chinese (the language setting says so, not "auto"). static REPLIES_HANT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); /// What the bot wrote, in Traditional Chinese with Taiwan wording when that is the setting. /// Models (Qwen especially) slip into Simplified characters despite the instruction. pub fn reply_text(s: &str) -> String { if REPLIES_HANT.load(std::sync::atomic::Ordering::Relaxed) && !s.is_empty() { zhconv::zhconv(s, zhconv::Variant::ZhTW) } else { s.to_string() } } /// "auto" follows the system language. pub fn set(tag: &str) { REPLIES_HANT.store(tag == "zh-TW", std::sync::atomic::Ordering::Relaxed); let t = if tag == "auto" || tag.is_empty() { system() } else { tag.to_string() }; *current().write().unwrap() = t; } pub fn lang() -> String { current().read().unwrap().clone() } /// The Mac's first preferred language, as one of LANGS (Traditional Chinese when unknown). pub fn system() -> String { if !cfg!(target_os = "macos") { // Linux: LANGUAGE (a list), then LC_ALL / LC_MESSAGES / LANG ("zh_TW.UTF-8"). let first = ["LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG"] .iter() .filter_map(|k| std::env::var(k).ok()) .map(|v| v.split(':').next().unwrap_or("").split('.').next().unwrap_or("").to_string()) .find(|v| !v.is_empty() && v != "C" && v != "POSIX") .unwrap_or_else(|| "zh-Hant-TW".into()); return match_tag(&first); } let out = std::process::Command::new("defaults").args(["read", "-g", "AppleLanguages"]).output().map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default(); let first = out.split('"').nth(1).unwrap_or("zh-Hant-TW").to_string(); match_tag(&first) } pub fn match_tag(code: &str) -> String { let c = code.replace('_', "-"); if c.starts_with("zh-Hans") || c == "zh-CN" || c == "zh-SG" { return "zh-CN".into(); } if c.starts_with("zh") { return "zh-TW".into(); } if c.starts_with("pt-PT") { return "pt-PT".into(); } let base = c.split('-').next().unwrap_or(""); LANGS.iter().find(|l| l.0 == base).map(|l| l.0.to_string()).unwrap_or_else(|| "zh-TW".into()) } pub fn english_name(tag: &str) -> &'static str { LANGS.iter().find(|l| l.0 == tag).map(|l| l.2).unwrap_or("Traditional Chinese") } pub fn endonym(tag: &str) -> &'static str { LANGS.iter().find(|l| l.0 == tag).map(|l| l.1).unwrap_or("繁體中文") } /// Added to every bot's instructions: answer in the chosen language ("auto" = the person's). pub fn reply_instruction(setting: &str) -> String { if setting == "auto" || setting.is_empty() { return "回答時使用使用者所用的語言。".into(); } match setting { "zh-TW" => "一律用繁體中文回答(台灣用語)。".into(), "zh-CN" => "一律用简体中文回答。".into(), t => format!("Always answer in {} ({}), whatever language the instructions are written in.", english_name(t), endonym(t)), } } fn cache() -> &'static Mutex> { static C: OnceLock>> = OnceLock::new(); C.get_or_init(|| Mutex::new(HashMap::new())) } /// A window string in the chosen language. pub fn t(s: &str) -> String { crate::platform::localize(&t_raw(s)) } fn t_raw(s: &str) -> String { let l = lang(); if l == "zh-TW" || s.is_empty() { return s.to_string(); } let key = format!("{l}\u{1}{s}"); if let Some(v) = cache().lock().unwrap().get(&key) { return v.clone(); } let v = match l.as_str() { "zh-CN" => to_simplified(s), "ja" => table(s, 2).or_else(|| table(s, 1)).unwrap_or_else(|| s.to_string()), _ => table(s, 1).unwrap_or_else(|| s.to_string()), }; cache().lock().unwrap().insert(key, v.clone()); v } fn table(s: &str, col: usize) -> Option { static M: OnceLock> = OnceLock::new(); let m = M.get_or_init(|| crate::i18n_table::ROWS.iter().map(|r| (r.0, (r.1, r.2))).collect()); m.get(s).map(|(en, ja)| if col == 2 { ja.to_string() } else { en.to_string() }).filter(|v| !v.is_empty()) } /// Traditional → Simplified with macOS's own converter (ICU transform "Hant-Hans"). #[cfg(target_os = "macos")] pub fn to_simplified(s: &str) -> String { use std::ffi::{c_char, c_void, CString}; type CFTypeRef = *const c_void; #[repr(C)] struct CFRange { location: isize, length: isize, } #[link(name = "CoreFoundation", kind = "framework")] extern "C" { fn CFStringCreateWithCString(alloc: CFTypeRef, s: *const c_char, enc: u32) -> CFTypeRef; fn CFStringCreateMutableCopy(alloc: CFTypeRef, max: isize, s: CFTypeRef) -> CFTypeRef; fn CFStringTransform(s: CFTypeRef, range: *mut CFRange, transform: CFTypeRef, reverse: u8) -> u8; fn CFStringGetLength(s: CFTypeRef) -> isize; fn CFStringGetMaximumSizeForEncoding(len: isize, enc: u32) -> isize; fn CFStringGetCString(s: CFTypeRef, buf: *mut c_char, size: isize, enc: u32) -> bool; fn CFRelease(o: CFTypeRef); } const UTF8: u32 = 0x0800_0100; let (Ok(c), Ok(tr)) = (CString::new(s), CString::new("Hant-Hans")) else { return s.to_string() }; unsafe { let src = CFStringCreateWithCString(std::ptr::null(), c.as_ptr(), UTF8); let rule = CFStringCreateWithCString(std::ptr::null(), tr.as_ptr(), UTF8); if src.is_null() || rule.is_null() { return s.to_string(); } let m = CFStringCreateMutableCopy(std::ptr::null(), 0, src); let ok = CFStringTransform(m, std::ptr::null_mut(), rule, 0) != 0; let size = CFStringGetMaximumSizeForEncoding(CFStringGetLength(m), UTF8) + 1; let mut buf = vec![0 as c_char; size as usize]; let got = CFStringGetCString(m, buf.as_mut_ptr(), size, UTF8); CFRelease(m); CFRelease(src); CFRelease(rule); if !ok || !got { return s.to_string(); } std::ffi::CStr::from_ptr(buf.as_ptr()).to_string_lossy().to_string() } } /// Traditional → Simplified elsewhere: zhconv (OpenCC and MediaWiki tables, pure Rust). #[cfg(not(target_os = "macos"))] pub fn to_simplified(s: &str) -> String { zhconv::zhconv(s, zhconv::Variant::ZhHans) } #[cfg(test)] mod tests { use super::*; #[test] fn languages_and_tables() { assert_eq!(LANGS.len(), 37); assert_eq!(match_tag("zh-Hant-TW"), "zh-TW"); set("zh-TW"); assert_eq!(reply_text("那边的软件"), "那邊的軟體"); assert_eq!(match_tag("zh-Hans-CN"), "zh-CN"); assert_eq!(match_tag("ja-JP"), "ja"); assert_eq!(match_tag("pt-PT"), "pt-PT"); assert_eq!(to_simplified("設定電腦與權限"), "设定电脑与权限"); set("en"); assert_eq!(t("一般"), "General"); set("ja"); assert_eq!(t("一般"), "一般"); assert_eq!(t("權限"), "権限"); set("de"); assert_eq!(t("權限"), "Permissions"); set("zh-TW"); assert_eq!(t("權限"), "權限"); assert!(reply_instruction("ko").contains("Korean")); // Every row has an English translation. for r in crate::i18n_table::ROWS { assert!(!r.1.is_empty() && !r.2.is_empty(), "{}", r.0); } } }