rpfm_extensions/translator/mod.rs
1//---------------------------------------------------------------------------//
2// Copyright (c) 2017-2026 Ismael Gutiérrez González. All rights reserved.
3//
4// This file is part of the Rusted PackFile Manager (RPFM) project,
5// which can be found here: https://github.com/Frodo45127/rpfm.
6//
7// This file is licensed under the MIT license, which can be found here:
8// https://github.com/Frodo45127/rpfm/blob/master/LICENSE.
9//---------------------------------------------------------------------------//
10
11//! Mod translation and localization support.
12//!
13//! This module provides tools for managing translations of mod content, making it
14//! easier to localize mods for different languages. It tracks translation status,
15//! detects changes in source text, and supports auto-translation from vanilla data.
16//!
17//! # Overview
18//!
19//! The translation system works by:
20//!
21//! 1. Extracting all translatable strings from a pack's Loc files
22//! 2. Storing translations in a separate JSON file alongside the pack
23//! 3. Tracking which translations need updating when source text changes
24//! 4. Auto-translating from vanilla localisation data where possible
25//!
26//! # Translation Files
27//!
28//! Translations are stored in separate JSON files. Each file contains all source
29//! strings and their translations, along with metadata about translation status.
30//!
31//! # Auto-Translation
32//!
33//! The system can automatically translate strings that exist in the game's vanilla
34//! localisation files. This is useful for mods that reference vanilla content or
35//! use similar terminology.
36//!
37//! # Workflow
38//!
39//! 1. Create a [`PackTranslation`] from a pack
40//! 2. Export to a translation file for external editing
41//! 3. Import completed translations
42//! 4. Generate the final translated Loc file for the pack
43//!
44//! # Output
45//!
46//! Translated strings are output to a Loc file that overrides the original mod's
47//! entries. The filename depends on the game:
48//!
49//! - **Warhammer 1 and newer** (except Thrones): `!!!!!!translated_locs.loc` - loads
50//! first due to its naming, allowing translations to override the original entries
51//! - **Thrones of Britannia and older games**: `localisation.loc`
52//!
53//! # Example
54//!
55//! ```ignore
56//! use rpfm_extensions::translator::PackTranslation;
57//!
58//! // Create translation from pack
59//! let mut translation = PackTranslation::new(
60//! &[translations_path],
61//! &pack,
62//! "warhammer_3",
63//! "EN", // Source language
64//! "ES", // Target language (Spanish)
65//! &dependencies,
66//! &english_base,
67//! &local_fixes,
68//! )?;
69//!
70//! // Save translation file
71//! translation.save(&output_path)?;
72//!
73//! // Generate translated Loc file for the pack
74//! let loc_file = translation.generate_loc()?;
75//! ```
76
77use csv::{QuoteStyle, WriterBuilder};
78use getset::{Getters, MutGetters, Setters};
79use itertools::Itertools;
80use rayon::prelude::*;
81use serde::{Serialize as SerdeSerialize, Serializer};
82use serde_derive::{Serialize, Deserialize};
83
84use std::collections::{BTreeMap, HashMap, HashSet};
85use std::fs::{self, DirBuilder, File};
86use std::io::{BufReader, BufWriter, Read, Write};
87use std::path::{Path, PathBuf};
88
89use rpfm_lib::error::{Result, RLibError};
90use rpfm_lib::files::{Container, FileType, loc::Loc, pack::Pack, RFile, RFileDecoded, table::{DecodedData, local::TableInMemory, Table}};
91use rpfm_lib::games::GameInfo;
92use rpfm_lib::schema::*;
93use rpfm_lib::utils::files_from_subdir;
94
95use crate::dependencies::Dependencies;
96
97pub mod hub;
98#[cfg(test)] mod test;
99
100/// Filename for the generated translated Loc file.
101///
102/// The leading exclamation marks ensure this file loads before other Loc files,
103/// allowing translations to override the original mod's strings.
104pub const TRANSLATED_FILE_NAME: &str = "!!!!!!translated_locs.loc";
105
106/// Full path for the translated Loc file within a pack.
107pub const TRANSLATED_PATH: &str = "text/!!!!!!translated_locs.loc";
108
109/// Legacy path for translated Loc files (for backwards compatibility).
110pub const TRANSLATED_PATH_OLD: &str = "text/localisation.loc";
111
112/// Name of the Translation Hub's TSV with the vanilla English texts.
113pub const VANILLA_LOC_NAME_EN: &str = "vanilla_english.tsv";
114
115//-------------------------------------------------------------------------------//
116// Enums & Structs
117//-------------------------------------------------------------------------------//
118
119/// Translation data for an entire pack.
120///
121/// Contains all translatable strings from a pack along with their translations
122/// and metadata about translation status.
123///
124/// # Persistence and Format Versioning
125///
126/// This struct is serialized to JSON files for storage. There are currently two
127/// on-disk formats, picked by the [`version`](Self::version) field:
128///
129/// - `0` — legacy format. Per-entry `key`/`value_original`/`value_translated`/
130/// `needs_retranslation`/`removed`; no `aut`, and no `src_lang`, `authors`,
131/// `glossary` or `version` at the root. The user can opt back into this format
132/// from the translator UI to share translations with older tooling.
133/// - `1` — current format. Adds `version`, `src_lang`, `authors`, `glossary` and
134/// `aut`, and uses the shorter per-entry field names (`src`/`dst`/`retr`/`rem`/`aut`).
135///
136/// On load, files without a `version` field are treated as v0 — i.e. existing
137/// translation hubs and legacy local files keep their format unless the user
138/// explicitly upgrades them.
139///
140/// # Parent Translations
141///
142/// When a pack has dependencies, translations from parent mods are also loaded
143/// and used for auto-translation, ensuring consistent terminology across
144/// dependent mods.
145#[derive(Debug, Clone, Getters, MutGetters, Setters, Serialize, Deserialize)]
146#[getset(get = "pub", get_mut = "pub", set = "pub")]
147pub struct PackTranslation {
148
149 /// On-disk format version. See the struct-level docs for the meaning of
150 /// each value. New in-memory translations default to v1; legacy files
151 /// without this field are treated as v0.
152 #[serde(default)]
153 version: u32,
154
155 /// Target language code for translations (e.g., "ES", "DE", "FR").
156 language: String,
157
158 /// Source language code these translations are based on (e.g., "EN").
159 ///
160 /// Missing from the old format; defaults to [`DEFAULT_SRC_LANG`] when loading
161 /// legacy files.
162 #[serde(default = "default_src_lang")]
163 src_lang: String,
164
165 /// Name of the pack these translations belong to.
166 pack_name: String,
167
168 /// Names of the translators who contributed to this translation.
169 ///
170 /// Missing from the old format; defaults to an empty list when loading legacy files.
171 #[serde(default)]
172 authors: Vec<String>,
173
174 /// Per-pack glossary of preferred translations for specific source terms.
175 ///
176 /// Stored as a sorted map (source term → preferred translation) so the
177 /// JSON output is deterministic. v1+ only; dropped when saving as v0.
178 #[serde(default)]
179 glossary: BTreeMap<String, String>,
180
181 /// Map of Loc keys to their translation data.
182 ///
183 /// Keys are the original Loc entry keys from the pack.
184 #[serde(serialize_with = "ordered_map_translations")]
185 translations: HashMap<String, Translation>,
186}
187
188/// Translation entry for a single localizable string.
189///
190/// Tracks both the source and translated text, along with status flags
191/// indicating whether the translation is up-to-date, was generated
192/// automatically, or refers to a string no longer present in the pack.
193///
194/// Field names were shortened in the new format; serde aliases keep
195/// backwards-compatible deserialization of the old field names. The old
196/// per-entry `key` field is no longer stored — the map key in
197/// [`PackTranslation::translations`] is the authoritative identifier — and
198/// extra fields in legacy files are silently ignored.
199#[derive(Debug, Clone, Default, Getters, MutGetters, Setters, Serialize, Deserialize)]
200#[getset(get = "pub", get_mut = "pub", set = "pub")]
201pub struct Translation {
202
203 /// Source text in the base language (typically English).
204 ///
205 /// Used to detect when the source text changes, requiring re-translation.
206 #[serde(alias = "value_original")]
207 src: String,
208
209 /// Translated text in the target language.
210 ///
211 /// May be empty if not yet translated.
212 #[serde(alias = "value_translated")]
213 dst: String,
214
215 /// Whether this translation needs review because the source text changed
216 /// after the previous translation.
217 #[serde(alias = "needs_retranslation")]
218 retr: bool,
219
220 /// Whether this string has been removed from the source pack.
221 ///
222 /// Translations for removed strings are kept for reference but marked
223 /// as removed. If the string reappears, it will be flagged for re-translation.
224 #[serde(alias = "removed")]
225 rem: bool,
226
227 /// Whether this translation was generated automatically (e.g. from vanilla
228 /// data) and still needs manual review.
229 ///
230 /// Missing from the old format; defaults to `false` when loading legacy files.
231 #[serde(default)]
232 aut: bool,
233}
234
235/// Current on-disk format version. New translations default to this; legacy
236/// files without a `version` field are still loaded as v0 and saved back as v0
237/// unless the user opts in to the new format from the UI.
238pub const CURRENT_VERSION: u32 = 1;
239
240/// Two-letter source-language code assumed when none is recorded on disk.
241///
242/// Translations have always been authored against the English base, so legacy
243/// files (which predate the `src_lang` field) and freshly created translations
244/// both end up with this value. Uppercase to match how `language` is stored
245/// elsewhere in the translator; not the same thing as `rpfm_lib::games::ENGLISH`,
246/// which is the lowercase identifier used for game data file naming
247/// (e.g. `local_en.pack`).
248pub const DEFAULT_SRC_LANG: &str = "EN";
249
250/// Wire-format counterparts used when saving as legacy v0. We can't reuse the
251/// canonical structs because field names changed and the per-entry `key` field
252/// is no longer stored. Building these on save lets us write the old shape
253/// without polluting [`PackTranslation`] with version-specific serde glue.
254#[derive(Serialize)]
255struct PackTranslationV0Wire<'a> {
256 language: &'a str,
257 pack_name: &'a str,
258 translations: BTreeMap<String, TranslationV0Wire<'a>>,
259}
260
261#[derive(Serialize)]
262struct TranslationV0Wire<'a> {
263 key: String,
264 value_original: &'a str,
265 value_translated: &'a str,
266 needs_retranslation: bool,
267 removed: bool,
268}
269
270//-------------------------------------------------------------------------------//
271// Implementations
272//-------------------------------------------------------------------------------//
273
274impl PackTranslation {
275
276 #[allow(clippy::too_many_arguments)]
277 pub fn new(paths: &[PathBuf], pack: &Pack, game_key: &str, src_lang: &str, language: &str, dependencies: &Dependencies, base_english: &HashMap<String, String>, base_local_fixes: &HashMap<String, String>) -> Result<Self> {
278 let mut translations = Self::load(paths, &pack.disk_file_name(), game_key, src_lang, language).unwrap_or_else(|_| {
279 // No existing file → new translation. `Default::default()` already gives us
280 // version=1; we stamp the language, source language and pack name on top.
281 Self {
282 language: language.to_owned(),
283 src_lang: src_lang.to_owned(),
284 pack_name: pack.disk_file_name(),
285 ..Default::default()
286 }
287 });
288
289 // Legacy files written before `src_lang` existed come through with an empty value.
290 // Stamp the caller's choice so the saved file always carries a meaningful source language.
291 if translations.src_lang.is_empty() {
292 translations.src_lang = src_lang.to_owned();
293 }
294
295 // If the pack has dependencies, we have to try to load their translations too, then patch the live dependencies with them.
296 // Otherwise, we'll have a situation where data is compared and imported from the wrong language.
297 let mut parent_tr = vec![];
298 for (_, pack_name) in pack.dependencies() {
299 if let Ok(ptr) = Self::load(paths, pack_name, game_key, src_lang, language) {
300 parent_tr.push(ptr);
301 }
302 }
303
304 // Once we got the previous translation loaded, get the files to translate from the Pack, updating our translation.
305 let mut locs = pack.files_by_type(&[FileType::Loc]);
306 let merged_loc = Self::sort_and_merge_locs_for_translation(&mut locs)?;
307 let merged_loc_data = merged_loc.data();
308 let merged_loc_hash = merged_loc_data
309 .par_iter()
310 .map(|x| (x[0].data_to_string(), x[1].data_to_string()))
311 .collect::<HashMap<_,_>>();
312
313 // Once we have the clean list of loc entries we have in our Pack, we need to update the translation with it.
314 // First we do a pass to mark all removed translations as such. This is separated from the rest because this pass is way slower than the rest.
315 for (tr_key, tr) in translations.translations_mut() {
316 let was_removed = tr.rem;
317 tr.rem = !merged_loc_hash.contains_key(&**tr_key);
318
319 // If the line has been removed, unmark it for translation.
320 // If the line has been re-added, only flag for retranslation if the original value changed or there's no translation yet.
321 if tr.rem {
322 tr.retr = false;
323 } else if was_removed {
324 if let Some(current_value) = merged_loc_hash.get(&**tr_key) {
325 tr.retr = tr.dst.is_empty() || *current_value != tr.src;
326 }
327 }
328 }
329
330 // Next, we update the translations data with the loc data of the merged loc.
331 for row in merged_loc.data().iter() {
332 let key = row[0].data_to_string();
333 let value = row[1].data_to_string();
334
335 match translations.translations.get_mut(&*key) {
336 Some(tr) => {
337 if value != tr.src {
338 tr.src = value.to_string();
339 tr.retr = true;
340 // Source changed — any prior auto-translation is no longer trustworthy.
341 tr.aut = false;
342 }
343 },
344
345 None => {
346 let tr = Translation {
347 src: value.to_string(),
348 dst: String::new(),
349 retr: true,
350 rem: false,
351 aut: false,
352 };
353
354 translations.translations.insert(key.to_string(), tr);
355 }
356 }
357 }
358
359 // Lastly, we do an auto-translation pass. We have two copies of base local: one normal and one patched with parent translations.
360 // This is needed because the base localisation data doesn't have the translation data for parent mods included.
361 let mut base_local_tr = dependencies.localisation_data().clone();
362 for ptr in parent_tr {
363 for (key, val) in ptr.translations() {
364 if !*val.retr() && !val.dst().is_empty() {
365 if let Some(ptr_val) = base_local_tr.get_mut(key) {
366 *ptr_val = val.dst().to_string();
367 }
368 }
369 }
370 }
371
372 let tr_copy = translations.translations().clone();
373 translations.translations_mut().par_iter_mut().for_each(|(tr_key, tr)| {
374 if !tr.rem {
375
376 // Fix incorrectly translated lines.
377 if !tr.src().trim().is_empty() && tr.dst().trim().is_empty() && !tr.retr() {
378 tr.retr = true;
379 }
380
381 // Empty source/empty translation: trivially "translate" by copying. Not flagged
382 // as auto because it requires no manual review.
383 else if tr.src().trim().is_empty() && tr.dst().trim().is_empty() {
384 tr.dst = tr.src.to_owned();
385 tr.retr = false;
386 tr.aut = false;
387 }
388
389 // If the value is unchanged from english, just copy the vanilla translation.
390 //
391 // NOTE: This is really a patch for packs not using optimizing pass, because the optimizer actually removes these entries.
392 else if let Some(vanilla_data) = base_english.get(tr_key) {
393 if tr.src() == vanilla_data {
394 if let Some(vanilla_data) = base_local_fixes.get(tr_key).filter(|v| !v.trim().is_empty()) {
395 tr.dst = vanilla_data.to_owned();
396 tr.retr = false;
397 tr.aut = true;
398 } else if let Some(vanilla_data) = base_local_tr.get(tr_key).filter(|v| !v.trim().is_empty()) {
399 tr.dst = vanilla_data.to_owned();
400 tr.retr = false;
401 tr.aut = true;
402 }
403 }
404 }
405
406 // If the value is equal to another value in the english translation (but with a different key), we may be able to reuse it.
407 //
408 // Note that this is prone to give wrong translations as it doesn't have any context, so we only do it for lines that are not yet translated.
409 else if tr.dst().trim().is_empty() || *tr.retr() {
410 if let Some((key, _)) = base_english.iter().find(|(_, value)| *value == tr.src()) {
411 if let Some(value_tr) = base_local_fixes.get(key).filter(|v| !v.trim().is_empty()) {
412 tr.dst = value_tr.to_owned();
413 tr.retr = false;
414 tr.aut = true;
415 } else if let Some(value_tr) = base_local_tr.get(key).filter(|v| !v.trim().is_empty()) {
416 tr.dst = value_tr.to_owned();
417 tr.retr = false;
418 tr.aut = true;
419 }
420 } else if let Some((_, value_tr)) = tr_copy.iter()
421 .find(|(_, tr_copy)| *tr_copy.src() == *tr.src() && !*tr_copy.retr() && *tr.retr() && !tr_copy.dst().trim().is_empty()) {
422 tr.dst = value_tr.dst().to_owned();
423 tr.retr = false;
424 tr.aut = true;
425 }
426 }
427 }
428 });
429
430 Ok(translations)
431 }
432
433 // TODO: Move this to the normal merge functions.
434 pub fn sort_and_merge_locs_for_translation(locs: &mut [&RFile]) -> Result<Loc> {
435
436 // We need them in a specific order so the file priority removes unused loc entries from the translation.
437 locs.sort_by(|a, b| a.path_in_container_raw().cmp(b.path_in_container_raw()));
438 let locs = locs.iter()
439 .filter(|file| {
440 if let Some(name) = file.file_name() {
441 !name.is_empty() && name != TRANSLATED_FILE_NAME
442 } else {
443 false
444 }
445 })
446 .filter_map(|file| if let Ok(RFileDecoded::Loc(loc)) = file.decoded() { Some(loc) } else { None })
447 .collect::<Vec<_>>();
448
449 // Once we merge all the locs in the correct order, remove duplicated keys except the first one.
450 let mut merged_loc = Loc::merge(&locs)?;
451 let mut keys_found = HashSet::new();
452 let mut rows_to_delete = vec![];
453 for (index, row) in merged_loc.data().iter().enumerate() {
454 if keys_found.contains(&row[0].data_to_string()) {
455 rows_to_delete.push(index);
456 } else {
457 keys_found.insert(row[0].data_to_string());
458 }
459 }
460
461 rows_to_delete.reverse();
462 for row in &rows_to_delete {
463 merged_loc.data_mut().remove(*row);
464 }
465
466 Ok(merged_loc)
467 }
468
469 /// Name of the TSV with the vanilla texts of a source language.
470 ///
471 /// # Arguments
472 ///
473 /// * `src_lang` - Source language code (e.g. "EN", "SP").
474 ///
475 /// # Returns
476 ///
477 /// [`VANILLA_LOC_NAME_EN`] for English, `vanilla_{src_lang}.tsv` for any other language.
478 pub fn vanilla_loc_file_name(src_lang: &str) -> String {
479 if src_lang.eq_ignore_ascii_case(DEFAULT_SRC_LANG) {
480 VANILLA_LOC_NAME_EN.to_owned()
481 } else {
482 format!("vanilla_{}.tsv", src_lang.to_lowercase())
483 }
484 }
485
486 /// Generates the vanilla texts TSV of a language from the game's locale packs.
487 ///
488 /// The file is kept so it can still be used after the game stops shipping that language,
489 /// and it's only regenerated when the locale packs are newer than it.
490 ///
491 /// # Arguments
492 ///
493 /// * `game` - Game the locale packs belong to.
494 /// * `game_path` - Path of the game's installation.
495 /// * `src_lang` - Language code of the locale packs to read (e.g. "SP").
496 /// * `dest_folder` - Folder where the TSV is written.
497 ///
498 /// # Returns
499 ///
500 /// `true` if the game has locale packs for the language (so the TSV is up to date), `false` otherwise.
501 ///
502 /// # Errors
503 ///
504 /// Returns an error if the game's data folder can't be read, or if the packs can't be read or the TSV written.
505 pub fn generate_vanilla_loc(game: &GameInfo, game_path: &Path, src_lang: &str, dest_folder: &Path) -> Result<bool> {
506 let packs = Self::locale_pack_paths(&game.data_path(game_path)?, src_lang)?;
507 if packs.is_empty() {
508 return Ok(false);
509 }
510
511 let dest_path = dest_folder.join(Self::vanilla_loc_file_name(src_lang));
512 if let Ok(generated) = fs::metadata(&dest_path).and_then(|meta| meta.modified()) {
513 let newest_pack = packs.iter()
514 .filter_map(|path| fs::metadata(path).and_then(|meta| meta.modified()).ok())
515 .max();
516
517 if newest_pack.is_some_and(|newest_pack| newest_pack <= generated) {
518 return Ok(true);
519 }
520 }
521
522 let mut pack = Pack::read_and_merge(&packs, game, true, false, false)?;
523 pack.files_by_type_mut(&[FileType::Loc]).par_iter_mut().for_each(|file| {
524 let _ = file.decode(&None, true, false);
525 });
526
527 let mut locs = pack.files_by_type(&[FileType::Loc]);
528 let merged_loc = Self::sort_and_merge_locs_for_translation(&mut locs)?;
529
530 // Written to a temp file first, so an interrupted write can't leave a truncated TSV that looks up to date.
531 DirBuilder::new().recursive(true).create(dest_folder)?;
532 let temp_path = dest_path.with_extension("tsv.tmp");
533 let mut writer = WriterBuilder::new()
534 .delimiter(b'\t')
535 .quote_style(QuoteStyle::Never)
536 .has_headers(false)
537 .flexible(true)
538 .from_path(&temp_path)?;
539
540 merged_loc.tsv_export(&mut writer, TRANSLATED_PATH_OLD)?;
541 drop(writer);
542 fs::rename(&temp_path, &dest_path)?;
543
544 Ok(true)
545 }
546
547 /// Paths of the game's locale packs for a language (`local_{lang}.pack`, `local_{lang}_*.pack`), sorted.
548 ///
549 /// Linux ports keep them in `localisation/{lang}/` instead of directly in the data folder.
550 fn locale_pack_paths(data_path: &Path, lang: &str) -> Result<Vec<PathBuf>> {
551 let lang = lang.to_lowercase();
552 let prefix = format!("local_{lang}");
553 let mut files = files_from_subdir(data_path, false)?;
554
555 let localisation_path = data_path.join("localisation").join(&lang);
556 if localisation_path.is_dir() {
557 files.extend(files_from_subdir(&localisation_path, false)?);
558 }
559
560 let mut paths = files.into_iter()
561 .filter(|path| {
562 let name = path.file_name().map(|name| name.to_string_lossy().to_lowercase()).unwrap_or_default();
563 name.ends_with(".pack") && name.strip_prefix(&prefix).is_some_and(|rest| rest.starts_with('.') || rest.starts_with('_'))
564 })
565 .collect::<Vec<_>>();
566
567 paths.sort();
568 Ok(paths)
569 }
570
571 /// This function applies a [PackTranslation] to a Pack.
572 pub fn apply(&self, _pack: &mut Pack) -> Result<()> {
573 todo!()
574 }
575
576 /// This function loads a [PackTranslation] to memory from either a local json file, or a remote one.
577 ///
578 /// Files written in the old format (with `key`, `value_original`, `value_translated`,
579 /// `needs_retranslation`, `removed` and without `src_lang`/`authors`/`glossary`/`aut`) are accepted
580 /// transparently via serde aliases and field defaults — no explicit version probing needed.
581 ///
582 /// On-disk layout:
583 /// - v1+ → `{game_key}/{pack_name}/{src_lang}-{language}.json`.
584 /// - v0 → `{game_key}/{pack_name}/{language}.json` (no source language in the path, since
585 /// v0 implicitly assumed English).
586 ///
587 /// `load_json` will probe both paths in that order so users who upgrade RPFM keep finding
588 /// their existing translations even if they were saved before this refactor.
589 pub fn load(paths: &[PathBuf], pack_name: &str, game_key: &str, src_lang: &str, language: &str) -> Result<Self> {
590 for path in paths {
591 match Self::load_json(path, pack_name, game_key, src_lang, language) {
592 Ok(mut tr) => return {
593 for trad in tr.translations_mut() {
594 trad.1.dst = trad.1.dst.replace("\n||\n", "||");
595 trad.1.dst = trad.1.dst.replace("\r", "\\\\r");
596 trad.1.dst = trad.1.dst.replace("\n", "\\\\n");
597 trad.1.dst = trad.1.dst.replace("\t", "\\\\t");
598 }
599 Ok(tr)
600 },
601 Err(_) => continue,
602 }
603 }
604
605 Err(RLibError::TranslatorCouldNotLoadTranslation)
606 }
607
608 fn load_json(path: &Path, pack_name: &str, game_key: &str, src_lang: &str, language: &str) -> Result<Self> {
609 // v1 layout encodes both source and target language in the filename. v0 layout, which
610 // predates non-EN sources, only used the target language. Probe v1 first; fall back to
611 // v0 only when sourcing from EN, since a v0 file can't possibly carry a non-EN source.
612 let v1_path = path.join(format!("{game_key}/{pack_name}/{src_lang}-{language}.json"));
613 let v0_path = path.join(format!("{game_key}/{pack_name}/{language}.json"));
614 let chosen = if v1_path.is_file() {
615 v1_path
616 } else if src_lang.eq_ignore_ascii_case("EN") && v0_path.is_file() {
617 v0_path
618 } else {
619 return Err(RLibError::TranslatorCouldNotLoadTranslation);
620 };
621
622 let mut file = BufReader::new(File::open(chosen)?);
623 let mut data = Vec::with_capacity(file.get_ref().metadata()?.len() as usize);
624 file.read_to_end(&mut data)?;
625
626 // Peek at the JSON to determine the version before deserialising. We need a different
627 // default for "missing version field in file" (= v0, legacy) versus "freshly created
628 // in-memory PackTranslation" (= v1, current). Serde defaults can only express one of
629 // those, so we resolve it manually here.
630 let value: serde_json::Value = serde_json::from_slice(&data)?;
631 let version = value.get("version").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
632 let mut pack_tr: Self = serde_json::from_value(value)?;
633 pack_tr.version = version;
634 Ok(pack_tr)
635 }
636
637 /// Name of the file this translation is saved as, which depends on its format version.
638 ///
639 /// # Returns
640 ///
641 /// `{src_lang}-{language}.json` for v1+, or the legacy `{language}.json` for v0.
642 pub fn file_name(&self) -> String {
643 if self.version == 0 {
644 format!("{}.json", self.language)
645 } else {
646 format!("{}-{}.json", self.src_lang, self.language)
647 }
648 }
649
650 /// This function saves a [PackTranslation] from memory to a `.json` file with the provided path.
651 ///
652 /// The on-disk format depends on [`Self::version`]: 0 writes the legacy shape, 1 (or higher)
653 /// writes the current shape. Downgrading v1 → v0 drops fields that don't exist in v0
654 /// (`src_lang`, `authors`, `glossary` and the per-entry `aut` flag).
655 ///
656 /// The filename also depends on the version: v1+ uses `{src_lang}-{language}.json` so
657 /// translations from different source languages live side-by-side; v0 keeps the legacy
658 /// `{language}.json` because the old format had no concept of source language.
659 pub fn save(&mut self, path: &Path, game_key: &str) -> Result<()> {
660 let folder = path.join(format!("{}/{}", game_key, self.pack_name));
661 let path = folder.join(self.file_name());
662
663 // Make sure the path exists to avoid problems with updating schemas.
664 DirBuilder::new().recursive(true).create(&folder)?;
665
666 // Switching between v0 and v1 moves an EN-sourced translation between the two filenames, so remove
667 // the stale one. Non-EN sources never have a v0 file, and `{language}.json` belongs to the EN one.
668 if self.src_lang.eq_ignore_ascii_case(DEFAULT_SRC_LANG) {
669 let other_filename = if self.version == 0 {
670 format!("{}-{}.json", self.src_lang, self.language)
671 } else {
672 format!("{}.json", self.language)
673 };
674
675 let other_path = folder.join(other_filename);
676 if other_path.is_file() {
677 std::fs::remove_file(&other_path)?;
678 }
679 }
680
681 let json = if self.version == 0 {
682 // Materialize the v0 wire shape from current data. The per-entry `aut` flag is
683 // dropped silently — there's no corresponding concept in v0.
684 let translations = self.translations.iter()
685 .map(|(key, tr)| (key.clone(), TranslationV0Wire {
686 key: key.clone(),
687 value_original: tr.src(),
688 value_translated: tr.dst(),
689 needs_retranslation: *tr.retr(),
690 removed: *tr.rem(),
691 }))
692 .collect::<BTreeMap<_, _>>();
693
694 let wire = PackTranslationV0Wire {
695 language: &self.language,
696 pack_name: &self.pack_name,
697 translations,
698 };
699
700 serde_json::to_string_pretty(&wire)?
701 } else {
702 serde_json::to_string_pretty(&self)?
703 };
704
705 let mut file = BufWriter::new(File::create(&path)?);
706 file.write_all(json.as_bytes())?;
707 Ok(())
708 }
709
710 pub fn definition() -> Definition {
711 // We put the booleans first because they may act as a kind of filter.
712 Definition::new_with_fields(0, &[
713 Field { name: "key".to_string(), field_type: FieldType::StringU8, is_key: true, ..Default::default() },
714 Field { name: "retr".to_string(), field_type: FieldType::Boolean, ..Default::default() },
715 Field { name: "rem".to_string(), field_type: FieldType::Boolean, ..Default::default() },
716 Field { name: "aut".to_string(), field_type: FieldType::Boolean, ..Default::default() },
717 Field { name: "src".to_string(), field_type: FieldType::StringU8, ..Default::default() },
718 Field { name: "dst".to_string(), field_type: FieldType::StringU8, ..Default::default() },
719 ], &[], None)
720 }
721
722 pub fn from_table(&mut self, table: &TableInMemory) -> Result<()> {
723 self.translations_mut().clear();
724
725 for row in table.data().iter() {
726 let mut tr = Translation::default();
727 let mut key = String::new();
728
729 if let DecodedData::StringU8(ref data) = row[0] {
730 key = data.to_owned();
731 }
732
733 if let DecodedData::Boolean(data) = row[1] {
734 tr.set_retr(data);
735 }
736
737 if let DecodedData::Boolean(data) = row[2] {
738 tr.set_rem(data);
739 }
740
741 if let DecodedData::Boolean(data) = row[3] {
742 tr.set_aut(data);
743 }
744
745 if let DecodedData::StringU8(ref data) = row[4] {
746 tr.set_src(data.to_owned());
747 }
748
749 if let DecodedData::StringU8(ref data) = row[5] {
750 tr.set_dst(data.to_owned());
751 }
752
753 self.translations_mut().insert(key, tr);
754 }
755
756 Ok(())
757 }
758
759 pub fn to_table(&self) -> Result<TableInMemory> {
760 let definition = Self::definition();
761 let mut table = TableInMemory::new(&definition, None, "");
762
763 // Due to bugs in the table filters, we pre-sort the data by putting stuff that needs to be retranslated at the start.
764 let data = self.translations()
765 .iter()
766 .sorted_by(|(k1, _), (k2, _)| Ord::cmp(k1, k2))
767 .sorted_by(|(_, tr1), (_, tr2)| Ord::cmp(tr2.retr(), tr1.retr()))
768 .map(|(key, tr)| vec![
769 DecodedData::StringU8(key.to_owned()),
770 DecodedData::Boolean(*tr.retr()),
771 DecodedData::Boolean(*tr.rem()),
772 DecodedData::Boolean(*tr.aut()),
773 DecodedData::StringU8(tr.src().to_owned()),
774 DecodedData::StringU8(tr.dst().to_owned()),
775 ]).collect::<Vec<_>>();
776
777 table.set_data(&data)?;
778 Ok(table)
779 }
780
781 /// Definition of the per-pack glossary table, used to drive the UI table view.
782 ///
783 /// The glossary is a flat two-column table: a source term (used as the key) and the
784 /// preferred translation for it.
785 pub fn glossary_definition() -> Definition {
786 Definition::new_with_fields(0, &[
787 Field { name: "source".to_string(), field_type: FieldType::StringU8, is_key: true, ..Default::default() },
788 Field { name: "translation".to_string(), field_type: FieldType::StringU8, ..Default::default() },
789 ], &[], None)
790 }
791
792 /// Materialize the glossary as an editable [`TableInMemory`] for the UI.
793 pub fn glossary_to_table(&self) -> Result<TableInMemory> {
794 let definition = Self::glossary_definition();
795 let mut table = TableInMemory::new(&definition, None, "");
796
797 // BTreeMap iteration is already sorted, which matches what we want in the UI.
798 let data = self.glossary()
799 .iter()
800 .map(|(src, dst)| vec![
801 DecodedData::StringU8(src.to_owned()),
802 DecodedData::StringU8(dst.to_owned()),
803 ]).collect::<Vec<_>>();
804
805 table.set_data(&data)?;
806 Ok(table)
807 }
808
809 /// Replace the glossary with the contents of `table`. Empty source cells are dropped so a
810 /// trailing blank row left over from the UI doesn't end up in the saved JSON.
811 pub fn glossary_from_table(&mut self, table: &TableInMemory) -> Result<()> {
812 self.glossary_mut().clear();
813
814 for row in table.data().iter() {
815 let src = match row.first() {
816 Some(DecodedData::StringU8(s)) => s.trim().to_owned(),
817 _ => continue,
818 };
819 if src.is_empty() {
820 continue;
821 }
822 let dst = match row.get(1) {
823 Some(DecodedData::StringU8(s)) => s.to_owned(),
824 _ => String::new(),
825 };
826 self.glossary_mut().insert(src, dst);
827 }
828
829 Ok(())
830 }
831}
832
833impl Default for PackTranslation {
834 fn default() -> Self {
835 Self {
836 version: CURRENT_VERSION,
837 language: String::new(),
838 src_lang: default_src_lang(),
839 pack_name: String::new(),
840 authors: Vec::new(),
841 glossary: BTreeMap::new(),
842 translations: HashMap::new(),
843 }
844 }
845}
846
847/// Default source language used when loading translation files that don't
848/// declare one (i.e. files written before `src_lang` existed). Exists as a
849/// function rather than a `&str` because `#[serde(default = "...")]` takes a
850/// function path; the actual value lives in [`DEFAULT_SRC_LANG`].
851fn default_src_lang() -> String {
852 DEFAULT_SRC_LANG.to_owned()
853}
854
855/// Special serializer function to sort the translations HashMap before serializing.
856fn ordered_map_translations<S>(value: &HashMap<String, Translation>, serializer: S) -> Result<S::Ok, S::Error> where S: Serializer, {
857 let ordered: BTreeMap<_, _> = value.iter().collect();
858 ordered.serialize(serializer)
859}