mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-13 09:27:03 +01:00
Refresh Cargo.lock to the newest compatible versions and move three crates to their next minor release: - parking_lot 0.12 -> 0.13 - rusqlite 0.40 -> 0.41 (newer bundled SQLite) - gettext-rs 0.7 -> 0.8 gettext-rs 0.8 marks setlocale as unsafe; the three calls in i18n.rs run before any thread starts, the same precondition the set_var calls next to them already state. rustc 1.99 deprecates AtomicX::fetch_update in favour of try_update (stable since 1.95, below the 1.96 minimum), which failed `clippy -D warnings` on main. Rename the two uses so clippy passes without `-A deprecated`. The open Dependabot alerts for zip (< 9.0.5) and rustls (< 0.23.46) are already resolved on main by #69 (zip 9.2.0, rustls 0.23.46).
396 lines
14 KiB
Rust
396 lines
14 KiB
Rust
//! Translation layer shared by `pdfs-app`, `pdfs-tray` and `pdfs-prompt`.
|
|
//!
|
|
//! Strings go through GNU gettext in the `pdfs` text domain. [`init`] runs first
|
|
//! in every binary: it applies the language picked in Preferences (if any) on
|
|
//! top of the system locale, then binds the domain to the installed catalogs.
|
|
//!
|
|
//! Messages with values use named placeholders (`{name}`) filled in by
|
|
//! [`gettext_f`] and [`ngettext_f`], so translators can reorder them. `po/`
|
|
//! holds the template and the catalogs; see `docs/TRANSLATING.md`.
|
|
|
|
// Each binary includes this module and uses a different part of it.
|
|
#![allow(dead_code)]
|
|
|
|
use gettextrs::LocaleCategory;
|
|
use pdfs_core::config::AppDirs;
|
|
|
|
/// The gettext text domain, matching `po/pdfs.pot` and the installed `.mo` name.
|
|
pub(crate) const DOMAIN: &str = "pdfs";
|
|
|
|
/// Overrides the catalog directory at run time, for running from a source tree
|
|
/// (`PDFS_LOCALEDIR=target/locale`).
|
|
const LOCALEDIR_ENV: &str = "PDFS_LOCALEDIR";
|
|
|
|
/// Where packages install the catalogs unless the build sets `PDFS_LOCALEDIR`.
|
|
const DEFAULT_LOCALEDIR: &str = "/usr/share/locale";
|
|
|
|
/// Languages the app ships a catalog for, as (gettext code, native name). The
|
|
/// Preferences picker lists them after "System Default". US English is the
|
|
/// source language and needs no catalog. Names stay untranslated so a user can
|
|
/// always find their own language.
|
|
pub(crate) const LANGUAGES: &[(&str, &str)] = &[
|
|
("de", "Deutsch"),
|
|
("en_GB", "English (UK)"),
|
|
("en_US", "English (US)"),
|
|
("es", "Español"),
|
|
("fr", "Français"),
|
|
("it", "Italiano"),
|
|
("nl", "Nederlands"),
|
|
("pl", "Polski"),
|
|
("pt_BR", "Português (Brasil)"),
|
|
];
|
|
|
|
/// Set up the locale and the text domain. Call first thing in `main`, before
|
|
/// any thread starts: an explicit language is applied through the process
|
|
/// environment, so that GTK's own strings follow it too.
|
|
pub(crate) fn init() {
|
|
let language = AppDirs::new()
|
|
.ok()
|
|
.and_then(|dirs| dirs.load_config().language)
|
|
.filter(|code| LANGUAGES.iter().any(|(known, _)| known == code));
|
|
// SAFETY: `init` runs at the top of `main`, before any other thread exists.
|
|
unsafe { gettextrs::setlocale(LocaleCategory::LcAll, "") };
|
|
if let Some(code) = language {
|
|
apply_language(&code);
|
|
}
|
|
let dir = std::env::var(LOCALEDIR_ENV)
|
|
.ok()
|
|
.filter(|dir| !dir.is_empty())
|
|
.unwrap_or_else(|| {
|
|
option_env!("PDFS_LOCALEDIR")
|
|
.unwrap_or(DEFAULT_LOCALEDIR)
|
|
.to_string()
|
|
});
|
|
if let Err(e) = gettextrs::bindtextdomain(DOMAIN, dir) {
|
|
tracing::warn!("bindtextdomain failed: {e}");
|
|
}
|
|
let _ = gettextrs::bind_textdomain_codeset(DOMAIN, "UTF-8");
|
|
// Country names come translated from the iso-codes catalogs, which live in
|
|
// the system's own locale directory.
|
|
let _ = gettextrs::bind_textdomain_codeset(ISO_3166_DOMAIN, "UTF-8");
|
|
if let Err(e) = gettextrs::textdomain(DOMAIN) {
|
|
tracing::warn!("textdomain failed: {e}");
|
|
}
|
|
}
|
|
|
|
/// The iso-codes text domain that translates ISO 3166-1 country names.
|
|
const ISO_3166_DOMAIN: &str = "iso_3166-1";
|
|
|
|
/// The name of the country with ISO 3166-1 code `code`, in the user's language.
|
|
/// The English names are the iso-codes project's own, so that project's
|
|
/// catalogs translate them; Kosovo has no ISO code of its own and is
|
|
/// translated here. An unknown code is shown as it is.
|
|
pub(crate) fn country_name(code: &str) -> String {
|
|
if code == "XK" {
|
|
return gettext("Kosovo");
|
|
}
|
|
match pdfs_core::places::country_name(code) {
|
|
Some(name) => gettextrs::dgettext(ISO_3166_DOMAIN, name),
|
|
None => code.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Make `code` the message language. gettext reads `LANGUAGE` first, but
|
|
/// ignores it while the message locale is "C" or "C.UTF-8", so a system without
|
|
/// a configured locale also gets its message locale moved to a real one. Both
|
|
/// go into the environment, because GTK calls `setlocale` again during its own
|
|
/// start.
|
|
fn apply_language(code: &str) {
|
|
// SAFETY: `init` runs at the top of `main`, before any other thread exists.
|
|
unsafe { std::env::set_var("LANGUAGE", code) };
|
|
// SAFETY: as above, still single-threaded.
|
|
let current = unsafe { gettextrs::setlocale(LocaleCategory::LcMessages, "") }
|
|
.map(|name| String::from_utf8_lossy(&name).into_owned())
|
|
.unwrap_or_default();
|
|
if !is_c_locale(¤t) {
|
|
return;
|
|
}
|
|
for candidate in message_locales(code) {
|
|
// SAFETY: as above, still single-threaded.
|
|
if unsafe { gettextrs::setlocale(LocaleCategory::LcMessages, candidate.as_str()) }.is_some()
|
|
{
|
|
// SAFETY: as above, still single-threaded.
|
|
unsafe { std::env::set_var("LC_MESSAGES", &candidate) };
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn is_c_locale(name: &str) -> bool {
|
|
let base = name.split('.').next().unwrap_or(name);
|
|
matches!(base, "" | "C" | "POSIX")
|
|
}
|
|
|
|
/// Locales to try, in order, as the message locale for `code`: the language's
|
|
/// own locale, then US English, which any locale-enabled system has and which
|
|
/// is enough for gettext to honour `LANGUAGE`.
|
|
fn message_locales(code: &str) -> Vec<String> {
|
|
let full = if code.contains('_') {
|
|
code.to_string()
|
|
} else {
|
|
format!("{code}_{}", code.to_uppercase())
|
|
};
|
|
let mut locales = vec![format!("{full}.UTF-8")];
|
|
if full != "en_US" {
|
|
locales.push("en_US.UTF-8".to_string());
|
|
}
|
|
locales
|
|
}
|
|
|
|
/// Mark `msgid` for extraction without translating it yet, for tables of
|
|
/// labels built at compile time. Pass the value through [`gettext`] where it is
|
|
/// shown.
|
|
pub(crate) const fn gettext_noop(msgid: &'static str) -> &'static str {
|
|
msgid
|
|
}
|
|
|
|
/// Translate `msgid`.
|
|
pub(crate) fn gettext(msgid: &str) -> String {
|
|
gettextrs::gettext(msgid)
|
|
}
|
|
|
|
/// Translate `msgid` in context `context`, for short strings whose meaning
|
|
/// depends on where they appear ("Open" the verb against "Open" the state).
|
|
pub(crate) fn pgettext(context: &str, msgid: &str) -> String {
|
|
gettextrs::pgettext(context, msgid)
|
|
}
|
|
|
|
/// Translate the singular or plural form for `n`.
|
|
pub(crate) fn ngettext(singular: &str, plural: &str, n: u64) -> String {
|
|
gettextrs::ngettext(singular, plural, plural_count(n))
|
|
}
|
|
|
|
/// Translate `msgid`, then fill its `{name}` placeholders from `args`.
|
|
pub(crate) fn gettext_f(msgid: &str, args: &[(&str, &str)]) -> String {
|
|
fill(&gettext(msgid), args)
|
|
}
|
|
|
|
/// Translate the form for `n`, then fill `{n}` with the count and the other
|
|
/// placeholders from `args`.
|
|
pub(crate) fn ngettext_f(singular: &str, plural: &str, n: u64, args: &[(&str, &str)]) -> String {
|
|
let count = n.to_string();
|
|
let filled = fill(&ngettext(singular, plural, n), args);
|
|
fill(&filled, &[("n", &count)])
|
|
}
|
|
|
|
/// The translated form of [`pdfs_core::control::pending_summary`]: what is
|
|
/// still waiting to go up, or `None` when nothing is.
|
|
pub(crate) fn pending_summary(uploads: u64, changes: u64) -> Option<String> {
|
|
match (uploads, changes) {
|
|
(0, 0) => None,
|
|
(uploads, 0) => Some(ngettext_f(
|
|
"{n} upload queued",
|
|
"{n} uploads queued",
|
|
uploads,
|
|
&[],
|
|
)),
|
|
(0, changes) => Some(ngettext_f(
|
|
"{n} change queued",
|
|
"{n} changes queued",
|
|
changes,
|
|
&[],
|
|
)),
|
|
(uploads, changes) => {
|
|
let uploads = ngettext_f("{n} upload", "{n} uploads", uploads, &[]);
|
|
let changes = ngettext_f("{n} change", "{n} changes", changes, &[]);
|
|
// Translators: {uploads} is "3 uploads", {changes} is "2 changes".
|
|
Some(gettext_f(
|
|
"{uploads}, {changes} queued",
|
|
&[("uploads", &uploads), ("changes", &changes)],
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Format a byte count as a short binary-unit string (e.g. `1.2 GiB`, or
|
|
/// `512 bytes` below one KiB).
|
|
///
|
|
/// GLib does the formatting, so the units, the plural of "bytes" and the
|
|
/// decimal separator follow the user's language from GLib's own catalog. GLib
|
|
/// separates number and unit with a no-break space; it is turned back into a
|
|
/// plain space so the text matches what the rest of the UI builds around it.
|
|
pub(crate) fn human_bytes(bytes: u64) -> String {
|
|
gtk4::glib::format_size_full(bytes, gtk4::glib::FormatSizeFlags::IEC_UNITS)
|
|
.replace('\u{a0}', " ")
|
|
}
|
|
|
|
/// gettext takes the count as `u32`. Counts past that pick the same form as
|
|
/// the largest one, which is the plural in every shipped language.
|
|
fn plural_count(n: u64) -> u32 {
|
|
u32::try_from(n).unwrap_or(u32::MAX)
|
|
}
|
|
|
|
/// Replace each `{key}` in `template` with its value. Unknown placeholders are
|
|
/// left as they are, so a translation mistake shows up rather than vanishing.
|
|
fn fill(template: &str, args: &[(&str, &str)]) -> String {
|
|
let mut out = template.to_string();
|
|
for (key, value) in args {
|
|
out = out.replace(&format!("{{{key}}}"), value);
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn fill_replaces_named_placeholders_in_any_order() {
|
|
assert_eq!(
|
|
fill("{b} before {a}", &[("a", "x"), ("b", "y")]),
|
|
"y before x"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn fill_keeps_unknown_placeholders() {
|
|
assert_eq!(fill("{missing} {a}", &[("a", "1")]), "{missing} 1");
|
|
}
|
|
|
|
#[test]
|
|
fn ngettext_f_fills_the_count() {
|
|
assert_eq!(ngettext_f("{n} file", "{n} files", 1, &[]), "1 file");
|
|
assert_eq!(
|
|
ngettext_f(
|
|
"{n} file in {dir}",
|
|
"{n} files in {dir}",
|
|
3,
|
|
&[("dir", "A")]
|
|
),
|
|
"3 files in A"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pending_summary_matches_the_core_wording() {
|
|
for (uploads, changes) in [(0, 0), (1, 0), (3, 0), (0, 1), (2, 4)] {
|
|
assert_eq!(
|
|
pending_summary(uploads, changes),
|
|
pdfs_core::control::pending_summary(uploads, changes)
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn plural_count_saturates() {
|
|
assert_eq!(plural_count(7), 7);
|
|
assert_eq!(plural_count(u64::MAX), u32::MAX);
|
|
}
|
|
|
|
#[test]
|
|
fn language_codes_are_unique_and_sorted() {
|
|
let codes: Vec<&str> = LANGUAGES.iter().map(|(code, _)| *code).collect();
|
|
let mut sorted = codes.clone();
|
|
sorted.sort_unstable();
|
|
sorted.dedup();
|
|
assert_eq!(codes, sorted);
|
|
}
|
|
|
|
fn repo_file(path: &str) -> std::path::PathBuf {
|
|
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../..")
|
|
.join(path)
|
|
}
|
|
|
|
fn linguas() -> Vec<String> {
|
|
std::fs::read_to_string(repo_file("po/LINGUAS"))
|
|
.unwrap()
|
|
.lines()
|
|
.map(str::trim)
|
|
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
|
.map(str::to_string)
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn every_offered_language_has_a_catalog() {
|
|
let linguas = linguas();
|
|
for (code, _) in LANGUAGES {
|
|
// US English is the source language.
|
|
if *code != "en_US" {
|
|
assert!(
|
|
linguas.iter().any(|lang| lang == code),
|
|
"{code} is not in po/LINGUAS"
|
|
);
|
|
}
|
|
}
|
|
for lang in &linguas {
|
|
assert!(
|
|
repo_file(&format!("po/{lang}.po")).exists(),
|
|
"po/{lang}.po is missing"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn potfiles_lists_every_source_that_translates() {
|
|
let listed = std::fs::read_to_string(repo_file("po/POTFILES.in")).unwrap();
|
|
let listed: Vec<&str> = listed.lines().map(str::trim).collect();
|
|
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
|
let mut stack = vec![src];
|
|
while let Some(dir) = stack.pop() {
|
|
for entry in std::fs::read_dir(dir).unwrap() {
|
|
let path = entry.unwrap().path();
|
|
if path.is_dir() {
|
|
stack.push(path);
|
|
continue;
|
|
}
|
|
if path.extension().is_none_or(|ext| ext != "rs") {
|
|
continue;
|
|
}
|
|
let text = std::fs::read_to_string(&path).unwrap();
|
|
if !text.contains("gettext") {
|
|
continue;
|
|
}
|
|
let relative = path.strip_prefix(env!("CARGO_MANIFEST_DIR")).unwrap();
|
|
let name = format!("crates/pdfs-gui/{}", relative.display());
|
|
assert!(
|
|
listed.contains(&name.as_str()),
|
|
"{name} is not in po/POTFILES.in"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `msgfmt --check` rejects a translation whose `{placeholders}` differ from
|
|
/// the English, besides syntax errors. Skipped where gettext is not installed.
|
|
#[test]
|
|
fn catalogs_compile_with_matching_placeholders() {
|
|
let msgfmt = std::process::Command::new("msgfmt")
|
|
.arg("--version")
|
|
.output();
|
|
if msgfmt.is_err() {
|
|
eprintln!("msgfmt not installed; skipping the catalog check");
|
|
return;
|
|
}
|
|
for lang in linguas() {
|
|
let output = std::process::Command::new("msgfmt")
|
|
.args(["--check", "--output-file=/dev/null"])
|
|
.arg(repo_file(&format!("po/{lang}.po")))
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"po/{lang}.po: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn c_locales_are_detected() {
|
|
assert!(is_c_locale("C"));
|
|
assert!(is_c_locale("POSIX"));
|
|
assert!(is_c_locale("C.UTF-8"));
|
|
assert!(is_c_locale("C.utf8"));
|
|
assert!(!is_c_locale("de_DE.UTF-8"));
|
|
}
|
|
|
|
#[test]
|
|
fn message_locales_start_with_the_language_region() {
|
|
assert_eq!(message_locales("de"), ["de_DE.UTF-8", "en_US.UTF-8"]);
|
|
assert_eq!(message_locales("pt_BR"), ["pt_BR.UTF-8", "en_US.UTF-8"]);
|
|
assert_eq!(message_locales("en_US"), ["en_US.UTF-8"]);
|
|
}
|
|
}
|