diff --git a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java index 0e998a6..d922851 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java @@ -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; diff --git a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java index cabe0d9..193d95d 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java +++ b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java @@ -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 diff --git a/src/main/java/dev/marcospaulo/canalhandia/I18n.java b/src/main/java/dev/marcospaulo/canalhandia/I18n.java new file mode 100644 index 0000000..2c9a45a --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/I18n.java @@ -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}. + * + *

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. + * + *

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 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 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()); + } + } +} \ No newline at end of file diff --git a/src/main/java/dev/marcospaulo/canalhandia/Lang.java b/src/main/java/dev/marcospaulo/canalhandia/Lang.java new file mode 100644 index 0000000..f3334bf --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/Lang.java @@ -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)}. + * + *

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); + } +} \ No newline at end of file diff --git a/src/main/resources/lang/messages_en.properties b/src/main/resources/lang/messages_en.properties new file mode 100644 index 0000000..03ed83b --- /dev/null +++ b/src/main/resources/lang/messages_en.properties @@ -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}. \ No newline at end of file diff --git a/src/main/resources/lang/messages_pt.properties b/src/main/resources/lang/messages_pt.properties new file mode 100644 index 0000000..48aad2e --- /dev/null +++ b/src/main/resources/lang/messages_pt.properties @@ -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}. \ No newline at end of file diff --git a/src/test/java/dev/marcospaulo/canalhandia/I18nTest.java b/src/test/java/dev/marcospaulo/canalhandia/I18nTest.java new file mode 100644 index 0000000..c7a2a67 --- /dev/null +++ b/src/test/java/dev/marcospaulo/canalhandia/I18nTest.java @@ -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 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 only(java.util.Set a, java.util.Set 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)); + } +} \ No newline at end of file