diff --git a/common/src/main/java/com/lishid/openinv/util/lang/LanguageManager.java b/common/src/main/java/com/lishid/openinv/util/lang/LanguageManager.java index 6e5fe207..49dc55ac 100644 --- a/common/src/main/java/com/lishid/openinv/util/lang/LanguageManager.java +++ b/common/src/main/java/com/lishid/openinv/util/lang/LanguageManager.java @@ -50,6 +50,7 @@ public class LanguageManager { private final Plugin plugin; + private final File folder; private final String defaultLocale; private final Map locales; @@ -57,6 +58,17 @@ public LanguageManager(@NotNull Plugin plugin, @NotNull String defaultLocale) { this.plugin = plugin; this.defaultLocale = defaultLocale; this.locales = new HashMap<>(); + this.folder = new File(plugin.getDataFolder(), "locale"); + + if (!folder.exists() && !folder.mkdirs()) { + plugin.getLogger().warning(() -> "Unable to create " + folder.getPath() + "! Languages may not be editable."); + } + + reload(); + } + + public void reload() { + this.locales.clear(); getOrLoadLocale(defaultLocale); } @@ -66,57 +78,86 @@ public LanguageManager(@NotNull Plugin plugin, @NotNull String defaultLocale) { return loaded; } - File file = new File(plugin.getDataFolder(), locale + ".yml"); + LangLocation lang = bestMatch(locale, null); + + // If a parent was a better match, check if it is already loaded. + if (!locale.equals(lang.locale)) { + loaded = locales.get(lang.locale); + if (loaded != null) { + locales.put(locale, loaded); + return loaded; + } + } // Load locale config from disk and bundled locale defaults. - YamlConfiguration localeConfig = loadLocale(locale, file); + YamlConfiguration localeConfig = loadLocale(lang); // If the locale is not the default locale, also handle any missing translations from the default locale. if (!locale.equals(defaultLocale)) { - addTranslationFallthrough(locale, localeConfig, file); + addTranslationFallthrough(lang, localeConfig); if (plugin.getConfig().getBoolean("settings.secret.warn-about-guess-section", true) && localeConfig.isConfigurationSection("guess")) { // Warn that guess section exists. This should run once per language per server restart // when accessed by a user to hint to server owners that they can make UX improvements. - plugin.getLogger().info(() -> "[LanguageManager] Missing translations from " + locale + ".yml! Check the guess section!"); + plugin.getLogger().info(() -> "[LanguageManager] Missing translations from " + lang.locale + ".yml! Check the guess section!"); } } locales.put(locale, localeConfig); + locales.put(lang.locale, localeConfig); + return localeConfig; } - private @NotNull YamlConfiguration loadLocale( - @NotNull String locale, - @NotNull File file) { - // Load defaults from the plugin's bundled resources. - InputStream resourceStream = plugin.getResource("locale/" + locale + ".yml"); + private @NotNull LangLocation bestMatch(@NotNull String locale, @Nullable LangLocation initial) { + File file = new File(folder, locale + ".yml"); + InputStream bundled = plugin.getResource("locale/" + locale + ".yml"); + + if (file.exists() || bundled != null) { + return new LangLocation(locale, file, bundled); + } + + if (initial == null) { + initial = new LangLocation(locale, file, null); + } + + int lastSeparator = locale.lastIndexOf('_'); + + // Must be at least some content before separator. + if (lastSeparator < 1) { + return initial; + } + + return bestMatch(locale.substring(0, lastSeparator), initial); + } + + private @NotNull YamlConfiguration loadLocale(@NotNull LangLocation lang) { YamlConfiguration localeConfigDefaults; - if (resourceStream == null) { + if (lang.bundled == null) { localeConfigDefaults = new YamlConfiguration(); } else { - try (BufferedReader reader = new BufferedReader(new InputStreamReader(resourceStream, StandardCharsets.UTF_8))) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(lang.bundled, StandardCharsets.UTF_8))) { localeConfigDefaults = YamlConfiguration.loadConfiguration(reader); } catch (IOException e) { - plugin.getLogger().log(Level.WARNING, e, () -> "[LanguageManager] Unable to load resource " + locale + ".yml"); + plugin.getLogger().log(Level.WARNING, e, () -> "[LanguageManager] Unable to load resource " + lang.locale + ".yml"); localeConfigDefaults = new YamlConfiguration(); } } - if (!file.exists()) { + if (!lang.file.exists()) { // If the file does not exist on disk, save bundled defaults. try { - localeConfigDefaults.save(file); + localeConfigDefaults.save(lang.file); } catch (IOException e) { - plugin.getLogger().log(Level.WARNING, e, () -> "[LanguageManager] Unable to save resource " + locale + ".yml"); + plugin.getLogger().log(Level.WARNING, e, () -> "[LanguageManager] Unable to save resource " + lang.locale + ".yml"); } // Return loaded bundled locale. return localeConfigDefaults; } // If the file does exist on disk, load it. - YamlConfiguration localeConfig = YamlConfiguration.loadConfiguration(file); + YamlConfiguration localeConfig = YamlConfiguration.loadConfiguration(lang.file); // Check for missing translations from the bundled file. List newKeys = getMissingKeys(localeConfigDefaults, localeConfig::isSet); @@ -142,22 +183,19 @@ public LanguageManager(@NotNull Plugin plugin, @NotNull String defaultLocale) { localeConfig.set("guess", null); } - plugin.getLogger().info(() -> "[LanguageManager] Added new translation keys to " + locale + ".yml: " + String.join(", ", newKeys)); + plugin.getLogger().info(() -> "[LanguageManager] Added new translation keys to " + lang.locale + ".yml: " + String.join(", ", newKeys)); // Write new keys to disk. try { - localeConfig.save(file); + localeConfig.save(lang.file); } catch (IOException e) { - plugin.getLogger().log(Level.WARNING, e, () -> "[LanguageManager] Unable to save resource " + locale + ".yml"); + plugin.getLogger().log(Level.WARNING, e, () -> "[LanguageManager] Unable to save resource " + lang.locale + ".yml"); } return localeConfig; } - private void addTranslationFallthrough( - @NotNull String locale, - @NotNull YamlConfiguration localeConfig, - @NotNull File file) { + private void addTranslationFallthrough(@NotNull LangLocation location, @NotNull YamlConfiguration localeConfig) { YamlConfiguration defaultLocaleConfig = locales.get(defaultLocale); // Get missing keys. Keys that already have a guess value are not new and don't need to trigger another write. @@ -173,9 +211,9 @@ private void addTranslationFallthrough( // Write modified guess section to disk. try { - localeConfig.save(file); + localeConfig.save(location.file); } catch (IOException e) { - plugin.getLogger().log(Level.WARNING, e, () -> "[LanguageManager] Unable to save resource " + locale + ".yml"); + plugin.getLogger().log(Level.WARNING, e, () -> "[LanguageManager] Unable to save resource " + location.locale + ".yml"); } } @@ -197,8 +235,8 @@ private void addTranslationFallthrough( } public @Nullable String getValue(@NotNull String key, @Nullable String locale) { - String value = getOrLoadLocale(locale == null ? defaultLocale : locale.toLowerCase(Locale.ROOT)).getString(key); - if (value == null || value.isEmpty()) { + String value = getOrLoadLocale(locale == null ? defaultLocale : locale.toLowerCase(Locale.ENGLISH)).getString(key); + if (value == null || value.isBlank()) { return null; } @@ -276,4 +314,6 @@ public void sendSystemMessage(@NotNull Player player, @NotNull String key) { player.spigot().sendMessage(ChatMessageType.ACTION_BAR, TextComponent.fromLegacy(message)); } + private record LangLocation(@NotNull String locale, @NotNull File file, @Nullable InputStream bundled) {} + } diff --git a/plugin/src/main/java/com/lishid/openinv/OpenInv.java b/plugin/src/main/java/com/lishid/openinv/OpenInv.java index f6c9f160..b8ad1ad9 100644 --- a/plugin/src/main/java/com/lishid/openinv/OpenInv.java +++ b/plugin/src/main/java/com/lishid/openinv/OpenInv.java @@ -32,6 +32,7 @@ import com.lishid.openinv.internal.ISpecialPlayerInventory; import com.lishid.openinv.util.ConfigUpdater; import com.lishid.openinv.util.InternalAccessor; +import com.lishid.openinv.util.LangMigrator; import com.lishid.openinv.util.Permissions; import com.lishid.openinv.util.StringMetric; import com.lishid.openinv.util.lang.LanguageManager; @@ -51,6 +52,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Iterator; import java.util.Map; @@ -83,6 +85,7 @@ public class OpenInv extends JavaPlugin implements IOpenInv { @Override public void reloadConfig() { super.reloadConfig(); + languageManager.reload(); this.offlineHandler = disableOfflineAccess() ? OfflineHandler.REMOVE_AND_CLOSE : OfflineHandler.REQUIRE_PERMISSIONS; if (this.accessor != null && this.accessor.isSupported()) { this.accessor.reload(this.getConfig()); @@ -128,7 +131,11 @@ public void onEnable() { // Save default configuration if not present. this.saveDefaultConfig(); - this.languageManager = new LanguageManager(this, "en_us"); + // Migrate locale files to a subfolder. + Path dataFolder = getDataFolder().toPath(); + new LangMigrator(dataFolder, dataFolder.resolve("locale"), getLogger()).migrate(); + + this.languageManager = new LanguageManager(this, "en"); this.accessor = new InternalAccessor(getLogger(), languageManager); try { diff --git a/plugin/src/main/java/com/lishid/openinv/util/LangMigrator.java b/plugin/src/main/java/com/lishid/openinv/util/LangMigrator.java new file mode 100644 index 00000000..348e0d5a --- /dev/null +++ b/plugin/src/main/java/com/lishid/openinv/util/LangMigrator.java @@ -0,0 +1,87 @@ +package com.lishid.openinv.util; + +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.DirectoryStream; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.logging.Level; +import java.util.logging.Logger; + +public class LangMigrator { + + private final @NotNull Path oldFolder; + private final @NotNull Path newFolder; + private final @NotNull Logger logger; + + public LangMigrator(@NotNull Path oldFolder, @NotNull Path newFolder, @NotNull Logger logger) { + this.oldFolder = oldFolder; + this.newFolder = newFolder; + this.logger = logger; + } + + public void migrate() { + if (!Files.exists(oldFolder.resolve("en_us.yml"))) { + // Probably already migrated. + return; + } + + logger.info(() -> String.format("[LanguageManager] Migrating language files to %s", newFolder)); + + if (!Files.exists(newFolder)) { + try { + Files.createDirectories(newFolder); + } catch (IOException e) { + logger.log(Level.WARNING, "Unable to create language subdirectory!", e); + } + } + + try (DirectoryStream files = Files.newDirectoryStream(oldFolder)) { + files.forEach(path -> { + if (path == null) { + return; + } + + String fileName = path.getFileName().toString(); + + if (fileName.startsWith("config") || !fileName.endsWith(".yml")) { + return; + } + + // Migrate certain files to be parent languages. + fileName = switch (fileName) { + case "en_us.yml" -> "en.yml"; + case "de_de.yml" -> "de.yml"; + case "es_es.yml" -> "es.yml"; + case "pt_br.yml" -> "pt.yml"; + default -> fileName; + }; + + try { + Files.copy(path, newFolder.resolve(fileName)); + Files.delete(path); + } catch (FileAlreadyExistsException e1) { + // File already migrated? + try { + Files.copy(path, newFolder.resolve("old_" + fileName)); + Files.delete(path); + } catch (IOException e2) { + // If it fails again, just re-throw. + throw new UncheckedIOException(e2); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } catch (UncheckedIOException e) { + logger.log(Level.WARNING, "Unable to migrate languages to subdirectory!", e.getCause()); + } catch (IOException e) { + logger.log(Level.WARNING, "Unable to migrate languages to subdirectory!", e); + } + + } + +} diff --git a/plugin/src/main/resources/locale/de_de.yml b/plugin/src/main/resources/locale/de.yml similarity index 97% rename from plugin/src/main/resources/locale/de_de.yml rename to plugin/src/main/resources/locale/de.yml index ea1d0dce..9a00b1db 100644 --- a/plugin/src/main/resources/locale/de_de.yml +++ b/plugin/src/main/resources/locale/de.yml @@ -23,8 +23,8 @@ messages: container: noMatches: 'Keine Container mit %target% gefunden.' matches: 'Container hat %target%: %detail%' - on: 'an' - off: 'aus' + 'on': 'an' + 'off': 'aus' container: player: '%player%''s Inventar' enderchest: '%player%''s Endertruhe' diff --git a/plugin/src/main/resources/locale/en_us.yml b/plugin/src/main/resources/locale/en.yml similarity index 97% rename from plugin/src/main/resources/locale/en_us.yml rename to plugin/src/main/resources/locale/en.yml index 829a0e3c..37ec8449 100644 --- a/plugin/src/main/resources/locale/en_us.yml +++ b/plugin/src/main/resources/locale/en.yml @@ -23,8 +23,8 @@ messages: container: noMatches: 'No containers found with %target%.' matches: 'Containers holding %target%: %detail%' - on: 'on' - off: 'off' + 'on': 'on' + 'off': 'off' container: player: '%player%''s Inventory' enderchest: '%player%''s Ender Chest' diff --git a/plugin/src/main/resources/locale/es_es.yml b/plugin/src/main/resources/locale/es.yml similarity index 97% rename from plugin/src/main/resources/locale/es_es.yml rename to plugin/src/main/resources/locale/es.yml index 41541fae..05d45dcf 100644 --- a/plugin/src/main/resources/locale/es_es.yml +++ b/plugin/src/main/resources/locale/es.yml @@ -23,8 +23,8 @@ messages: container: noMatches: 'No se encontraron contenedores con %target%.' matches: 'Contenedores con %target%: %detail%' - on: 'activado' - off: 'desactivado' + 'on': 'activado' + 'off': 'desactivado' container: player: 'Inventario de %player%' enderchest: 'Cofre de Ender de %player%' diff --git a/plugin/src/main/resources/locale/pt_br.yml b/plugin/src/main/resources/locale/pt.yml similarity index 97% rename from plugin/src/main/resources/locale/pt_br.yml rename to plugin/src/main/resources/locale/pt.yml index cd26a2d7..4d64c43e 100644 --- a/plugin/src/main/resources/locale/pt_br.yml +++ b/plugin/src/main/resources/locale/pt.yml @@ -23,8 +23,8 @@ messages: container: noMatches: 'Nenhum recipiente encontrado com %target%.' matches: 'Recipientes contendo %target%: %detail%' - on: 'ligado' - off: 'desligado' + 'on': 'ligado' + 'off': 'desligado' container: player: 'Inventario de %player%' enderchest: 'Bau de Ender de %player%' diff --git a/plugin/src/main/resources/locale/zh_cn.yml b/plugin/src/main/resources/locale/zh_cn.yml index bec0185c..aa696f19 100644 --- a/plugin/src/main/resources/locale/zh_cn.yml +++ b/plugin/src/main/resources/locale/zh_cn.yml @@ -24,8 +24,8 @@ messages: container: noMatches: 找不到放有 %target% 的储物箱。 matches: '找到放有 %target% 的储物箱 : %detail%' - 'true': '开启' - 'false': '关闭' + 'on': '开启' + 'off': '关闭' container: player: '%player% 的物品栏' enderchest: '%player% 的末影箱' diff --git a/plugin/src/main/resources/locale/zh_tw.yml b/plugin/src/main/resources/locale/zh_tw.yml index c5e7399d..2a798241 100644 --- a/plugin/src/main/resources/locale/zh_tw.yml +++ b/plugin/src/main/resources/locale/zh_tw.yml @@ -24,8 +24,8 @@ messages: container: noMatches: 找不到放有 %target% 的儲物箱。 matches: '找到放有 %target% 的儲物箱 : %detail%' - 'true': '開啟' - 'false': '關閉' + 'on': '開啟' + 'off': '關閉' container: player: '%player% 的物品欄' enderchest: '%player% 的終界箱'