From 3162defde497a874002cccabe61bd0e9c0bac90f Mon Sep 17 00:00:00 2001 From: cedric Date: Sat, 6 Sep 2025 03:44:52 +0200 Subject: [PATCH] Add internationalization --- .../java/nl/openminetopia/OpenMinetopia.java | 11 +- .../nl/openminetopia/utils/language/I18n.java | 137 ++++++++++++++++++ 2 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 src/main/java/nl/openminetopia/utils/language/I18n.java diff --git a/src/main/java/nl/openminetopia/OpenMinetopia.java b/src/main/java/nl/openminetopia/OpenMinetopia.java index 7b8fa88f..036b3ed9 100644 --- a/src/main/java/nl/openminetopia/OpenMinetopia.java +++ b/src/main/java/nl/openminetopia/OpenMinetopia.java @@ -12,8 +12,8 @@ import nl.openminetopia.configuration.DefaultConfiguration; import nl.openminetopia.configuration.MessageConfiguration; import nl.openminetopia.registry.CommandComponentRegistry; -import nl.openminetopia.utils.ChatUtils; import nl.openminetopia.utils.input.ChatInputHandler; +import nl.openminetopia.utils.language.I18n; import nl.openminetopia.utils.placeholderapi.OpenMinetopiaExpansion; import nl.openminetopia.utils.wrappers.listeners.CitzensNpcClickListener; import nl.openminetopia.utils.wrappers.listeners.FancyNpcClickListener; @@ -22,9 +22,10 @@ import org.bukkit.Bukkit; import org.bukkit.ChatColor; import org.bukkit.plugin.java.JavaPlugin; -import org.bukkit.plugin.messaging.PluginMessageListener; import org.jetbrains.annotations.NotNull; +import java.util.Locale; + @Getter @Setter public final class OpenMinetopia extends JavaPlugin { @@ -40,6 +41,9 @@ public final class OpenMinetopia extends JavaPlugin { @Getter @Setter private static DefaultConfiguration defaultConfiguration; + @Getter + private I18n i18n; + @Getter @Setter private static MessageConfiguration messageConfiguration; @@ -65,6 +69,9 @@ public void onEnable() { defaultConfiguration = new DefaultConfiguration(getDataFolder()); defaultConfiguration.saveConfiguration(); + Locale defaultLocale = Locale.forLanguageTag(getConfig().getString("default-locale", "en-US")); + i18n = new I18n(this, defaultLocale); + messageConfiguration = new MessageConfiguration(getDataFolder()); messageConfiguration.saveConfiguration(); diff --git a/src/main/java/nl/openminetopia/utils/language/I18n.java b/src/main/java/nl/openminetopia/utils/language/I18n.java new file mode 100644 index 00000000..2cfed138 --- /dev/null +++ b/src/main/java/nl/openminetopia/utils/language/I18n.java @@ -0,0 +1,137 @@ +package nl.openminetopia.utils.language; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import org.bukkit.command.CommandSender; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; + +import java.io.File; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +public final class I18n { + private final JavaPlugin plugin; + private final Locale defaultLocale; + private final File langDir; + private final MiniMessage mm = MiniMessage.miniMessage(); + + // Cache loaded YAML per filename + private final Map cache = new ConcurrentHashMap<>(); + + public I18n(JavaPlugin plugin, Locale defaultLocale) { + this.plugin = plugin; + this.defaultLocale = defaultLocale; + this.langDir = new File(plugin.getDataFolder(), "lang"); + if (!langDir.exists()) langDir.mkdirs(); + + // Make sure defaults exist in data folder (optional, but handy) + saveIfMissing("lang/" + "en_EN.yml"); + saveIfMissing("lang/" + "nl_NL.yml"); + } + + private void saveIfMissing(String resourcePath) { + File out = new File(plugin.getDataFolder(), resourcePath); + if (!out.exists()) { + out.getParentFile().mkdirs(); + plugin.saveResource(resourcePath, false); + } + } + + // Locale detection (Paper modern + fallback for older) + private static Locale localeOf(CommandSender sender, Locale fallback) { + if (sender instanceof Player p) { + try { + Locale loc = p.locale(); // Paper 1.19+ + if (loc != null) return loc; + } catch (NoSuchMethodError ignored) { + try { + String s = p.getLocale(); // older Bukkit returns "en_us" + if (s != null && !s.isEmpty()) + return Locale.forLanguageTag(s.replace('_', '-')); + } catch (Throwable ignored2) {} + } + } + return fallback; // console & others + } + + // Candidate filenames in order of specificity + private List candidates(Locale loc) { + List out = new ArrayList<>(); + String lang = loc.getLanguage(); // "nl" + String country = loc.getCountry(); // "BE" + String defLang = defaultLocale.getLanguage(); + String defCountry = defaultLocale.getCountry(); + + if (!lang.isEmpty() && !country.isEmpty()) out.add(lang + "_" + country + ".yml"); // messages_nl_BE.yml + if (!lang.isEmpty()) out.add(lang + ".yml"); // messages_nl.yml + if (!defLang.isEmpty() && !defCountry.isEmpty()) out.add(defLang + "_" + defCountry + ".yml"); + if (!defLang.isEmpty()) out.add(defLang + ".yml"); + out.add("messages.yml"); // ultimate fallback if you keep one + return out; + } + + private YamlConfiguration loadFile(String filename) { + return cache.computeIfAbsent(filename, f -> { + File file = new File(langDir, f); + if (file.exists()) { + return YamlConfiguration.loadConfiguration(file); + } + // fallback to bundled resource inside the JAR + try (InputStreamReader reader = new InputStreamReader( + plugin.getResource("lang/" + f), StandardCharsets.UTF_8)) { + if (reader != null) { + YamlConfiguration conf = YamlConfiguration.loadConfiguration(reader); + return conf; + } + } catch (Exception ignored) {} + return new YamlConfiguration(); // empty + }); + } + + private String rawString(CommandSender target, String key) { + Locale loc = localeOf(target, defaultLocale); + for (String name : candidates(loc)) { + String v = loadFile(name).getString(key); + if (v != null) return v; + } + return null; + } + + private static String normalizeMiniMessage(String s) { + // Let you keep in files + return s.replace("", ""); + } + + public Component translate(CommandSender target, String key, TagResolver... placeholders) { + String s = rawString(target, key); + if (s == null) return Component.text(key); // show key when missing + // Render either MiniMessage or legacy "&" if used + if (s.indexOf('<') >= 0) { + return mm.deserialize(normalizeMiniMessage(s), TagResolver.resolver(placeholders)); + } + if (s.indexOf('&') >= 0) { + return LegacyComponentSerializer.legacyAmpersand().deserialize(s); + } + return Component.text(s); + } + + public Component trCount(CommandSender target, String baseKey, int n, TagResolver... extra) { + String suffix = (n == 0) ? ".zero" : (n == 1) ? ".one" : ".many"; + TagResolver count = Placeholder.unparsed("n", Integer.toString(n)); + return translate(target, baseKey + suffix, TagResolver.resolver(count, TagResolver.resolver(extra))); + } + + public Component withPrefix(CommandSender target, Component message) { + return translate(target, "general.prefix").append(Component.space()).append(message); + } + + public void reload() { cache.clear(); } +} +