增强本地化支持 (#4379)
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.util.i18n;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.ResourceBundle;
|
||||
|
||||
/// Overrides the default behavior of [ResourceBundle.Control], optimizing the candidate list generation logic.
|
||||
///
|
||||
/// Compared to the default implementation, [DefaultResourceBundleControl] optimizes the following scenarios:
|
||||
///
|
||||
/// - If no language is specified (such as [Locale#ROOT]), `en` is used instead.
|
||||
/// - For all Chinese locales, if no script is specified, the script (`Hans`/`Hant`/`Latn`) is always inferred based on region and variant.
|
||||
/// - For all Chinese locales, `zh-CN` is always added to the candidate list. If `zh-Hans` already exists in the candidate list,
|
||||
/// `zh-CN` is inserted before `zh`; otherwise, it is inserted after `zh`.
|
||||
/// - For all Traditional Chinese locales, `zh-TW` is always added to the candidate list (before `zh`).
|
||||
/// - For all Chinese variants (such as `lzh`, `cmn`, `yue`, etc.), a candidate list with the language code replaced by `zh`
|
||||
/// is added to the end of the candidate list.
|
||||
///
|
||||
/// @author Glavo
|
||||
public class DefaultResourceBundleControl extends ResourceBundle.Control {
|
||||
|
||||
public static final DefaultResourceBundleControl INSTANCE = new DefaultResourceBundleControl();
|
||||
|
||||
public DefaultResourceBundleControl() {
|
||||
}
|
||||
|
||||
private static List<Locale> ensureEditable(List<Locale> list) {
|
||||
return list instanceof ArrayList<?>
|
||||
? list
|
||||
: new ArrayList<>(list);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Locale> getCandidateLocales(String baseName, Locale locale) {
|
||||
if (locale.getLanguage().isEmpty())
|
||||
return getCandidateLocales(baseName, Locale.ENGLISH);
|
||||
|
||||
if (LocaleUtils.isChinese(locale)) {
|
||||
String language = locale.getLanguage();
|
||||
String script = locale.getScript();
|
||||
|
||||
if (script.isEmpty()) {
|
||||
script = LocaleUtils.getScript(locale);
|
||||
locale = new Locale.Builder()
|
||||
.setLocale(locale)
|
||||
.setScript(script)
|
||||
.build();
|
||||
}
|
||||
|
||||
List<Locale> locales = super.getCandidateLocales("", locale);
|
||||
|
||||
if (language.equals("zh")) {
|
||||
if (locales.contains(LocaleUtils.LOCALE_ZH_HANT) && !locales.contains(Locale.TRADITIONAL_CHINESE)) {
|
||||
locales = ensureEditable(locales);
|
||||
int chineseIdx = locales.indexOf(Locale.CHINESE);
|
||||
if (chineseIdx >= 0)
|
||||
locales.add(chineseIdx, Locale.TRADITIONAL_CHINESE);
|
||||
}
|
||||
|
||||
if (!locales.contains(Locale.SIMPLIFIED_CHINESE)) {
|
||||
int chineseIdx = locales.indexOf(Locale.CHINESE);
|
||||
|
||||
if (chineseIdx >= 0) {
|
||||
locales = ensureEditable(locales);
|
||||
if (locales.contains(LocaleUtils.LOCALE_ZH_HANS))
|
||||
locales.add(chineseIdx, Locale.SIMPLIFIED_CHINESE);
|
||||
else
|
||||
locales.add(chineseIdx + 1, Locale.SIMPLIFIED_CHINESE);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
locales = ensureEditable(locales);
|
||||
locales.removeIf(it -> !it.getLanguage().equals(language));
|
||||
|
||||
locales.addAll(getCandidateLocales("", new Locale.Builder()
|
||||
.setLocale(locale)
|
||||
.setLanguage("zh")
|
||||
.build()));
|
||||
}
|
||||
|
||||
return locales;
|
||||
}
|
||||
|
||||
return super.getCandidateLocales(baseName, locale);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.util.i18n;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* @author Glavo
|
||||
*/
|
||||
public class LocaleUtils {
|
||||
|
||||
public static final Locale SYSTEM_DEFAULT = Locale.getDefault();
|
||||
|
||||
public static final Locale LOCALE_ZH_HANS = Locale.forLanguageTag("zh-Hans");
|
||||
public static final Locale LOCALE_ZH_HANT = Locale.forLanguageTag("zh-Hant");
|
||||
|
||||
public static String toLanguageKey(Locale locale) {
|
||||
if (locale.getLanguage().isEmpty())
|
||||
return "default";
|
||||
else
|
||||
return locale.toLanguageTag();
|
||||
}
|
||||
|
||||
public static @NotNull List<Locale> getCandidateLocales(Locale locale) {
|
||||
return DefaultResourceBundleControl.INSTANCE.getCandidateLocales("", locale);
|
||||
}
|
||||
|
||||
public static String getScript(Locale locale) {
|
||||
if (locale.getScript().isEmpty()) {
|
||||
if (isChinese(locale)) {
|
||||
if (CHINESE_LATN_VARIANTS.contains(locale.getVariant()))
|
||||
return "Latn";
|
||||
if (locale.getLanguage().equals("lzh") || CHINESE_TRADITIONAL_REGIONS.contains(locale.getCountry()))
|
||||
return "Hant";
|
||||
else
|
||||
return "Hans";
|
||||
}
|
||||
}
|
||||
|
||||
return locale.getScript();
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
public static boolean isEnglish(Locale locale) {
|
||||
return locale.getLanguage().equals("en") || locale.getLanguage().isEmpty();
|
||||
}
|
||||
|
||||
public static final Set<String> CHINESE_TRADITIONAL_REGIONS = Set.of("TW", "HK", "MO");
|
||||
public static final Set<String> CHINESE_LATN_VARIANTS = Set.of("pinyin", "wadegile", "tongyong");
|
||||
public static final Set<String> CHINESE_LANGUAGES = Set.of(
|
||||
"zh",
|
||||
"zho", "cmn", "lzh", "cdo", "cjy", "cpx", "czh",
|
||||
"gan", "hak", "hsn", "mnp", "nan", "wuu", "yue"
|
||||
);
|
||||
|
||||
public static boolean isChinese(Locale locale) {
|
||||
return CHINESE_LANGUAGES.contains(locale.getLanguage());
|
||||
}
|
||||
|
||||
private LocaleUtils() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.util.i18n;
|
||||
|
||||
import com.google.gson.JsonSyntaxException;
|
||||
import com.google.gson.TypeAdapter;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.stream.JsonReader;
|
||||
import com.google.gson.stream.JsonToken;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.*;
|
||||
|
||||
@JsonAdapter(LocalizedText.Adapter.class)
|
||||
public final class LocalizedText {
|
||||
private final @Nullable String value;
|
||||
private final @Nullable Map<String, String> localizedValues;
|
||||
|
||||
public LocalizedText(String value) {
|
||||
this.value = value;
|
||||
this.localizedValues = null;
|
||||
}
|
||||
|
||||
public LocalizedText(@NotNull Map<String, String> localizedValues) {
|
||||
this.value = null;
|
||||
this.localizedValues = Objects.requireNonNull(localizedValues);
|
||||
}
|
||||
|
||||
public String getText(@NotNull List<Locale> candidates) {
|
||||
if (localizedValues != null) {
|
||||
for (Locale locale : candidates) {
|
||||
String value = localizedValues.get(LocaleUtils.toLanguageKey(locale));
|
||||
if (value != null)
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
} else
|
||||
return value;
|
||||
}
|
||||
|
||||
static final class Adapter extends TypeAdapter<LocalizedText> {
|
||||
|
||||
@Override
|
||||
public LocalizedText read(JsonReader jsonReader) throws IOException {
|
||||
JsonToken nextToken = jsonReader.peek();
|
||||
if (nextToken == JsonToken.NULL) {
|
||||
return null;
|
||||
} else if (nextToken == JsonToken.STRING) {
|
||||
return new LocalizedText(jsonReader.nextString());
|
||||
} else if (nextToken == JsonToken.BEGIN_OBJECT) {
|
||||
LinkedHashMap<String, String> localizedValues = new LinkedHashMap<>();
|
||||
|
||||
jsonReader.beginObject();
|
||||
while (jsonReader.hasNext()) {
|
||||
String name = jsonReader.nextName();
|
||||
String value = jsonReader.nextString();
|
||||
|
||||
localizedValues.put(name, value);
|
||||
}
|
||||
jsonReader.endObject();
|
||||
|
||||
return new LocalizedText(localizedValues);
|
||||
} else {
|
||||
throw new JsonSyntaxException("Unexpected token " + nextToken);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(JsonWriter jsonWriter, LocalizedText localizedText) throws IOException {
|
||||
if (localizedText == null) {
|
||||
jsonWriter.nullValue();
|
||||
} else if (localizedText.localizedValues != null) {
|
||||
|
||||
jsonWriter.beginObject();
|
||||
for (var entry : localizedText.localizedValues.entrySet()) {
|
||||
jsonWriter.name(entry.getKey());
|
||||
jsonWriter.value(entry.getValue());
|
||||
}
|
||||
jsonWriter.endObject();
|
||||
} else {
|
||||
jsonWriter.value(localizedText.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.util.i18n;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* @author Glavo
|
||||
*/
|
||||
public final class LocaleUtilsTest {
|
||||
private static void assertCandidateLocales(String languageTag, List<String> candidateLocales) {
|
||||
assertEquals(candidateLocales,
|
||||
LocaleUtils.getCandidateLocales(Locale.forLanguageTag(languageTag))
|
||||
.stream()
|
||||
.map(Locale::toLanguageTag)
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetCandidateLocales() {
|
||||
assertCandidateLocales("zh", List.of("zh-Hans", "zh-CN", "zh", "und"));
|
||||
assertCandidateLocales("zh-CN", List.of("zh-Hans-CN", "zh-Hans", "zh-CN", "zh", "und"));
|
||||
assertCandidateLocales("zh-SG", List.of("zh-Hans-SG", "zh-Hans", "zh-SG", "zh-CN", "zh", "und"));
|
||||
assertCandidateLocales("zh-MY", List.of("zh-Hans-MY", "zh-Hans", "zh-MY", "zh-CN", "zh", "und"));
|
||||
assertCandidateLocales("zh-US", List.of("zh-Hans-US", "zh-Hans", "zh-US", "zh-CN", "zh", "und"));
|
||||
assertCandidateLocales("zh-TW", List.of("zh-Hant-TW", "zh-Hant", "zh-TW", "zh", "zh-CN", "und"));
|
||||
assertCandidateLocales("zh-HK", List.of("zh-Hant-HK", "zh-Hant", "zh-HK", "zh-TW", "zh", "zh-CN", "und"));
|
||||
assertCandidateLocales("zh-MO", List.of("zh-Hant-MO", "zh-Hant", "zh-MO", "zh-TW", "zh", "zh-CN", "und"));
|
||||
assertCandidateLocales("zh-Hans", List.of("zh-Hans", "zh-CN", "zh", "und"));
|
||||
assertCandidateLocales("zh-Hant", List.of("zh-Hant", "zh-TW", "zh", "zh-CN", "und"));
|
||||
assertCandidateLocales("zh-Hans-US", List.of("zh-Hans-US", "zh-Hans", "zh-US", "zh-CN", "zh", "und"));
|
||||
assertCandidateLocales("zh-Hant-CN", List.of("zh-Hant-CN", "zh-Hant", "zh-CN", "zh-TW", "zh", "und"));
|
||||
assertCandidateLocales("zh-Hans-TW", List.of("zh-Hans-TW", "zh-Hans", "zh-TW", "zh-CN", "zh", "und"));
|
||||
assertCandidateLocales("zh-Latn", List.of("zh-Latn", "zh", "zh-CN", "und"));
|
||||
assertCandidateLocales("zh-Latn-CN", List.of("zh-Latn-CN", "zh-Latn", "zh-CN", "zh", "und"));
|
||||
assertCandidateLocales("zh-pinyin", List.of("zh-Latn-pinyin", "zh-Latn", "zh-pinyin", "zh", "zh-CN", "und"));
|
||||
assertCandidateLocales("lzh", List.of("lzh-Hant", "lzh", "zh-Hant", "zh-TW", "zh", "zh-CN", "und"));
|
||||
assertCandidateLocales("lzh-Hant", List.of("lzh-Hant", "lzh", "zh-Hant", "zh-TW", "zh", "zh-CN", "und"));
|
||||
assertCandidateLocales("lzh-Hans", List.of("lzh-Hans", "lzh", "zh-Hans", "zh-CN", "zh", "und"));
|
||||
assertCandidateLocales("cmn", List.of("cmn-Hans", "cmn", "zh-Hans", "zh-CN", "zh", "und"));
|
||||
assertCandidateLocales("cmn-Hans", List.of("cmn-Hans", "cmn", "zh-Hans", "zh-CN", "zh", "und"));
|
||||
assertCandidateLocales("yue", List.of("yue-Hans", "yue", "zh-Hans", "zh-CN", "zh", "und"));
|
||||
|
||||
assertCandidateLocales("ja", List.of("ja", "und"));
|
||||
assertCandidateLocales("ja-JP", List.of("ja-JP", "ja", "und"));
|
||||
|
||||
assertCandidateLocales("en", List.of("en", "und"));
|
||||
assertCandidateLocales("und", List.of("en", "und"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testIsChinese() {
|
||||
assertTrue(LocaleUtils.isChinese(Locale.CHINESE));
|
||||
assertTrue(LocaleUtils.isChinese(Locale.SIMPLIFIED_CHINESE));
|
||||
assertTrue(LocaleUtils.isChinese(Locale.TRADITIONAL_CHINESE));
|
||||
assertTrue(LocaleUtils.isChinese(LocaleUtils.LOCALE_ZH_HANS));
|
||||
assertTrue(LocaleUtils.isChinese(LocaleUtils.LOCALE_ZH_HANT));
|
||||
assertTrue(LocaleUtils.isChinese(Locale.forLanguageTag("lzh")));
|
||||
assertTrue(LocaleUtils.isChinese(Locale.forLanguageTag("cmn")));
|
||||
assertTrue(LocaleUtils.isChinese(Locale.forLanguageTag("cmn-Hans")));
|
||||
assertTrue(LocaleUtils.isChinese(Locale.forLanguageTag("yue")));
|
||||
|
||||
assertFalse(LocaleUtils.isChinese(Locale.ROOT));
|
||||
assertFalse(LocaleUtils.isChinese(Locale.ENGLISH));
|
||||
assertFalse(LocaleUtils.isChinese(Locale.JAPANESE));
|
||||
assertFalse(LocaleUtils.isChinese(Locale.forLanguageTag("es")));
|
||||
assertFalse(LocaleUtils.isChinese(Locale.forLanguageTag("ru")));
|
||||
assertFalse(LocaleUtils.isChinese(Locale.forLanguageTag("uk")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetScript() {
|
||||
assertEquals("Hans", LocaleUtils.getScript(Locale.CHINESE));
|
||||
assertEquals("Hans", LocaleUtils.getScript(Locale.forLanguageTag("zh")));
|
||||
assertEquals("Hans", LocaleUtils.getScript(Locale.forLanguageTag("zh-Hans")));
|
||||
assertEquals("Hans", LocaleUtils.getScript(Locale.forLanguageTag("zh-Hans-US")));
|
||||
assertEquals("Hans", LocaleUtils.getScript(Locale.forLanguageTag("zh-SG")));
|
||||
assertEquals("Hans", LocaleUtils.getScript(Locale.forLanguageTag("zh-MY")));
|
||||
assertEquals("Hans", LocaleUtils.getScript(Locale.forLanguageTag("cmn")));
|
||||
assertEquals("Hans", LocaleUtils.getScript(Locale.forLanguageTag("cmn-Hans")));
|
||||
assertEquals("Hans", LocaleUtils.getScript(Locale.forLanguageTag("cmn-CN")));
|
||||
assertEquals("Hans", LocaleUtils.getScript(Locale.forLanguageTag("lzh-Hans")));
|
||||
|
||||
assertEquals("Hant", LocaleUtils.getScript(Locale.forLanguageTag("zh-Hant")));
|
||||
assertEquals("Hant", LocaleUtils.getScript(Locale.forLanguageTag("zh-TW")));
|
||||
assertEquals("Hant", LocaleUtils.getScript(Locale.forLanguageTag("zh-HK")));
|
||||
assertEquals("Hant", LocaleUtils.getScript(Locale.forLanguageTag("zh-MO")));
|
||||
assertEquals("Hant", LocaleUtils.getScript(Locale.forLanguageTag("cmn-Hant")));
|
||||
assertEquals("Hant", LocaleUtils.getScript(Locale.forLanguageTag("lzh")));
|
||||
assertEquals("Hant", LocaleUtils.getScript(Locale.forLanguageTag("lzh-Hant")));
|
||||
assertEquals("Hant", LocaleUtils.getScript(Locale.forLanguageTag("lzh-CN")));
|
||||
|
||||
assertEquals("Latn", LocaleUtils.getScript(Locale.forLanguageTag("zh-pinyin")));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user