mirror of
https://code.gri.mw/GUI/grim.git
synced 2026-07-19 21:28:55 +00:00
Build 78: honest transport labels, request decline/cancel, NIP-05 on requests, full localization
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.
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
// 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")
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user