From 4ca6d9fe0998daa971cfcf80dbeb2b0e658cd9a6 Mon Sep 17 00:00:00 2001 From: Helvetica Volubi Date: Sat, 14 Jun 2025 16:08:19 +0800 Subject: [PATCH] refactor: I18n update --- .../features/0014-I18n-support.patch | 4 +- .../lophine/utils/LocalLangUtil.java.patch | 267 ------------------ .../lophine/utils/ServerI18nUtil.java.patch | 254 +++++++++++++++++ 3 files changed, 256 insertions(+), 269 deletions(-) delete mode 100644 lophine-server/paper-patches/files/src/main/java/me/earthme/lophine/utils/LocalLangUtil.java.patch create mode 100644 lophine-server/paper-patches/files/src/main/java/me/earthme/lophine/utils/ServerI18nUtil.java.patch diff --git a/lophine-server/minecraft-patches/features/0014-I18n-support.patch b/lophine-server/minecraft-patches/features/0014-I18n-support.patch index f2bd3ca..bf6ce6d 100644 --- a/lophine-server/minecraft-patches/features/0014-I18n-support.patch +++ b/lophine-server/minecraft-patches/features/0014-I18n-support.patch @@ -5,14 +5,14 @@ Subject: [PATCH] I18n support diff --git a/net/minecraft/server/dedicated/DedicatedServer.java b/net/minecraft/server/dedicated/DedicatedServer.java -index ba0cdba050cd3bf9231e15dd9543d34f37d9dae5..ce7d70afa524e5b8ea6f9de75d34c8f1eaa6ad72 100644 +index ba0cdba050cd3bf9231e15dd9543d34f37d9dae5..33e7cb15ceafdb246bb9cc1a43d9968d3f28ca6b 100644 --- a/net/minecraft/server/dedicated/DedicatedServer.java +++ b/net/minecraft/server/dedicated/DedicatedServer.java @@ -172,6 +172,7 @@ public class DedicatedServer extends MinecraftServer implements ServerInterface this.paperConfigurations.initializeWorldDefaultsConfiguration(this.registryAccess()); // Paper end - initialize global and world-defaults configuration me.earthme.lophine.config.LophineConfig.loadConfigFiles(); //Luminol - load config file // Lophine - load config file -+ me.earthme.lophine.utils.LocalLangUtil.init(); // Lophine - I18n support ++ me.earthme.lophine.utils.ServerI18nUtil.init(); // Lophine - I18n support if (false) this.server.spark.enableEarlyIfRequested(); // Paper - spark // Luminol - Force disable builtin spark // Paper start - fix converting txt to json file; convert old users earlier after PlayerList creation but before file load/save if (this.convertOldUsers()) { diff --git a/lophine-server/paper-patches/files/src/main/java/me/earthme/lophine/utils/LocalLangUtil.java.patch b/lophine-server/paper-patches/files/src/main/java/me/earthme/lophine/utils/LocalLangUtil.java.patch deleted file mode 100644 index c596994..0000000 --- a/lophine-server/paper-patches/files/src/main/java/me/earthme/lophine/utils/LocalLangUtil.java.patch +++ /dev/null @@ -1,267 +0,0 @@ ---- /dev/null -+++ b/src/main/java/me/earthme/lophine/utils/LocalLangUtil.java -@@ -1,0 +_,264 @@ -+package me.earthme.lophine.utils; -+ -+import com.google.gson.JsonElement; -+import com.google.gson.JsonObject; -+import com.google.gson.JsonParseException; -+import com.google.gson.JsonParser; -+import net.minecraft.locale.DeprecatedTranslationsInfo; -+import net.minecraft.locale.Language; -+import net.minecraft.network.chat.FormattedText; -+import net.minecraft.network.chat.Style; -+import net.minecraft.util.FormattedCharSequence; -+import net.minecraft.util.StringDecomposer; -+ -+import java.io.ByteArrayInputStream; -+import java.io.IOException; -+import java.io.InputStream; -+import java.io.InputStreamReader; -+import java.net.HttpURLConnection; -+import java.net.URL; -+import java.nio.file.Files; -+import java.nio.file.Path; -+import java.nio.file.Paths; -+import java.util.HashMap; -+import java.util.Map; -+import java.util.Objects; -+import java.util.Optional; -+import java.util.concurrent.CompletableFuture; -+import java.util.function.BiConsumer; -+import java.util.logging.Logger; -+ -+public class LocalLangUtil { -+ final static Logger logger = Logger.getLogger("LangLoader"); -+ final static String VERSION = "1.21.5"; -+ final static String basePath = "cache/lophine/" + VERSION + "/"; -+ static String lang = me.earthme.lophine.config.modules.optimizations.LanguageConfig.lang; -+ static String versionManifestUrl = "https://launchermeta.mojang.com/mc/game/version_manifest.json"; -+ static String resourceUrlHeader = "https://resources.download.minecraft.net/"; -+ -+ public static void init() { -+ init(true); -+ } -+ -+ public static void init(boolean init) { -+ if (Objects.equals(lang, "en_us")) return; -+ CompletableFuture.runAsync(() -> { -+ try { -+ String path = basePath + "lang/" + lang + ".json"; -+ if (!Files.exists(Path.of(path))) { -+ downloadLangAndCheck(); -+ } -+ try { -+ loadLocalLang(path, init); -+ } catch (JsonParseException e) { -+ cleanCache(); -+ if (init) { -+ init(false); -+ } -+ } -+ } catch (Exception e) { -+ logger.severe(() -> "Async initialization failed: " + e.getMessage()); -+ } -+ }); -+ } -+ -+ private static void downloadLangAndCheck() { -+ String path = basePath + VERSION + ".json"; -+ JsonObject json; -+ if (Files.exists(Path.of(path))) { -+ try { -+ json = loadJson(path); -+ } catch (Exception e) { -+ logger.warning("Failed to load local JSON: " + e.getMessage()); -+ json = download(); -+ } -+ } else { -+ json = download(); -+ } -+ -+ if (json == null) { -+ logger.warning("Failed to load language metadata"); -+ return; -+ } -+ -+ try { -+ JsonObject assetIndex = json.getAsJsonObject("assetIndex"); -+ String assetUrl = assetIndex.get("url").getAsString(); -+ byte[] assetData = fetchAndSave(assetUrl, basePath + "resource.json"); -+ -+ JsonObject assets = JsonParser.parseString(new String(assetData)).getAsJsonObject(); -+ JsonObject langEntry = assets.getAsJsonObject("objects") -+ .getAsJsonObject("minecraft/lang/" + lang + ".json"); -+ -+ String hash = langEntry.get("hash").getAsString(); -+ if (hash == null || hash.length() < 2) { -+ throw new IllegalArgumentException("Invalid hash value"); -+ } -+ -+ downloadLang(hash); -+ } catch (Exception e) { -+ logger.warning("Asset processing failed: " + e.getMessage()); -+ } -+ } -+ -+ private static void downloadLang(String hash) { -+ String url = resourceUrlHeader + hash.substring(0, 2) + "/" + hash; -+ -+ for (int i = 0; i < 3; i++) { -+ try { -+ fetchAndSave(url, basePath + "lang/" + lang + ".json"); -+ return; -+ } catch (Exception e) { -+ if (i == 2) logger.warning("Final attempt failed: " + e.getMessage()); -+ } -+ } -+ } -+ -+ private static JsonObject download() { -+ String targetVersionUrl = null; -+ -+ // Phase 1: Fetch version manifest -+ for (int i = 0; i < 3; i++) { -+ try (InputStreamReader reader = new InputStreamReader( -+ new ByteArrayInputStream(fetch(versionManifestUrl)))) { -+ JsonObject manifest = JsonParser.parseReader(reader).getAsJsonObject(); -+ for (JsonElement element : manifest.getAsJsonArray("versions")) { -+ JsonObject version = element.getAsJsonObject(); -+ if (VERSION.equals(version.get("id").getAsString())) { -+ targetVersionUrl = version.get("url").getAsString(); -+ break; -+ } -+ } -+ if (targetVersionUrl != null) break; -+ } catch (Exception e) { -+ logger.warning("Failed to fetch version manifest: " + e.getMessage()); -+ } -+ } -+ -+ if (targetVersionUrl == null) return null; -+ -+ // Phase 2: Fetch version metadata -+ for (int i = 0; i < 3; i++) { -+ try (InputStreamReader reader = new InputStreamReader( -+ new ByteArrayInputStream(fetchAndSave(targetVersionUrl, basePath + VERSION + ".json")))) { -+ return JsonParser.parseReader(reader).getAsJsonObject(); -+ } catch (Exception e) { -+ logger.warning("Failed to fetch version metadata: " + e.getMessage()); -+ } -+ } -+ return null; -+ } -+ -+ public static byte[] fetch(String urlString) throws IOException { -+ HttpURLConnection conn = (HttpURLConnection) new URL(urlString).openConnection(); -+ conn.setRequestMethod("GET"); -+ conn.setConnectTimeout(5000); -+ conn.setReadTimeout(10000); -+ -+ try (InputStream stream = conn.getInputStream()) { -+ if (conn.getResponseCode() != 200) { -+ throw new IOException("HTTP error: " + conn.getResponseCode()); -+ } -+ return stream.readAllBytes(); -+ } finally { -+ conn.disconnect(); -+ } -+ } -+ -+ public static byte[] fetchAndSave(String url, String savePath) throws IOException { -+ byte[] data = fetch(url); -+ Path outputPath = Paths.get(savePath); -+ Files.createDirectories(outputPath.getParent()); -+ Files.write(outputPath, data); -+ return data; -+ } -+ -+ private static void cleanCache() { -+ Path cachePath = Paths.get(basePath); -+ try { -+ if (Files.exists(cachePath)) { -+ Files.walk(cachePath) -+ .forEach(path -> { -+ try { -+ Files.deleteIfExists(path); -+ } catch (IOException e) { -+ logger.warning("Failed to delete: " + path + " - " + e.getMessage()); -+ } -+ }); -+ logger.info("Cache cleaned: " + cachePath); -+ } else { -+ logger.info("Cache directory does not exist: " + cachePath); -+ } -+ } catch (IOException e) { -+ logger.severe("Cache cleanup failed: " + e.getMessage()); -+ } -+ } -+ -+ -+ public static JsonObject loadJson(String path) throws IOException { -+ byte[] data = Files.readAllBytes(Paths.get(path)); -+ return JsonParser.parseString(new String(data)).getAsJsonObject(); -+ } -+ -+ public static void loadLocalLang(String lang, boolean init) throws IOException { -+ try { -+ Language.inject(load(lang)); -+ } catch (Exception e) { -+ logger.warning("Failed to load language file for " + lang + "\n" + e); -+ logger.info("Load default en_us instead of local lang " + lang); -+ if (init) throw e; -+ } -+ } -+ -+ private static Language load(String lang) throws IOException { -+ DeprecatedTranslationsInfo deprecatedTranslationsInfo = DeprecatedTranslationsInfo.loadFromDefaultResource(); -+ Map map = new HashMap<>(); -+ BiConsumer biConsumer = map::put; -+ parseTranslations(biConsumer, lang); -+ deprecatedTranslationsInfo.applyToMap(map); -+ final Map map1 = Map.copyOf(map); -+ return new Language() { -+ @Override -+ public String getOrDefault(String key, String defaultValue) { -+ return map1.getOrDefault(key, defaultValue); -+ } -+ -+ @Override -+ public boolean has(String id) { -+ return map1.containsKey(id); -+ } -+ -+ @Override -+ public boolean isDefaultRightToLeft() { -+ return false; -+ } -+ -+ @Override -+ public FormattedCharSequence getVisualOrder(FormattedText text) { -+ return sink -> text.visit( -+ (style, content) -> StringDecomposer.iterateFormatted(content, style, sink) ? Optional.empty() : FormattedText.STOP_ITERATION, -+ Style.EMPTY -+ ) -+ .isPresent(); -+ } -+ }; -+ } -+ -+ public static void parseTranslations(BiConsumer output, String languagePath) throws IOException { -+ try { -+ Path filePath = Paths.get(languagePath); -+ try (InputStream fileStream = Files.newInputStream(filePath)) { -+ Language.loadFromJson(fileStream, output); -+ } catch (java.nio.file.NoSuchFileException fileEx) { -+ logger.warning("Language file not found in both locations: " + languagePath); -+ throw fileEx; -+ } catch (Exception fileEx) { -+ logger.warning("Failed to load from filesystem " + filePath + "\n" + fileEx); -+ throw fileEx; -+ } -+ } catch (Exception ep) { -+ logger.warning("Couldn't read strings from " + languagePath + "\n" + ep); -+ throw ep; -+ } -+ } -+} diff --git a/lophine-server/paper-patches/files/src/main/java/me/earthme/lophine/utils/ServerI18nUtil.java.patch b/lophine-server/paper-patches/files/src/main/java/me/earthme/lophine/utils/ServerI18nUtil.java.patch new file mode 100644 index 0000000..6dd89fb --- /dev/null +++ b/lophine-server/paper-patches/files/src/main/java/me/earthme/lophine/utils/ServerI18nUtil.java.patch @@ -0,0 +1,254 @@ +--- /dev/null ++++ b/src/main/java/me/earthme/lophine/utils/ServerI18nUtil.java +@@ -1,0 +_,251 @@ ++package me.earthme.lophine.utils; ++ ++import com.google.gson.JsonElement; ++import com.google.gson.JsonObject; ++import com.google.gson.JsonParser; ++import com.google.gson.JsonSyntaxException; ++import me.earthme.lophine.config.modules.optimizations.LanguageConfig; ++import net.minecraft.locale.DeprecatedTranslationsInfo; ++import net.minecraft.locale.Language; ++import net.minecraft.network.chat.FormattedText; ++import net.minecraft.network.chat.Style; ++import net.minecraft.util.FormattedCharSequence; ++import net.minecraft.util.StringDecomposer; ++import org.apache.commons.io.FileUtils; ++import org.jetbrains.annotations.NotNull; ++ ++import java.io.IOException; ++import java.io.InputStream; ++import java.io.UnsupportedEncodingException; ++import java.net.URI; ++import java.net.http.HttpClient; ++import java.net.http.HttpRequest; ++import java.net.http.HttpResponse; ++import java.nio.file.*; ++import java.util.HashMap; ++import java.util.Map; ++import java.util.Objects; ++import java.util.Optional; ++import java.util.concurrent.CompletableFuture; ++import java.util.function.BiConsumer; ++import java.util.logging.Logger; ++ ++/* ++* authored by: Helvetica Volubi ++* modified by: Lumine1909 <133463833+Lumine1909@users.noreply.github.com> ++* Some of diff form Leaves ++*/ ++public class ServerI18nUtil { ++ ++ private static final Logger logger = Logger.getLogger("LangLoader"); ++ private static final String VERSION = "1.21.5"; ++ private static final String BASE_PATH = "cache/lophine/" + VERSION + "/"; ++ private static final String manifestUrl = "https://launchermeta.mojang.com/mc/game/version_manifest.json"; ++ private static final String resourceBaseUrl = "https://resources.download.minecraft.net/"; ++ ++ private static String langPath; ++ private static String assetsPath; ++ private static String versionPath; ++ private static String manifestPath; ++ private static String langJsonPath; ++ ++ public static void init() { ++ if (Objects.equals(LanguageConfig.lang, "en_us")) { ++ return; ++ } ++ langPath = BASE_PATH + "lang/" + LanguageConfig.lang + ".json"; ++ assetsPath = BASE_PATH + "assets.json"; ++ versionPath = BASE_PATH + VERSION + ".json"; ++ manifestPath = BASE_PATH + "manifest.json"; ++ langJsonPath = "minecraft/lang/" + LanguageConfig.lang + ".json"; ++ logger.info("Starting load language: " + LanguageConfig.lang); ++ CompletableFuture.runAsync(() -> loadI18n(LanguageConfig.lang, 2)); ++ } ++ ++ private static void loadI18n(String lang, int retryTime) { ++ try { ++ if (!Files.exists(Path.of(langPath))) { ++ downloadLang(true); ++ } ++ Language.inject(createLangInstance()); ++ logger.info("Successfully loaded language: " + lang); ++ } catch (Exception e) { ++ logger.warning("Failed to load language file for " + lang + "\n" + e); ++ if (retryTime > 0) { ++ cleanCache(); ++ loadI18n(lang, retryTime - 1); ++ } else { ++ logger.severe("Failed to load for many times, use default lang \"en_us\" instead"); ++ cleanCache(); ++ } ++ } ++ } ++ ++ private static void downloadLang(boolean fetchFromAssets) throws Exception { ++ JsonObject json; ++ if (!Files.exists(Path.of(assetsPath)) || (json = loadJson(assetsPath)) == null) { ++ if (fetchFromAssets) { ++ downloadAssets(true); ++ downloadLang(false); ++ } ++ return; ++ } ++ ++ JsonObject langEntry = json.getAsJsonObject("objects").getAsJsonObject(langJsonPath); ++ ++ String hash = langEntry.get("hash").getAsString(); ++ if (hash == null || hash.length() < 2) { ++ throw new IllegalArgumentException("Invalid hash value"); ++ } ++ ++ String langUrl = resourceBaseUrl + hash.substring(0, 2) + "/" + hash; ++ fetchAndSave(langUrl, langPath); ++ } ++ ++ private static void downloadAssets(boolean fetchFromVersion) throws Exception { ++ JsonObject json; ++ if (!Files.exists(Path.of(versionPath)) || (json = loadJson(versionPath)) == null) { ++ if (fetchFromVersion) { ++ downloadVersion(true); ++ downloadAssets(false); ++ } ++ return; ++ } ++ ++ JsonObject assetIndex = json.getAsJsonObject("assetIndex"); ++ String assetUrl = assetIndex.get("url").getAsString(); ++ fetchAndSave(assetUrl, assetsPath); ++ } ++ ++ private static void downloadVersion(boolean fetchFromManifest) throws Exception { ++ JsonObject json; ++ if (!Files.exists(Path.of(manifestPath)) || (json = loadJson(manifestPath)) == null) { ++ if (fetchFromManifest) { ++ fetchAndSave(manifestUrl, manifestPath); ++ downloadVersion(false); ++ } ++ return; ++ } ++ ++ String versionUrl = null; ++ for (JsonElement element : json.getAsJsonArray("versions")) { ++ String id = element.getAsJsonObject().get("id").getAsString(); ++ String url = element.getAsJsonObject().get("url").getAsString(); ++ if (VERSION.equals(id)) { ++ versionUrl = url; ++ break; ++ } ++ } ++ ++ if (versionUrl == null) { ++ throw new RuntimeException("Could not find version URL"); ++ } ++ ++ fetchAndSave(versionUrl, versionPath); ++ } ++ ++ private static String createHttpResponse(String path) throws IOException, InterruptedException { ++ try { ++ HttpResponse response; ++ try (HttpClient httpClient = HttpClient.newHttpClient()) { ++ ++ HttpRequest request = HttpRequest.newBuilder() ++ .uri(URI.create(path)) ++ .build(); ++ ++ response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); ++ } ++ ++ int responseCode = response.statusCode(); ++ if (responseCode != 200) { ++ logger.info("Unexpected response code: " + responseCode); ++ logger.info("Response body: " + response.body()); ++ throw new UnsupportedEncodingException("Unexpected response code"); ++ } else { ++ return response.body(); ++ } ++ } catch (Exception e) { ++ logger.warning("Error in getting info: " + e.getMessage()); ++ throw e; ++ } ++ } ++ ++ private static byte[] fetch(String urlString) throws IOException, InterruptedException { ++ String ret = createHttpResponse(urlString); ++ return ret.getBytes(); ++ } ++ ++ private static void fetchAndSave(String url, String savePath) throws IOException, InterruptedException { ++ byte[] data = fetch(url); ++ Path outputPath = Path.of(savePath); ++ Files.createDirectories(outputPath.getParent()); ++ Files.write(outputPath, data, StandardOpenOption.CREATE, StandardOpenOption.WRITE); ++ } ++ ++ private static void cleanCache() { ++ try { ++ FileUtils.deleteDirectory(Path.of(BASE_PATH).toFile()); ++ } catch (IOException e) { ++ logger.severe("Cache cleanup failed: " + e); ++ } ++ } ++ ++ private static JsonObject loadJson(String path) { ++ try { ++ byte[] data = Files.readAllBytes(Paths.get(path)); ++ return JsonParser.parseString(new String(data)).getAsJsonObject(); ++ } catch (JsonSyntaxException e) { ++ logger.warning("Corrupt json file: " + e); ++ throw e; ++ } catch (Exception e) { ++ logger.warning("Failed to load local JSON: " + e); ++ return null; ++ } ++ } ++ ++ private static Language createLangInstance() throws IOException { ++ DeprecatedTranslationsInfo deprecatedTranslationsInfo = DeprecatedTranslationsInfo.loadFromDefaultResource(); ++ Map map = new HashMap<>(); ++ parseTranslations(map::put); ++ deprecatedTranslationsInfo.applyToMap(map); ++ final Map map1 = Map.copyOf(map); ++ return new Language() { ++ @Override ++ public @NotNull String getOrDefault(@NotNull String key, @NotNull String defaultValue) { ++ return map1.getOrDefault(key, defaultValue); ++ } ++ ++ @Override ++ public boolean has(@NotNull String id) { ++ return map1.containsKey(id); ++ } ++ ++ @Override ++ public boolean isDefaultRightToLeft() { ++ return false; ++ } ++ ++ @Override ++ public @NotNull FormattedCharSequence getVisualOrder(@NotNull FormattedText text) { ++ return sink -> text.visit( ++ (style, content) -> StringDecomposer.iterateFormatted(content, style, sink) ? Optional.empty() : FormattedText.STOP_ITERATION, ++ Style.EMPTY ++ ) ++ .isPresent(); ++ } ++ }; ++ } ++ ++ private static void parseTranslations(BiConsumer output) throws IOException { ++ Path filePath = Path.of(langPath); ++ try (InputStream fileStream = Files.newInputStream(filePath)) { ++ Language.loadFromJson(fileStream, output); ++ } catch (NoSuchFileException noSuchFileException) { ++ logger.warning("Couldn't find language file: " + langPath); ++ throw noSuchFileException; ++ } catch (Exception e) { ++ logger.warning("Failed to load language from filesystem " + filePath + "\n" + e); ++ throw e; ++ } ++ } ++}