i18n: per-player EN/PT via Adventure GlobalTranslator #1
@@ -5,6 +5,7 @@ import net.kyori.adventure.text.event.ClickEvent;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.format.TextDecoration;
|
||||
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
|
||||
import net.kyori.adventure.translation.TranslationStore;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.GameRule;
|
||||
import org.bukkit.Location;
|
||||
@@ -99,6 +100,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
||||
private Poll poll;
|
||||
private Titles titles;
|
||||
private DeathGift deathGift;
|
||||
private TranslationStore<?> i18n;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
@@ -106,6 +108,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
||||
// Ship the editable catalogues; false = never overwrite the operator's copy.
|
||||
saveResource("conquistas-catalogo.yml", false);
|
||||
saveResource("marcos-catalogo.yml", false);
|
||||
i18n = I18n.install(i18n, getLogger());
|
||||
settings = new Settings(this);
|
||||
notes = new Notes(new java.io.File(getDataFolder(), "notas.yml"));
|
||||
mail = new Mail(new java.io.File(getDataFolder(), "recados.yml"));
|
||||
@@ -252,6 +255,11 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
||||
deathGift.reload();
|
||||
}
|
||||
|
||||
/** Reloads the i18n bundles from the jar and re-registers the translator. */
|
||||
void reloadI18n() {
|
||||
i18n = I18n.install(i18n, getLogger());
|
||||
}
|
||||
|
||||
/** The weekly ranking baseline. Never null. */
|
||||
WeeklyStats weeklyStats() {
|
||||
return weeklyStats;
|
||||
@@ -775,11 +783,12 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
||||
button = button.clickEvent(ClickEvent.runCommand(
|
||||
"/canalhandia reagir " + mourning.id() + " f"));
|
||||
}
|
||||
Component prompt = Lang.tr(bedrock
|
||||
? "canalhandia.morte.luto.digitar"
|
||||
: "canalhandia.morte.luto.prestar",
|
||||
Component.text(name));
|
||||
return Component.text(" ").append(button)
|
||||
.append(Component.text(bedrock
|
||||
? "digite /f para prestar luto por " + name
|
||||
: "prestar luto por " + name,
|
||||
NamedTextColor.GRAY));
|
||||
.append(prompt.color(NamedTextColor.GRAY));
|
||||
}), 2L);
|
||||
|
||||
getServer().getScheduler().runTaskLater(this, () -> {
|
||||
@@ -789,9 +798,10 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
||||
int shown = Math.min(who.size(), settings.summaryNames());
|
||||
String text = String.join(", ", who.subList(0, shown))
|
||||
+ (who.size() > shown ? " +" + (who.size() - shown) : "");
|
||||
Component summary = Lang.tr("canalhandia.morte.luto.resumo",
|
||||
Component.text(text), Component.text(name));
|
||||
Bukkit.broadcast(Component.text(" ")
|
||||
.append(Component.text(text + " prestaram luto por " + name + ".",
|
||||
NamedTextColor.GRAY)));
|
||||
.append(summary.color(NamedTextColor.GRAY)));
|
||||
}
|
||||
if (liveReactions == mourning) {
|
||||
liveReactions = null;
|
||||
|
||||
@@ -107,6 +107,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
||||
if (admin(sender)) {
|
||||
plugin.reloadConfig();
|
||||
plugin.reloadCatalogo();
|
||||
plugin.reloadI18n();
|
||||
plugin.rescheduleTimer();
|
||||
plugin.rescheduleMilestones();
|
||||
Msg.ok(sender, "Recarregado: " + Achievement.values().length
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package dev.marcospaulo.canalhandia;
|
||||
|
||||
import net.kyori.adventure.key.Key;
|
||||
import net.kyori.adventure.translation.GlobalTranslator;
|
||||
import net.kyori.adventure.translation.TranslationStore;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Locale;
|
||||
import java.util.PropertyResourceBundle;
|
||||
import java.util.ResourceBundle;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* i18n: registers an Adventure {@link TranslationStore} (key
|
||||
* {@code canalhandia}) into the {@link GlobalTranslator}, populated from the
|
||||
* bundled {@code lang/messages_pt.properties} (source of truth) and
|
||||
* {@code lang/messages_en.properties}.
|
||||
*
|
||||
* <p>Per-viewer rendering is automatic: Paper runs every component sent to an
|
||||
* audience through the {@code GlobalTranslator} in the viewer's own locale, so
|
||||
* one broadcast shows each player their own language. No per-player lookup.
|
||||
*
|
||||
* <p>Language resolution: registry default is {@code en} (the base bundle).
|
||||
* {@code pt} and {@code pt_BR} fall back to the PT bundle; the store does the
|
||||
* locale fallback, unknown locales hit the default. That's the whole rule.
|
||||
*/
|
||||
final class I18n {
|
||||
|
||||
static final Key SOURCE = Key.key("canalhandia");
|
||||
static final Locale DEFAULT = Locale.ENGLISH;
|
||||
/** Escape single quotes so MessageFormat does not swallow apostrophes. */
|
||||
private static final boolean ESCAPE_QUOTES = true;
|
||||
private static final String PT = "lang/messages_pt.properties";
|
||||
private static final String EN = "lang/messages_en.properties";
|
||||
|
||||
private I18n() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads both bundles and registers the store with the global translator.
|
||||
* Removes any previously registered store first, so {@code /canalhandia
|
||||
* reload} does not stack sources.
|
||||
*/
|
||||
static TranslationStore<?> install(TranslationStore<?> previous, Logger logger) {
|
||||
if (previous != null) {
|
||||
GlobalTranslator.translator().removeSource(previous);
|
||||
}
|
||||
TranslationStore.StringBased<java.text.MessageFormat> store = TranslationStore.messageFormat(SOURCE);
|
||||
store.defaultLocale(DEFAULT);
|
||||
load(EN, store, Locale.ENGLISH, logger);
|
||||
load(PT, store, Locale.of("pt"), logger);
|
||||
GlobalTranslator.translator().addSource(store);
|
||||
return store;
|
||||
}
|
||||
|
||||
private static void load(String resource,
|
||||
TranslationStore.StringBased<java.text.MessageFormat> store,
|
||||
Locale locale, Logger logger) {
|
||||
try (InputStream in = I18n.class.getClassLoader().getResourceAsStream(resource)) {
|
||||
if (in == null) {
|
||||
logger.warning("i18n: recurso ausente: " + resource);
|
||||
return;
|
||||
}
|
||||
// PropertyResourceBundle(Reader) honours the reader's encoding; the
|
||||
// InputStream constructor is fixed to ISO-8859-1 and would mojibake
|
||||
// the PT accents.
|
||||
ResourceBundle bundle = new PropertyResourceBundle(
|
||||
new InputStreamReader(in, StandardCharsets.UTF_8));
|
||||
store.registerAll(locale, bundle, ESCAPE_QUOTES);
|
||||
} catch (IOException e) {
|
||||
logger.warning("i18n: falha ao carregar " + resource + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package dev.marcospaulo.canalhandia;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
|
||||
/**
|
||||
* Terse facade over {@link Component#translatable} so call sites read as i18n
|
||||
* rather than as an Adventure call: {@code Lang.tr("canalhandia.morte.luto.prestar", name)}.
|
||||
*
|
||||
* <p>The key is rendered per-viewer by the {@link GlobalTranslator} source that
|
||||
* {@link I18n} registers; args are inserted by {@code MessageFormat} ({@code {0}},
|
||||
* {@code {1}}, …).
|
||||
*/
|
||||
final class Lang {
|
||||
|
||||
private Lang() {
|
||||
}
|
||||
|
||||
static Component tr(String key, Component... args) {
|
||||
return Component.translatable(key, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Canalhandia — English (translated from messages_pt.properties).
|
||||
# Never alter {0}/{1} placeholders or MiniMessage <...> tags.
|
||||
|
||||
# Mourning (Canalhandia.onDeath) — F button under each death message.
|
||||
canalhandia.morte.luto.prestar=pay respects for {0}
|
||||
canalhandia.morte.luto.digitar=type /f to pay respects for {0}
|
||||
canalhandia.morte.luto.resumo={0} paid respects for {1}.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Canalhandia — portugues (fonte de verdade). Padroes MessageFormat: {0}, {1}, ...
|
||||
# NUNCA altere os placeholders {0}/{1} nem as tags MiniMessage <...>.
|
||||
|
||||
# Luto (Canalhandia.onDeath) — botao F sob cada mensagem de morte.
|
||||
canalhandia.morte.luto.prestar=prestar luto por {0}
|
||||
canalhandia.morte.luto.digitar=digite /f para prestar luto por {0}
|
||||
canalhandia.morte.luto.resumo={0} prestaram luto por {1}.
|
||||
@@ -0,0 +1,109 @@
|
||||
package dev.marcospaulo.canalhandia;
|
||||
|
||||
import net.kyori.adventure.key.Key;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
|
||||
import net.kyori.adventure.translation.GlobalTranslator;
|
||||
import net.kyori.adventure.translation.TranslationStore;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.text.MessageFormat;
|
||||
import java.util.Locale;
|
||||
import java.util.PropertyResourceBundle;
|
||||
import java.util.ResourceBundle;
|
||||
import java.util.TreeSet;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
/**
|
||||
* The two contracts the i18n bundles must hold: identical key sets across PT
|
||||
* and EN, and one translatable rendering differently per locale. The store's
|
||||
* own {@code translate(key, locale)} is an exact-locale lookup (no fallback);
|
||||
* the fallback chain runs in {@link GlobalTranslator#render}, which is what
|
||||
* the locale-resolution tests exercise.
|
||||
*/
|
||||
class I18nTest {
|
||||
|
||||
private TranslationStore.StringBased<MessageFormat> store;
|
||||
|
||||
@BeforeEach
|
||||
void registerStore() throws IOException {
|
||||
store = TranslationStore.messageFormat(Key.key("canalhandia"));
|
||||
store.defaultLocale(Locale.ENGLISH);
|
||||
store.registerAll(Locale.ENGLISH, bundle("lang/messages_en.properties"), true);
|
||||
store.registerAll(Locale.of("pt"), bundle("lang/messages_pt.properties"), true);
|
||||
GlobalTranslator.translator().addSource(store);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void unregisterStore() {
|
||||
GlobalTranslator.translator().removeSource(store);
|
||||
}
|
||||
|
||||
private static ResourceBundle bundle(String resource) throws IOException {
|
||||
try (var in = I18nTest.class.getClassLoader().getResourceAsStream(resource)) {
|
||||
assertNotNull(in, "bundle ausente no classpath: " + resource);
|
||||
return new PropertyResourceBundle(new InputStreamReader(in, StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
private static String plain(Component c) {
|
||||
return PlainTextComponentSerializer.plainText().serialize(c);
|
||||
}
|
||||
|
||||
/** Every key in one bundle must exist in the other, or a locale shows the raw key. */
|
||||
@Test
|
||||
void bothBundlesHaveTheSameKeys() throws IOException {
|
||||
var pt = new TreeSet<>(bundle("lang/messages_pt.properties").keySet());
|
||||
var en = new TreeSet<>(bundle("lang/messages_en.properties").keySet());
|
||||
assertEquals(pt, en,
|
||||
"chaves divergentes — so no PT: " + only(pt, en) + ", so no EN: " + only(en, pt));
|
||||
}
|
||||
|
||||
private static java.util.Set<String> only(java.util.Set<String> a, java.util.Set<String> b) {
|
||||
var diff = new TreeSet<>(a);
|
||||
diff.removeAll(b);
|
||||
return diff;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pt client and an en client see different text from one component.
|
||||
* Rendering runs through {@link GlobalTranslator}, the same path Paper uses
|
||||
* on send — the store itself only resolves a key to a {@link MessageFormat}.
|
||||
*/
|
||||
@Test
|
||||
void rendersDifferentlyPerLocale() {
|
||||
Component translatable = Component.translatable("canalhandia.morte.luto.prestar",
|
||||
Component.text("Steve"));
|
||||
|
||||
Component pt = GlobalTranslator.render(translatable, Locale.of("pt"));
|
||||
Component en = GlobalTranslator.render(translatable, Locale.ENGLISH);
|
||||
assertEquals("prestar luto por Steve", plain(pt));
|
||||
assertEquals("pay respects for Steve", plain(en));
|
||||
}
|
||||
|
||||
/** pt_BR falls back to pt via the GlobalTranslator chain. */
|
||||
@Test
|
||||
void ptBrFallsBackToPt() {
|
||||
Component rendered = GlobalTranslator.render(
|
||||
Component.translatable("canalhandia.morte.luto.resumo",
|
||||
Component.text("Ana, Bob"), Component.text("Steve")),
|
||||
Locale.forLanguageTag("pt-BR"));
|
||||
assertEquals("Ana, Bob prestaram luto por Steve.", plain(rendered));
|
||||
}
|
||||
|
||||
/** An unknown locale renders in the default (en), not as the raw key. */
|
||||
@Test
|
||||
void unknownLocaleFallsBackToDefault() {
|
||||
Component rendered = GlobalTranslator.render(
|
||||
Component.translatable("canalhandia.morte.luto.prestar", Component.text("Steve")),
|
||||
Locale.forLanguageTag("ja"));
|
||||
assertEquals("pay respects for Steve", plain(rendered));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user