fix: Disallow Qt translation strings in Rust/TS code (#5127)

## Linked issue

Closes #5126

## Summary

This prevents translation strings from the
https://github.com/ankitects/anki-desktop-ftl to be used in Rust/TS code
as AnkiMobile does not pull this module.

## Steps to reproduce (before)

Qt strings such as `addons-no-updates-available` were allowed to be used
in Rust: #5122

## How to test (after)

Revert #5122 and confirm you get a compile error with this PR.
This commit is contained in:
Abdo
2026-07-08 21:21:33 +03:00
committed by GitHub
parent 531875e67e
commit 62efee43f0
9 changed files with 93 additions and 53 deletions

View File

@@ -36,6 +36,8 @@ decks-repeat-failed-cards-after = Delay Repeat failed cards after
decks-study = Study
decks-study-deck = Study Deck
decks-filtered-deck-search-empty = No cards matched the provided search. Some cards may have been excluded because they are in a different filtered deck, or suspended.
decks-choose-deck = Choose Deck
decks-target-deck-ctrlandd = Target Deck (Ctrl+D)
## Sort order of cards

View File

@@ -1,4 +1,6 @@
notetypes-notetype = Note Type
notetypes-choose-note-type = Choose Note Type
notetypes-change-note-type-ctrlandn = Change Note Type (Ctrl+N)
## Default field names in newly created note types

View File

@@ -12,8 +12,10 @@ use super::gather::TranslationsByLang;
pub fn check(lang_map: &TranslationsByLang) {
for (lang, files_map) in lang_map {
for (fname, content) in files_map {
check_content(lang, fname, content);
for (fname, repos) in files_map {
for content in repos.values() {
check_content(lang, fname, content);
}
}
}
}

View File

@@ -2,7 +2,9 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use std::collections::HashSet;
use std::fmt::Display;
use std::fmt::Write;
use std::marker::PhantomData;
use fluent_syntax::ast::Entry;
use fluent_syntax::ast::Expression;
@@ -13,6 +15,8 @@ use fluent_syntax::parser::parse;
use serde::Serialize;
use crate::gather::TranslationsByLang;
use crate::gather::TranslationsByRepo;
#[derive(Debug, PartialOrd, Ord, PartialEq, Eq, Serialize)]
pub struct Module {
pub name: String,
@@ -23,6 +27,7 @@ pub struct Module {
#[derive(Debug, PartialOrd, Ord, PartialEq, Eq, Serialize)]
pub struct Translation {
pub key: String,
pub repo: String,
pub text: String,
pub variables: Vec<Variable>,
pub index: usize,
@@ -42,13 +47,19 @@ pub enum VariableKind {
Any,
}
impl Translation {
pub fn is_core(&self) -> bool {
!["qt", "mobile"].contains(&self.repo.as_str())
}
}
pub fn get_modules(data: &TranslationsByLang) -> Vec<Module> {
let mut output = vec![];
for (module, text) in &data["templates"] {
for (module, translations_by_repo) in &data["templates"] {
output.push(Module {
name: module.to_string(),
translations: extract_metadata(text),
translations: extract_metadata(translations_by_repo),
index: 0,
});
}
@@ -65,38 +76,39 @@ pub fn get_modules(data: &TranslationsByLang) -> Vec<Module> {
output
}
fn extract_metadata(ftl_text: &str) -> Vec<Translation> {
let res = parse(ftl_text).unwrap();
fn extract_metadata(translations_by_repo: &TranslationsByRepo) -> Vec<Translation> {
let mut output = vec![];
for (repo, ftl_text) in translations_by_repo.iter() {
let res = parse(ftl_text.clone()).unwrap();
for entry in res.body {
if let Entry::Message(m) = entry {
if let Some(pattern) = m.value {
let mut visitor = Visitor::default();
visitor.visit_pattern(&pattern);
let key = m.id.name.to_string();
for entry in res.body {
if let Entry::Message(m) = entry {
if let Some(pattern) = m.value {
let mut visitor = Visitor::default();
visitor.visit_pattern(&pattern);
let key = m.id.name.to_string();
// special case translations that were ported from gettext, and use embedded
// terms that reference other variables that aren't visible to our visitor
if key == "statistics-studied-today" {
visitor.variables.push("amount".to_string());
visitor.variables.push("cards".to_string());
} else if key == "statistics-average-answer-time" {
visitor.variables.push("cards-per-minute".to_string());
}
// special case translations that were ported from gettext, and use embedded
// terms that reference other variables that aren't visible to our visitor
if key == "statistics-studied-today" {
visitor.variables.push("amount".to_string());
visitor.variables.push("cards".to_string());
} else if key == "statistics-average-answer-time" {
visitor.variables.push("cards-per-minute".to_string());
let (text, variables) = visitor.into_output();
output.push(Translation {
key,
repo: repo.clone(),
text,
variables,
index: 0,
})
}
let (text, variables) = visitor.into_output();
output.push(Translation {
key,
text,
variables,
index: 0,
})
}
}
}
output.sort_unstable();
output
@@ -104,12 +116,16 @@ fn extract_metadata(ftl_text: &str) -> Vec<Translation> {
/// Gather variable names and (rough) text from Fluent AST.
#[derive(Default)]
struct Visitor {
struct Visitor<T> {
text: String,
variables: Vec<String>,
_phantom: PhantomData<T>,
}
impl Visitor {
impl<T> Visitor<T>
where
T: AsRef<str> + Display,
{
fn into_output(self) -> (String, Vec<Variable>) {
// make unique, preserving order
let mut seen = HashSet::new();
@@ -128,16 +144,16 @@ impl Visitor {
(self.text, vars)
}
fn visit_pattern(&mut self, pattern: &Pattern<&str>) {
fn visit_pattern(&mut self, pattern: &Pattern<T>) {
for element in &pattern.elements {
match element {
PatternElement::TextElement { value } => self.text.push_str(value),
PatternElement::TextElement { value } => self.text.push_str(value.as_ref()),
PatternElement::Placeable { expression } => self.visit_expression(expression),
}
}
}
fn visit_inline_expression(&mut self, expr: &InlineExpression<&str>, in_select: bool) {
fn visit_inline_expression(&mut self, expr: &InlineExpression<T>, in_select: bool) {
match expr {
InlineExpression::VariableReference { id } => {
if !in_select {
@@ -152,7 +168,7 @@ impl Visitor {
}
}
fn visit_expression(&mut self, expression: &Expression<&str>) {
fn visit_expression(&mut self, expression: &Expression<T>) {
match expression {
Expression::Select { selector, variants } => {
self.visit_inline_expression(selector, true);

View File

@@ -10,7 +10,8 @@ use std::fs;
use std::path::Path;
use std::path::PathBuf;
pub type TranslationsByFile = HashMap<String, String>;
pub type TranslationsByRepo = HashMap<String, String>;
pub type TranslationsByFile = HashMap<String, TranslationsByRepo>;
pub type TranslationsByLang = HashMap<String, TranslationsByFile>;
/// Read the contents of the FTL files into a TranslationMap structure.
@@ -19,24 +20,29 @@ pub fn get_ftl_data() -> TranslationsByLang {
// English core templates are taken from this repo
let ftl_base = source_tree_root();
add_folder(&mut map, &ftl_base.join("core"), "templates");
add_folder(&mut map, &ftl_base.join("core"), "core", "templates");
// And core translations from submodule
add_translation_root(&mut map, &ftl_base.join("core-repo/core"), true);
add_translation_root(&mut map, &ftl_base.join("core-repo/core"), "core", true);
if let Some(path) = extra_ftl_root() {
// Mobile client has requested its own extra translations
add_translation_root(&mut map, &path, false);
add_translation_root(
&mut map,
&path,
&path.file_name().unwrap().to_string_lossy(),
false,
);
} else {
// Qt core templates from this repo
add_folder(&mut map, &ftl_base.join("qt"), "templates");
add_folder(&mut map, &ftl_base.join("qt"), "qt", "templates");
// And translations from submodule
add_translation_root(&mut map, &ftl_base.join("qt-repo/desktop"), true)
add_translation_root(&mut map, &ftl_base.join("qt-repo/desktop"), "qt", true)
}
map
}
/// For each .ftl file in the provided folder, add it to the translation map.
fn add_folder(map: &mut TranslationsByLang, folder: &Path, lang: &str) {
fn add_folder(map: &mut TranslationsByLang, folder: &Path, repo: &str, lang: &str) {
let map_entry = map.entry(lang.to_string()).or_default();
for entry in fs::read_dir(folder).unwrap() {
let entry = entry.unwrap();
@@ -50,7 +56,12 @@ fn add_folder(map: &mut TranslationsByLang, folder: &Path, lang: &str) {
text.ends_with('\n'),
"file was missing final newline: {entry:?}"
);
map_entry.entry(module).or_default().push_str(&text);
map_entry
.entry(module)
.or_default()
.entry(repo.to_string())
.or_default()
.push_str(&text);
println!("cargo:rerun-if-changed={}", entry.path().to_str().unwrap());
}
}
@@ -58,14 +69,19 @@ fn add_folder(map: &mut TranslationsByLang, folder: &Path, lang: &str) {
/// For each language folder in `root`, add the ftl files stored inside.
/// If ignore_templates is true, the templates/ folder will be ignored, on the
/// assumption the templates were extracted from the source tree.
fn add_translation_root(map: &mut TranslationsByLang, root: &Path, ignore_templates: bool) {
fn add_translation_root(
map: &mut TranslationsByLang,
root: &Path,
repo: &str,
ignore_templates: bool,
) {
for entry in fs::read_dir(root).unwrap() {
let entry = entry.unwrap();
let lang = entry.file_name().to_string_lossy().to_string();
if ignore_templates && lang == "templates" {
continue;
}
add_folder(map, &entry.path(), &lang);
add_folder(map, &entry.path(), repo, &lang);
}
}

View File

@@ -42,7 +42,7 @@ fn render_module_map(modules: &[Module], ts_out: &mut String) {
fn render_methods(modules: &[Module], ts_out: &mut String) {
for module in modules {
for translation in &module.translations {
for translation in module.translations.iter().filter(|t| t.is_core()) {
let text = &translation.text;
let key = &translation.key;
let func_name = key.replace('-', "_").to_camel_case();

View File

@@ -8,6 +8,7 @@ use std::fs;
use std::path::PathBuf;
use inflections::Inflect;
use itertools::Itertools;
use crate::extract::Module;
use crate::extract::Translation;
@@ -45,7 +46,7 @@ use std::borrow::Cow;
);
writeln!(buf, "impl I18n<{tag}> {{").unwrap();
for module in modules {
for translation in &module.translations {
for translation in module.translations.iter().filter(|t| t.is_core()) {
let func = translation.key.to_snake_case();
let key = &translation.key;
let doc = translation.text.replace('\n', " ");
@@ -196,8 +197,9 @@ pub(crate) const {lang_name}: phf::Map<&str, &str> = phf::phf_map! {{",
)
.unwrap();
for (module, contents) in modules {
let escaped_contents = escape_unicode_control_chars(contents);
for (module, repos) in modules {
let contents = repos.values().join("");
let escaped_contents = escape_unicode_control_chars(&contents);
writeln!(
buf,
r###" "{module}" => r##"{escaped_contents}"##,"###

View File

@@ -53,11 +53,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<ItemChooser
bind:this={itemChooser}
title={tr.qtMiscChooseDeck()}
title={tr.decksChooseDeck()}
bind:selectedItem={selectedDeck}
{onChange}
items={decks}
icon={mdiBookOutline}
keyCombination="Control+D"
tooltip={tr.qtMiscTargetDeckCtrlandd()}
tooltip={tr.decksTargetDeckCtrlandd()}
/>

View File

@@ -53,11 +53,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<ItemChooser
bind:this={itemChooser}
title={tr.qtMiscChooseNoteType()}
title={tr.notetypesChooseNoteType()}
bind:selectedItem={selectedNotetype}
{onChange}
items={notetypes}
icon={mdiNewspaper}
keyCombination="Control+N"
tooltip={tr.qtMiscChangeNoteTypeCtrlandn()}
tooltip={tr.notetypesChangeNoteTypeCtrlandn()}
/>