feat: add i18n to launcher (#4361)

* add anki_i18n and locale_config crates to launcher

* add launcher.ftl

* add tr to state

* replace most hardcoded strings with translations

* add support for `launcher` rustcfg to trim translations

* use marker structs to denote type of translations

* move underscores into generated code

* Update cargo-license, which may fix the license order issue (dae)
This commit is contained in:
llama 2025-09-27 14:58:46 +08:00 committed by GitHub
parent b0665a8ef1
commit e0b0d0d19b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 252 additions and 110 deletions

35
Cargo.lock generated
View file

@ -3555,6 +3555,7 @@ dependencies = [
name = "launcher" name = "launcher"
version = "1.0.0" version = "1.0.0"
dependencies = [ dependencies = [
"anki_i18n",
"anki_io", "anki_io",
"anki_process", "anki_process",
"anyhow", "anyhow",
@ -3563,6 +3564,7 @@ dependencies = [
"embed-resource", "embed-resource",
"libc", "libc",
"libc-stdhandle", "libc-stdhandle",
"locale_config",
"serde_json", "serde_json",
"widestring", "widestring",
"windows 0.61.3", "windows 0.61.3",
@ -3702,6 +3704,19 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed"
[[package]]
name = "locale_config"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d2c35b16f4483f6c26f0e4e9550717a2f6575bcd6f12a53ff0c490a94a6934"
dependencies = [
"lazy_static",
"objc",
"objc-foundation",
"regex",
"winapi",
]
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.13" version = "0.4.13"
@ -4380,6 +4395,26 @@ dependencies = [
"malloc_buf", "malloc_buf",
] ]
[[package]]
name = "objc-foundation"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9"
dependencies = [
"block",
"objc",
"objc_id",
]
[[package]]
name = "objc_id"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b"
dependencies = [
"objc",
]
[[package]] [[package]]
name = "object" name = "object"
version = "0.36.7" version = "0.36.7"

View file

@ -92,6 +92,7 @@ itertools = "0.14.0"
junction = "1.2.0" junction = "1.2.0"
libc = "0.2" libc = "0.2"
libc-stdhandle = "0.1" libc-stdhandle = "0.1"
locale_config = "0.3.0"
maplit = "1.0.2" maplit = "1.0.2"
nom = "8.0.0" nom = "8.0.0"
num-format = "0.4.4" num-format = "0.4.4"

33
ftl/core/launcher.ftl Normal file
View file

@ -0,0 +1,33 @@
launcher-title = Anki Launcher
launcher-press-enter-to-start = Press enter to start Anki.
launcher-anki-will-start-shortly = Anki will start shortly.
launcher-you-can-close-this-window = You can close this window.
launcher-updating-anki = Updating Anki...
launcher-latest-anki = Latest Anki (just press Enter)
launcher-choose-a-version = Choose a version
launcher-sync-project-changes = Sync project changes
launcher-keep-existing-version = Keep existing version ({ $current })
launcher-revert-to-previous = Revert to previous version ({ $prev })
launcher-allow-betas = Allow betas: { $state }
launcher-on = on
launcher-off = off
launcher-cache-downloads = Cache downloads: { $state }
launcher-download-mirror = Download mirror: { $state }
launcher-uninstall = Uninstall
launcher-invalid-input = Invalid input. Please try again.
launcher-latest-releases = Latest releases: { $releases }
launcher-enter-the-version-you-want = Enter the version you want to install:
launcher-versions-before-cant-be-installed = Versions before 2.1.50 can't be installed.
launcher-invalid-version = Invalid version.
launcher-unable-to-check-for-versions = Unable to check for Anki versions. Please check your internet connection.
launcher-checking-for-updates = Checking for updates...
launcher-uninstall-confirm = Uninstall Anki's program files? (y/n)
launcher-uninstall-cancelled = Uninstall cancelled.
launcher-program-files-removed = Program files removed.
launcher-remove-all-profiles-confirm = Remove all profiles/cards? (y/n)
launcher-user-data-removed = User data removed.
launcher-download-mirror-options = Download mirror options:
launcher-mirror-no-mirror = No mirror
launcher-mirror-china = China
launcher-mirror-disabled = Mirror disabled.
launcher-mirror-china-enabled = China mirror enabled.

View file

@ -8,11 +8,13 @@ publish = false
rust-version.workspace = true rust-version.workspace = true
[dependencies] [dependencies]
anki_i18n.workspace = true
anki_io.workspace = true anki_io.workspace = true
anki_process.workspace = true anki_process.workspace = true
anyhow.workspace = true anyhow.workspace = true
camino.workspace = true camino.workspace = true
dirs.workspace = true dirs.workspace = true
locale_config.workspace = true
serde_json.workspace = true serde_json.workspace = true
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] [target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]

View file

@ -10,6 +10,7 @@ use std::process::Command;
use std::time::SystemTime; use std::time::SystemTime;
use std::time::UNIX_EPOCH; use std::time::UNIX_EPOCH;
use anki_i18n::I18n;
use anki_io::copy_file; use anki_io::copy_file;
use anki_io::create_dir_all; use anki_io::create_dir_all;
use anki_io::modified_time; use anki_io::modified_time;
@ -31,6 +32,7 @@ use crate::platform::respawn_launcher;
mod platform; mod platform;
struct State { struct State {
tr: I18n<anki_i18n::Launcher>,
current_version: Option<String>, current_version: Option<String>,
prerelease_marker: std::path::PathBuf, prerelease_marker: std::path::PathBuf,
uv_install_root: std::path::PathBuf, uv_install_root: std::path::PathBuf,
@ -100,7 +102,14 @@ fn run() -> Result<()> {
let (exe_dir, resources_dir) = get_exe_and_resources_dirs()?; let (exe_dir, resources_dir) = get_exe_and_resources_dirs()?;
let locale = locale_config::Locale::user_default().to_string();
let mut state = State { let mut state = State {
tr: I18n::new(&[if !locale.is_empty() {
locale
} else {
"en".to_owned()
}]),
current_version: None, current_version: None,
prerelease_marker: uv_install_root.join("prerelease"), prerelease_marker: uv_install_root.join("prerelease"),
uv_install_root: uv_install_root.clone(), uv_install_root: uv_install_root.clone(),
@ -160,7 +169,7 @@ fn run() -> Result<()> {
} }
print!("\x1B[2J\x1B[H"); // Clear screen and move cursor to top print!("\x1B[2J\x1B[H"); // Clear screen and move cursor to top
println!("\x1B[1mAnki Launcher\x1B[0m\n"); println!("\x1B[1m{}\x1B[0m\n", state.tr.launcher_title());
ensure_os_supported()?; ensure_os_supported()?;
@ -178,15 +187,18 @@ fn run() -> Result<()> {
} }
if cfg!(unix) && !cfg!(target_os = "macos") { if cfg!(unix) && !cfg!(target_os = "macos") {
println!("\nPress enter to start Anki."); println!("\n{}", state.tr.launcher_press_enter_to_start());
let mut input = String::new(); let mut input = String::new();
let _ = stdin().read_line(&mut input); let _ = stdin().read_line(&mut input);
} else { } else {
// on Windows/macOS, the user needs to close the terminal/console // on Windows/macOS, the user needs to close the terminal/console
// currently, but ideas on how we can avoid this would be good! // currently, but ideas on how we can avoid this would be good!
println!(); println!();
println!("Anki will start shortly."); println!("{}", state.tr.launcher_anki_will_start_shortly());
println!("\x1B[1mYou can close this window.\x1B[0m\n"); println!(
"\x1B[1m{}\x1B[0m\n",
state.tr.launcher_you_can_close_this_window()
);
} }
// respawn the launcher as a disconnected subprocess for normal startup // respawn the launcher as a disconnected subprocess for normal startup
@ -258,7 +270,7 @@ fn handle_version_install_or_update(state: &State, choice: MainMenuChoice) -> Re
// Remove sync marker before attempting sync // Remove sync marker before attempting sync
let _ = remove_file(&state.sync_complete_marker); let _ = remove_file(&state.sync_complete_marker);
println!("Updating Anki...\n"); println!("{}\n", state.tr.launcher_updating_anki());
let python_version_trimmed = if state.user_python_version_path.exists() { let python_version_trimmed = if state.user_python_version_path.exists() {
let python_version = read_file(&state.user_python_version_path)?; let python_version = read_file(&state.user_python_version_path)?;
@ -440,44 +452,62 @@ fn file_timestamp_secs(path: &std::path::Path) -> i64 {
fn get_main_menu_choice(state: &State) -> Result<MainMenuChoice> { fn get_main_menu_choice(state: &State) -> Result<MainMenuChoice> {
loop { loop {
println!("1) Latest Anki (press Enter)"); println!("1) {}", state.tr.launcher_latest_anki());
println!("2) Choose a version"); println!("2) {}", state.tr.launcher_choose_a_version());
if let Some(current_version) = &state.current_version { if let Some(current_version) = &state.current_version {
let normalized_current = normalize_version(current_version); let normalized_current = normalize_version(current_version);
if state.pyproject_modified_by_user { if state.pyproject_modified_by_user {
println!("3) Sync project changes"); println!("3) {}", state.tr.launcher_sync_project_changes());
} else { } else {
println!("3) Keep existing version ({normalized_current})"); println!(
"3) {}",
state.tr.launcher_keep_existing_version(normalized_current)
);
} }
} }
if let Some(prev_version) = &state.previous_version { if let Some(prev_version) = &state.previous_version {
if state.current_version.as_ref() != Some(prev_version) { if state.current_version.as_ref() != Some(prev_version) {
let normalized_prev = normalize_version(prev_version); let normalized_prev = normalize_version(prev_version);
println!("4) Revert to previous version ({normalized_prev})"); println!(
"4) {}",
state.tr.launcher_revert_to_previous(normalized_prev)
);
} }
} }
println!(); println!();
let betas_enabled = state.prerelease_marker.exists(); let betas_enabled = state.prerelease_marker.exists();
println!( println!(
"5) Allow betas: {}", "5) {}",
if betas_enabled { "on" } else { "off" } state.tr.launcher_allow_betas(if betas_enabled {
state.tr.launcher_on()
} else {
state.tr.launcher_off()
})
); );
let cache_enabled = !state.no_cache_marker.exists(); let cache_enabled = !state.no_cache_marker.exists();
println!( println!(
"6) Cache downloads: {}", "6) {}",
if cache_enabled { "on" } else { "off" } state.tr.launcher_cache_downloads(if cache_enabled {
state.tr.launcher_on()
} else {
state.tr.launcher_off()
})
); );
let mirror_enabled = is_mirror_enabled(state); let mirror_enabled = is_mirror_enabled(state);
println!( println!(
"7) Download mirror: {}", "7) {}",
if mirror_enabled { "on" } else { "off" } state.tr.launcher_download_mirror(if mirror_enabled {
state.tr.launcher_on()
} else {
state.tr.launcher_off()
})
); );
println!(); println!();
println!("8) Uninstall"); println!("8) {}", state.tr.launcher_uninstall());
print!("> "); print!("> ");
let _ = stdout().flush(); let _ = stdout().flush();
@ -499,7 +529,7 @@ fn get_main_menu_choice(state: &State) -> Result<MainMenuChoice> {
if state.current_version.is_some() { if state.current_version.is_some() {
MainMenuChoice::KeepExisting MainMenuChoice::KeepExisting
} else { } else {
println!("Invalid input. Please try again.\n"); println!("{}\n", state.tr.launcher_invalid_input());
continue; continue;
} }
} }
@ -511,7 +541,7 @@ fn get_main_menu_choice(state: &State) -> Result<MainMenuChoice> {
} }
} }
} }
println!("Invalid input. Please try again.\n"); println!("{}\n", state.tr.launcher_invalid_input());
continue; continue;
} }
"5" => MainMenuChoice::ToggleBetas, "5" => MainMenuChoice::ToggleBetas,
@ -519,7 +549,7 @@ fn get_main_menu_choice(state: &State) -> Result<MainMenuChoice> {
"7" => MainMenuChoice::DownloadMirror, "7" => MainMenuChoice::DownloadMirror,
"8" => MainMenuChoice::Uninstall, "8" => MainMenuChoice::Uninstall,
_ => { _ => {
println!("Invalid input. Please try again."); println!("{}\n", state.tr.launcher_invalid_input());
continue; continue;
} }
}); });
@ -534,9 +564,9 @@ fn get_version_kind(state: &State) -> Result<Option<VersionKind>> {
.map(|v| v.as_str()) .map(|v| v.as_str())
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .join(", ");
println!("Latest releases: {releases_str}"); println!("{}", state.tr.launcher_latest_releases(releases_str));
println!("Enter the version you want to install:"); println!("{}", state.tr.launcher_enter_the_version_you_want());
print!("> "); print!("> ");
let _ = stdout().flush(); let _ = stdout().flush();
@ -560,11 +590,11 @@ fn get_version_kind(state: &State) -> Result<Option<VersionKind>> {
Ok(Some(version_kind)) Ok(Some(version_kind))
} }
(None, true) => { (None, true) => {
println!("Versions before 2.1.50 can't be installed."); println!("{}", state.tr.launcher_versions_before_cant_be_installed());
Ok(None) Ok(None)
} }
_ => { _ => {
println!("Invalid version.\n"); println!("{}\n", state.tr.launcher_invalid_version());
Ok(None) Ok(None)
} }
} }
@ -700,7 +730,7 @@ fn fetch_versions(state: &State) -> Result<Vec<String>> {
let output = match cmd.utf8_output() { let output = match cmd.utf8_output() {
Ok(output) => output, Ok(output) => output,
Err(e) => { Err(e) => {
print!("Unable to check for Anki versions. Please check your internet connection.\n\n"); print!("{}\n\n", state.tr.launcher_unable_to_check_for_versions());
return Err(e.into()); return Err(e.into());
} }
}; };
@ -709,7 +739,7 @@ fn fetch_versions(state: &State) -> Result<Vec<String>> {
} }
fn get_releases(state: &State) -> Result<Releases> { fn get_releases(state: &State) -> Result<Releases> {
println!("Checking for updates..."); println!("{}", state.tr.launcher_checking_for_updates());
let include_prereleases = state.prerelease_marker.exists(); let include_prereleases = state.prerelease_marker.exists();
let all_versions = fetch_versions(state)?; let all_versions = fetch_versions(state)?;
let all_versions = filter_and_normalize_versions(all_versions, include_prereleases); let all_versions = filter_and_normalize_versions(all_versions, include_prereleases);
@ -911,7 +941,7 @@ fn get_anki_addons21_path() -> Result<std::path::PathBuf> {
} }
fn handle_uninstall(state: &State) -> Result<bool> { fn handle_uninstall(state: &State) -> Result<bool> {
println!("Uninstall Anki's program files? (y/n)"); println!("{}", state.tr.launcher_uninstall_confirm());
print!("> "); print!("> ");
let _ = stdout().flush(); let _ = stdout().flush();
@ -920,7 +950,7 @@ fn handle_uninstall(state: &State) -> Result<bool> {
let input = input.trim().to_lowercase(); let input = input.trim().to_lowercase();
if input != "y" { if input != "y" {
println!("Uninstall cancelled."); println!("{}", state.tr.launcher_uninstall_cancelled());
println!(); println!();
return Ok(false); return Ok(false);
} }
@ -928,11 +958,11 @@ fn handle_uninstall(state: &State) -> Result<bool> {
// Remove program files // Remove program files
if state.uv_install_root.exists() { if state.uv_install_root.exists() {
anki_io::remove_dir_all(&state.uv_install_root)?; anki_io::remove_dir_all(&state.uv_install_root)?;
println!("Program files removed."); println!("{}", state.tr.launcher_program_files_removed());
} }
println!(); println!();
println!("Remove all profiles/cards? (y/n)"); println!("{}", state.tr.launcher_remove_all_profiles_confirm());
print!("> "); print!("> ");
let _ = stdout().flush(); let _ = stdout().flush();
@ -942,7 +972,7 @@ fn handle_uninstall(state: &State) -> Result<bool> {
if input == "y" && state.anki_base_folder.exists() { if input == "y" && state.anki_base_folder.exists() {
anki_io::remove_dir_all(&state.anki_base_folder)?; anki_io::remove_dir_all(&state.anki_base_folder)?;
println!("User data removed."); println!("{}", state.tr.launcher_user_data_removed());
} }
println!(); println!();
@ -1036,9 +1066,9 @@ fn get_mirror_urls(state: &State) -> Result<Option<(String, String)>> {
fn show_mirror_submenu(state: &State) -> Result<()> { fn show_mirror_submenu(state: &State) -> Result<()> {
loop { loop {
println!("Download mirror options:"); println!("{}", state.tr.launcher_download_mirror_options());
println!("1) No mirror"); println!("1) {}", state.tr.launcher_mirror_no_mirror());
println!("2) China"); println!("2) {}", state.tr.launcher_mirror_china());
print!("> "); print!("> ");
let _ = stdout().flush(); let _ = stdout().flush();
@ -1052,14 +1082,14 @@ fn show_mirror_submenu(state: &State) -> Result<()> {
if state.mirror_path.exists() { if state.mirror_path.exists() {
let _ = remove_file(&state.mirror_path); let _ = remove_file(&state.mirror_path);
} }
println!("Mirror disabled."); println!("{}", state.tr.launcher_mirror_disabled());
break; break;
} }
"2" => { "2" => {
// Write China mirror URLs // Write China mirror URLs
let china_mirrors = "https://registry.npmmirror.com/-/binary/python-build-standalone/\nhttps://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/"; let china_mirrors = "https://registry.npmmirror.com/-/binary/python-build-standalone/\nhttps://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/";
write_file(&state.mirror_path, china_mirrors)?; write_file(&state.mirror_path, china_mirrors)?;
println!("China mirror enabled."); println!("{}", state.tr.launcher_mirror_china_enabled());
break; break;
} }
"" => { "" => {
@ -1067,7 +1097,7 @@ fn show_mirror_submenu(state: &State) -> Result<()> {
break; break;
} }
_ => { _ => {
println!("Invalid input. Please try again."); println!("{}", state.tr.launcher_invalid_input());
continue; continue;
} }
} }

View file

@ -23,10 +23,10 @@ use write_strings::write_strings;
fn main() -> Result<()> { fn main() -> Result<()> {
// generate our own requirements // generate our own requirements
let map = get_ftl_data(); let mut map = get_ftl_data();
check(&map); check(&map);
let modules = get_modules(&map); let mut modules = get_modules(&map);
write_strings(&map, &modules); write_strings(&map, &modules, "strings.rs", "All");
typescript::write_ts_interface(&modules)?; typescript::write_ts_interface(&modules)?;
python::write_py_interface(&modules)?; python::write_py_interface(&modules)?;
@ -41,5 +41,12 @@ fn main() -> Result<()> {
write_file_if_changed(path, meta_json)?; write_file_if_changed(path, meta_json)?;
} }
} }
// generate strings for the launcher
map.iter_mut()
.for_each(|(_, modules)| modules.retain(|module, _| module == "launcher"));
modules.retain(|module| module.name == "launcher");
write_strings(&map, &modules, "strings_launcher.rs", "Launcher");
Ok(()) Ok(())
} }

View file

@ -1,8 +1,15 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
// Include auto-generated content
#![allow(clippy::all)] #![allow(clippy::all)]
#[derive(Clone)]
pub struct All;
// Include auto-generated content
include!(concat!(env!("OUT_DIR"), "/strings.rs")); include!(concat!(env!("OUT_DIR"), "/strings.rs"));
impl Translations for All {
const STRINGS: &phf::Map<&str, &phf::Map<&str, &str>> = &_STRINGS;
const KEYS_BY_MODULE: &[&[&str]] = &_KEYS_BY_MODULE;
}

View file

@ -0,0 +1,15 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
#![allow(clippy::all)]
#[derive(Clone)]
pub struct Launcher;
// Include auto-generated content
include!(concat!(env!("OUT_DIR"), "/strings_launcher.rs"));
impl Translations for Launcher {
const STRINGS: &phf::Map<&str, &phf::Map<&str, &str>> = &_STRINGS;
const KEYS_BY_MODULE: &[&[&str]] = &_KEYS_BY_MODULE;
}

View file

@ -2,8 +2,10 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
mod generated; mod generated;
mod generated_launcher;
use std::borrow::Cow; use std::borrow::Cow;
use std::marker::PhantomData;
use std::sync::Arc; use std::sync::Arc;
use std::sync::Mutex; use std::sync::Mutex;
@ -12,8 +14,6 @@ use fluent::FluentArgs;
use fluent::FluentResource; use fluent::FluentResource;
use fluent::FluentValue; use fluent::FluentValue;
use fluent_bundle::bundle::FluentBundle as FluentBundleOrig; use fluent_bundle::bundle::FluentBundle as FluentBundleOrig;
use generated::KEYS_BY_MODULE;
use generated::STRINGS;
use num_format::Locale; use num_format::Locale;
use serde::Serialize; use serde::Serialize;
use unic_langid::LanguageIdentifier; use unic_langid::LanguageIdentifier;
@ -22,6 +22,9 @@ type FluentBundle<T> = FluentBundleOrig<T, intl_memoizer::concurrent::IntlLangMe
pub use fluent::fluent_args as tr_args; pub use fluent::fluent_args as tr_args;
pub use crate::generated::All;
pub use crate::generated_launcher::Launcher;
pub trait Number: Into<FluentNumber> { pub trait Number: Into<FluentNumber> {
fn round(self) -> Self; fn round(self) -> Self;
} }
@ -187,20 +190,67 @@ fn get_bundle_with_extra(
get_bundle(text, extra_text, &locales) get_bundle(text, extra_text, &locales)
} }
#[derive(Clone)] pub trait Translations {
pub struct I18n { const STRINGS: &phf::Map<&str, &phf::Map<&str, &str>>;
inner: Arc<Mutex<I18nInner>>, const KEYS_BY_MODULE: &[&[&str]];
} }
fn get_key(module_idx: usize, translation_idx: usize) -> &'static str { #[derive(Clone)]
KEYS_BY_MODULE pub struct I18n<P: Translations = All> {
inner: Arc<Mutex<I18nInner>>,
_translations_type: std::marker::PhantomData<P>,
}
impl<P: Translations> I18n<P> {
fn get_key(module_idx: usize, translation_idx: usize) -> &'static str {
P::KEYS_BY_MODULE
.get(module_idx) .get(module_idx)
.and_then(|translations| translations.get(translation_idx)) .and_then(|translations| translations.get(translation_idx))
.cloned() .cloned()
.unwrap_or("invalid-module-or-translation-index") .unwrap_or("invalid-module-or-translation-index")
} }
fn get_modules(langs: &[LanguageIdentifier], desired_modules: &[String]) -> Vec<String> {
langs
.iter()
.cloned()
.map(|lang| {
let mut buf = String::new();
let lang_name = remapped_lang_name(&lang);
if let Some(strings) = P::STRINGS.get(lang_name) {
if desired_modules.is_empty() {
// empty list, provide all modules
for value in strings.values() {
buf.push_str(value)
}
} else {
for module_name in desired_modules {
if let Some(text) = strings.get(module_name.as_str()) {
buf.push_str(text);
}
}
}
}
buf
})
.collect()
}
/// This temporarily behaves like the older code; in the future we could
/// either access each &str separately, or load them on demand.
fn ftl_localized_text(lang: &LanguageIdentifier) -> Option<String> {
let lang = remapped_lang_name(lang);
if let Some(module) = P::STRINGS.get(lang) {
let mut text = String::new();
for module_text in module.values() {
text.push_str(module_text)
}
Some(text)
} else {
None
}
}
impl I18n {
pub fn template_only() -> Self { pub fn template_only() -> Self {
Self::new::<&str>(&[]) Self::new::<&str>(&[])
} }
@ -225,7 +275,7 @@ impl I18n {
let mut output_langs = vec![]; let mut output_langs = vec![];
for lang in input_langs { for lang in input_langs {
// if the language is bundled in the binary // if the language is bundled in the binary
if let Some(text) = ftl_localized_text(&lang).or_else(|| { if let Some(text) = Self::ftl_localized_text(&lang).or_else(|| {
// when testing, allow missing translations // when testing, allow missing translations
if cfg!(test) { if cfg!(test) {
Some(String::new()) Some(String::new())
@ -244,7 +294,7 @@ impl I18n {
// add English templates // add English templates
let template_lang = "en-US".parse().unwrap(); let template_lang = "en-US".parse().unwrap();
let template_text = ftl_localized_text(&template_lang).unwrap(); let template_text = Self::ftl_localized_text(&template_lang).unwrap();
let template_bundle = get_bundle_with_extra(&template_text, None).unwrap(); let template_bundle = get_bundle_with_extra(&template_text, None).unwrap();
bundles.push(template_bundle); bundles.push(template_bundle);
output_langs.push(template_lang); output_langs.push(template_lang);
@ -261,6 +311,7 @@ impl I18n {
bundles, bundles,
langs: output_langs, langs: output_langs,
})), })),
_translations_type: PhantomData,
} }
} }
@ -270,7 +321,7 @@ impl I18n {
message_index: usize, message_index: usize,
args: FluentArgs, args: FluentArgs,
) -> String { ) -> String {
let key = get_key(module_index, message_index); let key = Self::get_key(module_index, message_index);
self.translate(key, Some(args)).into() self.translate(key, Some(args)).into()
} }
@ -305,7 +356,7 @@ impl I18n {
/// implementation. /// implementation.
pub fn resources_for_js(&self, desired_modules: &[String]) -> ResourcesForJavascript { pub fn resources_for_js(&self, desired_modules: &[String]) -> ResourcesForJavascript {
let inner = self.inner.lock().unwrap(); let inner = self.inner.lock().unwrap();
let resources = get_modules(&inner.langs, desired_modules); let resources = Self::get_modules(&inner.langs, desired_modules);
ResourcesForJavascript { ResourcesForJavascript {
langs: inner.langs.iter().map(ToString::to_string).collect(), langs: inner.langs.iter().map(ToString::to_string).collect(),
resources, resources,
@ -313,47 +364,6 @@ impl I18n {
} }
} }
fn get_modules(langs: &[LanguageIdentifier], desired_modules: &[String]) -> Vec<String> {
langs
.iter()
.cloned()
.map(|lang| {
let mut buf = String::new();
let lang_name = remapped_lang_name(&lang);
if let Some(strings) = STRINGS.get(lang_name) {
if desired_modules.is_empty() {
// empty list, provide all modules
for value in strings.values() {
buf.push_str(value)
}
} else {
for module_name in desired_modules {
if let Some(text) = strings.get(module_name.as_str()) {
buf.push_str(text);
}
}
}
}
buf
})
.collect()
}
/// This temporarily behaves like the older code; in the future we could either
/// access each &str separately, or load them on demand.
fn ftl_localized_text(lang: &LanguageIdentifier) -> Option<String> {
let lang = remapped_lang_name(lang);
if let Some(module) = STRINGS.get(lang) {
let mut text = String::new();
for module_text in module.values() {
text.push_str(module_text)
}
Some(text)
} else {
None
}
}
struct I18nInner { struct I18nInner {
// bundles in preferred language order, with template English as the // bundles in preferred language order, with template English as the
// last element // last element
@ -490,7 +500,7 @@ mod test {
#[test] #[test]
fn i18n() { fn i18n() {
// English template // English template
let tr = I18n::new(&["zz"]); let tr = I18n::<All>::new(&["zz"]);
assert_eq!(tr.translate("valid-key", None), "a valid key"); assert_eq!(tr.translate("valid-key", None), "a valid key");
assert_eq!(tr.translate("invalid-key", None), "invalid-key"); assert_eq!(tr.translate("invalid-key", None), "invalid-key");
@ -513,7 +523,7 @@ mod test {
); );
// Another language // Another language
let tr = I18n::new(&["ja_JP"]); let tr = I18n::<All>::new(&["ja_JP"]);
assert_eq!(tr.translate("valid-key", None), "キー"); assert_eq!(tr.translate("valid-key", None), "キー");
assert_eq!(tr.translate("only-in-english", None), "not translated"); assert_eq!(tr.translate("only-in-english", None), "not translated");
assert_eq!(tr.translate("invalid-key", None), "invalid-key"); assert_eq!(tr.translate("invalid-key", None), "invalid-key");
@ -524,7 +534,7 @@ mod test {
); );
// Decimal separator // Decimal separator
let tr = I18n::new(&["pl-PL"]); let tr = I18n::<All>::new(&["pl-PL"]);
// Polish will use a comma if the string is translated // Polish will use a comma if the string is translated
assert_eq!( assert_eq!(
tr.translate("one-arg-key", Some(tr_args!["one"=>2.07])), tr.translate("one-arg-key", Some(tr_args!["one"=>2.07])),

View file

@ -15,7 +15,7 @@ use crate::extract::VariableKind;
use crate::gather::TranslationsByFile; use crate::gather::TranslationsByFile;
use crate::gather::TranslationsByLang; use crate::gather::TranslationsByLang;
pub fn write_strings(map: &TranslationsByLang, modules: &[Module]) { pub fn write_strings(map: &TranslationsByLang, modules: &[Module], out_fn: &str, tag: &str) {
let mut buf = String::new(); let mut buf = String::new();
// lang->module map // lang->module map
@ -25,23 +25,25 @@ pub fn write_strings(map: &TranslationsByLang, modules: &[Module]) {
// ordered list of translations by module // ordered list of translations by module
write_translation_key_index(modules, &mut buf); write_translation_key_index(modules, &mut buf);
// methods to generate messages // methods to generate messages
write_methods(modules, &mut buf); write_methods(modules, &mut buf, tag);
let dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); let dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
let path = dir.join("strings.rs"); let path = dir.join(out_fn);
fs::write(path, buf).unwrap(); fs::write(path, buf).unwrap();
} }
fn write_methods(modules: &[Module], buf: &mut String) { fn write_methods(modules: &[Module], buf: &mut String, tag: &str) {
buf.push_str( buf.push_str(
r#" r#"
use crate::{I18n,Number}; #[allow(unused_imports)]
use crate::{I18n,Number,Translations};
#[allow(unused_imports)]
use fluent::{FluentValue, FluentArgs}; use fluent::{FluentValue, FluentArgs};
use std::borrow::Cow; use std::borrow::Cow;
impl I18n {
"#, "#,
); );
writeln!(buf, "impl I18n<{tag}> {{").unwrap();
for module in modules { for module in modules {
for translation in &module.translations { for translation in &module.translations {
let func = translation.key.to_snake_case(); let func = translation.key.to_snake_case();
@ -142,7 +144,7 @@ fn write_translation_key_index(modules: &[Module], buf: &mut String) {
writeln!( writeln!(
buf, buf,
"pub(crate) const KEYS_BY_MODULE: [&[&str]; {count}] = [", "pub(crate) const _KEYS_BY_MODULE: [&[&str]; {count}] = [",
count = modules.len(), count = modules.len(),
) )
.unwrap(); .unwrap();
@ -162,7 +164,7 @@ fn write_translation_key_index(modules: &[Module], buf: &mut String) {
fn write_lang_map(map: &TranslationsByLang, buf: &mut String) { fn write_lang_map(map: &TranslationsByLang, buf: &mut String) {
buf.push_str( buf.push_str(
" "
pub(crate) const STRINGS: phf::Map<&str, &phf::Map<&str, &str>> = phf::phf_map! { pub(crate) const _STRINGS: phf::Map<&str, &phf::Map<&str, &str>> = phf::phf_map! {
", ",
); );

View file

@ -256,7 +256,7 @@ fn check_for_unstaged_changes() {
fn generate_licences() -> Result<String> { fn generate_licences() -> Result<String> {
if which::which("cargo-license").is_err() { if which::which("cargo-license").is_err() {
Command::run("cargo install cargo-license@0.5.1")?; Command::run("cargo install cargo-license@0.7.0")?;
} }
let output = Command::run_with_output([ let output = Command::run_with_output([
"cargo-license", "cargo-license",