diff --git a/api/src/main/java/org/geysermc/geyser/api/GeyserApi.java b/api/src/main/java/org/geysermc/geyser/api/GeyserApi.java index 5c20d06e110..dbee70454e5 100644 --- a/api/src/main/java/org/geysermc/geyser/api/GeyserApi.java +++ b/api/src/main/java/org/geysermc/geyser/api/GeyserApi.java @@ -161,6 +161,59 @@ public interface GeyserApi extends GeyserApiBase { @NonNull CommandSource consoleCommandSource(); + /** + * Gets the default locale used within Geyser + * @return the default locale + */ + @NonNull + String defaultLocale(); + + /** + * Get's the translation string associated with the key from the locale specified + * @param locale the locale to use + * @param key the key of the translation + * @return the translated message, or the key if there is none + */ + @NonNull + default String translationString(@NonNull String locale, @NonNull String key) { + return translationStringOrDefault(locale, key, key); + } + + /** + * Get's the translation string associated with the key from the locale specified + * @param locale the locale to use + * @param key the key of the translation + * @param defaultValue the fallback value for this translation + * @return the translated message, or the key if there is none + */ + @NonNull + String translationStringOrDefault(@NonNull String locale, @NonNull String key, @NonNull String defaultValue); + + /** + * Get's the translation string associated with the key from the locale specified + * using the parameters specified + * @param locale the locale to use + * @param key the key of the translation + * @param parameters the parameters of the translation + * @return the translated message, or the key if there is none + */ + @NonNull + default String translationString(@NonNull String locale, @NonNull String key, @NonNull String... parameters) { + return translationStringOrDefault(locale, key, key, parameters); + } + + /** + * Get's the translation string associated with the key from the locale specified + * using the parameters specified + * @param locale the locale to use + * @param key the key of the translation + * @param defaultValue the fallback value for this translation + * @param parameters the parameters of the translation + * @return the translated message, or the key if there is none + */ + @NonNull + String translationStringOrDefault(@NonNull String locale, @NonNull String key, @NonNull String defaultValue, @NonNull String... parameters); + /** * Gets the current {@link GeyserApiBase} instance. * diff --git a/api/src/main/java/org/geysermc/geyser/api/command/CommandSource.java b/api/src/main/java/org/geysermc/geyser/api/command/CommandSource.java index c1453f5798d..13ce785be80 100644 --- a/api/src/main/java/org/geysermc/geyser/api/command/CommandSource.java +++ b/api/src/main/java/org/geysermc/geyser/api/command/CommandSource.java @@ -27,6 +27,7 @@ import org.checkerframework.checker.nullness.qual.NonNull; import org.checkerframework.checker.nullness.qual.Nullable; +import org.geysermc.geyser.api.GeyserApi; import org.geysermc.geyser.api.connection.GeyserConnection; import java.util.UUID; @@ -61,6 +62,44 @@ default void sendMessage(String[] messages) { } } + /** + * Translates the given message using the key and source's locale then sends the message + * @param key the translation key + */ + default void sendTranslatedMessage(String key) { + sendMessage(GeyserApi.api().getTranslationString(locale(), key)); + } + + /** + * Translates the given message using the key and source's locale then sends the message + * @param key the translation key + * @param defaultValue the fallback value if the translation does not exist + */ + default void sendTranslatedOrDefaultMessage(String key, String defaultValue) { + sendMessage(GeyserApi.api().getTranslationStringOrDefault(locale(), key, defaultValue)); + } + + /** + * Translates the given message using the key and source's locale then sends the message + * using the provided parameters + * @param key the translation key + * @param parameters the parameters for the translation + */ + default void sendTranslatedMessage(String key, String... parameters) { + sendMessage(GeyserApi.api().getTranslationString(locale(), key, parameters)); + } + + /** + * Translates the given message using the key and source's locale then sends the message + * using the provided parameters + * @param key the translation key + * @param defaultValue the fallback value if the translation does not exist + * @param parameters the parameters for the translation + */ + default void sendTranslatedOrDefaultMessage(String key, String defaultValue, String... parameters) { + sendMessage(GeyserApi.api().getTranslationStringOrDefault(locale(), key, defaultValue, parameters)); + } + /** * If this source is the console. * diff --git a/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineCustomTranslationsEvent.java b/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineCustomTranslationsEvent.java new file mode 100644 index 00000000000..5cdbf137053 --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineCustomTranslationsEvent.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.event.lifecycle; + +import org.geysermc.event.Event; +import org.geysermc.geyser.api.language.LanguageProvider; + +/** + * Called when {@link LanguageProvider}s are to be registered within Geyser + *

+ * This event allows you to register a {@link LanguageProvider} to Geyser in + * order to provide Geyser with translation strings. + */ +public interface GeyserDefineCustomTranslationsEvent extends Event { + + /** + * Registers the given {@link LanguageProvider} to Geyser. + * @param languageProvider the language provider to register + */ + void register(LanguageProvider languageProvider); +} diff --git a/api/src/main/java/org/geysermc/geyser/api/language/LanguageProvider.java b/api/src/main/java/org/geysermc/geyser/api/language/LanguageProvider.java new file mode 100644 index 00000000000..33f4c1bb16c --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/language/LanguageProvider.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.language; + +public interface LanguageProvider { + /** + * Loads locale data in the provided {@link LocaleManager} + * @param localeManager The locale manager to load data into + */ + void loadLocale(LocaleManager localeManager); +} diff --git a/api/src/main/java/org/geysermc/geyser/api/language/LocaleManager.java b/api/src/main/java/org/geysermc/geyser/api/language/LocaleManager.java new file mode 100644 index 00000000000..aec682e15dd --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/language/LocaleManager.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.language; + +import java.util.Map; + +public interface LocaleManager { + /** + * Get the locale code for this locale manager + * @return the locale code + */ + String localeCode(); + + /** + * Register a translation string + * @param key the key of the translation string + * @param value the value of the translation string + */ + void registerTranslationString(String key, String value); + + /** + * Checks whether or not a translation key has already been registered + * @param key the key of the translation string + * @return whether a translation with that key exists + */ + boolean hasTranslationString(String key); + + /** + * Gets all translations for this locale, the returned map is immutable + * @return All translations for this locale + */ + Map translationStrings(); +} diff --git a/build-logic/.kotlin/sessions/kotlin-compiler-10364697687613482546.salive b/build-logic/.kotlin/sessions/kotlin-compiler-10364697687613482546.salive new file mode 100644 index 00000000000..e69de29bb2d diff --git a/core/src/main/java/org/geysermc/geyser/GeyserImpl.java b/core/src/main/java/org/geysermc/geyser/GeyserImpl.java index 86d80ee75c8..83102bec6fa 100644 --- a/core/src/main/java/org/geysermc/geyser/GeyserImpl.java +++ b/core/src/main/java/org/geysermc/geyser/GeyserImpl.java @@ -54,12 +54,14 @@ import org.geysermc.geyser.api.GeyserApi; import org.geysermc.geyser.api.command.CommandSource; import org.geysermc.geyser.api.event.EventRegistrar; +import org.geysermc.geyser.api.event.lifecycle.GeyserDefineCustomTranslationsEvent; import org.geysermc.geyser.api.event.lifecycle.GeyserPostInitializeEvent; import org.geysermc.geyser.api.event.lifecycle.GeyserPostReloadEvent; import org.geysermc.geyser.api.event.lifecycle.GeyserPreInitializeEvent; import org.geysermc.geyser.api.event.lifecycle.GeyserPreReloadEvent; import org.geysermc.geyser.api.event.lifecycle.GeyserRegisterPermissionsEvent; import org.geysermc.geyser.api.event.lifecycle.GeyserShutdownEvent; +import org.geysermc.geyser.api.language.LanguageProvider; import org.geysermc.geyser.api.network.AuthType; import org.geysermc.geyser.api.network.BedrockListener; import org.geysermc.geyser.api.network.RemoteServer; @@ -73,6 +75,7 @@ import org.geysermc.geyser.event.type.SessionDisconnectEventImpl; import org.geysermc.geyser.extension.GeyserExtensionManager; import org.geysermc.geyser.impl.MinecraftVersionImpl; +import org.geysermc.geyser.language.LanguageManager; import org.geysermc.geyser.level.BedrockDimension; import org.geysermc.geyser.level.WorldManager; import org.geysermc.geyser.network.GameProtocol; @@ -100,6 +103,7 @@ import org.geysermc.geyser.util.NewsHandler; import org.geysermc.geyser.util.VersionCheckUtils; import org.geysermc.geyser.util.WebUtils; +import org.jetbrains.annotations.NotNull; import java.io.File; import java.io.FileWriter; @@ -152,6 +156,7 @@ public class GeyserImpl implements GeyserApi, EventRegistrar { private static final Pattern IP_REGEX = Pattern.compile("\\b\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\b"); private final SessionManager sessionManager = new SessionManager(); + private final LanguageManager languageManager = new LanguageManager(); private FloodgateCipher cipher; private FloodgateSkinUploader skinUploader; @@ -251,6 +256,9 @@ public void initialize() { EntityDefinitions.init(); MessageTranslator.init(); + // Register LanguageProviders before loading any Locale + eventBus.fire((GeyserDefineCustomTranslationsEvent) languageManager::registerLanguageProvider); + // Download the latest asset list and cache it AssetUtils.generateAssetCache().whenComplete((aVoid, ex) -> { if (ex != null) { @@ -817,6 +825,30 @@ public PlatformType platformType() { return getLogger(); } + @Override + public @NonNull String defaultLocale() { + return GeyserLocale.getDefaultLocale(); + } + + @Override + public @NonNull String translationStringOrDefault(@NonNull String locale, @NonNull String key, @NonNull String defaultValue) { + String translation = MinecraftLocale.getLocaleStringIfPresent(key, locale); + if (translation == null) translation = defaultValue; + + return translation; + } + + @Override + public @NonNull String translationStringOrDefault(@NonNull String locale, @NonNull String key, @NonNull String defaultValue, @NotNull @NonNull String... parameters) { + String translation = translationStringOrDefault(locale, key, defaultValue); + int order = 0; + for (String parameter : parameters) { + translation = translation.replaceFirst("%s", parameter).replace("%" + order + "$s", parameter); + } + + return translation; + } + public int buildNumber() { if (!this.isProductionEnvironment()) { return 0; diff --git a/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionLoader.java b/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionLoader.java index 2be6d2f8fba..851be8214c0 100644 --- a/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionLoader.java +++ b/core/src/main/java/org/geysermc/geyser/extension/GeyserExtensionLoader.java @@ -41,12 +41,18 @@ import org.geysermc.geyser.api.extension.ExtensionManager; import org.geysermc.geyser.api.extension.exception.InvalidDescriptionException; import org.geysermc.geyser.api.extension.exception.InvalidExtensionException; +import org.geysermc.geyser.api.language.LanguageProvider; +import org.geysermc.geyser.api.language.LocaleManager; import org.geysermc.geyser.extension.event.GeyserExtensionEventBus; import org.geysermc.geyser.text.GeyserLocale; import org.geysermc.geyser.util.ThrowingBiConsumer; +import java.io.File; import java.io.IOException; +import java.io.InputStreamReader; import java.io.Reader; +import java.net.URL; +import java.net.URLClassLoader; import java.nio.file.FileSystem; import java.nio.file.FileSystems; import java.nio.file.Files; @@ -59,10 +65,14 @@ import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Properties; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiConsumer; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; import java.util.function.Consumer; import java.util.regex.Pattern; @@ -123,7 +133,52 @@ public GeyserExtensionContainer loadExtension(Path path, GeyserExtensionDescript private GeyserExtensionContainer setup(Extension extension, GeyserExtensionDescription description, Path dataFolder, ExtensionEventBus eventBus) { GeyserExtensionLogger logger = new GeyserExtensionLogger(GeyserImpl.getInstance().getLogger(), description.id()); - return new GeyserExtensionContainer(extension, dataFolder, description, this, logger, eventBus); + GeyserExtensionContainer container = new GeyserExtensionContainer(extension, dataFolder, description, this, logger, eventBus); + + try { + for (URL url : ((URLClassLoader) extension.getClass().getClassLoader()).getURLs()) { + File file = new File(url.toURI()); + JarFile jarFile = new JarFile(file); + JarEntry languagesEntry = jarFile.getJarEntry("languages/"); + + if (languagesEntry != null && languagesEntry.isDirectory()) { + Map> translationStrings = new HashMap<>(); + + for (JarEntry entry : jarFile.stream().toList()) { + if (entry.getName().startsWith("languages/") && entry.getName().endsWith(".properties")) { + String localeCode = entry.getName().substring(entry.getName().indexOf('/') + 1, entry.getName().length() - 11).toLowerCase(Locale.ROOT); + + Properties properties = new Properties(); + properties.load(jarFile.getInputStream(entry)); + + if (!properties.isEmpty()) + translationStrings.put(localeCode, new HashMap<>(properties)); + } + } + + eventBus.subscribe(GeyserDefineCustomTranslationsEvent.class, event -> { + event.register(localeManager -> { + String localeCode = localeManager.getLocaleCode().toLowerCase(Locale.ROOT); + if (translationStrings.containsKey(localeCode)) { + translationStrings.get(localeCode).forEach( + (key, value) -> + localeManager.registerTranslationString( + key.toString(), + value.toString() + ) + ); + } + }); + }); + } + + jarFile.close(); + } + } catch (Exception e) { + GeyserImpl.getInstance().getLogger().error("Unable to load locale files from extension.", e); + } + + return container; } public GeyserExtensionDescription extensionDescription(Path path) throws InvalidDescriptionException { diff --git a/core/src/main/java/org/geysermc/geyser/language/LanguageManager.java b/core/src/main/java/org/geysermc/geyser/language/LanguageManager.java new file mode 100644 index 00000000000..82a83b89bf7 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/language/LanguageManager.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.language; + +import org.geysermc.geyser.GeyserImpl; +import org.geysermc.geyser.api.language.LanguageProvider; +import org.geysermc.geyser.api.language.LocaleManager; + +import java.util.HashSet; +import java.util.Set; + +public final class LanguageManager { + private final Set languageProviders = new HashSet<>(); + + /** + * Registers a {@link LanguageProvider}, should only be done before Minecraft locales are loaded! + * @param provider the {@link LanguageProvider} to register + * @return if the {@link LanguageProvider} was registered successfully + */ + public boolean registerLanguageProvider(LanguageProvider provider) { + return languageProviders.add(provider); + } + + /** + * Load locales into the given {@link LocaleManager} + * @param localeManager the {@link LocaleManager} + */ + public void registerTranslationStrings(LocaleManager localeManager) { + languageProviders.forEach(provider -> provider.loadLocale(localeManager)); + } +} diff --git a/core/src/main/java/org/geysermc/geyser/text/MinecraftLocale.java b/core/src/main/java/org/geysermc/geyser/text/MinecraftLocale.java index 74ab1e7e6af..cd9682f0c14 100644 --- a/core/src/main/java/org/geysermc/geyser/text/MinecraftLocale.java +++ b/core/src/main/java/org/geysermc/geyser/text/MinecraftLocale.java @@ -28,6 +28,7 @@ import com.fasterxml.jackson.databind.JsonNode; import org.checkerframework.checker.nullness.qual.Nullable; import org.geysermc.geyser.GeyserImpl; +import org.geysermc.geyser.api.language.LocaleManager; import org.geysermc.geyser.util.AssetUtils; import org.geysermc.geyser.util.FileUtils; import org.geysermc.geyser.util.WebUtils; @@ -38,6 +39,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; +import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.Locale; @@ -175,6 +177,29 @@ private static boolean loadLocale(String locale) { langMap.putAll(parseLangFile(localeOverride, lowercaseLocale)); } + // Load locales from extensions (the ultimate override) + GeyserImpl.getInstance().getLanguageManager().registerTranslationStrings(new LocaleManager() { + @Override + public String localeCode() { + return locale; + } + + @Override + public void registerTranslationString(String key, String value) { + langMap.put(key, value); + } + + @Override + public boolean hasTranslationString(String key) { + return langMap.containsKey(key); + } + + @Override + public Map translationStrings() { + return Collections.unmodifiableMap(langMap); + } + }); + if (!langMap.isEmpty()) { LOCALE_MAPPINGS.put(lowercaseLocale, langMap); return true;