Skip to main content

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}