weblate-luau 0.1.0

Generates a Luau table for weblate translations that are represented in basic JSON
use crate::args::Args;
use crate::db::LuauLocaleDb;
use crate::language::Language;
use clap::Parser;
use log::{LevelFilter, error, warn};
use serde_json::Value;
use simple_logger::SimpleLogger;
use std::collections::HashMap;
use std::ffi::OsStr;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::{fs, iter};

mod args;
mod db;
mod language;

fn main() {
    let args = Args::parse();
    let mut db = LuauLocaleDb::default();

    // start the logger
    SimpleLogger::new()
        .with_level(LevelFilter::Debug)
        .init()
        .expect("Failed to install logger");

    // try to translate each language
    for language in Language::FROSTBITE_LANGUAGES {
        let language_files = files_for_langauge(&args.root, language);
        if language_files.is_empty() {
            warn!(
                "No valid entries found for {} (expect any of {:?})",
                language.name, language.additional_codes
            );
            continue;
        }

        // read each language file
        for (path, contents) in language_files {
            // read the file as a JSON map
            let map = serde_json::from_str::<HashMap<String, Value>>(&contents)
                .unwrap_or_else(|e| panic!("Failed to deserialize {path:?}: {e}"));

            for (key, value) in map
                .into_iter()
                .flat_map(|(key, value)| extract_key_values(&path, key, value))
            {
                // add them to the mapping
                db.insert_mapping(language, key, value)
            }
        }
    }

    // write to the output file
    fs::write(args.output, format!("{db}")).expect("Failed to write output file")
}

/// Extracts key-values from a potentially-nested JSON object.
fn extract_key_values(
    path: &Path,
    key: String,
    value: Value,
) -> Box<dyn Iterator<Item = (String, String)> + '_> {
    match value {
        Value::String(str) => Box::new(iter::once((key, str))),
        Value::Object(object) => {
            // extract all keys and combine paths with `.`
            Box::new(
                object
                    .into_iter()
                    .flat_map(move |(inner_key, inner_value)| {
                        extract_key_values(path, format!("{key}.{inner_key}"), inner_value)
                    }),
            )
        }
        v => panic!("Unexpected JSON value {v} in {path:?}"),
    }
}

/// Returns a list of files for a given language, and their contents.
fn files_for_langauge(root: &Path, language: &Language) -> Vec<(PathBuf, String)> {
    for language_code in iter::once(&language.fb_code).chain(language.additional_codes.iter()) {
        // try to read a direct file first
        let base_path = root.join(language_code);
        let direct_path = base_path.join(".json");
        if let Some(contents) = read_file(&direct_path) {
            return vec![(direct_path, contents)];
        }

        // try to read nested files in a directory with the name
        match fs::read_dir(&base_path) {
            Ok(directory) => {
                return directory
                    .filter_map(move |entry| {
                        let path = match entry {
                            Ok(entry) => entry.path(),
                            Err(err) => {
                                error!("Failed reading an entry in {base_path:?}: {err:?}");
                                return None;
                            }
                        };

                        // ignore non-JSON files
                        if !path.is_file() || path.extension() != Some(OsStr::new("json")) {
                            warn!("Ignoring {path:?} because it is not a JSON file");
                            return None;
                        }

                        // read the contents
                        read_file(&path).map(|contents| (path, contents))
                    })
                    .collect();
            }
            Err(err) if err.kind() != ErrorKind::NotFound => {
                error!("Unexpected error trying to read {base_path:?} as a directory: {err}");
            }
            _ => {}
        }
    }

    vec![]
}

/// Reads file contents to a string.
fn read_file(path: &Path) -> Option<String> {
    match fs::read_to_string(path) {
        Ok(contents) => Some(contents),
        Err(err) => {
            if err.kind() != ErrorKind::NotFound {
                error!("Unexpected error trying to read {path:?}: {err}");
            }

            None
        }
    }
}