use super::{Error, Result};
use crate::{ArbEntry, ArbFile};
use deepl::{DeeplApi, Lang, TagHandling, TranslateTextRequest};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{
borrow::Cow,
collections::{BTreeMap, HashMap},
path::{Path, PathBuf},
};
use yaml_rust2::YamlLoader;
const ARB_DIR: &str = "arb-dir";
const TEMPLATE_ARB_FILE: &str = "template-arb-file";
const NAME_PREFIX: &str = "name-prefix";
const OVERRIDES_DIR: &str = "overrides-dir";
const CACHE_FILE: &str = ".cache.json";
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct ArbCache(BTreeMap<Lang, ArbFile>);
impl ArbCache {
pub fn get_file(&self, lang: &Lang) -> Option<&ArbFile> {
self.0.get(lang)
}
pub fn add_entry(&mut self, lang: Lang, entry: ArbEntry<'_>) {
let file = self.0.entry(lang).or_insert(ArbFile::default());
file.insert_entry(entry);
}
pub fn remove_entry(&mut self, lang: &Lang, key: &str) -> Option<Value> {
if let Some(file) = self.0.get_mut(lang) {
file.remove(key)
} else {
None
}
}
}
pub enum Invalidation {
All,
Keys(Vec<String>),
}
pub struct TranslationOptions {
pub target_lang: Lang,
pub dry_run: bool,
pub invalidation: Option<Invalidation>,
pub overrides: Option<HashMap<Lang, ArbFile>>,
#[doc(hidden)]
pub disable_cache: bool,
}
impl TranslationOptions {
pub fn new(target_lang: Lang) -> Self {
Self {
target_lang,
dry_run: false,
invalidation: None,
overrides: None,
disable_cache: false,
}
}
}
#[derive(Debug)]
pub struct TranslateResult {
pub template: ArbFile,
pub translated: ArbFile,
pub length: usize,
}
#[derive(Debug)]
enum CachedEntry<'a> {
Entry(ArbEntry<'a>),
Translate {
entry: ArbEntry<'a>,
names: Option<Vec<&'a str>>,
index: Option<usize>,
},
}
#[derive(Debug)]
pub struct Intl {
file_path: PathBuf,
arb_dir: String,
template_language: Lang,
template_arb_file: String,
name_prefix: String,
overrides_dir: Option<String>,
pub(crate) cache: ArbCache,
}
impl Intl {
pub fn new(path: impl AsRef<Path>) -> Result<Self> {
Self::new_with_prefix(path, None)
}
pub fn new_with_prefix(path: impl AsRef<Path>, name_prefix: Option<String>) -> Result<Self> {
if !path.as_ref().try_exists()? {
return Err(Error::NoFile(path.as_ref().to_path_buf()));
}
if !path.as_ref().is_file() {
return Err(Error::NotFile(path.as_ref().to_path_buf()));
}
let content = std::fs::read_to_string(path.as_ref())?;
let docs = YamlLoader::load_from_str(&content)?;
if docs.is_empty() {
return Err(Error::NoYamlDocuments(path.as_ref().to_owned()));
}
let doc = &docs[0];
let arb_dir = doc[ARB_DIR]
.as_str()
.ok_or_else(|| Error::ArbDirNotDefined(path.as_ref().to_owned()))?;
let template_arb_file = doc[TEMPLATE_ARB_FILE]
.as_str()
.ok_or_else(|| Error::TemplateArbFileNotDefined(path.as_ref().to_owned()))?;
let name_prefix = if let Some(name_prefix) = doc[NAME_PREFIX].as_str() {
name_prefix.to_string()
} else {
name_prefix.unwrap_or_else(|| "app".to_string())
};
let overrides_dir = doc[OVERRIDES_DIR].as_str().map(|s| s.to_string());
let stem = template_arb_file.trim_end_matches(".arb");
let pat = format!("{}_", name_prefix);
let lang_code = stem.trim_start_matches(&pat);
let template_language: Lang = lang_code.parse()?;
let mut index = Intl {
file_path: path.as_ref().to_owned(),
arb_dir: arb_dir.to_owned(),
template_arb_file: template_arb_file.to_owned(),
template_language,
name_prefix,
cache: Default::default(),
overrides_dir,
};
index.cache = index.read_cache()?;
Ok(index)
}
pub fn arb_dir(&self) -> &str {
&self.arb_dir
}
pub fn template_arb_file(&self) -> &str {
&self.template_arb_file
}
pub fn name_prefix(&self) -> &str {
&self.name_prefix
}
pub fn overrides_dir(&self) -> Option<&str> {
self.overrides_dir.as_ref().map(|s| &s[..])
}
pub fn template_language(&self) -> &Lang {
&self.template_language
}
pub fn cache(&self) -> &ArbCache {
&self.cache
}
pub fn template_content(&self) -> Result<ArbFile> {
let path = self
.parent_path()?
.to_owned()
.join(&self.arb_dir)
.join(&self.template_arb_file);
let content = std::fs::read_to_string(&path)?;
Ok(serde_json::from_str(&content)?)
}
pub fn parent_path(&self) -> Result<&Path> {
self.file_path
.parent()
.ok_or_else(|| Error::NoParentPath(self.file_path.clone()))
}
pub fn file_path(&self, lang: Lang) -> Result<PathBuf> {
Ok(self.arb_directory()?.join(self.format_file_name(lang)))
}
pub fn format_file_name(&self, lang: Lang) -> String {
format!(
"{}_{}.arb",
self.name_prefix,
lang.to_string().to_lowercase().replace("-", "_")
)
}
pub fn parse_file_name(&self, path: impl AsRef<Path>) -> Option<Lang> {
if let Some(name) = path.as_ref().file_stem() {
let name = name.to_string_lossy();
if name.starts_with(&self.name_prefix) {
let pat = format!("{}_", self.name_prefix);
let lang_code = name.trim_start_matches(&pat);
lang_code.parse().ok()
} else {
None
}
} else {
None
}
}
pub fn arb_directory(&self) -> Result<PathBuf> {
let arb_dir = PathBuf::from(&self.arb_dir);
let parent = if arb_dir.is_relative() {
self.parent_path()?.join(arb_dir)
} else {
arb_dir
};
if !parent.is_dir() {
return Err(Error::NotDirectory(parent));
}
Ok(parent)
}
pub fn list_translated(&self) -> Result<BTreeMap<Lang, PathBuf>> {
self.list_directory(self.arb_directory()?)
}
pub fn list_directory(&self, dir: impl AsRef<Path>) -> Result<BTreeMap<Lang, PathBuf>> {
let mut output = BTreeMap::new();
if !dir.as_ref().is_dir() {
return Err(Error::NotDirectory(dir.as_ref().to_path_buf()));
}
for entry in std::fs::read_dir(dir.as_ref())? {
let entry = entry?;
let path = entry.path();
if let (true, Some(lang)) = (path.is_file(), self.parse_file_name(&path)) {
output.insert(lang, path);
}
}
Ok(output)
}
pub fn load_overrides(
&self,
dir: impl AsRef<Path>,
languages: Option<Vec<Lang>>,
) -> Result<HashMap<Lang, ArbFile>> {
let mut output = HashMap::new();
let langs = self.list_directory(dir.as_ref())?;
for (lang, path) in langs {
if let Some(filters) = &languages {
if !filters.contains(&lang) {
continue;
}
}
let content = std::fs::read_to_string(&path)?;
let file: ArbFile = serde_json::from_str(&content)?;
output.insert(lang, file);
}
Ok(output)
}
pub fn load(&self, lang: Lang) -> Result<ArbFile> {
let path = self.file_path(lang)?;
if !path.try_exists()? {
return Err(Error::NoFile(path));
}
let content = std::fs::read_to_string(&path)?;
Ok(serde_json::from_str(&content)?)
}
pub fn load_or_default(&self, lang: Lang) -> Result<ArbFile> {
match self.load(lang) {
Ok(res) => Ok(res),
Err(Error::NoFile(_)) => Ok(ArbFile::default()),
Err(e) => Err(e),
}
}
pub async fn translate(
&mut self,
api: &DeeplApi,
options: TranslationOptions,
) -> Result<TranslateResult> {
tracing::info!(lang = %options.target_lang, "translate");
let template = self.template_content()?;
let mut output = self.load_or_default(options.target_lang)?;
let mut cached = Vec::new();
let mut translatable = Vec::new();
let diff = template.diff(&output, self.cache.get_file(&options.target_lang));
let overrides = if let Some(overrides) = &options.overrides {
overrides.get(&options.target_lang)
} else {
None
};
for entry in template.entries() {
let invalidated = match &options.invalidation {
Some(Invalidation::All) => true,
Some(Invalidation::Keys(keys)) => keys.iter().any(|x| x == entry.key().as_ref()),
_ => false,
};
if !invalidated
&& (diff.delete.contains(entry.key().as_ref())
|| (!diff.create.contains(entry.key().as_ref())
&& !diff.update.contains(entry.key().as_ref())))
{
continue;
}
if let Some(overrides) = overrides {
if overrides.lookup(entry.key().as_ref()).is_some() {
continue;
}
}
if entry.is_translatable() {
let placeholders = template.placeholders(entry.key())?;
if let Some(placeholders) = &placeholders {
tracing::info!(
key = %entry.key(),
placeholders = ?placeholders.to_vec(),
"prepare");
} else {
tracing::info!(
key = %entry.key(),
"prepare");
}
let text = entry.value().as_str().unwrap();
let names = if let Some(placeholders) = &placeholders {
placeholders.verify(text)?;
Some(placeholders.to_vec())
} else {
None
};
let text = if let Some(names) = &names {
let mut text = text.to_string();
for name in names {
text = text.replacen(
&format!("{{{}}}", name),
&format!("<ph>{}</ph>", name),
1,
);
}
Cow::Owned(text)
} else {
Cow::Borrowed(text)
};
let key_index = if diff.create.contains(entry.key().as_ref()) {
template.contents.get_index_of(entry.key().as_ref())
} else {
None
};
if !options.dry_run {
translatable.push(text.as_ref().to_string());
if !options.disable_cache {
self.cache.add_entry(options.target_lang, entry.clone());
}
cached.push(CachedEntry::Translate {
entry,
names,
index: key_index,
});
} else {
cached.push(CachedEntry::Entry(entry));
}
} else {
cached.push(CachedEntry::Entry(entry));
}
}
for key in diff.delete {
tracing::info!(key = %key, "delete");
output.remove(&key);
self.cache.remove_entry(&options.target_lang, &key);
}
let length = translatable.len();
tracing::info!(
lang = %options.target_lang,
length = %length,
"translate");
if !translatable.is_empty() {
let mut request = TranslateTextRequest::new(translatable, options.target_lang);
request.tag_handling = Some(TagHandling::Xml);
request.ignore_tags = Some(vec!["ph".to_string()]);
let mut result = api.translate_text(&request).await?;
if result.translations.len() != length {
return Err(Error::TranslationLength(length, result.translations.len()));
}
for entry in cached {
match entry {
CachedEntry::Entry(entry) => {
output.insert_entry(entry);
}
CachedEntry::Translate {
entry,
names,
index,
} => {
let translated = result.translations.remove(0).text;
let translation = if let Some(names) = names {
let mut translation = translated;
for name in names.into_iter() {
let needle = format!("<ph>{}</ph>", name);
let original = format!("{{{}}}", name);
translation = translation.replacen(&needle, &original, 1);
}
translation
} else {
translated
};
if let Some(index) = index {
if index < output.len() {
output.shift_insert_translation(index, entry.key(), translation)
} else {
output.insert_translation(entry.key(), translation)
}
} else {
output.insert_translation(entry.key(), translation)
}
}
}
}
}
if let Some(overrides) = overrides {
for entry in overrides.entries() {
tracing::info!(key = %entry.key().as_ref(), "override");
output.insert_entry(entry);
}
}
if !options.disable_cache {
self.write_cache()?;
}
Ok(TranslateResult {
template,
translated: output,
length,
})
}
fn read_cache(&self) -> Result<ArbCache> {
let cache_path = self.arb_directory()?.join(CACHE_FILE);
if cache_path.try_exists()? {
let mut cache_file = std::fs::File::open(cache_path)?;
Ok(serde_json::from_reader(&mut cache_file)?)
} else {
Ok(ArbCache::default())
}
}
fn write_cache(&self) -> Result<()> {
let cache_path = self.arb_directory()?.join(CACHE_FILE);
let mut cache_file = std::fs::File::create(cache_path)?;
serde_json::to_writer_pretty(&mut cache_file, &self.cache)?;
Ok(())
}
}