From 0e931808c9a8a692f8fffa687a80ebdd23376315 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sun, 23 Feb 2020 14:57:02 +1000 Subject: [PATCH] translations no longer require an open collection --- proto/backend.proto | 5 +++++ pylib/anki/lang.py | 15 +++++++++++-- pylib/anki/rsbackend.py | 34 ++++++++++++++++++++++------- qt/aqt/utils.py | 9 +++----- qt/tests/test_i18n.py | 13 ++++++++++++ rslib/src/backend.rs | 47 +++++++++++++++++++++++++++++++++++++++++ rspy/src/lib.rs | 35 +++++++++++++++++++++++++++++- 7 files changed, 141 insertions(+), 17 deletions(-) create mode 100644 qt/tests/test_i18n.py diff --git a/proto/backend.proto b/proto/backend.proto index d95eabbec..404635ba1 100644 --- a/proto/backend.proto +++ b/proto/backend.proto @@ -14,6 +14,11 @@ message BackendInit { string locale_folder_path = 5; } +message I18nBackendInit { + repeated string preferred_langs = 4; + string locale_folder_path = 5; +} + // 1-15 reserved for future use; 2047 for errors message BackendInput { diff --git a/pylib/anki/lang.py b/pylib/anki/lang.py index 772975686..2656ed95a 100644 --- a/pylib/anki/lang.py +++ b/pylib/anki/lang.py @@ -3,11 +3,15 @@ # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # Please leave the coding line in this file to prevent xgettext complaining. +from __future__ import annotations + import gettext import os import re from typing import Optional, Union +import anki + langs = sorted( [ ("Afrikaans", "af_ZA"), @@ -134,11 +138,14 @@ def lang_to_disk_lang(lang: str) -> str: # the currently set interface language currentLang = "en" -# the current translation catalog +# the current gettext translation catalog current_catalog: Optional[ Union[gettext.NullTranslations, gettext.GNUTranslations] ] = None +# the current Fluent translation instance +current_i18n: Optional[anki.rsbackend.I18nBackend] + # path to locale folder locale_folder = "" @@ -159,13 +166,17 @@ def ngettext(single: str, plural: str, n: int) -> str: def set_lang(lang: str, locale_dir: str) -> None: - global currentLang, current_catalog, locale_folder + global currentLang, current_catalog, current_i18n, locale_folder gettext_dir = os.path.join(locale_dir, "gettext") + ftl_dir = os.path.join(locale_dir, "fluent") currentLang = lang current_catalog = gettext.translation( "anki", gettext_dir, languages=[lang], fallback=True ) + current_i18n = anki.rsbackend.I18nBackend( + preferred_langs=[lang], ftl_folder=ftl_dir + ) locale_folder = locale_dir diff --git a/pylib/anki/rsbackend.py b/pylib/anki/rsbackend.py index 61e258a11..c28ae1ed6 100644 --- a/pylib/anki/rsbackend.py +++ b/pylib/anki/rsbackend.py @@ -329,15 +329,8 @@ class RustBackend: ) def translate(self, key: FString, **kwargs: Union[str, int, float]): - args = {} - for (k, v) in kwargs.items(): - if isinstance(v, str): - args[k] = pb.TranslateArgValue(str=v) - else: - args[k] = pb.TranslateArgValue(number=v) - return self._run_command( - pb.BackendInput(translate_string=pb.TranslateStringIn(key=key, args=args)) + pb.BackendInput(translate_string=translate_string_in(key, **kwargs)) ).translate_string def format_time_span( @@ -366,3 +359,28 @@ class RustBackend: ) ) ).congrats_learn_msg + + +def translate_string_in( + key: FString, **kwargs: Union[str, int, float] +) -> pb.TranslateStringIn: + args = {} + for (k, v) in kwargs.items(): + if isinstance(v, str): + args[k] = pb.TranslateArgValue(str=v) + else: + args[k] = pb.TranslateArgValue(number=v) + return pb.TranslateStringIn(key=key, args=args) + + +class I18nBackend: + def __init__(self, preferred_langs: List[str], ftl_folder: str) -> None: + init_msg = pb.I18nBackendInit( + locale_folder_path=ftl_folder, preferred_langs=preferred_langs + ) + self._backend = ankirspy.open_i18n(init_msg.SerializeToString()) + + def translate(self, key: FString, **kwargs: Union[str, int, float]): + return self._backend.translate( + translate_string_in(key, **kwargs).SerializeToString() + ) diff --git a/qt/aqt/utils.py b/qt/aqt/utils.py index a8bbe0a02..1872743c1 100644 --- a/qt/aqt/utils.py +++ b/qt/aqt/utils.py @@ -10,6 +10,7 @@ import subprocess import sys from typing import Any, Optional, Union +import anki import aqt from anki.lang import _ from anki.rsbackend import FString @@ -32,12 +33,8 @@ def locale_dir() -> str: def tr(key: FString, **kwargs: Union[str, int, float]) -> str: - """Shortcut to access translations from the backend. - (Currently) requires an open collection.""" - if aqt.mw.col: - return aqt.mw.col.backend.translate(key, **kwargs) - else: - return repr(key) + "Shortcut to access Fluent translations." + return anki.lang.current_i18n.translate(key, *kwargs) def openHelp(section): diff --git a/qt/tests/test_i18n.py b/qt/tests/test_i18n.py new file mode 100644 index 000000000..edff4dc87 --- /dev/null +++ b/qt/tests/test_i18n.py @@ -0,0 +1,13 @@ +import anki.lang +from anki.rsbackend import FString + + +def test_no_collection_i18n(): + anki.lang.set_lang("zz", "") + tr2 = anki.lang.current_i18n.translate + no_uni = anki.lang.without_unicode_isolation + assert no_uni(tr2(FString.STATISTICS_REVIEWS, reviews=2)) == "2 reviews" + + anki.lang.set_lang("ja", "") + tr2 = anki.lang.current_i18n.translate + assert no_uni(tr2(FString.STATISTICS_REVIEWS, reviews=2)) == "2 枚の復習カード" diff --git a/rslib/src/backend.rs b/rslib/src/backend.rs index 8eac434e3..7d0f278fe 100644 --- a/rslib/src/backend.rs +++ b/rslib/src/backend.rs @@ -493,3 +493,50 @@ fn media_sync_progress(p: &MediaSyncProgress, i18n: &I18n) -> pb::MediaSyncProgr ), } } + +/// Standalone I18n backend +/// This is a hack to allow translating strings in the GUI +/// when a collection is not open, and in the future it should +/// either be shared with or merged into the backend object. +/////////////////////////////////////////////////////// + +pub struct I18nBackend { + i18n: I18n, +} + +pub fn init_i18n_backend(init_msg: &[u8]) -> Result { + let input: pb::I18nBackendInit = match pb::I18nBackendInit::decode(init_msg) { + Ok(req) => req, + Err(_) => return Err(AnkiError::invalid_input("couldn't decode init msg")), + }; + + let i18n = I18n::new(&input.preferred_langs, input.locale_folder_path); + + Ok(I18nBackend { i18n }) +} + +impl I18nBackend { + pub fn translate(&self, req: &[u8]) -> String { + let req = match pb::TranslateStringIn::decode(req) { + Ok(req) => req, + Err(_e) => return "decoding error".into(), + }; + + self.translate_string(req) + } + + fn translate_string(&self, input: pb::TranslateStringIn) -> String { + let key = match pb::FluentString::from_i32(input.key) { + Some(key) => key, + None => return "invalid key".to_string(), + }; + + let map = input + .args + .iter() + .map(|(k, v)| (k.as_str(), translate_arg_to_fluent_val(&v))) + .collect(); + + self.i18n.trn(key, map) + } +} diff --git a/rspy/src/lib.rs b/rspy/src/lib.rs index 729623099..45e705fea 100644 --- a/rspy/src/lib.rs +++ b/rspy/src/lib.rs @@ -1,12 +1,17 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use anki::backend::{init_backend, Backend as RustBackend}; +use anki::backend::{ + init_backend, init_i18n_backend, Backend as RustBackend, I18nBackend as RustI18nBackend, +}; use log::error; use pyo3::prelude::*; use pyo3::types::PyBytes; use pyo3::{exceptions, wrap_pyfunction}; +// Regular backend +////////////////////////////////// + #[pyclass] struct Backend { backend: RustBackend, @@ -68,11 +73,39 @@ impl Backend { } } +// I18n backend +////////////////////////////////// + +#[pyclass] +struct I18nBackend { + backend: RustI18nBackend, +} + +#[pyfunction] +fn open_i18n(init_msg: &PyBytes) -> PyResult { + match init_i18n_backend(init_msg.as_bytes()) { + Ok(backend) => Ok(I18nBackend { backend }), + Err(e) => Err(exceptions::Exception::py_err(format!("{:?}", e))), + } +} + +#[pymethods] +impl I18nBackend { + fn translate(&self, input: &PyBytes) -> String { + let in_bytes = input.as_bytes(); + self.backend.translate(in_bytes) + } +} + +// Module definition +////////////////////////////////// + #[pymodule] fn ankirspy(_py: Python, m: &PyModule) -> PyResult<()> { m.add_class::()?; m.add_wrapped(wrap_pyfunction!(buildhash)).unwrap(); m.add_wrapped(wrap_pyfunction!(open_backend)).unwrap(); + m.add_wrapped(wrap_pyfunction!(open_i18n)).unwrap(); env_logger::init();