mirror of
https://github.com/ankitects/anki.git
synced 2026-07-21 23:46:48 -04:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}"##,"###
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user