f0b854171c
Settings now says "Manual transaction" and the privacy row reads "Messages & lookups" opening a new Network privacy page that tells the truth: messages, names, price and avatars ride the Nym mixnet; the grin node connects directly. README and lander updated to match. Requests are messages, payments are final: declining a request now sends the requester a void control message (NIP-17), a requester can cancel a request they sent (cancels the local invoice and notifies the payer), and incoming requests resolve the sender's verified @username instead of a bare npub. The Requested amount on the success screen is centered. New NostrDecline/NostrCancel tasks and a goblin-action control message carry it, bound to the stored counterparty. Localization: every Goblin-screen string moved to t!() keys (370 keys) and translated into de/fr/ru/tr/zh-CN, guarded by a key/placeholder drift test. System-locale auto-detect now matches region locales like zh-CN.
126 lines
3.8 KiB
Rust
126 lines
3.8 KiB
Rust
// Copyright 2026 The Goblin Developers
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
//! Localization drift guard. en.yml is the source of truth: every `goblin.*`
|
|
//! key (and its `%{...}` interpolation placeholders) must exist, identically,
|
|
//! in every other locale, so the language picker never falls back to a raw key
|
|
//! or a string that drops a value. Fails CI the moment a translation lags.
|
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::path::Path;
|
|
|
|
/// The locales shipped alongside English.
|
|
const OTHER_LOCALES: &[&str] = &["de", "fr", "ru", "tr", "zh-CN"];
|
|
|
|
/// Flatten a YAML mapping into dotted leaf keys → string value.
|
|
fn flatten(value: &serde_yaml::Value, prefix: &str, out: &mut BTreeMap<String, String>) {
|
|
match value {
|
|
serde_yaml::Value::Mapping(map) => {
|
|
for (k, v) in map {
|
|
let key = k.as_str().unwrap_or_default();
|
|
let next = if prefix.is_empty() {
|
|
key.to_string()
|
|
} else {
|
|
format!("{prefix}.{key}")
|
|
};
|
|
flatten(v, &next, out);
|
|
}
|
|
}
|
|
other => {
|
|
let s = match other {
|
|
serde_yaml::Value::String(s) => s.clone(),
|
|
serde_yaml::Value::Bool(b) => b.to_string(),
|
|
serde_yaml::Value::Number(n) => n.to_string(),
|
|
_ => String::new(),
|
|
};
|
|
out.insert(prefix.to_string(), s);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Load a locale file flattened to `goblin.*` keys only.
|
|
fn load_goblin(locale: &str) -> BTreeMap<String, String> {
|
|
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("locales")
|
|
.join(format!("{locale}.yml"));
|
|
let text = std::fs::read_to_string(&path)
|
|
.unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
|
|
let doc: serde_yaml::Value =
|
|
serde_yaml::from_str(&text).unwrap_or_else(|e| panic!("invalid YAML in {locale}.yml: {e}"));
|
|
let mut all = BTreeMap::new();
|
|
flatten(&doc, "", &mut all);
|
|
all.into_iter()
|
|
.filter(|(k, _)| k.starts_with("goblin."))
|
|
.collect()
|
|
}
|
|
|
|
/// `%{name}` placeholders contained in a value, sorted.
|
|
fn placeholders(s: &str) -> BTreeSet<String> {
|
|
let mut out = BTreeSet::new();
|
|
let bytes = s.as_bytes();
|
|
let mut i = 0;
|
|
while i + 1 < bytes.len() {
|
|
if bytes[i] == b'%' && bytes[i + 1] == b'{' {
|
|
if let Some(end) = s[i..].find('}') {
|
|
out.insert(s[i..i + end + 1].to_string());
|
|
i += end + 1;
|
|
continue;
|
|
}
|
|
}
|
|
i += 1;
|
|
}
|
|
out
|
|
}
|
|
|
|
#[test]
|
|
fn every_locale_has_all_goblin_keys() {
|
|
let en = load_goblin("en");
|
|
assert!(
|
|
en.len() > 300,
|
|
"en.yml goblin block looks too small ({} keys) — did it load?",
|
|
en.len()
|
|
);
|
|
let en_keys: BTreeSet<&String> = en.keys().collect();
|
|
|
|
let mut problems = Vec::new();
|
|
for &loc in OTHER_LOCALES {
|
|
let other = load_goblin(loc);
|
|
let other_keys: BTreeSet<&String> = other.keys().collect();
|
|
for missing in en_keys.difference(&other_keys) {
|
|
problems.push(format!("{loc}: MISSING key {missing}"));
|
|
}
|
|
for extra in other_keys.difference(&en_keys) {
|
|
problems.push(format!("{loc}: EXTRA key {extra} (not in en.yml)"));
|
|
}
|
|
// Placeholder parity: a translation must carry the same %{...} args.
|
|
for (k, en_val) in &en {
|
|
if let Some(other_val) = other.get(k) {
|
|
if placeholders(en_val) != placeholders(other_val) {
|
|
problems.push(format!(
|
|
"{loc}: placeholder mismatch in {k} (en {:?} vs {:?})",
|
|
placeholders(en_val),
|
|
placeholders(other_val)
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
problems.is_empty(),
|
|
"localization drift detected:\n{}",
|
|
problems.join("\n")
|
|
);
|
|
}
|