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.NamedTextColor;
|
||||||
import net.kyori.adventure.text.format.TextDecoration;
|
import net.kyori.adventure.text.format.TextDecoration;
|
||||||
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
|
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
|
||||||
|
import net.kyori.adventure.translation.TranslationStore;
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
import org.bukkit.GameRule;
|
import org.bukkit.GameRule;
|
||||||
import org.bukkit.Location;
|
import org.bukkit.Location;
|
||||||
@@ -99,6 +100,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
private Poll poll;
|
private Poll poll;
|
||||||
private Titles titles;
|
private Titles titles;
|
||||||
private DeathGift deathGift;
|
private DeathGift deathGift;
|
||||||
|
private TranslationStore<?> i18n;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEnable() {
|
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.
|
// Ship the editable catalogues; false = never overwrite the operator's copy.
|
||||||
saveResource("conquistas-catalogo.yml", false);
|
saveResource("conquistas-catalogo.yml", false);
|
||||||
saveResource("marcos-catalogo.yml", false);
|
saveResource("marcos-catalogo.yml", false);
|
||||||
|
i18n = I18n.install(i18n, getLogger());
|
||||||
settings = new Settings(this);
|
settings = new Settings(this);
|
||||||
notes = new Notes(new java.io.File(getDataFolder(), "notas.yml"));
|
notes = new Notes(new java.io.File(getDataFolder(), "notas.yml"));
|
||||||
mail = new Mail(new java.io.File(getDataFolder(), "recados.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();
|
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. */
|
/** The weekly ranking baseline. Never null. */
|
||||||
WeeklyStats weeklyStats() {
|
WeeklyStats weeklyStats() {
|
||||||
return weeklyStats;
|
return weeklyStats;
|
||||||
@@ -775,11 +783,12 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
button = button.clickEvent(ClickEvent.runCommand(
|
button = button.clickEvent(ClickEvent.runCommand(
|
||||||
"/canalhandia reagir " + mourning.id() + " f"));
|
"/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)
|
return Component.text(" ").append(button)
|
||||||
.append(Component.text(bedrock
|
.append(prompt.color(NamedTextColor.GRAY));
|
||||||
? "digite /f para prestar luto por " + name
|
|
||||||
: "prestar luto por " + name,
|
|
||||||
NamedTextColor.GRAY));
|
|
||||||
}), 2L);
|
}), 2L);
|
||||||
|
|
||||||
getServer().getScheduler().runTaskLater(this, () -> {
|
getServer().getScheduler().runTaskLater(this, () -> {
|
||||||
@@ -789,9 +798,10 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
int shown = Math.min(who.size(), settings.summaryNames());
|
int shown = Math.min(who.size(), settings.summaryNames());
|
||||||
String text = String.join(", ", who.subList(0, shown))
|
String text = String.join(", ", who.subList(0, shown))
|
||||||
+ (who.size() > shown ? " +" + (who.size() - 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(" ")
|
Bukkit.broadcast(Component.text(" ")
|
||||||
.append(Component.text(text + " prestaram luto por " + name + ".",
|
.append(summary.color(NamedTextColor.GRAY)));
|
||||||
NamedTextColor.GRAY)));
|
|
||||||
}
|
}
|
||||||
if (liveReactions == mourning) {
|
if (liveReactions == mourning) {
|
||||||
liveReactions = null;
|
liveReactions = null;
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
if (admin(sender)) {
|
if (admin(sender)) {
|
||||||
plugin.reloadConfig();
|
plugin.reloadConfig();
|
||||||
plugin.reloadCatalogo();
|
plugin.reloadCatalogo();
|
||||||
|
plugin.reloadI18n();
|
||||||
plugin.rescheduleTimer();
|
plugin.rescheduleTimer();
|
||||||
plugin.rescheduleMilestones();
|
plugin.rescheduleMilestones();
|
||||||
Msg.ok(sender, "Recarregado: " + Achievement.values().length
|
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