use cratefield_core::{ModuleContext, Notification, Scope};
use cratefield_i18n::{BODY, Catalog, LanguageIdentifier, SUBJECT, TITLE, localize};
use serde_json::json;
use crate::message::Localizable;
pub const EVENT_MISSING_TRANSLATION: &str = "notifications.missing_translation";
pub(crate) const FALLBACK_LOCALE: &str = "en";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct MissingTranslation {
locale: String,
key: String,
attribute: &'static str,
}
impl MissingTranslation {
fn payload(&self, category: &str) -> serde_json::Value {
json!({
"category": category,
"key": self.key,
"attribute": self.attribute,
"locale": self.locale,
})
}
}
pub(crate) fn report_missing(
ctx: &ModuleContext,
scope: &Scope,
category: &str,
missing: &[MissingTranslation],
) {
for entry in missing {
tracing::warn!(
category,
key = %entry.key,
attribute = entry.attribute,
locale = %entry.locale,
"no translation for this message: the notification carries the key instead"
);
ctx.events
.emit_in(scope, EVENT_MISSING_TRANSLATION, entry.payload(category));
}
}
#[must_use]
pub(crate) fn resolve(
subscription: Option<LanguageIdentifier>,
account: Option<LanguageIdentifier>,
venture: &LanguageIdentifier,
) -> LanguageIdentifier {
subscription.or(account).unwrap_or_else(|| venture.clone())
}
pub(crate) struct Localised {
pub notification: Notification,
pub locale: LanguageIdentifier,
pub subject: Option<String>,
pub missing: Vec<MissingTranslation>,
}
pub(crate) fn render(
catalog: &dyn Catalog,
message: &Localizable,
requested: &LanguageIdentifier,
want_subject: bool,
) -> Localised {
let title = localize(catalog, requested, &message.key, TITLE, &message.args);
let body = localize(catalog, requested, &message.key, BODY, &message.args);
let mut missing = Vec::new();
for (attribute, rendered) in [(TITLE, &title), (BODY, &body)] {
if rendered.missing {
missing.push(MissingTranslation {
locale: rendered.locale.to_string(),
key: message.key.clone(),
attribute,
});
}
}
let locale = title.locale.clone();
let subject = want_subject
.then(|| localize(catalog, requested, &message.key, SUBJECT, &message.args))
.filter(|rendered| !rendered.missing)
.map(|rendered| rendered.text);
Localised {
notification: message.shape(title.text, body.text),
locale,
subject,
missing,
}
}
#[cfg(test)]
mod tests {
use super::*;
use cratefield_i18n::StaticCatalog;
fn langid(tag: &str) -> LanguageIdentifier {
tag.parse().expect("a language tag")
}
fn catalog() -> StaticCatalog {
StaticCatalog::new("en")
.with("en", "booking-confirmed", TITLE, "Booking confirmed")
.with("en", "booking-confirmed", BODY, "With { $coach }")
.with("id", "booking-confirmed", TITLE, "Pesanan dikonfirmasi")
.with("id", "booking-confirmed", BODY, "Bersama { $coach }")
}
#[test]
fn the_subscription_wins_then_the_account_then_the_venture() {
let venture = langid("en");
assert_eq!(
resolve(Some(langid("id")), Some(langid("de")), &venture),
langid("id"),
"a device's own setting is the best signal there is for a push"
);
assert_eq!(
resolve(None, Some(langid("de")), &venture),
langid("de"),
"a device that never said falls through to the account"
);
assert_eq!(resolve(None, None, &venture), venture);
}
#[test]
fn a_missing_key_reports_identifiers_and_nothing_else() {
let message = Localizable::new("room-starting")
.arg("coach", "alice@example.test")
.arg("code", "tok_live_51H8sec")
.url("https://push.example.test/wp/cAPABILITYtoken");
let rendered = render(&catalog(), &message, &langid("id"), false);
assert_eq!(rendered.missing.len(), 2, "both the title and the body");
for entry in &rendered.missing {
let payload = entry.payload("booking").to_string();
let debug = format!("{entry:?}");
for secret in [
"alice@example.test",
"tok_live_51H8sec",
"cAPABILITYtoken",
"push.example.test",
] {
assert!(!payload.contains(secret), "event payload leaked: {payload}");
assert!(!debug.contains(secret), "Debug leaked: {debug}");
}
assert!(payload.contains("room-starting"), "{payload}");
assert!(payload.contains("\"locale\":\"en\""), "{payload}");
}
assert_eq!(
rendered.notification.url.as_deref(),
Some("https://push.example.test/wp/cAPABILITYtoken")
);
assert_eq!(rendered.notification.title, "room-starting.title");
}
#[test]
fn a_rendered_message_reports_nothing() {
let rendered = render(
&catalog(),
&Localizable::new("booking-confirmed").arg("coach", "Sari"),
&langid("id-ID"),
false,
);
assert!(rendered.missing.is_empty());
assert_eq!(rendered.notification.title, "Pesanan dikonfirmasi");
assert_eq!(rendered.notification.body, "Bersama Sari");
assert_eq!(rendered.locale, langid("id"));
}
#[test]
fn an_absent_subject_is_not_a_missing_translation() {
let rendered = render(
&catalog(),
&Localizable::new("booking-confirmed").arg("coach", "Sari"),
&langid("id"),
true,
);
assert!(rendered.subject.is_none());
assert!(rendered.missing.is_empty(), "{:?}", rendered.missing);
}
#[test]
fn the_locale_reported_is_the_one_it_was_rendered_in() {
let rendered = render(
&catalog(),
&Localizable::new("booking-confirmed").arg("coach", "Sari"),
&langid("de"),
false,
);
assert_eq!(rendered.notification.title, "Booking confirmed");
assert_eq!(rendered.locale, langid("en"));
}
}