diff options
| author | metamuffin <metamuffin@disroot.org> | 2025-10-07 17:32:43 +0200 |
|---|---|---|
| committer | metamuffin <metamuffin@disroot.org> | 2025-10-07 17:32:43 +0200 |
| commit | 30bfe4dc801fdfd3ab8d7a5e36bba67eee70d33b (patch) | |
| tree | 59c6b9676e555f2264a1e8bd0f2b4f0fa7acd4e7 /server/locale-export/src | |
| parent | f01b9bb2375e1dbaede262c6281dc3a3d068cbb1 (diff) | |
| download | hurrycurry-30bfe4dc801fdfd3ab8d7a5e36bba67eee70d33b.tar hurrycurry-30bfe4dc801fdfd3ab8d7a5e36bba67eee70d33b.tar.bz2 hurrycurry-30bfe4dc801fdfd3ab8d7a5e36bba67eee70d33b.tar.zst | |
Split book exporting to own crate; move locale tool to server dir
Diffstat (limited to 'server/locale-export/src')
| -rw-r--r-- | server/locale-export/src/main.rs | 313 |
1 files changed, 313 insertions, 0 deletions
diff --git a/server/locale-export/src/main.rs b/server/locale-export/src/main.rs new file mode 100644 index 00000000..23823716 --- /dev/null +++ b/server/locale-export/src/main.rs @@ -0,0 +1,313 @@ +use anyhow::{anyhow, Context, Result}; +use clap::Parser; +use std::{ + collections::BTreeMap, + fmt::Write as W2, + fs::{read_to_string, File}, + io::Write, + path::{Path, PathBuf}, +}; + +#[derive(Parser)] +enum Args { + ImportOldPot { + input: PathBuf, + output: PathBuf, + }, + ImportOldPo { + reference: PathBuf, + input: PathBuf, + output: PathBuf, + }, + ExportJson { + output: PathBuf, + inputs: Vec<PathBuf>, + }, + ExportGodotCsv { + input_dir: PathBuf, + output: PathBuf, + }, + ExportPo { + #[arg(long)] + remap_ids: Option<PathBuf>, + output: PathBuf, + inputs: Vec<PathBuf>, + }, +} + +static NATIVE_LANGUAGE_NAMES: &[(&str, &str)] = &[ + ("en", "English"), + ("de", "Deutsch"), + ("fr", "Français"), + ("nl", "Nederlands"), + ("es", "Español"), + ("eu", "Euskara"), + ("ja", "日本語"), + ("he", "עִברִית"), + ("tr", "Türkçe"), + ("fi", "Suomi"), + ("ar", "العربية"), + ("zh_Hans", "中文 (简化字)"), + ("zh_Hant", "中文 (繁體字)"), + ("pl", "Polski"), + ("pt", "Português"), + ("it", "Italiano"), + ("ko", "한국인"), + ("el", "ελληνικά"), + ("ru", "русский"), +]; + +fn export_load(inputs: &[PathBuf]) -> Result<BTreeMap<String, String>> { + let mut ini = BTreeMap::new(); + for path in inputs { + let f = load_ini(path)?; + for (k, v) in f { + ini.entry(k).or_insert(v); + } + } + for &(code, name) in NATIVE_LANGUAGE_NAMES { + ini.insert(format!("c.settings.ui.language.{code}"), name.to_owned()); + } + Ok(ini) +} + +fn main() -> Result<()> { + let args = Args::parse(); + match args { + Args::ExportJson { inputs, output } => { + let ini = export_load(&inputs)?; + File::create(output)?.write_all(serde_json::to_string(&ini)?.as_bytes())?; + Ok(()) + } + Args::ExportPo { + remap_ids: id_map, + output, + inputs, + } => { + let ini = export_load(&inputs)?; + let id_map = id_map.map(|path| load_ini(&path)).transpose()?; + File::create(output)?.write_all( + format!( + r#" +msgid "" +msgstr "" +"Language: {}\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +{}"#, + inputs + .first() + .ok_or(anyhow!("at least one input required"))? + .file_stem() + .ok_or(anyhow!("file name empty"))? + .to_string_lossy(), + ini.into_iter() + .try_fold(String::new(), |mut a, (mut key, value)| { + if let Some(id_map) = &id_map { + if let Some(new_id) = id_map.get(&key) { + key = new_id.to_owned() + } + } + let key = serde_json::to_string(&key)?; + let value = serde_json::to_string(&value)?; + writeln!(a, "msgid {key}\nmsgstr {value}\n\n",)?; + Ok::<_, anyhow::Error>(a) + })? + ) + .as_bytes(), + )?; + Ok(()) + } + Args::ExportGodotCsv { input_dir, output } => { + let translations = input_dir + .read_dir()? + .flat_map(|e| { + e.map_err(|e| anyhow!("{e}")) + .and_then(|e| { + if e.file_name().to_string_lossy().ends_with(".ini") { + Ok(Some(( + e.path() + .file_stem() + .ok_or(anyhow!("empty filename"))? + .to_string_lossy() + .to_string(), + load_ini(&e.path())?, + ))) + } else { + Ok(None) + } + }) + .transpose() + }) + .collect::<Result<BTreeMap<_, _>>>()?; + + let langs = translations.keys().cloned().collect::<Vec<String>>(); + let mut tr_tr = BTreeMap::<String, BTreeMap<String, String>>::new(); + for (k, v) in translations { + for (kk, vv) in v { + tr_tr.entry(kk).or_default().insert(k.clone(), vv); + } + } + + File::create(output)?.write_all( + tr_tr + .into_iter() + .try_fold(format!("id,{}\n", langs.join(",")), |mut a, (k, v)| { + writeln!( + a, + "{k},{}", + v.values() + .map(serde_json::to_string) + .collect::<Result<Vec<_>, serde_json::Error>>()? + .join(",") + )?; + Ok::<_, anyhow::Error>(a) + })? + .as_bytes(), + )?; + Ok(()) + } + Args::ImportOldPo { + reference, + input, + output, + } => { + let reference = read_to_string(reference)?; + let input = read_to_string(input)?; + + let id_reverse = reference + .lines() + .skip(1) + .map(|l| { + l.split_once("=") + .map(|(k, v)| (v, k)) + .ok_or(anyhow!("invalid ini")) + }) + .collect::<Result<BTreeMap<&str, &str>>>()?; + + let mut outmap = BTreeMap::new(); + let mut mode = 0; + let mut msgid = String::new(); + let mut msgstr = String::new(); + for (i, mut line) in input.lines().enumerate() { + if line.starts_with("#") { + continue; + } + if line.is_empty() { + continue; + } + if let Some(rest) = line.strip_prefix("msgid ") { + if !msgid.is_empty() { + if let Some(id) = id_reverse.get(&msgid.as_str()) { + outmap.insert(id.to_owned(), msgstr.clone()); + } else { + eprintln!("warning: message id {msgid:?} is unknown") + } + } + line = rest; + msgid = String::new(); + mode = 1; + } else if let Some(rest) = line.strip_prefix("msgstr ") { + line = rest; + msgstr = String::new(); + mode = 2; + } else if line.starts_with("msgctxt ") { + mode = 0; + eprintln!("warning: msgctxt not implemented (line {})", i + 1); + continue; + } + let frag = + serde_json::from_str::<String>(line).context(anyhow!("line {}", i + 1))?; + match mode { + 0 => (), + 1 => msgid.push_str(&frag), + 2 => msgstr.push_str(&frag), + _ => unreachable!(), + }; + } + + File::create(output)?.write_all( + outmap + .into_iter() + .try_fold("[hurrycurry]\n".to_string(), |mut a, (k, v)| { + writeln!(a, "{k}={v}")?; + Ok::<_, anyhow::Error>(a) + })? + .as_bytes(), + )?; + + Ok(()) + } + Args::ImportOldPot { input, output } => { + let output_raw = read_to_string(&output).unwrap_or("".to_owned()); + let input = read_to_string(input)?; + + let mut output_flip = output_raw + .lines() + .skip(1) + .map(|l| { + l.split_once("=") + .map(|(k, v)| (v.to_owned(), k.to_owned())) + .ok_or(anyhow!("invalid ini")) + }) + .collect::<Result<BTreeMap<String, String>>>()?; + + let mut id = false; + let mut msgid = String::new(); + for (i, mut line) in input.lines().enumerate() { + if line.starts_with("#") { + continue; + } + if line.is_empty() { + continue; + } + if let Some(rest) = line.strip_prefix("msgid ") { + if !msgid.is_empty() && !output_flip.contains_key(&msgid) { + output_flip.insert(msgid.replace("\n", "\\n"), format!("unknown{i}")); + } + line = rest; + id = true; + msgid = String::new(); + } else if line.starts_with("msgctxt ") || line.starts_with("msgstr ") { + id = false; + continue; + } + if id { + let frag = + serde_json::from_str::<String>(line).context(anyhow!("line {}", i + 1))?; + msgid.push_str(frag.as_str()); + } + } + + let output_unflip = output_flip + .into_iter() + .map(|(v, k)| (k, v)) + .collect::<BTreeMap<_, _>>(); + + File::create(output)?.write_all( + output_unflip + .into_iter() + .try_fold("[hurrycurry]\n".to_string(), |mut a, (k, v)| { + writeln!(a, "{k}={v}")?; + Ok::<_, anyhow::Error>(a) + })? + .as_bytes(), + )?; + + Ok(()) + } + } +} + +fn load_ini(path: &Path) -> Result<BTreeMap<String, String>> { + read_to_string(path)? + .lines() + .skip(1) + .map(|l| { + let (k, v) = l.split_once("=").ok_or(anyhow!("'=' missing"))?; + Ok::<_, anyhow::Error>((k.trim_end().to_owned(), v.trim_start().replace("%n", "\n"))) + }) + .collect() +} |