diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java index d9b1e9c54de..bd4dd3d52eb 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java @@ -114,31 +114,40 @@ private void generateOptionsTxt() { private static String normalizedLanguageTag(Locale locale, GameVersionNumber gameVersion) { String region = locale.getCountry(); + if ("en".equals(LocaleUtils.getRootLanguage(locale))) { + if ("Qabs".equals(LocaleUtils.getScript(locale)) && gameVersion.compareTo("1.16") >= 0) { + return "en_UD"; + } + + return ""; + } + + if ("zh".equals(LocaleUtils.getRootLanguage(locale))) { + if ("lzh".equals(locale.getLanguage())) { + return gameVersion.compareTo("1.16") >= 0 ? "lzh" : "zh_TW"; + } + + if ("Hant".equals(LocaleUtils.getScript(locale))) { + if (region.equals("HK") || region.equals("MO")) { + return gameVersion.compareTo("1.16") >= 0 ? "zh_HK" : "zh_TW"; + } + return "zh_TW"; + } + + return "zh_CN"; + } + + String languageTag = LocaleUtils.getMinecraftLanguageTag(locale); + if (languageTag != null && languageTag.contains("_")) { + return languageTag; + } + return switch (LocaleUtils.getRootLanguage(locale)) { case "ar" -> "ar_SA"; case "es" -> "es_ES"; case "ja" -> "ja_JP"; case "ru" -> "ru_RU"; case "uk" -> "uk_UA"; - case "zh" -> { - if ("lzh".equals(locale.getLanguage()) && gameVersion.compareTo("1.16") >= 0) - yield "lzh"; - - String script = LocaleUtils.getScript(locale); - if ("Hant".equals(script)) { - if ((region.equals("HK") || region.equals("MO") && gameVersion.compareTo("1.16") >= 0)) - yield "zh_HK"; - yield "zh_TW"; - } - yield "zh_CN"; - } - case "en" -> { - if ("Qabs".equals(LocaleUtils.getScript(locale)) && gameVersion.compareTo("1.16") >= 0) { - yield "en_UD"; - } - - yield ""; - } default -> ""; }; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ResourcePackListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ResourcePackListPage.java index 120c1f3f7b9..28b22816f87 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ResourcePackListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ResourcePackListPage.java @@ -58,6 +58,7 @@ import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.TaskCancellationAction; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.i18n.I18n; import org.jetbrains.annotations.Nullable; import java.io.IOException; @@ -109,7 +110,7 @@ protected Skin createDefaultSkin() { public void loadVersion(Profile profile, String version) { this.profile = profile; this.instanceId = version; - this.resourcePackManager = new ResourcePackManager(profile.getRepository(), version); + this.resourcePackManager = new ResourcePackManager(profile.getRepository(), version, I18n.getLocale().getLocale()); this.resourcePackDirectory = this.resourcePackManager.getDirectory(); refresh(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/PackMcMeta.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/PackMcMeta.java index fd8056f162a..786dcefbec0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/PackMcMeta.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/PackMcMeta.java @@ -42,6 +42,65 @@ public record PackMcMeta(@SerializedName("pack") PackInfo pack) implements Valid private static final Gson LENIENT_GSON = JsonUtils.defaultGsonBuilder().setStrictness(Strictness.LENIENT).create(); + private static List pairToPart(List> lists, String color) { + List parts = new ArrayList<>(); + for (Pair list : lists) { + parts.add(new LocalAddonFile.Description.Part(list.getKey(), list.getValue().isEmpty() ? color : list.getValue())); + } + return parts; + } + + private static void parseComponent(JsonElement element, List parts, String parentColor) throws JsonParseException { + if (parentColor == null) { + parentColor = ""; + } + String color = parentColor; + if (element instanceof JsonPrimitive primitive) { + parts.addAll(pairToPart(StringUtils.parseMinecraftColorCodes(primitive.getAsString()), color)); + } else if (element instanceof JsonObject jsonObj) { + if (jsonObj.get("color") instanceof JsonPrimitive primitive) { + color = primitive.getAsString(); + } + if (jsonObj.get("text") instanceof JsonPrimitive primitive) { + parts.addAll(pairToPart(StringUtils.parseMinecraftColorCodes(primitive.getAsString()), color)); + } + if (jsonObj.get("extra") instanceof JsonArray jsonArray) { + parseComponent(jsonArray, parts, color); + } + } else if (element instanceof JsonArray jsonArray) { + if (!jsonArray.isEmpty() && jsonArray.get(0) instanceof JsonObject jsonObj && jsonObj.get("color") instanceof JsonPrimitive primitive) { + color = primitive.getAsString(); + } + + for (JsonElement childElement : jsonArray) { + parseComponent(childElement, parts, color); + } + } else { + LOG.warning("Skipping unsupported element in description. Expected a string, object, or array, but got type " + element.getClass().getSimpleName() + ". Value: " + element); + } + } + + public static LocalAddonFile.Description parseDescription(JsonElement json) throws JsonParseException { + List parts = new ArrayList<>(); + + if (json == null || json.isJsonNull()) { + return new LocalAddonFile.Description(parts); + } + + try { + parseComponent(json, parts, ""); + } catch (JsonParseException | IllegalStateException e) { + parts.clear(); + LOG.warning("An unexpected error occurred while parsing a description component. The description may be incomplete.", e); + } + + return new LocalAddonFile.Description(parts); + } + + public static LocalAddonFile.Description parseDescription(String text) { + return parseDescription(new JsonPrimitive(text)); + } + public static PackMcMeta fromNonNullJson(String jsonString) throws JsonParseException { PackMcMeta parsed = LENIENT_GSON.fromJson(jsonString, PackMcMeta.class); if (parsed == null) @@ -58,6 +117,20 @@ public static PackMcMeta fromNonNullJsonFile(Path jsonFile) throws JsonParseExce } } + public PackMcMeta withDescription(LocalAddonFile.Description description) { + if (pack == null) { + return this; + } + + return new PackMcMeta(new PackInfo( + pack.packFormat(), + pack.supportedFormats(), + pack.minPackVersion(), + pack.maxPackVersion(), + description + )); + } + @Override public void validate() throws JsonParseException { if (pack == null) @@ -177,62 +250,6 @@ public static PackVersion fromJson(JsonElement element, boolean anyMinor) throws } public static final class PackInfoDeserializer implements JsonDeserializer { - - private List pairToPart(List> lists, String color) { - List parts = new ArrayList<>(); - for (Pair list : lists) { - parts.add(new LocalAddonFile.Description.Part(list.getKey(), list.getValue().isEmpty() ? color : list.getValue())); - } - return parts; - } - - private void parseComponent(JsonElement element, List parts, String parentColor) throws JsonParseException { - if (parentColor == null) { - parentColor = ""; - } - String color = parentColor; - if (element instanceof JsonPrimitive primitive) { - parts.addAll(pairToPart(StringUtils.parseMinecraftColorCodes(primitive.getAsString()), color)); - } else if (element instanceof JsonObject jsonObj) { - if (jsonObj.get("color") instanceof JsonPrimitive primitive) { - color = primitive.getAsString(); - } - if (jsonObj.get("text") instanceof JsonPrimitive primitive) { - parts.addAll(pairToPart(StringUtils.parseMinecraftColorCodes(primitive.getAsString()), color)); - } - if (jsonObj.get("extra") instanceof JsonArray jsonArray) { - parseComponent(jsonArray, parts, color); - } - } else if (element instanceof JsonArray jsonArray) { - if (!jsonArray.isEmpty() && jsonArray.get(0) instanceof JsonObject jsonObj && jsonObj.get("color") instanceof JsonPrimitive primitive) { - color = primitive.getAsString(); - } - - for (JsonElement childElement : jsonArray) { - parseComponent(childElement, parts, color); - } - } else { - LOG.warning("Skipping unsupported element in description. Expected a string, object, or array, but got type " + element.getClass().getSimpleName() + ". Value: " + element); - } - } - - private List parseDescription(JsonElement json) throws JsonParseException { - List parts = new ArrayList<>(); - - if (json == null || json.isJsonNull()) { - return parts; - } - - try { - parseComponent(json, parts, ""); - } catch (JsonParseException | IllegalStateException e) { - parts.clear(); - LOG.warning("An unexpected error occurred while parsing a description component. The description may be incomplete.", e); - } - - return parts; - } - @Override public PackInfo deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { JsonObject packInfo = json.getAsJsonObject(); @@ -246,8 +263,7 @@ public PackInfo deserialize(JsonElement json, Type typeOfT, JsonDeserializationC PackVersion minVersion = PackVersion.fromJson(packInfo.get("min_format"), false); PackVersion maxVersion = PackVersion.fromJson(packInfo.get("max_format"), true); - List parts = parseDescription(packInfo.get("description")); - return new PackInfo(packFormat, supportedFormats, minVersion, maxVersion, new LocalAddonFile.Description(parts)); + return new PackInfo(packFormat, supportedFormats, minVersion, maxVersion, PackMcMeta.parseDescription(packInfo.get("description"))); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcePackDescriptionResolver.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcePackDescriptionResolver.java new file mode 100644 index 00000000000..e3f784f8c3a --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcePackDescriptionResolver.java @@ -0,0 +1,143 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.resourcepack; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import kala.compress.archivers.zip.ZipArchiveEntry; +import org.jackhuang.hmcl.mod.LocalAddonFile; +import org.jackhuang.hmcl.mod.modinfo.PackMcMeta; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.i18n.MinecraftTranslatedTextResolver; +import org.jackhuang.hmcl.util.tree.ArchiveFileTree; +import org.jackhuang.hmcl.util.tree.ZipFileTree; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; + +final class ResourcePackDescriptionResolver { + private ResourcePackDescriptionResolver() { + } + + static @Nullable LocalAddonFile.Description resolveFromFolder(Path root, Locale locale) throws IOException { + Path mcmeta = root.resolve("pack.mcmeta"); + String mcmetaText = Files.readString(mcmeta); + return resolve(mcmetaText, locale, new FolderTranslationLookup(root)); + } + + static @Nullable LocalAddonFile.Description resolveFromZip(ZipFileTree tree, Locale locale) throws IOException { + String mcmetaText = tree.readTextEntry("/pack.mcmeta"); + return resolve(mcmetaText, locale, new ZipTranslationLookup(tree)); + } + + static @Nullable LocalAddonFile.Description resolve(String mcmetaText, Locale locale, MinecraftTranslatedTextResolver.TranslationLookup translationLookup) { + JsonObject json = JsonUtils.fromMaybeMalformedJson(mcmetaText, JsonObject.class); + if (json == null) { + return null; + } + + JsonObject pack = getJsonObject(json, "pack"); + if (pack == null) { + return null; + } + + JsonElement description = pack.get("description"); + if (description instanceof JsonObject descriptionObject && descriptionObject.has("translate")) { + String translated = MinecraftTranslatedTextResolver.resolve(descriptionObject, locale, translationLookup); + return translated != null ? PackMcMeta.parseDescription(translated) : null; + } + + return PackMcMeta.parseDescription(description); + } + + private static @Nullable JsonObject getJsonObject(JsonObject object, String memberName) { + JsonElement element = object.get(memberName); + return element instanceof JsonObject jsonObject ? jsonObject : null; + } + + private static final class FolderTranslationLookup implements MinecraftTranslatedTextResolver.TranslationLookup { + private final Path root; + + private FolderTranslationLookup(Path root) { + this.root = root; + } + + @Override + public @Unmodifiable List listNamespaces() throws IOException { + Path assets = root.resolve("assets"); + if (!Files.isDirectory(assets)) { + return List.of(); + } + + try (var stream = Files.list(assets)) { + return stream + .filter(Files::isDirectory) + .map(path -> path.getFileName().toString()) + .sorted(String.CASE_INSENSITIVE_ORDER) + .toList(); + } + } + + @Override + public @Nullable String findTranslation(String namespace, String languageFileName, String key) throws IOException { + Path langFile = root.resolve("assets").resolve(namespace).resolve("lang").resolve(languageFileName); + if (!Files.isRegularFile(langFile)) { + return null; + } + + Map translations = JsonUtils.fromJsonFile(langFile, JsonUtils.mapTypeOf(String.class, String.class)); + return translations != null ? translations.get(key) : null; + } + } + + private static final class ZipTranslationLookup implements MinecraftTranslatedTextResolver.TranslationLookup { + private final ZipFileTree tree; + + private ZipTranslationLookup(ZipFileTree tree) { + this.tree = tree; + } + + @Override + public @Unmodifiable List listNamespaces() { + ArchiveFileTree.Dir assets = tree.getDirectory("assets"); + if (assets == null) { + return List.of(); + } + + return assets.getSubDirs().keySet().stream() + .sorted(String.CASE_INSENSITIVE_ORDER) + .toList(); + } + + @Override + public @Nullable String findTranslation(String namespace, String languageFileName, String key) throws IOException { + String path = "assets/" + namespace + "/lang/" + languageFileName; + ZipArchiveEntry entry = tree.getEntry(path); + if (entry == null) { + return null; + } + + Map translations = JsonUtils.fromNonNullJson(tree.readTextEntry(entry), JsonUtils.mapTypeOf(String.class, String.class)); + return translations.get(key); + } + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcePackFolder.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcePackFolder.java index 7deeaeec2e1..c89aa92f7bd 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcePackFolder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcePackFolder.java @@ -40,7 +40,8 @@ public ResourcePackFolder(ResourcePackManager manager, Path path) { PackMcMeta meta = null; try { - meta = PackMcMeta.fromNonNullJsonFile(path.resolve("pack.mcmeta")); + meta = PackMcMeta.fromNonNullJsonFile(path.resolve("pack.mcmeta")) + .withDescription(ResourcePackDescriptionResolver.resolveFromFolder(path, manager.getLocale())); } catch (Exception e) { LOG.warning("Failed to parse resource pack meta", e); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcePackManager.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcePackManager.java index 7f1c9adad8c..82f1607bd5c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcePackManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcePackManager.java @@ -27,6 +27,7 @@ import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.CompressingUtils; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.i18n.LocaleUtils; import org.jackhuang.hmcl.util.tree.ZipFileTree; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jackhuang.hmcl.util.versioning.VersionRange; @@ -211,6 +212,7 @@ private static List deserializePackList(String json) { private final Path resourcePackDirectory; private final Path optionsFile; + private final Locale locale; private @Nullable GameVersionNumber minecraftVersion; private @Nullable PackMcMeta.PackVersion requiredVersion; @@ -219,9 +221,18 @@ private static List deserializePackList(String json) { private boolean loaded = false; public ResourcePackManager(GameRepository repository, String id) { + this(repository, id, LocaleUtils.SYSTEM_DEFAULT); + } + + public ResourcePackManager(GameRepository repository, String id, Locale locale) { super(repository, id); this.resourcePackDirectory = this.repository.getResourcePackDirectory(this.id); this.optionsFile = repository.getRunDirectory(id).resolve("options.txt"); + this.locale = locale; + } + + Locale getLocale() { + return locale; } @NotNull diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcePackZipFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcePackZipFile.java index 838702bf235..4f9f5fa4aa9 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcePackZipFile.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcePackZipFile.java @@ -47,7 +47,8 @@ public ResourcePackZipFile(ResourcePackManager manager, Path path) throws IOExce try (var zipFileTree = CompressingUtils.openZipTree(path)) { try { - metaTemp = PackMcMeta.fromNonNullJson(zipFileTree.readTextEntry("/pack.mcmeta")); + metaTemp = PackMcMeta.fromNonNullJson(zipFileTree.readTextEntry("/pack.mcmeta")) + .withDescription(ResourcePackDescriptionResolver.resolveFromZip(zipFileTree, manager.getLocale())); } catch (Exception e) { LOG.warning("Failed to parse resource pack meta", e); } @@ -104,4 +105,3 @@ public AddonUpdate checkUpdates(DownloadProvider downloadProvider, String gameVe return new AddonUpdate(this, currentVersion.get(), remoteVersions.get(0), false); } } - diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/i18n/LocaleUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/i18n/LocaleUtils.java index 7cf7ece168e..27cabb41626 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/i18n/LocaleUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/i18n/LocaleUtils.java @@ -304,6 +304,90 @@ private static void addCandidateLocales(ArrayList list, return null; } + public static @Nullable String getMinecraftLanguageTag(Locale locale) { + if (locale == null) return null; + + String root = getRootLanguage(locale); + String script = getScript(locale); + + if ("en".equals(root)) { + if ("Qabs".equals(script)) return "en_UD"; + return "en_US"; + } + + if ("zh".equals(root)) { + return getMinecraftChineseLanguageTag(locale); + } + + String region = locale.getCountry(); + return region.isEmpty() ? root : root + "_" + region; + } + + public static @NotNull List getMinecraftLanguageFileNames(Locale locale) { + ArrayList candidates = new ArrayList<>(); + for (Locale candidate : getCandidateLocales(locale)) { + String fileName = toMinecraftLanguageFileName(candidate); + if (fileName != null && !candidates.contains(fileName)) { + candidates.add(fileName); + } + } + + String minecraftLanguageTag = getMinecraftLanguageTag(locale); + if (minecraftLanguageTag != null) { + String minecraftFileName = minecraftLanguageTag.toLowerCase(Locale.ROOT) + ".json"; + if (!candidates.contains(minecraftFileName)) { + String genericFileName = getRootLanguage(locale).toLowerCase(Locale.ROOT) + ".json"; + int genericIndex = candidates.indexOf(genericFileName); + if (genericIndex >= 0) { + candidates.add(genericIndex, minecraftFileName); + } else { + candidates.add(minecraftFileName); + } + } + } + + if ("zh".equals(getRootLanguage(locale)) && "Hant".equals(getScript(locale))) { + moveBefore(candidates, "zh_tw.json", "zh.json"); + } + + return List.copyOf(candidates); + } + + private static void moveBefore(List candidates, String fileName, String beforeFileName) { + int fileIndex = candidates.indexOf(fileName); + int beforeIndex = candidates.indexOf(beforeFileName); + if (beforeIndex >= 0 && fileIndex > beforeIndex) { + candidates.remove(fileIndex); + candidates.add(beforeIndex, fileName); + } + } + + private static @Nullable String toMinecraftLanguageFileName(Locale locale) { + String language = locale.getLanguage(); + if (language.isEmpty()) { + return null; + } + + String normalizedLanguage = getRootLanguage(locale).toLowerCase(Locale.ROOT); + String region = locale.getCountry().toLowerCase(Locale.ROOT); + return (!region.isEmpty() ? normalizedLanguage + "_" + region : normalizedLanguage) + ".json"; + } + + private static @NotNull String getMinecraftChineseLanguageTag(Locale locale) { + if ("lzh".equals(locale.getLanguage())) { + return "lzh"; + } + + String region = locale.getCountry(); + String script = getScript(locale); + + if ("Hant".equals(script)) { + return ("HK".equals(region) || "MO".equals(region)) ? "zh_HK" : "zh_TW"; + } else { + return "zh_CN"; + } + } + /// Find all localized files in the given directory with the given base name and extension. /// The file name should be in the format of `baseName[_languageTag].ext`. /// diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/i18n/MinecraftTranslatedTextResolver.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/i18n/MinecraftTranslatedTextResolver.java new file mode 100644 index 00000000000..a7c555eed3c --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/i18n/MinecraftTranslatedTextResolver.java @@ -0,0 +1,86 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.util.i18n; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonPrimitive; +import org.jackhuang.hmcl.util.StringUtils; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; + +import java.io.IOException; +import java.util.List; +import java.util.Locale; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// Resolves Minecraft JSON text components that use a top-level `translate` key and optional `fallback`. +@NotNullByDefault +public final class MinecraftTranslatedTextResolver { + /// Prevents instantiation. + private MinecraftTranslatedTextResolver() { + } + + /// Resolves a Minecraft translation key component to a localized plain text value. + /// + /// If a translation key is present, language files are searched using Minecraft's locale fallback order. + /// If no translation is found, this method returns the component's non-blank `fallback` string when present. + public static @Nullable String resolve(JsonObject component, Locale locale, TranslationLookup translationLookup) { + String translate = getJsonString(component, "translate"); + if (StringUtils.isNotBlank(translate)) { + try { + List langFileNames = LocaleUtils.getMinecraftLanguageFileNames(locale); + List namespaces = translationLookup.listNamespaces(); + for (String langFileName : langFileNames) { + for (String namespace : namespaces) { + String translated = translationLookup.findTranslation(namespace, langFileName, translate); + if (translated != null) { + return translated; + } + } + } + } catch (IOException e) { + LOG.warning("Failed to resolve translated Minecraft text component", e); + } catch (JsonParseException e) { + LOG.warning("Failed to parse Minecraft language file", e); + } + } + + String fallback = getJsonString(component, "fallback"); + return StringUtils.isNotBlank(fallback) ? fallback : null; + } + + /// Returns a string member from a JSON object. + private static @Nullable String getJsonString(JsonObject object, String memberName) { + return object.get(memberName) instanceof JsonPrimitive primitive && primitive.isString() + ? primitive.getAsString() + : null; + } + + /// Looks up Minecraft language entries from a caller-provided language source. + public interface TranslationLookup { + + /// Lists namespaces that may contain Minecraft language files, in lookup precedence order. + @Unmodifiable List listNamespaces() throws IOException; + + /// Finds a translation value for the namespace, language file, and translation key. + @Nullable String findTranslation(String namespace, String languageFileName, String key) throws IOException, JsonParseException; + } +} diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/i18n/LocaleUtilsTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/i18n/LocaleUtilsTest.java index 6918ed2ab7e..f02d6d8201d 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/i18n/LocaleUtilsTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/i18n/LocaleUtilsTest.java @@ -254,4 +254,25 @@ public void testGetTextDirection() { assertEquals(TextDirection.RIGHT_TO_LEFT, LocaleUtils.getTextDirection(Locale.forLanguageTag("he"))); assertEquals(TextDirection.RIGHT_TO_LEFT, LocaleUtils.getTextDirection(Locale.forLanguageTag("heb"))); } + + @Test + public void testGetMinecraftLanguageTag() { + assertEquals("en_US", LocaleUtils.getMinecraftLanguageTag(Locale.ENGLISH)); + assertEquals("zh_CN", LocaleUtils.getMinecraftLanguageTag(Locale.SIMPLIFIED_CHINESE)); + assertEquals("zh_TW", LocaleUtils.getMinecraftLanguageTag(Locale.TRADITIONAL_CHINESE)); + assertEquals("ja", LocaleUtils.getMinecraftLanguageTag(Locale.JAPANESE)); + assertEquals("de_DE", LocaleUtils.getMinecraftLanguageTag(Locale.GERMANY)); + assertEquals("he_IL", LocaleUtils.getMinecraftLanguageTag(Locale.forLanguageTag("iw-IL"))); + assertEquals("zh_HK", LocaleUtils.getMinecraftLanguageTag(Locale.forLanguageTag("zh-HK"))); + assertEquals("lzh", LocaleUtils.getMinecraftLanguageTag(Locale.forLanguageTag("lzh"))); + assertEquals("de", LocaleUtils.getMinecraftLanguageTag(Locale.forLanguageTag("de"))); + } + + @Test + public void testGetMinecraftLanguageFileNames() { + assertEquals(List.of("en_us.json", "en.json"), LocaleUtils.getMinecraftLanguageFileNames(Locale.ENGLISH)); + assertEquals(List.of("en_gb.json", "en_us.json", "en.json"), LocaleUtils.getMinecraftLanguageFileNames(Locale.UK)); + assertEquals(List.of("zh_cn.json", "zh.json"), LocaleUtils.getMinecraftLanguageFileNames(Locale.SIMPLIFIED_CHINESE)); + assertEquals(List.of("zh_hk.json", "zh_tw.json", "zh.json", "zh_cn.json"), LocaleUtils.getMinecraftLanguageFileNames(Locale.forLanguageTag("zh-HK"))); + } }