Skip to content

Commit

Permalink
Improve language manager (#223)
Browse files Browse the repository at this point in the history
* Add fallthrough to "parent" language
  * I am aware that the "correct" parent for Portuguese is likely `pt_pt`, but as no one has submitted it, welcome to Brazil.
* Use English for lowercasing locale name
  * Fixes potential issues locating translations for certain languages on systems in other languages
* Migrate locales to "locale" subdirectory to reduce clutter
* Fix container settings getting clobbered/not suggested correctly/not editable
  * This was due to a quirk of the YAML parser - OpenInv was looking for `'on'` and `'off'` but translation files provided `'true'` and `'false'` due to the paths being interpreted as truthy. The built-in translations have had their paths quoted to fix this. You will need to manually relocate these if you have custom translations.
  • Loading branch information
Jikoo authored Jul 12, 2024
1 parent bb80d93 commit 59fff35
Show file tree
Hide file tree
Showing 9 changed files with 174 additions and 40 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,25 @@
public class LanguageManager {

private final Plugin plugin;
private final File folder;
private final String defaultLocale;
private final Map<String, YamlConfiguration> locales;

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);
}

Expand All @@ -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<String> newKeys = getMissingKeys(localeConfigDefaults, localeConfig::isSet);

Expand All @@ -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.
Expand All @@ -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");
}
}

Expand All @@ -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;
}

Expand Down Expand Up @@ -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) {}

}
9 changes: 8 additions & 1 deletion plugin/src/main/java/com/lishid/openinv/OpenInv.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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 {
Expand Down
87 changes: 87 additions & 0 deletions plugin/src/main/java/com/lishid/openinv/util/LangMigrator.java
Original file line number Diff line number Diff line change
@@ -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<Path> 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);
}

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Original file line number Diff line number Diff line change
Expand Up @@ -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%'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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%'
4 changes: 2 additions & 2 deletions plugin/src/main/resources/locale/zh_cn.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ messages:
container:
noMatches: 找不到放有 %target% 的储物箱。
matches: '找到放有 %target% 的储物箱 : %detail%'
'true': '开启'
'false': '关闭'
'on': '开启'
'off': '关闭'
container:
player: '%player% 的物品栏'
enderchest: '%player% 的末影箱'
4 changes: 2 additions & 2 deletions plugin/src/main/resources/locale/zh_tw.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ messages:
container:
noMatches: 找不到放有 %target% 的儲物箱。
matches: '找到放有 %target% 的儲物箱 : %detail%'
'true': '開啟'
'false': '關閉'
'on': '開啟'
'off': '關閉'
container:
player: '%player% 的物品欄'
enderchest: '%player% 的終界箱'

0 comments on commit 59fff35

Please sign in to comment.