use tinyjson::JsonValue;
pub const PROD_ENDPOINT: &str = "https://firefox.settings.services.mozilla.com";
pub const COLLECTION: &str = "translations-models-v2";
pub const CDN_ROOT: &str = "https://firefox-settings-attachments.cdn.mozilla.net";
pub const RECORDS_URL_ENV: &str = "FXTRANSLATE_RECORDS_URL";
pub fn records_url() -> String {
if let Ok(url) = std::env::var(RECORDS_URL_ENV) {
if !url.is_empty() {
return url;
}
}
format!("{PROD_ENDPOINT}/v1/buckets/main/collections/{COLLECTION}/records")
}
#[derive(Clone, Debug)]
pub struct Record {
pub name: String,
pub file_type: String,
pub src: String,
pub trg: String,
pub version: String,
pub architecture: Option<String>,
pub decompressed_hash: Option<String>,
pub location: String,
}
impl Record {
pub fn cdn_url(&self) -> String {
format!("{CDN_ROOT}/{}", self.location)
}
}
fn as_str(v: Option<&JsonValue>) -> Option<String> {
match v {
Some(JsonValue::String(s)) => Some(s.clone()),
_ => None,
}
}
pub fn parse_records(body: &str) -> Result<Vec<Record>, String> {
let root: JsonValue = body.parse().map_err(|e| format!("invalid JSON: {e}"))?;
let obj = match &root {
JsonValue::Object(o) => o,
_ => return Err("records response is not an object".into()),
};
let data = match obj.get("data") {
Some(JsonValue::Array(a)) => a,
_ => return Err("records response has no `data` array".into()),
};
let mut out = Vec::new();
for item in data {
let rec = match item {
JsonValue::Object(o) => o,
_ => continue,
};
let attachment = match rec.get("attachment") {
Some(JsonValue::Object(a)) => a,
_ => continue,
};
let (name, file_type, src, trg, location) = match (
as_str(rec.get("name")),
as_str(rec.get("fileType")),
as_str(rec.get("sourceLanguage")),
as_str(rec.get("targetLanguage")),
as_str(attachment.get("location")),
) {
(Some(n), Some(f), Some(s), Some(t), Some(l)) => (n, f, s, t, l),
_ => continue,
};
out.push(Record {
name,
file_type,
src,
trg,
version: as_str(rec.get("version")).unwrap_or_else(|| "0".into()),
architecture: as_str(rec.get("architecture")),
decompressed_hash: as_str(rec.get("decompressedHash")),
location,
});
}
Ok(out)
}
#[cfg(feature = "download")]
pub fn fetch_records(fetch: &dyn crate::fetch::Fetch) -> Result<Vec<Record>, String> {
let body = fetch.get(&records_url())?;
let text = String::from_utf8(body).map_err(|e| format!("records not UTF-8: {e}"))?;
parse_records(&text)
}
fn version_key(v: &str) -> Vec<u64> {
v.split('.').map(|p| p.parse().unwrap_or(0)).collect()
}
pub const SUPPORTED_MAJOR: u64 = 3;
fn version_supported(version: &str) -> bool {
version_key(version).first().copied() == Some(SUPPORTED_MAJOR)
}
pub fn pick<'a>(
records: &'a [Record],
file_type: &str,
src: &str,
trg: &str,
) -> Option<&'a Record> {
records
.iter()
.filter(|r| r.file_type == file_type && r.src == src && r.trg == trg)
.filter(|r| version_supported(&r.version))
.max_by(|a, b| version_key(&a.version).cmp(&version_key(&b.version)))
}
pub fn language_matches(src: &str, trg: &str, query: &str) -> bool {
match query.split_once('-') {
Some((q_src, q_trg)) => src.starts_with(q_src) && trg.starts_with(q_trg),
None => src.starts_with(query) || trg.starts_with(query),
}
}
pub fn pairs(records: &[Record]) -> Vec<(String, String)> {
let mut ps: Vec<(String, String)> = records
.iter()
.filter(|r| r.file_type == "model" && version_supported(&r.version))
.map(|r| (r.src.clone(), r.trg.clone()))
.collect();
ps.sort();
ps.dedup();
ps
}