#[cfg(feature = "admin")]
use crate::i18n::db::{all_pool, delete_key_pool, delete_override, upsert_pool, Translation};
#[cfg(feature = "admin")]
use crate::sql::Pool;
#[derive(Debug, Clone, PartialEq)]
pub struct KeyRow {
pub key: String,
pub values: Vec<Option<String>>,
}
#[must_use]
pub fn pivot(rows: &[(String, String, String)]) -> (Vec<String>, Vec<KeyRow>) {
use std::collections::{BTreeMap, BTreeSet};
let mut locales: BTreeSet<String> = BTreeSet::new();
let mut by_key: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
for (locale, key, value) in rows {
locales.insert(locale.clone());
by_key
.entry(key.clone())
.or_default()
.insert(locale.clone(), value.clone());
}
let locales: Vec<String> = locales.into_iter().collect();
let key_rows = by_key
.into_iter()
.map(|(key, per_locale)| KeyRow {
values: locales
.iter()
.map(|loc| per_locale.get(loc).cloned())
.collect(),
key,
})
.collect();
(locales, key_rows)
}
#[must_use]
pub fn coverage_gaps(locales: &[String], rows: &[KeyRow]) -> Vec<(String, usize)> {
locales
.iter()
.enumerate()
.map(|(i, loc)| {
let missing = rows
.iter()
.filter(|r| r.values.get(i).map_or(true, |v| v.is_none()))
.count();
(loc.clone(), missing)
})
.collect()
}
#[must_use]
pub fn render_editor(
locales: &[String],
rows: &[KeyRow],
action: &str,
escape: impl Fn(&str) -> String,
) -> String {
let mut h = String::with_capacity(1024 + rows.len() * 128);
h.push_str("<h1>Translations</h1>");
let gaps = coverage_gaps(locales, rows);
h.push_str("<p class=\"coverage\">");
for (loc, missing) in &gaps {
h.push_str(&format!(
"<span>{}: {}</span> ",
escape(loc),
if *missing == 0 {
"complete".to_owned()
} else {
format!("{missing} missing")
}
));
}
h.push_str("</p>");
h.push_str(r#"<p class="export"><a href="export.json">Export JSON</a></p>"#);
h.push_str(&format!(
"<form method=\"post\" action=\"{}\"><table><thead><tr><th>key</th>",
escape(action)
));
for loc in locales {
h.push_str(&format!("<th>{}</th>", escape(loc)));
}
h.push_str("<th>delete</th></tr></thead><tbody>");
for row in rows {
h.push_str(&format!("<tr><td>{}</td>", escape(&row.key)));
for (i, loc) in locales.iter().enumerate() {
let val = row.values.get(i).and_then(Option::as_deref).unwrap_or("");
h.push_str(&format!(
"<td><input name=\"tr:{l}:{k}\" value=\"{v}\">\
<input type=\"hidden\" name=\"orig:{l}:{k}\" value=\"{v}\"></td>",
l = escape(loc),
k = escape(&row.key),
v = escape(val)
));
}
h.push_str(&format!(
"<td><input type=\"checkbox\" name=\"delkey:{}\"></td></tr>",
escape(&row.key)
));
}
h.push_str("</tbody>");
h.push_str("<tfoot><tr><td><input name=\"newkey\" placeholder=\"new key\"></td>");
for loc in locales {
h.push_str(&format!(
"<td><input name=\"newval:{}\" placeholder=\"{}\"></td>",
escape(loc),
escape(loc)
));
}
h.push_str("<td></td></tr></tfoot>");
h.push_str("</table><button type=\"submit\">Save</button></form>");
h
}
#[must_use]
pub fn parse_edits(form: &[(String, String)]) -> Vec<(String, String, String)> {
form.iter()
.filter_map(|(name, value)| {
let rest = name.strip_prefix("tr:")?;
let (locale, key) = rest.split_once(':')?;
if locale.is_empty() || key.is_empty() {
return None;
}
Some((locale.to_owned(), key.to_owned(), value.clone()))
})
.collect()
}
#[must_use]
pub fn parse_new_key(form: &[(String, String)]) -> Vec<(String, String, String)> {
let key = form
.iter()
.find(|(n, _)| n == "newkey")
.map_or("", |(_, v)| v.trim());
if key.is_empty() {
return Vec::new();
}
form.iter()
.filter_map(|(name, value)| {
let locale = name.strip_prefix("newval:")?;
if locale.is_empty() || value.is_empty() {
return None;
}
Some((locale.to_owned(), key.to_owned(), value.clone()))
})
.collect()
}
#[must_use]
pub fn parse_deletes(form: &[(String, String)]) -> Vec<String> {
form.iter()
.filter_map(|(name, _)| name.strip_prefix("delkey:").map(str::to_owned))
.filter(|k| !k.is_empty())
.collect()
}
#[must_use]
pub fn export_json(rows: &[(String, String, String)]) -> String {
use std::collections::BTreeMap;
let mut by_locale: BTreeMap<&str, BTreeMap<&str, &str>> = BTreeMap::new();
for (locale, key, value) in rows {
by_locale
.entry(locale.as_str())
.or_default()
.insert(key.as_str(), value.as_str());
}
serde_json::to_string_pretty(&by_locale).unwrap_or_else(|_| "{}".to_owned())
}
#[must_use]
pub fn changed_edits<'a>(
current: &[(String, String, String)],
edits: &'a [(String, String, String)],
) -> Vec<&'a (String, String, String)> {
let current: std::collections::HashMap<(&str, &str), &str> = current
.iter()
.map(|(l, k, v)| ((l.as_str(), k.as_str()), v.as_str()))
.collect();
edits
.iter()
.filter(|(l, k, v)| {
!v.is_empty() && current.get(&(l.as_str(), k.as_str())) != Some(&v.as_str())
})
.collect()
}
#[must_use]
pub fn parse_shown(form: &[(String, String)]) -> Vec<(String, String, String)> {
form.iter()
.filter_map(|(name, value)| {
let (locale, key) = name.strip_prefix("orig:")?.split_once(':')?;
(!locale.is_empty() && !key.is_empty())
.then(|| (locale.to_owned(), key.to_owned(), value.clone()))
})
.collect()
}
#[must_use]
pub fn cleared_cells<'a>(
shown: &[(String, String, String)],
edits: &'a [(String, String, String)],
) -> Vec<(&'a str, &'a str)> {
let shown: std::collections::HashMap<(&str, &str), &str> = shown
.iter()
.map(|(l, k, v)| ((l.as_str(), k.as_str()), v.as_str()))
.collect();
edits
.iter()
.filter(|(l, k, v)| {
v.is_empty()
&& shown
.get(&(l.as_str(), k.as_str()))
.is_some_and(|s| !s.is_empty())
})
.map(|(l, k, _)| (l.as_str(), k.as_str()))
.collect()
}
#[cfg(feature = "admin")]
pub async fn apply_edits(
pool: &Pool,
edits: &[(String, String, String)],
updated_by: &str,
) -> Result<usize, crate::sql::ExecError> {
let current = editor_rows(pool).await?;
let changed = changed_edits(¤t, edits);
for (locale, key, value) in &changed {
upsert_pool(pool, locale, key, value, updated_by).await?;
}
Ok(changed.len())
}
#[cfg(feature = "admin")]
pub async fn apply_clears(
pool: &Pool,
cells: &[(&str, &str)],
) -> Result<u64, crate::sql::ExecError> {
let mut removed = 0;
for (locale, key) in cells {
removed += delete_override(pool, locale, key).await?;
}
Ok(removed)
}
#[cfg(feature = "admin")]
pub async fn apply_form(
pool: &Pool,
form: &[(String, String)],
updated_by: &str,
) -> Result<(), crate::sql::ExecError> {
let mut edits = parse_edits(form);
let shown = parse_shown(form);
apply_clears(pool, &cleared_cells(&shown, &edits)).await?;
edits.extend(parse_new_key(form));
apply_edits(pool, &edits, updated_by).await?;
apply_deletes(pool, &parse_deletes(form)).await?;
Ok(())
}
#[cfg(feature = "admin")]
pub async fn apply_deletes(pool: &Pool, keys: &[String]) -> Result<u64, crate::sql::ExecError> {
let mut removed = 0;
for key in keys {
removed += delete_key_pool(pool, key).await?;
}
Ok(removed)
}
#[cfg(feature = "admin")]
pub async fn editor_rows(
pool: &Pool,
) -> Result<Vec<(String, String, String)>, crate::sql::ExecError> {
let rows: Vec<Translation> = all_pool(pool).await?;
Ok(rows
.into_iter()
.map(|t| (t.locale, t.key, t.value))
.collect())
}
#[cfg(feature = "admin")]
use crate::text::html_escape;
#[cfg(feature = "admin")]
async fn editor_get(
pool: Pool,
_req: axum::http::Request<axum::body::Body>,
) -> axum::response::Response {
use axum::response::{Html, IntoResponse};
match editor_rows(&pool).await {
Ok(triples) => {
let (locales, rows) = pivot(&triples);
Html(render_editor(&locales, &rows, "editor", html_escape)).into_response()
}
Err(e) => (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
format!("translations editor: {e}"),
)
.into_response(),
}
}
#[cfg(feature = "admin")]
async fn editor_post(
pool: Pool,
req: axum::http::Request<axum::body::Body>,
) -> axum::response::Response {
use axum::http::StatusCode;
use axum::response::{IntoResponse, Redirect};
let updated_by = match crate::admin::session::from_extensions(req.extensions()) {
Some(session) if session.is_superuser => session.actor().as_token(),
Some(_) => {
return (
StatusCode::FORBIDDEN,
"translations editor: editing requires a superuser",
)
.into_response();
}
None => "admin".to_owned(),
};
let body = match axum::body::to_bytes(req.into_body(), 1 << 20).await {
Ok(b) => b,
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
};
let form: Vec<(String, String)> = serde_urlencoded::from_bytes(&body).unwrap_or_default();
if let Err(e) = apply_form(&pool, &form, &updated_by).await {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("save translations: {e}"),
)
.into_response();
}
Redirect::to("editor").into_response()
}
#[cfg(feature = "admin")]
async fn export_get(
pool: Pool,
_req: axum::http::Request<axum::body::Body>,
) -> axum::response::Response {
use axum::http::{header, StatusCode};
use axum::response::IntoResponse;
match editor_rows(&pool).await {
Ok(triples) => (
[(header::CONTENT_TYPE, "application/json")],
export_json(&triples),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("export translations: {e}"),
)
.into_response(),
}
}
#[cfg(feature = "admin")]
crate::register_admin_view!(
"rustango_translations",
"editor",
axum::http::Method::GET,
"Edit translations",
editor_get,
);
#[cfg(feature = "admin")]
crate::register_admin_view!(
"rustango_translations",
"editor",
axum::http::Method::POST,
"",
editor_post,
);
#[cfg(feature = "admin")]
crate::register_admin_view!(
"rustango_translations",
"export.json",
axum::http::Method::GET,
"Export translations (JSON)",
export_get,
);
#[cfg(test)]
mod tests {
use super::*;
#[cfg(all(feature = "admin", feature = "sqlite"))]
#[tokio::test]
async fn a_user_named_like_an_operator_is_recorded_as_a_user() {
use crate::admin::session::AdminSession;
use crate::sql::FetcherPool as _;
let pool: Pool = crate::sql::sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap()
.into();
crate::i18n::db::ensure_table_pool(&pool).await.unwrap();
let operator = AdminSession::impersonation(7).actor().as_token();
let mut req = axum::http::Request::post("/editor")
.body(axum::body::Body::from("tr:en:greeting=Hi"))
.unwrap();
req.extensions_mut()
.insert(AdminSession::new(5, operator.clone(), true));
editor_post(pool.clone(), req).await;
let rows = crate::i18n::db::Translation::objects()
.fetch(&pool)
.await
.unwrap();
assert_eq!(rows[0].updated_by, "user:5");
assert_ne!(rows[0].updated_by, operator);
}
#[cfg(all(feature = "admin", feature = "sqlite"))]
#[tokio::test]
async fn a_non_superuser_cannot_write_translations() {
use crate::admin::session::AdminSession;
let pool: Pool = crate::sql::sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap()
.into();
crate::i18n::db::ensure_table_pool(&pool).await.unwrap();
let mut req = axum::http::Request::post("/editor")
.body(axum::body::Body::from("tr:en:greeting=pwned"))
.unwrap();
req.extensions_mut()
.insert(AdminSession::new(5, "u", false));
let res = editor_post(pool.clone(), req).await;
assert_eq!(res.status(), axum::http::StatusCode::FORBIDDEN);
assert!(crate::i18n::db::all_pool(&pool).await.unwrap().is_empty());
}
fn rows() -> Vec<(String, String, String)> {
vec![
("en".into(), "greeting".into(), "Hello".into()),
("fr".into(), "greeting".into(), "Bonjour".into()),
("en".into(), "bye".into(), "Bye".into()),
]
}
#[test]
fn pivot_groups_by_key_with_aligned_locale_columns() {
let (locales, key_rows) = pivot(&rows());
assert_eq!(locales, vec!["en", "fr"]);
let greeting = key_rows.iter().find(|r| r.key == "greeting").unwrap();
assert_eq!(
greeting.values,
vec![Some("Hello".into()), Some("Bonjour".into())]
);
let bye = key_rows.iter().find(|r| r.key == "bye").unwrap();
assert_eq!(bye.values, vec![Some("Bye".into()), None]); }
#[test]
fn coverage_counts_missing_per_locale() {
let (locales, key_rows) = pivot(&rows());
let gaps = coverage_gaps(&locales, &key_rows);
assert_eq!(gaps, vec![("en".into(), 0), ("fr".into(), 1)]);
}
#[test]
fn render_has_inputs_named_for_locale_and_key() {
let (locales, key_rows) = pivot(&rows());
let html = render_editor(&locales, &key_rows, "editor", |s| s.to_owned());
assert!(
html.contains(r#"name="tr:fr:greeting" value="Bonjour""#),
"{html}"
);
assert!(
html.contains(r#"name="tr:fr:bye" value="""#),
"missing-value input: {html}"
);
assert!(html.contains("fr: 1 missing"));
}
#[test]
fn parse_edits_recovers_locale_and_key() {
let form = vec![
("tr:fr:greeting".into(), "Salut".into()),
("tr:en:bye".into(), "Goodbye".into()),
("csrf".into(), "token".into()), ("tr:bad".into(), "x".into()), ];
let edits = parse_edits(&form);
assert_eq!(
edits,
vec![
("fr".into(), "greeting".into(), "Salut".into()),
("en".into(), "bye".into(), "Goodbye".into()),
]
);
}
#[test]
fn changed_edits_skip_empty_and_unchanged_cells() {
let t = |l: &str, k: &str, v: &str| (l.to_owned(), k.to_owned(), v.to_owned());
let current = vec![t("en", "greet", "Hi"), t("fr", "bye", "Salut")];
let edits = vec![
t("en", "greet", "Hi"), t("fr", "greet", ""), t("fr", "bye", "Au revoir"), t("de", "greet", "Hallo"), ];
assert_eq!(changed_edits(¤t, &edits), vec![&edits[2], &edits[3]]);
}
#[test]
fn parse_edits_handles_keys_with_dots_and_underscores() {
let form = vec![("tr:en:nav.home_link".into(), "Home".into())];
assert_eq!(
parse_edits(&form),
vec![("en".into(), "nav.home_link".into(), "Home".into())]
);
}
#[test]
fn render_includes_delete_export_and_add_key_controls() {
let (locales, key_rows) = pivot(&rows());
let html = render_editor(&locales, &key_rows, "editor", |s| s.to_owned());
assert!(
html.contains(r#"href="export.json""#),
"export link: {html}"
);
assert!(
html.contains(r#"name="delkey:greeting""#),
"per-row delete: {html}"
);
assert!(html.contains(r#"name="newkey""#), "add-key field: {html}");
assert!(
html.contains(r#"name="newval:fr""#),
"add-key per-locale: {html}"
);
}
#[test]
fn parse_new_key_builds_edits_for_nonempty_values_only() {
let form = vec![
("newkey".into(), " farewell ".into()), ("newval:en".into(), "Goodbye".into()),
("newval:fr".into(), String::new()), ("tr:en:greeting".into(), "Hi".into()), ];
assert_eq!(
parse_new_key(&form),
vec![("en".into(), "farewell".into(), "Goodbye".into())]
);
}
#[test]
fn parse_new_key_empty_key_yields_nothing() {
let form = vec![
("newkey".into(), " ".into()),
("newval:en".into(), "x".into()),
];
assert!(parse_new_key(&form).is_empty());
}
#[test]
fn parse_deletes_collects_checked_keys() {
let form = vec![
("delkey:greeting".into(), "on".into()),
("delkey:nav.home".into(), "on".into()),
("tr:en:bye".into(), "Bye".into()), ];
assert_eq!(parse_deletes(&form), vec!["greeting", "nav.home"]);
}
#[test]
fn export_json_is_locale_keyed_and_sorted() {
let json = export_json(&rows());
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["en"]["greeting"], "Hello");
assert_eq!(v["fr"]["greeting"], "Bonjour");
assert_eq!(v["en"]["bye"], "Bye");
assert!(v["fr"].get("bye").is_none(), "fr.bye absent (coverage gap)");
}
}